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

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

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

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

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

Неработающий синтаксис и стандартная библиотека: Most Vexing Parse

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

Пользовательские типы и функции можно объявлять где попало и как попало.

template <class T>
struct STagged {};


using S1 = STagged<struct Tag1>;  // предобъявление структуры Tag1
using S2 = STagged<struct Tag2*>; // предобъявление структуры Tag2

void fun(struct Tag3*); // предобъявление структуры Tag3

void external_fun() {
  int internal_fun();   // предобъявление функции!
  internal_fun();
}

int internal_fun() {    // определение предобъявленой функции
  std::cout << "hello internal\n";
  return 0;
}

int main() {
  external_fun();
}

При этом определять сущности можно не везде. Типы можно определять локально — внутри функции. А функции определять нельзя.

void fun() {
  struct LocalS {
    int x, y;
  };                         // OK

  void local_f() {
    std::cout << "local_f";
  }                          // Compilation Error
}

И всё могло бы быть хорошо, если бы не одно: в C++ есть конструкторы, вызов которых похож на объявление функции.

struct Timer {
  int val;
  explicit Timer(int v = 0) : val(v) {}
};

struct Worker {
  int time_to_work;

  explicit Worker(Timer t) : time_to_work(t.val) {}

  friend std::ostream&
  operator << (std::ostream& os, const Worker& w) {
    return os << "Time to work=" << w.time_to_work;
  }
};

int main() {

  // ЭТО НЕ ВЫЗОВ КОНСТРУКТОРА!
  Worker w(Timer()); // Предъобъявление функции,
                     // которая возвращает Worker и принимает функцию,
                     // возвращающую Timer и не принимающую ничего!

  std::cout << w;    // Имя функции неявно преобразуется к указателю,
                     // который неявно преобразуется к bool.
                     // Будет выведено 1 (true)
}

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

Может показаться, что виноват именно конструктор по умолчанию класса Timer. На самом деле, виноват C++. В нём можно объявлять функции вот так:

void fun(int (val)); // Скобки вокруг имени параметра допустимы!

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

int main() {
  const int time_to_work = 10;
  Worker w(Timer(time_to_work)); // Предъобъявление функции,
                                 // которая возвращает Worker
                                 // и принимает параметр типа Timer.
                                 // time_to_work — имя этого параметра!

  std::cout << w;                // Печатаем 1
}

GCC и Clang способны предупреждать о подобном.

В С++11 и далее предлагают uniform initialization (через {}), которая не совсем универсальна и имеет свои проблемы. C++20 предлагает ещё одну universal инициализацию, но снова через ()...

Избежать проблемы можно, используя Almost Always Auto подход с инициализацией вида auto w = Worker(Timer()). Круглые или фигурные скобки здесь — не так важно (хотя, на самом деле, важно, но в другой ситуации).

Возможно, когда-нибудь объявление функций в старом сишном стиле запретят в пользу trailing return type (auto fun(args) -> ret). И вляпаться в рассмотренную прелесть станет значительно сложнее (но все равно возможно!).

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

В С++ есть ключевое слово const, позволяющее помечать значения как неизменяемые. Также в C++ есть const_cast, позволяющий этот const игнорировать. И иногда за это вам ничего не будет. А иногда будет неопределённое поведение, segfault и прочие радости жизни.

Разница между этими "иногда" в том, что есть настоящие константы, попытка модификации которых — UB. А есть ссылки на константы, ссылающиеся не на константы. И раз на самом деле объект неконстантен, то модифицировать его можно без проблем.

Так, например, эту "фичу" можно эксплуатировать, чтобы не повторять один и тот же код для const и не const методов класса:

class MyMap {
public:
  // какой-то метод с длинной реализацией:
  const int& get_for_val_or_abs_val(int val) const {
    const auto it_val = m.find(val);
    if (it_val != m.end()) {
      return it_val->second;
    }
    const auto abs_val = std::abs(val);
    const auto it_abs = m.find(abs_val);
    if (it_abs != m.end()) {
      return it_abs->second;
    }
    throw std::runtime_error("no value");
  }

