Qt隐式共享和d指针

相信不少刚开始阅读Qt源代码的朋友在看到其中的Private类和诸如Q_D、Q_Q等宏时都会思考,为什么Qt要用这样一个设计模式呢?这样一段增加了不少复杂度的代码,到底有多大的好处呢?简单的说,这样的好处在于保证代码的二进制兼容性。

隐式共享也称“写时复制(copy-on-write)”。总之一句话,隐式共享的思想就是:修改前大家都用同一块内存的数据,修改时才会进行全数据拷贝操作。

dq指针

二进制兼容

什么是二进制兼容性?大名鼎鼎的KDE项目是这样介绍的:一个库是二进制兼容的,如果一个程序和某个库的某个版本动态链接,并且不需要重新编译,即可在安装有该库较新版本的环境中运行。为什么要保证二进制兼容性?如果不能保证库的二进制兼容性,就意味着每次发布新版本时,依赖该库的所有程序都必须重新编译才能正常运行。显然,这对于像Qt这样被广泛采用的库而言是完全不可接受的。

class Widget {

...

private:

Rect m_geometry;

};

class Label :public Widget {

...

String text()const{return m_text; }

private:

String m_text;

};

在这里工程名为CuteApp,Widget类包含一个私有成员变量m_geometry。我们编译Widget类,并且将其发布为WidgetLib 1.0。对于WidgetLib 1.1版本,我们希望加入对样式表的支持。在Widget类中我们相应的加入了新的数据成员。

class Widget {

...

private:

Rect m_geometry;

String m_stylesheet; // NEW in WidgetLib 1.1

};

class Label :public Widget {

...

String text()const{return m_text; }

private:

String m_text;

};

在WidegetLib 1.0中,Label类的成员变量m_text还在<offset 1>。被编译器编译后,将Label::text()方法解释为获取Label对象的<offset 1>。而在WidegetLib 1.1中,由于添加新的数据成员,导致m_text的标记位变为<offset 2>

由于工程没有重新编译,c++编译器还会将在编译和运行时的对象大小认为一致。也就是说,在编译时,编译器为Label对象按照其大小在内存上分配了空间。而在运行时,由于Widget中m_stylesheet的加入导致Label的构造函数重写了已经存在的内存空间,导致了程序崩溃。

d指针

保持一个库中的所有公有类的大小恒定的问题可以通过单独的私有指针给予解决。这个指针指向一个包含所有数据的私有数据结构体。这个结构体的大小可以随意改变而不会产生副作用,应用程序只使用相关的公有类,所使用的对象大小永远不会改变,它就是该指针的大小。这个指针就被称作D指针。

/* widget.h */
// 私有数据结构体声明。 其定义会在 widget.cpp 或是
// widget_p.h,总之不能在此头文件
class WidgetPrivate;

class Widget {
...
Rect geometry()const;
...
private:
// d指针永远不能在此头文件中被引用
// 由于WidgetPrivate没有在此头文件中被定义,
// 任何访问都会导致编译错误。
WidgetPrivate *d_ptr;
};

/* widget_p.h */(_p 指示private)
struct WidgetPrivate {
Rect geometry;
String stylesheet;
};

/* widget.cpp */
#include "widget_p.h"
Widget::Widget()
: d_ptr(new WidgetPrivate)// 初始化 private 数据 {
}

Rect Widget::geoemtry()const{
// 本类的d指针只能被在自己的库内被访问
return d_ptr->geometry;
}

/* label.h */
class LabelPrivate;
class Label :publicWidget {
...
String text();
private:
// 自己类对应自己的d指针
LabelPrivate *d_ptr;
};

/* label.cpp */
// 这里将私有结构体在cpp中定义
struct LabelPrivate {
String text;
};

Label::Label()
: d_ptr(new LabelPrivate) {
}

String Label::text() {
return d_ptr->text;
}

有了上面的结构,CuteApp就不会与d指针直接打交道。因为d指针只能在WidgetLib中被访问,在每一次对Widget修改之后都要对其重新编译,私有的结构体可以随意更改,而不需要重新编译整个工程项目。

除了以上优点,d指针还有如下优势: 1.隐藏实现细节——我们可以不提供widget.cpp文件而只提供WidgetLib和相应的头文件和二进制文件。 2.头文件中没有任何实现细节,可以作为API使用。 3.由于原本在头文件的实现部分转移到了源文件,所以编译速度有所提高。

