Какие выгоды дает компилятору новое ключевое слово final в C++?

C++11 позволит помечать классы и виртуальные методы как final, чтобы запретить производные от них или переопределять их.

class Driver {
  virtual void print() const;
};
class KeyboardDriver : public Driver {
  void print(int) const final;
};
class MouseDriver final : public Driver {
  void print(int) const;
};
class Data final {
  int values_;
};

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

Но есть ли преимущество с точки зрения компилятора? Может ли компилятор сделать что-то другое, если он знает, что "этот класс никогда не будет производным от" или "эта виртуальная функция никогда не будет переопределена"?

Для final я в основном нашел только N2751 со ссылкой на него. Просматривая некоторые обсуждения, я нашел аргументы со стороны C++/CLI, но не нашел ясного намека, почему final может быть полезен для компилятора. Я думаю об этом, потому что я также вижу некоторые недостатки пометки класса final: для модульного тестирования защищенных функций-членов можно получить класс и вставить тестовый код. Иногда эти классы являются хорошими кандидатами на пометку final. Этот метод был бы невозможен в этих случаях.


person towi    schedule 24.09.2011    source источник
comment
эта виртуальная функция никогда не будет переопределена - в этом случае вызов не должен полагаться на динамическую диспетчеризацию (методы/деструкторы). это зависит от авторов компилятора, чтобы сделать эту оптимизацию. учитывая правильность/строгость C++, i лично хотел бы это увидеть. такая же оптимизация может быть выполнена для случаев, когда компилятор знает тип (например, инициализируется в пределах видимости или как типизированная переменная-член). более важными, чем накладные расходы на виртуальный вызов, часто является возможность встраивания или оптимизации на основе расширенных знаний о выполнении.   -  person justin    schedule 24.09.2011
comment
Я действительно не вижу, что это имеет значение. Назначение override и final состоит в том, чтобы позволить компилятору помешать пользователю облажаться. Вы должны использовать их, чтобы остановить себя и других от неправильных поступков. То, что компилятор может или не может сделать что-то быстрее, на самом деле не имеет значения, потому что вы должны всегда использовать их там, где это уместно.   -  person Nicol Bolas    schedule 24.09.2011


Ответы (3)


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

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

Например, если у вас есть delete most_derived_ptr, где most_derived_ptr — указатель на производный тип final, компилятор может упростить вызовы деструктора virtual.

Аналогично для вызовов virtual функций-членов для ссылок/указателей на наиболее производный тип.

Я был бы очень удивлен, если бы какие-либо компиляторы сделали это сегодня, но похоже, что это может быть реализовано в течение следующего десятилетия или около того.

Также может быть некоторая польза в том, чтобы сделать вывод, что (в отсутствие friends) вещи, отмеченные protected в final class, также эффективно становятся private.

person Flexo    schedule 24.09.2011
comment
+1, почему этот комментарий был на первом месте. Это стоит ответа - person iammilind; 24.09.2011
comment
Это началось как комментарий, потому что я думал, что это будет выброс, здесь может быть ответ, подсказка / замечание, пока я не понял, что отредактировал комментарий достаточно, чтобы в основном написать ответ вместо подсказки! - person Flexo; 24.09.2011
comment
Позволит ли это в лучшем случае даже встроить вызовы уже не виртуальных функций? Это может быть огромной выгодой! И тогда подсказка inline будет иметь смысл для virtual функций, т.е. деструкторы- - person towi; 24.09.2011
comment
@towi Любой вызов, который может быть разрешен во время компиляции, может быть встроен. (На самом деле вы можете спекулятивно встраивать вызовы независимо от того, с логикой диспетчеризации, если выяснится, что вам на самом деле не нужно то, что вы встроили. Не знаю, делает ли это какой-либо компилятор C++, но это вся предпосылка встраивания в JIT-компиляторы. для динамических языков.) - person gsnedders; 23.05.2012
comment
@gsnedders: JIT могут убедиться, что типы, которые они спекулятивно встраивают, встречались хотя бы один раз на сайте вызова, с C ++ такой гарантии нет, поэтому гораздо сложнее понять, является ли это победой. Компилятор должен быть в состоянии доказать, что по крайней мере некоторое время он будет иметь тип X во время компиляции, что звучит сложно. Тем не менее, не знаю, делают ли это какие-либо компиляторы, они могут существовать. - person Joseph Garvin; 26.06.2012

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

  1. Нахождение указателя v-таблицы и через него доступ к v-таблице
  2. Нахождение указателя функции в v-таблице и выполнение вызова через него

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

Оптимизатор компилятора по-прежнему стремится избежать всевозможных накладных расходов, а девиртуализация вызовов функций, как правило, является легкой задачей. Например, см. в C++03:

struct Base { virtual ~Base(); };

struct Derived: Base { virtual ~Derived(); };

void foo() {
  Derived d; (void)d;
}

Кланг получает:

define void @foo()() {
  ; Allocate and initialize `d`
  %d = alloca i8**, align 8
  %tmpcast = bitcast i8*** %d to %struct.Derived*
  store i8** getelementptr inbounds ([4 x i8*]* @vtable for Derived, i64 0, i64 2), i8*** %d, align 8

  ; Call `d`'s destructor
  call void @Derived::~Derived()(%struct.Derived* %tmpcast)

  ret void
}

Как видите, компилятор уже был достаточно умен, чтобы определить, что d является Derived, и нет необходимости нести накладные расходы на виртуальный вызов.

