Rust 기초: AsRef 트레이트로 유연한 함수 만들기

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: ?SizedTstr이나 [u8]처럼 컴파일 타임에 크기가 정해지지 않는 타입이어도 된다는 뜻입니다. str은 문자열 데이터가 몇 바이트인지, [u8]은 바이트가 몇 개인지 컴파일할 때 알 수 없어서 이런 타입을 크기 미정(unsized) 타입이라고 부릅니다.

표준 라이브러리에서 StringstrAsRef<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

UsernameAsRef<str>을 구현했기 때문에 impl AsRef<str>을 받는 함수 어디에든 넘길 수 있습니다. 생성자 newimpl AsRef<str>로 받고 있어서 &strString 모두 받을 수 있죠.

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]

StringAsRef<str>도 되고 AsRef<[u8]>도 됩니다. Deref로는 이런 다중 변환이 불가능하죠. DerefTarget은 타입당 하나뿐이니까요.

둘의 차이를 표로 비교해보면 이렇습니다.

특성DerefAsRef
목적스마트 포인터의 투명한 역참조제네릭 함수의 유연한 매개변수
작동 방식자동 (역참조 강제)명시적 (.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()로 얻은 값은 원래 값 xHash, 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)

HashMapString으로 키를 넣었는데 &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 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord