본문 바로가기

3.구현/기타언어

Rust 배우기3 - 활용

들어가기

이번은 Rust의 표준 라이브러리를 다루는 글이지만, 표준 라이브러리 전체를 다루기 보다는 그중에 핵심 일부만 다룬다. 어쩌면 Rust 문법을 활용한다고 생각해서 제목을 “활용“으로 지었다. 그리고 잡다한 것도 추가했다. 이번 글은 “활용”이지만 심화보다 더 어려울 수도 있고 어쩌면 쉬울 수도 있다. 시작해보자.

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

컬렉션

Rust에서 제공되는 컬렉션 중에 벡터(vector), 스트링(string), 해쉬맵(hash map)에 대해서 살펴보자.

벡터

Vec 벡터 타입은 같은 타입 값을 리스트 형태로 저장하는 컬렉션이다. 벡터 인젝스는 0에서 시작한다. 사용하는 방법을 간단하게 보자.

// 신규 생성
let v1: Vec<i32> = Vec::new();
// 매크로 사용한 생성
let v2 = vec![1,2,3];

v1.push(1);
v2.push(2);

// 참조자로 읽기
let val1: &i32 = &v[2];
// Option으로 읽기
let val2: Option<&i32> = v.get(1);

// panic 발생
let err1 = &v[100];
// None 리턴
let err2 = v.get(100);

for i in &v2 {
  println!("{}", i);
}

// 각 항목 수정하는 경우
for i in &mut v2 {
  *i += 10; // 역참조 연산자(*)사용해 값을 얻음
}

벡터 사용시 주의 점이 있다. 벡터에서 불변 참조자를 획득한 후에 새로운 값을 추가할 경우 에러가 발생한다.

let mut v = vec![1, 2, 3];
let first = v[0];
v.push(4); // 에러

이는 새로운 값을 추가하면 벡터 크기가 변경되고 새로운 메모리 할당으로 참조가 변겨될 수 있기 때문에 에러가 발생했다.

벡터에는 한가지 타입만 저장할 수 있지만 열거형을 사용해 다양한 값을 저장할 수 있다.

enum Value {
  Int(i32),
  Float(f64),
  Text(String),
}

let v vec![Value:Int(10), Value:Float(3.14), Value::String("hi")];

이 방법은 벡터에 들어갈 모든 타입을 알고 있을 경우 가능하다. 알지 못한다면 트레잇 객체를 활용하는 방법도 있다.

String

String은 표준라이브러리에서 제공되는 컬렉션이다. 단순 스트링이라면 &str이라는 스트링 슬라이스 타입이 있다. 스트링에 저장되는 데이터는 UTF-8 인코딩된 스트링 데이터이다. 추가로 표준 라이브러리에는 OsString, OsStr, CString, CStr도 제공된다. 이름을 잘 보면 긴형태와 짧은 형태가 있는데 이는 String과 &str 관계와 같은 형태이다. 스트링에 대한 간단한 예를 보자.

// 빈 문자열 생성
let s1 = String::new();
let s2 = "hello ";
// 문자열에서 String 생성
let s3 = s2.to_string();
let s4 = String::from("world");

//문자열 추가
s1.push_str("foo");
// 문자 추가
s1.push('!');

// s3는 이동으로 유효하지 않음
let s5 = s3 + &s4;
// 포멧팅
let s6 = format!("{} {}", s2, s1);

// 에러: 인덱싱 미지원
// let c1 = s5[0]; 
// 첫 2 바이트를 담는 &str
let s7 = &s2[0..2]; 

for c in s6.chars() {
  println!("{}", c);
}

for b in s6.bytes() {
  println!("{}", b);
}

“+” 연산자나 format! 매크로를 이용해서 문자열 합치기하고 있다. “+” 연산자는 Add 트레잇의 add()호출한다. “+” 연산자 이해를 위해서 add() 선언을 살펴보자.

fn add(self, s: &str) -> String { ... }

인자가 self와 스트링 슬라이가 입력된다. self으로 인해 소유권이 이동하게 된다. 이는 복사보다 이동이 더 효율적이기 때문이다. 그렇기 때문에 s3 스트링은 이동했기 때문에 더 이상 사용할 수 없게 된다.

문자열은 인덱싱을 지원하지 않는다. 물론 String이 Vec를 깜싸고 있지만 UTF-8로 인코딩되어 있기 때문에 특정 위치 문자를 얻기 위해서 앞의 모든 문자를 모두 계산해야한다. 그렇기 때문에 상수 시간 O(1)을 보장할 수 없기 때문에 인덱싱을 지원하지 않는다. 그렇지만 범위를 추출할 수는 있다.

해시맵(Hash map)

해쉬맵 HashMap<K, V>는 K 타입의 키에 V 타입의 값을 매핑한다. 간단한 예를 보자.

use std::collections::HashMap;

let h1 = HashMap::new();

h1.insert(String::from("one"), 1);
h2.insert(String::from("two"), 2);

let keys = vec![String::from("one"), String::from("two")];
let values = vec![1, 2];

let h2: HashMap<_,_> = keys.iter().zip(values.iter()).collect();

let key1 = String::from("one");
let val1 = h1.get(&key1);

for (key, value) in &h1 {
  println!("{}: {}", key, value);
}

h1.insert(key1, 11); // overwrite

h1.entry(String::from("three")).or_insert(3); // 없을 경우에 추가

해쉬맵은 키와 값의 소유권이 해쉬맵으로 이동된다. 만약 참조자로 타입을 지정한다면 해쉬맵을 유효할 동안 키와 값도 계속 유효해야 한다. 해쉬맵을 사용해 예전 값을 재사용하는 예이다.

let text = "...";
let mut map = HashMap::new();

for w in text.split_whitespace() {
  let cnt = map.entry(w).or_insert(0);
  *cnt += 1;
}

println!("{:?}", map);

Iterator 트레잇

