러스트의 클로저
원문: http://huonw.github.io/blog/2015/05/finding-closure-in-rust/
클로저란?
클로저를 한 문장으로 표현하자면: 클로저란, 자신이 정의된 스코프의 변수를 직접 사용할 수 있는 함수이다. 클로저의 이 특성은 흔히 closing over 또는 capturing variables 이라 불리며, 이렇게 접근되는 변수를 environment 라 한다.
※ 이하 capturing 을 캡처링, environment 를 변수라 번역.
문법적으로, 러스트의 클로저는 루비에서의 것과 유사하게 파이프('|') 로 정의되는 익명 함수이다. |arguments...| body. 예로, |a, b| a + b 는 두 아규먼트를 받아 둘의 합계를 리턴하는 클로저이다. 이건 평범한 함수 정의와 비슷하면서 추론 기능이 더해진 형태다.
일반 함수와 마찬가지로, 클로저도 괄호와 함께 호출된다: closure(arguments...).
아래 코드는 캡처링을 표현하기 위해 Option<i32> 의 map 을 호출한다. map 은 i32 값에 대해 클로저를 호출하고, 새 Option 을 생성하여 여기에 클로저의 결과값을 담아 리턴한다.
클로저는 변수 x 와 y 를 캡처하고 이 변수들을 매핑에 사용할 수 있도록 한다. (이 코드에 하드코딩된 값은 실제로는 런타임에서만 알 수 있는 값이라 생각하자.)
근본으로 돌아가서...
이제 우리는 클로저의 의미를 알았다. 이제 그 근본으로 들어가보자: 러스트에 클로저가 없다면, 제너릭 map 을 어떻게 구현해야 할까?
Option::map 의 기능을 동등하게 구현해보자면:
우리는 ... 에 X 를 Y 로 변환하는 무언가를 넣어야 한다. Option::map 을 완벽히 대체하기에 가장 큰 제약사항은, 여기에 어떤 형태로든 제너릭이 필요하다는 점이다. 그래야만 우리가 원하는대로 유연하게 작동할 수 있다. 러스트에서 이 제너릭 바운딩은 트레잇을 통해 이루어진다.
이 트레잇에 특정 데이터를 다른 데이터로 변환하는 메서드가 있어야 한다. 그래서 여기에 map 과 같은 제너릭 바운드에서 특정 데이터의 정확한 타입을 지정할 수 있는 타입 파라미터가 필요하다. 두 선택지가 있다: 트레잇 정의의 제너릭 ("인풋 타입 파라미터"), 연관(associated) 타입 ("아웃풋 타입 파라미터"). 쌍따옴표로 표시된 부분이 우리 선택의 힌트가 된다: 데이터 변환의 인풋 타입은 제너릭 정의에, 그리고 아웃풋 타입은 연관 타입에 있어야 한다.
이렇게 하여 나온 트레잇은 다음과 같다:
이제 마지막 남은 질문은 어떤 형태의 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 과 트레잇은 이제 다음과 같다:
이제 이 트레잇을 구현하는 적절한 구조체를 만든다:
우리는 대강 러스트 클로저와 의미적으로 동일한 것을 구현했다. 트레잇과 구조체를 사용해서 스코프의 변수를 다뤘다. 사실 이 구조체에는 클로저의 스코프(environment)와 묘한 유사성이 있다: transform 의 바디에서 사용할 변수를 구조체 자신 안에 저장한다는 것이다.
진짜 클로저는 어떻게 동작하는가?
클로저는 위에서 구현한 것에 유연성과 문법적 편의성(syntactic sugar)을 더한 것이다. Option::map 의 진짜 정의는 다음과 같다:
FnOnce(X) -> Y 는 우리의 Transform<X, Output = Y> 바운드와, 그리고 f(x) 는 transform.transform(x) 와의 대칭이다.
클로저에는 세 가지 트레잇이 있다. 셋 모두 ...(...) 의 호출 형태를 제공한다. 이 트레잇들의 차이는 호출 메서드의 self 의 타입에 있다. 그리고 이들은 위에서 언급한, self 의 모든 유형을 다룬다.
- &self : Fn
- &mut self : FnMut
- self : FnOnce
- 변수는 어떻게 캡처되는가? (클로저 구조체 필드의 타입은 무엇?)
- 어떤 트레잇이 사용되는가? (self 의 유형은 무엇?)
- 캡처된 변수가 오직 공유된 레퍼런스를 통해서만 사용되면, 이 변수는 & 레퍼런스로 캡처된다.
- 캡처된 변수가 뮤터블 레퍼런스로 사용되면 (값 할당 포함), 이 변수는 &mut 레퍼런스로 캡처된다.
- 캡처된 변수가 이동한다면, 이 변수는 값에 의한 이동으로 캡처된다. (주의: Copy 타입의 캡처는 & 레퍼런스만을 필요로 한다. 때문에 이 규칙은 오직 non-Copy 타입에만 적용된다.)
유연성에 초점을 두자: x 는 공유 레퍼런스로 캡처되기 때문에 이 클로저가 존재하는(유효한) 동안에도 사용할 수 있다. 그리고 y 는 뮤터블 레퍼런스로 대여되었기 때문에 이 클로저가 스코프를 벗어나 사라져야만 사용할 수 있다. z 값으로 캡처되기 때문에 이 클로저가 사라진 뒤에도 어디에서도 다시 사용할 수 없다.
컴파일러는 대강 다음과 같은 코드를 생성한다:
이 구조체는 러스트의 타입 시스템의 모든 기능을 사용하여, 실수로 댕글링 레퍼런스를 생성하거나 이미 해제된 메모리를 사용하거나 클로저를 오용하여 메모리의 안전을 침해하는 등의 현상을 방지한다. 문제가 될 수 있는 코드가 있다면 컴파일러가 잡아낼 것이다.
move 와 이스케이프
위에서, 추론 알고리즘이 non-escaping 클로저에 대해서는 완벽하다고 했다. 이는 다른 한편으로, escaping 클로저에 대해서는 완벽하지 않다는 의미이다.
클로저가 escaping 이면, 즉 클로저가 자신이 생성된 스택 프레임을 떠나게 된다면, 이 클로저는 자신이 떠나는 스택 프레임 안의 어떠한 레퍼런스도 가지고 있지 않아야 한다. 레퍼런스를 가지고 있게 된다면 클로저가 스택 프레임을 벗어날 때 이는 댕글링 레퍼런스가 될 것이며, 이건 매우 나쁜 일이다. 다행히도 컴파일러는 이런 현상이 발생할 여지가 있으면 에러를 일으킨다. 그런데, 클로저를 리턴하는 일은 유용하면서도 가능해야 하는 일이다. 예로:
상당히 괜찮다... 컴파일이 안된다는 것만 빼면:
...: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 가 필요한 부분은 + 연산으로, 레퍼런스에 의한 캡처이면 족하다. 그래서 컴파일러는 이를 다음과 같은 코드로 추론해낸다:
x 는 make_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 || { /* 같은 코드 */ } 형태였으면, 클로저 환경 구조체는 이렇게 달라졌을 것이다:
모든 변수를 값으로 캡처하는 일은 엄밀히 말해서 레퍼런스에 의한 캡처보다 일반적인 것이다: 러스트에서 레퍼런스 타입은 일급(first-class)으로 취급된다. 때문에, '레퍼런스에 의한 캡처' 는 사실 '레퍼런스를 값으로 캡처' 와 동일한 말이다. 그래서, C++ 과는 달리, 여기에는 레퍼런스에 의한 캡처와 값에 의한 캡처의 근본적인 구별이 있다. 그리고 러스트의 분석은 필수가 아니다: 이 사실은 프로그래머을 편하게 해준다.
다음 코드는 move 를 사용하면서 레퍼런스를 캡처하여 첫 번째 버전과 동일한 동작, 동일한 환경을 가진다:
캡처된 변수들은 클로저 몸체에서 사용되는 변수와 똑같다. C++ 11 에서와 같은 세분화된 캡처 목록은 존재하지 않는다. [=] 캡처 목록은 move 키워드로 존재하지만, 이게 전부다.
이제 우린 make_adder 에서의 문제를 해결할 수 있다. move 를 사용함으로써 컴파일러의 암시적 레퍼런스 추가를 막고, 클로저가 스택 프레임에 묶이지 않도록 한다. 컴파일러의 권고에 따라 Box::new(move |y| x + y) 로 코드를 수정한다:
컴파일러는 move 가 필요한 때를 인지하지 못한다는 건 명백하다. 그런데 help 메시지를 보면 컴파일러는 경우에 따라 move 가 언제 필요한지 충분히 인지할 수 있음을 암시한다. 불행하게도, 컴파일러의 move 추론을 기본 기능에 포함시키는 일은 클로저 바디의 내부를 분석하는 것 이상의 작업을 필요로 한다: 클로저 값이 어디에서/어떻게 사용되느냐를 고려하는 일은 기술적으로 더 복잡한 일일 것이다. 결국 help 메시지가 move 추론에 대한 최선의 노력의 결과이고, 추론 기능이 러스트의 기본 기능에는 들어갈 수 없다.
트레잇
클로저의 실제 '함수' 는 위에서 언급한 트레잇에 의해 다루어진다. 암시적 구조체 타입은 트레잇을 암시적으로 구현하며, 이렇게 구현된 트레잇이 실제 클로저 타입으로 동작한다.
예제를 보자: make_adder 예제에서 Fn 트레잇이 암시적 클로저 구조체에 대해 구현되었다.
실제로는 여기에 Closure 에 대한 FnMut, FnOnce 의 암시적 구현이 존재하지만, Fn 은 이 클로저에 대한 '기본적인' 것이다.
트레잇은 세 가지가 있고, 여기서 7개의 트레잇 세트가 구현될 수 있다. 그런데 여기서는 그들 중 가장 중요한 세 가지를 본다:
- Fn, FnMut, FnOnce
- FnMut, FnOnce
- FnOnce
한마디로: FnMut 를 구현하는 어떤 것이든 FnOnce 를 반드시 구현한다.
컴파일러가 어떤 트레잇을 구현하느냐를 추론하는 데에 있어 어떤 미묘한 점은 없다. 컴파일러는 구현할 수 있는 한 모든 트레잇을 구현한다. 이런 성질은 '최대한의 유연성 제공' 규칙으로 유지된다. 이 규칙은 캡처 타입의 추론에 적용되는데, 구현하는 트레잇이 많다는 것은 그만큼 많은 옵션이 존재할 수 있음을 의미하기 때문이다. Fn* 트레잇의 집합이 의미하는 것은 이 규칙은 언제나 위에 나열된 세 가지 트레잇 집합 목록 중 하나를 구현하게 한다는 것이다.
예제로, 아래 코드는 Fn 구현이 불가능하지만 FnMut 와 FnOnce 는 가능한 경우를 보여준다:
& &mut ... 를 변경하는 일은 불가능하다. 그리고 &self 는 그 외부 공유 영역을 참조한다. &mut self 혹은 self 였으면 아무 문제가 없을 것이다: 전자 쪽이 더 유연성이 좋으니, 컴파일러는 FnMut 를 구현한다. (물론 FnOnce 도)
이와 유사하게, closure 가 || drop(v); 라면, 즉 v 를 밖으로 던진다면, 이 경우는 Fn 와 FnMut 이 불가능할 것이다. 왜냐하면 &self 와 &mut self 로 이런 동작을 수행하는 일은 대여된 데이터의 오너십을 훔치는 일이니까.
유연성
러스트의 목표 중 하나는 프로그래머에게 선택권을 남겨두면서도, 그들의 코드를 효율적이게 하고, 추상화를 컴파일하여 빠른 머신 코드를 생성하는 일이다. 클로저의 유니트 구조체 타입와 트레잇/제너릭 디자인이 여기의 핵심이 된다.
각 클로저는 저마다의 타입을 가지기 때문에, 클로저를 사용할 때 의무적인 힙 할당은 필요하지 않다: 위에서 보여졌듯, 클로저의 변수 캡처는 변수를 구조체 안으로 곧장 위치시킨다. 이는 러스트가 C++ 11 과 공유하는 성질이다. 클로저는 기본적으로 bare-mental 환경을 포함한 어떤 환경에서든 사용할 수 있도록 허용된다.
유니크 타입이란 서로 다른 클로저를 함께 사용할 수 없음을 의미한다. 예를 들어, 각각 다른 클로저를 담고 있는 벡터를 만들 수는 없다. 클로저들은 각각 서로 사이즈가 다르며, 호출 방식도 다르다. (각각의 클로저들은 내부 코드가 서로 다르기에, 호출하는 함수도 서로 다르다.) 다행히도 트레잇을 사용하여 클로저 유형을 추상화할 수 있다. 트레잇 객체를 통해 이런 기능과 이점을 '온 디맨드로' 선택할 수 있다: Box<Fn(i32) -> i32> 를 리턴한다.
유니크한 타입과 제너릭의 또다른 이점은, 기본적으로 컴파일러는 클로저가 호출되는 위치에서 무엇을 하는지 전부 알고 있기 때문에, 인라이닝과 같은 최적화를 수행할 수 있다는 것이다. 예를 들어, 아래 스니펫은 동일한 코드로 컴파일된다:
(내가 위의 두 코드를 각각 서로 다른 함수 안에 두고, 각 함수를 하나의 파일에 두어 컴파일 해보았다. 컴파일러는 두 번째 함수에서 첫 번째 함수를 호출하는 방식으로 최적화를 수행했다.)
이건 러스트가 monomorphisation 을 통해 제너릭을 구현하는 방법 때문이다. 제너릭 함수는 주어진 타입 파라미터마다 각각의 호출 형태에 맞는 코드를 생성하는 방식으로 컴파일된다. 불행히도, 이 최적화가 언제나 좋은 것은 아니다. 이 방식은 하나의 함수를 두고 비슷한 함수 여러개를 복제해내면서 거대한 코드를 낳게 된다. 이것을 막는 방법으로는 트레잇 객체가 있다. 트레잇 객체를 이용하면 제너릭 타입에 여러 클로저가 함께 사용되더라도 동적으로 디스패치된 클로저를 사용하여 함수의 사본이 하나만 존재하는지 확인할 수 있다.
마지막 바이너리는 generic_clocure 의 두 가지 복사본을 가지게 된다. 하나는 A 에 대해서, 다른 하나는 B 에 대해서. 그런데 closure_object 의 복사본은 하나이다. 사실 포인터에 대한 Fn* 트레잇 구현체가 존재하기 때문에 트레잇 객체를 generic_closure 에 바로 사용할 수도 있다. 예로, generic_closure((&|x| {... }) as &Fn(_)) 으로 사용하는 것이 가능하다. 고차원 함수의 사용자는 스스로 원하는 방식을 택하여 사용할 수 있다.
클로저의 강력함은 사용자로 하여금 세부 사항을 직접 작성하는 것과 비교하여 성능 면에서 손실이 없으면서도 높은 수준의 "fluent" 한 API 를 작성하도록 해준다는 것이다. 이것의 좋은 예는 iterators 이다: 사용자는 C 수준의 효율성으로 최적화되는 map 과 filter 같은 어댑터 함수를 체이닝 방식으로 호출할 수 있다.
'Rust' 카테고리의 다른 글
클로저(심화) (0) | 2018.03.07 |
---|---|
라이프타임 (0) | 2018.02.28 |
트레잇(Traits) (0) | 2018.02.28 |
제너릭 타입 (0) | 2018.02.27 |
에러 핸들링(Result) (0) | 2018.02.26 |