Полиморфизм в Rust

Перевод | Автор оригинала: Matt Oswalt

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

Полиморфный выбор Rust

Если вы читаете этот пост, то почти наверняка слышали о термине «полиморфизм». По-моему, полиморфизм дает нам возможность представить единый интерфейс для потенциально многих различных конкретных типов. Это позволяет нам создавать более гибкие API-интерфейсы, которые дают больше контроля потребителю и проще в обслуживании.

Использование полиморфизма дает несколько практических преимуществ, но одно из самых больших - это повторное использование кода. Если вы разрабатываете API на основе определенных конкретных типов, то вы, как и ваши потребители, привержены этому подходу.

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

Вместо этого полиморфизм позволяет нам создавать функции, которые принимают любой тип, если эти типы демонстрируют определенное поведение или свойства, которые нам нужны. В этом примере мы можем принять любой конкретный тип, если они реализуют метод Growl().

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

Это общая идея, почему мы хотели бы использовать полиморфизм на любом языке, но какие варианты полиморфизма существуют в Rust? Есть два основных способа, и оба из них требуют компромиссов, когда речь идет о производительности, а также о размере двоичного файла:

  1. Статическая диспетчеризация - этот подход использует универсальные шаблоны (также называемые параметрическим полиморфизмом) и (обычно) границы трэйтов, чтобы обеспечить необходимую гибкость, сохраняя при этом полную безопасность типов и не требуя большого количества повторяющегося кода. Этот подход чрезвычайно производительный (в Rust это известно как «абстракция с нулевой стоимостью»), однако из-за мономорфизации это создает больший двоичный размер.

  2. Динамическая диспетчеризация - этот подход использует «характерные объекты» для принятия решения о том, какой тип требуется для удовлетворения некоторого вида полиморфного интерфейса для среды выполнения. Это сокращает двоичный размер (поскольку здесь не используется мономорфизация), но снижает производительность из-за дополнительного поиска во время выполнения. Этот подход также явно запрещает использование дженериков.

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

Что лично для меня более интересно, так это то, что Rust - это первый язык, который я использовал, который дает такой выбор. Языки, в которых нет универсальных шаблонов, вынуждают разработчика либо написать кучу дублированного кода, чтобы получить преимущества «статического» подхода, либо снизить производительность, вызванную «динамическим» подходом. Я знаю, что есть другие языки, которые предоставляют такой выбор (C++ - один из них), но способ, которым это делается в Rust, действительно хорош.

Именно этот выбор я хочу изучить более глубоко.

РЕДАКТИРОВАТЬ: очень полезный редактор указал на несколько вещей, о которых следует помнить при чтении этого сообщения в блоге. Примеры для подражания были сделаны путем отключения встраивания и сборки в режиме «отладки», чтобы немного облегчить процесс обучения, но я не очень хорошо выделил этот вариант. Сборка в режиме выпуска и возможность встраивания Rust там, где это необходимо, вероятно, значительно изменит итоговую программу.

Трэйта «Growler» и ее реализации

Для этого поста я создал трейт под названием Growler, который обеспечивает реализацию единственного метода Growl(), не имеющего параметров или возвращаемых типов. Я также создал три типа Льва, Тигра и Медведя, которые имеют свои собственные реализации этой трэйты. Мы будем использовать эти три типа, чтобы продемонстрировать разницу между статической и динамической отправкой:

trait Growler {
    fn growl(&self);
}

struct Lion;
impl Growler for Lion {
    #[inline(never)]
    fn growl(&self) {
        println!("Lion says GROWL!");
    }
}
struct Tiger;
impl Growler for Tiger {
    #[inline(never)]
    fn growl(&self) {
        println!("Tiger says GROWL!");
    }
}
struct Bear;
impl Growler for Bear {
    #[inline(never)]
    fn growl(&self) {
        println!("Bear says GROWL!");
    }
}

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

Статическая отправка

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

fn static_dispatch<T: Growler>(t: T) {
    t.growl();
}

