Типы данных

Любое значение в Rust является определённым типом данных (data type), которое говорит о том, какие данные указаны, так что Rust знает как с ними работать. Рассмотрим два подмножества тип данных: скалярные (простые) и составные (сложные).

Не забывайте, что Rust является статически типизированным (statically typed) языком. Это означает, что он должен знать типы всех переменных во время компиляции. Обычно компилятор может вывести (infer) какой тип мы хотим использовать, основываясь на значении и на том, как мы с ним работаем. В случаях, когда может быть выведено несколько типов, необходимо вручную добавлять аннотацию типа. Например, когда мы конвертировали String в число с помощью вызова parse в разделе "Сравнение предположения с загаданным номером" Главы 2, мы должны добавить такую аннотацию:


#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

Если её не добавить, то 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!");
  |         ^^^^^ consider giving `guess` a type

error: aborting due to previous error

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

To learn more, run the command again with --verbose.

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

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

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

Целые числа

Целое число, integer, является числом без дробной составляющей. Мы использовали целочисленный тип в Главе 2, это был u32. Данное объявление типа указывает, что значение связанное с ним должно быть беззнаковым целым (unsigned integer). Типы знаковых целых (signed integer) начинаются с буквы i, вместо буквы u и занимают до 32 бит памяти. Таблица 3-1 показывает встроенные целые типы Rust. Каждый вариант в колонках Знаковый и Беззнаковый, к примеру i16, может использоваться для объявления значения целочисленного типа.

Таблица 3-1: целые типы Rust

РазмерЗнаковыйБеззнаковый
8-biti8u8
16 битi16u16
32 битаi32u32
64 битаi64u64
128 битi128u128
archisizeusize

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

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

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

Числовые литералыПример
Десятичный98_222
Шестнадцатеричный0xff
Восьмеричный0o77
Бинарный0b1111_0000
Байтовый (только u8)b'A'

Как узнать, какой литерал необходимо использовать? Если вы не уверены, то вариант предоставляемый в Rust по умолчанию является хорошим выбором. Для целых чисел типом по умолчанию является i32: в общем случае, данный тип самый быстрый даже на 64-битных системах. Основной ситуацией, когда вам необходимо использование isize или usize, является индексирование некоторых коллекций.

Целочисленное переполнение

Предположим, у нас есть переменная типа u8, которая может сохранять значения между 0 и 255. Если вы попытаетесь задать переменной значение вне данного диапазона, например в 256, то произойдёт целочисленное переполнение. В Rust есть несколько интересных правил, связанных с этим поведением. При компиляции кода в режиме отладки, компилятор Rust включает проверки, которые приводят к панике во время выполнения, если случится целочисленное переполнение. В Rust термин "паниковать" означает, что программа сразу завершается с ошибкой. Мы обсудим "панику" более детально разделе "Необрабатываемые ошибки с помощью макроса panic!" главы 9.

При компиляции кода в финальную версию при помощи флага --release, Rust не включает проверки на целочисленное переполнение, приводящие к панике. Вместо этого, в случае переполнения Rust выполняет оборачивание дополнительного кода. Если кратко, то значения больше, чем максимальное значение, которое может хранить тип, превращается в минимальное значение данного типа. Для типа u8, число 256 превращается в 0, 257 станет 1 и так далее. Программа не будет "паниковать", но переменная получит значение, которое вы возможно не ожидали. Полагаться на такое поведение считается ошибкой.

Чтобы явно обработать возможность переполнения, вы можете использовать следующие семейства методов, которые стандартная библиотека предоставляет для примитивных числовых типов:

  • Обернуть все режимы с помощью wrapping_* методов, например wrapping_add
  • Вернуть значение None в случае переполнения при помощи методов checked_*
  • Вернуть значение и логическое значение, указывающее, было ли переполнение с помощью методов overflowing_*
  • Подавить минимальные или максимальные значения с помощью методов saturating_*

Числа с плавающей запятой

В Rust есть два примитивных типа для чисел с плавающей точкой (floating-point numbers), которые являются числами с десятичными точками. Числа с плавающей точкой в Rust представлены типами f32 и f64, имеющими размер 32 и 64 бита соответственно. Типом по умолчанию является f64, потому что все современные CPU работают с ним приблизительно с такой же скоростью, как и с 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;

    // 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 в разделе "Условные конструкции".

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

До этого момента мы работали только с числами, но Rust поддерживает также и буквы. Тип char является самым примитивным буквенным типом и следующий код показывает как им пользоваться. (Заметим, что литералы char определяются с помощью одинарных кавычек, в отличии от строк где используются двойные кавычки.)

Файл: src/main.rs

fn main() {
    let c = 'z';
    let z = 'ℤ';
    let heart_eyed_cat = '😻';
}

Тип char имеет размер в четыре байта и представляет собой скалярное юникод значение (Unicode Scalar Value), а значит, он может представить больше символов, чем есть в ASCII. Акцентированные буквы, китайские, японские и корейские символы, эмодзи и пробелы нулевой ширины - всё является корректными значениями char в Rust. Скалярное юникод значение имеет диапазон от U+0000 до U+D7FF и от U+E000 до U+10FFFF включительно. Тем не менее, "символ" на самом деле не является концептом в Юникод, так что человеческая интуиция о том, что такое "символ" может не совпадать с тем, чем является тип 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, а затем создаёт новые переменные для каждого элемента, используя соответствующие индексы. Как и в большинстве языков, первый индекс в кортеже - это ноль.

Массивы

Другим способом получения коллекции из множества значений является массив. В отличии от кортежа, каждый элемент массива имеет одинаковый тип. Массивы в Rust отличаются от массивов в некоторых других языках тем, что в Rust они, подобно кортежам, имеют фиксированную длину.

В 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 {} is: {}",
        index, 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 защищает вас от такого рода ошибок тем, что немедленно завершает программу, вместо того, чтобы позволить получить такой доступ и продолжить выполнение. Обсуждение обработки ошибок в Rust ведётся в Главе 9.