Перейти к основному содержимому

Типы данных и владение памятью

Разработчику Архитектору

Типы данных и владение памятью

Программирование на любом языке начинается с понимания того, как язык представляет информацию внутри программы. В Rust эта задача решается через строгую, но гибкую систему типов, которая сочетает безопасность, производительность и предсказуемость. Переменные и типы данных — это фундаментальные строительные блоки любой программы, и в Rust им уделяется особое внимание. Язык не просто позволяет хранить значения, он требует от программиста чётко определять, как эти значения устроены, сколько памяти они занимают и какие операции над ними допустимы.

Переменные как именованные контейнеры

В Rust переменная — это именованное место в памяти, предназначенное для хранения значения определённого типа.

Объявление переменной всегда начинается с ключевого слова let.

let x = 5;
// let x = 6; // Ошибка компиляции: cannot assign twice to immutable variable
println!("Значение x равно {}", x);

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

let number = 10; // Тип определяется как i32
let fraction = 2.5; // Тип определяется как f64
let message = "Hi"; // Тип определяется как &str

Если требуется изменять значение переменной, необходимо явно указать это, добавив ключевое слово mut после let. Например, запись let mut counter = 0; создаёт изменяемую переменную с начальным значением ноль. Такая явность — часть философии Rust: программа должна честно отражать свои намерения. Изменяемость — это не случайное свойство, а осознанное решение, которое программист фиксирует в коде.

let integer_variable: i32 = 42;
let floating_point: f64 = 3.14;
let text_string: &str = "Hello";
let character_symbol: char = 'A';

Имя переменной в Rust следует правилам идентификаторов: оно может содержать буквы, цифры, символ подчёркивания и символы Unicode, но не может начинаться с цифры. Соглашения сообщества рекомендуют использовать стиль snake_case — все буквы строчные, слова разделяются символами подчёркивания. Это способствует единообразию кода и упрощает его чтение.

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

let user_id = 101;
let _unused_logger = "system_log";
let total_sum = 500;

Константы объявляются с помощью ключевого слова const. Они всегда должны иметь явный тип данных и их значение известно на этапе компиляции. Константы доступны во всем модуле и не могут быть изменены после объявления.

Отличие констант от неизменяемых переменных заключается в области видимости и моменте инициализации. Константа должна быть инициализирована значением, известным компилятору, и распространяется на весь модуль.

const MAX_USERS: u32 = 1000;
const PI: f64 = 3.14159;

Статические переменные (static) хранят свои значения в глобальной области памяти на протяжении всего времени выполнения программы. Они могут быть как изменяемыми, так и неизменяемыми, но доступ к ним требует особой осторожности из-за многопоточности. Статические переменные часто используются для хранения конфигураций или глобальных состояний.

static GLOBAL_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

Типы данных: скалярные и составные

Rust разделяет типы данных на две большие категории: скалярные и составные. Скалярные типы представляют одно значение. Составные типы позволяют группировать несколько значений в одну единицу.

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

Скалярные типы в Rust включают целые числа, числа с плавающей запятой, логические значения и символы.

Целочисленные типы делятся на знаковые и беззнаковые.

Знаковые целые числа могут представлять как положительные, так и отрицательные значения. Беззнаковые — только неотрицательные.

Каждый тип имеет фиксированный размер в битах: 8, 16, 32, 64 или 128. Например, i32 — это знаковое 32-битное целое число, а u64 — беззнаковое 64-битное.

Существует также специальный тип isize и usize, размер которых зависит от архитектуры системы: 32 бита на 32-битной платформе и 64 бита на 64-битной. Эти типы часто используются для индексации и работы с размерами коллекций.

// Объявление знаковых целых чисел разного размера
let signed_8bit: i8 = -5;
let signed_16bit: i16 = -32768;
let signed_32bit: i32 = 1000000;
let signed_64bit: i64 = -9223372036854775808;

// Объявление беззнаковых целых чисел
let unsigned_8bit: u8 = 255;
let unsigned_16bit: u16 = 65535;
let unsigned_32bit: u32 = 4294967295;
let unsigned_64bit: u64 = 18446744073709551615;

// Платформозависимые типы для индексов
let index: usize = 10; // Размер зависит от архитектуры (32 или 64 бита)
let count: isize = -1; // Часто используется для смещений

// Пример использования в цикле
for i in 0..10 {
println!("Текущий индекс: {}", i);
}

