首页 Qt 学习之路 2 Qt 学习之路 2(10):对象模型

Qt 学习之路 2(10):对象模型

50 6

标准 C++ 对象模型在运行时效率方面卓有成效,但是在某些特定问题域下的静态特性就显得捉襟见肘。GUI 界面需要同时具有运行时的效率以及更高级别的灵活性。为了解决这一问题,Qt “扩展”了标准 C++。所谓“扩展”,实际是在使用标准 C++ 编译器编译 Qt 源程序之前,Qt 先使用一个叫做 moc(Meta Object Compiler,元对象编译器)的工具,先对 Qt 源代码进行一次预处理(注意,这个预处理与标准 C++ 的预处理有所不同。Qt 的 moc 预处理发生在标准 C++ 预处理器工作之前,并且 Qt 的 moc 预处理不是递归的。),生成标准 C++ 源代码,然后再使用标准 C++ 编译器进行编译。如果你曾经为信号函数这样的语法感到奇怪(现在我们已经编译过一些 Qt 程序,你应当注意到了,信号函数是不需要编写实现代码的,那怎么可以通过标准 C++ 的编译呢?),这其实就是 moc 进行了处理之后的效果。

Qt 使用 moc,为标准 C++ 增加了一些特性:

  • 信号槽机制,用于解决对象之间的通讯,这个我们已经了解过了,可以认为是 Qt 最明显的特性之一;
  • 可查询,并且可设计的对象属性;
  • 强大的事件机制以及事件过滤器;
  • 基于上下文的字符串翻译机制(国际化),也就是 tr() 函数,我们简单地介绍过;
  • 复杂的定时器实现,用于在事件驱动的 GUI 中嵌入能够精确控制的任务集成;
  • 层次化的可查询的对象树,提供一种自然的方式管理对象关系。
  • 智能指针(QPointer),在对象析构之后自动设为 0,防止野指针;
  • 能够跨越库边界的动态转换机制。

通过继承QObject类,我们可以很方便地获得这些特性。当然,这些特性都是由 moc 帮助我们实现的。moc 其实实现的是一个叫做元对象系统(meta-object system)的机制。正如上面所说,这是一个标准 C++ 的扩展,使得标准 C++ 更适合于进行 GUI 编程。虽然利用模板可以达到类似的效果,但是 Qt 没有选择使用模板。按照 Qt 官方的说法,模板虽然是内置语言特性,但是其语法实在是复杂,并且由于 GUI 是动态的,利用静态的模板机制有时候很难处理。而自己使用 moc 生成代码更为灵活,虽然效率有些降低(一个信号槽的调用大约相当于四个模板函数调用),不过在现代计算机上,这点性能损耗实在是可以忽略。

在本节中,我们将主要介绍 Qt 的对象树。还记得我们前面在MainWindow的例子中看到了 parent 指针吗?现在我们就来解释这个 parent 到底是干什么的。

QObject是以对象树的形式组织起来的。当你创建一个QObject对象时,会看到QObject的构造函数接收一个QObject指针作为参数,这个参数就是 parent,也就是父对象指针。这相当于,在创建QObject对象时,可以提供一个其父对象,我们创建的这个QObject对象会自动添加到其父对象的children()列表。当父对象析构的时候,这个列表中的所有对象也会被析构。(注意,这里的父对象并不是继承意义上的父类!)这种机制在 GUI 程序设计中相当有用。例如,一个按钮有一个QShortcut(快捷键)对象作为其子对象。当我们删除按钮的时候,这个快捷键理应被删除。这是合理的。

QWidget是能够在屏幕上显示的一切组件的父类。QWidget继承自QObject,因此也继承了这种对象树关系。一个孩子自动地成为父组件的一个子组件。因此,它会显示在父组件的坐标系统中,被父组件的边界剪裁。例如,当用户关闭一个对话框的时候,应用程序将其删除,那么,我们希望属于这个对话框的按钮、图标等应该一起被删除。事实就是如此,因为这些都是对话框的子组件。

