Замыкание обобщенного типа в Rust
Этот паттерн встречается в исходниках 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>() ", 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); }
}
Запустить
Но все, что она должна захватывать извне — это тип Model
, поэтому ссылку на сам компонент мы вынуждены передавать в эту функцию через аргумент. Обратите внимание, что в компоненте мы сохраняем указатель на функцию, которая создана в методе new
с помощью синтаксиса определения замыкания.
Потому что внутренняя функция в 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.