Kompilator Rust egzekwuje regułę osierocenia (kluczowy element systemu koherencji), aby zagwarantować, że każda para cecha-typ ma co najwyżej jedną implementację w całej grafie zależności. Ta zasada wymaga, aby blok impl był ważny tylko wtedy, gdy albo cecha, która jest implementowana, albo typ, który otrzymuje implementację, są zdefiniowane w bieżącym crate'ie, określanym jako "lokalny" crate. Zabraniając implementacji, w których zarówno cecha, jak i typ są obce (zewnętrzne), Rust zapobiega sytuacjom, w których dwa niezależne crate'y mogłyby wprowadzić sprzeczne implementacje dla tego samego celu, co prowadziłoby do nieokreślonego zachowania lub nieodwracalnych niejasności w projektach zależnych. Wyjątek "typ lokalny" umożliwia programistom implementowanie zewnętrznej cechy dla lokalnego typu (umożliwiając standardowe operatory na niestandardowych strukturach) lub lokalnej cechy dla zewnętrznego typu (umożliwiając metody rozszerzające), zapewniając jednoznaczną monomorfizację i abstrakcję przy zerowym koszcie bez tabel dysponujących w czasie wykonania.
Nasz zespół budował bibliotekę serwera GraphQL o wysokiej wydajności, która musiała serializować definicje schematu do JSON przy użyciu frameworka serde. Musieliśmy zaimplementować cechę Serialize z serde dla naszej lokalnej struktury Schema, co było proste, ponieważ typ był lokalny. Jednak potrzebowaliśmy również niestandardowego formatowania dla typu Document z zewnętrznego crate'a graphql_parser, aby zintegrować go z naszym systemem logowania za pomocą standardowej cechy Display. To stworzyło napięcie projektowe, ponieważ zarówno Document, jak i Display były obce, a obawialiśmy się przyszłych awarii, jeśli główny crate dodałby własną implementację Display, potencjalnie tworząc naruszenie koherencji dla naszych użytkowników.
Pierwszym rozwiązaniem, które rozważyliśmy, był wzorzec Newtype, który owinął graphql_parser::Document w strukturę krotki struct DocWrapper(graphql_parser::Document) i zaimplementował Display dla DocWrapper.
To podejście idealnie respektuje regułę osierocenia, ponieważ DocWrapper jest typem lokalnym, a Rust gwarantuje zerowy koszt abstrakcji dla nowych typów bez narzutu czasu wykonania. Pozwala nam to utrzymać pełną kontrolę nad API i zapobiega wszelkim przyszłym konfliktom górnym. Jednak wprowadza to znaczną ilość szablonów dla konwersji i degraduje ergonomię, gdyż użytkownicy muszą ręcznie owinąć instancje lub polegać na dostarczonych implementacjach From, co może zagracić publiczne API typami opakowującymi, które ujawniają szczegóły implementacji.
Drugie rozwiązanie polegało na stworzeniu cechy rozszerzającej, GraphQLDisplay, zdefiniowanej lokalnie w naszym crate'ie, i zaimplementowaniu jej bezpośrednio dla obcego typu Document.
To jest legalne zgodnie z regułą osierocenia, ponieważ sama cecha jest lokalna, mimo że typ jest obcy, i unika ergonomicznych problemów z typami opakowującymi, umożliwiając składnię łańcucha metod. Krytyczną wadą jest to, że nie integruje się z standardowymi makrami formatowania Rust, takimi jak format! lub println!, które wymagają cechy Display; użytkownicy musieliby zaimportować naszą niestandardową cechę i wywołać konkretną metodę, co tworzyłoby rozdzielone doświadczenie, które nie jest zgodne ze standardowymi konwencjami Rust.
Ostatecznie wybraliśmy wzorzec Newtype dla typu Document, ponieważ długoterminowa stabilność i integracja z biblioteką standardową przeważały nad krótkoterminowymi kosztami ergonomicznymi. Dzięki użyciu DocWrapper zapewniliśmy, że nasze logowanie błędów może wykorzystywać standardowe narzędzia formatowania bez niestandardowych makr lub importów cech. Dla typu Schema po prostu dziedziczyliśmy Serialize, ponieważ zarówno typ, jak i makro dziedziczenia były lokalne. Efektem było spójne, przyszłościowe API, w którym wszystkie rozwiązania cech były jednoznaczne w czasie kompilacji, kompilacja pozostawała szybka z powodu braku narzutu na rozwiązanie niejasności i wyeliminowaliśmy ryzyko problemów z zależnościami diamentowymi, jeśli graphql_parser kiedykolwiek wprowadzi swój własny mechanizm Display.
Jak reguła osierocenia rozciąga się na typy generyczne, takie jak Vec<T>, i dlaczego dozwolone jest implementowanie obcej cechy dla Vec<LocalType>, podczas gdy Vec<ForeignType> jest zabronione?
Reguła osierocenia ma zastosowanie do typów generycznych poprzez koncepcję "pokrycia typem lokalnym", co wymaga, aby przynajmniej jeden argument typowy w strukturze generycznej był lokalny dla bieżącego crate'a. Dlatego impl ForeignTrait for Vec<LocalType> jest ważne, ponieważ LocalType zakotwicza implementację w lokalnym crate'ie, zapewniając, że żaden inny crate nie może napisać sprzecznej implementacji dla tego konkretnego typu. Z drugiej strony impl ForeignTrait for Vec<ForeignType> narusza tę regułę, ponieważ zarówno cecha, jak i wszystkie argumenty typowe są zewnętrzne, co stwarza ryzyko, że crate definiujący ForeignType mógłby później zaimplementować tę samą cechę dla Vec<ForeignType>, prowadząc do konfliktów koherencji. Kandydaci często umykają, że to pokrycie dotyczy rekurencyjnie zagnieżdżonych generików, ale nie rozciąga się na sam kontener generyczny, chyba że ten kontener jest również zdefiniowany lokalnie.
Dlaczego implementacja ogólna (taka jak impl<T> Trait for T where T: ToString) w górnym crate'ie uniemożliwia dolnym crate'om implementowanie tej cechy dla określonych typów, nawet lokalnych?
Ogólna implementacja zapewnia domyślne zachowanie dla wszystkich typów spełniających określone ograniczenia cech, a zasady koherencji Rust zabraniają jakiejkolwiek konkretnej implementacji, która kolidowałaby z istniejącą ogólną implementacją. Jeśli górny crate dostarcza impl<T> Serialize for T where T: ToString, dolne crate'y nie mogą zaimplementować Serialize dla żadnego typu implementującego ToString, nawet jeśli ten typ jest lokalny, ponieważ kompilator nie może zapewnić, że ogólna impl i konkretna impl są wzajemnie wykluczające. To różni się od reguły osierocenia; podczas gdy reguła osierocenia reguluje kto może pisać implementację, reguła nakładania się reguluje, czy dwie ważne implementacje mogą współistnieć w tej samej przestrzeni nazw. Kandydaci często mylą te koncepcje, próbując stworzyć konkretne impl, które są składniowo ważne zgodnie z regułami osierocenia, ale odrzucane z powodu nakładających się ogólnych implementacji w górnym crate'ie.
Jakie szczególne traktowanie przyjmują fundamentalne cechy, takie jak Fn, FnMut i FnOnce, w odniesieniu do reguły osierocenia, i dlaczego pozwala to zamknięciom implementować te cechy bez naruszania koherencji?
Rodzina cech Fn jest klasyfikowana jako "fundamentalna", co łagodzi regułę osierocenia, aby pozwolić na implementacje tych cech dla obcych typów, gdy implementacja obejmuje lokalne typy w parametrach generycznych cechy. Ta "odwrócona" reguła zasadniczo traktuje cechę jako lokalną w celach koherencyjnych przy określaniu, czy implementacja jest dozwolona. Na przykład, zamknięcie zdefiniowane w twoim crate'ie ma unikalny, nie nazwany typ, który jest lokalny dla twojego crate'a, a implementacja FnOnce dla tego zamknięcia jest dozwolona, nawet jeśli FnOnce jest zdefiniowane w bibliotece standardowej, a typ zamknięcia jest nieprzezroczysty. Kandydaci często umykają ten mechanizm, ponieważ jest to szczegół implementacyjny dotyczący tego, jak Rust obsługuje zamknięcia, ale zrozumienie tego wyjaśnia, dlaczego zamknięcia mogą uchwycić lokalne środowiska i implementować obce cechy bez wymagania nowych typów opakowujących lub wywoływania błędów koherencji.