[Перевод] Использование final для повышения производительности в C++

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

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

Однако цена, которую необходимо заплатить за использование виртуальных функций в C++, — снижение производительности во время выполнения. Эти накладные расходы могут показаться несущественными, если рассматривать их отдельно для каждого конкретного вызова, но в нетривиальном встраиваемом реалтайм приложении эти накладные расходы имеют тенденцию накапливаться и оказывать заметное влияние на общую скорость отклика системы.

Рефакторинг существующей кодовой базы на поздних этапах жизненного цикла проекта с целью повышения производительности — задача, которую мало кто может назвать долгожданной. Сжатые сроки проекта обычно означают, что любая доработка может привести к потенциальным новым ошибкам в существующем уже хорошо протестированном коде. И все же мы бы хотели избежать любой необязательной преждевременной оптимизации (например, вообще не использовать виртуальные функции), поскольку они чреваты возникновением технического долга, который может аукнуться нам (или какому-то другому бедолаге) во время последующего обслуживания кодовой базы.

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

Интерфейсы и создание подтипов

В отличие от Java, C++ не имеет явной концепции интерфейсов, встроенной в язык. Интерфейсы играют центральную роль в шаблонах проектирования и являются основным механизмом для реализации 'D' из SOLID — принципа инверсии зависимостей.

Простой пример интерфейса

Давайте рассмотрим простой пример; здесь у нас есть MechanismLayer, определяющий класс под названием PDO_Protocol. Чтобы отделить протокол от нижележащего UtilityLayer«а, мы ввели интерфейс под названием Data_link. Конкретный класс CAN_bus затем реализует этот интерфейс.

0ce09a4181a4a8f17ac349a585cbbe47.png

Класс интерфейса в этой архитектуре будет выглядеть следующим образом:

b6fc8ce2c8670aa7b260b66f357298d0.png

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

Клиент (в нашем случае PDO_protocol) зависит только от интерфейса:

bd538d9ac3a414675eedefe9f1a55999.png46918b5f34bc8c0b30c3ea39a15fe511.png

Любой класс, реализующий интерфейс (в нашем случае это класс CAN_bus), должен переопределить (override) чисто виртуальные функции интерфейса:

d791d3a71d8a4605e9ea2fc570725cc4.png

Наконец, в main мы можем привязать объект CAN_bus к объекту PDO_protocol. Вызовы из PDO_protocol вызывают функции, переопределенные в CAN_bus:

270122082a71b46098e20a7fb9ca4dc1.png

Использование динамического полиморфизма

В этой архитектуре заменить CAN_bus на альтернативный служебный объект, например RS422, очень просто:

ac74ad8f99f487fc0db129e484b7dffa.png

Мы просто привязываем объект PDO_protocol к альтернативному классу в main:

311f1b536e7384d95de5a49931fbfc31.png

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

Да, существует много способов создать новый тип (например, с помощью фабрик и т. д.), которые мы могли бы обсудить в этом контексте, но, опять же, давайте оставим эту тему для другой статьи.

Цена динамического полиморфного поведения

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

Код примеров сгенерирован с помощью Arm GNU Toolchain v11.2.1.

В предыдущей статье мы рассматривали соглашение о вызовах Arm для AArch32 ISA. Например, простой вызов функции-члена будет выглядеть следующим образом:

331ecb1cd98d5c8fe42adb33886ddead.png

Для вызова функции-члена в read_sensor мы получим следующий ассемблерный код:

4fb4d451a39c40d009a32048a5c56221.png

Опкод bl (branch with link) — это соглашение о вызове функции AArch32 (r0 содержит адрес объекта).

Так что же будет на месте вызова, когда мы сделаем эту функцию виртуальной?

ec5e1cae96c48f7f71666265dd71b2d5.png

Сгенерированный ассемблерный код для sensor.get_value() теперь будет таким:

07121e7bb10b78d1075df7015ac91b95.png

Фактический сгенерированный код, естественно, зависит от конкретного ABI (бинарного интерфейса приложения). Но для всех компиляторов C++ потребуется аналогичный набор шагов. Визуализация этой реализации:

481f1b3d8f41ed8a0f1259373dffe078.png

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

  • r0 содержит адрес объекта (передается как параметр в read_sensor)

  • содержимое по этому адресу загружается в r3

  • r3 теперь содержит vtable-указатель (vtptr)

  • vtptr по сути, представляет собой массив указателей на функции.

  • Первая запись в vtable (таблицу виртуальных методов) загружается обратно в r3 (например vtable[0])

  • r3 теперь содержит адрес Sensor::get_value

  • текущий счетчик команд (pc) перемещается в линк регистр (lr) перед вызовом 

  • Выполняется опкод branch-with-exchange, и инструкция bx r3 вызывает Sensor::get_value

