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

Привет, Хабр.

Пару дней назад мне на глаза попался вот этот твит:

C++ — Stateful TMP#cpp #cplusplus #Cpp20https://t.co/Q3sh3XtiHC pic.twitter.com/AkCRB2zvrT

— Kris Jusiak (@krisjusiak) October 21, 2019

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

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

Вот весь код:

#include <cstdio>

class tag;

template<class>
struct type { friend constexpr auto get(type); };

template<class TKey, class TValue>
struct set { friend constexpr auto get(TKey) { return TValue{}; } };

void foo() { // never called
if constexpr(false) { // never true
if (false) { // never true
constexpr auto call = [](auto value) { std::printf(«called %d», value); };
void(set<type<tag>, decltype(call)>{});
}
}
}

int main() {
get(type<tag>{})(42); // prints called 42
}

Будем читать его построчно.

class tag;

Ну, тут всё понятно.

template<class>
struct type { friend constexpr auto get(type); };

Объявляем структуру type. Заметим, что она объявляет функцию с именем get и каким-то там параметром.

Что будет, если инстанциировать (13.9.1/1) type<T> для некоторого T? В глобальном неймспейсе (но не в глобальной области видимости, но доступная для argument-dependent lookup, это важно!) окажется объявление функции get(T) (9.8.1.2/3, 13.9.1/4), пусть и без определения (6.2/2.1).

template<class TKey, class TValue>
struct set { friend constexpr auto get(TKey) { return TValue{}; } };

Объявляем структуру set. Она, в свою очередь, определяет функцию с именем get и каким-то параметром.

Что будет, если инстанциировать set<K, V> для некоторых K, V? В глобальный неймспейс снова попадёт функция get(K), но теперь вместе с определением (6.2/2).

void foo() {
if constexpr(false) {
if (false) {
constexpr auto call = [](auto value) { std::printf(«called %d», value); };
void(set<type<tag>, decltype(call)>{});
}
}
}

Ясно, что if (false) никак не влияет на какие бы то ни было инстанциирования шаблонов, равно как и приведение типа, поэтому упростим этот фрагмент:

void foo() {
if constexpr(false) {
constexpr auto call = [](auto value) { std::printf(«called %d», value); };
set<type<tag>, decltype(call)>{};
}
}

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

Посмотрим на определение if constexpr внимательнее: 8.5.1/2. Нас интересует сначала вот эта фраза:

If the value of the converted condition is false, the first substatement is a discarded statement

То есть, наша ерунда с call и set — discarded statement. Пока звучит многообещающе.

Посмотрим на следующую фразу:

During the instantiation of an enclosing templated entity, if the condition is not value-dependent after its instantiation, the discarded substatement (if any) is not instantiated.

Это единственное упоминание поведения discarded statement, и единственное упоминание случая, когда оно не инстанциируется. false, естественно, не value-dependent, но есть одно «но». Эта фраза говорит об «enclosing template entity», а у нас enclosing entity — функция foo, которая ни в коей мере не является шаблонной. Соответственно, эта фраза неприменима, и ничего никуда не выкидывается, тело if constexpr вполне себе инстанциируется.

Дальше всё понятно. Инстанциируется тело, инстанциируется type<tag>, появляется объявление get(type<tag>) в глобальном скоупе, видимое через ADL, если аргумент имени get будет иметь тип type<tag> (снова 9.8.1.2/3). Дальше инстанциируется set<type<tag>, decltype(call)>, которая определяет функцию get(type<tag>), объявленную в момент инстанциирования type<tag>. Определение при этом возвращает новое значение типа decltype(call), а так как call ничего не захватывает, и в C++20 лямбды без списка захвата можно конструировать по умолчанию (7.5.5.1/13), то всё это будет работать. В main мы просто вызываем get(type<tag>{}), которая находит объявленную и определённую ранее get через многострадальный ADL. Она возвращает лямбду, эквивалентную call, которую мы сразу же и вызываем, передавая туда 42.

Такие дела.

Заметим, что ключевой момент здесь — взаимодействие discarded statement и enclosing template entity. Действительно, если заменить void foo() на template<typename> void foo(), и даже если её потом явно вызвать

как-то так#include <cstdio>

class tag;

template<class>
struct type { friend constexpr auto get(type); };

template<class TKey, class TValue>
struct set { friend constexpr auto get(TKey) { return TValue{}; } };

template<typename>
void foo() {
if constexpr(false) { // never true
if (false) { // never true
constexpr auto call = [](auto value) { std::printf(«called %d», value); };
void(set<type<tag>, decltype(call)>{});
}
}
}

int main() {
foo<int>();
get(type<tag>{})(42); // prints called 42
}

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

prog.cc:23:3: error: function ‘get’ with deduced return type cannot be used before it is defined
get(type<tag>{})(42); // prints called 42
^
prog.cc:6:37: note: ‘get’ declared here
struct type { friend constexpr auto get(type); };
^

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

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

Впрочем, конкретно в этом случае ничего нового. Техника стейтфул-метапрограммирования была открыта ещё во времена C++14, и вполне реализуема в C++11, если не 03.

Какое решение? Его нет:

Defining a friend function in a template, then referencing that function later provides a means of capturing and retrieving metaprogramming state. This technique is arcane and should be made ill-formed.
Notes from the May, 2015 meeting:
CWG agreed that such techniques should be ill-formed, although the mechanism for prohibiting them is as yet undetermined.

Абьюзить правила языка, чтобы делать такие непотребства — плохо, пнятненько.

В любом случае, счастливого кодинга!

Оставить комментарий