구조체를 사용하는 예제 프로그램
구조체를 사용하는 예제 프로그램 (An Example Program Using Structs)
구조체를 언제 써야 할지 이해하기 위해, 사각형의 넓이를 계산하는 프로그램을 하나 만들어 볼게요. 단일 변수로 시작해서, 마지막에는 구조체를 사용하도록 프로그램을 단계적으로 리팩터링해 나갈 거예요.
출처: The Rust Book
Cargo로 rectangles라는 새 바이너리 프로젝트를 만들 거예요. 이 프로젝트는 픽셀 단위로 지정된 사각형의 가로와 세로를 받아 넓이를 계산해요. Listing 5-8은 프로젝트의 src/main.rs에서 정확히 그 일을 하는 짧은 프로그램을 보여줘요.
Filename: src/main.rs
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
}
Listing 5-8: 별개의 가로·세로 변수로 지정된 사각형의 넓이 계산하기
이제 cargo run으로 이 프로그램을 실행해 보세요.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.
이 코드는 각 변을 area 함수에 넘겨서 사각형의 넓이를 구하는 데는 성공해요. 하지만 이 코드를 더 명확하고 읽기 좋게 만들 수 있어요.
이 코드의 문제는 area의 시그니처에서 드러나요.
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
}
area 함수는 사각형 하나의 넓이를 계산하려고 하는데, 우리가 작성한 함수에는 매개변수가 둘이에요. 그리고 프로그램 어디에도 이 두 매개변수가 서로 관련되어 있다는 표시가 없어요. 가로와 세로를 함께 묶는 편이 더 읽기 쉽고 관리하기 쉬워요. 그렇게 할 수 있는 한 가지 방법을 3장의 "튜플 타입" 절에서 이미 다뤘는데, 바로 튜플을 사용하는 거예요.
튜플로 리팩터링하기
Listing 5-9는 튜플을 사용하는 우리 프로그램의 또 다른 버전이에요.
Filename: src/main.rs
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
}
Listing 5-9: 튜플로 사각형의 가로와 세로 지정하기
어떤 면에서 이 프로그램은 더 나아요. 튜플 덕분에 약간의 구조가 생기고, 이제 인자를 하나만 넘기게 됐으니까요. 하지만 다른 면에서 이 버전은 덜 명확해요. 튜플은 요소에 이름을 붙이지 않으므로 튜플의 각 부분을 인덱스로 접근해야 해서, 계산 의도가 덜 드러나요.
넓이 계산에서는 가로와 세로를 뒤바꿔도 문제없지만, 사각형을 화면에 그리려고 한다면 문제가 돼요! width가 튜플 인덱스 0이고 height가 튜플 인덱스 1이라는 사실을 계속 기억해야 하거든요. 남이 우리 코드를 쓴다면 이걸 알아내고 기억하는 건 더 어려워요. 코드에서 데이터의 의미를 전달하지 않았기 때문에, 이제 오류가 들어올 가능성이 더 커진 거죠.
구조체로 리팩터링하기
구조체는 데이터에 이름표를 붙여 의미를 더해요. 우리가 쓰던 튜플을, 전체에 대한 이름과 각 부분에 대한 이름을 가진 구조체로 바꿀 수 있어요. Listing 5-10을 보세요.
Filename: src/main.rs
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
}
Listing 5-10: Rectangle 구조체 정의하기
여기서 구조체를 정의하고 이름을 Rectangle이라고 지었어요. 중괄호 안에는 필드를 width와 height로 정의했고 둘 다 타입은 u32예요. 그러고 나서 main에서 가로가 30, 세로가 50인 Rectangle의 특정 인스턴스를 만들었어요.
이제 area 함수는 rectangle이라는 매개변수 하나로 정의되는데, 그 타입은 Rectangle 인스턴스의 불변 대여(immutable borrow)예요. 4장에서 언급했듯이, 구조체의 소유권을 가져가기보다는 구조체를 대여하고 싶어요. 이렇게 하면 main이 소유권을 유지하고 rect1을 계속 사용할 수 있어요. 그래서 함수 시그니처와 함수를 호출하는 곳에서 &를 사용하지요.
area 함수는 Rectangle 인스턴스의 width와 height 필드에 접근해요. (대여된 구조체 인스턴스의 필드에 접근해도 필드 값이 이동되지 않는데, 그래서 구조체의 대여를 자주 보게 되는 거예요.) 이제 area의 함수 시그니처가 우리가 뜻하는 바를 정확히 말해 줘요. Rectangle의 width와 height 필드를 사용해 넓이를 계산한다는 거죠. 이는 가로와 세로가 서로 관련되어 있다는 것을 전달하고, 튜플 인덱스 0과 1 대신 값에 설명적인 이름을 붙여 줘요. 명확성 측면에서 한 몫 톡톡히 하는 거예요.
파생 트레이트(Derived Traits)로 기능 추가하기
프로그램을 디버깅하는 동안 Rectangle 인스턴스를 출력해서 모든 필드의 값을 볼 수 있다면 유용할 거예요. Listing 5-11은 이전 장들에서 했던 것처럼 println! 매크로를 사용해 보는 모습이에요. 하지만 이렇게는 동작하지 않아요.
Filename: src/main.rs
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1}");
}
Listing 5-11: Rectangle 인스턴스 출력 시도하기
이 코드를 컴파일하면 다음과 같은 핵심 메시지로 에러가 나와요.
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
println! 매크로는 여러 종류의 포맷팅을 할 수 있고, 기본적으로 중괄호는 Display라는 포맷팅을 사용하라고 println!에 지시해요. Display는 최종 사용자가 직접 보기 위한 출력이에요. 지금까지 본 기본 타입들은 기본적으로 Display를 구현해요. 사용자에게 1이나 다른 기본 타입을 보여주는 방법은 딱 한 가지뿐이기 때문이죠. 하지만 구조체는 표시할 방법이 여러 가지라서 println!이 출력을 어떻게 포맷팅해야 할지 덜 분명해요. 쉼표를 넣을까? 중괄호를 출력할까? 모든 필드를 보여줄까? 이런 모호함 때문에 Rust는 우리가 원하는 것을 추측하지 않고, 구조체에는 println!과 {} 자리표시자에 사용할 수 있는 제공된 Display 구현이 없어요.
에러를 계속 읽어 보면 다음과 같은 도움이 되는 메모를 발견할 수 있어요.
| |`Rectangle` cannot be formatted with the default formatter
| required by this formatting parameter
한번 해보죠! println! 매크로 호출이 이제 println!("rect1 is {rect1:?}");처럼 보일 거예요. 중괄호 안에 :? 지정자를 넣으면 Debug라는 출력 형식을 사용하겠다고 println!에 알려줘요. Debug 트레이트는 구조체를 개발자에게 유용한 방식으로 출력할 수 있게 해줘서, 코드를 디버깅하는 동안 그 값을 볼 수 있게 해줘요.
이 변경 사항을 반영해 코드를 컴파일해 보세요. 어라, 아직도 에러가 나요!
error[E0277]: `Rectangle` doesn't implement `Debug`
하지만 또 다시 컴파일러가 도움이 되는 메모를 알려줘요.
| required by this formatting parameter
|
Rust는 디버깅 정보를 출력하는 기능을 정말로 포함하고 있어요. 다만 이 기능을 우리 구조체에 사용할 수 있게 하려면 명시적으로 동의해야 해요. 그렇게 하려면 구조체 정의 바로 앞에 외부 속성(outer attribute) #[derive(Debug)]를 추가하면 돼요. Listing 5-12를 보세요.
Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1:?}");
}
Listing 5-12: Debug 트레이트를 파생하는 속성을 추가하고 debug 포맷팅으로 Rectangle 인스턴스 출력하기
이제 프로그램을 실행하면 에러 없이 다음 출력을 볼 수 있어요.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [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` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/rectangles`
rect1 is Rectangle {
width: 30,
height: 50,
}
Debug 형식으로 값을 출력하는 또 다른 방법은 dbg! 매크로를 사용하는 거예요. dbg!는 표현식의 소유권을 가져가고(참조를 받는 println!과는 반대로), 코드에서 그 dbg! 매크로 호출이 일어난 파일과 줄 번호를 표현식의 결과값과 함께 출력한 다음, 값의 소유권을 돌려줘요.
참고: dbg! 매크로를 호출하면 표준 출력 스트림(stdout)에 출력하는 println!과 달리 표준 에러 콘솔 스트림(stderr)에 출력돼요. stderr와 stdout에 대해서는 12장의 "표준 에러로 에러 리다이렉트하기" 절에서 더 다룰게요.
width 필드에 할당되는 값과 rect1의 구조체 전체 값에 관심이 있는 예시를 볼게요.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}
dbg!를 표현식 30 * scale 주위에 놓을 수 있어요. 그리고 dbg!는 표현식 값의 소유권을 돌려주기 때문에, width 필드는 dbg! 호출이 없을 때와 같은 값을 받아요. rect1의 소유권이 dbg!로 넘어가는 걸 원하지 않으므로, 다음 호출에서는 rect1에 대한 참조를 사용해요. 이 예시의 출력은 다음과 같아요.
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
width: 60,
height: 50,
}
첫 번째 출력이 src/main.rs 10번째 줄에서 나왔다는 걸 볼 수 있어요. 그곳에서 표현식 30 * scale을 디버깅했고 그 결과값은 60이에요. (정수에 구현된 Debug 포맷팅은 값을 그대로 출력해요.) src/main.rs 14번째 줄의 dbg! 호출은 Rectangle 구조체인 &rect1의 값을 출력해요. 이 출력은 Rectangle 타입의 보기 좋은 Debug 포맷팅을 사용한 거죠. dbg! 매크로는 코드가 무슨 일을 하는지 파악하려고 할 때 정말 유용해요!
Debug 트레이트 외에도, Rust는 커스텀 타입에 유용한 동작을 더할 수 있도록 derive 속성과 함께 사용할 수 있는 여러 트레이트를 제공해요. 그러한 트레이트와 그 동작은 부록 C에 나열되어 있어요. 사용자 지정 동작으로 이런 트레이트를 구현하는 방법과 직접 트레이트를 만드는 방법은 10장에서 다룰게요. derive 외에도 많은 속성이 있고, 더 자세한 내용은 Rust 레퍼런스의 "속성(Attributes)" 절을 참고하세요.
우리의 area 함수는 아주 특화되어 있어요. 오직 사각형의 넓이만 계산하죠. 이 동작을 다른 타입에서는 작동하지 않으니 Rectangle 구조체에 더 밀접하게 연결하는 편이 유용할 거예요. area 함수를 Rectangle 타입에 정의된 area 메서드로 바꿔서 이 코드를 계속 리팩터링하는 방법을 살펴볼게요.