Синтаксис, синглтон и смертельный ромб в С++: взгляд опытного разработчика на C
Давайте знакомиться: меня зовут Анатолий Семятнёв, я и моя команда разрабатываем ПО для опорных сетей 5G в YADRO. В IT-сфере работаю давно, и мой опыт в основном связан с языком С: занимался Board Support Package (BSP) и драйверами, много работал с операционной системой QNX.
До того, как начал полноценно работать на С++, сталкивался с языком в нулевые, писал на С++98. Тем не менее все это время я краем глаза поглядывал, что происходит в С++, и хотел вернуться к программированию на этом языке. Читал книги, делал пет-проекты, смотрел записи конференций и митапов по С++. А когда пришел в YADRO, стал писать на С++.
Мне с ходу дали большую фичу для имплементации, я писал много кода, и получал комментарии от коллег. В этом материале собрал все, что изучил или вспомнил по итогам код-ревью. Что рассмотрим в статье:
Ключевые концепции — explicit, final, default, string — и как их использовать.
Инициализацию мемберов с помощью пустого брейс-листа.
Синглтон Майерса в корутинах.
«Смертельный ромб» и все, что связано с виртуальным наследованием.
Мы ищем специалистов в департамент разработки технологий мобильной связи. Сейчас открыты две вакансии: инженер на С/С++ и разработчик на С++ в 5G Core. Если хотите присоединиться к моей команде, оставляйте резюме на карьерном портале YADRO.
Особенности синтаксиса на С++: explicit, final, default, string
Explicit
Когда в конструкторе один аргумент
Как я написал выше, я получил в разработку крупную фичу, в работе над которой потребовалось написать много различных классов. Однажды я написал конструктор, который принимал один параметр.
class A;
class MyClass
{
public:
MyClass(A *a); // please add ‘explicit’
private:
A * m_pA;
};
Комментарий ревьюера: «Добавляй к конструктору explicit, потому что тут только один аргумент».
Я добавил и пошел разбираться, почему нужно применять explicit в таких случаях.
class A;
class MyClass
{
public:
MyClass(A *a); // converting constructor
private:
A * m_pA;
};
int f(MyClass mc)
{
….
}
int main() {
std::cout << f(0) << std::endl; // OK, 0 => MyClass(0)
return 0;
}
У нас есть простой класс, конструктор с одним аргументом и функция f, которая принимает объект этого класса. Функцию f вызываем с нулем. Код прекрасно компилируется, конструируется MyClass, в качестве параметра конструктора передается ноль. Как только добавляем слово explicit, компилятор ругается.
Что же происходит, когда используем explicit с конструктором с одним аргументом? Неявные преобразования, в нашем случае — User Defined преобразования. Мы имеем дело с так называемым Converting Constructor. Почему со словом explicit не работает, а без explicit работает? Потому что неявные преобразования возможны только с не explicit-конструкторами.
Когда в конструкторе два аргумента и более
Когда появились классы с несколькими аргументами в конструкторе, снова использовал explicit. На что получил комментарий: «А зачем здесь explicit?». Пошел разбираться дальше, имеет ли смысл использовать слово explicit, когда в конструкторе больше чем один аргумент.
class A;
class MyClass
{
public:
MyClass(A *a, int val);
private:
A * m_pA;
int m_val;
};
int f(MyClass mc)
{
….
}
int main() {
std::cout << f({0, 0}) << std::endl; // OK, {0, 0} => MyClass(0, 0)
return 0;
}
Этот код отличается от предыдущего примера: конструктор с двумя аргументами, функцию f вызываем по-другому, аргументы передаем в фигурных скобках. Здесь также конструируется объект типа MyClass, все компилируется и работает хорошо.
Как только мы добавляем слово explicit, этот код уже не компилируется. Стоит нам добавить к началу фигурных скобок имя класса, все снова работает, здесь компилируется даже несмотря на то, что у нас есть слово explicit.
class A;
class MyClass
{
public:
MyClass(A *a, int val);
MyClass(std::initializer_list list);
private:
A * m_pA;
int m_val;
};
int f(MyClass mc)
{
….
}
int main() {
std::cout << f({0, 0}) << std::endl; // OK, {0, 0} => MyClass(std::initializer_list);
return 0;
}
Если мы здесь добавляем еще один конструктор, принимающий initializer list, у нас опять все скомпилируется.Но будет вызван уже не тот конструктор, который принимает два аргумента, а тот, который принимает initializer list из int-ов.
Braced-init-list
Вернемся к теории. Мы рассмотрели неявные преобразования, когда у нас в конструкторе один аргумент. А теперь с каким элементом C++ мы работаем, когда у нас слово explicit применяется к классу с более чем одним аргументом?
Здесь используется braced-init-list — по сути, init-list, перечисленные аргументы, которые обрамлены в фигурные скобки. C++ reference говорит, что он попытается из braced-init-list перебрать все конструкторы. Но если наиболее подходящий конструктор будет объявлен со словом explicit, такая компиляция у нас зафейлится и не будет проходить.
Мои выводы
Нужно использовать слово explicit всякий раз, когда компилятор может сконструировать объект, но мы этого не хотим.
Нужно понимать разницу между тем, когда мы использованием explicit с конструкторами с одним аргументом и несколькими аргументами.
Отмечу, что эти заключения я сделал исключительно для себя, чтобы использовать их как инструкции. В других компаниях и командах могут быть другие гайды по использованию explicit.
Final
В одном классе я добавил ключевое слово final. По моему представлению, его нужно использовать, когда хочется сказать компилятору, что от меня наследоваться нельзя, все, никакой иерархии тут после меня не будет. Это мое намерение как программиста и архитектора!
Комментарий ревьюера: «А зачем ты здесь final используешь? Здесь нет никакой виртуальности, ты не наследуешься ни от каких классов с виртуальными функциями, тебе слово final здесь не надо».
Я давным-давно слышал, что final используется для оптимизации вызовов виртуальных функций, пришлось вспоминать все.
class MyInterface
{
public:
virtual void f() = 0;
virtual void g() = 0;
};
class MyImpl : public MyInterface
{
public:
void f() override;
void g() override;
};
void myFunc(MyImpl &impl)
{
impl.g();
}
int main() {
MyImpl myImpl;
myFunc(myImpl);
return 0;
}
Рассмотрим пример. У нас есть интерфейс, в нем две функции f и g. Есть класс, который имплементирует этот интерфейс, и функция MyFunc, принимающая ссылку на имплементацию. В этой функции MyFunc мы вызываем эту виртуальную функцию g.
myFunc(MyImpl&):
endbr64
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %rdi,-0x8(%rbp)
mov -0x8(%rbp),%rax
mov (%rax),%rax – move vtable address to rax
add $0x8,%rax – get address of ‘g’ in vtable
mov (%rax),%rdx – put address of g() to rdx
mov -0x8(%rbp),%rax
mov %rax,%rdi
call *%rdx – call g()
nop
leave
ret
Чтобы вызвать функцию g, компилятор берет адрес функции g из vtable, таблицы виртуальных функций. Мы видим в коде: там, где написано put address of g () to rdx, есть overhead, где мы получаем адрес и функцию g из таблицы виртуальных функций. Как только добавляем слово final в класс MyImpl, компилятор все понимает. Ему не надо лезть в таблицу виртуальных функций, он сразу оптимизирует вызов виртуальной функции g.
Нужно понимать, что если мы заменим тип принимаемого параметра на сам интерфейс, то опять произойдет обращение к таблице виртуальных функций и считывание адреса функции g. Оптимизация будет сделана только в том случае, если мы непосредственно используем финальный класс.
Пришлось познакомиться с таким понятием, как девиртуализация. Это значит, что компилятор попытается убрать всякие обращения к vtable и позвать функцию напрямую, хоть она и виртуальная.
В каких случаях он может сделать такую оптимизацию? Если класс помечен как final, и если класс и его наследник находятся в анонимном неймспейсе, где нет другого продолжения иерархии. Сразу скажу, второй случай я не рассматривал и не проверял, действительно ли компиляторы умеют вот таким образом оптимизировать.
Мои выводы
Можно использовать final в классах без виртуальных функций, чтобы защитить их от наследования. Если кто-то все же попытается создать наследника и использовать указатели или ссылки на базовые классы, это может привести к проблемам. При использовании оператора «равно» информация из класса-наследника может не скопироваться полностью. В результате произойдет sliced-off, и часть данных будет потеряна.
Сейчас слово final я использую, чтобы оптимизировать девиртуализацию. Иногда позволяю себе написать final, когда точно знаю, что класс не должен использоваться в качестве базового, несмотря на то, что там нет никакой виртуальности.
Default
Про ключевое слово default я узнал из стандарта С++11 и с радостью стал его использовать везде, где меня устраивал дефолтный оператор «равно» и дефолтный конструктор.
Комментарий ревьюера: «А зачем ты здесь используешь слово default? Компилятор здесь сам все сделает, без всяких дефолтов, он сгенерирует и конструкторы, и операторы».
Я принялся изучать тему и сразу нашел The Rule of Five. Таким образом, если объявлять copy constructor, то нужно объявить все остальные функции — и copy assignment, и destructor, и move constructor, и move assignment.
Компилятор сгенерирует все самостоятельно, только если программист вручную не объявит одну из функций. Работает это так: компилятор сомневается, что может по дефолту сделать «правильно», если разработчик уже имплементировал один из конструкторов вручную.
class MyClass
{
public:
MyClass(int);
MyClass(MyClass const&) = default;
MyClass& operator=(MyClass&& c);
};
void f(MyClass c);
int main() {
MyClass c{2};
f(c); // OK
return 0;
}
В этом примере мы сами пишем оператор «равно», но предполагаем, что у нас есть дефолтно сконструированный конструктор, который принимает референс на класс. Тут сразу получим ошибку от компилятора: мы определили оператор «равно», теперь у нас нет дефолтной имплементации для такого конструктора. Поэтому здесь мы вынуждены написать конструкцию с =default, чтобы компилятор все-таки его по дефолту сгенерировал.
Мои выводы
Главный вывод об использовании default — не использовать default, чтобы компилятор все сделал сам. И использовать, если нарушили правило пяти и при этом уверены, что дефолтная имплементация того или иного конструктора или оператора «равно» будет нас совершенно устраивать.
String
class MyClass
{
std::string m_myStr{}; // don’t use such initialization! But why?
};
Комментарий ревьюера: «Ты давай так не делай. Ты лучше просто точку с запятой оставь, а фигурные скобки убери отсюда».
Думаю: «Хорошо. А почему? Вроде бы есть у нас такая возможность, uniform initialization с одиннадцатого стандарта». Стал разбираться, что здесь не так.
С этим вопросом мы попадаем в область стандарта direct list initialization, специальный подпункт которого называется empty braces. Так как здесь не built-in type, должен вызваться дефолтный конструктор. В случае его отсутствия попытается вызваться конструктор, который принимает initializer list.
В нашем случае все работает правильно, так как у std: string есть дефолтный конструктор, но все-таки возникает неоднозначность. Этой строчкой мы хотели позвать пустой initializer list или дефолтный конструктор? Конечно, поэтому здесь лучше не использовать фигурные скобки, а поставить точку с запятой. Так будет понятно, что мы хотели сделать этим кодом.
У Тимура Думлера есть замечательная таблица, которая поможет понять, что будет выполняться при разных типах инициализации — с различными типами, с built-in типами, с агрегатами, с типами, у которых есть initializer list, нет initializer list.
Мои выводы
Если у нас есть класс, а не built-in тип и я хочу позвать дефолтный конструктор, то не надо использовать пустые фигурные скобки. Но, с другой стороны, если у меня built-in тип и я хочу, чтобы integer инициализировался нулем, то почему бы не использовать фигурные скобки, которые пойдут в value initialization, а value initialization приведет к zero initialization.
На этом мы заканчиваем с софтовой частью и переходим к более интересному явлению — синглтону Майерса.
Синглтон Майерса
MyTask f()
{
static MyClass c = co_await getMyClass(); // what??? Does it work in such way?
co_yield c.getVal();
c.setVal(6);
co_yield c.getVal();
std::cout << "val=" << c.getVal() << std::endl;
}
Как-то я написал вот такой код. Есть корутина, в ней есть синглтон Майерса или, по-простому, статическая переменная. Мы ее инициализируем с co_await. Возникает еще одна корутина. Я как-то написал вот такую конструкцию, она работала, но на ревью мне все-таки оставили комментарий.
Комментарий ревьюера: «Это вообще будет работать? Что это за такое вообще? Страшно же, как это вообще будет работать? Работает ли вообще?»
void f()
{
static MyClass c{5};
...
}
int main()
{
f();
}
Давайте разбираться. Сначала откатимся назади и посмотрим на синглтон Майерса, который тоже существует со стандарта С++11.
static bool __guard = false;
static char __storage[sizeof(MyClass)];
static pthread_mutex_t __guard_mutex;
void f() {
if (!__guard ) {
pthread_mutex_lock(__guard_mutex);
__guard = true;
new (__storage) MyClass(5);
pthread_mutex_unlock(__guard_mutex);
}
MyClass & c = *reinterpret_cast(__storage);
...
}
Above code is very simplified! In disassembly you will see something like:
lea 0x2f33(%rip),%rax # 0x555555558160
mov %rax,%rdi
call 0x555555555110 <__cxa_guard_acquire@plt>
…
call 0x555555555338
…
call 0x5555555550b0 <__cxa_guard_release@plt>
На примере видим функцию f со статической переменной с
класса MyClass. Сверху — так называемый псевдокод, который описывает, как под переменную c
будет зарезервирована память в rw section глобальных данных и будет обеспечена многопоточность.
Когда мы зайдем в функцию f, первым делом проверяется так называемая переменная guard, если она не выставлена, мы захватим Mutex и выставим переменную guard в true, вызовем конструктор, инициализируем переменную c
. И только потом освободим Mutex, получив таким образом инициализированный объект, который хранится в переменной с
.
Под фигурной скобкой я разместил кусочек дизассемблера, где видно, что в результате будет вызвана функция cxa_guard_release — это как раз используются примитивы синхронизации.
Почему они используются? Meyers singleton гарантирует, что объект будет инициализирован только один раз вне зависимости от количества потоков.
MyTask f() // f is coroutine now
{
MyClass c{4}; // each time f() is called a coroutine frame is created in the heap
// and MyClass object is contained in this frame in the heap
co_yield c.getVal();
c.setVal(6);
co_yield c.getVal();
}
Давайте разбираться дальше. Здесь мы убрали синглтон, но превратили функцию в корутину. Что произойдет с переменной c
? Всякий раз, когда будет вызываться функция f, в heap будет появляться фрейм и переменная c
будет содержаться в heap внутри фрейма.
MyTask f() // f is coroutine now
{
static MyClass c{4}; // each time f() is called a coroutine frame is created in the heap
// MyClass object is contained in the rw ‘data’ section of a program
// the same code with ‘__guard’ for MyClass object is used in the coroutine frame’s
// initialization
co_yield c.getVal();
c.setVal(6);
co_yield c.getVal();
}
Так, с корутиной разобрались. Сделаем теперь переменную c
статической. Что поменяется? Также будет создаваться фрейм в heap, но память под переменную c
будет выделена уже не в этом фрейме, а в rw data section программы. Мы получаем использование примитивов синхронизации, чтобы инициализировать переменную c
только один раз вне зависимости от количества потоков.
// also coroutine
MyClassGetter getMyClass();
MyTask f()
{
MyClass c = co_await getMyClass();
co_yield c.getVal();
...
}
int main()
{
auto t = f();
t(); // resume execution of coroutine
}
MyClassGetter&& value = getMyClass();
auto&& awaiter = value.operator co_await();
if (!awaiter.await_ready())
{
using handle_t = std::coroutine_handle;
awaiter.await_suspend(handle_t::from_promise(p));
// return to main MyTask object
// when main() resumes execution
}
MyClass c = awaiter.await_resume();
// Note. Above code is just possible example of path which could be taken, actual code will depend on how MyTask, promise and awaiter classes are implemented
В этом примере добавляем co_await, но убираем static. Теперь переменная c
обычная, но мы инициализируем ее с помощью co_await. На второй врезке вы видите псевдокод того, что примерно должно происходить. Судя по учебникам по корутинам, co_await разворачивается именно так. Мы получаем awaiter, проверяем ready он или не ready, и в конце концов, когда awaiter станет ready, переменная c
инициализируется результатом функции awaiter.await_resume ().
// also coroutine
MyClassGetter getMyClass();
MyTask f()
{
static MyClass c = co_await getMyClass();
co_yield c.getVal();
...
}
int main()
{
auto t = f();
t(); // resume execution of coroutine
}
if (!__guard ) {
pthread_mutex_lock(__guard_mutex);
__guard = true;
MyClassGetter&& value = getMyClass();
auto&& awaiter = value.operator co_await();
if (!awaiter.await_ready())
{
using handle_t = std::coroutine_handle;
awaiter.await_suspend(handle_t::from_promise(p));
// return to main MyTask object
// when main() resumes execution
}
new (__storage) awaiter.await_resume();
pthread_mutex_unlock(__guard_mutex);
}
MyClass & c = *reinterpret_cast(__storage);
Наконец-то мы добрались до того, что я написал в изначальном коде и на что получил коммент.
Мы видим здесь и co_await, и статическую переменную c
. Что же мы получаем в таком случае? На предыдущих примерах мы уже обсуждали основы, и теперь к ним добавляется важный момент: перед созданием нового корутин-фрейма при вызове функции getMyClass мы сначала активируем примитив синхронизации. Это нужно для проверки инициализации статической переменной c
.
Мы получили смесь из всех предыдущих случаев — и в ней нет ничего страшного, все это будет работать. Нужно только понимать, что перед тем, как вызовется co_await, мы посмотрим, инициализирована она или нет. Если нет, то вызовется. Если что-то другое находится в процессе инициализации, будем ждать, пока Mutex освободится.
Мои выводы
Когда используем синглтон Майерса в корутинах и co_await для инициализации, следует помнить: при вызове функции f, в зависимости от того, инициализирован у нас синглтон или нет, мы можем получить у MyTask разное количество suspension points.
Разное количество suspension points может привести к проблемам в коде. Если несколько раз попытаемся с одного и того же треда зайти в корутину, а в этот момент какая-то из корутин держит примитив синхронизации, то получим runtime error.
Необходимо учитывать, что если один поток зашел в корутину и захватил Mutex, а другой поток в это же время пытается сделать то же самое, то второй поток будет ждать, так как Mutex занят. Это может быть нежелательно, особенно если у вас кооперативная многозадачность, где не предполагается принудительное прерывание выполнения потоков. Поэтому важно помнить об этом, когда вы используете подобный код.
Смертельный ромб
И последняя тема, которую мы сегодня с вами рассмотрим, это «смертельный ромб», или Deadly Diamond of Death, как его именуют в «Википедии».
Как-то раз я сделал примерно такую иерархию классов. У нас есть базовый класс A, и B от него наследуется. C наследуется от него, и от B. D наследуется и от A, и от C.
Получил много вопросов.
Комментарий ревьюера: «Как это будет работать? Какой будет overhead? Сколько объектов класса A будет содержать класс D, если не использовать виртуальное наследование?»
Я, конечно, переработал код и убрал иерархию, но захотелось разобраться, что происходит, когда используем такое виртуальное наследование. В классической литературе такую ситуацию называют «смертельным ромбом». Отмечу, что у меня класс А содержит обычный, а не виртуальный метод do_something. Я это сделал специально, чтобы посмотреть на overhead, когда нет вызова никаких виртуальных функций.
Смертельный ромб
struct A
{
void do_something()
{ std::cout << "" << std::endl; }
};
struct B: public virtual A {};
struct C: public virtual A {};
struct D: public B, public C {};
void f(D* dPtr)
{
dPtr->do_something();
}
int main() {
D d{};
f(&d);
return 0;
}
Этот код иллюстрирует смертельный ромб. Есть функция f, которая принимает указатель на самый последний most derived class D, и мы вызываем функцию do_something, которая у нас определена в базовом классе A.
f(D*):
endbr64
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %rdi,-0x8(%rbp)
mov -0x8(%rbp),%rax
mov (%rax),%rax – put address of VTT (virtual table table) into rax
sub $0x18,%rax – get row addr in VTT where addr of vtable for D is kept and put to rax
mov (%rax),%rax – read offset of A in D from vtable
mov %rax,%rdx – copy offset of A to rdx
mov -0x8(%rbp),%rax - put start addr of D object to rax
add %rdx,%rax – apply offset to get address of A
mov %rax,%rdi
call 0x5555555552ca
nop
leave
ret
Что происходит при дизассемблировании, когда вызываем метод do_something
Чтобы вызвать метод do_something, нам нужно определить, указатель this на класс A. Мы же знаем, что у нас неявно всегда должен присутствовать указатель this при вызове методов классов
Здесь в бой вступает Virtual Table Table (VTT), таблица виртуальных таблиц. Чтобы получить адрес класса A, который внутри большого лэйаута класса D, мы обращаемся к VTT, получаем адрес vtable для класса D, считываем оттуда offset и по нему определяем, где в большом лэйауте класса D располагается объект класса A.
struct A
{
void do_something()
{ std::cout << "" << std::endl; }
};
struct B: public virtual A {};
struct C: public virtual A, public B {};
struct D: public virtual A, public C {};
void f(D* dPtr)
{
dPtr->do_something();
}
int main() {
D d{};
f(&d);
return 0;
}
Этот код иллюстрирует изначальную картинку. Происходит то же самое: определяется указатель this с помощью VTT. Заметьте, что это не формат Virtual Table Table, а ее упрощенное представление. Я сделал это, чтобы вы понимали, что Virtual Table Table для класса D будет содержать строки vtable for D, vtable for B-in-D, vtable for C-in-D. Эти указатели нужны, чтобы правильно сконструировать класс и потом определять оффсеты базового класса A.
Что еще есть интересного в виртуальном наследовании с большим количеством базовых классов? Если мы используем виртуальное наследование, будет создан только один объект класса А. И стандарт говорит: «Будет один».
struct A
{
A(char c) { val = c; std::cout << "A=" << c << std::endl; }
void do_something() { std::cout << "val=" << val << std::endl; }
char val{'0'};
};
struct B: public virtual A { B() : A('b') {} };
struct C: public virtual A, public B { C() : A('c') {} };
struct D: public virtual A, public C { D() : A('d') {} };
void f(A* dPtr)
{
dPtr->do_something();
}
int main() {
D d{}; B b{}; C c{};
f(&d); f(&c); f(&b);
return 0;
}
Но ведь класс A можно инициализировать в других классах, как на примере выше. Когда мы, например, инициализируем класс D, то можем в конструкторе класса D инициализировать объект класса A. И в C тоже можем, и в B. А какой же конструктор вызовется в результате?
Спойлер: конструктор позовется от most derived class. В нашем случае даже если во всех классах есть вызов конструктора класса A, то когда мы создаем класс D, вызовется конструктор класса A, который у нас указан в конструкторе класса D, и так далее для других классов.
Обратите внимание: если класс D не зовет конструктор класса A. Даже в этом случае именно D — главный по инициализации, он most derived, так что вызовется дефолтный конструктор класса A, а не конструктор, который вызывается в классе C. Это нужно понимать, когда мы используем такие хитрые виртуальные наследования.
Мои выводы
Появляется дополнительный уровень индирекции. Из-за этого увеличивается время выполнения и объем кода, так как нужно создавать VTT. При вызове в процессе выполнения также возрастает overhead, потому что сначала нужно обратиться к VTT, чтобы найти адрес vtable, а затем уже считывать адреса виртуальных функций из vtable. Если overhead и размер кода для вас не критичны, то, в принципе, можно это использовать. Главное, чтобы вы понимали, что происходит.
С++ — сложный, интересный и необъятный язык. Разработчикам следует понимать, для чего они пишут каждое слово и к каким последствиям это может привести с точки зрения производительности. Я подготовил небольшой список полезных материалов по темам, о которых я рассказал в статье.