Числа с плавающей запятой в Rust представлены двумя типами: f32 и f64. Они соответствуют стандарту IEEE 754 и обеспечивают одинарную и двойную точность соответственно. По умолчанию, если тип не указан явно, компилятор выбирает f64, так как он обеспечивает лучшую точность при сопоставимой производительности на большинстве современных процессоров.

// Числа с плавающей запятой
let single_precision: f32 = 3.14_f32;
let double_precision: f64 = 2.718281828459045;

// Явное указание типа для литерала
let pi_approx: f32 = 3.14;

// Арифметические операции
let sum: f64 = 10.5 + 5.25;
let product: f32 = 2.5 * 4.0;
let division: f64 = 10.0 / 3.0;

// Проверка специальных значений
let infinity: f64 = f64::INFINITY;
let not_a_number: f32 = f32::NAN;
let zero: f64 = 0.0;

println!("Сумма: {}", sum);
println("Произведение: {}", product);
println!("Деление: {}", division);

Логический тип в Rust называется bool и может принимать два значения: true и false. Он используется в условиях, циклах и логических выражениях. Логические операции, такие как конъюнкция (&&) и дизъюнкция (||), работают с этим типом и возвращают значение того же типа.

// Объявление логических переменных
let is_active: bool = true;
let is_complete: bool = false;

// Логические операции
let condition_one: bool = true;
let condition_two: bool = false;

let and_result: bool = condition_one && condition_two; // false
let or_result: bool = condition_one || condition_two; // true
let not_result: bool = !condition_one; // false

// Использование в условном операторе
if is_active && !is_complete {
println!("Задача выполняется, но еще не завершена");
}

// Сравнение возвращает bool
let x: i32 = 10;
let y: i32 = 20;
let is_less: bool = x < y; // true
let is_equal: bool = x == y; // false

println!("x меньше y: {}", is_less);

Символьный тип char в Rust представляет собой один символ Юникода. Он занимает четыре байта и способен хранить любые символы, включая эмодзи, акцентированные буквы и специальные символы. Это отличает Rust от языков, где символ ограничен ASCII или однобайтовым представлением.

// Объявление символов разных категорий
let ascii_letter: char = 'A';
let cyrillic_letter: char = 'Я';
let emoji: char = '🚀';
let math_symbol: char = '∑';
let digit: char = '5';

// Проверка длины и свойств
let text = "Привет";
let first_char = text.chars().next(); // Первый символ строки

// Работа с эмодзи и сложными символами
let heart: char = '❤️'; // Эмодзи сердца
let kanji: char = '日'; // Иероглиф "день"

// Сравнение символов
let is_uppercase = 'A' > 'a'; // false
let is_digit = '5' >= '0'; // true

// Вывод символов
println!("Буква: {}", ascii_letter);
println!("Эмодзи: {}", emoji);
println!("Иероглиф: {}", kanji);

// Преобразование символа в его код
let code_point = 'A' as u32; // 65

Составные типы

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

Кортеж — это упорядоченная последовательность значений, каждое из которых может иметь собственный тип. Кортеж фиксированного размера создаётся путём перечисления значений в круглых скобках. Например, (500, 6.4, 'a') — это кортеж из трёх элементов: целого числа, числа с плавающей запятой и символа. Тип этого кортежа записывается как (i32, f64, char). Доступ к элементам кортежа осуществляется через декомпозицию (деструктуризацию) или по индексу с помощью точки и номера: tuple.0, tuple.1 и так далее. Кортежи удобны для временного группирования связанных данных, особенно когда нужно вернуть несколько значений из функции.

// Объявление кортежа с различными типами данных
let mixed_tuple = (500, 6.4, 'a');

// Доступ к элементам по индексу
let first_element = mixed_tuple.0; // Значение 500
let second_element = mixed_tuple.1; // Значение 6.4
let third_element = mixed_tuple.2; // Значение 'a'

println!("Первый элемент: {}", first_element);
println!("Второй элемент: {}", second_element);
println!("Третий элемент: {}", third_element);

// Деструктуризация кортежа
let (number, float_val, symbol) = mixed_tuple;

println!("Разложенное число: {}", number);
println!("Разложенная дробь: {}", float_val);
println!("Разложенный символ: {}", symbol);

// Использование в качестве возвращаемого значения функции
fn get_user_data() -> (String, i32, bool) {
("Alice".to_string(), 30, true)
}

