콘텐츠로 이동

Rust 고급 제네릭: const 제네릭, const fn, PhantomData

제네릭 페이지에서 제네릭 매개변수는 언제나 타입이었어요. Pair<T>에는 i32도, &str도 넣을 수 있었죠. 이 페이지에서는 같은 아이디어를 한 걸음 더 밀고 나가는 도구 세 가지를 소개할게요.

  • const 제네릭(const generics)을 쓰면 [i32; 3]의 3처럼 숫자를 제네릭 매개변수로 쓸 수 있어요.
  • const fn 함수는 컴파일러가 프로그램을 빌드하는 동안 실행해 줘요. 그래서 프로그램이 시작되기 전에 답이 이미 준비돼 있어요.
  • PhantomData 타입을 쓰면 데이터를 하나도 더 저장하지 않고도 타입에 라벨을 붙일 수 있어요. “이건 사용자 ID예요”나 “이 길이는 피트 단위예요” 같은 라벨이요.

달걀판을 떠올려 보세요. 6구짜리와 12구짜리는 둘 다 “달걀판”이지만 서로 다른 상품이에요. 크기가 그 물건이 무엇인지를 정하는 일부예요. const 제네릭을 쓰면 Rust에서도 이걸 그대로 표현할 수 있어요. 천천히 읽어도 괜찮아요. 각 섹션은 따로 읽어도 이해할 수 있게 되어 있으니, 뒤쪽 내용은 필요할 때 다시 돌아와서 봐도 돼요.

const 제네릭: 숫자를 제네릭 매개변수로

섹션 제목: “const 제네릭: 숫자를 제네릭 매개변수로”

Rust에서는 배열의 길이가 타입의 일부예요. [u32; 3]과 [u32; 4]는 i32와 String만큼이나 서로 다른 타입이에요. 그래서 [u32; 3]을 받는 함수에 항목 네 개짜리 배열을 넘기면 받아 주지 않아요.

error[E0308]: mismatched types
--> src/main.rs:7:26
|
7 | println!("{}", total(basket));
| ----- ^^^^^^ expected an array with a size of 3, found one with a size of 4
| |
| arguments to this function are incorrect

“크기가 3인 배열을 기대했는데 크기가 4인 배열이 왔다”는 뜻이에요. const 제네릭으로 이 문제를 풀 수 있어요. 꺾쇠괄호 안에 const N: usize라고 쓰면, 함수가 어떤 길이의 배열이든 받을 수 있게 돼요.

fn summary<const N: usize>(prices: [u32; N]) -> String {
let mut sum = 0;
for price in prices {
sum += price;
}
// 함수 안에서 N은 그냥 평범한 숫자예요
format!("상품 {N}개, 합계 {sum}원")
}
fn main() {
let small_basket = [4, 10, 2];
let big_basket = [3, 8, 1, 12, 6];
println!("{}", summary(small_basket));
println!("{}", summary(big_basket));
}

실행 결과:

상품 3개, 합계 16원
상품 5개, 합계 30원
▶ 직접 실행해 보기

<const N: usize>는 “usize 타입의 어떤 숫자 N에 대해서든”이라고 읽으면 돼요. 보통 제네릭에서 T를 알아내는 것처럼, Rust는 넘겨받은 배열을 보고 N을 알아내요. 그리고 보통 제네릭과 마찬가지로 컴파일러가 쓰인 길이마다 summary를 따로 하나씩 만들어 주기 때문에 런타임 비용은 없어요.

솔직히 말하면, 이 정도로 작은 함수라면 슬라이스(prices: &[u32])로도 똑같이 잘 동작하고 더 간단해요. 슬라이스 페이지를 참고하세요. const 제네릭이 진짜 빛나는 건 길이가 타입의 일부여야 할 때예요. 힙 할당 없이 구조체 안에 저장하거나, 반환 타입에 쓰는 경우죠. 바로 다음에 볼게요.

const 제네릭을 쓰는 구조체: 좌석 배치도

섹션 제목: “const 제네릭을 쓰는 구조체: 좌석 배치도”

영화관 좌석 배치도를 떠올려 보세요. 줄이 몇 개 있고, 줄마다 좌석 수가 같아요. 다음은 행 수 R과 열 수 C가 둘 다 타입의 일부인 행렬이에요.

struct Matrix<const R: usize, const C: usize> {
cells: [[i32; C]; R],
}
impl<const R: usize, const C: usize> Matrix<R, C> {
fn new(cells: [[i32; C]; R]) -> Self {
Matrix { cells }
}
fn size(&self) -> String {
format!("{R}x{C}")
}
// 행이 열이 돼요: R x C 행렬이 C x R 행렬로 바뀌어요
fn transpose(&self) -> Matrix<C, R> {
let mut cells = [[0; R]; C];
for row in 0..R {
for col in 0..C {
cells[col][row] = self.cells[row][col];
}
}
Matrix { cells }
}
}
fn main() {
let seats = Matrix::new([[1, 2, 3], [4, 5, 6]]);
let flipped = seats.transpose();
println!("{} {:?}", seats.size(), seats.cells);
println!("{} {:?}", flipped.size(), flipped.cells);
}

