Как стать автором
Обновить
743.06
OTUS
Цифровые навыки от ведущих экспертов

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

Время на прочтение7 мин
Количество просмотров14K
Автор оригинала: Niall Cooling

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

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

Однако цена, которую необходимо заплатить за использование виртуальных функций в 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++. Регистрация открыта по ссылке.

Теги:
Хабы:
+14
Комментарии19

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS