Приветствую на моем канале, посвященному языку программирования C++!
В этом канале я пишу интересные факты, советы, редкие возможности языка, особенности реализации компилятора.
Для каждого поста будут свои хэштеги:
#creepy - стремные правила Стандарта
#compiler - интересные факты про компилятор и его внутренности
#advice - советы из практики, не обязательно связаны напрямую с языком
#madskillz - технические чудеса
#video - YouTube видео про C++
(список хэштегов может пополняться)
В этом канале я пишу интересные факты, советы, редкие возможности языка, особенности реализации компилятора.
Для каждого поста будут свои хэштеги:
#creepy - стремные правила Стандарта
#compiler - интересные факты про компилятор и его внутренности
#advice - советы из практики, не обязательно связаны напрямую с языком
#madskillz - технические чудеса
#video - YouTube видео про C++
(список хэштегов может пополняться)
#creepy
Alternative operator representations (диграфы)
Когда-то давным-давно на свете существовали странные кодировки, в которых не существовало базовых символов.
Например, кодировка для немецкого языка
Чтобы юзеры кодировки смогли программировать на C++, была реализована гениальная схема - в языке вместо
Вместе с заменой
Также для прикола были сделаны триграфы - можно вместо
Alternative operator representations (диграфы)
Когда-то давным-давно на свете существовали странные кодировки, в которых не существовало базовых символов.
Например, кодировка для немецкого языка
DIN 66003
, бывшая в использовании с 1974 по 1999 годы. Она была создана прямой заменой символов [\]{|}~
на ÄÖÜäöüß
, которых нет в ASCII. Получилось очень круто - никому не нужные скобочки заменили на буквы алфавита.Чтобы юзеры кодировки смогли программировать на C++, была реализована гениальная схема - в языке вместо
{
, }
, [
, ]
, #
можно писать соответственно <%
, %>
, <:
, :>
, %:
, %:%:
.Вместе с заменой
&&
на and
, !=
на not_eq
, etc. получился код, который можно скомпилировать и сейчас:int main(int argc, char* argv<::>)
<%
// lambda with reference-capture:
auto greet = <:bitand:>(const char* name)
<%
std::cout << "Hello " << name
<< " from " << argv<:0:> << '\n';
%>;
if (argc > 1 and argv<:1:> not_eq nullptr) <%
greet(argv<:1:>);
%> else <%
greet("Anon");
%>
%>
Также для прикола были сделаны триграфы - можно вместо
{
писать ??<
, вместо [
писать ??(
, и так далее.#compiler
Как в компиляторе реализуют NRVO?
(и почему он не всегда работает)
Для подробной информации о явлении можно прочитать на cppreference.
Оптимизация
Даже если copy/move ctors имеют побочные эффекты, их все равно не вызовут. Эти конструкторы можно объявить
Эта оптимизация старая, все компиляторы ее делают, а с C++17 она стала обязательной при определенных условиях.
Оптимизация
Условие здесь похожее - если у нас все
Как вычисление NRVO происходит в компиляторе Clang?
Во время парсинга файла Clang держит стек scope (думаю +- понятно для чего нужны скоупы). Одни scope вложены в другие scope, и образуют дерево из scope.
Свой scope создается для каждого метода, каждого класса, каждого if-выражения, каждого for-loop, и так далее. Самый "высокоуровневый" scope это scope Translation Unit-а.
Как в компиляторе реализуют NRVO?
(и почему он не всегда работает)
Для подробной информации о явлении можно прочитать на cppreference.
NRVO (Named Return Value Optimization)
- это оптимизация из класса copy elision
. Copy elision
это отсутствие вызова конструкторов копирования/мува за счёт того, что объект изначально создается в целевом месте.Оптимизация
RVO (Return Value Optimization)
выглядит чуть проще:std::string foo1() {Компилятор может понять, что никакой нужды в вызове copy/move нет - надо заранее выделить место на стеке под
return std::string("bar");
}
std::string f = foo1();
std::string f
и создать строку туда.Даже если copy/move ctors имеют побочные эффекты, их все равно не вызовут. Эти конструкторы можно объявить
= delete
, или объявить без определения, или сделать private - это ни на что не повлияет.Эта оптимизация старая, все компиляторы ее делают, а с C++17 она стала обязательной при определенных условиях.
Оптимизация
NRVO
сложнее, но она не обязательная (в отличие от RVO
) - компилятор может сгенерировать суб-оптимальный код.Условие здесь похожее - если у нас все
return xxx;
внутри метода возвращают один и тот же xxx;
, то в таком случае тоже не происходит copy/move:std::string foo2() {Как мы видим, все
std::string y = "I'm a redundant string";
std::string x = "sample text";
if (x.size() % 2 == 0) {
return x;
}
x += "xxx";
return x;
}
std::string f = foo2(); // без копирования
return
возвращают одно и то же, поэтому NRVO сработает. Если бы у нас где-нибудь был return y;
или return std::move(x);
, то NRVO бы не сработал.Как вычисление NRVO происходит в компиляторе Clang?
Во время парсинга файла Clang держит стек scope (думаю +- понятно для чего нужны скоупы). Одни scope вложены в другие scope, и образуют дерево из scope.
Свой scope создается для каждого метода, каждого класса, каждого if-выражения, каждого for-loop, и так далее. Самый "высокоуровневый" scope это scope Translation Unit-а.
C++95
#compiler Как в компиляторе реализуют NRVO? (и почему он не всегда работает) Для подробной информации о явлении можно прочитать на cppreference. NRVO (Named Return Value Optimization) - это оптимизация из класса copy elision. Copy elision это отсутствие…
#compiler
Как в компиляторе реализуют NRVO? (продолжение)
Во время парсинга у каждого scope функций и их потомков есть три возможных состояния насчет nrvo:
(1) нет переменной-кандидата на nrvo
(2) есть 1 переменная-кандидат на nrvo (хранится ссылка на нее)
(3) кандидатов на nrvo больше 1 -> nrvo запрещен
Когда scope полностью распарсен, он уведомляет scope-родителя о своем nrvo-состоянии. Если после парсинга scope функции оказалось, что nrvo-кандидат ровно один, то оптимизация сработает. Если кандидатов несколько, то nrvo не будет работать.
В C++17 ввели конструкцию if constexpr, и с тех пор вычисление nrvo в некоторых случаях дает субоптимальный результат.
Для такого примера NRVO работает, потому что тело if-а полностью дискардится из-за false вычисленного в compile-time:
https://godbolt.org/z/fMfcYf75W (автор кода - Антон Полухин, 2021 год)
С другой стороны, переписать вычисление NRVO с анализа scope на анализ AST - это прямо гипер сложно, и такие усилия лучше потратить на более полезные вещи. Все-таки NRVO - это не обязательная оптимизация, поэтому никто не парится насчет
Как в компиляторе реализуют NRVO? (продолжение)
Во время парсинга у каждого scope функций и их потомков есть три возможных состояния насчет nrvo:
(1) нет переменной-кандидата на nrvo
(2) есть 1 переменная-кандидат на nrvo (хранится ссылка на нее)
(3) кандидатов на nrvo больше 1 -> nrvo запрещен
Когда scope полностью распарсен, он уведомляет scope-родителя о своем nrvo-состоянии. Если после парсинга scope функции оказалось, что nrvo-кандидат ровно один, то оптимизация сработает. Если кандидатов несколько, то nrvo не будет работать.
В C++17 ввели конструкцию if constexpr, и с тех пор вычисление nrvo в некоторых случаях дает субоптимальный результат.
Для такого примера NRVO работает, потому что тело if-а полностью дискардится из-за false вычисленного в compile-time:
template<bool B>Для такого примера NRVO не работает, потому что тело не дискардится, true тоже вычисляется не отходя от кассы
std::string foo() {
std::string y = "y";
std::string x = "x";
if constexpr (1 + 2 == 4) {
return y;
}
return x;
}
template<bool B>А для такого примера NRVO будет работать лишь для некоторых инстанциаций:
std::string foo() {
std::string y = "y";
std::string x = "x";
if constexpr (1 + 2 == 3) {
return y;
}
return x;
}
template<bool B>Так как NRVO вычисляется через анализ scope, а не для отдельных инстанциацию, то Clang-у приходится "неизвестный заранее" результат
std::string foo() {
std::string y = "y";
std::string x = "x";
if constexpr (B) {
return y;
}
return x;
}
if constexpr
обрабатывать как если бы тело не дискардилось. В итоге для foo<true>
код генерируется оптимальный, а для foo<false>
- субоптимальный.https://godbolt.org/z/fMfcYf75W (автор кода - Антон Полухин, 2021 год)
С другой стороны, переписать вычисление NRVO с анализа scope на анализ AST - это прямо гипер сложно, и такие усилия лучше потратить на более полезные вещи. Все-таки NRVO - это не обязательная оптимизация, поэтому никто не парится насчет
if constexpr
.#advice
Иммутабельные классы для объектов API
(Практический совет)
Допустим, что вы программируете показ рекламы фастфуда 🍟🍔🍕🥤 на сайтах. Надо показать топ-5 наиболее подходящих блюд. Формула оценки довольно сложная: зависит от региона юзера, доступности блюд в ближайшей точке, маркетинговых акций, текущего времени, etc.
Запросы приходят в API вашего сервиса. Пусть все данные для оценки представлены классами/структурами на C++.
Представим себе API-класс, который описывает одну из ближайших к юзеру точек питания. У него есть расстояние до юзера (чем дольше, тем меньше "вес" в оценке), загруженность, наличие разных блюд, акции, а также история посещений юзером.
Здесь получаем проблему - пусть у нас где-то в программе лежат объекты блюд
Можно обернуть объекты в умный указатель
Есть способ, который решает несколько проблем - все API-классы нужно объявить non-copyable И non-movable. Какие будут плюсы:
(1) Объекты невозможно случайно передать по значению/скопировать
(2) Указатели на объекты живут столько же, сколько контейнер, где содержится объект.
Компилятор C++ не даст заиспользовать контейнер как
Иммутабельные классы для объектов API
(Практический совет)
Допустим, что вы программируете показ рекламы фастфуда 🍟🍔🍕🥤 на сайтах. Надо показать топ-5 наиболее подходящих блюд. Формула оценки довольно сложная: зависит от региона юзера, доступности блюд в ближайшей точке, маркетинговых акций, текущего времени, etc.
Запросы приходят в API вашего сервиса. Пусть все данные для оценки представлены классами/структурами на C++.
Представим себе API-класс, который описывает одну из ближайших к юзеру точек питания. У него есть расстояние до юзера (чем дольше, тем меньше "вес" в оценке), загруженность, наличие разных блюд, акции, а также история посещений юзером.
struct Restaurant {Некоторыми данными объект "владеет" (как историей посещения), а на некоторые просто ссылается, потому что они общие для всех.
double Distance; // расстояние
double Occupancy; // загруженность
std::vector<Meal*> Meals; // доступные блюда
std::vector<Promo*> Promos; // акции
std::vector<Visit> VisitHistory; // история посещений
};
Здесь получаем проблему - пусть у нас где-то в программе лежат объекты блюд
std::vector<Meal> Meals
. Если мы создадим Restaurant
-ы, а потом добавим какое-то новое блюдо, то можно попасть на переаллокацию вектора, и в таком случае все ссылки Meal*
станут висячими.Можно обернуть объекты в умный указатель
std::vector<std::shared_ptr<Meal>> Meals
, но это не бесплатно и некрасиво.Есть способ, который решает несколько проблем - все API-классы нужно объявить non-copyable И non-movable. Какие будут плюсы:
(1) Объекты невозможно случайно передать по значению/скопировать
(2) Указатели на объекты живут столько же, сколько контейнер, где содержится объект.
Компилятор C++ не даст заиспользовать контейнер как
std::vector
, который потенциально сможет инвалидировать ссылки. Скомпилируется использование безопасного контейнера, например std::list
.#madskillz
Указатели-коммуналки
Пусть мы хотим из метода вернуть какой-то объект вместе с булевыми флагами. Флаги это свойства, которые объект имеет. Тогда мы должны возвращать что-то вроде этого:
Но некоторым проектам (например Clang) это ощутимые затраты по памяти из-за выравнивания и прочего. Если у вас много где есть работа с указателями, и
В этом случае флаги можно отселить... прямо вовнутрь ссылки:
Как это должно работать? Ссылочный тип в 64-бит архитектуре имеет размер 8 байт, и мог бы напрямую адресовать оперативку до объемом до 16 млн ТБ.
Отсюда получается, что некоторые биты из этих 8 байт (старшие) вообще не используются, потому что они заведомо равны нулю.
Можно отселить флаг
А сам указатель сдвинуть на один бит, освобождая место флагу:
В Clang разрешено встраивать в указатель до трёх флагов (трёх битов): class PointerIntPair
Один из самых прикольных способов использования - класс
Из-за вышеописанной техники этот класс совершенно бесплатен по памяти!
Указатели-коммуналки
Пусть мы хотим из метода вернуть какой-то объект вместе с булевыми флагами. Флаги это свойства, которые объект имеет. Тогда мы должны возвращать что-то вроде этого:
template<typename Ty> class ActionResult {
bool Invalid = false;
Ty T;
// некие методы...
};
Но некоторым проектам (например Clang) это ощутимые затраты по памяти из-за выравнивания и прочего. Если у вас много где есть работа с указателями, и
Ty
часто имеет тип указателя, то ActionResult
можно приспособить чисто для указателей.В этом случае флаги можно отселить... прямо вовнутрь ссылки:
template<typename PtrTy> class ActionResult {
uintptr_t PtrWithInvalid; // можно считать что sizeof(uintptr_t) == sizeof(PtrTy*)
};
Как это должно работать? Ссылочный тип в 64-бит архитектуре имеет размер 8 байт, и мог бы напрямую адресовать оперативку до объемом до 16 млн ТБ.
Отсюда получается, что некоторые биты из этих 8 байт (старшие) вообще не используются, потому что они заведомо равны нулю.
Можно отселить флаг
Invalid
в нулевой бит:bool isInvalid() const { return PtrWithInvalid & 0x01; }
bool isUsable() const { return PtrWithInvalid > 0x01; }
bool isUnset() const { return PtrWithInvalid == 0; }
А сам указатель сдвинуть на один бит, освобождая место флагу:
PtrTy get() const {
return reinterpret_cast<PtrTy *>((PtrWithInvalid & ~0x01) >> 1);
}
В Clang разрешено встраивать в указатель до трёх флагов (трёх битов): class PointerIntPair
Один из самых прикольных способов использования - класс
QualType
. Он содержит ссылку на чистый тип (Type*
) и флаги-наличие квалификаторов (const
, restrict
, volatile
).Из-за вышеописанной техники этот класс совершенно бесплатен по памяти!
#video
NEED FOR SPEED
Если сравнивать C++ с другими популярными языками (Python, Java, C#, etc.), то адекватно написанные программы на нем будут почти наверняка быстрее аналогичных программ на других языках.
Однако и внутри C++ есть своя сегрегация по скорости.
(1) В стандартных проектах мало кого может удивлять использование
(2) В не очень стандартных проектах (браузеры, компиляция) уже немного сходят с ума - используют small vector (часть вектора на стеке), статический полиморфизм (юзают CRTP вместо виртуальных функций), вместо
(3) Но в реалтаймовых программах своя вселенная. Нельзя делать системные вызовы, блокировать поток, использовать алгоритмы сложности > O(1), и еще куча ограничений. Это обработка сигналов, звука, HFT-системы...
Про программы из класса (3) рассказывает Тимур Думлер:
https://youtu.be/8GlwkWxf3hk?t=3504
Использование стандартной библиотеки С++ для обработки сигналов в real-time
Это выступление было мне интересно, как человеку, никогда не сталкивавшимся с такими жесткими рамками 👍
NEED FOR SPEED
Если сравнивать C++ с другими популярными языками (Python, Java, C#, etc.), то адекватно написанные программы на нем будут почти наверняка быстрее аналогичных программ на других языках.
Однако и внутри C++ есть своя сегрегация по скорости.
(1) В стандартных проектах мало кого может удивлять использование
std::shared_ptr
вместо голых указателей, постоянные аллокации памяти, забытый где-то std::move
. Скорее всего, фикс такого это действительно "экономия на спичках" и только будет потом мешать разработке.(2) В не очень стандартных проектах (браузеры, компиляция) уже немного сходят с ума - используют small vector (часть вектора на стеке), статический полиморфизм (юзают CRTP вместо виртуальных функций), вместо
std::string
делают непонятно куда указывающие std::string_view
и т.д. Это встречали многие.(3) Но в реалтаймовых программах своя вселенная. Нельзя делать системные вызовы, блокировать поток, использовать алгоритмы сложности > O(1), и еще куча ограничений. Это обработка сигналов, звука, HFT-системы...
Про программы из класса (3) рассказывает Тимур Думлер:
https://youtu.be/8GlwkWxf3hk?t=3504
Использование стандартной библиотеки С++ для обработки сигналов в real-time
Это выступление было мне интересно, как человеку, никогда не сталкивавшимся с такими жесткими рамками 👍
#madskillz
Garbage Collector
На C++ есть проекты, где реализована сборка мусора.
Например, браузерный движок Blink (часть Chromium). Это такой монолит, где зависимости между разными объектами настолько сложные, что понимание общей картины - нереально для человека. И там есть МНОГО циклических зависимостей, потому что в какой-то момент архитектура бронзовеет и ее не переделать. Чтобы циклов не было, писали примерно так:
В какой-то момент всё было настолько плохо, что память протекала в 10% тестов. Решили проблему, добавив сборщик мусора. Это не особо повлияло на перф, но убрало многие протекания и краши - win!
https://docs.google.com/presentation/d/1YtfurcyKFS0hxPOnC3U6JJroM8aRP49Yf0QWznZ9jrk/edit
Garbage Collector
На C++ есть проекты, где реализована сборка мусора.
Например, браузерный движок Blink (часть Chromium). Это такой монолит, где зависимости между разными объектами настолько сложные, что понимание общей картины - нереально для человека. И там есть МНОГО циклических зависимостей, потому что в какой-то момент архитектура бронзовеет и ее не переделать. Чтобы циклов не было, писали примерно так:
class A {
RefPtr<B> m_b;
};
class B {
A* m_a;
};
В какой-то момент всё было настолько плохо, что память протекала в 10% тестов. Решили проблему, добавив сборщик мусора. Это не особо повлияло на перф, но убрало многие протекания и краши - win!
https://docs.google.com/presentation/d/1YtfurcyKFS0hxPOnC3U6JJroM8aRP49Yf0QWznZ9jrk/edit
Google Docs
Oilpan: GC for Blink (public)
Oilpan: GC for Blink No more crashes, No more leaks Kentaro Hara (haraken@chromium.org)
#advice
В любой непонятной ситуации делай шаблон
Это совет от Капитана Очевидности. Нередко в разных проектах встречаются две проблемы, которые решаются одним способом.
Пусть у нас есть метод, который принимает...
(1) Очень длинный тип, который вручную пишут полностью или через typedef.
Совет - надо просто заиспользовать шаблон, тогда не придется выискивать по репозиторию, какой же тип надо точно вписать.
(2) Передача лямбды в функцию. Вряд ли кто-то передает их как "ссылку на функцию" (а я их без Интернета не напишу), поэтому могут заиспользовать
Совет - можно избавиться от лишнего звена и передавать "напрямую", а компилятор даже сможет заинлайнить и оптимизировать код
Нужно быть осторожным, если не хотите копирования больших объектов. Лямбды это объекты closure type, размер в байтах которого зависит от размера за-capture-нных данных (ссылка/указатель - 8 байт, объекты по значению - их sizeof). В примере выше именно копирование объекта closure type, хотя на это забиваем из-за того что размер 0 байт (ничего не capture-им).
Поэтому код выше можно сломать, если сделать capture некопируемого объекта
Чтобы оптимизировать передачу лямбды в любых условиях, можно использовать универсальные ссылки
В любой непонятной ситуации делай шаблон
Это совет от Капитана Очевидности. Нередко в разных проектах встречаются две проблемы, которые решаются одним способом.
Пусть у нас есть метод, который принимает...
(1) Очень длинный тип, который вручную пишут полностью или через typedef.
using TBazArray = ::google::protobuf::RepeatedPtrField<Namespace::Foo::Bar::Baz>;
bool AllShallFall(const TBazArray& bazArray) { ... };
Совет - надо просто заиспользовать шаблон, тогда не придется выискивать по репозиторию, какой же тип надо точно вписать.
template<typename T>
bool AllShallFall(const T& bazArray) { ... };
(2) Передача лямбды в функцию. Вряд ли кто-то передает их как "ссылку на функцию" (а я их без Интернета не напишу), поэтому могут заиспользовать
std::function
. Он плох тем, что аллоцирует память в стеке, и вообще лишнее звено.bool AllShallFall(std::function<int(void)> callback) { ... };
// ...
AllShallFall([]() { return 4; });
Совет - можно избавиться от лишнего звена и передавать "напрямую", а компилятор даже сможет заинлайнить и оптимизировать код
template<typename T> AllShallFall(T callback) { ... };
// ...
AllShallFall([]() { return 4; });
Нужно быть осторожным, если не хотите копирования больших объектов. Лямбды это объекты closure type, размер в байтах которого зависит от размера за-capture-нных данных (ссылка/указатель - 8 байт, объекты по значению - их sizeof). В примере выше именно копирование объекта closure type, хотя на это забиваем из-за того что размер 0 байт (ничего не capture-им).
Поэтому код выше можно сломать, если сделать capture некопируемого объекта
std::unique_ptr i = std::make_unique<int>(3);
auto l = [i = std::move(i)]() { return 4; };
AllShallFall(l);
Чтобы оптимизировать передачу лямбды в любых условиях, можно использовать универсальные ссылки
template<typename T> AllShallFall(T&& callback) { ... };