Если, например, мы вызывали sensor.set_ID(), то второй загрузкой в память будет LDR r3,[r3,#4] для загрузки адреса Sensor::set_ID в r3 (например, vtable[1]). Большинство ABI структурируют vtable на основе порядка объявления виртуальных функций.

Мы можем сделать вывод, что накладные расходы на использование виртуальной функции (для Arm Cortexv7-M) составляют:

Однако наиболее существенной является вторая загрузка в память (LDR r3,[r3]), поскольку это считывание из памяти требует доступ к флэш-памяти программы. Чтение из флэш-памяти обычно выполняется медленнее, чем эквивалентное чтение из SRAM. Много усилий при проектировании системы уходит на улучшение производительности чтения из флэш-памяти, поэтому ваш опыт в отношении фактических временных затрат может отличаться.

Использование полиморфных функций

Если мы создадим производный от Sensor класс, как например:

94b0eb0827f79572aee4b6d92864021f.png

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

6960c7313514350800e7de6556f4a6aa.png

Но визуализируя модель памяти, становится ясно, как тот же код:

e889b04f3723514435ba5ace99ea80cd.png

вызывает производную функцию:

8da3ba0a5dc514d56c4726e396860299.png

У производного класса есть собственная vtable заполняемая во время компоновки. Любые переопределенные функции заменяют запись в vtable на адрес новой функции. Конструкторы отвечают за сохранение адреса vtable в классах vtptr.

Любые виртуальные функции в базовом классе, которые не переопределены, по-прежнему указывают на реализацию из базового класса. Чисто виртуальные функции (используемые в паттерне интерфейса) не имеют записи, которая бы была внесена в vtable, поэтому их необходимо переопределять.

Поприветствуйте final

Как было сказано ранее, final был введен вместе с override в C++11.

Спецификатор final был введен, чтобы гарантировать, что производный класс не может переопределить виртуальную функцию или что класс не может иметь наследников.

Например, в настоящее время мы можем наследоваться от класса Rotary_sensor.

32e057ea25ee8190370dd354c4d8a0d1.png

Определяя класс Rotary_encoder мы могли бы иметь совершенно противоположные намерения. Добавление спецификатора final делает невозможным любое дальнейшее наследование.

Класс может быть определен как final следующим образом:

76ca1acd8a935ea40b752a2c686a19d6.png

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

c67694b9481478cc9a1dd2ab6fda328c.png

Отдельная функция может быть помечена определена как final следующим образом:

f5069f129a6ae13397edd760ca24849e.pngc21b9453d7601d19e617bff06059a432.png

Девиртуализация

Итак, как это может помочь с оптимизацией во время компиляции?

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

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

b977425f372987ea43bdd97565a875e8.png

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

Без спецификатора final компилятор не может доказать, что ссылка на Rotary_encode, sensor, не привязана к следующему экземпляру производного класса. Таким образом, сгенерированный ассемблерный код для обеих read_sensor идентичен.

Однако, если мы применим спецификатор final к Rotary_encoder, компилятор может доказать, что единственным подходящим вызовом может быть только Rotary_encoder::get_value, и тогда он может применить девиртуализацию и сгенерировать следующий код для read_sensor(Rotary_encoder&):

136a48371850041de576ae9efd9c6da8.png

Шаблоны и final

Поскольку обе наши функции read_sensor идентичны, в игру вступает принцип DRY («не повторяйся»). Если мы изменим код так, чтобы read_sensor стала шаблонной функцией, это будет выглядеть следующим образом:  

5eae1726ee0941c4d5c7a69d87645e24.png

Генератор кода будет использовать динамическое или статическое связывание, в зависимости от того, вызываем ли мы объект Sensor или Rotary_encoder.

Обратно к интерфейсу

Зная о потенциале девиртуализации, можем ли мы использовать ее в архитектуре нашего интерфейса?

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

c022a34d32243dcb2aba82a9983de7cc.png

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

Девиртулизация с использованием прямой ссылки

Использование прямой ссылки — это «быстрое и грязное» решение.

d2aeb78803af292ccaf1aae286474cc2.png

В рамках этого решения мы изменили только верхушку PDO_protocol, но в остальном оно «делает свою работу». Сгенерированный код теперь вызывает CAN_bus::send и CAN_bus::recieve напрямую, а не через vtable-вызов.

Однако, используя этот подход, мы снова вводим связь между «MechanismLayer» и «UtilityLayer», нарушая DIP.

Девиртулизация с помощью шаблонов

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

463354cd838d7fda3d69a3920347a8ed.png

Шаблоны, конечно, имеют свои сложности, но они гарантируют, что мы получим статическую привязку к любым классам, указанным как final.

Заключение

Спецификатор final предоставляет возможность для рефакторинга существующего кода интерфейса, чтобы изменить привязку с динамического на статический полиморфизм, что обычно повышает производительность во время выполнения. Фактический выигрыш будет в значительной степени зависеть от ABI и архитектуры машины (начните добавлять конвейерную обработку и кэширование, и вода станет еще мутнее).

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

Всех желающих приглашаем на открытое занятие, на котором рассмотрим несколько полезных инструментов для повседневной работы программиста на языке C++. Регистрация открыта по ссылке.

© Habrahabr.ru