Типы Данных
Каждое значение в Rust относится к определённому типу данных, который указывает на вид данных, что позволяет Rust знать, как работать с этими данными. Мы рассмотрим два подмножества типов данных: скалярные и составные.
Не забывайте, что Rust является статически типизированным (statically typed) языком. Это означает, что он должен знать типы всех переменных во время компиляции. Обычно компилятор может предположить, какой тип используется (вывести его), основываясь на значении и на том, как мы с ним работаем. В случаях, когда может быть выведено несколько типов, необходимо добавлять аннотацию типа вручную. Например, когда мы конвертировали 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.
Целочисленные типы
Целочисленный тип (integer) — это число без дробной части. В главе 2 мы использовали один целочисленный тип — тип u32
. Такое объявление типа указывает, что значение, с которым оно связано, должно быть целым числом без знака (типы целых чисел со знаком начинаются с i
вместо u
), которое занимает 32 бита памяти. В Таблице 3-1 показаны встроенные целочисленные типы в Rust. Мы можем использовать любой из этих вариантов для объявления типа целочисленного значения.
Длина | Со знаком | Без знака |
---|---|---|
8 бит | i8 | u8 |
16 бит | i16 | u16 |
32 бита | i32 | u32 |
64 бита | i64 | u64 |
128 бит | i128 | u128 |
архитектурно-зависимая | isize | usize |
Каждый вариант может быть как со знаком, так и без знака и имеет явный размер. Такая характеристика типа как знаковый и беззнаковый определяет возможность числа быть отрицательным. Другими словами, должно ли число иметь знак (знаковое) или оно всегда будет только положительным и, следовательно, может быть представлено без знака (беззнаковое). Это похоже на написание чисел на бумаге: когда знак имеет значение, число отображается со знаком плюс или со знаком минус; однако, когда можно с уверенностью предположить, что число положительное, оно отображается без знака. Числа со знаком хранятся с использованием дополнительного кода.
Каждый вариант со знаком может хранить числа от -(2 n - 1 ) до 2 n - 1 - 1 включительно, где n — количество битов, которые использует этот вариант. Таким образом, i8
может хранить числа от -(2 7 ) до 2 7 - 1, что равно значениям от -128 до 127. Варианты без знака могут хранить числа от 0 до 2 n - 1, поэтому u8
может хранить числа от 0 до 2 8 - 1, что равно значениям от 0 до 255.
Кроме того, типы isize
и usize
зависят от архитектуры компьютера, на котором выполняется программа, и обозначаются в таблице как "arch": 64 бита, если используется 64-битная архитектура, и 32 бита, если используется 32-битная архитектура.
Вы можете записывать целочисленные литералы в любой из форм, показанных в таблице 3-2. Заметьте, что числовые литералы, имеющие несколько числовых типов, допускают использование суффикса типа, например 57u8
, для обозначения типа. Числовые литералы также могут использовать _
в качестве визуального разделителя для облегчения чтения числа, например 1_000
, который будет иметь такое же значение, как если бы было задано 1000
.
Числовой литерал | Пример |
---|---|
Десятичный | 98_222 |
Шестнадцатеричный | 0xff |
восьмеричный | 0o77 |
Двоичный | 0b1111_0000 |
Байт (только u8 ) | b'A' |
Как же узнать, какой тип целого числа использовать? Если вы не уверены, значения по умолчанию в Rust, как правило, подходят для начала: целочисленные типы по умолчанию i32
. Основной случай, в котором вы должны использовать isize
или usize
, — это индексация какой-либо коллекции.
Целочисленное переполнение Допустим, имеется переменная типаu8
, которая может хранить значения от 0 до 255. Если попытаться изменить переменную на значение вне этого диапазона, например, 256, произойдёт целочисленное переполнение, что может привести к одному из двух вариантов поведения. Если выполняется компиляция в режиме отладки, Rust включает проверку на целочисленное переполнение, приводящую вашу программу к панике во время выполнения, когда возникает такое поведение. Rust использует термин паника(panicking), когда программа завершается с ошибкой. Мы обсудим панику более подробно в разделе "Неустранимые ошибки сpanic!
" в главе 9. . При компиляции в режиме release с флагом--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. Тип f32
является плавающей запятой одинарной точности, а f64
- двойной точности.
Числовые операции
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. Мы подробно обсудим эту тему в главе 8 "Хранение текста в кодировке UTF-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.
Кортеж, не имеющий значений, имеет специальное имя единичный тип (unit). Это значение и соответствующий ему тип записываются как ()
и представляет собой пустое значение или пустой возвращаемый тип. Выражения неявно возвращают значение единичного типа, если не возвращают никакого другого значения.
Массивы
Другим способом создания коллекции из нескольких значений является массив array. В отличие от кортежа, каждый элемент массива должен иметь один и тот же тип. В отличие от массивов в некоторых других языках, массивы в 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
, показывающее, что массив содержит 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 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Программа столкнулась с ошибкой во времени выполнения на этапе применения недопустимого значения в операции индексирования. Программа завершилась с сообщением об ошибке и не выполнила финальную инструкцию println!
. При попытке доступа к элементу с помощью индексирования Rust проверяет, что указанный индекс меньше длины массива. Если индекс больше или равен длине, Rust паникует. Эта проверка должна происходить во время выполнения, особенно в данном случае, потому что компилятор не может знать, какое значение введёт пользователь при последующем выполнении кода.
Это пример принципов безопасности памяти Rust в действии. Во многих низкоуровневых языках такая проверка не выполняется, и когда вы указываете неправильный индекс, доступ к памяти может быть некорректным. Rust защищает вас от такого рода ошибок, немедленно закрываясь вместо того, чтобы разрешать доступ к памяти и продолжать работу. В главе 9 подробнее обсуждается обработка ошибок в Rust и то, как вы можете написать читаемый, безопасный код, который не вызывает панику и не разрешает некорректный доступ к памяти.