Rust by Example
Rust는 안전성, 속도, 동시성에 중점을 둔 현대적인 시스템 프로그래밍 언어입니다. 가비지 컬렉션을 사용하지 않고도 메모리 안전성을 확보하여 이러한 목표를 달성합니다.
Rust by Example (RBE)는 다양한 Rust 개념과 표준 라이브러리를 보여주는 실행 가능한 예제 모음입니다. 이러한 예제를 더 잘 활용하려면 Rust를 로컬에 설치하고 공식 문서를 확인해 보세요. 또한 궁금하신 분들은 이 사이트의 소스 코드도 확인하실 수 있습니다.
자, 이제 시작해 봅시다!
-
Hello World - 전통적인 Hello World 프로그램으로 시작합니다.
-
기본 자료형 - 부호 있는 정수, 부호 없는 정수 및 기타 기본 자료형에 대해 배웁니다.
-
사용자 정의 타입 -
struct와enum. -
변수 바인딩 - 가변 바인딩, 스코프, 섀도잉.
-
타입 - 타입을 변경하고 정의하는 법을 배웁니다.
-
타입 변환 - 문자열, 정수, 부동 소수점 등 서로 다른 타입 간의 변환에 대해 배웁니다.
-
표현식 - 표현식과 그 사용법에 대해 배웁니다.
-
제어 흐름 -
if/else,for등을 배웁니다. -
함수 - 메서드, 클로저, 고차 함수에 대해 배웁니다.
-
모듈 - 모듈을 사용하여 코드를 구성합니다.
-
크레이트 - 크레이트는 Rust의 컴파일 단위입니다. 라이브러리를 만드는 법을 배웁니다.
-
Cargo - Rust 공식 패키지 관리 도구의 기본적인 기능을 살펴봅니다.
-
속성 - 속성은 모듈, 크레이트 또는 아이템에 적용되는 메타데이터입니다.
-
제네릭 - 다양한 타입의 인자에 대해 작동할 수 있는 함수나 데이터 타입을 작성하는 법을 배웁니다.
-
스코프 규칙 - 스코프는 소유권, 빌림, 라이프타임에서 중요한 역할을 합니다.
-
트레이트 - 트레이트는 알 수 없는 타입
Self에 대해 정의된 메서드 모음입니다. -
매크로 - 매크로는 다른 코드를 작성하는 코드를 작성하는 방법으로, 메타프로그래밍이라고도 합니다.
-
에러 핸들링 - 실패를 처리하는 Rust의 방식을 배웁니다.
-
표준 라이브러리 타입 -
std라이브러리에서 제공하는 몇 가지 사용자 정의 타입에 대해 배웁니다. -
기타 표준 라이브러리 - 파일 처리, 스레드 등을 위한 추가적인 사용자 정의 타입을 배웁니다.
-
테스트 - Rust에서의 다양한 테스트 방식을 배웁니다.
-
Unsafe 연산 - Unsafe 연산 블록에 진입하는 법을 배웁니다.
-
호환성 - Rust의 발전과 잠재적인 호환성 문제를 처리하는 법을 배웁니다.
-
메타 - 문서화, 벤치마킹.
Hello World
전통적인 Hello World 프로그램의 소스 코드입니다.
// 이것은 주석이며, 컴파일러에 의해 무시됩니다.
// 오른쪽에 있는 "Run" 버튼을 클릭하거나,
// 키보드를 사용하고 싶다면 "Ctrl + Enter" 단축키를 사용하여
// 이 코드를 테스트해 볼 수 있습니다.
// 이 코드는 수정 가능하므로, 자유롭게 고쳐보세요!
// "Reset" 버튼을 클릭하면 언제든지 원래 코드로 되돌릴 수 있습니다 ->
// 메인 함수입니다.
fn main() {
// 여기에 있는 문장들은 컴파일된 바이너리가 호출될 때 실행됩니다.
// 콘솔에 텍스트를 출력합니다.
println!("Hello World!");
}
println!은 콘솔에 텍스트를 출력하는 매크로입니다.
Rust 컴파일러인 rustc를 사용하여 바이너리를 생성할 수 있습니다.
$ rustc hello.rs
rustc는 실행 가능한 hello 바이너리를 생성합니다.
$ ./hello
Hello World!
실습
위의 ’Run’을 클릭하여 예상 출력을 확인하세요. 그런 다음, 두 번째 println! 매크로를 사용하여 다음과 같은 출력이 나오도록 새로운 라인을 추가하세요.
Hello World!
I'm a Rustacean!
주석
어떤 프로그램이든 주석이 필요하며, Rust는 몇 가지 다른 종류를 지원합니다.
일반 주석
이것들은 컴파일러에 의해 무시됩니다:
- 라인 주석:
//로 시작하여 줄의 끝까지 이어집니다. - 블록 주석:
/* ... */로 감싸여 여러 줄에 걸쳐 있을 수 있습니다.
HTML 라이브러리 문서로 파싱되는 문서화 주석 (Doc Comments):
///- 다음에 오는 아이템에 대한 문서를 생성합니다.//!- 해당 아이템을 포함하는 아이템(주로 파일이나 모듈의 상단에서 사용됨)에 대한 문서를 생성합니다.
fn main() {
// 라인 주석은 두 개의 슬래시로 시작합니다.
// 슬래시 이후의 모든 내용은 컴파일러에 의해 무시됩니다.
// 예시: 이 줄은 실행되지 않습니다
// println!("Hello, world!");
// 위의 슬래시를 제거하고 코드를 다시 실행해 보세요.
/*
* 블록 주석은 코드를 일시적으로 비활성화하는 데 유용합니다.
* 블록 주석은 중첩될 수도 있습니다: /* 이와 같이 */ 중첩이 가능하여
* 큰 섹션을 빠르게 주석 처리하기 쉽습니다.
*/
/*
참고: 왼쪽의 별표 기둥은 단지 스타일을 위한 것입니다 -
언어적으로 요구되는 사항은 아닙니다.
*/
// 블록 주석은 슬래시 하나를 추가하거나 제거하여
// 코드를 켜고 끄기 쉽게 만들어 줍니다:
/* <- 여기에 '/'를 추가하면 아래 블록 전체의 주석이 해제됩니다
println!("이제");
println!("모든 것이");
println!("실행됩니다!");
// 내부의 라인 주석은 영향을 받지 않습니다
// */
// 블록 주석은 표현식 내에서도 사용될 수 있습니다:
let x = 5 + /* 90 + */ 5;
println!("`x`는 10인가요 100인가요? x = {}", x);
}
참고:
형식화된 출력
출력은 std::fmt에 정의된 일련의 매크로들에 의해 처리되며, 그 중 몇 가지는 다음과 같습니다:
format!: 형식화된 텍스트를String에 씁니다.print!:format!과 같지만 텍스트가 콘솔(io::stdout)에 출력됩니다.println!:print!와 같지만 줄바꿈 문자가 추가됩니다.eprint!:print!와 같지만 텍스트가 표준 에러(io::stderr)로 출력됩니다.eprintln!:eprint!와 같지만 줄바꿈 문자가 추가됩니다.
모두 같은 방식으로 텍스트를 파싱합니다. 추가로, Rust는 컴파일 타임에 형식화의 올바름을 검사합니다.
fn main() {
// 일반적으로 `{}`는 어떤 인자로든 자동으로 교체됩니다.
// 이들은 문자열화될 것입니다.
println!("{}일", 31);
// 위치 인자를 사용할 수 있습니다. `{}` 안에 정수를 지정하면
// 어떤 추가 인자가 교체될지 결정합니다. 인자는 형식 문자열
// 바로 뒤에서 0부터 시작합니다.
println!("{0}, 여기는 {1}입니다. {1}, 여기는 {0}입니다.", "앨리스", "밥");
// 이름을 지정한 인자(named arguments)도 사용할 수 있습니다.
println!("{subject} {verb} {object}",
object="게으른 개",
subject="빠른 갈색 여우",
verb="넘어 뛰어넘다");
// `:` 뒤에 형식 문자를 지정하여 다양한 형식화를 수행할 수 있습니다.
println!("10진수: {}", 69420); // 69420
println!("2진수: {:b}", 69420); // 10000111100101100
println!("8진수: {:o}", 69420); // 207454
println!("16진수: {:x}", 69420); // 10f2c
// 지정된 너비로 텍스트를 오른쪽 정렬할 수 있습니다. 이 예시는
// " 1"을 출력할 것입니다. (공백 4개와 "1" 하나로 총 너비 5가 됩니다.)
println!("{number:>5}", number=1);
// 숫자를 0으로 채울 수도 있습니다,
println!("{number:0>5}", number=1); // 00001
// 그리고 부호를 뒤집어 왼쪽 정렬할 수 있습니다. 이것은 "10000"을 출력합니다.
println!("{number:0<5}", number=1); // 10000
// 형식 지정자(format specifier)에 `$`를 붙여 명명된 인자를 사용할 수 있습니다.
println!("{number:0>width$}", number=1, width=5);
// Rust는 올바른 개수의 인자가 사용되었는지도 확인합니다.
println!("제 이름은 {0}, {1} {0}입니다.", "본드");
// FIXME ^ 누락된 인자를 추가하세요: "제임스"
// fmt::Display를 구현하는 타입만 `{}`로 형식화할 수 있습니다. 사용자
// 정의 타입은 기본적으로 fmt::Display를 구현하지 않습니다.
#[allow(dead_code)] // 사용되지 않는 모듈에 대한 경고인 `dead_code`를 비활성화합니다
struct Structure(i32);
// `Structure`가 fmt::Display를 구현하지 않으므로 컴파일되지 않습니다.
// println!("이 구조체 `{}`는 출력되지 않습니다...", Structure(3));
// TODO ^ 이 줄의 주석을 해제해 보세요
// Rust 1.58 이상에서는 주변 변수에서 직접 인자를 캡처할 수 있습니다.
// 위와 마찬가지로, 이것은 " 1"(공백 4개와 "1")을 출력합니다.
let number: f64 = 1.0;
let width: usize = 5;
println!("{number:>width$}");
}
std::fmt에는 텍스트 표시를 제어하는 많은 트레이트가 포함되어 있습니다. 그 중 중요한 두 가지의 기본 형태는 다음과 같습니다:
fmt::Debug:{:?}마커를 사용합니다. 디버깅 목적으로 텍스트 형식을 지정합니다.fmt::Display:{}마커를 사용합니다. 보다 우아하고 사용자 친화적인 방식으로 텍스트 형식을 지정합니다.
여기서는 표준 라이브러리가 이러한 타입들에 대한 구현을 제공하기 때문에 fmt::Display를 사용했습니다. 커스텀 타입에 대해 텍스트를 출력하려면 더 많은 단계가 필요합니다.
fmt::Display 트레이트를 구현하면 타입을 String으로 변환할 수 있게 해주는 ToString 트레이트가 자동으로 구현됩니다.
_43행_의 #[allow(dead_code)]는 그 뒤에 오는 모듈에만 적용되는 속성입니다.
실습
- 위 코드의 문제(FIXME 참고)를 수정하여 에러 없이 실행되도록 하세요.
Structure구조체를 형식화하려는 줄의 주석을 제거해 보세요 (TODO 참고).- 표시되는 소수점 자릿수를 제어하여
Pi is roughly 3.142를 출력하는println!매크로 호출을 추가하세요. 이 연습을 위해 파이의 근사값으로let pi = 3.141592를 사용하세요. (힌트: 표시할 소수점 자릿수를 설정하는 방법은std::fmt문서를 확인해야 할 수도 있습니다.)
참고:
std::fmt, 매크로, 구조체, 트레이트, 그리고 dead_code
디버그
std::fmt 형식화 트레이트를 사용하려는 모든 타입은 출력 가능하도록 구현이 필요합니다. 자동 구현은 std 라이브러리와 같은 타입들에 대해서만 제공됩니다. 그 외의 모든 타입은 어떻게든 수동으로 구현_해야만_ 합니다.
fmt::Debug 트레이트는 이를 매우 간단하게 만들어 줍니다. 모든 타입은 fmt::Debug 구현을 derive(자동 생성)할 수 있습니다. 하지만 fmt::Display는 그렇지 않으며 수동으로 구현해야 합니다.
#![allow(unused)]
fn main() {
// 이 구조체는 `fmt::Display`나 `fmt::Debug` 중 어느 것으로도
// 출력할 수 없습니다.
struct UnPrintable(i32);
// `derive` 속성은 이 `구조체`를 `fmt::Debug`로 출력 가능하게 만드는 데
// 필요한 구현을 자동으로 생성합니다.
#[derive(Debug)]
struct DebugPrintable(i32);
}
모든 std 라이브러리 타입들도 {:?}로 자동 출력이 가능합니다:
// `Structure`에 대해 `fmt::Debug` 구현을 유도(derive)합니다. `Structure`는
// 단일 `i32`를 포함하는 구조체입니다.
#[derive(Debug)]
struct Structure(i32);
// `Deep` 구조체 안에 `Structure`를 넣으세요. 그리고 역시 출력 가능하게 만드세요.
#[derive(Debug)]
struct Deep(Structure);
fn main() {
// `{:?}`를 이용한 출력은 `{}`와 유사합니다.
println!("1년은 {:?}개월입니다.", 12);
println!("{1:?} {0:?}은 {actor:?}의 이름입니다.",
"슬레이터",
"크리스찬",
actor="배우의");
// `Structure`는 출력 가능합니다!
println!("이제 {:?}가 출력될 것입니다!", Structure(3));
// `derive`의 문제점은 결과가 어떻게 보일지 제어할 수 없다는 것입니다.
// 만약 이것을 단지 `7`로 보여주고 싶다면 어떡하죠?
println!("이제 {:?}가 출력될 것입니다!", Deep(Structure(7)));
}
따라서 fmt::Debug는 확실히 출력을 가능하게 하지만 우아함을 어느 정도 희생합니다. Rust는 {:#?}를 사용한 “예쁘게 출력하기(pretty printing)“도 제공합니다.
#[derive(Debug)]
struct Person<'a> {
name: &'a str,
age: u8
}
fn main() {
let name = "피터";
let age = 27;
let peter = Person { name, age };
// 예쁘게 출력하기
println!("{:#?}", peter);
}
fmt::Display를 수동으로 구현하여 출력을 제어할 수 있습니다.
참고:
디스플레이
fmt::Debug는 콤팩트하고 깔끔해 보이지 않으므로, 출력 모양을 커스터마이징하는 것이 종종 유리합니다. 이는 {} 출력 마커를 사용하는 fmt::Display를 수동으로 구현함으로써 수행됩니다. 구현은 다음과 같습니다:
#![allow(unused)]
fn main() {
// `fmt` 모듈을 (`use`를 통해) 가져와서 사용할 수 있게 합니다.
use std::fmt;
// `fmt::Display`를 구현할 구조체를 정의합니다. 이것은
// `i32`를 포함하는 `Structure`라는 이름의 튜플 구조체입니다.
struct Structure(i32);
// `{}` 마커를 사용하려면, `fmt::Display` 트레이트가 해당 타입에 대해
// 수동으로 구현되어야 합니다.
impl fmt::Display for Structure {
// 이 트레이트는 정확히 이 시그니처를 가진 `fmt`를 요구합니다.
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// 제공된 출력 스트림 `f`에 첫 번째 요소를 씁니다.
// 작업의 성공 또는 실패 여부를 나타내는 `fmt::Result`를 반환합니다.
// `write!`는 `println!`과 매우 유사한 구문을 사용한다는 점에 유의하세요.
write!(f, "{}", self.0)
}
}
}
fmt::Display가 fmt::Debug보다 깔끔할 수 있지만, 이는 std 라이브러리에 문제를 제기합니다. 모호한 타입들은 어떻게 표시되어야 할까요? 예를 들어, std 라이브러리가 모든 Vec<T>에 대해 단일 스타일을 구현했다면, 어떤 스타일이어야 할까요? 다음 두 가지 중 하나일까요?
Vec<path>:/:/etc:/home/username:/bin(:로 구분)Vec<number>:1,2,3(,로 구분)
아니요, 모든 타입에 이상적인 스타일은 없으며 std 라이브러리가 하나를 독단적으로 정하지 않기 때문입니다. fmt::Display는 Vec<T>나 다른 제네릭 컨테이너에 대해 구현되어 있지 않습니다. 따라서 이러한 제네릭 케이스에는 fmt::Debug를 사용해야 합니다.
하지만 제네릭이 아닌 새로운 컨테이너 타입에 대해서는 fmt::Display를 구현할 수 있으므로 이는 문제가 되지 않습니다.
use std::fmt; // `fmt` 가져오기
// 두 숫자를 보유하는 구조체입니다. `Display`와 대조해 볼 수 있도록
// `Debug`를 유도(derive)할 것입니다.
#[derive(Debug)]
struct MinMax(i64, i64);
// `MinMax`에 대해 `Display`를 구현합니다.
impl fmt::Display for MinMax {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// 각 위치별 데이터 지점을 참조하기 위해 `self.number`를 사용합니다.
write!(f, "({}, {})", self.0, self.1)
}
}
// 비교를 위해 필드에 이름을 붙일 수 있는 구조체를 정의합니다.
#[derive(Debug)]
struct Point2D {
x: f64,
y: f64,
}
// 마찬가지로, `Point2D`에 대해 `Display`를 구현합니다.
impl fmt::Display for Point2D {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// `x`와 `y`만 표시되도록 커스터마이징합니다.
write!(f, "x: {}, y: {}", self.x, self.y)
}
}
fn main() {
let minmax = MinMax(0, 14);
println!("구조체 비교:");
println!("디스플레이: {}", minmax);
println!("디버그: {:?}", minmax);
let big_range = MinMax(-300, 300);
let small_range = MinMax(-3, 3);
println!("큰 범위는 {big}이고 작은 범위는 {small}입니다.",
small = small_range,
big = big_range);
let point = Point2D { x: 3.3, y: 7.2 };
println!("좌표 비교:");
println!("디스플레이: {}", point);
println!("디버그: {:?}", point);
// 에러. `Debug`와 `Display`는 모두 구현되었지만, `{:b}`는
// `fmt::Binary` 구현을 요구합니다. 이것은 작동하지 않을 것입니다.
// println!("Point2D의 2진수 형태는 무엇일까요: {:b}?", point);
}
따라서, fmt::Display는 구현되었지만 fmt::Binary는 구현되지 않았으므로 사용할 수 없습니다. std::fmt에는 이와 같은 많은 트레이트가 있으며 각각은 자체적인 구현을 요구합니다. 이에 대한 자세한 내용은 std::fmt에 나와 있습니다.
실습
위 예제의 출력을 확인한 후, Point2D 구조체를 가이드로 삼아 예제에 Complex 구조체를 추가해 보세요. 같은 방식으로 출력했을 때의 결과는 다음과 같아야 합니다:
Display: 3.3 +7.2i
Debug: Complex { real: 3.3, imag: 7.2 }
Display: 4.7 -2.3i
Debug: Complex { real: 4.7, imag: -2.3 }
보너스: +/- 기호 앞에 공백을 추가하세요.
막혔을 때를 위한 힌트:
참고:
derive, std::fmt, 매크로, 구조체, 트레이트, 그리고 use
테스트케이스: 리스트
각 요소를 순차적으로 처리해야 하는 구조체에 대해 fmt::Display를 구현하는 것은 까다롭습니다. 문제는 각 write!가 fmt::Result를 생성한다는 것입니다. 이를 적절히 처리하려면 모든 결과를 다뤄야 합니다. Rust는 정확히 이 목적을 위해 ? 연산자를 제공합니다.
write!에 ?를 사용하는 것은 다음과 같습니다:
// `write!`가 에러를 발생시키는지 확인합니다. 에러가 발생하면
// 에러를 반환합니다. 그렇지 않으면 계속 진행합니다.
write!(f, "{}", value)?;
?가 사용 가능해지면 Vec에 대한 fmt::Display 구현은 간단해집니다:
use std::fmt; // `fmt` 모듈을 가져옵니다.
// `Vec`을 포함하는 `List`라는 이름의 구조체를 정의합니다.
struct List(Vec<i32>);
impl fmt::Display for List {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// 튜플 인덱싱을 사용하여 값을 추출하고,
// `vec`에 대한 참조를 생성합니다.
let vec = &self.0;
write!(f, "[")?;
// `vec`의 `v`를 순회하면서 반복 인덱스를 `index`에 열거합니다.
for (index, v) in vec.iter().enumerate() {
// 첫 번째를 제외한 모든 요소에 콤마를 추가합니다.
// 에러 시 반환하기 위해 ? 연산자를 사용합니다.
if index != 0 { write!(f, ", ")?; }
write!(f, "{}", v)?;
}
// 열려 있는 대괄호를 닫고 `fmt::Result` 값을 반환합니다.
write!(f, "]")
}
}
fn main() {
let v = List(vec![1, 2, 3]);
println!("{}", v);
}
실습
벡터의 각 요소의 인덱스도 출력되도록 프로그램을 변경해 보세요. 새로운 출력은 다음과 같아야 합니다:
[0: 1, 1: 2, 2: 3]
참고:
for, ref, Result, 구조체, ?, 그리고 vec!
형식화
형식화는 _형식 문자열_을 통해 지정되는 것을 보았습니다:
format!("{}", foo)->"3735928559"format!("0x{:X}", foo)->"0xDEADBEEF"format!("0o{:o}", foo)->"0o33653337357"
동일한 변수(foo)라도 X, o 또는 _지정되지 않음_과 같이 어떤 _인자 타입_이 사용되느냐에 따라 다르게 형식화될 수 있습니다.
이 형식화 기능은 트레이트를 통해 구현되며, 각 인자 타입에 대해 하나의 트레이트가 존재합니다. 가장 일반적인 형식화 트레이트는 Display이며, 인자 타입이 지정되지 않은 경우(예: {})를 처리합니다.
use std::fmt::{self, Formatter, Display};
struct City {
name: &'static str,
// 위도
lat: f32,
// 경도
lon: f32,
}
impl Display for City {
// `f`는 버퍼이며, 이 메서드는 버퍼에 형식화된 문자열을 써야 합니다.
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let lat_c = if self.lat >= 0.0 { '북' } else { '남' };
let lon_c = if self.lon >= 0.0 { '동' } else { '서' };
// `write!`는 `format!`과 비슷하지만, 형식화된 문자열을
// 버퍼(첫 번째 인자)에 씁니다.
write!(f, "{}: {:.3}°{} {:.3}°{}",
self.name, self.lat.abs(), lat_c, self.lon.abs(), lon_c)
}
}
#[derive(Debug)]
struct Color {
red: u8,
green: u8,
blue: u8,
}
fn main() {
for city in [
City { name: "더블린", lat: 53.347778, lon: -6.259722 },
City { name: "오슬로", lat: 59.95, lon: 10.75 },
City { name: "밴쿠버", lat: 49.25, lon: -123.1 },
] {
println!("{}", city);
}
for color in [
Color { red: 128, green: 255, blue: 90 },
Color { red: 0, green: 3, blue: 254 },
Color { red: 0, green: 0, blue: 0 },
] {
// fmt::Display 구현을 추가한 후에는 {}를 사용하도록 변경하세요.
println!("{:?}", color);
}
}
std::fmt 문서에서 형식화 트레이트 전체 목록과 해당 인자 타입을 확인할 수 있습니다.
실습
위의 Color 구조체에 fmt::Display 트레이트 구현을 추가하여 다음과 같이 출력되도록 하세요:
RGB (128, 255, 90) 0x80FF5A
RGB (0, 3, 254) 0x0003FE
RGB (0, 0, 0) 0x000000
막혔을 때를 위한 두 가지 힌트:
- 각 색상을 두 번 이상 나열해야 할 수도 있습니다.
:0>2를 사용하여 0을 채워 너비를 2로 맞출 수 있습니다. 16진수의 경우:02X를 사용할 수 있습니다.
보너스:
- 타입 변환(type casting)을 미리 실험해보고 싶다면, RGB 색 공간에서 색상을 계산하는 공식은
RGB = (R * 65_536) + (G * 256) + B이며, 여기서R은 RED, G는 GREEN, B는 BLUE입니다. 부호 없는 8비트 정수(u8)는 255까지의 숫자만 담을 수 있습니다.u8을u32로 변환하려면variable_name as u32와 같이 쓸 수 있습니다.
참고:
기본 자료형
Rust는 다양한 기본 자료형(primitives)에 대한 접근을 제공합니다. 샘플은 다음과 같습니다:
스칼라 타입
- 부호 있는 정수:
i8,i16,i32,i64,i128및isize(포인터 크기) - 부호 없는 정수:
u8,u16,u32,u64,u128및usize(포인터 크기) - 부동 소수점:
f32,f64 char유니코드 스칼라 값:'a','α','∞'등 (각 4바이트)bool:true또는false- 유닛 타입
(): 가능한 유일한 값은 빈 튜플()입니다.
유닛 타입의 값이 튜플임에도 불구하고, 여러 값을 포함하지 않기 때문에 복합 타입(compound type)으로 간주되지 않습니다.
복합 타입
[1, 2, 3]과 같은 배열(1, true)와 같은 튜플
변수는 항상 _타입 어노테이션_이 가능합니다. 숫자는 추가적으로 _접미사(suffix)_를 통하거나 _기본값_으로 어노테이션될 수 있습니다. 정수 기본값은 i32이고 부동 소수점 기본값은 f64입니다. 또한 Rust는 문맥으로부터 타입을 추론할 수도 있습니다.
fn main() {
// 변수는 타입 어노테이션이 가능합니다.
let logical: bool = true;
let a_float: f64 = 1.0; // 일반적인 어노테이션
let an_integer = 5i32; // 접미사 어노테이션
// 혹은 기본값이 사용됩니다.
let default_float = 3.0; // `f64`
let default_integer = 7; // `i32`
// 타입을 문맥에서 추론할 수도 있습니다.
let mut inferred_type = 12; // i64 타입이 다른 줄에서 추론됩니다.
inferred_type = 4294967296i64;
// 가변 변수의 값은 변경될 수 있습니다.
let mut mutable = 12; // 가변 `i32`
mutable = 21;
// 에러! 변수의 타입은 변경할 수 없습니다.
mutable = true;
// 변수는 섀도잉(shadowing)을 통해 덮어쓸 수 있습니다.
let mutable = true;
/* 복합 타입 - 배열과 튜플 */
// 배열 시그니처는 타입 T와 길이를 포함하여 [T; length] 형식입니다.
let my_array: [i32; 5] = [1, 2, 3, 4, 5];
// 튜플은 서로 다른 타입의 값들을 모아놓은 것이며
// 소괄호 ()를 사용하여 구성됩니다.
let my_tuple = (5u32, 1u8, true, -5.04f32);
}
참고:
std 라이브러리, mut, 추론(inference), 그리고 섀도잉(shadowing)
리터럴과 연산자
정수 1, 부동 소수점 1.2, 문자 'a', 문자열 "abc", 불리언 true 그리고 유닛 타입 ()은 리터럴을 사용하여 표현할 수 있습니다.
정수는 또한 0x, 0o, 0b 접두사를 사용하여 각각 16진수, 8진수, 2진수 표기법으로 표현할 수 있습니다.
가독성을 높이기 위해 숫자 리터럴에 언더스코어(_)를 삽입할 수 있습니다. 예를 들어 1_000은 1000과 같고, 0.000_001은 0.000001과 같습니다.
Rust는 또한 과학적 기수법(E-표기법)을 지원합니다. (예: 1e6, 7.6e-4). 관련 타입은 f64입니다.
우리는 사용하는 리터럴의 타입을 컴파일러에게 알려주어야 합니다. 당분간은 부호 없는 32비트 정수임을 나타내기 위해 u32 접미사를 사용하고, 부호 있는 32비트 정수임을 나타내기 위해 i32 접미사를 사용할 것입니다.
Rust에서의 사용 가능한 연산자와 우선순위는 다른 C 계열 언어들과 유사합니다.
fn main() {
// 정수 덧셈
println!("1 + 2 = {}", 1u32 + 2);
// 정수 뺄셈
println!("1 - 2 = {}", 1i32 - 2);
// TODO ^ `1i32`를 `1u32`로 변경하여 타입이 중요한 이유를 확인해 보세요
// 과학적 표기법
println!("1e4는 {}, -2.5e-3은 {}입니다", 1e4, -2.5e-3);
// 단락(Short-circuiting) 불리언 로직
println!("true AND false는 {}입니다", true && false);
println!("true OR false는 {}입니다", true || false);
println!("NOT true는 {}입니다", !true);
// 비트 연산
println!("0011 AND 0101은 {:04b}입니다", 0b0011u32 & 0b0101);
println!("0011 OR 0101은 {:04b}입니다", 0b0011u32 | 0b0101);
println!("0011 XOR 0101은 {:04b}입니다", 0b0011u32 ^ 0b0101);
println!("1 << 5는 {}입니다", 1u32 << 5);
println!("0x80 >> 2는 0x{:x}입니다", 0x80u32 >> 2);
// 가독성을 높이기 위해 밑줄을 사용하세요!
println!("백만은 {}로 씁니다", 1_000_000u32);
}
튜플
튜플은 서로 다른 타입의 값들의 모음입니다. 튜플은 괄호 ()를 사용하여 생성되며, 각 튜플 자체는 (T1, T2, ...) 형태의 타입 시그니처를 가진 값입니다. 여기서 T1, T2는 튜플 멤버의 타입입니다. 튜플은 여러 값을 담을 수 있으므로 함수에서 여러 값을 반환하는 데 사용할 수 있습니다.
// 튜플은 함수의 인자와 반환값으로 사용될 수 있습니다.
fn reverse(pair: (i32, bool)) -> (bool, i32) {
// `let`을 사용하여 튜플의 멤버를 변수에 바인딩할 수 있습니다.
let (int_param, bool_param) = pair;
(bool_param, int_param)
}
// 다음 구조체는 실습을 위한 것입니다.
#[derive(Debug)]
struct Matrix(f32, f32, f32, f32);
fn main() {
// 여러 다른 타입을 가진 튜플입니다.
let long_tuple = (1u8, 2u16, 3u32, 4u64,
-1i8, -2i16, -3i32, -4i64,
0.1f32, 0.2f64,
'a', true);
// 튜플 인덱싱을 사용하여 튜플에서 값을 추출할 수 있습니다.
println!("긴 튜플의 첫 번째 값: {}", long_tuple.0);
println!("긴 튜플의 두 번째 값: {}", long_tuple.1);
// 튜플은 튜플의 멤버가 될 수 있습니다.
let tuple_of_tuples = ((1u8, 2u16, 2u32), (4u64, -1i8), -2i16);
// 튜플은 출력 가능합니다.
println!("튜플의 튜플: {:?}", tuple_of_tuples);
// 하지만 긴 튜플(요소 12개 초과)은 출력할 수 없습니다.
//let too_long_tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
//println!("Too long tuple: {:?}", too_long_tuple);
// TODO ^ 위의 두 줄의 주석을 해제하여 컴파일 에러를 확인해 보세요
let pair = (1, true);
println!("페어는 {:?}입니다", pair);
println!("반전된 페어는 {:?}입니다", reverse(pair));
// 요소가 하나인 튜플을 만들려면, 괄호로 둘러싸인 리터럴과
// 구분하기 위해 콤마가 필요합니다.
println!("요소가 하나인 튜플: {:?}", (5u32,));
println!("그냥 정수: {:?}", (5u32));
// 튜플은 구조 분해(destructure)하여 바인딩을 생성할 수 있습니다.
let tuple = (1, "안녕", 4.5, true);
let (a, b, c, d) = tuple;
println!("{:?}, {:?}, {:?}, {:?}", a, b, c, d);
let matrix = Matrix(1.1, 1.2, 2.1, 2.2);
println!("{:?}", matrix);
}
실습
-
요약: 위 예제의
Matrix구조체에fmt::Display트레이트를 추가하여, 디버그 형식{:?}에서 디스플레이 형식{}로 출력을 전환했을 때 다음과 같은 출력이 나오도록 하세요:( 1.1 1.2 ) ( 2.1 2.2 )표시 출력에 대한 예제를 다시 참조하고 싶을 수도 있습니다.
-
reverse함수를 템플릿으로 사용하여transpose함수를 추가하세요. 이 함수는 행렬을 인자로 받아 두 요소가 바뀐 행렬을 반환합니다. 예를 들어:println!("행렬:\n{}", matrix); println!("전치(Transpose):\n{}", transpose(matrix));출력 결과:
Matrix: ( 1.1 1.2 ) ( 2.1 2.2 ) Transpose: ( 1.1 2.1 ) ( 1.2 2.2 )
배열과 슬라이스
배열은 연속된 메모리에 저장된 동일한 타입 T를 가진 객체들의 모음입니다. 배열은 대괄호 []를 사용하여 생성되며, 컴파일 타임에 알려진 배열의 길이는 타입 시그니처 [T; length]의 일부가 됩니다.
슬라이스는 배열과 유사하지만, 컴파일 타임에 그 길이를 알 수 없습니다. 대신, 슬라이스는 두 개의 워드(word)로 구성된 객체입니다. 첫 번째 워드는 데이터에 대한 포인터이고, 두 번째 워드는 슬라이스의 길이입니다. 워드 크기는 프로세서 아키텍처에 의해 결정되는 usize와 같으며, 예를 들어 x86-64에서는 64비트입니다. 슬라이스는 배열의 일부분을 빌려오는 데 사용될 수 있으며 &[T]라는 타입 시그니처를 가집니다.
use std::mem;
// 이 함수는 슬라이스를 빌려옵니다.
fn analyze_slice(slice: &[i32]) {
println!("슬라이스의 첫 번째 요소: {}", slice[0]);
println!("슬라이스는 {}개의 요소를 가지고 있습니다", slice.len());
}
fn main() {
// 고정 크기 배열 (타입 시그니처는 생략 가능합니다).
let xs: [i32; 5] = [1, 2, 3, 4, 5];
// 모든 요소를 동일한 값으로 초기화할 수 있습니다.
let ys: [i32; 500] = [0; 500];
// 인덱싱은 0부터 시작합니다.
println!("배열의 첫 번째 요소: {}", xs[0]);
println!("배열의 두 번째 요소: {}", xs[1]);
// `len`은 배열의 요소 개수를 반환합니다.
println!("배열의 요소 개수: {}", xs.len());
// 배열은 스택에 할당됩니다.
println!("배열의 점유 메모리: {} 바이트", mem::size_of_val(&xs));
// 배열은 자동으로 슬라이스로 빌려올 수 있습니다.
println!("배열 전체를 슬라이스로 빌려옵니다.");
analyze_slice(&xs);
// 슬라이스는 배열의 한 섹션을 가리킬 수 있습니다.
// [시작_인덱스..종료_인덱스] 형태입니다.
// `시작_인덱스`는 슬라이스의 첫 번째 위치입니다.
// `종료_인덱스`는 슬라이스의 마지막 위치보다 하나 더 큰 값입니다.
println!("배열의 한 섹션을 슬라이스로 빌려옵니다.");
analyze_slice(&ys[1 .. 4]);
// 빈 슬라이스 `&[]`의 예시:
let empty_array: [u32; 0] = [];
assert_eq!(&empty_array, &[]);
assert_eq!(&empty_array, &[][..]); // 동일하지만 더 장황한 표현
// 배열은 `.get`을 사용하여 안전하게 접근할 수 있으며, 이는 `Option`을 반환합니다.
// 이는 아래와 같이 매치(match)될 수 있으며, 프로그램을 계속 진행하는 대신
// 친절한 메시지와 함께 종료하고 싶다면 `.expect()`와 함께 사용될 수 있습니다.
for i in 0..xs.len() + 1 { // 이런, 범위를 하나 벗어났네요!
match xs.get(i) {
Some(xval) => println!("{}: {}", i, xval),
None => println!("진정하세요! {}는 너무 멀리 갔습니다!", i),
}
}
// 상수 값을 사용한 배열의 범위를 벗어난 인덱싱은 컴파일 타임 에러를 발생시킵니다.
//println!("{}", xs[5]);
// 슬라이스에서 범위를 벗어난 인덱싱은 런타임 에러를 발생시킵니다.
//println!("{}", xs[..][5]);
}
사용자 정의 타입
Rust의 사용자 정의 데이터 타입은 주로 다음 두 키워드를 통해 형성됩니다:
struct: 구조체를 정의합니다enum: 열거형을 정의합니다
상수는 const와 static 키워드를 통해 생성할 수 있습니다.
구조체
struct 키워드를 사용하여 생성할 수 있는 구조체(“structs”)에는 세 가지 유형이 있습니다:
- 기본적으로 이름이 붙은 튜플인 튜플 구조체.
- 전통적인 C 구조체
- 필드가 없는 유닛 구조체로, 제네릭에 유용합니다.
// 사용되지 않는 코드에 대한 경고를 숨기기 위한 속성입니다.
#![allow(dead_code)]
#[derive(Debug)]
struct Person {
name: String,
age: u8,
}
// 유닛 구조체
struct Unit;
// 튜플 구조체
struct Pair(i32, f32);
// 두 개의 필드를 가진 구조체
struct Point {
x: f32,
y: f32,
}
// 구조체는 다른 구조체의 필드로 재사용될 수 있습니다.
struct Rectangle {
// 사각형은 공간상에서 왼쪽 위와 오른쪽 아래 모서리의 위치로 지정될 수 있습니다.
top_left: Point,
bottom_right: Point,
}
fn main() {
// 필드 초기화 축약형(field init shorthand)으로 구조체를 생성합니다.
let name = String::from("피터");
let age = 27;
let peter = Person { name, age };
// 디버그용 구조체 출력
println!("{:?}", peter);
// `Point` 인스턴스 생성
let point: Point = Point { x: 5.2, y: 0.4 };
let another_point: Point = Point { x: 10.3, y: 0.2 };
// 좌표의 필드에 접근
println!("좌표 좌표: ({}, {})", point.x, point.y);
// 구조체 업데이트 구문을 사용하여 다른 포인트의 필드를 활용해
// 새로운 포인트를 만듭니다.
let bottom_right = Point { x: 10.3, ..another_point };
// `another_point`의 필드를 사용했으므로 `bottom_right.y`는 `another_point.y`와 동일합니다.
println!("두 번째 좌표: ({}, {})", bottom_right.x, bottom_right.y);
// `let` 바인딩을 사용하여 좌표를 구조 분해합니다.
let Point { x: left_edge, y: top_edge } = point;
let _rectangle = Rectangle {
// 구조체 인스턴스 생성은 표현식이기도 합니다.
top_left: Point { x: left_edge, y: top_edge },
bottom_right: bottom_right,
};
// 유닛 구조체 인스턴스 생성
let _unit = Unit;
// 튜플 구조체 인스턴스 생성
let pair = Pair(1, 0.1);
// 튜플 구조체의 필드에 접근
println!("페어는 {:?}와 {:?}를 포함합니다", pair.0, pair.1);
// 튜플 구조체 구조 분해
let Pair(integer, decimal) = pair;
println!("페어는 {:?}와 {:?}를 포함합니다", integer, decimal);
}
실습
Rectangle의 넓이를 계산하는rect_area함수를 추가하세요. (중첩된 구조 분해를 사용해 보세요).Point와f32를 인자로 받아, 해당 포인트를 왼쪽 위 모서리로 하고f32에 해당하는 너비와 높이를 가진Rectangle을 반환하는square함수를 추가하세요.
참고
열거형
enum 키워드는 몇 가지 다른 변체 중 하나일 수 있는 타입을 생성할 수 있게 합니다. struct로 유효한 모든 변체는 enum에서도 유효합니다.
// 웹 이벤트를 분류하기 위한 `enum`을 생성합니다. 이름과 타입 정보가
// 함께 변체를 지정하는 방식에 주목하세요:
// `PageLoad != PageUnload` 이고 `KeyPress(char) != Paste(String)` 입니다.
// 각각은 서로 다르고 독립적입니다.
enum WebEvent {
// `enum` 변체는 유닛 형태(unit-like)일 수도 있고,
PageLoad,
PageUnload,
// 튜플 구조체 형태일 수도 있으며,
KeyPress(char),
Paste(String),
// 또는 C 구조체 형태일 수도 있습니다.
Click { x: i64, y: i64 },
}
// `WebEvent` 열거형을 인자로 받고 아무것도 반환하지 않는 함수입니다.
fn inspect(event: WebEvent) {
match event {
WebEvent::PageLoad => println!("페이지 로드됨"),
WebEvent::PageUnload => println!("페이지 언로드됨"),
// `enum` 변체 내부에서 `c`를 구조 분해합니다.
WebEvent::KeyPress(c) => println!("'{}' 키가 눌렸습니다.", c),
WebEvent::Paste(s) => println!("\"{}\"가 붙여넣기 되었습니다.", s),
// `Click`을 `x`와 `y`로 구조 분해합니다.
WebEvent::Click { x, y } => {
println!("x={}, y={} 지점이 클릭되었습니다.", x, y);
},
}
}
fn main() {
let pressed = WebEvent::KeyPress('x');
// `to_owned()`는 문자열 슬라이스로부터 소유권이 있는 `String`을 생성합니다.
let pasted = WebEvent::Paste("나의 텍스트".to_owned());
let click = WebEvent::Click { x: 20, y: 80 };
let load = WebEvent::PageLoad;
let unload = WebEvent::PageUnload;
inspect(pressed);
inspect(pasted);
inspect(click);
inspect(load);
inspect(unload);
}
타입 별칭
타입 별칭을 사용하면, 별칭을 통해 각 열거형 변체를 참조할 수 있습니다. 이는 열거형의 이름이 너무 길거나 너무 일반적이어서 이름을 바꾸고 싶을 때 유용할 수 있습니다.
enum VeryVerboseEnumOfThingsToDoWithNumbers {
Add,
Subtract,
}
// 타입 별칭을 생성합니다
type Operations = VeryVerboseEnumOfThingsToDoWithNumbers;
fn main() {
// 길고 불편한 이름 대신 별칭을 통해 각 변체를 참조할 수 있습니다.
let x = Operations::Add;
}
이를 가장 흔히 볼 수 있는 곳은 Self 별칭을 사용하는 impl 블록 내부입니다.
enum VeryVerboseEnumOfThingsToDoWithNumbers {
Add,
Subtract,
}
impl VeryVerboseEnumOfThingsToDoWithNumbers {
fn run(&self, x: i32, y: i32) -> i32 {
match self {
Self::Add => x + y,
Self::Subtract => x - y,
}
}
}
열거형과 타입 별칭에 대해 더 자세히 알아보려면, 이 기능이 Rust에서 안정화되었을 때의 안정화 보고서를 읽어보세요.
참고:
match, fn, 그리고 String, “타입 별칭 열거형 변체” RFC
use
use 선언을 사용하면 이름을 사용하기 위해 전체 모듈 경로를 입력하는 것을 피할 수 있습니다:
// 사용되지 않는 코드에 대한 경고를 숨기기 위한 속성입니다.
#![allow(dead_code)]
enum Stage {
Beginner,
Advanced,
}
enum Role {
Student,
Teacher,
}
fn main() {
// 각 이름을 명시적으로 `use`하여 수동으로 스코프를 지정하지 않고도
// 사용할 수 있게 합니다.
use Stage::{Beginner, Advanced};
// `Role` 내부의 각 이름을 자동으로 `use`합니다.
use Role::*;
// `Stage::Beginner`와 동일합니다.
let stage = Beginner;
// `Role::Student`와 동일합니다.
let role = Student;
match stage {
// 위의 명시적 `use` 덕분에 스코프 지정이 필요 없음에 주목하세요.
Beginner => println!("초보자들이 학습 여정을 시작합니다!"),
Advanced => println!("상급 학습자들이 주제를 마스터하고 있습니다..."),
}
match role {
// 다시 한번 스코프 지정이 없음에 주목하세요.
Student => println!("학생들이 지식을 습득하고 있습니다!"),
Teacher => println!("교사들이 지식을 전파하고 있습니다!"),
}
}
참고:
C-like
enum은 C 스타일의 열거형으로도 사용될 수 있습니다.
// 사용되지 않는 코드에 대한 경고를 숨기기 위한 속성입니다.
#![allow(dead_code)]
// 암시적 식별자(0부터 시작)를 가진 열거형
enum Number {
Zero,
One,
Two,
}
// 명시적 식별자를 가진 열거형
enum Color {
Red = 0xff0000,
Green = 0x00ff00,
Blue = 0x0000ff,
}
fn main() {
// `enum`은 정수로 형변환(cast)될 수 있습니다.
println!("0은 {}입니다", Number::Zero as i32);
println!("1은 {}입니다", Number::One as i32);
println!("장미는 #{:06x}색입니다", Color::Red as u32);
println!("제비꽃은 #{:06x}색입니다", Color::Blue as u32);
}
참고:
테스트케이스: 연결 리스트
연결 리스트를 구현하는 일반적인 방법은 enums를 사용하는 것입니다:
use crate::List::*;
enum List {
// Cons: 요소와 다음 노드에 대한 포인터를 감싸는 튜플 구조체
Cons(u32, Box<List>),
// Nil: 연결 리스트의 끝을 나타내는 노드
Nil,
}
// 열거형에 메서드를 붙일 수 있습니다
impl List {
// 빈 리스트를 생성합니다
fn new() -> List {
// `Nil`은 `List` 타입을 가집니다
Nil
}
// 리스트를 소비하고, 그 앞에 새로운 요소가 추가된 동일한 리스트를 반환합니다
fn prepend(self, elem: u32) -> List {
// `Cons` 역시 `List` 타입을 가집니다
Cons(elem, Box::new(self))
}
// 리스트의 길이를 반환합니다
fn len(&self) -> u32 {
// 메서드의 동작이 `self`의 변체에 따라 달라지므로 `self`에 대해 매치(match)를 수행해야 합니다.
// `self`는 `&List` 타입이고, `*self`는 `List` 타입입니다. 구체적인 타입 `T`에 대한 매칭이
// 참조 `&T`에 대한 매칭보다 선호됩니다.
// Rust 2018 이후에는 여기서 self를 사용하고 아래에서 tail(ref 없이)을 사용할 수도 있습니다.
// Rust가 &s와 ref tail을 추론할 것입니다.
// 참고: https://doc.rust-lang.org/edition-guide/rust-2018/ownership-and-lifetimes/default-match-bindings.html
match *self {
// `self`가 빌려온 상태이므로 꼬리(tail)의 소유권을 가질 수 없습니다.
// 대신 꼬리에 대한 참조를 가져옵니다.
// 그리고 이것은 꼬리 재귀가 아닌 호출이므로 긴 리스트의 경우 스택 오버플로우가 발생할 수 있습니다.
Cons(_, ref tail) => 1 + tail.len(),
// 기저 사례(Base Case): 빈 리스트의 길이는 0입니다
Nil => 0
}
}
// 리스트를 (힙에 할당된) 문자열 표현으로 반환합니다
fn stringify(&self) -> String {
match *self {
Cons(head, ref tail) => {
// `format!`은 `print!`와 유사하지만, 콘솔에 출력하는 대신
// 힙에 할당된 문자열을 반환합니다
format!("{}, {}", head, tail.stringify())
},
Nil => {
format!("Nil")
},
}
}
}
fn main() {
// 빈 연결 리스트를 생성합니다
let mut list = List::new();
// 몇 가지 요소를 앞에 추가합니다
list = list.prepend(1);
list = list.prepend(2);
list = list.prepend(3);
// 리스트의 최종 상태를 보여줍니다
println!("연결 리스트의 길이는 {}입니다", list.len());
println!("{}", list.stringify());
}
참고:
상수
Rust에는 전역 범위를 포함한 모든 범위에서 선언할 수 있는 두 가지 유형의 상수가 있습니다. 둘 다 명시적인 타입 어노테이션이 필요합니다:
const: 변경할 수 없는 값 (일반적인 경우).static:'static라이프타임을 가진, 가변적일 수 있는 변수입니다. static 라이프타임은 추론되므로 명시할 필요는 없습니다. 가변적인 static 변수에 접근하거나 수정하는 것은unsafe합니다.
// 전역 변수는 다른 모든 범위의 바깥에 선언됩니다.
static LANGUAGE: &str = "러스트";
const THRESHOLD: i32 = 10;
fn is_big(n: i32) -> bool {
// 어떤 함수 내에서 상수에 접근
n > THRESHOLD
}
fn main() {
let n = 16;
// 메인 스레드에서 상수에 접근
println!("이것은 {}입니다", LANGUAGE);
println!("임계값은 {}입니다", THRESHOLD);
println!("{}은(는) {}입니다", n, if is_big(n) { "큰" } else { "작은" });
// 에러! `const`는 수정할 수 없습니다.
THRESHOLD = 5;
// FIXME ^ 이 줄을 주석 처리하세요
}
참고:
const/static RFC, 'static 라이프타임
변수 바인딩
Rust는 정적 타이핑을 통해 타입 안전성을 제공합니다. 변수 바인딩은 선언 시 타입 어노테이션을 할 수 있습니다. 하지만 대부분의 경우, 컴파일러는 문맥으로부터 변수의 타입을 추론할 수 있어 어노테이션의 부담을 크게 줄여줍니다.
값(리터럴 등)은 let 바인딩을 사용하여 변수에 바인딩될 수 있습니다.
fn main() {
let an_integer = 1u32;
let a_boolean = true;
let unit = ();
// `an_integer`를 `copied_integer`로 복사합니다
let copied_integer = an_integer;
println!("정수: {:?}", copied_integer);
println!("불리언: {:?}", a_boolean);
println!("유닛 값을 만나보세요: {:?}", unit);
// 컴파일러는 사용되지 않는 변수 바인딩에 대해 경고를 보냅니다. 이러한 경고는
// 변수 이름 앞에 밑줄을 붙여서 비활성화할 수 있습니다.
let _unused_variable = 3u32;
let noisy_unused_variable = 2u32;
// FIXME ^ 경고를 억제하기 위해 밑줄을 접두사로 붙이세요
// 브라우저에서는 경고가 표시되지 않을 수 있음에 유의하세요
}
가변성
변수 바인딩은 기본적으로 불변(immutable)이지만, mut 수식어를 사용하여 이를 재정의할 수 있습니다.
fn main() {
let _immutable_binding = 1;
let mut mutable_binding = 1;
println!("변경 전: {}", mutable_binding);
// 좋습니다
mutable_binding += 1;
println!("변경 후: {}", mutable_binding);
// 에러! 불변 변수에는 새로운 값을 할당할 수 없습니다
_immutable_binding += 1;
}
컴파일러는 가변성 에러에 대해 상세한 진단을 제공할 것입니다.
스코프와 섀도잉
변수 바인딩은 스코프(범위)를 가지며, 블록 내에 거주하도록 제한됩니다. 블록은 중괄호 {}로 둘러싸인 문장들의 모음입니다.
fn main() {
// 이 바인딩은 메인 함수에 거주합니다
let long_lived_binding = 1;
// 이것은 블록이며, 메인 함수보다 더 작은 스코프를 가집니다
{
// 이 바인딩은 이 블록 내에만 존재합니다
let short_lived_binding = 2;
println!("내부 short: {}", short_lived_binding);
}
// 블록의 끝
// 에러! `short_lived_binding`은 이 스코프에 존재하지 않습니다
println!("외부 short: {}", short_lived_binding);
// FIXME ^ 이 줄을 주석 처리하세요
println!("외부 long: {}", long_lived_binding);
}
또한, 변수 섀도잉이 허용됩니다.
fn main() {
let shadowed_binding = 1;
{
println!("섀도잉 전: {}", shadowed_binding);
// 이 바인딩은 외부 바인딩을 *섀도잉*합니다
let shadowed_binding = "abc";
println!("내부 블록에서 섀도잉됨: {}", shadowed_binding);
}
println!("내부 블록 밖: {}", shadowed_binding);
// 이 바인딩은 이전 바인딩을 *섀도잉*합니다
let shadowed_binding = 2;
println!("외부 블록에서 섀도잉됨: {}", shadowed_binding);
}
선언 우선
변수 바인딩을 먼저 선언하고 나중에 초기화하는 것이 가능하지만, 모든 변수 바인딩은 사용되기 전에 초기화되어야 합니다. 컴파일러는 정의되지 않은 동작을 초래할 수 있는 초기화되지 않은 변수 바인딩의 사용을 금지합니다.
변수 바인딩을 선언하고 나중에 함수 내에서 초기화하는 것은 일반적이지 않습니다. 선언과 초기화가 분리되어 있으면 독자가 초기화 지점을 찾기 더 어렵기 때문입니다. 변수가 사용될 위치 근처에서 선언과 초기화를 함께 하는 것이 일반적입니다.
fn main() {
// 변수 바인딩 선언
let a_binding;
{
let x = 2;
// 바인딩 초기화
a_binding = x * x;
}
println!("바인딩 a: {}", a_binding);
let another_binding;
// 에러! 초기화되지 않은 바인딩 사용
println!("또 다른 바인딩: {}", another_binding);
// FIXME ^ 이 줄을 주석 처리하세요
another_binding = 1;
println!("또 다른 바인딩: {}", another_binding);
}
동결
데이터가 동일한 이름으로 불변하게 바인딩되면, 해당 데이터는 _동결(freeze)_됩니다. 동결된 데이터는 불변 바인딩이 스코프를 벗어날 때까지 수정할 수 없습니다.
fn main() {
let mut _mutable_integer = 7i32;
{
// 불변 `_mutable_integer`에 의한 섀도잉
let _mutable_integer = _mutable_integer;
// 에러! `_mutable_integer`는 이 스코프에서 동결되었습니다
_mutable_integer = 50;
// FIXME ^ 이 줄을 주석 처리하세요
// `_mutable_integer`가 스코프를 벗어납니다
}
// 좋습니다! `_mutable_integer`는 이 스코프에서 동결되지 않았습니다
_mutable_integer = 3;
}
타입
Rust는 기본 타입과 사용자 정의 타입의 타입을 변경하거나 정의하기 위한 여러 메커니즘을 제공합니다. 다음 섹션에서는 다음을 다룹니다:
- 기본 타입 간의 형변환(Casting)
- 리터럴의 원하는 타입을 지정하는 법
- 타입 추론(type inference) 사용
- 타입 별칭(Aliasing)
형변환
Rust는 기본 타입 간의 암시적 타입 변환(강제)을 제공하지 않습니다. 하지만 as 키워드를 사용하여 명시적 타입 변환(캐스팅)을 수행할 수 있습니다.
정수 타입 간의 변환 규칙은 일반적으로 C 언어의 관례를 따르지만, C에서 정의되지 않은 동작(undefined behavior)이 발생하는 경우는 예외입니다. Rust에서 정수 타입 간의 모든 캐스팅 동작은 잘 정의되어 있습니다.
// 오버플로우가 발생하는 캐스팅으로 인한 모든 에러를 억제합니다.
#![allow(overflowing_literals)]
fn main() {
let decimal = 65.4321_f32;
// 에러! 암시적 변환 없음
let integer: u8 = decimal;
// FIXME ^ 이 줄을 주석 처리하세요
// 명시적 변환
let integer = decimal as u8;
let character = integer as char;
// 에러! 변환 규칙에 제한이 있습니다.
// 부동 소수점은 문자로 직접 변환할 수 없습니다.
let character = decimal as char;
// FIXME ^ 이 줄을 주석 처리하세요
println!("형변환: {} -> {} -> {}", decimal, integer, character);
// 값을 부호 없는 타입 T로 캐스팅할 때, #![allow(overflowing_literals)]
// 린트가 위와 같이 지정된 경우에만 값이 새로운 타입에 맞을 때까지
// T::MAX + 1을 더하거나 뺍니다. 그렇지 않으면 컴파일 에러가 발생합니다.
// 1000은 이미 u16에 들어맞습니다
println!("1000을 u16으로 변환하면: {}", 1000 as u16);
// 1000 - 256 - 256 - 256 = 232
// 내부적으로는 첫 8개의 최하위 비트(LSB)는 유지되고,
// 최상위 비트(MSB) 쪽의 나머지 비트들은 잘려 나갑니다.
println!("1000을 u8로 변환하면 : {}", 1000 as u8);
// -1 + 256 = 255
println!(" -1을 u8로 변환하면 : {}", (-1i8) as u8);
// 양수의 경우, 이는 나머지 연산(modulus)과 동일합니다
println!("1000 mod 256은 : {}", 1000 % 256);
// 부호 있는 타입으로 캐스팅할 때, (비트 단위) 결과는 먼저
// 대응하는 부호 없는 타입으로 캐스팅하는 것과 같습니다. 만약 그 값의
// 최상위 비트가 1이면, 그 값은 음수입니다.
// 물론 이미 들어맞는 경우는 제외합니다.
println!(" 128을 i16으로 변환하면: {}", 128 as i16);
// 경계 케이스의 경우, 8비트 2의 보수 표현에서 128이라는 값은 -128입니다.
println!(" 128을 i8로 변환하면 : {}", 128 as i8);
// 위 예제를 반복합니다
// 1000을 u8로 변환 -> 232
println!("1000을 u8로 변환하면 : {}", 1000 as u8);
// 그리고 8비트 2의 보수 표현에서 232라는 값은 -24입니다.
println!(" 232를 i8로 변환하면 : {}", 232 as i8);
// Rust 1.45부터, 부동 소수점을 정수로 캐스팅할 때 `as` 키워드는
// *포화 캐스팅(saturating cast)*을 수행합니다. 부동 소수점 값이
// 상한을 초과하거나 하한보다 작으면, 반환되는 값은 해당 경계값이 됩니다.
// 300.0을 u8로 변환하면 255입니다
println!(" 300.0을 u8로 변환하면 : {}", 300.0_f32 as u8);
// -100.0을 u8로 변환하면 0입니다
println!("-100.0을 u8로 변환하면 : {}", -100.0_f32 as u8);
// nan을 u8로 변환하면 0입니다
println!(" nan을 u8로 변환하면 : {}", f32::NAN as u8);
// 이러한 동작은 약간의 런타임 비용을 발생시키며, unsafe 메서드를 사용하여
// 피할 수 있습니다. 하지만 결과가 오버플로우되어 **불건전한 값(unsound values)**을
// 반환할 수 있습니다. 이러한 메서드들은 현명하게 사용하세요:
unsafe {
// 300.0을 u8로 변환하면 44입니다
println!(" 300.0을 u8로 변환하면 : {}", 300.0_f32.to_int_unchecked::<u8>());
// -100.0을 u8로 변환하면 156입니다
println!("-100.0을 u8로 변환하면 : {}", (-100.0_f32).to_int_unchecked::<u8>());
// nan을 u8로 변환하면 0입니다
println!(" nan을 u8로 변환하면 : {}", f32::NAN.to_int_unchecked::<u8>());
}
}
리터럴
숫자 리터럴은 타입 이름을 접미사로 추가하여 타입 어노테이션을 할 수 있습니다. 예를 들어, 리터럴 42가 i32 타입을 가져야 함을 지정하려면 42i32라고 씁니다.
접미사가 없는 숫자 리터럴의 타입은 어떻게 사용되느냐에 따라 달라집니다. 아무런 제약이 없다면 컴파일러는 정수에 대해서는 i32를, 부동 소수점 숫자에 대해서는 f64를 사용합니다.
fn main() {
// 접미사가 붙은 리터럴은 초기화 시 그 타입을 알 수 있습니다.
let x = 1u8;
let y = 2u32;
let z = 3f32;
// 접미사가 없는 리터럴의 타입은 어떻게 사용되느냐에 따라 달라집니다.
let i = 1;
let f = 1.0;
// `size_of_val`은 변수의 크기를 바이트 단위로 반환합니다.
println!(`x`의 바이트 크기: {}, std::mem::size_of_val(&x));
println!(`y`의 바이트 크기: {}, std::mem::size_of_val(&y));
println!(`z`의 바이트 크기: {}, std::mem::size_of_val(&z));
println!(`i`의 바이트 크기: {}, std::mem::size_of_val(&i));
println!(`f`의 바이트 크기: {}, std::mem::size_of_val(&f));
}
앞선 코드에서 아직 설명되지 않은 몇 가지 개념이 사용되었습니다. 성급한 독자들을 위한 간단한 설명은 다음과 같습니다:
std::mem::size_of_val은 함수이지만, _전체 경로(full path)_를 사용하여 호출되었습니다. 코드는 _모듈_이라고 불리는 논리적 단위로 나뉠 수 있습니다. 이 경우,size_of_val함수는mem모듈에 정의되어 있고,mem모듈은std_크레이트_에 정의되어 있습니다. 자세한 내용은 모듈 및 크레이트를 참조하세요.
추론
타입 추론 엔진은 꽤 똑똑합니다. 초기화 중에 값 표현식의 타입을 살펴보는 것 이상의 일을 합니다. 또한 변수가 이후에 어떻게 사용되는지를 보고 타입을 추론합니다. 다음은 타입 추론의 심화 예시입니다:
fn main() {
// 어노테이션 덕분에 컴파일러는 `elem`이 u8 타입임을 압니다.
let elem = 5u8;
// 빈 벡터(가변 크기 배열)를 생성합니다.
let mut vec = Vec::new();
// 이 시점에서 컴파일러는 `vec`의 정확한 타입을 알지 못하며,
// 단지 무언가의 벡터(`Vec<_>`)라는 것만 압니다.
// 벡터에 `elem`을 삽입합니다.
vec.push(elem);
// 아하! 이제 컴파일러는 `vec`이 `u8`들의 벡터(`Vec<u8>`)임을 압니다.
// TODO ^ `vec.push(elem)` 줄을 주석 처리해 보세요
println!("{:?}", vec);
}
변수의 타입 어노테이션이 필요하지 않았습니다. 컴파일러도 만족하고 프로그래머도 행복합니다!
별칭
type 문을 사용하면 기존 타입에 새로운 이름을 부여할 수 있습니다. 타입 이름은 UpperCamelCase여야 하며, 그렇지 않으면 컴파일러가 경고를 보냅니다. 이 규칙의 예외는 기본 타입인 usize, f32 등입니다.
// `NanoSecond`, `Inch`, `U64`는 `u64`를 가리키는 새로운 이름입니다.
type NanoSecond = u64;
type Inch = u64;
type U64 = u64;
fn main() {
// `NanoSecond` = `Inch` = `U64` = `u64`입니다.
let nanoseconds: NanoSecond = 5 as u64;
let inches: Inch = 2 as U64;
// 타입 별칭은 새로운 타입이 아니기 때문에, 추가적인 타입 안전성을
// 제공하지 않는다는 점에 유의하세요.
println!("{} 나노초 + {} 인치 = {} 유닛?",
nanoseconds,
inches,
nanoseconds + inches);
}
별칭의 주요 용도는 보일러플레이트(boilerplate)를 줄이는 것입니다. 예를 들어 io::Result<T> 타입은 Result<T, io::Error> 타입의 별칭입니다.
참고:
타입 변환
기본 타입들은 캐스팅(casting)을 통해 서로 변환될 수 있습니다.
Rust는 트레이트(traits)를 사용하여 사용자 정의 타입(예: struct, enum) 간의 변환을 처리합니다. 범용 변환에는 From과 Into 트레이트를 사용합니다. 하지만 더 흔한 경우, 특히 String과의 상호 변환을 위한 더 구체적인 트레이트들이 존재합니다.
From과 Into
From과 Into 트레이트는 본질적으로 연결되어 있으며, 이는 실제로 구현의 일부입니다. 만약 타입 B로부터 타입 A를 만들 수 있다면(From), 타입 B를 타입 A로 변환할 수 있다(Into)는 것을 쉽게 알 수 있습니다.
From
From 트레이트는 한 타입이 다른 타입으로부터 자신을 생성하는 방법을 정의할 수 있게 하여, 여러 타입 간의 변환을 위한 매우 단순한 메커니즘을 제공합니다. 표준 라이브러리에는 기본 타입 및 일반적인 타입의 변환을 위해 이 트레이트가 수없이 많이 구현되어 있습니다.
예를 들어, str을 String으로 쉽게 변환할 수 있습니다.
#![allow(unused)]
fn main() {
let my_str = "안녕";
let my_string = String::from(my_str);
}
우리는 자신의 타입에 대한 변환을 정의하기 위해 유사한 작업을 수행할 수 있습니다.
use std::convert::From;
#[derive(Debug)]
struct Number {
value: i32,
}
impl From<i32> for Number {
fn from(item: i32) -> Self {
Number { value: item }
}
}
fn main() {
let num = Number::from(30);
println!("제 숫자는 {:?}입니다", num);
}
Into
Into 트레이트는 단순히 From 트레이트의 반대입니다. 이는 한 타입을 다른 타입으로 변환하는 방법을 정의합니다.
into()를 호출할 때는 일반적으로 결과 타입을 지정해야 합니다. 대부분의 경우 컴파일러가 이를 결정할 수 없기 때문입니다.
use std::convert::Into;
#[derive(Debug)]
struct Number {
value: i32,
}
impl Into<Number> for i32 {
fn into(self) -> Number {
Number { value: self }
}
}
fn main() {
let int = 5;
// 타입 어노테이션을 제거해 보세요
let num: Number = int.into();
println!("제 숫자는 {:?}입니다", num);
}
From과 Into는 상호 교환 가능합니다
From과 Into는 상호 보완적으로 설계되었습니다. 두 트레이트 모두에 대해 구현을 제공할 필요는 없습니다. 만약 자신의 타입에 대해 From 트레이트를 구현했다면, Into는 필요할 때 이를 호출할 것입니다. 하지만 그 반대는 성립하지 않음에 유의하세요. 즉, 자신의 타입에 대해 Into를 구현한다고 해서 From 구현이 자동으로 제공되지는 않습니다.
use std::convert::From;
#[derive(Debug)]
struct Number {
value: i32,
}
// `From` 정의
impl From<i32> for Number {
fn from(item: i32) -> Self {
Number { value: item }
}
}
fn main() {
let int = 5;
// `Into` 사용
let num: Number = int.into();
println!("제 숫자는 {:?}입니다", num);
}
TryFrom과 TryInto
From과 Into와 유사하게, TryFrom과 TryInto는 타입 간 변환을 위한 제네릭 트레이트입니다. From/Into와 달리, TryFrom/TryInto 트레이트는 실패할 가능성이 있는 변환에 사용되며, 따라서 Result를 반환합니다.
use std::convert::TryFrom;
use std::convert::TryInto;
#[derive(Debug, PartialEq)]
struct EvenNumber(i32);
impl TryFrom<i32> for EvenNumber {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
if value % 2 == 0 {
Ok(EvenNumber(value))
} else {
Err(())
}
}
}
fn main() {
// TryFrom
assert_eq!(EvenNumber::try_from(8), Ok(EvenNumber(8)));
assert_eq!(EvenNumber::try_from(5), Err(()));
// TryInto
let result: Result<EvenNumber, ()> = 8i32.try_into();
assert_eq!(result, Ok(EvenNumber(8)));
let result: Result<EvenNumber, ()> = 5i32.try_into();
assert_eq!(result, Err(()));
}
String 변환
문자열로 변환하기
어떤 타입을 String으로 변환하는 것은 해당 타입에 대해 ToString 트레이트를 구현하는 것만큼 간단합니다. 직접 구현하기보다는 fmt::Display 트레이트를 구현하는 것이 좋습니다. 이는 자동으로 ToString을 제공할 뿐만 아니라, print! 섹션에서 다룬 것처럼 해당 타입을 출력할 수 있게 해줍니다.
use std::fmt;
struct Circle {
radius: i32
}
impl fmt::Display for Circle {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "반지름이 {}인 원", self.radius)
}
}
fn main() {
let circle = Circle { radius: 6 };
println!("{}", circle.to_string());
}
문자열 파싱하기
문자열을 여러 타입으로 변환하는 것은 유용하지만, 가장 흔한 작업 중 하나는 문자열을 숫자로 변환하는 것입니다. 이에 대한 관용적인 방법은 parse 함수를 사용하는 것이며, 타입 추론을 이용하거나 ‘turbofish’ 구문을 사용하여 파싱할 타입을 지정할 수 있습니다. 다음 예제에 두 가지 방식이 모두 나와 있습니다.
해당 타입에 대해 FromStr 트레이트가 구현되어 있다면 문자열을 지정된 타입으로 변환합니다. 표준 라이브러리의 수많은 타입에 대해 이 트레이트가 구현되어 있습니다.
fn main() {
let parsed: i32 = "5".parse().unwrap();
let turbo_parsed = "10".parse::<i32>().unwrap();
let sum = parsed + turbo_parsed;
println!("합계: {:?}", sum);
}
사용자 정의 타입에서 이 기능을 사용하려면 해당 타입에 대해 FromStr 트레이트를 구현하기만 하면 됩니다.
use std::num::ParseIntError;
use std::str::FromStr;
#[derive(Debug)]
struct Circle {
radius: i32,
}
impl FromStr for Circle {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().parse() {
Ok(num) => Ok(Circle{ radius: num }),
Err(e) => Err(e),
}
}
}
fn main() {
let radius = " 3 ";
let circle: Circle = radius.parse().unwrap();
println!("{:?}", circle);
}
표현식
Rust 프로그램은 (대부분) 일련의 문장(statements)으로 구성됩니다:
fn main() {
// 문장
// 문장
// 문장
}
Rust에는 몇 가지 종류의 문장이 있습니다. 가장 흔한 두 가지는 변수 바인딩을 선언하는 것과 표현식에 ;를 사용하는 것입니다:
fn main() {
// 변수 바인딩
let x = 5;
// 표현식;
x;
x + 1;
15;
}
블록도 표현식이므로 할당 시 값으로 사용될 수 있습니다. 블록의 마지막 표현식은 로컬 변수와 같은 장소 표현식(place expression)에 할당됩니다. 하지만 블록의 마지막 표현식이 세미콜론으로 끝나면 반환 값은 ()가 됩니다.
fn main() {
let x = 5u32;
let y = {
let x_squared = x * x;
let x_cube = x_squared * x;
// 이 표현식은 `y`에 할당됩니다
x_cube + x_squared + x
};
let z = {
// 세미콜론이 이 표현식을 억제하여 `z`에는 `()`가 할당됩니다
2 * x;
};
println!("x는 {:?}입니다", x);
println!("y는 {:?}입니다", y);
println!("z는 {:?}입니다", z);
}
제어 흐름
모든 프로그래밍 언어의 필수적인 부분은 제어 흐름을 수정하는 방법입니다: if/else, for 등. Rust에서의 제어 흐름에 대해 이야기해 봅시다.
if/else
if-else를 사용한 분기는 다른 언어와 유사합니다. 많은 언어와 달리 불리언 조건은 괄호로 둘러쌀 필요가 없으며, 각 조건 뒤에는 블록이 옵니다. if-else 조건문은 표현식이며, 모든 분기는 동일한 타입을 반환해야 합니다.
fn main() {
let n = 5;
if n < 0 {
print!("{}은(는) 음수입니다", n);
} else if n > 0 {
print!("{}은(는) 양수입니다", n);
} else {
print!("{}은(는) 0입니다", n);
}
let big_n =
if n < 10 && n > -10 {
println!(", 그리고 작은 수이므로 10배 증가시킵니다");
// 이 표현식은 `i32`를 반환합니다.
10 * n
} else {
println!(", 그리고 큰 수이므로 반으로 나눕니다");
// 이 표현식 또한 `i32`를 반환해야 합니다.
n / 2
// TODO ^ 세미콜론을 사용하여 이 표현식을 억제해 보세요.
};
// ^ 여기에 세미콜론을 넣는 것을 잊지 마세요! 모든 `let` 바인딩에 필요합니다.
println!("{} -> {}", n, big_n);
}
loop
Rust는 무한 루프를 나타내기 위해 loop 키워드를 제공합니다.
break 문은 언제든지 루프를 종료하는 데 사용할 수 있으며, continue 문은 나머지 반복을 건너뛰고 새로운 반복을 시작하는 데 사용될 수 있습니다.
fn main() {
let mut count = 0u32;
println!("무한대까지 세어봅시다!");
// 무한 루프
loop {
count += 1;
if count == 3 {
println!("3");
// 이번 반복의 나머지를 건너뜁니다
continue;
}
println!("{}", count);
if count == 5 {
println!("좋아요, 그만하면 됐습니다");
// 이 루프를 종료합니다
break;
}
}
}
중첩과 레이블
중첩 루프를 다룰 때 외부 루프를 break하거나 continue하는 것이 가능합니다. 이 경우 루프에는 어떤 'label을 붙여야 하며, 해당 레이블은 break/continue 문에 전달되어야 합니다.
#![allow(unreachable_code, unused_labels)]
fn main() {
'outer: loop {
println!("외부 루프에 진입했습니다");
'inner: loop {
println!("내부 루프에 진입했습니다");
// 이것은 내부 루프만 종료시킵니다
//break;
// 이것은 외부 루프를 종료시킵니다
break 'outer;
}
println!("이 지점에는 결코 도달하지 않습니다");
}
println!("외부 루프에서 나갔습니다");
}
반복문에서 리턴하기
성공할 때까지 작업을 재시도하는 것이 loop의 용도 중 하나입니다. 만약 작업이 값을 반환한다면, 이를 코드의 나머지 부분으로 전달해야 할 수도 있습니다. break 뒤에 값을 넣으면 loop 표현식에 의해 그 값이 반환됩니다.
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2;
}
};
assert_eq!(result, 20);
}
while
while 키워드는 조건이 참(true)인 동안 루프를 실행하는 데 사용될 수 있습니다.
악명 높은 FizzBuzz를 while 루프를 사용하여 작성해 봅시다.
fn main() {
// 카운터 변수
let mut n = 1;
// `n`이 101보다 작은 동안 루프 실행
while n < 101 {
if n % 15 == 0 {
println!("fizzbuzz");
} else if n % 3 == 0 {
println!("fizz");
} else if n % 5 == 0 {
println!("buzz");
} else {
println!("{}", n);
}
// 카운터 증가
n += 1;
}
}
for 루프
for와 range
for in 구문은 Iterator를 통해 반복하는 데 사용될 수 있습니다. 이터레이터를 만드는 가장 쉬운 방법 중 하나는 범위 표기법 a..b를 사용하는 것입니다. 이는 a(포함)부터 b(미포함)까지의 값을 1씩 증가시키며 생성합니다.
while 대신 for를 사용하여 FizzBuzz를 작성해 봅시다.
fn main() {
// 각 반복에서 `n`은 1, 2, ..., 100의 값을 갖게 됩니다.
for n in 1..101 {
if n % 15 == 0 {
println!("fizzbuzz");
} else if n % 3 == 0 {
println!("fizz");
} else if n % 5 == 0 {
println!("buzz");
} else {
println!("{}", n);
}
}
}
또는 양쪽 끝을 모두 포함하는 범위에는 a..=b를 사용할 수 있습니다. 위의 코드는 다음과 같이 작성될 수 있습니다:
fn main() {
// 각 반복에서 `n`은 1, 2, ..., 100의 값을 갖게 됩니다.
for n in 1..=100 {
if n % 15 == 0 {
println!("fizzbuzz");
} else if n % 3 == 0 {
println!("fizz");
} else if n % 5 == 0 {
println!("buzz");
} else {
println!("{}", n);
}
}
}
for와 이터레이터
for in 구문은 여러 방식으로 Iterator와 상호작용할 수 있습니다. Iterator 트레이트 섹션에서 다룬 것처럼, 기본적으로 for 루프는 컬렉션에 into_iter 함수를 적용합니다. 하지만 이것이 컬렉션을 이터레이터로 변환하는 유일한 방법은 아닙니다.
into_iter, iter, iter_mut은 모두 내부 데이터에 대해 서로 다른 뷰를 제공함으로써 컬렉션을 이터레이터로 변환하는 작업을 서로 다른 방식으로 처리합니다.
iter- 매 반복마다 컬렉션의 각 요소를 빌려옵니다. 따라서 컬렉션은 수정되지 않은 상태로 유지되며 루프 이후에도 재사용할 수 있습니다.
fn main() {
let names = vec!["밥", "프랭크", "페리스"];
for name in names.iter() {
match name {
&"페리스" => println!("우리 중에 러스타시안(rustacean)이 있습니다!"),
// TODO ^ &를 삭제하고 그냥 "Ferris"와 매칭해 보세요
_ => println!("안녕 {}", name),
}
}
println!("이름들: {:?}", names);
}
into_iter- 컬렉션을 소비(consume)하여 각 반복마다 실제 데이터를 제공합니다. 컬렉션이 소비되면 루프 내에서 ’이동(moved)’된 것이므로 더 이상 재사용할 수 없습니다.
fn main() {
let names = vec!["밥", "프랭크", "페리스"];
for name in names.into_iter() {
match name {
"페리스" => println!("우리 중에 러스타시안(rustacean)이 있습니다!"),
_ => println!("안녕 {}", name),
}
}
println!("이름들: {:?}", names);
// FIXME ^ 이 줄을 주석 처리하세요
}
iter_mut- 컬렉션의 각 요소를 가변적으로 빌려와서, 컬렉션을 제자리에서 수정할 수 있게 합니다.
fn main() {
let mut names = vec!["밥", "프랭크", "페리스"];
for name in names.iter_mut() {
*name = match name {
&mut "페리스" => "우리 중에 러스타시안(rustacean)이 있습니다!",
_ => "안녕",
}
}
println!("이름들: {:?}", names);
}
위의 스니펫에서 match 분기의 타입에 주목하세요. 이것이 반복 방식의 핵심적인 차이점입니다. 타입의 차이는 물론 수행할 수 있는 작업의 차이를 의미합니다.
참고:
match
Rust는 match 키워드를 통해 패턴 매칭을 제공하며, 이는 C의 switch처럼 사용될 수 있습니다. 첫 번째로 매칭되는 팔(arm)이 실행되며, 가능한 모든 값을 다뤄야 합니다.
fn main() {
let number = 13;
// TODO ^ `number`에 다른 값을 넣어보세요
println!("{}에 대해 알려주세요", number);
match number {
// 단일 값 매칭
1 => println!("1입니다!"),
// 여러 값 매칭
2 | 3 | 5 | 7 | 11 => println!("이것은 소수입니다"),
// TODO ^ 소수 목록에 13을 추가해 보세요
// 포함 범위를 매칭
13..=19 => println!("10대입니다"),
// 나머지 경우 처리
_ => println!("특별할 것 없습니다"),
// TODO ^ 이 모든 경우를 처리하는 팔(catch-all arm)을 주석 처리해 보세요
}
let boolean = true;
// Match도 표현식입니다
let binary = match boolean {
// match의 팔들은 가능한 모든 값을 다뤄야 합니다
false => 0,
true => 1,
// TODO ^ 이 팔들 중 하나를 주석 처리해 보세요
};
println!("{} -> {}", boolean, binary);
}
구조 분해
match 블록은 다양한 방식으로 아이템을 구조 분해(destructure)할 수 있습니다.
참고:
튜플
튜플은 match에서 다음과 같이 구조 분해될 수 있습니다:
fn main() {
let triple = (0, -2, 3);
// TODO ^ `triple`에 다른 값을 넣어보세요
println!("{:?}에 대해 알려주세요", triple);
// match를 사용하여 튜플을 구조 분해할 수 있습니다
match triple {
// 두 번째와 세 번째 요소를 구조 분해합니다
(0, y, z) => println!("첫 번째는 `0`이고, `y`는 {:?}, `z`는 {:?}입니다", y, z),
(1, ..) => println!("첫 번째는 `1`이고 나머지는 상관없습니다"),
(.., 2) => println!("마지막은 `2`이고 나머지는 상관없습니다"),
(3, .., 4) => println!("첫 번째는 `3`, 마지막은 `4`이며 나머지는 상관없습니다"),
// `..`을 사용하여 튜플의 나머지를 무시할 수 있습니다
_ => println!("그것들이 무엇이든 상관없습니다"),
// `_`는 값을 변수에 바인딩하지 않음을 의미합니다
}
}
참고:
배열/슬라이스
튜플과 마찬가지로, 배열과 슬라이스도 다음과 같은 방식으로 구조 분해될 수 있습니다:
fn main() {
// 배열의 값을 변경하거나 슬라이스로 만들어 보세요!
let array = [1, -2, 6];
match array {
// 두 번째와 세 번째 요소를 각각의 변수에 바인딩합니다
[0, second, third] =>
println!("array[0] = 0, array[1] = {}, array[2] = {}", second, third),
// 단일 값은 _로 무시할 수 있습니다
[1, _, third] => println!(
"array[0] = 1, array[2] = {} 이고 array[1]은 무시되었습니다",
third
),
// 일부만 바인딩하고 나머지는 무시할 수도 있습니다
[-1, second, ..] => println!(
"array[0] = -1, array[1] = {} 이고 다른 모든 것들은 무시되었습니다",
second
),
// 아래 코드는 컴파일되지 않습니다
// [-1, second] => ...
// 또는 다른 배열/슬라이스에 저장할 수도 있습니다 (타입은 매칭되는 값의 타입에 따라 달라집니다)
[3, second, tail @ ..] => println!(
"array[0] = 3, array[1] = {} 이고 다른 요소들은 {:?}입니다",
second, tail
),
// 이러한 패턴들을 결합하여, 예를 들어 첫 번째와 마지막 값을 바인딩하고 나머지는 하나의 배열에 저장할 수 있습니다
[first, middle @ .., last] => println!(
"array[0] = {}, middle = {:?}, array[2] = {}",
first, middle, last
),
}
}
참고:
열거형
enum도 비슷하게 구조 분해됩니다:
// 단 하나의 변체만 사용되므로 경고를 억제하기 위해 `allow`가 필요합니다.
#[allow(dead_code)]
enum Color {
// 이 세 가지는 오직 이름으로만 지정됩니다.
Red,
Blue,
Green,
// 이들은 마찬가지로 `u32` 튜플을 서로 다른 이름(컬러 모델)에 연결합니다.
RGB(u32, u32, u32),
HSV(u32, u32, u32),
HSL(u32, u32, u32),
CMY(u32, u32, u32),
CMYK(u32, u32, u32, u32),
}
fn main() {
let color = Color::RGB(122, 17, 40);
// TODO ^ `color`에 다른 변체들을 넣어보세요
println!("이것은 무슨 색인가요?");
// `match`를 사용하여 `enum`을 구조 분해할 수 있습니다.
match color {
Color::Red => println!("빨간색입니다!"),
Color::Blue => println!("파란색입니다!"),
Color::Green => println!("초록색입니다!"),
Color::RGB(r, g, b) =>
println!("빨강: {}, 초록: {}, 파랑: {}!", r, g, b),
Color::HSV(h, s, v) =>
println!("색상: {}, 채도: {}, 명도: {}!", h, s, v),
Color::HSL(h, s, l) =>
println!("색상: {}, 채도: {}, 밝기: {}!", h, s, l),
Color::CMY(c, m, y) =>
println!("청록: {}, 자주: {}, 노랑: {}!", c, m, y),
Color::CMYK(c, m, y, k) =>
println!("청록: {}, 자주: {}, 노랑: {}, 검정: {}!",
c, m, y, k),
// 모든 변체를 검사했으므로 다른 팔(arm)은 필요하지 않습니다.
}
}
참고:
포인터/ref
포인터의 경우, 구조 분해(destructuring)와 역참조(dereferencing)를 구분해야 합니다. 이들은 C/C++와 같은 언어와는 다르게 사용되는 서로 다른 개념이기 때문입니다.
- 역참조는
*를 사용합니다 - 구조 분해는
&,ref,ref mut을 사용합니다
fn main() {
// `i32` 타입의 참조를 할당합니다. `&`는 참조가 할당되고 있음을 나타냅니다.
let reference = &4;
match reference {
// 만약 `reference`를 `&val`에 대해 패턴 매칭하면, 다음과 같은 비교 결과가 됩니다:
// `&i32`
// `&val`
// ^ 매칭되는 `&`들을 떼어내면, `i32`가 `val`에 할당되어야 함을 알 수 있습니다.
&val => println!("구조 분해를 통해 값을 얻었습니다: {:?}", val),
}
// `&`를 피하려면, 매칭하기 전에 역참조를 수행합니다.
match *reference {
val => println!("역참조를 통해 값을 얻었습니다: {:?}", val),
}
// 참조로 시작하지 않는다면 어떨까요? `reference`는 오른쪽이 이미
// 참조였기 때문에 `&`였습니다. 이것은 오른쪽이 참조가 아니기 때문에
// 참조가 아닙니다.
let _not_a_reference = 3;
// Rust는 정확히 이 목적을 위해 `ref`를 제공합니다. 이는 할당 방식을
// 수정하여 요소에 대한 참조가 생성되도록 하며, 이 참조가 할당됩니다.
let ref _is_a_reference = 3;
// 따라서, 참조가 없는 두 값을 정의함으로써, `ref`와 `ref mut`를 통해 참조를 가져올 수 있습니다.
let value = 5;
let mut mut_value = 6;
// 참조를 생성하려면 `ref` 키워드를 사용합니다.
match value {
ref r => println!("값에 대한 참조를 얻었습니다: {:?}", r),
}
// `ref mut`도 비슷하게 사용합니다.
match mut_value {
ref mut m => {
// 참조를 얻었습니다. 여기에 무언가를 더하기 전에
// 역참조를 해야 합니다.
*m += 10;
println!("10을 더했습니다. `mut_value`: {:?}", m);
},
}
}
참고:
구조체
마찬가지로, struct는 다음과 같이 구조 분해될 수 있습니다:
fn main() {
struct Foo {
x: (u32, u32),
y: u32,
}
// 구조체의 값을 변경하여 어떤 일이 일어나는지 확인해 보세요
let foo = Foo { x: (1, 2), y: 3 };
match foo {
Foo { x: (1, b), y } => println!("x의 첫 번째는 1이고, b = {}, y = {} 입니다", b, y),
// 구조체를 구조 분해하고 변수 이름을 바꿀 수 있으며, 순서는 중요하지 않습니다
Foo { y: 2, x: i } => println!("y는 2이고, i = {:?}입니다", i),
// 그리고 일부 변수를 무시할 수도 있습니다:
Foo { y, .. } => println!("y = {}이며, x는 신경 쓰지 않습니다", y),
// 이것은 에러를 발생시킵니다: 패턴에 `x` 필드가 언급되지 않았습니다
//Foo { y } => println!("y = {}", y),
}
let faa = Foo { x: (1, 2), y: 3 };
// 구조체를 구조 분해하기 위해 반드시 match 블록이 필요한 것은 아닙니다:
let Foo { x : x0, y: y0 } = faa;
println!("외부: x0 = {x0:?}, y0 = {y0}");
// 구조 분해는 중첩된 구조체에서도 작동합니다:
struct Bar {
foo: Foo,
}
let bar = Bar { foo: faa };
let Bar { foo: Foo { x: nested_x, y: nested_y } } = bar;
println!("중첩됨: nested_x = {nested_x:?}, nested_y = {nested_y:?}");
}
참고:
가드
매치 팔(arm)을 필터링하기 위해 match _가드(guard)_를 추가할 수 있습니다.
#[allow(dead_code)]
enum Temperature {
Celsius(i32),
Fahrenheit(i32),
}
fn main() {
let temperature = Temperature::Celsius(35);
// ^ TODO `temperature`에 다른 값을 시도해 보세요
match temperature {
Temperature::Celsius(t) if t > 30 => println!("{}C는 섭씨 30도 초과입니다", t),
// `if 조건` 부분 ^ 이 가드입니다
Temperature::Celsius(t) => println!("{}C는 섭씨 30도 이하입니다", t),
Temperature::Fahrenheit(t) if t > 86 => println!("{}F는 화씨 86도 초과입니다", t),
Temperature::Fahrenheit(t) => println!("{}F는 화씨 86도 이하입니다", t),
}
}
컴파일러는 매치 표현식이 모든 패턴을 다루고 있는지 확인할 때 가드 조건을 고려하지 않는다는 점에 유의하세요.
fn main() {
let number: u8 = 4;
match number {
i if i == 0 => println!("0"),
i if i > 0 => println!("0보다 큼"),
// _ => unreachable!("절대 일어날 수 없는 일입니다."),
// TODO ^ 컴파일을 수정하려면 주석을 해제하세요
}
}
참고:
바인딩
변수에 간접적으로 접근하면 재바인딩 없이 해당 변수를 분기하고 사용하는 것이 불가능합니다. match는 값을 이름에 바인딩하기 위한 @ 기호를 제공합니다:
// `u32`를 반환하는 `age` 함수.
fn age() -> u32 {
15
}
fn main() {
println!("당신이 어떤 종류의 사람인지 말해주세요");
match age() {
0 => println!("아직 첫 번째 생일을 맞이하지 않았습니다"),
// 1 ..= 12를 직접 `match`할 수 있지만, 그러면
// 아이의 나이가 몇 살인지 알 수 있을까요?
// n을 `match`하고 `if` 가드를 사용할 수 있지만,
// 이는 철저함(exhaustiveness) 검사에 기여하지 않습니다.
// (비록 이 경우에는 하단에 "catch-all" 패턴이 있어 상관없지만요)
// 대신, 1 ..= 12 시퀀스에 대해 `n`에 바인딩합니다.
// 이제 나이를 보고할 수 있습니다.
n @ 1 ..= 12 => println!("저는 {:?}살인 어린이입니다", n),
n @ 13 ..= 19 => println!("저는 {:?}살인 청소년입니다", n),
// 여러 값을 매칭할 때도 유사한 바인딩을 수행할 수 있습니다.
n @ (1 | 7 | 15 | 13) => println!("저는 {:?}살인 청소년입니다", n),
// 바인딩된 것이 없습니다. 결과를 반환합니다.
n => println!("저는 {:?}살인 어르신입니다", n),
}
}
또한 Option과 같은 enum 변체를 “구조 분해“하기 위해 바인딩을 사용할 수 있습니다:
fn some_number() -> Option<u32> {
Some(42)
}
fn main() {
match some_number() {
// `Some` 변체를 얻었을 때, `n`에 바인딩된 그 값이
// 42와 같은지 매칭합니다.
// `Some(42)`를 사용하여 `"정답: 42!"`를 출력할 수도 있지만,
// 그러면 나중에 값을 바꾸고 싶을 때 두 군데를 수정해야 합니다.
// `Some(n) if n == 42`를 사용하여 `"정답: {n}!"`를 출력할 수도 있지만,
// 이는 철저함 검사에 기여하지 않습니다.
// (비록 이 경우에는 다음 팔이 "catch-all" 패턴이라 상관없지만요)
Some(n @ 42) => println!("정답: {}!", n),
// 다른 모든 숫자 매칭.
Some(n) => println!("흥미롭지 않네요... {}", n),
// 그 외의 모든 경우(`None` 변체) 매칭.
_ => (),
}
}
참고:
if let
어떤 경우에는 열거형을 매칭할 때 match가 어색할 수 있습니다. 예를 들어:
#![allow(unused)]
fn main() {
// `Option<i32>` 타입의 `optional` 생성
let optional = Some(7);
match optional {
Some(i) => println!("이것은 정말 긴 문자열이며 `{:?}`입니다", i),
_ => {},
// ^ `match`는 철저해야(exhaustive) 하기 때문에 필요합니다. 공간 낭비처럼
// 보이진 않나요?
};
}
이런 경우 if let이 더 깔끔하며, 추가로 다양한 실패 옵션을 지정할 수 있습니다:
fn main() {
// 모두 `Option<i32>` 타입입니다
let number = Some(7);
let letter: Option<i32> = None;
let emoticon: Option<i32> = None;
// `if let` 구문은 다음과 같이 읽힙니다: "만약 `let`이 `number`를
// `Some(i)`로 구조 분해한다면, 블록(`{}`)을 실행하라."
if let Some(i) = number {
println!("{:?}가 매칭되었습니다!", i);
}
// 실패한 경우를 지정해야 한다면, else를 사용하세요:
if let Some(i) = letter {
println!("{:?}가 매칭되었습니다!", i);
} else {
// 구조 분해에 실패했습니다. 실패 사례로 전환합니다.
println!("숫자와 매칭되지 않았습니다. 문자로 해봅시다!");
}
// 변경된 실패 조건을 제공합니다.
let i_like_letters = false;
if let Some(i) = emoticon {
println!("{:?}가 매칭되었습니다!", i);
// 구조 분해에 실패했습니다. 다른 실패 분기를 타야 하는지
// `else if` 조건을 평가합니다:
} else if i_like_letters {
println!("숫자와 매칭되지 않았습니다. 문자로 해봅시다!");
} else {
// 조건이 거짓(false)으로 평가되었습니다. 이 분기가 기본입니다:
println!("문자는 별로예요. 이모티콘 :)으로 가봅시다!");
}
}
같은 방식으로, if let은 모든 열거형 값을 매칭하는 데 사용될 수 있습니다:
// 예제 열거형
enum Foo {
Bar,
Baz,
Qux(u32)
}
fn main() {
// 예제 변수 생성
let a = Foo::Bar;
let b = Foo::Baz;
let c = Foo::Qux(100);
// 변수 a는 Foo::Bar와 매칭됩니다
if let Foo::Bar = a {
println!("a는 foobar입니다");
}
// 변수 b는 Foo::Bar와 매칭되지 않습니다
// 따라서 아무것도 출력되지 않습니다
if let Foo::Bar = b {
println!("b는 foobar입니다");
}
// 변수 c는 값을 가진 Foo::Qux와 매칭됩니다
// 앞선 예제의 Some()과 유사합니다
if let Foo::Qux(value) = c {
println!("c는 {}입니다", value);
}
// 바인딩은 `if let`에서도 작동합니다
if let Foo::Qux(value @ 100) = c {
println!("c는 100입니다");
}
}
또 다른 장점은 if let을 사용하면 파라미터가 없는 열거형 변체도 매칭할 수 있다는 점입니다. 이는 열거형이 PartialEq를 구현하거나 유도하지 않는 경우에도 마찬가지입니다. 이러한 경우 if Foo::Bar == a는 열거형 인스턴스를 비교할 수 없기 때문에 컴파일에 실패하지만, if let은 계속 작동합니다.
도전해 보시겠습니까? 다음 예제를 if let을 사용하도록 수정해 보세요:
// 이 열거형은 의도적으로 PartialEq를 구현하거나 유도(derive)하지 않았습니다.
// 그래서 아래에서 Foo::Bar == a 를 비교하는 것이 실패합니다.
enum Foo {Bar}
fn main() {
let a = Foo::Bar;
// 변수 a는 Foo::Bar와 매칭됩니다
if Foo::Bar == a {
// ^-- 이것은 컴파일 타임 에러를 발생시킵니다. 대신 `if let`을 사용하세요.
println!("a는 foobar입니다");
}
}
참고:
let-else
🛈 안정화 버전: rust 1.65
🛈 다음과 같이 컴파일하여 특정 에디션을 타겟팅할 수 있습니다:
rustc --edition=2021 main.rs
let-else를 사용하면, 일반적인 let처럼 주변 스코프에서 변수를 매칭하고 바인딩할 수 있으며, 패턴이 매칭되지 않을 경우 분기(예: break, return, panic!)할 수 있습니다.
use std::str::FromStr;
fn get_count_item(s: &str) -> (u64, &str) {
let mut it = s.split(' ');
let (Some(count_str), Some(item)) = (it.next(), it.next()) else {
panic!("카운트 아이템 쌍을 분리할 수 없습니다: '{s}'");
};
let Ok(count) = u64::from_str(count_str) else {
panic!("정수를 파싱할 수 없습니다: '{count_str}'");
};
(count, item)
}
fn main() {
assert_eq!(get_count_item("3 chairs"), (3, "chairs"));
}
이름 바인딩의 스코프는 이것이 match나 if let-else 표현식과 다른 주요 점입니다. 이전에는 외부 let과 약간의 반복을 통해 이러한 패턴을 흉내 낼 수 있었습니다:
#![allow(unused)]
fn main() {
use std::str::FromStr;
fn get_count_item(s: &str) -> (u64, &str) {
let mut it = s.split(' ');
let (count_str, item) = match (it.next(), it.next()) {
(Some(count_str), Some(item)) => (count_str, item),
_ => panic!("카운트 아이템 쌍을 분리할 수 없습니다: '{s}'"),
};
let count = if let Ok(count) = u64::from_str(count_str) {
count
} else {
panic!("정수를 파싱할 수 없습니다: '{count_str}'");
};
(count, item)
}
assert_eq!(get_count_item("3 chairs"), (3, "chairs"));
}
참고:
option, match, if let 그리고 let-else RFC.
while let
if let과 유사하게, while let은 어색한 match 시퀀스를 더 견딜만하게 만들어 줍니다. i를 증가시키는 다음 시퀀스를 고려해 보세요:
#![allow(unused)]
fn main() {
// `Option<i32>` 타입의 `optional` 생성
let mut optional = Some(0);
// 이 테스트를 반복해서 시도합니다.
loop {
match optional {
// 만약 `optional`이 구조 분해된다면, 블록을 평가합니다.
Some(i) => {
if i > 9 {
println!("9보다 큽니다, 종료!");
optional = None;
} else {
println!("`i`는 `{:?}`입니다. 다시 시도하세요.", i);
optional = Some(i + 1);
}
// ^ 3단계의 들여쓰기가 필요합니다!
},
// 구조 분해에 실패하면 루프를 종료합니다:
_ => { break; }
// ^ 왜 이것이 필요할까요? 분명 더 좋은 방법이 있을 것입니다!
}
}
}
while let을 사용하면 이 시퀀스가 훨씬 깔끔해집니다:
fn main() {
// `Option<i32>` 타입의 `optional` 생성
let mut optional = Some(0);
// 다음과 같이 읽힙니다: "`let`이 `optional`을 `Some(i)`로
// 구조 분해하는 동안, 블록(`{}`)을 실행하라. 그 외에는 `break` 하라."
while let Some(i) = optional {
if i > 9 {
println!("9보다 큽니다, 종료!");
optional = None;
} else {
println!("`i`는 `{:?}`입니다. 다시 시도하세요.", i);
optional = Some(i + 1);
}
// ^ 오른쪽으로의 들여쓰기가 줄어들었으며 실패 사례를
// 명시적으로 처리할 필요가 없습니다.
}
// ^ `if let`에는 추가적인 선택 사항인 `else`/`else if` 절이
// 있었지만, `while let`에는 이러한 것들이 없습니다.
}
참고:
함수
함수는 fn 키워드를 사용하여 선언됩니다. 인자는 변수와 마찬가지로 타입 어노테이션이 필요하며, 함수가 값을 반환하는 경우 반환 타입은 화살표 -> 뒤에 지정해야 합니다.
함수의 마지막 표현식은 반환 값으로 사용됩니다. 또는 return 문을 사용하여 루프나 if 문 내부를 포함하여 함수 내에서 더 일찍 값을 반환할 수 있습니다.
함수를 사용하여 FizzBuzz를 다시 작성해 봅시다!
// C/C++와 달리, 함수 정의 순서에는 제한이 없습니다.
fn main() {
// 여기서 이 함수를 사용하고, 나중에 어딘가에서 정의할 수 있습니다.
fizzbuzz_to(100);
}
// 불리언 값을 반환하는 함수
fn is_divisible_by(lhs: u32, rhs: u32) -> bool {
// 예외 케이스, 조기 반환
if rhs == 0 {
return false;
}
// 이것은 표현식이며, 여기서는 `return` 키워드가 필요하지 않습니다.
lhs % rhs == 0
}
// 값을 반환하지 "않는" 함수는 실제로는 유닛 타입 `()`를 반환합니다.
fn fizzbuzz(n: u32) -> () {
if is_divisible_by(n, 15) {
println!("fizzbuzz");
} else if is_divisible_by(n, 3) {
println!("fizz");
} else if is_divisible_by(n, 5) {
println!("buzz");
} else {
println!("{}", n);
}
}
// 함수가 `()`를 반환할 때, 시그니처에서 반환 타입을 생략할 수 있습니다.
fn fizzbuzz_to(n: u32) {
for n in 1..=n {
fizzbuzz(n);
}
}
연관 함수와 메서드
일부 함수는 특정 타입과 연결되어 있습니다. 이들은 연관 함수와 메서드라는 두 가지 형태로 제공됩니다. 연관 함수는 일반적으로 타입 상에 정의된 함수인 반면, 메서드는 특정 타입의 인스턴스에서 호출되는 연관 함수입니다.
struct Point {
x: f64,
y: f64,
}
// 구현(Implementation) 블록으로, 모든 `Point` 연관 함수와 메서드가 여기에 들어갑니다.
impl Point {
// 이것은 이 함수가 특정 타입인 Point와 연관되어 있기 때문에
// "연관 함수"라고 불립니다.
//
// 연관 함수는 인스턴스를 통해 호출될 필요가 없습니다.
// 이러한 함수들은 일반적으로 생성자처럼 사용됩니다.
fn origin() -> Point {
Point { x: 0.0, y: 0.0 }
}
// 두 개의 인자를 받는 또 다른 연관 함수:
fn new(x: f64, y: f64) -> Point {
Point { x: x, y: y }
}
}
struct Rectangle {
p1: Point,
p2: Point,
}
impl Rectangle {
// 이것은 메서드입니다
// `&self`는 `self: &Self`를 줄여 쓴 것인데, 여기서 `Self`는
// 호출 객체의 타입을 의미합니다. 이 경우 `Self` = `Rectangle`입니다.
fn area(&self) -> f64 {
// `self`는 점(dot) 연산자를 통해 구조체 필드에 대한 접근을 제공합니다.
let Point { x: x1, y: y1 } = self.p1;
let Point { x: x2, y: y2 } = self.p2;
// `abs`는 호출자의 절댓값을 반환하는 `f64` 메서드입니다.
((x1 - x2) * (y1 - y2)).abs()
}
fn perimeter(&self) -> f64 {
let Point { x: x1, y: y1 } = self.p1;
let Point { x: x2, y: y2 } = self.p2;
2.0 * ((x1 - x2).abs() + (y1 - y2).abs())
}
// 이 메서드는 호출 객체가 가변적일 것을 요구합니다
// `&mut self`는 `self: &mut Self`로 풀어서 쓸 수 있습니다.
fn translate(&mut self, x: f64, y: f64) {
self.p1.x += x;
self.p2.x += x;
self.p1.y += y;
self.p2.y += y;
}
}
// `Pair`는 리소스를 소유합니다: 힙에 할당된 두 개의 정수
struct Pair(Box<i32>, Box<i32>);
impl Pair {
// 이 메서드는 호출 객체의 리소스를 "소비"합니다
// `self`는 `self: Self`로 풀어서 쓸 수 있습니다.
fn destroy(self) {
// `self` 구조 분해
let Pair(first, second) = self;
println!("Pair({}, {}) 파괴 중", first, second);
// `first`와 `second`는 스코프를 벗어나며 해제됩니다.
}
}
fn main() {
let rectangle = Rectangle {
// 연관 함수는 이중 콜론을 사용하여 호출됩니다.
p1: Point::origin(),
p2: Point::new(3.0, 4.0),
};
// 메서드는 점 연산자를 사용하여 호출됩니다.
// 첫 번째 인자인 `&self`는 암시적으로 전달됩니다. 즉,
// `rectangle.perimeter()`는 `Rectangle::perimeter(&rectangle)`와 동일합니다.
println!("사각형 둘레: {}", rectangle.perimeter());
println!("사각형 넓이: {}", rectangle.area());
let mut square = Rectangle {
p1: Point::origin(),
p2: Point::new(1.0, 1.0),
};
// 에러! `rectangle`은 불변이지만, 이 메서드는 가변 객체를 요구합니다.
// rectangle.translate(1.0, 0.0);
// TODO ^ 이 줄의 주석을 해제해 보세요
// 좋습니다! 가변 객체는 가변 메서드를 호출할 수 있습니다
square.translate(1.0, 1.0);
let pair = Pair(Box::new(1), Box::new(2));
pair.destroy();
// 에러! 이전의 `destroy` 호출이 `pair`를 "소비"했습니다
// pair.destroy();
// TODO ^ 이 줄의 주석을 해제해 보세요
}
클로저
클로저는 감싸고 있는 환경을 캡처할 수 있는 함수입니다. 예를 들어, x 변수를 캡처하는 클로저는 다음과 같습니다:
|val| val + x
클로저의 구문과 기능은 즉석에서 사용하기에 매우 편리합니다. 클로저를 호출하는 것은 함수를 호출하는 것과 똑같습니다. 하지만 입력 및 반환 타입은 추론될 수 있으며, 입력 변수 이름은 반드시 지정되어야 합니다.
클로저의 다른 특징들은 다음과 같습니다:
- 입력 변수 주위에
()대신||를 사용합니다. - 한 줄 표현식의 경우 본문 구분 기호(
{})는 선택 사항입니다 (그 외의 경우는 필수). - 외부 환경 변수를 캡처하는 능력.
fn main() {
let outer_var = 42;
// 일반 함수는 자신을 감싸고 있는 환경의 변수를 참조할 수 없습니다
// fn function(i: i32) -> i32 { i + outer_var }
// TODO: 위의 줄의 주석을 해제하고 컴파일 에러를 확인해 보세요. 컴파일러는
// 대신 클로저를 정의할 것을 제안합니다.
// 클로저는 익명이며, 여기서는 참조에 바인딩하고 있습니다.
// 어노테이션은 함수의 어노테이션과 동일하지만 선택 사항이며,
// 본문을 감싸는 `{}` 역시 선택 사항입니다. 이 이름 없는 함수들은
// 적절하게 이름 붙여진 변수들에 할당됩니다.
let closure_annotated = |i: i32| -> i32 { i + outer_var };
let closure_inferred = |i | i + outer_var ;
// 클로저를 호출합니다.
println!("어노테이션된 클로저: {}", closure_annotated(1));
println!("추론된 클로저: {}", closure_inferred(1));
// 클로저의 타입이 한 번 추론되면, 다른 타입으로 다시 추론될 수 없습니다.
// println!("다른 타입으로 closure_inferred를 재사용할 수 없습니다: {}", closure_inferred(42i64));
// TODO: 위의 줄의 주석을 해제하고 컴파일 에러를 확인해 보세요.
// 인자를 받지 않고 `i32`를 반환하는 클로저입니다.
// 반환 타입은 추론됩니다.
let one = || 1;
println!("1을 반환하는 클로저: {}", one());
}
캡처
클로저는 본질적으로 유연하며, 어노테이션 없이도 클로저가 작동하는 데 필요한 기능을 수행합니다. 이를 통해 캡처 방식이 사용 사례에 따라 유연하게 적응할 수 있으며, 때로는 이동(move)하고 때로는 빌림(borrow)을 수행합니다. 클로저는 다음과 같은 방식으로 변수를 캡처할 수 있습니다:
- 참조에 의한 방식:
&T - 가변 참조에 의한 방식:
&mut T - 값에 의한 방식:
T
클로저는 가급적 참조로 변수를 캡처하며, 필요한 경우에만 더 낮은 수준(이동 등)으로 내려갑니다.
fn main() {
use std::mem;
let color = String::from("초록");
// `color`를 출력하는 클로저입니다. 이 클로저는 즉시 `color`를 빌리고(`&`)
// 그 빌림과 클로저를 `print` 변수에 저장합니다. `print`가 마지막으로
// 사용될 때까지 빌린 상태가 유지됩니다.
//
// `println!`은 인자로 불변 참조만 요구하므로 더 이상의 제약을
// 가하지 않습니다.
let print = || println!("`color`: {}", color);
// 빌림을 사용하여 클로저를 호출합니다.
print();
// 클로저가 `color`에 대한 불변 참조만 가지고 있기 때문에,
// `color`를 다시 불변으로 빌릴 수 있습니다.
let _reborrow = &color;
print();
// `print`를 마지막으로 사용한 후에는 이동(move)이나 다시 빌리는 것이 허용됩니다
let _color_moved = color;
let mut count = 0;
// `count`를 증가시키는 클로저는 `&mut count`나 `count` 중 하나를 취할 수 있지만,
// `&mut count`가 덜 제한적이므로 이를 취합니다. 즉시 `count`를 빌립니다.
//
// 내부에 `&mut`가 저장되어 있기 때문에 `inc`에 `mut`가 필요합니다. 따라서
// 클로저를 호출하면 `count`가 수정되며, 이를 위해 `mut`가 필요합니다.
let mut inc = || {
count += 1;
println!("`count`: {}", count);
};
// 가변 빌림을 사용하여 클로저를 호출합니다.
inc();
// 클로저가 나중에 호출되기 때문에 여전히 `count`를 가변적으로 빌리고 있습니다.
// 다시 빌리려는 시도는 에러를 발생시킵니다.
// let _reborrow = &count;
// ^ TODO: 이 줄의 주석을 해제해 보세요.
inc();
// 클로저가 더 이상 `&mut count`를 빌릴 필요가 없습니다. 따라서
// 에러 없이 다시 빌리는 것이 가능합니다.
let _count_reborrowed = &mut count;
// 복사(copy)되지 않는 타입.
let movable = Box::new(3);
// `mem::drop`은 `T`를 요구하므로 값을 가져와야 합니다. 복사 타입은
// 원본을 그대로 둔 채 클로저 내부로 복사될 것입니다.
// 복사되지 않는 타입은 이동(move)해야 하므로 `movable`은 즉시
// 클로저 내부로 이동됩니다.
let consume = || {
println!("`movable`: {:?}", movable);
mem::drop(movable);
};
// `consume`은 변수를 소비하므로 한 번만 호출될 수 있습니다.
consume();
// consume();
// ^ TODO: 이 줄의 주석을 해제해 보세요.
}
수직 바(||) 앞에 move를 사용하면 클로저가 캡처된 변수의 소유권을 강제로 갖게 됩니다:
fn main() {
// `Vec`은 복사되지 않는 세맨틱을 가집니다.
let haystack = vec![1, 2, 3];
let contains = move |needle| haystack.contains(needle);
println!("{}", contains(&1));
println!("{}", contains(&4));
// println!("vec에는 {}개의 요소가 있습니다", haystack.len());
// ^ 위의 줄의 주석을 해제하면 컴파일 타임 에러가 발생합니다.
// 빌림 검사기(borrow checker)가 이동된 후의 변수 재사용을 허용하지 않기 때문입니다.
// 클로저의 시그니처에서 `move`를 제거하면 클로저가 `haystack` 변수를
// 불변으로 빌리게 되므로, `haystack`을 여전히 사용할 수 있고
// 위의 줄의 주석을 해제해도 에러가 발생하지 않습니다.
}
참고:
입력 파라미터로 사용
Rust는 대부분 타입 어노테이션 없이 즉석에서 변수를 캡처하는 방식을 선택하지만, 함수를 작성할 때는 이러한 모호함이 허용되지 않습니다. 클로저를 입력 파라미터로 받을 때, 클로저의 전체 타입은 몇 가지 트레이트 중 하나를 사용하여 어노테이션되어야 하며, 이는 클로저가 캡처된 값으로 무엇을 하는지에 따라 결정됩니다. 제약이 강한 순서대로 나열하면 다음과 같습니다:
Fn: 클로저가 캡처된 값을 참조(&T)로 사용합니다FnMut: 클로저가 캡처된 값을 가변 참조(&mut T)로 사용합니다FnOnce: 클로저가 캡처된 값을 값(T)으로 사용합니다
변수별로 컴파일러는 가능한 가장 제약이 적은 방식으로 변수를 캡처합니다.
예를 들어, FnOnce로 어노테이션된 파라미터를 생각해 보세요. 이는 클로저가 &T, &mut T, 또는 T로 캡처할 수 있음을 나타내지만, 컴파일러는 최종적으로 클로저 내에서 캡처된 변수가 어떻게 사용되는지에 따라 선택합니다.
이동(move)이 가능하다면 어떤 유형의 빌림(borrow)도 가능해야 하기 때문입니다. 그 반대는 성립하지 않음에 유의하세요. 파라미터가 Fn으로 어노테이션된 경우, &mut T나 T로 변수를 캡처하는 것은 허용되지 않습니다. 하지만 &T는 허용됩니다.
다음 예제에서 Fn, FnMut, FnOnce의 사용을 서로 바꿔보며 어떤 일이 일어나는지 확인해 보세요:
// 클로저를 인자로 받아 호출하는 함수입니다.
// <F>는 F가 "제네릭 타입 파라미터"임을 나타냅니다.
fn apply<F>(f: F) where
// 이 클로저는 입력을 받지 않고 아무것도 반환하지 않습니다.
F: FnOnce() {
// ^ TODO: 이를 `Fn`이나 `FnMut`로 변경해 보세요.
f();
}
// 클로저를 받아 `i32`를 반환하는 함수입니다.
fn apply_to_3<F>(f: F) -> i32 where
// 이 클로저는 `i32`를 받아 `i32`를 반환합니다.
F: Fn(i32) -> i32 {
f(3)
}
fn main() {
use std::mem;
let greeting = "안녕";
// 복사되지 않는 타입.
// `to_owned`는 빌려온 데이터로부터 소유권이 있는 데이터를 생성합니다.
let mut farewell = "잘 가요".to_owned();
// 두 개의 변수를 캡처합니다: `greeting`은 참조로, `farewell`은 값으로.
let diary = || {
// `greeting`은 참조에 의한 방식입니다: `Fn`이 필요합니다.
println!("저는 {}라고 말했습니다.", greeting);
// 수정을 위해 `farewell`을 가변 참조로 캡처해야 합니다.
// 이제 `FnMut`가 필요합니다.
farewell.push_str("!!!");
println!("그러고 나서 저는 {}라고 소리쳤습니다.", farewell);
println!("이제 잘 수 있겠네요. zzzzz");
// 수동으로 drop을 호출하면 `farewell`을 값으로
// 캡처해야 합니다. 이제 `FnOnce`가 필요합니다.
mem::drop(farewell);
};
// 클로저를 적용하는 함수를 호출합니다.
apply(diary);
// `double`은 `apply_to_3`의 트레이트 바운드를 만족합니다
let double = |x| 2 * x;
println!("3의 두 배: {}", apply_to_3(double));
}
참고:
std::mem::drop, Fn, FnMut, 제네릭, where 및 FnOnce
타입 익명성
클로저는 자신을 감싸고 있는 스코프에서 변수를 간결하게 캡처합니다. 이것이 어떤 결과를 초래할까요? 분명히 결과가 있습니다. 클로저를 함수 파라미터로 사용할 때 제네릭이 왜 필요한지, 클로저가 어떻게 정의되는지를 통해 살펴보세요:
#![allow(unused)]
fn main() {
// `F`는 제네릭이어야 합니다.
fn apply<F>(f: F) where
F: FnOnce() {
f();
}
}
클로저가 정의될 때, 컴파일러는 내부에 캡처된 변수를 저장하기 위해 새로운 익명 구조체를 암시적으로 생성하며, 동시에 이 알 수 없는 타입에 대해 Fn, FnMut, 또는 FnOnce 트레이트 중 하나를 통해 기능을 구현합니다. 이 타입은 변수에 할당되어 호출될 때까지 저장됩니다.
이 새로운 타입은 알 수 없는 타입이므로, 함수에서 사용할 때는 제네릭이 필요합니다. 하지만 제약이 없는 타입 파라미터 <T>는 여전히 모호하므로 허용되지 않습니다. 따라서 클로저가 구현하는 Fn, FnMut, 또는 FnOnce 트레이트 중 하나로 바운딩하는 것이 그 타입을 지정하기에 충분합니다.
// `F`는 입력이 없고 아무것도 반환하지 않는 클로저를 위해
// `Fn`을 구현해야 합니다. 이는 정확히 `print`에 요구되는 사항입니다.
fn apply<F>(f: F) where
F: Fn() {
f();
}
fn main() {
let x = 7;
// `x`를 익명 타입으로 캡처하고 그에 대해 `Fn`을 구현합니다.
// 이를 `print`에 저장합니다.
let print = || println!("{}", x);
apply(print);
}
참고:
입력 함수
클로저가 인자로 사용될 수 있으므로, 함수에 대해서도 똑같이 말할 수 있는지 궁금할 것입니다. 실제로 가능합니다! 클로저를 파라미터로 받는 함수를 선언하면, 해당 클로저의 트레이트 바운드를 만족하는 모든 함수를 파라미터로 전달할 수 있습니다.
// `Fn`으로 바운딩된 제네릭 `F` 인자를 받아
// 호출하는 함수를 정의합니다
fn call_me<F: Fn()>(f: F) {
f();
}
// `Fn` 바운드를 만족하는 래퍼 함수를 정의합니다
fn function() {
println!("저는 함수입니다!");
}
fn main() {
// `Fn` 바운드를 만족하는 클로저를 정의합니다
let closure = || println!("저는 클로저입니다!");
call_me(closure);
call_me(function);
}
추가로, Fn, FnMut, FnOnce 트레이트는 클로저가 주변 스코프에서 변수를 캡처하는 방식을 결정합니다.
참고:
출력 파라미터로 사용
클로저를 입력 파라미터로 사용하는 것이 가능하므로, 클로저를 출력 파라미터로 반환하는 것도 가능해야 합니다. 하지만 익명 클로저 타입은 정의상 알 수 없으므로, 이를 반환하려면 impl Trait를 사용해야 합니다.
클로저를 반환하기 위해 유효한 트레이트들은 다음과 같습니다:
FnFnMutFnOnce
이 외에도, 모든 캡처가 값에 의해 발생함을 나타내는 move 키워드를 반드시 사용해야 합니다. 이는 참조에 의한 캡처가 함수가 종료되는 즉시 드롭되어 클로저에 유효하지 않은 참조를 남기기 때문에 필요합니다.
fn create_fn() -> impl Fn() {
let text = "Fn".to_owned();
move || println!("이것은 {}입니다", text)
}
fn create_fnmut() -> impl FnMut() {
let text = "FnMut".to_owned();
move || println!("이것은 {}입니다", text)
}
fn create_fnonce() -> impl FnOnce() {
let text = "FnOnce".to_owned();
move || println!("이것은 {}입니다", text)
}
fn main() {
let fn_plain = create_fn();
let mut fn_mut = create_fnmut();
let fn_once = create_fnonce();
fn_plain();
fn_mut();
fn_once();
}
참고:
Fn, FnMut, 제네릭 및 impl Trait.
std 예제
이 섹션에는 std 라이브러리의 클로저를 사용하는 몇 가지 예제가 포함되어 있습니다.
Iterator::any
Iterator::any는 이터레이터가 주어졌을 때, 어떤 요소라도 서술어(predicate)를 만족하면 true를 반환하는 함수입니다. 그렇지 않으면 false를 반환합니다. 시그니처는 다음과 같습니다:
pub trait Iterator {
// 반복되는 요소의 타입.
type Item;
// `any`는 `&mut self`를 취하므로 호출자는 빌려지거나
// 수정될 수 있지만, 소비(consume)되지는 않습니다.
fn any<F>(&mut self, f: F) -> bool where
// `FnMut`는 캡처된 변수가 최대 수정될 수 있지만 소비되지는 않음을 의미합니다.
// `Self::Item`은 클로저 파라미터 타입으로, 이터레이터에 의해 결정됩니다
// (예: `.iter()`의 경우 `&T`, `.into_iter()`의 경우 `T`).
F: FnMut(Self::Item) -> bool;
}
fn main() {
let vec1 = vec![1, 2, 3];
let vec2 = vec![4, 5, 6];
// 벡터에 대한 `iter()`는 `&i32`를 생성합니다. `i32`로 구조 분해합니다.
println!("vec1에 2가 있음: {}", vec1.iter() .any(|&x| x == 2));
// 벡터에 대한 `into_iter()`는 `i32`를 생성합니다. 구조 분해가 필요 없습니다.
println!("vec2에 2가 있음: {}", vec2.into_iter().any(|x| x == 2));
// `iter()`는 오직 `vec1`과 그 요소들을 빌리기만 하므로, 다시 사용할 수 있습니다.
println!("vec1 길이: {}", vec1.len());
println!("vec1의 첫 번째 요소: {}", vec1[0]);
// `into_iter()`는 `vec2`와 그 요소들을 이동(move)시키므로, 다시 사용할 수 없습니다.
// println!("vec2의 첫 번째 요소: {}", vec2[0]);
// println!("vec2 길이: {}", vec2.len());
// TODO: 위의 두 줄의 주석을 해제하고 컴파일 에러를 확인해 보세요.
let array1 = [1, 2, 3];
let array2 = [4, 5, 6];
// 배열에 대한 `iter()`는 `&i32`를 생성합니다.
println!("array1에 2가 있음: {}", array1.iter() .any(|&x| x == 2));
// 배열에 대한 `into_iter()`는 `i32`를 생성합니다.
println!("array2에 2가 있음: {}", array2.into_iter().any(|x| x == 2));
}
참고:
이터레이터 검색
Iterator::find는 이터레이터를 순회하며 어떤 조건을 만족하는 첫 번째 값을 찾는 함수입니다. 조건을 만족하는 값이 없으면 None을 반환합니다. 시그니처는 다음과 같습니다:
pub trait Iterator {
// 반복되는 요소의 타입.
type Item;
// `find`는 `&mut self`를 취하므로 호출자는 빌려지거나
// 수정될 수 있지만, 소비(consume)되지는 않습니다.
fn find<P>(&mut self, predicate: P) -> Option<Self::Item> where
// `FnMut`는 캡처된 변수가 최대 수정될 수 있지만 소비되지는 않음을 의미합니다.
// `&Self::Item`은 클로저에 대한 인자를 참조로 취함을 나타냅니다.
P: FnMut(&Self::Item) -> bool;
}
fn main() {
let vec1 = vec![1, 2, 3];
let vec2 = vec![4, 5, 6];
// `vec1.iter()`는 `&i32`를 생성합니다.
let mut iter = vec1.iter();
// `vec2.into_iter()`는 `i32`를 생성합니다.
let mut into_iter = vec2.into_iter();
// `iter()`는 `&i32`를 생성하고, `find`는 서술어(predicate)에 `&Item`을 전달합니다.
// `Item = &i32`이므로 클로저 인자는 `&&i32` 타입을 가지며,
// 우리는 이를 패턴 매칭하여 `i32`로 역참조합니다.
println!("vec1에서 2 찾기: {:?}", iter.find(|&&x| x == 2));
// `into_iter()`는 `i32`를 생성하고, `find`는 서술어에 `&Item`을 전달합니다.
// `Item = i32`이므로 클로저 인자는 `&i32` 타입을 가지며,
// 우리는 이를 패턴 매칭하여 `i32`로 역참조합니다.
println!("vec2에서 2 찾기: {:?}", into_iter.find(|&x| x == 2));
let array1 = [1, 2, 3];
let array2 = [4, 5, 6];
// `array1.iter()`는 `&i32`를 생성합니다
println!("array1에서 2 찾기: {:?}", array1.iter().find(|&&x| x == 2));
// `array2.into_iter()`는 `i32`를 생성합니다
println!("array2에서 2 찾기: {:?}", array2.into_iter().find(|&x| x == 2));
}
Iterator::find는 아이템에 대한 참조를 제공합니다. 하지만 아이템의 인덱스를 원한다면 Iterator::position을 사용하세요.
fn main() {
let vec = vec![1, 9, 3, 3, 13, 2];
// `position`은 이터레이터의 `Item`을 값으로 서술어에 전달합니다.
// `vec.iter()`는 `&i32`를 생성하므로 서술어는 `&i32`를 받으며,
// 우리는 이를 패턴 매칭하여 `i32`로 역참조합니다.
let index_of_first_even_number = vec.iter().position(|&x| x % 2 == 0);
assert_eq!(index_of_first_even_number, Some(5));
// `vec.into_iter()`는 `i32`를 생성하므로 서술어는 `i32`를 직접 받습니다.
let index_of_first_negative_number = vec.into_iter().position(|x| x < 0);
assert_eq!(index_of_first_negative_number, None);
}
참고:
std::iter::Iterator::rposition
고차 함수
Rust는 고차 함수(Higher Order Functions, HOF)를 제공합니다. 이들은 하나 이상의 함수를 인자로 받거나 더 유용한 함수를 만들어내는 함수들입니다. HOF와 게으른(lazy) 이터레이터는 Rust에 함수형 프로그래밍의 풍미를 더해줍니다.
fn is_odd(n: u32) -> bool {
n % 2 == 1
}
fn main() {
println!("1000 미만의 홀수 제곱값들의 합 찾기");
let upper = 1000;
// 명령형 접근 방식
// 누적 변수 선언
let mut acc = 0;
// 반복: 0, 1, 2, ... 무한대까지
for n in 0.. {
// 숫자를 제곱합니다
let n_squared = n * n;
if n_squared >= upper {
// 상한을 초과하면 루프를 종료합니다
break;
} else if is_odd(n_squared) {
// 홀수라면 값을 누적합니다
acc += n;
}
}
println!("명령형 스타일: {}", acc);
// 함수형 접근 방식
let sum: u32 =
(0..).take_while(|&n| n * n < upper) // 상한 미만
.filter(|&n| is_odd(n * n)) // 홀수인 것들
.sum(); // 합계를 구함
println!("함수형 스타일: {}", sum);
}
Option과 Iterator는 상당수의 HOF를 구현하고 있습니다.
발산 함수
발산 함수(Diverging functions)는 절대 반환되지 않습니다. 이들은 빈 타입인 !를 사용하여 표시됩니다.
#![allow(unused)]
fn main() {
fn foo() -> ! {
panic!("이 호출은 절대 반환되지 않습니다.");
}
}
다른 모든 타입들과 달리, 이 타입은 인스턴스화할 수 없습니다. 이 타입이 가질 수 있는 모든 가능한 값의 집합이 비어 있기 때문입니다. 이는 정확히 하나의 가능한 값을 갖는 () 타입과는 다르다는 점에 유의하세요.
예를 들어, 이 함수는 반환 값에 정보가 없더라도 평소와 같이 반환됩니다.
fn some_fn() {
()
}
fn main() {
let _a: () = some_fn();
println!("이 함수는 반환되며 당신은 이 줄을 볼 수 있습니다.");
}
반면 이 함수는 호출자에게 제어권을 절대 반환하지 않습니다.
#![feature(never_type)]
fn main() {
let x: ! = panic!("이 호출은 절대 반환되지 않습니다.");
println!("당신은 결코 이 줄을 볼 수 없을 것입니다!");
}
이것이 추상적인 개념처럼 보일 수 있지만, 실제로는 매우 유용하며 종종 편리하게 사용됩니다. 이 타입의 주요 장점은 다른 어떤 타입으로도 형변환될 수 있다는 것이며, 이는 match 분기와 같이 정확한 타입이 요구되는 상황에서 다재다능하게 만들어줍니다. 이러한 유연성 덕분에 다음과 같은 코드를 작성할 수 있습니다:
fn main() {
fn sum_odd_numbers(up_to: u32) -> u32 {
let mut acc = 0;
for i in 0..up_to {
// 이 match 표현식의 반환 타입은 "addition" 변수의 타입 때문에
// 반드시 u32여야 함에 주목하세요.
let addition: u32 = match i%2 == 1 {
// "i" 변수는 u32 타입이며, 이는 완벽하게 괜찮습니다.
true => i,
// 반면에 "continue" 표현식은 u32를 반환하지 않지만,
// 결코 반환되지 않으므로 match 표현식의 타입 요구 사항을
// 위반하지 않으며 여전히 괜찮습니다.
false => continue,
};
acc += addition;
}
acc
}
println!("9 미만의 홀수들의 합: {}", sum_odd_numbers(9));
}
또한 네트워크 서버처럼 영원히 루프를 도는 함수(예: loop {})나 프로세스를 종료하는 함수(예: exit())의 반환 타입이기도 합니다.
모듈
Rust는 코드를 논리적 단위(모듈)로 계층적으로 나누고, 그들 사이의 가시성(public/private)을 관리하는 데 사용할 수 있는 강력한 모듈 시스템을 제공합니다.
모듈은 함수, 구조체, 트레이트, impl 블록, 그리고 다른 모듈까지 포함하는 아이템들의 모음입니다.
가시성
기본적으로 모듈 내의 아이템들은 비공개(private) 가시성을 가지지만, pub 수식어를 사용하여 이를 재정의할 수 있습니다. 모듈 스코프 외부에서는 모듈의 공개(public) 아이템만 접근할 수 있습니다.
// `my_mod`라는 이름의 모듈
mod my_mod {
// 모듈 내의 아이템들은 기본적으로 비공개 가시성을 가집니다.
fn private_function() {
println!("`my_mod::private_function()` 호출됨");
}
// 기본 가시성을 재정의하려면 `pub` 수식어를 사용하세요.
pub fn function() {
println!("`my_mod::function()` 호출됨");
}
// 아이템들은 같은 모듈 내의 다른 아이템들에 접근할 수 있으며,
// 이는 비공개 아이템이라도 마찬가지입니다.
pub fn indirect_access() {
print!("`my_mod::indirect_access()` 호출됨, 결과는\n> ");
private_function();
}
// 모듈은 중첩될 수도 있습니다
pub mod nested {
pub fn function() {
println!("`my_mod::nested::function()` 호출됨");
}
#[allow(dead_code)]
fn private_function() {
println!("`my_mod::nested::private_function()` 호출됨");
}
// `pub(in path)` 구문을 사용하여 선언된 함수들은 오직 지정된
// 경로 내에서만 보입니다. `path`는 반드시 부모 또는 조상 모듈이어야 합니다.
pub(in crate::my_mod) fn public_function_in_my_mod() {
print!("`my_mod::nested::public_function_in_my_mod()` 호출됨, 결과는\n> ");
public_function_in_nested();
}
// `pub(self)` 구문을 사용하여 선언된 함수들은 오직 현재 모듈
// 내에서만 보이며, 이는 비공개로 두는 것과 동일합니다.
pub(self) fn public_function_in_nested() {
println!("`my_mod::nested::public_function_in_nested()` 호출됨");
}
// `pub(super)` 구문을 사용하여 선언된 함수들은 오직 부모 모듈
// 내에서만 보입니다.
pub(super) fn public_function_in_super_mod() {
println!("`my_mod::nested::public_function_in_super_mod()` 호출됨");
}
}
pub fn call_public_function_in_my_mod() {
print!("`my_mod::call_public_function_in_my_mod()` 호출됨, 결과는\n> ");
nested::public_function_in_my_mod();
print!("> ");
nested::public_function_in_super_mod();
}
// pub(crate)는 함수를 오직 현재 크레이트 내에서만 보이게 만듭니다
pub(crate) fn public_function_in_crate() {
println!("`my_mod::public_function_in_crate()` 호출됨");
}
// 중첩된 모듈도 동일한 가시성 규칙을 따릅니다
mod private_nested {
#[allow(dead_code)]
pub fn function() {
println!("`my_mod::private_nested::function()` 호출됨");
}
// 비공개 부모 아이템은 자식 아이템이 더 큰 스코프 내에서 보이도록
// 선언되어 있더라도 여전히 그 가시성을 제한합니다.
#[allow(dead_code)]
pub(crate) fn restricted_function() {
println!("`my_mod::private_nested::restricted_function()` 호출됨");
}
}
}
fn function() {
println!("`function()` 호출됨");
}
fn main() {
// 모듈은 이름이 같은 아이템들 간의 모호함을 해소할 수 있게 해줍니다.
function();
my_mod::function();
// 중첩된 모듈 내부에 있는 아이템을 포함한 공개 아이템들은
// 부모 모듈 외부에서 접근할 수 있습니다.
my_mod::indirect_access();
my_mod::nested::function();
my_mod::call_public_function_in_my_mod();
// pub(crate) 아이템은 같은 크레이트 내 어디에서나 호출될 수 있습니다
my_mod::public_function_in_crate();
// `pub(in path)` 아이템은 오직 지정된 모듈 내에서만 호출될 수 있습니다
// 에러! `public_function_in_my_mod` 함수는 비공개입니다
// my_mod::nested::public_function_in_my_mod();
// TODO ^ 이 줄의 주석을 해제해 보세요
// 모듈의 비공개 아이템은 공개 모듈에 중첩되어 있더라도
// 직접 접근할 수 없습니다:
// 에러! `private_function`은 비공개입니다
// my_mod::private_function();
// TODO ^ 이 줄의 주석을 해제해 보세요
// 에러! `private_function`은 비공개입니다
// my_mod::nested::private_function();
// TODO ^ 이 줄의 주석을 해제해 보세요
// 에러! `private_nested`는 비공개 모듈입니다
// my_mod::private_nested::function();
// TODO ^ 이 줄의 주석을 해제해 보세요
// 에러! `private_nested`는 비공개 모듈입니다
// my_mod::private_nested::restricted_function();
// TODO ^ 이 줄의 주석을 해제해 보세요
}
구조체 가시성
구조체는 필드에 대해 추가적인 수준의 가시성을 가집니다. 가시성은 기본적으로 비공개이며, pub 수식어로 재정의할 수 있습니다. 이 가시성은 구조체가 정의된 모듈 외부에서 접근할 때만 의미가 있으며, 정보를 숨기는 것(캡슐화)이 목적입니다.
mod my {
// 제네릭 타입 `T`의 공개 필드를 가진 공개 구조체
pub struct OpenBox<T> {
pub contents: T,
}
// 제네릭 타입 `T`의 비공개 필드를 가진 공개 구조체
pub struct ClosedBox<T> {
contents: T,
}
impl<T> ClosedBox<T> {
// 공개 생성자 메서드
pub fn new(contents: T) -> ClosedBox<T> {
ClosedBox {
contents: contents,
}
}
}
}
fn main() {
// 공개 필드를 가진 공개 구조체는 평소처럼 생성할 수 있습니다
let open_box = my::OpenBox { contents: "공개 정보" };
// 그리고 그 필드들에 정상적으로 접근할 수 있습니다.
println!("열린 박스 내용물: {}", open_box.contents);
// 비공개 필드를 가진 공개 구조체는 필드 이름을 사용하여 생성할 수 없습니다.
// 에러! `ClosedBox`에 비공개 필드가 있습니다
// let closed_box = my::ClosedBox { contents: "classified information" };
// TODO ^ 이 줄의 주석을 해제해 보세요
// 하지만 비공개 필드를 가진 구조체는 공개 생성자를 통해 생성할 수 있습니다
let _closed_box = my::ClosedBox::new("기밀 정보");
// 그리고 공개 구조체의 비공개 필드에는 접근할 수 없습니다.
// 에러! `contents` 필드는 비공개입니다
// println!("닫힌 박스 내용물: {}", _closed_box.contents);
// TODO ^ 이 줄의 주석을 해제해 보세요
}
참고:
use 선언
use 선언을 사용하여 전체 경로를 새로운 이름에 바인딩하여 더 쉽게 접근할 수 있습니다. 다음과 같이 자주 사용됩니다:
use crate::deeply::nested::{
my_first_function,
my_second_function,
AndATraitType
};
fn main() {
my_first_function();
}
as 키워드를 사용하여 임포트(imports)를 다른 이름에 바인딩할 수 있습니다:
// `deeply::nested::function` 경로를 `other_function`에 바인딩합니다.
use deeply::nested::function as other_function;
fn function() {
println!("`function()` 호출됨");
}
mod deeply {
pub mod nested {
pub fn function() {
println!("`deeply::nested::function()` 호출됨");
}
}
}
fn main() {
// `deeply::nested::function`에 더 쉽게 접근
other_function();
println!("블록 진입");
{
// 이는 `use deeply::nested::function as function`과 동일합니다.
// 이 `function()`은 외부의 것을 섀도잉합니다.
use crate::deeply::nested::function;
// `use` 바인딩은 지역 스코프를 가집니다. 이 경우
// `function()`의 섀도잉은 이 블록 내에서만 유효합니다.
function();
println!("블록 나감");
}
function();
}
super와 self
super와 self 키워드는 아이템에 접근할 때 모호함을 제거하고 경로의 불필요한 하드코딩을 방지하기 위해 경로 내에서 사용될 수 있습니다.
fn function() {
println!("`function()` 호출됨");
}
mod cool {
pub fn function() {
println!("`cool::function()` 호출됨");
}
}
mod my {
fn function() {
println!("`my::function()` 호출됨");
}
mod cool {
pub fn function() {
println!("`my::cool::function()` 호출됨");
}
}
pub fn indirect_call() {
// 이 스코프에서 `function`이라는 이름의 모든 함수에 접근해 봅시다!
print!("`my::indirect_call()` 호출됨, 결과는\n> ");
// `self` 키워드는 현재 모듈 스코프를 가리킵니다. 이 경우 `my`입니다.
// `self::function()`을 호출하는 것과 `function()`을 직접 호출하는 것은
// 동일한 함수를 가리키기 때문에 같은 결과를 냅니다.
self::function();
function();
// `self`를 사용하여 `my` 내부의 다른 모듈에 접근할 수도 있습니다:
self::cool::function();
// `super` 키워드는 부모 스코프(`my` 모듈 외부)를 가리킵니다.
super::function();
// 이는 *크레이트* 스코프에 있는 `cool::function`에 바인딩됩니다.
// 이 경우 크레이트 스코프는 가장 바깥쪽 스코프입니다.
{
use crate::cool::function as root_function;
root_function();
}
}
}
fn main() {
my::indirect_call();
}
파일 계층 구조
모듈은 파일/디렉토리 계층 구조에 매핑될 수 있습니다. 가시성 예제를 파일별로 나누어 봅시다:
$ tree .
.
├── my
│ ├── inaccessible.rs
│ └── nested.rs
├── my.rs
└── split.rs
split.rs에서:
// 이 선언은 `my.rs`라는 파일을 찾아 그 내용을
// 이 스코프 아래의 `my`라는 이름의 모듈로 삽입할 것입니다
mod my;
fn function() {
println!("`function()` 호출됨");
}
fn main() {
my::function();
function();
my::indirect_access();
my::nested::function();
}
my.rs에서:
// 마찬가지로 `mod inaccessible`과 `mod nested`는 `nested.rs`와
// `inaccessible.rs` 파일을 찾아 각각의 모듈 아래에
// 삽입할 것입니다
mod inaccessible;
pub mod nested;
pub fn function() {
println!("`my::function()` 호출됨");
}
fn private_function() {
println!("`my::private_function()` 호출됨");
}
pub fn indirect_access() {
print!("`my::indirect_access()` 호출됨, 결과는\n> ");
private_function();
}
my/nested.rs에서:
pub fn function() {
println!("`my::nested::function()` 호출됨");
}
#[allow(dead_code)]
fn private_function() {
println!("`my::nested::private_function()` 호출됨");
}
my/inaccessible.rs에서:
#[allow(dead_code)]
pub fn public_function() {
println!("`my::inaccessible::public_function()` 호출됨");
}
모든 것이 이전과 같이 여전히 잘 작동하는지 확인해 봅시다:
$ rustc split.rs && ./split
called `my::function()`
called `function()`
called `my::indirect_access()`, that
> called `my::private_function()`
called `my::nested::function()`
크레이트
크레이트(crate)는 Rust의 컴파일 단위입니다. rustc some_file.rs가 호출될 때마다 some_file.rs는 _크레이트 파일_로 처리됩니다. 만약 some_file.rs 내부에 mod 선언이 있다면, 컴파일러가 실행되기 _전_에 크레이트 파일에서 mod 선언이 발견된 위치에 모듈 파일의 내용이 삽입됩니다. 즉, 모듈은 개별적으로 컴파일되지 않으며 오직 크레이트만 컴파일됩니다.
크레이트는 바이너리 또는 라이브러리로 컴파일될 수 있습니다. 기본적으로 rustc는 크레이트로부터 바이너리를 생성합니다. 이 동작은 --crate-type 플래그에 lib을 전달하여 재정의할 수 있습니다.
라이브러리 생성
라이브러리를 만든 다음, 이를 다른 크레이트와 어떻게 연결하는지 살펴봅시다.
rary.rs에서:
pub fn public_function() {
println!(rary의 `public_function()` 호출됨);
}
fn private_function() {
println!(rary의 `private_function()` 호출됨);
}
pub fn indirect_access() {
print!(rary의 `indirect_access()` 호출됨, 결과는\n> ");
private_function();
}
$ rustc --crate-type=lib rary.rs
$ ls lib*
library.rlib
라이브러리에는 “lib” 접두사가 붙으며, 기본적으로 크레이트 파일의 이름을 따서 명명되지만, 이 기본 이름은 rustc에 --crate-name 옵션을 전달하거나 crate_name 속성을 사용하여 재정의할 수 있습니다.
라이브러리 사용
이 새로운 라이브러리에 크레이트를 연결하려면 rustc의 --extern 플래그를 사용할 수 있습니다. 그러면 모든 아이템은 라이브러리와 동일한 이름을 가진 모듈 아래로 임포트됩니다. 이 모듈은 일반적으로 다른 모듈과 동일하게 동작합니다.
// extern crate rary; // Rust 2015 에디션 이하에서는 필요할 수 있습니다
fn main() {
rary::public_function();
// 에러! `private_function`은 비공개입니다
// rary::private_function();
rary::indirect_access();
}
# Where library.rlib is the path to the compiled library, assumed that it's
# in the same directory here:
$ rustc executable.rs --extern rary=library.rlib && ./executable
called rary's `public_function()`
called rary's `indirect_access()`, that
> called rary's `private_function()`
카고
cargo는 Rust 공식 패키지 관리 도구입니다. 코드 품질과 개발 속도를 향상시키기 위한 정말 유용한 기능들을 많이 가지고 있습니다! 다음과 같은 기능들이 포함됩니다:
- crates.io (공식 Rust 패키지 레지스트리)와의 통합 및 의존성 관리
- 유닛 테스트 지원
- 벤치마크 지원
이 장에서는 기본적인 사항들을 빠르게 살펴볼 것입니다. 더 자세한 문서는 The Cargo Book에서 찾을 수 있습니다.
의존성
대부분의 프로그램은 일부 라이브러리에 의존성을 가집니다. 의존성을 수동으로 관리해 본 적이 있다면 그것이 얼마나 고통스러운 일인지 알 것입니다. 다행히도 Rust 생태계에는 cargo가 표준으로 포함되어 있습니다! cargo는 프로젝트의 의존성을 관리할 수 있습니다.
새로운 Rust 프로젝트를 만들려면,
# 바이너리
cargo new foo
# 라이브러리
cargo new --lib bar
이 장의 나머지 부분에서는 라이브러리가 아닌 바이너리를 만든다고 가정하겠습니다. 하지만 모든 개념은 동일합니다.
위의 명령어를 실행한 후에는 다음과 같은 파일 계층 구조를 보게 될 것입니다:
.
├── bar
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── foo
├── Cargo.toml
└── src
└── main.rs
main.rs는 새로운 foo 프로젝트의 루트 소스 파일입니다. 특별할 것은 없습니다. Cargo.toml은 이 프로젝트에 대한 cargo 설정 파일입니다. 그 안을 들여다보면 다음과 같은 내용을 볼 수 있을 것입니다:
[package]
name = "foo"
version = "0.1.0"
authors = ["mark"]
[dependencies]
[package] 아래의 name 필드는 프로젝트의 이름을 결정합니다. 이는 크레이트를 배포할 때(나중에 자세히 설명함) crates.io에서 사용됩니다. 또한 컴파일할 때 생성되는 바이너리의 이름이기도 합니다.
version 필드는 유의적 버전(Semantic Versioning)을 사용하는 크레이트 버전 번호입니다.
authors 필드는 크레이트를 배포할 때 사용되는 작성자 목록입니다.
[dependencies] 섹션에서는 프로젝트에 필요한 의존성을 추가할 수 있습니다.
예를 들어, 우리 프로그램이 멋진 CLI를 갖기를 원한다고 가정해 봅시다. crates.io (공식 Rust 패키지 레지스트리)에서 훌륭한 패키지들을 많이 찾을 수 있습니다. 인기 있는 선택지 중 하나는 clap입니다. 이 글을 쓰는 시점에서 clap의 최신 버전은 2.27.1입니다. 프로그램에 의존성을 추가하려면 Cargo.toml의 [dependencies] 아래에 다음과 같이 추가하면 됩니다: clap = "2.27.1". 이것으로 끝입니다! 이제 프로그램에서 clap을 사용할 수 있습니다.
cargo는 다른 유형의 의존성도 지원합니다. 다음은 몇 가지 예시입니다:
[package]
name = "foo"
version = "0.1.0"
authors = ["mark"]
[dependencies]
clap = "2.27.1" # crates.io에서
rand = { git = "https://github.com/rust-lang-nursery/rand" } # 온라인 저장소에서
bar = { path = "../bar" } # 로컬 파일시스템의 경로에서
cargo는 단순한 의존성 관리자 그 이상입니다. 사용 가능한 모든 설정 옵션은 Cargo.toml의 형식 명세(format specification)에 나열되어 있습니다.
프로젝트를 빌드하려면 프로젝트 디렉토리 어디에서나(하위 디렉토리 포함!) cargo build를 실행하면 됩니다. 또한 cargo run을 통해 빌드와 실행을 동시에 할 수도 있습니다. 이 명령어들은 모든 의존성을 해결하고, 필요한 경우 크레이트를 다운로드하며, 여러분의 크레이트를 포함한 모든 것을 빌드합니다. (이미 빌드된 것은 다시 빌드하지 않으며, 이는 make와 유사합니다).
보세요! 이게 전부입니다!
관례
이전 장에서 우리는 다음과 같은 디렉터리 계층 구조를 보았습니다.
foo
├── Cargo.toml
└── src
└── main.rs
하지만 같은 프로젝트에 두 개의 바이너리를 두고 싶다면 어떨까요? 그럴 땐 어떻게 해야 할까요?
다행히 cargo는 이를 지원합니다. 이전에 보았듯이 기본 바이너리 이름은 main이지만, bin/ 디렉터리에 추가 바이너리를 배치하여 추가할 수 있습니다.
foo
├── Cargo.toml
└── src
├── main.rs
└── bin
└── my_other_bin.rs
cargo에게 이 바이너리만 컴파일하거나 실행하도록 하려면, cargo에 --bin my_other_bin 플래그를 전달하면 됩니다. 여기서 my_other_bin은 작업하려는 바이너리의 이름입니다.
추가 바이너리 외에도 cargo는 벤치마크, 테스트, 예제와 같은 더 많은 기능을 지원합니다.
다음 장에서는 테스트에 대해 더 자세히 살펴보겠습니다.
테스트
우리 모두 알다시피 테스트는 모든 소프트웨어의 필수적인 부분입니다! Rust는 유닛 테스트와 통합 테스트를 위한 일류(first-class) 지원을 제공합니다(TRPL의 이 장을 참조하세요).
위에 링크된 테스트 장들에서 유닛 테스트와 통합 테스트를 작성하는 방법을 확인할 수 있습니다. 구조적으로 유닛 테스트는 테스트 대상 모듈에 배치하고, 통합 테스트는 별도의 tests/ 디렉터리에 배치할 수 있습니다.
foo
├── Cargo.toml
├── src
│ └── main.rs
│ └── lib.rs
└── tests
├── my_test.rs
└── my_other_test.rs
tests 내의 각 파일은 별도의 통합 테스트입니다. 즉, 의존성 있는 크레이트에서 호출되는 것처럼 라이브러리를 테스트하기 위한 것입니다.
테스트 장에서는 유닛(Unit), 문서(Doc), 통합(Integration)의 세 가지 테스트 스타일에 대해 자세히 설명합니다.
cargo는 당연히 모든 테스트를 실행할 수 있는 쉬운 방법을 제공합니다!
$ cargo test
다음과 같은 출력을 볼 수 있을 것입니다:
$ cargo test
Compiling blah v0.1.0 (file:///nobackup/blah)
Finished dev [unoptimized + debuginfo] target(s) in 0.89 secs
Running target/debug/deps/blah-d3b32b97275ec472
running 4 tests
test test_bar ... ok
test test_baz ... ok
test test_foo_bar ... ok
test test_foo ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
``````shell
$ cargo test
Compiling blah v0.1.0 (file:///nobackup/blah)
Finished dev [unoptimized + debuginfo] target(s) in 0.89 secs
Running target/debug/deps/blah-d3b32b97275ec472
running 4 tests
test test_bar ... ok
test test_baz ... ok
test test_foo_bar ... ok
test test_foo ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
이름이 패턴과 일치하는 테스트만 실행할 수도 있습니다:
$ cargo test test_foo
$ cargo test test_foo
Compiling blah v0.1.0 (file:///nobackup/blah)
Finished dev [unoptimized + debuginfo] target(s) in 0.35 secs
Running target/debug/deps/blah-d3b32b97275ec472
running 2 tests
test test_foo ... ok
test test_foo_bar ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out
한 가지 주의할 점: Cargo는 여러 테스트를 동시에 실행할 수 있으므로 서로 경쟁(race)하지 않도록 확인해야 합니다.
이러한 동시성이 문제를 일으키는 한 가지 예는 아래와 같이 두 테스트가 하나의 파일에 출력하는 경우입니다:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
// 필요한 모듈 가져오기
use std::fs::OpenOptions;
use std::io::Write;
// 이 테스트는 파일에 씁니다
#[test]
fn test_file() {
// ferris.txt 파일을 열거나 없으면 생성합니다.
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("ferris.txt")
.expect("ferris.txt를 여는 데 실패했습니다");
// "Ferris"를 5번 출력합니다.
for _ in 0..5 {
file.write_all("Ferris\n".as_bytes())
.expect("ferris.txt에 쓸 수 없습니다");
}
}
// 이 테스트는 동일한 파일에 쓰기를 시도합니다
#[test]
fn test_file_also() {
// ferris.txt 파일을 열거나 없으면 생성합니다.
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("ferris.txt")
.expect("ferris.txt를 여는 데 실패했습니다");
// "Corro"를 5번 출력합니다.
for _ in 0..5 {
file.write_all("Corro\n".as_bytes())
.expect("ferris.txt에 쓸 수 없습니다");
}
}
}
}
의도는 다음과 같은 결과를 얻는 것이었지만:
$ cat ferris.txt
Ferris
Ferris
Ferris
Ferris
Ferris
Corro
Corro
Corro
Corro
Corro
ferris.txt에 실제로 기록된 내용은 다음과 같습니다:
$ cargo test test_file && cat ferris.txt
Corro
Ferris
Corro
Ferris
Corro
Ferris
Corro
Ferris
Corro
Ferris
빌드 스크립트
cargo의 일반적인 빌드만으로는 충분하지 않은 경우가 있습니다. 코드 생성이나 컴파일되어야 하는 네이티브 코드와 같이 cargo가 성공적으로 컴파일되기 전에 크레이트가 필요로 하는 전제 조건이 있을 수 있습니다. 이 문제를 해결하기 위해 Cargo가 실행할 수 있는 빌드 스크립트가 있습니다.
패키지에 빌드 스크립트를 추가하려면 다음과 같이 Cargo.toml에 지정할 수 있습니다:
[package]
...
build = "build.rs"
그렇지 않으면 Cargo는 기본적으로 프로젝트 디렉토리에서 build.rs 파일을 찾습니다.
빌드 스크립트 사용 방법
빌드 스크립트는 단순히 또 다른 Rust 파일로, 패키지의 다른 어떤 것을 컴파일하기 전에 먼저 컴파일되고 호출됩니다. 따라서 크레이트의 전제 조건을 충족시키는 데 사용할 수 있습니다.
Cargo는 스크립트에서 사용할 수 있는 환경 변수(여기에 명시됨)를 통해 입력을 제공합니다.
스크립트는 stdout을 통해 출력을 제공합니다. 출력된 모든 줄은 target/debug/build/<pkg>/output에 기록됩니다. 또한, cargo:로 시작하는 줄은 Cargo에 의해 직접 해석되어 패키지 컴파일을 위한 매개변수를 정의하는 데 사용될 수 있습니다.
추가적인 사양과 예제는 Cargo 사양서를 읽어보세요.
속성
속성(attribute)은 모듈, 크레이트 또는 아이템에 적용되는 메타데이터입니다. 이 메타데이터는 다음과 같은 용도로 사용될 수 있습니다:
- 코드의 조건부 컴파일
- 크레이트 이름, 버전 및 유형(바이너리 또는 라이브러리) 설정
- 린트(lints) (경고) 비활성화
- 컴파일러 기능(매크로, glob 임포트 등) 활성화
- 외부 라이브러리 링크
- 함수를 유닛 테스트로 표시
- 벤치마크의 일부가 될 함수 표시
- 속성형 매크로
속성은 #[outer_attribute] 또는 #![inner_attribute]와 같은 형태를 띠며, 이 둘의 차이점은 적용되는 위치에 있습니다.
-
#[outer_attribute]는 바로 뒤에 오는 아이템에 적용됩니다. 아이템의 예로는 함수, 모듈 선언, 상수, 구조체, 열거형 등이 있습니다. 다음은#[derive(Debug)]속성이Rectangle구조체에 적용된 예시입니다:#![allow(unused)] fn main() { #[derive(Debug)] struct Rectangle { width: u32, height: u32, } } -
#![inner_attribute]는 자신을 감싸고 있는 아이템 (주로 모듈이나 크레이트)에 적용됩니다. 다시 말해, 이 속성은 자신이 위치한 전체 스코프에 적용되는 것으로 해석됩니다. 다음은#![allow(unused_variables)]가 (main.rs에 위치할 경우) 전체 크레이트에 적용되는 예시입니다:#![allow(unused_variables)] fn main() { let x = 3; // 이것은 보통 사용되지 않는 변수에 대해 경고합니다. }
속성은 다양한 문법으로 인자를 받을 수 있습니다:
#[attribute = "value"]#[attribute(key = "value")]#[attribute(value)]
속성은 여러 값을 가질 수 있으며 여러 줄에 걸쳐 분리될 수도 있습니다:
#[attribute(value, value2)]
#[attribute(value, value2, value3,
value4, value5)]
dead_code
컴파일러는 사용되지 않는 함수에 대해 경고하는 dead_code 린트(lint)를 제공합니다. _속성_을 사용하여 이 린트를 비활성화할 수 있습니다.
fn used_function() {}
// `#[allow(dead_code)]`는 `dead_code` 린트를 비활성화하는 속성입니다
#[allow(dead_code)]
fn unused_function() {}
fn noisy_unused_function() {}
// FIXME ^ 경고를 억제하기 위해 속성을 추가하세요
fn main() {
used_function();
}
실제 프로그램에서는 죽은 코드를 제거해야 합니다. 이 예제들에서는 예제의 대화형 특성 때문에 일부 장소에서 죽은 코드를 허용할 것입니다.
크레이트
crate_type 속성은 크레이트가 바이너리인지 라이브러리인지(심지어 어떤 종류의 라이브러리인지) 컴파일러에게 알려주는 데 사용될 수 있으며, crate_name 속성은 크레이트의 이름을 설정하는 데 사용될 수 있습니다.
하지만 Rust 패키지 관리자인 Cargo를 사용할 때는 crate_type과 crate_name 속성 모두 아무런 효과가 없다는 점에 유의해야 합니다. 대다수의 Rust 프로젝트가 Cargo를 사용하므로, crate_type과 crate_name의 실제 사용은 상대적으로 제한적입니다.
// 이 크레이트는 라이브러리입니다
#![crate_type = "lib"]
// 라이브러리 이름은 "rary"입니다
#![crate_name = "rary"]
pub fn public_function() {
println!(rary의 `public_function()` 호출됨);
}
fn private_function() {
println!(rary의 `private_function()` 호출됨);
}
pub fn indirect_access() {
print!(rary의 `indirect_access()` 호출됨, 결과는\n> ");
private_function();
}
crate_type 속성이 사용되면, 더 이상 rustc에 --crate-type 플래그를 전달할 필요가 없습니다.
$ rustc lib.rs
$ ls lib*
library.rlib
cfg
설정 조건 검사는 두 가지 다른 연산자를 통해 가능합니다:
cfg속성: 속성 위치에서의#[cfg(...)]cfg!매크로: 불리언 표현식에서의cfg!(...)
전자는 조건부 컴파일을 가능하게 하는 반면, 후자는 조건부로 true 또는 false 리터럴로 평가되어 런타임에 검사를 가능하게 합니다. 둘 다 동일한 인자 문법을 사용합니다.
cfg!는 #[cfg]와 달리 코드를 제거하지 않으며 오직 참 또는 거짓으로만 평가됩니다. 예를 들어 cfg!가 조건으로 사용될 때, cfg!가 무엇으로 평가되든 상관없이 if/else 표현식의 모든 블록은 유효해야 합니다.
// 이 함수는 대상 OS가 리눅스인 경우에만 컴파일됩니다
#[cfg(target_os = "linux")]
fn are_you_on_linux() {
println!("당신은 리눅스를 사용하고 있습니다!");
}
// 그리고 이 함수는 타겟 OS가 리눅스가 *아닌* 경우에만 컴파일됩니다.
#[cfg(not(target_os = "linux"))]
fn are_you_on_linux() {
println!("당신은 리눅스를 사용하고 있지 *않습니다*!");
}
fn main() {
are_you_on_linux();
println!("확실한가요?");
if cfg!(target_os = "linux") {
println!("네. 확실히 리눅스입니다!");
} else {
println!("네. 확실히 리눅스가 *아닙니다*!");
}
}
참고:
사용자 정의
target_os와 같은 일부 조건은 rustc에 의해 암시적으로 제공되지만, 사용자 정의 조건은 --cfg 플래그를 사용하여 rustc에 전달되어야 합니다.
#[cfg(some_condition)]
fn conditional_function() {
println!("조건 충족!");
}
fn main() {
conditional_function();
}
사용자 정의 cfg 플래그 없이 실행하면 어떻게 되는지 확인해 보세요.
사용자 정의 cfg 플래그 사용 시:
$ rustc --cfg some_condition custom.rs && ./custom
condition met!
제네릭
_제네릭(Generics)_은 타입과 기능을 더 넓은 케이스로 일반화하는 주제입니다. 이는 여러 면에서 코드 중복을 줄이는 데 매우 유용하지만, 다소 복잡한 문법을 요구할 수 있습니다. 즉, 제네릭이 되려면 제네릭 타입이 실제로 유효한 것으로 간주되는 타입을 지정하는 데 세심한 주의가 필요합니다. 제네릭의 가장 간단하고 흔한 용도는 타입 파라미터입니다.
타입 파라미터는 꺾쇠 괄호와 대문자 카멜 케이스를 사용하여 제네릭으로 지정됩니다: <Aaa, Bbb, ...>. “제네릭 타입 파라미터“는 일반적으로 <T>로 표현됩니다. Rust에서 “제네릭“은 하나 이상의 제네릭 타입 파라미터 <T>를 허용하는 모든 것을 설명하기도 합니다. 제네릭 타입 파라미터로 지정된 모든 타입은 제네릭이며, 그 외의 모든 것은 구체적(비-제네릭)입니다.
예를 들어, 모든 타입의 인자 T를 취하는 foo라는 이름의 _제네릭 함수_를 정의하면 다음과 같습니다:
fn foo<T>(arg: T) { ... }
T가 <T>를 사용하여 제네릭 타입 파라미터로 지정되었으므로, 여기서 (arg: T)와 같이 사용될 때 제네릭으로 간주됩니다. 이는 T가 이전에 struct로 정의되었더라도 마찬가지입니다.
이 예제는 실제 작동하는 문법의 일부를 보여줍니다:
// 구체적인 타입 `A`.
struct A;
// `Single` 타입을 정의할 때, `A`의 첫 사용 앞에 `<A>`가 붙지 않습니다.
// 따라서 `Single`은 구체적인 타입이며, `A`는 위에서 정의된 대로입니다.
struct Single(A);
// ^ 여기서 `Single`이 처음으로 `A` 타입을 사용합니다.
// 여기서 `<T>`가 `T`의 첫 사용 앞에 오므로, `SingleGen`은 제네릭 타입입니다.
// 타입 파라미터 `T`는 제네릭이므로, 상단에서 정의된 구체적인 타입 `A`를 포함하여
// 무엇이든 될 수 있습니다.
struct SingleGen<T>(T);
fn main() {
// `Single`은 구체적이며 명시적으로 `A`를 취합니다.
let _s = Single(A);
// `SingleGen<char>` 타입의 변수 `_char`를 생성하고
// `SingleGen('a')` 값을 줍니다.
// 여기서 `SingleGen`은 타입 파라미터가 명시적으로 지정되었습니다.
let _char: SingleGen<char> = SingleGen('a');
// `SingleGen`은 타입 파라미터를 암시적으로 지정할 수도 있습니다:
let _t = SingleGen(A); // 상단에서 정의된 `A`를 사용합니다.
let _i32 = SingleGen(6); // `i32`를 사용합니다.
let _char = SingleGen('a'); // `char`를 사용합니다.
}
참고:
함수
함수에도 동일한 규칙이 적용될 수 있습니다: 타입 T 앞에 <T>가 붙으면 제네릭이 됩니다.
제네릭 함수를 사용할 때 때로는 타입 파라미터를 명시적으로 지정해야 할 필요가 있습니다. 반환 타입이 제네릭인 곳에서 함수가 호출되거나, 컴파일러가 필요한 타입 파라미터를 추론하기에 충분한 정보를 가지고 있지 않은 경우가 이에 해당할 수 있습니다.
명시적으로 지정된 타입 파라미터를 사용한 함수 호출은 다음과 같습니다: fun::<A, B, ...>().
struct A; // 구체적인 타입 `A`.
struct S(A); // 구체적인 타입 `S`.
struct SGen<T>(T); // 제네릭 타입 `SGen`.
// 다음 함수들은 모두 전달된 변수의 소유권을 가져가며,
// 즉시 스코프를 벗어나 변수를 해제합니다.
// `S` 타입의 인자 `_s`를 받는 `reg_fn` 함수를 정의합니다.
// `<T>`가 없으므로 이것은 제네릭 함수가 아닙니다.
fn reg_fn(_s: S) {}
// `SGen<T>` 타입의 인자 `_s`를 받는 `gen_spec_t` 함수를 정의합니다.
// 타입 파라미터 `A`가 명시적으로 주어졌지만, `A`가 `gen_spec_t`의
// 제네릭 타입 파라미터로 지정되지 않았으므로, 이것은 제네릭이 아닙니다.
fn gen_spec_t(_s: SGen<A>) {}
// `SGen<i32>` 타입의 인자 `_s`를 받는 `gen_spec_i32` 함수를 정의합니다.
// 구체적인 타입인 `i32`가 타입 파라미터로 명시적으로 주어졌습니다.
// `i32`는 제네릭 타입이 아니므로, 이 함수 또한 제네릭이 아닙니다.
fn gen_spec_i32(_s: SGen<i32>) {}
// `SGen<T>` 타입의 인자 `_s`를 받는 `generic` 함수를 정의합니다.
// `SGen<T>` 앞에 `<T>`가 오기 때문에, 이 함수는 `T`에 대해 제네릭입니다.
fn generic<T>(_s: SGen<T>) {}
fn main() {
// 제네릭이 아닌 함수들 사용
reg_fn(S(A)); // 구체적인 타입.
gen_spec_t(SGen(A)); // 암시적으로 지정된 타입 파라미터 `A`.
gen_spec_i32(SGen(6)); // 암시적으로 지정된 타입 파라미터 `i32`.
// `generic()`에 명시적으로 지정된 타입 파라미터 `char`.
generic::<char>(SGen('a'));
// `generic()`에 암시적으로 지정된 타입 파라미터 `char`.
generic(SGen('c'));
}
참고:
구현
함수와 마찬가지로, 구현(implementation)도 제네릭을 유지하기 위해 주의가 필요합니다.
#![allow(unused)]
fn main() {
struct S; // 구체적인 타입 `S`
struct GenericVal<T>(T); // 제네릭 타입 `GenericVal`
// 타입 파라미터를 명시적으로 지정한 GenericVal의 구현:
impl GenericVal<f32> {} // `f32` 지정
impl GenericVal<S> {} // 위에서 정의한 `S` 지정
// 제네릭을 유지하려면 `<T>`가 타입 앞에 와야 합니다
impl<T> GenericVal<T> {}
}
struct Val {
val: f64,
}
struct GenVal<T> {
gen_val: T,
}
// Val 구현
impl Val {
fn value(&self) -> &f64 {
&self.val
}
}
// 제네릭 타입 `T`에 대한 GenVal 구현
impl<T> GenVal<T> {
fn value(&self) -> &T {
&self.gen_val
}
}
fn main() {
let x = Val { val: 3.0 };
let y = GenVal { gen_val: 3i32 };
println!("{}, {}", x.value(), y.value());
}
참고:
참조를 반환하는 함수, impl, 그리고 struct
트레이트
물론 trait도 제네릭일 수 있습니다. 여기서는 Drop 트레이트를 자신과 입력을 drop하는 제네릭 메서드로 재구현한 것을 정의합니다.
// 복사 불가능한 타입.
struct Empty;
struct Null;
// `T`에 대해 제네릭인 트레이트.
trait DoubleDrop<T> {
// 호출자 타입에 대해 추가적인 단일 파라미터 `T`를 취하고
// 아무것도 하지 않는 메서드를 정의합니다.
fn double_drop(self, _: T);
}
// 임의의 제네릭 파라미터 `T`와 호출자 `U`에 대해
// `DoubleDrop<T>`를 구현합니다.
impl<T, U> DoubleDrop<T> for U {
// 이 메서드는 전달된 두 인자의 소유권을 가져와
// 둘 다 해제합니다.
fn double_drop(self, _: T) {}
}
fn main() {
let empty = Empty;
let null = Null;
// `empty`와 `null`을 해제합니다.
empty.double_drop(null);
//empty;
//null;
// ^ TODO: 이 줄들의 주석을 해제해 보세요.
}
참고:
바운드
제네릭을 사용할 때, 타입 파라미터는 종종 타입이 구현해야 하는 기능을 규정하기 위해 트레이트를 _바운드(bounds)_로 사용해야 합니다. 예를 들어, 다음 예제는 Display 트레이트를 사용하여 출력하므로 T가 Display에 바운드되어야 합니다. 즉, T는 반드시 Display를 구현해야 합니다.
// 제네릭 타입 `T`를 받는 함수 `printer`를 정의합니다.
// `T`는 반드시 `Display` 트레이트를 구현해야 합니다.
fn printer<T: Display>(t: T) {
println!("{}", t);
}
바운딩은 제네릭을 해당 바운드를 준수하는 타입들로 제한합니다. 즉:
struct S<T: Display>(T);
// 에러! `Vec<T>`는 `Display`를 구현하지 않습니다.
// 이 특수화는 실패할 것입니다.
let s = S(vec![1]);
바운딩의 또 다른 효과는 제네릭 인스턴스가 바운드에 지정된 트레이트의 메서드에 접근할 수 있게 된다는 것입니다. 예를 들어:
// 출력 마커 `{:?}`를 구현하는 트레이트입니다.
use std::fmt::Debug;
trait HasArea {
fn area(&self) -> f64;
}
impl HasArea for Rectangle {
fn area(&self) -> f64 { self.length * self.height }
}
#[derive(Debug)]
struct Rectangle { length: f64, height: f64 }
#[allow(dead_code)]
struct Triangle { length: f64, height: f64 }
// 제네릭 `T`는 반드시 `Debug`를 구현해야 합니다.
// 타입에 상관없이 이는 제대로 작동할 것입니다.
fn print_debug<T: Debug>(t: &T) {
println!("{:?}", t);
}
// `T`는 반드시 `HasArea`를 구현해야 합니다. 바운드를 충족하는
// 모든 타입은 `HasArea`의 `area` 함수에 접근할 수 있습니다.
fn area<T: HasArea>(t: &T) -> f64 { t.area() }
fn main() {
let rectangle = Rectangle { length: 3.0, height: 4.0 };
let _triangle = Triangle { length: 3.0, height: 4.0 };
print_debug(&rectangle);
println!("면적: {}", area(&rectangle));
//print_debug(&_triangle);
//println!("면적: {}", area(&_triangle));
// ^ TODO: 이 줄들의 주석을 해제해 보세요.
// | 에러: `Debug`나 `HasArea` 중 어느 것도 구현하지 않았습니다.
}
추가적으로, 어떤 경우에는 where 절을 사용하여 바운드를 더 표현력 있게 적용할 수도 있습니다.
참고:
테스트케이스: 빈 바운드
바운드가 작동하는 방식의 결과로, trait가 아무런 기능을 포함하지 않더라도 여전히 바운드로 사용할 수 있습니다. std 라이브러리의 Eq와 Copy가 그러한 trait의 예입니다.
struct Cardinal;
struct BlueJay;
struct Turkey;
trait Red {}
trait Blue {}
impl Red for Cardinal {}
impl Blue for BlueJay {}
// 이 함수들은 이 트레이트들을 구현하는 타입에 대해서만 유효합니다.
// 트레이트가 비어 있다는 사실은 무관합니다.
fn red<T: Red>(_: &T) -> &'static str { "빨강" }
fn blue<T: Blue>(_: &T) -> &'static str { "파랑" }
fn main() {
let cardinal = Cardinal;
let blue_jay = BlueJay;
let _turkey = Turkey;
// 바운드 때문에 `red()`는 blue jay(파랑 어치)에 대해 작동하지 않으며
// 그 반대도 마찬가지입니다.
println!("홍관조(cardinal)는 {}입니다", red(&cardinal));
println!("파랑 어치(blue jay)는 {}입니다", blue(&blue_jay));
//println!("칠면조는 {}", red(&_turkey));
// ^ TODO: 이 줄의 주석을 해제해 보세요.
}
참고:
std::cmp::Eq, std::marker::Copy, 그리고 trait
다중 바운드
단일 타입에 대해 +를 사용하여 다중 바운드를 적용할 수 있습니다. 평소와 마찬가지로, 서로 다른 타입은 ,로 구분됩니다.
use std::fmt::{Debug, Display};
fn compare_prints<T: Debug + Display>(t: &T) {
println!("디버그: `{:?}`", t);
println!("디스플레이: `{}`", t);
}
fn compare_types<T: Debug, U: Debug>(t: &T, u: &U) {
println!("t: `{:?}`", t);
println!("u: `{:?}`", u);
}
fn main() {
let string = "단어들";
let array = [1, 2, 3];
let vec = vec![1, 2, 3];
compare_prints(&string);
//compare_prints(&array);
// TODO ^ 주석을 해제해 보세요.
compare_types(&array, &vec);
}
참고:
Where 절
바운드는 타입이 처음 언급되는 곳이 아닌, 여는 중괄호 { 직전에 where 절을 사용하여 표현할 수도 있습니다. 또한, where 절은 타입 파라미터뿐만 아니라 임의의 타입에도 바운드를 적용할 수 있습니다.
where 절이 유용한 몇 가지 경우:
- 제네릭 타입과 바운드를 따로 명시하는 것이 더 명확할 때:
impl <A: TraitB + TraitC, D: TraitE + TraitF> MyTrait<A, D> for YourType {}
// `where` 절로 바운드 표현하기
impl <A, D> MyTrait<A, D> for YourType where
A: TraitB + TraitC,
D: TraitE + TraitF {}
where절을 사용하는 것이 일반 구문을 사용하는 것보다 더 표현력이 좋을 때. 이 예제의impl은where절 없이는 직접 표현할 수 없습니다:
use std::fmt::Debug;
trait PrintInOption {
fn print_in_option(self);
}
// 그렇지 않으면 `T: Debug`로 표현하거나
// 다른 간접적인 방법을 사용해야 하므로, 여기에는 `where` 절이 필요합니다:
impl<T> PrintInOption for T where
Option<T>: Debug {
// 출력되는 것이 `Option<T>`이므로, 바운드로 `Option<T>: Debug`를 원합니다.
// 다르게 하면 잘못된 바운드를 사용하는 셈이 됩니다.
fn print_in_option(self) {
println!("{:?}", Some(self));
}
}
fn main() {
let vec = vec![1, 2, 3];
vec.print_in_option();
}
참고:
New Type 관용구
newtype 관용구는 프로그램에 올바른 타입의 값이 제공됨을 컴파일 타임에 보장해 줍니다.
예를 들어, 나이를 연 단위로 확인하는 나이 검증 함수는 반드시 Years 타입의 값을 받아야 합니다.
struct Years(i64);
struct Days(i64);
impl Years {
pub fn to_days(&self) -> Days {
Days(self.0 * 365)
}
}
impl Days {
/// 부분적인 연도는 버립니다
pub fn to_years(&self) -> Years {
Years(self.0 / 365)
}
}
fn is_adult(age: &Years) -> bool {
age.0 >= 18
}
fn main() {
let age = Years(25);
let age_days = age.to_days();
println!("성인입니까? {}", is_adult(&age));
println!("성인입니까? {}", is_adult(&age_days.to_years()));
// println!("성인입니까? {}", is_adult(&age_days));
}
마지막 출력문의 주석을 해제하여 제공되는 타입이 반드시 Years여야 함을 관찰해 보세요.
newtype의 값을 기본 타입으로 얻으려면, 다음과 같이 튜플 또는 구조 분해 문법을 사용할 수 있습니다:
struct Years(i64);
fn main() {
let years = Years(42);
let years_as_primitive_1: i64 = years.0; // 튜플
let Years(years_as_primitive_2) = years; // 구조 분해
}
참고:
연관 아이템
“연관 아이템(Associated Items)“은 다양한 타입의 아이템에 관련된 규칙 집합을 말합니다. 이는 trait 제네릭의 확장이며, trait가 내부적으로 새로운 아이템을 정의할 수 있게 해줍니다.
그러한 아이템 중 하나가 _연관 타입(associated type)_이며, trait가 컨테이너 타입에 대해 제네릭일 때 더 간단한 사용 패턴을 제공합니다.
참고:
문제점
컨테이너 타입에 대해 제네릭인 trait는 타입 명시 요구사항을 가집니다 - trait 사용자는 모든 제네릭 타입을 반드시 명시해야 합니다.
아래 예제에서 Contains trait는 제네릭 타입 A와 B의 사용을 허용합니다. 그 후 이 트레이트는 Container 타입에 대해 구현되는데, fn difference()와 함께 사용될 수 있도록 A와 B에 대해 i32를 지정합니다.
Contains가 제네릭이기 때문에, 우리는 fn difference()에 대해 모든 제네릭 타입을 명시적으로 나열해야만 합니다. 실제로는 A와 B가 입력 C에 의해 결정된다는 것을 표현하고 싶습니다. 다음 섹션에서 보게 되겠지만, 연관 타입은 정확히 그런 기능을 제공합니다.
struct Container(i32, i32);
// 컨테이너 안에 2개의 아이템이 저장되어 있는지 확인하는 트레이트입니다.
// 또한 첫 번째 또는 마지막 값을 가져옵니다.
trait Contains<A, B> {
fn contains(&self, _: &A, _: &B) -> bool; // 명시적으로 `A`와 `B`를 요구합니다.
fn first(&self) -> i32; // 명시적으로 `A`나 `B`를 요구하지 않습니다.
fn last(&self) -> i32; // 명시적으로 `A`나 `B`를 요구하지 않습니다.
}
impl Contains<i32, i32> for Container {
// 저장된 숫자들이 같으면 참입니다.
fn contains(&self, number_1: &i32, number_2: &i32) -> bool {
(&self.0 == number_1) && (&self.1 == number_2)
}
// 첫 번째 숫자를 가져옵니다.
fn first(&self) -> i32 { self.0 }
// 마지막 숫자를 가져옵니다.
fn last(&self) -> i32 { self.1 }
}
// `C`는 `A`와 `B`를 포함합니다. 이를 고려할 때, `A`와 `B`를
// 다시 표현해야 하는 것은 성가신 일입니다.
fn difference<A, B, C>(container: &C) -> i32 where
C: Contains<A, B> {
container.last() - container.first()
}
fn main() {
let number_1 = 3;
let number_2 = 10;
let container = Container(number_1, number_2);
println!("컨테이너가 {}와 {}를 포함합니까: {}",
&number_1, &number_2,
container.contains(&number_1, &number_2));
println!("첫 번째 숫자: {}", container.first());
println!("마지막 숫자: {}", container.last());
println!("차이는: {}", difference(&container));
}
참고:
연관 타입
“연관 타입“을 사용하면 내부 타입을 트레이트 내의 출력 타입으로 지역적으로 이동시켜 코드의 전반적인 가독성을 향상시킬 수 있습니다. trait 정의를 위한 구문은 다음과 같습니다:
#![allow(unused)]
fn main() {
// `A`와 `B`는 `type` 키워드를 통해 트레이트 내에 정의됩니다.
// (참고: 이 문맥에서의 `type`은 별칭(aliases)에 사용되는 `type`과는 다릅니다).
trait Contains {
type A;
type B;
// 이 새로운 타입들을 제네릭하게 참조하기 위해 업데이트된 구문입니다.
fn contains(&self, _: &Self::A, _: &Self::B) -> bool;
}
}
Contains trait를 사용하는 함수들은 더 이상 A나 B를 표현할 필요가 없다는 점에 유의하세요:
// 연관 타입을 사용하지 않는 경우
fn difference<A, B, C>(container: &C) -> i32 where
C: Contains<A, B> { ... }
// 연관 타입 사용
fn difference<C: Contains>(container: &C) -> i32 { ... }
이전 섹션의 예제를 연관 타입을 사용하여 다시 작성해 봅시다:
struct Container(i32, i32);
// 컨테이너 안에 2개의 아이템이 저장되어 있는지 확인하는 트레이트입니다.
// 또한 첫 번째 또는 마지막 값을 가져옵니다.
trait Contains {
// 여기서 제네릭 타입을 정의하여 메서드들이 활용할 수 있게 합니다.
type A;
type B;
fn contains(&self, _: &Self::A, _: &Self::B) -> bool;
fn first(&self) -> i32;
fn last(&self) -> i32;
}
impl Contains for Container {
// `A`와 `B`가 어떤 타입인지 명시합니다. 만약 `input` 타입이
// `Container(i32, i32)`라면, `output` 타입은 `i32`와 `i32`로
// 결정됩니다.
type A = i32;
type B = i32;
// `&Self::A`와 `&Self::B` 또한 여기서 유효합니다.
fn contains(&self, number_1: &i32, number_2: &i32) -> bool {
(&self.0 == number_1) && (&self.1 == number_2)
}
// 첫 번째 숫자를 가져옵니다.
fn first(&self) -> i32 { self.0 }
// 마지막 숫자를 가져옵니다.
fn last(&self) -> i32 { self.1 }
}
fn difference<C: Contains>(container: &C) -> i32 {
container.last() - container.first()
}
fn main() {
let number_1 = 3;
let number_2 = 10;
let container = Container(number_1, number_2);
println!("컨테이너가 {}와 {}를 포함합니까: {}",
&number_1, &number_2,
container.contains(&number_1, &number_2));
println!("첫 번째 숫자: {}", container.first());
println!("마지막 숫자: {}", container.last());
println!("차이는: {}", difference(&container));
}
팬텀 타입 파라미터
팬텀 타입 파라미터(phantom type parameter)는 런타임에는 나타나지 않지만, 컴파일 타임에만 정적으로 검사되는 파라미터입니다.
데이터 타입은 추가적인 제네릭 타입 파라미터를 사용하여 마커 역할을 하거나 컴파일 타임에 타입 검사를 수행할 수 있습니다. 이러한 추가 파라미터는 저장 값을 가지지 않으며, 런타임 동작도 없습니다.
다음 예제에서는 std::marker::PhantomData와 팬텀 타입 파라미터 개념을 결합하여 서로 다른 데이터 타입을 포함하는 튜플을 생성합니다.
use std::marker::PhantomData;
// 숨겨진 파라미터 `B`를 가지며 `A`에 대해 제네릭인 팬텀 튜플 구조체.
#[derive(PartialEq)] // 이 타입에 대한 동등성 테스트를 허용합니다.
struct PhantomTuple<A, B>(A, PhantomData<B>);
// 숨겨진 파라미터 `B`를 가지며 `A`에 대해 제네릭인 팬텀 타입 구조체.
#[derive(PartialEq)] // 이 타입에 대한 동등성 테스트를 허용합니다.
struct PhantomStruct<A, B> { first: A, phantom: PhantomData<B> }
// 주의: 제네릭 타입 `A`에는 저장 공간이 할당되지만, `B`에는 할당되지 않습니다.
// 따라서 `B`는 연산에 사용될 수 없습니다.
fn main() {
// 여기서 `f32`와 `f64`는 숨겨진 파라미터입니다.
// PhantomTuple 타입이 `<char, f32>`로 지정되었습니다.
let _tuple1: PhantomTuple<char, f32> = PhantomTuple('Q', PhantomData);
// PhantomTuple 타입이 `<char, f64>`로 지정되었습니다.
let _tuple2: PhantomTuple<char, f64> = PhantomTuple('Q', PhantomData);
// 타입이 `<char, f32>`로 지정되었습니다.
let _struct1: PhantomStruct<char, f32> = PhantomStruct {
first: 'Q',
phantom: PhantomData,
};
// 타입이 `<char, f64>`로 지정되었습니다.
let _struct2: PhantomStruct<char, f64> = PhantomStruct {
first: 'Q',
phantom: PhantomData,
};
// 컴파일 타임 에러! 타입 불일치로 인해 비교할 수 없습니다:
// println!("_tuple1 == _tuple2 yields: {}",
// _tuple1 == _tuple2);
// 컴파일 타임 에러! 타입 불일치로 인해 비교할 수 없습니다:
// println!("_struct1 == _struct2 yields: {}",
// _struct1 == _struct2);
}
참고:
테스트케이스: 단위 명확화
팬텀 타입 파라미터로 Add를 구현하여 유용한 단위 변환 방법을 살펴볼 수 있습니다. Add 트레이트는 아래에서 살펴봅니다:
// 이 구조는 다음을 부과합니다: `Self + RHS = Output`
// 구현에서 지정되지 않은 경우 RHS는 기본적으로 Self입니다.
pub trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
// `Output`은 `T<U> + T<U> = T<U>`가 되도록 반드시 `T<U>`여야 합니다.
impl<U> Add for T<U> {
type Output = T<U>;
...
}
전체 구현:
use std::ops::Add;
use std::marker::PhantomData;
/// 단위 타입을 정의하기 위해 빈 열거형을 생성합니다.
#[derive(Debug, Clone, Copy)]
enum Inch {}
#[derive(Debug, Clone, Copy)]
enum Mm {}
/// `Length`는 팬텀 타입 파라미터 `Unit`을 가지는 타입이며,
/// 길이 타입(`f64`)에 대해서는 제네릭이 아닙니다.
///
/// `f64`는 이미 `Clone`과 `Copy` 트레이트를 구현하고 있습니다.
#[derive(Debug, Clone, Copy)]
struct Length<Unit>(f64, PhantomData<Unit>);
/// `Add` 트레이트는 `+` 연산자의 동작을 정의합니다.
impl<Unit> Add for Length<Unit> {
type Output = Length<Unit>;
// add()는 합계를 포함하는 새로운 `Length` 구조체를 반환합니다.
fn add(self, rhs: Length<Unit>) -> Length<Unit> {
// `+`는 `f64`에 대한 `Add` 구현을 호출합니다.
Length(self.0 + rhs.0, PhantomData)
}
}
fn main() {
// `one_foot`가 팬텀 타입 파라미터 `Inch`를 가지도록 지정합니다.
let one_foot: Length<Inch> = Length(12.0, PhantomData);
// `one_meter`는 팬텀 타입 파라미터 `Mm`을 가집니다.
let one_meter: Length<Mm> = Length(1000.0, PhantomData);
// `+`는 우리가 `Length<Unit>`에 대해 구현한 `add()` 메서드를 호출합니다.
//
// `Length`가 `Copy`를 구현하므로, `add()`는 `one_foot`과 `one_meter`를
// 소비(consume)하지 않고 `self`와 `rhs`로 복사합니다.
let two_feet = one_foot + one_foot;
let two_meters = one_meter + one_meter;
// 덧셈이 작동합니다.
println!("1피트 + 1피트 = {:?} 인치", two_feet.0);
println!("1미터 + 1미터 = {:?} mm", two_meters.0);
// 말도 안 되는 연산은 실패합니다:
// 컴파일 타임 에러: 타입 불일치.
// let one_feter = one_foot + one_meter;
}
참고:
빌림(Borrowing) (&), 바운드(Bounds) (X: Y), 열거형(enum), impl & self, 오버로딩(Overloading), ref, 트레이트(Traits) (X for Y), 그리고 튜플 구조체(TupleStructs).
스코프 규칙
스코프(범위)는 소유권(ownership), 빌림(borrowing), 라이프타임(lifetimes)에서 중요한 역할을 합니다. 즉, 스코프는 컴파일러에게 언제 빌림이 유효한지, 언제 자원이 해제될 수 있는지, 언제 변수가 생성되고 파괴되는지를 알려줍니다.
RAII
Rust의 변수는 스택에 데이터를 저장하는 것 이상의 일을 합니다. 예를 들어 Box<T>는 힙 메모리를 소유하는 것처럼 리소스를 _소유(own)_하기도 합니다. Rust는 RAII (자원의 획득은 초기화이다)를 강제하므로, 객체가 스코프를 벗어날 때마다 소멸자가 호출되어 소유한 리소스가 해제됩니다.
이러한 동작은 리소스 누수 버그를 방지하므로, 수동으로 메모리를 해제하거나 메모리 누수에 대해 걱정할 필요가 없습니다! 간단한 예시를 보여드리겠습니다:
// raii.rs
fn create_box() {
// 힙에 정수를 할당합니다
let _box1 = Box::new(3i32);
// `_box1`은 여기서 파괴되고, 메모리가 해제됩니다
}
fn main() {
// 힙에 정수를 할당합니다
let _box2 = Box::new(5i32);
// 중첩된 스코프:
{
// 힙에 정수를 할당합니다
let _box3 = Box::new(4i32);
// `_box3`은 여기서 파괴되고, 메모리가 해제됩니다
}
// 재미 삼아 많은 상자를 만듭니다
// 수동으로 메모리를 해제할 필요가 없습니다!
for _ in 0u32..1_000 {
create_box();
}
// `_box2`는 여기서 파괴되고, 메모리가 해제됩니다
}
물론 valgrind를 사용하여 메모리 에러를 다시 확인할 수 있습니다:
$ rustc raii.rs && valgrind ./raii
==26873== Memcheck, a memory error detector
==26873== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==26873== Using Valgrind-3.9.0 and LibVEX; rerun with -h for copyright info
==26873== Command: ./raii
==26873==
==26873==
==26873== HEAP SUMMARY:
==26873== in use at exit: 0 bytes in 0 blocks
==26873== total heap usage: 1,013 allocs, 1,013 frees, 8,696 bytes allocated
==26873==
==26873== All heap blocks were freed -- no leaks are possible
==26873==
==26873== For counts of detected and suppressed errors, rerun with: -v
==26873== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 2 from 2)
누수가 없습니다!
소멸자
Rust에서 소멸자의 개념은 Drop 트레이트를 통해 제공됩니다. 소멸자는 리소스가 스코프를 벗어날 때 호출됩니다. 모든 타입에 대해 이 트레이트를 구현할 필요는 없으며, 자체적인 소멸자 로직이 필요한 경우에만 구현하면 됩니다.
아래 예제를 실행하여 Drop 트레이트가 어떻게 작동하는지 확인해 보세요. main 함수의 변수가 스코프를 벗어나면 사용자 정의 소멸자가 호출될 것입니다.
struct ToDrop;
impl Drop for ToDrop {
fn drop(&mut self) {
println!("ToDrop이 드롭되고 있습니다");
}
}
fn main() {
let x = ToDrop;
println!("ToDrop을 만들었습니다!");
}
참고:
소유권과 이동
변수는 자신의 리소스를 해제할 책임이 있으므로, 리소스는 오직 하나의 소유자만 가질 수 있습니다. 이는 리소스가 한 번 이상 해제되는 것을 방지합니다. 모든 변수가 리소스를 소유하는 것은 아닙니다(예: 참조).
할당(let x = y)을 하거나 함수 인자를 값으로 전달(foo(x))할 때, 리소스의 _소유권_이 이전됩니다. Rust 용어로는 이를 _이동(move)_이라고 합니다.
리소스를 이동한 후에는 이전 소유자를 더 이상 사용할 수 없습니다. 이는 댕글링 포인터(dangling pointers) 생성을 방지합니다.
// 이 함수는 힙에 할당된 메모리의 소유권을 가져갑니다
fn destroy_box(c: Box<i32>) {
println!("{}를 포함하는 상자를 파괴 중", c);
// `c`가 파괴되고 메모리가 해제됩니다
}
fn main() {
// _스택_에 할당된 정수
let x = 5u32;
// `x`를 `y`로 *복사* - 리소스가 이동되지 않음
let y = x;
// 두 값 모두 독립적으로 사용될 수 있음
println!("x는 {}이고, y는 {}입니다", x, y);
// `a`는 _힙_에 할당된 정수에 대한 포인터입니다
let a = Box::new(5i32);
println!("a가 포함하는 것: {}", a);
// `a`를 `b`로 *이동*
let b = a;
// `a`의 포인터 주소가 (데이터가 아니라) `b`로 복사됩니다.
// 둘 다 이제 동일한 힙 할당 데이터를 가리키지만,
// 이제 `b`가 소유권을 가집니다.
// 에러! `a`는 더 이상 데이터를 소유하고 있지 않으므로 접근할 수 없습니다.
//println!("a가 포함하는 것: {}", a);
// TODO ^ 이 줄의 주석을 해제해 보세요
// 이 함수는 `b`로부터 힙에 할당된 메모리의 소유권을 가져갑니다
destroy_box(b);
// 이 시점에서 힙 메모리가 해제되었으므로, 이 동작은
// 해제된 메모리를 역참조하는 결과를 낳겠지만, 컴파일러에 의해 금지됩니다.
// 에러! 이전 에러와 같은 이유입니다.
//println!("b가 포함하는 것: {}", b);
// TODO ^ 이 줄의 주석을 해제해 보세요
}
가변성
소유권이 이전될 때 데이터의 가변성이 변경될 수 있습니다.
fn main() {
let immutable_box = Box::new(5u32);
println!("불변 상자가 포함하는 것: {}", immutable_box);
// 가변성 에러
//*immutable_box = 4;
// 상자를 *이동*하여 소유권(및 가변성)을 변경합니다
let mut mutable_box = immutable_box;
println!("가변 상자가 포함하는 것: {}", mutable_box);
// 상자의 내용물을 수정합니다
*mutable_box = 4;
println!("가변 상자가 이제 포함하는 것: {}", mutable_box);
}
부분 이동
단일 변수의 구조 분해 내에서 이동에 의한(by-move) 패턴 바인딩과 참조에 의한(by-reference) 패턴 바인딩을 동시에 사용할 수 있습니다. 이렇게 하면 변수의 _부분 이동(partial move)_이 발생하는데, 이는 변수의 일부는 이동하고 다른 부분은 남아있음을 의미합니다. 이런 경우 부모 변수는 전체로서 나중에 사용될 수 없지만, 참조만 되고 이동되지 않은 부분은 여전히 사용될 수 있습니다. Drop 트레이트를 구현한 타입은 부분적으로 이동될 수 없다는 점에 유의하세요. 왜냐하면 해당 타입의 drop 메서드가 나중에 전체로서 사용될 것이기 때문입니다.
fn main() {
#[derive(Debug)]
struct Person {
name: String,
age: Box<u8>,
}
// 에러! `Drop` 트레이트를 구현한 타입에서는 이동해 나올 수 없습니다
//impl Drop for Person {
// fn drop(&mut self) {
// println!("Person 구조체를 드롭합니다 {:?}", self)
// }
//}
// TODO ^ 이 줄들의 주석을 해제해 보세요
let person = Person {
name: String::from("앨리스"),
age: Box::new(20),
};
// `name`은 person에서 밖으로 이동되지만, `age`는 참조됩니다
let Person { name, ref age } = person;
println!("이 사람의 나이는 {}입니다", age);
println!("이 사람의 이름은 {}입니다", name);
// 에러! 부분적으로 이동된 값의 빌림: `person`의 부분 이동이 발생했습니다
//println!("Person 구조체는 {:?}입니다", person);
// `person`은 사용할 수 없지만 `person.age`는 이동되지 않았으므로 사용할 수 있습니다
println!("person 구조체에서 가져온 이 사람의 나이는 {}입니다", person.age);
}
(이 예제에서는 부분 이동을 설명하기 위해 age 변수를 힙에 저장합니다. 위 코드에서 ref를 삭제하면 person.age의 소유권이 age 변수로 이동하기 때문에 에러가 발생합니다. Person.age가 스택에 저장되어 있었다면, age의 정의가 person.age에서 데이터를 이동 없이 복사하므로 ref가 필요하지 않았을 것입니다.)
참고:
빌림
대부분의 경우, 우리는 데이터의 소유권을 가져오지 않고 데이터에 접근하고 싶어 합니다. 이를 달성하기 위해 Rust는 빌림(borrowing) 메커니즘을 사용합니다. 객체를 값(T)으로 전달하는 대신, 참조(&T)로 전달할 수 있습니다.
컴파일러는 (빌림 검사기를 통해) 참조가 항상 유효한 객체를 가리킨다는 것을 정적으로 보장합니다. 즉, 객체에 대한 참조가 존재하는 동안에는 해당 객체를 파괴할 수 없습니다.
// 이 함수는 상자의 소유권을 가져와 파괴합니다
fn eat_box_i32(boxed_i32: Box<i32>) {
println!("{}를 포함하는 상자를 파괴 중", boxed_i32);
}
// 이 함수는 i32를 빌립니다
fn borrow_i32(borrowed_i32: &i32) {
println!("이 정수는: {}", borrowed_i32);
}
fn main() {
// 힙에 boxed i32를 생성하고, 스택에 i32를 생성합니다
// 기억하세요: 숫자는 가독성을 위해 임의의 밑줄을 추가할 수 있습니다
// 5_i32는 5i32와 동일합니다
let boxed_i32 = Box::new(5_i32);
let stacked_i32 = 6_i32;
// 상자의 내용물을 빌립니다. 소유권을 가져가지 않으므로,
// 내용물을 다시 빌릴 수 있습니다.
borrow_i32(&boxed_i32);
borrow_i32(&stacked_i32);
{
// 상자 안에 포함된 데이터에 대한 참조를 가져옵니다
let _ref_to_i32: &i32 = &boxed_i32;
// 에러!
// 내부 값이 스코프의 나중 부분에서 빌려지고 있는 동안에는 `boxed_i32`를 파괴할 수 없습니다.
eat_box_i32(boxed_i32);
// FIXME ^ 이 줄을 주석 처리하세요
// 내부 값이 파괴된 후에 `_ref_to_i32`를 빌리려고 시도합니다
borrow_i32(_ref_to_i32);
// `_ref_to_i32`가 스코프를 벗어나며 더 이상 빌려지지 않습니다.
}
// `boxed_i32`는 이제 `eat_box_i32`에게 소유권을 넘기고 파괴될 수 있습니다
eat_box_i32(boxed_i32);
}
가변성
가변 데이터는 &mut T를 사용하여 가변적으로 빌려올 수 있습니다. 이를 _가변 참조(mutable reference)_라고 하며 빌리는 사람에게 읽기/쓰기 권한을 부여합니다. 반면 &T는 불변 참조를 통해 데이터를 빌려오며, 빌리는 사람은 데이터를 읽을 수는 있지만 수정할 수는 없습니다:
#[allow(dead_code)]
#[derive(Clone, Copy)]
struct Book {
// `&'static str`은 읽기 전용 메모리에 할당된 문자열에 대한 참조입니다
author: &'static str,
title: &'static str,
year: u32,
}
// 이 함수는 책에 대한 참조를 받습니다
fn borrow_book(book: &Book) {
println!("{} - {} 판을 불변으로 빌렸습니다", book.title, book.year);
}
// 이 함수는 가변적인 책에 대한 참조를 받아 `year`를 2014로 변경합니다
fn new_edition(book: &mut Book) {
book.year = 2014;
println!("{} - {} 판을 가변으로 빌렸습니다", book.title, book.year);
}
fn main() {
// `immutabook`이라는 이름의 불변 책을 생성합니다
let immutabook = Book {
// 문자열 리터럴은 `&'static str` 타입을 가집니다
author: "더글라스 호프스태터",
title: "괴델, 에셔, 바흐",
year: 1979,
};
// `immutabook`의 가변 복사본을 생성하고 `mutabook`이라고 부릅니다
let mut mutabook = immutabook;
// 불변 객체를 불변으로 빌립니다
borrow_book(&immutabook);
// 가변 객체를 불변으로 빌립니다
borrow_book(&mutabook);
// 가변 객체를 가변으로 빌립니다
new_edition(&mut mutabook);
// 에러! 불변 객체는 가변으로 빌릴 수 없습니다
new_edition(&mut immutabook);
// FIXME ^ 이 줄을 주석 처리하세요
}
참고:
별칭
데이터는 횟수에 제한 없이 불변으로 빌려질 수 있지만, 불변으로 빌려진 동안에는 원본 데이터를 가변으로 빌릴 수 없습니다. 반면에, 가변 빌림은 한 번에 _하나_만 허용됩니다. 원본 데이터는 가변 참조가 마지막으로 사용된 _후_에만 다시 빌려질 수 있습니다.
struct Point { x: i32, y: i32, z: i32 }
fn main() {
let mut point = Point { x: 0, y: 0, z: 0 };
let borrowed_point = &point;
let another_borrow = &point;
// 데이터는 참조와 원본 소유자를 통해 접근할 수 있습니다
println!("점의 좌표: ({}, {}, {})",
borrowed_point.x, another_borrow.y, point.z);
// 에러! 현재 불변으로 빌려진 상태이기 때문에 `point`를 가변으로 빌릴 수 없습니다.
// let mutable_borrow = &mut point;
// TODO ^ 이 줄의 주석을 해제해 보세요
// 빌려온 값들이 여기서 다시 사용됩니다
println!("점의 좌표: ({}, {}, {})",
borrowed_point.x, another_borrow.y, point.z);
// 불변 참조들이 코드의 나머지 부분에서 더 이상 사용되지 않으므로
// 가변 참조로 다시 빌리는 것이 가능합니다.
let mutable_borrow = &mut point;
// 가변 참조를 통해 데이터를 변경합니다
mutable_borrow.x = 5;
mutable_borrow.y = 2;
mutable_borrow.z = 1;
// 에러! 현재 가변으로 빌려진 상태이기 때문에 `point`를 불변으로 빌릴 수 없습니다.
// let y = &point.y;
// TODO ^ 이 줄의 주석을 해제해 보세요
// 에러! `println!`은 불변 참조를 받기 때문에 출력할 수 없습니다.
// println!("점의 Z 좌표는 {}입니다", point.z);
// TODO ^ 이 줄의 주석을 해제해 보세요
// 좋습니다! 가변 참조는 불변 참조로 `println!`에 전달될 수 있습니다
println!("점의 좌표: ({}, {}, {})",
mutable_borrow.x, mutable_borrow.y, mutable_borrow.z);
// 가변 참조가 코드의 나머지 부분에서 더 이상 사용되지 않으므로
// 다시 빌리는 것이 가능합니다
let new_borrowed_point = &point;
println!("점은 이제 다음 좌표를 가집니다: ({}, {}, {})",
new_borrowed_point.x, new_borrowed_point.y, new_borrowed_point.z);
}
ref 패턴
let 바인딩을 통해 패턴 매칭이나 구조 분해를 수행할 때, ref 키워드를 사용하여 구조체/튜플 필드에 대한 참조를 가져올 수 있습니다. 아래 예제는 이것이 유용할 수 있는 몇 가지 사례를 보여줍니다:
#[derive(Clone, Copy)]
struct Point { x: i32, y: i32 }
fn main() {
let c = 'Q';
// 할당의 왼쪽 편에 있는 `ref` 빌림은
// 오른쪽 편에 있는 `&` 빌림과 동일합니다.
let ref ref_c1 = c;
let ref_c2 = &c;
println!("ref_c1은 ref_c2와 같습니다: {}", *ref_c1 == *ref_c2);
let point = Point { x: 0, y: 0 };
// `ref`는 구조체를 구조 분해할 때도 유효합니다.
let _copy_of_x = {
// `ref_to_x`는 `point`의 `x` 필드에 대한 참조입니다.
let Point { x: ref ref_to_x, y: _ } = point;
// `point`의 `x` 필드 복사본을 반환합니다.
*ref_to_x
};
// `point`의 가변 복사본
let mut mutable_point = point;
{
// `ref`는 `mut`와 짝을 이루어 가변 참조를 가져올 수 있습니다.
let Point { x: _, y: ref mut mut_ref_to_y } = mutable_point;
// 가변 참조를 통해 `mutable_point`의 `y` 필드를 변경합니다.
*mut_ref_to_y = 1;
}
println!("point는 ({}, {})입니다", point.x, point.y);
println!("mutable_point는 ({}, {})입니다", mutable_point.x, mutable_point.y);
// 포인터를 포함하는 가변 튜플
let mut mutable_tuple = (Box::new(5u32), 3u32);
{
// `mutable_tuple`을 구조 분해하여 `last` 값을 변경합니다.
let (_, ref mut last) = mutable_tuple;
*last = 2u32;
}
println!("튜플은 {:?}입니다", mutable_tuple);
}
라이프타임
_라이프타임(lifetime)_은 컴파일러(더 구체적으로는 빌림 검사기)가 모든 빌림이 유효한지 확인하기 위해 사용하는 구성 요소입니다. 구체적으로, 변수의 라이프타임은 생성될 때 시작하고 파괴될 때 끝납니다. 라이프타임과 스코프는 종종 함께 언급되지만, 같은 것은 아닙니다.
예를 들어, &를 통해 변수를 빌리는 경우를 생각해 봅시다. 빌림은 선언된 위치에 따라 결정되는 라이프타임을 가집니다. 결과적으로 빌림은 빌려준 대상이 파괴되기 전에 끝나는 한 유효합니다. 하지만 빌림의 스코프는 참조가 사용되는 위치에 따라 결정됩니다.
다음 예제와 이 섹션의 나머지 부분에서 라이프타임이 스코프와 어떻게 관련되어 있는지, 그리고 두 가지가 어떻게 다른지 살펴보겠습니다.
// 아래에는 각 변수의 생성과 파괴를 나타내는 선으로 라이프타임이 주석 처리되어 있습니다.
// `i`의 스코프가 `borrow1`과 `borrow2`를 완전히 감싸고 있기 때문에
// `i`는 가장 긴 라이프타임을 가집니다. `borrow1`과 `borrow2`는 서로 겹치지 않으므로
// 둘의 기간 비교는 무의미합니다.
fn main() {
let i = 3; // `i`의 라이프타임 시작. ──────────────────┐
// │
{ // │
let borrow1 = &i; // `borrow1` 라이프타임 시작. ──┐│
// ││
println!("borrow1: {}", borrow1); // ││
} // `borrow1` 끝. ───────────────────────────────────┘│
// │
// │
{ // │
let borrow2 = &i; // `borrow2` 라이프타임 시작. ──┐│
// ││
println!("borrow2: {}", borrow2); // ││
} // `borrow2` 끝. ───────────────────────────────────┘│
// │
} // 라이프타임 끝. ─────────────────────────────────────┘
레이블 라이프타임에는 이름이나 타입이 할당되지 않는다는 점에 유의하세요. 이는 앞으로 보게 될 것처럼 라이프타임을 사용하는 방식에 제한을 줍니다.
명시적 어노테이션
빌림 검사기는 참조가 얼마나 오래 유효해야 하는지 결정하기 위해 명시적 라이프타임 어노테이션을 사용합니다. 라이프타임이 생략(elided)1되지 않는 경우, Rust는 참조의 라이프타임이 어떻게 되어야 하는지 결정하기 위해 명시적인 어노테이션을 요구합니다. 라이프타임을 명시적으로 어노테이션하는 구문은 다음과 같이 아포스트로피 문자를 사용합니다:
foo<'a>
// `foo`는 라이프타임 파라미터 `'a`를 가집니다
클로저와 유사하게, 라이프타임을 사용하려면 제네릭이 필요합니다. 또한, 이 라이프타임 구문은 foo의 라이프타임이 'a의 라이프타임을 초과할 수 없음을 나타냅니다. 타입의 명시적 어노테이션은 &'a T 형식을 가지며, 여기서 'a는 이미 도입된 라이프타임입니다.
여러 라이프타임이 있는 경우에도 구문은 유사합니다:
foo<'a, 'b>
// `foo`는 라이프타임 파라미터 `'a`와 `'b`를 가집니다
이 경우, foo의 라이프타임은 'a 또는 'b 중 어느 하나의 라이프타임도 초과할 수 없습니다.
명시적 라이프타임 어노테이션이 사용된 다음 예제를 참조하세요:
// `print_refs`는 서로 다른 라이프타임 `'a`와 `'b`를 가진
// 두 개의 `i32` 참조를 받습니다. 이 두 라이프타임은 모두
// 적어도 `print_refs` 함수만큼 길어야 합니다.
fn print_refs<'a, 'b>(x: &'a i32, y: &'b i32) {
println!("x는 {}이고 y는 {}입니다", x, y);
}
// 인자를 받지 않지만 라이프타임 파라미터 `'a`를 가진 함수입니다.
fn failed_borrow<'a>() {
let _x = 12;
// 에러: `_x`의 수명이 충분히 길지 않습니다
let _y: &'a i32 = &_x;
// 함수 내부에서 라이프타임 `'a`를 명시적 타입 어노테이션으로 사용하려고 하면
// 실패합니다. `&_x`의 라이프타임이 `_y`의 라이프타임보다 짧기 때문입니다.
// 짧은 라이프타임은 더 긴 라이프타임으로 강제 변환될 수 없습니다.
}
fn main() {
// 아래에서 빌려올 변수들을 생성합니다.
let (four, nine) = (4, 9);
// 두 변수의 빌림(`&`)이 함수로 전달됩니다.
print_refs(&four, &nine);
// 빌려온 모든 입력은 빌리는 주체보다 오래 살아야 합니다.
// 다시 말해, `four`와 `nine`의 라이프타임은
// `print_refs`의 라이프타임보다 길어야 합니다.
failed_borrow();
// `failed_borrow`는 `'a`가 함수의 라이프타임보다 길어야 함을 강제하는
// 참조를 포함하지 않지만, `'a`는 더 깁니다.
// 라이프타임이 결코 제약되지 않으므로, 이는 `'static`으로 기본 설정됩니다.
}
참고:
-
생략(elision)은 암시적으로 라이프타임을 어노테이션하므로 다릅니다. ↩
함수
생략을 무시하고, 라이프타임을 가진 함수 시그니처에는 몇 가지 제약 사항이 있습니다:
- 모든 참조는 반드시 어노테이션된 라이프타임을 가져야 합니다.
- 반환되는 모든 참조는 입력과 동일한 라이프타임을 가지거나
static이어야 합니다.
또한, 입력 없이 참조를 반환하는 것이 유효하지 않은 데이터에 대한 참조를 반환하는 결과를 낳는다면 금지된다는 점에 유의하세요. 다음 예제는 라이프타임이 있는 유효한 함수 형태를 보여줍니다:
// 라이프타임 `'a`를 가진 하나의 입력 참조입니다.
// 이 참조는 적어도 함수만큼 오래 살아야 합니다.
fn print_one<'a>(x: &'a i32) {
println!("`print_one`: x는 {}입니다", x);
}
// 가변 참조도 라이프타임과 함께 사용할 수 있습니다.
fn add_one<'a>(x: &'a mut i32) {
*x += 1;
}
// 서로 다른 라이프타임을 가진 여러 요소들입니다. 이 경우,
// 둘 다 동일한 라이프타임 `'a`를 가져도 괜찮지만,
// 더 복잡한 경우에는 서로 다른 라이프타임이 필요할 수 있습니다.
fn print_multi<'a, 'b>(x: &'a i32, y: &'b i32) {
println!("`print_multi`: x는 {}, y는 {}입니다", x, y);
}
// 전달된 참조를 반환하는 것은 허용됩니다.
// 하지만, 올바른 라이프타임이 반환되어야 합니다.
fn pass_x<'a, 'b>(x: &'a i32, _: &'b i32) -> &'a i32 { x }
//fn invalid_output<'a>() -> &'a String { &String::from("foo") }
// 위 코드는 유효하지 않습니다: `'a`는 함수보다 오래 살아야 합니다.
// 여기서 `&String::from("foo")`는 `String`을 생성한 후 참조를 생성합니다.
// 그런 다음 스코프를 벗어날 때 데이터가 드롭되어,
// 유효하지 않은 데이터에 대한 참조가 반환됩니다.
fn main() {
let x = 7;
let y = 9;
print_one(&x);
print_multi(&x, &y);
let z = pass_x(&x, &y);
print_one(z);
let mut t = 3;
add_one(&mut t);
print_one(&t);
}
참고:
메서드
메서드는 함수와 유사하게 어노테이션됩니다:
struct Owner(i32);
impl Owner {
// 독립형 함수처럼 라이프타임을 어노테이션합니다.
fn add_one<'a>(&'a mut self) { self.0 += 1; }
fn print<'a>(&'a self) {
println!("`print`: {}", self.0);
}
}
fn main() {
let mut owner = Owner(18);
owner.add_one();
owner.print();
}
참고:
구조체
구조체에서의 라이프타임 어노테이션도 함수와 비슷합니다:
// `i32`에 대한 참조를 보관하는 `Borrowed` 타입입니다.
// `i32`에 대한 참조는 `Borrowed`보다 오래 살아야 합니다.
#[derive(Debug)]
struct Borrowed<'a>(&'a i32);
// 마찬가지로, 여기의 두 참조는 이 구조체보다 오래 살아야 합니다.
#[derive(Debug)]
struct NamedBorrowed<'a> {
x: &'a i32,
y: &'a i32,
}
// `i32`이거나 이에 대한 참조인 열거형입니다.
#[derive(Debug)]
enum Either<'a> {
Num(i32),
Ref(&'a i32),
}
fn main() {
let x = 18;
let y = 15;
let single = Borrowed(&x);
let double = NamedBorrowed { x: &x, y: &y };
let reference = Either::Ref(&x);
let number = Either::Num(y);
println!("x는 {:?}에서 빌려졌습니다", single);
println!("x와 y는 {:?}에서 빌려졌습니다", double);
println!("x는 {:?}에서 빌려졌습니다", reference);
println!("y는 {:?}에서 빌려지지 *않았습니다*", number);
}
참고:
트레이트
트레이트 메서드의 라이프타임 어노테이션은 기본적으로 함수와 유사합니다. impl도 라이프타임 어노테이션을 가질 수 있음에 유의하세요.
// 라이프타임 어노테이션이 있는 구조체.
#[derive(Debug)]
struct Borrowed<'a> {
x: &'a i32,
}
// impl에 라이프타임을 어노테이션합니다.
impl<'a> Default for Borrowed<'a> {
fn default() -> Self {
Self {
x: &10,
}
}
}
fn main() {
let b: Borrowed = Default::default();
println!("b는 {:?}입니다", b);
}
참고:
바운드
제네릭 타입이 바운드될 수 있는 것처럼, (그 자체로 제네릭인) 라이프타임도 바운드를 사용합니다. 여기서 : 문자는 약간 다른 의미를 갖지만, +는 동일합니다. 다음이 어떻게 읽히는지 주목하세요:
T: 'a:T내의 모든 참조는 라이프타임'a보다 오래 살아야 합니다.T: Trait + 'a: 타입T는 트레이트Trait를 구현해야 하며,T내의 모든 참조는'a보다 오래 살아야 합니다.
아래 예제는 where 키워드 뒤에 사용된 위 구문의 실제 사용 예를 보여줍니다:
use std::fmt::Debug; // 바운드할 트레이트.
#[derive(Debug)]
struct Ref<'a, T: 'a>(&'a T);
// `Ref`는 `Ref`가 알 수 없는 어떤 라이프타임 `'a`를 가진 제네릭 타입 `T`에 대한
// 참조를 포함합니다. `T`는 `T` 내부의 모든 *참조*가 `'a`보다 오래 살아야 한다는
// 바운드를 가집니다. 추가적으로, `Ref`의 라이프타임은 `'a`를 초과할 수 없습니다.
// `Debug` 트레이트를 사용하여 출력하는 제네릭 함수.
fn print<T>(t: T) where
T: Debug {
println!("`print`: t는 {:?}입니다", t);
}
// 여기서 `T`는 `Debug`를 구현하고 `T`의 모든 *참조*가 `'a`보다 오래 사는
// `T`에 대한 참조를 받습니다. 또한, `'a`는 함수보다 오래 살아야 합니다.
fn print_ref<'a, T>(t: &'a T) where
T: Debug + 'a {
println!("`print_ref`: t는 {:?}입니다", t);
}
fn main() {
let x = 7;
let ref_x = Ref(&x);
print_ref(&ref_x);
print(ref_x);
}
참고:
제네릭, 제네릭의 바운드, 그리고 제네릭의 다중 바운드
강제
더 긴 라이프타임은 평소에는 작동하지 않을 스코프 내에서도 작동하도록 더 짧은 라이프타임으로 강제 변환(coerced)될 수 있습니다. 이는 Rust 컴파일러에 의한 추론된 강제 변환 형태와 라이프타임 차이를 선언하는 형태로 나타납니다:
// 여기서 Rust는 가능한 한 짧은 라이프타임을 추론합니다.
// 그 후 두 참조는 해당 라이프타임으로 강제 변환됩니다.
fn multiply<'a>(first: &'a i32, second: &'a i32) -> i32 {
first * second
}
// `<'a: 'b, 'b>`는 라이프타임 `'a`가 적어도 `'b`만큼 길다는 뜻으로 읽힙니다.
// 여기서 우리는 `&'a i32`를 받아 강제 변환의 결과로 `&'b i32`를 반환합니다.
fn choose_first<'a: 'b, 'b>(first: &'a i32, _: &'b i32) -> &'b i32 {
first
}
fn main() {
let first = 2; // 더 긴 라이프타임
{
let second = 3; // 더 짧은 라이프타임
println!("곱은 {}입니다", multiply(&first, &second));
println!("{}은(는) 첫 번째입니다", choose_first(&first, &second));
};
}
Static
Rust에는 몇 가지 예약된 라이프타임 이름이 있습니다. 그중 하나가 'static입니다. 두 가지 상황에서 이를 마주칠 수 있습니다:
// 'static 라이프타임을 가진 참조:
let s: &'static str = "안녕 세상";
// 트레이트 바운드의 일부로서의 'static:
fn generic<T>(x: T) where T: 'static {}
두 가지는 관련이 있지만 미묘하게 다르며, 이는 Rust를 배울 때 흔한 혼란의 원인이 됩니다. 각 상황에 대한 몇 가지 예시를 소개합니다:
참조 라이프타임
참조 라이프타임으로서 'static은 참조가 가리키는 데이터가 실행 중인 프로그램의 남은 수명 동안 살아있음을 나타냅니다. 여전히 더 짧은 라이프타임으로 강제 변환될 수 있습니다.
'static 라이프타임을 가진 변수를 만드는 두 가지 일반적인 방법이 있으며, 둘 다 바이너리의 읽기 전용 메모리에 저장됩니다:
static선언으로 상수를 만듭니다.&'static str타입을 갖는문자열리터럴을 만듭니다.
각 방법에 대한 예제는 다음을 참조하세요:
// `'static` 라이프타임을 가진 상수를 만듭니다.
static NUM: i32 = 18;
// `NUM`에 대한 참조를 반환합니다. 여기서 `NUM`의 `'static`
// 라이프타임은 입력 인자의 라이프타임으로 강제 변환됩니다.
fn coerce_static<'a>(_: &'a i32) -> &'a i32 {
&NUM
}
fn main() {
{
// `문자열` 리터럴을 만들고 출력합니다:
let static_string = "저는 읽기 전용 메모리에 있습니다";
println!("static_string: {}", static_string);
// `static_string`이 스코프를 벗어나면, 참조는
// 더 이상 사용할 수 없지만, 데이터는 바이너리에 남아 있습니다.
}
{
// `coerce_static`에 사용할 정수를 만듭니다:
let lifetime_num = 9;
// `NUM`을 `lifetime_num`의 라이프타임으로 강제 변환합니다:
let coerced_static = coerce_static(&lifetime_num);
println!("강제 변환된 static: {}", coerced_static);
}
println!("NUM: {} 여전히 접근 가능합니다!", NUM);
}
'static 참조는 프로그램 수명의 나머지 기간 동안만 유효하면 되므로, 프로그램이 실행되는 동안 생성될 수 있습니다. 아래 예제는 Box::leak를 사용하여 'static 참조를 동적으로 생성하는 방법을 보여줍니다. 이 경우 전체 기간 동안 살아있는 것은 아니지만, 누수 지점부터는 계속 살아있습니다.
extern crate rand;
use rand::Fill;
fn random_vec() -> &'static [u64; 100] {
let mut rng = rand::rng();
let mut boxed = Box::new([0; 100]);
boxed.fill(&mut rng);
Box::leak(boxed)
}
fn main() {
let first: &'static [u64; 100] = random_vec();
let second: &'static [u64; 100] = random_vec();
assert_ne!(first, second)
}
트레이트 바운드
트레이트 바운드로서, 이는 해당 타입이 정적이지 않은(non-static) 참조를 포함하지 않음을 의미합니다. 예를 들어, 수신자는 원하는 만큼 해당 타입을 보유할 수 있으며, 드롭할 때까지 절대 유효하지 않게 되지 않습니다.
이것이 의미하는 바를 이해하는 것이 중요합니다. 모든 소유된 데이터는 항상 'static 라이프타임 바운드를 통과하지만, 소유된 데이터에 대한 참조는 일반적으로 그렇지 않습니다:
use std::fmt::Debug;
fn print_it(input: impl Debug + 'static) {
println!("'static 값이 전달되었습니다: {:?}", input);
}
fn main() {
// i는 소유된 데이터이며 참조를 포함하지 않으므로 'static입니다:
let i = 5;
print_it(i);
// 이런, &i는 main()의 스코프에 의해 정의된 라이프타임만
// 가지므로 'static이 아닙니다:
print_it(&i);
}
컴파일러는 다음과 같이 알려줄 것입니다:
error[E0597]: `i` does not live long enough
--> src/lib.rs:15:15
|
15 | print_it(&i);
| ---------^^--
| | |
| | borrowed value does not live long enough
| argument requires that `i` is borrowed for `'static`
16 | }
| - `i` dropped here while still borrowed
참고:
생략
일부 라이프타임 패턴은 압도적으로 흔해서, 빌림 검사기는 타이핑을 줄이고 가독성을 높이기 위해 이를 생략할 수 있게 해줍니다. 이를 생략(elision)이라고 합니다. 생략은 오직 이러한 패턴이 흔하다는 이유만으로 Rust에 존재합니다.
다음 코드는 생략의 몇 가지 예시를 보여줍니다. 생략에 대한 더 포괄적인 설명은 책의 라이프타임 생략을 참조하세요.
// `elided_input`과 `annotated_input`은 본질적으로 동일한 시그니처를 가집니다.
// `elided_input`의 라이프타임이 컴파일러에 의해 추론되기 때문입니다:
fn elided_input(x: &i32) {
println!("`elided_input`: {}", x);
}
fn annotated_input<'a>(x: &'a i32) {
println!("`annotated_input`: {}", x);
}
// 마찬가지로, `elided_pass`와 `annotated_pass`는 동일한 시그니처를 가집니다.
// 라이프타임이 `elided_pass`에 암시적으로 추가되기 때문입니다:
fn elided_pass(x: &i32) -> &i32 { x }
fn annotated_pass<'a>(x: &'a i32) -> &'a i32 { x }
fn main() {
let x = 3;
elided_input(&x);
annotated_input(&x);
println!("`elided_pass`: {}", elided_pass(&x));
println!("`annotated_pass`: {}", annotated_pass(&x));
}
참고:
트레이트
trait는 알 수 없는 타입 Self에 대해 정의된 메서드들의 모음입니다. 이들은 동일한 트레이트 내에 선언된 다른 메서드에 접근할 수 있습니다.
트레이트는 모든 데이터 타입에 대해 구현될 수 있습니다. 아래 예제에서는 메서드 그룹인 Animal을 정의합니다. 그런 다음 Animal trait를 Sheep 데이터 타입에 대해 구현하여 Sheep으로 Animal의 메서드를 사용할 수 있게 합니다.
struct Sheep { naked: bool, name: &'static str }
trait Animal {
// 연관 함수 시그니처; `Self`는 구현하는 타입을 가리킵니다.
fn new(name: &'static str) -> Self;
// 메서드 시그니처; 이들은 문자열을 반환할 것입니다.
fn name(&self) -> &'static str;
fn noise(&self) -> &'static str;
// 트레이트는 기본 메서드 정의를 제공할 수 있습니다.
fn talk(&self) {
println!("{}가 말하길 {}", self.name(), self.noise());
}
}
impl Sheep {
fn is_naked(&self) -> bool {
self.naked
}
fn shear(&mut self) {
if self.is_naked() {
// 구현자의 메서드는 구현자의 트레이트 메서드를 사용할 수 있습니다.
println!("{}는 이미 벌거벗었습니다...", self.name());
} else {
println!("{}가 털을 깎습니다!", self.name);
self.naked = true;
}
}
}
// `Sheep`에 대해 `Animal` 트레이트를 구현합니다.
impl Animal for Sheep {
// `Self`는 구현하는 타입인 `Sheep`입니다.
fn new(name: &'static str) -> Sheep {
Sheep { name: name, naked: false }
}
fn name(&self) -> &'static str {
self.name
}
fn noise(&self) -> &'static str {
if self.is_naked() {
"메에에에?"
} else {
"메에에에!"
}
}
// 기본 트레이트 메서드는 재정의(오버라이드)될 수 있습니다.
fn talk(&self) {
// 예를 들어, 조용한 사색을 추가할 수 있습니다.
println!("{}가 잠시 멈춥니다... {}", self.name, self.noise());
}
}
fn main() {
// 이 경우에는 타입 어노테이션이 필요합니다.
let mut dolly: Sheep = Animal::new("돌리");
// TODO ^ 타입 어노테이션을 제거해 보세요.
dolly.talk();
dolly.shear();
dolly.talk();
}
Derive
컴파일러는 #[derive] 속성을 통해 일부 트레이트에 대한 기본적인 구현을 제공할 수 있습니다. 더 복잡한 동작이 필요한 경우에는 이러한 트레이트들을 수동으로 구현할 수도 있습니다.
다음은 derive 가능한 트레이트 목록입니다:
- 비교 트레이트:
Eq,PartialEq,Ord,PartialOrd. Clone, 복사를 통해&T로부터T를 생성합니다.Copy, 타입에 ‘이동 의미론(move semantics)’ 대신 ’복사 의미론(copy semantics)’을 부여합니다.Hash,&T로부터 해시를 계산합니다.Default, 데이터 타입의 빈 인스턴스를 생성합니다.Debug,{:?}포맷터를 사용하여 값을 형식화합니다.
// `Centimeters`, 비교 가능한 튜플 구조체
#[derive(PartialEq, PartialOrd)]
struct Centimeters(f64);
// `Inches`, 출력 가능한 튜플 구조체
#[derive(Debug)]
struct Inches(i32);
impl Inches {
fn to_centimeters(&self) -> Centimeters {
let &Inches(inches) = self;
Centimeters(inches as f64 * 2.54)
}
}
// `Seconds`, 추가 속성이 없는 튜플 구조체
struct Seconds(i32);
fn main() {
let _one_second = Seconds(1);
// 에러: `Seconds`는 출력될 수 없습니다. `Debug` 트레이트를 구현하지 않았기 때문입니다.
//println!("One second looks like: {:?}", _one_second);
// TODO ^ 이 줄의 주석을 해제해 보세요.
// 에러: `Seconds`는 비교될 수 없습니다. `PartialEq` 트레이트를 구현하지 않았기 때문입니다.
//let _this_is_true = (_one_second == _one_second);
// TODO ^ 이 줄의 주석을 해제해 보세요.
let foot = Inches(12);
println!("1피트는 {:?}와 같습니다.", foot);
let meter = Centimeters(100.0);
let cmp =
if foot.to_centimeters() < meter {
"더 작습니다"
} else {
"더 큽니다"
};
println!("1피트는 1미터보다 {}.", cmp);
}
참고:
dyn으로 트레이트 반환하기
Rust 컴파일러는 모든 함수의 반환 타입이 얼마나 많은 공간을 필요로 하는지 알아야 합니다. 이는 여러분의 모든 함수가 구체적인 타입을 반환해야 함을 의미합니다. 다른 언어와 달리, Animal과 같은 트레이트가 있을 때 Animal을 반환하는 함수를 작성할 수 없는데, 그 이유는 서로 다른 구현체들이 서로 다른 양의 메모리를 필요로 하기 때문입니다.
하지만 쉬운 해결 방법이 있습니다. 트레이트 객체를 직접 반환하는 대신, 우리 함수는 어떤 Animal을 포함하는 Box를 반환합니다. Box는 단지 힙 메모리에 대한 참조일 뿐입니다. 참조는 정적으로 알려진 크기를 가지며, 컴파일러는 그것이 힙에 할당된 Animal을 가리키고 있음을 보장할 수 있으므로, 우리 함수에서 트레이트를 반환할 수 있게 됩니다!
Rust는 힙에 메모리를 할당할 때마다 가능한 한 명시적이려고 노력합니다. 따라서 여러분의 함수가 이런 방식으로 힙상의 트레이트에 대한 포인터를 반환한다면, 반환 타입에 dyn 키워드를 사용해야 합니다. 예: Box<dyn Animal>.
struct Sheep {}
struct Cow {}
trait Animal {
// 인스턴스 메서드 시그니처
fn noise(&self) -> &'static str;
}
// `Sheep`에 대해 `Animal` 트레이트를 구현합니다.
impl Animal for Sheep {
fn noise(&self) -> &'static str {
"메에에에!"
}
}
// `Cow`에 대해 `Animal` 트레이트를 구현합니다.
impl Animal for Cow {
fn noise(&self) -> &'static str {
"음메~!"
}
}
// `Animal`을 구현하는 어떤 구조체를 반환하지만, 컴파일 타임에는 그것이 무엇인지 알 수 없습니다.
fn random_animal(random_number: f64) -> Box<dyn Animal> {
if random_number < 0.5 {
Box::new(Sheep {})
} else {
Box::new(Cow {})
}
}
fn main() {
let random_number = 0.234;
let animal = random_animal(random_number);
println!("동물을 무작위로 선택했고, 그 동물은 {}라고 말합니다.", animal.noise());
}
연산자 오버로딩
Rust에서 많은 연산자들은 트레이트를 통해 오버로딩될 수 있습니다. 즉, 일부 연산자들은 입력 인자에 따라 서로 다른 작업을 수행하는 데 사용될 수 있습니다. 이는 연산자가 메서드 호출의 구문 설탕(syntactic sugar)이기 때문에 가능합니다. 예를 들어, a + b에서의 + 연산자는 (a.add(b)와 같이) add 메서드를 호출합니다. 이 add 메서드는 Add 트레이트의 일부입니다. 따라서 + 연산자는 Add 트레이트의 모든 구현체에서 사용될 수 있습니다.
연산자를 오버로딩하는 Add와 같은 트레이트들의 목록은 core::ops에서 찾을 수 있습니다.
use std::ops;
struct Foo;
struct Bar;
#[derive(Debug)]
struct FooBar;
#[derive(Debug)]
struct BarFoo;
// `std::ops::Add` 트레이트는 `+`의 기능을 지정하는 데 사용됩니다.
// 여기서 우리는 `Add<Bar>`를 만듭니다 - 이는 RHS(우항) 타입이 `Bar`인 덧셈을 위한 트레이트입니다.
// 다음 블록은 Foo + Bar = FooBar 연산을 구현합니다.
impl ops::Add<Bar> for Foo {
type Output = FooBar;
fn add(self, _rhs: Bar) -> FooBar {
println!("> Foo.add(Bar)가 호출되었습니다");
FooBar
}
}
// 타입을 반대로 함으로써, 우리는 비가환(non-commutative) 덧셈을 구현하게 됩니다.
// 여기서 우리는 `Add<Foo>`를 만듭니다 - 이는 RHS 타입이 `Foo`인 덧셈을 위한 트레이트입니다.
// 이 블록은 Bar + Foo = BarFoo 연산을 구현합니다.
impl ops::Add<Foo> for Bar {
type Output = BarFoo;
fn add(self, _rhs: Foo) -> BarFoo {
println!("> Bar.add(Foo)가 호출되었습니다");
BarFoo
}
}
fn main() {
println!("Foo + Bar = {:?}", Foo + Bar);
println!("Bar + Foo = {:?}", Bar + Foo);
}
참고
Drop
Drop 트레이트는 drop이라는 하나의 메서드만을 가집니다. 이 메서드는 객체가 스코프를 벗어날 때 자동으로 호출됩니다. Drop 트레이트의 주요 용도는 구현체 인스턴스가 소유한 리소스를 해제하는 것입니다.
Box, Vec, String, File, 그리고 Process는 리소스를 해제하기 위해 Drop 트레이트를 구현하는 몇 가지 타입의 예입니다. Drop 트레이트는 임의의 사용자 정의 데이터 타입에 대해 수동으로 구현될 수도 있습니다.
다음 예제는 drop 함수가 호출될 때 알림을 주기 위해 콘솔 출력을 추가합니다.
struct Droppable {
name: &'static str,
}
// 이 간단한 `drop` 구현은 콘솔에 출력을 추가합니다.
impl Drop for Droppable {
fn drop(&mut self) {
println!("> {}를 해제합니다", self.name);
}
}
fn main() {
let _a = Droppable { name: "a" };
// 블록 A
{
let _b = Droppable { name: "b" };
// 블록 B
{
let _c = Droppable { name: "c" };
let _d = Droppable { name: "d" };
println!("블록 B를 나가는 중");
}
println!("방금 블록 B를 나갔습니다");
println!("블록 A를 나가는 중");
}
println!("방금 블록 A를 나갔습니다");
// 변수는 `drop` 함수를 사용하여 수동으로 해제할 수 있습니다.
drop(_a);
// TODO ^ 이 줄을 주석 처리해 보세요.
println!("메인 함수 끝");
// `_a`는 여기서 다시 `drop`되지 않습니다. 이미 (수동으로) `drop`되었기 때문입니다.
}
더 실용적인 예로, Drop 트레이트를 사용하여 더 이상 필요하지 않은 임시 파일을 자동으로 정리하는 방법은 다음과 같습니다:
use std::fs::File;
use std::path::PathBuf;
struct TempFile {
file: File,
path: PathBuf,
}
impl TempFile {
fn new(path: PathBuf) -> std::io::Result<Self> {
// 참고: `File::create()`는 기존 파일을 덮어씁니다.
let file = File::create(&path)?;
Ok(Self { file, path })
}
}
// `TempFile`이 drop될 때:
// 1. 먼저, 우리가 작성한 drop 구현이 파일 시스템에서 파일 이름을 제거합니다.
// 2. 그런 다음, `File`의 drop이 파일을 닫고 디스크에서 기본 콘텐츠를 제거합니다.
impl Drop for TempFile {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.path) {
eprintln!("임시 파일을 제거하는 데 실패했습니다: {}", e);
}
println!("> 임시 파일을 해제했습니다: {:?}", self.path);
// `File`의 drop은 이 구조체의 필드이므로 여기서 암시적으로 호출됩니다.
}
}
fn main() -> std::io::Result<()> {
// drop 동작을 시연하기 위해 새 스코프를 생성합니다.
{
let temp = TempFile::new("test.txt".into())?;
println!("임시 파일이 생성되었습니다");
// `temp`가 스코프를 벗어날 때 파일이 자동으로 정리됩니다.
}
println!("스코프 종료 - 파일이 정리되어야 합니다");
// 필요하다면 수동으로 drop할 수도 있습니다.
let temp2 = TempFile::new("another_test.txt".into())?;
drop(temp2); // 파일을 명시적으로 drop합니다.
println!("수동으로 파일을 drop했습니다");
Ok(())
}
이터레이터
Iterator 트레이트는 배열과 같은 컬렉션에 대한 이터레이터를 구현하는 데 사용됩니다.
이 트레이트는 next 요소에 대해 정의될 메서드 하나만을 필요로 하며, 이는 impl 블록에서 수동으로 정의하거나 (배열 및 범위와 같이) 자동으로 정의될 수 있습니다.
일반적인 상황에서의 편의를 위해, for 구문은 .into_iter() 메서드를 사용하여 일부 컬렉션을 이터레이터로 변환합니다.
struct Fibonacci {
curr: u32,
next: u32,
}
// `Fibonacci`에 대해 `Iterator`를 구현합니다.
// `Iterator` 트레이트는 `next` 요소에 대해 정의될 메서드 하나와,
// 이터레이터의 반환 타입을 선언하기 위한 `연관 타입(associated type)`만을 필요로 합니다.
impl Iterator for Fibonacci {
// `Self::Item`을 사용하여 이 타입을 참조할 수 있습니다.
type Item = u32;
// 여기서리는 `.curr`와 `.next`를 사용하여 수열을 정의합니다.
// 반환 타입은 `Option<T>`입니다:
// * 이터레이터가 끝나면 `None`이 반환됩니다.
// * 그렇지 않으면 다음 값이 `Some`으로 감싸져서 반환됩니다.
// 반환 타입에 `Self::Item`을 사용하므로, 함수 시그니처를 업데이트할 필요 없이
// 타입을 변경할 수 있습니다.
fn next(&mut self) -> Option<Self::Item> {
let current = self.curr;
self.curr = self.next;
self.next = current + self.next;
// 피보나치 수열에는 끝이 없으므로, 이 `Iterator`는
// 결코 `None`을 반환하지 않으며 항상 `Some`이 반환됩니다.
Some(current)
}
}
// 피보나치 수열 생성기를 반환합니다.
fn fibonacci() -> Fibonacci {
Fibonacci { curr: 0, next: 1 }
}
fn main() {
// `0..3`은 0, 1, 2를 생성하는 `Iterator`입니다.
let mut sequence = 0..3;
println!("0..3에 대한 네 번의 연속적인 `next` 호출");
println!("> {:?}", sequence.next());
println!("> {:?}", sequence.next());
println!("> {:?}", sequence.next());
println!("> {:?}", sequence.next());
// `for`는 `None`이 반환될 때까지 `Iterator`를 통해 작동합니다.
// 각 `Some` 값은 래핑이 해제되어 변수(여기서는 `i`)에 바인딩됩니다.
println!("`for`를 사용하여 0..3을 순회합니다");
for i in 0..3 {
println!("> {}", i);
}
// `take(n)` 메서드는 `Iterator`를 처음 `n`개의 항으로 줄입니다.
println!("피보나치 수열의 처음 네 항은 다음과 같습니다: ");
for i in fibonacci().take(4) {
println!("> {}", i);
}
// `skip(n)` 메서드는 처음 `n`개의 항을 버리고 `Iterator`를 단축시킵니다.
println!("피보나치 수열의 다음 네 항은 다음과 같습니다: ");
for i in fibonacci().skip(4).take(4) {
println!("> {}", i);
}
let array = [1u32, 3, 3, 7];
// `iter` 메서드는 배열/슬라이스에 대한 `Iterator`를 생성합니다.
println!("다음 배열 {:?}을 순회합니다", &array);
for i in array.iter() {
println!("> {}", i);
}
}
impl Trait
impl Trait는 두 위치에서 사용될 수 있습니다:
- 인자 타입으로서
- 반환 타입으로서
인자 타입으로서
함수가 트레이트에 대해 제네릭이지만 구체적인 타입에는 관심이 없다면, 인자 타입으로 impl Trait를 사용하여 함수 선언을 단순화할 수 있습니다.
예를 들어, 다음 코드를 살펴보세요:
fn parse_csv_document<R: std::io::BufRead>(src: R) -> std::io::Result<Vec<Vec<String>>> {
src.lines()
.map(|line| {
// 소스의 각 줄에 대해
line.map(|line| {
// 줄을 성공적으로 읽었다면 처리하고, 그렇지 않으면 에러를 반환합니다
line.split(',') // 콤마로 구분된 줄을 분할합니다
.map(|entry| String::from(entry.trim())) // 앞뒤 공백을 제거합니다
.collect() // 한 행의 모든 문자열을 `Vec<String>`으로 수집합니다
})
})
.collect() // 모든 줄을 `Vec<Vec<String>>`으로 수집합니다
}
parse_csv_document는 제네릭이어서 BufReader<File>이나 [u8]과 같이 BufRead를 구현하는 모든 타입을 취할 수 있지만, R이 어떤 타입인지는 중요하지 않고 R은 단지 src의 타입을 선언하는 데만 사용되므로, 다음과 같이 작성할 수도 있습니다:
fn parse_csv_document(src: impl std::io::BufRead) -> std::io::Result<Vec<Vec<String>>> {
src.lines()
.map(|line| {
// 소스의 각 줄에 대해
line.map(|line| {
// 줄을 성공적으로 읽었다면 처리하고, 그렇지 않으면 에러를 반환합니다
line.split(',') // 콤마로 구분된 줄을 분할합니다
.map(|entry| String::from(entry.trim())) // 앞뒤 공백을 제거합니다
.collect() // 한 행의 모든 문자열을 `Vec<String>`으로 수집합니다
})
})
.collect() // 모든 줄을 `Vec<Vec<String>>`으로 수집합니다
}
impl Trait를 인자 타입으로 사용하는 것은 여러분이 함수의 어떤 형태를 사용하는지 명시적으로 명시할 수 없음을 의미합니다. 즉, parse_csv_document::<std::io::Empty>(std::io::empty())는 두 번째 예제에서 작동하지 않습니다.
반환 타입으로서
여러분의 함수가 MyTrait를 구현하는 타입을 반환한다면, 반환 타입을 -> impl MyTrait로 작성할 수 있습니다. 이는 타입 시그니처를 아주 많이 단순화하는 데 도움이 될 수 있습니다!
use std::iter;
use std::vec::IntoIter;
// 이 함수는 두 개의 `Vec<i32>`를 결합하고 그에 대한 이터레이터를 반환합니다.
// 반환 타입이 얼마나 복잡한지 보세요!
fn combine_vecs_explicit_return_type(
v: Vec<i32>,
u: Vec<i32>,
) -> iter::Cycle<iter::Chain<IntoIter<i32>, IntoIter<i32>>> {
v.into_iter().chain(u.into_iter()).cycle()
}
// 이것은 정확히 동일한 함수이지만, 반환 타입에 `impl Trait`를 사용합니다.
// 훨씬 더 간단해진 것을 보세요!
fn combine_vecs(
v: Vec<i32>,
u: Vec<i32>,
) -> impl Iterator<Item=i32> {
v.into_iter().chain(u.into_iter()).cycle()
}
fn main() {
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5];
let mut v3 = combine_vecs(v1, v2);
assert_eq!(Some(1), v3.next());
assert_eq!(Some(2), v3.next());
assert_eq!(Some(3), v3.next());
assert_eq!(Some(4), v3.next());
assert_eq!(Some(5), v3.next());
println!("모두 완료");
}
더 중요한 점은, 일부 Rust 타입은 명시적으로 작성할 수 없다는 것입니다. 예를 들어, 모든 클로저는 자신만의 이름 없는 구체적인 타입을 가집니다. impl Trait 구문이 생기기 전에는 클로저를 반환하기 위해 힙에 할당해야만 했습니다. 하지만 이제는 다음과 같이 정적으로 처리할 수 있습니다:
// 입력에 `y`를 더하는 함수를 반환합니다.
fn make_adder_function(y: i32) -> impl Fn(i32) -> i32 {
let closure = move |x: i32| { x + y };
closure
}
fn main() {
let plus_one = make_adder_function(1);
assert_eq!(plus_one(2), 3);
}
또한 impl Trait를 사용하여 map이나 filter 클로저를 사용하는 이터레이터를 반환할 수도 있습니다! 이는 map과 filter를 더 쉽게 사용할 수 있게 해줍니다. 클로저 타입은 이름이 없기 때문에, 함수가 클로저를 포함하는 이터레이터를 반환할 경우 명시적인 반환 타입을 작성할 수 없습니다. 하지만 impl Trait를 사용하면 이를 쉽게 할 수 있습니다:
fn double_positives<'a>(numbers: &'a Vec<i32>) -> impl Iterator<Item = i32> + 'a {
numbers
.iter()
.filter(|x| x > &&0)
.map(|x| x * 2)
}
fn main() {
let singles = vec![-3, -2, 2, 3];
let doubles = double_positives(&singles);
assert_eq!(doubles.collect::<Vec<i32>>(), vec![4, 6]);
}
Clone and Copy
리소스를 다룰 때, 기본 동작은 할당이나 함수 호출 시 리소스를 이전(transfer)하는 것입니다. 하지만 때로는 리소스의 복사본을 만들어야 할 때도 있습니다.
Clone 트레이트는 바로 이런 일을 돕습니다. 가장 일반적으로, Clone 트레이트에 정의된 .clone() 메서드를 사용할 수 있습니다.
Copy: Implicit Cloning
The Copy trait allows a type to be duplicated simply by copying bits, with no additional logic required. When a type implements Copy, assignments and function calls will implicitly copy the value instead of moving it.
Important: Copy requires Clone - any type that implements Copy must also implement Clone. This is because Copy is defined as a subtrait: trait Copy: Clone {}. The Clone implementation for Copy types simply copies the bits.
Not all types can implement Copy. A type can only be Copy if:
- All of its components are
Copy - It doesn’t manage external resources (like heap memory, file handles, etc.)
// A unit struct without resources
// Note: Copy requires Clone, so we must derive both
#[derive(Debug, Clone, Copy)]
struct Unit;
// A tuple struct with resources that implements the `Clone` trait
// This CANNOT be Copy because Box<T> is not Copy
#[derive(Clone, Debug)]
struct Pair(Box<i32>, Box<i32>);
fn main() {
// `Unit`을 인스턴스화합니다
let unit = Unit;
// Copy `Unit` - this is an implicit copy, not a move!
// Because Unit implements Copy, the value is duplicated automatically
let copied_unit = unit;
// 두 `Unit` 모두 독립적으로 사용될 수 있습니다.
println!("원본: {:?}", unit);
println!("복사본: {:?}", copied_unit);
// `Pair`를 인스턴스화합니다
let pair = Pair(Box::new(1), Box::new(2));
println!("원본: {:?}", pair);
// Move `pair` into `moved_pair`, moves resources
// Pair does not implement Copy, so this is a move
let moved_pair = pair;
println!("이동됨: {:?}", moved_pair);
// 에러! `pair`는 리소스를 잃었습니다
//println!("original: {:?}", pair);
// TODO ^ 이 줄의 주석을 해제해 보세요
// Clone `moved_pair` into `cloned_pair` (resources are included)
// Unlike Copy, Clone is explicit - we must call .clone()
let cloned_pair = moved_pair.clone();
// `std::mem::drop`을 사용하여 이동된 원본 페어를 해제합니다
drop(moved_pair);
// 에러! `moved_pair`가 해제되었습니다
//println!("moved and dropped: {:?}", moved_pair);
// TODO ^ 이 줄의 주석을 해제해 보세요
// `.clone()`의 결과는 여전히 사용될 수 있습니다!
println!("클론: {:?}", cloned_pair);
}
슈퍼트레이트
Rust에는 “상속“이 없지만, 한 트레이트를 다른 트레이트의 슈퍼셋(superset)으로 정의할 수 있습니다. 예를 들어:
trait Person {
fn name(&self) -> String;
}
// Person은 Student의 슈퍼트레이트입니다.
// Student를 구현하려면 Person도 구현해야 합니다.
trait Student: Person {
fn university(&self) -> String;
}
trait Programmer {
fn fav_language(&self) -> String;
}
// CompSciStudent(컴퓨터 공학 학생)는 Programmer와 Student 모두의 서브트레이트입니다.
// CompSciStudent를 구현하려면 두 슈퍼트레이트를 모두 구현해야 합니다.
trait CompSciStudent: Programmer + Student {
fn git_username(&self) -> String;
}
fn comp_sci_student_greeting(student: &dyn CompSciStudent) -> String {
format!(
"제 이름은 {}이고 {}에 다닙니다. 제가 가장 좋아하는 언어는 {}입니다. 제 Git 사용자 이름은 {}입니다.",
student.name(),
student.university(),
student.fav_language(),
student.git_username()
)
}
struct CSStudent {
name: String,
university: String,
fav_language: String,
git_username: String
}
impl Programmer for CSStudent {
fn fav_language(&self) -> String {
self.fav_language.clone()
}
}
impl Student for CSStudent {
fn university(&self) -> String {
self.university.clone()
}
}
impl Person for CSStudent {
fn name(&self) -> String {
self.name.clone()
}
}
impl CompSciStudent for CSStudent {
fn git_username(&self) -> String {
self.git_username.clone()
}
}
fn main() {
let student = CSStudent {
name: String::from("앨리스"),
university: String::from("MIT"),
fav_language: String::from("러스트"),
git_username: String::from("alice_codes"),
};
let greeting = comp_sci_student_greeting(&student);
println!("{}", greeting);
}
참고:
중복 트레이트 명확화
하나의 타입은 여러 개의 서로 다른 트레이트를 구현할 수 있습니다. 만약 두 트레이트가 함수에 대해 동일한 이름을 요구한다면 어떨까요? 예를 들어, 많은 트레이트가 get()이라는 이름의 메서드를 가질 수 있습니다. 심지어 반환 타입이 다를 수도 있습니다!
좋은 소식은, 각 트레이트 구현이 자신만의 impl 블록을 가지기 때문에, 어떤 트레이트의 get 메서드를 구현하고 있는지 명확하다는 것입니다.
그럼 그 메서드들을 _호출_해야 할 때는 어떨까요? 이들 사이의 모호함을 없애기 위해, 우리는 ’완전한 정규화 구문(Fully Qualified Syntax)’을 사용해야 합니다.
trait UsernameWidget {
// 이 위젯에서 선택된 사용자 이름을 가져옵니다
fn get(&self) -> String;
}
trait AgeWidget {
// 이 위젯에서 선택된 나이를 가져옵니다
fn get(&self) -> u8;
}
// UsernameWidget과 AgeWidget을 모두 포함하는 폼
struct Form {
username: String,
age: u8,
}
impl UsernameWidget for Form {
fn get(&self) -> String {
self.username.clone()
}
}
impl AgeWidget for Form {
fn get(&self) -> u8 {
self.age
}
}
fn main() {
let form = Form {
username: "러스타시안(rustacean)".to_owned(),
age: 28,
};
// 이 줄의 주석을 해제하면 "multiple `get` found"라는 에러가 발생합니다.
// 결국 `get`이라는 이름의 메서드가 여러 개 있기 때문입니다.
// println!("{}", form.get());
let username = <Form as UsernameWidget>::get(&form);
assert_eq!("러스타시안(rustacean)".to_owned(), username);
let age = <Form as AgeWidget>::get(&form);
assert_eq!(28, age);
}
참고:
macro_rules!
Rust는 메타프로그래밍을 가능하게 하는 강력한 매크로 시스템을 제공합니다. 이전 장들에서 보았듯이, 매크로는 이름이 느낌표 !로 끝난다는 점을 제외하면 함수와 비슷해 보입니다. 하지만 함수 호출을 생성하는 대신, 매크로는 프로그램의 나머지 부분과 함께 컴파일되는 소스 코드로 확장됩니다. 그러나 C나 다른 언어의 매크로와 달리, Rust 매크로는 문자열 전처리가 아닌 추상 구문 트리(AST)로 확장되므로, 예상치 못한 연산자 우선순위 버그가 발생하지 않습니다.
매크로는 macro_rules! 매크로를 사용하여 생성됩니다.
// `say_hello`라는 이름의 간단한 매크로입니다.
macro_rules! say_hello {
// `()`는 매크로가 인자를 취하지 않음을 나타냅니다.
() => {
// 매크로는 이 블록의 내용으로 확장됩니다.
println!("안녕하세요!")
};
}
fn main() {
// 이 호출은 `println!("안녕하세요!")`로 확장됩니다.
say_hello!()
}
그렇다면 왜 매크로가 유용할까요?
-
반복하지 마세요(DRY, Don’t repeat yourself). 여러 곳에서 서로 다른 타입에 대해 유사한 기능이 필요할 수 있는 경우가 많습니다. 종종 매크로를 작성하는 것은 코드 반복을 피하는 유용한 방법입니다. (이에 대해서는 나중에 자세히 다룹니다)
-
도메인 특화 언어(DSL, Domain-specific languages). 매크로를 사용하면 특정 목적을 위한 특정 구문을 정의할 수 있습니다. (이에 대해서는 나중에 자세히 다룹니다)
-
가변 인자 인터페이스. 때로는 가변적인 개수의 인자를 취하는 인터페이스를 정의하고 싶을 때가 있습니다. 예를 들어
println!은 형식 문자열에 따라 인자를 몇 개든 취할 수 있습니다! (이에 대해서는 나중에 자세히 다룹니다)
문법
이어지는 하위 섹션에서는 Rust에서 매크로를 정의하는 방법을 보여줄 것입니다. 여기에는 세 가지 기본 아이디어가 있습니다:
지시자
매크로의 인자는 달러 기호 $가 접두사로 붙으며, 지시자(designator)로 타입이 어노테이션됩니다:
macro_rules! create_function {
// 이 매크로는 지시자 `ident` 인자를 취하여
// `$func_name`이라는 이름의 함수를 생성합니다.
// `ident` 지시자는 변수/함수 이름에 사용됩니다.
($func_name:ident) => {
fn $func_name() {
// `stringify!` 매크로는 `ident`를 문자열로 변환합니다.
println!("당신은 {:?}()를 호출했습니다",
stringify!($func_name));
}
};
}
// 위의 매크로를 사용하여 `foo`와 `bar`라는 이름의 함수를 생성합니다.
create_function!(foo);
create_function!(bar);
macro_rules! print_result {
// 이 매크로는 `expr` 타입의 표현식을 취하여
// 결과와 함께 문자열로 출력합니다.
// `expr` 지시자는 표현식에 사용됩니다.
($expression:expr) => {
// `stringify!`는 표현식을 *있는 그대로* 문자열로 변환합니다.
println!("{:?} = {:?}",
stringify!($expression),
$expression);
};
}
fn main() {
foo();
bar();
print_result!(1u32 + 1);
// 블록도 표현식이라는 점을 기억하세요!
print_result!({
let x = 1u32;
x * x + 2 * x - 1
});
}
사용 가능한 지시자들의 일부는 다음과 같습니다:
blockexpr은 표현식에 사용됩니다ident는 변수/함수 이름에 사용됩니다itemliteral은 리터럴 상수에 사용됩니다pat(패턴)pathstmt(문장)tt(토큰 트리)ty(타입)vis(가시성 한정자)
전체 목록은 Rust 레퍼런스를 참조하세요.
오버로드
매크로는 서로 다른 인자 조합을 수용하도록 오버로딩될 수 있습니다. 그런 점에서는 macro_rules!는 match 블록과 비슷하게 작동할 수 있습니다:
// `test!`는 호출 방식에 따라 `$left`와 `$right`를
// 서로 다른 방식으로 비교할 것입니다:
macro_rules! test {
// 인자들은 콤마로 구분될 필요가 없습니다.
// 어떤 템플릿이든 사용될 수 있습니다!
($left:expr; and $right:expr) => {
println!("{:?} and {:?} is {:?}",
stringify!($left),
stringify!($right),
$left && $right)
};
// ^ 각 팔(arm)은 세미콜론으로 끝나야 합니다.
($left:expr; or $right:expr) => {
println!("{:?} or {:?} is {:?}",
stringify!($left),
stringify!($right),
$left || $right)
};
}
fn main() {
test!(1i32 + 1 == 2i32; and 2i32 * 2 == 4i32);
test!(true; or false);
}
반복
매크로는 인자 목록에서 +를 사용하여 인자가 한 번 이상 반복될 수 있음을 나타내거나, *를 사용하여 인자가 0번 이상 반복될 수 있음을 나타낼 수 있습니다.
다음 예제에서, 매처(matcher)를 $(...),+로 감싸면 콤마로 구분된 하나 이상의 표현식과 매치됩니다. 또한 마지막 케이스에서는 세미콜론이 선택사항임에 유의하세요.
// `find_min!`은 임의 개수의 인자들 중 최솟값을 계산합니다.
macro_rules! find_min {
// 기본 케이스:
($x:expr) => ($x);
// `$x` 뒤에 최소 하나의 `$y,`가 오는 경우
($x:expr, $($y:expr),+) => (
// 나머지 `$y`들에 대해 `find_min!`을 호출합니다.
std::cmp::min($x, find_min!($($y),+))
)
}
fn main() {
println!("{}", find_min!(1));
println!("{}", find_min!(1 + 2, 2));
println!("{}", find_min!(5, 2 * 3, 4));
}
DRY (반복하지 말라)
매크로는 함수나 테스트 스위트의 공통 부분을 추출함으로써 DRY(Don’t Repeat Yourself) 코드를 작성할 수 있게 해줍니다. 다음은 Vec<T>에 대해 +=, *= 및 -= 연산자를 구현하고 테스트하는 예제입니다:
use std::ops::{Add, Mul, Sub};
macro_rules! assert_equal_len {
// `tt`(토큰 트리) 지시자는
// 연산자와 토큰에 사용됩니다.
($a:expr, $b:expr, $func:ident, $op:tt) => {
assert!($a.len() == $b.len(),
"{:?}: dimension mismatch: {:?} {:?} {:?}",
stringify!($func),
($a.len(),),
stringify!($op),
($b.len(),));
};
}
macro_rules! op {
($func:ident, $bound:ident, $op:tt, $method:ident) => {
fn $func<T: $bound<T, Output=T> + Copy>(xs: &mut Vec<T>, ys: &Vec<T>) {
assert_equal_len!(xs, ys, $func, $op);
for (x, y) in xs.iter_mut().zip(ys.iter()) {
*x = $bound::$method(*x, *y);
// *x = x.$method(*y);
}
}
};
}
// `add_assign`, `mul_assign`, `sub_assign` 함수를 구현합니다.
op!(add_assign, Add, +=, add);
op!(mul_assign, Mul, *=, mul);
op!(sub_assign, Sub, -=, sub);
mod test {
use std::iter;
macro_rules! test {
($func:ident, $x:expr, $y:expr, $z:expr) => {
#[test]
fn $func() {
for size in 0usize..10 {
let mut x: Vec<_> = iter::repeat($x).take(size).collect();
let y: Vec<_> = iter::repeat($y).take(size).collect();
let z: Vec<_> = iter::repeat($z).take(size).collect();
super::$func(&mut x, &y);
assert_eq!(x, z);
}
}
};
}
// `add_assign`, `mul_assign`, `sub_assign`을 테스트합니다.
test!(add_assign, 1u32, 2u32, 3u32);
test!(mul_assign, 2u32, 3u32, 6u32);
test!(sub_assign, 3u32, 2u32, 1u32);
}
$ rustc --test dry.rs && ./dry
running 3 tests
test test::mul_assign ... ok
test test::add_assign ... ok
test test::sub_assign ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured
도메인 특화 언어 (DSL)
DSL은 Rust 매크로에 내장된 미니 “언어“입니다. 매크로 시스템이 일반적인 Rust 구문으로 확장되기 때문에 완전히 유효한 Rust 코드이지만, 작은 언어처럼 보입니다. 이를 통해 (일정한 범위 내에서) 특정 기능을 위한 간결하거나 직관적인 구문을 정의할 수 있습니다.
작은 계산기 API를 정의하고 싶다고 가정해 봅시다. 표현식을 입력하면 출력이 콘솔에 인쇄되도록 하고 싶습니다.
macro_rules! calculate {
(eval $e:expr) => {
{
let val: usize = $e; // 타입을 부호 없는 정수로 강제합니다
println!("{} = {}", stringify!{$e}, val);
}
};
}
fn main() {
calculate! {
eval 1 + 2 // 히히히 `eval`은 Rust 키워드가 아닙니다!
}
calculate! {
eval (1 + 2) * (3 / 4)
}
}
출력:
1 + 2 = 3
(1 + 2) * (3 / 4) = 0
이것은 매우 간단한 예제였지만, lazy_static이나 clap과 같이 훨씬 더 복잡한 인터페이스들이 개발되어 있습니다.
또한, 매크로에 두 쌍의 중괄호가 있음에 유의하세요. 바깥쪽 중괄호는 ()나 [] 외에 macro_rules! 문법의 일부입니다.
가변 인자 인터페이스
가변 인자 인터페이스는 임의의 개수의 인자를 취합니다. 예를 들어, println!은 형식 문자열에 따라 결정되는 임의의 개수의 인자를 취할 수 있습니다.
이전 섹션의 calculate! 매크로를 가변 인자를 취하도록 확장할 수 있습니다:
macro_rules! calculate {
// 단일 `eval`을 위한 패턴
(eval $e:expr) => {
{
let val: usize = $e; // 타입을 정수로 강제합니다
println!("{} = {}", stringify!{$e}, val);
}
};
// 여러 개의 `eval`을 재귀적으로 분해합니다
(eval $e:expr, $(eval $es:expr),+) => {{
calculate! { eval $e }
calculate! { $(eval $es),+ }
}};
}
fn main() {
calculate! { // 보세요! 가변 인자 `calculate!`입니다!
eval 1 + 2,
eval 3 + 4,
eval (2 * 3) + 1
}
}
출력:
1 + 2 = 3
3 + 4 = 7
(2 * 3) + 1 = 7
에러 핸들링
에러 핸들링은 실패 가능성을 처리하는 과정입니다. 예를 들어, 파일을 읽는 데 실패했는데도 그 잘못된 입력을 계속 사용하는 것은 분명히 문제가 될 것입니다. 이러한 에러를 인지하고 명시적으로 관리하면 프로그램의 나머지 부분을 다양한 함정으로부터 보호할 수 있습니다.
Rust에는 에러를 처리하는 다양한 방법이 있으며, 이는 이어지는 하위 장에서 설명합니다. 이들은 모두 어느 정도 미묘한 차이가 있으며 용도가 다릅니다. 일반적인 원칙은 다음과 같습니다:
명시적인 panic은 주로 테스트와 복구 불가능한 에러를 처리하는 데 유용합니다. 프로토타이핑을 할 때도 유용할 수 있는데, 예를 들어 아직 구현되지 않은 함수를 다룰 때 유용하지만, 그런 경우에는 더 설명적인 unimplemented를 사용하는 것이 좋습니다. 테스트에서 panic은 명시적으로 실패를 알리는 합리적인 방법입니다.
Option 타입은 값이 선택적이거나 값의 부재가 에러 상황이 아닐 때 사용됩니다. 예를 들어 디렉터리의 부모 - /와 C:는 부모가 없습니다. Option을 다룰 때, unwrap은 프로토타이핑이나 값이 반드시 존재한다고 절대적으로 확신할 수 있는 경우에 괜찮습니다. 하지만 expect는 무언가 잘못되었을 경우를 대비해 에러 메시지를 지정할 수 있게 해주므로 더 유용합니다.
무언가 잘못될 가능성이 있고 호출자가 그 문제를 처리해야 할 때는 Result를 사용하세요. Result에 대해서도 unwrap과 expect를 사용할 수 있지만 (테스트나 빠른 프로토타입이 아니라면 제발 그렇게 하지 마세요).
에러 핸들링에 대한 더 엄밀한 논의는 공식 도서의 에러 핸들링 섹션을 참조하세요.
panic
우리가 살펴볼 가장 단순한 에러 핸들링 메커니즘은 panic입니다. 이것은 에러 메시지를 출력하고, 스택을 되감기(unwind) 시작하며, 보통 프로그램을 종료합니다. 여기서는 에러 조건에 대해 명시적으로 panic을 호출합니다:
fn drink(beverage: &str) {
// 설탕이 든 음료를 너무 많이 마시면 안 됩니다.
if beverage == "레모네이드" { panic!("으아아아아!!!!"); }
println!("상쾌한 {}만 있으면 돼요.", beverage);
}
fn main() {
drink("물");
drink("레모네이드");
drink("생수");
}
첫 번째 drink 호출은 작동합니다. 두 번째는 패닉을 일으키며, 따라서 세 번째는 결코 호출되지 않습니다.
abort와 unwind
이전 섹션에서는 에러 핸들링 메커니즘인 panic을 설명했습니다. 패닉 설정에 따라 서로 다른 코드 경로를 조건부로 컴파일할 수 있습니다. 현재 사용 가능한 값은 unwind와 abort입니다.
앞선 레모네이드 예제를 바탕으로, 서로 다른 코드 라인을 실행해 보기 위해 패닉 전략을 명시적으로 사용합니다.
fn drink(beverage: &str) {
// 설탕이 든 음료를 너무 많이 마시면 안 됩니다.
if beverage == "레모네이드" {
if cfg!(panic = "abort") {
println!("여긴 당신 파티가 아니에요. 도망쳐요!!!!");
} else {
println!("뱉어내요!!!!");
}
} else {
println!("상쾌한 {}만 있으면 돼요.", beverage);
}
}
fn main() {
drink("물");
drink("레모네이드");
}
여기 drink()를 다시 작성하고 unwind 키워드를 명시적으로 사용하는 또 다른 예제가 있습니다.
#[cfg(panic = "unwind")]
fn ah() {
println!("뱉어내요!!!!");
}
#[cfg(not(panic = "unwind"))]
fn ah() {
println!("여긴 당신 파티가 아니에요. 도망쳐요!!!!");
}
fn drink(beverage: &str) {
if beverage == "레모네이드" {
ah();
} else {
println!("상쾌한 {}만 있으면 돼요.", beverage);
}
}
fn main() {
drink("물");
drink("레모네이드");
}
패닉 전략은 커맨드 라인에서 abort 또는 unwind를 사용하여 설정할 수 있습니다.
rustc lemonade.rs -C panic=abort
Option과 unwrap
지난 예제에서, 우리는 프로그램을 의도적으로 실패하게 만들 수 있음을 보여주었습니다. 설탕이 든 레모네이드를 마시면 panic하도록 프로그램에 지시했습니다. 하지만 음료가 있을 것으로 예상했는데 아무것도 받지 못하면 어떨까요? 그 경우도 마찬가지로 나쁠 것이므로, 처리가 필요합니다!
레모네이드의 경우처럼 빈 문자열("")인지 테스트할 수도 있습니다. 하지만 우리는 Rust를 사용하고 있으니, 대신 컴파일러가 음료가 없는 경우를 지적하도록 해봅시다.
표준 라이브러리의 Option<T>라는 열거형은 부재의 가능성이 있을 때 사용됩니다. 이는 두 가지 “옵션” 중 하나로 나타납니다:
Some(T):T타입의 요소가 발견됨None: 요소가 발견되지 않음
이러한 경우들은 match를 통해 명시적으로 처리하거나 unwrap을 통해 암시적으로 처리할 수 있습니다. 암시적 처리는 내부 요소를 반환하거나 panic을 일으킵니다.
expect를 사용하여 panic을 수동으로 커스터마이징할 수 있지만, 그렇지 않으면 unwrap은 명시적 처리보다 덜 의미 있는 출력을 남깁니다. 다음 예제에서, 명시적 처리는 원하는 경우 panic할 수 있는 옵션을 유지하면서도 더 제어된 결과를 제공합니다.
// 어른은 모든 것을 보았고, 어떤 음료든 잘 다룰 수 있습니다.
// 모든 음료는 `match`를 사용하여 명시적으로 처리됩니다.
fn give_adult(drink: Option<&str>) {
// 각 경우에 대한 행동 지침을 지정합니다.
match drink {
Some("레모네이드") => println!("윽! 너무 달아요."),
Some(inner) => println!("{}요? 좋네요.", inner),
None => println!("음료가 없나요? 뭐, 어쩔 수 없죠."),
}
}
// 다른 사람들은 단 음료를 마시기 전에 `panic`할 것입니다.
// 모든 음료는 `unwrap`을 사용하여 암시적으로 처리됩니다.
fn drink(drink: Option<&str>) {
// `unwrap`은 `None`을 받으면 `panic`을 반환합니다.
let inside = drink.unwrap();
if inside == "레모네이드" { panic!("으아아아아!!!!"); }
println!("난 {}가 너무 좋아요!!!!!", inside);
}
fn main() {
let water = Some("물");
let lemonade = Some("레모네이드");
let void = None;
give_adult(water);
give_adult(lemonade);
give_adult(void);
let coffee = Some("커피");
let nothing = None;
drink(coffee);
drink(nothing);
}
?로 옵션 풀기
match 문을 사용하여 Option을 풀 수(unpack) 있지만, ? 연산자를 사용하는 것이 더 쉬울 때가 많습니다. 만약 x가 Option이라면, x?를 평가할 때 x가 Some이면 그 내부 값을 반환하고, 그렇지 않으면 실행 중인 함수를 종료하고 None을 반환합니다.
fn next_birthday(current_age: Option<u8>) -> Option<String> {
// 만약 `current_age`가 `None`이면, 이는 `None`을 반환합니다.
// 만약 `current_age`가 `Some`이면, 내부의 `u8` 값 + 1이
// `next_age`에 할당됩니다.
let next_age: u8 = current_age? + 1;
Some(format!("내년에 저는 {}살이 됩니다", next_age))
}
여러 개의 ?를 체인으로 연결하여 코드를 훨씬 더 읽기 쉽게 만들 수 있습니다.
struct Person {
job: Option<Job>,
}
#[derive(Clone, Copy)]
struct Job {
phone_number: Option<PhoneNumber>,
}
#[derive(Clone, Copy)]
#[allow(dead_code)]
struct PhoneNumber {
area_code: Option<u8>,
number: u32,
}
impl Person {
// 사람의 직장 전화번호의 지역 번호가 존재한다면 가져옵니다.
fn work_phone_area_code(&self) -> Option<u8> {
// `?` 연산자가 없다면 많은 중첩된 `match` 문이 필요했을 것입니다.
// 훨씬 더 많은 코드가 필요할 것입니다 - 직접 작성해 보고 어느 쪽이 더 쉬운지
// 확인해 보세요.
self.job?.phone_number?.area_code
}
}
fn main() {
let p = Person {
job: Some(Job {
phone_number: Some(PhoneNumber {
area_code: Some(61),
number: 439222222,
}),
}),
};
assert_eq!(p.work_phone_area_code(), Some(61));
}
콤비네이터: map
match는 Option을 처리하는 유효한 방법입니다. 하지만 특히 입력이 있을 때만 유효한 연산들의 경우, 과도한 사용은 지루할 수 있습니다. 이런 경우, 콤비네이터(combinators)를 사용하여 모듈식으로 제어 흐름을 관리할 수 있습니다.
Option에는 Some -> Some 및 None -> None의 단순 매핑을 위한 콤비네이터인 map() 메서드가 내장되어 있습니다. 여러 개의 map() 호출을 체인으로 연결하여 훨씬 더 유연하게 사용할 수 있습니다.
다음 예제에서 process()는 간결함을 유지하면서 그 이전의 모든 함수들을 대체합니다.
#![allow(dead_code)]
#[derive(Debug)] enum Food { Apple, Carrot, Potato }
#[derive(Debug)] struct Peeled(Food);
#[derive(Debug)] struct Chopped(Food);
#[derive(Debug)] struct Cooked(Food);
// 음식을 껍질 벗깁니다. 음식이 없다면 `None`을 반환합니다.
// 그렇지 않으면 껍질을 벗긴 음식을 반환합니다.
fn peel(food: Option<Food>) -> Option<Peeled> {
match food {
Some(food) => Some(Peeled(food)),
None => None,
}
}
// 음식을 썹니다. 음식이 없다면 `None`을 반환합니다.
// 그렇지 않으면 썬 음식을 반환합니다.
fn chop(peeled: Option<Peeled>) -> Option<Chopped> {
match peeled {
Some(Peeled(food)) => Some(Chopped(food)),
None => None,
}
}
// 음식을 요리합니다. 여기서는 케이스 처리를 위해 `match` 대신 `map()`을 보여줍니다.
fn cook(chopped: Option<Chopped>) -> Option<Cooked> {
chopped.map(|Chopped(food)| Cooked(food))
}
// 음식을 순서대로 껍질 벗기고, 썰고, 요리하는 함수입니다.
// 코드를 단순화하기 위해 `map()`을 여러 번 체인으로 연결합니다.
fn process(food: Option<Food>) -> Option<Cooked> {
food.map(|f| Peeled(f))
.map(|Peeled(f)| Chopped(f))
.map(|Chopped(f)| Cooked(f))
}
// 먹기 전에 음식이 있는지 없는지 확인합니다!
fn eat(food: Option<Cooked>) {
match food {
Some(food) => println!("음~ 전 {:?}가 정말 좋아요", food),
None => println!("오 이런! 먹을 수 있는 게 아니었어요."),
}
}
fn main() {
let apple = Some(Food::Apple);
let carrot = Some(Food::Carrot);
let potato = None;
let cooked_apple = cook(chop(peel(apple)));
let cooked_carrot = cook(chop(peel(carrot)));
// 이제 더 간단해 보이는 `process()`를 시도해 봅시다.
let cooked_potato = process(potato);
eat(cooked_apple);
eat(cooked_carrot);
eat(cooked_potato);
}
참고:
콤비네이터: and_then
map()은 match 문을 단순화하기 위한 체인 가능한 방법으로 설명되었습니다. 하지만 Option<T>를 반환하는 함수에 map()을 사용하면 중첩된 Option<Option<T>>가 생성됩니다. 여러 호출을 함께 체인으로 연결하는 것은 혼란스러울 수 있습니다. 이때 다른 언어에서 flatmap으로 알려진 and_then()이라는 또 다른 콤비네이터가 등장합니다.
and_then()은 래핑된 값을 가지고 함수 입력을 호출하고 그 결과를 반환합니다. 만약 Option이 None이라면, 대신 None을 반환합니다.
다음 예제에서, cookable_v3()는 Option<Food>를 결과로 냅니다. and_then() 대신 map()을 사용했다면 Option<Option<Food>>를 얻었을 것이며, 이는 eat()에 대해 유효하지 않은 타입입니다.
#![allow(dead_code)]
#[derive(Debug)] enum Food { CordonBleu, Steak, Sushi }
#[derive(Debug)] enum Day { Monday, Tuesday, Wednesday }
// 우리는 스시를 만들 재료가 없습니다.
fn have_ingredients(food: Food) -> Option<Food> {
match food {
Food::Sushi => None,
_ => Some(food),
}
}
// 우리는 코르동 블뢰(Cordon Bleu)를 제외한 모든 것의 레시피를 가지고 있습니다.
fn have_recipe(food: Food) -> Option<Food> {
match food {
Food::CordonBleu => None,
_ => Some(food),
}
}
// 요리를 하려면 레시피와 재료가 모두 필요합니다.
// 우리는 이 로직을 `match`들의 체인으로 표현할 수 있습니다:
fn cookable_v1(food: Food) -> Option<Food> {
match have_recipe(food) {
None => None,
Some(food) => have_ingredients(food),
}
}
// 이는 `and_then()`을 사용하여 더 간결하게 다시 작성될 수 있습니다:
fn cookable_v3(food: Food) -> Option<Food> {
have_recipe(food).and_then(have_ingredients)
}
// 그렇지 않다면 `Option<Food>`를 얻기 위해 `Option<Option<Food>>`를 `flatten()`해야 할 것입니다:
fn cookable_v2(food: Food) -> Option<Food> {
have_recipe(food).map(have_ingredients).flatten()
}
fn eat(food: Food, day: Day) {
match cookable_v3(food) {
Some(food) => println!("야호! {:?}에 우리는 {:?}를 먹게 되었어요.", day, food),
None => println!("오 이런. {:?}에는 못 먹는 건가요?", day),
}
}
fn main() {
let (cordon_bleu, steak, sushi) = (Food::CordonBleu, Food::Steak, Food::Sushi);
eat(cordon_bleu, Day::Monday);
eat(steak, Day::Tuesday);
eat(sushi, Day::Wednesday);
}
참고:
클로저, Option, Option::and_then(), 그리고 Option::flatten()
Option 풀기와 기본값
Option이 None일 때 이를 풀고 기본값으로 돌아가는 방법은 여러 가지가 있습니다. 우리의 필요에 맞는 것을 선택하려면 다음을 고려해야 합니다:
- 조급한(eager) 평가가 필요한가요, 아니면 느긋한(lazy) 평가가 필요한가요?
- 원래의 빈 값을 그대로 두어야 하나요, 아니면 그 자리에서 수정해야 하나요?
or()은 체인 가능하며, 조급하게 평가하고, 빈 값을 그대로 유지합니다.
or()은 체인 가능하며 다음 예제에서 보듯 인자를 조급하게 평가합니다. or의 인자들은 조급하게 평가되기 때문에, or에 전달된 변수는 이동(move)된다는 점에 유의하세요.
#[derive(Debug)]
enum Fruit { Apple, Orange, Banana, Kiwi, Lemon }
fn main() {
let apple = Some(Fruit::Apple);
let orange = Some(Fruit::Orange);
let no_fruit: Option<Fruit> = None;
let first_available_fruit = no_fruit.or(orange).or(apple);
println!("first_available_fruit: {:?}", first_available_fruit);
// first_available_fruit: Some(Orange)
// `or`는 인자를 이동시킵니다.
// 위 예제에서 `or(orange)`가 `Some`을 반환했으므로 `or(apple)`은 호출되지 않았습니다.
// 하지만 `apple`이라는 이름의 변수는 상관없이 이동되었으며, 더 이상 사용할 수 없습니다.
// println!("Variable apple was moved, so this line won't compile: {:?}", apple);
// TODO: 위 줄의 주석을 해제하여 컴파일러 에러를 확인해 보세요.
}
or_else()는 체인 가능하며, 느긋하게 평가하고, 빈 값을 그대로 유지합니다.
또 다른 대안은 or_else를 사용하는 것입니다. 이 역시 체인 가능하며 다음 예제에서 보듯 느긋하게 평가합니다:
#[derive(Debug)]
enum Fruit { Apple, Orange, Banana, Kiwi, Lemon }
fn main() {
let no_fruit: Option<Fruit> = None;
let get_kiwi_as_fallback = || {
println!("키위를 대체값으로 제공함");
Some(Fruit::Kiwi)
};
let get_lemon_as_fallback = || {
println!("레몬을 대체값으로 제공함");
Some(Fruit::Lemon)
};
let first_available_fruit = no_fruit
.or_else(get_kiwi_as_fallback)
.or_else(get_lemon_as_fallback);
println!("first_available_fruit: {:?}", first_available_fruit);
// 키위를 대체값으로 제공함
// first_available_fruit: Some(Kiwi)
}
get_or_insert()는 조급하게 평가하며, 빈 값을 그 자리에서 수정합니다.
Option에 값이 포함되도록 보장하기 위해, 다음 예제에서 보듯 get_or_insert를 사용하여 대체값으로 그 자리에서 수정할 수 있습니다. get_or_insert는 파라미터를 조급하게 평가하므로, apple 변수는 이동된다는 점에 유의하세요.
#[derive(Debug)]
enum Fruit { Apple, Orange, Banana, Kiwi, Lemon }
fn main() {
let mut my_fruit: Option<Fruit> = None;
let apple = Fruit::Apple;
let first_available_fruit = my_fruit.get_or_insert(apple);
println!("first_available_fruit is: {:?}", first_available_fruit);
println!("my_fruit is: {:?}", my_fruit);
// first_available_fruit is: Apple
// my_fruit is: Some(Apple)
//println!("apple이라는 이름의 변수는 이동되었습니다: {:?}", apple);
// TODO: 위 줄의 주석을 해제하여 컴파일러 에러를 확인해 보세요.
}
get_or_insert_with()는 느긋하게 평가하며, 빈 값을 그 자리에서 수정합니다.
대체할 값을 명시적으로 제공하는 대신, 다음과 같이 get_or_insert_with에 클로저를 전달할 수 있습니다:
#[derive(Debug)]
enum Fruit { Apple, Orange, Banana, Kiwi, Lemon }
fn main() {
let mut my_fruit: Option<Fruit> = None;
let get_lemon_as_fallback = || {
println!("레몬을 대체값으로 제공함");
Fruit::Lemon
};
let first_available_fruit = my_fruit
.get_or_insert_with(get_lemon_as_fallback);
println!("first_available_fruit is: {:?}", first_available_fruit);
println!("my_fruit is: {:?}", my_fruit);
// 레몬을 대체값으로 제공함
// first_available_fruit is: Lemon
// my_fruit is: Some(Lemon)
// Option에 값이 있으면 변경되지 않은 채로 유지되며, 클로저는 호출되지 않습니다.
let mut my_apple = Some(Fruit::Apple);
let should_be_apple = my_apple.get_or_insert_with(get_lemon_as_fallback);
println!("should_be_apple is: {:?}", should_be_apple);
println!("my_apple is unchanged: {:?}", my_apple);
// 출력은 다음과 같습니다. `get_lemon_as_fallback` 클로저가 호출되지 않았음에 유의하세요.
// should_be_apple is: Apple
// my_apple is unchanged: Some(Apple)
}
참고:
클로저(closures), get_or_insert, get_or_insert_with, 이동된 변수(moved variables), or, or_else
Result
Result은 발생 가능한 _에러_를 설명하는, Option 타입의 더 풍부한 버전입니다.
즉, Result<T, E>는 다음 두 가지 결과 중 하나를 가질 수 있습니다:
Ok(T): 요소T가 발견됨Err(E): 요소E와 함께 에러가 발견됨
관례적으로 기대되는 결과는 Ok이며, 기대하지 않은 결과는 Err입니다.
Option과 마찬가지로, Result도 많은 메서드들을 가지고 있습니다. 예를 들어 unwrap()은 요소 T를 내놓거나 panic을 일으킵니다. 케이스 처리를 위해 Result와 Option 사이에는 겹치는 많은 콤비네이터들이 있습니다.
Rust로 작업하다 보면 parse() 메서드와 같이 Result 타입을 반환하는 메서드들을 자주 만나게 될 것입니다. 문자열을 다른 타입으로 파싱하는 것이 항상 가능한 것은 아니므로, parse()는 실패 가능성을 나타내는 Result를 반환합니다.
문자열을 parse() 하는 데 성공했을 때와 실패했을 때 어떤 일이 일어나는지 봅시다:
fn multiply(first_number_str: &str, second_number_str: &str) -> i32 {
// `unwrap()`을 사용하여 숫자를 꺼내봅시다. 문제가 생길까요?
let first_number = first_number_str.parse::<i32>().unwrap();
let second_number = second_number_str.parse::<i32>().unwrap();
first_number * second_number
}
fn main() {
let twenty = multiply("10", "2");
println!("두 배는 {}", twenty);
let tt = multiply("t", "2");
println!("두 배는 {}", tt);
}
실패한 경우, parse()는 unwrap()이 panic을 일으킬 수 있도록 에러를 남깁니다. 게다가 이 panic은 프로그램을 종료시키고 불쾌한 에러 메시지를 제공합니다.
에러 메시지의 품질을 높이려면, 반환 타입에 대해 더 구체적이어야 하며 에러를 명시적으로 처리하는 것을 고려해야 합니다.
main에서 Result 사용하기
Result 타입은 명시적으로 지정될 경우 main 함수의 반환 타입이 될 수도 있습니다. 일반적으로 main 함수는 다음과 같은 형태입니다:
fn main() {
println!("Hello World!");
}
하지만 main은 반환 타입으로 Result를 가질 수도 있습니다. 만약 main 함수 내에서 에러가 발생하면, 에러 코드를 반환하고 에러의 디버그 표현(Debug 트레이트 사용)을 출력합니다. 다음 예제는 그러한 시나리오를 보여주며 다음 섹션에서 다루는 측면들을 언급합니다.
use std::num::ParseIntError;
fn main() -> Result<(), ParseIntError> {
let number_str = "10";
let number = match number_str.parse::<i32>() {
Ok(number) => number,
Err(e) => return Err(e),
};
println!("{}", number);
Ok(())
}
Result에서의 map
이전 예제의 multiply에서 패닉을 일으키는 것은 견고한 코드가 아닙니다. 일반적으로 우리는 호출자가 에러에 대응하는 올바른 방법을 결정할 수 있도록 에러를 호출자에게 반환하기를 원합니다.
먼저 우리가 어떤 종류의 에러 타입을 다루고 있는지 알아야 합니다. Err 타입을 결정하기 위해, i32에 대해 FromStr 트레이트로 구현된 parse()를 살펴봅니다. 그 결과, Err 타입은 ParseIntError로 지정됩니다.
아래 예제에서, 단순한 match 문은 전체적으로 더 번거로운 코드로 이어집니다.
use std::num::ParseIntError;
// 반환 타입을 다시 작성했으므로, `unwrap()` 없이 패턴 매칭을 사용합니다.
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
match first_number_str.parse::<i32>() {
Ok(first_number) => {
match second_number_str.parse::<i32>() {
Ok(second_number) => {
Ok(first_number * second_number)
},
Err(e) => Err(e),
}
},
Err(e) => Err(e),
}
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
// 이는 여전히 합리적인 답을 제시합니다.
let twenty = multiply("10", "2");
print(twenty);
// 다음은 이제 훨씬 더 도움이 되는 에러 메시지를 제공합니다.
let tt = multiply("t", "2");
print(tt);
}
다행히도 Option의 map, and_then 및 다른 많은 콤비네이터들이 Result에 대해서도 구현되어 있습니다. Result 문서에 전체 목록이 나와 있습니다.
use std::num::ParseIntError;
// `Option`과 마찬가지로 `map()`과 같은 콤비네이터를 사용할 수 있습니다.
// 이 함수는 위와 동일하며 다음과 같이 읽힙니다:
// 두 값 모두 문자열에서 파싱될 수 있으면 곱하고, 그렇지 않으면 에러를 넘깁니다.
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
first_number_str.parse::<i32>().and_then(|first_number| {
second_number_str.parse::<i32>().map(|second_number| first_number * second_number)
})
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
// 이는 여전히 합리적인 답을 제시합니다.
let twenty = multiply("10", "2");
print(twenty);
// 다음은 이제 훨씬 더 도움이 되는 에러 메시지를 제공합니다.
let tt = multiply("t", "2");
print(tt);
}
Result의 별칭
특정한 Result 타입을 여러 번 재사용하고 싶을 때는 어떨까요? Rust는 별칭(aliases)을 만들 수 있게 해준다는 점을 기억하세요. 편리하게도 문제의 특정 Result에 대해 별칭을 정의할 수 있습니다.
모듈 수준에서 별칭을 만드는 것은 특히 유용할 수 있습니다. 특정 모듈에서 발견되는 에러들은 종종 동일한 Err 타입을 가지므로, 단일 별칭으로 모든 연관된 Result들을 간결하게 정의할 수 있습니다. 이는 매우 유용하여 표준 라이브러리에서도 하나를 제공합니다: io::Result!
다음은 문법을 보여주는 간단한 예제입니다:
use std::num::ParseIntError;
// 에러 타입이 `ParseIntError`인 `Result`에 대한 제네릭 별칭을 정의합니다.
type AliasedResult<T> = Result<T, ParseIntError>;
// 위 별칭을 사용하여 우리의 특정한 `Result` 타입을 참조합니다.
fn multiply(first_number_str: &str, second_number_str: &str) -> AliasedResult<i32> {
first_number_str.parse::<i32>().and_then(|first_number| {
second_number_str.parse::<i32>().map(|second_number| first_number * second_number)
})
}
// 여기서도 별칭을 사용하여 공간을 절약할 수 있습니다.
fn print(result: AliasedResult<i32>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
print(multiply("10", "2"));
print(multiply("t", "2"));
}
참고:
조기 리턴
이전 예제에서 우리는 콤비네이터를 사용하여 에러를 명시적으로 처리했습니다. 이러한 케이스 분석을 처리하는 또 다른 방법은 match 문과 *조기 리턴(early returns)*을 조합하여 사용하는 것입니다.
즉, 에러가 발생하면 단순히 함수의 실행을 멈추고 에러를 반환할 수 있습니다. 어떤 이들에게는 이런 형태의 코드가 읽고 쓰기에 더 쉬울 수 있습니다. 이전 예제를 조기 리턴을 사용하여 다시 작성한 버전을 살펴보세요:
use std::num::ParseIntError;
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
let first_number = match first_number_str.parse::<i32>() {
Ok(first_number) => first_number,
Err(e) => return Err(e),
};
let second_number = match second_number_str.parse::<i32>() {
Ok(second_number) => second_number,
Err(e) => return Err(e),
};
Ok(first_number * second_number)
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
print(multiply("10", "2"));
print(multiply("t", "2"));
}
지금까지 우리는 콤비네이터와 조기 리턴을 사용하여 에러를 명시적으로 처리하는 방법을 배웠습니다. 일반적으로 패닉은 피하고 싶지만, 모든 에러를 명시적으로 처리하는 것은 번거로운 일입니다.
다음 섹션에서는 panic을 일으키지 않고 단순히 unwrap이 필요한 경우를 위한 ?를 소개하겠습니다.
? 소개
때로는 panic의 가능성 없이 unwrap의 단순함만을 원할 때가 있습니다. 지금까지 unwrap은 우리가 정말 원했던 것이 변수를 꺼내는 것임에도 불구하고 점점 더 깊게 중첩하도록 강제해 왔습니다. 이것이 바로 ?의 목적입니다.
Err을 발견했을 때 취할 수 있는 유효한 조치는 두 가지입니다:
- 가능하다면 피하기로 이미 결정한
panic! Err은 처리될 수 없음을 의미하므로return
?는 Err에 대해 panic을 일으키는 대신 return하는 unwrap과 거의1 정확히 동일합니다. 콤비네이터를 사용했던 이전 예제를 어떻게 단순화할 수 있는지 봅시다:
use std::num::ParseIntError;
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
let first_number = first_number_str.parse::<i32>()?;
let second_number = second_number_str.parse::<i32>()?;
Ok(first_number * second_number)
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
print(multiply("10", "2"));
print(multiply("t", "2"));
}
try! 매크로
?가 나오기 전에는 동일한 기능을 try! 매크로로 구현했습니다. 현재는 ? 연산자가 권장되지만, 오래된 코드를 볼 때 여전히 try!를 발견할 수도 있습니다. 이전 예제의 동일한 multiply 함수는 try!를 사용하면 다음과 같습니다:
// Cargo를 사용하면서 이 예제를 에러 없이 컴파일하고 실행하려면,
// `Cargo.toml` 파일의 `[package]` 섹션에 있는 `edition` 필드 값을 "2015"로 변경하세요.
use std::num::ParseIntError;
fn multiply(first_number_str: &str, second_number_str: &str) -> Result<i32, ParseIntError> {
let first_number = try!(first_number_str.parse::<i32>());
let second_number = try!(second_number_str.parse::<i32>());
Ok(first_number * second_number)
}
fn print(result: Result<i32, ParseIntError>) {
match result {
Ok(n) => println!("n은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
print(multiply("10", "2"));
print(multiply("t", "2"));
}
여러 에러 타입
이전 예제들은 항상 매우 편리했습니다. Result는 다른 Result와 상호작용하고, Option은 다른 Option과 상호작용했습니다.
때때로는 Option이 Result와 상호작용해야 하거나, Result<T, Error1>이 Result<T, Error2>와 상호작용해야 할 때가 있습니다. 그런 경우, 우리는 서로 다른 에러 타입들을 구성 가능하고 상호작용하기 쉬운 방식으로 관리하고 싶어 합니다.
다음 코드에서, 두 개의 unwrap 인스턴스는 서로 다른 에러 타입을 생성합니다. Vec::first는 Option을 반환하는 반면, parse::<i32>는 Result<i32, ParseIntError>를 반환합니다:
fn double_first(vec: Vec<&str>) -> i32 {
let first = vec.first().unwrap(); // 에러 1 발생
2 * first.parse::<i32>().unwrap() // 에러 2 발생
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
println!("두 배가 된 첫 번째 값은 {}입니다", double_first(numbers));
println!("두 배가 된 첫 번째 값은 {}입니다", double_first(empty));
// 에러 1: 입력 벡터가 비어 있습니다
println!("두 배가 된 첫 번째 값은 {}입니다", double_first(strings));
// 에러 2: 요소를 숫자로 파싱할 수 없습니다
}
다음 섹션들에서 이러한 종류의 문제들을 처리하기 위한 몇 가지 전략을 살펴볼 것입니다.
Option에서 Result 꺼내기
혼합된 에러 타입들을 처리하는 가장 기본적인 방법은 단순히 서로를 내포시키는 것입니다.
use std::num::ParseIntError;
fn double_first(vec: Vec<&str>) -> Option<Result<i32, ParseIntError>> {
vec.first().map(|first| {
first.parse::<i32>().map(|n| 2 * n)
})
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(numbers));
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(empty));
// 에러 1: 입력 벡터가 비어 있습니다
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(strings));
// 에러 2: 요소를 숫자로 파싱할 수 없습니다
}
에러가 발생하면 처리를 중단하고 싶지만(?와 같이), Option이 None일 때는 계속 진행하고 싶을 때가 있습니다. 이럴 때 Result와 Option을 서로 바꾸어 주는 transpose 함수가 유용합니다.
use std::num::ParseIntError;
fn double_first(vec: Vec<&str>) -> Result<Option<i32>, ParseIntError> {
let opt = vec.first().map(|first| {
first.parse::<i32>().map(|n| 2 * n)
});
opt.transpose()
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(numbers));
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(empty));
println!("두 배가 된 첫 번째 값은 {:?}입니다", double_first(strings));
}
에러 타입 정의하기
때로는 모든 서로 다른 에러들을 하나의 에러 타입으로 마스킹하는 것이 코드를 단순화합니다. 이를 커스텀 에러를 통해 보여주겠습니다.
Rust에서는 우리만의 에러 타입을 정의할 수 있습니다. 일반적으로 “좋은” 에러 타입은 다음과 같습니다:
- 서로 다른 에러들을 동일한 타입으로 표현합니다
- 사용자에게 보기 좋은 에러 메시지를 제공합니다
- 다른 타입들과 비교하기 쉽습니다
- 좋은 예:
Err(EmptyVec) - 나쁜 예:
Err("Please use a vector with at least one element".to_owned())
- 좋은 예:
- 에러에 대한 정보를 담을 수 있습니다
- 좋은 예:
Err(BadChar(c, position)) - 나쁜 예:
Err("+ cannot be used here".to_owned())
- 좋은 예:
- 다른 에러들과 잘 구성(compose)됩니다
use std::fmt;
type Result<T> = std::result::Result<T, DoubleError>;
// 우리의 에러 타입들을 정의합니다. 이들은 우리의 에러 처리 케이스에 맞게 커스터마이징될 수 있습니다.
// 이제 우리만의 에러를 작성하거나, 기저 에러 구현에 위임하거나, 혹은 그 사이의 작업을 수행할 수 있습니다.
#[derive(Debug, Clone)]
struct DoubleError;
// 에러의 생성은 표시 방식과 완전히 분리되어 있습니다.
// 표시 스타일 때문에 복잡한 로직이 어지러워질까 봐 걱정할 필요가 없습니다.
//
// 참고로 우리는 에러에 대한 추가 정보를 저장하지 않습니다. 즉, 정보를 담도록 타입을 수정하지 않고서는
// 어떤 문자열이 파싱에 실패했는지 알 수 없습니다.
impl fmt::Display for DoubleError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "두 배로 만들 첫 번째 항목이 유효하지 않습니다")
}
}
fn double_first(vec: Vec<&str>) -> Result<i32> {
vec.first()
// 에러를 우리의 새로운 타입으로 변경합니다.
.ok_or(DoubleError)
.and_then(|s| {
s.parse::<i32>()
// 여기서도 새로운 에러 타입으로 업데이트합니다.
.map_err(|_| DoubleError)
.map(|i| 2 * i)
})
}
fn print(result: Result<i32>) {
match result {
Ok(n) => println!("두 배가 된 첫 번째 값은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
print(double_first(numbers));
print(double_first(empty));
print(double_first(strings));
}
에러 Box하기
원본 에러를 보존하면서 단순한 코드를 작성하는 방법은 에러들을 Box하는 것입니다. 단점은 기저 에러 타입을 런타임에만 알 수 있고 정적으로 결정되지 않는다는 것입니다.
표준 라이브러리는 Box가 From을 통해 Error 트레이트를 구현하는 모든 타입으로부터 트레이트 객체 Box<Error>로의 변환을 구현하도록 함으로써 에러를 박싱하는 것을 돕습니다.
use std::error;
use std::fmt;
// 별칭이 `Box<dyn error::Error>`를 사용하도록 변경합니다.
type Result<T> = std::result::Result<T, Box<dyn error::Error>>;
#[derive(Debug, Clone)]
struct EmptyVec;
impl fmt::Display for EmptyVec {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "두 배로 만들 첫 번째 항목이 유효하지 않습니다")
}
}
impl error::Error for EmptyVec {}
fn double_first(vec: Vec<&str>) -> Result<i32> {
vec.first()
.ok_or_else(|| EmptyVec.into()) // Into 트레이트를 사용하여 Box로 변환합니다.
.and_then(|s| {
s.parse::<i32>()
.map_err(From::from) // From::from 함수 포인터를 사용하여 Box로 변환합니다.
.map(|i| 2 * i)
})
}
fn print(result: Result<i32>) {
match result {
Ok(n) => println!("두 배가 된 첫 번째 값은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
print(double_first(numbers));
print(double_first(empty));
print(double_first(strings));
}
참고:
동적 디스패치(Dynamic dispatch)와 Error 트레이트
?의 다른 용도
이전 예제에서 parse를 호출했을 때 우리의 즉각적인 반응은 라이브러리 에러를 박싱된 에러로 map하는 것이었습니다:
.and_then(|s| s.parse::<i32>())
.map_err(|e| e.into())
이는 단순하고 흔한 작업이므로 생략할 수 있다면 편리할 것입니다. 아쉽게도 and_then은 충분히 유연하지 못해 생략할 수 없습니다. 하지만 대신 ?를 사용할 수 있습니다.
?는 이전에 unwrap 또는 return Err(err)로 설명되었습니다. 이는 거의 맞지만 전부는 아닙니다. 실제로는 unwrap 또는 return Err(From::from(err))를 의미합니다. From::from은 서로 다른 타입 간의 변환 유틸리티이므로, 에러가 반환 타입으로 변환 가능한 곳에서 ?를 사용하면 자동으로 변환됩니다.
여기서는 이전 예제를 ?를 사용하여 다시 작성합니다. 결과적으로, 우리의 에러 타입에 대해 From::from이 구현되면 map_err이 사라질 것입니다:
use std::error;
use std::fmt;
// 별칭이 `Box<dyn error::Error>`를 사용하도록 변경합니다.
type Result<T> = std::result::Result<T, Box<dyn error::Error>>;
#[derive(Debug)]
struct EmptyVec;
impl fmt::Display for EmptyVec {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "두 배로 만들 첫 번째 항목이 유효하지 않습니다")
}
}
impl error::Error for EmptyVec {}
// 이전과 동일한 구조이지만 모든 `Result`와 `Option`을 체인으로 연결하는 대신,
// `?`를 사용하여 즉시 내부 값을 꺼냅니다.
fn double_first(vec: Vec<&str>) -> Result<i32> {
let first = vec.first().ok_or(EmptyVec)?;
let parsed = first.parse::<i32>()?;
Ok(2 * parsed)
}
fn print(result: Result<i32>) {
match result {
Ok(n) => println!("두 배가 된 첫 번째 값은 {}입니다", n),
Err(e) => println!("에러: {}", e),
}
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
print(double_first(numbers));
print(double_first(empty));
print(double_first(strings));
}
이제 상당히 깔끔해졌습니다. 원본 panic 예제와 비교하면, 반환 타입이 Result라는 점을 제외하고는 unwrap 호출을 ?로 대체한 것과 매우 유사합니다. 그 결과, 이들은 최상위 레벨에서 구조 분해(destructure)되어야 합니다.
참고:
에러 감싸기
에러를 박싱하는 것의 대안은 에러를 여러분만의 에러 타입으로 감싸는 것입니다.
use std::error;
use std::error::Error;
use std::num::ParseIntError;
use std::fmt;
type Result<T> = std::result::Result<T, DoubleError>;
#[derive(Debug)]
enum DoubleError {
EmptyVec,
// 파싱 에러에 대해서는 해당 에러 구현에 위임할 것입니다.
// 추가 정보를 제공하려면 타입에 더 많은 데이터를 추가해야 합니다.
Parse(ParseIntError),
}
impl fmt::Display for DoubleError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
DoubleError::EmptyVec =>
write!(f, "최소한 하나의 요소를 가진 벡터를 사용하세요"),
// 감싸진 에러는 추가 정보를 포함하며 `source()` 메서드를 통해
// 사용할 수 있습니다.
DoubleError::Parse(..) =>
write!(f, "제공된 문자열을 정수로 파싱할 수 없습니다"),
}
}
}
impl error::Error for DoubleError {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match *self {
DoubleError::EmptyVec => None,
// 원인은 기저 구현 에러 타입입니다. 이는 트레이트 객체 `&error::Error`로
// 암시적으로 캐스팅됩니다. 기저 타입이 이미 `Error` 트레이트를
// 구현하고 있기 때문에 작동합니다.
DoubleError::Parse(ref e) => Some(e),
}
}
}
// `ParseIntError`에서 `DoubleError`로의 변환을 구현합니다.
// `ParseIntError`를 `DoubleError`로 변환해야 할 때 `?`에 의해
// 자동으로 호출됩니다.
impl From<ParseIntError> for DoubleError {
fn from(err: ParseIntError) -> DoubleError {
DoubleError::Parse(err)
}
}
fn double_first(vec: Vec<&str>) -> Result<i32> {
let first = vec.first().ok_or(DoubleError::EmptyVec)?;
// 여기서 우리는 `DoubleError`를 생성하기 위해 (위에서 정의한)
// `From`의 `ParseIntError` 구현을 암시적으로 사용합니다.
let parsed = first.parse::<i32>()?;
Ok(2 * parsed)
}
fn print(result: Result<i32>) {
match result {
Ok(n) => println!("두 배가 된 첫 번째 값은 {}입니다", n),
Err(e) => {
println!("에러: {}", e);
if let Some(source) = e.source() {
println!(" 원인: {}", source);
}
},
}
}
fn main() {
let numbers = vec!["42", "93", "18"];
let empty = vec![];
let strings = vec!["두부", "93", "18"];
print(double_first(numbers));
print(double_first(empty));
print(double_first(strings));
}
이는 에러 처리를 위해 상용구(boilerplate) 코드를 조금 더 추가하며, 모든 애플리케이션에서 필요하지는 않을 수 있습니다. 상용구 코드를 대신 처리해 주는 몇몇 라이브러리들이 있습니다.
참고:
Result 반복하기
예를 들어, Iter::map 연산은 실패할 수 있습니다:
fn main() {
let strings = vec!["두부", "93", "18"];
let numbers: Vec<_> = strings
.into_iter()
.map(|s| s.parse::<i32>())
.collect();
println!("결과: {:?}", numbers);
}
이를 처리하기 위한 전략들을 단계별로 살펴봅시다.
filter_map()으로 실패한 항목 무시하기
filter_map은 함수를 호출하고 그 결과가 None인 것들을 걸러냅니다.
fn main() {
let strings = vec!["두부", "93", "18"];
let numbers: Vec<_> = strings
.into_iter()
.filter_map(|s| s.parse::<i32>().ok())
.collect();
println!("결과: {:?}", numbers);
}
map_err()와 filter_map()으로 실패한 항목 수집하기
map_err은 에러와 함께 함수를 호출하므로, 이를 이전의 filter_map 방식에 추가하여 순회하는 동안 에러들을 따로 저장해둘 수 있습니다.
fn main() {
let strings = vec!["42", "두부", "93", "999", "18"];
let mut errors = vec![];
let numbers: Vec<_> = strings
.into_iter()
.map(|s| s.parse::<u8>())
.filter_map(|r| r.map_err(|e| errors.push(e)).ok())
.collect();
println!("숫자: {:?}", numbers);
println!("에러: {:?}", errors);
}
collect()를 사용하여 전체 연산을 실패로 처리하기
Result는 FromIterator를 구현하므로 결과들의 벡터(Vec<Result<T, E>>)를 벡터를 가진 결과(Result<Vec<T>, E>)로 바꿀 수 있습니다. Result::Err이 발견되면 즉시 순회가 종료됩니다.
fn main() {
let strings = vec!["두부", "93", "18"];
let numbers: Result<Vec<_>, _> = strings
.into_iter()
.map(|s| s.parse::<i32>())
.collect();
println!("결과: {:?}", numbers);
}
동일한 기술을 Option에 대해서도 사용할 수 있습니다.
partition()을 사용하여 모든 유효한 값과 실패를 수집하기
fn main() {
let strings = vec!["두부", "93", "18"];
let (numbers, errors): (Vec<_>, Vec<_>) = strings
.into_iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
println!("숫자: {:?}", numbers);
println!("에러: {:?}", errors);
}
결과를 보면 모든 것이 여전히 Result로 감싸져 있음을 알 수 있습니다. 이를 위해서는 상용구 코드가 조금 더 필요합니다.
fn main() {
let strings = vec!["두부", "93", "18"];
let (numbers, errors): (Vec<_>, Vec<_>) = strings
.into_iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
let numbers: Vec<_> = numbers.into_iter().map(Result::unwrap).collect();
let errors: Vec<_> = errors.into_iter().map(Result::unwrap_err).collect();
println!("숫자: {:?}", numbers);
println!("에러: {:?}", errors);
}
표준 라이브러리 타입
표준 라이브러리는 기본 자료형을 대폭 확장한 많은 사용자 정의 타입을 제공합니다. 이들 중 일부는 다음과 같습니다:
- 가변 크기
String: 예:"hello world" - 가변 크기 벡터:
[1, 2, 3] - 선택적 타입:
Option<i32> - 에러 핸들링 타입:
Result<i32, i32> - 힙 할당 포인터:
Box<i32>
참고:
Box, 스택과 힙
Rust의 모든 값은 기본적으로 스택에 할당됩니다. Box<T>를 생성함으로써 값을 박싱(힙에 할당)할 수 있습니다. 박스는 힙에 할당된 T 타입 값을 가리키는 스마트 포인터입니다. 박스가 스코프를 벗어나면 데스트럭터가 호출되어 내부 객체가 파괴되고 힙 메모리가 해제됩니다.
박싱된 값은 * 연산자를 사용하여 역참조할 수 있습니다. 이는 한 단계의 간접 참조를 제거합니다.
use std::mem;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
struct Point {
x: f64,
y: f64,
}
// 사각형은 공간상에서의 왼쪽 상단과 오른쪽 하단
// 꼭짓점 위치로 지정될 수 있습니다
#[allow(dead_code)]
struct Rectangle {
top_left: Point,
bottom_right: Point,
}
fn origin() -> Point {
Point { x: 0.0, y: 0.0 }
}
fn boxed_origin() -> Box<Point> {
// 이 점을 힙에 할당하고, 그에 대한 포인터를 반환합니다
Box::new(Point { x: 0.0, y: 0.0 })
}
fn main() {
// (모든 타입 어노테이션은 불필요합니다)
// 스택에 할당된 변수들
let point: Point = origin();
let rectangle: Rectangle = Rectangle {
top_left: origin(),
bottom_right: Point { x: 3.0, y: -4.0 }
};
// 힙에 할당된 사각형
let boxed_rectangle: Box<Rectangle> = Box::new(Rectangle {
top_left: origin(),
bottom_right: Point { x: 3.0, y: -4.0 },
});
// 함수의 출력은 박싱될 수 있습니다
let boxed_point: Box<Point> = Box::new(origin());
// 이중 간접 참조
let box_in_a_box: Box<Box<Point>> = Box::new(boxed_origin());
println!("Point는 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&point));
println!("Rectangle은 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&rectangle));
// 박스 크기 == 포인터 크기
println!("박싱된 point는 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&boxed_point));
println!("박싱된 rectangle은 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&boxed_rectangle));
println!("박싱된 박스는 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&box_in_a_box));
// `boxed_point`에 담긴 데이터를 `unboxed_point`로 복사합니다
let unboxed_point: Point = *boxed_point;
println!("박싱 해제된 point는 스택에서 {} 바이트를 차지합니다",
mem::size_of_val(&unboxed_point));
}
벡터
벡터는 크기 조절이 가능한 배열입니다. 슬라이스와 마찬가지로 크기를 컴파일 타임에 알 수 없지만, 언제든지 늘리거나 줄일 수 있습니다. 벡터는 3개의 파라미터로 표현됩니다:
- 데이터에 대한 포인터
- 길이
- 용량
용량은 벡터를 위해 예약된 메모리 양을 나타냅니다. 벡터는 길이가 용량보다 작은 동안에는 계속 늘어날 수 있습니다. 이 임계값을 넘어야 할 때, 벡터는 더 큰 용량으로 재할당됩니다.
fn main() {
// 이터레이터는 벡터로 수집(collect)될 수 있습니다
let collected_iterator: Vec<i32> = (0..10).collect();
println!("(0..10)을 다음으로 수집함: {:?}", collected_iterator);
// `vec!` 매크로는 벡터를 초기화하는 데 사용될 수 있습니다
let mut xs = vec![1i32, 2, 3];
println!("초기 벡터: {:?}", xs);
// 벡터 끝에 새로운 요소를 삽입합니다
println!("벡터에 4를 푸시합니다");
xs.push(4);
println!("벡터: {:?}", xs);
// 에러! 불변 벡터는 늘어날 수 없습니다
collected_iterator.push(0);
// FIXME ^ 이 줄을 주석 처리하세요
// `len` 메서드는 현재 벡터에 저장된 요소의 개수를 반환합니다
println!("벡터 길이: {}", xs.len());
// 인덱싱은 대괄호를 사용하여 수행됩니다 (인덱싱은 0부터 시작합니다)
println!("두 번째 요소: {}", xs[1]);
// `pop`은 벡터에서 마지막 요소를 제거하고 이를 반환합니다
println!("마지막 요소 팝: {:?}", xs.pop());
// 범위를 벗어난 인덱싱은 패닉을 일으킵니다
println!("네 번째 요소: {}", xs[3]);
// FIXME ^ 이 줄을 주석 처리하세요
// 벡터는 쉽게 순회(iterate)될 수 있습니다
println!("xs의 내용:");
for x in xs.iter() {
println!("> {}", x);
}
// 벡터는 반복 횟수가 별도의 변수(`i`)에 열거되면서 순회될 수도 있습니다
for (i, x) in xs.iter().enumerate() {
println!("{} 위치에 {} 값이 있습니다", i, x);
}
// `iter_mut` 덕분에 가변 벡터도 각 값을 수정할 수 있는 방식으로
// 순회될 수 있습니다
for x in xs.iter_mut() {
*x *= 3;
}
println!("업데이트된 벡터: {:?}", xs);
}
더 많은 Vec 메서드는 std::vec 모듈에서 찾을 수 있습니다
문자열
Rust에서 가장 많이 사용되는 두 가지 문자열 타입은 String과 &str입니다.
String은 바이트 벡터(Vec<u8>)로 저장되지만, 항상 유효한 UTF-8 시퀀스임이 보장됩니다. String은 힙에 할당되며, 크기 조절이 가능하고 널 종료(null terminated)되지 않습니다.
&str은 항상 유효한 UTF-8 시퀀스를 가리키는 슬라이스(&[u8])이며, &[T]가 Vec<T>를 들여다보는 창(view)인 것과 마찬가지로 String을 들여다보는 데 사용될 수 있습니다.
fn main() {
// (모든 타입 어노테이션은 불필요합니다)
// 읽기 전용 메모리에 할당된 문자열에 대한 참조
let pangram: &'static str = "the quick brown fox jumps over the lazy dog";
println!("팬그램(Pangram): {}", pangram);
// 단어들을 역순으로 순회합니다. 새로운 문자열은 할당되지 않습니다
println!("역순 단어들");
for word in pangram.split_whitespace().rev() {
println!("> {}", word);
}
// 문자들을 벡터로 복사하고, 정렬한 뒤 중복을 제거합니다
let mut chars: Vec<char> = pangram.chars().collect();
chars.sort();
chars.dedup();
// 비어 있고 크기 조절 가능한 `String`을 생성합니다
let mut string = String::new();
for c in chars {
// 문자열 끝에 문자를 삽입합니다
string.push(c);
// 문자열 끝에 문자열을 삽입합니다
string.push_str(", ");
}
// 트림된 문자열은 원본 문자열에 대한 슬라이스이므로,
// 새로운 할당이 수행되지 않습니다
let chars_to_trim: &[char] = &[' ', ','];
let trimmed_str: &str = string.trim_matches(chars_to_trim);
println!("사용된 문자들: {}", trimmed_str);
// 문자열을 힙에 할당합니다
let alice = String::from("I like dogs");
// 새로운 메모리를 할당하고 수정된 문자열을 그곳에 저장합니다
let bob: String = alice.replace("dog", "cat");
println!("앨리스가 말합니다: {}", alice);
println!("밥이 말합니다: {}", bob);
}
더 많은 str/String 메서드는 std::str 및 std::string 모듈에서 찾을 수 있습니다
리터럴과 이스케이프
특수 문자가 포함된 문자열 리터럴을 작성하는 방법은 여러 가지가 있습니다. 모두 비슷한 &str을 결과로 내므로 가장 작성하기 편리한 형식을 사용하는 것이 좋습니다. 마찬가지로 바이트 문자열 리터럴을 작성하는 방법도 여러 가지가 있으며, 모두 &[u8; N] 결과를 냅니다.
일반적으로 특수 문자는 백슬래시 문자 \로 이스케이프됩니다. 이 방법을 통해 출력 불가능한 문자나 입력 방법을 모르는 문자 등 어떤 문자든 문자열에 추가할 수 있습니다. 백슬래시 자체를 리터럴로 원한다면 백슬래시 하나를 더 붙여 \\와 같이 이스케이프하세요.
리터럴 내에 나타나는 문자열 또는 문자 리터럴 구분자는 반드시 이스케이프해야 합니다: "\"", '\''.
fn main() {
// 16진수 값을 사용하여 바이트를 작성하기 위해 이스케이프를 사용할 수 있습니다...
let byte_escape = "I'm writing \x52\x75\x73\x74!";
println!("What are you doing\x3F (\\x3F는 ?를 의미합니다) {}", byte_escape);
// ...또는 유니코드 코드 포인트.
let unicode_codepoint = "\u{211D}";
let character_name = "\"DOUBLE-STRUCK CAPITAL R\"";
println!("유니코드 문자 {} (U+211D)는 {}라고 불립니다",
unicode_codepoint, character_name );
let long_string = "문자열 리터럴은
여러 줄에 걸쳐 있을 수 있습니다.
여기서의 줄바꿈과 들여쓰기 ->\
<- 역시 이스케이프될 수 있습니다!";
println!("{}", long_string);
}
때때로 이스케이프해야 할 문자가 너무 많거나 문자열을 있는 그대로 작성하는 것이 훨씬 더 편리할 때가 있습니다. 이때 로우(raw) 문자열 리터럴이 사용됩니다.
fn main() {
let raw_str = r"이스케이프가 여기선 작동하지 않습니다: \x3F \u{211D}";
println!("{}", raw_str);
// 로우 문자열에서 따옴표가 필요하다면, # 쌍을 추가하세요
let quotes = r#"And then I said: "There is no escape!""#;
println!("{}", quotes);
// 문자열 내에 "#가 필요하다면, 구분자에 더 많은 #를 사용하세요.
// 최대 255개의 #를 사용할 수 있습니다.
let longer_delimiter = r###"A string with "# in it. And even "##!"###;
println!("{}", longer_delimiter);
}
UTF-8이 아닌 문자열을 원하시나요? (str과 String은 반드시 유효한 UTF-8이어야 함을 기억하세요). 아니면 대부분이 텍스트인 바이트 배열을 원하시나요? 바이트 문자열이 해결해 드립니다!
use std::str;
fn main() {
// 이는 실제로 `&str`이 아님에 유의하세요
let bytestring: &[u8; 21] = b"this is a byte string";
// 바이트 배열은 `Display` 트레이트를 가지고 있지 않으므로, 출력에 다소 제한이 있습니다
println!("바이트 문자열: {:?}", bytestring);
// 바이트 문자열은 바이트 이스케이프를 가질 수 있습니다...
let escaped = b"\x52\x75\x73\x74 as bytes";
// ...하지만 유니코드 이스케이프는 허용되지 않습니다
// let escaped = b"\u{211D} is not allowed";
println!("일부 이스케이프된 바이트: {:?}", escaped);
// 로우 바이트 문자열은 로우 문자열과 똑같이 작동합니다
let raw_bytestring = br"\u{211D} is not escaped here";
println!("{:?}", raw_bytestring);
// 바이트 배열을 `str`로 변환하는 것은 실패할 수 있습니다
if let Ok(my_str) = str::from_utf8(raw_bytestring) {
println!("그리고 텍스트로 변환하면: '{}'", my_str);
}
let _quotes = br#"일반 로우 문자열처럼, \
더 "화려한" 포맷팅을 사용할 수도 있습니다"#;
// 바이트 문자열은 UTF-8일 필요가 없습니다
let shift_jis = b"\x82\xe6\x82\xa8\x82\xb1\x82\xbb"; // SHIFT-JIS 인코딩의 "ようこそ"(환영합니다)
// 그런 경우에는 항상 `str`로 변환될 수는 없습니다
match str::from_utf8(shift_jis) {
Ok(my_str) => println!("변환 성공: '{}'", my_str),
Err(e) => println!("변환 실패: {:?}", e),
};
}
문자 인코딩 간의 변환에 대해서는 encoding 크레이트를 확인해 보세요.
문자열 리터럴을 작성하고 문자를 이스케이프하는 방법에 대한 더 자세한 목록은 Rust 레퍼런스의 ‘Tokens’ 장에 나와 있습니다.
Option
때로는 프로그램의 일부에서 발생하는 실패를 panic!을 호출하는 대신 포착하고 싶을 때가 있습니다. 이는 Option 열거형을 사용하여 수행할 수 있습니다.
Option<T> 열거형은 두 가지 변체를 가집니다:
None: 실패나 값의 부재를 나타냄Some(value):T타입의value를 감싸는 튜플 구조체
// `panic!`을 일으키지 않는 정수 나눗셈
fn checked_division(dividend: i32, divisor: i32) -> Option<i32> {
if divisor == 0 {
// 실패는 `None` 변체로 표현됩니다
None
} else {
// 결과는 `Some` 변체로 감싸집니다
Some(dividend / divisor)
}
}
// 이 함수는 성공하지 않을 수 있는 나눗셈을 처리합니다
fn try_division(dividend: i32, divisor: i32) {
// `Option` 값은 다른 열거형과 마찬가지로 패턴 매칭될 수 있습니다
match checked_division(dividend, divisor) {
None => println!("{} / {} 실패!", dividend, divisor),
Some(quotient) => {
println!("{} / {} = {}", dividend, divisor, quotient)
},
}
}
fn main() {
try_division(4, 2);
try_division(1, 0);
// `None`을 변수에 바인딩할 때는 타입 어노테이션이 필요합니다
let none: Option<i32> = None;
let _equivalent_none = None::<i32>;
let optional_float = Some(0f32);
// `Some` 변체의 래핑을 해제(unwrap)하면 감싸진 값을 추출합니다.
println!("{:?}의 래핑을 해제하면 {:?}가 됩니다", optional_float, optional_float.unwrap());
// `None` 변체의 래핑을 해제하면 `panic!`이 발생합니다
println!("{:?}의 래핑을 해제하면 {:?}가 됩니다", none, none.unwrap());
}
Result
우리는 실패할 수 있는 함수로부터 None을 반환하여 실패를 나타낼 때 Option 열거형이 사용될 수 있음을 보았습니다. 하지만 때로는 작업이 왜 실패했는지 표현하는 것이 중요합니다. 이를 위해 우리는 Result 열거형을 가지고 있습니다.
Result<T, E> 열거형은 두 가지 변체를 가집니다:
Ok(value): 작업이 성공했음을 나타내며, 작업에 의해 반환된value를 감쌉니다. (value는T타입을 가집니다)Err(why): 작업이 실패했음을 나타내며, 실패의 원인을 설명하는 (바라건대)why를 감쌉니다. (why는E타입을 가집니다)
mod checked {
// 우리가 포착하고 싶은 수학적 "에러"들
#[derive(Debug)]
pub enum MathError {
DivisionByZero,
NonPositiveLogarithm,
NegativeSquareRoot,
}
pub type MathResult = Result<f64, MathError>;
pub fn div(x: f64, y: f64) -> MathResult {
if y == 0.0 {
// 이 작업은 실패하게 되므로, 대신 실패의 원인을
// `Err`로 감싸서 반환합시다
Err(MathError::DivisionByZero)
} else {
// 이 작업은 유효하므로, 결과를 `Ok`로 감싸서 반환합니다
Ok(x / y)
}
}
pub fn sqrt(x: f64) -> MathResult {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
pub fn ln(x: f64) -> MathResult {
if x <= 0.0 {
Err(MathError::NonPositiveLogarithm)
} else {
Ok(x.ln())
}
}
}
// `op(x, y)` === `sqrt(ln(x / y))`
fn op(x: f64, y: f64) -> f64 {
// 이것은 3단계 `match` 피라미드입니다!
match checked::div(x, y) {
Err(why) => panic!("{:?}", why),
Ok(ratio) => match checked::ln(ratio) {
Err(why) => panic!("{:?}", why),
Ok(ln) => match checked::sqrt(ln) {
Err(why) => panic!("{:?}", why),
Ok(sqrt) => sqrt,
},
},
}
}
fn main() {
// 이것은 실패할까요?
println!("{}", op(1.0, 10.0));
}
?
match를 사용하여 결과를 체인으로 연결하는 것은 꽤 지저분해질 수 있습니다. 다행히 ? 연산자를 사용하여 다시 깔끔하게 만들 수 있습니다. ?는 Result를 반환하는 표현식의 끝에 사용되며, match 표현식과 동일하게 작동합니다. 여기서 Err(err) 가지는 조기 리턴인 return Err(From::from(err))로 확장되고, Ok(ok) 가지는 ok 표현식으로 확장됩니다.
mod checked {
#[derive(Debug)]
enum MathError {
DivisionByZero,
NonPositiveLogarithm,
NegativeSquareRoot,
}
type MathResult = Result<f64, MathError>;
fn div(x: f64, y: f64) -> MathResult {
if y == 0.0 {
Err(MathError::DivisionByZero)
} else {
Ok(x / y)
}
}
fn sqrt(x: f64) -> MathResult {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
fn ln(x: f64) -> MathResult {
if x <= 0.0 {
Err(MathError::NonPositiveLogarithm)
} else {
Ok(x.ln())
}
}
// 중간 함수
fn op_(x: f64, y: f64) -> MathResult {
// 만약 `div`가 "실패"하면, `DivisionByZero`가 반환됩니다
let ratio = div(x, y)?;
// 만약 `ln`가 "실패"하면, `NonPositiveLogarithm`이 반환됩니다
let ln = ln(ratio)?;
sqrt(ln)
}
pub fn op(x: f64, y: f64) {
match op_(x, y) {
Err(why) => panic!("{}", match why {
MathError::NonPositiveLogarithm
=> "0 이하의 수에 대한 로그",
MathError::DivisionByZero
=> "0으로 나누기",
MathError::NegativeSquareRoot
=> "음수의 제곱근",
}),
Ok(value) => println!("{}", value),
}
}
}
fn main() {
checked::op(1.0, 10.0);
}
Result를 매핑하거나 구성(compose)하는 많은 메서드들이 있으니, 반드시 문서를 확인해 보세요.
panic!
panic! 매크로는 패닉을 생성하고 스택 되감기(unwinding)를 시작하는 데 사용될 수 있습니다. 되감기를 하는 동안, 런타임은 모든 객체의 데스트럭터를 호출하여 해당 스레드가 소유한 모든 리소스를 해제합니다.
우리는 단일 스레드 프로그램만 다루고 있으므로, panic!은 프로그램이 패닉 메시지를 보고하고 종료하게 만듭니다.
// 정수 나눗셈(/)의 재구현
fn division(dividend: i32, divisor: i32) -> i32 {
if divisor == 0 {
// 0으로 나누기는 패닉을 트리거합니다
panic!("0으로 나누기");
} else {
dividend / divisor
}
}
// 메인 태스크
fn main() {
// 힙 할당 정수
let _x = Box::new(0i32);
// 이 작업은 태스크 실패를 트리거합니다
division(3, 0);
println!("이 지점에는 도달하지 않습니다!");
// 이 지점에서 `_x`가 파괴되어야 합니다
}
panic!이 메모리를 누수하지 않는지 확인해 봅시다.
$ rustc panic.rs && valgrind ./panic
==4401== Memcheck, a memory error detector
==4401== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==4401== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==4401== Command: ./panic
==4401==
thread '<main>' panicked at 'division by zero', panic.rs:5
==4401==
==4401== HEAP SUMMARY:
==4401== in use at exit: 0 bytes in 0 blocks
==4401== total heap usage: 18 allocs, 18 frees, 1,648 bytes allocated
==4401==
==4401== All heap blocks were freed -- no leaks are possible
==4401==
==4401== For counts of detected and suppressed errors, rerun with: -v
==4401== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
해시맵
벡터가 정수 인덱스로 값을 저장하는 반면, HashMap은 키(key)로 값을 저장합니다. HashMap의 키는 불리언, 정수, 문자열 또는 Eq와 Hash 트레이트를 구현하는 임의의 다른 타입이 될 수 있습니다. 이에 대해서는 다음 섹션에서 자세히 다룹니다.
벡터와 마찬가지로 HashMap은 늘어날 수 있지만, 공간이 남을 때는 스스로 줄어들 수도 있습니다. HashMap::with_capacity(uint)를 사용하여 특정 시작 용량으로 HashMap을 생성하거나, HashMap::new()를 사용하여 기본 초기 용량을 가진 HashMap을 얻을 수 있습니다(권장됨).
use std::collections::HashMap;
fn call(number: &str) -> &str {
match number {
"798-1364" => "죄송합니다, 전화 연결을 할 수 없습니다.
전화를 끊고 다시 시도해 주세요.",
"645-7689" => "안녕하세요, 어썸 피자입니다. 저는 프레드라고 합니다.
오늘 무엇을 도와드릴까요?",
_ => "안녕! 누구시더라?"
}
}
fn main() {
let mut contacts = HashMap::new();
contacts.insert("다니엘", "798-1364");
contacts.insert("애슐리", "645-7689");
contacts.insert("케이티", "435-8291");
contacts.insert("로버트", "956-1745");
// 참조를 인자로 받아 `Option<&V>`를 반환합니다
match contacts.get(&"다니엘") {
Some(&number) => println!("다니엘에게 전화 거는 중: {}", call(number)),
_ => println!("다니엘의 번호가 없습니다."),
}
// `HashMap::insert()`는 삽입된 값이 새로운 것이면 `None`을,
// 그렇지 않으면 `Some(value)`를 반환합니다
contacts.insert("다니엘", "164-6743");
match contacts.get(&"애슐리") {
Some(&number) => println!("애슐리에게 전화 거는 중: {}", call(number)),
_ => println!("애슐리의 번호가 없습니다."),
}
contacts.remove(&"애슐리");
// `HashMap::iter()`는 (&'a key, &'a value) 쌍을
// 임의의 순서로 내놓는 이터레이터를 반환합니다.
for (contact, &number) in contacts.iter() {
println!("{}에게 전화 거는 중: {}", contact, call(number));
}
}
해싱과 해시 맵(때때로 해시 테이블이라고도 불림)이 어떻게 작동하는지에 대한 더 자세한 정보는 Wikipedia의 해시 테이블을 참조하세요.
대체/사용자 정의 키 타입
Eq와 Hash 트레이트를 구현하는 어떤 타입이든 HashMap의 키가 될 수 있습니다. 여기에는 다음이 포함됩니다:
bool(가능한 키가 두 개뿐이라 그리 유용하진 않지만요)int,uint및 그 모든 변형들String과&str(꿀팁:String을 키로 하는HashMap을 만들고&str을 사용하여.get()을 호출할 수 있습니다)
f32와 f64는 Hash를 구현하지 않는다는 점에 유의하세요. 아마도 부동 소수점 정밀도 에러로 인해 이들을 해시 맵 키로 사용하는 것이 극도로 에러에 취약해질 수 있기 때문일 것입니다.
모든 컬렉션 클래스는 포함된 타입이 각각 Eq와 Hash를 구현한다면 Eq와 Hash를 구현합니다. 예를 들어, Vec<T>는 T가 Hash를 구현한다면 Hash를 구현합니다.
단 한 줄의 코드로 커스텀 타입에 대해 Eq와 Hash를 쉽게 구현할 수 있습니다: #[derive(PartialEq, Eq, Hash)]
컴파일러가 나머지를 알아서 할 것입니다. 만약 세부 사항을 더 제어하고 싶다면, Eq나 Hash를 직접 구현할 수 있습니다. 이 가이드에서는 Hash 구현의 구체적인 내용은 다루지 않습니다.
HashMap에서 struct를 사용하는 것을 연습해 보기 위해, 매우 간단한 사용자 로그인 시스템을 만들어 봅시다:
use std::collections::HashMap;
// Eq는 해당 타입에 대해 PartialEq를 유도(derive)할 것을 요구합니다.
#[derive(PartialEq, Eq, Hash)]
struct Account<'a>{
username: &'a str,
password: &'a str,
}
struct AccountInfo<'a>{
name: &'a str,
email: &'a str,
}
type Accounts<'a> = HashMap<Account<'a>, AccountInfo<'a>>;
fn try_logon<'a>(accounts: &Accounts<'a>,
username: &'a str, password: &'a str){
println!("사용자 이름: {}", username);
println!("비밀번호: {}", password);
println!("로그인 시도 중...");
let logon = Account {
username,
password,
};
match accounts.get(&logon) {
Some(account_info) => {
println!("로그인 성공!");
println!("이름: {}", account_info.name);
println!("이메일: {}", account_info.email);
},
_ => println!("로그인 실패!"),
}
}
fn main(){
let mut accounts: Accounts = HashMap::new();
let account = Account {
username: "j.everyman",
password: "password123",
};
let account_info = AccountInfo {
name: "John Everyman",
email: "j.everyman@email.com",
};
accounts.insert(account, account_info);
try_logon(&accounts, "j.everyman", "psasword123");
try_logon(&accounts, "j.everyman", "password123");
}
해시셋
HashSet을 키에만 관심이 있는 HashMap이라고 생각해보세요 (HashSet<T>는 실제로 HashMap<T, ()>의 래퍼(wrapper)일 뿐입니다).
“그게 무슨 소용이죠?“라고 물으실 수 있습니다. “그냥 키를 Vec에 저장할 수도 있잖아요.”
HashSet의 고유한 특징은 중복된 요소가 없음을 보장한다는 것입니다. 이는 모든 집합(set) 컬렉션이 지키는 계약입니다. HashSet은 그 중 하나의 구현일 뿐입니다. (참고: BTreeSet)
이미 HashSet에 존재하는 값을 삽입하면 (즉, 새 값이 기존 값과 같고 해시도 같다면), 새 값이 기존 값을 대체하게 됩니다.
이는 무언가를 중복 없이 유지하고 싶을 때나, 이미 가지고 있는지 알고 싶을 때 매우 유용합니다.
하지만 집합은 그 이상의 일을 할 수 있습니다.
집합은 4가지 주요 연산을 가집니다 (다음 호출들은 모두 이터레이터를 반환합니다):
-
union(합집합): 두 집합에 있는 모든 고유한 요소들을 가져옵니다. -
difference(차집합): 첫 번째 집합에는 있지만 두 번째 집합에는 없는 모든 요소들을 가져옵니다. -
intersection(교집합): 오직 양쪽 집합 모두에 있는 모든 요소들을 가져옵니다. -
symmetric_difference(대칭차집합): 한쪽 집합에는 있지만 _양쪽 모두_에 있지는 않은 모든 요소들을 가져옵니다.
다음 예제에서 이들 모두를 시도해 보세요:
use std::collections::HashSet;
fn main() {
let mut a: HashSet<i32> = vec![1i32, 2, 3].into_iter().collect();
let mut b: HashSet<i32> = vec![2i32, 3, 4].into_iter().collect();
assert!(a.insert(4));
assert!(a.contains(&4));
// `HashSet::insert()`는 값이 이미 존재하면 false를 반환합니다.
assert!(b.insert(4), "값 4는 이미 집합 B에 있습니다!");
// FIXME ^ 이 줄을 주석 처리하세요
b.insert(5);
// 컬렉션의 요소 타입이 `Debug`를 구현한다면,
// 컬렉션도 `Debug`를 구현합니다.
// 보통 `[elem1, elem2, ...]` 형식으로 요소를 출력합니다.
println!("A: {:?}", a);
println!("B: {:?}", b);
// [1, 2, 3, 4, 5]를 임의의 순서로 출력합니다
println!("합집합: {:?}", a.union(&b).collect::<Vec<&i32>>());
// 이는 [1]을 출력해야 합니다
println!("차집합: {:?}", a.difference(&b).collect::<Vec<&i32>>());
// [2, 3, 4]를 임의의 순서로 출력합니다.
println!("교집합: {:?}", a.intersection(&b).collect::<Vec<&i32>>());
// [1, 5]를 출력합니다
println!("대칭차집합: {:?}",
a.symmetric_difference(&b).collect::<Vec<&i32>>());
}
(예제는 문서에서 발췌 및 수정되었습니다.)
Rc
다중 소유권이 필요한 경우, Rc(참조 횟수 계산, Reference Counting)를 사용할 수 있습니다. Rc는 참조의 개수를 추적하며, 이는 Rc 내부에 감싸진 값의 소유자 수를 의미합니다.
Rc의 참조 횟수는 Rc가 클론될 때마다 1씩 증가하고, 클론된 Rc가 스코프를 벗어날 때마다 1씩 감소합니다. Rc의 참조 횟수가 0이 되면 (즉, 남은 소유자가 없으면), Rc와 그 내부의 값 모두 해제됩니다.
Rc를 클론하는 것은 결코 깊은 복사(deep copy)를 수행하지 않습니다. 클론은 단지 감싸진 값을 가리키는 또 다른 포인터를 생성하고, 횟수를 증가시킬 뿐입니다.
use std::rc::Rc;
fn main() {
let rc_examples = "Rc 예제".to_string();
{
println!("--- rc_a가 생성됨 ---");
let rc_a: Rc<String> = Rc::new(rc_examples);
println!("rc_a의 참조 횟수: {}", Rc::strong_count(&rc_a));
{
println!("--- rc_a가 rc_b로 클론됨 ---");
let rc_b: Rc<String> = Rc::clone(&rc_a);
println!("rc_b의 참조 횟수: {}", Rc::strong_count(&rc_b));
println!("rc_a의 참조 횟수: {}", Rc::strong_count(&rc_a));
// 두 `Rc`는 내부 값이 같으면 같습니다
println!("rc_a와 rc_b는 같음: {}", rc_a.eq(&rc_b));
// 값의 메서드들을 직접 사용할 수 있습니다
println!("rc_a 내부 값의 길이: {}", rc_a.len());
println!("rc_b의 값: {}", rc_b);
println!("--- rc_b가 스코프를 벗어나 해제됨 ---");
}
println!("rc_a의 참조 횟수: {}", Rc::strong_count(&rc_a));
println!("--- rc_a가 스코프를 벗어나 해제됨 ---");
}
// 에러! `rc_examples`는 이미 `rc_a`로 이동되었습니다
// 그리고 `rc_a`가 해제될 때, `rc_examples`도 함께 해제됩니다
// println!("rc_examples: {}", rc_examples);
// TODO ^ 이 줄의 주석을 해제해 보세요
}
참고:
Arc
스레드 간의 공유 소유권이 필요한 경우, Arc(원자적 참조 횟수 계산, Atomically Reference Counted)를 사용할 수 있습니다. 이 구조체는 Clone 구현을 통해 힙 메모리에 있는 값의 위치를 가리키는 참조 포인터를 생성하고 참조 횟수를 증가시킵니다. 스레드 간에 소유권을 공유하므로, 값에 대한 마지막 참조 포인터가 스코프를 벗어날 때 변수가 해제됩니다.
use std::time::Duration;
use std::sync::Arc;
use std::thread;
fn main() {
// 이 변수 선언에서 값이 지정됩니다.
let apple = Arc::new("같은 사과");
for _ in 0..10 {
// 여기서는 힙 메모리의 참조를 가리키는 포인터이므로 별도의 값 지정이 없습니다.
let apple = Arc::clone(&apple);
thread::spawn(move || {
// `Arc`를 사용했으므로, `Arc` 변수 포인터 위치에 할당된 값을 사용하여 스레드를 생성할 수 있습니다.
println!("{:?}", apple);
});
}
// 모든 `Arc` 인스턴스가 생성된 스레드에서 출력되도록 합니다.
thread::sleep(Duration::from_secs(1));
}
기타 표준 라이브러리
표준 라이브러리는 다음과 같은 것들을 지원하기 위해 많은 다른 타입들을 제공합니다:
- 스레드
- 채널
- 파일 입출력
이들은 기본 자료형이 제공하는 것 이상으로 확장됩니다.
참고:
스레드
Rust는 spawn 함수를 통해 네이티브 OS 스레드를 생성하는 메커니즘을 제공합니다. 이 함수의 인자는 이동(moving) 클로저입니다.
use std::thread;
const NTHREADS: u32 = 10;
// 메인 스레드입니다
fn main() {
// 생성된 자식 스레드들을 담을 벡터를 만듭니다.
let mut children = vec![];
for i in 0..NTHREADS {
// 또 다른 스레드를 생성합니다
children.push(thread::spawn(move || {
println!("이것은 {}번 스레드입니다", i);
}));
}
for child in children {
// 스레드가 종료될 때까지 기다립니다. 결과를 반환합니다.
let _ = child.join();
}
}
이러한 스레드들은 OS에 의해 스케줄링됩니다.
테스트케이스: 맵-리듀스
Rust는 전통적으로 이러한 시도와 관련된 많은 골칫거리 없이 데이터 처리를 병렬화하는 것을 매우 쉽게 만들어 줍니다.
표준 라이브러리는 훌륭한 스레딩 프리미티브를 즉시 제공합니다. 이들은 Rust의 소유권 개념 및 에일리어싱 규칙과 결합하여 자동으로 데이터 경합(data races)을 방지합니다.
에일리어싱 규칙(하나의 쓰기 가능 참조 XOR 여러 개의 읽기 가능 참조)은 다른 스레드에 보이는 상태를 조작하는 것을 자동으로 방지합니다. (동기화가 필요한 곳에는 Mutex나 Channel과 같은 동기화 프리미티브가 있습니다.)
이 예제에서, 우리는 숫자 블록에 있는 모든 숫자의 합을 계산할 것입니다. 블록의 덩어리들을 서로 다른 스레드에 할당하여 이를 수행할 것입니다. 각 스레드는 자신의 작은 숫자 블록의 합을 구하고, 이어서 각 스레드에서 생성된 중간 합계들을 모두 더할 것입니다.
스레드 경계를 넘어 참조를 전달하고 있지만, Rust는 우리가 읽기 전용 참조만 전달하고 있음을 이해하며, 따라서 안전하지 않은 상황이나 데이터 경합이 발생할 수 없음을 알고 있습니다. 또한 우리가 전달하는 참조들이 'static 라이프타임을 가지고 있기 때문에, Rust는 이 스레드들이 실행되는 동안 데이터가 파괴되지 않을 것임을 이해합니다. (스레드 간에 static이 아닌 데이터를 공유해야 할 때는 Arc와 같은 스마트 포인터를 사용하여 데이터를 유지하고 static이 아닌 라이프타임 문제를 피할 수 있습니다.)
use std::thread;
// 메인 스레드입니다
fn main() {
// 처리할 데이터입니다.
// 스레드된 맵-리듀스(map-reduce) 알고리즘을 통해 모든 숫자의 합을 계산할 것입니다.
// 공백으로 구분된 각 덩어리는 서로 다른 스레드에서 처리될 것입니다.
//
// TODO: 공백을 삽입하면 출력에 어떤 변화가 생기는지 확인해 보세요!
let data = "86967897737416471853297327050364959
11861322575564723963297542624962850
70856234701860851907960690014725639
38397966707106094172783238747669219
52380795257888236525459303330302837
58495327135744041048897885734297812
69920216438980873548808413720956532
16278424637452589860345374828574668";
// 우리가 생성할 자식 스레드들을 담을 벡터를 만듭니다.
let mut children = vec![];
/*************************************************************************
* "Map" 단계
*
* 데이터를 세그먼트로 나누고, 초기 처리를 적용합니다
************************************************************************/
// 개별 계산을 위해 데이터를 세그먼트로 나눕니다.
// 각 덩어리는 실제 데이터에 대한 참조(&str)가 될 것입니다.
let chunked_data = data.split_whitespace();
// 데이터 세그먼트들을 순회합니다.
// `.enumerate()`는 순회하는 대상에 현재 루프 인덱스를 추가합니다.
// 결과 튜플 "(index, element)"는 "구조 분해 할당"
// 을 통해 즉시 두 변수 "i"와 "data_segment"로 "구조 분해"됩니다.
for (i, data_segment) in chunked_data.enumerate() {
println!("데이터 세그먼트 {}는 \"{}\"입니다", i, data_segment);
// 각 데이터 세그먼트를 별도의 스레드에서 처리합니다
//
// spawn()은 새 스레드에 대한 핸들을 반환하며,
// 반환된 값에 접근하려면 이 핸들을 반드시 보관해야 합니다
//
// 'move || -> u32'는 다음과 같은 클로저 문법입니다:
// * 인자를 취하지 않음 ('||')
// * 캡처한 변수의 소유권을 가져옴 ('move')
// * 부호 없는 32비트 정수를 반환함 ('-> u32')
//
// Rust는 클로저 자체에서 '-> u32'를 추론할 수 있을 만큼 영리하므로
// 이를 생략할 수도 있었습니다.
//
// TODO: 'move'를 제거해 보고 어떤 일이 일어나는지 확인해 보세요
children.push(thread::spawn(move || -> u32 {
// 이 세그먼트의 중간 합계를 계산합니다:
let result = data_segment
// 세그먼트의 문자들을 순회합니다..
.chars()
// .. 텍스트 문자를 숫자 값으로 변환합니다..
.map(|c| c.to_digit(10).expect("숫자여야 합니다"))
// .. 그리고 결과 숫지 이터레이터의 합을 구합니다
.sum();
// println!은 표준 출력을 잠그므로, 텍스트가 서로 섞이지 않습니다
println!("세그먼트 {} 처리됨, 결과={}", i, result);
// Rust는 "표현식 언어"이므로 "return"이 필요하지 않습니다.
// 각 블록에서 마지막으로 평가된 표현식이 자동으로 그 값이 됩니다.
result
}));
}
/*************************************************************************
* "Reduce" 단계
*
* 중간 결과들을 수집하여 최종 결과로 결합합니다
************************************************************************/
// 각 스레드의 중간 결과들을 하나의 최종 합계로 결합합니다.
//
// `sum()`에 타입 힌트를 제공하기 위해 "터보피쉬(turbofish)" `::<>`를 사용합니다.
//
// TODO: 터보피쉬 대신 `final_result`의 타입을 명시적으로 지정하여 시도해 보세요
let final_result = children.into_iter().map(|c| c.join().unwrap()).sum::<u32>();
println!("최종 합계 결과: {}", final_result);
}
과제
스레드 개수가 사용자 입력 데이터에 의존하게 하는 것은 현명하지 않습니다. 사용자가 많은 공백을 삽입하기로 한다면 어떨까요? 정말로 2,000개의 스레드를 생성하고 싶으신가요? 프로그램 시작 부분에 정의된 정적 상수에 따라 데이터가 항상 제한된 개수의 덩어리로 나뉘도록 프로그램을 수정해 보세요.
참고:
- 스레드
- 벡터와 이터레이터
- 클로저, 이동(move) 의미론 및
move클로저 - 구조 분해(destructuring) 할당
- 타입 추론을 돕기 위한 터보피쉬 표기법
- unwrap vs. expect
- enumerate
채널
Rust는 스레드 간 통신을 위한 비동기 채널(channels)을 제공합니다. 채널은 Sender와 Receiver라는 두 끝점 사이에서 정보의 단방향 흐름을 가능하게 합니다.
use std::sync::mpsc::{Sender, Receiver};
use std::sync::mpsc;
use std::thread;
static NTHREADS: i32 = 3;
fn main() {
// 채널에는 두 끝점이 있습니다: `Sender<T>`와 `Receiver<T>`.
// 여기서 `T`는 전송될 메시지의 타입입니다
// (타입 어노테이션은 불필요합니다)
let (tx, rx): (Sender<i32>, Receiver<i32>) = mpsc::channel();
let mut children = Vec::new();
for id in 0..NTHREADS {
// 발신자(sender) 끝점은 복사될 수 있습니다
let thread_tx = tx.clone();
// 각 스레드는 채널을 통해 자신의 id를 보낼 것입니다
let child = thread::spawn(move || {
// 스레드가 `thread_tx`에 대한 소유권을 가져갑니다
// 각 스레드는 채널에 메시지를 큐에 넣습니다
thread_tx.send(id).unwrap();
// 보내기는 비차단(non-blocking) 연산이므로, 스레드는
// 메시지를 보낸 직후 계속 실행됩니다
println!("{}번 스레드 종료", id);
});
children.push(child);
}
// 여기서 모든 메시지가 수집됩니다
let mut ids = Vec::with_capacity(NTHREADS as usize);
for _ in 0..NTHREADS {
// `recv` 메서드는 채널에서 메시지를 하나 꺼냅니다
// `recv`는 사용 가능한 메시지가 없으면 현재 스레드를 차단(block)합니다
ids.push(rx.recv());
}
// 스레드들이 남은 작업을 모두 완료할 때까지 기다립니다
for child in children {
child.join().expect("이런! 자식 스레드에서 패닉이 발생했습니다");
}
// 메시지가 전송된 순서를 보여줍니다
println!("{:?}", ids);
}
경로
Path 타입은 기저 파일 시스템의 파일 경로를 나타냅니다. 모든 플랫폼에 걸쳐 플랫폼별 경로 의미론과 구분자를 추상화하는 단일 std::path::Path가 존재합니다. 필요할 때 use std::path::Path;를 사용하여 스코프로 가져오세요.
Path는 OsStr로부터 생성될 수 있으며, 경로가 가리키는 파일/디렉터리로부터 정보를 얻기 위한 여러 메서드를 제공합니다.
Path는 불변(immutable)입니다. Path의 소유형(owned) 버전은 PathBuf입니다. Path와 PathBuf의 관계는 str과 String의 관계와 유사합니다. PathBuf는 그 자리에서 수정될 수 있으며, Path로 역참조(dereference)될 수 있습니다.
Path는 내부적으로 UTF-8 문자열로 표현되지 않고, 대신 OsString으로 저장됩니다. 따라서 Path를 &str로 변환하는 것은 비용이 들며 실패할 수도 있습니다(Option이 반환됩니다). 하지만 Path는 각각 into_os_string과 as_os_str을 사용하여 OsString이나 &OsStr로 자유롭게 변환될 수 있습니다.
use std::path::Path;
fn main() {
// &'static str로부터 Path를 생성합니다
let path = Path::new(".");
// `display` 메서드는 `Display` 가능한 구조체를 반환합니다
let _display = path.display();
// `join`은 OS 전용 구분자를 사용하여 경로를 바이트 컨테이너와 병합하고, `PathBuf`를 반환합니다.
let mut new_path = path.join("a").join("b");
// `push`는 `PathBuf`를 `&Path`로 확장합니다
new_path.push("c");
new_path.push("myfile.tar.gz");
// `set_file_name`은 `PathBuf`의 파일 이름을 업데이트합니다
new_path.set_file_name("package.tgz");
// `PathBuf`를 문자열 슬라이스로 변환합니다
match new_path.to_str() {
None => panic!("새 경로가 유효한 UTF-8 시퀀스가 아닙니다"),
Some(s) => println!("새 경로는 {}입니다", s),
}
}
다른 Path 메서드들과 Metadata 구조체도 반드시 확인해 보세요.
참고:
파일 입출력
File 구조체는 열려 있는 파일을 나타내며(파일 디스크립터를 감쌉니다), 기저 파일에 대해 읽기 및/또는 쓰기 접근 권한을 제공합니다.
파일 입출력을 수행할 때는 많은 것들이 잘못될 수 있으므로, 모든 File 메서드는 Result<T, io::Error>의 별칭인 io::Result<T> 타입을 반환합니다.
이는 모든 입출력 연산의 실패를 _명시적_으로 만듭니다. 덕분에 프로그래머는 모든 실패 경로를 확인할 수 있으며, 이를 적극적인 방식으로 처리하도록 권장됩니다.
open
open 함수는 파일을 읽기 전용 모드로 여는 데 사용될 수 있습니다.
File은 리소스인 파일 디스크립터를 소유하며, drop될 때 파일을 닫는 것을 책임집니다.
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
fn main() {
// 원하는 파일에 대한 경로를 생성합니다
let path = Path::new("hello.txt");
let display = path.display();
// 경로를 읽기 전용 모드로 엽니다. `io::Result<File>`을 반환합니다
let mut file = match File::open(&path) {
Err(why) => panic!("{}를 열 수 없습니다: {}", display, why),
Ok(file) => file,
};
// 파일 내용을 문자열로 읽어옵니다. `io::Result<usize>`를 반환합니다
let mut s = String::new();
match file.read_to_string(&mut s) {
Err(why) => panic!("{}를 읽을 수 없습니다: {}", display, why),
Ok(_) => print!("{}의 내용:\n{}", display, s),
}
// `file`이 스코프를 벗어나고, "hello.txt" 파일이 닫힙니다
}
예상되는 성공적인 출력 결과는 다음과 같습니다:
$ echo "Hello World!" > hello.txt
$ rustc open.rs && ./open
hello.txt의 내용:
Hello World!
(이전 예제를 hello.txt가 존재하지 않거나, 읽기 권한이 없는 등 다양한 실패 조건에서 테스트해 보시길 권장합니다.)
create
create 함수는 파일을 쓰기 전용 모드로 엽니다. 파일이 이미 존재한다면 이전 내용은 파괴됩니다. 그렇지 않으면 새로운 파일이 생성됩니다.
static LOREM_IPSUM: &str =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
";
use std::fs::File;
use std::io::prelude::*;
use std::path::Path;
fn main() {
let path = Path::new("lorem_ipsum.txt");
let display = path.display();
// 파일을 쓰기 전용 모드로 엽니다. `io::Result<File>`을 반환합니다
let mut file = match File::create(&path) {
Err(why) => panic!("{}를 생성할 수 없습니다: {}", display, why),
Ok(file) => file,
};
// `LOREM_IPSUM` 문자열을 `file`에 씁니다. `io::Result<()>`를 반환합니다
match file.write_all(LOREM_IPSUM.as_bytes()) {
Err(why) => panic!("{}에 쓸 수 없습니다: {}", display, why),
Ok(_) => println!("{}에 성공적으로 썼습니다", display),
}
}
예상되는 성공적인 출력 결과는 다음과 같습니다:
$ rustc create.rs && ./create
successfully wrote to lorem_ipsum.txt
$ cat lorem_ipsum.txt
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
(이전 예제와 마찬가지로, 이 예제를 실패 조건에서 테스트해 보시길 권장합니다.)
OpenOptions 구조체는 파일이 열리는 방식을 구성하는 데 사용될 수 있습니다.
read_lines
단순한 접근 방식
이는 초보자가 파일에서 줄을 읽기 위해 처음 시도해 볼 만한 합리적인 방식일 수 있습니다.
#![allow(unused)]
fn main() {
use std::fs::read_to_string;
fn read_lines(filename: &str) -> Vec<String> {
let mut result = Vec::new();
for line in read_to_string(filename).unwrap().lines() {
result.push(line.to_string())
}
result
}
}
lines() 메서드가 파일의 줄들에 대한 이터레이터를 반환하므로, 인라인에서 map을 수행하고 결과를 collect하여 더 간결하고 유려한 표현식을 만들 수도 있습니다.
#![allow(unused)]
fn main() {
use std::fs::read_to_string;
fn read_lines(filename: &str) -> Vec<String> {
read_to_string(filename)
.unwrap() // 발생 가능한 파일 읽기 에러에 대해 패닉을 일으킵니다
.lines() // 문자열을 문자열 슬라이스 이터레이터로 분할합니다
.map(String::from) // 각 슬라이스를 String으로 만듭니다
.collect() // 이들을 벡터로 모읍니다
}
}
위의 두 예제 모두에서, lines()가 반환한 &str 참조를 .to_string() 또는 String::from을 사용하여 소유형인 String으로 변환해야 한다는 점에 유의하세요.
더 효율적인 접근 방식
여기서는 열려 있는 File의 소유권을 BufReader 구조체로 넘깁니다. BufReader는 내부 버퍼를 사용하여 중간 할당을 줄입니다.
또한 각 줄마다 메모리에 새로운 String 객체를 할당하는 대신 이터레이터를 반환하도록 read_lines를 업데이트합니다.
use std::fs::File;
use std::io::{self, BufRead};
use std::path::Path;
fn main() {
// hosts.txt 파일이 현재 경로에 존재해야 합니다
if let Ok(lines) = read_lines("./hosts.txt") {
// 이터레이터를 소비하여, (선택적인) String을 반환합니다
for line in lines.map_while(Result::ok) {
println!("{}", line);
}
}
}
// 에러에 대한 매칭이 가능하도록 출력을 `Result`로 감쌉니다.
// 파일의 각 줄을 읽어오는 이터레이터를 반환합니다.
fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
where P: AsRef<Path>, {
let file = File::open(filename)?;
Ok(io::BufReader::new(file).lines())
}
이 프로그램을 실행하면 단순히 각 줄을 개별적으로 출력합니다.
$ echo -e "127.0.0.1\n192.168.0.1\n" > hosts.txt
$ rustc read_lines.rs && ./read_lines
127.0.0.1
192.168.0.1
(File::open이 인자로 제네릭 AsRef<Path>를 요구하므로, 우리의 제네릭 read_lines() 메서드도 where 키워드를 사용하여 동일한 제네릭 제약 조건을 정의한다는 점에 유의하세요.)
이 과정은 파일의 모든 내용을 메모리에 String으로 생성하는 것보다 효율적입니다. 특히 대용량 파일을 다룰 때 메모리에 모두 올리는 방식은 성능 문제를 일으킬 수 있습니다.
자식 프로세스
process::Output 구조체는 종료된 자식 프로세스의 출력을 나타내며, process::Command 구조체는 프로세스 빌더입니다.
use std::process::Command;
fn main() {
let output = Command::new("rustc")
.arg("--version")
.output().unwrap_or_else(|e| {
panic!("프로세스 실행 실패: {}", e)
});
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout);
print!("rustc가 성공했으며 표준 출력은 다음과 같습니다:\n{}", s);
} else {
let s = String::from_utf8_lossy(&output.stderr);
print!("rustc가 실패했으며 표준 에러는 다음과 같습니다:\n{}", s);
}
}
(이전 예제에서 rustc에 잘못된 플래그를 전달하여 시도해 보시길 권장합니다.)
파이프
std::process::Child 구조체는 자식 프로세스를 나타내며, 파이프를 통해 기저 프로세스와 상호작용하기 위한 stdin, stdout, stderr 핸들을 노출합니다.
use std::io::prelude::*;
use std::process::{Command, Stdio};
static PANGRAM: &'static str =
"the quick brown fox jumps over the lazy dog\n";
fn main() {
// `wc` 명령어를 실행(spawn)합니다
let mut cmd = if cfg!(target_family = "windows") {
let mut cmd = Command::new("powershell");
cmd.arg("-Command").arg("$input | Measure-Object -Line -Word -Character");
cmd
} else {
Command::new("wc")
};
let process = match cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn() {
Err(why) => panic!("wc를 실행할 수 없습니다: {}", why),
Ok(process) => process,
};
// `wc`의 표준 입력(stdin)에 문자열을 씁니다.
//
// `stdin`은 `Option<ChildStdin>` 타입이지만, 이 인스턴스에는 반드시
// 존재한다는 것을 알고 있으므로 직접 `unwrap`할 수 있습니다.
match process.stdin.unwrap().write_all(PANGRAM.as_bytes()) {
Err(why) => panic!("wc의 표준 입력에 쓸 수 없습니다: {}", why),
Ok(_) => println!("wc에 팬그램을 보냈습니다"),
}
// 위의 호출 이후에는 `stdin`이 더 이상 살아있지 않으므로 `drop`되고,
// 파이프가 닫힙니다.
//
// 이는 매우 중요합니다. 그렇지 않으면 `wc`가 우리가 보낸 입력을
// 처리하기 시작하지 않을 것이기 때문입니다.
// `stdout` 필드도 `Option<ChildStdout>` 타입이므로 `unwrap`해야 합니다.
let mut s = String::new();
match process.stdout.unwrap().read_to_string(&mut s) {
Err(why) => panic!("wc의 표준 출력을 읽을 수 없습니다: {}", why),
Ok(_) => print!("wc의 응답:\n{}", s),
}
}
대기
process::Child가 종료될 때까지 기다리려면 Child::wait를 호출해야 하며, 이는 process::ExitStatus를 반환합니다.
use std::process::Command;
fn main() {
let mut child = Command::new("sleep").arg("5").spawn().unwrap();
let _result = child.wait().unwrap();
println!("메인의 끝에 도달함");
}
$ rustc wait.rs && ./wait
# `wait`는 `sleep 5` 명령어가 끝날 때까지 5초 동안 계속 실행됩니다
reached end of main
파일시스템 연산
std::fs 모듈은 파일 시스템을 다루는 여러 함수들을 포함하고 있습니다.
use std::fs;
use std::fs::{File, OpenOptions};
use std::io;
use std::io::prelude::*;
#[cfg(target_family = "unix")]
use std::os::unix;
#[cfg(target_family = "windows")]
use std::os::windows;
use std::path::Path;
// `% cat path`를 간단히 구현한 것
fn cat(path: &Path) -> io::Result<String> {
let mut f = File::open(path)?;
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
// `% echo s > path`를 간단히 구현한 것
fn echo(s: &str, path: &Path) -> io::Result<()> {
let mut f = File::create(path)?;
f.write_all(s.as_bytes())
}
// `% touch path`를 간단히 구현한 것 (기존 파일은 무시함)
fn touch(path: &Path) -> io::Result<()> {
match OpenOptions::new().create(true).write(true).open(path) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
fn main() {
println!("`mkdir a`");
// 디렉터리를 생성합니다. `io::Result<()>`를 반환합니다
match fs::create_dir("a") {
Err(why) => println!("! {:?}", why.kind()),
Ok(_) => {},
}
println!("`echo hello > a/b.txt`");
// 이전의 `match`는 `unwrap_or_else` 메서드를 사용하여 단순화될 수 있습니다
echo("안녕", &Path::new("a/b.txt")).unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`mkdir -p a/c/d`");
// 디렉터리를 재귀적으로 생성합니다. `io::Result<()>`를 반환합니다
fs::create_dir_all("a/c/d").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`touch a/c/e.txt`");
touch(&Path::new("a/c/e.txt")).unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`ln -s ../b.txt a/c/b.txt`");
// 심볼릭 링크를 생성합니다. `io::Result<()>`를 반환합니다
#[cfg(target_family = "unix")] {
unix::fs::symlink("../b.txt", "a/c/b.txt").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
}
#[cfg(target_family = "windows")] {
windows::fs::symlink_file("../b.txt", "a/c/b.txt").unwrap_or_else(|why| {
println!("! {:?}", why.to_string());
});
}
println!("`cat a/c/b.txt`");
match cat(&Path::new("a/c/b.txt")) {
Err(why) => println!("! {:?}", why.kind()),
Ok(s) => println!("> {}", s),
}
println!("`ls a`");
// 디렉터리의 내용을 읽습니다. `io::Result<Vec<Path>>`를 반환합니다
match fs::read_dir("a") {
Err(why) => println!("! {:?}", why.kind()),
Ok(paths) => for path in paths {
println!("> {:?}", path.unwrap().path());
},
}
println!("`rm a/c/e.txt`");
// 파일을 제거합니다. `io::Result<()>`를 반환합니다
fs::remove_file("a/c/e.txt").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
println!("`rmdir a/c/d`");
// 빈 디렉터리를 제거합니다. `io::Result<()>`를 반환합니다
fs::remove_dir("a/c/d").unwrap_or_else(|why| {
println!("! {:?}", why.kind());
});
}
예상되는 성공적인 출력 결과는 다음과 같습니다:
$ rustc fs.rs && ./fs
`mkdir a`
`echo hello > a/b.txt`
`mkdir -p a/c/d`
`touch a/c/e.txt`
`ln -s ../b.txt a/c/b.txt`
`cat a/c/b.txt`
> hello
`ls a`
> "a/b.txt"
> "a/c"
`rm a/c/e.txt`
`rmdir a/c/d`
a 디렉터리의 최종 상태는 다음과 같습니다:
$ tree a
a
|-- b.txt
`-- c
`-- b.txt -> ../b.txt
1 directory, 2 files
cat 함수를 정의하는 또 다른 방법은 ? 표기법을 사용하는 것입니다:
fn cat(path: &Path) -> io::Result<String> {
let mut f = File::open(path)?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
참고:
프로그램 인자
표준 라이브러리
커맨드 라인 인자들은 std::env::args를 통해 접근할 수 있으며, 이는 각 인자에 대해 String을 내놓는 이터레이터를 반환합니다:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
// 첫 번째 인자는 프로그램을 호출하는 데 사용된 경로입니다.
println!("제 경로는 {}입니다.", args[0]);
// 나머지 인자들은 전달된 커맨드 라인 파라미터들입니다.
// 프로그램을 다음과 같이 호출해 보세요:
// $ ./args arg1 arg2
println!("{}개의 인자를 받았습니다: {:?}.", args.len() - 1, &args[1..]);
}
$ ./args 1 2 3
제 경로는 ./args입니다.
3개의 인자를 받았습니다: ["1", "2", "3"].
크레이트
또는 커맨드 라인 애플리케이션을 만들 때 추가 기능을 제공하는 수많은 크레이트들이 있습니다. 가장 인기 있는 커맨드 라인 인자 크레이트 중 하나는 clap입니다.
인자 파싱
match를 사용하여 간단한 인자들을 파싱할 수 있습니다:
use std::env;
fn increase(number: i32) {
println!("{}", number + 1);
}
fn decrease(number: i32) {
println!("{}", number - 1);
}
fn help() {
println!("사용법:
match_args <string>
주어진 문자열이 정답인지 확인합니다.
match_args {{increase|decrease}} <integer>
주어진 정수를 1만큼 증가시키거나 감소시킵니다.");
}
fn main() {
let args: Vec<String> = env::args().collect();
match args.len() {
// 전달된 인자가 없음
1 => {
println!("제 이름은 'match_args'입니다. 인자를 전달해 보세요!");
},
// 한 개의 인자가 전달됨
2 => {
match args[1].parse() {
Ok(42) => println!("정답입니다!"),
_ => println!("정답이 아닙니다."),
}
},
// 하나의 명령어와 하나의 인자가 전달됨
3 => {
let cmd = &args[1];
let num = &args[2];
// 숫자를 파싱합니다
let number: i32 = match num.parse() {
Ok(n) => {
n
},
Err(_) => {
eprintln!("에러: 두 번째 인자가 정수가 아닙니다");
help();
return;
},
};
// 명령어를 파싱합니다
match &cmd[..] {
"increase" => increase(number),
"decrease" => decrease(number),
_ => {
eprintln!("에러: 유효하지 않은 명령어");
help();
},
}
},
// 그 외의 모든 경우
_ => {
// 도움말 메시지를 표시함
help();
}
}
}
프로그램 이름을 match_args.rs라고 짓고 rustc match_args.rs와 같이 컴파일했다면, 다음과 같이 실행할 수 있습니다:
$ ./match_args Rust
This is not the answer.
$ ./match_args 42
This is the answer!
$ ./match_args do something
error: second argument not an integer
usage:
match_args <string>
Check whether given string is the answer.
match_args {increase|decrease} <integer>
Increase or decrease given integer by one.
$ ./match_args do 42
error: invalid command
usage:
match_args <string>
Check whether given string is the answer.
match_args {increase|decrease} <integer>
Increase or decrease given integer by one.
$ ./match_args increase 42
43
외부 함수 인터페이스
Rust는 C 라이브러리에 대한 외부 함수 인터페이스(Foreign Function Interface, FFI)를 제공합니다. 외부 함수는 외부 라이브러리의 이름이 포함된 #[link] 속성이 달린 extern 블록 내에 선언되어야 합니다.
use std::fmt;
// 이 extern 블록은 libm 라이브러리에 링크됩니다
#[cfg(target_family = "windows")]
#[link(name = "msvcrt")]
extern {
// 이는 단정도 복소수의 제곱근을 계산하는
// 외부 함수입니다
fn csqrtf(z: Complex) -> Complex;
fn ccosf(z: Complex) -> Complex;
}
#[cfg(target_family = "unix")]
#[link(name = "m")]
extern {
// 이는 단정도 복소수의 제곱근을 계산하는
// 외부 함수입니다
fn csqrtf(z: Complex) -> Complex;
fn ccosf(z: Complex) -> Complex;
}
// 외부 함수를 호출하는 것은 안전하지 않은(unsafe) 것으로 간주되므로,
// 이를 감싸는 안전한 래퍼(wrapper)를 작성하는 것이 일반적입니다.
fn cos(z: Complex) -> Complex {
unsafe { ccosf(z) }
}
fn main() {
// z = -1 + 0i
let z = Complex { re: -1., im: 0. };
// 외부 함수를 호출하는 것은 안전하지 않은(unsafe) 작업입니다
let z_sqrt = unsafe { csqrtf(z) };
println!("{:?}의 제곱근은 {:?}입니다", z, z_sqrt);
// unsafe 연산을 감싼 안전한 API 호출
println!("cos({:?}) = {:?}", z, cos(z));
}
// 단정밀도 복소수의 최소 구현
#[repr(C)]
#[derive(Clone, Copy)]
struct Complex {
re: f32,
im: f32,
}
impl fmt::Debug for Complex {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
if self.im < 0. {
write!(f, "{}-{}i", self.re, -self.im)
} else {
write!(f, "{}+{}i", self.re, self.im)
}
}
}
테스트
Rust는 올바름(correctness)을 매우 중요하게 생각하는 프로그래밍 언어이며, 언어 자체에서 소프트웨어 테스트 작성을 지원합니다.
테스트는 세 가지 스타일로 제공됩니다:
- 유닛(Unit) 테스트.
- 문서(Doc) 테스트.
- 통합(Integration) 테스트.
또한 Rust는 테스트를 위한 추가 의존성 지정을 지원합니다:
참고
유닛 테스트
테스트는 테스트 대상 코드가 예상대로 작동하는지 확인하는 Rust 함수입니다. 테스트 함수의 본문은 일반적으로 설정을 수행하고, 테스트하려는 코드를 실행한 다음, 결과가 예상과 일치하는지 단언(assert)합니다.
대부분의 유닛 테스트는 #[cfg(test)] 속성이 있는 tests 모듈에 들어갑니다. 테스트 함수는 #[test] 속성으로 표시됩니다.
테스트 함수 내에서 패닉이 발생하면 테스트가 실패합니다. 다음과 같은 몇 가지 도우미 매크로가 있습니다:
assert!(expression)- 표현식의 평가 결과가false이면 패닉을 발생시킵니다.assert_eq!(left, right)및assert_ne!(left, right)- 왼쪽과 오른쪽 표현식이 각각 같은지 또는 다른지를 테스트합니다.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// 이것은 정말 형편없는 더하기 함수입니다. 이 예제에서 실패하도록 만드는 것이 목적입니다.
#[allow(dead_code)]
fn bad_add(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
// 이 유용한 관용구를 기억하세요: 외부 스코프(mod tests의 경우)에서 이름들을 가져옵니다.
use super::*;
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
#[test]
fn test_bad_add() {
// 이 단언(assert)은 실행될 것이고 테스트는 실패할 것입니다.
// 참고로, 프라이빗 함수도 테스트할 수 있습니다!
assert_eq!(bad_add(1, 2), 3);
}
}
테스트는 cargo test로 실행할 수 있습니다.
$ cargo test
running 2 tests
test tests::test_bad_add ... FAILED
test tests::test_add ... ok
failures:
---- tests::test_bad_add stdout ----
thread 'tests::test_bad_add' panicked at 'assertion failed: `(left == right)`
left: `-1`,
right: `3`', src/lib.rs:21:8
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
tests::test_bad_add
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
테스트와 ?
이전의 유닛 테스트 예제들에는 반환 타입이 없었습니다. 하지만 Rust 2018에서는 유닛 테스트가 Result<()>를 반환할 수 있어, 테스트 내에서 ?를 사용할 수 있습니다! 이를 통해 테스트를 훨씬 더 간결하게 만들 수 있습니다.
fn sqrt(number: f64) -> Result<f64, String> {
if number >= 0.0 {
Ok(number.powf(0.5))
} else {
Err("음수 부동 소수점은 제곱근을 가질 수 없습니다".to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sqrt() -> Result<(), String> {
let x = 4.0;
assert_eq!(sqrt(x)?.powf(2.0), x);
Ok(())
}
}
더 자세한 내용은 “The Edition Guide”를 참조하세요.
패닉 테스트
특정 상황에서 패닉이 발생해야 하는 함수를 확인하려면 #[should_panic] 속성을 사용합니다. 이 속성은 선택적 파라미터 expected = 를 받으며, 패닉 메시지의 텍스트를 확인합니다. 만약 여러분의 함수가 여러 방식으로 패닉을 일으킬 수 있다면, 이 파라미터는 예상한 패닉이 발생했는지 확인하는 데 도움이 됩니다.
참고: Rust는 #[should_panic = "message"]와 같은 단축 형태도 허용하며, 이는 #[should_panic(expected = "message")]와 정확히 똑같이 작동합니다. 둘 다 유효하지만, 후자가 더 일반적으로 사용되며 더 명시적인 것으로 간주됩니다.
pub fn divide_non_zero_result(a: u32, b: u32) -> u32 {
if b == 0 {
panic!("0으로 나누기 에러");
} else if a < b {
panic!("나눗셈 결과가 0입니다");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_divide() {
assert_eq!(divide_non_zero_result(10, 2), 5);
}
#[test]
#[should_panic]
fn test_any_panic() {
divide_non_zero_result(1, 0);
}
#[test]
#[should_panic(expected = "나눗셈 결과가 0입니다")]
fn test_specific_panic() {
divide_non_zero_result(1, 10);
}
#[test]
#[should_panic = "나눗셈 결과가 0입니다"] // 이 방식도 작동합니다
fn test_specific_panic_shorthand() {
divide_non_zero_result(1, 10);
}
}
이 테스트들을 실행하면 다음과 같은 결과를 얻습니다:
$ cargo test
running 4 tests
test tests::test_any_panic ... ok
test tests::test_divide ... ok
test tests::test_specific_panic ... ok
test tests::test_specific_panic_shorthand ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
특정 테스트 실행하기
특정 테스트를 실행하려면 cargo test 명령에 테스트 이름을 지정하면 됩니다.
$ cargo test test_any_panic
running 1 test
test tests::test_any_panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
여러 테스트를 실행하려면 실행하려는 모든 테스트와 매칭되는 테스트 이름의 일부분을 지정하면 됩니다.
$ cargo test panic
running 3 tests
test tests::test_any_panic ... ok
test tests::test_specific_panic ... ok
test tests::test_specific_panic_shorthand ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out
Doc-tests tmp-test-should-panic
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
테스트 무시하기
일부 테스트를 제외하기 위해 #[ignore] 속성을 표시할 수 있습니다. 또는 cargo test -- --ignored 명령으로 무시된 테스트들만 실행할 수도 있습니다.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
#[test]
fn test_add_hundred() {
assert_eq!(add(100, 2), 102);
assert_eq!(add(2, 100), 102);
}
#[test]
#[ignore]
fn ignored_test() {
assert_eq!(add(0, 0), 0);
}
}
$ cargo test
running 3 tests
test tests::ignored_test ... ignored
test tests::test_add ... ok
test tests::test_add_hundred ... ok
test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out
Doc-tests tmp-ignore
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo test -- --ignored
running 1 test
test tests::ignored_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests tmp-ignore
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
문서 테스트
Rust 프로젝트를 문서화하는 기본 방법은 소스 코드에 주석을 다는 것입니다. 문서화 주석은 CommonMark Markdown 명세로 작성되며 코드 블록을 지원합니다. Rust는 올바름(correctness)을 중요하게 생각하므로, 이러한 코드 블록들은 컴파일되어 문서 테스트(documentation tests)로 사용됩니다.
/// 첫 번째 줄은 함수를 설명하는 짧은 요약입니다.
///
/// 다음 줄들은 상세한 문서화를 보여줍니다. 코드 블록은 백틱 세 개로 시작하며,
/// 내부적으로 암시적인 `fn main()`과 `extern crate <cratename>`을 가집니다.
/// `playground` 라이브러리 크레이트를 테스트하거나 플레이그라운드의
/// Test 액션을 사용하는 예시라고 가정해 봅시다:
///
/// ```
/// let result = playground::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// 일반적으로 문서 주석에는 "Examples", "Panics", "Failures" 섹션이 포함될 수 있습니다.
///
/// 다음 함수는 두 수를 나눕니다.
///
/// # Examples
///
/// ```
/// let result = playground::div(10, 2);
/// assert_eq!(result, 5);
/// ```
///
/// # Panics
///
/// 두 번째 인자가 0이면 함수가 패닉을 발생시킵니다.
///
/// ```rust,should_panic
/// // 0으로 나누기 시 패닉 발생
/// playground::div(10, 0);
/// ```
pub fn div(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("0으로 나누기 에러");
}
a / b
}
문서 내의 코드 블록은 일반적인 cargo test 명령을 실행할 때 자동으로 테스트됩니다:
$ cargo test
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests playground
running 3 tests
test src/lib.rs - add (line 7) ... ok
test src/lib.rs - div (line 21) ... ok
test src/lib.rs - div (line 31) ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
문서 테스트의 의도
문서 테스트의 주된 목적은 기능을 실행해보는 예시로서의 역할을 하는 것이며, 이는 가장 중요한 가이드라인 중 하나입니다. 이를 통해 문서의 예시를 완전한 코드 스니펫으로 사용할 수 있습니다. 하지만 main이 유닛 타입(unit)을 반환하기 때문에 ?를 사용하면 컴파일에 실패합니다. 이때 문서에서 일부 소스 라인을 숨길 수 있는 기능이 도움이 됩니다. fn try_main() -> Result<(), ErrorType>을 작성하고, 이를 숨긴 뒤 숨겨진 main에서 unwrap 할 수 있습니다. 복잡하게 들리나요? 여기 예시가 있습니다:
/// 문서 테스트에서 숨겨진 `try_main` 사용하기.
///
/// ```
/// # // `#` 기호로 시작하는 줄은 숨겨지지만, 여전히 컴파일 가능합니다!
/// # fn try_main() -> Result<(), String> { // 문서에 보여지는 본문을 감싸는 줄
/// let res = playground::try_div(10, 2)?;
/// # Ok(()) // try_main에서 반환
/// # }
/// # fn main() { // unwrap()을 호출할 main 시작
/// # try_main().unwrap(); // try_main을 호출하고 unwrap하여
/// # // 에러 발생 시 테스트가 패닉을 일으키도록 함
/// # }
/// ```
pub fn try_div(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("0으로 나누기"))
} else {
Ok(a / b)
}
}
참고
통합 테스트
유닛 테스트는 한 번에 하나의 모듈을 격리하여 테스트합니다. 유닛 테스트는 크기가 작고 프라이빗 코드도 테스트할 수 있습니다. 통합 테스트는 크레이트 외부에 존재하며, 다른 코드와 마찬가지로 공개(public) 인터페이스만 사용합니다. 통합 테스트의 목적은 라이브러리의 여러 부분이 함께 올바르게 작동하는지 테스트하는 것입니다.
Cargo는 src 옆의 tests 디렉터리에서 통합 테스트를 찾습니다.
src/lib.rs 파일:
// `adder`라는 이름의 크레이트에 이를 정의합니다.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
테스트 파일: tests/integration_test.rs:
#[test]
fn test_add() {
assert_eq!(adder::add(3, 2), 5);
}
cargo test 명령으로 테스트 실행하기:
$ cargo test
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-bcd60824f5fbfe19
running 1 test
test test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
통합 테스트 간에 일부 코드를 공유하기 위해 공개 함수가 있는 모듈을 만들고, 이를 테스트 내에서 가져와 사용할 수 있습니다.
tests/common/mod.rs 파일:
pub fn setup() {
// 필요한 파일/디렉터리 생성, 서버 시작 등과 같은 일부 설정 코드
}
테스트 파일: tests/integration_test.rs
// common 모듈 가져오기.
mod common;
#[test]
fn test_add() {
// 공통 코드 사용하기.
common::setup();
assert_eq!(adder::add(3, 2), 5);
}
tests/common.rs로 모듈을 만드는 것도 가능하지만, 테스트 러너가 이 파일을 테스트 크레이트로 취급하여 내부의 테스트를 실행하려 하므로 권장되지 않습니다.
개발 의존성
가끔 테스트(또는 예제, 벤치마크)만을 위한 의존성이 필요할 때가 있습니다. 이러한 의존성은 Cargo.toml의 [dev-dependencies] 섹션에 추가됩니다. 이 의존성들은 이 패키지에 의존하는 다른 패키지들로는 전파되지 않습니다.
그러한 예 중 하나로 표준 assert_eq! 및 assert_ne! 매크로를 확장하여 색상화된 diff를 제공하는 pretty_assertions가 있습니다. Cargo.toml 파일:
# 표준 크레이트 데이터는 생략되었습니다
[dev-dependencies]
pretty_assertions = "1"
src/lib.rs 파일:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq; // 테스트 전용으로만 사용되는 크레이트입니다. 테스트가 아닌 코드에서는 사용할 수 없습니다.
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
참고
의존성 지정에 관한 Cargo 문서.
Unsafe 연산
이 섹션을 시작하며 공식 문서의 말을 빌리자면, “코드베이스에서 unsafe 코드의 양을 최소화하도록 노력해야 합니다.” 이 점을 염두에 두고 시작해 봅시다! Rust에서 Unsafe 어노테이션은 컴파일러가 제공하는 보호 기능을 우회하는 데 사용됩니다. 구체적으로, unsafe는 다음과 같은 네 가지 주요 작업에 사용됩니다:
- raw 포인터 역참조하기
unsafe함수나 메서드 호출하기 (FFI를 통한 함수 호출 포함, 이 책의 이전 장 참조)- 정적 가변(static mutable) 변수에 접근하거나 수정하기
- unsafe 트레이트 구현하기
Raw 포인터
Raw 포인터 *와 참조 &T는 비슷하게 작동하지만, 참조는 빌림 검사기(borrow checker) 덕분에 항상 유효한 데이터를 가리킨다는 것이 보장되므로 항상 안전합니다. Raw 포인터의 역참조는 unsafe 블록을 통해서만 수행될 수 있습니다.
fn main() {
let raw_p: *const u32 = &10;
unsafe {
assert!(*raw_p == 10);
}
}
Unsafe 함수 호출하기
일부 함수는 unsafe로 선언될 수 있는데, 이는 컴파일러 대신 프로그래머가 올바름을 보장해야 할 책임이 있음을 의미합니다. 그 예로 첫 번째 요소에 대한 포인터와 길이를 받아 슬라이스를 생성하는 std::slice::from_raw_parts가 있습니다.
use std::slice;
fn main() {
let some_vector = vec![1, 2, 3, 4];
let pointer = some_vector.as_ptr();
let length = some_vector.len();
unsafe {
let my_slice: &[u32] = slice::from_raw_parts(pointer, length);
assert_eq!(some_vector.as_slice(), my_slice);
}
}
slice::from_raw_parts의 경우, 반드시 지켜져야 하는 가정 중 하나는 전달된 포인터가 유효한 메모리를 가리키고 가리키는 메모리가 올바른 타입이어야 한다는 것입니다. 이러한 불변성(invariant)이 지켜지지 않으면 프로그램의 동작은 정의되지 않으며 어떤 일이 일어날지 알 수 없습니다.
인라인 어셈블리
Rust는 asm! 매크로를 통해 인라인 어셈블리를 지원합니다. 이를 통해 컴파일러가 생성하는 어셈블리 출력에 수기로 작성한 어셈블리를 삽입할 수 있습니다. 일반적으로 이는 필요하지 않지만, 다른 방법으로는 필요한 성능이나 타이밍을 달성할 수 없는 경우에 유용할 수 있습니다. 커널 코드와 같은 저수준 하드웨어 프리미티브에 접근하는 경우에도 이 기능이 필요할 수 있습니다.
참고: 여기의 예제들은 x86/x86-64 어셈블리로 제공되지만, 다른 아키텍처도 지원됩니다.
인라인 어셈블리는 현재 다음 아키텍처에서 지원됩니다:
- x86 및 x86-64
- ARM
- AArch64
- RISC-V
기본 사용법
가장 간단한 예제부터 시작해 봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
unsafe {
asm!("nop");
}
}
}
이 코드는 컴파일러가 생성한 어셈블리에 NOP (no operation) 명령어를 삽입합니다. asm! 호출은 임의의 명령어를 삽입하여 다양한 불변성을 깰 수 있으므로 모두 unsafe 블록 안에 있어야 한다는 점에 유의하세요. 삽입할 명령어는 asm! 매크로의 첫 번째 인자에 문자열 리터럴로 나열됩니다.
입력과 출력
아무것도 하지 않는 명령어를 넣는 것은 꽤 지루합니다. 실제로 데이터에 작용하는 무언가를 해봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 5", out(reg) x);
}
assert_eq!(x, 5);
}
}
이 코드는 값 5를 u64 변수 x에 씁니다. 명령어를 지정하는 데 사용하는 문자열 리터럴이 실제로는 템플릿 문자열이라는 것을 알 수 있습니다. 이는 Rust 형식 문자열과 동일한 규칙의 적용을 받습니다. 하지만 템플릿에 삽입되는 인자들은 여러분에게 익숙한 것과는 조금 달라 보일 수 있습니다. 먼저 변수가 인라인 어셈블리의 입력인지 출력인지를 지정해야 합니다. 이 경우에는 출력입니다. 우리는 out을 작성하여 이를 선언했습니다. 또한 어셈블리가 변수를 어떤 종류의 레지스터에 두기를 기대하는지도 지정해야 합니다. 이 경우에는 reg를 지정하여 임의의 범용 레지스터에 넣었습니다. 컴파일러는 템플릿에 삽입할 적절한 레지스터를 선택하고, 인라인 어셈블리 실행이 끝난 후 해당 레지스터에서 변수를 읽어올 것입니다.
입력을 사용하는 또 다른 예제를 살펴봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let i: u64 = 3;
let o: u64;
unsafe {
asm!(
"mov {0}, {1}",
"add {0}, 5",
out(reg) o,
in(reg) i,
);
}
assert_eq!(o, 8);
}
}
이 코드는 변수 i의 입력에 5를 더하고 그 결과를 변수 o에 씁니다. 이 어셈블리가 이를 수행하는 구체적인 방식은 먼저 i의 값을 출력으로 복사한 다음, 거기에 5를 더하는 것입니다.
이 예제는 몇 가지를 보여줍니다:
첫째, asm!이 여러 템플릿 문자열 인자를 허용한다는 것을 알 수 있습니다. 각각은 별도의 어셈블리 코드 줄로 처리되며, 마치 줄바꿈으로 모두 연결된 것처럼 작동합니다. 이를 통해 어셈블리 코드를 쉽게 포맷팅할 수 있습니다.
둘째, out 대신 in을 작성하여 입력을 선언한다는 것을 알 수 있습니다.
셋째, 다른 형식 문자열에서처럼 인자 번호나 이름을 지정할 수 있음을 알 수 있습니다. 인라인 어셈블리 템플릿에서는 인자가 한 번 이상 사용되는 경우가 많기 때문에 이 기능이 특히 유용합니다. 복잡한 인라인 어셈블리의 경우 이 기능을 사용하는 것이 일반적으로 권장되는데, 가독성을 높이고 인자 순서를 변경하지 않고도 명령어를 재배치할 수 있게 해주기 때문입니다.
위의 예제를 더 개선하여 mov 명령어를 피할 수 있습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut x: u64 = 3;
unsafe {
asm!("add {0}, 5", inout(reg) x);
}
assert_eq!(x, 8);
}
}
inout이 입력과 출력 모두로 사용되는 인자를 지정하는 데 사용됨을 알 수 있습니다. 이는 입력과 출력을 따로 지정하는 것과 다른데, inout은 두 경우 모두 동일한 레지스터에 할당됨을 보장하기 때문입니다.
inout 피연산자의 입력과 출력 부분에 대해 서로 다른 변수를 지정하는 것도 가능합니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let x: u64 = 3;
let y: u64;
unsafe {
asm!("add {0}, 5", inout(reg) x => y);
}
assert_eq!(y, 8);
}
}
늦은(Late) 출력 피연산자
The Rust compiler is conservative with its allocation of operands. It is assumed that an out can be written at any time, and can therefore not share its location with any other argument. However, to guarantee optimal performance it is important to use as few registers as possible, so they won’t have to be saved and reloaded around the inline assembly block. To achieve this Rust provides a lateout specifier. This can be used on any output that is written only after all inputs have been consumed. There is also an inlateout variant of this specifier.
release 모드나 기타 최적화된 경우에 inlateout을 사용할 수 없는 예시는 다음과 같습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
let c: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
"add {0}, {2}",
inout(reg) a,
in(reg) b,
in(reg) c,
);
}
assert_eq!(a, 12);
}
}
최적화되지 않은 경우(예: Debug 모드), 위 예제에서 inout(reg) a를 inlateout(reg) a로 대체해도 계속해서 예상된 결과를 얻을 수 있습니다. 그러나 release 모드나 기타 최적화된 경우에는 inlateout(reg) a를 사용하면 최종 값이 a = 16이 되어 단언(assertion)이 실패할 수 있습니다.
이는 최적화된 경우 컴파일러가 입력 b와 c에 대해 동일한 값을 가지고 있음을 알고 있으므로 동일한 레지스터를 할당할 수 있기 때문입니다. 또한 inlateout이 사용될 때 a와 c가 동일한 레지스터에 할당될 수 있는데, 이 경우 첫 번째 add 명령어가 변수 c로부터의 초기 로드 값을 덮어쓰게 됩니다. 이는 inout(reg) a를 사용하여 a에 대해 별도의 레지스터가 할당되도록 보장하는 것과 대조적입니다.
하지만 다음 예제에서는 모든 입력 레지스터를 읽은 후에만 출력이 수정되므로 inlateout을 사용할 수 있습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!("add {0}, {1}", inlateout(reg) a, in(reg) b);
}
assert_eq!(a, 8);
}
}
보시다시피, a와 b가 동일한 레지스터에 할당되더라도 이 어셈블리 조각은 여전히 올바르게 작동할 것입니다.
명시적 레지스터 피연산자
일부 명령어는 피연산자가 특정 레지스터에 있어야 함을 요구합니다. 따라서 Rust 인라인 어셈블리는 몇 가지 더 구체적인 제약 지정자를 제공합니다. reg는 일반적으로 모든 아키텍처에서 사용할 수 있지만, 명시적 레지스터는 아키텍처에 따라 매우 다릅니다. 예를 들어 x86의 경우 eax, ebx, ecx, edx, ebp, esi, edi 등의 범용 레지스터를 이름으로 지정할 수 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let cmd = 0xd1;
unsafe {
asm!("out 0x64, eax", in("eax") cmd);
}
}
}
이 예제에서는 out 명령어를 호출하여 cmd 변수의 내용을 포트 0x64로 출력합니다. out 명령어는 eax (및 그 하위 레지스터)만을 피연산자로 받아들이므로 eax 제약 지정자를 사용해야 했습니다.
참고: 다른 피연산자 타입과 달리 명시적 레지스터 피연산자는 템플릿 문자열에서 사용할 수 없습니다.
{}를 사용할 수 없으며 대신 레지스터 이름을 직접 작성해야 합니다. 또한 다른 모든 피연산자 타입 뒤에 피연산자 목록의 마지막에 나타나야 합니다.
x86 mul 명령어를 사용하는 이 예제를 살펴보세요:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
fn mul(a: u64, b: u64) -> u128 {
let lo: u64;
let hi: u64;
unsafe {
asm!(
// x86 mul 명령어는 rax를 암시적 입력으로 받고
// 128비트 곱셈 결과를 rax:rdx에 씁니다.
"mul {}",
in(reg) a,
inlateout("rax") b => lo,
lateout("rdx") hi
);
}
((hi as u128) << 64) + lo as u128
}
}
}
이 코드는 mul 명령어를 사용하여 두 개의 64비트 입력을 곱해 128비트 결과를 얻습니다. 유일한 명시적 피연산자는 레지스터이며, 변수 a로 채웁니다. 두 번째 피연산자는 암시적이며 rax 레지스터여야 하는데, 이는 변수 b로 채웁니다. 결과의 하위 64비트는 rax에 저장되어 변수 lo를 채우고, 상위 64비트는 rdx에 저장되어 변수 hi를 채웁니다.
클로버(Clobbered) 레지스터
많은 경우 인라인 어셈블리는 출력으로 필요하지 않은 상태를 수정합니다. 보통 어셈블리 내에서 스크래치 레지스터를 사용해야 하거나, 명령어가 더 이상 검사할 필요가 없는 상태를 수정하기 때문입니다. 이러한 상태를 일반적으로 “클로버(clobbered)” 되었다고 합니다. 컴파일러가 인라인 어셈블리 블록 전후로 이러한 상태를 저장하고 복원해야 할 수도 있으므로 이를 컴파일러에 알려야 합니다.
use std::arch::asm;
#[cfg(target_arch = "x86_64")]
fn main() {
// 각각 4바이트인 3개의 엔트리
let mut name_buf = [0_u8; 12];
// 문자열은 ebx, edx, ecx 순서대로 ASCII로 저장됩니다.
// ebx는 예약되어 있으므로, 어셈블리는 이 값을 보존해야 합니다.
// 따라서 메인 어셈블리 전후로 push와 pop을 수행합니다.
// 64비트 프로세서의 64비트 모드에서는 32비트 레지스터(ebx와 같은)를
// push/pop 하는 것을 허용하지 않으므로, 대신 확장된 rbx 레지스터를 사용해야 합니다.
unsafe {
asm!(
"push rbx",
"cpuid",
"mov [rdi], ebx",
"mov [rdi + 4], edx",
"mov [rdi + 8], ecx",
"pop rbx",
// 몇 가지 어셈블리 명령어를 더 사용하는 대신 Rust 코드를 단순화하기 위해
// 값을 저장하는 데 배열 포인터를 사용합니다.
// 하지만 이는 `out("ecx") val`과 같은 명시적 레지스터 출력과는 반대로
// 어셈블리가 어떻게 작동하는지 더 명시적으로 보여줍니다.
// *포인터 자체*는 비록 그 뒤에 값이 쓰여지더라도 입력일 뿐입니다.
in("rdi") name_buf.as_mut_ptr(),
// cpuid 0을 선택하고, eax를 클로버된 것으로 지정합니다.
inout("eax") 0 => _,
// cpuid는 이 레지스터들도 클로버합니다.
out("ecx") _,
out("edx") _,
);
}
let name = core::str::from_utf8(&name_buf).unwrap();
println!("CPU 제조사 ID: {}", name);
}
#[cfg(not(target_arch = "x86_64"))]
fn main() {}
위의 예제에서는 cpuid 명령어를 사용하여 CPU 제조사 ID를 읽습니다. 이 명령어는 eax에 지원되는 최대 cpuid 인자를 쓰고, ebx, edx, ecx 순서대로 CPU 제조사 ID를 ASCII 바이트로 씁니다.
eax는 결코 읽히지 않지만, 컴파일러가 어셈블리 이전의 레지스터 값을 저장할 수 있도록 레지스터가 수정되었음을 알려야 합니다. 이는 출력으로 선언하되 변수 이름 대신 _를 사용하여 수행하며, 이는 출력 값을 버림을 나타냅니다.
이 코드는 ebx가 LLVM에 의해 예약된 레지스터라는 제한 사항도 해결합니다. 이는 LLVM이 해당 레지스터를 완전히 제어한다고 가정하며 어셈블리 블록을 종료하기 전에 원래 상태로 복원해야 함을 의미합니다. 따라서 컴파일러가 일반 레지스터 클래스(예: in(reg))를 충족하기 위해 사용하는 경우를 제외하고는 입력이나 출력으로 사용할 수 없습니다. 이로 인해 예약된 레지스터를 사용할 때 reg 피연산자는 위험할 수 있는데, 동일한 레지스터를 공유하여 자신도 모르게 입력이나 출력을 손상시킬 수 있기 때문입니다.
이를 해결하기 위해 rdi를 사용하여 출력 배열에 대한 포인터를 저장하고, push를 통해 ebx를 저장한 후, 어셈블리 블록 내에서 ebx로부터 배열로 읽어 들인 다음, pop을 통해 ebx를 원래 상태로 복원합니다. 전체 레지스터가 저장되도록 push와 pop은 레지스터의 64비트 버전인 rbx를 사용합니다. 32비트 타겟의 경우 코드에서 대신 ebx를 push/pop에 사용할 것입니다.
이는 어셈블리 코드 내부에서 사용할 스크래치 레지스터를 얻기 위해 일반 레지스터 클래스와 함께 사용될 수도 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
// 시프트와 더하기를 사용하여 x에 6을 곱함
let mut x: u64 = 4;
unsafe {
asm!(
"mov {tmp}, {x}",
"shl {tmp}, 1",
"shl {x}, 2",
"add {x}, {tmp}",
x = inout(reg) x,
tmp = out(reg) _,
);
}
assert_eq!(x, 4 * 6);
}
}
심볼 피연산자와 ABI 클로버
기본적으로 asm!은 출력으로 지정되지 않은 모든 레지스터의 내용이 어셈블리 코드에 의해 보존된다고 가정합니다. asm!의 clobber_abi 인자는 컴파일러가 주어진 호출 규약 ABI에 따라 필요한 클로버 피연산자를 자동으로 삽입하도록 지시합니다. 해당 ABI에서 완전히 보존되지 않는 모든 레지스터는 클로버된 것으로 취급됩니다. 여러 clobber_abi 인자를 제공할 수 있으며, 지정된 모든 ABI의 모든 클로버가 삽입됩니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
extern "C" fn foo(arg: i32) -> i32 {
println!("인자 = {}", arg);
arg * 2
}
fn call_foo(arg: i32) -> i32 {
unsafe {
let result;
asm!(
"call {}",
// 호출할 함수 포인터
in(reg) foo,
// 첫 번째 인자는 rdi에
in("rdi") arg,
// 반환 값은 rax에
out("rax") result,
// "C" 호출 규약에 의해 보존되지 않는 모든 레지스터를 클로버된 것으로 표시합니다.
clobber_abi("C"),
);
result
}
}
}
}
레지스터 템플릿 수정자
어떤 경우에는 템플릿 문자열에 삽입될 때 레지스터 이름이 포맷팅되는 방식에 대해 세밀한 제어가 필요합니다. 이는 아키텍처의 어셈블리 언어에 동일한 레지스터에 대한 여러 이름이 있을 때 필요하며, 각 이름은 일반적으로 레지스터 일부에 대한 “뷰(view)“입니다(예: 64비트 레지스터의 하위 32비트).
기본적으로 컴파일러는 항상 전체 레지스터 크기를 참조하는 이름을 선택합니다(예: x86-64의 rax, x86의 eax 등).
이 기본값은 형식 문자열에서처럼 템플릿 문자열 피연산자에 수정자를 사용하여 재정의할 수 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut x: u16 = 0xab;
unsafe {
asm!("mov {0:h}, {0:l}", inout(reg_abcd) x);
}
assert_eq!(x, 0xabab);
}
}
이 예제에서는 reg_abcd 레지스터 클래스를 사용하여 레지스터 할당자를 처음 두 바이트를 독립적으로 주소 지정할 수 있는 4개의 레거시 x86 레지스터(ax, bx, cx, dx)로 제한합니다.
레지스터 할당자가 x를 ax 레지스터에 할당하기로 선택했다고 가정해 봅시다. h 수정자는 해당 레지스터의 상위 바이트에 대한 레지스터 이름을 출력하고, l 수정자는 하위 바이트에 대한 레지스터 이름을 출력합니다. 따라서 어셈블리 코드는 mov ah, al로 확장되어 값의 하위 바이트를 상위 바이트로 복사합니다.
피연산자와 함께 더 작은 데이터 타입(예: u16)을 사용하면서 템플릿 수정자를 사용하는 것을 잊은 경우, 컴파일러는 경고를 내보내고 사용할 올바른 수정자를 제안합니다.
메모리 주소 피연산자
때로는 어셈블리 명령어가 메모리 주소/메모리 위치를 통해 전달되는 피연산자를 요구합니다. 타겟 아키텍처에 지정된 메모리 주소 구문을 수동으로 사용해야 합니다. 예를 들어, Intel 어셈블리 구문을 사용하는 x86/x86_64에서는 입력/출력을 []로 감싸서 메모리 피연산자임을 나타내야 합니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
fn load_fpu_control_word(control: u16) {
unsafe {
asm!("fldcw [{}]", in(reg) &control, options(nostack));
}
}
}
}
레이블
이름이 지정된 레이블(로컬이든 아니든)을 재사용하면 어셈블러나 링커 오류가 발생하거나 기타 이상한 동작이 발생할 수 있습니다. 이름이 지정된 레이블의 재사용은 다음과 같은 다양한 방식으로 발생할 수 있습니다.
- 명시적으로: 하나의
asm!블록에서 레이블을 두 번 이상 사용하거나, 블록 간에 여러 번 사용하는 경우. - 인라이닝을 통한 암시적 방식: 컴파일러는
asm!블록의 여러 복사본을 인스턴스화할 수 있습니다. 예를 들어 해당 블록을 포함하는 함수가 여러 위치에서 인라이닝되는 경우가 이에 해당합니다. - LTO를 통한 암시적 방식: LTO는 _다른 크레이트_의 코드를 동일한 코드 생성 단위에 배치할 수 있으며, 이로 인해 임의의 레이블이 유입될 수 있습니다.
결과적으로, 인라인 어셈블리 코드 내부에서는 GNU 어셈블러 숫자 로컬 레이블만 사용해야 합니다. 어셈블리 코드에서 심볼을 정의하면 심볼 중복 정의로 인해 어셈블러 및/또는 링커 오류가 발생할 수 있습니다.
또한 x86에서 기본 Intel 구문을 사용할 때, LLVM 버그로 인해 0, 11 또는 101010과 같이 0과 1 숫자로만 구성된 레이블은 사용하지 말아야 합니다. 이는 바이너리 값으로 해석될 수 있기 때문입니다. options(att_syntax)를 사용하면 모호함을 피할 수 있지만, 이는 asm! 블록 _전체_의 구문에 영향을 미칩니다. (options에 대한 자세한 내용은 아래의 옵션 섹션을 참조하세요.)
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a = 0;
unsafe {
asm!(
"mov {0}, 10",
"2:",
"sub {0}, 1",
"cmp {0}, 3",
"jle 2f",
"jmp 2b",
"2:",
"add {0}, 2",
out(reg) a
);
}
assert_eq!(a, 5);
}
}
이 코드는 {0} 레지스터 값을 10에서 3으로 감소시킨 다음, 2를 더하고 그 결과를 a에 저장합니다.
이 예제는 몇 가지를 보여줍니다:
- 첫째, 동일한 숫자를 하나의 인라인 블록 내에서 레이블로 여러 번 사용할 수 있습니다.
- 둘째, 숫자 레이블이 참조(예: 명령어 피연산자)로 사용될 때는 숫자 레이블에 접미사 “b” (“backward”, 뒤로) 또는 ”f” (“forward”, 앞으로)를 추가해야 합니다. 그러면 해당 방향에서 이 숫자로 정의된 가장 가까운 레이블을 참조하게 됩니다.
옵션
기본적으로 인라인 어셈블리 블록은 커스텀 호출 규약을 사용하는 외부 FFI 함수 호출과 동일하게 취급됩니다. 즉, 메모리를 읽거나 쓰고, 관찰 가능한 부수 효과가 있을 수 있다고 가정합니다. 하지만 많은 경우 컴파일러가 더 나은 최적화를 수행할 수 있도록 어셈블리 코드가 실제로 무엇을 하는지에 대한 더 많은 정보를 제공하는 것이 바람직합니다.
이전의 add 명령어 예제를 다시 살펴봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
inlateout(reg) a, in(reg) b,
options(pure, nomem, nostack),
);
}
assert_eq!(a, 8);
}
}
옵션은 asm! 매크로의 선택적인 마지막 인자로 제공될 수 있습니다. 여기서는 세 가지 옵션을 지정했습니다:
pure는 어셈블리 코드가 관찰 가능한 부수 효과가 없으며 출력이 입력에만 의존함을 의미합니다. 이를 통해 컴파일러 최적화 프로그램은 인라인 어셈블리를 더 적게 호출하거나 아예 제거할 수 있습니다.nomem은 어셈블리 코드가 메모리를 읽거나 쓰지 않음을 의미합니다. 기본적으로 컴파일러는 인라인 어셈블리가 접근 가능한 모든 메모리 주소(예: 피연산자로 전달된 포인터나 전역 변수를 통해)를 읽거나 쓸 수 있다고 가정합니다.nostack은 어셈블리 코드가 스택에 어떤 데이터도 푸시하지 않음을 의미합니다. 이를 통해 컴파일러는 x86-64의 스택 레드 존(stack red zone)과 같은 최적화를 사용하여 스택 포인터 조정을 피할 수 있습니다.
이러한 옵션들은 컴파일러가 asm!을 사용하는 코드를 더 잘 최적화할 수 있게 해줍니다. 예를 들어 출력이 필요하지 않은 순수(pure) asm! 블록을 제거할 수 있습니다.
사용 가능한 전체 옵션 목록과 그 효과에 대해서는 레퍼런스를 참조하세요.
호환성
Rust 언어는 빠르게 진화하고 있으며, 이로 인해 가능한 한 전방 호환성을 보장하려는 노력에도 불구하고 특정 호환성 문제가 발생할 수 있습니다.
Raw 식별자
Rust는 많은 프로그래밍 언어와 마찬가지로 “키워드“라는 개념이 있습니다. 이러한 식별자들은 언어에 의미가 있으므로 변수 이름, 함수 이름 등에서 사용할 수 없습니다. Raw 식별자를 사용하면 일반적으로 허용되지 않는 위치에서 키워드를 사용할 수 있습니다. 이는 특히 Rust가 새로운 키워드를 도입하고, 이전 에디션의 Rust를 사용하는 라이브러리에 최신 에디션에서 도입된 키워드와 동일한 이름의 변수나 함수가 있을 때 유용합니다.
예를 들어, 2015 에디션 Rust로 컴파일된 foo 크레이트가 try라는 이름의 함수를 내보낸다고 가정해 봅시다. 이 키워드는 2018 에디션의 새로운 기능을 위해 예약되어 있으므로, Raw 식별자가 없다면 함수 이름을 지을 방법이 없을 것입니다.
extern crate foo;
fn main() {
foo::try();
}
다음과 같은 오류가 발생합니다:
error: expected identifier, found keyword `try`
--> src/main.rs:4:4
|
4 | foo::try();
| ^^^ expected identifier, found keyword
Raw 식별자를 사용하여 다음과 같이 작성할 수 있습니다:
extern crate foo;
fn main() {
foo::r#try();
}
메타
일부 주제는 프로그램의 실행 방식과 직접적인 관련은 없지만, 모든 사람에게 도움이 되는 도구 또는 인프라 지원을 제공합니다. 이러한 주제는 다음과 같습니다.
문서화
target/doc에 문서를 빌드하려면 cargo doc을 사용하세요. cargo doc --open은 웹 브라우저에서 문서를 자동으로 엽니다.
모든 테스트(문서 테스트 포함)를 실행하려면 cargo test를, 문서 테스트만 실행하려면 cargo test --doc을 사용하세요.
이러한 명령어들은 필요에 따라 rustdoc(및 rustc)을 적절히 호출합니다.
문서 주석
문서 주석은 문서화가 필요한 큰 프로젝트에 매우 유용합니다. rustdoc을 실행할 때, 이 주석들이 문서로 컴파일됩니다. 이들은 ///로 표시되며 Markdown을 지원합니다.
#![crate_name = "doc"]
/// 여기에 사람(human being)이 표현됩니다.
pub struct Person {
/// 줄리엣이 아무리 싫어하더라도 사람은 이름을 가져야 합니다.
name: String,
}
impl Person {
/// 주어진 이름으로 사람을 생성합니다.
///
/// # Examples
///
/// ```
/// // 주석 내부의 울타리(fence) 사이에 Rust 코드를 넣을 수 있습니다.
/// // `rustdoc`에 --test를 전달하면, 코드 테스트까지 해줍니다!
/// use doc::Person;
/// let person = Person::new("name");
/// ```
pub fn new(name: &str) -> Person {
Person {
name: name.to_string(),
}
}
/// 친절하게 인사합니다!
///
/// 호출된 `Person`의 이름을 사용하여 "안녕, [name](Person::name)"이라고 말합니다.
pub fn hello(&self) {
println!("안녕, {}!", self.name);
}
}
fn main() {
let john = Person::new("존");
john.hello();
}
테스트를 실행하려면 먼저 코드를 라이브러리로 빌드한 다음, rustdoc이 각 문서 테스트 프로그램에 링크할 수 있도록 라이브러리 위치를 알려주어야 합니다.
$ rustc doc.rs --crate-type lib
$ rustdoc --test --extern doc="libdoc.rlib" doc.rs
문서 속성
다음은 rustdoc과 함께 사용되는 가장 일반적인 #[doc] 속성의 몇 가지 예입니다.
inline
별도의 페이지로 링크하는 대신 문서를 인라인으로 표시하는 데 사용됩니다.
#[doc(inline)]
pub use bar::Bar;
/// bar 문서
pub mod bar {
/// Bar에 대한 문서
pub struct Bar;
}
no_inline
별도의 페이지나 다른 곳으로 링크되는 것을 방지하는 데 사용됩니다.
// libcore/prelude에서 가져온 예시
#[doc(no_inline)]
pub use crate::mem::drop;
hidden
이를 사용하면 rustdoc이 해당 항목을 문서에 포함하지 않도록 합니다.
// futures-rs 라이브러리에서 가져온 예시
#[doc(hidden)]
pub use self::async_await::*;
문서화를 위해 커뮤니티에서는 rustdoc이 널리 사용됩니다. 표준 라이브러리 문서를 생성하는 데 사용되는 도구이기도 합니다.
참고:
- The Rust Book: 유용한 문서 주석 만들기
- rustdoc 책
- The Reference: 문서 주석
- RFC 1574: API 문서 관례
- RFC 1946: 문서 주석에서 다른 아이템으로의 상대 링크 (intra-rustdoc links)
- 주석에 대한 문서화 스타일 가이드가 있나요? (reddit)
플레이그라운드
Rust 플레이그라운드는 웹 인터페이스를 통해 Rust 코드를 실험해 볼 수 있는 방법입니다.
mdbook에서 사용하기
mdbook에서는 코드 예제를 실행 가능하고 수정 가능하게 만들 수 있습니다.
fn main() {
println!("Hello World!");
}
이를 통해 독자는 코드 샘플을 실행할 뿐만 아니라 수정하고 조정해 볼 수도 있습니다. 여기서 핵심은 코드 펜스(codefence) 블록에 콤마로 구분하여 editable이라는 단어를 추가하는 것입니다.
```rust,editable
//...place your code here
```
빌드 및 테스트 시에.
```rust,editable,ignore
//...place your code here
```
문서에서 사용하기
공식 Rust 문서 중 일부에서 “Run“이라고 적힌 버튼을 본 적이 있을 것입니다. 이 버튼을 누르면 Rust 플레이그라운드의 새 탭에서 코드 샘플이 열립니다. 이 기능은 html_playground_url이라는 #[doc] 속성을 사용하면 활성화됩니다.
#![doc(html_playground_url = "https://play.rust-lang.org/")]
//! ```
//! println!("Hello World");
//! ```