In the first part of the article, I presented a general review of the callback mechanism, and of the tools C++ offers to implement it.
In this second part I will focus on the pitfalls and abuses in the usage of C++ callbacks.
Callback Pitfalls, Really?
Callbacks embody the principle of delegation and indirection, which is indeed a powerful and elegant way to crack open complex software design problems. At first sight, they look soo simple and powerful that no harm can come from them.
In fact, the (tongue-in-cheek) fundamental theorem of software development states:
All problems in computer science can be solved by another level of indirection.
But the very important first corollary is often left out:
& except the problem of too many levels of indirections.
As any powerful tool in the toolset of software developers, callbacks can be abused, and the power tools offered by C++ make their abuse even easier.
In the rest of this article I will list some the most common pitfalls and abuses I have witnessed in my career, in no particular order, as a set of things that are better avoided (TABA”).
As everything in software development, TABAs are not “taboo”; there might be very good reasons for going TABA, but when that needs to happen, it should be deliberate and well motivated, and not just due to lack of knowledge and awareness of the consequences.
Callback-based Polymorphism
While C++ remains a strongly typed language, modern C++ allows for an unprecedented freedom of choice when it comes to functional programming. While virtual inheritance was the preferential choice to provide dynamic polymorphism, it’s now easy to use callbacks for this purpose.
The following example shows how the same effect can be achieved through traditional polymorphism and callbacks.
// Ploymorphism through inheritance.
struct VirtualButton {
virtual void onClick() {}
};
struct OkButton : public VirtualButton {
virtual void onClick() override {
std::cout << "You Clicked OK!\n";
}
};
// Polymorphism through callbacks.
struct CallbackButton {
using ClickHandler = std::function<void()>;
CallbackButton(ClickHandler onClick): onClick_{onClick} {}
private:
ClickHandler onClick_;
};
CallbackButton OkCallbackButton{ []{std::cout << "You clicked OK\n";} };
Callback-based polymorphism has two massive advantages:
- Behaviour can be defined by providing arbitrary functions, in particular closures that can carry additional data, creating them on the fly where and when needed.
- It’s possible to change dynamically the behaviour of already existing objects.
However it has also some massive drawbacks:
- When using closures, care must be taken to match the lifetime of the closed objects and that of the calling entity.
- It’s hard to find and follow the flow of the logic used by the caller, as the reader can never be sure that the callback hasn’t changed across different parts of the user code, or even across different iterations on the same code.
- It’s a easy to create closures just to carry around locally defined entities, and hard for a reader to remember what entities are involved each time the callback is used.
Callback-based polymorphism can lead to unreadable, brittle and rigid code, as new dependencies and constraints can creep in unchecked at later stages of development, and changing any code related with the caller or the callbacks can become increasingly expensive. For this reasons, it should be avoided unless its benefits are strongly needed.
Entity Relationship Obfuscation
Somewhat related with the previous point, at times a liberal use of callbacks can obfuscate an otherwise clear relationship between program entities.