0%

Qt 的 D-Pointer 原理和简单实现

又称 不透明指针,是实习的时候看到的一个设计模式,可以隐藏对象的数据成员,保证二进制兼容性(防止添加数据成员导致的链接库偏移改变),主要服务于 Qt 早年的闭源目的。

Qt 的 Wiki 里面有比较详细的介绍,但是代码似乎有点问题。

二进制兼容

首先大概讲一下二进制兼容。C++通过指定偏移量来在运行时访问对象的成员(函数或者变量),为了便于快速定位,它使用一套字节对齐的规则来安排链接库等二进制文件中对象成员的偏移量。如果给对象添加成员,就有可能改变部分或全部成员的偏移,那么发布的二进制链接库就无法保证兼容旧版本的程序,除非旧版本的程序也跟着重新编译一遍。例如:

1
2
3
4
5
6
7
8
// Monster.h
#pragma once
#include <iostream>
class Monster {
public:
Monster();
int health;
};
1
2
3
// Monster.cpp
#include "Monster.h"
Monster::Monster() : health(100) { std::cout << "Monster::Monster()\n"; }
1
2
3
4
5
6
7
8
// main.cpp
#include <iostream>
#include "Monster.h"
int main() {
Monster m;
std::cout << m.health << std::endl;
return 0;
}

这样一个简单的结构,将 Monster 类导出成动态链接库,运行后的结果显然是先输出构造函数的提示句,然后输出初始化的 health,值是100。接着我们给 Monster 添加一个成员变量,比如攻击力 attack。这次我们只重新编译动态链接库,模拟发布时直接替换更新的情景。

1
2
3
4
5
6
7
8
9
// Monster.h
#pragma once
#include <iostream>
class Monster {
public:
Monster();
int attack;
int health;
};
1
2
3
4
5
// Monster.cpp
#include "Monster.h"
Monster::Monster() : health(100), attack(30) {
std::cout << "Monster::Monster()\n";
}

直接运行原来的程序,发现本来输出的 100 变成了 30,显然是访问到了存放攻击力数值的内存。每次把新增的成员放到最后面可以“解决”这个问题,但这种规范肯定比花括号换行还难遵守,更何况如果新增的并不是一样大的成员,可能连之前的成员偏移都会因为 alignment 而改变。

D-Pointer

为了避免这种情况,我们可以把在后续开发中易变或者易扩展的成员放到一个独立的结构体中,然后在原对象里保存一个指针(D-Pointer)指向这个结构体,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
// Monster.h
#pragma once
#include <iostream>
struct MonsterPrivate;
class Monster {
public:
Monster();
int health() const;

protected:
MonsterPrivate *d_ptr;
};
1
2
3
4
5
6
7
8
9
10
11
// Monster.cpp
#include "Monster.h"
struct MonsterPrivate {
MonsterPrivate() { std::cout << "MonsterPrivate::MonsterPrivate()\n"; }
int health = 100;
int attack = 30;
};
Monster::Monster() : d_ptr{new MonsterPrivate} {
std::cout << "Monster::Monster()\n";
}
int Monster::health() const { return d_ptr->health; }

由于同时修改了可见性,主函数也要改一下:

1
2
3
4
5
6
7
8
9
// main.cpp
#include <iostream>
#include "Monster.h"
using namespace std;
int main() {
Monster m;
std::cout << m.health() << std::endl;
return 0;
}

这样我们实际上把更新偏移的工作转移给了编写链接库的人。

目前的示例代码只是用不透明指针来存放可以剥离出来的成员变量。如果我们需要存放和调用者相关的成员函数,那还需要在结构体里放一个指回原对象的指针,并且在合适的时候(比如原对象构造时)初始化。

继承和优化

面向对象无法绕过的事情就是继承,很多问题也是在继承的时候才出现的。现在我们要实现一个继承自 Monster 的类 Dragon,当然也要给它的私有成员(比如颜色)准备一个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Dragon.h
#pragma once
#include "Monster.h"
#include <iostream>
class DragonPrivate;
class Dragon : public Monster {
public:
Dragon();
int color() const;

protected:
DragonPrivate *d_ptr;
};
1
2
3
4
5
6
7
8
9
10
// Dragon.cpp
#include "Dragon.h"
struct DragonPrivate {
DragonPrivate() { std::cout << "DragonPrivate::DragonPrivate()\n"; }
int color = 7;
};
Dragon::Dragon() : d_ptr(new DragonPrivate) {
std::cout << "Dragon::Dragon()\n";
}
int Dragon::color() const { return d_ptr->color; }

然后测试一下:

1
2
3
4
5
6
7
8
9
10
11
// main.cpp
#include <iostream>
#include "Monster.h"
#include "Dragon.h"
using namespace std;
int main() {
Dragon d;
std::cout << d.health() << std::endl;
std::cout << d.color() << std::endl;
return 0;
}

运行没有问题。这样就够了吗?注意到每次我们构造新的对象时,都新申请了一块内存用来存放 xxxPrivate。如果一个类在继承树上很深的位置(比如 Qt 自己的一堆类),构造这个对象就会有很多次额外的内存分配。我们可以尽量共用内存,比如说充分利用不透明指针只是指针的特点。

以下优化基于的假设(大概)是基于实例化一个派生类需要的内存申请(系统调用)次数比继承层数少。

我们可以通过把不透明结构体也纳入继承体系来统一内存布局:直接实例化派生出来的不透明结构体,然后用它的地址去向上初始化基类的 d_ptr。这样总不会比让两个不透明结构体分开存放更差。至少看起来只用一次 new

由于需要继承 MonsterPrivate,我们把它的定义放到单独的头文件里。

1
2
3
4
5
6
7
8
// Monster_p.h
#pragma once
#include <iostream>
struct MonsterPrivate {
MonsterPrivate() { std::cout << "MonsterPrivate::MonsterPrivate()\n"; }
int health = 100;
int attack = 30;
};

Monster 类多了一个构造函数,用来接收不透明结构体。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Monster.h
#pragma once
#include <iostream>
struct MonsterPrivate;
class Monster {
public:
Monster();
int health() const;
int attack() const;

protected:
Monster(MonsterPrivate &d);
MonsterPrivate *d_ptr;
};
1
2
3
4
5
6
7
8
9
10
// Monster.cpp
#include "Monster.h"
Monster::Monster() : d_ptr{new MonsterPrivate} {
std::cout << "Monster::Monster()\n";
}
Monster::Monster(MonsterPrivate &d) : d_ptr{&d} {
std::cout << "Monster::Monster(MonsterPrivate&)\n";
}
int Monster::health() const { return d_ptr->health; }
int Monster::attack() const { return d_ptr->attack; }

Dragon 类就可以不存储自己的不透明指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Dragon.h
#pragma once
#include "Monster.h"
#include <iostream>
class DragonPrivate;
class Dragon : public Monster {
public:
Dragon();
int color() const;

protected:
// DragonPrivate *d_ptr;
};

但是,如果想使用基类的不透明指针,需要转换一下类型来访问派生部分的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Dragon.cpp
#include "Dragon.h"
#include "Monster_p.h"
struct DragonPrivate : public MonsterPrivate {
DragonPrivate() { std::cout << "DragonPrivate::DragonPrivate()\n"; }
int color = 7;
};
Dragon::Dragon() : Monster(*(new DragonPrivate)) {
std::cout << "Dragon::Dragon()\n";
}
int Dragon::color() const {
DragonPrivate *d = static_cast<DragonPrivate *>(d_ptr);
return d->color;
}

这样下来只需要在实例化 Dragon 时构造一个不透明结构体就好了。

如果不想写那么多类型转换,可以用宏代替:

1
#define _D(Class) Class##Private *d = static_cast<Class##Private *>(d_ptr)

然后把这个宏放在什么头文件里全局包含。Qt 里用的是 Q_D(Class),还额外套了一层宏,加上一个 getter,具体见 Wiki。

当然这样实际优化的重任就交给编译器了。