Раздел 23 · Rust

Функциональный интерфейс через владение

middle-senior~35 мин

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

Функциональный интерфейс через владение

Ты пишешь редактор персонажа. Игрок выбрал гнома Глоина, выставил возраст 127 лет и выдал ему топор. После сохранения экран должен показывать обновлённого персонажа. Старый вариант редактору больше не нужен.

Хочется записать изменение как цепочку: dwarf.with_age(127).with_weapon(Weapon::Axe). Но должна ли каждая ступень копировать персонажа? И кто решает, что произойдёт со старым состоянием: вызывающий код или метод?

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

Три разрешения для одного персонажа

Вспомни передачу владения и заимствование. Выбор между self, &self и &mut self определяет не стиль записи, а права метода.

  • &mut self разрешает менять значение вызывающего кода. После завершения заимствования владелец получает доступ к уже изменённому персонажу.
  • &self даёт разделяемый доступ. Для нашего персонажа метод может прочитать данные и построить отдельную копию, сохранив исходную версию.
  • self передаёт владение. Метод забирает персонажа целиком; если возвращает Self, вызывающий код получает владение результатом.

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

Собери следующий листинг в отдельный main.rs. Он использует только стандартную библиотеку.

#[derive(Clone, Debug)]
enum Weapon {
    Unarmed,
    Axe,
}

#[derive(Clone, Debug)]
struct Dwarf {
    name: String,
    age: u16,
    weapon: Weapon,
}

impl Dwarf {
    fn new(name: String, age: u16) -> Self {
        Self {
            name,
            age,
            weapon: Weapon::Unarmed,
        }
    }

    fn with_age(mut self, age: u16) -> Self {
        self.age = age;
        self
    }

    fn with_weapon(mut self, weapon: Weapon) -> Self {
        self.weapon = weapon;
        self
    }

    fn set_age(&mut self, age: u16) {
        self.age = age;
    }

    fn aged_copy(&self, age: u16) -> Self {
        self.clone().with_age(age)
    }
}

fn main() {
    let dwarf = Dwarf::new(String::from("Глоин"), 126);
    let dwarf = dwarf.with_age(127).with_weapon(Weapon::Axe);
    println!("{}: {}, {:?}", dwarf.name, dwarf.age, dwarf.weapon);

    let mut visitor = Dwarf::new(String::from("Балин"), 81);
    visitor.set_age(82);
    let older = visitor.aged_copy(83);
    println!("старая версия: {}, новая: {}", visitor.age, older.age);
}

Ожидаемый вывод:

Глоин: 127, Axe
старая версия: 82, новая: 83

Weapon перечисляет разрешённые варианты. Опечатка в строке "axee" не должна превращаться в новый вид оружия. Подробности такого моделирования есть в уроке о перечислениях.

Внутреннее mut self не требует let mut dwarf у вызывающего кода. Метод получил значение в собственную локальную переменную и может менять её. Возврат self отдаёт результат наружу. Новое let dwarf затеняет прежнее имя, но не восстанавливает прежнюю версию.

Разложи цепочку по шагам:

  1. with_age получает владение исходным Dwarf и меняет его возраст.
  2. Возвращённое значение переходит в with_weapon; тот меняет оружие.
  3. Последний результат становится значением нового связывания dwarf.

Ни один из этих методов не вызывает clone. Логически ты получил обновлённое значение, но отдельный снимок старого состояния не создавал.

Ошибка, которая делает договор видимым

Теперь сохрани определения Weapon, Dwarf и весь impl из первого листинга, а main замени на этот. Этот пример намеренно не компилируется.

fn main() {
    let old = Dwarf::new(String::from("Глоин"), 126);
    let updated = old.with_age(127);
    println!("старый возраст: {}", old.age);
    println!("новый возраст: {}", updated.age);
}

Обращение к old.age после поглощающего вызова приводит к E0382: значение old уже перемещено. Это не проверка версии во время выполнения. Компилятор запрещает пользоваться прежним владельцем ещё до запуска.

Поэтому вызывающий код должен выбрать стратегию заранее:

Что требуетсяВызовЧто останется после вызова
Обновить текущего персонажаvisitor.set_age(82)Один изменённый персонаж
Сохранить старую версиюvisitor.aged_copy(83)Исходный персонаж и отдельный результат
Продолжить без старой версииvisitor.with_age(83)Только возвращённый персонаж

Для aged_copy производный Clone клонирует все поля. В этом типе String копирует содержимое имени в отдельный буфер, а возраст и вариант оружия копируются без выделения памяти. Эта цена оправдана, если старый снимок нужен для отмены действия или сравнения.

