[rust] 5. 구조체로 연관된 데이터를 구조화하기

About_work·2024년 6월 23일
0

rust

목록 보기
7/16
  • 구조체 (struct) 는 여러 값을 묶고 이름을 지어서 의미 있는 묶음을 정의
  • 구조체 (struct): 객체의 데이터 속성 (attribute) 과 비슷한 것
  • 구조체의 데이터와 연관된 동작을 표현하는 메서드와 연관 함수 (associated functions) 정의 방법을 다루겠습니다.
  • 구조체와 열거형(6장에서 설명할 예정입니다)은 프로그램 도메인에 새로운 타입을 만들어서, 러스트의 컴파일 시점 타입 검사 기능을 최대한 활용하기 위한 기본 구성 요소

5.1. 구조체 정의 및 인스턴트 화

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
  • 중괄호 안에서는 필드 (field) 라고 부르는 각 구성 요소의 이름 및 타입을 정의
  • 구조체의 인스턴스 (instance) 를 생성해야 합니다.
    • 키: 값 쌍의 형태로 추가해야 합니다.
    • 이때 필드의 순서는 구조체를 정의했을 때와 동일하지 않아도 됩니다.
fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}
  • 구조체 내 특정 값은 점(.) 표기법으로 얻어올 수 있습니다.
  • 가변 인스턴스라면 같은 방식으로 특정 필드의 값을 변경할 수도 있습니다.
fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}
  • 가변성은 해당 인스턴스 전체가 지니게 됩니다. 일부 필드만 가변으로 만들 수는 없음
  • 다른 표현식과 마찬가지로,
    • 함수의 마지막 표현식에 구조체의 새 인스턴스를 생성하는 표현식을 써서
    • 해당 인스턴스를 암묵적으로 반환할 수 있습니다.
fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}
  • 위 함수의 매개변수명과 구조체 필드명이 email, username으로 동일한데 굳이 반복해서 작성하는 건 귀찮은 감이 있군요.

5.1.1. 필드 초기화 축약법 사용하기

  • 변수명과 구조체 필드명이 같을 땐, 필드 초기화 축약법 (field init shorthand) 을 사용해서 더 적은 타이핑으로 같은 기능을 구현할 수 있습니다.
fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

5.1.2. 기존 인스턴스를 이용해 새 인스턴스를 만들 때 "구조체 업데이트 문법" 사용하기

  • 다른 인스턴스에서 대부분의 값을 유지한 채로 몇 개의 값만 바꿔 새로운 인스턴스를 생성하게 되는 경우
  • 구조체 업데이트 문법 (struct update syntax)
fn main() {
    // --생략--

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}
  • .. 문법은 따로 명시된 필드를 제외한 나머지 필드를 주어진 인스턴스의 필드 값으로 설정
  • user1의 값들과 동일한 값들로 나머지를 채우려면 ..user1을 제일 끝에 적어야 하지만,
    • 다른 필드들은 구조체의 정의 내에 있는 필드들의 순서와는 상관없이 우리 마음대로 몇 개든 임의 순서로 적을 수 있습니다.
  • 아래 내용 매우 중요!!
  • 구조체 업데이트 문법이 대입처럼 =을 이용한다는 점을 주목하세요.
    • 이 구문은 데이터를 이동시킵니다.
    • 이 예제에서 user2를 생성한 이후에는 user1을 더 이상 사용할 수 없는데, 이는 user1의 username 필드의 String이 user2로 이동되기 때문
    • user2에 email과 username의 String 모두를 제공하고, user1에서는 active와 sign_in_count 값만 사용한다면, user2를 만든 이후에도 user1는 유효
      • active와 sign_in_count 모두 Copy 트레이트를 구현한 타입이므로, ‘스택에만 저장되는 데이터: 복사’절에서 살펴본 동작이 적용

5.1.3. 명명된 필드가 없는 튜플 구조체를 사용하여 다른 타입 만들기

  • 튜플과 유사한 형태의 튜플 구조체 (tuple structs) 도 지원
  • 튜플 구조체는 튜플 전체에 이름을 지어주거나 특정 튜플을 다른 튜플과 구분하고는 싶은데,
    • 그렇다고 각 필드명을 일일이 정해 일반적인 구조체 형태로 만들면 너무 장황하거나 불필요할 경우 유용
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}
  • black, origin이 서로 다른 튜플 구조체의 인스턴스이므로, 타입이 서로 달라진다는 점이 중요
  • 구조체 내 필드 구성이 같더라도 각각의 구조체는 별도의 타입이기 때문
  • Color 타입을 매개변수로 받는 함수에 Point 타입을 인수로 넘겨주는 건 불가능
  • 앞서 말한 점을 제외하면 튜플처럼 사용할 수 있습니다. 여러 부분으로 해체할 수도 있고, . 과 인덱스로 개별 값에 접근할 수도 있죠.

