Deref 트레이트로 스마트 포인터를 보통의 참조자처럼 취급하기

Deref 트레이트를 구현하면 역참조 연산자 (dereference operator) * 동작의 커스터마이징을 가능하게 해 줍니다. (곱하기 혹은 글롭 연산자와 헷갈리지 마세요.) 스마트 포인터가 보통의 참조자처럼 취급될 수 있도록 Deref를 구현함으로써, 참조자에 작동하도록 작성된 코드가 스마트 포인터에도 사용되게 할 수 있습니다.

먼저 역참조 연산자가 보통의 참조자에 대해 동작하는 방식을 살펴보고, 그런 다음 Box<T>처럼 동작하는 커스텀 타입의 정의를 시도해 보면서, 역참조 연산자가 새로 정의한 타입에서는 참조자처럼 동작하지 않는 이유를 알아보겠습니다. Deref 트레이트를 구현하는 것이 스마트 포인터가 참조자와 유사한 방식으로 동작하도록 하는 원리를 탐구해 볼 것입니다. 그리고서 러스트의 역참조 강제 (deref corecion) 기능과 이 기능이 참조자 혹은 스마트 포인터와 함께 동작하도록 하는 방식을 살펴보겠습니다.

Note: 이제부터 만들려고 하는 MyBox<T> 타입과 실제 Box<T> 간에는 한 가지 큰 차이점이 있습니다: 우리 버전은 데이터를 힙에 저장하지 않습니다. 이 예제는 Deref에 초점을 맞추고 있으므로, 데이터가 어디에 저장되는가 하는 것은 포인터 같은 동작에 비해 덜 중요합니다.

포인터를 따라가서 값 얻기

보통의 참조자는 포인터의 한 종류이고, 포인터에 대해 생각하는 방법 하나는 어딘가에 저장된 값을 가리키는 화살표처럼 생각하는 것입니다. 예제 15-6에서는 i32 값의 참조자를 생성하고는 역참조 연산자를 사용하여 참조자를 따라가서 값을 얻어냅니다:

파일명: src/main.rs

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

예제 15-6: 역참조 연산자를 사용하여 i32 값에 대한 참조자 따라가기

변수 xi325를 가지고 있습니다. y에는 x의 참조자를 설정했습니다. x5와 같음을 단언할 수 있습니다. 하지만 만일 y 안의 값에 대하여 단언하고 싶다면, *y를 사용하여 참조자를 따라가서 이 참조자가 가리키고 있는 값을 얻어 내어 (그래서 역참조라고 합니다) 컴파일러가 실제 값을 비교할 수 있도록 해야 합니다. 일단 y를 역참조하면, 5와 비교 가능한 y가 가리키고 있는 정숫값에 접근하게 됩니다.

대신 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}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            f32
            f64
            i128
            i16
            i32
            i64
            i8
            isize
          and 6 others
  = 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` due to previous error

숫자와 숫자에 대한 참조자를 비교하는 것은 이 둘이 서로 다른 타입이므로 허용되지 않습니다. *를 사용하여 해당 참조자를 따라가서 그것이 가리키고 있는 값을 얻어내야 합니다.

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

예제 15-6의 코드는 참조자 대신 Box<T>를 사용하여 다시 작성할 수 있습니다; 예제 15-7의 Box<T>에 사용된 역참조 연산자는 예제 15-6의 참조자에 사용된 역참조 연산자와 동일한 방식으로 기능합니다:

파일명: src/main.rs

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

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

예제 15-7: Box<i32>에 역참조 연산자 사용하기

여기서 예제 15-7과 예제 15-6 간의 주요 차이점은 yx의 값을 가리키는 참조자가 아닌 x의 복제된 값을 가리키는 Box<T>의 인스턴스를 설정했다는 것입니다. 마지막 단언문에서 y가 참조자일 때 했던 것과 동일한 방식으로 박스 포인터 앞에 역참조 연산자를 사용할 수 있습니다. 다음으로, 자체 박스 타입을 정의함으로써 Box<T>가 역참조 연산자의 사용을 가능하게끔 해주는 특별함이 무엇인지 탐구해 보겠습니다.

자체 스마트 포인터 정의하기

표준 라이브러리가 제공하는 Box<T>와 유사한 스마트 포인터를 만들어 보면서 스마트 포인터는 어떻게 기본적으로 참조자와는 다르게 동작하는지 경험해 봅시다. 그다음 역참조 연산자의 사용 기능을 추가하는 방법을 살펴보겠습니다.

Box<T> 타입은 궁극적으로 하나의 요소를 가진 튜플 구조체로 정의되므로, 예제 15-8에서 MyBox<T> 타입을 동일한 방식으로 정의했습니다. 또한 Box<T>에 정의된 new 함수와 짝을 이루는 new 함수도 정의하겠습니다.

파일명: src/main.rs

struct MyBox<T>(T);

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

fn main() {}

예제 15-8: MyBox<T> 타입 정의하기

MyBox라는 이름의 구조체를 정의하고 제네릭 매개변수 T를 선언했는데, 이는 모든 타입의 값을 가질 수 있도록 하고 싶기 때문입니다. MyBox 타입은 T 타입의 요소 하나를 가진 튜플 구조체입니다. MyBox::new 함수는 T 타입의 매개변수 하나를 받아서 그 값을 들고 있는 MyBox 인스턴스를 반환합니다.

예제 15-7의 main 함수를 예제 15-8에 추가하고 Box<T> 대신 우리가 정의한 MyBox<T> 타입을 사용하도록 고쳐봅시다. 러스트는 MyBox를 역참조하는 방법을 모르기 때문에 예제 15-9의 코드는 컴파일되지 않을 것입니다.

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

예제 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);
   |                   ^^

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

MyBox<T> 타입은 역참조 될 수 없는데, 그 이유는 이 타입에 그런 기능을 구현한 적이 없기 때문입니다. * 연산자로 역참조를 할 수 있게 하려면 Deref 트레이트를 구현해야 합니다.

Deref 트레이트를 구현하여 임의의 타입을 참조자처럼 다루기

10장의 ‘특정 타입에 트레이트 구현하기’절에서 논의한 바와 같이, 어떤 트레이트를 구현하기 위해서는 그 트레이트가 요구하는 메서드에 대한 구현체를 제공해야 합니다. 표준 라이브러리가 제공하는 Deref 트레이트는 deref라는 이름의 메서드 하나를 구현하도록 요구하는데, 이 함수는 self를 빌려와서 내부 데이터의 참조자를 반환합니다. 예제 15-10은 MyBox의 정의에 덧붙여 Deref의 구현체를 담고 있습니다:

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

예제 15-10: MyBox<T>에 대한 Deref 구현하기

type Target = T; 문법은 Deref 트레이트가 사용할 연관 타입 (associated type) 을 정의합니다. 연관 타입은 제네릭 매개변수를 선언하는 약간 다른 방식이지만, 지금은 여기에 신경 쓰지 않아도 됩니다; 이에 대해서는 19장에서 더 자세히 다룰 예정입니다.

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를 구현한 어떤 타입의 참조자를 다른 타입의 참조자로 바꿔줍니다. 예를 들어, 역참조 강제는 &String&str로 바꿔줄 수 있는데, 이는 StringDeref 트레이트 구현이 그렇게 &str을 반환하도록 했기 때문입니다. 역참조 강제는 러스트가 함수와 메서드의 인수에 대해 수행해 주는 편의성 기능이고, Deref 트레이트를 구현한 타입에 대해서만 동작합니다. 이는 어떤 특정한 타입값에 대한 참조자를 함수 혹은 메서드의 인수로 전달하는데 이 함수나 메서드의 정의에는 그 매개변수 타입이 맞지 않을 때 자동으로 발생합니다. 일련의 deref 메서드 호출이 인수로 제공한 타입을 매개변수로서 필요한 타입으로 변경해 줍니다.

역참조 강제는 함수와 메서드 호출을 작성하는 프로그래머들이 &*를 사용하여 수많은 명시적인 참조 및 역참조를 추가할 필요가 없도록 하기 위해 도입되었습니다. 또한 역참조 강제 기능은 참조자나 스마트 포인터 둘 중 어느 경우라도 작동되는 코드를 더 많이 작성할 수 있도록 해 줍니다.

역참조 강제가 실제 작동하는 것을 보기 위해서, 예제 15-8에서 정의했던 MyBox<T>와 예제 15-10에서 추가했던 Deref의 구현체를 이용해 봅시다. 예제 15-11은 문자열 슬라이스 매개변수를 갖는 함수의 정의를 보여줍니다:

파일명: src/main.rs

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

fn main() {}

예제 15-11: &str 타입의 name 매개변수를 갖는 hello 함수

hello 함수는 이를테면 hello("Rust");와 같이 문자열 슬라이스를 인수로 호출될 수 있습니다. 예제 15-12에서 보는 바와 같이, 역참조 강제는 MyBox<String> 타입 값에 대한 참조자로 hello의 호출을 가능하게 만들어 줍니다:

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

예제 15-12: 역참조 강제에 의해 작동되는, MyBox<String> 값에 대한 참조자로 hello 호출하기

여기서는 hello 함수에 &m 인수를 넣어 호출하고 있는데, 이것이 MyBox<String> 값에 대한 참조자입니다. 예제 15-10에서 MyBox<T>에 대한 Deref 트레이트를 구현했으므로, 러스트는 deref를 호출하여 &MyBox<String>&String으로 바꿀 수 있습니다. Deref에 대한 API 문서에도 나와 있듯이, 표준 라이브러리에 구현되어 있는 StringDeref가 문자열 슬라이스를 반환합니다. 러스트는 다시 한번 deref를 호출하여 &String&str로 바꾸는데, 이것이 hello 함수의 정의와 일치하게 됩니다.

만일 러스트에 역참조 강제가 구현되어 있지 않았다면, &MyBox<String> 타입의 값으로 hello를 호출하기 위해서는 예제 15-12의 코드 대신 예제 15-13의 코드를 작성했어야 할 것입니다:

파일명: 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)[..]);
}

예제 15-13: 러스트에 역참조 강제가 없었을 경우 작성했어야 할 코드

(*m)MyBox<String>String으로 역참조해 줍니다. 그런 다음 &[..]가 전체 문자열과 동일한 String의 문자열 슬라이스를 얻어와서 hello 시그니처와 일치되도록 합니다. 역참조 강제가 없는 코드는 이 모든 기호가 수반된 상태가 되어 읽기도, 쓰기도, 이해하기도 더 힘들어집니다. 역참조 강제는 러스트가 프로그래머 대신 이러한 변환을 자동으로 다룰 수 있도록 해 줍니다.

인수로 넣어진 타입에 대해 Deref 트레이트가 정의되어 있다면, 러스트는 해당 타입을 분석하고 Deref::deref를 필요한 만큼 사용하여 매개변수 타입과 일치하는 참조자를 얻을 것입니다. Deref::deref가 추가되어야 하는 횟수는 컴파일 타임에 분석되므로, 역참조 강제의 이점을 얻는 데에 관해서 어떠한 런타임 페널티도 없습니다!

역참조 강제가 가변성과 상호작용하는 법

Deref 트레이트를 사용하여 불변 참조자에 대한 *를 오버라이딩하는 방법과 비슷한 방식으로, DerefMut 트레이트를 사용하여 가변 참조자에 대한 * 연산자를 오버라이딩할 수 있습니다.

러스트는 다음의 세 가지 경우에 해당하는 타입과 트레이트 구현을 찾았을 때 역참조 강제를 수행합니다:

  • T: Deref<Target=U>일 때 &T에서 &U
  • T: DerefMut<Target=U>일 때 &mut T에서 &mut U
  • T: Deref<Target=U>일 때 &mut T에서 &U

처음 두 가지 경우는 두 번째가 가변성을 구현했다는 점을 제외하면 동일합니다. 첫 번째 경우는 어떤 &T가 있는데, T가 어떤 타입 U에 대한 Deref를 구현했다면, 명료하게 &U를 얻을 수 있음을 기술하고 있습니다. 두 번째 경우는 동일한 역참조 강제가 가변 참조자에 대해서도 발생함을 기술합니다.

세 번째 경우는 좀 더 까다로운데, 러스트는 가변 참조자를 불변 참조자로 강제할 수도 있습니다. 하지만 그 역은 불가능하며, 불변 참조자는 가변 참조자로 결코 강제되지 않을 것입니다. 대여 규칙에 의거하여, 가변 참조자가 있을 경우에는 그 가변 참조자가 해당 데이터에 대한 유일한 참조자여야 합니다. (그렇지 않다면, 그 프로그램은 컴파일되지 않을 것입니다.) 가변 참조자를 불변 참조자로 변경하는 것은 결코 대여 규칙을 깨트리지 않을 것입니다. 불변 참조자를 가변 참조자로 변경하는 것은 초기 불변 참조자가 해당 데이터에 대한 단 하나의 불변 참조자여야 함을 요구할 것인데, 대여 규칙으로는 이를 보장해 줄 수 없습니다. 따라서, 러스트는 불변 참조자의 가변 참조자로의 변경 가능성을 가정할 수 없습니다.