메뉴
HN
Hacker News 17일 전

완벽한 구조체 분해(destructuring)를 찬양하는 이유

IMP
7/10
핵심 요약

Rust 개발자가 구조체 필드를 분해할 때 일부 생략 기호(..)를 배제하고 모든 필드를 명시적으로 나열해야 하는 이유를 설명합니다. 새로운 필드가 추가될 때 컴파일러가 누락을 감지하게 하여 소프트웨어 유지보수성과 안전성을 크게 높일 수 있기 때문입니다.

번역된 본문

내가 Rust를 배울 때 가장 답답했던 점 중 하나는 구조체(struct)를 분해(destructuring)할 때 모든 필드를 나열하거나 .. 문법을 사용해야 한다는 것이었다. 이를 설명하기 위해, 마침 이 글을 폭염 속에서 쓰고 있으니 다음과 같은 구조체가 있다고 가정해 보자:

struct WeatherReading { station_id: String, recorded_at: DateTime, temperature: f64, humidity: f64, pressure: f64, }

(참고: 간결함을 위해 생략했지만, recorded_at을 제외한 모든 필드는 뉴타입(newtype)이어야 한다.)

이제 station_idrecorded_at만 가져오고 싶다면, 왜 다음과 같이 작성해야만 할까?

let WeatherReading { station_id, recorded_at, .. } = weather_reading;

TypeScript라면 다음과 같이 간단히 쓸 수 있다:

const { station_id, recorded_at } = weather_reading;

Haskell에서도 마찬가지다:

-- NamedFieldPuns 확장을 사용할 경우 let WeatherReading { station_id, recorded_at, } = weather_reading

-- 또는 RecordWildCards를 사용할 경우 let WeatherReading { .. } = weather_reading

그렇게 큰 불편함은 아니었지만, 이러한 이유로 나는 구조체 분해 대신 weather_reading.station_id / weather_reading.recorded_at 처럼 점(.) 문법으로 필드에 접근하는 방식을 선호했다.

하지만 지난 몇 달 동안, 나는 소프트웨어 유지보수를 더 안전하게 만들어준다는 이유로 점(.) 문법을 통한 필드 접근보다 구조체 분해를 강하게 선호하게 되었다.

예를 들어, 위험한 날씨를 감지하는 다음 함수를 보자:

fn is_dangerous(weather_reading: &WeatherReading) -> bool { if weather_reading.temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if weather_reading.humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if weather_reading.pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; }

이 함수는 꽤 위험하다! 그 이유를 알기 위해, 이제 일부 관측소에 풍속계가 설치되어 풍속을 기록할 수 있다고 가정해 보자. 그래서 WeatherReading 구조체에 새로운 wind_speed 필드를 추가했다:

