Po co generyki
Szybko trafisz na typy, które powinny działać dla więcej niż jednego typu elementu. "Parze" dwóch wartości jest obojętne, czy wartości są liczbami całkowitymi, tekstami, czy jakimś zdefiniowanym przez użytkownika shape. Generyki pozwalają napisać typ raz i tworzyć jego instancje z typami elementów, których potrzebuje wywołujący.
Alternatywa, czyli pisanie IntPair, StringPair, BytePair i tak dalej, szybko się nudzi i nie daje się składać. Generyki w Zero to standardowe narzędzie do tego zadania.
Funkcje generyczne
Zadeklaruj parametry typu w nawiasach ostrych między nazwą funkcji a listą parametrów:
fun makePair<T, U>(left: T, right: U) -> Pair<T, U> {
return Pair { left: left, right: right }
}
T i U to typy zastępcze: o tym, czym są, decyduje wywołujący.
W wywołaniach zwykle nie trzeba ich wypisywać, bo kompilator wnioskuje je z typów argumentów:
let pair = makePair(40, 2_u8)
Tutaj T zostaje wywnioskowane jako i32 (domyślny typ literału całkowitego bez sufiksu), a U jako u8 (z sufiksu _u8). Powstałe wiązanie pair ma typ Pair<i32, u8>.
Jeśli wnioskowanie wybrałoby złe typy, na przykład dlatego, że literały są niejednoznaczne, możesz ustalić parametry w miejscu wywołania:
let pair = makePair<u8, u8>(1, 2)
(To, czy składnia wywołania z nawiasami ostrymi wygląda dokładnie tak, może zależeć od wersji Zero; dokładny zapis sprawdź w aktualnej dokumentacji. Stałe jest to, że najpierw działa wnioskowanie.)
Generyczne shape
Shape przyjmują parametry typu w ten sam sposób:
shape Pair<T, U> {
left: T,
right: U,
}
Typ każdego pola może korzystać z parametrów. Instancje je ustalają:
let intBytes: Pair<i32, u8> = Pair { left: 40, right: 2_u8 }
let words: Pair<String, String> = Pair { left: "hi", right: "there" }
Przykład łączący generyczny shape i generyczną funkcję. Kliknij Run, aby zobaczyć wnioskowanie w działaniu:
Jedna deklaracja generycznego shape, jedna generyczna funkcja, jedno ściśle typowane miejsce wywołania. Żadnego szablonowego IntBytePair.
Aliasy typów
Gdy ten sam sparametryzowany typ pojawia się raz za razem, nadaj mu nazwę przez type:
type BytePair = Pair<u8, u8>
Teraz BytePair jest wymienne z Pair<u8, u8> wszędzie tam, gdzie można zapisać typ:
Alias to tylko sposób nazywania: nie tworzy odrębnego typu. Funkcja przyjmująca BytePair bez problemu przyjmie wartość typu Pair<u8, u8> (i odwrotnie).
Generyki w bibliotece standardowej
Ten sam mechanizm napędza dużą część biblioteki standardowej. Kilka typów, które zobaczysz w prawdziwym kodzie Zero:
Maybe<T>: wartość opcjonalna, przechowującaTalbo nic.Span<T>: pożyczony wycinek wartościT.Span<u8>to kanoniczny widok na bufor bajtów.ref<T>imutref<T>: jawne typy referencyjne na sytuacje, w których trzeba współdzielić dane bez kopiowania.
Nie musisz uczyć się ich wszystkich naraz. Cały sens generyków polega na tym, że ten sam kształt działa dla dowolnego typu elementu, jaki masz pod ręką.
Kiedy generyki się opłacają (a kiedy nie)
Sięgaj po generyki, gdy zauważysz, że piszesz tę samą funkcję lub shape dwa razy dla różnych typów elementów. Sięgaj po konkretny typ, gdy:
- Logika funkcji ma sens tylko dla konkretnego typu (na przykład parser dla
String). - Chcesz, żeby typ pojawiał się w komunikatach o błędach i ułatwiał debugowanie.
- Charakterystyka wydajności zależy od konkretnego rozmiaru w pamięci.
Generyki mają realny koszt: większe pliki binarne (każda instancja generuje nowy kod) i nieco dłuższą kompilację. W większości kodu aplikacji ten koszt jest pomijalny, ale warto o nim wiedzieć, gdy piszesz zwarty kod w stylu embedded, w którym liczy się rozmiar pliku binarnego.
Kilka słów o ograniczeniach
Niektóre systemy generyków pozwalają ograniczać parametr typu ("T musi obsługiwać ==", "T musi implementować Iterator"). W Zero przed wersją 1.0 kwestia ograniczeń wciąż się kształtuje: przykłady w oficjalnym repozytorium używają generyków w prostej formie, bez rozbudowanych ograniczeń. Gdy język się ustabilizuje, można się spodziewać składni ograniczeń w małej, regularnej formie, spójnej z resztą języka. Na razie pisz generyki działające dla każdego T, które faktycznie przekazujesz, i pozwól kompilatorowi powiedzieć, gdy jakaś operacja nie jest obsługiwana.
Dalej: enum
Generyki pozwalają parametryzować typy. Następny element leży na drugim końcu spektrum: enum, czyli zwykły typ wyliczeniowy Zero na przypadki, w których warianty nie niosą dodatkowych danych.
Najczęściej zadawane pytania
Jak działają generyki w Zero?
Zadeklaruj parametry typu w nawiasach ostrych po nazwie funkcji lub shape: fun makePair<T, U>(left: T, right: U) -> Pair<T, U> albo shape Pair<T, U> { left: T, right: U }. Kod wywołujący albo jawnie ustala parametry (Pair<i32, u8>), albo pozwala kompilatorowi wywnioskować je z argumentów wywołania.
Czy shape w Zero mogą być generyczne?
Tak. Shape może przyjmować parametry typu w tej samej składni z nawiasami ostrymi co funkcje: shape Pair<T, U> { left: T, right: U }. Każde pole może używać parametrów w swoim typie. Instancje tworzy się, zapisując sparametryzowany typ, na przykład Pair<i32, u8>.
Czy przy wywołaniu funkcji generycznej trzeba podawać parametry typu?
Zwykle nie. Kompilator wnioskuje je z typów argumentów. Wystarczy wywołać makePair(40, 2_u8): T staje się i32, a U staje się u8. Parametry możesz ustalić jawnie, gdy wnioskowanie wybrałoby zły typ albo gdy chcesz je udokumentować w miejscu wywołania.
Czym jest alias typu w Zero?
Alias typu to skrócona nazwa dłuższego wyrażenia typu. type BytePair = Pair<u8, u8> pozwala pisać BytePair wszędzie tam, gdzie inaczej trzeba by napisać Pair<u8, u8>. Alias służy wyłącznie nazywaniu: nie wprowadza nowego typu, tylko krótszy sposób odwołania się do istniejącego.
Gdzie generyki pojawiają się w bibliotece standardowej Zero?
Wszędzie tam, gdzie typ musi przechowywać dowolny typ elementu albo na nim działać. Maybe<T> dla wartości opcjonalnych, Span<u8> dla wycinków bajtów, typy kontenerów sparametryzowane typem elementu. Ten sam mechanizm generyków obsługuje typy zdefiniowane przez użytkownika i typy biblioteki standardowej.