Сравнение перегрузки С++ и мультиметодов Julia¶
Язык Julia очень хорошо позволяет использовать повторно использовать код для работы с разными типами данных, что позволяет вам легко определять новые типы и использовать существующие функции для работы с ними, или определять новые функции для существующих типов. Основной механизм, который позволяет этого достигать, называется мультиметоды, или множественная диспетчеризация (multiple dispatch).
В этом примере мы сравним механизм мультиметодов Julia и более привычный механизм перегрузки функций из "чистых ООП языков" и попробуем продемонстрировать его преимущества.
Перегрузка функций и диспетчеризация аргументов¶
Механизм перегрузки функций возник для того, чтобы облегчить работу программистов. Благодаря нему, функцию квадратного корня sqrt
или оператор сложения +
можно переопределить для разных типов данных и заставить выполнять разные действия в зависимости от списка аргументов. Этот механизм используется в "чистых ООП-языках" (Python, C++ или Delphi), в которых перегрузка функций и операторов является инструментом статического полиморфизма.
- Языки без диспетчеризации (C) позволяют иметь только одну реализацию функции, то есть не позволяют иметь вызывать через одно и то же название вызывать разные функции в зависимости от переданных в нее аргументов.
- Языки одиночной или с динамической диспетчеризацией (C++, Python...) опираются на один из аргументов для определения конкретной реализации вызываемой функции (первый, второй...).
- Языки с множественной диспетчеризацией (Lisp, Julia) выбирают конкретную реализацию функции, опираясь на все аргументы в списке (или на первый и третий, или на любое сочетание приоритетов).
Множественная диспетчеризация в Julia на первый взгляд похожа на перегрузку функций, но на самом деле работает иначе. Предлагаем погрузиться в пример, который должен показать работу механизма множественной диспетчеризации.
Реализация в Julia¶
В этом примере мы оставим англоязычные имена типов и полей для упрощения сравнения с исходным кодом на С++, который мы тоже напишем чуть позже.
Первым делом, мы создаем абстрактный тип Pet
и наследуем от него типы Dog
и Cat
. Мы не можем задать поле name
абстрактному типу, поэтому задаем это поле у каждого класса, допуская немного повторного кода. Затем мы задаем универсальную функцию encounter
, которая описывает взаимодействие двух животных. Первым делом она находит, какой глагол применим для этого взаимодействия, вызывая другую универсальную функцию meets
, которая еще не определена.
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
Последним шагом функция encounter
печатает отчет о происшествии: какие животные встретились и подставляет нужный глагол.
Мы создаем четыре варианта функции meets
с разными аргументами и определяем разные глаголы, которые будут подставляться в итоговый отчет (функция encounter
у нас по-прежнему одна). В зависимости от типа обоих животных глаголы будут разными.
meets( a::Dog, b::Dog ) = "обнюхивает"
meets( a::Dog, b::Cat ) = "прогоняет"
meets( a::Cat, b::Dog ) = "шипит"
meets( a::Cat, b::Cat ) = "подкрадывается"
Затем мы создаем экземпляры наших объектов (конкретных питомцев) и впоследствии вызовем функцию encounter
с ними в качестве аргументов.
fido = Dog( "Fido" )
rex = Dog( "Rex" )
whiskers = Cat( "Whiskers" )
spots = Cat( "Spots" )
Отметим, что абстрактные типы в Julia служат только для создания графа зависимости между типами. В них нельзя объявлять поля. Что может быть причиной:
- Может быть то, что в Julia наследуется тип, а не класс.
- Или то, что если объявить поле в абстрактном классе, то дочерний класс не сможет от него избавиться.
- Или то, что при определении полей в наследованном типе, код получается более самодокументированным.
Реализация на С++¶
Реализуем такой же хрестоматийный пример на 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
, который можно будет запускать и получать вывод.
;g++ pets.cpp -o pets
Сравнение реализаций¶
Теперь сравним работу механизма множественной диспетчеризации и механизм перегрузки функций, который он призван развивать.
1. Реализация в Julia
Подстроим несколько "встреч" при помощи Julia-функции encounter
и посмотрим на вывод.
encounter( fido, rex )
encounter( fido, whiskers )
encounter( whiskers, rex )
encounter( whiskers, spots )
Все так, как мы и ожидали.
2. Реализация на С++
Запустим код, который был скомпилирован в бинарный файл pets
.
;./pets
Почему так происходит¶
Узким местом тут является функция 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
.