Post

rust.01

rust programming concept, basic, ownership, struct, enum, package managing

rust.01

기본 개념

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도 존재합니다. 불변 변수와 동일하게 값을 변경할 수 없지만, 몇가지 차이점도 존재합니다.

  1. constants에서는 mut를 사용할 수 없습니다.
    • const는 기본적으로 불변인 것이 아니라 항상 불변입니다.
    • const 키워드를 이용해 constants를 선언할 수 있고, 타입을 명시해야합니다.
  2. constants는 어떤 스코프에서도 선언 가능합니다.
  3. 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은 다음과 같습니다.

lengthsignedunsigned
8 biti8u8
16 biti16u16
32 biti32u32
64 biti64u64
128 biti128u128
archisizeusize

isizeusize 자료형은 실행중인 컴퓨터의 아키텍쳐에 따라 정해집니다. 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+611이라는 값을 가집니다.. 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이라는 값을 가집니다.

내부 반복문을 사용할 때, breakcontinue는 가장 내부 반복문에 적용됩니다. loop label을 명시해서 가장 내부 반복문 말고 breakcontinue를 실행할 반복문을 지정할 수 있습니다.

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을 호출할 때 필요한 메모리를 요청합니다. 이는 프로그래밍 언어에서 모두 공통적으로 동작합니다.

두번째 단계는 다릅니다. 가비지 컬렉터를 사용하는 언어 같은 경우에는 가비지 컬렉터가 더 이상 사용하지 않는 메모리를 제거합니다. 가비지 컬렉터가 사용하지 않는 메모리를 해제하기에 개발자는 신경 쓰지 않아도 됩니다. 가비지 컬렉터가 없으면 개발자가 명시적으로 메모리를 해제해야합니다.

역사적으로 개발자가 직접 메모리를 해제하는 것은 어려운 일이었습니다. 만약 메모리 해제 작업을 까먹으면 메모리가 낭비될 것입니다. 만약 너무 빨리 해제하면 변수는 유효하지 않게됩니다. 그리고 실수로 두번해제하게 되는 상황에서는 버그가 발생합니다. allocatefree는 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에 할당합니다. 결과적으로 xy는 같은 값을 가집니다. 코드는 예상대로 동작하고, 그 이유는 integer가 simple value이기 때문입니다.

String의 경우를 생각해보면,

1
2
let s1 = String::from("hello");
let s2 = s1;

integer에서 다룬 예시와 비슷하기에, 동작 방식도 유사하다고 생각할 수 있습니다. s1의 값을 복사해서 s2에 전달한다고 생각 할 수도 있지만, 실제 동작은 그렇지 않습니다.

https://doc.rust-lang.org/book/img/trpl04-01.svg

String 내부 동작을 위 그림에서 확인할 수 있습니다. String은 3가지 구성 성분을 가지고 있습니다. string의 데이터를 담고 있는 메모리 포인터, length 그리고 capacity 입니다. 왼쪽 데이터들은 stack에 저장되고 오른쪽 데이터들은 heap에 저장된 데이터입니다.

length란 바이트 단위로 String의 컨텐츠가 사용하고 있는 메모리 양을 의미합니다. capacity는 바이트 단위로 String이 allocator로부터 할당 받은 메모리 크기를 의미합니다.

go 에서 slice length, capacity 개념과 유사합니다.

s1s2에 할당하면, String의 데이터는 복사됩니다. 다시 말해 stack의 저장된 포인터, length, capacity 값이 복사됩니다. 그 결과 다음 그림 같은 메모리 구조가 구성됩니다. https://doc.rust-lang.org/book/img/trpl04-02.svg

s1s2가 같은 메모리 주소를 point하고 있는 것을 알 수 있습니다. rust는 아래와 같이 동작하지 않습니다. https://doc.rust-lang.org/book/img/trpl04-03.svg

위와 같이 동작했다면, 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를 나타내는 그림은 다음과 같습니다. https://doc.rust-lang.org/book/img/trpl04-04.svg

s1s2move되면서 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는 유효하고, ymove되지 않았습니다.

그 이유는 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}");
}

stakes_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으로 변경된 것을 확인할 수 있습니다. https://doc.rust-lang.org/book/img/trpl04-05.svg

&이 reference를 나타냅니다. *는 dereference를 나타냅니다.

작성한 코드를 좀 더 분석해보면

1
2
let s1 = String::from("hello");
let len = calculate_length(&s1);

&s1s1의 값에 대한 참조를 생성합니다. 참조를 생성하는 것이지, 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라는 값을 가집니다.

https://doc.rust-lang.org/book/img/trpl04-06.svg

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.emailUser인스턴스의 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
  }
}

user2user1과 이메일만 다르고 나머지는 모두 동일한 값을 가집니다. ..user1은 마지막에 작성되어야 정의되지 않은 필드의 값은 user1의 값으로 채울 수 있습니다.

주의 해야할 점은 struct update는 위와 같은 예시에서는 더 이상 user1을 쓸 수 없다는 점입니다. user1String 필드 중 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);
}

blackorigin은 다른 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를 사용하고 있는 것을 확인할 수 있습니다. &selfself : &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 functionimpl블럭 내부에서 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`.

i8Option<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))을 호출하면, coinCoin::Quarter(UsState::Alaska)입니다. match문이 실행되면,Coin::Quarter(state)에서 패턴이 일치됩니다. 이때, stateUsState::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 vegetablessrc/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 {}

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으로 시작합니다.
  • 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을 합니다.

useuse가 등장하는 특정한 스코프에만 유효하다는 것을 유의해야합니다.

다음 코드는 컴파일 되지 않습니다.

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이기에 컴파일 되지 않습니다.

usecustomer모듈에 추가하는 것으로 코드를 컴파일할 수 있습니다.

앞서 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_househosting도 분리됩니다.

앞서서 모듈을 확인하는 위치에 관해서 3가지를 다뤘었는데, 그중에서 src/front_of_house/hosting.rs로 관리하는 것이 가장 권장되는 방법입니다.

This post is licensed under CC BY 4.0 by the author.