요약

관련된 값 몇 개를 한 묶음으로 묶고 이름을 붙인 것이 구조체다. Java의 클래스처럼 함수를 붙일 수 있지만, 상속은 없고 데이터와 함수를 따로 적는다. 함수마다 묶음을 읽기만 할지, 고칠지, 아예 가져가 버릴지를 첫 인자에 적어야 한다. 이 선택을 잘못하면 컴파일러가 막는다.

예시로 보기

사용자 한 명의 정보를 변수 넷에 따로 들고 다니면, 함수에 넘길 때마다 넷을 다 적어야 하고 순서도 헷갈린다. 넷을 한 묶음(구조체)으로 묶는다1.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,          // username: username 을 줄여 쓴 것
        email,
        sign_in_count: 1,
    }
}

매개변수와 필드의 이름이 같으면 email: email 대신 email만 쓴다(필드 이름 줄여 쓰기)1.

메모리에서 User 값 하나는 이렇게 놓인다. String 필드는 글자를 힙에 두고, 구조체 안에는 그 주소와 길이만 든다.

스택: user1
┌──────────────┐
│ active  true │
│ username ────┼──▶ 힙 "ann"
│ email ───────┼──▶ 힙 "a@x.com"
│ sign_in 1    │
└──────────────┘

일부만 바꿔 새로 만들기

기존 값에서 한두 필드만 바꾼 새 값을 만들 때는 ..을 쓴다(구조체 갱신 문법)1.

let user2 = User {
    email: String::from("b@x.com"),
    ..user1
};

..user1은 “나머지 필드는 user1에서 가져온다”는 뜻이다. 가져온다는 말은 대입 =과 같다. Copy인 필드(bool, u64)는 복사되고, String인 username은 user2로 옮겨 간다12.

user1.username ──X   (옮겨 갔다)
user2.username ─────▶ 힙 "ann"
user1.email ────────▶ 힙 "a@x.com"   (새 값을 줬으니 그대로 남는다)

그래서 user1을 통째로 쓰면 컴파일되지 않는다.

error[E0382]: use of partially moved value: `user1`
  = note: partial move occurs because `user1.username` has type `String`, which does not implement the `Copy` trait

“어? 그럼 user1은 아예 못 쓰나?” 그렇지 않다. 옮겨 가지 않은 user1.email은 계속 쓸 수 있다. String 필드 둘 다 새 값을 주면 Copy 필드만 복사되므로 user1 전체가 그대로 남는다1.

검증: 위 오류 코드와 세 경우(통째로 사용 → E0382, user1.email만 사용 → 컴파일됨, 두 String 필드 모두 새로 줌 → 컴파일됨)를 rustc로 확인했다 — 10_structs-methods_verify.rs

메서드와 self

구조체에 붙는 함수는 impl 블록 안에 쓴다. impl 안의 함수를 모두 연관 함수(associated function)라 부르고, 그중 첫 인자가 self인 것을 메서드라 부른다3.

impl Counter {
    fn new() -> Self { Self { count: 0 } }        // 연관 함수: Counter::new()
    fn get(&self) -> u32 { self.count }          // 읽기만
    fn bump(&mut self) -> u32 { self.count += 1; self.count }  // 고친다
    fn finish(self) -> String { format!("최종 {}", self.count) } // 가져간다
}

impl 안에서 Self는 “지금 impl하는 타입”의 다른 이름이다. &self는 self: &Self를 줄여 쓴 것이다3. 세 가지 첫 인자는 빌림과 참조와 소유권과 이동의 규칙을 그대로 따른다.

첫 인자 메서드가 받는 것 부른 뒤 원래 변수 쓰는 때
&self 읽기 전용 빌림 그대로 쓸 수 있다 값을 읽어 계산한다
&mut self 고칠 수 있는 빌림 그대로 쓸 수 있다. 단 변수가 let mut이어야 한다 필드를 고친다
self 값 전체(소유권) 못 쓴다(E0382) 값을 다른 것으로 바꾸거나 끝낸다

let c = Counter::new();처럼 mut 없이 만든 변수에서 c.bump()를 부르면 E0596으로 막힌다. c.finish() 뒤에 c.get()을 부르면 E0382로 막힌다.

C에는 ->와 .이 따로 있지만 Rust에는 .뿐이다. c.get()이라고 쓰면 컴파일러가 메서드의 첫 인자 모양에 맞게 &, &mut, *를 알아서 붙인다(자동 참조)3. 그래서 c.get()은 Counter::get(&c)와 같다.

한 타입에 impl 블록을 여러 개 둘 수 있다3. 트레이트 구현을 따로 묶을 때 쓴다.

생성자는 그냥 연관 함수다

Rust에는 Java의 생성자(생성자) 같은 특별한 문법이 없다. new도 키워드가 아니라 이름 짓는 관례일 뿐이다3. Rust API 지침은 이렇게 정한다4.

  • 생성자는 self 없는 연관 함수다. 대표 생성자 이름은 new다.
  • 다른 방법으로 만드는 생성자는 with_…(예: Vec::with_capacity), 다른 타입에서 바꿔 만드는 생성자는 from_…로 시작한다.
  • 인자 없는 new가 있으면 Default도 구현하고, 둘이 똑같이 동작하게 한다.

구조체의 세 모양

모양 예 필드 접근 쓰는 때
이름 붙은 필드 struct User { email: String, … } u.email 대부분
튜플 구조체 struct Meters(f64); m.0 필드 하나에 의미만 붙일 때
유닛 구조체 struct AlwaysEqual; 없음 데이터 없이 트레이트만 구현할 때

튜플 구조체는 필드 타입이 같아도 서로 다른 타입이다1. struct Meters(f64)와 struct Seconds(f64)가 있을 때 Meters를 받는 함수에 Seconds(3.0)을 주면 E0308로 막힌다. 이 성질은 타입으로 잘못된 상태 막기의 뉴타입이 된다.

유닛 구조체는 크기가 0바이트다. Meters는 안의 f64와 똑같이 8바이트다. 감싸도 메모리가 늘지 않는다.

derive로 흔한 기능 붙이기

#[derive(...)]를 구조체 위에 붙이면 컴파일러가 흔한 트레이트의 구현을 대신 써 준다. 붙일 수 있는 것은 Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Default다5. 만들어 주는 구현은 필드마다 같은 트레이트를 부른다. 그래서 필드가 모두 그 트레이트를 구현해야 붙일 수 있다5.

#[derive(Debug)]
struct Counter { count: u32 }

println!("{:?}", Counter { count: 3 });   // Counter { count: 3 }
println!("{:#?}", Counter { count: 3 });  // 여러 줄로 보기 좋게

PartialOrd를 derive하면 필드를 선언한 순서대로 비교한다5. 필드 순서를 바꾸면 정렬 결과가 바뀐다는 뜻이다. 트레이트 하나하나는 꼭 구현할 표준 트레이트에서 다룬다.

활용

  • API 서비스의 요청·응답 본문. JSON 본문은 보통 이름 붙은 필드의 구조체에 #[derive(Debug, Clone, serde::Deserialize)]를 붙여 받는다. axum으로 HTTP 서비스 만들기에서 쓴다.
  • 설정 값의 기본값. #[derive(Default)]로 기본값을 만들고 갱신 문법으로 몇 개만 바꾼다: Config { port: 8080, ..Config::default() }. clippy의 field_reassign_with_default(기본으로 켜짐)는 let mut c = Config::default(); c.port = 8080;처럼 만든 직후 필드를 다시 넣는 코드를 갱신 문법으로 바꾸라고 경고한다6.
  • new만 있고 Default가 없을 때. pub fn new() -> Self가 있는 공개 타입에 Default가 없으면 clippy의 new_without_default(기본으로 켜짐)가 경고한다6.
  • 흔한 실수.
    • 메서드마다 습관적으로 &mut self를 쓴다. 읽기만 하는 메서드까지 &mut self면, 그 값을 읽는 동안 다른 빌림을 못 만들어 호출하는 쪽이 불편해진다. 고치지 않으면 &self로 둔다.
    • 갱신 문법이 원본을 “복사”한다고 생각한다. String·Vec 같은 필드는 옮겨 간다. 원본을 계속 쓰려면 ..user1.clone()처럼 복제본에서 가져온다.
    • 필드를 모두 pub으로 연다. 바깥에서 필드를 직접 바꿀 수 있으면 “이메일에는 @가 있다” 같은 약속을 지킬 수 없다. 필드를 닫고 검사하는 생성자를 두는 법은 타입으로 잘못된 상태 막기에 있다.
  • 성능. 구조체는 필드를 모아 놓은 값이다. 따로 힙을 쓰지 않고, 메서드 호출 c.get()은 Counter::get(&c)라는 보통 함수 호출이다. 필드를 메모리에 놓는 순서는 컴파일러가 정한다. 선언 순서와 같다는 보장이 없다7. 그래서 { a: u8, b: u32, c: u8 }은 이 기계에서 8바이트인데, #[repr(C)]를 붙여 C처럼 선언 순서대로 놓게 하면 빈칸이 끼어 12바이트가 된다. C 라이브러리와 구조체를 주고받을 때만 #[repr(C)]를 붙인다.

연결

확인 문제

C1 let user2 = User { email: String::from("b@x.com"), ..user1 }; 다음 줄에 println!("{}", user1.username);을 쓰면 컴파일되는가? user1.email이면? 이유는?

답: user1.username은 안 된다(E0382). user1.email은 된다.
이유: ..user1은 지정하지 않은 필드를 대입처럼 가져온다. username은 String이라 user2로 옮겨 갔다. email은 새 값을 줬으므로 user1에서 가져가지 않았다.
흔한 오답: “갱신 문법은 복사이므로 둘 다 된다.” Copy가 아닌 필드는 옮겨 간다.

C2 장바구니 타입 Cart에 세 메서드를 만든다. 각각 첫 인자를 self, &self, &mut self 중 무엇으로 하는가? (가) 담긴 물건 가격의 합 total (나) 물건 하나를 넣는 add (다) 장바구니를 주문서 Order로 바꾸고 장바구니는 더 못 쓰게 하는 checkout

답: (가) &self (나) &mut self (다) self
이유: 읽기만 하면 &self, 필드를 고치면 &mut self, 값을 다른 것으로 바꾸고 원래 것을 못 쓰게 하려면 소유권을 가져가는 self다. (다)를 &self로 하면 주문한 뒤에도 장바구니에 물건을 더 넣을 수 있어 버그가 생긴다.

C3 위의 Counter로 다음 코드를 돌리면 무엇이 찍히는가?
let mut t = Counter::new();
t.bump();
let snapshot = t.get();
t.bump();
t.bump();
println!("{snapshot} {} {}", t.get(), t.finish());

답: 1 3 최종 3
이유: snapshot은 첫 bump 직후의 값 1을 복사해 둔 것이라 뒤의 bump에 영향받지 않는다. println!의 인자는 왼쪽부터 계산되므로 t.get()이 3을 읽은 뒤 t.finish()가 t를 가져간다.

  1. The Rust Programming Language, 5.1 Defining and Instantiating Structs — https://doc.rust-lang.org/book/ch05-01-defining-structs.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. The Rust Reference, Struct expressions, Functional update syntax — https://doc.rust-lang.org/reference/expressions/struct-expr.html ↩

  3. The Rust Programming Language, 5.3 Methods — https://doc.rust-lang.org/book/ch05-03-method-syntax.html ↩ ↩2 ↩3 ↩4 ↩5

  4. Rust API Guidelines, Predictability, C-CTOR: Constructors are static, inherent methods — https://rust-lang.github.io/api-guidelines/predictability.html ↩

  5. The Rust Programming Language, Appendix C: Derivable Traits — https://doc.rust-lang.org/book/appendix-03-derivable-traits.html ↩ ↩2 ↩3

  6. Clippy Lints, new_without_default, field_reassign_with_default — https://rust-lang.github.io/rust-clippy/master/index.html (rustc 1.99.0의 clippy-driver로 두 린트가 기본으로 경고하는 것을 확인했다) ↩ ↩2

  7. The Rust Reference, Type layout, The Rust representation과 The C representation — https://doc.rust-lang.org/reference/type-layout.html ↩