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

Мы пока не будем строить полноценный CardDOM — начнём с небольшого упражнения.

Задача

Представим простую иерархию типов: кусочек модели документа, которая состоит всего из трех типов.

  • базовый тип DomNode,

  • производный интерфейс CardItem,

  • один конкретный класс TextItem, который расширяет CardItem и DomNode.

Наша задача проста:

  • Создать указатель на DomNode,

  • который на самом деле ссылается на TextItem,

  • затем привести его вниз к CardItem и вызвать какой-нибудь метод.

Зачем такое может понадобиться?

У нас в приложении есть множество разнотипных классов. И есть инфраструктурные механизмы - копирования логирования, сериализации и т.д., которые будут хранить эти разнотипные объекты в коллекциях, вида map(оригинал->копия) или vector(id->объект) или set(visited). И ссылки в этих коллекциях будут иметь какой-то универсальный тип, например (С++) weak_ptr<DomNode>. Очень часто элементы из этих коллекций нужно возвращать в поля объектов, при этом поля могут быть указателями на конкретные типы (sized в терминологии Rust, и тогда можно применить Any) но гораздо чаще они - полиморфные dyn Traits. Сегодняшний пример исследует именно этот сценарий приведения типов. Берем указатель на базовый тип, приводим его к производному трейту (с проверкой, конечно) и вызываем метод этого трейта.


Версия на Argentum

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

interface DomNode {}
interface CardItem {
    +DomNode;
    echo();
}
class TextItem {
    +CardItem {
        echo() { sys_log("Hello from Text") }
    }
}
v = TextItem~DomNode;
v~CardItem?_.echo();

Давайте разберём, что здесь происходит (Ссылка на Playground):

Мы определяем два интерфейса — DomNode и CardItem, где CardItem расширяет DomNode.
Затем определяем конкретный класс TextItem, реализующий CardItem, и добавляем ему метод echo(), который логгирует строчку.

Далее мы создаем экземпляр TextItem, приводим его (upcast) к DomNode и сохраняем в переменной v

А затем пробуем привести (downcast) к CardItem. Оператор ? проверяет результат нисходящего приведения (распаковывает optional) и если всё успешно — вызывает echo().

И всё. 12 простых строк кода. Без синтаксического мусора и танцев с бубном.


Версия на Rust

Теперь попробуем то же самое в Rust (Ссылка на Playground)

Чтобы добиться того же результата, нам понадобятся Rc и RefCell, плюс немного акробатики:

use std::cell::{RefCell};
use std::rc::{Rc, Weak};

trait DomNode {
    fn as_card_item(&self) -> Option<Rc<RefCell<dyn CardItem>>> {  // 2
        None
    }
}

trait CardItem: DomNode {
    fn echo(&self);
}

struct TextItem {
    me: Weak<RefCell<Self>>,  // 4
}

impl TextItem {
    fn new() -> Rc<RefCell<Self>> {
        let node = Rc::new(RefCell::new(Self { me: Weak::new() }));  // 5
        node.borrow_mut().me = Rc::downgrade(&node);
        node
    }
}

impl DomNode for TextItem {
    fn as_card_item(&self) -> Option<Rc<RefCell<dyn CardItem>>> {  // 3
        self.me
            .upgrade()
            .map(|rc| rc as Rc<RefCell<dyn CardItem>>)  // 6
    }
}

impl CardItem for TextItem {
    fn echo(&self) {
        print!("Hello from Text")
    }
}

fn main() {
    let text_as_dom_node = TextItem::new() as Rc<RefCell<dyn DomNode>>;  // 1
    if let Some(text_as_card_item) = text_as_dom_node.borrow().as_card_item() {  // 7
        text_as_card_item.borrow().echo();
    }
}

Выглядит немного избыточно? Давайте разберёмся, почему так.

Rc и RefCell используются для создания разделяемых и изменяемых объектов. Это не встроено в язык по умолчанию, поэтому подключаем из стандартной библиотеки.

Мы определяем трейты (интерфейсы) DomNode и CardItem.

Как и в Argentum, мы приводим возвращаемое значение Rc<RefCell<Self>> к типу Rc<RefCell<dyn DomNode>> [1].
Ключевое слово dyn делает этот указатель fat: он состоит из двух указателей — один на саму структуру данных, другой на таблицу методов интерфейса. Это и есть механизм полиморфизма в Rust.

