Typy danych
Każda wartość w Ruście ma określony typ danych, który informuje Rusta, jaki rodzaj danych jest określany, aby wiedział, jak z nimi pracować. Przyjrzymy się dwóm podzbiorom typów danych: skalarnym i złożonym.
Pamiętaj, że Rust jest językiem statycznie typowanym, co oznacza, że musi
znać typy wszystkich zmiennych w czasie kompilacji. Kompilator zazwyczaj może
wywnioskować, jakiego typu chcemy użyć, na podstawie wartości i sposobu jej
użycia. W przypadkach, gdy możliwych jest wiele typów, np. gdy konwertowaliśmy
String na typ numeryczny za pomocą parse w sekcji „Porównywanie strzału z
tajną liczbą”
w Rozdziale 2, musimy dodać adnotację typu, taką jak ta:
#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}
Jeśli nie dodamy adnotacji typu : u32 pokazanej w poprzednim kodzie, Rust
wyświetli następujący błąd, co oznacza, że kompilator potrzebuje od nas więcej
informacji, aby wiedzieć, jakiego typu chcemy użyć:
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
|
2 | let guess: /* Type */ = "42".parse().expect("Not a number!");
| ++++++++++++
For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error
Zobaczysz różne adnotacje typów dla innych typów danych.
Typy skalarne
Typ skalarny reprezentuje pojedynczą wartość. Rust ma cztery główne typy skalarne: liczby całkowite, liczby zmiennoprzecinkowe, wartości boolowskie i znaki. Możesz je rozpoznać z innych języków programowania. Przejdźmy do tego, jak działają w Rust.
Typy całkowite
Liczba całkowita to liczba bez części ułamkowej. Użyliśmy jednego typu
całkowitego w Rozdziale 2, typu u32. Ta deklaracja typu wskazuje, że wartość,
z którą jest skojarzona, powinna być niepodpisaną liczbą całkowitą (typy
całkowite ze znakiem zaczynają się od i zamiast u), która zajmuje 32 bity
pamięci. Tabela 3-1 pokazuje wbudowane typy całkowite w Rust. Możemy użyć
dowolnego z tych wariantów do zadeklarowania typu wartości całkowitej.
Tabela 3-1: Typy całkowite w Rust
| Długość | Ze znakiem | Bez znaku |
|---|---|---|
| 8-bitów | i8 | u8 |
| 16-bitów | i16 | u16 |
| 32-bitów | i32 | u32 |
| 64-bitów | i64 | u64 |
| 128-bitów | i128 | u128 |
| Zależne od architektury | isize | usize |
Każdy wariant może być ze znakiem lub bez znaku i ma wyraźny rozmiar. Ze znakiem i bez znaku odnoszą się do tego, czy liczba może być ujemna – inaczej mówiąc, czy liczba musi mieć znak (ze znakiem), czy zawsze będzie dodatnia i dlatego może być reprezentowana bez znaku (bez znaku). To jak pisanie liczb na papierze: gdy znak ma znaczenie, liczba jest pokazywana ze znakiem plus lub minus; jednak, gdy można bezpiecznie założyć, że liczba jest dodatnia, jest pokazywana bez znaku. Liczby ze znakiem są przechowywane za pomocą reprezentacji dopełnienia do dwóch.
Każdy wariant ze znakiem może przechowywać liczby od −(2n − 1) do
2n − 1 − 1 włącznie, gdzie n to liczba bitów, które używa ten wariant.
Tak więc i8 może przechowywać liczby od −(27) do 27 − 1,
co równa się −128 do 127. Warianty bez znaku mogą przechowywać liczby od 0 do
2n − 1, więc u8 może przechowywać liczby od 0 do 28 − 1,
co równa się 0 do 255.
Dodatkowo, typy isize i usize zależą od architektury komputera, na którym
działa Twój program: 64 bity, jeśli jesteś na architekturze 64-bitowej, i 32
bity, jeśli jesteś na architekturze 32-bitowej.
Literały całkowite można zapisywać w dowolnej z form pokazanych w tabeli 3-2.
Zauważ, że literały liczbowe, które mogą być wieloma typami numerycznymi,
dopuszczają sufiks typu, taki jak 57u8, do określenia typu. Literały
liczbowe mogą również używać _ jako wizualnego separatora, aby ułatwić
odczytanie liczby, np. 1_000, która będzie miała taką samą wartość, jakbyś
określił 1000.
Tabela 3-2: Literały całkowite w Rust
| Literały liczbowe | Przykład |
|---|---|
| Dziesiętne | 98_222 |
| Szesnastkowe | 0xff |
| Ósemkowe | 0o77 |
| Binarne | 0b1111_0000 |
Bajtowe (tylko u8) | b'A' |
Więc skąd wiesz, jakiego typu liczby całkowitej użyć? Jeśli nie jesteś pewien,
domyślne ustawienia Rusta są zazwyczaj dobrym punktem wyjścia: typy całkowite
domyślnie przyjmują i32. Główna sytuacja, w której użyłbyś isize lub
usize, to indeksowanie jakiegoś rodzaju kolekcji.
Przepełnienie liczby całkowitej
Załóżmy, że masz zmienną typu u8, która może przechowywać wartości od 0 do
255. Jeśli spróbujesz zmienić zmienną na wartość spoza tego zakresu, taką jak
256, nastąpi przepełnienie liczby całkowitej, co może skutkować jednym z
dwóch zachowań. Podczas kompilacji w trybie debugowania Rust zawiera
sprawdzenia przepełnienia liczby całkowitej, które powodują, że program
panikuje w czasie wykonywania, jeśli wystąpi takie zachowanie. Rust używa
terminu panicking, gdy program kończy działanie z błędem; omówimy paniki
bardziej szczegółowo w sekcji „Błędy nie do odzyskania za pomocą
panic!” w Rozdziale 9.
Podczas kompilacji w trybie wydania z flagą --release, Rust nie zawiera
sprawdzeń przepełnienia liczby całkowitej, które powodują paniki. Zamiast
tego, jeśli wystąpi przepełnienie, Rust wykonuje zawijanie dopełnienia do
dwóch. Krótko mówiąc, wartości większe niż maksymalna wartość, jaką może
przechowywać typ, „zawijają się” do minimalnej wartości, jaką może przechowywać
typ. W przypadku u8, wartość 256 staje się 0, wartość 257 staje się 1 i tak
dalej. Program nie będzie panikował, ale zmienna będzie miała wartość, która
prawdopodobnie nie jest tym, czego się spodziewałeś. Opieranie się na zachowaniu
zawijania przepełnienia liczby całkowitej jest uważane za błąd.
Aby jawnie obsłużyć możliwość przepełnienia, możesz użyć tych rodzin metod dostarczanych przez standardową bibliotekę dla prymitywnych typów liczbowych:
- Zawijaj we wszystkich trybach za pomocą metod
wrapping_*, takich jakwrapping_add. - Zwracaj wartość
None, jeśli wystąpi przepełnienie, za pomocą metodchecked_*. - Zwracaj wartość i wartość boolowską wskazującą, czy wystąpiło
przepełnienie, za pomocą metod
overflowing_*. - Saturuj przy minimalnych lub maksymalnych wartościach typu za pomocą metod
saturating_*.
Typy zmiennoprzecinkowe
Rust ma również dwa prymitywne typy dla liczb zmiennoprzecinkowych, które są
liczbami z punktami dziesiętnymi. Typy zmiennoprzecinkowe Rusta to f32 i f64,
które mają odpowiednio 32 i 64 bity. Domyślnym typem jest f64, ponieważ na
nowoczesnych procesorach ma on w przybliżeniu taką samą prędkość jak f32,
ale jest w stanie zapewnić większą precyzję. Wszystkie typy zmiennoprzecinkowe
są ze znakiem.
Oto przykład, który pokazuje liczby zmiennoprzecinkowe w akcji:
Nazwa pliku: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
Liczby zmiennoprzecinkowe są reprezentowane zgodnie ze standardem IEEE-754.
Operacje numeryczne
Rust obsługuje podstawowe operacje matematyczne, których można oczekiwać dla
wszystkich typów liczbowych: dodawanie, odejmowanie, mnożenie, dzielenie i
reszta z dzielenia. Dzielenie całkowite obcina w kierunku zera do
najbliższej liczby całkowitej. Poniższy kod pokazuje, jak użyć każdej operacji
numerycznej w instrukcji let:
Nazwa pliku: src/main.rs
fn main() {
// dodawanie
let sum = 5 + 10;
// odejmowanie
let difference = 95.5 - 4.3;
// mnożenie
let product = 4 * 30;
// dzielenie
let quotient = 56.7 / 32.2;
let truncated = -5 / 3; // Wynosi -1
// reszta z dzielenia
let remainder = 43 % 5;
}
Każde wyrażenie w tych instrukcjach używa operatora matematycznego i ocenia się do pojedynczej wartości, która następnie jest wiązana ze zmienną. Dodatek B zawiera listę wszystkich operatorów, dostarczanych przez Rusta.
Typ boolowski
Podobnie jak w większości innych języków programowania, typ boolowski w Rust
ma dwie możliwe wartości: true i false. Wartości boolowskie mają rozmiar
jednego bajta. Typ boolowski w Rust jest określany za pomocą bool. Na
przykład:
Nazwa pliku: src/main.rs
fn main() {
let t = true;
let f: bool = false; // z jawną adnotacją typu
}
Głównym sposobem używania wartości boolowskich jest poprzez warunki, takie jak
wyrażenie if. Omówimy, jak działają wyrażenia if w Rust w sekcji „Sterowanie
przepływem”.
Typ znakowy
Typ char w Rust jest najbardziej prymitywnym typem alfabetycznym języka.
Oto kilka przykładów deklarowania wartości char:
Nazwa pliku: src/main.rs
fn main() {
let c = 'z';
let z: char = 'ℤ'; // z jawną adnotacją typu
let heart_eyed_cat = '😻';
}
Zwróć uwagę, że literały char określamy pojedynczymi cudzysłowami, w
przeciwieństwie do literałów stringowych, które używają podwójnych cudzysłowów.
Typ char w Rust ma rozmiar 4 bajtów i reprezentuje skalarną wartość Unicode,
co oznacza, że może reprezentować znacznie więcej niż tylko ASCII. Litery
akcentowane; znaki chińskie, japońskie i koreańskie; emotikony; oraz spacje o
zerowej szerokości są w Ruście prawidłowymi wartościami char. Skalarne
wartości Unicode mieszczą się w zakresie od U+0000 do U+D7FF oraz od U+E000
do U+10FFFF włącznie. Jednak „znak” nie jest tak naprawdę koncepcją w Unicode,
więc Twoja ludzka intuicja co do tego, czym jest „znak”, może nie pasować do
tego, czym jest char w Rust. Omówimy ten temat szczegółowo w sekcji
„Przechowywanie tekstu kodowanego UTF-8 za pomocą ciągów
znaków” w Rozdziale 8.
Typy złożone
Typy złożone mogą grupować wiele wartości w jeden typ. Rust ma dwa prymitywne typy złożone: krotki i tablice.
Typ krotki
Krotka to ogólny sposób grupowania wielu wartości o różnych typach w jeden typ złożony. Krotki mają stałą długość: po zadeklarowaniu nie mogą rosnąć ani kurczyć się.
Tworzymy krotkę, pisząc listę wartości oddzielonych przecinkami w nawiasach. Każda pozycja w krotce ma typ, a typy różnych wartości w krotce nie muszą być takie same. W tym przykładzie dodaliśmy opcjonalne adnotacje typów:
Nazwa pliku: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
Zmienna tup wiąże się z całą krotką, ponieważ krotka jest traktowana jako
pojedynczy element złożony. Aby uzyskać poszczególne wartości z krotki, możemy
użyć dopasowania wzorców do dekonstrukcji wartości krotki, w ten sposób:
Nazwa pliku: src/main.rs
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {y}");
}
Ten program najpierw tworzy krotkę i wiąże ją ze zmienną tup. Następnie używa
wzorca z let, aby wziąć tup i zamienić ją w trzy oddzielne zmienne: x, y
i z. Nazywa się to dekonstrukcją, ponieważ rozbija pojedynczą krotkę na
trzy części. Na koniec program wypisuje wartość y, która wynosi 6.4.
Możemy również uzyskać dostęp do elementu krotki bezpośrednio, używając kropki
(.) następującej po indeksie wartości, do której chcemy uzyskać dostęp. Na
przykład:
Nazwa pliku: src/main.rs
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
}
Ten program tworzy krotkę x, a następnie uzyskuje dostęp do każdego elementu
krotki za pomocą ich odpowiednich indeksów. Podobnie jak w większości języków
programowania, pierwszy indeks w krotce to 0.
Krotka bez żadnych wartości ma specjalną nazwę, jednostka. Ta wartość i jej
odpowiedni typ są zapisywane jako () i reprezentują pustą wartość lub pusty
typ zwracany. Wyrażenia niejawnie zwracają wartość jednostkową, jeśli nie
zwracają żadnej innej wartości.
Typ tablicowy
Innym sposobem posiadania kolekcji wielu wartości jest tablica. W przeciwieństwie do krotki, każdy element tablicy musi mieć ten sam typ. W przeciwieństwie do tablic w niektórych innych językach, tablice w Rust mają stałą długość.
Wartości w tablicy zapisujemy jako listę oddzieloną przecinkami w nawiasach kwadratowych:
Nazwa pliku: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
Tablice są przydatne, gdy chcesz, aby dane były alokowane na stosie, tak jak inne typy, które widzieliśmy do tej pory, a nie na stercie (omówimy stos i sterę dokładniej w Rozdziale 4) lub gdy chcesz zapewnić, że zawsze będziesz mieć stałą liczbę elementów. Tablica nie jest jednak tak elastyczna jak typ wektora. Wektor to podobny typ kolekcji dostarczany przez standardową bibliotekę, który może rosnąć lub kurczyć się w rozmiarze, ponieważ jego zawartość znajduje się na stercie. Jeśli nie masz pewności, czy użyć tablicy, czy wektora, prawdopodobnie powinieneś użyć wektora. Rozdział 8 omawia wektory bardziej szczegółowo.
Jednak tablice są bardziej przydatne, gdy wiesz, że liczba elementów nie będzie musiała się zmieniać. Na przykład, jeśli używałbyś nazw miesięcy w programie, prawdopodobnie użyłbyś tablicy, a nie wektora, ponieważ wiesz, że zawsze będzie zawierać 12 elementów:
#![allow(unused)]
fn main() {
let months = ["Styczeń", "Luty", "Marzec", "Kwiecień", "Maj", "Czerwiec", "Lipiec",
"Sierpień", "Wrzesień", "Październik", "Listopad", "Grudzień"];
}
Typ tablicy zapisujesz, używając nawiasów kwadratowych z typem każdego elementu, średnika, a następnie liczby elementów w tablicy, w ten sposób:
#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}
Tutaj i32 jest typem każdego elementu. Po średniku liczba 5 wskazuje, że
tablica zawiera pięć elementów.
Możesz również zainicjalizować tablicę tak, aby zawierała tę samą wartość dla każdego elementu, określając wartość początkową, po której następuje średnik, a następnie długość tablicy w nawiasach kwadratowych, jak pokazano tutaj:
#![allow(unused)]
fn main() {
let a = [3; 5];
}
Tablica o nazwie a będzie zawierała 5 elementów, z których wszystkie
początkowo zostaną ustawione na wartość 3. Jest to to samo, co napisanie
let a = [3, 3, 3, 3, 3];, ale w bardziej zwięzły sposób.
Dostęp do elementów tablicy
Tablica to pojedynczy blok pamięci o znanej, stałej wielkości, który może być alokowany na stosie. Możesz uzyskać dostęp do elementów tablicy za pomocą indeksowania, w ten sposób:
Nazwa pliku: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
W tym przykładzie zmienna o nazwie first otrzyma wartość 1, ponieważ jest
to wartość pod indeksem [0] w tablicy. Zmienna o nazwie second otrzyma
wartość 2 z indeksu [1] w tablicy.
Nieprawidłowy dostęp do elementów tablicy
Zobaczmy, co się stanie, jeśli spróbujesz uzyskać dostęp do elementu tablicy, który wykracza poza jej koniec. Powiedzmy, że uruchamiasz ten kod, podobny do gry w zgadywanie z Rozdziału 2, aby uzyskać indeks tablicy od użytkownika:
Nazwa pliku: src/main.rs
use std::io;
fn main() {
let a = [1, 2, 3, 4, 5];
println!("Please enter an array index.");
let mut index = String::new();
io::stdin()
.read_line(&mut index)
.expect("Failed to read line");
let index: usize = index
.trim()
.parse()
.expect("Index entered was not a number");
let element = a[index];
println!("The value of the element at index {index} is: {element}");
}
Ten kod kompiluje się pomyślnie. Jeśli uruchomisz ten kod za pomocą
cargo run i wprowadzisz 0, 1, 2, 3 lub 4, program wyświetli
odpowiednią wartość pod tym indeksem w tablicy. Jeśli zamiast tego wprowadzisz
liczbę wykraczającą poza koniec tablicy, taką jak 10, zobaczysz dane wyjściowe
podobne do tego:
thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Program spowodował błąd wykonawczy w momencie użycia nieprawidłowej wartości w
operacji indeksowania. Program zakończył działanie z komunikatem o błędzie i
nie wykonał końcowej instrukcji println!. Kiedy próbujesz uzyskać dostęp do
elementu za pomocą indeksowania, Rust sprawdzi, czy podany indeks jest mniejszy
niż długość tablicy. Jeśli indeks jest większy lub równy długości, Rust
spanikuje. To sprawdzenie musi nastąpić w czasie wykonania, szczególnie w tym
przypadku, ponieważ kompilator nie jest w stanie wiedzieć, jaką wartość
użytkownik wprowadzi, gdy uruchomi kod później.
Jest to przykład działania zasad bezpieczeństwa pamięci Rusta. W wielu niskopoziomowych językach ten rodzaj sprawdzenia nie jest wykonywany, a gdy podasz nieprawidłowy indeks, można uzyskać dostęp do nieprawidłowej pamięci. Rust chroni Cię przed tego rodzaju błędami, natychmiast kończąc działanie, zamiast pozwalać na dostęp do pamięci i kontynuować. Rozdział 9 omawia więcej o obsłudze błędów w Rust i o tym, jak pisać czytelny, bezpieczny kod, który ani nie panikuje, ani nie pozwala na dostęp do nieprawidłowej pamięci.