Historia pytania
System typów Rust klasyfikuje parametry czasów życia jako "wcześnie związane" lub "późno związane". Wczesne czasy życia są rozwiązywane w momencie definicji lub instancjonowania, stając się konkretnymi i stałymi przez czas istnienia elementu. Późne czasy życia, wprowadzone za pomocą składni for<'a> w HRTB, pozostają polimorficzne aż do rzeczywistego momentu użycia, pozwalając na to, aby powiązanie funkcji lub cechy mogło działać jednolicie na dowolnym możliwym czasie życia. Ten podział powstał z potrzeby wspierania prawdziwych funkcji wyższego rzędu — tych, które akceptują wywołania zwrotne lub zamknięcia, które same manipulują pożyczonymi danymi — bez zmuszania wywołującego do zdeklarowania jednego, konkretnego czasu życia dla wszystkich wywołań.
Problem
Gdy funkcja wyższego rzędu deklaruje jawny parametr czasu życia w swoim podpisie, taki jak fn process<'a, F: Fn(&'a Data)>(f: F), czas życia 'a staje się wczesno związany. To oznacza, że kompilator wybiera konkretny czas życia 'a w miejscu wywołania na podstawie kontekstu, a typ zamknięcia F musi spełniać Fn(&'a Data) tylko dla tego konkretnego 'a. W rezultacie zamknięcie nie może być ponownie użyte z danymi o różnych czasach życia w kolejnych wywołaniach, a próba przekazania go do kontekstu, w którym czas pożyczania jest krótszy lub dłuższy, skutkuje błędem niezgodności czasów życia. To ograniczenie skutecznie uniemożliwia tworzenie elastycznych, wielokrotnego użytku abstrakcji, takich jak pule wątków czy rozdzielacze zdarzeń, które muszą przetwarzać przejrzyste pożyczki.
Rozwiązanie
HRTB rozwiązuje to, przesuwając parametr czasu życia do samego powiązania cechy: fn process<F: for<'a> Fn(&'a Data)>(f: F). Tutaj for<'a> potwierdza, że typ F implementuje cechę dla wszystkich możliwych czasów życia 'a, a nie tylko jednego. To sprawia, że czas życia jest późno związany; kompilator sprawdza, że zamknięcie jest uniwersalnie polimorficzne, co pozwala mu akceptować referencje o dowolnym czasie życia w każdym odrębnym miejscu wywołania w obrębie ciała funkcji. Mechanizm ten oddziela przechowywanie wywołania zwrotnego od długości życia danych, umożliwiając zerowe koszty abstrakcji, które bezpiecznie obsługują pożyczone dane w różnych kontekstach wykonania.
// Wczesno związane: 'a jest ustalone w miejscu wywołania, ograniczając elastyczność fn bad_process<'a, F>(f: F) where F: Fn(&'a str) -> usize, { let local = String::from("temp"); // BŁĄD: local nie żyje tak długo jak wczesno związane 'a // f(&local); } // Późno związane: HRTB pozwala, aby 'a był dowolnym czasem życia przy każdym wywołaniu fn good_process<F>(f: F) where F: for<'a> Fn(&'a str) -> usize, { let local = String::from("temp"); // OK: 'a jest instancjonowane jako czas życia &local tylko dla tego wywołania println!("{}", f(&local)); } fn main() { let count_fn = |s: &str| s.len(); good_process(count_fn); }
Opis problemu
Podczas tworzenia systemu rozdzielania zdarzeń bez kopiowania dla silnika handlowego o wysokiej częstotliwości, zespół potrzebował rejestru handlerów strategii. Ci handlerzy byli zamknięciami, które analizowały pakiety danych rynkowych bez posiadania ich, co pozwalało na przetwarzanie na poziomie mikrosekund. Centralny rozdzielacz potrzebował przechowywać te handlerzy w HashMap<String, Box<dyn Handler>> i wywoływać je z tymczasowych widoków nadchodzących buforów sieciowych. Wyzwanie polegało na tym, że bufory sieciowe miały niezwykle krótkie, związane z zakresem czasy życia, podczas gdy sam rozdzielacz był długo żyjącym singletonem. Jeśli cecha handlera była związana ze specificznym czasem życia, rozdzielacz wymagałby tego parametru czasu życia, co uniemożliwiało przechowywanie w globalnym stanie lub przetrwanie w różnych sesjach handlowych.
Rozwiązanie A: Statyczne Wiązanie z Parametryzacją Czasu Życia
Jednym z podejść było uczynienie rozdzielacza ogólnym nad 'a, przechowując Box<dyn Handler<'a>>. To wymagałoby, aby cały struktura rozdzielacza niosła czas życia 'a, co skutecznie czyniło go obiektem krótko żyjącym związanym z zakresem bufora sieciowego. Zalety obejmowały zerowe koszty abstrakcji i brak nadheadu w czasie wykonania. Jednak wady były w architekturalnych łamwiaczach: rozdzielacz nie mógł być przechowywany w lazy_static! ani wysyłany do innych wątków z niezależnymi czasami życia, co zmusiło do całkowitej przeprojektowania logiki zarządzania sesjami.
Rozwiązanie B: Wykasowane Czas życia za pomocą granic 'static
Inną opcją byłoby wymaganie, aby wszystkie dane przekazywane do handlerów były 'static lub narzucenie by handlerzy przyjmowali posiadane dane (np. Vec<u8>). To pozwoliłoby na przechowywanie handlerów jako Box<dyn Handler + 'static>. Zalety obejmowały prostotę i łatwość przechowywania. Wady obejmowały poważne kary wydajnościowe: każdy pakiet sieciowy wymagałby alokacji i memcpy, aby przekształcić go w status 'static lub posiadany, co zniszczyłoby wymagania latencji mikrosekundowych i zwiększyłoby presję pamięci podczas dużej przepustowości.
Rozwiązanie C: Wyższe Ograniczenia Cecha (HRTB)
Wybrane rozwiązanie zdefiniowało cechę handlera używając HRTB: trait Handler { fn handle(&self, data: &Packet); } zaimplementowane dla F: for<'a> Fn(&'a Packet). To pozwalało na przechowywanie Box<dyn Handler> (implicitnie 'static, ponieważ obiecuje działać dla dowolnego czasu życia) podczas nadal przekazywania efemerycznych pożyczek buforów sieciowych podczas wywołania handle. Zalety obejmowały zachowanie wydajności bez kopiowania oraz możliwość przechowywania handlerów w długo żyjącym, globalnym stanie. Wady wiązały się ze zwiększoną złożonością w granicach cech i potrzebą zapewnienia, że handlerzy przypadkowo nie przechwytują referencji z ich otoczenia, które naruszyłyby umowę for<'a>.
Wynik
Silnik handlowy skutecznie przetwarzał miliony zdarzeń na sekundę bez alokacji dla danych pakietów. Architektura oparta na HRTB pozwoliła zespołowi łączyć i dopasowywać handlerów z różnych modułów — niektóre pożyczały z stosu, inne z lokalnych aren wątków — podczas gdy kompilator zapewnił, że żaden handler nie mógł przeżyć przejrzystych danych, do których uzyskiwał dostęp, zapobiegając wyścigom danych i użyciu po zwolnieniu w wysoce współbieżnym środowisku.
Dlaczego Box<dyn Fn(&'a T)> wymusza parametr czasu życia na zawierającej strukturze, podczas gdy Box<dyn for<'a> Fn(&'a T)> tego nie robi?
W pierwszym przypadku czas życia 'a jest konkretnym parametrem typu obiektu cechy. Typ dyn Fn(&'a T) niezmiennie niesie związanie 'a, co oznacza, że obiekt cechy jest ważny tylko dla tego konkretnego czasu życia. W rezultacie każda struktura, która go zawiera, musi zadeklarować <'a>, aby udowodnić, że struktura nie żyje dłużej niż referencje, które zamknięcie może przechwycić lub zaakceptować. Z for<'a>, obiekt cechy potwierdza, że zamknięcie działa dla wszystkich czasów życia, skutecznie eliminując konkretną zależność od 'a z podpisu typu pojemnika. To pozwala, aby struktura była 'static, ponieważ zobowiązuje się do uniwersalnej stosowalności, a nie do powiązania z konkretną pożyczką.
Jak HRTB współpracują z zamknięciami, które próbują zwrócić referencje do pożyczonego wejścia?
Kandydaci często próbują napisać F: for<'a> Fn(&'a T) -> &'a U, oczekując, że czas życia wyjścia odpowiada czasowi życia wejścia. Jednak typ powiązany z cechą Fn nie jest generowany nad 'a; jest ustalony dla typu zamknięcia. Dlatego HRTB samodzielnie nie może wyrazić typu zwracanego, którego czas życia jest powiązany z argumentem wejściowym w rodzinie cech Fn. Aby to osiągnąć, należy użyć Typów Związanych z Generacją (GAT) w połączeniu z HRTB, definiując niestandardową cechę, np. trait Processor { type Output<'a>; fn process<'a>(&self, input: &'a T) -> Self::Output<'a>; }. Bez zrozumienia tego ograniczenia, kandydaci często mają problemy z błędami kompilatora stwierdzającymi, że typ zwracany "nie żyje wystarczająco długo", błędnie wierząc, że HRTB może rozwiązać problem czasu życia zwrotu w standardowych zamknięciach.
Jaka jest fundamentalna różnica między wczesno związanym czasem życia w funkcji a późno związanym czasem życia w powiązaniu cechy w zakresie monomorfizacji?
Kiedy funkcja deklaruje swój własny czas życia, jak w fn foo<'a, F: Fn(&'a T)>, czas życia 'a jest wczesno związany. Podczas monomorfizacji lub sprawdzania typów w miejscu wywołania, kompilator wybiera pojedynczy, konkretny czas życia 'a, który zaspokaja wszystkie ograniczenia dla tego konkretnego wywołania. Typ F jest następnie sprawdzany w odniesieniu do tego konkretnego 'a. W przeciwieństwie do tego, z fn foo<F: for<'a> Fn(&'a T)>, kompilator sprawdza, że F spełnia ograniczenie dla wszystkich możliwych czasów życia uniwersalnie. To oznacza, że wewnątrz foo możesz wielokrotnie wywoływać zamknięcie z argumentami o różnych czasach życia, podczas gdy w wersji wczesno związanej, wszystkie wywołania wewnątrz foo byłyby ograniczone do jednego 'a, wybranego w momencie, gdy foo zostało wywołane. Kandydaci często pomijają to, że wczesno związane czasy życia w funkcjach działają jak "stałe czasowe kompilacji" dla tego wywołania, podczas gdy późno związane czasy życia w HRTB działają jak "zmienne uniwersalnie wyznaczone", ważne dla każdej instancji.