Skuteczny Rust

35 konkretnych sposobów na ulepszenie kodu w języku Rust

David Drysdale

przekład: Witold Sikorski

APN Promise

Warszawa 2024

Skuteczny Rust

© 2024 APN PROMISE SA

Authorized translation of English edition of
Effective Rust
ISBN 978-1-098-15140-9
Copyright © 2024 Galloglass Consulting Limited. All rights reserved.

This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls of all rights to publish and sell the same.

APN PROMISE SA, ul. Domaniewska 44a, 02-672 Warszawa
tel. +48 22 35 51 600
e-mail: wydawnictwo@promise.pl

Wszystkie prawa zastrzeżone. Żadna część niniejszej książki nie może być powielana ani rozpowszechniana w jakiejkolwiek formie i w jakikolwiek sposób (elektroniczny, mechaniczny), włącznie z fotokopiowaniem, nagrywaniem na taśmy lub przy użyciu innych systemów bez pisemnej zgody wydawcy.

Logo O’Reilly jest zarejestrowanym znakiem towarowym O’Reilly Media, Inc. Ilustracja z okładki i powiązane elementy są znakami towarowymi O’Reilly Media, Inc.

Wszystkie inne nazwy handlowe i towarowe występujące w niniejszej publikacji mogą być znakami towarowymi zastrzeżonymi lub nazwami zastrzeżonymi odpowiednich firm odnośnych właścicieli.

Przykłady firm, produktów, osób i wydarzeń opisane w niniejszej książce są fikcyjne i nie odnoszą się do żadnych konkretnych firm, produktów, osób i wydarzeń. Ewentualne podobieństwo do jakiejkolwiek rzeczywistej firmy, organizacji, produktu, nazwy domeny, adresu poczty elektronicznej, logo, osoby, miejsca lub zdarzenia jest przypadkowe i niezamierzone.

APN PROMISE SA dołożyła wszelkich starań, aby zapewnić najwyższą jakość tej publikacji. Jednakże nikomu nie udziela się rękojmi ani gwarancji.
APN PROMISE SA nie jest w żadnym wypadku odpowiedzialna za jakiekolwiek szkody będące następstwem korzystania z informacji zawartych w niniejszej publikacji, nawet jeśli APN PROMISE została powiadomiona o możliwości wystąpienia szkód.

ISBN: 978-83-7541-564-3 (druk), 978-83-7541-565-0 (ebook)

Projekt okładki: Karen Montgomery
Ilustracje: Kate Dullea

Przekład: Witold Sikorski
Redakcja: Marek Włodarz
Korekta: Ewa Swędrowska
Skład i łamanie: MAWart Marek Włodarz

Spis treści

Wstęp

Typy

Zagadnienie 1: Użycie systemu typów do wyrażania własnej struktury danych

Zagadnienie 2: Użycie systemu typów do wyrażania typowego zachowania

Zagadnienie 3: Lepiej wybierać transformacje Option i Result, niż jawne wyrażenie match

Zagadnienie 4: Lepiej wybierać idiomatyczne typy Error

Zagadnienie 5: Zrozumienie konwersji typów

Zagadnienie 6: Przyjęcie wzorca newtype

Zagadnienie 7: Użycie budowniczych do złożonych typów

Zagadnienie 8: Zapoznanie się z typami referencyjnymi i wskaźnikowymi

Zagadnienie 9: Rozważanie użycia transformacji iteratora zamiast jawnych pętli

Cechy

Zagadnienie 10: Zapoznanie ze standardowymi cechami

Zagadnienie 11: Implementacja cechy Drop dla wzorców RAII

Zagadnienie 12: Zrozumienie kompromisów między obiektami generycznymi a obiektami cech

Zagadnienie 13: Korzystanie z domyślnych implementacji w celu minimalizowania wymaganych metod cech

Pojęcia

Zagadnienie 14: Zrozumienie czasu życia

Zagadnienie 15: Zrozumienie kontrolera pożyczek

Zagadnienie 16: Unikanie pisania kodu unsafe

Zagadnienie 17: Uważanie na współbieżność ze stanem współdzielonym

Zagadnienie 18: Nie panikuj

Zagadnienie 19: Unikanie refleksji

Zagadnienie 20: Unikanie pokusy nadmiernej optymalizacji

Zależności

Zagadnienie 21: Zrozumienie, co obiecują nam wersje semantyczne

Zagadnienie 22: Minimalizowanie widoczności

Zagadnienie 23: Unikanie importu z symbolami wieloznacznymi

Zagadnienie 24: Reeksport zależności, których typy pojawiają się w API

Zagadnienie 25: Zarządzanie schematem zależności

Zagadnienie 26: Uważanie na rozrastanie się feature

Oprzyrządowanie

Zagadnienie 27: Dokumentowanie interfejsów publicznych

Zagadnienie 28: Rozsądne korzystanie z makr

Zagadnienie 29: Słuchajmy Clippy

Zagadnienie 30: Piszmy nie tylko testy jednostkowe

Zagadnienie 31: Korzystanie z ekosystemu oprzyrządowania

Zagadnienie 32: Konfiguracja systemu ciągłej integracji (CI)

Poza standardem Rusta

Zagadnienie 33: Rozważanie uczynienia kodu biblioteki kompatybilnym z no_std

Zagadnienie 34: Kontrola tego, co przekracza granice FFI

Zagadnienie 35: Lepiej wybierać bindgen, niż ręczne odwzorowywanie FFI

Posłowie

Indeks

O autorze

Polecamy także

Wstęp

Kod jest bardziej czymś, co można nazwać wytycznymi, niż faktycznymi zasadami.

– Hector Barbossa

W zatłoczonym krajobrazie nowoczesnych języków programowania Rust jest inny. Rust oferuje szybkość języka kompilowanego, wydajność języka niezbierającego śmieci i bezpieczeństwo typów języka funkcyjnego – a także wyjątkowe rozwiązanie problemów związanych z bezpieczeństwem pamięci. W rezultacie Rust jest regularnie wybierany jako najbardziej lubiany język programowania (https://oreil.ly/KKcb6).

Siła i spójność systemu typów języka Rust oznacza, że jeśli program w tym języku się skompiluje, to jest duża szansa, że będzie działał – zjawisko obserwowane wcześniej tylko w przypadku bardziej akademickich, mniej dostępnych języków, takich jak Haskell. Jeśli program w języku Rust się skompiluje, to będzie również działał bezpiecznie.

To bezpieczeństwo – zarówno bezpieczeństwo typów, jak i bezpieczeństwo pamięci – ma jednak swoją cenę. Pomimo jakości podstawowej dokumentacji, Rust ma renomę stromego wejścia, w którym nowicjusze muszą przejść przez rytuały inicjacji, w których walczą ze sprawdzaniem zapożyczeń, przeprojektowywaniem struktur danych i są zagubieni przez okresy życia obiektów. Program Rust, który się kompiluje, może mieć duże szanse na zadziałanie za pierwszym razem, ale walka o jego kompilację jest realna – nawet przy niezwykle pomocnej diagnostyce błędów kompilatora Rust.

Dla kogo jest ta książka

Niniejsza książka stara się pomóc w tych obszarach, w których programiści mają trudności, nawet jeśli mają już doświadczenie z innym kompilowanym językiem, takim jak C++. W związku z tym – podobnie jak inne książki z serii Effective <Język> – książka ta ma być drugą książką, której może potrzebować nowicjusz w języku Rust, po tym, jak już zapoznał się z podstawami gdzie indziej – na przykład w The Rust Programming Language1 (Steve Klabnik i Carol Nichols, No Starch Press) lub Programming Rust (Jim Blandy i in., O’Reilly).

Jednak bezpieczeństwo Rusta prowadzi do nieco innego podejścia do omawianych tutaj elementów, szczególnie w porównaniu z oryginalną serią Effective C++ Scotta Meyersa2. Język C++ był (i jest) pełen niekorzystnych rozwiązań, więc Effective C++ skupiał się na zbiorze porad dotyczących unikania tych „strzałów w stopę” na podstawie rzeczywistego doświadczenia w tworzeniu oprogramowania w C++. Co ważne, zawierał on zalecenia, a nie reguły, ponieważ zalecenia mają wyjątki – podanie szczegółowego uzasadnienia dla zalecenia pozwala czytelnikom samodzielnie zdecydować, czy ich konkretny scenariusz uzasadnia złamanie reguły.

Ogólny styl udzielania porad wraz z ich uzasadnieniem został tutaj zachowany. Niemniej jednak, ponieważ Rust jest wyjątkowo wolny od „strzałów w stopę”, zagadnienia koncentrują się tutaj bardziej na koncepcjach, które Rust wprowadza. Wiele zagadnień ma tytuły rozpoczynające się od słów Zrozumienie... i Zapoznanie się z... i pomaga rozpocząć podróż w kierunku płynnego, idiomatycznego pisania w języku Rust.

Bezpieczeństwo Rusta przekłada się również na całkowity brak elementów zatytułowanych Nigdy... Jeśli naprawdę nigdy nie należy czegoś robić, kompilator na ogół to uniemożliwi.

Wersja Rusta

Tekst został napisany dla wydania Rust 2018, wykorzystującego stabilny zestaw narzędzi. Zapewnienie wstecznej kompatybilności Rusta (https://oreil.ly/husN4) oznacza, że każde późniejsze wydanie Rusta, w tym wydanie z 2021 roku, będzie nadal obsługiwać kod napisany dla wydania z 2018 roku, nawet jeśli to późniejsze wydanie wprowadzi przełomowe zmiany. Rust jest obecnie na tyle stabilny, że różnice między wydaniami 2018 i 2021 są niewielkie. Żaden kod zawarty w książce nie wymaga zmiany, aby był zgodny z wydaniem 2021 (jednak zagadnienie 19 zawiera jeden wyjątek, w którym nowsza wersja Rusta pozwala na nowe zachowanie, które wcześniej nie było możliwe).

Przedstawione tutaj zagadnienia nie obejmują żadnych aspektów asynchronicznej funkcjonalności Rusta (https://oreil.ly/a9r1B), ponieważ wiąże się to z bardziej zaawansowanymi koncepcjami i mniej stabilną obsługą łańcucha narzędzi – było wystarczająco dużo do omówienia z synchronicznym Rustem. Być może w przyszłości pojawi się Skuteczny niesynchroniczny Rust…

Do fragmentów kodu i komunikatów o błędach użyto wersji 1.70 rustc. Jest mało prawdopodobne, aby fragmenty kodu wymagały zmian w przypadku późniejszych wersji, ale komunikaty o błędach mogą się różnić w zależności od konkretnej wersji kompilatora. Komunikaty o błędach zawarte w tekście były również ręcznie edytowane, aby zmieścić się w granicach szerokości książki, ale poza tym są one takie same jak generowane przez kompilator.

Tekst zawiera szereg odniesień i porównań z innymi statycznie typowanymi językami, takimi jak Java, Go i C++, aby pomóc lepiej się zorientować czytelnikom mającym doświadczenie w tych językach. (C++ jest prawdopodobnie najbliższym równoważnym językiem, szczególnie gdy w grę wchodzi semantyka przenoszenia C++ 11).

Korzystanie z tej książki

Zagadnienia składające się na niniejszą książkę są podzielone na sześć rozdziałów:

Rozdział 1, „Typy”

Sugestie dotyczące podstawowego systemu typów Rusta.

Rozdział 2, „Cechy”

Sugestie dotyczące pracy z wykorzystaniem cech Rusta.

Rozdział 3, „Pojęcia”

Główne idee, które tworzą projekt Rusta.

Rozdział 4, „Zależności”

Wskazówki dotyczące pracy z ekosystemem pakietów Rusta.

Rozdział 5, „Oprzyrządowanie”

Sugestie dotyczące ulepszenia bazy kodu poprzez wyjście poza kompilator Rusta.

Rozdział 6, „Poza standardem Rusta”

Sugestie dotyczące pracy poza standardowym, bezpiecznym środowiskiem Rusta

Choć rozdział „Pojęcia” jest zapewne bardziej podstawowy, niż rozdziały „Typy” i „Cechy”, został celowo umieszczony w dalszej części książki, aby czytelnicy, którzy czytają od początku do końca, mogli najpierw zdobyć trochę pewności siebie.

Konwencje stosowane w tej książce

W niniejszej książce stosowane są następujące konwencje typograficzne:

Kursywa

Wskazuje nowe terminy, adresy URL, adresy e-mail, nazwy plików i rozszerzenia plików.

Czcionka o stałej szerokości

Używana w listingach programów, a także w akapitach do odwoływania się do elementów programu, takich jak nazwy zmiennych lub funkcji, bazy danych, typy danych, zmienne środowiskowe, instrukcje i słowa kluczowe.

NIE KOMPILUJE SIĘ

// Oznacza fragmenty kodu, które się nie kompilują

NIEPOŻĄDANE ZACHOWANIE

// Oznacza fragmenty kodu, które wykazują niepożądane zachowanie

Jak się z nami skontaktować

Komentarze i pytania dotyczące tej książki należy kierować do wydawcy

O’Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

800-889-8969 (w USA lub Kandzie)

707-827-7019 (międzynarodowy lub lokalny)

707-829-0104 (fax) support@oreilly.com

https://www.oreilly.com/about/contact.html

Dla tej książki prowadzimy stronę internetową, na której zamieszczamy erraty, przykłady i wszelkie dodatkowe informacje. Dostęp do tej strony można uzyskać pod adresem https://oreil.ly/effective-rust.

Aktualności i informacje o naszych książkach i kursach można znaleźć na stronie https://oreilly.com.

Podziękowania

Dziękuję osobom, które przyczyniły się do powstania tej książki:

Recenzentom technicznym, którzy przekazali fachowe i szczegółowe opinie na temat wszystkich aspektów tekstu: Pietrowi Albiniemu, Jessowi Malesowi, Mike’owi Cappowi, a szczególnie Carol Nichols.

Moim redaktorom w O’Reilly: Jeffowi Bleielowi, Brianowi Guerinowi i Katie Tozer.

Tizianowi Santoro, od którego nauczyłem się wielu rzeczy o języku Rust.

Danny’emu Elfanbaumowi, który udzielał istotnej pomocy technicznej przy formatowaniu książki w formacie AsciiDoc.

Uważnym czytelnikom oryginalnej wersji internetowej książki, w szczególności:

Julianowi Rosse, który zauważył dziesiątki literówek i innych błędów w tekście online.

Martinowi Dischowi, który wskazał możliwości poprawy i nieścisłości w kilku zagadnieniach.

Chrisowi Fleetwoodowi, Sergeyowi Kaunovowi, Cliffordowi Matthews, Remo Senekowitschowi, Kirillowi Zaborsky’emu, i anonimowemu użytkownikowi Proton Mail, który wskazał błędy w tekście.

Mojej rodzinie, która radziła sobie z wieloma weekendami, kiedy byłem pochłonięty pisaniem.

1 Wydanie polskie: Programowanie w języku Rust. Oficjalny podręcznik. Helion 2024 (przyp. tłum.).

2 Z tej serii w Polsce została wydana książka Skuteczny nowoczesny C++, Promise 2015 (przyp. tłum.).

Rozdział 1

Typy

Pierwszy rozdział obejmuje porady dotyczące systemu typów języka Rust. Jest on znacznie bardziej ekspresyjny, niż w innych językach głównego nurtu i ma więcej wspólnego z językami „akademickimi”, takimi jak OCaml czy Haskell.

Jedną z podstawowych części tego systemu typów jest typ enum Rusta, który jest znacznie bardziej ekspresyjny, niż typy wyliczeniowe w innych językach i pozwala na użycie algebraicznych typów danych.

Zagadnienia w tym rozdziale obejmują podstawowe typy występujące w języku i sposób łączenia ich w struktury danych, które precyzyjnie wyrażają semantykę programu. Ta koncepcja kodowania działania do systemu typów pomaga zmniejszyć ilość wymaganego sprawdzania i kodu ścieżki błędów, ponieważ nieprawidłowe stany są odrzucane przez zestaw narzędzi w czasie kompilacji, a nie przez program w czasie wykonywania.

Rozdział ten opisuje również niektóre z powszechnych struktur danych, które są dostarczane przez standardową bibliotekę Rusta: Option, Result, Error oraz Iterator. Znajomość tych standardowych narzędzi pomoże w pisaniu idiomatycznego Rusta, który jest wydajny i kompaktowy – w szczególności pozwalają one na zastosowanie do obsługi błędów operatora znaku zapytania Rusta, dzięki czemu działanie jest dyskretne, ale wciąż bezpieczne ze względu na typy.

Należy zauważyć, że zagadnienia, które obejmują cechy Rusta, są omówione w następnym rozdziale, ale z konieczności w pewnym stopniu pokrywają się z elementami z tego rozdziału, ponieważ cechy opisują zachowywanie się typów.

Zagadnienie 1: Użycie systemu typów do wyrażania własnej struktury danych

kto nazwał ich programistami, a nie maszynistami1

– @thingskatedid (https://oreil.ly/hHj5c)

To zagadnienie to krótka wycieczka po systemie typów Rusta, począwszy od podstawowych typów udostępnianych przez kompilator, a następnie przejdziemy do różnych sposobów, jakimi wartości można łączyć w struktury danych.

Później główną rolę przejmuje typ enum języka Rust. Wprawdzie podstawowa wersja jest odpowiednikiem tego, co oferują inne języki, ale możliwość łączenia wariantów enum z polami danych daje większą elastyczność i ekspresyjność.

Podstawowe typy

Podstawy systemu typów Rusta są dość dobrze znane każdemu, kto korzysta z innego statycznie typowanego języka programowania (takiego jak C++, Go lub Java). Mamy więc zbiór typów całkowitoliczbowych o określonych rozmiarach, zarówno ze znakiem (i8, i16, i32, i64, i128), jak i bez znaku (u8 .

Są również wartości całkowite ze znakiem (isize i bez znaku (, których rozmiar jest zgodny z rozmiarem wskaźnika w systemie docelowym. Jednak w języku Rust nie będziemy wykonywać zbyt wielu zadań związanych z przekształceniami między wskaźnikami i liczbami całkowitymi, więc ta równoważność rozmiaru nie jest tak naprawdę istotna. Standardowe kolekcje zwracają jednak swój rozmiar jako usize (z metody .len()), więc indeksowanie kolekcji oznacza, że wartości usize są dość powszechne – co jest oczywiście w porządku z punktu widzenia pojemności, ponieważ nie może być więcej elementów w kolekcji w pamięci, niż jest adresów pamięci w systemie.

Typy liczb całkowitych dają nam pierwszą wskazówkę, że Rust jest bardziej rygorystycznym językiem, niż C++. W Rust próba umieszczenia większego typu całkowitego (i32) w mniejszym typie całkowitym (i16) generuje błąd kompilacji:

NIE KOMPILUJE SIĘ

let x: i32 = 42;

let y: i16 = x;

error[E0308]: mismatched types

--> src/main.rs:18:18

|

18 | let y: i16 = x;

| --- ^ expected `i16`, found `i32`

| |

| expected due to this

|

help: you can convert an `i32` to an `i16` and panic if the converted value

doesn't fit

|

18 | let y: i16 = x.try_into().unwrap();

| ++++++++++++++++++++

To bardzo pocieszające: Rust nie zamierza pozostawać bezczynny, gdy programista robi rzeczy ryzykowne. Mimo że widzimy, iż wartości dotyczące tej konkretnej konwersji byłyby właściwe, kompilator musi dopuścić możliwość wystąpienia wartości, w których konwersja nie jest w porządku:

NIE KOMPILUJE SIĘ

let x: i32 = 66_000;

let y: i16 = x;

// Jaka byłaby ta wartość?

Informacja o błędzie daje również wczesną wskazówkę, że chociaż Rust ma sztywniejsze reguły, ma również pomocne komunikaty kompilatora, które wskazują drogę do przestrzegania reguł. Sugerowane rozwiązanie rodzi pytanie, jak radzić sobie z sytuacjami, w których konwersja musiałaby zmienić wartość, aby ją dopasować. Później będziemy mieli więcej do powiedzenia zarówno na temat obsługi błędów (zagadnienie 4), jak i używania panic! (zagadnienie 18).

Rust nie zezwala również na pewne rzeczy, które mogą wydawać się „bezpieczne”, takie jak umieszczanie wartości z mniejszego typu całkowitego w większym typie całkowitym:

NIE KOMPILUJE SIĘ

let x = 42i32;

// Liczba całkowita z przyrostkiem typu

let y: i64 = x;

error[E0308]: mismatched types

--> src/main.rs:36:18

|

36 | let y: i64 = x;

| --- ^ expected `i64`, found `i32`

| |

| expected due to this

|

help: you can convert an `i32` to an `i64`

|

36 | let y: i64 = x.into();

| +++++++

W tym przypadku sugerowane rozwiązanie nie podnosi kwestii obsługi błędów, ale konwersja nadal musi być jawna. Konwersje typów omówimy bardziej szczegółowo później (zagadnienie 5).

Kontynuując niezaskakujące typy podstawowe, Rust ma typ bool, typy zmiennoprzecinkowe (f32, f64) oraz typ jednostkowy () (jak void w C).

Ciekawszy jest typ znakowy char, który przechowuje wartość Unicode (podobnie jak typ rune w Go). Chociaż wewnętrznie jest on przechowywany jako cztery bajty, ponownie nie ma tu cichej konwersji do lub z 32-bitowej liczby całkowitej.

Ta precyzja systemu typów zmusza użytkownika do wyraźnego określenia tego, co próbuje wyrazić – wartość u32 różni się od typu char, który z kolei różni się od sekwencji bajtów UTF-8, a ta z kolei różni się od sekwencji dowolnych bajtów. To do użytkownika należy dokładne określenie, co ma na myśli2. Słynny wpis na blogu Joela Spolsky'ego (https://oreil.ly/wWy7T) może pomóc zrozumieć, co jest potrzebne.

Oczywiście istnieją metody pomocnicze, które umożliwiają konwersję między tymi różnymi typami, ale ich sygnatury zmuszają do obsługi (lub jawnego ignorowania) możliwości niepowodzenia. Na przykład, punkt kodowy Unicode może być zawsze reprezentowany w 32 bitach3, więc 'a' as u32 jest dozwolone, ale przeciwny kierunek jest trudniejszy (ponieważ niektóre wartości u32 nie są poprawnymi punktami kodowymi Unicode):

char::from_u32

Zwraca Option<char>, zmuszając obiekt wywołujący do obsługi przypadku błędu.

char::from_u32_unchecked

Przyjmuje założenie o ważności, ale może spowodować niezdefiniowane zachowanie, jeśli to założenie okaże się nieprawdziwe. W rezultacie funkcja jest oznaczona jako unsafe, co zmusza obiekt wywołujący do użycia również funkcji unsafe (zagadnienie 16).

Typy zagregowane

Przechodząc do typów zagregowanych, Rust ma wiele sposobów łączenia powiązanych wartości. Większość z nich to znane odpowiedniki mechanizmów agregacji dostępnych w innych językach:

Tablice

Przechowują wiele instancji pojedynczego typu, przy czym liczba instancji jest znana w czasie kompilacji. Na przykład [u32; 4] to kolejno cztery 4-bajtowe liczby całkowite.

Krotki

Przechowują instancje wielu typów heterogenicznych, przy czym liczba elementów i ich typy są znane w czasie kompilacji, na przykład (WidgetOffset, WidgetSize, WidgetColor). Jeśli typy w krotce nie są rozróżnialne – na przykład (i32, i32, &'static str, bool) – lepiej nadać każdemu elementowi nazwę i użyć struktury.

Struktury

Również przechowują instancje heterogenicznych typów znanych w czasie kompilacji, ale umożliwiają odwoływanie się poprzez nazwy zarówno do ogólnego typu, jak i do poszczególnych pól.

Rust zawiera także strukturę krotki, która jest mieszanką struct oraz krotki: istnieje nazwa dla całego typu, ale nie ma nazw dla poszczególnych pól – zamiast tego są do nich odwołania za pomocą liczb: s.0, s.1 itd:

/// Struktura z dwoma nienazwanymi polami.

struct TextMatch(usize, String);

// Tworzenie przez podanie zawartości w kolejności.

let m = TextMatch(12, "needle".to_owned());

// Dostęp według numeru pola.

assert_eq!(m.0, 12);

Typy enum

To prowadzi nas do klejnotu w koronie systemu typów Rusta, enum. W przypadku podstawowej postaci enum trudno jest dostrzec, czym tu można się zachwycać. Podobnie jak w innych językach, enum pozwala określić zestaw wzajemnie wykluczających się wartości, ewentualnie z dołączoną wartością liczbową:

enum HttpResultCode {

Ok = 200,

NotFound = 404,

Teapot = 418,

}

let code = HttpResultCode::NotFound;

assert_eq!(code as i32, 404);

Ponieważ każda definicja enum tworzy oddzielny typ, można to wykorzystać do poprawy czytelności i łatwości obsługi funkcji, które przyjmują argumenty logiczne (bool). Zamiast:

print_page(/* both_sides= */ true, /* color= */ false);

wersja z użyciem pary enum:

pub enum Sides {

Both,

Single,

}

pub enum Output {

BlackAndWhite,

Color,

}

pub fn print_page(sides: Sides, color: Output) {

// ...

}

jest bardziej bezpieczna dla typu i łatwiejsza do odczytania w punkcie wywołania:

print_page(Sides::Both, Output::BlackAndWhite);

W przeciwieństwie do wersji bool, jeśli użytkownik biblioteki przypadkowo zamieni kolejność argumentów, kompilator natychmiast zgłosi błąd.

error[E0308]: arguments to this function are incorrect

--> src/main.rs:104:9

|

104 | print_page(Output::BlackAndWhite, Sides::Single);

| ^^^^^^^^^^ --------------------- ------------- expected `enums::Output`,

| | found `enums::Sides`

| |

| expected `enums::Sides`, found `enums::Output`

|

note: function defined here --> src/main.rs:145:12

|

145 | pub fn print_page(sides: Sides, color: Output) {

| ^^^^^^^^^^ ------------ -------------

help: swap these arguments

|

104 | print_page(Sides::Single, Output::BlackAndWhite);

| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Użycie wzorca newtype – patrz zagadnienie 6 – do opakowania wartości bool również zapewnia bezpieczeństwo typu i łatwość obsługi. Na ogół najlepiej jest użyć wzorca newtype, jeśli semantyka zawsze będzie boolowska, oraz użyć enum, jeśli jest szansa, że w przyszłości może pojawić się nowa alternatywa, np. Sides::BothAlternateOrientation.

Bezpieczeństwo wyliczeń enum Rusta jest utrzymane dzięki wyrażeniu match:

NIE KOMPILUJE SIĘ

let msg = match code {

HttpResultCode::Ok => "Ok",

HttpResultCode::NotFound => "Not found",

// Zapomnieliśmy poradzić sobie z najważniejszym przypadkiem "Teapot".4

};

error[E0004]: non-exhaustive patterns: `HttpResultCode::Teapot` not covered

--> src/main.rs:44:21

|

44 | let msg = match code {

| ^^^^ pattern `HttpResultCode::Teapot` not covered

|

note: `HttpResultCode` defined here

--> src/main.rs:10:5

|

7 | enum HttpResultCode {

| -------------...

10 | Teapot = 418,

| ^^^^^^ not covered

= note: the matched value is of type `HttpResultCode`

help: ensure that all possible cases are being handled by adding a match arm

with a wildcard pattern or an explicit pattern as shown

|

46 ~ HttpResultCode::NotFound => "Not found",

47 ~ HttpResultCode::Teapot => todo!(),

|

Kompilator zmusza programistę do rozważenia wszystkich możliwości reprezentowanych przez enum5, nawet jeśli wynikiem jest po prostu dodanie domyślnej gałęzi _ => {}. (Zauważmy, że nowoczesne kompilatory C++ także mogą ostrzec i ostrzegają o brakujących gałęziach switch dla enum).

Wyliczenia enum z polami

Prawdziwa siła funkcjonalności enum Rusta wynika z faktu, że każdy jej wariant może mieć powiązane ze sobą dane, co czyni go typem zagregowanym, który działa jak algebraiczny typ danych (ADT, algebraic data type). Jest to mniej znane programistom popularnych języków – w terminologii C/C++ jest to jak połączenie enum z union – tylko bezpieczne pod względem typu.

Oznacza to, że niezmienniki struktur danych programu mogą być zakodowane w systemie typów Rusta, a stany, które nie są zgodne z tymi niezmiennikami, nawet się nie skompilują. Dobrze zaprojektowany enum sprawia, że intencje twórcy są jasne zarówno dla ludzi, jak i dla kompilatora

use std::collections::{HashMap, HashSet};

pub enum SchedulerState {

Inert,

Pending(HashSet<Job>),

Running(HashMap<CpuId, Vec<Job>>),

}

Na podstawie definicji typu można się domyślić, że Jobs (zadania) są umieszczane w kolejce w stanie Pending (oczekujące), dopóki harmonogramowanie nie będzie w pełni aktywne, po czym są przypisywane do puli jakiegoś procesora.

To uwydatnia główny temat tego zagadnienia, którym jest użycie systemu typów Rusta do wyrażenia koncepcji związanych z projektowaniem oprogramowania.

Oczywistym sygnałem, że tak się nie dzieje, jest komentarz wyjaśniający, kiedy jakieś pole lub parametr nie są poprawne:

NIEPOŻĄDANE ZACHOWANIE

pub struct DisplayProps {

pub x: u32,

pub y: u32,

pub monochrome: bool,

// `fg_color` musi być (0, 0, 0), jeśli `monochrome` jest prawdą.

pub fg_color: RgbColor,

}

Jest to główny kandydat do zastąpienia przez dane zawierające wyliczenie enum:

pub enum Color {

Monochrome,

Foreground(RgbColor),

}

pub struct DisplayProps {

pub x: u32,

pub y: u32,

pubw color: Color,

}

Ten mały przykład pokazuje kluczową radę: spraw, by nieprawidłowe stany nie były możliwe do wyrażenia w twoich typach. Typy obsługujące tylko poprawne kombinacje wartości oznaczają, że kompilator odrzuca całe klasy błędów, co prowadzi do mniejszego i bezpieczniejszego kodu.

Wszechobecne typy enum

Wracając do możliwości enum, istnieją dwie koncepcje, które są tak powszechne, że standardowa biblioteka Rusta zawiera wbudowane typy enum do ich wyrażania. Typy te są wszechobecne w kodzie Rusta.

Option<T>

Pierwszą koncepcją jest Option: albo istnieje wartość określonego typu (Some(T)), albo nie (None). Zawsze należy używać Option dla wartości, które mogą być nieobecne. Nigdy nie należy powracać do używania wartości strażników (ang. sentinel) (-1, nullptr, …), aby próbować wyrazić tę samą koncepcję w ramach tego samego typu.

Jest jednak jedna subtelna kwestia do rozważenia. Jeśli mamy do czynienia z kolekcją rzeczy, musimy zdecydować, czy posiadanie zera rzeczy w kolekcji jest tym samym co nieposiadanie kolekcji. W większości sytuacji rozróżnienie to nie występuje i można śmiało użyć (powiedzmy) Vec<Thing>: zerowa liczba rzeczy oznacza ich brak.

Istnieją jednak całkiem inne, rzadkie scenariusze, w których te dwa przypadki należy rozróżnić za pomocą Option<Vec<Thing>> – na przykład system kryptograficzny może wymagać rozróżnienia między „ładunkiem transportowanym osobno” (https://oreil.ly/vuLlo) a „dostarczonym pustym ładunkiem”. (Jest to powiązane z rozważaniami dotyczącymi znacznika NULL dla kolumn w SQL).

Analogicznie, jaki jest najlepszy wybór dla String, który może być nieobecny? Czy "" czy też None ma więcej sensu, aby wskazać brak wartości? Każdy sposób działa, ale Option<String> wyraźnie komunikuje możliwość, że ta wartość może być nieobecna.

Result<T, E>

Druga powszechna koncepcja wynika z przetwarzania błędów: jeśli funkcja zawiedzie, to jak należy zgłosić ten błąd? Historycznie używano specjalnych wartości strażników np. wartości zwrotnej (errno z wywołań systemowych Linuksa) lub zmiennych globalnych (errno dla systemów POSIX). Od niedawna języki, które obsługują wiele zwracanych wartości lub zwracanie krotek przez funkcje (np. Go), mogą stosować konwencję zwracania pary (result, error), zakładając istnienie odpowiedniej wartości „zero” dla result, gdy error jest inny, niż „zero”.

W języku Rust do tego celu służy właśnie enum: wynik operacji, która może zakończyć się niepowodzeniem, należy zawsze kodować jako Result<T, E>. Typ T przechowuje pomyślny wynik (w wariancie Ok), a typ E przechowuje szczegóły błędu (w wariancie Err) w przypadku niepowodzenia.

Użycie standardowego typu sprawia, że intencja projektu jest jasna. Pozwala również na użycie standardowych transformacji (zagadnienie 3) i przetwarzania błędów (zagadnienie 4), co z kolei umożliwia usprawnienie przetwarzania błędów za pomocą operatora ?.