CardItem является подтипом DomNode. Поэтому upcast-преобразование [1] работает. Но Rust не умеет делать обратное — downcast, то есть приводить указатель к базовому трейту. Эту функциональность нужно реализовать вручную.

Добавим в DomNode новый метод as_card_item [2]. Он возвращает опциональное значение Option<Rc<RefCell<dyn CardItem>>>. Для всех DOM-узлов по умолчанию он возвращает Option::None, а для тех, которые действительно можно привести к CardItem, мы реализуем собственную логику преобразования [3]. Эту реализацию нужно повторить во всех конкретных типах, реализующих интерфейс CardItem.

Возникает вопрос: как вернуть Rc<RefCell<Self>>, если у нас есть только ссылка на внутреннюю структуру, которая вложена в несколько оберток? Простого способа нет. Нужно хранить в каждом объекте ссылку на внешний контейнер Rc<RefCell<Self>>. Чтобы избежать утечек памяти, эта ссылка должна быть Weak.

Так что добавим в TextItem поле [4] (и аналогично — в каждый тип, реализующий CardItem). Это будет слабый указатель на RefCell<Self>. Так как поле объявлено, его нужно инициализировать пр�� создании объекта. Сделать это сразу нельзя — структура еще не обернута в Rc и RefCell, а слабая ссылка должна указывать именно на этот Rc. Поэтому сначала мы инициализируем поле пустым Weak [5], затем сохраняем созданный узел в переменную, берём его через borrow_mut() и присваиваем полю ссылку на внешний Rc через Rc::downgrade. После этого возвращаем этот узел как результат new().

Теперь, когда поле объявлено и инициализировано, можно завершить операцию приведения типов. Мы берём слабый указатель [6], пытаемся восстановить его в Rc (через upgrade()), проверяем, не равен ли он None, и если всё хорошо — приводим его к интерфейсу CardItem.

Так выполняется downcast из одного интерфейса в другой: сначала через виртуальный вызов получаем Rc<RefCell<Self>>, а потом снова upcast к нужному интерфейсу.

В функции main мы берем указатель на интерфейс DomNode. Вызываем метод as_card_item [7], который мы только что добавили; он возвращает Option<T>, как и в Argentum. Поэтому нужно проверить, не пуста ли ссылка, и вызвать метод только если есть значение. В отличие от Argentum, где есть оператор ?, — в Rust это требует более громоздкого синтаксиса: нужно сделать ...map(lambda) или матчинг if let Some(value) = ...,

После всего сделанного downcast заработал, программа компилируется и не падает.


Субъективные ощущения

Rust вызывает смешанные чувства. Его авторы, кажется, придумали его не для решения практических задач, а чтобы опробовать несколько абстрактно-научных концепций. Он не выглядит ориентированным на практические нужды программистов, поэтому в большинстве случаев код получается многократно длиннее и сложнее для написания и чтения. Программа выглядит как мешанина из вложенных  Rc, RefCell, Weak, upgrade, borrow, drop, unwrap, map, match, as_deref_mut.

Чтобы сделать тривиальное приведение типов, приходится вручную:

  • добавлять дополнительные поля,

  • реализовывать методы преобразования,

  • дублировать логику интерфейсов в каждом конкретном классе.

И всё это — ради того, что в других безопасных языках происходит автоматически.


Заключение

Rust (его safe-подмножество) действительно дает безопасность памяти (если считать панику безопасностью), но ценой многословности и перегруженности. Вы тратите больше времени на борьбу с компилятором, чем на выражение своих идей. Давайте кратко перечислим категории, которые нужно держать в голове, и явно и обслуживать в коде раз за разом: (Владение и заимствование) × (отдельно для указателей и для указуемых контейнеров) × (отдельно для структур и их Cell-фрагментов) × (отдельно в mutable и immutable форме) +  времена жизни этих владений и заимствований. Некоторые вещи проверяются компилятором, для всего остального вставляются рантайм-проверки. Причем рантайм автоматически ведет учет этих владений и заимствований, но не для того, чтобы помочь программисту, а для того, чтобы убить приложение паникой, если посчитает, что программист ошибся.

Может быть для полиморфных структур данных Раст предлагает другой метод приведения типов? Поделитесь в комментариях, если вы знаете каст попроще. Он нужен для реализации Card DOM.