고급 타입
러스트의 타입 시스템에는 지금까지 이름만 언급하고 아직 자세히 설명하지 않은 기능이 몇
가지 있습니다. 먼저 newtype을 일반적인 개념으로 다시 보면서 왜 타입으로서 유용한지
이야기하겠습니다. 그다음 newtype 과 비슷해 보이지만 의미는 조금 다른 타입 별칭(type
aliases)으로 넘어갑니다. 그리고 ! 타입, 동적 크기 타입도 함께 다룹니다.
newtype 패턴으로 타입 안전성과 추상화 얻기
이 절은 이미 앞의 “외부 트레이트를 newtype 패턴으로 구현하기”
절을 읽었다고 가정합니다. newtype 패턴은 거기서 다룬 것 외에도 다양한 작업에
유용합니다. 예를 들어 값이 절대로 서로 혼동되지 않도록 정적 으로 강제하거나,
값이 어떤 단위를 뜻하는지 명확히 표시하는 데 쓸 수 있습니다. 목록 20-16에서
Millimeters 와 Meters 구조체가 u32 를 감싼 newtype 으로 단위를 표현했던
것을 떠올려 보세요. 만약 어떤 함수 매개변수 타입이 Millimeters 라면, 실수로
Meters 나 그냥 u32 값을 넘기는 코드는 컴파일되지 않을 것입니다.
또한 newtype 패턴은 어떤 타입 구현의 일부 세부를 감추는 추상화 용도로도 쓸 수 있습니다. 새 타입은 private 한 내부 타입과는 다른 public API를 노출할 수 있기 때문입니다.
newtype 은 내부 구현을 숨기는 데도 유용합니다. 예를 들어 사람의 ID와 이름을 연결하는
HashMap<i32, String> 을 감싸는 People 타입을 제공할 수 있습니다. People
를 사용하는 코드는, 이름 문자열을 추가하는 메서드 같은 우리가 제공하는 public API로만
상호작용하면 됩니다. 내부적으로 이름에 i32 ID를 할당해 저장한다는 사실까지는
알 필요가 없습니다. 이것은 18장의 [“구현 세부를 숨기는 캡슐화”]
encapsulation-that-hides-implementation-details 절에서 이야기한,
구현 세부를 감추는 캡슐화를 가볍게 구현하는 방법입니다.
타입 별칭과 타입 동의어
러스트는 기존 타입에 다른 이름을 붙이는 타입 별칭(type alias) 기능도 제공합니다.
이를 위해 type 키워드를 사용합니다. 예를 들어 i32 에 대한 별칭으로
Kilometers 를 이렇게 만들 수 있습니다.
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
이제 Kilometers 는 i32 의 동의어(synonym) 가 됩니다. 즉, 목록 20-16에서 만든
Millimeters 와 Meters 처럼 완전히 별개의 새 타입은 아닙니다. Kilometers
타입 값을 가진 변수는 i32 값과 완전히 같은 타입으로 취급됩니다.
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Kilometers 와 i32 가 사실 같은 타입이기 때문에, 두 값을 더할 수도 있고
i32 를 받는 함수에 Kilometers 값을 넘길 수도 있습니다. 하지만 이 방식은 앞에서
newtype 패턴이 제공하던 타입 검사상의 이점을 주지 않습니다. 다시 말해, Kilometers
와 i32 를 코드 어딘가에서 실수로 섞더라도 컴파일러는 오류를 내 주지 않습니다.
타입 동의어의 주된 용도는 반복을 줄이는 것 입니다. 예를 들어 다음처럼 굉장히 긴 타입이 있다고 합시다.
Box<dyn Fn() + Send + 'static>
이 긴 타입을 함수 시그니처와 타입 주석 곳곳에 반복해서 적는 것은 지루하고 실수하기도 쉽습니다. 프로젝트 전체가 그런 코드로 가득 차 있다면 더욱 그렇겠죠. 목록 20-25를 생각해 봅시다.
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
Box::new(|| ())
}
}
타입 별칭을 쓰면 이런 코드는 훨씬 다루기 쉬워집니다. 목록 20-26에서는 이 긴 타입에
Thunk 라는 별칭을 붙이고, 그 타입을 쓰던 모든 자리를 더 짧은 Thunk 로
바꿉니다.
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// --snip--
}
fn returns_long_type() -> Thunk {
// --snip--
Box::new(|| ())
}
}
Thunk 라는 타입 별칭 도입하기이제 코드가 훨씬 읽기 쉽고 쓰기도 편해집니다. 또한 타입 별칭에 의미 있는 이름을 붙이면 의도 전달에도 도움이 됩니다. (Thunk 는 “나중에 평가될 코드”를 뜻하는 말이라, 나중에 실행할 클로저를 담아 두는 타입 이름으로 꽤 잘 어울립니다.)
타입 별칭은 Result<T, E> 와 함께 반복을 줄이는 데에도 자주 쓰입니다. 표준 라이브러리
의 std::io 모듈을 생각해 봅시다. I/O 연산은 자주 실패할 수 있으므로
Result<T, E> 를 많이 반환합니다. 이 라이브러리에는 가능한 모든 I/O 에러를 표현하는
std::io::Error 구조체도 있습니다. 그래서 std::io 안의 많은 함수는
다음 같은 형태의 Result<T, E> 를 반환하게 됩니다.
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
여기서 Result<..., Error> 가 아주 자주 반복됩니다. 그래서 std::io 는
다음과 같은 타입 별칭을 정의해 두었습니다.
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
이 선언이 std::io 모듈 안에 있기 때문에, 우리는 완전한 경로를 포함한
std::io::Result<T> 별칭을 사용할 수 있습니다. 이 말은 곧, E 가
std::io::Error 로 채워진 Result<T, E> 라는 뜻입니다. 그러면 Write
트레이트 함수 시그니처도 다음처럼 훨씬 짧아집니다.
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
타입 별칭은 두 가지 면에서 유용합니다. 첫째, 코드를 쓰기 쉽게 해 줍니다. 둘째,
std::io 전반에서 일관된 인터페이스를 제공하게 합니다. 그리고 이것은 어디까지나
별칭일 뿐이므로, 사실상 또 다른 Result<T, E> 입니다. 따라서
Result<T, E> 에 적용 가능한 메서드와 ? 같은 문법을 모두 그대로 사용할 수 있습니다.
절대 반환되지 않는 never 타입
러스트에는 타입 이론에서 empty type 이라고 부르는, 값이 하나도 없는 특별한 타입
! 가 있습니다. 하지만 우리는 이것을 never 타입 이라고 부르는 편을 선호합니다.
어떤 함수가 절대 반환하지 않을 때 그 반환 타입 자리에 등장하기 때문입니다. 예를
들면 다음과 같습니다.
fn bar() -> ! {
// --snip--
panic!();
}
이 코드는 “함수 bar 는 절대 반환하지 않는다”라고 읽습니다. 이런 함수는
diverging functions 라고 부릅니다. ! 타입 값은 만들 수 없으므로, bar 는
실제로 어떤 값도 반환할 수 없습니다.
그렇다면 만들 수도 없는 타입이 무슨 쓸모가 있을까요? 2장의 숫자 맞히기 게임 코드 일부를 다시 떠올려 보겠습니다. 목록 20-27에 다시 실었습니다.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
continue 로 끝나는 arm을 가진 match당시에는 여기서 일부 세부를 그냥 지나갔습니다. 6장의 [“match 제어 흐름 구문”]
the-match-control-flow-construct 절에서, match 의 각 arm은
모두 같은 타입을 반환해야 한다고 설명했습니다. 예를 들어 다음 코드는 동작하지
않습니다.
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
이 코드에서 guess 의 타입은 정수 이면서 문자열일 수는 없고, 러스트는 하나의
변수가 하나의 타입만 갖기를 요구합니다. 그렇다면 continue 는 무엇을 반환하기에
목록 20-27의 match 안에서 한 arm은 u32, 다른 arm은 continue 로 끝나도
허용되었을까요?
눈치챘겠지만, continue 의 타입은 ! 입니다. 즉, 러스트가 guess 의 타입을
계산할 때 한 arm은 u32 값을 만들고 다른 arm은 ! 값을 가진다고 봅니다.
! 는 실제 값을 가질 수 없으므로, 러스트는 guess 의 타입을 u32 로 결정합니다.
이 동작을 형식적으로 말하면, ! 타입 식은 어떤 타입으로도 강제될 수 있습니다.
목록 20-27에서 continue 로 arm을 끝낼 수 있었던 이유는, continue 가 값을
반환하는 것이 아니라 제어 흐름을 루프 맨 위로 돌려보내기 때문입니다. 따라서 Err
경우에는 guess 에 값을 대입하지 않습니다.
Never 타입은 panic! 매크로와도 함께 유용합니다. Option<T> 에 대해 호출했던
unwrap 함수 정의를 떠올려 보세요.
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
여기서도 목록 20-27의 match 와 같은 일이 일어납니다. val 은 타입 T 를 가지고,
panic! 은 타입 ! 를 가지므로, 전체 match 식 결과 타입은 T 가 됩니다.
이 코드가 동작하는 이유는 panic! 이 값을 만들어 내는 것이 아니라 프로그램을 끝내기
때문입니다. None 경우에는 unwrap 이 어떤 값도 반환하지 않으므로, 이 코드는
유효합니다.
Never 타입을 가지는 마지막 식의 예는 루프입니다.
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
여기서 루프는 끝나지 않으므로, 이 식의 값은 ! 입니다. 물론 루프 안에 break
가 있다면 이 말은 성립하지 않습니다. 루프가 결국 종료될 수 있기 때문입니다.
동적 크기 타입과 Sized 트레이트
러스트는 각 타입에 대해, 예를 들어 “이 타입 값을 저장하려면 얼마나 많은 공간이 필요한가” 같은 몇 가지 세부를 알아야 합니다. 이 때문에 러스트 타입 시스템에는 처음엔 조금 헷갈리는 구석이 하나 생깁니다. 바로 동적 크기 타입(dynamically sized types), 줄여서 DST 혹은 unsized type 입니다. 이런 타입은 값의 크기를 런타임에 가서야 알 수 있게 해 줍니다.
책 전반에서 계속 사용해 온 str 을 예로 들어 살펴보겠습니다. 맞습니다. &str 이
아니라 맨몸의 str 자체가 DST 입니다. 사용자 입력 문자열처럼, 문자열 길이는
런타임 전에는 알 수 없는 경우가 많습니다. 따라서 str 타입 변수 자체를 직접 만들
수도 없고, str 타입 인수를 직접 받는 함수도 쓸 수 없습니다. 다음 코드는
동작하지 않습니다.
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
러스트는 어떤 타입의 값이든 메모리를 얼마나 할당해야 하는지 알아야 하고, 같은 타입의
값은 같은 양의 메모리를 차지해야 합니다. 만약 이런 코드를 허용한다면, 두 str
값은 같은 타입이면서도 서로 다른 크기를 가져야 합니다. 예를 들어 s1 은 12바이트,
s2 는 15바이트가 필요할 수 있죠. 그래서 동적 크기 타입을 직접 담는 변수를 만드는
것은 불가능합니다.
그렇다면 어떻게 해야 할까요? 이 경우 정답은 이미 알고 있습니다. s1, s2 의 타입을
str 이 아니라 문자열 슬라이스 &str 로 만드는 것입니다. 4장의 [“문자열 슬라이스”]
string-slices 절에서 설명했듯, 슬라이스는 시작 위치와 길이만을
저장합니다. 따라서 &T 가 단일 값(메모리 주소 하나)이라면, 문자열 슬라이스는
str 의 주소와 길이라는 두 값 을 담습니다. 그래서 러스트는 문자열이 아무리 길어도
문자열 슬라이스 자체의 크기는 컴파일 시점에 항상 알 수 있습니다. 즉, 문자열 슬라이스의
크기는 언제나 usize 두 개분입니다. 일반적으로 동적 크기 타입은 이런 식으로 러스트에서
사용됩니다. 동적 정보를 설명하는 추가 메타데이터가 함께 붙는 것이지요. 동적 크기
타입에 대한 황금률은, 언제나 그것을 어떤 포인터 뒤에 두어야 한다는 것입니다.
str 은 다양한 포인터 타입과 함께 사용할 수 있습니다. 예를 들어 Box<str> 이나
Rc<str> 같은 식입니다. 사실 여러분은 이미 이런 패턴을 다른 동적 크기 타입과 함께
본 적이 있습니다. 바로 트레이트입니다. 모든 트레이트 역시 동적 크기 타입이며, 우리는
트레이트 이름 자체를 사용해 그것을 참조할 수 있습니다. 18장의 “트레이트 객체를
사용하여 공통 동작을 추상화하기”
절에서 이야기했듯, 트레이트를 트레이트 객체로 사용하려면 &dyn Trait,
Box<dyn Trait> (혹은 Rc<dyn Trait>) 같은 포인터 뒤에 두어야 합니다.
이런 DST 를 다루기 위해 러스트는 타입 크기를 컴파일 시점에 알 수 있는지 판단하는
Sized 트레이트를 제공합니다. 이 트레이트는 컴파일 시점에 크기를 알 수 있는 모든
타입에 자동으로 구현됩니다. 또한 러스트는 모든 제네릭 함수에 암묵적으로 Sized
바운드를 추가합니다. 즉, 다음과 같은 제네릭 함수 정의는
fn generic<T>(t: T) {
// --snip--
}
실제로는 다음과 같이 취급됩니다.
fn generic<T: Sized>(t: T) {
// --snip--
}
기본적으로 제네릭 함수는 컴파일 시점에 크기를 알 수 있는 타입에 대해서만 동작합니다. 하지만 다음과 같은 특별한 문법을 사용하면 이 제약을 완화할 수 있습니다.
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
?Sized 트레이트 바운드는 “T 는 Sized 일 수도 있고 아닐 수도 있다”는 뜻입니다.
이 문법은 “제네릭 타입은 기본적으로 컴파일 시점에 크기를 알아야 한다”는 러스트의
기본 제약을 덮어씁니다. 이런 의미의 ?Trait 문법은 Sized 에 대해서만 존재하며,
다른 트레이트에는 사용할 수 없습니다.
또한 매개변수 t 의 타입을 T 에서 &T 로 바꿨다는 점에도 주목하세요. 타입이
Sized 가 아닐 수도 있으므로, 어떤 포인터 뒤에서 사용해야 하기 때문입니다. 여기서는
참조를 선택했습니다.
다음으로는 함수와 클로저에 대해 더 깊이 들어가 보겠습니다!