Грокаем C++
5.09K subscribers
7 photos
3 files
278 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Рабочий Double-Checked Locking Pattern
#опытным

Мы уже довольно много говорим о нем и его проблемах. Давайте же сегодня обсудим решение.

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

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

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance;
... // insert acquire memory barrier
if (tmp == NULL) {
Lock lock;
tmp = m_instance;
if (tmp == NULL) {
tmp = new Singleton;
... // insert release memory barrier
m_instance = tmp;
}
}
return tmp;
}


Вот как выглядела бы более менее работающая реализация паттерна блокировки с двойной проверкой до нашей эры(до С++11). Так как в то время в языке и стандартной библиотеке не было ничего, что связано с потоками, то для барьеров приходилось использовать platform-specific инструкции, часто с ассемблерными вставками.

Acquire барьер предотвращает переупорядочивание любого чтения, которое находится сверху от него, с любыми чтением/записью, которые следуют после барьера. Одна из проблем кода без барьеров: мы можем считать ненулевой указатель в tmp, но при этом результат операции инициализации объекта к нам еще не подтянется. Мы вернем из геттера неинициализированный указатель, что UB. Именно для предотвращения такого эффекта, в данном случае такой барьер нужен сверху для того, чтобы мы подтянули инициализированный объект из кэша другого ядра в случае, если мы все-таки считали ненулевой указатель.

Плюс он еще нужен, чтобы мы именно первой инструкцией считывали указатель и процессор не менял местами эту операцию со следующими. Может произойти так, что процессор поставит проверки всех условий перед записью указателя в tmp и это приведет к повторной инициализации синглтона.

Release барьер предотвращает переупорядочивание любого чтения/записи, которое находится сверху от него, с любой записью, которые следуют после барьера. Здесь также 2 составляющие. Первая: предотвращает переупорядочивание иницализации синглтона с присваиванием его указателя к m_instance. Это дает четкий порядок: в начале создаем объект, а потом m_instance указываем на него. Вторая гарантирует нам правильный порядок "отправки" изменений из текущего треда в точки назначения.

Объяснения не самые подробные и точные, но опять же, не было такой цели. Кто понимает - поймет, а кто не понимает - ждите статьи по модели памяти)

И вот как выглядела бы реализация этого паттерна на современном С++, если бы статические локальные переменные не гарантировали бы потокобезопасной инициализации:

std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}


Здесь мы только на всякий случай обернули указатель синглтона в атомик указатель, чтобы полностью быть так сказать в lock-free контексте. Барьеры на своих местах, а для залочивания мьютекса используем стандартный std::lock_guard с CTAD из 17-х плюсов.

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

Establish your barriers. Stay cool.

#concurrency #cpp11 #cpp17
Ассемблер инициализации статических локальных переменных
#опытным

Пример из предыдущего поста - рабочая версия паттерна. Однако, нам, вообще говоря, можно всего этого не писать. Ведь начиная с С++11 нам гарантируют тред-сэйф инициализацию статических локальных переменных и можно просто писать:

Singleton& Singleton::getInstance() {
static Singleton instance;
return instance;
}


Мы посмотрели, как вся защита может выглядеть на уровне С++ кода. Но в примере сверху никакой защиты на этом уровне нет. А это значит, что она лежит ниже, на уровне машинных инструкций. Которые мы можем с горем-пополам прочитать в виде ассемблера.

Сейчас будет очень страшно, но я попытался оставить самые важные куски и места и опустил неважное. Показываю ассемблер под x86-64, сгенерированный gcc.

Singleton::getInstance():
1 movzbl guard variable for Singleton::getInstance()::instance(%rip), %eax
2 testb %al, %al
3 je .L19
4 movl $Singleton::getInstance()::instance, %eax
5 ret
.L19:
...
6 call __cxa_guard_acquire
7 testl %eax, %eax
8 jne .L20
.L9:
9 movl $Singleton::getInstance()::instance, %eax
10 popq %rbx
11 ret
.L20:
12 movl $Singleton::getInstance()::instance, %esi
{Constructor}
13 movl $guard variable for Singleton::getInstance()::instance, %edi
14 call __cxa_guard_release
{safe instance and return}



Так как код оперирует объектом, а не указателем, то и в ассемблере это отражено. Но да не особо это важно. Сейчас все поймете. Для удобства обращения к коду, пометил строчки номерами.

Итак, мы входим в функцию. И тут же на первой строчке у нас появляется строжевая гвардия для переменной instance. Гвардия защищена барьером памяти и она показывает, инициализирована уже instance или нет. Так как мы без указателей, то вместо загрузки указателя и установки барьера памяти тут просто происходит загрузка гард-переменной для instance в регистр eax. Дальше на второй строчке мы проверяем, была ли инициализирована instance. al - это младший байт регистра eax. Соотвественно, если al - ноль, то инструкция testb выставляет zero-flag и в условном прыжке на 3-ей строчке мы прыгаем по метке. Если al - не ноль, то наш синглтон уже инициализирован и мы можем вернуть его из функции. Получается, что это наша первая проверка на ноль.

На метке .L19 мы берем лок с помощью вызова __cxa_guard_acquire, которая используется для залочивания мьютексов. И снова проверяем переменную-гард на пустоту(напоминаем себе, что она в eax загружена), если до сих пор она нулевая, то прыгаем в .L20. Если уже не ноль, то есть переменная инициализирована, то проваливаемся в .L9, где кладем созданную переменную в регистр возврата значения на 9-й строчке и выходим из функции(10 и 11). Это была вторая проверка

На метке .L20 мы на 12-й строчке кладем наш неинициализированный синглтон в регистр для последующей обработки, а именно для конструирования объекта. На 13-й строчке кладем адрес гарда в регистр, чтобы чуть позже записать туда ненулевое значение aka синглтон инициализирован. Далее мы отпускаем лок с помощью __cxa_guard_release, делаем все необходимые завершающие действия и выходим из функции.

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

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

Для пытливых читателей оставлю ссылочку на годболт с примером, чтобы желающие могли поиграться.

Dig deeper. Stay cool.

#concurrency #cppcore
Named Constructor Idiom

Конструкторы - вещь хорошая, но довольно ограниченная. Если вы хотите создать объект класса ТяжеленнаяФиговина, то логично было бы ввести разные конструкторы для разных систем измерения. Ну например, чтобы фиговина могла создаваться из киллограммов и из фунтов. Но это невозможно.

Дело в том, что конструктор класса - такая же функция, как и все остальные. У него есть имя(имя класса), список аргументов(включая неявный this) и пустое возвращаемое значение. В языке С++ конструкторы вообще ничего не возвращают, но они так или иначе реализованы на обычных ассемблерных функциях, а они имеют все эти обязательные характеристики.

А раз это функция и неизменяемым именем, то различать разные конструкторы мы можем только с помощью разного списка параметров.

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

Допустим, что с фиговиной помогут справиться strong typedefs. Но со сложностью различий конструкторов для пользователя они не помогут. А вот что может помочь.

Named Constructor Idiom. Давайте дадим имена конструкторам!

Точнее мы немного схитрим. Добавим именные статические функции-фабрики, которые и будут конструировать наши объекты, и переместим все конструкторы в private секцию.

Покажу на примере фиговины, чтобы было по-проще и по-короче

struct HeavyThing {
static HeavyThing ConstructFromKilos(float kilos) {
return HeavyThing(kilos);
}
static HeavyThing ConstructFromPounds(float pounds) {
return HeavyThing(0,453592 * pounds);
}
private:
HeavyThing(float kilos) : kilos_{kilos} {}

float kilos_;
};

int main() {
HeavyThing a = HeavyThing::ConstructFromKilos(100500.0);
HeavyThing b = HeavyThing::ConstructFromPounds(12345678.0);
}


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

Make convenient interfaces. Stay cool.

#design
Невероятные вероятности

Зачастую, когда мы пишем какие-то условия, то предполагаем, что какая-то ветка будет выполняться чаще другой. Самый простой пример - проверка чего-то на корректность. И если это что-то некорректно, то мы делаем какие-то действия, сигнализирующие о проблеме. И логично предположить, что наша программа хорошо написана (по крайней мере мы в это охотно верим). Поэтому ошибка - некая экстренная ситуация, которая не должна появляться часто. В принципе, любой не happy path может рассматриваться, как пример такой ситуации.

Может ли нам это знание как-то помочь? Вполне. В процессорах есть такой модуль - предсказатель переходов. На основе кода он по определенным эвристикам пытается понять, какая из веток выполниться с большей вероятностью. Он заранее подгружает данные и код для этой ветки, чтобы в случае удачного предсказания сократить время простоя вычислительного конвейера. И на самом деле, современные процессоры - настоящие Ванги! Их модуль предсказания переходов принимает правильные решения примерно в 90% случаев! Что не мало. Но все равно не идеально.

И вот тут-то мы и вступаем в дело. Мы можем немножко помочь предсказателю сделать более правильный выбор в конкретной ситуации. Путем указания ветки, которая по нашему мнению, будет выполняться с большей вероятностью.

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

Потому что в С++20 появились стандартные аттрибуты [[likely]] и [[unlikely]]!

Допустим, мы пишем свой вектор интов. Причины покататься на байсикле мы отбросим в сторону и сконцентрируемся на сути. И мы дошли до метода MyVector::at, который по индексу выдает элемент. Но фишка в том, что этот метод проверяет индекс на нахождение в границах дозволенного и кидает исключение, если нештатная ситуация все-таки произошла.

int MyVector::at(size_t index) {
if (index >= this->size) [[unlikely]] {
throw std::out_of_range ("MyVector index is out of range");
}
return this->data[index];
}


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

Ставьте лайки, если хотите немного бэнчмарков на эту тему. Если хотите что-то определенное померять(в пределах разумного времени написания поста), то пишите в комментах свои идеи.

Predict people's actions. Stay cool.

#cpp20 #compiler #performance
XOR Swap

Есть такая интересная техника для свопинга содержимого двух переменных без надобности во временной третьей! Стандартный подход выглядит примерно так:

template <class T>
void swap(T& lhs, T& rhs) {
T tmp = std::move(lhs);
lhs = std::move(rhs);
rhs = std::move(tmp);
}


Все мы с программистких пеленок уже выучили это. И примерно так и реализована функция std::swap из стандартной библиотеки. Однако вот вам задали на собесе вопрос: "вот у вас есть 2 числа, но я хочу, чтобы вы обменяли их значения без временной переменной?". Какие мысли? Подумайте пару секунд.

Как всегда, на помощь приходит магия математики и битовых операций. Можно использовать 3 подряд операции xor над этими числами и мы получим нужный результат.

template <class T, typename std::enable_if_t<std::is_integral_v<T>> = 0>
void swap(T& x, T& y) {
if (&x == &y)
return;
x = x ^ y;
y = x ^ y;
x = x ^ y;
}


Доказывать сие утверждения я, конечно, не буду. Однако можете почитать в английской вики, там все подробно выводится из свойств Исключающего ИЛИ.

Тут есть один интересный момент, что в случае подачи на вход функции одной и той же переменной, то произойдет эффект зануления. Первый же xor занулит x, а значит и y. Поэтому в самом начале стоит условие на проверку одинакового адреса.

При подаче на вход просто одинаковых значений, все работает и без условия.

Ну и работает это дело только с целочисленными параметрами.

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

Современные компиляторы вполне могут и соптимизировать третью переменную и вы ее вовсе не увидите в ассемблере. Да и еще и вариант с доп переменной тупо быстрее работает. Всего 2 store'а и 2 load'а, которые еще и распараллелить можно, против 3 затратных ксоров. Да и даже довольно тяжеловесная XCHG работает быстрее, чем 3 xor'а.

Зачем я это все рассказываю тогда, если эта техника уже никому не уперлась? Для ретроспективы событий. Дело в том, что раньше люди писали программы без компиляторов, напрямую на ассемблере. Плюс в то время компьютеры имели такое маленькое количество памяти, что биться приходилось буквально за каждый байт. А используя операции xor, мы экономим 33% памяти на эту операцию. Довольно неплохо. В стародавние времена как только не извращались люди, чтобы выжимать все из железа. Эх, были времена...

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

Learn technics from the past. Stay cool.

#cppcore #fun #algorithms
Задача

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

Формулировка такая: дан непустой массив интов. Каждый элемент массива встречается ровно 2 раза, кроме одного, который встречается 1 раз. Необходимо найти этот элемент.

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

Всем удачи и погнали решать!

Challenge your life. Stay cool.
Решение задачи

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

Можно чуть получше - догадаться, но в отсортированном массиве одинаковые элементы будут стоять рядом и нам нужно лишь найти тот, у которого нет пары. Время - O(nlogn), память - константная.

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

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

Математика!

Не зря вчерашний пост был про операцию xor. Это была подводка к этой задаче.

Очевидным свойством операции xor над числами - xor двух одинаковых чисел дает ноль. И ксор любого числа с нулем в результате будет давать то же самое число.

Представим себе, что числа в нашем массиве расположены определенным образом - в начале идут парные числа, а в самом конце - наше одинокое число.

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

3 3 2 2 7 7 5
int res = 0;
res ^= 3; // res == 3
res ^= 3; // res == 0
res ^= 2; // res == 2
res ^= 2; // res == 0
res ^= 7; // res == 7
res ^= 7; // res == 0
res ^= 5; // res == 5


по итогу результат будет равен последнему числу, то есть нужному нам ответу.

"Но это же фигня какая-то. Порядок совсем не такой! Он может быть любым!"

Все верно. Однако мы добавляем в этот коктейль пару щепоточек свойства коммутативности xor и пару столовых ложек его ассоциативности (aka от перемены мест слагаемых сумма не меняется) и получим вкуснейшее решение. Как угодно переставляйте местами числа в массиве - результат будет ровно тот же по свойствам операции xor.

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

-3 + 1 + 5 + (-4) + 3 + (-1) + (-5)


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

Ровно то же самое происходит и в нашей сегодняшней задаче.

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

Solve your problems. Stay cool.
Порядок вызовов конструкторов и деструкторов дочерних классов
#новичкам

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

Вот есть у вас какая-то иерархия классов.

struct Base1 {
Base1() {std::cout << "Base1" << std::endl;}
~Base1() {std::cout << "~Base1" << std::endl;}
};

struct Derived1 : Base1 {
Derived1() {std::cout << "Derived1" << std::endl;}
~Derived1() {std::cout << "~Derived1" << std::endl;}
};

struct Base2 {
Base2() {std::cout << "Base2" << std::endl;}
~Base2() {std::cout << "~Base2" << std::endl;}
};

struct Derived2 : Base2 {
Derived2() {std::cout << "Derived2" << std::endl;}
~Derived2() {std::cout << "~Derived2" << std::endl;}
};

struct MostDerived: Derived2, Derived1 {
MostDerived() {std::cout << "MostDerived" << std::endl;}
~MostDerived() {std::cout << "~MostDerived" << std::endl;}
};

MostDerived{};


Что мы увидим в консоли, если запустим этот код? Вот это:

Base2
Derived2
Base1
Derived1
MostDerived
~MostDerived
~Derived1
~Base1
~Derived2
~Base2


Мы имеем 2 невиртуальные ветки наследования в класса MostDerived. В самих классах могут быть виртуальные функции, это роли не играет. В этом случае правила конструирования объекта такое: переходим в самую левую ветку и вызываем поочереди констукторы базовых классов сверху вниз. Как только дошли до MostDerived, переходим вправо в следующую ветку и также вызываем конструкторы сверху вниз. И только после этого конструируем MostDerived.

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

А вот деструкторами все легко, если вы запомнили порядок вызовов конструкторов. Деструкторы выполняются в обратном порядке вызовов конструкторов соответствующих классов. Вы можете заметить, что вывод консоли из примера полностью симметричен относительно момента, когда объект уже создан.

Казалось бы, тривиальное знание для программиста. Но очень важно осознавать эти вещи для того, чтобы понять более сложные концепции или отвечать на более сложные вопросы. Например: "Зачем нужен виртуальный деструктор?", "В какой момент инициализируется указатель на виртуальную таблицу?", "Какой конкретно метод вызовется, если позвать виртуальный метод из констуруктора базового класса?". Без четкого понимания базы создания объектов, на эти вопросы конечно можно заучить ответы, но понимания никакого не будет. А программирование - это только про понимание, как и любая другая техническая дисциплина.

Поэтому

Understand essence of basic concepts. Stay cool.

#OOP #cppcore
Усложняем задачу

Пока мы далеко не уехали от прошлой #задачки, предлагаю порешать ее усложненную версию, предложенную нашим подписчиком Антоном в этом комменте. В чате было вялое обсуждение, но прям конкретного решения не было предложено. Да и с тех пор в чате примерно 2к сообщений написали(за 2 дня!), поэтому вы вряд ли что-то найдете) Кстати, пользуясь моментом, призываю вас вступить в наш чат https://t.me/+qJ8-vWd97nExZGIy. Там ребята постоянно обсуждают код, фреймворки, метапрограммирование. Да и вообще просто веселятся и играют в шахматы)

А условие обновленной задачи такое: дан непустой массив интов. Каждый элемент массива встречается ровно 2 раза, кроме ДВУХ, которые встречаются 1 раз. Необходимо найти эти элементы за линейную временную сложность и константные затраты по памяти.

Всего один аспект условия изменили, и как сразу непонятнее все стало)

Знающим решение - просьба не отбирать хлеб у пытливых умов. Вечером выйдет ответ.

Challenge your life. Stay cool.
Ответ

Опять же, откидываем все решения через сортировку и мапы - они не самые эффективные. Хоть и более "программистские" чтоли.
Вернемся к решению к ксором.

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

А никак! Но мы можем использовать эту информацию для нахождения нужных элементов. Для определенности представим, что наши искомые числа обозначаются, как a и b, a ^ b - полученный результат после первого прохода.

Так как для одинаковых чисел xor будет давать ноль, то для разных чисел он будет давать какой-то ненулевой результат. Ненулевой - значит в бинарном представлении это значение будет иметь хотя бы единичку в каком-то из битов. Именно в этом бите N и будут различаться наши числа. А раз мы знаем, в каком бите различаются числа, то во время второго прохода мы можем отдельно ксорить числа с выставленным N битом и с невыставленным. По условию, только два числа непарные, а все остальные - парные. Пары чисел будут попадать в одну из групп и при ксоре будут занулять друг друга. Тогда у нас будут две суммы по модулю 2, которые и будут равняться нашим искомым a и b.

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

Solve your problems. Stay cool.
Виртуальный деструктор
#новичкам

Возможно САМЫЙ популярный вопрос на собеседованиях на джунов и мидлов. Оно и справедливо в принципе: очень простой и понятный вопрос, но ответ на него требует хорошего уровня понимания ООП в принципе и как оно конкретно работает в плюсах. Динамический полиморфизм, наследование, порядок вызовов конструкторов и деструкторов - да, это база. Но именно ее и нужно проверить у начинающих и продолжающих разработчиков.

Обычно они заходят немного издалека и просят вангануть, что выведется в консоль для примерно такого кода:

struct Resource {
Resource() { std::cout << "Resourse has been acquired\n";}
~Resource() { std::cout << "Resource has been released\n";}
};

struct Base {
Base() { std::cout << "Base Constructor Called\n";}
~Base() { std::cout << "Base Destructor called\n";}
};

struct Derived1: Base {
Derived1() {
ptr = std::make_unique<Resource>();
std::cout << "Derived constructor called\n";
}
~Derived1() {std::cout << "Derived destructor called\n";}
private:
std::unique_ptr<Resource> ptr;
};

int main() {
Base *b = new Derived1();
delete b;
}


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

В коде вроде все хорошо написано и невнимательный кандидат может выдать вот это:

Base Constructor Called
Resourse has been acquired
Derived constructor called
Derived destructor called
Resource has been released
Base Destructor called


Вот тут-то его и подловили! На самом деле никакого деструктора наследника вызвано не будет и соответственно ресурсы не освободятся. Интервьюер дает наводку посмотреть на деструктор базового класса. И кандидат с красным лицом кричит: "Деструктор - невиртуальный! По указателю на базовый класс вызовется сразу деструктор базового класса, а деструктор дочернего не вызовется. Будет утечка памяти". Его так на курсах научили говорить. И дальше он выдает правильный вывод программы.

И тут интервьюер говорит: "А что будет, если наследник не будет содержать никаких полей? Какие проблемы будут у этого кода?".

И молодой разработчик в ступоре: он же знает, что невиртуальный деструктор приводит к утечкам. Но тут вроде как и утекать нечему. И говорит, что вроде как и проблем не будет.

Естественно, это неправда.

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

if the static type of the object to be deleted 
is different from its dynamic type, the static type
shall be a base class of the dynamic type of
the object to be deleted and the static type
shall have a virtual destructor or
the behavior is undefined.


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

Для корректного поведения полиморфных объектов и вызова деструктора дочернего класса вам обязательно нужен виртуальный деструктор базового класса.

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

Stay armed. Stay cool.

#cppcore #interview
Default member initializer
#новичкам

Представьте себе большой класс, определенный целиком в одном файле. Этак строк на 300-400. Обычно принято в таком порядке описывать класс: конструкторы, деструктор, методы и только потом поля. Вариации могут быть разными, но из моей практики одно остается неизменным: объявления конструктора и полей находятся в разных концах тела класса. И вот бывают случаи, когда при создании объекта какие-то поля получают свое значение не из внешних параметров, а какие-то заранее заданные. Дефолтовые.

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

Такие удобства появились у нас в C++11 - default member initializer. Это именно то, что и хотелось иметь в описанных выше ситуациях. Пример

template<typename T>
struct Stack {
// rule of 5
void push(const T& elem) {...}
void push(T&& elem) {...}
T& front() {...}
T& front() const {...}
void pop() {}
T GetMinElem() {...}
private:
std::deque<T> container;
T min_elem{Limit<T>::max_value};
}

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

Пример здесь сильно укороченный. Если реализовывать все по чесноку, то реализация такого шаблонного класса займет приличное количество места. Вариантов методов и конструкторов может быть миллион. И я не очень хочу в них возвращаться, чтобы узнать, какое изначальное состояние имеет поле min_elem. А здесь мы сразу видим: у пустого стека примем значение минимального элемента, как максимально возможное значение этого типа. Тогда при добавлении в стек первого элемента для обновления минимума мы можем пользоваться тем же условием, что и для добавления остальных элементов
if (new_elem <= min_elem)
min_elem = new_elem;

Limit<T> - шаблонный класс, который хранит максимальное и минимальное значение для заданного шаблонного типа. Это может быть реализовано как угодно: через явные специализации, через if constexpr и так далее. Шаблонная магия в общем. Кто хочет, опять же, может в комментах попрактиковаться в реализации этого класса.

Кто не знал - пользуйтесь, вещь полезная.

Stay useful. Stay cool.

#cpp11 #cppcore
Сочетание member initialization list и default member initializer
#опытным

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

Также в С++11 у нас появилась фича под названием default member initializer. Это та самая штуковина, которая позволяет вам инициализировать нестатические поля класса не в конструкторе, а прям inplace. Типа того:

struct Class {
int field = 5;
};


Фича полезная, многие ей часто пользуются. Но вот возникает вопрос: как список инициализации конструктора взаимодействует с default member initializer? Если я инициализирую поля вне конструктора и компилятор видит эти значения явным образом, то возможно эти поля и получают значение первыми? Сейчас все узнаем.

Посмотрим на такой пример:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
c{'3'},
e{'5'} {}
Char a;
Char b = '2';
Char c;
Char d = '4';
Char e;
};


Есть простенький класс Char, который выводит на консоль момент создания объекта. И тестовый класс, на котором мы и проводим эксперимент. И в этом эксперименте мы и проверим, в каком порядке свои значения получают поля b и d, относительно a, c, e.

На самом деле здесь правило ровно такое же. Нестатические поля класса инициализируются в порядке их появления в описании класса. Поэтому вывод будет таким:

Char 1
Char 2
Char 3
Char 4
Char 5


С этим разобрались.

И тут назревает вопрос: а что будет, если я в начале проициализирую поле inplace, а потом еще раз в constructor initializer list? Какая из инициализаций победит другую? Или быть может они произойдут обе в какой-то очередности?

Выглядеть это может так:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
b{'2'},
c{'3'},
d{'4'},
e{'5'} {}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};


Опять в подопытные мы взяли поля b и d и задали им значения с помощью default member initializer. А вдогонку еще и в списке инициализации присвоили им значение.

В такой ситуации default member initializer не играет никакой роли, блаженно складывает лапки и отдает бразды правления списку инициализации. Вывод будет тем же, что и в прошлом примере:

Char 1
Char 2
Char 3
Char 4
Char 5


Но это только список инициализации так работает. Если для инициализации поля вы используете обычный конструктор, то оно первый раз проинициализируется с помощью default member initializer(которая обязательно происходит до входа в тело конструктора), а второй раз - в теле конструктора.

struct TestClass {
TestClass() : a{'1'},
c{'3'},
d{'4'},
e{'5'} {b = '2';}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};
// Output

Char 1
Char b
Char 3
Char 4
Char 5
Char 2


Пишите в комменты, если есть еще какие-то интересные кейсы взаимодействия этих сущностей. В будущем, разберем их на канале.

Mix things properly. Stay cool.

#cpp11 #cppcore
​​Member initialization. Best practices
#новичкам

Пост по запросу подписчика. Вот его вопрос.

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

Здесь я буду приводить какое-то общие и распространенные принципы. К каждому можно придраться и сказать "а у нас в проекте по-другому!". Исключения и другие подходы есть везде. Если хотите высказать свои варианты - комменты открыты.

Начну с того, что нужно предпочитать инициализировать поля либо с помощью списка инициализации конструктора, либо с помощью default member initializer. Дело в том, что все поля на самом деле инициализируются до входа в конструктор! Если списком инициализации или default member initializer'ом не установлено, как поле должно инициализироваться, то в конструктор оно попадет инициализированным по умолчанию. Именно поэтому, например, не можете в конструкторе инициализировать объект класса, у которого нет конструктора по умолчанию. Будет ошибка компиляции и у вас потребуют дефолтный конструктор. Запомните: конструктор нужен для нетривиальных вещей. С простой иницализацией справятся ctor initialization list и инициализатор по умолчанию.

Далее. Остается 2 способа, как инициализировать. Какой из них выбрать и в какой пропорции смешивать?

CppCoreGuidelies говорят нам: "Prefer default member initializers to member initializers in constructors for constant initializers".

То есть, если инициализатор константный, то используйте default member initializer.

Причина: inplace инициализатор делает явным то, что именно эти дефолтовые значения будут использоваться во всех конструкторах. Пример:

class X { // BAD 
int i;
string s;
int j;
public:
X() :i{666}, s{"qqq"} { } // j is uninitialized
X(int ii) :i{ii} {} // s is "" and j is uninitialized
// ...
};


Как в этом случае читатель кода поймет, была ли инициализация j специально пропущена(что скорее всего не очень гуд) или было ли для s намеренным выставление его значения в "qqq" в первом случае и в пустую строку во втором случае(почти стопроцентный баг)? Все эти ошибки могут появиться при добавлении новых полей в класс. По классике: добавили новое поле, использовали его в методах, но вот в одном месте упустили инициализацию. Кейс настолько жизненный, что мое почтение.

Более адекватный вариант:
class X2 { 
int i {666};
string s {"qqq"};
int j {0};
public:
X2() = default; // all members are initialized to their defaults
X2(int ii) :i{ii} {} // s and j initialized to their defaults
// ...
};


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

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

Используйте default member initializer и будет вам счастье!

Stay happy. Stay cool.

#cpp11 #cppcore #goodpractice
​​Short circuit для кастомных операторов
#опытным

Есть одно важное уточнение, которое не было упомянуто в посте про short-circuit операторы, но несколько комментаторов на это указывали. Прочитайте, кстати, пост, если впервые видите словосочетание short circuit.

В плюсах короткосхемностью обладают операторы && и ||. Из коробки их операндами могут быть переменные логического, целочисленного и указательного типа. Однако они все так или иначе приводятся к типу булеан. Поэтому в принципе корректно говорить, что логические операторы работают только с логическими типами. Что в целом довольно логичная логика.

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

Взгляните на следующий код:


struct CustomStruct
{
int number = 0;
bool operator&&(const CustomStruct& other)
{
return number && other.number;
}
};

static int check = 0;

CustomStruct func()
{
check = 1;
return CustomStruct{};
}

int main() {
CustomStruct a{};
a && func();
std::cout << check << std::endl;
}


Здесь мы создаем самую простую структурку и перегружаем для нее оператор логического И. Дальше, чтобы проверить ленивость вычисления оператора, пишем простую функцию, которая при исполнении изменяет статическую переменную. Так мы сможем наверняка убедиться, выполнилась ли функция или нет: если выполнилась, то переменная check будет выставлена в единицу, если нет, то останется нулем.

И вывод будет реально "1". Что выглядит довольно печально.

Ну и кстати, такое поведение довольно легко объяснить. Когда мы перегружаем операторы, то мы создаем новые функции. И я хочу акцентировать на этом внимание: это именно пользовательские функции, как бы они там не назывались. А аргументы пользовательских функций должны быть вычислены ДО захода в функцию. Поэтому любые операнды должны быть полностью вычислены до вычисления значения всего выражения. Это и приводит к отсутствию свойства short circuit.

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

Встроеные же операторы реализованы на более низком уровне и не являются в прямом смысле функциями. И в эту реализацию изначально заложен короткосхемный функционал.

Однако есть способ разрешить ленивое вычисление логического И или ИЛИ для кастомных типов. Но об этом в следующий раз.

Don't loose your properties. Stay cool

#cppcore
Short circuit операторы для кастомных типов

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

Что вообще такое логическое И и что оно делает?(я говорю только про И для краткости, те же рассуждения применяются и к ИЛИ) Эта логическая функция aka коньюнкция. Она отображает множество {0, 1}^N в {0, 1}. То есть она принимает N аргументов, каждый из которых может иметь только в двух значений 0 или 1, и результатом ее работы тоже является одно из двух значений: 0 и 1. Результатом будет 0, если хотя бы один из аргументов имеем значение 0. В обратном случае, результатом будет 1.

