DNK_C_C++_Go_Rust
45 subscribers
14 photos
45 links
DNK - дневник кодера С и С++
Download Telegram
#350_Cpp_PkS

Что такое vptr и vtable?

vptr (сокращение от virtual pointer) и vtable (virtual table) — ключевые элементы реализации виртуальных функций в ЯП с поддержкой ООП, таких как C++.


Virtual Pointer (vptr) — указатель, который находится внутри объекта класса, содержащего хотя бы одну виртуальную функцию.
Этот указатель указывает на
таблицу виртуальных функций данного класса (vtable).
Каждый объект такого класса будет содержать этот указатель.

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


Virtual Table (vtable) таблица, которая содержит адреса всех виртуальных функций класса.
Для каждого класса с виртуальными функциями компилятор создает свою собственную таблицу vtable.
Когда вызывается виртуальная функция через указатель или ссылку, используется эта таблица для определения правильного метода, который нужно вызвать.

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

class Base {
public:
virtual void func1() { std::cout << "Base::func1" << std::endl; }
virtual void func2() { std::cout << "Base::func2" << std::endl; }
};

class Derived : public Base {
public:
void func1() override { std::cout << "Derived::func1" << std::endl; }
void func3() { std::cout << "Derived::func3" << std::endl; }
};

Для класса Base, vtable может выглядеть следующим образом:
Адрес
------------
&Base::func1
&Base::func2


А для класса Derived, vtable будет такой:
Адрес 
---------------
&Derived::func1
&Base::func2

Обратите внимание, что func1 переопределена в классе Derived, поэтому в vtable Derived стоит адрес новой версии этой функции.


Как это работает?
Когда вызывается виртуальный метод через указатель или ссылку на базовый класс, происходит следующее:
— из объекта извлекается vptr, который указывает на соответствующую vtable;
— в зависимости от того, какой метод вызван, из таблицы выбирается нужный адрес функции;
— вызов функции осуществляется по этому адресу.

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


Важные моменты:
Если есть класс без виртуальных методов, то в нем нет ни vptr, ни vtable.
Все экземпляры одного и того же класса используют одну и ту же таблицу vtable.
vptr добавляется только в классы, которые содержат хотя бы одну виртуальную функцию или наследуют такие функции от своих базовых классов.

Этот механизм позволяет эффективно реализовать динамическое связывание и является основой для поддержки полиморфизма в C++.
#350_Cpp_PkS

Где содержится vptr?

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

Когда создается экземпляр такого класса, компилятор автоматически добавляет поле для хранения vptr.
Это поле обычно размещено в самом начале объекта, но точное расположение зависит от конкретного компилятора и архитектуры системы.

Например, если есть следующий класс:
class Base {
public:
virtual void func() {}
};

При создании объекта типа Base, память под него будет выделяться таким образом:
первые несколько байт отводятся под vptr, который указывает на vtable класса Base;
— далее следуют данные-члены класса (если они есть).

Важно отметить, что наличие vptr увеличивает размер объекта на величину, равную размеру указателя.
Например, на 64-битной системе это будет 8 байт.

Если же класс не содержит виртуальных функций, то в нем не будет vptr, и соответственно, размер объекта будет меньше.
#351_Cpp_PkS

Где содержится vtable?

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

Основные моменты о расположении vtable:
Создание — компилятор генерирует vtable для каждого класса, который содержит хотя бы одну виртуальную функцию.
Эта таблица создается один раз для каждого класса и остается неизменной.
Расположение — vtable хранится в сегменте данных программы, который доступен всем потокам исполнения. Обычно это сегмент .rodata (только для чтения), поскольку содержимое таблицы не изменяется после создания.
Использование — при создании объекта класса, содержащий виртуальные методы, его vptr инициализируется указателем на соответствующую vtable.
Этот указатель ссылается на таблицу, находящуюся в статическом сегменте памяти.
Наследование — если класс наследует другой класс с виртуальными методами, то vtable наследуемого класса также включается в состав нового класса. Однако порядок функций в vtable может измениться, чтобы учесть новые или переопределенные методы.

Пример структуры памяти для класса с виртуальными функциями:
+---------------+
| vptr | -> Указывает на vtable
+---------------+
| Данные-члены |
+---------------+


Таблица виртуальных функций (vtable) выглядит примерно так:
+-------------+
| Адрес func1 | -> Указатели на функции
+-------------+
| Адрес func2 |
+-------------+
...



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

Какая разница между overload и override?

В программировании термины overload (перегрузка) и override (переопределение) относятся к различным концепциям, связанным с использованием функций и методов в ООП.


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

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

Пример перегрузки в C++:
#include <iostream>
using namespace std;

/* Перегруженная функция print() */
void print(int x) {
cout << "Integer: " << x << endl;
}

void print(double x) {
cout << "Double: " << x << endl;
}

int main() {
print(5); // Выведет: Integer: 5
print(6.7); // Выведет: Double: 6.7
return 0;
}

Здесь мы создали две версии функции print() с различными типами параметров: одна принимает целое число, другая — вещественное число.
В зависимости от типа передаваемого значения вызывается соответствующая версия функции.


Override (переопределение) переопределение функции относится к механизму полиморфизма в ООЯП.
Переопределение подразумевает замену поведения метода родительского класса методом дочернего класса.
Переопределяемая функция должна иметь такую же сигнатуру (имя и параметры), как и исходная функция в родительском классе.

Чтобы метод мог быть переопределен, он должен быть объявлен как virtual в родительском классе.

Пример переопределения в C++:
#include <iostream>
using namespace std;

class Animal {
public:
virtual void speak() {
cout << "Animal sound!" << endl;
}
};

class Dog : public Animal {
public:
void speak() override {
cout << "Woof! Woof!" << endl;
}
};

int main() {
Animal* animal = new Dog();
animal->speak(); /* Выведет: Woof! Woof! */

delete animal;
return 0;
}

Здесь метод speak() в классе Dog переопределяет одноименный метод из класса Animal. Поскольку animal — указатель на базовый класс, но фактически указывает на объект производного класса Dog, вызывается метод speak() из класса Dog.


Основные различия.

Цель использования:
Overloading — позволяет создавать разные версии одной и той же функции с разными наборами параметров.
Overriding — позволяет заменять поведение унаследованного метода в дочернем классе.

Связь с наследованием:
Overloading — не требует наследования. Может использоваться в одном классе для разных наборов параметров.
Overriding — требуется наследование. Используется для изменения поведения метода в дочерних классах.

Сигнатуры:
Overloading — функции имеют одинаковые имена, но разные сигнатуры (разное количество или типы параметров).
Overriding — методы должны иметь идентичные сигнатуры (точно такие же имена и параметры).

Типы методов:
Overloading — применяется ко всем видам методов (статическим, нестатическим, виртуальным и невиртуальным).
Overriding — применяется только к виртуальным методам.

Эти концепции часто используются вместе для достижения гибкости и расширяемости кода в ООП.
#353_Cpp_CMPL_PkS

Как компилятор различает члены класса и обычные переменные в функциях?

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


Члены класса (данные-члены и методы) — объявляются внутри тела класса. Они принадлежат конкретному классу и доступны каждому объекту этого класса. Члены класса могут быть доступными извне через механизмы доступа (например, public, protected, private), либо могут быть скрыты внутри класса.

Пример объявления членов класса:
class MyClass {
public:
// член класса
int memberVariable;
// метод класса
void memberFunction() {}
};


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


Пример использования локальных переменных:
void myFunction() {
// локальная переменная
int localVariable;
}


Различие в контексте.

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

Имя разрешения:
Для членов класса — доступ к членам класса осуществляется через объект класса или указатель/указатель на объект (через операторы . или ->).
Также можно использовать ключевое слово this для обращения к текущему объекту.
Для локальных переменных — локальные переменные просто называются своим именем в пределах своей области видимости.

Время жизни:
Члены класса — время жизни совпадает со временем жизни объекта класса.
Когда объект уничтожается — уничтожаются и все его члены.

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


Поля памяти:
Члены класса — хранятся в памяти объекта класса. Размер объекта увеличивается на сумму размеров всех его членов.
Локальные переменные — хранятся в стеке вызова функции (или в регистрах процессора). Их память освобождается при выходе из функции.

Рассмотрим на пример, иллюстрирующий разницу:
class Example {
public:
// член класса
int value;

void setValue(int val) {
// локальная переменная
int temp = val + 10;
// обращение к члену класса
this->value = temp;
}
};

int main() {
Example obj;
obj.setValue(5);
return 0;
}

В этом примере:
value — член класса Example, доступный через объект obj.
temp — локальная переменная функции setValue(), существующая только в пределах этой функции.

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

Компилятор различает члены класса и локальные переменные по следующим критериям:
— объявление в теле класса или функции;
— контекст доступа (использование оператора . или -> для членов класса);
— область видимости и время жизни.

Это помогает правильно интерпретировать код и корректно генерировать машинные инструкции для работы с этими переменными.
#354_Cpp_CMPL_PkS

Зачем используют exceptions в С++?

В C++ исключения (exceptions) играют важную роль в обработке ошибок и управлении особыми ситуациями.

Основные причины использования исключений:
Структурированная обработка ошибок — в отличие от традиционных методов возврата кодов ошибок, использование исключений позволяет явно разделить обработку ошибок от основного потока программы.
Код становится чище и легче читается, поскольку основная логика программы не загромождена проверками ошибок.
void someFunction() {
// Основная логика
throw std::runtime_error("Ошибка!");
}

int main() {
try {
someFunction();
} catch (const std::exception& e) {
std::cerr << "Ошибка: " << e.what() << '\n';
}
return 0;
}


Автоматическое освобождение ресурсов — когда возникает исключение, деструкторы объектов, созданных в стеке, автоматически вызываются, что помогает избежать утечек памяти и других ресурсов, даже если происходит сбой в середине выполнения функции.
class Resource {
public:
~Resource() {
std::cout << "Ресурс освобожден\n";
}
};

void functionWithException() {
// Ресурс создается
Resource res;
throw std::runtime_error("Ошибка");
}

int main() {
try {
functionWithException();
} catch (const std::exception& e) {
std::cerr << "Ошибка: " << e.what() << '\n';
}
return 0;
}


Упрощенная передача ошибок вверх по стеку вызовов — исключения позволяют передавать информацию об ошибках через несколько уровней вызовов без необходимости вручную проверять каждый шаг.
Это удобно, когда ошибка должна быть обработана на более высоком уровне абстракции.
void innerFunction() {
throw std::logic_error("Логическая ошибка");
}

void outerFunction() {
// Вызываем внутреннюю
innerFunction(); функцию
}

int main() {
try {
outerFunction();
} catch (const std::exception& e) {
std::cerr << "Ошибка: " << e.what() << '\n';
}
return 0;
}


Создание собственных типов исключений — в C++ можно создавать свои классы исключений, наследуя стандартные исключения или создавая полностью новые.
Это дает возможность более точно описывать природу возникшей проблемы.
class MyCustomException : public std::exception {
private:
const char* message;
public:
explicit MyCustomException(const char* msg) : message(msg) {}
const char* what() const noexcept override {
return message;
}
};

void customFunction() {
throw MyCustomException("Моя собственная ошибка");
}

int main() {
try {
customFunction();
} catch (const MyCustomException& e) {
std::cerr << "Ошибка: " << e.what() << '\n';
}
return 0;
}


Поддержка стандартов и библиотек — многие библиотеки и фреймворки, такие как STL (Standard Template Library), активно используют исключения для обработки ошибок.
Использование исключений помогает унифицировать подход к обработке ошибок и интегрироваться с существующими решениями.

Таким образом, исключения в C++ являются мощным инструментом для создания надежного и легко сопровождаемого кода, позволяя эффективно управлять ошибочными состояниями и ресурсами.
#355_Cpp_PkS_PPPO

Почему не желательно использовать виртуальный оператор присваивания virtual operator=?

Оператор присваивания (operator=) не предназначен для работы с полиморфизмом, который обеспечивается виртуальными функциями.

Почему использование виртуального оператора присваивания считается нежелательным:

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

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

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


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

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



Альтернативы:

Использование виртуальной фабрики — создание виртуальную фабричной функции, которая возвращает новый объект нужного типа.
Это позволит создавать объекты производных классов и выполнять операции над ними без необходимости явной обработки копирования:
class Base {
public:
virtual ~Base() {}
virtual Base* clone() const = 0;
};

class Derived : public Base {
private:
int value;
public:
Derived(int v) : value(v) {}
Derived* clone() const override { return new Derived(*this); }
};

int main() {
Base* obj = new Derived(42);
Base* copy = obj->clone();
delete obj;
delete copy;
return 0;
}


Использование паттерна "Копирование через конструктор" — можно реализовать копирующий конструктор, принимающий ссылку на базовый класс и вызывающий соответствующую копию объекта:
class Base {
public:
virtual ~Base() {}
virtual void copy(Base& other) = 0;
};

class Derived : public Base {
private:
int value;
public:
Derived(int v) : value(v) {}
void copy(Base& other) override {
if (Derived* d = dynamic_cast<Derived*>(&other)) {
this->value = d->value;
}
}
};

int main() {
Base* obj = new Derived(42);
Base* copy = new Derived(0);
copy->copy(*obj);
delete obj;
delete copy;
return 0;
}



Таким образом, вместо виртуализации оператора присваивания лучше использовать альтернативные методы: виртуальные фабричные функции или специальные методы копирования, обеспечивающие безопасное и предсказуемое поведение при работе с объектами разных типов.
#356_Cpp_PkS

Что такое блоки try-throw-catch?

Блоки try-throw-catch являются частью механизма обработки исключений в ЯП, таких как C++, Java, Python и других.
Исключения позволяют структурировать обработку ошибок таким образом, чтобы предотвратить аварийное завершение программы и обеспечить корректную реакцию на возникающие исключения.


Основные компоненты блока try-throw-catch.

Блок try — используется для обозначения участка кода, в котором возможно возникновение исключительной ситуации. Все операторы внутри этого блока будут проверены на наличие исключений.
Оператор throw — используется для генерации исключения. Он позволяет передать информацию об ошибке в виде объекта, который затем будет обработан в блоке catch.
Блок catch — используется для перехвата и обработки исключений, возникающих в соответствующем блоке try.
Внутри блока catch можно указать тип исключения, которое следует обработать, и написать код для реакции на эту ошибку.


Пример использования блоков try-throw-catch в C++:
#include <iostream>
using namespace std;

double division(double a, double b) {
if (b == 0) {
throw "Деление на ноль!";
}
return a / b;
}

int main() {
double x = 10.0;
double y = 0.0;

try {
cout << "Результат деления: " << division(x, y) << endl;
} catch (const char* error) {
cerr << "Ошибка: " << error << endl;
}

return 0;
}


Пояснения к примеру.
Функция division — проверяет делитель b: если он равен нулю, генерируется исключение с помощью оператора throw.
Если деление проходит успешно, возвращается результат.
Основной блок try — вызывает функцию division с параметрами x и y.
Если возникает исключение, оно перехватывается соответствующим блоком catch.
Блок catch — перехватывает строку, брошенную оператором throw, и выводит сообщение об ошибке.


Преимущества использования блоков try-throw-catch:
Контроль за исключением — позволяет разработчику контролировать возможные ошибки и реагировать на них программно, предотвращая аварийное завершение программы.
Чистая структура кода — разделение логики основной программы и обработки ошибок, что делает код более читаемым и поддерживаемым.
Обработка нескольких видов исключенийможно определить несколько блоков catch для обработки различных типов исключений, обеспечивая гибкость и точность в управлении ошибками.

Блоки try-throw-catch инструмент для разработки надежных и устойчивых к ошибкам приложений.
#357_C_Cpp_PkS

Что такое выравнивание данных?

Выравнивание данных (англ. data alignment) — процесс размещения данных в памяти таким образом, чтобы их адреса были кратны определенному числу байт, называемому "границей выравнивания" (alignment boundary).
Это делается для повышения эффективности работы с данными, так как современные процессоры и архитектура памяти оптимизированы под работу с выровненными данными.


Основные моменты.


Граница выравнивания — для разных типов данных существуют разные границы выравнивания.
Например, для типа int обычно используется граница 4 байта, а для типа double – 8 байтов.

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

В некоторых случаях попытка доступа к невыровненным данным может привести к ошибке аппаратного обеспечения (например, исключение "некорректный доступ к памяти").

Примеры границ выравнивания:
  char: 1 байт;
