Типы данных
Каждое значение в Rust имеет определённый тип данных, который сообщает Rust, какой вид данных указан, чтобы он знал, как работать с этими данными. Мы рассмотрим два подмножества типов данных: скалярные и составные.
Имейте в виду, что 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
Вы увидите разные аннотации типов для других типов данных.
Скалярные типы
Скалярный тип представляет одно значение. В Rust есть четыре основных скалярных типа: целые числа, числа с плавающей точкой, булевы значения и символы. Вы можете узнать их из других языков программирования. Давайте перейдём к тому, как они работают в Rust.
Целые числа
Целое число — это число без дробной части. Мы использовали один тип целых чисел в главе 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 |
Каждый вариант может быть либо знаковым, либо беззнаковым и имеет явный размер. Знаковые и беззнаковые относятся к тому, может ли число быть отрицательным — другими словами, нужно ли число иметь знак (знаковое) или оно будет только положительным и поэтому может быть представлено без знака (беззнаковое). Это как запись чисел на бумаге: когда знак важен, число показывается со знаком плюс или минус; однако, когда можно безопасно предположить, что число положительное, оно показывается без знака. Знаковые числа хранятся с использованием дополнительного кода.
Каждый знаковый вариант может хранить числа от −(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, произойдёт переполнение целых чисел, которое может привести к одному из двух поведений. При компиляции в режиме отладки Rust включает проверки на переполнение целых чисел, которые вызывают панику вашей программы во время выполнения, если такое поведение происходит. Rust использует термин паниковать, когда программа завершается с ошибкой; мы обсудим паники более подробно в разделе «Неисправимые ошибки с panic!» главы 9.
При компиляции в режиме выпуска с флагом --release Rust не включает проверки на переполнение целых чисел, вызывающие панику. Вместо этого, если происходит переполнение, Rust выполняет дополнительное обёртывание. Короче говоря, значения, превышающие максимальное значение, которое может хранить тип, «заворачиваются» до минимального значения типа. В случае с u8 значение 256 становится 0, значение 257 становится 1 и так далее. Программа не упадёт в панику, но переменная будет иметь значение, которое, вероятно, не соответствует вашим ожиданиям. Полагаться на поведение обёртывания при переполнении целых чисел считается ошибкой.
Чтобы явно обработать возможность переполнения, вы можете использовать эти семейства методов, предоставляемых стандартной библиотекой для примитивных числовых типов:
- Обёртывание во всех режимах с помощью методов
wrapping_*, таких какwrapping_add. - Возврат значения
None, если происходит переполнение, с помощью методовchecked_*. - Возврат значения и логического значения, указывающего, было ли переполнение, с помощью методов
overflowing_*. - Насыщение до минимального или максимального значения типа с помощью методов
saturating_*.
Типы с плавающей точкой
Rust также имеет два примитивных типа для чисел с плавающей точкой, которые являются числами с десятичными точками. Типы с плавающей точкой в 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() { // 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.
Булев тип
Как и в большинстве других языков программирования, булев тип в Rust имеет два возможных значения: true и false. Булевы значения занимают один байт. Булев тип в Rust указывается с помощью bool. Например:
Имя файла: src/main.rs
fn main() { let t = true; let f: bool = false; // with explicit type annotation }
Основной способ использования булевых значений — через условные выражения, такие как выражение if. Мы рассмотрим, как работают выражения if в Rust, в разделе «Управление потоком».
Тип символа
Тип 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 имеет размер четыре байта и представляет скалярное значение Unicode, что означает, что он может представлять гораздо больше, чем просто ASCII. Буквы с диакритическими знаками; китайские, японские и корейские символы; эмодзи; и нулевые пробелы — все это допустимые значения char в Rust. Скалярные значения Unicode находятся в диапазоне от U+0000 до U+D7FF и от U+E000 до U+10FFFF включительно. Однако «символ» — это на самом деле не концепция в Unicode, поэтому ваша человеческая интуиция о том, что такое «символ», может не совпадать с тем, что такое char в Rust. Мы обсудим эту тему подробно в «Хранение текста в кодировке UTF-8 со строками» в главе 8.
Составные типы
Составные типы могут группировать несколько значений в один тип. В Rust есть два примитивных составных типа: кортежи и массивы.
Тип кортежа
Кортеж — это общий способ группировки нескольких значений с различными типами в один составной тип. Кортежи имеют фиксированную длину: после объявления они не могут увеличиваться или уменьшаться в размере.
Мы создаём кортеж, записывая разделённый запятыми список значений внутри круглых скобок. Каждая позиция в кортеже имеет тип, и типы разных значений в кортеже не обязательно должны быть одинаковыми. Мы добавили необязательные аннотации типов в этом примере:
Имя файла: 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. Это называется деструктуризацией, потому что она разбивает один кортеж на три части. Наконец, программа выводит значение 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.
Кортеж без каких-либо значений имеет специальное название — единица. Это значение и соответствующий ему тип оба записываются как () и представляют пустое значение или пустой тип возвращаемого значения. Выражения неявно возвращают единичное значение, если они не возвращают никакое другое значение.
Кроме того, мы можем изменять отдельные элементы изменяемого кортежа. Например:
Имя файла: src/main.rs
fn main() { let mut x: (i32, i32) = (1, 2); x.0 = 0; x.1 += 5; }
Эта программа устанавливает первый элемент в ноль и добавляет пять ко второму элементу. Конечное значение x равно (0, 7).
Тип массива
Другой способ иметь коллекцию из нескольких значений — это массив. В отличие от кортежа, каждый элемент массива должен иметь один и тот же тип. В отличие от массивов в некоторых других языках, массивы в 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 и о том, как вы можете писать читаемый, безопасный код, который не паникует и не разрешает недопустимый доступ к памяти.