반복자 패턴은 데이터를 순회를 위한 패턴이다. 반복자를 사용해서 for 문을 활용할 수 있도록 해준다.

let v = vec![1, 2, 3];
for i in v.iter() {
  println!("{}", i);
}

이런 반복자를 Iterator 트레잇으로 구현할 수 있게 해준다. 위의 예에서 벡터도 Iterator 트레잇을 구현했기에 for 문에서 사용할 수 있다. 표준 라이브러리에서 Iterator 트레잇은 아래와 비슷한 형태이다.

trait Iterator {
  type Item;
  fn next(&mut self) -> Option<Self::Item>;
  ...
}

연관 타입인 Item이 있고 next()에 의해 Option 타입에 Item이 포함되서 리턴된다. Option 중에 Some은 값이 있다는 의미이고 None이 리턴되는 값은 값이 없다는 의미로 for 문인 경우는 종료된다. 한번 Iterator 트레잇을 구현하는 예를 보자.

struct Range(i32, i32);

impl Iterator for Range {
    type Item = i32;
    fn next(&mut self) -> Option<Self::Item> {
        if self.0 < self.1 {
          let ret = self.0;
          self.0 += 1;
          Some(ret)
        } else {
            None
        }
    }
}

fn main() {
    let r = Range(1,10);
    for it in r {
        println!("{}", it);
    }
    println!("({}, {})", r.0, r.1);
}

특정 범위의 숫자를 순회하는 반복자이다. Iterator 트레잇에는 next() 뿐만 아니라 다양한 메소드가 있다.

let sum:i32 = Range(2,8).zip(Range(1,5).skip(1))
                    .map(|(a,b)| a * b)
                    .filter(|x| 0 == x % 2)
                    .sum();

(2,3,4,5,6,7)과 (2,3,4)에 대해 zip()으로 (2,2), (3,3), (4,4), (5,None), (6,None), (7,None)으로 None이 포함된 경우는 순회 대상이 아니므로 생략된다. 다음으로 map()에 의해서 두 값의 곱으로 4, 9, 16이 되고, filter()에 의해 짝수만 추출되고, 이 값의 합인 20이 된다.

추가로 루프와 반복자 간의 성능비교도 있다. 결과로는 반복자가 루프없이 풀어(unrolls) 놓아서 조금 빠르다.

스마트 포인터

스마트 포인터는 포인터처럼 작동하지만 추가적인 데이터 데이터와 이를 통해 다양한 기능을 가지고 있다. String과 Vec도 대표적인 스마트 포인터이다.

스마트 포인터는 구조체로 구현되어 있고, 이런 구조체는 Deref와 Drop 트레잇이 구현되어 있어야 한다. Deref 트레잇은 스마트 포인터가 참조자처럼 동작하게 해준다. Drop 트레잇은 트레잇이 스코프를 벗어날 경우 자원 해제시 실행된다.

Deref 트레잇

Deref 트레잇은 역참조 연산자(dereference operator)인 “*”의 동작을 정의할 수 있다. 이를 통해 스마트 포인터가 참조자 처럼 동작할 수 있다. 역참조에 대한 간단한 예를 보자.

let x = 10;
let y = &x;
assert_eq!(10, x);
assert_eq!(10, *y);

y는 x의 참조자이고 참조자를 따라 값을 사용하기 위해서 역참조인 “*y”을 사용해야 한다. 이를 통해 x의 값을 조회할 수 있다. 만약 역참조를 사용하지 않을 경우 정수값과 정수값 참조가 비교되면서 값을 비교할 수 없게 된다.

한번 간단한 MyBox라는 스마트 포인터를 만들어보자.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(v:T) -> MyBox<T> {
        MyBox(v)
    }
}

// 역참조
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

fn main() {
    let x = 5;
    let b = MyBox::new(x);

    assert_eq!(x, *b);

    println!("done.");
}

잘 동작하는지 확인하기 위해서 assert_eq!로 값이 일치하는지 확인한다. “b”가 역참조로 컴파일러에 의해서 “(b.deref())”으로 변환되고 b.deref() 호출하고 ”*“에 의해 역참조 된다.

역참조 강제(deref coercion)은 타입 참조와 관련된 편의 기능이다. 이는 Deref를 구현한 타입의 참조자를 바꿀려는 타입의 타입 참조자로 바꿔준다. 만약 역참조을 통해 바꿀려 타임으로 바꿀 수 없다면 에러가 발생하게 된다. 앞의 MyBox을 사용해 String을 저장하고 스트링 슬라이트 인자로 변화하는 예를 보자.

fn hello(name: &str) {
  println!("Hello, {}!", name);
}

fn main() {
  let m = MyBox::new(String::from("Foo"));
  hello(&m);
}

“&m”을 보면 “&(m.deref())”로 &MyBox 타입이 &String 타임으로 변경되었다. 다시 String의 deref()로 호출하면서 &str 타입으로 변환된다. 만약 강제 참조가 없다면 “hello(&(*m)[..])”으로 호출해야 한다.

앞의 역참조인 Deref는 불변 참조자에 대한 부분이고 가변 참조자 인 경우 DerefMut 트레잇을 사용해야 한다. T가 어떤 타입 U에 대해 Deref 또는 DerefMut를 구현했다면 다음과 같은 트레잇 바인딩일 경우 역참조 강제를 수행한다.

  • T: Deref<Target=U> 일때 &T → &U
  • T: DerefMut<Target=U> 일때 &mut T → &mut U
  • T: Deref<Target=U> 일때 &mut T → &U

첫번째와 두번째는 불변 참조자와 가변 참조자에 대한 역참조이다. 세번째는 가변 참조자를 불변 참조자로 강제할 수 있지만, 역은 안된다.

Drop 트레잇

스마트 포인터에서 중요한 트레잇으로 Drop이 있다. Drop 트레잇은 지원이 해제될 때에 호출되는 트레잇이다. 간단한 예를 보자.

struct MyBox<T>(T); // 튜플 형태로 정의

impl<T> Drop for MyBox<T> {
  fn drop(&mut self) {
    println!("Dropping MyBox");
  }
}

fn main() {
  let b = MyBox(10);
  println!("b is {}", b.0);
}

실행결과 main() 스코프가 벗어나면서 b 객체가 해제될 때에 Drop 트레잇의 drop()이 호출되는 모습을 확인할 수 있다. 만약 중간에 drop() 메소드를 호출하로 해제할 수 있어 보이지만 이중 해제가 될 수 있기에 에러가 발생하다. 안전한 해제를 위해서는 std::mem::drop()을 사용해야 한다.

fn main() {
  let b = MyBox(10);
  drop(b);
  println!("fin");
}

Box 스마트 포인터

Box은 데이터를 힙에 저장한다. 간단한 사용예를 보자.

{
  let b = Box::new(10);
  println!("b is {}", b);
}

Box는 스코프를 벗어나면 자동 해제되고 Drop 트레잇을 호출하게 된다. Box는 재귀적 타입에 활용할 수 있다. 재귀적 타입(recursive type)은 어떤 값의 일부로써 동일한 타입의 다른 값을 가질 수 있는 타입이다. 이를 확인하기 위해 간단한 연결 리스트(linked list) 자료 구조 구현해서 확인해보자.

#[derive(Debug)]
struct List {
    value: i32,
    link: Option<List>,
}

fn main() {
    let l = List { value: 1, link: None };

    println!("{:?}", l);
}

Option은 값이 있거나 없을 경우를 위해서 사용했다. 컴파일 하면 에러가 발생하며서 “recursive type ‘List’ has infinite size”라는 에러 메시지가 출력된다. 이는 컴파일러가 이 타입이 “무한한 크기를 갖는다” 그래서 메모리를 할당할 수 없어서 에러가 발생한 상황이다. 컴파일 과정에서 할당 메모리 크기를 계산하는데 위의 List 구조체는 재귀로 인해서 크기를 계산할 수 없다.

이를 회피하기 위해 Box를 사용해 힙에서 값을 저장하게 하고 러스트는 Box 자체 크기만 알면되기에 컴파일 에러가 발생하지 않는다. 아래와 같이 수정할 수 있다.

struct List {
    value: i32,
    link: Box<Option<List>>,
}

컴파일 해서 실행하면 잘 작동한다. Box에 의해서 중간에 재귀 관계를 끊었기에 저장 크기를 계산할 수 있게 된다.

Rc 스마트 포인터

Rc는 참조 카운팅(Reference counting)의 약자로 참조 개수를 사용해 계속 사용 중인지 아닌지를 추적한다. 앞에 List 구조체를 사용해 a, b, c 리스트를 만들어서 a 리스트를 b와 c에 연결해보자.

#[derive(Debug)]
struct List {
    value: i32,
    link: Box<Option<List>>,
}

impl List {
    fn new(value: i32) -> List {
        List { value, link: Box::new(None) }
    }
}

fn main() {
  let a = List::new(1);
  let mut b = List::new(2);
  let mut c = List::new(3);

  let a = Some(a);
  b.link = Box::new(a);
  c.link = Box::new(a);

  println!("b: {:?}", b);
  println!("c: {:?}", c);
}

b와 c 리스트가 a 리스트를 공유하고 있다. b에 연결할 때에 a의 소유권이 이동했기 때문에 c에 연결할 때에는 에러가 발생한다. 만약 링크를 참조자로 만들 경우 라이프타임 파라미터 추가하고 명시해야 한다. 이런 라이프타임을 지정하는 작업은 어렵고 비실용적이다. 여기서 Box 대신에 Rc를 사용하면 쉽게 해결할 수 있다.

use std::rc::Rc;

#[derive(Debug)]
struct List {
    value: i32,
    link: Rc<Option<List>>,
}

impl List {
    fn new(value: i32) -> List {
        List { value, link: Rc::new(None) }
    }
}

fn main() {
  let a = List::new(1);
  let mut b = List::new(2);

  let a = Rc::new(Some(a));
  println!("rc of a is {}", Rc::strong_count(&a));
  b.link = Rc::clone(&a);
  println!("rc of a is {}", Rc::strong_count(&a));
  println!("b: {:?}", b);
    {
    let mut c = List::new(3);
    c.link = Rc::clone(&a);
    println!("rc of a is {}", Rc::strong_count(&a));
    println!("c: {:?}", c);
  }
  println!("rc of a is {}", Rc::strong_count(&a));
}

Rc::clone()은 깊은 복사하지 않고 참조 카운트만 증가한다. 참조 카운트는 Rc::strong_count()로 조회할 수 있다. Rc은 불변 참조자를 읽기 전용으로 공유한다.

RefCell 스마트 포인트

Rust 디자인 패턴에서 내부 가변성(interior mutability)은 데이터에 불변 참조자가 있어도 데이터 변경을 허용하는 패턴이다. 이런 규칙을 적용하기 위해서는 unsafe 코드를 사용한다. 이를 적용한게 RefCell 스마트 포인터이다.

RefCell은 단일 소유권을 가진다. Box은 참조 규칙이 컴파일 타임에 적용되지만 RefCell은 런타임에 불변성을 검증한다. 즉, 적정분석 과정에서 발견이 안될 수 있다. 또한 단일 스레드에서만 사용가능하다. 만약 런타임에 위반이 감지된 경우 panic!이 발생한다.

RefCell 사용예를 살펴보자. MyListener로 value가 변경되면 Observer로 메시지를 전송한다.

trait Observer {
    fn notify(&self, msg: &str);
}

struct MyListener<'a, T: 'a + Observer> {
    observer: &'a T,
    value: i32,
}

