>
>
>
Путеводитель C++ программиста по неопре…

Андрей Карпов
Статей: 673

Дмитрий Свиридкин
Статей: 11

Путеводитель C++ программиста по неопределённому поведению: часть 7 из 11

Вашему вниманию предлагается седьмая часть электронной книги, которая посвящена неопределённому поведению. Книга не является учебным пособием и рассчитана на тех, кто уже хорошо знаком с программированием на C++. Это своего рода путеводитель C++ программиста по неопределённому поведению, причём по самым его тайным и экзотическим местам. Автор книги — Дмитрий Свиридкин, редактор — Андрей Карпов.

Неработающий синтаксис и стандартная библиотека: NULL-терминированные строки

В начале 70-х Кен Томпсон, Деннис Ритчи и Брайан Керниган, работая над первыми версиями C и Unix, приняли решение, которое отзывается болью, страданиями, багами и неэффективностью до сих пор спустя 50 лет. Они решили, что строки как данные переменной длины, нужно представлять в виде последовательности, заканчивающейся терминирующим символом — нулём. Так делали в ассемблере, а C ведь — высокоуровневый ассемблер! Да и памяти у старенького PDP немного: лучше всего один байтик лишний на строку, чем 2, 4, а то и все 8 байтов для хранения размера в зависимости от платформы... Не, лучше байтик в конце! Но в других языках почему-то предпочли хранить размер и ссылку/указатель на данные...

Ну что ж, посмотрим, к чему это привело.

Длина строки

Единственный способ узнать длину NULL-терминированной строки — пройтись по ней и посчитать символы. Это требует линейного времени от длины строки.

const char* str = ...;
for (size_t i = 0 ; i < strlen(str); ++i) {
  ....
}

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

const char* str = ...;
const size_t len = strlen(str);
for (size_t i = 0 ; i < len; ++i) {
  ....
}

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

Выглядел он примерно так (его получили путём реверс-инженеринга конечного бинарного файла):

const char* config = ....;
size_t N = ....;

for (size_t i = 0; i < N; ++i) {
  int value = 0;
  size_t parsed = sscanf(config, "%d", &value);
  if parsed > 0 {
    config += parsed;
  }
}

Прекрасный и замечательный цикл! Тело его выполняется всего N раз, но на большинстве версий стандартной библиотеки C каждый раз требуется strlen(config) операций на итерацию. Ведь sscanf должен посчитать длину строки, чтобы случайно не выйти за её пределы! А строка NULL-терминированная.

Вычисление длины строки — невероятно часто встречающаяся операция. И один из самых первых кандидатов на оптимизацию — посчитать её один раз и хранить со строкою... Но зачем тогда NULL-терминатор? Только лишний байт в памяти!

С++ и std::string

C++ — высокоуровневый язык! Уж повыше C, конечно. Стандартные строки в нём, учтя ошибку C, хранятся как размер плюс указатель на данные. Ура!

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

C++ и std::string_view

"Используйте std::string_view в своих API, и вам не придётся писать перегрузки для const char* и const std::string&, чтобы избежать лишнего копирования!"

Ага, конечно.

std::string_view — это тоже указатель плюс длина строки. Но уже, в отличие от std::string, указатель не обязательно на NULL-терминированную строку. Ура, мы можем использовать std::vector и не хранить лишний байт!

Но если вдруг за фасадом вашего удобного API со string_view скрывается обращение к какой-нибудь "сишной" библиотеке, требующей NULL-терминированную строку...

// Эта маленькая программа весело и задорно выведет

// Hello
// Hello World

// Хотите вы этого или нет.

void print_me(std::string_view s) {
  printf("%s\n", s.data());
}

int main() {
  std::string_view hello = "Hello World";
  std::string_view sub = hello.substr(0, 5);
  std::cout << sub << "\n";
  print_me(sub);
}

Чуть-чуть изменим аргументы:

// Теперь эта маленькая программа весело и задорно выведет

// Hello
// Hello Worldnext (или просто упадёт с ошибкой сегментации)

// Хотите вы этого или нет.

void print_me(std::string_view s) {
    printf("%s\n", s.data());
}

int main() {
  char next[] = {'n','e','x','t'};
  char hello[] = {'H','e','l','l','o', ' ',
                  'W','o','r','l','d'};
  std::string_view sub(hello, 5);
  std::cout << sub << "\n";
  print_me(sub);
}

Функция не менялась, мы просто передали другие параметры, и всё совсем сломалось! А это всего лишь print. С какой-то другой функцией может случиться что-то совершенно немыслимое, когда она пойдёт за границы диапазона, заданного в string_view.

Что же делать?

Нужно гарантировать NULL-терминированность. А для этого надо скопировать строку... Но ведь std::string_view мы же специально использовали в API, чтобы не копировать!

Увы. Как только вы сталкиваетесь со старыми API в C то, оборачивая их, вы либо вынуждены писать две имплементации — с сырым char* и c const std::string&, либо соглашаться на копирование на каком-то из уровней.

Как бороться?

Никак.

NULL-терминированные строки — унаследованная неэффективность и возможность для ошибок, от которых мы уже, вероятно, никогда не избавимся. В наших силах лишь постараться не плодить зло: в новых библиотеках на C стараться проектировать API, использующие пару указатель плюс длина, а не только указатель на NULL-терминированную последовательность.

От этого наследия страдают программы на всех языках, вынужденные взаимодействовать с API в C. Rust, например, использует отдельные типы CStr и CString для подобных строк, и переход к ним из нормального кода всегда сопровождается очень тяжёлыми преобразованиями.

Использование NULL-терминатора встречается не только для текстовых строк. Так, например, библиотека SRILM активно использует нуль-терминированные последовательности числовых идентификаторов, создавая этим дополнительные проблемы. Семейство функций exec в Linux принимают NULL-терминированные последовательности указателей. EGL использует для инициализации списки атрибутов, оканчивающиеся нулём. И так далее.

Не нужно дизайнить неудобные, уязвимые к ошибкам API без великой надобности. Экономия в функции на одном параметре размером в указатель редко когда оправдана.

Неработающий синтаксис и стандартная библиотека: конструкторы std::shared_ptr

С появления C++11 прошло уже больше 10 лет, так что большинство разработчиков на C++ уже всё-таки знают про умные указатели. Отдаёшь им владение сырым указателем и спишь спокойно — память будет освобождена. И всё хорошо.

И даже разницу между std::unique_ptr и std::shared_ptr они знают. Хотя, конечно, у меня был пару лет назад кандидат на собеседовании, который этой разницы не знал, потому что не пользовался STL...

Некопируемый, уникально владеющий std::unique_ptr просто хранит указатель (и, возможно, функцию очистки — deleter), и в своём деструкторе этот deleter применяет к сохранённому указателю. std::shared_ptr же хитрее, и для поддержания разделяемого владения между копиями ему нужен счётчик ссылок. Ну это все знают. Ничего интересного.

Давайте просто пользоваться и не думать.

Удивительно, но на практике в C++ довольно часто встречается ситуация, когда нам нужно описать некую сущность, которую ни в коем случае нельзя создавать на стеке. Она обязательно должна быть в куче.

Простейший пример: нам нужен потокобезопасный объект, который будет внутри защищён мьютексом/атомарными переменными. И мы хотим, чтобы этот объект можно было свободно перемещать из контейнера в контейнер, со стека в контейнер и обратно. А std::mutex и std::atomic конструкторов перемещения не имеют. И у нас два варианта действий в этом случае:

class MyComponent1 {
  ComponentData data_;
  // Сделать неперемещаемое поле перемещаемым, добавив
  // к нему слой индирекции и отправив данные в кучу.
  std::unique_ptr<std::mutex> data_mutex_;
};

// Как-то заставить пользователей этого класса создавать
// объекты только на куче и работать с
// std::unique_ptr<MyComponent2> или
// std::shared_ptr<MyComponent2>
class MyComponent2 {
  ComponentData data_;
  std::mutex data_mutex_;
};

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

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

Стандартный приём для ограничения создания объектов где попало — сделать конструкторы приватными, а для создания предоставить отдельную функцию.

class MyComponent {
public:
  static auto make(Arg1 arg1, Arg2 arg2) ->
    std::shared_ptr<MyComponent>
  {
    // ???
  }

  // Баним конструкторы копирования и перемещения,
  // чтобы случайно не вытянуть
  // данные объекта в экземпляр на стеке.
  MyComponent(const MyComponent&) = delete;
  MyComponent(MyComponent&&) = delete;
  // И этих друзей тоже баним, но это уже необязательно.
  MyComponent& operator = (const MyComponent&) = delete;
  MyComponent& operator = (MyComponent&&) = delete;

private:
  MyComponent(Arg1, Arg2) { ... };
  ....
};

Пойдём внутрь этой фабричной функции make. Обычно в этом месте выясняется, что опытный C++ разработчик на самом деле не очень опытный. Но это ему никак не мешает. Да и вообще редко кому мешает.

Можно попробовать написать эту функцию так:

auto MyComponent::make(Arg1 arg1, Arg2 arg2) ->
  std::shared_ptr<MyComponent>
{
  return std::make_shared<MyComponent>(std::move(arg1),
                                       std::move(arg2));
}

Но нас сразу же ждёт разочарование в виде полсотни строк ошибок — std::make_shared не может вызвать наш приватный конструктор!

Не беда! И наш C++ разработчик, не сильно напрягаясь, исправляет ошибку:

auto MyComponent::make(Arg1 arg1, Arg2 arg2) ->
  std::shared_ptr<MyComponent>
{
  return
    std::shared_ptr<MyComponent>(
      new MyComponent(std::move(arg1), std::move(arg2)));
}

Код компилируется, работает. Все свободны?

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

std::shared_ptr считает живые слабые (weak_ptr) и сильные ссылки. Для этого ему нужно выделить небольшой блок памяти под пару (атомарных) size_t и, может быть, ещё что-то. Этот блок зовётся контрольным.

При использовании std::make_shared контрольный блок выделяется рядом с создаваемым объектом. То есть выделяется один кусок памяти на как минимум sizeof(MyComponent) + 2 * sizeof(size_t). Это поведение рекомендовано стандартом, но не обязательно. Тем не менее все известные имплементации следуют рекомендации.

При вызове конструктора std::shared_ptr от сырого указателя объект уже как бы создан и контрольный блок рядом с ним не запихнуть. Поэтому будет выделено ещё как минимум 2 * sizeof(size_t) памяти. Где-то в другом месте. И тут в ход идут детали реализации аллокаторов, а также пляски с выравниваниями. И в действительности выделяется не sizeof(MyComponent) + 2 * sizeof(size_t), а больше. И в случае прямого вызова конструктора от сырого указателя — значительно больше. Ну а также при расположении контрольного блока рядом с данными иногда начинают заметно играть локальность данных и выигрыш от попадания в кэш. Но это всё в случае, если объект маленький.

А если большой?

А если большой, и вы создавали его через std::make_shared, а потом плодили std::weak_ptr, то у вас может начать происходить что-то очень похожее на утечку памяти. Хотя объекты исправно умирают, и деструкторы вызываются. Вы же видели это в логе!

Опять-таки: контрольный блок. Если у вас есть живые weak_ptr, привязанные к уже отмершим std::shared_ptr, то контрольный блок продолжает жить. Ну чтоб вы могли вызвать std::weak_ptr::expired(), и он бы вам сказал true. Но если контрольный блок сидел в одном куске памяти с умершим объектом, а именно так и получается при создании через std::make_shared, кусок памяти из-под объекта операционной системе возвращаться не будет, пока не помрёт сам контрольный блок! Вот вам и утечки.

Также есть разница в том, какой именно operator new будет вызываться. std::make_shared всегда вызывает глобальный. И, если вы перегрузили new для своего типа, поведение может быть не таким, какое вы ожидали.

Всё как обычно плохо

Так что же делать, если нам очень надо для своего компонента всё-таки выполнить одну аллокацию и потенциально сэкономить? Есть ли решение?

Конечно! В C++ всегда есть какое-нибудь страшное решение. Иногда даже без неопределённого поведения. И это даже наш случай.

