질문에 대한 역사는 Rust가 폐기물 수집된 함수 객체 대신 익명 구조체를 통해 클로저를 제로 비용 추상화로 구현하기로 결정한 것에서 유래합니다. JavaScript나 Python과 같은 언어와 달리 Rust는 소유권, 차용 및 가변성 규칙을 클로저의 유형에 직접 인코딩해야 합니다. 세 가지 특성인 Fn, FnMut 및 FnOnce는 자신의 호출 메서드에서 self 매개변수에 따라 엄격한 계층 구조를 형성하여 컴파일러가 클로저 사용이 캡처된 환경의 메모리 안전 불변성을 준수하는지 컴파일 시 검증할 수 있도록 합니다.
문제는 클로저가 변수(참조에 의해 또는 move를 통해 값으로) 캡처하는 방식과 이를 내부에서 사용하는 방식 간의 차이에 중점을 둡니다. FnOnce는 self (소유권을 소모) 를 요구하여 클로저가 캡처된 변수를 환경에서 이동할 수 있게 하지만 단일 호출로 제한합니다. FnMut는 &mut self를 요구하여 캡처된 상태의 변형을 허용하지만 클로저 자체에 대한 고유한 접근을 요구합니다. Fn은 &self를 요구하여 여러 동시 호출을 가능하게 하지만 내부 가변성이 사용되지 않는 한 캡처된 변수를 변형하는 것을 금지합니다. 비-Copy 유형을 클로저 본체로 이동하는 클로저는 첫 번째 호출 후 환경이 이동된 상태가 되기 때문에 FnOnce가 됩니다. 후보자들은 종종 캡처에 의해 값으로 강제하는 move 키워드와 FnOnce 특성을 혼동하여 Copy 유형만 포함된 move 클로저가 여전히 Fn을 구현한다는 것을 인식하지 못합니다.
해결책은 API에 필요한 가장 덜 제한적인 특성 제약을 선택하는 것입니다. 클로저가 정확히 한 번 호출되면 FnOnce를 사용하여 환경을 소비하는 클로저를 포함한 가장 다양한 클로저를 수용합니다. 여러 번 호출 및 변형이 필요한 경우에는 FnMut을 사용합니다. 동시적이거나 반복적인 읽기 전용 접근이 필요한 경우에는 Fn을 사용합니다. 컴파일러는 캡처 분석에 따라 이러한 구현을 자동으로 파생하며, 수동 특성 구현이 필요하지 않습니다.
fn apply_once<F: FnOnce()>(f: F) { f(); } fn apply_mut<F: FnMut()>(mut f: F) { f(); f(); } fn apply_fn<F: Fn()>(f: F) { f(); f(); } let data = vec![1, 2, 3]; let consume = move || drop(data); // FnOnce: Vec는 Copy가 아님 apply_once(consume); let mut count = 0; let mut increment = || { count += 1; }; // FnMut: 캡처 변형 apply_mut(&mut increment); let value = 42; let print = move || println!("{}", value); // Fn: i32는 Copy apply_fn(print); apply_fn(print); // 유효: print는 Fn
고속 웹 서버의 비동기 작업 스케줄러를 고려하십시오. 이 서버는 수신 요청을 처리하기 위해 사용자 정의 후크를 수용합니다. 스케줄러 API는 초기에는 모든 후크가 잠재적 병렬 실행을 허용하도록 Fn을 구현해야했습니다.
문제 설명: 새로운 기능은 후크가 연결별 통계를 유지해야 하며, 이로 인해 캡처된 카운터의 변형이 필요했습니다. 개발자들은 mut counter 변수를 캡처하는 move 클로저를 전달하려고 했지만 컨파일러는 Fn이 &self를 요구하므로 소유된 mut 필드를 내부적으로 변형할 수 없기 때문에 이를 거부했습니다. 팀은 특성 제약을 완화할 것인지 후크 서명을 재구성할 것인지 사이에서 선택해야 했습니다.
해결책 1: Atomic Types를 사용한 내부 가변성:
u64 카운터를 AtomicU64로 교체하고 Arc를 통해 캡처합니다. 클로저는 &self에 대한 원자적 작업을 통한 변형이 이루어지므로 Fn을 구현합니다. 고유한 접근이 클로저 자체에 대한 가변 접근을 요구하지 않습니다.
장점: Fn 제약을 유지하며, 스케줄러는 클로저 자체의 동기화 없이 여러 스레드에서 후크를 동시에 실행할 수 있습니다.
단점: 하드웨어 수준의 원자적 오버헤드와 메모리 순서 복잡성을 도입합니다. 단일 스레드 사용 시에도 Arc 할당이 필요하여 단순 카운터에 대한 제로 비용 추상화 원칙을 손상시킵니다.
해결책 2: 순차적 실행을 위한 FnMut 제약:
스케줄러 API를 FnMut 클로저를 수용하도록 변경합니다. 스케줄러는 후크를 **Vec<Box<dyn FnMut()>>**에 저장하고 &mut 접근을 유지하며 순차적으로 호출합니다.
장점: 변형에 대한 런타임 오버헤드가 없습니다. 타입 시스템이 호출 중에 고유한 접근을 강제하므로 데이터 경합이 발생하지 않도록 컴파일 시 보증합니다.
단점: 같은 후크의 동시 호출을 방지하며, 스케줄러의 내부 저장소가 복잡해집니다 (스케줄러 자체에서 &mut self를 요구). 기존 Fn 후크와의 호환성을 깨뜨리며, 전체 구현을 사용하지 않는 한 후크 호환성을 손상시킵니다.
선택된 해결책: 솔루션 2 (FnMut)이 선택되었습니다. 서버의 아키텍처가 스레드당 연결을 처리하므로 동시 후크 실행이 불필요해졌습니다. 팀은 동시 후크의 유연성보다 컴파일 타임 안전성을 선호했으며 API 변경 사항을 깨짐에도 불구하고 올바른 진화로 받아들였습니다.
결과: 스케줄러는 런타임 오버헤드 없이 상태 저장 후크를 성공적으로 처리했습니다. 타입 시스템은 두 개의 스레드가 비원자적 카운터를 동시 증가시키는 미세한 버그를 방지했습니다. 이는 RefCell을 Fn과 함께 사용하여 적절한 동기화 없이 발생했을 수 있습니다.
클로저 정의의 move 키워드는 클로저가 자동으로 FnOnce를 구현하게 하나요?
아니오. move 키워드는 캡처된 변수가 빌려지지 않고 값으로 클로저의 환경으로 이동된다는 것만 의미합니다. 특성 구현은 클로저 본체가 캡처를 어떻게 사용하는지에 따라 전적으로 달라집니다. 클로저가 환경에서 비-Copy 유형을 이동하면 (소비), FnOnce를 구현합니다. 캡처를 변형하는 경우에는 FnMut을 구현합니다. 변수를 읽거나 Copy 유형을 값으로만 사용하는 경우에는 move 키워드가 있어도 Fn을 구현합니다. 예를 들어 let x = 5; let f = move || x + 1;는 i32가 Copy이므로 Fn을 구현합니다.
어떻게 FnOnce를 수용하는 함수가 Fn을 구현하는 클로저를 호출할 수 있지만 그 반대는 불가능한 이유는 무엇인가요?
Fn은 FnMut의 하위 특성이며, 이는 FnOnce의 하위 특성입니다. 이는 Fn을 구현하는 모든 클로저가 자동으로 FnMut과 FnOnce를 구현하게 됨을 의미하지만, 그 반대는 성립하지 않습니다. FnOnce로 한정된 함수 매개변수는 한 번 호출할 수 있는 클로저를 수용하는데, 이는 여러 번 호출할 수 있는 클로저 (Fn 및 FnMut)도 포함됩니다. 반대로 Fn을 요구하는 함수는 클로저가 공유 참조(&self)를 통해 호출을 지원해야 하므로 환경을 소비하는 클로저(FnOnce만)가 이를 충족할 수 없습니다. 이는 표준 서브타입 규칙을 따르며, 더 능력 있는 유형(Fn)가 덜 능력 있는 유형(FnOnce)이 요구되는 곳에서 사용할 수 있습니다.
컴파일러는 클로저가 외부 스코프의 변수에 대한 참조를 캡처할 때 어떤 특성을 구현하는지를 어떻게 결정하나요?
컴파일러는 캡처된 변수가 어떻게 접근되는지 확인하기 위해 클로저 본체를 분석합니다. 클로저가 캡처된 변수에서 이동하면(그리고 유형이 Copy가 아닌 경우) FnOnce를 구현합니다. 캡처된 변수를 변형하면(할당하거나 &mut self 메서드를 호출하면) FnMut(및 FnOnce)을 구현합니다. 변수를 읽거나 &self 메서드를 호출하는 경우에는 Fn(및 다른 특성)을 구현합니다. 참조로 캡처할 경우(&T 또는 &mut T), 클로저는 참조를 보유합니다. &mut T를 캡처하는 경우에는 일반적으로 FnMut을 구현합니다. 왜냐하면 호출 시 클로저 자체에 대한 고유한 접근이 필요하여 가변 대출의 고유성을 유지해야 하기 때문입니다. &T를 캡처할 경우에는 Fn을 구현합니다.