Получи случайную криптовалюту за регистрацию!

C 95

Логотип телеграм канала @cxx95 — C  95 C
Логотип телеграм канала @cxx95 — C  95
Адрес канала: @cxx95
Категории: Познавательное
Язык: Русский
Количество подписчиков: 588
Описание канала:

640K ought to be enough for anybody
Author: @cloudy_district aka izaron.github.io

Рейтинги и Отзывы

1.33

3 отзыва

Оценить канал cxx95 и оставить отзыв — могут только зарегестрированные пользователи. Все отзывы проходят модерацию.

5 звезд

0

4 звезд

0

3 звезд

0

2 звезд

1

1 звезд

2


Последние сообщения

2023-01-06 22:54:14 #creepy #compiler

Самое мерзкое правило в C++ для модульных программ и как его обойти

Недавно я в своем pet project снова столкнулся с тем, что могу назвать самым мерзким правилом C++ в своем опыте, по крайней мере для модульных программ. Оно связано с особенностями работы линковщика и требует всяких тайных знания для решения.

В больших модульных проектах для скорости разработки иногда используется такая схема - каждому модулю бизнес-логики соответствует статическая библиотека (static library).

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

Есть файл module.h с такими методами (упрощенно)
struct IModule { virtual void Do() = 0; };
void AddModule(std::unique_ptr module);
const std::vector>& GetModules();

Файл main.cpp должен использовать GetModules(), а модуль должен зарегистрировать сам себя через AddModule.

Единственный способ, которым это можно адекватно сделать - добавить код, который должен вызываться на старте программы. Это делается через статическую инициализацию объектов в конструкторе объекта. Где-то в одном из .cpp-файлов модуля должно быть такое:
struct Dummy {
Dummy() {
AddModule(std::make_shared());
}
};
static Dummy dummy;

Дальше начинается кино. Переменная dummy и код для ее инициализации попадает в статическую библиотеку libcoolmodule.a (можно проверить через objdump), но при линковке бинарника эта переменная выбрасывается линкером как неиспользуемая. В итоге модуль не зарегистрируется.

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

Windows - указать на переменные, которых нельзя выбросить - ссылка
__pragma comment(linker,"/include:?variable_name@")

Помещение переменных в отдельные секции и пометка этих секций как невыбрасываемых - ссылка
static Var_t g_DumbVar __attribute__((__used__, section(".var_section.g_DumbVar"))) = (const Var_t) X_MARKER;
static Var_t* g_DumbVarGuard[] __attribute__((__used__, section(".guard"))) = { &g_DumbVar };

Linux - также указать на невыбрасываемые переменные через параметр командной строки - ссылка1, ссылка2, переменная не должна быть static и volatile.

Linux - сделать link-скрипт, который указывает что и как надо линковать - ссылка

Через время поисков я нашел ответ, почему так работает линкер.
Когда собираешь бинарник, то индивидуальные объектные файлы (.o) в команде к линкеру линкуются по порядку и ничто не выбрасывается.

С библиотеками (.a, архив из .o) есть "оптимизация": .o-файл из библиотеки линкуется только если в нем находится определение какого-нибудь undefined symbol, который требуется в уже слинкованных прежде .o-файлах. В противном случае считается, что этот .o-файл не нужен и в бинарник он не попадает.

В системе сборки CMake есть метод, который позволит обойти это правило. Надо заменить такую строку:
add_library(enum_serializer STATIC module.cpp helper.cpp)
на такую:
add_library(enum_serializer OBJECT module.cpp helper.cpp)
И тогда, если какой-то бинарник зависит от enum_serializer, он будет линковать не libenum_serializer.a, а module.o и helper.o.
Поэтому "регистрация модуля" сработает и проблема будет решена
213 views19:54
Открыть/Комментировать
2023-01-01 18:15:01 #story

Самое простое объяснение std::function за 15 минут

Этот пост был написан под влиянием крутого видео от Jason Turner "A Simplified std::function Implementation"

Часто люди не задумываются, как работает std::function. Чаще всего знают, что эта штука - обертка над чем-то, что можно "вызвать" как функцию. Кто-то смутно помнит, что std::function вроде как лезет в динамическую память. cppreference не сильно раскрывает внутренности реализации.

Можно сказать, что в C++ есть два типа объектов, на которых работает семантика вызова как функции. Можно условно назвать их Callable. Это:
Сами функции:
int foo(int a, int b) { return a + b; }
Объекты типов с определенным operator(), часто их называют "функторы":
struct foo {
int operator()(int a, int b) { return a + b; }
}
Все остальные Callable являются производными от этих двух типов. В том числе лямбды - компилятор их переделывает в структуры с operator(). Про лямбды есть хорошая книга - https://t.me/cxx95/48.

А std::function должен уметь хранить все возможные Callable с данной сигнатурой.

template
class function {
// код реализации
};

Возникает проблема - у std::function должен быть фиксированный размер, но Callable типа может иметь неопределенный размер. Например, размер структуры у лямбды зависит от того, какие captures он делает.

Поэтому, к сожалению, std::function хранит Callable в куче.
Также нужно использовать виртуальный класс, который для каждого отдельного типа как бы вычислит адрес вызываемого метода:

Виртуальный класс и указатель на кучу:
struct callable_interface {
virtual Ret call(Param...) = 0;
virtual ~callable_interface() = default;
};
std::unique_ptr callable_ptr;

Реализация для каждого отдельного типа Callable держит в себе сам объект Callable и метод для вызова operator() по правильному адресу:
template
struct callable_impl : callable_interface {
callable_impl(Callable callable_) : callable{std::move(callable_)} {}
Ret call(Param... param) override { return std::invoke(callable, param...); };
Callable callable;
}

Конструктор std::function принимает Callable и создает объект в куче:
template
function(Callable callable)
: callable_ptr{std::make_unique>(std::move(callable))}
{}

И наконец вызов operator() у самого std::function перенаправляет вызов в содержимый Callable:
Ret operator()(Param... param) { return callable_ptr->call(param...); }

Вот так выглядит один из способов type erasure в C++
331 viewsedited  15:15
Открыть/Комментировать
2022-12-30 20:01:30 #compiler #madskillz

[[assume]] - помоги компилятору сам

Раньше я писал про std::unreachable (он же __builtin_unreachable до C++23) - https://t.me/cxx95/58.

Эта штука делает указание компилятору, что в данную ветку исполнения программа никогда не попадет (под личную ответственность программиста), поэтому можно оптимизировать это место.

В C++23 по такому образу стандартизировали похожий функционал: атрибут [[assume(expr)]] (он же __builtin_assume до C++23).

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

На cppreference (ссылка выше) информации мало, лучше почитать "предложение" о стандартизации: https://wg21.link/p1774r8

Самый простой пример - метод, который делит число на 32:
int div32(int x) {
return x / 32;
}
Казалось бы, очевидная оптимизация - не делить на 32, а сделать битовый сдвиг на 5 битов:
int div32(int x) {
return x >> 5;
}
Но будет неправильно работать на отрицательных числах. Компилятор всегда должен учитывать возможность входа отрицательного числа, из-за этого метод больше по размеру: ссылка на godbolt.

Если программист совершенно точно знает, что все числа будут неотрицательными, то нужно сделать так:
int div32_2(int x) {
[[assume(x >= 0)]]; // или __builtin_assume(x >= 0);
return x / 32;
}
И тогда код оптимизируется: ссылка на godbolt.

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

Некоторые assume можно сделать общими для всего кода (в "предложении" есть пример с умными указателями), но в целом это вещь для узкого круга разработчиков. Есть несколько особенностей этой фичи:

Нужно действительно сильно зависеть от быстродействия программы, например это могут быть реалтаймовые программы. Я однажды кидал видео-выступление Тимура Думлера (автора "предложения") на эту тему - https://t.me/cxx95/16.

Нужно понимать, за счет чего срезаются инструкции. Пример программы, которая ограничивает значения массива через std::clamp:
void limiter(float* data, size_t size) {
[[assume(size > 0)]];
[[assume(size % 32 == 0)]];
for (size_t i = 0; i < size; ++i) {
[[assume(std::isfinite(data[i]))]];
data[i] = std::clamp(data[i], -1.0f, 1.0f);
}
}
Предполагая, что размер буфера всегда больше 0 и кратен 32, а флоаты нормализованные, программист ставит assume.
Первый и третий assume не дает делать лишние проверки, а второй assume вероятно как-то связан с кэш-линией процессора.

Нужно постоянно лезть в ассемблер скомпилированной программы и проверять результат - а как иначе? И даже нужно делать юнит-тесты на генерируемый ассемблер (я бы по крайней мере делал). У компиляторов C++ много тестов на получающийся ассемблер, и в отдельных программах с assume они тоже нужны.

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

Можно сделать разные приколы с assume
Фиксируем вариант в switch - ссылка на godbolt.
Решаем простые уравнения с переменной - ссылка на godbolt.
335 views17:01
Открыть/Комментировать
2022-12-28 10:02:26 #compiler

Быстрый Switch: таблица адресов

В C++ оператор switch используется для передачи потока управления в разные места в зависимости от значения переменной.
Оператор switch можно представить как соответствие между значениями переменной, и кодом который должен выполниться для каждого значения.

В зависимости от целевой архитектуры, настроек оптимизации, и свойств конкретного switch-оператора, код может сгенерироваться в разном виде. Есть два варианта, какой ассемблер сгенерирует компилятор:
Цепочка последовательных if-ов. Это самый простой путь, потому что switch-оператор всегда представим в этом виде.
Таблица адресов (мой перевод), он же branch table, он же jump table.

Первый вариант неинтересен, он самый простой и самый неоптимизированный. Если в нашем switch 30 штук case-ов, то в худшем случае произойдет 30 (!) последовательных сравнений (цепочка if-ов), прежде чем программа поймет номер нужной инструкции.
На самом деле в таких случаях компиляторы умеют делать а-ля "бинарный поиск", поэтому вероятно будет log_2(30) сравнений в худшем случае

Во втором варианте номер инструкции, куда надо перепрыгнуть, вычисляется в зависимости от значения переменной, в процессе чего не выполнится ни одного сравнения.

Пример switch с таблицей адресов: https://godbolt.org/z/3debYb4vq
В этом примере в switch сравнивается значение enum-а. Для компилятора enum представляется как underlying type. По умолчанию этот тип int, то есть во всех операциях с enum происходит неявная конвертация в int.
Таким образом, можно представить, что это switch по значениям от 0 до 6 включительно.

В примере компилятор сгенерировал метки LBB0_2, LBB0_3, ..., LBB0_8 для каждого соответствующего кода case X.

Также компилятор сделал таблицу LJTI0_0, где лежат адреса этих меток. Вообще "таблица" это громко сказано, это просто наша абстракция.
"Таблица" представляет из себя несколько последовательных 8-байтовых числа, которые являются адресами меток LBB0_2-LBB0_8.
А метка LJTI0_0 указывает на начало последовательности.

Теперь, имея "таблицу адресов", можно вычислить номер инструкции, куда надо прыгать. Если параметр равен 0, то прыгаем по первому адресу таблицы, если 1 - по второму, и так далее.
lea rcx, [rip + .LJTI0_0]
movsxd rax, dword ptr [rcx + 4*rax]
add rax, rcx
jmp rax

Отступление: Как известно, метки имеют смысл только для ассемблера. Метка просто условно указывает на позицию в бинарнике (инструкцию или данные). После процесса линковки, когда в один исполняемый файл (бинарник) утрамбуются отдельные объектные файлы, вместо меток появятся нормальные адреса.
lea rcx, [rip + 0x012345678]

Таблица адресов может иметь другую реализацию, но такая общая идея. Например, в примере с Википедии для рандомного 8-битного ассемблера, значение переменной прибавляется к регистру счетчика команд (addwf PCL,F), а сразу после этой инструкции находится таблица с goto до нужной инструкции, и счетчик команд укажет на нужный goto.

