Rust 기초: AsRef 트레이트로 유연한 함수 만들기
Rust로 함수를 작성하다 보면 이런 고민이 생깁니다.
&str을 받는 함수를 만들었는데, 호출하는 쪽에서 String을 넘기려면 매번 &를 붙이거나 .as_str()을 호출해야 하죠.
fn greet(name: &str) {
println!("안녕하세요, {}님!", name);
}
fn main() {
let name = String::from("Rust");
greet(&name); // & 필요
greet(name.as_str()); // 또는 as_str()
greet("Rust"); // 리터럴은 바로 됨
}
역참조 강제 덕분에 &name만으로도 잘 되긴 하지만, 직접 라이브러리 API를 설계할 때는 “이 함수가 어떤 타입을 받을 수 있는지”를 시그니처에서 명확히 드러내고 싶을 때가 있습니다.
AsRef<T> 트레이트가 바로 이런 상황을 위해 존재합니다.
AsRef 트레이트란?
AsRef<T>는 어떤 값에서 &T를 저비용으로 얻을 수 있다는 것을 나타내는 트레이트입니다.
표준 라이브러리에 다음과 같이 정의되어 있습니다.
pub trait AsRef<T: ?Sized> {
fn as_ref(&self) -> &T;
}
as_ref() 메서드 하나만 있고, &self에서 &T를 반환합니다.
여기서 T: ?Sized는 T가 str이나 [u8]처럼 컴파일 타임에 크기가 정해지지 않는 타입이어도 된다는 뜻입니다.
str은 문자열 데이터가 몇 바이트인지, [u8]은 바이트가 몇 개인지 컴파일할 때 알 수 없어서 이런 타입을 크기 미정(unsized) 타입이라고 부릅니다.
표준 라이브러리에서 String과 str이 AsRef<str>을 어떻게 구현하고 있는지 보면 이렇습니다.
// String은 내부 문자열 데이터의 &str을 반환
impl AsRef<str> for String {
fn as_ref(&self) -> &str {
self // &String → &str (역참조 강제)
}
}
// str은 자기 자신의 참조를 그대로 반환
impl AsRef<str> for str {
fn as_ref(&self) -> &str {
self
}
}
구현이 놀라울 정도로 단순하죠.
String은 역참조 강제를 통해 &str을 반환하고, str은 자기 자신을 그대로 돌려줍니다.
fn main() {
let s = String::from("hello");
let r: &str = s.as_ref(); // String → &str
println!("{}", r);
let literal = "world";
let r2: &str = literal.as_ref(); // &str → &str
println!("{}", r2);
}
hello
world
제네릭 함수에서 활용
AsRef<T>의 진짜 힘은 제네릭 함수와 합쳐질 때 나타납니다.
트레이트 바운드로 AsRef<T>를 지정하면 T로 변환 가능한 모든 타입을 받을 수 있거든요.
fn greet(name: impl AsRef<str>) {
let name = name.as_ref();
println!("안녕하세요, {}님!", name);
}
fn main() {
greet("Rust"); // &str
greet(String::from("Ferris")); // String
greet(&String::from("Crab")); // &String
}
안녕하세요, Rust님!
안녕하세요, Ferris님!
안녕하세요, Crab님!
&str, String, &String 모두 AsRef<str>을 구현하고 있어서 하나의 함수로 전부 받을 수 있습니다.
호출하는 쪽에서 타입 변환을 신경 쓸 필요가 없어지죠.
impl AsRef<str> 대신 제네릭 문법으로도 쓸 수 있습니다.
fn greet<S: AsRef<str>>(name: S) {
let name = name.as_ref();
println!("안녕하세요, {}님!", name);
}
두 형태는 똑같이 동작합니다.
표준 라이브러리의 AsRef 활용
Rust 표준 라이브러리는 AsRef를 곳곳에서 활용하고 있습니다.
가장 대표적인 곳이 파일 시스템 관련 함수입니다.
std::fs::read_to_string의 시그니처를 보면 이렇습니다.
pub fn read_to_string(path: impl AsRef<Path>) -> io::Result<String>
AsRef<Path>를 받기 때문에 Path, PathBuf, &str, String 모두 넘길 수 있습니다.
use std::fs;
fn main() {
// 전부 가능
let _ = fs::read_to_string("config.toml"); // &str
let _ = fs::read_to_string(String::from("config.toml")); // String
use std::path::{Path, PathBuf};
let _ = fs::read_to_string(Path::new("config.toml")); // &Path
let _ = fs::read_to_string(PathBuf::from("config.toml")); // PathBuf
}
이게 가능한 이유는 &str, String, Path, PathBuf 모두 AsRef<Path>를 구현하고 있기 때문입니다.
만약 AsRef가 없었다면 이 함수들의 매개변수를 &Path로 고정해야 하고, 호출하는 쪽에서 매번 Path::new()를 써야 했을 겁니다.
표준 라이브러리에서 자주 쓰이는 AsRef 조합을 정리하면 이렇습니다.
AsRef<str>—String,&str,Cow<str>등을 받을 때AsRef<Path>—PathBuf,&Path,&str,String,OsString등 경로 관련 타입을 받을 때AsRef<[u8]>—Vec<u8>,&[u8],String,&str등 바이트 슬라이스로 변환 가능한 타입을 받을 때AsRef<OsStr>— OS 문자열 관련 타입을 받을 때
커스텀 타입에 AsRef 구현하기
직접 만든 타입에도 AsRef를 구현할 수 있습니다.
사용자 이름을 감싸는 래퍼 타입을 예로 들어보겠습니다.
struct Username(String);
impl Username {
fn new(name: impl AsRef<str>) -> Self {
Username(name.as_ref().to_lowercase())
}
}
impl AsRef<str> for Username {
fn as_ref(&self) -> &str {
&self.0
}
}
fn print_greeting(name: impl AsRef<str>) {
println!("환영합니다, {}!", name.as_ref());
}
fn main() {
let user = Username::new("FERRIS");
print_greeting(&user); // Username도 AsRef<str>을 구현했으니까
print_greeting("Guest"); // &str도 당연히 됨
// AsRef<str> 덕분에 Username을 &str처럼 쓸 수 있음
println!("이름 길이: {}", user.as_ref().len());
}
환영합니다, ferris!
환영합니다, Guest!
이름 길이: 6
Username이 AsRef<str>을 구현했기 때문에 impl AsRef<str>을 받는 함수 어디에든 넘길 수 있습니다.
생성자 new도 impl AsRef<str>로 받고 있어서 &str과 String 모두 받을 수 있죠.
AsRef와 Deref의 차이
AsRef<str>과 Deref<Target = str>은 결과적으로 비슷한 일을 하는 것처럼 보입니다.
둘 다 어떤 타입에서 &str을 얻을 수 있게 해주니까요.
하지만 쓰임새가 다릅니다.
Deref는 스마트 포인터가 내부 값을 투명하게 노출하기 위한 트레이트입니다.
* 연산자와 역참조 강제를 통해 자동으로 작동하고, 하나의 타입에 대해 하나의 Target만 가질 수 있습니다.
반면 AsRef는 명시적인 변환을 위한 트레이트입니다.
같은 타입이 AsRef<str>과 AsRef<[u8]>을 동시에 구현할 수 있습니다.
fn main() {
let s = String::from("hello");
// AsRef는 여러 타입으로 변환 가능
let as_str: &str = s.as_ref();
let as_bytes: &[u8] = s.as_ref();
println!("문자열: {}", as_str);
println!("바이트: {:?}", as_bytes);
}
문자열: hello
바이트: [104, 101, 108, 108, 111]
String은 AsRef<str>도 되고 AsRef<[u8]>도 됩니다.
Deref로는 이런 다중 변환이 불가능하죠. Deref의 Target은 타입당 하나뿐이니까요.
둘의 차이를 표로 비교해보면 이렇습니다.
| 특성 | Deref | AsRef |
|---|---|---|
| 목적 | 스마트 포인터의 투명한 역참조 | 제네릭 함수의 유연한 매개변수 |
| 작동 방식 | 자동 (역참조 강제) | 명시적 (.as_ref() 호출) |
| 변환 대상 | 타입당 1개 (Target) | 타입당 여러 개 가능 |
| 적합한 상황 | Box<T>, Rc<T> 같은 래퍼 | API 설계에서 여러 타입을 받을 때 |
AsRef와 Borrow의 차이
AsRef와 비슷한 트레이트로 Borrow<T>가 있습니다. 깊이 있는 비교는 Borrow와 ToOwned 트레이트에서 다뤘으니 여기서는 핵심만 짚어볼게요.
시그니처만 보면 거의 같습니다.
// AsRef
pub trait AsRef<T: ?Sized> {
fn as_ref(&self) -> &T;
}
// Borrow
pub trait Borrow<Borrowed: ?Sized> {
fn borrow(&self) -> &Borrowed;
}
둘 다 &self에서 &T를 돌려주죠. 그런데 Borrow에는 문서에 명시된 추가 계약이 있습니다.
x.borrow()로 얻은 값은 원래 값 x와 Hash, Eq, Ord 결과가 동일해야 합니다.
이게 왜 중요한지 HashMap으로 예를 들어보겠습니다.
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(String::from("rust"), 2015);
// String 키로 삽입했지만 &str로 조회 가능
// 이게 되는 이유는 String이 Borrow<str>을 구현하고
// String과 str의 Hash가 동일하기 때문
let year = map.get("rust");
println!("{:?}", year);
}
Some(2015)
HashMap에 String으로 키를 넣었는데 &str로 조회할 수 있는 건 String: Borrow<str>이 Hash 동등성을 보장하기 때문입니다.
만약 AsRef만 있고 이 계약이 없었다면 String과 &str의 해시값이 다를 수도 있어서 키 조회가 깨질 수 있습니다.
그래서 실무에서는 이렇게 구분합니다.
| 상황 | 사용 트레이트 |
|---|---|
| 제네릭 함수에서 여러 타입을 유연하게 받고 싶을 때 | AsRef<T> |
HashMap/BTreeMap 등의 키 조회에서 쓸 때 | Borrow<T> |
대부분의 API 설계에서는 AsRef를 쓰면 됩니다.
Borrow는 컬렉션의 키 타입처럼 동등성 계약이 필요한 특수한 경우에만 직접 다루게 됩니다.
마치며
AsRef<T>는 Rust에서 유연한 API를 설계할 때 빠질 수 없는 트레이트입니다.
impl AsRef<str>이나 impl AsRef<Path> 하나면 호출하는 쪽에서 String이든 &str이든 PathBuf든 신경 쓰지 않고 넘길 수 있습니다.
From/Into가 값을 소유권째 변환하는 트레이트라면, AsRef는 참조만 빌려서 변환하는 트레이트입니다.
Deref가 자동으로 작동하는 역참조 강제라면, AsRef는 제네릭 바운드를 통해 명시적으로 유연성을 표현하는 방식이고요.
표준 라이브러리의 파일 I/O 함수나 문자열 처리 함수가 다양한 타입을 받을 수 있는 것도 다 AsRef 덕분입니다.
직접 라이브러리나 API를 설계할 때 “이 함수가 어떤 타입까지 받을 수 있으면 좋겠다”는 생각이 들면, AsRef를 떠올려 보세요.
더 자세한 내용은 std::convert::AsRef - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0