본문 바로가기

코딩/Rust

[Rust] 4장 열거형(Enum), 모듈화(Module)

해당 게시판에 올라오는 포스트는 The Rust Programming Language 의 한글 텍스트 버전을 보며 작성된 학습 게시판입니다.

전체 목차

[코딩/Rust] - [Rust] 1장 안정성 높은 언어

[코딩/Rust] - [Rust] 2장 일반적인 프로그래밍 개념

[코딩/Rust] - [Rust] 3장 러스트만의 특이함

[코딩/Rust] - [Rust] 4장 열거형(Enum), 모듈화(Module)

[코딩/Rust] - [Rust] 5장 컬렉션(Collection) - 벡터(Vector), 문자열(String), 해시맵(HashMap)

글 목차

  1. 열거형과 패턴 매칭(Enum and Match)
  2. 패키지와 크레이트(Package and Crate)
  3. 모듈(Module)
  4. 구조체, 열거형을 공개하기(Make Public Struct and Enum)

1. 열거형과 패턴 매칭(Enum and Match)

정의와 열거형 값(Defining and Enum Value)

바로 예제부터 작성해 보겠다. IP 주소를 다루는 프로그램을 만들어 보면서, 어떤 상황에서 열겨형이 구조체보다 유용하고 적절한 지 알아보겠다. IP 주소는 반드시 IPv4, IPv6 두 종류이다. 앞으로 만들 프로그램에서 다룰 IP 종류는 이 두 가지가 전부이므로, 이 처럼 가능한 모든 가능성(variant)들을 늘어 놓을 수 있는 데, 이 때문에 열거형이라는 이름이 붙었다.

enum IP {
    V4,
    V6
}

열거형 값

아래처럼 IP 의 두 개의 가능한 요소에 대한 인스턴스를 만들 수 있다.

    let version4 = IP::V4;
    let version6 = IP::V6;

열거형을 정의할 때는 식별자로 네임스페이스가 만들어져서, 각 가능한 요소 앞에 이중 콜론(::)을 붙여야 한다.

이제 IP 타입을 인수로 받는 함수를 정의해 보자.

fn route(ip_kind:IP){}

그리고, 가능한 요소 중 하나를 사용해서 함수를 호출할 수 있다.

    route(IP::V4);
    route(IP::V6);

열거형을 사용하면 더 많은 이점이 있다. IP 주소 타입에 대해 더 생각해 보면, 지금으로서는 실제 IP 주소 데이터를 저장할 방법이 없고 어떤 종류인지만 알 수 있다. 구조체에 대해 배웠다면, 이 문제를 아래 코드처럼 구조체를 사용하여 해결할 수 있다.

    enum IP {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IP,
        address: String,
    }

    let home = IpAddr {
        kind: IP::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IP::V6,
        address: String::from("::1"),
    };

여기서는 IP (이전에 정의한 열거형) 타입인 kind 필드와 String 타입인 address 필드를 갖는 IpAddr를 정의했다. 그리고 이 구조체의 인스턴스 두 개를 생성했다. 첫 번째 home kind의 값으로 IP::V4을, 연관된 주소 데이터로 127.0.0.1를 갖는다. 두 번째 loopback IP의 다른 요소인 V6을 값으로 갖고, 연관된 주소로 ::1를 갖는다. kind address의 값을 함께 사용하기 위해 구조체를 사용했다. 그렇게 함으로써 각 요소가 연관된 값을 갖게 되었다.

 

각 열거형 요소에 데이터를 직접 넣는 방식을 사용해서 열거형을 구조체의 일부로 사용하는 방식보다 더 간결하게 동일한 개념을 표현할 수 있다.

    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));
    let loopback = IpAddr::V6(String::from("::1"));

구조체 대신 열거형을 사용하면 또 다른 장점이 있다. 각 요소는 다른 타입과 다른 양의 연관된 데이터를 가질 수 있다.

    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);
    let loopback = IpAddr::V6(String::from("::1"));

열거형과 구조체는 한 가지 더 유사한 점이 있다. 구조체에 impl을 사용해서 메서드를 정의한 것처럼, 열거형에도 정의할 수 있다.

    impl IpAddr {
        fn connect(&self){
        	// 메서드 본문 정의
        }
    }
    
    let loopback = IpAddr::V6(String::from("::1"));
    loopback.connect();

Null 대신 Option 열거형(Option Enum instead of Null)

표준 라이브러리에서 열거형으로 정의된 또 다른 타입인 Option에 대한 사용 예를 살펴보겠다. Option 타입은 값이 있거나 없을 수 있는 아주 흔한 상황을 나타낸다.

enum Option<T> {
    None,
    Some(T),
}

