구조체를 사용한 예제 프로그램
사각형 넓이 계산 코드를 개별 변수에서 튜플과 구조체로 리팩터링해 데이터 의미와 Debug 출력을 개선합니다.
어떨 때 구조체를 사용하면 좋을지 이해해 보기 위해 사각형 넓이를 계산하는 프로그램을 작성해봅시다.
단일 변수로만 구성된 프로그램으로 시작해서 구조체를 사용하기까지 리팩터링하면서 말이죠.
Cargo를 사용해 rectangles라는 새로운 바이너리 프로젝트를 만들어 줍시다.
이 프로그램은 픽셀 단위로 지정된 너비와 높이로 사각형의 넓이를 계산할 겁니다.
예제 4-8은 src/main.rs에 이 기능을 간단하게 구현한 모습입니다.
예제 4-8: 각 변수에 지정된 너비와 높이로 사각형 넓이 계산하기fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
cargo run으로 실행해 보죠.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.
위 코드는 각 치수 값으로 area 함수를 호출하여 사각형의 면적을 성공적으로
계산합니다만, 몇 가지 작업을 더하여 코드를 더 명료하고 읽기 쉽게 만들 수
있습니다.
area 함수의 시그니처를 보면 개선해야 할 점이 여실히 드러납니다.
fn area(width: u32, height: u32) -> u32 {
area 함수는 하나의 사각형의 면적을 계산하는 것을 가정하고 있지만 두 개의 매개변수를 받고 있으며, 이 두 값이 서로 연관되어 있다는 것을 명확하게 표현하는 부분은 찾아볼 수 없군요.
두 값을 하나로 묶어버리면 코드의 가독성도 높아지고 관리하기도 쉬워질 겁니다.
2장 ‘튜플 타입’절에서 배운 튜플로 해결해 보겠습니다.
튜플로 리팩터링하기
다음 예제 4-9는 튜플을 사용한 모습입니다.
예제 4-9: 사각형의 너비와 높이를 튜플로 명시하는 코드fn main() {
let rect1 = (30, 50);
println!(
"The area of the rectangle is {} square pixels.",
area(rect1)
);
}
fn area(dimensions: (u32, u32)) -> u32 {
dimensions.0 * dimensions.1
}
튜플을 사용함으로써 더 짜임새 있는 코드가 됐고, 인수도 단 하나만 넘기면 된다는 점에서는 프로그램이 발전했다고 볼 수 있습니다.
하지만 각 요소에 이름이 없는 튜플의 특성 때문에 값을 인덱스로 접근해야 해서 계산식이 불명확해졌네요.
다행히 넓이를 계산할 땐 어떤 값이 너비이고 어떤 값이 높이인지 구분하지 못해도 별 문제가 없습니다.
하지만 만들어야 할 프로그램이 화면에 사각형을 그리는 프로그램이라고 가정해 보면 어떨까요?
너비 값인 width가 튜플 인덱스 0에 위치하고 높이 값인 height는 튜플 인덱스 1에 위치한다는 걸 꼭 기억하고 있어야 할 겁니다.
혹여나 다른 사람이 이 코드로 작업할 일이 생기면 그 사람도 이 점을 알아내서 기억해야 하겠죠.
코드 내에 데이터의 의미를 전달하지 못하므로 에러가 발생하기 더 쉬워집니다.
구조체로 리팩터링하여 코드에 더 많은 의미를 담기
DATA MODEL · CALL CONTRACT
값을 한 인수로 묶는 것만으로는 부족합니다. 필드 이름은 도메인 의미를 타입에 남기고, &Rectangle은 계산 뒤에도 호출자가 인스턴스를 소유하게 합니다.
| # | 함수 호출과 매개변수 | 의미를 읽는 단서 | 이 예제의 소유권 결과 |
|---|---|---|---|
| 1 | 개별 값
매개변수 둘은 |
매개변수 이름은 보이지만 두 값이 하나의 사각형이라는 관계는 타입 밖의 관례입니다. | 두 u32 값은 Copy이므로 호출할 때 복사되고 원본 바인딩도 유효합니다. |
| 2 | 튜플
|
관련 값은 하나로 묶이지만 .0과 .1의 의미를 순서로 기억해야 합니다. |
여기서는 튜플의 모든 필드가 Copy라 값 전달 뒤에도 rect1을 쓸 수 있습니다. |
| 3 | Rectangle
|
width와 height가 타입의 이름 있는 필드라 순서 없이 목적을 드러냅니다. |
함수는 인스턴스를 공유 대여합니다. 점 표기법이 참조를 따라가며, 이 예제의 u32 필드 읽기는 값을 복사합니다. |
- 1 · 개별
u32 - 호출
area(width1, height1) - 의미 두 매개변수의 관계는 관례에 남습니다.
- 소유권
u32: Copy라 두 값이 복사됩니다. - 2 · 위치 기반 튜플
- 호출
area(rect1) - 의미 한 값으로 묶지만
.0과.1을 기억해야 합니다. - 소유권
(u32, u32): Copy라 이 예제에서는 튜플이 복사됩니다. - 3 · 이름 있는
Rectangle - 호출
area(&rect1) - 의미
width와height가 타입 안에서 목적을 설명합니다. - 소유권 함수는 공유 대여하고 호출자가 계속 소유합니다.
- 필드 읽기의 정확한 경계
- 점 표기법은
&Rectangle을 따라 필드에 접근합니다. 여기서는u32: Copy라 값이 복사됩니다. Copy가 아닌 필드는 공유 참조 밖으로 이동할 수 없으며 참조로 읽어야 합니다.
area 자유 함수이며 매개변수는 &Rectangle입니다. impl Rectangle의 메서드로 옮기는 단계는 다음 절에서 다룹니다.구조체는 데이터에 이름표를 붙여서 의미를 나타낼 수 있습니다.
예제 4-10처럼, 기존에 사용하던 튜플을 구조체로 바꿔 각 구성 요소에 이름을 지어줍시다.
예제 4-10:Rectangle 구조체 정의
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!(
"The area of the rectangle is {} square pixels.",
area(&rect1)
);
}
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
Rectangle이라는 구조체를 정의하고, 중괄호 안에 width, height 필드를 u32 타입으로 정의했습니다.
이후 main에서는 너비 30, 높이 50 짜리 Rectangle 구조체의 인스턴스를 생성했습니다.
area 함수의 매개변수는 이제 rectangle 하나뿐입니다.
단, 구조체의 소유권을 가져와 버리면 main 함수에서 area 함수 호출 이후에 rect1을 더 사용할 수 없으므로, rectangle 매개변수의 타입을 불변 참조자 타입으로 정하여 소유권을 빌려오기만 하도록 만들었습니다.
불변 참조자 타입이니 함수 시그니처와 호출 부분에 &를 붙입니다.
area 함수는 Rectangle 인스턴스의 width, height 필드에 접근합니다. 점 표기법은 &Rectangle 참조를 따라 필드에 접근하며, 이 예제의 두 필드는 Copy인 u32라 값을 복사합니다. 이를 모든 필드 접근이 이동을 일으키지 않는다는 규칙으로 일반화하면 안 됩니다. Copy가 아닌 필드는 공유 참조 밖으로 이동할 수 없고 참조로 읽어야 합니다.
area 함수의 시그니처는 이제 의미하는 바를 정확히 알려줍니다.
Rectangle의 width와 height 필드를 사용하여 넓이를 계산하라는 뜻이지요.
width, height가 서로 연관된 값이라는 것도 알 수 있고, 0 이나 1 대신 서술적인 필드명을 제공합니다.
명료성 측면에서 승리입니다.
트레이트 파생으로 유용한 기능 추가하기
프로그램을 디버깅하는 동안 Rectangle 인스턴스 내 모든 필드 값을 출력해서 확인할 수 있다면 좋을 것 같군요.
예제 4-11은 앞서 다뤄본 println! 매크로를 사용해 본 예시이나, 작동하진 않습니다.
Rectangle 인스턴스
출력을 시도해 본 모습
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {}", rect1);
}
이 코드를 컴파일하면 다음과 같은 메시지가 나타납니다.
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
--> src/main.rs:12:29
|
12 | println!("rect1 is {}", rect1);
| ^^^^^ `Rectangle` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `rectangles` due to previous error
println! 매크로에는 여러 출력 형식을 사용할 수 있습니다.
그리고 기본 형식인 {} 로 지정할 땐 Display라는, 최종 사용자를 위한 출력 형식을 사용하죠.
여태 사용했던 여러 기본 타입에는 Display가 구현되어 있었습니다.
{}는 각 타입이 정한 기본적인 사용자용 표현을 요청합니다. 정수에도 2진수나 16진수처럼 다른 포맷 지정자를 사용할 수 있으므로, 기본 타입의 출력 형식이 하나뿐이라는 뜻은 아닙니다.
하지만 구조체라면 이야기가 달라집니다.
중간중간 쉼표를 사용해야 할 수도 있고, 중괄호도 출력해야 할 수도 있고, 필드 일부를 생략해야 할 수도 있는 등 여러 가지가 가능합니다.
러스트는 이런 선택을 임의로 정하지 않기 때문에 사용자 정의 구조체에는 println!의 {} 자리표시자가 요구하는 Display 구현이 자동으로 제공되지 않습니다.
에러를 더 읽다 보면 다음과 같은 도움말을 찾을 수 있습니다.
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
{} 대신 {:?}를 사용해 보라는군요.
한번 해보죠.
println! 매크로 호출을 println!("rect1 is {:?}", rect1);으로 바꿔봅시다.
{} 내에 :?를 추가하는 건 println!에 Debug라는 출력 형식을 사용하고 싶다고 전달하는 것과 같습니다.
이 Debug라는 트레이트는 최종 사용자가 아닌, 개발자에게 유용한 방식으로 출력하여 디버깅하는 동안 값을 볼 수 있게 해주는 트레이트입니다.
변경하고 나서 다시 컴파일해 보면, 어째서인지 여전히 에러가 발생하네요.
error[E0277]: `Rectangle` doesn't implement `Debug`
--> src/main.rs:12:31
|
12 | println!("rect1 is {:?}", rect1);
| ^^^^^ `Rectangle` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Rectangle` with `#[derive(Debug)]`
|
1 | #[derive(Debug)]
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `rectangles` due to previous error
그런데 컴파일러가 또 무언가를 알려주네요.
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
러스트는 디버깅 정보를 출력하는 기능을 자체적으로 가지고 있습니다.
하지만 우리가 만든 구조체에 해당 기능을 적용하려면 명시적인 동의가 필요하므로, 예제 4-12처럼 구조체 정의 바로 이전에 #[derive(Debug)] 외부 속성(outer attribute)을 작성해주어야 합니다.
Rectangle 인스턴스를 디버그 출력 형식으로
사용하기 위해, 속성을 추가하여 Debug 트레이트 파생(derive) 하기
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
이제 프로그램을 실행해 보면 더 이상 에러가 나타나지 않고, 다음과 같은 출력이 나타날 겁니다.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }
좋습니다!
가장 예쁜 출력 형태라 할 수는 없지만, 인스턴스 내 모든 필드 값을 보여주므로 디버깅하는 동안에는 확실히 유용할 겁니다.
필드가 더 많은 구조체라면 이보다 더 읽기 편한 형태가 필요할 텐데요, 그럴 땐 println! 문자열 내에 {:?} 대신 {:#?}를 사용하면 됩니다.
{:#?}를 사용했을 때의 출력 예시는 다음과 같습니다.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/rectangles`
rect1 is Rectangle {
width: 30,
height: 50,
}
Debug 포맷을 사용하여 값을 출력하는 그 밖의 방법은
dbg! 매크로를 사용하는 것입니다.
dbg!(expr)는 표현식을 한 번 평가해 그 값의 소유권을 받은 뒤, 호출 위치와
표현식 및 값을 출력하고 같은 값을 다시 반환합니다. 반면 포맷 매크로는 출력할
인수를 빌려 사용합니다. Copy가 아닌 소유 값을 계속 사용해야 한다면
dbg!(&expr)처럼 참조를 전달해야 합니다.
Note:
dbg!매크로의 호출은 표준 에러 콘솔 스트림(stderr)에 출력을 하는데, 이는 표준 출력 콘솔 스트림(stdout)에 출력하는println!과는 상반됩니다.stderr와stdout에 대한 정보는 11장의 ‘표준 출력 대신 표준 에러로 에러 메시지 작성하기’절에서 더 이야기하겠습니다.
아래는 rect의 전체 구조체 값뿐만 아니라 width 필드에 대입되는
값에 관심이 있는 경우에 대한 예시입니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}
표현식 30 * scale을 dbg!으로 감싸 넣었는데, 이는 dbg!가 표현식 값의 소유권을 반환하면서 dbg! 호출을 하지 않았을 때와 같은 값이 width 필드에 입력되기 때문입니다.
dbg!가 rect1의 소유권을 가져가는 것은 원치 않으므로, 그다음의 호출에서는 rect1에 대한 참조자를 사용하였습니다.
위 예제의 출력 결과는 아래와 같습니다.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/rectangles`
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
width: 60,
height: 50,
}
첫 번째 출력 결과가 현재 디버깅 중인 30 * scale 표현식이 있는 src/main.rs의 10번째 라인인 것, 그리고 그 결괏값은 60임을 알 수 있습니다. (정수형을 위한 Debug 형식은 그냥 그 값을 출력하는 것으로 되어 있습니다.)
src/main.rs의 14번째 라인에 있는 dbg! 호출은 Rectangle 구조체인 &rect1의 결과를 출력합니다.
이 출력 결과는 Rectangle 타입에 대한 보기 좋은 Debug 포맷을 이용합니다.
dbg! 매크로는 여러분의 코드가 어떤 일을 하고 있는지 알아볼 때 매우 유용할 수 있습니다!
다만 파생된 Debug 표현과 dbg!의 정확한 출력 문자열은 안정적인 데이터 형식이 아니며 러스트 버전에 따라 달라질 수 있습니다. 또한 dbg!는 release 빌드에서도 동작하므로, 장기간 제품 코드에 남길 로깅 API로 취급해서는 안 됩니다.
러스트에서는 이처럼 Debug 트레이트 말고도 derive 속성으로 직접 만든 타입에 유용한 동작을 추가할 수 있는 트레이트를 여럿 제공합니다.
이들 목록 및 각각의 동작은 부록 C에서 확인할 수 있으니 참고해 주세요.
또한, 여러분만의 트레이트를 직접 만들고, 이런 트레이트의 동작을 커스터마이징해서 구현하는 방법은 10장에서 배울 예정입니다.
또한 derive 외에도 여러 가지 속성들이 있습니다; 더 많은 정보는 러스트 참고 자료의 ‘속성(attributes)’절을 살펴보세요.
만들어진 area 함수는 사각형의 면적만을 계산합니다.
Rectangle 구조체를 제외한 다른 타입으로는 작동하지 않으니 Rectangle 구조체와 더 밀접하게 묶는 편이 더 유용할 겁니다.
다음에는 area 함수를 Rectangle 타입 내에 메서드(method) 형태로 정의하여 코드를 리팩터링하는 방법을 알아보겠습니다.
튜플에서 구조체로 옮기는 리팩터링은 단순히 문법을 바꾸는 일이 아니라, 데이터의 의미와 디버깅 표면을 코드에 남기는 과정입니다. 출력 문법은 필요한 트레이트뿐 아니라 값이 빌려지는지 이동하는지도 함께 확인해야 합니다.
DEBUG SURFACE · VALUE FLOW
#[derive(Debug)]는 개발자용 표현을 명시적으로 추가합니다. 포맷 지정자는 필요한 트레이트를 정하고, dbg!에 넘기는 표현식은 소유권 흐름까지 정합니다.
| # | 필요한 구현과 값의 흐름 | 스트림·목적 |
|---|---|---|
| 1 | Display ·
|
stdout · 사용자에게 보여 줄 의도적인 표현 |
| 2 | Debug · 모든 필드가 |
stdout · 간결한 개발자용 상태 확인 |
| 3 | Pretty Debug · 같은 |
stdout · 여러 줄 pretty Debug |
| 4 |
표현식 타입의 |
stderr · 표현식을 한 번 평가해 소유권을 받은 뒤 같은 값을 반환하므로 계산 결과를 필드에 바로 넣을 수 있습니다. |
| 5 |
|
stderr · 소스 위치, 표현식, 값을 남기는 임시 진단 |
{}·Display- 사용자용 표현입니다.
Rectangle에는 자동 구현되지 않으므로 이 예제는 컴파일 오류입니다. {:?}/{:#?}·Debug- 모든 필드가
Debug이면 파생할 수 있습니다. 두 형식 모두 값을 빌리고,#는 pretty 출력을 요청합니다. dbg!(expr)· 이동 후 반환- 표현식을 한 번 평가하고 소유권을 받아
stderr에 진단한 뒤 같은 값을 반환합니다. dbg!(&rect1)· 공유 대여- 참조만 전달해 원본 소유권을 유지합니다. 파일·줄·표현식과 값은
stderr에 기록됩니다.
Debug와 dbg!의 정확한 출력 문자열은 안정적인 인터페이스가 아닙니다. dbg!는 release 빌드에서도 동작하므로 장기간 제품 코드에 남길 로깅 수단으로 취급하지 않습니다.사각형 예제는 구조체가 단순히 값을 묶는 도구가 아니라 함수 시그니처와 디버깅 표면을 함께 바꾸는 리팩터링임을 보여줍니다. 이 절의 최종 area는 아직 자유 함수이며, impl Rectangle의 메서드로 옮기는 단계는 다음 절에서 다룹니다.