러스트의 클로저


원문: http://huonw.github.io/blog/2015/05/finding-closure-in-rust/ 




클로저란?


클로저를 한 문장으로 표현하자면: 클로저란, 자신이 정의된 스코프의 변수를 직접 사용할 수 있는 함수이다. 클로저의 이 특성은 흔히 closing over 또는 capturing variables 이라 불리며, 이렇게 접근되는 변수를 environment 라 한다. 

※ 이하 capturing 을 캡처링, environment 를 변수라 번역.


문법적으로, 러스트의 클로저는 루비에서의 것과 유사하게 파이프('|') 로 정의되는 익명 함수이다. |arguments...| body. 예로, |a, b| a + b 는 두 아규먼트를 받아 둘의 합계를 리턴하는 클로저이다. 이건 평범한 함수 정의와 비슷하면서 추론 기능이 더해진 형태다.


// 함수:
fn foo(a: i32, b:i32) -> i32 {a + b}
// 클로저:
|a, ,b| a + b


일반 함수와 마찬가지로, 클로저도 괄호와 함께 호출된다: closure(arguments...).


아래 코드는 캡처링을 표현하기 위해 Option<i32>map 을 호출한다. mapi32 값에 대해 클로저를 호출하고, 새 Option 을 생성하여 여기에 클로저의 결과값을 담아 리턴한다.


fn main() {
let option = Some(2);

let x = 3;
// 명시적 타입:
let new: Option<i32> = option.map(|val: i32| -> i32 { val + x });
println!("{:?}", new); // Some(5)

let y = 10;
// 추론형:
let new2 = option.map(|val| val * y);
println!("{:?}", new2); // Some(20)
}


클로저는 변수 xy 를 캡처하고 이 변수들을 매핑에 사용할 수 있도록 한다. (이 코드에 하드코딩된 값은 실제로는 런타임에서만 알 수 있는 값이라 생각하자.)




근본으로 돌아가서...


이제 우리는 클로저의 의미를 알았다. 이제 그 근본으로 들어가보자: 러스트에 클로저가 없다면, 제너릭 map 을 어떻게 구현해야 할까?


Option::map 의 기능을 동등하게 구현해보자면:


fn map<X, Y>(option: Option<X>, transformer: ...) -> Option<Y> {
match option {
Some(x) => Some(transformer(x)), // 일단은 클로저 호출 형태이다.
None => None,
}
}


우리는 ...XY 로 변환하는 무언가를 넣어야 한다. Option::map 을 완벽히 대체하기에 가장 큰 제약사항은, 여기에 어떤 형태로든 제너릭이 필요하다는 점이다. 그래야만 우리가 원하는대로 유연하게 작동할 수 있다. 러스트에서 이 제너릭 바운딩은 트레잇을 통해 이루어진다.


fn map<X, Y, T>(option: Option<X>, transform: T) -> Option<Y>
where T: /* 트레잇 */
{


이 트레잇에 특정 데이터를 다른 데이터로 변환하는 메서드가 있어야 한다. 그래서 여기에 map 과 같은 제너릭 바운드에서 특정 데이터의 정확한 타입을 지정할 수 있는 타입 파라미터가 필요하다. 두 선택지가 있다: 트레잇 정의의 제너릭 ("인풋 타입 파라미터"), 연관(associated) 타입 ("아웃풋 타입 파라미터"). 쌍따옴표로 표시된 부분이 우리 선택의 힌트가 된다: 데이터 변환의 인풋 타입은 제너릭 정의에, 그리고 아웃풋 타입은 연관 타입에 있어야 한다.


이렇게 하여 나온 트레잇은 다음과 같다:


trait Transform<Input> {
type Output;

fn transform(/* self?? */, input: Input) -> Self::Output;
}


이제 마지막 남은 질문은 어떤 형태의 self 를 취하느냐이다.


이 데이터 변환은 Input 이 가진 값 이상의 임의의 정보를 통합할 수 있어야 한다. self 아규먼트 없이는 이 메서드는 fn transform(input: Input) -> Self::Output 의 형태를 가지게 되며, 메서드의 기능은 전적으로 Input 과 글로벌 변수에 의존하게 된다. 때문에 우리는 self 가 필요하다.


가장 명쾌한 선택은 레퍼런스에 의한 &self, 뮤터플 레퍼런스에 의한 &mut self, 값에 의한 self 이다. 우리는 map 에 타입 체킹을 두면서도, map 의 사용자에게 가능한대로 강력한 기능을 제공하고 싶다. self 는 구현자(implementers: 이 트레잇을 구현하는 타입)에게 가장 강력한 유연성을 주고, &mut self&self 는 최소한의 유연성을 준다. 반대로, &self 는 이 트레잇의 소비자(comsumers: 이 트레잇을 통해 제너릭 바운딩되는 함수)에게 가장 강력한 유연성을 주고, self 는 최소한의 유연성을 준다.


 

 구현자(implementers: 이 트레잇을 구현하는 타입)

 소비자(comsumers: 이 트레잇을 통해 제너릭 바운딩되는 함수)

self

 이동할 수 있고, 변경할 수 있음

 메서드를 오직 한 번만 호출할 수 있음

&mut self

 이동할 수 없고, 변경할 수 있음

 메서드를 여러 번 호출할 수 있고, 자신만 단독으로 접근할 수 있음

&self

 이동할 수 없고, 변경할 수 없음

 메서드를 여러 번 호출할 수 있고, 접근에 제한이 없음

(여기서 '이동' 과 '변경' 은 self 에 저장되는 데이터를 의미한다.)


이들 중 어느 것을 선택하느냐는 균형의 문제이다. 우리는 보통 선택지에서 가장 강력한 것을 원한다. 소비자에게 원하는 것을 제약 없이 가능하게 하고, 동시에 구현자에게도 최대한 많은 것을 가능하게 하고 싶다.


선택지의 첫번째부터 시작해보자: 우리는 self 를 시도한다: fn transform(self, input: Input) -> Self::Output. 값에 의한 self 는 오너십을 소비한다. 때문에 transform 은 오직 한 번만 호출 가능하다. 다행히도 map 은 데이터 변환을 한 번만 하면 되기 때문에, self 를 사용할 수 있다.


간단히 표현해서, map 과 트레잇은 이제 다음과 같다:


trait Transform<Input> {
type Output;

fn transform(self, input: Input) -> Self::Output;
}

fn map<X, Y, T>(option: Option<X>, transform: T) -> Option<Y>
where T: Transform<X, Output = Y>
{
match option {
Some(x) => Some(transform.transform(x)),
None => none,
}
}


이제 이 트레잇을 구현하는 적절한 구조체를 만든다:


// |val| val + x 을 대체.
struct Adder { x: i32 }

impl Transform<i32> for Adder {
type Output = i32;

// `fn ... self` 은 무시한다. 이것은 |val| val + x 와 유사하다.
fn transform(self, val: i32) -> i32 {
val + self.x
}
}

// |val| val * y 을 대체.
struct Multiplier { y: i32 }

impl Transform<i32> for Multiplier {
type Output = i32;

// |val| val * y 와 유사하다.
fn transform(self, val: i32) -> i32 {
val * self.y
}
}

fn main() {
let option = Some(2);

let x = 3;
let new: Option<i32> = map(option, Adder { x: x });
println!("{:?}", new); // Some(5)

let y = 10;
let new2 = map(option, Multiplier { y: y });
println!("{:?}", new2); // Some(20)
}


우리는 대강 러스트 클로저와 의미적으로 동일한 것을 구현했다. 트레잇과 구조체를 사용해서 스코프의 변수를 다뤘다. 사실 이 구조체에는 클로저의 스코프(environment)와 묘한 유사성이 있다: transform 의 바디에서 사용할 변수를 구조체 자신 안에 저장한다는 것이다.




진짜 클로저는 어떻게 동작하는가?


클로저는 위에서 구현한 것에 유연성과 문법적 편의성(syntactic sugar)을 더한 것이다. Option::map 의 진짜 정의는 다음과 같다:


impl<X> Option<X> {
pub fn map<Y, F: FnOnce(X) -> Y>(self, f: F) -> Option<Y> {
match self {
Some(x) => Some(f(x)),
None => None
}
}
}


FnOnce(X) -> Y 는 우리의 Transform<X, Output = Y> 바운드와, 그리고 f(x)transform.transform(x) 와의 대칭이다.


클로저에는 세 가지 트레잇이 있다. 셋 모두 ...(...) 의 호출 형태를 제공한다. 이 트레잇들의 차이는 호출 메서드의 self 의 타입에 있다. 그리고 이들은 위에서 언급한, self 의 모든 유형을 다룬다.

  • &self : Fn
  • &mut self : FnMut
  • self : FnOnce
이 트레잇들은 러스트의 데이터 핸들링의 세 가지 핵심적 방식을 정확히 커버한다. 각 트레잇은 러스트의 타임 시스템과 완벽하게 어우러진다.

|args...| code... 코드를 작성하면, 컴파일러는 암시적으로 새로운 구조체 타입을 정의하고, 이 구조체 안에 캡처된 변수를 저장하고, 클로저의 바디를 이용하여 위 트레잇 중 하나를 구현하고, 클로저의 스코프를 통해 캡처된 변수를 다시 작성한다. 이 구조체 타입의 이름은 사용자에게 보여지지 않으며, 순수히 컴파일러에게만 내부적으로 존재한다. 런타임에 프로그램이 클로저 정의 코드에 닿으면(실행할 때가 되면), 클로저 정의는 컴파일러가 만든 구조체의 인스턴스를 생성하여 이 인스턴스를 클로저의 자리에 전달한다. (우리가 위에서 mapAdderMultiplier 를 생성해서 전달했던 것처럼)

여기엔 두 가지 의문이 있다:
  1. 변수는 어떻게 캡처되는가? (클로저 구조체 필드의 타입은 무엇?)
  2. 어떤 트레잇이 사용되는가? (self 의 유형은 무엇?)
컴파일러는 위 질문에 몇가지 로컬 규칙을 사용하여 답을 내린다. 이 규칙은 가장 강력한 유연성을 제공하는 옵션을 선택하기 위한 것이다. 로컬 규칙은 오직 클로저의 정의와 클로저가 캡처랑 변수의 타입만을 가지고 체크하도록 구현되어 있다.

여기서 '유연성' 이란, 컴파일러의 시각에서 컴파일 가능하면서도 프로그래머에게 최소한의 책임을 부과하는 옵션을 의미한다.



구조체와 캡처

당신이 C++11 의 클로저에 익숙하다면, [=][&] 캡처를 떠올릴 수 있다: 값에 의한 변수 캡처, 레퍼런스에 의한 변수 캡처. 러스트는 비슷한 기능을 제공한다: 값으로 캡처할 수 있고(클로저 스코프로 변수를 이동시키기), 레퍼런스로 캡처할 수도 있다(클로저 스코프로 변수의 레퍼런스를 저장하기).

기본적으로, 컴파일러는 클로저 바디 코드 안에서 캡처 대상 변수가 어떤식으로 사용되는지 체크하여 이 변수의 캡처 방식을 추론한다:
  • 캡처된 변수가 오직 공유된 레퍼런스를 통해서만 사용되면, 이 변수는 & 레퍼런스로 캡처된다.
  • 캡처된 변수가 뮤터블 레퍼런스로 사용되면 (값 할당 포함), 이 변수는 &mut 레퍼런스로 캡처된다.
  • 캡처된 변수가 이동한다면, 이 변수는 값에 의한 이동으로 캡처된다. (주의: Copy 타입의 캡처는 & 레퍼런스만을 필요로 한다. 때문에 이 규칙은 오직 non-Copy 타입에만 적용된다.)
이 알고리즘은 기술적으로 다소 복잡해 보이지만, 오너십/대여를 정확히 사용하는, 경험 있는 러스트 프로그래머의 멘탈 모델에 정확히 들어맞는다. 사실 클로저가 "non-escaping" 이라면, 즉 클로저가 자신이 정의된 스택 프레임을 절대 떠나지 않는다면, 이 알고리즘은 완벽하다고 생각된다: 코드 컴파일에는 캡처에 대한 어떠한 주석도 필요하지 않다.

요약해서, 컴파일러는 클로저의 변수 캡처에 있어 클로저 안에서 변수를 원하는 만큼, 의도한대로 사용하면서도, 클로저 밖에서도 변수를 계속 사용한다는 관점으로, 최소한의 제약을 두는 방식을 취한다(& 가 가장 선호되고, 다음은 &mut, 그리고 마지막이 값에 의한 캡처). 그리고 이 분석은 각 변수에 변수 단위로 이루어진다(per-variable basis):

struct T { ... }

fn by_value(_: T) {}
fn by_mut(_: &mut T) {}
fn by_ref(_: &T) {}

let x: T = ...;
let mut y: T = ...;
let mut z: T = ...;

let closure = || {
by_ref(&x);
by_ref(&y);
by_ref(&z);

// 'y' 와 'z' 가 적어도 '&mut' 레퍼런스로 캡처되도록 한다.
by_mut(&mut y);
by_mut(&mut z);

// 'z' 가 값에 의해 캡처되도록 한다.
by_value(z);
};

유연성에 초점을 두자: x 는 공유 레퍼런스로 캡처되기 때문에 이 클로저가 존재하는(유효한) 동안에도 사용할 수 있다. 그리고 y 는 뮤터블 레퍼런스로 대여되었기 때문에 이 클로저가 스코프를 벗어나 사라져야만 사용할 수 있다. z 값으로 캡처되기 때문에 이 클로저가 사라진 뒤에도 어디에서도 다시 사용할 수 없다.


컴파일러는 대강 다음과 같은 코드를 생성한다:


struct Environment<'x, 'y> {
x: &'x T,
y: &'y mut T,
z: T
}

/* impl of FnOnce for Environment */

let closure = Environment {
x: &x,
y: &mut y,
z: z
};


이 구조체는 러스트의 타입 시스템의 모든 기능을 사용하여, 실수로 댕글링 레퍼런스를 생성하거나 이미 해제된 메모리를 사용하거나 클로저를 오용하여 메모리의 안전을 침해하는 등의 현상을 방지한다. 문제가 될 수 있는 코드가 있다면 컴파일러가 잡아낼 것이다.




move 와 이스케이프


위에서, 추론 알고리즘이 non-escaping 클로저에 대해서는 완벽하다고 했다. 이는 다른 한편으로, escaping 클로저에 대해서는 완벽하지 않다는 의미이다.


클로저가 escaping 이면, 즉 클로저가 자신이 생성된 스택 프레임을 떠나게 된다면, 이 클로저는 자신이 떠나는 스택 프레임 안의 어떠한 레퍼런스도 가지고 있지 않아야 한다. 레퍼런스를 가지고 있게 된다면 클로저가 스택 프레임을 벗어날 때 이는 댕글링 레퍼런스가 될 것이며, 이건 매우 나쁜 일이다. 다행히도 컴파일러는 이런 현상이 발생할 여지가 있으면 에러를 일으킨다. 그런데, 클로저를 리턴하는 일은 유용하면서도 가능해야 하는 일이다. 예로:


/// `x` 를 더하는 클로저를 리턴한다.
fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
Box::new(|y| x + y)
}