Есть access token техника, с помощью которой можно осуществить задуманное.

Надо предоставить для std::make_shared публичный конструктор, но чтобы его можно было вызвать имея только экземпляр приватного типа (access token).

class MyComponent {
private:
  // access token
  struct private_ctor_token {
    // только MyComponent может их создавать
    friend class MyComponent;
    private:
      private_ctor_token() = default;
  };
public:
  static auto
  make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> {
    return
      std::make_shared<MyComponent>(
        private_ctor_token{}, std:: move(arg1), std::move(arg2));
  }


  // Этот конструктор приватный,
  // хотя и в публичной секции. Его никто не сможет вызвать, 
  // не имея доступа к приватному токену
  MyComponent(private_ctor_token, Arg1, Arg2) { .... };
  ....
};

И работает.

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

int main() {
  MyComponent c({}, // Создаём приватный токен, не называя его!
                    // У нас нет доступа только к имени.
                {}, {});
}

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: std::aligned_storage

Всех С++ разработчиков можно довольно успешно разделить на две категории:

1. Те, у кого код вида:

char buff[sizeof(T)];
....
T* obj = new (buff) T(...);

Работает, и для них нет никаких проблем.

2. И те, у кого из-за такого кода внезапно прилетает SIGSEGV или SIGBUS, или что-то ещё более интересное.

Чаще всего первые собирают свои программы только под x86 каким-нибудь старым компилятором, ничего не знающим про SIMD инструкции, и с, вероятно, выключенными "агрессивными" оптимизациями, чтобы точно ничего не сломалось.

Разработчики на С тоже не должны уйти обиженными. Для них этот код будет выглядеть как:

char buff[sizeof(T)];
....
T* obj = (T*)buff;

Что, в принципе, ещё страшнее из-за неинициализированной памяти, но это уже другая проблема.

Основная проблема: буфер, в который мы тут собрались что-то записать, может быть выровнен (alignment) не так, как это требуется для типа T.

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

Чтобы бездушная машина могла успешно прочитать/записать данные типа T по адресу, соответствующему значению указателя T* ptr, или иногда совершить какую-то хитрую операцию над ними, адрес должен быть выровнен — кратен некоторому числу (обычно это степень двойки), которое нам навязали инженеры-разработчики микроархитектуры этой самой машины. А навязали, потому что:

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

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

Type

alignment

char

1

int16

2

int32

4

int64

8

__m128i

16

Хотя как знаем... Например, тип double вполне себе стандартный и имеет размер 8 байт что на 32-битных Linux-системах, что на 64-битных. Но неожиданно выравнивание может оказаться разным. На 32-битной системе double выравнивается по границе 4 байта, а в 64-битной системе — по границе 8 байт. Так и живём.

Для других типов, специализированных для работы с SSE/SIMD инструкциями, выравнивание может быть и больше.

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

struct S {
  char c;    // 1
  // неявно char _padding[3] 
  int32_t i; // 4
};

static_assert(alignof(S) == 4);

Выравнивание массива соответствует выравниванию его элементов.

Поэтому:

char buff[sizeof(T)]; // alignment == 1
...
T* obj = new (buff) T(...); // (uintptr_t)(obj) должен быть
// кратен alignof(T), но в этом коде гарантируется
// только то, что он кратен 1

Для встроенных типов на x86 доступ по невыровненным указателям чаще всего просто приводит к более медленному исполнению. На других платформах же может быть segfault. Искать такие проблемы можно, например, с помощью анализатора PVS-Studio: V1032 Pointer is cast to a more strictly aligned pointer type.

С SSE типами и на x86 можно легко получить segfault, причём довольно красиво:

#include <memory>
#include <xmmintrin.h>

const size_t head = 0;
struct StaticStorage {
  char buffer[256];
} storage;

int main() {
  __m128i* a = new (storage.buffer) __m128i();
  // comment line above & uncomment any line below for segfault
  // __m128i* b = new (storage.buffer + 0) __m128i();
  // __m128i* c = new (storage.buffer + head) __m128i();
}

Что же делать?! Как же написать код без такого интересного неопределённого поведения? Не волнуйтесь, С++11 спешит на помощь!

Стандарт предоставляет alignas спецификатор. С его помощью можно явно указать требования к выравниванию при описании переменных и структур.

#include <memory>
#include <xmmintrin.h>

const size_t head = 0;
struct StaticStorage {
  alignas(__m128i) char buffer[256];
} storage;

int main() {
  __m128i* a = new (storage.buffer) __m128i();
  __m128i* b = new (storage.buffer + 0) __m128i();
  __m128i* c = new (storage.buffer + head) __m128i();
}

И вот уже программа и не падает.

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

Конечно есть!

std::aligned_storage и его старший брат std::aligned_union:

template<std::size_t Len,
         std::size_t Align =
           /* default alignment not implemented */
        >
struct aligned_storage
{
  struct type
  {
    alignas(Align) unsigned char data[Len];
  };
};

template <std::size_t Len, class... Types>
struct aligned_union
{
  static constexpr std::size_t alignment_value =
                                 std::max({alignof(Types)...});
 
  struct type
  {
    alignas(alignment_value)
      char _s[std::max({Len, sizeof(Types)...})];
  };
};

Это практически то же самое, что было в примере выше! Первый — совсем низкоуровневый, ему нужно напрямую число-значение выравнивания указать. А второй — более умный, он сам подходящее значение по списку типов выберет. Да ещё и размер буфера подстроит, если мы неправильный указали. Какая удобная метафункция!

Давайте же ею воспользуемся:

#include <memory>
#include <xmmintrin.h>
#include <type_traits>

const size_t head = 0;
std::aligned_union<256, __m128i> storage;

int main() {
  __m128i* a = new (&storage) __m128i();
  __m128i* b = new ((char*)(&storage) + 0) __m128i();
  __m128i* c = new ((char*)&storage + head) __m128i();
}

И сразу же всё упало: SIGSEGV.

Но как же так?! Ведь всё же верно...

А давайте проверим.

