Грокаем C++
5.15K subscribers
14 photos
3 files
290 links
Два сеньора C++ - Владимир и Денис - отныне ваши гиды в этом дремучем мире плюсов.

По всем вопросам - @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Универсальные ссылки

Вообще говоря, вся эта серия постов началась с просьбы нашего подписчика Сергея Нефедова объяснить зачем нужны универсальные ссылки. Дождались! 🤩

В предыдущей статье я сделал акцент:

Тип rvalue reference задаётся с помощью && перед именем класса.

ОДНО БОЛЬШОЕ НО! Вместо имени класса может быть установлен параметр-тип шаблона:
template<typename T>
void foo(T &&message)
{
...
}


Ожидается, что из него будет выведен тип rvalue reference, но это не всегда так. Такие ссылки позволяют с одной стороны определить поведения для работы с xvalue, а с другой, неожиданно, для lvalue.

В своё время Scott Meyers, придумал такой термин как универсальные ссылки, чтобы объяснить некоторые тонкости языка. Рассмотрим на примере вышеупомянутой foo:
std::string str = "blah blah blah";

// Передает lvalue
foo(str);

// Передает xvalue (rvalue reference)
foo(std::move(str));


Оба вызова функции foo будут корректны, если не брать во внимание реализацию foo. Живой пример

Универсальная ссылка (т.н. universal reference) — это переменная или параметр, которая имеет тип T&& для выведенного типа T. Из неё будет выведен тип rvalue reference, либо lvalue reference. Это так же касается auto переменных, т.к. их тип тоже выводится.

Расставляем точки над i вместе со Scott Meyers:
Widget &&var1 = someWidget;
// ~~^~~
// rvalue reference

auto &&var2 = var1;
// ~~^~~
// universal reference

template<typename T>
void f(std::vector<T> &&param);
// ~~^~~
// rvalue reference

template<typename T>
void f(T &&param);
// ~~^~~
// universal reference


В соответствии с этим маленьким нюансом поведение может меняться внутри функции foo. Банально, можно накодить тормозящее копирование вместо производительной передачи ресурса.

Я немного изменил предыдущий пример: https://compiler-explorer.com/z/EzddYhjdv. В зависимости от выведенного типа, строка будет либо скопирована, либо перемещена. Соответственно, в области видимости функции main объект либо выводит текст, либо нет (т.к. ресурс был передан другому объекту внутри foo).

Причем, это не работает, если T — параметр-тип шаблонного класса:
template<class T>
class mycontainer
{
public:
void push_back(T &&other) { ... }
~~~^~~~
rvalue reference
...
};


Пример: https://compiler-explorer.com/z/We4qzG5xG

Получается, что в универсальные ссылки заложен дуализм поведения. Зачем же так было сделано? А за тем, что существуют template parameter pack:
template<class... Ts>
void foo(Ts... args)
{
bar(args...);
}

foo(std::move(string), value);
~~~~^~~~ ~~^~~~
xvalue lvalue


Как мы видим, разные аргументы вызова foo могут относиться к разным категориям выражений.

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

Другое дело, непонятно, почему нельзя было для универсальных ссылок сделать отдельный синтаксис? Например, добавить T &&&. Т.к. сейчас это рушит всю концептуальную целостность системы типов. Если это планировалось как гибкий механизм, то он граничит с полной дезориентацией разработчиков 😊

Я думаю, что нам еще нужны посты на разбор этой темы, чтобы это в голове уложилось. А пока будем развивать тему в сторону move семантики. Не забываем об исключениях в перемещающем конструкторе, а так же про оптимизации RVO/NRVO.

#cppcore #memory #algorithm #hardcore
Преинкремент vs постинкремент

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

Возьмем опять простенькую программу:

int main()
{
int i = 0;
int a = ++i; //i++;
return 0;
}

В первом случае я буду использовать преинкремент, во втором - постинкремент. Компилировать буду с g++ на x86-64 и без оптимизаций, потому что для такого примера просто не сгенерируется никакого кода, так как ничего полезного мы не делаем. Ну а на чуть более сложных примерах, компилятор понимает, что мы хотим сделать и просто засунет уже готовые числа по нужным адресам. Хочу продемонстрировать такую базовую хрестоматийную версию, о которой пишут в книжках.

Ассемблер для первой версии:
movl $0, -8(%rbp)
addl $1, -8(%rbp)
movl -8(%rbp), %eax
movl %eax, -4(%rbp)

Я сознательно опускаю всю обвязку и тут только суть. Кладем нолик в память со сдвигом 8 от rbp(этой ячейке памяти соответствует переменная i), добавляем к нему единичку, и через eax кладем это значение во вторую локальную переменную a со сдвигом 4 от rbp. eax - регистр-аккумулятор, в нем сохраняются промежуточные результаты арифметических вычислений, поэтому операция инкремента проходит через него. Интересно, что переменная a находится ближе к base pointer'у, хотя она объявлена позже. Это просто следствие того, что компилятор сам выбирает, как ему удобнее расположить локальные переменные на стеке и на это никак нельзя повлиять.

Теперь посмотрим на код постинкремента:
movl $0, -8(%rbp)
movl -8(%rbp), %eax
leal 1(%rax), %edx
movl %edx, -8(%rbp)
movl %eax, -4(%rbp)

Поменялось немногое, но различия уже заметны. Инструкцией leal мы складываем единичку с младшими 32-м битами в регистре rax(кто знает, почему не eax, напишите в комментах) и кладем это значение в edx, не трогая eax. Получается, что в edx создержится инкремент, а в eax - старое значение. Ну и дальше раскидываем их по правильным адресам на стеке. Обратите внимание, что в этом случае мы используем дополнительную память, а именно регистр edx, который не фигурировал в прошлом примере. Именно про это все говорят, когда объясняют, что постинкремент использует дополнительное копирование. Вот вам наглядное представление, как это утверждение ложится на уровень машинного кода.

Ставьте лайк, если нравятся такие низкоуровневые штуки и их на канале будет больше)

Stay hardwared. Stay cool

#hardcore #cppcore
Реальное предназначение inline

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

Но тогда смысл ключевого слова inline несколько теряется в тумане. Все равно все используют оптимизации в продакшене. Тогда есть ли реальная польза от использования inline?

Есть! Сейчас все разберем.

В чем прикол. Прикол в том, что для того, чтобы компилятор смог встроить функцию, ее определение ОБЯЗАНО быть видно в той единице трансляции, в которой она используется. Именно на этапе компиляции. Как можно встроить код, которого нет сейчас в доступе?

Почему это нельзя сделать на этапе линковки? Линкер резолвит проблему символов. Он сопоставляет имена с их содержимым. Линкер от слова link - связка. Для встраивания функции нужно иметь доступ к ее исходникам и информации вокруг вызова функции. Такого доступа у линкера нет. Да и задачи кодогенерации у него нет.

Что нужно, чтобы на этапе компиляции, компилятор видел определение функции? Ее можно определить в цппшнике, тогда все будет четко. Но такую функцию нельзя переиспользовать. Она будет тупо скрыта от всех других единиц трансляции. Ее можно было бы переиспользовать. Тогда нужно было бы везде forward declaration вставлять, что очень неудобно. И она видна будет только во время линковки. Во время компиляции ни одна другая единица трансляции ее не увидит. Поэтому нам это не подходит.

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

Как выходить из ситуации? Можно пометить функцию как static. Тогда в каждой единице трансляции будет своя копия функции. Но это ведет к дублированию кода функции и увеличению размера бинарника. Это нам не подходит.

Выходит, что у нас только одно решение. Разрешить inline функциям находиться в хэдерах и не нарушать ODR! Тогда нам нужны некоторые оговорки: мы разрешаем определению одной и той же inline функции быть в разных единицах трансляции, но тогда все эти определения должны быть идентичные. Потому что как бы предполагается, что они все определены в одном месте КОДА. Линкер потом объединяет все определения функции в одно(на самом деле выбирает одно из них, а другие откидывает). И вот у нас уже один экземпляр функции на всю программу.

Что мы имеем по итогу. Если мы хотим поместить определение обычной функции в хэдэр, то нам настучит по башке линкер со своим multiple definition и мы уйдем грустные в закат. Но теперь у нас есть другой вид функций, которые как бы должны быть встроены, но никто этого не гарантирует, и которые можно определять в хэдерах. Такие функции могут быть встроены с той же вероятностью, что и все остальные, поэтому от этой части смысла никакого нет. Получается, что мы можем пометить нашу функцию inline и тогда она ее просто можно будет определять в заголовочниках. Гениально.

Ох и непростая тема! Советую пару раз прочитать этот пост, чтобы хорошо все усвоить. Информация очень глубокая и фундаментальная. Пишите в комментах, что непонятно. И замечания тоже пишите.

Dig deeper. Stay cool.

#cppcore #compiler #hardcore #design #howitworks
Оптимизации RVO / NRVO

Всем привет! Настало время завершающего поста этой серии. Сегодня мы поговорим об одной из самых нетривиальных оптимизаций в С++.

Я очень удивлюсь, если встречу человека, который по мере изучения стандартных контейнеров никогда не задумывался, что эти ребята слишком «жирные», чтобы их просто так возвращать в качестве результата функции или метода:
std::string get_very_long_string();

...и приходили к мысли, что нужно заполнять уже существующий объект:
void fill_very_long_string(std::string &);

Эта мысль волновала всех с давних времен... Поэтому она нашла поддержку от разработчиков компиляторов.

Существует такие древние оптимизации, как RVO (Return Value Optimization) и NRVO (Named Return Value Optimization). Они призваны избавить нас от потенциально избыточных и лишних вызовов конструктора копирования для объектов на стеке. Например, в таких ситуациях:
// RVO example
Foo f()
{
return Foo();
}

// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}

// Foo no coping
Foo obj = f();


Давайте взглянем на живой пример 1, в котором вызов конструктора копирования явно пропускается. Вообще говоря, эта информация немного выбивается в контексте постов, посвященных move семантике C++11, т.к. это работает даже на C++98. Вот поэтому я её называю древней 😉

Немного теории. При вызове функции резервируется место на стеке, куда должно быть записано возвращаемое значение функции. Если компилятор может гарантировать, что функция возвращает единственный локальный объект, тип которого совпадает с lvalue, тогда он может сразу сконструировать этот объект напрямую в ожидаемом месте вызывающего кода. Допустимо отличаться на константность.

Иными словами, компилятор пытается понять, можно ли "подсунуть" область памяти lvalue при вычислении rvalue и гарантировать, что мы получим тот же результат, что и при обычном копировании. Можно считать, что компилятор преобразует код в следующий:
void f(Foo *address)
{
// construct an object Foo
// in memory at address
new (address) Foo();
}

int main()
{
auto *address = reinterpret_cast<Foo *>(
// allocate memory directly on stack!
alloca(sizeof(Foo))
);

f(address);
}


В конце поста потом почитайте ассемблерный код в комментариях, а пока продолжим.

RVO отличается NRVO тем, что в первом случае выполняется оптимизация для объекта, который создается при выходе из функции в return:
// RVO example
Foo f()
{
return Foo();
}


А во втором для возвращаемого именованного объекта:
// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}


Но при этом замысел и суть остаются такими же! Тут важно отметить, что и вам, и компилятору, по объективным причинам, намного проще доказать корректность RVO, чем NRVO.

Давайте покажу, когда NRVO может не сработать и почему. Рассмотрим кусочек из живого примера 2:
// NRVO failed!
Foo f(bool value)
{
    Foo a, b;
   
    if (value)
        return a;
    else
        return b;
}


Оптимизация NRVO не выполнится. В данном примере компилятору будет неясно, какой именно из объектов a или b будет возвращен. Несмотря на то, что объекты БУКВАЛЬНО одинаковые, нельзя гарантировать применимость NRVO. До if (value) можно было по-разному поменять каждый из объектов и их память. Или вдруг у вас в конструкторе Foo зашит генератор случайных чисел? 😉 Следовательно, компилятору может быть непонятно куда надо конструировать объект напрямую из этих двух. Тут будет применено копирование.

Продолжение в комментариях!

#cppcore #memory #algorithm #hardcore
Static глобальные переменные

Начнем разбирать тонкие моменты применения static. В контексте глобальных переменных.

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

Первое, что стоит понимать - static обозначает определенный цикл жизни объекта. Например, цикл жизни объекта на стеке - от создания до выхода из скоупа. А для статических глобальных переменных их цикл жизни начинается до захода в main(причем порядок инициализации глобальных объектов не определен), сохраняется в течение всего времени существования программы и заканчивается после выхода из main.

Второе - static указывает на место хранения. Статические глобальные переменные хранятся в сегменте данных - .data segment. Это место в адресном пространстве, где находятся все глобальные и статические переменные. Также это Read-Write сегмент, поэтому мы спокойно можем изменять данные, которые в нем находятся(в отличие от .rodata segment).

Третье - это ключевое слово определяет тип линковки для сущности. В данном случае у объекта появляется внутреннее связывание. Это значит, что объект будет недоступным для других единиц трансляции. Никто другой его не увидит. И даже если такой объект будет определен в хэдере, который будет подключаться в разные единицы трансляции, то в каждой из них будет создаваться своя копия этого объекта и эти копии будут уникальными для своего юнита. И это будут именно копии, то есть несколько экземпляров. А значит памяти все это дело будет занимать больше.

Расскажу чуть подробнее про линковочный аспект. Каждая единица трансляции компилируется независимо от остальных. На этом этапе компилятору может не хватать данных(например у него есть только объявление сущности), поэтому он вставляет в такие места заглушки. Эти заглушки заменяет на ссылки на реальные символы уже линкер. Так вот. У каждой единицы трансляции создается свой .data segment и там лежат глобальные и статические переменные, определенные в этом юните. Когда вы в хэдере определяете статическую переменную, это ее определение попадает в ту единицу трансляции, куда этот хэдер был включен. Соотвественно, в каждом таком юните будет свой сегмент данных, каждый из которых будет содержать свою копию. И у каждой из них даже скорее всего имя будет одинаковым.

Но потом приходит компоновщик и объединяет все юниты трансляции в один исполняемый файл и, в том числе, он объединяет сегменты данных. Поэтому в объединенном .data segment у вас будут 2 объекта с потенциально одинаковым символьным представлением(хотя с чего они должны быть разными). Например, для целочисленной переменной с именем qwerty, ее внутреннее представление может иметь примерно такое имя - _ZL6qwerty. Разные могут быть варианты манглинга, но что-то похожее на это так или иначе будет. И вот такие экземпляров будет 2. Только у них разные адреса будут и каждая из них будет относится только к своему "модулю" программы. А конфликтовать они не будут, потому что линкер по очереди обрабатывает эти единицы трансляции и жестко привязывает символы к адресам в памяти.

Вроде довольно подробно рассказал. Задавайте вопросы, если что-то непонятно. Поправляйте, если что не так описал. В общем, ждем в комментах)

Stay based. Stay cool.

#compiler #cppcore #hardcore
Пропуск конструкторов копирования и перемещения

Недавно был опубликован пост про RVO/NRVO. Какой еще можно сделать вывод из этой статьи?

Конструкторы копирования/перемещения не всегда могут быть вызваны! И если вы туда засовываете, например, какие-то счетчики, которые должны влиять на внешний код, то будьте готовы, что они могут остаться нетронуты.

Вообще говоря, никогда не стоит определять никаких сайд эффектов в конструкторах / деструкторах / операторах, если вы на них рассчитываете. Иначе может случиться вот это.

Конечно же, такую оптимизацию можно отменить с помощью флага компиляции:
-fno-elide-constructors


Тогда всё всегда будет вызываться, но при этом с потерей производительности. С другой стороны, это в принципе кажется странным — конструкторы не должны менять ничего снаружи себя. Соблюдайте это правило, и всё будет хорошо!

#cppcore #algorithm #hardcore
Inline под капотом

Мы уже знаем, что inline позволяет находится определению одних и тех же сущностей в разных единицах трансляции. А потом на этапе линковки, компоновщик объединяет все эти определения в одно. Но как конкретно он это делает? Как устроен этот механизм в деталях? Сегодня будем в этом разбираться.

Вернемся к примерам из вот этого поста(продублирую его в прикрепленной картинке к этому посту), но только уберем constexpr, чтобы компилятор не просто вставлял значение переменной в место ее использования, а прям создал эту переменную в секции .data, чтобы ее можно было видеть. Ну и пометим их static, чтобы на нас линкер не ругался. Да, это глобальная переменная, так нельзя делать, и ля ля ля. Но пример учебный, просто для понимания.

Как будет выглядеть переменная light_speed в единице трансляции, соответствующей файлу first.cpp?

.data
.align 4
.type _ZN9constantsL11light_speedE, @object
.size _ZN9constantsL11light_speedE, 4
_ZN9constantsL11light_speedE:
.long 299792458


Рассмотрим по порядку, что здесь происходит. Начинается сегмент данных, которые выровнены на 4 байта. Говорим, что наш символ _ZN9constantsL11light_speedE - это объект с размером 4 байта. И определяем этом символ, говорим, что он типа long со значением 299792458.

И в результирующем бинарнике у нас будет 2 экземпляра семантически одной переменной в разных единицах трансляции.

0000000000004010 d _ZN9constantsL11light_speedE
0000000000004020 d _ZN9constantsL11light_speedE

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

Теперь, что будет, если мы static заменим на inline.
.weak    _ZN9constants11light_speedE
.section .data._ZN9constants11light_speedE,"awG",@progbits,_ZN9constants11light_speedE,comdat
.align 4
.type _ZN9constants11light_speedE, @gnu_unique_object
.size _ZN9constants11light_speedE, 4
_ZN9constants11light_speedE:
.long 299792458


Здесь определяется слабый символ _ZN9constants11light_speedE. Слабый символ может быть переписан другим определением. Дальше идет секция данных и очень много страшных букв, но нам важно только последнее слово "comdat". Оно значит: "Здарова братишка, компоновщик! Не в службу, а в дружбу, не конкатенируй определения для символа _ZN9constants11light_speedE, а просто выбери из них всех одно и вставь в финальный бинарь. Мое увожение!". Это и есть тот маркер, по которому линкер определяет inline сущности. Ну и это все идет с компании с типом @gnu_unique_object, который должен быть уникальным во всех программе и это предотвращает дупликацию кода.

Тогда в бинарнике будет только одна запись на эту переменную.

0000000000004018 u _ZN9constants11light_speedE

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

Кстати, можно заметить пару деталей. В первом случае символ имел имя _ZN9constantsL11light_speedE, а во втором случае _ZN9constants11light_speedE.

Их объединяет то, что в оба имя включено название их нэймспейса. В С++ каждый символ имеет свое замангленное имя, которое может включать много всяких интересностей. Такое имя сильно облегчает компилятору и линкеру работу по разрешению вызовов и сопоставлению символов. Так что имя неймспейса включено в это расширенное имя объекта.

Но можно заметить и разницу. После имени неймспейса в случае статических переменных мы имеем заглавную L. Так компилятор помечает символ с внутренним связыванием.

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

Stay hardcore. Stay cool.

#cppcore #hardcore #cpp17 #compiler
Статические методы класса

В этом посте я верхнеуровнево обрисовал возможные применения ключевого слова static. И сегодня мы остановимся подробнее на статических методах.

Что это за фича такая. Это совершенно обычная функция, практически ничем не отличающаяся от функции, определенной прямо сразу после класса. Единственное семантическое отличие - статические методы имеют доступ к приватным и защищенным мемберам(полям и методам) конкретного класса. Оттого она и называется методом, потому что как бы привязана к классу возможностью подсматривать в скрытые поля.

Термин цикл жизни не совсем корректно применять к функциям, но я все же попробую. Жизнь обычного метода, точнее время, когда его можно использовать, ограничено жизнью объекта класса. Есть объект - можно вызвать метод. Нет объекта - нельзя. Со статическими методами другая петрушка. Они доступны всему коду, который видит определение класса, и вызвать их можно в любое время. Даже до входа в main(не то, чтобы это сильно необычно, но утверждение все равно верное).

По поводу линковки тут все просто - тип линковки совпадает с линковкой класса. По умолчанию она внешняя. Но какой-нибудь anonymous namespace может это изменить. Опять же параллели с обычными свободными функциями.

Пока писал этот пост, мне в голову пришла интересная идея. А что, если рассматривать статический метод не как свободную функцию, прикрепленную к классу? Точнее даже не так. А что если рассматривать ВСЕ методы класса, как обычные свободные функции с возможностью заглядывать с приватные члены? На самом деле ведь так и есть: нет никакого метода, в языке С нет такого понятия(это я к тому, что все плюсовые оопшные концепции реализованы с помощью сишных инструментов). Зато есть функции. А класс - это просто данные и функции, которым разрешили работать с этими данными. Теперь вопрос: как с этой точки зрения отличаются статические и нестатические методы?

И на самом деле в базе они отличаются лишь одной вещью: в нестатические методы передается первым параметром this(указатель на объект, из которого вызывается метод), а в статические не передается. И ВСЁ! Все остальные отличия идут от этого.

Например, статические методы не могут быть виртуальными. Оно и понятно, ведь у них нет объекта, из которого они всегда могут достать указатель vptr.

Или еще статические методы не могут быть помечены const. Не удивительно. Ведь на самом деле константные методы - функции, в которые передали const Type *, то есть указатель на константный объект. А его не завезли в статический метод. По этой же причине volatile к таким методам не применим.

Статический метод может быть сохранен в обычный указатель на функцию. Но не в указатель на метод (можете посмотреть пост про него). И ничего странного здесь нет, потому что указателю на метод нужен объект, а статическому методы - нет. Кстати, обычный метод тоже можно вызывать как через указатель на метод, так и через обычный указатель на функцию(ссылочка на посты про это тут и тут).

Попробую даже продемонстрировать это. Возьмем класс и определим у него статический и нестатический методы, которые будут делать одно и то же: увеличивать поле класса за счет входного параметра. Чтобы сделать тоже самое для статического метода, придется ему передать указатель на объект касса. И мы можем использовать для вызова обоих методов один и тот же тип указателя на функцию(см на картинку). Получается, что в нестатическом методе просто к именам, которые совпадают с членами или методами класса, компилятор неявно приписывается this->. Вот и вся история.

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

See all sides of the coin. Stay cool.

#compiler #cppcore #hardcore
Подробности про std::conjunction vs &&

В этом посте я рассказывал про замечательные тайптрейты std::conjunction, std::disjunction. Они позволяют компоновать несколько трейтов в одну логическую последовательность. Там же я рассказывал про то, что до них для этих целей использовались операторы &&, ||. Безусловно, человеческим языком обозванные сущности проще воспринимаются, чем какие-то символы. Но неужели это все различия? Какая-то вялая причина, чтобы вводить в стандарт эти трейты.

И правда, различия есть. Еще какие!

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

Примерно так этот класс может быть реализован

template<class...> struct conjunction : std::true_type
template<class B1> struct conjunction<B1> : B1 {};
template<class B1, class... Bn>
struct conjunction<B1, Bn...>
: std::conditional_t<bool(B1::value), conjunction<Bn...>, B1> {};


Специализация std::conjunction<B1, ..., Bn> имеет публичную базу, которая варьируется в зависимости от аргументов

Если их нет, то базовым классом для std::conjunction будет std::true_type.

Если они есть, то базой будет первый тип Bi из B1, ..., Bn, для которого bool(Bi::value) == false.

Если для всех Bi bool(Bi::value) == true, тогда базой будет Bn.

Кстати std::conjunction не обязательно по итогу наследуется либо от std::true_type, либо от std::false_type: она просто наследует от первого B, для которого его ::value, явно преобразованное в bool, является ложным, или от самого последнего B, когда все они преобразуются в true. То есть это самое value может быть даже не булевым значением, а например числом. Вот так:

std::conjunction<std::integral_constant<int, 2>,std::integral_constant<int, 4>>::value == 4 - верно!


И вот в этом весь прикол. std::conjunction - это вычисление по короткой схеме! То есть как только мы нашли такой Bi, что для него bool(Bi::value) == false, компилятор прекращает дальше инстанцировать вглубь рекурсии и однозначно определяет тип базового класса, а значит и поля value.

И как раз таки в этом аспекте метаклассы conjunction и disjunction отличаются от обычных && и ||. Но об этом мы поговорим завтра.

Understand true essence of things. Stay cool.

#cpp17 #template #hardcore
Please open Telegram to view this post
VIEW IN TELEGRAM
Девиртуализация вызовов. Ч2
#опытным

В предыдущем посте мы столкнулись с невозможностью девиртуализировать функцию bar, т.к. мы не могли гарантировать отсутствие вызовов из других единиц трансляции.

Получается, что нам достаточно ограничить внешнее связывание? Рассмотрим в примерах дальше 😊

Запрет на внешнее связывание 1
Итак, мы ведь знаем, что для конкретной функции можно запретить внешнее связывание, например, с помощью static. Из живого примера:
// direct call!
static void bar(Base &da, Base &db)
{
// push  rbx
// mov rax, [rdi]
// mov   rbx, rsi
da.vmethod(); // call DerivedA::vmethod()
// mov   rdi, rbx
// pop   rbx
db.vmethod(); // jmp   DerivedB::vmethod()
}

Вызов функции bar - единственный в данной единице трансляции, с конкретными наследниками Base. Следовательно, мы можем доказать П.2, П.4, П.3 (терминология из первой части).

Кстати, П.2 может быть доказан лишь частично! Например, bar можно вызывать с разными аргументами, тогда оптимизация будет совершена лишь частично:
// indirect + direct call
static void bar(Base &da, Base &db)
{
// push  rbx
// mov rax, [rdi]
// mov   rbx, rsi
da.vmethod(); // call  [[rax]]
// mov   rdi, rbx
// pop   rbx
db.vmethod(); // jmp   DerivedB::vmethod()
}

В данном случае, с учетом всех наборов аргументов при вызове foo, только второй vmethod может быть оптимизирован.

Запрет на внешнее связывание 2
В предыдущих способах можно заметить, что сложности возникают с доказательством П.2 и П.4. Компилятор опасается, что в других единицах трансляции появятся либо новые перегрузки, либо будут вызваны функции с объектами других наследников полиморфных классов.

Учитывая особенности сборки проекта, разработчик может намеренно сообщить компилятору, что других единиц трансляции не будет. В частности, для LLVM Clang можно применить следующие опции:
-flto -fwhole-program-vtables -fvisibility=hidden

В GCC можно вообще указать, что компилируемая единица и есть вся программа с помощью флага:
-fwhole-program

Он буквально разрешает считать, что компилятор знает ВСЕ известные перегрузки и их вызовы. Короче, отметит все функции ключевым словом static: живой пример.

Запрет на внешнее связывание 3
Еще один способ показать компилятору, что новых полиморфных перегрузок не появится. Можно использовать unnamed namespace:
namespace
{
struct Base
{
virtual void vmethod();
};

struct Derived : public Base
{
void vmethod() override;
};
}

Теперь данное семейство полиморфных классов будет скрыто от других единиц трансляции, что доказывает компилятору П.3 и П.4, а так же П.2 по месту требования.

Вот такими несложными действиями можно сократить количество обращений к таблице виртуальных методов и ускорить выполнение вашего приложения 😉

#cppcore #hardcore #howitworks