q指针

我们同样需要WidgetPrivate存储一个指向公有类的q指针。

/* widget.h */
class WidgetPrivate;

class Widget {
...
Rect geometry()const;
...
private:
WidgetPrivate *d_ptr;
};

/* widget_p.h */
struct WidgetPrivate {
// 初始化q指针
WidgetPrivate(Widget *q) : q_ptr(q) { }
Widget *q_ptr;// q-ptr指向基类API
Rect geometry;
String stylesheet;
};

/* widget.cpp */
#include "widget_p.h"
// 初始化 private 数据,将this指针作为参数传递以初始化 q-ptr指针
Widget::Widget()
: d_ptr(new WidgetPrivate(this)) {
}

Rect Widget::geoemtry()const{

return d_ptr->geometry;
}

/* label.h */
class LabelPrivate;
class Label :publicWidget {
...
String text()const;
private:
LabelPrivate *d_ptr;};

/* label.cpp */
struct LabelPrivate {
LabelPrivate(Label *q) : q_ptr(q) { }
Label *q_ptr; //Label中的q指针
String text;
};

Label::Label()
: d_ptr(new LabelPrivate(this)) {
}

String Label::text() {
return d_ptr->text;
}

d指针的继承

在以上代码中,每产生一个Label对象,就会为相应的LabelPrivate和WidgetPrivate分配空间。如果我们用这种方式使用Qt的类,那么当遇到像QListWidget(此类在继承结构上有6层深度),就会为相应的Private结构体分配6次空间。 在下面示例代码中,将会看到,我们用私有类结构去实例化相应构造类,并在其继承体系上全部通过d指针来初始化列表。

/* widget.h */
class Widget {
public:
Widget();
...
protected:
// 只有子类会访问以下构造函数
Widget(WidgetPrivate &d);// 允许子类通过它们自己的私有结构体来初始化
WidgetPrivate *d_ptr;
};

/* widget_p.h */
struct WidgetPrivate {
WidgetPrivate(Widget *q) : q_ptr(q) { }
Widget *q_ptr;
Rect geometry;
String stylesheet;
};

/* widget.cpp */
Widget::Widget()
: d_ptr(new WidgetPrivate(this)) {
}

Widget::Widget(WidgetPrivate &d)
: d_ptr(&d) {
}

/* label.h */
class Label :public Widget {
public:
Label();
...
protected:
Label(LabelPrivate &d);// 允许Label的子类通过它们自己的私有结构体来初始化
// 注意Label在这已经不需要d_ptr指针,它用了其基类的d_ptr
};

/* label.cpp */
#include "widget_p.h"

class LabelPrivate :public WidgetPrivate {
public:
String text;
};

Label::Label()
: Widget(*new LabelPrivate)//用其自身的私有结构体来初始化d指针
}

Label::Label(LabelPrivate &d)
: Widget(d) {
}

当我们建立一个Label对象时,它就会建立相应的LabelPrivate结构体(其是WidgetPrivate的子类)。它将其d指针传递给Widget的保护构造函数。这时,建立一个Label对象仅需为其私有结构体申请一次内存。Label同样也有一个保护构造函数可以被继承Label的子类使用,以提供自己对应的私有结构体。

宏大法

前面一步优化导致的副作用是q-ptr和d-ptr分别是Widget和WidgetPrivate类型。这就意味着为了在子类能够使用d指针,我们用static_cast来做强制转换。为了不让所有地方都飘满static_cast,我们引入宏定义。

// global.h (macros)
#define DPTR(Class) Class##Private *d = static_cast<Class##Private *>(d_ptr)
#define QPTR(Class) Class *q = static_cast<Class *>(q_ptr)

// label.cpp
void Label::setText(constString &text) {
DPTR(Label);
d->text = text;
}

void LabelPrivate::someHelperFunction() {
QPTR(label);
q->selectAll();// 我们现在可以通过此函数来访问所有Label类中的方法
}

再进一步,你可以把public头文件里所有关于d指针的声明,成员变量的声明,都用宏包装起来,神不知鬼不觉你干了什么

隐式共享

直观感受一下

