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); }
변수 x
는 i32
값 5
를 가지고 있습니다. y
에는 x
의 참조자를 설정했습니다.
x
는 5
와 같음을 단언할 수 있습니다. 하지만 만일 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과 예제 15-6 간의 주요 차이점은 y
에 x
의
값을 가리키는 참조자가 아닌 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() {}
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);
}
아래는 그 결과 발생한 컴파일 에러입니다:
$ 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); }
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
로
바꿔줄 수 있는데, 이는 String
의 Deref
트레이트 구현이 그렇게 &str
을
반환하도록 했기 때문입니다. 역참조 강제 변환은 러스트가 함수와 메서드의 인수에
대해 수행해 주는 편의성 기능이고, Deref
트레이트를 구현한 타입에 대해서만
동작합니다. 이는 어떤 특정한 타입값에 대한 참조자를 함수 혹은 메서드의
인수로 전달하는데 이 함수나 메서드의 정의에는 그 매개변수 타입이 맞지
않을 때 자동으로 발생합니다. 일련의 deref
메서드 호출이 인수로 제공한
타입을 매개변수로서 필요한 타입으로 변경해 줍니다.
역참조 강제 변환은 함수와 메서드 호출을 작성하는 프로그래머들이 &
와 *
를
사용하여 수많은 명시적인 참조 및 역참조를 추가할 필요가 없도록 하기 위해
도입되었습니다. 또한 역참조 강제 변환 기능은 참조자나 스마트 포인터 둘 중 어느
경우라도 작동되는 코드를 더 많이 작성할 수 있도록 해 줍니다.
역참조 강제 변환이 실제 작동하는 것을 보기 위해서, 예제 15-8에서
정의했던 MyBox<T>
와 예제 15-10에서 추가했던 Deref
의 구현체를
이용해 봅시다. 예제 15-11은 문자열 슬라이스 매개변수를 갖는 함수의
정의를 보여줍니다:
파일명: src/main.rs
fn hello(name: &str) { println!("Hello, {name}!"); } fn main() {}
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); }
여기서는 hello
함수에 &m
인수를 넣어 호출하고 있는데, 이것이 MyBox<String>
값에 대한 참조자입니다. 예제 15-10에서 MyBox<T>
에 대한 Deref
트레이트를
구현했으므로, 러스트는 deref
를 호출하여 &MyBox<String>
을 &String
으로
바꿀 수 있습니다. Deref
에 대한 API 문서에도 나와 있듯이, 표준 라이브러리에
구현되어 있는 String
의 Deref
가 문자열 슬라이스를 반환합니다.
러스트는 다시 한번 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)[..]); }
(*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
를 얻을 수 있음을 기술하고 있습니다. 두 번째 경우는
동일한 역참조 강제 변환이 가변 참조자에 대해서도 발생함을 기술합니다.
세 번째 경우는 좀 더 까다로운데, 러스트는 가변 참조자를 불변 참조자로 강제할 수도 있습니다. 하지만 그 역은 불가능하며, 불변 참조자는 가변 참조자로 결코 강제되지 않을 것입니다. 대여 규칙에 의거하여, 가변 참조자가 있을 경우에는 그 가변 참조자가 해당 데이터에 대한 유일한 참조자여야 합니다. (그렇지 않다면, 그 프로그램은 컴파일되지 않을 것입니다.) 가변 참조자를 불변 참조자로 변경하는 것은 결코 대여 규칙을 깨트리지 않을 것입니다. 불변 참조자를 가변 참조자로 변경하는 것은 초기 불변 참조자가 해당 데이터에 대한 단 하나의 불변 참조자여야 함을 요구할 것인데, 대여 규칙으로는 이를 보장해 줄 수 없습니다. 따라서, 러스트는 불변 참조자의 가변 참조자로의 변경 가능성을 가정할 수 없습니다.