타입 안전성
newtype은 정적 구분을 제공합니다 (C-NEWTYPE)
newtype은 기반 타입의 서로 다른 해석을 정적으로 구분할 수 있습니다.
예를 들어, f64 값은 마일 단위의 양을 나타낼 수도 있고 킬로미터 단위의 양을 나타낼 수도 있습니다. newtype을 사용하면 의도한 해석을 추적할 수 있습니다:
#![allow(unused)]
fn main() {
struct Miles(pub f64);
struct Kilometers(pub f64);
impl Miles {
fn to_kilometers(self) -> Kilometers { /* ... */ }
}
impl Kilometers {
fn to_miles(self) -> Miles { /* ... */ }
}
}
이 두 타입을 분리하고 나면, 둘을 혼동하지 않도록 정적으로 보장할 수 있습니다. 예를 들어, 다음 함수는
#![allow(unused)]
fn main() {
fn are_we_there_yet(distance_travelled: Miles) -> bool { /* ... */ }
}
실수로 Kilometers 값으로 호출될 수 없습니다. 컴파일러가 변환을 수행하라고 알려 주므로, 일부 치명적인 버그를 피할 수 있습니다.
인수는 bool이나 Option이 아닌 타입을 통해 의미를 전달합니다 (C-CUSTOM-TYPE)
다음 방식이
#![allow(unused)]
fn main() {
let w = Widget::new(Small, Round)
}
다음 방식보다 낫습니다
#![allow(unused)]
fn main() {
let w = Widget::new(true, false)
}
bool, u8, Option 같은 핵심 타입은 여러 가지로 해석될 수 있습니다.
해석과 불변 조건을 전달하려면 의도적으로 정한 타입(열거형, 구조체, 튜플 중 무엇이든)을 사용하십시오. 위 예시에서는 인수 이름을 찾아보지 않으면 true와 false가 무엇을 뜻하는지 바로 알기 어렵지만, Small과 Round는 의미가 훨씬 잘 드러납니다.
사용자 정의 타입을 사용하면 나중에 ExtraLarge 배리언트를 추가하는 것처럼 선택지를 확장하기가 더 쉬워집니다.
기존 타입을 구별되는 이름으로 비용 없이 감싸는 방법은 newtype 패턴(C-NEWTYPE)을 참고하십시오.
플래그 집합을 나타내는 타입은 열거형이 아니라 bitflags입니다 (C-BITFLAG)
Rust는 판별값을 명시적으로 지정한 enum 타입을 지원합니다:
#![allow(unused)]
fn main() {
enum Color {
Red = 0xff0000,
Green = 0x00ff00,
Blue = 0x0000ff,
}
}
사용자 지정 판별값은 enum 타입을 다른 시스템이나 언어와 호환되는 정수 값으로 직렬화해야 할 때 유용합니다. 이러한 판별값은 “타입 안전한” API를 지원합니다. 함수가 정수 대신 Color를 받으면, 나중에 그 입력을 정수로 보더라도 올바른 형식의 입력을 받는다는 것이 보장됩니다.
enum을 사용하면 API가 여러 선택지 중 정확히 하나를 요청할 수 있습니다. 그런데 API의 입력이 플래그 집합의 유무인 경우도 있습니다. C 코드에서는 흔히 각 플래그를 특정 비트에 대응시켜, 하나의 정수로 예컨대 32개나 64개의 플래그를 나타내는 방식으로 이를 처리합니다. Rust의 bitflags 크레이트는 이 패턴을 타입 안전하게 표현하는 방법을 제공합니다.
use bitflags::bitflags;
bitflags! {
struct Flags: u32 {
const FLAG_A = 0b00000001;
const FLAG_B = 0b00000010;
const FLAG_C = 0b00000100;
}
}
fn f(settings: Flags) {
if settings.contains(Flags::FLAG_A) {
println!("doing thing A");
}
if settings.contains(Flags::FLAG_B) {
println!("doing thing B");
}
if settings.contains(Flags::FLAG_C) {
println!("doing thing C");
}
}
fn main() {
f(Flags::FLAG_A | Flags::FLAG_C);
}
빌더를 사용하면 복잡한 값을 생성할 수 있습니다 (C-BUILDER)
일부 데이터 구조는 생성하는 데 다음이 필요하므로 생성하기가 복잡합니다:
- 많은 수의 입력
- 복합 데이터(예: 슬라이스)
- 선택적인 설정 데이터
- 여러 형태 중 하나의 선택
이로 인해 각각 많은 인수를 받는 서로 다른 생성자가 많이 생기기 쉽습니다.
T가 이러한 데이터 구조라면 T 빌더의 도입을 고려하십시오.
T값을 점진적으로 설정하기 위한 별도의 데이터 타입TBuilder를 도입하십시오. 가능하다면 더 나은 이름을 선택하십시오. 예를 들어Command는 자식 프로세스의 빌더이며,Url은ParseOptions로부터 생성할 수 있습니다.- 빌더 생성자는
T를 만드는 데 반드시 필요한 데이터만 매개변수로 받아야 합니다. - 빌더는 복합 입력(슬라이스 등)을 점진적으로 설정하는 기능을 포함하여, 설정을 위한 편리한 메서드 모음을 제공해야 합니다. 이러한 메서드는 체이닝이 가능하도록
self를 반환해야 합니다. - 빌더는 실제로
T를 만드는 하나 이상의 “종결” 메서드를 제공해야 합니다.
빌더 패턴은 T를 만드는 과정에 태스크 생성이나 프로세스 실행 같은 부수 효과가 수반될 때 특히 적합합니다.
Rust에는 아래에서 설명하는 것처럼 소유권을 다루는 방식이 서로 다른 두 가지 빌더 패턴 배리언트가 있습니다.
소비하지 않는 빌더(권장)
어떤 경우에는 최종 T를 생성할 때 빌더 자체를 소비할 필요가 없습니다. 다음과 같은 std::process::Command의 배리언트가 한 예입니다:
#![allow(unused)]
fn main() {
// NOTE: the actual Command API does not use owned Strings;
// this is a simplified version.
pub struct Command {
program: String,
args: Vec<String>,
cwd: Option<String>,
// etc
}
impl Command {
pub fn new(program: String) -> Command {
Command {
program: program,
args: Vec::new(),
cwd: None,
}
}
/// Add an argument to pass to the program.
pub fn arg(&mut self, arg: String) -> &mut Command {
self.args.push(arg);
self
}
/// Add multiple arguments to pass to the program.
pub fn args(&mut self, args: &[String]) -> &mut Command {
self.args.extend_from_slice(args);
self
}
/// Set the working directory for the child process.
pub fn current_dir(&mut self, dir: String) -> &mut Command {
self.cwd = Some(dir);
self
}
/// Executes the command as a child process, which is returned.
pub fn spawn(&self) -> io::Result<Child> {
/* ... */
}
}
}
빌더 설정을 실제로 사용하여 프로세스를 생성하는 spawn 메서드가 빌더를 공유 참조로 받는다는 점에 유의하십시오. 이것이 가능한 이유는 프로세스를 생성하는 데 설정 데이터의 소유권이 필요하지 않기 때문입니다.
종결 메서드인 spawn은 참조만 필요로 하므로, 설정 메서드들은 self의 가변 빌림을 받고 반환합니다.
이점
전체적으로 빌림을 사용하므로, Command는 한 줄짜리 생성과 더 복잡한 생성 모두에 편리하게 사용할 수 있습니다.
#![allow(unused)]
fn main() {
// One-liners
Command::new("/bin/cat").arg("file.txt").spawn();
// Complex configuration
let mut cmd = Command::new("/bin/ls");
if size_sorted {
cmd.arg("-S");
}
cmd.arg(".");
cmd.spawn();
}
소비하는 빌더
때로는 빌더가 최종 타입 T를 생성할 때 소유권을 넘겨야 하는데, 이는 종결 메서드가 &self가 아닌 self를 받아야 한다는 뜻입니다.
#![allow(unused)]
fn main() {
impl TaskBuilder {
/// Name the task-to-be.
pub fn named(mut self, name: String) -> TaskBuilder {
self.name = Some(name);
self
}
/// Redirect task-local stdout.
pub fn stdout(mut self, stdout: Box<io::Write + Send>) -> TaskBuilder {
self.stdout = Some(stdout);
self
}
/// Creates and executes a new child task.
pub fn spawn<F>(self, f: F) where F: FnOnce() + Send {
/* ... */
}
}
}
여기서 stdout 설정에는 io::Write의 소유권을 넘기는 과정이 수반되며, 이 소유권은 생성 시(spawn에서) 태스크로 이전되어야 합니다.
빌더의 종결 메서드가 소유권을 필요로 하는 경우에는 기본적인 트레이드오프가 존재합니다.
-
다른 빌더 메서드들이 가변 빌림을 받고 반환한다면, 복잡한 설정의 경우에는 잘 동작하지만 한 줄짜리 설정은 불가능해집니다.
-
다른 빌더 메서드들이 소유된
self를 받고 반환한다면, 한 줄짜리 설정은 계속 잘 동작하지만 복잡한 설정은 덜 편리해집니다.
쉬운 일은 쉽게, 어려운 일은 가능하게 만든다는 원칙에 따라, 소비하는 빌더의 모든 빌더 메서드는 소유된 self를 받고 반환해야 합니다. 그러면 클라이언트 코드는 다음과 같이 동작합니다.
#![allow(unused)]
fn main() {
// One-liners
TaskBuilder::new("my_task").spawn(|| { /* ... */ });
// Complex configuration
let mut task = TaskBuilder::new();
task = task.named("my_task_2"); // must re-assign to retain ownership
if reroute {
task = task.stdout(mywriter);
}
task.spawn(|| { /* ... */ });
}
한 줄짜리 설정은 이전과 같이 동작하는데, 이는 소유권이 spawn에 의해 소비될 때까지 각 빌더 메서드를 거쳐 전달되기 때문입니다. 그러나 복잡한 설정은 더 장황해집니다. 각 단계마다 빌더를 다시 할당해야 하기 때문입니다.