Виртуальные функции в С++ и производительность

Тема поста навеяна недавними постами на www.codeblogz.ru и в журнале Benoît Jacob. Benoît Jacob заявляет, что использование виртуальных функций приводит к трехкратным издержкам по производительности и предлагает технику, позволяющую избавиться от виртуальных функций, за счет использования шаблонов. Здесь я изложу некоторые свои соображения по этому вопросу.

Виртуальные функции в C++

Сначала, небольшой экскурс в реализацию виртуальных функций в C++. Виртуальные функции класса позволяют переопределять их реализацию в наследниках, причем выбор конкретной реализации осуществляется динамически, в процессе выполнения программы. Безусловно, это требует некоторой дополнительной работы со стороны компилятора. Конкретнее, объект каждого класса, имеющего виртуальные функции содержит скрытый от программиста указатель на так называемую таблицу виртуальных функций своего класса. В свою очередь, таблица виртуальных функций содержит указатели на конкретные реализации функций для заданного класса. Таким, образом, понятно, что вызов виртуальной функции требует накладных расходов, а именно: одно дополнительное обращение к памяти, для извлечения элемента таблицы виртуальных функций. И тут мы напрямую подходим к утверждению о трехкратных издержках.

Корректность утверждения о трехкратных издержках

В предыдущем пункте, была рассмотрена реализация виртуальных функций в C++. Теперь рассмотрим две различные функции, которые могут быть виртуальными и невиртуальными.
  • Во-первых, аналогично, Benoît Jacob, рассмотрим простой геттер, возвращающий int. По сути, геттер осуществляет одно обращение к памяти. Все. Разумеется, мы получаем издержки порядка двух раз, при объявлении геттера виртуальным. Benoît Jacob получил еще большее число, за счет встраивания вызова компилятором при оптимизации.
  • Теперь рассмотрим функцию класса, вычисляющую, например, определитель квадратной матрицы, что в лучшем случае выполняется за O(n*n*n) операций, где n - размерность матрицы. То есть, например, для матрицы размерности 5, это уже как минимум 125 обращений к памяти (а в действительности, больше). Соответственно издержки от объявления этой функции виртуальной составляют менее 1%
Видно, что издержки тем меньше, чем более сложную задачу решает функция. Таким образом, говорить, что виртуальные функции приводят к n-кратным издержкам, где n - любое число (3, 1.5 и т.п.) просто некорректно. В различных задачах издержки разные.

Техника с шаблонами

Для устранения издержек Benoît Jacob предлагает следующую технику. Вместо кода:
#include <iostream>

class Animal
{
  protected:
    int age_in_years;

  public:
    Animal(int i) : age_in_years(i) {}
    virtual int actual_age() const = 0;
};

class Dog : public Animal
{
  public:
    Dog(int i) : Animal(i) {}
    virtual int actual_age() const
    {
      // one year is 7 dog-years
      return 7 * age_in_years;
    }
};

void msg(const Animal& a)
{
  std::cout << "This animal feels " << a.actual_age()
            << " years old." << std::endl;
}

int main(int, char*[])
{
  Dog d(3);
  msg(d);
  return 0;
}
можно написать код:

#include <iostream>

template<typename Derived>
class Animal
{
  protected:
    int age_in_years;

  public:
    Animal(int i) : age_in_years(i) {}
    int actual_age() const
    {
      return static_cast<const Derived*>(this)->_actual_age();
    }
};

class Dog : public Animal<Dog>
{
    friend class Animal<Dog>;
    int _actual_age() const
    {
      // one year is 7 dog-years
      return 7 * age_in_years;
    }

  public:
    Dog(int i) : Animal<Dog>(i) {}
};

template<typename Derived>
void msg(const Animal<Derived>& a)
{
  std::cout << "This animal feels " << a.actual_age()
            << " years old." << std::endl;
}

int main(int, char*[])
{
  Dog d(3);
  msg(d);
  return 0;
}
Итак, какие же плюсы имеет данный подход:
  • Он решает свою задачу, издержки исчезают.
Однако, за это приходится платить некоторую цену:
  • Код усложняется, становится менее читабельным, его сложнее поддерживать.
  • Функции, использующие класс Animal, приходится объявлять в заголовочных файлах (раздельная компиляция шаблонов пока мало распространена). В больших модулях, в заголовочные файлы уйдет много кода. Как следствие, мы получим увеличение времени сборки проекта.
  • На самом деле, весь дополнительный код не нужен! Например, этот код можно переписать так, ничего не потеряв:
    #include <iostream>
    
    class Animal
    {
      protected:
        int age_in_years;
    
      public:
        Animal(int i) : age_in_years(i) {}
    };
    
    class Dog : public Animal
    {
        int actual_age() const
        {
          // one year is 7 dog-years
          return 7 * age_in_years;
        }
    
      public:
        Dog(int i) : Animal(i) {}
    };
    
    template<typename Derived>
    void msg(const Derived & a)
    {
      std::cout << "This animal feels " << a.actual_age()
                << " years old." << std::endl;
    }
    
    int main(int, char*[])
    {
      Dog d(3);
      msg(d);
      return 0;
    }
    

Заключение или как же быть с виртуальными функциями

Безусловно, виртуальные функции несут издержки по производительности. Однако, это еще не повод отказываться от них совсем. Лучше следовать следующему общему правилу: все средства (в частности, виртуальные функции) желательно использовать с пониманием того, что за ними стоит и к каким эффектам это приведет. В частности, к задаче оптимизации при использовании виртуальных функций:
  • Главное, оцените свои приоритеты по параметрам системы. Что Вам важнее: модифицируемость, производительность или еще что-то?
  • Помните, для любой задачи есть много решений, нужно выбрать то которое наиболее Вам подходит.
  • Рассмотрите возможность перепроектирования иерархии классов, с тем, чтобы использовать виртуальные функции там, где преимущества от удобства выше чем издержки.
  • Возможно, какие-то виртуальные функции можно объединить. Вспомните, чем меньше вызовов, тем меньше и издрежек.
  • Используйте, если это возможно, общие шаблоны проектирования, такие, как Strategy и Template method. Изобретение велосипеда не всегда оправдано.
  • Многие решения, в том числе вышеприведенные шаблоны можно реализовать как динамически, с использованием виртуальных функций, так и статически, с использованием шаблонного метапрограммирования

Ссылки

 Подписаться на RSS

 #  #  #  #  #  #  #  #  #  #

blog comments powered by Disqus