Rust

Rust 정리 (3)

2021.03.20


Generic

fn main() {
  let numbers = vec![34, 50, 25, 100, 65];
  let mut largest = numbers[0];
  for number in numbers {
    if number > largest {
      largest = number;
    }
  }
  println!("Largest : {}", largest);
}

이 후 또 다른 정수 배열에 대해 가장 큰 수를 찾는다면, for 문이 다시 한번 사용될 것이다.

fn largest(list: &[i32]) -> &i32 {
  let mut largest = &list[0];
  for item in list {
    if item > largest {
      largest = item;
    }
  }
  largest
}

이런식으로 함수를 만든다면, 정수 배열에 대해서 largest 함수만 호출하면 될 것이다.

하지만, char 배열에 대해서는 또 다시 함수를 생성해야되는 것일까? 이를 Generic으로 해결한다.

fn largest<T>(list: &[T]) -> &T {
  // ... same
}

하지만 위 코드를 컴파일해보려 하면 오류가 난다. (binary operation '>' cannot be applied to type '&T')

이를 해결하기 위해 T에 적절한 trait을 지정해주어야한다.

얘를 작동하게 만들려면?

정답은... T: PartialOrd + Copy

 

Method 정의시

struct Point<T> {
  x: T,
  y: T,
}
impl<T> Point<T> {
  fn x(&self) -> &T {
    &self.x
  }
}
impl Point<i32> {
  // i32 타입일 경우에만 method 정의 가능
}
fn main() {
  let p = Point { x: 5, y: 10};
  println!("p.x = {}", p.x());
}

 

Trait ≈ interface

Golang에서의 interface와 같이 생각하면 된다.

pub trait Summary {
  fn summarize(&self) -> String;
}

 

  • Type에 Trait을 implement하려면?
pub struct NewsArticle {
  pub headline: String,
  pub location: String,
  pub author: String,
  pub content: String,
}
impl Summary for NewsArticle {
  fn summarize(&self) -> String {
    format!("{}, by {} ({})", self.headline, self.author, self.location)
  }
}

pub struct Tweet {
  pub username: String,
  pub content: String,
  pub reply: bool,
  pub retweet: bool,
}
impl Summary for Tweet {
  fn summarize(&self) -> String {
    format!("{}: {}", self.username, self.content)
  }
}

impl [[TraitName]] for [[Type]]

 

  • Parameter에 Trait 지정 (Parameter에 해당 Trait이 있어야한다.)
// (1)
pub fn notify<T: Summary + Display>(item: &T) {}

// (2)
pub fn notify(item: &(impl Summary + Display)) {}

 

+를 쓰면서 trait을 지정하다보면, parameter 부분의 가독성이 매우 떨어질 것이다. 이를 위해 where 을 사용할 수 있다.

fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone, U: Clone + Debug {
      // ...
}

+return type에 implement trait을 지정할 수 있다.

 

Lifetimes

Lifetime은 다른 언어에서의 변수 scope 라고 간단히 생각할 수 있다.

러스트는 원본 레퍼런스가 사라질 가능성이 있는 경우 컴파일 에러를 일으킨다.(dangling reference)

컴파일 시기에 이러한 에러를 잡기 위한 해답으로 Rust는 Lifetime을 제시한다.

// error : `x` does not live long enough
{
  let r; // lifetime : 'a
  {
    let x = 5; // lifetime : 'b
    r = &x;
  } // 'b end : (1)
  println!("r: {}", r); // (1)에서 만료된 x를 참조하려 함
} // 'a end : (2)

위 코드가 에러가 나는 이유는, (1) 때문이다.

Rust 컴파일러의 borrow checker 는 모든 borrw 행위들이 유효한지 확인한다. Lifetime의 크기를 비교해서 짧은 lifetime 내부의 변수를 외부에서 참조하고 있다면 오류를 일으킨다.

 

// compile error
fn longest(x: &str, y: &str) -> &str {
  if x.len() > y.len() {
    x
  } else {
    y
  }
}

fn main() {
  let string1 = String::from("abcd");
  let string2 = "xyz";
  let result = longest(string1.as_str(), string2);
  println!("The longest string is {}", result);
}

위와 같은 코드가 있을 경우, longest에서는 x가 return될지, y가 return될지 모르는 상황이다.

함수 입장에서는 x와 y의 lifetime이 return 값과 어떤 연관이 있을지도 모른다.

'a 와 같은 형식으로 lifetime을 표시할 수 있는데, lifetime에 변형을 일으키지 않는다.

단지 컴파일할 때, 프로그램 실행간 일어날 수 있는 에러를 사전 파악하기 위함이라고 보면 되겠다.

longest함수는 다음과 같이 수정해야한다.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  // ... same
}

x와 y가 'a 의 lifetime 만큼은 존재해야한다는 것을 의미하게 되었다.

Lifetime의 궁극적인 목적은, 다양한 parameter들과 함수의 return 값의 연결로 인해, memory-safe 한 연산을 보장하는 것이다.

  • Struct
struct ImportantExcerpt<'a> {
  part: &'a str,
}

구조체에 reference 가 담겨있다면, 해당 구조체와 reference되는 변수는 'a 만큼은 살아있어야함을 표시한다.

 

Lifetime Elision Rule

(1) 각각의 parameter는 고유한 lifetime 을 갖는다.

(2) parameter가 1개면, 해당 lifetime이 return value에도 적용된다.

(3) parameter 중에 &self&mut self 일 경우, self의 lifetime이 return value에 적용된다.

위의 세개의 규칙으로 lifetime이 지정되지 않는 값이 있을 경우에, compiler가 error가 있음을 이야기해준다.

 

  • method

