Rust dyn что это

Обновлено: 19.05.2024

impl dyn Trait

Если вы посмотрите на реализацию итератора, то увидите, что у него есть один required метод (то есть тот, который нужно реализовать нам) и provided методы (те, которые реализованы за нас). Что нам это даёт? А то, что если мы напишем свою реализацию какой-либо коллекции, то чтобы реализовать для нее итератор нам нужно всего-навсего имплементировать next() . Все остальные фп-шные штуки вроде map() , filter() , fold() и т.д. будут реализованы автоматически.

Представим, что вы пишете иерархию классов для игры в жанре RPG. Так вот, вместо того, чтобы в каждом новом классе писать один и тот же код, например, получения урона, можно сделать единый интерфейс, который подставит нужный код там, где вы этого попросите:

TL;DR сделали 2 структуры и реализовали для них трейт Character, в котором 6 required методов и 1 provided метод.

Но внимательный читатель совершенно справедливо спросит: "Так мы ведь всё равно скопировали код?" Верно, из-за того, что в трейтах нельзя объявлять поля, нам пришлось городить required методы для получения нужных данных. Сейчас-то, конечно, их всего 6, но потом у нас могут добавиться передвижения, анимации, левел-апы и проч. Конечно, целый один метод у нас единый на все имплементации, но код-то мы все равно копируем?

Заключение

В заключение хочу сказать, что в расте не помешало бы явное наследование структур. Всё-таки, как ни крути, в других языках оно позволяет писать с одной стороны хорошо читаемый, с другой стороны лаконичный код (если, конечно, это не множественное наследование). Подходы в Rust, конечно, позволяют экономить какое-то количество места, но хотелось бы чего-то более явного. Да и интерфейсы Deref, DerefMut вообще не предназначены для того, для чего мы их использовали в данной статье. Они, как следует из названия, нужны чтобы разыменовывать умные указатели. А если вы объединяете несколько структур данных, то вам придется использовать синтаксис self.1, который далеко не очевидный. В общем, как и всегда, есть и плюсы и минусы.

Вот, собственно, и все, что я хотел сказать на эту тему. Может, я что-то упустил? Напишите в комментариях, если вас тоже волнует эта тема, хочется знать, что не мне одному не хватает наследования в Rust.

Deref<Target=_>

Тут нам на помощь приходит трейт Deref. Честно говоря, не знаю, насколько это хорошая практика использовать его с такой целью, но, например, в image есть такое: ImageBuffer реализует Deref для [P::Subpixel] и, соответственно, имеет все методы массива, которые есть в стандартной библиотеке. Давайте перепишем наш пример под Deref и посмотрим, сколько места нам удалось сэкономить.

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

Но у такого подхода тоже есть минус. Если нам нужны другие данные, помимо "родительских", тогда нам придётся создавать дополнительные структуры, чтобы хранить уже свои, независимые от "родительских", данные. Тогда нам придется писать код вроде:

И, согласитесь, сигнатура этого метода не очень удобочитаемая. А если у нас будет несколько внутренних структур, то остаётся только пожелать здоровья людям, которые будут это ревьюить и/или поддерживать.

Rust — сохраняем безразмерные типы в статической памяти

А почему бы просто не взять какой-нибудь linked_list_allocator от Фила, дать ему пару килобайт памяти и воспользоваться обычным Box типом, или даже взять какой-нибудь простейший bump аллокатор, ведь мы хотим использовать его лишь для того, чтобы создать несколько глобальных объектов, но есть множество сценариев, когда куча не используется принципиально? Это и дополнительная зависимость от целого alloc крейта и дополнительные риски, что использование кучи выйдет за рамки строго детерминированных сценариев, что будет приводить к трудноуловимым ошибкам.

