以下内容为本人的学习笔记,如需要转载,请将本段内容(无删改)张贴于文章顶部:微信公众号「ENG八戒」mp.weixin.qq.com/s/26ODHzsI9…,更多无限制精彩内容欢迎查阅我的个人博客站点 ENG八戒
C++ 动态类型的演变之路
Hi,大家好! 我是八戒,那个喜欢写 C++ 原创技术文的乡下人。
我们做的大多数上点规模的项目都会采用分层设计,不同层次的代码之间使用接口传递数据和互动。通信双方传递的数据需要明确类型,否则接收方难以解读数据。
有不同类型数据的需求,也就对应不同接口。随着接口增多,针对不同数据类型的接口重复编写也会越多,代码啊,变得臃肿,从维护的角度来看是一件难受的事情。
另外,有时需要存储不同类型的对象,但无法提前确定具体类型。传统的做法可能会使用 void*,这会导致类型安全性问题,有没有一种类型可以兼容不同类型的数据?
带着这个问题,今天会和大家一起走一趟 C++ 动态类型演变史。
来看一下这个例子:有个事件引擎,负责对外分发事件,也提供事件处理注册接口,外部调用事件处理注册接口就可以订阅事件。随着事件的生成和分发,触发事件处理回调(callback)接收并处理事件。
所以这个事件引擎概括起来可以定义成这样:
class EventEngine {
public:
void addListener(Callback callback, ArgType arg) { ... }
void dispatchEvent() { ... }
};
addListener 是事件处理注册接口,dispatchEvent() 负责分发事件。Callback 是事件处理回调函数的类型,ArgType 是传递给事件处理回调函数的参数的类型。
由于传递数据类型的多变,导致 Callback 和 ArgType 的定义可以有多种。假设回调处理函数不返回任何数据,而且传递的数据是整形或者双精度浮点数据,那么需要最少声明下面这两种回调函数类型:
using IntCallback = std::function<void(int&)>;
using DoubleCallback = std::function<void(double&)>;
这里使用了 using 语法,定义了类型别名,可以简化代码书写。至于前者别名和后者本义的顺序,可参考 define 语句的顺序,本义在后,别名在前。
同时,事件处理注册接口也需要针对不同的参数类型重复编写:
class EventEngineA {
public:
void addIntListener(IntCallback callback, int context) { ... }
void addDoubleListener(DoubleCallback callback, double context) { ... }
// ...
};
针对不同数据类型,这是最直接的做法。随着传递数据类型的变多,重复编写的接口也跟着变多,线性增长关系。
可能你会想,模板不就可以省去重复编写嘛?
以上面的两个接口为例,如果使用了模板:
class EventEngineB {
public:
template <typename T>
void addListener(std::function<void(T&)> callback, T context) { ... }
// ...
};
上面的 EventEngineB 会在编译期被重新展开成 EventEngineA 那样,所以说只是换汤不换药,不用也罢。
既然如此,有没有一种集成的数据类型可以把所有需要用到的数据类型都囊括进去,用一个接口不就搞定所有不同的数据类型了吗?这得多直观啊!
这想法不错,撸起袖子加油干!
结构体 struct
说到集成各种不同数据类型,第一想到的是使用结构体 struct,比如:
struct data_t {
int i;
double d;
};
结构体类型 data_t 把 int 类型数据和 doulbe 类型数据分别作为成员包含在结构体内,虽然各个子数据类型的数据很清晰,但是这会导致每次为 data_t 类型分配空间都需要为全部数据类型分配空间,对空间是一种浪费,美中不足~
还有其它选择吗?
联合体 union
各种子类型数据并不是同时被使用的,结构体却要同时为所有数据类型同时分配空间,我们的目标是同一时间仅用到一种类型即可。
能不能把集成改为动态?
为了避免各个子类型的数据单独占用空间,可以考虑使用联合体 union 包含不同类型的数据成员,这时空间会被所有成员共享。
基于此,我们重新定义 data_t 类型:
class EventEngine {
public:
union data_t {
int i;
double d;
};
};
data_t 类型只用于 EventEngine 的接口,所以 data_t 的声明放在 EventEngine 类内也无妨。
基于类型 data_t,EventEngine 内的接口都可以重新声明如下:
class EventEngine {
public:
//...
using Callback = std::function<void(data_t&)>;
void addListener(Callback callback, data_t context) { ... }
void dispatchEvent() { ... }
//...
};
多条接口被一条接口完全替代,代码看起来清爽多了。
修改后的接口实际应用起来会是这样,基于上面两个不同的数据类型,需要注册两个不同的处理回调函数,对应不同的接收方。再在处理回调函数中按照不同的数据类型提取数据并处理,比如:
void callback1(EventEngine::data_t &ctx) {
std::cout << "callback1: " << ctx.i++ << std::endl;
}
void attach1(EventEngine &engine) {
EventEngine::data_t val;
val.i = 1;
engine.addListener(callback1, val);
}
void callback2(EventEngine::data_t &ctx) {
ctx.d *= 1.2;
std::cout << "callback2: " << ctx.d << std::endl;
}
void attach2(EventEngine &engine) {
EventEngine::data_t val;
val.d = 1.2;
engine.addListener(callback2, val);
}
callback1() 和 callback2() 是两个不同的接收方处理回调函数,attach1() 和 attach2() 分别将它们注册到对应的事件引擎 EventEngine,事件引擎会在分发事件时逐一调用处理回调函数并传递数据,如下:
int main() {
EventEngine engine;
attach1(engine);
attach2(engine);
engine.dispatchEvent();
engine.dispatchEvent();
return 0;
}
EventEngine 内如何注册并分发事件?
注册的要素包括函数和参数,需要一起保存在事件引擎 EventEngine 对象内。鉴于当前使用场景不考虑查找要素的问题,推荐使用 std::pair 捆版这两个要素,再存放于动态数组 std::vector 中,如下:
class EventEngine {
public:
//...
void addListener(Callback callback, data_t context) {
listeners_.push_back({callback, context});
}
void dispatchEvent() {
for (auto &listener : listeners_) {
listener.first(listener.second);
}
}
private:
std::vector<std::pair<Callback, data_t>> listeners_;
};
上面我们定义集成数据类型 data_t 时,包含的子类型都是基本数据类型,比如 int、double、bool、char 等等,如果还需要用到 std::string 这种较为复杂的数据呢?联合体 union 还能满足需求吗?
集成复杂类型
以 std::string 为例,它属于模板类 std::basic_string 的一种特化,也是容器的一种实现,会依据存入数据的需要而自动扩充必要空间,初始化后的占用空间大小不是固定的,属于动态内存的类型。
联合体的设计目标是省空间,内部各个成员之间是共享空间的,并没有显式的拷贝赋值操作符,一旦执行赋值操作会触发整体内存的直接复制而不是成员赋值,所以对包含动态内存的联合体执行拷贝赋值会有意外的行为发生。
设想类型 data_t 包含了基本类型和复杂类型,并声明两个实例 a 和 b,如下:
union data_t {
int i;
double d;
std::string s;
};
data_t a, b;
a.i = 1;
b.s = "hi";
如上,a 的值为整形,b 的值为字符串,这两个联合体就属于不同类型信息的同一类联合体。
由于动态内存类型的内存空间是可变化的,也就是说 a 和 b 的内存布局是不一样的,当将 a 的值赋给 b 时,b 的内存空间会被直接覆盖,赋值前后内存布局会凌乱不堪,甚至会发生内存泄漏,而且类型信息将变得不一致。
鉴于此,需要对动态内存的类型特殊处理,联合体不能简单地包含这种复杂类型。
注意:不是不能用联合体 union 包含 std::string 这种动态内存的数据类型,而是需要特殊处理。
那么,到底如何处理,才能继续在联合体内包含上面所说的复杂类型呢?
如上面说分析的,既然拷贝赋值会导致联合体的类型信息混乱,那么我们就给它绑定多一个类型信息。这个类型信息不应该存放在联合体内部,否则也会被拷贝赋值时覆盖。
可以考虑将联合体和类型信息放置于一个类内,分别作为成员被管理:
class Variant {
public:
// 支持的类型
enum class Type { INT, DOUBLE, STRING };
private:
// 手动管理的联合体,存储不同类型
union data_t {
int i;
double d;
std::string s;
data_t() {}
~data_t() {}
} data_;
Type type_;
};
如上,声明了一个类 Variant,在类内声明私有的联合体变量 data_ 和类型信息枚举量 type_。
类型信息的枚举值需要明确定义,如果你想看到如何避免显式定义,还得进一步,请耐心往下看。
这是不是意味着,接下来我们并不是直接调用联合体了?
是的,扛起大旗的将是类型 Variant。目前的类 Variant 还是骨瘦如柴弱不禁风的样子,接下来还需要对它丰满丰满。
我们的目标是,使用类 Variant 的变量应该可以如同其它普通变量一样被使用,可以默认构造,指定类型构造,拷贝构造,拷贝赋值,销毁释放空间等,还有我们也需要从它身上读取指定类型的值等等。
默认构造
当实例化 Variant 时没有传入参数,就是默认构造。比如:
Variant var;
创建 Variant 的默认实例,可以在默认构造函数内部指定一个默认的类型。上面 Type 类型定义了三个枚举值 INT、DOUBLE、STRING,分别对应类型 int、double、std::string。
这里可以指定 INT 为默认类型,没有特别要求,青菜萝卜各有所爱,如下:
class Variant {
public:
// ...
// 默认构造函数
Variant() : type_(Type::INT), data_() {
new (&data_.i) int(0);
}
// ...
};
说到 new,大家都用过,也知道是用于分配空间的。但上面函数内,这种 new 是什么鬼用法?
这叫 placement new 语法,placement new 和 以往的 new 语法有很大的区别。以往的 new 是用于重新分配空间,并返回新空间地址。这里的 placement new 既不分配空间,也不改变原有空间大小,而是在指定的缓冲区中重新初始化对象,需要缓冲区预先准备好。
placement new 格式:
new (空间地址) 目标类型(初始化值);
placement new 语法不是新语法,早在 C++98 版本中就被引入,只不过大家接触少而已。
上面的默认构造函数在初始化列表中,默认构造了 data_ 实例,并绑定类型信息为 int,然后在函数体内对 data_ 的 int 类型成员 i 初始化为 0。
实际上初始化列表里的
data_()是多余的,Variant 构造函数会隐式调用 data_ 的默认构造函数,而且 data_() 不会对联合体内任何成员初始化。
指定类型构造
在实例化 Variant 变量时,很多时候希望通过传入具体类型的数据来构造指定类型的实例,比如:
Variant var1 = 1; // 实例化为 int 类型,初始值 1
Variant var2 = 0.5; // 实例化为 double 类型,初始值 0.5
这需要重载构造函数。这些被重载的构造函数的输入参数类型是明确的,分别对应 Type 的定义,如下:
class Variant {
public:
// ...
// int 类型构造函数
Variant(int val) : type_(Type::INT) {
new (&data_.i) int(val);
}
// double 类型构造函数
Variant(double val) : type_(Type::DOUBLE) {
new (&data_.d) double(val);
}
// std::string 类型构造函数
Variant(const std::string& val) : type_(Type::STRING) {
new (&data_.s) std::string(val);
}
// ...
};
各个被重载的构造函数中,分别按照对应的输入参数类型重新初始化联合体对应的成员。
拷贝构造
在实例化 Variant 变量时,有的时候也希望通过传入另一个实例来构造相同类型的实例,比如:
// 实例化为与 var2 同类型的实例
Variant var3 = var2;
上面的例子中虽然使用了赋值操作符,但是实际执行调用的应该是拷贝构造函数,因为左侧操作数是新建的量,所以必然需要调用构造,而右侧操作数没有声明为移动类型,所以使用拷贝。
既然目标是构造相同类型的实例,那么需要按照传入实例的类型信息选择性初始化联合体的成员,初始化过程与其它构造函数类似,如下:
class Variant {
public:
// ...
// 拷贝构造函数
Variant(const Variant& other) : type_(other.type_) {
switch (type_) {
case Type::INT:
new (&data_.i) int(other.data_.i);
break;
case Type::DOUBLE:
new (&data_.d) double(other.data_.d);
break;
case Type::STRING:
new (&data_.s) std::string(other.data_.s);
break;
}
}
// ...
};
拷贝赋值
当已经存在的 Variant 变量值需要被修改时,如:
// 将 var1 的值赋给 var3,var3 原有数据被覆盖
var3 = var1;
此时,假设不移动传入的量,类 Variant 的拷贝赋值操作符将会被调用。在赋值操作符函数内,按照类型信息 type_ 对应执行成员赋值。
针对左操作数的类型信息,如果是基本类型,可以直接按照类型信息 type_ 对应地执行成员赋值;如果是动态内存的复杂类型,为了避免内存泄漏,应该先释放左操作数原来的空间。这部分代码可以提取出来作为释放内存的函数,提高利用率。
然后按照类型信息 type_ 对应执行成员赋值。
class Variant {
public:
// ...
// 拷贝赋值操作符重载
Variant& operator=(const Variant& other) {
destroy();
type_ = other.type_;
switch (type_) {
case Type::INT:
data_.i = other.data_.i;
break;
case Type::DOUBLE:
data_.d = other.data_.d;
break;
case Type::STRING:
new (&data_.s) std::string(other.data_.s);
break;
}
return *this;
}
// ...
};
destroy() 负责释放空间。
销毁释放空间
当 Variant 退出生命周期后,应该在析构函数中释放掉已占用的所有资源。
如果联合体的成员都是基本类型,默认的实现都能处理妥当,会把联合体内成员占用的空间全部释放。
但是一旦包含了动态内存的成员,动态分配的空间不会被联合体默认实现所释放,会产生内存泄漏。所以 Variant 的析构函数必须针对联合体动态内存类型的成员特殊处理:
class Variant {
public:
// ...
// 析构函数,根据类型销毁对象
~Variant() {
destroy();
}
// ...
private:
// ...
// 销毁当前存储的对象
void destroy() {
switch (type_) {
case Type::INT:
case Type::DOUBLE:
break;
case Type::STRING:
data_.s.~basic_string();
break;
}
}
};
std::string 是模板类 basic_string 的特化,故释放时调用 ~basic_string()。
读取指定类型的值
在不同的接收方中,被处理的数据类型是确定的,所以可以依据具体的类型读取 Variant 的值,读取值的接口按照类型信息分别定义:
class Variant {
public:
// ...
// 获取 int 类型的值
int getInt() const {
if (type_ != Type::INT)
throw std::runtime_error("the type of value in Variant is not int");
return data_.i;
}
// 获取 double 类型的值
double getDouble() const {
if (type_ != Type::DOUBLE)
std::runtime_error("the type of value in Variant is not double");
return data_.d;
}
// 获取 std::string 类型的值
const std::string& getString() const {
if (type_ != Type::STRING)
throw std::runtime_error("the type of value in Variant is not string");
return data_.s;
}
// ...
};
如上,如果 Variant 包含的当前类型信息与读取的目标类型不一致,可以通过异常抛出错误。
既然我们实现了 Variant,下面就来看看实际使用场景:
int main() {
Variant v1(1); // 定义 int 类型
std::cout << "v1 holds int: " << v1.getInt() << std::endl;
Variant v2 = 2.1; // 定义 double 类型
std::cout << "v2 holds double: " << v2.getDouble() << std::endl;
Variant v3(std::string("Hello Variant")); // 定义 std::string 类型
std::cout << "v3 holds string: " << v3.getString() << std::endl;
Variant v4(v3); // 拷贝定义 std::string 类型
std::cout << "v4 holds string: " << v4.getString() << std::endl;
v4 = v1; // 重设为 int 类型
std::cout << "v4 changes to hold int: " << v4.getInt() << std::endl;
v4 = v2; // 重设为 double 类型
std::cout << "v4 changes to hold double: " << v4.getDouble() << std::endl;
return 0;
}
上面的代码演示了,直接定义具体类型的 Variant 实例,拷贝其它 Variant 实例,修改旧的 Variant 实例。一个常用的数据类型,大多使用的场景无非这几种。
结果输出:
v1 holds int: 1
v2 holds double: 2.1
v3 holds string: Hello Variant
v4 holds string: Hello Variant
v4 changes to hold int: 1
v4 changes to hold double: 2.1
以上实现的 Variant 虽然能应用于大多数场景,但想要使用于任何场景,都必须先适配具体的类型信息,如上面示例代码中所示,目前只适配了 int、double、std::string 这三种类型。
为了适配其它的类型,还需要改动 Variant 的内部定义,这样就不太好,至少是麻烦了。
对用户来讲,简便才是好野。
本文边幅比较长,下面的内容非常硬核,留待下一篇再续,赶紧关注收藏一波~