이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.
우리는 지난 시간에 트레이트에 대한 고급 기술을 다루었으며
이번 시간에는 자료형, 함수와 클로저에 대한 고급 기술을 마저 다루기로 하였다.
지난 시간에 이야기했던 newtype 패턴에 대한 이야기부터 하도록 하자.
newtype 패턴은 외부 자료형에 외부 트레이트를 구현하는 용도로도 사용되지만
정적으로 값을 명확히 구분하고 그 단위를 나타내는 용도로도 사용된다.
예를 들어, 우리가 newtype 패턴이라고 언급하지 않고 사용해서 그렇지
지난 시간에 다룬 기본 제네릭 자료형 매개변수에서도 newtype 패턴을 사용했다.
struct Millimeters(u32);
struct Meters(u32);
이로써 Millimeters
를 매개변수로 사용하는 메서드에는
u32
나 Meters
를 대신 집어넣을 수 없게 하였다.
또한 newtype 패턴은 내부에 감싼 자료형의 원래의 API를 감추고
특정 기능에 대한 API만 외부에 제공함으로써 어떤 자료형의 기능을 제한할 수 있다.
그리고 내부에 감싼 자료형이 어떻게 구현되어 있는지 알 필요 없이
외부 wrapper 자료형과 그것의 API만을 통해 사용하는 추상화를 제공하기도 한다.
우리가 u32
를 나타내는 Millimeters
를 만들기 위해서
앞서 살펴본 방식대로 wrapper 자료형을 만들 수도 있지만
다음과 같이 별칭을 지정할 수도 있다.
type Millimeters = u32;
이렇게 하면 Milimeters
와 u32
사이의 연산이 허용된다.
대신 Millimeter
의 사용이 적절했는지 자료형 검사를 하지 못한다.
사실 자료형 별칭은 이런 경우보다 코드 중복을 줄이고자 할 때 주로 사용된다.
만약 우리가 다음과 같은 자료형을 사용한다면
그것이 필요할 때마다 매번 적어주는 것은 정말 번거로운 일이고
어딘가에서 실수가 발생하기 쉽다.
Box<dyn fn() + Send + 'static>
이런 상황에서 우리는 별칭을 통해 이 자료형의 길이를 줄일 수 있다.
type Thunk = Box<dyn fn() + Send + 'static>;
별칭을 통해 자료형에 유의미한 이름을 지으면
코드 작성자의 의도를 파악하는 데 도움이 될 수도 있다.
자료형 별칭은 Result<T, E>
를 사용할 때도 많이 사용되는데,
E
에 항상 같은 Error 자료형이 들어간다면 별칭을 통해 그 부분을 생략할 수 있다.
가령, 어떤 트레이트에서 사용하는 Result<T, E>
의 E
가 전부 std::io::Error
라면
우리는 다음과 같이 별칭을 작성할 수 있다.
type Result<T> = Result<T, std::io::Error>;
이것은 여전히 Result<T, E>
와 동일하게 사용될 수 있다.
Rust에는 !
로 표기되는 조금 특별한 자료형이 존재한다.
우리는 이것을 절대 값을 가지지 않는다는 의미에서 never 자료형이라고 부른다.
다음과 같이 never 자료형을 반환할 경우 그 함수는 값을 반환을 하지 않는데
우리는 이렇게 never 자료형을 반환하는 함수를 발산함수deverging function라고 한다.
그런데 never 자료형은 대체 어디에 사용되는걸까?
잠시 match
표현식에 대한 이야기를 해보자.
이 녀석은 어떤 값을 반환하게 될 경우 반드시 모든 가지에서 같은 자료형을 반환해야 한다.
즉, 다음과 같은 코드를 작성할 수 없다는 것이다.
let guess = match guess.trim.parse() {
Ok(_) => 5,
Err(_) => "hello",
}
하지만 우리는 다음과 같은 코드는 작성할 수 있다.
let guess: u32 = match guess.trim.parse() {
Ok(_) => num,
Err(_) => continue,
}
Ok
는 u32를 반환하고 Err
는 아무것도 반환하지 않은 채 continue
하는 것 같은데 말이다.
이 때, continue
가 반환하는 것이 바로 never 자료형이다.
Rust 컴파일러는 이 match
표현식의 반환 자료형을 검사하고
u32와 never 자료형으로 이루어져 있음을 인식한다.
그리고 never 자료형은 절대 값을 반환을 하지 않기 때문에
이 match
표현식의 반환 자료형은 u32라고 결론 내린다.
생각해보면 continue
에 도달하면 반복문의 시작으로 돌아가기 때문에
그 경우 match
표현식은 아무것도 반환하지 않는다.
아니, 반환 자체를 하지 않고 돌아가는 것으로 보인다.
continue
뿐만 아니라 panic!
매크로 또한 never 자료형으로,
match
표현식의 가지에서 언제든 반환할 수 있다.
never 자료형은 그 어떤 자료형으로도 강제될 수 있다.
그리고 break
없이 무한 반복하는 loop
표현식도
절대 무언가를 반환하지 않으므로 never 자료형을 갖는다.
Rust는 컴파일 시간에 특정 자료형에 필요한 메모리를 알아야 하는데
동적 크기 자료형은 실행 시간에 그 크기를 알 수 있다.
그렇다면 Rust는 동적 크기 자료형을 어떻게 처리할까?
우리가 자주 사용해온 문자열 슬라이스 &str
을 떠올려보자.
&str
는 그렇지 않지만 str
는 동적 크기 자료형이다.
Rust 컴파일러는 그 크기를 알 수 없기에 다음과 같이 str
를 직접 선언하는 걸 허용하지 않는다.
let s1: str = "Hello there!";
let s2: str = "How's it going?";
이것이 우리가 str
이 아닌 &str
을 사용하는 이유다.
&str
는 str
의 시작 주소와 그 길이를 가지고 있다.
usize 하나의 공간에 시작주소를 가지고 있는 일반적인 &T
형태의 자료형과 달리
&str
는 시작 주소와 길이를 위해 usize
두 개의 공간을 가진다는 것을
Rust 컴파일러는 이미 알고 있기 때문에 문제 없이 사용할 수 있다.
Rust의 동적 크기 자료형은 대부분 이런 식으로 사용한다.
크기에 대한 정보가 metadata로 추가되어 저장되는 것이다.
그리고 반드시 포인터를 이용해 가리켜야 한다는 특징이 있다.
반드시 &str
처럼 사용할 필요는 없고 Box<str>
, Rc<str>
같은 것도 가능하다.
트레이트 객체를 사용할 때 Box<dyn 트레이트>
와 같이 사용했던 것처럼 말이다.
Sized
트레이트Sized
트레이트는 동적 크기 자료형을 다루기 위해 사용하는 트레이트다.
이것은 컴파일 시간에 그 크기를 알 수 있는지 결정하는 데 사용되며
컴파일 시간에 그 크기를 알 수 있는 모든 자료형에 자동으로 구현된다.
그리고 묵시적으로 모든 제네릭 함수에 트레이트 경계로 Sized
경계를 추가한다.
예를 들어, 다음과 같은 제네릭 함수가 있다고 할 때
fn generic<T>(t: T) {
// snip
}
이것은 다음과 같이 작성한 것으로 취급된다.
fn generic<T: Sized>(t: T) {
// snip
}
그리고 T
에는 컴파일 시간에 그 크기가 정해진 자료형만 올 수 있다.
만약 우리가 제네릭 함수에서 동적 크기 자료형을 사용하고자 한다면
Sized
트레이트에만 허용되는 특별한 문법을 사용할 수 있다.
Sized
대신 ?Sized
를 쓰고 제네릭을 참조로 변경하는 것이다.
fn generic<T: ?Sized>(t: &T) {
// snip
}
이렇게 하면 T
는 Sized
일 수도 있고 아닐 수도 있게 된다.
Sized
가 아니라면 직접 사용하지 못하고 참조만 가능하므로
Sized
가 아닐 것을 고려하여 ?Sized
를 사용할 땐 참조로 받아와야 한다.
우리는 클로저에 대해 다룰 때 함수에 클로저를 전달하는 방법을 배웠다.
그런데 우리는 클로저뿐만 아니라 기존에 선언한 어떤 함수도 인자로 전달할 수 있다.
함수는 함수 포인터의 형태로 전달되는데
fn(매개변수자료형) -> 반환자료형
형태의 자료형을 사용한다.
Fn
트레이트가 아니라 fn
이라는 점에 유의하자.
따라서 제네릭이 아니라 구체적인 자료형으로 매개변수와 반환값을 설정한다.
예를 들어, 다음과 같이 함수를 전달할 수 있다.
peter@hp-laptop:~/rust-practice/chapter19/newtype_wrapper$ cd ..
peter@hp-laptop:~/rust-practice/chapter19$ cargo new function_pointer
Created binary (application) `function_pointer` package
peter@hp-laptop:~/rust-practice/chapter19$ cd function_pointer/
peter@hp-laptop:~/rust-practice/chapter19/function_pointer$ vi src/main.rs
src/main.rs
fn add_one(x: i32) -> i32 { x + 1 } fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 { f(arg) + f(arg) } fn main() { let answer = do_twice(add_one, 5); println!("The answer is: {}", answer); }
peter@hp-laptop:~/rust-practice/chapter19/function_pointer$ cargo run
Compiling function_pointer v0.1.0 (/home/peter/rust-practice/chapter19/function_pointer)
Finished dev [unoptimized + debuginfo] target(s) in 0.19s
Running `target/debug/function_pointer`
The answer is: 12
peter@hp-laptop:~/rust-practice/chapter19/function_pointer$
함수 포인터는 클로저의 세 가지 트레이트, Fn
, FnMut
, FnOnce
를 모두 구현하고 있다.
따라서 클로저를 요구하는 함수에 함수 포인터를 인자로 전달할 수 있다.
예를 들어 아래의 두 예제는 동일한 기능을 한다.
peter@hp-laptop:~/rust-practice/chapter19/function_pointer$ cd ..
peter@hp-laptop:~/rust-practice/chapter19$ cargo new map_closure
Created binary (application) `map_closure` package
peter@hp-laptop:~/rust-practice/chapter19$ cd map_closure/
peter@hp-laptop:~/rust-practice/chapter19/map_closure$ vi src/main.rs
src/main.rs
fn main() { let list_of_numbers = vec![1, 2, 3]; let list_of_strings: Vec<String> = list_of_numbers .iter() .map(|i| i.to_string()) .collect(); }
peter@hp-laptop:~/rust-practice/chapter19/map_closure$ cd ..
peter@hp-laptop:~/rust-practice/chapter19$ cargo new map_function
Created binary (application) `map_function` package
peter@hp-laptop:~/rust-practice/chapter19$ cd map_function/
peter@hp-laptop:~/rust-practice/chapter19/map_function$ vi src/main.rs
src/main.rs
fn main() { let list_of_numbers = vec![1, 2, 3]; let list_of_strings: Vec<String> = list_of_numbers .iter() .map(ToString::to_string) .collect(); }
두 가지 방식은 결과적으로 완전히 동일하므로 선호하는 방식을 따르면 된다.
트레이트 경계 문법을 사용하면 어떤 트레이트를 구현한 자료형을 함수의 매개변수로 사용할 수 있다.
하지만 반환할 땐 그럴 수 없고 구체적인 자료형을 사용해야 한다.
클로저는 트레이트로 표현되는데 구체적인 자료형이 존재하지 않으므로 클로저를 직접 반환할 수 없다.
직접 반환할 수는 없지만 우회할 수 있는 방법은 존재한다.
우리가 클로저를 반환하려고 시도하면 Sized
트레이트에 대한 오류가 발생한다.
즉, 클로저에게 할당해야 할 메모리를 알 수 없어 이를 반환할 수 없다는 것이다.
그런데 우리는 Rust의 객체지향 특징에 대해 알아볼 때
트레이트 객체를 통해 어떤 트레이트를 구현하는 자료형을 동적으로 사용하는 것을 배웠다.
트레이트 객체는 Box<dyn 트레이트>
형태로 표현된다.
그런 의미에서 트레이트 객체를 사용하면 다음과 같이 클로저를 반환할 수 있게 된다.
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
이 포스트의 내용은 공식문서의 19장 3절 Advanced Types & 19장 4절 Advanced Functions and Closures에 해당합니다.