구조체를 사용하는 예제 프로그램
언제 구조체를 사용하고 싶어지는지 이해하기 위해, 직사각형의 넓이를 계산하는 프로그램을 작성해 봅시다. 처음에는 개별 변수만 사용한 뒤, 점차 구조체를 사용하는 방식으로 리팩터링해 보겠습니다.
픽셀 단위로 주어진 직사각형의 너비와 높이를 받아 넓이를 계산하는 rectangles 라는 새 바이너리 프로젝트를 Cargo로 만들어 봅시다. 목록 5-8은 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
}
이제 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장의 “튜플 타입”
절에서 다뤘습니다. 튜플을 사용하는 방식입니다.
튜플로 리팩터링하기
목록 5-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
이라는 사실을 계속 기억해야 하기 때문입니다. 코드를 사용하는 다른 사람에게는 이 사실을
파악하고 계속 염두에 두는 일이 더 어려울 것입니다. 데이터의 의미를 코드에 충분히
드러내지 않았기 때문에, 오히려 오류를 만들기 쉬운 상태가 되었습니다.
구조체로 리팩터링하기
구조체를 사용하면 데이터에 이름을 붙여 의미를 더할 수 있습니다. 튜플로 표현하던 것을 목록 5-10처럼, 전체에도 이름이 있고 각 부분에도 이름이 있는 구조체로 바꿀 수 있습니다.
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 구조체 정의하기여기서는 구조체를 정의하고 이름을 Rectangle 로 붙였습니다. 중괄호 안에는
width 와 height 라는 필드를 정의했고, 둘 다 타입은 u32 입니다. 그리고
main 안에서는 너비가 30, 높이가 50 인 특정 Rectangle 인스턴스를 만들었습니다.
이제 area 함수는 rectangle 이라는 이름의 하나의 매개변수만 받는데, 그 타입은
Rectangle 구조체 인스턴스에 대한 불변 대여입니다. 4장에서 설명했듯이, 구조체의
소유권을 가져가는 대신 빌려 쓰고 싶기 때문에 참조를 사용합니다. 그렇게 하면 main
이 소유권을 계속 유지할 수 있고, 그래서 함수 시그니처와 호출 부분에 & 를 붙입니다.
area 함수는 Rectangle 인스턴스의 width, height 필드에 접근합니다
(빌린 구조체 인스턴스의 필드에 접근하는 것은 필드 값을 이동시키지 않는다는 점에
주의하세요. 그래서 러스트에서는 구조체를 빌리는 코드를 자주 보게 됩니다). 이제
area 의 함수 시그니처는 우리가 의도한 바를 정확히 말해 줍니다. Rectangle 의
넓이를 width 와 height 필드를 사용해 계산하라는 뜻입니다. 이렇게 하면 너비와
높이가 서로 관련된 값이라는 사실이 드러나고, 튜플 인덱스 0, 1 대신 설명적인
이름을 붙일 수 있습니다. 명확성 면에서 훨씬 낫습니다.
파생 트레이트로 유용한 기능 추가하기
프로그램을 디버깅할 때 Rectangle 인스턴스를 출력하고, 모든 필드 값을 한꺼번에 볼
수 있다면 유용할 것입니다. 목록 5-11은 우리가 앞 장들에서 써 왔던 것처럼
println! 매크로를 사용해 보려 하지만, 이 코드는
동작하지 않습니다.
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1}");
}
Rectangle 인스턴스를 출력하려 시도하기이 코드를 컴파일하면 핵심적으로 다음과 같은 오류 메시지를 받게 됩니다.
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
println! 매크로는 여러 가지 출력 형식을 지원하며, 기본적으로 중괄호는 Display
라는 형식을 사용하라는 뜻입니다. 이것은 최종 사용자가 바로 소비할 출력을 의도한
형식입니다. 지금까지 본 기본 타입들은 사용자에게 1 이나 다른 기본값을 보여 주는
방식이 사실상 하나뿐이므로 기본적으로 Display 를 구현합니다. 하지만 구조체는
상황이 다릅니다. println! 이 어떤 형식으로 출력해야 할지가 명확하지 않기 때문입니다.
쉼표를 넣을까요? 중괄호를 출력할까요? 모든 필드를 보여 줄까요? 이런 모호함 때문에,
러스트는 우리가 무엇을 원하는지 추측하지 않습니다. 그래서 구조체에는 println!
과 {} 플레이스홀더에 사용할 수 있는 Display 구현이 기본 제공되지 않습니다.
오류 메시지를 더 읽어 내려가면 이런 도움말을 찾을 수 있습니다.
| |`Rectangle` cannot be formatted with the default formatter
| required by this formatting parameter
한번 해 봅시다! 이제 println! 호출은 println!("rect1 is {rect1:?}"); 처럼
생기게 됩니다. 중괄호 안에 :? 지정자를 넣으면, println! 에게 Debug 라는 출력
형식을 사용하고 싶다고 알려 주는 것입니다. Debug 트레이트는 개발자에게 유용한
형식으로 구조체를 출력하게 해 주어, 디버깅 중에 그 값을 확인할 수 있게 합니다.
이 변경을 적용한 뒤 코드를 컴파일해 보세요. 아쉽게도 여전히 오류가 납니다.
error[E0277]: `Rectangle` doesn't implement `Debug`
하지만 이번에도 컴파일러는 유용한 힌트를 줍니다.
| required by this formatting parameter
|
러스트는 실제로 디버깅 정보를 출력하는 기능을 제공하지만, 우리 구조체에서 그 기능을
쓰겠다고 명시적으로 선택해야 합니다. 그렇게 하려면 목록 5-12처럼 구조체 정의 바로
앞에 #[derive(Debug)] 라는 바깥 속성을 추가합니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1:?}");
}
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,
}
또 다른 방법으로는 dbg! 매크로를 사용할 수 있습니다.
dbg! 는 참조를 받는 println! 과 달리, 식의 소유권을 가져와서 그 식이 있는
파일과 줄 번호, 그리고 그 식의 결과값을 함께 출력한 뒤, 다시 그 값의 소유권을
반환합니다.
Note: dbg! 매크로는 println! 이 사용하는 표준 출력 스트림(stdout)이 아니라,
표준 에러 스트림(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);
}
30 * scale 식을 dbg! 로 감싸면, 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! 호출은 &rect1 의 값을 출력하고, 이 출력은 Rectangle
타입의 예쁜 Debug 형식을 사용합니다. dbg! 매크로는 코드가 실제로 무엇을 하고
있는지 파악할 때 아주 도움이 됩니다.
Debug 트레이트 외에도, 러스트는 derive 속성과 함께 사용할 수 있는 여러 트레이트를
제공하며, 이것들은 사용자 정의 타입에 유용한 동작을 추가해 줍니다. 그런 트레이트와
그 동작 목록은 부록 C에 정리되어 있습니다. 10장에서는 이런
트레이트를 직접 구현해 원하는 동작을 정의하는 방법과, 여러분 자신의 트레이트를 만드는
방법도 다룹니다. 또한 derive 외에도 다양한 속성이 있는데, 자세한 내용은
Rust Reference의 “Attributes” 절을 참고하세요.
현재 area 함수는 매우 특수합니다. 사각형 넓이만 계산할 수 있습니다. 이 동작을
Rectangle 구조체와 더 강하게 연결할 수 있다면 좋겠습니다. 다른 타입에서는 동작하지
않기 때문입니다. 다음으로는 area 함수를 Rectangle 타입 위에 정의된 area
메서드로 바꾸면서 이 코드를 계속 리팩터링해 보겠습니다.