short int: 2 байта;
int, float: 4 байта;
long long, double: 8 байтов.


Компилирование кода — компиляторы часто автоматически выравнивают данные при размещении структур и массивов в памяти.
Однако можно явно указать компилятору, что данные должны быть выровнены определенным образом, используя специальные атрибуты или директивы препроцессора.

Пример на C/C++:
struct MyStruct {
char a; // 1 байт
int b; // 4 байта
double c; // 8 байтов
};

Без явного указания выравнивания компилятор может разместить поля структуры следующим образом:
a будет занимать первый байт;
за ним последуют три неиспользуемых байта (для выравнивания);
после этого начнется поле b,
и снова добавятся неиспользуемые байты перед полем c.


Явное указание выравнивания — в языке C можно использовать атрибут __attribute__((aligned(n))) для указания границы выравнивания:
struct __attribute__((packed)) MyPackedStruct {
char a;
int b;
double c;
};

Атрибут packed убирает выравнивание полей внутри структуры, делая её более компактной, но менее эффективной для чтения/записи.


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

Расскажите о логике catch-блоков в С++.

В C++ логика catch-блоков является частью механизма обработки исключений, которая позволяет программам эффективно реагировать на ошибки и исключительные ситуации во время выполнения.
Механизм состоит из трех ключевых компонентов: try, throw и catch.

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

Инструкция throw — когда возникает ошибка, инструкция throw генерирует исключение. Исключение может быть любым объектом, включая базовые типы данных (int, string), объекты классов или даже указатели.

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

Пример использования try-catch в C++:
#include <iostream>
using namespace std;

void division(int a, int b) {
if (b == 0) {
throw "Деление на ноль!";
}
cout << "Результат деления: " << a / b << endl;
}

int main() {
try {
division(10, 0);
} catch (const char* error) {
cerr << "Исключение: " << error << endl;
}

return 0;
}


Объяснение примера.
Функция division — проверяется условие деления на ноль. Если делитель равен нулю, выбрасывается исключение с помощью инструкции throw.
Если делитель не равен нулю, результат деления выводится на экран.
Блок main — вызывается функция division внутри блока try.
Если в функции division генерирует исключение, управление передается в ближайший catch-блок.
В данном примере catch-блок принимает строку const char*, которая соответствует типу исключения, выброшенного функцией division.
В catch-блоке сообщение об ошибке выводится на стандартный поток ошибок cerr.


Особенности логики catch-блоков в C++:
Последовательность catch-блоков — важно правильно расставить порядок catch-блоков.
Более специфичные исключения должны идти раньше общих.
Например, если нужно обработать конкретный тип исключения, такой как std::out_of_range, этот catch-блок должен стоять перед общим catch(...).
Перехват всех исключений — специальная форма catch-блока catch(...) позволяет перехватывать любое исключение, независимо от его типа.
Такой блок ставится последним в цепочке catch-блоков.
Ретроспектива исключений — в отличие от некоторых других языков, в C++ ретроспектива исключений (rethrow) осуществляется просто повторным использованием инструкции throw без аргументов внутри catch-блока, что позволяет передать исключение дальше по цепочке вызовов.
Конструкция try-catch с несколькими catch-блоками — можно иметь несколько catch-блоков для одного блока try, каждый из которых обрабатывает свой тип исключения.
Пример с несколькими catch-блоками:
#include <iostream>
#include <stdexcept>
using namespace std;

void exampleFunction() {
throw runtime_error("Ошибка времени выполнения");
}

int main() {
try {
exampleFunction();
} catch (const runtime_error& ex) {
cerr << "Исключение runtime_error: " << ex.what() << endl;
} catch (const exception& ex) {
cerr << "Исключение exception: " << ex.what() << endl;
} catch (...) {
cerr << "Неизвестное исключение!" << endl;
}

return 0;
}


Использование try-catch в C++ предоставляет инструмент для управления исключительными ситуациями, позволяя разработчикам писать более устойчивый и безопасный код.
Правильная организация catch-блоков помогает обеспечить корректную реакцию программы на различные виды ошибок и поддерживать целостность приложения.
#359_Cpp_PkS

Что такое move constructor?

Move конструктор (move constructor) — специальный конструктор класса в C++, который используется для перемещения ресурсов между объектами без их копирования.


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


Зачем нужен move конструктор?
Move конструктор позволяет избежать ненужного копирования и перемещать ресурсы из одного объекта в другой.
Вместо того чтобы создавать новую копию ресурса, он просто передает владение этим ресурсом новому объекту, оставляя исходный объект в "пустом" состоянии (например, указатели устанавливаются в nullptr):
#include <iostream>
#include <vector>

class Example {
public:
std::vector<int> data;

// Конструктор по умолчанию
Example() = default;

/* Обычный конструктор копирования */
Example(const Example& other)
: data(other.data) { /* Глубокое копирование вектора */
std::cout << "Copy constructor called\n";
}

// Move конструктор
Example(Example&& other) noexcept
: data(std::move(other.data)) { /* Перемещение вектора */
// Исходный вектор очищается
other.data.clear();
std::cout << "Move constructor called\n";
}
};

int main() {
// Создается пустой объект
Example a;
a.data.push_back(1);
a.data.push_back(2);
a.data.push_back(3);

/* Копируем объект a в b через конструктор копирования */
Example b(a);

/* Перемещаем объект a в c через move конструктор */
Example c(std::move(a));

return 0;
}

В этом примере при создании объекта b вызывается конструктор копирования, а при создании объекта c – move конструктор.
В результате после выполнения программы объект a будет пустым, так как его данные были перемещены в объект c.


Когда используется move конструктор:
— при передаче объектов в функции по значению;
— при возвращении временных объектов из функций;
— при работе со стандартными контейнерами
(std::vector, std::string и т.д.), которые могут вызывать перемещение элементов внутри себя.

Использование move конструкторов помогает значительно повысить производительность программ, особенно когда речь идет о больших объектах или сложных структурах данных.
#360_Cpp_PkS

Разница между константным методом и неконстантным?

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


Константные методы — объявляются с ключевым словом const в конце сигнатуры метода. Они обещают компилятору, что не будут изменять члены-данные объекта, за исключением тех, которые помечены как mutable:
class MyClass {
private:
int value;
public:
void setValue(int newValue) {
// Изменяет значение члена-данного
value = newValue;
}

// Объявляем метод как const
int getValue() const {
// Не изменяет членов-данных
return value;
}
};

Здесь метод getValue() является константным, потому что он не меняет состояние объекта.
Если попытаться изменить член-данное внутри константного метода, компилятор выдаст ошибку.


Неконстантные методы — не имеют ключевого слова const в своей сигнатуре и могут изменять состояние объекта:
void MyClass::setValue(int newValue) {
/* Изменение значения члена-данного */
value = newValue;
}

Метод setValue() является неконстантным, поскольку он явно изменяет значение члена-данного value.


Важность различий:
Безопасность — константные методы обеспечивают безопасность при использовании объектов, передаваемых по ссылке или указателю на константу.
Можно безопасно вызывать такие методы, зная, что они не изменяют состояние объекта.
Гибкость — неконстантные методы позволяют вам изменять состояние объекта, что необходимо во многих случаях, например, при изменении состояния объекта или обновлении его данных.

Пример вызова методов:
MyClass obj;
// Вызов неконстантного метода
obj.setValue(10);

const MyClass &ref = obj;
/* Можно вызвать только константные методы */
ref.getValue();
/* ref.setValue(20); // Ошибка! Нельзя вызвать неконстантный метод у константной ссылки */


Использование ключевых слов const для методов помогает улучшить читаемость кода и гарантировать, что объекты остаются неизмененными там, где это требуется.
#361_Cpp_PkS

Что такое В-нотация и как определить сложность любого алгоритма?

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

Основная идея B-нотации — оценка верхней границы сложности алгоритма, то есть насколько медленно алгоритм может работать в худшем случае.

Основные понятия B-нотации:
O(1) — постоянная сложность. Алгоритм выполняется за постоянное время независимо от размера входных данных.
O(log n) — логарифмическая сложность. Время работы алгоритма растет пропорционально логарифму размера входных данных.
O(n) — линейная сложность. Время работы алгоритма прямо пропорционально размеру входных данных.
O(n log n) — квазилинейная сложность. Часто встречается в сортировках, таких как быстрая сортировка (quick sort), сортировка слиянием (merge sort) и пирамидальная сортировка (heap sort).
O(n^2) — квадратичная сложность. Обычно встречается в алгоритмах, использующих вложенные циклы.
O(2^n) — экспоненциальная сложность. Очень медленные алгоритмы, такие как задачи перебора вариантов.
O(n!) — факториальная сложность. Еще более медленная, чем экспоненциальная, часто встречается в задачах комбинаторики.


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

Примеры.
Линейный поиск на С++:
#include <iostream>
#include <vector>

bool linearSearch(const std::vector<int>& arr, int target) {
for (size_t i = 0; i < arr.size(); ++i) {
if (arr[i] == target) {
return true;
}
}
return false;
}

int main() {
std::vector<int> arr{1, 2, 3, 4, 5};
int target = 3;
bool found = linearSearch(arr, target);
if (found) {
std::cout << "Элемент найден!" << std::endl;
} else {
std::cout << "Элемент не найден." << std::endl;
}
return 0;
}

Здесь цикл проходит по всему массиву, поэтому сложность составляет O(n).

Быстрая сортировка на С++:
#include <iostream>
#include <vector>

std::vector<int> quickSort(std::vector<int> arr) {
if (arr.size() <= 1) {
return arr;
}
int pivot = arr[arr.size() / 2];
std::vector<int> left, middle, right;
for (int num : arr) {
if (num < pivot) {
left.push_back(num);
} else if (num > pivot) {
right.push_back(num);
} else {
middle.push_back(num);
}
}
auto sortedLeft = quickSort(left);
auto sortedRight = quickSort(right);
sortedLeft.insert(sortedLeft.end(), middle.begin(), middle.end());
sortedLeft.insert(sortedLeft.end(), sortedRight.begin(), sortedRight.end());
return sortedLeft;
}

int main() {
std::vector<int> arr{3, 8, 2, 5, 1, 4, 7, 6};
std::vector<int> sortedArr = quickSort(arr);
for (int num : sortedArr) {
std::cout << num << ' ';
}
std::cout << std::endl;
return 0;
}

Быстрая сортировка имеет среднюю сложность O(nlog⁡n), но в худшем случае она может деградировать до O(n^2).
Перебор всех подмножеств на С++:
#include <iostream>
#include <vector>

void printSubsets(const std::vector<std::vector<int>>& subsets) {
for (const auto& subset : subsets) {
for (int num : subset) {
std::cout << num << ' ';
}
std::cout << std::endl;
}
}

std::vector<std::vector<int>> generateSubsets(const std::vector<int>& arr) {
size_t n = arr.size();
std::vector<std::vector<int>> subsets;
for (size_t i = 0; i < (1 << n); ++i) {
std::vector<int> subset;
for (size_t j = 0; j < n; ++j) {
if (i & (1 << j)) {
subset.push_back(arr[j]);
}
}
subsets.push_back(subset);
}
return subsets;
}

int main() {
std::vector<int> arr{1, 2, 3};
std::vector<std::vector<int>> subsets = generateSubsets(arr);
printSubsets(subsets);
return 0;
}

Здесь внешний цикл выполняет 2^n итераций, а внутренний цикл — n итераций, следовательно, общая сложность составляет O(2^n∗n).


B-нотация — инструмент для анализа производительности алгоритмов.
Понимание этой концепции помогает выбирать наиболее эффективные решения задач программирования.
#362_Cpp_PkS

Что такое таблица виртуальных методов в С++?

Таблица виртуальных методов (VMT, Virtual Method Table) — механизм, реализованный в C++ для поддержки динамического полиморфизма.
Он позволяет вызывать виртуальные методы класса на основе типа объекта во время выполнения программы, а не во время компиляции.


Виртуальный метод — метод который можно переопределить в производном классе.
Для объявления виртуального метода используется ключевое слово virtual:
class Base {
public:
virtual void method() {
std::cout << "Base::method()\n";
}
};

class Derived : public Base {
public:
void method() override {
std::cout << "Derived::method()\n";
}
};


Как работает VMT?
Для каждого класса, содержащего хотя бы один виртуальный метод, компилятор создает таблицу виртуальных методов.
Tаблица хранит адреса всех виртуальных методов данного класса.
Каждый объект такого класса содержит указатель на эту таблицу, называемый указателем на VMT (vptr).
Таким образом, при вызове виртуального метода программа обращается к таблице виртуальных методов соответствующего класса и вызывает нужный метод:
Base* basePtr = new Derived();
basePtr->method(); /* Выведет "Derived::method()" */

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


Структура VMT.
Каждая запись в таблице виртуальных методов соответствует одному виртуальному методу.
Таблицы VMT организованы следующим образом:
Указатель на базовый класс — указывает на таблицу виртуальных методов базового класса, если таковой имеется.
Адреса виртуальных методов — последовательность адресов всех виртуальных методов данного класса.

Если в производном классе добавлены новые виртуальные методы, они будут добавлены в конец таблицы VMT.

Пример структуры VMT, допустим, есть следующие классы:
class A {
public:
virtual void f() {}
virtual void g() {}
};

class B : public A {
public:
void f() override {}
void h() {} // Новый метод
};

Тогда таблицы виртуальных методов для этих классов могут выглядеть примерно так:
VMT для класса A:
Адрес метода A::f
Адрес метода A::g
VMT для класса B:
Адрес метода B::f (переопределяет A::f)
Адрес метода A::g (унаследован от A)
Адрес метода B::h (новый метод)



Преимущества и недостатки.

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

Недостатки:
— увеличение размера объектов за счет хранения указателя на VMT;
— небольшая дополнительная накладная стоимость на вызов виртуальных методов из-за необходимости обращения к таблице.



Таблица виртуальных методов — механизм в C++, обеспечивающий поддержку динамического полиморфизма и позволяет объектам разных типов вести себя по-разному в зависимости от контекста, делая код более универсальным и удобным для расширения.
#363_Cpp_PkS_PPPO

Какие функции класса автоматически генерирует компилятор, если их не определить?

Компиляторы C++ автоматически генерируют четыре специальные функции-члена для классов, если они явно не определены программистом:

Конструктор по умолчанию (default constructor) — конструктор без параметров.
Деструктор (destructor) — функция, которая вызывается при уничтожении объекта и освобождает ресурсы, выделенные объектом.
Конструктор копирования (copy constructor) — создает новый объект как копию существующего.
Оператор присваивания (assignment operator) — позволяет присвоить значение одного объекта другому.

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

Стоит отметить, что начиная с C++11, можно использовать ключевые слова = default и = delete, чтобы явно указать компилятору необходимость генерации специальных методов или их удаления соответственно.
#364_Cpp_PkS_STL

Какие есть стандартные контейнеры и на основе каких структур они построены?

В стандартной библиотеке C++ (STL) существует несколько типов контейнеров, каждый из которых построен на основе различных структур данных.


Последовательные контейнеры:

Вектор (std::vector) — основан на динамическом массиве.
std::vector — изменяемый массив элементов, который может увеличиваться и уменьшаться по мере необходимости и поддерживает произвольный доступ к элементам по индексу.

Список (std::list) — основан на двунаправленном списке.
std::list состоит из узлов, каждый из которых содержит данные и указатели на предыдущий и следующий элементы.
Позволяет быстро вставлять и удалять элементы в середине списка, но не поддерживает произвольный доступ.

Двунаправленная очередь (std::deque) — основан на двусвязном списке блоков фиксированного размера.
sdt::deque (двунаправленная очередь) — предоставляет возможность быстрого добавления и удаления элементов как в начале, так и в конце контейнера.
Поддерживает произвольный доступ к элементам.

Стек (std::stack) — основан на последовательных контейнерах (обычно на std::deque).
std::stack реализует принцип LIFO ("последний вошел — первый вышел"). Он позволяет добавлять и извлекать элементы только с конца контейнера.

Очередь (std::queue) — основан на последовательных контейнерах (обычно на std::deque).
std::queue — реализует принцип FIFO ("первый вошел — первый вышел"). Она позволяет добавлять элементы в конец очереди и извлекать их из начала.

Приоритетная очередь (std::priority_queue) — основан на бинарной куче (обычно реализованной через std::vector).
std::priority_queue — всегда возвращает элемент с наибольшим приоритетом (по умолчанию это максимальный элемент).
Это удобно для задач, где нужно обрабатывать элементы в порядке приоритета.