static_assert(sizeof(storage) >= 256);
<source>:9:1: error:
static assertion failed due to requirement
'sizeof (storage) >= 256'
static_assert(sizeof(storage) >= 256);
^             ~~~~~~~~~~~~~~~~~~~~~~
<source>:9:31: note: expression evaluates to '1 >= 256'
static_assert(sizeof(storage) >= 256);

Дивно. Но если мы ещё раз внимательно посмотрим на примеры определения шаблонов std::aligned_storage выше, то обнаружим великую подлость и предрасположенность этих шаблонов к ошибке использования.

Нам нужно использовать typename std::aligned_union<256, __m128i>::type storage!

Или std::aligned_union_t<256, __m128i> storage в C++17.

Теперь всё работает. Разница всего в два символа, а какие последствия.

На момент написания книги GCC 14.1 способен из коробки выдать предупреждения:

<source>:12:23: warning:
placement new constructing an object of type '__m128i' and
size '16' in a region of type
'std::aligned_union<256, __vector(2) long long int>' and
size '1' [-Wplacement-new=]
   12 |     __m128i* a = new (&storage) __m128i();

Наводящие на мысль об ошибке.

Компилятор Clang 18.1 по умолчанию такого не сообщает.

std::aligned_* признаны невероятно опасными к использованию. Из-за ужасного дизайна, ошибки при его использовании очень легко прячутся.

В C++23 их пометили как deprecated. Но кто ж знает, когда мы увидим C++23 в больших и старых кодовых базах...

Если вы используете std::aligned_* в своём коде, то убедитесь дважды, что используете его правильно. А лучше замените на свою структуру с явным использованием alignas.

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: (не)явное приведение типов

Рассматривая графики метрик, отображающие число ожидающих обработки запросов в момент времени, дежурный инженер заметил, что с графиком что-то не так: отсутствие какого-либо пульса, просто горизонтальная прямая. Взглянув на ось Y, инженер увидел, что график замер на значении 18446744073709552000. Такому вселенскому масштабу траффика могли бы позавидовать все крупные компании. Но, разумеется, никакого траффика не было. А была ошибка в метрике, даже целых две. Ну и чтобы все любители C++ порадовались, скажу, что этот код был написан на Rust.

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

Произошло переполнение. Хорошо. Счётчик, похоже, 64-битный, раз такое большое число. Но постойте. Беззнаковый -1 в uint64 — это 18446744073709551615. А число, которое увидел инженер, немного больше...

Код, генерирующий метрики, был следующего вида:

metrics.set(Metric::InflightRequests, counter as _);

Очевидно, в деле было замешано приведение типов. Второй аргумент метода set ожидал тип f64.

Любознательный читатель должен поглядеть в стандарт IEEE 754 и найти разгадку магии чисел. Для менее любознательного читателя скажу лишь, что f64(u64(-1)) == f64(u64(-1024)).

counter as _

Явное преобразование к чему-то непонятному, известному из контекста, но совсем неочевидному при чтении. Полезная и сомнительная фича Rust.

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

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

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

// Тут вы написали очень длинный и развёрнутый комментарий,
// что значения имеют тип double (f64), потому что так надо.
// И пользователь должен иметь в виду сопутствующие ограничения.
// И все такое прочее...
struct MetricSample{
  // Чтобы избежать неявного приведения вы сразу же
  // добавили explicit, как советуют все best practices.
  explicit MetricSample(double val): value {val} {}
private:
  double value;
};

class Metrics {
public:
  // Отлично, теперь у пользователя нет иного варианта, как выполнить
  // явное преобразование к MetricSample,
  // а там уж он почитает документацию...
  void set(std::string_view metric_name, MetricSample val);
};

// Вы пишете UX тест.
int main() {
  uint64_t value = -1;
  Metrics m;
  m.set("InflightRequests", value);
  m.set("InflightRequests" MetricSample{value});
}

И он не компилируется, как вы и хотели.

<source>:23:31: error:
no viable conversion from 'uint64_t' (aka 'unsigned long') to
'MetricSample'
   23 |     m.set("InflightRequests", value);
      |                               ^~~~~
<source>:4:8: note: candidate constructor (the implicit copy constructor)
not viable: no known conversion from 'uint64_t' (aka 'unsigned long')
to 'const MetricSample &' for 1st argument
    4 | struct MetricSample{
      |        ^~~~~~~~~~~~
<source>:4:8: note: candidate constructor (the implicit move constructor)
not viable: no known conversion from 'uint64_t' (aka 'unsigned long')
to 'MetricSample &&' for 1st argument
    4 | struct MetricSample{
      |        ^~~~~~~~~~~~
<source>:7:14: note: explicit constructor is not a candidate
    7 |     explicit MetricSample(double val): value {val} {}
      |              ^
<source>:16:57: note: passing argument to parameter 'val' here
   16 |     void set(std::string_view metric_name, MetricSample val);
      |                                                         ^
<source>:24:30: error: expected ')'
   24 |     m.set("InflightRequests" MetricSample{value});
      |                              ^
<source>:24:10: note: to match this '('
   24 |     m.set("InflightRequests" MetricSample{value});
      |          ^

Отлично. Дело сделано. Релизим.

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

Ваша защита от неявного приведения типов не содержит защиты от опытного дурака:

int main() {
  uint64_t value = -1;
  Metrics m;
  m.set("InflightRequests", MetricSample(value));
}

И оно компилируется.

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

Решение есть! Спасибо, C++20:

#include <concepts>

struct MetricSample{
  // Теперь только double может быть передан. 
  // Никаких неявных преобразований, поскольку это — шаблон.
  explicit MetricSample(std::same_as<double> auto val) :
    value {val} {}
private:
  double value;
};

int main() {
  uint64_t value = -1;
  Metrics m;
  m.set("InflightRequests", MetricSample(value));
  m.set("InflightRequests", MetricSample{value});
  m.set("InflightRequests", value);
}

Теперь ничего не компилируется.

Всё? Нет, подождите. Это же C++, а не у всех есть C++20! Вот версия для C++14 и C++17. Даже для C++11 можно сделать (можете взять это в качестве домашнего задания).:

#include <type_traits>

struct MetricSample{
  // Теперь только double может быть передан. 
  // Никаких неявных преобразований, поскольку это — шаблон.
  template <typename Double, 
            typename = std::enable_if_t<std::is_same_v<Double, double>>
            >
  explicit MetricSample(Double val): value {val} {}
private:
  double value;
};

Надеюсь, она убедит вас и ваших пользователей переходить на версии C++20 и выше.

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

Не вопрос. Вы решаете добавить перегрузку метода set с третьим — строковым — параметром.

class Metrics {
public:
  // Чтобы сделать явной для пользователя необходимость
  // аллоцировать память под строку и не делать лишних неявных
  // копий, вы решаете использовать rvalue reference.
  // Ведь это — отличный способ продемонстрировать, что ваш
  // интерфейс желает заполучить владение строкой.
  // И пользователь будет должен выполнить явный move.
  void set(std::string_view metric_name, MetricSample val,
           std::string&& comment);
}

int main() {
  Metrics m;
  auto val = MetricSample(1.0);
  std::string comment = "comment";
  m.set("MName", val, comment); // не компилируется, как и хотели
  m.set("MName", val, "comment"); // сомнительно, но для удобства Ok
  m.set("MName", val, std::move(comment));
  m.set("MName", val,
        std::string_view("comment")); // не компилируется, хорошо
  auto gen_comment = []()->std::string { return "comment"; };
  m.set("MName", val, gen_comment()); // отлично
}

Всё хорошо. Релизим. Через два дня к вам приходит пользователь и говорит, что он отстрелил себе ногу вашей библиотекой. И показывает ЭТО:

int main() {
  Metrics m;
  auto val = MetricSample(1.0);
  m.set("Metric", val, 0);
}

Output:
terminate called after throwing an instance
of 'std::logic_error' what():
basic_string: construction from null is not valid
Program terminated with signal: SIGSEGV

В этот момент вы проклинаете класс std::string, неявную интерпретацию 0 как указателя, а также пользователя, который совершенно не читает не только документацию, но и вообще код, который написал. Вы справляетесь с желанием написать собственный класс строк и начинаете думать, как подстелить солому и в этом случае.

Здесь, конечно, начинаются самые разные варианты. Мы можем разрешить только rvalue string:

class Metrics {
public:
  // только rvalue ссылки на string. Никакого неявного приведения типов
  void set(std::string_view metric_name, 
           MetricSample val, 
           std::same_as<std::string> auto&& comment) {};
};


int main() {
  Metrics m;
  auto val = MetricSample(1.0);
  std::string comm = "comment";
  m.set("Metric", val, comm); // не компилируется
  m.set("Metric", val, 0); // не компилируется
  m.set("Metric", val, std::move(comm)); // компилируется, как и хотели
  m.set("MName", val,
        std::string_view("comment")); // не компилируется, хорошо
  auto gen_comment = []()->std::string { return "comment"; };
  m.set("MName", val, gen_comment()); // отлично
}

Но вы уже живете в проклятом мире: вы разрешали использовать строковые литералы напрямую. Если их запретить, пользователь расстроится — у него код перестанет компилироваться. Так что придётся добавить и их. А чтобы пользователь не совал нулевые указатели и использовал только строковые литералы, есть замечательное решение — ссылки на массивы! Ведь строковые литералы — это массивы...

class Metrics {
public:
  // Только строковые литералы и явная передача владения
  // строкой разрешены вашим интерфейсом.
  void set(std::string_view metric_name, MetricSample val,
           std::same_as<std::string> auto&& comment) {};
  template <size_t N>
  void set(std::string_view metric_name, MetricSample val,
           const char(&comment)[N]) requires (N > 0) {
    this->set(metric_name, val, std::string(comment, N-1));
  }
};


int main() {
  Metrics m;
  auto val = MetricSample(1.0);
  std::string comm = "comment";
  const char* null_comment = 0;
  m.set("Metric", val, "comment"); // "ok"
  m.set("Metric", val, null_comment); // не компилируется
  m.set("Metric", val, comm); // не компилируется
  m.set("Metric", val, 0); // не компилируется
  m.set("Metric", val, std::move(comm)); // компилируется, как и хотели
  m.set("MName", val,
        std::string_view("comment")); // не компилируется, хорошо
  auto gen_comment = []()->std::string { return "comment"; };
  m.set("MName", val, gen_comment()); //  работает отлично
}

Всё отлично. Релизим!

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

В заключение стоит добавить, что в C++23 появилось новое применение для ключевого слова auto:

void call_it(auto&& obj) {
  call_impl(auto(obj));
}

Я видел разработчиков, которым приходится много работать одновременно с Rust и C++, и эта новая фича для них выглядит как преобразование obj к какому-то типу, который указан как аргумент call_impl. Прямо как as _ или вызов Into::into() в Rust. Это могло бы быть очень логичным...

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

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: std::ranges::views (как-то лениво)

2024 год. C++20 уже как четыре года готов (не совсем) к использованию в серьёзной production-разработке. По крайней мере, мне недавно сообщили, что компиляторы наконец-то обновлены и мы уже можем...

С++20 принёс четыре крупные фичи. Две из них сразу готовы к употреблению в вашем коде, а ещё две не очень. Здесь мы будем говорить о первых двух.

std::ranges

Революция в методе работы с последовательностями элементов в C++! Последний раз такое было, когда в C++11 range-based-for сделали. И вот опять.

Забудьте о паре итераторов begin/end и мучениях с тем, чтобы лихо и красиво выбросить все нечётные числа, а все чётные возвести в квадрат, как это можно сделать в других высокоуровневых языках:

let v : Vec<_> = ints.iter()
                     .filter(|x| x % 2 == 0)
                     .map(|x| x * x)
                     .collect();

List<int> v = Stream.of(ints)
      .filter(x -> x % 2 == 0)
      .map(x -> x * x)
      .collect(Collectors.toList());

var v = ints.Where(x => x % 2 == 0)
            .Select(x => x * 2)
            .ToList();

// До C++20
std::vector<int> v;
std::copy_if(ints.begin(), ints.end(),
             std::back_inserter(v), [](int x)
             { return x % 2 == 0;});
std::transform(v.begin(), v.end(), v.begin(),
               [](int x){return x * x;});

// После С++20
std::vector<int> v;
std::ranges::copy(
    ints | std::views::filter([](int x){ return x % 2 == 0;})
         | std::views::transform([](int x) { return x * x;})
    std::back_inserter(v)
);

// После C++23
auto v = 
    ints | std::views::filter([](int x){ return x % 2 == 0;})
         | std::views::transform([](int x) { return x * x;})
         | std::ranges::to<std::vector>();

Красота! Только компилируется оно долго и оптимизируется не всегда хорошо, но ничего страшного...

Concepts

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

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

std::integral auto sum(std::integral auto a
                       std::integral auto b) {
  return a + b;
}

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

А теперь стреляем с двух рук!

Один разработчик написал в общий C++ чат, что он работает над добавлением тестов к какой-то новой фиче в библиотеке, которая так здорово реализована с помощью C++20 и ranges, да вот только что-то странное происходит: тесты падают, Valgrind что-то совершенно невразумительное говорит...

Код фичи был таков:

struct MetricsRecord {
   std::string tag;
   // ....
};

struct Metrics {
  std::vector<MetricsRecord> records;

  std::ranges::range auto by_tag(const std::string& tag) const;
  // ....
};

// .... много-много кода

std::ranges::range auto
Metrics::by_tag(const std::string& tag) const {
  return records |
         std::ranges::views::filter([&](auto&& r)
           { return r.tag == tag; });
}

Ничего страшного, вроде всё нормально. Никаких проблем не видно.

Но посмотрим тесты:

int main() {
  auto m = Metrics {
    {
      {"hello"}, {"world"}, {"loooooooooooooooongtag"}
    }
  };

  {
    // печатает found
    auto found = m.by_tag("hello");
    for (const auto& f: found) {
      std::cout << std::format("found {}\n", f.tag);
    }
  }

  {
    // не печатает... странно
    auto found = m.by_tag("loooooooooooooooongtag");
    for (const auto& f: found) {
      std::cout << std::format("found {}\n", f.tag);
    }
  }

  {
    // а так работает
    std::string tag = "loooooooooooooooongtag";
    auto found = m.by_tag(tag);
    for (const auto& f: found) {
      std::cout << std::format("found {}\n", f.tag);
    }
  }
}

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

// Неявное создание временной переменной
// std::string в аргументе!
auto found = m.by_tag("loooooooooooooooongtag");

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

std::ranges::range auto
Metrics::by_tag(const std::string& tag) const {
  return records |
         std::ranges::views::filter([&](auto&& r)
           { return r.tag == tag; });
}

А ещё в том, что разработчик долгое время писал на JavaScript, а там Array.prototype.filter сразу же создаёт новый массив:

const words = ['spray', 'elite', 'exuberant',
               'destruction', 'present'];

const result = words.filter((word) => {
   console.log(word);
   return word.length > 6
  });
// Сразу же будут напечатаны все элементы.

И разработчик просто не понял сразу, что метод ленивый (и что все std::ranges ленивые).

Всё так просто! Вам нужно просто правильно использовать C++, и проблем не будет! Или будут?..

std::ranges::range auto
  Metrics::by_tag(const std::string& tag) const

Можно ли по сигнатуре метода догадаться, что он ленивый? Вряд ли. std::ranges::range auto не даёт об этом никакой информации.

Это должно быть написано в документирующем комментарии. Но его не было. Либо должен был быть использован концепт std::ranges::view. Ах, как обычно, вот если бы всё было сделано правильно...

Но зато Valgrind поймал ошибку! Да. В тестах к библиотеке. Кто знает, есть ли тесты у пользователей этой библиотеки...

Пусть пишут тесты! Пусть используют статический анализ! Ага. Возможно, они помогут. На момент написания этого текста (апрель 2024 года): ни Clang-Tidy, ни PVS-Studio не могут диагностировать эту ошибку.

Ну хорошо. Все теперь будут заранее читать документацию и будут знать, что std::viewes — ленивые. А значит, захватывать ссылки с ними нужно осторожно. Закрываем вопрос.

Секция ниже во многом вдохновлена выступлением Nicolai Josuttis на Keynote Meeting C++ 2022.

Подождите. std::ranges не просто ленивые, а невероятно ленивые! Им иногда не просто лень итерироваться по контейнеру, а даже лень у контейнера begin() и end() лишний раз вызвать. Причина такой лени — требования стандарта обеспечить в среднем константное время выполнения методов begin() и end():

Given an expression t such that decltype((t)) is T&, T models range only if (2.1)

[ranges::begin(t), ranges::end(t)) denotes a range ([iterator.requirements.general]), (2.2) both ranges::begin(t) and ranges::end(t) are amortized constant time and non-modifying, and ....

Поэтому некоторые views:

  • откладывают вызов begin/end у контейнера при конструировании;
  • кэшируют свои begin/end после их первого вычисления.

И получаются интересные спецэффекты:

void print_range(std::ranges::range auto&& r) {
  for (auto&& x: r) {
    std::cout << x << " ";
  }
  std::cout << "\n";
}

void test_drop_print() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::drop(2); // Пропустить первые два.
  ints.push_front(-5);
  print_range(v); // -5 и 1 пропущены.
                  // drop вызвал begin и end только сейчас.
}

void test_drop_print_before_after() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::drop(2);
  print_range(v); // 1, 2 пропущены
  ints.push_front(-5);
  print_range(v); // 1, 2 пропущены!
                  // drop не вызывает begin и end ещё раз.
}

