구조체를 사용한 예제 프로그램

어떨 때 구조체를 사용하면 좋을지 이해해 보기 위해 사각형 넓이를 계산하는 프로그램을 작성해 봅시다. 단일 변수로만 구성된 프로그램으로 시작해서 구조체를 사용하기까지 리팩터링하면서 말이죠.

카고를 사용해 rectangles라는 새로운 바이너리 프로젝트를 만들어 줍시다. 이 프로그램은 픽셀 단위로 지정된 너비와 높이로 사각형의 넓이를 계산할 겁니다. 예제 5-8은 src/main.rs에 이 기능을 간단하게 구현한 모습입니다:

파일명: 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
}

예제 5-8: 각 변수에 지정된 너비와 높이로 사각형 넓이 계산하기

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 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는 튜플을 사용한 모습입니다.

파일명: 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
}

예제 5-9: 사각형의 너비와 높이를 튜플로 명시하는 코드

튜플을 사용함으로써 더 짜임새 있는 코드가 됐고, 인수도 단 하나만 넘기면 된다는 점에서는 프로그램이 발전했다고 볼 수 있습니다. 하지만 각 요소에 이름이 없는 튜플의 특성 때문에 값을 인덱스로 접근해야 해서 계산식이 불명확해졌네요.

다행히 넓이를 계산할 땐 어떤 값이 너비이고 어떤 값이 높이인지 구분하지 못해도 별 문제가 없습니다. 하지만 만들어야 할 프로그램이 화면에 사각형을 그리는 프로그램이라고 가정해 보면 어떨까요? 너비 값인 width가 튜플 인덱스 0에 위치하고 높이 값인 height는 튜플 인덱스 1에 위치한다는 걸 꼭 기억하고 있어야 할 겁니다. 혹여나 다른 사람이 이 코드로 작업할 일이 생기면 그 사람도 이 점을 알아내서 기억해야 하겠죠. 코드 내에 데이터의 의미를 전달하지 못하므로 에러가 발생하기 더 쉬워집니다.

구조체로 리팩터링하여 코드에 더 많은 의미를 담기

구조체는 데이터에 이름표를 붙여서 의미를 나타낼 수 있습니다. 예제 5-10처럼, 기존에 사용하던 튜플을 구조체로 바꿔 각 구성 요소에 이름을 지어줍시다.

파일명: 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
}

예제 5-10: Rectangle 구조체 정의

Rectangle이라는 구조체를 정의하고, 중괄호 안에 width, height 필드를 u32 타입으로 정의했습니다. 이후 main에서는 너비 30, 높이 50 짜리 Rectangle 구조체의 인스턴스를 생성했습니다.

area 함수의 매개변수는 이제 rectangle 하나뿐입니다. 단, 구조체의 소유권을 가져와 버리면 main 함수에서 area 함수 호출 이후에 rect1을 더 사용할 수 없으므로, rectangle 매개변수의 타입을 불변 참조자 타입으로 정하여 소유권을 빌려오기만 하도록 만들었습니다. 불변 참조자 타입이니 함수 시그니처와 호출 부분에 &를 붙입니다.

area 함수는 Rectangle 인스턴스의 width, height 필드에 접근합니다. (빌린 구조체 인스턴스의 필드에 접근하는 것은 필드 값을 이동시키지 않으며, 이것이 구조체의 대여를 자주 보게 되는 이유임을 기억해 두세요.) area 함수의 시그니처는 이제 의미하는 바를 정확히 알려줍니다: Rectanglewidthheight 필드를 사용하여 넓이를 계산하라는 뜻이지요. width, height가 서로 연관된 값이라는 것도 알 수 있고, 0 이나 1 대신 서술적인 필드명을 제공합니다. 명료성 측면에서 승리입니다.

트레이트 파생으로 유용한 기능 추가하기

프로그램을 디버깅하는 동안 Rectangle 인스턴스 내 모든 필드 값을 출력해서 확인할 수 있다면 좋을 것 같군요. 예제 5-11은 앞서 다뤄본 println! 매크로를 사용해 본 예시이나, 작동하진 않습니다.

파일명: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}

예제 5-11: Rectangle 인스턴스 출력을 시도해 본 모습

이 코드를 컴파일하면 다음과 같은 메시지가 나타납니다:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

println! 매크로에는 여러 출력 형식을 사용할 수 있습니다. 그리고 기본 형식인 {} 로 지정할 땐 Display라는, 최종 사용자를 위한 출력 형식을 사용하죠. 여태 사용했던 기본 타입들은 Display가 기본적으로 구현되어 있었습니다. 1 같은 기본 타입들을 사용자에게 보여줄 수 있는 형식은 딱 한 가지뿐이니까요. 하지만 구조체라면 이야기가 달라집니다. 중간중간 쉼표를 사용해야 할 수도 있고, 중괄호도 출력해야 할 수도 있고, 필드 일부를 생략해야 할 수도 있는 등 여러 가지가 가능합니다. 러스트는 이런 애매한 상황에 우리가 원하는 걸 임의로 예상해서 제공하려 들지 않기 때문에, 구조체에는 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`

그런데 컴파일러가 또 무언가를 알려주네요:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

러스트는 디버깅 정보를 출력하는 기능을 자체적으로 가지고 있습니다. 하지만 우리가 만든 구조체에 해당 기능을 적용하려면 명시적인 동의가 필요하므로, 예제 5-12처럼 구조체 정의 바로 이전에 #[derive(Debug)] 외부 속성 (outer attribute) 을 작성해주어야 합니다.

파일명: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

예제 5-12: Rectangle 인스턴스를 디버그 출력 형식으로 사용하기 위해, 속성을 추가하여 Debug 트레이트 파생 (derive) 하기

이제 프로그램을 실행해 보면 더 이상 에러가 나타나지 않고, 다음과 같은 출력이 나타날 겁니다:

$ 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! 매크로를 사용하는 것인데, 이는 표현식의 소유권을 가져와서, (참조자를 사용하는 println!과는 다릅니다) 코드에서 dbg! 매크로를 호출한 파일 및 라인 번호를 결괏값과 함께 출력하고 다시 소유권을 반환합니다.

Note: dbg! 매크로의 호출은 표준 에러 콘솔 스트림(stderr)에 출력을 하는데, 이는 표준 출력 콘솔 스트림(stdout)에 출력하는 println!과는 상반됩니다. stderrstdout에 대한 정보는 12장의 ‘표준 출력 대신 표준 에러로 에러 메시지 작성하기’절에서 더 이야기하겠습니다.

아래는 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 * scaledbg!으로 감싸 넣었는데, 이는 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 트레이트 말고도 derive 속성으로 직접 만든 타입에 유용한 동작을 추가할 수 있는 트레이트를 여럿 제공합니다. 이들 목록 및 각각의 동작은 부록 C에서 확인할 수 있으니 참고해 주세요. 또한, 여러분만의 트레이트를 직접 만들고, 이런 트레이트의 동작을 커스터마이징해서 구현하는 방법은 10장에서 배울 예정입니다. 또한 derive 외에도 여러 가지 속성들이 있습니다; 더 많은 정보는 러스트 참고 자료의 ‘속성 (attributes)’절을 살펴보세요.

만들어진 area 함수는 사각형의 면적만을 계산합니다. Rectangle 구조체를 제외한 다른 타입으로는 작동하지 않으니 Rectangle 구조체와 더 밀접하게 묶는 편이 더 유용할 겁니다. 다음에는 area 함수를 Rectangle 타입 내에 메서드 (method) 형태로 정의하여 코드를 리팩터링하는 방법을 알아보겠습니다.