Документация Engee
Notebook

Сравнение перегрузки С++ и мультиметодов Julia

Язык Julia очень хорошо позволяет использовать повторно использовать код для работы с разными типами данных, что позволяет вам легко определять новые типы и использовать существующие функции для работы с ними, или определять новые функции для существующих типов. Основной механизм, который позволяет этого достигать, называется мультиметоды, или множественная диспетчеризация (multiple dispatch).

В этом примере мы сравним механизм мультиметодов Julia и более привычный механизм перегрузки функций из "чистых ООП языков" и попробуем продемонстрировать его преимущества.

Перегрузка функций и диспетчеризация аргументов

Механизм перегрузки функций возник для того, чтобы облегчить работу программистов. Благодаря нему, функцию квадратного корня sqrt или оператор сложения + можно переопределить для разных типов данных и заставить выполнять разные действия в зависимости от списка аргументов. Этот механизм используется в "чистых ООП-языках" (Python, C++ или Delphi), в которых перегрузка функций и операторов является инструментом статического полиморфизма.

  • Языки без диспетчеризации (C) позволяют иметь только одну реализацию функции, то есть не позволяют иметь вызывать через одно и то же название вызывать разные функции в зависимости от переданных в нее аргументов.
  • Языки одиночной или с динамической диспетчеризацией (C++, Python...) опираются на один из аргументов для определения конкретной реализации вызываемой функции (первый, второй...).
  • Языки с множественной диспетчеризацией (Lisp, Julia) выбирают конкретную реализацию функции, опираясь на все аргументы в списке (или на первый и третий, или на любое сочетание приоритетов).

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

Реализация в Julia

В этом примере мы оставим англоязычные имена типов и полей для упрощения сравнения с исходным кодом на С++, который мы тоже напишем чуть позже.

Первым делом, мы создаем абстрактный тип Pet и наследуем от него типы Dog и Cat. Мы не можем задать поле name абстрактному типу, поэтому задаем это поле у каждого класса, допуская немного повторного кода. Затем мы задаем универсальную функцию encounter, которая описывает взаимодействие двух животных. Первым делом она находит, какой глагол применим для этого взаимодействия, вызывая другую универсальную функцию meets, которая еще не определена.

In [ ]:
abstract type Pet end

struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end

# Мы можем определить метод для работы с данными родительского типа,
# но в этой нашей демонстрации он не будет вызываться
meets( c::Pet, d::Pet ) = "GENERIC"

function encounter( a::Pet, b::Pet )
    verb = meets(a, b)
    println( "$(a.name) встречает $(b.name) и $verb" )
end
Out[0]:
encounter (generic function with 1 method)

Последним шагом функция encounter печатает отчет о происшествии: какие животные встретились и подставляет нужный глагол.

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

In [ ]:
meets( a::Dog, b::Dog ) = "обнюхивает"
meets( a::Dog, b::Cat ) = "прогоняет"
meets( a::Cat, b::Dog ) = "шипит"
meets( a::Cat, b::Cat ) = "подкрадывается"
Out[0]:
meets (generic function with 6 methods)

Затем мы создаем экземпляры наших объектов (конкретных питомцев) и впоследствии вызовем функцию encounter с ними в качестве аргументов.

In [ ]:
fido = Dog( "Fido" )
rex = Dog( "Rex" )
whiskers = Cat( "Whiskers" )
spots = Cat( "Spots" )
Out[0]:
Cat("Spots")

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

  1. Может быть то, что в Julia наследуется тип, а не класс.
  2. Или то, что если объявить поле в абстрактном классе, то дочерний класс не сможет от него избавиться.
  3. Или то, что при определении полей в наследованном типе, код получается более самодокументированным.

Реализация на С++

Реализуем такой же хрестоматийный пример на C++. Его код лежит в файле pets.cpp.

Сразу видим одно преимущество: в C++ можно добавлять поле в абстрактный тип. Затем мы должны определить метод meets, потому что без него компилятор выдаст нам ошибку. Мы не можем переопределять функцию, которая не была определена, без нее компилятор вернет нам ошибку In function 'void encouter(Pet,Pet)': error: 'meets' was not declared in this scope.

class Pet{
    public:
        string name;
};

string meets( Pet a, Pet b ) {return "GENERIC"; }

void encounter( Pet a, Pet b ){
    string verb = meets( a, b );
    cout << a.name << " встречает " << b.name << " и " << verb << endl;
}

Затем мы создаем подтипы Dog и Cat, внутри их определений не нужно прописывать поле name, потому что оно есть у абстрактного класса. И ниже мы переопределяем методы, но, если быть точными, это не методы, а перегруженные функции.

class Dog : public Pet {};
class Cat : public Pet {};

string meets( Dog a, Dog b ) { return "обнюхивает"; }
string meets( Dog a, Cat b ) { return "прогоняет"; }
string meets( Cat a, Dog b ) { return "шипит"; }
string meets( Cat a, Cat b ) { return "подкрадывается"; }

И внутри функции main мы создаем все экземпляры объектов, даем им имена и определяем, что происходит при вызове encounter с разными питомцами в качестве аргументов.

int main() {
    Dog fido;      fido.name      = "Fido";
    Dog rex;       fido.name      = "Rex";
    Cat whiskers;  whiskers.name  = "Whiskers";
    Cat spots;     spots.name     = "Spots";

    encounter( fido, rex );
    encounter( fido, whiskers );
    encounter( whiskers, rex );
    encounter( whiskers, spots );

    return 0;
}

Скомпилируем этот код. В результате мы должны получить бинарный файл pets, который можно будет запускать и получать вывод.

In [ ]:
;g++ pets.cpp -o pets

Сравнение реализаций

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

1. Реализация в Julia

Подстроим несколько "встреч" при помощи Julia-функции encounter и посмотрим на вывод.

In [ ]:
encounter( fido, rex )
encounter( fido, whiskers )
encounter( whiskers, rex )
encounter( whiskers, spots )
Fido встречает Rex и обнюхивает
Fido встречает Whiskers и прогоняет
Whiskers встречает Rex и шипит
Whiskers встречает Spots и подкрадывается

Все так, как мы и ожидали.

2. Реализация на С++

Запустим код, который был скомпилирован в бинарный файл pets.

In [ ]:
;./pets
Fido встречает Rex и GENERIC
Fido встречает Whiskers и GENERIC
Whiskers встречает Rex и GENERIC
Whiskers встречает Spots и GENERIC

Почему так происходит

Узким местом тут является функция encounter( Pet a, Pet b ). Мы можем переопределить функцию encounter для всех возможных аргументов:

void encounter( Dog a, Cat b ){
    string verb = meets( a, b );
    cout << a.name << " встречает " << b.name << " и " << verb << endl;
}

Но это лишь покажет, что мы не можем наследовать поведение для новых типов данных. Для реализации динамического полиморфизма в C++ используются виртуальные методы, но их тоже нужно переопределять в каждом дочернем классе, и это тоже не упрощает наследование кода.

Заключение

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

Этот пример вдохновлен презентацией одного из создателей языка Julia: The Unreasonable Effectiveness of Multiple Dispatch | Stefan Karpinski | JuliaCon 2019.