  int& get_for_val_or_abs_val(int val) {
    return const_cast<int&>( // Отбрасываем const с результата.
      // Находясь в неконстантном методе,
      // мы знаем, что результат
      // в действительности не является константой,
      // и проблем не будет.
      std::as_const(*this) // Навешиваем const,
                           // чтобы вызвать const-метод,
                           // а не уйти в бесконечную рекурсию.
      .get_for_val_or_abs_val(val));
  }

  void set_val(int val, int x) {
    m[val] = x;
  }
private:
  std::map<int, int> m;
};

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

Вместо использования const_cast и привнесения в мир C++ ещё большей нестабильности, решить проблему дублирования кода можно с помощью шаблонного метода:

class MyMap {
public:
  const int& get_for_val_or_abs_val(int val) const {
    return get_for_val_or_abs_val_impl(*this, val); // *this — const&
  }

  int& get_for_val_or_abs_val(int val) {
    return get_for_val_or_abs_val_impl(*this, val); // *this  — &
  }

  void set_val(int val, int x) {
    m[val] = x;
  }
private:
  template <class Self> static decltype(auto)
    get_for_val_or_abs_val_impl(Self& self, int val)
  {
    auto&& m = self.m;
    if (it_val != m.end()) {
      // Дополнительные скобки для вывода категории значения
      return (it_val->second);
    }
    const auto abs_val = std::abs(val);
    const auto it_abs = m.find(abs_val);
    if (it_abs != m.end()) {
      return (it_abs->second);
    }
    throw std::runtime_error("no value");
  }

  std::map<int, int> m;
};

У этого варианта есть свои недостатки, сломать его ещё проще (скобки и decltype). Но, единожды его написав, можно рассчитывать, что странная магия отпугнёт желающих этот код поправить.

Конечно, вместо decltype(auto) можно написать чуть больше кода с явным указанием типов возвращаемых значений.

Const и оптимизации

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

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

Примечание. Стоит отметить, что у программистов встречаются завышенные ожидания о оптимизации кода компилятором, когда они добавляют побольше const. Хорошая заметка на эту тему "Why const Doesn't Make C Code Faster".

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

using predicate = bool (*) (int);

int count_if(const std::vector<int>& v, predicate p) {
  int res = 0;
  for (size_t i = 0; i < v.size(); ++i) // Значение v.size() нельзя
  {                                     // единожды сохранить в регистре.
    if (p(v[i])) // Конкретный p может иметь доступ к 
    {            // изменяемой ссылке на этот же самый v.
      ++res;
    }
    // код метода size() придётся выполнять на каждой итерации!
  }
   return res;
}

Пример, запрещающий оптимизацию, может быть неочевиден, но тоже прост:

std::vector<int> global_v = {1};

bool pred(int x) {
  if (x == global_v.size()) {
    global_v.push_back(x);
    return true;
  } else {
    return false;
  }
}

int main() {
  return count_if(global_v, pred);
}

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

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

Учитывая ограниченные возможности на автоматическую оптимизацию, подобный цикл переписывают (делая ту самую работу, которую ждали от компилятора):

int count_if(const std::vector<int>& v, predicate p) {
  int res = 0;

  // range-based-for не обращается к size()
  // а один раз считывает begin/end итераторы и работает
  // с ними. 
  for (auto x : v) { 
    if (p(v[i])) {
      ++res;
    }
  }
  return res;
}

В таком случае при передаче "нехорошего" предиката, меняющего вектор, мы получим неопределённое поведение. Но это уже совсем другая история...

Вот менее тривиальный пример const, никак не способствующего оптимизации:

void find_last_zero_pos(const std::vector<int>& v,
                        const int* *pointer_to_last_zero) {
  *pointer_to_last_zero = 0;
  // Опять не можем один раз сохранить значение v.size()
  for (size_t i = 0; i < v.size(); ++i) {
    if (v[i] == 0) {
      // Внутри вектора есть поля типа int* — begin, end.
      // Что, если pointer_to_last_zero указывает на один из них?!
      *pointer_to_last_zero = (v.data() + i);
    }
    // пересчитываем size!
  }
}

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

Но ненормальный код не запрещён! Применим грубую силу:

