Historia: Kiedy standardowa biblioteka Rust wprowadziła Cow (Clone-on-Write), celem było zbudowanie abstrakcji nad danymi, które mogą być zapożyczone lub własne, bez wymuszania natychmiastowej alokacji. Początkowo rozważano cechę Clone, ale pozwalała ona tylko na tworzenie identycznej kopii tego samego typu. Dla zapożyczonych danych, takich jak &str, klonowanie tworzyło kolejną referencję, a nie potrzebny do modyfikacji String. Cechę ToOwned zaprojektowano specjalnie, aby wyrazić relację między formami zapożyczonymi a własnymi za pomocą powiązanej cechy Owned.
Problem: Gdyby Cow polegało na Clone, konwersja Cow::Borrowed(&str) do własnej reprezentacji w celu modyfikacji wymagałaby zewnętrznej logiki konwersji. Clone nie ma mechanizmu na poziomie typu do przekształcania &str w String, zmuszając do wcześniejszych alokacji w czasie konstrukcji lub skomplikowanego ręcznego zarządzania stanem. Naruszyłoby to zasadę zerowego kosztu abstrakcji Cow, uniemożliwiając odroczenie alokacji na stercie aż do momentu, gdy modyfikacja jest rzeczywiście konieczna.
Rozwiązanie: ToOwned definiuje type Owned oraz fn to_owned(&self) -> Self::Owned, pozwalając &str określić Owned = String. To umożliwia Cow::to_mut() leniwą alokację tylko wtedy, gdy żądana jest modyfikacja. Jeśli Cow jest już Owned, zwraca mutowalną referencję do istniejących danych bez alokacji. Poniższy przykład ilustruje tę efektywność:
use std::borrow::Cow; fn normalize_whitespace(input: &str) -> Cow<'_, str> { if input.contains(" ") { let cleaned = input.replace(" ", " "); Cow::Owned(cleaned) // Alokuje tylko tutaj } else { Cow::Borrowed(input) // Zerowy koszt zapożyczenia } }
Usługa przetwarzania dzienników o dużej wydajności potrzebowała znormalizować znaczniki czasu w wpisach pochodzących z plików mapowanych w pamięci. Wejście przychodziło jako fragmenty &str wskazujące w mapę, ale około 10% wpisów wymagało dostosowania strefy czasowej, co wymagało alokacji String. Początkowa implementacja używała niestandardowego enuma z wariantami String i &str, co wymagało wyczerpującego dopasowania wzorców przy każdym punkcie dostępu i ręcznej logiki klonowania, która była podatna na błędy i szczegółowa.
Alternatywa 1: Chętna konwersja do String. Zespół rozważał konwersję wszystkich wejść na String natychmiast po ich przyjęciu. To podejście uprościło model danych i wyeliminowało obawy dotyczące czasu życia, ale nałożyło poważny narzut pamięciowy. W czasie szczytowym podwajało to zużycie pamięci dla 90% logów, które nigdy nie wymagały modyfikacji, powodując błędy OOM podczas przetwarzania plików o wielkości 10 GB.
Alternatywa 2: Użycie Arc<str> z kopiowaniem przy zapisie. Inna opcja obejmowała Arc<str> do niemutowalnego dzielenia się w połączeniu z Arc::make_mut dla modyfikacji. Choć to zapewniło semantykę współdzielonej własności, wprowadziło narzut licznika odniesień atomowych dla każdego dostępu. Dodatkowo nadal wymagało wyraźnej logiki do zarządzania przejściem od współdzielonego do mutowalnego, komplikując model zapożyczenia bez zapewnienia pożądanej ergonomii.
Alternatywa 3: Przyjęcie Cow<'_, str>. Zespół wybrał Cow, aby zbudować abstrakcję nad dwoma stanami. Warianty Borrowed wskazywały bezpośrednio w mapę pamięci, bez alokacji, podczas gdy warianty Owned przechowywały zmodyfikowane ciągi. To rozwiązanie zostało wybrane, ponieważ to_mut() opóźniało alokację do momentu pierwszej modyfikacji, zachowując zerowy koszt dla ścieżek tylko do odczytu, a jednocześnie oferując zunifikowane API.
Wynik: Parser utrzymał wysoką przepustowość, obsługując pliki dzienników o rozmiarze 10 GB z tylko 200 MB rzeczywistych alokacji na stercie. Dzięki wykorzystaniu Cow, system wyeliminował ręczne śledzenie stanu, utrzymał właściwości Send i Sync dla przetwarzania równoległego oraz zmniejszył złożoność kodu o 60% w porównaniu do podejścia niestandardowego enumu.
into_owned zwraca ToOwned::Owned przez wartość, co wymaga znanego rozmiaru w czasie kompilacji do alokacji miejsca na stosie. Choć Cow może owijać typy o nieokreślonym rozmiarze, takie jak str za pomocą Cow<'_, str>, typ Owned (String) ma określony rozmiar. Kandydaci często mylą Cow<'_, T> z Cow<'_, &T>, próbując zaimplementować cechy dla referencji zamiast typu zapożyczonego. Bez ograniczenia Sized na ToOwned::Owned, kompilator nie mógłby skonstruować wartości zwracanej dla into_owned, ponieważ próbowałby zwrócić nieokreślony str bezpośrednio, a nie kontener o określonym rozmiarze String.
Cow implementuje Borrow<Borrowed>, gdzie Borrowed: ToOwned, co pozwala Cow<String> być wyszukiwanym za pomocą &str. Jednak Borrow narzuca rygorystyczny kontrakt: jeśli dwie wartości są równe za pomocą Eq, muszą generować identyczne wartości haszowe. Kandydaci często implementują niestandardowy PartialEq dla Cow (np. porównanie bez uwzględnienia wielkości liter), zachowując standardową implementację Hash. Narusza to kontrakt, ponieważ dwa wartości Cow mogą być równe według niestandardowej logiki, ale haszować się różnie, jeśli implementacja Hash widzi oryginalne bajty. Prowadzi to do niepowodzeń wyszukiwania w HashMap, gdzie klucz wydaje się istnieć, ale nie może zostać znaleziony.
Aby skonstruować wariant Borrowed, Cow wymaga referencji &'a B z czasem życia 'a. Ogólna implementacja Default musiałaby produkować odniesienie ważne dla 'static (np. &'static str dla ""), ale &str sam w sobie nie implementuje Default, ponieważ nie ma uniwersalnej wartości referencyjnej do zwrócenia. Kandydaci często sugerują domyślne ustawienie na Cow::Borrowed(""), ale to wymagałoby ograniczenia czasu życia 'static dla B lub specjalizacji, która nie jest dostępna w stabilnym Rust. W konsekwencji standardowa biblioteka wymaga ToOwned::Owned: Default, wymuszając Cow::Owned(String::new()) (alokacja) nawet dla pustych wartości domyślnych. Kandydaci mijają tę różnicę, ponieważ mylą dostępność literałów łańcuchowych w określonych zakresach z ogólną implementacją Default dla referencji.