fn main() {
let f = make_adder(3);

println!("{}", f(1)); // 4
println!("{}", f(10)); // 13
}


상당히 괜찮다... 컴파일이 안된다는 것만 빼면:


...:3:14: 3:23 error: closure may outlive the current function, but it borrows `x`, which is owned by the current function [E0373]

...:3     Box::new(|y| x + y)

                   ^~~~~~~~~

...:3:18: 3:19 note: `x` is borrowed here

...:3     Box::new(|y| x + y)

                       ^

 

모든걸 명시적 구조체로 작성하면 문제는 명확해진다: x 가 필요한 부분은 + 연산으로, 레퍼런스에 의한 캡처이면 족하다. 그래서 컴파일러는 이를 다음과 같은 코드로 추론해낸다:


struct Closure<'a> {
x: &'a i32
}

/* impl of Fn for Closure */

fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
Box::new(Closure { x: &x })
}


xmake_adder 의 끝에서 스코프를 벗어나게 되기 때문에, 이 레퍼런스를 가진 요소를 make_adder 밖으로 리턴할 수 없다.


그럼 이 문제를 어떻게 해결할 것인가? 컴파일러가 방법을 제시해주면 좋을 것이다.


사실, 위에서 나는 에러 메시지의 마지막 두 줄을 생략했었다:


...:3:14: 3:23 help: to force the closure to take ownership of `x` (and any other referenced variables), use the `move` keyword, as shown:

...:      Box::new(move |y| x + y)


새로운 키워드의 등장이다. move 는 클로저 정의의 앞에 위치하여 클로저에서 캡처하는 모든 변수를 값에 의해 캡처되도록 한다. 이전 섹션으로 돌아가서, 만약 코드가 let closure = move || { /* 같은 코드 */ } 형태였으면, 클로저 환경 구조체는 이렇게 달라졌을 것이다:


struct Environment {
x: T,
y: T,
z: T
}


모든 변수를 값으로 캡처하는 일은 엄밀히 말해서 레퍼런스에 의한 캡처보다 일반적인 것이다: 러스트에서 레퍼런스 타입은 일급(first-class)으로 취급된다. 때문에, '레퍼런스에 의한 캡처' 는 사실 '레퍼런스를 값으로 캡처' 와 동일한 말이다. 그래서, C++ 과는 달리, 여기에는 레퍼런스에 의한 캡처와 값에 의한 캡처의 근본적인 구별이 있다. 그리고 러스트의 분석은 필수가 아니다: 이 사실은 프로그래머을 편하게 해준다.


다음 코드는 move 를 사용하면서 레퍼런스를 캡처하여 첫 번째 버전과 동일한 동작, 동일한 환경을 가진다:


let x: T = ...;
let mut y: T = ...;
let mut z: T = ...;

let x_ref: &T = &x;
let y_mut: &mut T = &mut y;

let closure = move || {
by_ref(x_ref);
by_ref(&*y_mut);
by_ref(&z);

by_mut(y_mut);
by_mut(&mut z);

by_value(z);
};


캡처된 변수들은 클로저 몸체에서 사용되는 변수와 똑같다. C++ 11 에서와 같은 세분화된 캡처 목록은 존재하지 않는다. [=] 캡처 목록은 move 키워드로 존재하지만, 이게 전부다.


이제 우린 make_adder 에서의 문제를 해결할 수 있다. move 를 사용함으로써 컴파일러의 암시적 레퍼런스 추가를 막고, 클로저가 스택 프레임에 묶이지 않도록 한다. 컴파일러의 권고에 따라 Box::new(move |y| x + y) 로 코드를 수정한다:


struct Closure {
x: i32
}

/* impl of Fn for Closure */

fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
Box::new(Closure { x: x })
}


컴파일러는 move 가 필요한 때를 인지하지 못한다는 건 명백하다. 그런데 help 메시지를 보면 컴파일러는 경우에 따라 move 가 언제 필요한지 충분히 인지할 수 있음을 암시한다. 불행하게도, 컴파일러의 move 추론을 기본 기능에 포함시키는 일은 클로저 바디의 내부를 분석하는 것 이상의 작업을 필요로 한다: 클로저 값이 어디에서/어떻게 사용되느냐를 고려하는 일은 기술적으로 더 복잡한 일일 것이다. 결국 help 메시지가 move 추론에 대한 최선의 노력의 결과이고, 추론 기능이 러스트의 기본 기능에는 들어갈 수 없다.




트레잇


클로저의 실제 '함수' 는 위에서 언급한 트레잇에 의해 다루어진다. 암시적 구조체 타입은 트레잇을 암시적으로 구현하며, 이렇게 구현된 트레잇이 실제 클로저 타입으로 동작한다.


예제를 보자: make_adder 예제에서 Fn 트레잇이 암시적 클로저 구조체에 대해 구현되었다.


impl Fn(i32) -> i32 for Closure {
fn call(&self, y: i32) -> i32 {
// |y| x + y
self.x + y
}
}


실제로는 여기에 Closure 에 대한 FnMut, FnOnce 의 암시적 구현이 존재하지만, Fn 은 이 클로저에 대한 '기본적인' 것이다.


트레잇은 세 가지가 있고, 여기서 7개의 트레잇 세트가 구현될 수 있다. 그런데 여기서는 그들 중 가장 중요한 세 가지를 본다:

  • Fn, FnMut, FnOnce
  • FnMut, FnOnce
  • FnOnce
왜 이런 세트가 나올까? 세 개의 클로저 트레잇은 실제로 세 가지 중첩된 세트이다: Fn 을 구현하는 모든 클로저는 FnMut 를 구현할 수 있다(&self 가 가능하다면 &mut self 역시 가능하다; &*self). 그리고 같은 맥락에서 FnMut 를 구현하는 모든 클로저는 FnOnce 를 구현할 수 있다. 이런 계층 구조는 타입 레벨에서 강제된다. FnMut 의 정의는 다음과 같다:


pub trait FnMut<Args>: FnOnce<Args> {
...
}


한마디로: FnMut 를 구현하는 어떤 것이든 FnOnce 를 반드시 구현한다.


컴파일러가 어떤 트레잇을 구현하느냐를 추론하는 데에 있어 어떤 미묘한 점은 없다. 컴파일러는 구현할 수 있는 한 모든 트레잇을 구현한다. 이런 성질은 '최대한의 유연성 제공' 규칙으로 유지된다. 이 규칙은 캡처 타입의 추론에 적용되는데, 구현하는 트레잇이 많다는 것은 그만큼 많은 옵션이 존재할 수 있음을 의미하기 때문이다. Fn* 트레잇의 집합이 의미하는 것은 이 규칙은 언제나 위에 나열된 세 가지 트레잇 집합 목록 중 하나를 구현하게 한다는 것이다.


