Historia. We wczesnym Rust wszystkie typy musiały mieć rozmiar znany w czasie kompilacji, aby zapewnić alokację na stosie i efektywną semantykę wartości. Kiedy wprowadzono typy o dynamicznie określonym rozmiarze (DST) takie jak fragmenty [T] i obiekty trait dyn Trait, język potrzebował mechanizmu do rozróżnienia między parametrami generycznymi o rozmiarze i potencjalnie bez rozmiaru, nie łamiąc istniejącego kodu. Składnia ?Sized została przyjęta jako "zrelaksowane" ograniczenie, pozwalające generykom na wyraźne opt-out z domyślnego wymogu Sized, zachowując ergonomiczne domyślne ustawienie dla większości przypadków użycia, które nie dotyczą danych bez rozmiaru.
Problem. Domyślne ograniczenie T: **Sized** tworzy fundamentalne napięcie: umożliwia manipulację wartościami i obliczenia pamięci w czasie kompilacji, ale uniemożliwia funkcjom akceptowanie typu dyn Trait lub typów segmentów bezpośrednio bez pośrednictwa. To ograniczenie zmusza programistów do korzystania z Box lub referencji, nawet gdy pożądana jest semantyka własności, komplikując API, które mają na celu obsługę zarówno polimorfizmu statycznego, jak i dynamicznego. Bez ?Sized kod generyczny nie może działać z typami konkretnymi i obiektami polimorficznymi w czasie wykonywania, prowadząc do wymuszonej alokacji na stercie lub powielania interfejsów dla rozmiarów i wersji nie mających rozmiaru.
Rozwiązanie. Kompilator rozwiązuje to, egzekwując, że typy ograniczone przez ?Sized mogą być dostępne tylko przez wskaźniki grube — złożone wartości zawierające wskaźnik do danych oraz metadane czasu wykonywania (długość dla segmentów, vtable dla obiektów trait). Gdy generyk określa T: **?Sized**, kompilator zabrania operacji wymagających znanych rozmiarów, takich jak std::mem::size_of::<T>() lub przenoszenie wartości przez wartość, zapewniając, że wszystkie układy pamięci pozostają obliczalne w czasie kompilacji. Ten projekt umożliwia zero-kosztowe abstrakcje, gdzie typy o rozmiarze używają wskaźników cienkich, a typy bez rozmiaru używają wskaźników grubych, z systemem typów, który przejrzyście obsługuje rozróżnienie.
Biblioteka monitorująca systemy potrzebowała rejestrować błędy, które mogły być zarówno małymi, alokowanymi na stosie kodami błędów, jak i dużymi, dynamicznie formatowanymi komunikatami błędów implementującymi dyn **Display**. Początkowy projekt API wykorzystujący fn log<T: **Display**>(error: T) odrzucał obiekty trait, ponieważ domyślne ograniczenie Sized uniemożliwiało dyn Display spełnienie tego wymogu, stwarzając poważny problem ergonomiczny dla dynamicznego obsługiwania błędów.
Pierwsze podejście, które rozważano, to nakazanie Box<dyn **Display**> dla wszystkich typów błędów, przekształcając nawet proste kody błędów u32 w alokacje na stercie. Plusy: Ujednoliciło powierzchnię API i pozwoliło na przejęcie własności dynamicznych błędów bez skomplikowanych generyków. Minusy: Wprowadziło zależności od alokatorów nieodpowiednich dla systemów wbudowanych i dodało wymierną latencję do intensywnego przetwarzania prostych, statycznych błędów.
Druga opcja polegała na utrzymaniu dwóch osobnych metod logowania: jednej dla generycznych typów T: **Display** o rozmiarze i jednej szczególnie dla &dyn **Display**. Plusy: Unikała alokacji na stercie dla typów o rozmiarze i poprawnie wspierała dynamiczne przekazywanie dla skomplikowanych błędów. Minusy: Wymagała znacznego powielania kodu, komplikowała dokumentację publicznego API i zmuszała wywołujących do wyboru odpowiedniej metody w oparciu o wcześniejszą wiedzę o rozmiarze typu.
Zespół wybrał trzecie podejście, używając fn log<T: **?Sized** + **Display**>(error: &T), akceptując referencje zarówno do typów o rozmiarze, jak i bez rozmiaru. To rozwiązanie zostało wybrane, ponieważ zachowało jeden, spójny punkt wejścia API, wspierało środowiska no-std unikając obowiązkowej alokacji, i narzuciło zerowy narzut czasowy w porównaniu do podejścia z dwiema metodami. Implementacja generyczna skompilowała się do identycznego kodu maszynowego dla typów o rozmiarze, jak w przypadku pierwotnej wersji monomorficznej, a jednocześnie poprawnie obsługiwała obiekty trait poprzez dispatch vtable.
Z końcową paczką z powodzeniem wdrożono w mikrokontrolerach i serwerach, przetwarzając miliony heterogenicznych zdarzeń błędów bez narzutu alokacji. Ujednolicona interfejs pozwoliła programistom na bezproblemowe przekazywanie zarówno &ConcreteError, jak i &dyn Error, demonstrując, że ?Sized umożliwia prawdziwy polimorfizm zero-kosztowy w różnych środowiskach wdrożeniowych.
Dlaczego funkcja nie może zwrócić wartości typu T, gdzie T: **?Sized**?
Funkcje zwracające wartości muszą umieścić te wartości w rejestrach lub na stosie, co wymaga znanego w czasie kompilacji rozmiaru, aby wygenerować poprawny kod konwencji wywołania i zarezerwować odpowiednią przestrzeń na stosie. Ponieważ typy ?Sized, takie jak [i32] czy dyn **Debug**, mają rozmiary ustalane w czasie wykonywania, kompilator nie może wygenerować stałych sekwencji instrukcji zwrotu wymaganych dla ABI. Tylko typy wskaźników (Box<T>, &T) mają znane w czasie statycznym rozmiary (sized lub szerokość wskaźnika grubego), co czyni je jedynymi legalnymi typami zwracanymi dla danych bez rozmiaru, zasadniczo ograniczając generyki ?Sized do typów „widoku”, a nie „wartości”, które mogą być przenoszone przez wartość.
Jak **?Sized** wchodzi w interakcję z zasadami spójności dotyczącymi implementacji traitów dla referencji?
Podczas implementacji traitów dla &T, gdzie T: **?Sized**, implementacja automatycznie dotyczy wskaźników grubych (takich jak &[i32] lub &dyn Trait), ponieważ są to po prostu referencje do typów ?Sized. Kandydaci często pomijają, że impl Trait for &T where T: **?Sized** obejmuje zarówno wskaźniki cienkie, jak i grube, podczas gdy impl Trait for T where T: **Sized** tego nie robi. To rozróżnienie ma kluczowe znaczenie dla definiowania ogólnych implementacji, które działają z danymi o stałym rozmiarze i obiektami trait, zapewniając spójność w hierarchii typów bez nakładających się implementacji, które naruszałyby zasady sierot Rust.
Co odróżnia reprezentację pamięci **Box<dyn Trait>** od **&dyn Trait** poza semantyką własności?
Podczas gdy oba używają wskaźników grubych (wskaźnik + vtable), **Box<dyn Trait>** posiada alokację i przechowuje wskaźnik vtable szczególnie do celów deallocacji, podczas gdy **&dyn Trait** jedynie obserwuje dane. Kluczowe jest to, że Box<T> gdzie T: **?Sized** wymaga, aby alokator obsługiwał deallocację typów o dynamicznym rozmiarze, wykorzystując rozmiar przechowywany w vtable, podczas gdy referencje nie niosą takiej odpowiedzialności. Początkujący często nie zauważają, że Box umożliwia alokację na stercie dla typów bez rozmiaru, które nie mogą istnieć na stosie, podczas gdy referencje jedynie pożyczają istniejącą pamięć, co czyni Box niezbędnym do zwracania danych bez rozmiaru z funkcji.