impl<'a, T> MyListener<'a, T>
  where T: Observer {
  fn new(observer: &T) -> MyListener<T> {
    MyListener { observer, value: 0 }    
  }
  fn set_value(&mut self, value: i32) {
      if self.value != value {
          self.value = value;
          self.observer.notify("changed value");
      }
  }
}

Observer 트레잇에 notify()은 self에 대한 불편 참조자와 문자열 인자를 갖는다. 이를 활용한 간단한 예를 실행해보자.

struct PrintObserver;

impl Observer for PrintObserver {
  fn notify(&self, msg: &str) {
    println!("observer: {}", msg);
  }
}

fn main() {
  let o = PrintObserver;
  let mut l = MyListener { value: 0, observer: &o };

  l.set_value(0);
  l.set_value(10);
}

우리가 할려는 작업은 LogObserver을 만들어서 수신한 메시지를 저장해 놓는다. 한번 구현해보자.

struct LogObserver {
  msgs: Vec<String>,
}

impl Observer for LogObserver {
    fn notify(&self, msg: &str) {
        self.msgs.push(String::from(msg));
    }
}

fn main() {
  let o = LogObserver { msgs: vec![] };
  let mut l = MyListener::new(&o);

  l.set_value(0);
  l.set_value(10);
  assert_eq!(1, o.msgs.len());

  for it in o.msgs.iter() {
    println!("{}", it);
  }
}

위의 코드에서 이상한 부분이 있다. Observer의 notify()의 self은 불변으로 msgs가 변경 불가능하다. 이미 눈치채겠지만, RefCell을 이용하면 된다.

use std::cell::RefCell;
struct LogObserver {
  msgs: RefCell<Vec<String>>,
}

impl Observer for LogObserver {
    fn notify(&self, msg: &str) {
        self.msgs.borrow_mut().push(String::from(msg));
    }
}

fn main() {
  let o = LogObserver { msgs: RefCell::new(vec![]) };
  let mut l = MyListener::new(&o);

  l.set_value(0);
  l.set_value(10);
  assert_eq!(1, o.msgs.borrow().len());

  for it in o.msgs.borrow().iter() {
    println!("{}", it);
  }
}

RefCell에서 가변 객체를 가져오기 위해서는 borrow_mut()을 호출하고 불변 객체를 가져오기 위해서는 borrow()을 호출하면 된다.

순환참조

순환 참조는 서로가 참조를 하면서 순환하는 참조 관계가 만들어 질 수 있다. 이로 인해 참조 카운트가 0이 되지 않게 되면서 메모리 해제가 안되는 문제가 생긴다. 앞에서 다루었던 Rc, RefCell를 활용해서 이중 연결 리스트(Doubly linked list)을 정의해보자.

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct List {
  value: i32,
  parent: RefCell<Rc<Option<List>>>,
  link: RefCell<Rc<Option<List>>>,
}

fn main() {
  let a = Rc::new(Some(List {
    value: 1,
    parent: RefCell::new(Rc::new(None)),
    link: RefCell::new(Rc::new(None)),
  }));
  let b = Rc::new(Some(List {
    value: 2,
    parent: RefCell::new(Rc::new(None)),
    link: RefCell::new(Rc::clone(&a)),
  }));

  if let Some(ref l) = *a {
    *l.parent.borrow_mut() = Rc::clone(&b);
  }

  println!("a: {:?}", a);

}

리스트 b 뒤에 리스트 a가 연결되고 리스트 a의 parent를 리스트 b가 연결되는 순환 참조를 갖는다. 실행하면 a 출력이 계속 실행되면서 스택 오버플로우가 발생하면서 종료된다.

이런 순환 참조 참조 방지를 위해 약한 참조인 Weak를 사용한다. Rc::clone()에 의해서 강한 참조를 생성하여 서로 강하게 연결되며, 제거될 때에 반드시 강한 참조 개수가 0이 되어야 한다. Weak에 의해 Rc의 값을 가리키는 약한 참조(weak reference)을 만들어 느슨한 연결을 만들 수 있다. Rc가 제거되기 위해서 약한 참조 개수가 0일 필요는 없다. 강한 참조는 소유권 공유하는 방법이지만 약한 참조는 소유권과 관련없다. 그렇기에 약한 참조를 사용할 때에는 참조가 없을 수 있기 때문에 항상 유효한지 확인해서 사용해야 한다. 확인하는 방법은 Weak::upgrade()로 Option<Rc> 또는 None으로 리턴된다. None인 경우는 삭제된 상태이다.

앞의 예를 Weak로 사용하는 방식으로 변경해보자.

use std::rc::Rc;
use std::rc::Weak;
use std::cell::RefCell;

#[derive(Debug)]
struct List {
  value: i32,
  parent: RefCell<Weak<Option<List>>>,
  link: RefCell<Rc<Option<List>>>,
}

fn main() {
  let a = Rc::new(Some(List {
    value: 1,
    parent: RefCell::new(Weak::new()),
    link: RefCell::new(Rc::new(None)),
  }));
  let b = Rc::new(Some(List {
    value: 2,
    parent: RefCell::new(Weak::new()),
    link: RefCell::new(Rc::clone(&a)),
  }));

  if let Some(ref l) = *a {
    *l.parent.borrow_mut() = Rc::downgrade(&b);
  }

  println!("a: {:?}", a);
  if let Some(ref l) = *a {
    println!("parent of a: {:?}", l.parent.borrow().upgrade());
  }

  println!("weak count of a: {}", Rc::weak_count(&a));
  println!("weak count of b: {}", Rc::weak_count(&b));
}

Rc::downgrade()로 Rc의 약한 참조를 얻는다. 그리고 upgrade()로 Weak에 있는 참조자를 획득한다. 약한 참조 개수는 Rc::weak_count()로 조회할 수 있다. 출력 중에 “(Weak)”은 약한 참조자라는 의미로 출력 되고 실제 객체는 출력 하지 않는다.

Fn 트레잇