int main() {
  std::vector<int> a = {1,2,4,0};
  const int* &data_ptr =
    reinterpret_cast<const int* &>(a); // ссылка на begin!
  find_last_zero_pos(a, &data_ptr);
}

И вот мы имеем парадоксальный результат: возможность написать явно некорректный код запрещает компилятору оптимизировать цикл! И вся концепция неопределённого поведения как возможности для оптимизации (некорректного кода не бывает) разваливается.

Что ж, по крайней мере, для этого примера имеется некоторая стабильность: исходный цикл со счётчиком и переписанный на range-based-for закончатся на неопределённом поведении.

В современных языках (например, в Rust, благодаря семантике владения) все эти циклы могут быть успешно оптимизированы.

Const, время жизни и происхождение указателей

Неизменяемые объекты всем хороши, кроме одного — это константные объекты в C++. Если они где-то засели, то их оттуда по-нормальному не выгонишь.

Что имеется в виду?

Пусть есть структура с константным полем:

struct Unit {
  const int id;
  int health;
};

Из-за константного поля объекты Unit теряют операцию присваивания. Их нельзя менять местами — std::swap больше не работает. std::vector<Unit> больше нельзя просто так отсортировать... В общем, сплошное удобство.

Но самое интересное начинается, если сделать что-то такое:

std::vector<Unit> units;
unit.emplace_back(Unit{1, 2});
std::cout << unit.back().id <<  " ";
unit.pop_back();
unit.emplace_back(Unit{2, 3});
std::cout << unit.back().id <<  "";

В зависимости от того, смогли ли при реализации вектора задушить агрессивные оптимизации компилятора, такой код может вывести либо 1 2 (всё хорошо), либо 1 1 (компилятор соптимизировал константное поле!).

Компилятор имеет право воспринимать происходящее следующим образом:

  • в векторе 1 элемент;
  • вектор не реаллоцировался;
  • указатель на элемент в первом cout и во втором cout один и тот же;
  • и там и там используется константное поле;
  • я его уже читал при первом cout;
  • зачем мне его читать ещё раз, это же константа;
  • вывожу закэшированное значение.

К сожалению или к счастью, воспроизвести подобное поведение компилятора на практике не получается. Тем не менее вот такой код, который может использоваться для реализации самописных std::optional, по стандарту содержит UB (и не одно!):

using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
new (&s) Unit{1,2};
std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
reinterpret_cast<Unit*>(&s)->~Unit(); // UB
new (&s) Unit{2,2};
std::cout << reinterpret_cast<Unit*>(&s)->id << "\n"; // UB
reinterpret_cast<Unit*>(&s)->~Unit(); // UB

Правильный вариант:

using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
auto p = new (&s) Unit{1,2};
std::cout << p->id << "\n";
p->~Unit();
p = new (&s) Unit{2,2};
std::cout << p->id << "\n";
p->~Unit();

Но поддерживать указатель, возращённый оператором new, не всегда возможно. Он занимает место, его надо хранить, что неэффективно при реализации optional: для int32_t будет нужно в три раза больше места на 64-битной системе (4 байта на storage + 8 байт на указатель)!

Поэтому в стандартной библиотеке, начиная с C++17, есть функция "отмывания" невесть откуда взявшихся указателей — std::launder.

using storage =
  std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
new (&s) Unit{1,2};
std::cout <<
  std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
std::launder(reinterpret_cast<Unit*>(&s))->~Unit();
new (&s) Unit{2,2};
std::cout <<
  std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";
std::launder(reinterpret_cast<Unit*>(&s))->~Unit();

Так и при чём тут const? "Настоящая" константность (переменные и поля, объявленные с const) вместе с UB при использовании "неправильных" указателей как раз и позволяют компилятору производить описанные спецэффекты.

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

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

При проектировании стандартной библиотеки C++ было принято множество странных решений, из-за которых приходится страдать. И исправить их не представляется возможным из-за соображений обратной совместимости.

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

Яркий пример:

using namespace std::string_literals;
std::string s1 { "Modern C++",  3 };
std::string s2 { "Modern C++"s, 3 };

std::cout << "S1: " << s1 << "\n";
std::cout << "S2: " << s2 << "\n";

Этот код выведет:

