Comparison of C++ overloading and Julia multimethods
The Julia language is very good at reusing code to work with different data types, which allows 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.
In this example, we will compare the Julia multimethod mechanism and the more familiar function overloading mechanism from "pure OOP languages" and try to demonstrate its advantages.
Overloading functions and dispatching arguments
The function overloading mechanism arose in order to make the work of programmers easier. Thanks to him, the square root function sqrt or the addition operator + it can be redefined for different data types and forced to perform different actions depending on the list of arguments. This mechanism is used in "pure OOP languages" (Python, C++, or Delphi), in which overloading functions and operators is a tool for static polymorphism.
- Languages without dispatching (C) allow you to have only one function implementation, that is, they do not allow you to call different functions through the same name depending on the arguments passed to it.
- Single or dynamic dispatch languages (C++, Python...) they rely on one of the arguments to determine the specific implementation of the function being called (first, second...).
- Languages with multiple dispatch (Lisp, Julia) select a specific implementation of the function based on all the arguments in the list (either the first and third, or any combination of priorities).
At first glance, Julia's multiple dispatching looks like function overloading, but it actually works differently. We suggest diving into an example that should show how the multiple dispatch mechanism works.
Implementation in Julia
In this example, we will leave the English-language names of types and fields to simplify comparison with the C++ source code, which we will also write later.
First of all, we create an abstract type. Pet and we inherit types from it. Dog and Cat. We can't set the field name an abstract type, so we set this field for each class, allowing for a bit of repetitive code. Then we define a universal function encounter, which describes the interaction of two animals. First of all, she finds which verb is applicable for this interaction, calling another universal function. meets, which has not yet been determined.
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
The last step is the function encounter prints the incident report: which animals met and substitutes the necessary verb.
We are creating four variants of the function meets with different arguments, we define different verbs that will be inserted into the final report (function encounter we still have one). 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 serve only to create a dependency graph between types. Fields cannot be declared in them. What could be the reason?:
- It may be that Julia inherits a type, not a class.
- Or that if you declare a field in an abstract class, then the child class will not be able to get rid of it.
- Or the fact that when defining fields in an inherited type, the code turns out to be more self-documenting.
Implementation in C++
Let's implement the same textbook example in C++. Its code is in the file pets.cpp.
We immediately see one advantage: in C++, you can add a field to an abstract type. Then we have to 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 returns 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 there is no need to specify a field inside their definitions name because the abstract class has it. And below we redefine the 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 "drives away"; }
string meets( Cat a, Dog b ) { return "hisses"; }
string meets( Cat a, Cat b ) { return is "creeping up"; }
And inside the function main we create all instances of objects, give them names, and determine what happens when called. 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 can be run and output can be received.
;g++ pets.cpp -o pets
Comparison of implementations
Now let's compare the operation of the multiple dispatch mechanism and the function overload mechanism that it is designed to develop.
1. Implementation in Julia
Let's set up several "meetings" using the Julia function encounter and let's 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 is this happening
The bottleneck here is the function encounter( Pet a, Pet b ). We can redefine 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 will only show that we cannot inherit behavior for new data types. To implement dynamic polymorphism in C++, virtual methods are used, but they also need to be redefined in each child class, and this also does not simplify code inheritance.
Conclusion
The function overloading mechanism does not allow working with dynamically defined types, all types are defined statically. Julia uses a multiple dispatch mechanism to solve the inheritance problem, and we have shown that inheritance in Julia works great 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.