예제로, 아래 코드는 Fn 구현이 불가능하지만 FnMutFnOnce 는 가능한 경우를 보여준다:


let mut v = vec![];

// nice form
let closure = || v.push(1);

// explicit form
struct Environment<'v> {
v: &'v mut Vec<i32>
}

// let's try implementing `Fn`
impl<'v> Fn() for Environment<'v> {
fn call(&self) {
self.v.push(1) // error: cannot borrow data mutably
}
}
let closure = Environment { v: &mut v };


& &mut ... 를 변경하는 일은 불가능하다. 그리고 &self 는 그 외부 공유 영역을 참조한다. &mut self 혹은 self 였으면 아무 문제가 없을 것이다: 전자 쪽이 더 유연성이 좋으니, 컴파일러는 FnMut 를 구현한다. (물론 FnOnce 도)


이와 유사하게, closure|| drop(v); 라면, 즉 v 를 밖으로 던진다면, 이 경우는 FnFnMut 이 불가능할 것이다. 왜냐하면 &self&mut self 로 이런 동작을 수행하는 일은 대여된 데이터의 오너십을 훔치는 일이니까.




유연성


러스트의 목표 중 하나는 프로그래머에게 선택권을 남겨두면서도, 그들의 코드를 효율적이게 하고, 추상화를 컴파일하여 빠른 머신 코드를 생성하는 일이다. 클로저의 유니트 구조체 타입와 트레잇/제너릭 디자인이 여기의 핵심이 된다.


각 클로저는 저마다의 타입을 가지기 때문에, 클로저를 사용할 때 의무적인 힙 할당은 필요하지 않다: 위에서 보여졌듯, 클로저의 변수 캡처는 변수를 구조체 안으로 곧장 위치시킨다. 이는 러스트가 C++ 11 과 공유하는 성질이다. 클로저는 기본적으로 bare-mental 환경을 포함한 어떤 환경에서든 사용할 수 있도록 허용된다.


유니크 타입이란 서로 다른 클로저를 함께 사용할 수 없음을 의미한다. 예를 들어, 각각 다른 클로저를 담고 있는 벡터를 만들 수는 없다. 클로저들은 각각 서로 사이즈가 다르며, 호출 방식도 다르다. (각각의 클로저들은 내부 코드가 서로 다르기에, 호출하는 함수도 서로 다르다.) 다행히도 트레잇을 사용하여 클로저 유형을 추상화할 수 있다. 트레잇 객체를 통해 이런 기능과 이점을 '온 디맨드로' 선택할 수 있다: Box<Fn(i32) -> i32> 를 리턴한다.


let mut closures: Vec<Box<Fn()>> = vec![];

let text = "second";

closures.push(Box::new(|| println!("first")));
closures.push(Box::new(|| println!("{}", text)));
closures.push(Box::new(|| println!("third")));

for f in &closures {
f(); // first / second / third
}


유니크한 타입과 제너릭의 또다른 이점은, 기본적으로 컴파일러는 클로저가 호출되는 위치에서 무엇을 하는지 전부 알고 있기 때문에, 인라이닝과 같은 최적화를 수행할 수 있다는 것이다. 예를 들어, 아래 스니펫은 동일한 코드로 컴파일된다:


x.map(|z| z + 3)

match x {
Some(z) => Some(z + 3),
None => None
}


(내가 위의 두 코드를 각각 서로 다른 함수 안에 두고, 각 함수를 하나의 파일에 두어 컴파일 해보았다. 컴파일러는 두 번째 함수에서 첫 번째 함수를 호출하는 방식으로 최적화를 수행했다.)


이건 러스트가 monomorphisation 을 통해 제너릭을 구현하는 방법 때문이다. 제너릭 함수는 주어진 타입 파라미터마다 각각의 호출 형태에 맞는 코드를 생성하는 방식으로 컴파일된다. 불행히도, 이 최적화가 언제나 좋은 것은 아니다. 이 방식은 하나의 함수를 두고 비슷한 함수 여러개를 복제해내면서 거대한 코드를 낳게 된다. 이것을 막는 방법으로는 트레잇 객체가 있다. 트레잇 객체를 이용하면 제너릭 타입에 여러 클로저가 함께 사용되더라도 동적으로 디스패치된 클로저를 사용하여 함수의 사본이 하나만 존재하는지 확인할 수 있다.


fn generic_closure<F: Fn(i32)>(f: F) {
f(0);
f(1);
}

generic_closure(|x| println!("{}", x)); // A
generic_closure(|x| { // B
let y = x + 2;
println!("{}", y);
});


fn closure_object(f: &Fn(i32)) {
f(0);
f(1);
}

closure_object(&|x| println!("{}", x));
closure_object(&|x| {
let y = x + 2;
println!("{}", y);
});


마지막 바이너리는 generic_clocure 의 두 가지 복사본을 가지게 된다. 하나는 A 에 대해서, 다른 하나는 B 에 대해서. 그런데 closure_object 의 복사본은 하나이다. 사실 포인터에 대한 Fn* 트레잇 구현체가 존재하기 때문에 트레잇 객체를 generic_closure 에 바로 사용할 수도 있다. 예로, generic_closure((&|x| {... }) as &Fn(_)) 으로 사용하는 것이 가능하다. 고차원 함수의 사용자는 스스로 원하는 방식을 택하여 사용할 수 있다.


클로저의 강력함은 사용자로 하여금 세부 사항을 직접 작성하는 것과 비교하여 성능 면에서 손실이 없으면서도 높은 수준의 "fluent" 한 API 를 작성하도록 해준다는 것이다. 이것의 좋은 예는 iterators 이다: 사용자는 C 수준의 효율성으로 최적화되는 mapfilter 같은 어댑터 함수를 체이닝 방식으로 호출할 수 있다. 







'Rust' 카테고리의 다른 글

클로저(심화)  (0) 2018.03.07
라이프타임  (0) 2018.02.28
트레잇(Traits)  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26

클로저



클로저의 트레잇에 관하여 매우 혼동되는 부분이 있어, 스택오버플로 검색 결과 좋은 답변 발견. 그대로 옮겨 적어둔다.



주소: https://stackoverflow.com/questions/30177395/when-does-a-closure-implement-fn-fnmut-and-fnonce

원문:


The traits each represent more and more restrictive properties about closures/functions, indicated by the signatures of their call_... method, and particularly the type of self:

  • FnOnce (self) are functions that can be called once,
  • FnMut (&mut self) are functions that can be called if they have &mut access to their environment
  • Fn (&self) are functions that can still be called if they only have & access to their environment.

A closure |...| ... will automatically implement as many of those as it can.

  • All closures implement FnOnce: a closure that can't be called once doesn't deserve the name. Note that if a closure only implements FnOnce, it can be called only once.
  • Closures that don't move out of their captures implement FnMut, allowing them to be called more than once (if there is unaliased access to the function object).
  • Closures that don't need unique/mutable access to their captures implement Fn, allowing them to be called essentially everywhere.

These restrictions follow directly from the type of self and the "desugaring" of closures into structs (described in Finding Closure in Rust).



번역:

각 트레잇은 자신의 call_... 메서드 시그니처와 특정한 self 의 타입에 의해 나타나는, 클로저/함수의 제한적인 성질을 표현한다.

  • FnOnce (self) 는 한 번 호출 가능한 함수이다.

  • FnMut (&mut self) 는 스코프의 아이템에 &mut 으로 접근하는 함수이다. (스코프 변수에 값을 변경하는 동작이 있는 경우를 의미하는 듯)

  • Fn (&self) 는 스코프의 아이템에 오직 & 으로만 접근하는 함수이다. (스코프 변수에 값을 변경하는 동작이 없는 경우를 의미하는 듯)

클로저는 위 트레잇 중 가능한 많은 트레잇을 구현한다 (조건만 만족한다면 하나의 클로저가 다수의 트레잇의 구현체가 될 수 있음)
  • 모든 클로저는 FnOnce 를 구현한다: 한 번 호출할 수 없는 클로저는 이름을 가질 필요가 없다. 클로저가 오직 FnOnce 트레잇만을 구현한 경우, 이 클로저는 오직 한 번만 호출 가능하다.
  • 스코프의 아이템을 이동시키지 않는 클로저는 FnMut 를 구현한며, 한 번 이상 호출 가능하다. (함수 객체에 별칭이 아닌 접근 방법이 있는 경우)
  • 스코프의 아이템에 유니크/뮤터블 접근이 필요하지 않은 클로저는 Fn 을 구현하며, 기본적으로 어디서든 호출 가능하다.
이러한 제한 사항은 self 의 타입과 구조체에 대한 클로저의 'desugaring' 을 직접적으로 따른다.




'Rust' 카테고리의 다른 글

클로저(심화)  (0) 2018.03.07
라이프타임  (0) 2018.02.28
트레잇(Traits)  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26

라이프타임



라이프타임은 어쩌면 러스트에서 가장, 적어도 지금까지 다룬 내용 중 개념적으로 가장 이해하기 어려운 파트일 수 있음. 이해한 것을 정리하는 느낌으로 라이프타임이 무엇이고, 왜 필요한지 적어본다. (단, 라이프타임 파라미터의 문법에 대해 세세하게 다루지는 않는다.)


라이프타임은 프로그래밍 언어에서 자주 등장하는, 변수 혹은 레퍼런스의 '유효 범위(이하 스코프)' 라고 생각하면 된다. 


차근차근 살펴보자.


먼저, 러스트의 모든 변수와 레퍼런스는 각자의 스코프를 가진다. 변수의 스코프는 자신이 선언/생성된 스코프와 그 하위 스코프 까지이고, 그 범위를 벗어난 위치에서의 접근은 컴파일 에러를 일으킨다. (레퍼런스의 경우 자신이 생성된 스코프에서 다른 스코프로 '이동' 할 가능성이 있다.)


fn main() {                                 // 메인 스코프

{ // 또 다른 스코프가 열림
let s1 = "my string".to_string(); // 이 스코프에서 변수와 스트링 생성.
} // 스코프가 끝나면서 변수 s1 은 폐기되고, 참조하던 스트링 메모리도 해제됨.
println!("{}", s1); // 변수 s1 의 유효 스코프 밖에서의 접근 -에러!
}


에러 메시지는 다음과 같다.


error[E0425]: cannot find value `s1` in this scope



그렇다면 아래 코드는 어떨까?


fn main() {
let s1;

{
let s2 = "my string".to_string();
s1 = s2;
}
println!("{}", s1);
}