Option<T> 열거형은 너무나 유용하기 때문에, 러스트에서 기본으로 임포트하는 목록인 프렐루드(prelude)에도 포함되어 있다. 이것의 요소 또한 프렐루드(prelude)에 포함되어 있습니다: 따라서 Some, None 배리언트 앞에 Option::도 붙이지 않아도 된다. 하지만 Option<T>는 여전히 그냥 일반적인 열거형이며, Some(T) None도 여전히 Option<T>의 요소다.

    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;

some_number의 타입은 Option<i32> 다. some_char의 타입은 Option<char>이고 둘은 서로 다른 타입이다. Some 요소 내에 어떤 값을 명시했기 때문에 러스트는 이 타입들을 추론할 수 있다. absent_number에 대해서는 전반적인 Option 타입을 명시하도록 해야 한다: None 값만 봐서는 동반되는 Some요소가 어떤 타입의 값을 가질지 컴파일러가 추론할 수 없기 때문이다. 위 예제에서는 absent_number Option<i32> 타입임을 명시했다.

Some 값을 얻게 되면, 값이 존재한다는 것과 해당 값이 Some 내에 있다는 것을 알 수 있다. None 값을 얻게 되면, 얻은 값이 유효하지 않다는, 어떤 면에서는 널과 같은 의미를 갖는다. 그렇다면 왜 Option<T>가 널보다 나을까?

간단하게 말하면, Option<T> T(T는 어떤 타입이던 될 수 있음)이 다른 타입이기 때문에, 컴파일러는 Option<T> 값을 명백하게 유효한 값처럼 사용하지 못하도록 한다.

match 제어 흐름 구조(match Control Flow Construct)

러스트는 match라고 불리는 매우 강력한 제어 흐름 연산자를 가지고 있는데 이는 일련의 패턴에 대해 어떤 값을 비교한 뒤 어떤 패턴에 매칭되었는지를 바탕으로 코드를 수행하도록 해준다.

대충 enum 에 대한 switch 문 이라고 이해하면 된다.

만일 match 갈래(arm) 내에서 여러 줄의 코드를 실행시키고 싶다면 중괄호를 사용하고, 그렇게 되면 갈래 뒤에 붙이는 쉼표는 옵션이 된다.

enum IpAddr {
    None,
    V4(u8, u8, u8, u8),
    V6(String),
}

fn to_string(addr: IpAddr) -> String {
    match addr {
        IpAddr::V4(a, b, c, d) => {
            println!("Internet Protocol Version 4!");
            format!("{}.{}.{}.{}", a, b, c, d)
        },
        IpAddr::V6(a) => a,
        IpAddr::None => {
            println!("None!");
            ""
        }
    }
}

포괄 패턴과 자리 표시자(Catch-all Patterns and Placeholder)

우리가 논의할 필요가 있는 match의 다른 관점이 있다. 갈래의 패턴들은 가능한 경우를 모두 다루어야 한다. switch 에서 하듯이 몇가지 경우의 수를 제외할 수 없다. 대신에 사용할 수 있는 포괄(catch-all) 패턴과 자리표시자가 있다.

 

어떤 게임을 구현하는 중인데 주사위를 굴려서 3이 나오면 플레이어는 움직이는 대신 새 멋진 모자를 얻고, 7을 굴리면 플레이어는 그 모자를 잃게 된다고 생각해 보자다. 그 외의 값들에 대해서는 게임판 위에서 해당 숫자만큼 칸을 움직인다.

    let dice_roll:u8 = 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) {}

처음 두 갈래에서의 패턴은 3 7 리터럴 값이다. 나머지 모든 가능한 값을 다루는 마지막 갈래에 대한 패턴은 other라는 이름을 가진 변수이다. other 갈래 쪽의 코드는 이 변숫값을 move_player 함수에 넘기는 데 사용한다. 즉 나머지 값을 변수로써 넘기고 하나의 명령으로 퉁칠 때 사용한다.

게임의 규칙을 바꿔보자. 이제부터 주사위를 굴려 3 혹은 7 이외의 숫자가 나왔다면 주사위를 다시 굴린다. 그러면 더 이상 포괄 패턴의 값을 사용할 필요가 없으므로, other라는 이름의 변수 대신 _를 사용하여 코드를 고칠 수 있다.

    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() {}

 

if let 으로 간소화(Simplifing with if let)

if let 문법은 if let을 조합하여 하나의 패턴만 매칭시키고 나머지 경우는 무시하도록 값을 처리하는 간결한 방법을 제공한다.

    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("최대값은 {} 라구", max),
        _ => (),
    }