struct WeatherReading { station_id: String, recorded_at: DateTime, temperature: f64, humidity: f64, pressure: f64, wind_speed: Option, // 마찬가지로 이것은 뉴타입(newtype)이어야 함 }

다 됐다! 배포해버리자! 아차. is_dangerous 함수를 업데이트하는 것을 잊지 않았는가? 분명 강풍 역시 위험한 상황으로 보고되어야 할 것이다. 하지만 안타깝게도 Rust 컴파일러는 우리에게 이 사실을 경고해주지 않는다.

다행히도, 만약 함수를 다음과 같이 작성했다면 우리는 경고를 받을 수 있었을 것이다:

fn is_dangerous( WeatherReading { station_id: _, recorded_at: _, temperature, humidity, pressure, }: &WeatherReading ) -> bool { if *temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if *humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if *pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; }

이렇게 작성했다면 다음과 같은 컴파일 에러를 마주했을 것이다: error[E0027]: pattern does not mention field 'wind_speed' (패턴이 'wind_speed' 필드를 언급하지 않음).

이 기법이 작동하려면 사용하지 않는 필드조차 명시적으로 작성해야 하며, 절대 .. 패턴을 사용하고 싶은 유혹에 넘어가서는 안 된다는 점을 기억하라.

Rust에서는 self를 인수 목록에서 바로 분해할 수 없기 때문에, 만약 is_dangerous가 메서드로 작성되었다면 약간의 추가 작업이 필요하다:

impl WeatherReading { fn is_dangerous(&self) -> bool { let WeatherReading { station_id: _, recorded_at: _, temperature, humidity, pressure, } = self; if *temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if *humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if *pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; } }

나는 CRUD 웹 서비스의 여러 계층(데이터 액세스, 비즈니스 로직, API) 간에 From 트레이트를 구현할 때 이 기법이 특히 유용하다는 것을 알게 되었다. 한 계층에 필드를 추가하면 컴파일러가 강제로 그 필드가 다른 계층으로 전파되어야 하는지 여부를 결정하게 만들기 때문이다.

또 다른 장점은, 만약 항상 특정 필드 그룹만 함께 분해되고 나머지는 무시되는 것을 본다면, 이는 그 필드들을 별도의 구조체로 추출해야 한다는 좋은 신호가 될 수 있다는 것이다.

TypeScript를 사용 중이라면 Required 타입을 활용한 트릭이 있다. Haskell의 경우, 놀랍게도 현재는 해결책이 없지만 관련 제안(proposal)은 존재한다.

원문 보기
원문 보기 (영어)
When I learned Rust, I was frustrated by the fact that one had to list all fields or use the .. syntax when destructuring a struct. To illustrate this and because I am writing this during a heatwave, let's suppose we have the following struct: struct WeatherReading { station_id: String, recorded_at: DateTime<Utc>, temperature: f64 , humidity: f64 , pressure: f64 , } Note : Except for recorded_at , all fields should be newtypes . Not done here for brevity. Now I just want to retrieve station_id and recorded_at , why should I need to write: let WeatherReading { station_id, recorded_at, .. } = weather_reading; In TypeScript, I could just do: const { station_id, recorded_at } = weather_reading; And in Haskell: -- With NamedFieldPuns extension let WeatherReading { station_id, recorded_at, } = weather_reading -- Or with RecordWildCards let WeatherReading { .. } = weather_reading Not that much of a hassle, but that made me favor the weather_reading.station_id / weather_reading.recorded_at syntax. However, in the past few months, I have started to greatly favor struct destructuring over accessing fields with the . syntax because that makes maintaining the software safer. For example, we want to detect dangerous weather: fn is_dangerous(weather_reading: & WeatherReading) -> bool { if weather_reading.temperature > 40.0 { // assuming °C, that's 104°F or 313.15K return true ; } if weather_reading.humidity < 5.0 { // assuming percentage return true ; } if weather_reading.pressure < 960.0 { // assuming hPa, that's 28.35 inHg return true ; } return false ; } That function is dangerous! To see why, let's suppose some stations now have an anemometer and are able to record wind speed. So, we add a new wind_speed field to the WeatherReading struct: struct WeatherReading { station_id: String, recorded_at: DateTime<Utc>, temperature: f64 , humidity: f64 , pressure: f64 , wind_speed: Option< f64 >, // Again, this should be a newtype } All good! Let's ship! Uh oh. Did you remember to update the is_dangerous function? Surely heavy wind should be reported as dangerous. Unfortunately, the Rust compiler did not warn us about it. The good news is that we could have been warned if we wrote the function like this: fn is_dangerous( WeatherReading { station_id: _ , recorded_at: _ , temperature, humidity, pressure, }: & WeatherReading ) -> bool { if *temperature > 40.0 { // assuming °C, that's 104°F or 313.15K return true ; } if *humidity < 5.0 { // assuming percentage return true ; } if *pressure < 960.0 { // assuming hPa, that's 28.35 inHg return true ; } return false ; } We would now get error[E0027]: pattern does not mention field 'wind_speed' . Note that you must be explicit about unused fields and resist the temptation of using the .. pattern for this trick to work. Because self can't be destructured from the arguments list in Rust, if is_dangerous was written as a method, a bit of ceremony is necessary: impl WeatherReading { fn is_dangerous( & self) -> bool { let WeatherReading { station_id: _ , recorded_at: _ , temperature, humidity, pressure, } = self; if *temperature > 40.0 { // assuming °C, that's 104°F or 313.15K return true ; } if *humidity < 5.0 { // assuming percentage return true ; } if *pressure < 960.0 { // assuming hPa, that's 28.35 inHg return true ; } return false ; } } I find this trick especially useful when writing From implementations between the different layers (data access, business logic, API) of a CRUD web service. If you add a field in one layer, the compiler will force you to decide whether that field should be propagated to the other layers or not. Another advantage is that if you see the same bunch of fields always destructured together while the others are ignored, that can be a good indication that maybe those fields should be extracted into their own struct. If you are using TypeScript, there is a trick using the Required type . For Haskell, there is surprisingly no solution at the moment, though there is a proposal .