lifetime이 struct 타입을 구성하는 요소이기 때문에, method 정의시에 lifetime을 반드시 작성해주어야한다.

impl<'a> ImportantExcerpt<'a> {
  fn level(&self) -> i32 {
    3
  }
}
  • static

'static 의 의미는 reference가 프로그램이 실행되는 전체 시간 동안 살아있을 수 있음을 의미한다.

모든 string literal들은 'static lifetime을 갖는다.

 

Test 작성하기

  • #[test] 를 표시하여 test 함수임을 나타낸다.
#[cfg(test)]
mod tests {
  #[test]
  fn exploration() {
    assert_eq!(2+2, 4);
  }
  
  #[test]
  fn another() {
    panic!("Make this test fail")
  }
}
cargo test
  • #[cfg(test)] : cargo test 일 때만 실행하라는 의미

    • cargo build 같은 경우에는 실행하지 않으므로, compile time 단축

      cfg : configuration

  • assert! : true이면 PASS, false 이면 FAIL

    • assert_eq! : LEFT == RIGHT 이면 PASS
    • assert_ne! : LEFT != RIGHT 이면 PASS
pub fn greeting(name: &str) -> String {
  String::from("Hello!")
}

#[cfg(test)]
mod tests {
  use super::*;
  
  #[test]
  fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
      result.contains("Carol"),
      "Greeting did not contain name, value was `{}`", // 에러메시지를 확인하기 좋음.
      result
    );
  }
}
  • #[should_panic] : panic이 일어나야 PASS
pub struct Guess {
  value: i32,
}
impl Guess {
  pub fn new(value: i32) -> Guess {
    if value < 1 {
      panic!("Guess value must be greater than or equal to 1, got {}.", value);
    } else if value > 100 {
      panic!("Guess value must be less than or equal to 100, got {}.", value);
    }
    
    Guess { value }
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  #[test]
  #[should_panic(expected = "Guess value must be less than or equal to 100")]
  fn greater_than_100() {
    Guess::new(200);
  }
}

expected에 들어갈 것은 error message의 substring이면 된다.

message 내용이 완전히 달라도 오류가 난다.

  • Result<T, E> : panic하는 대신 Err 를 return 한다.
#[cfg(test)]
mod tests {
  #[test]
  fn it_works() -> Result<(), String> {
    if 2+2 == 4 {
      Ok(())
    } else {
      Err(String::from("two plus two does not equal four"))
    }
  }
}

 

Test Control

  • cargo test 는 모든 테스트를 실행한다.
  • 테스트는 parallel 하게 진행되므로, 파일을 생성하고 지우는 경우가 겹치다보면 의도치 않은 오류가 발생할 수 있다.
    • thread를 1개를 제한하려면, cargo test -- --test-threads=1 와 같이 사용해야한다.
  • Test 코드에서 println!을 사용하더라도, PASS 했을 때 그 결과를 볼 수 없다.
    • 보고 싶다면, cargo test -- --show-output
  • 모든 테스트를 실행하는 것은 많은 시간이 걸릴 수도 있으므로, 어떤 특정 분야의 함수만 실행하고 싶을 수 있다. 이 때는 함수의 이름 일부분을 전달하면서 테스트할 함수를 제한 할 수 있다.
    • cargo test {{subset_of_function_name}}
  • 테스트 함수 중 굉장히 오랜 시간이 걸리는 함수가 있어서, 왠만하면 안 돌리는 테스트 코드를 만들 수 있다.
    • #[ignore]cargo test 가 무시하고 지나갈 수 있다.
    • cargo test -- --ignoredignore 한 테스트만 실행시킬 수 있다.

 

Test 조직화

  • unit test : 좀 더 작은 분야, private interface 테스트
  • integration test : 좀 더 큰 분야, public interface만 사용

1. unit test

src 폴더 안에 각 기능을 담고있는 파일 내부에 존재할 것이다.

각 파일마다 tests module 을 만들고(cfg(test) 명시), test 함수가 담겨있으면 된다.

private 함수가 직접적으로 테스트 되는 것에 대해 여러가지 의견이 있지만, Rust에서는 private 함수를 테스트 할 수 있다.

pub fn add_two(a: i32) -> i32 {
  internal_adder(a,2)
}
fn internal_adder(a: i32, b: i32) -> i32 {
  a + b
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn internal() {
    assert_eq!(4, internal_adder(2,2)); // *
  }
}

private한 internal_adder 함수를 테스트할 수 있음을 볼 수 있다.

 

2. integration test

src 폴더와 같은 위치에 tests 폴더를 생성한다. cargo가 이 폴더에 integration test 파일이 존재하는 것을 안다.

cargo는 tests 폴더 안에 있는 파일들을 각각의 crate로 취급하여 컴파일 한다.

// Filename : tests/lorem.rs
use adder; // (1)

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

(1) tests 폴더 내부에 있는 파일은 개별 crate 이므로 use adder 를 통해 사용할 수 있는 scope로 가져온다.

(2) #[cfg(test)] 가 필요하지 않다.(tests 폴더 자체가 cargo test 할 때 참조되기 때문)

(3) public 한 함수에 대하여 test 한다.

cargo test --test lorem 와 같이 실행하면 lorem.rs 파일에 대한 Integration test만 진행할 수 있다.

 

  • setup이 필요할 때

tests 내부에 있는 *.rs 파일들은 각각의 crate로 취급되므로, test에 포함시키고 싶지 않다면, 폴더를 만들고, 그 안에 파일을 만들면 된다.

// Filename : tests/common/mod.rs
pub fn setup() {
  // setup code
}
// Filename: tests/lorem.rs
use adder;
mod common;

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

 

참고