четверг, 30 мая 2024 г.

[prog.c++] Грустные впечатления от p3086

Недавно в очередной раз наткнулся на библиотеку proxy от Microsoft. Которая разработана в качестве поддержки предложения p3086. Попробовал почитать этот пропозал (2-я ревизия на тот момент). Спектр эмоций оказался широким -- от нехилого пригорания по началу до тихой грусти в итоге. Попытаюсь рассказать почему.

Началось все буквально с первых страниц. Вот на этом фрагменте у меня полыхнуло:

For decades, object-based virtual table has been a de facto implementation of runtime polymorphism in many (compiled) programming languages including C++. There are many drawbacks in this mechanism, including life management (because each object may have different size and ownership), reflection (because it is hard to balance between usability and memory allocation) and intrusiveness. To workaround these drawbacks, some languages like Java or C# choose to sacrifice performance by introducing GC to facilitate lifetime management, and JIT-compile the source code at runtime to generate full metadata.

Т.е. десятилетиями полиморфизм реализовывался на базе привязанных к классам таблиц виртуальных функций и это было плохо, т.к. и временем жизни приходилось озадачиваться, и слишком уж все это было интрузивненько. Поэтому такия языки, как Java и C# решили принести в жертву производительность и пошли на использование GC для управление временем жизни и JIT-генерацию кода во время исполнения.

facepalm.jpg

Ну как бы это помягче сказать.

Во-первых, языки с GC появились еще до возникновения такого явления как ООП. Т.е. управление временем жизни -- оно актуально не только для ООП, а для программирования вообще. Так что выбор GC для Java/C# вряд ли имел отношение к основанному на классах с виртуальными методами полиморфизму.

Во-вторых, байт-код и JIT-компиляция -- это следствие философии Java -- write once, run everywhere. Причем под "run everywhere" подразумевался запуск именно что скомпилированого кода. Поэтому-то "бинарный" код для Java и пришлось хранить в независимом от аппаратуры представлении (том самом байт-коде). И, т.к. байт-код не может на обычных аппаратных архитектурах исполняться нативно, то приходится его преобразовывать в код конкретной архитектуры. Отсюда и та самая JIT-генерация, которая, опять же, к ООП и основанному на классах полиморфизму не имеет отношения. Ну и MSIL в .NET-е -- лишь попытка устранить фатальный недостаток Java-овского байт-кода: "его писали не мы" (с)...

Так что если уж в самом начале автор предложения пишет откровенную ерунду, то сложно отделаться от предчувствия "из ложных предпосылок стоит ждать и ложных выводов". Посему осилить весь пропозал не смог, простите.

Вроде бы там два основных тезиса, каждый из которых, безусловно, заслуживает внимания.

Тезис первый состоит в том, что интерфейсы в стиле наследования классов, жестко интрузивны. И если вам нужно, чтобы класс A, который не был отнаследован от интерфейса I, считался реализующим интерфейс I, то у вас проблема.

Либо вам придется наследовать A от I. Что есть привычная уже пугалка из категории "ай-ай-ай, посмотрите как нехорошо получается-то". Хотя как раз в C++ с шаблонами эта пугалка менее актуальна, чем в той же Java.

Либо же вы можете написать класс-прокси, который хранит ссылку на экземпляр A, но реализует методы I, пробрасывая их вызовы в соответствующие вызовы класса A. О чем, кстати говоря, в пропозале не было сказано (по крайней мере в первой части, которую я осилил).

Тезис второй состоит в том, что у нас нет удобного способа управлять временем жизни объекта, реализующего некий интерфейс. Т.е. либо unique_ptr, но тут могут быть проблемы с отсутствием виртуального деструктора, либо shared_ptr, но тут накладные расходы. Плюс принципиальная сложность с возвратом полиморфного объекта "по значению" и поддержкой small-object-optimization.

Т.е. оба тезиса состоятельны и обозначают реальные проблемы. Поэтому, в принципе, было бы хорошо иметь для них решения.

Вот пропозал предлагает "решение". Всего одно. Проблемы две, а решение одно.

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

ИМХО, для того, чтобы тип A мог "реализовывать" интерфейс I не наследуясь от I, нужно вводить новую языковую сущность. По типу interface из языка Go. Ну или в виде trait из Rust-а, с возможностью написать реализацию trait-а I для типа A не используя наследования.

Тогда можно будет вводить новые интерфейсы и старые типы смогут их поддерживать без необходимости переделки этих старых типов. Причем мне в этом смысле interface из Go нравятся больше, чем trait из Rust. Ну вот нравятся и все 🙂, чистая субъективщина.

Что до проблемы с временем жизни, то в C++ не хватает чего-то вроде unique_ptr, но с возможностью поддержки small-buffer-optimization и deleter-ом, который не является частью типа unique_ptr.

Ну т.е. сейчас unique_ptr можно использовать с кастомным deleter-ом, но этот deleter становится видимой частью типа. Вроде unique_ptr<T, my_deleter>. А было бы неплохо иметь какой-то fat_unique_ptr<T>, в котором актуальный deleter будет спрятан внутри (и, соответственно, может быть разным для разных экземпляров fat_unique_ptr<T>).

В общем: раз есть две проблемы, то нужно и два решения. Причем желательно не смешанные друг с другом. А то получится такое же темплейтное говно, как и deducing this. Если не хуже.

Тут невольно вспоминается Boost.Lambda.

Был когда-то такой франкенштейн. Которым даже пользовались особенно отчаянные оправдываясь тем, что не ждать же пока в язык нормальные лямбды завезут. Вот и приходилось им жрать кактус, пока в муках рожали C++0x. Хорошо хоть в C++11 лямбды -- это полноценный элемент языка. А не какая-то библиотечная нашлепка с макросами и перегрузкой операторов.

В общем как-то грустно. Язык и так сложен. Но его упорно продолжают делать еще сложнее.

Комментариев нет: