В последнее время я часто слышу, что 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.

Комментарии (14)


  1. rsashka
    27.10.2025 14:57

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

    Сейчас набегут любители Rust и зададут вам по первое число, что мало не покажется, не смотря на то, что вы совершенно правы. ;-)


    1. smt_one
      27.10.2025 14:57

      Не то чтобы я люблю Rust, но это слегка выглядит как попытка пиара своего решения в виде Argentum. Так что наоборот жду любителей Rust, тогда может в дискуссии с ними выяснится что.


      1. rsashka
        27.10.2025 14:57

        Это безусловно пиар своего языка Argentum, в котором автор немного перемудрил со ссылочными типами данных


      1. kotan-11 Автор
        27.10.2025 14:57

        Я не могу пиарить Аргентум, он еще не готов. Я просто оцениваю другие решения, сравнивая со своим. И если где-то что-то будет лучше, это будет украдено я буду только рад.


        1. rsashka
          27.10.2025 14:57

          Я не могу пиарить Аргентум...

          Ну и зря. Как по мне, это наоборот нужно делать заранее, еще до выхода , чтобы было время переделать, если в базовой идее обнаружатся какие либо противоречия. Тогда в этом случае можно будет переделать и подправить не ломаю обратную совместимость (как это иногда происходило в Python или Rust). Ведь решение еще не выпущено :-)


  1. Mingun
    27.10.2025 14:57

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

    Не совсем понятно, зачем тут Rc и RefCell? Чем обычный &dyn CardItem не угодил? Даже гугловский ИИ в поиске уже дает готовое решение:

    use std::cell::RefCell;
    use std::rc::Rc;
    
    trait DomNode {
      fn as_card_item(&self) -> Option<&dyn CardItem> {
        None
      }
    }
    
    trait CardItem {// не обязательно даже требовать : DomNode
        fn echo(&self);
    }
    
    struct TextItem;
    
    impl DomNode for TextItem {
        fn as_card_item(&self) -> Option<&dyn CardItem> {
            Some(self)
        }
    }
    
    impl CardItem for TextItem {
        fn echo(&self) {
            println!("Hello from Text")
        }
    }
    
    #[test]
    fn test() {
        {
            let text: Box<dyn DomNode> = Box::new(TextItem);
            if let Some(card) = text.as_card_item() {
                card.echo();
            }
        }
    
        {
            let text: Rc<dyn DomNode> = Rc::new(TextItem);
            if let Some(card) = text.as_card_item() {
                card.echo();
            }
        }
    
        let text: Rc<RefCell<dyn DomNode>> = Rc::new(RefCell::new(TextItem));
        if let Some(card) = text.borrow().as_card_item() {
            card.echo();
        }
        // Вывод перед падением теста:
        // Hello from Text
        // Hello from Text
        // Hello from Text
        assert!(false);
    }
    

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


    1. kotan-11 Автор
      27.10.2025 14:57

      Я это объяснил в параграфе "Зачем такое может понадобиться?"


      1. Mingun
        27.10.2025 14:57

        Ну допустим, чтобы можно было взять из коллекции умный указатель, копирнуть указатель со сменой типа и новый указатель кому-то отдать. При этом хранить в коллекциях вы же собрались Rc<RefCell<dyn DomNode>>. Если и в поле вы затем решите сохранить Rc<RefCell<dyn CardItem>>, то как я ниже написал, зачем вам тогда реализовывать типаж над TextItem? Реализуйте ср��зу над Rc<RefCell<TextItem>>. Вы фактически это и делаете, только зачем-то как-то через задницу.

        Еще отмечу, что вы фактически реализуете C++-ный вариант наследования от enable_shared_from_this, но так как в Rust-е нет наследования, а только агрегация, то пришлось всю машинерию писать руками (хотя и тут ее можно было бы упрятать за макрос). Тут вы копируете свой опыт с C++, так как там по другому не сделать (точнее можно, но кажется многословнее и в сущности мало чем отличается от наследования от enable_shared_from_this -- если упрятать shared_ptr внутрь своей структуры и интерфейс реализовать для нее. Вообще это pimpl идиома получается).


        1. kotan-11 Автор
          27.10.2025 14:57

          Если реализовать трейт сразу над Rc<RefCell<TextItem>> то я не смогу хранить объект ни в чем другом, потому что тогда и все прочие реализации трейтов и собственных методов должны будут получать self не как ссылку на структуру, а как ссылку на Rc-обертку, чтобы их можно было вызывать друг из друга.

          Кроме того каждый метод будет начинаться с let realSelf = self.borrow() а мутабельный метод - с let mut inner = self.borrow_mut(); а любой вызов другого метода будет превращаться в последовательность drop-call-borrow_mut. Это выглядит как ручное управление памятью.


    1. Mingun
      27.10.2025 14:57

      Если прям так нужен Rc<dyn CardItem> и вы методы кастования только на Rc<TextItem> собрались вызывать -- так и реализуйте типаж на Rc<TextItem>, а не на TextItem:

      trait RcDomNode {
          fn as_card_item(&self) -> Option<Rc<dyn CardItem>> {
              None
          }
      }
      impl RcDomNode for Rc<TextItem> {
          fn as_card_item(&self) -> Option<Rc<dyn CardItem>> {
              Some(self.clone())
          }
      }
      


      1. kotan-11 Автор
        27.10.2025 14:57

        "Очень часто элементы из этих коллекций нужно возвращать в поля объектов..." Объекты приложения должны быть мутабельными, и еще таргетами для Weak. Буду рад, если укажете как это сделать без Rc<RefCell<T>>.


        1. cpud47
          27.10.2025 14:57

          Ну так сделайте аналогичный даункаст с Rc<RefCell<T>>. Суть в том, что если Вам нужен доступ к специфичному контейнеру, то просто реализуйте интерфейс для него.

          В целом можно более аккуратно покрутить трейты, чтобы пересобрать толстый указатель с другой метадатой. Хотя не уверен, что это без CoerceUnsize выйдет сделать.

          Но в расте в целом не очень приветствуют даункасты, поэтому инфраструктура под них плохо развита