Can the Bindable Properties system not handle recursive dependencies cleanly?
-
If one runs:
#include <QCoreApplication> #include <QTimer> #include <QProperty> struct Struct { QString mStr; Struct(QString s = "") : mStr(s) {} bool isEmpty() const { return mStr.isEmpty(); } bool isHello() const { return mStr == "hello"; } }; int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); QProperty<Struct> pStruct; QProperty<bool> pEmpty; QProperty<bool> pHello; qDebug() << "Setting bindings..."; pEmpty.setBinding([&]{ qDebug() << "Eval pEmpty."; auto empty = pStruct.value().isEmpty(); qDebug() << " pEmpty sees isEmpty() as" << empty; return empty; }); pHello.setBinding([&]{ qDebug() << "Eval pHello."; auto empty = pEmpty.value(); qDebug() << " pHello sees pEmpty as" << empty; return empty && pStruct.value().isHello(); }); QTimer t; t.setInterval(2000); QObject::connect(&t, &QTimer::timeout, [&]{ static bool toggle = true; QString s = toggle ? "hello" : ""; qDebug() << "!New pStruct with '" << s << "'"; pStruct = Struct(s); toggle = !toggle; }); qDebug() << "Starting toggle..."; t.start(); app.exec(); }
They will see:
Setting bindings...
Eval pEmpty.
pEmpty sees isEmpty() as true
Eval pHello.
pHello sees pEmpty as true
Starting toggle...
!New pStruct with ' "hello" '
Eval pEmpty.
pEmpty sees isEmpty() as false
Eval pHello.
pHello sees pEmpty as false
!New pStruct with ' "" '
Eval pHello. <-- Out of order evaluation!
pHello sees pEmpty as false <-- Stale value
Eval pEmpty.
pEmpty sees isEmpty() as true
Eval pHello.
pHello sees pEmpty as true
!New pStruct with ' "hello" '
Eval pEmpty.
pEmpty sees isEmpty() as false
Eval pHello.
pHello sees pEmpty as false
!New pStruct with ' "" '
Eval pHello. <-- Out of order evaluation!
pHello sees pEmpty as false <-- Stale value
Eval pEmpty.
pEmpty sees isEmpty() as true
Eval pHello.
pHello sees pEmpty as trueWhich is not what I'd expect. I'd expect the evaluation order to be stable. Any idea why there is the extra, out of order evaluation on every other cycle?
In reality this is just a demo and the actual properties are a poiner, and two bools, the first of which reflects if the pointer is valid, and the second if the pointer object has a method that returns true. This is because I want to share info about the pointed to instances outside of it's containing class, but without having to share access to instance itself. In the real code the program crashes of course because on the cycle where pHello is evaluated first an additional time with a stale state for pEmpty, that member access is on an invalid pointer.
I'd expect the system to be implemented in one of three ways which avoids this.
A) When sStruct is updated, all of it's dependent bindings are immediately marked as stale and then they are evaluated in an implementation defined order. Then, even if pHello is checked first, when it calls
pEmpty.value()
the system knows that pEmpty is stale and so before returning the value it first jumps to evaluate pEmpty to make sure it's up-to-date and then returns the correct value. Then, when the top loop moves to reevaluate pEmpty it sees that it was already done so during dependency recursion and just skips it.B) A dependency graph for the properties, based on the order that other properties are checked during the initial binding creation, is calculated like so:
pEmpty
- pStruct
pHello
- pEmpty
- pStruct
Then, when pStruct changes, all of its dependent bindings are re-evaluated in an implementation defined order. If pHello is picked first, it sees that pHello depends on pEmpty so it needs to see if pEmpty also depends on pStruct, which it does so it skips pHello and keeps going until it finds a property that doesn't have other unresolved dependencies. In this case pEmpty. It evaluates that and then restarts the loop. Now when it gets to pHello it knows pEmpty was handled and that pHello doesn't rely on anything else that's stale so it can safely update pHello. Basically, always update dependents "in order".
C) Bindings that need to be re-evaluated are always called in the order of their creation. This isn't as nice as A or B but allows the programmer to ensure that their bindings are ordered such dependent properties don't evaluate out of order like this as long as they carefully place them.
Of course I could just make sHello's binding
!pStruct.value().isEmpty() && pStruct.isHello.value().isHello()
so that it's independent of pEmpty, but that somewhat defeats the point since bindings should be able to rely on each other, and exponentially increases the complexity of bindings as they rely on more and more conditions that could just be chained through other properties.
Yes the properties also end up having the correct values, but that unnecessary in-between evaluation with a stale value wastes time and causes all kinds of edge cases like here with pointers, or if you wanted to subscribe to one of these properties and perform a task with side-effects (like keeping a count) each time the value is re-evaluated.
AFAICT the docs don't mention anything against doing this. This example does not involve updating multiple variables in a transient state (class invariants), nor are there any evaluation loops, there is just an inter-dependency. I'd like to think I'm doing something wrong, but it seems like this might be an inherent shortcoming of the system as is.
-
Well... if you want it done right do it yourself XD
Obviously, Qt has it's pedigree and Qt Bindable Properties are impressive in their own right, and I would never claim to be better than a team of veteran professionals; but, in this one case this (and a few other) quirk(s) of Qt Bindable Properties ended up bothering me enough that I decided to go nuts and take a stab at them myself. Although my system doesn't have quite the same level of polish as Qt's, I'm very satisfied with how they turned out.
So, if it happens that any one else:
- Doesn't need QML integration (i.e. C++ properties only)
- Doesn't need to access properties abstractly through the meta-object system
and
- Wants their binding evaluations to only ever run once per-update and never read stale values, such that the evaluation of the above example is always:
1) pStruct 2) pEmpty 3) pHello
with almost the same API, feel free to partake in my experiment :)
There's an easier to follow example in the documentation for this above under "Advantages".
-
O oblivioncth has marked this topic as solved