На тему разных языков программирования написаны разные книги. И классика В.Ш. Кауфмана, и Glynn Winskel. Но если бы попросили выделить отличия языков программирования, то как бы на этот вопрос ответил автор? И автор бы начал с описания кватернионов. Что известно про комплексные и гиперкомплексные числа?
- У вещественных чисел есть линейный порядок. У комплексных чисел линейного порядка уже нет.
- Умножение комплексных чисел коммутативно. Умножение кватернионов некоммутативно.
- Умножение кватернионов ассоциативно. Умножение седенионов не ассоциативно, но альтернативно.
Видно, как по мере усложнения структуры теряются свойства. Усложнение структуры можно далее называть «Синтезом», а способность алгебраической системы быть проанализированной можно назвать «Анализом». Синтез и Анализ, — альфа и омега языков программирования.
Что можно сказать про соотношение Синтеза и Анализа?
- Больше Синтеза, → обычно меньше Анализа
- Меньше Синтеза, → вовсе не факт, что больше Анализа
- Анализ на заказ обычно просто не выполним. Интересный Анализ открывают, а не заказывают
Например, по теореме Адамса существуют только четыре расслоения Хопфа. Спорадических групп 26 (с группой Титса 27).
Если захотеть заказывать Анализ, может просто ничего не получиться. Ну захотим гиперкомплексные числа размерности три, математики разведут руками. Можно поупрямствовать. Можно каждое ненулевое тройное число разложить на радиус и направление, и определить умножение тройных чисел как умножение радиусов отдельно, а для направлений использовать правило параллелограмма на сфере. Получается алгебра без особенно интересных свойств.
Так и в устройстве языков программирования. Анализируемые свойства растут из структуры. Можно проиграть сразу на двух фронтах, и Синтез завалить, и в обмен на заваленный Синтез так и не получить никакого интересного Анализа. И ещё не получается потребовать Анализ на заказ. Как общий принцип, делается какое-то открытие анализируемой структуры, и этот Анализ цементируется в языке программирования, и исходя из зацементированного Анализа, программисту выдаётся доза Синтеза, не идущая вразрез с выбранной дозой Анализа, и потом доза Синтеза по возможности увеличивается, но пределы очерчены изначально. Увеличение Синтеза, не идущее вразрез с Анализом, — это обычно синтаксический сахар, всё больше и больше синтаксического сахара, впрочем, и сам синтаксический сахар тоже не вполне полезен для Анализа, ведь исходные тексты могут быть подвергнуты Анализу со стороны разного инструментария, и новый синтаксический сахар сразу их все ломает, если только нет общепринятого промежуточного представления.
Структуры для полиморфизма
Сделав такое введение, можно рассматривать полиморфизм в языках программирования. Всё растёт из структуры. А структуры не заказываются, структуры открываются.
Какие структуры в языке программирования могут отвечать за полиморфизм? Это:
- структура ссылки
- структура нечто по ссылке
Под ссылкой имеются в виду даже и такие сущности, как параметры методов. Ведь нужно же вызывать методы. У метода должен быть какой-то формальный тип параметра. А передаётся значение какого-то актуального типа. От системы типов ожидается, что значение вызывающей стороны состыкуется с ожиданиями вызываемой стороны. Возможность стыковки определяется совместимостью на двоичном уровне, а совместимость на двоичном уровне, — это уже Анализ. Тот самый Анализ, который не бывает на заказ, а бывает только как открытие, и проистекает из Синтеза структуры. Даже если язык программирования не в машинных кодах, эти принципы действуют. Не претендуя на новые открытия, перечислим варианты устройства ссылки и нечто по ссылке.
Структура ссылки
И здесь известные интересные варианты:
- вообще не указатель, а непосредственно содержимое
- один указатель
- два указателя
В контексте полиморфизма 1, как в языке программирования Ада. generic. И generic в Delphi. И template в С++.
Один указатель, — тоже частый вариант. Можно встретить в COM, Delphi interface и Objective-C. В других языках программирования можно воплотить, но отложим это до рассмотрения структуры нечто по ссылке.
Два указателя, — вариант реже, но встречаемый. Это Rust. В других языках программирования можно это встретить для ссылки на отдельный метод. В Delphi есть синтаксис of object, объявляющий двойной указатель. В Objective-C нет явного типа, но популярна передача ссылки на объект и селектора к нему для подписки на событие.
Трудность с двумя указателями в том, что в процессорах есть инструкция DCAS, двойного сравнения и замены, и если указателей уже и так два, то весь DCAS придётся тратить на одну двойную ссылку, а две двойных ссылки не получится покрыть DCAS. Не в любом процессоре есть DCAS, но всё же часто сейчас можно ожидать, что есть. В линейке x86 инструкция cmpxchg8b появилась в Pentium, и с тех пор DCAS вроде бы как долго был доступен. Когда происходил переход с Intel x86 на AMD 64, новые процессоры ставились в старые слоты материнской платы. И тут получалось, что материнская плата, чипсет, и вот это всё, оно может только CAS для 8 байт, а для 64 битов это одиночный CAS, а не двойной, и двойного снова нет, пока не обновить материнскую плату. В 2024 можно посчитать, что трудное детство компьютеростроения всё-таки позади, и всё-таки теперь и в 64 битах есть DCAS. cmpxchg16b. Но всё ещё нет cmpxchg32b и, наверное, и не будет.
Структура нечто по указателю
- Произвольная
- Одиночно наследуемая VMT
- Динамическое разрешение метода
Известные комбинации структуры того и другого
Ada generic, Delphi template, C++ template
Ссылка вообще не указатель, а собственно содержимое. Как правило, это требует повторной генерации кода для каждого варианта. И код для каждого отдельного варианта, может быть, по отдельности и быстр, но если копий кода много, то все копии не влезают в кеш, а код, не влезающий в кеш, это медленный код.
COM interface, взгляд со стороны Delphi хакера
Ссылка из одного указателя, а сразу по указателю одиночная VMT. Отличительной особенностью является то, что на один и тот же объект могут быть разные указатели. Слабая «идентичность». Хотя, если запросить IUnknown, то обычно через любой интерфейс можно получить одну и ту же ссылку на IUnknown. Кстати, запрос интерфейса здесь работает как «динамическое разрешение метода», только запрашивается сразу пачка. Но так как это не является основным способом вызвать метод, то далее ограничения как для одиночной VMT.
Что можно делать, если VMT одиночная? Можно усекать VMT. Ссылка на интерфейсный объект двоично подходит в любое место, где хотят IUnknown. И с каким-то потомком IUnknown это тоже сработает, вот только базовый интерфейс должен быть подлинно неизменным интерфейсом (проблема хрупкого базового класса).
Ещё можно, если у какого-то интерфейса входы-выходы известны, синхронно наращивать VMT. Например, если есть воплощение IList<IUnknown>, то можно из того же воплощения строгать IList<ISomeAnotherInterface> принудительным приведением типа. А вообще при должной языковой поддержке это называется lightweight generic, и есть, например, в Objective-C 2.0. В Delphi такой поддержки нет, только если хакнуть систему, силой привести свежий чистый IList<IUnknown> к IList<ISomeAnotherInterface>. Это работает, но Delphi это не способна проверить. IList<T> работает на ввод и вывод и инвариантен относительно T. Тут главное, что со всеми интерфейсными ссылками нужно работать одинаково в смысле счётчика ссылок и в смысле передачи в параметры методов, везде любые интерфейсные ссылки обрабатываются одинаково в этом смысле.
Ещё можно усекать VMT сквозь контейнеры (ковариантность), но Delphi тоже не способна это проверить. Инвариантный IList<T> для этого не годится. Напрашивается IReadOnlyList<T>, но тут уточнение. В Delphi из коробки нет IList<T>, а в TList<T>, если выделять «максимальное только читаемое подмножество методов», то это не то же самое, что «максимальное ковариантное подмножество методов». Максимальное ковариантное подмножество можно было бы назвать IPermutableList<T>, так как перестановки по индексам оказываются допустимы и сортировка встроенным сравнителем оказывается допустима для элементов, которые уже оказались внутри списка через инвариантный IList<T>. И сортировка переданным извне сравнителем допустима. А что не допустимо, так это вызывать методы Contains или IndexOf, которые двоично законтачивают встроенный сравнитель с интерфейсными ссылками, возможно, за пределами поддерживаемого диапазона.
Но вот ведь незадача. Если по ссылке VMT с одиночным наследованием, и порядок нужно изобрести, то нужно определиться и выбрать только один вариант. Ну и, допустим, можно как промежуточную точку выбрать IReadOnlyList<T>, но с условием, что из него выброшены все методы, ломающие ковариантность, то есть, IndexOf и Contains. Тогда ссылки IReadOnlyList<ISomeAnotherInterface> оказываются двоично совместимы во всех местах, где ожидается IReadOnlyList<IUnknown>. Delphi об этом не знает, так что приходится явное небезопасное приведение интерфейса использовать, но это работает. И гипотетически когда-нибудь Delphi могли бы научить таким фокусам. Ковариантности в интерфейсах.
Но какому фокусу не получится научить в рамках выбранного направления, так это ковариантности за пределами одиночно наследуемая VMT. То есть, одиночно наследуемая VMT образует рельсы, по которым можно ехать к корню (IUnknown) только в одном направлении. От ISomeAnotherInterface к IUnknown ведёт конечное количество остановок, и двоичная совместимость только для них. Примечание. Нет, всё же кое-что есть за пределами. IReadOnlyList<IReadOnlyList<ISomeAnotherInterface>> можно свести к IUnknown двумя путями, не погрешив против двоичной совместимости, но на этом возможность множественных путей исчерпана. Множественное наследование интерфейсов в такую двоичную совместимость никак не лезет. А ведь иногда так не хватает.
Как бы можно было это починить? Вспоминаем про лазейку через QueryInterface. Можно изобрести нечто такое:
type ICompatible<T> = interface(IUnknown) end;
И чтобы это говорило честное слово, что, всё, что ICompatible<T>, если спросить QueryInterface(T), то что-то найдётся, но не обязательно тот же указатель, что и на входе. Ну и для ICompatible<T> действовали бы какие-то расширенные правила двоичной совместимости, в которых интерфейс, собранный из множественных интерфейсов, мог бы автоматически развалиться по нескольким путям. Но это что-то не очень понятное, это лучше смотреть на Objective-C 2.0.
А напоследок можно рассмотреть ещё такие особенности VMT COM, и именно в Delphi, а потом COM не в Delphi.
По одиночной ссылке расположено нечто, про которое вызывающей стороне известно только что сразу по этому указателю указатель на VMT. И если через VMT вызвать метод, передать в метод указатель, то вызовется вроде бы как то, что нужно. А то, что нужно, выглядит как набор прыжковых инструкций, вычитающий известное воплощению смещение между интерфейсной VMT и началом объекта, и прыжок в метод объекта.
Но это не вся история. Это так происходит, если методы не виртуальные. А если виртуальные, то из интерфейсной VMT делается прыжок по объектной VMT. А всё вместе получается два прыжка! Про COM бытует представление, что виртуальный прыжок один, но нет, их два. Потому что в общем случае невиртуальные методы неудобные. Интерфейс весь виртуальный, а в классе метод нет? Странно. Только если класс с претензией на то, чтоб быть последним. А если возможны потомки, то методы виртуальные, и у них прыжковые таблицы для интерфейсов общие, и чтобы они прыгали в разные воплощения, они прыгают тоже через VMT, но объектную VMT.
Впрочем, можно поступить иначе. В Delphi интерфейсная таблица и связанный с ней набор прыжковых инструкций генерируется каждый раз, как после базового класса написать интерфейс. И выделяется новая ячейка для VMT, которая перекрывает старую VMT, но и старая VMT для того же самого интерфейса всё ещё есть. И при таком подходе можно замену сделать невиртуальными методами. Но если замена в базовом интерфейсе, то надо ещё не забыть перечислить и все унаследованные интерфейсы, Delphi это автоматически не сделает. И если их перечислить, то виртуальный вызов получается один, но какой ценой. Ценой выделения кучи дублирующих VMT, и эта куча растёт экспоненциально.
В C++ как-то делается COM через полное сходство с устройством абстрактных классов C++. Какой там выбран подход, автор не изучал, но что ни выбрать, исходы такие же. Или двойной виртуальный прыжок, или экспоненциально растущая куча дополнительных VMT ради того, чтобы виртуальный прыжок мог быть один.
Динамическое разрешение метода
Встречается в Objective-C. Благодаря такому выбору, у объектов единственная «идентичность». То есть, все ссылки на объект являются одним и тем же указателем. Хоть протокол (аналог интерфейса), хоть надкласс, хоть через какой вид смотреть на объект, указатель одинаковый. И поэтому проще ввести ковариантность/контравариантность. Их и ввели, в виде lightweight generics.
В Objective-C одиночное наследование классов и множественные протоколы, но это ограничение Objective-C. В System Object Model множественное наследование классов. Это тот случай, когда между Анализом и Синтезом остался зазор. Когда язык программирования, будучи в чём-то простым, и не улучшил Анализ, и не улучшил Синтез. Во множественное наследование классов можно пойти, это просто разработчики Objective-C не осилили, хотя сделали многое другое.
Недостатком динамического разрешения считается долгое разрешение метода, но кое-что по этому поводу можно предпринять. Дело в том, что даже в Objective-C применяются не строки как таковые, а селекторы, которые непрозрачны для программиста. В селектор можно записать подсказки для более быстрого поиска. А в System Object Model применяются жетоны методов, и сами жетоны методов можно искать через что-то вроде VMT, быстро. И жетоны методов исполняемые на некоторых платформах (OS/2 и Win32 точно да), так что если нет множественного наследования, а далеко не всегда оно есть, то рантайм может это отследить, и получается двойной виртуальный прыжок или даже одиночный. Почти как в COM, но первая VMT ищется не внутри объекта по ссылке.
Два указателя
Для полноты указан и такой вариант.
Это применяется в Rust, и это делает ООП в Rust экзотическим. Для полноты такая возможность указана, но автору не хватает опыта, чтобы вот так же с разных сторон рассмотреть, сравнить. Это похоже на COM, но у объектов единственная идентичность?
Также ссылка состоит из двух указателей в C++, если применять не intrusive_ptr, а shared_ptr. Начинает вторым указателем тащиться уничтожитель. |