Fn 트레잇은 런타임에 호출할 수 있는 객체를 위한 트레잇이다. 보통 클로저나 함수를 호출할 수 있는 타입이다. 먼저 간단한 예를 살펴보자.

fn run_f1<T:Fn()>(f: T) {
  f();
}

fn run_f2(f : &dyn Fn()) {
  f();
}

fn main() {
  let f = || println!("Hello world!");
  run_f1(f);
  run_f2(&f);
}

함수나 트레잇을 인자로 넘겨서 Fn 트레잇 객체로 받아서 호출하고 있다. 주의할 점은 제너릭 타입으로 Fn을 선언할 경우 쉽게 사용하지만, 인자 타입에 Fn 트레잇을 선언할 경우 dyn이 없으면 컴파일러 에러가 발생한다. 트레잇 객체들은 타입에 dyn을 사용해야 한다. 그리고 참조자로 넘겨줘야 한다. 그렇지 않으면 컴파일러가 해당 객체의 크기를 할당하려고 하는데 할 수 없기 때문이다.

Fn 트레잇은 세가지 변경 이 있다.

  • FnOnce: 클로저인 경우값에 대해 한번만 소유권을 가진다. 소유권을 소비하기에 한번만 호출 가능하다.
  • Fn: 클로저인 경우 값을 불변으로 참조한다. 여러 번 호출 가능하다.
  • FnMut: 클로저인 경우 값을 가변으로 참조한다. 여러 번 호출 가능하다.

FnOnce 타입인 객체 호출을 할 경우는 한번만 호출되며, 두번 이상 호출할 경우 컴파일 에러가 발생한다.

클로저 생성할 때에 소유권을 이전을 강제하려면 move 키워드를 사용할 수 있다.

let x = 10;
let equal_x = move |v| v == x;
println!("x is {}", x); // 에러

euqal_x 클로저에서 x가 리터럴이라고 해도 소유권을 가져오도록 강제했다. 그렇기 때문에 이후로 x를 사용할 수 없기에 println!에서 x을 사용할 때에 에러가 발생한다.

동시성

동시성 프로그램(concurrent programming)은 서로 독립적으로 실행하는 방법이고, 병렬 프로그래밍(parallel programming)은 사로 다룬 부분이 동시에 실행하는 방법이다. 동시성 문제도 Rust의 소유권과 타입 시스템으로 겁없는 동시성(fearless concurrency)를 만들어준다.

스레드 사용

스레드는 동시성에 있어서 필수이다. 이런 스레드로 인해서 발생할 수 있는 문제는 다양하다.

  • 데이터 접근 경쟁 조건(data race condition)
  • 서로 공유 리소스 접근으로 데드락(dead lock)
  • 트정 상황에 발생하는 수정하기 힘든 버그

프로그래밍 언어 차원에서 제공하는 스레드를 그린(green) 스레드라고 한다. 운영체제 별로 스레드 컨텍스트에서 그린 스레드가 생성된다. 이런 관계를 M:N 으로 표현할 수 있다. m은 그린 스레드 개수이고, N은 시스템 스레드 개수이다. Rust에서는 1:1만 구현만 제공한다. 먼저 간단한 사용예를 보자.

use std::thread;
use std::time::Duration;

fn main() {
  thread::spawn(|| {
    for i in 1..10 {
      println!("spawned thread: {}", i);
      thread::sleep(Duration::from_millis(1));
    }
  });

  for i in 1..5 {
    println!("main thread: {}", i);
    thread::sleep(Duration::from_millis(1));
  }
}

스레드가 생성돼서 10밀리초 동안 출력과 대기를 반복한다. 그리고 메인 스레드는 5밀리초 동안 동작하고 종료된다. 그렇기 때문에 생성된 스레드가 실행이 끝나지 않은 상태에서 메인 스레드 중지로 같이 중지된다. 이를 해결하는 방법은 join()를 호출한다.

use std::thread;
use std::time::Duration;

fn main() {
  let t = thread::spawn(|| {
    for i in 1..10 {
      println!("spawned thread: {}", i);
      thread::sleep(Duration::from_millis(1));
    }
  });

  for i in 1..5 {
    println!("main thread: {}", i);
    thread::sleep(Duration::from_millis(1));
  }

  t.join().unwrap();
}

데이터를 쓰레드 내부 코드에서 바로 가져와 사용할 수 없다. 스레드 내부로 이동 데이터를 이동해야하는데 move 키워드르 사용하면 간단하다. 당연히 소유권이 스레드로 옮겨졌기 때문에 이후로는 사용이 불가능하다.

use std::thread;

fn main() {
  let v = vec![2,4,6,8,10];
  let t = thread::spawn(move || {
    for i in v.iter() {
      println!("spawned thread: {}", i);
    }
  });

  t.join().unwrap();
}

스레드 메시징(Thread messaging)

메시지 패싱(message passing)은 스레드나 액터 간에 메시지를 주고 받는 방법이다. Rust에서는 채널(channel)을 사용해 메시지 패싱을 할 수 있다. 채널은 송신자(transmitter)와 수신자(receiver) 쌍으로 나눠지면, 하나라도 해제되면 채널도 닫힌다. 사용 예를 보자.

use std::thread;
use std::sync::mpsc;

fn main() {
  // 채널 생성
  let (tx, rx) = mpsc::channel();

  thread::spawn(move || {
    let val = String::from("Hi!");
    tx.send(val).unwrap();
  });

  let received = rx.recv().unwrap();
  println!("recv - {}", received);
}

channel()로 채널을 생성하고 송신자 tx, 수신자 rx를 튜플로 리턴한다. 송신자는 send()에 의해서 메시지를 전송하고, 수신자는 recv()로 사용해서 수신한다. recv()은 수신 받을 때까지 블록이되며 Result<T,E>으로 리턴한다. try_recv()로도 수신 받을 수 있으며 블록되지 않고 즉시, Result<T,E>를 리턴한다. 채널을 사용한 간단한 예를 보자.

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
  let (tx, rx) = mpsc::channel();

  thread::spawn(move || {
    for i in 1..5 {
      let msg = format!("{}th message");
      tx.send(msg).unwrap();
      thread::sleep(Duration::from_secs(1));
    }
  });

  for received in rx {
    println!("recv - {}", received);
  }
}

rx가 반복자로 되어 있어서 for 문에 적용할 수 있다.

mpsc는 multiple producer, single consumer로 복수 생산자 단일 소비자의 약어이다. 의미 그래도 복수 생산자를 만들어 보자.

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
  let (tx, rx) = mpsc::channel();
  let tx1 = mpsc::Sender::clone(&tx);

  thread::spawn(move || {
    for i in 1..5 {
      let msg = format!("a: {}th message", i);
      tx.send(msg).unwrap();
      thread::sleep(Duration::from_secs(1));
    }
  });

  thread::spawn(move || {
    for i in 1..5 {
      let msg = format!("b: {}th message", i);
      tx1.send(msg).unwrap();
      thread::sleep(Duration::from_secs(1));
    }
  });

  for received in rx {
    println!("recv - {}", received);
  }
}

Sender::clone()을 사용해서 생산자를 복제해서 사용할 수 있다. 그리고 나머지는 이전과 동일하다.

뮤텍스(Mutex)

뮤텍스는 상호 배체(mutual exclusion)의 줄임말이다. 뮤텍스는 특정 시점에 오직 한 스레드만 접근을 허용한다. 뮤텍스 사용법은 데이터 접근 전에 반드시 락(lock)을 사용하고 사용이 완료되면 언락(unlock)을 한다. 간단한 뮤텍스 사용예를 보자.

use std::sync::Mutex;

fn main() {
  let m = Mutex::new(5);
  {
    let mut num = m.lock().unwrap();
    *num = 6;
  }
  println!("m is {:?}", m);
}

Mutex::new()에 의해서 정수형 데이터를 가진 뮤텍스를 생성한다. lock()을 통해 정수형 데이터 참조를 얻는다. lock()이 실패할 경우 unwrap()에 의해서 panic!이 발생한다. lock()을 받는 객체가 스코프를 벗어나면 해제되면서 자동으로 언락한다.

이번에는 다른 스레드와 뮤텍스를 공유해서 사용해보자. Rc을 사용해서 뮤텍스를 공유할 수 있지 않을까라고 생각할 수 있다. Rc은 스레드에서 사용할 수 없다. 그래서 사용할 수 있는 스마트 포인터가 Arc로 아토믹 참조 카운더(atomic reference counting)이다. Arc을 사용한 여러 스레드에서 뮤텍스 공유하는 예를 보자.

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
  let counter = Arc::new(Mutex::new(0));
  let mut handles = vec![];

  for _ in 0..10 {
    let counter = Arc::clone(&counter);
    let handle = thread::spawn(move || {
      let mut num = counter.lock().unwrap();
      *num += 1;
    });
    handles.push(handle);
  }

  for handle in handles {
    handle.join().unwrap();
  }

  println!("Result is {}", *counter.lock().unwrap());
}

스레드를 10개 생성해서 각각 counter를 1씩 증가시키는 예제이다.

안전하지 않은 러스트(Unsafe Rust)

Rust에서 unsafe 키워드를 사용해 안전하지 않은 블럭을 만들 수 있다. 이를 안전하지 않은 슈퍼파워라고 하며 다음과 같은 작업을 할 수 있다.

  • 로우 포인터(raw pointer) 역참조
  • 안전하지 않은 함수 호출
  • 가변 정적 변수(mutable static variable) 사용
  • 안전하지 않은 트레잇 구현

unsafe 블럭이라고 해서 컴파일러가 검사하지 않고 건너뛰는게 아니라 검사는 하지만 위의 네가지 경우를 허용할 뿐이다. 그래서 위의 코드를 안전하지 않은 코드로 격리한다. 그렇기 때문에 문제가 발생한다면 unsafe 블럭에서 발견될 가능성이 매우 높다.

안전하지 않은 슈퍼파워를 하나씩 살펴보자.

로우 포인터 역참조

안전하지 않은 러스트는 로우 포인터라는 참조자와 유사한 새로운 두가지 타입이 있다. 로우 포인터에는 가변과 불편이 있다. 표현은 각각 “const T“와 “mut T”이다. 앞에 에스터리스크(”*“)은 역참조가 아니라 표현의 일부이다. 로우포인터 성질은 다음과 같다.

  • 참조 규칙이 무시되고 불변 및 가변 포인터 양쪽 모두 갖거나 같은 위치에 여러 가변 포인터를 갖을 수 있다

  • 유효한 메모리 포인터인지 보장하지 않는다

  • 널이 될 수 있다

  • 자동 메모리 정리가 구현되어 있지 않다

    로우 포인터에 대한 간단한 예를 보자.

let mut val = 5;
let v1 = &num as *const i32;
let v2 = &mut num as *mut i32;
let addr = 0x012345usize;
let r = addr as *const i32;
unsafe {
  println!("v1 = {}", *v1);
  println!("v2 = {}", *v2);
}

로우 포인터 생성은 unsafe 블럭이 아니어도 가능하지만 역참조는 unsafe 블록에서만 가능하다. 로우 포인터를 as를 사용해 다른 타입으로 캐스팅할 수 있다. 만약 유효하지 않은 포인터라면 세그먼테이션 폴트(segmentation fault)가 발생하게 된다. 로우 포인터를 활용해서 다른 언어나 하드웨어와 상호작용할 수 있게 되었다.

안전하지 않은 함수 호출

여기서 함수는 일반 함수와 메소드를 모두 포함한다. 안전하지 않은 함수는 앞에 unsafe 키워드를 붙이고, 호출은 반드시 unsafe 블럭에서 해야 한다.

unsafe fn danger() {}
unsafe {
  danger();
}

