Внутренняя изменчивость в Rust: что, почему, как?

Перевод | Автор оригинала: Ricardo Martins

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

Ключевые выводы

Эта статья является частью серии о внутренней изменчивости в Rust. Вы можете прочитать часть 2 здесь и часть 3 здесь.

Введение

Иногда структурам данных необходимо изменить одно или несколько своих полей, даже если они объявлены неизменяемыми. Сначала это может показаться удивительным, но вы, вероятно, раньше полагались на это поведение, например, когда вы клонируете оболочку с подсчетом ссылок, такую как Rc, или когда вы блокируете мьютекс. Однако в Rust изменчивость - это атрибут «все или ничего»: либо переменная объявлена как изменяемая, и все ее поля также изменяемы (если это структура), либо она объявлена неизменной, как и все ее поля. Как добиться селективной изменчивости поля? Что-то загадочное происходит.

Вы когда-нибудь задумывались, как реализован Rc? Давай попробуем! Наивное первое решение было бы примерно таким:

struct NaiveRc<T> {
    reference_count: usize,
    inner_value: T,
}

impl Clone for NaiveRc<T> {
    fn clone(&self) -> Self {
        self.reference_count += 1;
        // ...
    }
}

Вероятно, вы сразу заметили проблему: clone принимает ссылку только для чтения на себя, поэтому счетчик ссылок не может быть обновлен! Трэйт возьми.

Мы могли бы реализовать специальную функцию клонирования с другим именем, которая принимает &mut self, но это ужасно для удобства использования (потому что это противоречит соглашению о простой проверке того, реализует ли тип Clone) и заставляет пользователя нашего API всегда объявлять изменяемые экземпляры. такого типа. Мы также знаем, что оболочки с подсчетом ссылок в стандартной библиотеке (std::rc::Rc и std::sync::Arc) не полагаются на это решение, что предполагает другой способ.

Итак, как они решили эту проблему в Rc и Arc? Стандартная библиотека полагается на какую-то особо неприятную магию? Нисколько!

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

Что?

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

И все же в некоторых случаях вам нужно несколько изменяемых полей в структурах данных, независимо от того, являются ли они изменяемыми или нет. Внутренняя изменчивость дает вам дополнительную гибкость и позволяет скрыть детали реализации от пользователя вашего API, сохраняя при этом (в некоторой степени) безопасность доступа.

Чтобы объяснить, что такое внутренняя изменчивость, лучше немного отступить и начать с того, что вам знакомо: внешней изменчивости.

Внешняя изменчивость - это вид изменяемости, который вы получаете из изменяемых ссылок (&mut T). Тип объявления, &T или &mut T, дает понять, можете ли вы обновлять переменную или вызывать методы изменения для объектов. Как вы знаете, внешняя изменчивость проверяется и применяется во время компиляции:

struct Foo { x: u32 };

let foo = Foo { x: 1 };
// The borrow checker will complain about this and abort compilation
foo.x = 2;

let mut bar = Foo { x: 1 };
// 'bar' is mutable, so you can change the content of any of its fields
bar.x = 2;

Если у вас есть неизменяемая ссылка, вы не можете изменить значение. И наоборот, поскольку в Rust нет изменяемости на уровне полей, если вы хотите изменить одно поле, вам нужно сделать изменяемой всю структуру, и все. Либо это? Не так быстро.

Напротив, внутренняя изменчивость - это когда у вас есть неизменяемая ссылка (например, &T), но вы можете изменять структуру данных. Как я упоминал ранее, вот что происходит, когда вы клонируете Rc или блокируете Mutex (и Mutex::lock, и Mutex::try_lock работают в неизменяемых экземплярах).

Простой пример прояснит разницу. Предположим, у нас есть простая структура, подобная следующей:

struct Point { x: i32, y: i32 }

Неизменяемый Point можно рассматривать как неизменяемый фрагмент памяти, в полях которого (разделах фрагмента памяти) вообще нельзя изменять свое содержимое. Когда вы объявляете неизменный Point, ваши руки связаны.

Рассмотрим теперь немного другой MagicPoint с магическими улучшениями:

struct MagicPoint { x: i32, y: Magic<i32> }

Point и MagicPoint

Пока не обращайте внимания на то, как работает Magic, и думайте об этом как об указателе на изменяемый адрес памяти, о новом уровне косвенного обращения. Как и раньше, если у вас есть неизменяемый MagicPoint, вы не можете назначать новые значения ни одному из его полей. Однако в этом случае вам не нужно изменять содержимое y, только место назначения этого волшебного указателя, то есть другой фрагмент памяти, и этот фрагмент является изменяемым! 1

