rust.02
rust collections, error handling, generic, test
collections
Vectors
첫번째로 다룰 컬렉션 타입은 Vec<T>
입니다. Vectors는 한가지 데이터 타입의 여러 데이터들을 메모리에 저장할 수 있습니다.
새로운 빈 Vector는 다음과 같이 생성할 수 있습니다.
1
let v: Vec<i32> = Vec::new();
Vector를 생성할 때 타입 정보를 넘기고 있는 것을 확인할 수 있습니다. Vector를 생성할 때, Vector에는 아무런 값도 들어있지 않으므로, rust에 어떤 타입을 저장하려고 Vector를 만들었는지를 알려줘야합니다.
종종 Vec<T>
를 초기 값을 가진 상태로 선언하기도 하는데 이때는 rust는 Vector 내부에 포함된 값으로부터 타입을 유추하게 됩니다. rust는 vec!
매크로를 제공하는데, 이를 이용해서 초기 값을 가진 Vector를 생성할 수 있습니다.
1
let v = vec![1,2,3]; // Vec<i32>
Vector를 생성하고 아이템을 추가하기 위해서는 push
메소드를 사용해야합니다.
1
2
3
4
5
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
변수들과 마찬가지로, mut
키워드를 사용해 v
값을 변경가능하게 선언해야합니다. 그리고 Vector에 추가하는 모든 값들이 i32
이기에 타입 어노테이션이 필요 없습니다.
Vector에 저장된 값을 읽는데에는 2가지 방법이 있습니다.
- indexing
get
method
1
2
3
4
5
6
7
8
9
10
let v = vec![1,2,3];
let third: &i32 = &v[2];
println!("third element is {third}");
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("third element is {third}"),
None => println!("there is no third element"),
}
&
와 []
을 사용해서 인덱스 값에 대한 reference를 리턴 받을 수 있습니다. get
메소드를 사용하면 Option<&T>
를 리턴받고, match를 사용해서 값을 꺼낼 수 있습니다.
rust는 엘리멘트를 레퍼런스하는 두 가지 방법을 지원하기에 프로그램이 어떻게 동작하기를 바라는지에 따라서 선택할 수 있습니다.
1
2
3
4
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
첫번째 []
는 런타임 오류를 발생시킵니다. 존재하지 않은 값을 레퍼런스하기 때문입니다. 만약 존재하지 않는 값을 참조했을 때 프로그램이 실행 중단되야하면 사용하기 좋은 메소드입니다.
get
메소드를 사용하면 런타임 오류를 발생하지 않고 None
을 리턴합니다. Vector의 범위 바깥 값을 종종 접근해야될 때 이러한 방법을 쓰는 것이 좋습니다.
Vectotr 역시 마찬가지로 mutable, immutable reference의 규칙을 따라야합니다.
1
2
3
4
let mut v= vec![1,2,3,4,5,];
let first = &v[0];
v.push(6);
println!("first is {first}");
위 코드는 에러를 발생시킵니다. first
엘리멘트가 immutable reference로 선언되었기에, first
의 값을 출력하고 Vector에 값을 추가해야 정상적으로 동작합니다.
다음과 같이 vector를 순회할 수 있습니다.
1
2
3
4
5
6
7
8
let v = vec![100, 32, 57];
for in in &v {
println!("{i}");
}
// 값을 변경해야한다면, 다음과 같이 작성할 수 있습니다.
for i in &mut v {
*i += 50;
}
mutable reference가 참조하는 값을 변경하기 위해서는 *
dereference 오퍼레이터를 사용해야합니다.
Vector는 같은 타입의 값들만 저장할 수 있는데, enum을 사용하면 다양한 값들을 저장할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
enum들은 같은 타입을 가지기에, enum 타입에 다양한 값을 가지게 선언해서 다양한 타입 값을 저장하는 Vector를 사용할 수 있습니다.
Strings
새로 rust를 학습하는 사람들은 3가지 이유들로 인해 string에 어려움을 겪습니다.
- rust의 발생 가능한 오류를 노출하는 특성
- string이 알려진 것보다 더 복잡한 자료구조
- UTF-8
위 3가지 이유가 혼합되어 다른 프로그래밍 언어를 사용하다 넘어온 사람들이 어려움을 겪습니다.
String이 무엇인가
먼저 string이라는 표현이 무엇을 의미하는지 정의해야합니다. rust는 내부적으로 str
이라는 단 하나의 string slice 타입을 가지고 있습니다. string literal 들도 모두 다 string slice들로 저장됩니다.
String
타입은 rust의 표준 라이브러리가 제공하는 타입으로 변형 가능한, UTF-8로 인코딩된 문자열 타입입니다.
str
, String
두 타입 모두 rust에서는 “strings”이고 다양한 표준 라이브러리에서 널리 쓰입니다.
새로운 문자열 생성하기
Vec<T>
에서 가능했던 많은 것들이 String
에서도 사용가능합니다. 다음과 같이 새로운 String
을 생성할 수 있습니다.
1
let mut s = String::new();
종종 어떤 데이터로부터 string을 시작하고 싶은 경우가 있는데, 그럴 때는 to_string
메소드를 사용할 수 있습니다.
1
2
3
let data = "initial contents";
let s = data.to_string();
let s = "initial_contents".to_string(); // 문자열 구문에서도 바로 사용 가능합니다.
위 코드는 initial_contents
를 포함하는 문자열을 생성합니다. 또 기존에 사용했던 것 처럼 다음과 같이 문자열을 생성할 수도 있습니다.
1
let s = String::from("initial_contents");
String
은 Vec<T>
처럼 데이터를 변경할 수 있습니다.
1
2
let mut s = String::from("foo");
s.push_str("bar");
s
는 foobar
이라는 값을 가집니다. push_str
메소드는 string slice를 파라미터로 받습니다. 왜나하면, 파라미터의 ownership을 가져갈 이유가 없기 때문입니다.
아래 코드에서 s1
에 s2
를 더한 이후에도 s2
를 사용할 수 있습니다.
1
2
3
4
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
만약 push_str
메소드가 s2
의 ownership을 가져갔다면, s2
의 값을 출력할 때 오류가 발생할 것입니다.
push
메소드는 하나의 문자를 파라미터로 전달받아 String
에 추가합니다.
1
2
let mut s= String::from("lo");
s.push('l');
기존에 존재하는 두 문자열은 +
연산자를 이용해 더할 수 있습니다.
1
2
3
let s1 = String::from("hello ");
let s2 = String::from("world");
let s3 = s1 + &s2; // s1은 이제 더 이상 쓸 수 없습니다.
s3
는 hello world
라는 값을 가지고, s1
은 더 이상 사용할 수 없습니다. s1
은 이제 더 이상 사용할 수 없고, s2
는 레퍼런스를 사용한 이유는 +
연산자를 사용했을 때 호출되는 메소드의 시그니처와 관련있습니다. +
연산자는 add
메소드를 사용하는데, 그 메소드 시그니처는 다음과 같습니다.
1
2
3
fn add(self. s: &str) -> String {
}
표준 라이브러리에서 add
가 제네릭 타입을 이용해서 정의된 것을 볼 수 있는데, 위 코드는 제네릭 타입을 String
으로 바꿔서 String
값으로 add
를 호출했을 때 무슨 일이 일어나는지를 알아볼 것입니다. 위 함수 시그니처는 +
연산자 동작 중 모호한 부분을 알기 위한 힌트를 줍니다.
먼저 s2
는 &
로 쓰였습니다. &
을 사용하면서 첫번째 문자열에 두번째 문자열의 레퍼런스를 더한다는 의미가 되는데, 그 이유는 add
함수의 파라미터를 보면 알 수 있습니다. add
함수의 파라미터를 보면 &str
인 것을 확인 할 수 있는데, 그렇기에 두 문자열의 값을 더할 수 없다는 것을 알 수 있습니다. 근데 생각해보면 &s2
의 타입은 &str
이 아니라 &String
인데, 어떻게 let s3 = s1 + &s2;
가 컴파일 되는 것일까요?
&s2
를 add
에 사용할 수 있는 이유는, 컴파일러가 &String
argument를 &str
으로 강제하기 때문입니다. add
메소드를 호출할 때, rust는 deref coercion을 사용합니다. deref coercion을 통해 &s2
는 &s2[..]
로 전환됩니다.
deref coercion에 대해서는 추후에 추가적으로 다룰 것입니다.
add
가 파라미터s
의 ownership을 가져가지는 않기에,s2
는 연산 이후에도 유효한 문자열입니다.
그리고 함수 시그니처에서 add
는 self
의 ownership을 가져가는 것을 알 수 있습니다. self
는 &
을 가지고 있지 않기 때문입니다. 그렇기에 s1
은 add
로 이동한 이후부터는 유효하지 않을 것입니다. let s3 = s1 + &s2;
가 두 문자열을 복사해서 새로운 문자열을 생성하는 것처럼 보이지만, 이 statement는 사실 s1
의 ownership을 가져가서, s2
의 내용물을 더하고 결과물의 ownership을 return하는 statement입니다. 다른말로, 많은 복사 작업이 일어나는 것 같지만, 사실 그렇지 않습니다. 실제 구현은 복사 작업보다 훨씬 효율적으로 이뤄집니다.
다수의 String을 더해야하면, format!
매크로를 사용할 수 있습니다. format!
매크로는 레퍼런스를 사용하기에, 파라미터의 ownership을 가져가지 않습니다.
1
2
3
4
5
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
많은 프로그래밍 언어에서 String indexing으로 개별 문자에 접근할 수 있습니다. 하지만 rust에서 String에 indexing을 적용하면 오류가 발생하는 것을 알 수 있습니다.
1
2
let s1 = String::from("hello");
let h = s1[0];
위 코드를 실행하면 아래 에러 메시지를 확인할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
String
은 Vec<u8>
의 wrapper입니다.
1
let hello = String::from("hola");
이 경우 len
값은 4입니다. hola
문자열이 4바이트라는 의미입니다. 문자열을 구성하는 각 문자는 UTF-8로 인코딩 되어있고, 1바이트씩을 차지합니다.
1
let hello = String::from("Здравствуйте");
위 문자열의 길이는 12처럼 보이지만, rust는 24로 응답합니다. Здравствуйте
유니코드 문자열을 UTF-8로 인코딩하려면 각 유니코드 값이 2바이트 저장 공간을 차지하기 때문입니다. 그렇기에, string의 바이트가 항상 유니코드 에서는 유효하지 않습니다.
indexing을 지원하지 않는 대신, 슬라이싱은 지원합니다.
1
2
let hello = "Здравствуйте";
let s = &hello[0..4];
hash maps
new
키워드로 빈 hash map을 생성할 수 있고, insert
메소드로 엘리멘트를 추가할 수 있습니다.
1
2
3
4
5
6
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
먼저 use
로 표준 라이브러리 collections에서 HashMap
경로를 등록한 것을 유의해야합니다. 앞서 살펴본 collection 자료구조보다 덜 쓰이는 자료구조이기에 포함되어있지 않고 경로를 추가해야 사용가능합니다.
vector와 마찬가지로 hashmap도 데이터를 heap에 저장합니다. hashmap도 vector처럼 모든 key와 value 타입이 동일해야합니다.
get
메소드를 이용해 hashmap key 값의 value를 조회할 수 있습니다.
1
2
3
4
5
6
7
8
9
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);
score
변수는 10이라는 값을 결과적으로 가집니다. get
메소드는 Option<&V>
를 리턴합니다. copied
를 호출해 Option<&i32>
를 Option<i32>
로 전환하고, unwrap_or
메소드를 이용해 None 일 경우에 대한 처리를 합니다.
다음과 같이 key, value 쌍을 순회할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
for (key, value) in &scores {
println!("{key}: {value}");
}
Copy
trait을 구현한 타입 같은 경우, hash map에 값이 복사됩니다. String
같은 소유자가 있는 타입들은 소유권이 넘어갑니다.
1
2
3
4
5
6
7
8
9
use std::collections::HashMap;
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
let mut map = HashMap::new();
map.insert(field_name, field_value);
// 이 시점부터 소유권이 넘어가서 문자열 변수를 사용할 수 없습니다.
// 레퍼런스를 넘겨도 되고, 해당 레퍼런스는 map이 유효한 순간까지 적어도 유효합니다.
다음과 같은 메소드를 이용해서 값이 존재하지 않을때만 값을 추가할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);
println!("{scores:?}");
or_insert
메소드 역시 mutable map에서만 사용 가능합니다.
다음과 같은 코드를 이용해서 기존에 있던 값을 업데이트 할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{map:?}");
error handling
unrecoverable errors with panic!
종종 작성한 코드에서 예상치 못한 일들이 벌어지고, 이런 일들에 대해서 어떠한 처리도 할 수 없는 상황이 발생하곤 합니다. rust에서는 panic!
이라는 매크로가 존재합니다. 통상 panic은 2가지 경우를 통해 발생합니다.
- 코드가 panic 상황에 빠지게 되는 액션이 발생
- 배열 크기를 뛰어 넘는 인덱스 접근
- 명시적
panic!
매크로 사용
기본적으로 이런 panic들은 failure 메시지를 출력하고, unwind하고, 스택을 비우며, 프로그램을 종료합니다. panic이 발생했을 때, panic의 발생 원인을 찾기 위해 call stack을 출력하게 환경 변수를 이용해 설정할 수도 있습니다.
Unwinding the stack or aborting in response to a panic
기본적으로 panic이 발생하면, 프로그램은 unwinding을 시작합니다. 스택을 되돌아가며, 마주하는 각 함수의 데이터를 지웁니다. 하지만 스택을 되돌아가며 각 데이터를 지우는 것은 꽤 큰 양의 작업입니다. 그러므로 rust는 이에 대한 대안으로 즉시 aborting하는 것을 선택할 수 있게 해줍니다. aborting을 선택하면, 프로그램은 cleaing up 하지 않고 즉시 종료하게 됩니다.프로그램이 사용한 메모리는 이후에 운영체제에 의해서 cleaned up 될 것입니다. 만약, 프로그램의 최종 바이너리 파일을 가능한 작게 만들고 싶다면,
panic = 'abort'
옵션을Cargo.toml
파일의[profile]
섹션에 명시할 수 있습니다.
간단한 panic을 던지는 프로그램을 작성해봅시다.
1
2
3
fn main() {
panic!("crash and burn");
}
1
2
3
4
5
6
7
❯ cargo run
Compiling panic v0.1.0 (/Users/dongjunkim/rustProject/panic)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.45s
Running `target/debug/panic`
thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
첫번째 줄을 통해서 panic이 발생한 코드 위치를 알 수 있습니다. src/main.rs
의 2번째 줄 5번째 character에서 panic이 발생했는데, 코드를 확인해보면, 명시적으로 panic!
매크로를 호출한 지점이라는 것을 알 수 있습니다.
이때 RUST_BACKTRACE
옵션을 통해, 발생한 런타임 오류 관련 stack trace를 확인할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
❯ RUST_BACKTRACE=1 cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/panic`
thread 'main' panicked at src/main.rs:2:5:
crash and burn
stack backtrace:
0: rust_begin_unwind
at /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/std/src/panicking.rs:665:5
1: core::panicking::panic_fmt
at /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/core/src/panicking.rs:74:14
2: panic::main
at ./src/main.rs:2:5
3: core::ops::function::FnOnce::call_once
at /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
recoverable errors with result
대부분의 에러는 프로그램이 종료될 만큼 큰 문제가 되지는 않습니다. 종종 함수가 실패했을 때, 그것에 대한 적절한 반응을 할 수 있는 경우가 있습니다. 예를 들어, 파일을 열려고 했는데, 파일이 존재하지 않기에 실패하면, 프로그램을 종료하는 대신 새로운 파일을 만들 수 있습니다.
Result
enum은 2가지 값을 가지고 있습니다. Ok
, Err
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}
T
와 E
는 제네릭 타입 파라미터입니다. Result
가 제네릭 타입 파라미터를 가지기에, 여러 다른 상황에서 Result
타입을 이용해서 성공했을 때, 실패했을 때 그에 맞는 결과를 리턴하게 할 수 있습니다.
함수의 실행이 실패할 수 있기에 Result
값을 리턴하는 함수를 호출해봅시다.
1
2
3
4
5
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
}
File::open
의 리턴 타입은 Result<T, E>
입니다. 제네릭 타입 파라미터 T
는 File::open
이 성공했을 때 리턴하는 값의 타입으로 채워졌습니다. 이 경우에는 처리할 file입니다. 에러 상황에서 사용할 타입 E
는 이 경우에는 std::io::Error
입니다.
Result<T,E>
리턴 타입은 File::open
이 성공할 수도 있고, 성공한다면, 읽고 쓸 수 있는 파일을 리턴할 것을 의미합니다. File::open
은 실패할 수도 있습니다. 열 파일이 존재하지 않을 수도 있고, 해당 파일을 열어볼 권한이 없을 수도 있습니다. File::open
함수는 자신이 성공했는지, 실패했는지, 여부를 알려줘야하고, 각 경우에 따라 처리해야할 파일 정보 혹은 발생한 에러 정보를 알려줘야합니다. Result
enum은 정확히 이런 정보들을 담고 있습니다.
File::open
이 성공했을 때, greeting_file_result
의 값은 처리해야할 파일을 담고 있는 Ok
인스턴스가 될 것입니다. 실패했을 때는 발생한 에러의 정보를 담고 있는 Err
의 인스턴스가 될 것입니다.
match
구문을 추가해서, File::open
의 결과에 대한 처리를 하는 코드입니다.
1
2
3
4
5
6
7
8
9
10
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("problem opening file : {error:?}"),
};
}
Option
enum과 같이, Result
enum은 이미 스코프에 포함되어 있어서, 따로 Result::
을 이용해서 명시하지 않아도 사용할 수 잇습니다.
결과가 Ok
이면, 이 코드는 greeting_file
에는 해당 파일이 담기게됩니다. 결과가 Err
이면, panic!
매크로가 호출되며 프로그램은 종료되게 됩니다. 작성한 코드를 실행해보면, 현재 디렉터리에는 hello.txt
파일이 존재하지 않기에, panic!
매크로가 실행되는 것을 알 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
❯ cargo run
Compiling result v0.1.0 (/Users/dongjunkim/rustProject/result)
warning: unused variable: `greeting_file`
--> src/main.rs:6:9
|
6 | let greeting_file = match greeting_file_result {
| ^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file`
|
= note: `#[warn(unused_variables)]` on by default
warning: `result` (bin "result") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
Running `target/debug/result`
thread 'main' panicked at src/main.rs:8:23:
problem opening file : Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
만약 파일이 존재하지 않을 때, 새로운 파일을 작성해 리턴하고 싶으면 어떻게 할 수 있을 까요? 다음과 같이 또 하나의 match
를 사용해서 처리할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("problem creating file : {e:?}"),
},
other_error => {
panic!("Problem opening the file : {other_error:?}");
}
},
};
}
File::open
이 리턴하는 Err
내부 데이터는 io::Error
로 표준 라이브러리가 제공하는 struct입니다. 이 struct는 kind
라는 메소드를 실행할 수 있는데, kind
메소드는 io::ErrorKind
값을 리턴합니다. io::ErrorKind
값은 표준 라이브러리가 제공하는 값이고, io
과정 중에 발생할 수 있는 다양한 에러에 대한 값을 가지고 있습니다. 위 코드에서 사용한 ErrorKind::NotFound
값은 열려고 하는 파일이 존재하지 않음을 의미하는 값입니다.
error.kind()
에도 match
문을 적용해서, ErrorKind::NotFound
경우에 대한 처리를 작성했습니다. NotFound
의 경우 File::craete
을 이용해서 파일을 생성하게 하였고, 이 함수 역시 실패할 수 있기에, match
문을 이용해 값을 처리해준 것을 확인할 수 있습니다.
match
를 사용해서 처리하는 것도 충분하지만, 너무 많은 코드를 작성해야하고, 코드의 의도 역시 잘 전해지지 않을 수 있습니다. Result<T, E>
타입은 다양하고 더 구체적인 작업을 할 수 있는 여러가지 메소드를 가지고 있습니다.
만약 Result
값이 Ok
라면, unwrap
메소드는 Ok
내부의 값을 리턴할 것입니다. 만약 Result
값이 Err
라면, unwrap
메소드는 panic!
매크로를 호출합니다.
unwrap
은 다음과 같이 간단하게 사용 가능합니다.
1
2
3
4
5
6
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt").unwrap();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
❯ cargo run
Compiling result v0.1.0 (/Users/dongjunkim/rustProject/result)
warning: unused variable: `greeting_file_result`
--> src/main.rs:4:9
|
4 | let greeting_file_result = File::open("hello.txt").unwrap();
| ^^^^^^^^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file_result`
|
= note: `#[warn(unused_variables)]` on by default
warning: `result` (bin "result") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
Running `target/debug/result`
thread 'main' panicked at src/main.rs:4:56:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
hello.txt
파일이 존재하지 않기에 panic!
매크로가 호출된 것을 확인할 수 있습니다.
expect
메소드를 사용하면, panic!
매크로가 실행될 때 발생할 에러 메시지를 지정할 수 있습니다.
1
2
3
4
5
6
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt").expect("hello.txt is not present in this project");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
❯ cargo run
Compiling result v0.1.0 (/Users/dongjunkim/rustProject/result)
warning: unused variable: `greeting_file_result`
--> src/main.rs:4:9
|
4 | let greeting_file_result = File::open("hello.txt").expect("hello.txt is not p...
| ^^^^^^^^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_greeting_file_result`
|
= note: `#[warn(unused_variables)]` on by default
warning: `result` (bin "result") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/result`
thread 'main' panicked at src/main.rs:4:56:
hello.txt is not present in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
대부분의 rust 사용자들은 unwrap
대신 expect
를 사용하여 디버깅 과정에서 도움을 얻습니다.
함수의 실행이 실패했을 때, 해당 함수에서 바로 그 결과를 처리하는 대신, 에러를 리턴해서 함수를 호출한 곳에서 무엇을 할지 결정하게 할 수도 있습니다. 이것을 에러의 전파라고 합니다.
다음과 같이 파일에서 유저 명을 읽고 성공한다면, 유저 명을 리턴하고 과정에서 실패한다면 에러를 리턴하게 할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
위 함수는 훨씬 더 짧게도 작성될 수 있지만, 에러 처리 과정을 살펴보기 위해 위와 같이 작성했습니다. 먼저 함수의 리턴 타입을 살펴보면, Result<String, io::Error>
인 것을 알 수 있습니다. 이것은 Result<T, E>
를 리턴하는데 T
가 String
이고 E
가 io::Error
임을 의미합니다.
만약 이 함수가 문제없이 동작한다면, 파일로부터 읽어들어온 username
문자열을 감싼 Ok
값이 리턴될 것입니다. 만약 어떤 문제가 발생한다면, 발생한 문제 관련 정보를 담은 io::Error
인스턴스를 감싼 Err
값이 리턴될 것입니다. 인스턴스 타입으로 io::Error
를 사용한 이유는 코드가 실패할 수 있는 지점은 File::open
함수와 read_to_string
메소드 이고 두 경우 모두 io::Error
를 리턴하기 때문입니다.
위 코드와 동일한 동작을하는 코드를 아래와 같이 간결하게도 작성할 수 있습니다.
1
2
3
4
5
6
7
8
9
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
Result
값 이후에 위치한 ?
은 이전 코드에서 Result
를 다루기 위해서 사용한 match
와 동일하게 동작합니다. 만약 Result
의 값이 Ok
라면, ?
를 이용해 Ok
에 담긴 값이 리턴되고 프로그램은 이어서 진행됩니다. 만약 Result
의 값이 Err
라면, Err
이 리턴되게 됩니다.
이전 코드에서 match
를 사용한 것과 ?
오퍼레이터가 동작하는데 차이점이 존재합니다. ?
오퍼레이터를 가진 error 값은 표준 라이브러리 From
trait에 정의된 from
함수를 통해 값이 변환됩니다. ?
오퍼레이터가 from
함수를 호출하면, 전달받은 에러 타입은 현재 함수 리턴타입에 명시한 에러타입으로 변환되게 됩니다. 함수가 다양한 이유로 실패할 수 있을 때, 이와 같은 처리는 매우 유용하게 작용됩니다.
예를 들어,
read_username_from_file
의 에러 타입을OutError
라는 커스텀 예외 타입으로 지정할 수도 있습니다. 그리고io::Error
로부터impl From<io::Error> for OutError
를 정의한다면,?
오퍼레이터는 함수 내부에서 발생한io::Error
들을OutError
로 변환해서 리턴하게 됩니다.
?
오퍼레이터는 함수의 구현을 훨씬 더 간단하게 해줍니다. 메소드 체이닝을 이용해서 다음과 같이 훨씬 더 간단하게 작성할 수도 있습니다.
1
2
3
4
5
6
7
8
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}
?
오퍼레이터는 함수의 리턴 타입이 ?
오퍼레이터가 적용된 곳의 리턴 타입과 동일한 곳에서만 사용 가능합니다. 이 이유는 ?
오퍼레이터를 이용해 함수의 리턴을 할 수 있기 때문에 ?
오퍼레이터가 적용된 곳과 함수의 리턴 타입이 동일해야합니다.
다음 코드는 에러가 발생합니다.
1
2
3
4
5
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
?
오퍼레이터는 File::open
이 리턴하는 Result
값을 따릅니다. 하지만 main
함수는 ()
을 리턴타입으로 가지게 컴파일 되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> return.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
|
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 | let greeting_file = File::open("hello.txt")?;
5 +
6 + Ok(())
7 + }
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
리턴 타입이 일치하지 않기에 위와 같은 컴파일 에러가 발생합니다.
위 코드가 컴파일 되기 위해서는 match
을 이용하거나, main
의 리턴 타입을 Result
로 변경해야 합니다.
지금까지 살펴본 모든 코드들은 메인 함수가 ()
을 리턴했습니다.
운 좋게도 main
함수는 Result<(), E>
를 리턴할 수도 있습니다.
다음 코드는 정상적으로 컴파일됩니다.
1
2
3
4
5
6
7
8
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
Box<dyn Error>
타입은 trait 오브젝트 입니다. trait 오브젝트는 추후에 다룰 것이지만, 현재로서는 Box<dyn Error>
는 어떠한 종류의 에러라고 이해해도 무방합니다.
to panic! or not to panic!
코드가 panic하게 되면, 그 상황을 더 이상 되돌릴 수는 없습니다. 에러 상황에서 panic!
을 호출하게 되면, 해당 상황을 되돌리거나 해결할 수 있어도, 해당 상황은 해결 불가라고 결정하게되는 것입니다. Result
값을 리턴하는 것은, 호출하는 코드에게 결정권을 부여합니다. 호출한 코드는 상황에 맞게 회복을 시도할 수도 있고, 복구 불가라고 판단하고 panic!
을 호출하게 할 수도 있습니다. 그러므로 실패할 수도 있는 코드를 호출할 때, Result
를 리턴하는 것이 좋은 선택입니다.
예제 코드나, 프로토 타입 코드, 테스트 코드 같은 경우는 Result
를 리턴하는 대신, panic!
를 호출하게 하는 것이 더 적합한 방법입니다.
값 검증을 위한 custom types
어떤 수 guess는 1부터 100까지의 값만 가져야한다는 제한사항이 있다고 가정해봅시다. 그렇다면, guess에 대한 값 검증은 다음과 같이 진행할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
loop {
// --snip--
let guess: i32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
if guess < 1 || guess > 100 {
println!("The secret number will be between 1 and 100.");
continue;
}
match guess.cmp(&secret_number) {
// --snip--
}
if
조건문을 이용해서 값이 원하는 범위에 속하는지 확인합니다.
하지만 위 해결 방법은 이상적인 솔루션이 아닙니다. 만약 값이 반드시 이 범위 안에 있어야해서 함수를 호출할 때 마다 이 범위를 확인해야하는 것은 너무 비효율적입니다.
위와 같이 처리하는 대신 새로운 타입을 만들어서 해당 타입의 인스턴스를 생성할 때 값의 검증을 처리하게 할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {value}.");
}
Guess { value }
}
pub fn value(&self) -> i32 {
self.value
}
}
위와 같이 새로운 타입을 만든다면, Guess::new
를 통해 생성된 값은 항상 유효하다는 신뢰하고 사용할 수 있습니다.
generics, traits, and lifetimes
generic data types
제네릭을 사용해서 함수 시그니처, 혹은 struct를 정의하고, 수 많은 데이터 타입으로 그것들을 사용할 수 있습니다.
제네릭을 사용해서 함수를 정의할 때, 함수 시그니처 중 데이터 타입을 명시하는 부분에 제네릭을 사용합니다.
다음 코드는 i32
, char
배열에 최대 값을 추출하는 코드를 각각 작성한 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
}
두 함수 바디가 정확하게 같은 코드로 구성되어있음을 확인할 수 있습니다. 제네릭 타입 파라미터를 이용해 중복을 줄이고 하나의 함수로 줄일 수 있습니다.
타입을 파라미터화 하기 위해서는 타입 파라미터에 이름을 붙여야합니다. rust 컨벤션에서 타입 파라미터는 한글자, 대문자로 선언해야하며, 주로 T
가 많이 사용됩니다.
함수 시그니처에서 타입 파라미터를 사용할 때 먼저 해당 타입 파라미터를 선언해야 합니다. 다음 코드처럼 먼저 <>
괄호 안에 타입 파라미터를 명시하고, 함수 시그니처에서 타입 파라미터를 사용해야 합니다.
1
fn largest<T> (list: &[T]) -> &T {}
largest
함수는 제네릭 타입 파라미터 T
를 가지고, list
라는 T
타입의 슬라이스를 가지며, T
타입의 레퍼런스를 리턴할 것임을 알 수 있습니다.
작성한 제네릭 함수를 참조하여 다음과 같이 largest
를 작성할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
작성한 코드는 다음과 같은 에러를 발생시키며 컴파일되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
❯ cargo run
Compiling generic v0.1.0 (/Users/dongjunkim/rustProject/generic)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `generic` (bin "generic") due to 1 previous error
에러 메시지를 확인해보면, std::cmp::PartialOrd
라는 trait에 대해서 언급하고 있는 것을 알 수 있습니다. trait에 대해서는 이후에 다룰 것인데, 현재로서는 largest
는 모든 가능한 T
에 대해서는 동작하지 않는 다는 것을 알 수 있습니다. 왜냐하면, T
타입의 값들을 비교하는데, 값을 비교하여 순서를 메길 수 있어야하기 때문입니다. 값의 비교를 가능하게 위해서 표준 라이브러리는 std::cmp::PartialOrd
라는 타입에 구현 가능한 trait을 지원합니다. 오류 메시지의 도움을 받아서 PartialOrd
를 구현한 타입으로 T
를 제한할 수 있습니다.
struct에서도 제네릭 타입 파라미터를 가질 수 있습니다.
1
2
3
4
5
6
7
8
9
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point {x: 5, y: 6};
let float = Point {x: 1.0, y: 4.0};
}
struct에서 제네릭을 사용하는 것과 함수 정의에서 사용하는 것이 비슷해보입니다.
이때 인스턴스를 생성할 때, 제네릭 타입 파라미터간 타입이 일치해야 인스턴스가 생성됩니다.
1
2
3
4
5
6
7
8
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
1
2
3
4
5
6
7
8
9
10
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
다음과 같이 struct 정의를 변형해서 서로 다른 타입을 가지게 할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
enum에서도 struct와 마찬가지로 사용 가능합니다.
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}
struct와 enum의 메소드에도 제네릭 타입을 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point {x : 5, y: 10};
println!("p.x = {}", p.x());
}
impl
블럭에 타입 파라미터 T
를 선언해 Point<T>
타입의 메소드를 구현함을 명시했습니다. T
를 impl
이후에 명시함으로서, Point
에서 제네릭 타입 파라미터를 사용할 것임을 rust에게 알려줍니다.
타입의 메소드를 정의할 때, 특정 타입에 대한 메소드를 정의할 수도 있습니다. Point<f32>
에 대한 메소드를 정의하고 싶으면 다음과 같이 정의할 수 있습니다.
1
2
3
4
5
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
위 코드는 Point<f32>
가 distance_from_origin
이라는 메소드를 가짐을 의미합니다. f32
가 아닌 다른 타입에서는 해당 메소드를 사용할 수 없습니다.
제네릭을 사용할 때, 런타임에 프로그램이 느려지는 것을 걱정할 수도 있는데, 확실한 타입을 사용했을 때와 비교해서 전혀 느려지지 않습니다.
traits : defining shared behavior
trait은 특정 타입이 가지는 기능인데, 다른 타입과 공유할 수 있는 기능을 의미합니다. trait을 이용해 특정 행동 방식을 추상적인 방식으로 정의할 수 있습니다.
trait은 다른 프로그래밍 언어에서 interface 개념과 유사합니다.
예를들어 텍스트를 저장하는 여러 타입 있다고 해봅시다. NewsArticle
타입은 신문 기사의 내용을 담고 있고, Tweet
타입은 최대 280자의 텍스트를 가지며, 새로운 트윗인지, 리트윗인지, 아님 트윗에 대한 답변인지에 대한 메타 데이터 정보를 가지고 있습니다.
aggregator
라는 라이브러리 crate을 만들어서 NewsArticle
그리고 Tweet
인스턴스의 요약 데이터를 디스플레이 하려고 합니다. 그러기 위해서는 각 타입의 요약 데이터가 필요하고, 각 인스턴스의 summarize
메소드를 호출했을 때, 요약 데이터를 얻을 수 있게 하고 싶습니다.
이런 요구사항을 만족하는 Summary
trait을 다음과 같이 작성할 수 있습니다.
1
2
3
pub trait Summary {
fn summarize(&self) -> String;
}
trait
키워드를 이용해 trait을 정의했습니다. pub
키워드를 사용해서 다른 crate에서도 이 trait을 사용 가능하게 했습니다. 괄호 안에는 trait에서 구현해야 하는 메소드 시그니처를 정의했습니다.
trait
도 정의했기에, 다음과 같이 타입마다 Summary
trait을 구현할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location);
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{} : {}", self.username, self.content)
}
}
trait을 구현하는 것을 일반 메소드를 구현하는 것과 비슷합니다. 차이점은 impl 이후에 구현하고자하는 trait의 이름을 명시해야하고, for
키워드를 이용해 trait을 구현하려는 타입을 명시해야합니다.
trait을 구현할 때 주의해야 할 점은, trait 혹은 type 둘 중 하나가 crate에 local인 경우에만 구현할 수 있습니다. Display
같은 external trait을 local aggregator
내부 Tweet
에 구현할 수 있고, Summary
같은 local trait을 external Vec<T>
에 구현할 수 있지만, external type에 external trait을 구현할 순 없습니다.
몇몇 경우에는 trait에 기본 구현을 작성해 놓는 것이 편할 때도 있습니다. 타입이 메소드를 구현하면, 구현한 메소드가 동작하고, 구현하지 않는 다면, 기본 구현을 따르는 것입니다.
1
2
3
4
5
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
위와 같이 기본 구현을 하고, impl Summary fo NewsArticle {}
구문을 선언해 메소드를 재정의하지 않았습니다.
1
2
3
4
5
6
7
8
9
10
11
let article = NewsArticle {
headline: String::from("Penguins win the Stanley Cup Championship!"),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
};
println!("New article available! {}", article.summarize());
1
New article available! (Read more...)
기본 구현을 trait에 포함해 놓으면, trait을 구현한 타입이 메소드를 정의하지 않아도 사용할 수 있습니다.
trait을 정의하고, 구현하는 방법에 대해서 다뤄보았는데, 다음과 같은 notify
메소드를 선언해서 summarize
를 호출할 수도 있습니다. summarize
를 호출하려면 Summary
trait을 구현해야하기에, 다음과 같이 trait을 파라미터로 받을 수 있습니다.
1
2
3
4
pub fn notify(item: &impl Summary) {
println!("Breaking news {}", item.summarize());
}
impl
그리고 trait 명을 파라미터로 전달함으로서 trait을 구현한 타입을 파라미터로 명시할 수 있습니다.
impl Trait
구문도 문제 없이 동작하지만, 직관적이지 않습니다. 그렇기에 다음과 같은 형태로 주로 작성합니다.
1
2
3
pub fn notify<T: Summary> (item: &T) {
println!("Breaking news {}", item.summarize());
}
위와 같은 trait bound syntax을 사용해서 보다 더 간결하고 직관적인 코드를 작성할 수 있습니다.
+
구문을 사용해서 하나 이상의 trait을 구현함을 표현할 수 있습니다.
1
pub fn notify<T: Summary + Display> (item : &T) {}
notify에 넘기는 데이터 타입은 Summary
와 Display
trait을 모두 구현해야 합니다.
trait bound 에 너무 많은 trait을 선언하게 되면, 오히려 가독성이 떨어지는 코드를 작성하게 됩니다. 그렇기에 rust는 where
절을 지원합니다.
1
fn some_function<T: Display+Clone, U: Display+Clone> (t: &T, u: &U) -> i32 {}
위와 같이 작성하는 대신, where
절을 이용해 보다 가독성 좋은 함수 시그니처를 작성할 수 있습니다.
1
2
3
4
5
6
7
fn some_function<T, U> (t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Display + Clone,
{
}
impl Trait
구문을 이용해 trait을 구현한 특정 타입을 리턴할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
fn returns_summarizable() -> impl Summary {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
}
하지만 하나의 type만 리턴 가능함을 유의해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
NewsArticle {
headline: String::from(
"Penguins win the Stanley Cup Championship!",
),
location: String::from("Pittsburgh, PA, USA"),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
}
}
}
위 코드와 같이 두가지 타입을 리턴하는 코드는 동작하지 않습니다. 이를 이해하기 위해서는 impl Trait
구문을 컴파일러가 구현하는지에 대해서 알아야 하며, 추후에 다룰 예정입니다.
using trait bounds to conditionally implement methods
다음과 같이 trait bound을 이용해서 특정 trait을 정의한 타입에서만 실행 가능한 메소드를 정의할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self { x, y }
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
Pair<T>
를 생성할 때 Display + PartialOrd
를 구현한 타입에 대해서만 cmp_display
메소드를 실행할 수 있습니다.
validating references with lifetimes
lifetime은 이미 다루고 있었던 또 다른 형태의 제네릭입니다. 타입이 원하는 동작을 함을 보장하는 것과 달리, lifetime은 레퍼런스가 적어도 우리가 필요한 만큼은 레퍼런스가 유효함을 보장합니다.
이전에 reference와 borrowing 개념을 다루면서 다루지 않은 것이, rust에서 모든 레퍼런스는 해당 레퍼런스가 유효한 스코프인 lifetime을 가진다는 것입니다. 대부분의 경우에 lifetime은 암시적이며, 타입과 마찬가지로 유추할 수 있습니다. 타입의 경우, 다양한 타입이 가능한 경우에 필수적으로 타입을 명시해줬는데, 이와 유사하게 lifetime도 다양한 lifetime이 가능할 때 명시해줘야합니다. rust는 런타임에 실제 레퍼런스가 항상 유효할 수 있도록 제네릭 lifetime 파라미터를 이용해 관계를 정의하게 합니다.
lifetime의 가장 큰 목적은 허상 포인터를 방지하는 것입니다.
다음과 같은 코드를 봅시다.
1
2
3
4
5
6
7
8
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r:{r}");
}
위 코드는 컴파일되지 않습니다. r
을 참조하려고 할 때, 이미 스코프를 벗어나있기 때문입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
❯ rustc lifetime.rs
error[E0597]: `x` does not live long enough
--> lifetime.rs:5:13
|
4 | let x = 5;
| - binding `x` declared here
5 | r = &x;
| ^^ borrowed value does not live long enough
6 | }
| - `x` dropped here while still borrowed
7 | println!("r : {r}");
| --- borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0597`.
에러 메시지를 확인해보면, x
가 더이상 유효하지 않다는 메시지를 확인할 수 있습니다. x
가 스코프에서 벗어난 이유는 내부 스코프가 7번 줄에서 끝나는데, r
은 외부 스코프에서 여전히 유효하기 때문입니다. 만약 rust가 위 코드의 컴파일을 허용했다면, 코드는 원하는 대로 동작하지 않을 것입니다.
borrow checker
rust 컴파일러는 borrow checker는 스코프를 비교해서 모든 borrow가 유효한지 확인 합니다.
1
2
3
4
5
6
7
8
9
10
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
각 변수의 라이프타임을 a
, b
로 표현했습니다. r
의 라이프타임이 x
보다 긴 것을 확인할 수 있습니다. 컴파일 시점에 rust는 두 변수의 라이프타임을 비교해서 r
이 x
보다 긴 라이프타임을 가지기에, 문제가 있다고 판단합니다.
허상 포인터 문제가 발생하지 않게 다음과 같이 코드를 수정할 수 있습니다.
1
2
3
4
5
6
7
8
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
r
이 x
를 참조할 수 있습니다. 그 이유는 r
이 유효한 시점 내에서 항상 x
도 유효하기 때문입니다.
두 문자열 슬라이스를 전달받고, 두 문자열 슬라이스 중 더 긴 문자열 슬라이스를 리턴하는 함수를 다음과 같이 전달할 수 있습니다.
1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
하지만 위 함수는 컴파일 되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
오류 메시지를 확인해보면, 라이프타임에 관한 메시지들을 확인할 수 있습니다. help text를 확인해보면, 제네릭 라이프타임 파라미터가 필요한데, 그 이유는 리턴되는 레퍼런스가 x
인지 y
인지 모르기 때문입니다.
위와 함수를 정의할 때, 우리는 이 함수에 전달될 값을 알지 못합니다. 그렇기에 if
의 경우가 실행될지, else
의 경우가 실행될지 알 수 없습니다. 또한 전달될 레퍼런스의 실제 라이프라팀 또한 알 수 없습니다. 이러한 에러를 해결하기 위해 제네릭 라이프타임 파라미터를 추가해, 레퍼런스 간 관계를 정의하고, borrow check로 하여금 라이프라팀을 확인할 수 있게 할 것입니다.
lifetime annotation syntax
lifetime annotation은 레퍼런스가 얼마나 오래 유효할지를 변경하지 않습니다. 대신에, 다양한 레퍼런스 변수의 라이프타임에 영향을 미치지 않고, 라이프타임의 관계를 정의합니다. 함수 시그니처가 제네릭 타입 파라미터를 명시하면 어떠한 타입도 받을 수 있는 것처럼, 제네릭 라이프타임 파라미터를 명시한 함수는 어떠한 레퍼런스도 받을 수 있습니다.
lifetime annotation은 기존과는 살짝 다른 문법을 사용합니다. 라이프타임 파라미터는 '
로 시작해야 하며, 소문자로 명명해야합니다. 대부분의 사람들은 라이프타임 파라미터로 'a
를 사용합니다.
1
2
3
&i32 // reference
&'a i32 // 라이프타임 파라미터를 포함한 레퍼런스
&'a mut i32 // 라이프타임 파라미터를 포함한 변경 가능한 레퍼런스
lifetime annotation을 함수 시그니처에서 사용하기 위해서는 다음과 같이 제네릭 라이프타임 파라미터를 선언해야합니다.
1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
위 함수 시그니처는 다음과 같은 내용을 의미합니다.
- 리턴된 레퍼런스는 파라미터로 전달된 값들이 유효한 동안 유효하다.
- 그리고 이것이 파라미터와 리턴 값 간의 라이프 타임 관계이다.
위 코드는 정상적으로 컴파일되고, 우리가 원하는 결과물을 리턴합니다.
함수 시그니처는 이제 rust에게 어떤 라이프타임 'a
동안 파라미터로 전달받은 두 문자열 슬라이스가 유효하다는 것을 알려줍니다. 또한 리턴 되는 문자열 슬라이스 'a
동안 유효하다는 것을 알려줍니다. 다른 말로 하면, longest
함수가 리턴하는 값은, 파라미터로 전달하는 값의 라이프타임보다 짧다고 표현할 수도 있습니다.
실제 레퍼런스를 longest
로 넘기면, 'a
를 대체하는 실제 라이프타임은 x
스코프와 y
스코프가 겹치는 부분으로 할당됩니다. 달리 말하면, 제네릭 라이프타임 파라미터 'a
는 x
와 y
라이프타임 중 작은 범위와 같거나 작게 할당됩니다.
그렇기에, 다음 코드는 문제 없이 동작합니다.
1
2
3
4
5
6
7
8
fn main() {
let str1 = String::from("long string is long");
{
let str2 = String::from("xyz");
let result = longest(str1.as_str(), str2.as_str());
println!("longest {}", result);
}
}
result
의 라이프타임은 str2
보다 짧아도 아무런 문제가 없기에 원할히 실행됩니다.
하지만 다음 코드는 컴파일 오류를 발생시킵니다.
1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| -------- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
result
가 println!
을 실행할 때 유효하기 위해서는 string2
가 외부 스코프가 종료될 시점까지 유효해야한다는 에러 메시지를 확인할 수 있습니다.
라이프타임 파라미터를 명시하는 것은, 함수의 동작에 따라 달라질 수 있습니다. 예를 들어 다음과 같이 longest
의 구현을 변경하면, y
파라미터에 제네릭 라이프타임 파라미터를 사용할 이유가 없어집니다.
1
2
3
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
y
파라미터의 라이프타임은 리턴 값에 아무런 영향을 미치지 않기에, 라이프타임 명시를 생략했습니다.
함수에서 레퍼런스를 리턴할 때, 리턴 타입의 라이프타임 파라미터는 파라미터의 라이프타임 중 하나와는 반드시 일치해야 합니다.
다음과 같은 코드는 컴파일 에러를 발생시킵니다.
1
2
3
4
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
라이프타임 파라미터를 명시했지만, 파라미터의 라이프타임과 일치하는 것은 없어, 오류가 발생합니다.
위와 같은 경우에는 레퍼런스를 리턴하는 것이 아닌 owned data type 자체를 리턴하는 것이 허상 포인터를 피하는 해결책이 될 수 있습니다.