На самом деле, это так же хорошо оптимизировало бы следующую функцию:

void bar() {
  Base* b = new Derived();
  delete b;
}

Однако бывают ситуации, когда компилятор не может прийти к такому выводу:

Derived* newDerived();

void deleteDerived(Derived* d) { delete d; }

Здесь мы могли ожидать (наивно), что вызов deleteDerived(newDerived()); приведет к тому же коду, что и раньше. Однако это не так:

define void @foobar()() {
  %1 = tail call %struct.Derived* @newDerived()()
  %2 = icmp eq %struct.Derived* %1, null
  br i1 %2, label %_Z13deleteDerivedP7Derived.exit, label %3

; <label>:3                                       ; preds = %0
  %4 = bitcast %struct.Derived* %1 to void (%struct.Derived*)***
  %5 = load void (%struct.Derived*)*** %4, align 8
  %6 = getelementptr inbounds void (%struct.Derived*)** %5, i64 1
  %7 = load void (%struct.Derived*)** %6, align 8
  tail call void %7(%struct.Derived* %1)
  br label %_Z13deleteDerivedP7Derived.exit

_Z13deleteDerivedP7Derived.exit:                  ; preds = %3, %0
  ret void
}

Соглашение может предписывать, что newDerived возвращает Derived, но компилятор не может сделать такое предположение: а что, если он вернет что-то более производное? Таким образом, вы можете увидеть весь уродливый механизм, связанный с извлечением указателя v-таблицы, выбором соответствующей записи в таблице и, наконец, выполнением вызова.

Однако если мы поместим final, то мы дадим компилятору гарантию, что это не может быть что-то другое:

define void @deleteDerived2(Derived2*)(%struct.Derived2* %d) {
  %1 = icmp eq %struct.Derived2* %d, null
  br i1 %1, label %4, label %2

; <label>:2                                       ; preds = %0
  %3 = bitcast i8* %1 to %struct.Derived2*
  tail call void @Derived2::~Derived2()(%struct.Derived2* %3)
  br label %4

; <label>:4                                      ; preds = %2, %0
  ret void
}

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

person Matthieu M.    schedule 24.09.2011
comment
Итак, есть уже компилятор, который может почти напрямую извлечь из этого пользу, применив свою магию оптимизации? Clang почти готов? - person towi; 24.09.2011
comment
Настоящая польза будет не только от девиртуализации, но и от инлайнинга. Возможно ли (теоретически) позволить компилятору даже встроить эти девиртуализированные вызовы? - person towi; 24.09.2011
comment
@towi: да, это возможно. Девиртуализация в Clang происходит очень рано, потому что это оптимизация, выполняемая во внешнем интерфейсе (оптимизатор не знает объектно-ориентированного программирования). Таким образом, после девиртуализации вызова становятся возможными все оптимизации, которые можно выполнить для обычных функций. Возникнут ли они на самом деле, конечно, зависит от того, считает ли оптимизатор их хорошей идеей. Что касается первого вопроса, то я не думаю, что Clang полностью готов к финалу, хотя кое-где он уже выигрывает от этого. На самом деле я сделал последнюю девиртуализацию вручную ;) - person Matthieu M.; 24.09.2011
comment
Хорошим компиляторам удается сделать это всего на 10-15% медленнее, чем при обычном вызове. Какие компиляторы достигают этого? - person curiousguy; 07.12.2011

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

Например, рассмотрим этот код:

class Base
{
  public:
    virtual void foo() { }
    Base() { }
    ~Base();
};

void destroy(Base* b)
{
  delete b;
}

Многие компиляторы будут выдавать предупреждение для невиртуального деструктора b при обнаружении delete b. Если бы класс Derived наследовался от Base и имел свой собственный деструктор ~Derived, использование destroy в динамически выделяемом экземпляре Derived обычно (в соответствии со спецификацией поведение не определено) вызывало бы ~Base, но не вызывало бы ~Derived. Таким образом, операции очистки ~Derived не будут выполняться, и это может быть плохо (хотя в большинстве случаев, вероятно, не катастрофично).

Однако если компилятор знает, что Base не может быть унаследовано, то нет проблем в том, что ~Base не является виртуальным, потому что никакая производная очистка не может быть случайно пропущена. Добавление final к class Base дает компилятору информацию, чтобы не выдавать предупреждение.

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

person Jeff Walden    schedule 20.11.2011
comment
В нем должно быть новое предупреждение: предупреждение 3: совершенно бессмысленное использование ключевого слова virtual — пожалуйста, удалите его. - person paulm; 01.06.2014
comment
В этом случае, да, дополнительное предупреждение может быть полезным. Но обратите внимание, что если класс не имеет внутренней связи, какой-то совершенно отдельный класс в совершенно отдельном файле может наследовать от него (я... думаю, что это нормально, пока два определения идентичны в соответствии с правилом одного определения), в другом единица компиляции, которую компилятор не видит. Таким образом, компилятор не всегда может выдать такое предупреждение, когда может показаться, что это возможно. - person Jeff Walden; 06.06.2014
comment
возможно, вместо этого его можно было бы реализовать в компоновщике - person paulm; 06.06.2014
comment
Да, я думаю, что мог. На практике я думаю, что компоновщики, как правило, не настолько тесно связаны с компоновкой C++, что у них есть знания или интеллект, чтобы выставить такое предупреждение. :-( - person Jeff Walden; 07.06.2014