Чтобы было ясно, даже несмотря на то, что API для Magic создаст впечатление, будто вы полагаетесь на косвенный доступ для доступа и обновления обернутого значения, представление MagicPoint в памяти на самом деле будет плоским.

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

Как?

Итак, как мы можем получить волшебные изменяемые указатели? К счастью для нас, стандартная библиотека Rust предоставляет две оболочки, std::cell::Cell и std::cell::RefCell, которые позволяют нам вводить внутреннюю изменчивость во внешне неизменяемые экземпляры структур данных. С Cell и RefCell в наших коллективных наборах инструментов мы можем использовать силу внутренней изменчивости.

Обе оболочки обеспечивают внутреннюю изменчивость и отказываются от проверки внутреннего значения во время компиляции, но дают разные гарантии безопасности и служат разным целям. Наиболее очевидное различие между ними состоит в том, что RefCell выполняет проверки заимствования во время выполнения, а Cell - нет.

Cell довольно прост в использовании: вы можете читать и записывать внутреннее значение Cell, вызывая для него get или set. Поскольку здесь нет проверок во время компиляции или выполнения, вы должны быть осторожны, чтобы избежать некоторых ошибок, которые программа проверки заимствований может остановить вас от записи, таких как случайная перезапись обернутого значения:

use std::cell::Cell;

fn foo(cell: &Cell<u32>) {
    let value = cell.get();
    cell.set(value * 2);
}

fn main() {
    let cell = Cell::new(0);
    let value = cell.get();
    let new_value = cell.get() + 1;
    foo(&cell);
    cell.set(new_value); // oops, we clobbered the work done by foo
}

Напротив, RefCell требует, чтобы вы вызывали заимствование или заимствование_mut (неизменяемые и изменяемые заимствования) перед его использованием, давая указатель на значение. Его семантика заимствования идентична внешне изменяемым переменным: вы можете иметь либо изменяемое заимствование внутреннего значения, либо несколько неизменяемых заимствований, поэтому тип ошибки, о которой я упоминал ранее, обнаруживается во время выполнения.

use std::cell::Cell;

struct NaiveRc<T> {
    inner_value: T,
    references: Cell<usize>,
}

impl<T> NaiveRc<T> {
    fn new(inner: T) -> Self {
        NaiveRc {
            inner_value: inner,
            references: Cell::new(1),
        }
    }

    fn references(&self) -> usize {
        self.references.get()
    }
}

impl<T: Clone> Clone for NaiveRc<T> {
    fn clone(&self) -> Self {
        self.references.set(self.references.get() + 1);
        NaiveRc {
            inner_value: self.inner_value.clone(),
            references: self.references.clone(),
        }
    }
}

fn main() {
    let wrapped = NaiveRc::new("Hello!");
    println!("references before cloning: {:?}", wrapped.references());
    let wrapped_clone = wrapped.clone();
    println!("references after cloning: {:?}", wrapped.references());
    println!("clone references: {:?}", wrapped_clone.references());
}

Вызов заимствования или заимствования_mut для изменяемого заимствования RefCell вызовет панику, как и вызов заимствования_mut для неизменяемого заимствованного значения. Этот аспект делает RefCell непригодным для использования в параллельном сценарии; вместо этого вам следует использовать потокобезопасный тип (например, Mutex или RwLock).

RefCell будет оставаться «заблокированным» до тех пор, пока полученный вами указатель не выйдет за пределы области видимости, поэтому вы можете захотеть объявить новую область видимости блока (например, {...}) при работе с заимствованным значением или даже явно отбросить заимствованное. цените, когда вы закончите с этим, чтобы избежать неприятных сюрпризов.

Еще одно существенное различие между Cell и RefCell заключается в том, что Cell требует, чтобы внутреннее значение T реализовывало Copy, тогда как RefCell не имеет такого ограничения. Часто вам не нужно копировать семантику для ваших обернутых типов, поэтому вам придется использовать RefCell.

Короче говоря, Cell имеет семантику копирования и предоставляет значения, а RefCell имеет семантику перемещения и предоставляет ссылки.

Почему?

Есть несколько общих случаев, требующих внутренней изменчивости, например:

  1. Введение изменчивости внутри чего-то неизменного
  2. Мутирующие реализации Clone
  3. Детали реализации логически неизменяемых методов.
  4. Преобразование переменных с подсчетом ссылок

Введение изменчивости внутри чего-то неизменного

Возвращаясь к примеру NaiveRc во введении, указатели подсчета ссылок, такие как Rc и Arc, нуждаются во внутренней изменчивости. Когда вы клонируете эти указатели, счетчик ссылок внутри них должен быть обновлен независимо от того, являются ли они изменяемыми или нет. Без внутренней изменчивости вы были бы вынуждены всегда использовать изменяемые указатели, которые допускали бы изменение внутреннего значения и могут быть нежелательными.

Например, рассмотрим следующую наивную оболочку с подсчетом ссылок:

use std::cell::Cell;

struct NaiveRc<'a, T: 'a> {
    inner_value: &'a T,
    references: Cell<usize>
}

let x = NaiveRc { inner_value: &1, references: Cell::new(1) };
x.references.set(2); // it works!
x.inner_value = &2;  // beep boop, x is immutable,
                     // you can't assign a new value to any of its fields!

Мутирующие реализации Clone

Еще во введении мы заметили, что клонирование значения со счетчиком ссылок (Rc) требует увеличения счетчика ссылок. Это просто частный случай предыдущего пункта, но он заслуживает повторения.

С другой стороны, удаление такого значения требует уменьшения счетчика ссылок, но drop работает с изменяемыми ссылками (fn drop (&mut self)), так что здесь нет никаких проблем.

Детали реализации логически неизменяемых методов

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

Мутирующие переменные со счетчиком ссылок

Предположим, нам нужно несколько ссылок на некоторые объекты. Например, при соединении узлов в графе. «О, это просто», - думаете вы. «Я просто заверну свои узлы в Rc или Arc и закончу». Это вполне разумная линия, и она сработает ... если вам никогда не понадобится изменять узлы. Как только вы попытаетесь построить граф, постепенно добавляя и соединяя узлы, компилятор вас огорчит. О нет, что происходит? К сожалению для нас, Rc сохраняет безопасность, предоставляя вам только общие (т. Е. Неизменяемые) ссылки, когда вы вызываете clone. Процитируйте документацию модуля std::rc:

Тип Rc обеспечивает совместное владение неизменным значением. Уничтожение детерминировано и произойдет, как только уйдет последний владелец.

Вы можете вызвать get_mut, чтобы получить Option<&mut T>, но это сработает только один раз: get_mut возвращает только изменяемую ссылку, как если бы есть только одна «сильная» ссылка на значение.

К счастью, здесь можно использовать внутреннюю изменчивость: используйте Rc <Cell> или Rc <RefCell>. Таким образом, вы можете клонировать оболочку с подсчетом ссылок столько, сколько захотите, и при этом изменять самое внутреннее значение, заключенное в оболочку Cell или RefCell.

Вы можете увидеть первую попытку решения в этом примере на Rust Playground. Как видите, проблема решена, но решение многословное и некрасивое. Мало того, пользователь нашего API знает детали реализации! Что дает? Где элегантная абстракция, которую я обещал несколькими абзацами выше?

Теперь, когда вы увидели и поняли, как это работает, я могу показать вам более понятную версию:

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

// A graph can be represented in several ways. For the sake of illustrating how
// interior mutability works in practice, let's go with the simplest
// representation: a list of nodes.
// Each node has an inner value and a list of adjacent nodes it is connected to
// (through a directed edge).
// That list of adjacent nodes cannot be the exclusive owner of those nodes, or
// else each node would have at most one edge to another node and the graph
// couldn't also own these nodes.
// We need to wrap Node with a reference-counted box, such as Rc or Arc. We'll
// go with Rc, because this is a toy example.
// However, Rc<T> and Arc<T> enforce memory safety by only giving out shared
// (i.e., immutable) references to the wrapped object, and we need mutability to
// be able to connect nodes together.
// The solution for this problem is wrapping Node in either Cell or RefCell, to
// restore mutability. We're going to use RefCell because Node<T> doesn't
// implement Copy (we don't want to have independent copies of nodes!).

// Represents a reference to a node.
// This makes the code less repetitive to write and easier to read.
type NodeRef<T> = Rc<RefCell<_Node<T>>>;

// The private representation of a node.
struct _Node<T> {
    inner_value: T,
    adjacent: Vec<NodeRef<T>>,
}