void test_take_print() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::take(2);
  ints.push_front(-5);
  print_range(v); // -5, 1 выведены
}

void test_take_print_before_after() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::take(2);
  print_range(v); // 1, 2 выведены
  ints.push_front(-5);
  print_range(v); // -5, 1 выведены!
                  // take вызывает begin и end каждый раз.
}

drop: 
2 3 4 5 
------
3 4 5 
3 4 5 
take: 
-5 1 
------
1 2 
-5 1

Здорово, совершенно естественно, а главное предсказуемо! Нет никакой магии, если знать как оно работает... Главное не ошибиться при использовании на практике.

Просто не надо брать и модифицировать контейнер, когда на него взят ranges::view. Это же так просто.

Кстати, если мы сделаем одно крохотное изменение:

void print_range(std::ranges::range auto r) // by value теперь
{
  for (auto&& x: r) {
    std::cout << x << " ";
  }
  std::cout << "\n";
}

void test_drop_print_before_after() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::drop(2);
  print_range(v); // 1, 2 пропущены
  ints.push_front(-5);
  print_range(v); // -5, 1 пропущены!
                  // Мы же теперь сделали копию view, и
                  // копия снова вызвала begin() и end().
}

Следующим вытекающим спецэффектом такого ленивого и иногда кэширующего поведения является то, что в функцию, принимающую const std::range::range&, абы какой view подставить нельзя.

