Skip to content
正常

现代Qt开发教程(专家篇)1.21——对象树与所有权源码拆解

咱们写 Qt 代码几乎每天都在依赖对象树:new QLabel("hello", this)——第二个参数 this 是 parent,往后咱们不用管这个 QLabel 的释放,因为它会跟着 this 一起被析构。这套"交给 parent 管生命周期"的机制乍看像黑魔法:写完 new 就不管了,代码居然不漏内存。这套机制是 QObject 设计的核心,但它背后有一堆值得追问的问题:setParent 到底改了什么?为什么析构 parent 会自动 delete 所有 children,而且不会出乱子?为什么 moveToThread 对一个有 parent 的对象会直接拒绝?deleteLaterdelete 到底差在哪,为什么 Qt 要专门发明它?

这些问题,靠"parent 管理子对象生命周期"这种概括是答不全的,咱们要把所有权机制从源码里挖出来。这一篇把散落在前面几篇里的线索拼起来——01 篇 咱们见过 children 字段存在 QObjectData 基类里、children() 内联返回它;02 篇 咱们见过 ~QObject 的两类连接清理;07 篇 咱们追过 DeferredDelete 事件的全链路。入门篇的 QObject 与元对象系统 讲过对象树的基本用法,那是知其然,本篇是知其所以然。本篇的边界:拆 setParent / 析构级联 deleteChildren / deleteLater / moveToThread 的线程亲和约束,以及 QWidget 走的 willBeWidget 快速分支;QWidget 特有的窗口系统部分(嵌入父 widget 的窗口、平台资源清理)不展开,只到 willBeWidget 这个分支点为止。

源码引用基于 qt_src/qt6.9.1,行号会随版本漂移,对不上时用函数名定位。本篇涉及的源码文件:

文件角色
qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cppsetParent / setParent_helper / deleteChildren / ~QObject / deleteLater / moveToThread / thread / protected 构造
qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.hsendChildEvents / receiveChildEvents 位域(QObjectData 基类)
qt_src/qt6.9.1/qtbase/src/corelib/kernel/qcoreapplication.cppDeferredDelete 时机判定(与 07 篇对接)
qt_src/qt6.9.1/qtbase/src/widgets/kernel/qwidget.cppQWidgetPrivate 构造置 willBeWidget

本篇无配套 example,原因:对象树所有权是 QObject 析构期的内部机制,对照 qt_src 翻源码就是最好的实验。所有权机制围绕"建立父子关系"和"析构级联"两条主线展开,中间穿插 deleteLater 的延迟删除和 moveToThread 的同线程约束,全图如下:

咱们从"建立父子关系"开始。

setParent 把活儿全转给 setParent_helper:三段式与双标志门控

QObject::setParent 这个公开方法本身几乎不干活,它把活儿全转手给了私有层的 setParent_helper

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:2206-2211

C++
void QObject::setParent(QObject *parent)
{
    Q_D(QObject);
    Q_ASSERT(!d->isWidget);
    d->setParent_helper(parent);
}

注意那个 Q_ASSERT(!d->isWidget)——setParent 公开方法禁止 widget 走。widget 的父子关系有专门的快速路径(下文 willBeWidget 分支),不走这里。普通 QObject 才进 setParent_helper

setParent_helper 是对象树所有权变更的核心,干的是经典的"三段式":

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:2230-2295

展开代码收起代码31 行
C++
void QObjectPrivate::setParent_helper(QObject *o)
{
    Q_Q(QObject);
    if (o == parent)
        return;

    if (parent) {
        QObjectPrivate *parentD = parent->d_func();
        // ...(省略 isDeletingChildren / wasDeleted 防护分支)
        } else {
            parentD->children.removeAt(index);
            if (sendChildEvents && parentD->receiveChildEvents) {
                QChildEvent e(QEvent::ChildRemoved, q);
                QCoreApplication::sendEvent(parent, &e);
            }
        }
    }
    // ...
    parent = o;

    if (parent) {
        // ...
        parent->d_func()->children.append(q);
        if (sendChildEvents && parent->d_func()->receiveChildEvents) {
            if (!isWidget) {
                QChildEvent e(QEvent::ChildAdded, q);
                QCoreApplication::sendEvent(parent, &e);
            }
        }
    }
}

三段:第一段,如果旧 parent 存在,从旧 parent 的 children 列表 removeAt 摘掉自己,然后在"双标志门控"通过时给旧 parent 发一个 ChildRemoved 事件。第二段,parent = o 改裸指针——这一步是所有权变更的落点。第三段,如果新 parent 存在,把自己 append 进新 parent 的 children 列表,同样在双标志门控 + 非 widget 时给新 parent 发 ChildAdded 事件。

这里要特别讲那个"双标志门控"——sendChildEvents && parentD->receiveChildEvents。这两个标志是 1-bit 位域,存在 QObjectData 基类:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.h:80-81

C++
    uint sendChildEvents : 1;
    uint receiveChildEvents : 1;

sendChildEvents 控制"我作为子对象,被加进/移出 parent 时,要不要给 parent 发事件";receiveChildEvents 控制"我作为 parent,要不要接收 ChildAdded/ChildRemoved 事件"。两边都要开,事件才会发。默认两个都置 true(QObjectPrivate 构造时),所以普通对象都能收到 child 事件。但某些内部对象会关掉它们(比如 QAbstractEventDispatcher 这类不需要被通知 child 变化的),避免无意义的事件开销。这个双标志门控是对象树"事件通知"和"数据维护"分离的设计——children 列表的增删无论如何都会做,事件发不发是另一回事,由标志位控制。

~QObject 先删子、再摘己:析构顺序不是随便定的

理解了父子关系怎么建立,析构时就是反过来——而且顺序很讲究。~QObject 做两件事,顺序固定:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:1139-1148

C++
    if (!d->children.isEmpty())
        d->deleteChildren();

    if (Q_UNLIKELY(qtHookData[QHooks::RemoveQObject]))
        reinterpret_cast<QHooks::RemoveQObjectCallback>(qtHookData[QHooks::RemoveQObject])(this);

    Q_TRACE(QObject_dtor, this);

    if (d->parent)        // remove it from parent object
        d->setParent_helper(nullptr);

deleteChildren() 删所有子对象,再 setParent_helper(nullptr) 把自己从父对象的 children 列表摘掉。这个顺序乍看不对劲:直觉会想"先把自己从父摘掉,再处理孩子"更干净。但它不是随便定的。子先于父销毁,保证了一个对象析构时,它的整棵子树都已经清理干净,不会留下"父死了子还在"的孤儿。而把自己从父摘掉放在最后,是因为此时自己还在收尾,不能过早断开和父的关系(deleteChildren 过程中可能还需要通过 parent 访问一些东西)。

注意 ~QObject 还做了 02 篇讲过的两类连接清理(那段在 deleteChildren 之前)——析构一个对象,顺序大致是:清连接(自己作为 sender 和 receiver 的所有连接)→ 删子对象 → 从父摘己。整个析构把对象在对象树、连接表、信号槽系统里的所有痕迹都抹干净。

这套流程看着万无一失,恰恰容易让人大意:对象树管理和手动 delete 混用,是新手在这一节最容易踩的坑。一个子对象既被 parent 纳入对象树(new Child(parent)child->setParent(parent)),您又在某处手动 delete child。表面看没事——delete child 触发 ~Child~QObject 走上面那套流程,其中 setParent_helper(nullptr) 会把自己从 parent 的 children 列表摘掉;摘掉之后,parent 析构时 deleteChildren 就不会再来 delete 它。但问题出在时序:如果手动 delete child 和 parent 析构并发,或者 child 的析构过程中 parent 也在析构(下文 deleteChildren 的重入场景),摘除和 deleteChildren 的顺序就可能错乱,parent 的 children 列表里残留已 delete 的指针,最终 double-free。更常见的情况是删了 child 就忘了它还被 parent 管着,parent 后来析构时又 delete 一次。解法很干脆:对象一旦交给对象树(设了 parent),就不要再手动 delete:要么完全交给对象树管,要么 setParent(nullptr) 脱离树再手动管理,两者混用迟早出问题;需要"稍后删除"就用 deleteLater(下文专门一节),它和对象树是兼容的。

deleteChildren 为什么故意不用 qDeleteAll:手写循环防 double-free

deleteChildren 这个函数有个反直觉的细节:它故意不用 qDeleteAll,而是手写一个索引 for 循环。原因写在注释里:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:2213-2228

C++
void QObjectPrivate::deleteChildren()
{
    Q_ASSERT_X(!isDeletingChildren, "QObjectPrivate::deleteChildren()", "isDeletingChildren already set, did this function recurse?");
    isDeletingChildren = true;
    // delete children objects
    // don't use qDeleteAll as the destructor of the child might
    // delete siblings
    for (int i = 0; i < children.size(); ++i) {
        currentChildBeingDeleted = children.at(i);
        children[i] = nullptr;
        delete currentChildBeingDeleted;
    }
    children.clear();
    currentChildBeingDeleted = nullptr;
    isDeletingChildren = false;
}

