Subnavigation

All about property bindings in Qt 6.2

Qt 6 introduced bindable properties a while ago. Based on our experience and feedback we got after the 6.0 release, we further improved the underlying engine. In this post we will give the overview of the bindable properties, see what has changed since our last update on property bindings, and discuss why you may want to use the new property bindings in your C++ code.  

Overview of bindable properties

Let's first review what bindable properties are, and the motivation behind them.
As readers might already know, property bindings are one of the core features of QML that allow to specify relationships between different object properties. When any dependency of a property changes, the value of the property is automatically updated. The new bindable property system brings this concept into C++ code. Consider the following simple QML example:

Rectangle {
    height: 10
    width: 20
    area: height * width
}

The equivalent C++ with bindable properties would be:

struct Rectangle
{
    QProperty<int> height { 10 };
    QProperty<int> width { 20 };
    QProperty<int> area;
    
    Rectangle() { area.setBinding([&] { return height * width; }); }
};

If width or height changes, area will be automatically recalculated:

Rectangle rect; // rect.area == 200
rect.width = 5; // rect.area == 50

QProperty also allows adding observers, to get notified as soon as its value changes:

auto notifier = rect.area.addNotifier([&] {
    qDebug()<<"Area changed:"<<rect.area;
});

rect.height = 20; // This will output "Area changed: 100"

Notice that there's no signal-slot connection involved, and Rectangle doesn't depend on the metaobject system. Of course, if you want, you can still expose your properties to the metaobject system (for example, if you want to make the property available also in QML) by marking your Q_PROPERTYs with the BINDABLE keyword:

class Rectangle : public QObject {
    Q_OBJECT
    
    Q_PROPERTY(int height READ height WRITE setHeight NOTIFY heightChanged BINDABLE bindableHeight)
    Q_PROPERTY(int width READ width WRITE setWidth NOTIFY widthChanged BINDABLE bindableWidth)
    Q_PROPERTY(int area READ area NOTIFY areaChanged BINDABLE bindableArea)
public:
    Rectangle() { m_area.setBinding([&] { return m_height * m_width; }); }  
    
    void setHeight(int h) { m_height = h; }
    int height() const { return m_height; }
    QBindable<int> bindableHeight() { return &m_height; }
    
    void setWidth(int w) { m_width = w; }
    int width() const { return m_width; }
    QBindable<int> bindableWidth() { return &m_width; } 
    
    int area() const { return m_area; } 
    QBindable<int> bindableArea() { return &m_area; }
    
signals: 
    void heightChanged(); 
    void widthChanged();
void areaChanged(); private: Q_OBJECT_BINDABLE_PROPERTY(Rectangle, int, m_height, &Rectangle::heightChanged) Q_OBJECT_BINDABLE_PROPERTY(Rectangle, int, m_width, &Rectangle::widthChanged) Q_OBJECT_BINDABLE_PROPERTY(Rectangle, int, m_area, &Rectangle::areaChanged) };

As you can see, apart from the BINDABLE keyword, we have a few other additions compared to Qt 5 properties. Firstly, for each property we have a bindable*() method that returns a QBindable. This is needed for retrieving bindings and accessing the functionality available in QProperty, for example:

Rectangle rect;
auto notifier = rect. bindableArea().addNotifier([&] { ... });

Secondly, the properties are declared via Q_OBJECT_BINDABLE_PROPERTY macros, to automatically emit the property-change notifications whenever the value of the property changes. Before setting a new value, the setters don't need to check if the new value actually differs from the old one and emit the property-change signals because the bindable properties already do that behind the scenes.

But everything described here is not new, we had this since Qt 6.0, so let's see what has changed with the latter releases.

Changes to bindable property system

The initial implementation of bindable properties was relying on deferred (or lazy) binding evaluation, which means that a property change doesn't immediately trigger re-evaluation of bindings that depend on the property. Instead, the bindings are evaluated lazily, only when the property is read. The advantage is that the bindings that depend on multiple properties won't be re-evaluated every time one of the dependent properties changes. This also results in less property-change notifications, since they aren't sent for intermediate values when evaluating a binding.

