본문 바로가기

3.구현/기타언어

Rust 배우기2 - 심화

들어가기

심화에서는 좀더 Rust를 깊게 들어갈려고 한다. 이글에서는 오프라인에서 Cargo를 사용할 예정이다. 그렇기 때문에 사전에 Cargo 설치가 필요하다. 구글링해서 검색하거나 참고[3]을 참고하시기 바란다. 이글에서는 Cargo 설치에 대해서는 다루지 않는다. 이제부터 Rust에 대해서 좀더 깊게 들어가보자.

작성자: ospace114@empal.com, http://ospace.tistory.com/

Cargo

Cargo는 Rust의 빌드 시스템 및 패키지 매니저이다. 코드 빌드 및 라이브러리 다운로드 및 설치를 수행한다. 먼저 Cargo로 프로젝트 관리하는 방법을 간단히 살펴보자.

프로젝트 생성

먼저 hello_cargo 프로젝트 생성해보자.

$ cargo new hello_cargo --bin
$ cd hello_cargo

“new” 명령에 의해서 hello_cargo 폴더가 자동으로 생성된다. 뒤에 “--bin” 옵션은 바이터리 크레이트를 생성하는 옵션이다. 크레이트(crate)는 프로젝트 단위로 모듈화한 패키지를 의미한다. Rust에서는 실행용 바이너리 크레이트(binary crate)와 라이브러리 크레이트(library crate)로 구분된다. “cargo new --help”로 다양한 옵션을 확인할 수 있다.

생성된 파일과 폴더 구조는 다음과 같다.

  • Cargo.toml
  • /src
    • main.rs

프로젝트 환경설정 파일인 Cargo.toml 파일이 있다. 이 파일은 확장자가 toml파일은 TOML(Tom’s Obvious, Minimal Language) 포멧 형태이다. 파일 내용을 살펴보자.

