Потокобезопасный интерфейс
#новичкам
Не для всех очевидная новость: не всегда можно превратить класс из небезопасного в потокобезопасный, просто по уши обложившись лок гардами. Да, вызов конкретного метода будет безопасен. Но это не значит, что классом безопасно пользоваться.
Возьмем максимально простую реализацию самой простой очереди:
Она конечно потокоНЕбезопасная. То есть ей можно адекватно пользоваться только в рамках одного потока.
Как может выглядеть код простого консьюмера этой очереди?
И вот мы захотели разделить обязанности производителя чисел и их потребителя между разными потокам. Значит, нам надо как-то защищать очередь от многопоточных неприятностей.
Бабахаем везде лок гард на один мьютекс и дело в шляпе!
Все доступы к очереди защищены. Но спасло ли реально это нас?
Вернемся к коду консюмера:
А вдруг у нас появится еще один консюмер? Тогда в первом из них мы можем войти условие, а в это время второй достанет последний элемент. Получается, что мы получим доступ к неинициализированной памяти в методе front.
То есть по факту в многопоточном приложении полученный стейт сущности сразу же утрачивает свою актуальность.
Что делать? Не только сами методы класса должны быть потокобезопасными. Но еще и комбинации использования этих методов тоже должны обладать таким свойством. И с данным интерфейсом это сделать просто невозможно.
Если стейт утрачивает актуальность, то мы вообще не должны давать возможность приложению получать стейт очереди. Нам нужны только команды управления. То есть push и pop.
Внутри метода
Теперь консюмер выглядит так:
Можно конечно было использовать кондвары и прочее. Но я хотел сфокусироваться именно на интерфейсе. Теперь реализация просто не позволяет получать пользователю потенциально неактульные данные.
Stay safe. Stay cool.
#concurrency #design #goodpractice
#новичкам
Не для всех очевидная новость: не всегда можно превратить класс из небезопасного в потокобезопасный, просто по уши обложившись лок гардами. Да, вызов конкретного метода будет безопасен. Но это не значит, что классом безопасно пользоваться.
Возьмем максимально простую реализацию самой простой очереди:
struct Queue {
void push(int value) {
storage.push_back(value);
}
void pop() {
storage.pop_front();
}
bool empty() {
return storage.empty();
}
int& front() {
return storage.front();
}
private:
std::deque<int> storage;
};Она конечно потокоНЕбезопасная. То есть ей можно адекватно пользоваться только в рамках одного потока.
Как может выглядеть код простого консьюмера этой очереди?
while(condition)
if (!queue.empty()) {
auto & elem = queue.front();
process_elem(elem);
queue.pop();
}
И вот мы захотели разделить обязанности производителя чисел и их потребителя между разными потокам. Значит, нам надо как-то защищать очередь от многопоточных неприятностей.
Бабахаем везде лок гард на один мьютекс и дело в шляпе!
struct Queue {
void push(int value) {
std::lock_guard lg{m};
storage.push_back(value);
}
void pop() {
std::lock_guard lg{m};
storage.pop_front();
}
bool empty() {
std::lock_guard lg{m};
return storage.empty();
}
int& front() {
std::lock_guard lg{m};
return storage.front();
}
private:
std::deque<int> storage;
std::mutex m;
};Все доступы к очереди защищены. Но спасло ли реально это нас?
Вернемся к коду консюмера:
while(true)
if (!queue.empty()) {
auto & elem = queue.front();
process_elem(elem);
queue.pop();
}
А вдруг у нас появится еще один консюмер? Тогда в первом из них мы можем войти условие, а в это время второй достанет последний элемент. Получается, что мы получим доступ к неинициализированной памяти в методе front.
То есть по факту в многопоточном приложении полученный стейт сущности сразу же утрачивает свою актуальность.
Что делать? Не только сами методы класса должны быть потокобезопасными. Но еще и комбинации использования этих методов тоже должны обладать таким свойством. И с данным интерфейсом это сделать просто невозможно.
Если стейт утрачивает актуальность, то мы вообще не должны давать возможность приложению получать стейт очереди. Нам нужны только команды управления. То есть push и pop.
struct ThreadSafeQueue {
void push(int value) {
std::lock_guard lg{m};
storage.push_back(value);
}
std::optional<int> pop() {
std::lock_guard lg{m};
if (!storage.empty()) {
int elem = storage.front();
storage.pop_front();
return elem;
}
return nullopt;
}
private:
std::deque<int> storage;
std::mutex m;
};Внутри метода
pop мы можем использовать проверять и получать стейт очереди, так как мы оградились локом. Возвращаем из него std::optional, который будет хранить фронтальный элемент, если очередь была непуста. В обратном случае он будет пуст.Теперь консюмер выглядит так:
while(true) {
auto elem = queue.pop();
if (elem)
process_elem(elem.value());
}Можно конечно было использовать кондвары и прочее. Но я хотел сфокусироваться именно на интерфейсе. Теперь реализация просто не позволяет получать пользователю потенциально неактульные данные.
Stay safe. Stay cool.
#concurrency #design #goodpractice
10👍42🔥11❤7😁2🤔1
Сколько вам было лет, когда вы узнали, что название бинарного файла, который по умолчанию генерирует GCC - a.out - это сокращение от assembler output(вывод ассемблера)? 🤯
Я вот узнал в толькочтогодиков.
Я вот узнал в толькочтогодиков.
😁130🤯47🔥17☃4👎4🤣2
Бросаем число
#новичкам
Мы привыкли, что исключения имеют какую-то свою иерархию и каждый класс имеет свое конкретное назначение в контексте отображения ошибки.
А что если мы попытаемся бросить что-то совсем несвязанное с иcключениями? Например, какой-нибудь тривиальный тип вроде int. Это вообще законно?
Абсолютно законно. В С++ можно бросать все, что угодно, кроме объектов неполных типов, абстрактных классов и указателей на неполный тип. Даже указатель на void можно.
Как и число.
Поймать число примерно также просто, как его бросить:
Это кстати один из любимых вопросов у интервьюеров.
"А можно ли кидать число вместо исключения?"
Теперь вы с полной уверенностью ответите "можно".
Но вот зачем это может быть нужно? Оставьте ваши мысли в комментариях
Make sense. Stay cool.
#interview #cppcore
#новичкам
Мы привыкли, что исключения имеют какую-то свою иерархию и каждый класс имеет свое конкретное назначение в контексте отображения ошибки.
А что если мы попытаемся бросить что-то совсем несвязанное с иcключениями? Например, какой-нибудь тривиальный тип вроде int. Это вообще законно?
Абсолютно законно. В С++ можно бросать все, что угодно, кроме объектов неполных типов, абстрактных классов и указателей на неполный тип. Даже указатель на void можно.
Как и число.
Поймать число примерно также просто, как его бросить:
void foo() {
throw 1;
}
int main() {
try {
foo();
}
catch(int i) {
std::cout << i << std::endl;
}
}
// OUTPUT: 1Это кстати один из любимых вопросов у интервьюеров.
"А можно ли кидать число вместо исключения?"
Теперь вы с полной уверенностью ответите "можно".
Но вот зачем это может быть нужно? Оставьте ваши мысли в комментариях
Make sense. Stay cool.
#interview #cppcore
❤24👍15😁7🔥3⚡1
Сколько минимально нужно мьютексов, чтобы вызвать дедлок?
#опытным
Мы уже рассматривали похожий вопрос и на собесах правильными ответом будет 2. 2 потока локают по одному мьютексу и пытаются захватить тот замок, который уже находится во владении другого потока. Они будут пытаться бесконечно исполнение в них перестанет двигаться вперед.
Но это скорее для всех ЯП некий универсальный ответ. У всех языков немного разные подходы к многопоточности и немного отличающиеся инструменты для работы с ней.
А что если мы не будем ориентироваться на общую универсальную для всех языков теорию многопоточного программирования и сфокусируется чисто на С++ и средствах, которые он предоставляет?
Какой тогда будет ответ?
#quiz
#опытным
Мы уже рассматривали похожий вопрос и на собесах правильными ответом будет 2. 2 потока локают по одному мьютексу и пытаются захватить тот замок, который уже находится во владении другого потока. Они будут пытаться бесконечно исполнение в них перестанет двигаться вперед.
Но это скорее для всех ЯП некий универсальный ответ. У всех языков немного разные подходы к многопоточности и немного отличающиеся инструменты для работы с ней.
А что если мы не будем ориентироваться на общую универсальную для всех языков теорию многопоточного программирования и сфокусируется чисто на С++ и средствах, которые он предоставляет?
Какой тогда будет ответ?
#quiz
👍7❤2🔥1🤔1
Сколько минимально нужно мьютексов, чтобы вызвать дедлок?
Anonymous Poll
3%
3
43%
2
35%
1
8%
0
11%
Мнимая единица!
🤔16👍4❤🔥2🔥2❤1🤬1
Ответ
#опытным
Правильный ответ на квиз из предыдущего поста- 0. Вообще ни одного мьютекса не нужно.
Много можно вариантов придумать. И даже в комментах написали несколько рабочих способов.
Вообще говоря, в определении дедлока не звучит слово "мьютекс". Потоки должны ждать освобождения ресурсов. А этими ресурсами может быть что угодно.
Для организации дедлока достаточно просто, чтобы 2 потока запустились в попытке присоединить друг друга. Естественно, что они будут бесконечно ждать окончания работы своего визави.
Однако не совсем очевидно, как это организовать. Вот мы определяем первый объект потока и его надо запустить с функцией, которая ждем еще не существующего потока.
Заметьте, что мы пытаемся сделать грязь. Так давайте же применим самые опасные вещи из плюсов и у нас все получится! Надо лишь добавить 50 грамм указателей и чайную ложку глобальных переменных. Получается вот такая каша:
Все просто. Вводим глобальный указатель на поток. В функции первого потока мы даем время инициализировать указатель и присоединяем поток по указателю. А тем временем в main создаем динамический объект потока и записываем его по указателю t_ptr. Таким образом первый поток получает доступ ко второму. В функцию второго потока передаем объект первого потока по ссылке и присоединяем его. Обе функции после инструкции join выводят на консоль запись.
Чтобы это все дело работало, нужно продлить существование основных потоков. В обратном случае, вызовутся деструкторы неприсоединенных потоков, а эта ситуация в свою очередь стриггерит вызов std::terminate. Поэтому делаем бесконечный цикл, чтобы иметь возможность посмотреть на этот самый дедлок.
И действительно. При запуске программы ничего не выводится. Более того, пока писался этот пост, программа работала и ничего так и не вывела. Учитывая, что потоки особо ничего не делают, то логично предположить, что ситуация и не поменяется.
Естественно, что потоков может быть больше и кольцо из ожидающих потоков может быть больше. Но это такой минимальный пример.
Если вы думаете, что это какая-то сова в вакууме, то подумайте еще раз. Владение потоками можно передавать в функции. Могут быть довольно сложные схемы организации взаимодействия потоков. И если вы присоединяете поток не в его родителе, то возникает благоприятные условия для возникновения такого безлокового дедлока.
Поэтому лучше избегать такого присоединения, или быть супервнимательным, если вы уж решились вступить на эту дорожку.
Stay surprised. Stay cool.
#concurrency #cppcore
#опытным
Правильный ответ на квиз из предыдущего поста- 0. Вообще ни одного мьютекса не нужно.
Много можно вариантов придумать. И даже в комментах написали несколько рабочих способов.
Вообще говоря, в определении дедлока не звучит слово "мьютекс". Потоки должны ждать освобождения ресурсов. А этими ресурсами может быть что угодно.
Для организации дедлока достаточно просто, чтобы 2 потока запустились в попытке присоединить друг друга. Естественно, что они будут бесконечно ждать окончания работы своего визави.
Однако не совсем очевидно, как это организовать. Вот мы определяем первый объект потока и его надо запустить с функцией, которая ждем еще не существующего потока.
Заметьте, что мы пытаемся сделать грязь. Так давайте же применим самые опасные вещи из плюсов и у нас все получится! Надо лишь добавить 50 грамм указателей и чайную ложку глобальных переменных. Получается вот такая каша:
std::thread * t_ptr = nullptr;
void func1() {
std::this_thread::sleep_for(std::chrono::seconds(1));
t_ptr->join();
std::cout << "Never reached this point1" << std::endl;
}
void func2(std::thread& t) {
t.join();
std::cout << "Never reached this point2" << std::endl;
}
int main() {
std::thread t1{func1};
t_ptr = new std::thread(func2, std::ref(t1));
while(true) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
Все просто. Вводим глобальный указатель на поток. В функции первого потока мы даем время инициализировать указатель и присоединяем поток по указателю. А тем временем в main создаем динамический объект потока и записываем его по указателю t_ptr. Таким образом первый поток получает доступ ко второму. В функцию второго потока передаем объект первого потока по ссылке и присоединяем его. Обе функции после инструкции join выводят на консоль запись.
Чтобы это все дело работало, нужно продлить существование основных потоков. В обратном случае, вызовутся деструкторы неприсоединенных потоков, а эта ситуация в свою очередь стриггерит вызов std::terminate. Поэтому делаем бесконечный цикл, чтобы иметь возможность посмотреть на этот самый дедлок.
И действительно. При запуске программы ничего не выводится. Более того, пока писался этот пост, программа работала и ничего так и не вывела. Учитывая, что потоки особо ничего не делают, то логично предположить, что ситуация и не поменяется.
Естественно, что потоков может быть больше и кольцо из ожидающих потоков может быть больше. Но это такой минимальный пример.
Если вы думаете, что это какая-то сова в вакууме, то подумайте еще раз. Владение потоками можно передавать в функции. Могут быть довольно сложные схемы организации взаимодействия потоков. И если вы присоединяете поток не в его родителе, то возникает благоприятные условия для возникновения такого безлокового дедлока.
Поэтому лучше избегать такого присоединения, или быть супервнимательным, если вы уж решились вступить на эту дорожку.
Stay surprised. Stay cool.
#concurrency #cppcore
👍28❤8🔥5🤯3😱3😁1
Дедлокаем один поток
#опытным
Мы привыкли, что для дедлоков нужно несколько потоков. Не удивительно. Давайте прочитаем определение дедлока по Коффману. Там речь про процессы, но если поменять слово "процесс" на "поток" ничего не изменится. Ну и перевод будет вольный.
Дедлок - это ситуация в коде, когда одновременно выполняются все следующие условия:
А ну, мальчики, играем поочереди. Только один поток может получить доступ к ресурсу в один момент времени.
У меня уже есть красный паровозик, но я хочу синий!. Поток в настоящее время хранит по крайней мере один ресурс и запрашивает дополнительные ресурсы, которые хранятся в других потоках.
Я тебя захватил, я тебя и отпущу. Ресурс может быть освобожден только добровольно потоком, удерживающим его.
Все: Я хочу твой паровозик! Каждый поток должен ждать ресурс, который удерживается другим потоков, который, в свою очередь, ожидает, когда первый поток освободит ресурс. В общем случае ждунов может быть больше двух. Важно круговое ожидание.
Судя по этому определению, минимальное количество потоков, чтобы накодить дедлок - 2.
Но это такая общая теория работы с многозадачностью в программах.
Определение оперирует общим термином ресурс. И не учитывает поведение конкретного ресурса и деталей его реализации. А они важны!
Возьмем пресловутый мьютекс. Что произойдет, если я попытаюсь его залочить дважды в одном потоке?
Стандарт говорит, что будет UB. То есть поведение программы неопределено, возможно она заставит Ким Чен Ира спеть гангам стайл.
Возможно, но обычно этого не происходит. Программа в большинстве случаев ведет себя по одному из нескольких сценариев.
1️⃣ Компилятор имплементировал умный мьютекс, который может задетектить double lock и, например, кинуть в этом случае исключение.
2️⃣ Мьютекс у нас обычный, подтуповатый и он делает ровно то, что ему говорят. А именно пытается залочить мьютекс. Конечно у него ничего не получится и он вечно будет ждать его освобождения. Результат такого сценария - дедлок одного потока одним мьютексом!
Результат не гарантирован стандартом, но мой код под гццшкой именно так себя и повел. Поэтому теперь у вас есть еще один факт, которым можно понтануться перед коллегами или на собесах.
Be self-sufficient. Stay cool.
#concurrency #cppcore #compiler
#опытным
Мы привыкли, что для дедлоков нужно несколько потоков. Не удивительно. Давайте прочитаем определение дедлока по Коффману. Там речь про процессы, но если поменять слово "процесс" на "поток" ничего не изменится. Ну и перевод будет вольный.
Дедлок - это ситуация в коде, когда одновременно выполняются все следующие условия:
А ну, мальчики, играем поочереди. Только один поток может получить доступ к ресурсу в один момент времени.
У меня уже есть красный паровозик, но я хочу синий!. Поток в настоящее время хранит по крайней мере один ресурс и запрашивает дополнительные ресурсы, которые хранятся в других потоках.
Я тебя захватил, я тебя и отпущу. Ресурс может быть освобожден только добровольно потоком, удерживающим его.
Все: Я хочу твой паровозик! Каждый поток должен ждать ресурс, который удерживается другим потоков, который, в свою очередь, ожидает, когда первый поток освободит ресурс. В общем случае ждунов может быть больше двух. Важно круговое ожидание.
Судя по этому определению, минимальное количество потоков, чтобы накодить дедлок - 2.
Но это такая общая теория работы с многозадачностью в программах.
Определение оперирует общим термином ресурс. И не учитывает поведение конкретного ресурса и деталей его реализации. А они важны!
Возьмем пресловутый мьютекс. Что произойдет, если я попытаюсь его залочить дважды в одном потоке?
std::mutex mtx;
mtx.lock();
mtx.lock();
Стандарт говорит, что будет UB. То есть поведение программы неопределено, возможно она заставит Ким Чен Ира спеть гангам стайл.
Возможно, но обычно этого не происходит. Программа в большинстве случаев ведет себя по одному из нескольких сценариев.
1️⃣ Компилятор имплементировал умный мьютекс, который может задетектить double lock и, например, кинуть в этом случае исключение.
2️⃣ Мьютекс у нас обычный, подтуповатый и он делает ровно то, что ему говорят. А именно пытается залочить мьютекс. Конечно у него ничего не получится и он вечно будет ждать его освобождения. Результат такого сценария - дедлок одного потока одним мьютексом!
Результат не гарантирован стандартом, но мой код под гццшкой именно так себя и повел. Поэтому теперь у вас есть еще один факт, которым можно понтануться перед коллегами или на собесах.
Be self-sufficient. Stay cool.
#concurrency #cppcore #compiler
👍17🔥8❤5😁5🤣4⚡3
No new line
Оказывается, чтобы получить неопределенное поведение даже необязательно писать какой-то плохой код. Достаточно просто не добавить перенос строки в конце подключаемого файла!
Небольшой пример:
А теперь вспоминаем, что препроцессор вставляет все содержимое хэдера на место инклюда И(!) не вставляет после него символ конца строки. То есть спокойно может получится следующее:
То есть включение baz.hpp может быть полностью заэкранировано.
Учитывая, сколько всего препроцессор может делать с кодом, комбинации вариантов развития событий могут быть абсолютно разными.
Стандарт нам говорит:
Так что ub без кода - вполне существующая вещь.
Или уже нет?
На самом деле приведенная цитата была из стандарта 2003 года.
С++11 пофиксил эту проблему и обязал препроцессоры вставлять new line в конце подключаемых файлов:
Так что теперь проблемы нет.
Решил написать об этом, просто потому что очень весело, что в плюсах можно было такими неочевидными способами отстрелить себе конечность.
Ну и хорошо, что стандарт все-таки не только новую функциональность вводит, а фиксит вот такие вот недоразумения.
Fix your flaws. Stay cool.
#compiler
Оказывается, чтобы получить неопределенное поведение даже необязательно писать какой-то плохой код. Достаточно просто не добавить перенос строки в конце подключаемого файла!
Небольшой пример:
Файлик foo.hpp:
// I love code
// I love C++<no newline>
Файлик bar.cpp:
#include "foo.hpp"
#include "baz.hpp"
А теперь вспоминаем, что препроцессор вставляет все содержимое хэдера на место инклюда И(!) не вставляет после него символ конца строки. То есть спокойно может получится следующее:
// I love code
// I love C++#include "baz.hpp"
То есть включение baz.hpp может быть полностью заэкранировано.
Учитывая, сколько всего препроцессор может делать с кодом, комбинации вариантов развития событий могут быть абсолютно разными.
Стандарт нам говорит:
... If a source file that is not empty does not end in a new-line character,
or ends in a new-line character immediately preceded by a backslash
character before any such splicing takes place, the behavior is undefined.
Так что ub без кода - вполне существующая вещь.
Или уже нет?
На самом деле приведенная цитата была из стандарта 2003 года.
С++11 пофиксил эту проблему и обязал препроцессоры вставлять new line в конце подключаемых файлов:
A source file that is not empty and that does not end in a new-line character,
or that ends in a new-line character immediately preceded by a backslash
character before any such splicing takes place, shall be processed
as if an additional new-line character were appended to the file.
Так что теперь проблемы нет.
Решил написать об этом, просто потому что очень весело, что в плюсах можно было такими неочевидными способами отстрелить себе конечность.
Ну и хорошо, что стандарт все-таки не только новую функциональность вводит, а фиксит вот такие вот недоразумения.
Fix your flaws. Stay cool.
#compiler
👍49❤10🔥9🤯3⚡2
Как вызвать методы класса напрямую через vtable?
#опытным
Всем нам как С++ разработчикам интересно, как все устроено внутри всего. Вызвано это профессиональной деформацией или врожденным позывом, но у нас у всех это есть. Поэтому сейчас заглянем немного под капот...
Полиморфизм - там штука, которая сделала ООП и ОО дизайн такими мощными и широкоиспользуемыми инструментами. За счет чего он реализован в С++?
Наиболее распространенное решение - vtable. Таблица виртуальных методов. Не буду сильно погружаться, но в сущности, это массив, который хранит указатели на методы класса. И сейчас мы попробуем вызвать метод класса напрямую через этот массив используя низкоуровневые преобразования.
Для начала нам нужна иерархия объектов. У родителя и ребенка по 2 метода, каждый выводит определенный инкремент целочисленного поля класса. Теперь создаем умный указатель родительского класса, который содержит объект ребенка.
Далее нужно найти указатель на vtable. Обычно он всегда лежит в первых 8 байтах объектов полиморфных классов. Так как у нас двухуровневая косвенность (указатель на объект, а в нем указатель на таблицу), мы объявляем двойной указатель на 8-мибайтный инт и он и будет нашим vtable_ptr. Почему uint64_t? Да на самом деле косвенность трехуровненая, потому что в ячейках vtable лежат указатели на методы, а указатель легко представить в виде 8-байтного числа.
Самое сложное позади, мы нашли все, что нужно. Осталось только кастануть инты, лежащие в ячейках vtable к указателям на функцию правильного типа. И вуаля. Готово. Возможный вывод:
Где это знание нужно?
Ну вообще в принципе неплохо разбираться в том, как работает полиморфизм в С++. У всего есть свои особенности и в какой-то необычной ситуации эти знания вам могут помочь пофиксить багу.
Знание внутреннего устройства инструмента позволить вам понимать его преимущества и недостатки. Возможно в проектах с высокими требованиями к производительности(слабое железо как в embedded или супер-ультра-гипер low latency как в hft), вам предстоит выбирать между динамическим полиморфизмом и статическим полиморфизмом aka шаблонами. Чтобы сделать выбор качественно и аргументировать его, нужно знание базы.
А как говорится, пока сам не потрогаешь ручками - не поймешь.
Только не нужно так писать в продовом коде! А то будет, как на картинке внизу.
Stay hardwared. Stay cool.
#опытным
Всем нам как С++ разработчикам интересно, как все устроено внутри всего. Вызвано это профессиональной деформацией или врожденным позывом, но у нас у всех это есть. Поэтому сейчас заглянем немного под капот...
Полиморфизм - там штука, которая сделала ООП и ОО дизайн такими мощными и широкоиспользуемыми инструментами. За счет чего он реализован в С++?
Наиболее распространенное решение - vtable. Таблица виртуальных методов. Не буду сильно погружаться, но в сущности, это массив, который хранит указатели на методы класса. И сейчас мы попробуем вызвать метод класса напрямую через этот массив используя низкоуровневые преобразования.
struct Base {
virtual void func1() {
std::cout << i+1<< std::endl;
}
virtual void func2() {
std::cout << i+2 << std::endl;
}
};
struct Derived: Base {
void func1() override {
std::cout << i+3 << std::endl;
}
void func2() override {
std::cout << i+4 << std::endl;
}
};
int main() {
auto basePtr = std::unique_ptr<Base>(new Derived);
uint64_t ** pVtable = (uint64_t **)basePtr.get();
printf("Vtable: %p\n", pVtable);
printf("First Entry of VTable: %p\n", (void*)pVtable[0][0]);
printf("Second Entry of VTable: %p\n", (void*)pVtable[0][1]);
using VoidFunc = void (*) (void*);
VoidFunc firstMethod = (VoidFunc) pVtable[0][0];
firstMethod(basePtr.get());
VoidFunc secondMethod = (VoidFunc) pVtable[0][1];
secondMethod(basePtr.get());
}Для начала нам нужна иерархия объектов. У родителя и ребенка по 2 метода, каждый выводит определенный инкремент целочисленного поля класса. Теперь создаем умный указатель родительского класса, который содержит объект ребенка.
Далее нужно найти указатель на vtable. Обычно он всегда лежит в первых 8 байтах объектов полиморфных классов. Так как у нас двухуровневая косвенность (указатель на объект, а в нем указатель на таблицу), мы объявляем двойной указатель на 8-мибайтный инт и он и будет нашим vtable_ptr. Почему uint64_t? Да на самом деле косвенность трехуровненая, потому что в ячейках vtable лежат указатели на методы, а указатель легко представить в виде 8-байтного числа.
Самое сложное позади, мы нашли все, что нужно. Осталось только кастануть инты, лежащие в ячейках vtable к указателям на функцию правильного типа. И вуаля. Готово. Возможный вывод:
Vtable: 0x5a7841237eb0
First Entry of VTable: 0x5a781fd28ae2
Second Entry of VTable: 0x5a781fd28b22
3
4
Где это знание нужно?
Ну вообще в принципе неплохо разбираться в том, как работает полиморфизм в С++. У всего есть свои особенности и в какой-то необычной ситуации эти знания вам могут помочь пофиксить багу.
Знание внутреннего устройства инструмента позволить вам понимать его преимущества и недостатки. Возможно в проектах с высокими требованиями к производительности(слабое железо как в embedded или супер-ультра-гипер low latency как в hft), вам предстоит выбирать между динамическим полиморфизмом и статическим полиморфизмом aka шаблонами. Чтобы сделать выбор качественно и аргументировать его, нужно знание базы.
А как говорится, пока сам не потрогаешь ручками - не поймешь.
Только не нужно так писать в продовом коде! А то будет, как на картинке внизу.
Stay hardwared. Stay cool.
🔥33👍15❤5😁2⚡1
Почему приватные методы находятся в описании класса?
Публичные, защищенные методы - понятно, они нужны либо для пользователей класса, либо для наследников.
Поля - понятно, они влияют на размер объекта, а клиенты должны уметь правильно аллоцировать нужное количество памяти под объект.
Ну а приватные-то методы зачем раскрывать? Зачем их помещать внутрь класса и делать видимыми для клиентского кода?
Ну во-первых. Представим, что этого ограничения нет. Тогда все приватные методы объявлялись и определялись бы в файле реализации, который никто не видит. Но если я могу в файле реализации определить приватный метод, то кто угодно может это сделать. Это будет давать рандомным людям прямой доступ к закрытым полям класса. Если мы завершили определение класса, то у нас нет способов как-то пометить именно наши файлы, как "благословленные владельцем". Есть всего лишь юниты трансляции и они равнозначны. Получается, что единственный способ сказать, что вот этот набор методов официально одобрен создателем - это объявить его в описании класса.
В С++ мы имеем прямой доступ к памяти, а значит, мы легко можем поменять байтики для приватных полей и все. Или даже создать тип, с таким же описанием, только с дополнительным методом. Кастануть недоступный тип к своему и вуаля, вы можете как хотите вертеть объектом во всех удобных вам позах. Но это уже хаки, мы такого не одобряем. Не используя манипуляции с памятью, мы не сможем добавлять рандомную функциональность в рандомный класс.
А во-вторых, оказывается в С++ приватные методы участвуют в разрешении перегрузок(внезапно). В целом, так оно и должно быть. Никто не мешает вам определить публичный метод и перегрузить его приватным методом. Проблема(или фича) в том, что этап разрешения перегрузок стоит перед проверкой модификатора доступа. То есть даже если метод приватный и его никто не должен увидеть, он все равно участвует в разрешении перегрузок наряду со всеми остальными методами. Поэтому каждый клиент должен видеть полный набор приватных методов. Об этом мы уже говорили в контексте pimpl идиомы.
В чем прикол того, что приватные методы участвуют в разрешении перегрузок?
Давайте снова представим, что такого правила нет. И вот у нас есть две перегрузки, одна приватная для double, другая публичная для int. И перегрузка с double всегда отбрасывалась бы только лишь по причине того, что она приватная. Тогда мы легко можем вызвать публичную функцию с дробным числом 1.5 и нам ничего не будет. Оно просто неявно приведется к int и все на этом.
А теперь посмотрим, что будет, если мы поменяем модификатор приватной перегрузки на публичный? Ничего не упадет, НО! наш вызов метода с аргументом 1.5 теперь пойдет в другую функцию! То есть изменится поведение объекта. Комитет хотел избежать таких ситуаций, поэтому ввели такое вот ограничение. Наверное, причина не одна. Но им этой одной было достаточно.
Однако, такой протокол поведения влечет за собой различные сайд-эффекты.
Я могу удалить приватную перегрузку публичного метода, например какую мы обсуждали выше. И вызвать публичный метод опять с дробным числом. Но компилятор на меня наругается, потому что я попытался вызвать удаленную перегрузку метода. Хотя она вообще-то объявлена, как приватная! А то, что я ее удалил - это детали реализации. Получается раскрытие деталей реализации.
Не знаю, как оценивать все перечисленные причины. Напишите свое мнение в комментариях.
Stay aware. Stay cool.
#design #howitworks #cppcore #hardcore
Публичные, защищенные методы - понятно, они нужны либо для пользователей класса, либо для наследников.
Поля - понятно, они влияют на размер объекта, а клиенты должны уметь правильно аллоцировать нужное количество памяти под объект.
Ну а приватные-то методы зачем раскрывать? Зачем их помещать внутрь класса и делать видимыми для клиентского кода?
Ну во-первых. Представим, что этого ограничения нет. Тогда все приватные методы объявлялись и определялись бы в файле реализации, который никто не видит. Но если я могу в файле реализации определить приватный метод, то кто угодно может это сделать. Это будет давать рандомным людям прямой доступ к закрытым полям класса. Если мы завершили определение класса, то у нас нет способов как-то пометить именно наши файлы, как "благословленные владельцем". Есть всего лишь юниты трансляции и они равнозначны. Получается, что единственный способ сказать, что вот этот набор методов официально одобрен создателем - это объявить его в описании класса.
В С++ мы имеем прямой доступ к памяти, а значит, мы легко можем поменять байтики для приватных полей и все. Или даже создать тип, с таким же описанием, только с дополнительным методом. Кастануть недоступный тип к своему и вуаля, вы можете как хотите вертеть объектом во всех удобных вам позах. Но это уже хаки, мы такого не одобряем. Не используя манипуляции с памятью, мы не сможем добавлять рандомную функциональность в рандомный класс.
А во-вторых, оказывается в С++ приватные методы участвуют в разрешении перегрузок(внезапно). В целом, так оно и должно быть. Никто не мешает вам определить публичный метод и перегрузить его приватным методом. Проблема(или фича) в том, что этап разрешения перегрузок стоит перед проверкой модификатора доступа. То есть даже если метод приватный и его никто не должен увидеть, он все равно участвует в разрешении перегрузок наряду со всеми остальными методами. Поэтому каждый клиент должен видеть полный набор приватных методов. Об этом мы уже говорили в контексте pimpl идиомы.
В чем прикол того, что приватные методы участвуют в разрешении перегрузок?
Давайте снова представим, что такого правила нет. И вот у нас есть две перегрузки, одна приватная для double, другая публичная для int. И перегрузка с double всегда отбрасывалась бы только лишь по причине того, что она приватная. Тогда мы легко можем вызвать публичную функцию с дробным числом 1.5 и нам ничего не будет. Оно просто неявно приведется к int и все на этом.
А теперь посмотрим, что будет, если мы поменяем модификатор приватной перегрузки на публичный? Ничего не упадет, НО! наш вызов метода с аргументом 1.5 теперь пойдет в другую функцию! То есть изменится поведение объекта. Комитет хотел избежать таких ситуаций, поэтому ввели такое вот ограничение. Наверное, причина не одна. Но им этой одной было достаточно.
Однако, такой протокол поведения влечет за собой различные сайд-эффекты.
struct DumbClass {
void DumbFunction(int param) {
// i do nothing because i’m dumb
}
private:
void DumbFunction(double param) = delete;
};
int main() {
DumbClass{}.DumbFunction(1.0);
}
// error: use of deleted function ‘void DumbClass::DumbFunction(double)’Я могу удалить приватную перегрузку публичного метода, например какую мы обсуждали выше. И вызвать публичный метод опять с дробным числом. Но компилятор на меня наругается, потому что я попытался вызвать удаленную перегрузку метода. Хотя она вообще-то объявлена, как приватная! А то, что я ее удалил - это детали реализации. Получается раскрытие деталей реализации.
Не знаю, как оценивать все перечисленные причины. Напишите свое мнение в комментариях.
Stay aware. Stay cool.
#design #howitworks #cppcore #hardcore
😁25❤9👍6🔥3👎1
Неинициализированные переменные
Все мы знаем, что это плохо, ужасно, это проделки Сатаны и заговор евреев. Но нет-нет да и используем их. Сегодня кратко приведу еще один довод в пользу того, чтобы выкинуть из своей картины мира неинициализированные переменные.
Кейс из ревью.
Вот у вас есть примерно такая функция и примерно так ее можно вызвать:
Вроде нормальная функция, в случае ошибки не возвращает объекта, в случае успешного условия заполняет как-то объект, в случае неуспеха оставляет его дефолтным. И заполняется output параметр, сигнализирующий об наступлении условия.
Здесь success заполняется всегда вне зависимости, наступит условие или нет. И вроде как очень безопасно туда передать неинициализированный bool. Какая же разница? success в любом случае инициализируется в функции.
А вот приходит другой программист и изменяет эту функцию примерно так:
По логике реального кода было все четко. Кроме того, что теперь в ветке, где не выполняются одновременно condition1, condition2 и condition3 success остается тем же, что и передали в функцию.
Так конечно не нужно делать. Это хоть чуть-чуть, но изменяет логику поведения функции. Но я хочу тут другой момент затронуть.
Теперь при передаче неинициализированной success результат функции может быть дефолтовым, а success == true, так как в него изначально был записан true.
В реальности привело к довольно долгому дебагу.
Но если бы изначально мы создавали бы инициализированную в ложь переменную
то этой боли можно было бы избежать. А изменение функциональности самой функции выявят тесты.
Вывод: не используйте неинициализированные переменные! И включите уже соответствующую опцию компилятора, которая ругается на них ошибкой.
Define your value. Stay cool.
#cppcore #goodpractice
Все мы знаем, что это плохо, ужасно, это проделки Сатаны и заговор евреев. Но нет-нет да и используем их. Сегодня кратко приведу еще один довод в пользу того, чтобы выкинуть из своей картины мира неинициализированные переменные.
Кейс из ревью.
Вот у вас есть примерно такая функция и примерно так ее можно вызвать:
std::optional<SomeType> WaitForSomething(bool& success) {
SomeType result;
if (!SomeOperation()) {
// error
return std::nullopt;
}
if (condition) {
//fill result
}
success = (condition == 1);
return result;
}
bool success;
auto obj = WaitForSomething(success);
if (!obj) {
// handle error
}
if (success) {
// do something
}Вроде нормальная функция, в случае ошибки не возвращает объекта, в случае успешного условия заполняет как-то объект, в случае неуспеха оставляет его дефолтным. И заполняется output параметр, сигнализирующий об наступлении условия.
Здесь success заполняется всегда вне зависимости, наступит условие или нет. И вроде как очень безопасно туда передать неинициализированный bool. Какая же разница? success в любом случае инициализируется в функции.
А вот приходит другой программист и изменяет эту функцию примерно так:
std::optional<SomeType> WaitForSomething(bool& success) {
SomeType result;
if (!SomeOperation()) {
// error
return std::nullopt;
}
while (condition1 && condition2) {
//fill result
if (condition3)
success = true;
}
return result;
}По логике реального кода было все четко. Кроме того, что теперь в ветке, где не выполняются одновременно condition1, condition2 и condition3 success остается тем же, что и передали в функцию.
Так конечно не нужно делать. Это хоть чуть-чуть, но изменяет логику поведения функции. Но я хочу тут другой момент затронуть.
Теперь при передаче неинициализированной success результат функции может быть дефолтовым, а success == true, так как в него изначально был записан true.
В реальности привело к довольно долгому дебагу.
Но если бы изначально мы создавали бы инициализированную в ложь переменную
bool success = false;
то этой боли можно было бы избежать. А изменение функциональности самой функции выявят тесты.
Вывод: не используйте неинициализированные переменные! И включите уже соответствующую опцию компилятора, которая ругается на них ошибкой.
Define your value. Stay cool.
#cppcore #goodpractice
👍23🔥19❤4⚡2👎2
Все грани new
#опытным
Не каждый знает, что в плюсах new - это как медаль, только лучше. У медали 2 стороны, а у new целых 3!
Сейчас со всем разберемся.
То, что наиболее часто используется, называется new expression. Это выражение делает 2 вещи последовательно: пытается в начале выделить подходящий объем памяти, а потом пытается сконструировать либо один объект, либо массив объектов в уже аллоцированной памяти. Возвращает либо указатель на объект, либо указатель на начало массива.
Выглядит оно так:
Эта штука делает 2 дела одновременно. А что, если мне не нужно выполнять сразу 2 этапа? Что, если мне нужна только аллокация памяти?
За это отвечает operator new. Это оператор делает примерно то же самое, что и malloc. То есть выделяет кусок памяти заданного размера. Выражение new именно этот оператор и вызывает, когда ему нужно выделить память. Но его можно вызвать и как обычную функцию, а также перегружать для конкретного класса:
Но что, если у меня уже есть выделенная память и я хочу на ней создать объект? Допустим, я не хочу использовать кучу и у меня есть массивчик на стеке, который я хочу переиспользовать для хранения разных объектов, потенциально разных типов.
Тогда мне нужен инструмент, который позволяет только вызывать конструктор на готовой памяти.
Для этого есть placement new. Это тот же самый new expression, только для этого есть свой синтаксис. Сразу после new в скобках вы передаете указатель на область памяти, достаточной для создания объекта.
В этом случае кстати вызывается специальная перегрузка operator new:
Однако она не делает ничего полезного и просто возвращает второй аргумент наружу.
Вот так по разному в С++ можно использовать new. Главное - не запутаться!
Have a lot of sides. Stay cool.
#cppcore #memory
#опытным
Не каждый знает, что в плюсах new - это как медаль, только лучше. У медали 2 стороны, а у new целых 3!
Сейчас со всем разберемся.
То, что наиболее часто используется, называется new expression. Это выражение делает 2 вещи последовательно: пытается в начале выделить подходящий объем памяти, а потом пытается сконструировать либо один объект, либо массив объектов в уже аллоцированной памяти. Возвращает либо указатель на объект, либо указатель на начало массива.
Выглядит оно так:
// creates dynamic object of type int with value equal to 42
int* p_triv = new int(42);
// created an array of 42 dynamic objects of type int with values equal to zero
int* p_triv_arr = new int[42];
struct String {std::string str};
// creates dynamic object of custom type String using aggregate initialization
String* p_obj = new String{"qwerty"};
// created an array of 5 dynamic objects of custom type String with default initialization
String* p_obj = new String[5];
Эта штука делает 2 дела одновременно. А что, если мне не нужно выполнять сразу 2 этапа? Что, если мне нужна только аллокация памяти?
За это отвечает operator new. Это оператор делает примерно то же самое, что и malloc. То есть выделяет кусок памяти заданного размера. Выражение new именно этот оператор и вызывает, когда ему нужно выделить память. Но его можно вызвать и как обычную функцию, а также перегружать для конкретного класса:
// class-specific allocation functions
struct X
{
static void* operator new(std::size_t count)
{
std::cout << "custom new for size " << count << '\n';
// explicit call to operator new
return ::operator new(count);
}
static void* operator new[](std::size_t count)
{
std::cout << "custom new[] for size " << count << '\n';
return ::operator new;
}
};
int main()
{
X* p1 = new X;
delete p1;
X* p2 = new X[10];
delete[] p2;
}
Но что, если у меня уже есть выделенная память и я хочу на ней создать объект? Допустим, я не хочу использовать кучу и у меня есть массивчик на стеке, который я хочу переиспользовать для хранения разных объектов, потенциально разных типов.
Тогда мне нужен инструмент, который позволяет только вызывать конструктор на готовой памяти.
Для этого есть placement new. Это тот же самый new expression, только для этого есть свой синтаксис. Сразу после new в скобках вы передаете указатель на область памяти, достаточной для создания объекта.
alignas(T) unsigned char buf[sizeof(T)];
// after new in parentheses we specified location of future object
T* tptr = new(buf) T;
// You must manually call the object's destructor
tptr->~T();
В этом случае кстати вызывается специальная перегрузка operator new:
void* operator new (std::size_t count, void* ptr);
Однако она не делает ничего полезного и просто возвращает второй аргумент наружу.
Вот так по разному в С++ можно использовать new. Главное - не запутаться!
Have a lot of sides. Stay cool.
#cppcore #memory
30👍35❤18🔥8🏆3⚡1
new vs malloc
Чем отличаются new и malloc? Один из популярных вопросов на собеседованиях, которые проверяет, насколько хорошо вы знакомы с тонкостями работы с памятью в С/С++. Поэтому давайте сегодня это обсудим.
Не совсем корректно, наверное сравнивать фичи двух разных языков с разными доминантными парадигмами программирования. Но раз в стандарте есть std::malloc, а new тоже выделяет память, то можно попробовать.
👉🏿 new expression помимо аллокации памяти вызывает конструктор объекта. std::malloc только выделяет память.
👉🏿 std::malloc - совсем не типобезопасный. Он возвращает void * без какого-либо признака типа. Придется явно кастовать результат к нужному типу. new в свою очередь возвращает типизированный указатель.
👉🏿 При ошибке выделения памяти new бросает исключение std::bad_alloc, в то время как std::malloc возвращает NULL. Соответственно нужны разные способы обработки ошибочных ситуаций.
👉🏿 Поведение new может быть переопределено внутри кастомных классов, поведение std::malloc - неизменно.
👉🏿 Если вам не нужно конструирование объекта, то просто вызывайте operator new. Он делает то же самое, что и std::malloc(потенциально вызывает его внутри себя).
👉🏿 Для new не нужно вручную высчитывать количество нужных байт. То есть мы не лезем на низкий уровень. Мы заботимся только типе данных, количестве объектов и об аргументах конструктора.
👉🏿 new плохо работает с реаллокациями. Нужно выделить новый сторадж, скопировать туда данные и вызвать delete. В то время, как malloc имеет функцию-партнера realloc, которая может изменить размер существующего куска памяти более эффективно, чем последовательность new-memcpy-delete.
Однако они имеют одну неочевидную схожесть. Нужно стараться по максимуму избегать их явного вызова. Давно придумали умные указатели и контейнеры, которые позволяют максимально освободить разработчика от обязанности ручного управления памятью.
Мы все же современные плюсовики. Поэтому в большинстве случаев, вам не нужны будут прямые вызовы этих функций. В более редких случаях(например кастомные аллокаторы) можно явно использовать new. Ну и в совсем редких случаях(нужда в реаллокации памяти или работа с сишным кодом) можно использовать malloc.
Control your memory. Stay cool.
#cppcore #interview #memory
Чем отличаются new и malloc? Один из популярных вопросов на собеседованиях, которые проверяет, насколько хорошо вы знакомы с тонкостями работы с памятью в С/С++. Поэтому давайте сегодня это обсудим.
Не совсем корректно, наверное сравнивать фичи двух разных языков с разными доминантными парадигмами программирования. Но раз в стандарте есть std::malloc, а new тоже выделяет память, то можно попробовать.
👉🏿 new expression помимо аллокации памяти вызывает конструктор объекта. std::malloc только выделяет память.
👉🏿 std::malloc - совсем не типобезопасный. Он возвращает void * без какого-либо признака типа. Придется явно кастовать результат к нужному типу. new в свою очередь возвращает типизированный указатель.
👉🏿 При ошибке выделения памяти new бросает исключение std::bad_alloc, в то время как std::malloc возвращает NULL. Соответственно нужны разные способы обработки ошибочных ситуаций.
👉🏿 Поведение new может быть переопределено внутри кастомных классов, поведение std::malloc - неизменно.
👉🏿 Если вам не нужно конструирование объекта, то просто вызывайте operator new. Он делает то же самое, что и std::malloc(потенциально вызывает его внутри себя).
👉🏿 Для new не нужно вручную высчитывать количество нужных байт. То есть мы не лезем на низкий уровень. Мы заботимся только типе данных, количестве объектов и об аргументах конструктора.
👉🏿 new плохо работает с реаллокациями. Нужно выделить новый сторадж, скопировать туда данные и вызвать delete. В то время, как malloc имеет функцию-партнера realloc, которая может изменить размер существующего куска памяти более эффективно, чем последовательность new-memcpy-delete.
Однако они имеют одну неочевидную схожесть. Нужно стараться по максимуму избегать их явного вызова. Давно придумали умные указатели и контейнеры, которые позволяют максимально освободить разработчика от обязанности ручного управления памятью.
Мы все же современные плюсовики. Поэтому в большинстве случаев, вам не нужны будут прямые вызовы этих функций. В более редких случаях(например кастомные аллокаторы) можно явно использовать new. Ну и в совсем редких случаях(нужда в реаллокации памяти или работа с сишным кодом) можно использовать malloc.
Control your memory. Stay cool.
#cppcore #interview #memory
🔥41👍17❤9⚡2👎1
Безопасный для исключений new
#опытным
Большинство приложений не могут физически жить без исключений. Даже если вы проектируете все свои классы так, чтобы они не возбуждали исключений, то от одного вида exception'ов вы вряд ли уйдете. Дело в том, что оператор new может бросить std::bad_alloc - исключение, которое говорит о том, что система не может выделить нам столько ресурсов, сколько было запрошено.
Однако мы можем заставить new быть небросающим! Надо лишь в скобках передать ему политику std::nothow. Синтаксис очень похож на placement new. Это в принципе он и есть, просто у new есть перегрузка, которая принимает политику вместо указателя.
В первом случае при недостатке памяти выброситься исключение. А во втором случае - вернется нулевой указатель. Прям как в std::malloc.
Так что, если хотите избавиться от исключений - вот вам еще один инструмент.
#cppcore #memory
#опытным
Большинство приложений не могут физически жить без исключений. Даже если вы проектируете все свои классы так, чтобы они не возбуждали исключений, то от одного вида exception'ов вы вряд ли уйдете. Дело в том, что оператор new может бросить std::bad_alloc - исключение, которое говорит о том, что система не может выделить нам столько ресурсов, сколько было запрошено.
Однако мы можем заставить new быть небросающим! Надо лишь в скобках передать ему политику std::nothow. Синтаксис очень похож на placement new. Это в принципе он и есть, просто у new есть перегрузка, которая принимает политику вместо указателя.
MyClass * p1 = new MyClass; // привычное использование
MyClass * p2 = new (std::nothrow) MyClass; // небросающая версия
В первом случае при недостатке памяти выброситься исключение. А во втором случае - вернется нулевой указатель. Прям как в std::malloc.
Так что, если хотите избавиться от исключений - вот вам еще один инструмент.
#cppcore #memory
❤🔥40👍26🔥9❤8⚡1
Еще один способ сделать new небросающим
#опытным
Дело в том, что new - не совсем ответственнен за поведение при недостатке памяти. По плюсовой традиции тут можно кастомизировать почти все. Встречайте: std::new_handler.
Это такой typedef'чик:
И алиас для функций, которые отвечают за обработку ситуации нехватки памяти. И вызываются они аллоцирующими функциями operator new и operator new[].
Чтобы установить такой хэдлер используется функция std::set_new_handler:
Она делает new_p новой глобальной функцией нового обработчика и возвращает ранее установленный обработчик.
Предполагаемое назначение хэндлера - одна из трех вещей:
1️⃣ Сделать больше памяти доступной. (За гранью фантастики)
2️⃣ Залогировать проблему и завершить программу (например, вызовом std::terminate) +. Если вам плевать на graceful shutdown, то ок.
3️⃣ Кинуть исключение типа std::bad_alloc или его отпрысков с какой-нибудь кастомной надписью и/или залогировать проблему.
Раз std::new_handler - алиас на указатель функции, то что будет, если мы передадим nullptr в качестве хэндлера?
На самом деле это и делается по умолчанию. При старте программы nullptr выставляется в качестве хэндлера. При невозможности выделить память operator new вызывает std::get_new_handler. Если указатель на функцию нулевой, то в этом случае new ведет себя дефолтно - просто кидает исключение std::bad_alloc. В ином случае вызывает хэндлер.
С обработчиками есть один нюанс.
Если обработчик успешно заканчивает работу(то есть из него не вызван terminate или не брошено исключение), то operator new повторяет ранее неудачную попытку выделения и снова вызывает обработчик, если выделение снова не удается. Чтобы закончить цикл, new-handler может вызвать std::set_new_handler(nullptr): если после неудачной попытки new обнаружит, что std::get_new_handler возвращает нулевое значение указателя и выбросит std::bad_alloc.
Примерно так это работает:
Пользуйтесь фичей, чтобы оставлять на проде девопсерам сообщение: "А че так мало памяти в конфиге пода прописано, мм?"
Спасибо @Nikseas_314 за идею для поста)
Customize your tools. Stay cool.
#memory #cppcore
#опытным
Дело в том, что new - не совсем ответственнен за поведение при недостатке памяти. По плюсовой традиции тут можно кастомизировать почти все. Встречайте: std::new_handler.
Это такой typedef'чик:
typedef void (*new_handler)();
И алиас для функций, которые отвечают за обработку ситуации нехватки памяти. И вызываются они аллоцирующими функциями operator new и operator new[].
Чтобы установить такой хэдлер используется функция std::set_new_handler:
std::new_handler set_new_handler(std::new_handler new_p) noexcept;
Она делает new_p новой глобальной функцией нового обработчика и возвращает ранее установленный обработчик.
Предполагаемое назначение хэндлера - одна из трех вещей:
1️⃣ Сделать больше памяти доступной. (За гранью фантастики)
2️⃣ Залогировать проблему и завершить программу (например, вызовом std::terminate) +. Если вам плевать на graceful shutdown, то ок.
3️⃣ Кинуть исключение типа std::bad_alloc или его отпрысков с какой-нибудь кастомной надписью и/или залогировать проблему.
Раз std::new_handler - алиас на указатель функции, то что будет, если мы передадим nullptr в качестве хэндлера?
На самом деле это и делается по умолчанию. При старте программы nullptr выставляется в качестве хэндлера. При невозможности выделить память operator new вызывает std::get_new_handler. Если указатель на функцию нулевой, то в этом случае new ведет себя дефолтно - просто кидает исключение std::bad_alloc. В ином случае вызывает хэндлер.
С обработчиками есть один нюанс.
Если обработчик успешно заканчивает работу(то есть из него не вызван terminate или не брошено исключение), то operator new повторяет ранее неудачную попытку выделения и снова вызывает обработчик, если выделение снова не удается. Чтобы закончить цикл, new-handler может вызвать std::set_new_handler(nullptr): если после неудачной попытки new обнаружит, что std::get_new_handler возвращает нулевое значение указателя и выбросит std::bad_alloc.
Примерно так это работает:
void handler()
{
std::cout << "Memory allocation failed, terminating\n";
std::set_new_handler(nullptr);
}
int main()
{
std::set_new_handler(handler);
try
{
while (true)
{
new int [1000'000'000ul] ();
}
}
catch (const std::bad_alloc& e)
{
std::cout << e.what() << '\n';
}
}
Пользуйтесь фичей, чтобы оставлять на проде девопсерам сообщение: "А че так мало памяти в конфиге пода прописано, мм?"
Спасибо @Nikseas_314 за идею для поста)
Customize your tools. Stay cool.
#memory #cppcore
👍37😁16❤🔥6❤4🔥2
Почему мы везде не используем nothrow new?
#опытным
В прошлый раз мы обсудили, что существует форма оператора new, которая не возбуждает исключений, а вместо этого при ошибке возвращает нулевой указатель. Однако я почему-то уверен, что большинство из вас впервые увидели эту форму. Почему же ее практически нигде не используют?
1️⃣ В современных плюсах вообще не часто можно увидеть прямой вызов new. Контейнеры и функции helper'ы std::make_* инкапсулируют в себе аллокации. Внутри них вызывается обычный бросающий new. Только в очень специфических кейсах явный вызов new оправдан. Поэтому пул примеров в принципе очень небольшой.
2️⃣ Представьте, что у вас закончилась память и nothrow new вернул вас nullptr. Можете ли вы локально обработать ошибку недостатка памяти? 99.9%, что нет. Поэтому вы будете вести эту ошибку по всему стеку вызовов до того места, где ее возможно обработать. То есть весь проект должен быть построен с учетом возможности возврата ошибки и постоянной проверкой этих ошибок.
3️⃣ И все же есть те люди, которых устраивает такая форма проекта с возвратом ошибки из функции и постоянной ее проверкой. Но если немного подумать, то выяснится, что очень часто ошибку недостатка памяти вы примерно никак не сможете обработать, кроме как напишите об этом в лог и завершите приложение. То есть, шо словите вы std::bad_alloc, шо просто напишите об этом в лог - разница не большая.
Раз разница небольшая, мы можем обработать ошибку только на определенном слое приложения, а исключения предоставляют возможность централизованной их обработки, то давайте только там и поставим эту обработку. В одном единственном месте. Скорее всего это будет где-то в функции main или в главной функции потока.
Сейчас может начаться холивар "исключения vs объекты ошибок". Но вряд ли можно отрицать, что работать с bad_alloc исключением банально проще, чем обрабатывать nullptr. Именно поэтому вы скорее всего вообще не увидите nothrow new.
Handle problems easily. Stay cool.
#cppcore
#опытным
В прошлый раз мы обсудили, что существует форма оператора new, которая не возбуждает исключений, а вместо этого при ошибке возвращает нулевой указатель. Однако я почему-то уверен, что большинство из вас впервые увидели эту форму. Почему же ее практически нигде не используют?
1️⃣ В современных плюсах вообще не часто можно увидеть прямой вызов new. Контейнеры и функции helper'ы std::make_* инкапсулируют в себе аллокации. Внутри них вызывается обычный бросающий new. Только в очень специфических кейсах явный вызов new оправдан. Поэтому пул примеров в принципе очень небольшой.
2️⃣ Представьте, что у вас закончилась память и nothrow new вернул вас nullptr. Можете ли вы локально обработать ошибку недостатка памяти? 99.9%, что нет. Поэтому вы будете вести эту ошибку по всему стеку вызовов до того места, где ее возможно обработать. То есть весь проект должен быть построен с учетом возможности возврата ошибки и постоянной проверкой этих ошибок.
3️⃣ И все же есть те люди, которых устраивает такая форма проекта с возвратом ошибки из функции и постоянной ее проверкой. Но если немного подумать, то выяснится, что очень часто ошибку недостатка памяти вы примерно никак не сможете обработать, кроме как напишите об этом в лог и завершите приложение. То есть, шо словите вы std::bad_alloc, шо просто напишите об этом в лог - разница не большая.
Раз разница небольшая, мы можем обработать ошибку только на определенном слое приложения, а исключения предоставляют возможность централизованной их обработки, то давайте только там и поставим эту обработку. В одном единственном месте. Скорее всего это будет где-то в функции main или в главной функции потока.
Сейчас может начаться холивар "исключения vs объекты ошибок". Но вряд ли можно отрицать, что работать с bad_alloc исключением банально проще, чем обрабатывать nullptr. Именно поэтому вы скорее всего вообще не увидите nothrow new.
Handle problems easily. Stay cool.
#cppcore
❤🔥17👍14❤4😁4🔥3
Ограничения в конструировании POD типов
#опытным
Довольно часто приходится работать с plain old data типами. Они не имеют никаких специальных методов и конструкторов, это просто структуры с полями. Например:
Создают объекты таких типов с помощью синтаксиса универсальной инициализации через фигурные скобки {}:
При этом через круглые скобки создать объект такого класса нельзя.
С повсеместным использованием универсальной инициализации с точки зрения код стайла создание таких объектов даже не выбивается из общего кода.
Но все-таки у этого есть проблемы.
Часто такие структурки хочется хранить в каком-нибудь векторе. Для добавления в вектор можно использовать push_back и emplace_back. Использование emplace_back выгоднее по перформансу, поэтому нужно обычно использовать именно этот метод.
Но с POD типами он работает хреново.
Вот так вы написать не можете. emplace_back под капотом использует создание объекта с помощью new именно через круглые скобки. А как мы помним, так инициализировать POD типы нельзя.
Поэтому приходится в этих случаях явно конструировать объект и вызывать мув-конструктор:
что убивает преимущества emplace_back перед push_back. Либо можно использовать непросредственно push_back.
Та же проблемы и в использовании функций std::make_*. Под капотом они тоже используют new с круглыми скобками и просто невозможно нормально использовать эти функции. Приходится явно вызывать new с фигурными скобками, что усугубляет проблему:
Конечно кейс с emplace_back намного чаще встречается в практике. POD типам не нужны фабличные методы и, обычно, контроль времени жизни.
В общем, не жизнь, а страдания.
Однако есть свет в конце тоннеля! Но об этом в следующий раз.
See the light. Stay cool.
#cppcore
#опытным
Довольно часто приходится работать с plain old data типами. Они не имеют никаких специальных методов и конструкторов, это просто структуры с полями. Например:
struct Point {
int x;
int y;
int z;
}Создают объекты таких типов с помощью синтаксиса универсальной инициализации через фигурные скобки {}:
Point p{1, 2, 3};
Point p(1, 2, 3); // WrongПри этом через круглые скобки создать объект такого класса нельзя.
С повсеместным использованием универсальной инициализации с точки зрения код стайла создание таких объектов даже не выбивается из общего кода.
Но все-таки у этого есть проблемы.
Часто такие структурки хочется хранить в каком-нибудь векторе. Для добавления в вектор можно использовать push_back и emplace_back. Использование emplace_back выгоднее по перформансу, поэтому нужно обычно использовать именно этот метод.
Но с POD типами он работает хреново.
std::vector<Point> vec;
vec.emplace_back(1, 2, 3); // Error!
Вот так вы написать не можете. emplace_back под капотом использует создание объекта с помощью new именно через круглые скобки. А как мы помним, так инициализировать POD типы нельзя.
Поэтому приходится в этих случаях явно конструировать объект и вызывать мув-конструктор:
vec.emplace_back(Point{1, 2, 3});что убивает преимущества emplace_back перед push_back. Либо можно использовать непросредственно push_back.
Та же проблемы и в использовании функций std::make_*. Под капотом они тоже используют new с круглыми скобками и просто невозможно нормально использовать эти функции. Приходится явно вызывать new с фигурными скобками, что усугубляет проблему:
std::unique_ptr<Point>(new Point{1, 2, 3}); Конечно кейс с emplace_back намного чаще встречается в практике. POD типам не нужны фабличные методы и, обычно, контроль времени жизни.
В общем, не жизнь, а страдания.
Однако есть свет в конце тоннеля! Но об этом в следующий раз.
See the light. Stay cool.
#cppcore
❤25👍10⚡4🔥4😁3
Фиксим проблему с конструированием POD типов
#опытным
В прошлом посте говорили о том, что тяжело POD типам работать с функциями, которые внутри себя вызывают new. Клятые скобки!
Это очевидные недоработки стандарта. Которые взяли и пофиксили в С++20!
Теперь объекты POD типов можно создавать как через фигурные скобки, так и через круглые. Хотя небольшая разница есть. Но это уже супердетали:
Теперь можно использовать все преимущества метода emplace_back и писать такой код:
Вроде мелочь, а раньше это выбивало из колеи. Правило almost always use emplace_back здесь не работало.
Давайте добавлю еще чуть больше контекста(и своей боли) про emplace_back vs push_back.
Что было до с++20.
push_back спокойно переваривал следующий код:
Он принимает аргументом уже сконструированный объект нужного типа. И компилятору достаточно знаний о типе вектора и фирурных скобок, чтобы понять, как конструируется объект.
Как видите здесь нет явного указания типа. И это работало до 20-х плюсов и было причиной делать исключения для правила с emplace_back'ом. Сравните:
Больше букав! А если название структуры длинное? Вот вот.
Тут либо нужно было отходить от правила везде использования emplace_back, либо постоянно вписывать конструктор в аргументы вызова emplace_back, либо определять совершенно тривиальные и от этого не несущие полезной логики конструкторы.
Благо теперь наши пальцы сильнее защищены от стачивания, что не может не радовать!
Кстати, с С++20 и сам термин POD стал помечаться устаревшим, теперь его заменили более тонкие типы TrivialType, ScalarType, и StandardLayoutType. Ну и функции std::make_* тоже работают как надо с простыми структурами.
Enjoy small things. Stay cool.
#cppcore #cpp20
#опытным
В прошлом посте говорили о том, что тяжело POD типам работать с функциями, которые внутри себя вызывают new. Клятые скобки!
Это очевидные недоработки стандарта. Которые взяли и пофиксили в С++20!
Теперь объекты POD типов можно создавать как через фигурные скобки, так и через круглые. Хотя небольшая разница есть. Но это уже супердетали:
struct A {
int a;
int&& r;
};
int f();
int n = 10;
A a1{1, f()}; // OK, lifetime is extended
A a2(1, f()); // well-formed, but dangling reference
A a3{1.0, 1}; // error: narrowing conversion
A a4(1.0, 1); // well-formed, but dangling reference
A a5(1.0, std::move(n)); // OKТеперь можно использовать все преимущества метода emplace_back и писать такой код:
std::vector<Point> vec;
vec.emplace_back(1, 2, 3);
Вроде мелочь, а раньше это выбивало из колеи. Правило almost always use emplace_back здесь не работало.
Давайте добавлю еще чуть больше контекста(и своей боли) про emplace_back vs push_back.
Что было до с++20.
push_back спокойно переваривал следующий код:
std::vector<Point> vec;
vec.push_back({1, 2, 3});
Он принимает аргументом уже сконструированный объект нужного типа. И компилятору достаточно знаний о типе вектора и фирурных скобок, чтобы понять, как конструируется объект.
Как видите здесь нет явного указания типа. И это работало до 20-х плюсов и было причиной делать исключения для правила с emplace_back'ом. Сравните:
std::vector<Point> vec;
vec.push_back({1, 2, 3});
vec.emplace_back(Point{1, 2, 3});
Больше букав! А если название структуры длинное? Вот вот.
Тут либо нужно было отходить от правила везде использования emplace_back, либо постоянно вписывать конструктор в аргументы вызова emplace_back, либо определять совершенно тривиальные и от этого не несущие полезной логики конструкторы.
Благо теперь наши пальцы сильнее защищены от стачивания, что не может не радовать!
Кстати, с С++20 и сам термин POD стал помечаться устаревшим, теперь его заменили более тонкие типы TrivialType, ScalarType, и StandardLayoutType. Ну и функции std::make_* тоже работают как надо с простыми структурами.
Enjoy small things. Stay cool.
#cppcore #cpp20
❤27👍15🔥14❤🔥2