Подтвердить что ты не робот

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

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

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, ссылаясь на него. Просеивая некоторые из обсуждений, я нашел аргументы, исходящие из С++/CLI, но не ясно, почему final может быть полезен для компилятора. Я думаю об этом, потому что я также вижу некоторые недостатки маркировки класса final. Для модульных тестов защищенных функций-членов можно получить класс и вставить тестовый код. Иногда эти классы являются хорошими кандидатами для маркировки с помощью final. Этот метод был бы невозможным в этих случаях.

4b9b3361

Ответ 1

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

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

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

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

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

Возможно также, что некоторые миллионы могут сделать вывод о том, что (в отсутствие friend s) объекты, отмеченные protected в final class, также эффективно становятся private.

Ответ 2

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

  • Расположение указателя v-таблицы, и через него достигается v-таблица
  • Распознавание указателя функции внутри v-таблицы и выполнение этого вызова

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

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

struct Base { virtual ~Base(); };

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

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

Clang получает:

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` 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-таблицы, выбора соответствующей записи в таблице и, наконец, выполнения вызова.

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

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

Ответ 3

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

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

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

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

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

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

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