[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your name <you@example.com>"]

[dependencies]

대괄호(”[]”)로 세션을 나누고 있다. package 세션은 패키지 전에 환경 영역이고 dependencies 세션은 의존성 영역으로 사용할 라이브러리 정보를 관리한다. rand라는 라이브러리를 추가한다면 아래과 같이 사용하면 된다.

[dependencies]
rand = "0.3.0"

등호 뒤에 문자열은 버정 정보이다.

소스코드는 src 폴더 안에 저장된다. 기본으로 main.rc 파일이 생성되어 있다.

빌드하고 실행

간단하게 빌드하고 실행해보자.

$ cargo build
$ ./target/debug/hello_cargo

기본 빌드는 디버그 빌더로 ./target/debu 폴더에 실행파일이 생성된다. 아래처럼 실행할 수도 있다.

$ cargo run

만약 릴리즈로 빌드하고 싶다면 다음처럼 실행한다.

$ cargo build --release

릴리즈 버전은 ./target/release 폴더에 생성된다.

그외로 패키지에 새로운 라이브러리나 변경된 라이브러리가 있다면 update 명령으로 반영할 수 있다.

$ cargo update

빌드 작업은 오래 걸리기 때문에 사전에 컴파일이 잘되는지 확인하려면 chekc를 실행한다.

$ cargo check

간단하게 cargo를 살펴보았다. 이외에도 다양한 기능이 매우 많다. 관심이 있다면 참고[4]를 참고바란다.

모듈

모듈은 프로그램 확장하는데 중요한 부분이다. Rust에서는 라이브러리 크레이트가 모듈 역활을 수행한다.

모듈 추가

라이브러리 크레이트는 cargo로 쉽게 생성할 수 있다. 먼저 myModule라는 라이브러리 크레이트를 생성해보자.

$ cargo new myModule --lib
$ cd myModule

생성된 파일과 폴더 구조를 보면 다음과 같다.

  • Cargo.toml
  • ./src
    • lib.rs

생성된 내용을 보면 앞의 바이너리 크레이트와 비슷한데 소스 파일명이 다르다. 생성된 소스 파일은 lib.rs로 파일 내용를 살펴보자.

#[cfg(test)]
mod tests {
  #[test]
  fn it_works() {
  }
}

라이브러리 크레이트를 컴파일 하기 위해서 build 명령을 수행한다.

$ cargo build

모듈 정의

모듈을 정의해보자. 모듈 정의 형식이 “mod 모듈명 { 모듈 몸체 }” 형태로 되어 있다. 기본적으로 포함된 내용은 테스트를 위한 부분으로 차후에 자세히 살펴볼 예정이다. 먼저 모듈을 추가 정의해보자.


mod foo {
  fn doFoo() {
  }
}
mod bar {
  mod nested1 {
    fn action() {
    }
  }
  mod nested2 {
    fn action() {
    }
  }
}

모듈을 한 파일에 두지 않고 여러 파일로 관리하는게 효율적이어서 모듈을 여러 파일로 분리해보자. 먼저 src/foo.rs파일을 생성한다.

mod foo {
  fn doFoo() {
  }
}

그리고 src/lib.rs 파일은 다음 처럼 수정한다.


mod foo; // 전방선언
mod bar {
  ...
}

foo에 대해 전방선언해준다. 이는 다른 위치에서 찾을 수 있다는 의미이다.

이번에는 bar 모듈인데 안에 여러 모듈이 포함되어 있다. bar 모듈은 내부에 여러 모듈로 구성되어 있어서 바로 파일로 분리하기 보다 폴더로 분리한다. 먼저 모듈명과 동일한 폴더를 생성하고 기본 파일인 mod.rs와 foo 모듈에 포함된 nested1과 nested2 모듈을 각각 파일로 생성한다. 최종적으로 생성되는 파일은 다음과 같다.

  • src/bar/
    • mod.rs
    • nested1.rs
    • nested2.rs

먼저 mod.rs 파일을 보자.


mod bar {
  mod nested1; // 전방선언
  mod nested2; // 전방선언
}

bar 모듈 내에 포함된 모듈을 모두 전방 선언해준다.

다음으로 nested1.rs 파일을 보자.

mod nested1 {
  fn action() {
  }
}

마지막으로 nested2.rs 파일을 보자.

mod nested2 {
  fn action() {
  }
}

마지막으로 src/lib.rs 파일은 다음 같이 수정된다.


mod foo; // 전방선언
mod bar; // 전방선언

지금까지 모듈 환경 구성하는 방법을 살펴보았다.

가시성

내부에 있는 코드간에는 가시성은 의미가 없다면, 외부에 있는 코드에서는 중요한게 가시성이다. 가시성으로 인해 사용할 수 있고 사용불가할 수도 있다. 만약 이런 가시성을 명시하지 않는다면 기본은 private으로 외부에서는 접근할 수 없다. 명시적으로 pub을 선언해줘야 한다.

pub mod foo;
pub mod bar;

모듈만 pub 해줘도 모듈 내에 다양한 요소를 접근하기 위해서 개별적으로 다시 지정해야 한다.

mod foo {
  pub fn doFoo() {
  }
}

외부에 접근을 허용할 함수에도 pub을 지정해야 한다. 만약 모듈 내에 구조체를 정의한 경우 다음 처럼 가시성을 명시해야 사용할 수 있다.

pub struct foo {
  pub name: &str,
}

impl foo {
  pub fn nameOf(&str) -> &str {
    self.name
  }
}

이는 열거형뿐만 아니라 다른 모든 것에 동일하게 가시성을 지정해야 한다. 만약 외부에 노출하고 싶지 않다면 아무것도 표시하지 않으면 된다.

모듈 사용

바이너리 크레이트에서 라이브러리 크레이트를 사용해보자. 먼저 사용할 라이브러리 크레이트인 myModule을 추가해보자. 크레이트 추가 형식은 “extern crate 크레이트이름”으로 표현된다.

extern crate myModule;

fn main() {
  myModule::foo::doFoo();
}

모듈을 가져올 때에 매번 긴 경로명을 use로 간편하게 지정할 수 있다.

use myModule::foo;
use myModule::{foo, bar}; // 선택
use myModule::*; // 모두

모듈이 계층구조로 구성되어 있을 경우 상위 모듈에 기능을 하위 모듈에서 실행할 경우가 있다. 예를 들어 bar::nested1 모듈에서 foo에 있는 기능을 호출하고 싶다면 super을 사용해 최상위 계층에서 접근할 수 있다.

super::foo:doFoo();

매번 이것도 귀찮다면 use을 사용하면 된다

use super::foo::doFoo();

에러 처리

Rust 에러는 자신의 원하는데로 처리할 수 있지만, 자체적으로 사용하는 에러 처리 메커니즘이 있다. Rust에서 에러 처리는 복구 가능한(recoverable) 에러와 복구 불가능한(unrecoverable) 에러로 나눌 수 있다. 복구 가능한 에러에는 Result<T,E>을 사용하고 볼구 불가능한 에러에서는 panic! 매크로를 사용한다.

복구 불가능한 에러

복구 불가능한 에러가 발생하면 panic!을 실행하게 되고 스택을 되감고(unwinding) 청고하고 종료된다. 이 작업은 길기 때문에 즉시 그만두기(abort)도 있다. abort으로 하고 싶다면 Cargo.toml에 [profile] 세션에 설정하면 된다.

[profile]
panic = 'abort'

간단한 panic! 예를 보자.

fn main() {
  panic!("crash and burn");
}

panic!을 실행하면 화면에 에러메시지가 표시되는데 마지막 3줄이 panic! 관련 메시지이다.

...
thread 'main' panicked at 'crash and burn', src/main.rs:2
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

첫번째 줄은 panic!이 발생한 위치이고, 두번째 줄은 백트레이스 위한 옵션 정보를 알려주며, 세번째 줄은 상세 에러 메시지를 표시한다.

백트레이스 옵션을 환경변수 RUST_BACkTRACE=1로 활성화하고 실행하면 디버그 심볼 정보가 있다면 트레이스 정보가 자세히 표시된다. 이를 바탕으로 정확한 원인을 분석할 수 있다.

panic!은 복구 불가능한 경우에 호출해서 실행을 중지한다. 복구 가능한 경우에 panic!을 호출된다면 정말로 복구 불가능한 상황으로 바뀐다. 예제, 프로토타임, 테스트나 복구 불가능하다고 확신한 경우에 panic!을 호출하는게 좋다.

또한 라이브러리에서 절대로 입력되어서는 안되는 값을 입력할 경우 panic!을 호출해서 강제로 수정하도록 만들 수 있다. 물론 어느정도 실수로 예상할 수 있는 값은 에러로 리턴해서 처리하도록 만들 수 있다. 이는 공격자가 유효하지 않은 값을 입력해서 코드 취약점을 찾을 수 있기에 panic!을 호출해야하는 이유이기도 하다.

복구 가능한 에러

대부분은 복구 가능한 에러이다. 복구 가능한 에러는 Rust 라이브러리에 정의된 Result을 사용해서 처리한다. Result 열거형으로 다음 처럼 정의되어 있다.

enum Result<T,E> {
  Ok(T),
  Err(E),
}

제너릭 타입 파라미터가 2개인 열거형이다. 성공할 경우 Ok을 실패할 경우는 Err을 리턴한다. 파일 처리하는 예를 가지고 살펴보자.

use std::fs::File;

fn main() {
  let f = File::open("foo.text");
}

함수 반환 타입은 API 문서를 참고하거나 직접 실행해서 확인할 수 있다. 직접 확인할 경우 엉뚱한 타입에 할당하면 컴파일러가 친절하게 알려준다.

let f:u32 = File::open("foo.text");

open()은 실행 성공하면 Ok가 리턴되고 실패하면 Err이 리턴되고 에러 정보도 같이 포함된다. 이런 에러에 대한 처리를 match 문을 사용해서 효과적으로 처리할 수 있다.

use std::fs::File;

fn main() {
  let f = File::open("foo.text");
  let f = match f {
    Ok(file) => file,
    Err(err) => panic!("failed to open {:?}", err),
  };
}

단순하게 처리하기 위해 에러가 발생하면 panic!으로 처리했다. 조금 더 정교하게 에러 를 세부적으로 처리해보자.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
  let f = File::open("foo.txt");
  let f = match f {
    Ok(file) => file,
    Err(ref err) if err.kind() == ErrorKind::NotFound => {
      match File::create("foo.txt") {
        Ok(fc) => fc,
        Err(e) => {
          panic!("failed to create: {:?}", e);
        },
      }
    },
    Err(err) => { // NotFound가 아닌 경우
      panic!("failed to open: {:?}", err);
    }
  }
}

