/ PROGRAMMING, RUST

Rust - 입문하기

Rust는 C/C++과 같은 수준의 성능을 제공하면서도, memory-safe한 프로그래밍 언어로써 주목을 받고 있죠. 백악관에서 개발자들에게 C/C++를 memory-safe한 언어로 대체하라고 지시한 사례는 이미 유명합니다. 대체 언어로 가장 유력한 후보가 바로 Rust라고 할 수 있어요. 이미 Microsoft에서는 Windows의 일부 컴포넌트를 Rust로 재작성하는 프로젝트를 진행 중입니다. Linux 프로젝트의 장기적인 목표 역시 기존에 C로 작성된 리눅스 커널을 Rust로 대체하는 것입니다.

이 글에서는 Linux 환경에서 차세대 시스템 프로그래밍 언어인 Rust와 프로젝트 및 라이브러리 관리 툴 Cargo를 설치하고, Cargo를 이용하여 프로젝트를 생성하고 빌드하는 방법을 알아보겠습니다. 또 간단한 Rust 프로그램을 작성해보며 Rust와 친숙해져 보도록 하겠습니다.

Rust 맛보기

Rust 설치 (Ubuntu & Debian 기반)

rustup 설치

rustup은 Rust와 관련 도구의 버전을 관리하는 CLI 툴입니다.

curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

이 명령어를 실행하면,

1) Proceed with installation (default - just press enter)
2) Customize installation
3) Cancel installation

이 메뉴가 나타나는데 통상적으로는 그냥 1번을 고르면 됩니다.

설치과 완료되면 다음과 같은 메시지가 나타납니다.

Rust is installed now. Great!

참고로 이때 Cargo도 함께 설치됩니다.

환경 변수 설정

Cargo와, Rust 바이너리가 설치된 디렉토리를 환경 변수에 추가해 줍시다.

export PATH="$HOME/.cargo/bin:$PATH"

이 명령어를 쉘에 입력하면 현재 세션에서만 적용되는 거 잘 아시죠? ~/.bashrc에 위 명령어를 추가해 주고, exec bashsource ~/.bashrc를 실행하여 적용해 줍시다.

이제 정말로 잘 설치되었는지 확인해 봅시다.

rustc --version

잘 되었다면 Rust 컴파일러의 버전이 출력될 겁니다.

참고 명령어

rustup update

Rust를 최신 버전으로 업데이트합니다.

rustup self uninstall

Rust를 제거합니다.

rustup doc

Rust의 API 문서를 엽니다.

단독 컴파일러 rustc 사용

프로젝트 관리자인 Cargo를 다뤄보기 전에, 단독으로 Rust 파일을 컴파일하고 실행하는 방법을 알아봅시다.

rust의 소스코드는 통상적으로 확장자가 .rs입니다. hello.rs 파일을 만들어봅시다.

fn main() {
    println!("Hello, world!");
}
rustc hello.rs
./hello
Hello, world!

Cargo 사용법

Cargo는 Rust의 빌드 시스템이자 패키지 매니저입니다. 소스 코드를 빌드하거나, 코드가 의존하는 라이브러리를 다운로드하거나, 라이브러리를 빌드하는 등의 다양한 작업을 수행합니다.

Cargo의 주요 명령어

cargo new project_name

새로운 프로젝트를 생성합니다(실행 가능한 프로그램).

cargo new --lib project_name

새로운 프로젝트를 생성합니다(라이브러리).

cargo build

프로젝트를 빌드합니다.

cargo run

프로젝트를 빌드하고 실행합니다.

cargo test

프로젝트에 정의된 테스트를 실행합니다.

예제 Rust 소스 코드

앞으로의 게시글에선 Rust의 문법을 자세히 다룰 텐데, 어떤 느낌의 언어인지 대략적으로 훑어보기 위해 간단한 Guessing game의 코드를 작성해 보겠습니다. 동시에 가장 기본적인 문법들도 여기서 다룰 겁니다.

use std::io;
use std::cmp::Ordering;
// Ordering type: enum that contains variants Less, Greater, Equal

use rand::Rng;

fn main() {
    println!("Guess the number!"); // ! postfix: macro function
    
    let secret_number: u32 = rand::thread_rng().gen_range(1..=100);

    loop {
    println!("Please input your guess.");

    let mut guess: String = String::new(); // mut: the variable is mutable
    io::stdin() // an instance of the handle of a standard input stream
        .read_line(&mut guess) // a method of std input handle: stores input into the "mutable" String the argument points to
                               // (call by reference, borrowing)
                               // Return value: Result (enum: Ok, Err)
        .expect("Failed to read line"); // a method of Result type; Err -> cause crach and display
                                        // the message that passsed as an argument, Ok ->
                                        // return that value(read_line(): the number of bytes in
                                        // the user's input
    let guess: u32 = match guess.trim().parse() {
               Ok(num) => num,
               Err(_) => continue,
    };
                                                                           // : -> type
                                                                           // specification
                                                                           // (parse() needs it)
                                                                           // parse() returns a
                                                                           // Result value so that
                                                                           // expect() method is
                                                                           // usable

    println!("You guessed: {guess}"); // ("{}", guess) is also possible, but expressions are only
                                      // allowed outside of bracket unlike single variables
    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big"),
        Ordering::Equal => {
            println!("You are winner");
            break;
        },
    }
    }

}

이 코드를 한 줄씩 살펴보며 문법의 의미를 파악해 보겠습니다.

use std::io;
  • use: 외부 라이브러리를 가져오는 명령어

  • std::io: 표준 라이브러리의 io 모듈

Rust의 표준 라이브러리에는 모든 프로그램에 포함되는 기본적인 기능이 구현되어 있는 모듈이 있는데, 이를 prelude라고 합니다. prelude에 포함되어 있지 않은 기능을 사용하려면 use를 통해 해당 모듈을 가져와야 합니다.

use std::cmp::Ordering;
  • std::cmp::Ordering: 표준 라이브러리의 cmp 모듈의 Ordering 타입

이 타입은 랜덤 넘버를 유저의 입력과 비교하기 위해 사용될 것입니다. Ordering 타입은 Less, Greater, Equal 세 가지의 variant를 가지는 enum입니다. 두 값을 비교할 때 가능한 세 가지의 결과에 해당해요.

use rand::Rng;

Rng trait는 Random Number Generator가 구현하는 메소드들을 정의합니다. Trait란 Java의 인터페이스와 비슷한 것으로, 단어 의미 그대로 어떠한 타입의 ‘성질’을 나타냅니다. 나중에 자세히 다루겠습니다.

fn main() {
  • fn: 함수를 선언하는 키워드

  • main(): 프로그램의 시작점(entry point)을 나타내는 함수

여기서의 main 함수는 아무 것도 리턴하고 있지 않습니다(사실 unit value()를 리턴하고 있긴 합니다). C에 비유하자면 void main()과 같은 형태입니다.

println!("Guess the number!");
  • println!: 화면에 문자열을 출력하고 개행 문자 '\n'을 추가하는 Rust의 매크로 함수

  • !: 매크로 함수를 호출할 때 사용하는 postfix

개행 문자를 포함하지 않는 print! 매크로 함수도 있습니다.

let secret_number: u32 = rand::thread_rng().gen_range(1..=100);
  • let: 변수를 선언하는 키워드

    • 참고로 let 키워드만을 사용해 선언한 변수는 기본적으로 값이 변경 불가능한 immutable 변수입니다. 변수를 변경 가능하게 하려면 잠시 후에 등장할 mut 이라는 키워드를 사용해야 합니다.
  • rand::thread_rng(): 특정한 RNG를 생성하는 함수입니다. 여기서 생성되는 Generator는 현재 쓰레드에서만 유효합니다. 즉, thread-local입니다. 시드는 OS에 의해 결정됩니다.

  • gen_range(1..=100): RNG의 메소드로써, 1부터 100 사이의 난수를 생성

    • 이 메소드는 앞서 언급한 바와 같이, Rng trait에 정의되어 있습니다. start..end 와 같은 형식의 range expression을 인자로 받아, 해당 range 내의 난수를 생성합니다.

start..end는 Python에서의 range와 마찬가지로 start부터 end-1까지의 범위를 나타냅니다. start..=end와 같이 =을 추가하면 start부터 end까지의 범위를 나타냅니다(inclusive on the lower and upper bounds).

    let mut guess: String = String::new(); // mut: the variable is mutable
  • mut: 변수를 mutable하게 선언하는 키워드

Rust에서 mut 키워드를 붙이지 않고 선언한 변수는 값의 변경이 불가능한 immutable 변수입니다.

  • String::new(): 새로운 String 객체(인스턴스)를 생성하는 함수

String은 표준 라이브러리에서 제공되는 문자열 타입입니다. String은 heap에 할당되는 동적 할당 타입이며, growable하고 UTF-8 인코딩을 지원합니다.

  • ::: 특정 타입에 구현된 associated function을 호출하는 연산자

    let mut guess: String = String::new(); // mut: the variable is mutable
    io::stdin() // an instance of the handle of a standard input stream
        .read_line(&mut guess) // a method of std input handle: stores input into the "mutable" String the argument points to
                               // (call by reference, borrowing)
                               // Return value: Result (enum: Ok, Err)
        .expect("Failed to read line"); // a method of Result type; Err -> cause crach and display
                                        // the message that passsed as an argument, Ok ->
                                        // return that value(read_line(): the number of bytes in
                                        // the user's input

대략적인 설명은 코드 주석에 담겨 있습니다.

입력을 받아들이는 함수 작성이 C에 비해 약간 복잡합니다. 하나씩 살펴 봅시다.

  • io::stdin(): 표준 입력 스트림(stdin)의 핸들(std::io::Stdin의 인스턴스)을 반환합니다.

처음에 std::io 모듈을 가져왔기 때문에 io::stdin()을 호출하여 표준 입력 스트림의 핸들을 얻을 수 있습니다. 만약 가져오지 않았다면 std::io::stdin()으로 호출해야 합니다.

read_line 메소드는 표준 입력 핸들에 구현된, 사용자의 입력을 받아들이는 메소드로, 사용자가 입력한 문자열을 &mut guess에 저장합니다. 이때, &reference를 의미하며, &mutmutable reference를 의미합니다.

read_line 메소드가 변수에 값을 쓰는 기능을 한다는 특성상, argument는 반드시 mutable reference여야 합니다.

  • expect("Failed to read line"): read_line 메소드의 반환값은 Result 타입입니다. ResultOkErr 두 가지의 enum variant를 가지며, read_line 메소드는 Ok variant에 사용자의 입력을 받아들인 바이트 수를 담고 있습니다. Err variant는 사용자의 입력을 받아들이는 과정에서 문제가 발생했을 때 반환됩니다. 이때, expect 메소드는 Result 타입의 값이 Err variant일 때, 프로그램을 종료하고 사용자가 지정한 메시지를 출력합니다. Ok variant일 때는 그냥 Ok variant에 담긴 값을 반환합니다.

에러가 발생했을 때 프로그램을 crash시키지 않고 적절히 핸들링하려면 expect가 아닌 다른 방식을 사용할 수 있습니다. 에러 핸들링 방법은 뒷부분에서 다루도록 하겠습니다.

만약 Result를 반환하는 함수를 호출할 때, expect메소드 등을 통해 해당 타입의 variant를 처리하지 않으면 컴파일은 가능하지만, 컴파일러는 경고를 띄웁니다.

    let guess: u32 = match guess.trim().parse() {
               Ok(num) => num,
               Err(_) => continue,
    };

위에서 정의한 guess 변수는 유저가 입력한 숫자를 문자열로써 저장하는, String 타입이었습니다. 그런데 생성된 random number는 정수형이고, 이와 비교하기 위해서는 유저가 입력한 숫자가 문자열이 아닌 정수형이어야 하겠죠. 따라서 여기서는 Variable Shadowing을 통해 guess 변수를 다시 선언하고, 유저가 입력한 guess 변수를 parse() 메소드를 통해 u32 타입으로 변환하고 있습니다.

  • trim(): 문자열의 앞 뒤 공백('\n', ' ' 등)을 제거하는 메소드

  • parse(): 문자열을 특정 타입으로 변환하는 메소드

    • parse() 메소드를 사용할 때에는 반드시 변수의 타입이 정해져 있어야 합니다. 이는 Rust의 타입 추론 기능 때문입니다. 만약 타입을 명시하지 않으면 컴파일러는 어떤 타입으로 변환해야 하는지 알 수 없기 때문에 에러를 발생시킵니다. 위 코드를 보면 guess: u32와 같이 타입이 u32로 명시되어 있습니다. 따라서 parse()메소드는 문자열의 값을 u32로 변환합니다. parse()의 반환 타입은 아까 read_line 에서도 봤던 Result형입니다. Ok variant에는 변환된 값이, Err variant에는 변환에 실패한 이유가 담겨 있습니다.
  • match: C언어의 switch와 비슷한 expression으로, arm들로 구성

    • arm은 매치시킬 패턴, 패턴이 매치될 때 실행할 코드로 구성되어 있습니다. 위 코드에서는 실행 코드 부분에 num 변수가 있는 것을 확인할 수 있는데, ; 없이 변수만 명시된 것은 해당 값이 expression 취급이 되어, 해당 값이 반환되는 기능을 합니다.

    • case 등의 키워드가 없는 대신, =>로 패턴과 실행 코드를 구분합니다.

여기서는 match 메소드가 에러 핸들링을 위해 사용되고 있습니다. OkErr variant에 따라 동작이 달라짐을 알 수 있습니다. 이때, _wildcard pattern으로, 어떤 값이든 매치될 수 있음을 의미합니다.

주의할 점은 match는 반드시 exhaustive해야 합니다. 즉, 모든 가능한 경우에 대해 arm이 정의되어 있어야 합니다. 만약 모든 경우에 대해 arm을 정의하지 않으면 컴파일러는 경고를 띄웁니다. 이럴 때 _가 유용합니다. 꼭 _ 대신 임의의 변수 이름을 명시하고 그 변수를 사용한 값을 반환하는 방법도 있습니다.

let dice_roll = 9;
match dice_roll {
    3 => add_fancy_hat(),
    7 => remove_fancy_hat(),
    other => {
        println!("The dice roll was: {}", other);
    },
}

예를 들면 위와 같이 활용이 가능합니다.

    println!("You guessed: {guess}"); // ("{}", guess) is also possible, but expressions are only
                                      // allowed outside of bracket unlike single variables
  • {}: placeholder

위 코드처럼 문자열 내 {} 안에 변수 이름을 넣으면 해당 변수의 값이 대입되어 바로 출력됩니다. Python의 f 문자열과 비슷하죠. 하지만 주의할 점은 이런 방식으로 출력할 때는 단일 변수만 가능하다는 것입니다. 일반적인 expression을 출력하고 싶을 때에는 println!("{}", var)과 같이 작성해야 합니다.

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big"),
        Ordering::Equal => {
            println!("You are winner");
            break;
        },
    }

마지막으로, 유저가 입력한 숫자와 생성된 random number를 비교하는 부분입니다. 정수형이 갖는 cmp 메소드는 Ordering 타입을 반환합니다. 처음에 std::cmp::Ordering 타입을 가져왔기 때문에 Ordering::Less와 같이 사용할 수 있습니다.

간단한 문법

const

앞서 let으로 선언한 변수는 기본적으로 immutable 변수라고 설명했었습니다. 그렇다면 immutable 변수는 사실상 상수라고 볼 수 있는 걸까요?

사실 Rust에서 상수를 선언하는 키워드는 따로 있습니다. C에서와 같은 const입니다.

const와 imuutable 변수의 차이

Rust에서 상수는 immutable 변수와 달리 반드시 타입이 명시되어야 합니다. 또한, runtime에 계산되는 값을 가질 수 없고 constant expression만 사용이 가능합니다.

Expression vs Statement

Statement는 값을 반환하지 않는 특정 action을 수행하는 instruction입니다.

Expression은 평가 결과 값으로 변환되는 코드입니다.

Rust는 {} 블록으로 하나의 Expression을 나타낼 수 있습니다.

let y = {
    let x = 3;
    x + 1 // 마지막 줄이 Expression이므로 반환됨
};

Statement에는 ;가 붙는 반면 expression에는 붙지 않습니다.

Test 코드

Rust에서는, #[test] 라는 키워드 뒤에 테스트 함수를 작성할 수 있습니다.

cargo test로 테스트 함수를 실행 가능합니다.

#[test]
fn test_add() {
    assert_eq!(add(3, 2), 5);
}

참고 문헌

Rust 맛보기