С другой стороны, мы можем просто принимать &'static dyn Trait и таким образом переложить заботу о том, как получить такую ссылку, на конечного пользователя, но чтобы обеспечить потом доступ к этой ссылке, нам необходимо использовать примитивы синхронизации или же воспользоваться unsafe кодом, с другой стороны, конечный пользователь тоже должен воспользоваться ими, чтобы создать такую ссылку. В конечном итоге у нас получается или двойная работа или unsafe в публичном API, что довольно плохо. Да и в целом, Box обладает гораздо более широкой областью применения, например, его можно использовать для организации очереди задач в очередном futures executor.

Что же такое безразмерные типы?

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

Ссылки и указатели в Rust это не всегда просто адреса в памяти, в случае с DST типами, помимо адреса хранится еще и объект с метаданными указателя, но гораздо проще это все осознать, если просто взглянуть на код стандартной библиотеки.

Отсюда видно, что указатель представляет из себя адрес на данные и, в некоторых случаях, какие-то байты с метаданными после. Получается, что размер указателя, в общем случае, может быть вообще любым, вот так, для примера, выглядят метаданные для любого dyn Trait - это просто статическая ссылка на таблицу виртуальных функций.

Таким образом, в текущей реализации, размер &dyn Display на x86_64 составляет 16 байт, а когда мы пишем такой вот код:

Компилятор генерирует объект VTable и сохраняет его где-то в статической памяти, а обычную ссылку заменяет на широкую, содержащую кроме адреса еще и указатель на таблицу виртуальных функций. Ссылка на таблицу виртуальных функций статическая и не зависит от места расположения значения, таким образом, для того, чтобы создать желаемый Box<dyn Display> из искомого значения a , нам необходимо извлечь метаданные из ссылки на dyn_a и все это вместе скопировать в заранее приготовленный для этого буфер. Чтобы все это сделать, нам необходимо использовать nightly features: unsize и ptr_metadata.

Для получения &dyn T из &Value используется специальный маркерный трейт Unsize , который выражает отношение между Sized типом и его безразмерным альтер-эго. То есть, T это Unsize<dyn Trait> в том случае, если T реализует Trait .

Помимо извлечения метаданных, данная функция еще и вычисляет размещение в памяти для метаданных и смещение по которому следует сохранять значение, но об этом мы подробнее еще поговорим.

Обратите внимание, что мы берем Layout от ссылки на метаданные, а не DynMetadata<Dyn>::layout , последний описывает размещение VTable , но нас интересует размещение самого DynMetadata , будьте внимательны!

Пишем свой Box

Вот, теперь у нас есть все необходимое, чтобы написать наш Box , его код довольно простой:

Конструктор, который копирует данные и метаданные в предоставленный буфер, используя довольно удобное API указателя.

А вот и код, который собирает байты назад в &dyn Trait :

Дальше смело можно добавить реализацию Deref и DerefMut , в конце концов, в данном случае это как раз тот самый случай, для которых эти самые типы и были созданы.

Казалось бы, все замечательно, можно использовать библиотеку в боевом коде. Но, постойте, мы же написали unsafe код, как мы вообще можем быть уверены в том, что нигде не нарушили никакие инварианты? К счастью, существует такой проект, как Miri, который интерпретирует промежуточное представление MIR, генерируемое компилятором rustc, используя специальную виртуальную машину. Таким образом, можно находить очень многие ошибки в unsafe коде, подробнее об этом можно почитать в этой статье. Давайте попробуем запустить наши тесты используя Miri.

Ага! Вот и нашлась довольно серьезная проблема, которую мы упустили, и которую нам наша x86 архитектура просто взяла и простила - невыровненный доступ к памяти. Напомню, что процессоры при работе с памятью используют машинные слова, размер которых обычно равен размеру указателя, поэтому компиляторы вставляют в типы, которые не кратны размеру машинного слова, дополнительные байты для выравнивания, тоже самое касается и полей структур. В нашем случае, мы просто подряд пишем байты метаданных и значения в буфер, начиная с какого-то адреса, никак ничего не проверяя, поэтому может возникать ситуация, когда адреса полей становятся не кратными машинному слову.

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