// The public representation of a node, with some syntactic sugar.
struct Node<T>(NodeRef<T>);

impl<T> Node<T> {
    // Creates a new node with no edges.
    fn new(inner: T) -> Node<T> {
        let node = _Node { inner_value: inner, adjacent: vec![] };
        Node(Rc::new(RefCell::new(node)))
    }

    // Adds a directed edge from this node to other node.
    fn add_adjacent(&self, other: &Node<T>) {
        (self.0.borrow_mut()).adjacent.push(other.0.clone());
    }
}

struct Graph<T> {
    nodes: Vec<Node<T>>,
}

impl<T> Graph<T> {
    fn with_nodes(nodes: Vec<Node<T>>) -> Self {
        Graph { nodes: nodes }
    }
}

fn main() {
    // Create some nodes
    let node_1 = Node::new(1);
    let node_2 = Node::new(2);
    let node_3 = Node::new(3);

    // Connect some of the nodes (with directed edges)
    node_1.add_adjacent(&node_2);
    node_1.add_adjacent(&node_3);
    node_2.add_adjacent(&node_1);
    node_3.add_adjacent(&node_1);

    // Add nodes to graph
    let graph = Graph::with_nodes(vec![node_1, node_2, node_3]);

    // Show every node in the graph and list their neighbors
    for node in graph.nodes.iter().map(|n| n.0.borrow()) {
        let value = node.inner_value;
        let neighbours = node.adjacent.iter()
            .map(|n| n.borrow().inner_value)
            .collect::<Vec<_>>();
        println!("node ({}) is connected to: {:?}", value, neighbours);
    }
}

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

Вы можете поиграть с этим примером в Rust Playground. Попробуйте сами изменить что-нибудь! Я считаю, что сломанные вещи помогают мне закрепить новые знания. Я предлагаю:

  1. Замена RefCell на Cell
  2. Удаление RefCell и использование Rc <Node>
  3. Удаление Rc и использование RefCell <Node>

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

Альтернативное решение может включать в себя обертывание вектора соседнего узла в RefCell вместо обертывания самого узла. Это также может работать, в зависимости от того, что вы собираетесь делать, но семантически отличается от предыдущего решения, так как вы не сможете изменить внутреннее значение узла в дополнение к его списку соседних узлов.

Что выбрать?

Если RefCell может взорваться вам прямо в лицо, и его нельзя использовать «в чистом виде» в многопоточной программе, зачем его использовать?

Хотя Cell является хорошим выбором во многих случаях, есть несколько причин, по которым вы можете захотеть использовать RefCell:

  1. Обернутое значение не реализует Copy.
  2. Только RefCell имеет проверки во время выполнения. В некоторых случаях вы предпочтете убить программу, чем рискуете испортить данные.
  3. RefCell предоставляет указатели на сохраненное значение, Cell - нет.

Как правило, выбирайте Cell, если ваше значение в оболочке реализует Copy (например, примитивные значения, такие как целые числа и числа с плавающей запятой). Если обернутое значение является структурой, не реализует Copy или вам нужны динамически проверяемые заимствования, используйте вместо этого RefCell.

Wrapping up

CellRefCell
SemanticsCopyMove
ProvidesValuesReferences
Panics?NeverMixed borrows or more than one mutable borrow
Use withPrimitive typesStructures or non-Copy types

В приведенной выше таблице обобщено то, что вы узнали из этого сообщения в блоге.

Надеюсь, вы нашли эту статью полезной и/или интересной. Как всегда, если вы обнаружили ошибку или у вас есть какие-либо вопросы, напишите мне в Twitter (@meqif) или отправьте мне электронное письмо (words@ricardomartins.cc). Вы также можете присоединиться к обсуждению на Reddit.

Как любезно отметили Стив Клабник, /u/crisiqjo и /u/birkenfield, Mutex и RwLock уже имеют внутреннюю изменчивость, поэтому нет необходимости помещать в них Cell. В многопоточных сценариях следует использовать Mutex и RwLock без дополнительных Cell или RefCell.

/u/krdln предложил альтернативную реализацию графа выше.

  1. Если вы знакомы с C и это напоминает вам константные указатели (значение которых также не может измениться, но содержимое целевого адреса памяти может), вы на правильном пути. y будет чем-то вроде int * const.

  2. Я действительно не хочу сейчас вдаваться в подробности о сильных и слабых ссылках. Достаточно сказать, что сильные ссылки предотвращают уничтожение объектов, а слабые - нет.