클로저: 자신의 환경을 캡처하는 익명 함수
클로저가 환경을 불변·가변 참조하거나 소유권으로 캡처하는 방식을 익히고 Fn·FnMut·FnOnce 호출 특성을 구분합니다.
러스트의 설계는 여러 기존 언어와 기법에서 영감을 얻었으며, 큰 영향을 준 것 가운데 하나가 함수형 프로그래밍(functional programming)입니다. 함수형 스타일의 프로그래밍에는 흔히 함수를 값처럼 인수로 넘기기, 다른 함수의 결과로 함수를 돌려주기, 나중에 실행하려고 함수를 변수에 넣어 두기 같은 것들이 들어갑니다. 함수형 프로그래밍이 무엇인지는 TypeScript 교재의 순수 함수와 불변성에서 순수 함수와 불변성을 중심으로 설명합니다.
이번 장에서는 무엇이 함수형 프로그래밍이고 무엇이 아닌지를 따지는 대신, 다른 언어에서 흔히 함수형이라고 부르는 기능과 비슷한 러스트의 기능을 살펴봅니다. 구체적으로는 다음을 다룹니다.
| 주제 | 내용 |
|---|---|
| 클로저(closure) | 변수에 저장할 수 있는, 함수와 비슷한 구조 |
| 반복자(iterator) | 일련의 원소를 처리하는 방법 |
| I/O 프로젝트 개선 | 클로저와 반복자로 11장의 minigrep을 고치는 방법 |
| 성능 | 클로저와 반복자의 성능. 생각보다 빠릅니다. |
패턴 매칭과 열거형처럼 이미 다룬 다른 기능들도 함수형 스타일의 영향을 받았습니다. 클로저와 반복자를 익히는 것은 관용적이고 빠른 러스트 코드를 쓰는 데 중요하므로, 한 장을 통째로 이 주제에 씁니다.
클로저: 자신의 환경을 캡처하는 익명 함수
러스트의 클로저는 변수에 저장하거나 다른 함수에 인수로 넘길 수 있는 익명 함수입니다. 한곳에서 클로저를 만들고, 다른 맥락의 다른 곳에서 그 클로저를 불러 실행할 수 있습니다. 함수와 달리 클로저는 자신이 정의된 스코프의 값을 캡처할 수 있습니다. 이 기능 덕분에 코드를 재사용하고 동작을 바꿔 끼울 수 있다는 것을 보게 됩니다.
클로저로 환경 캡처하기
먼저 클로저가 정의된 환경의 값을 캡처해 나중에 쓰는 방법을 봅니다. 상황은 이렇습니다. 티셔츠 회사가 판촉 행사로, 메일링 리스트에 있는 사람에게 가끔 한정판 티셔츠를 나눠 줍니다. 메일링 리스트의 사람들은 프로필에 좋아하는 색을 적어 둘 수 있습니다. 티셔츠를 받게 된 사람이 좋아하는 색을 적어 두었다면 그 색의 티셔츠를 받습니다. 좋아하는 색을 적지 않았다면, 회사가 지금 가장 많이 가진 색의 티셔츠를 받습니다.
구현할 방법은 많습니다. 이 예제에서는 Red와 Blue 배리언트를 가진 열거형 ShirtColor를 씁니다. 단순하게 하려고 쓸 수 있는 색은 이 둘로 제한합니다. 회사의 재고는 Inventory 구조체로 나타내며, 이 구조체에는 지금 재고에 있는 티셔츠 색을 나타내는 Vec<ShirtColor> 타입의 shirts 필드가 있습니다. Inventory에 정의한 giveaway 메서드는 무료 티셔츠를 받을 사람이 선택적으로 고른 색을 받아, 그 사람이 받을 티셔츠의 색을 돌려줍니다.
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}
struct Inventory {
shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};
let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}main에 정의한 store에는 이 한정판 판촉에 나눠 줄 파란 티셔츠 두 장과 빨간 티셔츠 한 장이 남아 있습니다. 빨간 티셔츠를 좋아하는 사용자와, 좋아하는 색이 없는 사용자에게 giveaway 메서드를 부릅니다.
이 코드도 다른 방식으로 구현할 수 있지만, 여기서는 클로저에 집중하기 위해 이미 배운 개념만 쓰고 giveaway 메서드의 본문에서만 클로저를 썼습니다. giveaway는 사용자가 좋아하는 색을 Option<ShirtColor> 타입의 매개변수로 받아 user_preference에 unwrap_or_else 메서드를 부릅니다. Option<T>의 unwrap_or_else 메서드는 표준 라이브러리에 정의되어 있습니다. 이 메서드는 인수 하나를 받습니다. 인수 없이 T 타입의 값을 돌려주는 클로저이며, 여기서 T는 Option<T>의 Some 배리언트에 저장되는 타입, 곧 ShirtColor입니다. Option<T>가 Some 배리언트라면 unwrap_or_else는 Some 안의 값을 돌려주고, None 배리언트라면 클로저를 불러 클로저가 돌려준 값을 돌려줍니다.
unwrap_or_else의 인수로는 클로저 표현식 || self.most_stocked()를 넘겼습니다. 이 클로저는 매개변수를 받지 않습니다. 클로저에 매개변수가 있다면 두 세로 막대 사이에 적습니다. 클로저의 본문은 self.most_stocked()를 부릅니다. 여기서 클로저를 정의하고, 결과가 필요해지면 unwrap_or_else의 구현이 나중에 이 클로저를 평가합니다. 이 코드를 실행하면 다음과 같이 출력됩니다.
The user with preference Some(Red) gets Red
The user with preference None gets Blue흥미로운 점은, 현재의 Inventory 인스턴스에 self.most_stocked()를 부르는 클로저를 넘겼다는 것입니다. 표준 라이브러리는 우리가 정의한 Inventory나 ShirtColor 타입이나, 이 상황에서 쓰려는 로직에 대해 아무것도 알 필요가 없었습니다. 클로저는 self, 곧 Inventory 인스턴스의 불변 참조를 캡처하고, 우리가 지정한 코드와 함께 그것을 unwrap_or_else 메서드에 넘깁니다. 반면 함수는 이런 식으로 자신의 환경을 캡처할 수 없습니다.
클로저 타입 추론과 명시
함수와 클로저 사이에는 차이가 더 있습니다. 클로저는 대개 fn 함수처럼 매개변수나 반환값의 타입을 적지 않아도 됩니다. 함수에 타입을 명시해야 하는 까닭은, 함수의 타입이 사용자에게 드러나는 명시적인 인터페이스의 일부이기 때문입니다. 이 인터페이스를 엄격하게 정의해 두는 것은, 함수가 어떤 타입의 값을 쓰고 돌려주는지 모두가 동의하게 하는 데 중요합니다. 반면 클로저는 이렇게 드러나는 인터페이스에 쓰이지 않습니다. 클로저는 변수에 저장되어 이름 없이 쓰이며, 라이브러리의 사용자에게 드러나지 않습니다.
클로저는 대개 짧고, 아무 상황에나 쓰이기보다 좁은 맥락에서만 쓰입니다. 이런 제한된 맥락에서는 컴파일러가 매개변수와 반환값의 타입을 추론할 수 있습니다. 대부분의 변수 타입을 컴파일러가 추론할 수 있는 것과 비슷합니다. 드물게 컴파일러가 클로저의 타입을 명시해 달라고 할 때도 있습니다.
변수처럼, 꼭 필요한 것보다 장황해지더라도 더 분명하고 명확하게 하고 싶다면 타입을 명시할 수 있습니다. 다음은 클로저를 정의해 변수에 저장하면서 타입을 명시한 예입니다. 앞의 예제처럼 클로저를 인수로 넘기는 곳에서 정의하는 대신, 여기서는 클로저를 정의해 변수에 저장합니다.
let expensive_closure = |num: u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};타입을 명시하면 클로저의 문법이 함수의 문법과 더 비슷해 보입니다. 다음은 매개변수에 1을 더하는 함수의 정의와, 같은 동작을 하는 클로저입니다. 비교하기 쉽도록 공백을 넣어 관련된 부분을 줄 맞춤했습니다.
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;첫 줄은 함수 정의이고 둘째 줄은 타입을 모두 명시한 클로저 정의입니다. 셋째 줄은 클로저 정의에서 타입 명시를 없앴고, 넷째 줄은 본문에 표현식이 하나뿐이므로 생략해도 되는 중괄호를 없앴습니다. 모두 호출될 때 같은 동작을 하는 올바른 정의입니다. add_one_v3와 add_one_v4가 컴파일되려면 클로저를 실제로 불러야 합니다. 쓰이는 곳에서 타입을 추론하기 때문입니다. 이것은 let v = Vec::new();가 컴파일되려면 타입을 명시하거나 Vec에 어떤 타입의 값을 넣어야 러스트가 타입을 추론할 수 있는 것과 비슷합니다.
클로저를 정의하면 컴파일러는 매개변수와 반환값마다 구체적인 타입 하나를 추론합니다. 예를 들어 다음은 받은 매개변수의 값을 그대로 돌려주는 짧은 클로저입니다. 이 클로저는 이 예제를 보여 주는 것 말고는 쓸모가 없습니다. 정의에 타입 명시를 넣지 않았다는 점을 보세요. 타입 명시가 없으므로 아무 타입으로나 이 클로저를 부를 수 있으며, 여기서는 처음에 String으로 불렀습니다. 그다음 정수로 example_closure를 부르면 오류가 납니다.
fn main() {
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
}error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| --------------- ^ expected `String`, found integer
| |
| arguments to this function are incorrect
|
note: expected because the closure was earlier called with an argument of type `String`
--> src/main.rs:4:29
|
4 | let s = example_closure(String::from("hello"));
| --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
| |
| in this closure call
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
help: try using a conversion method
|
5 | let n = example_closure(5.to_string());
| ++++++++++++
For more information about this error, try `rustc --explain E0308`.처음 example_closure를 String 값으로 불렀을 때, 컴파일러는 클로저의 x의 타입과 반환 타입을 String으로 추론합니다. 그 타입들은 example_closure의 클로저에 고정되며, 같은 클로저에 다른 타입을 쓰려 하면 타입 오류가 납니다.
참조자를 캡처하거나 소유권 이동하기
클로저는 환경의 값을 세 가지 방법으로 캡처할 수 있으며, 이것은 함수가 매개변수를 받는 세 가지 방법과 직접 대응합니다. 불변으로 빌리기, 가변으로 빌리기, 소유권 가져가기입니다. 클로저는 캡처한 값을 본문에서 어떻게 쓰는지에 따라 이 가운데 무엇을 쓸지 정합니다.
다음 코드는 list라는 벡터의 불변 참조를 캡처하는 클로저를 정의합니다. 값을 출력하는 데 불변 참조만 있으면 되기 때문입니다.
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
let only_borrows = || println!("From closure: {list:?}");
println!("Before calling closure: {list:?}");
only_borrows();
println!("After calling closure: {list:?}");
}이 예제는 변수가 클로저 정의를 가리킬 수 있고, 나중에 함수 이름처럼 변수 이름과 괄호로 그 클로저를 부를 수 있다는 것도 보여 줍니다. list의 불변 참조는 동시에 여러 개 있을 수 있으므로, 클로저를 정의하기 전에도, 정의한 뒤 부르기 전에도, 부른 뒤에도 list에 접근할 수 있습니다. 이 코드는 컴파일되고 실행되며 다음과 같이 출력합니다.
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]다음으로 클로저의 본문을 list 벡터에 원소를 더하도록 바꿉니다. 이제 클로저는 가변 참조를 캡처합니다.
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
let mut borrows_mutably = || list.push(7);
borrows_mutably();
println!("After calling closure: {list:?}");
}Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]borrows_mutably 클로저의 정의와 호출 사이에 있던 println!이 없어졌다는 점을 보세요. borrows_mutably가 정의될 때 list의 가변 참조를 캡처합니다. 클로저를 부른 뒤로는 클로저를 다시 쓰지 않으므로 가변 대여가 끝납니다. 클로저의 정의와 호출 사이에는 출력을 위한 불변 대여가 허용되지 않습니다. 가변 대여가 있는 동안에는 다른 대여가 허용되지 않기 때문입니다. 그 자리에 println!을 더해 어떤 오류 메시지가 나오는지 확인해 보세요.
클로저의 본문이 소유권을 꼭 필요로 하지 않더라도, 클로저가 환경에서 쓰는 값의 소유권을 가져가게 하고 싶다면 매개변수 목록 앞에 move 키워드를 씁니다.
이 기법은 주로 클로저를 새 스레드에 넘겨 데이터를 새 스레드가 소유하게 할 때 쓸모가 있습니다. 스레드가 무엇이고 왜 쓰는지는 스레드 동시 실행에서 동시성을 다룰 때 자세히 이야기합니다. 지금은 move 키워드가 필요한 클로저로 새 스레드를 만드는 것을 간단히 봅니다. 다음 코드는 벡터를 메인 스레드가 아니라 새 스레드에서 출력하도록 고친 것입니다.
use std::thread;
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
thread::spawn(move || println!("From thread: {list:?}"))
.join()
.unwrap();
}새 스레드를 만들면서 실행할 클로저를 인수로 넘깁니다. 클로저의 본문은 list를 출력합니다. 앞의 첫 예제에서는 출력에 불변 참조만 필요했으므로 클로저가 list를 불변 참조로만 캡처했습니다. 이 예제에서도 클로저의 본문에는 불변 참조만 필요하지만, 클로저 정의 앞에 move를 붙여 list가 클로저 안으로 이동해야 한다고 지정해야 합니다.
새 스레드는 메인 스레드의 나머지 부분보다 먼저 끝날 수도 있고, 메인 스레드가 먼저 끝날 수도 있습니다. 메인 스레드가 list의 소유권을 유지한 채 새 스레드보다 먼저 끝나 list를 버린다면, 스레드 안의 불변 참조는 유효하지 않게 됩니다. 그래서 컴파일러는 참조가 유효하도록 list를 새 스레드에 넘길 클로저 안으로 옮기라고 요구합니다. move 키워드를 지우거나, 클로저를 정의한 뒤 메인 스레드에서 list를 써서 어떤 컴파일러 오류가 나오는지 확인해 보세요.
캡처된 값을 클로저 밖으로 이동하기와 Fn 트레이트
클로저가 정의된 환경에서 참조를 캡처하든 소유권을 캡처하든, 곧 클로저 안으로 무엇이 들어오든, 클로저의 본문은 나중에 클로저가 실행될 때 그 참조나 값에 무슨 일이 일어나는지, 곧 클로저 밖으로 무엇이 나가는지를 정합니다.
클로저의 본문은 캡처한 값을 클로저 밖으로 옮기거나, 캡처한 값을 바꾸거나, 값을 옮기지도 바꾸지도 않거나, 처음부터 환경에서 아무것도 캡처하지 않을 수 있습니다. 클로저가 환경의 값을 캡처하고 다루는 방식은, 그 클로저가 어떤 트레이트를 구현하는지에 영향을 줍니다. 그리고 함수와 구조체는 트레이트로 자신이 쓸 수 있는 클로저의 종류를 정할 수 있습니다. 클로저는 본문이 값을 어떻게 다루는지에 따라 다음 Fn 트레이트 가운데 하나, 둘 또는 셋을 모두 자동으로 구현합니다.
| 트레이트 | 구현하는 클로저 |
|---|---|
FnOnce | 한 번 부를 수 있는 클로저입니다. 모든 클로저는 적어도 이 트레이트를 구현합니다. 캡처한 값을 본문 밖으로 옮기는 클로저는 한 번만 부를 수 있으므로 FnOnce만 구현하고 다른 Fn 트레이트는 구현하지 않습니다. |
FnMut | 캡처한 값을 본문 밖으로 옮기지 않지만 캡처한 값을 바꿀 수 있는 클로저입니다. 여러 번 부를 수 있습니다. |
Fn | 캡처한 값을 본문 밖으로 옮기지도 바꾸지도 않는 클로저와, 환경에서 아무것도 캡처하지 않는 클로저입니다. 환경을 바꾸지 않고 여러 번 부를 수 있으므로, 클로저를 여러 번 동시에 부르는 경우 같은 데 중요합니다. |
아래 그림은 세 트레이트가 어떻게 포개지는지를 보여 줍니다.
RUST · CAPTURE & CALL TRAITS
move 캡처와 FnOnce만 구현하는 본문은 다르다
값을 클로저 안으로 가져오는 방식과, 호출 중 값을 밖으로 보내는 동작을 구분합니다. 아래 화살표는 한 트레이트가 다른 바운드도 만족한다는 뜻입니다.
Fn → FnMut → FnOnce
Fn을 구현하면 두 다른 바운드도 만족하며, FnMut이면 FnOnce도 만족합니다. 화살표는 호출 능력의 포함 관계입니다.
move 후 읽기 · 반복 가능
let text = String::from("hello");
let read = move || text.len();
read();
read();text는 클로저가 소유하지만 본문은 빌려 읽기만 하므로 Fn입니다. move만으로 FnOnce 전용이 되지 않습니다.
소유 값을 밖으로 이동 · 한 번
let text = String::from("hello");
let take = move || text;
let owned = take();본문이 String을 반환하며 캡처 환경에서 빼냅니다. 이 클로저는 FnOnce만 구현합니다.
본문의 가변 캡처
list.push(7)이나 num_sort_operations += 1은 가변 접근을 요구하여 FnMut입니다. 호출 뒤 다시 필요한 상태가 남습니다.
캡처와 스레드 조건
자동 캡처·move 캡처와 호출 트레이트는 서로 다른 판단입니다. thread::spawn에는 캡처 환경의 Send와 'static 등 별도 조건도 필요합니다.
Fn은 공유 수신으로 호출할 수 있다는 계약이며 내부 가변성을 배제하지 않습니다. 캡처가 Copy 값이면 값 캡처가 복사일 수도 있습니다. 캡처하지 않는 일반 클로저는 함수 포인터로도 변환할 수 있습니다.
화살표는 시간 순서나 값의 이동이 아니라 호출 능력의 포함 관계를 나타냅니다. 공유 참조 &self로 부를 수 있는 Fn을 구현한 클로저는 FnMut와 FnOnce를 요구하는 자리도 만족하고, 가변 참조 &mut self로 부를 수 있는 FnMut를 구현하면 FnOnce를 요구하는 자리도 만족합니다. 값을 클로저 안으로 가져오는 방식과, 부를 때 값을 밖으로 보내는 동작은 다른 문제입니다. 그림의 let read = move || text.len();은 text를 소유하지만 본문은 빌려 읽기만 하므로 Fn이고 여러 번 부를 수 있습니다. move만으로 FnOnce 전용이 되지는 않습니다. 반면 let take = move || text;는 본문이 캡처한 String을 돌려주며 밖으로 빼내므로 FnOnce만 구현합니다. list.push(7)이나 num_sort_operations += 1처럼 캡처한 값을 바꾸는 클로저는 FnMut입니다. 스레드에 넘기는 클로저에는 이것과 별개로 Send와 'static 같은 조건도 필요합니다.
앞에서 쓴 Option<T>의 unwrap_or_else 메서드의 정의를 봅니다.
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}T는 Option의 Some 배리언트에 담긴 값의 타입을 나타내는 제네릭 타입입니다. T는 unwrap_or_else 함수의 반환 타입이기도 합니다. 예를 들어 Option<String>에 unwrap_or_else를 부르는 코드는 String을 받게 됩니다.
unwrap_or_else 함수에는 제네릭 타입 매개변수 F가 하나 더 있습니다. F는 매개변수 f의 타입이며, f는 unwrap_or_else를 부를 때 넘기는 클로저입니다. 제네릭 타입 F에 지정한 트레이트 바운드는 FnOnce() -> T입니다. 곧 F는 한 번 부를 수 있어야 하고, 인수를 받지 않고, T를 돌려주어야 합니다. 트레이트 바운드에 FnOnce를 쓴 것은 unwrap_or_else가 f를 많아야 한 번 부른다는 제약을 나타냅니다. unwrap_or_else의 본문을 보면, Option이 Some이면 f를 부르지 않고 None이면 f를 한 번 부릅니다. 모든 클로저가 FnOnce를 구현하므로, unwrap_or_else는 세 종류의 클로저를 모두 받아 주며 그만큼 유연합니다.
참고로 하고 싶은 일이 환경의 값을 캡처할 필요가 없다면, 클로저 대신 함수 이름을 쓸 수도 있습니다. 예를 들어 Option<Vec<T>> 값에 unwrap_or_else(Vec::new)를 부르면, 값이 None일 때 새 빈 벡터를 얻습니다. 컴파일러는 함수 정의에 알맞은 Fn 트레이트를 자동으로 구현합니다.
이번에는 슬라이스에 정의된 표준 라이브러리 메서드 sort_by_key를 보며, unwrap_or_else와 어떻게 다르고 왜 sort_by_key는 트레이트 바운드에 FnOnce 대신 FnMut를 쓰는지 봅니다. 클로저는 인수 하나를 받는데, 그것은 처리 중인 슬라이스의 현재 원소의 참조이며, 클로저는 정렬할 수 있는 K 타입의 값을 돌려줍니다. 이 함수는 각 원소의 특정 속성을 기준으로 슬라이스를 정렬하고 싶을 때 쓸모가 있습니다. 다음 코드는 Rectangle 인스턴스의 목록을 sort_by_key로 width 속성의 오름차순으로 정렬합니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
list.sort_by_key(|r| r.width);
println!("{list:#?}");
}[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
]sort_by_key가 FnMut 클로저를 받도록 정의된 까닭은, 클로저를 여러 번 부르기 때문입니다. 정렬 중 두 원소를 비교할 때마다 양쪽 원소의 키를 구하려고 부르며, 정확히 몇 번 부를지는 정렬 구현에 달려 있습니다. 클로저 |r| r.width는 환경에서 아무것도 캡처하거나 바꾸거나 옮기지 않으므로 이 트레이트 바운드의 요구를 만족합니다. 아래 그림은 API가 콜백을 어떻게 부르는지에 따라 어떤 바운드를 요구하는지 정리한 것입니다.
RUST · CALLBACK CONTRACT
콜백 바운드는 호출 횟수와 수신 방식으로 고른다
본문의 unwrap_or_else와 sort_by_key를 비교합니다. 콜백을 소비해 호출할지, 가변 또는 공유 참조로 다시 호출할지가 API의 요구를 정합니다.
FnOnce · self로 호출
F: FnOnce() -> Tunwrap_or_else는 Some이면 0회, None이면 1회 호출합니다. 콜백을 소비해도 되므로 모든 일반 클로저를 받을 수 있습니다.
FnMut · &mut self로 호출
F: FnMut(&T) -> Ksort_by_key는 키 함수를 여러 번 호출할 수 있습니다. 캡처한 카운터 변경은 가능하지만 캡처한 String의 소유권을 매번 빼낼 수는 없습니다.
Fn · &self로 호출
콜백에 대한 공유 참조로 반복 호출해야 하면 Fn을 요구합니다. Cell·RefCell 같은 내부 가변성이나 외부 상태의 변화까지 금지한다는 뜻은 아닙니다.
본문의 실제 정렬 결과
width: 10, 3, 7
.sort_by_key(|r| r.width)
width: 3, 7, 10원소마다 정확히 한 번 호출한다는 보장은 없습니다. Fn 클로저도 FnMut 바운드를 만족합니다.
바운드는 클로저가 제공해야 하는 호출 능력입니다. Fn은 FnMut·FnOnce도 만족하고 FnMut은 FnOnce도 만족합니다. 공유·동시 실행에 필요한 Send·Sync·수명 조건은 별도로 확인합니다.
unwrap_or_else는 Some이면 0번, None이면 1번 콜백을 부르므로 콜백을 소비해 부르는 FnOnce를 요구합니다. sort_by_key는 키 함수를 여러 번 부를 수 있으므로 FnMut를 요구합니다. 캡처한 카운터를 바꾸는 것은 괜찮지만, 캡처한 String의 소유권을 매번 빼낼 수는 없습니다. 콜백에 대한 공유 참조로 여러 번 불러야 하는 API는 Fn을 요구합니다. Fn이라고 해서 Cell이나 RefCell 같은 내부 가변성이나 외부 상태의 변화까지 막는 것은 아닙니다. 세 API는 서로 독립된 예이며, 순서대로 이어지는 흐름이 아닙니다.
반대로 다음 코드는 FnOnce 트레이트만 구현하는 클로저의 예입니다. 값을 환경 밖으로 옮기기 때문입니다. 컴파일러는 이 클로저를 sort_by_key에 쓰지 못하게 합니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("closure called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{list:#?}");
}이것은 list를 정렬할 때 sort_by_key가 클로저를 몇 번 부르는지 세려는, 억지스럽고 복잡한 방법이며 동작하지도 않습니다. 이 코드는 클로저의 환경에 있는 String인 value를 sort_operations 벡터에 넣어 횟수를 세려고 합니다. 클로저는 value를 캡처한 뒤 value의 소유권을 sort_operations 벡터로 옮겨 value를 클로저 밖으로 옮깁니다. 이 클로저는 한 번만 부를 수 있습니다. 두 번째로 부르려 하면 value가 더는 환경에 없어 sort_operations에 다시 넣을 수 없으므로 동작하지 않습니다. 그래서 이 클로저는 FnOnce만 구현합니다. 이 코드를 컴파일하려 하면, 클로저가 FnMut를 구현해야 하므로 value를 클로저 밖으로 옮길 수 없다는 오류가 납니다.
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
--> src/main.rs:18:30
|
15 | let value = String::from("closure called");
| ----- ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
| |
| captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ `value` is moved here
|
help: consider cloning the value if the performance cost is acceptable
|
18 | sort_operations.push(value.clone());
| ++++++++
For more information about this error, try `rustc --explain E0507`.오류는 클로저 본문에서 value를 환경 밖으로 옮기는 줄을 가리킵니다. 이것을 고치려면 클로저 본문이 환경 밖으로 값을 옮기지 않도록 바꾸어야 합니다. 환경에 카운터를 두고 클로저 본문에서 그 값을 늘리는 것이, 클로저가 몇 번 불렸는지 세는 더 간단한 방법입니다. 다음 클로저는 num_sort_operations 카운터의 가변 참조만 캡처하므로 sort_by_key와 함께 쓸 수 있고, 여러 번 부를 수 있습니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut num_sort_operations = 0;
list.sort_by_key(|r| {
num_sort_operations += 1;
r.width
});
println!("{list:#?}, sorted in {num_sort_operations} operations");
}[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
], sorted in 6 operations세 원소를 정렬하는 데 키 함수가 여섯 번 불렸습니다. 원소 수보다 많이 부른 것은 정렬 중 두 원소를 비교할 때마다 양쪽의 키를 구하기 때문이며, 이 횟수는 표준 라이브러리의 정렬 구현에 따라 달라질 수 있습니다. Fn 트레이트는 클로저를 쓰는 함수나 타입을 정의하거나 쓸 때 중요합니다. 다음 글에서는 반복자를 다룹니다. 반복자의 메서드 가운데 많은 것이 클로저를 인수로 받으므로, 계속 이 클로저의 세부 사항을 떠올리게 될 것입니다.
더 깊이: 클로저는 캡처한 값을 담은 이름 없는 구조체입니다
Reference의 클로저 타입 설명에 따르면 클로저 식은 적어 낼 수 없는 고유한 익명 타입의 값을 만들며, 그 타입은 대략 캡처한 값들을 필드로 가진 구조체와 같습니다. 불변 참조로 캡처한 값은 참조 필드가, move로 가져온 값은 값 필드가 되는 셈입니다. 그래서 정의가 똑같은 두 클로저도 서로 다른 타입이며, 함수에서 클로저를 돌려주거나 담을 때 impl Fn이나 제네릭, 트레이트 객체를 써야 합니다. move가 없다면 캡처 방식은 불변 대여에서 가변 대여, 이동으로 이어지는 순서 가운데 본문에서 값을 쓰는 방식과 맞는 첫 번째 것으로 정해지며, 클로저 바깥의 코드나 변수의 라이프타임은 이 선택에 영향을 주지 않습니다.
무엇을 캡처하는지도 2021 에디션에서 달라졌습니다. 2018 에디션까지 클로저는 변수를 통째로 캡처했지만, 지금은 본문에서 쓰는 경로만 캡처합니다. 에디션 가이드의 예처럼 || a.x + 1은 a 전체가 아니라 a.x만 캡처하므로, 클로저가 살아 있는 동안에도 a의 다른 필드를 바꾸거나 옮길 수 있습니다. 대신 값이 버려지는 시점이나 클로저가 Send, Clone 같은 트레이트를 구현하는지가 달라질 수 있어서, cargo fix --edition은 그런 곳에 let _ = &a; 같은 줄을 넣어 예전처럼 변수 전체를 캡처하게 합니다.
이 구조체 모양은 클로저가 구현하는 다른 트레이트도 정합니다. 클로저는 캡처한 값이 허용하는 한 Clone, Copy, Send, Sync를 구현하며, 예를 들어 가변 참조로 캡처하지 않고 값으로 캡처한 것이 모두 Copy라면 클로저도 Copy입니다. 또 아무것도 캡처하지 않는 클로저는 시그니처가 맞는 함수 포인터 fn 타입으로 바뀔 수 있습니다.
정리
- 클로저는
|매개변수| 본문형태의 익명 함수로, 변수에 저장하거나 인수로 넘길 수 있고, 정의된 스코프의 값을 캡처할 수 있습니다. - 클로저의 매개변수와 반환 타입은 대개 추론되며, 처음 부른 타입으로 고정되므로 다른 타입으로 다시 부르면 E0308 오류가 납니다.
- 클로저는 본문이 값을 쓰는 방식에 따라 불변 참조, 가변 참조, 소유권 가운데 하나로 캡처하며,
move를 붙이면 소유권을 가져갑니다. - 캡처한 값을 밖으로 옮기면
FnOnce만, 바꾸기만 하면FnMut까지, 옮기지도 바꾸지도 않으면Fn까지 구현합니다.Fn은FnMut와FnOnce의 요구도 만족합니다. unwrap_or_else는 콜백을 많아야 한 번 부르므로FnOnce를,sort_by_key는 여러 번 부르므로FnMut를 요구합니다.
연습 문제
1. 어떤 트레이트를 구현할까
다음 세 클로저는 각각 Fn, FnMut, FnOnce 가운데 무엇까지 구현하나요? 각 클로저의 본문이 캡처한 값을 읽기만 하는지, 바꾸는지, 밖으로 옮기는지를 보면 답할 수 있습니다.
let name = String::from("Ferris");
let mut count = 0;
let a = || println!("{name}");
let mut b = || count += 1;
let c = move || name;해설 보기
a는 name을 불변으로 빌려 읽기만 하므로 Fn을 구현하고, 따라서 FnMut와 FnOnce도 만족합니다. b는 count를 바꾸므로 FnMut와 FnOnce를 구현하지만 Fn은 아닙니다. c는 캡처한 name을 돌려주어 밖으로 옮기므로 FnOnce만 구현하며 한 번만 부를 수 있습니다. 다만 c가 name을 클로저 안으로 옮기므로, 이 코드를 그대로 쓰려면 a를 c보다 먼저 다 쓴 뒤여야 합니다.
2. 왜 move가 필요할까
thread::spawn(|| println!("{list:?}"))처럼 move 없이 쓰면 컴파일되지 않습니다. 클로저 본문은 list를 읽기만 하는데 왜 소유권을 옮겨야 하나요?
해설 보기
새 스레드가 언제 끝날지 컴파일러가 알 수 없으므로, 메인 스레드가 먼저 끝나며 list를 버리면 스레드 안의 참조가 유효하지 않게 될 수 있습니다. 그래서 thread::spawn은 클로저가 빌린 값에 기대지 않도록 'static을 요구하고, move로 list의 소유권을 클로저에 넘겨야 합니다. 본문이 읽기만 하므로 이 클로저는 move를 붙여도 Fn입니다.
다음 글 반복자 기반 데이터 처리에서는 Iterator 트레이트와, 클로저를 받는 반복자 메서드로 일련의 원소를 처리하는 방법을 살펴봅니다.