Search code examples
c++qtgraphicscallback

Stroustrup's Programming Principles and Practice 3rd edition graphics: why passing a callback function to Window::timer_wait gets unexpected results?


Section 14.6 (Simple animation) in Stroustrup's "Programming Principles and Practice using C++" third edition uses his PPP graphics library, which in turn makes use of QT. I am trying to understand what is involved. The following program works as intended: initially it displays two black circles. After 2 seconds the first circle changes color to red. After four more seconds the second circle changes color to blue. Stroustrup writes: "You can also add an action ("callback") as an argument. That action is invoked after the delay". So I experimented by replacing the indicated lines of code with code that does this. I expected the output to be exactly the same, but it is not. At two seconds nothing happens, but at four seconds both circles change color at the same time.

#include "PPP/Simple_window.h"
#include "PPP/Graph.h"

int main(int /*argc*/, char * /*argv*/[])
{
    using namespace Graph_lib;
    Application app;

    // Window initially displays 2 black circles
    Simple_window w {Point{0,0}, 600, 400, "Callback problem"};
    Circle c1{{175, 200}, 100};
    Circle c2{{425, 200}, 100};
    c1.set_fill_color(Color::black);
    c2.set_fill_color(Color::black);
    w.attach(c1);
    w.attach(c2);

    // Code to be replaced:
    w.timer_wait(2000);    //2000 milliseconds
    c1.set_fill_color(Color::red);
    w.timer_wait(4000);
    c2.set_fill_color(Color::blue);
    //////////////////////////////

    // // I expected this code to have the same result:
    // w.timer_wait(2000, [&]{c1.set_fill_color(Color::red);});
    // w.timer_wait(4000, [&]{c2.set_fill_color(Color::blue);});
    // //////////////////////////////////////////////

    w.wait_for_button();
 }

Why is the output not the same? Is there something I can do to use the callback method successfully?

Here is the code for timer_wait:

void WindowPrivate::timer_wait(int milliseconds, std::function<void()> cb)
{
    if (!accept_waits)
        return;
    auto conn = std::make_shared<QMetaObject::Connection>();
    *conn = QObject::connect(&user_timer, &QTimer::timeout,
                     [conn, func = std::move(cb)] {
        QObject::disconnect(*conn);
        func();
    });
    user_timer.start(milliseconds);
}

Solution

  • The issue is caused by the fact that WindowPrivate always uses the same QTimer object; see GUI_Private.h:

        QTimer user_timer{&nested_loop};
    

    When calling multiple timer_wait(timeout, callback) functions sequentially, they actually call the timer's start(msec):

    Starts or restarts the timer with a timeout interval of msec milliseconds. If the timer is already running, it will be stopped and restarted.

    The result is that the second time timer_wait(timeout, callback) is called, the timer is immediately reset and restarted with the new timeout.

    This is obviously not satisfactory for general usage, but it's also understandable due to the simple nature and purpose of the PPP library: it's a basic library intended for educational purposes, therefore it has somehow limited capabilities. I don't know if the implementation in the previous versions of the library (before switching to Qt) had a different result, but maybe it should be worth writing to Stroustrups and let him know about this issue, which should at least be documented, if not fixed in some way.

    In a real program, you would probably use two distinct timers, one for each callback.

    I believe it doesn't make a lot of sense to try and fix the PPP implementation, but you could use the existing Qt capabilities for this case.
    Specifically, you can use the QTimer::singleShot() static functions, which can also be used with lambdas:

    #include <QTimer>
    
    ...
    
        QTimer::singleShot(2000, [&]{c1.set_fill_color(Color::red);});
        QTimer::singleShot(4000, [&]{c2.set_fill_color(Color::blue);});
    

    Be aware of an important fact: the above usage of QTimer expects that whatever is accessed within the functor still exists when it's finally called.

    A more appropriate usage of the singleShot static functions uses the context of the target (a QObject) and automatically disconnects the function whenever it's destroyed.

    Alternatively, an actual QTimer object can be constructed with a parent (which could be the "sender" or the "receiver", depending on the situation), and then connect its timeout with the related function.