CodeRigor

FREE LESSON

Qt object lifetime: parents, deleteLater and QPointer

Who deletes a QObject and when: parent/child trees, deleteLater and the event loop, QPointer and destroyed, connection lifetime with context objects, thread affinity, and Qt's implicitly shared containers.

Open any Qt codebase and you'll see new everywhere and delete almost nowhere. That isn't sloppiness: Qt has its own ownership model, older than std::unique_ptr, built around three ideas:

  • The object tree. A QObject with a parent is owned by that parent and deleted by it.
  • Deferred deletion. deleteLater() asks the event loop to delete an object once nothing up the call stack can still be using it.
  • Observed lifetimes. QPointer and the destroyed signal tell you when an object you don't own has gone, and connections end automatically when either end dies (if you give them the right context object).

Interviews for Qt roles probe exactly these: "who deletes this?", "why deleteLater and not delete?", "what happens to a lambda connected to a deleted object?", "can I delete this from another thread?". This skill builds on smart-pointers-ownership (owner vs observer, unique_ptr, weak_ptr) and maps each Qt tool onto those relationships. Every run snippet here links Qt 6 Core and runs under ASan and UBSan.

1. The object tree: parents own children

Pass a parent to the constructor (or call setParent) and the parent takes ownership: its destructor deletes every child, recursively. Deleting a child first is fine too: the child removes itself from its parent's list on the way out, so nobody deletes it twice.

C++
#include <QCoreApplication>
#include <QObject>
#include <cstdio>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);

    auto* window = new QObject;            // no parent: we own it
    window->setObjectName("window");
    auto* registry = new QObject(window);  // owned by window
    registry->setObjectName("registry");
    for (const char* name : {"garden", "bicycle"})
        (new QObject(registry))->setObjectName(name);
    auto* timer = new QObject(window);
    timer->setObjectName("timer");

    QList<QObject*> all = window->findChildren<QObject*>();
    all.prepend(window);
    for (QObject* o : all)
        QObject::connect(o, &QObject::destroyed,
                         [](QObject* x) { std::printf("~%s ", qPrintable(x->objectName())); });

    std::printf("registry has %lld children\n", (long long)registry->children().size());
    delete registry->findChild<QObject*>("garden");  // leaves the tree on its own
    std::printf("\nregistry has %lld children\n", (long long)registry->children().size());
    delete window;                                   // deletes everything left
    std::printf("\n");
}
One owner per object: the parent
start / 2
Deleting window deletes the whole tree, depth first, in the order the children were added. A child deleted early unlinks itself from its parent.
One owner per object: the parent
Deleting window deletes the whole tree, depth first, in the order the children were added. A child deleted early unlinks itself from its parent.

The destruction order is worth knowing precisely, because code runs during it. QObject::~QObject does, in order:

What ~QObject does
By the time destroyed is emitted, the derived class's destructor has already run: only the QObject part is left. Connections are cut before the children die.
What ~QObject does
By the time destroyed is emitted, the derived class's destructor has already run: only the QObject part is left. Connections are cut before the children die.

Two practical consequences:

  • A destroyed handler only gets a QObject*. qobject_cast to your derived type fails and calling a derived virtual is wrong: that part is gone. Capture what you need (a name, an id) when you connect.
  • Step 3 before step 4 is what makes a context object safe: an object's connections, including lambdas it is the context of, are cut before its children are deleted. Section 4 builds on this.

Parents and the stack

Objects with a parent should be heap-allocated (new). A stack object with a parent is fine only while it dies before its parent: C++ destroys locals in reverse order of declaration, so the parent must be declared first.

C++
void bad() {
    QObject child;
    QObject parent;
    child.setParent(&parent);
}   // ~parent runs first and `delete`s &child, a stack address: crash

Parents and std::unique_ptr

The tree and unique_ptr are both unique ownership; never let both own the same object. The usual split: a unique_ptr (or a stack object, or a member) for the top of a tree, parents for everything below it. To move an object out of a tree into a unique_ptr, first setParent(nullptr):

C++
std::unique_ptr<QObject> take(QObject* child) {
    child->setParent(nullptr);              // the parent forgets it
    return std::unique_ptr<QObject>(child); // now exactly one owner
}

Skip the setParent(nullptr) and it works until the parent dies first — then the parent deletes the child and the unique_ptr deletes it again. The build task has exactly this trap.

2. Observers: QPointer and destroyed

QPointer<T> is Qt's weak_ptr for QObjects: a non-owning pointer that becomes null when the object is destroyed, however that happens. Unlike weak_ptr there's no lock() — you test it and use it, so it's only safe when the object lives in your thread (another thread could delete it between the test and the use).