S1: Mod
S2: ern C++

Потому что у std::basic_string есть один конструктор, принимающий указатель и длину строки. А есть ещё один конструктор, принимающий "что-то похожее на строку" и позицию, с которой надо из неё извлечь подстроку!

На этом причуды не заканчиваются.

std::string s1 {'H', 3};
std::string s2 {3, 'H'};
std::string s3 (3, 'H');

std::cout << "S1: " << s1.size() << "\n";
std::cout << "S2: " << s2.size() << "\n";
std::cout << "S3: " << s3.size() << "\n";

Этот пример выведет:

S1: 2
S2: 2
S3: 3

Потому что у строки есть конструктор, принимающий число n и символ c, который нужно повторить n раз. А ещё есть конструктор, принимающий список инициализации (std::initializer_list<T>), состоящий из символов. И существование этого конструктора взаимодействует с неявным приведением типов!

  • std::string s1 {'H', 3}; — строка "H\3"
  • std::string s2 {3, 'H'}; — строка "\3H"
  • std::string s3 (3, 'H'); — строка "HHH"

Аналогичной проблемой страдает std::vector:

std::vector<int> v1 {3, 2}; // v1 == {3, 2}
std::vector<int> v2 (3, 2); // v2 == {2,2,2}

А ещё у контейнеров есть конструктор, принимающий пару итераторов. И, казалось бы, с ними уж проблем-то не будет, но у нас есть указатели, которые также являются итераторами. А ещё есть тип bool:

bool array[5] = {true, false, true, false, true};
std::vector<bool> vector {array, array + 5};
std::cout << vector.size() << "\n";

Будет выведено 2, а не 5. Потому что указатели неявно приводятся к bool!

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

narrowing conversion of '(bool*)(& array)' from 'bool*' to 'bool'.

Либо, как Сlang, отказывают в компиляции:

error: type 'bool *' cannot be narrowed to
'bool' in initializer list [-Wc++11-narrowing]

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

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

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

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

Начиная с C++11, у нас есть rvalue-ссылки и семантика перемещения. Причём перемещение не деструктивно: исходный объект остаётся жив, что порождает множество ошибок. Ещё есть проблемы с тем, как избегать накладных расходов при использовании перемещаемых объектов, но с этим можно жить.

Накладные расходы

Несмотря на все громкие заявления, абстракции в C++ имеют далеко не нулевую стоимость. Занятным примером является std::unique_ptr, завязанный на семантику перемещения.

void run_task(std::unique_ptr<Task> ptask) {
  // do something
  ptask->go();
}

void run(...){
  auto ptask = std::make_unique<Task>(....);
  ....
  run_task(std::move(ptask));
}

При вызове run_task параметр передаётся по значению: создаётся новый объект unique_ptr, а старый остаётся, но оказывается пустым. Раз объекта два, то и вызова деструктора тоже два. С деструктивной семантикой перемещения (например, в Rust) вызов деструктора будет только один.

Можно исправить ситуацию — передать по rvalue-ссылке:

void run_task(std::unique_ptr<Task>&& ptask) {
  // do something
  ptask->go();
}

Тогда дополнительного объекта не будет. И произойдёт только один вызов деструктора. При этом из-за ссылки имеется дополнительный уровень индирекции и обращение к памяти.

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

void consume_v1(std::unique_ptr<int> p) {}
void consume_v2(std::unique_ptr<int>&& p) {}

void test_v1(){
  auto x = std::make_unique<int>(5);
  consume_v1(std::move(x));
  assert(!x); // ok
}

void test_v2(){
  auto x = std::make_unique<int>(5);
  consume_v2(std::move(x));
  assert(!x); // fire!
}

Запуск второй функции, скорее всего, закончится Access Violation (SIGSEGV).

И мы переходим к основной проблеме.

Use-after-move

Во-первых, функция std::move ничего не делает. Это всего лишь явное преобразование lvalue-ссылки в rvalue. Оно никак не влияет на состояние объекта. Обозреваемые эффекты от перемещения могут давать функции, работающие с этой самой rvalue-ссылкой. В основном это конструкторы и операторы перемещения.

Во-вторых, стандарт C++ не специфицирует состояние, в котором должен остаться объект, из которого произвели перемещение. Оно должно быть валидным в смысле вызова деструктора. Но более ничего не требуется. Объект не обязан быть пустым после перемещения. Его поля не обязаны быть занулёнными. Так, у std::thread после перемещения нельзя вызывать ни один из методов. А std::unique_ptr гарантированно становится пустым (nullptr).

Чаще и проще всего натолкнуться на use-after-move можно при реализации конструкторов, заполняющих поля переданными аргументами — достаточно дать одинаковые (или почти одинаковые) имена полям и аргументам.

struct Person {
public:
  Person(std::string first_name,
         std::string last_name) : first_name_(std::move(first_name)),
                                  last_name_(std::move(last_name)) {
      std::cerr << first_name; // wrong, use-after-move
  }
private:
  std::string first_name_;
  std::string last_name_;
};

Конечно, в таком случае ошибка будет быстро найдена — для std::string есть гарантия, что после перемещения объект окажется пустым. Но если сделать конструктор шаблонным и передавать в него тривиально перемещаемые типы, ошибка долго может не проявляться.

template <class T1, class T2>
Person(T1 first_name,
       T2 last_name) : first_name_(std::move(first_name)),
                       last_name_(std::move(last_name))
{
  std::cerr << first_name; // wrong, use-after-move
}
....

Person p("John", "Smith"); // T1, T2 = const char*

Другой интересный случай использования после перемещения — self-move-assignment, в результате которого из объекта могут внезапно пропадать данные. А могут и не пропадать. В зависимости от того, как реализовали перемещение для конкретного типа.

Так, например, вот такая наивная реализация алгоритма remove_if содержит ошибку:

template <class T, class P>
void remove_if(std::vector<T>& v, P&& predicate) {
  size_t new_size = 0;
  for (auto&& x : v) {
    if (!predicate(x)) {
      v[new_size] = std::move(x); // self-move-assignment!
      ++new_size;
    }
  }
  v.resize(new_size);
}

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

struct Person {
  std::string name;
  int age;
};

std::vector<Person> persons = {
  Person { "John", 30 }, Person { "Mary", 25 }
};
remove_if(persons, [](const Person& p) { return p.age < 20; });

for (const auto& p : persons){
  std::cout << p.name << " " << p.age << "\n";
}

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

30
25

Отследить некоторые случаи использования после перемещения способны статические анализаторы. Например, в PVS-Studio есть диагностика V1030, а в Clang-Tidy — bugprone-use-after-move.

Если вы реализуете перемещаемые классы и хотите учесть возможность самоприсваивания/самоперемещения, то либо используйте идиому copy/move-and-swap, либо не забывайте проверить совпадение адресов текущего и перемещаемого объектов:

MyType& operator=(MyType&& other) noexcept {
  if (this == std::addressof(other)) { // addressof сработает,
                                       // если у вас перегружен &
    return *this;
  }
  ....
}

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

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

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

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

Детали работы SFINAE выходят далеко за пределы этой книги. Здесь же будет обсуждаться то, что появилось в C++17; что должно облегчить написание кода, но не работает.

Если очень кратко, правило SFINAE (substitution failure is not an error) состоит в следующем:

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

Пример:

#include <type_traits>

template <class T>
decltype(void(std::declval<typename T::Inner>())) fun(T) { // 1
  std::cout << "f1\n";    
}

template <class T>
decltype(void(std::declval<typename T::Outer>())) fun(T) { // 2
  std::cout << "f2\n";    
}

struct X {
  struct Inner {};
};

struct Y {
  struct Outer {};
};

....

fun(X{}); // при подстановке в шаблон 2
          // конструкция X::Outer невалидна: 
          // в X нет такого типа. Отбрасывается.
          // Подстановка шаблона 1
          // проходит без ошибок — будет выведено "f1".

fun(Y{}); // аналогично, но наоборот.
          // Y::Inner не существует. Печатает "f2"

Конструкция decltype(void(std::declval<typename T::Outer>)), используемая для "паттерн матчинга", конечно же, ужасна. Сумрачный гений мастеров C++ порождал и более жуткие вещи. Но для менее искушённого пользователя хотелось бы чего-то более простого, понятного и удобного.

Так, у нас есть std::enable_if_t, позволяющий триггерить SFINAE не по самописной жуткой конструкции, а по булевому значению.

template<class T>
std::enable_if_t<sizeof(T) <= 8> process(T) {
  std::cout << "by value";
}

template<class T>
std::enable_if_t<sizeof(T) > 8> process(const T&) {
  std::cout << "by ref";
}
...
process(5); // by value
const std::vector<int> v;
process(v); // by ref

Причём в аргументе std::enable_if мы всё так же можем использовать страшные конструкции, а не только какие-то предикаты.

template <class T>
std::enable_if_t<std::is_same_v<typename T::Inner, typename T::Inner>> 
fun(T) { // 1
  std::cout << "f1\n";    
}

template <class T>
std::enable_if_t<std::is_same_v<typename T::Outer, typename T::Outer>> 
fun(T) { // 2
  std::cout << "f2\n";    
}

fun(X{}); // несмотря на то что значение std::is_same_v<T, T>
          // всегда истинно, X::Outer не существует.
          // И SFINAE сработает не из-за значения предиката,
          // а из-за его аргументов.

И тут начинается первая неприятность: std::enable_if против std::enable_if_t.

// примерно
template <bool cond, T = void>
struct enable_if {};

template <true, T = void>
struct enable_if {
  using type = T;
};

template <bool cond, T = void>
using enable_if_t = typename enable_if<cond, T>::type;

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

// SFINAE триггерилось от значения предиката и больше не работает.
// std::enable_if<false> — валидный тип.
// Получаем UB из-за переопределения одной и
// той же сущности по-разному
template<class T>
std::enable_if<sizeof(T) <= 8> process(T);
template<class T>
std::enable_if<sizeof(T) > 8> process(const T&);


// SFINAE триггерилось от аргументов предиката и
// продолжает работать.
// Если ожидали void в качестве типа возврата,
// может быть UB из-за отсутствующего return.
template <class T>
std::enable_if<std::is_same_v<
  typename T::Inner, typename T::Inner>> fun(T);
template <class T>
std::enable_if<std::is_same_v<
  typename T::Outer, typename T::Outer>> fun(T);

Эта же неприятность касается всех остальных зверей из заголовка <type_traits>. Любой std::trait_X и std::trait_X_t являются типами, которые, будучи перепутанными, далеко не всегда проявляют себя.

Лучше взять за правило: с помощью std::enable_if триггерить SFINAE только по предикату. Так проблем будет меньше.

Если предиката нет, его можно написать:

template <class T, 
          class = void> // костыль-placeholder для проверяемого "паттерна"
struct has_inner_impl : std::false_type {};

template <class T> 
struct has_inner_impl<T,
   // сам "паттерн", тип-результат должен совпадать с тем,
   // что указан в заглушке выше
   decltype(void(std::declval<typename T::Inner>()))>
   : std::true_type {};

template <class T>
constexpr bool has_inner_v = has_inner_impl<T>::value;

static_assert(has_inner_v<X>);
static_assert(!has_inner_v<Y>);

Это один из наиболее распространённых и "простых" подходов к написанию подобных предикатов. И void чаще всего используется в качестве костыльной заглушки. И чтобы не писать этот страшный decltype(void(std::declval<X>())) каждый раз, когда нам нужно всего лишь проверить тип X на валидность, придумали, а потом и втащили в C++17 шаблон std::void_t.

Вот такой:

template <class...>
using void_t = void;

И с ним всё должно стать короче и красивее:

template <class T> 
struct has_inner_impl<T,
  std::void_t<typename T::Inner>> 
  : std::true_type {};

Но, увы, это чаще не работает, чем работает. В стандарте C++11 был обнаружен дефект (issue 1558 — WG21 CWG Issues), позволяющий этой конструкции не работать. И под многими не самыми-самыми новыми версиями компиляторов такой предикат всегда будет возвращать истину.

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

template <class T>
std::void_t<typename T::Inner> fun(T) {
  std::cout << "f1\n";
}

template <class T>
std::void_t<typename T::Outer> fun(T) {
  std::cout << "f2\n";
}

Ни один из трёх основных компиляторов (GCC, Clang, MSVC) не будет этот код компилировать. Несмотря на то, что первая версия с уродливым decltype собиралась.

Потому что у нас есть понятия "эквивалентности" и "функциональной эквивалентности" объявлений. Компилятор проверяет первое. А вот второе имеет отношение к SFINAE. Страшная вещь.

1980. Equivalent but not functionally-equivalent redeclarations. In an example like:

template<typename T, typename U> using X = T;
template<typename T> X<void, typename T::type> f();
template<typename T> X<void, typename T::other> f();

it appears that the second declaration of f is a redeclaration of the first but distinguishable by SFINAE, i.e., equivalent but not functionally equivalent.

Notes from the November, 2014 meeting: CWG felt that these two declarations should not be equivalent.

Общий совет: не пользоваться std::void_t. А также не пытаться строить SFINAE на параметрах шаблонов-алиасов, если от этих параметров ничего не зависит справа от using =.

template <class T>
struct my_void {
  using type = void;
}

template <class T>
using my_void_t = void; // не работает

template <class T>
using my_void_t = typename my_void<T>::type; // ok

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

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

Неработающий синтаксис и стандартная библиотека: забытый return

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

В C и C++ функцией, возвращающей что-то, отличное от void, необязательно должен быть return что-то.

int add(int x, int y) {
  x + y;
}

Это синтаксически корректная функция, которая приведёт к неопределённому поведению. Может быть мусор, может быть провал в код следующей далее функции, а может быть и "всё нормально".

Если собрать этот код:

#include <stdio.h>

int f(int a, int b) {
  int c = a + b;
}

int main() {
  int x = 5, y = 6;
  printf("f(%d,%d) is %d\n", x, y, f(x,y));
  return 0;
}

Например, с помощью GCC 5.2, то сумма великолепно "посчитается" и программа выведет:

f(5,6) is 11

Однако не стоит думать, что неопределённое поведение для такого кода сводится только к тому, будет распечатано корректное значение или случайное. Отсутствующий return вполне может приводить к падению приложения. Рассмотренный код, кстати, тоже. Достаточно поменять компилятор на GCC 14.1, и результатом выполнения программы станет:

Program terminated with signal: SIGILL

Особенную боль это недоразумение может доставить тем, кто пришёл в C++ после какого-нибудь ориентированного на выражения языка, в котором похожий код абсолютно нормален:

fn add(x: i32, y: i32) -> i32 {
  x + y
}

Обоснования, почему не обязательно писать в конце функции return, следующие:

  • В функции может быть ветвление логики. В одной из веток может вызываться код, который не предполагает возврата: бесконечный цикл, исключение, std::exit, std::longjmp или что-то иное, помеченное аттрибутом [[noreturn]]. Проверить на наличие такого кода не всегда возможно.
  • Функция может содержать ассемблерную вставку со специальным кодом финализации и инструкцией ret.

Проверить наличие формального return, конечно, можно. Но нам разрешили не писать иногда (очень иногда!) чисто формальную строчку, а компиляторам разрешили не считать это ошибкой.

За несколько лет работы над большими проектами, совмещающими C++, Rust и Kotlin (целых два языка с опциональным return!), я видел немало забытых return. И сам их забывал иногда. С особым успехом return оказывается потерянным в пользовательских операторах присваивания. Ведь operator =, пожалуй, одна из немногих не void функций, результат которой почти всегда игнорируется.

С флагом -Wreturn-type GCC и Clang во многих случаях сообщают о проблеме. Анализаторы, например, PVS-Studio, также ругаются. Кстати, если заглянуть в коллекцию багов, собранных командой PVS-Studio, выясняется, что отсутствие return это не какие-то экзотические баги, а по-прежнему очень даже распространённые. Так что проявляйте бдительность.

Единственным исключением, начиная с C++11 (или C99, если говорить про код на чистом С), является функция main. В ней отсутствующий return к неопределённому поведению не приводит и трактуется как возврат 0.

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

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

Более восьми лет работает в сфере коммерческой разработки высокопроизводительного программного обеспечения на 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 активности.