고급 타입
러스트의 타입 시스템에는 지금까지 언급은 했지만 아직 논의하지는 않은
몇 가지 기능이 있습니다. 먼저 뉴타입이 타입으로써 유용한 이유를 살펴보면서
뉴타입에 대해 전반적으로 논의하겠습니다. 그런 다음 뉴타입과 비슷하지만 의미는
약간 다른 기능인 타입 별칭(type alias)에 대해 살펴보겠습니다. 또한 !
타입과 동적 크기 타입(dynamically sized type)에 대해서도 설명합니다.
타입 안전성과 추상화를 위한 뉴타입 패턴 사용하기
Note: 이 절은 여러분이 이전에 나온 ‘뉴타입 패턴을 사용하여 외부 타입에 외부 트레이트 구현하기’절을 읽었다고 가정합니다.
뉴타입 패턴은 지금까지 설명한 것 이외의 작업에도 유용한데, 여기에는
값이 혼동되지 않도록 정적으로 강제하는 것과 값의 단위를 표시하는
것들이 포함됩니다. 예제 19-15에서 뉴타입을 사용하여 단위를 표시하는
예제를 보았습니다. Millimeters 및 Meters 구조체가 u32 값을
뉴타입으로 감싸고 있었음을 상기하세요. Millimeters 타입의
매개변수가 있는 함수를 작성했다면, 실수로 Meters 또는 보통의
u32 타입의 값으로 해당 함수를 호출 시도하는 프로그램은 컴파일될
수 없습니다.
뉴타입 패턴은 어떤 타입의 구현 세부 사항을 추상화하는데도 사용 가능합니다. 뉴타입은 비공개 내부 타입의 API와는 다른 공개 API를 노출할 수 있습니다.
뉴타입은 내부 구현을 숨길 수도 있습니다. 예를 들면, 어떤 사람의 ID와
이에 연관된 그 사람의 이름을 저장하는 HashMap<i32, String>을
감싼 People 타입을 만들 수 있습니다. People을 사용하는
코드는 People 컬렉션에 이름 문자열을 추가하는 메서드처럼
우리가 제공하는 공개 API와만 상호작용할 수 있습니다; 해당
코드는 내부적으로 이름에 i32 ID를 할당한다는 사실을 알 필요가
없습니다. 뉴타입 패턴은 구현 세부 사항을 숨기는 캡슐화를 달성하는
가벼운 방법으로, 16장의 ‘상세 구현을 은닉하는 캡슐화’절에서
설명한 바 있습니다.
타입 별칭으로 타입의 동의어 만들기
러스트는 타입 별칭(type alias) 을 선언하여 기존 타입에 다른 이름을 부여하는
기능을 제공합니다. 이를 위해서는 type 키워드를 사용합니다. 예를 들어, 다음과
같이 i32에 대한 Kilometers라는 별칭을 만들 수 있습니다.
type Kilometers = i32;이제 별칭 Kilometers는 i32의 동의어입니다; 예제 19-15에서
만든 Millimeters 및 Meters 타입과는 달리, Kilometers는 별도의
새로운 타입은 아닙니다. Kilometers 타입을 가진 값은 i32 타입의 값과
동일하게 처리됩니다.
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);Kilometers와 i32는 동일한 타입이므로 두 타입의 값을 모두 더할
수 있고 Kilometers 값을 i32 매개변수를 받는 함수에 전달할 수
있습니다. 그러나, 이 방법을 사용하면 이전에 설명한 뉴타입 패턴에서
얻을 수 있는 타입 검사 이점을 얻을 수 없습니다. 다시 말해, 어딘가에서
Kilometers와 i32 값을 혼용하면 컴파일러는 에러를 표시하지
않습니다.
타입 동의어의 주요 사용 사례는 반복을 줄이는 것입니다. 예를 들어, 다음과 같은 긴 타입이 있을 수 있습니다.
Box<dyn Fn() + Send + 'static>이 긴 타입을 함수 시그니처 및 코드의 모든 곳에 타입 명시로 작성하는 것은 지루하고 에러가 발생하기 쉽습니다. 예제 19-24와 같은 코드로 가득 찬 프로젝트가 있다고 상상해보세요.
예제 19-24: 수많은 곳에 긴 타입 사용하기 let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --생략--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --생략--
}타입 별칭은 반복을 줄여 이 코드를 관리하기 쉽게 만듭니다. 예제 19-25에서는
이 장황한 타입에 대해 Thunk라는 별칭을 만들고 이 타입이
사용된 모든 곳을 짧은 별칭 Thunk로 대체했습니다.
Thunk을 도입하여
반복 줄이기
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// --생략--
}
fn returns_long_type() -> Thunk {
// --생략--
}이 코드는 읽고 작성하기 훨씬 쉽습니다! 또한 타입 별칭에 의미 있는 이름을 선택하면 의도를 전달하는 데 도움이 됩니다. (thunk는 나중에 평가될 코드를 나타내는 단어이므로, 저장되는 클로저에 적합한 이름입니다.)
타입 별칭은 또한 Result<T, E> 타입의 반복을 줄이기 위해 사용되기도
합니다. 표준 라이브러리의 std::io 모듈을 생각해보세요. I/O 연산은
종종 연산이 작동하지 않을 때의 상황을 처리하기 위해 Result<T, E>를
반환합니다. 이 라이브러리에는 가능한 모든 I/O 에러를 나타내는
std::io::Error 구조체가 있습니다. std::io의 많은 함수는 Write
트레이트의 함수와 같이 E가 std::io::Error인 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에는
이러한 타입 별칭 선언이 있습니다.
type Result<T> = std::result::Result<T, std::io::Error>;이 선언이 std::io 모듈에 있으므로, 완전 정규화된 별칭
std::io::Result<T>를 사용할 수 있습니다; 즉, E가
std::io::Error로 채워진 Result<T, E>입니다. Write 트레이트
함수 시그니처는 결국 다음과 같이 생기게 됩니다.
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>에서 작동하는
모든 메서드는 물론, ? 연산자와 같은 특별 문법도 사용할 수 있음을 뜻합니다.
절대 반환하지 않는 부정 타입
러스트에는 !라는 특수한 타입이 있는데, 이 타입은 값이 없기 때문에 타입
이론 용어로는 빈 타입(empty type) 이라고 알려져 있습니다. 함수가 절대
반환하지 않을 때 반환 타입을 대신하기 때문에 부정 타입(never type) 이라고
부르는 쪽이 선호됩니다. 다음은 예시입니다.
fn bar() -> ! {
// --생략--
}이 코드는 ‘함수 bar는 절대로 반환하지 않습니다’라고 읽습니다. 절대로 반환하지
않는 함수는 발산 함수(diverging functions) 라고 합니다. ! 타입의 값은
만들 수 없으므로, bar는 절대 반환할 수 없습니다.
그런데 값을 절대로 만들 수 없는 타입은 어디에 쓰는 거죠? 숫자 추리 게임의 부분인 예제 2-5의 코드를 기억해보세요; 여기에 예제 19-26에서 다시 일부를 재현해 두었습니다.
예제 19-26:continue로 끝나는 갈래가
있는 match
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};이 시점에서는 이 코드에서 몇 가지 세부 사항을 건너뛰었습니다. 5장의 ‘match 제어 흐름 연산자’절에서
match 갈래가 모두 같은 타입을 반환해야 한다는 것을 논의했습니다. 예를 들어,
다음 코드는 작동하지 않습니다.
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};guess의 타입은 정수 그리고 문자열이어야 하며, 러스트는
guess가 하나의 타입만 가져야 한다고 요구합니다. 그럼 continue가
무엇을 반환할까요? 어떻게 예제 19-26에서 한쪽 갈래는 u32를
반환하면서 다른 갈래는 continue로 끝나는 것이 허용되었을까요?
짐작하셨겠지만, continue는 ! 값을 가집니다. 즉, 러스트가 guess의
타입을 계산할 때, 두 개의 매치 갈래를 모두 살펴보게 되는데, 전자는 u32 값을
가지고 후자는 ! 값을 가집니다. !는 절대로 값을 가질 수 없으므로, 러스트는
guess의 타입이 u32라고 결정합니다.
이 동작을 설명하는 공식적인 방법은 ! 타입의 표현식이 다른 모든 타입으로
강제 변환될 수 있다는 것입니다. continue가 값을 반환하지 않기 때문에,
이 match 갈래가 continue로 끝나도 괜찮습니다; continue는 제어를
반복문의 맨 위로 이동시키기 때문에, Err 케이스에서는 guess에 값을
할당하지 않습니다.
부정 타입은 panic! 매크로와 함께 유용하게 쓰입니다. 값을 생성하거나
패닉을 일으키기 위해 Option<T> 값에서 호출한 unwrap 함수를 기억해
보시면, 여기 그 정의가 있습니다.
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}이 코드에서도 예제 19-26의 match에서와 같은 일이 발생합니다. val이
T 타입을 가지고 있고 panic!이 ! 타입을 가지고 있으므로, 러스트는 전체
match 표현식의 결과가 T라는 것을 알 수 있습니다. 이 코드는 panic!이
값을 생성하지 않기 때문에 작동합니다; 패닉은 프로그램을 종료하니까요. None의
경우 unwrap에서 값을 반환하지 않으므로, 이 코드는 유효합니다.
! 타입을 가지는 마지막 표현식은 loop입니다.
print!("forever ");
loop {
print!("and ever ");
}여기서 루프는 절대 끝나지 않으므로, !가 이 표현식의 값이 됩니다. 하지만
break를 포함시키면, 루프는 break에 도달했을 때 종료되므로, 이는
참이 아니게 될 것입니다.
동적 크기 타입과 Sized 트레이트
러스트는 특정 타입의 값에 할당할 공간의 크기 등 타입에 대한 특정 세부 사항을 알아야 합니다. 이로 인해 처음에는 타입 시스템의 한구석이 약간 혼란스럽습니다. 바로 동적 크기 타입(dynamically sized type) 의 개념이 그렇습니다. DST 또는 크기가 지정되지 않은 타입(unsized type) 이라고도 하는 이러한 타입을 사용하면 런타임에만 크기를 알 수 있는 값을 사용하여 코드를 작성할 수 있습니다.
이 책 전체에 걸쳐 사용했던 str이라는 동적 크기 타입에 대해 자세히
알아보겠습니다. 그렇습니다. &str이 아니라 str 자체는 DST입니다.
런타임이 될 때까지 문자열의 길이를 알 수 없으므로 str 타입의 변수를
만들 수도 없고, str 타입의 인수를 받을 수도 없습니다. 아래의
작동하지 않는 코드를 고려해보세요.
let s1: str = "Hello there!";
let s2: str = "How's it going?";러스트는 특정 타입의 값에 할당할 메모리의 크기를 알아야 하며,
타입의 모든 값은 동일한 크기의 메모리를 사용해야 합니다. 러스트에서
이 코드를 작성할 수 있다면 이 두 str 값은 같은 양의 공간을 차지해야
합니다. 그러나 이들은 길이가 다릅니다. s1은 12바이트의 저장 공간이
필요하고 s2는 15바이트가 필요하기 때문입니다. 이것이 바로 동적 크기를
갖는 변수를 생성할 수 없는 이유입니다.
그럼 어떻게 해야 할까요? 이 경우에는 이미 답을 알고 있습니다. s1과
s2의 타입을 str이 아닌 &str로 만듭니다. 3장의 ‘문자열 슬라이스’절에서 슬라이스
데이터 구조는 슬라이스의 시작 위치와 길이만 저장한다는 것을 기억하세요.
따라서 &T는 T가 위치한 메모리 주소를 저장하는 단일 값이지만,
&str은 두 개의 값입니다. str의 주소와 길이 말이지요. 따라서
컴파일 타임에 &str 값의 크기를 알 수 있습니다. usize 길이의
두 배입니다. 즉, &str이 참조하는 문자열의 길이가 아무리 길어도
항상 &str의 크기를 알 수 있습니다. 일반적으로 이것이 러스트에서
동적 크기 타입이 사용되는 방식입니다. 이들은 동적 정보의 크기를
저장하는 추가 메타데이터를 가지고 있습니다. 동적 크기 타입의
황금률은 동적 크기 타입의 값을 항상 어떤 종류의 포인터 뒤에 넣어야
한다는 것입니다.
str은 모든 종류의 포인터와 결합할 수 있습니다. 예를 들면, Box<str>나 Rc<str>
같은 것들이지요. 사실, 여러분은 이전에도 다른 종류의 동적 크기 타입이지만
이런 것을 본 적이 있습니다. 바로 트레이트입니다. 모든 트레이트는 그 트레이트의 이름을
사용하여 참조할 수 있는 동적 크기 타입입니다. 16장의 ‘트레이트 객체를 사용하여 다른 타입의 값 허용하기’절에서,
트레이트를 트레이트 객체로 사용하려면
&dyn Trait 또는 Box<dyn Trait>와
같은 포인터 뒤에 넣어야 한다고 언급했습니다. (Rc<dyn Trait>도
가능합니다.)
DST로 작업하기 위해 러스트에서는 컴파일 시점에 타입의 크기를 알 수
있는지 여부를 결정하는 Sized 트레이트를 제공합니다. 이 트레이트는
컴파일 시 크기가 알려진 모든 것에 대해 자동으로 구현됩니다. 또한
러스트는 암묵적으로 모든 제네릭 함수에 Sized 바운드를 추가합니다.
즉, 다음과 같은 제네릭 함수 정의는.
fn generic<T>(t: T) {
// --생략--
}실제로는 아래와 같이 작성한 것처럼 취급됩니다.
fn generic<T: Sized>(t: T) {
// --생략--
}기본적으로 제네릭 함수는 컴파일 시점에 크기가 알려진 타입에 대해서만 작동합니다. 그러나 다음과 같은 특별 문법을 사용하여 이 제한을 완화할 수 있습니다.
fn generic<T: ?Sized>(t: &T) {
// --생략--
}?Size 트레이트 바운드는 ‘T는 Sized일 수도 있고 아닐 수도 있다’는 의미를
가지며 이 문법은 제네릭 타입이 컴파일 시점에 크기가 알려진 타입이어야 한다는
기본값을 덮어씁니다. 이런 의미의 ?Trait 문법은 Sized에만 사용할 수 있고
다른 어떤 트레이트에도 사용할 수 없습니다.
또한 t 매개변수의 타입을 T에서 &T로 바꾸었음을 주목하세요.
타입이 Sized가 아닐 수 있기 때문에 어떤 종류의 포인터 뒤에 놓고
사용해야 합니다. 이 경우에는 참조를 선택했습니다.
다음으로는 함수와 클로저에 대해 이야기해보겠습니다!