From 0cb79dc69e0e41d096f23d30617ef247ad293e28 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Mon, 20 Jun 2022 14:49:56 +0200 Subject: [PATCH 1/5] posts: add mutiple-dispatch-in-c++ --- .../index.md | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 content/posts/2022-11-02-multiple-dispatch-in-c++/index.md diff --git a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md new file mode 100644 index 0000000..e9eb21b --- /dev/null +++ b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md @@ -0,0 +1,246 @@ +--- +title: "Multiple Dispatch in C++" +date: 2022-11-02T16:36:53+01:00 +draft: false # I don't care for draft mode, git has branches for that +description: "A Lisp super-power in C++" +tags: + - c++ + - design-pattern +categories: + - programming +series: +favorite: false +disable_feed: false +--- + +A great feature that can be used in more dynamic languages is *multiple +dispatch*. Here's an example in [Julia][julia-lang] taken from the [Wikipedia +article][wiki-multiple-dispatch]. + +```julia +abstract type SpaceObject end + +struct Asteroid <: SpaceObject + # Asteroid fields +end +struct Spaceship <: SpaceObject + # Spaceship fields +end + +collide_with(::Asteroid, ::Spaceship) = # Asteroid/Spaceship collision +collide_with(::Spaceship, ::Asteroid) = # Spaceship/Asteroid collision +collide_with(::Spaceship, ::Spaceship) = # Spaceship/Spaceship collision +collide_with(::Asteroid, ::Asteroid) = # Asteroid/Asteroid collision + +collide(x::SpaceObject, y::SpaceObject) = collide_with(x, y) +``` + +The `collide` function calls `collide_with` which, at runtime, will inspect the +types of its arguments and *dispatch* to the appropriate implementation. + +Julia was created with multiple dispatch as a first-class citizen, it is used +liberally in its ecosystem. C++ does not have access to such a feature natively, +but there are alternatives that I will be presenting in this article, and try to +justify there uses and limitations. + +[julia-lang]: https://julialang.org/ +[wiki-multiple-dispatch]: https://en.wikipedia.org/wiki/Multiple_dispatch + + +## Single dispatch + +The native way to perform dynamic dispatch in C++ is through the +use of *virtual methods*, which allows an object to *override* the behaviour of +one of its super-classes' method. + +Invoking a virtual method will perform *single dispatch*, on the dynamic type +of the object who's method is being called. + +Here is an example: + +```cpp +struct SpaceObject { + virtual ~SpaceObject() = default; + + // Pure virtual method, which must be overridden by non-abstract sub-classes + virtual void impact() = 0; +}; + +struct Asteroid : SpaceObject { + // Override the method for asteroid impacts + void impact() override { + std::cout << "Bang!\n"; + } +}; + +struct Spaceship : SpaceObject { + // Override the method for spaceship impacts + void impact() override { + std::cout << "Crash!\n"; + } +}; + +int main() { + std::unique_ptr object = std::make_unique(); + object->impact(); // Prints "Crash!" + + object = std::make_unique(); + object->impact(); // Prints "Bang!" +} +``` + +Virtual methods are great when you want to represent a common set of behaviour +(an *interface*), and be able to substitute various types with their specific +implementation. + +For example, a dummy file-system interface might look like the following: + +```cpp +struct Filesystem { + virtual void write(std::string_view filename, std::span data) = 0; + virtual std::vector read(std::string_view filename) = 0; + virtual void delete(std::string_view filename) = 0; +}; +``` + +You can then write `PosixFilesystem` which makes use of the POSIX API and +interact with actual on-disk data, `MockFilesystem` which only works in-memory +and can be used for testing, etc... + +## Double dispatch through the Visitor pattern + +Sometimes single dispatch is not enough, such as in the collision example at the +beginning of this article. In cases where a computation depends on the dynamic +type of *two* of its values, we can make use of double-dispatch by leveraging +the Visitor design pattern. This is done by calling a virtual method on the +first value, which itself will call a virtual method on the second value. + +Here's a commentated example: + +```cpp +struct Asteroid; +struct Spaceship; + +struct SpaceObject { + virtual ~SpaceObject() = default; + + // Only used to kick-start the double-dispatch process + virtual void collide_with(SpaceObject& other) = 0; + + // The actual dispatching methods + virtual void collide_with(Asteroid& other) = 0; + virtual void collide_with(Spaceship& other) = 0; +}; + +struct Asteroid : SpaceObject { + void collide_with(SpaceObject& other) override { + // `*this` is an `Asteroid&` which kick-starts the double-dispatch + other.collide_with(*this); + }; + + void collide_with(Asteroid& other) override { /* Asteroid/Asteroid */ }; + void collide_with(Spaceship& other) override { /* Asteroid/Spaceship */ }; +}; + +struct Spaceship : SpaceObject { + void collide_with(SpaceObject& other) override { + // `*this` is a `Spaceship&` which kick-starts the double-dispatch + other.collide_with(*this); + }; + + void collide_with(Asteroid& other) override { /* Spaceship/Asteroid */ }; + void collide_with(Spaceship& other) override { /* Spaceship/Spaceship */ }; +}; + +void collide(SpaceObject& first, SpaceObject& second) { + first.collide_with(second); +}; + +int main() { + auto asteroid = std::make_unique(); + auto spaceship = std::make_unique(); + + collide(*asteroid, *spaceship); + // Calls in order: + // - Asteroid::collide_with(SpaceObject&) + // - Spaceship::collide_with(Asteroid&) + + collide(*spaceship, *asteroid); + // Calls in order: + // - Spaceship::collide_with(SpaceObject&) + // - Asteroid::collide_with(Spaceship&) + + asteroid->collide_with(*spaceship); + // Only calls Asteroid::collide_with(Spaceship&) + + spaceship->collide_with(*asteroid); + // Only calls Spaceship::collide_with(Asteroid&) +} +``` + +Double dispatch is pattern is most commonly used with the *visitor pattern*, in +which a closed class hierarchy (the data) is separated from an open class +hierarchy (the algorithms acting on that data). This is especially useful in +e.g: compilers, where the AST class hierarchy represents the data *only*, and +all compiler stages and optimization passes are programmed by a series of +visitors. + +## Multiple dispatch on a closed class hierarchy + +When even double dispatch is not enough, there is a way to do multiple dispatch +in standard C++, included in the STL since C++17. However unlike the previous +methods I showed, this one relies on using [`std::variant`][variant-cppref] and +[`std::visit`][visit-cppref]. + +[variant-cppref]: https://en.cppreference.com/w/cpp/utility/variant +[visit-cppref]: https://en.cppreference.com/w/cpp/utility/variant/visit + +The limitation of `std::variant` is that you are limited to the types you can +select at *compile-time* for the values used during your dispatch operation. +You have a *closed* hierarchy of classes, which is the explicit list of types in +your `variant`. + +Nonetheless, if you can live with that limitation, then you have a great amount +of power available to you. I have used `std::visit` in the past to mimic the +effect of pattern matching. + +In this example, I re-create the double-dispatch from the previous section: + +```cpp +// No need to inherit from a `SpaceObject` base class +struct Asteroid {}; +struct Spaceship {}; + +// But the list of possible runtime *must* be enumerated at compile-time +using SpaceObject = std::variant; + +void collide(SpaceObject& first, SpaceObject& second) { + struct CollideDispatch { + void operator()(Asteroid& first, Asteroid& second) { + // Asteroid/Asteroid + } + void operator()(Asteroid& first, Spaceship& second) { + // Asteroid/Spaceship + } + void operator()(Spaceship& first, Asteroid& second) { + // Spaceship/Asteroid + } + void operator()(Spaceship& first, Spaceship& second) { + // Spaceship/Spaceship + } + }; + + std::visit(CollideDispatch(), first, second); +} + +int main() { + SpaceObject asteroid = Asteroid(); + SpaceObject spaceship = Spaceship(); + + collide(asteroid, spaceship); + // Calls CollideDispatch::operator()(Asteroid&, Spaceship&) + + collide(spaceship, asteroid); + // Calls CollideDispatch::operator()(Spaceship&, Asteroid&) +} +``` From bb48eaea3418a1b819b7f187039a3563a42e5ba9 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Wed, 2 Nov 2022 16:03:25 +0100 Subject: [PATCH 2/5] posts: multiple-dispatch: add visitor downside --- .../posts/2022-11-02-multiple-dispatch-in-c++/index.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md index e9eb21b..928f5f9 100644 --- a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md +++ b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md @@ -185,6 +185,16 @@ e.g: compilers, where the AST class hierarchy represents the data *only*, and all compiler stages and optimization passes are programmed by a series of visitors. +One downside of this approach is that if you want to add `SpaceStation` as +a sub-class of `SpaceObject`, and handle its collisions with other +`SpaceObject`s, you need to: + +* Implement all `collide_with` methods for this new class. +* Add a new virtual method `collide_with(SpaceStation&)` and implement it on + every sub-class. + +This can be inconvenient if your class hierarchy changes often. + ## Multiple dispatch on a closed class hierarchy When even double dispatch is not enough, there is a way to do multiple dispatch From 7c6db4c19eae57a8feedbd57c9d41f460a4c17e5 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Wed, 2 Nov 2022 16:06:51 +0100 Subject: [PATCH 3/5] posts: multiple-dispatch: add visit downside --- content/posts/2022-11-02-multiple-dispatch-in-c++/index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md index 928f5f9..997faec 100644 --- a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md +++ b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md @@ -254,3 +254,7 @@ int main() { // Calls CollideDispatch::operator()(Spaceship&, Asteroid&) } ``` + +Obviously, the issue with adding a new `SpaceStation` variant is once again +apparent in this implementation. You will get a compile error unless you handle +this new `SpaceStation` variant at every point you `visit` the `SpaceObject`s. From 245d7d123d34555356005202a43a7e532c38a3fb Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Wed, 2 Nov 2022 16:31:43 +0100 Subject: [PATCH 4/5] posts: multiple-dispatch: add expression problem --- .../index.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md index 997faec..f179561 100644 --- a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md +++ b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md @@ -258,3 +258,35 @@ int main() { Obviously, the issue with adding a new `SpaceStation` variant is once again apparent in this implementation. You will get a compile error unless you handle this new `SpaceStation` variant at every point you `visit` the `SpaceObject`s. + +## The Expression Problem + +One issue we have not been able to move past in these exemples is the +[Expression Problem][expression-problem]. In two words, this means that we can't +add a new data type (e.g: `SpaceStation`), or a new operation (e.g: `land_on`) +to our current code without re-compiling it. + +[expression-problem]: https://en.wikipedia.org/wiki/Expression_problem + +This is the downside I was pointing out in our previous sections: + +* Data type extension: one can easily add a new `SpaceObject` child-class in the + OOP version, but needs to modify each implementation if we want to add a new + method to the `SpaceObject` interface to implement a new operation. +* Operation extension: one can easily create a new function when using the + `std::variant` based representation, as pattern-matching easily allows us to + only handle the kinds of values we are interested in. But adding a new + `SpaceObject` variant means we need to modify and re-compile every + `std::visit` call to handle the new variant. + +There is currently no (good) way in standard C++ to tackle the Expression +Problem. A paper ([N2216][N2216]) was written to propose a new language feature +to improve the situation. However it looks quite complex, and never got followed +up on for standardization. + +[N2216]: https://open-std.org/jtc1/sc22/wg21/docs/papers/2007/n2216.pdf + +In the meantime, one can find some libraries (like [`yomm2`][yomm2]) that +reduce the amount of boiler-plate needed to emulate this feature. + +[yomm2]: https://github.com/jll63/yomm2 From bd5f94746956a69fe6b835a4b34c92c5c5c8e426 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Thu, 3 Nov 2022 11:22:24 +0100 Subject: [PATCH 5/5] posts: multiple-dispatch: add 'yomm2' example --- .../index.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md index f179561..838de49 100644 --- a/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md +++ b/content/posts/2022-11-02-multiple-dispatch-in-c++/index.md @@ -290,3 +290,40 @@ In the meantime, one can find some libraries (like [`yomm2`][yomm2]) that reduce the amount of boiler-plate needed to emulate this feature. [yomm2]: https://github.com/jll63/yomm2 + +```cpp +#include + +struct SpaceObject { + virtual ~SpaceObject() = default; +}; + +struct Asteroid : SpaceObject { /* fields, methods, etc... */ }; + +struct Spaceship : SpaceObject { /* fields, methods, etc... */ }; + +// Register all sub-classes of `SpaceObject` for use with open methods +register_classes(SpaceObject, Asteroid, Spaceship); + +// Register the `collide` open method, which dispatches on two arguments +declare_method(void, collide, (virtual_, virtual_)); + +// Write the different implementations of `collide` +define_method(void, collide, (Asteroid& left, Asteroid& right)) { /* work */ } +define_method(void, collide, (Asteroid& left, Spaceship& right)) { /* work */ } +define_method(void, collide, (Spaceship& left, Asteroid& right)) { /* work */ } +define_method(void, collide, (Spaceship& left, Spaceship& right)) { /* work */ } + + +int main() { + yorel::yomm2::update_methods(); + + auto asteroid = std::make_unique(); + auto spaceship = std::make_unique(); + + collide(*asteroid, *spaceship); // Calls (Asteroid, Spaceship) version + collide(*spaceship, *asteroid); // Calls (Spaceship, Asteroid) version + collide(*asteroid, *asteroid); // Calls (Asteroid, Asteroid) version + collide(*spaceship, *spaceship); // Calls (Spaceship, Spaceship) version +} +```