В этой короткой статье я расскажу о паттерне в 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. Поделитесь своими соображениями в комментариях.
Автор: freecoder_xx