실행 결과:

2x3 [[1, 2, 3], [4, 5, 6]]
3x2 [[1, 4], [2, 5], [3, 6]]
▶ 직접 실행해 보기

transpose의 반환 타입 Matrix<C, R>을 보세요. 컴파일러는 2x3 행렬을 뒤집으면 3x2 행렬이 된다는 걸 알고 있어요. 함수 안에서 실수로 모양이 틀린 행렬을 만들면 컴파일되지 않아요. 격자 전체가 구조체 안에 들어 있어서 Vec도 힙도 필요 없어요. impl<const R: usize, const C: usize> 줄은 impl<T>와 똑같이 동작해요. “이 메서드들은 모든 크기에 대해 존재한다”는 뜻이에요.

실전 예제: 크기가 고정된 링 버퍼

섹션 제목: “실전 예제: 크기가 고정된 링 버퍼”

자동차 블랙박스는 최근 몇 분 동안의 영상만 보관해요. 저장 공간이 가득 차면 가장 오래된 영상 위에 새 영상을 덮어써요. 이런 구조를 링 버퍼(ring buffer)라고 하는데, const 제네릭과 딱 맞아요. 용량이 고정되어 있으니 항목을 배열에 저장할 수 있거든요.

이 링 버퍼는 타입 T와 크기 N 둘 다에 대해 제네릭이라서, impl 줄에 impl<T, const N: usize>라고 써요.

struct RingBuffer<T, const N: usize> {
items: [T; N],
next: usize, // 다음 push가 들어갈 자리
len: usize, // 저장된 항목 수 (N보다 커지지 않아요)
}
impl<T: Copy + Default, const N: usize> RingBuffer<T, N> {
fn new() -> Self {
RingBuffer { items: [T::default(); N], next: 0, len: 0 }
}
fn push(&mut self, item: T) {
self.items[self.next] = item; // 가득 차 있으면 가장 오래된 항목을 덮어써요
self.next = (self.next + 1) % N;
if self.len < N {
self.len += 1;
}
}
// 저장된 항목들을 오래된 것부터 돌려줘요
fn to_vec(&self) -> Vec<T> {
let start = (self.next + N - self.len) % N;
(0..self.len).map(|i| self.items[(start + i) % N]).collect()
}
}
fn main() {
// 최근 온도 기록 3개만 보관해요
let mut recent: RingBuffer<i32, 3> = RingBuffer::new();
for temp in [18, 19, 21, 24, 22] {
recent.push(temp);
println!("{temp} 추가 후: {:?}", recent.to_vec());
}
}

실행 결과:

18 추가 후: [18]
19 추가 후: [18, 19]
21 추가 후: [18, 19, 21]
24 추가 후: [19, 21, 24]
22 추가 후: [21, 24, 22]
▶ 직접 실행해 보기

[T::default(); N]은 배열을 기본값(i32라면 0) N개로 채워요. 이렇게 값을 반복하려면 T: Copy가 필요해서 impl에 Copy + Default를 요구한 거예요. RingBuffer<i32, 3>과 RingBuffer<i32, 60>은 서로 다른 타입이라서, 60칸짜리 버퍼가 필요한 곳에 3칸짜리 버퍼를 넘기는 실수는 절대 일어나지 않아요. 크기가 늘어나는 표준 라이브러리 버전이 필요하다면 VecDeque와 BinaryHeap 페이지를 보세요.

기본값, 중괄호, 그리고 현재의 한계

섹션 제목: “기본값, 중괄호, 그리고 현재의 한계”

const 매개변수에는 const N: usize = 4처럼 기본값을 줄 수 있어요. 이름이 있는 상수나, 중괄호로 감싼 식을 넘길 수도 있어요.

struct Buffer<const N: usize = 4> {
data: [u8; N],
}
impl<const N: usize> Buffer<N> {
fn new() -> Self {
Buffer { data: [0; N] }
}
}
const PAGE: usize = 8;
fn main() {
let small: Buffer = Buffer::new(); // 숫자 없음: 기본값 4
let page = Buffer::<PAGE>::new(); // 이름이 있는 상수
let double = Buffer::<{ PAGE * 2 }>::new(); // 식은 { }로 감싸야 해요
println!("{} {} {}", small.data.len(), page.data.len(), double.data.len());
}

실행 결과:

4 8 16
▶ 직접 실행해 보기

기본값은 let small: Buffer처럼 숫자 없이 타입을 직접 쓸 때만 적용돼요. 그냥 let small = Buffer::new();라고만 쓰면 error[E0284]: type annotations needed for Buffer<_>(타입 표기가 필요하다) 에러가 나요. Rust는 식에서 기본값을 짐작하지 않거든요.

