Когда-то я собеседовался на должность C++ разработчика в одну приличную и даже известную контору. Опыт у меня тогда уже кое-какой был, я даже назывался ведущим разработчиком у тогдашнего своего работодателя. Но на вопросы о том, знаком ли я такими вещами, как DRY, KISS, YAGNI, NIH, раз за разом мне приходилось отвечать «Нет».
Собеседование я с треском провалил, конечно. Но упомянутые аббревиатуры потом загуглил и запомнил. По мере чтения тематических статей и книг, подготовок к собеседованиям и просто общения с коллегами я узнавал больше новых вещей, забывал их, снова гуглил и разбирался. Пару месяцев назад кто-то из коллег небрежно упомянул в рабочем чате IIFE в контексте C++. Я, как тот дед в анекдоте, чуть с печки не свалился и опять полез в гугл.
Тогда-то я и решил составить (в первую очередь для себя) шпаргалку по аббревиатурам, которые полезно знать C++ разработчику. Это не значит, что они относятся только к C++, или что это все-все-все понятия из C++ (об идиомах языка можно тома писать). Нет, это только реально встречавшиеся мне в работе и на собеседованиях понятия, обычно выражаемые в виде аббревиатур. Ну и я пропустил совсем уж тривиальные вещи вроде LIFO, FIFO, CRUD, OOP, GCC и MSVC.
Тем не менее аббревиатур набралось порядочно, поэтому шпаргалку я разделил на 2 части: сильно характерные для C++ и более общеупотребительные. Когда это было уместно, я группировал понятия вместе, иначе — просто перечислял по алфавиту. В общем, большого смысла в их порядке нет.
Базовые вещи:
• ODR
• POD
• POF
• PIMPL
• RAII
• RTTI
• STL
• UB
Тонкости языка:
• ADL
• CRTP
• CTAD
• EBO
• IIFE
• NVI
• RVO и NRVO
• SFINAE
• SBO, SOO, SSO
Базовые вещи
ODR
One Definition Rule. Правило одного определения. Упрощенно означает следующее:
- В пределах одной единицы трансляции каждая переменная, функция, класс и т. п. может иметь не более одного определения. Объявлений — сколько угодно (кроме перечислений без заданного базового типа, которые просто нельзя объявить, не определив), но определений — не больше одного. Можно меньше, если сущность не используется.
- В рамках всей программы каждая используемая не-inline функция и переменная обязана иметь строго одно определение. Каждая используемая inline функция и переменная должна иметь одно определение в каждой единице трансляции.
- Некоторые сущности — например классы, inline функции и переменный, шаблоны, перечисления и т. д. — могут иметь несколько определений в программе (но не больше одного в единице трансляции). Собственно это и происходит, когда в несколько .cpp файлов подключается один и тот же заголовок, содержащий полностью реализованный класс, например. Но эти определения должны совпадать (я сильно упрощаю, но суть такова). Иначе будет UB.
Компилятор легко отловит нарушение ODR в рамках единицы трансляции. Но он ничего не сможет сделать, если правило нарушается в масштабе программы — хотя бы потому, что компилятор обрабатывает по одной единице трансляции за раз.
Гораздо больше нарушений может найти линковщик, но, строго говоря, он не обязан этого делать (т. к. по Стандарту тут UB) и что-то может пропустить. К тому же процесс поиска нарушений ODR на этапе линковки имеет квадратичную сложность, а сборка C++ кода и так не быстрая.
В итоге главным ответственным за соблюдение этого правила (особенно в масштабе программы) является сам разработчик. И да — нарушить ODR на масштабе программы могут только сущности с внешней линковкой; те, что с внутренней (т. е. определенные в анонимных неймспейсах), в этом карнавале не участвуют.
Почитать еще: раз (англ.), два (англ.)
POD
Plain Old Data. Простая структура данных. Самое простое определение: это такая структура, которую можно как есть, в бинарном виде отправить в/получить из C библиотеки. Или, что то же самое, правильно скопировать простым memcpy
.
От Стандарта к Стандарту полное определение менялось в деталях. В новейшем на текущий момент C++17 POD определяется, как
- скалярный тип
- или класс/структура/объединение, который:
— есть тривиальный класс
— есть класс со стандартным устройством
— не содержит не-POD не-статических полей - или массив таких типов
Тривиальный класс (trivial class):
- имеет хотя бы по одному не удаленному:
— конструктор по умолчанию
— копирующий конструктор
— перемещающий конструктор
— копирующий оператор присваивания
— перемещающий оператор присваивания - все конструкторы по умолчанию, копирующие и перемещающие конструкторы и операторы присваивания являются тривиальным (упрощенно — сгенерированными компилятором) или удаленными
- имеет тривиальный не удаленный деструктор
- все базовые типы и все поля классовых типов имеют тривиальные деструкторы
- не имеет виртуальных методов (включая деструктор)
- не имеет виртуальных базовых типов
Класс со стандартным устройством (standard layout class):
- не имеет виртуальных методов
- не имеет виртуальных базовых типов
- не имеет нестатических полей-ссылок
- все нестатические поля имеют одинаковый модификатор доступа (public/protected/private)
- все нестатические поля и базовые классы — тоже типы со стандартным устройством
- все нестатические поля и самого класса и всех его предков объявлены в каком-то одном классе (т. е. в самом классе или в одном из предков)
- не наследует одному типу дважды, т. е. так нельзя:
struct A {}; struct B : A {}; struct C : A{}; struct D : B, C {};
- тип первого нестатического поля или, если это массив, тип его элемента не должен совпадать ни с одним из базовых типов (из-за обязательной в данном случае EBO)
В прочем, в C++20 понятия POD типа уже не будет, останутся только тривиальный тип и тип со стандартным устройством.
Почитать еще: раз (рус.), два (англ.), три (англ.)
POF
Plain Old Function. Простая функция в стиле C. Упоминается в Стандарте до C++14 включительно только в контексте обработчиков сигналов. Требования к ней такие:
- использует только общие для C и C++ вещи (т. е. никаких исключений и
try-catch
, например) - не вызывает косвенно или непосредственно не-POF фукнции, за исключение атомарных, свободных от блокировок операций (
std::atomic_init
,std::atomic_fetch_add
и т. п.)
Только такие функции, имеющие к тому же C линковку (extern "C"
), разрешается Стандартом использовать в качестве обработчиков сигналов. Поддержка других функций зависит от компилятора.
В C++17 понятие POF исчезает, вместо него появляется безопасное в смысле сигналов вычисление (signal-safe evaluation). В таких вычислениях запрещены:
- вызовы всех функций стандартной библиотеки, кроме атомарных, свободных от блокировок
- вызовы
new
иdelete
- использование
dynamic_cast
- обращение к
thread_local
сущности - любая работа с исключениями
- инициализация локальной статической переменной
- ожидание завершения инициализации статической переменной
Если обработчик сигнала делает что-то из вышеперечисленного, Стандарт обещает UB.
Почитать еще: раз (англ.)
PIMPL
Pointer To Implementation. Указатель на реализацию. Классическая идиома в C++, так же известная как d-pointer, opaque pointer, compilation firewall. Заключается в том, что все закрытые методы, поля и прочие детали реализации некоего класса выделяются в отдельный класс, а в исходном классе остаются только публичные методы (т. е. интерфейс) и указатель на экземпляр этого нового отдельного класса. Например:
class Foo
{
public:
Foo();
~Foo();
void doThis();
int doThat();
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
};
#include "foo.h"
class Foo::Impl
{
// implementation
};
Foo::Foo()
: pImpl_(std::make_unique<Impl>())
{}
Foo::~Foo() = default;
void Foo::doThis()
{
pImpl_->doThis();
}
int Foo::doThat()
{
return pImpl_->doThat();
}
Зачем это надо, т. е. преимущества:
- Инкапсуляция: пользователи класса через подключение заголовка получают только то, что им надо — публичный интерфейс. Если детали реализации изменятся, код клиента не придется перекомпилировать (см. ABI во второй части).
- Время компиляции: т. к. публичный заголовок ничего не знает о реализации, он не подключает множество нужных ей заголовков. Соответственно уменьшается количество неявно подключаемых заголовков в клиентском коде. Еще упрощается поиск имен и разрешение перегрузок, т. к. публичный заголовок не содержит закрытых членов (они хоть и закрытые, но участвуют в этих процессах).
Цена, т. е. недостатки:
- Плюс как минимум одно разыменование указателя и плюс один вызов функции при обращении к публичных методам.
- Размер необходимой классу памяти увеличивается на размер указателя.
- Часть этой памяти (скорее всего большая) выделяется в куче, что так же отрицательно сказывается на производительности.
- Легко может нарушаться логическая константность. Например такой код скомпилируется:
void Foo::doThis() const { pImpl_->doThis(); // cosnt method pImpl_->doSmthElse(); // non-const method }
Некоторые из этих недостатков устранимы, но цена — дальнейшее усложнение кода и введение дополнительных уровней абстракции (см. FTSE во второй части).
Почитать еще: раз (рус.), два (рус.), три (англ.)
RAII
Resource Acquisition Is Initialization. Захват ресурса есть инициализация. Смысл этой идиомы в том, что удержание некоторого ресурса длится в течении жизни соответствующего объекта. Захват ресурса происходит в момент создания/инициализации объекта, освобождение — в момент разрушения/финализации этого же объекта.
Как ни странно (в первую очередь для программистов на C++), эта идиома используется и в других языках, даже в тех, где существует сборщик мусора. В Java это try-с-ресурсами
, в Python – оператор with
, в C# – директива using
, в Go – defer
. Но именно в C++ с его абсолютно предсказуемой жизнью объектов RAII вписывается особенно органично.
В C++ обычно ресурс захватывается в конструкторе и освобождается в деструкторе. Например, умные указатели так управляют памятью, файловые потоки — файлами, локи мьютексов — мьютексами. Прелесть в том, что не зависимо от того, как происходит выход из блока (scope) – нормально ли через любую из точек выхода, или было брошено исключение — управляющий ресурсом объект, созданный в этом блоке, будет уничтожен, а ресурс — освобожден. Т.е. помимо инкапсуляции RAII в C++ еще и помогает обеспечивать безопасность в смысле исключений.
Ограничения, куда же без них. Деструкторы в C++ не возвращают значений и категорически не должны бросать исключения. Соответственно, если освобождение ресурса сопровождается тем или другим, придется реализовать дополнительную логику в деструкторе управляющего объекта.
Почитать еще: раз (рус.), два (англ.)
RTTI
Run-Time Type Information. Идентификация типа во время исполнения. Это механизм, позволяющий получить информацию о типе объекта или выражения во время выполнения. Существует и в других языках, а в C++ он используется для:
dynamic_cast
typeid
иtype_info
- перехвата исключений
Важное ограничение: RTTI использует таблицу виртуальных функций, и, следовательно, работает только для полиморфных типов (виртуального деструктора достаточно). Важное пояснение: dynamic_cast
и typeid
не всегда используют RTTI, поэтому работают и для неполиморфных типов. Например, для динамического приведения ссылки на потомка к ссылке на предка RTTI не нужен, вся информация доступна во время компиляции.
RTTI не дается бесплатно, пусть немного, но он отрицательно влияет на производительность и размер потребляемой памяти (отсюда частый совет не использовать dynamic_cast
из-за его медлительности). Поэтому компиляторы, как правило, позволяют отключить RTTI. GCC и MSVC обещают, что на корректности перехвата исключений это не скажется.
Почитать еще: раз (рус.), два (англ.)
STL
Standard Template Library. Стандартная библиотека шаблонов. Часть стандартной библиотеки C++, предоставляющая обобщенные контейнеры, итераторы, алгоритмы и вспомогательные функции.
Не смотря на известное имя, STL никогда так не называлась в Стандарте. Из разделов Стандарта к STL однозначно можно отнести Containers library, Iterators library, Algorithm library и частично General utilities library.
В описании вакансий часто можно встретить 2 отдельных требования — знание C++ и знакомство с STL. Я никогда этого не понимал, ведь STL — неотъемлемая часть языка с первого Стандарта 1998 года.
Почитать еще: раз (рус.), два (англ.)
UB
Undefined Behavior. Неопределенное поведение. Это поведение в тех ошибочных случаях, для которых Стандарт не имеет никаких требований. Многие из них явно перечислены в Стандарте как приводящие к UB. К ним, например, относятся:
- нарушение границ массива или STL контейнера
- использование неинициализированной переменной
- разыменование нулевого указателя
- переполнение целых со знаком
Результат UB зависит от всего подряд — и от версии компилятора, и от погоды на Марсе. Причем этим результатом может быть что угодно: и ошибка компиляции, и корректное выполнение, и аварийное завершение. Неопределенное поведение — зло, от него необходимо избавляться.
С другой стороны, неопределенное поведение не стоит путать с неуточняемым поведением (unspecified behavior). Неуточняемое поведение — это корректное поведение корректной программы, но которое с разрешения Стандарта зависит от компилятора. И компилятор не обязан документировать его. Например, это порядок вычисления аргументов функции или детали реализации std::map
.
Ну и тут же можно вспомнить про поведение, зависящее от реализации (implementation-defined behavior). От неуточняемого отличается наличием документации. Пример: размер std::size_t
.
Почитать еще: раз (рус.), два (рус.), три (англ.)
Тонкости языка
ADL
Argument-Dependent Lookup. Поиск, зависящий от аргументов. Он же поиск Кёнига — в честь Andrew Koenig. Это набор правил для разрешения неквалифицированных имен функций (т. е. имен без оператора ::
), дополнительный к обычному разрешению имен. Упрощенно: имя функции ищется в пространствах имен, относящихся к ее аргументам (это пространство, содержащее тип аргумента, сам тип, если это класс, все его предки и т.п.).
#include <iostream>
namespace N
{
struct S {};
void f(S) { std::cout << "f(S)" << std::endl; };
}
int main()
{
N::S s;
f(s);
}
Функция f
найдена в пространстве имен N
только потому, что ее аргумент принадлежит этому пространству.
Даже банальный std::cout << "Hello World!n"
использует ADL, т. к. std::basic_stream::operator<<
не перегружен для const char*
. Но первым аргументом этого оператора является std::basic_stream
, и компилятор ищет и находит подходящую перегрузку в пространстве имен std
.
Некоторые детали: ADL не применяется, если обычный поиск нашел объявление члена класса, или объявление функции в текущем блоке без использования using
, или объявление не функции и не шаблона функции. Или есть имя функции указано в скобках (пример выше не скомпилируется с (f)(s)
; придется писать (N::f)(s);
).
Иногда ADL заставляет использовать полные квалифицированные имена функций там, где это, казалось бы, излишне.
namespace N1
{
struct S {};
void foo(S) {};
}
namespace N2
{
void foo(N1::S) {};
void bar(N1::S s) { foo(s); }
}
Почитать еще: раз (англ.), два (англ.), три (англ.)
CRTP
Curiously Recurring Template Pattern. Странно рекурсивный шаблон. Суть шаблона в следующем:
- некий класс наследуется от шаблонного класса
- класс-наследник используется как параметр шаблона своего базового класса
Проще привести пример:
template <class T>
struct Base {};
struct Derived : Base<Derived> {};
CRTP — яркий пример статического полиморфизма. Базовый класс предоставляет интерфейс, классы-наследники — реализацию. Но в отличие от обычного полиморфизма здесь нет накладных расходов на создание и использование таблицы виртуальных функций.
template <typename T>
struct Base
{
void action() const { static_cast<T*>(this)->actionImpl(); }
};
struct Derived : Base<Derived>
{
void actionImpl() const { ... }
};
template <class Arg>
void staticPolymorphicHandler(const Arg& arg)
{
arg.action();
}
При правильном использовании T
всегда является потомком Base
, поэтому для приведения достаточно static_cast
. Да, в данном случае базовый класс знает интерфейс потомка.
Еще одной частой областью использования CRTP является расширение (или сужение) функциональности наследных классов (то, что в некоторых языках называется mixin). Пожалуй самые известные примеры:
struct Derived : singleton<Derived> { … }
struct Derived : private boost::noncopyable<Derived> { … }
struct Derived : std::enable_shared_from_this<Derived> { … }
struct Derived : counter<Derived> { … }
— подсчет числа созданных и/или существующих объектов
Недостатки, или, скорее, требующие внимания моменты:
- Нет общего базового класса, не получится создать коллекцию разных потомков и обращаться к ним через указатель на базовый тип. Но если хочется, можно унаследовать Base от обычного полиморфного типа.
- Есть дополнительная возможность отстрелить себе ногу по невнимательности:
Пример
template <typename T> struct Base {}; struct Derived1 : Base<Derived1> {}; struct Derived2 : Base<Derived1> {};
Но можно добавить защиту:
private: Base() = default; friend T;
- Т.к. все методы невиртуальные, то методы потомка скрывают методы базового класса с теми же именами. Поэтому лучше называть их по-другому.
- И вообще, у потомков есть публичные методы, которые нигде, кроме базового класса, использоваться не должны. Это нехорошо, но исправляется через дополнительный уровень абстракции (см. FTSE во второй части).
Почитать еще: раз (рус.), два (англ.)
CTAD
Class Template Argument Deduction. Автоматический вывод типа параметра шаблона класса. Это новая возможность из C++17. Раньше автоматически выводились только типы переменных (auto
) и параметры шаблонов функций, из-за чего и возникли вспомогательные функции типа std::make_pair
, std::make_tuple
и т. п. Теперь они по большей части не нужны, т. к. компилятор способен автоматически вывести и параметры шаблонов классов:
std::pair p{1, 2.0}; // -> std::pair<int, double>
auto lck = std::lock_guard{mtx}; // -> std::lock_guard<std::mutex>
CTAD – новая возможность, ей еще развиваться и развиваться (С++20 уже обещает улучшения). Пока же ограничения таковы:
-
Не поддерживается частичный вывод типов параметров
std::pair<double> p{1, 2}; // ошибка std::tuple<> t{1, 2, 3}; // ошибка
-
Не поддерживаются псевдонимы шаблонов
template <class T, class U> using MyPair = std::pair<T, U>; MyPair p{1, 2}; // ошибка
-
Не поддерживаются конструкторы, имеющиеся только в специализациях шаблона
template <class T> struct Wrapper {}; template <> struct Wrapper<int> { Wrapper(int) {}; }; Wrapper w{5}; // ошибка
-
Не поддерживаются вложенные шаблоны
template <class T> struct Foo { template <class U> struct Bar { Bar(T, U) {}; }; }; Foo::Bar x{ 1, 2.0 }; // ошибка Foo<int>::Bar x{1, 2.0}; // OK
-
Очевидно, CTAD не сработает, если тип параметра шаблона никак не связан с аргументами конструктора
template <class T> struct Collection { Collection(std::size_t size) {}; }; Collection c{5}; // ошибка
В некоторых случаях помогут явные правила вывода, которые должны быть объявлены в том же блоке, что и шаблон класса.
template <class T>
struct Collection
{
template <class It>
Collection(It from, It to) {};
};
Collection c{v.begin(), v.end()}; // ошибка
template <class It>
Collection(It, It)->Collection<typename std::iterator_traits<It>::value_type>;
Collection c{v.begin(), v.end()}; // теперь OK
Почитать еще: раз (рус.), два (англ.)
EBO
Empty Base Optimization. Оптимизация пустого базового класса. Так же может называться Empty Base Class Optimization (EBCO).
Как известно, в C++ размер объекта любого класса не может быть нулем. Иначе сломается вся арифметика указателей, т. к. по одному адресу будет возможно разметить сколько угодно разных объектов. Поэтому даже объекты пустых классов (т. е. классов без единого нестатического поля) имеют какой-то ненулевой размер, который зависит от компилятора и ОС и обычно равен 1.
Таким образом память зря тратится на все объекты пустых классов. Но не объекты их потомков, т. к. в данном случае Стандарт явно делает исключение. Компилятору разрешено не выделять память под пустой базовый класс и экономить таким образом не только 1 байт пустого класса, а все 4 (зависит от платформы), т. к. есть еще и выравнивание.
struct Empty {};
struct Foo : Empty
{
int i;
};
std::cout << sizeof(Empty) << std::endl; // 1
std::cout << sizeof(Foo) << std::endl; // 4
std::cout << sizeof(int) << std::endl; // 4
Но т. к. по одному адресу все-таки не могут размещаться разные объекты одного типа, EBO не сработает, если:
-
Пустой класс дважды встречается среди предков
struct Empty {}; struct Empty2 : Empty {}; struct Foo : Empty, Empty2 { int i; }; std::cout << sizeof(Empty) << std::endl; // 1 std::cout << sizeof(Empty2) << std::endl; // 1 std::cout << sizeof(Foo) << std::endl; // 8
-
Первое нестатическое поле является объектом того же пустого класса или его наследника
struct Empty {}; struct Foo : Empty { Empty e; int i; }; std::cout << sizeof(Empty) << std::endl; // 1 std::cout << sizeof(Foo) << std::endl; // 8
В случаях же когда объекты пустых классов являются нестатическими полями, никаких оптимизаций не предусмотрено (это пока, в C++20 появится атрибут [[no_unique_address]]
). Но тратить по 4 байта (или сколько компилятору надо) на каждое такое поле обидно, поэтому можно самостоятельно «схлопнуть» объекты пустых классов с первым непустым нестатическим полем.
struct Empty1 {};
struct Empty2 {};
template <class Member, class ... Empty>
struct EmptyOptimization : Empty ...
{
Member member;
};
struct Foo
{
EmptyOptimization<int, Empty1, Empty2> data;
};
Странно, но в этом случае размер Foo получается разным у разных компиляторов, у MSVC 2019 это 8, у GCC 8.3.0 это 4. Но в любом случае увеличение числа пустых классов на размер Foo
не влияет.
Почитать еще: раз (англ.), два (англ.)
IIFE
Immediately-Invoked Function Expression. Немедленно вызываемое функциональное выражение. Вообще это идиома в JavaScript, откуда Джейсон Тёрнер (Jason Turner) ее и позаимствовал вместе с названием. По факту это просто создание и немедленный вызов лямбды:
const auto myVar = [&] {
if (condition1())
{
return computeSomeComplexStuff();
}
return condition2() ? computeSonethingElse() : DEFAULT_VALUE;
} ();
Зачем это надо? Ну например, как в приведенном коде для того, чтобы инициализировать константу результатом нетривиального вычисления и не засорить при этом область видимости лишними переменными и функциями.
Почитать еще: раз (англ.), два (англ.)
NVI
Non-Virtual Interface. Невиртуальный интерфейс. Согласно этой идиоме открытый интерфейс класса не должен содержать виртуальных функций. Все виртуальные функции делаются закрытыми (максимум защищенными) и вызываются внутри открытых невиртуальных.
class Base
{
public:
virtual ~Base() = default;
void foo()
{
// check precondition
fooImpl();
// check postconditions
}
private:
virtual void fooImpl() = 0;
};
class Derived : public Base
{
private:
void fooImpl() override
{
}
};
Зачем это надо:
- Каждая открытая виртуальная функция делает 2 вещи: определяет открытый интерфейс класса и участвует в переопределении поведения в классах-потомках. Применение NVI избавляет от таких функций с двойной нагрузкой: интерфейс задается одними функциями, изменение поведения — другими. Можно менять и то, и другое независимо друг от друга.
- Если для всех вариантов реализации виртуальной функции есть некие общие требования (пред- и пост-проверки, захват мьютекса и т. п.), то очень удобно собрать их в одном месте (см. DRY во второй части) — в базовом классе — и запретить наследникам переопределять это поведение. Т.е. получается частный случай паттерна Шаблонный метод.
Плата за использование NVI – некоторое разбухание кода, возможное снижение производительности (из-за одного дополнительного вызова метода) и повышенная подверженность проблеме хрупкого базового класса (см. FBC во второй части).
Почитать еще: раз (англ.), два (англ.)
RVO и NRVO
(Named) Return Value Optimization. Оптимизация (именованного) возвращаемого значения. Это частный случай разрешенного Стандартом copy elision – компилятор может опустить ненужные копирования временных объектов, даже если их конструкторы и деструкторы имеют явные побочные эффекты. Такая оптимизация допустима, когда функция возвращает объект по значению (два других разрешенных случая copy elision – это выброс и поимка исключений).
Foo bar()
{
return Foo();
}
int main()
{
auto f = Foo();
}
Без RVO здесь был бы создан временный объект Foo
в функции bar
, потом через конструктор копирования из него был бы создан еще один временный объект в функции main
(чтобы получить результат bar
), и только потом был бы создан объект f
и ему было бы присвоено значение второго временного объекта. RVO избавляется от всех этих копирований и присваиваний, и функция bar
создает непосредственно f
.
Происходит это примерно так: функция main
выделяет в своем фрейме стека место под объект f
. Функция bar
(работающая уже в своем фрейме), получает доступ к этой памяти, выделенной в предыдущем фрейме и создает там нужный объект.
NRVO отличается от RVO тем, что делает такую же оптимизацию, но не когда объект создается в выражении return
, а когда возвращается ранее созданный в функции объект.
Foo bar()
{
Foo result;
return result;
}
Несмотря на кажущуюся небольшой разницу, NRVO реализовать гораздо сложнее, и потому она не работает во многих случая. Например, если функция возвращает глобальный объект или один из своих аргументов, или если у функции есть несколько точек выхода, и через них возвращаются разные объекты — NRVO не применится.
Foo bar(bool condition)
{
if (condition)
{
Foo f1;
return f1;
}
Foo f2;
return f2;
}
Практически все компиляторы давно поддерживают RVO. Степень поддержки же NRVO может варьироваться от компилятора к компилятору и от версии к версии.
RVO и NRVO – это всего лишь оптимизации. И хотя копирующие конструктор и оператор присваивания не вызываются, они должны быть у класса объекта. Правила немного поменялись в C++17: теперь RVO не считается copy elision, стала обязательной, и соответствующие конструктор и оператор присваивания не нужны.
Внимание: (N)RVO в константных выражениях — скользкая тема. До C++14 включительно об этом ничего не сказано, C++17 требует RVO в таких выражениях, а грядущий C++20 – запрещает.
Пара слов о связи с семантикой перемещения. Во-первых, (N)RVO все-таки эффективнее, т.к. не надо вызывать конструктор перемещения и деструктор. Во-вторых, если вместо result
из той же функции возвращать std::move(result)
, то NRVO гарантированно не сработает. Перефразируя Стандарт: RVO применяется к prvalue, NRVO – к lvalue, a std::move(result)
– это xvalue.
Почитать еще: раз (англ.), два (англ.), три (англ.)
SFINAE
Substitution Failure Is Not An Error. Неудачная подстановка — не ошибка. SFINAE — это особенность процесса инстанциации шаблонов — функций и классов — в С++. Суть в том, что если некий шаблон не получается инстанциировать, это не считается ошибкой, если есть другие варианты. Например, упрощенно алгоритм выбора наиболее подходящей перегрузки функций работает так:
- Происходит разрешение имени функции — компилятор ищет все функции с данным именем во всех рассматриваемых пространствах имен (см. ADL).
- Отбрасываются неподходящие функции — не то количество аргументов, нет нужного преобразования типов аргументов, не удалось вывести типы для шаблона функции, и т. п.
- Из оставшихся кандидатов формируется набора так называемых жизнеспособных функций (viable functions), из которого компилятор должен выбрать строго одну наиболее подходящую функцию. Если набор получился пустой или не получилось выбрать одну функцию — мы получаем соответствующую ошибку компиляции.
Так вот SFINAE происходит на втором шаге: если перегрузка получается инстанцированием шаблона функции, но компилятор не смог вывести типы сигнатуры функции, то такая перегрузка не считается ошибкой, а молча отбрасывается (даже без предупреждения). И аналогично для классов.
SFINAE может применяться для многих вещей, например, для подсчета длины списка инициализации или для подсчета бит в числе. Но чаще всего с ее помощью худо-бедно эмулируется рефлексия, т. е. определяется, если ли у класса метод с определенной сигнатурой.
#include <iostream>
#include <type_traits>
#include <utility>
template <class, class = void>
struct HasToString : std::false_type
{};
// это частичная специализация шаблона, и потому при разрешении перегрузки
// имеет приоритет - если типы получится вывести, конечно
// а если не получится — не беда, выше есть общий вариант, подходящий всем
template <class T>
struct HasToString<T, std::void_t<decltype(&T::toString)>>
: std::is_same<std::string, decltype(std::declval<T>().toString())>
{};
struct Foo
{
std::string toString() { return {}; }
};
int main()
{
std::cout << HasToString<Foo>::value << std::endl; // 1
std::cout << HasToString<int>::value << std::endl; // 0
}
Появившийся в C++17 static if
может в некоторых случаях заменить SFINAE, а ожидаемые в C++20 концепты чуть ли вообще не сделают ее ненужной. Посмотрим.
Почитать еще: раз (рус.), два (англ.), три (англ.)
SBO, SOO, SSO
Small Buffer/Object/String Optimization. Оптимизация малых буферов/объектов/строк. Иногда встречается SSO в значении Small Size Optimization, но очень редко, поэтому будем считать, что SSO – это про строки. SBO и SOO – просто синонимы, а SSO – наиболее известный частный случай.
Все структуры данных, использующие динамическую память, безусловно занимают и какое-то место и на стеке. Хотя бы для того, чтобы хранить указатель на кучу. И суть этих оптимизаций в том, чтобы для достаточно малых объектов не запрашивать память у кучи (что относительно затратно), а размещать их в уже выделенном пространстве стека.
Например, std::string можно было бы реализовать так:
class string
{
char* begin_;
size_t size_;
size_t capacity_;
};
Размер такого класса у меня получается 24 байта (зависит от компилятора и платформы). Т.е. строки не длиннее 24 символов можно было бы размещать на стеке. На самом не до 24, конечно, т. к. надо как-то различать размещение на стеке и в куче. Но вот простейший способ для коротких строк до 8 символов (размер тот же — 24 байта):
class string
{
union Buffer
{
char* begin_;
char local_[8];
};
Buffer buffer_;
size_t _size;
size_t _capacity;
};
Помимо отсутствия аллокаций в куче, есть еще одно преимущество — высокая степень локальности данных. Массив или вектор таких оптимизированных объектов будет действительно занимать лишь непрерывный кусок памяти.
Почти все реализации std::string
используют SSO и как минимум некоторые реализации std::function
. А вот std::vector
никогда не оптимизируется таким образом, т. к. Стандарт требует, чтобы std::swap
для двух векторов не вызывала копирования или присваивания их элементов, и чтобы все валидные итераторы оставались валидными. SBO не позволит выполнить эти требования (для std::string
их нет). Зато boost::container::small_vector
, как легко догадаться, использует SBO.
Почитать еще: раз (англ.), два (англ.)
P. S.
Если я что-то упустил или где-то ошибся — пишите в комментариях. Только помните, пожалуйста, что здесь перечислены только аббревиатуры, непосредственно относящиеся к C++. Для прочих, но не менее полезных, будет отдельный пост.
Автор: Михаил Зинин