Малоизвестные и интересные особенности C и C++

Kate

Administrator
Команда форума
В C и C++ есть особенности, о которых вас вряд ли спросят на собеседовании (вернее, не спросили бы до этого момента). Почему не спросят? Потому что такие аспекты имеют мало практического значения в повседневной работе или попросту малоизвестны.

Целью статьи является не освещение какой-то конкретной особенности языка или подготовка к собеседованиям, и уж тем более нет цели рассказать все потайные смыслы языка, т. к. для этого не хватит одной статьи и даже книги. Напротив, статья нужна для того, чтобы показать малоизвестные и странные решения, принятые в языках C и C++. Своего рода солянка из фактов. Вопрос “что делать с этими знаниями?” я оставляю читателю.

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

Начну я с простых, но не менее интересных особенностей языков C и C++, а точнее их различий.

Аргументы функций​

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

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

Рассмотрим следующий C-код:

#include <stdio.h>

void f() {
printf("f()");
}

int main() {
f();
return 0;
}
Сколько параметров принимает функция f()? Правильный ответ: “сколько угодно”. Потому что объявление void f() является эллипсисом в языке C (ellipsis notation; их также часто называют как variadic arguments, но в текущей статье я буду называть их именно эллипсисами чисто из-за синтаксиса; сами функции с variadic arguments называют variadic functions).

Т. е. следующий код абсолютно корректен и компилируется любым С-компилятором:

#include <stdio.h>

void f() {
printf("f()");
}

int main() {
f(5, 3.2f, "test");
return 0;
}
Чтобы указать компилятору языка C, что функция не принимает аргументов, нужно указать это явно с помощью аргумента void:

#include <stdio.h>

void f(void) {
printf("f()");
}

int main() {
f(); /* теперь мы не можем передать здесь ни одного параметра */
return 0;
}
Если вы когда-нибудь видели библиотеки C, у которых в публичном API писали в аргументах функциях void, теперь вы знаете почему.

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

Стоп, а как обстоят дела в C++? Безусловно в языке C это была ошибка проектирования. Поэтому в C++ пришлось идти на компромиссы, чтобы сохранить совместимость, но при этом не разрешать создавать неявных эллипсисов.

В результате в языке С++ это работает так:

void f1() { } // функция не принимает аргументов
void f2(void) { } // также ОК, функция не принимает аргументов
void f3(...) { } // сколько угодно агуметнов; эквивалент void f3() из языка C
Итак, подытожим в виде таблицы:

Объявление​
Значение в языке C​
Значение в языке C++​
void f()​
Эллипсис​
Не принимает аргументов​
void f(void)​
Не принимает аргументов​
Не принимает аргументов​
void f(int)​
Принимает один аргумент​
Принимает один аргумент​
void f(int, ...)​
Аргумент и эллипсис​
Аргумент и эллипсис​
void f(...)​
Эллипсис​
Эллипсис​
Я думаю, вы уже поняли почему в C++ сделали именно таким образом. Это позволило основной кодовой базе языка C без проблем компилироваться C++ компилятором, и при этом если там были эллипсисы, они становились функциями без аргументов. А случаев, когда неявные эллипсисы языка C реально использовались для передачи произвольного количества аргументов почти нет. Поэтому эта особенность языка почти никому не известна, т. к. не доставляла никому не удобств.

Кстати, обратите внимание, в языках C/C++ аргументы часто передаются справа налево по C-декларации, это как раз нужно для того, чтобы работали такие функции как printf(). Чтобы на вершине стека был параметр, по которому мы сможем определить сколько данных лежит еще на стеке. В данном случае в printf это будет строка с форматированием. Из этой строки мы знаем количество параметров, переданных после строки форматирования, их размерность и тип соответственно. Собственно, не удивительно что в С++ так ждали безопасные variadic templates времени компиляции (эллипсисами легко передать неверное количество аргументов и не тех типов).

Историческая справка: до появления стандарта ANSI C, был и альтернативный стиль объявления аргументов функций, т. н. стиль Кернигана и Ричи (стиль K&R). Пример кода (нужно компилировать C компилятором):

#include <stdio.h>

int max(a, b)
int a, b;
{
return a > b ? a : b;
}

int main() {
printf("%d", max(1, 3));
return 0;
}
Такой стиль аргументов функций до сих пор поддерживается всеми C компиляторами. Недостатком такого синтаксиса является то, что он не позволяет компилятору проверить соответствие типов аргументов при вызове функций.

Ну что ж, поговорим еще об эллипсисах на примере printf().

Printf()​

Рассмотрим код ниже:

#include <stdio.h>

int main() {
float v1;
double v2;
scanf("%f %lf", &v1, &v2);
printf("%f %f", v1, v2);
return 0;
}
Что мы здесь видим? Функция scanf считывает два значения типа float (4 байта) и double (8 байт), мы передаем их адреса в эллипсис. В строке мы явно указываем %f и %lf (обратите внимание на букву l перед f). Первый формат %f указывает, что нужно по адресу первой переменной положить 4 байта, а во второй, что нужно распарсить значение с плавающей точкой с двойной точностью и положить результат по адресу в 8 байтовое значение.

Смотрим на строку форматирования printf() и что мы видим %f и %f. Как функция printf() смогла “понять” сколько байт нужно считать со стека для v1 (4 байта) и v2 (8 байт)? Это хороший вопрос, потому что мы затолкали в стек (я говорю на “стеке” условно, потому что компиляторы в реальности редко предают аргументы через стек, но об этом ниже) сначала 8 байт, потом 4 байта, а затем строку с форматированием. Но из строки форматирования мы видим, что в стеке должны идти два аргумента с одинаковым размером друг за другом. Программа, если и не получит ошибку сегментации памяти, то как минимум выдаст некорректный результат. Но как мы знаем вывод такой программы будет корректный.

Ответ очень прост, язык C строго говоря никогда не был строго-типизированным и таковым не является и сейчас (как и C++). Язык C делает неявные преобразования, где только может и хочет. И не всегда точно можно сказать, где один тип приводится в другой. Мы увидим это в статье еще ни раз.

Итак, если параметры передаются в эллипсис там работает особое правило неявных преобразований. Например, если в эллипсис передается 3.2f (это float), он будет неявно преобразован в 3.2 (т.е. double), это справедливо и для других типов, таких как целочисленные типы. В scanf тоже есть эти неявные преобразования, потому что это тоже эллипсис (указатель так и останется указателем, если что).

При передаче аргументов в variadic function применяются следующие правила неявных приведений типов аргументов (правило default argument promotions):

  • аргументы типа char или short приводятся к типу int;
  • аргументы типа float приводятся к типу double;
  • аргументы с типами int, long, long long и long double остаются самими собой.
Это значит, что если я передам в printf("%d", (short) 4), аргумент типа short будет неявно приведен к int. Если я сделаю printf("%lld", 4LL), то long long так и останется long long.

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

Кстати, вот язык ассемблера примера выше который можно получить:

cvtss2sd xmm2, DWORD PTR [rbp-4]
movq rax, xmm2
movsd xmm0, QWORD PTR [rbp-16]
movapd xmm1, xmm0
movq xmm0, rax
mov edi, OFFSET FLAT:.LC0
mov eax, 2
call printf
В данном случае cvtss2sd конвертирует float в double и в функцию printf передаются уже два 8-ми байтовых числа с плавающей точкой.

Также обратите внимание, что для вызова был использован не стек, а регистры. Дело в то, что существуют различные т. н. calling convensions (соглашения о вызовах функций). Эти соглашения нужны, чтобы иметь бинарную совместимость для вызова подпрограмм уже откомпилированного кода, да и вообще систематизации вызова функций. В конвенцию включается по крайней мере: направления заталкивания аргументов и способ их передачи (стек и/или регистры); кто очищает стек (делает смещение адреса в стековом регистре) — вызывающая или вызываемая подпрограмма; а также символ имени для связывания. Существуют распространённые конвенции:

  • cdecl — C стиль вызова функций, параметры толкаются справа налево, стек очищает вызывающая подпрограмма, чтобы можно было реализовать работу этих эллипсисов, поэтому не удивительно, что адрес стека смещает вызывающая подпрограмма; был изобретен изначально специально для компилятора Microsoft для архитектуры x86, а не специально для C как могло показаться из названия;
  • stdcall — параметры толкаются справа налево, адрес стека смещает вызываемая подпрограмма;
  • fastcall — первые n-аргументов передаются через регистры общего назначения процессора, оставшиеся аргументы толкаются в стек справа-налево (количество параметров, передаваемых через регистры и используемые регистры зависят от платформы и компилятора).