안정(stable) 버전 Rust의 const 제네릭은 유용하지만, 일부러 기능을 제한해 두었어요. 부딪히기 전에 한계를 알아 두면 도움이 돼요.

  • 쓸 수 있는 타입: 정수(usize, u8, i32, …), bool, char뿐이에요. const NAME: &'static str이라고 쓰면 “&'static str is forbidden as the type of a const generic parameter”(이 타입은 const 제네릭 매개변수로 쓸 수 없다)라는 에러가 나요.
  • 타입 안에서 제네릭 매개변수로 계산할 수 없어요. 제네릭 구조체나 시그니처 안에 [f32; N * 2]라고 쓰면 에러가 나요(“generic parameters may not be used in const operations”). 그냥 N은 괜찮고, N이 그냥 값일 뿐인 함수 본문 안에서는 N * 2도 괜찮아요. 타입 안에서 N * 2를 허용하는 기능(generic_const_exprs)은 아직 nightly에서만 쓸 수 있어요.
  • 값 검사하기. “N은 0보다 커야 한다” 같은 바운드는 쓸 수 없어요. 가장 비슷한 방법은 함수 안에 인라인 const 블록을 두는 거예요. const { assert!(N > 0, "need at least one slot") };처럼요. 잘못된 N으로 프로그램을 빌드할 때 실패해요(cargo check로는 못 잡을 수도 있어요).

const fn: 계산은 컴파일러에게 맡기기

섹션 제목: “const fn: 계산은 컴파일러에게 맡기기”

어떤 가게는 계산대에서 가격을 매번 계산하는 대신 가격표를 미리 한 번 인쇄해 둬요. const fn 함수는 컴파일러가 프로그램을 빌드하는 동안 실행해도 되는 함수예요. 그래서 답이 미리 프로그램 안에 “인쇄”돼요.

변수 페이지에서 const 항목을 이미 만나 봤죠. const의 값은 컴파일 시간에 알 수 있어야 해서, 보통은 리터럴과 간단한 계산만 쓸 수 있어요. const fn을 쓰면 여기서 직접 만든 함수도 호출할 수 있어요.

const fn square(n: usize) -> usize {
n * n
}
const BOARD_SIDE: usize = 4;
const CELLS: usize = square(BOARD_SIDE); // 컴파일러가 계산해요
fn main() {
// 배열 길이는 컴파일 시간에 알아야 하는데, CELLS는 알 수 있어요
let board = [0u8; CELLS];
println!("{BOARD_SIDE}x{BOARD_SIDE} 보드의 칸 수: {}", board.len());
println!("런타임에 계산한 square(12): {}", square(12));
}

실행 결과:

4x4 보드의 칸 수: 16
런타임에 계산한 square(12): 144
▶ 직접 실행해 보기

const fn도 여전히 평범한 함수예요. main 안의 square(12)는 런타임에 실행되는 보통의 호출이에요. 컴파일 시간에 꼭 실행되어야 하는 건 const 컨텍스트(const context)에서 쓰일 때뿐이에요. const나 static 항목, 배열 길이, const 제네릭 인자, const { ... } 블록이 여기에 해당해요.

대표적인 쓰임새가 조회 테이블(lookup table)이에요. 값 목록을 컴파일 시간에 한 번 계산해 두고, 런타임에는 읽기만 하는 거죠. 다음 예제에서는 컴파일러가 윤년이 아닌 해를 기준으로 “각 달이 시작하기 전까지 지난 날 수” 테이블을 만들어요.

const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const fn days_before_each_month() -> [u32; 12] {
let mut table = [0; 12];
let mut month = 1;
while month < 12 {
table[month] = table[month - 1] + DAYS_IN_MONTH[month - 1];
month += 1;
}
table
}
// 컴파일러가 한 번만 만들어요. 런타임에는 그냥 숫자 12개예요.
const DAYS_BEFORE: [u32; 12] = days_before_each_month();
fn day_of_year(month: usize, day: u32) -> u32 {
DAYS_BEFORE[month - 1] + day
}
fn main() {
println!("{:?}", DAYS_BEFORE);
println!("3월 1일은 {}번째 날", day_of_year(3, 1));
println!("12월 25일은 {}번째 날", day_of_year(12, 25));
}

실행 결과:

[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
3월 1일은 60번째 날
12월 25일은 359번째 날
▶ 직접 실행해 보기

for 반복문 대신 while 반복문을 쓴 걸 눈치채셨나요? 취향 때문이 아니에요. 현재 const fn 안에서 쓸 수 있는 것은 다음과 같아요.

  • let과 let mut, 산술 연산, 비교, if, match, loop, while
  • 배열, 튜플, 구조체, 열거형, 그리고 table[month] 같은 인덱싱
  • 다른 const fn 호출(u32::pow나 slice.len()처럼 표준 라이브러리에도 const인 함수가 많아요)
  • &mut 참조와 부동소수점 계산(각각 Rust 1.83과 1.82부터 stable)
  • panic!과 assert!. 컴파일 시간에 실행되다가 걸리면 컴파일 에러가 돼요

그리고 아직 쓸 수 없는 것도 있어요. for 반복문(Iterator 트레이트가 필요한데, stable에서는 트레이트 메서드가 const가 아니에요), String::from이나 println! 같은 보통 함수 호출, 그리고 Vec에 push하는 것처럼 힙에 할당하는 모든 작업이에요.

const일까, static일까? 둘 다 바뀌지 않는 전역 값이지만 저장되는 방식이 달라요.

const static
어디에 있나요 쓰는 곳마다 복사돼요 메모리의 고정된 한 곳
주소 쓸 때마다 다를 수 있어요 항상 같고, 프로그램이 끝날 때까지 살아 있어요('static)
가변으로 만들 수 있나요 절대 안 돼요 static mut이 있지만 unsafe가 필요해요
어디에 쓰나요 숫자, 작은 테이블, 설정값 한 번만 저장하고 싶은 큰 테이블, 참조가 필요한 전역 값

잘 모르겠다면 const를 쓰세요. 바꿀 수 있는 전역 값이 필요하다면 static mut 대신 아토믹이나 Mutex를 담은 static을 쓰세요. 공유 상태 페이지를 참고하세요.

실제 시스템에서 아주 자주 일어나는 버그가 있어요. 사용자 ID와 주문 ID가 둘 다 그냥 숫자라서, cancel_order에 사용자 ID를 넘겨도 아무것도 막아 주지 않는 거예요. 타입에 Id<User>와 Id<Order> 같은 라벨이 붙어 있으면 좋겠죠. 처음에는 이렇게 시도해 볼 수 있어요.

struct Id<T> {
value: u32,
}
error[E0392]: type parameter `T` is never used
--> src/main.rs:1:11
|
1 | struct Id<T> {
| ^ unused type parameter
|
= help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`

Rust는 어떤 필드에서도 쓰지 않는 타입 매개변수를 허락하지 않아요. 해결 방법은 컴파일러가 help에서 제안한 그대로예요. std::marker::PhantomData<T>는 “이 구조체는 T와 연결되어 있다”고 말만 하고 실제로는 아무것도 담지 않는 필드예요. 크기는 0바이트예요.

use std::marker::PhantomData;
struct User;
struct Order;
struct Id<T> {
value: u32,
_marker: PhantomData<T>, // 0바이트, 그냥 라벨이에요
}
impl<T> Id<T> {
fn new(value: u32) -> Self {
Id { value, _marker: PhantomData }
}
}
fn find_user(id: &Id<User>) -> &'static str {
if id.value == 7 { "지민" } else { "알 수 없음" }
}
fn cancel_order(id: &Id<Order>) {
println!("주문 #{} 취소 중", id.value);
}
fn main() {
let user_id: Id<User> = Id::new(7);
let order_id: Id<Order> = Id::new(7);
println!("사용자 #{}: {}", user_id.value, find_user(&user_id));
cancel_order(&order_id);
println!("Id의 크기: {}바이트", std::mem::size_of::<Id<User>>());
}

실행 결과:

사용자 #7: 지민
주문 #7 취소 중
Id의 크기: 4바이트
▶ 직접 실행해 보기

두 ID 모두 숫자 7을 담고 있고, 크기도 둘 다 평범한 u32와 똑같은 4바이트예요. 그런데 main의 마지막 줄에 cancel_order(&user_id);를 추가해 보세요.

error[E0308]: mismatched types
--> src/main.rs:32:18
|
32 | cancel_order(&user_id);
| ------------ ^^^^^^^^ expected `&Id<Order>`, found `&Id<User>`
| |
| arguments to this function are incorrect

“&Id<Order>를 기대했는데 &Id<User>가 왔다”는 에러예요. 프로그램을 실행하기도 전에 실수가 잡혔어요. PhantomData는 타입이면서 값이기도 해요. 그래서 구조체를 만들 때 _marker: PhantomData라고 쓰는 거예요. 이름 앞의 _는 “이 필드는 읽지 않는다”는 뜻으로 흔히 쓰는 이름 짓기 습관일 뿐이에요.

측정 단위: 미터는 피트가 아니에요

섹션 제목: “측정 단위: 미터는 피트가 아니에요”

1999년, NASA는 화성 기후 궤도선(Mars Climate Orbiter)을 잃었어요. 한 소프트웨어는 야드파운드법 단위로 숫자를 내보냈는데, 다른 소프트웨어는 미터법 단위를 기대했거든요. 둘 다 “그냥 숫자”였던 거예요. PhantomData를 쓰면 단위가 타입의 일부가 되고, 제네릭 Length<U> 하나로 모든 단위를 다룰 수 있어요.

use std::marker::PhantomData;
use std::ops::Add;
struct Meters;
struct Feet;
struct Length<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U> Length<U> {
fn new(value: f64) -> Self {
Length { value, _unit: PhantomData }
}
}
// 모든 단위에 쓰이는 impl 하나. 단, 양쪽 단위가 같아야 해요
impl<U> Add for Length<U> {
type Output = Length<U>;
fn add(self, other: Length<U>) -> Length<U> {
Length::new(self.value + other.value)
}
}
impl Length<Feet> {
fn to_meters(self) -> Length<Meters> {
Length::new(self.value * 0.3048)
}
}
fn main() {
let run: Length<Meters> = Length::new(400.0) + Length::new(250.0);
let climb: Length<Feet> = Length::new(1000.0);
let climb_m = climb.to_meters();
println!("오르막: {:.1} m", climb_m.value);
let total = run + climb_m;
println!("합계: {:.1} m", total.value);
}

실행 결과:

오르막: 304.8 m
합계: 954.8 m
▶ 직접 실행해 보기

to_meters()를 깜빡하고 run + climb이라고 쓰면 Rust가 거부해요.

error[E0308]: mismatched types
--> src/main.rs:39:23
|
39 | let total = run + climb;
| ^^^^^ expected `Length<Meters>`, found `Length<Feet>`

고급 트레이트 페이지에서는 Meters와 Centimeters가 서로 다른 구조체였고, 각자 Add impl을 따로 가지고 있었어요. 여기서는 impl<U> Add for Length<U> 하나가 앞으로 더하게 될 모든 단위를 맡아요.

타입 상태: 열린 채로는 잠글 수 없는 문

섹션 제목: “타입 상태: 열린 채로는 잠글 수 없는 문”

같은 기법으로 무언가의 상태를 추적할 수도 있어요. 문은 열려 있거나, 닫혀 있거나, 잠겨 있을 수 있어요. 닫힌 문은 잠글 수 있지만, 열린 문을 잠그는 건 말이 안 되죠. 타입에 상태 라벨을 붙이면 잘못된 동작은 아예 존재하지 않게 돼요. 이런 패턴을 타입 상태(typestate)라고 해요.

use std::marker::PhantomData;
struct Open;
struct Closed;
struct Locked;
struct Door<State> {
room: String,
_state: PhantomData<State>,
}
// 어떤 상태에서든 쓸 수 있어요
impl<State> Door<State> {
fn room(&self) -> &str {
&self.room
}
// 같은 문에 새 상태 라벨을 붙여요
fn into_state<Next>(self, action: &str) -> Door<Next> {
println!("{}: {action}", self.room);
Door { room: self.room, _state: PhantomData }
}
}
impl Door<Closed> {
fn new(room: &str) -> Self {
Door { room: room.to_string(), _state: PhantomData }
}
fn open(self) -> Door<Open> {
self.into_state("열림")
}
fn lock(self) -> Door<Locked> {
self.into_state("잠김")
}
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
self.into_state("닫힘")
}
}
impl Door<Locked> {
fn unlock(self) -> Door<Closed> {
self.into_state("잠금 해제됨")
}
}
fn main() {
let door = Door::new("서버실").open().close().lock();
println!("{}: 밤 동안 잠가 둠", door.room());
let door = door.unlock().open();
println!("{}: 다시 열림", door.room());
}

실행 결과:

서버실: 열림
서버실: 닫힘
서버실: 잠김
서버실: 밤 동안 잠가 둠
서버실: 잠금 해제됨
서버실: 열림
서버실: 다시 열림
▶ 직접 실행해 보기

이번에는 열린 문을 잠가 보세요. Door::new("서버실").open().lock()이라고 쓰면 이렇게 돼요.

error[E0599]: no method named `lock` found for struct `Door<Open>` in the current scope
--> src/main.rs:50:40
|
7 | struct Door<State> {
| ------------------ method `lock` not found for this struct
...
50 | let door = Door::new("서버실").open().lock();
| ^^^^ method not found in `Door<Open>`
|
= note: the method was found for `Door<Closed>`

“Door<Open>에는 lock 메서드가 없다”는 에러이고, 마지막 note는 “그 메서드는 Door<Closed>에 있다”고 알려 줘요. 각 메서드는 self를 값으로 받기 때문에 이전 문은 그 자리에서 소비돼요. 그래서 문을 연 뒤에 “닫힌” 문을 계속 쓸 수는 없어요. 디자인 패턴 페이지에서는 각 상태가 자기 데이터도 함께 가지는 타입 상태 예제를 볼 수 있어요. 상태가 순수한 라벨일 때는 PhantomData를 쓰는 가벼운 방법이 잘 맞아요.

특정 크기 전용 메서드와 연관 상수

섹션 제목: “특정 크기 전용 메서드와 연관 상수”

제네릭 페이지에서 impl Pair<f64>를 봤죠. 구체적인 타입 하나에만 있는 메서드였어요. const 제네릭에서도 똑같이 할 수 있어요. 어떤 메서드는 특정 크기에서만 의미가 있거든요.

struct Matrix<const R: usize, const C: usize> {
cells: [[i32; C]; R],
}
// 정사각 행렬 전용: 행과 열이 같은 N이에요
impl<const N: usize> Matrix<N, N> {
fn trace(&self) -> i32 {
let mut sum = 0;
for i in 0..N {
sum += self.cells[i][i];
}
sum
}
}
// 2x2 행렬 전용
impl Matrix<2, 2> {
fn determinant(&self) -> i32 {
let [[a, b], [c, d]] = self.cells;
a * d - b * c
}
}
fn main() {
let small = Matrix { cells: [[3, 8], [4, 6]] };
let big = Matrix { cells: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] };
println!("작은 행렬: 대각합 {}, 행렬식 {}", small.trace(), small.determinant());
println!("큰 행렬: 대각합 {}", big.trace());
}

실행 결과:

작은 행렬: 대각합 9, 행렬식 -14
큰 행렬: 대각합 15
▶ 직접 실행해 보기

big.determinant()는 컴파일되지 않고, 2x3 행렬에 trace()를 호출해도 마찬가지예요. error[E0599]: no method named trace found for struct Matrix<2, 3> 같은 에러와 함께 the method was found for Matrix<N, N>이라는 note가 나와요(메서드는 있지만 정사각 행렬에만 있다는 뜻이에요). “정사각 행렬에만 대각합이 있다”는 규칙이 이제 타입에 적혀 있는 거예요.

연관 상수(associated const)는 메서드처럼 타입이나 트레이트에 속한 상수예요. 트레이트에 두면 구현마다 자기 값을 정할 수 있고, 제네릭 코드에서는 U::NAME처럼 읽을 수 있어요. 앞에서 만든 단위에 기호와 크기를 붙여 볼게요.

use std::fmt;
use std::marker::PhantomData;
trait Unit {
const SYMBOL: &'static str;
const IN_METERS: f64; // 이 단위 1이 몇 미터인지
}
struct Meters;
struct Feet;
struct Miles;
impl Unit for Meters {
const SYMBOL: &'static str = "m";
const IN_METERS: f64 = 1.0;
}
impl Unit for Feet {
const SYMBOL: &'static str = "ft";
const IN_METERS: f64 = 0.3048;
}
impl Unit for Miles {
const SYMBOL: &'static str = "mi";
const IN_METERS: f64 = 1609.344;
}
struct Length<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U: Unit> Length<U> {
fn new(value: f64) -> Self {
Length { value, _unit: PhantomData }
}
// 두 단위의 상수를 이용해서 어떤 단위끼리든 변환해요
fn convert<To: Unit>(&self) -> Length<To> {
Length::new(self.value * U::IN_METERS / To::IN_METERS)
}
}
impl<U: Unit> fmt::Display for Length<U> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.2} {}", self.value, U::SYMBOL)
}
}
fn main() {
let marathon: Length<Miles> = Length::new(26.2);
let everest: Length<Feet> = Length::new(29032.0);
println!("{} = {}", marathon, marathon.convert::<Meters>());
println!("{} = {}", everest, everest.convert::<Meters>());
println!("{} = {}", everest, everest.convert::<Miles>());
}

실행 결과:

26.20 mi = 42164.81 m
29032.00 ft = 8848.95 m
29032.00 ft = 5.50 mi
▶ 직접 실행해 보기

U::IN_METERS는 컴파일 시간에 결정되기 때문에 런타임에 테이블을 뒤질 필요가 없어요. 새 단위, 예를 들어 Kilometers를 추가하려면 작은 impl Unit 블록 하나만 쓰면 되고, convert와 Display는 바로 동작해요. 트레이트가 아닌 고유 impl에도 연관 상수를 둘 수 있어요. 예를 들어 impl<T, const N: usize> RingBuffer<T, N> { const CAPACITY: usize = N; }라고 쓰고 RingBuffer::<i32, 3>::CAPACITY로 읽어요.

조건을 만족하는 모든 타입에 트레이트를 한 번에 구현하고 싶다면, 그건 블랭킷 impl(blanket impl)이에요. 고급 트레이트 페이지에서 다뤄요.

이 기능들은 강력해서 자꾸 쓰고 싶어져요. 솔직한 조언 몇 가지를 드릴게요.

  • 슬라이스나 Vec으로 충분할 때가 많아요. 함수가 항목을 읽기만 한다면 &[T]는 어떤 길이든 받고 읽기도 쉬워요. 길이를 런타임에만 알 수 있다면(파일, 사용자 입력 등) 어차피 Vec이 필요해요.
  • 큰 배열은 스택에 놓여요. RingBuffer<u8, 10_000_000>은 구조체 안에 10메가바이트를 품고 있어서 스택 오버플로를 일으킬 수 있어요. Box에 넣거나(스마트 포인터 참고) Vec을 쓰세요.
  • 크기나 타입이 새로 나올 때마다 코드도 새로 복사돼요. 제네릭을 서로 다른 N 값 20개로 쓰면 20개 버전이 만들어져요. 그만큼 컴파일 시간과 바이너리 크기가 늘어나요. 성능 페이지를 참고하세요.
  • 타입 상태는 타입을 늘리고 에러 메시지를 길게 만들어요. 많은 사람이 쓰는 API(빌더, 연결, 결제 흐름)에는 아주 좋아요. 하지만 데이터베이스에서 불러온 상태값처럼 런타임 데이터에 따라 바뀌는 상태라면 열거형과 match를 쓰세요.

좋은 원칙은 이거예요. 처음에는 단순하게 시작하고, 이 도구들로 막을 수 있는 진짜 버그나 진짜 비용이 보일 때 꺼내 쓰세요.

타입 안에서 const 매개변수로 계산하기. 자연스러워 보이지만 stable Rust에서는 아직 허용되지 않아요.

struct Pairs<const N: usize> {
values: [f32; N * 2],
}
error: generic parameters may not be used in const operations
--> src/main.rs:2:19
|
2 | values: [f32; N * 2],
| ^ cannot perform const operation using `N`
|
= help: const parameters may only be used as standalone arguments here, i.e. `N`

“const 매개변수는 여기서 N처럼 단독 인자로만 쓸 수 있다”는 뜻이에요. 해결 방법은 [[f32; 2]; N](쌍의 배열)을 쓰거나, 매개변수를 하나 더 추가하거나, 크기가 타입에 들어갈 필요가 없다면 Vec을 쓰는 거예요.

식을 중괄호로 감싸는 걸 잊기. Buffer::<2 + 2>::new()라고 쓰면 error: expressions must be enclosed in braces to be used as const generic arguments(식을 const 제네릭 인자로 쓰려면 중괄호로 감싸야 한다) 에러가 나고, help 줄에 고치는 방법이 나와요. Buffer::<{ 2 + 2 }>::new()예요. 그냥 숫자나 const 이름에는 중괄호가 필요 없어요.

const 컨텍스트에서 const가 아닌 것을 호출하기. const에서는 보통 함수를 호출할 수 없고, const fn 안에서는 for 반복문을 쓸 수 없어요.

const GREETING: String = String::from("hello");
error[E0015]: cannot call non-const associated function `<String as From<&str>>::from` in constants
--> src/main.rs:1:26
|
1 | const GREETING: String = String::from("hello");
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: calls in constants are limited to constant functions, tuple structs and tuple variants

상수 안에서는 const가 아닌 함수를 호출할 수 없다는 에러예요. 텍스트라면 const GREETING: &str = "hello";라고 쓰세요. const fn 안에서는 앞의 조회 테이블처럼 for 대신 while 반복문과 인덱스를 쓰세요.

PhantomData 구조체에 #[derive(Clone, Copy)] 붙이기. Id<User>는 u32 하나만 담고 있으니 당연히 Copy일 거라고 생각하기 쉬워요. 그런데 derive는 T: Copy라는 바운드를 추가하고, User는 Copy가 아니에요. 그래서 ID가 복사되지 않고 이동해요.

error[E0382]: use of moved value: `id`
--> src/main.rs:16:10
|
14 | let id: Id<User> = Id { value: 1, _marker: PhantomData };
| -- move occurs because `id` has type `Id<User>`, which does not implement the `Copy` trait
15 | show(id);
| -- value moved here
16 | show(id);
| ^^ value used here after move
|
note: if all bounds were met, you could clone the value
--> src/main.rs:4:1
|
3 | #[derive(Clone, Copy)]
| ----- derived `Clone` adds implicit bounds on type parameters

“이동된 값을 사용했다”는 에러이고, note는 “derive된 Clone이 타입 매개변수에 암묵적인 바운드를 추가한다”고 알려 줘요. 가장 간단한 해결 방법은 마커 타입에도 Clone, Copy를 derive하는 거예요(#[derive(Clone, Copy)] struct User;). 아니면 impl을 직접 쓰면 T에 바운드가 필요 없어요.

impl<T> Clone for Id<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Id<T> {}

fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N]을 작성하세요. 점수를 정확히 N개짜리 배열로 복사하는 함수예요. 모자라는 자리는 0으로 채우고, 넘치는 점수는 버려요. pad_scores::<5>(&[90, 85])와 let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]); 두 가지 방법으로 호출해 보세요. 힌트: [0; N]으로 시작해서 0..N을 돌며 scores.get(i)를 쓰세요.

정답 보기
fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N] {
let mut out = [0; N];
for i in 0..N {
if let Some(&score) = scores.get(i) {
out[i] = score;
}
}
out
}
fn main() {
let five = pad_scores::<5>(&[90, 85]);
let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);
println!("{:?}", five);
println!("{:?}", top3);
}

실행 결과:

[90, 85, 0, 0, 0]
[70, 95, 88]
▶ 직접 실행해 보기

여기서는 N을 인자에서 알아낼 수 없어요(슬라이스는 타입에 길이가 없으니까요). 그래서 N은 터보피시 ::<5>나, 결과로 요구한 타입 [u32; 3]에서 정해져요.

2. 컴파일 시간에 계산하는 타임아웃

섹션 제목: “2. 컴파일 시간에 계산하는 타임아웃”

초 단위 숫자를 돌려주는 const fn 두 개, minutes(n: u64) -> u64와 hours(n: u64) -> u64를 작성하세요. 이 함수들로 const SESSION_TIMEOUT: u64 = hours(1) + minutes(30);과 1분, 5분, 15분을 담은 배열 const RETRY_DELAYS: [u64; 3]을 정의하고, 둘 다 출력하세요. 힌트: hours는 minutes를 호출할 수 있어요.

정답 보기
const fn minutes(n: u64) -> u64 {
n * 60
}
const fn hours(n: u64) -> u64 {
minutes(n * 60)
}
const SESSION_TIMEOUT: u64 = hours(1) + minutes(30);
const RETRY_DELAYS: [u64; 3] = [minutes(1), minutes(5), minutes(15)];
fn main() {
println!("세션 타임아웃: {SESSION_TIMEOUT}초");
println!("재시도 간격: {:?}", RETRY_DELAYS);
}

실행 결과:

세션 타임아웃: 5400초
재시도 간격: [60, 300, 900]
▶ 직접 실행해 보기

5400과 재시도 간격 테이블은 컴파일러가 빌드하는 동안 계산해 둬요. 그리고 hours(1) + minutes(30)이 그냥 5400이라고 쓰는 것보다 훨씬 읽기 쉽죠. minutes에서 const를 빼면 const 항목들이 E0015 에러와 함께 컴파일되지 않아요.

struct Euro;와 struct Dollar;를 만들고, cents: i64 필드와 PhantomData<C> 필드를 가진 Money<C>를 만드세요. new(cents)와 plus(self, other: Money<C>) -> Money<C>를 구현하세요. 유로로 점심값과 커피값을 더하고 합계를 출력하세요. 힌트: other의 C가 같아야 하므로 Money<Euro>와 Money<Dollar>는 더할 수 없어요.

정답 보기
use std::marker::PhantomData;
struct Euro;
struct Dollar;
struct Money<C> {
cents: i64,
_currency: PhantomData<C>,
}
impl<C> Money<C> {
fn new(cents: i64) -> Self {
Money { cents, _currency: PhantomData }
}
fn plus(self, other: Money<C>) -> Money<C> {
Money::new(self.cents + other.cents)
}
}
fn main() {
let lunch: Money<Euro> = Money::new(1250);
let coffee: Money<Euro> = Money::new(320);
let ticket: Money<Dollar> = Money::new(4999);
let total = lunch.plus(coffee);
println!("합계: {}.{:02} EUR", total.cents / 100, total.cents % 100);
println!("티켓: {}.{:02} USD", ticket.cents / 100, ticket.cents % 100);
}

실행 결과:

합계: 15.70 EUR
티켓: 49.99 USD
▶ 직접 실행해 보기

lunch.plus(ticket)을 시도해 보세요. expected Money<Euro>, found Money<Dollar>라는 에러로 실패해요. 그리고 금액을 f64 대신 정수 센트로 저장하면 돈 계산에서 반올림 오차도 피할 수 있어요.

  • const 제네릭(const N: usize)은 숫자를 타입의 일부로 만들어요. 길이가 다른 배열은 서로 다른 타입이고, impl<T, const N: usize>로 모든 크기에 대한 메서드를 작성할 수 있어요.
  • stable Rust에서 const 매개변수는 정수, bool, char만 될 수 있어요. 식을 인자로 넘기려면 중괄호({ PAGE * 2 })가 필요하고, 타입 안에서 제네릭 N으로 계산하는 건 아직 허용되지 않아요.
  • const fn 함수는 const 컨텍스트(const, static, 배열 길이)에서 컴파일 시간에 실행될 수 있어요. for 대신 while을 쓰고, 다른 const fn만 호출하세요.
  • PhantomData<T> 타입은 구조체를 저장하지 않는 타입과 연결해 주는 크기 0짜리 필드예요. 타입이 있는 ID, 측정 단위, 타입 상태에 쓰세요.
  • impl Matrix<2, 2>와 impl<const N: usize> Matrix<N, N>은 특정 크기에만 메서드를 줘요. U::IN_METERS 같은 연관 상수는 타입마다 컴파일 시간 값을 하나씩 줘요.
  • 이 도구들은 컴파일 시간과 가독성을 대가로 치러요. 진짜 버그를 막아 줄 때 쓰세요.

이해도 점검

1. [u32; 3]을 받는 함수에 [u32; 4]를 넘길 수 없는 이유는 무엇일까요?

2. PhantomData<T>는 런타임에 구조체에 무엇을 더할까요?

3. stable Rust의 const fn 안에서 쓸 수 *없는* 것은 무엇일까요?

4. impl<const N: usize> Matrix<N, N>은 무슨 뜻일까요?

점수: 0 / 4