러스트 타입 시스템의 핵심 중 하나인 enum 타입은 다른 언어보다 표현력이 뛰어나며, ADT도 지원한다.
ADT는 이런거다.
ADT 명칭 주요 연산
| 스택 (Stack) | push(x), pop(), top(), isEmpty() |
| 큐 (Queue) | enqueue(x), dequeue(), front(), isEmpty() |
| 리스트 (List) | insert(i, x), delete(i), get(i), length() |
| 집합 (Set) | insert(x), remove(x), contains(x), size() |
| 맵/딕셔너리 (Map) | put(k, v), get(k), remove(k), keys() |
러스트의 표준 라이브러리에서 제공하는 데이터 구조 중에서도 특히 자주 사용되는 Option, Result, Error, Iterator도 있다.
enum형 변수에 데이터 필드를 직접 넣을 수 있다.
표준 컬렉션이 크기를 (.len()을 통해) usize로 반환하기 때문에 컬렉션에 담긴 항목에 대한 인덱스를 표현하는 usize값을 자주 사용한다.
러스트의 문자 타입(char)은 더 특이하다. 유니코드 값을 갖는데, 내부적으로 4바이트로 표현됨에도 불구하고, 32비트 정수와의 암묵적인 변환은 허용하지 않는다.
이처럼 러스트의 타입 시스템은 엄격하기 때문에 항상 대상을 명확히 표현해야한다.
char::from_u32
Option<char>를 반환하며, 호출자는 실패한 경우를 처리할 수 있어야 한다.
char::from_u32_unchecked
정상적으로 변환된다고 가정하지만, 그 가정이 성립하지 않는 경우에는 정의되지 않은 동작(undefined behavior)이 발생할 수 있다. 그래서 이 함수는 unsafe로 지정되며, 이 함수를 호출하는 측에도 unsafe를 지정해야 한다.
타입이 같은 인스턴스 여러 개를 배열에 담을 수 있다. 이때 인스턴스의 개수는 컴파일 타입에 결정돼야 한다. 예를 들어 [u32; 4]는 4바이트 정수 네 개가 연달아 담긴다.
타입이 서로 다른 인스턴스를 튜플로 묶을 수 있다. 원소의 개수와 각 원소의 타입은 컴파일 타임에 결정되어야 한다. 튜플의 예로(WidgetOffset, WidgetSize, WidgetColor) 등이 있다. 하지만 (i32, i32, &`static str, bool) 처럼 튜플을 구성하는 원소 타입을 명확히 구분해야 한다면 각 원소마다 이름을 지정해서 구조체로 만드는 것이 낫다.
러스트에서는 구조체와 튜플을 혼합한 튜플 구조체도 있다. 튜플 구조체는 구조체 전체에 대해서는 이름을 붙일 수 있지만, 개별 필드에는 이름이 없고 s.0, s.1 등과 같은 숫자로 표현한다.
/// 이름 없는 필드 두 개로 구성된 구조체
struct TextMatch(usize, string);
// 내용을 순서대로 제공하도록 만든다.
let m = TextMatch(12, "needle".to_owned());
// 필드 번호로 접근한다.
assert_eq!(m.0, 12);
아래와 같이 enum형을 정의할 수 있다.
enum HttpResultCode {
Ok = 200,
NotFound = 404,
Teapot = 418,
}
let code = HttpResultCode::NotFound;
assert_eq!(code as i32, 404);
또 아래와 같이 사용하면 안정성과 가독성을 높일 수 있다.
pub enum Sides{
Both,
Single,
}
pub enum Output {
BlackAndWhite,
Color,
}
pub fn print_page(sides: Sides, color: Output {
// ...
}
위와 같이 enum 정의마다 타입이 별도로 생성되므로 단순히 bool 타입 인수를 받도록 정의할 때 보다 가독성과 유지 보수성을 높일 수 있다.
print_pages(Sides::Both, Output::BlackAndWhite);
뉴타입패턴? 디자인 패턴인것 같은데 이런식으로 bool을 래핑하여 타입 안정성과 유지 보수성을 확보할 수 있다.
그리고 enum에 대한 타입 안정성은 match 표현식으로도 보장할 수 있다.
match를 사용한 예제이다.
let msg = match code {
HttpResultCode::Ok => "Ok",
HttpResultCode::NotFound => "Not found",
// 가장 중요한 "I'm a teapot" 코드를 잊어버림
};
I’m a teapot을 잊어버렸기 때문에 컴파일 에러가 발생한다.
아까 맨 처음에 enum에 ADT를 사용할 수 있다고 했다. 사용 예제는 아래와 같다.
use std::collections::{HashMap, HashSet};
pub enum SchedulerState {
Inert,
Pending(HashSet<Job>),
Running(HashMap<CpuId, Vec<Job>>),
}
이런식의 정의를 본다면 “러스트는 어떻게 타입 시스템을 통해 프로그램 컨셉을 디자인하는가”를 보여줄 수 있다.
해당 타입의 정의만 보면 Job은 Pending 상태 큐에 들어가 있다가 스케줄러가 완전히 활성화 되는 시점에 CPU 풀에 할당된다고 예상할 수 있다.
이 타입은 특정 타입의 값이 있을 수도 있고(Some(T)), 없을 수도 있음(None)을 나타낸다. 값이 없을 수도 있는 경우는 반드시 Option으로 표현한다.
여기서 한 가지 고려할 점이 있다. 컬렉션을 다룰 때, 원소가 없는 경우와 컬렉션이 없는 경우가 같은 의미인지 결정해야 한다. 대부분의 상황에서는 두 경우를 구분할 필요가 없어서(Vec<Thing>) 컬렉션 자체가 없다는 것을 원소가 0개인 것으로 표현해도 된다.
하지만 이런 두 경우를 Option<Vec<Thing>>으로 구분해야 할 상황은 드물지만 분명히 있다. 예를 들어 암호화 ‘시스템에서 페이로드가 별도로 전송되는 경우’와 ‘빈 페이로드가 제공되는 경우’를 구분 해야한다.
그런 경우에는 Option<String>이 값이 없을 수 있다는 가능성을 보다 명확하게 드러낼 수 있다.
오류 처리에서 흔히 사용되는 Result다.
실패할 수 있는 연산 결과는 항상 Result<T, E>로 인코딩한다. T타입은 Ok 배리언트에 성공 결과를 담고, E 타입은 Err 배리언트에 실패했을 때의 세부 오류 정보를 담는다.
/// 'x'를 'y'를 나눈 결과를 반환한다.
fn div(x: f64, y: f64) -> f64 {
if y == 0.0 {
// 함수를 종료하고 값을 반환한다.
return f64:NAN;
}
// 함수 본문의 마지막에 나오는 표현식은 자동으로 반환된다.
x / y
}
/// 반환값을 받기 위해서가 아니라, 사이드 이펙트를 발생시키려고 호출한다.
/// 반환값을 '-> ()'로도 표현할 수 있다.
fn show(x: f64) {
println!("x = {x}");
}
특정 데이터 구조와 밀접하게 엮여 있는 함수를 메서드라고 한다. 메서드가 속하는 데이터 구조는 self로 표현하며 메서드 동작은 그 구조체 안의 항목에 대해 적용되고, 메서드 코드는 impl 데이터 구조 블록에 정의한다. 러스트의 enum이 가진 특유의 범용성에 의해 struct 타입뿐만 아니라 enum 타입에도 메서드를 정의할 수 있다.
enum Shape {
Rectangle { width: f64, height: f64 },
Circle { radius: f64 },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Rectangle { width, height } => width * height,
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
}
}
}
함수 포인터란, 특정 코드를 가리키는 포인터로서 타입은 함수의 시그니처로 정의된다.
fn sum(x: i32, y: i32) -> i32 {
x + y
}
// 'fn' 타입으로 명시적 강제 변환(coercion)해야 한다.
let op: fn(i32, i32) -> i32 = sum;
원시함수 포인터만으로 할 수 있는 일에는 한계가 있다.
// 실전에서는 '반복자(Iterator)' 메서드로 구현하는 것이 바람직하다.
pub fn modify_all(data: &mut[u32], mutator: fn(u32) -> u32) {
for value in data {
*value = mutator(*value);
}
}
let amount_to_add = 3;
fn add_n(v: u32) -> u32 {
v + amount_to_add // 여기서 에러 난다.
}
let mut data = vec![1, 2, 3];
modify_all(&mut data, add_n);
assert_eq!(data, vec![3, 4, 5]);
저렇게 에러가 나는 경우 클로저를 사용해야한다. 클로저란 함수 정의의 본문과 같은 코드 블록으로서 다음과 같은 차이가 있다.
let amount_to_add = 3;
let add_n = |y| {
// 클로저에서 'amount_to_add'를 캡처한다.
y + amount_to_add
};
let z = add_n(5);
assert_eq!(z, 8);
위처럼 쓰면 된다. 다시 modify_all 예제로 돌아가서 클로저를 받게 하려면 Fn* 트레이트 인스턴스를 받도록 수정해야 한다.
pub fn modify_all<F>(data: &mut [u32], mut mutator: F)
where
F: FnMut(u32) -> u32,
{
for value in data {
*value = mutator(*value);
}
}
러스트에서 제공하는 Fn* 트레이트는 다음과 같이 세 가지가 있다.
한 번만 호출할 수 있는 클로저를 표현한다. move를 통해 환경의 일부를 클로저의 컨텍스트로 이동시킨 후, 클로저 본문이 실행될 때 가져왔던 환경값을 다시 밖으로 이동시켜버리면, 클로저 본문 안에는 move로 이동시킬 원본 소스 항목에 대한 복사본이 더 이상 없기 때문에 단 한 번만 이동시킬 수 있다. 따라서 클로저도 단 한 번만 호출 가능하다.
여러 번 반복 호출할 수 있고, 환경에 있는 값들을 가변형으로 대여하기 때문에 환경을 수정할 수 있는 클로저를 표현한다.
여러 번 반복 호출할 수 있고, 값을 환경에서 불변형으로만 빌려오는 클로저를 표현한다.
원시 함수 포인터 보다는 Fn* 트레이트 바운드를 사용하자!
Fn* 트레이트가 단일 함수 동작만 표현할 수 있어서 시그니처로 사용 가능하다. 그런데 이런 점은 동작을 러스트의 타입 시스템으로 표현하는 메커니즘인 트레이트의 특성이다. 향후 유연성이 필요할 것 같다면, 구체적인 타입보다는 트레이트 타입을 받게 만들어라.
트레이트 함수 시그니처로 표현할 수 없는 동작은 마커 트레이트로 구분하라.
| [BoJ] 1008. A/B (0) | 2026.01.14 |
|---|---|
| [세상에서 가장 쉬운 러스트 백엔드 with Axum] 섹션 1. 러스트와 서버 개발 (0) | 2025.07.15 |
댓글 영역