Definiowanie typu wyliczeniowego
Podczas gdy struktury dają ci sposób grupowania powiązanych pól i danych, jak Rectangle z jego width i height, enums dają ci sposób powiedzenia, że wartość jest jedną z możliwych zestawów wartości. Na przykład, możemy chcieć powiedzieć, że Rectangle jest jednym z możliwych kształtów, które obejmują również Circle i Triangle. Aby to zrobić, Rust pozwala nam zakodować te możliwości jako enum.
Przyjrzyjmy się sytuacji, którą możemy chcieć wyrazić w kodzie i zobaczmy, dlaczego enums są użyteczne i bardziej odpowiednie niż struktury w tym przypadku. Powiedzmy, że musimy pracować z adresami IP. Obecnie używane są dwa główne standardy dla adresów IP: wersja czwarta i wersja szósta. Ponieważ są to jedyne możliwości dla adresu IP, z którymi nasz program się spotka, możemy wyliczyć wszystkie możliwe warianty, skąd pochodzi nazwa „enumeration”.
Każdy adres IP może być adresem wersji czwartej lub wersji szóstej, ale nie oboma jednocześnie. Ta właściwość adresów IP sprawia, że struktura danych enum jest odpowiednia, ponieważ wartość enum może być tylko jednym ze swoich wariantów. Zarówno adresy wersji czwartej, jak i wersji szóstej są nadal zasadniczo adresami IP, więc powinny być traktowane jako ten sam typ, gdy kod obsługuje sytuacje, które mają zastosowanie do każdego rodzaju adresu IP.
Możemy wyrazić tę koncepcję w kodzie, definiując wyliczenie IpAddrKind i wymieniając możliwe rodzaje adresów IP, którymi mogą być V4 i V6. Są to warianty enum:
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 to teraz niestandardowy typ danych, którego możemy używać w innych miejscach naszego kodu.
Wartości wyliczeniowe
Możemy tworzyć instancje każdego z dwóch wariantów IpAddrKind w następujący sposób:
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 enum są umieszczone w przestrzeni nazw pod jego identyfikatorem, a my używamy podwójnego dwukropka do rozdzielenia tych dwóch. Jest to przydatne, ponieważ teraz obie wartości IpAddrKind::V4 i IpAddrKind::V6 są tego samego typu: IpAddrKind. Możemy wtedy, na przykład, zdefiniować funkcję, która przyjmuje 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) {}
I możemy wywołać tę funkcję z 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) {}
Używanie typów wyliczeniowych (enums) ma jeszcze więcej zalet. Myśląc więcej o naszym typie adresu IP, w tej chwili nie mamy sposobu na przechowywanie rzeczywistych danych adresu IP; wiemy tylko, jakiego jest rodzaju. Biorąc pod uwagę, że właśnie dowiedziałeś się o strukturach w Rozdziale 5, możesz być skłonny do rozwiązania tego problemu za pomocą struktur, jak pokazano w 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"),
};
}
Tutaj zdefiniowaliśmy strukturę IpAddr, która ma dwa pola: pole kind typu IpAddrKind (enum, które zdefiniowaliśmy wcześniej) i pole address typu String. Mamy dwie instancje tej struktury. Pierwsza to home i ma wartość IpAddrKind::V4 jako swoje kind z powiązanymi danymi adresu 127.0.0.1. Druga instancja to loopback. Ma ona inny wariant IpAddrKind jako swoją wartość kind, V6, i ma powiązany z nim adres ::1. Użyliśmy struktury do połączenia wartości kind i address, więc teraz wariant jest powiązany z wartością.
Jednakże, reprezentowanie tej samej koncepcji używając tylko typu wyliczeniowego jest bardziej zwięzłe: zamiast typu wyliczeniowego wewnątrz struktury, możemy umieścić dane bezpośrednio w każdym wariancie typu wyliczeniowego. Ta nowa definicja typu wyliczeniowego IpAddr mówi, że zarówno warianty V4, jak i V6 będą miały powiązane wartości 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"));
}
Dane do każdego wariantu enum dołączamy bezpośrednio, więc nie ma potrzeby używania dodatkowej struktury. W tym przypadku łatwiej jest również zauważyć inną cechę działania enumów: nazwa każdego zdefiniowanego przez nas wariantu enum staje się również funkcją, która konstruuje instancję enum. Oznacza to, że IpAddr::V4() jest wywołaniem funkcji, która przyjmuje argument String i zwraca instancję typu IpAddr. Tę funkcję konstruującą otrzymujemy automatycznie w wyniku zdefiniowania enum.
Istnieje jeszcze jedna zaleta używania typu wyliczeniowego zamiast struktury: każdy wariant może mieć różne typy i ilości powiązanych danych. Adresy IP wersji czwartej zawsze będą miały cztery komponenty numeryczne o wartościach od 0 do 255. Gdybyśmy chcieli przechowywać adresy V4 jako cztery wartości u8, ale nadal wyrażać adresy V6 jako pojedynczą wartość String, nie bylibyśmy w stanie tego zrobić za pomocą struktury. Typy wyliczeniowe z łatwością radzą sobie z tym przypadkiem:
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 do przechowywania adresów IP wersji czwartej i szóstej. Jednak, jak się okazuje, chęć przechowywania adresów IP i kodowania ich rodzaju jest tak powszechna, że biblioteka standardowa ma definicję, której możemy użyć! Przyjrzyjmy się, jak biblioteka standardowa definiuje IpAddr. Ma ona dokładnie ten sam typ wyliczeniowy i warianty, które zdefiniowaliśmy i użyliśmy, ale osadza dane adresowe w wariantach w postaci dwóch różnych struktur, które są definiowane inaczej dla każdego wariantu:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Ten kod ilustruje, że wariant enum może zawierać dowolny rodzaj danych: ciągi znaków, typy numeryczne lub struktury, na przykład. Może nawet zawierać inny enum! Ponadto, typy biblioteki standardowej często nie są o wiele bardziej skomplikowane niż to, co sam byś wymyślił.
Zauważ, że choć biblioteka standardowa zawiera definicję dla IpAddr, nadal możemy tworzyć i używać własnej definicji bez konfliktu, ponieważ nie wprowadziliśmy definicji z biblioteki standardowej do naszego zasięgu. Więcej o wprowadzaniu typów do zasięgu omówimy w Rozdziale 7.
Przyjrzyjmy się innemu przykładowi typu wyliczeniowego w Listingu 6-2: ten ma szeroką gamę typów osadzonych w swoich wariantach.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Ten typ wyliczeniowy ma cztery warianty o różnych typach:
Quit: Nie ma z nim związanych żadnych danychMove: Ma nazwane pola, podobnie jak strukturaWrite: Zawiera pojedynczyStringChangeColor: Zawiera trzy wartościi32
Definiowanie enum z wariantami takimi jak te w Listingu 6-2 jest podobne do definiowania różnych rodzajów definicji struktur, z tą różnicą, że enum nie używa słowa kluczowego struct, a wszystkie warianty są zgrupowane pod typem Message. Następujące struktury mogłyby przechowywać te same dane, co poprzednie warianty enum:
struct QuitMessage; // struktura jednostkowa
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // struktura krotki
struct ChangeColorMessage(i32, i32, i32); // struktura krotki
fn main() {}
Ale gdybyśmy użyli różnych struktur, z których każda ma swój własny typ, nie moglibyśmy tak łatwo zdefiniować funkcji, która przyjmowałaby dowolny z tych rodzajów wiadomości, jak w przypadku typu wyliczeniowego Message zdefiniowanego w Listingu 6-2, który jest pojedynczym typem.
Istnieje jeszcze jedno podobieństwo między typami wyliczeniowymi a strukturami: tak jak jesteśmy w stanie definiować metody w strukturach za pomocą impl, tak samo jesteśmy w stanie definiować metody w typach wyliczeniowych. Oto metoda o nazwie call, którą moglibyśmy zdefiniować w naszym typie wyliczeniowym Message:
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// treść metody zostałaby zdefiniowana tutaj
}
}
let m = Message::Write(String::from("hello"));
m.call();
}
Treść metody użyłaby self, aby uzyskać wartość, na której wywołaliśmy metodę. W tym przykładzie utworzyliśmy zmienną m, która ma wartość Message::Write(String::from("hello")), i to właśnie będzie self w treści metody call, gdy zostanie uruchomione m.call().
Przyjrzyjmy się innemu enumowi w bibliotece standardowej, który jest bardzo powszechny i użyteczny: Option.
Typ wyliczeniowy Option
Ta sekcja bada studium przypadku Option, które jest innym typem wyliczeniowym zdefiniowanym przez bibliotekę standardową. Typ Option koduje bardzo powszechny scenariusz, w którym wartość może być czymś, albo może być niczym.
Na przykład, jeśli zażądasz pierwszego elementu z niepustej listy, otrzymasz wartość. Jeśli zażądasz pierwszego elementu z pustej listy, otrzymasz nic. Wyrażenie tej koncepcji w kategoriach systemu typów oznacza, że kompilator może sprawdzić, czy obsłużyłeś wszystkie przypadki, które powinieneś obsłużyć; ta funkcjonalność może zapobiegać błędom, które są niezwykle powszechne w innych językach programowania.
Projektowanie języków programowania często rozpatruje się w kategoriach tego, jakie funkcje się uwzględnia, ale funkcje, które się wyklucza, również są ważne. Rust nie posiada funkcji null, którą ma wiele innych języków. Null to wartość, która oznacza, że nie ma tam żadnej wartości. W językach z null, zmienne mogą zawsze być w jednym z dwóch stanów: null lub nie-null.
W swojej prezentacji z 2009 roku „Null References: The Billion Dollar Mistake” Tony Hoare, wynalazca wartości null, powiedział:
Nazywam to moim miliardowym błędem. W tamtym czasie projektowałem pierwszy kompleksowy system typów dla referencji w języku obiektowym. Moim celem było zapewnienie, że wszelkie użycie referencji powinno być absolutnie bezpieczne, z automatycznym sprawdzaniem wykonywanym przez kompilator. Ale nie mogłem oprzeć się pokusie wprowadzenia referencji null, po prostu dlatego, że była tak łatwa do zaimplementowania. Doprowadziło to do niezliczonych błędów, luk w zabezpieczeniach i awarii systemów, które prawdopodobnie spowodowały miliard dolarów bólu i szkód w ciągu ostatnich czterdziestu lat.
Problem z wartościami null polega na tym, że jeśli spróbujesz użyć wartości null jako wartości nie-null, otrzymasz jakiś błąd. Ponieważ ta właściwość null lub nie-null jest wszechobecna, niezwykle łatwo jest popełnić tego rodzaju błąd.
Jednak koncepcja, którą null próbuje wyrazić, jest nadal użyteczna: null to wartość, która jest obecnie nieprawidłowa lub nieobecna z jakiegoś powodu.
Problem nie leży w samej koncepcji, lecz w konkretnej implementacji. W związku z tym Rust nie posiada wartości null, ale ma enum, który może kodować koncepcję obecności lub braku wartości. Tym enumem jest Option<T>, i jest on zdefiniowany przez bibliotekę standardową w następujący sposób:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Typ wyliczeniowy Option<T> jest tak użyteczny, że jest nawet włączony do preambuly; nie musisz jawnie wprowadzać go do zasięgu. Jego warianty są również włączone do preambuly: możesz używać Some i None bezpośrednio bez prefiksu Option::. Typ wyliczeniowy Option<T> jest nadal zwykłym typem wyliczeniowym, a Some(T) i None są nadal wariantami typu Option<T>.
Składnia <T> to cecha Rust, o której jeszcze nie mówiliśmy. Jest to ogólny parametr typu, a o ogólnych typach szerzej omówimy w Rozdziale 10. Na razie musisz wiedzieć, że <T> oznacza, że wariant Some typu wyliczeniowego Option może przechowywać pojedynczy element danych dowolnego typu, a każdy konkretny typ, który zostanie użyty zamiast T, sprawia, że cały typ Option<T> staje się innym typem. Oto kilka przykładów użycia wartości Option do przechowywania typów liczbowych i znakowych:
fn main() {
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
}
Typ some_number to Option<i32>. Typ some_char to Option<char>, co jest innym typem. Rust może wywnioskować te typy, ponieważ określiliśmy wartość w wariancie Some. Dla absent_number, Rust wymaga od nas adnotacji całego typu Option: kompilator nie może wywnioskować typu, który będzie przechowywał odpowiadający mu wariant Some, patrząc tylko na wartość None. Tutaj mówimy Rustowi, że chcemy, aby absent_number był typu Option<i32>.
Kiedy mamy wartość Some, wiemy, że wartość jest obecna, a wartość jest przechowywana w Some. Kiedy mamy wartość None, w pewnym sensie oznacza to to samo co null: nie mamy ważnej wartości. Dlaczego więc Option<T> jest lepsze niż null?
Krótko mówiąc, ponieważ Option<T> i T (gdzie T może być dowolnym typem) są różnymi typami, kompilator nie pozwoli nam użyć wartości Option<T> tak, jakby była ona na pewno prawidłową wartością. Na przykład ten kod nie skompiluje się, ponieważ próbuje dodać i8 do Option<i8>:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
Jeśli uruchomimy ten kod, otrzymamy komunikat o błędzie podobny do tego:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` do `i8`
--> src/main.rs:5:17
|
5 | let sum = x + y;
| ^ brak implementacji dla `i8 + Option<i8>`
|
= help: cecha `Add<Option<i8>>` nie jest zaimplementowana dla `i8`
= help: następujące inne typy implementują cechę `Add<Rhs>`:
`&i8` implementuje `Add<i8>`
`&i8` implementuje `Add`
`i8` implementuje `Add<&i8>`
`i8` implementuje `Add`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Intensywnie! W efekcie ten komunikat o błędzie oznacza, że Rust nie rozumie, jak dodać i8 i Option<i8>, ponieważ są to różne typy. Kiedy mamy wartość typu takiego jak i8 w Rust, kompilator zapewni, że zawsze mamy prawidłową wartość. Możemy postępować pewnie, nie musząc sprawdzać wartości null przed użyciem tej wartości. Tylko wtedy, gdy mamy Option<i8> (lub dowolny typ wartości, z którym pracujemy), musimy martwić się o ewentualny brak wartości, a kompilator upewni się, że obsłużymy ten przypadek przed użyciem wartości.
Innymi słowy, musisz przekonwertować Option<T> na T, zanim będziesz mógł wykonywać operacje T. Ogólnie rzecz biorąc, pomaga to wychwycić jeden z najczęstszych problemów z null: założenie, że coś nie jest null, gdy w rzeczywistości jest.
Eliminowanie ryzyka błędnego założenia wartości nie-null pomaga ci być pewniejszym swojego kodu. Aby mieć wartość, która potencjalnie może być null, musisz jawnie się na to zgodzić, czyniąc typ tej wartości Option<T>. Następnie, gdy używasz tej wartości, musisz jawnie obsłużyć przypadek, gdy wartość jest null. Wszędzie tam, gdzie wartość ma typ, który nie jest Option<T>, możesz bezpiecznie założyć, że wartość nie jest null. Była to celowa decyzja projektowa dla Rust, aby ograniczyć wszechobecność wartości null i zwiększyć bezpieczeństwo kodu w Rust.
Więc jak wyciągnąć wartość T z wariantu Some, gdy masz wartość typu Option<T>, aby móc jej użyć? Enum Option<T> ma wiele metod, które są przydatne w różnych sytuacjach; możesz je sprawdzić w jej dokumentacji. Zapoznanie się z metodami w Option<T> będzie niezwykle pomocne w Twojej podróży z Rust.
Ogólnie rzecz biorąc, aby użyć wartości Option<T>, chcesz mieć kod, który obsłuży każdy wariant. Chcesz, aby pewien kod uruchamiał się tylko wtedy, gdy masz wartość Some(T), i ten kod może używać wewnętrznego T. Chcesz, aby inny kod uruchamiał się tylko wtedy, gdy masz wartość None, a ten kod nie ma dostępnej wartości T. Wyrażenie match jest konstrukcją przepływu sterowania, która robi to właśnie w przypadku enumów: uruchamia inny kod w zależności od wariantu enum, który posiada, a ten kod może używać danych zawartych w pasującej wartości.