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

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


  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. malstraem
      27.10.2025 14:57

      Мне, как человеку, у которого есть опыт только с сишным синтаксисом (C++ и преимущественно C#), Раст бьёт по глазам очень сильно.

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

      Хорошо сделанную либу, написанную на Расте, просто невозможно вызвать из, например Шарпа, не написав кучу клея с оверхедом.

      То есть язык, с которым невозможно общаться без С ABI (повышая цену вызова), позиционируется как замена С.


      1. Mingun
        27.10.2025 14:57

        А зачем еще один стабильный ABI, когда есть C ABI? Тут была статья о том, как компилятор оптимизирует вложенные друг в друга перечисления, типа Option<Result<Option<...>>>. Как вы представляете себе это со стабильным ABI, если теги каждого фактического варианта поползут от изменения любого типа?


  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

            Непонятно, откуда это следует. Один раз получите ссылку realSelf и все методы этого типа на ней вызываете, внутри них никаких self.borrow() не потребуется, они-то ведь будут реализованы на типе, а не на Rc-обертке.


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

              Предположим, что у меня есть класс с десятком методов, все они пользуются данными класса, 3 из 10 являются реализацией интерфейса, а остальные - или методами других интерфейсов или методы непосредственно класса. У предлагаемого решения есть три пути реализации:

              1. Пусть три метода принимают self как &[mut] Rc<RefCell<Self>> а остальные как &[mut] Self. В этом случае только три дожны делать borrow[_mut] и делать drop-borrow, вызывая друг друга. При этом они могут свободно вызывать остальные 7, но эти 7 никогда не смогут вызвать эти 3. Звучит не очень удобно.

              2. Пусть все методы принимают self как &[mut] Rc<RefCell<Self>> мучаются с borrow/drop/borrow. Зато тогда не будет проблем с невозможностью вызова некоторых методов из других методов.

              3. Пусть все методы принимают self как &[mut] Self но для трех методов будут написаны отдельные методы-обертки, принимающие self как &[mut] Rc<RefCell<Self>>, делающие borrow[_mut] и перевызывающие обычные методы. Но тогда мы не сможем передавать self наружу, например в виде Weak (это иногда справедливо и для 1 кстати).

              Итого мы имеем или странные на ровном месте ограничения, или тонну рукописной работы по менеджменту времени жизни заимствований, которая очень похожа на ручное управление памятью на максималках или удвоение количества интерфейсных методов и невозможность подписывать свой объект на внешние события через Weak.

              По сравнению с перечисленными проблемами хранение в объекте self-weak не такая большая плата.


    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 выйдет сделать.

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


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

            Этот подход как раз обсуждается двумя комментами выше (он имеет большие проблемы).