Типы данных
Every value in Rust is of a certain data type, which tells Rust what kind of data is being specified so that it knows how to work with that data. We’ll look at two data type subsets: scalar and compound.
Не забывайте, что 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 |
| Architecture-dependent | isize | usize |
Each variant can be either signed or unsigned and has an explicit size. Signed and unsigned refer to whether it’s possible for the number to be negative—in other words, whether the number needs to have a sign with it (signed) or whether it will only ever be positive and can therefore be represented without a sign (unsigned). It’s like writing numbers on paper: When the sign matters, a number is shown with a plus sign or a minus sign; however, when it’s safe to assume the number is positive, it’s shown with no sign. Signed numbers are stored using two’s complement representation.
Each signed variant can store numbers from −(2n − 1) to 2n − 1 − 1 inclusive, where n is the number of bits that variant uses. So, an i8 can store numbers from −(27) to 27 − 1, which equals −128 to 127. Unsigned variants can store numbers from 0 to 2n − 1, so a u8 can store numbers from 0 to 28 − 1, which equals 0 to 255.
Additionally, the isize and usize types depend on the architecture of the computer your program is running on: 64 bits if you’re on a 64-bit architecture and 32 bits if you’re on a 32-bit architecture.
Вы можете записывать литералы целых чисел в любом формате из тех, что перечислены в Таблице 3-2. Стоит отметить, что тип литералов, которые могут быть отнесены к разным типам, может быть определён явно с помощью суффикса типа, например: 57u8. В числовых литералах можно также использовать знак _ как разделитель для удобства чтения: например, запись 1_000 будет эквивалентна записи 1000.
Таблица 3-2: Целочисленные литералы в Rust
| Литералы | Пример |
|---|---|
| Десятичный | 98_222 |
| Шестнадцатеричный | 0xff |
| Восьмеричный | 0o77 |
| Двоичный | 0b1111_0000 |
Байт (только тип u8) | b'A' |
So how do you know which type of integer to use? If you’re unsure, Rust’s defaults are generally good places to start: Integer types default to i32. The primary situation in which you’d use isize or usize is when indexing some sort of collection.
Целочисленное переполнение
Допустим, у вас есть переменная типа
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 = '😻'; }
Note that we specify char literals with single quotation marks, as opposed to string literals, which use double quotation marks. Rust’s char type is 4 bytes in size and represents a Unicode scalar value, which means it can represent a lot more than just ASCII. Accented letters; Chinese, Japanese, and Korean characters; emojis; and zero-width spaces are all valid char values in Rust. Unicode scalar values range from U+0000 to U+D7FF and U+E000 to U+10FFFF inclusive. However, a “character” isn’t really a concept in Unicode, so your human intuition for what a “character” is may not match up with what a char is in Rust. We’ll discuss this topic in detail in “Storing UTF-8 Encoded Text with Strings” in Chapter 8.
Составные типы
Составные типы собираются из нескольких других типов. Rust имеет два базовых составных типа: кортежи и массивы.
Тип кортежа
A tuple is a general way of grouping together a number of values with a variety of types into one compound type. Tuples have a fixed length: Once declared, they cannot grow or shrink in size.
Кортеж записывается как заключённый в круглые скобки список значений, разделённых запятыми. Каждая элемент кортежа имеет собственный тип, и типы разных элементов могут не совпадать. В примере ниже мы создаём кортеж и (что необязательно) аннотируем переменную, с которой он будет связан:
Файл: 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]; }
Arrays are useful when you want your data allocated on the stack, the same as the other types we have seen so far, rather than the heap (we will discuss the stack and the heap more in Chapter 4) or when you want to ensure that you always have a fixed number of elements. An array isn’t as flexible as the vector type, though. A vector is a similar collection type provided by the standard library that is allowed to grow or shrink in size because its contents live on the heap. If you’re unsure whether to use an array or a vector, chances are you should use a vector. Chapter 8 discusses vectors in more detail.
Однако, массивы будут полезны в том случае, если вы точно знаете, что количество элементов никогда не изменится. Например, если в своей программе вы используете названия месяцев, вам, вероятно, понадобится именно массив, поскольку вы точно знаете, что он всегда будет содержать 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];, но, очевидно, запись выше куда проще и прозрачнее.
Array Element Access
Массив — это единый участок памяти постоянного известного размера, размещаемый на стеке. Вы можете получить доступ к элементам массива, обращаясь к ним по индексу; вот так:
Файл: 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
The program resulted in a runtime error at the point of using an invalid value in the indexing operation. The program exited with an error message and didn’t execute the final println! statement. When you attempt to access an element using indexing, Rust will check that the index you’ve specified is less than the array length. If the index is greater than or equal to the length, Rust will panic. This check has to happen at runtime, especially in this case, because the compiler can’t possibly know what value a user will enter when they run the code later.
Это — наглядный пример принципов обеспечения безопасности памяти в Rust. Во многих низкоуровневых языках подобные проверки не проводятся, а потому обращение к элементам за пределами массива приводит к получению некорректных данных. Rust защищает вас от подобных ошибок, мгновенно останавливая программу вместо того, чтобы дать доступ к памяти и дать вам продолжить с ней работать. Глава 9 подробнее освещает обработку ошибок в Rust и способы написания более читаемого и безопасного кода — такого, который не запаникует и не позволит некорректно обращаться к памяти.