$ sudo teach IT
Модуль 3 · Владение · Урок 3.1

Что такое владение

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

Теория + практика~30 минутНовичок

В прошлых модулях мы работали с числами, а текст видели лишь в шаблоне чтения с клавиатуры: String::new() и загадочное &mut line. Чтобы обращаться с текстом уверенно, нужно понять главную идею Rust — владение. Она объясняет и этот &mut, и то, почему Rust обходится без сборщика мусора и при этом не даёт сломать память.

Где Rust хранит данные: стек и куча

Представьте две кладовки. Первая — стопка тарелок: кладёте сверху, снимаете сверху, каждая тарелка одного размера, всё быстро и без вопросов. Вторая — большой склад с ячейками: нужно найти свободное место подходящего размера, получить номерок и потом по нему возвращаться. Зато в ячейку можно положить что угодно, а коробку при необходимости заменить на побольше.

Программа устроена так же. Первая кладовка называется стеком (stack): сюда попадают значения заранее известного размера — u32, bool, char. Второе хранилище называется кучей (heap): там живут данные, размер которых меняется по ходу работы, например текст, который пользователь вводит с клавиатуры.

Раньше нам хватало строк в кавычках, вроде "Копилка 0.1": они вшиты в готовую программу и не меняются. Для текста, который можно дописывать и читать с клавиатуры, существует тип String — «строка-владелец». На стеке лежит небольшой ярлык (где текст и сколько его), а сами буквы — в куче.

Rust · main.rs
fn main() {
    let amount = 350;
    let category = String::from("еда");
    println!("{amount} руб., {category}");
}

Разбор по строкам

  • let amount = 350; — число известного размера, лежит на стеке.
  • String::from("еда") — берёт текст из кавычек и создаёт на его основе свою строку в куче. Читается как «сделать String из этого текста».
  • let category = ... — в переменной лежит ярлык, по которому Rust находит буквы в куче.
  • println!("{amount} руб., {category}"); — подставляет оба значения в текст. Выведет 350 руб., еда.

Три правила владения

Теперь вопрос: кто убирает за программой? Когда строка в куче больше не нужна, её место надо вернуть. В одних языках это делает сборщик мусора, в других — сам программист, и забытая уборка превращается в утечку памяти. Rust выбрал третий путь: у каждого значения есть владелец, и когда владелец исчезает, значение убирается автоматически.

Бытовая аналогия — ключ от квартиры. Квартира одна, и ключ от неё один. Можно передать ключ другому человеку, но тогда у вас его уже нет. А когда хозяин съезжает и никому ключ не передал, квартиру освобождают. Из этого получаются три правила владения (ownership):

  1. У каждого значения есть владелец — переменная, в которой оно лежит.
  2. В каждый момент времени у значения ровно один владелец.
  3. Когда владелец выходит из области видимости, значение удаляется.

Область видимости (scope) — участок кода между фигурными скобками, где переменная существует. Она начинается со строки с let и заканчивается закрывающей скобкой }. В этой скобке Rust сам вызывает уборку — она называется drop («сбросить»). Писать её не нужно, но знать, что она происходит, нужно.

Rust · main.rs
fn main() {
    {
        let temp = String::from("временно");
        println!("{temp}");
    }
    println!("{temp}");
}

Внутренние фигурные скобки образуют свой блок. Переменная temp живёт только в нём: на закрывающей скобке происходит drop, и строка из кучи убирается. Последний println! обращается к переменной, которой уже нет:

Ошибка компилятора
error[E0425]: cannot find value `temp` in this scope
 --> src/main.rs:6:16
  |
6 |     println!("{temp}");
  |                ^^^^
  |
help: the binding `temp` is available in a different scope in the same function
 --> src/main.rs:3:13
  |
3 |         let temp = String::from("временно");
  |             ^^^^

Компилятор честно подсказывает: «переменная есть, но в другой области видимости». Это и есть третье правило в действии.

Перемещение: что случается при let b = a

А что если хозяев должно стать двое? В большинстве языков вы просто пишете b = a, и теперь оба имени указывают на одно и то же. В Rust так нельзя, и причина простая. Допустим, a и b владеют одной строкой в куче. Когда они оба выйдут из области видимости, каждый попытается её убрать, и одну и ту же коробку выбросят дважды — это тяжёлая ошибка памяти. Поэтому Rust выбирает по второму правилу: владелец один.

Когда вы пишете let b = a; для String, ключ передаётся: b становится владельцем, а a «опустошается» — пользоваться ей больше нельзя. Это называется перемещением (move). Проверим:

Rust · main.rs
fn main() {
    let a = String::from("еда");
    let b = a;
    println!("{a}");
    println!("{b}");
}

Компилятор остановит сборку и объяснит, что случилось. Вот настоящий текст из cargo check:

Ошибка компилятора
error[E0382]: borrow of moved value: `a`
 --> src/main.rs:4:16
  |
