https://en.cppreference.com/w/cpp/filesystem/exists
Когда только начинаешь учить С++ после высокоуровневых языка самое забавное — насколько иногда простые вещи в этом языке сложно исполнять.
К примеру функция проверки существования файла на диске в стандартную библиотеку была добавлена только в стандарте 2017го года. До этого приходилось извращаться со всякими:
Когда только начинаешь учить С++ после высокоуровневых языка самое забавное — насколько иногда простые вещи в этом языке сложно исполнять.
К примеру функция проверки существования файла на диске в стандартную библиотеку была добавлена только в стандарте 2017го года. До этого приходилось извращаться со всякими:
ifstream f(name.c_str());
return f.good();
Еще одна проблема с переходом на низкоуровневые языки — операции с указателями (pointer).
Про это знают, наверно, все программисты, но иногда случаются ситуации, в которых с первого раза и не предположить подводных камней.
Рассмотрим следующий код:
Проблема данного кода в том, что вектор совершенно не гарантирует что данные будут все время лежать в одной и той же области памяти. В случае изменения размера — стандартная библиотека может переложить элементы в новую область памяти.
Давайте немного поменяем исходный код:
В худшем же случае данный код просто упадет в попытке произвести запись в ненадлежащий сегмент.
Про это знают, наверно, все программисты, но иногда случаются ситуации, в которых с первого раза и не предположить подводных камней.
Рассмотрим следующий код:
vector<int> v;Данный код выведет на экран значения:
v.push_back(1);
cout << v[0] << " ";
int *p = &*(--v.end());
(*p) = 2;
cout << v[0] << " ";
(*p) = 3;
cout << v[0] << " ";
1 2 3
Казалось бы все как мы и предполагали.Проблема данного кода в том, что вектор совершенно не гарантирует что данные будут все время лежать в одной и той же области памяти. В случае изменения размера — стандартная библиотека может переложить элементы в новую область памяти.
Давайте немного поменяем исходный код:
vector<int> v;В лучшем случае эта вариация выведет на экран:
v.reserve(1);
v.push_back(1);
cout << v[0] << " ";
int *p = &*(--v.end());
(*p) = 2;
cout << v[0] << " ";
v.reserve(3);
(*p) = 3;
cout << v[0] << " ";
1 2 2
, так как мы запишем последнее изменение в область памяти уже не имеющей отношения к вектору.В худшем же случае данный код просто упадет в попытке произвести запись в ненадлежащий сегмент.
Количество способов получить
Недавно я наткнулся на, кажется, самый идиотский из возможных способов которым хочу поделиться.
Допустим мы написали следующий код:
Казалось бы какие тут могут быть проблемы? И действительно структура города имеет размер
И действительно в данном коде проблем нет, давайте попробуем скомпилировать его с соответствующим санитайзером и убедиться в этом:
Дело в том, что примитивные типа должны записываться в ячейки с адресом значение которого целочисленно делится на выравнивание соответствующего типа.
Возьмем для примера long — выравнивание данного типа равно 8 байтам (на архитектуре x86-64), это значит что значение типа long должно начинаться с адресов делящихся на 8 (0, 8, 16, 24, etc). Тем не менее в мире С++ никто не останавливает нас от интерпретации любого случайного адреса памяти как long. Однако подобная интерпретация собственно и приводит к undefined behavior. Дело в том, что выравнивание памяти важно при низкоуровневых операциях, таких как подкачка кэша процессора. Соответственно неверное выравнивание может привести в дальнейшем к ошибкам в вычислениях.
Что же произошло в нашем случае?
В изначальной структуре long значение идентификатора города (в виде массива cityInfo) находилось в ячейке со смещением 0, что подходит под условия выравнивания. Однако после добавления int свойства (с размером в 4 байта), массив с информацией о городе сместился в ячейку 4, что собственно и привело к UB.
Что можно сделать в данном случае?
Мы можем вручную попросить компилятор использовать заданное смещение для свойства в структуре:
А что если мы не хотим тратить лишнюю память?
undefined behavior
в С++ порой поражает.Недавно я наткнулся на, кажется, самый идиотский из возможных способов которым хочу поделиться.
Допустим мы написали следующий код:
#include <iostream>Все казалось бы очевидно: у нас есть структура с информацией о каком то человеке (
struct Person {
char cityInfo[4];
};
struct City {
long id;
};
int main() {
Person me;
City *city = (City*) &me.cityInfo;
std::cout << city->id << std::endl;
return 0;
}
Person
), частью этой информации является байт массив с информацией о городе в котором человек живет (cityInfo
). Все что мы хотим сделать в данном коде — интерпретировать байт массив города в виде структуры (City
) и вывести на экран идентификатор этого города.Казалось бы какие тут могут быть проблемы? И действительно структура города имеет размер
sizeof(long)
, что в любой ситуации меньше sizeof(char[4]), а следовательно мы спокойно можем интерпретировать массив соответствующим образом.И действительно в данном коде проблем нет, давайте попробуем скомпилировать его с соответствующим санитайзером и убедиться в этом:
clang++ -fsanitize=undefined good_sample.cc && ./a.outТеперь давайте добавим в структуру с человеком информацию о его возрасте:
struct Person {Если же мы теперь попробуем перекомпилировать пример и выполнить его мы получим следующую ошибку:
int age;
char cityInfo[4];
};
clang++ -fsanitize=undefined ub_sample.cc && ./a.outВ чем же тут проблема? А проблема тут возникает в выравнивании памяти (alignment).
ub_sample.cc:17:24: runtime error: member access within misaligned address 0x7ffd07a42df4 for type 'City', which requires 8 byte alignment
0x7ffd07a42df4: note: pointer points here
f0 2e a4 07 fd 7f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 83 70 31 84 a6 7f 00 00
^
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub_sample.cc:17:24 in
ub_sample.cc:17:24: runtime error: load of misaligned address 0x7ffd07a42df4 for type 'long', which requires 8 byte alignment
0x7ffd07a42df4: note: pointer points here
f0 2e a4 07 fd 7f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 83 70 31 84 a6 7f 00 00
^
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior ub_sample.cc:17:24 in
32765
Дело в том, что примитивные типа должны записываться в ячейки с адресом значение которого целочисленно делится на выравнивание соответствующего типа.
Возьмем для примера long — выравнивание данного типа равно 8 байтам (на архитектуре x86-64), это значит что значение типа long должно начинаться с адресов делящихся на 8 (0, 8, 16, 24, etc). Тем не менее в мире С++ никто не останавливает нас от интерпретации любого случайного адреса памяти как long. Однако подобная интерпретация собственно и приводит к undefined behavior. Дело в том, что выравнивание памяти важно при низкоуровневых операциях, таких как подкачка кэша процессора. Соответственно неверное выравнивание может привести в дальнейшем к ошибкам в вычислениях.
Что же произошло в нашем случае?
В изначальной структуре long значение идентификатора города (в виде массива cityInfo) находилось в ячейке со смещением 0, что подходит под условия выравнивания. Однако после добавления int свойства (с размером в 4 байта), массив с информацией о городе сместился в ячейку 4, что собственно и привело к UB.
Что можно сделать в данном случае?
Мы можем вручную попросить компилятор использовать заданное смещение для свойства в структуре:
struct Person {По сути это будет равносильно добавлению еще одного int значения перед нашим массивом, компилятор просто выделит еще 4 байта между свойствами и проинициализирует их нулями.
int age;
char cityInfo[4] __attribute__ ((aligned (8)));
};
А что если мы не хотим тратить лишнюю память?
Продолжение.
В таком случае нам на помощь приходит std::bit_cast (https://en.cppreference.com/w/cpp/numeric/bit_cast), либо его аналог написанный вручную:
Все примеры вы можете найти тут: https://github.com/PatriosTheGreat/cppchannel/tree/main/ub_cast
В таком случае нам на помощь приходит std::bit_cast (https://en.cppreference.com/w/cpp/numeric/bit_cast), либо его аналог написанный вручную:
#include <cstdint>И хоть в данном случае мы и выделили на стеке память под новую структуру, в реальности в большинстве случаев компилятор может оптимизировать данное выделение.
#include <cstring>
#include <iostream>
struct Person {
int age;
char cityInfo[4];
};
struct City {
long id;
};
int main() {
Person me;
City city;
std::memcpy(&city, &me.cityInfo, sizeof(City));
std::cout << city.id << std::endl;
return 0;
}
Все примеры вы можете найти тут: https://github.com/PatriosTheGreat/cppchannel/tree/main/ub_cast
GitHub
cppchannel/ub_cast at main · PatriosTheGreat/cppchannel
Contribute to PatriosTheGreat/cppchannel development by creating an account on GitHub.
Грабли со static_cast
Недавно наткнулся на забавную багу в коде, которая заставила задуматься над поведением, казалось бы банального,
Давайте рассмотрим следующее дерево наследования:
Давайте теперь сделаем
В теории
Полный код доступен тут: https://godbolt.org/z/Yvr5oMd7h
Странным образом результатом такого вызова будет вполне корректный возврат значения 0. Впрочем если у вас опыта с плюсами побольше моего – для вас ничего странного в этом нет. Компилятор просто вставил вызов метода
Тут логично возникает два вопроса:
1. Что будет если метод foo станет виртуальным?
2. Что будет если метод foo решит использовать свойства класса
Ответ на первый вопрос я для краткости заметки отложу на следующий раз, а сейчас давайте попробуем использовать какое-нибудь свойство:
Полный код: https://godbolt.org/z/raccns5hs
Результатом подобного вызова будет считывание случайного куска памяти. Чтобы понять как это работает давайте посмотрим на ассемблер:
В данном случае в rbp лежит указатель на объект класса
По сути передав в этот метод объект класса
https://godbolt.org/z/b561Yajqa
И действительно результатом данной программы будет 42.
В следующий раз я попробую поиграться с виртуальными методами и посмотреть как компилятор будет вести себя в этом случае. А в качестве вывода статьи скажу, что если вы не уверены что лежит под указателем на родительский класс – пользуйтесь dynamic_cast, хоть это и дороже и требует RTTI.
Недавно наткнулся на забавную багу в коде, которая заставила задуматься над поведением, казалось бы банального,
static_cast
.Давайте рассмотрим следующее дерево наследования:
struct Base {};
struct ActualChild : Base {};
struct OtherChild : Base {};
Давайте теперь сделаем
static_cast
между братскими классами:ActualChild obj;
ActualChild* actual_ptr = &obj;
Base* base_ptr = actual_ptr;
OtherChild* other_ptr = static_cast<OtherChild*>(base_ptr);
В теории
static_cast
не должен делать ничего специфичного. Он просто интерпретирует память как объект данного типа. Пока что вроде особых проблем нет, но давайте добавим в класс OtherChild
метод и попробуем его вызвать:struct OtherChild : Base {
int foo() { return 0; }
};
—
other_ptr->foo();
Полный код доступен тут: https://godbolt.org/z/Yvr5oMd7h
Странным образом результатом такого вызова будет вполне корректный возврат значения 0. Впрочем если у вас опыта с плюсами побольше моего – для вас ничего странного в этом нет. Компилятор просто вставил вызов метода
OtherChild::foo()
и передал ему соответствующий указатель в качестве свойства this. Это можно и увидеть в окне с ассемблером в следующих строках:mov rdi, qword ptr [rbp - 32]
call OtherChild::foo()
Тут логично возникает два вопроса:
1. Что будет если метод foo станет виртуальным?
2. Что будет если метод foo решит использовать свойства класса
OtherClass
.Ответ на первый вопрос я для краткости заметки отложу на следующий раз, а сейчас давайте попробуем использовать какое-нибудь свойство:
struct OtherChild : Base {
int foo() { return value; }
int value;
};
…
other_ptr->foo();
Полный код: https://godbolt.org/z/raccns5hs
Результатом подобного вызова будет считывание случайного куска памяти. Чтобы понять как это работает давайте посмотрим на ассемблер:
mov rax, qword ptr [rbp - 8]
mov eax, dword ptr [rax]
В данном случае в rbp лежит указатель на объект класса
ActualChild
(this). Компилятор же в данном участке думает что он работает с OtherChild
. Для этого класса он знает, что значение value лежит в первых 4 байтах.По сути передав в этот метод объект класса
ActualChild
мы просто считаем память лежащую в первых 4 байт от этого указателя. Давайте собственно проверим так ли это следующим кодом:struct ValueHolder {
int value = 42;
};
int main() {
ValueHolder holder;
OtherChild* other_ptr =
static_cast<OtherChild*>(
(void*)&holder);
return other_ptr->foo();
}
https://godbolt.org/z/b561Yajqa
И действительно результатом данной программы будет 42.
В следующий раз я попробую поиграться с виртуальными методами и посмотреть как компилятор будет вести себя в этом случае. А в качестве вывода статьи скажу, что если вы не уверены что лежит под указателем на родительский класс – пользуйтесь dynamic_cast, хоть это и дороже и требует RTTI.
godbolt.org
Compiler Explorer - C++ (x86-64 clang 17.0.1)
struct Base {};
struct ActualChild : Base {};
struct OtherChild : Base {
int foo() { return 0; }
};
int main() {
ActualChild obj;
ActualChild* actual_ptr = &obj;
Base* base_ptr = actual_ptr;
OtherChild* other_ptr = static_cast<Other…
struct ActualChild : Base {};
struct OtherChild : Base {
int foo() { return 0; }
};
int main() {
ActualChild obj;
ActualChild* actual_ptr = &obj;
Base* base_ptr = actual_ptr;
OtherChild* other_ptr = static_cast<Other…
Продолжение граблей со static_cast
В прошлый раз мы выяснили что при касте к братскому классу компилятор вставит вызов метода того типа которым помечен объект.
Рассмотрим данный код:
https://godbolt.org/z/E1z194zf6
Результатом программы будет 2 и в окне ассемблера мы видим что компилятор вставил напрямую вызов
Давайте теперь посмотрим что будет в случае если метод станет виртуальным. Для этого нам надо поменять только структуру Base:
https://godbolt.org/z/1Kh8qfcfs
Теперь результатом будет 1.
Но каким образом компилятор определил какой метод вызвать? Ответ на это мы найдем в окне ассемблера в следующих строках (7-12 строки):
Последняя строка собственно является вызовом метода ActualChild::foo, однако как видно метод вызывается не напрямую, а через указатель на область памяти который лежит в регистре rax.
Давайте попробуем понять какой именно адрес лежит в этой строке. Как видно из второй строчки – адрес достается из регистра
Мы видим до 23й строки определенные операции которые нас не сильно интересуют. Далее мы видим вызов конструктора базового класса (строка 23). Затем в инструкции
После этого к адресу добавляется смещение в 16 (26 строка) и получившийся адрес собственно и кладется на стек, откуда мы его далее и получаем.
Зачем компилятору нужно смещение в 16? Для ответа на этот вопрос давайте собственно посмотрим что такое виртуальная таблица
Итак в первых 8 битах лежит 0 (честно говоря не знаю зачем, возможно это результат отсутствия оптимизаций, я собирал с O0). Далее лежит некоторая RTTI информация о классе, и после этого на расстоянии в 16 бит начинается список указателей на непосредственно виртуальные методы. Таким образом мы получаем указатель на
Дописав эту заметку, я вдруг понял, что по сути она не имеет ничего общего со static_cast. Я просто разобрал как работают таблицы виртуальных методов. Извиняюсь за небольшое вранье в названии.
В прошлый раз мы выяснили что при касте к братскому классу компилятор вставит вызов метода того типа которым помечен объект.
Рассмотрим данный код:
struct Base {};
struct ActualChild : Base {
int foo() { return 1; }
};
struct OtherChild : Base {
int foo() { return 2; }
};
int main() {
ActualChild actual;
OtherChild* other_ptr = static_cast<OtherChild*>((Base*)&actual);
return other_ptr->foo();
}
https://godbolt.org/z/E1z194zf6
Результатом программы будет 2 и в окне ассемблера мы видим что компилятор вставил напрямую вызов
OtherChild::foo()
(9я строка).Давайте теперь посмотрим что будет в случае если метод станет виртуальным. Для этого нам надо поменять только структуру Base:
struct Base {
virtual int foo() = 0;
};
https://godbolt.org/z/1Kh8qfcfs
Теперь результатом будет 1.
Но каким образом компилятор определил какой метод вызвать? Ответ на это мы найдем в окне ассемблера в следующих строках (7-12 строки):
call ActualChild::ActualChild() [base object constructor]
lea rax, [rbp - 16]
…
call qword ptr [rax]
Последняя строка собственно является вызовом метода ActualChild::foo, однако как видно метод вызывается не напрямую, а через указатель на область памяти который лежит в регистре rax.
Давайте попробуем понять какой именно адрес лежит в этой строке. Как видно из второй строчки – адрес достается из регистра
rbp
со смещением в 16. На всякий напомню что регистр rbp используется для хранения указателя на текущую голову стека. То есть конструктор ActualChild положил в стек указатель на соответствующую функцию foo. Давайте найдем как он это сделал. Для этого посмотрим на ассемблер данного конструктора (строки 16-30):ActualChild::ActualChild() [base object constructor]:
…
call Base::Base() [base object constructor]
…
lea rcx, [rip + vtable for ActualChild]
add rcx, 16
mov qword ptr [rax], rcx
add rsp, 16
pop rbp
ret
Мы видим до 23й строки определенные операции которые нас не сильно интересуют. Далее мы видим вызов конструктора базового класса (строка 23). Затем в инструкции
lea rcx, [rip + vtable for ActualChild]
мы видимо, что в регистр rcx кладется указатель на глобальный объект vtable_for_ActualChild
(rip
это специальная инструкция конкретно для Intel которая позволяет брать адреса объектов в глобальной области).После этого к адресу добавляется смещение в 16 (26 строка) и получившийся адрес собственно и кладется на стек, откуда мы его далее и получаем.
Зачем компилятору нужно смещение в 16? Для ответа на этот вопрос давайте собственно посмотрим что такое виртуальная таблица
vtable for ActualChild
в глобальной области. Для этого прокрутим ассемблер вниз до 48й строки где видим следующее:vtable for ActualChild:
.quad 0
.quad typeinfo for ActualChild
.quad ActualChild::foo()
Итак в первых 8 битах лежит 0 (честно говоря не знаю зачем, возможно это результат отсутствия оптимизаций, я собирал с O0). Далее лежит некоторая RTTI информация о классе, и после этого на расстоянии в 16 бит начинается список указателей на непосредственно виртуальные методы. Таким образом мы получаем указатель на
ActualChild::foo()
который конструктор ActualChild положит в сам объект и который будет далее вызван компилятором.Дописав эту заметку, я вдруг понял, что по сути она не имеет ничего общего со static_cast. Я просто разобрал как работают таблицы виртуальных методов. Извиняюсь за небольшое вранье в названии.
godbolt.org
Compiler Explorer - C++ (x86-64 clang 17.0.1)
struct Base {};
struct ActualChild : Base {
int foo() { return 1; }
};
struct OtherChild : Base {
int foo() { return 2; }
};
int main() {
ActualChild actual;
OtherChild* other_ptr = static_cast<OtherChild*>((Base*)&actual);
return other_ptr…
struct ActualChild : Base {
int foo() { return 1; }
};
struct OtherChild : Base {
int foo() { return 2; }
};
int main() {
ActualChild actual;
OtherChild* other_ptr = static_cast<OtherChild*>((Base*)&actual);
return other_ptr…
Битовые флаги
Недавно наткнулся в одной библиотеке на интересный способ объявления и использования битовых флагов которым хочу поделиться. Но перед тем как перейти к нему хотел бы показать как я бы лично их реализовал и чем мой метод хуже.
Для начала определение – под битовыми флагами я имею ввиду именованные свойства с которыми можно проводить битовые операции (побитовое И, побитовое ИЛИ и т.д). Определение наверно только запутало давайте лучше к примеру как я бы это реализовал:
https://godbolt.org/z/c9fevjEYG
Как видно из примера такие флаги очень удобно комбинировать, проверять на наличие свойства и т.д. Ну впрочем вы и сами явно в курсе. Давайте лучше сфокусируемся на недостатках этого кода. А их много:
1. Необходимо кастовать значение enum к int перед каждой битовой операцией:
2. Необходимо корректно инициализировать значения enum степенями двойки.
3. Битовые выражение для проверки наличия свойства которые сложно читать
А теперь собственно глянем как подобное может быть реализовано удобнее:
https://godbolt.org/z/TxxEs71hP
На всякий если кто-то не понял что произошло объясню. Union позволяет объявить несколько альтернативных способов доступа к одному и тому же куску памяти. В данном случае первое свойство структуры File будет иметь размер Int (самый большой тип под Union), но при этом эту же область памяти можно будет в коде интерпретировать как второй заданный тип юниона. При этом второй тип в свою очередь состоит из 4 значений каждое из которых занимает ровно 1 бит памяти.
Таким образом мы можем одновременно использовать кусок памяти выделенный под свойство permissions и как целочисленное число (что позволит нам проводить битовые операции) и как набор битовых полей, что упростит нам проверку на наличие какого-то свойства.
Конечно этот пример не во всех случаях лучше исходного решения. Как минимум вопрос инициализации как видно тут решен не очень красиво, однако это еще один инструмент в копилке возможностей языка.
Недавно наткнулся в одной библиотеке на интересный способ объявления и использования битовых флагов которым хочу поделиться. Но перед тем как перейти к нему хотел бы показать как я бы лично их реализовал и чем мой метод хуже.
Для начала определение – под битовыми флагами я имею ввиду именованные свойства с которыми можно проводить битовые операции (побитовое И, побитовое ИЛИ и т.д). Определение наверно только запутало давайте лучше к примеру как я бы это реализовал:
struct File {
int permissions;
enum class ACLs {
read = 1,
write = 2,
remove = 4,
rename = 8
};
};
int main() {
File file;
file.permissions = (int)File::ACLs::read | (int)File::ACLs::write; // Файл в который можно читать и писать но не переименовывать или удалять
if (file.permissions & (int)File::ACLs::remove) { // Удалить файл если есть права на удаление
// removeFile(file);
}
}
https://godbolt.org/z/c9fevjEYG
Как видно из примера такие флаги очень удобно комбинировать, проверять на наличие свойства и т.д. Ну впрочем вы и сами явно в курсе. Давайте лучше сфокусируемся на недостатках этого кода. А их много:
1. Необходимо кастовать значение enum к int перед каждой битовой операцией:
(int)File::ACLs::read | (int)...
2. Необходимо корректно инициализировать значения enum степенями двойки.
3. Битовые выражение для проверки наличия свойства которые сложно читать
А теперь собственно глянем как подобное может быть реализовано удобнее:
struct File {
union {
int permissions;
struct { int read:1, write:1, remove:1, rename:1; };
};
};
int main() {
File file;
file.permissions = 0b11; // Установить биты отвечающие за read и write
if (file.remove) { // Удалить файл если есть права на удаление
// removeFile(file);
}
}
https://godbolt.org/z/TxxEs71hP
На всякий если кто-то не понял что произошло объясню. Union позволяет объявить несколько альтернативных способов доступа к одному и тому же куску памяти. В данном случае первое свойство структуры File будет иметь размер Int (самый большой тип под Union), но при этом эту же область памяти можно будет в коде интерпретировать как второй заданный тип юниона. При этом второй тип в свою очередь состоит из 4 значений каждое из которых занимает ровно 1 бит памяти.
Таким образом мы можем одновременно использовать кусок памяти выделенный под свойство permissions и как целочисленное число (что позволит нам проводить битовые операции) и как набор битовых полей, что упростит нам проверку на наличие какого-то свойства.
Конечно этот пример не во всех случаях лучше исходного решения. Как минимум вопрос инициализации как видно тут решен не очень красиво, однако это еще один инструмент в копилке возможностей языка.
godbolt.org
Compiler Explorer - C++ (x86-64 clang 17.0.1)
struct File {
int permissions;
enum class ACLs {
read = 1,
write = 2,
remove = 4,
rename = 8
};
};
int main() {
File file;
file.permissions = (int)File::ACLs::read | (int)File::ACLs::write; // Файл…
int permissions;
enum class ACLs {
read = 1,
write = 2,
remove = 4,
rename = 8
};
};
int main() {
File file;
file.permissions = (int)File::ACLs::read | (int)File::ACLs::write; // Файл…
Перегрузка операторов (часть 1)
Все вы в курсе, что создатели С++ посчитав свой язык слишком простым к освоению, решили добавить в него парочку, так сказать, интересных возможностей. Одной из таких возможностей является перегрузка операторов. Все мы знаем, что в отличи от той же Java, эта фича дает возможность C++ разработчикам писать свои удобные к использованию коллекции, умные указатели и многое другое. Но есть парочка операторов, которые в здравом уме, скорее всего, вы бы даже использовать не стали, но оказывается их вполне себе можно перегрузить. Давайте глянем на пару из них.
Для начала давайте глянем на операторы доступа к свойствам объекта. К таким операторам относятся оператор разыменования (
Казалось бы, если у нас есть некоторое свойство класса которое является указателем в память, то его разыменование должно происходить так:
Вот собственно что бы пользоваться такими указателями и существует оператор
Потрясающе. И зачем мне перегружать этот оператор? Честно говоря я без понятия. На просторах интернета я нашел только этот вразумительный случай использования: https://stackoverflow.com/questions/2696864/are-free-operator-overloads-evil. Для простоты статьи я не буду вдаваться в объяснение, если вам интересно, почитайте первый же ответ описывающий библиотеку Boost.Phoenix. А мы двинемся дальше.
Операторы сравнения. Опять же, очевидно что можно перегрузить операторы
https://godbolt.org/z/1qs14eeoz
Результат этого кода следующий:
Итак оператор
Вот так может выглядеть класс двухмерных координат с использованием данного оператора:
https://godbolt.org/z/6W9fzKxcE
Все вы в курсе, что создатели С++ посчитав свой язык слишком простым к освоению, решили добавить в него парочку, так сказать, интересных возможностей. Одной из таких возможностей является перегрузка операторов. Все мы знаем, что в отличи от той же Java, эта фича дает возможность C++ разработчикам писать свои удобные к использованию коллекции, умные указатели и многое другое. Но есть парочка операторов, которые в здравом уме, скорее всего, вы бы даже использовать не стали, но оказывается их вполне себе можно перегрузить. Давайте глянем на пару из них.
Для начала давайте глянем на операторы доступа к свойствам объекта. К таким операторам относятся оператор разыменования (
*
), взятие ссылки на (&
), или доступа к свойству по адресу (->
). Вы скорее всего знаете что эти операторы перегружаемые, что дает возможность реализовывать простые к использованию умные указатели. Однако один оператор меня тут удивил – оператор разыменования свойства класса (->*
). Согластно cppref (https://en.cppreference.com/w/cpp/language/operator_member_access) В обычной жизни (без перегрузки) этот оператор равносилен конструкции (*object).*field_ptr
. Тут лично у меня возник логичный вопрос, а зачем собственно разыменовывать свойство объекта?Казалось бы, если у нас есть некоторое свойство класса которое является указателем в память, то его разыменование должно происходить так:
*object->field_ptr
. И я был абсолютно прав, однако оказалось что в С++
существует такая концепция как указатели на свойства класса. Давайте рассмотрим что это такое на следующем коде:class A {
public:
A() { field = 0; }
int field;
};
int main() {
A a;
A *a_ptr = &a;
int (A::*field_ptr); // Вот это собственно указатель на свойство
field_ptr = &A::field; // А теперь оно указывет на свойство field
}
Вот собственно что бы пользоваться такими указателями и существует оператор
->*
который можно использовать как:return a_ptr->*field_ptr;
Потрясающе. И зачем мне перегружать этот оператор? Честно говоря я без понятия. На просторах интернета я нашел только этот вразумительный случай использования: https://stackoverflow.com/questions/2696864/are-free-operator-overloads-evil. Для простоты статьи я не буду вдаваться в объяснение, если вам интересно, почитайте первый же ответ описывающий библиотеку Boost.Phoenix. А мы двинемся дальше.
Операторы сравнения. Опять же, очевидно что можно перегрузить операторы
<
, >
, =
, но какого было мое удивление узнать, что начиная с С++20 существует оператор трехстороннего сравнения <=>
. Давайте для начала поймем что он делает без перегрузок:https://godbolt.org/z/1qs14eeoz
auto comp = 1 <=> 2;
std::cout << typeid(comp).name() << std::endl;
std::cout << "Is lower: " << (comp < 0) << std::endl;
std::cout << "Is bigger: " << (comp > 0) << std::endl;
std::cout << "Is equal: " << (comp == 0) << std::endl;
Результат этого кода следующий:
std::strong_ordering
Is lower: 1
Is bigger: 0
Is equal: 0
Итак оператор
<=>
возвращает объект некого типа std::strong_ordering
либо std::weak_ordering
который можно сравнить с 0 операторами <
, >
, ‘==’ и который по сути реализует метод int compare
. По сути перегрузка данного оператора позволяет нам одним методом реализовать целых 5 операторов: ==
, !=
, <=
, >
, >=
.Вот так может выглядеть класс двухмерных координат с использованием данного оператора:
https://godbolt.org/z/6W9fzKxcE
#include <compare>
#include <iostream>
struct Point {
int x, y;
friend constexpr std::strong_ordering operator<=>(Point lhs, Point rhs)
{
if (lhs.x < rhs.x or (lhs.x == rhs.x and lhs.y < rhs.y))
return std::strong_ordering::less;
if (lhs.x > rhs.x or (lhs.x == rhs.x and lhs.y > rhs.y))
return std::strong_ordering::greater;
return std::strong_ordering::equivalent;
}
};
int main() {
Point p1{0,0};
Point p2{0,-1};
Point p3{1,0};
std::cout << (p1 < p2) << " " << (p2 <= p3) << " " << (p3 > p1) << std::endl;
}
✍2❤1🔥1
Перегрузка операторов (часть 2)
Ладно, вы еще не устали? Тогда вот вам на десерт самый абсурдный оператор. Оператор перечисления
Что тут делает этот оператор? Правильно, вычисляет по цепочке все выражения и возвращает самое правое. Давайте попробуем его перегрузить:
Результатом данной программы будет 1, то есть мы заставили вернуть левый объект вместо правого. Отлично, а нафига это нужно? Ну есть парочка интересных примеров (которые можно глянуть тут: https://stackoverflow.com/questions/5602112/when-to-overload-the-comma-operator). Вот один из них: Boost.assign умеет делать так:
Перегрузив запятую позволяет создавать цепочки на добавление элементов в коллекцию.
Ну что же, теперь вы знаете на 3 бессмысленных фичи С++ больше. Поздравляю вас, еще пару тысяч таких и мы с вами научимся проходить самые душные C++ интервью!
Ладно, вы еще не устали? Тогда вот вам на десерт самый абсурдный оператор. Оператор перечисления
,
. Да, да, его можно перегрузить. Давайте для начала вспомним когда он может использоваться. Ну например в следующих строчках:int main() {
int a = 0;
int b = 0;
for (a = 0, b = 0; a < 2 && b < 2; a++, b++);
return a, b;
}
Что тут делает этот оператор? Правильно, вычисляет по цепочке все выражения и возвращает самое правое. Давайте попробуем его перегрузить:
struct Crazy {
int a;
int operator,(Crazy &ob2)
{
return a;
}
};
int main() {
Crazy a{1};
Crazy b{2};
return a, b;
}
Результатом данной программы будет 1, то есть мы заставили вернуть левый объект вместо правого. Отлично, а нафига это нужно? Ну есть парочка интересных примеров (которые можно глянуть тут: https://stackoverflow.com/questions/5602112/when-to-overload-the-comma-operator). Вот один из них: Boost.assign умеет делать так:
vector<int> v;
v += 1,2,3,4,5,6,7,8,9;
Перегрузив запятую позволяет создавать цепочки на добавление элементов в коллекцию.
Ну что же, теперь вы знаете на 3 бессмысленных фичи С++ больше. Поздравляю вас, еще пару тысяч таких и мы с вами научимся проходить самые душные C++ интервью!
👍2✍1❤1🔥1
Оптимизация с использованием данных профилятора. Часть 1
Я долго думал как подступиться к этой теме и понял, что она слишком обширна для одной статьи. Так что здесь я фокусируюсь на общих аспектах зачем оптимизации с использованием данных профилятора (далее PGO) нужны и как этим делом пользоваться. В дальнейших статьях (которые я надеюсь будут) я покажу реальные примеры использование этой техники и погружусь в детали принятия решения оптимизатором.
Как вы знаете
Для начала коротко про подстановку функций. Допустим у нас есть следующий код:
При прямолинейной компиляции этого кода мы получим что-то типа:
Недостаток этого кода в том, что прыжок (call) является накладной операцией, так как переходы по сегментам исполняемого файла влияют на эффективность исполнение и компиляторы стараются сводить их к минимуму.
Если мы соберем этот пример с флагом -O2 мы увидим следующий код:
То есть компилятор подставил код самой функции вместо ее вызовы.
У вас скорее всего возник вопрос “а почему бы тогда не подставлять вообще все функции в программе?”. Хороший вопрос, однако большинство функций в реальном коде достаточно большие и вызываются во многих местах кода, если подставлять их всех везде размер исполняемого файла очень быстро станет слишком большим. Компиляторы стараются соблюдать некий компромисс между скоростью исполнения и размером бинарного файла. Для этого есть понятие квоты подстановок и при обычных условиях компилятор руководствуется рядом эвристик для того чтобы решить какой из методов подставить. Допустим в следующем примере:
При прочих равных если у компилятора осталась одна квота на подстановку, то он выберет функцию
Однако что если в реальной жизни пользователь намного чаще вызывает эту программу с одним аргументом? Проще говоря, если функция
Способов получения этой информации несколько, один из них – использование стороннего профилятора во время выполнения для сбора семплирования. В линуксе таким профилятором для CPU служит утилита
Я долго думал как подступиться к этой теме и понял, что она слишком обширна для одной статьи. Так что здесь я фокусируюсь на общих аспектах зачем оптимизации с использованием данных профилятора (далее PGO) нужны и как этим делом пользоваться. В дальнейших статьях (которые я надеюсь будут) я покажу реальные примеры использование этой техники и погружусь в детали принятия решения оптимизатором.
Как вы знаете
clang
является оптимизирующим компилятором. То есть он пытается перепаковать исходный код в нечто наиболее эффективное для конкретной архитектуры процессора. И для большей части оптимизаций знания самого кода вполне достаточно. Однако существует ряд оптимизаций, которые могут работать эффективнее в случае если на этапе компиляции известно как код используется во время исполнения. К таким оптимизациям относятся к примеру: девиртуализация вызовов виртуальных методов (devitualization of virtual methods), порядок расположения блоков кода в исполняемом файле (instructions memory layout) и подстановка функций (functions inling). Для простоты статьи я остановлюсь подробнее на последней оптимизации, про первые две есть неплохое видео на ютуб, ссылку на которое я оставлю в конце статьи.Для начала коротко про подстановку функций. Допустим у нас есть следующий код:
int foo(int arg) {
return arg + 1;
}
int main(int argc, char*argv[]) {
return foo(argc); // Место вызова функции
}
При прямолинейной компиляции этого кода мы получим что-то типа:
foo(int):
lea eax, [rdi + 1] // Логика функции
ret
main:
... // Инициализация аргументов
call foo(int) // Прыжок в область кода с реализацией функции
ret
Недостаток этого кода в том, что прыжок (call) является накладной операцией, так как переходы по сегментам исполняемого файла влияют на эффективность исполнение и компиляторы стараются сводить их к минимуму.
Если мы соберем этот пример с флагом -O2 мы увидим следующий код:
main:
lea eax, [rdi + 1]
ret
То есть компилятор подставил код самой функции вместо ее вызовы.
У вас скорее всего возник вопрос “а почему бы тогда не подставлять вообще все функции в программе?”. Хороший вопрос, однако большинство функций в реальном коде достаточно большие и вызываются во многих местах кода, если подставлять их всех везде размер исполняемого файла очень быстро станет слишком большим. Компиляторы стараются соблюдать некий компромисс между скоростью исполнения и размером бинарного файла. Для этого есть понятие квоты подстановок и при обычных условиях компилятор руководствуется рядом эвристик для того чтобы решить какой из методов подставить. Допустим в следующем примере:
int foo(int arg) {
return arg + 1;
}
int bar(int arg) {
int c = arg * 2;
int d = c + arg * 3;
return c + d;
}
int main(int argc, char*argv[]) {
int result;
for (int i = 0; i < 1000000000; i++) {
if (argc > 2) {
result += foo((int)argv[2][0]);
} else {
result += bar((int)argv[1][0]);
}
}
return result;
}
При прочих равных если у компилятора осталась одна квота на подстановку, то он выберет функцию
foo
так как она короче (я сейчас игнорирую остальные оптимизации).Однако что если в реальной жизни пользователь намного чаще вызывает эту программу с одним аргументом? Проще говоря, если функция
bar
вызывается намного чаще, чем функция foo
то ее подстановка становится более выгодной. Но что бы понять это компилятору нужна информация о том как программа используется пользователем.Способов получения этой информации несколько, один из них – использование стороннего профилятора во время выполнения для сбора семплирования. В линуксе таким профилятором для CPU служит утилита
perf
.🔥1👀1
Оптимизация с использованием данных профилятора. Часть 2
Давайте посмотрим как это может работать.
Шаг 1: Компилируем программу с несколькими флагами позволяющими сопоставить собранные метрики с исходным кодом:
Шаг 2: Исполняем собранную программу с профилятором:
Шаг 3: Так как perf это внешняя для llvm утилита, нам нужно сконвертировать результат ее работы в формат понятный для компилятора. Для этого в llvm есть утилита
Шаг 4: Перекомпилируем исходную программу с собранной статистикой:
Давайте глянем на собранную статистику профилятора. Для этого в llvm есть другая утилита
Команда выше покажет нам следующее:
Имея эту информацию на руках компилятор может понять что функция bar вызывается намного чаще чем функция foo и проработать эффективнее. Важное замечание: Если сценарий использования программы в реальной жизни резко меняется, то результат сбора PGO данных может повлиять негативно на производительность программы, так что чаще всего в реальной жизни PGO данные пере-собираются в реальном времени.
В следующей части я попробую собрать сам clang вместе и без PGO и сравню производительность сборки какого нибудь примера.
На последок видео о котором я говорил в начале: https://www.youtube.com/watch?v=3RtMMHkVsDg
Давайте посмотрим как это может работать.
Шаг 1: Компилируем программу с несколькими флагами позволяющими сопоставить собранные метрики с исходным кодом:
clang++ -O2 -gline-tables-only -fdebug-info-for-profiling -funique-internal-linkage-names sample.cc -o sample
Шаг 2: Исполняем собранную программу с профилятором:
sudo perf record -b -e BR_INST_RETIRED.NEAR_TAKEN:uppp ./sample 1
Шаг 3: Так как perf это внешняя для llvm утилита, нам нужно сконвертировать результат ее работы в формат понятный для компилятора. Для этого в llvm есть утилита
llvm-profgen
:llvm-profgen --binary=./sample --output=sample.prof --perfdata=perf.data
Шаг 4: Перекомпилируем исходную программу с собранной статистикой:
clang++ -O2 -fprofile-sample-use=sample.prof sample.cc -o sample_pgo
Давайте глянем на собранную статистику профилятора. Для этого в llvm есть другая утилита
llvm-profdata
которая позволяет объединять статистики, смотреть их содержимое и многое другое:llvm-profdata show --sample sample.prof
Команда выше покажет нам следующее:
Function: main: 1965000, 0, 7 sampled lines
Samples collected in the function's body {
…
6: 82500, calls: _Z3bari:82500
…
}
Имея эту информацию на руках компилятор может понять что функция bar вызывается намного чаще чем функция foo и проработать эффективнее. Важное замечание: Если сценарий использования программы в реальной жизни резко меняется, то результат сбора PGO данных может повлиять негативно на производительность программы, так что чаще всего в реальной жизни PGO данные пере-собираются в реальном времени.
В следующей части я попробую собрать сам clang вместе и без PGO и сравню производительность сборки какого нибудь примера.
На последок видео о котором я говорил в начале: https://www.youtube.com/watch?v=3RtMMHkVsDg
YouTube
How Profile-Guided Optimization Makes Your Code Faster Without Any Code Changes - Stephan Dollberg
#Programming #Cpp #CppOnSea
2021 Program: https://cpponsea.uk/2021/schedule/
C++ On Sea Website: https://cpponsea.uk/
C++ On Sea Twitter: https://twitter.com/cpponsea
Streamed & Edited By Digital Medium Ltd: https://events.digital-medium.co.uk
-----…
2021 Program: https://cpponsea.uk/2021/schedule/
C++ On Sea Website: https://cpponsea.uk/
C++ On Sea Twitter: https://twitter.com/cpponsea
Streamed & Edited By Digital Medium Ltd: https://events.digital-medium.co.uk
-----…
🔥7