공통동작은 타입시스템으로 표현하라
함수와 메서드
다른 프로그래밍 언어와 마찬가지로 러스트도 함수를 제공한다. 함수는 특정한 코드 묶음을 재사용할 수 있도록 이름을 붙이고 매개 변수 목록을 통해 그 코드에 필요한 입력을 전달한다.
다른 정적 타입 언어처럼 반환값과 매개변수 타입을 명시적으로 지정해야 한다.
fn div(x: f64, y: f64) -> f64 {
if y == 0.0 {
f64::NAN
}
x / y
}
fn show(x: f64) {
println!("x = {x}");
}특정 데이터 구조와 밀접하게 엮여 있는 함수를 메서드라고 한다. 메서드가 속하는 데이터 구조는 self 로 표현하며 메서드 동작은 그 구조체 안의 항목에 대해 적용되고 메서드 코드는 impl 데이터 구조 블록에 정의한다.
러스트의 메서드는 다른 언어처럼 객체 지향 방식으로 관련 데이터와 코드를 함께 캡슐화하지만, 러스트의 enum 이 가진 특유의 범용성에 의해 struct 타입 뿐 아니라 enum 타입에도 적용할 수 있다.
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
}메서드 이름은 동작을 구분하는 레이블 역할을 하고 메서드 시그니처는 입력과 출력에 대한 타입 정보를 제공한다.
메서드는 첫번째 이름으로 다음과 같이 다양한 형태의 self 를 받는데, 이를 통해 메서드가 데이터 구조에 수행하는 작업의 성격을 나타낸다.
- &self 매개변수는 데이터 구조의 내용은 읽을 수 있지만 수정할 수는 없음을 나타낸다.
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
}- &mut self 매개변수는 메서드가 데이터 구조의 내용을 수정할 수 있음을 나타낸다.
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
fn perimeter(&self) -> f64 {
match self {
Shape::Circle { radius } => 2.0 * std::f64::consts::PI * radius,
Shape::Rectangle { width, height } => 2.0 * (width + height),
}
}
}
impl Shape {
fn scale(&mut self, factor: f64) {
match self {
Shape::Circle { radius } => *radius *= factor,
Shape::Rectangle { width, height } => {
*width *= factor;
*height *= factor;
}
}
}
}- self 매개변수는 이 메서드가 데이터 구조를 소비한다는 것을 나타낸다.
impl Shape {
fn consume(self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
}함수 포인터
함수를 호출할 때마다 실행되는 코드는 항상 일정하다. 달라지는 것은 함수가 다루는 데이터뿐이다. 이것만으로도 다양한 시나리오를 표현할 수 있지만 만약 런타임에 실행될 코드도 달라져야 한다면 어떻게 해야할까?
가장 간단한 방법은 함수 포인터를 사용하는 것이다.
함수 포인터
함수 포인터란 특정 코드(함수)를 가리키는 포인터로서 타입은 함수의 시그니처로 정의된다.
fn sum(x: i32, y: i32) -> i32 {
x + y
}
fn main() {
let add: fn(i32, i32) -> i32 = sum;
println!("{}", add(1, 2));
}함수 포인터의 타입은 컴파일 타임에 확인할 수 있으므로 프로그램이 실행될 시점에는 포인터의 크기에 맞는 값만 오게 된다. 이런 크기 값 말고는 함수 포인터에 연계된 다른 데이터는 없기 때문에 다음과 같이 값으로 취급할 수 있다.
// fn 타입은 Copy 트레잇을 구현한다
let op1 = sum;
let op2 = sum;
// fn 타입은 Eq 트레잇을 구현한다
assert_eq!(op1, op2);
// fn 은 {:p} 서식 지정자가 사용하는 std::fmt:Pointer 를 구현한다
println!("{:p}", &op1); // output: 0x7f9b9c0a3e00이때 주의할 점은, 여기서 fn 타입으로 강제 변환해야한다는 점이다.
함수 이름만으로는 자동으로 fn 타입이 되질 않는다.
let op1 = sum;
let op2 = sum;
// op1 과 op2 는 둘 다 사용자 코드에서 이름을 지정할 수 없는 타입이다.
// 이러한 내부 타입은 Eq 를 구현하지 않는다.
assert!(op1 == op2);error[E0369]: binary operation `==` cannot be applied to type `fn(i32, i32) -> i32 {sum}`
--> challenges/playground/src/main.rs:8:15
|
8 | assert!(op1 == op2);
| --- ^^ --- fn(i32, i32) -> i32 {sum}
| |
| fn(i32, i32) -> i32 {sum}
|
help: use parentheses to call these
|
8 | assert!(op1(/* i32 */, /* i32 */) == op2(/* i32 */, /* i32 */));
| ++++++++++++++++++++++ ++++++++++++++++++++++
클로저
원시 함수 포인터만으로 할 수 있는 일에는 한계가 있다. 매개변수를 통해 명시적으로 지정한 값만 호출된 함수의 입력값으로 전달될 수 있기 때문이다.
예를 들어 다음과 같이 슬라이스에 담긴 모든 원소를 수정하는 작업을 함수 포인터로 구현하는 경우를 생각해보자.
pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) {
for value in data {
*value = mutator(*value);
}
}
fn add2(v: u32) -> u32 {
v + 2
}
fn main() {
let mut data = vec![1,2,3];
modify_all(&mut data, add2);
assert_eq!(data, vec![3,4,5]);
}하지만 외부 상태를 기반으로 수정해야 할 때 그 상태를 함수 포인터로 전달할 방법이 없다.
fn add_n(v: u32) -> u32 {
v + amount_to_add
}
fn main() {
let amount_to_add = 3;
let mut data = vec![1,2,3];
modify_all(&mut data, |v| v + amount_to_add);
assert_eq!(data, vec![4,5,6]);
}error[E0425]: cannot find value `amount_to_add` in this scope
--> src/main.rs:2:9
|
2 | v + amount_to_add
| ^^^^^^^^^^^^^ not found in this scope이럴 경우 클로저를 사용해야 한다. 클로저란 함수 정의의 본문(람다 표현식; lambda expression)으로 함수를 정의하는 방식이다. 클로저는 함수와 달리 변수를 참조할 수 있다.
- 표현식의 일부로 만들 수 있어서 이름을 붙일 필요가 없다.
- 입력 매개변수는 |param1, param2| 와 같은 파이프로 묶는다. 매개 변수 타입은 대부분 컴파일러가 자동으로 추론할 수 있다.
- 주변 환경을 캡쳐할 수 있다. 클로저가 속한 스코프에 있는 변수를 클로저 안에서 사용할 수 있도록 클로저 생성 시점에 그 변수를 저장하는 기술을 의미한다.
let amount_to_add = 3;
let add_n = |y| {
y + amount_to_add
};
let z = add_n(5);
assert_eq!(z, 8);캡쳐의 자동 방식을 간단히 표현하면 다음 코드와 같다. 우선 컴파일러는 람다 표현식에서 언급하는 환경을 구성하는 모든 부분에 대해 일회용 내부 타입을 생성한다.
그리고 나서 클로저가 생성되는 시점에 앞서 만들어 둔 일회용 타입의 인스턴스를 생성해 환경값을 보관한다.
그러다 클로저가 호출되면 해당 인스턴스를 컨텍스트에 추가해서 사용한다.
let amount_to_add = 3;
struct InternalContext<'a> {
// 캡쳐한 변수에 대한 레퍼런스
amount_to_add: &'a i32,
}
impl<'a> InternalContext<'a> {
fn internal_op(&self, y: u32) -> u32 {
// 람다 표현식의 본문
y + *self.amount_to_add as u32
}
}
let add_n = InternalContext {
amount_to_add: &amount_to_add,
};
let z = add_n.internal_op(5);
assert_eq!(z, 8);여기서 개념적인 컨텍스트(notional context) 에 저장된 값은 이 예제 코드처럼 레퍼런스(아이템 8)인 경우가 많지만 환경에 있는 것에 대한 가변 레퍼런스(mutable reference)이거나, 입력 매개변수 앞에 move 키워드를 사용해 환경에 이동된 값일 수 있다.
다시 modify_all 예제로 돌아가서, 여기서 함수 포인터를 받는 자리에 그냥 클로저를 전달하면 안된다.
// 수정전
pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) {
for value in data {
*value = mutator(*value);
}
}
// 수정후
pub fn modify_all<F>(data: &mut [u32], mut mutator: F)
where
F: FnMut(u32) -> u32,
{
for value in data {
*value = mutator(*value);
}
}러스트에서 제공하는 Fn* 트레이트는 다음과 같이 세 가지가 있는데 환경 캡쳐 관련 동작이 약간 다르다.
FnOnce
한번만 호출할 수 있는 클로저를 표현한다.
move 를 통해 환경의 일부를 클로저의 컨텍스트로 이동시킨 후 클로저 본문이 실행될 때 가져왔던 환경값을 다시 밖으로 이동시켜버리면,
클로저 본문 안에는 move 로 이동시킬 원본 소스 항목에 대한 복사본이 더 이상 없기 때문에 단 한번만 이동 시킬 수 있다.
따라서 클로저도 단 한번만 호출 가능하다.
FnMut
여러 번 반복 호출할 수 있고 환경에 있는 값들을 가변형(mutable)으로 대여하기 때문에 환경을 수정할 수 있는 클로저를 표현한다.
Fn
여러번 반복 호출 할 수 있고 값을 환경에서 불변형(immutable)으로만 빌려오는 클로저를 표현한다.
컴파일러는 코드에 나온 람다 표현식을 모두 적절한 Fn* 트레이트로 자동으로 구현해 준다. 단 Fn* 트레이트를 직접 구현할 수는 없다.
따라서 컴파일러가 어떤 트레이트로 구현해 줄지는 캡쳐된 환경 컨텍스트에 다음 요소가 있는지 여부에 어느정도 영향을 받는다.
- FnOnce: 이동된 값이 있을 때
- FnMut: 값에 대한 가변 레퍼런스(&mut T)가 있을 때
- Fn: 값에 대한 일반(normal) 불변 레퍼런스 (&T)가 있을때
이 중에서 두번째와 세번째 트레이트는 각각 첫번째와 두번째 트레이트를 트레이트 바운드로 갖는다.
클로저를 사용하는 대상을 생각해보면 당연하다.
- (FnOnce 를 받도록 지정해서) 단 한 번만 호출될 클로저를 받는 자리에 반복 호출 가능한 클로저(FnMut)를 전달해도 문제가 없다.
- (FnMut 를 받도록 지정해서) 환경을 수정할 수 있고 반복 호출도 가능한 클로저를 받는 자리에 환경을 수정하지 않는 클로저(Fn)을 전달해도 문제 없다.
원시 함수 포인터 타입인 fn 역시 개념상 이 목록의 마지막 항목에 해당한다. (unsafe 로 지정하지 않은) fn 타입은 모두 환경으로부터 아무것도 빌려오지 않기 때문에 자동으로 Fn* 트레이트로 구현된다.
결론적으로 클로저를 받는 코드를 작성할 때는 가장 범용적인 Fn* 트레이트를 사용해 호출자의 자유도를 극대화하는 것이 좋다.
예를 들어 단 한 번만 사용되는 클로저에 대해서는 FnOnce 를 받게 된다. 같은 논리로 원시 함수 포인터(fn)보다는 Fn 트레이트 바운드를 사용하는 것이 좋다.*
트레이트
Fn* 트레이트가 원시 함수 포인터보다는 유연하지만 단일 함수 동작만 표현할 수 있고 그것도 함수 시그니처로만 가능하다.
그런데 이런 점은 동작을 러스트의 타입 시스템으로 표현하는 메커니즘인 트레이트(Trait) 의 특성이다. 트레이트는 내부 항목을 외부에서 사용할 수 있게 공개하는 데 관련된 함수들의 집합을 정의한다.
이때 함수는 대부분 self 또는 그 변형을 첫 번째 인수로 받는 메서드로 정의한다.
트레이트를 구성하는 함수마다 이름을 붙일 수 있다. 이런 이름은 컴파일러가 시그니처가 같은 함수를 서로 구별하는 레이블로 활용할 수 있을 뿐 아니라 무엇보다도 프로그래머가 함수의 의도를 파악할 수 있게 한다.
러스트의 트레이트는 자바나 고 언어의 인터페이스와 C++ 의 추상 클래스(abstract class)와 비슷하다. 트레이트를 구현할 때는 반드시 트레이트에 정의된 모든 함수를 구현해야 한다.
단 아이템 13에서 설명한 것처럼 트레이트 정의에 기본 구현이 포함될 수도 있다.
또한 트레이트 구현에서 사용될 데이터도 함께 제공할 수 있다.
다시 말해 객체 지향 스타일로 코드와 데이터를 하나로 캡슐화한 공통 추상화를 제공할 수 있다.
구조체를 받아서 그 안에 정의된 함수를 호출하는 코드는 특정한 타입에 대해서만 작동할 수 밖에 없다.
같은 동작이 여러 타입에 구현되어 있다면 특정한 구조체에 속한 함수로 만들기보다는 공통 동작을 캡슐화하는 트레이트를 정의해서 트레이트의 함수를 사용하는 방식으로 작성하면 코드가 훨씬 유연해진다.
따라서 다음과 같이 다른 객체 지향 언어에서 흔히 볼 수 있는 조언을 할 수 있다.
향후 유연성이 필요할 것 같다면 구체적인 타입보다는 트레이트 타입을 받게 만들어라
간혹 어떤 동작을 타입 시스템으로 구분하고 싶은데 트레이트 정의의 함수 시그니처로는 표현할 수 없는 경우가 있다.
예를 들어 컬렉션을 정렬하는 Sort 트레이트 코드를 들여다보면 비교 결과가 같은 요소의 상대적 순서를 정렬 전과 후에 똑같이 유지하는 안정적 정렬로 구현되어 있지만 sort 메서드의 인수만 보고 이 사실을 알 수는 없다.
이러한 특성도 마커 트레이트를 사용해 타입 시스템으로 표현하면 좋다.
pub trait Sort {
fn sort(&mut self);
}
// `Sort` 가 안정적으로 정렬된다고 알려주는 마커 트레이트
pub trait StableSort: Sort {}마커 트레이트에는 정의된 함수가 없지만 이 트레이트를 구현한다고 선언해 두면 유용하다. 이는 마치 구현하는 측에서 내 구현은 안정적으로 정렬됨을 엄숙히 선언합니다 라고 보장하는 것과 같다.
그러면 안정적인 정렬이 필요한 코드에서 StableSort 트레이트 바운드를 지정함으로써 이러한 불변성을 유지하는 신뢰 관계를 형성할 수 있다.
따라서 트레이트 함수 시그니처로 표현할 수 없는 동작은 마커 트레이트로 구분하라
트레이트를 통해 동작을 러스트의 타입 시스템 안에 캡슐화 했다면 다음과 같이 두 가지 방식으로 활용할 수 있다.
- 트레이트 바운드: 제네릭 데이터 타입이나 함수로 전달할 수 있는 타입을 컴파일 타임에 제한한다.
- 트레이트 객체: 함수에 전달하거나 함수에 저장할 수 있는 타입을 런타임에 제한한다.
트레이트 바운드
트레이트 바운드는 타입 T 를 매개변수로 받도록 정의한 제네릭 코드에서 타입 T 가 특정한 트레이트를 구현하는 경우에만 받을 수 있게 제한한다. 제네릭 코드에 트레이트 바운드가 지정되어 있다면 그 제네릭 구현은 지정된 트레이트에 정의된 함수를 사용할 수 있다.
컴파일 검사를 문제없이 통과한 타입이라면 지정된 트레이트에 정의된 함수가 반드시 존재한다고 컴파일러가 보장하기 때문이다. 따라서 이러한 검사는 제네릭이 단형화(monomorphize)되는 컴파일 타임에 수행된다.
여기서 단형화란 임의의 타입 T 에 대해 처리하도록 정의된 제네릭 코드가 특정한 타입(SomeType)을 처리하도록 변환되는 것을 말한다.
C++ 에서는 이를 템플릿 인스턴스화(template instantiation)라고 부른다.
이처럼 대상 타입인 T 에 주어지는 제약 조건이 트레이트 바운드를 통해 명시적으로 표현된다.
즉, 주어진 트레이트 바운드를 충족하는 타입에 대해서만 트레이트를 구현할 수 있다.
명시적인 트레이트 바운드가 필요하다는 말은 제네릭 중 상당수가 트레이트 바운드를 사용한다는 뜻이기도 하다. 관점을 바꿔서 struct Thing
트레이트 바운드가 없으면 Thing 은 모든 타입 (T) 에 적용되는 연산만 수행할 수 있다(실질적으로 값을 이동하거나 드롭하는 작업만 할 수 있다). 제네릭 컨테이너, 컬렉션, 스마트 포인터 정도는 이처럼 트레이트 바운드 없이 정의할 수 있겠지만 그렇게 되면 기본 연산 외에는 할 수 있는 것이 많지 않다.
결국 T 타입을 사용하려면 트레이트 바운드가 필요하다.
pub fn dump_sorted<T>(mut collection: T)
where
T: Sort + IntoIterator,
T::Item: std::fmt::Debug,
{
collection.sort();
for item in collection {
println!("{:?}", item);
}
}정리하면 제네릭에서 사용하는 타입에 대한 조건은 트레이트 바운드로 표현하라. 다행히 따르기 쉬운 조언이다. 이렇게 지정한 조건을 따르지 않으면 컴파일 오류가 발생한다.
트레이트 객체
트레이트 객체 역시 트레이트를 통해 원하는 동작을 타입 시스템 안에 캡슐화하는 방법을 제공한다는 점은 같지만, 다양한 트레이트 구현 중에서 하나를 선택하는 시점이 컴파일 타임이 아닌 런타임이라는 점이 다르다.
이러한 동적 디스패치(dynamic dispatch)는 C++ 의 가상함수와 비슷하다. 러스트에서 내부적으로 vtable 을 이용한다는 점도 C++ 과 거의 비슷하다.
이런 동적인 특성 때문에 트레이트 객체를 레퍼런스(예: &dyn Trait)나 포인터(예: Box
컴파일 시점에는 트레이트를 구현하는 객체의 크기를 알 수 없기 때문이다(거대한 struct 일 수 있고 조그마한 enum 일 수 있다).
따라서 원시 트레이트 객체를 할당하는 데 필요한 공간 크기를 정확히 알 방법이 없다.
객체의 크기를 정확히 모른다는 말은 트레이트 객체로 사용되는 트레이트가 Self 타입을 반환하는 함수를 가질 수 없거나 메서드가 호출되는 객체의 수신자 말고는 Self 를 사용하는 인수를 가질 수 없다는 뜻이기도 하다.
그 이유는 트레이트 객체를 사용하도록 사전에 컴파일 된 코드는 Self 가 얼마나 큰지 전혀 알 수 없기 때문이다.
fn some_fn
트레이트 바운드로 사용할 거라면 그래도 괜찮다. 호출될 가능성이 있는 제네릭 함수가 무한히 많더라도 컴파일 타임에 실제로 호출되는 제네릭 함수는 유한하기 때문이다.
하지만 트레이트 객체는 그렇지 않다. 컴파일 타임에 코드를 생성할 때 런타임에 올 수 있는 모든 T 를 처리할 수 있게 만들어야 하기 때문이다.
Self 와 제네릭 함수를 사용할 수 없다는 제약 사항을 묶어 객체 안정성(object safety)이라고 한다.
객체 안정성을 만족하는 트레이트만 트레이트 객체로 사용할 수 있다.