Historia pytania pochodzi z decyzji Rust o wdrożeniu zamknięć jako zerokosztowych abstrakcji za pomocą anonimowych struktur, a nie obiektów funkcji z zarządzanym przez system pamięci. W przeciwieństwie do języków takich jak JavaScript czy Python, Rust musi wprost zakodować zasady własności, pożyczania i mutowalności bezpośrednio w typie zamknięcia. Trzy cechy — Fn, FnMut i FnOnce — tworzą surową hierarchię opartą na parametrze self w ich metodach call, co umożliwia kompilatorowi weryfikację w czasie kompilacji, że sposób użycia zamknięcia respektuje inwarianty bezpieczeństwa pamięci swojego otoczenia.
Problem koncentruje się na rozróżnieniu między tym, jak zamknięcie przechwytuje zmienne (przez referencję lub przez wartość za pomocą move) oraz jak je wykorzystuje wewnętrznie. FnOnce wymaga self (konsumując własność), co pozwala zamknięciu na przeniesienie przechwyconych zmiennych z jego otoczenia, ale ogranicza je do jednego wywołania. FnMut wymaga &mut self, umożliwiając mutację stanu przechwyconego, ale wymagając unikalnego dostępu do samego zamknięcia. Fn wymaga &self, co umożliwia wielokrotne równoległe wywołania, ale zabrania mutacji przechwyconych zmiennych, chyba że zastosowana jest mutowalność wewnętrzna. Zamknięcie, które przenosi typ, który nie jest Copy do swojego ciała, staje się FnOnce, ponieważ pierwsze wywołanie pozostawia otoczenie w stanie przeniesionym, co unieważnia kolejne wywołania. Kandydaci często mylą słowo kluczowe move — które jedynie wymusza przechwycenie przez wartość — z cechą FnOnce, nie dostrzegając, że zamknięcie move, które zawiera tylko typy Copy, wciąż implementuje Fn.
Rozwiązanie polega na wybraniu najmniej restrykcyjnego ograniczenia cechowego koniecznego dla API. Jeśli zamknięcie jest wywoływane dokładnie raz, użyj FnOnce, aby zaakceptować najszerszą gamę zamknięć (w tym te, które konsumują swoje otoczenie). Jeśli wymagana jest wielokrotna mutacja, użyj FnMut. Dla równoległego lub powtarzanego dostępu tylko do odczytu, użyj Fn. Kompilator automatycznie wyprowadza te implementacje na podstawie analizy przechwytywania, wymagając braku ręcznej implementacji cechy.
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 nie jest Copy apply_once(consume); let mut count = 0; let mut increment = || { count += 1; }; // FnMut: mutuje przechwycone apply_mut(&mut increment); let value = 42; let print = move || println!("{}", value); // Fn: i32 jest Copy apply_fn(print); apply_fn(print); // Ważne: print jest Fn
Rozważ asynchroniczny harmonogram zadań w serwerze internetowym o wysokiej przepustowości, który przyjmuje definiowane przez użytkownika haki do przetwarzania nadchodzących żądań. API harmonogramu początkowo wymagało, aby wszystkie haki implementowały Fn, aby umożliwić potencjalne równoległe wykonanie.
Opis problemu: Nowa funkcja wymagała, aby haki utrzymywały statystyki na poziomie połączeń, co wymagało mutacji przechwyconych liczników. Programiści próbowali przekazać zamknięcia move przechwytujące zmienne mut counter, ale kompilator odrzucił je, ponieważ Fn wymaga &self, co nie może mutować własnych pól mut bez mutowalności wewnętrznej. Zespół stanął przed wyborem między poluzowaniem ograniczenia cechy a restrukturyzacją sygnatury haka.
Rozwiązanie 1: Mutowalność wewnętrzna z typami atomicznymi:
Zamień licznik u64 na AtomicU64 i przechwyć go za pomocą Arc. Zamknięcie implementuje Fn, ponieważ mutacja odbywa się poprzez operacje atomowe na &self, co nie wymaga dostępu mutowalnego do samego zamknięcia.
Zalety: Utrzymuje ograniczenie Fn, pozwala harmonogramowi na równoległe wykonywanie haków z wielu wątków bez synchronizacji samych zamknięć.
Wady: Wprowadza narzut na poziomie sprzętu oraz złożoność dotycząca porządku pamięci. Wymaga alokacji Arc nawet w przypadku użycia w jednym wątku, co narusza zasady zerokosztowych abstrakcji dla prostych liczników.
Rozwiązanie 2: Ograniczenie FnMut z sekwencyjnym wykonaniem:
Zmień API harmonogramu, aby akceptowało zamknięcia FnMut. Harmonogram przechowuje haki w Vec<Box<dyn FnMut()>> i wywołuje je sekwencyjnie, trzymając dostęp &mut.
Zalety: Zerowy narzut czasowy na mutację. Gwarancja czasowa, że nie wystąpią wyścigi danych, ponieważ system typów wymusza unikalny dostęp w czasie wywołania.
Wady: Uniemożliwia równoległe wywołania tego samego haka i komplikuje wewnętrzne przechowywanie harmonogramu (wymaga &mut self w harmonogramie). Zrywa kompatybilność z istniejącymi hakami Fn, chyba że zastosowane są implementacje ogólne.
Wybrane rozwiązanie: Wybrano rozwiązanie 2 (FnMut), ponieważ architektura serwera przetwarzała połączenia na wątek, eliminując potrzebę równoległego wykonywania haków. Zespół preferował bezpieczeństwo w czasie kompilacji nad elastyczność równoległych haków, akceptując zmianę API jako przełomową, lecz poprawną ewolucję.
Wynik: Harmonogram pomyślnie obsługiwał stanowe haki bez narzutu czasowego. System typów zapobiegł subtelnemu błędowi, w którym dwa wątki mogłyby równocześnie zwiększać licznik, który nie byłby atomowy, co byłoby możliwe, gdyby zastosowano RefCell z Fn bez właściwej synchronizacji.
Czy słowo kluczowe move w definicji zamknięcia automatycznie sprawia, że to zamknięcie implementuje FnOnce zamiast Fn lub FnMut?
Nie. Słowo kluczowe move oznacza tylko, że przechwycone zmienne są przenoszone do otoczenia zamknięcia przez wartość, a nie są pożyczane. Implementacja cechy zależy wyłącznie od tego, jak ciało zamknięcia wykorzystuje swoje przechwycenia. Jeśli zamknięcie przenosi typ, który nie jest Copy z jego otoczenia (konsumując go), implementuje FnOnce. Jeśli tylko mutuje przechwycenia, implementuje FnMut. Jeśli tylko odczytuje zmienną lub wywołuje metody &self, implementuje Fn (i inne). Dla przechwyceń przez referencję (&T lub &mut T) zamknięcie trzyma referencje. Jeśli przechwytuje &mut T, zazwyczaj implementuje FnMut, ponieważ jego wywołanie wymaga unikalnego dostępu do samego zamknięcia, aby zachować unikalność mutowalnego pożyczania. Jeśli przechwytuje &T, implementuje Fn.