В 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() является функцией без прототипа.

Как верно заметил @TheCalligrapher, изначально у меня в статье была фактическая ошибка, я называл это эллипсисом (ellipsis notation; также известные как variadic arguments; сами функции с variadic arguments называют variadic functions), хотя это не так. В данном примере это т.н. стиль K&R.

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

#include <stdio.h>

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

int main() { 
    f(5, 3.2f, "test");
    return 0;
}

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

Историческая справка по стилю K&R

До появления стандарта 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 компиляторами. Недостатком такого синтаксиса является то, что он не позволяет компилятору проверить соответствие типов аргументов при вызове функций. А потому не рекомендуется к использованию

Как еще отметил TheCalligrapher в комментариях, в C23 функции без прототипа были убраны из стандарта и должны приводить к ошибкам компиляции.

Чтобы указать компилятору языка 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(...) { } // сколько угодно аргуметнов

Итак, подытожим в виде таблицы:

Объявление 

Значение в языке C

Значение в языке C++

void f() 

Функция без прототипа

Не принимает аргументов

void f(void)

Не принимает аргументов

Не принимает аргументов

void f(int)

Принимает один аргумент

Принимает один аргумент

void f(int, ...)

Аргумент и эллипсис

Аргумент и эллипсис

void f(...)

Эллипсис (поддерживается начиная с C23, как отметил @svlasov)

Эллипсис

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

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

Ну что ж, поговорим еще об эллипсисах на примере 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.

Про calling convensions

Это не тема данной статьи. Но вдруг у кого-то возникнут вопросы почему так.

Для вызова был использован не стек, а регистры. Дело в то, что существуют различные т. н. 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 (@TheCalligrapher опроверг это утверждение, это как раз из-за K&R функций, у которых не было прототипов; здесь int потому, что параметры подвергались default argument promotions).

В стандарте языка 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 строковые литералы имеют тип char[N], а в C++ const char[N].

В стандарте языка C сказано (раздел 6.4.5):

For character string literals, the array elements have type char, and are initialized with the individual bytes of the multibyte character sequence

Для литералов символьных строк элементы массива имеют тип char и инициализируются отдельными байтами многобайтовой последовательности символов

В стандарте языка C++ сказано (в последней редакции это раздел 5.13.5):

An ordinary string literal has type “array of n const char” where n is the size of the string...

Обычный строковый литерал имеет тип «array of n const char», где n — размер строки...

Такая же логика и для строковых литералов с wchar_t и т.д.

Также в стандарте явно сказано, что изменение таких массивов это UB. Вот выдержка из стандарта языка C:

If the program attempts to modify such an array, the behavior is undefined.

Если программа пытается изменить такой массив, поведение не определено

Это значит следующее:

char *p = "string"; // допустимо, но опасно
p[0] = 'S'; // неопределенное поведение (UB)

Почему в C++ можно неявно присваивать строковые литералы к char*, потеряв квалификатор const? Из-за сохранения совместимости с языком C (в языке C квалификатор const появился только с принятием ANSI C в 1989 году).

Но в C++ это считается плохой практикой, т.к. строковые литералы считаются неизменяемыми, поэтому лучше явно писать const char*.

С типами что в 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::operator delete((void*) this, sizeof(Derived));
    }
}

Для базового класса он сделал то же самое, но для типа Base. Кстати, если не переопределять деструктор ~Derived(), компилятор все равно его неявно переопределит, чтобы передать sizeof(Derived)

Дизассемблированный код деструктора, который создал компилятор для класса Derived
Derived::~Derived() [base object destructor]:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     qword ptr [rbp - 8], rdi
        mov     rdi, qword ptr [rbp - 8]
        call    Base::~Base() [base object destructor]
        add     rsp, 16
        pop     rbp
        ret
Derived::~Derived() [deleting destructor]:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     qword ptr [rbp - 8], rdi
        mov     rdi, qword ptr [rbp - 8]
        mov     qword ptr [rbp - 16], rdi
        call    Derived::~Derived() [base object destructor]

        mov     rdi, qword ptr [rbp - 16]
        mov     esi, 24 # передается в оператор удаления как аргумент size
        call    Base::operator delete(void*, unsigned long)

        add     rsp, 16
        pop     rbp
        ret

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

