둘 다 “이 트레이트를 구현한 아무 타입”을 받는 방법이다. 가르는 질문은 “어느 타입인지가 컴파일할 때 정해지는가, 실행 중에 정해지는가“다. 제네릭은 컴파일러가 타입마다 함수 사본을 찍어 두고 부를 몸통을 미리 정한다1. 트레이트 객체는 사본 하나만 두고, 부를 때마다 값 옆의 표(vtable)를 보고 몸통을 고른다. Book은 한 번에 한 타입만 담는 목록이라면 제네릭이 낫고, 여러 타입을 한 목록에 담아야 하면 트레이트 객체를 쓰라고 정리한다2.
어느 쪽일까
C1 (가) 설정 파일을 여는 함수가 &str, String, PathBuf 가운데 무엇이든 받게 하고 싶다. (나) GUI 화면이 버튼, 글상자, 그리고 라이브러리 사용자가 나중에 만들 위젯까지 한 목록에 담아 차례로 그린다. 각각 제네릭과 트레이트 객체 중 무엇이 맞는가?
답: (가)는 제네릭이다. 표준 라이브러리의 File::open도 open<P: AsRef<Path>>(path: P)다3. 부르는 자리마다 넘기는 타입이 정해져 있다. 제네릭 바깥 함수는 as_ref()로 바꾸는 일만 하고 무거운 몸통은 안쪽 함수 하나에 두면, 사본이 늘어도 부담이 작다(제네릭과 트레이트 경계의 안쪽 함수 기법). (나)는 트레이트 객체다. Vec<Box<dyn Draw>>라야 버튼과 글상자를 한 목록에 담는다. Screen<T: Draw>로 만들면 한 화면에 한 종류만 담긴다2.
이유: (가)는 한 호출 안에 타입이 하나다. (나)는 한 목록 안에 타입이 여럿이고, 어떤 타입이 올지 라이브러리를 컴파일할 때 모른다.
C2 (다) 이미지 필터가 픽셀 수백만 개마다 Pixel 트레이트의 메서드 하나를 부른다. 프로그램이 쓰는 픽셀 타입은 하나로 정해져 있다. (라) 웹 서비스가 시작할 때 설정을 읽어 저장소를 SQLite와 메모리 중 하나로 고르고, 핸들러 여러 개가 그 저장소를 같이 쓴다. 각각 무엇이 맞는가?
답: (다)는 제네릭이다. 몸통이 작고 호출이 아주 많은 곳이라, 컴파일러가 몸통을 펼쳐 넣고 루프를 더 빠르게 바꿀 수 있어야 한다. 아래 측정에서 정수 넓이 합은 제네릭이 dyn보다 약 4배 빨랐다. (라)는 트레이트 객체(Arc<dyn Repo + Send + Sync>)가 자연스럽다. 저장소는 실행 중에 정해지고, 핸들러는 사본 하나로 둘 중 무엇이든 쓴다.
이유: (라)에서 간접 호출 한 번의 비용은 아래 측정으로 원소 하나에 1ns가 안 된다. 데이터베이스 요청 한 번에 비하면 묻힌다. 선택지가 둘로 정해져 있으니 열거형도 맞는 답이다(아래 “둘 다 아닐 때”).
흔한 오답: (라)를 제네릭 AppState<R: Repo>로 만든다. 될 수는 있지만 R이 핸들러와 라우터 타입 전체로 퍼지고, 실행 중에 고른 값을 담으려면 결국 두 경우를 따로 컴파일해야 한다.
결정적 차이
제네릭 (T: Trait, impl Trait) |
트레이트 객체 (dyn Trait) |
|
|---|---|---|
| 몸통을 고르는 때 | 컴파일할 때(정적 디스패치)2 | 실행 중에 vtable을 보고(동적 디스패치)2 |
| 한 목록에 섞어 담기 | 안 된다. all(vec![Circle, Square])는 E0308 |
된다. Vec<Box<dyn Shape>> |
| 담는 쪽 타입 | Screen<T>처럼 바깥 타입에도 T가 붙는다 |
Screen 하나 |
| 함수 사본(타입 6개로 부름) | 6개 | 1개 |
| 목적 파일(타입 40개로 부름) | 82,088바이트 | 30,200바이트 |
| 컴파일 시간(타입 40개로 부름) | 약 245ms | 약 85ms |
| 정수 넓이 합, 원소 200만 개 | 약 0.41ms | 약 1.59ms |
| 실수 넓이 합, 원소 200만 개 | 약 1.48ms | 약 1.59ms |
| 가리키는 포인터 | &T 8바이트 |
&dyn Trait 16바이트 |
| 트레이트에 거는 조건 | 없다 | dyn 호환이어야 한다(트레이트 객체) |
선택을 바꾸는 줄은 둘째와 일곱째다. 섞어 담아야 하면 트레이트 객체밖에 없다. 몸통이 작은 메서드를 아주 많이 부르면 제네릭이 빠르다. 나머지 줄은 둘 다 될 때 저울질하는 값이다.
숫자는 모두 이 기계(aarch64 macOS, rustc 1.99)에서 잰 것이다. 실행 시간은 opt-level=3으로 만든 프로그램을 7번 돌린 것 가운데 가장 짧은 값이다. 목적 파일 크기와 컴파일 시간은 opt-level=2로 3번 컴파일한 것 가운데 가장 짧은 값이다. 여러 번 되풀이해 돌리면 같은 칸이 20%까지 오르내렸다. 기계가 바뀌면 배수도 바뀐다. 내 코드의 결정은 내 코드로 재야 한다(성능 측정과 최적화).
실행 시간 전체
넓이를 모두 더하는 같은 일을 여섯 가지로 했다. 원은 넓이 \(3r^2\), 정사각형은 \(s^2\)이다.
| 방식 | 실수(f64) 합 |
정수(u64) 합 |
|---|---|---|
제네릭 Vec<Circle> |
1,483µs | 406µs |
&dyn Shape (값은 한 Vec에 붙어 있음) |
1,592µs | 1,589µs |
Box<dyn Shape> (값마다 따로 힙) |
1,592µs | 1,592µs |
열거형 Vec<AnyShape> |
1,513µs | 550µs |
Box<dyn Shape>, 원과 정사각형을 번갈아 섞음 |
2,912µs | 2,911µs |
| 열거형, 원과 정사각형을 번갈아 섞음 | 1,514µs | 549µs |
정수 합에서는 제네릭이 dyn보다 약 3.9배 빨랐다. 원소 하나로 나누면 차이는 약 0.6ns다. 실수 합에서는 네 방식이 1.4~1.8ms 안에 몰렸고, 돌릴 때마다 그 안의 순서가 바뀌었다.
왜 실수 합에서는 차이가 사라질까? 실수 덧셈은 묶는 순서를 바꾸면 결과가 달라질 수 있다. (0.1 + 0.2) + 0.3은 0.6000000000000001이고 0.1 + (0.2 + 0.3)은 0.6이다. 그래서 컴파일러는 실수 합을 적힌 순서대로 하나씩 더한다. 같은 모양의 합 루프를 어셈블리로 보면, 정수 합은 레지스터 네 개(x8, x10, x11, x12)에 나눠 쌓고 실수 합은 레지스터 하나(d0)에 차례로 쌓았다. 실수 쪽은 덧셈마다 앞 덧셈을 기다리는 한 줄짜리 사슬이 시간을 정한다. 그 사슬이 네 방식 모두에 똑같이 있어서 dyn의 간접 호출 비용이 대부분 그 뒤에 가려졌다. 정수 쪽에서 제네릭은 몸통을 펼쳐 넣은 뒤 합을 네 갈래로 나눠 더할 수 있었다. dyn은 몸통을 모르니 원소마다 vtable을 거쳐 함수를 한 번씩 불러야 한다. 그 호출이 시간을 정해서, dyn은 정수 합과 실수 합이 똑같이 약 1.59ms였다. Book이 동적 디스패치가 인라인을 막아 일부 최적화를 막는다고 적은 것이 이 차이다2.
섞은 Box<dyn Shape>는 한 가지 타입만 담았을 때보다 약 1.8배 느렸다. 한 가지 타입만 있으면 간접 호출이 매번 같은 몸통으로 가지만, 섞으면 원소마다 가는 곳이 바뀐다.
Vec에 두 타입), LLVM IR의 total 정의 6개 대 1개, report 정의 40개 대 1개, 목적 파일 크기, 원만 담은 네 방식과 섞어 담은 두 방식이 각각 같은 합을 낸다는 것, 어셈블리에서 합을 쌓는 레지스터 수(정수 4개, 실수 1개), 실수 덧셈의 순서 차이를 확인하고 시간을 쟀다 — 19_generics-vs-trait-objects_bench.rs둘 다 아닐 때
담을 타입이 컴파일할 때 정해진 몇 가지뿐이면 열거형이 셋째 길이다. 변형마다 한 타입을 담고, 메서드는 match로 가른다.
enum AnyShape { C(Circle), S(Square) }
impl AnyShape {
fn area(&self) -> f64 {
match self {
AnyShape::C(c) => c.area(),
AnyShape::S(s) => s.area(),
}
}
}
Book도 고정된 타입 몇 가지를 한 목록에 담을 때는 열거형이 충분히 좋은 방법이라고 적는다2. 위 측정에서 열거형은 섞어 담았을 때도 정수 합 549µs로, 섞은 Box<dyn Shape>(2,911µs)보다 약 5배 빨랐다. 원소마다 힙에 따로 할당하지 않고, match가 변형을 빠짐없이 다뤘는지 컴파일러가 검사한다는 장점도 있다(열거형과 패턴 매칭).
대가는 확장이다. 새 도형을 더하려면 열거형과 모든 match를 고쳐야 한다. 라이브러리 사용자는 내 열거형에 변형을 더할 수 없다. 그래서 (나)의 GUI처럼 사용자가 타입을 더해야 하면 열거형은 안 된다. 또 열거형 하나의 크기는 가장 큰 변형이 정한다. 한 변형만 아주 크면 clippy large_enum_variant(perf, 기본 경고)가 알린다4. 그 변형의 내용을 Box로 감싸 줄인다.
| 제네릭 | 트레이트 객체 | 열거형 | |
|---|---|---|---|
| 섞어 담기 | 안 된다 | 된다 | 된다 |
| 나중에 타입 더하기 | 된다 | 된다(남의 크레이트도) | 열거형을 고쳐야 한다 |
| 몸통 펼쳐 넣기 | 된다 | 안 된다 | 된다 |
다른 길도 있다. 동작이 함수 하나뿐이면 트레이트를 만들지 않고 클로저나 함수를 넘긴다(클로저). 한 함수가 늘 한 타입만 돌려준다면 반환 자리 impl Trait로 타입 이름만 숨긴다(트레이트).
C3 계산기의 식 트리를 만든다. 마디는 숫자, 더하기, 곱하기 세 종류뿐이고 늘릴 계획이 없다. 마디마다 eval을 부른다. 제네릭, 트레이트 객체, 열거형 중 무엇을 쓰겠는가? 나머지 둘이 왜 덜 맞는지도 쓰라.
답: 열거형이다. enum Expr { Num(f64), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>) }처럼 두고 eval을 match로 쓴다. 자기를 품는 자리는 Box로 감싸야 크기가 정해진다(Box와 힙).
이유: 제네릭은 한 트리 안에 숫자 마디와 더하기 마디가 섞이므로 맞지 않는다. 트레이트 객체(Box<dyn Node>)도 되지만, 종류가 셋으로 정해져 있으니 확장성이라는 장점을 쓸 일이 없다. 그러면서 마디마다 간접 호출을 낸다. 열거형은 섞어 담고, 몸통을 펼쳐 넣을 수 있으며, 새 종류를 빠뜨린 match를 컴파일러가 잡아 준다.
흔한 오답: “섞이니까 무조건 dyn이다.” 섞인 타입이 정해진 몇 가지라면 열거형도 섞어 담는다.
-
The Rust Programming Language, 10.1 Generic Data Types — Performance of Code Using Generics — https://doc.rust-lang.org/book/ch10-01-syntax.html ↩
-
The Rust Programming Language, 18.2 Using Trait Objects to Abstract over Shared Behavior (Defining a Trait for Common Behavior: fixed set of types, homogeneous collections; Performing Dynamic Dispatch: runtime cost, prevents inlining) — https://doc.rust-lang.org/book/ch18-02-trait-objects.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
std 문서, Struct
std::fs::File—open— https://doc.rust-lang.org/std/fs/struct.File.html ↩ -
Clippy Lints, large_enum_variant (Enum size is bounded by the largest variant) — https://rust-lang.github.io/rust-clippy/master/index.html ↩