match 문이 중복으로 복잡하게 중첩되어 있다. 이런 작업을 단순하게 도와주는 헬퍼가 있다. unwrap()이 해당 핼퍼로 Ok라면 포함된 값을 반환하고 Err이면 panic!을 호출해준다.

let f = File::open("foo.txt").unwrap();

다른 핼퍼로 expect()가 있는데 에러 메시지를 추가할 수 있다.

let f = File::open("foo.txt").expect("failed to open foo.txt");

이를 함수로 만들어서 파일을 읽어서 리턴하는 함수를 만들어보자. 리턴값은 Result를 사용해보자.

use std::io;
use std::io::Read;
use std::fs::File;

fn readFile(filename: &str) -> Result<String, io::Error> {
  let f = File::open(filename);
  let mut f = match f {
    Ok(file) => file,
    Err(e) => return Err(e),
  };
  let mut s = String::new();
  match f.read_to_string(&mut s) {
    Ok(_) => Ok(s),
    Err(e) => Err(e),
  }
}

readFile() 처리 중에 에러가 발생한 Err을 리턴한다. 만약 성공하면 Ok에 파일 내용이 문자열로 포함되어 리턴된다. 위의 코드도 “?”을 사용해서 좀더 쉽게 처리할 수 있다.

fn readFile(filename: &str) -> Result<String, io::Error> {
  let f = File::open(filename)?;
  let mut s = String::new();
  f.read_to_string(&mut s)?
  Ok(s)
}

Result 뒤에 “?”은 match 문과 동일한 동작으로 Ok인 경우는 저장된 값을 추출하고 Err인 경우 해당 에러를 바로 리턴한다. “?”은 바로 뒤에 코드를 계속 연결할 수 있다.

fn readFile(filename: &str) -> Result<String, io::Error> {
  let mut s = String::new();
  File::open(filename)?.read_to_string(&mut s)?;
  Ok(s)
}

“?”은 Result에만 사용할 수 있다.

테스트

Rust에서는 테스트를 할 수 있는 기능을 제공한다. Rust에서는 “#[test]”라는 어노테이션으로 test 속성(attribute)을 지정한 함수를 통해서 테스트를 실행한다. 이런 속성은 메타데이터로서 테스트 실행할 때에 테스트 실행 대상이 되는 정보이다.

테스트 작성

새로운 라이브러리 크레이트를 추가하면 src/lib.rc 파일에 테스트 코드가 포함되어 있다. 이 테스트 코드에 간단하게 작성해서 추가해보자.

#[cfg(test)]
mod tests {
  #[test]
  fn it_works() {
    assert_eq!(1 + 1, 2);
  }
  #[test]
  fn another() {
    panic!("test failed");
  }
}

첫줄 “#[cfg(test)]”은 테스트 자체 설정으로 테스트 명령 실행할 때에 테스트에 포함되어 진행한다는 의미다. 그리고 각 테스트 함수마다 “#[test]” 어노테이션을 추가해 테스트시 실행할 함수임을 지정한다. 그리고 결과 확인을 위해 assert_eq! 매크로를 사용해서 결과를 비교한다. 이런 매크로는 assert!, assert_ne! 등 다양하게 제공된다.

assert!(foo.check(), "check failed, foo is `{}`", foo);

만약 테스트 결과가 panic!일 경우 확인할 수 있는 방법도 제공된다.

#[test]
#[should_panic]
fn fail_test() {
  panic_call();
}

“#[should_panic]“ 지정함으로써 해당 테스트는 panic! 발생을 검증할 수 있다.

테스트 실행

테스트 실행은 test 명령으로 실행할 수 있다.

$ cargo test

테스트를 병렬로 실행할 수도 있다.

$ cargo test -- --test-threads=1

테스트 진행 중에 발생하는 출력은 모두 캡처된다. 만약 테스트에 실패할 경우에 같이 화면에 표시된다. 만약 테스트가 성공한 경우에도 출력하고 싶다면 nocapture 옵션을 사용한다.

$ cargo test -- --nocapture

만약 테스트에서 특정 테스트 함수만 실행하거나 특정 키워드에 맞는 경우만 실행하고 싶다면 아래와 같이 입력한다.

$ cargo test a_test_function_name
$ cargo test a_keyword

만약 특정 함수를 테스트에서 배제하고 싶다면 해당 함수에 “#[ignore]” 어노테이션을 추가한다.

#[test]
#[ignore]
fn test1() {
  ...
}

실제 테스트 실행해서는 “#[ignore]” 어노테이션을 지정해도 테스트에 포함된다. ignore된 테스트를 제외하고 싶다면 ignored 옵션을 사용해야 한다.

$ cargo test -- --ignored

통합 테스트

