Comments 27
Стоит отметить, что в c++ один или несколько предков могут находиться в других единицах трансляции и/или их исходный код вообще не доступен при компиляции наследника. Подход с уникальными id интерфейсов в таком случае, насколько я понимаю, невозможен
Не совсем понятно, как работают виртуальные функции.
Я правильно понимаю, что функция-диспетчер - это по факту просто хитрый switch по ID интерфейса/номеру функции?
Как работает передача аргументов через функцию-деспетчер? Некоторые аргументы ведь передаются через регистры, которые эта функция для своих внутренних нужд может тоже использовать. Используется ли тогда их сохранение/восстановление? Или функция-диспетчер использует только те регистры, которые не используются для передачи аргументов? Или же вообще, виртуальные функции используют нестандартное соглашение о вызовах, которое бы исключало использование некоторых регистров для передачи аргументов?
На счёт необходимости реализации множества интерфейсов: в Rust эту проблему обходят тем, что в подавляющем случае там полиморфизм статический. Редко когда надо действительно таскать объекты вроде dyn A + B + C ... + Z
. В C++ аналогично - в современном C++ не любят увлекаться с разветвлёнными иерархиями и множественным наследованием, как раз из-за потенциальной неоптимальности такого подхода.
В статье также не увидел описания механизма работы наследования в данном языке? Имеется ли возможность унаследоваться от более чем одного не-интерфейса и как следствие необходимость коррекции this
при виртуальном вызове?
Я правильно понимаю, что функция-диспетчер - это по факту просто хитрый switch по ID интерфейса/номеру функции?
Да. Диспетчеризация виртуальной функции это всегда динамический выбор точки входа по классу × интерфейсу × методу.
Как работает передача аргументов через функцию-деспетчер?
Насквозь. Диспетчер использует для своих целей только регистры, которые в соответстсвии с ABI целевой платформы считаются временными/мусором. Наличие диспетчера никак не влияет на вызывающую и вызываемую стороны.
Редко когда надо действительно таскать объекты вроде dyn A + B + C
Это зависит от задачи. Прямо сейчас я пишу этот текст в браузере, DOM которого состоит из групп картинок, текстов, таблиц, всяких кнопок и вложенных групп - воплне себе полиморфная структура данных.
Имеется ли возможность унаследоваться от более чем одного не-интерфейса?
Аргентум следует по пути Java/Kotlin/Swift, позволяя наследоваться от одного класса и реализовывать множество интерфейсов. Я считаю подход Rust/Go которые запрещают наследовать реализацию слишком ограничивающим, а подход С++, который позволяет наследовать реализацию и поля множества базовых классов, в том числе иметь в производом классе несколько копий одного и того же базового класса слишком эээ, как бы это помягче сказать, непрактичным и порождающим ошибки.
Поэтому в Аргентуме коррекция this
не нужна.
Спасибо за хорошие вопросы.
С передачей параметров понял. Хитро, но возможно несколько заморочено - надо писать код метода dispatch под каждую платформу, используя только "мусорные" на ней регистры.
На счёт полиморфизма - да, он есть в том же браузере, но у меня сомнения, что там классы по 10 интерфейсов (утрировано) реализуют. Максимум 1-2.
На счёт модели наследования - у меня в Ü подход тоже схожий с Java - можно иметь не более одно полноценного класса-предка и реализовывать сколько угодно интерфейсов. При этом коррекция this всё же нужна, ибо в классе на каждого предка есть свой указатель на таблицу виртуальных функций. При этом коррекция нужна только при вызове через интерфейс. Если просто базовый (не-интерфейсный класс) используется для вызова, то this берётся как есть, т. к. гарантируется, что базовый класс всегда лежит по смещению 0.
Я вам ещё один хороший вопрос задам, а что на счёт сравнения вашей диспетчеризации с С++, но только наложите на с++ код те же ограничения что и у вас в языке - нет виртуального наследования, наследовать можно только одну реализацию и сколько угодно интерфейсов (классов без данных с виртуальными методами). Мне асемблер обычно не интересен и я туда не гляжу, но даётся мне что для компиляторов это первичный сценарий для оптимизаций и ставлю на то что в с++ для такого случая быстрее чем у вас.
В таком сценарии в С++-классе все равно будут по одному vmt_ptr на каждый отдельный реализованный интерфейс. А значит будет движение this-указателя при кастах (c проверками на nullptr) и сложная настройка объекта в конструкторе с заполнением всех этих vmt_ptr-полей. Сохранится даже двойной размер всех смарт-поинтеров. Единственное чего возможно удастся избежать - это генерация thunk-функций, но и то только до первого наследования от такого класса.
Тот случай, когда своё выстраданное решение хочется защитить любой ценой.
Тут "движение this-указателя при кастах" (по сути, ADD RCX, offset) и "генерация thunk-функций" — это ужас-ужас, а каждый(!) виртуальный вызов выполнять не напрямую, а через функцию-диспетчер, в которой творится вычислительная магия с расшифровкой переданного дескриптора функции — это отлично.
будут по одному vmt_ptr на каждый отдельный реализованный интерфейс
Да.
будет движение this-указателя при кастах
Да, это движение тривиально, смещение на компайл тайм константу + касты редки. Ну т.е. когда вы пишите код который наследует вот таким образом, то вы и объекты храните в контейнерах сразу через базовый указатель, а иначе зачем это все.
двойной размер всех смарт-поинтеров
Это я не понял о чем вы, любой юник поинтер всегда один указатель, любой шаред поинтер зависит от реализации, но все с чем я реально сталкивался всегда 2 указателя(объект + делетер) + счётчик. И это так сделано совсем по другим причинам, это такой дизайн шаред поинтера.
сложная настройка объекта в конструкторе с заполнением всех этих vmt_ptr-полей
Нет, эта часть тривиальна, очередной (базовый) конструктор отработал перезапишится первый ВМТ(и то один раз после конструктора базового не интерфейса) и посестится очередной вмт, опять же по фиксированным смещениям, но соглашусь это для каждого объекта, хотя обычно конструкторы вот в таких случаях совсем не тривиальны и так.
Вообще сравнивать надо яблоки с яблоками, если у вас ограничения то и в других языках нужно смотреть на реализацию с учётом таких же ограничений.
И ещё, вы тут много пишете про бест практисы, но я не очень понимаю как это у вас сочетается с оптимизацией перформанса динамик кастов. Я может отстал от жизни, но всегда считал динамик каст хаком, нарушением инкапсуляции, но готов посмотреть на ваш пример с рантайм "интроспекцией" (ну хак хаком же) где это по вашему уместно. Сразу скажу что в тех же растах у вас просто будет несколько контейнеров (а)рс где один и тот же объект боксится несколько раз по разным крэйтам и никаких динамик кастов не потребуется
Простйший тест:
struct IA {
virtual ~IA() {}
virtual void mA1() = 0;
};
struct IB {
virtual ~IB() {}
virtual void mB1() = 0;
};
struct Impl : IA, IB {
int field;
void mA1() override {}
void mB1() override {}
};
void f(IB* a) { a->mB1(); } // {1}
int main() {
volatile auto obj = new Impl; // для подавления девиртуализации
f(obj); // этот вызов MSVC заинлайнил
}
Вызов в строке {1} (MSVC, release):
mov rcx,qword ptr [obj]
test rcx,rcx
je main+44h
mov rax,qword ptr [rcx+8]
add rcx,8
call qword ptr [rax+8]
О чем это говорит? Даже если вы не пишете в своих приложениях кастов, копилятор щедро расставляет эти невидимые касты для вас и они гораздо сложнее, чем "смещение на компайл тайм константу". Если храните базовые указатели (имеется в виду указатель на сам объект?) Будьте готовы к невидимым кастам при каждом вызове.
они гораздо сложнее, чем "смещение на компайл тайм константу"
Ну как же, вот же асемблер, да проверка на нул, потом в ах грузится виртуальная таблица ИБ, для ИА тоже самое только константа будет 0, для какого нибудь ИС будет 16 и т.д. В сх грузится зис также смещенный на ту же константу. Проверку на нул не избежать и она у вас и так есть в виде оптионала. Мало того у вас в примере каст + виртуальный вызов, сделайте просто каст. При касте сместиться только зис и все (я не проверял, но ванную даже проверки на нул не будет).
Если храните базовые указатели (имеется в виду указатель на сам объект?)
Нет, имеется в виду, что если вам нужна виртуальность то никто не хранит указатели на конкретные типы, вместо этого хранятся указатели на интерфейсы/базовые классы. Это значит, что в вашем примере обдж не авто поинтер, а ИБ*.
Если хотите узнать сколько стоит каст, то тестить надо вот это, а не то что вы тестить
auto* obj = new Impl();
IB* base = obj;
Вот вторая строка и есть статик каст, и в конвенциальном коде который использует виртуальность этот каст будет один раз при инициализации указателя, т.к. обдж никому не нужен, а нужен только бэйз.
Невидимых кастов никаких нет при использовании бэйз указателя.
Проверку на нул не избежать и она у вас и так есть в виде оптионала
Ну почему. Передавайте ссылку, а не указатель, там не может быть null-ов. С другой стороны, тут выполняется разыменовывание указателя, а значит, null тут — UB, и компилятор имеет право не проверять. Почему осталась проверка — непонятно.
Согласен, проверка на нул здесь скорее всего по другим причинам, у вас пример перегружен, вам надо иметь отдельные виртуальные методы или помеченные ноинлайн или ещё как то сказать компилятору не инлайнить и в этих методах нужно по отдельности / в изоляции смотреть на каст, вызов, конструктор, и вы ещё забыли про деструктор. В деструкторах происходит обратное колдунство, после очередного деструктора соответствующий указатель на втэйбл зануляется.
Почему осталась проверка — непонятно.
По стандарту: 5.2.9 Static cast 11 [...] The null pointer value (4.10) is converted to the null pointer value of the destination type.
Почему unique_ptr непригоден для организации иерархий объектов, я уже описал тут: https://habr.com/ru/articles/751630/ см. "Композиция в С++/Rust/Swift". Вкратце:
Его таргет может удалиться в тот момент, когда в стековых кадрах есть ссылки на него. В многослойных приложения, когда объекты модели и бизнес-логика, работающая над этими объектами пишется и сопровождается разными командами разработчиков или если жизненный чикл приложения измеряется годами это происходит очень часто.
Он не своместим с weak_ptr, и не может в ассоциативные кросс-связи.
Поэтому остаются только shared/weak_ptr, которые имеют двойную ширину со всеми оверхедами, включая удары по кешу.
Сам набор требований к проектируемым классам выглядит сомнительно.
С одной стороны, вы пытаетесь предоставить максимально защищённый фрейморк, чтобы его пользователи могли творить что угодно и это оставалось надёжным. Но тогда у них нужно отобрать указатели и дать хендлы, как HWND в WinAPI.
Когда вы пишете "есть ссылки на стековых кадрах", я подозреваю, вы разрешаете многопоточный доступ. А это не разруливается одними лишь смарт-указателями. Нужны потокобезопасные коллекции и вообще всё потокобезопасное.
С другой стороны, какая от этого бизнес-польза? Корректно выходить из ситуаций, когда обработчику строки документа нужна шапка документа, он пытается взять её по weak-ссылке, не может, и корректно выходит, передавая статус ошибки наверх. Как по мне, мартышкин труд программировать подобные сценарии, гораздо полезнее спроектировать систему с гарантиями, что когда функция работает с документом, его строки не могут быть удалены другим потоком.
Вы плохо говорите на с++, наверное потому что он не родной для вас.
Шаред поинтеров вы не знаете, одна из причин на уровне дизайна почему в нем 2 указателя перегрузка номер 8 https://en.cppreference.com/w/cpp/memory/shared_ptr/shared_ptr
Но признаюсь честно, даже среди тех кто пишут на плюсах годами каждый день очень мало кто знает и понимает зачем это.
А нужно это именно для выражение композиции через владение значением по дефолту, но если нужно, то юник поинтер тоже можно использовать.
Ещё раз в плюсах композиция это по дефолту владение значением, есть причины когда это не желательно, тогда композиция выражается через владение юник поинтером.
Выши замечания, что владение по значению и/или юник поинтером не совместимы с шаред/вик поинтером - не состоятельны. Смотрите документацию, и сразу скажу я так делал и не раз. Единственное условие для этой техники это алокация рут объекта дерева объектов в хипе и владение этим рутом через шаред поинтер.
В плюсах все связи (агрегация/ассоциация) между объектами внутри одного дерева это просто сырые указатели. Потребность в вик поинтерах возникает только для ассоциации с объектом из другого дерева объектов.
Агрегация с независимым объектом (иммутабл в вашем случае) в плюсах выражается не через шаред поинтер(хотя конечно можно, но не нужно это как из пушки по воробьям), а через интрузив поинтер его в стандарте нет, но есть много где в других либах. Если агрегаты и объекты которые их агрегируют живут в одном потоке то счётчик может быть не атомарным, в общем случае нужен атомарный (но х86 разницы нет). В случае многопотока, имутабельность агрегатов позволяет обойтись только релаксед операциями на счётчике (заметьте что релакседы все равно атомарны по записи в память), а не более дорогой парой аквайр/релиз которая нужна только если объект мутабельный (чтобы как раз эти мутации гарантировано подтянулись в деструкторе).
Если у вас вызывает затруднения понимание как это работает, я могу написать вам кратенький пример.
готов посмотреть на ваш пример с рантайм "интроспекцией" (ну хак хаком же) где это по вашему уместно
Интроспекция и dynamic cross-casts уместны и полезны в обобщенных view-model фреймворках, автоматизации и скриптовании, логгировании отладке и тестах, сериализации, ORM, RMI и много еще где.
Быстрый dynamic downcast может заменить опасный static_cast во всех сценариях, значительно подняв безопасность приложения и добавить простой паттерн-матчинг по рантайм-типам. В сущности std::variant
обеспечивает именно его, и при всех его недостатках, его именно за это и любят.
в тех же растах у вас просто будет несколько контейнеров (а)рс где один и тот же объект боксится несколько раз по разным крэйтам
Box в Расте не допускает множественного владения.
Rust, Go и Swift выигрывают эти четыре инструкции в операции вызова, но проигрывают по две инструкции в каждой операции передачи, сохранения и загрузки ссылки из-за ее удвоенного размера. А эти операции выполняются чаще чем вызов.
тут не очевидный баланс, копирование двух указателей уже на SSE будет одной инструкцией, с другой стороны вызов у вас происходит через диспечеризатор, который не только несколько инструкций, но и переход в другую часть кода с потерей кеша инструкций
И не понятно что с кодом загружаемым из dll, какие там будут id ?
копирование двух указателей уже на SSE будет одной инструкцией
SSE регистры не могут хранить указатели. Если компилятор умеет предавать fat ptr в регистрах, это будет две дополнительные инструкции и переполнение регистрового файла, как описано в статье. Если не умеет и следует ABI для структур (как например MSVC), то каждая передача параметра-ссылки в функцию превращается в резервирование стека, два movaps, lea, и учитывая, что при вызове каждого метода надо передавать this, это становится дороже подхода Аргентума на каждом вызове.
переход в другую часть кода с потерей кеша инструкций
Вызов метода - это вообще всегда переход в другую часть кода.
Кстати, удвоение размера каждого указателя в программе - это рост размера данных и сделовательно рост количества кеш-промахов, который, в зависимости от связанности структур данных может доходить до 200%. Поэтому подход с двойными указателями с кешем не дружит.
И не понятно что с кодом загружаемым из dll, какие там будут id ?
Код загружаемый из DLL должен следовать соглашениям ABI платформы. Я пока не видел ABI, стандартизирующий вызов виртуальных методов. Поэтому правильно написанная DLL/o не должна выставлять наружу ООП API, если не хочет зависимости от конкретной версии компилятора с конкретными настройками сборки.
SSE регистры не могут хранить указатели
Конечно могут, регистр это просто данные. Да, нет инструкций для переходов по данным из SSE/AVX регистров,но если вам нужно копировать / передавать кучу удвоенных указателей - это возможно через векторные регистры. Правда есть вопросы на сколько часто это приходится делать и вообще есть ли в этом смысл.
Мне кажется вы вообще как-то зациклены на одном конкретном ABI, хотя вроде как новый язык делаете и казалось бы что мешает вам завести свой собственный с преферансом и танцовщицами. Тот же раст так и делал, не знаю как сейчас, но раньше у него вообще стабильного ABI не было.
Да, я тут глянул в godbolt.org е на вопрос передачи объекта за интерфейсом в расте, и что то я двух указателей не вижу, у вас есть пример? Мне кажется что вы как-то смешали передачу указателя на объект с вызовом интерфейса объекта.
Вызов метода - это вообще всегда переход в другую часть кода.
Кстати, удвоение размера каждого указателя в программе - это рост размера данных и сделовательно рост количества кеш-промахов, который, в зависимости от связанности структур данных может доходить до 200%. Поэтому подход с двойными указателями с кешем не дружит.
У вас два перехода, так что накладные расходы удваиваются. Увеличение размера указателя на промахи данных не влияет, данные последовательные и или уже подняты в кеш линию, либо будут подняты предсказателем, это он умеет очень хорошо. Единственное на что размер указателя мог бы влиять это на исчерпание кеш линий, но это нужно столько указателей что то не практично
Код загружаемый из DLL должен следовать соглашениям ABI платформы. Я пока не видел ABI, стандартизирующий вызов виртуальных методов. Поэтому правильно написанная DLL/o не должна выставлять наружу ООП API, если не хочет зависимости от конкретной версии компилятора с конкретными настройками сборки.
Ну во первых это не так, код из длл не обязан следовать никаким соглашениям, это вопрос того как заранее договорятся библиотека и приложение. Ну и не секрет что да, многие программы особенно из под MSVC имеют проблемы с тем, что они собраны не той версией, однако внутри версии они не имеют проблемы что я могу загрузить полностью валидный код который положит мою программу.
P.S.
В принципе это всё теоритезирования, что на самом деле быстрее показать может быть только замер, но разумеется сделать его корректно не просто.
Если пойти дальше, мы знаем все интерфейсы, которые есть в компилируемой программе. Просто все методы всех интерфейсов (ну, сколько их там будет? 200-500?) размещаем в vtbl, как будто каждый объект реализует каждый интерфейс, а если не реализует, на его адресе ставим заглушку. Сколько у нас классов? Пускай будет 20000 классов. На 500 методов каждого класса, это 80MB указателей в vtpr. Зато всё прозрачно и понятно, никакой магии.
Подход автора, инвертирующий адрес метода и индекс, я боюсь, не будет понят современными процессорами, которые имеют множество хрупких механизмов предсказаний по треку регистра RIP. Тут же, все вызовы проходят через единую точку-диспетчер, с хендлом метода в RAX. Я думаю, процессоры этому трюку не обучены, и точность предсказаний сильно упадёт, а с ним и скорость выполнения.
Каждый вызов виртуального метода в Rust/Swift/C++ сегодня происходит через `call [RAX+...]` процессор справляется.
Справляется, потому что предсказывает, что в какой-то конкретной точке, где расположен этот CALL, в 99% будет переход по адресу 0x1234560. А у вас в диспетчере на таком CALL будут переходы вообще во всем методам класса. И чтобы сделать предсказание, нужно учитывать, из какой точки вызван диспетчер. Процессоры этому не обучены.
Как язык Аргентум делает быстрый dynamic_cast и диспетчеризацию методов интерфейсов четырьмя инструкциями процессора