Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

스마트 포인터를 일반 참조처럼 다루기

Deref 트레이트를 구현하면 역참조 연산자 * 의 동작을 원하는 대로 바꿀 수 있습니다(* 는 곱셈 연산자나 글롭 연산자와는 다른 의미라는 점을 기억하세요). 스마트 포인터가 일반 참조처럼 취급되도록 Deref 를 구현하면, 참조를 대상으로 쓴 코드를 스마트 포인터와도 함께 쓸 수 있게 됩니다.

먼저 역참조 연산자가 일반 참조와 함께 어떻게 동작하는지 보겠습니다. 그런 다음 Box<T> 처럼 동작하는 사용자 정의 타입을 만들어 보고, 왜 새 타입에 대해서는 역참조 연산자가 참조처럼 바로 동작하지 않는지 확인할 것입니다. 이어서 Deref 트레이트를 구현하면 왜 스마트 포인터도 참조와 비슷한 방식으로 동작할 수 있는지 살펴봅니다. 마지막으로 러스트의 역참조 강제(deref coercion) 기능이 참조와 스마트 포인터를 둘 다 자연스럽게 다루게 해 주는 방식도 보겠습니다.

참조가 가리키는 값 따라가기

일반 참조는 포인터의 한 종류이며, 포인터를 “어딘가 다른 곳에 저장된 값을 가리키는 화살표”라고 생각할 수 있습니다. 목록 15-6에서는 i32 값에 대한 참조를 만든 뒤, 역참조 연산자를 사용해 그 참조가 가리키는 실제 값을 따라갑니다.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-6: 역참조 연산자로 i32 값을 가리키는 참조 따라가기

변수 xi325 를 들고 있습니다. 우리는 yx 에 대한 참조로 설정합니다. 따라서 x == 5 라는 비교는 바로 할 수 있습니다. 하지만 y 가 가리키는 값에 대해 단언하고 싶다면, 역참조(*y)를 사용해 참조가 가리키는 실제 값까지 따라가야 합니다. 그래야 컴파일러가 실제 값을 비교할 수 있습니다. y 를 역참조하면 y 가 가리키는 정수 값에 접근할 수 있고, 그것을 5 와 비교할 수 있습니다.

반대로 assert_eq!(5, y); 라고 쓰면 다음과 같은 컴파일 오류가 납니다.

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

숫자와 “숫자에 대한 참조”는 서로 다른 타입이기 때문에 비교할 수 없습니다. 참조가 가리키는 값으로 들어가려면 역참조 연산자를 써야 합니다.

Box<T> 를 참조처럼 사용하기

목록 15-6의 코드를 참조 대신 Box<T> 를 사용하도록 다시 써도, 목록 15-7처럼 역참조 연산자는 똑같이 동작합니다.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-7: Box<i32> 에 역참조 연산자 사용하기

목록 15-7과 15-6의 주요 차이는, 여기서는 yx 값을 가리키는 참조가 아니라 x 값을 복사해서 힙에 저장한 박스로 만들었다는 점입니다. 마지막 단언에서도 마찬가지로, 박스 포인터를 역참조해 실제 값을 따라갈 수 있습니다. 이제 우리만의 박스 타입을 정의하면서, 왜 Box<T> 에서 이런 역참조 동작이 가능한지 살펴봅시다.

우리만의 스마트 포인터 정의하기

표준 라이브러리의 Box<T> 와 비슷한 래퍼 타입을 직접 만들어 보면, 스마트 포인터 타입이 기본적으로 참조와 어떻게 다르게 동작하는지 체감할 수 있습니다. 그 뒤에 역참조 연산자를 사용할 수 있게 하는 방법도 보겠습니다.

Note: 우리가 곧 만들 MyBox<T> 는 실제 Box<T> 와 큰 차이가 하나 있습니다. 우리의 버전은 데이터를 힙에 저장하지 않습니다. 이 예제는 Deref 에 초점을 맞추기 때문에, 데이터가 실제로 어디 저장되는지는 “포인터처럼 동작한다”는 성질만큼 중요하지 않습니다.

Box<T> 는 결국 요소 하나를 담는 튜플 구조체로 정의되므로, 목록 15-8에서는 MyBox<T> 를 똑같은 방식으로 정의합니다. 또한 Box<T> 에 정의된 new 함수와 비슷한 new 함수도 만들 것입니다.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}
Listing 15-8: MyBox<T> 타입 정의하기

우리는 MyBox 라는 구조체를 정의하고, 어떤 타입이든 담을 수 있도록 제네릭 매개변수 T 를 선언합니다. MyBox 는 타입 T 요소 하나를 담는 튜플 구조체입니다. MyBox::new 함수는 T 타입 인수 하나를 받아, 그 값을 담은 MyBox 인스턴스를 반환합니다.

이제 목록 15-7의 main 함수를 목록 15-8에 추가해, Box<T> 대신 우리가 정의한 MyBox<T> 타입을 사용하도록 바꿔 보겠습니다. 목록 15-9의 코드는 컴파일되지 않는데, 그 이유는 러스트가 MyBox 를 어떻게 역참조해야 할지 모르기 때문입니다.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-9: 참조와 Box<T> 를 썼던 것과 같은 방식으로 MyBox<T> 를 사용하려 시도하기

컴파일 오류는 다음과 같습니다.

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^ can't be dereferenced

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

우리의 MyBox<T> 는 아직 역참조될 수 없습니다. 그 기능을 직접 구현하지 않았기 때문입니다. * 연산자로 역참조를 가능하게 하려면 Deref 트레이트를 구현해야 합니다.

Deref 트레이트 구현하기

10장의 “타입에 트레이트 구현하기” 절에서 이야기했듯, 어떤 트레이트를 구현하려면 그 트레이트가 요구하는 메서드를 직접 구현해야 합니다. 표준 라이브러리가 제공하는 Deref 트레이트는 self 를 빌리고 내부 데이터에 대한 참조를 반환하는 deref 메서드 하나를 구현하라고 요구합니다. 목록 15-10은 MyBox<T> 정의에 추가할 Deref 구현입니다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-10: MyBox<T>Deref 구현하기

type Target = T; 문법은 Deref 트레이트가 사용할 연관 타입을 정의합니다. 연관 타입은 제네릭 매개변수를 선언하는 또 다른 방식이지만, 지금은 굳이 신경 쓰지 않아도 됩니다. 20장에서 더 자세히 다룹니다.

우리는 deref 메서드 본문을 &self.0 으로 채웠습니다. 즉, deref* 연산자로 접근하고 싶은 내부 값에 대한 참조를 반환합니다. 5장의 “튜플 구조체로 서로 다른 타입 만들기” 절에서 보았듯, .0 은 튜플 구조체 첫 번째 값에 접근하는 문법입니다. 이제 목록 15-9처럼 MyBox<T> 값에 * 를 적용하는 main 함수도 컴파일되고, 단언도 통과합니다!

Deref 트레이트가 없다면, 컴파일러는 오직 & 참조에 대해서만 역참조를 수행할 수 있습니다. deref 메서드는, 어떤 타입이 Deref 를 구현하고 있을 때 그 값에서 deref 를 호출해 컴파일러가 역참조할 수 있는 참조를 얻는 방법을 제공합니다.

우리가 목록 15-9에서 *y 를 적었을 때, 러스트는 내부적으로 사실 다음 코드를 실행한 것입니다.

*(y.deref())

러스트는 * 연산자를 deref 메서드 호출과 일반 역참조 조합으로 바꿔 주기 때문에, 우리가 일일이 deref 를 직접 호출할지 고민할 필요가 없습니다. 이 기능 덕분에 참조가 있을 때든 Deref 를 구현한 타입이 있을 때든 똑같이 동작하는 코드를 쓸 수 있습니다.

deref 메서드가 값 그 자체가 아니라 값에 대한 참조를 반환하고, *(y.deref()) 처럼 바깥에 일반 역참조 연산이 여전히 필요한 이유는 소유권 시스템과 관련이 있습니다. 만약 deref 가 참조가 아니라 값을 직접 반환했다면, 그 값은 self 안에서 이동되어 나와 버렸을 것입니다. 대부분의 상황에서 우리는 MyBox<T> 안의 값을 역참조하는 것만 원할 뿐, 그 내부 값의 소유권을 가져오고 싶은 것은 아닙니다.

또 하나 기억할 점은, 우리 코드에서 * 를 사용할 때마다 러스트는 deref 메서드 호출과 그 뒤의 한 번의 일반 역참조로 치환할 뿐이며, 이 치환이 끝없이 재귀적으로 반복되지는 않는다는 것입니다. 따라서 결국 우리는 i32 타입 데이터를 얻게 되고, 목록 15-9의 assert_eq!5 와 타입이 맞게 됩니다.

함수와 메서드에서 역참조 강제 사용하기

역참조 강제(deref coercion)Deref 트레이트를 구현한 타입에 대한 참조를, 또 다른 타입에 대한 참조로 변환하는 기능입니다. 예를 들어 StringDeref 트레이트를 구현해 &str 를 반환하므로, 역참조 강제는 &String&str 로 바꿀 수 있습니다. 역참조 강제는 함수와 메서드 인수에 대해 러스트가 자동으로 수행하는 편의 기능이며, 오직 Deref 트레이트를 구현한 타입에서만 작동합니다. 함수나 메서드 정의의 매개변수 타입과 우리가 넘긴 참조 타입이 다를 때, 러스트는 deref 메서드를 연쇄 호출하여 필요한 타입으로 바꿔 줍니다.

역참조 강제가 러스트에 들어간 이유는, 프로그래머가 함수와 메서드를 호출할 때 &* 를 일일이 많이 적지 않아도 되게 하기 위해서입니다. 또한 이 기능 덕분에 참조로도, 스마트 포인터로도 동작하는 더 많은 코드를 작성할 수 있습니다.

이 기능이 실제로 어떻게 동작하는지 보기 위해, 목록 15-8에서 정의한 MyBox<T> 와 목록 15-10의 Deref 구현을 사용해 보겠습니다. 목록 15-11은 문자열 슬라이스 매개변수를 받는 함수 정의를 보여 줍니다.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: 타입이 &str 인 매개변수 name 을 받는 hello 함수

예를 들어 hello("Rust"); 처럼 문자열 슬라이스를 인수로 넘겨 hello 함수를 호출할 수 있습니다. 그리고 역참조 강제 덕분에, 목록 15-12처럼 MyBox<String> 값에 대한 참조를 넘겨도 hello 를 호출할 수 있습니다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
Listing 15-12: 역참조 강제 덕분에 MyBox<String> 참조로 hello 호출하기

여기서 우리는 &mhello 함수에 넘기는데, 이 값은 MyBox<String> 에 대한 참조입니다. 목록 15-10에서 MyBox<T>Deref 트레이트를 구현했기 때문에, 러스트는 deref 를 호출하여 &MyBox<String>&String 으로 바꿀 수 있습니다. 그리고 표준 라이브러리는 String 에 대해서도 Deref 를 구현해 문자열 슬라이스를 반환하도록 해 두었습니다. 이 구현은 Deref 문서에서도 볼 수 있습니다. 그래서 러스트는 다시 한 번 deref 를 호출해 &String&str 로 바꾸고, 그 결과가 hello 함수의 시그니처와 맞아떨어집니다.

만약 러스트에 역참조 강제가 없었다면, 타입이 &MyBox<String> 인 값을 이용해 hello 를 호출하려면 목록 15-12 대신 목록 15-13처럼 써야 했을 것입니다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
Listing 15-13: 러스트에 역참조 강제가 없었다면 직접 써야 했을 코드

(*m)MyBox<String> 을 역참조해 String 으로 바꾸고, 그다음 &[..] 로 그 전체 String 에 대한 슬라이스를 만들어 hello 시그니처와 맞춥니다. 역참조 강제가 없는 이 코드는 기호가 많아져서 읽기 어렵고, 쓰기도 어렵고, 이해하기도 어렵습니다. 역참조 강제 덕분에 러스트가 이런 변환을 자동으로 처리해 줍니다.

관련 타입들에 대해 Deref 트레이트가 정의되어 있으면, 러스트는 타입을 분석해 매개변수 타입과 맞는 참조를 얻을 때까지 필요한 횟수만큼 Deref::deref 를 호출합니다. 이 Deref::deref 삽입 횟수는 컴파일 시점에 결정되므로, 역참조 강제를 사용해도 런타임 비용은 없습니다.

가변 참조에서의 역참조 강제

불변 참조에서 Deref 트레이트로 * 동작을 바꿀 수 있는 것과 비슷하게, 가변 참조에서는 DerefMut 트레이트를 사용해 * 동작을 바꿀 수 있습니다.

러스트는 세 가지 경우에 타입과 트레이트 구현을 보고 역참조 강제를 수행합니다.

  1. T: Deref<Target = U> 일 때 &T 에서 &U
  2. T: DerefMut<Target = U> 일 때 &mut T 에서 &mut U
  3. T: Deref<Target = U> 일 때 &mut T 에서 &U

첫 두 경우는 두 번째가 가변성까지 포함한다는 점만 빼면 거의 같습니다. 첫 번째 규칙은 &T 가 있고 T 가 어떤 타입 UDeref 된다면, &U 를 투명하게 얻을 수 있다는 뜻입니다. 두 번째 규칙은 가변 참조에서도 같은 일이 일어난다고 말합니다.

세 번째 규칙은 조금 더 미묘합니다. 러스트는 가변 참조를 불변 참조로도 강제할 수 있습니다. 하지만 그 반대는 절대 허용되지 않습니다. 대여 규칙 때문에, 가변 참조가 있다면 그 가변 참조는 그 데이터에 대한 유일한 참조여야 합니다(그렇지 않으면 프로그램은 애초에 컴파일되지 않습니다). 따라서 가변 참조 하나를 불변 참조 하나로 바꾸는 것은 대여 규칙을 깨지 않습니다. 하지만 불변 참조를 가변 참조로 바꾸려면, 그 불변 참조가 사실상 유일한 참조여야 한다는 추가 가정이 필요합니다. 대여 규칙은 그런 보장을 하지 않기 때문에, 러스트는 그런 강제를 허용할 수 없습니다.