C++
#include <QCoreApplication>
#include <QObject>
#include <QPointer>
#include <cstdio>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);

    auto* parent = new QObject;
    auto* scene = new QObject(parent);
    QObject* raw = scene;
    QPointer<QObject> guard = scene;

    QObject::connect(scene, &QObject::destroyed, [&](QObject*) {
        std::printf("in destroyed: guard is %s\n", guard ? "set" : "null");
    });
    delete parent;
    std::printf("after: guard is %s, raw still holds %s\n",
                guard ? "set" : "null", raw ? "an address" : "null");
}

raw still holds the old address — dereferencing it would be a heap-use-after-free. guard is null, already inside the destroyed handler (step 1 above). Use QPointer for:

  • a "current"/"selected" object that someone else owns (the build task's active()),
  • a pointer captured into a callback that may run later,
  • anything you hand out from a lookup and can't promise stays alive.

Use the destroyed signal when you need to do something at the moment an object dies, such as dropping it from an index. It fires for every death: parent destruction, plain delete, deleteLater. An index that only cleans up the deaths it ordered itself will hold dangling pointers.

3. deleteLater and the event loop

delete is immediate. That's a problem when code up the call stack is still using the object: you're inside a slot connected to one of its signals, it has events queued for it, or it lives in another thread. deleteLater() posts a QEvent::DeferredDelete to the object's thread; the event loop deletes the object when control gets back to it, after every frame that might use the object has returned.

C++
#include <QCoreApplication>
#include <QEvent>
#include <QObject>
#include <QPointer>
#include <cstdio>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);

    QPointer<QObject> p = new QObject;
    p->deleteLater();
    p->deleteLater();                       // harmless: still one deferred delete
    std::printf("after deleteLater: %s\n", p ? "alive" : "deleted");
    QCoreApplication::processEvents();      // not inside a loop: deferred deletes wait
    std::printf("after processEvents: %s\n", p ? "alive" : "deleted");
    QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
    std::printf("after sendPostedEvents: %s\n", p ? "alive" : "deleted");
}

The rule behind the middle line: a deferred delete runs when control returns to the event loop that was running when deleteLater was called (or, if none was, once a loop runs). A bare processEvents() at the top level isn't such a return, so in tests you flush them explicitly with QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete) — the build task's tests do. Two more rules from the docs: calling deleteLater after the main loop has exited means the object is never deleted, and objects that are destroyed before their deferred delete arrives (a parent deleted them) simply drop the posted event.

remove() from inside a slot
deleteLater turns 'delete it now' into 'delete it once everybody up the stack is done'. Between the two, the registry reports the scene as pending.
remove() from inside a slot
deleteLater turns 'delete it now' into 'delete it once everybody up the stack is done'. Between the two, the registry reports the scene as pending.

Qt's own emission code survives a delete sender() inside a slot in most cases, but your own classes (and Qt's documented rules) promise nothing: code after an emit routinely touches this. deleteLater is the habit.

4. Connections have lifetimes too

QObject::connect comes in two shapes for lambdas:

C++
connect(sender, &Sender::signal, [this] { use(this); });           // lives as long as sender
connect(sender, &Sender::signal, context, [this] { use(this); });  // ends when either dies

The three-argument form has no idea that the lambda captured this. When this dies first, the next emission runs the lambda on a dead object. The four-argument form registers context as the receiver: when the context is destroyed, step 3 of ~QObject cuts the connection. (The context also chooses the thread the lambda runs in: if it lives in another thread, the call is queued — see qt-background-work.) Rule: a lambda that captures this gets a context whose lifetime covers this — usually this itself, or an object this owns.

C++
#include <QCoreApplication>
#include <QObject>
#include <cstdio>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);

    QObject sender;
    int with_context = 0, without_context = 0;
    auto* receiver = new QObject;
    QObject::connect(&sender, &QObject::objectNameChanged, receiver, [&] { ++with_context; });
    QObject::connect(&sender, &QObject::objectNameChanged, [&] { ++without_context; });

    sender.setObjectName("one");
    delete receiver;                 // disconnects the first connection
    sender.setObjectName("two");
    std::printf("with context: %d, without: %d\n", with_context, without_context);
}

Here the context-less lambda only touches two ints that are still alive. In real code it captured this of something that's gone — a heap-use-after-free that only appears when the destruction order is unlucky.

Other tools for ending a connection:

  • QMetaObject::Connection c = connect(...), later QObject::disconnect(c). Keep the handle when the relationship ends before either object dies (the build task's release()).
  • disconnect(sender, nullptr, receiver, nullptr) cuts everything between two objects.
  • Qt::SingleShotConnection (Qt 6) disconnects after the first call.

Members versus the tree: the order trap

The registry in the build task holds a root QObject and a few containers. Members are destroyed in reverse declaration order, and the root's destructor deletes the scenes, whose destroyed handlers touch the containers:

C++
class SceneRegistry {
    std::unique_ptr<QObject> root_;      // declared first: destroyed last
    std::map<QString, Entry> index_;     // gone by the time root_ dies
    // ...
    // connect(scene, &QObject::destroyed, root_.get(), [this, name] { forget(name); });
};

With root_.get() as the context, ~QObject of the root disconnects the handlers before deleting the scenes, so forget never runs on a destroyed index_. Without it, it does. Both versions compile without a warning.

5. Thread affinity

Every QObject lives in a thread — the one that created it, unless moved — reported by thread(). Its timers, queued slot calls and deferred deletes are delivered by that thread's event loop. Rules that follow:

  • moveToThread(t) must be called from the object's current thread, on an object without a parent; its children move with it.
  • Children must live in the parent's thread: new QObject(parent) from another thread is an error Qt warns about.
  • Delete an object in its own thread. From elsewhere, call deleteLater(): the deferred delete is delivered by the owning thread's loop. For a worker: connect QThread::finished to deleteLater.
C++
#include <QCoreApplication>
#include <QEvent>
#include <QObject>
#include <QPointer>
#include <QThread>
#include <cstdio>

int main() {
    int argc = 0;
    QCoreApplication app(argc, nullptr);

    QThread worker_thread;
    auto* worker = new QObject;                    // no parent: it's going to move
    worker->moveToThread(&worker_thread);
    QObject::connect(&worker_thread, &QThread::finished, worker, &QObject::deleteLater);
    QPointer<QObject> watch = worker;
    worker_thread.start();

    bool ran_elsewhere = false;
    QMetaObject::invokeMethod(worker, [&] {        // queued into worker_thread's loop
        ran_elsewhere = QThread::currentThread() == &worker_thread;
        QMetaObject::invokeMethod(&app, &QCoreApplication::quit, Qt::QueuedConnection);
    }, Qt::QueuedConnection);
    app.exec();

    worker_thread.quit();                          // the loop ends: finished → deleteLater
    worker_thread.wait();                          // the worker was deleted in its thread
    std::printf("ran in worker thread: %d, worker deleted: %d\n", ran_elsewhere, watch.isNull());
}

finished is emitted from the worker thread as it winds down, so deleteLater is called there; a finishing QThread then processes its deferred deletes, so the worker is destroyed in its own thread before wait() returns — no flush needed in main. Background work, queued connections and clean shutdown are the subject of qt-background-work.

Objects live in threads
Each thread's event loop delivers the events of the objects that live in it: timers, queued calls, deferred deletes. Delete from another thread with deleteLater.
Objects live in threads
Each thread's event loop delivers the events of the objects that live in it: timers, queued calls, deferred deletes. Delete from another thread with deleteLater.

6. Why Q_OBJECT needs moc

Custom signals, slots invoked by name, Q_PROPERTY, qobject_cast and QML_ELEMENT all need a class's meta-object: a table of its signals, methods and properties, plus the function bodies of the signals. C++ can't produce that by itself, so Qt's moc (meta-object compiler) reads headers containing Q_OBJECT and generates a moc_*.cpp for each.

C++
class SceneItem : public QObject {
    Q_OBJECT                                   // declares staticMetaObject, metaObject(), qt_metacall…
    Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
public:
    explicit SceneItem(QObject* parent = nullptr) : QObject(parent) {}
    QString name() const { return name_; }
    void setName(const QString& n) {
        if (n == name_) return;
        name_ = n;
        emit nameChanged(n);                   // `emit` is an empty macro: a plain call
    }
signals:
    void nameChanged(const QString& name);     // body generated by moc
private:
    QString name_;
};
CMake
find_package(Qt6 6.5 REQUIRED COMPONENTS Core)
qt_standard_project_setup()          # turns on CMAKE_AUTOMOC (and AUTOUIC)
qt_add_executable(viewer main.cpp sceneitem.h sceneitem.cpp)
target_link_libraries(viewer PRIVATE Qt6::Core)

When moc didn't run on a header (AUTOMOC off, header not listed or not next to its .cpp, Q_OBJECT added without re-running CMake), the link fails with undefined reference to 'vtable for SceneItem' or to SceneItem::nameChanged — the generated code is the missing definition. This course's sandbox has no moc, so graded code here sticks to built-in signals (destroyed, objectNameChanged, QThread::finished…) connected to lambdas, which need no generated code. Custom signals belong in the project.

7. Implicit sharing: Qt containers copy lazily

QString, QByteArray, QList, QHash, QMap, QImage and friends are implicitly shared (copy-on-write): a copy shares the data and bumps an atomic reference count; the first non-const access on a shared instance detaches — copies the data for itself.

C++
#include <QList>
#include <QString>
#include <cstdio>
#include <utility>

int main() {
    QList<int> a{1, 2, 3};
    QList<int> b = a;                                     // O(1): shares the buffer
    std::printf("shared after copy: %d\n", a.constData() == b.constData());
    for (int x : std::as_const(b)) (void)x;               // const access: no detach
    std::printf("shared after const loop: %d\n", a.constData() == b.constData());
    for (int& x : b) (void)x;                             // non-const begin(): detaches
    std::printf("shared after non-const loop: %d\n", a.constData() == b.constData());

    QString s = "splat";
    QString t = s;
    t[0] = 'S';                                           // detach, then write
    std::printf("%s %s\n", qPrintable(s), qPrintable(t));
}
Copy-on-write
A copy is a second handle on the same buffer. The first write through a shared handle copies the buffer, so the other copy never sees the change.
Copy-on-write
A copy is a second handle on the same buffer. The first write through a shared handle copies the buffer, so the other copy never sees the change.

How it differs from std::vector and std::string in practice:

Qt containers std containers
copy O(1), shares deep copy
non-const access on a copy may silently copy everything never copies
pointers/references into it invalidated by a detach, not just by growth invalidated only by reallocation/erase
threads copies may be used in different threads (atomic refcount); one instance is not thread-safe the same rule, without the hidden sharing

Habits: iterate shared Qt containers with std::as_const (or const auto&) to avoid accidental detaches, prefer constFirst()/at() over first()/[] for reads, and don't keep a reference into a container that you then copy and write. Passing const QString& vs QString by value is cheap either way, which is why Qt APIs return containers by value freely. In Qt 6, QList and QVector are the same class.

8. Testing lifetime code

Lifetime bugs are deterministic — the same destruction order crashes every time — so they make good unit tests, provided the test drives the event loop explicitly:

  • One QCoreApplication per process. In a test binary, create it lazily in a function-local static and reuse it.
  • Flush deferred deletes with QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); don't sleep and hope.
  • Hold a QPointer to every object whose death you want to assert: CHECK(p.isNull()).
  • Run under ASan: a use-after-free in a destroyed handler or a double delete is reported with the free stack pointing into ~QObject. (Qt code can't be checked with TSan: the system Qt isn't instrumented. Test thread-safety on Qt-free code.)
  • With QtTest, QSignalSpy spy(obj, &QObject::destroyed) counts emissions; the next skill, qt-models-and-qml, uses it heavily.

In the project

Ungraded practice in projects/scene-viewer/app/ (stage 2); the evidence is your own build and test output.

  1. Draw the viewer's object tree and threads: SceneController, its SceneListModel (a child — why?), the image provider (owned by the QML engine — why does it hold a QPointer?), the two LatestWins workers and the QML window. Add it to docs/ARCHITECTURE.md.
  2. Find every QMetaObject::invokeMethod(this, …, Qt::QueuedConnection) in scene_controller.cpp. Explain what happens to a posted call when the controller has been destroyed, and why the destructor shuts the workers down first.
  3. app/tests/tst_scene_controller.cpp destroys the controller during active work: run it under ASan (cmake -S . -B build/app-asan -DSPLAT_SANITIZER=address -DSPLAT_BUILD_APP=ON), then break the destructor order on purpose and read what ASan reports.
  4. Add a feature that needs ownership care (e.g. closing a scene: remove the row, drop the scene, cancel its render) with a QtTest test.

Summary

  • A parent owns its children and deletes them, depth first; a child deleted early unlinks itself. Never let a parent and a unique_ptr both own an object: setParent(nullptr) to take it out.
  • QPointer observes a QObject and nulls itself on destruction (same thread only); destroyed fires for every death, after the derived part is gone.
  • deleteLater defers deletion to the event loop; flush it in tests with sendPostedEvents(nullptr, QEvent::DeferredDelete).
  • Connect lambdas that capture this with a context object; keep the QMetaObject::Connection when you need to end the relationship early.
  • Objects live in threads; delete them in theirs (deleteLater).
  • Q_OBJECT needs moc (AUTOMOC); missing moc output shows up as an undefined vtable at link time.
  • Qt containers are copy-on-write: cheap copies, hidden detaches.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

What QObject is responsible for, the object tree, and what should and shouldn't be allocated with new. The widget examples apply unchanged to Qt Quick back ends.

QThread, thread affinity, per-thread event loops and cross-thread signals from a Qt core maintainer. Watch the first half for this skill; the rest prepares qt-background-work.