Что это нам дает.

А то, что операндами по строгому математическому определению могут быть только булевые значения. То есть, когда вы делаете логическое И с любыми объектами, на самом деле вы не хотите перегружать этот оператор для работы со своими объектами. Вы хотите(может и не осознанно) ровно такую же логику работы, как и у встроенного оператора: приводить операнды к true или false на ходу. Потому что Логическое И работает с бинарными сущностями. Это просто из определения исходит, что операнды должны быть бинарными. Поэтому на самом деле нужно не перегружать оператор, а научить компилятор преобразовывать объект в тип bool. Тогда вы сможете насладиться всеми чудесами вычислений по короткой схеме.

Пример из прошлого поста можно переписать вот так:


struct CustomStruct
{
int number = 0;
explicit operator bool() const
{
return number;
}
};

static int check = 0;

CustomStruct func()
{
check = 1;
return CustomStruct{};
}

int main() {
CustomStruct a{};
a && func();
std::cout << check << std::endl;
}


Теперь мы научили компилятор преобразовывать объекты нашего кастомного класса в булы и вместо перегруженного оператора используем встроенный. И вуаля, вывод этого кода будет "0". То есть функция func не выполнилась, потому что результат выражения стал ясен после вычисления первого операнда и смысла от вычисления второго нет.

Вот так получается, что нет смысла перегружать операторы логического И и ИЛИ для кастомных объектов. На самом деле нужно перегрузить оператор приведения к булевому значению. И будет вам счастье.

Define things properly. Stay cool.

#cppcore
​​Ревью

Попробуем сегодня новую рубрику на канале - #ревью. Мы будем выкладывать коротенькие и не очень куски кода, а вы в комментариях попытаетесь найти все-превсе ошибки в нем. Ну и предложить улучшения конечно. Че хейтить код попросту?

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

Посмотрим, что из этого выйдет. Думаю, будет интересно)

Код на картинке под постом.

Analyse your life. Stay cool.
​​Результаты ревью

Как и обещали, публикуем самый подробный ответ. Автором оказался Михаил, давайте похлопаем ему👏👏👏👏.

Ну и коротенькое саммари из комментариев:

🔞 С пространством имен точно намудрили, так как есть типа нестандартный istream, который возвращает стандартный streampos. Если это работало, то где-то был using определен, но пацаны не решили, как они будут дальше писать - с std:: или без. Либо крестик снимите, либо трусы наденьте.

🔞 Если это стандартный стрим, то и размер у него должен быть соответствующего типа std::streamsize.

🔞 Можно использовать новые стандарты с их constexpr'ами и std::array'ями. C++11 уже с натяжкой можно назвать новым стандартом, поэтому в большинстве проектов он уже доступен.

🔞 Оч много вопросов по поводу типа uint32. Стандартный ли это тип или нет? Что там с его выравниванием? Зачем он так мимикрирует под стандартный? Видимо ребятам было лень приписывать "_t" и они объявили typedef...

Ну и самая мякотка и основной консёрн - чтение.

🔞 Всего одна наивная проверка на конец стрима, но нет никаких проверок на ошибки(failbit, badbit с помощью соответствующих методов istream или просто is.good()). Потенциально это может привести к бесконечному циклу, так как ошибка произойдет, стрим мы читать не сможем, а условие окончания не достигнуто.

🔞 Цикл довольно тяжело читать, лучше поменять его на for. Тогда будет более явным образом подсвечены начальное состояние, условие окончания и переходная операция.

Такая короткая функция, а вон сколько всего можно улучшить!

Ревью - очень важная часть разработки ПО. Его обязательно нужно в принудительном порядке проводить во всех проектах, чтобы они не скатились в лютое govniwe.

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

Analyser yourself. Stay cool.
Виртуальный деструктор и std::shared_ptr
#опытным

Плюсы - поистине удивительный язык. Вот подписчик изучил у нас на канале пользу виртуального деструктора и пошел в комментарии. А там Василий прислал пример, который говорит о том, что в определенном случае виртульность деструктора не важна и без него все работает корректно. И подписчик действительно удивляется: "What the fuck is going on?!?!?!?". Разберем все по порядку.

Пример вот такой:
struct Base {
~Base() {
std::cout << "Base::~Base()" << std::endl;
}
};

struct Derived : Base {
~Derived() {
std::cout << "Derived::~Derived()" << std::endl;
}
};

int main() {
std::shared_ptr<Base> p1 = std::make_shared<Derived>();
}


Прикол в том, что при удалении p1 вызовется деструктор наследованного класса:

Derived::~Derived()
Base::~Base()


Почему так?

Во время создания std::shared_ptr вы можете задать свой кастомный делитер
. Но даже если вы его не предоставили, делитер все равно создается. Просто компилятор сам выведет по его мнению подходящий удалятель. И сохранит его в контрол блок умного указателя.

