Первые страницы Rust: как мыслить через ownership на маленьких примерах
Соберём набор упражнений, где ownership объясняется на практике: перемещения, заимствования и время жизни ссылок. Цель — перестать бояться компилятора и начать использовать его как подсказчика.
Содержание
Первые страницы Rust: как мыслить через ownership на маленьких примерах
Rust часто воспринимают как язык с «магическим компилятором». На самом деле компилятор просто строго проверяет модель владения и времени жизни (ownership и lifetimes). Если вы научитесь читать эти проверки как подсказки, исчезает ключевой страх: «почему я не могу собрать код» превращается в «что именно мне нужно изменить в модели данных».
В этой статье соберём набор маленьких упражнений — не абстрактных лекций, а кусков кода, которые демонстрируют три опоры Rust:
- перемещение (move) значения и его последствие для исходной переменной;
- заимствование (borrow) — как безопасно обращаться к данным без передачи владения;
- время жизни ссылок (lifetimes) — почему компилятор требует гарантий про “кто живёт дольше”.
Цель — перестать бояться Rust-компилятора и начать использовать его как навигацию по правилам языка. В конце добавим аккуратную рекомендацию учебного материала: курс Rust, если хочется систематизировать практику.
Ownership: что компилятор “видит” за вас
Ownership в Rust — это не про “понятия ради понятий”. Это конкретные правила, которые определяют:
- У каждого значения есть владелец — переменная или структура.
- В момент перемещения владелец переносится на новое место.
- После перемещения старый владелец не имеет права использовать значение.
- Ссылки (references) могут быть либо:
- неизменяемыми
&T, - изменяемыми
&mut T, и при этом должны укладываться в правила времени жизни.
- неизменяемыми
Компилятор не требует от вас “доверия”. Он требует доказательств: “эта ссылка будет валидна здесь и сейчас”.
Дальше — упражнения. Пишите их по порядку: так проще поймать причинно-следственные связи.
Упражнение 1. Перемещение строк: почему String “уезжает”
Минимальный пример
fn main() {
let s1 = String::from("hello");
let s2 = s1; // move
println!("{}", s2);
// println!("{}", s1); // ошибка: s1 больше не владеет
}
Что происходит
String — это структура, которая владеет выделенным в куче буфером. Когда вы делаете let s2 = s1;, выполняется move: владение переносится с s1 на s2. Перемещение в Rust не копирует содержимое (как минимум в общем случае), а меняет “владельца”.
Поэтому попытка использовать s1 после move приводит к ошибке компиляции.
Вариант “как исправить”
Если вам нужен исходный String и копия, используйте clone():
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1: {}", s1);
println!("s2: {}", s2);
}
Подводный камень: clone() — это не “дополнительное удобство”. Это явное решение “я действительно хочу ещё одну копию”. Компилятор не угадывает ваши намерения.
Упражнение 2. Copy-типы: когда move не страшен
Пример с числом
fn main() {
let x = 10;
let y = x; // по сути copy
println!("x: {}, y: {}", x, y);
}
Числа (и другие типы вроде bool, char, массивы фиксированного размера при определённых условиях) реализуют trait Copy. Для таких типов правило проще: операция присваивания делает копирование. Владение “не уезжает” из переменной — оно просто копируется по значению.
Зачем это знать
Чтобы не путаться: не каждый присваиваемый тип ведёт себя как String. Но принцип один: Rust всегда знает, можно ли копировать без риска.
Упражнение 3. Заимствование неизменяемой ссылкой: &T
Зачем заимствования нужны
Допустим, вы хотите вывести строку, но не менять её и не забирать владение:
fn print_len(s: &String) {
println!("len = {}", s.len());
}
fn main() {
let s = String::from("hello");
print_len(&s);
println!("still usable: {}", s);
}
Почему так безопасно
Функция принимает &String, то есть не владение, а заимствование. Владелец остаётся у main и продолжает существовать после вызова.
Компилятор проверяет две вещи:
- ссылка
&sвалидна в момент вызова; - за время жизни ссылки владелец
sне был уничтожен и не перешёл в другое владение.
Улучшение по стилю
Rust-разработчики часто избегают конкретизации &String там, где достаточно &str. Это делает функцию более универсальной:
fn print_len(s: &str) {
println!("len = {}", s.len());
}
fn main() {
let s = String::from("hello");
print_len(&s); // &String приводится к &str
}
Упражнение 4. Заимствование изменяемой ссылкой: &mut T и эксклюзивность
Неизменяемые ссылки &T допускают много одновременных “наблюдателей”. Изменяемая ссылка &mut T — особая: она даёт право на модификацию и потому должна быть единственной.
Пример: почему нельзя одновременно & и &mut
fn main() {
let mut s = String::from("hello");
let r1 = &s; // неизменяемое заимствование
let r2 = &mut s; // попытка получить изменяемое заимствование
r2.push('!');
println!("{}", r1);
}
Такой код не скомпилируется: одновременно активны r1 и r2, а значит нарушается правило “либо много неизменяемых, либо одна изменяемая”.
Как исправить
Нужно сделать так, чтобы неизменяемая ссылка не жила дольше, чем необходимо. Один из подходов — ограничить область видимости (scope):
fn main() {
let mut s = String::from("hello");
{
let r1 = &s;
println!("before: {}", r1);
} // r1 выходит из области видимости — заимствование заканчивается
let r2 = &mut s;
r2.push('!');
println!("after: {}", s);
}
Подводный камень: “ссылка ещё используется”
Иногда кажется, что ссылка “уже не нужна”, но компилятор видит, что она потенциально используется дальше (влияние на время жизни часто проявляется неожиданно). Поэтому ограничения по scope — надёжный инструмент.
Упражнение 5. Заимствования в функциях: “кто владеет данными?”
В учебных примерах полезно явно разделять две роли: владелец и то, что делает функция.
Вариант 1: функция берёт владение (move)
fn consume(s: String) {
println!("consumed: {}", s);
}
fn main() {
let s = String::from("hello");
consume(s);
// println!("{}", s); // ошибка: s перемещён
}
Вариант 2: функция заимствует (borrow)
fn read_only(s: &String) {
println!("read only: {}", s);
}
fn main() {
let s = String::from("hello");
read_only(&s);
println!("still here: {}", s);
}
Практический принцип
- Хотите, чтобы функция могла “забрать” данные — принимайте
T. - Хотите, чтобы функция только читала — принимайте
&T(или&str). - Хотите, чтобы функция могла менять — принимайте
&mut T.
Упражнение 6. Время жизни: ссылки не могут “переживать” данные
Базовая ошибка
fn make_ref() -> &String {
let s = String::from("hello");
&s
}
Этот код не компилируется: s живёт только внутри make_ref. Возвращать ссылку на неё — значит позволить ссылке жить дольше, чем значение, а это недопустимо.
Rust не даёт вам случайно получить висячую ссылку (dangling reference).
Как сделать правильно: вернуть владение
Самый простой ремонт — вернуть String, то есть владение:
fn make_string() -> String {
let s = String::from("hello");
s
}
fn main() {
let s = make_string();
println!("{}", s);
}
Как сделать правильно: возвращать ссылку на данные, которые живут снаружи
Представим функцию, которая выбирает ссылку на одну из двух строк:
fn choose_longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let s1 = String::from("hello");
let s2 = String::from("world!");
let res = choose_longer(&s1, &s2);
println!("{}", res);
}
Здесь <'a> означает: обе входные ссылки должны жить минимум так же долго, как возвращаемая ссылка, и res привязан к общей длительности 'a.
Упражнение 7. Сравнение времени жизни: два разных 'a и 'b
Иногда ссылки живут разное время. Рассмотрим более тонкий случай: возвращаемая ссылка должна быть валидна до истечения более короткой входной ссылки.
fn pick_first<'a, 'b>(a: &'a str, _b: &'b str) -> &'a str {
a
}
Параметры 'a и 'b независимы. Возвращается ссылка a, поэтому корректность зависит только от 'a.
Почему это важно
Компилятор управляет не “длительностью в секундах”, а гарантией валидности ссылок. Как только возвращаемая ссылка зависит от одного входа — соответствующая lifetime должна быть связана только с ним.
Упражнение 8. Чтение ошибок как подсказок: как “думать вместе с компилятором”
Ошибки Rust часто выглядят пугающе, но они обычно отвечают на три вопроса:
- Кто именно заимствован (конкретная переменная/значение)?
- Где оно используется (строка/выражение)?
- Что конфликтует (например, изменяемое заимствование при активном неизменяемом)?
Практика: не пытайтесь “угадывать правку”. Сделайте шаги:
- Найдите конфликт: “cannot borrow … as mutable because … is borrowed as immutable”.
- Посмотрите на область видимости используемой ссылки. Попробуйте “сжать” scope.
- Если возвращаемая ссылка указывает на локальную переменную — верните владение или привяжите ссылку к данным более внешнего уровня.
Пример типичной правки scope
fn main() {
let mut v = vec![1, 2, 3];
let r = &v; // неизменяемое
v.push(4); // попытка изменить вектор
println!("{:?}", r);
}
Ошибка ожидаемая: пока r жив, менять v нельзя. Решение — изменить порядок и ограничить время жизни r:
fn main() {
let mut v = vec![1, 2, 3];
{
let r = &v;
println!("{:?}", r);
} // r умерло
v.push(4);
println!("{:?}", v);
}
Упражнение 9. Ownership и коллекции: итераторы, move и “почему вектор пуст”
В работе с коллекциями ownership проявляется особенно ярко. Например:
fn main() {
let v = vec![String::from("a"), String::from("b")];
for s in v {
println!("{}", s);
}
// v больше нельзя использовать: move при итерации
// println!("{:?}", v);
}
for s in v фактически делает into_iter() и “забирает” элементы — владение переходит в цикл.
Если нужно сохранить v
Используйте заимствующую итерацию:
fn main() {
let v = vec![String::from("a"), String::from("b")];
for s in &v {
println!("{}", s);
}
println!("v still available: {}", v.len());
}
Ещё один нюанс: если хотите изменить элементы
Нужна изменяемая итерация:
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
for s in &mut v {
s.push('!');
}
println!("{:?}", v);
}
Упражнение 10. “Почему не работает” при передаче владения в несколько мест
Одна из частых ошибок новичков: попробовать использовать одно значение после передачи в функцию, которая принимает владение.
fn takes_ownership(s: String) {
println!("{}", s);
}
fn main() {
let s = String::from("hello");
takes_ownership(s);
// takes_ownership(s); // ошибка: s уже перемещён
}
Решение: заимствовать
Если функция не должна владеть, меняем сигнатуру:
fn takes_borrow(s: &str) {
println!("{}", s);
}
fn main() {
let s = String::from("hello");
takes_borrow(&s);
takes_borrow(&s);
}
Когда всё-таки нужен move
Если функция строит структуру, преобразует данные и должна “забрать” ответственность за ресурс — владение логично. Но это нужно осознанно, а не “потому что компилятор попросил”.
Как встроить ownership-мышление в привычки разработки
К этому моменту вы уже видели основные сценарии: move, borrow, lifetimes. Теперь полезно сформировать устойчивые привычки.
1) Начинайте с “контракта функции”
Перед тем как писать код, ответьте:
- Функция создаёт результат и может не зависеть от входа? Тогда возвращайте владение.
- Функция только читает вход? Тогда
&T/&str. - Функция меняет вход? Тогда
&mut T. - Функция обрабатывает и “забирает” ресурс? Тогда принимайте
T.
Это резко уменьшает число “переезды” владения и конфликты заимствований.
2) Смотрите на области видимости
Когда заимствования конфликтуют, почти всегда помогает ограничить scope.
В Rust lifetime часто “вычисляется” не магией, а обычной видимостью и тем, как компилятор понимает последующее использование переменных.
3) Для ссылок выбирайте границы данных
Если вы видите ошибку про возвращаемые ссылки на локальные значения — не боритесь с компилятором, а поменяйте архитектуру:
- вернуть владение,
- или передать данные наружу как аргумент и вернуть ссылку на них.
Типичные ошибки на старте (и как их избежать)
Ошибка 1: “Я же просто печатаю — почему нельзя?”
Если вы передаёте String в функцию и она принимает String, вы не “печатаете”, а передаёте владение. Для печати нужны ссылки.
Ошибка 2: “Я думал, ссылку можно, она же неизменяемая”
Даже неизменяемая ссылка &T может блокировать &mut (или изменение самого владения) до конца её области жизни.
Ошибка 3: “Время жизни должно быть ‘очевидным’”
Компилятор не верит человеческой очевидности. Если ссылка возвращается наружу — Rust требует доказательств через lifetimes (в явном виде или через вывод).
Ошибка 4: “clone — это всегда решение”
clone() полезен, но он меняет модель производительности и семантику. На старте лучше научиться отделять “заимствование” от “копирования”.
Вывод: компилятор как навигатор, а не как противник
Ownership в Rust поначалу кажется жёстким, потому что язык не позволяет “интуитивно неправильные” вещи вроде использования после move или возвращения ссылок на уже уничтоженные данные. Но эта жёсткость — плата за безопасность: Rust не даёт получить класс ошибок, которые в системных языках часто стоят времени отладки.
Рабочая стратегия — делать маленькие упражнения и читать ошибки как инструкции:
- move произошло? значит, владение ушло — используйте borrow или clone по необходимости;
- заимствования конфликтуют? значит, вы нарушили правило эксклюзивности — попробуйте ограничить scope или поменять типы ссылок;
- ссылка невалидна “снаружи”? значит, нужна другая архитектура — вернуть владение или корректно связать lifetimes.
Если вы хотите дальше углубляться и собрать эти правила в более системный набор практик, можно попробовать учебный курс Rust — как способ структурировать упражнения, а не как попытку “обойти
Комментарии
Пока нет комментариев