文章目录
- C++ 类声明详解
- C++ 联合声明详解
- C++ 注入类名详解
- C++ 非静态数据成员详解
- C++ 位域详解
- C++ 非静态成员函数详解
- C++ `this` 指针详解
- C++ 静态成员详解
- C++ 嵌套类详解
- C++ 派生类详解
- C++ `using` 声明详解
- 空基类优化 (Empty Base Optimization, EBO)
- C++ 虚函数说明符 `virtual`
- C++ 抽象类 (Abstract Class)
- C++ `override` 和 `final` 说明符
- C++ 访问说明符 (Access Specifiers)
- C++ 友元声明 (Friend Declarations)
- C++ 构造函数和成员初始化列表
- 默认构造函数
- 析构函数
- 复制构造函数
- 复制赋值运算符
- 输出
- 总结
- 移动构造函数
- 输出
- 总结
- 复制省略 (Return Value Optimization, RVO)
- 强异常安全保证
- 重载解析
- 示例解释
- 结论
- 移动赋值运算符 (Move Assignment Operator)
- 输出
- 总结
- 复制省略 (Return Value Optimization, RVO)
- 强异常安全保证
- 重载解析
- 示例解释
- 结论
- 转换构造函数 (Converting Constructor)
- `explicit` 说明符
C++ 类声明详解
在 C++ 中,类(class
)、结构体(struct
)和联合体(union
)是用户定义的类型,它们由类说明符(class specifier)定义。类说明符出现在声明语法中的 decl-specifier-seq
中。以下是类声明的详细解释,包括其语法、成员规范、前向声明、局部类等内容。
1. 类声明的语法
类声明的基本语法如下:
class-key attr (可选) class-head-name final (可选) base-clause (可选) { member-specification }
class-key
: 关键字,可以是class
、struct
或union
。class
和struct
的主要区别在于默认的成员访问控制:class
: 默认成员为private
,默认基类继承为private
。struct
: 默认成员为public
,默认基类继承为public
。
union
: 声明联合体,所有成员共享同一块内存。
attr
: 从 C++11 开始,可以在类声明前添加任意数量的属性(attributes),例如alignas
指定对齐方式。class-head-name
: 类的名称,可以是限定的(带命名空间或作用域解析运算符)。final
: 从 C++11 开始,表示该类不能被派生。base-clause
: 基类列表及其继承模式(如public
、protected
、private
),用于多继承或多层继承。member-specification
: 类的成员规范,包含成员变量、成员函数、嵌套类、枚举等。
2. 成员规范
成员规范是由大括号包围的序列,包含以下内容:
-
成员声明:
attr (可选) decl-specifier-seq (可选) member-declarator-list (可选);
attr
: 属性(自 C++11 起)。decl-specifier-seq
: 说明符序列,仅在构造函数、析构函数和用户定义的类型转换函数的声明中是可选的。member-declarator-list
: 成员声明列表,类似于初始化声明列表,但允许位域声明、纯虚函数说明符、override
和final
说明符(自 C++11 起),并且不允许直接非列表初始化语法。
成员声明可以声明静态和非静态数据成员、成员函数、成员 typedef、成员枚举和嵌套类,也可以是友元声明。
class S { int d1; // 非静态数据成员 int a[10] = {1, 2}; // 非静态数据成员带初始化器(C++11) static const int d2 = 1; // 静态数据成员带初始化器 virtual void f1(int) = 0; // 纯虚成员函数 std::string d3, *d4, f2(int); // 两个数据成员和一个成员函数 enum { NORTH, SOUTH, EAST, WEST }; struct NestedS { std::string s; } d5, *d6; typedef NestedS value_type, *pointer_type; };
-
成员函数定义:
在类体内定义的成员函数会自动内联(除非它们附加到命名模块,自 C++20 起)。分号是可选的。class M { std::size_t C; std::vector<int> data; public: M(std::size_t R, std::size_t C) : C(C), data(R * C) {} // 构造函数定义 int operator()(std::size_t r, std::size_t c) const { // 成员函数定义 return data[r * C + c]; } int& operator()(std::size_t r, std::size_t c) { // 另一个成员函数定义 return data[r * C + c]; } };
-
访问说明符:
使用public
、protected
和private
来控制成员的访问权限。默认情况下,class
的成员是private
,而struct
的成员是public
。class S { public: S(); // 公有构造函数 S(const S&); // 公有拷贝构造函数 virtual ~S(); // 公有虚析构函数 private: int* ptr; // 私有数据成员 };
-
使用声明:
使用using
关键字将基类的成员引入派生类中,或者继承基类的构造函数(自 C++11 起)。class Base { protected: int d; }; class Derived : public Base { public: using Base::d; // 将 Base 的保护成员 d 引入为 Derived 的公有成员 using Base::Base; // 继承所有基类的构造函数(C++11) };
-
static_assert
声明:
使用static_assert
进行编译时断言,确保某些条件成立。template<typename T> struct Foo { static_assert(std::is_floating_point<T>::value, "Foo<T>: T must be floating point"); };
-
成员模板声明:
成员函数和嵌套类可以是模板。struct S { template<typename T> void f(T&& n); template<class CharT> struct NestedS { std::basic_string<CharT> s; }; };
-
别名声明:
使用using
关键字为类型创建别名(自 C++11 起)。template<typename T> struct identity { using type = T; };
-
成员类模板的推断指南:
为成员类模板提供推断指南(自 C++17 起)。struct S { template<class CharT> struct NestedS { std::basic_string<CharT> s; }; template<class CharT> NestedS(std::basic_string<CharT>) -> NestedS<CharT>; };
-
使用枚举声明:
使用using enum
将枚举成员引入当前作用域(自 C++20 起)。enum class color { red, orange, yellow }; struct highlight { using enum color; };
3. 前向声明
前向声明用于声明类类型,但不立即定义它。这允许相互引用的类,并减少头文件依赖。
class Vector; // 前向声明
class Matrix {
// ...
friend Vector operator*(const Matrix&, const Vector&);
};
class Vector {
// ...
friend Vector operator*(const Matrix&, const Vector&);
};
如果前向声明出现在局部范围内,它将隐藏先前声明的类、变量、函数以及可能出现在封闭范围内的所有其他具有相同名称的声明。
struct s { int a; };
struct s; // 什么都不做(s 已经在这个范围内定义)
void g() {
struct s; // 前向声明一个新的局部 struct "s"
// 这隐藏了全局 struct s 直到这个块结束
s* p; // 指向局部 struct s 的指针
struct s { char* p; }; // 定义局部 struct s
}
4. 局部类
局部类是在函数体内声明的类,其名称仅在函数范围内有效,在函数外部无法访问。局部类有以下限制:
- 不能有静态数据成员。
- 成员函数没有链接。
- 成员函数必须完全在类体内定义。
- 除了闭包类型(自 C++14 起),局部类不能有成员模板。
- 局部类不能有友元模板。
- 局部类不能在类定义内部定义友元函数。
- 函数(包括成员函数)内部的局部类可以访问封闭函数可以访问的相同名称。
- 局部类不能用作模板参数(直到 C++11)。
示例代码:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
struct Local {
bool operator()(int n, int m) {
return n > m;
}
};
std::sort(v.begin(), v.end(), Local()); // 自 C++11 起
for (int n : v)
std::cout << n << ' ';
std::cout << '\n';
return 0;
}
输出:
3 2 1
总结
- 类声明:使用
class
、struct
或union
关键字定义用户定义的类型。class
和struct
的主要区别在于默认的成员访问控制和基类继承模式。 - 成员规范:类的主体包含成员变量、成员函数、嵌套类、枚举、别名声明等。成员函数在类体内定义时自动内联。
- 前向声明:允许相互引用的类,并减少头文件依赖。
- 局部类:在函数体内声明的类,名称仅在函数范围内有效,具有一定的限制。
理解这些概念和规则可以帮助你更好地设计和实现复杂的类层次结构,编写更清晰、更高效的 C++ 代码。选择合适的类声明和成员规范可以使代码更加简洁和易读。
C++ 联合声明详解
在 C++ 中,联合(union
)是一种特殊的类类型,它允许在同一块内存中存储多个不同类型的成员,但一次只能有一个成员处于活动状态。联合的大小至少与它最大的非静态数据成员一样大,其他成员共享同一块内存。联合可以有成员函数(包括构造函数和析构函数),但不能有虚函数、基类或引用类型的非静态数据成员。
1. 联合声明的语法
联合声明的类说明符类似于 class
或 struct
声明:
union attr class-head-name { member-specification }
attr
: 从 C++11 开始,可以在联合声明前添加任意数量的属性(attributes)。class-head-name
: 联合的名称,可以是限定的(带命名空间或作用域解析运算符)。名称可以省略,在这种情况下,联合是未命名的。member-specification
: 访问说明符、成员对象和成员函数声明以及定义的列表。
2. 联合的特点
- 内存共享:联合的所有非静态数据成员共享同一块内存。因此,联合的大小至少与它最大的非静态数据成员一样大。
- 活动成员:联合一次只能有一个非静态数据成员处于活动状态。访问非活动成员会导致未定义行为(UB)。
- 成员函数:联合可以有成员函数(包括构造函数和析构函数),但不能有虚函数。
- 基类:联合不能有基类,也不能用作基类。
- 引用类型:联合不能有引用类型的非静态数据成员。
- 非平凡特殊成员函数:如果联合包含具有非平凡特殊成员函数(复制/移动构造函数、复制/移动赋值运算符或析构函数)的非静态数据成员,则这些函数默认情况下被删除,需要由程序员显式定义。
- 默认成员初始化器:最多一个变体成员可以具有默认成员初始化器(自 C++11 起)。
3. 示例代码
简单联合
#include <cstdint>
#include <iostream>
union S {
std::int32_t n; // 占用 4 字节
std::uint16_t s[2]; // 占用 4 字节
std::uint8_t c; // 占用 1 字节
}; // 整个联合占用 4 字节
int main() {
S s = {0x12345678}; // 初始化第一个成员,s.n 是当前活动成员
// 此时,读取 s.s 或 s.c 是未定义行为
std::cout << std::hex << "s.n = " << s.n << '\n';
s.s[0] = 0x0011; // s.s 是当前活动成员
// 此时,读取 s.n 或 s.c 是未定义行为,但大多数编译器允许这样做
std::cout << "s.c is now " << +s.c << '\n' // 11 或 00,取决于平台
<< "s.n is now " << s.n << '\n'; // 12340011 或 00115678
}
可能的输出:
s.n = 12345678
s.c is now 0
s.n is now 115678
具有复杂成员的联合
#include <iostream>
#include <string>
#include <vector>
union S {
std::string str;
std::vector<int> vec;
~S() {} // 需要显式定义析构函数,因为成员有非平凡的析构函数
};
int main() {
S s = {"Hello, world"};
// 此时,读取 s.vec 是未定义行为
std::cout << "s.str = " << s.str << '\n';
s.str.~basic_string(); // 显式调用析构函数
new (&s.vec) std::vector<int>; // 使用 placement new 构造新的成员
// 现在,s.vec 是当前活动成员
s.vec.push_back(10);
std::cout << s.vec.size() << '\n';
s.vec.~vector(); // 显式调用析构函数
}
输出:
s.str = Hello, world
1
4. 成员生存期
联合成员的生存期从成员变为活动时开始。如果之前有其他成员处于活动状态,则其生存期结束。当通过赋值表达式切换活动成员时,C++ 标准规定了一些规则来确保成员的正确创建和销毁。
例如:
union A { int x; int y[4]; };
struct B { A a; };
union C { B b; int k; };
int f() {
C c; // 不启动任何联合成员的生存期
c.b.a.y[3] = 4; // OK: 创建 c.b 和 c.b.a.y 对象
return c.b.a.y[3]; // OK: 引用新创建的对象
}
struct X { const int a; int b; };
union Y { X x; int k; };
void g() {
Y y = { { 1, 2 } }; // OK, y.x 是当前活动成员
int n = y.x.a;
y.k = 4; // OK: 结束 y.x 的生存期,y.k 成为当前活动成员
y.x.b = n; // 未定义行为:修改 y.x.b 时,y.x 的生存期已结束
}
5. 平凡特殊成员函数
联合的平凡移动构造函数、移动赋值运算符、复制构造函数和复制赋值运算符会复制对象表示。如果源和目标不是同一个对象,这些特殊成员函数将启动目标中每个对象的生存期(除了既不是目标子对象也不是隐式生存期类型的对象),这些对象对应于源中嵌套的对象,在复制执行之前。否则,它们不执行任何操作。
6. 匿名联合
匿名联合是未命名的联合定义,不会同时定义任何变量(包括联合类型对象、引用或指向联合的指针)。匿名联合有以下限制:
- 不能有成员函数。
- 不能有静态数据成员。
- 所有数据成员必须是公共的。
- 允许的唯一声明是非静态数据成员和
static_assert
声明(自 C++11 起)。 - 匿名联合的成员被注入到封闭的作用域中,并且不能与在那里声明的其他名称冲突。
示例:
int main() {
union {
int a;
const char* p;
};
a = 1;
p = "Jennifer";
}
命名空间范围的匿名联合必须声明为静态的,除非它们出现在未命名的命名空间中。
7. 类似联合的类
类似联合的类是指联合本身,或者至少有一个匿名联合作为成员的(非联合)类。类似联合的类具有一组变体成员,这些成员是其成员匿名联合的非静态数据成员,以及如果该类是联合,则其不是匿名联合的非静态数据成员。
示例:
struct S {
enum { CHAR, INT, DOUBLE } tag;
union {
char c;
int i;
double d;
};
};
void print_s(const S& s) {
switch (s.tag) {
case S::CHAR: std::cout << s.c << '\n'; break;
case S::INT: std::cout << s.i << '\n'; break;
case S::DOUBLE: std::cout << s.d << '\n'; break;
}
}
int main() {
S s = {S::CHAR, 'a'};
print_s(s);
s.tag = S::INT;
s.i = 123;
print_s(s);
}
输出:
a
123
8. std::variant
替代方案
C++ 标准库提供了 std::variant
,它可以替代许多联合和类似联合类的用途。std::variant
是一个类型安全的联合,能够自动管理不同类型的生命周期,并提供更安全的访问方式。
示例:
#include <iostream>
#include <variant>
int main() {
std::variant<char, int, double> s = 'a';
std::visit([](auto x) { std::cout << x << '\n'; }, s);
s = 123;
std::visit([](auto x) { std::cout << x << '\n'; }, s);
}
输出:
a
123
总结
- 联合:一种特殊的类类型,允许多个不同类型的成员共享同一块内存,但一次只能有一个成员处于活动状态。
- 内存共享:联合的大小至少与它最大的非静态数据成员一样大,其他成员共享同一块内存。
- 活动成员:访问非活动成员会导致未定义行为。
- 成员函数:联合可以有成员函数,但不能有虚函数。
- 基类:联合不能有基类,也不能用作基类。
- 引用类型:联合不能有引用类型的非静态数据成员。
- 非平凡特殊成员函数:如果联合包含具有非平凡特殊成员函数的非静态数据成员,则这些函数默认情况下被删除,需要由程序员显式定义。
- 默认成员初始化器:最多一个变体成员可以具有默认成员初始化器。
- 匿名联合:未命名的联合,成员被注入到封闭的作用域中。
- 类似联合的类:联合本身或至少有一个匿名联合作为成员的类。
std::variant
:C++ 标准库提供的类型安全的联合,能够替代许多联合和类似联合类的用途。
理解这些概念和规则可以帮助你更好地设计和实现复杂的联合结构,编写更安全、更高效的 C++ 代码。选择合适的联合声明和使用方式可以使代码更加简洁和易读。
C++ 注入类名详解
在 C++ 中,注入类名(injected class name)是指在类或类模板的作用域内,类的名称被视为该类的一个公共成员。这意味着类名可以在类内部直接使用,而无需通过作用域解析运算符(::
)来引用。注入类名的行为在普通类和类模板中有所不同,尤其是在类模板中,它可以作为模板名或类型名使用。
1. 普通类中的注入类名
在普通类中,类名在其作用域内被视为一个公共成员名。这个名称的声明点紧随类定义的左花括号。例如:
int X; // 全局变量 X
struct X {
void f() {
X* p; // OK, X 是注入类名,表示 struct X
::X* q; // Error: 名称查找找到全局变量 X,隐藏了 struct X
}
};
在这个例子中,X
在 struct X
的作用域内是注入类名,表示 struct X
。然而,当使用 ::X
时,名称查找会找到全局变量 X
,而不是 struct X
,因此会导致编译错误。
2. 类模板中的注入类名
在类模板中,注入类名可以作为模板名或类型名使用,具体取决于上下文。以下是几种常见的情况:
-
作为模板名使用:
- 当注入类名后面紧跟
<
时,它被视为模板名。 - 当注入类名用作模板模板参数时,它被视为模板名。
- 当注入类名是详细类说明符中的最后一个标识符,用于
friend
类模板声明时,它被视为模板名。
- 当注入类名后面紧跟
-
作为类型名使用:
- 否则,注入类名被视为类型名,等效于模板名后跟类模板的模板参数,并用
<>
括起来。
- 否则,注入类名被视为类型名,等效于模板名后跟类模板的模板参数,并用
示例:
template<template<class, class> class>
struct A;
template<class T1, class T2>
struct X {
X<T1, T2>* p; // OK, X 被视为模板名
using a = A<X>; // OK, X 被视为模板名
template<class U1, class U2>
friend class X; // OK, X 被视为模板名
X* q; // OK, X 被视为类型名,等效于 X<T1, T2>
};
3. 类模板特化中的注入类名
在类模板特化或偏特化的作用域内,当注入类名用作类型名时,它等效于模板名后跟类模板特化或偏特化的模板参数,并用 <>
括起来。
示例:
template<>
struct X<void, void> {
X* p; // OK, X 被视为类型名,等效于 X<void, void>
template<class, class>
friend class X; // OK, X 被视为模板名(与主模板相同)
X<void, void>* q; // OK, X 被视为模板名
};
template<class T>
struct X<char, T> {
X* p, q; // OK, X 被视为类型名,等效于 X<char, T>
using r = X<int, int>; // OK, 可以用于命名其他特化
};
4. 注入类名的继承
与其他成员一样,注入类名也会被继承。在私有或受保护继承的情况下,间接基类的注入类名最终可能在派生类中不可访问。
示例:
struct A {};
struct B : private A {};
struct C : public B {
A* p; // Error: 注入类名 A 不可访问
::A* q; // OK, 不使用注入类名
};
5. 注入类名和构造函数
构造函数没有名称,但在构造函数声明和定义中,封闭类的注入类名被认为是构造函数的名称。这意味着在限定名称 C::D
中,如果名称查找不忽略函数名称,并且在类 C
的作用域内查找 D
会找到其注入类名,则限定名称始终被认为是命名 C
的构造函数。
示例:
struct A {
A();
A(int);
template<class T>
A(T) {}
};
using A_alias = A;
A::A() {} // 构造函数定义
A_alias::A(int) {} // 构造函数定义
template A::A(double); // 构造函数模板实例化
struct B : A {
using A_alias::A; // 继承构造函数
};
A::A a; // Error: A::A 被认为是构造函数名称,不是类型
struct A::A a2; // OK, 等同于 'A a2;'
B::A b; // OK, 等同于 'A b;'
6. 模板模板参数中的注入类名
在模板模板参数中,注入类名可以作为模板名使用。例如:
template<class T, template<class> class U = T::template Base>
struct Third {};
template<class T>
struct Base {
Base* p; // OK: Base 表示 Base<T>
};
template<class T>
struct Derived : public Base<T*> {
typename Derived::Base* p; // OK: Derived::Base 表示 Derived<T>::Base,即 Base<T*>
};
Third<Derived<int>> t; // OK: 默认参数使用注入类名作为模板
7. 注入类名的歧义
在某些情况下,查找注入类名可能会导致歧义,特别是在多个基类中找到相同的注入类名时。如果所有找到的注入类名都引用同一个类模板的特化,并且如果该名称用作模板名,则该引用引用类模板本身而不是其特化,并且不会产生歧义。
示例:
template<class T>
struct Base {};
template<class T>
struct Derived : Base<int>, Base<char> {
typename Derived::Base b; // Error: 模糊,不确定是哪个 Base
typename Derived::Base<double> d; // OK, 使用模板名 Base
};
总结
- 注入类名:在类或类模板的作用域内,类名被视为一个公共成员名,可以直接使用。
- 普通类:注入类名在类作用域内表示该类本身。
- 类模板:注入类名可以作为模板名或类型名使用,具体取决于上下文。
- 类模板特化:在特化或偏特化的作用域内,注入类名作为类型名时,等效于模板名后跟特化的模板参数。
- 继承:注入类名会被继承,但在私有或受保护继承的情况下,可能不可访问。
- 构造函数:构造函数没有名称,但在构造函数声明和定义中,封闭类的注入类名被认为是构造函数的名称。
- 模板模板参数:注入类名可以作为模板名使用。
- 歧义:在多个基类中找到相同的注入类名时,可能会导致歧义,但如果有明确的模板参数,则不会产生歧义。
理解这些规则可以帮助你更好地设计和实现复杂的类层次结构,编写更清晰、更高效的 C++ 代码。选择合适的类声明和使用方式可以使代码更加简洁和易读。
C++ 非静态数据成员详解
在 C++ 中,非静态数据成员(non-static data members)是类的实例变量,每个类的对象都有自己的一份副本。它们在类的成员说明中声明,并且可以在类的定义内部或外部进行初始化。非静态数据成员的行为和限制与类的整体设计密切相关,尤其是在标准布局类、位域、默认成员初始化器等方面。
1. 非静态数据成员的声明
非静态数据成员在类的成员说明中声明,通常包括以下几种类型:
- 普通数据成员:如
int n;
。 - 引用成员:如
int& r;
。 - 数组成员:如
int a[2] = {1, 2};
。 - 嵌套类型的成员:如
struct NestedS { std::string s; } d5;
。 - 位域成员:如
char bit : 2;
。
示例:
class S {
int n; // 普通非静态数据成员
int& r; // 引用成员
int a[2] = {1, 2}; // 数组成员,带默认成员初始化器 (C++11)
std::string s, *ps; // 两个非静态数据成员
struct NestedS {
std::string s;
} d5; // 嵌套类型的成员
char bit : 2; // 位域成员
};
2. 限制
非静态数据成员的声明有一些限制:
-
存储类说明符:
extern
和register
存储类说明符不允许用于非静态数据成员。thread_local
存储类说明符不允许用于非静态数据成员(但允许用于静态数据成员)。
-
不完整类型:
- 不完整类型、抽象类类型及其数组不允许作为非静态数据成员。例如,类
C
不能具有类型为C
的非静态数据成员,但可以具有类型为C&
(对C
的引用)或C*
(指向C
的指针)的非静态数据成员。
- 不完整类型、抽象类类型及其数组不允许作为非静态数据成员。例如,类
-
构造函数名称冲突:
- 如果类有用户声明的构造函数,则非静态数据成员不能与类名相同。
-
占位符类型说明符:
auto
、decltype(auto)
(从 C++14 开始)、推导的类模板名称(从 C++17 开始)、受约束的占位符(从 C++20 开始)等占位符类型说明符不能用于非静态数据成员声明(尽管它们允许在类定义中初始化静态数据成员时使用)。
3. 布局
当创建某个类 C
的对象时,C
的对象表示中会为 C
的每个非静态数据成员分配一些存储空间。引用成员是否占用任何存储空间是实现定义的,但它们的存储持续时间与它们所在的类的对象的存储持续时间相同。
对于非联合类类型,非零大小的成员总是按声明顺序分配,后来声明的成员在类对象中具有更高的地址。访问说明符(如 public
、protected
、private
)不会影响成员的布局顺序,但可能会影响标准布局属性。
示例:
struct A {
int a;
char b;
};
struct B {
const int b1;
volatile char b2;
};
// A 和 B 的共同初始序列是 A.a 和 A.b 以及 B.b1 和 B.b2
4. 标准布局
一个类被认为是标准布局(standard layout),并且具有以下属性:
- 所有非静态数据成员具有相同的访问控制。
- 类没有虚函数、虚基类或非平凡的特殊成员函数(如复制构造函数、赋值运算符、析构函数等)。
- 类的第一个非静态数据成员可以是另一个标准布局类,或者是一个标准布局类的最后一个非静态数据成员。
两个标准布局非联合类类型被称为布局兼容(layout-compatible),如果它们满足以下条件:
- 它们是相同的类型(忽略 cv 限定符)。
- 它们的共同初始序列包含每个非静态数据成员和位域。
示例:
struct A { int a; char b; };
struct B { const int b1; volatile char b2; };
// A 和 B 的共同初始序列是 A.a 和 A.b 以及 B.b1 和 B.b2
5. 成员初始化
非静态数据成员可以通过两种方式之一进行初始化:
-
在构造函数的成员初始化列表中:
struct S { int n; std::string s; S() : n(7) {} // 直接初始化 n, 默认初始化 s };
-
通过默认成员初始化器:
默认成员初始化器是一个花括号或等号初始化器,包含在成员声明中。如果成员被省略在构造函数的成员初始化列表中,则使用默认成员初始化器。
struct S { int n = 7; std::string s{'a', 'b', 'c'}; S() {} // 默认成员初始化器将复制初始化 n,列表初始化 s };
如果一个成员具有默认成员初始化器,并且也出现在构造函数的成员初始化列表中,则对于该构造函数,默认成员初始化器将被忽略。
示例代码:
#include <iostream> int x = 0; struct S { int n = ++x; S() {} // 使用默认成员初始化器 S(int arg) : n(arg) {} // 使用成员初始化列表 }; int main() { std::cout << x << '\n'; // 输出 0 S s1; // 默认初始化器运行 std::cout << x << '\n'; // 输出 1 S s2(7); // 默认初始化器未运行 std::cout << x << '\n'; // 输出 1 }
6. 默认成员初始化器的限制
-
位域成员:默认成员初始化器不允许用于位域成员。
struct S { int n : 2 = 3; // 错误 };
-
数组成员:数组类型的成员不能从成员初始化器中推断出它们的大小。
struct X { int a[] = {1, 2, 3}; // 错误 int b[3] = {1, 2, 3}; // 正确 };
-
引用成员:引用成员不能在默认成员初始化器中绑定到临时对象。
struct A { const int& v = 42; // 正确 }; A a1; // 错误:临时对象绑定 A a2(1); // 正确(默认成员初始化器被忽略)
-
递归初始化:如果引用成员从其默认成员初始化器进行初始化,并且其任何一个可能被求值的子表达式是一个聚合初始化,它将使用该默认成员初始化器,则程序格式错误。
struct A; extern A a; struct A { const A& a1{A{a, a}}; // 正确 const A& a2{A{}}; // 错误 }; A a{a, a}; // 正确
7. 使用场景
非静态数据成员或非静态成员函数的名称只能在以下三种情况下出现:
-
作为类成员访问表达式的组成部分:
- 在成员函数体中,隐式
this->
成员访问表达式是允许的。 - 在成员初始化列表中,隐式
this->
成员访问表达式是允许的。 - 在类内默认成员初始化器中,隐式
this->
成员访问表达式是允许的。
struct S { int m; int n; int x = m; // 正确:隐式 this-> 允许在默认初始化器中 (C++11) S(int i) : m(i), n(m) // 正确:隐式 this-> 允许在成员初始化列表中 { this->f(); // 显式成员访问表达式 f(); // 隐式 this-> 允许在成员函数体中 } void f(); };
- 在成员函数体中,隐式
-
用于形成指向非静态成员的指针:
struct S { int m; void f(); }; int S::*p = &S::m; // 正确:使用 m 形成指向成员的指针 void (S::*fp)() = &S::f; // 正确:使用 f 形成指向成员的指针
-
在使用未求值的操作数时:
struct S { int m; static const std::size_t sz = sizeof m; // 正确:m 在未求值的操作数中 }; std::size_t j = sizeof(S::m + 42); // 正确:即使没有 "this" 对象,m 也可以在未求值的操作数中使用
总结
- 非静态数据成员:类的实例变量,每个类的对象都有自己的一份副本。
- 声明:在类的成员说明中声明,包括普通数据成员、引用成员、数组成员、嵌套类型的成员和位域成员。
- 限制:不能使用
extern
、register
或thread_local
存储类说明符;不能使用不完整类型或抽象类类型;不能与类名相同(如果有用户声明的构造函数);不能使用占位符类型说明符。 - 布局:非静态数据成员按声明顺序分配,后来声明的成员在类对象中具有更高的地址。访问说明符不影响布局顺序,但可能影响标准布局属性。
- 标准布局:满足特定条件的类被认为是标准布局,具有特殊的内存布局和访问规则。
- 成员初始化:可以通过构造函数的成员初始化列表或默认成员初始化器进行初始化。默认成员初始化器有某些限制,特别是对于位域成员、数组成员和引用成员。
- 使用场景:非静态数据成员或非静态成员函数的名称只能在类成员访问表达式、形成指向成员的指针或未求值的操作数中使用。
理解这些规则可以帮助你更好地设计和实现复杂的类结构,编写更安全、更高效的 C++ 代码。选择合适的声明和初始化方式可以使代码更加简洁和易读。
C++ 位域详解
位域(bit-fields)是 C++ 中的一种特殊数据成员,允许你声明具有明确大小(以位为单位)的类数据成员。位域可以用于节省内存,特别是在需要存储多个布尔值或少量整数值的情况下。位域的主要特点是它们可以打包在一起,共享和跨越字节,从而优化内存使用。
1. 位域的声明
位域的声明语法如下:
type name : size; // (1)
type name : size = initializer; // (2) (自 C++20 起)
type
:位域的类型,必须是整型或(可能是cv
限定的)枚举类型。name
:位域的名称,可选。如果省略名称,则表示无名位域,引入指定数量的填充位。size
:一个整数常量表达式,表示该位域将占用的位数。size
必须大于或等于零。对于无名位域,size
为零时有特殊含义。initializer
:默认成员初始化器,用于初始化位域(自 C++20 起)。
示例:
struct S {
unsigned int b1 : 3; // 3 位的无符号位域
unsigned int b2 : 5; // 5 位的无符号位域
};
2. 位域的类型限制
- 类型:位域的类型只能是整型或(可能是
cv
限定的)枚举类型。无名位域不能用cv
限定的类型声明。 - 无名位域:无名位域不会占用任何名称空间,仅用于引入指定数量的填充位。特别地,
size
为零的无名位域可以强制分解填充,即从下一个分配单元的开头开始新的位域。
示例:
struct S {
unsigned char b1 : 3; // 3 位的位域
unsigned char : 2; // 2 位的无名位域,作为填充
unsigned char b2 : 6; // 6 位的位域
unsigned char : 0; // 强制分解填充,从新字节开始
unsigned char b3 : 2; // 2 位的位域
};
3. 位域的大小限制
- 位数:位域的大小由
size
指定,表示该位域将占用的位数。size
必须大于或等于零。对于无名位域,size
为零时有特殊含义,表示从下一个分配单元的开头开始新的位域。 - 值范围:位域的位数设定了它可以保存的值范围。例如,3 位的无符号位域可以保存 0 到 7 之间的值。
示例:
#include <iostream>
struct S {
unsigned int b : 3; // 3 位的无符号位域,允许的值是 0...7
};
int main() {
S s = {6};
++s.b; // 存储值 7 在位域中
std::cout << s.b << '\n'; // 输出 7
++s.b; // 值 8 不适合这个位域
std::cout << s.b << '\n'; // 实现定义的行为,通常输出 0
}
4. 位域的打包行为
多个相邻的位域通常会打包在一起,共享和跨越字节。然而,这种行为是实现定义的,不同的编译器可能会有不同的处理方式。
- 字节对齐:某些平台上的编译器可能会在位域之间插入填充位,以确保字节对齐。例如,如果一个位域的类型是
unsigned char
,则编译器可能会在位域之间插入填充位,以确保下一个位域从新的字节开始。 - 位域的方向:某些平台上的编译器可能会从左到右打包位域,而其他平台可能会从右到左打包位域。
示例:
#include <bit>
#include <cstdint>
#include <iostream>
struct S {
unsigned char b1 : 3; // 1st 3 bits (in 1st byte) are b1
unsigned char : 2; // next 2 bits (in 1st byte) are blocked out as unused
unsigned char b2 : 6; // 6 bits for b2 - doesn't fit into the 1st byte => starts a 2nd
unsigned char b3 : 2; // 2 bits for b3 - next (and final) bits in the 2nd byte
};
int main() {
std::cout << sizeof(S) << '\n'; // 通常输出 2
S s;
s.b1 = 0b111;
s.b2 = 0b101111;
s.b3 = 0b11;
auto i = std::bit_cast<std::uint16_t>(s);
for (auto b = i; b; b >>= 1) // 打印 LSB-first
std::cout << (b & 1);
std::cout << '\n';
}
可能的输出:
2
1110000011110111
5. 位域的特殊行为
- 无名位域:
size
为零的无名位域可以强制分解填充,即从下一个分配单元的开头开始新的位域。这在需要确保位域不跨越字节边界时非常有用。
示例:
#include <iostream>
struct S {
unsigned char b1 : 3; // 3 位的位域
unsigned char : 0; // 强制分解填充,从新字节开始
unsigned char b2 : 2; // 2 位的位域
};
int main() {
std::cout << sizeof(S) << '\n'; // 通常输出 2
}
- 超出范围的值:如果为位域赋值或初始化的值超出了其允许的范围,则结果是实现定义的。通常情况下,超出范围的值会被截断或重置为最小值。
示例:
#include <iostream>
struct S {
signed int b : 3; // 3 位的有符号位域,允许的值是 -4...3
};
int main() {
S s = {3};
std::cout << s.b << '\n'; // 输出 3
++s.b; // 存储值 4,在 3 位有符号位域中溢出
std::cout << s.b << '\n'; // 实现定义的行为,通常输出 -4
}
- 地址和指针:因为位域不一定从字节的开头开始,所以不能获取位域的地址。指向位域的指针和非
const
引用是不可能的。当从位域初始化const
引用时,将创建一个临时对象(其类型是位域的类型),使用位域的值复制初始化,并且引用绑定到该临时对象。
示例:
struct S {
int b : 1; // 1 位的位域
};
void f(const int& x) {
std::cout << x << '\n';
}
int main() {
S s;
s.b = 1;
f(s.b); // 创建临时对象并绑定到 const 引用
}
- 默认成员初始化器:位域不允许使用默认成员初始化器(
int b : 1 = 0;
和int b : 1 {0};
是非法的)。然而,自 C++20 起,位域可以使用大括号或等号初始化器进行初始化。
示例:
struct S {
int x1 : 8 = 42; // OK; "= 42" 是默认成员初始化器
int x2 : 8 {42}; // OK; "{42}" 是默认成员初始化器
};
6. 位域的实现定义行为
以下属性是实现定义的:
- 超出范围的值:用超出范围的值为带符号位域赋值或初始化,或将带符号位域递增到超出其范围时产生的值。
- 位域的分配细节:有关位域在类对象中实际分配的所有内容,包括是否跨越字节、打包方向等。
- C 语言中的位域宽度:在 C 编程语言中,位域的宽度不能超过底层类型的宽度,并且是否
int
未明确指定为signed
或unsigned
的位域是有符号还是无符号是实现定义的。在 C++ 中,只允许后一种选择。
总结
- 位域:允许你声明具有明确大小(以位为单位)的类数据成员,通常用于节省内存。
- 声明:位域的声明语法为
type name : size;
,其中type
必须是整型或枚举类型,size
表示位域的大小。 - 类型限制:位域的类型只能是整型或枚举类型,无名位域不能用
cv
限定的类型声明。 - 大小限制:位域的大小由
size
指定,表示该位域将占用的位数。值范围取决于位域的大小。 - 打包行为:多个相邻的位域通常会打包在一起,共享和跨越字节,但这种行为是实现定义的。
- 特殊行为:无名位域可以强制分解填充,超出范围的值是实现定义的,不能获取位域的地址,位域不允许使用默认成员初始化器(直到 C++20)。
- 实现定义行为:位域的某些属性是实现定义的,包括超出范围的值、位域的分配细节等。
理解这些规则可以帮助你更好地设计和实现复杂的类结构,编写更安全、更高效的 C++ 代码。选择合适的声明和初始化方式可以使代码更加简洁和易读。
C++ 非静态成员函数详解
非静态成员函数(non-static member functions)是类的成员函数,它们与特定的对象实例相关联,并且可以通过该对象调用。与静态成员函数不同,非静态成员函数隐式地接收一个指向调用对象的指针 this
,并且可以访问和修改对象的非静态数据成员。
1. 基本声明和定义
非静态成员函数在类的成员说明中声明,没有 static
或 friend
规范符。它们可以具有以下特性:
- 返回类型:可以是任何合法的 C++ 类型。
- 参数列表:可以包含任意数量的参数。
- cv 限定符:可以声明为
const
或volatile
,表示该函数不会修改对象的状态。 - 引用限定符:可以声明为左值引用
&
或右值引用&&
,表示该函数只能被左值或右值调用。 - 虚函数:可以声明为
virtual
,允许派生类重写该函数。 - final/override:可以使用
final
禁止进一步重写,或使用override
表示该函数重写了基类中的虚函数。 - 默认成员初始化器:可以在类内定义成员函数(即内联定义)。
示例:
class S {
int mf1(); // 非静态成员函数声明
void mf2() volatile, mf3() &&; // 可以有 cv 限定符和/或引用限定符
int mf4() const { return data; } // 可以内联定义
virtual void mf5() final; // 可以是虚函数,并使用 final/override
S() : data(12) {} // 构造函数也是成员函数
int data;
};
int S::mf1() { return 7; } // 如果不是内联定义,则必须在命名空间中定义
2. 调用规则
非静态成员函数可以通过以下方式调用:
- 使用类成员访问运算符:对于类型为
X
的对象,使用.
或->
运算符调用。 - 从派生类对象调用:对于从
X
派生的类的对象,可以直接调用基类的非静态成员函数。 - 从成员函数内部调用:可以从
X
的成员函数的函数体中直接调用其他成员函数。 - 从派生类成员函数内部调用:可以从从
X
派生的类的成员函数的函数体中直接调用基类的成员函数。
在 X
的非静态成员函数体中,任何解析为 X
或 X
基类的非类型非静态成员的标识符表达式 e
(例如标识符)将被转换为成员访问表达式 (*this).e
。这在模板定义上下文中不会发生,因此可能需要显式地使用 this->
前缀名称以使其成为依赖的。
示例:
struct S {
int n;
void f();
};
void S::f() {
n = 1; // 转换为 (*this).n = 1;
}
int main() {
S s1, s2;
s1.f(); // 修改 s1.n
}
对于静态成员、枚举器或嵌套类型的非限定标识符,将被转换为相应的限定标识符。
示例:
struct S {
static int n;
void f();
};
void S::f() {
n = 1; // 转换为 S::n = 1;
}
int main() {
S s1, s2;
s1.f(); // 修改 S::n
}
3. 带 cv 限定符的成员函数
非静态成员函数可以声明为带 const
或 volatile
限定符,表示该函数不会修改对象的状态。具有不同 cv
限定符的函数可以相互重载。
在带 cv
限定符的函数体内,*this
是 cv
限定的,这意味着在 const
成员函数中,只能调用其他 const
成员函数。如果应用了 const_cast
或通过不涉及 this
的访问路径,则仍然可以调用没有 const
限定符的成员函数。
示例:
#include <vector>
struct Array {
std::vector<int> data;
Array(int sz) : data(sz) {}
// const 成员函数
int operator[](int idx) const {
return data[idx]; // *this 是 const Array*
}
// 非 const 成员函数
int& operator[](int idx) {
return data[idx]; // *this 是 Array*
}
};
int main() {
Array a(10);
a[1] = 1; // OK: a[1] 的类型是 int&
const Array ca(10);
ca[1] = 2; // Error: ca[1] 的类型是 int
}
4. 带引用限定符的成员函数
非静态成员函数可以声明为带左值引用限定符 &
或右值引用限定符 &&
,表示该函数只能被左值或右值调用。没有引用限定符的函数可以绑定左值和右值。
在重载解析期间,具有类 X
的 cv
限定符序列的隐式对象成员函数将被视为以下情况:
- 没有引用限定符:隐式对象参数的类型为
cv
限定的X
的左值引用,并且还允许绑定右值隐式对象参数。 - 左值引用限定符:隐式对象参数的类型为
cv
限定的X
的左值引用。 - 右值引用限定符:隐式对象参数的类型为
cv
限定的X
的右值引用。
注意:与 cv
限定符不同,引用限定符不会改变 this
指针的属性:在右值引用限定的函数中,*this
仍然是一个左值表达式。
示例:
#include <iostream>
struct S {
void f() & { std::cout << "lvalue\n"; }
void f() && { std::cout << "rvalue\n"; }
};
int main() {
S s;
s.f(); // 输出 "lvalue"
std::move(s).f(); // 输出 "rvalue"
S().f(); // 输出 "rvalue"
}
5. 虚函数和纯虚函数
非静态成员函数可以声明为虚函数或纯虚函数。虚函数允许派生类重写该函数,而纯虚函数则要求派生类必须实现该函数。有关详细信息,请参见 虚函数 和 抽象类。
示例:
struct Base {
virtual void foo() { std::cout << "Base::foo()\n"; }
virtual void bar() = 0; // 纯虚函数
};
struct Derived : Base {
void foo() override { std::cout << "Derived::foo()\n"; }
void bar() override { std::cout << "Derived::bar()\n"; }
};
6. 显式对象成员函数 (自 C++23 起)
自 C++23 起,非静态成员函数可以声明为带有显式对象参数的函数。显式对象参数允许更灵活的函数签名,并且可以推断对象的类型和值类别。显式对象参数使用前缀关键字 this
表示。
示例:
struct X {
void foo(this X const& self, int i); // 相当于 void foo(int i) const &
void bar(this X self, int i); // 传递对象按值:复制 *this
};
struct D : X {};
void ex(X& x, D& d) {
x.foo(1); // Self 推断为 X&
std::move(x).foo(2); // Self 推断为 X
d.foo(3); // Self 推断为 D&
}
在显式对象成员函数体内部,不能使用 this
指针:所有成员访问必须通过第一个参数完成,就像在静态成员函数中一样。
示例:
struct C {
void bar();
void foo(this C c) {
auto x = this; // 错误:没有 this
bar(); // 错误:没有隐式的 this->
c.bar(); // 正确
}
};
指向显式对象成员函数的指针是普通的函数指针,而不是成员指针。
示例:
struct Y {
int f(int, int) const&;
int g(this Y const&, int, int);
};
auto pf = &Y::f;
pf(y, 1, 2); // 错误:指向成员函数的指针不可调用
(y.*pf)(1, 2); // 正确
std::invoke(pf, y, 1, 2); // 正确
auto pg = &Y::g;
pg(y, 3, 4); // 正确
(y.*pg)(3, 4); // 错误:“pg”不是指向成员函数的指针
std::invoke(pg, y, 3, 4); // 正确
7. 特殊成员函数
一些成员函数是特殊的,即使没有由用户定义,它们也会由编译器定义。这些特殊成员函数包括:
- 默认构造函数
- 复制构造函数
- 移动构造函数(自 C++11 起)
- 复制赋值运算符
- 移动赋值运算符(自 C++11 起)
- 析构函数
- 预期析构函数(自 C++20 起)
特殊成员函数以及比较运算符(自 C++20 起)是唯一可以默认的函数,即使用 = default
而不是函数体来定义。
示例:
struct S {
int data;
// 简单的转换构造函数
S(int val);
// 显式的构造函数
explicit S(std::string str);
// const 成员函数
virtual int getData() const { return data; }
};
// 构造函数的定义
S::S(int val) : data(val) {
std::cout << "ctor1 called, data = " << data << '\n';
}
// 带有捕获子句的构造函数
S::S(std::string str) try : data(std::stoi(str)) {
std::cout << "ctor2 called, data = " << data << '\n';
} catch (const std::exception&) {
std::cout << "ctor2 failed, string was '" << str << "'\n";
throw; // 构造函数的捕获子句应始终重新抛出异常
}
struct D : S {
int data2;
// 带默认参数的构造函数
D(int v1, int v2 = 11) : S(v1), data2(v2) {}
// 虚成员函数
int getData() const override { return data * data2; }
// 仅限左值的赋值运算符
D& operator=(D other) & {
std::swap(other.data, data);
std::swap(other.data2, data2);
return *this;
}
};
int main() {
D d1 = 1;
S s2("2");
try {
S s3("not a number");
} catch (const std::exception&) {}
std::cout << s2.getData() << '\n';
D d2(3, 4);
d2 = d1; // OK: 赋值给左值
// D(5) = d1; // ERROR: 没有合适的重载的赋值运算符
}
总结
- 非静态成员函数:与特定的对象实例相关联,可以通过该对象调用。隐式地接收一个指向调用对象的指针
this
。 - 声明和定义:可以在类的成员说明中声明,具有返回类型、参数列表、
cv
限定符、引用限定符、虚函数、final/override
规范符等特性。 - 调用规则:可以通过类成员访问运算符、从派生类对象调用、从成员函数内部调用、从派生类成员函数内部调用。
cv
限定符:可以声明为const
或volatile
,表示该函数不会修改对象的状态。具有不同cv
限定符的函数可以相互重载。- 引用限定符:可以声明为左值引用
&
或右值引用&&
,表示该函数只能被左值或右值调用。没有引用限定符的函数可以绑定左值和右值。 - 虚函数和纯虚函数:可以声明为虚函数或纯虚函数,允许派生类重写该函数。
- 显式对象成员函数(自 C++23 起):可以声明为带有显式对象参数的函数,允许更灵活的函数签名,并且可以推断对象的类型和值类别。
- 特殊成员函数:包括默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符、析构函数等,可以由编译器默认生成。
理解这些规则可以帮助你更好地设计和实现复杂的类结构,编写更安全、更高效的 C++ 代码。选择合适的声明和定义方式可以使代码更加简洁和易读。
C++ this
指针详解
this
指针是 C++ 中的一个关键字,用于在类的非静态成员函数中引用调用该函数的对象。它是一个右值表达式,其值为隐式对象参数(即调用成员函数的对象)的地址。this
指针在类的成员函数中扮演着重要的角色,帮助区分成员变量和局部变量,并允许对当前对象进行操作。
1. this
指针的使用场景
this
指针可以在以下上下文中使用:
- 成员函数的主体中:包括成员初始化列表和 lambda 表达式主体(自 C++11 起)。
- 成员函数的声明中:在(可选)
cv
限定符序列之后,包括异常说明和尾随返回类型(自 C++11 起)。 - 默认成员初始化器中:用于在类内初始化成员变量。
- lambda 表达式的捕获列表中:可以将
this
捕获到 lambda 表达式中,以便在 lambda 内部访问类的成员(自 C++11 起)。
示例:
class T {
int x;
public:
void foo() {
x = 6; // 等价于 this->x = 6;
this->x = 5; // 显式使用 this->
}
void foo() const {
// x = 7; // 错误:*this 是常量
}
void foo(int x) { // 参数 x 隐藏了成员 x
this->x = x; // 使用 this-> 解消歧义
}
T(int x) : x(x), y(this->x) {} // 成员初始化列表中使用 this
};
2. this
指针的类型
在类 X
的成员函数中,this
的类型是 X*
(指向 X
的指针)。如果成员函数用 cv
限定符序列声明为 const
或 volatile
,则 this
的类型是 cv X*
(指向相同 cv
限定的 X
的指针)。
- 构造函数和析构函数:由于构造函数和析构函数不能用
cv
限定符声明,因此它们中的this
类型始终为X*
,即使在构造或销毁const
对象时也是如此。
示例:
class Array {
std::vector<int> data;
public:
// const 成员函数
int operator[](int idx) const {
return data[idx]; // *this 是 const Array*
}
// 非 const 成员函数
int& operator[](int idx) {
return data[idx]; // *this 是 Array*
}
};
3. this
指针的限制
- 只能与最近的封闭类相关联:
this
只能与它出现的最近的封闭类相关联,即使它在上下文中无效。
示例:
class Outer {
int a[sizeof(*this)]; // 错误:不在成员函数内部
unsigned int sz = sizeof(*this); // 正确:在默认成员初始化器中
void f() {
int b[sizeof(*this)]; // 正确
struct Inner {
int c[sizeof(*this)]; // 错误:不在成员函数内部
// “this” 与 Outer 无关
};
}
};
- 模板中的依赖表达式:在类模板中,
this
是一个依赖表达式,可以使用显式this->
来强制另一个表达式变为依赖项。
示例:
template<typename T>
struct B {
int var;
};
template<typename T>
struct D : B<T> {
D() {
// var = 1; // 错误:“var”未在此作用域中声明
this->var = 1; // 正确
}
};
4. 构造函数中的 this
指针
在对象构造期间,如果对象的任何值或其任何子对象的值是通过非直接或间接从构造函数的 this
指针获得的 glvalue 访问的,则这样获得的对象或子对象的值是未指定的。换句话说,this
指针在构造函数中不能被别名。
示例:
extern struct D d;
struct D {
D(int a) : a(a), b(d.a) {} // b(a) 或 b(this->a) 是正确的
int a, b;
};
D d = D(1); // 因为 b(d.a) 没有通过 this 获取 a,d.b 的值是未指定的
5. delete this;
的使用
如果程序可以保证对象是由 new
分配的,那么执行 delete this;
是可能的。这会使指向已释放对象的每个指针都变为无效,包括 this
指针本身。在 delete this;
返回后,此类成员函数不能引用类的成员(因为这涉及到对 this
的隐式解引用),并且不能调用任何其他成员函数。
这种技术通常用于引用计数指针的成员函数中,例如 std::shared_ptr
(自 C++11 起),用于在对管理对象的最后一个引用超出范围时递减引用计数。
示例:
class ref {
private:
int mnRef;
public:
void incRef() { ++mnRef; }
void decRef() { if (--mnRef == 0) delete this; }
};
6. this
指针的常见用途
- 解消成员变量和局部变量的命名冲突:当成员函数的参数或局部变量与成员变量同名时,可以使用
this->
来明确引用成员变量。
示例:
class T {
int x;
public:
void foo(int x) { // 参数 x 隐藏了成员 x
this->x = x; // 使用 this-> 解消歧义
}
};
- 返回当前对象的引用:许多重载运算符返回
*this
,以便支持链式调用。
示例:
class T {
int x;
public:
T& operator=(const T& b) {
x = b.x;
return *this; // 返回当前对象的引用
}
};
- 在 lambda 表达式中捕获
this
:可以将this
捕获到 lambda 表达式中,以便在 lambda 内部访问类的成员。
示例:
class T {
int x;
public:
void foo() {
auto lambda = [this]() {
this->x = 10; // 在 lambda 内部访问成员变量
};
lambda();
}
};
总结
this
指针:是一个右值表达式,表示调用成员函数的对象的地址。它只能出现在类的非静态成员函数、默认成员初始化器、lambda 表达式的捕获列表等特定上下文中。- 类型:在类
X
的成员函数中,this
的类型是X*
。如果成员函数用cv
限定符声明,则this
的类型是cv X*
。 - 限制:
this
只能与最近的封闭类相关联,不能在类模板中直接访问基类成员,必须使用this->
强制依赖表达式。 - 构造函数中的注意事项:
this
指针在构造函数中不能被别名,避免通过非直接方式访问对象的值。 delete this;
:可以在确保对象由new
分配的情况下使用,但要注意this
指针会变为无效。- 常见用途:解消成员变量和局部变量的命名冲突、返回当前对象的引用、在 lambda 表达式中捕获
this
等。
理解 this
指针的规则和用法可以帮助你编写更清晰、更安全的 C++ 代码,特别是在处理复杂的类结构和成员函数时。正确使用 this
指针可以避免许多常见的错误,并使代码更加易读和维护。
C++ 静态成员详解
在 C++ 中,静态成员(包括静态数据成员和静态成员函数)是与类的实例无关的成员。它们属于类本身,而不是类的任何特定对象。静态成员具有静态存储持续时间,这意味着它们在整个程序运行期间都存在,并且所有对象共享同一个静态成员。
1. 静态成员的声明和定义
1.1 静态成员的声明
静态成员的声明是在类定义中使用 static
关键字进行的。static
关键字通常出现在其他说明符之前,但它可以出现在说明符序列中的任何位置。
class X {
static int n; // 声明静态数据成员
static void f(); // 声明静态成员函数
};
1.2 静态成员的定义
静态成员的定义必须在类定义之外进行,不能在类定义内部定义静态成员。定义时不需要再使用 static
关键字。
int X::n = 1; // 定义静态数据成员
void X::f() { /* 函数体 */ } // 定义静态成员函数
2. 静态数据成员
2.1 存储持续时间
静态数据成员具有静态存储持续时间,即它们在整个程序运行期间都存在。即使没有定义类的任何对象,静态数据成员也存在。每个静态数据成员在整个程序中只有一个实例。
- 全局静态数据成员:具有外部链接,如果类本身具有外部链接(不是无名命名空间的成员)。
- 线程局部静态数据成员(自 C++11 起):使用
thread_local
关键字声明,每个线程都有一个独立的实例,具有线程存储持续时间。
示例:
class X {
static int n;
thread_local static int t;
};
int X::n = 0; // 全局静态数据成员
int X::t = 0; // 线程局部静态数据成员
2.2 初始化
- 非内联静态数据成员:必须在类定义之外进行定义,并且可以在定义时初始化。
struct X {
static int n;
};
int X::n = 1; // 定义并初始化
- 内联静态数据成员(自 C++17 起):可以在类定义中直接定义并初始化,不需要类外定义。
struct X {
inline static int fully_usable = 1; // 不需要类外定义
inline static const std::string class_name{"X"}; // 同样不需要类外定义
};
- 常量静态数据成员:如果静态数据成员是整型或枚举类型,并且声明为
const
,则可以在类定义中直接初始化。
struct X {
static const int n = 1;
static const int m{2}; // 自 C++11 起
static const int k;
};
const int X::k = 3; // 必须在类外定义
constexpr
静态数据成员(自 C++11 起):必须在类定义中使用初始化程序进行初始化,其中每个表达式都是常量表达式。
struct X {
constexpr static int arr[] = {1, 2, 3}; // OK
constexpr static std::complex<double> n = {1, 2}; // OK
constexpr static int k; // 错误:constexpr static 需要初始化
};
- ODR 使用:如果常量静态数据成员或
constexpr
静态数据成员被 ODR 使用(例如取地址),则仍然需要在命名空间作用域中进行定义,但不能使用初始化程序。
struct X {
static const int n = 1;
static constexpr int m = 4;
};
const int *p = &X::n, *q = &X::m; // X::n 和 X::m 被 ODR 使用
const int X::n; // 所以需要定义
constexpr int X::m; // 自 C++17 起不需要重新声明
3. 静态成员函数
3.1 特点
- 与对象无关:静态成员函数不与任何对象关联,因此它们没有
this
指针。 - 不能为虚函数:静态成员函数不能声明为
virtual
、const
、volatile
或引用限定的。 - 普通函数指针:静态成员函数的地址可以存储在普通函数指针中,但不能存储在成员函数指针中。
3.2 引用方式
静态成员函数可以通过以下两种方式引用:
- 限定名称:使用类名加作用域解析运算符
::
。 - 成员访问表达式:使用对象或指针访问静态成员函数,尽管静态成员函数与对象无关。
示例:
struct X {
static void f();
static int n;
};
X g() { return X(); } // 返回 X 的对象
void f() {
X::f(); // 使用限定名称
g().f(); // 使用成员访问表达式
}
int X::n = 7; // 定义静态数据成员
void X::f() {
n = 1; // X::n 在此范围内可以直接访问
}
4. 静态成员的访问控制
静态成员遵循类成员的访问控制规则(私有、受保护、公共)。根据访问控制级别,静态成员可以在类的不同部分被访问。
示例:
class X {
private:
static int private_n;
protected:
static int protected_n;
public:
static int public_n;
};
int X::private_n = 0;
int X::protected_n = 0;
int X::public_n = 0;
void someFunction() {
X::public_n = 1; // 可以访问公共静态成员
// X::private_n = 1; // 错误:不能访问私有静态成员
// X::protected_n = 1; // 错误:不能访问受保护的静态成员
}
5. 静态成员的限制
- 不能是
mutable
:静态数据成员不能声明为mutable
,因为mutable
用于允许在const
对象中修改非静态数据成员。 - 局部类和无名类:局部类(在函数内定义的类)和无名类(包括无名类的成员类)不能具有静态数据成员。
6. 内联静态数据成员(自 C++17 起)
内联静态数据成员可以在类定义中直接定义并初始化,不需要类外定义。它们隐式为 inline
,并且可以指定初始化程序。
示例:
struct X {
inline static int fully_usable = 1; // 不需要类外定义
inline static const std::string class_name{"X"}; // 同样不需要类外定义
};
7. 常量静态成员
- 整型或枚举类型的常量静态成员:如果静态数据成员是整型或枚举类型,并且声明为
const
,则可以在类定义中直接初始化。
struct X {
static const int n = 1;
static const int m{2}; // 自 C++11 起
static const int k;
};
const int X::k = 3; // 必须在类外定义
constexpr
静态成员(自 C++11 起):必须在类定义中使用初始化程序进行初始化,其中每个表达式都是常量表达式。
struct X {
constexpr static int arr[] = {1, 2, 3}; // OK
constexpr static std::complex<double> n = {1, 2}; // OK
constexpr static int k; // 错误:constexpr static 需要初始化
};
总结
- 静态成员:是与类的实例无关的成员,属于类本身。它们具有静态存储持续时间,所有对象共享同一个静态成员。
- 静态数据成员:具有静态存储持续时间,可以在类定义中声明并在类外定义。自 C++17 起,可以使用
inline
在类定义中直接定义并初始化。 - 静态成员函数:与对象无关,没有
this
指针。不能声明为virtual
、const
、volatile
或引用限定的。可以存储在普通函数指针中。 - 访问控制:静态成员遵循类成员的访问控制规则(私有、受保护、公共)。
- 限制:静态数据成员不能是
mutable
,局部类和无名类不能具有静态数据成员。
理解静态成员的规则和用法可以帮助你更好地设计类结构,编写更高效、更安全的代码。静态成员在实现单例模式、全局计数器、配置管理等功能时非常有用。正确使用静态成员可以提高代码的可读性和维护性。
C++ 嵌套类详解
在 C++ 中,嵌套类(Nested Class)是指一个类定义在另一个类的内部。嵌套类是 C++ 类系统的一个重要特性,它允许你将相关的类组织在一起,提高代码的逻辑性和封装性。嵌套类可以访问封闭类的私有和受保护成员,但除此之外,它是独立的,并且对封闭类的 this
指针没有特殊访问权限。
1. 嵌套类的基本概念
1.1 定义
嵌套类的声明和定义可以在封闭类的内部完成。嵌套类的名称存在于封闭类的作用域中,因此在使用时需要通过封闭类的作用域解析符 ::
来访问。
class Outer {
public:
class Inner {
// 内部类的成员
};
};
1.2 访问封闭类的成员
嵌套类可以访问封闭类的所有成员(包括私有和受保护成员),但需要注意的是,嵌套类本身并没有隐式的 this
指针指向封闭类的对象。因此,如果要访问非静态成员,必须通过显式的指针或引用传递封闭类的对象。
class Outer {
private:
int x;
static int s;
public:
class Inner {
public:
void f(int i) {
// x = i; // 错误:不能直接访问非静态成员 x
s = i; // 正确:可以访问静态成员 s
::x = i; // 正确:可以访问全局变量 x
y = i; // 正确:可以访问全局变量 y
}
void g(Outer* p, int i) {
p->x = i; // 正确:通过指针访问非静态成员 x
}
};
};
int Outer::s = 0;
int x = 0; // 全局变量
int y = 0; // 全局变量
2. 嵌套类的成员函数
嵌套类的成员函数可以在类定义内部或外部定义。如果在类外部定义,函数名前需要加上封闭类的作用域解析符 ::
。
struct Outer {
struct Inner {
static int x;
void f(int i);
};
};
int Outer::Inner::x = 1; // 定义静态数据成员
void Outer::Inner::f(int i) {} // 定义成员函数
3. 前向声明
嵌套类可以进行前向声明,然后在同一封闭类主体中或在封闭类主体之外进行定义。
class Outer {
class Nested1; // 前向声明
class Nested2; // 前向声明
class Nested1 {}; // 定义嵌套类
};
class Outer::Nested2 {}; // 在类外部定义嵌套类
4. 成员访问控制
嵌套类的声明遵守成员访问说明符(private
、protected
、public
)。如果嵌套类是私有的,那么它只能在封闭类的作用域内被访问,不能在类外直接命名。然而,可以通过返回嵌套类的对象或引用的方式来操作该类的对象。
class Outer {
private:
struct Nested {
void g() {}
};
public:
static Nested f() { return Nested{}; }
};
int main() {
// Outer::Nested n1 = Outer::f(); // 错误:'Nested' 是私有的
Outer::f().g(); // 正确:不直接命名 'Nested'
auto n2 = Outer::f(); // 正确:不直接命名 'Nested'
n2.g();
}
5. 友元函数
在嵌套类中定义的函数无法特别访问封闭类的成员,即使从嵌套类中定义的成员函数的函数体进行查找可以找到封闭类的私有成员。也就是说,嵌套类中的函数不会自动成为封闭类的友元函数。
class Outer {
private:
int x;
class Inner {
public:
void f() {
// x = 1; // 错误:不能直接访问封闭类的私有成员 x
}
};
};
如果你希望嵌套类中的函数能够访问封闭类的私有成员,可以显式地将该函数声明为封闭类的友元函数。
class Outer {
private:
int x;
class Inner {
public:
friend void Outer::Inner::f(); // 显式声明为友元函数
void f() {
x = 1; // 正确:现在可以访问封闭类的私有成员 x
}
};
};
6. 嵌套类的 this
指针
嵌套类的成员函数没有隐式的 this
指针指向封闭类的对象。因此,如果你想在嵌套类中访问封闭类的非静态成员,必须通过显式的指针或引用来传递封闭类的对象。
class Outer {
private:
int x;
public:
class Inner {
public:
void f(Outer* outer, int i) {
outer->x = i; // 通过指针访问封闭类的非静态成员
}
};
};
7. 嵌套类的静态成员
嵌套类可以声明和定义静态成员,这些静态成员的行为与普通类的静态成员相同。它们属于类本身,而不是类的任何特定对象。
class Outer {
public:
class Inner {
public:
static int s;
void f() {
s = 1; // 访问静态成员
}
};
};
int Outer::Inner::s = 0; // 定义静态成员
8. 嵌套类的模板
嵌套类也可以是模板类。模板嵌套类的定义和使用与其他模板类类似,唯一的区别是它们位于封闭类的作用域内。
template<typename T>
class Outer {
public:
template<typename U>
class Inner {
public:
void f(T t, U u) {
// 使用模板参数
}
};
};
int main() {
Outer<int>::Inner<double> inner;
inner.f(1, 2.0);
}
9. 缺陷报告
C++ 标准委员会已经处理了一些关于嵌套类的缺陷报告。例如:
- DR 102: 在 C++11 之前,嵌套类不能使用
sizeof
操作符来获取封闭类的非静态成员的大小,因为这涉及到未评估的操作数。自 C++11 起,sizeof
操作符的运算数是未求值的表达式,因此这种用法是合法的。
class Outer {
private:
int x;
public:
class Inner {
public:
void f() {
int a = sizeof(x); // 自 C++11 起合法
}
};
};
总结
- 嵌套类:是定义在另一个类内部的类,它的名称存在于封闭类的作用域中。
- 访问封闭类的成员:嵌套类可以访问封闭类的所有成员(包括私有和受保护成员),但需要通过显式的指针或引用来访问非静态成员。
- 成员函数:嵌套类的成员函数可以在类定义内部或外部定义,外部定义时需要使用封闭类的作用域解析符。
- 前向声明:嵌套类可以进行前向声明,然后在同一封闭类主体中或在封闭类主体之外进行定义。
- 成员访问控制:嵌套类的声明遵守成员访问说明符,私有成员类无法在封闭类作用域之外命名。
- 友元函数:嵌套类中的函数不会自动成为封闭类的友元函数,除非显式声明。
this
指针:嵌套类的成员函数没有隐式的this
指针指向封闭类的对象。- 静态成员:嵌套类可以声明和定义静态成员,这些静态成员的行为与普通类的静态成员相同。
- 模板:嵌套类也可以是模板类,模板嵌套类的定义和使用与其他模板类类似。
理解嵌套类的规则和用法可以帮助你更好地组织代码,提高代码的可读性和维护性。嵌套类在实现复杂的类层次结构、封装相关类以及提供更好的命名空间管理时非常有用。正确使用嵌套类可以增强代码的模块化和封装性。
C++ 派生类详解
在 C++ 中,派生类(Derived Class)是从一个或多个基类(Base Class)继承而来的类。通过继承,派生类可以重用基类的成员函数和数据成员,并且可以根据需要扩展或修改这些成员。C++ 支持单继承、多继承以及虚继承,形成了复杂的继承层次结构。
1. 继承语法
派生类的声明使用 class
或 struct
关键字,后面跟一个冒号 :
和一个或多个以逗号分隔的基类列表。每个基类可以带有访问说明符(public
、protected
、private
),并且可以指定为虚继承(virtual
)。
class Derived : access-specifier virtual BaseClass {
// 派生类的成员
};
access-specifier
:可以是public
、protected
或private
,用于控制基类成员在派生类中的访问权限。virtual
:用于指定虚继承,确保派生类中只有一个基类子对象实例,即使该基类在继承层次结构中多次出现。
2. 访问说明符
访问说明符决定了基类成员在派生类中的可见性:
public
:基类的公有成员在派生类中仍然是公有的,基类的受保护成员在派生类中仍然是受保护的。私有成员永远不可访问。protected
:基类的公有和受保护成员在派生类中都是受保护的。私有成员永远不可访问。private
:基类的公有和受保护成员在派生类中都是私有的。私有成员永远不可访问。
如果省略了访问说明符:
- 对于用
struct
声明的派生类,默认访问说明符是public
。 - 对于用
class
声明的派生类,默认访问说明符是private
。
示例:
class Base {
public:
int pub;
protected:
int prot;
private:
int priv;
};
class PublicDerived : public Base {
// pub 是公有的,prot 是受保护的,priv 不可访问
};
class ProtectedDerived : protected Base {
// pub 和 prot 都是受保护的,priv 不可访问
};
class PrivateDerived : private Base {
// pub 和 prot 都是私有的,priv 不可访问
};
3. 虚继承
虚继承用于解决多继承中的菱形问题,确保派生类中只有一个基类子对象实例,即使该基类在继承层次结构中多次出现。
struct B { int n; };
struct X : virtual B {};
struct Y : virtual B {};
struct Z : B {};
struct AA : X, Y, Z {
AA() {
X::n = 1; // 修改虚基类 B 的成员
Y::n = 2; // 修改同一个虚基类 B 的成员
Z::n = 3; // 修改非虚基类 B 的成员
std::cout << X::n << Y::n << Z::n << '\n'; // 输出 223
}
};
虚基类的构造函数由最派生类的构造函数调用,而不是由中间派生类调用。因此,在成员初始化列表中,最派生类必须显式调用虚基类的构造函数。
示例:
struct B {
int n;
B(int x) : n(x) {}
};
struct X : virtual B { X() : B(1) {} };
struct Y : virtual B { Y() : B(2) {} };
struct AA : X, Y { AA() : B(3), X(), Y() {} };
AA a; // a.n == 3
X x; // x.n == 1
4. 公有继承
公有继承是最常见的继承方式,它模拟了面向对象编程中的子类型关系(IS-A 关系)。派生类可以访问基类的公有和受保护成员,并且可以通过指针或引用来将派生类对象视为基类对象。
示例:
#include <iostream>
#include <string>
#include <vector>
struct MenuOption { std::string title; };
class Menu : public std::vector<MenuOption> {
public:
std::string title;
void print() const {
std::cout << title << ":\n";
for (std::size_t i = 0, s = size(); i < s; ++i)
std::cout << " " << (i + 1) << ". " << at(i).title << '\n';
}
};
int main() {
Menu menu;
menu.title = "Main Menu";
menu.push_back({"Option 1"});
menu.push_back({"Option 2"});
menu.print();
}
然而,公有继承也存在一些潜在的问题。例如,当派生类依赖于基类的具体实现时,可能会导致脆弱的继承关系。为了避免这些问题,应该遵循里氏替换原则(LSP),即派生类对象应该能够替换基类对象而不影响程序的正确性。
5. 受保护继承
受保护继承使得基类的公有和受保护成员在派生类中成为受保护成员。这种方式通常用于实现“受控多态性”,即派生类可以在其成员函数中访问基类的成员,但外部代码不能直接访问这些成员。
示例:
class Base {
protected:
int value;
};
class Derived : protected Base {
public:
void set_value(int v) {
value = v; // 受保护成员可以访问
}
};
int main() {
Derived d;
d.set_value(42); // 正确
// d.value = 42; // 错误:value 是受保护的
}
6. 私有继承
私有继承使得基类的公有和受保护成员在派生类中成为私有成员。这种方式通常用于实现基于策略的设计模式,或者用于实现组合关系,其中基类子对象是派生类对象的实现细节。
示例:
template<typename Transport>
class Service : private Transport {
public:
void transmit() {
this->send(...); // 调用基类的 send 函数
}
};
class TCP {
public:
void send(...) { /* 实现发送逻辑 */ }
};
class UDP {
public:
void send(...) { /* 实现发送逻辑 */ }
};
Service<TCP> tcp_service;
tcp_service.transmit(); // 使用 TCP 传输
私有继承还可以用于实现组合关系,尤其是在需要访问基类的受保护成员或虚成员时。与使用成员对象相比,私有继承可以利用空基优化(Empty Base Optimization, EBO),从而减少派生类对象的大小。
7. 成员名称查找
在派生类中,成员名称查找遵循以下规则:
- 非限定名称查找:首先在当前类的作用域中查找,然后依次向上查找基类的作用域。对于虚基类,查找会跳过中间派生类,直接查找最派生类的虚基类子对象。
- 限定名称查找:使用作用域解析运算符
::
明确指定要查找的类,避免名称冲突。
示例:
class Base {
public:
void f() { std::cout << "Base::f\n"; }
};
class Derived : public Base {
public:
void f() { std::cout << "Derived::f\n"; }
void g() {
f(); // 调用 Derived::f
Base::f(); // 调用 Base::f
}
};
int main() {
Derived d;
d.g();
}
8. 缺陷报告
C++ 标准委员会已经处理了一些关于继承的缺陷报告。例如:
- DR 102:在 C++11 之前,嵌套类不能使用
sizeof
操作符来获取封闭类的非静态成员的大小,因为这涉及到未评估的操作数。自 C++11 起,sizeof
操作符的运算数是未求值的表达式,因此这种用法是合法的。
总结
- 继承:C++ 支持单继承、多继承和虚继承,形成了复杂的继承层次结构。
- 访问说明符:
public
、protected
和private
控制基类成员在派生类中的可见性。 - 虚继承:用于解决多继承中的菱形问题,确保派生类中只有一个基类子对象实例。
- 公有继承:模拟子类型关系,派生类可以访问基类的公有和受保护成员。
- 受保护继承:用于实现受控多态性,派生类可以在其成员函数中访问基类的成员,但外部代码不能直接访问。
- 私有继承:用于实现基于策略的设计模式或组合关系,基类子对象是派生类对象的实现细节。
- 成员名称查找:遵循非限定和限定名称查找规则,确保正确的成员访问。
理解继承的规则和用法可以帮助你更好地设计类层次结构,编写更灵活、更安全的代码。正确使用继承可以提高代码的可读性和维护性,同时避免常见的陷阱和问题。
C++ using
声明详解
在 C++ 中,using
声明(using declaration
)用于将另一个作用域中的名称引入当前作用域。它可以用于引入命名空间成员、基类成员、枚举器等。using
声明可以简化代码,减少重复,并提高代码的可读性和灵活性。
1. using
声明的基本语法
using typename(可选) 嵌套命名空间说明符 非限定标识符;
typename
:关键字typename
可用于解析依赖名称,尤其是在类模板中从基类引入成员类型时。嵌套命名空间说明符
:名称和作用域解析运算符::
的序列,以作用域解析运算符结束。单个::
指向全局命名空间。非限定标识符
:一个id-表达式
,通常是类名、函数名、变量名或枚举器。
自 C++17 起,using
声明可以包含多个声明符,形成一个逗号分隔的列表:
using 声明符列表;
2. 在命名空间和块作用域中的使用
using
声明可以将另一个命名空间的成员引入当前命名空间或块作用域。这有助于避免频繁使用作用域解析运算符 ::
,使代码更加简洁。
示例:
#include <iostream>
#include <string>
using std::string; // 引入 std::string
int main() {
string str = "Example"; // 不需要使用 std:: 前缀
using std::cout; // 引入 std::cout
cout << str; // 不需要使用 std:: 前缀
}
3. 在类定义中的使用
using
声明可以将基类的成员引入派生类定义中。这通常用于将基类的受保护成员公开为派生类的公有成员,或将基类的重载成员函数引入派生类。
示例:
#include <iostream>
struct B {
virtual void f(int) { std::cout << "B::f\n"; }
void g(char) { std::cout << "B::g\n"; }
void h(int) { std::cout << "B::h\n"; }
protected:
int m; // B::m 是受保护的
typedef int value_type;
};
struct D : B {
using B::m; // D::m 是公有的
using B::value_type; // D::value_type 是公有的
using B::f;
void f(int) override { std::cout << "D::f\n"; } // D::f(int) 覆盖 B::f(int)
using B::g;
void g(int) { std::cout << "D::g\n"; } // 两个 g 函数都可见
using B::h;
void h(int) { std::cout << "D::h\n"; } // D::h(int) 隐藏 B::h(int)
};
int main() {
D d;
B& b = d;
// b.m = 2; // 错误:B::m 是受保护的
d.m = 1; // B::m 作为 D::m 是公有的
b.f(1); // 调用派生类的 f()
d.f(1); // 调用派生类的 f()
std::cout << "----------\n";
d.g(1); // 调用派生类的 g(int)
d.g('a'); // 调用基类的 g(char),通过 using B::g 引入
std::cout << "----------\n";
b.h(1); // 调用基类的 h()
d.h(1); // 调用派生类的 h()
}
输出:
D::f
D::f
----------
D::g
B::g
----------
B::h
D::h
4. 继承构造函数
从 C++11 开始,using
声明可以用于继承基类的构造函数。这意味着派生类可以使用基类的所有构造函数来初始化基类子对象,而不需要显式地定义这些构造函数。
示例:
struct B1 {
B1(int, ...) {}
};
struct D1 : B1 {
using B1::B1; // 继承 B1(int, ...)
int x;
int y = get();
};
void test() {
D1 d(2, 3, 4); // OK: B1 初始化为 B1(2, 3, 4),然后 d.x 默认初始化,d.y 初始化为 get()
D1 e; // 错误:D1 没有默认构造函数
}
继承构造函数的注意事项:
- 如果派生类已经定义了与基类构造函数签名匹配的构造函数,则继承的构造函数将被隐藏。
- 继承构造函数不会阻止派生类隐式生成复制/移动构造函数。
- 如果基类是虚基类,并且派生类不是最派生类,则继承构造函数的调用将被省略。
示例:
struct V {
V(int);
};
struct X : virtual V {
using V::V;
X() = delete;
};
struct Y : X {
using X::X;
};
struct Z : Y, virtual V {
using Y::Y;
};
Z z(0); // OK: 初始化 Y 时不调用 X 的构造函数,因为 V 是虚基类
5. 引入作用域枚举器
从 C++20 开始,using
声明还可以用于将枚举器引入命名空间、块和类作用域。这对于作用域枚举(enum class
)特别有用,因为它允许你直接使用枚举器,而不需要每次都使用枚举类型的前缀。
示例:
enum class button { up, down };
struct S {
using button::up;
button b = up; // OK
};
using button::down;
constexpr button non_up = down; // OK
constexpr auto get_button(bool is_up) {
using button::up, button::down;
return is_up ? up : down; // OK
}
enum unscoped { val };
using unscoped::val; // OK,虽然无意义
6. 包扩展
在 C++17 中,using
声明可以用于包展开(pack expansion),这在处理可变参数模板时非常有用。例如,可以通过 using
声明将多个基类的成员函数引入派生类。
示例:
template<typename... Ts>
struct Overloader : Ts... {
using Ts::operator()...; // 将每个基类的 operator() 引入派生类
};
template<typename... T>
Overloader(T...) -> Overloader<T...>; // C++17 deduction guide
int main() {
auto o = Overloader{
[] (auto const& a) { std::cout << a; },
[] (float f) { std::cout << std::setprecision(3) << f; }
};
}
7. 注意事项
using
声明不能引用命名空间、作用域枚举器(直到 C++20)、基类的析构函数或用户定义的转换函数的成员模板特化。using
声明不能命名成员模板特化(模板 ID 不允许使用语法)。using
声明不能用于将依赖成员模板的名称引入为模板名称(依赖名称的template
消歧符是不允许的)。
示例:
struct B {
template<class T>
void f();
};
struct D : B {
using B::f; // OK: 名称是一个模板
// using B::f<int>; // 错误:名称是一个模板特化
void g() { f<int>(); }
};
8. 缺陷报告
C++ 标准委员会已经处理了一些关于 using
声明的缺陷报告。例如:
- DR 102:在 C++11 之前,嵌套类不能使用
sizeof
操作符来获取封闭类的非静态成员的大小,因为这涉及到未评估的操作数。自 C++11 起,sizeof
操作符的运算数是未求值的表达式,因此这种用法是合法的。
总结
using
声明:用于将另一个作用域中的名称引入当前作用域,简化代码并提高可读性。- 命名空间和块作用域:可以引入命名空间成员,避免频繁使用作用域解析运算符。
- 类定义:可以引入基类成员,将受保护成员公开为公有成员,或将基类的重载成员函数引入派生类。
- 继承构造函数:从 C++11 开始,可以继承基类的构造函数,简化派生类的构造函数定义。
- 引入作用域枚举器:从 C++20 开始,可以引入枚举器,使代码更加简洁。
- 包扩展:在 C++17 中,可以用于包展开,处理可变参数模板。
理解 using
声明的规则和用法可以帮助你更好地组织代码,减少冗余,并提高代码的可维护性。正确使用 using
声明可以增强代码的模块化和封装性,同时避免常见的陷阱和问题。
空基类优化 (Empty Base Optimization, EBO)
在 C++ 中,空基类优化(EBO)是一种编译器优化技术,它允许派生类对象不为从空类(即没有非静态数据成员的类)继承的基类子对象分配额外的存储空间。这种优化可以显著减少派生类对象的大小,尤其是在涉及多个空基类或多层继承的情况下。
1. 基本原理
根据 C++ 标准,任何对象或成员子对象的尺寸都必须至少为 1 字节,以确保不同对象的地址始终不同。然而,对于基类子对象,这一限制并不适用。编译器可以在对象布局中完全省略空基类子对象,从而实现空基类优化。
示例:
struct Base {}; // 空类
struct Derived1 : Base {
int i;
};
int main() {
// 空类的大小至少为 1 字节
static_assert(sizeof(Base) >= 1);
// 空基类优化应用,派生类的大小等于 int 的大小
static_assert(sizeof(Derived1) == sizeof(int));
}
2. 优化条件
空基类优化的应用取决于以下几个条件:
- 空基类:基类必须是空类,即没有非静态数据成员。
- 派生类的第一个非静态数据成员:如果空基类不是派生类的第一个非静态数据成员的类型或其基类,则可以应用优化。
- 多重继承:在多重继承的情况下,具体优化行为可能因编译器而异。例如:
- MSVC:仅对最后一个空基类应用优化,其余的空基类会分配 1 字节。
- GCC 和 Clang:无论存在多少个空基类,都会应用优化,不分配任何空间,所有空基类的地址与派生类对象的第一个地址相同。
示例:
struct Base {}; // 空类
struct Derived1 : Base {
int i;
};
struct Derived2 : Base {
Base c; // Base 占用 1 字节,后面有填充以满足 int 对齐要求
int i;
};
struct Derived3 : Base {
Derived1 c; // Derived1 占用 sizeof(int) 字节
int i;
};
int main() {
// 空基类优化应用,派生类的大小等于 int 的大小
static_assert(sizeof(Derived1) == sizeof(int));
// 空基类优化不应用,Base 占用 1 字节,后面有填充
static_assert(sizeof(Derived2) == 2 * sizeof(int));
// 空基类优化不应用,Base 占用 1 字节,后面有填充
static_assert(sizeof(Derived3) == 3 * sizeof(int));
}
3. [[no_unique_address]]
属性 (自 C++20 起)
自 C++20 起,C++ 引入了 [[no_unique_address]]
属性,允许编译器优化掉空成员子对象,类似于空基类优化。使用该属性后,获取此类成员的地址可能会返回与同一对象中其他成员相同的地址。
示例:
struct Empty {}; // 空类
struct X {
int i;
[[no_unique_address]] Empty e;
};
int main() {
// 空类的大小至少为 1 字节
static_assert(sizeof(Empty) >= 1);
// 空成员优化掉,派生类的大小等于 int 的大小
static_assert(sizeof(X) == sizeof(int));
}
4. 标准布局类型 (Standard Layout Types)
空基类优化对于标准布局类型(Standard Layout Types)是必需的,以保持通过 reinterpret_cast
转换的指向标准布局对象的指针指向其初始成员的要求。标准布局类型要求包括:
- 所有非静态数据成员在同一个类中声明(都在派生类中或都在某个基类中)。
- 没有与第一个非静态数据成员类型相同的基类。
这些要求确保了空基类优化不会破坏标准布局类型的内存布局规则。
5. 实际应用
空基类优化广泛应用于标准库中,特别是在涉及无状态分配器(如 std::vector
、std::function
、std::shared_ptr
等)的情况下。通过将分配器与必要的数据成员(如 std::vector
的 begin
、end
或 capacity
指针)组合在一起,可以避免为分配器占用额外的存储空间。
示例:
#include <memory>
#include <vector>
struct EmptyAllocator {
template <typename T>
struct rebind {
using other = EmptyAllocator;
};
void* allocate(std::size_t) { return nullptr; }
void deallocate(void*, std::size_t) {}
};
// 使用无状态分配器的 vector
std::vector<int, EmptyAllocator> vec;
int main() {
// 由于空基类优化,vector 不会为分配器占用额外的存储空间
static_assert(sizeof(vec) == sizeof(int*) * 3); // begin, end, capacity
}
6. 编译器支持
不同的编译器对空基类优化的支持有所不同:
- GCC 和 Clang:通常会对所有空基类应用优化,不分配任何空间。
- MSVC:仅对最后一个空基类应用优化,其余的空基类会分配 1 字节。
因此,在编写跨平台代码时,应该考虑到不同编译器的行为差异。
7. 注意事项
- 空基类优化:只有当基类是空类时才会应用。如果基类包含非静态数据成员,则不会应用优化。
[[no_unique_address]]
:自 C++20 起引入,允许编译器优化掉空成员子对象,但需要注意获取此类成员的地址可能会返回与同一对象中其他成员相同的地址。- 标准布局类型:空基类优化对于标准布局类型是必需的,以保持内存布局的正确性。
总结
空基类优化是 C++ 编译器的一种重要优化技术,可以显著减少派生类对象的大小,尤其是在涉及多个空基类或多层继承的情况下。通过合理利用空基类优化和 [[no_unique_address]]
属性,可以编写更高效、更紧凑的代码。理解空基类优化的工作原理和限制有助于编写更好的 C++ 代码,尤其是在设计复杂的类层次结构时。
C++ 虚函数说明符 virtual
在 C++ 中,virtual
说明符用于指定类的非静态成员函数是虚函数(virtual function)。虚函数支持动态多态性,即在运行时根据对象的实际类型来决定调用哪个版本的函数。这使得派生类可以重写基类中的虚函数,并且通过基类的指针或引用来调用这些重写后的函数。
1. 基本概念
- 虚函数:虚函数是在基类中声明为
virtual
的成员函数,允许派生类重写其行为。 - 动态调度:当通过基类的指针或引用调用虚函数时,实际调用的是最派生类中定义的版本,即使编译时只知道基类的类型。
- 重写:派生类中与基类虚函数具有相同名称、参数列表、cv 限定符和引用限定符的函数会自动成为虚函数,并重写基类的虚函数。
2. 语法
class Base {
public:
virtual void f(); // 声明为虚函数
};
class Derived : public Base {
public:
void f() override; // 重写基类的虚函数
};
virtual
关键字只能出现在类定义中首次声明虚函数时。override
是一个可选的关键字,用于显式表示派生类中的函数意在重写基类的虚函数。如果使用了override
,但函数并没有重写任何虚函数,则会导致编译错误。
3. 动态调度示例
#include <iostream>
struct Base {
virtual void f() {
std::cout << "base\n";
}
};
struct Derived : Base {
void f() override {
std::cout << "derived\n";
}
};
int main() {
Base b;
Derived d;
// 通过引用调用虚函数
Base& br = b;
Base& dr = d;
br.f(); // 输出 "base"
dr.f(); // 输出 "derived"
// 通过指针调用虚函数
Base* bp = &b;
Base* dp = &d;
bp->f(); // 输出 "base"
dp->f(); // 输出 "derived"
// 非虚函数调用
br.Base::f(); // 输出 "base"
dr.Base::f(); // 输出 "base"
}
4. 重写规则
-
匹配条件:派生类中的函数只有在以下条件都满足时才会重写基类的虚函数:
- 名称相同
- 参数类型列表相同(不包括返回类型)
- cv 限定符相同
- 引用限定符相同
-
私有继承和私有成员:基类的虚函数可以是私有的,或者类可以通过私有继承从基类派生。在这种情况下,派生类仍然可以重写基类的虚函数,即使它不可见或不可访问。
class B {
private:
virtual void do_f(); // 私有虚函数
public:
void f() { do_f(); } // 公共接口
};
struct D : public B {
void do_f() override; // 重写 B::do_f
};
int main() {
D d;
B* bp = &d;
bp->f(); // 内部调用 D::do_f()
}
5. 最终重写器
对于每个虚函数,存在一个最终重写器(final overrider),它是在进行虚函数调用时实际执行的函数。如果派生类没有引入新的重写器,则基类的虚函数或更早的重写器将成为最终重写器。
struct A { virtual void f(); };
struct B : A { void f(); }; // B::f 重写 A::f
struct C : virtual B { void f(); }; // C::f 重写 A::f
struct D : virtual B {}; // D 没有引入重写器,B::f 是最终重写器
struct E : C, D { // E 没有引入重写器,C::f 是最终重写器
using A::f; // 只是使 A::f 可见于查找,不是函数声明
};
int main() {
E e;
e.f(); // 虚函数调用,调用 C::f
e.E::f(); // 非虚函数调用,调用 A::f
}
6. 多重继承中的问题
如果一个虚函数在多个基类中被重写,而派生类没有引入新的重写器,则程序是不合法的,因为存在多个最终重写器。
struct A {
virtual void f();
};
struct VB1 : virtual A {
void f(); // 重写 A::f
};
struct VB2 : virtual A {
void f(); // 重写 A::f
};
// struct Error : VB1, VB2 {
// // 错误:A::f 有两个最终重写器
// };
struct Okay : VB1, VB2 {
void f(); // OK:这是 A::f 的最终重写器
};
7. 函数隐藏
如果派生类中有一个与基类虚函数同名但参数列表不同的函数,则不会重写基类的虚函数,而是隐藏它。这意味着在派生类中通过非限定名称查找时,只会找到派生类的函数,而不会继续查找基类中的同名函数。
struct B {
virtual void f();
};
struct D : B {
void f(int); // 隐藏 B::f
};
struct D2 : D {
void f(); // 重写 B::f
};
int main() {
B b;
B& b_as_b = b;
D d;
B& d_as_b = d;
D& d_as_d = d;
D2 d2;
B& d2_as_b = d2;
D& d2_as_d = d2;
b_as_b.f(); // 调用 B::f
d_as_b.f(); // 调用 B::f
d2_as_b.f(); // 调用 D2::f
d_as_d.f(); // 错误:查找 D 找到 f(int),而不是 f()
d2_as_d.f(); // 错误:查找 D 找到 f(int),而不是 f()
}
8. override
和 final
说明符
-
override
:用于显式表示派生类中的函数意在重写基类的虚函数。如果函数没有重写任何虚函数,则会导致编译错误。struct B { virtual void f(int); }; struct D : B { virtual void f(int) override; // OK virtual void f(long) override; // 错误:f(long) 没有重写 B::f(int) };
-
final
:用于禁止进一步的重写。如果另一个函数试图重写带有final
的虚函数,则会导致编译错误。struct B { virtual void f() const final; }; struct D : B { void f() const; // 错误:D::f 尝试重写 final 的 B::f };
9. 协变返回类型
派生类中的虚函数可以有不同的返回类型,只要这些返回类型是协变的。两个类型是协变的,如果它们满足以下条件:
- 两种类型都是指向类的指针或引用(左值或右值)。
- 基类的返回类型是指向派生类返回类型的基类。
- 派生类的返回类型的 cv 限定符不多于基类的返回类型。
- 派生类的返回类型必须是派生类本身或在声明点已经是完整类型。
class B {};
class D : public B {};
struct Base {
virtual B* vf4();
};
struct Derived : public Base {
D* vf4() override; // 返回类型是协变的
};
int main() {
Derived d;
Base& br = d;
Derived& dr = d;
B* p = br.vf4(); // 调用 Derived::vf4() 并将结果隐式转换为 B*
D* q = dr.vf4(); // 调用 Derived::vf4(),不进行转换
}
10. 虚析构函数
虚析构函数确保可以通过基类的指针删除派生类对象时,正确调用派生类的析构函数,从而避免资源泄漏。
class Base {
public:
virtual ~Base() { /* 释放 Base 的资源 */ }
};
class Derived : public Base {
public:
~Derived() { /* 释放 Derived 的资源 */ }
};
int main() {
Base* b = new Derived;
delete b; // 虚函数调用 Base::~Base(),实际调用 Derived::~Derived()
}
如果基类的析构函数不是虚函数,则通过基类指针删除派生类对象的行为是未定义的,可能会导致资源泄漏或其他问题。
11. 构造和析构期间的虚函数调用
在构造和析构期间,虚函数调用的行为有所不同。当在一个类的构造函数或析构函数中调用虚函数时,调用的是该类及其基类中的最终覆盖者,而不是更派生类中的重写版本。这是因为此时更派生类的部分尚未完全构造或已经销毁。
struct V {
virtual void f();
virtual void g();
};
struct A : virtual V {
virtual void f();
};
struct B : virtual V {
virtual void g();
B(V*, A*);
};
struct D : A, B {
virtual void f();
virtual void g();
D() : B((A*) this, this) {}
};
B::B(V* v, A* a) {
f(); // 调用 V::f,因为 D 还不存在
g(); // 调用 B::g,因为 B 是当前正在构造的类
v->g(); // 调用 B::g,v 的类型是 V,它是 B 的基类
a->f(); // 未定义行为,a 的类型是 A,属于不同分支
}
12. 总结
- 虚函数:用于实现动态多态性,允许派生类重写基类中的函数。
- 动态调度:通过基类的指针或引用来调用虚函数时,实际调用的是最派生类中定义的版本。
- 重写规则:派生类中的函数必须与基类虚函数具有相同的名称、参数列表、cv 限定符和引用限定符才能重写。
- 最终重写器:每个虚函数都有一个最终重写器,它是实际执行的函数。
- 函数隐藏:派生类中同名但不同参数列表的函数会隐藏基类的虚函数。
override
和final
:用于显式表示重写和禁止进一步重写。- 协变返回类型:派生类的虚函数可以有不同的返回类型,只要它们是协变的。
- 虚析构函数:确保通过基类指针删除派生类对象时,正确调用派生类的析构函数。
- 构造和析构期间:虚函数调用只限于当前正在构造或析构的类及其基类。
理解虚函数的工作原理和使用规则是编写高效、安全的 C++ 代码的关键,尤其是在涉及复杂的类层次结构时。
C++ 抽象类 (Abstract Class)
在 C++ 中,抽象类 是一种不能被实例化的类,但可以作为其他类的基类。抽象类通常用于定义接口或通用行为,而具体的实现则由派生类提供。抽象类的关键特性是它包含至少一个 纯虚函数(pure virtual function),即没有具体实现的虚函数。
1. 纯虚函数 (Pure Virtual Function)
纯虚函数是通过在函数声明后添加 = 0
来定义的。纯虚函数的作用是强制派生类必须提供该函数的具体实现,否则派生类也将成为抽象类。
struct Base {
virtual void f() = 0; // 纯虚函数
};
- 纯虚函数:声明为
virtual
并且在声明符后加上= 0
。 - 纯虚函数可以有定义:虽然纯虚函数没有默认实现,但可以在类外提供定义。这允许派生类在必要时调用基类的纯虚函数。
- 析构函数可以是纯虚函数:如果基类的析构函数是纯虚函数,则必须为其提供定义,因为派生类的对象销毁时需要调用基类的析构函数。
2. 抽象类的定义
一个类如果定义或继承了至少一个纯虚函数,那么这个类就是抽象类。抽象类不能直接实例化,但可以通过指针或引用来引用派生类的对象。
struct Abstract {
virtual void f() = 0; // 纯虚函数
};
struct Concrete : Abstract {
void f() override {} // 实现纯虚函数
};
int main() {
// Abstract a; // 错误:不能实例化抽象类
Concrete b; // OK
Abstract& a = b; // OK:可以引用抽象类
a.f(); // 调用 Concrete::f()
}
3. 纯虚函数的定义
虽然纯虚函数没有默认实现,但可以在类外提供定义。派生类可以通过限定名称来调用基类的纯虚函数。
struct Abstract {
virtual void f() = 0; // 纯虚函数
virtual void g() {} // 非纯虚函数
~Abstract() {
g(); // OK:调用 Abstract::g()
// f(); // 未定义行为:虚调用纯虚函数
Abstract::f(); // OK:非虚调用纯虚函数
}
};
void Abstract::f() { // 纯虚函数的定义
std::cout << "A::f()\n";
}
struct Concrete : Abstract {
void f() override {
Abstract::f(); // OK:调用纯虚函数
}
void g() override {}
~Concrete() {
g(); // OK:调用 Concrete::g()
f(); // OK:调用 Concrete::f()
}
};
4. 抽象类的特性
-
不能实例化:抽象类不能直接创建对象。尝试实例化抽象类会导致编译错误。
Abstract a; // 错误:不能实例化抽象类
-
可以有派生类:派生类可以继承抽象类,并通过实现所有纯虚函数来使其成为具体类。
struct Concrete : Abstract { void f() override {} // 实现纯虚函数 }; Concrete c; // OK
-
可以有非纯虚函数:抽象类可以包含非纯虚函数,这些函数可以有默认实现。
struct Abstract { virtual void f() = 0; // 纯虚函数 virtual void g() {} // 非纯虚函数 };
-
可以有非虚函数:抽象类也可以包含非虚函数,这些函数的行为不会被派生类重写。
struct Abstract { void h() {} // 非虚函数 };
-
可以有纯虚析构函数:如果基类的析构函数是纯虚函数,则必须为其提供定义,以确保派生类的对象能够正确销毁。
struct Abstract { virtual ~Abstract() = 0; }; Abstract::~Abstract() {} // 必须提供定义
5. 抽象类的使用场景
抽象类通常用于定义接口或通用行为,而具体的实现则由派生类提供。常见的应用场景包括:
-
接口类:定义一组纯虚函数,要求派生类实现这些函数。
struct Shape { virtual double area() const = 0; // 纯虚函数 virtual double perimeter() const = 0; // 纯虚函数 }; struct Circle : Shape { double radius; Circle(double r) : radius(r) {} double area() const override { return 3.14 * radius * radius; } double perimeter() const override { return 2 * 3.14 * radius; } };
-
多态性:通过基类的指针或引用来调用派生类的成员函数,实现动态绑定。
Shape* shape = new Circle(5); std::cout << "Area: " << shape->area() << "\n"; // 动态绑定,调用 Circle::area()
6. 注意事项
-
构造函数和析构函数中的虚调用:在构造函数或析构函数中对纯虚函数进行虚调用是未定义行为。这是因为此时对象尚未完全构造或已经部分销毁,导致虚函数表可能不完整。
struct Abstract { virtual void f() = 0; Abstract() { f(); // 未定义行为:虚调用纯虚函数 } ~Abstract() { f(); // 未定义行为:虚调用纯虚函数 } };
-
解决方案:在构造函数或析构函数中使用非虚调用(通过限定名称)来调用纯虚函数。
struct Abstract { virtual void f() = 0; Abstract() { Abstract::f(); // OK:非虚调用 } ~Abstract() { Abstract::f(); // OK:非虚调用 } };
-
-
多重继承中的纯虚函数:如果一个类从多个基类继承了同一个纯虚函数,派生类必须显式地重写该函数,否则该类仍然是抽象类。
struct A { virtual void f() = 0; }; struct B { virtual void f() = 0; }; struct C : A, B { void f() override {} // 必须重写 f() };
7. 总结
- 抽象类 是不能实例化的类,通常用于定义接口或通用行为。
- 纯虚函数 是没有默认实现的虚函数,派生类必须提供具体实现。
- 抽象类可以有非纯虚函数 和非虚函数,这些函数可以有默认实现。
- 析构函数可以是纯虚函数,但必须为其提供定义。
- 不能在构造函数或析构函数中虚调用纯虚函数,否则会导致未定义行为。
- 抽象类的主要用途 是定义接口或通用行为,派生类负责提供具体实现。
理解抽象类和纯虚函数的工作原理是编写高效、安全的 C++ 代码的关键,尤其是在设计复杂的类层次结构时。抽象类提供了一种强大的机制,用于定义接口和强制派生类实现特定的行为。
C++ override
和 final
说明符
从 C++11 开始,C++ 引入了两个新的说明符:override
和 final
。这两个说明符用于增强虚函数的声明和使用,提供更强的编译时检查,并帮助开发者避免常见的错误。
1. override
说明符
override
说明符用于显式表示一个成员函数意在重写基类中的虚函数。如果该函数并没有重写任何基类的虚函数,则会导致编译错误。这有助于捕获那些可能由于拼写错误或其他原因导致的未正确重写的函数。
语法
class Derived : public Base {
public:
void f() override; // 表示 f() 意在重写 Base::f()
};
override
必须紧跟在成员函数声明或定义的声明符之后。override
可以与virtual
一起使用,但不是必须的,因为派生类中的函数如果匹配基类的虚函数签名,会自动成为虚函数。
示例
#include <iostream>
struct Base {
virtual void foo();
void bar(); // 非虚函数
};
void Base::foo() { std::cout << "Base::foo()\n"; }
void Base::bar() { std::cout << "Base::bar()\n"; }
struct Derived : Base {
void foo() override; // 正确:重写 Base::foo()
// void bar() override; // 错误:Base::bar() 不是虚函数
};
void Derived::foo() { std::cout << "Derived::foo()\n"; }
int main() {
Derived d;
d.foo(); // 调用 Derived::foo()
d.bar(); // 调用 Base::bar()
}
输出
Derived::foo()
Base::bar()
注意事项
-
签名匹配:
override
函数的名称、参数类型列表、cv 限定符和引用限定符必须与基类的虚函数完全匹配。否则会导致编译错误。struct Base { virtual void foo(int); }; struct Derived : Base { void foo(long) override; // 错误:签名不匹配 };
-
非虚函数:
override
不能应用于非虚函数,否则会导致编译错误。struct Base { void bar(); // 非虚函数 }; struct Derived : Base { void bar() override; // 错误:Base::bar() 不是虚函数 };
-
纯虚函数:
override
也可以用于重写纯虚函数。struct Base { virtual void foo() = 0; }; struct Derived : Base { void foo() override; // 正确:重写纯虚函数 };
-
析构函数:
override
可以应用于虚析构函数。struct Base { virtual ~Base() {} }; struct Derived : Base { ~Derived() override; // 正确:重写虚析构函数 };
-
override
作为标识符:override
在成员函数声明中具有特殊含义,但在其他上下文中(如变量名或普通函数名)可以正常使用。struct B { void override() {} // OK:普通成员函数 }; int override = 42; // OK:普通变量
2. final
说明符
final
说明符用于防止虚函数被进一步重写,或者防止类被继承。它提供了更强的封装性,确保某些类或函数的行为不会被意外修改。
语法
-
应用于虚函数:
class Base { public: virtual void foo() final; // 禁止派生类重写 foo() };
-
应用于类:
class FinalClass final { }; // 禁止从 FinalClass 派生
-
final
可以与override
一起使用:class A { public: virtual void foo(); }; class B : public A { public: void foo() final override; // 重写并禁止进一步重写 };
示例
#include <iostream>
struct Base {
virtual void foo();
};
void Base::foo() { std::cout << "Base::foo()\n"; }
struct A : Base {
void foo() final; // 禁止进一步重写 foo()
};
void A::foo() { std::cout << "A::foo()\n"; }
struct B final : A {}; // B 是 final 类,不能被继承
// struct C : B {}; // 错误:B 是 final 类
struct D : A {
void foo() override; // 错误:A::foo() 是 final 的
};
void D::foo() { std::cout << "D::foo()\n"; }
int main() {
A a;
a.foo(); // 调用 A::foo()
// B b; // OK
// C c; // 错误:B 是 final 类
// D d; // 错误:A::foo() 是 final 的
}
输出
A::foo()
注意事项
-
虚函数:
final
可以应用于虚函数,禁止派生类进一步重写该函数。struct Base { virtual void foo() final; // 禁止重写 }; struct Derived : Base { void foo() override; // 错误:Base::foo() 是 final 的 };
-
非虚函数:
final
不能应用于非虚函数,否则会导致编译错误。struct Base { void bar() final; // 错误:bar 不是虚函数 };
-
类:
final
可以应用于类,禁止从该类派生。struct FinalClass final {}; struct Derived : FinalClass {}; // 错误:FinalClass 是 final 类
-
联合体:
final
也可以应用于联合体,但在 C++14 中没有实际效果(除了对std::is_final
的结果有影响)。union FinalUnion final {};
-
final
作为标识符:final
在成员函数声明或类头部中具有特殊含义,但在其他上下文中(如变量名或普通函数名)可以正常使用。struct B final { void final() {} // OK:普通成员函数 }; int final = 42; // OK:普通变量
3. override
和 final
的组合使用
override
和 final
可以组合使用,以明确表示某个函数既重写了基类的虚函数,又禁止进一步重写。
struct Base {
virtual void foo();
};
struct A : Base {
void foo() override final; // 重写并禁止进一步重写
};
struct B : A {
void foo() override; // 错误:A::foo() 是 final 的
};
4. 总结
override
:用于显式表示一个成员函数意在重写基类中的虚函数。如果该函数并没有重写任何基类的虚函数,则会导致编译错误。这有助于捕获那些可能由于拼写错误或其他原因导致的未正确重写的函数。final
:用于防止虚函数被进一步重写,或者防止类被继承。它提供了更强的封装性,确保某些类或函数的行为不会被意外修改。- 组合使用:
override
和final
可以组合使用,以明确表示某个函数既重写了基类的虚函数,又禁止进一步重写。
使用 override
和 final
说明符可以提高代码的可读性和安全性,帮助开发者更好地管理类层次结构中的虚函数重写行为。
C++ 访问说明符 (Access Specifiers)
在 C++ 中,访问说明符用于控制类或结构体中成员(包括数据成员和成员函数)的访问权限。它们决定了哪些代码可以在类外部访问这些成员,哪些只能在类内部或派生类中访问。C++ 提供了三种访问说明符:public
、protected
和 private
。
1. 访问说明符的语法
访问说明符可以应用于类或结构体的成员声明,以及基类的继承方式。
-
成员访问说明符:
public:
:后续成员具有公有访问权限。protected:
:后续成员具有保护访问权限。private:
:后续成员具有私有访问权限。
-
基类访问说明符:
public 基类
:公有继承。protected 基类
:保护继承。private 基类
:私有继承。
2. 成员访问说明符的作用
访问说明符决定了类的成员在不同上下文中的可访问性:
public
:公有成员可以在类外部、派生类中以及通过类的对象直接访问。protected
:保护成员只能在类内部、派生类中以及通过类的友元访问,不能通过类的对象直接访问。private
:私有成员只能在类内部以及通过类的友元访问,不能在派生类中或通过类的对象直接访问。
3. 类的默认访问说明符
class
:默认访问说明符是private
。这意味着如果没有显式指定访问说明符,默认情况下所有成员都是私有的。struct
:默认访问说明符是public
。这意味着如果没有显式指定访问说明符,默认情况下所有成员都是公有的。union
:默认访问说明符是public
。这意味着如果没有显式指定访问说明符,默认情况下所有成员都是公有的。
4. 访问控制的详细规则
4.1. 成员访问
- 类内部访问:类的所有成员(包括静态成员、非静态成员、成员函数的函数体、成员对象的初始化器以及嵌套类定义)都可以访问该类的任何成员,无论其访问权限如何。
- 派生类访问:
- 公有继承:基类的公有和保护成员在派生类中保持其原始访问权限。
- 保护继承:基类的公有和保护成员在派生类中成为保护成员。
- 私有继承:基类的公有和保护成员在派生类中成为私有成员。
- 私有成员:无论继承方式如何,基类的私有成员始终无法被派生类访问。
4.2. 友元访问
-
友元函数:可以通过
friend
关键字声明一个函数或类为类的友元。友元函数或类可以访问类的私有和保护成员。class A { private: int x; public: friend void f(A& a); // 函数 f 是 A 的友元 }; void f(A& a) { a.x = 10; // OK: 可以访问 A 的私有成员 }
4.3. 访问检查的时机
-
编译时检查:访问权限检查是在编译时进行的。如果某个代码试图访问一个不可访问的成员,编译器会报错。
-
虚函数调用:对于虚函数,访问权限检查是在调用点使用表示调用成员函数的对象的表达式的类型进行的。最终覆盖者的访问权限将被忽略。
struct B { virtual int f(); // f 是公有的 }; class D : public B { private: int f() override; // f 是私有的 }; void f() { D d; B& b = d; b.f(); // OK: B::f 是公有的,即使 D::f 是私有的 d.f(); // 错误:D::f 是私有的 }
4.4. 名称查找与访问控制
-
名称查找:名称查找和访问控制是两个独立的过程。名称查找首先确定要访问的成员,然后才进行访问权限检查。
-
多重继承:如果一个类通过多个路径继承同一个基类成员,该成员的访问权限将是所有路径中最高的访问权限。
class W { public: void f(); }; class A : private virtual W {}; class B : public virtual W {}; class C : public A, public B { void g() { W::f(); // OK: W 是通过 B 公有继承的 } };
4.5. 类布局与访问说明符
-
类布局:非静态数据成员的地址保证按照声明顺序递增,前提是这些成员未被访问说明符隔开(直到 C++11)。从 C++11 开始,具有相同访问权限的成员必须按声明顺序排列。
-
标准布局类型:对于标准布局类型,所有非静态数据成员必须具有相同的访问权限。
struct S { int a; // 公有 private: int b; // 私有 public: int c; // 公有 };
4.6. 重新声明成员
-
相同访问权限:如果一个成员在同一类中被重新声明,它必须在相同的访问权限下进行重新声明。
struct S { class A; // S::A 是公有的 private: class A {}; // 错误:不能改变访问权限 };
5. 示例
5.1. 成员访问示例
#include <iostream>
class Example {
public:
void add(int x) {
n += x; // OK: 私有成员 n 可以在类内部访问
}
private:
int n = 0; // 私有成员
};
int main() {
Example e;
e.add(1); // OK: 公有成员 add 可以在外部访问
// e.n = 7; // 错误:私有成员 n 不能在外部访问
}
5.2. 继承访问示例
#include <iostream>
class Base {
protected:
int i;
private:
void g(Base& b, Derived& d);
};
class Derived : public Base {
friend void h(Base& b, Derived& d);
void f(Base& b, Derived& d) {
++d.i; // OK: 通过 Derived 对象访问保护成员
++i; // OK: 通过 this 指针访问保护成员
// ++b.i; // 错误:不能通过 Base 对象访问保护成员
}
};
void Base::g(Base& b, Derived& d) {
++i; // OK: 在 Base 类内部访问保护成员
++b.i; // OK: 在 Base 类内部访问保护成员
++d.i; // OK: 在 Base 类内部访问保护成员
}
void h(Base& b, Derived& d) {
++d.i; // OK: 友元函数可以访问保护成员
// ++b.i; // 错误:友元函数不能通过 Base 对象访问保护成员
}
void x(Base& b, Derived& d) {
// ++b.i; // 错误:非成员非友元函数不能访问保护成员
// ++d.i; // 错误:非成员非友元函数不能访问保护成员
}
6. 总结
public
:公有成员可以在类外部、派生类中以及通过类的对象直接访问。protected
:保护成员只能在类内部、派生类中以及通过类的友元访问,不能通过类的对象直接访问。private
:私有成员只能在类内部以及通过类的友元访问,不能在派生类中或通过类的对象直接访问。- 访问说明符:控制类成员的访问权限,确保类的封装性和安全性。
- 继承方式:不同的继承方式会影响基类成员在派生类中的访问权限。
- 友元:可以通过
friend
关键字授予特定函数或类对私有和保护成员的访问权限。
使用访问说明符可以有效地控制类的接口和实现,确保类的内部细节不会被意外修改,同时提供必要的功能给用户和派生类。
C++ 友元声明 (Friend Declarations)
在 C++ 中,友元声明允许特定的函数或类访问另一个类的私有和保护成员。这为类提供了更大的灵活性,可以在保持封装性的同时,允许某些外部实体(如友元函数或友元类)访问其内部细节。
1. 友元声明的语法
友元声明可以出现在类体中,并且可以分为以下几类:
-
友元函数声明:
friend function-declaration;
:声明一个非成员函数为友元。friend function-definition;
:定义一个非成员函数并同时将其设为友元。此类函数总是内联的,除非它附加到一个命名模块(自 C++20 起)。
-
友元类声明:
friend elaborated-type-specifier;
:使用详细类型说明符声明一个类为友元。friend simple-type-specifier;
:使用简单类型说明符声明一个类为友元(自 C++11 起)。friend typename-specifier;
:使用typename
关键字后跟限定标识符或限定的简单模板标识符声明一个类为友元。friend friend-type-specifier-list;
:声明多个类为友元(自 C++26 起),其中friend-type-specifier-list
是一个非空的逗号分隔的simple-type-specifier
、elaborated-type-specifier
和typename-specifier
列表,每个说明符后面可以跟省略号...
以支持包展开。
2. 友元函数声明
2.1. 声明非成员函数为友元
class Y {
private:
int data;
public:
// 声明非成员函数 operator<< 为友元
friend std::ostream& operator<<(std::ostream& out, const Y& o);
};
// 定义非成员函数 operator<<
std::ostream& operator<<(std::ostream& out, const Y& y) {
return out << y.data; // 可以访问私有成员 Y::data
}
2.2. 在类体内定义非成员函数并声明为友元
class X {
private:
int a;
public:
// 定义非成员函数 friend_set 并声明为友元
friend void friend_set(X& p, int i) {
p.a = i; // 这是一个非成员函数
}
void member_set(int i) {
a = i; // 这是一个成员函数
}
};
3. 友元类声明
3.1. 使用详细类型说明符声明友元类
class A {
private:
int data;
class B {};
enum { a = 100 };
public:
// 声明类 X 为友元
friend class X;
// 声明类 Y 为友元
friend Y;
};
class X : A::B {
A::B mx; // OK: A::B 对友元类可见
class Y {
A::B my; // OK: A::B 对嵌套的友元类可见
};
int v[A::a]; // OK: A::a 对友元类可见
};
3.2. 使用简单类型说明符声明友元类(自 C++11 起)
class A {
private:
int data;
public:
// 声明类 Y 为友元
friend Y;
};
class Y {};
3.3. 同时声明多个类为友元(自 C++26 起)
class A {
private:
int data;
public:
// 同时声明类 X 和 Y 为友元
friend class X, Y;
};
class X {};
class Y {};
4. 模板友元
4.1. 模板类作为友元
class A {
template<typename T>
friend class B; // 每个 B<T> 都是 A 的友元
template<typename T>
friend void f(T) {} // 每个 f<T> 都是 A 的友元
};
4.2. 模板部分特化和完全特化
template<class T>
class A {}; // 主模板
template<class T>
class A<T*> {}; // 部分特化
template<>
class A<int> {}; // 完全特化
class X {
template<class T>
friend class A<T*>; // 错误:不能引用部分特化
friend class A<int>; // 正确:引用完全特化
};
4.3. 模板友元函数的完全特化
template<class T>
void f(int);
template<>
void f<int>(int);
class X {
friend void f<int>(int x = 1); // 错误:默认参数不允许
};
4.4. 模板友元成员函数
template<class T>
struct A {
struct B {};
void f();
struct D { void g(); };
T h();
template<T U>
T i();
};
template<>
struct A<int> {
struct B {};
int f();
struct D { void g(); };
template<int U>
int i();
};
template<>
struct A<float*> {
int *h();
};
class X {
template<class T>
friend struct A<T>::B; // 所有 A<T>::B 都是友元
template<class T>
friend void A<T>::f(); // A<int>::f() 不是友元,因为签名不匹配
template<class T>
friend int* A<T*>::h(); // 所有 A<T*>::h 都是友元
template<class T>
template<T U>
friend T A<T>::i(); // 所有 A<T>::i() 和 A<int>::i() 的实例化都是友元
};
5. 模板友元运算符
5.1. 在类体内定义模板友元运算符
#include <iostream>
template<typename T>
class Foo {
private:
T data;
public:
Foo(const T& val) : data(val) {}
// 生成一个非模板 operator<<,针对每个 T
friend std::ostream& operator<<(std::ostream& os, const Foo& obj) {
return os << obj.data;
}
};
int main() {
Foo<double> obj(1.23);
std::cout << obj << '\n';
}
5.2. 引用模板友元运算符的完全特化
#include <iostream>
template<typename T>
class Foo; // 前向声明
template<typename T>
std::ostream& operator<<(std::ostream&, const Foo<T>&);
template<typename T>
class Foo {
private:
T data;
public:
Foo(const T& val) : data(val) {}
// 引用 operator<< 的完全特化
friend std::ostream& operator<< <> (std::ostream&, const Foo&);
};
template<typename T>
std::ostream& operator<<(std::ostream& os, const Foo<T>& obj) {
return os << obj.data;
}
int main() {
Foo<double> obj(1.23);
std::cout << obj << '\n';
}
6. 友元声明的特性
-
传递性和继承性:
- 友谊不是传递的:如果你的朋友的朋友并不是你的朋友。
- 友谊不是继承的:如果你的朋友的孩子并不是你的朋友,你的朋友也不是你孩子的朋友。
-
访问说明符的影响:
- 访问说明符(如
public
、protected
、private
)对友元声明的含义没有影响。友元声明可以出现在任何访问说明符部分,效果相同。
- 访问说明符(如
-
局部类中的友元声明:
- 当局部类将非限定函数或类声明为友元时,只有最内层非类范围内的函数和类会被查找,而不是全局函数。
class F {}; int f(); int main() { extern int g(); class Local { friend int f(); // 错误:main() 中没有名为 f 的函数 friend int g(); // 正确:main() 中有 g 的声明 friend class F; // 朋友的是局部的 F(稍后定义) friend class ::F; // 朋友的是全局的 F }; class F {}; // 局部的 F }
-
链接规则:
- 如果函数或函数模板首先在友元声明中声明和定义,并且封闭类在导出声明中定义,则其名称具有与封闭类名称相同的链接(自 C++20 起)。
- 否则,如果函数或函数模板在友元声明中声明,并且可以访问相应的非友元声明,则该名称具有从该先前声明确定的链接。
- 否则,由友元声明引入的名称的链接将按通常方式确定。
7. 示例
7.1. 流插入和提取运算符作为友元
#include <iostream>
#include <sstream>
class MyClass {
private:
int i;
static inline int id{6};
// 声明流插入和提取运算符为友元
friend std::ostream& operator<<(std::ostream& out, const MyClass&);
friend std::istream& operator>>(std::istream& in, MyClass&);
friend void change_id(int);
public:
MyClass(int i = 0) : i(i) {}
};
std::ostream& operator<<(std::ostream& out, const MyClass& mc) {
return out << "MyClass::id = " << MyClass::id << "; i = " << mc.i;
}
std::istream& operator>>(std::istream& in, MyClass& mc) {
return in >> mc.i;
}
void change_id(int id) {
MyClass::id = id;
}
int main() {
MyClass mc(7);
std::cout << mc << '\n';
std::istringstream("100") >> mc;
std::cout << mc << '\n';
change_id(9);
std::cout << mc << '\n';
}
输出
MyClass::id = 6; i = 7
MyClass::id = 6; i = 100
MyClass::id = 9; i = 100
8. 总结
- 友元声明:允许特定的函数或类访问另一个类的私有和保护成员,提供更大的灵活性。
- 模板友元:可以用于模板类和模板函数,允许模板的每个特化都成为友元。
- 链接规则:友元声明引入的名称的链接按特定规则确定,确保正确的符号解析。
- 注意事项:友谊不是传递的,也不是继承的,访问说明符对友元声明的含义没有影响。
通过合理使用友元声明,可以在保持类封装性的同时,允许必要的外部实体访问类的内部细节,从而实现更灵活的设计。
C++ 构造函数和成员初始化列表
构造函数是C++中用于初始化类对象的特殊成员函数。它们在创建对象时自动调用,并且可以根据参数的不同来执行不同的初始化逻辑。构造函数可以带有成员初始化列表,用于在进入构造函数主体之前初始化类的成员变量或基类。
1. 构造函数声明
构造函数使用以下语法声明:
class-name ( parameter-list ) except attr
class-name
:类名,可以跟属性列表,并可能被一对括号包围(自 C++11 起)。parameter-list
:参数列表,可选。except
:异常说明符,可以是动态异常说明(直到 C++11),noexcept
说明(自 C++11 起),或空。attr
:属性列表(自 C++11 起)。
构造函数声明中的唯一允许的说明符是 friend
、inline
、constexpr
(自 C++11 起)、consteval
(自 C++20 起)和 explicit
。不允许使用返回值类型、cv
限定符和引用限定符。
2. 成员初始化列表
构造函数定义的函数体在复合语句的左花括号之前可以包含成员初始化列表,其语法为冒号字符 :
后跟一个或多个用逗号分隔的成员初始化器列表。每个初始化器具有以下语法:
class-or-identifier ( expression-list ) // 直接初始化
class-or-identifier { brace-init-list } // 列表初始化 (自 C++11 起)
parameter-pack ... // 包扩展 (自 C++11 起)
class-or-identifier
:命名非静态数据成员的标识符,或命名类本身(用于委托构造函数)或直接或虚拟基类的类型名。expression-list
:传递给基类或成员构造函数的参数列表,可以为空。brace-init-list
:用花括号括起来的初始化列表。parameter-pack
:可变模板参数包的名称。
3. 初始化顺序
成员初始化列表中的初始化顺序并不影响实际的初始化顺序。实际的初始化顺序如下:
- 虚拟基类:如果构造函数用于最派生类,则按深度优先从左到右遍历基类声明的顺序进行初始化。
- 直接基类:按直接基类在该类基类说明符列表中的出现顺序从左到右初始化。
- 非静态数据成员:按非静态数据成员在类定义中的声明顺序初始化。
- 构造函数主体:最后执行构造函数的主体。
4. 示例
4.1. 基本构造函数和成员初始化列表
struct S {
int n;
S(int); // 构造函数声明
S() : n(7) {} // 构造函数定义: ": n(7)" 是成员初始化列表
};
S::S(int x) : n{x} {} // 构造函数定义: ": n{x}" 是成员初始化列表
int main() {
S s; // 调用 S::S()
S s2(10); // 调用 S::S(int)
}
4.2. 复杂的构造函数和成员初始化列表
#include <fstream>
#include <string>
#include <mutex>
struct Base {
int n;
};
struct Class : public Base {
unsigned char x;
unsigned char y;
std::mutex m;
std::lock_guard<std::mutex> lg;
std::fstream f;
std::string s;
// 构造函数 1
Class(int x)
: Base{123}, // 初始化基类
x(x), // x (成员) 初始化为 x (参数)
y{0}, // y 初始化为 0
f{"test.cc", std::ios::app}, // f 在 m 和 lg 之后初始化
s(__func__), // __func__ 是可用的,因为 init-list 是构造函数的一部分
lg(m), // lg 使用 m,m 已经初始化
m{} // m 在 lg 之前初始化,尽管它出现在这里
{}
// 构造函数 2
Class(double a)
: y(a + 1),
x(y), // x 在 y 之前初始化,x 的值在这里是未定义的
lg(m)
{}
// 构造函数 3:委托构造函数
Class() try
: Class(0.0) // 委托构造函数
{
// ...
}
catch (...) {
// 捕获初始化期间的异常
}
};
int main() {
Class c; // 调用 Class()
Class c1(1); // 调用 Class(int)
Class c2(0.1); // 调用 Class(double)
}
4.3. 默认成员初始化器和成员初始化列表
struct S {
int n = 42; // 默认成员初始化器
S() : n(7) {} // 将 n 初始化为 7,而不是 42
};
struct A {
const int& v; // 引用成员不能绑定到临时对象
A() : v(42) {} // 错误
};
4.4. 委托构造函数
class Foo {
public:
Foo(char x, int y) {}
Foo(int y) : Foo('a', y) {} // Foo(int) 委托给 Foo(char, int)
};
4.5. 继承构造函数
继承构造函数允许派生类继承基类的构造函数,从而避免重复代码。这需要使用 using
关键字。
struct Base {
Base(int x) : x(x) {}
private:
int x;
};
struct Derived : Base {
using Base::Base; // 继承 Base 的构造函数
};
int main() {
Derived d(42); // 调用继承的 Base(int) 构造函数
}
5. 特殊规则和注意事项
- 构造函数不能是协程(自 C++20 起)。
- 构造函数不能有显式对象参数(自 C++23 起)。
- 转换构造函数:没有
explicit
说明符的构造函数可以隐式转换。 - 默认构造函数:可以不带任何参数调用的构造函数。
- 拷贝构造函数和移动构造函数:以相同类型对象的另一个对象作为参数的构造函数。
constexpr
构造函数:使其类型成为 LiteralType。- 成员初始化列表:必须用于初始化无法通过默认初始化或默认成员初始化器初始化的成员,如引用和
const
限定类型的成员。 - 匿名联合和变体成员:不会进行任何初始化,除非有成员初始化器或默认成员初始化器。
- 委托构造函数:只能有一个成员初始化器,且该初始化器必须是类本身的名称。
- 初始化顺序:成员初始化列表中的顺序不影响实际的初始化顺序,实际顺序由类定义中的声明顺序决定。
- 函数 try 块:可以在成员初始化列表中捕获异常。
- 虚拟调用:在构造函数中调用虚函数时,行为如同
*this
的动态类型是正在构造的类的静态类型。
通过合理使用构造函数和成员初始化列表,可以确保类对象在创建时正确初始化,同时保持代码的简洁性和可维护性。
默认构造函数
默认构造函数是一种可以不带参数调用的构造函数。它在对象创建时被调用,用于初始化对象的数据成员。C++ 提供了多种方式来定义和控制默认构造函数的行为。
1. 语法
默认构造函数可以通过以下几种方式声明和定义:
-
隐式声明:如果类没有用户定义的构造函数,编译器会隐式声明一个默认构造函数。
class MyClass { // 没有用户定义的构造函数 };
-
显式声明:可以在类内部或外部显式声明默认构造函数。
class MyClass { public: MyClass(); // 在类内声明 }; MyClass::MyClass() {} // 在类外定义
-
显式默认:使用
= default
关键字来显式要求编译器生成默认构造函数。class MyClass { public: MyClass() = default; };
-
删除构造函数:使用
= delete
关键字来禁止生成默认构造函数。class MyClass { public: MyClass() = delete; };
2. 隐式声明的默认构造函数
如果类没有用户定义的构造函数或构造函数模板,编译器将隐式声明一个默认构造函数。这个构造函数是公共的、内联的,并且具有 noexcept
规范(自 C++17 起)。
class MyClass {
int x;
};
在这种情况下,编译器会为 MyClass
自动生成一个默认构造函数,该构造函数会调用基类和非静态成员的默认构造函数。
3. 隐式定义的默认构造函数
当隐式声明的默认构造函数被 odr 使用(即在程序中实际需要其定义)时,编译器会生成并编译该构造函数的函数体。这个隐式定义的默认构造函数等同于一个空函数体和空初始化列表的用户定义构造函数。
class MyClass {
int x;
};
MyClass obj; // 隐式定义的默认构造函数被调用
4. 平凡的默认构造函数
如果满足以下所有条件,类的默认构造函数是平凡的(即不执行任何操作):
- 构造函数不是用户提供的(即它是隐式声明的或显式默认的)。
- 类没有虚成员函数。
- 类没有虚基类。
- 类没有带有默认初始值设定项的非静态成员。
- 类的每个直接基类都有一个平凡的默认构造函数。
- 类类型(或其数组)的每个非静态成员都有一个平凡的默认构造函数。
平凡的默认构造函数是不执行任何操作的构造函数。所有与 C 语言兼容的数据类型(POD 类型)都是平凡默认可构造的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的默认构造函数是平凡的,因为它满足上述所有条件。
5. 已删除的默认构造函数
类的隐式声明或显式默认的默认构造函数在以下任一条件满足时会被定义为已删除:
- 类是一个联合体,并且它的所有变体成员都是
const
限定类型(或者可能是其多维数组)。 - 类是一个非联合体类,并且任何匿名联合体成员的所有成员都是
const
限定类型(或者可能是其多维数组)。 - 类具有一个没有默认初始化器的非静态引用类型数据成员。
- 类具有一个没有默认成员初始化器的非变体、非静态、非
const-default-constructible
、const
限定类型(或者可能是其多维数组)的数据成员。 - 类具有一个类类型
M
(或者可能是其多维数组)的潜在构造子对象,使得:M
的析构函数被删除或无法从默认构造函数访问,或者M
的析构函数是非平凡的,并且M
是变体成员。
struct InvalidClass {
const int& ref; // 没有默认初始化器的引用成员
};
在这个例子中,InvalidClass
的默认构造函数被隐式定义为已删除,因为 ref
是一个没有默认初始化器的引用成员。
6. 合格的默认构造函数
如果默认构造函数是用户声明的,或者既是隐式声明的又是可定义的,则它是合格的。如果默认构造函数未被删除,则它是合格的(自 C++11 起)。在 C++20 中,如果满足以下所有条件,则默认构造函数是合格的:
- 它没有被删除。
- 它关联的约束(如果有)得到满足。
- 在所有关联约束得到满足的默认构造函数中,它比任何其他默认构造函数更受约束。
7. 示例
#include <iostream>
struct A {
int x;
A(int x = 1) : x(x) {} // 用户定义的默认构造函数
};
struct B : A {
// B::B() 是隐式定义的,默认调用 A::A()
};
struct C {
A a;
// C::C() 是隐式定义的,默认调用 A::A()
};
struct D : A {
D(int y) : A(y) {}
// D::D() 未声明,因为存在另一个构造函数
};
struct E : A {
E(int y) : A(y) {}
E() = default; // 显式默认,默认调用 A::A()
};
struct F {
int& ref; // 引用成员
const int c; // const 成员
// F::F() 是隐式定义为已删除的
};
struct G {
G(const G&) {}
// G::G() 是隐式定义为已删除的
};
struct H {
H(const H&) = delete;
// H::H() 是隐式定义为已删除的
};
struct I {
I(const I&) = default;
// I::I() 是隐式定义为已删除的
};
int main() {
A a; // 调用 A::A()
B b; // 调用 B::B() 和 A::A()
C c; // 调用 C::C() 和 A::A()
// D d; // 编译错误,D 没有默认构造函数
E e; // 调用 E::E() 和 A::A()
// F f; // 编译错误,F 的默认构造函数被删除
// G g; // 编译错误,G 的默认构造函数被删除
// H h; // 编译错误,H 的默认构造函数被删除
// I i; // 编译错误,I 的默认构造函数被删除
}
析构函数
析构函数是一种特殊的成员函数,它在对象的生命周期结束时被调用,用于释放对象在其生命周期内可能已获取的资源。析构函数不能是协程(自 C++20 起)。
1. 语法
析构函数的声明和定义遵循以下语法:
-
隐式声明:如果类没有用户声明的析构函数,编译器会隐式声明一个析构函数。
class MyClass { // 没有用户定义的析构函数 };
-
显式声明:可以在类内部或外部显式声明析构函数。
class MyClass { public: ~MyClass(); // 在类内声明 }; MyClass::~MyClass() {} // 在类外定义
-
显式默认:使用
= default
关键字来显式要求编译器生成默认析构函数。class MyClass { public: ~MyClass() = default; };
-
删除析构函数:使用
= delete
关键字来禁止生成析构函数。class MyClass { public: ~MyClass() = delete; };
2. 隐式声明的析构函数
如果类没有提供用户声明的析构函数,编译器将始终声明一个析构函数作为其类的 inline public
成员。隐式声明的析构函数的异常说明是非抛出型,除非任何可能被构造的基类或成员的析构函数是可能抛出型(自 C++17 起)。
class MyClass {
int x;
};
在这种情况下,编译器会为 MyClass
自动生成一个默认析构函数,该析构函数会调用基类和非静态成员的析构函数。
3. 隐式定义的析构函数
当隐式声明的析构函数被 odr 使用时,编译器会生成并编译该析构函数的函数体。这个隐式定义的析构函数有一个空函数体。
class MyClass {
int x;
};
MyClass obj; // 隐式定义的析构函数在对象销毁时被调用
4. 平凡的析构函数
如果满足以下所有条件,类的析构函数是平凡的:
- 析构函数不是用户提供的(即它是隐式声明的,或者在首次声明时被显式定义为默认)。
- 析构函数不是虚函数。
- 所有直接基类都具有平凡析构函数。
- 所有非静态数据成员的类类型(或类类型数组)都具有平凡析构函数。
平凡析构函数是不执行任何操作的析构函数。具有平凡析构函数的对象不需要 delete
表达式,可以通过简单地释放其存储空间来销毁。所有与 C 语言兼容的数据类型(POD 类型)都是平凡可析构的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的析构函数是平凡的,因为它满足上述所有条件。
5. 已删除的析构函数
如果满足以下任一条件,类的隐式声明或显式默认的析构函数会被定义为已删除:
- 类具有一个类类型
M
(或可能是其多维数组)的可能被构造的子对象,使得M
具有一个析构函数,该析构函数:- 被删除或无法从类的析构函数访问,或者
- 在子对象是变体成员的情况下,是非平凡的。
- 析构函数是虚函数,并且对释放函数的查找结果是歧义的,或者一个从析构函数被删除或无法访问的函数。
class InvalidClass {
InvalidClass* p;
~InvalidClass() = delete; // 删除析构函数
};
6. 虚析构函数
通过指向基类的指针删除对象会导致未定义的行为,除非基类中的析构函数是虚函数。因此,基类的析构函数通常应该声明为虚函数,以确保派生类的析构函数也能被正确调用。
class Base {
public:
virtual ~Base() {} // 虚析构函数
};
class Derived : public Base {};
Base* b = new Derived;
delete b; // 安全
7. 纯虚析构函数
纯虚析构函数可以用于抽象类,但必须提供定义,因为所有基类析构函数在派生类被销毁时都会被调用。
class AbstractBase {
public:
virtual ~AbstractBase() = 0; // 纯虚析构函数
};
AbstractBase::~AbstractBase() {} // 必须提供定义
class Derived : public AbstractBase {};
// AbstractBase obj; // 编译错误
Derived obj; // OK
8. 异常处理
析构函数可以通过抛出异常来终止,但这通常需要它被显式声明为 noexcept(false)
(自 C++11 起)。然而,如果析构函数恰好在栈展开期间被调用,则 std::terminate
会被调用。因此,允许析构函数抛出异常通常被认为是不好的做法。
class MyClass {
public:
~MyClass() noexcept(false) {
throw std::runtime_error("Error in destructor");
}
};
9. 示例
#include <iostream>
struct A {
int i;
A(int num) : i(num) {
std::cout << "ctor a" << i << '\n';
}
~A() {
std::cout << "dtor a" << i << '\n';
}
};
A a0(0);
int main() {
A a1(1);
A* p;
{ // 嵌套作用域
A a2(2);
p = new A(3);
} // a2 出作用域
delete p; // 调用 a3 的析构函数
}
// 输出:
// ctor a0
// ctor a1
// ctor a2
// ctor a3
// dtor a2
// dtor a3
// dtor a1
// dtor a0
在这个例子中,A
类的构造函数和析构函数分别在对象创建和销毁时被调用。嵌套作用域中的对象 a2
在作用域结束时自动调用析构函数,而通过 new
动态分配的对象 a3
则通过 delete
显式调用析构函数。
复制构造函数
复制构造函数是 C++ 中的一种特殊成员函数,用于初始化一个对象为另一个同类型对象的副本。它在对象被拷贝时调用,例如通过直接初始化、复制初始化、函数参数传递或函数返回值。
1. 语法
复制构造函数可以通过以下几种方式声明和定义:
-
隐式声明:如果类没有用户定义的复制构造函数,编译器会隐式声明一个复制构造函数。
class MyClass { // 没有用户定义的复制构造函数 };
-
显式声明:可以在类内部或外部显式声明复制构造函数。
class MyClass { public: MyClass(const MyClass& other); // 在类内声明 }; MyClass::MyClass(const MyClass& other) { // 复制构造函数的实现 }
-
显式默认:使用
= default
关键字来显式要求编译器生成默认的复制构造函数。class MyClass { public: MyClass(const MyClass& other) = default; };
-
删除复制构造函数:使用
= delete
关键字来禁止生成复制构造函数。class MyClass { public: MyClass(const MyClass& other) = delete; };
2. 隐式声明的复制构造函数
如果类没有提供用户定义的复制构造函数,编译器将始终声明一个复制构造函数作为其类的 inline public
成员。这个构造函数的形式取决于类的成员和基类:
- 如果所有直接和虚基类以及非静态数据成员都有
const T&
或const volatile T&
类型的复制构造函数,则隐式声明的复制构造函数形式为T::T(const T&)
。 - 否则,隐式声明的复制构造函数形式为
T::T(T&)
。
class MyClass {
int x;
};
在这个例子中,编译器会为 MyClass
自动生成一个 MyClass(const MyClass&)
形式的复制构造函数。
3. 隐式定义的复制构造函数
当隐式声明的复制构造函数被 odr 使用(即在程序中实际需要其定义)时,编译器会生成并编译该构造函数的函数体。对于非联合类类型,构造函数会按成员顺序逐个复制每个非静态数据成员和直接基类子对象。
class MyClass {
int x;
std::string s;
public:
MyClass(const MyClass& other) = default;
};
MyClass obj1(42, "hello");
MyClass obj2 = obj1; // 调用复制构造函数
4. 平凡的复制构造函数
如果满足以下所有条件,类的复制构造函数是平凡的:
- 它不是用户提供的(即它是隐式定义的或默认为)。
- 类没有虚成员函数。
- 类没有虚基类。
- 为类的每个直接基类选择的复制构造函数是平凡的。
- 为类的每个非静态类类型(或类类型数组)成员选择的复制构造函数是平凡的。
平凡的复制构造函数实际上只是简单地复制了参数的每个标量子对象,而不执行其他操作。具有平凡复制构造函数的对象可以通过手动复制其对象表示形式来复制,例如使用 std::memmove
。所有与 C 语言兼容的数据类型(POD 类型)都是平凡可复制的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的复制构造函数是平凡的,因为它满足上述所有条件。
5. 已删除的复制构造函数
如果满足以下任何条件,类的隐式声明或显式默认的复制构造函数会被定义为已删除:
- 类具有右值引用类型的非静态数据成员。
- 类具有类类型
M
(或可能是其多维数组)的可能已构造的子对象,使得:M
具有删除或无法从复制构造函数访问的析构函数,或者- 应用于查找
M
的复制构造函数的重载解析没有导致可用候选者,或者 - 在子对象是变体成员的情况下,选择了非平凡函数。
- 类声明了移动构造函数或移动赋值运算符。
class InvalidClass {
int&& rvalue_ref; // 右值引用成员
};
在这个例子中,InvalidClass
的复制构造函数被隐式定义为已删除,因为 rvalue_ref
是一个右值引用成员。
6. 示例
#include <iostream>
#include <string>
struct A {
int n;
std::string s;
A(int n = 1, const std::string& s = "default") : n(n), s(s) {}
A(const A& other) : n(other.n), s(other.s) {
std::cout << "Copy constructor called\n";
}
};
struct B : A {
std::string s2;
B() : A(), s2("base") {}
// 隐式定义的复制构造函数
};
struct C {
C(const C& other) = delete; // 删除复制构造函数
};
int main() {
A a1(7, "hello");
A a2 = a1; // 调用复制构造函数
std::cout << "a2.n = " << a2.n << ", a2.s = " << a2.s << '\n';
B b1;
B b2 = b1; // 调用隐式定义的复制构造函数
std::cout << "b2.s2 = " << b2.s2 << '\n';
// C c1;
// C c2 = c1; // 编译错误,C 的复制构造函数被删除
}
复制赋值运算符
复制赋值运算符是 C++ 中的一种特殊成员函数,用于将一个对象的内容复制到另一个同类型对象中。它在对象赋值时被调用,例如通过赋值表达式。
1. 语法
复制赋值运算符可以通过以下几种方式声明和定义:
-
隐式声明:如果类没有用户定义的复制赋值运算符,编译器会隐式声明一个复制赋值运算符。
class MyClass { // 没有用户定义的复制赋值运算符 };
-
显式声明:可以在类内部或外部显式声明复制赋值运算符。
class MyClass { public: MyClass& operator=(const MyClass& other); // 在类内声明 }; MyClass& MyClass::operator=(const MyClass& other) { // 复制赋值运算符的实现 return *this; }
-
显式默认:使用
= default
关键字来显式要求编译器生成默认的复制赋值运算符。class MyClass { public: MyClass& operator=(const MyClass& other) = default; };
-
删除复制赋值运算符:使用
= delete
关键字来禁止生成复制赋值运算符。class MyClass { public: MyClass& operator=(const MyClass& other) = delete; };
2. 隐式声明的复制赋值运算符
如果类没有提供用户定义的复制赋值运算符,编译器将始终声明一个复制赋值运算符作为其类的 inline public
成员。这个运算符的形式取决于类的成员和基类:
- 如果所有直接基类和非静态数据成员都有
const T&
或const volatile T&
类型的复制赋值运算符,则隐式声明的复制赋值运算符形式为T& T::operator=(const T&)
。 - 否则,隐式声明的复制赋值运算符形式为
T& T::operator=(T&)
。
class MyClass {
int x;
};
在这个例子中,编译器会为 MyClass
自动生成一个 MyClass& operator=(const MyClass&)
形式的复制赋值运算符。
3. 隐式定义的复制赋值运算符
当隐式声明的复制赋值运算符被 odr 使用(即在程序中实际需要其定义)时,编译器会生成并编译该运算符的函数体。对于非联合类类型,运算符会按成员顺序逐个复制每个非静态数据成员和直接基类子对象。
class MyClass {
int x;
std::string s;
public:
MyClass& operator=(const MyClass& other) = default;
};
MyClass obj1(42, "hello");
MyClass obj2;
obj2 = obj1; // 调用复制赋值运算符
4. 平凡的复制赋值运算符
如果满足以下所有条件,类的复制赋值运算符是平凡的:
- 它不是用户提供的(即它是隐式定义的或默认为)。
- 类没有虚成员函数。
- 类没有虚基类。
- 为类的每个直接基类选择的复制赋值运算符是平凡的。
- 为类的每个非静态类类型(或类类型数组)成员选择的复制赋值运算符是平凡的。
平凡的复制赋值运算符就像 std::memmove
一样复制对象表示。所有与 C 语言兼容的数据类型(POD 类型)都是平凡可复制赋值的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的复制赋值运算符是平凡的,因为它满足上述所有条件。
5. 已删除的复制赋值运算符
如果满足以下任何条件,类的隐式声明或显式默认的复制赋值运算符会被定义为已删除:
- 类具有常量限定的非类类型(或可能是其多维数组)的非静态数据成员。
- 类具有引用类型的非静态数据成员。
- 类具有类类型
M
(或可能是其多维数组)的潜在构造的子对象,使得应用于查找M
的复制赋值运算符的重载解析没有导致可用候选者,或者在子对象是变体成员的情况下,选择了非平凡函数。 - 类声明了移动构造函数或移动赋值运算符。
class InvalidClass {
const int x; // 常量成员
};
在这个例子中,InvalidClass
的复制赋值运算符被隐式定义为已删除,因为 x
是一个常量成员。
6. 示例
#include <algorithm>
#include <iostream>
#include <memory>
#include <string>
struct A {
int n;
std::string s1;
A() = default;
A(A const&) = default;
// 用户定义的复制赋值运算符(使用 copy-and-swap 惯用法)
A& operator=(A other) {
std::cout << "copy assignment of A\n";
std::swap(n, other.n);
std::swap(s1, other.s1);
return *this;
}
};
struct B : A {
std::string s2;
// 隐式定义的复制赋值运算符
};
struct C {
std::unique_ptr<int[]> data;
std::size_t size;
// 用户定义的复制赋值运算符(非 copy-and-swap 惯用法)
C& operator=(const C& other) {
if (this != &other) { // 不是自赋值
if (size != other.size) { // 不能复用资源
data.reset(new int[other.size]);
size = other.size;
}
std::copy(&other.data[0], &other.data[0] + size, &data[0]);
}
return *this;
}
};
int main() {
A a1, a2;
std::cout << "a1 = a2 calls ";
a1 = a2; // 用户定义的复制赋值运算符
B b1, b2;
b2.s1 = "foo";
b2.s2 = "bar";
std::cout << "b1 = b2 calls ";
b1 = b2; // 隐式定义的复制赋值运算符
std::cout << "b1.s1 = " << b1.s1 << "; b1.s2 = " << b1.s2 << '\n';
}
输出
a1 = a2 calls copy assignment of A
b1 = b2 calls copy assignment of A
b1.s1 = foo; b1.s2 = bar
总结
- 复制构造函数 用于初始化一个对象为另一个同类型对象的副本,通常在对象创建时调用。
- 复制赋值运算符 用于将一个对象的内容复制到另一个同类型对象中,通常在对象赋值时调用。
- 编译器会根据类的成员和基类自动声明和定义这些特殊成员函数,但用户也可以显式声明和定义它们。
- 如果类包含某些特定类型的成员(如右值引用、常量成员或引用成员),编译器可能会删除默认生成的复制构造函数或复制赋值运算符。
- 用户可以使用
= default
来显式要求编译器生成默认的复制构造函数或复制赋值运算符,也可以使用= delete
来禁止生成这些函数。
移动构造函数
移动构造函数是 C++11 引入的一种特殊成员函数,用于从右值(如临时对象或 std::move
的结果)初始化一个新对象。与复制构造函数不同,移动构造函数可以转移资源(如动态分配的内存、文件描述符等),而不是创建副本。这使得移动操作通常比复制更高效。
1. 语法
移动构造函数可以通过以下几种方式声明和定义:
-
隐式声明:如果类没有用户定义的移动构造函数,并且满足一定条件,编译器会隐式声明一个移动构造函数。
class MyClass { // 没有用户定义的移动构造函数 };
-
显式声明:可以在类内部或外部显式声明移动构造函数。
class MyClass { public: MyClass(MyClass&& other); // 在类内声明 }; MyClass::MyClass(MyClass&& other) { // 移动构造函数的实现 }
-
显式默认:使用
= default
关键字来显式要求编译器生成默认的移动构造函数。class MyClass { public: MyClass(MyClass&& other) = default; };
-
删除移动构造函数:使用
= delete
关键字来禁止生成移动构造函数。class MyClass { public: MyClass(MyClass&& other) = delete; };
2. 隐式声明的移动构造函数
如果类没有提供用户定义的移动构造函数,并且满足以下所有条件,编译器将隐式声明一个移动构造函数作为其类的 inline public
成员:
- 类没有用户声明的复制构造函数。
- 类没有用户声明的复制赋值运算符。
- 类没有用户声明的移动赋值运算符。
- 类没有用户声明的析构函数。
class MyClass {
int x;
};
在这个例子中,编译器会为 MyClass
自动生成一个 MyClass(MyClass&&)
形式的移动构造函数。
3. 隐式定义的移动构造函数
当隐式声明的移动构造函数被 odr 使用(即在程序中实际需要其定义)时,编译器会生成并编译该构造函数的函数体。对于非联合类类型,移动构造函数会按成员顺序逐个移动每个非静态数据成员和直接基类子对象。
class MyClass {
int x;
std::string s;
public:
MyClass(MyClass&& other) = default;
};
MyClass obj1(42, "hello");
MyClass obj2 = std::move(obj1); // 调用移动构造函数
4. 平凡的移动构造函数
如果满足以下所有条件,类的移动构造函数是平凡的:
- 它不是用户提供的(即它是隐式定义的或默认为)。
- 类没有虚成员函数。
- 类没有虚基类。
- 为类的每个直接基类选择的移动构造函数是平凡的。
- 为类的每个非静态类类型(或类类型数组)成员选择的移动构造函数是平凡的。
平凡的移动构造函数实际上只是简单地复制了参数的每个标量子对象,而不执行其他操作。具有平凡移动构造函数的对象可以通过手动复制其对象表示形式来移动,例如使用 std::memmove
。所有与 C 语言兼容的数据类型(POD 类型)都是平凡可移动的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的移动构造函数是平凡的,因为它满足上述所有条件。
5. 已删除的移动构造函数
如果满足以下任何条件,类的隐式声明或显式默认的移动构造函数会被定义为已删除:
- 类具有类类型
M
(或可能是其多维数组)的可能已构造的子对象,使得:M
的析构函数被删除或无法从复制构造函数访问,或者- 应用于查找
M
的移动构造函数的重载解析没有导致可用候选者,或者 - 在子对象是变体成员的情况下,选择了非平凡函数。
- 类声明了用户定义的析构函数、复制构造函数、复制赋值运算符或移动赋值运算符。
class InvalidClass {
std::unique_ptr<int> ptr; // 移动构造函数存在
~InvalidClass() {} // 用户定义的析构函数
};
在这个例子中,InvalidClass
的移动构造函数被隐式定义为已删除,因为类声明了用户定义的析构函数。
6. 示例
#include <iomanip>
#include <iostream>
#include <string>
#include <utility>
struct A {
std::string s;
int k;
A() : s("test"), k(-1) {}
A(const A& o) : s(o.s), k(o.k) { std::cout << "Copy constructor called\n"; }
A(A&& o) noexcept :
s(std::move(o.s)), // 显式移动类类型的成员
k(std::exchange(o.k, 0)) // 显式移动非类类型的成员
{
std::cout << "Move constructor called\n";
}
};
A f(A a) {
return a;
}
struct B : A {
std::string s2;
int n;
// 隐式定义的移动构造函数
// 调用 A 的移动构造函数
// 调用 s2 的移动构造函数
// 并对 n 进行位复制
};
struct C : B {
~C() {} // 析构函数阻止隐式移动构造函数
};
struct D : B {
D() {}
~D() {} // 析构函数会阻止隐式移动构造函数
D(D&&) = default; // 强制生成移动构造函数
};
int main() {
std::cout << "Trying to move A\n";
A a1 = f(A()); // 返回值通过移动构造函数初始化目标
// 从函数参数移动构造
std::cout << "Before move, a1.s = " << std::quoted(a1.s)
<< " a1.k = " << a1.k << '\n';
A a2 = std::move(a1); // 从 xvalue 移动构造
std::cout << "After move, a1.s = " << std::quoted(a1.s)
<< " a1.k = " << a1.k << '\n';
std::cout << "\nTrying to move B\n";
B b1;
std::cout << "Before move, b1.s = " << std::quoted(b1.s) << "\n";
B b2 = std::move(b1); // 调用隐式定义的移动构造函数
std::cout << "After move, b1.s = " << std::quoted(b1.s) << "\n";
std::cout << "\nTrying to move C\n";
C c1;
C c2 = std::move(c1); // 调用复制构造函数,因为移动构造函数被删除
std::cout << "\nTrying to move D\n";
D d1;
D d2 = std::move(d1); // 调用显式默认的移动构造函数
}
输出
Trying to move A
Move constructor called
Before move, a1.s = "test" a1.k = -1
Move constructor called
After move, a1.s = "" a1.k = 0
Trying to move B
Before move, b1.s = "test"
Move constructor called
After move, b1.s = ""
Trying to move C
Copy constructor called
Trying to move D
Move constructor called
总结
- 移动构造函数 用于从右值(如临时对象或
std::move
的结果)初始化一个新对象,通常比复制更高效。 - 编译器会根据类的成员和基类自动声明和定义这些特殊成员函数,但用户也可以显式声明和定义它们。
- 如果类包含某些特定类型的成员(如用户定义的析构函数、复制构造函数、复制赋值运算符或移动赋值运算符),编译器可能会删除默认生成的移动构造函数。
- 用户可以使用
= default
来显式要求编译器生成默认的移动构造函数,也可以使用= delete
来禁止生成这些函数。 - 移动构造函数通常会转移资源(如指针、文件描述符等),并将源对象置于有效但不确定的状态。
- 对于某些类型,如
std::unique_ptr
,移动后的状态是完全指定的。
复制省略 (Return Value Optimization, RVO)
从 C++17 开始,编译器必须在某些情况下优化掉移动构造函数调用,这种优化称为 复制省略 或 返回值优化 (RVO)。具体来说,当返回一个局部对象时,编译器可以直接将返回值构造在调用者的存储位置,而不需要调用移动构造函数。这不仅提高了性能,还避免了不必要的资源转移。
例如,在以下代码中,C++17 及之后的标准确保不会调用 A
的移动构造函数:
A f() {
A a;
return a; // C++17 及之后确保不会调用移动构造函数
}
强异常安全保证
为了确保强异常安全保证,用户定义的移动构造函数不应该抛出异常。例如,std::vector
依赖于 std::move_if_noexcept
来在元素需要重新分配时选择移动或复制。如果移动操作可能抛出异常,则 std::vector
会选择复制以确保安全性。
重载解析
如果同时提供了复制构造函数和移动构造函数,并且没有其他构造函数可用,则重载解析会选择适当的构造函数:
- 如果参数是右值(如
std::move
的结果或无名临时对象),则选择移动构造函数。 - 如果参数是左值(命名对象或返回左值引用的函数/运算符),则选择复制构造函数。
如果只提供了复制构造函数,则所有参数类别都会选择它(只要它接受对常量的引用,因为右值可以绑定到常量引用),这使得复制成为移动的回退,当移动不可用时。
示例解释
在上面的示例中:
-
A
类:展示了如何使用移动构造函数显式移动类类型成员(如std::string
)和非类类型成员(如int
)。移动后,源对象的状态被修改(s
被清空,k
被设置为 0)。 -
B
类:展示了隐式定义的移动构造函数如何自动调用基类和成员的移动构造函数。移动后,b1
的s
成员被清空。 -
C
类:由于声明了用户定义的析构函数,隐式移动构造函数被删除,因此只能使用复制构造函数。移动失败,输出显示复制构造函数被调用。 -
D
类:尽管声明了用户定义的析构函数,但通过= default
强制生成了移动构造函数,因此移动成功。
结论
移动构造函数是 C++11 引入的重要特性,能够显著提高性能,特别是在处理大型对象或资源密集型对象时。理解移动构造函数的工作原理及其与复制构造函数的区别,对于编写高效、安全的 C++ 代码至关重要。
移动赋值运算符 (Move Assignment Operator)
移动赋值运算符是 C++11 引入的一种特殊成员函数,用于从右值(如临时对象或 std::move
的结果)将资源转移到当前对象。与复制赋值运算符不同,移动赋值运算符可以转移资源(如动态分配的内存、文件描述符等),而不是创建副本。这使得移动操作通常比复制更高效。
1. 语法
移动赋值运算符可以通过以下几种方式声明和定义:
-
在类内声明:
class MyClass { public: MyClass& operator=(MyClass&& other); // 移动赋值运算符 };
-
在类内定义:
class MyClass { public: MyClass& operator=(MyClass&& other) { // 移动赋值运算符的实现 return *this; } };
-
显式默认:使用
= default
关键字来显式要求编译器生成默认的移动赋值运算符。class MyClass { public: MyClass& operator=(MyClass&& other) = default; };
-
删除移动赋值运算符:使用
= delete
关键字来禁止生成移动赋值运算符。class MyClass { public: MyClass& operator=(MyClass&& other) = delete; };
-
在类外定义:可以在类外部定义移动赋值运算符,但类内部必须有相应的声明。
class MyClass { public: MyClass& operator=(MyClass&& other); }; MyClass& MyClass::operator=(MyClass&& other) { // 移动赋值运算符的实现 return *this; }
2. 隐式声明的移动赋值运算符
如果类没有提供用户定义的移动赋值运算符,并且满足以下所有条件,编译器将隐式声明一个移动赋值运算符作为其类的 inline public
成员:
- 类没有用户声明的复制构造函数。
- 类没有用户声明的移动构造函数。
- 类没有用户声明的复制赋值运算符。
- 类没有用户声明的析构函数。
class MyClass {
int x;
};
在这个例子中,编译器会为 MyClass
自动生成一个 MyClass& operator=(MyClass&&)
形式的移动赋值运算符。
3. 隐式定义的移动赋值运算符
当隐式声明的移动赋值运算符被 odr 使用(即在程序中实际需要其定义)时,编译器会生成并编译该运算符的函数体。对于非联合类类型,移动赋值运算符会按成员顺序逐个移动每个非静态数据成员和直接基类子对象。
class MyClass {
int x;
std::string s;
public:
MyClass& operator=(MyClass&& other) = default;
};
MyClass obj1(42, "hello");
MyClass obj2 = std::move(obj1); // 调用移动赋值运算符
4. 平凡的移动赋值运算符
如果满足以下所有条件,类的移动赋值运算符是平凡的:
- 它不是用户提供的(即它是隐式定义的或默认为)。
- 类没有虚成员函数。
- 类没有虚基类。
- 为类的每个直接基类选择的移动赋值运算符是平凡的。
- 为类的每个非静态类类型(或类类型数组)成员选择的移动赋值运算符是平凡的。
平凡的移动赋值运算符实际上只是简单地复制了参数的每个标量子对象,而不执行其他操作。具有平凡移动赋值运算符的对象可以通过手动复制其对象表示形式来移动,例如使用 std::memmove
。所有与 C 语言兼容的数据类型(POD 类型)都是平凡可移动的。
struct PlainOldData {
int x;
double y;
};
在这个例子中,PlainOldData
的移动赋值运算符是平凡的,因为它满足上述所有条件。
5. 已删除的移动赋值运算符
如果满足以下任何条件,类的隐式声明或显式默认的移动赋值运算符会被定义为已删除:
- 类具有类类型
M
(或可能是其多维数组)的可能已构造的子对象,使得:M
的析构函数被删除或无法从复制构造函数访问,或者- 应用于查找
M
的移动赋值运算符的重载解析没有导致可用候选者,或者 - 在子对象是变体成员的情况下,选择了非平凡函数。
- 类声明了用户定义的析构函数、复制构造函数、复制赋值运算符或移动构造函数。
class InvalidClass {
std::unique_ptr<int> ptr; // 移动赋值运算符存在
~InvalidClass() {} // 用户定义的析构函数
};
在这个例子中,InvalidClass
的移动赋值运算符被隐式定义为已删除,因为类声明了用户定义的析构函数。
6. 示例
#include <iomanip>
#include <iostream>
#include <string>
#include <utility>
struct A {
std::string s;
int k;
A() : s("test"), k(-1) {}
A(const A& o) : s(o.s), k(o.k) { std::cout << "Copy constructor called\n"; }
A(A&& o) noexcept :
s(std::move(o.s)), // 显式移动类类型的成员
k(std::exchange(o.k, 0)) // 显式移动非类类型的成员
{
std::cout << "Move constructor called\n";
}
A& operator=(const A& o) {
if (this != &o) {
s = o.s;
k = o.k;
std::cout << "Copy assignment called\n";
}
return *this;
}
A& operator=(A&& o) noexcept {
if (this != &o) {
s = std::move(o.s); // 显式移动类类型的成员
k = std::exchange(o.k, 0); // 显式移动非类类型的成员
std::cout << "Move assignment called\n";
}
return *this;
}
};
A f(A a) {
return a;
}
struct B : A {
std::string s2;
int n;
// 隐式定义的移动赋值运算符
// 调用 A 的移动赋值运算符
// 调用 s2 的移动赋值运算符
// 并对 n 进行位复制
};
struct C : B {
~C() {} // 析构函数阻止隐式移动赋值运算符
};
struct D : B {
D() {}
~D() {} // 析构函数会阻止隐式移动赋值运算符
D& operator=(D&&) = default; // 强制生成移动赋值运算符
};
int main() {
std::cout << "Trying to move A\n";
A a1 = f(A()); // 返回值通过移动构造函数初始化目标
std::cout << "Before move, a1.s = " << std::quoted(a1.s)
<< " a1.k = " << a1.k << '\n';
A a2 = std::move(a1); // 从 xvalue 移动构造
std::cout << "After move, a1.s = " << std::quoted(a1.s)
<< " a1.k = " << a1.k << '\n';
A a3;
a3 = std::move(a2); // 从 xvalue 移动赋值
std::cout << "After move assignment, a2.s = " << std::quoted(a2.s)
<< " a2.k = " << a2.k << '\n';
std::cout << "\nTrying to move B\n";
B b1;
std::cout << "Before move, b1.s = " << std::quoted(b1.s) << "\n";
B b2 = std::move(b1); // 调用隐式定义的移动构造函数
std::cout << "After move, b1.s = " << std::quoted(b1.s) << "\n";
B b3;
b3 = std::move(b2); // 调用隐式定义的移动赋值运算符
std::cout << "After move assignment, b2.s = " << std::quoted(b2.s) << "\n";
std::cout << "\nTrying to move C\n";
C c1;
C c2 = std::move(c1); // 调用复制构造函数,因为移动构造函数被删除
C c3;
c3 = std::move(c2); // 调用复制赋值运算符,因为移动赋值运算符被删除
std::cout << "\nTrying to move D\n";
D d1;
D d2 = std::move(d1); // 调用显式默认的移动构造函数
D d3;
d3 = std::move(d2); // 调用显式默认的移动赋值运算符
}
输出
Trying to move A
Move constructor called
Before move, a1.s = "test" a1.k = -1
Move constructor called
After move, a1.s = "" a1.k = 0
Move assignment called
After move assignment, a2.s = "" a2.k = 0
Trying to move B
Before move, b1.s = "test"
Move constructor called
After move, b1.s = ""
Move assignment called
After move assignment, b2.s = ""
Trying to move C
Copy constructor called
Copy assignment called
Trying to move D
Move constructor called
Move assignment called
总结
- 移动赋值运算符 用于从右值(如临时对象或
std::move
的结果)将资源转移到当前对象,通常比复制更高效。 - 编译器会根据类的成员和基类自动声明和定义这些特殊成员函数,但用户也可以显式声明和定义它们。
- 如果类包含某些特定类型的成员(如用户定义的析构函数、复制构造函数、复制赋值运算符或移动构造函数),编译器可能会删除默认生成的移动赋值运算符。
- 用户可以使用
= default
来显式要求编译器生成默认的移动赋值运算符,也可以使用= delete
来禁止生成这些函数。 - 移动赋值运算符通常会转移资源(如指针、文件描述符等),并将源对象置于有效但不确定的状态。
- 对于某些类型,如
std::unique_ptr
,移动后的状态是完全指定的。
复制省略 (Return Value Optimization, RVO)
从 C++17 开始,编译器必须在某些情况下优化掉移动赋值运算符调用,这种优化称为 复制省略 或 返回值优化 (RVO)。具体来说,当返回一个局部对象时,编译器可以直接将返回值构造在调用者的存储位置,而不需要调用移动构造函数或移动赋值运算符。这不仅提高了性能,还避免了不必要的资源转移。
例如,在以下代码中,C++17 及之后的标准确保不会调用 A
的移动构造函数或移动赋值运算符:
A f() {
A a;
return a; // C++17 及之后确保不会调用移动构造函数或移动赋值运算符
}
强异常安全保证
为了确保强异常安全保证,用户定义的移动赋值运算符不应该抛出异常。例如,std::vector
依赖于 std::move_if_noexcept
来在元素需要重新分配时选择移动或复制。如果移动操作可能抛出异常,则 std::vector
会选择复制以确保安全性。
重载解析
如果同时提供了复制赋值运算符和移动赋值运算符,并且没有其他赋值运算符可用,则重载解析会选择适当的赋值运算符:
- 如果参数是右值(如
std::move
的结果或无名临时对象),则选择移动赋值运算符。 - 如果参数是左值(命名对象或返回左值引用的函数/运算符),则选择复制赋值运算符。
如果只提供了复制赋值运算符,则所有参数类别都会选择它(只要它接受对常量的引用,因为右值可以绑定到常量引用),这使得复制成为移动的回退,当移动不可用时。
示例解释
在上面的示例中:
-
A
类:展示了如何使用移动赋值运算符显式移动类类型成员(如std::string
)和非类类型成员(如int
)。移动后,源对象的状态被修改(s
被清空,k
被设置为 0)。 -
B
类:展示了隐式定义的移动赋值运算符如何自动调用基类和成员的移动赋值运算符。移动后,b1
的s
成员被清空。 -
C
类:由于声明了用户定义的析构函数,隐式移动赋值运算符被删除,因此只能使用复制赋值运算符。移动失败,输出显示复制赋值运算符被调用。 -
D
类:尽管声明了用户定义的析构函数,但通过= default
强制生成了移动赋值运算符,因此移动成功。
结论
移动赋值运算符是 C++11 引入的重要特性,能够显著提高性能,特别是在处理大型对象或资源密集型对象时。理解移动赋值运算符的工作原理及其与复制赋值运算符的区别,对于编写高效、安全的 C++ 代码至关重要。
转换构造函数 (Converting Constructor)
在 C++ 中,转换构造函数是指那些可以使用单个参数调用的构造函数,并且没有使用 explicit
指定符声明。这些构造函数允许从其参数的类型隐式转换为类的类型。与显式构造函数不同,转换构造函数不仅在直接初始化(包括显式转换,例如 static_cast
)期间被考虑,还在复制初始化期间作为用户定义的转换序列的一部分被考虑。
1. 转换构造函数的特点
- 隐式转换:转换构造函数允许从其参数的类型隐式转换为类的类型。
- 复制初始化:在复制初始化(如
A a = 1;
)期间,转换构造函数会被考虑。 - 直接初始化:在直接初始化(如
A a(2);
)和列表初始化(如A a{4, 5};
)期间,转换构造函数也会被考虑。 - 显式转换:即使使用
static_cast
进行显式转换,转换构造函数仍然会被调用。
2. 显式构造函数 (Explicit Constructor)
使用 explicit
关键字声明的构造函数称为显式构造函数。显式构造函数不会在复制初始化期间被考虑,因此不能用于隐式转换。它们只能在直接初始化或显式转换时使用。
struct B {
explicit B() {}
explicit B(int) {}
explicit B(int, int) {}
};
在这个例子中,B
的构造函数都被声明为 explicit
,因此它们不会在复制初始化期间被考虑。
3. 示例代码解析
#include <iostream>
struct A {
A() { std::cout << "A::A()\n"; }
A(int) { std::cout << "A::A(int)\n"; }
A(int, int) { std::cout << "A::A(int, int)\n"; }
};
struct B {
explicit B() { std::cout << "B::B()\n"; }
explicit B(int) { std::cout << "B::B(int)\n"; }
explicit B(int, int) { std::cout << "B::B(int, int)\n"; }
};
int main() {
// A 类的转换构造函数
A a1 = 1; // OK: 复制初始化,调用 A::A(int)
A a2(2); // OK: 直接初始化,调用 A::A(int)
A a3{4, 5}; // OK: 直接列表初始化,调用 A::A(int, int)
A a4 = {4, 5}; // OK: 复制列表初始化,调用 A::A(int, int)
A a5 = (A)1; // OK: 显式转换,调用 A::A(int)
// B 类的显式构造函数
// B b1 = 1; // error: 复制初始化不考虑 B::B(int),因为它是显式的
B b2(2); // OK: 直接初始化,调用 B::B(int)
B b3{4, 5}; // OK: 直接列表初始化,调用 B::B(int, int)
// B b4 = {4, 5}; // error: 复制列表初始化选择了显式的 B::B(int, int)
B b5 = (B)1; // OK: 显式转换,调用 B::B(int)
B b6; // OK: 默认初始化,调用 B::B()
B b7{}; // OK: 直接列表初始化,调用 B::B()
// B b8 = {}; // error: 复制列表初始化选择了显式的 B::B()
[](...){}(a1, a4, a4, a5, b5); // 可能抑制 "unused variable" 警告
}
4. 输出解释
A::A(int)
A::A(int)
A::A(int, int)
A::A(int, int)
A::A(int)
B::B(int)
B::B(int, int)
B::B(int)
B::B()
B::B()
5. 详细说明
-
A
类:A a1 = 1;
:复制初始化,调用A::A(int)
,因为A
的构造函数是转换构造函数,允许隐式转换。A a2(2);
:直接初始化,调用A::A(int)
。A a3{4, 5};
:直接列表初始化,调用A::A(int, int)
。A a4 = {4, 5};
:复制列表初始化,调用A::A(int, int)
。A a5 = (A)1;
:显式转换,调用A::A(int)
。
-
B
类:B b1 = 1;
:编译错误,因为B::B(int)
是显式构造函数,不能用于复制初始化。B b2(2);
:直接初始化,调用B::B(int)
。B b3{4, 5};
:直接列表初始化,调用B::B(int, int)
。B b4 = {4, 5};
:编译错误,因为B::B(int, int)
是显式构造函数,不能用于复制列表初始化。B b5 = (B)1;
:显式转换,调用B::B(int)
。B b6;
:默认初始化,调用B::B()
。B b7{};
:直接列表初始化,调用B::B()
。B b8 = {};
:编译错误,因为B::B()
是显式构造函数,不能用于复制列表初始化。
6. 注意事项
-
避免隐式转换:虽然转换构造函数提供了方便的隐式转换功能,但在某些情况下可能会导致意外的行为。为了避免不必要的隐式转换,建议使用
explicit
关键字来声明构造函数,除非确实需要隐式转换。 -
多重隐式转换:如果一个类有多个转换构造函数,可能会导致编译器无法确定使用哪个构造函数,从而引发编译错误。因此,在设计类时应谨慎处理转换构造函数,避免引入过多的隐式转换路径。
-
复制初始化 vs. 直接初始化:
- 复制初始化:使用等号赋值语法(如
A a = 1;
),会考虑转换构造函数。 - 直接初始化:使用圆括号或花括号语法(如
A a(2);
或A a{4, 5};
),不会考虑显式构造函数,但会考虑转换构造函数。
- 复制初始化:使用等号赋值语法(如
-
列表初始化:C++11 引入了列表初始化(如
A a{4, 5};
),它可以在某些情况下提供更安全的初始化方式,尤其是在处理显式构造函数时。
7. 总结
- 转换构造函数:允许从其参数的类型隐式转换为类的类型,适用于复制初始化、直接初始化和显式转换。
- 显式构造函数:使用
explicit
关键字声明,禁止隐式转换,只适用于直接初始化和显式转换。 - 设计建议:为了防止意外的隐式转换,建议尽量使用
explicit
关键字声明构造函数,除非确实需要隐式转换。
理解转换构造函数和显式构造函数的区别对于编写安全、高效的 C++ 代码非常重要,特别是在处理类的初始化和类型转换时。
explicit
说明符
explicit
是 C++ 中的关键字,用于指定构造函数或用户定义的转换函数为显式的。这意味着这些函数不能用于隐式转换和复制初始化。explicit
的使用可以防止意外的类型转换,从而提高代码的安全性和可读性。
1. 语法
-
普通用法:
explicit (constructor or conversion function);
-
带条件表达式的用法(自 C++20 起):
explicit (expression) (constructor or conversion function);
其中
expression
是一个布尔类型的常量表达式。只有当该表达式求值为true
时,构造函数或转换函数才是显式的。
2. 应用范围
- 构造函数:
explicit
可以应用于单参数构造函数(除了复制构造函数和移动构造函数),以及多参数构造函数(自 C++11 起)。 - 用户定义的转换函数:
explicit
可以应用于用户定义的转换函数(自 C++11 起)。 - 推断指南:
explicit
可以应用于模板推断指南(自 C++17 起)。
3. 行为
- 显式构造函数:不能用于隐式转换和复制初始化,但可以用于直接初始化和显式转换(如
static_cast
)。 - 隐式构造函数:可以用于隐式转换和复制初始化。
4. 示例代码解析
#include <iostream>
struct A {
A(int) { std::cout << "A::A(int)\n"; }
A(int, int) { std::cout << "A::A(int, int)\n"; }
operator bool() const { return true; }
};
struct B {
explicit B(int) { std::cout << "B::B(int)\n"; }
explicit B(int, int) { std::cout << "B::B(int, int)\n"; }
explicit operator bool() const { return true; }
};
int main() {
// A 类的隐式构造函数
A a1 = 1; // OK: 复制初始化,调用 A::A(int)
A a2(2); // OK: 直接初始化,调用 A::A(int)
A a3{4, 5}; // OK: 直接列表初始化,调用 A::A(int, int)
A a4 = {4, 5}; // OK: 复制列表初始化,调用 A::A(int, int)
A a5 = (A)1; // OK: 显式转换,调用 A::A(int)
if (a1) { } // OK: 隐式转换为 bool,调用 A::operator bool()
bool na1 = a1; // OK: 复制初始化,调用 A::operator bool()
bool na2 = static_cast<bool>(a1); // OK: 显式转换,调用 A::operator bool()
// B 类的显式构造函数
// B b1 = 1; // error: 复制初始化不考虑 B::B(int),因为它是显式的
B b2(2); // OK: 直接初始化,调用 B::B(int)
B b3{4, 5}; // OK: 直接列表初始化,调用 B::B(int, int)
// B b4 = {4, 5}; // error: 复制列表初始化不考虑 B::B(int, int),因为它是显式的
B b5 = (B)1; // OK: 显式转换,调用 B::B(int)
if (b2) { } // OK: 显式转换为 bool,调用 B::operator bool()
// bool nb1 = b2; // error: 复制初始化不考虑 B::operator bool(),因为它是显式的
bool nb2 = static_cast<bool>(b2); // OK: 显式转换,调用 B::operator bool()
[](...){}(a4, a5, na1, na2, b5, nb2); // 抑制 "unused variable" 警告
}
5. 输出解释
A::A(int)
A::A(int)
A::A(int, int)
A::A(int, int)
A::A(int)
B::B(int)
B::B(int, int)
B::B(int)
6. 详细说明
-
A
类:A a1 = 1;
:复制初始化,调用A::A(int)
,因为A
的构造函数是隐式的,允许隐式转换。A a2(2);
:直接初始化,调用A::A(int)
。A a3{4, 5};
:直接列表初始化,调用A::A(int, int)
。A a4 = {4, 5};
:复制列表初始化,调用A::A(int, int)
。A a5 = (A)1;
:显式转换,调用A::A(int)
。if (a1) { }
:隐式转换为bool
,调用A::operator bool()
。bool na1 = a1;
:复制初始化,调用A::operator bool()
。bool na2 = static_cast<bool>(a1);
:显式转换,调用A::operator bool()
。
-
B
类:B b1 = 1;
:编译错误,因为B::B(int)
是显式的,不能用于复制初始化。B b2(2);
:直接初始化,调用B::B(int)
。B b3{4, 5};
:直接列表初始化,调用B::B(int, int)
。B b4 = {4, 5};
:编译错误,因为B::B(int, int)
是显式的,不能用于复制列表初始化。B b5 = (B)1;
:显式转换,调用B::B(int)
。if (b2) { }
:显式转换为bool
,调用B::operator bool()
。bool nb1 = b2;
:编译错误,因为B::operator bool()
是显式的,不能用于复制初始化。bool nb2 = static_cast<bool>(b2);
:显式转换,调用B::operator bool()
。
7. C++20 的新特性
从 C++20 开始,explicit
说明符可以与布尔常量表达式一起使用,形成条件显式说明符。只有当表达式求值为 true
时,构造函数或转换函数才是显式的。
struct S {
explicit(true) S(int) { std::cout << "S::S(int)\n"; }
explicit(false) S(double) { std::cout << "S::S(double)\n"; }
explicit(true) operator bool() const { return true; }
};
int main() {
S s1(1); // OK: 直接初始化,调用 S::S(int)
// S s2 = 1; // error: 复制初始化不考虑 S::S(int),因为它是显式的
S s3(2.0); // OK: 直接初始化,调用 S::S(double)
S s4 = 2.0; // OK: 复制初始化,调用 S::S(double),因为它是非显式的
if (s1) { } // OK: 显式转换为 bool,调用 S::operator bool()
// bool b1 = s1; // error: 复制初始化不考虑 S::operator bool(),因为它是显式的
bool b2 = static_cast<bool>(s1); // OK: 显式转换,调用 S::operator bool()
}
8. 注意事项
-
避免不必要的隐式转换:
explicit
说明符可以帮助防止意外的隐式转换,尤其是在处理复杂的类层次结构时。建议尽量将单参数构造函数和用户定义的转换函数声明为explicit
,除非确实需要隐式转换。 -
条件显式说明符(C++20):通过使用条件表达式,可以在编译时根据某些条件决定是否使构造函数或转换函数显式化。这提供了更大的灵活性,但需要注意表达式的复杂性,确保其在编译时可以正确求值。
-
复制初始化 vs. 直接初始化:
- 复制初始化:使用等号赋值语法(如
A a = 1;
),会考虑隐式构造函数。 - 直接初始化:使用圆括号或花括号语法(如
A a(2);
或A a{4, 5};
),不会考虑显式构造函数,但会考虑隐式构造函数。
- 复制初始化:使用等号赋值语法(如
-
列表初始化:C++11 引入了列表初始化(如
A a{4, 5};
),它可以在某些情况下提供更安全的初始化方式,尤其是在处理显式构造函数时。
9. 总结
explicit
说明符:用于指定构造函数或用户定义的转换函数为显式的,防止隐式转换和复制初始化。- 条件显式说明符(C++20):可以通过布尔常量表达式控制构造函数或转换函数是否显式化。
- 设计建议:为了防止意外的隐式转换,建议尽量使用
explicit
关键字声明构造函数和转换函数,除非确实需要隐式转换。
理解 explicit
说明符的使用对于编写安全、高效的 C++ 代码非常重要,尤其是在处理类的初始化和类型转换时。