Типы данных
Каждое значение в Rust имеет определённый тип данных. Тип говорит Rust, что это за данные, а значит, как с ними работать. Мы рассмотрим две группы типов: скалярные и составные.
Помните, что Rust — язык со статической типизацией: типы всех переменных
должны быть известны уже при компиляции. Обычно компилятор сам выводит нужный
тип по значению и по тому, как оно используется. Но если подходит сразу
несколько типов — как в разделе «Сравниваем догадку с загаданным
числом» главы 2,
когда мы превращали String в число методом parse, — нужно добавить
аннотацию типа:
#![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
Для других типов данных аннотации выглядят по-разному — вы это ещё увидите.
Скалярные типы
Скалярный тип представляет одно значение. В Rust четыре основных скалярных типа: целые числа, числа с плавающей точкой, логические значения и символы. Скорее всего, вы встречали их и в других языках. Посмотрим, как они устроены в Rust.
Целочисленные типы
Целое число — это число без дробной части. В главе 2 мы уже пользовались
одним целочисленным типом — u32. Такое объявление типа говорит, что
значение — беззнаковое целое (у знаковых типов вместо u в начале стоит i) и
занимает 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, произойдёт переполнение (integer overflow). Дальше возможны два
варианта. При компиляции в отладочном режиме Rust добавляет проверки на
переполнение, и если оно случится, программа во время работы запаникует.
Словом «паника» (panic) в Rust называют аварийное завершение программы с
ошибкой; подробнее о панике мы поговорим в разделе «Неисправимые ошибки и
panic!» главы 9.
При компиляции в режиме релиза (с флагом --release) Rust не добавляет
проверок, вызывающих панику. Вместо этого при переполнении происходит
циклический перенос (two’s complement wrapping). Проще говоря, значения
больше максимума для типа «заворачиваются» и начинаются с минимума. Для u8
значение 256 превращается в 0, 257 — в 1 и так далее. Паники не будет, но в
переменной окажется не то значение, которого вы, скорее всего, ожидали.
Полагаться на такое «заворачивание» при переполнении считается ошибкой.
Чтобы явно обработать возможность переполнения, используйте семейства методов, которые стандартная библиотека даёт примитивным числовым типам:
wrapping_*(например,wrapping_add) — всегда заворачивают значение, в любом режиме компиляции;checked_*— при переполнении возвращаютNone;overflowing_*— возвращают значение и логический флаг, было ли переполнение;saturating_*— при переполнении «упираются» в минимальное или максимальное значение типа.
Числа с плавающей точкой
Ещё в Rust есть два примитивных типа для чисел с плавающей точкой, то есть
дробных чисел: f32 и f64 — 32 и 64 бита соответственно. По умолчанию
используется f64: на современных процессорах он работает примерно так же
быстро, как f32, но точнее. Все типы с плавающей точкой знаковые.
Вот пример работы с числами с плавающей точкой:
Файл: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
Числа с плавающей точкой представлены по стандарту IEEE-754.
Арифметические операции
Для всех числовых типов Rust поддерживает привычные математические операции:
сложение, вычитание, умножение, деление и остаток от деления. Целочисленное
деление отбрасывает дробную часть (округляет к нулю). Вот как каждая операция
выглядит в инструкции let:
Файл: src/main.rs
fn main() {
// сложение
let sum = 5 + 10;
// вычитание
let difference = 95.5 - 4.3;
// умножение
let product = 4 * 30;
// деление
let quotient = 56.7 / 32.2;
let truncated = -5 / 3; // Получится -1
// остаток от деления
let remainder = 43 % 5;
}
Каждое из этих выражений использует математический оператор и вычисляется в одно значение, которое затем привязывается к переменной. Полный список операторов Rust есть в приложении B.
Логический тип
Как и в большинстве других языков, у логического типа в Rust два возможных
значения: true и false. Занимает он один байт и обозначается bool.
Например:
Файл: src/main.rs
fn main() {
let t = true;
let f: bool = false; // с явной аннотацией типа
}
Чаще всего логические значения используются в условиях, например в выражении
if. Как работает if в Rust, мы разберём в разделе «Управление потоком
выполнения».
Символьный тип
Тип char — самый примитивный «буквенный» тип в Rust. Вот несколько примеров
объявления значений char:
Файл: src/main.rs
fn main() {
let c = 'z';
let z: char = 'ℤ'; // с явной аннотацией типа
let heart_eyed_cat = '😻';
}
Обратите внимание: литералы char записываются в одинарных кавычках, а
строковые литералы — в двойных. Тип char в Rust занимает 4 байта и
представляет скалярное значение Юникода (Unicode scalar value), а значит,
умеет гораздо больше, чем просто ASCII. Буквы с диакритикой, китайские,
японские и корейские иероглифы, эмодзи, пробелы нулевой ширины — всё это
допустимые значения char. Скалярные значения Юникода лежат в диапазонах от
U+0000 до U+D7FF и от U+E000 до U+10FFFF включительно. Однако понятия
«символ» в Юникоде на самом деле нет, поэтому ваше интуитивное представление о
«символе» может не совпадать с тем, что такое char в Rust. Подробно мы
обсудим это в разделе «Строки: текст в UTF-8» главы 8.
Составные типы
Составные типы объединяют несколько значений в один тип. В Rust два примитивных составных типа: кортежи и массивы.
Кортежи
Кортеж (tuple) — универсальный способ собрать несколько значений разных типов в одно составное значение. Длина кортежа фиксирована: после объявления он не может ни вырасти, ни уменьшиться.
Кортеж создаётся так: в круглых скобках через запятую перечисляются значения. У каждой позиции в кортеже свой тип, и типы разных значений не обязаны совпадать. В этом примере мы добавили необязательные аннотации типов:
Файл: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
Переменная tup привязана ко всему кортежу целиком: кортеж считается одним
составным элементом. Чтобы достать из кортежа отдельные значения, можно
разобрать его с помощью сопоставления с образцом:
Файл: 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. Это называется деструктуризацией (destructuring): один кортеж
разбивается на три части. В конце программа выводит значение 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 («единичный
тип»). И само значение, и его тип записываются как (); они обозначают пустое
значение или отсутствие возвращаемого значения. Если выражение не возвращает
никакого другого значения, оно неявно возвращает ().
Массивы
Другой способ хранить несколько значений вместе — массив. В отличие от кортежа, все элементы массива должны быть одного типа. А в отличие от массивов в некоторых других языках, длина массива в Rust фиксирована.
Значения массива записываются через запятую в квадратных скобках:
Файл: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
Массив пригодится, когда данные должны лежать на стеке, а не в куче (как и значения всех типов, что мы видели до сих пор; о стеке и куче подробно поговорим в главе 4). Или когда нужно гарантировать, что элементов всегда одно и то же количество. Но массив не так гибок, как вектор. Вектор — это похожая коллекция из стандартной библиотеки, которая может расти и уменьшаться, потому что её содержимое хранится в куче. Если не уверены, что выбрать — массив или вектор, — скорее всего, нужен вектор. Подробнее о векторах — в главе 8.
Зато массивы хороши, когда вы точно знаете, что количество элементов меняться не будет. Например, если программе нужны названия месяцев, лучше взять массив, а не вектор: месяцев всегда 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];, только короче.
Доступ к элементам массива
Массив — это один непрерывный кусок памяти известного фиксированного размера, который может располагаться на стеке. К элементам массива обращаются по индексу:
Файл: 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].
Обращение за пределы массива
Посмотрим, что будет, если обратиться к элементу за концом массива. Допустим, вы запускаете вот такой код, похожий на игру из главы 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 и о том, как писать понятный и безопасный код, который и не паникует, и не допускает некорректного доступа к памяти.