C++ 动态类型的演变之路
有时需要存储不同类型的对象,但无法提前确定具体类型。传统的做法可能会使用 `void*`,这会导致类型安全性问题,有没有一种类型可以兼容不同类型的数据?
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 的内部定义,这样就不太好,至少是麻烦了。
对用户来讲,简便才是好野。
下面的内容非常硬核,赶紧关注收藏一波~
自动扩展 Variant
类 Variant 能不能自动适配指定的类型?比如使用预编译指令在预编译阶段自动生成对应代码,省掉手动适配新类型,像这样:
DEFINE_VARIANT_T((int)(double)(string))
按照上面的形式,注册需要被适配的类型,然后就可以通过类 Variant 存取这些类型的数据。
那么,宏 DEFINE_VARIANT_T 里边究竟做了什么事情?
说白了,就是在预编译期,按照注册声明的类型,对代码展开,最终生成类似前面实现的类 Variant 定义。
类型信息
第一步是先来定义类型信息的枚举类型 Type,但是我们不能直接显式指定枚举值,必须通过宏 DEFINE_VARIANT_T 输入的变长参数序列自动扩展而来。
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
enum class Type { \
BOOST_PP_SEQ_FOR_EACH( \
GENERATE_ENUM_VALUE, \
_, \
enumerators) \
_COUNT}; \
// ...
private: \
Type type_; \
// ...
};
enumerators 是宏 DEFINE_VARIANT_T 的变长参数序列,为了方便起见我这里直接是使用 boost.preprocessor 库里提供的宏 BOOST_PP_SEQ_FOR_EACH 帮助我们迭代访问序列 enumerators,并逐个调用宏 GENERATE_ENUM_VALUE 来处理,然后输出枚举值标识符。
注意,这里使用宏 BOOST_PP_SEQ_FOR_EACH 的迭代是运行于预编译期阶段,既不是编译期,也不是运行时。笔者八戒在往期文章里聊枚举反射时详细解析过它的用法,欢迎关注我,了解更多。
Type 枚举类型定义的末尾还添加了一个固定的枚举值 _COUNT,它有两个妙用。其一是统计枚举值的数量,其二是方便前面在生成的枚举值标识符时统一在末尾加上逗号(,)进而简化生成的逻辑过程。
为了省却额外的声明,宏 DEFINE_VARIANT_T 在调用时输入的参数,目前规定必须是已知类型的关键词或者已定义的标识符,想要将其转换成枚举值标识符,我认为最简单的形式就是在各个类型名后边添加 _e 字样即可,而且也直观。
所以生成枚举值标识符的宏 GENERATE_ENUM_VALUE 可以这样定义:
// 连结标识符
#define CONCATENATE(x, y) x##y
// 添加后缀,生成枚举值
#define GENERATE_ENUM_VALUE(r, data, elem) CONCATENATE(elem, _e),
因为是被宏 BOOST_PP_SEQ_FOR_EACH 调用的缘故,定义宏 GENERATE_ENUM_VALUE 的格式必须如上所示,通常前两个参数是没什么卵用的,第三个参数 elem 表示宏 BOOST_PP_SEQ_FOR_EACH 从序列中取出的每一个元素。
宏 CONCATENATE 将两个输入的参数连接成一个整体的新标识符。调用宏 CONCATENATE 生成新标识符后,再在各个标识符末尾加上逗号(,),这样就可以不用考虑是否是最后一个标识符,因为我们在所有生成的枚举值最后,预留了一个额外的值 _COUNT。
展开后是怎样?
class Variant {
public:
enum class Type {
int_e,
double_e,
string_e,
_COUNT
};
// ...
private:
Type type_;
// ...
};
联合体数据类型
那么嵌入的联合体 Data 类型呢?
从前面联合体 Data 类型定义来看,所有需要注册的类型都有对应类型的成员,以及默认构造析构函数。由于需要从序列中逐个抽取元素来处理,还需要继续使用宏 BOOST_PP_SEQ_FOR_EACH:
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
// ...
private: \
// ...
union data_t { \
BOOST_PP_SEQ_FOR_EACH( \
GENERATE_UNION_MEMBER_DATA_DECLAR, \
_, \
enumerators) \
data_t() {} \
~data_t() {} \
} data_; \
// ...
};
宏 GENERATE_UNION_MEMBER_DATA_DECLAR 的作用和前面的宏 GENERATE_ENUM_VALUE 类似,都是为了从序列逐一抽取元素并处理生成新的标识符或者表达式,这里是为了生成 data_t 的成员的定义语句。
// 连结标识符,中间插入空格
#define CONCATENATE_WITH_SPACE(x, y) x y
// 定义成员
#define GENERATE_UNION_MEMBER_DATA_DECLAR(r, data, elem) \
CONCATENATE_WITH_SPACE(elem, CONCATENATE(elem, _v));
data_t 的成员名可以在类型标识符的基础上末尾简单添加 _v 即可,所以 CONCATENATE(elem, _v)
就能处理好。
由于在定义 data_t 的成员时,类型和成员名之间是有空格的,那么再引入宏 CONCATENATE_WITH_SPACE 负责连结标识符,中间插入空格,形成表达式语句。
宏定义展开后,是这样子:
class Variant {
// ...
private:
// ...
union data_t {
int int_v;
double double_v;
string string_v;
data_t() {}
~data_t() {}
} data_;
// ...
};
int int_v;
这里的 string 就是 std::string,所以在调用宏 DEFINE_VARIANT_T 之前应该声明
using namespace std;
之所以不直接使用 std::string,是因为特殊符号 ::
不适合作为标识符的一部份,用宏处理比较麻烦。为了简化代码展开的逻辑,就直接要求先声明命名空间 std。
构造函数
Variant 可以初始化为多种已注册类型,类型由初始化数据自带。而默认构造只有一个并且没有显式的用户输入数据,需要指定其中一种类型为初始化目标,目标类型的默认选择逻辑是不明确的,所以默认构造函数在 Variant 中不具有意义。
而指定类型的构造函数就没有选择类型的烦恼,对列举的类型逐个定义对应的构造函数即可,还是使用宏 BOOST_PP_SEQ_FOR_EACH:
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
// ...
BOOST_PP_SEQ_FOR_EACH( \
SUB_CONSTRUCTOR, \
_, \
enumerators) \
// ...
};
指定类型的构造函数形式基本一样,差别在于目标数据不一样,包括类型和变量名:
// 传入子类型数据的构造函数
#define SUB_CONSTRUCTOR(r, data, elem) \
Variant(elem val) : type_(Type::CONCATENATE(elem, _e)) { \
new (&(data_.CONCATENATE(elem, _v))) elem(val); \
}
这里用到了前面定义的宏 CONCATENATE,目标是将类型标识符转换成类型信息的对应枚举值和联合体 data_t 的对应成员名。
展开后,如下:
class Variant {
public:
// ...
Variant(int val): type_(Type::int_e) {
new( & (data_.int_v)) int(val);
}
Variant(double val): type_(Type::double_e) {
new( & (data_.double_v)) double(val);
}
Variant(string val): type_(Type::string_e) {
new( & (data_.string_v)) string(val);
}
// ...
};
拷贝构造函数
拷贝构造函数的目的是对传入的对象数据执行复制,由于传入的数据类型只有笼统的一种 Variant 类对象,可以依据对象内的数据类型信息,在同一个函数内对应构造新的 Variant 对象,如下:
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
// ...
Variant(const Variant& other) : type_(other.type_) { \
switch (type_) { \
BOOST_PP_SEQ_FOR_EACH( \
COPY_CONSTRUCTOR, \
_, \
enumerators) \
default: throw runtime_error("Invalid type to variant COPY_CONSTRUCTOR"); \
} \
// ...
};
继续使用宏 BOOST_PP_SEQ_FOR_EACH 逐个遍历类型,宏 COPY_CONSTRUCTOR 负责生成对应类型的拷贝代码分支,由于数据保存在联合体内,各个分支都是采用 placement new 语句,如下:
// 子类型拷贝构造逻辑
#define COPY_CONSTRUCTOR(r, data, elem) \
case Type::CONCATENATE(elem, _e): \
new (&(data_.CONCATENATE(elem, _v))) elem(other.data_.CONCATENATE(elem, _v)); \
break;
展开后的代码:
class Variant {
public:
// ...
Variant(const Variant & other): type_(other.type_) {
switch (type_) {
case Type::int_e:
new( & (data_.int_v)) int(other.data_.int_v);
break;
case Type::double_e:
new( & (data_.double_v)) double(other.data_.double_v);
break;
case Type::string_e:
new( & (data_.string_v)) string(other.data_.string_v);
break;
default:
throw runtime_error("Invalid type to variant COPY_CONSTRUCTOR");
}
}
// ...
};
拷贝赋值操作符函数
拷贝赋值操作符用于将右侧操作数的数据和类型信息替换左侧的变量原有的内容。
从代码形式上来看,拷贝赋值操作符函数和拷贝构造函数类似,拷贝赋值操作符右侧传入的也是 Variant 对象,同样包含有数据类型信息,所以也可以在同一个函数内按照类型赋值联合体对应成员的内容。
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
// ...
Variant& operator=(const Variant& other) { \
destroy(); \
type_ = other.type_; \
switch (type_) { \
BOOST_PP_SEQ_FOR_EACH( \
COPY_ASSIGNMENT, \
_, \
enumerators) \
default: throw runtime_error("Invalid type to variant COPY_ASSIGNMENT"); \
} \
return *this; \
} \
// ...
};
继续使用宏 BOOST_PP_SEQ_FOR_EACH 逐个遍历类型序列,宏 COPY_ASSIGNMENT 负责生成对应各个类型的代码分支,各个分支都是按照成员的类型赋值,如下:
// 子类型拷贝赋值操作逻辑
#define COPY_ASSIGNMENT(r, data, elem) \
case Type::CONCATENATE(elem, _e): \
data_.CONCATENATE(elem, _v) = other.data_.CONCATENATE(elem, _v); \
break;
展开后:
class Variant {
public:
// ...
Variant & operator = (const Variant & other) {
destroy();
type_ = other.type_;
switch (type_) {
case Type::int_e:
data_.int_v = other.data_.int_v;
break;
case Type::double_e:
data_.double_v = other.data_.double_v;
break;
case Type::string_e:
data_.string_v = other.data_.string_v;
break;
default:
throw runtime_error("Invalid type to variant COPY_ASSIGNMENT");
}
return * this;
}
// ...
};
读取函数
读取指定类型的值的函数和指定类型构造函数的形式也很类似,需要按照不同类型分开函数编写,如下:
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
// ...
BOOST_PP_SEQ_FOR_EACH( \
SUB_READ, \
_, \
enumerators) \
// ...
};
宏 SUB_READ 负责生成对应各个类型的读值方法:
#define DESCRIPTOR(str) #str
#define SUB_READ(r, data, elem) \
elem CONCATENATE(get_, elem)() const { \
if (type_ != Type::CONCATENATE(elem, _e)) \
throw runtime_error("the type of value in Variant is not " \
DESCRIPTOR(elem)); \
return data_.CONCATENATE(elem, _v); \
}
宏 DESCRIPTOR 的作用是将标识符转换成字符串,这样方便调试打印信息,目的是查看当前执行的构造函数是那种类型的。
析构函数
需要用宏定义改写的就剩下析构这部份了,其中实则内容都集中在成员函数 destroy() 里,同样地可以按照不同类型信息,选择对不同成员进行析构,所以要求生成不同分支,对应不同类型成员的析构:
#define DEFINE_VARIANT_T(enumerators) \
class Variant { \
public: \
// ...
~Variant() { \
destroy(); \
} \
// ...
private: \
// ...
void destroy() { \
switch (type_) { \
BOOST_PP_SEQ_FOR_EACH( \
DESTROY, \
_, \
enumerators) \
default: throw runtime_error("Invalid type to variant destroy"); \
} \
} \
};
宏 DESTROY 负责生成不同类型成员的析构分支代码。
从前面的 destroy() 实现来看,基本类型的成员是不需要执行析构的,只需要对动态内存的类型成员析构即可。
但是在预编译阶段是无法判断类型是否是动态内存的,这个需要在编译期或者运行时才能做到。如何是好?
预编译指令做不了,那就搭配编译期的指令辅助实现,比如模板?
// 函数模板
template<typename T>
void destroyType(T* ptr) {
if constexpr (!std::is_trivially_destructible<T>::value) {
ptr->~T(); // 调用析构函数
}
}
// 子类型析构逻辑
#define DESTROY(r, data, elem) \
case Type::CONCATENATE(elem, _e): \
destroyType<elem>(&(data_.CONCATENATE(elem, _v))); \
break;
上面在宏 DESTROY 定义中按照类型生成不同类型的析构分支,各个分支内调用了模版函数 destroyType(),模版函数 destroyType() 的类型参数就是被注册的各个类型。
if constexpr
是 C++ 17 引入的条件编译指令,在编译期由编译器计算判断右边的条件语句,判断为 true 则编译后边紧跟的代码块,否则忽略。
std::is_trivially_destructible<T>::value
用于编译期判断类型 T 是否是非平凡类型,是则返回 true。
平凡类型表示该类型的析构函数没有用户定义的行为,且可以在没有任何清理操作的情况下自动生成和被调用。非平方类型的判断和我们的需求非常吻合,当判断 Variant 存储的数据为非平方类型则调用对应类型的析构函数,否则什么也不做。
展开后:
class Variant {
public:
// ...
~Variant() {
destroy();
}
// ...
private:
// ...
void destroy() {
switch (type_) {
case Type::int_e:
destroyType < int > ( & (data_.int_v));
break;
case Type::double_e:
destroyType < double > ( & (data_.double_v));
break;
case Type::string_e:
destroyType < string > ( & (data_.string_v));
break;
default:
throw runtime_error("Invalid type to variant destroy");
}
}
};
上面是造轮子的,造完就是上路试车了:
using namespace std;
DEFINE_VARIANT_T((int)(double)(string))
int main() {
Variant v1(1);
std::cout << "v1 holds int: " << v1.get_int() << std::endl;
Variant v2 = 2.1;
std::cout << "v2 holds double: " << v2.get_double() << std::endl;
Variant v3(std::string("Hello Variant"));
std::cout << "v3 holds string: " << v3.get_string() << std::endl;
Variant v4(v3);
std::cout << "v4 holds string: " << v4.get_string() << std::endl;
v4 = v1;
std::cout << "v4 changes to hold int: " << v4.get_int() << std::endl;
v4 = v2;
std::cout << "v4 changes to hold double: " << v4.get_double() << std::endl;
return 0;
}
这里需要强调的是,如果调用宏 DEFINE_VARIANT_T 的参数中有任何标准库的类型,必须去掉 std,还有在调用 DEFINE_VARIANT_T 之前必须先声明使用命名空间 std。
上面的通用化过程大量使用了预编译指令,如果全部改成模板会不会更好用呢?
std::variant
在 C++ 17 里标准库引入了 std::variant,它的功能就类似上面我们自己实现的类 Variant,但是使用 std::variant 时不需要调用任何的宏定义,因为它属于模板类。这么看,上面介绍的 Variant 实现还是比较原始一些,局限比较明显。
来看看标准库的 std::variant 是怎么用的:
#include <iostream>
#include <variant>
#include <string>
int main() {
std::variant<int, double, std::string> var;
// 赋值
var = 42; // 存储 int
std::cout << "Int: " << std::get<int>(var) << std::endl;
var = 3.14; // 存储 double
std::cout << "Double: " << std::get<double>(var) << std::endl;
var = "Hello, Variant"; // 存储 std::string
std::cout << "String: " << std::get<std::string>(var) << std::endl;
std::visit([](auto&& arg) {
std::cout << "Value: " << arg << std::endl;
}, var);
return 0;
}
首先是创建 std::variant 实例,并指明它将被用于存取的数据类型列表,各类型逗号分隔。
从 std::variant 实例读取数据有两种方式:
-
使用 std::get
() 指明具体的数据类型 -
使用 std::visit 不用指明具体的数据类型,但是需要提供处理过程,比如函数、lambda 表达式、可调用对象等。
std::variant 相比前面我们自己实现的 variant 使用起来已经大为简化,但是使用前还是需要显式指定兼容的类型,难道不能继续优化做到默认兼容所有类型吗?
存取任意类型
前面我们已经做到让对象兼容几种指定的类型数据,为了让对象能存取任意类型的数据,到底还缺点什么?
类对象存取数据到底有哪几个相关特性?
对象的使用无非就初始化、拷贝、赋值、销毁等这几种基本操作,分别对应到类的构造函数、拷贝构造、拷贝赋值操作符、析构函数等。要想继续重构目标类型,那么以上的几种成员就必须理解好。
回过头来,我们思考一下,类对象存取数据到底存放在哪里?
比如前面实现的 Variant,对象存取指定类型的数据是用内部的一个联合体成员。
在我们使用 Variant 指定兼容的类型时,联合体的定义在预编译期就被具体化了,所有需要用到的类型都必须在联合体内声明。
那么是否可以把这种具体化放在编译期呢?
在编译期,编译器可以使用的手段更多,这里边就包括万能的模板特性。
利用模板,编写代码时甚至可以在碰到具体数据类型时,指定当前存储的数据类型,编译器会在编译期自动将该数据类型插入到类定义中。这样,不同的数据类型会被编译器全部插入到类定义中。
对象不需要在初始化之前就指明所有兼容的类型,仅需要在初始化的时候指明要兼容的当前类型即可,那么使用模版技术再适合不过了。
构造
下面我们开始重新设计一个万金油那样丝滑的类 My_Any:
class My_Any {
public:
template <typename T>
My_Any(const T& value) : content(new Holder<T>(value)) {
std::cout << "construct" << std::endl;
}
private:
template <typename T>
struct Holder {
Holder<T>(const T& value) : value(value) {}
~Holder<T>() {};
T value;
};
Holder *content;
};
如上,在类 My_Any 内部定义了一个数据成员 content 用于存储具体类型的数据,content 是模版类类型 Holder 对象的指针。这样,任何类型数据在不必预先声明具体类型的情况下,就可以用来初始化我们的 My_Any 对象。
Holder 使用 struct 声明,这样成员会默认为 public。
为什么 content 必须是指针类型?往下看。
但初始化 My_Any 时,必须指明传入的数据类型,所以构造函数也必须被定义为模版函数。
但是编译上面的代码是无法通过的,因为上面在定义 content 时并没有正确特化类型 Holder。因为 My_Any 不是模版类,无法传递类型参数,不能为了定义 content 而直接特化 Holder,比如 Holder<T> *content;
。
为了避免这种尴尬,依据类的动态特性,我们可以利用父类的指针可以指向任何子类的实例这个特性,先给 Holder 定义一个父类 Base,然后 content 也被声明为 Base 的指针。
到这里你应该明白为什么 content 必须是指针类型了。
class My_Any {
// ...
private:
struct Base {};
template <typename T>
struct Holder : Base {
Holder<T>(const T& value) : value(value) {}
T value;
};
Base *content;
};
由于 Base 不是模版类,所以 content 可以顺利编译通过。
拷贝
哦,如果拷贝其它 My_Any 对象来初始化新的 My_Any 对象呢?
直接拷贝内部的 content 即可,可以为 content 添加一个成员方法 copy(),负责克隆当前数据,并转移给调用方。
class My_Any {
public:
// ...
My_Any(const My_Any& other) : content(other.content ? other.content->copy() : nullptr) {
std::cout << "copy construct" << std::endl;
}
private:
struct Base {
virtual Base* copy() const = 0;
};
template <typename T>
struct Holder : Base {
Holder<T>(const T& value) : value(value) {}
Base* copy() const override { return new Holder<T>(value); }
T value;
};
Base *content;
};
如上 copy() 的实现,所谓克隆就是重新分配空间,并拷贝数据内容。
赋值
赋值就是将一个现成的 My_Any 对象的数据拷贝到另外一个现成的 My_Any 对象,另一个对象的原有数据需要被清除,尤其是涉及到动态内存类型的数据。
content 如果继续作为裸指针使用,在清除原有数据时会变得麻烦。基于 RAII 设计原则,把 content 指向的资源用智能指针管理起来。
class My_Any {
public:
// ...
My_Any& operator=(const My_Any& other) {
std::cout << "copy asignment" << std::endl;
if (this != &other) {
content.reset(other.content ? other.content->copy() : nullptr);
}
return *this;
}
private:
struct Base {
virtual Base* copy() const = 0;
virtual ~Base() {};
};
template <typename T>
struct Holder : Base {
Holder<T>(const T& value) : value(value) {}
Base* copy() const override { return new Holder<T>(value); }
~Holder<T>() {};
T value;
};
std::unique_ptr<Base> content;
};
如上 content 改用 std::unique_ptr 托管,std::unique_ptr 提供的 reset 方法可以一步实现替换并清除原有数据的功能。
由于 content 被声明为基类 Base 的指针,实际指向的是派生类的实例。在调用智能指针的 reset 时会调用实例的析构函数,为了能调用正确的析构函数,编译器要求 ~Base() 被声明为虚函数。
读取数据
由于 content 是基类指针,数据是定义在具体的派生类 Holder 中的,需要被转化为明确的派生类型才可以读取数据,这个派生类型的指定就依赖于对 Holder 的特化,特化的参数可以通过不同的方法明确指定。
所以,读取数据时,调用的方法还是要类似前面实现的 Variant,不同的数据类型调用不同的方法。
但不需要每个类型都手动写一遍,利用模板特性,调用读取方法时,通过指定的类型参数,就可以在编译期生成对应的方法,这个类型参数同时作为 Holder 的特化参数。
由于读取数据调用的不同方法必须和对象的当前存储的数据类型一致,那么 content 也需要提供类型信息的读取方法,一旦类型不一致需要抛出错误。
class My_Any {
public:
// ...
template <typename T>
T& get() const {
if (std::type_index(typeid(T)) != content->type()) {
throw std::bad_cast();
}
return static_cast<Holder<T>*>(content.get())->value;
}
private:
// ...
struct Base {
// ...
virtual std::type_index type() const = 0;
};
template <typename T>
struct Holder : Base {
// ...
std::type_index type() const override { return typeid(T); }
};
};
typeid 是 C++ 的关键词,操作数是类型标识符,用于编译期返回对象 std::type_info。std::type_info 包含类型对应的信息,比如类型名等,但是它的比较功能将会被移除,需要其它的包装器辅助比较,比如 std::type_index。
好了,到目前为止,类 My_Any 的基本功能已经差不多完成,是骡是马都得拉出来遛一遛:
int main() {
My_Any a = 42;
std::cout << "a Stored value: " << a.get<int>() << std::endl;
My_Any b(std::string("hi"));
std::cout << "b Stored value: " << b.get<std::string>() << std::endl;
My_Any c = a;
std::cout << "c Stored value: " << a.get<int>() << std::endl;
a = b;
std::cout << "a Stored value: " << a.get<std::string>() << std::endl;
return 0;
}
输出:
construct
a Stored value: 42
construct
b Stored value: hi
copy construct
c Stored value: 42
copy asignment
a changed to Stored value: hi
事实上,前面实现的 My_Any 类和 C++ 17 提供的 std::any 类功能基本一样,算得上是简化版本了,使用的时候可以直接替换,但后者读值要借助 std::any_cast。
上面实现的这些类,虽然在任何时间点只能有一个类型的值,但最重要的是能方便并安全地访问各种类型数据。
好了,写到这里,欢迎读者朋友们在评论区和我聊聊。。。