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

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

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Продолжение про вызов метода через указатель

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

18 строка - Объявляется синоним к типу указателя на функцию, которая ничего не возвращает и принимает указатель на тип RandomType. Позже будет понятно, зачем это нужно.

19 строка - Есть 2 возможных синтаксиса для получения указателя на функцию: с амперсантом(сюрприз, кто не знал) и без. Без амперсанта не работает, потому такой синтаксис уже используется для вызова статических методов и будет путаница. Итак, мы получили какого-то неизвестного нам типа указатель на функцию. Идем дальше.

20 строка - Это трюк, который позволяет превратить указатель на функцию в указатель на число. Это необходимо для того, чтобы скастовать указатель _ptr из 19 строки к указателю на функцию типа func_ptr. Такого рода касты очень опасные и ведут к неопределенному поведению. Поэтому компиляторы просто их запрещают. Поэтому и нужно какое-то прокси состояние. Только компиляторы также запрещают кастовать указатель на функцию к указателю на число. Поэтому в ход идет наращивание индирекции указателя.

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

И, наконец, важнейший пункт. Помните в книжках всегда говорили, что в методы скрытно передается указатель на вызывающий его объект this? Так вот сейчас вам скорее всего впервые понадобится это знание на практике!
Методы неполиморфных классов - те же самые обычные функции(кто не знал). От остальных их отличает лишь этот параметр this, который скрытно передается первым аргументом. Именно поэтому, чтобы вызвать нестатический метод, нам нужен объект. Чтобы передать его первым параметром в функцию. Иначе вызов будет не соответствовать сигнатуре.
Поэтому мы берем созданный на стеке объект, находим адрес первого байта и передаем его в метод в качестве аргумента.

И вуаля. Все работает. Выводится пятёрочка.

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

Из этого поста надо вынести главное: ООП - лишь абстракция над более примитивными и низкоуровневыми конструкциями. Уметь оперировать абстракциями - полезнейший навык в нашем мире. Чем более сложные абстракции ты обрабатываешь, тем больше понимаешь мир и эффективнее решаешь задачи. Однако знать те самые кирпичики, на которых строятся сложные конструкции - не менее важная вещь. Только полная картина мира дает полное понимание происходящего.

Конструируй абстракции и погружайся в детали. Stay cool.

#fun #memory #howitworks #cppcore #hardcore
Квантовая суперпозиция bool переменных

Наверно у многих в головах давно лежит прямая и четкая ассоциация, что тип данных bool всегда принимает значение true или false. Спешу развеять ваши убеждения!

На первый взгляд кажется, что такая ветка условия никогда не может быть выполнена:
bool condition;

if (condition != true && condition != false)
{
// Недостижимый код?
}

Но, неожиданно и к сожалению, у менять есть вот такой пример: https://compiler-explorer.com/z/jf7zE64eq

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

Небольшой экскурс в историю. Раньше в языке C такого типа как bool не существовало в принципе. Вместо него использовались целочисленные переменные, такие как int. Неявное приведение происходит по правилу: 0 -> false, иначе true. Приведу пример:
if ( 0) // false
if ( 1) // true
if ( 2) // true
if (-1) // true


Но как мы знаем, С++ во многом совместимым с С. Следовательно, он перенимает некоторые особенности своего прародителя, поэтому логическая переменная может скрывать под собой абсолютно любое целочисленное значение! И напротив, логические константы true и false однозначно определены, как 1 и 0 соответственно.

Получается, что на самом деле мы работаем с этим:
int condition; // Неинициализированное значение

if (condition != 1 && condition != 0)
{
// Вполне себе достимый код
}


Конечно, в С++ этого получается почти всегда избежать, т.к. есть заранее определенные константы и неявные преобразования к типу bool. Но все же иногда бывают случаи, когда этого недостаточно. Например, когда переменная осталась неинициализированной.

Чисто теоретически можно создать другие, очень специфичные условия. Приведу другой пример, но напоминаю -- это UB: https://compiler-explorer.com/z/fn6YPvnzP

Да, просто под капотом сравнивается 10 != 1 - и никакой магии. Но увидеть это порой столь же неожиданно.

У вас могут появиться вопросы, зачем нам может понадобиться такое знание? Это ведь, фактически, UB, которое надо постараться воспроизвести!

Приведу практический пример из моей опыта. Я написал этот код несколько лет назад. Мне хотелось избежать лишних условных ветвлений в коде и написать что-то типа такого:
bool condition = ???; 
int position = index + static_cast<int>(condition);


А-ля, если логическая переменная condition == true (типа оно равно 1), значит index + 1, иначе index + 0. Так вот на самом деле нельзя с уверенностью сказать, какое целочисленное значение лежит под булем.

Пока что этот код не выстрелил :) Но я вижу его проблему... Так что перепроверяйте некоторые очевидные убеждения и пишите безопасный код!

#hardcore #cppcore #goodoldc
Сколько памяти вы можете аллоцировать?

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

На этом месте я предлагаю вам задуматься, на какой итерации остановится цикл? Ну то есть, сколько всего памяти с смогу выделить таким образом?

Для конкретики определимся, что у меня на машине 64-битная Ubuntu c 21111872 кбайт оперативной памяти или ~21 Гб. И выделяю я, просто вызывая маллок, ничего больше. Память я также не освобождаю (ждал бы завершения эксперимента уже в гробу😵).

Тут есть несколько вариантов:

1️⃣ Система нам выделить 21 Гб и скажет гуляй хлопец дальше без меня.

2️⃣ У операционной системы есть какой-то внутренний лимит, больше или меньше реального количества доступной памяти, который зависит от количества доступной RAM, и при достижении вот этого лимита ОС откажется выдавать больше памяти.

3️⃣ Мы каким-то образом сможем использовать тот факт, что процессы оперируют с виртуальной памятью, которая по размерам намного больше доступной, и сможем выделить больше памяти, чем есть на самом деле.

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

Система смогла выделить 131 террабайт памяти для нас. 131 ТЕРРАБАЙТ, КАРЛ. Вы в шоке? Я в шоке. Все в шоке.

Это примерно в 2^12 раза больше, чем доступно на машине. Кто офигел - ставим лайкосик.

What the fuck is going on и откуда такие цифры взялись, разберем в следующих постах.

Stay in touch. Stay cool.

#fun #memory #hardcore
Как система может выделить 131 Терабайт оперативы?

Здесь мы выясняли, сколько же памяти может нам выдать система. И ответ для многих оказался неожиданным. 131 тарабайт - в дохренальен раз больше, чем реальный объем RAM на тестовой машине. Понятное дело, что это фейковые терабайты, потому что их просто негде расположить. И если бы было хотя бы RAMx2, можно было бы еще поговорить про такие штуки, как файлы подкачки. Но здесь прям совсем ничего не сходится, поэтому погнали разбираться, что к чему. Повторю ремарку, что здесь я говорю про 64-битные системы.

Первая подсказка к ответу для вас - практически в точности такой же результат я получил на других своих машинах. Да и под тем постом @dtbeaver оставил скрин, что у него такие же цифры +- 2 Гб от того, что получил я. Значит этот предел - общий для, по крайней мере, большой группы линуксоидов с 64-битными системами. Это наводит на вопрос: а сколько вообще можно адресовать памяти? Может 131 Тб и есть это количество?

Вторая подсказка - выделилось на самом деле не 131(ох уж это эти десятичные приставки в двоичном мире...), а 128. До боли знакомое число...

Однажды на собесе меня спросили: сколько байт я могу адресовать в программе? И я ответил: 2^64 байт. Ну вот у нас есть указатель. Он занимает 8 байт или 64-бит памяти. Минимально адресуемый размер памяти - 1 байт. И получается, что 8 байт памяти могут хранить 2^64 уникальных чисел и, соответственно, именно столько байт и могут быть адресованы. У меня этот ответ приняли, типа я ответил правильно. Но я ошибался....

Для начала вспомним, как вообще данные программы маппятся на физическую память. Напрямую использовать физические адреса мы не можем, потому что тогда каждый процесс должен был знать о том, какие ячейки уже используются, чтобы не нарваться на конфликт. Поэтому придумали такую абстракцию - виртуальная память. Теперь каждый процесс думает, что он пуп вселенной и ему одному принадлежит вся память компьютера. Теперь процессу ничего не нужно знать, он просто кайфует и оперирует всем адресным пространством единолично. А грязной работой занимается ОС. А раз процессу "принадлежит" вся память компьютера, то в теории ему и доступны все те 2^64 байта для размещения своих данных.

Но на самом деле в современных системах для адресации используются только 48 бит адреса. Почему не все 64? 48-бит - это 256 Тб оперативной памяти. Нет таких промышленных систем, которые бы обладали таким объемом оперативной+swap памяти. Сейчас уже конечно стали появляться, поэтому появляются системы с 52/57 адресными битами, но сегодня не об этом. Представим, что их нет. Тогда введение возможности адресовать все 2^64 байта виртуальной памяти будет увеличивать сложность и нагрузку на преобразование виртуального адреса в физический. Зачем платить за то, чем не пользуешься? Да и 64-битная адресация потребовала бы больший размер страниц, больший размер таблиц страниц или большую глубину страничной структуры. Это все увеличивает стоимость кеш промаха в буфере ассоциативной трансляции (TLB). В общем, накладные расходы были бы больше. А никому этого не надо, пока у нас нет столько памяти.

Но вы спросите у меня: 128 терабайт - это 2^47, а ты нам говоришь, что 48 бит адресуются. Куда делся еще один бит, ааа?

Операционная система, как главный дерижер всех процессов в системе, может вмешиваться в их работу по самым разным причинам. Ну например, через системные вызовы. Поэтому в ОС нужно иметь возможность в адресном пространстве конкретного процесса адресовать свой код и свои данные. Поэтому операционка делает свою виртуальную память видимой в адресном пространстве каждого процесса. Это значит, что 2^48 байт делятся между адресным пространством пользователя (user space) и ядра (kernel space). История встречала разные отношения в этом разделении. Но сейчас более-менее все остановились на соотношении 1:1. То есть 256 терабайт делятся поровну между пользовательским процессом и системой. Положительную часть берет себе система, а отрицательную - процесс. Так и получаются те самые 128 Тб.

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

#memory #OS #fun #hardcore
std::byte

Если вы приличное время работаете с байтами на низком уровне, вы понимаете, что стандартные сишные возможности репрезентации сырых байтов данных не очень удобные. В основном сложности, конечно, в семантике. Вот возьмём какой-нибудь указатель на чар char *. Что это? Символ, число или просто сырой байт? Да, со временем это уже откладывается на подкорке, все всё понимают, ничего лучше же нет. Или есть?

Что такое std::byte?

std::byte — это фундаментальный тип данных, предназначенный для представления необработанных байтов памяти. Это неотъемлемая часть стандарта C++17, призванная обеспечить стандартизированный способ работы с необработанными двоичными данными. В отличие от базовых числовых типов, таких как char, int или float, std::byte — это отдельный тип, оптимизированный для операций на уровне байтов, что делает его более подходящим для задач, связанных с манипулированием памятью и низкоуровневым программированием.

Откуда оно взялось?

На самом деле все просто. В cтандарте этот тип определяется как enum class byte : unsigned char {} ;

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

1️⃣ Обеспечивает строгое разделение манипуляций с сырой памятью от числовых типов. Вы сразу видите, что оперируете с байтами, что снижает риск путаницы с типами данных.

2️⃣ Обеспечивает безопасность при проверке типов. Операции над std::byte выполняются без непреднамеренного преобразования типов, так как нет переопределенных операторов преобразования в базовые типы. Это помогает выявить потенциальные проблемы, связанные с типами, и повышает безопасность кода при работе с данными низкого уровня.

3️⃣ Явно поддерживает только байтовую и битовую арифметику за счёт переопределенных операторов сравнения и битовых манипуляций. Это с одной стороны, ограничивает функционал класса, а, с другой стороны, че вы ещё хотите делать с байтами?

4️⃣ В качестве стандартной фичи C++ использование std::byte обеспечивает безопасность вашего кода на уровне языка.

Есть один единственный минус у этой фичи. Очень мало народу ей пользуется. Большинству существующих проектов на плюсах больше 5 лет и там есть уже свои привычные методы работы с сырой памятью и сишным интерфейсом, которые естественно все завязано на типе char. И только потому, что в стандарте появилась новая фича, никто эти методы изменять не будет. Да и новые проекты могут по инерции использовать старый подход. Он всем знаком и проверен временем.

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

Stay hardcore. Stay cool.

#cpp17 #memory #hardcore
Рефлексия

Вам когда-нибудь хотелось получить значение enum'а, который имеет имя, соответствующее определенной строке? Или например во время выполнения на основе данных конфига выбрать подходящий класс для обработки? Или вызвать метод класса с помощью строки, содержащей его имя? Да все это в плюсах можно сделать, но какой ценой. Нужны эти нагромождения условных конструкций, которые смотрятся просто ужасно и от которых глаза вытекают из орбит. Почему нельзя сделать так, чтобы существовала какая-то структура, типа словаря, к которому можно обращаться в рантайме и выбирать произвольный тип, из которого я хочу создать объект?

Ответ прост. Можно. Но не в плюсах)

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

1️⃣ Язык C++ имеет совместимость с языком C и является его логическим приемником. Поэтому он стремится быть эффективным и близким к машинному коду. Полноценная рефлексия встроенная в язык требует дополнительные значительные вычислительные и ресурсные затраты, что может не соответствовать целям C++ для высокой производительности и низкого уровня абстракции. Как говорится, не плати за то, чем не пользуешься(слоган эмбеддед разработчиков по жизни).

2️⃣ Сюрприз: эффективность кода. Компиляторы с++ знамениты тем, что издеваются над нашим кодом во всех непристойных позициях и выдают самый эффективный машинный код. Они могут разворачивать циклы, встраивать функции и даже целые классы. Это все нужно для максимальной скорости и работает только за счет того, что все известно на момент компиляции. Что позволяет избегать накладных расходов и предоставлять предсказуемое поведение(не всегда). А рефлексия может нарушить эту предсказуемость, а значит и производительность.

Тем не менее плюсы медленно, но верно продвигаются в расширении рефлексивной функциональности. Мало того, что шаблонная магия позволяет из коробки делать очень многие вещи, так и в современных стандартах появляются такие штуки, как std::any, std::experimental::source_location и тд. Вряд ли когда-нибудь завезут что-то стандартное, подходящее всем и для любых целей, если за 40 лет этого так и не случилось ни с одной библиотекой или модулем. Но тенденция явно позитивная. А в пропоузале к С++26 есть даже целый раздел, посвященный статической рефлексии. Так что per aspera ad astra.

Stay optimistic. Stay cool.

#howitworks #hardcore
Application Binary Interface

Вчера разобрали, что обратная совместимость ABI играет значительную роль при разработке shared библиотек. Но это только применение этого понятие, сам термин мы еще не разбирали. Сегодня исправим этот момент.

ABI - набор правил, которые определяют соглашения о вызовах и расположение структур(стека, ваших кастомных и тд) в памяти. Соглашение о вызовах, наверное, здесь центровую роль играет. В общих словах, это какие операции нужно делать, чтобы выполнить функцию. Компьютер на самом деле не знает, что такое "выполнить функцию". Он знает лишь небольшой набор команд. Типа сложить, переместить, прыгнуть. То, как передаются аргументы для функции - через стек или через регистры, в каком порядке передаются аргументы, как очищать регистры, куда сохранять возвращаемое значение, и определяет calling convention. Но это что-то низкоуровневое, нам бы поближе к коду.

Сравним с API. Если API говорит, что вот есть такой-то набор функций и вы можете их использовать. То ABI говорит, как вы должны этими функциями пользоваться. Ну не вы, а скомпилированный код. Можно представить себе, что все детали того, что нужно сделать процессору, чтобы выполнить ваш код - это и есть ABI. То есть, как API, только на уровень ниже.

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

Приведу некоторые изменения в коде, которые могут аффектить ABI.

👉🏿 Добавление, удаление и изменение порядка полей класса.

👉🏿 Изменение иерархии классов. Данные всех базовых классов лежат в определенном порядке, изменение иерархии влечет изменения в том, как данные объекта располагаются в памяти.

👉🏿 Изменение шаблонных аргументов в шаблонных классах. Это влияет на то, какое mangled имя будет у класса, и соответсвенно, то как к нему обращаться.

👉🏿 Объявление функции как inline. Компилятор может встроить такую функцию и ее имени просто больше не будет в списке доступных функций.

👉🏿 Изменение сигнатуры функций, включая cv-квалификаторы. Тоже по причине манглинга.

👉🏿 Добавление первого виртуального метода. Обычно внутри объекта появляется vptr, это ведет к изменению расположения объекта в памяти.

👉🏿 Изменение порядка объявления виртуальных методов. В таблице виртуальных функций они располагаются по порядку и вызываются по порядку. Изменив порядок можно вызвать не тот метод.

👉🏿 Изменение набора приватных методов. Ну а здесь-то што?! Клиент же их даже вызвать не может? Дело в том, что приватные методы участвуют в разрешении перегрузок, поэтому клиентский код в каком-то смысле имеет к ним доступ и знает об этом наборе методов. Его изменение в коде не перезаписывает знание клиента о нем, поэтому низя так делать.

👉🏿 И еще куча приколюх с наследованием.

Деталей очень много и списочек там реально очень большой. Я привел только самую верхушку, которую все понимают.

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

При использовании pimpl мы избегаем такого исхода. Указатель - он и в Африке указатель. На заданной платформе имеет один и тот же размер. И пока вы не делаете этих "опасных" изменений в публичном классе, его структура никак не изменяется. А обычно таких изменений не делают, потому что там оставляют только базовый API, который очень стабилен.

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

Stay compatible. Stay cool.

#design #hardcore #cppcore
Что не нарушает ABI класса?

В комментах к предыдущему посту @MayerArtur удачно ванганул тему поста, который я писал в момент публикации его коммента. Поэтому этот пост обязан был выйти сегодня 😁. Вчера мы поговорили о том, что делать нельзя, если мы хотим сохранить стабильный ABI. Сегодня коротко пройдемся по тому, что делать можно. Завершим, так сказать, тему с ABI, чтобы картинка полная у вас была. Тут будет все в перемешку: и для хедеров, и для файлов реализации. Поехали:

Изменять реализацию метода. Довольно очевидно. Если не трогать сигнатуру, то внутри можно хоть хороводы водить с гусями, ничего для внешнего наблюдателя не изменится.

Добавлять новые публичные невиртуальные методы. Это новая функциональность, которая никак не мешает находить и пользоваться старой.

Добавлять новые конструкторы. Same thing. Ничто не помешает создать объект старым способом.

Добавлять новые енамы в класс.

Добавлять новые поля в существующие енамы. Сишные енамы - это всего лишь красивенькие цифры, памяти они не занимают и компилятор их встраивает в код в виде обычных чисел.

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

Добавлять новые классы и функции в файл. Obviously.

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

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

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

Stay compatible. Stay cool.

#design #hardcore #cppcore
Inline функции

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

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

Что здесь важно знать. Чтобы выполнить функцию нужно сделать довольно много дополнительных действий. Положить значение base pointer'а на стек, через него же или через регистры передать аргументы, прыгнуть по адресу функции, сохранить возвращаемое значение функции, восстановить base pointer и прыгнуть обратно в вызывающий код. Может что-то забыл, но не суть. Суть в том, что дополнительные действия - дополнительные временные затраты на выполнение. Опять такой trade-off между перфомансом и удобством.

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

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

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

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

Но помимо бенефитов встраивания кода, у него есть и недостатки.

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

Из неочевидного - встраивание функций может оказывать повышенное давление на кэш процессора. Например, если функция слишком большая, чтобы поместиться в L1, она может выполниться медленнее, чем при обычном выполнении function call. Для вызова функции CPU может заранее подгрузить ее инструкции и адрес возврата и выполнить ее быстрее. Или например, большое количество одного и того же встроенного кода может увеличить вероятность кэш-промаха и замедлить пайплайн процессора.

Опций, контролирующих встраивание, в компиляторе довольно много. Если будет желание, накидайте лайков и расскажу о них подробнее. Но самый простой способ разрешить инлайнинг - включить оптимизации O1 или даже О2.

Stay optimized. Stay cool.

#compiler #optimization #cppcore #performance #hardcore #memory
Универсальные ссылки

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

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

Тип 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