注释逐字写着"don't use qDeleteAll as the destructor of the child might delete siblings"——子的析构函数可能会删掉它的兄弟。读到这句注释难免愣一下:析构函数还能动兄弟?听起来奇怪,但在 Qt 里很常见:假设子 A 的析构函数里有 siblingB->deleteLater() 或者直接操作了兄弟对象。如果用 qDeleteAll(它内部是 for (auto &x : list) delete x),A 的析构可能触发兄弟 B 被删,而 B 也在同一个 list 里,轮到遍历到 B 时就是 double-free。

Qt 的对策是手写索引循环,而且循环体有个关键设计——先把 children[i] 置 nullptr,再 delete

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:2213-2228

C++
    for (int i = 0; i < children.size(); ++i) {
        currentChildBeingDeleted = children.at(i);
        children[i] = nullptr;
        delete currentChildBeingDeleted;
    }

先把这一项置 nullptr 再 delete。为什么?因为 delete currentChildBeingDeleted 会触发子对象的析构,而子对象的析构会走上文那套流程——子会调 setParent_helper(nullptr) 想把自己从父(也就是当前正在 deleteChildren 的这个对象)的 children 列表摘掉。如果摘除逻辑简单地 removeAt(index),就会在循环遍历的 list 上动手脚,搞乱索引。先把该项置 nullptr,摘除逻辑(setParent_helper 里检查到这个 child 已经是 nullptr 或通过 currentChildBeingDeleted 判断)就能识别"这个子正在被父删,跳过",安全地继续循环。这是个相当精巧的"析构期重入保护",与 02 篇信号槽的 highestConnectionId 重入保护异曲同工。开头还有个 isDeletingChildren 标志 + Q_ASSERT_X 防递归:deleteChildren 不该被递归调用(析构已经设了标志,子析构不会再次进入父的 deleteChildren)。这些都是为了应对"析构期间对象树被反向修改"的复杂情况。

那条注释同时也是个坑的路标:析构期间访问兄弟对象。parent 析构,deleteChildren 正在逐个 delete 子对象,子 A 的析构函数里咱们写了 if (siblingB) siblingB->doSomething()——但 siblingB 可能已经被 deleteChildren 在 A 之前 delete 了(B 在 children 列表里排在 A 前面),此时 siblingB 是悬垂指针,访问就是 use-after-free。后果是看似随机的崩溃,而且析构顺序取决于 children 列表顺序(取决于 setParent 调用顺序),问题时有时无,极难复现。解法:对象的析构函数里绝对不要去访问兄弟对象:析构时只能确定自己正在销毁,兄弟可能已经没了;确实需要和兄弟交互的,用 QPointer<QObject> 持有兄弟(它能安全检测对方是否已析构),或者重新设计让对象树关系别耦合得那么紧。

deleteLater:把"删除自己"推迟到安全时机

07 篇 咱们从"事件循环派发端"追过 DeferredDelete 全链路,本篇从"对象端"看 deleteLater 怎么投递这个事件。deleteLater 本质是把"删除自己"这件事推迟到事件循环的某个安全时机:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:2446-2500

C++
void QObject::deleteLater()
{
    // ...
    Q_D(QObject);
    if (d->deleteLaterCalled)
        return;

    d->deleteLaterCalled = true;
    // ...
    auto *objectThreadData = eventListLocker.threadData;
    // ...
    QCoreApplication::postEvent(this,
        new QDeferredDeleteEvent(loopLevel, scopeLevel));
}

开头是去重:if (d->deleteLaterCalled) return,之前已经调过 deleteLater(标志已置位)就直接返回,不会重复投递,所以咱们可以在不确定的地方多次调它,只生效一次。接着把删除请求投出去:postEvent 一个 QDeferredDeleteEvent,投递到 this 自己的亲和线程(objectThreadData),事件带着当前的 loopLevel/scopeLevel(这两个层级用于 07 篇讲过的时机判定)。

为什么需要 deleteLater 而不是直接 delete?因为很多时候咱们是在"自己正被使用"的上下文里想销毁自己——比如在自己的槽函数里、在事件处理器里、在被别人遍历的列表里。直接 delete this 会让当前栈帧持有的 this 立刻失效,回到调用方就是 use-after-free。deleteLater 把删除推迟到"当前事件处理完、回到事件循环"那一刻,那时谁都不再持有当前栈帧,delete this(07 篇的 QObject::event case DeferredDelete)才安全。这就是 DeferredDelete 三种时机判定(投递它的 event loop 已返回 / 当前 loop 显式请求 / 投递早于最外层 loop)要解决的问题,07 篇详细讲过,这里不重复。

一棵对象树必须同线程:moveToThread 的有父拒绝

对象树有一条必须遵守的规则:一棵树必须同线程。源头在 moveToThread

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:1655-1698

C++
    if (d->parent != nullptr) {
        qWarning("QObject::moveToThread: Cannot move objects with a parent");
        return false;
    }
    if (d->isWidget) {
        qWarning("QObject::moveToThread: Widgets cannot be moved to a new thread");
        return false;
    }