match 표현식을 만족시키려면 딱 하나의 배리언트 처리 후 _ => () 를 붙여야 하는데, 이는 다소 겅사긴 보일러 플레이트 코드이다. 대신, if let 을 이용하여 이 코드를 더 짧게 쓸 수 있다. 아래 코드는 위 match 코드와 동일하게 동작한다.

    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("최대값은 {} 라구", max);
    }

즉 if let 은 한 패턴에 매칭 될 때 만 코드를 실행하고 다른 경우는 무시하는 match 문을 작성하고 싶을 때 사용하는 문법 설탕 (syntax sugar) 라고 생각하면 된다.


2. 패키지와 크레이트(Package and Crate)

  • 패키지: 크레이트를 빌드하고, 테스트하고, 공유하는 데 사용하는 카고 기능.
  • 크레이트: 라이브러리나 실행 가능한 모듈로 구성된 트리 구조.
  • 모듈과 use: 구조, 스코프를 제어하고, 조직 세부 경로를 감추는 데 사용.
  • 경로: 구조체, 함수, 모듈 등의 이름을 지정.

패키지와 크레이트

크레이트 (crate) 는 러스트가 컴파일 한 차례에 고려하는 가장 작은 코드 단위이다.

라이브러리 크레이트 (library crate) 는 main 함수를 가지고 있지 않고 실행 파일 현태로 컴파일 되지 않는다.

크레이트 루트 (crate root) 는 러스트 컴파일러가 컴파일을 시작하는 소스 파일이고, 크레이트의 루트 모듈을 구성한다.

패키지 (package) 는 일련의 기능을 제공하는 하나 이상의 크레이트로 구성된 번들이다.


3. 모듈 (Module)

모듈을 정의하여 스코프 및 공개 여부 제어

  • 크레이트 루트 부터 시작 : 크레이트를 컴파일할 때 컴파일러는 먼저 크레이트 루트 파일을 본다.
  • 모듈 선언 : 크레이트 루트 파일에는 새로운 모듈을 선언할 수 있다. mod garden 이라는 코드로 'garden' 모듈을 선언할 수 있다.
    • mod garden 뒤에 세미콜론 대신 중괄호를 써서 안쪽에 코드를 적은 인라인
    • src/garden.rs 파일 안
    • src/garden/mod.rs 파일 안
  • 서브 모듈 선언 : 크레이트 루트가 아닌 다른 파일에서는 서브 모듈(submodule)을 선언할 수 있다. 예를 들어 src/garden.rs 파일 안에 mod vegetables 를 선언할 수 있다. 컴파일러는 부모 모듈 이름의 디렉터리 안쪽에 위치한 아래의 장소들에서 이 서브 모듈의 코드가 있는 지 살펴 볼 것이다.
    • mod vegetables 뒤에 세미콜론 대신 중괄호를 써서 안쪽에 코드를 적은 인라인
    • src/garden/vegetables.rs 파일 안
    • src/garden/vegetables/mod.rs 파일 안
  • 모듈 내 코드로의 경로 : 일단 모듈이 크레이트의 일부로서 구성되면, 공개 규칙이 허용하는 한도 내에서라면 해당 코드의 경로를 사용하여 동일한 크레이트의 어디에서든 이 모듈의 코드를 참조 할 수 있게 된다.
  • 비공개 vs 공개 : 모듈 내의 코드는 기본적으로 부모 모듈에게 비공개(private)이다. 모듈을 공개(public)으로 만들려면, mod 대신 pub mod를 써서 선언해라. 공개 모듈의 아이템들을 공개하려면 마찬가지로 그 선언 앞에 pub 를 붙여라.
  • use 키워드 : 어떤 스코프 내에서 use 키워드는 긴 경로의 반복을 줄이기 위한 어떤 아이템으로의 단축 경로를 만들어준다.

모듈로 관련된 코드 묶기

모듈은 크레이트의 코드를 읽기 쉽고 재사용하기도 쉽게 끔 구조화를 할 수 있게 해 준다. 모듈 내의 코드는 기본적으로 비공개이므로, 모듈은 아이템의 공개 여부 (privacy) 를 제어하도록 해주기도 한다. 비공개 아이템은 외부에서의 사용이 허용되지 않는 내부의 세부 구현이다. 모듈과 모듈 내 아이템을 선택적으로 공개할 수 있는데, 이렇게 하여 외부의 코드가 모듈 및 아이템을 의존하고 사용할 수 있도록 노출해 준다.

 

예시로, 레스토랑 기능을 제공하는 라이브러리 크레이트를 작성한다고 가정해 보자. 코드 구조에 집중할 수 있도록 레스토랑을 실제 코드로 구현하지는 않고, 본문은 비워둔 함수 시그니처만 정의하겠다.

 

레스토랑 업계에서는 레스토랑을 크게 접객 부서 (front of house)  지원 부서 (back of house) 로 나뉜다. 접객 부서는 호스트가 고객을 안내하고, 웨이터가 주문 접수 및 결제를 담당하고, 바텐더가 음료를 만들어 주는 곳이다. 지원 부서는 셰프, 요리사, 주방보조가 일하는 주방과 매니저가 행정 업무를 하는 곳이다.

 

중첩 (nested) 모듈 안에 함수를 집어넣어 구성하면 크레이트 구조를 실제 레스토랑이 일하는 방식과 동일하게 구성할 수 있다. cargo new --lib restaurant 명령어를 실행하여 restaurant이라는 새 라이브러리를 생성하고, 아래 코드를 src/lib.rs에 작성하여 모듈, 함수 시그니처를 정의하자. 아래는 접객 부서 쪽 코드이다

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() {}
    }
}

경로를 사용하여 모듈 트리의 아이템 참조하기

  • 절대 경로 (absolute path) 는 크레이트 루트로부터 시작되는 전체 경로이다. 외부 크레이트로부터의 코드에 대해서는 해당 크레이트 이름으로 절대 경로가 시작되고 현재의 크레이트로부터의 코드에 대해서는 crate 리터럴로부터 시작된다.
  • 상대 경로 (relative path) 는 현재의 모듈을 시작점으로 하여 self, super 혹은 현재 모듈 내의 식별자를 사용한다.

절대 경로, 상대 경로 뒤에는 :: 으로 구분된 식별자가 하나 이상 따라온다.

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

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

해당 코드를 실행하려하면 컴파일 에러가 발생한다. hosting 모듈이 비공개 (private) 여서 발생하는 에러이다. 해당 영역이 비공개 영역이기 때문에 러스트가 접근을 허용하지 않는다.

pub 키워드로 경로 노출

hosting 모듈에 pub 키워드를 추가해서 오류를 수정해보자.

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

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

하지만 또 에러가 발생한다. mod hosting 앞에 pub 키워드를 추가하여 모듈이 공개되었다. 따라서, front_of_house 에 접근할 수 있다면 hosting 모듈에도 접근할 수 있다. 하지만 hosting 모듈의 내용은 여전히 비공개이다. 모듈을 공개했다고 해서 내용까지 공개되는 것은 아니다.

 

다음과 같이 수정해야 비로소 정상적으로 컴파일 할 수 있다.

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();
}

super 로 시작하는 상대 경로

super 로 시작하면 현재 모듈 혹은 크레이트 루트 대신 자기 부모 모듈부터 시작되는 상대 경로를 만들 수 있다. 이는 파일 시스템 경로에서 .. 으로 시작하는 것과 동일하다.

fn deliver_order() {}

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

    fn cook_order() {}
}

fix_incorrect_order 함수는 back_of_house 모듈 내에 위치하므로, super back_of_house의 부모 모듈, 즉 루트를 의미한다.


4. 구조체, 열거형을 공개하기(Make Public Struct and Enum)

pub 키워드로 구조체와 열거형을 공개할 수도 있지만, 이를 활용하기 전에 알아두어야 할 추가사항이 몇 가지 있다. 구조체 정의에 pub를 쓰면 구조체는 공개되지만, 구조체의 필드는 비공개로 유지된다. 공개 여부는 각 필드마다 정할 수 있다.

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() {
    // 호밀 (Rye) 토스트를 곁들인 여름철 조식 주문하기
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // 먹고 싶은 빵 바꾸기
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // 다음 라인의 주석을 해제하면 컴파일되지 않는다; 식사와 함께
    // 제공되는 계절 과일은 조회나 수정이 허용되지 않는다
    // meal.seasonal_fruit = String::from("blueberries");
}

use 키워드로 경로를 스코프 안으로 가져오기

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --생략--
}

fn function2() -> io::Result<()> {
    // --생략--
}

as 키워드로 새로운 이름 제공하기

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

fn function1() -> Result {
    // --생략--
}

fn function2() -> IoResult<()> {
    // --생략--
}

pub use 로 다시 내보내기(re-exporting)

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();
}

중첩 경로를 사용하여 대량의 use 나열을 정리하기

// --생략--
use std::cmp::Ordering;
use std::io;
// --생략--

다음과 같이 변경할 수 있다.

// --생략--
use std::{cmp::Ordering, io};
// --생략--

 

다음과 같이 하위 경로가 같은 두 use 구문의 경우

use std::io;
use std::io::Write;

 

다음과 같이 변경할 수 있다.

use std::io::{self, Write};

 

글롭 연산자

경로에 글롭 (glob) 연산자 * 를 붙이면 경로 안에 정의된 모든 공개 아이템을 가져올 수 있다.

use std::collections::*;