-
Notifications
You must be signed in to change notification settings - Fork 130
Motivation
Let's get started with a basic example that explains the motivation behind signals and which kind of problems they are meant to address.
Here's a class Shape
with two dimensions Width
and Height
that can be changed imperatively:
class Shape
{
public:
int Width = 0;
int Height = 0;
};
The size of the shape should be calculated accordingly:
int calculateSize(int width, int height) { return width*height; }
class Shape
{
public:
int Width = 0;
int Height = 0;
int Size() const { return Width * Height; }
};
This gets the job done, but whenever Size()
is called, the calculation is repeated, even if the the shape's dimensions did not change after the previous call.
With this simple example it would be fine, but let's assume calculating size would be an expensive operation.
We rather want to re-calculate it once after width or height have been changed and just return that result in Size()
.
class Shape
{
public:
int Width() const { return width_; }
int Height() const { return height_; }
int Size() const { return size_; }
void SetWidth(int v)
{
if (width_ == v) return;
width_ = v;
updateSize();
}
void SetHeight(int v)
{
if (height_ == v) return;
height_ = v;
updateSize();
}
private:
void updateSize() { size_ = width_ * height_; }
int width_ = 0;
int height_ = 0;
int size_ = 0;
};
Shape myShape;
// Set dimensions
myShape.SetWidth(20);
myShape.SetHeight(20);
// Get size
auto curSize = myShape.Size();
To calculate the size, updateSize()
is called manually after width or height have been changed.
This adds quite a bit of boilerplate code and as usual when having to do things manually, we can make mistakes.
What if more dependent attributes should be added? Using the current approach, updates are manually triggered from the dependencies. This requires changing all dependencies when adding a new dependent values, which gets increasingly complex. More importantly, it's not an option, if the dependent values are not known yet or could be added and removed dynamically. A common approach to enable this are callbacks.
class Shape
{
public:
using CallbackT = std::function<void(int)>;
int Width() const { return width_; }
int Height() const { return height_; }
int Size() const { return size_; }
void SetWidth(int v)
{
if (width_ == v) return;
width_ = v;
updateSize();
}
void SetHeight(int v)
{
if (height_ == v) return;
height_ = v;
updateSize();
}
void AddSizeChangeCallback(const CallbackT& f) { sizeCallbacks_.push_back(f); }
private:
void updateSize()
{
auto oldSize = size_;
size_ = width_ * height_;
if (oldSize != size_)
notifySizeCallbacks();
}
void notifySizeCallbacks()
{
for (const auto& f : sizeCallbacks_)
f(size_);
}
int width_ = 0;
int height_ = 0;
int size_ = 0;
std::vector<CallbackT> sizeCallbacks_;
};
Shape myShape;
// Callback on change
myShape.AddSizeChangeCallback([] (int newSize) {
redraw();
});
For brevity, this example includes callbacks for size changes, but not for width and height. Nonetheless, it adds even more boilerplate.
Instead of implementing the callback mechanism ourselves, we can use external libraries for that, for example boost::signals2
, which handles storage and batch invocation of callbacks; but overall, it has no impact on the design.
To summarize some of the pressing issues with the solutions shown so far:
- Error-proneness: There is no guarantee that
size == width * height
. It's only true as long as we don't forget to callupdateSize()
after changes. - Boilerplate: Check against previous value, trigger update of dependent internal values, trigger callback notification, register callbacks, ...
- Complexity: Adding new dependent attributes requires changes in existing functions and potentially adding additional callback holders.
- API pollution: Registration functions for callbacks.
What it boils down to is that the change propagation must be handled by hand. The next example shows how features of our library address those issues.
#include "react/Domain.h"
#include "react/Signal.h"
#include "react/Observer.h"
using namespace react;
REACTIVE_DOMAIN(D);
class Shape : public ReactiveObject<D>
{
public:
VarSignalT<int> Width = MakeVar(0);
VarSignalT<int> Height = MakeVar(0);
SignalT<int> Size = Width * Height;
};
Shape myShape;
// Set dimensions
myShape.Width <<= 20;
myShape.Height <<= 20;
// Get size
auto curSize = myShape.Size(); // Or more verbose: myShape.Size.Value()
Size
now behaves like a pure function of Width
and Height
similar to Solution 1.
But behind the scenes, it still does everything it does in Solution 2, i.e. only re-calculate size when width or height have changed.
Every reactive value automatically supports registration of callbacks (they are called observers here):
// Callback on change
myShape.Size.Observe([] (int newSize) {
redraw();
});
// Those work, too
myShape.Width.Observe([] (int newWidth) { ... });
myShape.Height.Observe([] (int newHeight) { ... });