상세 컨텐츠

본문 제목

[이펙티브 러스트] Chapter 1. 타입

공부/Rust

by 근성 2025. 7. 14. 22:17

본문

러스트 타입 시스템의 핵심 중 하나인 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도 있다.

아이템 1: 데이터 구조를 타입 시스템으로 표현하라

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형을 정의할 수 있다.

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

아까 맨 처음에 enum에 ADT를 사용할 수 있다고 했다. 사용 예제는 아래와 같다.

use std::collections::{HashMap, HashSet};

pub enum SchedulerState {
	Inert,
	Pending(HashSet<Job>),
	Running(HashMap<CpuId, Vec<Job>>),
}

이런식의 정의를 본다면 “러스트는 어떻게 타입 시스템을 통해 프로그램 컨셉을 디자인하는가”를 보여줄 수 있다.

해당 타입의 정의만 보면 Job은 Pending 상태 큐에 들어가 있다가 스케줄러가 완전히 활성화 되는 시점에 CPU 풀에 할당된다고 예상할 수 있다.

흔히 사용하는 enum 타입

Option<T>

이 타입은 특정 타입의 값이 있을 수도 있고(Some(T)), 없을 수도 있음(None)을 나타낸다. 값이 없을 수도 있는 경우는 반드시 Option으로 표현한다.

여기서 한 가지 고려할 점이 있다. 컬렉션을 다룰 때, 원소가 없는 경우와 컬렉션이 없는 경우가 같은 의미인지 결정해야 한다. 대부분의 상황에서는 두 경우를 구분할 필요가 없어서(Vec<Thing>) 컬렉션 자체가 없다는 것을 원소가 0개인 것으로 표현해도 된다.

하지만 이런 두 경우를 Option<Vec<Thing>>으로 구분해야 할 상황은 드물지만 분명히 있다. 예를 들어 암호화 ‘시스템에서 페이로드가 별도로 전송되는 경우’와 ‘빈 페이로드가 제공되는 경우’를 구분 해야한다.

그런 경우에는 Option<String>이 값이 없을 수 있다는 가능성을 보다 명확하게 드러낼 수 있다.

Result<T, E>

오류 처리에서 흔히 사용되는 Result다.

실패할 수 있는 연산 결과는 항상 Result<T, E>로 인코딩한다. T타입은 Ok 배리언트에 성공 결과를 담고, E 타입은 Err 배리언트에 실패했을 때의 세부 오류 정보를 담는다.

 


아이템 2: 공통 동작은 타입 시스템으로 표현하라

  • 함수: 코드 블록에 이름을 붙이고 매개변수 목록을 받게 만든 범용 메커니즘이다.
  • 메서드: 특정 데이터 구조의 인스턴스에 속한 함수로서, 객체 지향 프로그래밍 패러다임의 등장으로 여러 프로그래밍 언어에서 쉽게 볼 수 있다.
  • 함수 포인터: 다른 코드를 간접 참조 방식으로 호출하게 해주는 메커니즘
  • 클로저
  • 트레이트: 동일한 대상에 적용될 수 있는 관련 기능의 묶음이다. 다른 언어에도 트레이트와 비슷한 개념이 있다. (C++ 추상클래스, 고와 자바의 인터페이스)

함수와 메서드

/// '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,
		}
	}
}
  • &self 매개변수는 데이터 구조의 내용을 읽을 수는 있지만 수정할 수는 없음을 나타낸다.
  • &mut self 매개변수는 메서드가 데이터 구조의 내용을 수정할 수 있음을 나타낸다.
  • self 매개변수는 이 메서드가 데이터 구조를 소비한다는 것을 나타낸다.

함수 포인터

함수 포인터란, 특정 코드를 가리키는 포인터로서 타입은 함수의 시그니처로 정의된다.

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]);

저렇게 에러가 나는 경우 클로저를 사용해야한다. 클로저란 함수 정의의 본문과 같은 코드 블록으로서 다음과 같은 차이가 있다.

  • 표현식의 일부로 만들 수 있어서 이름을 붙일 필요가 없다.
  • 입력 매개변수는 |param1, param2|와 같이 파이프(|)로 묶는다
  • 주변 환경을 캡처 할 수 있다.(캡처란 클로저가 속한 스코프에 있는 변수를 클로저 안에서 사용할 수 있도록, 클로저 생성 시점에 그 변수를 저장하는 것이다.)
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* 트레이트는 다음과 같이 세 가지가 있다.

FnOnce ⇒ 이동된 값이 있을 때

한 번만 호출할 수 있는 클로저를 표현한다. move를 통해 환경의 일부를 클로저의 컨텍스트로 이동시킨 후, 클로저 본문이 실행될 때 가져왔던 환경값을 다시 밖으로 이동시켜버리면, 클로저 본문 안에는 move로 이동시킬 원본 소스 항목에 대한 복사본이 더 이상 없기 때문에 단 한 번만 이동시킬 수 있다. 따라서 클로저도 단 한 번만 호출 가능하다.

FnMut ⇒ 값에 대한 가변 레퍼런스(&mut T)가 있을 때

여러 번 반복 호출할 수 있고, 환경에 있는 값들을 가변형으로 대여하기 때문에 환경을 수정할 수 있는 클로저를 표현한다.

Fn ⇒ 값에 대한 일반(불변) 레퍼런스 (&T)가 있을 때

여러 번 반복 호출할 수 있고, 값을 환경에서 불변형으로만 빌려오는 클로저를 표현한다.

  • (FnOnce를 받도록 지정해서) 단 한 번만 호출될 클로저를 받는 자리에, 반복 호출 가능한 클로저(FnMut)를 전달해도 문제없다.
  • (FnMut를 받도록 지정해서) 환경을 수정할 수 있고 반복 호출도 가능한 클로저를 받는 자리에, 환경을 수정하지 않는 클로저(Fn)를 전달해도 문제없다.

원시 함수 포인터 보다는 Fn* 트레이트 바운드를 사용하자!

트레이트

Fn* 트레이트가 단일 함수 동작만 표현할 수 있어서 시그니처로 사용 가능하다. 그런데 이런 점은 동작을 러스트의 타입 시스템으로 표현하는 메커니즘인 트레이트의 특성이다. 향후 유연성이 필요할 것 같다면, 구체적인 타입보다는 트레이트 타입을 받게 만들어라.

트레이트 함수 시그니처로 표현할 수 없는 동작은 마커 트레이트로 구분하라.

  • 트레이트 바운드: 제네릭 데이터 타입이나 함수로 전달할 수 있는 타입을 컴파일 타임에 재한한다.
  • 트레이트 객체: 함수에 전달하거나 함수에 저장할 수 있는 타입을 런타임에 제한한다.

관련글 더보기

댓글 영역