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

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

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

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

enum class Color {
kRed,
kGreen,
kBlue
};


И в каком-то месте программы вам нужно узнать размер этого перечисления. Вопрос: как в коде получить его размер?

В таком варианте, когда элементам enum'а явно не присвоены никакие числа, каждому из них присвоен порядковый номер, начиная с нуля. kRed - 0, kGreen - 1, kBlue - 2.

Соответственно, чтобы получить количество элементов перечисления нужно сделать такую операцию:

auto size = static_cast<int>(Color::kBlue) + 1;


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

enum class Color {
kRed,
kGreen,
kBlue,
kBlack
};


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

На этот случай есть проверенный прием: заранее вставлять в enum фейковый последний элемент, порядковый номер которого и будет равен размеру перечисления:

enum class Color {
kRed,
kGreen,
kBlue,
kCount
};

auto size = static_cast<int>(Color::kCount);


В этом случае расширять enum нужно приписывая элементы перед kCount. А код получения размера не меняется.

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

Create extendable solutions. Stay cool.

#goodpractice #cppcore
69👍20🔥14
​​Cпецификатор, модификатор, квалификатор и идентификатор
#новичкам

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

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

Начнем с простого. Идентификатор. Это просто имя, которым "идентифицируется" сущность. Имя переменной, константы, функции, класса, шаблона - это идентификаторы. Такие себе id'шники сущностей.

Спецификатор. Это слово скрывает в себе самое большое разнообразие сущностей. В основном это ключевые слова, уточняющие, что это за сущность:

- Спецификаторы типа. Ключевые слова, использующиеся для определения типа или сущности. class и struct(при объявлении класса указываем что идентификатор является классом), enum, все тривиальные типы(char, bool, short, int, long, signed, unsigned, float, double, void), объявленный прежде имена классов, enum'ов, typedef'ов.

- Спецификаторы объявления. typedef, inline, friend, conetexpr, consteval, constinit, static, mutable, extern, thread_local, final, override.

- Спецификаторы доступа к полям классов: private, protected, public.

Модификатор

Модификатор типа - это ключевое слово, которое изменяет поведение стандартных числовых типов. Модификаторами являются: short, insigned, signed, long. Например, unsigned int - это уже беззнаковый тип, в short int - короткий тип инт, который обычно занимает 16 бит вместо 32.

Это слово редко используется, потому что все модификаторы - это спецификаторы. Так что это вносит только путаницу.

Квалификатор

Существует всего 4 квалификатора. cv-квалификаторы: const и volatile. И ref-квалификаторы: & и &&.

Все. Теперь вы native говоруны и можете speak как про С++ coders.

Know the meaning. Stay cool.

#cppcore
🔥49👍1612🤓6
Как итерироваться в обратном порядке?
#новичкам

Кто часто решал задачки на литкоде поймут проблему. Есть вектор и надо проитерироваться по нему с конца. Ну пишем:

std::vector vec { 1, 2, 3, 4, 5, 6 };
for (auto i = vec.size() - 1; i >= 0; --i) {
std::cout << i << ": " << vec[i] << '\n';
}


В чем проблема этого кода?

Бесконечный цикл и ub. auto определяет тип i беззнаковым, который физически не может быть меньше нуля. Происходит переполнение, i становится очень большим и происходит доступ к невалидной памяти.

В большинстве задач можно написать тип int и все будет работать. Но все-таки size() возвращает size_t и будет происходить сужающее преобразование. В реальных проектах нужно избегать этого и сегодня мы посмотрим, как безопасно итерироваться в обратном порядке.

Использовать свободную функцию ssize() из C++20:

std::vector vec { 1, 2, 3, 4, 5, 6 };
for (auto i = std::ssize(vec) - 1; i >= 0; --i) {
std::cout << vec[i] << '\n';
}


Ее можно применить к вектору и она вернет значение типа std::ptrdiff_t. В первом приблежении это знаковый аналог std::size_t, который позволяет вычислять расстояние между двумя указателями, даже для очень больших массивов.

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

Использовать обратные итераторы:

std::vector vec { 1, 2, 3, 4, 5, 6 };
for (auto it = std::rbegin(vec); it != std::rend(vec); ++it)
std::cout << *it << '\n';


Тут все довольно очевидно и безопасно.

Однако cppcore гайдлайны говорят нам, что нужно предпочитать использовать range-based-for циклы обычным for'ам. Чтож, давайте пойдем в эту сторону.

Написать свой легковесный адаптер для итерирования в обратном порядке:

template <typename T>
class reverse {
private:
T &iterable_;

public:
explicit reverse(T &iterable) : iterable_{iterable} {}
auto begin() const { return std::rbegin(iterable_); }
auto end() const { return std::rend(iterable_); }
};

std::vector vec{1, 2, 3, 4, 5};
for (const auto &elem : reverse(vec))
std::cout << elem << '\n';


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

А ренджи для кого придумали? Они для этой задачи подходят идеально:

for (const auto& elem : vec | std::views::reverse)
std::cout << elem << '\n';

// или без пайпов

for (const auto& elem : std::ranges::reverse_view(vec))
std::cout << elem << '\n';


Рэнджи из C++20 предоставляют кучу удобных адаптеров для работы с контейнерами. В сущности std::views::reverse или std::ranges::reverse_view делает примерно то же самое, что и мы сами написали в третьем пункте.

Можно совсем упороться и применить алгоритмы ренждей:

std::ranges::copy(vec | std::views::reverse, 
std::ostream_iterator<int>( std::cout,"\n" ));

// или c лямбдой

std::ranges::for_each(vec | std::views::reverse,
[](const auto& elem) {
std::cout << elem << '\n';
});


Бывает, что индексы элементов все-таки нужны внутри цикла. Но это решается с помощью std::ranges::iota_view. Оставляем реализацию этого решения для домашних изысканий.

Have a large toolkit. Stay cool.

#cppcore #cpp20 #STL
36👍23🔥11👎3👀2❤‍🔥1
​​ssize_t
#новичкам

Есть такой интересный тип ssize_t. Интересный он, потому что в отличии от стандартных типов имеет несимметричный относительно нуля диапазон значений [-1, SSIZE_MAX]. То есть это знаковый тип, но с нюансом, что отрицательное значение может быть только одно: -1.

Зачем такой тип нужен?

В posix api есть много функций, которые возвращают количество байт. Но так как это С и там нет исключений, а об ошибках как-то надо говорить, то значение -1 является индикатором ошибки:

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void buf, size_t count);


Если вы работаете с сырыми дескрипторами, то явно пользуетесь функциями read и write, которые возвращают количество считанных или записанных байт соответственно. Если что-то пошло не так, то вместо количества байт возвращается -1:

char buffer[1024];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
if (bytes_read == -1) { / Обработка ошибки */ }


Но почему этого типа нет в С++ стандарте? С его помощью мы бы могли например решить задачу итерации по контейнеру в обратном порядке из предыдущего поста.

Ответ простой, если подумать. Этот тип нужен только для апи, которое возвращает -1, как ошибку. В С++ есть исключения, объекты и шаблоны. С помощью этих трех инструментов можно как душе вздумается сообщать об ошибках. И это будет лучше и экспрессивнее, чем просто -1.

Use the right tool. Stay cool.

#cppcore #goodoldc #NONSTANDARD
🔥38👍1910😁3👎1
​​Преимущества std::make_shared
#новичкам

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

Коротко о том, что это за функция.

template< class T, class... Args >
shared_ptr<T> make_shared( Args&&... args );


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

В чем же преимущества этой функции по сравнению с явным вызовом конструктора shared_ptr?

Ну для начала, она не предполагает явного использования сырых указателей. Никакого вызова new!

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

Но если new не вызывает программист, это не значит, что функция его не вызывает. Еще как вызывает. И в том, как она это делает кроется главное преимущество std::make_shared над явным вызовом конструктора.

ОООчень упрощенно внутреннее устройство std::shared_ptr выглядит вот так:

template <typename T>
struct shared_ptr {
T * obj_ptr;
ControlBlock * block_ptr;
}


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

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

std::shared_ptr<Foo> ptr(new Foo(arg1, arg2));


Память под объект Foo выделяется при вызове new, а память под контрольный блок выделяется внутри конструктора shared_ptr.

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

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

template <typename T, typename... Args>
shared_ptr<T> my_make_shared(Args&&... args) {
// Выделяем память для ControlBlock и объекта T одним блоком
char* memory = new char[sizeof(ControlBlock) + sizeof(T)];

// Инициализируем ControlBlock в начале памяти
ControlBlock* block = new (memory) ControlBlock();

// Инициализируем объект T после ControlBlock
T* object = new (memory + sizeof(ControlBlock)) T(std::forward<Args>(args)...); // Placement new

shared_ptr<T> ptr;
ptr.obj_ptr = object;
ptr.block_ptr = block;

return ptr;


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

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

Ну и на последок.

std::shared_ptr<Foo> ptr(new Foo(arg1, arg2));


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

Это не происходит с std::make_shared, потому что у нас есть волшебное слово auto:

auto ptr = std::make_shared<Foo>(arg1, arg2);


Есть(было) и еще одно преимущество make_shared. Но его разберем уже отдельно, там ситуация непростая.

А на этом у нас все)

Make better tools. Stay cool.

#cppcore #memory
👍4016🔥7💯2❤‍🔥1
​​Недостатки std::make_shared. Кастомный new и delete
#новичкам

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

И начнем с непопулярного.

Внутри себя она создает объект с помощью ::new. Это значит, что если вы для своего класса переопределяете операторы работы с памятью, то make_shared не будет учитывать это поведение, а вы будете гадать, почему не видите нужных спецэффектов:

class A {
public:
void *operator new(size_t) {
std::cout << "allocate\n";
return ::new A();
}

void operator delete(void *a) {
std::cout << "deallocate\n";
::delete static_cast<A *>(a);
}
};

int main() {
const auto a =
std::make_shared<A>(); // ignores overloads
//const auto b =
// std::shared_ptr<A>(new A); // uses overloads
}
// OUTPUT:
// Пусто!


В общем, если нужный кастомный менеджент памяти, то std::make_shared - не ваш бро.

Customize your solutions. Stay cool.

#cppcore #cpp11 #memory
628👍18😁14🔥4❤‍🔥1
Недостатки std::make_shared. Непубличные конструкторы
#новичкам

std::make_shared - это сторонний код по отношению к классу, объект которого он пытается создать. Поэтому на принципиальную возможность создания объекта влияет спецификатор видимости конструктора.

Если конструктор публичный - проблем нет, просто вызываем std::make_shared.

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

Для определенности примем, что конструктор приватный. Это может делаться по разным причинам. Например у нас есть необходимость в создании копий std::shared_ptr, в котором находится исходный объект this. Тогда класс надо унаследовать от std::enable_shared_from_this и возвращать из фабрики std::shared_ptr. Если создавать объект любым другим путем, то будет ub. Поэтому, как заботливые нянки, помогает пользователям не изменять количество отверстий в ногах:

struct Class: public std::enable_shared_from_this<Class> {
static std::shared_ptr<Class> Create() {
// return std::make_shared<Class>(); // It will fail.
return std::shared_ptr<Class>(new Class);
}
private:
Class() {}
};


Использовать make_shared здесь не выйдет. Хоть мы и используем эту функцию внутри метода класса, это не позволяет ей получить доступ к приватным членам.

В таком случае мы просто вынуждены использовать явный конструктор shared_ptr и явный вызов new.

А как вы знаете, в этом случае будут 2 аллокации: для самого объекта и для контрольного блока. Если объект создается часто, то это может быть проблемой, если вы упарываетесь по перфу.

Hide your secrets. Stay cool.

#cppcore #cpp11
614👍14🔥61
​​Недостатки std::make_shared. Кастомные делитеры
#новичкам

Заходим на cppreference и видим там такие слова:

This function may be used as an alternative to std::shared_ptr<T>(new T(args...)).

Также видим ее сигнатуру:

template< class T, class... Args >
shared_ptr<T> make_shared( Args&&... args );


И понимаем, что make_shared не предоставляет возможности указывать кастомный делитер. Все аргументы функции просто перенаправляются в конструктор шареного указателя.

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

Мы же попытаемся ответить на вопрос: "А почему нельзя указать делитер?".

Одной из особенностей make_shared является то, что она аллоцирует единый отрезок памяти и под объект, и под контрольный блок. И использует базовый оператор new для этого.

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

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

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

Customize your solutions. Stay cool.

#cppcore #cpp11 #memory
🔥177👍62❤‍🔥1
Одно значимое улучшение С++17
#опытным

У компилятора большая свобода в том, что и как он может делать с исходным кодом при компиляции.

Возьмем, например, вызов функции:

f( g(expr1, expr2), h(expr3) );


В каком порядке вызываются expr1, expr2, expr3, g, h и f?

Культурно западный человек интуитивно будет представлять обход в глубину слева направо. То есть порядок вычисления будет примерно такой: expr1 -> expr2 -> g -> expr3 -> h -> f.

Однако это абсолютно не совпадает с тем как поступает компилятор в соответствии со стандартом.

Что было до С++17?

Было единственное правило: все аргументы функции должны быть вычислены до вызова функции. Все!

То есть могло теоретически мог бы быть такой порядок: expr2 -> expr3 -> h -> expr1 -> g -> f.

Полный бардак! И это приводило на самом деле к неприятным последствиям.

Что если мы принимаем в функцию два умных указателя и попробуем вызвать ее так:

void bar(std::unique_ptr<SomeClass1> a, std::unique_ptr<SomeClass2> b) {}

bar(std::unique_ptr<SomeClass1>(new SomeClass1{}), std::unique_ptr<SomeClass1>(new SomeClass2{}));


Какие тут могут быть проблемы?

Итоговый порядок вычислений может быть следующий:

new SomeClass1{} -> new SomeClass2{} -> std::unique_ptr<SomeClass1> -> std::unique_ptr<SomeClass2>


Что произойдет, если SomeClass2 выкинет исключение? Правильно, утечка памяти. Для объекта, созданного как new SomeClass1{}, не вызовется деструктор.

Эту проблему решали с помощью std::make_* фабрик умных указаателей:

bar(std::make_unique<SomeClass1>(), std::make_unique<SomeClass2>());


Нет сырого вызова new, а значит если из второго конструктора вылетит исключение, то первый объект будет уже обернут в unique_ptr и для него вызовется деструктор.

Это было одной из мощных мотиваций использования std::make_* функций для умных указателей.

Что стало с наступлением С++17?

f(e(), g(expr1, expr2), h(expr3));


До сих пор неопределено в каком порядке вычислятся e, f и h. Или expr1 и expr2.

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

Теперь такой код не будет проблемой:

void bar(std::unique_ptr<SomeClass1> a, std::unique_ptr<SomeClass2> b) {}

bar(std::unique_ptr<SomeClass1>(new SomeClass1{}), std::unique_ptr<SomeClass1>(new SomeClass2{}));


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

Это немного обесценило использование std::make_* функций. Но их все равно предпочтительно использовать из-за отсутствия явного использования сырых указателей.

Fix problems. Stay cool.

#cppcore #memory #cpp17
👍3615🔥10❤‍🔥4
Помогите Доре найти ошибку
#опытным

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

Вот такой код:

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);
}
};


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

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

Сможете найти? Пишите свои варианты в комментариях.

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

Deduce the error. Stay cool.
13👍7🤓6🔥3🤔1
Бага обнаружена!
#опытным

Проблема в текущей реализации

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👍2012🔥5❤‍🔥3
​​Как использовать std::unordered_map с ключом в виде std::pair?
#опытным

При работе над задачами 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 библиотеку логирования и собственно пишите:

// 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🔥129
Они про скорость и многозадачность: асинхронные сетевые фреймворки для C++ 🚀

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

Сравнительный анализ популярных С++ фреймворков, работающих как раз по такой парадигме, уже в карточках. Сделали, кстати, совместно с каналом Грокаем C++: там много полезного для разработчиков си-плюс-плюс, так что заходите!

#BellintegratorTeam #советыBell
Please open Telegram to view this post
VIEW IN TELEGRAM
Please open Telegram to view this post
VIEW IN TELEGRAM
👍259🔥9👎1
​​Inline виртуальные методы
#опытным

В догонку предыдущего поста. Если можно сделать обычные методы 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
113👍6🔥6❤‍🔥2