Компилятор сам определяет, нужна ли таблица адресов. Обычно она используется для "плотных" switch, где есть case X для последовательных значений X. Если в case X поставить рандомные значения, то таблицы не получится - пример на godbolt, будут последовательные if-ы.
384 views07:02
Открыть/Комментировать
2022-12-25 20:18:09 #story

StarForce: привет из прошлого

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

Я и сам пострадал от этой системы - много лет назад я не смог установить крутые игры с дисков школьного друга, потому что на них стояла эта защита.

Сейчас про StarForce мало кто вспоминает. Системы проверки CD/DVD-носителей давно неактуальны в силу неактуальности самих дисков, а новые ноутбуки много лет производятся без дисковода.

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

Среди продуктов предлагается C++ Obfuscator, система для изменения кода до нереверсируемого хакерами состояния. Эта система добавляет в исходники лишние условия, циклы, вызовы методов. Скачать его просто так нельзя, предлагается заполнить форму, поэтому я не посмотрел его работу. По ссылке есть пример на алгоритме Евклида.

Есть несколько десятков способов обфускации.
Например, можно сделать так, чтобы нигде в исходниках не встречались строковые литералы в "чистом" виде.
В обычной программе запись
const char* key = "abacaba";
означает, что в бинарнике в секции .rodata (или аналоге) будет в открытом виде лежать последовательность байтов abacaba\0 (\0- нулевой байт). Но обфускатор может сделать так, чтобы этот литерал вычислялся хитрым способом в каком-то методе, и тогда его будет невозможно вытащить просто так.

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

После массового изменения исходного кода может произойти дурка - всплывают скрытые багов, время работы многих кусков кода меняется, ломается код с указателями и так далее...
371 views17:18
Открыть/Комментировать
2022-12-06 09:17:10 #creepy

Как обмануть [[nodiscard]] через std::ignore

В C++17 добавили атрибут [[nodiscard]], которым можно помечать функции, чтобы тот, кто вызвал функцию, не игнорировал возвращаемое значение.

Во многих окружениях любой warning ломает компиляцию (с флагом -Werror). Можно ли все-таки проигнорировать значение?

Оказывается - да В C++17 добавили std::ignore - это объект, которому можно присвоить любое значение, без эффекта.

[[nodiscard]] int status_code() { return -1; }
[[nodiscard]] std::string sample_text() { return "hello world"; }

void foo() {
std::ignore = status_code(); // нет warning/error
std::ignore = sample_text(); // нет warning/error
}

Но в духе C++ будет запретить std::ignore для некоторых типов. Покопавшись в реализации, можно выключить его, например для int:
template<>
const decltype(std::ignore)&
decltype(std::ignore)::operator=(const int&) const = delete;

Пример на godbolt
629 views06:17
Открыть/Комментировать
2022-11-28 11:14:00 #testing #books

Обзор книги "Modern C++ Programming with Test-Driven Development" (2013 г.)

(можно посмотреть тут - https://pragprog.com/titles/lotdd/modern-c-programming-with-test-driven-development/ в интернете есть PDF)

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

Это было мое вступление А книга описывает реалии test-driven development в C++. TDD это работа в коротких циклах, в каждом цикле сначала пишется тест на новое поведение, потом пишется реализация которая удовлетворяет тесту.
Несмотря на название, TDD это больше про дизайн системы, потому что он заставляет делать интерфейсы программы так, чтобы они были максимально тестируемы.

В принципе из книги можно понять что такое TDD, для тестов используется GoogleTest. К сожалению в книге есть минусы:

Много воды
Повторяется одно и то же по десять раз, как будто тему раздувают. Также есть много капитанства.
Например, в разделе "Running the Wrong Tests" совет - если вы запускали тесты, но новый тест не запустился, то что делать? Ответ: возможно вы запускали не тот test suite, или у вас неправильный фильтр, или вы не скомпилировали тесты, или тест выключен.
В разделе "Testing the Wrong Code" - вы тестируете не тот код, если вы забыли скомпилировать модуль, или компиляция закончилась неуспешно и вы этого не заметили.
После того, как тратишь по 10 минут на какие-то банальности, начинаешь читать книгу по диагонали

Автор борщит
В одной главе на 50 страницах разбирается пример элементарного модуля, который типа сделали по TDD, и вместо первого приблизительного решения (как это делают в реальном мире) автор делает угарные правки, чтобы пройти следующий кейс и не более того - в итоге все переписано по сто раз.
Автор дает тупые крутые советы а-ля "Мартин Фаулер":
Your favorite tests contain one, two, or three lines with one assertion
Tests with no more than three lines and a single assertion take only minutes to write and usually only minutes to implement
Которые не имеют отношения к реальности.

Слабая техническая составляющая
Очень мало написано про code coverage, CI. Время исполнения методов замеряется на коленке, хотя есть Google Benchmark для мелкого кода и Valgrind для всей программы. Dependency Inversion (чтобы можно было подсунуть мок-класс в тестах) называется сложным понятием.

Вывод: Лучше прочитать доку про GoogleTest, а к тестам относиться как во "вступлении", тогда код и так будет близок к TDD без сомнительных советов.
610 viewsedited  08:14
Открыть/Комментировать
2022-11-25 07:48:41 #creepy

ABI: три весёлых буквы

Одна из тем, которая мало заметна во внешнем мире, но вокруг которой происходит регулярный shitstorm среди тех, кто двигает C++ - это вопрос слома ABI

ABI (wiki) это гарантии, которым интерфейс программного модуля (библиотека, операционная система) удовлетворяет на бинарном уровне: соглашение о вызове, размер типов данных, и многое другое.

Самый простой пример, когда это важно - когда бинарь (исполняемая программа, .exe на Windows) требует для работы динамические библиотеки (.dll на Windows или .so на Unix).
Бинарь и динамическая библиотека - две разные программы, которые должны быть всегда совместимы друг с другом. Это значит, что бинарь и библиотека должны уметь обновляться независимо друг от друга таким образом, чтобы они могли продолжать работать вместе.

(про работу динамических библиотек можно прочитать в этой статье или в этой книге)

Проблема в том, что ABI очень просто сломать - есть неполный список ломающих изменений. Одни из понятных примеров:
Добавить новое поле в публичный класс
Ломает вообще всё, потому что теперь у .exe-файла (который не перекомпилируют!) неправильное смещение стека, размеры зависимых классов и так далее.
Добавить новый виртуальный метод
.exe-файл теперь неправильно вычисляет адреса старых виртуальных методов.

Есть тулзы, которые проверяют совместимость ABI: abidiff.

Стандарт C++ развивается так, чтобы не ломать ABI в новых версиях стандарта - то есть чтобы перекомпилирование проекта не мешало использовать зависимые .dll/.so.
Это приводит к проблемам, которые описаны в статьях:
День, когда умерла стандартная библиотека - лонгрид
Цифровые демоны - лонгрид посложнее

Есть два класса проблем, связанные с сохранением ABI:
Нельзя сильно поменять язык, например сортировать поля класса, чтобы класс занял меньше места.
Нельзя нормально менять стандартную библиотеку - большинство пропозалов уничтожаются на месте (проблем этого класса в несколько раз больше).

Реализация стандартной библиотеки C++ не является частью компилятора. Стандарт C++ только определяет интерфейс, а реализаций есть несколько, и они предоставляются в виде динамической библиотеки.

Из-за этого стандартная библиотека C++ крайне бедная, ее контейнеры медленнее чем нужно, некоторые классы супер хреновые (std::initializer_list, std::regex), и так далее - и никто не может это исправить. Если задизайнить что-то новое (например парсер JSON), это потом нельзя будет нормально поменять.
Даже дизайн Win32, POSIX, протоколов Ethernet предполагает всевозможные изменения в будущем - но только не стандартная библиотека C++.

С каждым годом стоимость не-слома ABI (застой в языке) постепенно приближается к стоимости слома ABI. Пока непонятно, будет ли разрешено ломать ABI, так как минимум два вендора против:
GCC. Все коммерческие продукты, поставляемые в бинарном виде для Linux, надеются на неизменность ABI, благодаря чему они могут не пересобирать свой продукт под каждый новый дистрибутив.
Microsoft. Visual Studio с 15 версии сохраняет своё ABI, библиотеки скомпилированные в 2015 версии без проблем линкуются в 2017 и 2019.

Некоторые думают, что C++ "сохраняет совместимость" и это типа хорошо. На деле, как мы видим, причины в сохранении совместимости бизнесовые - чтобы не перекомпилировать продукт кучу раз под разное окружение.

Ведь если подумать логически, ничего хорошего не выйдет, если мы возьмем какую-то библиотеку, скомпилированную ~20 лет назад. Она скорее всего либо собрана под старую архитектуру, либо несовместимым компилятором, и медленной (с каждым годом компиляторы быстрее).

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

Программирование с учетом ABI это не самая распространенная бизнес схема. Например, Qt ломает ABI каждый релиз, и никто от этого не умер.

(конец в комментарии)
516 viewsedited  04:48
Открыть/Комментировать
2022-11-21 19:31:03 #creepy

Миф о виртуальных деструкторах

На собеседованиях и в реальной жизни часто встречается вопрос: "Зачем нужен виртуальный деструктор?"

В очень достоверном источнике знаний (то есть в интернете) практически везде написано в таком ключе:
Если у вас в классе присутствует хотя бы один виртуальный метод, деструктор также следует сделать виртуальным. При этом не следует забывать, что деструктор по умолчанию виртуальным не будет, поэтому следует объявить его явно. Если этого не сделать, у вас в программе почти наверняка будут UB (скорее всего в виде утечек памяти).

Но есть логичный вопрос: почему бы тогда компилятору не генерировать виртуальный деструктор автоматически (для классов с виртуальными методами)?
Ведь он кучу всего генерирует сам (например move- и copy-конструкторы, move- и copy-операторы присваивания).

Ответ: Потому что утверждение выше неправильное! Виртуальный деструктор нужен только тогда, когда есть риск, что объект класса-наследника могут разрушить по указателю на класс-предок.

Пусть есть базовый виртуальный класс DrinkMachine и его наследник класс CoffeeMachine.
Каждый объект программы рано или поздно надо разрушить, это делается в 2х разных случаях:
Объект создан на стеке, тогда программа сама вызовет деструктор
{
CoffeeMachine cm;
// перед выходом из scope сам вызывается cm.~CoffeeMachine()
}
В этом случае неважно какой деструктор (виртуальный или нет), потому что в обоих случаях вызовется то что нужно - деструктор реального объекта.

Объект создан в куче, тогда программист сам делает разрушение объекта, обычно так:
DrinkMachine* dm = ...; // возможно вместо `...` здесь был `new CoffeeMachine()`
// ...
delete dm;
(или если у нас объект по типу std::unique_ptr - происходит то же самое)

Оператор delete обычно делает две вещи: вызывает деструктор и освобождает память:
dm->~DrinkMachine();
std::free(dm);

В этом случае важно чтобы вызвался именно деструктор реального объекта, т.е. возможно мы на самом деле хотели бы вызвать ~CoffeeMachine(). В этом случае нужен виртуальный деструктор - он будет лежать во vtable, и как метод будет находиться динамически.

Если второго случая в программе не бывает, то виртуальный деструктор не нужен - например в этой программе все работает без ошибок:
void MakeDrink(DrinkMachine& dm) {
dm.MakeDrink(); // вызов виртуального метода
}
void MakeCoffee() {
CoffeeMachine cm;
MakeDrink(cm);
// cm удалится сам через `cm.~CoffeeMachine()`
}

Это может быть важно для программ, которых нужно оптимизировать, потому что вызов виртуального метода (в т.ч. виртуального деструктора) дает оверхед в виде двух memory load.

P. S. Есть такой прикол как девиртуализация - когда компилятор сразу понимает какой метод нужно вызвать (не глядя во vtable). Но для этого компилятор должен доказать, что он точно знает, какой метод нужно вызвать. Хорошая статья на эту тему.

Девиртуализация не стандартизирована, поэтому нужно проверять на своем компиляторе самому - оптимизируется ли вызов виртуального деструктора в примере с MakeCoffee выше или нет. Если да - то можно забить и всегда делать виртуальный деструктор.
548 views16:31
Открыть/Комментировать
2022-11-21 10:01:42 #compiler #books

Обзор книги "Language Implementation Patterns" (2010 г.)

(можно посмотреть тут - https://pragprog.com/titles/tpdsl/language-implementation-patterns/)

После прохождения в университете стандартных условно "компиляторских" курсов ("формальные языки и грамматики" и "конструирование компиляторов"), я увидел что они слабо относятся к реальному дело.

Реальные компиляторы написаны не совсем так, как сказано в кондовых теоретических учебниках. Мне как будто не хватало какой-то информации - например, ни одна грамматика не разберет сходу, что означает запись T(i) в C++, так как для этого нужно знать что такое T и i, а грамматики так не смогут.

Книга Language Implementation Patterns супер информативная и содержит real-life теорию с кодом для реализации языковых тулз.
В ней разбираются всякие примеры как: интерпретатор байткода, статический анализатор кода, компилятор C/C++ (урезанный) и многое другое.

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

Паттерны разделены на 4 части: чтение ввода (I), анализ ввода (II), интерпретация ввода (III), генерация вывода (IV).
Самые простые приложения используют только I, сложные используют I+II+III или I+II+IV.
Для примера с моего старого поста (https://t.me/cxx95/40): часть I объясняет "лексический анализ", часть II "синтаксический анализ", часть IV "кодогенерацию".

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

Это экономит много времени, потому что у человекочитаемой грамматики (например в форме БНФ) могут быть супер неочевидные правила разбора, и надо самому вычислять множества FIRST и FOLLOW, а этому посвящается университетский курс... Генератор парсеров все делает за программиста по описанию грамматики.

Казалось бы - разве так много людей часто делают компилятор чего-либо? Но эта книга все равно будет полезна всем, кто делает свой DSL, тулзы для рефакторинга кода, форматирования, статического анализа, метрик.

Какие есть комментарии к книге:
Надо помнить, что разрабатываемый в книге примерный "компилятор для C++" делается для урезанного языка. В реальном мире анализатор для C++ это не LL(1) или LL(*), а скорее LL(k), где k - размер файла...
Весь код в книге написан на Java (ANTLR тоже на нем), возможно кому-то не понравится этот язык.
В одном из паттернов есть крохоборство для "буфера токенов" - прочитанные из файла токены выкидываются из памяти. Но даже в C++, где после препроцессора файлы могут быть в миллионы строк (и в N раз больше токенов) всё хранится в памяти.
В книге часто рекламируется генератор парсеров ANTLR, но для ряда сложных задач он выглядит трешово и намного более непонятным чем если бы это делалось в коде. Пример - когда "оптимизируем" код в языке для записи операция с векторами:
4 * [0, 5*0, 3] -> [4*0, 4*5*0, 4*3] -> [0, 0, 4*3]
предлагается сделать это прямо в конфиге ANTLR:
scalarVectorMult : ^('*' INT ^(VEC (e+=.)+)) -> ^(VEC ^('*' INT $e)+) ;
zeroX : ^('*' a=INT b=INT {$a.int==0}?) -> $a ; // 0*x -> 0
xZero : ^('*' a=INT b=INT {$b.int==0}?) -> $b ; // x*0 -> 0
В книге совсем не описана генерация в машинный код и сложные оптимизации. Это отдельная наука и большинству людей никогда не пригодятся эти знания. Но надо иметь в виду, что книга грубо говоря показывает как код на С++ переводится в LLVM IR.
Буквально на последней странице упоминается LLVM и рекомендуется его использование для компилируемых языков, так как там уже есть все для этого.
469 views07:01
Открыть/Комментировать