Типи даних (Data Types)
Кожне значення в Rust має певний тип даних, який повідомляє Rust, який саме вид даних було вказано, щоб він знав, як працювати з цими даними. Ми розглянемо два підмножини типів даних: скалярні (scalar) та складені (compound).
Пам’ятайте, що Rust — це статично типізована мова, що означає, що вона
має знати типи всіх змінних під час компіляції. Зазвичай компілятор може
вивести, який тип ми хочемо використовувати, на основі значення та того, як
ми його використовуємо. У випадках, коли можливі багато типів, наприклад, коли
ми перетворювали String на числовий тип за допомогою parse у розділі
“Порівняння припущення із секретним
числом” у розділі 2,
ми маємо додати анотацію типу, ось так:
#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}
Якщо ми не додамо показану в попередньому коді анотацію типу : u32, Rust
відобразить таку помилку, яка означає, що компілятору потрібно від нас більше
інформації, щоб знати, який тип ми хочемо використовувати:
$ 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
Ви побачите різні анотації типів для інших типів даних.
Скалярні типи (Scalar Types)
Скалярний тип представляє одне значення. Rust має чотири основні скалярні типи: цілі числа, числа з рухомою комою, булеві (boolean) та символи. Ви можете впізнати їх з інших мов програмування. Давайте перейдемо до того, як вони працюють у Rust.
Цілі типи (Integer Types)
Ціле число — це число без дробової частини. Ми використовували один цілочисельний
тип у розділі 2, тип u32. Це оголошення типу вказує, що значення, з яким воно
пов’язане, має бути беззнаковим цілим числом (знакові цілочисельні типи
починаються з i замість u), яке займає 32 біти пам’яті. Таблиця 3-1 показує
вбудовані цілочисельні типи в Rust. Ми можемо використовувати будь-який із цих
варіантів, щоб оголосити тип цілочисельного значення.
Таблиця 3-1: Цілочисельні типи в Rust
| Довжина | Знаковий | Беззнаковий |
|---|---|---|
| 8-бітний | i8 | u8 |
| 16-бітний | i16 | u16 |
| 32-бітний | i32 | u32 |
| 64-бітний | i64 | u64 |
| 128-бітний | i128 | u128 |
| Залежний від архітектури | isize | usize |
Кожен варіант може бути або знаковим, або беззнаковим і має явний розмір. Знаковий і беззнаковий стосуються того, чи можливо для числа бути від’ємним — іншими словами, чи потрібно числу мати знак із собою (знаковий), чи воно буде лише додатним і тому може бути представлене без знака (беззнаковий). Це як записувати числа на папері: коли знак має значення, число показується зі знаком плюс або мінус; однак коли можна безпечно припустити, що число додатне, воно показується без знака. Знакові числа зберігаються за допомогою представлення доповняльного коду (two’s complement).
Кожен знаковий варіант може зберігати числа від −(2n − 1) до 2n −
1 − 1 включно, де n — це кількість бітів, яку використовує цей варіант.
Отже, i8 може зберігати числа від −(27) до 27 − 1, що дорівнює
від −128 до 127. Беззнакові варіанти можуть зберігати числа від 0 до 2n − 1,
тому u8 може зберігати числа від 0 до 28 − 1, що дорівнює від 0 до 255.
Крім того, типи isize і usize залежать від архітектури комп’ютера, на якому
запущена ваша програма: 64 біти, якщо ви на 64-бітній архітектурі,
і 32 біти, якщо ви на 32-бітній архітектурі.
Ви можете записувати цілочисельні літерали в будь-якій із форм, показаних у Таблиці 3-2. Зауважте,
що числові літерали, які можуть належати до кількох числових типів, дозволяють суфікс типу,
такий як 57u8, щоб позначити тип. Числові літерали також можуть використовувати _ як
візуальний роздільник, щоб зробити число легшим для читання, наприклад 1_000, яке матиме
те саме значення, що й 1000.
Таблиця 3-2: Цілочисельні літерали в Rust
| Числові літерали | Приклад |
|---|---|
| Десятковий | 98_222 |
| Шістнадцятковий | 0xff |
| Вісімковий | 0o77 |
| Двійковий | 0b1111_0000 |
Байтовий (u8 лише) | b'A' |
Отже, як дізнатися, який цілочисельний тип використовувати? Якщо ви не впевнені, типові
значення Rust зазвичай є хорошими точками для початку: цілочисельні типи за замовчуванням
мають тип i32. Основна ситуація, у якій ви б використовували isize або usize,
— це індексування деякої колекції.
Переповнення цілого числа
Припустімо, у вас є змінна типу
u8, яка може містити значення від 0 до 255. Якщо ви спробуєте змінити змінну на значення поза цим діапазоном, наприклад 256, станеться переповнення цілого числа, що може призвести до однієї з двох поведінок. Коли ви компілюєте в режимі debug, Rust додає перевірки на переповнення цілого числа, які спричиняють паніку програми під час виконання, якщо це відбувається. Rust використовує термін панікування, коли програма завершується з помилкою; ми детальніше обговоримо паніку в розділі “Невідновлювані помилки зpanic!” у розділі 9.Коли ви компілюєте в режимі release з прапорцем
--release, Rust не додає перевірки на переповнення цілого числа, які спричиняють паніку. Натомість, якщо відбувається переповнення, Rust виконує зациклення за доповняльним кодом (two’s complement wrap-around). Коротко кажучи, значення, більші за максимальне значення, яке може містити тип, “обертаються” до мінімального значення, яке може містити тип. У випадкуu8значення 256 стає 0, значення 257 стає 1, і так далі. Програма не панікуватиме, але змінна матиме значення, яке, ймовірно, не є тим, якого ви очікували. Покладатися на поведінку переповнення цілого числа як на зациклення (wrap-around) вважається помилкою.Щоб явно обробляти можливість переповнення, ви можете використовувати такі сімейства методів, надані стандартною бібліотекою для примітивних числових типів:
- Обертати в усіх режимах за допомогою методів
wrapping_*, таких якwrapping_add.- Повернути значення
None, якщо є переповнення, за допомогою методівchecked_*.- Повернути значення та булеве значення (boolean), що вказує, чи було переповнення, за допомогою методів
overflowing_*.- Насичуватися на мінімальному або максимальному значенні за допомогою методів
saturating_*.
Типи з рухомою комою (Floating-Point Types)
Rust також має два примітивні типи для чисел з рухомою комою, які є
числами з десятковими крапками. Типи з рухомою комою в Rust — це f32 і f64,
які мають розмір 32 біти та 64 біти відповідно. Тип за замовчуванням — f64,
тому що на сучасних CPU він приблизно такий самий швидкий, як f32, але здатний
до більшої точності. Усі типи з рухомою комою є знаковими.
Ось приклад, який показує числа з рухомою комою в дії:
Ім’я файлу: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
Числа з рухомою комою представлені відповідно до стандарту IEEE-754.
Числові операції (Numeric Operations)
Rust підтримує базові математичні операції, яких ви очікували б для всіх
числових типів: додавання, віднімання, множення, ділення та остача. Цілочисельне
ділення відтинає до нуля до найближчого цілого числа. Наступний код показує,
як би ви використовували кожну числову операцію в операторі let:
Ім’я файлу: src/main.rs
fn main() {
// addition
let sum = 5 + 10;
// subtraction
let difference = 95.5 - 4.3;
// multiplication
let product = 4 * 30;
// division
let quotient = 56.7 / 32.2;
let truncated = -5 / 3; // Results in -1
// remainder
let remainder = 43 % 5;
}
Кожен вираз у цих операторах використовує математичний оператор і обчислюється до одного значення, яке потім прив’язується до змінної. Додаток B містить список усіх операторів, які надає Rust.
Булевий (boolean) тип (The Boolean Type)
Як і в більшості інших мов програмування, булевий (boolean) тип у Rust має два можливі
значення: true і false. Булеві значення (boolean values) мають розмір один байт. Тип Boolean у
Rust позначається за допомогою bool. Наприклад:
Ім’я файлу: src/main.rs
fn main() {
let t = true;
let f: bool = false; // with explicit type annotation
}
Основний спосіб використання булевих значень (boolean values) — через умовні конструкції, такі як
вираз if. Ми розглянемо, як працюють вирази if у Rust, у розділі “Керування
потоком”.
Тип символу (The Character Type)
Тип char у Rust — це найпримітивніший алфавітний тип мови. Ось
кілька прикладів оголошення значень char:
Ім’я файлу: src/main.rs
fn main() {
let c = 'z';
let z: char = 'ℤ'; // with explicit type annotation
let heart_eyed_cat = '😻';
}
Зауважте, що ми вказуємо літерали char одинарними лапками, на відміну від
рядкових літералів, які використовують подвійні лапки. Тип char у Rust має
розмір 4 байти і представляє скалярне значення Unicode (Unicode scalar value), що означає, що він може
представляти набагато більше, ніж лише ASCII. Літери з наголосами; китайські,
японські та корейські символи; emoji; і пробіли нульової ширини — усе це
є дійсними значеннями char у Rust. Скалярні значення Unicode (Unicode scalar values) знаходяться в діапазоні
від U+0000 до U+D7FF і від U+E000 до U+10FFFF включно. Однак
“символ” насправді не є концепцією в Unicode, тому ваша людська інтуїція щодо
того, чим є “символ”, може не збігатися з тим, чим є char у Rust. Ми детально
обговоримо цю тему в розділі “Зберігання тексту, закодованого в UTF-8,
у рядках” у розділі 8.
Складені типи (Compound Types)
Складені типи можуть групувати кілька значень в один тип. Rust має два примітивні складені типи: кортежі та масиви.
Тип кортежу (The Tuple Type)
Кортеж — це загальний спосіб згрупувати разом певну кількість значень із різними типами в один складений тип. Кортежі мають фіксовану довжину: після оголошення вони не можуть збільшуватися або зменшуватися в розмірі.
Ми створюємо кортеж, записуючи список значень, розділених комами, всередині дужок. Кожна позиція в кортежі має тип, і типи різних значень у кортежі не обов’язково мають бути однаковими. У цьому прикладі ми додали необов’язкові анотації типів:
Ім’я файлу: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
Змінна tup прив’язується до всього кортежу, тому що кортеж вважається
одним складеним елементом. Щоб отримати окремі значення з кортежу, ми можемо
використати зіставлення зі зразком (pattern matching), щоб розпакувати значення кортежу, ось так:
Ім’я файлу: src/main.rs
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {y}");
}
Ця програма спочатку створює кортеж і прив’язує його до змінної tup. Потім
вона використовує шаблон з let, щоб взяти tup і перетворити його на три окремі
змінні, x, y і z. Це називається розпакуванням, тому що воно розбиває
один кортеж на три частини. Нарешті, програма друкує значення y, яке дорівнює
6.4.
Ми також можемо безпосередньо отримати доступ до елемента кортежу, використовуючи
крапку (.), після якої вказується індекс значення, до якого ми хочемо отримати
доступ. Наприклад:
Ім’я файлу: 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;
}
Ця програма створює кортеж x, а потім отримує доступ до кожного елемента кортежу,
використовуючи відповідні індекси. Як і в більшості мов програмування, перший
індекс у кортежі — 0.
Кортеж без жодних значень має спеціальну назву, одиничний тип (unit type). Це значення і його
відповідний тип обидва записуються як () і представляють порожнє значення або
порожній тип повернення. Вирази неявно повертають значення unit, якщо вони не
повертають жодного іншого значення.
Тип масиву (The Array Type)
Інший спосіб мати колекцію з кількох значень — це масив. На відміну від кортежу, кожен елемент масиву має мати той самий тип. На відміну від масивів у деяких інших мовах, масиви в Rust мають фіксовану довжину.
Ми записуємо значення в масиві як список, розділений комами, всередині квадратних дужок:
Ім’я файлу: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
Масиви корисні, коли ви хочете, щоб ваші дані були розміщені в стеку, так само як інші типи, які ми бачили дотепер, а не в купі (ми обговоримо стек і купу докладніше в Розділі 4) або коли ви хочете гарантувати, що у вас завжди є фіксована кількість елементів. Однак масив не настільки гнучкий, як тип вектора (vector). Вектор (vector) — це подібний тип колекції, наданий стандартною бібліотекою, якому дозволено збільшуватися або зменшуватися в розмірі, тому що його вміст живе в купі. Якщо ви не впевнені, чи використовувати масив або вектор (vector), швидше за все, вам слід використовувати вектор (vector). Розділ 8 докладніше обговорює вектори (vectors).
Однак масиви корисніші, коли ви знаєте, що кількість елементів не потрібно буде змінювати. Наприклад, якщо б ви використовували назви місяців у програмі, ви, ймовірно, використовували б масив, а не vector, тому що ви знаєте, що він завжди міститиме 12 елементів:
#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
}
Ви записуєте тип масиву за допомогою квадратних дужок із типом кожного елемента, крапкою з комою, а потім кількістю елементів у масиві, ось так:
#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}
Тут i32 — це тип кожного елемента. Після крапки з комою число 5
вказує, що масив містить п’ять елементів.
Ви також можете ініціалізувати масив так, щоб він містив те саме значення для кожного елемента, вказавши початкове значення, після нього — крапку з комою, а потім довжину масиву в квадратних дужках, як показано тут:
#![allow(unused)]
fn main() {
let a = [3; 5];
}
Масив із назвою a міститиме 5 елементів, і всі вони спочатку будуть
встановлені в значення 3. Це те саме, що написати let a = [3, 3, 3, 3, 3];, але
більш стисло.
Доступ до елементів масиву (Accessing Array Elements)
Масив — це один фрагмент пам’яті відомого, фіксованого розміру, який може бути розміщений у стеку. Ви можете отримати доступ до елементів масиву, використовуючи індексування, ось так:
Ім’я файлу: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
У цьому прикладі змінна з назвою first отримає значення 1, тому що це
значення з індексом [0] у масиві. Змінна з назвою second отримає
значення 2 з індексу [1] у масиві.
Недійсний доступ до елемента масиву (Invalid Array Element Access)
Давайте подивимося, що станеться, якщо ви спробуєте отримати доступ до елемента масиву, який знаходиться за межами масиву. Припустімо, ви запускаєте цей код, подібний до гри у вгадування в розділі 2, щоб отримати індекс масиву від користувача:
Ім’я файлу: 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}");
}
Цей код успішно компілюється. Якщо ви запустите цей код за допомогою cargo run
і введете 0, 1, 2, 3 або 4, програма виведе відповідне
значення за цим індексом у масиві. Якщо натомість ви введете число за межами
масиву, наприклад 10, ви побачите такий вивід:
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
Програма завершилася з помилкою під час виконання в точці використання недійсного
значення в операції індексування. Програма завершилася з повідомленням про помилку і
не виконала останній оператор println!. Коли ви намагаєтеся отримати доступ до
елемента за допомогою індексування, Rust перевірятиме, що вказаний вами індекс менший
за довжину масиву. Якщо індекс більший або дорівнює довжині,
Rust викличе паніку. Цю перевірку потрібно виконувати під час виконання, особливо в цьому випадку,
тому що компілятор жодним чином не може знати, яке значення введе користувач, коли
запустить код пізніше.
Це приклад принципів безпеки пам’яті Rust у дії. У багатьох мовах низького рівня такий тип перевірки не виконується, і коли ви надаєте неправильний індекс, може бути отримано доступ до недійсної пам’яті. Rust захищає вас від такої помилки, негайно завершуючи роботу замість того, щоб дозволити доступ до пам’яті і продовжити виконання. Розділ 9 обговорює більше про обробку помилок у Rust і про те, як ви можете писати читабельний, безпечний код, який не викликає паніку і не дозволяє отримати доступ до недійсної пам’яті.