2 |     let a = String::from("еда");
  |         - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 |     let b = a;
  |             - value moved here
4 |     println!("{a}");
  |                ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let b = a.clone();
  |              ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `kopilka` (bin "kopilka") due to 1 previous error

Разбор по строкам

  • error[E0382]: borrow of moved value: `a` — «использование значения, которое уже перемещено». Номер E0382 вы будете встречать часто, запомните его.
  • move occurs because ... does not implement the Copy trait — почему именно перемещение: у String нет умения копироваться само (о Copy — ниже).
  • value moved here — стрелка на строку let b = a;: ключ передали здесь.
  • value borrowed here after move — стрелка на println!("{a}"): здесь пытаются воспользоваться уже отданным.
  • help: consider cloning — подсказка с одним из решений; о нём следующий раздел. Последние две строки сообщают, что проект не собран.

Читайте ошибки сверху вниз

Сообщения Rust устроены как маленький рассказ: что случилось, где значение отдали, где пытались взять снова и что можно сделать. Не пугайтесь длины — ответ почти всегда уже написан в подсказках help и note.

Копия: clone и умение Copy

Иногда вам нужны именно два независимых текста. Тогда владелец делает настоящую копию — клонирует значение. Метод clone() создаёт в куче вторую строку с теми же буквами, и у каждой появляется свой владелец:

Rust · main.rs
fn main() {
    let a = String::from("еда");
    let mut b = a.clone();
    b.push_str(" и ужин");
    println!("{a} / {b}");
}

Разбор по строкам

  • a.clone() — создаёт вторую строку-копию. a остаётся владельцем своей, b получает новую.
  • let mut b — mut нужен, потому что мы будем менять строку (как с spent раньше).
  • b.push_str(" и ужин") — метод push_str («протолкнуть строку») дописывает текст в конец. Менять b можно: строки разные.
  • println! выведет еда / еда и ужин: копия изменилась, оригинал нет.

Но почему с числами всё работает без всяких clone? Посмотрите:

Rust · main.rs
fn main() {
    let x = 5;
    let y = x;
    println!("{x} {y}");
}

Выведется 5 5, ошибки нет. Дело в том, что маленькие значения на стеке дёшево скопировать, и Rust делает это сам при каждом присваивании. Такие типы помечены особым умением (трейтом) Copy: целые и дробные числа, bool, char. А вот у String такого умения нет — копировать данные в куче дорого, и Rust не станет делать это втихомолку.

ТипЧто делает let b = a;Что с a после
u32, bool, charкопируетможно пользоваться
Stringперемещаетпользоваться нельзя

Не лечите clone все ошибки подряд

clone — рабочий инструмент, но каждая копия стоит времени и памяти. Если вставлять его везде «чтобы компилятор отстал», вы ничего не поймёте про владение. Сначала спросите себя: действительно ли мне нужны два независимых значения? В следующем уроке увидим, что чаще достаточно просто дать посмотреть.

Владение и функции

Передача значения в функцию устроена точно так же, как let b = a. Параметр функции — это новая переменная, а аргумент в неё перемещается. Бытовая картина: вы отдали ключ мастеру, и теперь квартира его забота. Когда мастер закончил (функция дошла до }), для строки срабатывает drop.

Если мастер должен вернуть ключ, он возвращает его результатом функции:

Rust · main.rs
fn labeled(note: String) -> String {
    format!("[{note}]")
}

fn main() {
    let note = String::from("обед");
    let result = labeled(note);
    println!("{result}");
}

Разбор по строкам

  • fn labeled(note: String) -> String — функция забирает строку во владение и обещает вернуть другую.
  • format!("[{note}]") — макрос format! работает как println!, только не печатает, а возвращает готовую String. Результат без точки с запятой — он и есть ответ функции.
  • labeled(note) — владение переходит в параметр. После этой строки переменной note в main пользоваться нельзя.
  • let result — новый владелец результата. Выведется [обед].

Теперь видно неудобство. Если функция только читает строку, например печатает, нам придётся каждый раз либо отдавать и забирать её обратно, либо делать clone. Именно поэтому в Rust есть ссылки: способ дать значение посмотреть, не передавая ключ. Они — тема следующего урока, а пока удобно знать: & перед именем и означает «посмотреть, не забирая». Так, push_str(&copy) допишет к строке содержимое другой строки, не отбирая её.

Практика: заметка в Копилке

Добавим в проект первую настоящую String: заметку к трате. Увидим на собственном проекте и ошибку, и исправление. Остальное в main.rs (print_header, константа MONTH_LIMIT, print_status, цикл ввода из прошлого модуля) остаётся как было.

  1. Откройте папку проекта kopilka и файл src/main.rs.
  2. Выше функции main, рядом с остальными функциями, добавьте функцию print_note.
  3. В начало main, сразу после вызова print_header(); и до цикла ввода, вставьте три строки про заметку.
  4. В терминале, в папке kopilka, выполните cargo check. Это быстрая проверка без запуска.