void print_range(const std::ranges::range auto& r) {
  for (auto&& x: r) {
    std::cout << x << " ";
  }
  std::cout << "\n";
}

void test_drop_print() {
  std::list<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::drop(2);
  print_range(v); // Не компилируется!
                  // drop от std::list должен быть мутабельным.
  /*
    <source>: In instantiation of
      'void print_range(const auto:42&) [with auto:42 =
      std::ranges::drop_view<std::ranges::ref_view<
        std::__cxx11::list<int> > >]':
    <source>:19:16:   required from here
             19 |     print_range(v);
                |     ~~~~~~~~~~~^~~
    <source>:10:5: error: passing 'const std::ranges::drop_view<
      std::ranges::ref_view<std::__cxx11::list<int> > >' as
      'this' argument discards qualifiers [-fpermissive]
             10 |     for (auto&& x: r) { 
  */
}

void test_drop_print_vector() {
  std::vector<int> ints = {1, 2, 3 ,4, 5};
  auto v = ints | std::views::drop(2);
  print_range(v); // Всё ок.
}

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

Для начала вот эти четыре:

  • std::ranges::range — слишком абстрактный: только begin и end;
  • std::ranges::view — тоже range, но ему удовлетворяют только views;
  • std::ranges::borrowed_range — тоже слишком абстрактный, но его итераторы безопасно возвращать из функций;
  • std::ranges::constant_range (С++23) — тоже абстрактный, но итераторы дают только read-only доступ.

А потом ещё и оставшиеся подключатся.

Последним выдающимся спецэффектом ленивого кэширования является следующий курьёз:

enum State {
  Stopped,
  Working,
  ....
};

struct Unit {
  State state;
  ....
};

....

std::vector<Unit> units; 
....

// stop all working units
for (auto& unit: units | std::views::filter{[](auto& unit)
  { return unit.state == Working; }}) {
    ....
    unit.state = State::Stopped; // UB!
    // https://eel.is/c++draft/range.filter#iterator-1
    /*
    Modification of the element a filter_view​::​iterator
    denotes is permitted, but results in undefined behavior
    if the resulting value does not satisfy the filter predicate.
    */
}

Стандарт явно запрещает изменять элементы, найденные с помощью std::views::filter, таким образом, чтобы результат предиката менялся! Всё из-за предположения, что вы, возможно, ещё раз будете итерироваться по тому же самому view. И, чтобы не делать работу дважды, нужно закэшировать begin().

Самое чудовищное в том, что такое поведение закреплено стандартом. Это не implementation defined:

Remarks: In order to provide the amortized constant time complexity required by the range concept when filter_view models forward_range, this function caches the result within the filter_view for use on subsequent calls.

Полезные ссылки

Неработающий синтаксис и стандартная библиотека: как передать стандартную функцию и ничего не сломать

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

И вы взяли готовую прекрасную функцию интегрирования:

template <class F>
concept NumericFunction =
  std::is_invocable_v<F, float> && requires (float arg, F f) {
    { f(arg) } -> std::convertible_to<float>;
};

float integrate(NumericFunction auto f) {
  float sum = 0;
  /* Мы не будем вдаваться в подробности точности,
     сходимости, шагов разбиения и выбора точки,
     хотя это тоже очень важно, но в другой книжке. */
  for (float x : std::views::iota(1, 26)) {
    sum += f(x);
  }
  return sum;
}

Отлично. Вы начинаете её тестировать на какой-нибудь стандартной функции:

#include <cmath>
....
int main() {
  return integrate(sqrt); // Всё ок?
                          // (приведение к int считаем нормальным)
}

Вроде да. Программа возвращает 85.

На самом деле нет. И проблемы тут как минимум две.

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

static_assert(std::abs(5.8) > 5.5);
static_assert(abs(5.8) > 5.5);

//---------------------

<source>:26:24: error: static assertion failed
   26 | static_assert(abs(5.8) > 5.5);
      |               ~~~~~~~~~^~~~~
<source>:26:24: note: the comparison reduces
to '(5.0e+0 > 5.5e+0)'
Compiler returned: 1

Окей. Мы поняли. Надо использовать std::sqrt, чтобы не попасть не в ту перегрузку.

int main() {
  return integrate(std::sqrt);
}

// ---------------

<source>:22:21: error: no matching function for call to
'integrate(<unresolved overloaded function type>)'
   22 |     return integrate(std::sqrt);

Ага, overloaded function type. И как же нам тогда выбрать нужный?

Вы пошли в Google с этим вопросом, и первая ссылка привела вас на какой-нибудь Qt-форум (о, в Qt это распространённая проблема — указать перегрузку при соединении сигналов и слотов). И самый релевантный ответ был — соорудить явное приведение типов указателей на функцию.

int main() {
  return integrate(
    static_cast<float(*)(float)>(&std::sqrt));
}

Ура, работает! Программа по-прежнему возвращает 85. Но это немного по-другому посчитанные 85 :)

Поздравляю...

2. ...Вы нарушили пункт 16.4.5.2.6

Let F denote a standard library function ([global.functions]), a standard library static member function, or an instantiation of a standard library function template. Unless F is designated an addressable function, the behavior of a C++ program is unspecified (possibly ill-formed) if it explicitly or implicitly attempts to form a pointer to F.