5.1.4. 필드가 없는 유사 유닛 구조체

  • 필드가 아예 없는 구조체를 정의할 수도 있음
  • unit-like structs
  • TODO: 유사 유닛 구조체는 어떤 타입에 대해 트레이트를 구현하고 싶지만, 타입 내부에 어떤 데이터를 저장할 필요는 없을 경우 유용
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}
  • subject 변수에 AlwaysEqual 인스턴스를 만들어 넣을 때도 비슷한 방식을 사용합니다: 정의한 이름을 적어 넣고, 중괄호나 괄호는 안 썼습니다.
  • TODO:
    • 나중에 AlwaysEqual의 모든 인스턴스는 언제나 다른 모든 타입의 인스턴스와 같도록 하는 동작을 구현하여, 이미 알고 있는 결괏값의 테스트 용도로 사용한다고 가정해 봅시다.
    • 이런 동작에는 데이터가 필요 없을 것입니다!

5.1.5. 구조체 데이터의 소유권

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
  • 예제 5-1의 User 구조체 정의에서는 의도적으로 &str 문자열 슬라이스 대신 구조체가 소유권을 갖는 String 타입을 사용했습니다.
  • 구조체 인스턴스가 유효한 동안 각 인스턴스 내의 모든 데이터가 유효하도록 만들기 위해서죠.
  • TODO: 참조자를 이용해 구조체가 소유권을 갖지 않는 데이터도 저장할 수는 있지만, 이는 10장에서 배울 라이프타임 (lifetime) 을 활용해야 합니다.
    • 라이프타임을 사용하면 구조체가 존재하는 동안에 -> 구조체 내 참조자가 가리키는 데이터의 유효함을 보장받을 수 있기 때문이죠.
  • 만약 라이프타임을 명시하지 않고 참조자를 저장하고자 하면 다음처럼 문제가 발생합니다.
    • 라이프타임이 명시돼야 한다며 컴파일러가 에러를 일으킬 겁니다.
struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

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

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 함수는 하나의 사각형의 면적을 계산하는 것을 가정하고 있지만 두 개의 매개변수를 받고 있으며, 이 두 값이 서로 연관되어 있다는 것을 명확하게 표현하는 부분은 찾아볼 수 없군요.
  • 두 값을 하나로 묶어버리면 코드의 가독성도 높아지고 관리하기도 쉬워질 겁니다.

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

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.2.3. 트레이트 파생으로 유용한 기능 추가하기

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라는, 최종 사용자를 위한 출력 형식을 사용하죠.
  • 여태 사용했던 기본 타입들은 Display가 기본적으로 구현되어 있었습니다.
  • 구조체에는 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!에 Debug trait 출력 형식을 사용하고 싶다고 전달하는 것
    • 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) 을 작성해주어야 합니다.
      • TODO: outer attribute가 뭔가요?
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

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

    println!("rect1 is {:?}", rect1);
}
  • 결과
$ 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 }
  • {:?} 대신 {:#?}를 사용했을 때의 출력 예시는 다음과 같습니다.
$ 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 trait 포맷을 사용하여 값을 출력하는 그 밖의 방법dbg! 매크로를 사용하는 것인데,
    • 이는 표현식(return 값이 있음)의 소유권을 가져와서, 표준 에러 콘솔 스트림(stderr)에 출력
    • 코드에서 dbg! 매크로를 호출한 파일 및 라인 번호를 결괏값과 함께 출력하고 다시 소유권을 반환
    • println!
      • 참조자를 사용 (소유권을 가져오지 않음)
      • 표준 출력 콘솔 스트림(stdout)에 출력
  • 아래는 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 * scale을 dbg!으로 감싸 넣었는데, 이는 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,
}
  • 정수형을 위한 Debug 형식은 그냥 그 값을 출력하는 것으로 되어 있습니다.
  • 러스트에서는 이처럼 Debug 트레이트 말고도 derive 속성으로 직접 만든 타입에 유용한 동작을 추가할 수 있는 트레이트를 여럿 제공합니다.
    • 이들 목록 및 각각의 동작은 부록 C에서 확인할 수 있으니 참고해 주세요.
  • 다음에는 area 함수를 Rectangle 타입 내에 메서드 (method) 형태로 정의하여 코드를 리팩터링하는 방법을 알아보겠습니다.

5.3. 메서드 문법

  • 하지만 메서드는 함수와 달리 구조체 컨텍스트에 정의되고 (열거형이나 트레이트 객체 안에 정의되기도 함),
  • 첫 번째 매개변수가 항상 self 라는 차이점
  • self 매개변수는 메서드를 호출하고 있는 구조체 인스턴스를 나타냅니다.