Вот собственно и все, после этого Miri больше не показывает ошибок выравнивания.

Хочу еще сказать несколько слов относительно типа Layout , в нем содержится два поля size , которое содержит размер памяти в байтах, необходимый для размещения объекта, и align - это число (причем всегда степень двойки), которому должен быть кратен указатель на объект данного типа. И таким образом, чтобы починить выравнивание, мы просто вычисляем сколько нам нужно прибавить к адресу начала буфера, чтобы получить адрес кратный align . Дополнительно довольно доступно написано про выравнивание у у Фила.

Заключение

Ура, теперь мы можем писать вот такой вот код!

Итак, мы при помощи unsafe и некоторого количества nightly фич смогли написать тип, позволяющий размещать полиморфные объекты на стеке или в статической памяти без использования кучи, что может быть полезным во многих случаях. Хотя, конечно, каждый раз при получении ссылки на объект приходится дополнительно вычислять адрес метаданных и значения, но мы не можем просто так взять и сохранить эти адреса как поля структуры, в этом случае она станет самоссылающиеся, что довольно неприятно в Rust контексте, это не работает с семантикой перемещения. В целом, если воспользоваться pin API, и сделать нашBox неперемещаемым, то можно будет позволить себе эту оптимизацию, а заодно и обеспечить возможность работать с любыми Future типами.

Хочу еще сказать напоследок, что не стоит бояться писать низкоуровневый unsafe код, но стоит 10 раз подумать над его корректностью и обязательно использовать Miri в CI тестах, он отлавливает довольно много ошибок, а разработка низкоуровневого кода требует очень большой внимательности к деталям всевозможным граничным случаям. В конечном счете, именно знания того, как в реальности реализована та или иная языковая абстракция, позволяет перестать воспринимать её как черную магию. Часто все намного проще и очевиднее, чем кажется, стоит просто копнуть чуть поглубже.

А еще важно иногда выходить за рамки stable Rust, чтобы быть в курсе, куда же язык дальше развивается и тем самым расширять свой кругозор.

Как не копировать код в Rust

Первое правило хорошего тона в программировании (или одно из первых) гласит: "Не копируй код". Используй функции и наследование.


Если с функциями все понятно, то с наследованием посложнее. Вы, наверное, знаете, что в Rust нет прямого наследования, но есть способы добиться чего-то на него похожего. О них я и расскажу.

Использование типаж-объектов, допускающих значения разных типов

Однако иногда мы хотим, чтобы пользователь нашей библиотеки мог расширить набор типов, которые допустимы в конкретной ситуации. Чтобы показать как этого добиться, мы создадим пример инструмента с графическим интерфейсом пользователя (GUI), который просматривает список элементов, вызывает метод draw для каждого из них, чтобы нарисовать его на экране - это обычная техника для инструментов GUI. Мы создадим библиотечный крейт с именем gui , содержащий структуру библиотеки GUI. Этот крейт мог бы включать некоторые готовые типы для использования, такие как Button или TextField . Кроме того, пользователи такого крейта gui захотят создавать свои собственные типы, которые могут быть нарисованы: например, кто-то мог бы добавить тип Image , а кто-то другой добавить тип SelectBox .

Мы не будем реализовывать полноценную библиотеку GUI для этого примера, но покажем, как её части будут подходить друг к другу. На момент написания библиотеки мы не можем знать и определить все типы, которые могут захотеть создать другие программисты. Но мы знаем, что gui должен отслеживать множество значений различных типов и ему нужно вызывать метод draw для каждого из этих значений различного типа. Ему не нужно точно знать, что произойдёт, когда вызывается метод draw , просто у значения будет доступен такой метод для вызова.

