Типы данных
Каждое значение в Rust имеет определённый тип данных, сообщающий о том, что это за значение и как с ним работать. Мы посмотрим на две группы типов данных: неделимые и составные.
Не забывайте, что Rust — язык со статической типизацией. Это означает, что типы всех переменных должны быть известны уже на этапе компиляции. Компилятор обычно может самостоятельно вывести тип переменной, основываясь на её значении и том, как она используется. В тех случаях, когда компилятор не может вывести конкретный тип (например, как в том случае, когда мы преобразовывали String
к численному типу данных методом parse
в разделе "Сравнение догадки с загаданным числом" Главы 2), мы должны явно аннотировать тип; вот так:
#![allow(unused)] fn main() { let guess: u32 = "42".parse().expect("Не число!"); }
Если мы не добавим аннотацию типа (: 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("Не число!");
| ^^^^^ ----- 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("Не число!");
| ++++++++++++
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 |
На каждую длину в битах приходится два варианта: знаковое и беззнаковое целые. Термины знаковое и беззнаковое означают возможность для числа принимать отрицательные значения; другими словами, необходимо ли числу иметь определённый знак (это будут знаковые целые), или же оно однозначно будет положительным и потому может храниться без знака (это будут беззнаковые целые). Представьте, что записываете числа на бумаге: если вам важно знать знак, вы будете тратить место на запись знака "плюс" или "минус"; если же вы знаете, что точно работаете с положительными числами, вы не тратите место на знак. Знаковые целые хранятся в памяти с помощью дополнительного кода.
Каждый знаковый тип может хранить значения от −(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. Если вы попытаетесь приписать этой переменной значение за этими пределами (например, 270), произойдёт целочисленное переполнение, которое может привести к двум различным ситуациям. Если вы скомпилировали программу в режиме отладки, Rust включил в неё проверки на целочисленное переполнение, которые вызовут панику, если переполнение произойдёт. В Rust под термином паника понимается завершение программы с ошибкой. Больше о панике мы поговорим в разделе "Неустранимые ошибки с panic!" Главы 9.Когда вы компилируете программу в релизном режиме (используя флаг
--release
), Rust не включает проверки на целочисленное переполнение и не вызывает паники в его случае. Вместо этого, Rust выполняет обращение по модулю. Кратко говоря, значения, превышающие максимальное значение типа, "оборачиваются" по модулю до числа, которое будет находиться в пределах типа. В случае типа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; }
В каждой строчке используется математический оператор и вычисляется результат, который привязывается к переменной. Приложение B содержит список всех операторов в Rust.
Логический тип
Как и в других языках программирования, логический тип в Rust представлен двумя возможными значениями: true
и false
. Логический тип занимает один байт в памяти. Логический тип аннотируется словом bool
; например:
Файл: src/main.rs
fn main() { let t = true; let f: bool = false; // с явной аннотацией типа }
Основным местом, где используются логические значения, являются условные выражения, вроде выражения if
. Мы расскажем о том, как раюотают выражения if
, в разделе "Управление потоком".
Тип символа
Тип char
— это простейший тип, реализующий символ. Вот несколько примеров объявления значений типа char
:
Файл: src/main.rs
fn main() { let c = 'z'; let z: char = 'ℤ'; // с явной аннотацией типа let heart_eyed_cat = '😻'; }
Обратите внимание, что мы заключаем литерал char
в одиночные кавычки, тогда как строки обрамляются двойными кавычками. Тип char
занимает в памяти четыре байта и представляет собой символ Unicode, то есть он может представлять значительно больше символов, чем ASCII. Диакритические знаки, китайские иероглифы, японские кандзи, катакану и хирагану, корейский хангыль, эмодзи, пробелы нулевой ширины — всё это допустимые значения типа char
в Rust. Символы Unicode принадлежат промежутку от U+0000
до U+D7FF
и от U+E000
до U+10FFFF
включительно. Однако, как такового, понятия "символ" в Unicode нет, так что ваше интуитивное представление о том, что такое "символ", может не соответствовать тому, чем может являться значение типа char
. Мы обсудим это подробнее в разделе "Хранение текста в кодировке 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!("Значение y: {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
и затем получает каждый его элемент, используя обращение по индексу. Как и в большинстве языков программирования, индексация начинается с нуля.
Кортеж без элементов — особенный, он называется unit. Значение этого типа записывается так же, как и сам тип: ()
. Unit представляет собой отсутствие значения или отсутствие возвращаемого значения. Выражения неявно возвращают unit, если не возвращают что-либо другое.
Тип массива
Другой базовой коллекцией нескольких значений является массив. В противоположность кортежу, все элементы массива должны иметь одинаковый тип. В отличие от некоторых других языков программирования, массивы в Rust имеют постоянную длину.
Массив записывается как заключённый в квадратные скобки список значений, разделённых запятыми:
Файл: src/main.rs
fn main() { let a = [1, 2, 3, 4, 5]; }
Массивы нужны тогда, когда вам необходимо разместить некоторые данные на стеке (подобно типам, рассмотренным нами ранее), а не в куче (кучу и стек мы обсудим подробнее в Главе 4), или когда вам нужно точно быть уверенным, что количество элементов строго постоянно. Более гибким типом, чем массив, является вектор. Вектор — это похожий на массив тип коллекции данных, предоставляемый стандартной библиотекой. Вектор можно сокращать или удлинять. Если вы сомневаетесь, что использовать: массив или вектор, возможно, вам нужен вектор. Векторы будут рассмотрены подробнее в Главе 8.
Однако, массивы будут полезны в том случае, если вы точно знаете, что количество элементов никогда не изменится. Например, если в своей программе вы используете названия месяцев, вам, вероятно, понадобится именно массив, поскольку вы точно знаете, что он всегда будет содержать 12 элементов:
#![allow(unused)] fn main() { let months = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"]; }
Тип массива записывается как разделённая точкой с запятой и заключённая в квадратные скобки пара из типа, к которому будет принадлежать каждый элемент, и количества элементов массива. Например:
#![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!("Пожалуйста, введите индекс элемента массива.");
let mut index = String::new();
io::stdin()
.read_line(&mut index)
.expect("Не удалось прочесть ввод.");
let index: usize = index
.trim()
.parse()
.expect("Введено не число");
let element = a[index];
println!("Значение элемента массива по индексу {index}: {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 и способы написания более читаемого и безопасного кода — такого, который не запаникует и не позволит некорректно обращаться к памяти.