当然,我们也可以自己删除子对象,它们会自动从其父对象列表中删除。比如,当我们删除了一个工具栏时,其所在的主窗口会自动将该工具栏从其子对象列表中删除,并且自动调整屏幕显示。

我们可以使用QObject::dumpObjectTree()QObject::dumpObjectInfo()这两个函数进行这方面的调试。

Qt 引入对象树的概念,在一定程度上解决了内存问题。

当一个QObject对象在堆上创建的时候,Qt 会同时为其创建一个对象树。不过,对象树中对象的顺序是没有定义的。这意味着,销毁这些对象的顺序也是未定义的。Qt 保证的是,任何对象树中的 QObject对象 delete 的时候,如果这个对象有 parent,则自动将其从 parent 的children()列表中删除;如果有孩子,则自动 delete 每一个孩子。Qt 保证没有QObject会被 delete 两次,这是由析构顺序决定的。

如果QObject在栈上创建,Qt 保持同样的行为。正常情况下,这也不会发生什么问题。来看下下面的代码片段:

{
    QWidget window;
    QPushButton quit("Quit", &window);
}

作为父组件的 window 和作为子组件的 quit 都是QObject的子类(事实上,它们都是QWidget的子类,而QWidgetQObject的子类)。这段代码是正确的,quit 的析构函数不会被调用两次,因为标准 C++ (ISO/IEC 14882:2003)要求,局部对象的析构顺序应该按照其创建顺序的相反过程。因此,这段代码在超出作用域时,会先调用 quit 的析构函数,将其从父对象 window 的子对象列表中删除,然后才会再调用 window 的析构函数。

但是,如果我们使用下面的代码:

{
    QPushButton quit("Quit");
    QWidget window;

    quit.setParent(&window);
}

情况又有所不同,析构顺序就有了问题。我们看到,在上面的代码中,作为父对象的 window 会首先被析构,因为它是最后一个创建的对象。在析构过程中,它会调用子对象列表中每一个对象的析构函数,也就是说, quit 此时就被析构了。然后,代码继续执行,在 window 析构之后,quit 也会被析构,因为 quit 也是一个局部变量,在超出作用域的时候当然也需要析构。但是,这时候已经是第二次调用 quit 的析构函数了,C++ 不允许调用两次析构函数,因此,程序崩溃了。

由此我们看到,Qt 的对象树机制虽然帮助我们在一定程度上解决了内存问题,但是也引入了一些值得注意的事情。这些细节在今后的开发过程中很可能时不时跳出来烦扰一下,所以,我们最好从开始就养成良好习惯,在 Qt 中,尽量在构造的时候就指定 parent 对象,并且大胆在堆上创建。

50 评论

lbn23 2012年11月4日 - 00:03

只能指针(QPointer),在对象析构之后自动设为 0,放置野指针;
应该是智能指针。

回复
DevBean 2012年11月5日 - 10:33

多谢指出,已经修改!

回复
esdf 2012年11月14日 - 02:43

原来qt是这样析构的,这真不智能呀,受教了...

回复
斯啦丝拉 2013年1月9日 - 08:51

博主你说“在 Qt 中,尽量在构造的时候就指定 parent 对象,并且大胆在堆上创建。”,可是您又在《hello world》中拒了一个在堆上创建QLabel的反例。求解惑。

回复
豆子 2013年1月9日 - 14:10

在 main() 函数中,不应该在堆上面创建对象。这是由于如果在 main() 中在堆上面创建对象,app.exec() 函数是一个死循环,创建出的这个对象没有办法被 delete(不开启事件循环,组件就不能显示,不显示组件就不能 delete,否则你还创建它干什么呢?)。另外的原因是,由于我们的 QApplication 是在栈上面创建的,在堆上面创建的 QLabel 对象生命周期要长于 QApplication,这在 Qt 中是应该避免的。而对于我们自己定义的组件就没有这个问题,因为不在 main() 函数中,我们始终可以保证最晚在关闭时销毁(当然是不发生内存泄露的情况下),也就没有这个问题。

回复
xie 2015年5月15日 - 10:37

豆哥,你好;
你说的这句话我不是很理解“另外的原因是,由于我们的 QApplication 是在栈上面创建的,在堆上面创建的 QLabel 对象生命周期要长于 QApplication,这在 Qt 中是应该避免的。而对于我们自己定义的组件就没有这个问题,因为不在 main() 函数中,我们始终可以保证最晚在关闭时销毁(当然是不发生内存泄露的情况下),也就没有这个问题。”
为什么我们自己定义的组件不在 main() 函数中,可以解释一下吗?
谢谢!

回复
豆子 2015年6月3日 - 09:51

我们自己定义的组件不在 main() 函数直接被调用,而是通过 MainWindow 之类的顶层窗口调用,而 MainWindow 通常直接被 main() 实例化。因此,只要保证了最顶层的 MainWindow 能够正确释放,并且 parent 能够连接成链,就可以保证每一个组件被正确释放。

回复
smile 2013年1月10日 - 14:56

有个问题需要请教豆子,假如一个继承自QObject的堆对象添加一个继承自QObject的栈对象,会有问题吗?如下代码:
QStatusBar* status=window.statusBar();
QLabel lab("status");
status->addWidget(&lab);
QLabel先于QStatusBar释放,会不会出现2次delete?还是您在此文中说的QLabel释放的时候会将自己从QStatusBar的子对象集合中删除,从而避免2次delete?

回复
豆子 2013年1月10日 - 22:44

不会有问题的,子组件在释放的时候会自动从父组件的子对象集合中删除。否则的话,应用程序中所有弹出的对话框岂不是都不能释放空间了吗?

回复
路过 2013年3月27日 - 12:29

我问个比较白的问题,你说在堆、栈上面创建对象,我怎么控制?我怎么知道它是创建到哪里的?

回复
豆子 2013年3月27日 - 12:45

使用 new 创建的对象全部是在堆上面创建的,不使用 new 的都是在栈上创建的。这是标准 C++ 规定的。

回复
路过 2013年3月27日 - 22:57

o.o,我看了C++的语法都没看见这个。

回复
豆子 2013年3月28日 - 09:02

一般应该有的,不过的确就是这么回事,嘿嘿

回复
大风 2015年10月17日 - 16:57

不使用new的对象的private数据成员一定是栈上创建的,但不能保证这个对象的构造函数里没有new操作。所以不使用new的不是说一定在栈上创建,应该说编译器会帮我们管理内存。

回复
hailong 2013年5月9日 - 12:54

最后那个例子的避免方法就是在堆上创建。

回复
梓涵 2013年6月11日 - 11:43

很可能是不是跳出来烦扰一下
应该是时不时吧,虽然不是程序方面的问题,但如果以后做pdf文档的话修正一下比较好

回复
豆子 2013年6月14日 - 11:14

什么跳出来了啊?

回复
梓涵 2013年6月11日 - 11:46

抱歉,没注意到我的邮箱写错了

回复
爱在梦幻谷 2013年7月25日 - 19:41

这些细节在今后的开发过程中很可能 是不是 跳出来烦扰一下===修改成:这些细节在今后的开发过程中很可能 时不时 跳出来烦扰一下

回复
豆子 2013年7月26日 - 07:56

感谢指出,已经修改过了

回复
DKWings 2013年7月25日 - 21:26

第一段“现在智能指针我们已经编译过一些 Qt 程序”一句,语义无法理解……“智能指针”是否为笔误?//顺带对辛苦奉献表达衷心的感谢! 🙂

回复
豆子 2013年7月26日 - 07:57

多谢指出!的确应该是笔误

回复
sz 2013年7月28日 - 18:12

“放置野指针” 应该是 “防止野指针” 吧

回复
豆子 2013年7月31日 - 15:37

已经修改过了,感谢指出!

回复
Ninja 2014年1月10日 - 10:27

请问豆子,文章最后那个例子,如果都在堆上创建,是否也会delete两次?