Про оптимизирующий компилятор

С включенной оптимизаций 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::operator 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::operator 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::out_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::out_of_range::out_of_range(char const*)@PLT
        mov     rsi, qword ptr [rip + typeinfo for std::out_of_range@GOTPCREL]
        mov     rdx, qword ptr [rip + std::out_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::out_of_range) {
    if (vec.size() > 10) {
        throw std::out_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::out_of_range::out_of_range(char const*)@PLT
        mov     rsi, qword ptr [rip + typeinfo for std::out_of_range@GOTPCREL]
        mov     rdx, qword ptr [rip + std::out_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::out_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++. Но если вы открыли для себя что-то новое и получили удовольствие от прочтения статьи, считаю миссию выполненной. 

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

Комментарии (55)


  1. Kelbon
    12.01.2024 17:06
    +5

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

    Так нет же, деструктор по умолчанию noexcept, если вылетает исключение, то terminate. Если поставил явно noexcept(false), то всё ещё никакого уб нет, откуда бы ему взяться? Ну вылетит исключение вот и всё. Если во время анвиндига, то опять же в стандарте написано, что вызывается terminate

    https://godbolt.org/z/vxPaWdzcb


    1. vladimirshaleev Автор
      12.01.2024 17:06

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

      Ваш пример кода на gobolt это UB код

      Подробнее здесь

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

      Приведу пример.

      Вот код https://godbolt.org/z/hnqTaE7jG. Здесь мы создаем два экземпляра двух типов. Из деструктора B вылетает исключение и он успешно разворачивает стек и ловится (за счет noexcept(false)).

      Потом пришел другой программист и начал дорабатывать бизнес логику продукта в этом UB коде и добавил вызов функции после создания экземпляра B. И он это сделал абсолютно легально. Его функция в некоторых случаях может бросать исключение, что также абсолютно легально. Он написал полностью корректный C++ код.

      Запустите этот доработанный код https://godbolt.org/z/3YavK5Eah и вы увидите, что он перестал работать. Вряд ли другой программист, мог знать, что Вы кидаете исключение из деструктора. И здесь прав он, его исключение должно было быть перехвачено по правилам языка C++ и обработано. Но вместо этого деструктор A не был вызван, а процесс упал.

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


      1. boldape
        12.01.2024 17:06
        +1

        Нет, это НЕ уб, в такой ситуации по стандарту поведение четко определенно - вызов терминэйт.

        Вы пишете, что не существует случаев когда из деструктора имеет смысл кидать исключения - вы слишком высокого о себе мнения и берете на себя больше чем можете вынести (на данный момент). По теме исключений вам конечно незачет, приходите с другой статьей на пересдачу. Хинт гуглите std::uncaught_exceptions(), эс на конце очень важна, можете ещё в строку поиска добавить Александреску.

        Задание на следующую статью - напишите класс который может кинуть исключение из деструктора и НИКОГДА не привидет этим к вызову терминэйт.

        Вопрос со зведочкой - зачем это может понадобиться в реальном коде. Одного примера достаточно.


        1. fujinon
          12.01.2024 17:06

          напишите класс который может кинуть исключение из деструктора и НИКОГДА не привидет этим к вызову терминэйт.

          Кинуть из деструктора - имеется в виду, что исключение выходит за пределы деструктора?


      1. nameless323
        12.01.2024 17:06
        +3

        Вставлю свои 5 копеек.

        Назовем "корректное поведение", в стандарте написано "в случае stack unwinding если выбрасывается исключение - вызывается terminate" - все знают что так и будет и оно так и будет.

        Implementation defined - компилятор A говорит что в упомянутом случае вызовется какой кастомный abort(), компилятор B говорит что что диск будет отформатирован.

        Unspecified behavior - по сути предыдущее, но не документрованно и фактически observable behavior, но не приводит к UB (см порядок эвалюации аргументов функции).

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

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


        1. EvilFox
          12.01.2024 17:06
          -1

          говорит что что диск будет отформатирован

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

          Зачем вы транслируете этот форс абуза UB? Да и не только вы.

          Если компилятор при UB ломает программу или делает явно нелогичное поведение это баг/ошибка (как было с дебильной ситуацией с циклом у clang который растранслировался в "мем"), а если он при этом ещё и занимается явным вредительством доводя концепцию UB до абсурда это саботаж.

          Дополню: UB это далеко не всегда ошибка, как например чтение за границей массива, а вполне возможно законная оптимизация.


          1. nameless323
            12.01.2024 17:06
            +4

            Зачем вы транслируете этот форс абуза UB? Да и не только вы.

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

            Если компилятор при UB ломает программу

            Компилятор оптимизирует код на основании того что UB в программе нет, он может реогранизовывать код исходя из этого, может выкидывать куски когда исходя и этого ("а зачем тут нужна эта проверка? она может пригодиться только если выше будет UB, его выше быть не может - выкидываем проверку"). Это не проблема компилятора что человек пишет не валидный код. Некоторые компиляторы некоторые UB могут апнуть до Unspecified Behaviour, но это их личное дело и полагаться на это глупо, так как в след версии все может поменяться.

            UB это далеко не всегда ошибка, как например чтение за границей массива

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

            Да, в реальной жизни почти нереально найти серьезную программу без UB, тот же Strict Aliasing нарушают все кому не лень, но факт остается фактом - UB надо избегать.


            1. boldape
              12.01.2024 17:06

              Ну я могу рассказать о такой оптимизации, которая формально УБ, но на практике всегда ок. Расклад такой, у вас есть большой массив структур, ну например массив ргб цветов по байту на цвет. И так вы можете все это хранить либо в интах и просерать 25% памяти или хранить цвета в 3 байтах. Так а где же здесь УБ? Ну оно возникает когда вам надо все это обрабатывать используя симд. В симде у вас будет 0 в четвертом компоненте, а читать вы будете из памяти по началу по байтово. А потом поймёте что не выровненное чтение быстрее чем побоайтовая загрузка и таки начнёте читать 3х компонентные структуры как будто они 4х компонентные. Вот тут у вас формально УБ, но на практике всегда ок. А что бы не читать за пределами пеэйджа вы алоцируете немного больше памяти под весь массив что бы вот этот фэйковый 4 компонент не вылез за границы буфера. Я тут мог что то не точно описать про 1 байт, но суть более менее передал, а смотреть код мне в лом.


              1. mpa4b
                12.01.2024 17:06

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


          1. q271
            12.01.2024 17:06

            Зачем вы транслируете этот форс абуза UB? Да и не только вы.

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


  1. AshBlade
    12.01.2024 17:06
    +7

    Вставлю свои 2 копейки

    1. restrict - единственное ключевое слово из C, которое не поддерживается в C++

    2. Есть заголовок <setjmp.h>, который (добавляет функции, возволяющие) позволяет делать нелокальные переходы. Т.е. можно вернуться вверх по стеку через несколько функций без явного return. В C++ она хоть есть, но если использовать, то можно забыть о всех деструкторах - они вызваны не будут.


    1. vladimirshaleev Автор
      12.01.2024 17:06
      +3

      Большое спасибо, за полезный комментарий! Сразу вспомнился API libpng, в частности то как там обрабатываются ошибки с setjmp


    1. jcmvbkbc
      12.01.2024 17:06
      +1

      Странно говорить о каких-то свойствах языков и не упоминать версии их стандартов. Я уверен, что ни _Bool, ни _Complex ни _Generic из C11 никакая из версий c++ тоже не поддерживает.


    1. TheCalligrapher
      12.01.2024 17:06

      С ключевым словами - неверно. В С есть _Bool, _Complex, _Imaginary и т.д.

      Что касается longjmpто различие между С и С++ тут не настолько сильны, как может показаться. Во-первых, в С менеджмент ресурсов делается рукописным эпилог-кодом. И он точно так же "не выполнится" при выходе из функции по longjmp. Во-вторых, в С уже тоже появились [опциональные] неявные конструкторы и деструкторы - это внутренний код, обслуживающий VLA. Именно поэтому описанию longjmp в совокупности с VLA уделено отдельное внимание в стандарте языка.


  1. YuriPanchul
    12.01.2024 17:06
    +4

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

    Здрасьте, varargs не спросят даже на позицию по компиляторам или software stack в процессорные компании? Да в MIPS Technologies сидел специальный человек, которому зарплату платили за то, что он был экспертом по calling conventions. Наверняка такие же есть и в ARM, Intel, SiFive и других таких компаниях.


    1. vladimirshaleev Автор
      12.01.2024 17:06
      +13

      Вы абсолютно правы в своих суждениях.

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

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


  1. geher
    12.01.2024 17:06
    +2

    под Windows компилятор MSVC считает, что char — это unsigned char

    А разве оно не определяется параметрвми компилятора? Не могу сказать уверенно про MSVC (хотя память упорно подсказывает, что такой параметр есть или был когда-то), а в других компилятораз такое точно было.

    так для архитектуры x64 ясное дело __stdcall и __cdecl будут проигнорированы.

    Не совсем так. Наличие или отсутствие cdecl в объявлении экспортируемой функции даже для архитектуры x64 как минимум указывает на особенности ее именования в разделяемой библиотеке (dll или so). В случае cdecl это будет имя функции, иначе к имени функции будут добавлены символы, кодирующие типы параметров и возвращаемого значения (причем для разных компиляторов этот код может отличвться, что может приводить к интересным ситуациям при попытке, например, подключить dll функциями LoadLibrary и GetProcAddress).


    1. chnav
      12.01.2024 17:06
      +2

      >> под Windows компилятор MSVC считает, что char — это unsigned char

      Определённо автор что-то путает. В MSVC signed char (знаковый), постоянно приходится это учитывать при операциях сравнения и сдвига.


      1. vladimirshaleev Автор
        12.01.2024 17:06

        chanav, Вы правы, в моей статье ошибка, большое спасибо за данный комментарий!


      1. NN1
        12.01.2024 17:06

    1. vladimirshaleev Автор
      12.01.2024 17:06

      geher, большое спасибо за комментарий про char

      Можете уточнить про имена символов или, если есть такая возможность, привести дизасм C кода сбилженого под архитектуру x64 с разными колконвеншенами функций: cdecl, stdcall, fastcall и т.д., где будет видно что имена символов изменились?

      Не совсем так. Наличие или отсутствие cdecl в объявлении экспортируемой функции даже для архитектуры x64 как минимум указывает на особенности ее именования в разделяемой библиотеке (dll или so).

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

      Мои обоснования такие

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

      Например линковщик видит символ с таким именем:

      @AddSubFc@16

      И он понимает из названия (по символу @), что речь идет о конвенции fastcall:

      extern "C" int _fastcall AddSubFc(int i1, int i2, int i3, int i4);

      Или например видит такой символ:

      _AddInts@8

      Линковщик понимает что речь идет о такой сигнатуре stdcall:

      extern "C" int _stdcall AddInts(int i1, int i2);

      Это примеры для MSVC x86.

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

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


      1. geher
        12.01.2024 17:06

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

        Сейчас попробовал в Qt, подвернувшемся под руку (mingw 64 бит 14.1).

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

        Так-то описанное мной поведение (выключение декорирования имени) по человечески выполняется через определение extern "C" (которое всегда использовал) или def файл (там можно вообще любое имя нарисовать), но в памяти почему-то сидела и неправильная альтернатива, которую очень давно не пробовал (ну не было никогда необходимости объявлять явно cdecl в программах на С и С++).


  1. laphroaig
    12.01.2024 17:06
    +12

    Для меня, как исключительно С++ разработчика, тыжсишник==тыжпрограммист, Си и С++ - это разные языки с различными парадигмами и подходами к разработке. Я не знаю Си, о чем открыто заявляю, когда на меня пытаются повесить подобные проекты. Разумеется я его знаю, на каком-то уровне, но не смогу на нем так же продуктивно работать. Т.е. С++ основной язык разработки, а python, Си, php и пр. примерно в одном ряду, как бы парадоксально это ни казалось.


  1. kotlomoy
    12.01.2024 17:06
    +4

    А что на счет строковых литералов, например "string literal"? Это будет char*

    Тут стоит заострить внимание на то, что именно char *, а не char const *. Почему это важно?
    Случай из практики (писали мы на C тогда). Была у нас функция, которая принимала на вход строку char * и изменяла ее. Долго она у нас была, пока однажды мы не обнаружили место, где в функцию передается... строковый литерал. Функция бодро писала в литерал и всё работало, потому что в данном случае изменения заключались в удалении пробелов (так называемый trimming). То есть за пределы литерала ничего не писалось.
    Но это открытие нас так напугало (мы то были абсолютно уверены, что литералы конечно же char const *, ну кому в здравом уме может прийти в голову сделать литералы изменяемыми?), что мы добавили в наш кодстайл обязательное требование все строковые литералы приводить к char const *


    1. NN1
      12.01.2024 17:06
      +2

      В C++ как раз типом будет char const [N] как и ожидается.

      Пишите на C++ ????


      1. chnav
        12.01.2024 17:06

        Что-то я уже начинаю путаться чем отличается const char * от char const *. Первое постоянно использую на C++ использовал в стиле (const char)*, т.е. указатель на неизменяемый char, а второе как-то не замечал. Надо мне перекурить параграф "приоритеты и порядок выполнения операций".


        1. NN1
          12.01.2024 17:06

          Нет разницы

          char const* == const char*

          Указатель на константный символ

          Адрес менять можно содержимое нельзя

          char * const

          Тут уже будет константный указатель на символ

          Адрес менять нельзя , а содержимое можно.

          Чаще всего используют const char* называя это константный указателем, что ещё больше вносит путаницы.


        1. kotlomoy
          12.01.2024 17:06
          +3

          Это очередное тяжёлое наследие C. Типы читаются справа налево, но для const сделали исключение. Из-за этого все путаются и ничего не понимают.

          https://mariusbancila.ro/blog/2018/11/23/join-the-east-const-revolution/


          1. NN1
            12.01.2024 17:06

            Почему исключение ? Всё читается справа налево в каноническом виде.

            Это как раз вариант где const можно ставить слева не является каноническим.

            Поэтому некоторые стандарты кодирования предлагают всегда ставить const справа.


            1. kotlomoy
              12.01.2024 17:06
              +1

              Я ровно это и сказал. Для const сделали исключение - в определенной ситуации const допускается писать слева. Могу только предположить, что авторам языка не понравилось, что const получился не в начале строки, и они влепили этот страшный костыль вместо того, чтобы пересмотреть сам дизайн.
              В итоге редко кто знает, что типы читаются справа налево, потому что вот же, контрпример.
              Я одно время сидел на StackOverflow. Там такие вопросы прилетали почти каждый день. И каждый раз ответом был некорректное правило spiral rule https://c-faq.com/decl/spiral.anderson.html. За год ежедневного сидения не видел ни разу, чтобы ответили правильно и показали right-left rule https://cseweb.ucsd.edu/~ricko/rt_lt.rule.html. Всё, что нужно знать о дизайне языка и об общем уровне его понимания


          1. TheCalligrapher
            12.01.2024 17:06

            Наследие или нет, это особенность грамматики С и С++. Грамматика (объявления) в С и С++ упрощенно такова

            <спецификатор-типа> <декларатор>, <декларатор>, ..., <декларатор>;

            В С и С++ самый левый const возле самого типа являются частью спецификатор-типа, а не частью индивидуального декларатор (сравните с *, которая наоборот, является частью индивидуального декларатор, а не спецификатор-типа).

            Именно по этой причине грамотные программисты предпочитают выравнивать звездочки вправо, а cv-спецификаторы - влево. Более того, чтобы подчеркнуть особую грамматическую роль этого самого левого constего предпочитают писать слева от имени типа.

            Правильно:

            const int *const *a;

            Неправильно:

            int const *const *a;

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

            В конечном итоге, если вам на уровне персональных предпочтений нравится выравнивать const вправо - ваше право. Но не пытайтесь подвести под это какое-то теоретическое обоснование. Все теоретические обоснования сразу же ведут к тому, что правильнее писать именно const int *const *a;


            1. kotlomoy
              12.01.2024 17:06
              +2

              Чем const возле типа астрономически отличается от const возле указателя по своей сути? Кажется, по своей сути они не отличаются, потому что указатель это и есть тип.

              char * a; - тип этой переменной - указатель
              int * a; - тип этой переменной - указатель
              void * a; - тип этой переменной - указатель

              char и int перед указателем лишь указание на то, что будет, если инкрементировать или разыменовать указатель, они ничего не говорят о том, что хранится в самой переменной. А void * это просто указатель.

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

              int a, b, *c, d[5];


              1. TheCalligrapher
                12.01.2024 17:06

                Чем const возле типа астрономически отличается от const возле указателя по своей сути?

                Мой комментарий выше содержит ответ на этот вопрос.


            1. Playa
              12.01.2024 17:06

              Именно по этой причине грамотные программисты предпочитают выравнивать звездочки вправо, а cv-спецификаторы - влево.

              А другие не менее грамотные программисты предпочитают east-const.
              ИМХО: читая код сверху вниз гораздо приятнее прыгать по типам прямо, а не зигзагом. Наличие const при этом имеет второстепенное значение.


    1. TheCalligrapher
      12.01.2024 17:06

      Неверно.
      Строковые литералы и в С и в С++ являются массивами и имеют тип char [N] в языке С и const char [N] в языке С++. Независимо от типа, строковые литералы являются немодифицируемыми объектами и в С, и в С++.


  1. redfox0
    12.01.2024 17:06
    +2

    В двоичном интерфейсе приложений (ABI) x64 по умолчанию используется четырехрегистровое соглашение о вызове, т. н. x64 calling convention

    Могу ошибаться, но это справедливо только для windows. В linux x86_64 ABI передаёт через регистры 8 целочисленных аргументов (остальные через стек).


    1. vladimirshaleev Автор
      12.01.2024 17:06
      +2

      Верно, это зависит от платформы.

      Я ввел в заблуждение, когда обозначил, что в x64 calling convention есть строго-оговорённое количество используемых регистров. Я доработаю этот абзац, чтобы он соответствовал действительности


  1. blind_oracle
    12.01.2024 17:06
    +4

    Какой ужос. Это ж сколько всяких особенностей и UB нужно, по-хорошему, держать в голове помимо прочего. Хорошо что я не пишу на Си давно уже :)


    1. Dmitry_604
      12.01.2024 17:06
      +2

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


      1. blind_oracle
        12.01.2024 17:06

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

        Я на плюсах мало писал, но даже банальная арифметика между int-ами разной разрядности часто приводила к багам в тех проектах, с которыми имел дело. В новых языках типа Rust/Go это явно запрещено - хочешь? кастуй тип.


    1. sprinter11
      12.01.2024 17:06

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


  1. LeetCode_Monkey
    12.01.2024 17:06
    +19

    Комменты - отличная иллюстрация что никто полноценно C и C++ не знает. У всех знания фрагментальны. Поэтому типовой собес это всего-лишь определение насколько фрагментальные знания собеседующего пересекаются с фрагментальными знаниями кандидата. Но какая в этом ценность? Гораздо ценнее кооперирующаяся команда с непересекающимися знаниями её участников.


    1. Arkady_VK
      12.01.2024 17:06

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


  1. domix32
    12.01.2024 17:06

    // якобы здесь утечка памяти, т. к. по правилам RAII во время уничтожения
    // Class, в деструкторе может возникнуть исключение; а т. к.

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


  1. svlasov
    12.01.2024 17:06

    void f(...) {} добавили только в C23.


    1. TheCalligrapher
      12.01.2024 17:06

      Верно. Дополню, что суть замечания в том, что до C23 все variadic функции в С должны были иметь хотя бы один не-variadic параметр.


    1. vladimirshaleev Автор
      12.01.2024 17:06

      svlasov, спасибо, уточню это в статье


  1. TheCalligrapher
    12.01.2024 17:06
    +3

    Уже с самого начала - дичайшее количесто ошибок в статье. Просто ошибка на ошибке.

    Потому что объявление void f() является эллипсисом в языке C (ellipsis notation; их также часто называют как variadic arguments

    Что??? Эллипсис в языке С - это именно ..., то есть действительно variadic arguments. Но объявление c ()никакого отношения к ellipsis или variadic arguments не имеет и никогда не имело. Это т.наз. объявление без прототипа, которое не указывает список параметров. Я не буду расписывать подробно, но замечу, что такое объявление не совместимо с ellipsis-определением. Функции с ellipsis-определением в С, то есть f(...), обязаны быть объявлены заранее именно с ..., иначе при вызове такой функции поведение не определено. Это одно из мест "классического" стандартного С, в котором требуется ранее (до первого вызова) объявление функции.

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

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

    int main() {
    f(5, 3.2f, "test");

    Да, такой код является формально корректным из-за того, что функция объявлена без прототипа. Однако поведение такого кода не определено. Вызов функции без прототипа эквивалентен вызову вообще не объявленной функции (в С89/90 такое разрешалось). При вызове функции без прототипа компилятор С обязан "придумать" прототип функции на основе переданных аргументов. В данном случае это будет f(int, double, char *)(именно double). Если при этом оказывается, что "придуманный" прототип не совпадает с фактическим определением функции, то проведение не определено. Именно это происходит и в вашем случае - проведение не определено.

    Это "абсолютно корректен" или нет?

    Чтобы указать компилятору языка C, что функция не принимает аргументов, нужно указать это явно с помощью аргумента void:

    Ключевым различием здесь, которую я уже упомянул выше, является "объявление с прототипом" против "объявления без прототипа". Объявление с (void) - это уже объявление с прототипом. Кстати, объявления без прототипа давно являются deprecated и в С23 будут, наконец, формально запрещены.

    Итак, подытожим в виде таблицы:

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

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

    Очередной заряд полнейшей чуши... Функции printf()ничего подобного не нужно. Никакой передачи параметров "справа налево" не существует. Это не говоря уже о том, что современные ABI позволяют передавать даже variadic аргументы в регистрах процессора.

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

    Во-первых, то, о чем вы писали выше, это и есть "альтернативный стиль K&R". Всякий раз , когда в языке С вы пишете ()в объявлении или определении функции - это объявление без прототипа, то есть тот самый "альтернативный стиль K&R" - один из его частных случаев. Чтобы уйти от "альтернативного стиля K&R" для функции без параметров мы и пишем (void)

    Во-вторых, не "до появления стандарта ANSI C", а и в стандарте ANSI С тоже. Стандарт ANSI С разрешает K&R объявления, хотя там они давно являются deprecated. Стандарт С89/90 разрешал вызов функций вообще без объявления. С99 запретил вызов без объявления, оставив однако возможность делать объявления без прототипа. Только C23 запрещает объявления без прототипа и K&R-стиль в целом. При этом хотя объявления функций c ()являются K&R объявлениями, в С23 они запрещены не будут - их "сконвертировали" в эквивалент (void), то есть все станет в точности как как в С++.

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

    Во-первых, default argument promotions состоят из integer promotions плюс преобразование float в double. Вы развернули описание, но у вас однако integer promotions описаны не совсем корректно. Ну да ладно.

    Во-вторых, default argument promotions делаются при передаче не только variadic аргументов, но и при вызове функций без прототипа (именно поэтому в примере выше я указал, что передача аргумента 3.2fприводит к "придуманному" прототипу с параметром double). Вы это все, конечно, попытались запихать в свою теорию "это всё эллипсис", но это - полная чушь.

    Дело в то, что существуют различные т. н. calling convensions (соглашения о вызовах функций)

    Никакого отношения к языкам С и С++ это, разумеется, не имеет. Никаких calling conventions ни в С, ни в С++ нет. Что это делает в статье про "особенности С и С++" - не ясно.

    А что на счет строковых литералов, например "string literal"? Это будет char*

    Грубейше не верно. Строковый литерал - это массив, то есть char[N] в языке С и const char[N] в C++.

    Ответ очень прост: charsigned char и unsigned char — это три разных типа. Но при этом стандарт не запрещает, чтобы char был псевдонимом либо signed char, либо unsigned char.

    "Псевдонимами" в терминологии С и С++ называют идентичные типы, то есть одни и те же типы под разными именами. Так как charsigned char и unsigned charкак вы правильно заметили - это три разных типа, никаких "псевдонимов" тут нет и быть не может. О каком "стандарт не запрещает" вы ведете речь - не ясно.

    Далее читать не стал...


    1. lrrr11
      12.01.2024 17:06

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

      Кстати, объявления без прототипа давно являются deprecated и в С23 будут, наконец, формально запрещены.

      больше того, в новых версиях компиляторов вроде как задействовали -Wstrict-prototypes (то есть из коробки кидается ворнинг в таких местах)


      1. vladimirshaleev Автор
        12.01.2024 17:06

        lrrr11 Правильно что не стали читать, не надо было и этот комментарий писать


    1. TheCalligrapher
      12.01.2024 17:06
      +1

      Далее:

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

      Это - совершенно не верное объяснение. Тип символьной константы здесь совершенно ни при чем.

      Причина, по которой стандартные функции принимают тип int заключается в том, что в старинном K&R С у функций не было прототипов. Функции либо объявлялись как (), либо вызывались без объявления вообще. Все это разрешалось и в первом стандарте С - С89/90. При вызовах таких функций передаваемые аргументы всегда безусловно подвергались default argument promotions, о которых вы сами уже упоминали. В процессе default argument promotions тип char превращается в тип int и в функцию передается уже именно int. Вот именно поэтому у "классических" стандартных функций вы никогда не увидите параметров типа char, а вместо них будуь параметры типа int.


      По этой же самой причине вы никогда не увидите у "классических" стандартных функций параметров типа short или float. Другими словами, в K&R C вообще не было возможности передать параметры типа char, short или float. Эти типы передавались как int, int и double соответственно.

      А ваши домыслы про влияние типа символьного литерала тут совершенно ни при чем.



      1. vladimirshaleev Автор
        12.01.2024 17:06

        TheCalligrapher

        Это - совершенно не верное объяснение. Тип символьной константы здесь совершенно ни при чем.

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


    1. vladimirshaleev Автор
      12.01.2024 17:06
      +1

      TheCalligrapher большое спасибо за такой развернутый комментарий

      Лучше такой объем опускайте под спойлер, чтобы другим читателям было удобнее читать комментарии.

      Я постараюсь внести необходимые правки про функции без прототипа, чтобы текст более соответствовал действительности.

      ABI позволяют передавать даже variadic аргументы в регистрах процессора.

      что в статье и сказано, читайте статью внимательнее, но я согласен, что возможно стоило выразить это в более явной форме

      Во-первых, то, о чем вы писали выше, это и есть "альтернативный стиль K&R"

      спасибо, это я тоже поправлю, чтобы это было явно указано

      Вы это все, конечно, попытались запихать в свою теорию "это всё эллипсис", но это - полная чушь.

      Нет, не пытался. Но если Вы меня так поняли, значит я плохо написал

      Никакого отношения к языкам С и С++ это, разумеется, не имеет. Никаких calling conventions ни в С, ни в С++ нет. Что это делает в статье про "особенности С и С++" - не ясно.

      Полностью согласен, я это писал только для того чтобы дизасм объяснить для printf. Конкретно почему там не через стек толкаются параметры. А то у кого-то могли возникнуть вопросы. Но этот текст нужно либо вообще удалить из статьи, либо опустить под спойлер.

      Грубейше не верно. Строковый литерал - это массив, то есть char[N] в языке С и const char[N] в C++

      Что в статье и написано, читайте внимательно.


  1. remova
    12.01.2024 17:06
    +1

    Спасибо за статью!
    Простите за оффтоп, но есть небольшой вопрос. Камрады, подскажите, есть ли какие-то более-менее централизованные базы/опиcания/списки/дайджесты неплохих библиотек на С++ (и тот же вопрос про Qt)? Знаю вот такой ресурс, а есть ли что-то еще?


  1. anonymous
    12.01.2024 17:06

    НЛО прилетело и опубликовало эту надпись здесь