앞에서 테스트는 “[cfg(test)]”에 의해서 테스트 실행할 때에 같이 테스트를 진행하게 된다. 그렇기 때문에 빌드할 때에는 테스트 코드가 포함되지 않는다. 또한 이런 테스트 코드는 비공개 함수도 같이 테스트할 수 있다. 즉, 단위 테스트를 진행할 수 있다.

단위 테스트외에 수많은 라이브러리들이 서로 통합되서 잘 작동하는지 테스트할 경우가 있다. 이를 통합 테스트라고 하고 별개 프로젝트로 구성되서 진행된다. 이를 위한 테스트 프로젝트의 최상위 폴더를 tests라고 하고 해당 폴더 안에 테스트 코드를 작성하면 된다. 작성할 테스트 코드는 앞의 작성했던 테스트 코드와 동일한 형태로 작성하면 된다.

tests/some_integration_test.rs 파일로 통합테스트를 만든 예를 살펴보자..

extern crate adder;

#[test]
fn it_adds_two() {
  assert_eq!(4, adder::add_two(2));
}

첫출에 “extern crate”로 사용한 외부 크레이트를 선택한다. 단위 테스트에는 필요없지만 통합 테스트는 별도 프로젝트이므로 추가해야 한다. 나머지 테스트 코드는 단위 테스트와 동일하다.

cargo에서는 tests 폴더를 특별 취급해서 실행한다. 테스트 실행할 때에 tests 폴더도 컴파일해서 테스트에 포함하고, 실행 결과에서도 새로운 세션을 구분해서 출력한다. 이런 세션은 단위 테스트, 통합 테스트, 문서 테스트로 나눠진다.

테스트 출력 내용에서 “Running target/debug/deps/통합테스트파일명-해시키“ 형태로 시작되서 테스트서 실행된 통합 테스트에 함수가 출력된다.

만약 특정 통합 테스트만 실행하려면 해당 통합 테스트를 지정하면 된다.

$ cargo test --test some_integration_test

통합 테스트를 작성하다 보면 테스트가 커지면서 테스트에 필요한 공통 파일을 생성할 수 있다. 예를 들어 tests/common.rs 공통 파일을 만들고 setup()이라는 함수를 추가했다고 하자.

pub fn setup() {
  // 테스트용 셋업 코드
}

cargo에서는 공통 파일인지 알 수 없기 때문에 common.rs 파일도 테스트에 포함된다. 만약 테스트에 포함하지 않으려면 tests 밑에 폴더를 생성하고 그 않에 mod.rs 파일을 생성한다. common.rs 파일인 경우 common 폴더를 생성하고 “common/mod.rs” 파일을 생성해서 공통 코드를 추가하면 된다. 이후 테스트 실행할 때에는 더이상 출력되지 않는다.

만약 바이터리 크레이트 안에서 테스트 코드를 실행하고 싶을 수 있지만 테스트는 진해이 불가능하다 그렇기 때문에 테스트할 중요한 코드가 있다면 라이브러리 크레이트로 분리해서 테스트를 할 수 있도록 구성하면 된다.

트레잇 (trait)

트레잇은 추상화를 위한 기능으로 공통적인 동작을 추상화해준다. OOP에서 인터페이스(interface)와 유사한 기능이다. 다음과 같은 트레잇이 있다고 하자.

trait Sayable {
  fn say(&self);
}

struct Greeting(String);

impl Sayable for Greeting {
  fn say(&self) {
    println!("{}", self.0);
  }
}

fn main() {
  let greeting = Greeting(String::from("Hello world!"));
  greeting.say();
}

만약 트레잇에 기본 동작을 정의할 수 있다. 아무것도 구현하지 않으면 기본 동작이 실행된다.

trait Sayable {
  fn say(&self) {
    println!("Hi!");
  }
}

struct Greeting(String);

impl Sayable for Greeting {}

이번에는 연관 타입(associated type)에 대해서 살펴보자. 연관 타입은 트레잇 내에 선언된 타입 매개변수로 시그니처에 사용할 수 있다. 이로 인해 임의 타입을 트레잇 구현할 때 정의해서 사용할 수 있다. 이를 이해하기 위한 간단한 예를 보자.

trait Getter {
  type Ret;
  fn get(&self) -> Self::Ret;
}

struct Greeting(String);

impl Getter for Greeting {
  type Ret = String;
  fn get(&self) -> Self::Ret {
    self.0.clone()
  }
}

fn main() {
  let g = Greeting(String::from("Hello world!"));
  println!("{}", g.get());
}

트레잇에서 type을 사용해 연관 타입을 선언한다. 선언된 연관 타입을 get()의 리턴 타입으로 사용하고 있다. 그외에 인자나, get()은 기본 정의 내에서도 사용가능하다.

완전 정규화(fully qualified) 문법

다른 트레잇일지라도 동일한 메소드를 가질 수 있다. 컴파일러에게 호출할 메소드를 알려줘야 한다.

trait Foo {
  fn execute(&self);
}
trait Bar {
  fn execute(&self);
}

struct Dummy;

impl Dummy {
  fn execute(&self) {
    println!("dummy");
  }
}

impl Foo for Dummy {
  fn execute(&self) {
    println!("foo for dummy");
  }
}

impl Bar for Dummy {
  fn execute(&self) {
    println!("bar for dummy");
  }
}

fn main() {
  let d = Dummy;
  d.execute();
  Foo::execute(&d);
  Bar::execute(&d);
}

만약 트레잇의 메소드에 “&self”가 없을 경우는 어떻게 될까?

trait Foo {
  fn name() -> String;
}

struct Dummy;

impl Dummy {
  fn name() -> String {
    String::from("Dummy")
  }
}

impl Foo for Dummy {
  fn name() -> String {
     String::from("Foo")
  }
}

