구조체를 사용한 예제 프로그램
사각형 넓이 계산 코드를 개별 변수에서 튜플과 구조체로 리팩터링해 데이터 의미와 Debug 출력을 개선합니다.
구조체가 언제 도움이 되는지 알아보기 위해 사각형의 넓이를 구하는 프로그램을 만들어 봅니다. 너비와 높이를 각각의 변수로 다루는 코드에서 시작해, 튜플로 묶고, 다시 구조체로 바꿔 가며 코드가 어떻게 달라지는지 비교합니다. 마지막에는 구조체의 값을 디버깅용으로 출력하는 방법도 살펴봅니다.
변수로 넓이 구하기
cargo new rectangles로 새 프로젝트를 만듭니다. 이 프로그램은 픽셀 단위로 정한 너비와 높이로 사각형의 넓이를 구합니다.
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
}The area of the rectangle is 1500 square pixels.이 코드는 너비와 높이로 area를 불러 넓이 1500을 잘 구하지만, 더 분명하고 읽기 쉽게 고칠 수 있습니다. 문제는 area의 시그니처 fn area(width: u32, height: u32) -> u32에 드러납니다. area는 사각형 하나의 넓이를 구하는 함수인데 매개변수가 둘이고, 두 값이 서로 관련된 하나의 사각형이라는 사실이 코드 어디에도 나타나 있지 않습니다. 너비와 높이를 하나로 묶으면 더 읽기 쉽고 다루기 쉬워지므로, 먼저 튜플 타입으로 묶어 봅니다.
튜플로 리팩터링하기
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
}튜플로 묶어 인수를 하나만 넘기게 되었으니 조금은 나아졌습니다. 하지만 튜플의 원소에는 이름이 없어 위치 번호로 값을 꺼내야 하므로, 계산식은 오히려 덜 분명해졌습니다. 넓이를 구할 때는 너비와 높이가 바뀌어도 상관없지만, 화면에 사각형을 그리는 프로그램이라면 너비가 인덱스 0이고 높이가 인덱스 1이라는 것을 기억해야 합니다. 이 코드를 쓰는 다른 사람도 그것을 알아내 기억해야 하니, 데이터의 뜻이 코드에 드러나지 않아 실수하기 쉽습니다.
구조체로 리팩터링하여 코드에 더 많은 의미를 담기
구조체를 쓰면 데이터에 이름을 붙여 뜻을 드러낼 수 있습니다. 튜플을 구조체로 바꾸고, 전체와 각 부분에 이름을 붙입니다.
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이라는 구조체를 정의하고 중괄호 안에 u32 타입의 width와 height 필드를 두었습니다. main에서는 너비 30, 높이 50인 Rectangle 인스턴스를 만들고, area는 이제 Rectangle을 가리키는 매개변수 rectangle 하나를 받습니다. 구조체의 소유권을 가져오지 않고 빌리려고 불변 참조 &Rectangle을 받으므로, area를 부른 뒤에도 main은 rect1을 계속 소유하고 쓸 수 있습니다. 그래서 시그니처와 부르는 곳에 &를 붙였습니다.
area는 Rectangle 인스턴스의 width와 height 필드에 접근합니다. 이제 area의 시그니처는 Rectangle의 width와 height 필드로 넓이를 구한다는 뜻을 그대로 나타내며, 두 값이 서로 관련되어 있다는 것과 각 값의 이름을 0이나 1 대신 보여 줍니다. 세 버전 모두 30 × 50 = 1500을 구하는 것은 같습니다. 바뀐 것은 계산이 아니라, 데이터의 뜻이 코드에 드러나는 정도입니다. 아래 표는 세 버전을 비교합니다.
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의 메서드로 옮기는 단계는 다음 절에서 다룹니다.개별 값 버전은 매개변수 이름은 보이지만 두 값이 한 사각형이라는 관계가 타입에 없고, 두 u32는 Copy라 넘길 때 복사됩니다. 튜플 버전은 두 값을 한 인수로 묶었지만 위치 번호에 뜻을 기억해야 합니다. 구조체 버전은 필드 이름이 뜻을 타입에 남기고, &Rectangle로 빌려주므로 계산한 뒤에도 부른 쪽이 인스턴스를 소유합니다.
트레이트 파생으로 유용한 기능 추가하기
프로그램을 디버깅하는 동안 Rectangle 인스턴스의 필드 값을 모두 출력해 볼 수 있으면 편할 것입니다. 지금까지 쓴 println! 매크로로 해 보면 컴파일되지 않습니다.
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`println!은 여러 출력 형식을 지원하며, 중괄호 {}는 최종 사용자에게 보여 줄 Display 형식으로 출력하라는 뜻입니다. 지금까지 본 기본 타입은 사용자에게 1이나 다른 값을 보여 주는 방법이 하나뿐이므로 Display가 이미 구현되어 있었습니다. 하지만 구조체는 쉼표를 넣을지, 중괄호를 출력할지, 필드를 모두 보일지처럼 보여 주는 방법이 여러 가지이므로, 러스트는 짐작해서 정하지 않고 구조체에 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!("rect1 is {:?}", rect1);처럼 중괄호 안에 :?를 넣으면, 개발자가 디버깅할 때 값을 볼 수 있게 출력하는 Debug 형식을 쓰겠다는 뜻이 됩니다. 그런데 이렇게 고쳐도 아직 컴파일되지 않습니다.
error[E0277]: `Rectangle` doesn't implement `Debug`이번에도 도움말이 있습니다. note 줄은 #[derive(Debug)]를 붙이는 방법과 Debug를 직접 구현하는 방법 두 가지를 제안하며, 여기서는 더 간단한 앞의 방법을 씁니다.
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`러스트에는 디버깅 정보를 출력하는 기능이 들어 있지만, 직접 만든 구조체에 그 기능을 쓰려면 명시적으로 켜야 합니다. 구조체 정의 바로 앞에 바깥쪽 속성 #[derive(Debug)]를 붙이면 됩니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1:?}");
}rect1 is Rectangle { width: 30, height: 50 }이제 오류 없이 인스턴스의 필드 값이 모두 출력됩니다. 보기 좋은 형식은 아니지만 디버깅에는 충분하며, 필드가 많은 구조체라면 {:?} 대신 {:#?}를 써서 필드마다 줄을 나눈 형식으로 출력할 수 있습니다. 위의 코드에서 {rect1:?}를 {rect1:#?}로 바꾸면 다음과 같이 출력됩니다.
rect1 is Rectangle {
width: 30,
height: 50,
}Debug 형식으로 값을 출력하는 다른 방법은 dbg! 매크로입니다. println!은 출력할 값의 참조를 받지만, dbg!는 식의 값을 소유권째 받아 그 식이 있는 파일과 줄, 열, 식과 그 값을 출력한 뒤 받은 값을 그대로 돌려줍니다. 또 println!은 표준 출력(stdout)에 쓰지만 dbg!는 표준 에러(stderr)에 쓰며, 이 둘의 차이는 표준 에러 출력에서 다룹니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect1);
}[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
width: 60,
height: 50,
}dbg!는 받은 값을 그대로 돌려주므로, 30 * scale을 dbg!로 감싸도 width에는 감싸지 않았을 때와 같은 60이 들어갑니다. 첫 줄은 10번째 줄 16번째 열의 30 * scale이 60이라는 것을 보여 줍니다. 아래 그림처럼 이 출력은 표준 에러로 따로 나갈 뿐, width에 들어가는 값은 dbg!가 돌려준 값입니다.
Rust 디버그 출력은 트레이트, 포맷, 소유권 경계를 함께 본다
dbg!(30*2)가 한 번 계산한 60을 width에 돌려주고 Rectangle 60×50의 공유 참조 dbg 뒤에도 rect1이 유효하게 남는 값의 흐름이다. stderr 진단 둘을 반환값 경로와 분리하고 로그의 정확한 문자열은 고정하지 않는다.
DEBUG SURFACE · VALUE FLOW
출력 형식은 트레이트·값의 흐름·스트림을 함께 정한다
#[derive(Debug)]는 개발자용 표현을 명시적으로 추가합니다. 포맷 지정자는 필요한 트레이트를 정하고, dbg!에 넘기는 표현식은 소유권 흐름까지 정합니다.
| # | 필요한 구현과 값의 흐름 | 스트림·목적 |
|---|---|---|
| 1 | Display ·
|
stdout · 사용자에게 보여 줄 의도적인 표현 |
| 2 | Debug · 모든 필드가 |
stdout · 간결한 개발자용 상태 확인 |
| 3 | Pretty Debug · 같은 |
stdout · 여러 줄 pretty Debug |
| 4 |
표현식 타입의 |
stderr · 표현식을 한 번 평가해 소유권을 받은 뒤 같은 값을 반환하므로 계산 결과를 필드에 바로 넣을 수 있습니다. |
| 5 |
|
stderr · 소스 위치, 표현식, 값을 남기는 임시 진단 |
{}·Displayprintln!의 사용자용 표현으로 인수를 빌려stdout에 출력합니다.Rectangle에는 자동 구현되지 않아 이 예제는 컴파일 오류입니다.{:?}/{:#?}·Debug- 모든 필드가
Debug이면 파생할 수 있습니다. 두 형식 모두 값을 빌려stdout에 출력하고,#는 pretty 출력을 요청합니다. dbg!(expr)· 평가·진단 후 반환- 표현식을 한 번 평가해
stderr에 진단한 뒤 같은 값을 반환합니다.Copy값은 복사되고, 그렇지 않은 값은 이동합니다. dbg!(&rect1)· 공유 대여- 참조만 전달해 원본 소유권을 유지합니다. 파일·줄·표현식과 값은
stderr에 기록됩니다.
Debug와 dbg!의 정확한 출력 문자열은 안정적인 인터페이스가 아닙니다. dbg!는 release 빌드에서도 동작하므로 장기간 제품 코드에 남길 로깅 수단으로 취급하지 않습니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 7노드와 6간선이다. 원래 scale 2 예의 dbg 30 곱하기 2 계산 60→width 60 대입→Rectangle 60,50→공유 참조 dbg→main의 rect1 유효 상태를 연결한다. 첫 dbg는 stderr 진단 1로, 둘째 dbg는 stderr 진단 2로 각각 출력 간선을 갖는다. 진단 노드 사이나 진단에서 필드 대입으로 향하는 간선은 없으며 로그 표시가 반환값이 아니다. 마지막 유효 상태는 대여 계약을 설명한 것으로 원래 프로그램에서 추가 println을 실행했다는 주장이 아니다. 세로 도표에서도 값 경로와 두 진단 출구가 같다. |
| 노드·연결선 라벨 | 같은 60을 반환받음 |
| 노드·연결선 라벨 | 표현식과 60 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 계산과 진단 dbg!가 식 30*2를 한 번 계산한다. |
|
|
| 구조체 저장 반환된 60이 width에 들어간다. |
|
|
| 참조 dbg &rect1을 관측한 뒤 원본을 계속 쓸 수 있다. |
|
|
Rust 디버그 출력은 트레이트, 포맷, 소유권 경계를 함께 본다
dbg!(30*2)가 한 번 계산한 60을 width에 돌려주고 Rectangle 60×50의 공유 참조 dbg 뒤에도 rect1이 유효하게 남는 값의 흐름이다. stderr 진단 둘을 반환값 경로와 분리하고 로그의 정확한 문자열은 고정하지 않는다.
DEBUG SURFACE · VALUE FLOW
출력 형식은 트레이트·값의 흐름·스트림을 함께 정한다
#[derive(Debug)]는 개발자용 표현을 명시적으로 추가합니다. 포맷 지정자는 필요한 트레이트를 정하고, dbg!에 넘기는 표현식은 소유권 흐름까지 정합니다.
| # | 필요한 구현과 값의 흐름 | 스트림·목적 |
|---|---|---|
| 1 | Display ·
|
stdout · 사용자에게 보여 줄 의도적인 표현 |
| 2 | Debug · 모든 필드가 |
stdout · 간결한 개발자용 상태 확인 |
| 3 | Pretty Debug · 같은 |
stdout · 여러 줄 pretty Debug |
| 4 |
표현식 타입의 |
stderr · 표현식을 한 번 평가해 소유권을 받은 뒤 같은 값을 반환하므로 계산 결과를 필드에 바로 넣을 수 있습니다. |
| 5 |
|
stderr · 소스 위치, 표현식, 값을 남기는 임시 진단 |
{}·Displayprintln!의 사용자용 표현으로 인수를 빌려stdout에 출력합니다.Rectangle에는 자동 구현되지 않아 이 예제는 컴파일 오류입니다.{:?}/{:#?}·Debug- 모든 필드가
Debug이면 파생할 수 있습니다. 두 형식 모두 값을 빌려stdout에 출력하고,#는 pretty 출력을 요청합니다. dbg!(expr)· 평가·진단 후 반환- 표현식을 한 번 평가해
stderr에 진단한 뒤 같은 값을 반환합니다.Copy값은 복사되고, 그렇지 않은 값은 이동합니다. dbg!(&rect1)· 공유 대여- 참조만 전달해 원본 소유권을 유지합니다. 파일·줄·표현식과 값은
stderr에 기록됩니다.
Debug와 dbg!의 정확한 출력 문자열은 안정적인 인터페이스가 아닙니다. dbg!는 release 빌드에서도 동작하므로 장기간 제품 코드에 남길 로깅 수단으로 취급하지 않습니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 7노드와 6간선이다. 원래 scale 2 예의 dbg 30 곱하기 2 계산 60→width 60 대입→Rectangle 60,50→공유 참조 dbg→main의 rect1 유효 상태를 연결한다. 첫 dbg는 stderr 진단 1로, 둘째 dbg는 stderr 진단 2로 각각 출력 간선을 갖는다. 진단 노드 사이나 진단에서 필드 대입으로 향하는 간선은 없으며 로그 표시가 반환값이 아니다. 마지막 유효 상태는 대여 계약을 설명한 것으로 원래 프로그램에서 추가 println을 실행했다는 주장이 아니다. 세로 도표에서도 값 경로와 두 진단 출구가 같다. |
| 노드·연결선 라벨 | 같은 60을 반환받음 |
| 노드·연결선 라벨 | 표현식과 60 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 계산과 진단 dbg!가 식 30*2를 한 번 계산한다. |
|
|
| 구조체 저장 반환된 60이 width에 들어간다. |
|
|
| 참조 dbg &rect1을 관측한 뒤 원본을 계속 쓸 수 있다. |
|
|
rect1의 소유권은 넘기고 싶지 않으므로 두 번째 dbg!에는 참조 &rect1을 넘겼고, 그래서 그 뒤에도 rect1을 쓸 수 있습니다. 출력은 {:#?}와 같은 보기 좋은 Debug 형식입니다. dbg!는 코드가 무엇을 하는지 알아볼 때 유용하지만, Debug의 출력 모양은 러스트 버전에 따라 달라질 수 있으므로 프로그램이 읽을 데이터 형식으로 쓰지는 않습니다.
러스트에는 Debug 말고도 derive 속성으로 직접 만든 타입에 붙일 수 있는 트레이트가 여럿 있으며, 부록 C 파생 가능한 트레이트에 정리되어 있습니다. 트레이트의 동작을 직접 구현하는 방법과 새 트레이트를 만드는 방법은 트레이트와 공통 동작에서 다루며, derive 외의 여러 속성은 Reference의 속성에서 볼 수 있습니다.
지금의 area는 사각형의 넓이만 구하고 다른 타입에는 쓸 수 없으므로, Rectangle 구조체와 더 가깝게 묶어 두는 편이 낫습니다. 다음 글에서는 area를 Rectangle의 메서드로 바꿉니다.
정리
- 서로 관련된 값을 따로 다루면 그 관계가 코드에 드러나지 않고, 튜플로 묶으면 위치 번호에 뜻을 기억해야 합니다.
- 구조체로 묶으면 필드 이름이 데이터의 뜻을 드러내며, 참조로 넘기면 부른 쪽이 소유권을 유지합니다.
{}는Display형식이고 구조체에는 자동으로 구현되지 않으며,{:?}와{:#?}는Debug형식입니다.- 구조체에
#[derive(Debug)]를 붙이면Debug형식으로 출력할 수 있습니다. dbg!는 식의 위치와 값을 표준 에러에 출력하고 받은 값을 돌려주며, 소유권을 넘기지 않으려면 참조를 넘깁니다.
연습 문제
1. 두 번 부른 뒤에도
구조체 버전에서 width를 12, height를 8로 바꾸면 넓이는 얼마인가요? main에서 area(&rect1)을 두 번 부른 뒤 rect1.width를 출력할 수 있나요? area가 &Rectangle 대신 Rectangle을 받는다면 어떻게 되나요?
해설 보기
12 × 8 = 96입니다. area는 불변 참조로 빌려 읽기만 하므로 두 번 불러도 main이 rect1을 소유하며, rect1.width인 12를 출력할 수 있습니다. area가 Rectangle을 소유권째 받으면 첫 호출에서 rect1이 함수로 이동하므로, 두 번째 호출이나 그 뒤의 rect1.width는 E0382 오류로 컴파일되지 않습니다. 이 Rectangle에는 Copy를 붙이지 않았기 때문입니다.
2. dbg!가 돌려주는 값
dbg! 예제에서 scale을 3으로 바꾸고, dbg!(&rect1); 다음에 println!("{} {}", rect1.width, rect1.width * rect1.height);를 넣었습니다. width에 들어가는 값과 새 println!의 출력은 무엇이며, 각 출력은 어디로 나가나요?
해설 보기
30 * 3이 한 번 계산되고 dbg!가 그 90을 그대로 돌려주므로 width는 90, height는 50입니다. 새 println!은 90 4500을 표준 출력으로 내보내고, 두 dbg!의 출력은 표준 에러로 나갑니다. 그래서 실행할 때 cargo run > out.txt처럼 표준 출력만 파일로 보내면, out.txt에는 90 4500만 남고 dbg!의 출력은 터미널에 나타납니다.
3. 사용자에게 보여 줄 출력
Rectangle을 사용자에게 30x50처럼 보여 주고 싶습니다. #[derive(Debug)]와 {:?}로 충분할까요?
해설 보기
충분하지 않습니다. Debug는 개발자가 디버깅할 때 쓰는 형식이어서 Rectangle { width: 30, height: 50 }처럼 출력되며, 그 모양도 러스트 버전에 따라 달라질 수 있습니다. 사용자에게 보여 줄 형식은 {}가 쓰는 Display 트레이트를 직접 구현해 정해야 하며, 트레이트를 구현하는 방법은 9장에서 다룹니다.
다음 글 메서드 문법에서는 area를 Rectangle의 메서드로 바꾸고, 매개변수가 더 있는 메서드와 연관 함수를 만들어 봅니다.