Поглощающий метод не позволяет сохранить старую версию бесплатно. Чтобы получить два независимых Dwarf, вызови clone перед передачей владения либо выбери представление, рассчитанное на хранение нескольких версий.

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

Аффинность не требует использовать значение ровно один раз

Аффинное владение

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

Линейная дисциплина требует расходовать ресурс ровно один раз. Разница проявляется, когда значение больше не нужно: аффинная модель разрешает его отбросить. В Rust можно вызвать drop(dwarf) или дать значению выйти из области видимости.

Эта формулировка относится к передаче владения значениями, которые не реализуют Copy, как наш Dwarf. Обычный u16 можно скопировать и продолжить использовать оба числа. Аффинность не означает, что любое имя в Rust можно упомянуть лишь однажды.

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

Сохранился ли буфер в куче

Перемещение значения и копирование всех данных в куче, которыми оно владеет, не одно и то же. Для Vec это можно показать без измерений скорости и без догадок об ассемблере.

Сохрани следующий независимый листинг в другой main.rs. Заранее резервируем место под четыре числа, добавляем три и сравниваем указатель на буфер до и после цепочки.

struct Roster {
    members: Vec<u64>,
}

impl Roster {
    fn with_capacity(capacity: usize) -> Self {
        Self {
            members: Vec::with_capacity(capacity),
        }
    }

    fn with_member(mut self, member: u64) -> Self {
        self.members.push(member);
        self
    }
}

fn main() {
    let roster = Roster::with_capacity(4);
    let before = roster.members.as_ptr();
    let capacity = roster.members.capacity();

    let roster = roster.with_member(11).with_member(22).with_member(33);

    assert_eq!(before, roster.members.as_ptr());
    assert_eq!(capacity, roster.members.capacity());
    assert_eq!(roster.members, [11, 22, 33]);
    println!("буфер сохранён: {}", before == roster.members.as_ptr());
    println!("участники: {:?}", roster.members);
}

Ожидаемый вывод:

буфер сохранён: true
участники: [11, 22, 33]

before хранит сырой указатель, а не заимствование, поэтому он не мешает перемещать владельца. Мы не разыменовываем указатель. Возвращённый roster всё ещё владеет буфером, с которым сравниваем адрес.

Этот результат опирается на документированные гарантии Vec: сам перенос Vec не переносит его элементы в другой буфер, а push не выделяет новый буфер, пока хватает ёмкости. with_capacity(4) обеспечивает ёмкость не меньше четырёх; три добавления в пустой вектор её не исчерпают.

Что пример не доказывает:

  • Он не показывает адрес самого Roster. Обёртка может оказаться в другом месте памяти.
  • Он не считает машинные инструкции и не измеряет время.
  • Он не обещает, что добавление пятого или сотого участника обойдётся без роста буфера. Ориентируйся на фактическую capacity, не на запрошенное число.
  • Он не переносит гарантию на любой метод с self. Такой метод может вызвать clone, заменить поле или выделить новый буфер.

В четвёртой части исходного материала есть оговорка о физических копиях при move. Точнее говорить так: перемещение задаёт семантику передачи владения, но не фиксирует набор машинных действий. Компилятор может переносить байты значения или устранить этот перенос. Поэтому неверны обе крайности: «все move обязательно копируют байты» и «move никогда ничего физически не копирует».

При перемещении Vec<u64> не вызывается clone его элементов и не создаётся второй буфер с их копиями. Для большого встроенного массива картина другая: байты элементов входят в само перемещаемое значение. Не превращай удачный пример с Vec в закон о бесплатности всех типов.

Даже with_name(mut self, name: String) -> Self не делает создание имени бесплатным: новый String кто-то должен подготовить. При замене прежнее поле будет уничтожено. Разбирай стоимость конкретных операций, как в уроке о реализациях модулей, а не угадывай её по красивой цепочке.

Владение обёрткой не означает владение всем графом

Наш Dwarf владеет String напрямую. Но поле может быть указателем на данные, которые разделяют другие значения. Тогда поглощение внешней структуры не уничтожает все пути к вложенному состоянию.

Вот третий независимый листинг. Он тоже использует только std.

use std::cell::RefCell;
use std::rc::Rc;

struct SharedDwarf {
    age: Rc<RefCell<u16>>,
}

impl SharedDwarf {
    fn with_age(self, age: u16) -> Self {
        *self.age.borrow_mut() = age;
        self
    }
}

