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 jeden String.
  • ChangeColor zawiera trzy wartości o typach i32.

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.