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

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

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

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

Вот есть у вас какая-то иерархия классов.

struct Base1 {
Base1() {std::cout << "Base1" << std::endl;}
~Base1() {std::cout << "~Base1" << std::endl;}
};

struct Derived1 : Base1 {
Derived1() {std::cout << "Derived1" << std::endl;}
~Derived1() {std::cout << "~Derived1" << std::endl;}
};

struct Base2 {
Base2() {std::cout << "Base2" << std::endl;}
~Base2() {std::cout << "~Base2" << std::endl;}
};

struct Derived2 : Base2 {
Derived2() {std::cout << "Derived2" << std::endl;}
~Derived2() {std::cout << "~Derived2" << std::endl;}
};

struct MostDerived: Derived2, Derived1 {
MostDerived() {std::cout << "MostDerived" << std::endl;}
~MostDerived() {std::cout << "~MostDerived" << std::endl;}
};

MostDerived{};


Что мы увидим в консоли, если запустим этот код? Вот это:

Base2
Derived2
Base1
Derived1
MostDerived
~MostDerived
~Derived1
~Base1
~Derived2
~Base2


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

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

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

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

Поэтому

Understand essence of basic concepts. Stay cool.

#OOP #cppcore
Усложняем задачу

Пока мы далеко не уехали от прошлой #задачки, предлагаю порешать ее усложненную версию, предложенную нашим подписчиком Антоном в этом комменте. В чате было вялое обсуждение, но прям конкретного решения не было предложено. Да и с тех пор в чате примерно 2к сообщений написали(за 2 дня!), поэтому вы вряд ли что-то найдете) Кстати, пользуясь моментом, призываю вас вступить в наш чат https://t.me/+qJ8-vWd97nExZGIy. Там ребята постоянно обсуждают код, фреймворки, метапрограммирование. Да и вообще просто веселятся и играют в шахматы)

А условие обновленной задачи такое: дан непустой массив интов. Каждый элемент массива встречается ровно 2 раза, кроме ДВУХ, которые встречаются 1 раз. Необходимо найти эти элементы за линейную временную сложность и константные затраты по памяти.

Всего один аспект условия изменили, и как сразу непонятнее все стало)

Знающим решение - просьба не отбирать хлеб у пытливых умов. Вечером выйдет ответ.

Challenge your life. Stay cool.
Ответ

Опять же, откидываем все решения через сортировку и мапы - они не самые эффективные. Хоть и более "программистские" чтоли.
Вернемся к решению к ксором.

Вот мы проксорили первым проходом весь массив. Получили на выходе ксор наших одиноких элементов. И что теперь делать? Как из этого значения получить оригинальные элементы?

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

Так как для одинаковых чисел xor будет давать ноль, то для разных чисел он будет давать какой-то ненулевой результат. Ненулевой - значит в бинарном представлении это значение будет иметь хотя бы единичку в каком-то из битов. Именно в этом бите N и будут различаться наши числа. А раз мы знаем, в каком бите различаются числа, то во время второго прохода мы можем отдельно ксорить числа с выставленным N битом и с невыставленным. По условию, только два числа непарные, а все остальные - парные. Пары чисел будут попадать в одну из групп и при ксоре будут занулять друг друга. Тогда у нас будут две суммы по модулю 2, которые и будут равняться нашим искомым a и b.

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

Solve your problems. Stay cool.
Виртуальный деструктор
#новичкам

Возможно САМЫЙ популярный вопрос на собеседованиях на джунов и мидлов. Оно и справедливо в принципе: очень простой и понятный вопрос, но ответ на него требует хорошего уровня понимания ООП в принципе и как оно конкретно работает в плюсах. Динамический полиморфизм, наследование, порядок вызовов конструкторов и деструкторов - да, это база. Но именно ее и нужно проверить у начинающих и продолжающих разработчиков.

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

struct Resource {
Resource() { std::cout << "Resourse has been acquired\n";}
~Resource() { std::cout << "Resource has been released\n";}
};

struct Base {
Base() { std::cout << "Base Constructor Called\n";}
~Base() { std::cout << "Base Destructor called\n";}
};

struct Derived1: Base {
Derived1() {
ptr = std::make_unique<Resource>();
std::cout << "Derived constructor called\n";
}
~Derived1() {std::cout << "Derived destructor called\n";}
private:
std::unique_ptr<Resource> ptr;
};

int main() {
Base *b = new Derived1();
delete b;
}


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

В коде вроде все хорошо написано и невнимательный кандидат может выдать вот это:

Base Constructor Called
Resourse has been acquired
Derived constructor called
Derived destructor called
Resource has been released
Base Destructor called


Вот тут-то его и подловили! На самом деле никакого деструктора наследника вызвано не будет и соответственно ресурсы не освободятся. Интервьюер дает наводку посмотреть на деструктор базового класса. И кандидат с красным лицом кричит: "Деструктор - невиртуальный! По указателю на базовый класс вызовется сразу деструктор базового класса, а деструктор дочернего не вызовется. Будет утечка памяти". Его так на курсах научили говорить. И дальше он выдает правильный вывод программы.

И тут интервьюер говорит: "А что будет, если наследник не будет содержать никаких полей? Какие проблемы будут у этого кода?".

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

Естественно, это неправда.

Если с виду ничего плохого не может произойти и даже при запуске программы ничего плохого не происходит - это не значит, что в программе нет проблем. Стандарт говорит:

if the static type of the object to be deleted 
is different from its dynamic type, the static type
shall be a base class of the dynamic type of
the object to be deleted and the static type
shall have a virtual destructor or
the behavior is undefined.


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

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

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

Stay armed. Stay cool.

#cppcore #interview
Default member initializer
#новичкам

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

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

Такие удобства появились у нас в C++11 - default member initializer. Это именно то, что и хотелось иметь в описанных выше ситуациях. Пример

template<typename T>
struct Stack {
// rule of 5
void push(const T& elem) {...}
void push(T&& elem) {...}
T& front() {...}
T& front() const {...}
void pop() {}
T GetMinElem() {...}
private:
std::deque<T> container;
T min_elem{Limit<T>::max_value};
}

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

Пример здесь сильно укороченный. Если реализовывать все по чесноку, то реализация такого шаблонного класса займет приличное количество места. Вариантов методов и конструкторов может быть миллион. И я не очень хочу в них возвращаться, чтобы узнать, какое изначальное состояние имеет поле min_elem. А здесь мы сразу видим: у пустого стека примем значение минимального элемента, как максимально возможное значение этого типа. Тогда при добавлении в стек первого элемента для обновления минимума мы можем пользоваться тем же условием, что и для добавления остальных элементов
if (new_elem <= min_elem)
min_elem = new_elem;

Limit<T> - шаблонный класс, который хранит максимальное и минимальное значение для заданного шаблонного типа. Это может быть реализовано как угодно: через явные специализации, через if constexpr и так далее. Шаблонная магия в общем. Кто хочет, опять же, может в комментах попрактиковаться в реализации этого класса.

Кто не знал - пользуйтесь, вещь полезная.

Stay useful. Stay cool.

#cpp11 #cppcore
Сочетание member initialization list и default member initializer
#опытным

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

Также в С++11 у нас появилась фича под названием default member initializer. Это та самая штуковина, которая позволяет вам инициализировать нестатические поля класса не в конструкторе, а прям inplace. Типа того:

struct Class {
int field = 5;
};


Фича полезная, многие ей часто пользуются. Но вот возникает вопрос: как список инициализации конструктора взаимодействует с default member initializer? Если я инициализирую поля вне конструктора и компилятор видит эти значения явным образом, то возможно эти поля и получают значение первыми? Сейчас все узнаем.

Посмотрим на такой пример:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
c{'3'},
e{'5'} {}
Char a;
Char b = '2';
Char c;
Char d = '4';
Char e;
};


Есть простенький класс Char, который выводит на консоль момент создания объекта. И тестовый класс, на котором мы и проводим эксперимент. И в этом эксперименте мы и проверим, в каком порядке свои значения получают поля b и d, относительно a, c, e.

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

Char 1
Char 2
Char 3
Char 4
Char 5


С этим разобрались.

И тут назревает вопрос: а что будет, если я в начале проициализирую поле inplace, а потом еще раз в constructor initializer list? Какая из инициализаций победит другую? Или быть может они произойдут обе в какой-то очередности?

Выглядеть это может так:

struct Char {
Char(char c) : field{c} {std::cout << "Char " << field << std::endl;}
Char() = default;
char field;
};

struct TestClass {
TestClass() : a{'1'},
b{'2'},
c{'3'},
d{'4'},
e{'5'} {}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};


Опять в подопытные мы взяли поля b и d и задали им значения с помощью default member initializer. А вдогонку еще и в списке инициализации присвоили им значение.

В такой ситуации default member initializer не играет никакой роли, блаженно складывает лапки и отдает бразды правления списку инициализации. Вывод будет тем же, что и в прошлом примере:

Char 1
Char 2
Char 3
Char 4
Char 5


Но это только список инициализации так работает. Если для инициализации поля вы используете обычный конструктор, то оно первый раз проинициализируется с помощью default member initializer(которая обязательно происходит до входа в тело конструктора), а второй раз - в теле конструктора.

struct TestClass {
TestClass() : a{'1'},
c{'3'},
d{'4'},
e{'5'} {b = '2';}
Char a;
Char b = 'b';
Char c;
Char d = 'd';
Char e;
};
// Output

Char 1
Char b
Char 3
Char 4
Char 5
Char 2


Пишите в комменты, если есть еще какие-то интересные кейсы взаимодействия этих сущностей. В будущем, разберем их на канале.

Mix things properly. Stay cool.

#cpp11 #cppcore
​​Member initialization. Best practices
#новичкам

Пост по запросу подписчика. Вот его вопрос.

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

Здесь я буду приводить какое-то общие и распространенные принципы. К каждому можно придраться и сказать "а у нас в проекте по-другому!". Исключения и другие подходы есть везде. Если хотите высказать свои варианты - комменты открыты.

Начну с того, что нужно предпочитать инициализировать поля либо с помощью списка инициализации конструктора, либо с помощью default member initializer. Дело в том, что все поля на самом деле инициализируются до входа в конструктор! Если списком инициализации или default member initializer'ом не установлено, как поле должно инициализироваться, то в конструктор оно попадет инициализированным по умолчанию. Именно поэтому, например, не можете в конструкторе инициализировать объект класса, у которого нет конструктора по умолчанию. Будет ошибка компиляции и у вас потребуют дефолтный конструктор. Запомните: конструктор нужен для нетривиальных вещей. С простой иницализацией справятся ctor initialization list и инициализатор по умолчанию.

Далее. Остается 2 способа, как инициализировать. Какой из них выбрать и в какой пропорции смешивать?

CppCoreGuidelies говорят нам: "Prefer default member initializers to member initializers in constructors for constant initializers".

То есть, если инициализатор константный, то используйте default member initializer.

Причина: inplace инициализатор делает явным то, что именно эти дефолтовые значения будут использоваться во всех конструкторах. Пример:

class X { // BAD 
int i;
string s;
int j;
public:
X() :i{666}, s{"qqq"} { } // j is uninitialized
X(int ii) :i{ii} {} // s is "" and j is uninitialized
// ...
};


Как в этом случае читатель кода поймет, была ли инициализация j специально пропущена(что скорее всего не очень гуд) или было ли для s намеренным выставление его значения в "qqq" в первом случае и в пустую строку во втором случае(почти стопроцентный баг)? Все эти ошибки могут появиться при добавлении новых полей в класс. По классике: добавили новое поле, использовали его в методах, но вот в одном месте упустили инициализацию. Кейс настолько жизненный, что мое почтение.

Более адекватный вариант:
class X2 { 
int i {666};
string s {"qqq"};
int j {0};
public:
X2() = default; // all members are initialized to their defaults
X2(int ii) :i{ii} {} // s and j initialized to their defaults
// ...
};


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

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

Используйте default member initializer и будет вам счастье!

Stay happy. Stay cool.

#cpp11 #cppcore #goodpractice
​​Short circuit для кастомных операторов
#опытным

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

В плюсах короткосхемностью обладают операторы && и ||. Из коробки их операндами могут быть переменные логического, целочисленного и указательного типа. Однако они все так или иначе приводятся к типу булеан. Поэтому в принципе корректно говорить, что логические операторы работают только с логическими типами. Что в целом довольно логичная логика.

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

Взгляните на следующий код:


struct CustomStruct
{
int number = 0;
bool operator&&(const CustomStruct& other)
{
return number && other.number;
}
};

static int check = 0;

CustomStruct func()
{
check = 1;
return CustomStruct{};
}

int main() {
CustomStruct a{};
a && func();
std::cout << check << std::endl;
}


Здесь мы создаем самую простую структурку и перегружаем для нее оператор логического И. Дальше, чтобы проверить ленивость вычисления оператора, пишем простую функцию, которая при исполнении изменяет статическую переменную. Так мы сможем наверняка убедиться, выполнилась ли функция или нет: если выполнилась, то переменная check будет выставлена в единицу, если нет, то останется нулем.

И вывод будет реально "1". Что выглядит довольно печально.

Ну и кстати, такое поведение довольно легко объяснить. Когда мы перегружаем операторы, то мы создаем новые функции. И я хочу акцентировать на этом внимание: это именно пользовательские функции, как бы они там не назывались. А аргументы пользовательских функций должны быть вычислены ДО захода в функцию. Поэтому любые операнды должны быть полностью вычислены до вычисления значения всего выражения. Это и приводит к отсутствию свойства short circuit.

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

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

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

Don't loose your properties. Stay cool

#cppcore
Short circuit операторы для кастомных типов

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

Что вообще такое логическое И и что оно делает?(я говорю только про И для краткости, те же рассуждения применяются и к ИЛИ) Эта логическая функция aka коньюнкция. Она отображает множество {0, 1}^N в {0, 1}. То есть она принимает N аргументов, каждый из которых может иметь только в двух значений 0 или 1, и результатом ее работы тоже является одно из двух значений: 0 и 1. Результатом будет 0, если хотя бы один из аргументов имеем значение 0. В обратном случае, результатом будет 1.

Что это нам дает.

А то, что операндами по строгому математическому определению могут быть только булевые значения. То есть, когда вы делаете логическое И с любыми объектами, на самом деле вы не хотите перегружать этот оператор для работы со своими объектами. Вы хотите(может и не осознанно) ровно такую же логику работы, как и у встроенного оператора: приводить операнды к true или false на ходу. Потому что Логическое И работает с бинарными сущностями. Это просто из определения исходит, что операнды должны быть бинарными. Поэтому на самом деле нужно не перегружать оператор, а научить компилятор преобразовывать объект в тип bool. Тогда вы сможете насладиться всеми чудесами вычислений по короткой схеме.

Пример из прошлого поста можно переписать вот так:


struct CustomStruct
{
int number = 0;
explicit operator bool() const
{
return number;
}
};

static int check = 0;

CustomStruct func()
{
check = 1;
return CustomStruct{};
}

int main() {
CustomStruct a{};
a && func();
std::cout << check << std::endl;
}


Теперь мы научили компилятор преобразовывать объекты нашего кастомного класса в булы и вместо перегруженного оператора используем встроенный. И вуаля, вывод этого кода будет "0". То есть функция func не выполнилась, потому что результат выражения стал ясен после вычисления первого операнда и смысла от вычисления второго нет.

Вот так получается, что нет смысла перегружать операторы логического И и ИЛИ для кастомных объектов. На самом деле нужно перегрузить оператор приведения к булевому значению. И будет вам счастье.

Define things properly. Stay cool.

#cppcore
​​Ревью

Попробуем сегодня новую рубрику на канале - #ревью. Мы будем выкладывать коротенькие и не очень куски кода, а вы в комментариях попытаетесь найти все-превсе ошибки в нем. Ну и предложить улучшения конечно. Че хейтить код попросту?

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

Посмотрим, что из этого выйдет. Думаю, будет интересно)

Код на картинке под постом.

Analyse your life. Stay cool.
​​Результаты ревью

Как и обещали, публикуем самый подробный ответ. Автором оказался Михаил, давайте похлопаем ему👏👏👏👏.

Ну и коротенькое саммари из комментариев:

🔞 С пространством имен точно намудрили, так как есть типа нестандартный istream, который возвращает стандартный streampos. Если это работало, то где-то был using определен, но пацаны не решили, как они будут дальше писать - с std:: или без. Либо крестик снимите, либо трусы наденьте.

🔞 Если это стандартный стрим, то и размер у него должен быть соответствующего типа std::streamsize.

🔞 Можно использовать новые стандарты с их constexpr'ами и std::array'ями. C++11 уже с натяжкой можно назвать новым стандартом, поэтому в большинстве проектов он уже доступен.

🔞 Оч много вопросов по поводу типа uint32. Стандартный ли это тип или нет? Что там с его выравниванием? Зачем он так мимикрирует под стандартный? Видимо ребятам было лень приписывать "_t" и они объявили typedef...

Ну и самая мякотка и основной консёрн - чтение.

🔞 Всего одна наивная проверка на конец стрима, но нет никаких проверок на ошибки(failbit, badbit с помощью соответствующих методов istream или просто is.good()). Потенциально это может привести к бесконечному циклу, так как ошибка произойдет, стрим мы читать не сможем, а условие окончания не достигнуто.

🔞 Цикл довольно тяжело читать, лучше поменять его на for. Тогда будет более явным образом подсвечены начальное состояние, условие окончания и переходная операция.

Такая короткая функция, а вон сколько всего можно улучшить!

Ревью - очень важная часть разработки ПО. Его обязательно нужно в принудительном порядке проводить во всех проектах, чтобы они не скатились в лютое govniwe.

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

Analyser yourself. Stay cool.
Виртуальный деструктор и std::shared_ptr
#опытным

Плюсы - поистине удивительный язык. Вот подписчик изучил у нас на канале пользу виртуального деструктора и пошел в комментарии. А там Василий прислал пример, который говорит о том, что в определенном случае виртульность деструктора не важна и без него все работает корректно. И подписчик действительно удивляется: "What the fuck is going on?!?!?!?". Разберем все по порядку.

Пример вот такой:
struct Base {
~Base() {
std::cout << "Base::~Base()" << std::endl;
}
};

struct Derived : Base {
~Derived() {
std::cout << "Derived::~Derived()" << std::endl;
}
};

int main() {
std::shared_ptr<Base> p1 = std::make_shared<Derived>();
}


Прикол в том, что при удалении p1 вызовется деструктор наследованного класса:

Derived::~Derived()
Base::~Base()


Почему так?

Во время создания std::shared_ptr вы можете задать свой кастомный делитер
. Но даже если вы его не предоставили, делитер все равно создается. Просто компилятор сам выведет по его мнению подходящий удалятель. И сохранит его в контрол блок умного указателя.

Так вот логично, что, если мы создаем указатель от объекта тип Derived, то и делитер выбирается соотвествующий. И в контрол блоке правого шареда будет делитер, который удаляет Derived*. Далее при присваивании указатель на этот конкретный контрол блок копируется левому шареду. После этого контрольный блок p1 содержит тот самый изначальный делитер, который условно говоря сделает перед удалением указателя каст к классу наследника(delete static_cast<Derived*>(ptr)).

Именно поэтому и вызывается деструктор наследника.

Если мы попытаемся создать std::shared_ptr вот так:

std::shared_ptr<Base> shared(static_cast<Base*>(new Derived));


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

Ну и с уникальным указателем с одним шаблонным параметров такая штука тоже не сработает. Там делитер оптимизирован и выбирается по умолчанию std::default_delete для типа шаблонного параметра, он не хранится в объекте. Поэтому для такой строчки:

std::unique_ptr<Base> p1 = std::make_unique<Derived>();


для p1 не вызовется деструктор наследника, потому что делитер типа std::unique_ptr<Base> удаляет только указатели на базовый класс. Чтобы объект удалялся корректно, нужен виртуальный деструктор базового класса. Без него никак.

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

Stay amazed. Stay cool.

#cpp11 #cppcore
Директивы ifdef, ifndef, if
#новичкам

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

Этот способ - использование директив препроцессора #ifdef, #ifndef, #if. Все три - условные конструкции. Первая смотрит, определен ли в коде какой-то макрос. Если да, то делаем одни действия, если нет - другие. Второй наоборот, входит в первую ветку условия, если макрос не определен, и входит во вторую, если определен. Директива #if проверяет какое-то условие, ничего необычного. Все три директивы могут иметь как полные формы(с веткой в случае если условие ложно), так и неполные(без "else").

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

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

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == 0
// mmx|sse|avx code
#elif CPU_TYPE == 1
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}


Если каждое значение CPU_TYPE включает нужную ветку кода и убирает из текста программы все остальные.

Если мы хотим оптимизировать только под интеловские процессоры, то можем написать чуть проще:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#ifdef OPTIMIZATION_ON
// mmx|sse|avx code
#else
for (int i = 0; i < vec1.size(); ++i)
result += vec1[i] * vec2[i];
#endif
return result;
}


(Все примеры - учебные, все совпадения с реальным кодом - случайны, не повторяйте код в домашних условиях). Здесь мы проверяем директивой ifdef, определен ли макрос OPTIMIZATION_ON, сигнализирующий что нужно использовать векторные инструкции. Если да, то ключаем в текст программы оптимизированный код. Если нет - обычный.

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

Широко известно, что такой способ не только устарел, но еще и опасен. Завтра посмотрим, чем конкретно.

Choose the right path. Stay cool.

#compiler
Опасности использования директив препроцессора

Вчерашний способ выбора ветки кода имеет несколько недостатков:

⛔️ Препроцессор работает с буквами/текстом программы, но не понимает программных сущностей. Это значит, что типабезопасность уходит из окна, и открывается простор для разного рода трудноотловимых багов.

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

⛔️ Вы ограничены возможностями препроцессора. Это значит, что вы не можете использовать в условии compile-time вычисления (аля результат работы constexpr функции).

⛔️ Отсюда же вытекает отсутствие возможности проверки условий, основанных на шаблонных параметрах кода. Это все из-за того, что препроцессор работает до начала компиляции программы. Он в душе не знает, что вы вообще программу пишите. Ему в целом ничего не мешает обработать текст Войны и Мира. Именно из-за отсутствия понимания контекста программы, мы и не можем проверять условия, основанные на compile-time значениях или шаблонных параметрах. Если вы хотите проверить, указатель ли к вам пришел в функцию или нет, или собрать какую-то метрику с constexpr массива и на ее основе принять решение - у вас ничего не выйдет.

⛔️ Вы очень сильно ограничены возможностями препроцессора. Попробуйте например сравнить какой-нибудь макрос с фиксированной строкой. Спойлер: у вас скорее всего ничего не выйдет. Например, как в примере из поста выше мы не можем написать так:

int DotProduct(const std::vector<int>& vec1, const std::vector<int>& vec2)
{
int result = 0;
#if CPU_TYPE == "INTEL"
// mmx|sse|avx code
#elif CPU_TYPE == "ARM"
// arm neon code
#else
static_assert(0, "NO CPU_TYPE IS SPECIFIED");
#endif
return result;
}

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

⛔️ С препроцессором в принципе опасно работать и еще труднее отлаживать магические баги. Могут возникнуть например вот такие трудноотловимые ошибки. Вам придется смотреть уже обработанную единицу трансляции, причем иногда даже не понимая, где может быть проблема. А со всеми включенными бинарниками и преобразованиями препроцессора это делать очень долго и больно. А потом оказывается, что какой-то умник заменил в макросах функцию DontWorryBeHappy на ILovePainGiveMeMore.

В комментах @xiran22 скидывал пример библиотечки, написанной с помощью макросов. Вот она, можете посмотреть. Это не только пример сложности понимания кода и всех проблем выше. Тут просто плохая архитектура, затыки которой решаются макросами.

Поделитесь в комментах своими интересными кейсами простреленных ступней из-за макросов.

Avoid dangerous tools. Stay cool.

#compiler #cppcore
​​Правила константности
#новичкам

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

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

Не будем долго задерживаться над константными методами. Константные объекты могут вызвать только константные методы. Все. Синтаксис такой:

void Class::Method(Type1 param1, Type2 param2) const {}

Теперь и константные, и неконстантные объекты могут вызывать метод Method.

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

Константный объект можно объявить двумя способами:

const T obj;
// Либо
T const obj;


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

Собственно также есть 2 нотации объявления массивов констант:

const T arr[5];
// либо
T const arr[5];


И 2 нотации определения ссылок:

const T& ref;
// либо
T const & ref;


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

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

struct Type {
Type& operator=(const Type& other) {
std::cout << "copy assign" << std::endl;
return *this;
}
};

Type a{};
Type& b = a;
b = Type{};
// OUTPUT:
// copy assign


Когда говорят "константная ссылка" имеют ввиду ссылку на константу. И при любом виде объявления const T& ref или T const & ref она также будет ссылкой на константу.

Теперь указатели. Наверное самое сложное из всего перечисленного. Указатели, в отличии от ссылок, сами могут быть константными, да еще и указывать на константные объекты. А еще могут быть многоуровненые указатели. В общем сложно. Но есть правило: при объявлении указателя каждое появление ключевого слова const относится к тому уровню вложенности, который находится слева от этого слова. Вы просто читаете объявление справа налево и получаете правильное понимание объявления. Примеры:

// Читаем справа налево
int * const ptr; // ptr - это константный указатель на инт
int * * const ptr; // ptr - это константный указатель на указатель на инт
int * const * const ptr; // ptr - это константный указатель на константный указатель на инт

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

int const * const * * const ptr; // ptr - это константный указатель на указатель
// на константный указатель на интовую константу
const int * const * * const ptr; // Тоже самое


ПРОДОЛЖЕНИЕ В КОММЕНТАРИЯХ

Rely on fixed thing in your life. Stay cool.

#cppcore

ПРОДОЛЖЕНИЕ В КОММЕНТАРИЯХ
​​Шаблонный сеттер
#опытным

Увидел на ревью интересный кейс. Мы о нем уже говорили, что не сильно акцентировали внимание. Сегодня больше времени уделим одному интересному явлению.

Если у вас есть какой-то шаблонный класс, который хранит тип Т, и в этом классе есть сеттер на этот тип, то по давней привычке(еще с 98 стандарта) его можно написать вот так:

template <class T>
struct TemplateClass {
void SetValue(const T& value) {
value_ = value;
}
private:
T value_;
};


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

Но иногда привычки ограничивают нас. Мы-то уже в modern C++ эре. И в данном случае как раз такой кейс.

Что будет, если мы захотим передать в этот метод временный объект? Например так:

struct ShowConstruct {
ShowConstruct() = default;
ShowConstruct(int value) : field{value} {
std::cout << "Param construct " << field << std::endl;}
ShowConstruct& operator=(const ShowConstruct& other) {
field = other.field;
std::cout << "Copy assign " << field << std::endl;
return *this;}
ShowConstruct& operator=(ShowConstruct&& other) {
field = other.field;
std::cout << "Move assign " << field << std::endl;
return *this;}
int field = 0;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});


На экран выведется:

Param construct 5
Copy assign 5


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

Потому что в сеттере value уже относится к категории lvalue. А при присваивании объекта от lvalue будет вызываться копирующий оператор присваивания.

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

Использовать универсальную ссылку. Скажет прошаренный читатель.

Для шаблонного кода мы можем пометить параметр метода двумя амперсандами и дальше внутри передавать его во все места через std::forward. Таким образом, если нам на вход пришел именованный объект, то std::forward скастует его к lvalue ссылке, а если временный, то к rvalue ссылке. И это поможет нам в нужных случая вызывать правильный оператор присваивания. И std::forward и universal reference доступны с 11-го стандарта вместе с введением мув-семантики.

template <class T>
struct TemplateClass {
void SetValue(T&& value) {
value_ = std::forward<T>(value);
}
private:
T value_;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});


Теперь мы получаем нужный вывод:

Param construct 5
Move assign 5


Однако этот прошаренный читатель оказался не таким уж и прошаренным! Такая штука не сработает для шаблонных параметров класса!

in class template argument deduction, template parameter 
of a class template is never a forwarding reference


Универсальная ссылка(она же forwarding reference) появляется только, когда тип параметра функции Т&& и Т - шаблонной параметр самой функции. В нашем случае нет никакого вывода - тип Т известен из класса. Поэтому и никакой универсальной ссылки не появляется.

Мы просто определили метод, который принимает rvalue ссылку. При попытке передать туда lvalue будет ошибка:

TemplateClass<ShowConstruct> obj;
ShowConstruct lvalue{7};
obj.SetValue(lvalue);

//ERROR: rvalue reference to type 'ShowConstruct'
// cannot bind to lvalue of type 'ShowConstruct'


Какой выход? Просто рядышком с сеттером для константной lvalue ссылки написать сеттер для rvalue ссылки.

template <class T>
struct TemplateClass {
void SetValue(const T& value) {
value_ = value;
}
void SetValue(T&& value) {
value_ = std::move(value);
}
private:
T value_;
};

TemplateClass<ShowConstruct> obj;
obj.SetValue(ShowConstruct{5});
ShowConstruct lvalue{7};
obj.SetValue(lvalue);


Тогда все нормально скомпилируется и в нужных места будут вызваны нужные операторы.

Stay universal. Stay cool.

#cpp11
​​Swap idiom

Рассуждения в комментах под предыдущим постом навели меня на мысли рассказать о swap idiom.

Дело в том, что, когда у вас есть рабочие деструктор, конструктор копирования и перемещения, вы можете соединять методы, которые должны принимать константную lvalue ссылку и rvalue ссылку, в один метод, который принимает параметр по значению. То есть можно вместо 2-х методов сеттеров можно написать 1:

template <class T>
struct TemplateClass {
void SetValue(T value) {
value_ = std::move(value);
}
private:
T value_;
};


Этой же концепцией вдохновлено появление swap идиомы. На самом деле я немного вру, но с появлением мув-семантики идиома приобрела эти черты.

Суть в чем. Есть у вас класс, который мэнэджит какие-то ресурсы. Например самописный класс массива:
class SimpleArray
{
public:
SimpleArray(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new intmSize : nullptr) {}

SimpleArray(const SimpleArray& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr) {
std::copy(other.mArray, other.mArray + mSize, mArray);
}
SimpleArray(simple_array&& other) noexcept
: mSize(other.mSize),
mArray(other.mArray) {other.mArray = nullptr;}

~SimpleArray()
{
delete [] mArray;
}

private:
std::size_t mSize;
int* mArray;
};


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

SimpleArray& operator=(const SimpleArray& other) {
if (this != &other) {
delete [] mArray;
mArray = nullptr;
mSize = 0;

mSize = other.mSize;
mArray = mSize ? new int[mSize] : nullptr;
std::copy(other.mArray, other.mArray + mSize, mArray);
}
return *this;
}


В такой реализации есть 3 проблемы:

❗️ Нам просто необходима проверка на самоприсвоение, чтобы в объекте остались те же данные. Но это настолько редкий кейс, что каждый раз при присвоении тратить время на проверку не очень хочется. А хочется операторы без этой проверки.

❗️ У нас есть только базовая гарантия исключений. Если из new бросится исключение, то состояние изменяемого объекта хоть и останется согласованным, но оно все равно изменится. А операция не завершится до конца. Хотелось бы строгой гарантии безопасности исключений.

❗️ Мы повторяем код. Помимо проверки самоприсваивания и очищения ресурсов тупо повторяется код копирующего конструктора. Хочется этого не делать.

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

SimpleArray& operator=(SimpleArray other) noexcept {
swap(*this, other);
return *this;
}


Как же красиво! Нам осталось только реализовать функцию swap. Она может быть и методом класса, но почему бы еще не иметь просто функцию, которая свапает контент. Поэтому покажу реализацию дружественной функции.

friend void swap(SimpleArray& first, SimpleArray& second) noexcept {
using std::swap;
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}


Выглядит кратко, читаемо, да еще и исключений нет(об этом даже явно в коде можно сказать)! Ляпота.

ПРОДОЛЖЕНИЕ В КОММЕНТАРИЯХ

Stay laconic. Stay cool.

#patter #cppcore #cpp11
Разбей пару яиц

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

Условие: у вас есть 2 яйца и 100 этажный дом. Яйца у вас очень крепкие(мы же плюсовики), поэтому если их сбрасывать из окон этого дома, то они не будут биться. Однако рано или поздно физика победит и, начиная с какого-то номера этажа N, яйца все-таки будут разбиваться. Вы можете перемещаться в доме вверх и вниз в любой последовательности. Какое минимальное количество бросков гарантировано понадобится, чтобы установить нужный этаж под номером N?

Знакомых с решением, прошу воздержаться от комментариев. Всех остальных призываю к обсуждению решения. Уверен, оно вам понравится своей красотой)

Ответ будет, как всегда вечером.

Хватит яйца мять, пора их разбивать!

Challenge yourself. Stay cool.

#задачки
Ответ

Буду описывать мои мысли, которые мне приходили в голову при решении.

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

Число 2 как бы намекает на то, что нужно что-то уполовинить. Но если мы сбросим с 50-го этажа и яйцо разобъется, это нам не сильно сократит задачу - придется в худшем случае еще 49 раз бросить.

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

Той же логикой можно увеличивать шаг - идти через 3/4/5 и тд этажей. Тогда после того, как первое яйцо разобьется, мы пойдем с предыдущего посещенного этажа вверх и будем подряд бросать. Формула для нахождения гарантированного количества шагов c помощью такого методв - (100 // step) + (step - 1) - 1.

Путем нехитрых математико-алгебраических вычислений придем к тому, что оптимальный вариант - идти через 10 этажей. Да и число красивое. В этом случае мы сможем найти нужный этаж за 18 бросков.

Но оптимальное ли это решение?

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

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

Можно было бы как-то соединить: ходить в начале с большим шагом, но с каждым броском первого яйца его уменьшать, пока нам вообще второе яйцо не понадобится или пока не наступит 100 этаж.

Так можно и соединить. Давайте с каждым броском первого яйца уменьшать шаг на 1 этаж. И к сотому этажу пусть у нас шаг уменьшится до минимума. Получается, что нам нужно начинать с шага в 14. После того, как бросили с 14-го, идем на 27. Потом на 39. И так далее.

Прикол в чем - нам всегда нужно будет максимум 14 бросков для нахождения нужного этажа.

Это и является ответом.

А я говорил, что задача красивая)

Solve your problems. Stay cool.
​​Swap idiom. Pros and cons
#опытным

В этом посте поговорили про суть swap идиомы. Сегодня обсудим ее плюсы и минусы.

Плюсы вроде как обсуждали, но я финализирую, когда можно рассмотреть внедрение swap idiom:

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

Если вы хотите красивый, лаконичный и понятный код без повторений действий.

Вы не очень беспокоитесь о потенциальных потерях производительности.

Погнали по минусам:

❗️ Не всегда можно написать nothrowing swap. Для базовых типов и указателей - да. Но swap нетривиальных типов использует временный объект. При создании которого и может возникнуть исключение. Сейчас swap делается с помощью перемещающих операций, но например в С++03 std::string мог кинуть исключение в копирующем конструкторе. Да и сейчас поля класса могут быть немувабельными и бросающими при копировании. Это надо иметь ввиду.

❗️ Каждый раз при присваивании мы выполняем 2 операции: конструктор копирования + swap или конструктор перемещения + swap. "Потери производительности" надо конечно тестить и смотреть реальные результаты, но в голове все равно надо держать потенциальные просадки.

❗️ Самостоятельно писать деструктор для менеджинга ресурсов в 2к24 - такая себе практика в большинстве случаев. Давно есть std::unique_ptr<T[]>, указатели с кастомными делитерами и прочие вещи. Одно из ключевых преимуществ идиомы - сокращение и переиспользование кода. Так вот с отсутствием деструктора вам вообще может не понадобится кастомное присваивание и вы сможете объявить операции дефолтными, поэтому надобность в идиоме сама по себе отпадет.

❗️❗️ Часто пропускаемый огромный минус: технически у нас есть оператор перемещения, который может принимать rvalue ссылки. Однако мы явным образом не реальзовывали присваивание перемещением, поэтому по правилу 5, компилятор не будет его генерировать за нас и у класса просто будет отсутствовать оператор присваивания перемещением.

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

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

struct FirstField {
FirstField() = default;
FirstField(const FirstField& other) {
std::cout << "FirstField Copy ctor" << std::endl;
}
FirstField& operator=(FirstField other) {
std::cout << "FirstField assign" << std::endl;
return *this;
}
FirstField(FirstField&& other) {
std::cout << "FirstField Move ctor" << std::endl;
}
};

struct SecondField {
SecondField() = default;
SecondField(const SecondField& other) {
std::cout << "SecondField Copy ctor" << std::endl;
}
SecondField& operator=(const SecondField& other) {
std::cout << "SecondField Copy assign" << std::endl;
return *this;
}
SecondField(SecondField&& other) {
std::cout << "SecondField Move ctor" << std::endl;
}
SecondField& operator=(SecondField&& other) {
std::cout << "SecondField Copy assign" << std::endl;
return *this;
}
};

struct Wrapper {
FirstField ff;
SecondField sf;
};

Wrapper w;
w = std::move(Wrapper{});

// OUTPUT:
// FirstField Move ctor
// FirstField assign
// SecondField Copy assign


Выбор использовать или не исопльзовать - как всегда за вам. Тестируйте гипотезы и выбирайте из них лучшую.

Analyse your solutions. Stay cool.

#cppcore #cpp11