Чтобы сделать это на языке с наследованием, можно определить класс с именем Component у которого есть метод с названием draw . Другие классы, такие как Button , Image и SelectBox наследуются от Component и следовательно, наследуют метод draw . Каждый из них может переопределить реализацию метода draw , чтобы определить своё пользовательское поведение, но платформа может обрабатывать все типы, как если бы они были экземплярами Component и вызывать draw у них. Но поскольку в Rust нет наследования, нам нужен другой способ структурировать gui библиотеку, чтобы позволить пользователям расширять её новыми типами.

Определение типажа для общего поведения

Чтобы реализовать поведение, которое мы хотим иметь в gui , мы определим типаж с именем Draw , который будет содержать один метод с названием draw . Затем мы можем определить вектор, который принимает типаж-объект. Типаж-объект указывает как на экземпляр типа, реализующего указанный типаж, так и на внутреннюю таблицу, используемую для поиска методов типажа указанного типа во время выполнения. Мы создаём типаж-объект, указывая что-то вроде указателя, такого как ссылка & или умный указатель Box<T> , затем ключевое слово dyn , а затем определяем соответствующий типаж. (Мы будем говорить о причине того, что типаж-объекты должны использовать указатель в главе 19 раздела "Типы динамического размера и Sized типаж" <!-- --> ). Мы можем использовать типаж-объекты вместо универсального или конкретного типа. Везде, где мы используем типаж-объект, система типов Rust гарантирует во время компиляции, что любое значение используемое в этом контексте будет реализовывать нужный типаж у типаж-объекта. Следовательно, нам не нужно знать все возможные типы во время компиляции.

Мы упоминали, что в Rust мы воздерживаемся от слова «объекты» для структур и перечислений, чтобы отличать их от объектов в других языках. В структуре или перечислении данные в полях структуры и поведение в блоках impl разделены, тогда как в других языках данные и поведение, объединённые в одну концепцию, часто обозначающуюся как объект. Тем не менее, типаж-объекты являются более похожими на объекты на других языках, в том смысле, что они сочетают в себе данные и поведение. Но типаж-объекты отличаются от традиционных объектов тем, что мы нельзя добавлять данные к типаж-объекту. Типаж-объекты обычно не настолько полезны, как объекты в других языках: их конкретная цель - обеспечить абстракцию через обычное поведение.

В листинге 17.3 показано, как определить типаж с именем Draw с помощью одного метода с именем draw :

Листинг 17-3: Определение типажа Draw

Этот синтаксис должен выглядеть знакомым из наших дискуссий о том, как определять типажи в главе 10. Далее следует новый синтаксис: в листинге 17.4 определена структура с именем Screen , которая содержит вектор с именем components . Этот вектор имеет тип Box<dyn Draw> , который и является типаж-объектом; это замена для любого типа внутри Box который реализует типаж Draw .

Листинг 17-4: Определение структуры Screen с полем components , которое является вектором типаж-объектов, которые реализуют типаж Draw

В структуре Screen , мы определим метод run , который будет вызывать метод draw каждого элемента вектора components , как показано в листинге 17-5:

Листинг 17-5: Реализация метода run у структуры Screen , который вызывает метод draw каждого компонента из вектора

Это работает иначе, чем определение структуры, которая использует параметр общего типа с ограничениями типажа. Обобщённый параметр типа может быть заменён только одним конкретным типом, тогда как типаж-объекты позволяют нескольким конкретным типам замещать типаж-объект во время выполнения. Например, мы могли бы определить структуру Screen используя общий тип и ограничение типажа, как показано в листинге 17-6:

Листинг 17-6: Альтернативная реализация структуры Screen и метода run , используя обобщённый тип и ограничения типажа

Это вариант ограничивает нас экземпляром Screen , который имеет список компонентов всех типов Button или всех типов TextField . Если у вас когда-либо будут только однородные коллекции, использование обобщений и ограничений типажа является предпочтительным, поскольку определения будут мономорфизированы во время компиляции для использования с конкретными типами.

