[Перевод] Использование final для повышения производительности в C++
Динамический полиморфизм (виртуальные функции) занимает центральное место в объектно-ориентированном программировании (ООП). При правильном использовании он способствует созданию входных точек в существующей кодовой базе, с помощью которых новый функционал и поведение могут (относительно) легко интегрироваться в уже проверенную, хорошо протестированную кодовую базу.
Наследование подтипов может принести значительные преимущества, такие как упрощение интеграции, сокращение времени регрессионного тестирования и улучшение обслуживаемости.
Однако цена, которую необходимо заплатить за использование виртуальных функций в C++, — снижение производительности во время выполнения. Эти накладные расходы могут показаться несущественными, если рассматривать их отдельно для каждого конкретного вызова, но в нетривиальном встраиваемом реалтайм приложении эти накладные расходы имеют тенденцию накапливаться и оказывать заметное влияние на общую скорость отклика системы.
Рефакторинг существующей кодовой базы на поздних этапах жизненного цикла проекта с целью повышения производительности — задача, которую мало кто может назвать долгожданной. Сжатые сроки проекта обычно означают, что любая доработка может привести к потенциальным новым ошибкам в существующем уже хорошо протестированном коде. И все же мы бы хотели избежать любой необязательной преждевременной оптимизации (например, вообще не использовать виртуальные функции), поскольку они чреваты возникновением технического долга, который может аукнуться нам (или какому-то другому бедолаге) во время последующего обслуживания кодовой базы.
Спецификатор final был введен в C++11, чтобы обозначить невозможность дальнейшего переопределения класса или виртуальной функции. Однако, как мы увидим далее, он также позволяет им выполнять оптимизацию, известную как девиртуализация, тем самым повышая производительность во время выполнения.
Интерфейсы и создание подтипов
В отличие от Java, C++ не имеет явной концепции интерфейсов, встроенной в язык. Интерфейсы играют центральную роль в шаблонах проектирования и являются основным механизмом для реализации 'D' из SOLID — принципа инверсии зависимостей.
Простой пример интерфейса
Давайте рассмотрим простой пример; здесь у нас есть MechanismLayer
, определяющий класс под названием PDO_Protocol
. Чтобы отделить протокол от нижележащего UtilityLayer«а, мы ввели интерфейс под названием Data_link
. Конкретный класс CAN_bus
затем реализует этот интерфейс.
Класс интерфейса в этой архитектуре будет выглядеть следующим образом:
Примечание: сегодня мы не будем концентрироваться на использовании pragma once, виртуальных деструкторов по умолчанию и передаче через копирование. Возможно мы поговорим об этом в следующих статьях.
Клиент (в нашем случае PDO_protocol) зависит только от интерфейса:
Любой класс, реализующий интерфейс (в нашем случае это класс CAN_bus), должен переопределить (override) чисто виртуальные функции интерфейса:
Наконец, в main мы можем привязать объект CAN_bus
к объекту PDO_protocol
. Вызовы из PDO_protocol
вызывают функции, переопределенные в CAN_bus
:
Использование динамического полиморфизма
В этой архитектуре заменить CAN_bus
на альтернативный служебный объект, например RS422, очень просто:
Мы просто привязываем объект PDO_protocol
к альтернативному классу в main
:
Важно отметить, что в класс PDO_protocol
не нужно вносить никаких изменений. При условии реализованного модульного тестирования внедрение кода RS422 в нашу существующую кодовую базу подразумевает только интеграционное тестирование, а не непонятную кашу из модульное и интеграционного тестирования, какая могла бы быть в некоторых других сценариях.
Да, существует много способов создать новый тип (например, с помощью фабрик и т. д.), которые мы могли бы обсудить в этом контексте, но, опять же, давайте оставим эту тему для другой статьи.
Цена динамического полиморфного поведения
Использование подтипов и полиморфного поведения является важным инструментом в процессе управления изменениями. Но, как и все в нашей жизни, за также имеет свою цену.
Код примеров сгенерирован с помощью Arm GNU Toolchain v11.2.1.
В предыдущей статье мы рассматривали соглашение о вызовах Arm для AArch32 ISA. Например, простой вызов функции-члена будет выглядеть следующим образом:
Для вызова функции-члена в read_sensor
мы получим следующий ассемблерный код:
Опкод bl (branch with link) — это соглашение о вызове функции AArch32 (r0 содержит адрес объекта).
Так что же будет на месте вызова, когда мы сделаем эту функцию виртуальной?
Сгенерированный ассемблерный код для sensor.get_value()
теперь будет таким:
Фактический сгенерированный код, естественно, зависит от конкретного ABI (бинарного интерфейса приложения). Но для всех компиляторов C++ потребуется аналогичный набор шагов. Визуализация этой реализации:
Изучив сгенерированный ассемблерный код, мы можем увидеть следующую последовательность:
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 класс, как например:
и затем передадим объект производного типа в функцию read_sensor
, то будет выполняться тот же самый ассемблерный код.
Но визуализируя модель памяти, становится ясно, как тот же код:
вызывает производную функцию:
У производного класса есть собственная vtable
заполняемая во время компоновки. Любые переопределенные функции заменяют запись в vtable
на адрес новой функции. Конструкторы отвечают за сохранение адреса vtable
в классах vtptr
.
Любые виртуальные функции в базовом классе, которые не переопределены, по-прежнему указывают на реализацию из базового класса. Чисто виртуальные функции (используемые в паттерне интерфейса) не имеют записи, которая бы была внесена в vtable
, поэтому их необходимо переопределять.
Поприветствуйте final
Как было сказано ранее, final
был введен вместе с override в C++11.
Спецификатор final
был введен, чтобы гарантировать, что производный класс не может переопределить виртуальную функцию или что класс не может иметь наследников.
Например, в настоящее время мы можем наследоваться от класса Rotary_sensor
.
Определяя класс Rotary_encoder
мы могли бы иметь совершенно противоположные намерения. Добавление спецификатора final
делает невозможным любое дальнейшее наследование.
Класс может быть определен как final
следующим образом:
Если мы попытаемся делать производные классы от этого класса, то получим следующую ошибку:
Отдельная функция может быть помечена определена как final
следующим образом:
Девиртуализация
Итак, как это может помочь с оптимизацией во время компиляции?
При вызове функции, такой как read_sensor
, когда параметр является указателем/ссылкой на базовый класс, который, в свою очередь, вызывает виртуальную функцию-член, вызов должен быть полиморфным.
Если мы перегрузим read_sensor
для получения объекта Rotary_encode
по ссылке, то это будет выглядеть следующим образом:
Если компилятор может точно доказать, какой именно метод вызывается во время компиляции, он может изменить вызов виртуального метода на прямой вызов метода.
Без спецификатора final
компилятор не может доказать, что ссылка на Rotary_encode
, sensor
, не привязана к следующему экземпляру производного класса. Таким образом, сгенерированный ассемблерный код для обеих read_sensor
идентичен.
Однако, если мы применим спецификатор final
к Rotary_encoder
, компилятор может доказать, что единственным подходящим вызовом может быть только Rotary_encoder::get_value
, и тогда он может применить девиртуализацию и сгенерировать следующий код для read_sensor(Rotary_encoder&)
:
Шаблоны и final
Поскольку обе наши функции read_sensor
идентичны, в игру вступает принцип DRY («не повторяйся»). Если мы изменим код так, чтобы read_sensor
стала шаблонной функцией, это будет выглядеть следующим образом:
Генератор кода будет использовать динамическое или статическое связывание, в зависимости от того, вызываем ли мы объект Sensor
или Rotary_encoder
.
Обратно к интерфейсу
Зная о потенциале девиртуализации, можем ли мы использовать ее в архитектуре нашего интерфейса?
К сожалению, для того, чтобы компилятор смог подтвердить фактический вызов метода, мы должны использовать final
в сочетании с указателем/ссылкой на производный тип. Учитывая наш исходный код:
Компиляция не может выполнить девиртуализацию, потому что мы имеем дело с ссылкой на интерфейсный (базовый) класс, а не на производный класс. Это оставляет нам два возможных пути для рефакторинга:
Девиртулизация с использованием прямой ссылки
Использование прямой ссылки — это «быстрое и грязное» решение.
В рамках этого решения мы изменили только верхушку PDO_protocol
, но в остальном оно «делает свою работу». Сгенерированный код теперь вызывает CAN_bus::send
и CAN_bus::recieve
напрямую, а не через vtable-вызов.
Однако, используя этот подход, мы снова вводим связь между «MechanismLayer» и «UtilityLayer», нарушая DIP.
Девиртулизация с помощью шаблонов
В качестве альтернативы мы можем переработать клиентский код в шаблонный класс, где наш link-класс указывается через шаблонный параметр.
Шаблоны, конечно, имеют свои сложности, но они гарантируют, что мы получим статическую привязку к любым классам, указанным как final
.
Заключение
Спецификатор final
предоставляет возможность для рефакторинга существующего кода интерфейса, чтобы изменить привязку с динамического на статический полиморфизм, что обычно повышает производительность во время выполнения. Фактический выигрыш будет в значительной степени зависеть от ABI и архитектуры машины (начните добавлять конвейерную обработку и кэширование, и вода станет еще мутнее).
В идеале, при использовании виртуальных функций во встроенных приложениях вопрос о том, должен ли класс быть указан как final
, должен решаться во время разработки, а не на более поздних стадиях развития проекта.
Всех желающих приглашаем на открытое занятие, на котором рассмотрим несколько полезных инструментов для повседневной работы программиста на языке C++. Регистрация открыта по ссылке.