Типы данных

Каждое значение в 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[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^
  |
help: consider giving `guess` an explicit type
  |
2 |     let guess: _ = "42".parse().expect("Not a number!");
  |              +++

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error

В будущем вы увидите различные аннотации для разных типов данных.

Скалярные типы данных

Скалярный тип представляет единственное значение. В Rust есть четыре скалярных типа: целые и вещественные числа, логический тип и символы. Вы можете узнать эти типы по другим языкам программирования. Посмотрим, как они работают в Rust.

Целочисленные типы

Целое число (integer) — это число без дробной части. В главе 2 мы использовали один целочисленный тип — u32. Это объявление типа указывает, что значение, с которым оно связано, должно быть целым числом без знака (типы целых чисел со знаком начинаются с i вместо u), которое занимает 32 бита памяти. В таблице 3-1 показаны встроенные целочисленные типы в Rust. Мы можем использовать любой из этих вариантов для объявления типа целочисленного значения.

Таблица 3-1: целочисленные типы в Rust

ДлинаСо знакомБез знака
8 битi8u8
16 битi16u16
32 битi32u32
64 битi64u64
128 битi128u128
archisizeusize

Каждый вариант может быть как со знаком, так и без знака и имеет явный размер. Такая характеристика типа как знаковый и беззнаковый определяет возможность числа быть отрицательным. Другими словами, должно ли число иметь знак (знаковое) или оно всегда будет только положительным и, следовательно, может быть представлено без знака (беззнаковое). Это похоже на написание чисел на бумаге: когда знак имеет значение, число отображается со знаком плюс или со знаком минус; однако, когда можно с уверенностью предположить, что число положительное, оно отображается без знака. Числа со знаком хранятся с использованием дополнительного кода.

Каждый вариант со знаком может хранить числа от -(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.

Таблица 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. Тип 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;
}

Каждое из этих выражений использует математические операции и вычисляет значение, которое затем присваивается переменной. Приложение Б содержит список всех операторов, имеющихся в Rust.

Логический тип данных

Как и в большинстве языков программирования, логический тип в Rust может иметь два значения: true и false и занимает в памяти один байт. Логический тип в Rust аннотируется при помощи bool. Например:

Файл: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

Основной способ использования значений логического типа — условные конструкции, такие как выражение if. Мы расскажем про работу выражения if в разделе «Условные конструкции».

Символьный тип данных

Тип 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. Такого рода операция называется деструктуризацией (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. Это значение и соответствующий ему тип записываются как () и представляют собой пустое значение или пустой возвращаемый тип. Выражения неявно возвращают 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, показывающее, что массив содержит 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 и то, как вы можете написать читаемый, безопасный код, который не вызывает панику и не разрешает некорректный доступ к памяти.