С другой стороны, с помощью метода, использующего типаж-объекты, один экземпляр Screen может содержать Vec<T> который содержит Box<Button> , также как и Box<TextField> . Давайте посмотрим как это работает, а затем поговорим о влиянии на производительность во время выполнения.

Реализации типажа

Теперь мы добавим несколько типов, реализующих типаж Draw . Мы объявим тип Button . Опять же, фактическая реализация библиотеки GUI выходит за рамки этой книги, поэтому тело метода draw не будет иметь никакой полезной реализации. Чтобы представить, как может выглядеть такая реализация, структура Button может иметь поля для width , height и label , как показано в листинге 17-7:

Листинг 17-7: Структура Button реализует типаж Draw

Поля width , height и label структуры Button будут отличаться от, например, полей других компонентов вроде типа TextField , которая могла бы иметь те же поля плюс поле placeholder . Каждый из типов, который мы хотим нарисовать на экране будет реализовывать типаж Draw , но будет использовать отличающийся код метода draw для определения как рисовать конкретный тип, также как Button в этом примере (без фактического кода GUI, который выходит за рамки этой главы). Например, тип Button может иметь дополнительный блок impl , содержащий методы, относящиеся к тому, что происходит, когда пользователь нажимает кнопку. Эти варианты методов не будут применяться к таким типам, как TextField .

Если кто-то использующий нашу библиотеку решает реализовать структуру SelectBox , которая имеет width , height и поля options , он реализует также и типаж Draw для типа SelectBox , как показано в листинге 17-8:

Листинг 17-8: Другой крейт использующий gui и реализующий типаж Draw у структуры SelectBox

Пользователь нашей библиотеки теперь может написать свою функцию main для создания экземпляра Screen . К экземпляру Screen он может добавить SelectBox и Button , поместив каждый из них в Box<T> , чтобы он стал типаж-объектом. Затем он может вызвать метод run у экземпляра Screen , который вызовет draw для каждого из компонентов. Листинг 17-9 показывает эту реализацию:

Листинг 17-9: Использование типаж-объектов для хранения значений разных типов, реализующих один и тот же типаж

Когда мы писали библиотеку, мы не знали, что кто-то может добавить тип SelectBox , но наша реализация Screen могла работать с новым типом и рисовать его, потому что SelectBox реализует типаж Draw , что означает, что он реализует метод draw .

Преимущество использования типаж-объектов и системы типов Rust для написания кода, похожего на код с использованием концепции duck typing состоит в том, что нам не нужно во время выполнения проверять реализует ли значение в векторе конкретный метод или беспокоиться о получении ошибок, если значение не реализует метод, мы все равно вызываем метод. Rust не скомпилирует наш код, если значения не реализуют типаж, который нужен типаж-объектам.

Например, код (17-10) демонстрирует, что случится если мы попытаемся добавить String в качестве компонента вектора:

Листинг 17-10: Попытка использования типа, который не реализует типаж для типаж-объекта

Мы получим ошибку, потому что String не реализует типаж Draw :

Эта ошибка даёт понять, что либо мы передаём в компонент Screen что-то, что мы не собирались передавать и мы тогда должны передать другой тип, либо мы должны реализовать типаж Draw у типа String , чтобы Screen мог вызывать draw у него.

Типаж-объекты выполняют динамическую диспетчеризацию (связывание)

Напомним, в разделе «Производительность кода с использованием обобщений» главы 10 обсуждается процесс мономорфизации выполняемый компилятором, когда мы используем ограничения типажей для обобщённых типов: компилятор генерирует конкретные реализации функций и методов для каждого конкретного типа, который мы используем вместо параметра обобщённого типа. Код, полученный в результате мономорфизации, выполняет статическую диспетчеризацию, когда компилятор знает какой метод вы вызываете во время компиляции. Это противоположно подходу динамической диспетчеризации, когда компилятор не может сказать во время компиляции, какой метод вы вызываете. В случаях динамической диспетчеризации компилятор генерирует код, который во время выполнения определяет, какой метод необходимо вызывать.