위 코드의 아웃풋은 "my string" 이다. 스트링이 생성된 스코프가 끝난 뒤 스코프 밖에서 스트링에 접근했는데 컴파일-실행에 아무런 문제가 없다. 이것은 왜냐하면 변수 s2 이 폐기되기 전에 s1 에 레퍼런스를 '이동' 시켰기 때문이다. s2 은 폐기되어도 바깥 스코프의 s1 에 스트링 레퍼런스를 넘겼으니 스트링은 살아있고, 변수 s1 을 통한 접근에 문제가 없다.



이제 아래 코드를 본다.


fn main() {

let s1;

{
let s2 = "my string".to_string();
s1 = &s2;
}
println!("{}", s1);
}


위 코드는 컴파일 에러를 일으킨다. 에러 메시지는 다음과 같다.


error[E0597]: `s2` does not live long enough


이게 무슨 말인가. 여기서부터 라이프타임의 개념이 시작된다.


라이프타임은 레퍼런스의 '이동' 이 아닌, '대여' 에 관한 것이다. 원본 레퍼런스가 존재하고, 이 레퍼런스를 다른 변수에 빌려주었는데, 이 상태에서 원본 레퍼런스가 사라질 '가능성' 이 있는 코드는 러스트에서 컴파일 에러를 일으킨다. 위 코드에서는 변수 s1 의 스코프가 s2 의 스코프보다 넓다. s2 이 스코프가 끝나 사라져도 s1 은 여전히, '더 오래' 존재한다. 그런데 s1 은 s2 의 레퍼런스를 '대여' 한 상황이다. 이 상태에서 원본 레퍼런스를 가진 s2 이 먼저 사라진다면? s1 이 대여한 레퍼런스도 무효화된다. 변수 s1 의 사용자는 여전히 s1 이 스트링을 참조하고 있다고 생각하지만, 이 참조는 쓰레기 참조이다. 전형적인 댕글링 레퍼런스(댕글링 포인터)의 발생이며, use-after-free 케이스이다. 러스트는 컴파일 타임에 댕글링 레퍼런스 현상을 막고자 한다.


라이프타임은 댕글링 레퍼런스를 어떻게 막을 것인가? 에 대한 러스트의 해답이다.


처음에, 모든 변수와 레퍼런스는 자신의 스코프를 가진다고 했다. 이 스코프를 라이프타임이라 생각하자. 모든 변수와 레퍼런스는 생성되면서 자신의 라이프타임을 가진다.


{
let r; // -------+-- 'a
// |
{ // |
let x = 5; // -+-----+-- 'b
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
// |
// -------+
}


위 코드에서, 변수 r 의 라이프타임은 'a 이다. 변수 x 의 라이프타임은 'b 이다. 라이프타임 'a 의 범위가 'b 의 범위보다 넓다. 이 말은 즉, 라이프타임 'a 의 변수는 라이프타임 'b 보다 오래 존재한다. 때문에 라이프타임 'b 의 변수는 라이프타임 'a 의 레퍼런스를 대여해도 괜찮지만, 반대로 라이프타임 'a 의 변수는 라이프타임 'b 의 레퍼런스를 대여할 수 없다. (대여된 레퍼런스가 대여한 레퍼런스보다 먼저 사리지면 대여한 레퍼런스가 가진 것은 무효한 참조(댕글링 레퍼런스)가 되기 때문에.)


간단히 말해서, 대여되는 쪽이 대여 해가는 쪽보다 오래(or 동일하게) 살아 있어야 한다.


{
let x = 5; // -----+-- 'b
// |
let r = &x; // --+--+-- 'a
// | |
println!("r: {}", r); // | |
// --+ |
}


위 코드에서, 변수 x 의 라이프타임은 'b 이다. 변수 r 의 라이프타임은 'a 이다. 'a 보다 'b 이 더 길다. 고로, 변수 x 가 r 보다 오래(사실 여기서는 똑같이) 살고, r 은 x 의 레퍼런스를 대여할 수 있다.


간단하지 않은가? 사실 다른 언어에서도 마찬가지인 이야기이고, 특별할 게 없다. 그럼 넘어가보자.



함수에서의 라이프타임



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


위 코드는 아래와 같은 컴파일 에러를 일으킨다.


error[E0106]: missing lifetime specifier

 --> src/main.rs:6:33

  |

6 | fn longest(x: &str, y: &str) -> &str {

  |                                 ^ expected lifetime parameter

  |

  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`


이 함수의 리턴값은 x 가 될 수도, y 가 될 수도 있다. 둘 중 무엇이 될지는 런타임에 파라미터 값에 따라 결정된다. 즉 컴파일 타임에 미리 알 수는 없다. 당연하다. 그런데 이게 왜 문제가 될까?

아래 코드를 보면 답이 나온다. (코드에서 하드코딩된 스트링 값은 실제 런타임에는 알 수 없는 값이라고 생각하고 봐야 한다.)

fn main() {

let s1 = "value of s1".to_string();
let result;
{
let s2 = "value of s2".to_string();
result = longest(&s1, &s2);
}

println!("{}", result);
}


감이 오는가? 코드의 스코프 구조에 집중해서 보도록 하자.


함수 longest 의 입장에서는 파라미터로 어떤 라이프타임을 가진 레퍼런스가 전달될지 모른다. 그리고 리턴값을 받는 주체도 어떤 라이프타임을 가졌을지 알 수 없다. 위 코드에서 파라미터가 된 s1, s2 의 라이프타임은 명백히 다르다. longest 는 두 값의 길이를 비교하여 큰 쪽을 레퍼런스 그대로 리턴하는데, 그 리턴값은 받는 변수 result 의 라이프타임은 s1 과 동일하다. 앞서 언급한 규칙을 생각해보자. 이 상황에서, 함수 longest(&s1, &s2); 의 실행 결과로 &s1 이 리턴된다면 어떨까? 리턴값(레퍼런스)은 result 가 받는데, result 와 s1 의 라이프타임이 동일하기 때문에 문제가 없다. 그럼 &s2 이 리턴된다면 어떨까? 이걸 result 가 받는다면? 규칙에서 대여되는 쪽이 대여해가는 쪽보다 오래(or 동일하게) 살아야 한다고 했는데, 대여되는 쪽인 &s2 의 라이프타임이 대여해가는 쪽인 result 보다 짧게 된다. 이 코드를 컴파일 허용한다면, 댕글링 레퍼런스가 생길 여지를 허용하는 셈이 되는 것이다.


러스트는 이렇게 댕글링 레퍼런스를 야기할 여지가 있는 코드를 컴파일에서 걸러낸다. 그리고 하나의 함수는 런타임에 여러 랜덤한 값을 파라미터로 받게 될 수 있고, 특히 다양한 스코프 구조에서 실행될 수 있다. 때문에 댕글링 레퍼런스가 생길 수 있는 코드인지 체크하는 작업은 하나의 독립된 함수 단위에서부터 이루어진다. 간단히 말해, 정작 당장 함수를 호출하는 코드가 전혀 없더라도, 그 함수의 생김새 만으로도 이 함수가 댕글링 레퍼런스를 야기할 수 있다고 판단하면 러스트는 컴파일 에러를 던진다.


그럼 함수 longest 는 어떻게 해야 컴파일 할 수 있나?


아래와 같이 라이프타임 파라미터를 정의하면 된다. (처음에 언급했듯, 라이프타임 파라미터의 문법에 대해서는 다루지 않는다.)


fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}


위 라이프타임 파라미터의 의미는, "함수 longest 에 'a 라는 라이프타임이 있고, 레퍼런스 아규먼트 s1, s2, 그리고 리턴 레퍼런스는 같은 라이프타임을 가진다." 라는 것이다. 설명했듯이 컴파일러는 longest 함수의 리턴값이 s1 이 될지 s2 이 될지 알 수 없으므로, 이에 대한 조언을 해주는 셈이다. 컴파일러는 이를 이해하고 longest 를 컴파일한다. 


여기서 절대 착각해서는 안된다. 라이프타임 파라미터는 절대로 라이프타임을 변경하는 것이 아니다. 파라미터의 라이프타임은 호출 코드의 문맥에서 결정되는 것이지, 함수 안에서 변경 가능한 것이 아니다. 라이프타임 파라미터는 컴파일러에게 컴파일을 위한 힌트를 줄 뿐, 프로그램의 런타임 동작에 어떠한 영향도 주지 않는다.


이렇게 라이프타임 파라미터를 정의해도 아래와 같은 코드는 여전히 컴파일 에러를 일으킨다.


fn main() {

let s1 = "value of s1".to_string();
let result;
{
let s2 = "value of s2".to_string();
result = longest(&s1, &s2);
}

println!("{}", result);
}

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() > s2.len() {
s1
} else {
s2
}
}


그런데 컴파일 에러 메시지가 아까와 좀 다르다.


error[E0597]: `s2` does not live long enough

  --> src/main.rs:7:32

   |

7  |         result = longest(&s1, &s2);   

   |                                ^^ borrowed value does not live long enough

8  |     }

   |     - `s2` dropped here while still borrowed

...

11 | }

   | - borrowed value needs to live until here


그렇다, 함수 longest 는 컴파일이 된 것이다! 동시에, 라이프타임 파라미터를 정의했어도 s2 의 라이프타임을 지적하는 에러는 발생한다는 점에 주목해야 한다. 다시 한 번 말하건데, 라이프타임 파라미터를 똑같이 정의한다고 해서 레퍼런스의 실제 라이프타임이 절대로 같아지지 않는다.


그렇다면 라이프타임 파라미터가 어떤 역할을 했길래 함수 longest 가 컴파일 됐을까? 라이프타임 생략 규칙




Elision Rules (생략 규칙)



라이프타임 생략 규칙이라는 것이 있다. 라이프타임을 항상 명시적으로 정의하는 일은 번거로운 일이기에, 몇 가지 아주 명백한 상황에서는 라이프타임 파라미터를 명시하지 않아도 러스트가 알아서 라이프타임을 추론한다. 이 '명백한 상황' 을 판단하는 규칙이 elision rules(이하 생략 규칙) 이며, 이 규칙에 해당되지 않으면 라이프타임 파라미터를 명시적으로 정의해주어야 한다. 그렇지 않으면 컴파일 에러가 발생한다.


규칙은 다음과 같다.


1. 레퍼런스 타입의 파라미터는 모두 각자의 독립된 라이프타임을 가진다. 

-> 예를 들어, 함수 fn foo(x: &i32) 는 fn foo<'a>(x: &'a i32) 가 되고, fn foo(x: &i32, y: &i32) 는 fn foo<'a, 'b>(x: &'a i32, y: &'b i32) 가 된다.


2. 파라미터가 단 하나일 경우, 리턴 레퍼런스는 동일한 라이프타임 파라미터를 가진다. 

-> 예를 들어, fn foo(x: &i32) -> &i32 는 fn foo<'a>(x: &'a i32) -> &'a i32 가 된다.


3. 다수의 파라미터가 있는데 그 중 하나가 &self 혹은 &mut self 인 경우(즉 메서드인 경우), self 의 라이프타임은 리턴 레퍼런스의 라이프타임이 된다.


몇 가지 케이스로 규칙을 이해해보자. 


일단 아래 코드가 있다. 컴파일러가 어떤 방식으로 여기에 규칙을 적용하여 라이프타임을 추론할까?


fn first_word(s: &str) -> &str {


첫 번째 규칙에 따라, 파라미터 s 는 라이프타임을 가진다.


fn first_word<'a>(s: &'a str) -> &str {


그리고 이 코드는 두 번째 규칙도 적용된다. 컴파일러는 함수 first_word 의 라이프타임을 아래와 같이 추론하여 컴파일한다.


fn first_word<'a>(s: &'a str) -> &'a str {



파라미터가 두 개인 경우는 어떨까?


fn longest(x: &str, y: &str) -> &str {


첫 번째 규칙에 따라, 각 파라미터는 각자의 독립된 라이프타임을 가진다.


fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {


파라미터 수가 하나를 초과하기 때문에, 두 번째 규칙은 적용되지 못한다. 그리고 파라미터에 self 가 없기 때문에, 세 번째 규칙도 적용되지 못한다. 규칙을 모두 대입해도 리턴 파라미터의 라이프타임을 알 수 없다. 이제 컴파일 에러가 발생한다. 


알겠는가? 처음에 함수 longest 에 컴파일 에러가 발생한 이유는 이 때문이다. 그리고 후에 명시적 파라미터 정의를 통해서 파라미터의 라이프타임과 리턴값의 라이프타임을 연결해 주었기 때문에 정상적으로 컴파일 될 수 있었다. 함수에서의 라이프타임 파라미터의 역할은, 궁극적으로 다양한 레퍼런스 파라미터와 리턴 레퍼런스의 라이프타임을 연결해 주는 것이다. 


논리적으로, 레퍼런스가 리턴값인 함수에서 레퍼런스 파라미터 말고는 리턴할 수 있는 레퍼런스가 없다. 


fn return_invaild_reference(r1: &i32, r2: &i32) -> &i32 {


위와 같은 함수가 있다고 할 때, 리턴값은 반드시 r1 혹은 r2 가 오게 된다는 말이다. 왜냐하면 둘 말고 다른 레퍼런스, 즉 함수 안에서 생성된 또다른 레퍼런스를 리턴한다는 것은 그 시도 자체만으로도 이미 러스트의 스코프 범위 규칙을 위반하는 일이다. 함수가 종료되면서 그 안에서 생성된 레퍼런스는 모두 해제될태니까. 


때문에 레퍼런스 리턴은 파라미터를 통해 이루어져야 하고, 이를 위해서 파라미터 레퍼런스의 라이프타임과 리턴 레퍼런스의 라이프타임이 동일해야 한다.



구조체에서의 라이프타임


구조체에서의 경우는 더 단순하다. 구조체가 레퍼런스 타입 필드를 가지고 있다면, 무조건 라이프타임 파라미터가 명시되어야 한다.


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

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.')
.next()
.expect("Could not find a '.'");
let i = ImportantExcerpt { part: first_sentence };
}


구조체 ImportantExcerpt 에 왜 라이프타임이 필요한지는 지금까지 알아본 내용에 비추어 보면 당연한 일이다. 구조체가 자신이 참조하는 메모리보다 오래 살아있으면 안되니까.



메서드 정의에서의 라이프타임


메서드를 정의할 때, 메서드가 속한 구조체의 필드 또는 메서드의 아규먼트 와 리턴값이 라이프타임 파라미터가 필요한 경우라면 impl 에도 라이프타임 파라미터가 필요하다. 실제로 구현되는 메서드가 라이프타임 파라미터를 필요로 하지 않더라고, 그 구조체가 라이프타임 파라미터를 가진다면 impl 에도 라이프타임 파라미터는 필요하다.


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


위 코드의 경우, 메서드 level 은 생략 규칙에 따라 라이프타임 파라미터가 필요하지 않다. 하지만 구조체 ImportantExcerpt 가 라이프타임 파라미터를 필요로 하기에, impl 에도 똑같이 이를 지정해 주어야 한다.


impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}


위 코드의 메서드는 파라미터가 둘이다. 첫 번째 생략 규칙에 따라 두 파라미터는 각각 독립된 라이프타임 파라미터를 가진다. 그리고 세 번째 규칙에 따라 &self 파라미터의 라이프타임이 리턴 레퍼런스의 라이프타임이 된다.



스태틱 라이프타임


스태틱 라이프타임은 좀 특별하다. 스태틱 라이프타임은 프로그램 전체에 걸쳐 유효하다. 스태틱 라이프타임이 적용된 대표적인 예는 스트링이다. 모든 스트링 리터럴은 스태틱 라이프타임을 가지고 있다.



제너릭 타입, 트레잇 바운드, 라이프타임을 함께 사용하기


말이 필요없고, 아래 코드처럼 사용 가능하다.


use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str
where T: Display
{
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}




'Rust' 카테고리의 다른 글

클로저(심화)  (0) 2018.03.07
클로저(심화)  (0) 2018.03.07
트레잇(Traits)  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26

트레잇(Traits)



러스트의 트레잇은 굳이 다른 언어로 말하자면 인터페이스라 할 수 있음.



트레잇의 규칙 및 성질


  1. 트레잇은 하나의 타입으로 취급됨. 같은 트레잇을 구현한 타입은 트레잇으로 같은 타입으로 동작할 수 있음. (제너릭)
  2. 트레잇은 추상 메서드, 디폴트 메서드를 가질 수 있음.
  3. 트레잇을 구현하는 타입은 반드시 트레잇의 모든 추상 메서드를 구현해야 함. (디폴트 메서드는 반드시 구현할 필요 없음)
  4. 한 타입이 트레잇의 디폴트 메서드를 구현하면 오버라이딩 되어 구현된 메서드가 실행됨.
  5. 한 타입은 여러 트레잇을 구현할 수 있음.
  6. 한 타입이 여러 트레잇을 구현할 때, 메서드 시그니처가 겹치면 컴파일 에러 발생.
  7. 외부 타입으로 로컬 트레잇을 구현할 수 있음.
  8. 로컬 타입이 외부 트레잇을 구현할 수 있음.
  9. 외부 타입으로 외부 트레잇을 구현할 수 없음. (orphan rule)



트레잇 정의 및 구현


트레잇 정의.


pub trait Summarizable {
fn summary(&self) -> String;

fn summary_nicer(&self) { // 디폴트 메서드(구현할 필요 없음)
// do something..
}
}


그리고 아래와 같이 구현. summary_nicer 메서드는 디폴트라 구현하지 않아도 됨.


pub struct NewsArticle {

}

impl Summarizable for NewsArticle {
fn summary(&self) -> {
// do something..
}
}



트레잇 제한


아래는 트레잇을 타입으로 하여 메서드 파라미터 제한.


pub fn notify<T: Summarizable>(item: T) {
println!("Breaking news! {}", item.summary());
}



아래처럼 and 조건을 걸듯, 여러 트레잇을 구현한 타입으로 제한할 수도 있음.


fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {



위처럼 파라미터가 많고 제한 트레잇이 많은 경우 보기가 불편함. 아래와 같이 where 키워드를 사용하여 트레잇을 예쁘게 명시할 수 있음.


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



아래와 같이 impl 에 트레잇의 제너릭 타입과 타입의 제너릭 타입을 지정하여 제한된 메서드 구현을 할 수 있음.


trait movable<T> {
fn goto(&self);
}

struct Location<T> {
X: i32,
y: i32,
loc_type: T,
}

impl movable<i32> for Location<i32> {
fn goto(&self) {
// do something..
}
}


위의 경우, 같은 Location 인스턴스라도 Location<i32> 타입만 movable 을, 그것도 movable<i32> 를 구현하여 movable<i32> 타입으로 동작할 수 있음. Location<String> 타입 인스턴스는 goto 메서드를 호출할 수 없음.


impl 에 다중 트레잇 타입을 한정하는 경우 아래와 같이 하면 됨.


struct Location<T> {
X: i32,
y: i32,
loc_type: T,
}

impl<T: Display + PartialOrd> Location<T> {
fn do_something(&self) {
// do something..
}
}


Display + PartialOrd 트레잇을 모두 구현한 타입의 Location 만이 do_something 메서드를 구현.




'Rust' 카테고리의 다른 글

클로저(심화)  (0) 2018.03.07
라이프타임  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26
에러 핸들링(panic)  (0) 2018.02.26

제너릭 타입



제너릭 타입은 자바에서의 것과 크게 다르지 않음.


fn largest_i32(list: &[i32]) -> i32 {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest =item;
}
}
largest
}

fn largest_char(list: &[char]) -> char {
let mut largest = list[0];

for &item in list.iter() {
if item > largest {
largest =item;
}
}
largest
}


위 두 함수를 제너릭으로 바꾸자면,


fn largest<T>(list: &[T]) -> T {


이렇게 쓰면 됨.


그런데 위와 같이 바꾸고 컴파일 시도하면 에러가 남. 이유는 타입 T 가 어떻게 값을 비교해야 하는지 정의되지 않았기 때문에 item > largest 코드가 에러를 일으킨 것. 이에 관하여는 후에 자세히 다룸.



구조체에 제너릭 타입 사용


특별할 것 없음.


struct Point<T> {
x: T,
y: T,
}

struct AnotherPoint<T, U> {
x: T,
y: U,
}



메서드에 제너릭 타입 사용


아래와 같이 사용.


struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}


아래는 조금 다름.


impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}


위의 경우, Point<T> 에서도 Point<f32> 타입에만 메서드를 구현한 것. Point<i32> 인스턴스를 생성한다면 그 인스턴스는 위의 메서드를 사용할 수 없음. 아래와 같이 일부 제너릭 타입만 한정하는 것도 가능.


struct Point<T, U> {
x: T,
y: U,
}

impl<T, i32> Point<T, i32> {
fn x(&self) -> &T {
&self.x
}
}


또, 아래와 같이 메서드에 다른 제너릭 타입을 부여하여 섞어 사용하는 것도 가능(사실 이건 자바에서도 가능..).


struct Point<T, U> {
x: T,
y: U,
}

impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}



제너릭의 성능 문제?


러스트의 제너릭은 런타임 비용이 없음. 컴파일 타임에 코드의 monorphization(단일 형태화) 을 수행함. 이는 제너릭 코드를 구체적인 타입 코드로 변환하는 일. 컴파일러는 사용자가 제너릭 코드를 작성하는 과정과 반대의 절차를 거쳐서 구체적인 코드를 생성해냄.


아래와 같은 코드가 있다면,


let integer = Some(5);
let float = Some(5.0);


컴파일러는 다음과 같은 코드를 만들어냄



enum Option_i32 {
Some(i32),
None,
}

enum Option_f64 {
Some(f64),
None,
}

fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}


이런 과정을 컴파일 타임에 모두 거치기 때문에 제너릭 타입에 런타임 시 타입 추론 등의 동작이 아예 발생하지 않음. 때문에 성능상의 비용은 없음.

'Rust' 카테고리의 다른 글

라이프타임  (0) 2018.02.28
트레잇(Traits)  (0) 2018.02.28
에러 핸들링(Result)  (0) 2018.02.26
에러 핸들링(panic)  (0) 2018.02.26
컬렉션(해시맵)  (0) 2018.02.25

Result 를 이용한 에러 핸들링



Result<T, E> 는 enum 타입이며, 주로 에러가 발생할 수 있는 함수에서 리턴 타입으로 사용됨. 


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

보다시피 제너릭 타입임.


Result<T, E> 타입에는 Ok, Err 이라는 요소가 있음. Option<T> 때와 비슷하게 생각하면 됨. Ok 인 경우는 함수(또는 메서드)의 동작이 정상적으로 수행된 경우, Err 인 경우는 비정상적인 상황을 만나 함수가 정상적으로 수행되지 못했음을 알리는 것.


Ok 는 안에 정상적인 함수의 리턴값을 담고, Err 은 에러값을 담음.


예제로 보자면,


use std::fs::File;

fn main() {
let f = File::open("hello.txt");
}


위 코드는 hello.txt 라는 파일을 찾아서 열기를 시도함. 그런데 hello.txt 파일이 존재하지 않아도 실행에 아무런 에러가 없음. 왜냐하면 File::open 함수는 Result<T, E> 를 리턴하기 때문. 바로 에러를 내고 프로그램이 죽어버리는 것이 아니라, 파일이 있을 경우 Ok 에 파일을 담아서 리턴하고, 파일이 없으면 Err 에 에러를 담아서 리턴하는 것. 사용자는 이걸 알고 경우에 맞는 처리를 하면 됨.


use std::fs::File;

fn main() {
let f = File::open("hello.txt");

let f = match f {
Ok(file) => file,
Err(error) => {
panic!("There was a problem opening the file: {:?}", error);
},
};
}


위 코드는 File::open 함수가 파일을 정상적으로 찾고 열었으면 변수 f 에 파일을 넣고, panic! 을 던지고 프로그램을 종료함.


실제로 파일이 없으므로 실행 결과는 아래와 같음.




에러 종류에 따른 처리


에러는 다양한 종류가 있을 수 있고, 에러의 종류에 따라 대응하는 코드가 달라질 수 있음. 긴 말이 필요없고, 아래 코드를 보면 됨.


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

fn main() {
let f = File::open("hello.txt"); // 파일 오픈 시도

let f = match f {
Ok(file) => file, // 파일이 존재하고 읽는 데 문제가 없을 경우 file 리턴
Err(ref error) if error.kind() == ErrorKind::NotFound => { // 에러가 발생했는데 그 에러가 ErrorFind::NotFound 일 경우
match File::create("hello.txt") { // 새로운 파일을 만들기
Ok(fc) => fc, // 파일이 정상적으로 만들어졌으면 그 새 파일을 리턴
Err(e) => { // 파일 생성 중 에러가 발생했으면
panic!("Tried to create file but there was a problem: {:?}", e); // panic!
}
}
},
Err(e) => { // 파일 열기 중 발생한 에러가 ErrorKind::NotFound 가 아닌 다른 에러라면
panic!("There was a problem opening the file: {:?}", e); // panic!
},
};
}


읽기 시도하는 파일이 없을 경우 File::open 함수가 전달하는 에러는 std::io::ErrorKind 라는 enum 타입의 NotFound 요소임. 위와 같은 방법으로 에러의 종류에 따라 처리를 달리 할 수 있음. 


if error.kind() == ErrorKind::NotFound 코드를 match guard 라 함. 그리고 Err(ref error) 의 ref 는 error 가 match guard 에 사용되면서도 오너십을 잃지 않게 하기 위함임. 이에 대해서는 후에 자세히 다룸.



에러 처리 코드 줄이기


자주 사용되어 반복되는 에러 처리 코드를 줄이는 방법이 있음. unwrap/expect 를 사용하는 것.


unwrap 은 Result 가 Ok 이면 Ok 안의 값을 리턴하고, Err 이면 알아서 panic! 을 호출함.


fn main() {
let f = File::open("hello.txt").unwrap();
}


expect 는 unwrap 과 동일하게 동작하되, panic! 의 메시지를 다르게 지정할 수 있다는 차이만 있음.


let f = File::open("hello.txt").expect("Failed to open hello.txt");



에러 전파(propagating)


코드에서 에러가 발생할 경우 때로는 에러를 직접 처리하지 않고 그 코드를 호출한 코드에게 넘겨버리는게 좋을 때도 있음(자바에서 익셉션을 throw 할 때와 같이). 이럴 땐 다음과 같이 처리함.


fn read_file() -> Result<String, io::Error> {
let f = File::open("hello.txt").unwrap();

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

읽을 파일이 없으면 더 이상 함수를 수행할 수 없으므로 에러를 그냥 던짐


보다시피, 당연히, 함수의 리턴 타입은 Result 가 와야 함. 위 함수는 에러가 발생하면 함수 호출 코드에게 에러 처리를 넘기고, 정상적으로 실행되면 파일의 내용을 스트링으로 리턴함(Ok(s))



에러 전파 코드 줄이기


에러 전파 코드는 자주 반복될 수 있음. '?' 를 사용해서 코드를 줄일 수 있음.


앞의 read_file 함수를 아래와 같이 줄일 수 있음.


fn read_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt");
let mut s = String::new(0;
f.read_to_string(&mut s)?;
Ok(s);
}


위의 read_file 함수는 이전의 함수와 거의 똑같이 동작함. 차이점이 있다면, '?' 의 동작 방식임. '?' 는 std 라이브러리의 From 트레잇에 정의된 from 함수를 사용함. 


코드 실행 중 에러가 발생하고 -> '?' 에 의해 from 함수가 호출되면 -> from 함수는 발생한 에러를 현재 함수의 리턴 타입에 맞는 에러로(예제에서는 io::Error로) 변환해줌. 이로 인해 한 함수가 여러가지 이유로 다른 에러가 발생해도 하나의 에러 타입으로 에러를 던질 수 있게 됨. 아무 에러나 이런 처리가 되는 것은 아니고, 에러 타입이 from 함수를 구현하여 이 에러가 어떻게 변환되어야 하는지 정의되어 있어야 함. 이 조건만 충족되면 '?' 는 알아서 에러를 변환해서 던져줌.


그리고 '?' 의 좋은 기능이 더 있음. 


fn read_file() -> Result<String, io:Error> {
let mut s = String::new();

File::open("hello.txt")?.read_to_string(*mut s)?;
Ok(s);
}


이전의 함수를 위와 같이 줄일 수 있음. File::open("hello.txt") 에서 에러가 발생하면 '?' 가 알아서 함수 실행을 중단하고 에러를 던지고, 정상적으로 파일이 열리면 파일을 리턴함. 그리고 그 파일에 바로 read_to_string 함수를 사용하는 것임. 제이쿼리나 여러가지 빌더 패턴에서 보는 체이닝 코드임.


'?' 는 반드시 리턴 타입이 Result 인 함수를 호출할 때만 사용할 수 있음. 왜냐하면 '?' 는 Result 타입을 다룰 때의 match 와 같은 방식으로 동작하기 때문임. match 를 Result 에 사용하되, Err 를 만나면 return Err(e) 를 수행하는 것이 '?' 의 동작 방식임. 때문에 리턴 타입이 Result 여야만 '?' 를 사용 가능. 아니면 컴파일 에러 발생.




'Rust' 카테고리의 다른 글

트레잇(Traits)  (0) 2018.02.28
제너릭 타입  (0) 2018.02.27
에러 핸들링(panic)  (0) 2018.02.26
컬렉션(해시맵)  (0) 2018.02.25
컬렉션(스트링)  (0) 2018.02.24

에러 핸들링



러스트의 에러는 회복 가능한(recoverable) 에러와 회복 불가능한(unrecoverable) 에러로 나눌 수 있음. 회복 가능한 에러는 프로그램을 바로 죽일 정도는 아닌, 런타임에 충분히 대응할 수 있는 에러를 의미하고, 회복 불가능한 에러는 그렇지 못한 에러임. 자바로 치면 체크드 익셉션을 써야 할 상황과 언체크드 인셉션을 써야 할 상황으로 생각하면 됨.


러스트에 익셉션을 없음. 대신 Result<T, E> 타입과 panic! 매크로가 존재함. Result<T, E> 는 회복 가능한 에러에, panic! 은 회복 불가능한 에러에 사용됨.



panic! 매크로


런타임에 에러가 발생했고 이 에러에 대해 해결할 수 있는 방법이 없는 경우, panic! 매크로를 사용함. 이 매크로는 프로그램 구동 중 에러가 발생했음을 알리고 프로그램을 즉각 종료함.


프로그램을 종료하는 과정에는 unwinding 이 있음. 이는 스택을 비우고 프로그램이 사용했던 메모리를 청소하는 작업임. 기본적으로 panic! 을 만나 프로그램이 종료되면 이 작업이 수행되는데, 이 작업은 내부적으로 상당히 많은 일을 필요로 함. 때문에 이 작업 없이 그냥 프로그램을 즉각 종료하도록 하는 방법이 마련되어 있음. 이렇게 하면 프로그램이 사용하던 메모리는 OS에 의해 청소되어야 함. 바이너리를 가능한 작게 만들고 싶을 경우 이 방법을 사용할 수도 있음. 방법은 Cargo.toml 파일에 다음 코드를 넣는 것.


[profile.release]

panic = 'abort'



패닉을 사용하는 방법은 간단함.


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


위 코드는 실행되자마자 종료됨. 그리고 아래 메시지를 남김.


panic! 에 파라미터로 전달한 메시지가 포함됨. ("crash and burn")


그런데 복잡한 모듈 구조와 다양한 외부 라이브러리를 사용하면 위와 같은 메시지로는 에러가 발생한 정확한 위치를 찾기 어려움. 콜 스택 트레이스와 같은 정보가 필요함. 여기에 환경변수 RUST_BACKTRACE 를 사용하면 됨.


RUST_BACKTRACE 는 아래와 같이 사용(윈도우 기준).


fn main() {
let vec = vec![1, 2, 3];

vec[99];
}


위 코드를 그냥 컴파일 하면 아래와 같이 에러가 뜸.



set RUST_BACKTRACE=1 후에 컴파일 시도하면 사용하면 아래와 같이 에러가 뜸.


스택 트레이스의 라인넘버 10 을 보면 main.rs 가 나옴. 여기서부터 에러를 찾아가면 됨.







'Rust' 카테고리의 다른 글

제너릭 타입  (0) 2018.02.27
에러 핸들링(Result)  (0) 2018.02.26
컬렉션(해시맵)  (0) 2018.02.25
컬렉션(스트링)  (0) 2018.02.24
컬렉션(벡터)  (0) 2018.02.24

해시맵



모두에게 익숙한 그 해시맵 타입을 알아보기로 함. HashMap<K, V>(이하 해시맵) 은 std 라이브러리에서 제공하지만, 벡터만큼 사용 빈도가 높다고 여겨지진 않았는지 자동으로 로드되지 않음. use 를 사용해야 함. 해시맵은 제너릭 타입이 적용되며, 요소의 키와 값이 각각 같아야 함.



해시맵 생성


기본적으로 아래와 같이 생성하고 데이터를 넣음.


use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);


collect 메서드를 사용하면 두 벡터를 이용해서 맵으로 합칠 수 있음.


use std::collections::HashMap;

let teams = vec![String::from("Blud"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

teams 의 요소는 키로, initial_scores 의 요소는 값으로 1:1 매핑된 해시맵이 생성됨.


타입을 '<_, _>' 으로 지정했는데, 이는 collect 메서드는 다양한 타입의 맵을 리턴할 수 있고, 호출 결과로 어떤 타입이 올지 모르기 때문. '_' 를 사용하면 러스트는 벡터 안의 데이터 타입을 기반으로 해시맵의 타입을 추론할 수 있음.



해시맵과 오너십


해시맵의 키/값 insert 에도 오너십 규칙이 적용됨.


let key = String::from("color");
let value = String::from("blud");

let mut map = HashMap::new();
map.insert(key, value); // key, value 의 오너십 이동

변수 key, value 는 오너십을 상실함.


어떤 값의 레퍼런스(참조)를 해시맵에 넣는다면, 그 값은 해시맵으로 이동하지 않음. 레퍼런스가 가리키는 값은 해시맵이 유효한 이상 반드시 유효해야 함. 이는 라이프타임에 관한 것인데, 후에 자세히 다룸.



해시맵 값 접근


아래와 같이 get 메서드를 이용해서 해시맵에 저장된 값에 접근할 수 있음.


use std::collections::HashMap;

let key = String::from("color");
let value = String::from("blue");

let mut map = HashMap::new();
map.insert(key, value);

let blue = map.get(String::from("color"));


get 메서드는 Option<&V> 를 리턴함. 위의 변수 blue 는 Some(&String::from("color")) 가 됨. 만약 키에 해당하는 값이 없으면 get 메서드는 None 을 리턴함.


아래의 방법으로 맵을 순회하며 키와 값을 얻을 수 있음.


let mut map = HashMap::new();
map.insert(String::from("Blue"), 10);
map.insert(String::from("Yellow"), 50);

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



해시맵 값 변경


해시맵은 하나의 키로 하나의 값만 할당할 수 있음. 만약 맵에 넣으려는 키에 해당하는 값이 이미 존재할 경우, 새로운 값으로 덮어쓸 것인지 기존 값을 유지할 것인지 결정해야 함.



값 덮어쓰기


아래와 같이 이미 존재하는 키로 값을 넣으면 그냥 새로운 값으로 덮어쓰고 기존 값은 사라짐.


map.insert(String::from("Blue"), 10);
map.insert(String::from("Blue"), 50);



값 유지하기


해시맵에 entry 메서드가 있음. 파라미터로 키 값을 받아서, 이 키에 해당하는 값이 이미 있는지 체크하고 그 결과를 리턴함. 리턴 타입은 enum 타입 Entry 임. 아래와 같이 사용.


map.insert(String::from("Blue"), 10);
map.entry(String::from("Yellow")).or_insert(50);
map.entry(String::from("Blue")).or_insert(50);


Entry 타입의 메서드 or_insert 는 Entry 의 값이 있을 경우 그 값을 리턴하고, 값이 없을 경우 파라미터로 주어진 값을 맵에 넣음. 위의 코드에서는 키 Yellow 는 값이 없으니 50 을 값으로 넣고, 키 Blue 는 값이 있으니 기존 값 10을 유지함.



기존 값을 기반으로 값 변경하기


기존 값을 그대로 덮어쓰거나 유지하지 않고, 기존 값을 이용해서 새로운 값을 만드는 경우도 있음.


let text = "hello world wonderful world";

let mut map = HashMap::new();

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

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


위 코드의 아웃풋은 다음과 같음.


{"wonderful": 1, "world": 2, "hello": 1}


코드의 수행 과정을 다음과 같음.


1. for word in text.split_whitespace() {..} 는 text 의 문자열을 공백 기준으로 잘라낸 결과를 가지고 순회.


2. let count = map.entry(word).or_insert(0); 는 map 에 해당 단어가 키로 존재하는지 체크(entry)하여 or_inert(0) 호출, 즉 값이 이미 있다면 있는 값을 리턴하고, 없다면 0 을 값으로 맵에 넣음.


3. *count += 1; 은 존재하는 값에 1 을 더함.


결국 이미 키가 존재하는 경우 count 를 1 더하고, 없으면 0 으로 초기화 함. or_insert 메서드는 값이 있는 경우 뮤터블 레퍼런스(*mut V) 를 리턴하기 때문에, 역참조(*) 로 값을 변경할 수 있음.



해싱 함수


기본적으로 러스트의 해시맵은 서비스 거부 공격(Denial of Service)을 방어할 수 있는, 암호화된 보안 해싱 함수를 사용함. 이 함수는 가장 빠른 해싱 함수는 아님. 성능을 어느 정도 희생하여 안정성을 취한 형태. 만약 다른 해싱 함수를 사용하길 원한다면, hasher 를 명시하여 원하는 해싱 함수를 지정할 수 있음. 이에 대한 자세한 내용은 나중에 다루기로 함.




'Rust' 카테고리의 다른 글

에러 핸들링(Result)  (0) 2018.02.26
에러 핸들링(panic)  (0) 2018.02.26
컬렉션(스트링)  (0) 2018.02.24
컬렉션(벡터)  (0) 2018.02.24
모듈(다른 모듈 접근)  (0) 2018.02.22

스트링(문자열)


러스트의 스트링은 좀 복잡함. 스트링이 컬렉션에 포함된 이유는, 스트링은 텍스트로 표현되는 바이트의 집합 +이 바이트를 다루는 유용한 메서드들로 구현되어 있음. 이런 성질은 본질적으로 컬렉션 타입의 것이라 볼 수 있음. 



스트링이란?


러스트에는 크게 두 가지 스트링 타입이 존재함: str 과 String.


str: 

러스트 코어 존재하는 유일한 스트링 타입임. 레퍼런스 대여 코드에 더 많이 보이기 때문에 보통 &str 형태로 보임. 스트링 슬라이스의 타입이기도 한 이 타입은 그 위치가 어디이든 UTF-8 로 인코딩된 모든 문자열을 참조함. 따라서, 프로그램의 바이너리에 위치하는 스트링 리터럴도 스트링 슬라이스라 할 수 있음.


String:

std 라이브러리에 의해 제공되는 스트링 타입임. 이 타입은 늘어날 수 있고(growable), 뮤터블이며, 소유할 수 있고, UTF-8 임. 


러스트 프로그래머가 '문자열(스트링)' 을 이야기할 때, 이는 대부분 &str 과 String 을 동시에 의미함. (둘 중 하나만 떼어놓고 이야기하지 않는다는 의미.) 두 스트링 타입 모두 러스트에서 중요하게 다루는 타입이며, 모두 UTF-8 인코딩을 취한다는 사실을 기억해야 함.


std 는 또한 OsString, OsStr, CString, CStr 과 같은 다른 스트링 타입도 제공함. 이들의 이름은 String/&str 처럼 *String/*str 의 형태를 가짐. 이런 타입들은 다른 인코딩 타입이거나 하는 등 각각의 특징을 가지고 있음. 여기서는 자세히 다루지 않으니, 궁금한 사항은 API 를 보길 바람.



새 스트링 생성


아래는 String 타입의 빈 스트링을 생성함.


let mut s = String::new();



아래는 초기값을 가진 String 타입의 스트링을 생성함.


let date = "initial contents";
let s = data.to_string();

let s = "initial contents".to_string(); // 위와 같은 결과.



아래는 초기값을 가진 또 다른 방법. 위의 방법과 결과적으로 똑같다.


let s = String::from("initial contents");



스트링 값 변경


문자열을 더하는 방법에는 메서드 push_str 과 push 이 있음.


let mut s = String::from("foo");
s.push_str("bar");


push_str 메서드는 파라미터로 스트링 슬라이스를 받음. 때문에 파라미터 원본의 오너십을 가지지 않음. 


let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);



push 메서드는 문자 하나를 파라미터로 받음.


let mut = String::from("lo");
s.push('l');



format! 매크로와 '+' 연산자를 이용한 문자열 이어붙이기


다른 언어에서 흔히 그렇듯, 러스트도 '+' 연산자를 이용한 문자열 붙이기가 가능함.


let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2 // 여기서 s1 은 '이동' 이 발생함. 변수 s1 은 이제 사용 불가.


s3 는 'Hello, world!' 가 됨. 그런데 주석에서 보다시피, s1 은 이제 버려지고, s2 은 여전히 유효함. 그 이유는, 여기 '+' 연산자는 add 메서드를 사용함. 아래는 그 시그니처.


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


이건 사실 std 라이브러리의 정확한 시그니처는 아님. 정확히는 제너릭이 사용되어 있음. 일단 여기서는 중요하지 않으니 패스.


여기서 눈여겨볼 점은 따로 있음. 이 메서드는 두 번째 파라미터로 &str 을 받아서 첫 번째 파라미터인 자기 자신, 즉 String 에 더함. '+' 연산자를 사용할 때 우린 &str 을 String 에 더하는 것이며, String 과 String 을 합칠 수는 없음. 그런데, 위의 예제에서 s2 은 String 임. 그리고 &s2 는 &String 이지, &str 이 아님. 그런데 add 메서드의 파라미터로 쓰일 수 있는 이유는?


컴파일러는 인자값으로 넘어온 &String 를 &str 으로 강제함. add 메서드를 호출하면 러스트는 deref coercion 을 사용하는데, 이는 &s2 를 &s2[...] 로 바꿈. deref coercion 에 대해서는 나중에 자세히 다루니 여기선 패스. 아무튼 그렇고, add 메서드는 파라미터 s 의 오너십을 취하지 않음. 때문에 변수 s2 는 add 후에도 여전히 유효함.


그리고, add 의 시그니처에서 self 는 오너십을 취함. let s3 = s1 + &s2; 은 얼핏 보기에 두 스트링을 복사하여 하나의 새로운 스트링을 생성하는 것처럼 보이지만, 사실은 s1 의 오너십을 취한 뒤 여기에 s2 의 내용을 복사하여 더하고(append), 오너십을 다시 리턴하는 것임. 다시 말해서, 보기와 달리 이 과정에서 많은 복사 작업이 발생하지 않음. add 의 구현 방식은 단순 복사보다 효율적으로 이루어져 있음.



더 많은 수의 스트링을 더할 수도 있음.


let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;


이런 작업이 복잡해질 경우, format! 매크로를 사용할 수도 있음.


let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2 ,s3);


위 두 예제에서 s 는 같은 값을 가짐. 그런데 format! 매크로는 오너십을 취하지 않기 때문에, s1, s2, s3 모두 유효함.



스트링 인덱싱


let s1 = String::from("hello");
let h = s1[0];


위 코드는 컴파일 에러를 일으킴. 즉 러스트 스트링은 인덱싱을 지원하지 않음. 이유를 알아보자면,


내부적으로 String 은 Vec<u8> 의 래퍼(wrapper)임. 


let len = String::from("Hola").len();


위 코드에서 len 은 4 가 됨. 이는 "Hola" 를 저장하고 있는 Vec 의 길이가 4바이트라는 의미임. UTF-8 에서 각 문자는 1바이트씩 차지함. 그렇다면 다음 코드는?


let len = String::from("Здравствуйте").len();


위 문자열의 첫 문자는 키릴 문자의 대문자 Ze 임. 숫자 3 이 아님. len 의 값은 12가 아닌 24인데, 이는 위 문자열이 UTF-8 에서 24바이트를 취했다는 의미임. 각 유니코드 스칼라 값이 2바이트씩 차지함. 이렇게 러스트 스트링의 인코딩인 UTF-8 에서 스트링의 인덱스가 언제나 하나의 문자의 위치라는 것을 보장할 수 없음. 


let hello = "Здравствуйте";
let answer = &hello[0];


answer 의 값은 무엇이 되어야 함? 첫 번째 문자? UTF-8 에서 첫 번째 문자는 208 바이트임. 두 번째는 151임. 그리고 이미 알아봤듯 이 문자열은 문자 하나가 2바이트를 차지함. 때문에 첫 번째 바이트인 208이나, 두 번째 바이트인 151이 각자 단독으로는 유효한 문자가 되지 못함. 때문에 인덱스 0 으로 이 문자를 리턴할 수가 없음. 그래도 어쨌든 첫 번째 인덱스이니 일단 208 을 리턴하도록 한다면, 유저는 첫 번째 인덱싱으로 나온 바이트이니 이 값이 첫 번째 문자의 바이트라고 잘못 인식하게 될 여지가 있고, 이는 유저가 원하는 데이터가 아님. 이런 문제로 인해 러스트는 이런 방식의 스트링 인덱싱 접근을 허용하지 않음.



바이트와 스칼라 값 그리고 문자 집합 


데바나가리 스크립트로 쓰려진 힌디 단어 'नमस्ते' 은 Vec 의 u8 값으로 다음과 같이 저장됨.


[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]


이건 18바이트이며 컴퓨터가 최종적으로 데이터를 저장하는 방식임. 이걸 유니코드 스칼라 값으로 본다면 다음과 같음. 이는 러스트에서 char 타입 값임.


['न', 'म', 'स', '्', 'त', 'े']


이렇게 여섯 개의 char 값이 됨. 그런데 4, 6번째 값은 문자가 아님: 이건 diacritic 이라는, 스스로 문자화 될 수 없는 값임. 결국 이 값들을 문자 집합으로 본다면, 사람이 보기에 다음 4개의 문자가 하나의 단어로 되어버림. 4, 6번째 값은 버려지고, 남은 값들로 단어가 구성되어 원래 단어와 값이 달라짐.


["न", "म", "स्", "ते"]


러스트는 프로그램이 어떤 언어의 문자든 적절하게 해석할 수 있도록, 컴퓨터가 저장하는 원천 스트링 데이터를 해석하는 여러가지 방법을 제공함.


러스트가 String 에 인덱싱 접근을 허용하지 않는 마지막 이유는, 인덱싱 연산은 언제나 정적 시간(O1(1)) 을 취해야 한다고 여겨지기 때문임. 러스트는 문자열 안의 유효한 문자의 수를 도출하기 위해 문자열의 처음부터 끝까지 순회해야 하기 때문에 String 으로 정적 시간 연산 성능을 보장할 수 없음.



스트링 슬라이싱


문자열 인덱싱 접근은 별로 좋은 방법이 아님. 왜냐하면 리턴 타입이 뭐가 되어야 할지 분명하지 않기 때문. 바이트인지, 문자인지, 문자열인지... 때문에 러스트는 이에 대하여 범위를 지정한 슬라이스를 사용하도록 권함.


let hello = "Здравствуйте";

let s = &hello[0..4];


s 는 &str 타입이 되고, hello 의 처음 4바이트를 참조함. 위 문자가 각 2바이트를 취한다고 했으니, s 는 처음 두 문자를 참조하게 됨.


&hello[0..1] 를 참조한다면? 이건 panic 이 발생함. 이건 무효한 인덱싱 접근과 마찬가지임. 이런 결과가 발생할 수 있으므로 스트링 슬라이싱을 사용함에 주의가 필요함.



문자열 순회 메서드


아래 방법으로 문자열의 각 문자를 순회할 수 있음. char 메서드는 문자열의 각 문자를 나누어줌.


for c in "नमस्ते".chars() {
println!("{}", c);
}


아래는 바이트. bytes 메서드는 문자열의 각 문자를 바이트로 나누어줌.


for c in "नमस्ते".bytes() {
println!("{}", c);
}



아무튼 스트링은 복잡함.




'Rust' 카테고리의 다른 글

에러 핸들링(panic)  (0) 2018.02.26
컬렉션(해시맵)  (0) 2018.02.25
컬렉션(벡터)  (0) 2018.02.24
모듈(다른 모듈 접근)  (0) 2018.02.22
모듈(pub)  (0) 2018.02.22

벡터



기본적인 컬렉션 타입 Vec<T>(이하 벡터) 에 대해 알아봄. 벡터는 std 에 포함된 컬렉션 타입임. 따로 스코프로 로드하지 않아도 사용할 수 있음. 한 벡터 인스턴스에는 오직 같은 타입의 값만 저장 가능함.



벡터 인스턴스 생성 방법


기본적인, 빈 벡터 인스턴스는 아래와 같이 생성. 제너릭에 타입을 지정함.


let v: Vec<i32> = Vec::new();


아래는 벡터 인스턴스 생성과 함께 초기값을 설정. 초기값의 타입에 따라 벡터의 제너릭 타입이 추론됨.


let v = vec![1, 2, 3];



벡터 변경


기본적으로 아래와 같이 벡터에 데이터를 넣음. 벡터에 값을 넣거나 빼려면 역시 뮤터블이어야 함


let mut v: Vec<i32> = Vec::new();

v.push(1);
v.push(2);
v.push(3);



러스트의 다른 인스턴스와 마찬가지로, 벡터 또한 스코프를 벗어나면 메모리에서 해제되며, 벡터가 가지고 있던 값들도 사라짐.


{
let v = vec![1, 2, 3];
// do something..
}



벡터의 요소 읽기


아래 코드에 벡터의 요소를 읽은 두 가지 다른 방법이 있음.


let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
let third: Option<&i32> = v.get(2);


첫 번째는 인덱스를 직접 접근, 두 번째는 get 메서드를 사용. 두 방법의 차이는 다음과 같음.


let v = vec![1, 2, 3, 4, 5];
let does_not_exist: &i32 = &v[100];
let does_not_exist: Option<&i32> = v.get(100);


위처럼 범위를 넘어선 인덱스를 지정하면 첫 번째 방법은 panic 이 발생함. 반면 두 번째 방법은 Option<T> 타입인 None 을 리턴하여, 해당 인덱스에 요소가 없을 경우에 실행할 코드를 지정할 수 있음. 인덱스에 요소가 없을 때 치명적인 오류로 보고 프로그램을 바로 종료한다면 첫 번째 방법을, 요소가 없는 경우가 발생할 수 있고 이에 대해 다른 대응을 해야 한다면 두 번째 방법을 취하면 됨.



무효한 레퍼런스


벡터의 요소를 읽는 데에도 레퍼런스 대여 규칙이 적용됨. 


let mut v = vec![1, 2, 3, 4, 5];

let first = &v[0]; // 임뮤터블 대여 발생

v.push(6); // 뮤터블 대여 발생

위 코드는 컴파일 에러를 일으킴.


벡터에 충분한 여유 공간이 없다면, 벡터에 새로운 값을 넣는 push 메서드는 새로운 메모리 할당을 요구하고 기존 요소를 새로운 공간에 복사하는 작업을 수행할 수 있음. 그런데 위의 변수 first 가 벡터의 기존 첫 번째 요소(의 주소)를 참조하고 있는데 push 메서드로 인해 그 요소의 메모리가 해제되는 일이 생기면, 변수 first 의 참조는 무효한 것이 되어버림. 이런 현상을 방지하고자 컴파일러는 앞서 레퍼런스 대여 규칙을 체크함.



벡터 값 순회하기


아래는 기본적인 방법.


let v = vec![100, 32, 57];
for i in v {
println!("{}", i);
}



아래는 벡터 안의 요소값을 변경할 경우.


let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}

각각의 값에 50을 더함.


'*' 는 역참조(디레퍼런스) 연산자임. 참조(&) 로 요소를 읽어왔기에, 안의 값을 변경하려면 다시 역참조를 수행.



enum 을 이용한 다양한 타입 다루기


벡터 인스턴스는 한번에 한 가지 타입으로 제한되기 때문에, 여러 타입을 하나의 벡터에 넣으려면 enum 을 사용하는 방법이 있음.


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

let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text("blut".to_string()),
SpreadsheetCell::Float(10.12),
];


러스트가 이렇게 벡터의 타입을 미리 지정하도록 하는 이유는, 벡터가 요소를 저장하는 데에 필요한 메모리를 컴파일 타임에 알 수 있게 하기 위함임. 그리고 벡터에 아무 타입이나 담을 수 있게 되면, 하나의 벡터 인스턴스를 다루면서 안에 섞여 있는 요소의 다양성으로 인해 오류가 발생할 위험성이 높아지기 마련임.


하지만 enum 은 완전하지 않음. 때로는 구현하려는 프로그램이 런타임에 어떤 타입의 데이터를 받게 될지 알 수 없는 경우가 있을 이럴 때 enum 은 유효하지 못함. 이럴 땐 '특성(트레잇)' 을 사용해야 하는데, 이건 나중에 다루기로 함.




'Rust' 카테고리의 다른 글

컬렉션(해시맵)  (0) 2018.02.25
컬렉션(스트링)  (0) 2018.02.24
모듈(다른 모듈 접근)  (0) 2018.02.22
모듈(pub)  (0) 2018.02.22
모듈  (0) 2018.02.22

+ Recent posts