В этой короткой статье я расскажу о паттерне в Rust, который позволяет "сохранять" для последующего использования тип, переданный через обобщенный метод. Этот паттерн встречается в исходниках Rust-библиотек и я тоже иногда его использую в своих проектах. Мне не удалось найти в сети публикаций о нем, поэтому я дал ему свое название: "Замыкание обобщенного типа", и в этой статье хочу рассказать, что он из себя представляет, зачем и как его можно использовать.
Проблема
В Rust развитая статическая система типов и ее статических возможностей достаточно для, наверное, 80% случаев использования. Но бывает, что необходима динамическая типизация, когда требуется хранить объекты разных типов в одном и том же месте. Тут на помощь приходят типажи-объекты: они стирают реальные типы объектов, сводят их к некоему общему интерфейсу, заданному типажом, и дальше можно оперировать этими объектами уже как однотипными типажами-объектами.
Это хорошо работает еще в половине случаев из оставшихся. Но как быть, если нам все-таки нужно восстанавливать стертые типы объектов при их использовании? Например, если поведение наших объектов задается таким типажом, который не может использоваться в качестве типажа-объекта. Это — обычная ситуация для типажей с ассоциированными типами. Как быть в таком случае?
Решение
Для всех 'static
-типов (то есть типов, не содержащих не статических ссылок) в Rust реализуется типаж Any
, который позволяет осуществлять преобразование типажа-объекта dyn Any
к ссылке на исходный тип объекта:
let value = "test".to_string();
let value_any = &value as &dyn Any;
// Пытаемся привести наше значение к типу String. Если
// не получилось - значит наше значение имеет другой тип.
if let Some(as_string) = value_any.downcast_ref::<String>() {
println!("String: {}", as_string);
} else {
println!("Unknown type");
}
Также у Box
для этих целей имеется метод downcast
.
Такое решение подходит для тех случаев, когда исходный тип известен в месте работы с ним. Но что делать, если это не так? Что делать, если вызывающий код просто не знает об исходном типе объекта в месте его использования? Тогда нам нужно как-то запомнить исходный тип, взять его там, где он определен, и сохранить наряду с типажом-объектом dyn Any
, чтобы потом последний привести к исходному типу в нужном месте.
К обобщенным типам в Rust можно относиться как к переменным типа, в которые можно передавать те или иные значения типа при вызове. Но в Rust нет способа запомнить такой тип для дальнейшего его использования в другом месте. Тем не менее, есть способ запомнить весь функционал, использующий данный тип, вместе с этим типом. В этом и заключается идея паттерна "Замыкание обобщенного типа": код, использующий тип, оформляется в виде замыкания, которое сохраняется как обычная функция, потому что оно не использует никаких объектов окружения, кроме обобщенных типов.
Реализация
Давайте рассмотрим пример реализации. Пусть мы хотим сделать рекурсивное дерево, представляющее иерархию графических объектов, в котором каждый узел может быть либо графическим примитивом с дочерними узлами, либо компонетом — отдельным деревом графических объектов:
enum Node {
Prim(Primitive),
Comp(Component),
}
struct Primitive {
shape: Shape,
children: Vec<Node>,
}
struct Component {
node: Box<Node>,
}
enum Shape {
Rectangle,
Circle,
}
Упаковка Node
в структуре Component
необходима, так как сама структура Component
используется в Node
.
Теперь предположим, что наше дерево — это только представление некоторой модели, с которой оно должно быть связано. Причем у каждого компонента будет своя модель:
struct Primitive<Model> {
shape: Shape,
children: Vec<Node<Model>>,
}
struct Component<Model> {
node: Box<Node<Model>>,
model: Model, // Компонент содержит Model
}
Мы могли бы написать:
enum Node<Model> {
Prim(Primitive<Model>),
Comp(Component<Model>),
}
Но этот код не будет работать так, как нам нужно. Потому что компонент должен иметь свою собственную модель, а не модель родительского элемента, который содержит в себе компонент. То есть, нам нужно:
enum Node<Model> {
Prim(Primitive<Model>),
Comp(Component),
}
struct Primitive<Model> {
shape: Shape,
children: Vec<Node<Model>>,
_model: PhantomData<Model>, // Имитируем использование Model
}
struct Component {
node: Box<dyn Any>,
model: Box<dyn Any>,
}
impl Component {
fn new<Model: 'static>(node: Node<Model>, model: Model) -> Self {
Self {
node: Box::new(node),
model: Box::new(model),
}
}
}
Мы переместили указание конкретного типа модели в метод new
, а в самом компоненте храним модель и поддерево уже со стертыми типами.
Теперь добавим метод use_model
, который будет использовать модель, но не будет параметризован ее типом:
struct Component {
node: Box<dyn Any>,
model: Box<dyn Any>,
use_model_closure: fn(&Component),
}
impl Component {
fn new<Model: 'static>(node: Node<Model>, model: Model) -> Self {
let use_model_closure = |comp: &Component| {
comp.model.downcast_ref::<Model>().unwrap();
};
Self {
node: Box::new(node),
model: Box::new(model),
use_model_closure,
}
}
fn use_model(&self) {
(self.use_model_closure)(self);
}
}
Обратите внимание, что в компоненте мы сохраняем указатель на функцию, которая создана в методе new
с помощью синтаксиса определения замыкания. Но все, что она должна захватывать извне — это тип Model
, поэтому ссылку на сам компонент мы вынуждены передавать в эту функцию через аргумент.
Кажется, что вместо замыкания мы можем использовать внутреннюю функцию, но такой код не скомпилируется. Потому что внутренняя функция в Rust не может захватывать обобщенные типы из внешней в силу того, что от обычной функции верхнего уровня она отличается только видимостью.
Теперь метод use_model
можно использовать в контексте, где реальный тип Model
неизвестен. Например, при рекурсивном обходе дерева, состоящем из множества различных компонентов с разными моделями.
Альтернатива
Если есть возможность вынести интерфейс компонента в типаж, допускающий создание типажа-объекта, то лучше так и поступить, и вместо самого компонента оперировать его типажом-объектом:
enum Node<Model> {
Prim(Primitive<Model>),
Comp(Box<dyn ComponentApi>),
}
struct Component<Model> {
node: Node<Model>,
model: Model,
}
impl<Model> Component<Model> {
fn new(node: Node<Model>, model: Model) -> Self {
Self {
node,
model,
}
}
}
trait ComponentApi {
fn use_model(&self);
}
impl<Model> ComponentApi for Component<Model> {
fn use_model(&self) {
&self.model;
}
}
Заключение
Оказывается, замыкания в Rust могут захватывать не только объекты окружения, но и типы. При этом их можно интерпретировать как обычные функции. Это свойство становится полезным, когда требуется единообразно работать с различными типами не теряя о них информации, если типажи-объекты при этом не применимы.
Надеюсь, эта статья поможет вам в использовании Rust. Поделитесь своими соображениями в комментариях.
Комментарии (19)
Amomum
03.06.2019 12:51Возможно, мысль несколько мимо темы, но как было бы здорово, если бы типы тоже были first class citizens, как функции; т.е. чтобы была встроенная в язык возможность хранить тип в рантайме.
Даже не знаю, есть ли языки, где так можно делать?
Мне кажется, что довольно вещей можно было бы делать проще и чище.
Dima_Sharihin
03.06.2019 16:36Возможность хранить тип в рантайме — не нужна в 90% случаев, в остальных случаях применяется что-то вроде C++ного std::variant или C++ного-же RTTI (который я обычно первым делом отключаю)
bm13kk
03.06.2019 16:48А можно пример когда надо хранить тип в рантайме?
И второй сразу вопрос. В расте есть енам. Судя по тексту подразумевается, что енам не подходит.Amomum
03.06.2019 17:00Процитирую проблему из статьи:
Такое решение подходит для тех случаев, когда исходный тип известен в месте работы с ним. Но что делать, если это не так? Что делать, если вызывающий код просто не знает об исходном типе объекта в месте его использования? Тогда нам нужно как-то запомнить исходный тип, взять его там, где он определен, и сохранить наряду с типажом-объектом dyn Any, чтобы потом последний привести к исходному типу в нужном месте.
Если было можно просто сохранить исходный тип объекта как обычное поле в самом объекте и потом скастовать к нему, то не потребовалось ничего изобретать. Если я правильно понял статью, конечно.
Енам, насколько я понимаю, не подходит, потому что нужно будет заранее знать все возможные исходные типы, чтобы их потом явно перебрать.
Представьте, что можно делать как-то так (прошу прощения для псевдо-С++):
class A { Type realType; }; class B : A { } B b; A * a = &b; a.realType = typeof(b); auto p = a as a.realType; // <<- auto выводит тип В
bm13kk
04.06.2019 11:25Простите, это не пример а описание. Хотя и тут можно сказать что если есть проблемы с дизайном, когда нужен даункастинг.
У Вас лично в практике была такая надобность? Можете описать?Amomum
04.06.2019 12:21Я имел в виду, что вся статья вроде как решает именно эту проблему, просто окольными путями.
Хотя и тут можно сказать что если есть проблемы с дизайном, когда нужен даункастинг.
Это само собой, я не пытался продумать все нюансы, просто хотелось показать, как бы я хотел это видеть.
У Вас лично в практике была такая надобность? Можете описать?
Сходу что-то конкретное не могу вспомнить, но периодически натыкаюсь на необходимость "прыгать через обручи" — делать кучу специализаций шаблонов, потому что нельзя просто написать switch по типу (я понимаю, что это не рантайм, но синтаксис мог бы быть простым) или делать type-tag из enum'a руками, а потом свитчится по нему.
Я понимаю, что это не очень часто нужно и, наверное, затраты на разработку такой фичи того не стоят. Но каждый раз, когда приходится изобретать обходные пути, становится грустно.
Вот рефлексия, допустим, тоже не слишком-то часто нужна, но без нее тоже грустно (имхо это достаточно близкие вещи).
freecoder_xx Автор
04.06.2019 13:51Дело в том, что типы (по крайней мере в Rust) — это объекты времени компиляции. Тут нужен способ некоего "структурного запоминания", если таковой возможен.
Amomum
04.06.2019 14:01Да, это понятно. Я просто мечтал о том, как могло бы быть.
Помнится, натыкался на пост о проблемах в D, где описания типов могут занимать дофигища места из-за декорирования имен. Во, нашел.
Create a chain of 12 calls to square above and the symbol length increases to 207,114. Even worse, the resulting object file for COFF/64-bit is larger than 15 MB and the time to compile increases from 0.1 seconds to about 1 minute. Most of that time is spent generating code for functions only used at compile time.
Я понимаю, что очень плохо представляю насколько все это сложно и не претендую на экспертное мнение. Просто вздыхаю.
freecoder_xx Автор
03.06.2019 20:39В Rust по идее можно развить функционал ассоциированных типов для достижения нечто подобного. Собственно, первый вариант "замыкания" пытался их использовать, но ограничения системы типов текущего Rust не позволили.
Amomum
03.06.2019 21:51К сожалению, я плохо понимаю теорию языков программирования, поэтому не знаю, почему нельзя просто сделать тип Type или вроде того.
Вроде бы в Питоне так можно. И в Руби вроде есть класс Class. Хм. Может, к этому больше тяготеют языки с динамической типизацией, потому что там люди вынуждены тип в рантайме проверять.
ZyXI
04.06.2019 03:11К этому тяготеют языки с динамической типизацией, потому что там разработчики языка вынуждены таскать вместе с объектом ссылки на его функции в том или ином виде из?за этой самой динамической типизации. Т.к. наличия таких ссылок при динамической типизации не избежать, то нет особого смысла скрывать их от пользователя.
В языках со статической типизацией компилятор обычно знает, какие именно функции будут вызваны в любом месте программы (или хотя бы знает, что здесь происходит вызов функции, адрес которой в runtime можно найти вот в этом месте памяти), поэтому ему совершенно не нужно тащить в runtime набор ссылок на функции, относящиеся к данному типу. Т.к. набор ссылок на функции для работы программы не нужен, а вот выкидывать неиспользованные функции как раз?таки часто нужно во имя эффективности, то в языках со статической типизацией никто никаких
Type
не делает. Это не то, что совсем невозможно, просто противоречит концепции использования языка и очень сложно технически (особенно если там есть generic’и).Amomum
04.06.2019 03:14Просто грустно, что когда это все-таки нужно, приходится извращаться. Насколько это технически сложно — судить не берусь, но так обидно, что тип вроде вот он на этапе компиляции, руку протяни только. И пропадает.
mvlabat
Спасибо за статью, очень познавательно!
У меня появилось 2 вопроса.
'static
-типы — здесь идет речь про lifetime, или же это какое-то отдельное понятие? Значит ли это ограничение, что все дерево должно быть определено уже на этапе компиляции?Component
. Или с данным подходом можно будет менять ссылку с одного замыкания на другое? Меня настораживает, что если я захочу усложнить логику приложения, расширяя список функций, которыми я смогу взаимодействовать с моделью, то мне придется каждый раз менять объявление структуры компонента, добавляя в нее новые поля для замыканий.bm13kk
по 1
Данные есть принципиально двух типов — известной длины до компиляции и неизвестной. Первые, и есть статичные, лежат в стеке. Примеры инты, строки фиксированной длины, енамы без значений. Вторые лежат в куче.
Хотя могу ошибаться.
mvlabat
Все же думаю, что статика никакой роли на выделение переменных на стеке не влияет. Насколько я понимаю,
'static
в расте означает то, что не только размер, но еще и расположение значения в памяти будет уже известно на этапе компиляции. В куче можно разместить что-либо, лишь положив это вBox
,Rc
либо какой-то другой смарт-указатель. Меня просто немного сбила с толку формулировка "static
-типы", но вижу, что и в коде используется'static
в качетсве lifetime bound, что как бы намекает… Последний уточняющий вопрос: значит ли данное ограничение, что составить дерево из произвольного количества компонент на основе, например, пользовательского input'а с таким подходом будет невозможно?Кстати, енамы со значениями так же будут иметь известный размер, так как по факту это обычные union-ы, размер которых принимает наибольшее значение размера его варианта (+ тег).
freecoder_xx Автор
На первые вопросы ответил ниже
Нет, не значит )
mvlabat
Спасибо большое за ответы. Вроде бы все прояснилось теперь :)
Понял еще, что несколько путал эффекты от статических переменных (
static
),'static
-типов и'static
в качестве времени жизни — это создавало больше всего конфуза в понимании кодаfreecoder_xx Автор
Ну,
'static
-типы — это такие типы, определение которых не содержит ссылок или содержит только статические ссылки (с лайфтаймом'static
). Да, это касается только лайфтаймов, никаких ограничений на задание самих значений полей нет.В простейшем случае добавляется по одному полю-указателю на каждый метод, который должен оперировать "захваченным" типом. Но можно сделать и по-другому, например, так:
Запустить
freecoder_xx Автор
Вариант с вектором методов:
Запустить
Можно даже кастить функциональные указатели напрямую. Они одного размера, так что по идее с этим не должно быть проблем, хотя я на 100% не уверен в безопасности такого кода.