5.3.1. 메서드 정의하기

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

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

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

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}
  • Rectangle의 컨텍스트에 함수를 정의하기 위해서, Rectangle에 대한 impl (implementation, 구현) 블록을 만드는 것으로 시작
    • 이 impl 블록 내의 모든 것은 Rectangle 타입과 연관
  • 메서드 문법 (method syntax) 을 사용해 Rectangle 인스턴스의 area 메서드를 호출할 수 있음
  • area 시그니처에서는 rectangle: &Rectangle 대신 &self를 사용
  • &self는 실제로는 self: &Self를 줄인 것
    • impl 블록 내에서 Self는 impl 블록의 대상이 되는 타입의 별칭
  • 메서드는 다른 매개변수가 그런 것처럼 self의 소유권을 가져올 수도, 지금처럼 self를 불변으로 빌려올 수도, 가변으로 빌려올 수도 있습니다.
  • 만약 메서드에서 작업 중 호출한 인스턴스를 변경하고 싶다면, 첫 번째 매개변수로 &mut self를 사용하면 됩니다.
  • self라고만 작성하여 인스턴스의 소유권을 가져오도록 만드는 일은 거의 없습니다;
    • 이러한 기법은 보통 해당 메서드가 self를 다른 무언가로 변환하고, 그 이후에는 원본 인스턴스의 사용을 막고자 할 때 사용
  • impl 블록: 하나의 impl 블록 내에 이 타입의 인스턴스로 할 수 있는 모든 것들을 모아두는 것
  • 구조체의 필드 이름과 동일한 이름의 메서드를 만들 수도 있습니다. 예를 들면, width라는 중복된 이름의 메서드를 Rectangle 상에 정의할 수 있지요:
impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

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

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}
  • 필드와 동일한 이름의 메서드를 만드는 경우는 해당 필드의 값을 얻어오는 것 말고는 안하는 경우가 대부분이긴 합니다.
  • 이러한 메서드를 게터 (getter) 라고 부르는데, 러스트는 다른 언어들처럼 구조체 필드에 대한 게터를 자동으로 만들지 않습니다.
  • 필드를 비공개 (private) 로 하고 메서드는 공개 (public) 로 만들 수 있기 때문에
    • 게터는 어떤 타입의 공개 API로써 어떤 필드에 대해 읽기 전용 접근만 허용하고자 하는 경우 유용

5.3.2. 연산자는 없나요?

  • C 나 C++ 언어에서는 메서드 호출에 두 종류의 연산자가 쓰입니다.
  • TODO: 어떤 객체의 메서드를 직접 호출할 땐 .를 사용하고, 어떤 객체의 포인터를 이용해 메서드를 호출하는 중이라서 역참조가 필요할 땐 ->를 사용하죠.
    • 예를 들어서 object라는 포인터가 있다면, object->something()(*object).something()로 나타낼 수 있습니다.
  • 이 -> 연산자와 동일한 기능을 하는 연산자는 러스트에 없습니다.
  • 러스트에는 자동 참조 및 역참조 (automatic referencing and dereferencing) 라는 기능이 있고, 메서드 호출에 이 기능이 포함되어 있기 때문입니다.
  • 여러분이 object.something() 코드로 메서드를 호출하면, 러스트에서 자동으로 해당 메서드의 시그니처에 맞도록 &, &mut, *를 추가합니다.
    • 참조, 가변 참조, *을 알아서 함
  • 즉, 다음 두 표현은 서로 같은 표현입니다:
p1.distance(&p2);
(&p1).distance(&p2);
  • 첫 번째 표현이 더 깔끔하죠?
  • 이런 자동 참조 동작은 메서드의 수신자(self의 타입을 말합니다)가 명확하기 때문에 가능
  • 수신자와 메서드명을 알면 해당 메서드가 인스턴스를 읽기만 하는지(&self), 변경하는지(&mut self), 소비하는지(self) 러스트가 알아낼 수 있거든요.
  • 또한 메서드의 수신자를 러스트에서 암묵적으로 빌린다는 점은 실제로 소유권을 인체공학적으로 만드는 중요한 부분입니다.

5.3.3. 더 많은 매개변수를 가진 메서드

  • 예제 5-15: 다른 Rectangle 인스턴스를 매개변수로 갖는 can_hold 메서드를 Rectangle에 구현
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

5.3.4. 연관 함수

  • impl 블록 내에 구현된 모든 함수를 연관 함수 (associated function) 라고 부르는데, 이는 impl 뒤에 나오는 타입과 모두 연관된 함수이기 때문
  • 작하는 데 해당 타입의 인스턴스가 필요하지 않다면 self를 첫 매개변수로 갖지 않는 (따라서 메서드가 아닌) 연관 함수를 정의할 수도 있음
    • python의 static method를, rust에서는 method라고 하지 않습니다.
  • 우리는 이미 String 타입에 정의되어 있는 String::from 함수처럼 이런 종류의 함수를 사용해 봤습니다.
  • 메서드가 아닌 연관 함수는 구조체의 새 인스턴스를 반환하는 생성자로 자주 활용
    • 이 함수들은 보통 new라고 명명되는데, new는 이 언어에서 특별한 이름 혹은 키워드가 아닙니다.
impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}
  • 연관 함수를 호출할 땐 let sq = Rectangle::square(3);처럼 구조체 명에 :: 구문을 붙여서 호출
    • 연관 함수는 구조체의 네임스페이스 안에 있기 때문이죠.
  • TODO: :: 구문은 7장에서 알아볼 모듈에 의해 생성되는 네임스페이스에도 사용

5.3.5. 여러 개의 impl 블록

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
  • 위 코드에서는 impl 블록을 여러 개로 나눠야 할 이유가 전혀 없지만, impl 블록을 반드시 하나만 작성해야 할 필요는 없음을 보여드리기 위한 예시로 작성했습니다.

profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글