Константная инициализация. Ч1
Это первый шаг, который пытается выполнить компилятор, когда пробует инициализировать переменную. Для него требуется, чтобы инициализатор был константным выражением. То есть его можно было бы вычислить во время компиляции. И не путать с обычным const! Позже покажу почему.
Также гарантируется, что эта инициализация происходит до любой другой инициализации статиков. На практике же компиляторы вообще сразу в бинарь помещают предвычисленное значение объекта и во время выполнения с ним уже ничего не нужно делать. Пример:
С переменной
Точнее немного не так. Она будет проиниализирована последней, но аж 2 раза! Первый раз - zero-инициализацией на этапе компиляции, второй раз - динамической в рантайме.
Чтобы не пустословить по чем зря, покажу вырезки из ассембера, которые подкрепляют мои слова. Вот чего нашел:
Дальше мы переходим к data секции, в которой подряд инициализируются
И в последнюю очередь, уже в рантайме, динамически ини
Это рантаймовая рутина, которая запускается перед вызовом main() и инициализирует
Тут можно довольно простую аналогию провести. Константная инициализаци выполняется для тех объектов, которые можно пометить constexpr, и, не учитывая весь остальной код, компиляция после этого успешно завершится.
Define order of your life. Stay cool.
#cppcore #compiler
Это первый шаг, который пытается выполнить компилятор, когда пробует инициализировать переменную. Для него требуется, чтобы инициализатор был константным выражением. То есть его можно было бы вычислить во время компиляции. И не путать с обычным const! Позже покажу почему.
Также гарантируется, что эта инициализация происходит до любой другой инициализации статиков. На практике же компиляторы вообще сразу в бинарь помещают предвычисленное значение объекта и во время выполнения с ним уже ничего не нужно делать. Пример:
constexpr double constexpr_var{1.0};
double const_intialized_var1{constexpr_var};
const double const_var{const_intialized_var1};
double const_intialized_var2{3.0};
С переменной
constexpr_var
все хорошо, константа присваивается константному выражению и инициализируется эта переменная первой. Далее устанавливается значение для const_intialized_var1
. Несмотря на то, что эта переменная не константа, ее инициализатор - константное выражение, а этого достаточно для выполнения константной инициализации. Интересно, что дальше устанавливается значение переменной const_intialized_var2
, а не const_var
. Хоть const_var
и константа, ее инициализатор не является константным выражением! Все потому, что у переменной const_intialized_var1
нет пометки const(constexpr), а значит, хоть она и проинициализирована константой, сама таковой не является. И const_var
будет инициализироваться последней уже в рантайме.Точнее немного не так. Она будет проиниализирована последней, но аж 2 раза! Первый раз - zero-инициализацией на этапе компиляции, второй раз - динамической в рантайме.
Чтобы не пустословить по чем зря, покажу вырезки из ассембера, которые подкрепляют мои слова. Вот чего нашел:
.section __DATA,__data
.globl _const_intialized_var1 ## @const_intialized_var1
.p2align 3, 0x0
_const_intialized_var1:
.quad 0x3ff0000000000000 ## double 1
.globl _const_intialized_var2 ## @const_intialized_var2
.p2align 3, 0x0
_const_intialized_var2:
.quad 0x4008000000000000 ## double 3
.section __TEXT,__const
.p2align 3, 0x0 ## @_ZL13constexpr_var
__ZL13constexpr_var:
.quad 0x3ff0000000000000 ## double 1
.zerofill __DATA,__bss,__ZL9const_var,8,3 ## @_ZL9const_var
.section __DATA,__mod_init_func,mod_init_funcs
.p2align 3, 0x0
.quad __GLOBAL__sub_I_main.cpp
constexpr_var
инициализируется в текстовой секции. Не смотрите, что эта секция расположена в середине, стандарт гарантирует, что ее инициализация произойдет первой(в ином случае const_intialized_var1
досталась бы фига).Дальше мы переходим к data секции, в которой подряд инициализируются
const_intialized_var1
и _const_intialized_var2
. И после всего этого в секции .zerofill у нас заполняется нулями const_var
.И в последнюю очередь, уже в рантайме, динамически ини
циализируется const_var.
.section __TEXT,__StaticInit,regular,pure_instructions
.p2align 4, 0x90 ## -- Begin function __cxx_global_var_init
___cxx_global_var_init: ## @__cxx_global_var_init
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movsd _const_intialized_var1(%rip), %xmm0 ## xmm0 = mem[0],zero
movsd %xmm0, __ZL9const_var(%rip)
popq %rbp
retq
.cfi_endproc
Это рантаймовая рутина, которая запускается перед вызовом main() и инициализирует
const_var
.Тут можно довольно простую аналогию провести. Константная инициализаци выполняется для тех объектов, которые можно пометить constexpr, и, не учитывая весь остальной код, компиляция после этого успешно завершится.
Define order of your life. Stay cool.
#cppcore #compiler
Константная инициализация. Ч2
То, каким образом в деталях работает константная иницализация, довольно сложно понять, просто читая стандарт и другие официальные источники. И вот почему.
Вот такой пример они дают:
И говорится, что
Но видимо при проведении константной инициализации компилятор только один раз проходит сверху вниз программы. По факту,
Еще более интересные вещи происходят в ассемблер
Это внутреннее представление этих глобальных переменных. static_class_var прям глобальная, ее могу видеть и другие единицы трансляции. Поэтому она с пометкой .globl. const_var же статическая переменная, а значит ее видно видно только из текущей единицы трансляции.
Проблема в том, что
Дело в том, что здесь замешано одно интересное право компилятора. Ему в определенных случаях разрешено устанавливать начальные значения переменным в compile-time, если он уверен, что их значения не изменится на момент начала старта программы.
Этот пример и пример из прошлого поста кстати показывают, что инициализация глобальных переменных идет не совсем сверху вниз. Для каждого этап инициализации это более менее верно, но под каждый этап попадают разные подмножества переменных. Поэтому могут возникать сайд-эффекты, подобные тем, что были в этом посте.
Accept side affects. Stay cool.
#cpcore #compiler
То, каким образом в деталях работает константная иницализация, довольно сложно понять, просто читая стандарт и другие официальные источники. И вот почему.
Вот такой пример они дают:
struct S
{
static const int static_class_var;
};
static const int const_var = 10 * S::static_class_var;
const int S::static_class_var = 5;
int main()
{
std::cout << &const_var << std::endl; // ODR-use for explicit generation of symbol
std::array<int, S::static_class_var> a1; // OK
// std::array<int, const_var> a2; // ERROR
}
И говорится, что
static_class_var
на момент создания const_var
не имеет инициализатора. А так как компилятор в первую очередь выполняет константную инициализацию, поэтому он не может сейчас установить значение для const_var
. Поэтому в начале инициализируется static_class_var
. И вот уже дальше - const_var
.Но видимо при проведении константной инициализации компилятор только один раз проходит сверху вниз программы. По факту,
static_class_var
- константа, инициализированная константным выражением. И по всем канонам должна сама стать константным выражением. Так и получается, ведь мы можем создать std::array из нее. Но вот из const_var
- не можем. Хотя эта переменная тоже проинициализирована константным выражением. Но так, как ее инициализация происходит после константной инициализации, то этот факт не дает ей шанса стать нормальным constant expression.Еще более интересные вещи происходят в ассемблер
е.
.section __TEXT,__const
.globl __ZN1S16static_class_varE ## @_ZN1S16static_class_varE
.p2align 2, 0x0
__ZN1S16static_class_varE:
.long 5 ## 0x5
.p2align 2, 0x0 ## @_ZL9const_var
__ZL9const_var:
.long 50 ## 0x32
Это внутреннее представление этих глобальных переменных. static_class_var прям глобальная, ее могу видеть и другие единицы трансляции. Поэтому она с пометкой .globl. const_var же статическая переменная, а значит ее видно видно только из текущей единицы трансляции.
Проблема в том, что
const_var
выглядит такой же compile-time константой, как и static_class_var
. Хотя по идее тут должна быть какая-нибудь zero-инициализация + динамическая в рантайме. Но array мы не можем создать с const_var
🗿.Дело в том, что здесь замешано одно интересное право компилятора. Ему в определенных случаях разрешено устанавливать начальные значения переменным в compile-time, если он уверен, что их значения не изменится на момент начала старта программы.
Этот пример и пример из прошлого поста кстати показывают, что инициализация глобальных переменных идет не совсем сверху вниз. Для каждого этап инициализации это более менее верно, но под каждый этап попадают разные подмножества переменных. Поэтому могут возникать сайд-эффекты, подобные тем, что были в этом посте.
Accept side affects. Stay cool.
#cpcore #compiler
Идея динамического полиморфизма
«Полиморфизм (от др. греч. много видов) — возможность существования чего-либо в различных формах»
Идея динамического полиморфизма заключается в том, чтобы иметь возможность обобщенно описать поведение семейства классов. Каждый класс семейства имеет общий набор действий, но может выполнять их по своему — учитывать особенности конкретного представителя семейства.
Приведу простой пример: все подписчики канала относятся к семейству Homo sapiens. Общей чертой этого вида является возможность говорить, но все говорят по-разному! Даже если мы произносим один и тот же текст, будет отличается интонация, голос, диалект, темп... Я делаю акцент на разных способах совершения одного и того же действия. Допустим, мы хотим запрограммировать это разнообразие действий. В языке есть конкретный способ, как это можно сделать легко и понятно. Для упрощенного взаимодействия с такими семействами в язык введен динамический полиморфизм.
В рамках данной темы слово семейство подходит лучше всего. Мы можем сделать иерархию классов, связанных по смыслу. Родительский класс будет обобщать поведение для всех своих наследников. Так получается семейство, где каждый класс имеет некоторое родство с другими классами: общий набор виртуальных методов. Они могут наследоваться от предков и могут быть изменены у потомков. Такой набор представляет из себя общий интерфейс для семейства классов.
Ключевое слово
Реализация виртуального метода может быть изменена у наследников класса:
Каждый представитель семейства — это отдельный класс. Это позволяет им определять разное количество полей с разными типами. В связи с этим, их не получится традиционно положить последовательно друг за другом в какой-то контейнер, например, массив или вектор. Как минимум у них будут разные типы, что недопустимо в языке. Нельзя гарантировать, что размер объектов разных классов будет одинаковый, значит и ходить по памяти с фиксированным шагом нельзя. В этом случае, как правило, объект полиморфного класса создают на куче, а в контейнер сохраняют указатель на эту область памяти. Тип указателя выбирается таким образом, чтобы он был общим предком для всех объектов, которые будут сохраняться в конкретный контейнер. Пример:
Продолжение в комментариях 👇
#cppcore
«Полиморфизм (от др. греч. много видов) — возможность существования чего-либо в различных формах»
Идея динамического полиморфизма заключается в том, чтобы иметь возможность обобщенно описать поведение семейства классов. Каждый класс семейства имеет общий набор действий, но может выполнять их по своему — учитывать особенности конкретного представителя семейства.
Приведу простой пример: все подписчики канала относятся к семейству Homo sapiens. Общей чертой этого вида является возможность говорить, но все говорят по-разному! Даже если мы произносим один и тот же текст, будет отличается интонация, голос, диалект, темп... Я делаю акцент на разных способах совершения одного и того же действия. Допустим, мы хотим запрограммировать это разнообразие действий. В языке есть конкретный способ, как это можно сделать легко и понятно. Для упрощенного взаимодействия с такими семействами в язык введен динамический полиморфизм.
В рамках данной темы слово семейство подходит лучше всего. Мы можем сделать иерархию классов, связанных по смыслу. Родительский класс будет обобщать поведение для всех своих наследников. Так получается семейство, где каждый класс имеет некоторое родство с другими классами: общий набор виртуальных методов. Они могут наследоваться от предков и могут быть изменены у потомков. Такой набор представляет из себя общий интерфейс для семейства классов.
Ключевое слово
virtual
используется для обозначения виртуальных методов:struct ParentКласс, который содержит хотя бы один виртуальный метод, в том числе наследует его от родителя, является полиморфным классом.
{
// Виртуальные методы `whoami` и `can_work`
// могут быть переопределены у наследников
// этого класса
virtual void whoami() {
std::cout << "I'm a human. ";
}
virtual void can_work() {
std::cout << "I can work hard 5 days a week. ";
}
// Невиртуальный метод
void say_hello() {
std::cout << "Hello! ";
}
// Обязательно объявляем
// виртуальный деструктор
virtual ~Parent() {;}
};
Реализация виртуального метода может быть изменена у наследников класса:
struct Child : public ParentВиртуальный метод наследника будет перегружен только тогда, когда сигнатура метода полностью совпадет с сигнатурой у родителя. То есть имя, типы и набор параметров у методов совпадает. Возвращаемый тип тоже должен совпадать, но есть одно исключение: подробнее.
{
// Методы `whoami` наследован
// без переопределения
// Метод `can_work` переопределен
void can_work() override {
std::cout << "I can't work because I'm too young. ";
}
// Метод `say_hello` наследован от родителя
// Метод, недоступный из общего интерфейса
void child_only() {
std::cout << "I can play games hard 7 days a week! ";
}
};
Каждый представитель семейства — это отдельный класс. Это позволяет им определять разное количество полей с разными типами. В связи с этим, их не получится традиционно положить последовательно друг за другом в какой-то контейнер, например, массив или вектор. Как минимум у них будут разные типы, что недопустимо в языке. Нельзя гарантировать, что размер объектов разных классов будет одинаковый, значит и ходить по памяти с фиксированным шагом нельзя. В этом случае, как правило, объект полиморфного класса создают на куче, а в контейнер сохраняют указатель на эту область памяти. Тип указателя выбирается таким образом, чтобы он был общим предком для всех объектов, которые будут сохраняться в конкретный контейнер. Пример:
Parent *humans[2] { nullptr };Указатель
// Parent - общий предок
humans[0] = new Parent();
humans[1] = new Child();
data
имеет тип Parent
. Почему выбран именно этот тип? В данном случае, мы сможем вызывать общие методы не только с классом Parent
, но и со всеми его наследниками. Не обязательно выбирать самый базовый класс, т.к. наследник может предоставлять более широкий интерфейс, содержать больше общих методов для выбранного подсемейства.Продолжение в комментариях 👇
#cppcore
Виртуальный деструктор
В предыдущей статье в комментарии к примеру было написано, что деструктор полиморфного класса обязательно должен быть виртуальным. Зачем же? Погнали разбираться!
Объект каждого класса обладает своим жизненным циклом: начало, счастливая жизнь и конец. С началом ассоциирован - конструктор, а с концом - деструктор, о котором и пойдет речь дальше. Он вызывается автоматически, когда объект удаляется вручную или автоматически. Это даёт возможность корректно завершить работу, которую выполнял объект. Например, освободить выделенную в конструкторе память, закрыть сокет. Это позволяет поддерживать систему в валидном состоянии на протяжении всей работы программы. Это счастливый конец!
Условно, если вы поработали в мастерской, значит, уходя из неё, надо всё за собой убрать и разложить по полочкам. Иначе следующий мастер там просто не найдет нужный инструмент, запнется о мусор и еще что-нибудь испортит. Повторим это с десяток раз и можно сжигать мастерскую 😃. Кажется, что сжигать мастерскую — это перебор 🤭, но именно так и поступит система: прибьет её из-за исчерпания памяти (Out Of Memory). Это грустный конец...
Вернёмся к динамическому полиморфизму. Давайте свяжем два наблюдения:
1) Зачастую, наследники полиморфных классов могут владеть ресурсом, который обязаны вернуть системе (например, память на куче).
2) Зачастую, взаимодействие происходит через указатель на родительский класс.
Из П.1 следует, что у наследника должен быть вызван деструктор, в котором происходит возврат ресурса системе.
Из П.2 следует, что динамический тип объекта может отличаться от типа указателя.
Из этого следует, что корректное удаление объекта подразумевает вызов деструктора класса наследника. И вот как его вызвать, если тип указателя - родительский? Например тут:
Пишу тут
Есть простое встроенное решение 😊 Отмечайте деструктор родительского класса виртуальным! Пример:
Вызов виртуального деструктора приведёт к вызову цепочки деструкторов у всех наследников от родительского до динамического типа:
Если в иерархии классов деструктор не был отмечен виртуальным, то будет вызван только деструктор класса, который является типом указателя. Деструктор наследника не будет вызван вообще, следовательно, ресурс будет упущен. Добавлю живой пример, в котором количество выделяемых ресурсов не совпадает с освобождаемым. Как-нибудь напишем, как это дело проверять по-нормальному 😉
Пока деструктор останется невиртуальным, у компилятора просто нет указания, что надо заботиться о чем-то. Ну вдруг у вас умышленно очень хитрое поведение у программы?
Есть еще один тонкий момент. Кажется, что если полиморфное семейство не выделяет никаких ресурсов, то и проблем не будет. Но это пока! Пройдет время, код эволюционирует, появятся такие ресурсы и вот тогда что-то может да потечь. Короче, это чеховское ружьё 😉
Помочь избежать этих проблем поможет, как всегда, предупреждение:
#cppcore
В предыдущей статье в комментарии к примеру было написано, что деструктор полиморфного класса обязательно должен быть виртуальным. Зачем же? Погнали разбираться!
Объект каждого класса обладает своим жизненным циклом: начало, счастливая жизнь и конец. С началом ассоциирован - конструктор, а с концом - деструктор, о котором и пойдет речь дальше. Он вызывается автоматически, когда объект удаляется вручную или автоматически. Это даёт возможность корректно завершить работу, которую выполнял объект. Например, освободить выделенную в конструкторе память, закрыть сокет. Это позволяет поддерживать систему в валидном состоянии на протяжении всей работы программы. Это счастливый конец!
Условно, если вы поработали в мастерской, значит, уходя из неё, надо всё за собой убрать и разложить по полочкам. Иначе следующий мастер там просто не найдет нужный инструмент, запнется о мусор и еще что-нибудь испортит. Повторим это с десяток раз и можно сжигать мастерскую 😃. Кажется, что сжигать мастерскую — это перебор 🤭, но именно так и поступит система: прибьет её из-за исчерпания памяти (Out Of Memory). Это грустный конец...
Вернёмся к динамическому полиморфизму. Давайте свяжем два наблюдения:
1) Зачастую, наследники полиморфных классов могут владеть ресурсом, который обязаны вернуть системе (например, память на куче).
2) Зачастую, взаимодействие происходит через указатель на родительский класс.
Из П.1 следует, что у наследника должен быть вызван деструктор, в котором происходит возврат ресурса системе.
Из П.2 следует, что динамический тип объекта может отличаться от типа указателя.
Из этого следует, что корректное удаление объекта подразумевает вызов деструктора класса наследника. И вот как его вызвать, если тип указателя - родительский? Например тут:
Parent *data = new Child();
...
delete data;
Пишу тут
new
и delete
в ознакомительных целях. Используйте умные указатели: unique_ptr, shared_ptr.Есть простое встроенное решение 😊 Отмечайте деструктор родительского класса виртуальным! Пример:
struct Parent
{
...
virtual ~Parent() {...}
...
};
Вызов виртуального деструктора приведёт к вызову цепочки деструкторов у всех наследников от родительского до динамического типа:
... -> ~Child_2() -> ~Child_1() -> ~Parent();
Если в иерархии классов деструктор не был отмечен виртуальным, то будет вызван только деструктор класса, который является типом указателя. Деструктор наследника не будет вызван вообще, следовательно, ресурс будет упущен. Добавлю живой пример, в котором количество выделяемых ресурсов не совпадает с освобождаемым. Как-нибудь напишем, как это дело проверять по-нормальному 😉
Пока деструктор останется невиртуальным, у компилятора просто нет указания, что надо заботиться о чем-то. Ну вдруг у вас умышленно очень хитрое поведение у программы?
Есть еще один тонкий момент. Кажется, что если полиморфное семейство не выделяет никаких ресурсов, то и проблем не будет. Но это пока! Пройдет время, код эволюционирует, появятся такие ресурсы и вот тогда что-то может да потечь. Короче, это чеховское ружьё 😉
Помочь избежать этих проблем поможет, как всегда, предупреждение:
-Wdelete-non-virtual-dtor
#cppcore
Вызов переопределенных методов в конструкторе / деструкторе
Едва познакомившись с поведением динамического полиморфизма можно придумать достаточно много разных способов его применения. Здорово, если это действительно так! 😃
Вот, например, теперь мы можем в базовом классе выполнить переопределенный виртуальный метод наследованного класса:
Далее следует интересный вопрос, что будет если вызвать метод
вывелось из base_t. Почему?
Такое ограничение при вызове переопределенных методов связано с жизненным циклом объекта и порядком выполнения конструкторов и деструкторов:
При вызове переопределенного метода из конструктора или деструктора, будет вызвана реализация из этого же класса. Такое поведение явно определено в целях безопасности: иначе ваши переопределенные виртуальные методы могут использовать неинициализированную память наследников.
Сделал живой пример 2, в котором постарался наглядно продемонстрировать, что при прямом вызове перегруженной реализации мы бы прочитали невалидные данные. Если что, так делать нельзя 😉 Тут важно просто понять мотивацию ограничений.
Желаю всем, чтобы вы всегда получали ожидаемый результат исполнения ваших программ!
#cppcore
Едва познакомившись с поведением динамического полиморфизма можно придумать достаточно много разных способов его применения. Здорово, если это действительно так! 😃
Вот, например, теперь мы можем в базовом классе выполнить переопределенный виртуальный метод наследованного класса:
struct base_tБазовый класс знает, что метод
{
virtual const char* name() const
{
return "I'm base!";
}
void call_me()
{
std::cout << name() << std::endl;
}
virtual ~base_t() = default;
};
struct derived_t : base_t
{
const char* name() const override
{
return "I'm derived!";
}
};
void main()
{
derived_t object;
// Выводит "I'm derived!"
object.call_me();
}
name()
- виртуальный, поэтому используя встроенный механизм виртуальных таблиц, будет вызвано его переопределение.Далее следует интересный вопрос, что будет если вызвать метод
call_me
из конструктора или деструктора?virtual ~base_t()Виртуальный деструктор будет вызван в момент выхода тела
{
call_me();
}
main
. Вероятно, что вы ожидаете увидеть имя из derived_t
, ведь метод был переопределен. Давайте проверим в живом примере 1. Спойлер: Такое ограничение при вызове переопределенных методов связано с жизненным циклом объекта и порядком выполнения конструкторов и деструкторов:
Constructors:Следовательно, и состояние данных в классах тоже зависит от порядка инициализации / разрушения классов.
base() -> derived_1() -> derived_2() -> ...;
Destructors:
... -> ~derived_2() -> ~derived_1() -> ~base();
При вызове переопределенного метода из конструктора или деструктора, будет вызвана реализация из этого же класса. Такое поведение явно определено в целях безопасности: иначе ваши переопределенные виртуальные методы могут использовать неинициализированную память наследников.
Сделал живой пример 2, в котором постарался наглядно продемонстрировать, что при прямом вызове перегруженной реализации мы бы прочитали невалидные данные. Если что, так делать нельзя 😉 Тут важно просто понять мотивацию ограничений.
Желаю всем, чтобы вы всегда получали ожидаемый результат исполнения ваших программ!
#cppcore
Идентификатор
В статье про динамический полиморфизм мы затронули вопрос переопределения виртуальных методов. Напомню: если сигнатура виртуального метода наследника будет отличаться от сигнатуры метода родителя, то может нарушиться работа динамического полиморфизма. Если вы хотели переопределить метод, но ошиблись с сигнатурой, то будет создан новый метод. К нему нельзя получить доступ через общий интерфейс!
Может быть вы это специально задумали, может быть вы ошиблись - компилятор не знает, что именно произошло. С его точки зрения и то, и другое - возможное корректное поведение программы, что зависит только от замысла разработчика.
Вот чтобы разделять эти два состояния, переопределяемые виртуальные методы в наследниках нужно отмечать идентификатором
Так мы не только явно выражаем наше намерение для других разработчиков, но и просим компилятор проверить нас. Если по каким-то причинам нарушается правило переопределения родительского виртуального метода, то это приведёт к ошибке компиляции. Мы рекомендуем это делать в строго обязательном порядке!
В противном случае, у компилятора просто нет никаких других критериев, по которым мы можем однозначно утверждать переопределяем мы метод или нет. В живом примере продемонстрировал, что вызов ошибочно переопределенного метода не привел к вызову другой реализации.
Кажется, что проблема надуманная, но практически в любом рабочем проекте это будет запутывать. Этого стараются избегать любой ценой, и про это не упустят возможность спросить на собеседовании :) Тем не менее, иногда такое можно встретить. Например, при просмотре кода 20-летней давности. Идентификатор
Напоминать о необходимости
#cppcore
override
В статье про динамический полиморфизм мы затронули вопрос переопределения виртуальных методов. Напомню: если сигнатура виртуального метода наследника будет отличаться от сигнатуры метода родителя, то может нарушиться работа динамического полиморфизма. Если вы хотели переопределить метод, но ошиблись с сигнатурой, то будет создан новый метод. К нему нельзя получить доступ через общий интерфейс!
Может быть вы это специально задумали, может быть вы ошиблись - компилятор не знает, что именно произошло. С его точки зрения и то, и другое - возможное корректное поведение программы, что зависит только от замысла разработчика.
Вот чтобы разделять эти два состояния, переопределяемые виртуальные методы в наследниках нужно отмечать идентификатором
override
: struct Child : public Parent
{
// Переопределяем виртуальный метод
// Parent::say_name
virtual void say_name() override
};
Так мы не только явно выражаем наше намерение для других разработчиков, но и просим компилятор проверить нас. Если по каким-то причинам нарушается правило переопределения родительского виртуального метода, то это приведёт к ошибке компиляции. Мы рекомендуем это делать в строго обязательном порядке!
В противном случае, у компилятора просто нет никаких других критериев, по которым мы можем однозначно утверждать переопределяем мы метод или нет. В живом примере продемонстрировал, что вызов ошибочно переопределенного метода не привел к вызову другой реализации.
Кажется, что проблема надуманная, но практически в любом рабочем проекте это будет запутывать. Этого стараются избегать любой ценой, и про это не упустят возможность спросить на собеседовании :) Тем не менее, иногда такое можно встретить. Например, при просмотре кода 20-летней давности. Идентификатор
override
появился в C++11, а до этого писали без него. И вот тут придется потратить время, чтобы распутать клубок переопределений. Особенно остро этот вопрос встаёт, когда в наследниках так же не указывается ключевое слово virtual
, что допускается стандартом. В шутку сделал вам игру лабиринт, в которой надо найти неправильно переопределенный метод.Напоминать о необходимости
override
можно с помощью предупреждения:-Wsuggest-override
#cppcore
dynamic_cast
Изучая тему динамического полиморфизма нельзя не упомянуть про оператор приведения
Бывает, что в рамках работы с полиморфными классами нам необходимо выполнить приведение от указателя с одним типом к другому из этого же полиморфного семейства. Зачастую мы не можем гарантировать, что динамический тип объекта совпадает с ожидаемым. Приведение оператором static_cast сопряжено с рисками получить UB. Как же нам безопасно его выполнить?
Отличительной особенностью
Как мы видим, компилятор позволяет собрать программу, но, в случае попытки приведения к ложному потомку,
Давайте сразу договоримся о цене таких преимуществ. Как вы догадываетесь, за эдакую роскошь приходится платить тактами процессора. Это подтверждают результаты бенчмарка. Это, действительно, в несколько десятков раз медленнее, но безопаснее! Ни о каком сравнении эффективности не может быть речи, если наша программа работает неправильно.
Когда же мы можем допустить ошибку? Давайте подумаем, какие вообще могут быть сценарии приведения:
1) От наследника к предку (up cast)
2) От предка к наследнику (down cast)
3) Между ветками подсемейств полиморфных классов (cross cast, side cast)
Кейс №1 достаточно тривиален. Мы знаем иерархию наследования, текущий тип объекта и нам надо лишь вычислить смещение до полей предка. Это можно сделать даже на этапе компиляции. Тут можно применить
Кейс №2 уже сложнее тем, что динамический тип объекта неизвестен на этапе компиляции. Его можно узнать только лишь в процессе выполнения программы, прочитав виртуальный указатель. Это как раз та ситуация, когда мы должны использовать
Конечно, приведение можно попытаться выполнить с помощью оператора
Кейс №3 декомпозируется на кейсы 1 + 2: выполняем приведение к общему предку, а затем выполняем от него приведение к требуемому наследнику. Следовательно, нам так же следует использовать
Резюмируем. Оператор
#cppcore
Изучая тему динамического полиморфизма нельзя не упомянуть про оператор приведения
dynamic_cast
, который создан специально для полиморфных классов.Бывает, что в рамках работы с полиморфными классами нам необходимо выполнить приведение от указателя с одним типом к другому из этого же полиморфного семейства. Зачастую мы не можем гарантировать, что динамический тип объекта совпадает с ожидаемым. Приведение оператором static_cast сопряжено с рисками получить UB. Как же нам безопасно его выполнить?
Отличительной особенностью
dynamic_cast
является проверка корректности приведения во время исполнения программы. Из живого примера 1:Device *base = new Laptop();
// Try Laptop* -> Smartphone*
// Result: `derived` is `nullptr`
auto *derived = dynamic_cast<Smartphone*>(base);
...
// Try Laptop& -> Smartphone&
// Result: throw exception std::bad_cast
auto &derived = dynamic_cast<Smartphone&>(*base);
Как мы видим, компилятор позволяет собрать программу, но, в случае попытки приведения к ложному потомку,
dynamic_cast
возвращает либо нулевой указатель, либо бросает исключение std::bad_cast
для ссылок.Давайте сразу договоримся о цене таких преимуществ. Как вы догадываетесь, за эдакую роскошь приходится платить тактами процессора. Это подтверждают результаты бенчмарка. Это, действительно, в несколько десятков раз медленнее, но безопаснее! Ни о каком сравнении эффективности не может быть речи, если наша программа работает неправильно.
Когда же мы можем допустить ошибку? Давайте подумаем, какие вообще могут быть сценарии приведения:
1) От наследника к предку (up cast)
2) От предка к наследнику (down cast)
3) Между ветками подсемейств полиморфных классов (cross cast, side cast)
Кейс №1 достаточно тривиален. Мы знаем иерархию наследования, текущий тип объекта и нам надо лишь вычислить смещение до полей предка. Это можно сделать даже на этапе компиляции. Тут можно применить
dynamic_cast
, но достаточно и static_cast
. Более того, если вы примените dynamic_cast
, то все равно компилятор сгенерирует инструкции, аналогичные static_cast
: живой пример 2.Кейс №2 уже сложнее тем, что динамический тип объекта неизвестен на этапе компиляции. Его можно узнать только лишь в процессе выполнения программы, прочитав виртуальный указатель. Это как раз та ситуация, когда мы должны использовать
dynamic_cast
, чтобы быть готовым перехватить исключение или нулевой указатель. Так же, если в иерархии классов вы используете виртуальное наследование, то static_cast
неприменим, т.к. смещение неизвестно для этого кейса.Конечно, приведение можно попытаться выполнить с помощью оператора
static_cast
, чтобы было побыстрее! Но чем же это грозит? Можем выстрелить себе в ногу и начать работать с полученным объектом, как с объектом другого класса. Сравним разные операторы и продемонстрируем ошибку на живом примере 3. В общем случае, мы прочитаем что-то невнятное, а если изменим данные, то ещё и испортим память, что однозначно негативно скажется на всей программе. Попытка приведения оператором static_cast
к ложному потомку правомерна с точки зрения типа. Ну правда, это тип из одной иерархии, и это будет работать, если случайно динамический тип объекта включает нужного потомка. Но при работе с семейством классов, как правило, вариантов потомков больше одного. Вот будет ли это поддерживаемым кодом? Можно ли безопасно вносить изменения в иерархию классов в будущем?Кейс №3 декомпозируется на кейсы 1 + 2: выполняем приведение к общему предку, а затем выполняем от него приведение к требуемому наследнику. Следовательно, нам так же следует использовать
dynamic_cast
. Вспоминаем так же об особенностях представления памяти. Прикрепляю разбор на живом примере 4. Резюмируем. Оператор
dynamic_cast
имеет преимущество с точки зрения безопасности и удобства, но он работает медленнее. Тут возникает вопрос, а за что вы переплачиваете? Если вам приходится использовать dynamic_cast
, то это повод подумать, насколько хорошо продумана архитектура вашего решения. Не факт, что это плохая архитектура, но это повод её пересмотреть.#cppcore
Динамическая инициализация
После статической инициализации в компайлтайме идет динамическая инициализация в рантайме. Хотелось бы сказать, что хоть здесь простой и понятный порядок, но нет. Это глобальные переменные и С++, поэтому будет немного больно.
Динамическая инициализация разделяется на 3 подгруппы:
1️⃣ Неупорядоченная динамическая инициализация. Она применяется только для статических полей шаблонных классов и шаблонных переменных, которые не специализированы явно(явно специализированные шаблоны - обычные классы). И вот порядок установки значений этих сущностей вообще неопределен. Куда понравится компилятору, туда и вставит.
2️⃣ Частично упорядоченная инициализация. Применяется для всех нешаблонных инлайн переменных. Есть 2 переменные: inline переменная А и В, которая не подходит под критерии применения первой подгруппы. Если А определена во всех единицах трансляции раньше В, то и ее инициализация происходит раньше. Здесь есть однана*бка особенность, которую мы увидим в примере.
3️⃣ Упорядоченная инициализация. Вот это то, что мы упоминали тут. Все переменные со static storage duration, которые не подходят под предыдущие подгруппы, инициализируются в порядке появления их определения в единице трансляции. Между разными единицами трансляции порядок инициализации не установлен.
Давайте на "простой" пример посмотрим:
Возможный вывод:
Здесь как раз все три типа проявляются.
Далее мы имеем уже упорядоченные вещи.
Это понятно. Но погодите:
Вот тут-то и кроется подвох: вы наверное подумали, что из факта "если А определено раньше В, то А инициализируется раньше" автоматически вытекает, что в обратном случае А инициализируется позже. Тут вас и подловили: не вытекает. Поэтому она и называется частично упорядоченной инициализацией. В обратном случае порядок неопределен.
Теперь все понятно:
Ну и статики
И теперь представьте свое лицо, когда вы сделали эти переменные зависимыми друг от друга, предполагая, что статики(со static storage duration) в одной единице трансляции инициализируются в порядке появления определения. Как минимум 🗿, а как максимум🤡.
Последние несколько постов по статикам так и наровят крикнуть: "Не используйте глобальные переменные!" Ну или хотя бы старайтесь не делать их зависимыми друг от друга. Потому что с порядком полный беспорядок, а с перекрестными зависимостями остается только надеяться, что заговор бабки-поветухи на продуктивную работу поможет не словить багов.
Decouple your program. Stay cool.
#cppcore #cpp17
После статической инициализации в компайлтайме идет динамическая инициализация в рантайме. Хотелось бы сказать, что хоть здесь простой и понятный порядок, но нет. Это глобальные переменные и С++, поэтому будет немного больно.
Динамическая инициализация разделяется на 3 подгруппы:
1️⃣ Неупорядоченная динамическая инициализация. Она применяется только для статических полей шаблонных классов и шаблонных переменных, которые не специализированы явно(явно специализированные шаблоны - обычные классы). И вот порядок установки значений этих сущностей вообще неопределен. Куда понравится компилятору, туда и вставит.
2️⃣ Частично упорядоченная инициализация. Применяется для всех нешаблонных инлайн переменных. Есть 2 переменные: inline переменная А и В, которая не подходит под критерии применения первой подгруппы. Если А определена во всех единицах трансляции раньше В, то и ее инициализация происходит раньше. Здесь есть одна
3️⃣ Упорядоченная инициализация. Вот это то, что мы упоминали тут. Все переменные со static storage duration, которые не подходят под предыдущие подгруппы, инициализируются в порядке появления их определения в единице трансляции. Между разными единицами трансляции порядок инициализации не установлен.
Давайте на "простой" пример посмотрим:
struct ShowOrderHelper {
ShowOrderHelper(int num) : data{num} {
std::cout << "Object initialized with data " << num << std::endl;
}
int data;
};
static ShowOrderHelper static_var1{3};
static ShowOrderHelper static_var2{4};
struct ClassWithInlineStaticVar {
static inline ShowOrderHelper inline_member{1};
};
inline ShowOrderHelper inline_var{2};
template <class T>
struct TemplateClassWithStaticVar {
static ShowOrderHelper static_member;
};
template <class T>
ShowOrderHelper TemplateClassWithStaticVar<T>::static_member{27};
Возможный вывод:
Object initialized with data 1
Object initialized with data 2
Object initialized with data 27
Object initialized with data 3
Object initialized with data 4
Здесь как раз все три типа проявляются.
static_member
- статическое поле неспециализированного явно шаблона, поэтому установка ее значения в рандомном месте происходит.Далее мы имеем уже упорядоченные вещи.
inline_member
определен раньше, чем inline_var
, поэтому она и инициализируется раньше.Это понятно. Но погодите:
inline_member
и inline_var
определены позже статиков static_var1
и static_var2
. Какого хера они инициализирутся раньше? Это же противоречит правилам частично упорядоченной динамической инициализации!Вот тут-то и кроется подвох: вы наверное подумали, что из факта "если А определено раньше В, то А инициализируется раньше" автоматически вытекает, что в обратном случае А инициализируется позже. Тут вас и подловили: не вытекает. Поэтому она и называется частично упорядоченной инициализацией. В обратном случае порядок неопределен.
Теперь все понятно:
inline_member
инициализируется строго раньше inline_var
, потому что определение стоит раньше. Но, как группа inline'ов, они расположены после static_var1
и static_var2
и в этом случае для них значение устанавливается в неизвестном порядке. В данном случае перед всеми инициализациями.Ну и статики
static_var1
и static_var2
инициализируются в ожидаемом порядке из-за применения упорядоченной инициализации.И теперь представьте свое лицо, когда вы сделали эти переменные зависимыми друг от друга, предполагая, что статики(со static storage duration) в одной единице трансляции инициализируются в порядке появления определения. Как минимум 🗿, а как максимум🤡.
Последние несколько постов по статикам так и наровят крикнуть: "Не используйте глобальные переменные!" Ну или хотя бы старайтесь не делать их зависимыми друг от друга. Потому что с порядком полный беспорядок, а с перекрестными зависимостями остается только надеяться, что заговор бабки-поветухи на продуктивную работу поможет не словить багов.
Decouple your program. Stay cool.
#cppcore #cpp17
Всем привет!
У нас кстати есть отдельный чат, в котором вы можете общаться, не привязываясь к постам
Там сейчас оживленное обсуждение идет, поэтому можете присоединяться и общаться на любые темы!
Вот ссылочка
Продублируем ее потом в закрепный пост
Всем хорошего вечера!
У нас кстати есть отдельный чат, в котором вы можете общаться, не привязываясь к постам
Там сейчас оживленное обсуждение идет, поэтому можете присоединяться и общаться на любые темы!
Вот ссылочка
Продублируем ее потом в закрепный пост
Всем хорошего вечера!
Инициализация статических полей класса. Ч4
Продолжение нелегендарной истории static class members initialization. Предыдущие части тут, тут и тут.
Я немного наврал, когда сказал, что статические переменные и мемберы инициализируются до входа в main(). Как Эдгар отметил в своем комменте, на самом деле тут вот что:
Стандарт дает на откуп реализациям вопрос о том, в какой конкретно момент времени происходит динамическая инициализация глобальных объектов. Единственное ограничение, что инициализация должна произойти до любого неинициализирующего odr-use действия над неинлайн переменными и функциями, определенными в той же единице трансляции, где переменная собирается инициализироваться(немного духоты). То есть до любого действия по считыванию, записи, взятию адреса и созданию ссылки от переменной или функции.
Довольно сложно воспроизвести пример, когда инициализация происходит после main(), потому что мы на это напрямую не может повлиять. Поэтому может быть вот такой потенциальный пример. Он не про статические поля класса конкретно, но зато более наглядный.
В мейне мы говорим, что где-то определен массив интов и внутри главной функции мы печатаем его содержимое.
Проблема в том, что не понятно, произойдет ли в source.cpp инициализация array до вызова main() или после. Если после, то мы вполне можем накнуться на неинициализированную память, что UB.
Подливает масло в огонь вот такое утверждение:
То есть если никакие переменные и функции не используются в юните, где переменная должна инициализироваться, ее значение вообще может быть не установлено.
Так и происходит в source.cpp. Что с бо'льшей вероятностью приведет эту программу к фрилансерскому(нерабочему) состоянию. Однако популярные компиляторы стараются сгладить углы в этом моменте и даже в таком виде у вас в 99.9 случаев из 100 будет все в порядке. Что не отменяет потенциальную угрозу, но тем не менее.
Такие проблемы скорее свойственны программам, использующим шареные библиотеки, потому что они сами по себе в рантайме подгружаются и непонятно когда это происходит. Да и стандарт никак не упоминает эти библиотеки, поэтому здесь большой простор для фантазии и поведения реализации.
Avoid dangerous situations with no gain. Stay cool.
#cppcore
Продолжение нелегендарной истории static class members initialization. Предыдущие части тут, тут и тут.
Я немного наврал, когда сказал, что статические переменные и мемберы инициализируются до входа в main(). Как Эдгар отметил в своем комменте, на самом деле тут вот что:
It is implementation-defined whether
the dynamic initialization of
a non-block non-inline variable with
static storage duration is sequenced
before the first statement of main or
is deferred. If it is deferred, it
strongly happens before any
non-initialization odr-use of any
non-inline function or non-inline
variable defined in the same
translation unit as the variable to be
initialized. It is implementation-defined
in which threads and at which points in
the program such deferred dynamic
initialization occurs.
Стандарт дает на откуп реализациям вопрос о том, в какой конкретно момент времени происходит динамическая инициализация глобальных объектов. Единственное ограничение, что инициализация должна произойти до любого неинициализирующего odr-use действия над неинлайн переменными и функциями, определенными в той же единице трансляции, где переменная собирается инициализироваться(немного духоты). То есть до любого действия по считыванию, записи, взятию адреса и созданию ссылки от переменной или функции.
Довольно сложно воспроизвести пример, когда инициализация происходит после main(), потому что мы на это напрямую не может повлиять. Поэтому может быть вот такой потенциальный пример. Он не про статические поля класса конкретно, но зато более наглядный.
// header.hpp
struct Class {
Class() : array{1, 2, 3, -1} {}
int array[4];
};
//source.cpp
#include "header.hpp"
Class var;
// main.cpp
#include <cstdio>
#include "header.hpp"
extern Class var;
int main(void)
{
for (int i = 0; var.array[i] != -1; i++) {
printf("%d\n", i);
}
}
В мейне мы говорим, что где-то определен массив интов и внутри главной функции мы печатаем его содержимое.
Проблема в том, что не понятно, произойдет ли в source.cpp инициализация array до вызова main() или после. Если после, то мы вполне можем накнуться на неинициализированную память, что UB.
Подливает масло в огонь вот такое утверждение:
If no variable or function is odr-used
from a given translation unit, the
non-local variables defined in that
translation unit may never be initialized
То есть если никакие переменные и функции не используются в юните, где переменная должна инициализироваться, ее значение вообще может быть не установлено.
Так и происходит в source.cpp. Что с бо'льшей вероятностью приведет эту программу к фрилансерскому(нерабочему) состоянию. Однако популярные компиляторы стараются сгладить углы в этом моменте и даже в таком виде у вас в 99.9 случаев из 100 будет все в порядке. Что не отменяет потенциальную угрозу, но тем не менее.
Такие проблемы скорее свойственны программам, использующим шареные библиотеки, потому что они сами по себе в рантайме подгружаются и непонятно когда это происходит. Да и стандарт никак не упоминает эти библиотеки, поэтому здесь большой простор для фантазии и поведения реализации.
Avoid dangerous situations with no gain. Stay cool.
#cppcore
Вот когда точно статики инициализируются после main
Все-таки есть стопроцентный способ создать условия, чтобы этот эффект проявился.
Как вы знаете, есть 2 вида библиотек: статические и динамические. Код статических библиотек вставляется в основной код программы в то время, как код динамических библиотек подгружается в рантайме.
Так вот есть способы в любой момент исполнения программы руками подгрузить shared library и использовать ее символы, даже ничего не зная о ней на этапе линковки объектников!
На юниксах это системный вызов dlopen. Он принимает путь к библиотеки и возвращает ее хэндл. Через этот хэндл можно получать указатели на сущности из либы.
Естественно, что раз бинарник ничего не знал о сущностях библиотеки до ее explicit подгрузки, а библиотека просто лежала камнем в файловой системе, то буквально никакой код библиотеки не может быть выполнен до ее подгрузки. А значит, если мы открываем либу в main(), то только в этот момент начинается вся динамическая инициализация сущностей либы. Поэтому значение ее переменных со static storage duration устанавливается после входа в main()!
Минимальный пример:
Вывод:
Чтобы запустить это дело(на примере gcc), нужно:
1️⃣ Скомпилировать объектный файл из source.cpp g++ -c -fpic -std=c++17 source.cpp
2️⃣ Превратить его в библиотеку g++ -shared -o libsource.so source.o
3️⃣ Скомпилировать main.cpp g++ -o test main.cpp -std=c++17
4️⃣ Запустить ./test
Важно отметить, что о существовании библиотеки исполняемый файл test вообще не в курсе. Также не нужно добавлять путь до либы в какой-нибудь $LD_LIBRARY_PATH.
Если библиотеку сликовать с бинарем сразу же и подгружать ее неявно, то порядок инициализации будет снова неопределен в соотвествии с предыдущим постом. И скорее всего такого эффекта в этом случае не будет.
Вот такие интересности существуют в мире инициализации статиков. Пост не несет какого-то особого смысла, разве что познакомить некоторых читателей с таким вот явным способом загрузки динамических либ в код.
Dig deeper. Stay cool.
#compiler
Все-таки есть стопроцентный способ создать условия, чтобы этот эффект проявился.
Как вы знаете, есть 2 вида библиотек: статические и динамические. Код статических библиотек вставляется в основной код программы в то время, как код динамических библиотек подгружается в рантайме.
Так вот есть способы в любой момент исполнения программы руками подгрузить shared library и использовать ее символы, даже ничего не зная о ней на этапе линковки объектников!
На юниксах это системный вызов dlopen. Он принимает путь к библиотеки и возвращает ее хэндл. Через этот хэндл можно получать указатели на сущности из либы.
Естественно, что раз бинарник ничего не знал о сущностях библиотеки до ее explicit подгрузки, а библиотека просто лежала камнем в файловой системе, то буквально никакой код библиотеки не может быть выполнен до ее подгрузки. А значит, если мы открываем либу в main(), то только в этот момент начинается вся динамическая инициализация сущностей либы. Поэтому значение ее переменных со static storage duration устанавливается после входа в main()!
Минимальный пример:
// lib.cpp
struct CreationMomentShower {
CreationMomentShower(int num=0) : data{num} {
std::cout << "Created object with data " << num << std::endl;
}
int data;
};
struct Use {
static inline CreationMomentShower help{6};
};
// main.cpp
#include <iostream>
#include <dlfcn.h>
int main()
{
std::cout << "Main has already started" << std::endl;
void* libraryHandle = dlopen("libsource.so", RTLD_NOW);
if (libraryHandle == nullptr) {
std::cerr << dlerror() << std::endl;
return 1;
}
dlclose(libraryHandle);
}
Вывод:
Main has already started
Created object with data 6
Чтобы запустить это дело(на примере gcc), нужно:
1️⃣ Скомпилировать объектный файл из source.cpp g++ -c -fpic -std=c++17 source.cpp
2️⃣ Превратить его в библиотеку g++ -shared -o libsource.so source.o
3️⃣ Скомпилировать main.cpp g++ -o test main.cpp -std=c++17
4️⃣ Запустить ./test
Важно отметить, что о существовании библиотеки исполняемый файл test вообще не в курсе. Также не нужно добавлять путь до либы в какой-нибудь $LD_LIBRARY_PATH.
Если библиотеку сликовать с бинарем сразу же и подгружать ее неявно, то порядок инициализации будет снова неопределен в соотвествии с предыдущим постом. И скорее всего такого эффекта в этом случае не будет.
Вот такие интересности существуют в мире инициализации статиков. Пост не несет какого-то особого смысла, разве что познакомить некоторых читателей с таким вот явным способом загрузки динамических либ в код.
Dig deeper. Stay cool.
#compiler
Static initialization order fiasco
Добрались мы наконец-то до этого мерзопакостного явления. По сути, про статики мы говорили ради нескольких тем и эта одна из них.
В чем суть. Как вы уже поняли, что с порядком инициализации у статиков все очень плохо. Но внутри одной единицы трансляции он хотя бы определен и предсказуем! С божественными способностями предсказания, конечно. Ну или с томиком стандарта и нашими статьями под рукой. Но он этот порядок хотя бы какой-то есть. Один раз нормально сделай и можно надеяться на обратную совместимость языка, что все будет работать как надо.
Но вот между разными юнитами трансляции порядок вообще не определен.
Static initialization order fiasco отсылается к неопределенности в порядке, в котором инициализируются объекты со статической продолжительностью хранения в разных единицах трансляции. Если мы пытаемся создать объект в одном юните, который полагается на существующий объект в другом, то мы можем знатно утяжелить штаны, если получится так, что объект еще не существует. То есть он просто zero-инициализирован. В общем случае, поведение в такой программе неопределено.
Простейший воспроизводимый пример:
Если скомпилировать это дело как:
А если файлы передать в другом порядке:
Очевидно, что результат зависит от того, в каком порядке линкер увидит единицы трансляции. И это зашквар!
Например, GCC версии до 4.7 инициализировал единицы трансляции в обратном порядке их появления в строке компиляции. И в один момент это поведение поменялось на обратное, что с хренам поломало кучу проектов, которые были завязаны на инициализации именно в таком порядке.
Кстати, линкер инициализирирует единицы трансляции не в рандомном порядке. Есть разные способы: в алфавитном порядке, в передаваемом ему на вход порядке и так далее. То есть система есть, но у каждого она своя.
Это можно видеть даже на нашем примере:
В первом случае
Во втором случае source.cpp инициализируется первым и теперь все в правильном порядке.
На эти порядки ни в коем случае нельзя надеяться! Опять же пример с гцц говорит нам, что неследование стандарту чревато надеванием кастрюли на голову и ударами по ней поварешкой. Но для понимания процессов, это примерно так происходит.
Define the order of your life. Stay cool.
#cppcore #NONSTANDARD
Добрались мы наконец-то до этого мерзопакостного явления. По сути, про статики мы говорили ради нескольких тем и эта одна из них.
В чем суть. Как вы уже поняли, что с порядком инициализации у статиков все очень плохо. Но внутри одной единицы трансляции он хотя бы определен и предсказуем! С божественными способностями предсказания, конечно. Ну или с томиком стандарта и нашими статьями под рукой. Но он этот порядок хотя бы какой-то есть. Один раз нормально сделай и можно надеяться на обратную совместимость языка, что все будет работать как надо.
Но вот между разными юнитами трансляции порядок вообще не определен.
Static initialization order fiasco отсылается к неопределенности в порядке, в котором инициализируются объекты со статической продолжительностью хранения в разных единицах трансляции. Если мы пытаемся создать объект в одном юните, который полагается на существующий объект в другом, то мы можем знатно утяжелить штаны, если получится так, что объект еще не существует. То есть он просто zero-инициализирован. В общем случае, поведение в такой программе неопределено.
Простейший воспроизводимый пример:
// source.cpp
int quad(int n) {
return n * n;
}
auto staticA = quad(5);
// main.cpp
#include <iostream>
extern int staticA;
auto staticB = staticA;
int main() {
std::cout << "staticB: " << staticB << std::endl;
}
Если скомпилировать это дело как:
g++ main.cpp source.cpp -std=c++17
, то результат будет такой:staticB: 0
А если файлы передать в другом порядке:
g++ source.cpp main.cpp -std=c++17
, то такой:staticB: 25
Очевидно, что результат зависит от того, в каком порядке линкер увидит единицы трансляции. И это зашквар!
Например, GCC версии до 4.7 инициализировал единицы трансляции в обратном порядке их появления в строке компиляции. И в один момент это поведение поменялось на обратное, что с хренам поломало кучу проектов, которые были завязаны на инициализации именно в таком порядке.
Кстати, линкер инициализирирует единицы трансляции не в рандомном порядке. Есть разные способы: в алфавитном порядке, в передаваемом ему на вход порядке и так далее. То есть система есть, но у каждого она своя.
Это можно видеть даже на нашем примере:
В первом случае
staticB
равен нулю, потому что main.cpp стоит первым в строке компиляции и линкер инициализирует глобальные переменные этой единицы трансляции первыми. А так как на этот момент staticA
не получила своего окончательного значения, а была лишь zero-инициализирована, то staticB
инициализируется нулем.Во втором случае source.cpp инициализируется первым и теперь все в правильном порядке.
staticB
получает свое значение от уже инициализированного staticA
.На эти порядки ни в коем случае нельзя надеяться! Опять же пример с гцц говорит нам, что неследование стандарту чревато надеванием кастрюли на голову и ударами по ней поварешкой. Но для понимания процессов, это примерно так происходит.
Define the order of your life. Stay cool.
#cppcore #NONSTANDARD
Еще одно отличие С от С++
Это вот прям такое, мажорное отличие. Скажете его на собесе - все охренеют, вам руку пожмут через вебку и возьмут вас на работу сразу же(но это не точно).
В языке С нет проблемы Static initialization order fiasco!
Как же так? В С тоже есть статики и тоже есть разные единицы трансляции. Почему так?
Таким образом, в С статическая переменная со скалярным типом может быть инициализирована только константным выражением. Это не constexpr, а просто выражение, которое компилятор в состоянии вычислить во время компиляции. Если тип переменной представляет собой массив со скалярным типом элемента, то каждый инициализатор должен быть константным выражением и так далее. Поскольку такие выражения не могут ни вызывать побочных эффектов, ни зависеть от побочных эффектов, вызванных любыми другими вычислениями, изменение порядка вычисление константных выражений не влияет на результат. А значит и никакого фиаско нет!
Единственные неконстантные выражения, которые могут быть вычислены перед main, - это те, которые вызываются из среды выполнения C. Вот почему объекты FILE, на которые указывают stdin, stdout и stderr, уже доступны для использования сразу же после начала main.
Стандартный C не позволяет пользователям регистрировать свой собственный код запуска перед основным, хотя GCC предоставляет расширение под названием constructor (возможна массонская связь с конструкторами из C++), которое вы можете использовать для воссоздания SIOF в C. Но это, как говорится, НЕСТАНДАРТ и у каждого свой путь в могилу.
Целью Страуструпа было сделать пользовательские типы пригодными для использования везде, где есть встроенные типы. Это означало, что C++ должен был разрешать глобальным переменным быть кастомными типами, что означает, что их конструкторы будут вызываться во время запуска программы. Поскольку в начале C++ не было функций constexpr, такие вызовы конструкторов никогда не могли быть постоянными выражениями. И так, родилось чудовище, погубившее много наших ребят - Static initialization order fiasco.
В процессе стандартизации C++ вопрос о порядке выполнения статической инициализации был спорной темой. Я думаю, что вы согласитесь с тем, что идеальная ситуация - это когда каждая статическая переменная была инициализирована до ее использования. К сожалению, для этого требуется технология компоновки, которой в те дни не существовало (и, вероятно, до сих пор не существует?). Инициализация статической переменной может включать вызовы функций, и эти функции могут быть определены в другой TU, что означает, что вам нужно будет выполнить анализ всей программы, чтобы успешно отсортировать статические переменные в порядке зависимостей. Стоит отметить, что даже если бы C++ мог быть разработан таким образом, он все равно не полностью предотвратил бы проблемы с порядком инициализации. Представьте, если бы у вас была какая-то библиотека, где предварительным условием функции использования было то, что функция init() была вызвана в какой-то момент в прошлом и повлияла на нужную для инициализацию переменную. Компилятор не может увидеть такие зависимости, которые есть только у программиста в голове. По коду этого совсем не видно. Поэтому, думаю, что даже полноценный анализ кода не помог бы решить проблему.
В конечном счете, ограниченные гарантии порядка инициализации, которые мы получили в C++98, были лучшими, которые мы могли получить в данных обстоятельствах. С помощью народного "а вот сделали бы по-человечески", возможно, многие из нас высказали пару ласковых о том, что тот стандарт не был полным без функций constexpr и что статические переменные должны иметь только константную инициализацию. Но такого рода размышления надо оставить это нытикам и нюням. А настоящие программисты прогают на том, что есть. В тех условиях, в которых возможно.
Don't complain to your life. Work on it and stay cool.
#cppcore #goodoldc
Это вот прям такое, мажорное отличие. Скажете его на собесе - все охренеют, вам руку пожмут через вебку и возьмут вас на работу сразу же(но это не точно).
В языке С нет проблемы Static initialization order fiasco!
Как же так? В С тоже есть статики и тоже есть разные единицы трансляции. Почему так?
All the expressions in an initializer
for an object that has static storage
duration or in an initializer list for an
object that has aggregate or union type
shall be constant expressions.
Таким образом, в С статическая переменная со скалярным типом может быть инициализирована только константным выражением. Это не constexpr, а просто выражение, которое компилятор в состоянии вычислить во время компиляции. Если тип переменной представляет собой массив со скалярным типом элемента, то каждый инициализатор должен быть константным выражением и так далее. Поскольку такие выражения не могут ни вызывать побочных эффектов, ни зависеть от побочных эффектов, вызванных любыми другими вычислениями, изменение порядка вычисление константных выражений не влияет на результат. А значит и никакого фиаско нет!
Единственные неконстантные выражения, которые могут быть вычислены перед main, - это те, которые вызываются из среды выполнения C. Вот почему объекты FILE, на которые указывают stdin, stdout и stderr, уже доступны для использования сразу же после начала main.
Стандартный C не позволяет пользователям регистрировать свой собственный код запуска перед основным, хотя GCC предоставляет расширение под названием constructor (возможна массонская связь с конструкторами из C++), которое вы можете использовать для воссоздания SIOF в C. Но это, как говорится, НЕСТАНДАРТ и у каждого свой путь в могилу.
Целью Страуструпа было сделать пользовательские типы пригодными для использования везде, где есть встроенные типы. Это означало, что C++ должен был разрешать глобальным переменным быть кастомными типами, что означает, что их конструкторы будут вызываться во время запуска программы. Поскольку в начале C++ не было функций constexpr, такие вызовы конструкторов никогда не могли быть постоянными выражениями. И так, родилось чудовище, погубившее много наших ребят - Static initialization order fiasco.
В процессе стандартизации C++ вопрос о порядке выполнения статической инициализации был спорной темой. Я думаю, что вы согласитесь с тем, что идеальная ситуация - это когда каждая статическая переменная была инициализирована до ее использования. К сожалению, для этого требуется технология компоновки, которой в те дни не существовало (и, вероятно, до сих пор не существует?). Инициализация статической переменной может включать вызовы функций, и эти функции могут быть определены в другой TU, что означает, что вам нужно будет выполнить анализ всей программы, чтобы успешно отсортировать статические переменные в порядке зависимостей. Стоит отметить, что даже если бы C++ мог быть разработан таким образом, он все равно не полностью предотвратил бы проблемы с порядком инициализации. Представьте, если бы у вас была какая-то библиотека, где предварительным условием функции использования было то, что функция init() была вызвана в какой-то момент в прошлом и повлияла на нужную для инициализацию переменную. Компилятор не может увидеть такие зависимости, которые есть только у программиста в голове. По коду этого совсем не видно. Поэтому, думаю, что даже полноценный анализ кода не помог бы решить проблему.
В конечном счете, ограниченные гарантии порядка инициализации, которые мы получили в C++98, были лучшими, которые мы могли получить в данных обстоятельствах. С помощью народного "а вот сделали бы по-человечески", возможно, многие из нас высказали пару ласковых о том, что тот стандарт не был полным без функций constexpr и что статические переменные должны иметь только константную инициализацию. Но такого рода размышления надо оставить это нытикам и нюням. А настоящие программисты прогают на том, что есть. В тех условиях, в которых возможно.
Don't complain to your life. Work on it and stay cool.
#cppcore #goodoldc
Решение static initialization order fiasco
Раз есть проблема - должно быть и решение. Сегодня поговорим о паре-тройке вариантов. Пост вдохновлен этим комментом нашего подписчика Антона.
Очевидно, что в комментах немного поразгоняли эту тему. Поэтому вот небольшое саммари, плюс немного от себя.
1️⃣ Самое очевидное - дропнуть дурнопахнущие статики. Ну или стараться по-максимуму уменьшать их количество. Человечество давно осознало, что глобальные переменные - зло, а со злом нужно бороться и побеждать его. Используйте ООП, группируйте данные вместе. И не ленитесь передавать объекты в функции. И это поможет вам избавиться о большинства глобальных переменных. Способ, я бы сказал, идеальный. Но наш мир таковым не является и в реальном коде будут продолжать жить статики и надо уметь с ними правильно обходиться.
2️⃣ Делайте свои глобальные объекты constexpr. Глобальные изменяемые объекты - зло. Но вот умные константы, для которых можно проводить вычисления на этапе компиляции - тема богоугодная. Константны в коде так или иначе нужны, а в современных стандартах много уделяется внимания вычислениям на этапе компиляции и не зря. Вряд ли вам на этапе инициализации программы нужно делать что-то суперсложное, зависящее от внешнего мира. Зачастую, много чего можно вычислить в compile-time и не заботиться об опасностях динамической инициализации. К тому же их инициализация безопасна и предсказуема.
3️⃣ Иметь один хэдэр со всеми глобальными переменными и определить их все в одной единице трансляции. В пределах единицы трансляции порядок полностью определен, поэтому никаких проблем не будет. Однако есть один момент, что это решение будет сильно связывать друг с другом несвязанный по смыслу код. Держать все переменные в одном месте может показаться удобным на первый взгляд. Это еще сильнее развязывает руки разработчикам в плане увеличения количества связей между переменными. И в будущем распутывать эти связи будет еще сложнее. Single responsibility ушел в закат...
Да и банально разрабатывать сложнее. Удобно, когда код разбит на модули и каждый модуль максимально изолирован от остальных, чтобы не провоцировать мерж конфликты. А в этот суперфайл будут лезть буквально все и будут постоянные пересечения в изменениях разных разработчиков. Плюс можно так замержиться, что можно сломать логику работы глобальных переменных и все сильно пойдет по одному месту, потому что тесты хрен напишешь на них, а отлавливать баги в глобальных переменных - очень сложно.
Способ хоть и рабочий и много где используется, но далеко не идеальный.
4️⃣ Construct on first use idiom. Помните, как мы говорили про то, что статические локальные переменные функций инициализируются при первом вызове функции? Так вот эту особенность можно использовать, чтобы никогда не использовать объект в неинициализированном виде. Если у вас есть переменная А, инициализация которой зависит от переменной В, то есть вероятность, что В еще не инициализирована. Тогда можно переменную В обернуть в глобальную функцию-геттер, в которой эта переменная будет хранится в виде статическом локальной переменной и ее значение будет возвращаться наружу. Таким образом любое использование переменной будет проходить через вызов этой функции и нам гарантируется, что переменная создастся в момент первого вызова функции. Техника заслуживает отдельный пост, который выйдет чуть позже.
Solve your problems. Stay cool.
#cppcore
Раз есть проблема - должно быть и решение. Сегодня поговорим о паре-тройке вариантов. Пост вдохновлен этим комментом нашего подписчика Антона.
Очевидно, что в комментах немного поразгоняли эту тему. Поэтому вот небольшое саммари, плюс немного от себя.
1️⃣ Самое очевидное - дропнуть дурнопахнущие статики. Ну или стараться по-максимуму уменьшать их количество. Человечество давно осознало, что глобальные переменные - зло, а со злом нужно бороться и побеждать его. Используйте ООП, группируйте данные вместе. И не ленитесь передавать объекты в функции. И это поможет вам избавиться о большинства глобальных переменных. Способ, я бы сказал, идеальный. Но наш мир таковым не является и в реальном коде будут продолжать жить статики и надо уметь с ними правильно обходиться.
2️⃣ Делайте свои глобальные объекты constexpr. Глобальные изменяемые объекты - зло. Но вот умные константы, для которых можно проводить вычисления на этапе компиляции - тема богоугодная. Константны в коде так или иначе нужны, а в современных стандартах много уделяется внимания вычислениям на этапе компиляции и не зря. Вряд ли вам на этапе инициализации программы нужно делать что-то суперсложное, зависящее от внешнего мира. Зачастую, много чего можно вычислить в compile-time и не заботиться об опасностях динамической инициализации. К тому же их инициализация безопасна и предсказуема.
3️⃣ Иметь один хэдэр со всеми глобальными переменными и определить их все в одной единице трансляции. В пределах единицы трансляции порядок полностью определен, поэтому никаких проблем не будет. Однако есть один момент, что это решение будет сильно связывать друг с другом несвязанный по смыслу код. Держать все переменные в одном месте может показаться удобным на первый взгляд. Это еще сильнее развязывает руки разработчикам в плане увеличения количества связей между переменными. И в будущем распутывать эти связи будет еще сложнее. Single responsibility ушел в закат...
Да и банально разрабатывать сложнее. Удобно, когда код разбит на модули и каждый модуль максимально изолирован от остальных, чтобы не провоцировать мерж конфликты. А в этот суперфайл будут лезть буквально все и будут постоянные пересечения в изменениях разных разработчиков. Плюс можно так замержиться, что можно сломать логику работы глобальных переменных и все сильно пойдет по одному месту, потому что тесты хрен напишешь на них, а отлавливать баги в глобальных переменных - очень сложно.
Способ хоть и рабочий и много где используется, но далеко не идеальный.
4️⃣ Construct on first use idiom. Помните, как мы говорили про то, что статические локальные переменные функций инициализируются при первом вызове функции? Так вот эту особенность можно использовать, чтобы никогда не использовать объект в неинициализированном виде. Если у вас есть переменная А, инициализация которой зависит от переменной В, то есть вероятность, что В еще не инициализирована. Тогда можно переменную В обернуть в глобальную функцию-геттер, в которой эта переменная будет хранится в виде статическом локальной переменной и ее значение будет возвращаться наружу. Таким образом любое использование переменной будет проходить через вызов этой функции и нам гарантируется, что переменная создастся в момент первого вызова функции. Техника заслуживает отдельный пост, который выйдет чуть позже.
Solve your problems. Stay cool.
#cppcore
Empty base optimization
В этом посте мы рассказали, о том, сколько весит объект пустого класса. Настоятельно рекомендую вернуться к этому посту, чтобы быть в контексте.
Теперь возникает вопрос: что будет, если мы отнаследуемся от пустого класса? Каким образом будет учитываться этот один байт в наследнике и где он будет расположен?
Вообще говоря, ненулевой размер объекта пустого класса нужен просто для нормальной его адресации. Никакой полезной нагрузки он не несет и нужен, чтобы "просто работало". Однако, когда мы наследуется от такого класса, и, например, размещаем в наследнике какие-то поля, то наследник уже не нуждается в фейковом байте, чтобы нормально работать. У него это и так получится прекрасно. Получается, что этот 1 байт будет, как жабры на теле млекопитающего: предкам были нужны, а сейчас вообще ни к селу, ни к пгт.
Поэтому есть такое понятие, как empty base class optimization. Если мы наследуемся от пустого класса, то размер класса наследника будет ровно таким же, как как будто бы он ни от чего не наследовался.
Пример:
Вывод консоли:
Вроде бы очень логичная штука и даже почти интуитивная штука, но немногие знают в ее в профиль и анфас, поэтому сегодня исправили этот момент)
Optimize your life. Stay cool.
#cppcore #optimization
В этом посте мы рассказали, о том, сколько весит объект пустого класса. Настоятельно рекомендую вернуться к этому посту, чтобы быть в контексте.
Теперь возникает вопрос: что будет, если мы отнаследуемся от пустого класса? Каким образом будет учитываться этот один байт в наследнике и где он будет расположен?
Вообще говоря, ненулевой размер объекта пустого класса нужен просто для нормальной его адресации. Никакой полезной нагрузки он не несет и нужен, чтобы "просто работало". Однако, когда мы наследуется от такого класса, и, например, размещаем в наследнике какие-то поля, то наследник уже не нуждается в фейковом байте, чтобы нормально работать. У него это и так получится прекрасно. Получается, что этот 1 байт будет, как жабры на теле млекопитающего: предкам были нужны, а сейчас вообще ни к селу, ни к пгт.
Поэтому есть такое понятие, как empty base class optimization. Если мы наследуемся от пустого класса, то размер класса наследника будет ровно таким же, как как будто бы он ни от чего не наследовался.
Пример:
struct EmptyClass {
void MethodMeantJustNotToLeaveClassDeadInside() {}
};
struct Derived : public EmptyClass {
int a;
char b;
double c;
};
struct SizeReference {
int a;
char b;
double c;
};
int main() {
EmptyClass a;
Derived b;
SizeReference c;
std::cout << "EmptyClass object size: " << sizeof(a) << std::endl;
std::cout << "Derived object size: " << sizeof(b) << std::endl;
std::cout << "SizeReference object size: " << sizeof(c) << std::endl;
}
Вывод консоли:
EmptyClass object size: 1
Derived object size: 16
SizeReference object size: 16
Вроде бы очень логичная штука и даже почти интуитивная штука, но немногие знают в ее в профиль и анфас, поэтому сегодня исправили этот момент)
Optimize your life. Stay cool.
#cppcore #optimization
Идентификатор
#новичкам
Продолжаем серию постов! Ранее мы уже упоминали идентификатор со специальным значением
Запретить переопределение метода в наследниках можно с помощью идентификатора
Ограничивать переопределения виртуальных методов может показаться неочевидным действием. Казалось бы, вот к чему это? Этот вопрос стоит рассматривать с технической и смысловой точки зрения. Начнем с последнего.
Код, который вы пишите, может быть достаточно глубоко осмыслен и выразителен. В частности, в семействе вашего класса можно выделить подсемейство со специфичным и фиксированным поведением.
В качестве примера, давайте рассмотрим семейство датчиков умного дома. Пусть была разработана открытая библиотека, которая предоставляет некоторый набор интерфейсов для разных типов датчиков. Например, для подсемейства пожарных датчиков. Производители устройств могут наследовать специальный интерфейс и реализовать прошивку для своего девайса. Как у них работает этот сенсор — никто не знает, но главное, что в случае срабатывания такого датчика происходит важное, в рамках этого подсемейства, действие - вызывается бригада пожарных. Это достаточно важный смысл, который может быть заложен в наследника класса и ограничен в переопределении для производителя:
Думаю, обосновывать важность этого ограничения не стоит. Если производитель по каким-то причинам захочет вызвать бригаду стриптизёров вместо пожарных, то до жаркой вечеринки дело не дойдёт!
Конечно, можно написать комментарии к коду, в надежде, что их прочитают... Но вот практика показывает, что их периодически игнорируют. Если какое-то ограничение не срабатывает во время компиляции и доходит до ревью, то разработка затягивается. Пока это увидят, пока переделают... Да и вообще, это сработает, только если у проверяющего достаточно компетенций / внимательности.
Кстати, хоть
С помощью идентификатора
#cppcore #cpp11
final
для виртуальных методов#новичкам
Продолжаем серию постов! Ранее мы уже упоминали идентификатор со специальным значением
final
в рамках наследования классов — запрещали создавать наследников того или иного класса. Это поведение распространялось на весь класс целиком, в том числе и на все его методы. Это может быть слишком строгим ограничением в какой-то ветке нашего подсемейства. Например, мы хотим его продолжать развивать, но точечно зафиксировать поведение одного конкретного переопределенного метода.Запретить переопределение метода в наследниках можно с помощью идентификатора
final
(С++ 11 и выше):struct Child : public Parent
{
void method_name() override final;
};
Ограничивать переопределения виртуальных методов может показаться неочевидным действием. Казалось бы, вот к чему это? Этот вопрос стоит рассматривать с технической и смысловой точки зрения. Начнем с последнего.
Код, который вы пишите, может быть достаточно глубоко осмыслен и выразителен. В частности, в семействе вашего класса можно выделить подсемейство со специфичным и фиксированным поведением.
В качестве примера, давайте рассмотрим семейство датчиков умного дома. Пусть была разработана открытая библиотека, которая предоставляет некоторый набор интерфейсов для разных типов датчиков. Например, для подсемейства пожарных датчиков. Производители устройств могут наследовать специальный интерфейс и реализовать прошивку для своего девайса. Как у них работает этот сенсор — никто не знает, но главное, что в случае срабатывания такого датчика происходит важное, в рамках этого подсемейства, действие - вызывается бригада пожарных. Это достаточно важный смысл, который может быть заложен в наследника класса и ограничен в переопределении для производителя:
// Датчик возгорания
struct IFireSensor : public ISensor
{
// В случае срабатывания, вызываем пожарных
void onAlarm(control_panel_t *ctrl) override final
{
ctrl->call_fireman();
}
};
Думаю, обосновывать важность этого ограничения не стоит. Если производитель по каким-то причинам захочет вызвать бригаду стриптизёров вместо пожарных, то до жаркой вечеринки дело не дойдёт!
Конечно, можно написать комментарии к коду, в надежде, что их прочитают... Но вот практика показывает, что их периодически игнорируют. Если какое-то ограничение не срабатывает во время компиляции и доходит до ревью, то разработка затягивается. Пока это увидят, пока переделают... Да и вообще, это сработает, только если у проверяющего достаточно компетенций / внимательности.
Кстати, хоть
final
и может быть применён только к виртуальному методу, мы не можем с помощью него проверить действительно ли мы переопределяем метод. Идентификатор final
может быть применён к новому объявленному методу, а override
нельзя. Следовательно, мы можем добиться такой ситуации: живой пример.С помощью идентификатора
final
можно расширить список гарантий, которые предоставляет выделенная ветка семейства классов. Это может оказаться полезным не только для разработчиков, но и для компилятора. Так можно выполнить некоторые оптимизации, ускоряющие работу вашей программы. Поговорим об этом в следующих постах.#cppcore #cpp11
Telegram
Грокаем C++
Всем привет! Мы в очередной раз подготовили серию грокательных постов о простом и не очень. В ближайшее время нас ждут следующие посты:
1. Память наследованных классов
2. Память виртуально наследованных классов
3. Девиртуализация доступа к полям виртуально…
1. Память наследованных классов
2. Память виртуально наследованных классов
3. Девиртуализация доступа к полям виртуально…
Construct on first use idiom
Давайте здесь по-подробнее остановимся. Вещь важная. Предыдущий пост. #опытным
Название говорящее и говорит оно нам, что объект будет конструироваться при первом использовании, а не когда-то заранее. То есть это ленивые вычисления.
Суть в том, чтобы создавать объект только в тот момент, когда он нам понадобиться. Так мы можем четко контролировать момент его инициализации. Делается это с помощью статических локальных переменных.
Мы помним, что они инициализируются при первом вызове функции и существуют они до смерти всей программы. Таким образом, если мы из функции будем возвращать ссылку на эту переменную, то есть сделаем такой геттер, то мы функционально будем иметь глобальную переменную, для которой мы контролируем начало ее жизни.
Вернемся к примеру и посмотрим, как это выглядит. Было так:
а теперь стало так:
Переменная
Теперь следите за руками: мы берем и оборачивает переменную, задающую значение, в функцию-геттер, которая просто выдает наружу значение этой переменной. Но инициализироваться
Теперь результат компиляции не зависит от порядка файлов, которые передаются на вход. Что так
Если у класса есть статическое поле и создание класса зависит от этого статического поля, то попробуйте перенести это поле внутрь статической функции(пример из этого поста):
Теперь во всех местах использования бывшего статического поля, мы вызывает статический метод. Таким образом наша мапа создается ровно по первому нашему хотению и создавать статический объект класса InitializationTest теперь абсолютно безопасно.
Если у вас есть 2 статических объекта пользовательского типа и инициализация одного из них предполагает использование другого, то можно сделать так(пример нагло украден у подписчика Бобра из этого коммента)
В этом примере создание объекта класса AnotherSingleton зависит от объекта Singleton. Поэтому мы запрещаем плебесам создавать объекты класса Singleton, а создаем его один раз в статической функции геттера инстанса объекта и дальше везде используем только этот инстанс.
Заключение в комментах
Solve your problems. Stay cool.
#cppcore #goodpractice #design
Давайте здесь по-подробнее остановимся. Вещь важная. Предыдущий пост. #опытным
Название говорящее и говорит оно нам, что объект будет конструироваться при первом использовании, а не когда-то заранее. То есть это ленивые вычисления.
Суть в том, чтобы создавать объект только в тот момент, когда он нам понадобиться. Так мы можем четко контролировать момент его инициализации. Делается это с помощью статических локальных переменных.
Мы помним, что они инициализируются при первом вызове функции и существуют они до смерти всей программы. Таким образом, если мы из функции будем возвращать ссылку на эту переменную, то есть сделаем такой геттер, то мы функционально будем иметь глобальную переменную, для которой мы контролируем начало ее жизни.
Вернемся к примеру и посмотрим, как это выглядит. Было так:
// source.cpp
int quad(int n) {
return n * n;
}
auto staticA = quad(5);
// main.cpp
#include <iostream>
extern int staticA;
auto staticB = staticA;
int main() {
std::cout << "staticB: " << staticB << std::endl;
}
а теперь стало так:
// source.cpp
int quad(int n) {
return n * n;
}
int& GetStaticA() {
static int staticA = quad(5);
return staticA;
}
// main.cpp
#include <iostream>
int& GetStaticA();
static auto staticB = GetStaticA();
// just omit main
Переменная
staticB
зависит от значения staticA
и это может вызвать проблемы, если инициализации staticB
произойдет первой.Теперь следите за руками: мы берем и оборачивает переменную, задающую значение, в функцию-геттер, которая просто выдает наружу значение этой переменной. Но инициализироваться
staticA
будет ровно в момент первого вызова функции GetStaticA
. Таким образом, мы форсим рантайм инициализировать staticA первым
при любых обстоятельствах.Теперь результат компиляции не зависит от порядка файлов, которые передаются на вход. Что так
g++ main.cpp source.cpp
, что так g++ source.cpp main.cpp
, результат будет staticB: 25
.Если у класса есть статическое поле и создание класса зависит от этого статического поля, то попробуйте перенести это поле внутрь статической функции(пример из этого поста):
using Map = std::map<std::string, std::unique_ptr<InitializationTest>>;
class InitializationTest {
public:
static Map& GetMap() {
static Map map;
return map;
}
static bool Create(std::string ID) {
GetMap().insert({ID, std::move(std::unique_ptr<InitializationTest>{new InitializationTest})});
return true;
}
private:
static Map map;
Test() = default;
};
static bool creation_result = InitializationTest::Create("qwe");
int main() {}
Теперь во всех местах использования бывшего статического поля, мы вызывает статический метод. Таким образом наша мапа создается ровно по первому нашему хотению и создавать статический объект класса InitializationTest теперь абсолютно безопасно.
Если у вас есть 2 статических объекта пользовательского типа и инициализация одного из них предполагает использование другого, то можно сделать так(пример нагло украден у подписчика Бобра из этого коммента)
// singleton.h
class Singleton {
public:
static Singleton& instance() {
static Singleton inst{};
return inst;
}
int makeSomethingUsefull(){}
private:
Singleton() = default;
};
//another_singleton.h
#include "singleton.h"
class AnotherSingleton {
public:
static AnotherSingleton& instance() {;
static AnotherSingleton inst{Singleton::instance().makeSomethingUsefull()};
return inst;
}
private:
AnotherSingleton(int param) : data{param} {};
int data;
};
В этом примере создание объекта класса AnotherSingleton зависит от объекта Singleton. Поэтому мы запрещаем плебесам создавать объекты класса Singleton, а создаем его один раз в статической функции геттера инстанса объекта и дальше везде используем только этот инстанс.
Заключение в комментах
Solve your problems. Stay cool.
#cppcore #goodpractice #design
Telegram
Грокаем C++
Решение static initialization order fiasco
Раз есть проблема - должно быть и решение. Сегодня поговорим о паре-тройке вариантов. Пост вдохновлен этим комментом нашего подписчика Антона.
Очевидно, что в комментах немного поразгоняли эту тему. Поэтому вот…
Раз есть проблема - должно быть и решение. Сегодня поговорим о паре-тройке вариантов. Пост вдохновлен этим комментом нашего подписчика Антона.
Очевидно, что в комментах немного поразгоняли эту тему. Поэтому вот…
Как работает динамический полиморфизм?
#новичкам
Продолжаем серию постов! В предыдущих статьях мы немного познакомились с возможностями полиморфных классов. Давайте подумаем, как же эта штука работает? По возможности, на собеседованиях интересуются этим вопросом 😉
Наверняка у вас так или иначе пробегал вопрос в голове: как же во время выполнения программы получается выбрать нужную реализацию метода, обращаясь к указателю лишь базового класса?
Это подталкивает к мысли, что объект полиморфного класса хранит какой-то секретик и владеет информацией о том, какие реализации методов надо вызывать.
Объекты полиморфных классов отличаются тем, что содержат в себе скрытый указатель на дополнительный участок памяти. В частности, размер объекта полиморфного класса немного больше:
Несмотря на то, что в
Данный скрытый указатель ведет в статическую область памяти, где лежит таблица виртуальных методов. Эта таблица представляет собой массив указателей на методы полиморфных наследников, в том числе и переопределенные. В общем случае, компилятор генерирует такие инструкции, которые будут разыменовывать эти указатели и совершать вызов нужной реализации. Это называется косвенным вызовом, indirect call.
Независимо от типа указателя на объект полиморфного класса, его скрытый указатель будет смотреть именно на ту таблицу, которая ассоциирована с конструированным классом:
Таким образом, и получается отвязать тип указателя от набора методов, которые должны быть вызваны.
Таблицы виртуальных методов генерируются на каждый полиморфный класс (не объект!), чтобы учесть все переопределения методов. Компилятор анализирует объявленные виртуальные методы и пронумеровывает их, а затем в этом порядке размещает в таблице. Например, для базового класса она будет выглядеть так:
А для наследованного класса уже вот так:
В конкретно взятых табличках всего две ячейки, которые хранят адрес на свою реализацию виртуального метода. В момент вызова, нам будет известен порядковый номер виртуального метода, а значит и его ячейку в таблице.
В общем случае, без каких либо оптимизаций, вызов виртуального метода состоит из следующих шагов:
1. Прочитать скрытый виртуальный указатель на таблицу
2. Сместить значение загруженного указателя до записи в таблице с адресом вызываемого метода
3. Прочитать адрес метода
4. Выполнить косвенный вызов по прочитанному адресу
Давайте мысленно препарируем участок вызывающего кода:
Думаю, что по моим комментариям к коду, а именно п. 6, видно, что даже сама программа не знает, что именно она вызывает, пока этого не сделает. Именно поэтому эта механика называется динамический полиморфизм.
Продолжение в комментариях 👇
#howitworks #cppcore
#новичкам
Продолжаем серию постов! В предыдущих статьях мы немного познакомились с возможностями полиморфных классов. Давайте подумаем, как же эта штука работает? По возможности, на собеседованиях интересуются этим вопросом 😉
Наверняка у вас так или иначе пробегал вопрос в голове: как же во время выполнения программы получается выбрать нужную реализацию метода, обращаясь к указателю лишь базового класса?
struct Base
{
virtual void vmethod_1();
virtual void vmethod_2();
};
struct Derived : public Base
{
void vmethod_2() override;
};
Base *data = new Derived();
// Calls Derived::vmethod_2()
data->vmethod_2();
Это подталкивает к мысли, что объект полиморфного класса хранит какой-то секретик и владеет информацией о том, какие реализации методов надо вызывать.
Объекты полиморфных классов отличаются тем, что содержат в себе скрытый указатель на дополнительный участок памяти. В частности, размер объекта полиморфного класса немного больше:
sizeof(Base) // returns 8
Несмотря на то, что в
Base
нет никаких полей, в данном случае размер не будет равен одному байту. Класс Base
формально пуст, но как раз под этот скрытый указатель резервируется доп. память: живой пример. На платформе x86-64 размер указателя равен 8 байт.Данный скрытый указатель ведет в статическую область памяти, где лежит таблица виртуальных методов. Эта таблица представляет собой массив указателей на методы полиморфных наследников, в том числе и переопределенные. В общем случае, компилятор генерирует такие инструкции, которые будут разыменовывать эти указатели и совершать вызов нужной реализации. Это называется косвенным вызовом, indirect call.
Независимо от типа указателя на объект полиморфного класса, его скрытый указатель будет смотреть именно на ту таблицу, которая ассоциирована с конструированным классом:
// Скрытый указатель объекта
// смотрит на vtable класса Base
Base *data = new Base();
// Скрытый указатель объекта
// смотрит на vtable класса Derived
Base *data = new Derived();
Таким образом, и получается отвязать тип указателя от набора методов, которые должны быть вызваны.
Таблицы виртуальных методов генерируются на каждый полиморфный класс (не объект!), чтобы учесть все переопределения методов. Компилятор анализирует объявленные виртуальные методы и пронумеровывает их, а затем в этом порядке размещает в таблице. Например, для базового класса она будет выглядеть так:
| vtable of Base |
|---------------------|
| &Base::vmethod_1 |
|---------------------|
| &Base::vmethod_2 |
А для наследованного класса уже вот так:
| vtable of Derived |
|---------------------|
| &Base::vmethod_1 |
|---------------------|
| &Derived::vmethod_2 |
В конкретно взятых табличках всего две ячейки, которые хранят адрес на свою реализацию виртуального метода. В момент вызова, нам будет известен порядковый номер виртуального метода, а значит и его ячейку в таблице.
В общем случае, без каких либо оптимизаций, вызов виртуального метода состоит из следующих шагов:
1. Прочитать скрытый виртуальный указатель на таблицу
2. Сместить значение загруженного указателя до записи в таблице с адресом вызываемого метода
3. Прочитать адрес метода
4. Выполнить косвенный вызов по прочитанному адресу
Давайте мысленно препарируем участок вызывающего кода:
void virtual_call(Base *object)
{
// 1. Разыменовываем указатель `data` на класс `Base`
// 2. Читаем указатель на vtable
// 3. Смещаемся на величину 1 указателя
// 4. Читаем указатель на `vmethod_2`
// 5. Вызываем данный метод
// 6. Ого! Оказывается, это была переопределение Derived::vmethod_2
object->vmethod_2();
}
Думаю, что по моим комментариям к коду, а именно п. 6, видно, что даже сама программа не знает, что именно она вызывает, пока этого не сделает. Именно поэтому эта механика называется динамический полиморфизм.
Продолжение в комментариях 👇
#howitworks #cppcore
Проблема Construct on first use idiom
#опытным
Прошлый пост показывает решение проблемы static initialization order fiasco. Однако даже этот прием имеет свои проблемы.
Дело в том, что мы сильно фокусировались на инициализации объекта и решали проблемы с ней. Но как насчет разрушения объекта? Мы подумали об этом? Not really.
Давайте возьмем классы, которые могут быть использованы для создания и статических объектов и любых других.
У нас все также 2 класса, но они уже не синглтоны, а могут создаваться в какой угодно области. Нам нужны статические объекты этих классов. И мы, как умные дяди, оградили себя от проблемы инициализации статиков, используя construct on first use idiom. Однако замечу, что в деструкторах наших классов они используют глобальную переменную another_global. И например, для объектов с автоматическим временем жизни это вообще не проблема, они свободно создаются и разрушаются.
Но что же будет, если так получится, что another_global удалится раньше, чем статические объекты наших классов? Правильно. Static deinitialization order fiasco. Обращение к уже разрушенному объекту - такое же UB, как и обращение к еще не инициализированному.
Кому-то очень сильно сейчас может свести багскулы, потому что логирование в деструкторах объектов, которые могут быть статиками - очень частая вещь, а соотвественно и потенциальная проблема. Подписчики могут подтвердить это в комментах.
Я сознательно тут в пример не ставлю синглтоны, потому что для них еще как-то можно осознать потенциальную проблему самостоятельно: объект один, мы четко понимаем, как он себя ведет, и можем подумать о его разрушении. Но в сегодняшнем примере при создании подобных классов обычно сильно не задумываются, что объект могут создать в статической области, а значит и о статической деинициализации не думают. Такая невнимательность может привести к трудноотловимым багам.
И это проблема не идиомы в целом, а подхода к созданию объекта. Есть и другой способ это делать:
Обратите внимание на магию. Мы внутри статических функций определяем не статические объекты, а статические указатели, к которым при первом вызове прикрепляем динамически созданные объекты. Вроде ничего кардинально не поменялось, но это на первый взгляд.
Мы никогда не вызываем delete. В конце программы разрушится только указатель, но не объект, на который он указывает. Обычно такая ситуация называется data leak, но в этом случае "вы не понимаете, это другое". Потому что при завершении программы ОС сама освобождает всю память, которая была занята программой и на самом деле ничего не утекает. Утечка памяти - это постоянное увеличение использования памяти программы со временем ее жизни. А тут мы один раз захватили эту память(и только эту!), но просто не отдали. Потребление памяти в течение программы не увеличивается. Как говорится: "Это норма!".
Этот вариант конечно не подойдет для тех случаев, если вам прям обязательно как-то сигнализировать о разрушении всех-превсех объектов этого класса и без этого никуда. Но он совершенно точно избавит вас от потенциальных проблем деинициализации(ее просто не будет хехе), если вам не важен деструктор статических объектов.
See drawbacks of your solutions. Stay cool.
#goodpractice #design #cppcore
#опытным
Прошлый пост показывает решение проблемы static initialization order fiasco. Однако даже этот прием имеет свои проблемы.
Дело в том, что мы сильно фокусировались на инициализации объекта и решали проблемы с ней. Но как насчет разрушения объекта? Мы подумали об этом? Not really.
Давайте возьмем классы, которые могут быть использованы для создания и статических объектов и любых других.
// ClassA.h
class ClassA {
public:
int makeSomethingUsefull(){}
~ClassA() { another_global.use_it();}
};
static ClassA& GetStaticClassA() {
static ClassA inst{};
return inst;
}
//another_singleton.h
#include "singleton.h"
class ClassB {
public:
ClassB(int param) : data{param} {};
~ClassB() { another_global.use_it();}
private:
int data;
};
static ClassB& GetStaticClassB() {;
static ClassB inst{GetStaticClassA().makeSomethingUsefull()};
return inst;
}
У нас все также 2 класса, но они уже не синглтоны, а могут создаваться в какой угодно области. Нам нужны статические объекты этих классов. И мы, как умные дяди, оградили себя от проблемы инициализации статиков, используя construct on first use idiom. Однако замечу, что в деструкторах наших классов они используют глобальную переменную another_global. И например, для объектов с автоматическим временем жизни это вообще не проблема, они свободно создаются и разрушаются.
Но что же будет, если так получится, что another_global удалится раньше, чем статические объекты наших классов? Правильно. Static deinitialization order fiasco. Обращение к уже разрушенному объекту - такое же UB, как и обращение к еще не инициализированному.
Кому-то очень сильно сейчас может свести багскулы, потому что логирование в деструкторах объектов, которые могут быть статиками - очень частая вещь, а соотвественно и потенциальная проблема. Подписчики могут подтвердить это в комментах.
Я сознательно тут в пример не ставлю синглтоны, потому что для них еще как-то можно осознать потенциальную проблему самостоятельно: объект один, мы четко понимаем, как он себя ведет, и можем подумать о его разрушении. Но в сегодняшнем примере при создании подобных классов обычно сильно не задумываются, что объект могут создать в статической области, а значит и о статической деинициализации не думают. Такая невнимательность может привести к трудноотловимым багам.
И это проблема не идиомы в целом, а подхода к созданию объекта. Есть и другой способ это делать:
// ClassA.h
// Here Class A definition
static ClassA& GetStaticClassA() {
static ClassA* inst = new ClassA{};
return *inst;
}
//another_singleton.h
#include "singleton.h"
// Here ClassB definition
static ClassB& GetStaticClassB() {;
static ClassB* inst = new ClassB{GetStaticClassA().makeSomethingUsefull()};
return *inst;
}
Обратите внимание на магию. Мы внутри статических функций определяем не статические объекты, а статические указатели, к которым при первом вызове прикрепляем динамически созданные объекты. Вроде ничего кардинально не поменялось, но это на первый взгляд.
Мы никогда не вызываем delete. В конце программы разрушится только указатель, но не объект, на который он указывает. Обычно такая ситуация называется data leak, но в этом случае "вы не понимаете, это другое". Потому что при завершении программы ОС сама освобождает всю память, которая была занята программой и на самом деле ничего не утекает. Утечка памяти - это постоянное увеличение использования памяти программы со временем ее жизни. А тут мы один раз захватили эту память(и только эту!), но просто не отдали. Потребление памяти в течение программы не увеличивается. Как говорится: "Это норма!".
Этот вариант конечно не подойдет для тех случаев, если вам прям обязательно как-то сигнализировать о разрушении всех-превсех объектов этого класса и без этого никуда. Но он совершенно точно избавит вас от потенциальных проблем деинициализации(ее просто не будет хехе), если вам не важен деструктор статических объектов.
See drawbacks of your solutions. Stay cool.
#goodpractice #design #cppcore
Еще один способ решения Static Initialization Order Fiasco
#опытным
Предыдущий пост навел меня на еще один метод решения SIOF. Это в догонку к этому посту с решениями.
Суть в чем. Как верно указал наш подписчик xiran в этом комментарии - управлять временем жизни глобальных динамически созданных объектов намного проще, чем временем жизни статиков. Поэтому можно объявить не статические переменные, а статические указатели. Указатель можно инициализировать nullptr и оставить его в таком состоянии хоть на месяц. И вы можете его инициализировать в любой подходящий для вас момент времени.
Это позволит вам в одном месте инициализировать связанные объекты сразу и в том порядке, в котором это не вызовет неприятных эффектов. Вы полностью контролируете ситуацию.
Примерно так это все выглядит. Если раньше, при обычной инициализации статиков в разных единицах трансляции, у нас порядок зависел от разумения линкера, то сейчас как ни компилируй, как ни линкуй, как ни меняй версию компилятора - все будет работать. Расширяйте этот пример как угодно, тема рабочая.
Правда тут есть одна загвоздочка, как вы могли заметить. У нас статиками являются обычные указатели и при разрушении всех статиков освободится лишь те 8 байт, которые были отведены этому указателю и никакого delete вызвано не будет. Как бы ситуация не очень, но нам и не всегда нужны эффекты от удаления статических объектов.
И эту загвоздочку прекрасно решают умные указатели. Сергей в своем комменте заванговал их использование. Покажу на примере unique_ptr. При деинициализации статиков вызовется деструктор unique_ptr, который за собой потянет деструктор объекта. Тут тоже могут быть проблемы с индирекцией данных и более медленным доступом к ним, но это настолько редкий кейс с плохим дизайном, что не хочется это даже обсуждать.
Вот так это выглядит в "идеале". Можете дальше пользоваться своими глобальными переменными(осуждаем), но хотя бы безопасно.
Stay safe. Stay cool.
#cpprore #cpp11 #STL #pattern
#опытным
Предыдущий пост навел меня на еще один метод решения SIOF. Это в догонку к этому посту с решениями.
Суть в чем. Как верно указал наш подписчик xiran в этом комментарии - управлять временем жизни глобальных динамически созданных объектов намного проще, чем временем жизни статиков. Поэтому можно объявить не статические переменные, а статические указатели. Указатель можно инициализировать nullptr и оставить его в таком состоянии хоть на месяц. И вы можете его инициализировать в любой подходящий для вас момент времени.
Это позволит вам в одном месте инициализировать связанные объекты сразу и в том порядке, в котором это не вызовет неприятных эффектов. Вы полностью контролируете ситуацию.
// header.hpp
struct Class {
Class(int num) : field{num} {}
int field;
};
// source.cpp
Class * static_ptr2 = nullptr;
//main.cpp
int * static_ptr1;
extern Class * static_ptr2;
void Init() {
static_ptr1 = new int{6};
static_ptr2 = new Class{*static_ptr1};
}
int main() {
Init();
std::cout << static_ptr2->field << std::endl;
}
Примерно так это все выглядит. Если раньше, при обычной инициализации статиков в разных единицах трансляции, у нас порядок зависел от разумения линкера, то сейчас как ни компилируй, как ни линкуй, как ни меняй версию компилятора - все будет работать. Расширяйте этот пример как угодно, тема рабочая.
Правда тут есть одна загвоздочка, как вы могли заметить. У нас статиками являются обычные указатели и при разрушении всех статиков освободится лишь те 8 байт, которые были отведены этому указателю и никакого delete вызвано не будет. Как бы ситуация не очень, но нам и не всегда нужны эффекты от удаления статических объектов.
И эту загвоздочку прекрасно решают умные указатели. Сергей в своем комменте заванговал их использование. Покажу на примере unique_ptr. При деинициализации статиков вызовется деструктор unique_ptr, который за собой потянет деструктор объекта. Тут тоже могут быть проблемы с индирекцией данных и более медленным доступом к ним, но это настолько редкий кейс с плохим дизайном, что не хочется это даже обсуждать.
// header.hpp
struct Class {
Class(int num) : field{num} {}
int field;
};
// source.cpp
std::unique_ptr<Class> static_ptr2 = nullptr;
//main.cpp
std::unique_ptr<int> static_ptr1 = nullptr;
extern std::unique_ptr<Class> static_ptr2;
void Init() {
static_ptr1 = std::make_unique<int>(6);
static_ptr2 = std::make_unique<Class>(*static_ptr1);
}
int main() {
Init();
std::cout << static_ptr2->field << std::endl;
}
Вот так это выглядит в "идеале". Можете дальше пользоваться своими глобальными переменными(осуждаем), но хотя бы безопасно.
Stay safe. Stay cool.
#cpprore #cpp11 #STL #pattern
Как работает dynamic_cast? RTTI!
#опытным #fun
Продолжаем серию! В прошлой статье мы познакомились с таблицей виртуальных методов. Помимо этой таблицы, в этой же области памяти скрывается еще одна структура.
Как мы видели ранее, для полиморфных объектов существует специальный оператор dynamic_cast. Стандарт не регламентирует его реализацию, но чаще всего, для работы требуется дополнительная информация о типе полиморфного объекта RTTI (Run Time Type Information). Посмотреть эту структуру можно с помощью оператора
Содержимое RTTI зависит от компилятора, но как минимум там хранится
Операторы
Как же нам найти начало объекта RTTI? Не боги горшки обжигают, есть просто специальный указатель, который расположен прямо перед началом таблицы виртуальных методов. Он и ведёт к объекту RTTI:
Получив доступ к дополнительной информации остаётся выполнить приведение типа: upcast, downcast, sidecast/crosscast. Эта задача требует совершить поиск в ориентированном ациклическом графе (DAG, directed acyclic graph), что в рамках этой операции может быть трудоёмким, но необходимым для обработки общего случая. Теперь мы можем даже ответить, почему
Можем ли мы как-то ускорить работу? Мы можем просто запретить использовать
И такое ограничение будет автоматически подталкивать к пересмотру полученной архитектуры решения или разработке собственного механизма приведения типов.
На счет последнего надо много и долго думать. На стыке двух динамических библиотек, которые могут ничего не знать друг о друге, придется как-то проверять, что лежит в динамическом типе. Так же необходимо учитывать особенности множественного и виртуального наследования. От них можно и в принципе отказаться, но как запретить вышеупомянутые виды наследования в коде? Меня бы в первую очередь интересовала автономная и независимая жизнь проекта без пристального надзора хранителей знаний. Это задача, которая имеет много подводных камней или требует введения в проект ограничений, дополнительного контроля.
Если
#cppcore #howitworks
#опытным #fun
Продолжаем серию! В прошлой статье мы познакомились с таблицей виртуальных методов. Помимо этой таблицы, в этой же области памяти скрывается еще одна структура.
Как мы видели ранее, для полиморфных объектов существует специальный оператор dynamic_cast. Стандарт не регламентирует его реализацию, но чаще всего, для работы требуется дополнительная информация о типе полиморфного объекта RTTI (Run Time Type Information). Посмотреть эту структуру можно с помощью оператора
typeid
:cppОбратите внимание,
const auto &RTTI = typeid(object);
typeid
возвращает read-only ссылку на объект std::type_info
, т.к. эту область памяти нельзя изменять — она была сгенерирована компилятором на этапе компиляции.Содержимое RTTI зависит от компилятора, но как минимум там хранится
hash
полиморфного класса и его имя, которые доступны из std::type_info
. Маловероятно, что вам на этом потребуется построить какую-то логику приложения, но эта штука могла бы быть вам полезна при отладке / подсчёте статистики и т.д. Операторы
dynamic_cast
и typeid
получают доступ к этой структуре так же через скрытый виртуальный указатель, который подшивается к объектам полиморфного класса. Как мы знаем, этот указатель смотрит на начало таблицы виртуальных методов, коих может быть бесчисленное множество и варьироваться от наследника к наследнику.Как же нам найти начало объекта RTTI? Не боги горшки обжигают, есть просто специальный указатель, который расположен прямо перед началом таблицы виртуальных методов. Он и ведёт к объекту RTTI:
┌-─| ptr to RTTI | vtable pointer
| |----------------| <- looks here
| | vtable methods |
| |----------------|
└─>| RTTI object |
Получив доступ к дополнительной информации остаётся выполнить приведение типа: upcast, downcast, sidecast/crosscast. Эта задача требует совершить поиск в ориентированном ациклическом графе (DAG, directed acyclic graph), что в рамках этой операции может быть трудоёмким, но необходимым для обработки общего случая. Теперь мы можем даже ответить, почему
dynamic_cast
такой медленный.Можем ли мы как-то ускорить работу? Мы можем просто запретить использовать
dynamic_cast
😄 Это можно сделать, отключив RTTI с помощью флага компиляции:-fno-rtti
И такое ограничение будет автоматически подталкивать к пересмотру полученной архитектуры решения или разработке собственного механизма приведения типов.
На счет последнего надо много и долго думать. На стыке двух динамических библиотек, которые могут ничего не знать друг о друге, придется как-то проверять, что лежит в динамическом типе. Так же необходимо учитывать особенности множественного и виртуального наследования. От них можно и в принципе отказаться, но как запретить вышеупомянутые виды наследования в коде? Меня бы в первую очередь интересовала автономная и независимая жизнь проекта без пристального надзора хранителей знаний. Это задача, которая имеет много подводных камней или требует введения в проект ограничений, дополнительного контроля.
Если
dynamic_cast
становится бутылочным горлышком, то в первую очередь стоит пересмотреть именно архитектуру решения, а оптимизации оставить на крайний случай.#cppcore #howitworks