스마트 포인터를 일반 참조처럼 다루기: `Deref` 트레이트
스마트 포인터를 일반 참조처럼 다루기: Deref 트레이트
Deref 트레이트를 구현하면 역참조 연산자 *(곱셈이나 글롭 연산자와 헷갈리지 마세요)의 동작을 커스터마이즈할 수 있어요. 스마트 포인터가 일반 참조처럼 취급되도록 Deref를 구현해 두면, 참조에 대해 동작하는 코드를 스마트 포인터에도 그대로 쓸 수 있게 됩니다. 먼저 역참조 연산자가 일반 참조에서 어떻게 동작하는지 살펴보고, 우리만의 Box<T> 같은 타입을 정의해 봤을 때 왜 바로 동작하지 않는지 확인해 볼게요.
출처: The Rust Book
값을 가리키는 참조 따라가기 (Following the Reference to the Value)
일반 참조는 포인터의 한 종류인데, 포인터를 '다른 곳에 저장된 값을 가리키는 화살표'라고 생각하면 쉬워요. Listing 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 값에 대한 참조를 따라가기
변수 x는 i32 값 5를 담고 있어요. y에는 x에 대한 참조를 넣었죠. x가 5인지 단언(assert)할 수 있어요. 그런데 y가 가리키는 값을 확인하려면 *y로 참조를 따라가(역참조)서 실제 값에 접근해야 컴파일러가 값을 비교할 수 있습니다. y를 역참조하면 y가 가리키는 정수 값 5에 접근할 수 있고, 그제야 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>를 참조처럼 사용하기 (Using Box<T> Like a Reference)
Listing 15-6의 코드를 참조 대신 Box<T>를 사용하도록 다시 쓸 수 있어요. Listing 15-7에서 Box<T>에 사용한 역참조 연산자는 Listing 15-6의 참조에 사용한 역참조 연산자와 똑같이 동작합니다.
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<T>에 역참조 연산자 사용하기
Listing 15-7과 Listing 15-6의 주요 차이는, 여기서는 y를 x의 값에 대한 참조가 아니라 x의 복사된 값을 가리키는 박스 인스턴스로 만들었다는 점이에요. 마지막 단언에서는 y가 참조일 때 했던 것과 같은 방식으로 역참조 연산자를 사용해 박스의 포인터를 따라갈 수 있죠. 다음으로, 우리만의 박스 타입을 정의해 보면서 Box<T>의 어떤 점 덕분에 역참조 연산자를 쓸 수 있는지 알아볼게요.
우리만의 스마트 포인터 정의하기 (Defining Our Own Smart Pointer)
표준 라이브러리가 제공하는 Box<T>와 비슷한 래퍼 타입을 직접 만들어 보면서, 스마트 포인터 타입이 기본적으로 참조와 어떻게 다르게 동작하는지 경험해 볼게요.
참고: 우리가 만들
MyBox<T>타입과 진짜Box<T>사이에는 큰 차이가 하나 있어요. 우리 버전은 데이터를 힙에 저장하지 않습니다. 이 예시는Deref에 초점을 맞추고 있어서, 데이터가 실제로 어디에 저장되는지보다 포인터 같은 동작이 더 중요해요.
Box<T> 타입은 결국 한 개의 요소를 가진 튜플 구조체로 정의되므로, Listing 15-8은 같은 방식으로 MyBox<T> 타입을 정의해요. Box<T>에 정의된 것과 같은 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 인스턴스를 반환해요.
이제 Listing 15-7의 main 함수를 Listing 15-8에 더하고, Box<T> 대신 우리가 정의한 MyBox<T>를 쓰도록 바꿔 볼게요. Listing 15-9의 코드는 컴파일되지 않는데, Rust가 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 트레이트 구현하기 (Implementing the Deref Trait)
트레이트를 구현하려면 10장의 "타입에 트레이트 구현하기"에서 다룬 것처럼, 그 트레이트의 필수 메서드에 대한 구현을 제공해야 해요. 표준 라이브러리가 제공하는 Deref 트레이트는 self를 빌리고 내부 데이터에 대한 참조를 반환하는 deref라는 메서드 하나를 구현하도록 요구합니다. Listing 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 트레이트가 사용할 연관 타입(associated type)을 정의해요. 연관 타입은 제네릭 파라미터를 선언하는 약간 다른 방식인데, 지금은 신경 쓸 필요 없어요. 더 자세한 내용은 20장에서 다룰게요.
deref 메서드의 본문을 &self.0으로 채워서, deref가 * 연산자로 접근하고 싶은 값에 대한 참조를 반환하게 했어요. 5장의 "튜플 구조체로 다른 타입 만들기"에서 .0이 튜플 구조체의 첫 번째 값에 접근한다는 걸 기억하시면 됩니다.
Listing 15-9의 MyBox<T> 값에 *를 호출하는 main 함수는 이제 컴파일되고 단언도 통과해요!
Deref 트레이트가 없다면 컴파일러는 & 참조만 역참조할 수 있어요. deref 메서드는 컴파일러에게, Deref를 구현하는 어떤 타입의 값이든 가져와 deref 메서드를 호출해 컴파일러가 역참조할 줄 아는 참조를 얻을 수 있게 해 줍니다.
Listing 15-9에서 *y를 입력했을 때, 사실 Rust는 뒤에서 다음 코드를 실행했어요.
*(y.deref())
Rust는 * 연산자를 deref 메서드 호출과 그 뒤의 일반 역참조로 대체해서, deref 메서드를 호출해야 할지 고민하지 않게 해 줘요. 이 Rust 기능 덕분에, 일반 참조든 Deref를 구현하는 타입이든 완전히 동일하게 동작하는 코드를 쓸 수 있습니다.
deref 메서드가 값에 대한 참조를 반환하고, *(y.deref())에서 괄호 바깥의 일반 역참조가 여전히 필요한 이유는 소유권 시스템과 관련이 있어요. deref 메서드가 참조 대신 값을 직접 반환한다면, 그 값은 self 밖으로 이동(move)되어 버립니다. 우리는 이 경우나 역참조 연산자를 쓰는 대부분의 경우에서 MyBox<T> 안의 내부 값을 소유하고 싶지 않아요.
* 연산자는 코드에서 *를 쓸 때마다 deref 메서드 호출과 그 뒤의 * 연산자 호출로 딱 한 번 대체된다는 점을 기억하세요. * 연산자 치환은 무한히 재귀하지 않기 때문에, 우리는 결국 i32 타입의 데이터를 얻게 되고, 이는 Listing 15-9의 assert_eq!에서 5와 일치하게 됩니다.
함수와 메서드에서 역참조 강제 변환 사용하기 (Using Deref Coercion in Functions and Methods)
역참조 강제 변환(deref coercion)은 Deref 트레이트를 구현하는 타입에 대한 참조를 다른 타입에 대한 참조로 변환해 줘요. 예를 들어 역참조 강제 변환은 &String을 &str로 바꿀 수 있는데, String이 &str을 반환하도록 Deref를 구현하기 때문이에요. 역참조 강제 변환은 함수와 메서드의 인자에 대해 Rust가 제공하는 편의 기능으로, Deref 트레이트를 구현하는 타입에만 동작합니다. 어떤 타입의 값에 대한 참조를, 그 함수나 메서드 정의에서 파라미터 타입과 일치하지 않는 인자로 전달하면 자동으로 일어나죠. 일련의 deref 메서드 호출을 통해 우리가 전달한 타입을 파라미터가 필요로 하는 타입으로 변환해요.
역참조 강제 변환은 함수와 메서드 호출을 작성하는 프로그래머가 &와 *로 명시적인 참조와 역참조를 많이 추가하지 않아도 되도록 Rust에 추가되었어요. 이 기능 덕분에 참조든 스마트 포인터든 모두에서 동작하는 코드를 더 많이 쓸 수 있게 됩니다.
역참조 강제 변환이 실제로 어떻게 동작하는지 보려면, Listing 15-8에서 정의한 MyBox<T> 타입과 Listing 15-10에서 추가한 Deref 구현을 사용해 볼게요. Listing 15-11은 문자열 슬라이스 파라미터를 가진 함수의 정의를 보여줘요.
Filename: src/main.rs
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {}
Listing 15-11: &str 타입의 파라미터 name을 가진 hello 함수
hello("Rust");처럼 문자열 슬라이스를 인자로 hello를 호출할 수 있어요. 역참조 강제 변환 덕분에 Listing 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 호출하기
여기서는 인자 &m으로 hello를 호출하고 있는데, &m은 MyBox<String> 값에 대한 참조예요. Listing 15-10에서 MyBox<T>에 Deref 트레이트를 구현했기 때문에, Rust는 deref를 호출해 &MyBox<String>을 &String으로 바꿀 수 있어요. 표준 라이브러리는 String에 문자열 슬라이스를 반환하는 Deref 구현을 제공하고 있고, 이는 Deref의 API 문서에 나와 있어요. Rust는 다시 deref를 호출해 &String을 &str로 바꾸고, 이는 hello 함수의 정의와 일치하게 됩니다.
Rust가 역참조 강제 변환을 구현하지 않았다면, &MyBox<String> 타입의 값으로 hello를 호출하기 위해 Listing 15-12 대신 Listing 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: Rust에 역참조 강제 변환이 없었다면 작성해야 했을 코드
(*m)은 MyBox<String>을 String으로 역참조해요. 그런 다음 &와 [..]는 hello의 시그니처에 맞추기 위해 String의 전체를 문자열 슬라이스로 만듭니다. 역참조 강제 변환이 없는 이 코드는 기호가 너무 많이 등장해서 읽고, 쓰고, 이해하기 어려워요. 역참조 강제 변환은 Rust가 이런 변환을 자동으로 처리하게 해 줍니다.
관련된 타입에 Deref 트레이트가 정의되어 있으면, Rust는 타입을 분석해 파라미터의 타입과 일치하는 참조를 얻을 때까지 Deref::deref를 필요한 만큼 사용해요. Deref::deref가 삽입되어야 하는 횟수는 컴파일 타임에 결정되므로, 역참조 강제 변환을 활용해도 런타임 성능 손실은 전혀 없어요!
가변 참조로 역참조 강제 변환 다루기 (Handling Deref Coercion with Mutable References)
불변 참조에서 * 연산자를 재정의하기 위해 Deref 트레이트를 쓰는 것과 비슷하게, 가변 참조에서 * 연산자를 재정의하려면 DerefMut 트레이트를 사용하면 돼요.
Rust는 세 가지 경우에서 타입과 트레이트 구현을 발견하면 역참조 강제 변환을 수행합니다.
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를 얻을 수 있다는 뜻이에요. 두 번째 경우는 가변 참조에 대해 같은 역참조 강제 변환이 일어난다는 뜻입니다.
세 번째 경우가 좀 더 까다로워요. Rust는 가변 참조를 불변 참조로 강제 변환할 수도 있어요. 하지만 그 반대는 불가능합니다. 불변 참조는 결코 가변 참조로 강제 변환되지 않아요. 빌림 규칙 때문에, 가변 참조가 있다면 그 가변 참조는 그 데이터에 대한 유일한 참조여야 해요(그렇지 않으면 프로그램이 컴파일되지 않습니다). 가변 참조 하나를 불변 참조 하나로 변환하는 것은 절대 빌림 규칙을 어기지 않아요. 반면 불변 참조를 가변 참조로 변환하려면, 그 초기의 불변 참조가 그 데이터에 대한 유일한 불변 참조여야 하는데, 빌림 규칙은 그것을 보장하지 않습니다. 그래서 Rust는 불변 참조를 가변 참조로 변환하는 것이 가능하다고 가정할 수 없어요.
더 알아보기 (Learn more)
Deref트레이트 문서 —Deref와DerefMut의 전체 시그니처와 예시를 확인해 보세요.- 4장(소유권과 빌림), 10장(트레이트), 20장(연관 타입)을 다시 방문해
Deref가 어떻게 소유권 시스템과 맞물리는지 복습하세요.