fn main() {
  println!("{}", Dummy::name());
  // Foo::name() 호출시 에러
  println!("{}", <Dummy as Foo>::name());
}

만약 Foo::name()을 호출하면 컴파일 에러가 발생한다. Foo 트레잇의 name() 메소드는 기본 정의가 없기에 호출하면 에러가 발생한다. Dummy에서 Foo 트레잇을 구현한 name()를 호출해야 한다. 이때 as를 사용해 Dummy를 Foo 처럼해서 호출하면 된다. 이것이 완전 정규화 문법이고 다음과 같은 형식을 갖는다.

<Type as Trait>::function(arg1, arg2, ...);

슈퍼트레잇(supertrait)

트레잇 내에 다른 트레잇이 포함되는 경우 포함하는 트레잇이 슈퍼트레잇이라고 한다. 예를 들어 FooBar 트레잇이 있고 이 트레잇에서 Foo와 Bar 트레잇을 사용한다면 FooBar 트레잇이 슈퍼트레잇이 된다. 슈퍼 트레잇 뒤에 콜론(:)이 오고 “+” 사용할 트레잇들을 연결해주면 된다.

trait Foo { fn foo(&self); }
trait Bar { fn bar(&self); }

trait FooBar : Foo + Bar {
  fn execute(&self) {
    self.foo();
    self.bar();
    println!("done");
  }
}

struct Dummy;

impl Foo for Dummy {
  fn foo(&self) { println!("foo for dummy"); }
}

impl Bar for Dummy {
  fn bar(&self) { println!("bar for dummy"); }
}

impl FooBar for Dummy  {}

fn main() {
  let d = Dummy;
  d.execute();
}

FooBar 트레잇 뒤에 Foo와 Bar 트레잇이 없다면 foo()와 bar() 호출은 안된다.

제너릭(Generic)

변수 타입은 정해진 타입 하나만 사용할 수 있다. 제너릭 타입은 임의 타입을 지정해서 사용할 수 있고 컴파일 단계에서 결정된다. 제너릭 타입은 함수, 구조체, 열거형, 메소드등 다양한 곳에서 활용된다. 먼저 간단하게 함수에서 사용하는 방법을 살펴보자.

fn max<T>(l: T, r: T) -> T {
  if l > r { l } else { r }
}
fn main() {
  let v1:u32 = 0;
  let v2:i32 = -10;

  println!("max is {}", max(v1, 10));
  println!("max is {}", max(v2, 0));
}

함수명 뒤에 꺽쇠 안에 제너릭 파라미터을 선언한다. 위 예제에서 사용한 제너릭 파라미터 T는 type을 줄인 대문자 글자로 관행적으로 사용된다. 제너릭 파라미터는 인자와 리턴 타입에 사용된다. 물론 내부 변수 타입에도 사용할 수 있다.

트레잇 바인딩

위의 예제는 컴파일 오류가 발생한다. 이는 “>”에 의해 두 값 비교를 해야하는데 제너릭 타입으로 인해 해당 타입이 비교할 수 있는지 알 수 없다. 해당 타입이 서로 “>”로 비교하려면 std::cmd::PartialOrd 트레잇을 구현하고 있으면 된다. 그래서 컴파일러에게 해당 타입이 ”>“ 비교할 수 있는 타입이라고 알려줘야 한다. 즉, 제너릭 파라미터에 타입이 지정된 트레잇이 구현된 타입한 제한하는 트레잇 바인딩이다. 표현식은 “<파라미터: 트레잇1+트레잇2>” 형태로 제너릭 파라미터 뒤에 세미콜론(:)을 붙이고 사용할 트레잇을 명시하면 된다. 위의 예제에 max()를 수정하면 아래와 같다.

use std::cmd::PartialOrd;
fn max<T: PartialOrd>(l: T, r: T) -> T {
  if l > r { l } else { r }
}

컴파일러 에러 메시지를 보면 잘 설명해준다.

사용할 트레잇이 여러 개일 경우는 “+” 기호로 계속 연결해서 트레잇을 나열하면 된다.

use std::cmd::PartialOrd;
use std::fmt::Display;
fn max<T: PartialOrd + Display>(l: T, r: T) -> T {
  println!("{} > {}", l, r);
  if l > r { l } else { r }
}

트레잇이 너무 많아서 한꺼번에 표기하기 힘들면 where을 사용해서 별도 표기 가능하다.

use std::cmd::PartialOrd;
use std::fmt::Display;
fn max<T>(l: T, r: T) -> T 
  where T: PartialOrd + Display {
  println!("{} > {}", l, r);
  if l > r { l } else { r }
}

좀더 보기 편해졌다.

제너릭 열거형/구조체

제너릭은 열거형에도 사용할 수 있다. 이번에는 제너릭 파리미터를 2개를 사용한 열러경을 정의해보자.

enum Result<T, E> {
  Ok(T),
  Err(E),
}

제너릭 파라미터를 여러 개 사용하고 싶다면 꺽쇠 안에 콤마(,) 구분자로 여러 제너릭 파라미터를 나열하면 된다. 위의 예제는 Result 열거형이고 Ok와 Err이라는 튜플이 있다. 그리고 튜플에 타입은 제너릭 파라미터를 사용하고 있다. 이는 임의의 자료형을 가진 데이터를 Ok또는 Err에 서로 다른 형식으로 저장할 수 있다는 의미이다.

구조체에서도 제너릭을 활용할 수 있다.

struct Pair<T, U> {
  first: T,
  second: U,
}

impl<T:Copy, U:Copy> Pair<T, U> {
  fn mixup<V:Copy,W:Copy>(&self, other: Pair<V,W>) -> Pair<T, W> {
    Pair { first: self.first, second: other.second }
  }
}

