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