Rust · main.rs (новая функция, над main)
fn print_note(note: String) {
    println!("Заметка: {note}");
}
Rust · main.rs (в начале main, после print_header)
    let note = String::from("обед");
    print_note(note);
    println!("Заметка после вызова: {note}");

Получите ошибку, которую уже умеете читать:

Ошибка компилятора
error[E0382]: borrow of moved value: `note`
  --> src/main.rs:41:38
   |
39 |     let note = String::from("обед");
   |         ---- move occurs because `note` has type `String`, which does not implement the `Copy` trait
40 |     print_note(note);
   |                ---- value moved here
41 |     println!("Заметка после вызова: {note}");
   |                                      ^^^^ value borrowed here after move
   |
note: consider changing this parameter type in function `print_note` to borrow instead if owning the value isn't necessary
  --> src/main.rs:32:21
   |
32 | fn print_note(note: String) {
   |    ----------       ^^^^^^ this parameter takes ownership of the value
   |    |
   |    in this function
help: consider cloning the value if the performance cost is acceptable
   |
40 |     print_note(note.clone());
   |                    ++++++++

For more information about this error, try `rustc --explain E0382`.

Номера строк у вас могут отличаться. Читаем: note перемещена в print_note (строка с value moved here), а потом мы пытаемся её напечатать. Компилятор даже указывает на параметр String и советует либо взять значение взаймы (это будет в следующем уроке), либо клонировать. Сегодня выберем второе: замените вызов на копию.

Rust · main.rs (исправлено)
    let note = String::from("обед");
    print_note(note.clone());
    println!("Заметка после вызова: {note}");

Запустите программу: cargo run, а на запрос суммы введите 350, затем стоп. Заметка напечатается дважды, потом пойдёт знакомый цикл:

Вывод
Копилка 0.2
Вводите траты по одной, для выхода напишите стоп.
Заметка: обед
Заметка после вызова: обед
Введите сумму (или стоп):
Записано: 350 руб. Всего: 350 руб.
Введите сумму (или стоп):
Итого потрачено: 350 руб. из 30000 руб.
Осталось: 29650 руб.
Использовано лимита: 1%
Статус: в норме

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

Проверьте себя

  • Вы можете объяснить, на какой строке заметка перестала принадлежать main.
  • Вызов cargo check с print_note(note) даёт error[E0382], а с note.clone() — нет.
  • Вы понимаете, почему такая же передача обычного числа в функцию не вызвала бы ошибки: числа копируются сами.
  • Вы знаете, что print_note забирает строку и удаляет её на закрывающей скобке функции.

Подсказка про следующий шаг

Эта копия — временное решение. В следующем уроке перепишем print_note так, чтобы она получала не саму строку, а ссылку на неё, и clone исчезнет.

Частые ошибки

1. Использовать значение после перемещения

Что видно. error[E0382]: borrow of moved value (иногда use of moved value), стрелки value moved here и value borrowed here after move.

Почему. Строку передали в другую переменную или в функцию, и владельцем стал кто-то другой. Как исправить. Найдите строку value moved here. Если вам нужны два значения — clone(). Если нужно просто посмотреть — ссылка (в следующем уроке). Если старое значение больше не нужно — уберите его использование.

2. Вставлять clone везде «чтобы компилятор отстал»

Что видно. Код компилируется, но в нём десятки .clone(), и непонятно, зачем они.

Почему. Копия — не лекарство, а решение: каждая создаёт новую строку в куче. Как исправить. Для каждого clone задайте вопрос: «нужны ли мне именно два независимых значения?». Если нет — это место для ссылки, о которой следующий урок.

3. Думать, что для чисел let b = a тоже перемещает

Что видно. Ученик пишет let y = x.clone(); для обычного u32 или переживает за x после let y = x;.

Почему. Числа, bool и char имеют умение Copy: они копируются сами, и оба имени остаются живыми. Как исправить. Помните правило: перемещаются только значения, которые владеют данными в куче (пока что — String).

4. Искать переменную за пределами её скобок

Что видно. error[E0425]: cannot find value `temp` in this scope.

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

Что важно запомнить

  • Стек — для значений известного размера (числа, bool, char), куча — для растущих данных, как String.
  • Три правила: у значения есть владелец; владелец один; владелец вышел из области видимости — значение удалено (drop).
  • let b = a; для String перемещает значение: a после этого использовать нельзя, иначе error[E0382]: borrow of moved value.
  • clone() делает настоящую копию в куче: пользуйтесь им осознанно, не по привычке.
  • Числа, bool и char имеют умение Copy и копируются сами.
  • Передача в функцию — тоже перемещение; вернуть значение можно результатом функции.
  • format! собирает String по шаблону, push_str дописывает текст в конец изменяемой строки.

Дальше разберёмся, как дать функции прочитать строку, не отбирая её у хозяина, — поговорим про ссылки и заимствование.

На следующий урок