This sounds great, but when porting some of the properties of different Qt modules to the new bindable property system, we realized that for most of the properties deferred evaluation doesn't work well. Most of the existing code requires eager evaluation, and changing it to work with lazy evaluation without breaking the logic requires a lot of modifications and significant rewrites in some cases. This might also be true for the user code. So it was decided to use eager evaluation and add support of grouping property updates together, to minimize unnecessary recalculations and change notifications. For example:

QProperty<int> a {1};
QProperty<int> b {2};
QProperty<int> c;
c.setBinding([&] { return a + b; });
 
auto notifier = c.addNotifier([&] {
    qDebug()<<"Value of c changed:"<<c.value();
});

Here we have the property c, that depends on a and b. If we change the values of a and b, due to the eager evaluation, the value of c will be updated after each change:

a = 2;
b = 3;

This will output:

Value of c changed: 4
Value of c changed: 5

And c's value will be updated after each assignment. In contrast, if we group the property updates:

Qt::beginPropertyUpdateGroup();
a = 2;
b = 3; 
Qt::endPropertyUpdateGroup();

This will output:

Value of c changed: 5

If we read the value of c between Qt::beginPropertyUpdateGroup() and Qt::endPropertyUpdateGroup(), we'll see the old value (3 in this case), and the value change notification will be sent only once, after all property updates inside the group are done.

To recap, the major change is using eager evaluation instead of lazy evaluation, with an option to defer the evaluation by grouping together related property updates.

Bindable properties in action

If you're not convinced yet that using bindable properties is a good idea and if it makes sense to port properties in your existing code, let's look at a practical example and see how property bindings can simplify your code.

Let's say we are modeling a subscription system and we want to calculate the cost of the subscription based on the age and country of the user, and the duration of the subscription. So we have the Subscription class:

class Subscription : public QObject
{
    Q_OBJECT
public:
    enum Duration { Monthly = 1, Quarterly = 4, Yearly = 12 };
Subscription(User *user); void calculatePrice(); int price() const { return m_price; } Duration duration() const { return m_duration; } void setDuration(Duration newDuration); bool isValid() const { return m_isValid; } void updateValidity(); signals: void priceChanged(); void durationChanged(); void isValidChanged(); private: double calculateDiscount() const; int basePrice() const; QPointer<User> m_user; Duration m_duration = Monthly; int m_price = 0; bool m_isValid = false; };

The price is calculated based on the country of the user and the duration of the subscription:

void Subscription::calculatePrice()
{
    const auto oldPrice = m_price;
    
    m_price = qRound(calculateDiscount() * m_duration * basePrice());
    if (m_price != oldPrice)
        emit priceChanged();
}

int Subscription::basePrice() const
{
    return (m_user->country() == User::Norway) ? 100 : 80;
}

double Subscription::calculateDiscount() const
{
    switch (m_duration) {
    case Monthly:
        return 1;
    case Quarterly:
        return 0.9;
    case Yearly:
        return 0.6;
    }
    return -1;
}

When the duration of the subscription changes, Subscription needs to recalculate the price and notify about the duration change:

void Subscription::setDuration(Duration newDuration)
{
    if (newDuration != m_duration) {
        m_duration = newDuration;
        calculatePrice();
        emit durationChanged();
    }
}

Additionally, we want to enable subscription only for the valid countries and users older than 12:

void Subscription::updateValidity()
{
    bool isValid = m_isValid;
    m_isValid = m_user->country() != User::None && m_user->age() > 12;
    
    if (m_isValid != isValid)
        emit isValidChanged();
}

The User class is simpler: it stores country and age of the user, and provides the corresponding getters, setters, and notifier signals:

class User : public QObject
{
    Q_OBJECT
public:
    enum Country { None,  Finland, Germany, Norway };
    
    Country country() const { return m_country; }
    void setCountry(Country country)
    {
        if (m_country != country) {
            m_country = country;
            emit countryChanged();
        }
    }
    
