rust.01
rust programming concept, basic, ownership, struct, enum, package managing
기본 개념
rust로 간단한 Hell world를 출력하는 프로그램은 다음과 같이 작성할 수 있습니다.
1
2
3
4
// hello_world.rs
fn main() {
println!("Hello world");
}
rustc hello_world.rs
명령어로 생성한 rust 파일을 컴파일할 수 잇고, 컴파일하게 되면 실행 파일이 만들어집니다.
main
함수는 특별한 함수입니다. 모든 실행 가능한 rust 프로그램에서 실행되는 첫번째 코드입니다. main
함수를 보면 파라미터를 전달받지 않고, 아무것도 리턴하지 않는 것을 확인할 수 있습니다.
함수의 바디는 다음 코드를 포함하고 있습니다.
1
println!("Hello world");
위 코드에서 4가지 rust의 중요한 디테일을 알 수 있습니다.
- rust에서 tab 사이즈는 4입니다.
println!
은 러스트 매크로를 호출합니다. 만약 함수를 호출했다면println
으로 작성해야합니다. 매크로는 일반 함수와는 다른 개념입니다.println!
의 파라미터로 전달한 문자열이 출력되는 것을 확인할 수 있습니다.- 라인 끝에 세미 콜론을 포함하고 있는 것을 확인할 수 있습니다.
rust 프로그램을 실행하기 전에 rustc
를 이용해 실행 파일을 생성햇습니다. 성공적으로 컴파일되면 실행 파일이 생성됩니다.
만약 Ruby, Python, JavaScript 같은 동적인 언어에 익숙하다면, 컴파일과 실행이 분리된 것이 익숙하지 않을 수도 있습니다. rust는 ahead-of-time
컴파일 언어입니다. 프로그램을 컴파일하고, 실행 파일을 다른 사람에게 전해주면, 그 사람은 rust를 설치하지 않아도 프로그램을 실행 가능합니다. .rb
, .py
, .js
같은 파일은 실행하기 위해서는 Ruby, Python, JavaScript가 설치되야 합니다. 하지만 하나의 명령어로 컴파일과 실행을 모두 할 수 있다는 장점이 있습니다.
rustc
도 간단한 프로그램에서는 문제가 없지만, 큰 프로젝트에서는 cargo
를 사용하는 것이 권장됩니다.
cargo
cargo
는 rust의 빌드 시스템이자 패키지 매니저입니다. rust를 사용하는 많은 사람들이 cargo를 사용하여 rust 프로젝트를 관리합니다.
앞서 작성한 프로그램은 아무런 의존성을 가지고 있지 않습니다. rust 프로그램이 복잡해지고 의존성이 추가될 수록, cargo로 프로젝트를 관리하는 것이 편해집니다.
cargo new hello_cargo
명령어로 hello_cargo
라는 새로운 프로젝트를 생성할 수 있습니다.
새로운 프로젝트는 다음과 같은 파일들로 구성됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
❯ cargo new hello_cargo
Creating binary (application) `hello_cargo` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
❯ cd hello_cargo
❯ ll
total 16
drwxr-xr-x 6 dongjunkim staff 192 Sep 27 10:51 .
drwxr-xr-x 4 dongjunkim staff 128 Sep 27 10:51 ..
drwxr-xr-x 10 dongjunkim staff 320 Sep 27 10:51 .git
-rw-r--r-- 1 dongjunkim staff 8 Sep 27 10:51 .gitignore
-rw-r--r-- 1 dongjunkim staff 82 Sep 27 10:51 Cargo.toml
drwxr-xr-x 3 dongjunkim staff 96 Sep 27 10:51 src
Cargo.toml
파일에는 다음과 같은 내용이 작성되어 있습니다.
1
2
3
4
5
6
7
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"
[dependencies]
cargo의 설정 파일은 TOML
형식으로 작성되어 있습니다. 첫번째 줄 package
섹션에서는 패키지 관련 설정을 작성할 수 있습니다. name
, version
, edition
이 3값은 cargo가 프로그램을 컴파일하기 위해 필요한 정보입니다.
dependencies
섹션에서 프로젝트의 의존성을 관리할 수 있습니다. rust에서 코드 패키지는 crates
라고 부릅니다. 이 프로젝트에서는 다른 crates가 요구되지는 않습니다.
생성된 src/main.rs
파일의 내용은 다음과 같습니다.
1
2
3
fn main() {
println!("Hello, world!");
}
cargo는 소스 코드들은 src
디렉터리에 저장합니다. 프로젝트의 탑 레벨 디렉터리에는 리드미, 라이센스 정보, 설정 파일 그리고 코드와 관련 없는 파일들을 담고 있습니다. cargo로 프로젝트를 생성하지 않았어도, 코드를 src
디렉터리에 저장하고, 적합한 Cargo.toml
파일을 생성하면 cargo를 사용하는 것이 가능합니다.
cargo build
명령어를 hello_cargo
디렉터리에서 실행하면 프로젝트가 빌드됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
❯ cargo build
Compiling hello_cargo v0.1.0 (/Users/dongjunkim/rustProject/hello_cargo)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
❯ ll
total 24
drwxr-xr-x 8 dongjunkim staff 256 Sep 27 11:05 .
drwxr-xr-x 4 dongjunkim staff 128 Sep 27 10:51 ..
drwxr-xr-x 9 dongjunkim staff 288 Sep 27 10:51 .git
-rw-r--r-- 1 dongjunkim staff 8 Sep 27 10:51 .gitignore
-rw-r--r-- 1 dongjunkim staff 155 Sep 27 11:05 Cargo.lock
-rw-r--r-- 1 dongjunkim staff 82 Sep 27 10:51 Cargo.toml
drwxr-xr-x 3 dongjunkim staff 96 Sep 27 10:51 src
drwxr-xr-x@ 5 dongjunkim staff 160 Sep 27 11:05 target
실행 파일은 target/debug/hello_cargo
로 생성됩니다.
그리고 프로젝트를 빌드한 결과 Cargo.lock
파일이 생성된 것을 확인할 수 있습니다. 이 파일은 프로젝트 의존성의 버전 정보를 관리합니다. 개발자가 직접이 lock 파일을 다루지 않아도 cargo가 알아서 관리해줍니다.
cargo build && ./target/debug/hello_cargo
명령어로도 실행가능하지만, cargo run
하나의 명령어로도 코드를 컴파일하고, 실행 파일을 실행 가능합니다.
cargo check
명령어로는 실행 파일을 생성하지 않고, 컴파일 되는 것을 확인 가능합니다. cargo check
은 실행 파일을 만들지 않기에, 빌드 명령어보다 훨씬 빠르게 동작합니다.
만약 프로젝트가 배포할 준비가 되었다면, cargo build --release
명령어로 최적화된 컴파일을 할 수 있습니다. cargo build --release
명령어는 실행 파일은 target/release
디렉터리에 생성합니다. 최적화된 컴파일은 rust 코드의 실행 시간을 줄여주지만, 컴파일되는데는 더 많은 시간이 필요합니다. 그렇기에 프로필이 분리된 것입니다. 개발 과정에서는 자주 빠르게 빌드하고, 운영 환경에서는 빠르게 빌드되지는 않더라도 빠른 실행 시간을 확보합니다.
variables
rust에서 기본적으로 변수는 불변입니다. rust는 동시성 제어 그리고 코드의 안전성을 보장하기 위해 변수를 불변으로 관리합니다. 하지만 변수를 가변적으로 생성할 수도 있습니다.
불변인 변수는 값을 재할당 받을 수 없습니다. 다음 코드는 에러가 발생합니다.
1
2
3
4
5
6
fn main() {
let x = 5;
println!("x is {x}");
x = 6;
println!("x is {x}");
}
발생한 컴파일 타임 에러 메세지입니다.
error[E0384]: cannot assign twice to immutable variable 'x'
불변인 변수에 값을 변경하려고 할 때 컴파일 타임 에러가 발생하는 것은 매우 중요합니다. 왜냐하면 이 상황이 버그로 이어질 수 있기 때문입니다. rust 컴파일러는 불변으로 선언한 변수의 값이 변하지 않는 것을 보장합니다.
변수의 가변성이 편리하고 코드 작성을 편하게 해주는 부분도 존재합니다. 기본적으로 rust 변수는 불변이지만, mut
를 추가하는 것으로 가변 변수로 선언할 수 있습니다.
1
2
3
4
5
6
fn main() {
let mut x = 5;
println!("x is {x}");
x = 4;
println!("x is {6}");
}
불변 변수와 비슷한 constants
도 존재합니다. 불변 변수와 동일하게 값을 변경할 수 없지만, 몇가지 차이점도 존재합니다.
- constants에서는
mut
를 사용할 수 없습니다.- const는 기본적으로 불변인 것이 아니라 항상 불변입니다.
const
키워드를 이용해 constants를 선언할 수 있고, 타입을 명시해야합니다.
- constants는 어떤 스코프에서도 선언 가능합니다.
- constants는 어떤 상수 표현을 저장합니다. 런타임 실행의 결과를 저장하는 용도로는 사용하지 않습니다.
1
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
rust 네이밍 컨벤션에서 constants는 대문자 그리고 단어 사이는 underscore로 채우는 것을 권장합니다.
shadowing
이전에 선언한 변수명과 동일한 변수를 선언할 수 있습니다. shadowing 예시는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let x = 5;
let x = x + 1;
{
let x = x* 2;
println!("x is {x}");
}
println!("x is {x}");
}
/*
x is 12
x is 6
*/
x의 초기값은 5입니다. 그리고 새로운 x는 x+1 = 6의 값을 가집니다. 이너 스코프에서 x는 x*2 = 12의 값을 가지고, 이너 스코프가 끝나며 쉐도잉이 해제되어 x는 다시 6의 값을 가집니다.
쉐도잉은 mut
을 이용해서 변수를 가변으로 만드는 것과는 다릅니다. let
키워드를 사용하지 않으면 값을 변경할 수 없기 때문입니다.
mut
과 쉐도잉의 또 다른 차이점은 쉐도잉은 새로운 변수를 생성하는 것이기에 동일한 이름의 변수로 다른 타입의 값을 저장할 수 있습니다.
1
2
3
4
5
let spaces = " ";
let spaces = spaces.len();
let mut spaces = " ";
spaces = spaces.len(); // spaces가 문자열 타입으로 지정되었기에, 타입 미스매치로 할당이 불가합니다.
data types
rust는 정적인 타입 언어입니다. 모든 변수의 타입은 컴파일 시점에 정해져야합니다.
1
let guess: u32 = "42".parse().expect(" not a number ");
위 코드에서 : u32
를 제거하면 오류가 발생합니다. 어느 타입으로 parse()
할지 알 수 없기 때문입니다.
rust에서는 두가지 타입 서브셋이 존재합니다 : scalar & compound
scalar types
스칼라 타입은 단일 값을 나타냅니다. rust는 4가지 스칼라 타입을 가집니다. : integers, floating-point numbers, Booleans, characters
rust에서 integer type은 다음과 같습니다.
length | signed | unsigned |
---|---|---|
8 bit | i8 | u8 |
16 bit | i16 | u16 |
32 bit | i32 | u32 |
64 bit | i64 | u64 |
128 bit | i128 | u128 |
arch | isize | usize |
isize
와 usize
자료형은 실행중인 컴퓨터의 아키텍쳐에 따라 정해집니다. 64 비트인 경우 64비트로, 32비트인 경우 32비트로 정해집니다.
Integer overflow
u8
타입 변수는 0 ~ 255 값을 저장할 수 있습니다. 256 같은 값을 저장하려고하면 integer overflow가 발생합니다.
rust에서는 두가지 소수 자료형이 있습니다. f32
, f64
입니다. 기본적으로 f64
가 할당되고, f32
는 명시적으로 할당해서 지정 가능합니다.
1
2
3
4
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0 // f32
}
boolean 타입과 character 타입은 다음과 같이 사용 가능합니다.
1
2
3
4
5
6
7
fn main() {
let t = true;
let f: bool = false;
let c = 'c';
let z: char = 'z';
}
compound types
컴파운드 타입은 여러 개의 값을 그룹핑하는 타입입니다. rust에는 두가지 컴파운드 타입이 존재합니다 : tuple & array
tuple은 여러 개의 다양한 타입의 값을 묶는 방법 중 하나입니다. tuple은 고정된 길이를 가집니다. 선언된 이후 줄어들거나 늘어날 수 없습니다. 다음과 같이 타입을 명시해서 사용할 수 있습니다.
1
2
3
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
tup
의 값을 다음과 같이 분해할 수도 있습니다.
1
2
3
4
5
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("y is {y}");
}
다음처럼 tuple의 인덱스를 통해서 값을 꺼내올 수 있습니다.
1
2
3
4
5
6
fn main() {
let x = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
}
아무런 값을 포함하지 않는 tuple은 unit
이라고 부릅니다. unit
은 빈 값을 대표하고, unit
을 리턴한다는 것은 빈 값을 리턴하는 것과 같습니다.
다른 컴파운드 타입으로는 array가 있습니다. tuple과는 다르게 array는 모든 엘리멘트들이 같은 타입입니다.
1
2
3
fn main() {
let a = [1, 2, 3, 4, 5];
}
array는 힙보다 스택에 데이터를 저장하고 싶을 때 혹은 고정된 길이의 데이터를 다룰 때 유용합니다. array는 vector처럼 유연하지 않습니다. vector는 비슷한 컬렉션 타입이지만 사이즈를 줄이거나 키울 수 있습니다.
array의 타입은 다음과 같이 명시할 수 있습니다.
1
2
3
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}
i32
는 array element의 자료형을 명시하고, 5로 배열의 크기를 지정했습니다.
같은 값을 가지는 배열을 다음과 같이 선언할 수 도 있습니다.
1
2
3
fn main() {
let a:[3; 5]; // [3, 3, 3, 3, 3]
}
배열 엘리멘트의 접근은 다음과 같이 할 수 있습니다.
1
2
3
4
5
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
functions
rust 컨벤션으로 함수 명과 변수명은 snake case로 작성합니다. 모든 글자는 lowercase로 작성하고, 단어는 underscore로 구분합니다.
1
2
3
4
5
6
7
8
9
fn main() {
println!("hello world");
another_function();
}
fn another_funcion() {
println!("another function");
}
rust에서 새로운 함수는 fn
키워드를 이용해서 정의합니다.
rust에서 함수 선언 위치는 중요하지 않습니다. another_function
이 main 함수 다음에 선언된 것을 확인할 수 잇씁니다.
다음과 같이 함수는 파라미터를 가질 수 있습니다. 파라미터로 넘어가는 실제 값을 argument라고 부릅니다.
1
2
3
4
5
6
7
fn main() {
another_function(5);
}
fn another_function(x: i32) {
println!("x is {x}");
}
함수의 시그니처에서 파라미터의 타입은 필수적으로 선언해야합니다.
1
2
3
4
5
6
7
fn main() {
print_labeled_measurement(5, 'h');
}
fn print_labeled_measurement(value: i32, unit_label: char) {
println!("measurement is {value}{unit_label}");
}
함수의 바디는 statements 들로 구성되고 선택적으로 expression으로 종료됩니다. 지금까지 작성한 함수들은 expression을 가지지 않았습니다. 하지만 expression을 statement의 일부로 작성했습니다. rust는 expression 에 기반한 언어이기에 둘의 차이점을 아는 것이 중요합니다.
- statement : 어떤 행동을 하고 값을 리턴하지 않는 명령어
- expression : 어떤 값을 리턴하는 코드
지금까지 이미 statement와 expression을 사용해봤습니다. 변수를 생성하고, 변수에 값을 할당하는 것은 statement입니다. statement는 값을 리턴하지 않기에 다음 코드는 에러가 발생합니다.
1
2
3
fn main() {
let x = (let y = 6);
}
let y = 6
statement는 어떤 값을 리턴하지 않기에 x
에 어떤 값을 bind할 수 없습니다. Ruby나 C에서는 x = y = 6
같이 사용가능한데, rust에서는 불가합니다.
expression은 어떤 값을 가지는 표현입니다. 다음과 같은 수식 5+6
은 11
이라는 값을 가집니다.. 6
이라는 statement는 expression입니다. let y = 6
에서 6
이라는 값을 나타냅니다. 함수를 호출하는 것은 expression이고 macro를 호출하는 것도 expression입니다. 새로운 스코프 블록도 expression입니다.
1
2
3
4
5
6
7
8
fn main() {
let y = {
let x = 3;
x + 1
};
println!("y is {y}");
}
// y is 4
새로운 스코프 블록에서 x+1
은 세미콜론으로 끝나지 않습니다. expression은 세미콜론을 포함하지 않습니다. 만약 세미콜론을 추가하게되면, statement로 동작하고, 어떤 값을 리턴하지 않습니다.
함수는 어떤 값을 리턴할 수 있습니다. 리턴할 값의 타입을 ->
로 명시할 수 있습니다. rust에서 함수의 리턴 값은 함수 바디의 마지막 expression과 동일합니다. return
키워드와 리턴 값을 명시해서 일찍 리턴할 수도 있지만, 대부분의 함수가 암시적 리턴을 사용합니다.
1
2
3
4
5
6
7
8
fn five() -> i32 {
5
}
fn main() {
let x = five();
println!("x is {x}");
}
five()
에서 마지막 expression이 리턴되고 x is 5
가 출력됩니다.
control flow
if
rust에서 조건문은 다음과 같이 사용 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let number = 6;
if number % 4 == 0 {
println!("number is divisible by four");
} else if number % 3 == 0 {
println!("number is divisible by three");
} else {
println!("number is not divisible by four or three");
}
}
if
가 expression 이기에 다음과 같이 사용할 수도 있습니다.
1
2
3
4
5
6
fn main() {
let condition = true;
let number = if condition {5} else {6};
println!("number is {number}");
}
// number is 5
이때 주의해야할 점은 if
그리고 else
에서 expression 타입이 동일해야합니다. 타입이 다르면 에러가 발생합니다.
loop
rust에서는 3가지 종류의 반복문이 존재합니다 : loop
, while
, for
loop
키워드는 명시적으로 반복문을 벗어나기 전까지 무한히 반복합니다.
1
2
3
4
5
fn main() {
loop {
println!("again!");
}
}
위 코드를 실행해보면, 실행을 종료하기 전까지 무한히 출력되는 것을 확인할 수 있습니다.
이외에도 break
문을 사용해서 반복문을 벗어나게 설정할 수도 있습니다.
loop
의 사용 방법 중 하나는 실패할 수도 있는 작업을 재시도하는 거나 반복문의 결과를 리턴 받는 것입니다. 반복문의 결과를 리턴받기 위해선 break
문 다음에 리턴 받을 값을 명시하는 것으로 결과를 리턴 받을 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut count = 0;
let result = loop {
count += 1;
if count == 10 {
break count * 2;
}
}
println("result is {result}");
}
// result is 20
반복문 이전에 count
라는 변수를 선언하고 0으로 초기화했습니다. result
라는 변수를 선언하고, loop
에서 리턴한 값을 result
에 저장합니다. 매 반복마다 count
변수는 1 증가하고, count
값이 10에 도달한 순간 break
키워드가 실행되고 20이 리턴되기에 result
변수는 최종적으로 20이라는 값을 가집니다.
내부 반복문을 사용할 때, break
와 continue
는 가장 내부 반복문에 적용됩니다. loop label
을 명시해서 가장 내부 반복문 말고 break
와 continue
를 실행할 반복문을 지정할 수 있습니다.
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
fn main() {
let mut count = 0;
'counting_up: loop {
println!("count = {count}");
let mut remaining = 10;
loop {
println!("remaining = {remaining}");
if remaining == 9 {
break;
}
if count == 2 {
break 'counting_up;
}
remaining -= 1;
}
count += 1;
}
println!("end count = {count}");
}
/*
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
end count = 2
*/
count 변수 값이 2이자, 내부 반복문에서 break 'counting_up
이 실행되어 외부 반복문에서도 바로 벗어난 것을 확인할 수 있습니다.
rust에서 while 문은 다음과 같이 사용가능합니다.
1
2
3
4
5
6
7
fn main() {
let mut number = 3;
while number != 0 {
println("{number}")
number -= 1;
}
}
for 문을 이용해서 다음과 같이 컬렉션을 순회할 수도 있습니다.
1
2
3
4
5
6
fn main() {
let list = [1, 2, 3, 4, 5];
for l in list {
println!("l is {l}");
}
}
명확한 실행 종료 조건이 있지 않는 경우 반복문은 for 문을 쓰는 것이 안전합니다. 특정 범위의 수를 반복하는 반복문은 다음과 같이 사용 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
for i in 1..4 {
println!("ascending {i}");
}
for i in (1..4).rev() {
println!("descending {i}");
}
}
/*
ascending 1
ascending 2
ascending 3
descending 3
descending 2
descending 1
*/
ownership
ownership은 rust 프로그램이 메모리를 관리하는 것과 관련된 몇 가지 규칙들입니다. 모든 프로그램은 실행중에 컴퓨터 메모리를 어떻게 사용할 것인지를 관리해야합니다. 몇몇 언어들은 가비지 컬렉션을 사용해서 프로그램 실행중에 종종 더이상 사용되지 않는 메모리를 확인합니다. 다른 언어들은 프로그래머가 명시적으로 메모리의 할당과 해제를 관리해아합니다. rust는 또 다른 방법을 사용합니다.
rust에서 메모리는 시스템의 ownership을 통해 관리됩니다. 시스템의 ownership은 컴파일러가 확인하는 몇가지 규칙들을 기반해서 동작합니다. 만약 위반된 규칙이 있다면, 프로그램은 컴파일되지 않습니다.
ownership은 많은 프로그래머들에게 새로운 개념이기에 익숙해지기 위한 시간이 필요합니다. rust와 ownership의 규칙에 익숙해질 수록 안전하고 효율적인 코드를 작성할 수 있습니다.
stack과 heap
많은 프로그래밍 언어들이 stack과 heap에 대해서 생각하게 하지 않습니다. 하지만 rust에서는 값이 stack에 있는지, heap에 있는지가 언어의 행동 방식에 영향을 미칩니다.stack과 heap은 모두 런타임에 사용할 수 있는 메모리의 일부분입니다. 하지만 둘은 다른 방식으로 구성되어있습니다.
stack은 lifo 방식으로 동작합니다. stack에 저장되는 모든 데이터는 고정된 사이즈를 가져야합니다. 컴파일 시점에 데이터 크기를 알 수 없거나 사이즈가 변할 수 있는 데이터는 heap에 저장되어야합니다.
heap에 데이터를 저장할 때는 특정 용량의 요청합니다. memory allocator가 heap에서 빈 공간을 찾고, 해당 공간을 사용중 처리한 다음 포인터를 리턴합니다. 포인터는 빈 공간의 주소 값입니다.
이 과정을 allocating on the heap 이라고 하며 allocating이라고 줄여서 부르기도합니다. heap의 포인터 자체는 고정된 크기이기에 stack에 포인터를 저장할 수 있습니다. 하지만 만약 실제 데이터를 원한다면, 포인터를 통해서 실제 데이터에 접근해야 합니다. 식당을 예시로 heap의 메모리 할당 과정을 살펴보면 다음과 같습니다. 식당에 입장해 인원 수를 몇 명 왔는지 설명 == allocator에게 특정 용량의 데이터를 요청 식당 호스트가 자리로 안내 == allocator가 빈 공간을 찾고 포인터를 리턴
stack에 push하는 것이 heap allocating 하는 것보다 빠릅니다. 왜냐하면 allocator가 빈 공간을 찾을 필요가 없기 때문입니다. stack에 push는 항상 stack의 top에 하면 되기에 속도가 더 빠릅니다. 그리고 allocator가 요청한 데이터를 저장할 수 있는 빈 공간을 찾아야 하기에 더 오랜 시간이 필요합니다.
heap에 있는 데이터를 접근하는 것이 stack 데이터를 접근하는 것보다 느립니다. 우선 포인터 변수에 접근해서 주소 값을 가져와야 하기에 더 느립니다. 현대 프로세서들은 메모리에 덜 이동할 수록 더 빠르게 동작합니다. 식당 예시를 이어서 설명하면, A 테이블에서 주문을 받고 B 테이블 주문을 받고 다시 A 테이블에 와서 이어서 주문을 받고 다시 B 테이블로 이동하는 것은 긴 시간을 필요로 할 것입니다.
코드가 함수를 호출하면, 값은 함수로 전달됩니다. 그리고 함수의 지역 변수들은 stack에 push됩니다. 함수가 종료되면, 값은 stack에 pop되어서 사라집니다. 코드가 heap에서 어떤 데이터를 사용하는지, heap에서 중복 데이터를 어떻게 줄일지, heap에서 사용하지 않는 데이터를 어떻게 지울지 등등이 모두 ownership이 다루는 것들입니다. ownership에 대해서 이해하게되면, stack와 heap에 대해서 크게 신경 쓰지 않아도 무방합니다.
ownership rule들은 다음과 같습니다.
- rust에서 value들은 owner를 가진다.
- 하나의 owner만 존재 가능하다.
- owner가 스코프 바깥으로 사라진다면, value는 drop된다.
variable scope
변수의 스코프는 아이템이 유효한 범위입니다.
변수의 스코프는 다음과 같이 설정됩니다.
1
2
3
4
5
{ // s 가 아직 선언되지 않았습니다.
let s = "hello"; // s는 이후부터 유효 합니다.
// ~~ s로 어떤 작업을 처리
} // 스코프가 종료되기에 s 는 더 이상 유효하지 않습니다.
스코프에서 중요한 포인트는 다음과 같습니다.
- s가 스코프 내부로 들어오면, valid입니다.
- 그리고 스코프가 종료될 때 까지 valid합니다.
여기까지는 다른 언어에서의 스코프와 변수의 개념과 동일합니다.
String type
ownership의 규칙을 설명하기 위해서는 앞서 다뤘던 데이터 타입보다 복잡한 데이터 타입이 필요합니다. 앞서 학습한 데이터 타입들은 모두 크기가 정해진 데이터 타입들이였습니다. 그렇기에 스코프가 종료되면 stack에서 pop되고 다른 스코프에서 동일한 값을 사용해야한다면 쉽게 독립적인 인스턴스를 만들 수 있습니다.
String type은 heap에 저장되는 데이터 입니다. String type을 통해 ownership의 규칙을 알아볼 수 있습니다. 이전 예시에서 부터 하드 코딩된 문자열 값을 다룬 적이 있습니다.
1
2
3
4
fn main() {
let hw = "hello world";
println!("{hw}");
}
이런 하드 코딩된 문자열 값은 유용하지만, 텍스트를 다루는 모든 상황에서 유용하지는 않습니다. 그 이유 중 하나는 문자열은 불변이라는 점입니다. 또 모든 문자열 값이 코드를 작성할 때 알 수 있지는 않습니다. 사용자의 입력을 받아서 문자열 변수에 저장하려고 할 때, 사용자가 어떤 문자열을 입력할지 미리 알 수는 없습니다.
이런 상황들에 대응하기 위해서 String
타입이 존재합니다. 이 타입은 heap에 allocated된 데이터를 관리합니다. String
타입 데이터는 다음과 같이 생성 가능합니다.
1
let s = String::from("hello");
위와 같은 방식으로 생성된 string은 변형 가능 합니다.
1
2
3
let mut s= String::from("hello");
s.push_str(", world");
println!("{s}"); // hello, world
왜 String::from
으로 생성된 문자열은 변형 가능하고, 하드 코딩된 문자열 값은 변형 불가능한 것일까요? 차이점은 두 타입이 메모리를 다루는 방식입니다.
문자열 구문 같은 경우 컴파일 시점에 문자열의 내용을 알 수 있습니다. 그렇기에 텍스트 내용이 실행 파일로 하드 코딩됩니다. 문자열 구문이 빠르고 효율적인 이유가 여기에 있습니다. 이런 장점은 문자열 구문이 불변인 것에 기초합니다. 불행히도 실행 파일에 런타임에 크기가 변할 수도 있는 크기의 메모리 할당을 명령하는 것은 불가능합니다.
가변적인 문자열 사용을 위한 String
타입은 컴파일 시점에 알지못하는 데이터를 저장하기 위해 heap에 특정 크기의 memory를 allocate합니다.
- 런타임에 memory allocator에 의해 메모리를 요청해야합니다.
- 문자열을 다 쓴 다음에 이 문자열이 점유한 메모리를 해제해야합니다.
첫번째 단계는 개발자에 의해 실행됩니다. String::from
을 호출할 때 필요한 메모리를 요청합니다. 이는 프로그래밍 언어에서 모두 공통적으로 동작합니다.
두번째 단계는 다릅니다. 가비지 컬렉터를 사용하는 언어 같은 경우에는 가비지 컬렉터가 더 이상 사용하지 않는 메모리를 제거합니다. 가비지 컬렉터가 사용하지 않는 메모리를 해제하기에 개발자는 신경 쓰지 않아도 됩니다. 가비지 컬렉터가 없으면 개발자가 명시적으로 메모리를 해제해야합니다.
역사적으로 개발자가 직접 메모리를 해제하는 것은 어려운 일이었습니다. 만약 메모리 해제 작업을 까먹으면 메모리가 낭비될 것입니다. 만약 너무 빨리 해제하면 변수는 유효하지 않게됩니다. 그리고 실수로 두번해제하게 되는 상황에서는 버그가 발생합니다. allocate
과 free
는 1대1 대응되야합니다.
rust는 다른 접근 방법을 사용합니다. 메모리는 변수가 스코프 바깥으로 벗어나는 순간 해제됩니다.
1
2
3
4
{
let s = String::from("hello"); // s는 이 시점 이후로 스코프에서 유효합니다.
// s를 이용하여 어떤 작업 처리
} // 스코프가 종료되고, s는 이 시점 이후로 유효하지 않습니다.
s
가 스코프에서 벗어나는 순간 rust는 drop
이라는 특별한 함수를 호출합니다. rust는 {}
가 닫힐때마다 자동으로 drop
을 호출합니다. 간단하게 동작하는 것 같지만, 다양한 변수가 heap에 allocate된 상황에서는 예상치 못하게 동작하는 경우도 있습니다.
variables and data interacting with move
동일한 데이터를 다루는 다양한 변수는 rust에서는 좀 다르게 동작합니다.
1
2
let x = 5;
let y = x;
위 코드의 동작을 예상해보면, 우선 x
에 5를 할당하고, x
의 값을 복사해서 y
에 할당합니다. 결과적으로 x
와 y
는 같은 값을 가집니다. 코드는 예상대로 동작하고, 그 이유는 integer가 simple value이기 때문입니다.
String
의 경우를 생각해보면,
1
2
let s1 = String::from("hello");
let s2 = s1;
integer에서 다룬 예시와 비슷하기에, 동작 방식도 유사하다고 생각할 수 있습니다. s1
의 값을 복사해서 s2
에 전달한다고 생각 할 수도 있지만, 실제 동작은 그렇지 않습니다.
String
내부 동작을 위 그림에서 확인할 수 있습니다. String
은 3가지 구성 성분을 가지고 있습니다. string의 데이터를 담고 있는 메모리 포인터, length 그리고 capacity 입니다. 왼쪽 데이터들은 stack에 저장되고 오른쪽 데이터들은 heap에 저장된 데이터입니다.
length란 바이트 단위로 String
의 컨텐츠가 사용하고 있는 메모리 양을 의미합니다. capacity는 바이트 단위로 String
이 allocator로부터 할당 받은 메모리 크기를 의미합니다.
go 에서 slice length, capacity 개념과 유사합니다.
s1
을 s2
에 할당하면, String
의 데이터는 복사됩니다. 다시 말해 stack의 저장된 포인터, length, capacity 값이 복사됩니다. 그 결과 다음 그림 같은 메모리 구조가 구성됩니다.
s1
과 s2
가 같은 메모리 주소를 point하고 있는 것을 알 수 있습니다. rust는 아래와 같이 동작하지 않습니다.
위와 같이 동작했다면, s2 = s1
같은 연산은 매우 비싼 연산일 것입니다. heap에 저장하고 있는 데이터의 크기가 매우 크다면 복사해야할 데이터 양이 매우 크기 때문입니다.
앞서서 변수가 스코프 범위에서 벗어난다면, drop
함수가 호출되어서 해당 변수의 heap 메모리를 제거한다고 했었는데, s2 = s1
같은 상황에서 두 변수는 같은 데이터 포인터를 가리키고 있습니다. 그렇기에 drop
함수가 호출되면 두 변수 모두 같은 메모리 해제 요청이 발생해서 double free error가 발생합니다. 메모리를 보호하기 위해 let s2 = s1;
이 호출되면, rust는 그 시점부터 s1
을 유효하지 않다고 판단합니다. 그렇기에 함수 스코프가 종료될 때, s1
메모리는 해제하려고 하지 않습니다.
1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("s1 is {s1}");
}
위 rust 파일을 컴파일하게 되면 다음 같은 오류가 발생하는 것을 확인할 수 있습니다.
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
warning: unused variable: `s2`
--> ownership.rs:3:9
|
3 | let s2 = s1;
| ^^ help: if this is intentional, prefix it with an underscore: `_s2`
|
= note: `#[warn(unused_variables)]` on by default
error[E0382]: borrow of moved value: `s1`
--> ownership.rs:5:21
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("s1 is {s1}");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
error: aborting due to 1 previous error; 1 warning emitted
For more information about this error, try `rustc --explain E0382`.
shallow copy, deep copy 라는 개념을 다른 프로그래밍 언어를 공부하면서 들어봤다면, heap 데이터 자체를 복사하는 것이 아니라 stack의 데이터 포인터, length, capactiy를 복사하는 것이 shallow copy처럼 느껴집니다. 하지만, rust는 shallow copy를 하는 것이 아니라, 첫번째 변수를 무효화처리하기에, move
를 한다고 할 수 있습니다.
rust에서 move
를 나타내는 그림은 다음과 같습니다.
s1
이 s2
로 move
되면서 double free error는 발생하지 않습니다.
추가적으로 rust는 이렇게 동작하기에 절대 데이터를 deep copy하지 않습니다.
만약 String
의 데이터를 deep copy하고 싶다면, clone
이라는 공통 메서드를 사용할 수 있습니다.
1
2
3
4
5
6
7
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 is {s1}, s2 is {s2}");
}
// s1 is hello, s2 is hello
clone
을 통해 heap 데이터를 deep copy하였기에 컴파일 되지 않던 코드가 정상적으로 컴파일되고 실행되는 것을 확인할 수 있습니다.
stack-only data : copy
다음 코드는 정상적으로 동작합니다.
1
2
3
4
let x = 5;
let y = x;
println!("x = {x} y = {y}");
하지만 방금 전까지 배운 개념과는 약간 모순되게 느껴지긴 합니다. clone
을 호출하지 않았는데도, x
는 유효하고, y
로 move
되지 않았습니다.
그 이유는 integer 같은 타입은 컴파일 시점에 크기를 알 수 있기에 stack에 저장되고, 실제 값 복사를 빠른 시간에 할 수 있습니다. 그렇기에 x
를 무효화해야할 이유가 없습니다. 다른 말로 하면 deep copy, shallow copy 간 차이점이 없습니다. clone
을 호출하는 것과 다른게 없습니다.
rust에는 Copy
트레이트라는 특별한 주석이 있습니다. 이 주석은 정수와 같이 스택에 저장되는 타입에 사용할 수 있습니다(트레이트에 대해서는 이후 더 자세히 다룰 예정입니다). 만약 어떤 타입이 Copy
트레이트를 구현하면, 그 타입을 사용하는 변수는 이동하지 않고 간단히 복사됩니다. 따라서 다른 변수에 할당된 후에도 여전히 유효합니다.
rust에서는 타입이나 그 일부가 Drop
트레이트를 구현한 경우, 해당 타입에 Copy
주석을 추가하는 것을 허용하지 않습니다. 만약 타입이 스코프를 벗어날 때 특별한 처리가 필요하고, 그 타입에 Copy
주석을 추가하 컴파일 시간에 오류가 발생하게 됩니다.
Copy
를 구현한 타입이 궁금하다 주어진 타입의 공식 문서를 확인할 수 있습니다. Copy
를 구현한 타입은 다음과 같습니다.
- 모든 정수 타입,
u32
- boolean 타입,
bool
- 모든 소수 타입,
f64
- 문자 타입
char
- 만약 튜플안 모든 타입이
Copy
를 구현했다면 튜플,(i32, i32)
ownership & functions
함수에 값을 전달하는 메커니즘은 변수에 값을 할당하는 메커니즘과 유사합니다. 함수에 변수를 전달할 때 move
되거나, copy
됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
let s = String::from("hello");
takes_ownership(s); // s의 ownership이 takes_ownership으로 move됩니다.
let x = 5;
makes_copy(x); // copy로 동작하기에 이후에 x를 사용 가능합니다.
}
fn takes_ownership(some_string: String) {
println!("{some_string}");
} // 스코프가 종료되며 s가 보유한 메모리가 해제됩니다.
fn makes_copy(some_integer: i32) {
println!("{some_integer}");
}
s
를 takes_ownership
을 호출한 다음 사용하려면, 오류가 발생합니다. takes_ownership
을 호출할 때, s
의 ownership이 move
되기 때문입니다.
return values and scope
값을 리턴하는 것 역시 ownership을 이전합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let s1 = gives_ownership(); // gives_ownership은 내부에서 string을 생성해서 ownership을 s1으로 이전합니다.
let s2 = String::from("hello"); // s2를 선언합니다.
let s3 = takes_and_gives_back(s2); // s2의 ownership이 s3로 이전됩니다.
}
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string
}
fn takes_and_gives_back(a_string: String) -> String {
a_string
}
변수의 ownership은 매번 같은 패턴을 따릅니다 : 다른 변수에 할당될 때 이전된다. 힙에 있는 데이터를 포함하는 변수가 스코프를 벗어나면, 그 데이터의 소유권이 다른 변수로 이동하지 않는 한 drop
에 의해 값이 정리됩니다.
이런 방식이 동작하긴 하지만, 함수가 소유권을 가져갔다가 다시 반환하는 것은 다소 번거롭습니다. 만약 함수가 값을 사용하되, 소유권을 가져가지 않게 하려면 값을 다시 사용하고 싶을 때, 함수에 전달한 모든 것을 다시 반환 받아야합니다.
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("length of s2 is {len}");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}
이런 방식은 너무 번거롭기에, rust는 ownership을 이전하지 않는 reference라는 feature를 사용합니다.
reference and borrowing
reference
는 포인터와 유사합니다. 포인터와 다른 점은 레퍼런스는 레퍼런스의 생명 주기 동안 유효한 값을 가리키는 것이 보장됩니다.
1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("length of {s1} is {len}");
}
fn calculate_length(s: &String) -> usize {
s.len()
}
앞서 작성한 것보다 깔끔한 코드가 작성되었습니다. 다른 점은 &s1
이 함수 파라미터로 전달되고, 함수 시그니처의 파라미터 타입이 &String
으로 변경된 것을 확인할 수 있습니다.
&
이 reference를 나타냅니다.*
는 dereference를 나타냅니다.
작성한 코드를 좀 더 분석해보면
1
2
let s1 = String::from("hello");
let len = calculate_length(&s1);
&s1
은 s1
의 값에 대한 참조
를 생성합니다. 참조를 생성하는 것이지, ownership을 가질 수는 없습니다. ownership을 가지지는 않기에 레퍼런스의 사용이 중단 되어도 point하는 값은 drop되지 않습니다.
함수 시그니처에서도 &
을 사용해서 레퍼런스를 받는 다는 것을 나타냅니다.
1
2
3
fn calculate_length(s: &String) -> usize { // s는 String의 레퍼런스입니다.
s.len()
} // s의 스코프는 종료되지만, ownership은 없기에 s가 레퍼런스하는 값은 drop되지 않습니다.
s
는 ownership을 가지지 않기에 s
의 스코프가 종료되어도 실제 값은 drop되지 않습니다. 레퍼런스를 생성하는 행위를 borrowing
이라고 부릅니다. 실생활에서 물건을 빌리면 다시 돌려줘야하는 것처럼, 레퍼런스는 ownership을 소유하지 않습니다.
만약 borrow한 값을 변경하려면 어떤 일이 일어날까요? -> 오류가 발생하고 제대로 동작하지 않습니다.
1
2
3
4
5
6
7
8
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(s: &String) {
s.push_str(", world");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0596]: cannot borrow `*s` as mutable, as it is behind a `&` reference
--> borrow.rs:7:5
|
7 | s.push_str(", world");
| ^ `s` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
6 | fn change(s: &mut String) {
| +++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0596`.
변수가 기본적으로 불변인 것처럼, reference또한 불변입니다.
mutable references
작성한 코드를 조금 변경해서 mutable reference를 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(s: &mut String) {
s.push_str(", world");
}
먼저 s
를 가변 변수로 선언해야합니다. 그리고 &mut s
을 이용해서 mutable reference를 생성합니다. change
함수의 시그니처도 &mut String
으로 변경합니다.
mutable reference는 하나의 큰 제한사항이 있습니다. 만약 어떤 값에 mutable reference가 존재한다면, 다른 reference는 존재할 수 없습니다.
다음 코드는 컴파일되지 않습니다.
1
2
3
4
5
6
7
8
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> mutable_reference.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{r1}, {r2}")
| ---- first borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0499`.
에러 메시지를 확인해보면 s
를 mutable reference로 2번 빌릴 수 없다는 내용을 확인할 수 있습니다. 첫번째 mutable reference borrow는 r1
의 생성 시점에 발생하고 println!
까지 지속됩니다. mutable reference의 생성과 이용 사이에 새로운 mutable reference borrow가 발생합니다.
동일한 데이터에 대해 동시에 여러 개의 가변 참조를 허용하지 않는 제한은 매우 통제된 방식으로 변경을 허용합니다. 대부분의 언어는 언제든지 데이터를 변경할 수 있도록 하기 때문에, 새로운 rust 사용자들은 이런 부분에서 어려움을 겪곤 합니다. 이런 제한의 장점은 rust가 컴파일 시점에 데이터 병합을 방지할 수 있다는 점입니다.
데이터 경합은 경쟁 조건과 유사하며, 다음 3 가지 동작이 발생할 때 일어납니다.
- 2개 혹은 그 이상의 포인터가 같은 데이터를 같은 시점에 접근할 수 있을 때
- 적어도 하나의 포인터가 데이터를 변경 가능할 때
- 데이터 접근에 동기화 메커니즘이 적용되지 않을 때
데이터 경합은 예상치 못한 동작을 발생시키고, 원인을 분석하고, 고치기 어렵습니다. rust는 이런 문제를 컴파일 시점에 방지해줍니다.
항상 그렇듯이, 새로운 스코프를 이용해서 여러개의 mutable reference를 선언할 수 있습니다.
1
2
3
4
5
6
7
fn main() {
let mut s = String::from("hello");
{
let r1 = &muts;
} // r1의 스코프는 종료되기에 다른 mutable reference를 생성할 수 있습니다.
let r2 = &mut s;
}
mutable reference와 immutable reference을 혼합할 때도, 비슷한 규칙을 강제합니다.
1
2
3
4
5
6
7
8
9
10
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{r1}, {r2}, {r3}")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> mutable_reference.rs:6:14
|
4 | let r1 = &s;
| -- immutable borrow occurs here
5 | let r2 = &s;
6 | let r3 = &mut s;
| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{r1}, {r2}, {r3}")
| ---- immutable borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0502`.
immutable reference가 존재할 때, mutable reference도 선언할 수 없습니다.
immutable reference를 사용할 때, 값이 변경될 것을 예상하지 않기에 immutable reference가 존재할 때 mutable reference를 선언할 수 없습니다.
reference의 스코프는 선언된 시점부터, 마지막으로 reference가 사용된 시점까지 입니다. 다음과 같은 코드는 문제 없이 실행됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}");
// r1과 r2는 이 시점 이후에 사용되지 않기에 reference scope가 끝납니다.
let r3 = &mut s;
println!("{r3}");
}
borrowing error는 몇몇 경우 좌절스럽기도 하지만, 런 타임 시점에 발생할 수 있는 에러를 컴파일 타임에 잡아주는 것임을 기억해야 합니다.
dangling reference
포인터를 사용하는 몇몇 언어에서는 dangling pointer를 생성하기 쉽습니다.
dangling pointer : 다른 곳에 할당된 메모리 위치를 참조하는 잘못된 포인터
반면 rust에서는 컴파일러가 참조가 dangling 참조가 되지 않도록 보장해 줍니다.
1
2
3
4
5
6
7
8
9
10
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
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
36
error[E0106]: missing lifetime specifier
--> dangling.rs:6:16
|
6 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
6 | fn dangle() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
6 - fn dangle() -> &String {
6 + fn dangle() -> String {
|
warning: unused variable: `reference_to_nothing`
--> dangling.rs:2:9
|
2 | let reference_to_nothing = dangle();
| ^^^^^^^^^^^^^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_reference_to_nothing`
|
= note: `#[warn(unused_variables)]` on by default
error[E0515]: cannot return reference to local variable `s`
--> dangling.rs:8:5
|
8 | &s
| ^^ returns a reference to data owned by the current function
error: aborting due to 2 previous errors; 1 warning emitted
Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
아직 학습하지 않은 개념이지만, 코드의 문제를 확인해볼 수 있는 에러 메시지가 있습니다.
1
this function's return type contains a borrowed value, but there is no value for it to be borrowed from
dangle
의 코드를 좀 살펴보면
1
2
3
4
fn dangle() -> &String { // String의 레퍼런스를 리턴합니다.
let s = String::from("hello"); // s는 새로운 String 입니다.
&s // s의 레퍼런스를 리턴하는데
} // 여기서 s의 스코프는 끝나서 s의 메모리는 휘발되기에 문제가 되는 상황이 발생합니다.
이런 경우에는 String 값을 리턴하는 것으로 문제를 해결할 수 있습니다.
1
2
3
4
fn no_dangle() -> String {
let s = String::from("hello");
s
}
slice type
slices
는 컬렉션 전체를 reference하기 보단 컬렉션 내부의 연속적인 엘리멘트들을 reference하는 개념입니다. slice 역시 레퍼런스의 일종입니다.
공백으로 구분된 단어들을 포함한 string을 파라미터로 전달받고, 그 중 첫번째 단어를 리턴하는 함수를 slice를 사용하지 않고 작성해보면
1
2
3
4
5
6
7
8
9
10
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
먼저 전달받은 String
element를 순회하기 위해서 as_bytes
메서드를 이용해 byte의 배열로 변경했습니다.
1
let bytes = s.as_bytes();
iter
메서드를 사용해서 byte 배열을 순회할 수 있는 iterator를 생성하고 enumerate를 이용해 인덱스와 함께 배열을 순회합니다.
1
2
3
for (i, &item) in bytes.iter().enumerate() {
}
.iter().enumerate()
에서 엘리멘트의 레퍼런스를 리턴하기에 &
를 이용해서 순회합니다.
반복문 내부에서 공백을 발견하면, 그 인덱스를 리턴하고 그렇지 않으면 주어진 문자열의 길이를 리턴합니다.
단어의 마지막 인덱스를 이제 구할 수는 있지만 문제가 있습니다. 문자열의 컨텍스트에서만 유효한 값이라는 문제가 존재합니다.
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // 5가 리턴됩니다.
s.clear(); // clear가 실행되면, 빈 문자열이 됩니다.
// word에는 여전히 5라는 값이 들어있는데, s의 길이가 변형되었기에 더이상 word는 유효한 값이 아닙니다.
}
s.clear()
을 호출하고 나서 word
변수를 사용하여도 컴파일 상 아무 문제는 없습니다. word
변수는 s
의 상태와 연결되지 않았기에, word
변수는 여전히 5
라는 값을 가집니다. 이후에 word
변수를 이용해서 첫번째 단어를 꺼내려고 할 때, 에러가 발생합니다.
word
가 유효하지 않은 값을 담게되는 것은 좋지 않습니다. rust는 이런 상황에 대한 해결책으로 쓸 수 있는 string slices라는 것이 있습니다.
string slices
string slice
는 String의 부분에 대한 레퍼런스입니다.
1
2
3
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
전체 문자열에 대한 reference보단, hello
는 String의 부분에 대한 레퍼런스입니다. 슬라이스는 [starting_index..ending_index]
를 명시하는 것으로 생성하며 starting_index
는 슬라이스의 시작 포지션, ending_index
는 종료 인덱스보다 하나 큰 값입니다. 내부적으로 슬라이스 자료구조는 starting position과 슬라이스의 length에 대한 값을 가집니다. let world = &s[6..11]
같은 슬라이스는 s
의 6번째 인덱스에 대한 포인터와 길이 5라는 값을 가집니다.
rust의 ..
는 range 관련 문법입니다.
1
2
3
4
5
6
7
8
9
10
11
12
let s = String::from("hello");
let slice = &s[0::2]; // 아래와 동일합니다.
let slice = &s[::2];
let len = s.len();
let slice = &s[3..len]; // 아래와 동일합니다.
let slice = &s[3..];
let slice = &s[0..len]; // 아래와 동일합니다.
let slice = &s[..];
앞서 작성했던 first_word 함수를 슬라이스를 이용하도록 변경해보면,
1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item ==b' ' {
return &s[..i];
}
}
&s[..]
}
공백 문자를 찾고, 슬라이스를 리턴하게 변경했습니다. first_word
를 이제 호출하게 되면, 원본 배열에 대한 레퍼런스를 리턴받습니다.
함수를 이렇게 선언하게되면, 위에서 발생했던 문제는 더 이상 발생하지 않습니다.
1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // error
println!("first word is : {word}");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> slice.rs:6:5
|
4 | let word = first_word(&s);
| -- immutable borrow occurs here
5 |
6 | s.clear();
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("word is {word}");
| ------ immutable borrow later used here
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0502`.
만약 immutable reference가 존재하면, mutable reference를 생성할 수 없습니다. clear
가 String 값을 제거하기에, mutable reference가 필요합니다. println!
이 clear
이후에 호출되어서 word
의 레퍼런스를 사용합니다. 그렇기에 clear
를 호출한 시점에 immutable reference는 여전히 유효합니다.
string literals as slices
이전에 string literal이 binary에 저장된다고 하였습니다.
1
let s = "hello world";
여기서 s
의 타입은 &str
입니다 : 바이너리의 특정 포인트로의 슬라이스 입니다. &str
은 immutable reference 입니다.
string slices as parameters
앞서 작성한 first_word
함수를 한층 더 개선할 수 있습니다.
1
fn first_word(s: &str) -> &str {}
string slice인 경우, 해당 값을 바로 사용할 수 있고, String
의 경우, String
의 레퍼런스를 전달하면 됩니다. String
대신 레퍼런스를 사용하는 것으로 훨씬 더 유연하게 함수를 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let my_string = String::from("hello world");
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
let word = first_word(&my_string); // String의 레퍼런스를 넘기는 것으로도 사용 가능합니다.
let my_string_literals = "hello world";
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
let word = first_word(my_string_literal);
}
other slices
다른 슬라이스 타입도 존재합니다.
1
2
3
4
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
이 슬라이스는 &[i32]
타입입니다. string slice와 같은 방식으로 동작합니다.
struct
defining and instantiating structs
struct는 tuple과 유사합니다. tuple과 유사하게 다수의 관련 있는 값을 저장합니다. tuple과 유사하게 struct는 서로 다른 타입의 값을 저장할 수 있습니다. tuple과 다른 점은 데이터의 이름을 명시할 수 있습니다.
struct를 정의하기 위해서는 struct
키워드를 사용해야 합니다.
1
2
3
4
5
6
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
struct를 정의하고 사용하려 struct의 인스턴스를 생성해야 합니다.
1
2
3
4
5
6
7
8
fn main() {
let user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
}
}
user1.email
로 User
인스턴스의 email
값을 가져올 수 있습니다. 만약 user1
이 mutable이라면, 특정 필드의 값을 변경할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
fn main() {
let mut user1 = User {
active: true,
username: String::from("someusername123"),
email: String::from("someone@example.com"),
sign_in_count: 1,
}
user1.email = String::from("anotheremail@example.com");
}
인스턴스 전체를 변경 가능하게 선언해야된다는 점에 유의해야합니다. rust는 특정 필드만 변경 가능하게 허용하지 않습니다. 다음과 같이 인스턴스를 생성하고 리턴하는 함수도 사용가능합니다.
1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}
파라미터 명과 struct 필드 명이 동일항 경우 field init shorthand을 적용할 수 있습니다.
1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
email,
username,
sign_in_count: 1,
}
}
struct update
종종 한 인스턴스의 필드 값들 중 일부를 그대로 사용해서 다른 인스턴스를 생성하는 경우가 있습니다.
1
2
3
4
5
6
7
8
fn main() {
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
}
}
위와 같이 변수를 생성할 때, 다음과 같이 간단하게 명시할 수도 있습니다.
1
2
3
4
5
6
fn main() {
let user2 = User {
email: String::from("another@example.com"),
..user1
}
}
user2
는 user1
과 이메일만 다르고 나머지는 모두 동일한 값을 가집니다. ..user1
은 마지막에 작성되어야 정의되지 않은 필드의 값은 user1
의 값으로 채울 수 있습니다.
주의 해야할 점은 struct update는 위와 같은 예시에서는 더 이상 user1
을 쓸 수 없다는 점입니다. user1
의 String
필드 중 username
의 ownership이 이전되었기 때문입니다.
user2
가 email, username 모두 user1
과 다른 값을 사용했다면, user1
은 이후에도 사용가능합니다.
active, sign_in_count 값은 ownership과 관련 없는 데이터 타입이기 때문입니다.
tuple struct
struct를 tuple과 유사하게도 사용 가능합니다. tuple struct는 다음과 같이 정의 가능합니다.
1
2
3
4
5
6
7
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
fn main() {
let black = Color(0,0,0);
let origin = Point(0,0,0);
}
black
과 origin
은 다른 tuple struct이기에 서로 다른 타입입니다. 선언한 인스턴스는 튜플처럼 .0
같은 키워드로 개별 원소에 접근 가능합니다.
ownership of struct data
User
struct 정의에서 &str
타입 대신 String
타입을 사용했습니다. 일부러 String
타입을 사용한 것인데 왜냐하면 struct가 데이터의 소유권을 가지고, 데이터가 struct가 유효한 동안 유효함을 보장하기 위해서 입니다.
struct가 소유권이 없는 다른 데이터의 레퍼러슨를 저장하는 것도 가능합니다. 하지만 그러려면 lifetimes
라는 개념을 사용해야합니다. lifetimes
없이 &str
같은 레퍼런스 자료형을 저장하려 하면 오류가 발생 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct User {
active: bool,
username: &str,
email: &str,
sign_in_count: u64,
}
fn main() {
let user1 = User {
active: true,
username: "newUser",
email: "user@email.com",
sign_in_count: 1,
};
}
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
error[E0106]: missing lifetime specifier
--> struct_ownership.rs:3:15
|
3 | username: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | active: bool,
3 ~ username: &'a str,
|
error[E0106]: missing lifetime specifier
--> struct_ownership.rs:4:12
|
4 | email: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | active: bool,
3 | username: &str,
4 ~ email: &'a str,
|
error: aborting due to 2 previous errors
For more information about this error, try `rustc --explain E0106`.
example program using structs
두 정수를 전달 받아 직사각형의 넓이를 리턴하는 간단한 코드를 다음과 같이 작성할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
fn main() {
let width = 30;
let height = 50;
println!(" area of rectangle is {}", area(width, height));
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
area
함수는 직사각형의 넓이를 계산하긴하지만, 두가지 파라미터를 전달 받는 것이 직관적이지 않습니다. width과 height 값을 함께 넘기는 것이 보다더 가독성 좋고 직관적입니다.
tuple을 사용하여 다음과 같이 변경할 수 있습니다.
1
2
3
4
5
6
7
8
fn main() {
let rect = (30, 50);
println!("area of rectangle is {}", area(rect));
}
fn area(rect: (u32, u32)) -> u32 {
rect.0 * rect.1
}
tuple을 사용하여 값을 묶었습니다. 하지만 어느면에서는 위 코드가 더 불명확한 코드입니다. 넓이를 계산할 때, width, height로 계산하는 것이 아니라 tuple의 인덱스를 이용해 값을 꺼내오고 계산하기 깨문입니다. 이 예시에서는 값의 순서가 바뀌는 것이 중요하진 않지만, 값의 순서 역시 불명확하다는 단점이 있습니다.
struct를 이용해서 의미를 부여할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
println!("area is {}", area(&rect));
}
fn area(rect: &Rectangle) -> u32 {
rect.width * rect.height
}
area
는 이제 하나의 Rectangle
인스턴스를 파라미터로 받습니다. 이때 ownership을 borrow하기 위해 &Rectangle
타입을 파라미터로 받습니다.
area
함수 내부에서도 인덱스로 값을 가져와서 계산하는 것이 아닌 필드명으로 값을 가져와서 계산하는 것을 확인할 수 있습니다.
코드를 짜며 디버깅하는 과정에서 Rectangle
의 인스턴스를 출력하는 것은 도움이 됩니다. println!
매크로를 사용해서 간단하게 출력을 시도해보면 동작하지 않는 것을 확인할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("rect is {}", rect);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
--> rectangle.rs:12:28
|
12 | println!("rect is {}", rect);
| ^^^^ `Rectangle` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
println!
매크로는 기본적으로 Display
라는 formatting을 사용합니다. 기본적인 타입들은 Display
를 구현했기에 아무 문제 없이 출력됐지만, struct는 Display
를 구현해야 합니다.
오류 메시지를 확인해보면, 유용한 정보를 얻을 수 있는데
1
2
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
:?
구분자를 추가하면 println!
은 Debug
출력 형식을 사용합니다. 코드를 아래와 같이 수정해서 실행해보면
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("rect is {rect:?}");
}
아래와 같은 에러 메시지가 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
error[E0277]: `Rectangle` doesn't implement `Debug`
--> rectangle.rs:12:23
|
12 | println!("rect is {rect:?}");
| ^^^^^^^^ `Rectangle` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Rectangle` with `#[derive(Debug)]`
|
1 + #[derive(Debug)]
2 | struct Rectangle {
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
컴파일러가 알려준 해결 방법을 적용하면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("rect is {rect:?}");
}
1
rect is Rectangle { width: 30, height: 50 }
문제 없이 실행되는 것을 확인할 수 있습니다. {:#?}
을 사용하면 필드를 줄별로 구분되게 출력할 수 있습니다.
1
2
3
4
rect is Rectangle {
width: 30,
height: 50,
}
또 한 가지 방법은 dbg!
매크로를 사용하는 것입니다. dbg!
매크로는 ownership을 전달받아, expression의 결과와, 소스 코드의 위치를 출력하고 전달 받은 값의 ownership을 리턴합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let scale = 2;
let rect = Rectangle {
width: dbg!(30 * scale),
height: 50,
};
dbg!(&rect);
}
1
2
3
4
5
[rectangle.rs:10:16] 30 * scale = 60
[rectangle.rs:14:5] &rect = Rectangle {
width: 60,
height: 50,
}
dbg!
에 30*scale
expression을 전달하면, dbg!
는 expression의 value를 리턴하기에, dbg!
를 사용하지 않은 것과 같은 값으로 width에 값이 할당됩니다. dbg!
이 rect
의 ownership을 가져가는 것을 의도하지 않았기에, rect
의 reference를 전달했습니다.
method
methods는 함수와 매우 유사합니다. fn
키워드와 이름으로 선언하고, 파라미터와 리턴 값을 가집니다. 함수와 다른 점은 메소드들은 struct의 context에 정의 됩니다. 그리고 항상 첫번째 파라미터로 self
를 받습니다. self
는 메소드를 실행하는 인스턴스를 의미합니다.
앞서서 정의한 area
함수를 메소드로 변경해보면
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("area is {}", rect.area());
}
Rectangle
의 컨텍스트에 함수를 정의하기 위해서는 impl
블럭으로 시작해야합니다. impl
블럭 내부 내용은 모두 Rectangle
타입과 연관됩니다. area
함수를 impl
블럭 내부에 작성한 뒤에 첫번째 파라미터를 self
로 변경했습니다.
area
의 시그니처를 확인해보면, &self
를 사용하고 있는 것을 확인할 수 있습니다. &self
는 self : &Self
를 줄인것입니다. impl
블럭 내부에서 Self
타입은 impl
블럭이 적용되는 타입과 동일합니다. 메소드는 Self
타입의 self
변수를 첫번째 파라미터로 가져야합니다. 그것을 줄여 self
로 선언하는 것이 허용되고, 이때 ownership을 borrow하려면 &
를 사용해야하는 것을 주의해야합니다.
&self
를 사용해야 메소드가 ownership을 가져가는 것을 방지할 수 있습니다. &self
는 인스턴스의 데이터를 읽어오기만 하는 경우에 사용하고, 만약 인스턴스의 필드 값을 변경해야 한다면, &mut self
를 사용해야 합니다.
함수 대신 메소드를 사용하는 가장 큰 이유는 인스턴스 타입과 관련된 함수를을 impl
블럭 내부에 작성함으로서 코드의 가독성을 향상시킬 수 있습니다.
또 다음과 같이 필드명과 동일한 메소드를 생성할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn width(&self) -> bool {
self.width > 0
}
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
}
if rect.width() {
println!("rectangle width has positive value {}", rect.width);
}
}
width
메소드는 인스턴스의 width
값이 양수인 경우 true
를 리턴합니다. 필드명과 동일한 메소드를 자유롭게 사용할 수 있습니다.
몇몇 언어에서는 필드 값을 리턴하는 getter라는 메소드가 존재합니다만 rust에서는 이런 메소드를 자동으로 생성해주지는 않습니다. getter는 보통 private 변수에 대한 가져오기 위한 public method입니다.
automatic referencing & dereferencing
rust는 automatic referencing, dereferencing을 지원합니다.object.something()
같이 메소드를 호출하면 rust는 자동으로&
,&mut
,*
을 붙여서 오브젝트가 메소드 시그니처와 일치하는지 확인합니다. 다시 말해 다음 두 코드는 동일하게 동작합니다.
1 2 p1.distance(&p2); (&p1).distance(&p2);
associated functions
impl
블럭에 정의된 함수들을 associated function
이라고 부릅니다. 왜냐하면 impl
이후에 작성되는 타입과 연관되어있기 때문입니다. associated function
을 impl
블럭 내부에서 self
를 첫번째 파라미터로 가지지 않는 함수를 정의하는 것으로 정의할 수 있습니다. 이미 이러한 associated function
을 다뤄봤는데, String::from
이 이러한 associated function
입니다.
메소드가 아닌 associated function
은 종종 struct의 새로운 인스턴스를 리턴하는 생성자로 사용됩니다. 종종 new
라는 이름으로 불리는데, 다음과 같이 사용할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn square(size: u32) -> Self {
Self {
width: size,
height: size,
}
}
}
Self
키워드는 리턴 타입이자 impl
키워드 다음에 등장하는 타입으로 이 경우에는 Rectangle
입니다.
이 associated function을 호출하려면 ::
을 사용해야합니다. let sq = Rectangle::sqaure(3);
과 같이 사용할 수 있습니다.
enum
struct를 이용해서 관련있는 필드와 데이터를 묶을 수 있었던 것처럼, enum을 사용하면 관련 있는 여러가지 값들을 함께 묶을 수 있습니다. 예를들어 Rectangle
도 하나의 도형이니 Circle
,Triangle
과 묶을 수 있습니다.
ip 주소를 다루는 작업을 진행하는 경우 enum이 유용하게 쓰일 수 있습니다. 현재 2가지 ip 주소 버전이 주로 쓰이고 있습니다 : v4, v6 모든 ip 주소는 v4 주소이거나 v6 주소입니다.
IpAddrKind
라는 enum 타입을 만들고 다음과 같이 관리할 수 있습니다.
1
2
3
4
enum IpAddrKind {
V4,
V6,
}
다음과 같이 enum 타입의 인스턴스를 생성할 수 있습니다.
1
2
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
다음과 같이 함수 파라미터로 전달받고, 사용할 수 있습니다.
1
2
3
route(IpAddrKind::V4);
fn route(ip_kind: IpAddrKind) {}
enum을 사용하는 것은 더 많은 장점을 가지고 있습니다. ip 주소 타입에 대해서 더 생각해보면, 실제 ip 주소 값을 저장해야된다는 것을 알 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
}
let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
}
선언한 struct IpAddr
은 두가지 필드를 가집니다.
kind
: 이전에 선언한IpAddrKind
enum 필드address
: 실제 주소 값을 저장할 문자열 필드
위와 같이 struct을 사용하는 것보다, enum을 사용하는 것이 더 간결합니다. struct 내부에 enum을 가지는 것보다, enum 값에 데이터를 추가하는 것으로 더 간결하게 값을 묶을 수 있습니다.
1
2
3
4
5
6
7
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::From("::1"));
enum 값에 바로 데이터를 추가하면서 추가적인 struct의 생성이 필요 없게 됐습니다. 위 코드를 통해 선언한 enum 값의 이름이 enum의 인스턴스를 생성하는 생성자로도 사용된다는 것을 확인할 수 있습니다.
IpAddr::V4()
에 String argument를 추가해서V4
인스턴스를 생성하는 것을 확인할 수 있습니다.
struct 대신 enum을 사용할 때 한가지 장점이 더 있습니다. enum 값 마다 다른 데이터를 저장할 수 있습니다.
1
2
3
4
5
6
7
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from"::1");
enum은 다음과 같은 다양한 값을 가질 수 있습니다.
1
2
3
4
5
6
enum Message {
Quit,
Move {x: i32, y: i32},
Write(String),
ChangeColor(i32, i32, i32),
}
위 enum은 서로 다른 타입을 가집니다.
Quit
: 아무런 데이터와 연관되어있지 않습니다.Move
: struct와 유사하게 필드를 가집니다.Write
: 단일 문자열을 가집니다.ChangeColor
: 3가지i32
값을 가집니다.
위와 같이 enum을 선언하는 것은 서로 다른 struct을 정의하는 것과 유사합니다.
1
2
3
4
5
6
7
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
하지만 만약 위와 같이 서로 다른 struct을 이용해 정의 했다면, 서로다른 Message 타입에서 공통적으로 사용할 함수를 정의하기 어렵습니다.
enum과 struct 타입 간 또 다른 유사한 점은 impl
을 이용해서 함수를 정의할 수 있다는 점입니다.
1
2
3
4
5
6
7
8
impl Message {
fn call(&self) {
// method body
}
}
let m = Message::Write(String::from("hello"));
m.call();
위와 같이 enum Message
의 메세지를 추가하고 사용할 수 있습니다.
option enum
Option
은 표준 라이브러리에서 정의한 enum입니다. Option
타입은 무언가 있을 수도, 없을 수도 있는 값을 감싸는데 사용합니다.
예를들어, non-empty list의 첫번째 아이템을 요청하면 데이터를 리턴 받습니다. empty list에 데이터를 요청하면 아무 것도 돌아오지 않을 것입니다.
rust는 다른 프로그래밍 언어들에서 다루는 null feature가 제공되지 않습니다.
null 값의 문제점은 null 값을 non-null 값으로 다루려고하는 순간 에러가 발생한다는 것입니다. null 값 관련해서 여러가지 문제들이 있지만, null의 개념은 매우 유용합니다. null의 문제는 개념이 아니라, 특정 구현 방법에 있습니다. 그렇기에 rust는 null을 다루지 않습니다. 대신 값이 존재하는지, 존재하는지를 encode하는 enum이 있는데, 이 enum은 Option<T>
입니다.
Option
은 표준 라이브러리에 다음과 같이 정의되어 있습니다.
1
2
3
4
enum Option<T> {
None,
Some(T),
}
Option<T>
enum은 Option::
prefix를 작성하지 않고도 간편하게 사용할 수 있습니다.
<T>
문법은 제네릭 타입 파라미터를 의미합니다. Options
의 값은 다음과 같이 사용할 수 있습니다.
1
2
3
4
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
some_number
의 타입은 Option<i32>
입니다. some_char
의 타입은 Option<char>
입니다. rust는 Some
의 값에 따라 타입을 유츄할 수 있습니다. absent_number
같은 경우에는 타입 정보를 유츄할 수 없기에 Option<i32>
라고 명시해준 것을 확인할 수 있습니다.
Some
값을 가지고 있을 때, 값이 존재하고 Some
내부에서 유효하다는 것을 알 수 있습니다. None
값을 가지고 있으면 null과 유사한 상태라는 것을 알 수 있습니다.
왜 Option<T>
을 사용하는 것이 null을 다루는 것보다 나을까요?
Option<T>
와 T
는 서로 다른 타입이기에, 컴파일러가 Option<T>
타입이 유효한 값을 가지지 않는 이상 사용을 허용하지 ㅇ낳습니다.
다음 코드는 컴파일 되지 않습니다.
1
2
3
4
5
6
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
error[E0277]: cannot add `Option<i8>` to `i8`
--> option.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
= help: the following other types implement trait `Add<Rhs>`:
`&'a i8` implements `Add<i8>`
`&i8` implements `Add<&i8>`
`i8` implements `Add<&i8>`
`i8` implements `Add`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
i8
과 Option<i8>
의 타입이 다르기에, + 연산을 수행할 수 없다는 오류 메시지를 확인할 수 있습니다. 연산을 수행하기 위해서는 Option<T>
을 T
로 변환해야합니다. 이 과정에서 null을 확인할 수 있기에, null 값을 null이 아닌 것처럼 사용하는 오류를 방지할 수 있습니다.
Option<T>
에서 값을 꺼내오는 함수로는 unwrap
, unwrap_or
, unwrap_or_else
등이 있습니다.
Option
관련 보다 더 많은 정보는 공식 문서에서 확인 가능합니다.
match
rust에는 match
라는 매우 강력한 control flow construct가 있습니다. match
의 장점은 컴파일러가 발생가능한 모든 경우가 확인되었는지를 확인한다는 점입니다.
match
expression을 동전 분류기처럼 생각할 수 있습니다. 동전이 동전 분류기에 들어가서 서로 다른 크기의 구멍을 지나가다, 정확히 사이즈가 맞는 구멍에서 분류됩니다.
다음과 같이 동전 분류하는 함수를 match
을 이용해서 작성 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
if
문과 유사하게 보이지만, 차이점은 if는 조건이 boolean 값이어야 하지만, match에서는 어느 타입이어도 문제되지 않습니다.
각 match
의 줄기는 두 부분으로 구성되어 있습니다 줄기의 첫번째 부분은 Coin::Penny
라는 값을 가집니다. 그리고 =>
오퍼레이터가 패턴을 분리하고, 실행할 코드가 그 다음에 작성되어있습니다.
match
expression이 실행되면, 주어진 값을 각 줄기의 패턴과 비교합니다. 만약 패턴과 값이 일치하면, 해당 패턴의 코드가 실행됩니다. 만약 일치하지 않는다면, 다음 줄기로 이동해 비교를 이어갑니다.
각 줄기에 포함된 코드는 expression입니다. 각 줄기에 있는 expression은 match
expression의 리턴 값이 됩니다.
만약 match 줄기의 코드가 짧다면, {}
을 작성하지 않습니다만, 줄기의 코드가 길어진다면 {}
을 이용해 감쌀 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("lucky penny");
1
}
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}
match 줄기의 또 다른 특징은 enum의 값을 추출할 수 있다는 점입니다. 동전 분류기의 예시를 살짝 변형해서, quarter 코인에 UsState 관련 값이 추가됐습니다.
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
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// ~~~
}
enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState),
}
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("state is {state:?}");
25
}
}
}
value_in_cents(Coin::Quarter(UsState::Alaska))
을 호출하면, coin
은 Coin::Quarter(UsState::Alaska)
입니다. match문이 실행되면,Coin::Quarter(state)
에서 패턴이 일치됩니다. 이때, state
에 UsState::Alaska
값이 bind됩니다. 이 bind 된 값을 println!
에서 사용하는 것을 확인할 수 있습니다.
matching with Option<T>
Coin
에서 했던 것처럼, Option<T>
를 다룰 수 있습니다.
Option<i32>
를 파라미터로 받고, 만약 값이 존재한다면, 값에 1을 추가하고, 만약 값이 존재하지 않는다면, None을 리턴하는 함수를 다음과 같이 작성할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);
Some(5)
는 Some(i)
를 만족합니다. i
값은 Some
의 데이터를 bind하여 5의 값을 가집니다. 그리고 Some(6)
이 리턴됩니다.
matches are exhaustive
match
의 특징 중 하나는, 줄기의 패턴이 모든 경우를 처리해야합니다. 다음 코드는 컴파일되지 않습니다.
1
2
3
4
5
fn plus_one(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i+1),
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0004]: non-exhaustive patterns: `None` not covered
--> match.rs:2:11
|
2 | match x {
| ^ pattern `None` not covered
|
note: `Option<i32>` defined here
--> /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/core/src/option.rs:574:1
::: /rustc/eeb90cda1969383f56a2637cbd3037bdf598841c/library/core/src/option.rs:578:5
|
= note: not covered
= note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
rust는 위 코드가 모든 경우를 처리하지 않는 것을 압니다. 그렇기에 컴파일되지 않는 것을 확인할 수 있습니다.
enum을 사용할 때, 몇몇 특정한 값에 대해서는 특별한 액션을 취하고 싶고, 나머지 값들에 대해서는 공통적인 함수를 실행하고 싶을 수 있습니다. 예를들어 주사위를 굴렸을 때 3이나오면, 플레이어는 움직이지 않고 모자를 얻는다는지, 주사위를 굴렸을 때 7이 나오면, 모자가 사라지고, 나머지 주사위가 나오면 움직이는 경우가 있을 수 있습니다.
예시 로직을 구현한 함수는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
other => move_player(other),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn move_player(num_spaces: u8) {}
마지막 other
줄기가 나머지 모든 경우를 처리합니다. 위 코드는 모든 경우를 명시하지는 않았지만, other
을 이용했기에 정상적으로 컴파일되고 실행됩니다.
만약 나머지 경우에 해당되는 변수를 사용할 일이 없다면, _
을 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => reroll(),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
fn reroll() {}
rust는 사용하지 않는 변수에 대해서 경고하기에, _
을 사용해서 나머지 경우에 변수를 사용하지 않는 경우를 처리할 수 있습니다.
혹은 다음처럼 나머지 경우 아무런 처리를 하지 않는 것을 명시할 수 있습니다.
1
2
3
4
5
6
7
8
9
let dice_roll = 9;
match dice_roll {
3 => add_fancy_hat(),
7 => remove_fancy_hat(),
_ => (),
}
fn add_fancy_hat() {}
fn remove_fancy_hat() {}
concise control with if let
if let
구문은 if
구문과 let
을 합쳐서 하나의 패턴에 대해서만 특정 처리를 하고 나머지는 무시하는 구문입니다.
다음 두 코드는 서로 동일하게 동작합니다.
1
2
3
4
5
let config_max = Some(3u8);
match config_max {
Some(max) => println!("maximum is {max}"),
_ => (),
}
1
2
3
4
let config_max = Some(3u8);
if let Some(max) = config_max {
println!("maximum is {max}");
}
if let
을 사용하여 match
문을 보다 더 간결하게 사용하는 것을 확인할 수 있습니다.
다음과 같이 else 문도 적용할 수 있습니다.
1
2
3
4
5
let mut count = 0;
match coin {
Coin::Quarter(state) => println!("state from {state}"),
_ => count += 1,
}
1
2
3
4
5
6
let mut count = 0;
if let Coin::Quarter(state) = coin {
println!("state from {state}");
} else {
count += 1;
}
managing projects with packages, crates and modules
규모가 큰 프로그램을 작성할 때, 코드를 구성하고 관리하는 것은 매우 중요합니다. 연관된 기능을 제공하는 코드끼리 묶고 분리하는 것이 특정 기능의 동작에 대해 궁금할 때, 살펴봐야할 부분을 명확하게 합니다.
지금까지 작성한 대부분의 프로그램은 하나의 모듈안에 하나의 파일로서 동작했습니다. 프로젝트의 규모가 커가면서, 작성한 코드들을 서로 다른 모듈과 파일들로 분리하여 관리해야합니다.
패키지는 다수의 binary crates을 포함할 수 있고, 선택적으로 하나의 라이브러리 carate을 포함합니다. 패키지가 커가며 특정 crate을 추출해 분리할 수 잇고, 이런 것들은 외부 의존성이 됩니다.
코드 관리와 연관된 개념 중 하나로는
scope
가 있습니다. 코드를 읽거나, 작성하거나, 컴파일할 때 프로그래머 그리고 컴파일러는 특정 위치의 특정 이름이 변수인지, 함수인지, struct인지, enum인지, module, 혹은 상수인지를 알아야합니다. 스코프를 생성해 어떤이름이 scope 내부에 있고 외부에 있는지를 변경할 수 있습니다.
rust는 코드 관리를 위해 다수의 기능을 제공합니다.
- Packages : cargo 기능 중 하나로, crates들을 빌드, 테스트, 공유할 수 있게 해줍니다.
- Crates : 라이브러리 혹은 실행 프로그램을 만드는 모듈의 tree입니다.
- Modules & use : organization, scope 그리고 경로의 visibility를 관리하게 해줍니다.
- Paths : struct, function, 그리고 module의 네이밍하는 방법입니다.
packages and crates
crate
은 rust 컴파일러가 고려하는 가장 작은 단위의 코드입니다. cargo
대신 rustc
를 사용하여 하나의 소스코드 파일을 컴파일할 때도, 컴파일러는 소스 코드 파일을 crate로 고려합니다. crate는 모듈을 포함할 수 잇고, 다른 파일에 위치한 모듈들고 함께 컴파일됩니다.
crate은 binary crate 혹은 library crate이 될 수 있습니다.
- binary crate
- 실행 프로그램으로 컴파일하는 프로그램을 의미합니다.
- 반드시
main
함수를 포함해야하며, 지금까지 다룬 것들이 모두 binary crate입니다.
- library crate
main
함수를 포함하지 않습니다. 그리고 실행 프로그램으로 컴파일되지 않습니다.- 대신 다양한 프로젝트에 공유되고 제공하는 기능을 정의합니다.
- rust 사용자들이 crate라고 의미하면 library crate을 의미합니다.
crate root는 컴파일러가 컴파일을 시작하는 소스 파일로 crate의 root module을 구성합니다.
package는 특정 기능을 제공하는 하나 혹은 다수의 crate입니다. package는 어떻게 crate을 빌드할지를 정의한 Cargo.toml
을 포함합니다. cargo도 사실 코드를 빌드할 때 필요한 여러가지 binary crate을 포함한 package입니다. cargo 패키지 역시 여러 binary crate이 의존하는 여러 library crate을 포함합니다.
package는 binary crate을 제한 없이 가질 수 있지만, library crate은 최대의 경우 하나를 가집니다. package는 최소 하나의 library 혹은 binary crate을 가집니다.
cargo new
명령어로 새로운 패키지를 생성해보면, 다음과 같은 파일들이 생성됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
❯ cargo new my-project
Creating binary (application) `my-project` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
❯ cd my-project
❯ ll
total 16
drwxr-xr-x 6 dongjunkim staff 192 Sep 30 15:35 .
drwxr-xr-x 5 dongjunkim staff 160 Sep 30 15:35 ..
drwxr-xr-x 9 dongjunkim staff 288 Sep 30 15:35 .git
-rw-r--r-- 1 dongjunkim staff 8 Sep 30 15:35 .gitignore
-rw-r--r-- 1 dongjunkim staff 81 Sep 30 15:35 Cargo.toml
drwxr-xr-x 3 dongjunkim staff 96 Sep 30 15:35 src
Cargo.toml
파일이 생성된 것을 확인할 수 있고, src 디렉터리 하위에는 main.rs
라는 코드 파일이 생성됐습니다.
생성된 Cargo.toml
파일 내용은 다음과 같습니다.
1
2
3
4
5
6
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
[dependencies]
파일의 내용을 보면 src/main.rs
에 대한 내용은 일절 찾아볼 수 없는 것을 알 수 있습니다. cargo는 컨벤션으로 src/main.rs
를 binary crate에 대한 crate root로 지정합니다. 그와 유사하게 src/lib.rs
파일이 존재한다면, library crate이 존재하고, src/lib.rs
을 crate root로 판단합니다. cargo는 crate root 파일들을 rustc
컴파일로 전달해 라이브러리 혹은 실행 파일을 빌드합니다.
생성된 파일을 확인해보면, src/main.rs
파일만 존재하는 것을 확인할 수 있습니다. my-project
라는 이름으로 binary crate만 존재한다는 것을 확인할 수 있고, 만약 src/main.rs
, src/lib.rs
가 존재한다면, 동일한 이름으로 binary, 그리고 library crate이 존재하는 것입니다. 패키지는 src/bin
디렉터리 내부에 다수의 binary crate을 위치시킬 수 있습니다.
defining modules to control scope and privacy
모듈과 path의 디테일에 대해서 알아보기 이전에, 컴파일러에서 module, path, use 키워드, pub 키워드가 어떻게 동작하는지 알아야 합니다.
- crate root로부터 시작 : crate을 컴파일할 때, 컴파일러는 먼저 crate root file을 확인합니다.
src/lib.rs
혹은src/main.rs
을 확인 합니다.
- 모듈 선언 : crate root 파일에서 새로운 모듈을 선언할 수 있습니다.
mod garden
으로garden
모듈을 선언할 수 있습니다.- 컴파일러는 다음과 같은 위치를 보며 모듈의 코드를 찾습니다.
mod garden
이후 작성된 코드 블럭src/garden.rs
src/garden/mod.rs
- 하위 모듈 선언 : crate root을 제외한 어느 파일에서든 서브 모듈을 선언할 수 있습니다.
mod vegetables
을src/garden.rs
에서 선언할 수 있습니다.- 컴파일러는 하위 모듈의 코드를 다음과 같은 위치에서 확인합니다.
mod vegetables
이후 작성된 코드 블럭src/garden/vegetables.rs
src/garden/vegetables/mod.rs
- paths to code in modules : 모듈이 crate의 일부가 되고, 동일한 crate 내에서 privacy rule을 충족한다면, 해당 모듈의 코드를 사용 가능합니다.
- 만약
Asparagus
타입이 garden vegetables 모듈 내부에서 선언된다면,crate::garden::vegetables::Asparagus
로 사용 가능합니다.
- 만약
- private vs public : 모듈 내부 코드는 부모 모듈에 기본적으로 노출되지 않습니다.
- 모듈을 공개하기 위해서는
pub mod
로 모듈을 선언해야합니다. - 모듈 내부의 아이템들도 공개하기 위해서는
pub
키워드가 필요합니다.
- 모듈을 공개하기 위해서는
use
키워드 : 스코프 내에서use
키워드는 긴 path 경로를 줄이기 위해 사용합니다.crate::garden::vegetables::Asparagus
를 접근 가능한 스코프에서use crate::garden::vegetables::Asparagus
을 통해Asparagus
로 해당 타입을 스코프 내에서 사용가능합니다.
backyard
라는 crate를 생성하고, Asparagus
타입을 사용하는 예시입니다.
1
2
3
4
5
6
7
8
9
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│ └── vegetables.rs
├── garden.rs
└── main.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/main.rs
use crate::garden::vegetables::Asparagus;
pub mod garden;
fn main() {
let plant = Asparagus {};
println!("I'm growing {plant:?}!");
}
// src/garden.rs
pub mod vegetables;
// src/garden/vegetables.rs
#[derive(Debug)]
pub struct Asparagus {}
grouping related code in modules
module은 코드들을 묶어서 코드의 가독성과 재사용성을 높입니다. module은 기본적으로 private이기에 코드의 privacy을 조절할 수 있습니다. private item들은 외부에서는 사용이 불가능한 내부 구현체입니다.
cargo new restaurant --lib
명령어로 library crate을 생성할 수 있습니다. 그리고 src/lib.rs
에 다음과 같이 함수 시그니처들을 정의할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}
fn seat_at_table() {}
}
mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
}
위와 같이 모듈을 사용해서 특정 코드의 정의, 위치를 손쉽게 파악할 수 있습니다.
paths referring to an item in module tree
rust module tree에서 아이템을 찾기 위해서는 file system과 동일한 경로를 사용해야합니다. 함수를 호출하기 위해서는, 함수의 경로를 알아야합니다.
path는 두가지 형태를 취할 수 있습니다.
- absolute path : crate root로부터 시작하는 전체 경로입니다.
- external create 코드의 경우, 경로는 crate 이름으로 시작하고, 내부 코드인 경우
crate
으로 시작합니다.
- external create 코드의 경우, 경로는 crate 이름으로 시작하고, 내부 코드인 경우
- relative path : 현재 모듈에서 시작하는 경로입니다.
self
,super
같은 구분자를 사용합니다.
absolute path, relative path 모두 ::
사용하여 구분자들을 분리합니다.
앞선 예제에서 작성한 코드를 살짝 변경하면,
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
pub mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {
println!("added to waitlist");
}
// fn seat_at_table() {}
}
/*
pub mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
*/
}
pub fn eat_at_restaurant() {
// absolute path
crate::front_of_house::hosting::add_to_waitlist();
// relative path
front_of_house::hosting::add_to_waitlist();
}
절대 경로를 사용할지, 상대 경로를 사용할지는 정해진 것이 없으며 아이템 정의 코드와 아이템 코드를 분리할지 혹은 함께 할지와 관련있긴 합니다.
위 예제에서
front_of_house
모듈과eat_at_restaurant
함수를customer_experience
모듈 내부로 이동한다면add_to_waitlist
함수의 절대 경로는 수정해야하지만, 상대경로는 여전히 유효합니다. 하지만 만약eat_at_restaurant
함수를dining
이라는 모듈로 분리한다면,add_to_waitlist
함수의 절대 경로는 유효하지만, 상대 경로는 변경해줘야합니다.
이때 코드를 빌드해보면 private
관련 에러를 마주치게 됩니다.
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
$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
--> src/lib.rs:9:28
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^
error[E0603]: module `hosting` is private
--> src/lib.rs:12:21
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ --------------- function `add_to_waitlist` is not publicly re-exported
| |
| private module
|
note: the module `hosting` is defined here
--> src/lib.rs:2:5
|
2 | mod hosting {
| ^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
hosting
모듈이 private이라 접근할 수 없다는 메시지를 확인할 수 있습니다. rust에서 모든 아이템은 부모 모듈에 private입니다. 아이템을 private으로 만들고 싶으면, 모듈 내부에 집어넣으면 됩니다.
부모 모듈의 아이템들은 자녀 모듈의 private 아이템들을 사용할 수 없습니다. 하지만 자녀 모듈의 아이템들은 부모의 아이템들을 사용할 수 있습니다. 이때 pub
키워드를 사용해 모듈을 공개할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}
}
}
pub fn eat_at_restaurant() {
// Absolute path
crate::front_of_house::hosting::add_to_waitlist();
// Relative path
front_of_house::hosting::add_to_waitlist();
}
하지만 위 코드도 여전히 오류를 발생시킵니다.
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
$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:9:37
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^
error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:12:30
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
|
note: the function `add_to_waitlist` is defined here
--> src/lib.rs:3:9
|
3 | fn add_to_waitlist() {}
| ^^^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
pub
키워드를 사용하여 hosting
모듈은 접근가능해졌지만, add_to_waitlist
함수는 여전히 private인 것을 알 수 있습니다.
부모 모듈에서 자녀 모듈의 아이템을 사용하기 위해서는 아이템에도 pub
키워드가 필요합니다.
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
pub mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {
println!("added to waitlist");
}
// fn seat_at_table() {}
}
/*
pub mod serving {
fn take_order() {}
fn serve_order() {}
fn take_payment() {}
}
*/
}
pub fn eat_at_restaurant() {
// absolute path
crate::front_of_house::hosting::add_to_waitlist();
// relative path
front_of_house::hosting::add_to_waitlist();
}
이제 코드는 컴파일 될 것이고, 절대 경로와 상대 경로를 살펴볼 것입니다.
절대 경로를 확인해보면 crate
, crate root로부터 시작하는 것을 알 수 있습니다. front_of_house
모듈은 crate root, src/lib.rs
에 정의되어 있습니다. pub
키워드가 없지만, eat_as_restaurant
와 같은 레벨에 선언되어있기에, 함수에서 모듈을 접근할 수 있습니다. hosting
모듈과 add_to_waitlist
모두 pub
키워드를 포함하고 있기에, 부모 모듈인 crate root에서 사용 가능합니다.
상대 경로는 모두 동일하지만, front_of_house
에서부터 시작하는 것이 다릅니다. front_of_house
모듈은 eat_at_restaurant
와 같은 모듈에 선언되어 있습니다.
super
부모 모듈에서 시작하는 상대적인 경로를 super
를 이용해서 작성할 수 있습니다. 파일 시스템 ..
와 유사합니다.
super
는 다음과 같이 사용 가능합니다.
1
2
3
4
5
6
7
8
9
10
fn deliver_order() {}
mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order();
}
fn cook_order() {}
}
structs and enums public
pub
키워드를 사용해서 struct와 enum을 public으로 돌릴 수 있습니다. 하나 주의해야 할 점은, struct는 public으로 선언해도, 필드는 여전히 private입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}
impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}
pub fn eat_at_restaurant() {
let mut meal = back_of_house::Breakfast::summer("Rye");
meal.toast = String::from("Wheat");
println!("toast {}", meal.toast);
// 아래 코드는 실행되지 않습니다. private이기에 접근 불가합니다.
meal.seasonal_fruit = String::from("blueberries");
}
struct와 다르게 enum은 public이면 enum의 데이터도 모두 public입니다.
bringing paths into scope with the use keyword
함수를 호출할 때마다 전체 경로를 작성해야한 일은 불편하고, 반복적입니다. 이런 과정을 간략화해주는 use
키워드가 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {
println!("added to waitlist");
}
}
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
use
를 추가하는 것을 파일 시스템에서 심볼릭 링크와 유사해보입니다. use crate::front_of_house::hosting
을 crate root에 추가하는 것으로 hosting
은 스코프에서 유효한 이름이 됩니다. use
에 명시된 경로도 다른 경로들과 마찬가지로 privacy check을 합니다.
use
는 use
가 등장하는 특정한 스코프에만 유효하다는 것을 유의해야합니다.
다음 코드는 컴파일 되지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting;
mod customer {
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}
use
statement와 다른 scope이기에 컴파일 되지 않습니다.
use
를 customer
모듈에 추가하는 것으로 코드를 컴파일할 수 있습니다.
앞서 use
를 사용할 때 use crate::front_of_house::hosting
을 작성하고 hosting::add_to_waitlist
을 사용했는지 의문 일 수 있습니다.
다음 코드도 동작합니다.
1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
use crate::front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
add_to_waitlist();
}
두 방식 모두 동작하지만, use crate::fron_of_house::hosting
으로 사용하는 것이 더 권장됩니다. use
로 모듈을 가져와서 함수를 사용할 때 모듈을 포함해서 호출하는 것이 함수의 선언 위치도 보다 선명하게 구분되고 직관적입니다.
struct나 enum 같은 경우에는 전체 경로를 use
에 포함하는 것이 권장됩니다.
1
2
3
4
5
6
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
struct 명이 동일하지만, 모듈이 다른 경우에는 모듈 단위로
use
를 사용해야 합니다.
동일한 이름의 type을 import 할때 쓸 수 있는 또 한 가지 방법은 as
를 사용해 이름을 변경하는 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::fmt;
use std::io;
fn function1() -> fmt::Result {
// --snip--
}
fn function2() -> io::Result<()> {
// --snip--
}
----
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
}
fn function2() -> IoResult<()> {
// --snip--
}
re-exporting
use
키워드를 사용하여 이름을 스코프에서 사용할 때, 해당 이름은 private입니다. 이때 pub use
라는 re-exporting을 하용하여 이 이름을 외부 스코프로 노출할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
이전에는 외부 코드가 add_to_waitlist
함수를 사용하기 위해서는 restaurant::front_of_house::hosting::add_to_waitlist()
로 호출해야했습니다. pub use
로 이름을 re-export 했기에, 외부 코드는 restaurant::hosting::add_to_waitlist()
로 사용 가능합니다.
re-exporting은 코드 내부 구조가 다른 프로그래머들의 생각과는 다를 때 유용합니다. 식당의 비유를 이어서 사용하면, 식당 도메인에 익숙한 사람은
front_of_house
,back_of_house
개념이 익숙하지만, 관련 지식이 없는 사람은 어색할 수 있습니다. 이때 re-exporting을 사용하여 다른 프로그래머도 익숙한 코드로 구성할 수 있습니다.
use external packages
이전에 작성한 프로그램에서 Cargo.toml
에 다음 의존성을 추가해서 사용했습니다.
1
rand = "0.8.5"
rand
의존성을 Cargo.toml
에 추가해 Cargo
로 하여금 rand
패키지를 다운 받고, rand
패키지를 프로젝트에서 사용가능하게 설정했습니다.
만약 같은 crate 혹은 모듈에서 많은 아이템을 사용하고 있다면, use
statement가 많은 코드 스페이스를 차지하게 됩니다.
1
2
use std::cmp::Ordering;
use std::io;
위와 같이 작성하는 대신, 한줄로 깔끔하게 작성할 수 있습니다.
1
2
3
4
5
6
7
8
use std::{cmp::Ordering, io};
// ---
use std::io;
use std::io::Write;
// --- 아래와 동일합니다.
use std::io::{self, Write};
// 아래와 같이 전부를 경로에 등록할 수 있습니다.
use std::collections::*;
separating moduels into different files
지금까지 다룬 예시들에서는 주로 여러 가지 모듈들을 하나의 파일에 다뤘습니다. 모듈의 크기가 커지면 다른 파일로 분리하는 것이 관리에 용이합니다.
1
2
3
4
5
6
7
8
// src/lib.rs
mod front_of_house;
pub use crate::front_house::hosting;
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
작성했던 crate root 파일을 위와 같이 작성해서 module을 다른 파일로 분리할 수 있습니다.
1
2
3
4
5
6
// src/front_of_house.rs
pub mod hosting {
pub fn add_to_waitlist() {
}
}
위와 같이 작성하면 front_of_house
와 crate root는 분리됩니다.
1
2
3
4
5
// src/front_of_house.rs
pub mod hosting;
// src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}
위와 같이 작성하면 front_of_house
와 hosting
도 분리됩니다.
앞서서 모듈을 확인하는 위치에 관해서 3가지를 다뤘었는데, 그중에서
src/front_of_house/hosting.rs
로 관리하는 것이 가장 권장되는 방법입니다.