소개
**고급 타입 (Advanced Types)**에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 실력을 연습할 수 있습니다.
이 랩에서는 Rust 타입 시스템에서 newtype, 타입 별칭 (type aliases), ! 타입, 그리고 동적으로 크기가 결정되는 타입 (dynamically sized types) 에 대해 논의할 것입니다.
**고급 타입 (Advanced Types)**에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 실력을 연습할 수 있습니다.
이 랩에서는 Rust 타입 시스템에서 newtype, 타입 별칭 (type aliases), ! 타입, 그리고 동적으로 크기가 결정되는 타입 (dynamically sized types) 에 대해 논의할 것입니다.
Rust 타입 시스템에는 지금까지 언급했지만 아직 논의하지 않은 몇 가지 기능이 있습니다. 먼저 newtype 이 타입으로서 유용한 이유를 살펴보면서 일반적으로 newtype 에 대해 논의하겠습니다. 그런 다음 newtype 과 유사하지만 약간 다른 의미 체계를 가진 기능인 타입 별칭 (type aliases) 으로 넘어가겠습니다. 또한 ! 타입과 동적으로 크기가 결정되는 타입 (dynamically sized types) 에 대해서도 논의할 것입니다.
참고: 이 섹션에서는 앞서 "외부 트레이트 (External Traits) 를 구현하기 위한 Newtype 패턴 사용" 섹션을 읽었다고 가정합니다.
newtype 패턴은 지금까지 논의한 것 외에도 값의 혼동을 정적으로 강제하고 값의 단위를 나타내는 것을 포함하여 다양한 작업에 유용합니다. Listing 19-15 에서 newtype 을 사용하여 단위를 나타내는 예시를 보았습니다. Millimeters와 Meters 구조체가 u32 값을 newtype 으로 감쌌던 것을 기억하십시오. Millimeters 타입의 매개변수를 가진 함수를 작성했다면, 실수로 Meters 타입의 값이나 일반 u32 값을 사용하여 해당 함수를 호출하려고 시도하는 프로그램을 컴파일할 수 없을 것입니다.
또한 newtype 패턴을 사용하여 타입의 일부 구현 세부 정보를 추상화할 수 있습니다. 새로운 타입은 비공개 내부 타입의 API 와 다른 공개 API 를 노출할 수 있습니다.
Newtype 은 내부 구현을 숨길 수도 있습니다. 예를 들어, 사람의 ID 를 이름과 연결하여 저장하는 HashMap<i32, String>을 감싸는 People 타입을 제공할 수 있습니다. People을 사용하는 코드는 People 컬렉션에 이름 문자열을 추가하는 메서드와 같이 우리가 제공하는 공개 API 와만 상호 작용합니다. 해당 코드는 내부적으로 i32 ID 를 이름에 할당한다는 것을 알 필요가 없습니다. newtype 패턴은 "구현 세부 정보를 숨기는 캡슐화"에서 논의한 구현 세부 정보를 숨기기 위한 캡슐화를 달성하는 가벼운 방법입니다.
Rust 는 기존 타입에 다른 이름을 부여하기 위해 *타입 별칭 (type alias)*을 선언하는 기능을 제공합니다. 이를 위해 type 키워드를 사용합니다. 예를 들어, 다음과 같이 Kilometers 별칭을 i32에 대해 생성할 수 있습니다.
type Kilometers = i32;
이제 Kilometers 별칭은 i32의 *동의어 (synonym)*입니다. Listing 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 매개변수를 받는 함수에 전달할 수 있습니다. 그러나 이 방법을 사용하면 앞서 논의한 newtype 패턴에서 얻을 수 있는 타입 검사 (type-checking) 의 이점을 얻을 수 없습니다. 즉, Kilometers와 i32 값을 어딘가에서 혼합하면 컴파일러는 오류를 발생시키지 않습니다.
타입 동의어의 주요 사용 사례는 반복을 줄이는 것입니다. 예를 들어, 다음과 같은 긴 타입을 가질 수 있습니다.
Box<dyn Fn() + Send + 'static>
이 긴 타입을 함수 시그니처 (function signatures) 와 코드 전체의 타입 어노테이션 (type annotations) 으로 작성하는 것은 지루하고 오류가 발생하기 쉽습니다. Listing 19-24 와 같은 코드로 가득 찬 프로젝트를 상상해 보십시오.
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--
}
Listing 19-24: 여러 곳에서 긴 타입 사용
타입 별칭은 반복을 줄여 이 코드를 더 관리하기 쉽게 만듭니다. Listing 19-25 에서, 장황한 타입에 대한 Thunk라는 별칭을 도입했으며, 타입의 모든 사용을 더 짧은 별칭 Thunk로 대체할 수 있습니다.
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--
}
Listing 19-25: 반복을 줄이기 위해 Thunk 타입 별칭 도입
이 코드는 훨씬 읽고 쓰기 쉽습니다! 타입 별칭에 의미 있는 이름을 선택하면 의도를 전달하는 데 도움이 될 수 있습니다 (thunk는 나중에 평가될 코드를 의미하는 단어이므로 저장되는 클로저에 적합한 이름입니다).
타입 별칭은 반복을 줄이기 위해 Result<T, E> 타입과 함께 자주 사용됩니다. 표준 라이브러리의 std::io 모듈을 고려해 보십시오. I/O 작업은 작업이 실패할 경우를 처리하기 위해 종종 Result<T, E>를 반환합니다. 이 라이브러리에는 모든 가능한 I/O 오류를 나타내는 std::io::Error 구조체가 있습니다. std::io의 많은 함수는 E가 std::io::Error인 Result<T, E>를 반환합니다. 예를 들어, Write 트레이트의 다음 함수와 같습니다.
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>에서 작동하는 모든 메서드를 사용할 수 있을 뿐만 아니라 ? 연산자와 같은 특수 구문을 사용할 수 있음을 의미합니다.
Rust 에는 !라는 특수한 타입이 있습니다. 이 타입은 타입 이론 (type theory) 용어로는 empty type으로 알려져 있는데, 값을 갖지 않기 때문입니다. 우리는 이 타입을 never type이라고 부르는 것을 선호합니다. 함수가 결코 반환하지 않을 때 반환 타입의 자리에 있기 때문입니다. 다음은 예시입니다.
fn bar() -> ! {
--snip--
}
이 코드는 "함수 bar는 never 를 반환한다"로 읽습니다. never 를 반환하는 함수를 diverging functions라고 부릅니다. ! 타입의 값을 생성할 수 없으므로, bar는 절대로 반환할 수 없습니다.
하지만 값을 생성할 수 없는 타입은 어떤 용도로 사용될까요? 숫자 맞추기 게임의 일부인 Listing 2-5 의 코드를 기억하십시오. Listing 19-26 에서 그 일부를 재현했습니다.
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
Listing 19-26: continue로 끝나는 arm 이 있는 match
당시 이 코드의 몇 가지 세부 사항을 건너뛰었습니다. "The match Control Flow Construct"에서 match arm 은 모두 동일한 타입을 반환해야 한다고 논의했습니다. 따라서 예를 들어, 다음 코드는 작동하지 않습니다.
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
이 코드에서 guess의 타입은 정수 및 문자열이어야 하며, Rust 는 guess가 하나의 타입만 갖도록 요구합니다. 그렇다면 continue는 무엇을 반환할까요? Listing 19-26 에서 한 arm 에서 u32를 반환하고 다른 arm 이 continue로 끝나는 것을 어떻게 허용했습니까?
짐작하셨겠지만, continue는 ! 값을 갖습니다. 즉, Rust 가 guess의 타입을 계산할 때, 두 match arm 을 모두 살펴봅니다. 전자는 u32 값을 갖고, 후자는 ! 값을 갖습니다. !는 값을 가질 수 없으므로, Rust 는 guess의 타입이 u32라고 결정합니다.
이 동작을 설명하는 공식적인 방법은 ! 타입의 표현식이 다른 모든 타입으로 강제될 수 있다는 것입니다. continue가 값을 반환하지 않기 때문에 이 match arm 을 continue로 끝낼 수 있습니다. 대신, 제어를 루프의 맨 위로 다시 이동시키므로, Err의 경우 guess에 값을 할당하지 않습니다.
never 타입은 panic! 매크로와도 유용합니다. Option<T> 값에서 값을 생성하거나 다음과 같이 패닉 (panic) 을 발생시키기 위해 호출하는 unwrap 함수를 기억하십시오.
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!(
"called `Option::unwrap()` on a `None` value"
),
}
}
}
이 코드에서는 Listing 19-26 의 match와 동일한 일이 발생합니다. Rust 는 val이 T 타입을 갖고 panic!이 ! 타입을 갖는 것을 보므로, 전체 match 표현식의 결과는 T입니다. 이 코드는 panic!이 값을 생성하지 않고 프로그램을 종료하기 때문에 작동합니다. None의 경우, unwrap에서 값을 반환하지 않으므로 이 코드는 유효합니다.
! 타입을 갖는 마지막 표현식은 loop입니다.
print!("forever ");
loop {
print!("and ever ");
}
여기서 루프는 결코 끝나지 않으므로 !가 표현식의 값입니다. 그러나 break를 포함하면 루프가 break에 도달할 때 종료되므로 그렇지 않습니다.
Rust 는 특정 타입의 값에 대해 얼마나 많은 공간을 할당해야 하는지 등, 타입에 대한 특정 세부 정보를 알아야 합니다. 이것은 타입 시스템의 한 부분을 처음에는 약간 혼란스럽게 만듭니다. 바로 *동적으로 크기가 정해지는 타입 (dynamically sized types)*의 개념입니다. DSTs 또는 unsized types라고도 불리는 이러한 타입은 런타임에만 크기를 알 수 있는 값을 사용하여 코드를 작성할 수 있게 해줍니다.
책 전체에서 사용해 온 str이라는 동적으로 크기가 정해지는 타입의 세부 사항을 살펴보겠습니다. 맞습니다. &str이 아니라, str 자체는 DST 입니다. 런타임까지 문자열이 얼마나 긴지 알 수 없으므로, str 타입의 변수를 생성할 수도 없고, str 타입의 인수를 사용할 수도 없습니다. 작동하지 않는 다음 코드를 고려하십시오.
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust 는 특정 타입의 모든 값에 대해 얼마나 많은 메모리를 할당해야 하는지 알아야 하며, 타입의 모든 값은 동일한 양의 메모리를 사용해야 합니다. Rust 가 이 코드를 작성하도록 허용했다면, 이 두 개의 str 값은 동일한 양의 공간을 차지해야 합니다. 하지만 길이가 다릅니다. s1은 12 바이트의 스토리지가 필요하고 s2는 15 바이트가 필요합니다. 이것이 동적으로 크기가 정해지는 타입을 담는 변수를 생성하는 것이 불가능한 이유입니다.
그렇다면 어떻게 해야 할까요? 이 경우, 이미 답을 알고 있습니다. s1과 s2의 타입을 str이 아닌 &str로 만듭니다. "String Slices"에서 슬라이스 데이터 구조는 슬라이스의 시작 위치와 길이를 저장한다는 것을 기억하십시오. 따라서 &T는 T가 위치한 메모리 주소를 저장하는 단일 값인 반면, &str은 두 값입니다. 즉, str의 주소와 그 길이입니다. 따라서 컴파일 시간에 &str 값의 크기를 알 수 있습니다. 이는 usize의 두 배입니다. 즉, 참조하는 문자열이 얼마나 길든 상관없이 &str의 크기를 항상 알고 있습니다. 일반적으로, 이것이 Rust 에서 동적으로 크기가 정해지는 타입이 사용되는 방식입니다. 동적 정보의 크기를 저장하는 추가 메타데이터 비트가 있습니다. 동적으로 크기가 정해지는 타입의 황금률은 동적으로 크기가 정해지는 타입의 값을 항상 어떤 종류의 포인터 뒤에 두어야 한다는 것입니다.
str을 모든 종류의 포인터와 결합할 수 있습니다. 예를 들어, Box<str> 또는 Rc<str>입니다. 사실, 다른 동적으로 크기가 정해지는 타입인 트레이트를 사용하여 이전에 본 적이 있습니다. 모든 트레이트는 트레이트의 이름을 사용하여 참조할 수 있는 동적으로 크기가 정해지는 타입입니다. "Using Trait Objects That Allow for Values of Different Types"에서 트레이트를 트레이트 객체로 사용하려면 &dyn Trait 또는 Box<dyn Trait>와 같은 포인터 뒤에 두어야 한다고 언급했습니다 (Rc<dyn Trait>도 작동합니다).
DST 로 작업하기 위해 Rust 는 컴파일 시간에 타입의 크기를 알 수 있는지 여부를 결정하기 위해 Sized 트레이트를 제공합니다. 이 트레이트는 컴파일 시간에 크기를 알 수 있는 모든 것에 대해 자동으로 구현됩니다. 또한 Rust 는 모든 제네릭 함수에 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가 아닐 수 있으므로, 어떤 종류의 포인터 뒤에서 사용해야 합니다. 이 경우, 참조를 선택했습니다.
다음으로, 함수와 클로저에 대해 이야기하겠습니다!
축하합니다! 고급 타입 (Advanced Types) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.