Rust 기초: Borrow와 ToOwned 트레이트
Rust로 HashMap을 다루다 보면 한 번쯤 이런 의문이 생깁니다.
키로 String을 넣었는데, 조회할 때는 &str을 넘겨도 잘 찾아지는 게 신기하지 않나요?
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(String::from("rust"), 2015);
let year = map.get("rust"); // &str로 조회
println!("{:?}", year);
}
이게 우연이 아니라 Borrow라는 트레이트가 만들어주는 보장 덕분인데요.
그리고 그 반대쪽, 즉 빌린 값을 다시 소유권이 있는 값으로 되돌리는 역할을 하는 게 ToOwned 트레이트입니다.
이 글에서는 이 두 트레이트가 어떤 계약을 맺고 있고, 왜 짝을 이루는지 알아보겠습니다. AsRef 트레이트와 비슷해 보이지만 미묘하게 다른 지점이 핵심입니다.
Borrow 트레이트란?
Borrow<T>는 어떤 값에서 &T를 빌려올 수 있다는 것을 나타내는 트레이트입니다.
표준 라이브러리에 다음과 같이 정의되어 있습니다.
pub trait Borrow<Borrowed: ?Sized> {
fn borrow(&self) -> &Borrowed;
}
시그니처만 보면 AsRef와 판박이죠.
&self에서 &Borrowed를 돌려주는 것뿐이니까요.
하지만 Borrow에는 문서에 명시된 추가 계약이 있습니다.
x.borrow()로 얻은 참조는 원래 값 x와 Hash, Eq, Ord 연산 결과가 동일해야 한다는 것입니다.
use std::borrow::Borrow;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn hash_of<T: Hash + ?Sized>(value: &T) -> u64 {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn main() {
let s = String::from("rust");
let borrowed: &str = s.borrow();
// String과 &str의 해시가 동일해야 Borrow의 계약을 지킴
println!("String 해시: {}", hash_of(&s));
println!("&str 해시 : {}", hash_of(borrowed));
println!("동등 비교 : {}", s == *borrowed);
}
String 해시: 12917457178259771758
&str 해시 : 12917457178259771758
동등 비교 : true
해시값이 같고 동등 비교도 true를 반환합니다.
이 계약이 지켜지기 때문에 HashMap의 키 타입을 섞어 쓸 수 있는 거죠.
HashMap이 Borrow를 쓰는 방식
HashMap::get의 시그니처를 보면 왜 Borrow가 필요한지 명확해집니다.
pub fn get<Q: ?Sized>(&self, k: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq,
키 타입 K가 Borrow<Q>를 구현하고 있다면, &Q로도 조회를 허용하는 구조인데요.
K = String, Q = str일 때 String: Borrow<str>이 구현되어 있으니 map.get("rust")가 동작하는 겁니다.
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(String::from("rust"), 2015);
map.insert(String::from("go"), 2009);
// 전부 가능
println!("{:?}", map.get("rust")); // &str
println!("{:?}", map.get(&String::from("go"))); // &String
}
Some(2015)
Some(2009)
만약 AsRef만 있고 해시 동등성 계약이 없었다면, String과 str의 해시가 서로 다를 수 있다는 가능성 때문에 이런 조회를 안전하게 할 수 없습니다.
키 조회가 깨질지도 모르는 채로 API를 노출할 수는 없으니까요.
ToOwned 트레이트란?
Borrow가 “소유한 값에서 참조를 빌려오는” 방향이었다면, ToOwned는 그 반대입니다.
빌린 값에서 소유권이 있는 값을 만들어내는 트레이트죠.
pub trait ToOwned {
type Owned: Borrow<Self>;
fn to_owned(&self) -> Self::Owned;
}
연관 타입 Owned가 있어서, 빌린 타입 Self가 만들어낼 “소유형”이 뭔지 지정합니다.
그리고 그 Owned 타입은 다시 Borrow<Self>를 구현해야 한다는 제약이 걸려 있습니다.
빌려주고 되돌려받는 관계가 양방향으로 성립해야 한다는 뜻이에요.
가장 익숙한 구현은 str에 대한 것입니다.
impl ToOwned for str {
type Owned = String;
fn to_owned(&self) -> String {
self.to_string()
}
}
str의 Owned 타입은 String이고, String은 Borrow<str>을 구현하죠.
짝이 완성됩니다.
fn main() {
let borrowed: &str = "hello";
let owned: String = borrowed.to_owned();
println!("빌린 값 : {}", borrowed);
println!("소유한 값: {}", owned);
}
빌린 값 : hello
소유한 값: hello
Clone과 뭐가 다른가요?
to_owned()와 .clone()은 결과만 보면 둘 다 “소유한 복사본”을 만들어내기 때문에 헷갈리기 쉽습니다.
실제로 String에서 둘은 같은 일을 합니다.
fn main() {
let s = String::from("hello");
let a = s.clone(); // Clone
let b = s.to_owned(); // ToOwned
println!("{} {} {}", s, a, b);
}
차이는 “참조에서 호출할 때” 드러납니다.
fn main() {
let slice: &str = "hello";
// str은 Clone을 직접 구현하지 않음
// slice.clone()은 &str을 복제할 뿐 String을 만들지 않음
let copied: &str = slice.clone();
let owned: String = slice.to_owned();
println!("참조 복사: {}", copied);
println!("소유 생성: {}", owned);
}
slice.clone()은 &str이라는 참조 자체를 복사할 뿐입니다.
원본 문자열 데이터는 여전히 빌린 상태죠.
반면 slice.to_owned()는 힙에 새 String을 할당해 실제 소유권이 있는 값을 만들어냅니다.
정리하면 이렇습니다.
Clone— 같은 타입의 복사본을 만듭니다.&str→&strToOwned— 빌린 타입에서 소유형 타입을 만듭니다.&str→String
Copy와 Clone이 값의 “복사 방식”을 다루는 트레이트라면, ToOwned는 “빌림에서 소유로의 타입 전환”을 다루는 트레이트입니다.
Borrow와 AsRef, 언제 뭘 쓰나요?
AsRef와 Borrow는 시그니처가 사실상 같습니다.
실무에서는 이렇게 구분하면 됩니다.
AsRef<T>— 함수가 “이 타입으로 변환 가능한 여러 입력을 받고 싶다”는 의도를 드러낼 때 씁니다. 호출 편의가 목적이고, 해시/비교 계약은 없습니다.Borrow<T>— 컬렉션의 키 조회처럼 “빌린 형태와 소유한 형태가 동등하게 동작해야” 하는 맥락에서 씁니다.
직접 라이브러리 함수의 매개변수 타입으로 고민한다면 거의 항상 AsRef가 답입니다.
Borrow는 여러분이 HashMap, BTreeMap, HashSet 같은 키-기반 컬렉션을 직접 설계하지 않는 한 바운드로 쓸 일이 드물죠.
대신 Borrow의 구현 자체는 표준 라이브러리가 미리 깔아놓은 것을 이용하게 됩니다.
ToOwned의 블랭킷 구현
표준 라이브러리는 Clone을 구현한 모든 타입에 대해 ToOwned를 자동으로 구현하는 블랭킷 구현을 제공합니다.
impl<T> ToOwned for T
where
T: Clone,
{
type Owned = T;
fn to_owned(&self) -> T {
self.clone()
}
}
그래서 i32, String, Vec<T>처럼 Clone을 구현한 타입은 별도 작업 없이 to_owned()를 쓸 수 있습니다.
이 경우 Owned = Self가 되어서 결국 clone()과 똑같이 동작하죠.
ToOwned가 진짜 의미를 갖는 건 str → String이나 [T] → Vec<T>처럼 빌린 타입과 소유한 타입이 다른 경우입니다.
이 대칭성이 바로 Cow 같은 타입이 “빌린 값도 받고, 필요하면 소유한 값을 만들어낸다”는 유연함을 가질 수 있게 해주는 기반입니다.
fn main() {
// 슬라이스 → 벡터
let slice: &[i32] = &[1, 2, 3];
let vec: Vec<i32> = slice.to_owned();
println!("{:?}", vec);
// 바이트 슬라이스 → 벡터
let bytes: &[u8] = b"rust";
let owned: Vec<u8> = bytes.to_owned();
println!("{:?}", owned);
}
[1, 2, 3]
[114, 117, 115, 116]
마치며
Borrow와 ToOwned는 겉보기엔 조용한 트레이트지만, Rust의 소유권 모델에서 “빌린 값과 소유한 값의 대칭 관계”를 타입 시스템으로 보장해주는 중요한 장치입니다.
Borrow가 있기에 HashMap<String, V>에 &str로 조회할 수 있고, ToOwned가 있기에 &str에서 필요할 때만 String을 만들어낼 수 있죠.
그리고 이 두 트레이트가 진가를 드러내는 곳이 바로 Cow<'a, B>입니다.
Cow는 B: ToOwned라는 한 줄의 바운드로 “빌린 채 쓰다가 변경이 필요해지면 그때 소유하라”는 패턴을 타입으로 표현하는데, 이게 가능한 이유가 전부 이 글에서 살펴본 짝 관계 덕분이에요.
AsRef 트레이트와의 미묘한 차이를 한번 더 곱씹어 보시면 표준 라이브러리 API를 읽는 시야가 훨씬 넓어질 겁니다.
더 자세한 내용은 std::borrow::Borrow - Rust 공식 문서와 std::borrow::ToOwned - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0