C++ 类成员初始化发展历程(从C++11到C++20)
介绍
在现代 C++(自 C++11 起)中, 有许多新特性可以简化我们的工作, 使代码更直观. 本文介绍有关类成员初始化方面的知识, 包括:
- 数据成员的基本规则以及如何编写构造函数
- 如何使用 C++11 功能更有效地初始化非静态数据成员, 如非静态数据成员初始化, 继承和委托构造函数
- C++14 和 C++20 中的进一步功能改进
- 如何使用 C++17 的内联变量简化静态数据成员的工作
构造函数
构造函数是一种特殊的成员函数, 用于初始化类的对象. 构造函数的名称与类名称相同, 没有返回类型, 也没有返回值. 构造函数可以有参数, 也可以没有参数.
可以看到Position
类有两个构造函数, 一个是默认构造函数, 另一个是带有两个参数的构造函数.
需要注意的是编译器是按照类成员声明的顺序初始化的, 而不是按照初始化列表的顺序. 如果出现两者的顺序不一致, 则会出现警告.
拷贝构造函数
拷贝构造函数是一种特殊的成员函数, 用于创建一个新对象, 该对象是另一个对象的副本.
移动构造函数(From C++11)
移动构造函数有一个右值引用作为参数, 用于将临时对象的资源转移到新对象.
与赋值操作符的区别
委托构造函数(From C++11)
委托构造函数是一个构造函数, 它调用同一个类中的另一个构造函数. 下面的例子中, 我们定义了一个委托构造函数, 它调用了带有两个参数的构造函数.
委托构造函数的限制
过多的委托构造函数可能会导致一些错误和递归调用. 参考下面例子:
另外一个限制是不能同时使用构造函数列表和委托构造函数.
非静态数据成员初始化
数据成员默认值(From C++11)
从 C++11 开始, 我们可以在类定义中为非静态数据成员提供默认值.
如何工作
我们想知道默认初始化如何工作. 下面的例子中, 我们定义了一个结构体, 其中有两个非静态数据成员, 一个有默认值, 另一个没有. 我们在构造函数中显式初始化一个成员, 另一个使用默认初始化. 我们在 main
函数中调用了构造函数, 并且在构造函数中调用了函数 getX()
和 getY()
.
上面函数的输出结果是:
所以我们看到如果在构造函数的初始化列表中指定了成员变量的值, 那么会使用初始化列表中的值, 而不是使用默认初始化. 否则会使用默认初始化.
拷贝构造函数
上面函数的输出结果是:
可以看到成员变量此时先被赋值,接着被覆盖.
改进方法:
移动构造函数
上面函数的输出结果是:
我们还是看到成员变量此时先被赋值,接着被覆盖. 可以采用类似的方法避免
C++14 的更新
起初在 C++11 中, 如果使用默认成员初始化, 你的类不能是一个聚合类型:
在 C++14 中, 这个限制被取消了, 你可以在聚合类型中使用默认成员初始化:
聚合类型是下面的类型之一:
- 数组类型
- 包含如下条件的类:
- 没有私有或保护的非静态数据成员
- 没有用户声明或继承的构造函数
- 没有虚拟, 私有或保护的基类
- 没有虚拟成员函数
C++20 的更新
增加了对位域的支持
优缺点
优点:
- 容易编写
- 可以确保每个成员都被正确初始化
- 声明和默认值在同一个地方, 更容易维护
- 特别适用于有多个构造函数的情况
- 当你有很多个构造函数的时候非常有用
缺点
- 性能方面, 如果你有性能关键的数据结构, 你可能想要一个"空"的初始化代码. 你可能会有未初始化的数据成员, 但是你可能会节省几个 CPU 指令.
- (仅限于 C++14) 使类在 C++11 中不是聚合的. 参见关于 C++14 更改的部分.
- 由于默认值在头文件中, 任何更改可能需要重新编译依赖的编译单元. 如果只在实现文件中设置值, 则不会出现这种情况. 如果使用了 C++ Module,可能这方面就不用考虑.
C++17 静态数据成员初始化
在 C++17 之前, 静态成员变量的初始化必须在类的定义外部进行, 例如: 头文件:
实现文件:
唯一的例外是一个静态常量整数变量, 你可以在一个地方声明和初始化:
在 C++17 中, 你可以使用内联变量来定义并初始化静态数据成员:
编译器保证这个静态变量的定义只有一个, 无论哪个翻译单元包含了类声明. inline 变量仍然是静态类变量, 因此它们将在调用 main()
函数之前初始化.
这个功能让开发只包含头文件的库变得更容易, 因为不需要为静态变量创建 CPP 文件, 或者使用一些技巧来保持它们在头文件中.
C++20 指定初始化(Designated Initializers)
基本用法
使用规则
使用规则如下:
- 只适用于聚合初始化, 因此只支持聚合类型
- 只能初始化非静态数据成员
- 初始化顺序必须与类声明中的顺序相同
- 并非所有数据成员都必须在表达式中指定
- 不能混合常规初始化和指定初始化
- 每个数据成员只能指定初始化一次
- 不能嵌套.
一些错误示例:
使用指定初始化的优点:
- 可读性: 设计器指向特定的数据成员, 因此在这里不可能出错.
- 灵活性: 你可以跳过一些数据成员, 并依赖其他数据成员的默认值.
- 与 C 兼容: 在 C99 中, 使用类似的初始化形式很流行(尽管更加放松). 有了 C++20 的功能, 可以有非常相似的代码并共享它.
- 标准化: 一些编译器, 如 GCC 或 Clang, 已经对此功能有一些扩展, 因此将其在所有编译器中启用是一个自然的步骤.
可以使用 auto
推导的情况
对静态成员可以使用auto
:
但是不能用于非静态变量:
类模板参数推导 CTAD(class template argument deduction)
自 C++17 起, 可用 CTAD 定义一个类模板对象, 而不指定模板参数.
这个新功能对于静态数据成员初始化是有效的, 但是对于非静态数据成员初始化是无效的.
完整示例
总结
本文详细梳理了从 C++11 到 C++20 在类成员初始化方面的功能演进. C++11 引入了默认成员初始化, 委托构造函数和移动构造函数, 简化了对象初始化和资源管理. C++14 允许聚合类型使用默认初始化, C++17 通过内联变量简化了静态成员的初始化. C++20 新增了指定初始化和位域支持, 进一步提高了代码的可读性和灵活性. 这些特性使得类初始化更加直观, 高效, 但也需注意性能影响和兼容性问题. 通过合理利用这些新特性, 开发者可以编写出更简洁, 高效的 C++ 代码.