다음 split_at()은 지정된 위치에서 두개 배열로 나누는 함수이다.

fn split_at(arr: &mut [i32], mid: usize) ->  (&mut [i32], &mut [i32]) {
  let len = arr.len();
  assert(mid <= len);

  (&mut arr[..mid], &mut arr[mid..])
}

특별히 문제가 없어보이지만 컴파일 하면 에러가 뜬다. 이는 마지막에 리턴하는 코드가 가변 참조자를 두번 생성하고 있다. 이는 참조 규칙에 어긋난다. 해당 코드는 unsafe 블럭 처리해야 한다.

use std::slice;

fn split_at(arr: &mut [i32], mid: usize) ->  (&mut [i32], &mut [i32]) {
  let len = arr.len();
  let ptr = arr.as_mut_ptr(); // 로우 포인터
  assert(mid <= len);
  unsafe {
      (
      slice::from_raw_parts_mut(ptr, mid),
      slice::from_raw_parts_mut(ptr.offset(mid as isize), len - mid),
    )
    }
}

from_raw_parts_mut()은 로우 포인터와 길이를 받아서 슬라이스를 생성하는 함수이다. 로우 포인터를 사용하기 때문에 포인터 유효성은 보장하지 않는다.

함수 호출에서 다른 언어의 함수를 호출해야할 경우도 있다. 이를 위해 Rust에서는 extern 키워드를 사용하는 FFI(Foreign Function Interface)을 제공하고 있다. C언어에서 abs()을 호출하는 예를 보자.

extern "C" {
 fn abs(input: i32) -> i32;
}

fn main() {
  unsafe {
    println!("absolute value is {}", abs(-1));
  }
}

extern “C” 블록에 호출할 함수 시그니처를 추가한다. “C”에 해당하는 부분이 ABI(Application binary interface)를 지정하는 부분이다. 현재는 C 프로그래밍 언어 ABI를 의미한다. extern에 있는 함수를 호출하기 위해서는 unsafe 블록을 사용해야 한다. 거의 대부분 C 언어를 지원하기 때문에 “C”로 하는게 무난하다.

Rust 함수를 외부에서 호출할 경우도 비슷하게 정의하면 된다.

#[no_mangle]
pub extern "C" fn call_from_c() {
  println!("extern function for C");
}

앞에 “#[no_mangle]” 어노테이션은 Rust가 함수 이름을 맹글링 하지 않도록 비활성화한다. 맹글링(mangling)은 컴파일러가 더 많은 정보를 함수 이름을 추가해서 다른 일므으로 변경하는 과정이다. 이를 해제 하지 않으면 외부에서는 이상한 함수 이름으로 노출될 수 있다.

가변 정적 변수 사용

Rust에서 전역(global) 변수를 사용하지 않고 정적(static) 변수를 사용한다. 사용법은 간단한다.

static MSG: &str = "Hello, world!";

fn main() {
  println!("message is {}", MSG);
}

정적 변수는 대문자 언더스코어 표기법을 사용하는게 관례이다. 또한 ‘static 라이프타입을 갖는 참조자만 저장할 수 있다. 다음은 가변 정적 변수 예이다.

static mut COUNTER: u32 = 0;

fn inc_count() {
  unsafe {
    COUNTER += 1;
  }
}

fn main() {
  inc_count();
  unsafe {
    println!("COUNTER = {}", COUNTER);
  }
}

mut를 사용해 가변 변수를 정의한다. 이를 사용할 때에는 unsafe 블럭을 사용해야 한다. 이는 데이터 레이스로 인해 안전을 보장할 수 없기 때문이다.

안전하지 않은 트레잇 구현

트레잇에도 unsafe을 표기해 안전하지 않은 메소드로 표시할 수 있다.

unsafe trait Foo {
}

unsafe impl Foo for i32 {
  ...
}

트레잇 구현에도 unsafe impl을 사용해서 안전하지 않은 구현 방식임을 명시한다.

매크로

매크로는 함수와 비슷하지만 실제로는 다른 코드를 생성하는 코드이다. 메타 프로그래밍(metaprogramming) 형태라고 할 수 있다. 이로 인해 관리할 코드량을 줄여주고 함수가 할 수 없었던 기능도 가능한다. 대표적인 예로 함수는 인자 개수와 타입을 엄밀하게 정의해야하지만, 매크로는 인자를 가변인자로 처리할 수 있다. 또한 함수는 런타임에 동작하지만 매크로는 컴파일 타임에 동작한다. 물론 생성한 코드는 런타임에 동작한다.

매크로 단점으로는 코드를 생성하기 때문에 추가화된 계층이 생기고 가독성이 떨어진다. 매크로는 네임스페이스가 없기에 외부 크레이트 사용시 “[macro_use]” 어노테이션으로 지정해야 매크로를 사용할 수 있게 된다.

#[macro_use]
extern crate serde;

serde 크레이트에 정의된 매크로를 현재 스코프로 가져온다. 그렇기에 이름 충돌에 주의해야 한다.

간단하게 매크로 작성

macro_rules!를 사용해서 선언적 매크로(declarative macro)를 작성해보자. 선언적 매크로는 코드 패턴을 가지고 새로운 코드를 생성한다. 대표적인 예로 vec! 매크로가 있다. vec! 매크로 정의는 다음과 비슷하다.

#[macro_export]
macro_rules! vec {
  ( $( $x: expr ), * ) => {
    {
      let mut temp_vec = Vec::new();
      $(
        temp_vec.push($x);
      )*
      temp_vec
    }
  }
}

“#[macro_export]” 어노테이션은 현재 크레이스 임포트할 때에 지정된 매크로도 가져갈 수 있게 지정한다. 이 어노테이션이 없다면 해당 매크로는 외부에서 가져다 사용할 수 없다.

