Definiowanie wyliczeń
Podczas gdy struktury pozwalają na grupowanie powiązanych pól i danych, jak Rectangle
z jego szerokością i wysokością, typy wyliczeniowe pozwalają na określenie wartości jako jednej z możliwych. Na przykład, za ich pomocą możemy wyrazić, że Rectangle
(prostokąt) jest jednym z możliwych kształtów, które obejmują również Circle
(koło) i Triangle
(trójkąt).
Weźmy na tapetę pewną sytuację, w której wyliczenia są przydatniejsze i bardziej odpowiednie niż struktury. Załóżmy, że chcemy wykonywać operacje na adresach IP. Obecnie istnieją dwa standardy adresów IP: wersja czwarta i szósta. Ponieważ to jedyne możliwe typy adresów IP z jakimi napotka się nasz program: możemy wyliczyć (ang. enumerate) wszystkie możliwe wartości, stąd nazwa wyliczeń/enumeracji.
Dany adres IP może być albo wersji czwartej albo szóstej, ale nigdy obiema naraz. Ta właściwość adresów IP sprawia, że wyliczenia będą dobrym wyborem, skoro mogą przyjąć tylko jedną wartość ze wszystkich swoich wariantów. Zarówno adresy wersji czwartej, jak i wersji szóstej to nadal adresy IP, więc kod zajmujący się operacjami niezależnymi od typu adresu powinien traktować oba adresy jakby były tym samym typem.
Możemy wyrazić tę myśl w kodzie definując wyliczenie IpAddrKind
i
wymieniając wszystkie możliwe typy adresów IP: V4
oraz V6
. To są warianty tego enuma:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
IpAddrKind
jest teraz niestandardowym typem danych dostępnym dla nas w całym kodzie.
Wartości wyliczeń
Możemy stworzyć instancje obu wariantów IpAddrKind
następująco:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
Zauważ, że warianty wyliczenia dostępne są w przestrzeni jego nazwy, a więc korzystamy z dwóch dwukropków pomiędzy nazwą enuma a jego wariantem. To okazuje się być przydatne, bo teraz zarówno wartość IpAddrKind::V4
oraz IpAddrKind::V6
mają ten sam typ: IpAddrKind
. A co za tym idzie, możemy napisać funkcję przyjmującą jako argument dowolny IpAddrKind
.
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
tę funkcję wywołać możemy dowolnym wariantem:
enum IpAddrKind { V4, V6, } fn main() { let four = IpAddrKind::V4; let six = IpAddrKind::V6; route(IpAddrKind::V4); route(IpAddrKind::V6); } fn route(ip_kind: IpAddrKind) {}
Enumeracje mają jeszcze więcej zalet. Dalej wgłębiając się w nasz typ adresu IP, na chwilę obecną nie jesteśmy w stanie przechować samego adresu IP, czyli jego danych; a przechowujemy jedynie jego rodzaj. Skoro dopiero co w rozdziale 5 poznaliśmy struktury, moglibyśmy pokusić się by ich użyć, jak pokazano na listingu 6-1.
fn main() { enum IpAddrKind { V4, V6, } struct IpAddr { kind: IpAddrKind, address: String, } let home = IpAddr { kind: IpAddrKind::V4, address: String::from("127.0.0.1"), }; let loopback = IpAddr { kind: IpAddrKind::V6, address: String::from("::1"), }; }
Listing 6-1: Przechowywanie danych i wariantu IpAddrKind
adresu IP przy użyciu struktury
Zdefiniowaliśmy strukturę IpAddr
mającą dwa pola: kind
(ang. rodzaj) typu IpAddrKind
(wyliczenie zdefiniowane przez nas wcześniej) oraz address
przechowującą wartość typu String
.
Stworzyliśmy dwie instancje tej struktury.
Pierwsza, home
, do pola kind
ma przypisaną wartość IpAddrKind::V4
, zaś do address
wartość 127.0.0.1
.
Druga instancja, loopback
ma inny wariant IpAddrKind
jako wartość pola kind
,
ta wartość wynosi V6
; oraz jako adres przypisany ma String ::1
.
Tym samym użyliśmy struktury aby zgrupować wartości kind
i address
, dzięki czemu typ adresu i sam adres są ze sobą powiązane.
Jednakże, tę samą myśl jesteśmy w stanie wyrazić zwięzłej za pomocą samego enuma:
zamiast wstawiając enuma do struktury, możemy umieścić dane w każdym z wariantów enuma.
Ta nowa definicja enuma IpAddr
zawiera zarówno w wariancie V4
jak i V6
nową wartość o typie String
:
fn main() { enum IpAddr { V4(String), V6(String), } let home = IpAddr::V4(String::from("127.0.0.1")); let loopback = IpAddr::V6(String::from("::1")); }
Bezpośrednio dołączamy dane do każdego warianta enuma, więc dodatkowa struktura
staje się niepotrzebna.
Here, it’s also easier to see another detail of how enums work:
the name of each enum variant that we define also becomes a function that
constructs an instance of the enum. That is, IpAddr::V4()
is a function call
that takes a String
argument and returns an instance of the IpAddr
type. We
automatically get this constructor function defined as a result of defining the
enum.
Wykorzystanie enuma zamiast struktury niesie ze sobą jeszcze jedną korzyść:
z każdym wariantem mogą być powiązane inne typy oraz ilości danych.
Adresy IP wersji czwartej zawsze będą miały cztery liczby, o wartościach pomiędzy
0 a 255. Zapisanie adresu V4
jako czterech wartości u8
, a adresu V6
nadal jako typ String
byłoby niemożliwe przy użyciu struktury.
W przypadku wyliczeń jest to proste:
fn main() { enum IpAddr { V4(u8, u8, u8, u8), V6(String), } let home = IpAddr::V4(127, 0, 0, 1); let loopback = IpAddr::V6(String::from("::1")); }
Pokazaliśmy kilka różnych sposobów definiowania struktur danych przechowujących
adresy IP czwartej i szóstej wersji. Jak się jednak okazuje, przechowywanie adresów IP
wraz z rodzajem ich wersji jest tak powszechne, że biblioteka standardowa ma gotową
definicję czekającą tylko na to, aby jej użyc!
Spójrzmy na definicję IpAddr
w bibliotece standardowej: ma dokładnie taką samą nazwę
i takie sama warianty, ale przechowuje dane o adresach w postaci dwóch różnych struktur
umieszczonych w wyliczeniach. Każda struktura zdefiniowana jest inaczej dla każdego wariantu.
#![allow(unused)] fn main() { struct Ipv4Addr { // --snip-- } struct Ipv6Addr { // --snip-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
Jak demonstruje powyższy wycinek kodu, do wariantów enuma umieścić można każdy typ danych, np.: ciągi znaków (stringi), typy liczbowe, lub struktury. W enumie umieścić możesz nawet innego enuma! Ponadto, typy w standardowej bibliotece często nie są wcale bardziej skomplikowane od tego co wymyślisz własnoręcznie.
Mimo że standardowa biblioteka definuje własny IpAddr
, nadal możemy
stworzyć i używać własnej definicji bez żadnych konfliktów, bo nie zaimportowaliśmy
definicji z biblioteki standardowej do zasięgu (ang. scope).
Więcej o importowaniu typów do zasięgu w rozdziale 7.
Spójrzmy na innego enuma, na przykładzie listingu 6-2: ten w swoich wariantach zawierał będzie wiele różnych typów.
enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() {}
Listing 6-2: Enum Message
, którego warianty zawierają
różne ilości i typy wartości
Ten enum definiuje cztery warianty, każdy z innymi typami:
Quit
, nie zawiera w sobie żadnych danych.Move
zawiera nazwane pola, zupełnie jak struktura.Write
zawiera w sobie jedenString
.ChangeColor
zawiera trzy wartości o typachi32
.
Na przykładzie tego w listingu 6-2 widzimy, że definiowanie wariantów enuma, jest podobne do definiowania
kilku struktur, z taką różnicą, że nie używamy słowa kluczowego struct
oraz,
wszystkie warianty zgrupowane są w typie Message
.
Poniższe struktury mogłyby zawierać te same dane co powyższe typy enuma:
struct QuitMessage; // unit struct struct MoveMessage { x: i32, y: i32, } struct WriteMessage(String); // tuple struct struct ChangeColorMessage(i32, i32, i32); // tuple struct fn main() {}
Ale jeśli użylibyśmy różnych struktur, to każda z nich miałaby inny typ.
Zdefiniowanie funkcji mogącej przyjąć jako parametr
różne rodzaje wiadomości, nie byłoby tak proste jak przy użyciu
enuma Message
zdefiniowanego w listingu 6-2, który jest tylko jednym typem.
Jest jeszcze jedno podobieństwo między wyliczeniami, a strukturami: tak samo, jak
zdefiniować można metody na strukturach używając bloku impl
, zdefiniować można
metody na wyliczeniach. Spójrzmy na metodę o nazwie call
zdefinowaną na naszym enumie Message
:
fn main() { enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } impl Message { fn call(&self) { // method body would be defined here } } let m = Message::Write(String::from("hello")); m.call(); }
Ciało metody użyje wartości self
, aby uzyskać wariant enuma, na którym ta metoda została wywołana.
W tym przykładzie przypisaliśmy do zmiennej m
wartość
Message::Write(String::from("witaj"))
równoważną parametrowi self
,
który znajduje się w ciele metody call
, kiedy ta uruchomiona zostanie poprzez m.call()
.
Spójrzmy na kolejne wyliczenie z biblioteki standardowej,
które jest bardzo przydatne i często używane, czyli Option
.
Wyliczenie Option
i jego przewagi nad wartościami pustymi (ang. null)
W tej sekcji znajduje się analiza typu Option
, kolejnego enuma z biblioteki standardowej.
Typ Option
używany jest w wielu miejscach, ponieważ opisuje bardzo częsty scenariusz, w którym dana wartość może być zarówno czymś albo niczym.
Na przykład, jeśli zażądamy pierwszego elementu niepustej listy, to otrzymamy jego wartość. Jeśli zażądamy pierwszego elementu pustej listy, nie otrzymamy nic. Wyrażenie tej koncepcji za pomocą systemu typów Rusta sprawia, że kompilator jest w stanie sprawdzić, czy wzięliśmy pod uwagę wszystkie przypadki, co pozwala zapobiegać błędom (bugom) pojawiającym się bardzo często w innych językach programowania.
Przez konstrukcję języka programowania często rozumie się decyzje o zamieszczeniu w nim jakichś funkcji,
ale to jakie funkcje się w nim nie znalazły, jest również istotne.
Rust nie ma wartości null
znanej z wielu innych języków.
Null to wartość oznaczająca brak wartości - jest to wartośc pusta.
W językach z pustymi wartościami, zmienne zawsze mogą być jednym z dwóch stanów: null lub nie-null.
W swojej prezentacji z 2009 roku "Puste referencje: Błąd warty miliard dolarów" (oryg. „Null References: The Billion Dollar Mistake,”), Tony Hoare, wynalazca nulla, miał to do powiedzenia:
Ten błąd warty jest miliard dolarów. W tamtych czasach projektowałem pierwszy kompleksowy system typów referencji dla języków obiektowych. Moim celem była możliwość gwarancji, że każde użycie referencji byłoby całkowicie bezpieczne, co automatycznie sprawdzałby kompilator. Ale nie mogłem oprzeć się pokusie implementacji pustych referencji, z prostej przyczyny: było to łatwe do zaimplementowania. Ta decyzja doprowadziła do tylu niezliczonych błędów, luk i awarii systemów, że łącznie przez ostatnie czterdzieści lat pewnie spowodowała ból i szkody warte miliard dolarów.
Problem z pustymi wartościami polega na tym, że kiedy spróbujesz użyć nulla, jak gdyby nie był nullem, spowodujesz błąd. Własność null i nie-null rozpowszechniła się tak szeroko, że popełnienie takiego błędu jest bardzo łatwe.
Jednak pojęcie jakie null próbuje wyrazić jest samo w sobie przydatne: wartość pusta jest albo obecnie nieważna albo nieobecna.
Problemem nie jest sam pomysł, ale ta konkretna implementacja pustych wartości.
Rust nie ma jako takich pustych wartości null, ale istnieje w Ruście wyliczenie,
które wyraża pojęcie obecności lub braku obecności danej wartości.
Tym wyliczeniem jest Option<T>
zdefiniowane przez bibliotekę standardową]option
w następujący sposób:
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Enum Option<T>
jest tak przydatny, że znajduje się w preludzie (prelude);
nie musisz samemu go importować. Ponadto, to samo dotyczy jego wariantów:
możesz użyć Some
i None
, bez używania prefiksu Option::
.
Enum Option<T>
jest zwykłym wyliczeniem, a Some(T)
oraz None
to nadal zwykłe warianty Option<T>
.
Składnia <T>
jest funkcjonalnością Rusta, której jeszcze nie omówiliśmy.
Jest to tzw. parametr generyczny. Bardziej szczegółowo je omówimy w rozdziale 10.
Póki co, wszystko co musisz o nich wiedzieć to, że <T>
oznacza, że wariant Some
enuma Option
może zawierać w sobie jedną wartość dowolnego typu.
Co więcej, Option<T>
jest różnych typów dla różnych, konkretnych typów T
.
Oto niektóre przykłady używania wartości Option
do przechowywania typów liczbowych oraz łańcuchowych (stringów):
fn main() { let some_number = Some(5); let some_char = Some('e'); let absent_number: Option<i32> = None; }
The type of some_number
is Option<i32>
. The type of some_char
is
Option<char>
, which is a different type. Rust can infer these types because
we’ve specified a value inside the Some
variant. For absent_number
, Rust
requires us to annotate the overall Option
type: the compiler can’t infer the
type that the corresponding Some
variant will hold by looking only at a
None
value. Here, we tell Rust that we mean for absent_number
to be of type
Option<i32>
.
Widząc Some
, wiemy że wartość jest obecna oraz że znajduje się ona w Some
. Za to None
, w pewnym sensie oznacza to samo co null, czyli brak prawidłowej wartości. To dlaczego Option<T>
jest lepszy od nulla?
W skrócie, Option<T>
i T
(gdzie T
może być dowolnym typem)
są różnymi typami, więc kompilator nie pozwoli nam użyć Option<T>
tak jakby
była to prawidłowa wartość typu T
. Na przykład, ten kod się nie skompiluje, bo próbujemy w nim
dodać wartość typu i8
do typu Option<i8>
:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
Uruchamiając ten kod, otrzymamy następujący komunikat o błędzie:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ no implementation for `i8 + Option<i8>`
|
= help: the trait `Add<Option<i8>>` is not implemented for `i8`
= help: the following other types implement trait `Add<Rhs>`:
<&'a f32 as Add<f32>>
<&'a f64 as Add<f64>>
<&'a i128 as Add<i128>>
<&'a i16 as Add<i16>>
<&'a i32 as Add<i32>>
<&'a i64 as Add<i64>>
<&'a i8 as Add<i8>>
<&'a isize as Add<isize>>
and 48 others
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
Bezlitośnie! Ten komunikat oznacza, że Rust nie wie jak ma dodać do siebie typ i8
oraz typ Option<i8>
, ponieważ to dwa różne typy. Kiedy w Ruście posługujemy się typem takim jak i8
, kompilator zawsze
gwarantuje, że jest to prawidłowa wartość. Możemy być pewni swego i kontynuować kodowanie bez
sprawdzania czy dana wartość jest pusta w środku. Jedynie kiedy posługujemy się typem Option<i8>
(czy jakimkolwiek innym typem zawarym wewnątrz Option
) musimy się upewnić, czy
w środku znajduje się prawidłowa wartość, a kompilator sprawdzi, czy
na pewno wzięliśmy obie sytuacje pod uwagę.
Innymi słowy, musisz przekonwertować wartość typu Option<T>
na T
zanim
przyjmie ona zachowania charakterystyczne dla typu T
.
W większości przypadków pozwala to na pozbycie się
jednego z najczęstszych problemów z nullem: zakładanie, że jakaś wartość istnieje, kiedy
tak na prawdę nie istnieje.
Wyeliminowanie ryzyka nieprawidłowego założenia, że dana wartość nie jest pusta, daje nam większą pewność co do napisanego kodu. Aby dana wartośc mogła nie istnieć musisz wyrazić na to zgodę
definiując daną wartość jako typ Option<T>
.
Następnie używając tę wartość do twoich obowiązków należeć będzie powzięcie innych kroków,
dla przypadków kiedy ta wartość jest pusta.
Gdziekolwiek, gdzie dany typ wartości nie jest typem Option<T>
, możesz
bezpiecznie założyć, że ta wartość nie jest pusta.
To była przemyślana decyzja w konstrukcji Rusta mająca na celu ograniczenie wszechobecności
nulla oraz zwiększenie bezpieczeństwa kodu napisanego w Ruście.
Więc mając wartość typu Option<T>
, jak można dostać się do wartości typu T
znajdującej się w wariancie Some
? Enum Option<T>
ma wiele przydatnych metod odpowiednich dla różnych sytuacji;
możesz je sprawdzić w dokumentacji. Zapoznanie się z metodami
typu Option<T>
będzie bardzo przydatne w twojej przygodzie z Rustem.
Zwykle, aby użyć wartości typu Option<T>
, musisz napisać kod sprawdzający oba warianty.
Jedna część kodu będzie odpowiedzialna za wariant Some(T)
- ta część będzie miała
dostęp do wewnętrznej wartości typu T
.
Druga część będzie odpowiedzialna za wariant None
- ten kod oczywiście nie będzie miał dostępu
do wartości typu T
. Wyrażenie match
jest konstruktem umożliwiającym kontrolę przepływu (ang. control flow)
pozwalającym na tego typu zachowanie.
Wyrażenie match
uruchomi różny kod w zależności od tego,
jakim wariantem jest dany enum. Ten kod będzie będzie miał dostęp do danych znajdujących się w danym enumie.