Ассоциативные контейнеры:

Множество (std::set) и мультимножество (std::multiset) — основан на сбалансированном двоичном дереве поиска (например, красно-черное дерево).
Множество хранит уникальные элементы в отсортированном порядке.
Мультимножество допускает дубликаты элементов.

Отображение (std::map) и мультикарта (std::multimap) — основаны на сбалансированных деревьях поиска (аналогично множествам).
Отображение — связывает ключи с значениями таким образом, что каждому ключу соответствует одно значение.
Мультикарта — позволяет одному ключу иметь несколько значений.

Хеш-множества (std::unordered_set) и хеш-мультимножества (std::unordered_multiset) — основаны на хэш-таблицах.
Эти контейнеры аналогичны множеству и мультимножеству, но используют хеширование для ускорения поиска элементов.
Элементы хранятся в случайном порядке.

Хеш-отображения (std::unordered_map) и хеш-мультиотображения (std::unordered_multimap) — основаны на хэш-таблицах.
Аналогичны обычным отображениям, но используют хеширование вместо дерева для хранения пар "ключ-значение".
Позволяют быстрее находить элементы по ключу, чем обычные карты.


Каждый тип контейнера имеет свои особенности и оптимизирован под разные задачи.
Выбор подходящего контейнера зависит от требований к производительности операций (вставка, удаление, поиск), а также от особенностей вашей программы.
#365_CMPL_Cpp_PkS_UB

Что такое Undefined behavior?

Undefined Behavior (UB) — ситуация в ЯП, когда поведение программы не определено стандартами языка.
Когда программа сталкивается с UB, она может вести себя непредсказуемо: завершиться аварийно, продолжить выполнение с неверными данными, выдать неожиданный результат или даже работать корректно, но только до тех пор, пока условия выполнения кода остаются неизменными.

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

Примеры Undefined Behavior в C++.

Чтение/запись за пределами массива:
int arr[5];
arr[10] = 42; /* UB: попытка записи за пределы массива */

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

Разыменование нулевого указателя:
int* ptr = nullptr;
*ptr = 100; /* UB: разыменование нулевого указателя */

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

Использование неинициализированного значения:
int x;
std::cout << x; /* UB: чтение неинициализированной переменной */

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

Попытка изменения константного объекта:
const int a = 10;
/* UB: изменение константного объекта */
const_cast<int&>(a) = 20;

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

Модификация строки литерала:
char* str = "Hello";
/* UB: модификация строкового литерала */
str[0] = 'J';

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

Неопределенный порядок вычислений:
int i = 1;
/* UB: неопределённый порядок вычисления выражения */
i = i++ + ++i;

Выражение i = i++ + ++i включает две модификации одной и той же переменной между двумя последовательностями точек наблюдения, что вызывает неопределённое поведение.
Результат этого выражения непредсказуем.

Неправильное приведение типов:
float f = 12345.6789f;
/* UB: неправильное приведение типа */
int* p = reinterpret_cast<int*>(&f);
// Непредсказуемое поведение
std::cout << *p;

Приведение указателя на float к указателю на int и последующее использование такого указателя для доступа к данным приведет к неопределённому поведению, так как нарушаются правила выравнивания и интерпретации данных.

Переход за пределы целочисленного диапазона:

unsigned int u = UINT_MAX;
/* UB: переполнение беззнакового целого числа */
u++;

Увеличение максимального значения беззнакового целого числа вызывает UB.
Стандарт C++ гарантирует, что результатом будет 0, однако это правило распространяется только на арифметические операции над объектами с типом unsigned.


Почему важно избегать Undefined Behavior?
Когда программа сталкивается с UB, её поведение становится непредсказуемым. Это может приводить к различным проблемам:
Невозможность отладки — ошибки, вызванные UB, часто трудно обнаружить, особенно если программа продолжает выполняться после возникновения проблемы.
Проблемы безопасности — неопределённые состояния могут использоваться злоумышленниками для проведения атак, таких как атаки переполнения буфера.
Изменение поведения при оптимизации — компиляторы могут оптимизировать код, предполагая отсутствие UB, что может привести к тому, что программа перестанет работать правильно после изменений в среде сборки или компиляции.


Поэтому очень важно избегать ситуаций, которые могут вызывать UB, тщательно проверяя код и следуя стандартам ЯП.
#366_CMPL_Cpp_LIB_PkS_TLS

Как определить, что в программе есть memory leak?

Memory leak (утечка памяти) возникает, когда программа выделяет память, но не освобождает её должным образом, что может привести к исчерпанию доступной памяти и ухудшению производительности системы.


Инструменты анализа памяти.

Valgrind — инструмент для анализа памяти, который работает в Linux-системах, предоставляет подробный отчет о возможных утечках памяти, некорректных операциях с памятью и других проблемах:
valgrind --leak-check=full ./your_program


AddressSanitizer (ASan) — инструмент, встроенный в современные компиляторы GCC и Clang, помогает обнаруживать ошибки работы с памятью, включая утечки.
Чтобы включить ASan, необходимо перекомпилировать программу с соответствующими флагами:
g++ -fsanitize=address -g your_program.cpp

clang++ -fsanitize=address -g your_program.cpp


Visual Leak Detector (VLD) — библиотека для Windows, интегрируется с Microsoft Visual Studio и помогает отслеживать утечки памяти.
После установки VLD, достаточно запустить программу в режиме отладки, и инструмент покажет информацию об утечках.


Логирование выделения и освобождения памяти.
Можно добавить логирование всех операций выделения и освобождения памяти в программу, что поможет увидеть, какие участки кода выделяют память, и убедиться, что каждая выделенная область памяти освобождается соответствующим образом.
Например, можно обернуть вызовы malloc, free, new и delete в макросы или функции, которые записывают информацию в файл или консоль.

Пример для C:
#include <stdio.h>
#include <stdlib.h>

void* my_malloc(size_t size, const char* file, int line) {
void* ptr = malloc(size);
if (ptr == NULL) {
fprintf(stderr, "Failed to allocate %zu bytes at %s:%d\n", size, file, line);
exit(EXIT_FAILURE);
}
printf("Allocated %zu bytes at %p from %s:%d\n", size, ptr, file, line);
return ptr;
}

void my_free(void* ptr, const char* file, int line) {
if (ptr != NULL) {
free(ptr);
printf("Freed memory at %p from %s:%d\n", ptr, file, line);
} else {
fprintf(stderr, "Attempting to free null pointer at %s:%d\n", file, line);
}
}

#define MALLOC(size) my_malloc(size, __FILE__, __LINE__)
#define FREE(ptr) my_free(ptr, __FILE__, __LINE__)

int main() {
int* array = (int*)MALLOC(10 * sizeof(int));
for (int i = 0; i < 10; ++i) {
array[i] = i;
}
FREE(array);
return 0;
}


Аналогичный подход можно применить и для C++, используя операторы new и delete.


Профайлеры и анализаторы.
Существуют коммерческие и бесплатные профайлеры для анализа производительности программ и выявления утечек памяти:
Intel VTune Profiler — инструмент для анализа производительности и выявления проблем с памятью.
Microsoft Visual Studio Performance and Diagnostics Tools — встроенные средства анализа памяти и производительности в Visual Studio.
GDB with Memory Debugging Library (mcheck) — GNU Debugger с библиотекой mcheck для проверки ошибок работы с памятью.


Проверка счетчика выделений и освобождений.
Простейший способ проверить наличие утечек памяти — подсчитать количество вызовов функций выделения и освобождения памяти (malloc/free, new/delete и т.д.).
Если число выделенных областей больше числа освобожденных, значит, произошла утечка памяти.


Анализ дампов памяти.
Некоторые ОС позволяют создавать дампы памяти процесса, которые затем можно проанализировать на предмет утечек.
Например, в Windows можно использовать утилиту procdump для создания дампа процесса, а затем исследовать его с помощью WinDbg или другой специализированной утилиты.


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



Регулярное тестирование и мониторинг помогут поддерживать здоровье вашего приложения и избежать критических сбоев, связанных с нехваткой памяти.
#367_Cpp_LIB_PkS_PPPO

Для чего нужен std::make_shared?
Чем он лучше создания std::shared_ptr через конструктор?


Функция std::make_shared используется для создания объектов и одновременно передачи управления ими std::shared_ptr.
Она имеет ряд преимуществ перед созданием std::shared_ptr напрямую через конструктор.

Основные преимущества std::make_shared:
Оптимизация памяти и производительности
— при использовании конструктора std::shared_ptr, сначала создается сам объект, а затем выделяется дополнительная память для хранения счётчика ссылок, что требует двух отдельных аллокаций памяти.
Функция std::make_shared создаёт объект и счётчик ссылок вместе, что уменьшает количество необходимых аллокаций до одной, что улучшает производительность и снижает фрагментацию памяти.
// Прямое создание shared_ptr
std::shared_ptr<Foo> sptr(new Foo());

// Создание через make_shared
auto sptr = std::make_shared<Foo>();


Исключение утечек памяти — если создать объект через new и передать его конструктору std::shared_ptr, существует риск утечки памяти в случае исключения, которое может возникнуть между этими шагами. std::make_shared исключает такую возможность, так как вся операция выполняется атомарно.
// Возможная утечка памяти
try {
std::shared_ptr<Foo> sptr(new Foo());
throw std::runtime_error("Ошибка");
} catch(...) {
/* Объект Foo был создан, но не передан shared_ptr, следовательно, произошла утечка памяти. */
}

// Безопасное создание через make_shared
try {
auto sptr = std::make_shared<Foo>();
throw std::runtime_error("Ошибка");
} catch(...) {
/* Память была корректно освобождена благодаря shared_ptr. */
}


Улучшение читаемости кода — код с использованием std::make_shared выглядит более лаконичным и понятным. Нет необходимости писать длинные конструкции с new, что упрощает поддержку и понимание кода.
// С make_shared
auto sptr = std::make_shared<Foo>(arg1, arg2, arg3);

// Без make_shared
std::shared_ptr<Foo> sptr(new Foo(arg1, arg2, arg3));


Совместимость с C++11 и выше — в стандарте C++14 появилась поддержка std::make_unique, которая аналогична std::make_shared, но предназначена для работы с std::unique_ptr. Однако std::make_shared доступен уже начиная с C++11, что делает его универсальным решением для современных проектов.


Недостаток std::make_shared.
Единственный недостаток std::make_shared заключается в том, что она не поддерживает кастомные деаллокаторы.
Если требуется задать специальный деаллокатор для управления памятью, придётся использовать конструктор std::shared_ptr напрямую.
// Кастомный деаллокатор
auto deleter = [](Foo* p) { /* Специальная логика освобождения */ };
std::shared_ptr<Foo> sptr(new Foo(), deleter);



std::make_shared — наиболее предпочтительным способом создания объектов, управляемых std::shared_ptr, благодаря своей эффективности, безопасности и удобству использования.
Исключение составляют лишь случаи, когда необходим кастомный деаллокатор.