매크로 이름은 느낌표(“!”)가 없는 vec로 시작한다. 그리고 뒤에 중괄호가 매크로 정의 본문으로 match 구조와 유사하다. “($(…), *)” 부분 패턴과 일치하면 “=>“ 뒤에 코드 블럭을 실행한다. 위의 예는 패턴이 한개만 있으므로 그외의 패턴은 에러 처리된다.

“($(…), )” 패턴에서 “$x: expr”은 임의 표현식과 매치되고 이름은 $x로 부여한다. 그리고 쉼표(”,“) 뒤에 ”“은 0개 이상 패턴을 의미한다. 예를 들어 vec![1,2,3]은 $x가 1, 2, 그리고 3인 세 표현식이 세 번 매칭된다. 패턴 처리하는 블록에서 ”$(…)*“ 부분은 매칭되는 패턴 개수만큼 반복적으로 생성한다. 결국, vec![1,2,3] 매크로에 의해서 생성된 코드는 다음과 같다.

{
  let mut temp_vec = Vec::new();
  temp_vec.push(1);
  temp_vec.push(2);
  temp_vec.push(3);
  temp_vec
}

프로시저 매크로 만들기

프로시저 매크로는 함수에 가까운 매크로이다. 프로시저 매크로는 derive 어노테이션으로 특정 트레잇을 저징하여 해당 트레잇을 구현하는 매크로이다. 앞에서 종종 사용했던 “#[derive(Debug)]”이다. 즉, Debug 트레잇을 구현하는 매크로이다.

say 연관함수를 가진 Greeting 트레잇을 구현하는 프로시저 매크로를 만들어보자. 즉, 타임에 “#[derive(Greeting)]” 어노테이션을 추가하면 say()가 구현된다. 먼저 Greeting 트레잇을 정의해보자.

trait Greeting {
  fn say();
}

이번에는 Foo 구조체에서 Greeting 트레잇을 구현해보자.

struct Foo;

impl Greeting for Foo {
  fn say() {
    println!("Hello Foo!");
  }
}

fn main() {
  Foo::say();
}

만약 다른 곳에서 Greeting 트레잇을 구현하려면 위의 작업을 반복해야 한다. 프로시저 매크로를 만들어서 이런 작업을 단순하게 만들어 보자. 아래는 매크로를 사용할 경우이다.

#[derive(Greeting)]
struct Foo;

fn main() {
  Foo::say();
}

먼저 Cargo.toml 설정파일을 수정해야 한다. 사용할 크레이트인 proc-macro, sync와 quote를 추가해줘야 한다. proc-macro는 Rust에서 제공되어서 별도로 dependencies에 추가할 필요는 없다. sync 클레이트는 문자열로된 러스트 코드를 실행위한 자료구조로 파싱한다. quote 크레이트는 sync 자료구조를 러스트 코드로 생성한다.

[lib]
proc-macro = true

[dependencies]
syn = "0.11.11"
quote = "0.3.15"

매크로를 정의해보자.

extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;

use proc_macro::TokenStream;

#[proc_macro_derive(Greeting)]
pub fn greeting_derive(input: TokenStream) -> TokenStream {
  let s = input.to_string();
  let ast = sync::parse_derive_input(&s).unwrap();
  let gen = impl_greeting(&ast);

  gen.parse().unwrap()
}

“#[proc_macro_derive(Greeting)]” 어노테이션은 매크로를 사용하는 곳에서 “#[derive(Greeting)]”를 명시할 때에 호출할 함수와 연결한다. 즉, greeting_derive()를 호출한다. 이름은 호출할 트레잇과 동일하게 정의는게 관습이다. greeting_derive() 내부에서 TokenStream에서 문자열을 추출한다. 이는 “#[derive(Greeing)]”이 지정한 코드이다. 앞에 예제에서는 “struct Foo;”에 해당한다. 이 문자열을 parse_derive_input()으로 입력해서 파싱하고 DeriveInput 구조체로 리턴받는다. 해당 구조체 내용은 아래와 비슷하게 된다.

DeriveInpupt {
  //...
  ident: Ident(
    "Foo"
  ),
  body: Struct(
    Unit
  )
}

파싱된 결과에 “Foo”라는 식별자를 갖는 유닛 구조체임을 알 수 있다. 다음으로 핵심이라고 할 수 있는 impl_greeting()을 정의해보자.

fn impl_greeting(ast: &syn::DeriveInput) -> quote::Tokens {
  let name = &ast.ident;
  quote! {
    impl Greeting for #name {
      fn say() {
        println!("Hello {}!", stringify!(#name));
      }
    }
  }
}

ast.ident 식별자로 Ident 구조체 인스탄스를 획득한다. name은 ident(”Foo”)가 된다. quote! 매크로는 블록에 있는 코드를 quote::Tokens로 변환한다. 이때에 “#name”은 name 변수로 대체한다. stringify! 매크로는 표현식을 문자열로 변환한다. 이제 실행하면 ”#[derive(Greeting)]” 어노테이션 부분이 Greeting 트레잇 구현체가 추가된다.

마무리

길고 긴 Rust 배우기가 끝났다. 생각보다 난이도가 있는 언어이다. Lear Rust의 글을 이해하기 쉽게 새로 구성하고, 장황한(내가 보기에는) 예제를 단순화 시키고 실행하고 검증하는 시간이 생각 보다 오래 걸렸다. 어쩌면 이 글이 더 난해할지도…ㅡ.ㅡ;;;;

아무튼 부족한 글이지만 여러분에게 도움이 되었으면 합니다. 모두 즐거운 코딩생활하세요. ospace.

참고

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

[2] cons, https://en.wikipedia.org/wiki/Cons, 2024.01.09

[3] The Little Book of Rust Macros, https://danielkeep.github.io/tlborm/book/index.html

[4] sync, Struct DeriveInput, https://docs.rs/syn/0.11.11/syn/struct.DeriveInput.html

[5] Crate quote, https://docs.rs/quote/latest/quote/

반응형

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

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