Вызов integrate(static_cast<float(*)(float)>(&std::sqrt)); делает именно это. Вы взяли указатель на функцию. Указатели почти на любую функцию стандартной библиотеки брать нельзя.

Изначальный вариант с return integrate(sqrt), использующий sqrt из библиотеки C, также попадает в эту ловушку, только неявно.

А с версии C++20 грозят, что может перестать компилироваться, но я пока такого не видел.

Почему нельзя?

А кто вам сказал, что это функция?

Да, почти все функции стандартной библиотеки C++17 после инстанциирования шаблонов всё-таки оказываются нормальными функциями, и потому у нас уж сколько лет всё работает.

C функциями стандартной библиотеки C всё, конечно, хуже — они могут быть макросами, и чёрт его знает от чего вы на самом деле взяли адрес в таком случае.

С версии C++20 (вдохновлённые ranges Эрика Ниблера) новые (а также потенциально старые, после перехода std на модули) функции внезапно могут оказаться ниблоидами. Глобальными объектами с определённым operator(). Так что они могут выглядеть и крякать как старые добрые функции, но таковыми не быть. И, если вы использовали С-style каст вместо громоздкого static_cast, вас могут ждать интересные результаты:

// Старая версия
// float f(float a) {
//   return a;
// }

// Вы обновились, и теперь это — ниблоид!
auto f = [](float a) -> float {
    return a;
};

int main() {
  return integrate((float(*)(float))(&f));
  // Segfault
}

Положение могло бы спасти отсутствие & перед именем функции. Для функций и лямбд применяется неявный decay к указателю:

// float f(float a) {
//   return a;
// }

auto f = [](float a) -> float {
  return a;
};

int main() {
  return integrate((float(*)(float))(f));
  // Компилируется и работает.
}

Ниблоиды в std чаще определяются как показано ниже, а не с помощью лямбд:

struct {
  static float operator()(float x) {
    return x;
  } 
} f;

int main() {
  return integrate((float(*)(float))(f));
  // error: invalid cast from type '<unnamed struct>'
  // to type 'float (*)(float)'
}

Не компилируется, и нам ужасно повезло, что это так!

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

Что же делать?

Хорошая новость: если когда-нибудь вся замечательная гора стандартных функций станет вызываемыми объектами, ваш integrate(std::sqrt) будет компилироваться и работать правильно из коробки. И все будут счастливы.

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

Проблема решится оборачиванием вызова к std функции в вашу функцию или в лямбду.

int main() {
  return integrate([](float x) {
    return std::sqrt(x);
  });
}

Или можно завести вспомогательный макрос. Если использовать C++20, то он выглядит менее страшно, чем обычно.

#define LAMBDA_WRAP(f) []<class... T>(T&&... args) \
  noexcept(noexcept(f(std::forward<T>(args)...))) -> \
    decltype(auto) \
  { return f(std::forward<T>(args)...); }

int main() {
  return integrate(LAMBDA_WRAP(std::sqrt));
}

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

Если вызов шаблонной функции integrate по какой-либо причине не может быть заинлайнен компилятором, и вы передаёте указатель на функцию, компилятор не имеет никакого выбора, кроме как генерировать инструкцию call по этому указателю:

#define LAMBDA_WRAP(f) []<class... T>(T&&... args) \
  noexcept(noexcept(f(std::forward<T>(args)...))) -> \
    decltype(auto) \
  { return f(std::forward<T>(args)...); }

float my_sqrt(float f) {
  return std::sqrt(f);
}

int main() {
  return integrate(my_sqrt) + integrate(LAMBDA_WRAP(std::sqrt));
}

Ассемблерный код при использовании функции:

// float integrate<float (*)(float)>(float (*)(float)):
    push    rbp
    mov     rbp, rdi
    ....
.L24:
    pxor    xmm0, xmm0
    cvtsi2ss        xmm0, ebx
    add     ebx, 1
    call    rbp  // ! нет информации о функции - вызов по указателю
    addss   xmm0, DWORD PTR [rsp+12]
    movss   DWORD PTR [rsp+12], xmm0
    cmp     ebx, 26
    ....
    ret

Ассемблерный код при использовании лямбды:

float integrate<
  main::{lambda<typename... $T0>(($T0&&)...)#1}>(
    main::{lambda<typename... $T0>(($T0&&)...)#1})
      [clone .isra.0]:
    ....
.L16:
    pxor    xmm0, xmm0
    cvtsi2ss        xmm0, ebx
    ucomiss xmm2, xmm0
    ja      .L21
    sqrtss  xmm0, xmm0 // ! sqrt подставлен
    add     ebx, 1
    addss   xmm1, xmm0
    cmp     ebx, 26
    jne     .L16
.L11:
    ...
.L21:
    movss   DWORD PTR [rsp+12], xmm1
    add     ebx, 1
    call    sqrtf  /// ! sqrt подставлен
    ....

Почему лямбда предпочтительнее, но не всегда?

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

int main() {
  return integrate(my_sqrt) +
         integrate(LAMBDA_WRAP(std::sqrt)) + 
         integrate(LAMBDA_WRAP(std::sqrt)) +
         integrate(LAMBDA_WRAP(std::sqrt));
}

Что поделать, раздутие кода — известный результат мономорфизации шаблонов/generic-функций.

Переиспользуйте лямбду, и будет лучше:

// Сгенерированный код в два раза меньше, чем для примера выше.
int main() {
  auto sqrt_f = LAMBDA_WRAP(std::sqrt);
  return integrate(my_sqrt) +
         integrate(sqrt_f) + 
         integrate(sqrt_f) +
         integrate(sqrt_f);
}

Полезные ссылки

Автор — Дмитрий Свиридкин

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на C и C++. С 2019 по 2021 год преподавал курсы системного программирования под Linux в СПбГУ и практики C++ в ВШЭ. В настоящее время — Software Engineer в AWS (Cloudfront), занимается системной и embedded-разработкой на Rust и C++ для edge-серверов. Основная сфера интересов — безопасность программного обеспечения.

Редактор — Андрей Карпов

Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активности.