fn main() {
  let p1 = Pair { first: 10, second: 11 };
  let val:u32 = 1;
  let p2 = Pair { first:val, second: val };
  let p3 = p2.mixup(p1);
  println!("p3({}, {})", p3.first, p3.second);
}

구조체를 impl 할때에 메소드에서도 제너릭을 사용할 수 있고, 구조체 제너릭과 별개로 정의할 수 있다.

트레잇의 연관 타입과 제너릭은 비슷해보이지만 다르다. 제너릭은 컴파일 타입에 정해진다. 즉, 제너릭은 타입마다 구현이 필요하므로 타입 별로 여러 트레잇이 생길수 있다. 연관 타입은 하나의 트레잇으로 여러 타입을 지원할 수 있다라는 차이점이 있다.

기본 제너릭 타입 파라미터

제너릭 타입에 대해 구체 타입을 명시해야하지만, 기본 타입을 미리 지정해서 사용할 수 있다. 기본 제너릭 파라미터는 트레잇에서 사용할 경우 매우 유용하다. 타입 파라미터에 대한 기본 타입은 구조체, 열거형, 타입 또는 트레잇만 가능하다.

다음 두 객체를 더하는 Add 트레잇이다.

trait Add<RHS=Self> {
  type Output;
  fn add(&self, rhs: RHS) -> Self::Output;
}

제너릭 파라미터인 RHS의 기본 타입은 Self이다. Self은 자신 타입이 되며, impl에 의해서 트레잇을 구현하게 되면 대상 구조체 또는 열거형 타입이 Self가 된다. Millimeter 구조체를 만들어서 Add 트레잇을 구현해보자.

struct Millimeter(u32);

impl Add for Millimeter {
  type Output=Millimeter;
  fn add(&self, rhs: Millimeter) -> Millimeter {
    Millimeter (self.0 + rhs.0)
  }
}

제너릭 파라미터인 RHS을 별도 지정하지 않았기 때문에 Millimeter가 된다. 사용예를 보자.

fn main() {
  let m1 = Millimeter(10);
  let m2 = Millimeter(20);
  println!("{}", m1.add(m2).0);
}

여기서 하나 더 확장해보자. 이번에는 Meter 구조체를 추가하고 Add 트레잇을 추가해보자.

struct Meter(u32);

impl Add<Meter> for Millimeter {
  type Output = Millimeter;
  fn add(&self, rhs: Meter) -> Millimeter {
    Millimeter(self.0 + rhs.0 * 1000)
  }
}

Millimeter에서 Meter에 대한 Add 트레잇 구현을 추가했다. 제너릭 파라미터를 Meter로 지정했기에 RHS는 Meter가 된다. 사용예를 보자.

fn main() {
  let m1 = Millimeter(2);
  let m2 = Meter(2);
  println!("{}", m1.add(m2).0);
}

추가로 추후에 연산자 재정의에서 다루겠지만 Add 트레잇은 표준 라이브러리에 포함되어 있다. 이를 사용하면 일반적인 “+” 연산자를 사용할 수 있다.

type 키워드

type 키워드는 타입에 대한 별칭을 부여여 이름을 짧게하거나 가독성이 있게 변경할 수 있다.

enum VeryVeryLongName {
}

type ShortName = VeryVeryLongName;

type MyResult<T> = Result<T, String>;

별칭을 부여한 이후에는 별칭으로 타입을 대신해서 사용할 수 있다. 이는 나중에 나올 제너릭도 동일하게 적용된다.

패턴 매칭 상세

match 문, if set 문, while set 문등 패턴 매칭이 지원하는 문법이 있다. 사용상 주의 사항과 추가적인 조건에 대해서 살펴보자.

if set 문

match 외에서 사용할 수 있는 매칭 표현식으로 if set이 있다. if set은 값이 할당 가능하다면 실행이 된다.

enum Option {
  Some(i32),
  None,
}

fn main() {
  let val = Option::Some(1);
  if let Option::Some(x) = val {
    println!("some value is {}", x);
  }
}

while set 문

while set 문은 해당 값이 계속 할당 가능하다면 계속 반복한다.

enum Option {
  Some(i32),
  None,
}

fn main() {
  let vals = [Option::Some(1),Option::Some(2),Option::None];
  let mut i = 0;
  while let Option::Some(x) = vals[i] {
      println!("{}", x);
      i += 1;
  }
}

vals의 마지막이 None이기 때문에 while set에 만족하지 않아서 종료된다.

ref 키워드

match에서 의해서 값이 매칭된 경우 값이 이동되어 이후에는 해당 값을 사용할 수 없다. 간단한 예를 보자.

#[derive(Debug)]
enum Option {
  Some(String),
  None,
}

fn main() {
  let val = Option::Some(String::from("foo"));
  match val {
    Option::Some(s) => println!("matched: {}", s),
    Option::None => (),
  }
  println!("{:?}", val); // 에러
}

소유권 이동으로 match 문이후에는 val을 사용할 수 없게 되면서 컴파일 에러가 발생한다.

참고로 “#[derive(Debug)]”는 어노테이션으로 열거형이나 구조체에 사용하면, 간단하게 출력할 수 없다. println!에서 “{:?}”을 사용해서 출력하면 내부에 있는 값까지 자동으로 표시해준다.

에러를 해결할 방법은 match 문에서 참조자를 사용해서 해결할 수 있다. 그러나 match 문에서는 참조자를 생성하는 참조 구문(”&”)을 사용할 수 없다. 이를 대신해서 ref 키워드를 사용한다.

#[derive(Debug)]
enum Option {
  Some(String),
  None,
}

fn main() {
  let val = Option::Some(String::from("foo"));
  match val {
    Option::Some(ref s) => println!("matched: {}", s),
    Option::None => (),
  }
  println!("{:?}", val);
}

실행하면 제대로 컴파일되고 실행되었음을 확인할 수 있다.