Когда мы используем типаж-объекты, Rust должен использовать динамическую диспетчеризацию. Компилятор не знает всех типов, которые могут быть использованы с кодом, использующим типаж-объекты, поэтому он не знает, какой метод реализован для какого типа при вызове. Вместо этого во время выполнения Rust использует указатели внутри типаж-объекта, чтобы узнать какой метод вызывать. Когда происходит такой поиск, то возникают затраты времени выполнения, которые не происходят при статической диспетчеризации. Динамическая диспетчеризация также не позволяет компилятору выбрать встраивание кода метода, что в свою очередь предотвращает некоторые оптимизации. Однако мы получили дополнительную гибкость в коде, который мы написали в листинге 17-5 и смогли поддержать в листинге 17-9, так что это является компромиссом.

Безопасность объекта необходима для типаж-объектов

Вы можете превратить только объектно-безопасные типажи в типаж-объекты. Некоторые сложные правила управляют всеми свойствами, которые делают типаж-объект безопасным, но на практике имеют значение только два правила. Типаж является объектно-безопасным, если все методы определённые в нем имеют следующие свойства:

  • Тип возвращаемого значения не является Self .
  • Нет обобщённых параметров типа.

Ключевое слово Self является псевдонимом для типа, для которого мы реализуем типажи или его методы. Типаж-объекты должны быть объектно-безопасными, потому что как только вы использовали типаж-объект Rust больше не знает точный тип, реализующий типаж. Если метод типажа возвращает тип Self , но типаж-объект забывает чем является этот точный тип Self , то у метода нет возможности использовать исходный точный тип. То же самое верно для параметров обобщённого типа, которые заполняются параметрами конкретного типа при использовании типажа: конкретные типы становятся частью типа, который реализует типаж. Когда тип забыт из-за использования типаж-объекта, невозможно узнать какими типами нужно заполнять параметры обобщённого типа.

Примером типажа методы которого не являются объектно-безопасными, является типаж Clone из стандартной библиотеки. Сигнатура для метода clone в типаже Clone выглядит следующим образом:

Тип String реализует типаж Clone и когда мы вызываем метод clone у экземпляра String , мы получаем экземпляр String . Точно так же, если мы вызываем clone для экземпляра Vec<T> то, мы возвращаем экземпляр Vec<T> . Сигнатура clone должна знать, какой тип будет заменять Self , потому что это тип возвращаемого значения.

Компилятор укажет, когда вы пытаетесь сделать что-то, что нарушает правила безопасности объекта в отношении свойств объекта. Например, допустим, мы попытались реализовать структуру Screen в листинге 17-4 для хранения типов, которые реализуют типаж Clone вместо типажа Draw , например:

Мы получим ошибку:

Эта ошибка означает, что таким способом вы не можете использовать этот типаж как типаж-объект. Если вы заинтересованы в более подробной информации о безопасности объектов, см. Rust RFC 255.

Возврат типажа с dyn

Компилятору Rust нужно знать сколько места занимает результат каждой функции. Это обозначает, что все ваши функции имеют конкретный тип результата. В отличие от других языком, если у вас есть типаж, например Animal , то вы не можете написать функцию, которая вернёт Animal , по той причине, что разные реализации этого типажа будут занимать разное количество памяти.

Однако есть простой обходной путь. Вместо не посредственного возврата типажа-объекта, наши функции могут возвращать Box , который содержит некоторую реализацию Animal . box - это просто ссылка на какую-то память в куче. Так как размер ссылки известен статически и компилятор может гарантировать, что она указывает на аллоцированную в куче реализацию, мы можем вернуть типаж из нашей функции!

Rust пытается быть предельно явным, когда он выделяет память в куче. Так что если ваша функция возвращает указатель-на-типаж-в-куче, вы должны дописать к возвращаемому типу ключевое слово dyn , например Box<dyn Animal> .

Читайте также: