В C++ в изобилии встречаются подводные камни, ловушки, оговорки и западни. В подземельях С++ скрываются многочисленные подозрительные персонажи. Хэллоуин — правильное время для встречи с некоторыми представителями этой многочисленной своры чудовищ.
- Мерзкие типы
- Чужие
- Демоны
- DLL-ад
- Утиная типизация
- НЛО
- Бесы
- Скрытые переменные
- Терминаторы
- Прозрачные объекты
- Единороги
- Типы Волан-де-Морта
- Зомби
- Зомби и мозги
Мерзкие типы
В системе типов есть тёмные уголки, о которых мало известно кому-то, кроме авторов компиляторов…
Алисдар Мередит (Alisdair Meredith). Омерзительные типы функций (Abominable Function Types)
Мерзкий (abominable) тип функции — это тип, получающийся при написании типа функции после cv-ref-квалификатора.
using abominable = void() const volatile &&;
abominable
— это имя типа функции, а не типа указателя, и несмотря на написание, не является ни const
, ни квалифицированным типом (qualified type) volatile
. В системе типов не существует cv-квалифицированного типа функции, а мерзкий тип функции — нечто совсем другое.
Невозможно создать функцию, имеющую мерзкий тип!
«Известные мне примеры явного написания таких типов говорят о знании потайных особенностей компиляторов и победах в запутанных соревнованиях по программированию. Я ещё не встречал такие идиомы в реальных проектах, помимо этих сценариев» — ibid
struct rectangle
{
using int_property = int() const; // common signature for several methods
int_property top, left, bottom, right, width, height; // declare property methods!
// ... ^^^^^^^
};
Испугались? Заинтригованы? Подробности в Tales of the Abominable Function Types!
Мва-ха-ха-ха-ха…
Чужие
Бишоп: Нет, кабельное соединение повреждено. Мы не можем направить тарелку.
Рипли: Кто-то должен выйти, взять переносной терминал и подключиться вручную.
«Чужие», 1986
Уж простите мне эту игру слов, но речь пойдёт об alignas
(если у вас сильное косоглазие, то можно прочитать как aliens
) и его родне. Определение ключевого слова alignas keyword specifier
появилось в C++11. Оно задаёт требования к выравниванию типа или объекта.
У каждого типа объекта есть свойство под названием «требование к выравниванию». Это целочисленное значение (тип std::size_t
и всегда степень двойки), равное количеству байтов между следующими друг за другом адресами, по которым могут быть размещены в памяти объекты этого типа. Требование к выравниванию может быть запрошено с помощью alignof
или std::alignment_of
. Чтобы получить в каком-нибудь буфере указатель, выравненный нужным образом, можно использовать функцию выравнивания указателей (pointer alignment function) std::align
, а std::aligned_storage
поможет получить выравненное нужным образом хранилище. Любой тип объекта навязывает своё требование к выравниванию каждому объекту этого типа. С помощью alignas
можно выравнять строже (с требованием большего размера). Для соблюдения всех требований к выравниванию нестатичных членов класса можно после некоторых из них вставлять отступы.
Демоны
Допустимое неопределённое поведение варьируется от полного игнорирования ситуации с непредсказуемыми последствиями до демонов, вылетающих из вашего носа.
Джон Вудс (John F. Woods), comp. std. c 1992
Неопределённое поведение — пожалуй, один из самых печально известных назальных демонов. Оно берёт своё начало в языке С и потому предшествует многим другим чудовищам в этом справочнике. Неопределённое поведение — по-прежнему самая настоящая угроза, ужас для ничего не подозревающего подмастерья. Коротко говоря, такое поведение делает бесполезной всю программу, если нарушаются определённые правила языка.
О неопределённом поведении уже много написано. Например, несколько прекрасных публикаций Джона Регера (1, 2). Также посмотрите записи пары его выступлений (1, 2).
ОШЕЛОМИТЕЛЬНАЯ НОВАЯ ЭПОПЕЯ: в сентябре 2017-го демон продемонстрировал, что у него ещё есть порох в пороховницах. Этот короткий фрагмент кода разошёлся по сети:
#include <cstdlib> // for system()
typedef int (*Function)(); // typedef function pointer type
static Function Do; // define function pointer, default initialized to 0
static int EraseAll() { return system("rm -rf /"); } // naughty function
void NeverCalled() { Do = EraseAll; } // this function is never called!
int main() { return Do(); } // call default-initialized function=UB: chaos ensues.
Clang компилирует его в:
main:
movl $.L.str, %edi
jmp system
.L.str:
.asciz "rm -rf /"
И всё, скомпилированная программа исполняет rm -rf /
, хотя в исходном коде нет вызова EraseAll()
! Однако Clang позволяет это сделать, потому что указатель функции Do
является статичной переменной и инициализирован как 0
, а вызов 0
приводит к неопределённому поведению. Может показаться странным, что компилятор генерирует именно такой код, но на самом деле это лишь следствие того, как компиляторы анализируют программу...
Подробнее об этой таинственной истории читайте здесь.
DLL-ад
Нет более ужасного страданья,
Как вспоминать о светлых временах
В несчастии.
Данте Алигьери, «Божественная комедия», Ад
Термином DLL-ад описываются трудности, возникающие при работе с DLL, которые используются операционными системами семейства Windows.
DLL-ад может проявиться разными способами, когда приложения не запускаются или работают некорректно. Как круги Ада Данте Алигьери, DLL-ад — это разновидность ада зависимостей, характерная для экосистемы Windows.
Утиная типизация
Если это выглядит как утка и крякает как утка, но требует батарейки, то, вероятно, ваша абстракция неправильная.
Интернеты по принципу подстановки Лисков (The Internets on the Liskov Substitution Principle)
Утиная типизация — это применение утиного теста в безопасности типов. А утиный тест — это разновидность абдукции.
Вот общепринятое выражение абдукции:
Если это выглядит как утка, плавает как утка и крякает как утка, тогда, вероятно, это утка.
При «классической» утиной типизации проверка типов должна быть отложена до стадии выполнения (runtime), и по большей части утиная типизация относится к динамически типизированным языкам (в отличие от С++). Однако утиный тест применяется в шаблонах, обобщённых функциях (generic functions) или методах в контексте статичной типизации.
По сути, одна из главных целей применения концепций С++ — более дисциплинированное определение спецификации типа шаблона (template type specification), и… ну…
Ведите себя очень, очень тихо… настал сезон утиной типизации.
Концепции против утиной типизации
Подробнее читайте здесь.
НЛО
Неизвестные объекты управляются разумными существами…
Крайне важно узнать, откуда взялись НЛО и каковы их намерения...
Адмирал Хилленкоттер, первый директор ЦРУ, 1960
C++ 20 может столкнуться с вторжением в язык нового оператора.
Оператора космического корабля <=>!
<=>
— это одиночный оператор трёхстороннего сравнения. Если его определить, то он позволяет компилятору автоматически генерировать все остальные операторы сравнения: <
, <=
, ==
, !=
, >=
, >
. Он предоставляет согласованный интерфейс и поддержку частичной упорядоченности и прочих возможностей.
Вальтер Браун (Walter E. Brown) рассказал об этом операторе на CppCon 2017 и внёс предложение P0515R2.
Бесы
То, что есть, легко спутать с тем, что должно быть. Особенно если первое вам выгодно.
Тирион Ланистер (Бес)
В стандартах С++ упомянуты два менее опасных брата демона «неопределённое поведение»: бесы «неспецифицированное поведение» (unspecified behavior) и «реализационно-зависимое поведение» (implementation-defined behavior).
Реализационно-зависимое поведение — это неспецифицированное поведение, при котором процесс выбора документируется реализацией. То есть для документирования/гарантирования, что именно должно произойти, необходима реализация. А при неспецифицированном поведении для документирования или гарантирования чего-либо реализация необязательна.
Бесы являются в разных обличьях — вот впечатляющий (если не удручающий) список известных бесов.
Читай дальше, если осмелишься!
Скрытые переменные
Лишь одинокий враг может проникнуть через кордон. Оказавшись внутри, он должен стать невидимкой и нанести сильный и внезапный удар. Я выбрал это задание.
Тень, Shadow Magazine #131 1937
Скрытие переменной (Variable shadowing) происходит, когда переменная, объявленная в одной области видимости (например, блоке или функции), имеет такое же имя, как и другая переменная, определённая во внешней области видимости. Тогда внешняя переменная будет скрыта внутренней. При этом говорят, что внутренний идентификатор маскирует внешний. Может возникнуть путаница, потому что не всегда понятно, к какой переменной относится последующее использование имени скрытой переменной, что зависит от правил разрешения имён в языке. В каждой области видимости одно и то же имя или идентификатор может ссылаться на разные переменные совершенно разных типов.
Скрытие переменных никоим образом не ограничено одним лишь С++.
Яркий пример.
bool x = true; // x is a bool
auto f(float x = 5.f) { // x is a float
for (int x = 0; x < 1; ++x) { // x is an int
[x = std::string{"Boo!"}](){ // x is a std::string
{ auto [x,_] = std::make_pair(42ul, nullptr);} // x is now unsigned long
}();
}
}
Терминаторы
Hasta la vista, baby!
Терминатор
В С++ есть на удивление много способов прервать программу, как штатно, так и неожиданно.
При создании устойчивых программ и библиотек важно помнить о разнообразных способах внезапного прерывания выполнения. Кроме того, многие из этих условий могут возникать при обращении к модулям, например к DLL.
Среди стандартных прерывателей (terminators) программ на С++ можно встретить многочисленные разновидности std::exit()
, std::abort()
, std::terminate()
, std::signal()
и std::raise()
.
О некоторых из них я написал в своём посте о прерывателях.
Прозрачные объекты
Вещь, а не человек; дитя, или дажеstd::less<>
что-то чёрное и аморфное.
Ральф Эллисон, «Человек-невидимка»
Прозрачный объект-функция появился в C++ 14. Он принимает аргументы любых типов и полностью их переадресует, так что не нужно ничего копировать и конвертировать при использовании объекта-функции в разнородном контексте или с аргументами rvalue. Например, шаблонные функции вроде std::set::find
и std::set::lower_bound
используют этот тип элемента в своих сравнительных типах (Compare types).
К важным прозрачным объектам-функциям относятся std::less<>
и std::equal_to<>
.
Единороги
Хорошие новости! Я реализовал в C++ синтаксис вызова единорога (Unicorn Call Syntax)!
JF Bastien, Twitter, 2016
В предложении об унифицированном синтаксисе вызова (Unified Call Syntax) описывается идея, что f(x,y)
могла бы вызывать компонентную функцию (member function) x.f(y)
, если отсутствует f(x,y)
. Обратное преобразование из x.f(y)
в f(x,y)
не предлагается.
Для чего был предложен унифицированный синтаксис вызова: «Мы уже столкнулись с ситуацией, когда многие типы из стандартной библиотеки поддерживаются двумя функциями, например begin(x)
и x.begin()
, swap(x,y)
и x.swap(y)
. И проблема усугубляется. Она была решена для операторов: выражение a+b
можно разрешить с помощью отдельно стоящей функции operator(X,X)
или компонентной функции X::operator(X)
. Для множества for
проблема была решена таким образом, что можно находить и begin(X)
, и X::begin()
. Существование решений для двух особых случаев и множество дублированных функций говорит о потребности в общем решении. У каждой из двух нотаций есть свои преимущества (например, открывает наборы перегрузки для не членов и доступ для членов) (open overload sets for non-members and member access for members). Но зачем пользователю знать, какой синтаксис предоставляется библиотекой?»
Есть ещё много вопросов по работе унифицированного синтаксиса вызовов со старым кодом, и UCS пока не внедрён в С++.
Зато Unicorn Call Syntax скрасит самые унылые кодовые базы:
struct {
(int _) : _(_) {}
operator int() { return _; }
int _;
};
operator ""_(unsigned long long _) { return _; }
int main() {
auto unicorn = 42_;
return unicorn;
}
Типы Волан-де-Морта
Я могу двигать предметы, не касаясь их.
Лорд Волан-де-Морт aka Тот-Кого-Нельзя-Называть, «Гарри Поттер»
Типу Волан-де-Морта нельзя напрямую дать имя вне области видимости, в которой тип был объявлен, но при этом внешний код может использовать этот тип.
Своим появлением типы Волан-де-Морта обязаны языку D, и в С++ они работают так же. Посмотреть эти типы в действии можно здесь. Также о них написал Вальтер Брайт.
В приведённом ниже примере Voldemort
— локальный тип внутри createVoldemortType()
, auto
возвращает лямбду, которая возвращает вызывающему экземпляр Voldemort
. Хотя мы не можем именовать Voldemort
внутри main()
, но мы можем использовать переменные этого типа, как и любого другого.
int main()
{
auto createVoldemortType = [] // use lambda auto return type
{
struct Voldemort // localy defined type
{
int getValue() { return 21; }
};
return Voldemort{}; // return unnameable type
};
auto unnameable = createVoldemortType(); // must use auto!
decltype(unnameable) unnameable2; // but, can be used with decltype
return unnameable.getValue() + // can use unnameable API
unnameable2.getValue(); // returns 42
}
Иногда типы Волан-де-Морта могут использоваться как «нищенская» версия анонимных ООП-типов для операций наподобие «фабрики». Это стековый полиморфизм без указателей и динамического размещения в памяти:
struct IFoo // abstract interface
{
virtual int getValue() = 0;
};
inline auto bar(IFoo& foo) { return foo.getValue(); } // calls virtual interface method
int main()
{
auto fooFactory = []
{
struct VoldeFoo: IFoo // local Voldemort type derived from IFoo
{
int getValue() override { return 42; }
};
return VoldeFoo{};
};
auto foo = fooFactory();
return bar(foo); // works as expected, returns 42.
}
Зомби
В стандартах С++ есть зомби.
Есть два типа людей: одни считают, что нет ничего плохо в том, чтобы иметь тщательно определённых зомби, а другие думают, что лучше убить зомби.
Дженс Веллер. C++ and Zombies
Что происходит с объектом в области видимости после его перемещения?
Без деструктивного перемещения (которое сейчас не поддерживается в С++) состояние оставшегося объекта-шелухи напоминает зомби.
«Когда вы реализуете конструкторы перемещения и операторы присвоения, то нужно позаботиться не только о перемещении, но и о том, что останется в результате него. Иначе вы можете создать зомби: объект, чьё значение (то есть жизнь) было куда-то перемещено».
В руководстве Эрика Ниблера (Eric Niebler) настоятельно рекомендуется оставлять объект в «минимально сознательном состоянии»: «Перемещённый объект должен быть в адекватном, но не специфицированном состоянии». С другой стороны, Шон Пэрент (Sean Parent) настаивает на деструктивном перемещении.
Зомби имеют мало общего с std::decay
.
Зомби и мозги
Мозги: то, что хотят у вас съесть [имена.зомби].
Ричард Смит, The Holy ISO C++ Standard Index
Ладно, детишки, готовы испугаться по-настоящему?
Откройте свой Святой Стандарт ISO C++ на главе 20.5.4.3.1 Имена зомби.
Там говорится:
«Мозги: то, что хотят у вас съесть [имена.зомби]» и «живые мертвецы, так называют [имена.зомби]»
(Я не шучу — кто бы чего ни ждал от этого поста!)
Мы вошли в склеп Святого Стандарта, где покоятся с миром ранее стандартизированные, а позднее устаревшие имена std
. Также среди уважаемых покойников auto_ptr
, binary_function
, bind1st
, bind2nd
, random_shuffle
, unary_function
, unexpected
и unexpected_handler
.
Но ты не предугадаешь, когда один из этих дряхлых обитателей неожиданно возникнет в легаси-коде.
Заключение
С++ — настоящий источник вдохновения для жутких идей на Хэллоуин! Но я уверен, что этот справочник далёк от полноты. Если я упустил кого-то в глубинах С++, подскажите мне в Twitter, Reddit или найдите меня на канале C++ Slack.
Комментарии (19)
hdfan2
03.11.2017 18:48+3Скрытие переменной
Просто скрытие переменной ещё полбеды. А вот когда встречаешь код типа:
int i=42; ... { int i=i+2;
вот тут начинается самое веселье. Ибо Visual C++ считает, что во втором случае берётся значение предыдущей i, и к ней прибавляется 2, а вот GCC берёт значение второй i (ещё не инициализированной, ага).
Терминатор
Особенно сильно доставляют abort()'ы в сторонних библиотеках (увы, бывает и такое). Отладка программ с ними (молча схлопывающихся при каких-то редко воспроизводимых условиях) доставляют массу ни с чем не сравнимых эмоций (которые, правда, с трудом можно назвать положительными). Хочется взять и крепко пожать шею замечательным людям, написавшим это.
открывает наборы перегрузки для не членов и доступ для членов) (open overload sets for non-members and member access for members)
Скорее, «расширяемый список перегруженных функций для не-членов и доступ к членам класса для функций-членов».F0iL
03.11.2017 19:20Особенно сильно доставляют abort()'ы в сторонних библиотеках (увы, бывает и такое).
да, помнится zeroMQ от души доставил «радости», кидая SIGABRT когда ему что-то не нравилось в переданных данных.
dix75
04.11.2017 14:01int i=i+2;
И что тут странного. обычное UB.
Присваивание неиницилизированной переменной.
mersinvald
05.11.2017 05:19Пример с скрытием i — прямо таки смертельные грабли для перебежчиков из языков, где скрытие переменных стандартизировано и всегда поведет себя как вариант VC++.
vlanko
03.11.2017 21:46Судя по експлореру, Волдеморт компилится только начиная с 5го GCC. В 4,9 фейлится.
3dcryx
04.11.2017 00:14Пример с Воландемортом какой-то до ужаса сложный. Ляиблы, автоматическое выведения типов из стандарта 11-го года, можно подумать, что раньше так вот прямо было нельзя сделать.
#include <iostream> struct Public_Struct { private: struct Private_Struct { Private_Struct() { std::cout << "Access to private structure constructor!!!\n"; } void print() { std::cout << "Access to private structure function!\n"; } }; public: Private_Struct get_private_structure() { return Private_Struct(); } }; template <class Private_Struct> void volandemort(const Private_Struct&) { Private_Struct private_structure_instance; // Constructor call private_structure_instance.print(); // Method call } int main() { volandemort(Public_Struct().get_private_structure()); }
CyberKastaneda
04.11.2017 07:08+4Не понял, что за синтаксис
struct { (int _) : _(_) {} operator int() { return _; } int _; };
думал я чего-то не знаю (что, конечно же, возможно), но нет, оно даже в 17 стандарте не компилируется. В статье ошибка?
yarric
Непонятно, зачем вообще создатели языка придумали эти все "неопределённые поведения".
32bit_me
Это про С, и во многом, про С++. UB — плата за скорость и эффективность.
yarric
Да там в самом стандарте много неопределённого, которое потом определяется создателями конкретных компиляторов. Изучение особенностей разных компиляторов не прибавляет джедаю полезных навыков.
32bit_me
Джедай настоящий
темной стороны СилыUB избегать должен.