let (name, age, is_active) = get_user_data();
println!("Пользователь: {}, Возраст: {}, Активен: {}", name, age, is_active);

// Пустой кортеж (единственный элемент)
let empty_tuple = ();

Массив — это упорядоченная коллекция элементов одного типа. В отличие от многих других языков, массивы в Rust имеют фиксированный размер, который определяется во время компиляции. Это означает, что массив не может расти или сокращаться во время выполнения программы. Объявление массива выглядит как список значений в квадратных скобках: [1, 2, 3, 4, 5]. Если все элементы одинаковы, можно использовать сокращённую форму: [3; 5] создаёт массив из пяти троек. Тип массива записывается как [T; N], где T — тип элемента, а N — количество элементов. Массивы хранятся в стеке, что делает их быстрыми и предсказуемыми по памяти, но ограничивает их применение случаями, где размер известен заранее и невелик.

// Объявление массива явным списком значений
let numbers = [1, 2, 3, 4, 5];

// Доступ к элементам по индексу
let first_num = numbers[0]; // 1
let last_num = numbers[4]; // 5

// Ошибка: индекс вне границ приведет к панике времени выполнения
// let out_of_bounds = numbers[10];

// Сокращенная форма объявления: создание массива из повторяющихся значений
let zeros = [0; 10]; // Массив из десяти нулей
let ones = [1; 5]; // Массив из пяти единиц
let fives = [5; 3]; // Массив из трех пятерок

// Тип массива явно указан как [i32; 5]
let explicit_array: [i32; 5] = [10, 20, 30, 40, 50];

// Изменение элементов массива
let mut mutable_array = [1, 2, 3];
mutable_array[0] = 99; // Разрешено, так как массив изменяемый

println!("Первый элемент после изменения: {}", mutable_array[0]);

// Итерация по массиву
for value in &numbers {
println!("Значение: {}", value);
}

// Вычисление суммы элементов массива
let sum: i32 = numbers.iter().sum();
println!("Сумма элементов: {}", sum);

// Работа с подмассивами (срезы)
let slice = &numbers[1..4]; // Элементы со второго по четвертый (не включая пятый)
println!("Срез: {:?}", slice);

Аннотации типов и вывод типов

Rust обладает мощной системой вывода типов. Это означает, что во многих случаях компилятор может автоматически определить тип переменной на основе присвоенного значения. Например, в строке let x = 5; компилятор выводит, что x имеет тип i32, так как это стандартный целочисленный тип по умолчанию. Однако программист всегда может указать тип явно, используя двоеточие после имени переменной: let x: i32 = 5;.

Явное указание типа полезно в ситуациях, когда значение не даёт однозначной информации о типе, или когда требуется уточнить намерения. Например, при вызове функции, которая принимает несколько возможных типов, аннотация помогает компилятору выбрать правильный вариант. Также аннотации делают код более читаемым, особенно для сложных структур данных.

Владение и время жизни в контексте переменных

Хотя тема владения выходит за рамки простого объявления переменных, она тесно связана с тем, как Rust управляет данными. Каждое значение в Rust имеет владельца — переменную, которая отвечает за его существование. Когда владелец выходит из области видимости, значение автоматически уничтожается. Это правило гарантирует отсутствие утечек памяти без необходимости сборщика мусора.

Для составных типов, содержащих данные на куче (например, строки String или векторы Vec<T>), это означает, что при присвоении одной переменной другой происходит передача владения, а не копирование данных. Исходная переменная становится недействительной. Такое поведение — часть системы безопасности Rust, предотвращающей использование уже освобождённой памяти.


Строки: два мира — str и String

В Rust существует два основных способа работы с текстом: строковый срез (&str) и владеющая строка (String). Эти типы отражают фундаментальный принцип языка — различие между данными, которые только ссылаются на память, и данными, которые управляют своей памятью.

Строковый срез &str — это неизменяемая последовательность байтов в кодировке UTF-8, хранящаяся где-то в памяти. Чаще всего он используется в виде строковых литералов, например "привет". Такие литералы размещаются в секции исполняемого файла и существуют на протяжении всей программы. Срезы не владеют данными, они лишь указывают на них. Это делает их лёгкими и быстрыми для передачи.

Тип String, напротив, представляет собой изменяемую, растущую строку, выделенную в куче. Он владеет своими данными, что позволяет добавлять, удалять или изменять содержимое. Создание строки происходит через функцию String::from("текст") или метод .to_string(). Например:

let mut s = String::from("Привет");
s.push_str(", мир!");

Этот код корректен, потому что s является изменяемой переменной типа String. Попытка изменить строковый срез приведёт к ошибке компиляции, так как срезы по своей природе неизменяемы.

Преобразование между &str и String происходит часто. Любой String можно превратить в &str с помощью взятия ссылки: &s. Обратное преобразование требует выделения памяти и копирования данных, что делается явно через String::from() или .to_owned().

Пользовательские типы данных

Rust предоставляет мощные инструменты для создания собственных типов. Основные из них — структуры (struct) и перечисления (enum).

Структура объединяет несколько значений под одним именем. Каждое поле имеет своё имя и тип. Например:

struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}

Этот тип описывает пользователя с четырьмя полями. Экземпляр создаётся синтаксисом инициализации структуры:

let user1 = User {
username: String::from("alice"),
email: String::from("alice@example.com"),
sign_in_count: 1,
active: true,
};

Поля структуры доступны через точку: user1.email.

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

enum IpAddr {
V4(String),
V6(String),
}

Здесь IpAddr может быть либо IPv4-адресом, либо IPv6-адресом, каждый из которых хранит строку. Более эффективно использовать встроенные целочисленные типы:

enum IpAddr {
V4(u8, u8, u8, u8),
V6(u16, u16, u16, u16, u16, u16, u16, u16),
}

Такой подход экономит память и исключает недопустимые значения.

Особенно важен стандартный перечисляемый тип Option<T>, который выражает наличие или отсутствие значения:

enum Option<T> {
Some(T),
None,
}

Использование Option вместо нулевых указателей — ключевой элемент безопасности Rust. Компилятор заставляет обрабатывать оба случая: когда значение есть (Some) и когда его нет (None). Это предотвращает ошибки, связанные с обращением к несуществующим данным.

Константы и статические переменные

Помимо обычных переменных, Rust поддерживает константы и статические переменные.

Константа объявляется с помощью ключевого слова const. Она всегда неизменяема, её значение должно быть известно во время компиляции, и она не имеет фиксированного адреса в памяти. Константы могут использоваться в любом месте программы и часто применяются для определения глобальных параметров:

const MAX_POINTS: u32 = 100_000;

Имена констант пишутся заглавными буквами с подчёркиваниями.

Статическая переменная объявляется через static. В отличие от констант, она имеет фиксированный адрес в памяти и живёт всё время выполнения программы. Статические переменные могут быть изменяемыми, но работа с ними требует особых мер предосторожности, так как они нарушают правила владения. Изменяемые статические переменные используются редко и только в специфических сценариях, таких как низкоуровневые системы или встраиваемые устройства.

Область видимости и время жизни

Область видимости переменной в Rust начинается с точки её объявления и заканчивается в конце блока, в котором она объявлена. Блок — это любая часть кода, заключённая в фигурные скобки {}. Когда переменная выходит из области видимости, вызывается её деструктор (если он определён), и память освобождается.

Это поведение гарантирует, что ресурсы не утекают. Например, если переменная содержит файловый дескриптор или сетевое соединение, они будут корректно закрыты при выходе из блока.

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

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}

Здесь 'a — это параметр времени жизни, который говорит компилятору, что возвращаемая ссылка будет жить столько же, сколько и обе входные ссылки. Это предотвращает возврат ссылки на данные, которые уже уничтожены.

Практические рекомендации

При работе с типами и переменными в Rust стоит придерживаться нескольких принципов:

— Используйте неизменяемые переменные по умолчанию. Делайте переменную изменяемой только тогда, когда это действительно необходимо. — Предпочитайте строковые срезы (&str) в качестве параметров функций, если не требуется владение строкой. Это делает функции более гибкими. — Используйте Option<T> вместо магических значений (например, -1 или null) для обозначения отсутствия данных. — Объявляйте константы для значений, которые не меняются и используются в нескольких местах. — Избегайте преждевременной оптимизации. Доверяйте системе типов и компилятору — они помогут вам писать безопасный и эффективный код.


Сопоставление с образцом: декларативный доступ к данным

Rust предоставляет мощный механизм сопоставления с образцом (pattern matching), который позволяет декларативно извлекать данные из сложных структур. Этот механизм особенно эффективен при работе с перечислениями, кортежами и структурами.

Оператор match — центральный инструмент сопоставления. Он принимает значение и сравнивает его с набором образцов. Каждый образец описывает возможную форму данных. Например:

enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

fn handle_message(msg: Message) {
match msg {
Message::Quit => println!("Выход"),
Message::Move { x, y } => println!("Перемещение в ({}, {})", x, y),
Message::Write(text) => println!("Текст: {}", text),
Message::ChangeColor(r, g, b) => println!("Цвет: R{} G{} B{}", r, g, b),
}
}

Каждая ветка match точно соответствует одному из вариантов перечисления. Компилятор проверяет, что все возможные случаи обработаны, что исключает ошибки, связанные с неполным анализом состояния.

Сопоставление работает не только с перечислениями. Оно применимо к кортежам:

let point = (3, 5);
match point {
(0, 0) => println!("Начало координат"),
(0, y) => println!("На оси Y: {}", y),
(x, 0) => println!("На оси X: {}", x),
(x, y) => println!("Произвольная точка: ({}, {})", x, y),
}

И к структурам:

struct Point {
x: i32,
y: i32,
}

let p = Point { x: 0, y: 7 };
match p {
Point { x: 0, y } => println!("На оси Y: {}", y),
Point { x, y: 0 } => println!("На оси X: {}", x),
Point { x, y } => println!("Точка: ({}, {})", x, y),
}

Во всех этих случаях сопоставление с образцом делает код выразительным и безопасным, избегая ручного доступа к полям или индексам.

Преобразования типов

Rust не выполняет неявных преобразований между типами. Это предотвращает неожиданные потери точности или изменения поведения программы. Все преобразования должны быть явными.

Для числовых типов используется метод .into() или функция as. Метод .into() работает через трейт Into, который гарантирует безопасное преобразование. Например, u8 можно преобразовать в u16 без потерь:

let a: u8 = 5;
let b: u16 = a.into();

Однако обратное преобразование может привести к потере данных. В таких случаях Rust требует использования as, что сигнализирует о возможном риске:

let c: u16 = 1000;
let d: u8 = c as u8; // 1000 не помещается в u8 — результат 232

Такой подход заставляет программиста осознанно принимать решение о преобразовании.

Для строковых типов преобразования также строго контролируются. Преобразование String в &str происходит через взятие ссылки. Обратное преобразование требует владения данными и выполняется через String::from() или .to_string().

Типы и память: стек и куча

Понимание того, где хранятся данные, помогает писать эффективный код. Rust разделяет память на стек и кучу.

Стек — это область памяти с быстрым доступом, работающая по принципу «последним пришёл — первым ушёл». Данные на стеке имеют фиксированный размер, известный во время компиляции. Скалярные типы, кортежи из скаляров, массивы фиксированного размера — всё это хранится на стеке.

Куча — это менее упорядоченная область памяти, используемая для данных переменного размера. Когда размер данных неизвестен заранее или может меняться, они размещаются в куче. Владеющие типы, такие как String и Vec<T>, хранят свои данные в куче, а в стеке оставляют только метаданные: указатель, длину и ёмкость.

Это разделение объясняет, почему присвоение String передаёт владение, а не копирует данные: копирование всей строки было бы дорогостоящим. Вместо этого Rust перемещает указатель, сохраняя производительность и безопасность.

Пример: работа с пользовательскими данными

Рассмотрим пример, объединяющий многие из рассмотренных концепций:

#[derive(Debug)]
struct Person {
name: String,
age: u8,
}

enum Contact {
Email(String),
Phone(String),
}

fn main() {
let person = Person {
name: String::from("Анна"),
age: 30,
};

let contact = Contact::Email(String::from("anna@example.com"));

println!("{:?}", person);

match contact {
Contact::Email(addr) => println!("Электронная почта: {}", addr),
Contact::Phone(num) => println!("Телефон: {}", num),
}
}

Здесь: — Person — структура с владеющими полями. — Contact — перечисление, позволяющее хранить один из двух типов контактов. — #[derive(Debug)] автоматически реализует вывод структуры для отладки. — match безопасно извлекает данные из перечисления. — Все переменные неизменяемы, что соответствует рекомендациям.

Этот код демонстрирует, как система типов Rust обеспечивает ясность, безопасность и эффективность одновременно.


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).