回复
Ninja 2014年1月10日 - 10:28

啊。。我傻了。new 出来的不会自己释放

回复
QCloudy 2014年3月9日 - 13:29

但是如果new的时候指定父对象,就不需要自己手动释放了

回复
水调歌头 2014年3月4日 - 17:56

谁分配谁释放,Qt这种释放方式太暴力了。

回复
不如归去 2014年5月4日 - 16:18

这里你理解有错误,本来只有动态创建的对象才应该设置父对象,栈上创建的对象不应该设置父对象

回复
cycloneii 2014年5月4日 - 16:29

我觉得你对Qt对象模型理解有点偏差,父子对象是为了简化动态分配的对象的管理,本来就应该是只有动态创建的对象才可以设置父对象,栈上创建的对象本来就不应该设置父对象

回复
豆子 2014年5月5日 - 15:46

这个不知道算不算偏差。毕竟这样写还是正确的,既然提供了 setParent() 函数,也就可以设置栈上对象的父对象。这一点无论从语法还是语义来说都是正常的。这段代码也并不是鼓励这样使用,只是提醒一下可能出现的问题。毕竟文档中也有这样的例子。

回复
GoodJob 2014年7月26日 - 15:52

首先非常感谢博主的辛勤劳动,为我等qt初学者提供了非常棒的学习教程~

关于父对象我有个问题请问豆子:
如果有一个子对象在堆上创建,并在创建的时候设置父对象为A,然后这个子对象又手动调用setParent()设置父对象为B
1. 这样合不合法,子对象在A对象中会被取消吗?
2. 如果A,B两个对象能共用一个子对象,那么在A对象销毁的时候,是否也把这个子对象销毁了呢? 这样B再访问这个子对象的时候,程序不就崩溃了嘛。

回复
豆子 2014年7月28日 - 22:13

setParent() 函数会按照预想的方式进行:将对象从原来的父对象中移除。所以并不是发生你所担心的第二个问题。

回复
d 2014年11月24日 - 02:30

看上去原则就是按对象树的顺序从上层到下层依次创建,先容器后内容

回复
rrr 2015年5月5日 - 07:39

比如说我在头文件里定义了一个类
class PaintWidget:public QWidget
{
public:
PaintWidget(QWidget*parent=0)
}
然后在cpp中写
PaintWidget::PaintWidget(QWidget*parent):QWidget(parent)

PaintWidget这个派生类把parent指针给了基类QWidget了 他是怎么通过基类来指定父对象的? 这样的话不就成了指定了基类的父对象了吗?

回复
豆子 2015年6月3日 - 11:10

这里是调用了父类的构造函数,parent 属性是由父类定义的,可以认为是通过继承作为自己的属性。

回复
lyfljw 2015年7月23日 - 14:22

其实可不可以理解成:
每次构建时,先定义父级,再定义子级,这样即使在构建子级的时候不传递parent,再后面使用setParent也没有问题。
比如
QWidget window;
QPushButton quit("Quit");
quit.setParent(&window);
这种情况就可以了

回复
豆子 2015年8月1日 - 14:28

这样写也是可以的,只不过有点麻烦,而且很容易忘记

回复
dreamychi 2015年11月11日 - 14:43

特意跑了下博主的代码,果然会崩。后来想下这种情况基本不会出现在代码中,也难怪之前自己都没有发现这个可以崩溃。作为UI控件在程序段中用栈分配,我想了半天还真没有想到实际的应用。不过博主的例子还是发人深省的,赞一个

回复
善良超哥哥 2015年12月2日 - 11:08

如果是函数内的局部对象就在栈上创建,没必要添加一个父类来依靠Qt的内存管理机制。
如果是在堆上创建的对象,就依靠Qt的管理机制,给其添加一个父类对象。这样是不是比较OK?

回复
豆子 2015年12月6日 - 19:47

一般是这样子的。

回复
Jack 2016年2月12日 - 22:24

对象的析构讲的很到位,感谢豆子的分享

回复
小怪 2016年4月10日 - 13:07

博主 给窗口添加部件是不是就代表隐藏制定部件的父类是窗口 比如:
mainwindow->addDockWidget(Qt::LeftDockWidgetArea,dock);//主窗口添加浮动窗口
是不是就代表dock部件父类是主窗口 还是说我必须在new时候制定:
QDockWidget *dock=new QDockWidget(QObject::tr("File Browser"),mainwindow);

还有一个问题就是在布局套布局情况下 子布局的父类可以是布局吗 如果两个布局的父类都指向同一个部件时候 系统就会提示部件已经存在布局:
QLayout: Attempting to add QLayout "" to QDockWidget "", which already has a layout

博主好人 求解答!!

回复
豆子 2016年4月11日 - 21:58

对于第一个问题,有些函数是会自动设置 parent 指针,有些则不会,这需要仔细阅读文档。例如,addDockWidget() 就不会,而 addLayout() 函数说明中有这么一句:layout becomes a child of the grid layout,因此就会自动设置 parent 指针。为避免这一情况,我们还是在创建时显式指定,这样无论 Qt 会不会修改 parent 指针,都能保证自动销毁。

对于第二个问题,目前只有 QBoxLayout 和 QGridLayout 有 addLayout() 函数。一个组件只能设置一个布局,如果需要类似的嵌套,可以尝试使用组件的嵌套替代。

回复
wlz 2016年7月1日 - 14:39

博主 你好:感谢你的教程,下面这个例子我有一点不同的看法:
QPushButton quit("Quit");
QWidget window;

quit.setParent(&window);
你说会导致quit两次引起崩溃? 我觉得应该是:window在析构的时候delete子对象 quit 引起的,而不因为quit析构两次,因为quit是在栈上分配的。

for (int i = 0; i < children.count(); ++i) {
currentChildBeingDeleted = children.at(i);
children[i] = 0;
delete currentChildBeingDeleted;
}
delete currentChildBeingDeleted; //这里导致崩溃!

回复
粉蒸排骨 2019年12月30日 - 17:05

个人觉得这里的 parent 翻译成"容器对象"更易于初学者理解一点.

回复
chenyiqian 2020年8月14日 - 11:23

豆哥,另外的原因是,由于我们的 QApplication 是在栈上面创建的,在堆上面创建的 QLabel 对象生命周期要长于 QApplication,这在 Qt 中是应该避免的

我找的资料说先析构堆上的在析构栈上的

回复
豆子 2020年8月14日 - 13:12

这个我也不大清楚,因为栈上的是超出作用域就会被析构,而堆上的则是需要手动调用 delete 才会被析构。所以这个到底谁先析构,就得看是怎么编写的了吧

回复
Jion 2021年3月15日 - 08:49

“{
QPushButton quit("Quit");
QWidget window;

quit.setParent(&window);
}
情况又有所不同,析构顺序就有了问题。我们看到,在上面的代码中,作为父对象的 window 会首先被析构,因为它是最后一个创建的对象。在析构过程中,它会调用子对象列表中每一个对象的析构函数,也就是说, quit 此时就被析构了。然后,代码继续执行,在 window 析构之后,quit 也会被析构,因为 quit 也是一个局部变量,在超出作用域的时候当然也需要析构。但是,这时候已经是第二次调用 quit 的析构函数了,C++ 不允许调用两次析构函数,因此,程序崩溃了。”
这个例子没有理解,当我点击quit的时候,他肯定关联了window的close,所以window在调用close的时候,自然会将其子对象都delet掉,怎么会出现quit delet 两次的情况呢?

回复
豆子 2021年4月6日 - 09:14

因为 quit 按钮是定义在 {} 中的局部变量。window 析构的同时销毁其所有子对象 quit 是 Qt 定义的操作,超出作用域析构作用域中所有局部变量是 C++ 定义的操作。所以在超出 {} 时,window 的析构会销毁 quit,而 quit 自己也有销毁自己的操作,所以 quit 析构了两次。

回复

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2