Коллекции и строки
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Коллекции и строки
Vec,HashMap,BTreeMap,HashSet,VecDequeи вечный вопрос новичка: почему в Rust две строки. Урок про выбор структуры под задачу и про то, как владение живёт внутри контейнеров.
Идея
В JavaScript массив это просто массив: положил, достал, забыл. В Rust коллекция это в первую очередь владелец. Положил значение в Vec, и владение переехало в вектор, как переезжало в функцию в уроке про move. Умер вектор, умерли все его элементы: один drop снаружи, каскад drop внутри.
Из этого одного факта выводится всё поведение коллекций, которое поначалу кажется придирками:
let words = vec![String::from("сборка"), String::from("деплой")];
let first = words[0]; // ошибка: cannot move out of index
Забрать words[0] по значению нельзя: это вырвало бы владение из середины вектора, и в нём осталась бы дыра. Поэтому стандартная библиотека говорит на языке ссылок и Option: посмотреть на элемент можно через &words[0] или words.get(0), а забрать насовсем только методом, который честно перестраивает коллекцию, например words.remove(0) или words.pop(). Коллекции в Rust это владение из третьего урока плюс заимствование из четвёртого, применённые к множеству значений сразу. Новых правил сегодня не будет, будут следствия старых.
Vec
Vec<T> это растущий массив на куче и рабочая лошадка номер один: если нет причины брать что-то другое, бери Vec. Создаётся пустым или макросом с элементами:
let mut log: Vec<String> = Vec::new();
let primes = vec![2, 3, 5, 7];
У вектора два разных размера, и их важно не путать: длина, сколько элементов лежит сейчас, и ёмкость, под сколько уже выделена память:
let mut v = Vec::new();
for i in 0..5 {
v.push(i);
println!("len = {}, capacity = {}", v.len(), v.capacity());
}
Запусти и посмотри: ёмкость растёт скачками, а не на единицу. Когда место кончается, вектор выделяет новый блок побольше и переносит все элементы. Поэтому push стоит дёшево в среднем, амортизированно за константу, но отдельно взятый push может оказаться переездом всего содержимого. Если размер известен заранее, Vec::with_capacity(n) убирает переезды совсем: эта мелочь будет всплывать во всех проектах курса, от эмулятора до HFT, где лишняя аллокация это лишняя задержка.
Переезд объясняет и поведение borrow checker, которое в четвёртом уроке выглядело строгостью ради строгости:
let mut v = vec![1, 2, 3];
let first = &v[0]; // ссылка внутрь буфера вектора
v.push(4); // буфер мог переехать
println!("{first}"); // ошибка: cannot borrow v as mutable
first указывает в старый буфер. Если push вызвал реаллокацию, буфер освобождён, и ссылка повисла в воздухе. В C++ это знаменитая инвалидация итераторов, баг, который стреляет раз в полгода и не воспроизводится. В Rust это ошибка компиляции E0502: правило про эксклюзивность &mut поймало переезд памяти ещё до запуска.
Достают элементы двумя способами с разным характером:
let v = vec![10, 20, 30];
let a = v[7]; // паника: index out of bounds
let b = v.get(7); // None: решай сам, что делать
Индексация это утверждение «элемент точно есть», и за ошибку в нём программа платит паникой. get возвращает Option<&T> и отдаёт решение тебе, как положено по уроку про enum. Правило простое: индекс, который ты только что проверил или вычислил сам, можно брать скобками; всё, что пришло снаружи, через get.
Обходят вектор циклом for, и форма цикла повторяет три способа передать значение:
for item in &v { } // &T: читаем
for item in &mut v { } // &mut T: правим на месте
for item in v { } // T: забираем владение, вектора больше нет
Третья форма поглощает вектор целиком, и это не подвох, а услуга: так элементы можно перекладывать в другую структуру без клонирования. Под капотом этих трёх форм живут итераторы, у них впереди отдельный урок.
Карты и множества
HashMap<K, V> это словарь по ключу, тот же Map из JavaScript, но с владением. Живёт не в prelude, нужен use:
use std::collections::HashMap;
let mut stock: HashMap<String, u32> = HashMap::new();
let name = String::from("болт M6");
stock.insert(name, 40);
// name дальше недоступно: владение ключом ушло в карту
insert забирает и ключ, и значение. get возвращает Option<&V>, и по тем же причинам, что у вектора: значения нет либо оно принадлежит карте. Поиск, вставка и удаление стоят в среднем константу, ты разбирал это в уроке про Big-O и хеш-таблицы. Цена константы: порядок обхода непредсказуем и меняется от запуска к запуску, на него нельзя опираться.
Классическая задача «вставь или обнови» наивно пишется двойным поиском: сначала get, потом insert. Идиома Rust называется entry API и делает это за один поиск:
use std::collections::HashMap;
fn word_count(text: &str) -> HashMap<&str, u32> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
counts
}
entry(word) находит место под ключ, занятое или пустое. or_insert(0) кладёт ноль, если пусто, и в обоих случаях возвращает &mut u32 на значение в карте. Звёздочка разыменовывает ссылку, и += 1 правит счётчик прямо на месте. Одна строка, один поиск, никаких проверок «а есть ли ключ».
Вокруг HashMap стоит небольшая семья, и у каждого члена своя специальность:
BTreeMap<K, V>держит ключи отсортированными. Обход всегда в порядке возрастания, есть запросы по диапазону. Платишь логарифмом за операцию там, где у хеша константа.HashSet<T>это множество, карта без значений: «видел ли я этот элемент» однимcontains, дедупликация однимinsert, который возвращаетfalseдля повтора.VecDeque<T>это очередь на кольцевом буфере:push_backиpop_frontза константу. Тот самый случай, когдаVecне подходит: егоremove(0)сдвигает весь хвост влево.
Ключ HashMap обязан уметь хешироваться и сравниваться, ключ BTreeMap упорядочиваться. На языке Rust эти «обязан уметь» записываются ограничениями K: Hash + Eq и K: Ord, и это первый звонок темы, которая займёт весь следующий блок: способности типов описываются трейтами, начало в уроке про дженерики.
String против &str
Теперь самый частый вопрос первого месяца. Почему строки две и почему функция, ждущая &str, не хочет String без танцев. Ответ ты уже знаешь из четвёртого урока, осталось его собрать: String это владеющий буфер, по устройству Vec<u8> с гарантией корректного текста внутри, а &str это срез, взгляд на чужие байты без владения. Пара String и &str это в точности пара Vec<T> и &[T], только для текста:
let mut owned = String::from("привет"); // буфер на куче, можно растить
owned.push_str(", Rust");
let view: &str = &owned[..]; // срез: указатель + длина, владения нет
let literal = "статичный текст"; // &str: байты зашиты в бинарник
Гарантия корректного текста означает UTF-8, и Rust принимает её всерьёз, а не делает вид, что символ равен байту:
let s = String::from("привет");
println!("{}", s.len()); // 12: длина в байтах
println!("{}", s.chars().count()); // 6: длина в символах
let c = s[0]; // не компилируется: индексации нет
Индексации по строке нет намеренно. s[0] обещал бы константное время, но чтобы найти четвёртый символ в UTF-8, нужно пройти первые три: они переменной длины. Rust не продаёт линейную операцию под видом константной и заставляет сказать явно, что тебе нужно: байты через s.bytes(), символы через s.chars(). Срезы по диапазону байтов разрешены, но граница обязана попасть на стык символов: &s[0..2] для кириллицы даст первую букву, а &s[0..1] запаникует с внятным сообщением про char boundary. И это ещё не вся правда: то, что человек считает одним символом, бывает несколькими char сразу, эмодзи с модификаторами тому пример. Для честных «видимых символов», графем, берут крейт unicode-segmentation.
Склеивают строки два инструмента:
let base = String::from("отчёт");
let full = base + ".pdf"; // + поглощает base, добавляет &str
let label = format!("{full} за {}", 2026); // format! собирает новую строку
Оператор + забирает левый операнд по владению и дописывает в его буфер, после склейки base недоступна. format! ничего не поглощает и читается лучше, когда кусков больше двух.
Осталось правило про сигнатуры, и оно стоит того, чтобы повесить его на стену: функция, которая читает текст, принимает &str, не &String.
fn shout(text: &str) -> String {
text.to_uppercase()
}
let owned = String::from("тише");
shout(&owned); // работает: &String превращается в &str
shout("громче"); // работает: литерал уже &str
&str принимает и срез владеющей строки, и литерал, и кусок чужого буфера. &String принимает только ссылку на String и отрезает половину вызывающих без всякой выгоды. Превращение &String в &str на вызове компилятор делает сам, механизм называется deref coercion и устроен через трейт Deref, разберём его в уроке про стандартные трейты.
Cow как тизер
Иногда функция чаще всего возвращает вход как есть и лишь изредка правит его. Возвращать String значит клонировать всегда, возвращать &str значит не мочь вернуть исправленную версию. Для этого в стандартной библиотеке есть Cow<str>, clone on write: бери взаймы, пока можно, клонируй, когда придётся. Сейчас достаточно знать, что такой инструмент существует; развёрнутый разбор с примерами будет в уроке про идиоматичный Rust.
Выбор структуры по задаче
Сведём блок в таблицу решений. Читается как «по умолчанию Vec, остальное по симптомам»:
| Задача | Структура | Почему |
|---|---|---|
| Список значений, обход подряд | Vec<T> | плотная память, дёшево добавлять в конец |
| Поиск по ключу, порядок не важен | HashMap<K, V> | константа в среднем на операцию |
| Поиск по ключу, нужен порядок или диапазоны | BTreeMap<K, V> | ключи отсортированы, обход предсказуем |
| Проверка «уже видел», дедупликация | HashSet<T> | членство одним вызовом |
| Очередь: добавить в конец, забрать из начала | VecDeque<T> | оба конца за константу |
| Владеющий текст | String | растущий буфер с гарантией UTF-8 |
| Текст только читаем | &str | срез, принимает всё строковое |
За кадром таблицы остался эффект, который не виден из Big-O: Vec лежит в памяти подряд и дружит с кэшем процессора, поэтому на реальных размерах он нередко обыгрывает структуры с лучшей асимптотикой. Что такое кэш и почему подряд лежащая память быстрее, разберём в машинном блоке, начиная с урока про биты и байты.
Практика
Таблица выбора структур хороша на стене, но запоминается руками. Четыре задачи ниже прогоняют тебя по всему уроку: Vec с сортировкой копии, entry API у HashMap, обе строки с UTF-8 границами и упорядоченный мир BTreeMap. Решай сверху вниз.
ДЗ
Дальше
Коллекции готовы, и вместе с ними почти весь словарь повседневного Rust: данные моделируем структурами и enum, храним в Vec и HashMap, на текст смотрим через &str. Осталось последнее звено блока: что делать, когда операция не удалась. В уроке 08 разбираем panic! против Result, оператор ? и то, как строить свои типы ошибок, которые не стыдно показать вызывающему.