반증 가능성(Refutability)

반증 가능성은 반증 가능(refutable) 패턴과 반증 불가(irrefutable) 패턴으로 두 가지 패턴이 있다. 반증 가능 패턴은 주어진 값이 매칭에 성공할 수도 있고 실패할 수 도 있다. 반증 불가 패턴은 절대로 매칭이 실패할 수 없다. 예를 들어 살펴보자.

let x = 5; // (1) 반증 불가 패턴
let Some(x) = some_option; // (2)에러: 반증 가능 패턴 미허용
if let Some(x) = some_option { // (3) 반증 가능 패턴
}
if let x = 5 { // (4) 에러: 반증 불가 패턴 미허용
}

(1)은 반증 불가로 실패할 수 없다. let, for 인경우 반증 불가 패턴만 허용된다. (2) 처럼 let, for에서 반증 가능 패턴을 사용하면 컴파일 에러가 발생한다. (3)은 반증 가능 패턴으로 값 할당이 되면 처리한다. some_option이 Some()이 아닌 다른 값이 올 수도 있다. (4) 처럼 반증 불가 패턴을 반증 허용 패턴에서 사용은 허용되지 않는다. 이처럼 서로 사용할 수 있는 장소가 정해져있기 때문에 주의가 필요하다.

매치 가드(match guard)

매치 가지는 match 문의 조건 뒤에 추가로 붙은 if 조건이다. if 조건까지 만족해야 실행된다.

enum Option {
  Some(i32),
  None,
}

fn main() {
  let val = Option::Some(1);
  match val {
    Option::Some(x) if x < 5 => println!("less the five: {}", x),
    Option::Some(x) => println!("greater equal the five: {}", x),
    Option::None => (),
  }
}

@ 바인딩

match 문에서 at 연산자로 패턴 매칭과 변수 별칭을 생성한다. 실제 사용 예를 보자.

struct Message {
  index: i32,
}

fn main() {
  let msg = Message { index: 1 };
  match msg {
    Message { index: 6..=10 } => println!("found higher: {}", index), // (2)
    Message { index: id @ 1..=5 } => println!("found lowere: {}", id), // (3)
    Message { index } => println!("found index: {}", index), // (1)
  }
}

(3)이 일반적으로 사용하는 경우로 구조체 필드인 index를 사용할 수 있다. (2)는 index 필드에 범위 조건을 추가했다. 이경우 index를 변수로 사용할 수 없다. index를 변수로 사용하고 싶다면 (2) 처럼 “@”을 사용해 별칭과 범위 조건을 지정할 수 있다.

주의할 부분은 현시점 배타적 범위 패턴(exclusive range pattern)은 지원하지 않고 포괄 범위 패턴(inclusive range pattern)을 지원한다(주: 추후 변경 가능).

라이프타임(lifetime)

모든 참조자를 라이프 타임을 갖는다. 라이프 타임을 참조자 생성과 소멸까지는 주기를 관리한다. 라이프 타임은 참조자의 유효한 범위를 가리킨다. 이 라이프 타임의 목적은 댕글링 참조자(dangling reference) 생성을 방지하기 위함이다.

참조 검사기(Borrow checker)

참조 검사기는 모든 참조가 유효한지 결정하기 위해서 참조들 간에 스코프를 비교한다.

{
  let r;                // --------+- (1)
  {                     //         |
    let x = 5;          // -+- (2) |
    r = &x;             //  |      |
  }                     // -+      |
  println!("r[{}]", r); //         |
}                       // --------+

(1)은 r의 라이프타임이고 (2)는 x의 라이프타임이다. r은 x보다 라이프타임이 길다. 즉, x가 먼저 소멸된다. 즉, r에 있는 x참조를 사용할 때에는 유효하지 않은 포인터가되서 에러가 발생한다. 어떤 객체를 참조하기 위해서 참조하는 객체보다 참조할 객체의 라이프 타임이 더 길어야 한다. 위의 경우 참조하는 객체인 r보다 참조할 객체인 x의 라이프타임이 더 길어야 한다.

{
  let x = 5;            // --------+- (1)
  let r = &x;           // -+- (2) |
  println!("r[{}]", r); //  |      |
}                       // -+------+

x의 라이프타임인 (1)이 r의 라이프타임인 (2)보다 더 길기 때문에 r에서 x를 참조할 때 포인터가 유효하다.

함수 라이프타임

라이프타임을 사용해보자. 다음은 더 긴 문자열을 리턴하는 함수이다

fn max(l: &str, r: &str) -> &str {
  if l.len() > r.len() { l } else { r }
}

코드 상으로는 문제 없어 보이지만 컴파일 에러가 발생한다. 이유는 리턴 값이 참조자 l 또는 참조자 r 중에 어떤 참조자가 리턴될지 모르기 때문에 라이프타임을 결정할 수 없기 때문이다.

이를 해결하기 위해 라이프타임 표기를 한다. 라이프타임 표기는 어퍼스트로피(‘) 위에 이름을 부여한다. 먼저 사용할 라이프타임 파라미터를 함수이름 뒤에 꺽쇠 안에 표기한다. 앞에서 보았던 제너릭 파라미터가 있던 자리이다. 지정할 타입의 참조자 기호(&) 뒤에 위치한다. 타입과는 공배로 구분한다.

fn max<'a>(l: &'a str, r: &'a str) -> &'a str {
  if l.len() > r.len() { l } else { r }
}

라이프타임이 참조에 대한 내용이기 때문에 참조자가 있는 곳에 사용된다. 위의 예제를 해석하면 같은 라이프타임을 갖는 대상에서 입력되는 두 인자 중에 가장 짧은 라이프타임이 리턴 값의 라이프타임으로 결정된다. 주의할 부분은 리턴에 관여하는 입력 라이프타임이 없다면 에러가 발생한다.

fn main() {
  let s1 = String::from("long string");
  let res;
  {
    let s2 = String::from("short");
    res = max(s1.as_str(), s2.as_str());
  }
  println!("{}", res);
}

위의 예제에서는 s1이 리턴되고 출력되는데는 문제 없어 보인다. 그러나 앞의 라이프타임으로 인해 리턴 값 res은 가잘 짧은 라이프타임을 가지는 s2로 되어 있기에 출력 시점에서는 유효하지 않다.

구조체 라이프타임

구조체에서 라이프타임도 함수와 동일하다. 표기도 동일하다.

struct Foo<'a> {
  name: &'a str,
}

fn main() {
  let s = String::from("_foo");
  let f = Foo { name: &s[1..] };
  println!("{}", f.name);
}

name이라는 스트링 슬라이를 가진 구조체이다. 라이프타임 파라미터 선언는 꺽쇠 안에 위치하고 참조자 뒤에 라이프타임을 명시한다. Foo 구조체의 인스탄스는 문자열 s에 대한 참조자의 라이프타임을 가지게 된다.

메소드 라이프타임

메소드는 impl에 의해서 정의된다. 그렇기에 메소드 라이프타임도 impl 정의할 때에 포함되어야 한다. 이미 구조체에 라이프타임이 이미 포함되어있는 경우이다. 다음은 앞의 Foo 구조체의 메소드 라이프 타임에 대한 예이다.

impl<'a> Foo<'a> {
  fn nameOf(&self) -> &str {
    self.name
  }
}

impl에서도 꺽쇠안에 라이프타임 파라미터를 선언한다.

라이프타임 생략

모든 참조자에 대해서 라이프타임을 명시할 필요가 없다. 컴파일러가 예측 가능한 결정 패턴에 적합하다면 라이프타임을 추론할 수 있다. 이런 패턴을 라이프타임 생략 규칙(lifetime elision rules)이라고 한다. 함수나 메소드 기준으로 라이프타임 사용 위치에 따라 입력 인자에 대해 입력 라이프타임(input lifetime이라고 하고 출력 값에 대해 출력 라이프타임(output lifetime)이라고 한다. 라이프타임 생략 규칙은 아래 세가지가 있다.

  • 입력 인자에 대해 개별적인 고유 라이프타임을 갖는다.
  • 입력 라이프타임이 하나라면 모든 출력 라이프타임에 적용된다.
  • 메소드의 입력 라이프타임들 중에 &self 또는 &mut self가 있다면 이를 모든 출력 라이프타임에 적용된다.

아래 함수에 대해 규칙을 적용한 예를 살펴보자.

fn foo(s: &str) -> &str { ... }

첫번째 규칙에 의해서 각각 고유 라이프타임을 갖는다.

fn foo(s: &'a str) -> &str { ... }

두번째 규칙에 의해서 입력이 하나이므로 모든 출력에 같은 라이프타임을 적용한다.

fn foo(s: &'a str) -> &'a str { ... }

세번재 규칙은 대상이 아니므로 건너뛴다.

다른 함수에 대해서 규칙을 적용한 예를 보자.

fn bar(a: &str, b: &str) -> &str { ... }

첫번째 규칙에 의해서 각각 고유 라이프타임을 갖는다.

fn bar(a: &'a str, b: &'b str) -> &str { ... }

두번째 규칙은 입력이 두 개라서 적용되지 않고, 세번째 규칙도 메소드가 아니라서 적용되지 않는다. 결국 리턴값 라이프타임을 알 수 없게 된다.

메소드인 경우를 보자.

impl Foo<'a> {
  fn nameOf(&self) -> &str { ...  }
}

첫번째 규칙에 의해서 각각 고유 라이프타임을 갖는다.

impl Foo<'a> {
  fn nameOf(&'a self) -> &str { ...  }
}

세번째 규칙에 의해서 &self의 라이프타임이 리턴 값에 적용된다.

impl Foo<'a> {
  fn nameOf(&'a self) -> &'a str { ...  }
}

정적 라이프타임

‘static이라는 프로그램 전체 생애 주기를 갖는 특별한 라이프타임으로 프로그램이 종료될 때까지 존재한다. 이 라이프타임은 문자열 리터럴이나 정적 변수에 사용된다.

lset s:&'static str = "static lifetime";

상수나 정적 변수인 경우 내부적으로 ‘static 라이프타임을 갖는다. ‘static 라이프타임이 다른 라이프타임보다 가장 길다.

마무리

심화 과정도 마무리 했다. 앞으로 다룰 내용이 남아 있는데, 이 부분을 심화라고 하기에 조금 모호하다. 불필요하거나 너무 장황한 부분은 모두 생략하고 예제도 최대한 단순화했다. 어쩌면 실용적인 예제가 아닐 수 있다. 이해를 돕기 위한 부분이기 때문에 유념하시기 바란다. 이로 인해 정리하는 시간이 의외로 많이 소모되었다. 자세한 내용은 참고에 있는 링크를 참고하시기 바란다. 부족한 글이지만 도움이 되었으면 합니다. 모두 즐거운 코딩 생활하세요 ^^. ospace.

참고

[1] Learn Rust, https://www.rust-lang.org/learn

[2] Rust(프로그래밍 언어), https://namu.wiki/w/Rust(프로그래밍 언어)

[3] Installation, https://doc.rust-lang.org/cargo/getting-started/installation.html

[4] The Cargo Book, https://doc.rust-lang.org/cargo/

반응형

'3.구현 > 기타언어' 카테고리의 다른 글

Rust 배우기3 - 활용  (2) 2024.01.10
Rust 배우기1 - 기본  (2) 2024.01.04
C++개발자 위한 C샵 배우기  (0) 2007.10.22
[Flash] 3D Action Script 활용  (0) 2007.05.22