moveToThread 一进来就拦两种情况:有 parent 直接拒绝(Cannot move objects with a parent),是 widget 直接拒绝(widget 不能搬线程)。有 parent 的对象不能搬线程——为什么?因为对象树的所有权依赖 parent 管理,如果子搬到别的线程,parent 析构时(在自己线程)要去 delete 别的线程的对象,这是跨线程析构,不安全。

这个坑的阴险处在于失败是静默的:程序只给一句 warning,很多人不开 warning 输出,于是您以为对象搬到了 worker 线程,实际它还在原线程——信号槽跨线程分流没按预期、事件投递到错的线程,全乱套,而且排查时很少有人的第一反应是"对象压根没搬成"。搬线程前确认对象没 parent,别指望那行 warning 会提醒您。要搬,要么 setParent(nullptr) 让它脱离父(变成独立树根)再 moveToThread,要么直接搬整棵子树的根(根搬了,子树跟着搬,树内线程关系一致);widget 则根本不能搬线程(isWidget 也会被拒)。

线程亲和本身的查询很轻量:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:1610-1613

C++
QThread *QObject::thread() const
{
    return d_func()->threadData.loadRelaxed()->thread.loadAcquire();
}

thread() 从私有数据的 threadData 原子读出所属 QThread 指针。这个 threadData 是 02 篇 doActivate 里判断 receiverInSameThread 时用的同一个东西——信号槽的跨线程分流、事件投递的目标线程、moveToThread 的约束,全部依赖这个线程亲和字段。对象树同线程这条硬约束,本质是为了让这套基于线程亲和的机制在对象树上保持一致:如果父子不同线程,一个信号发出去,到底按谁的线程分流?

QWidget 的快速分支:willBeWidget 跨模块协作

最后讲一个优化细节。上文 setParent 有个 Q_ASSERT(!d->isWidget)——widget 不走普通 setParent。那 widget 的父子关系怎么建立?通过一个 willBeWidget 快速分支,在 QObject 的 protected 构造里:

qt_src/qt6.9.1/qtbase/src/corelib/kernel/qobject.cpp:960-968

C++
            if (d->willBeWidget) {
                if (parent) {
                    d->parent = parent;
                    d->parent->d_func()->children.append(this);
                }
                // no events sent here, this is done at the end of the QWidget constructor
            } else {
                setParent(parent);

如果 willBeWidget 标志为真(这个对象将是一个 widget),构造时直接裸写 d->parent = parent + append 进 parent 的 children 列表,完全绕开 setParent_helper——不发 ChildAdded 事件,不走双标志门控。注释说"no events sent here, this is done at the end of the QWidget constructor",事件推迟到 QWidget 构造末尾再发。

为什么要另起炉灶?QObject 已经有完整的 setParent_helper 三段式了。原因在于 QWidget 的构造非常频繁(界面里控件动辄成百上千),而 widget 在构造阶段还没完全建好,发 ChildAdded 事件也没意义(parent 自己都还在构造)。走快速分支省掉了事件投递的开销和未就绪对象处理事件的风险。

willBeWidget 这个标志是跨模块置位的——它在 QtWidgets 模块里置位,给 QtCore 的 QObject 构造读:

qt_src/qt6.9.1/qtbase/src/widgets/kernel/qwidget.cpp:182

C++
    willBeWidget = true; // used in QObject's ctor

QWidgetPrivate 的构造体里直接 willBeWidget = true。于是当 QWidget 构造走到 QObject 的 protected 构造时,willBeWidget 已经是真,走快速分支。这是一个"上层模块(Widgets)反向告诉下层模块(Core)走特殊路径"的跨模块协作——willBeWidget 这个标志名本身就是给 QObject 构造看的(注释"used in QObject's ctor"直说了)。

官方文档参考

Qt 文档 · QObject -- setParent / deleteLater / moveToThread / thread 的官方文档

Qt 文档 · Object Trees & Ownership -- 对象树与所有权机制总览,parent/child 关系与析构级联


对象树是 Qt 管理生命周期的地基:谁负责删谁、能不能搬线程、析构时为什么不能碰兄弟,这三类判断本篇都给了源码层面的依据。本篇全部行号证据按机制归类收在 code-index · qtbase,带着行号去 qt_src/qt6.9.1 翻原文就能核对。QtBase 卷还剩 18(信号槽深水区)与 20 两篇撰写中;要横向换方向的,02-qtgui 卷 是绘图的源码拆解,用法面需要补课的回入门篇 1.1 QObject 与元对象系统

AwesomeQt v0.3.0-13-g31f4d47 · 31f4d47 · 2026-09-22 · CHECKED-BY: CI