Comparison of C++ overload and multimethods Julia¶
The Julia language is very good at reusing code to work with different data types, allowing you to easily define new types and use existing functions to work with them, or define new functions for existing types. The main mechanism that allows this to be achieved is called multimethods, or multiple dispatch (multiple dispatch).
In this example, we will compare Julia's multi-methods mechanism with the more familiar function overloading mechanism from "pure OOP languages" and try to demonstrate its advantages.
Function overloading and argument dispatching¶
The function overloading mechanism was created to make programmers' work easier. Thanks to it, the square root function sqrt
or the addition operator +
can be overridden for different data types and made to perform different actions depending on the argument list. This mechanism is used in "pure OOP languages" (Python, C++ or Delphi), where function and operator overloading is a tool of static polymorphism.
- Non-dispatchable languages (C) allow you to have only one implementation of a function, i.e. they do not allow you to have different functions called through the same name depending on the arguments passed to it.
- Languages with single or dynamic dispatch (C++, Python...) rely on one of the arguments to define the specific implementation of the function to be called (first, second...).
- Languages with multiple dispatch (Lisp, Julia) choose a particular implementation of a function by relying on all arguments in the list (or on the first and third, or on any combination of priorities).
Multiple dispatch in Julia looks like function overloading at first glance, but it actually works differently. We suggest diving into an example that should show how the multiple dispatching mechanism works.
Implementation in Julia¶
In this example we will leave the English names of types and fields to simplify comparison with the C++ source code, which we will also write a bit later.
First of all, we create the abstract type Pet
and inherit from it the types Dog
and Cat
. We can't set the name
field to an abstract type, so we set this field in each class, allowing a bit of repetitive code. We then define a generic function encounter
, which describes the interaction between two animals. It first finds which verb applies to this interaction by calling another universal function meets
, which has not yet been defined.
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
In the last step, the function encounter
prints an incident report: which animals met and substitutes the verb needed.
We create four variants of the function meets
with different arguments and define different verbs to be substituted in the final report (we still have one function encounter
). Depending on the type of both animals, the verbs will be different.
meets( a::Dog, b::Dog ) = "обнюхивает"
meets( a::Dog, b::Cat ) = "прогоняет"
meets( a::Cat, b::Dog ) = "шипит"
meets( a::Cat, b::Cat ) = "подкрадывается"
Then we create instances of our objects (specific pets) and subsequently call the function encounter
with them as arguments.
fido = Dog( "Fido" )
rex = Dog( "Rex" )
whiskers = Cat( "Whiskers" )
spots = Cat( "Spots" )
Note that abstract types in Julia are only used to create a dependency graph between types. You cannot declare fields in them. Which may be the reason:
- It could be that in Julia a type is inherited rather than a class.
- Or that if you declare a field in an abstract class, the child class cannot get rid of it.
- Or that when defining fields in an inherited type, the code is more self-documenting.
C++ implementation¶
Let's implement the same textbook example in C++. Its code is in the file pets.cpp
.
At once we see one advantage: in C++ we can add a field to an abstract type. Then we must define the method meets
, because without it the compiler will give us an error. We cannot redefine a function that has not been defined, without it the compiler will return an error 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 << " meets " << b.name << " and " << verb << endl;
}
Then we create subtypes Dog
and Cat
, inside their definitions we don't need to write the field name
, because the abstract class has it. And below we redefine methods, but to be precise, these are not methods, but overloaded functions.
class Dog : public Pet {};
class Cat : public Pet {};
string meets( Dog a, Dog b ) { return "sniffs"; }
string meets( Dog a, Cat b ) { return "chases"; }
string meets( Cat a, Dog b ) { return "hisses"; }
string meets( Cat a, Cat b ) { return "sneaks"; }
And inside the main
function, we create all the object instances, name them, and define what happens when we call encounter
with different pets as arguments.
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;
}
Let's compile this code. As a result, we should get a binary file pets
, which we can run and get the output.
;g++ pets.cpp -o pets
Comparison of implementations¶
Let us now compare the operation of the multiple dispatching mechanism and the function overloading mechanism it is intended to develop.
1. Implementation in Julia
Let's tweak a few "encounters" using the Julia function encounter
and look at the output.
encounter( fido, rex )
encounter( fido, whiskers )
encounter( whiskers, rex )
encounter( whiskers, spots )
Everything is as we expected.
2. Implementation in C++
Let's run the code that was compiled into a binary file pets
.
;./pets
Why does this happen¶
The bottleneck here is the function encounter( Pet a, Pet b )
. We can override the function encounter
for all possible arguments:
void encounter( Dog a, Cat b ){
string verb = meets( a, b );
cout << a.name << " meets " << b.name << " and " << verb << endl;
}
But this just goes to show that we can't inherit behaviour for new data types. C++ uses virtual methods to implement dynamic polymorphism, but these too need to be overridden in each child class, and this doesn't make code inheritance any easier either.
Conclusion¶
Function overloading mechanism does not allow you to work with dynamically defined types, all types are defined statically. To solve the problem of inheritance in Julia, the multiple dispatch mechanism is used, and we have shown that inheritance in Julia works fine with both parent and child data types.
This example is inspired by a presentation by one of the creators of the Julia language: The Unreasonable Effectiveness of Multiple Dispatch | Stefan Karpinski | JuliaCon 2019
.