Бага обнаружена!
#опытным
Проблема в текущей реализации
заключается в том, что константность не правильно распространяется через итератор при работе с const-объектами Vector. Давайте по порядку
Когда мы имеем
В текущей реализации, когда вызывается
Шаблонный вывод типов в форме CTAD отбрасывает верхнеуровневую константность: для
В результате мы получаем итератор, который позволяет изменять элементы, даже когда Vector константный.
То есть, такой код становится вполне валиден:
Что не есть хорошо.
Выходом тут будет не использовать CTAD, а явно и гибко задавать тип шаблонного параметра на основе константности self и нескольких трейтов:
По пути еще чуть-чуть причесываем передачу универсальной ссылки через std::forward.
Fix the problem. Stay cool.
#template #cppcore
#опытным
Проблема в текущей реализации
template <typename T>
class Vector {
private:
T *m_data;
T *m_endSize;
T *m_endCapacity;
public:
// Use a different type "U to support const and non-const
template <typename U>
class Iterator {
private:
U *m_ptr;
public:
Iterator(U *ptr) : m_ptr{ptr} {}
U &operator*() const { return m_ptr; }
};
template <typename Self>
auto begin(this Self &&self) {
return Iterator(self.m_data);
}
};
заключается в том, что константность не правильно распространяется через итератор при работе с const-объектами Vector. Давайте по порядку
Когда мы имеем
const Vector<T>, поле m_data становится T* const (константный указатель на T), а не const T* (указатель на константный T). Так называемая синтаксическая или поверхностная константность.В текущей реализации, когда вызывается
begin() на const-объекте:Self выводится как const Vector<T>& self.m_data имеет тип T const Шаблонный вывод типов в форме CTAD отбрасывает верхнеуровневую константность: для
Iterator создает Iterator<T>, а не Iterator<const T> В результате мы получаем итератор, который позволяет изменять элементы, даже когда Vector константный.
То есть, такой код становится вполне валиден:
const Vector<int> vec{1, 2, 3};
auto it = vec.begin();
*it = 2;Что не есть хорошо.
Выходом тут будет не использовать CTAD, а явно и гибко задавать тип шаблонного параметра на основе константности self и нескольких трейтов:
template<typename Self>
auto begin(this Self&& self)
{
using value_type = std::conditional_t<
std::is_const_v<std::remove_reference_t<Self>>,
const T,
T>;
return Iterator<value_type>(std::forward<Self>(self).m_data);
}
По пути еще чуть-чуть причесываем передачу универсальной ссылки через std::forward.
Fix the problem. Stay cool.
#template #cppcore
1👍20❤12🔥5❤🔥3
Как использовать std::unordered_map с ключом в виде std::pair?
#опытным
При работе над задачами C++ часто необходимо использовать сложные ключи в контейнерах на основе хэша - std::unordered_map. Распространенным подходом является использование std::pair<int, int> в качестве типа ключа. Однако попытка объявить unordered_map следующим образом:
приводит к подобной ошибке компиляции:
Происходит это, потому что для std::pair не определена хэш-функция. Она нужна для превращения значение объекта-ключа в число, которое используется для индексации элемента в хэш-таблице.
STL предоставляет нам хэш-функции для тривиальных типов данных и, например, std::string.
Но для сложных шаблонных типов непонятно в общем случае, как реализовать хэш-функцию. Поэтому эту задачу и возложили на самих программистов. Нужно самим определять хэш-функцию для объекта так, как того требует конкретная задача.
Ну хорошо. Определять надо. Но как это сделать? В азбуке не написано, как написать хэш для пары...
Давайте по порядку. Самый тривиальный подход - просто ксорим два хэша типов пары(в предположении, что они уже есть):
Отлично, заработало! Или нет?
Это компилируется, но есть проблема с коллизиями. Если ключом будет std::pair<int, int>, то для двух разных ключей {1, 2} и {2, 1} будут одинаковые хэши. Не очень хорошо.
Сделаем ход конем:
Побитово сдвинем второй хэш на один бит влево. Так мы не сильно ухудшим распределение(всего один бит заменим на нолик), но уберем коллизии.
Но это конечно все на коленке сделаный велосипед и можно найти антипримеры. В бусте есть функция hash_combine, которая делает ровно то, что мы хотим:
Если хочется узнать, что там у этой штуки под капотом, что в сущности код выше будет эквивалентен следующему коду:
Магические числа во всей красе. Но это нормально, когда мы имеем дело с математикой: генераторы случайных чисел, шифрование, хэш-функции.
Кстати, естественно, что такой подход можно использовать и для кастомных структур, и для туплов. В общем, можно пользоваться. Хотите тяните буст, хотите сами пишите, там все равно не так сложно.
Use ready-made solutions. Stay cool.
#cppcore #STL #template
#опытным
При работе над задачами C++ часто необходимо использовать сложные ключи в контейнерах на основе хэша - std::unordered_map. Распространенным подходом является использование std::pair<int, int> в качестве типа ключа. Однако попытка объявить unordered_map следующим образом:
std::unordered_map<std::pair<int, int>, int> map;
приводит к подобной ошибке компиляции:
error: call to implicitly-deleted default constructor of
'unordered_map<std::pair<int, int>, int>'
Происходит это, потому что для std::pair не определена хэш-функция. Она нужна для превращения значение объекта-ключа в число, которое используется для индексации элемента в хэш-таблице.
STL предоставляет нам хэш-функции для тривиальных типов данных и, например, std::string.
Но для сложных шаблонных типов непонятно в общем случае, как реализовать хэш-функцию. Поэтому эту задачу и возложили на самих программистов. Нужно самим определять хэш-функцию для объекта так, как того требует конкретная задача.
Ну хорошо. Определять надо. Но как это сделать? В азбуке не написано, как написать хэш для пары...
Давайте по порядку. Самый тривиальный подход - просто ксорим два хэша типов пары(в предположении, что они уже есть):
namespace std {
template <typename T1, typename T2>
struct hash<pair<T1, T2>> {
size_t operator()(const pair<T1, T2>& p) const {
size_t h1 = hash<T1>{}(p.first);
size_t h2 = hash<T2>{}(p.second);
return h1 ^ h2;
}
};
}
std::unordered_map<std::pair<int, std::string>, double> map;
map[{42, "foo"}] = 3.14;Отлично, заработало! Или нет?
Это компилируется, но есть проблема с коллизиями. Если ключом будет std::pair<int, int>, то для двух разных ключей {1, 2} и {2, 1} будут одинаковые хэши. Не очень хорошо.
Сделаем ход конем:
namespace std {
template <typename T1, typename T2>
struct hash<pair<T1, T2>> {
size_t operator()(const pair<T1, T2>& p) const {
size_t h1 = hash<T1>{}(p.first);
size_t h2 = hash<T2>{}(p.second);
return h1 ^ (h2 << 1);
}
};
}
std::unordered_map<std::pair<int, int, double> map;Побитово сдвинем второй хэш на один бит влево. Так мы не сильно ухудшим распределение(всего один бит заменим на нолик), но уберем коллизии.
Но это конечно все на коленке сделаный велосипед и можно найти антипримеры. В бусте есть функция hash_combine, которая делает ровно то, что мы хотим:
namespace std {
template <typename T1, typename T2>
struct hash<std::pair<T1, T2>> {
size_t operator()(const std::pair<T1, T2>& p) const {
size_t seed = 0;
boost::hash_combine(seed, p.first);
boost::hash_combine(seed, p.second);
return seed;
}
};
}Если хочется узнать, что там у этой штуки под капотом, что в сущности код выше будет эквивалентен следующему коду:
namespace std {
template <typename T1, typename T2>
struct hash<pair<T1, T2>> {
size_t operator()(const pair<T1, T2>& p) const {
size_t h1 = hash<T1>{}(p.first);
size_t h2 = hash<T2>{}(p.second);
return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
}
};
}Магические числа во всей красе. Но это нормально, когда мы имеем дело с математикой: генераторы случайных чисел, шифрование, хэш-функции.
Кстати, естественно, что такой подход можно использовать и для кастомных структур, и для туплов. В общем, можно пользоваться. Хотите тяните буст, хотите сами пишите, там все равно не так сложно.
Use ready-made solutions. Stay cool.
#cppcore #STL #template
❤37🔥15👍8😁7
Методы, определенные внутри класса
#новичкам
Вы хотите написать header-only библиотеку логирования и собственно пишите:
Ваши пользователи вызывают из одной единицы трансляции метод Log:
И из второй:
А потом это все успешно линкуется в один бинарник. Как так? Должно же было сработать One Definition Rule, которое запрещает иметь более одного определения функции на всю программу? А у нас как раз все единицы трансляции видят определение метода Log.
Дело в том, что все методы, определенные внутри тела класса, неявно помечены как inline. Это не значит, что компилятор встроит код этих методов в вызывающий код. Это значит, что для таких методов разрешается иметь сколько угодно одинаковых определений внутри программы. На этапе линковки выберется одна любая реализация и везде, где будет нужен адрес метода для вызова будет подставляться адрес именно этой реализации.
Так что явно использовать ключевое слово inline в этом случае бессмысленно.
Но и в обычном, не херед-онли коде, можно определять методы внутри класса. Когда это стоит делать?
Каждая единица трансляции должна сгенерировать свой код для inline метода. Это значит, что обильное использование inline методов может привести к увеличенному времени компиляции.
Однако наличие определения метода внутри класса может быть использовано компилятором для встраивания его кода в caller. Это снижает издержки на вызов метода.
Противоречивые последствия. Либо быстрый рантайм и медленный компайл-тайм, либо наоборот. Как быть?
Обычно inline делают простые и короткие методы, типа сеттеров и геттеров, а длинные методы, которые менее вероятно будут встраиваться, выносят в цпп. Короткие функции сильнее всего страдают от оверхеда на вызов, который может быть сравним с временем выполнения самой функции. Но они не засоряют собой интерфейс класса, хэдэр также легко и быстро читается. Вот такой компромисс.
Look for a compromise. Stay cool.
#cppcore #goodpractice
#новичкам
Вы хотите написать header-only библиотеку логирования и собственно пишите:
// logger.hpp
namespace SimpleLogger {
enum class Level { Debug, Info, Warning, Error };
class Logger {
public:
Logger(const Logger &) = delete;
Logger &operator=(const Logger &) = delete;
static Logger &GetInstance() {
static Logger instance;
return instance;
}
void SetMinLevel(Level level) {
m_minLevel = level;
}
void Log(Level level, const std::string &message) {
if (level < m_minLevel)
return;
auto time = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
std::lock_guard lock{m_mutex};
std::cout << "[" << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S") << "] " << "["
<< levelToString(level) << "] " << message << std::endl;
}
private:
Logger() : m_minLevel(Level::Info) { Log(Level::Info, "Logger initialized"); }
Level m_minLevel;
std::mutex m_mutex;
std::string levelToString(Level level) {
switch (level) {
case Level::Debug: return "DEBUG";
case Level::Info: return "INFO";
case Level::Warning: return "WARN";
case Level::Error: return "ERROR";
default: return "UNKNOWN";
}
}
};
} // namespace SimpleLogger
Ваши пользователи вызывают из одной единицы трансляции метод Log:
...
#include <logger.hpp>
...
using namespace SimpleLogger;
Logger::GetInstance().Log(Level::Info, "Select recent items");
db->Execute("Select bla bla");
...
И из второй:
...
#include <logger.hpp>
...
using namespace SimpleLogger;
if (!result) {
Logger::GetInstance().Log(Level::ERROR, "Result is empty");
throw std::runtime_error("Result is empty");
}
...
А потом это все успешно линкуется в один бинарник. Как так? Должно же было сработать One Definition Rule, которое запрещает иметь более одного определения функции на всю программу? А у нас как раз все единицы трансляции видят определение метода Log.
Дело в том, что все методы, определенные внутри тела класса, неявно помечены как inline. Это не значит, что компилятор встроит код этих методов в вызывающий код. Это значит, что для таких методов разрешается иметь сколько угодно одинаковых определений внутри программы. На этапе линковки выберется одна любая реализация и везде, где будет нужен адрес метода для вызова будет подставляться адрес именно этой реализации.
Так что явно использовать ключевое слово inline в этом случае бессмысленно.
Но и в обычном, не херед-онли коде, можно определять методы внутри класса. Когда это стоит делать?
Каждая единица трансляции должна сгенерировать свой код для inline метода. Это значит, что обильное использование inline методов может привести к увеличенному времени компиляции.
Однако наличие определения метода внутри класса может быть использовано компилятором для встраивания его кода в caller. Это снижает издержки на вызов метода.
Противоречивые последствия. Либо быстрый рантайм и медленный компайл-тайм, либо наоборот. Как быть?
Обычно inline делают простые и короткие методы, типа сеттеров и геттеров, а длинные методы, которые менее вероятно будут встраиваться, выносят в цпп. Короткие функции сильнее всего страдают от оверхеда на вызов, который может быть сравним с временем выполнения самой функции. Но они не засоряют собой интерфейс класса, хэдэр также легко и быстро читается. Вот такой компромисс.
Look for a compromise. Stay cool.
#cppcore #goodpractice
👍25🔥12❤9
Inline виртуальные методы
#опытным
В догонку предыдущего поста. Если можно сделать обычные методы inline, то можно и виртуальные сделать. Что это изменит?
Ну то есть такая иерархия:
Получим ли мы какой-то профит от того, что виртуальные методы теперь inline?
Посмотрим на такой код:
Конечно же метод будет встраиваться в вызывающий код. Компилятор на этапе компиляции четко знает тип, с которым он работает, и может проводить оптимизации.
Но вообще говоря, это далеко не основной кейс использования виртуальных методов. Давайте ближе к реальной задаче:
Здесь все понятно. Компилятор реально не знает, какие типы находятся внутри my_vec в функции vec_base. Поэтому все, что он может сделать - это использовать указатель на таблицу виртуальных функций и пусть рантайме уже решается, какой конкретно метод вызвать.
Теперь посмотрим такой код:
И здесь тоже не инлайнится код! Хотя компилятор же видит, что Derived - это единственный наследник.
Но на деле это может быть не так. Единица трансляции с vec_derived может не знать о наследниках уже Derived класса, которые определены отдельно. Компилятор не может достоверно доказать, что у Derived нет наследников, поэтому и не может инлайнить код. Опять vtable и оверхэд.
И здесь в игру вступает ключевое слово final и девиртуализация вызовов, о которой мы говорили тут и тут. Пометив класс как final, мы можем ожидать, что компилятор поймет, что никаких наследников у него нет. Поэтому он может встраивать вызов метода foo для Derived класса в последнем примере.
Вот код на годболте с последними примерами и final девиртуализацией. Немного упростил его, чтобы легче было ассемблер читать.
Правды ради стоит сказать, что для девиртуализации нужны очень особенные условия, которых сложно достичь, используя стандартные практики программирования и паттерны ООП.
Учитывая, что
1️⃣ виртуальные функции обычно довольно громоздкие(их код будет генерится во всех единицах трансляции, что раздувает бинарь)
2️⃣ операции над непосредственно типами наследниками не распространены
3️⃣ а для девиртуализации нужно танцевать с бубнами
от определения чуть сложных виртуальных методов внутри класса могут быть только проблемы. Поэтому например в Chromium гайдлайнах четко прописано, что запрещены инлайн определения виртуальных методов. Все уносим в цпп.
Be useful. Stay cool.
#cppcore #OOP #design
#опытным
В догонку предыдущего поста. Если можно сделать обычные методы inline, то можно и виртуальные сделать. Что это изменит?
Ну то есть такая иерархия:
class Base {
public:
virtual int foo() = 0;
protected:
int x = 2;
};
class Derived : public Base {
public:
int foo() override {
x *= 2;
return x;
}
};Получим ли мы какой-то профит от того, что виртуальные методы теперь inline?
Посмотрим на такой код:
void simple() {
Derived d;
std::cout << d.foo();
}Конечно же метод будет встраиваться в вызывающий код. Компилятор на этапе компиляции четко знает тип, с которым он работает, и может проводить оптимизации.
Но вообще говоря, это далеко не основной кейс использования виртуальных методов. Давайте ближе к реальной задаче:
void vec_base(std::vector<std::unique_ptr<Base>>& my_vec) {
for (auto &p : my_vec) {
std::cout << p->foo();
}
}
// другая TU
std::vector<std::unique_ptr<Base>> my_vec;
my_vec.push_back(new Derived());
vec_base(my_vec);Здесь все понятно. Компилятор реально не знает, какие типы находятся внутри my_vec в функции vec_base. Поэтому все, что он может сделать - это использовать указатель на таблицу виртуальных функций и пусть рантайме уже решается, какой конкретно метод вызвать.
Теперь посмотрим такой код:
void vec_derived(std::vector<std::unique_ptr<Derived>>& my_vec) {
for (auto &p : my_vec) {
std::cout << p->foo();
}
}
// другая TU
std::vector<std::unique_ptr<Derived>> my_vec;
my_vec.push_back(new Derived());
vec_base(my_vec);И здесь тоже не инлайнится код! Хотя компилятор же видит, что Derived - это единственный наследник.
Но на деле это может быть не так. Единица трансляции с vec_derived может не знать о наследниках уже Derived класса, которые определены отдельно. Компилятор не может достоверно доказать, что у Derived нет наследников, поэтому и не может инлайнить код. Опять vtable и оверхэд.
И здесь в игру вступает ключевое слово final и девиртуализация вызовов, о которой мы говорили тут и тут. Пометив класс как final, мы можем ожидать, что компилятор поймет, что никаких наследников у него нет. Поэтому он может встраивать вызов метода foo для Derived класса в последнем примере.
Вот код на годболте с последними примерами и final девиртуализацией. Немного упростил его, чтобы легче было ассемблер читать.
Правды ради стоит сказать, что для девиртуализации нужны очень особенные условия, которых сложно достичь, используя стандартные практики программирования и паттерны ООП.
Учитывая, что
1️⃣ виртуальные функции обычно довольно громоздкие(их код будет генерится во всех единицах трансляции, что раздувает бинарь)
2️⃣ операции над непосредственно типами наследниками не распространены
3️⃣ а для девиртуализации нужно танцевать с бубнами
от определения чуть сложных виртуальных методов внутри класса могут быть только проблемы. Поэтому например в Chromium гайдлайнах четко прописано, что запрещены инлайн определения виртуальных методов. Все уносим в цпп.
Be useful. Stay cool.
#cppcore #OOP #design
1❤13👍6🔥6❤🔥2
Zero-Cost Abstractions
#новичкам
Нельзя в промышленных масштабах писать код без абстракций. Вряд ли вы за обозримое время напишите даже эхо-сервер на ассемблере. Даже сам язык программирования - это абстракция. Он позволяет писать программы более-менее на английском языке(или на патриотическом Русском на 1С).
Абстракции упрощают программирование. А что с перфомансом?
Гипотеза такова, что чем выше уровень абстракции, тем выше косты производительности в рантайме.
Это например четко видно на примере ООП. ООП и ООДизайн позволили нам создать почти весь софт, которым мы пользуемся. Но на вызов виртуальных функций накладывается большой дебафф: компилятор во время компиляции не знает конкретных типов и приходится использовать индиректные вызовы с помощью таблицы виртуальных функций. Никакого инлайнинга да и хотя бы конкретных фиксированных адресов.
Однако есть и такие абстракции, которые не накладывают оверхэд на рантайм! Они называются Zero-Cost абстракциями.
И это чуть ли не основаная философия программирования на С++. Мы можем играться с уровнями абстракции, писать самый отдаленный от железа код и за все это мы ничего не платим! Компилятор своим мегамозгом анализирует на код, выполняет кучу оптимизаций и на выходе получаем быструю, высокопроизводительную конфетку.
За примерами долго ходить не надо. Можно сложить элементы вектора руками, а можно использовать std::accumulate:
При этом для обоих вариантов генерируется почти идентичный код. И это с использованием итераторов и прочих прелестей стандартных контейнеров.
На каких китах стоит возможность использовать "бесплатные" абстракции в С++?
🐳 Полиморфизм времени компиляции, compile-time вычисления и метапрограммирование. Тут все просто: переносим вычисления с рантайма в компайл тайм с помощью шаблонов, constexpr и меты и радуемся жизни. Параллельно можно еще и кофеек себе заваривать, пока проект билдится.
🐳 Инлайнинг. Одна из основных оптимизаций компилятора. Позволяет встраивать код функции в вызывающий код. С помощью инлайнинга 4 вложенных вызова функций могут превратиться в одну сплошную портянку низкоуровневого кода без дорогостоящих инструкций call.
🐳 Другие оптимизации. Компилятор дополнительно выполняет кучу оптимизаций: переставляет инструкции, подставляет известные значения сразу в код, обрезает ненужные инструкции, векторизует циклы и тд. Все они нужны для одной цели - ускорение кода.
Объединяя эти механизмы, C++ стремится обеспечить абстракции высокого уровня, которые можно использовать для написания выразительного и читаемого кода, сохраняя при этом производительность сравнимую с более низкоуровневым кодом, написанным например на С.
Don't pay for abstraction. Stay cool
#cppcore #compiler
#новичкам
Нельзя в промышленных масштабах писать код без абстракций. Вряд ли вы за обозримое время напишите даже эхо-сервер на ассемблере. Даже сам язык программирования - это абстракция. Он позволяет писать программы более-менее на английском языке(или на патриотическом Русском на 1С).
Абстракции упрощают программирование. А что с перфомансом?
Гипотеза такова, что чем выше уровень абстракции, тем выше косты производительности в рантайме.
Это например четко видно на примере ООП. ООП и ООДизайн позволили нам создать почти весь софт, которым мы пользуемся. Но на вызов виртуальных функций накладывается большой дебафф: компилятор во время компиляции не знает конкретных типов и приходится использовать индиректные вызовы с помощью таблицы виртуальных функций. Никакого инлайнинга да и хотя бы конкретных фиксированных адресов.
Однако есть и такие абстракции, которые не накладывают оверхэд на рантайм! Они называются Zero-Cost абстракциями.
И это чуть ли не основаная философия программирования на С++. Мы можем играться с уровнями абстракции, писать самый отдаленный от железа код и за все это мы ничего не платим! Компилятор своим мегамозгом анализирует на код, выполняет кучу оптимизаций и на выходе получаем быструю, высокопроизводительную конфетку.
За примерами долго ходить не надо. Можно сложить элементы вектора руками, а можно использовать std::accumulate:
void foo(std::vector<int>& vec) {
int sum = 0;
for(int i = 0; i < vec.size(); i++) {
sum += vec[i];
}
std::cout << sum;
}
void bar(std::vector<int>& vec) {
int sum = std::accumulate(vec.begin(), vec.end(), 0);
std::cout << sum;
}При этом для обоих вариантов генерируется почти идентичный код. И это с использованием итераторов и прочих прелестей стандартных контейнеров.
На каких китах стоит возможность использовать "бесплатные" абстракции в С++?
🐳 Полиморфизм времени компиляции, compile-time вычисления и метапрограммирование. Тут все просто: переносим вычисления с рантайма в компайл тайм с помощью шаблонов, constexpr и меты и радуемся жизни. Параллельно можно еще и кофеек себе заваривать, пока проект билдится.
🐳 Инлайнинг. Одна из основных оптимизаций компилятора. Позволяет встраивать код функции в вызывающий код. С помощью инлайнинга 4 вложенных вызова функций могут превратиться в одну сплошную портянку низкоуровневого кода без дорогостоящих инструкций call.
🐳 Другие оптимизации. Компилятор дополнительно выполняет кучу оптимизаций: переставляет инструкции, подставляет известные значения сразу в код, обрезает ненужные инструкции, векторизует циклы и тд. Все они нужны для одной цели - ускорение кода.
Объединяя эти механизмы, C++ стремится обеспечить абстракции высокого уровня, которые можно использовать для написания выразительного и читаемого кода, сохраняя при этом производительность сравнимую с более низкоуровневым кодом, написанным например на С.
Don't pay for abstraction. Stay cool
#cppcore #compiler
👍33❤15🔥6😁3
std::allocate_shared
#опытным
В одном из прошлых постов мы упоминали, что одним из недостатков std::make_shared является то, что с ней нельзя использовать кастомный менеджмент памяти.
Причиной является то, что для выделения памяти она использует глобальный new. Поэтому и для освобождения памяти должна использовать глобальный delete. Здесь нет места кастомщине.
Но что, если нам очень нужно по-особенному работать с памятью для объекта? Даже хотя бы просто отслеживать выделение и разрушение без влияния на глобальный delete?
Тут на помощью приходит функция std::allocate_shared. Первым аргументом она принимает аллокатор, который и будет ответственен за выделение и освобождение памяти.
Вот вам простой примерчик с простым STL-совместимым аллокатором, логирующим операции выделения и освобождения памяти:
Стандартом на аллокаторы накладываются определенные требования: нужно определить нужные алиасы, методы allocate и deallocate, структуру rebind и соответствующий конструктор копирования и операторы сравнения. Полный список требований можно прочитать тут.
По консольному выводу видно, что аллокатор выделяет немного больше памяти, чем должен занимать объект. Это сделано в том числе для того, чтобы хранить в выделенном куске еще и контрольный блок std::shared_ptr. Так что касаемо особенностей аллокации и деаллокации тут похожая ситуация с std::make_shared.
Аллокатор кстати хранится в том же контрольном блоке, так что информация о способе деаллокации берется оттуда.
Customize your solutions. Stay cool.
#cppcore #STL #memory
#опытным
В одном из прошлых постов мы упоминали, что одним из недостатков std::make_shared является то, что с ней нельзя использовать кастомный менеджмент памяти.
Причиной является то, что для выделения памяти она использует глобальный new. Поэтому и для освобождения памяти должна использовать глобальный delete. Здесь нет места кастомщине.
Но что, если нам очень нужно по-особенному работать с памятью для объекта? Даже хотя бы просто отслеживать выделение и разрушение без влияния на глобальный delete?
Тут на помощью приходит функция std::allocate_shared. Первым аргументом она принимает аллокатор, который и будет ответственен за выделение и освобождение памяти.
Вот вам простой примерчик с простым STL-совместимым аллокатором, логирующим операции выделения и освобождения памяти:
template <typename T>
class LoggingAllocator {
public:
using value_type = T;
using pointer = T *;
using const_pointer = const T *;
using reference = T &;
using const_reference = const T &;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
template <typename U>
struct rebind {
using other = LoggingAllocator<U>;
};
LoggingAllocator() noexcept = default;
template <typename U>
LoggingAllocator(const LoggingAllocator<U> &) noexcept {}
pointer allocate(size_type n) {
size_type bytes = n * sizeof(T);
std::cout << "Allocating " << n << " elements ("
<< bytes << " bytes)\n";
return static_cast<pointer>(::operator new(bytes));
}
void deallocate(pointer p, size_type n) noexcept {
size_type bytes = n * sizeof(T);
std::cout << "Deallocating " << n << " elements ("
<< bytes << " bytes)\n";
::operator delete(p);
}
};
template <typename T, typename U>
bool operator==(const LoggingAllocator<T> &,
const LoggingAllocator<U> &) noexcept { return true; }
template <typename T, typename U>
bool operator!=(const LoggingAllocator<T> &,
const LoggingAllocator<U> &) noexcept { return false; }
class MyClass {
public:
MyClass(int value) : value(value) {
std::cout << "Constructed with " << value << "\n";
}
~MyClass() {
std::cout << "Destroyed with " << value << "\n";
}
void print() const {
std::cout << "Value: " << value << "\n";
}
private:
int value;
};
int main() {
LoggingAllocator<MyClass> alloc;
auto ptr = std::allocate_shared<MyClass>(alloc, 42);
ptr->print();
return 0;
}
// OUTPUT:
// Allocating 1 elements (24 bytes)
// Constructed with 42
// Value: 42
// Destroyed with 42
// Deallocating 1 elements (24 bytes)
Стандартом на аллокаторы накладываются определенные требования: нужно определить нужные алиасы, методы allocate и deallocate, структуру rebind и соответствующий конструктор копирования и операторы сравнения. Полный список требований можно прочитать тут.
По консольному выводу видно, что аллокатор выделяет немного больше памяти, чем должен занимать объект. Это сделано в том числе для того, чтобы хранить в выделенном куске еще и контрольный блок std::shared_ptr. Так что касаемо особенностей аллокации и деаллокации тут похожая ситуация с std::make_shared.
Аллокатор кстати хранится в том же контрольном блоке, так что информация о способе деаллокации берется оттуда.
Customize your solutions. Stay cool.
#cppcore #STL #memory
❤21👍13🔥9🤣1
Оператор, бороздящий просторы вселенной
#новичкам
В этом посте мы рассказали об одной фишке, которая может помочь при сравнении кастомных структур:
Однако иногда структуры требуется сравнивать и с помощью других операторов: >, ==, !=, >=, <=. В итоге полноценный набор операторов сравнения для Time выглядит так:
Попахивает зловонным бойлерплейтом.
Недавно увидел мем, где девочка 8-ми лет, которая изучает питон, спрашивает отца: "папа, а если компьютер знает, что здесь пропущено двоеточие, почему он сам не может его поставить?". И батя такой: "Я не знаю, дочка, я не знаю ...".
Здесь вот похожая ситуация. Компилятор же умеет сравнивать набор чисел в лексикографическом порядке. Какого хрена он не может сделать это за нас?
Начиная с С++20 может!
Теперь вы можете сказать компилятору, что вам достаточно простого лексикографического сравнения поле класса и пусть он сам его генерирует:
В отличие от специальных методов класса, компилятор не сгенерирует за нас эти операторы, если мы явно не попросим. Получается, что мы решили только полпроблемы и нам все равно нужно писать 6 скучных засоряющих код строчек. Хотелось бы один раз сказать, что нам нужны сразу все операторы.
Тут же нам на помощью приходит еще одна фича С++20 - трехсторонний оператор сравнения или spaceship operator. Теперь код выглядит так:
Spaceship потому что похож на космический корабль, имхо прям имперский истребитель из далекой-далекой.
Один раз определив этот оператор можно сравнивать объекты какими угодно операторами и это будет работать. Подробнее про применение будет в следующем посте.
Conquer your space. Stay cool.
#cppcore #cpp20
#новичкам
В этом посте мы рассказали об одной фишке, которая может помочь при сравнении кастомных структур:
struct Time {
int hours;
int minutes;
int seconds;
bool operator<(const Time& other) {
return std::tie(hours, minutes, seconds) < std::tie(other.hours, other.minutes, other.seconds);
}
};Однако иногда структуры требуется сравнивать и с помощью других операторов: >, ==, !=, >=, <=. В итоге полноценный набор операторов сравнения для Time выглядит так:
struct Time {
int hours;
int minutes;
int seconds;
bool operator<(const Time& other) const noexcept {
return std::tie(hours, minutes, seconds) < std::tie(other.hours, other.minutes, other.seconds);
}
bool operator==(const Time& other) const noexcept {
return std::tie(hours, minutes, seconds) == std::tie(other.hours, other.minutes, other.seconds);
}
bool operator<=(const Time& other) const noexcept { return !(other < *this); }
bool operator>(const Time& other) const noexcept { return other < *this; }
bool operator>=(const Time& other) const noexcept { return !(*this < other); }
bool operator!=(const Time& other) const noexcept { return !(*this == other); }
};Попахивает зловонным бойлерплейтом.
Недавно увидел мем, где девочка 8-ми лет, которая изучает питон, спрашивает отца: "папа, а если компьютер знает, что здесь пропущено двоеточие, почему он сам не может его поставить?". И батя такой: "Я не знаю, дочка, я не знаю ...".
Здесь вот похожая ситуация. Компилятор же умеет сравнивать набор чисел в лексикографическом порядке. Какого хрена он не может сделать это за нас?
Начиная с С++20 может!
Теперь вы можете сказать компилятору, что вам достаточно простого лексикографического сравнения поле класса и пусть он сам его генерирует:
struct Time {
int hours;
int minutes;
int seconds;
bool operator<(const Time& other) const = default;
bool operator==(const Time& other) const = default;
bool operator<=(const Time& other) const = default;
bool operator>(const Time& other) const = default;
bool operator>=(const Time& other) const = default;
bool operator!=(const Time& other) const = default;
};В отличие от специальных методов класса, компилятор не сгенерирует за нас эти операторы, если мы явно не попросим. Получается, что мы решили только полпроблемы и нам все равно нужно писать 6 скучных засоряющих код строчек. Хотелось бы один раз сказать, что нам нужны сразу все операторы.
Тут же нам на помощью приходит еще одна фича С++20 - трехсторонний оператор сравнения или spaceship operator. Теперь код выглядит так:
struct Time {
int hours;
int minutes;
int seconds;
// Один оператор вместо шести!
auto operator<=>(const Time& other) const = default;
};Spaceship потому что похож на космический корабль, имхо прям имперский истребитель из далекой-далекой.
Один раз определив этот оператор можно сравнивать объекты какими угодно операторами и это будет работать. Подробнее про применение будет в следующем посте.
Conquer your space. Stay cool.
#cppcore #cpp20
❤40👍22🔥12
Spaceship оператор. Детали 1
#новичкам
В прошлом посте мы рассказали, как трехсторонний оператор сравнения может помочь сократить код определения операций сравнения, но это не единственное его предназначение. Сегодня подробнее рассмотрим, какую функциональность он предоставляет.
Ну для начала: наличие определенного spaceship оператора гарантирует вам наличие всех 6 операций сравнения:
Это уже прекрасно, но это еще не все!
Обратите внимание на сигнатуру spaceship operator. Зачем там нужен auto?
Вот теперь объясненяем, почему это называется оператор трехстороннего сравнения.
Он возвращает объект, который содержит информацию о результате сравнения:
Если результат сравнения >0, то первый операнд больше второго. И так далее по аналогии.
Тип возвращаемого значения у оператора один из этих трех:
- std::strong_ordering
- std::weak_ordering
- std::partial_ordering
Что они значат - тема отдельного разговора, но каждый из них может находится в одном из 3-х состояний: less, greater, equal. Это можно использовать, например, для проверки возвращаемых значений системных вызовов:
Кейсы применения непосредственно spaceship'а в коде не так обширны, потому что не очень привычно, есть вопросы к перфу(об этом в следующем посте) да и поди разберись с этими ордерингами еще. Но его точно стоит использовать для автоматической генерации 6 базовых операторов.
Be universal. Stay cool.
#cppcore #cpp20
#новичкам
В прошлом посте мы рассказали, как трехсторонний оператор сравнения может помочь сократить код определения операций сравнения, но это не единственное его предназначение. Сегодня подробнее рассмотрим, какую функциональность он предоставляет.
Ну для начала: наличие определенного spaceship оператора гарантирует вам наличие всех 6 операций сравнения:
struct Time {
int hours;
int minutes;
int seconds;
// Spaceship operator (генерирует все 6 операторов сравнения)
auto operator<=>(const Time& other) const = default;
};
Time t1{10, 30, 15}; // 10:30:15
Time t2{9, 45, 30}; // 09:45:30
Time t3{10, 30, 15}; // 10:30:15
assert(t1 > t2); // 10:30:15 > 09:45:30
assert(!(t1 < t2)); // 10:30:15 не < 09:45:30
assert(t1 == t3); // 10:30:15 == 10:30:15
assert(t1 != t2); // 10:30:15 != 09:45:30
assert(t1 <= t3); // 10:30:15 <= 10:30:15
assert(t1 >= t2); // 10:30:15 >= 09:45:30Это уже прекрасно, но это еще не все!
Обратите внимание на сигнатуру spaceship operator. Зачем там нужен auto?
Вот теперь объясненяем, почему это называется оператор трехстороннего сравнения.
Он возвращает объект, который содержит информацию о результате сравнения:
Time t1{10, 30, 15}; // 10:30:15
Time t2{9, 45, 30}; // 09:45:30
// Можно использовать и сам spaceship operator напрямую
auto cmp = t1 <=> t2;
if (cmp > 0) {
std::cout << "t1 is later than t2\n";
} else if (cmp < 0) {
std::cout << "t1 is earlier than t2\n";
} else {
std::cout << "t1 is the same as t2\n";
}
// OUTPUT:
// t1 is later than t2Если результат сравнения >0, то первый операнд больше второго. И так далее по аналогии.
Тип возвращаемого значения у оператора один из этих трех:
- std::strong_ordering
- std::weak_ordering
- std::partial_ordering
Что они значат - тема отдельного разговора, но каждый из них может находится в одном из 3-х состояний: less, greater, equal. Это можно использовать, например, для проверки возвращаемых значений системных вызовов:
constexpr int strong_ordering_to_int(const std::strong_ordering& o)
{
if (o == std::strong_ordering::less) return -1;
if (o == std::strong_ordering::greater) return 1;
return 0;
}
char buffer[256];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
// Сравниваем результат read() с нулём через <=>
switch (strong_ordering_to_int(bytes_read <=> 0)) {
case 1:
std::cout << "Read " << bytes_read << " bytes: "
<< std::string(buffer, bytes_read) << "\n";
break;
case 0:
std::cout << "End of file reached (0 bytes read)\n";
break;
case -1:
perror("read failed");
return 1;
}
Кейсы применения непосредственно spaceship'а в коде не так обширны, потому что не очень привычно, есть вопросы к перфу(об этом в следующем посте) да и поди разберись с этими ордерингами еще. Но его точно стоит использовать для автоматической генерации 6 базовых операторов.
Be universal. Stay cool.
#cppcore #cpp20
❤30👍15🔥10⚡1
struct String {
std::string str;
bool operator==(const String &other) const {
std::cout << "Using optimized ==\n";
if (str.size() != other.str.size())
return false;
return str == other.str;
}
auto operator<=>(const String &other) const {
std::cout << "Using <=>\n";
return str <=> other.str; // Сравнивает символы до первого различия
}
};
struct SString {
String str;
auto operator<=>(const SString &other) const = default;
};
a == b; // Вызовет operator==
a != b; // Вызовет operator!=, который определен через ==
a < b; // Вызовет spaceship
// OUTPUT:
// Using optimized ==
// Using optimized ==
// Using <=>В общем, оказалось, что здесь такая собака зарыта... Если используйте дефолтовый космический оператор для простых структур, то скорее всего все будет ок. Но если хотите определять свой spaceship, то придется глубоко погружаться в кроличью нору.
Don't go to a rabbit hole. Stay cool.
#cppcore
❤22👍7🔥5