Перевод/заметки Dynamic vs Static Dispatch


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

Динамическая диспетчеризация - определяющая черта объектно-ориентированного программирования. Что же в ней такого особенного?

Статическая диспетчеризация(static dispatch) (или раннее связывание(early binding)) происходит, когда во время компиляции я знаю, какое тело функции будет выполнено при вызове метода. В отличие от этого, динамическая диспетчеризация(dynamic dispatch) (или диспетчеризация во время выполнения(run-time dispatch), или вызов виртуального метода(virtual method call), или позднее связывание(late binding)) происходит, когда я откладываю это решение до времени выполнения. Такая диспетчеризация во время выполнения требует либо косвенного вызова(indirect call) через указатель функции, либо поиска метода по имени.

Рантайм диспетчеризация добавляет нашей системе большую гибкость. Например, мы можем комбинировать компоненты во время выполнения или даже менять местами компоненты в работающей системе. Это используется для динамической загрузки DLL-файлов (Windows) или SO-файлов (Unix, Linux). В системах ООП такая настройка во время выполнения часто называется dependency injection.

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

Открытая рекурсия - это возможность одного метода вызывать другой метод того же объекта через специальную переменную self или, в некоторых языках, this. Особенность поведения self заключается в том, что он имеет позднюю привязку, позволяя методу, определенному в одном классе, вызывать другой метод, который определен позже, в каком-то подклассе первого.

Диспетчеризация виртуальных и статических методов в различных языках

Все языки ООП обязательно поддерживают виртуальные методы в той или иной форме. Некоторые языки поддерживают только виртуальные методы.

Одним из таких языков является Java. В Java все методы экземпляра по умолчанию являются виртуальными. Хорошо это или плохо - вопрос спорный: это делает класс более гибким, поскольку в подклассе можно переопределить все, что угодно, но по той же причине и более хрупким. Виртуальная диспетчеризация не нужна, когда метод не может быть переопределен, поэтому есть два особых случая, когда статическая диспетчеризация может быть использована безопасно: когда переопределение метода предотвращено путем пометки его как final, или когда метод является private, так как метод может быть вызван только из данного класса. “static methods” Java используют статическую диспетчеризацию, но они не могут использоваться как методы экземпляра, поэтому здесь они не актуальны.

Еще один язык, в котором отсутствует статическая диспетчеризация методов, - это Python. Фактически, Python использует позднюю привязку для всего, даже для обычных вызовов функций и переменных. В Python есть так называемые «static methods», но они совершенно не связаны с ранним связыванием. Их отличительной особенностью является то, что метод с @staticmethod, может быть вызван как на классе, так и на экземпляре. Следовательно, у него нет аргумента self или cls, как у методов экземпляра или методов класса.

Другие языки предоставляют нам выбор и явно поддерживают как виртуальные, так и невиртуальные методы, например C++ и C#. У них методы по умолчанию невиртуальные. Они по-прежнему могут быть переопределены/заменены(overridden/shadowed) в подклассах, но невиртуальный метод разрешается исключительно в соответствии со статическим типом объекта в месте вызова. Такие невиртуальные методы не имеют открытой рекурсии; пример из C++, демонстрирующий это различие, приведен ниже.

В C++ очень важно, чтобы виртуальные методы не использовались по умолчанию, потому что:

  • Виртуальная диспетчеризация нужна только при определенных обстоятельствах - если вам нужен полиморфизм. Дополнительная непрямая связь, необходимая для виртуальных методов, делает вызовы виртуальных методов немного менее эффективными, хотя обычно это незаметно. Что еще более важно, виртуальные методы делают класс более сложным, поскольку поведение класса с виртуальными методами зависит от всех подклассов. Это совершенно нежелательно при написании надежного программного обеспечения.
  • Виртуальные методы влияют на object layout. Объект с виртуальными методами нуждается в дополнительном скрытом члене: указателе на vtable. vtable - это таблица поиска, сопоставляющая виртуальные методы с их реализацией, фактически как массив указателей функций. Обязательный указатель на vtable (как в Java) делает невозможными абстракции с нулевыми затратами(zero-cost abstractions) и делает struct layout несовместимой с C.

В соответствии с девизом «не плати за то, что не используешь», C++ требует от программиста явного подтверждения того, что он хочет заплатить за виртуальные методы.

Принудительная статическая диспетчеризация

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

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

В Python: Class.method(object, a, b). Обратите внимание, что здесь по-прежнему используется поздняя привязка для разрешения Class.method, но динамической диспетчеризации при применении этого метода не происходит.

На языке Perl: Class::method($object, $a, $b). Здесь действительно используется статическая диспетчеризация, если метод Class::method уже был определен на момент вызова. В этом случае скобки можно опустить, так как функция может быть использована как оператор: Class::method $object, $a, $b. В Perl единственное, что превращает функцию в метод, это то, что она вызывается как метод ($object->method($a, $b)).

В JavaScript: Class.prototype.method.call(object, a, b). Это практически то же самое, что и в Python, за исключением того, что теперь у нас есть дополнительный уровень вызова прототипа, и нам нужно использовать call для вызова метода. При вызове функции с помощью call вместо прямого обращения к ней первый аргумент привязывается к this.

Некоторые языки допускают использование в синтаксисе вызова метода полностью определенных имен методов.

В C++: object.Class::method(a, b). Компилятор следит за тем, чтобы класс был базовым для object, поэтому вызовы вне иерархии наследования невозможны.

В Perl: $object->Class::method($a, $b). Технически это все еще реализовано в терминах позднего связывания, но вместо поиска метода используется поиск подпрограммы. Это мало чем отличается от Class::method($object, $a, $b). В синтаксисе вызова функции $object может быть любым значением, а в синтаксисе вызова метода $object должен быть либо объектом, либо строкой, похожей на имя класса (Perl иногда немного странный). В отличие от C++, здесь нет ограничений на вызываемый метод.

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

Кстати, о Java: в ней нет возможности статически диспетчеризировать метод экземпляра. Изначально я думал, что можно вызвать ссылку на метод типа Class::method, обернув ее в интерфейс, например ((Function3<Class, A, B, R>) Class::method).apply(object, a, b), где @FunctionalInterface interface Function3<A1, A2, A3, R> { R apply(A1 a1, A2 a2, A3 a3); }. Это не только ужасно косвенно и запутанно, но и на самом деле не работает. Ссылка на метод ссылается не на конкретный метод реализации method, как это статически видно в Class, а на соответствующий слот метода. Если мы вызовем эту ссылку на метод на объекте, который переопределил этот метод, то вместо него будет вызван переопределенный метод.

Иллюстрация динамической диспетчеризации и открытой рекурсии с помощью Template Method Pattern.

Все шаблоны проектирования ООП опираются на виртуальные методы. Простой пример - template method pattern. Здесь базовый класс определяет метод, который может быть переопределен в подклассе для изменения поведения базового класса. В отличие от виртуальных методов Java по умолчанию, это намеренная точка расширения, часто с модификатором доступа protected.

Вот пример программы на C++, использующей паттерн шаблонного метода. Определив или неопределив макрос препроцессора USE_VIRTUAL_DISPATCH, мы можем включить или выключить виртуальную диспетчеризацию и наблюдать разницу.

#include <iostream>

// use macros to switch between virtual and static dispatch
#ifdef USE_VIRTUAL_DISPATCH
  #define METHOD virtual
  #define OVERRIDE override
#else
  #define METHOD /*non-virtual*/
  #define OVERRIDE /*override*/
#endif

class Base {
  public:

    // invoke the templateMethod from the static context of the base class
    void consumeTemplate() const {
      std::cout << templateMethod() << std::endl;
    }

  protected:

    // may be overridden in subclasses
    METHOD std::string templateMethod() const {
      return "base class";
    }
};

class Subclass : public Base {
  protected:

    // modify base class behaviour
    METHOD std::string templateMethod() const OVERRIDE {
      return "subclass";
    }
};

int main() {
  Subclass().consumeTemplate();
  return 0;
}

Если мы скомпилируем его с параметром -UUSE_VIRTUAL_DISPATCH (-U не определяет этот макрос), то будет выведен base class. Внутри Base::consumeTemplate() вызов templateMethod() ссылается на Base::templateMethod(). Base не знает о Subclass::templateMethod().

Но если мы перекомпилируем с параметром -DUSE_VIRTUAL_DISPATCH (-D определяет этот макрос), то будет выведен subclass. Макрос приводит к тому, что templateMethod() определяется как virtual. Теперь метод Base::consumeTemplate() знает только то, что this поддерживает некоторую операцию templateMethod(), но не то, какой класс предоставляет эту операцию. Ему каким-то образом нужно искать в объекте нужный метод. Поскольку используемый объект на самом деле является экземпляром Subclass, он будет использовать Subclass::templateMethod().

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


Комментарии в Telegram-группе!