dereference_pointer_there
#prog #моё
Сегодня я хотел бы рассказать о возможных способах реализации полиморфизма в языках программирования и о том, какие выгоды и издержки они имеют. Disclaimer: я не особо шарю, так что могу наговорить глупостей.
Полиморфизм — это свойство кода обрабатывать данные разных типов единым образом. Замечательная вещь, не так ли? К сожалению, истинно полиморфного кода мало: если полиморфная функция делает что-то помимо тасовки аргументов, рано или поздно нужно будет выполнить операцию, специфичную для конкретного типа. Для того, чтобы исполнить полиморфный код, нужно каким-то образом передать в него эти функции для конкретного типа. О том, как это можно сделать и на какие компромиссы при этом приходится идти, я сегодня и расскажу.
1. ad hoc полиморфизм (aka полиморфизм для бедных). В этом варианте мы не прилагаем никаких специальных усилий к обеспечению полиморфизма: достаточно разрешить иметь в языке перегрузку функций. При подстановке конкретного типа в обобщённый код компилятор ищет перегрузки с соответствующими типами и вставляет нужные вызовы.
Достоинства:
* Легко реализовать.
Недостатки:
* Наличие перегрузки сразу ставит вопрос о том, какая перегрузка более специфичная, что усложняет рассуждения о коде.
* Требует наличия в языке механизма написания кода с отложенной проверкой типов (макросы в C и шаблоны в C++ под это определение в данном контексте подходят).
* Непосредственное следствие из предыдущего пункта: ошибки несоответствия типов ловятся по месту использования, а не по месту определения, что затрудняет отладку кода и написание высокообобщённого кода ("У меня не сошлись типы потому, что я неправильно вызвал функцию или потому. что сама функция определена ошибочно?").
* Сам факт обобщённости затруднительно переносить между границами отдельных единиц компиляции (читай, фиг вам, а не динамическая линковка).
Резюмируя: использовать имеет смысл тогда, когда нет других альтернатив.
2. Если нам нужно иметь таблицу функций для каждого значения некоего типа, наиболее прямолинейный способ достичь этого — это включить эту таблицу в каждое значение. Так поступают компиляторы всех мейнстримных объектно-ориентированных языков программирования (Java, C#, C++ при использовании классов).
Достоинства:
* Обобщённая функция в скомпилированном коде всего одна для каждой комбинации типов, поэтому итоговый бинарник остаётся достаточно маленьким.
* Если в языке есть интерфейсы, которые могут расширять друг друга, то можно организовать таблицы функций (vtable) таким образом, чтобы vtable для интерфейса
Bar
, расширяющего интерфейс
Foo
, начиналась с vtable интерфейса
Foo
. Таким образом можно получить повышающее преобразование (upcast), которое ничего не стоит в рантайме.
* Нормальная проверка типов: ошибки ловятся в определении обобщённой функции, если функция неправильна определена, и на месте использования при вызове с неправильными типами аргументов.
* Нормально работает с динамической линковкой.
К сожалению, в этом бочонке мёда есть ковш дёгтя:
* (Этот пункт относится главным образом к вышеупомянутым мейнстримным ООП ЯП, которые смешивают наследование интерфейса и наследование данных) Так как размер конкретного типа вызываемой функции неизвестен, аргументы приходится передавать по указателю (это можно обойти, но
с весьма нетривиальными ухищрениями), то есть как минимум первое обращение к аргументу косвенное (последующие могут быть напрямую, если данные остались в кеше процессора). Если созданный аргумент выходит за пределы функции, в которой был создан, то его приходится выделять в куче.
* Если мы хотим иметь в языке расширяемые интерфейсы (а мы хотим, чтобы не копипастить всё руками), то мы не можем напрямую включать vtable в само значение, потому что мы не знаем размера этой vtable наперёд. Поэтому обращение к функциям проходит через ещё один указатель. Конечно, если мы знаем, что интерфейс нерасширяем, то мы можем включить эти функции напрямую, но: а) в этом случае upcast сломается; б) vtable будет занимать в кеше место, оставляя меньше места под поля аргумента.
* Если мы хотим иметь возможность реализовывать несколько интерфейсов для типов (а мы хотим), то придётся держать несколько таблиц, и так как их число наперёд неизвестно, то их придётся
держать в динамическом списке (+1 индирекция) и при апкасте к разным интерфейсам либо при каждом обращении искать нужную таблицу, либо менять порядок таблиц в рантайме, так что по факту бесплатный upcast мы теряем.
* Так как у каждого отдельного аргумента потенциально своя vtable, мы не можем толком иметь функции вида
fn<T>(arg1 T, arg T) -> T
, что очень — нет, не так — ОЧЕНЬ сильно ограничивает выразительность системы типов.
* Получить доступ к vtable можно только в том случае, если у нас есть на руках значение нужного типа, поэтому мы не можем иметь функции вида
fn default<T>() -> T
и
fn from_str<T>(string s) -> T
.