Так вот логично, что, если мы создаем указатель от объекта тип Derived, то и делитер выбирается соотвествующий. И в контрол блоке правого шареда будет делитер, который удаляет Derived*. Далее при присваивании указатель на этот конкретный контрол блок копируется левому шареду. После этого контрольный блок p1 содержит тот самый изначальный делитер, который условно говоря сделает перед удалением указателя каст к классу наследника(delete static_cast<Derived*>(ptr)).

Именно поэтому и вызывается деструктор наследника.

Если мы попытаемся создать std::shared_ptr вот так:

std::shared_ptr<Base> shared(static_cast<Base*>(new Derived));


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

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

std::unique_ptr<Base> p1 = std::make_unique<Derived>();


для p1 не вызовется деструктор наследника, потому что делитер типа std::unique_ptr<Base> удаляет только указатели на базовый класс. Чтобы объект удалялся корректно, нужен виртуальный деструктор базового класса. Без него никак.

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

Stay amazed. Stay cool.

#cpp11 #cppcore
Директивы ifdef, ifndef, if
#новичкам

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

Этот способ - использование директив препроцессора #ifdef, #ifndef, #if. Все три - условные конструкции. Первая смотрит, определен ли в коде какой-то макрос. Если да, то делаем одни действия, если нет - другие. Второй наоборот, входит в первую ветку условия, если макрос не определен, и входит во вторую, если определен. Директива #if проверяет какое-то условие, ничего необычного. Все три директивы могут иметь как полные формы(с веткой в случае если условие ложно), так и неполные(без "else").

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

Например, у нас есть какой-то платформоспецифичный участок кода. Пусть это будет низкоуровневая оптимизация скалярного произведения на векторных инструкциях. Они разные для интеловских процессоров и для армов. Код может выглядеть примерно так:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == 0
// mmx|sse|avx code
#elif CPU_TYPE == 1
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}


Если каждое значение CPU_TYPE включает нужную ветку кода и убирает из текста программы все остальные.

Если мы хотим оптимизировать только под интеловские процессоры, то можем написать чуть проще:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#ifdef OPTIMIZATION_ON
// mmx|sse|avx code
#else
for (int i = 0; i < vec1.size(); ++i)
result += vec1[i] * vec2[i];
#endif
return result;
}


(Все примеры - учебные, все совпадения с реальным кодом - случайны, не повторяйте код в домашних условиях). Здесь мы проверяем директивой ifdef, определен ли макрос OPTIMIZATION_ON, сигнализирующий что нужно использовать векторные инструкции. Если да, то ключаем в текст программы оптимизированный код. Если нет - обычный.

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

Широко известно, что такой способ не только устарел, но еще и опасен. Завтра посмотрим, чем конкретно.

Choose the right path. Stay cool.

#compiler
Опасности использования директив препроцессора

Вчерашний способ выбора ветки кода имеет несколько недостатков:

⛔️ Препроцессор работает с буквами/текстом программы, но не понимает программных сущностей. Это значит, что типабезопасность уходит из окна, и открывается простор для разного рода трудноотловимых багов.

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

⛔️ Вы ограничены возможностями препроцессора. Это значит, что вы не можете использовать в условии compile-time вычисления (аля результат работы constexpr функции).

⛔️ Отсюда же вытекает отсутствие возможности проверки условий, основанных на шаблонных параметрах кода. Это все из-за того, что препроцессор работает до начала компиляции программы. Он в душе не знает, что вы вообще программу пишите. Ему в целом ничего не мешает обработать текст Войны и Мира. Именно из-за отсутствия понимания контекста программы, мы и не можем проверять условия, основанные на compile-time значениях или шаблонных параметрах. Если вы хотите проверить, указатель ли к вам пришел в функцию или нет, или собрать какую-то метрику с constexpr массива и на ее основе принять решение - у вас ничего не выйдет.

⛔️ Вы очень сильно ограничены возможностями препроцессора. Попробуйте например сравнить какой-нибудь макрос с фиксированной строкой. Спойлер: у вас скорее всего ничего не выйдет. Например, как в примере из поста выше мы не можем написать так:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == "INTEL"
// mmx|sse|avx code
#elif CPU_TYPE == "ARM"
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}

Поэтому и приходилось определять тип циферками.
Это конечно мем: сущность, которая работает с текстом программы, то есть со строками, не может работать со строками.

⛔️ С препроцессором в принципе опасно работать и еще труднее отлаживать магические баги. Могут возникнуть например вот такие трудноотловимые ошибки. Вам придется смотреть уже обработанную единицу трансляции, причем иногда даже не понимая, где может быть проблема. А со всеми включенными бинарниками и преобразованиями препроцессора это делать очень долго и больно. А потом оказывается, что какой-то умник заменил в макросах функцию DontWorryBeHappy на ILovePainGiveMeMore.

В комментах @xiran22 скидывал пример библиотечки, написанной с помощью макросов. Вот она, можете посмотреть. Это не только пример сложности понимания кода и всех проблем выше. Тут просто плохая архитектура, затыки которой решаются макросами.

Поделитесь в комментах своими интересными кейсами простреленных ступней из-за макросов.

Avoid dangerous tools. Stay cool.

#compiler #cppcore