    int age() const { return m_age; }
    void setAge(int age)
    {
        if (m_age != age) {
            m_age = age;
            emit ageChanged();
        }
    }
    
signals:
    void countryChanged();
    void ageChanged();
    
private:
    Country m_country = Country::None;
    int m_age = 0;
};

To update the price in the UI when any of the parameters change, we need to create the proper signal-slot connections:


User user;
Subscription subscription(&user);
...
QObject::connect(&subscription, &Subscription::priceChanged, [&] {
    priceDisplay->setText(QString::number(subscription.price()));
});

QObject::connect(&subscription, &Subscription::isValidChanged, [&] {
    priceDisplay->setEnabled(subscription.isValid());
});

QObject::connect(&user, &User::countryChanged, [&] {
    subscription.calculatePrice();
    subscription.updateValidity();
});

QObject::connect(&user, &User::ageChanged, [&] {
    subscription.updateValidity();
});

This works, but isn't ideal. We need to rely on the metaobject system to handle the changes to user and subscription via signal-slot connections. We need to be careful to recalculate the price and update the validity, whenever the country, age, or duration changes. If more dependencies are added in the future, we might end up with "spaghetti" code, which will be hard to extend and maintain.

Now let's see how the same problem can be solved using the property bindings.

class BindableSubscription
{
public:
    enum Duration { Monthly = 1, Quarterly = 4, Yearly = 12 };
    
    BindableSubscription(BindableUser *user);
    BindableSubscription(const BindableSubscription &) = delete;
    
    int price() const { return m_price; }
    QBindable<int> bindablePrice() { return &m_price; }
    
    Duration duration() const { return m_duration; }
    void setDuration(Duration newDuration) { m_duration = duration; }
    QBindable<Duration> bindableDuration() { return &m_duration; }
    
    bool isValid() const { return m_isValid; }
    QBindable<bool> bindableIsValid() { return &m_isValid; }
    
private:
    double calculateDiscount() const;
    int basePrice() const;
    
    BindableUser *m_user;
    QProperty<Duration> m_duration { Monthly };
    QProperty<int> m_price { 0 };
    QProperty<bool> m_isValid { false };
};

Instead of the QObject-based Subscription class, we now have BindableSubscription, which wraps its data members inside QProperty classes and doesn't have any signals. The setter for duration is now trivial, and the calculatePrice() and updateValidty() methods for manually triggering the price and validity updates are gone. Instead, the relationships between different properties are specified in BindableSubscription's constructor:

BindableSubscription::BindableSubscription(BindableUser *user) : m_user(user)
{
    m_price.setBinding([this] { return qRound(calculateDiscount() * m_duration * basePrice()); });    
    m_isValid.setBinding([this] {
        return m_user->country() != BindableUser::None && m_user->age() > 12;
    });
}

The User class is also simplified:

class BindableUser
{
public:
    enum Country { None,  Finland, Germany, Norway };
    
    BindableUser() = default;
    BindableUser(const BindableUser &) = delete;
    
    Country country() const { return m_country; }
    void setCountry(Country country) { m_country = country; }
    QBindable<Country> bindableCountry() { return &m_country; }
    
    int age() const { return m_age; }
    void setAge(int age) { m_age = age; }
    QBindable<int> bindableAge() { return &m_age; }
    
private:
    QProperty<Country> m_country { None };
    QProperty<int> m_age { 0 };
};

All we need to do for updating the price in the UI when any of the properties change, is to subscribe to price and validity changes:

auto priceChangeHandler = subscription.bindablePrice().subscribe([&] {
    priceDisplay->setText(QString::number(subscription.price()));
});

auto priceValidHandler = subscription.bindableIsValid().subscribe([&] {
    priceDisplay->setEnabled(subscription.isValid());
});

Notice how much boilerplate code is removed. If more dependencies are added in future, you only need to add the corresponding bindable properties and specify relationships between them by setting the correct bindings. Also note that we don't need to depend on the metaobject system to handle the program logic.

If you find this interesting, you can also check out the source code of the example implemented with both signal-slot connection and bindable properties and share your thoughts in comments.


Blog Topics:

Comments