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

По всем вопросам (+ реклама) @ninjatelegramm

Менеджер: @Spiral_Yuri
Реклама: https://telega.in/c/grokaemcpp
Мы на TGstat: https://tgstat.ru/channel/@grokaemcpp/stat
Download Telegram
Оптимизации 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
🔥18👍85
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
🔥15👍103💯1
Пропуск конструкторов копирования и перемещения

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

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

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

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


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

#cppcore #algorithm #hardcore
👍8🔥64💯1
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
🔥14👍114😱1
Статические методы класса

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

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

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

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

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

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

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

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

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

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

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

See all sides of the coin. Stay cool.

#compiler #cppcore #hardcore
🔥175👍5
Подробности про 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
👍156🔥6❤‍🔥1
Девиртуализация вызовов. Ч1
#опытным

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

Вызов виртуальных методов требует дополнительного разыменования указателя для вызова нужной реализации метода. Эта дополнительная операция как раз и тратит ценные такты процессора. А что нам мешает от неё избавиться? По большому счёту, если мы уверены, что работаем с объектом конкретного типа, то мы могли бы сделать что-то такое:
// object is always `Derived`
void baz(Base &object)
{
// explicit cast to Derived
static_cast<Derived&>(object).Derived::vmethod();
}


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

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

Будем рассуждать логически, что необходимо гарантировать компилятору, чтобы применить такую оптимизацию?
П.1: Неизменность виртуальной таблицы методов
П.2: Однозначность типа полиморфного объекта
П.3: Неизменность виртуального указателя в полиморфном объекте
П.4: Однозначность кандидата при вызове виртуального метода

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

Тут следует сделать оговорку, что стандарт C++ никак не регламентирует реализацию оптимизаций. Следовательно, они могут не выполняться. Данный материал актуален для компиляторов gcc, llvm clang, icc/icx под x86-64:
- GCC (6.1 и выше,
см. подробнее)
- LLVM, Clang (8.0.0 и выше,
см. подробнее)
- Intel Compiler (icx 2021.1.2 и выше, icc 19.0.0 и выше)


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

Классика
Начнём с того, что компилятор сам пытается выполнять прямую подстановку вызова метода. Из живого примера:
struct Base
{
virtual void vmethod();
};

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

int main()
{
Base *object = new Derived();

// call direct Derived::vmethod()
object->vmethod();
}

В данном случае, у компилятора получается проследить все операции над указателем object до вызова vmethod, чтобы доказать П.2 и П.3. Условия П.4 выполняется, т.к. main - это единственная точка входа в программу.

Запрет на переопределение
Идентификатор final, используемый как при объявлении классов, так и при объявлении методов, позволяет гарантировать, что других перегрузок методов не может быть в принципе:
struct Base
{
virtual void vmethod();
};

struct DerivedA : public Base
{
// Can't override `DerivedA::vmethod`
// in child classes
void vmethod() override final;
};

// Can't inherit from `DerivedB`
struct DerivedB final : public Base
{
void vmethod() override;
};

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

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

#optimization #hardcore #howitworks
🔥9👍7❤‍🔥221🤯1
Девиртуализация вызовов. Ч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
👍17🔥52🆒21🤯1
​​Почему приватные методы находятся в описании класса?
#опытным

Публичные, защищенные методы - понятно, они нужны либо для пользователей класса, либо для наследников. Поля - понятно, они влияют на размер объекта, а клиенты должны уметь правильно аллоцировать нужное количество памяти под объект. Ну а приватные-то методы зачем раскрывать?

Приватные методы - это вообще-то детали реализации. Если я в своем фреймворке по-приколу назову какой-нибудь приватный метод KillTheNigga, то другим людям уже нельзя будет пользоваться этим фреймворком. Закенселлят еще меня. Хотя какая блин разница, какие у меня там приватные методы? Они типа и названы private, чтобы показать, что они МОИ ЛИЧНЫЕ(никому не отдам). А их оказывается нужно еще и показывать всем. Что-то не сходится.

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

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

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

В чем прикол такого дизайн решения?

Давайте представим, что такого правила нет. И вот у нас есть две перегрузки, одна приватная для double, другая публичная для int. И перегрузка с double всегда отбрасывалась бы только лишь по причине того, что она приватная. Тогда мы легко можем вызвать публичную функцию с дробным числом 1.5 и нам ничего не будет. Оно просто неявно приведется к int и все на этом.

А теперь посмотрим, что будет, если мы поменяем модификатор приватной перегрузки на публичный? Ничего не упадет, НО! наш вызов метода с аргументом 1.5 теперь пойдет в другую функцию! То есть изменится поведение объекта. Комитет хотел избежать таких ситуаций, поэтому ввели такое вот ограничение. Наверное, причина не одна. Но это весомый аргумент.

Однако, такой протокол поведения влечет за собой различные сайд-эффекты. Я могу удалить(=delete) приватную перегрузку публичного метода, например какую мы обсуждали выше. И вызвать публичный метод опять с дробным числом. Но компилятор на меня наругается, потому что я попытался вызвать удаленную перегрузку метода. Хотя она вообще-то объявлена, как приватная! А то, что я ее удалил - это детали реализации. Получается раскрытие деталей реализации.

Stay aware. Stay cool.
#design #howitworks #cppcore #hardcore
👍21🔥74❤‍🔥2
​​Почему приватные методы находятся в описании класса?

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

Поля - понятно, они влияют на размер объекта, а клиенты должны уметь правильно аллоцировать нужное количество памяти под объект.

Ну а приватные-то методы зачем раскрывать? Зачем их помещать внутрь класса и делать видимыми для клиентского кода?

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

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

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

В чем прикол того, что приватные методы участвуют в разрешении перегрузок?


Давайте снова представим, что такого правила нет. И вот у нас есть две перегрузки, одна приватная для double, другая публичная для int. И перегрузка с double всегда отбрасывалась бы только лишь по причине того, что она приватная. Тогда мы легко можем вызвать публичную функцию с дробным числом 1.5 и нам ничего не будет. Оно просто неявно приведется к int и все на этом.

А теперь посмотрим, что будет, если мы поменяем модификатор приватной перегрузки на публичный? Ничего не упадет, НО! наш вызов метода с аргументом 1.5 теперь пойдет в другую функцию! То есть изменится поведение объекта. Комитет хотел избежать таких ситуаций, поэтому ввели такое вот ограничение. Наверное, причина не одна. Но им этой одной было достаточно.

Однако, такой протокол поведения влечет за собой различные сайд-эффекты.

struct DumbClass {
void DumbFunction(int param) {
// i do nothing because i’m dumb
}
private:
void DumbFunction(double param) = delete;
};

int main() {
DumbClass{}.DumbFunction(1.0);
}
// error: use of deleted function ‘void DumbClass::DumbFunction(double)’

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

Stay aware. Stay cool.
#design #howitworks #cppcore #hardcore
😁259👍6🔥3👎1