Есть и другие соглашения, а также их вариации. В примере выше сборка происходила под архитектуру x64. В двоичном интерфейсе приложений (ABI) x64 по умолчанию используется четырехрегистровое n-регистровое соглашение о вызове, т. н. x64 calling convention (как указал @redfox0 количество регистров зависит от платформы, за конкретными цифрами лучше обращаться к спецификациям тех или иных платформ). Это соглашение также совместимо с функциями, принимающие переменное количество аргументов. Но что хочу сказать. В опциях компиляции можно указать любое соглашение или выбрать соглашение для конкретной функции такими ключевыми словами как __stdcall, __cdecl и прочими, но на практике компилятор может игнорировать ваши желания, так для архитектуры x64 ясное дело __stdcall и __cdecl будут проигнорированы.

Можно идти дальше. В языке C помимо printf() есть и другие места, когда сложно сказать, что за тип у того или иного выражения.

Тип символа​

Всем известно, что тип символа такого как 'a' в C++ является тип char. Но это не так в языке C. Рассмотрим следующий стохастический пример кода:

#include <stdio.h>

int main() {
if (sizeof('a') == sizeof(int)) {
printf("C");
} else {
printf("C++");
}
return 0;
}
В зависимости от того компилируете ли вы его C или C++ компилятором, результат будет разный (при условии, что на конечной платформе sizeof(char) != sizeof(int)).

Дело в том, что в C тип символьного литерала имеет тип int, а не char. Поэтому такие функции стандартной библиотеки языка C как char *strchr( const char *str, int ch ) принимают int, а не char в аргументе ch.

В стандарте языка C тип символьной константы определяется в разделе 6.4.4.4 Character constants. В этом разделе говорится, что символьная константа имеет тип int и представляет собой последовательность одного или более символов, заключенных в одинарные кавычки. Каждый символ в символьной константе интерпретируется как целое число, соответствующее его коду в используемой кодировке. Например, символьная константа 'a' имеет значение 97, если используется кодировка ASCII.

В языке C++ символьные литералы стали типом char для более корректной перегрузки функций. Вот вырезка из стандарта языка C++ из приложения по совместимости с языком C раздела “lexical conventions”:

Change: Type of character-literal is changed from int to char.
Rationale: This is needed for improved overloaded function argument type matching. For example:
int function( int i );
int function( char c );
function( 'x' );
It is preferable that this call match the second version of function rather than the first.
Effect on original feature: Change to semantics of well-defined feature. ISO C programs which depend on
sizeof('x') == sizeof(int)
will not work the same as C++ programs.
Difficulty of converting: Simple.
How widely used: Programs which depend upon sizeof('x') are probably rare.
Дословный перевод
Изменение: тип символьного литерала изменен с int на char.
Обоснование: это необходимо для улучшения сопоставления типов аргументов перегруженной функции. Например:
int function( int i );

int function( char c );
function( 'x' );
Предпочтительно, чтобы этот вызов соответствовал второй версии функции, а не первой.
Влияние на исходный объект: изменение семантики четко определенного объекта. Программы ISO C, которые зависят от
sizeof('x') == sizeof(int)
не будет работать так же, как программы на C++.
Сложность конвертации: Просто.
Насколько широко используется: Программы, зависящие от sizeof('x'), вероятно, редки.
А что на счет строковых литералов, например "string literal"? Это будет char*. Прочитав, комментарий @kotlomoy и увидев его верное замечание, я понял, что необходимо сделать уточнение, с которым можно ознакомиться в спойлере:

Более подробное разъяснение по строковым литералам








С типами что в C, что в С++ очень все интересно. И заодно сложно. Мало того, что размер целочисленных типов зависит от конечной платформы и компилятора (но тем не менее стандарт гарантирует минимальные диапазоны и что short <= int <= long; это означает, что тип short не может быть больше, чем тип int, а тип int не может быть больше, чем тип long), так еще имеет кучу синонимов для сокращенного написания. Например, long int — это синоним для long; long int также может явно использоваться с модификатором signed как signed long int. Логика я думаю здесь ясна.

Для signed int допустимо опустить сам базовый тип int и оставить только модификатор signed. В данном случае я опустил ключевое слово int:

#include <stdio.h>

int main() {
signed value = 4;
printf("%d", value);
return 0;
}
Целые числа, если явно не указано иное, являются знаковыми, т.е. signed. А char является signed или unsigned?

Ответ очень прост: char, signed char и unsigned char — это три разных типа. Но при этом стандарт не запрещает, чтобы char был псевдонимом либо signed char, либо unsigned char. Здесь главное — не запутаться. И лучше всего относиться к char как к отдельному типу. Даже несмотря на то, что в большинстве случаев он является псевдонимом. Если вы писали кроссплатформенный код, вы наверняка замечали, что под Windows компилятор MSVC считает, что char — это unsigned char, а под Linux с компилятором gcc char — это signed char. Как верно заметил @chnav, здесь у меня перепутаны модификаторы для char, правильно так: Windows компилятор MSVC считает, что char — это signed char по умолчанию, а под Linux с компилятором gcc знак char — зависит от платформы под которую идет сборка. В свою очередь @geher заметил, что знак явно можно настроить с опциями компилятора: MSVC — это параметр /J, а gcc/clang — это соответственно параметры -fsigned-char и -funsigned-char. Поэтому в коде не стоит делать предположений касательно знака типа char.

Кстати, ответьте на вопросы:

  1. Какой размер у енамов в C и C++?
  2. Какой размер будет иметь абсолютно пустая структура в C++, имею в виду если в ней не будет ни одного поля (и не будет виртуальной таблицы естественно)? Например sizeof для: struct Empty {}. А также аналогичный вопрос для языка C;
  3. Как измениться размер структуры, если унаследовать ее от пустой структуры и добавить одно поле int64_t, какой результат sizeof мы увидим? Например для: struct Derived : public Empty { int64_t value; };
Ну что ж, перейдём к более сложным темам и уже касательно только C++.

Sized deallocation functions​

Малоизвестная фича языка C++. Самое интересное это не то, что она есть как таковая или зачем она нужна, а как она работает.

Рассмотрим следующий код:

#include <iostream>
#include <cstdint>

struct Base {
int64_t value1;

virtual ~Base() = default;

virtual void f() {}

void* operator new(std::size_t size) {
auto result = malloc(size);
return result
? result
: throw std::bad_alloc{};
}

void operator delete(void* ptr, std::size_t size) noexcept {
std::cout << "free: " << size << std::endl;
free(ptr);
}
};

struct Derived : public Base {
int64_t value2;
};

void deleteObj(Base* base) {
delete base;
}

int main() {
deleteObj(new Base());
deleteObj(new Derived());
return 0;
}
Вывод программы будет следующим:

free: 16
free: 24
Я не стал реализовывать конструкторы и деструкторы, чтобы там вывести названия вызываемых конструкторов и деструкторов. Я надеюсь, вы и так хорошо понимает наследование и полиморфизм, поэтому не буду на этом останавливаться.

Вывод очевиден: sizeof(Base) == 16 — это 8 байт на поле value1 и еще 8 байт указатель на виртуальную таблицу для этого типа; sizeof(Derived) == 24, потому что он добавляет еще 8 байт полем value2 к уже имеющимся 16 байтам.

Пробежимся по коду. Мы распределяем два экземпляра на куче, затем передаем указатели на эти экземпляры функции deleteObj(), которая принимает указатель на базовый класс Base. В этой функции мы делаем delete переданного экземпляра.

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

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

free: 16
free: 16
Мы потеряли верный размер. Как это работает? На самом деле на практике, компиляторы всегда делают это с помощью деструктора, так как это самое удобное место. При том не важно какой это деструктор: по умолчанию или вы реализовали собственный, или переопределили базовый, или даже не переопределяли базовый. Компилятор всегда добавит код, передающий размера класса, если используется версия оператора удаления с перегрузкой sized deallocation. Т. е. допустим у нас есть код:

struct Derived : public Base {
int64_t value2;

~Derived() override {
std::cout << "~Derived()" << std::endl;
}
};
Компилятор на самом деле может сделать так (дизассемблер показывает, что именно так делают gcc и clang):

struct Derived : public Base {
int64_t value2;

~Derived() override {
std::cout << "~Derived()" << std::endl;
Base::eek:perator delete((void*) this, sizeof(Derived));
}
}
Для базового класса он сделал то же самое, но для типа Base. Кстати, если не переопределять деструктор ~Derived(), компилятор все равно его неявно переопределит, чтобы передать sizeof(Derived).

Дизассемблированный код деструктора, который создал компилятор для класса Derived

Поэтому при удалении экземпляров у программы во время выполнения всегда есть информация о размере этих экземпляров. Это малополезная возможность, но тем не менее библиотеки которые реализуют свой менеджмент памяти (перегружая базовые операторы по работе с памятью) или который следят за утечками памяти, или реализуют какой-то пул объектов могут использовать эту возможность для подсчета выделенной и освобожденной памяти.
Про оптимизирующий компилятор
С включенной оптимизаций Clang спокойно смог оптимизировать этот код, встроив все функции в тело main. Из-за этого можно обойтись без полиморфизма, также компилятор убрал вызовы конструкторов и деструкторов и тут же вызвал оператор удаления для этих экземпляров с нужным размером. Т.е. он смог проследить за размером экземпляров во время компиляции:
main:
push rax
mov edi, 1
mov esi, 16
call calloc@PLT
test rax, rax
je .LBB1_3
lea rcx, [rip + vtable for Base+16]
mov qword ptr [rax], rcx
mov esi, 16
mov rdi, rax
call Base::eek:perator delete(void*, unsigned long)
mov edi, 1
mov esi, 24
call calloc@PLT
test rax, rax
je .LBB1_3
lea rcx, [rip + vtable for Derived+16]
mov qword ptr [rax], rcx
mov esi, 24
mov rdi, rax
call Base::eek:perator delete(void*, unsigned long)
xor eax, eax
pop rcx
ret
Еще одно замечание по поводу sized deallocation function. Как можно заметить в примере используется пользовательский оператор удаления для класса Base. И все наследники Base будут использовать его. Но с глобальными операторами удаления sized deallocation function все несколько сложнее. Их поддержка появилась только в C++ 14. Не все компиляторы используют их по умолчанию и это значит, что для полиморфных экземпляров будет вызываться unsized deallocation function. Для включения фичи в clang нужно добавить флаг: -fsized-deallocation. В компиляторах MSVC это поведение по умолчанию начиная с Visual Studio 2015. В gcc это также поведение по умолчанию, если сборка идет для стандарта C++ 14 и выше. Если выполнены эти условия по версиям и настройкам компиляторов, то и для глобальных операторов delete будет вызываться sized deallocation function версия оператора и передаваться в него правильные размеры экземпляров полиморфных типов при удалении.
Можете проверить это на своем компиляторе
#include <iostream>
#include <cstdint>

struct Base {
int64_t value1;

virtual ~Base() = default;

virtual void f() {}
};

void operator delete(void* ptr, std::size_t size) noexcept {
// std::cout не будет работать здесь в MSVC-компиляторе
printf("Global sized delete for object of size %d\n", int(size));
std::free(ptr);
}

void operator delete(void* ptr) noexcept {
// std::cout не будет работать здесь в MSVC-компиляторе
printf("Global unsized delete\n");
std::free(ptr);
}

struct Derived : public Base {
int64_t value2;
};

void deleteObj(Base* base) {
delete base;
}

int main() {
Base* b = new Base;
deleteObj(b);

Base* d = new Derived;
deleteObj(d);

return 0;
}
Раз уж заговорили про конструкторы и деструкторы посмотрим на них пристальнее.

Исключения в конструкторах и деструкторах​

На собеседованиях иногда проскакивает эта особенность языка, но не специально, обычно хотят проверить как вы понимаете проблемы с утечками памяти.
Вы, наверное, сами сталкивались с такими вопросами неоднократно. Или же сами спрашивали его на собеседованиях. Задача состоит в том, чтобы найти ошибки, в частности утечки памяти. И вы их находите. Но как правило задачи такого типа — это задачи на внимательность и с какой-то изюминкой/хитростью. Без всякой мишуры такой код может выглядеть следующим образом, в котором я оставил только утечку памяти из-за необработанного исключения в деструкторе:
#include <iostream>

class Class {
public:
~Class() {
// здесь код, который может привести к исключению;
// я упрощу и сразу брошу исключение, который обычно
// якобы может развернуть стек
throw std::runtime_error("it's actually impossible to catch");
}
};

void test(int count) {
int* ptr = new int[100];

for (int i = 0; i < count; ++i) {
Class c;
// ...
}

// якобы здесь утечка памяти, т. к. по правилам RAII во время уничтожения
// Class, в деструкторе может возникнуть исключение; а т. к.
// у нас нет try-catch блока мы не дойдем до delete[] ptr
delete[] ptr;
}
Как сказано в комментариях к коду, здесь не было перехвачено исключение, что приведет к утечке памяти. На собеседовании, надеюсь, вы скорее всего сказали бы: “так нельзя”. И были бы правы, потому что в C++ есть RAII и нужно понимать, как он работает. Здесь нет утечки памяти, это просто на просто некорректный код. Я бы не стал об этом подробно расписывать, если бы сам не сталкивался с этим несколько раз. Но удивляет больше другое, хотя возможно это и оценочное суждение, сами интервьюеры никогда по всей видимости даже просто ради интереса не пытались бросить исключение из деструктора и проверить работает ли это вообще, а просто слепо уверовали в то, что исключения, выброшенные из деструктора, можно корректно обработать.
И здесь мне придется более подробно остановиться на терминологии, а также объяснить, как работают исключения в связке с RAII.
Если вы не знакомы с термином Undefined Behavior, сокращенно UB, то это такое неопределенное поведение, вызванное некорректным кодом, который нарушает правила языка, хотя является синтаксически верным, но не семантически. Что приводит к непредсказуемому поведению программы во время выполнения. Примеры такого кода — это некорректная работа с потоками, чтение или запись за границы массива и даже различие в реализациях стандарта компиляторами (или даже противоречащие стандарту). Все эти примеры — это синтаксически верный код, и он компилируется, но все это логические ошибки, которые могут привести к разным результатам. Что будет если сделать запись за границы массива? Зависит от компилятора и конечной платформы. Компиляторы не обязаны диагностировать UB, выдавая warnings или errors во время компиляции (зачастую это и невозможно сделать путем анализа кода), но для простых случаев компиляторы постараются выдать warnings.
Рассмотрим UB код, чтобы понять проблему:
int main() {
int i = 5;
short* ptr = (short*) &i;
return 0;
}
Да, здесь есть ошибка. Компилятору на серьезных щах разрешено насмерть убить код выше, потому нельзя кастить указатель int к указателю short по правилу strict aliasing. Стандарт это регламентирует как UB и реализация по большому счету отдается на откуп компилятору, т.е. сам программист должен понимать, что он осознанно написал некорректный код. И знает, что для конечной платформы, используемых компиляторов и флагов компиляции это будет работать. А поведение может различаться от компилятору к компилятору. В данном коде проблема заключается в том, что по правилам strict aliasing псевдонимы разных типов не могут указывать на пересекающийся участок памяти, а значит у компилятора есть возможность оптимизировать этот код. Например, переставив операции чтения и записи по работе с указателем ptr до инициализации i.
Хорошо. Пришло время разобраться с исключениями в деструкторах. Основная сложность заключается в том, что это поведение хоть совсем немного, но изменялась от стандарта к стандарту. До “современно” C++, когда не было noexcept-ов, в подразделе стандарта 15.3 Constructors and destructors раздела Exception handling сказано:
If a destructor called during stack unwinding exits with an exception, std::terminate is called (15.5.1). So destructors should generally catch exceptions and not let them propagate out of the destructor
Т.е. это даже не UB, ибо поведение стандартом строго определено: если деструктор завершается выбросом исключения при уничтожении экземпляра с помощью RAII, то исключение перехвачено быть не может, за место этого вызывается функция std::terminate, которая по умолчанию прерывает выполнение программы.
В актуальном стандарте этот раздел находится в другом месте, но с тем же названием. Ищите его под номером 14.3. Переводя на человеческий язык в нем сказано, что деструкторы по умолчанию являются noexcept(true). Это значит, что стек развернут быть не может при выходе исключения из деструктора, что в свою очередь приводит нас всё к тому же поведению что и в “старом” C++. Я подчеркну, даже если вы пишете свой деструктор, явно не помечая его noexcept, он все равно будет noexcept(true), почти все остальные функции-члены класса, если явно не указано другое являются noexcept(false).
И так как в “современном” C++ Вас никто не может остановить пометить деструктор как noexcept(false) и бросить из него исключение, оно в некоторых случаях все же может быть перехвачено, что однозначно приведет к UB, в том числе также может привести к падению процесса.
Звучит сложно, и я не хотел в этой статье слишком часто ссылаться на стандарт, но это нужно для понимания дальнейших примеров. Чтобы понять почему стандарт не разрешает бросать исключения, есть логическое объяснение.
Как вы уже поняли, код от интервьюера полностью некорректный. Добавим в него try-catch блок и проверим, что произойдет:
Версия с try-catch
#include <iostream>