fn main() {
    let observer = Rc::new(RefCell::new(126));
    let dwarf = SharedDwarf {
        age: Rc::clone(&observer),
    };
    let updated = dwarf.with_age(127);

    assert_eq!(*updated.age.borrow(), 127);
    println!("наблюдатель видит: {}", *observer.borrow());
}

Ожидаемый вывод:

наблюдатель видит: 127

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

У метода даже нет mut self: менять само поле age не требуется, меняется содержимое за указателем. Если добавить в другую структуру обычные поля и записать mut self, разделяемые пути к вложенным данным от этого не исчезнут.

Поэтому mut self не доказывает чистоту. Метод может менять разделяемое состояние, писать файл, читать часы или отправлять запрос. Аналогичная проблема возникает с Arc<Mutex<T>>: поглощение одной ручки Arc не отбирает остальные. Замок согласует доступ, но другие владельцы увидят изменение. Это разбирается в уроке об Arc и Mutex.

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

Функциональная форма и чистота не одно и то же

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

Цепочка Dwarf даёт логически новое значение через единственное владение: ты продолжаешь работу с результатом, а старое владение больше не доступно. В пределах этого типа, без разделяемых изменяемых полей и внешних эффектов, о преобразовании можно рассуждать локально. Это не универсальное доказательство чистоты функции Rust.

Не путай это и с трейтом Fn: он описывает способ вызова замыкания, а не отсутствие эффектов. Замыкание, подходящее под Fn, может печатать текст или менять RefCell.

У первой части исходного материала полезна мысль об ограничении прав вызываемого кода. Но функциональное программирование не определяется требованием, чтобы все функции программы были чистыми. Программе нужны ввод, вывод и работа с внешним миром; вопрос в том, как она отделяет эти эффекты от преобразований данных.

Неизменяемые данные также не устраняют все гонки. Два потока могут читать один неизменяемый снимок баланса и независимо решить, что денег хватает на покупку. Если списания не согласованы, ошибка останется: снимок никто не менял, а общий внешний ресурс всё равно использовали дважды. Отличай отсутствие гонки данных от корректности порядка действий.

Когда старую версию всё-таки надо сохранить

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

Полное копирование не единственный вариант. Персистентная структура сохраняет старые версии и может разделять между ними неизменённые узлы. Например, обновление дерева создаёт новый путь к изменённому листу, а остальные ветви использует повторно. Конкретная стоимость зависит от устройства структуры и операции.

Разделение структуры не равно Rc<RefCell<T>> из предыдущего примера. Там обе ручки видят одну мутацию. При сохранении версий общие узлы остаются неизменяемыми, поэтому старая версия не превращается в новую за спиной читателя.

Таким образом, предположение второй части материала «обновление обязательно копирует всю структуру» слишком широко. Есть полные копии, разделение неизменённых частей и повторное использование уникально доступных узлов. Выбирай по требуемым наблюдениям и стоимости, а не по ярлыку языка.

В четвёртой части Бобровский упоминает Clojure. Его официальные transients предлагают отдельную сессию построения:

  1. transient создаёт временное представление из персистентной структуры, не меняя исходную версию.
  2. Операции вроде conj! выполняют изменения; результат каждой операции нужно передавать следующей.
  3. persistent! завершает сессию и возвращает персистентный результат. Использовать transient после этого нельзя, включая ранее созданные псевдонимы.

Это не отмена требований к владению: одновременная работа нескольких потоков с одним transient запрещена. Документация требует изоляции доступа. В отличие от Rust, обычное связывание Clojure не даёт той же статической проверки передачи владения; часть ошибок использования transient обнаружится во время выполнения.

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

Выбирай по наблюдениям клиента

Перед тем как писать with_*, ответь на три вопроса:

  1. Нужен ли старый снимок? Если нет, self -> Self позволяет продолжить цепочку без обязательного клонирования. Если да, оцени цену clone или представления с сохранением версий.
  2. Нужно ли менять текущий объект на месте? Если клиент уже владеет изменяемым состоянием и переход не требует отдельного результата, &mut self может точнее выразить договор.
  3. Есть ли другие владельцы вложенных данных? Проверь Rc, Arc, внутреннюю мутабельность и внешние ресурсы. Уникальность обёртки не гарантирует изоляцию всего графа.

Итог: контроль над состоянием начинается с явного договора. self отдаёт владение, &mut self разрешает изменить текущий объект, &self оставляет возможность читать исходное состояние. Для последнего случая отдельно проверь внутреннюю мутабельность. Память и чистота не следуют автоматически из одного слова в сигнатуре.

Практика

Проверь себя