修改前,大家都用同一块内存的数据

20220629191255

修改后,大家各用自己开辟的内存的数据

20220629191325

修改数据后,s2 从共享状态中脱离了出来自立山头,内部进行了深拷贝。其中 s1 的数据在内存中的 0x179ca058 位置,而 s2 的数据在内存中的 0x179ca098 位置。

int main(int argc, char *argv[])
{
QApplication a(argc, argv);

QString s1 = "ABC";
QString s2 = s1;
s2[0] = 'B';

qDebug() << s1.constData();
qDebug() << s2.constData();

return a.exec();
}

基于Qt的隐式共享

Qt 有 QSharedData 和 QSharedDataPointer 类可以方便的实现隐式共享。这两个该类本身就实现了线程安全的引用计数,所以很多细节的东西不需要再去考虑了。

20220629191539

Employee 类对象的数据都从 d 指针访问。其中的写操作会自动调用QSharedDataPointer的detach() 函数,这样共享数据对象的引用计数大于1会创建共享数据对象的副本。因为继承 QSharedData,所以内部会有个引用计数器。一般来说,实现隐式共享机制必须有默认构造函数、拷贝构造函数、析构函数三个。

#include <QSharedData>
#include <QString>

class EmployeeData : public QSharedData
{
public:
EmployeeData() : id(-1) {}
EmployeeData(const EmployeeData &other) : QSharedData(other), id(other.id), name(other.name) {}
~EmployeeData();

int id;
QString name;
}

class Employee
{
public:
Employee()
{
d = new EmployeeData;
}
Employee(int id, const QString &name)
{
d = new EmployeeData;
setId(id);
setName(name);
}
Employee(const Employee &other) : d(other.d) ()

int id() const {return d->id;}
void setId(int id) {d->id = id;}
QString name() {return d->name;}
void setName(const QString &name) {d->name = name;}
private:
QSharedDataPointer<EmployeeData> d;
}

20220629191609

隐式共享引起的 STL 游标问题

因为 STL 游标是采用指针形式的,所以就不得不考虑隐式共享所带来的影响。请看下列代码:

我的 i 游标本来是指向 n1 容器的开头,但由于我修改了 n1 容器元素,所以 n1 容器脱离了共享状态自立山头进行了深拷贝,自然游标 i 就和 n1 没什么关系了。而现在的游标 i 仍然指向刚才的共享数据的开头。那么我用 *i 赋值其实是给 n2 容器赋值。

foreach 关键字 - Qt 新增关键字

Qt 提供了一种简洁的用于遍历所有元素的关键字:foreach。有时候也可以搭配 break 来跳出循环。如下:

QLinkedList<QString> list;
...
foreach (const QString &str, list) {
if (str.isEmpty())
break;
qDebug() << str;
}

QMap<QString, int> map;
...
foreach (const QString &str, map.keys())
qDebug() << str << ':' << map.value(str);

QMultiMap<QString, int> map;
...
foreach (const QString &str, map.uniqueKeys()) {
foreach (int i, map.values(str))
qDebug() << str << ':' << i;
}

如果是像 QMap 这种存放“键值对”的容器,使用思路是获取每个 key,然后根据每个 key 来找到 value

如果是像 QMultiMap 这种多值的容器,使用思路是获取每个 key,然后根据每个 key 来找到 values,再从 values 中遍历所有 value

使用 foreach 关键字时一定要知道,Qt 在进入 foreach 循环时总是会自动获取容器的副本。天呐,第一反应是不是特别耗资源?尤其是容器很大的情况。确实是这样,大容器一般就用游标即可,但是这个关键字的优点就是简洁!所以对于不大的容器使用起来影响很小。

争议

Qt 容器都是隐式共享的,而除了 std::string 之外的所有 STL 容器都是深拷贝的。虽然看起来 STL 是浪费资源,但人们对于隐式共享这种优化方式还存在一些争论。使用 swap() 和 C++11 中的移动语义可以大大消除复制容器的需要。

STL 容器对于索引和大小一直用无符号整数类型(size_t),而 Qt 使用有符号的整数类型(int)。所以切换的时候考虑转换问题。

STL 关联容器的行为更加一致,插入就是插入,永远不会更改现有的值。而 Qt 的关联容器如果存在某 key,则会更新值。