class Class {
public:
~Class() {
// здесь код, который может привести к исключению;
// я упрощу и сразу брошу исключение, который обычно
// якобы может развернуть стек
throw std::runtime_error("it's actually impossible to catch");
}
};

void test(int count) {
int* ptr = new int[100];

try {
for (int i = 0; i < count; ++i) {
Class c;
// ...
}
} catch (const std::exception& exc) {
std::cout << "Error: " << exc.what() << std::endl;
} catch (...) {
std::cout << "Unknown error" << std::endl;
}

// якобы теперь ОК
delete[] ptr;
}

int main() {
test(2);
return 0;
}
Естественно, процесс просто упадет с SIGSEGV или любой другой проблемой. Исключение перехвачено не будет.
Но это и не удивительно, т.к. в C++ есть RAII, а он гарантирует (!), что экземпляры классов будут уничтожены в обратном порядке их создания. Если разрешить бросать из деструкторов исключения, RAII реализовать будет невозможно. Надо немного порефликсировать над этим тезисом и сразу станет ясно почему так.
Если не получается, взглянем на такой код:
#include <iostream>

struct A final {
A() {
std::cout << "A::A()" << std::endl;
}

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

void f() {
std::cout << "A::f()" << std::endl;
throw std::bad_alloc{};
}
};

struct B final {
B() {
std::cout << "B::B()" << std::endl;
}

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

int main() {
try {
A a;
B b;

a.f();
std::cout << "unreachable code" << std::endl;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Вывод этой программы, следующий:
A::A()
B::B()
A::f()
B::~B()
A::~A()
error: std::bad_alloc
Это тот результат, который мы и ожидали увидеть. Сначала был создан экземпляр класса A, затем B. Потом мы вызвали функцию-член A::f() из которой было брошено исключение. Когда бросается исключение стек вызовов должен развернуться, но при этом RAII гарантирует, что все экземпляры будут уничтожены в обратном порядке их создания. Что мы и видим: деструкторы были вызваны, а исключение перехвачено.
Вот теперь подумаем, что будет если допустим деструктор класса B бросит исключение в данном примере. Что мы ожидаем? Мы сначала выполним A::f() из которого выбросится исключение std::bad_alloc, по правилам RAII будут вызваны деструкторы B::~B(), а затем A::~A(). Но B::~B() сам бросает исключение. Соотвственно A::~A() не будет вызван, а исключение std::bad_alloc из функции члена A::f() вообще будет потеряно! Потому что из деструктора полетело другое исключение.
Проверим это на практике:
#include <iostream>

struct A final {
A() {
std::cout << "A::A()" << std::endl;
}

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

void f() {
std::cout << "A::f()" << std::endl;
throw std::bad_alloc{};
}
};

struct B final {
B() {
std::cout << "B::B()" << std::endl;
}

~B() {
std::cout << "B::~B()" << std::endl;
throw std::runtime_error("it's actually impossible to catch");
}
};

int main() {
try {
A a;
B b;

a.f();
std::cout << "unreachable code" << std::endl;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Процесс упал. А деструктор для экземпляра A даже не был вызван.
Это простой пример. Можно сказать: “компилятору стоило бы вызов деструктора в кэтч обернуть неявно и там сделать магию какую-то” или т.п. Но вы должны понимать, что у класса могут быть поля, не являющиеся простыми типами, у тех свои поля и т.д., в качестве локальных переменных могут выступать непростые типы. Вы так или иначе попадете в патовую ситуацию, где RAII не будет работать, если разрешить бросать из деструкторов исключения. Поэтому RAII запрещает их оттуда бросать.
Давайте теперь пометим деструктор как noexcept(false), чтобы иметь возможность развернуть стек. И посмотрим, что произойдет:
Тот же пример, но с noexcept(false)
#include <iostream>

struct A final {
A() {
std::cout << "A::A()" << std::endl;
}

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

void f() {
std::cout << "A::f()" << std::endl;
throw std::bad_alloc{};
}
};

struct B final {
B() {
std::cout << "B::B()" << std::endl;
}

~B() noexcept(false) {
std::cout << "B::~B()" << std::endl;
throw std::runtime_error("it's actually impossible to catch");
}
};

int main() {
try {
A a;
B b;

a.f();
std::cout << "unreachable code" << std::endl;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Процесс также упал с ошибкой SIGSEGV. Это произошло потому, что несмотря на то, что мы можем развернуть стек в деструкторе, у нас в это время уже разворачивался стек с другим исключением, которое “нельзя молча забыть” и компилятор вполне очевидно для такого случая создал код убивающий процесс. Если убрать исключение из функции-члена A::f(), то программа скорее всего отработает успешно и даже будет перехвачено исключение std::runtime_error. Но это даже хуже, чем крушение процесса, т.к. мы не можем гарантировать, что очистка ресурсов произошла корректно.
А теперь несколько замечаний:
  • Перехватывать и обрабатывать исключения непосредственно в самом теле деструктора, конечно же можно, главное, чтобы оно не вышло за него.
  • Сам по себе деструктор такая же функция-член, как и другие функции-члены. Технически, с какими-то оговорками и noexcept(false), вы можете вызывать его явно (как и любую функцию-член), и перехватить из него исключение. И даже размещать такие объекты (placement new) в памяти и уничтожать их. В этих и любых других случаях ручного управления памятью, где не задействуется RAII, исключение из деструктора реально перехватить. Но вы не должны предполагать где и как будет использоваться объект и оправдывать себя: “вот здесь могу из деструктора бросить исключение”.
На самом деле не существует случаев, когда вообще потребовалось бы из деструктора распространить исключение, это всегда ошибка проектирования кода.
Хорошо, с деструкторами разобрались, а как на счет конструктора? Что будет, если из конструктора будет брошено исключение? Для наглядности можете опираться на этот код и подумать, что произойдет и является ли это поведение определенным:
#include <iostream>

struct A final {
int* ptr1;
int* ptr2;

A() {
auto size = 0x7fffffff;
std::cout << "A::A()" << std::endl;
ptr1 = new int[5];
ptr2 = new int[size];
}

~A() {
std::cout << "A::~A()" << std::endl;
delete[] ptr2;
delete[] ptr1;
}
};

int main() {
try {
A a;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Ну а мы движемся дальше, раз мы начали обсуждать исключения, поговорим и о них. Как известно исключения в языке C++ были не всегда (они появились только с принятием первого стандарта языка в 1989 году ANSI C++; да, язык существовал без стандарта, не стоит этому удивляться, множество современных языков мантейнтся частными кампаниями и так же не имеют стандартов). Сегодня в C++ компиляторах есть возможность полностью отключить исключения (clang, gcc: -fno-exceptions; MSVC: /EHsc). Вы наверняка в курсе этого, если вам приходилось писать какие-то библиотеки C++, которые должны быть совместимы с основными компиляторами и работать с различными флагами компиляции. Зачем вообще отключают исключения? По двум причинам:
  1. Не все платформы их поддерживают. Например, когда появился Android NDK он их не поддерживал, хотя это было относительно недавно.
  2. Исключения понижают скорость выполнения кода. Даже если вы их не используете. Это потому, что компилятор не знает от куда может быть брошено исключение и через какие функции ему придется пройти, поэтому компилятору приходится для каждой функции генерировать специальный код для разворачивания стека и, чтобы при этом работал RAII (экземпляры локальных переменных должны же уничтожиться в обратном порядке при размотке стека). Само наличие кода, созданного на случай разворачивания стека и гарантированного уничтожения объектов уже негативно сказывается на производительность, что касается упущенных возможностей оптимизации остается только гадать. Кстати, это одна из основных причин почему принятие в стандарт исключений ставилось под вопрос.
Тем не менее начиная с C++ 11 появилась возможность явно сказать компилятору может ли функция бросить исключение. Это noexcept(expression). На практике noexcept(true) указывает компилятору, что функция не бросает исключение и это ключевое слово справляется со своими обязанностями. Оно может подсказать компилятору не генерировать код, отвечающий за разворачивание стека. В случае, если из функции помеченной noexcept все же будет брошено исключение по каким-то причинам, стек развернуться не сможет. Процесс скорее всего упадет.
Но уверен кто-то помнит, до С++ 20 и C++ 17 были так называемые списки исключений.

Списки исключений​

Начиная C++ 17, мы знаем, что пустой список исключений, т. е. throw() сделали эквивалентным noexcept(true), а список исключений и вовсе убрали. Стоп! А что же тогда значил throw() до C++17? Конечно же не то, что функция или функция-член не бросают исключение. И конечно же он не был эквивалентным noexcept(true).
Это один из тех аспектов языка, который уже очень долгое время пытались убрать из языка из-за его неудобства и не ясности.
Из недостатков списка исключений можно выделить следующее:
  • Они не гарантируют, что функция не выбросит другой тип исключения, кроме указанных. Если это произойдет, то программа автоматически вызовет std::unexpected(), который по умолчанию завершает программу.
  • Они не совместимы с шаблонами, так как невозможно заранее знать, какие типы исключений может выбросить шаблонная функция или класс.
  • Они не совместимы с виртуальными функциями, так как невозможно изменить список исключений в переопределенной функции без нарушения LSP.
  • Они скорее всего будут ухудшать производительность приложения, так как компилятору приходится генерировать дополнительный код для проверки списка исключений помимо кода для поддержки развёртывания стека.
В связи с этим, в стандарте C++11 было решено отказаться от списков исключений и добавить спецификатор noexcept, который указывает, что функция не выбрасывает исключений вообще. Это позволяет компилятору оптимизировать код и избежать ненужных проверок.
Давайте глубже погрузимся в эту тему. Выше я уже описал про проблемы с производительностью, вызываемые исключениями. Посмотрим решит ли эту проблему пустой список исключений (нужно компилировать со стандартом языка ниже 17-го, иначе throw() будет то же самое, что и noexcept):
#include <iostream>
#include <exception>
#include <vector>

int sum(const std::vector<int>& vec) throw() {
if (vec.size() > 10) {
throw std::eek:ut_of_range("vec.size() > 10");
}
int result = 0;
for (const auto& value : vec) {
result += value;
}
return result;
}

int main() {
try {
std::cout << sum({1, 2, 3}) << std::endl;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Дизассемблированный код функции sum (с включенной оптимизаций):
sum(std::vector<int, std::allocator<int> > const&):
push r14
push rbx
push rax
mov r8, qword ptr [rdi]
mov rcx, qword ptr [rdi + 8]
mov rsi, rcx
sub rsi, r8
cmp rsi, 40
ja .LBB0_10
cmp r8, rcx
je .LBB0_2
add rsi, -4
xor eax, eax
cmp rsi, 28
jae .LBB0_6
mov rdx, r8
jmp .LBB0_5
.LBB0_2:
xor eax, eax
add rsp, 8
pop rbx
pop r14
ret
.LBB0_6:
shr rsi, 2
inc rsi
mov rdi, rsi
and rdi, -8
lea rdx, [r8 + 4*rdi]
pxor xmm0, xmm0
xor eax, eax
pxor xmm1, xmm1
.LBB0_7: # =>This Inner Loop Header: Depth=1
movdqu xmm2, xmmword ptr [r8 + 4*rax]
paddd xmm0, xmm2
movdqu xmm2, xmmword ptr [r8 + 4*rax + 16]
paddd xmm1, xmm2
add rax, 8
cmp rdi, rax
jne .LBB0_7
paddd xmm1, xmm0
pshufd xmm0, xmm1, 238 # xmm0 = xmm1[2,3,2,3]
paddd xmm0, xmm1
pshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
paddd xmm1, xmm0
movd eax, xmm1
cmp rsi, rdi
je .LBB0_9
.LBB0_5: # =>This Inner Loop Header: Depth=1
add eax, dword ptr [rdx]
add rdx, 4
cmp rdx, rcx
jne .LBB0_5
.LBB0_9:
add rsp, 8
pop rbx
pop r14
ret
.LBB0_10:
mov edi, 16
call __cxa_allocate_exception@PLT
mov rbx, rax
lea rsi, [rip + .L.str]
mov rdi, rax
call std::eek:ut_of_range::eek:ut_of_range(char const*)@PLT
mov rsi, qword ptr [rip + typeinfo for std::eek:ut_of_range@GOTPCREL]
mov rdx, qword ptr [rip + std::eek:ut_of_range::~out_of_range()@GOTPCREL]
mov rdi, rbx
call __cxa_throw@PLT
mov rdi, rax
call __cxa_call_unexpected@PLT
mov r14, rax
mov rdi, rbx
call __cxa_free_exception@PLT
mov rdi, r14
call __cxa_call_unexpected@PLT
На самом деле компилятор сделал то, что требовал от него стандарт до C++ 17. А именно (передаю суть, что примерно делает компилятор):
#include <iostream>

int sum(const std::vector<int>& vec) {
try {
// ...
} catch (...) {
std::unexpected();
}
}

int main() {
try {
std::cout << sum({1, 2, 3}) << std::endl;
} catch (const std::exception& exc) {
std::cout << "error: " << exc.what() << std::endl;
}
return 0;
}
Это не то, что мы хотели увидеть, код для разворачивания стека на месте. Но при это в случае разворачивания стека при исключении процесс завершается. Кода стало даже больше, чем это было без пустого списка исключений.
Попробуем добавить список исключений (нужно компилировать со стандартом языка ниже 17-го):
int sum(const std::vector<int>& vec) throw(std::eek:ut_of_range) {
if (vec.size() > 10) {
throw std::eek:ut_of_range("vec.size() > 10");
}
int result = 0;
for (const auto& value : vec) {
result += value;
}
return result;
}
Дизассемблер стал еще хуже
sum(std::vector<int, std::allocator<int> > const&):
push r15
push r14
push rbx
mov r8, qword ptr [rdi]
mov rcx, qword ptr [rdi + 8]
mov rsi, rcx
sub rsi, r8
cmp rsi, 40
ja .LBB0_10
cmp r8, rcx
je .LBB0_2
add rsi, -4
xor eax, eax
cmp rsi, 28
jae .LBB0_6
mov rdx, r8
jmp .LBB0_5
.LBB0_2:
xor eax, eax
pop rbx
pop r14
pop r15
ret
.LBB0_6:
shr rsi, 2
inc rsi
mov rdi, rsi
and rdi, -8
lea rdx, [r8 + 4*rdi]
pxor xmm0, xmm0
xor eax, eax
pxor xmm1, xmm1
.LBB0_7: # =>This Inner Loop Header: Depth=1
movdqu xmm2, xmmword ptr [r8 + 4*rax]
paddd xmm0, xmm2
movdqu xmm2, xmmword ptr [r8 + 4*rax + 16]
paddd xmm1, xmm2
add rax, 8
cmp rdi, rax
jne .LBB0_7
paddd xmm1, xmm0
pshufd xmm0, xmm1, 238 # xmm0 = xmm1[2,3,2,3]
paddd xmm0, xmm1
pshufd xmm1, xmm0, 85 # xmm1 = xmm0[1,1,1,1]
paddd xmm1, xmm0
movd eax, xmm1
cmp rsi, rdi
je .LBB0_9
.LBB0_5: # =>This Inner Loop Header: Depth=1
add eax, dword ptr [rdx]
add rdx, 4
cmp rdx, rcx
jne .LBB0_5
.LBB0_9:
pop rbx
pop r14
pop r15
ret
.LBB0_10:
mov edi, 16
call __cxa_allocate_exception@PLT
mov r14, rax
lea rsi, [rip + .L.str]
mov rdi, rax
call std::eek:ut_of_range::eek:ut_of_range(char const*)@PLT
mov rsi, qword ptr [rip + typeinfo for std::eek:ut_of_range@GOTPCREL]
mov rdx, qword ptr [rip + std::eek:ut_of_range::~out_of_range()@GOTPCREL]
mov rdi, r14
call __cxa_throw@PLT
mov r15, rdx
mov rbx, rax
jmp .LBB0_14
mov r15, rdx
mov rbx, rax
mov rdi, r14
call __cxa_free_exception@PLT
.LBB0_14:
mov rdi, rbx
test r15d, r15d
jns .LBB0_15
call __cxa_call_unexpected@PLT
.LBB0_15:
call _Unwind_Resume@PLT
.Ltmp32: # TypeInfo 1
.long .L_ZTISt12out_of_range.DW.stub-.Ltmp32
.byte 1
.byte 0
Это похоже на следующий код:
int sum(const std::vector<int>& vec) {
try {
// ...
} catch (const std::eek:ut_of_range &) {
throw;
} catch (...) {
std::unexpected();
}
}
Это не совсем то, что можно было ожидать увидеть.
Списки исключений по большому счету не говорят о том какие исключения могут произойти в функции и не заставляют соблюдать эту сигнатуру. Нет никаких гарантий на типы исключений, которые могут быть выброшены из функции. И во всех случаях все равно будет создаваться код для перехвата необработанных исключений, разворачивания стека, а затем остановки процесса. Они создают много кода для поддержания своей работы. Но вряд ли это хорошо влияет на производительность, а также вряд ли что сам созданный код вообще имеет ценность. Потому что это не то, что мы ожидаем получить от списка исключений. А значит не стоит затраченных на него ресурсов. В итоге списки исключений и пустой список исключений были убраны из языка.
Но ничего страшного, что-то убрали, что-то добавили. Я считаю в C++ 14 и 17 тоже были внесены ошибочные вещи. Но мои идеи возможно радикальны и им сейчас не место, должно пройти еще какое-то время, чтобы сообщество осознало полезность или недальновидность некоторых принятых нововведений. По крайней мере я и сам могу ошибаться в своих заключениях. Все-таки развитие языка — это тяжелый труд, в котором задействовано много людей и мнений, невозможно все спроектировать наперед и при этом нигде не ошибиться, не внести конфликты в язык, да так, чтобы еще новые фичи не били старый код. Конечно, всем бы хотелось, чтобы их идеи были услышаны, чтобы кто-то сказал, что они были правы, и всё сделали правильно. Потому что негативных моментов и так у всех хватает в обыденной жизни. Не стоит строго судить те или иные неудачно принятые решения, потому что это все способствует эволюции языков программирования и не только C++. Прежде всего новые фичи призваны помочь разработчику, даже если они когда-то утратят свою актуальность.

Правило одного определения​

Так же известное как ODR (One Definition Rule). Оно говорит о том, что в C++ в пределах программы (или пределах единицы трансляции, если видимость определения находится только в рамках этой единицы) может быть только одно определение одной и той же сущности. В то же время объявления одной и той же сущности может многократно появляться в различных единицах трансляции.
Это и понятно, так как реализация, например функции может быть только в одном экземпляре и находиться только в одной единице трансляции. Другие единицы трансляции будут использовать символ для связывания с этой сущностью. Во время линковки все единицы трансляции будут связаны в конечную программу используя эти символы (грубо говоря код будет сшит через эти символы в одно полотно, это и есть процесс линковки простым языком). Если реализация произойдет несколько раз в одной или нескольких единицах трансляции из-за многократного определения, то во время линковки произойдет ошибка многократного определения символа. Линковщик просто на просто не будет знать с каким из этих символов ему нужно слинковаться. И в самом деле, я могу в разных единицах трансляции определить две совершенно разные функции, но по невнимательности дать им одинаковое имя, было бы странно, если линковщик выбрал бы какое-то из них случайно и связал его.
Рассмотрим пример линковки. Допустим у нас есть две единицы трансляции.
sum.cpp:
int sum(int v1, int v2) {
return v1 + v2;
}
И main.cpp:
int sum(int, int);

int main() {
int r = sum(3, 2);
return 0;
}
Если мы попытаемся еще раз определить sum, мы получим ошибку линковки программы. Но объявление sum мы можем делать многократно в разных единицах трансляции. В реальности, чтобы во всех единицах трансляции не копипастить сигнатуру объявлений, т.к. они часто нужны в нескольких местах, вся объявления выносят в заголовочные файлы. И в той единице трансляции, где мы хотим использовать, например функцию sum, мы просто сделаем #include заголовка, #include это ничто иное как CTRL+C, CTRL+V всего содержимого заголовочного файла в .cpp файл, не в буквальном смысле конечно.
А теперь зададимся вопросом. Тезисы-шмезисы, все эти ваши правила, а можно в C++ делать многократное определения?
Ответ: терминологически нет, но вообще можем. Само-собой это inline функции (inline не гарантирует встраивание тела функции, но абсолютно точно может многократно определяться). Также это могут делать шаблоны.
Для того, чтобы это было возможно в ODR делаются определенные “допущения” для их реализации в языке.
Если есть повторное инстансирование шаблона с одинаковыми параметрами, но в разных единицах трансляции линкер во время линковки в итоге выберет только одно из них и не будет выдавать ошибку многократного определения символа. Это, кстати, также порождает проблему т. н. разбухания кода при инстансировании шаблонов. Потому что компилятор вынужден создавать код во всех единицах трансляции, где разворачивается этот шаблон (с одинаковым кодом), но в итоге все они будут отброшены кроме одной реализации.
Как линкер понимает, что нужно выбрать только одну реализацию из любой единицы трансляции, а не выдавать ошибку многократного определения символа? Такие определения будут иметь слабое связывание (weak linkage), т.е. они будут помечены как слабые символы. Также замечу, что, если шаблон с одинаковыми параметрами разворачивается по-разному в разных единицах трансляции из-за препроцессора (#if #else), то это вряд ли хорошая практика.
Небольшое отступление. Проблему разбухания кода из-за инстансирования шаблонов (и множество других проблем) должны решить модули из C++ 20. Но что-то мне подсказывает, что переход на них будет долгий, потому что и дальше будут разрабатываться библиотеки без их поддержки, в основном из-за сохранения совместимости со старыми компиляторами. Так до сих пор обстоят дела со многими C библиотеками (до сих пор API С библиотек делают совместимым с C99, но это как правило не проблема, потому что C библиотека — это обычно заголовки с объявлениями и либа для линковки, сама либа может хоть на C++ быть написана). Сложно спрогнозировать как быстро будет осуществлен переход сообществом на модули. Предполагаю, что в первое время новые библиотеки будут предоставлять как интерфейс через заголовки, так и модуль для импорта, и возможно такой подход станет стандартом де-факто.
Ну что ж, проверим многократное определение шаблонов в нескольких единицах трансляции на практике.
Добавим заголовочный файл template-sample.hpp со следующим содержимым:
#ifndef TEMPLATE_SAMPLE_HPP
#define TEMPLATE_SAMPLE_HPP

template <typename T>
struct TemplateSample {
void inc() noexcept {
counter += T(1);
}
static T counter;
};

template <typename T>
T TemplateSample<T>::counter = T();

#endif
Как видим в шаблонном класса TemplateSample есть статическое поле counter, которое после линковки программы должно быть инстансировано один раз. Иначе было бы странно, получать никак не связанные значение из поля counter в разных единицах трансляции, не так ли? Но как мы знаем, это не так. Кстати, функции-члены, определенные в теле класса, неявно имеют модификатор inline, поэтому в шаблонах не нужно писать inline явно. Если бы мы делали определение вне класса, нам пришлось бы указать inline явно как:
template <typename T>
inline void TemplateSample<T>::inc() noexcept {
counter += T(1);
}
Нужно это, чтобы получать мягкий символ для этой функции-члена после разворачивания шаблона в нескольких единицах трансляции.
Добавим две единицы трансляции с функциями a и b.
a.cpp:
#include <template-sample.hpp>

void a() {
TemplateSample<int> ts;
ts.inc();
}
b.cpp:
#include <template-sample.hpp>

void b() {
TemplateSample<int> ts;
ts.inc();

TemplateSample<long> ts2;
ts2.inc();
}
И вызовем эти две функции из единицы трансляции main.cpp:
#include <iostream>
#include "template-sample.hpp"

void a();
void b();

int main() {
a();
b();

TemplateSample<int> ts;
TemplateSample<long> ts2;
TemplateSample<short> ts3;

std::cout << ts.counter << std::endl;
std::cout << ts2.counter << std::endl;
std::cout << ts3.counter << std::endl;

return 0;
}
Как и ожидалось вывод будет:
2
1
0
Кстати, интересные факты, в C и C++ иногда достаточно иметь только объявления сущностей и вообще не иметь их определений (даже во время линковки кода!).
Например, следующий прием часто используется при написании C-библиотек. Клиент такой библиотеки подключает заголовок с API библиотеки (my-lib.h), и там есть объявления вида:
typedef struct my_type_* my_type;

struct create_options {
int a;
int b;
int c;
};

my_type create_my_type(const create_options& options);
Библиотека уже может быть откомпилирована, и в ней будет определение структуры my_type_, но клиенту она не будет известна, он будет оперировать только указателем на этот тип. При всем этом компилятор будет отслеживать статическую типизацию для указателя my_type_*. Во время финального связывания приложения определение my_type_ так и не понадобится. Это очень полезная особенность, потому что позволяет, инкапсулировать внутреннюю структуру объекта (скрыть ее от клиента), дает возможность менять внутреннюю структуру объекта, не ломая код клиента (если бы клиент мог напрямую обращаться к полям структуры мы бы уже не могли ее переделать). Реализации библиотеки может быть написана на любом неуправляемом языке (на самом деле библиотека может быть реализована на C++, а у my_type может быть виртуальная таблица, он может иметь глубокое наследование и т.п. но клиенту это будет не известно).
Также определения иногда не нужны в статическом метапрограммировании, например:
#include <type_traits>
#include <iostream>

class A {};
class B : public A {};
class C {};

template <typename T, typename D>
struct my_is_base_of {
typedef char Small;
struct Big {Small unused[2];};

static Big test(T*);
static Small test(...);

enum {
value = sizeof(test(static_cast<D*>(0))) > sizeof(Small) ? 1 : 0
};
};

int main() {
std::cout << "std::is_base_of<A, B>::value == " << std::is_base_of<A, B>::value << std::endl;
std::cout << "std::is_base_of<A, C>::value == " << std::is_base_of<A, C>::value << std::endl;

std::cout << "my_is_base_of<A, B>::value == " << my_is_base_of<A, B>::value << std::endl;
std::cout << "my_is_base_of<A, C>::value == " << my_is_base_of<A, C>::value << std::endl;

return 0;
}
Как видим нам не нужны определения для функций-членов test, так как во время статической компиляции нам важно знать только возвращаемый тип. Конечно, my_is_base_of не то же самое, что и is_base_of, т. к. данная реализация не может работать с приватным и защищенным наследованием, но для примера пойдет.
Ну раз мы уж начали говорить про шаблоны, попробуем сказать что-то и про их интересные факты.

Шаблоны шаблонов​

Или как еще их называют — шаблонизированные шаблоны, в оригинале template template. Это такой шаблон, у которого в качестве аргумента описывается другой шаблон. Но чтобы понять для чего он нужен, сначала рассмотрим обычный шаблон (не шаблонизированный):
#include <iostream>
#include <vector>
#include <list>

template <typename T, typename Container>
class MyClass
{
public:
void push(const T& value)
{
container.push_back(value);
}

T pop()
{
T value = container.back();
container.pop_back();
return value;
}
private:
Container container;
};

int main() {
MyClass<int, std::vector<int>> with_vector;
with_vector.push(1);
with_vector.push(2);
with_vector.push(3);

MyClass<int, std::list<int>> with_list;
with_list.push(4);
with_list.push(5);
with_list.push(6);

return 0;
}
Это достаточно, простой код. И он делает то, что от него ожидается. Но в данном коде нам никто не запретит сделать вместо MyClass<int, std::vector<int>>, например MyClass<int, std::vector<float>>. Да и зачем вообще в принципе нам нужно передавать параметр в контейнер, если мы и так уже в аргументе шаблона передали параметр T? Почему бы шаблону самого его не вывести.
Для этого, собственно, и существуют шаблонизированные шаблоны. Посмотрим на результат:
#include <iostream>
#include <vector>
#include <list>

template <typename T, template <typename, typename> typename Container>
class MyClass
{
public:
void push(const T& value)
{
container.push_back(value);
}

T pop()
{
T value = container.back();
container.pop_back();
return value;
}

private:
Container<T, std::allocator<T>> container;
};

int main() {
MyClass<int, std::vector> with_vector;
with_vector.push(1);
with_vector.push(2);
with_vector.push(3);

MyClass<int, std::list> with_list;
with_list.push(4);
with_list.push(5);
with_list.push(6);

return 0;
}
Как видим теперь достаточно передать в качестве параметра сам шаблон без его параметров. Теперь мы не сможем допустить ошибку с типом контейнера, а также код использования MyClass становится немного проще.
Кстати, для шаблонных параметров мы также можем указывать значения по умолчанию
#include <iostream>
#include <vector>
#include <list>

template <typename T, template <typename, typename> typename Container = std::vector>
class MyClass
{
public:
void push(const T& value)
{
container.push_back(value);
}

T pop()
{
T value = container.back();
container.pop_back();
return value;
}

private:
Container<T, std::allocator<T>> container;
};

int main() {
MyClass<int> with_vector;
with_vector.push(1);
with_vector.push(2);
with_vector.push(3);

MyClass<int, std::list> with_list;
with_list.push(4);
with_list.push(5);
with_list.push(6);

return 0;
}
Поговорим теперь про частичную специализацию.

Частичная специализация​

Частичная специализация шаблонов (Partial template specialization) очень мощное средство языка C++, от части оно определило его судьбу в статическом метапрограммировании. За счет свой мощнейшей дедукции при выборе частичных специализаций в языке в свое время удалось реализовать ветвления, связанные списки, математические функции по типу вычисления факториала и другие операции, которые выполняются во время компиляции. Что в дальнейшем привело к появлению constexpr, consteval, constinit, а также концептам. В дальнейшем это также приведет и к статической рефлексии.
В данном разделе я расскажу о малоизвестной частичной специализации — частичная специализация типов указателей. Ее редко получается встретить. Но тем не менее от этого она не становится менее интересной:
#include <iostream>

template <typename T>
class MyClass
{
// обобщенная реализация
public:
MyClass() {
std::cout << "MyClass<T>" << std::endl;
}
};

// частичная специализация шаблона MyClass для всех типов указателей
template <typename T>
class MyClass<T*>
{
public:
MyClass(T* p) : ptr(p) {
std::cout << "MyClass<T*>" << std::endl;
}

T* getPtr() {
return ptr;
}

private:
T* ptr;
};

int main() {
MyClass<int> generic;
MyClass<int*> ci(0);
MyClass<long*> cl(0);
MyClass<std::pair<char, float>*> cp(0);
return 0;
}
Возможно, это не совсем удачный выбор для синтаксиса специализации типов указателей. Но он вполне очевиден, как мне кажется.
На самом деле частичную специализацию можно делать также для квалификаторов типов таких как const или volatile. Например:
template <typename T>
class MyClass<const T>
{
public:
MyClass() {
std::cout << "MyClass<const T>" << std::endl;
}
};
И если в качестве шаблонных параметров указать тип с квалификатором, то будет выбрана соответствующая специализация, например MyClass<const int>.
Кстати, интересный факт для тек, кто не застал. До стандарта C++ 11 в шаблонах между символами > необходимо было ставить пробел, если они шли друг за другом. Иначе это приводило к ошибкам компиляции, если этого не сделать. Потому что двойной >> распознавался как сдвиг вправо (т.е. нужно было писать так std::vector<std::vector<int> >). В C++ 11 было введено правило, которое позволяет компилятору распознавать >> как закрывающий символ списка параметров шаблона, если он находится в контексте шаблона.

Семантический анализ в шаблонах​

В функциях-членах шаблонов семантический анализ кода не производится, а делается только синтаксический анализ, при условии, что функция член нигде не вызывается. Это легко проверить:
#include <vector>
#include <iostream>

template <typename T>
struct Class {
void f() {
this->asdfadfa();

**** T:: adsf[234] . sdf;

// class; // incorrect syntax
}

void b() {
std::cout << T() << std::endl;
}
};

int main() {
Class<int> c;
c.b();
// c.f();
return 0;
}
Это полностью корректный код, и он компилируется. Если в функции-члене Class<T>::f() раскомментировать строку // class; // incorrect syntax, то компилятор выдаст ошибку, т.к. это некорректный синтаксис (после ключевого слова class должен идти идентификатор согласно грамматике). Что касается остального кода в теле функции-члена Class<T>::f(), то он абсолютно корректен, поэтому компиляция проходит успешно. Если же раскомментировать строку // c.f();, то очевидно мы получим ошибку компиляции, т.к. семантически мы знаем, что таких функций членов и полей ни у Class ни у int нет.
На самом деле это очевидно. Но подметить это полезно.
Теперь посмотрим на совсем изысканные возможности языка. Например, виртуальное наследование.

Виртуальное наследование​

Оно нужно для того, чтобы решать проблемы ромбовидного наследования. В этом мало чего есть интересного с практической стороны. Как правило, если ситуация обстоит таким образом, что виртуальное наследование может решить какую-то проблему в коде и кажется хорошей идеей, скорее всего в коде есть проблемы, связанные с проектированием. В частности, запутанность в наследовании и пренебрежение к современным паттернам проектирования.
Но сейчас интерес представляет, как они реализуются в языке программирования C++. Для начала посмотрим на пример кода:
#include <iostream>

using namespace std;

class A {
public:
int x;
A(int x) : x(x) {}
virtual void f() { cout << "A::f()" << endl; }
};

class B : public virtual A {
public:
int y;
B(int x, int y) : A(x), y(y) {}
void f() override { cout << "B::f()" << endl; }
};

class C : public virtual A {
public:
int z;
C(int x, int z) : A(x), z(z) {}
void f() override { cout << "C::f()" << endl; }
};

class D : public B, public C {
public:
int w;
D(int x, int y, int z, int w) : A(x), B(x, y), C(x, z), w(w) {}
void f() override { cout << "D::f()" << endl; }
};

int main() {
D d(1, 2, 3, 4);
A* pa = &d;
C* pc = &d;
pa->f();
pc->f();
cout << pa->x << endl;
cout << pc->z << endl;
return 0;
}
Вывод такой как мы и ожидаем:
D::f()
D::f()
1
3
Несмотря на то, что мы приводили указатель на тип D к указателям на базовые классы, на которых вызывалась функция-член f(), мы все равно попадали в переопределённую функцию-член классом D.
Если посмотреть на размеры этих классов, то мы увидим:
cout << sizeof(A) << std::endl; // 16
cout << sizeof(B) << std::endl; // 32
cout << sizeof(C) << std::endl; // 32
cout << sizeof(D) << std::endl; // 48
Быстро посчитаем... На данной платформе у нас int это 4 байта, а система 64 битная и для адресации памяти используется 8 байт. Класс A: 4 байта + 8 байт на виртуальную таблицу, итого 16 (из-за выравнивания данных). B и C это sizeof(A) + 4 байта + еще что-то. Это что-то как правило нужно для поддержания множественного и виртуального наследования. Стандарт не регламентирует как реализовывать виртуальное наследование, но как правило реализация такова, что каждый класс наследник хранит указатель на свою виртуальную таблицу с расположением всех базовых классов.
Еще раз посмотрим на взятие адресов базовых классов:
A* pa = &d;
C* pc = &d;
Код ниже демонстрирует как, с помощью этих хитрых таблиц мы и смещаемся по адресам для данного экземпляра в памяти:
cout << pa << std::endl;
cout << pc << std::endl;
cout << &d << std::endl;
Возможный вывод такой:
7ffff0a86c58
7ffff0a86c48
7ffff0a86c38
За счет этого мы и можем обращаться к правильным полям экземпляра объекта в памяти.
Кстати, попробуйте взять указатель на функцию-член и посмотреть на его размер в памяти:
void (A::*p)() = &A::f;
(d.*p)(); // Выведет: D::f()

cout << sizeof(p) << std::endl; // Выведет: 16
Если вы удивлены, то все в порядке. Дело в том, что, если вы пишете под x64 процессор, не все указатели будут 8 байтовыми. В обычном случае указатель будет соответствовать размеру регистра общего назначения, который может использоваться для разыменовывания адреса хранящемуся в нем. Но не надо забывать, что термин “указатель” — это понятие языков C и C++ и этот термин грубо говоря имеет мало какого отношения к разрядности процессоров, к размерам регистров, к адресному пространству, к виртуальному адресному пространству и прочему. С точки зрения семантики языка это вещь весьма абстрактная, если выражаться простым языком. Когда мы взяли указатель на виртуальную функцию член, он в данном случае будет представлен компилятором не как обычный адрес в памяти, а как специальный указатель представленной парой значений: адрес функции и смещения. Это смещение от начала объекта до начала виртуального базового класса, в котором определена виртуальная функция член. Это смещение нужно для того, чтобы правильно обратиться к членам виртуального базового класса через указатель на произвольный класс. Вроде теперь понятно.
Историческая справка, во времена сегментированной памяти указатели в C могли вообще быть как 16-битными так и 32-битными в одном процессе одновременно.
У виртуального наследования есть одна интересная особенность. Вы наверняка заметили, что при виртуальном наследовании у базовых классов может быть один общий предок, как на примере выше у классов B и C. Очевидно, что по правилам “обычного” наследования C++ наследник обязательно должен вызвать конструктор базового класса вне зависимости является ли это конструктор по умолчанию или определенный программистом. Но с таким правилом в данном случае у нас конструктор для класса A был бы вызван два раза. Что будет некорректно. Поэтому при виртуальном наследовании, конструкторы базовых классов не вызываются вовсе. Их нужно вызвать явно при завершении ромбовидного наследования, посмотрите на класс D — он явно вызывает сначала конструктор A, а затем конструкторы классов B и C. Т.е. конструктор A будет вызван только один раз, чтобы поля, относящиеся к классу A, были проинициализированы только один раз.
Вообще это интересная особенность сама по себе, даже в разрезе от разрешения ситуации ромбовидного наследования. Технически это можно использовать и не по назначению, когда нужно отсрочить вызов конструктора. Когда и где? Ну это уже вопрос дискуссии и требует дополнительных исследований каких-то нестандартных паттернов.

Ref-qualifier​

Это такой специальный синтаксис, позволяющий перегружать функции-члены класса в зависимости от того, является ли объект класса, на котором вызывается функция, lvalue или rvalue. Не часто об этом рассказывают. Поэтому пример кода:
#include <iostream>

using namespace std;

class A {
public:
// функция-член f() с ref-qualifier &
void f() & { cout << "A::f() &" << endl; }
// функция-член f() с ref-qualifier &&
void f() && { cout << "A::f() &&" << endl; }
};

int main()
{
A a;
a.f(); // вызываем функцию-член f() на lvalue, выводит "A::f() &"
A().f(); // вызываем функцию-член f() на rvalue, выводит "A::f() &&"
return 0;
}
В этом примере функция-член f() перегружена с ref-qualifier & и с ref-qualifier &&. Когда функция-член f() вызывается на lvalue, то выбирается версия с ref-qualifier &. Когда функция-член f() вызывается на rvalue, то выбирается версия с ref-qualifier &&.
Это полезно, когда нужно получить разное поведение в зависимости от двух случаев.
Ну и закончу я эту статью препроцессором.

Pragma​

Что такое pragma? Очевидно, это #pragma once, которая нужна чтобы сделать однократное включение заголовка в единицу трансляции. Эта директива подсказывает компилятору пропустить include заголовка, если он уже включался. Это именно подсказка, сама директива once не входит ни в стандарт языка C, ни в стандарт языка C++. Хоть и реализуется всеми существующими компиляторами. Поэтому ее частенько не используют, хоть она и повышает скорость компиляции кода.
Но что такое вообще #pragma? Технически эта директива, которая дает подсказку компилятору. Например:
#pragma sdf asfda
#pragma $%^$%$
Это легальный код, ну что поделать, что компилятор не знает моих директив.

Alternative operator representations​

По непонятной причине об этом тоже не рассказывают, но посмотрим на пример кода, который успешно компилируется:
%:include <iostream>

int main() <%
int i = compl 0;
if (i not_eq 0 and i > 0) <%
std::cout << "OK" << std::endl;
%>
return 0;
%>
Примечание: MSVC компилятор не поддерживает альтернативные ключевые слова compl, not_eq, and и прочие. Чтобы их включить, в старых версиях компилятора нужно использовать заголовок #include <iso646.h>, который добавляет макросы. Или использовать последнюю версию языка, которую можно включить, например с помощью флага компиляции /std:c++latest. В компиляторах gcc и clang таких проблем нет.

Заключение​

Конечно, в C и C++ много есть о чем еще можно рассказать. Но одной статьи недостаточно, чтобы охватить такие сложные языки программирования и их многолетнюю историю. Какие-то аспекты языка, показанные в этой статье, вряд ли даже когда-либо будут применимы в повседневной работе.
Но не обманывайтесь. Несмотря на то, что эта статья скорее развлекательная, нежели освещающая какие-то практические аспекты языка (такой цели и не стояло). Хотя это зависит от того, как вы развлекаетесь. Она тем не менее будет полезна. И даже по нескольким причинам. То, о чем было сказано выше часто применимо и на практике, например при написании библиотек, которые должны собираться под разные платформы и разными компиляторами. Да и чтобы лишний раз убедиться, что C и C++ прошли длинную и тернистую историю. Скорее всего вы и так были знакомы со всеми этим аспектам, если достаточно давно пишете на C/C++. Но если вы открыли для себя что-то новое и получили удовольствие от прочтения статьи, считаю миссию выполненной.

 
Сверху