Как мы узнали ранее, определяя трэйту Growler как привязку к универсальному параметру T, любой передаваемый тип должен реализовывать эту трэйту. Итак, в нашей функции main() мы можем передать наши три конкретных типа, которые, как мы знаем, реализуют эту трэйту:

pub fn main() {
    static_dispatch(Lion{});
    static_dispatch(Tiger{});
    static_dispatch(Bear{});
}

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

Признаки мономорфизации можно сразу увидеть, используя objdump для просмотра инструкций в нашей скомпилированной программе на Rust:

objdump --disassemble=polymorphism::main -S -C target/debug/polymorphism -EL -M intel --insn-width=8

target/debug/polymorphism:     file format elf64-x86-64

0000000000005510 <polymorphism::main>:
    5510:       48 83 ec 18                     sub    rsp,0x18
    5514:       e8 47 fe ff ff                  call   5360 <polymorphism::static_dispatch>
    5519:       e8 12 fe ff ff                  call   5330 <polymorphism::static_dispatch>
    551e:       e8 6d fe ff ff                  call   5390 <polymorphism::static_dispatch>

Наша функция static_dispatch не имеет параметров, и ее вызов - это первое, что мы делаем в нашей функции main(), поэтому первое, что мы видим, - это три вызова местоположения этой функции в памяти. Однако вы заметите, что все три местоположения разные - 5360, 5330 и 5390. Это потому, что на самом деле это три разные функции - по одной для каждого из наших конкретных типов.

0000000000005330 <polymorphism::static_dispatch>:
    5330:       48 83 ec 18                     sub    rsp,0x18
    5334:       48 89 e7                        mov    rdi,rsp
    5337:       e8 34 01 00 00                  call   5470 <<polymorphism::Tiger as polymorphism::Growler>::growl>
    ...

0000000000005360 <polymorphism::static_dispatch>:
    5360:       48 83 ec 18                     sub    rsp,0x18
    5364:       48 89 e7                        mov    rdi,rsp
    5367:       e8 b4 00 00 00                  call   5420 <<polymorphism::Lion as polymorphism::Growler>::growl>
    ...

0000000000005390 <polymorphism::static_dispatch>:
    5390:       48 83 ec 18                     sub    rsp,0x18
    5394:       48 89 e7                        mov    rdi,rsp
    5397:       e8 24 01 00 00                  call   54c0 <<polymorphism::Bear as polymorphism::Growler>::growl>
    ...

Компилятор взял нашу единственную функцию static_dispatch в Rust и во время компиляции создал по одному экземпляру для каждого конкретного типа, который ему когда-либо передавался. Затем в каждой из этих функций выполняется запекание вызова соответствующей реализации метода, поэтому в каждой из них вы видите вызов метода Growl() трех разных типов.

Теперь должно быть очевидно, почему подход «статической диспетчеризации» приводит к большему двоичному размеру - компилятор может выполнять все эти предварительные оптимизации, но должен хранить весь мономорфный код в самом двоичном файле. Однако наша программа более эффективна, поскольку решения о том, какие функции вызывать, в зависимости от того, какие типы, принимаются во время компиляции, а не во время выполнения. Код на Rust, который мы должны поддерживать, по-прежнему настолько элегантен и лаконичен, насколько мы хотим.

Между прочим, на этот подход никак не влияет с точки зрения производительности присутствие граничного трэйта (фактически, полученная скомпилированная программа идентична). Это по-прежнему абстракция с нулевой стоимостью - единственное отличие состоит в том, что добавление границ характеристик добавляет проверки во время компиляции, для каких конкретных типов можно использовать в качестве параметра нашей функции. Если бы мы передали тип, в котором не реализована трэйта Growler, наш код просто не компилировался бы.

В этом суть того, что делает этот «статический» подход к полиморфизму таким мощным. Мы пишем краткий код, а компилятор позаботится обо всем остальном, чтобы производительность оставалась высокой.

Динамическая отправка

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

В Rust этот подход использует «Trait Objects» для достижения полиморфизма. В отличие от границ трэйтов, которые являются необязательным ограничением, которое вы можете добавить к универсальным параметрам, объекты трэйтов на самом деле вообще не могут использоваться с универсальными шаблонами, и вместо этого являются обязательным методом для выполнения динамической диспетчеризации в Rust.

В наши дни синтаксис объектов-трэйтов характеризуется ключевым словом dyn, за которым следует имя трэйта:

fn dynamic_dispatch(t: &dyn Growler) {
    t.growl();
}

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

Несмотря на то, что мы не используем общие параметры, компилятору все равно нужна небольшая помощь, чтобы узнать размер наших типов во время компиляции. Вот почему вы обычно видите границы трэйтов, переданные через ссылку, как в приведенном выше примере, но это также можно сделать, заключив объект трэйта в контейнеры, такие как Box, Rc или Arc<dyn Гроулер>.

Затем мы можем вызвать их из основной функции, передав ссылки на каждый тип:

dynamic_dispatch(&Lion{});
dynamic_dispatch(&Tiger{});
dynamic_dispatch(&Bear{});

Давайте заглянем под капот и посмотрим, что компилятор Rust произвел для этих вызовов:

0000000000005510 <polymorphism::main>:
    ...
    # "dynamic_dispatch" call with "Lion" type
    #
    # Populates "rax" with a pointer to 0x43558
    5523:   48 8d 05 2e e0 03 00        lea    rax,[rip+0x3e02e]        # 43558
    552a:   48 8b 0d ef df 03 00        mov    rcx,QWORD PTR [rip+0x3dfef]        # 43520
    5531:   48 89 cf                    mov    rdi,rcx
    # Moves pointer in "rax" into "rsi" register
    5534:   48 89 c6                    mov    rsi,rax
    5537:   e8 44 00 00 00              call   5580 <polymorphism::dynamic_dispatch>

    # "dynamic_dispatch" call with "Tiger" type
    553c:   48 8d 05 35 e0 03 00        lea    rax,[rip+0x3e035]        # 43578
    5543:   48 8b 0d d6 df 03 00        mov    rcx,QWORD PTR [rip+0x3dfd6]        # 43520
    554a:   48 89 cf                    mov    rdi,rcx
    554d:   48 89 c6                    mov    rsi,rax
    5550:   e8 2b 00 00 00              call   5580 <polymorphism::dynamic_dispatch>

    # "dynamic_dispatch" call with "Bear" type
    5555:   48 8d 05 3c e0 03 00        lea    rax,[rip+0x3e03c]        # 43598
    555c:   48 8b 0d bd df 03 00        mov    rcx,QWORD PTR [rip+0x3dfbd]        # 43520
    5563:   48 89 cf                    mov    rdi,rcx
    5566:   48 89 c6                    mov    rsi,rax
    5569:   e8 12 00 00 00              call   5580 <polymorphism::dynamic_dispatch>
    ...

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

Затем давайте взглянем на нашу функцию dynamic_dispatch. В отличие от раздела, в котором мы обсуждали статическую отправку и «мономорфизацию», мы находим только одну копию этой функции в скомпилированной программе:

0000000000005580 <polymorphism::dynamic_dispatch>:
fn dynamic_dispatch(t: &dyn Growler) {
    5580:   48 83 ec 18                 sub    rsp,0x18
    5584:   48 89 7c 24 08              mov    QWORD PTR [rsp+0x8],rdi
    5589:   48 89 74 24 10              mov    QWORD PTR [rsp+0x10],rsi

    # This is where the concrete type's method is called
    558e:   ff 56 18                    call   QWORD PTR [rsi+0x18]
    ...

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

Итак, из предыдущего примера мы знаем, что rsi будет содержать 0x43558, когда используется наш тип Lion, 0x43578 для Tiger и 0x43598 для Bear. Но что на самом деле означают эти значения? И почему программа добавляет к этому значению 0x18 перед вызовом полученной ячейки памяти?

Это подводит нас к сути того, что на самом деле представляет собой типаж-объект. Короче говоря, это указатель. В частности, это указатель на часть памяти, в которой можно найти методы привязки типа, а также на некоторые другие вещи (например, деструкторы). Это обычно называется «таблицей виртуальных методов» или «vtable».

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

Каждая из трех ячеек памяти, загружаемых в rsi на протяжении всего жизненного цикла нашей программы, является местом, где находится vtable другого типа:

К сожалению, у меня возникли проблемы с поиском vtable в выводе objdump - по какой-то причине то, что я нашел в указанном смещении, полностью отличалось от того, что мне показывали другие инструменты, и я не мог понять, почему. Итак, текст ниже скопирован с действительно классного визуального дизассемблера Cutter.

0x00043558      .qword 0x00000000000052b0 ; sym.core::ptr::drop_in_place::h1a84c45b0db95228 ; dbg.drop_in_place_polymorphism::Lion; RELOC 64 
0x00043560      .qword 0x0000000000000000
0x00043568      .qword 0x0000000000000001
0x00043570      .qword 0x0000000000005420 ; sym._polymorphism::Lion_as_polymorphism::Growler_::growl::h9ba07c281cda2327; RELOC 64 

0x00043578      .qword 0x00000000000052d0 ; sym.core::ptr::drop_in_place::hbe44f9f0e5cd7b58 ; dbg.drop_in_place_polymorphism::Tiger; RELOC 64 
0x00043580      .qword 0x0000000000000000
0x00043588      .qword 0x0000000000000001
0x00043590      .qword 0x0000000000005470 ; sym._polymorphism::Tiger_as_polymorphism::Growler_::growl::hd8d4c2f459b86277; RELOC 64

0x00043598      .qword 0x00000000000052c0 ; sym.core::ptr::drop_in_place::h5b23dc3563c0609b ; dbg.drop_in_place_polymorphism::Bear; RELOC 64 
0x000435a0      .qword 0x0000000000000000
0x000435a8      .qword 0x0000000000000001
0x000435b0      .qword 0x00000000000054c0 ; sym._polymorphism::Bear_as_polymorphism::Growler_::growl::h4af959d647eb2439 ; dbg.growl; RELOC 64 

Опять же, я добавил некоторый интервал, чтобы было легче следовать. Вы заметите, что каждая из трех ячеек памяти, загруженных в rsi, отмечает начало vtable каждого типа в памяти. Однако часть vtable, содержащая указатель на метод, который мы хотим вызвать, на самом деле находится со смещением в 24 байта от начала каждой таблицы. Между прочим, шестнадцатеричный эквивалент для этого - 0x18, поэтому наша функция dynamic_dispatch добавляет это к rsi перед инструкцией вызова. rsi содержит расположение vtable для нужного нам типа, и добавление к нему 0x18 приводит нас к записи в этой vtable, к которой мы хотим получить доступ.

Однако то, что здесь находится, все же не наш метод, а скорее еще один указатель. Например, ячейка памяти 0x43570 просто содержит указатель на 0x5420. Именно здесь мы можем найти наш метод. Вот почему функция dynamic_dispatch использует вызов инструкции QWORD PTR [rsi + 0x18] - сначала загружает значение, расположенное в rsi + 0x18, в качестве указателя, а затем вызывает ячейку памяти, представленную этим указателем.

Если присмотреться, каждый из этих указателей должен показаться знакомым - 5420, 5470, 54c0. Это те же адреса, по которым мы видели методы для каждого типа в разделе статической отправки! Независимо от того, используем ли мы статическую или динамическую отправку, эти функции все равно должны существовать в нашей программе - разница в том, как мы получаем к ним доступ.

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

Вывод

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

Я создал проект в Compiler Explorer, который содержит полную программу с обоими подходами. Это отличный инструмент, который позволяет очень легко увидеть, как наша программа на самом деле работает под капотом, поэтому, если вам не нравится использовать objdump или Cutter, это также отличный инструмент для обучения.

Дополнительные ссылки

Некоторые дополнительные ссылки, которые я нашел в своем исследовании, не вошли в текст этого сообщения: