/ PROGRAMMING, RUST

Rust - Project Organization

Rust 및 Cargo 설치 글에서 Cargo로 프로젝트를 만들고, 빌드, 실행하는 방법을 살짝 다뤄봤었죠. 이번 글에서는 프로젝트를 어떻게 관리하는지, 그리고 Rust에서 정의한 프로젝트 관련 용어들에 대해 알아볼까요? 좋죠?

Rust 맛보기

Packages and Crates

Crates

crate란 컴파일러가 한 번에 고려하는 가장 작은 단위의 코드를 의미합니다. 한 crate는 라이브러리나 실행 파일을 만드는 여러 module들을 포함합니다.

Crate에는 두 가지 유형이 있어요.

  • Binary crates

    • 실행 파일로 컴파일 될 수 있는 crate

    • 각각은 반드시 main() 함수를 포함해야 합니다.

  • Library crates

    • main 함수가 없고 실행 파일로 컴파일되지 않는 crate

    • 여러 프로젝트에서 공유되는 기능을 정의합니다.

일반적으로, crate라는 용어는 library crate를 가리키고, 다른 프로그래밍 언어에서 주로 library 라고 하는 개념과 같은 의미라고 보시면 됩니다.

crate root는 컴파일러가 crate를 컴파일하는 시작점을 가리킵니다. 이는 src/lib.rs(library crates) 또는 src/main.rs (binary crates)파일입니다.

Packages

package는 하나 이상의 crate를 포함하는 한 묶음입니다. Cargo가 관리하는 것이 바로 package예요. 이전 게시글에서는 프로젝트라는 말을 썼었지만, 사실 cargo new 명령어로 생성되는 것은 package입니다. 각 package는 Cargo.toml 파일을 가지고 있습니다.

package가 가질 수 있는 binary crate의 개수에는 제한이 없지만, library crate(lib.rs)는 하나만 가질 수 있습니다.

Binary crate를 갖는 package를 생성했을 때에도, lib.rs는 libray crate root로 간주되어 crate:: 경로를 사용하여 내부에서 불러오는 모듈에 접근이 가능합니다.

Modules

Rust는 module이라는 개념을 가지고 있습니다. module은 코드를 그룹화하고, 코드의 가시성을 제어하는데 사용됩니다. module은 crate root 파일에서 mod 키워드를 사용하여 정의합니다.

mod front_of_house;

fn main() {
    ...
}

위의 예시는 main 함수가 존재하는 binary crate에서 모듈을 정의한 경우입니다.

위와 같은 방식으로 mod 키워드로 module을 정의하면, 컴파일러는 front_of_house.rs 또는 front_of_house/mod.rs 파일을 찾아 module을 로드합니다. 별개의 파일로 module을 분리하지 않고, 그 자리에서 module을 정의할 수도 있습니다.

mod front_of_house {
    mod hosting { // module 내부에 또 다른 submodule을 정의할 수 있습니다.
        fn add_to_waitlist() {}
    }
}

파일을 찾아 module을 로드한다는 점에서 use와 비슷하다고 보일 수 있지만, 엄밀히는 mod는 module을 정의하는 것이고, use는 이미 정의된 module을 현재 scope로 가져오는 것입니다. 즉, 위 코드의 경우 front_of_house.rs 파일이 존재한다고 해서 그 모듈이 정의된 상태는 아닙니다. crate root 파일에서 비로소 정의되는 것입니다.

그리고 위와 같이 module 내부에 submodule을 정의할 경우, 컴파일러는 트리 구조로 해당 module에 대응하는 소스코드를 불러옵니다. 즉, 위 예제의 경우 컴파일러는 hosting 모듈을 정의하기 위해 src/front_of_house/hosting.rs 또는 src/front_of_house/hosting/mod.rs 파일을 불러옵니다.

이렇게 정의된 module tree에서 한 아이템에 접근하려면 :: 연산자를 사용하여 파일 시스템의 경로와 같이 접근할 수 있습니다.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // 절대 경로
    crate::front_of_house::hosting::add_to_waitlist();

    // 상대 경로
    front_of_house::hosting::add_to_waitlist();
}

여기서 서브모듈과 함수 모두 pub 키워드가 추가된 것을 눈치채셨나요? 그렇습니다. Rust는 encapsulation을 위해 기본적으로 모든 항목이 부모 모듈에 대해 private으로 설정합니다. 즉 부모 모듈은 자식 모듈에 있는 private한 아이템(함수 등)을 사용할 수가 없습니다. 단, 자식 모듈의 아이템은 부모 모듈의 아이템을 이용 가능합니다.

부모 모듈에게 자식 모듈의 아이템을 이용 가능하도록 노출시키려면, pub 키워드를 사용하여 해당 아이템을 public으로 만들어야 합니다.

자식 모듈이 부모 모듈에 접근할 때, 상대 경로 쓰기

super 키워드를 이용하여 자식 모듈이 부모 모듈의 아이템에 상대 경로로 접근 가능합니다.

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

Public Structs and Enums

모듈에서 구조화된 데이터를 다룰 때에는 더 주의해야 합니다.

struct의 경우

구조체를 pub로 선언하더라도, 구조체의 필드는 기본적으로 private입니다.

그리고 private 필드를 가진 구조체는, public한 constructor를 반드시 정의할 필요가 있습니다. 왜냐하면 private 필드를 가진 구조체는 외부에서 constructor 없이 인스턴스를 생성하는 것이 불가능하기 때문입니다. 왜 불가능할까요? private 필드의 값을 초기화할 방법이 없기 때문입니다.

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast { // Public Constructor
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    let mut meal = back_of_house::Breakfast::summer("Rye"); // public constructor를 사용하여 인스턴스 생성
    meal.toast = String::from("Wheat"); // public field에 접근 가능
    // meal.seasonal_fruit = String::from("blueberries"); // error: `seasonal_fruit` is private
}

enum의 경우

하지만, enumstruct와 달리 그 자체가 public이면 그 내부의 모든 variant가 public입니다. 개인적으로는 enum은 반드시 variant와 함께 사용되니 consistency를 위해 이렇게 조치한 것으로 보입니다.

use 키워드

여기서 use 키워드의 정확한 용도가 드러납니다. 모듈 내 아이템에 접근할 때 항상 경로를 쓰는 것은 번거롭기 때문에, use 키워드를 사용하여 해당 아이템을 현재 scope로 가져올 수 있습니다.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

단, scope에 주의해야 합니다. use를 썼는데 그 뒤에 다른 모듈이 정의된다면, 그 모듈 내부는 use가 적용되는 scope가 아니기 때문에 use의 효과를 볼 수 없습니다.

우리가 다뤘던 첫 예제에서 등장한

use std::io;

가 정확히 무슨 의미인지 이해가 되시나요?

바로 std crate의 io 모듈을 현재 스코프로 가져온 것입니다.

lib.rs에서 정의된 모듈을 사용하려면 crate::를 사용해야 한다고 초반에 언급했었는데, use 키워드로 이 역시 생략 가능하죠.

use crate::front_of_house::hosting;

이런 식으로 말입니다.

as 키워드

그런데 같은 이름을 가진 아이템을 가져오고 싶을 때는 어떻게 해야 할까요? Rust는 중복된 이름을 허용하지 않습니다.

이럴 때에는 as 키워드로 이름을 바꿔주면 됩니다.

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    ...
}

fn function2() -> IoResult<()> {
    ...
}

pub use로 모듈을 re-export하기

use 키워드를 사용하여 모듈을 가져올 때, 그 모듈을 다시 패키지 내의 외부 crate로 re-export할 수 있습니다. 이를 통해 중첩된 모듈을 사용하는 코드를 더 간결하게 만들 수 있습니다.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting; // re-export

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

위 파일이 my_crate/src/lib.rs에 있다고 가정하면, hosting 모듈을 eat_at_restaurant 함수를 사용하는 다른 파일에서도 사용할 수 있습니다.

use  my_crate::hosting;

fn main() {
    hosting::add_to_waitlist();
}

my_crate/src/main.rs

외부 패키지 사용

외부 crate/패키지를 사용할 때에는 Cargo.toml 파일에 해당 패키지를 추가해야 합니다. 그리고 use 키워드를 사용하여 해당 패키지를 가져올 수 있습니다.

여기서 crate와 패키지라는 말을 혼용하고 있는데, 엄밀히는 패키지가 맞으나 어차피 실행 파일을 포함하지 않는 패키지는 하나의 lib.rs 즉 단일 crate를 갖고 있으니 library crate에 한정하여 의미가 통용 가능합니다.

예를 들면 이 예제에서는 rand crate를 사용했었죠.

단, std crate는 이미 기본적으로 Rust에 포함되어 있으므로 Cargo.toml에 추가하지 않아도 됩니다.

https://crates.io 는 Rust에서 제공하는 공식 crate/package registry입니다. 개발자들이 다양한 crate를 공유하고 사용할 수 있습니다.

Nested Path와 Glob Operator 활용하기

만약 동일한 crate나 모듈 내에 정의된 아이템을 가지고 오고 싶을 때에는 {}를 사용하여 한 줄에 간결하게 나타낼 수 있습니다.

use std::cmp::Ordering;
use std::io;

->

use std::{cmp::Ordering, io};
use std::io;
use std::io::Write;

->

use std::io::{self, Write}; // 모듈도 구조체 인스턴스처럼 self로 자기 자신 참조 가능?

* glob operator로 한 crate나 모듈 내의 모든 아이템을 가져올 수도 있습니다.

use std::{io::*, collections::*};

모듈을 서로 다른 파일로 분리하기

앞서 서브모듈를 설명할 때에도 소개하였습니다만, 프로젝트 크기가 커지면 모듈들을 트리 구조 형태로 서로 다른 파일로 분리할 수가 있습니다.

예를 들어, front_of_house 모듈을 src/front_of_house.rs 파일로 분리하고 싶다면, src/front_of_house.rs 파일을 생성하고 그 안에 모듈을 정의하면 됩니다.

// src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}

그리고 src/lib.rs 파일에서는 mod 키워드를 사용하여 해당 파일을 로드하면 됩니다.

// src/lib.rs
mod front_of_house;

여기서 한 단계 더 나아가, hosting 모듈을 src/front_of_house/hosting.rs 파일로 분리하고 싶다면, src/front_of_house 디렉토리를 생성하고 그 안에 hosting.rs 파일을 생성하면 되겠죠?

// src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}

그리고 src/front_of_house.rs 파일에서는 mod 키워드를 사용하여 해당 파일을 로드하면 됩니다.

// src/front_of_house.rs
pub mod hosting;

이렇게 모듈을 서로 다른 파일로 분리하면, 프로젝트의 구조가 더 명확해지고, 코드의 가독성이 높아집니다.


참고 문헌

Rust 맛보기