事先说明
资料来源:
- 《Effective Modern C++(社区翻译版本)》
- 《Modern C++ Tutorial: C++11/14/17/20 On the Fly》
- 《现代 C++ 模板教程》
- 《Google 开源项目风格指南——中文版》
- 《C++ STL Tutorial》
- 《C++之继承详解(万字讲解)》
- 《掌握虚函数、纯虚函数与抽象类:C++多态基石详解》
还有各处零散资料,不一一例举。同 C 语言差不多的地方,也不会写在本文。
编写规范
语言风格
Google 风格:
头文件:
- 头文件中需要写好需要导入的库,所有调用该头文件函数的文件不需要额外导入什么新的文件。
- 所有头文件都应该用
#define防护符来防止重复导入. 防护符的格式是:<项目>_<路径>_<文件名>_H_。 - 若 foo.cc 使用了 bar.h 的符号,就需要导入 bar.h,即使 foo.h 已经导入了 bar.h。不要依赖间接导入。
- 禁止
extern,直接使用#include。 - 导入顺序:配套的头文件,C 语言系统库头文件,C++ 标准库头文件,其他库的头文件,本项目的头文件。每个种类隔一层空行。
- 头文件内放置声明,类型别名,宏定义和模板实现和简单的工具函数,其余不要放。只在公开的命名空间写公开 API,不要在里面写一些不公开的别名,变量和函数。
作用域:
- 每个文件内容都写在自己的命名空间,仅本文件使用则使用匿名命名空间遮蔽,其余的命名格式为:
<项目名>::<模块名>::<文件名>_<名字>,全小写。 - 用注释注明命名空间的名字给命名空间收尾
- 禁止内联命名空间
- 每个文件内容都写在自己的命名空间,仅本文件使用则使用匿名命名空间遮蔽,其余的命名格式为:
类:
- 需要相应构造显式写出,不需要则显式删除。
命名约定:
- 文件名采用小写+蛇型,使用 cc,h,inc 结尾
- 类型命名用大驼峰
- 变量命名用小写+蛇型,类变量要在末尾加下划线
- 常量命名用小驼峰,首字母为 k
- 函数命名用大驼峰
- 宏命名大写+蛇型,不建议使用宏
微软风格:同 C 语言一致。
注意事项
尽量使用现代 Cpp 写法
语言要求
main
任何 Cpp 程序必须从 main 函数开始,与 C 语言差不多。另外 Cpp 中构造函数也是在 main 函数之前执行。
C 头文件的迁移
有些 C 头文件被转换为 C++ 头文件,这些文件被重新命名,去掉了扩展后缀 h,并在文件名称前面加上前缀 c。表示来自 C 语言但是改造成了 C++ 的库。例如,C++ 版本的 math.h 为 cmath。
有时 C 头文件的 C 版本和 C++版本相同,而有时候新版本做了一些修改。所以建议用 C++ 风格的头文件
命名空间
命名空间是为了避免命名重复和污染而产生的。
定义:
// 命名空间可以定义变量,函数,类型。
namespace Jill{
double bucket(double n){ ... }
double fetch;
int pal;
struct Hill{ ... };
}
// 可以命名同样名称的命名空间,但是最后会合并成同一个。
namespace Jill{
void fuck;
}
// 可以嵌套
namespace outer{namespace inter{}}
// 等效于上面
namespace outer::inter{}
// 可匿名
namespace{}
// 全局变量真实写法
int g = 42;
void f();
// cpp 有个隐式的名称空间,被成为全局名称空间,同名字意思。等同于上面
namespace /* global */ {
int g = 42;
void f();
}
int main(){
::f(); // 所以可以用:: 指是全局函数
}使用:
namespace myspace {
int a;
}
int main(){
myspace::a = 1; // 通过限定符调用内部内容
using namespace myspace; // 直接展开该命名空间所有内容,不推荐
using myspace::a; // 展开该命名空间部分内容
}类型
字面量
C++11 允许用户自定义字面量,可以理解为相应的字面量会按照用户的想法处理,但是这个必须符合 C++ 标准给出的形参格式。 一般来说这个可以用来对字面量做一些处理和符号重载。具体参考规定:https://cppreference.cn/w/cpp/language/user_literal
类型转化
| 运算符 | 主要用途 / 语义 | 检查时机 | 风险与注意事项 |
|---|---|---|---|
static_cast | 编译期类型转换(“正常的”类型间转换) | 编译期 | 仅做语义合理的转换,编译器会做基本检查。下行转换不做运行时检查,类型不匹配会产生未定义行为 |
dynamic_cast | 运行时带检查的多态类型转换(安全 downcast) | 运行期(RTTI) | 只能用于类的指针/引用,且类必须是多态类型(至少一个虚函数)。指针转换失败返回 nullptr,引用失败抛 std::bad_cast |
reinterpret_cast | 底层“重解释”转换(仅重解释比特位) | 编译期(几乎不检查语义) | 极易导致未定义行为和不可移植代码,仅在非常清楚底层布局时使用,普通业务代码应尽量避免 |
const_cast | 改变表达式的 const / volatile 属性 | 编译期 | “若原始对象本身是 const,通过去 const 后修改它”属于是未定义行为,只应用于“本体非 const,只是接口写成 const”的场景 |
普通类型
| 类别 | C++ 中的东西 | 在 C 里的情况 | 说明与典型用法 |
|---|---|---|---|
| 布尔类型 | bool(关键字),true / false(关键字) | C99 起在 <stdbool.h> 中用宏:typedef _Bool bool;,true/false 是宏,不是关键字 | 在 C++ 中 bool 是内建基本类型,语法层面更“第一等”;在 C 中要 #include <stdbool.h> 才有 bool,底层是 _Bool。 |
| 布尔字面量 | true / false(关键字) | 在 C99 里是宏:#define true 1 / #define false 0(通过 <stdbool.h> 提供) | 从语法上 C++ 支持 bool x = true; 不需要任何头文件;C 要 #include <stdbool.h> 才有。 |
| 宽字符类型 | wchar_t 是内建关键字类型 | C 中 wchar_t 是通过头文件引入的 typedef(<wchar.h> 或 <stddef.h>),不是关键字 | 用于宽字符(如 Unicode 宽字符)处理:wchar_t c = L'中';,搭配 L"宽字符串"、wprintf 等。C 与 C++ 都有,但 C++ 把它当关键字处理,C 则当普通 typedef。 |
| UTF 字符类型 | char16_t,char32_t(关键字类型) | C11 中没有 char16_t/char32_t 关键字,只有 _Char16_t / _Char32_t 这样的保留标识用于实现内部;用户一般不用 | C++11 引入了专门的 UTF-16 / UTF-32 字符类型和字面量:u'a',u"...",U'a',U"..."。C11 也有 u"..." / U"..." 字面量,但没有对应的关键字类型供直接声明。 |
| UTF 字符串字面量 | u"abc"(char16_t const[]),U"abc"(char32_t const[]) | C11 也支持 u"..." / U"..." 宽字符串字面量,但对应类型是实现相关的宽字符数组(常是 char16_t/char32_t 的内部等价),不直接暴露关键字类型 | 区别主要在:C++ 把 char16_t/char32_t 作为独立基本类型公开;C 偏向通过宽字符串和库来处理。 |
| 原始字符串字面量 | R"(原始字符串\n不需要转义)" | C 标准没有原始字符串字面量;GCC 等有 R"(...)" 作为 C 扩展,但不是标准 C | C++11 标准引入的特性:不需要对反斜杠、引号做转义,适合正则表达式、路径等。C 里想要类似效果只能用多行字符串拼接或宏。 |
| 空指针字面量 | nullptr(关键字,类型为 std::nullptr_t) | C 没有 nullptr,只有宏 NULL(一般是 0 或 (void*)0) | C++ 不允许直接将 void * 隐式转换到其他类型,从而 ((void*)0) 不是 NULL 的合法实现,而用 0 赋值又无法解决重载的混乱。故创造了 nullptr,可隐式转换到任意指针类型。 |
nullptr_t 类型 | std::nullptr_t(在 <cstddef> 中) | C 无对应类型,只能用 void* 或整型表示“空指针值” | 便于模板与重载区分“真·空指针”与整数 0。 |
复合结构
结构体
在 C++ 中,struct 等价于类 class。
联合体
union 语法与类一致,如果添加一个有非平凡构造析构的类型,需要自己手动析构。
std::variant 和 std::visit
std::variant 是 C++17 引入的一个更类型安全型的联合体。std::visit 是一个相关的访问器,避免重复写一堆 std::get 来访问 std::variant。
枚举
C++ 引入了 enum class 关键字,虽然仍然能用 enum,但是已经完全不推荐了。
enum 所有枚举成员名字都直接放在外层作用域中,而且本质上是 int,可以用隐式当整形用,容易混淆。而 enum class 解决了这些问题。
// 解决作用域问题
enum class Color { Red,Green };
enum class TrafficLight { Red,Green,Yellow };
Color c = Color::Red;
TrafficLight t = TrafficLight::Red;
// 解决隐式转化问题
Color c = Color::Red;
int x = c; // 编译错误:不能隐式转成 int
c = 1; // 编译错误:不能用整数给 enum class 赋值
// 指定底层类型
enum class Color : unsigned char {
Red,
Green,
Blue
};类型推断
auto
auto 只能初始化用,用于自动推断类型,编译期开始.
从 C++ 14 起,auto 能用于 lambda 表达式中的函数传参,而 C++ 20 起该功能推广到了一般的函数和模板返回值。但是 auto 还不能用于推导数组类型
decltype
decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。用于处理表达式。例如:decltype (a+b) c = a + b;
所以 decltype 可以推断 const,引用等值类别,会严格保留相应表达式类别,而不只是类型。
C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置来推断模板返回值类型,不过 C++14 开始是可以直接让普通函数具备返回值推导。下例:
template<typename T,typename U>
auto add2(T x,U y) -> decltype(x+y){
return x + y;
}decltype(auto)
decltype(auto) 会像 decltype 一样分析表达式的类型和值类别,但语法上像 auto 一样不需要显式指定表达式。
左右值
凡是需要区分左值右值的,统一用通用引用来重载。第一代码更可读,否则每个都要写一个重载,太麻烦了; 第二对于某些量(比如字符串字面量),性能更好,可以避免一次构造,原因就是它是 const,数组形式不会退化。 凡是用通用引用的,统一用 forward 转发; 凡是用右值引用的,统一用 move 移动。
右值引用
左值,右值,将亡值
左值,顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
右值,右边的值,是指表达式结束后就不再存在的临时对象。 而中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。
纯右值,纯粹的右值,要么是纯粹的字面量,例如;要么是求值结果相当于字面量或匿名临时对象,例如。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、表达式都属于纯右值。但是字符串字面量不算右值,这是特例。
简单来说,左值,即可以用&取地址的值,右值就不可以,包括 return 返回的临时变量。
右值引用
使用 && 可以引用右值,这个引用本身是左值,因为可以取地址。比如 int &&a = 19,a 是左值,19 为右值。
常量引用 const T& 左值右值都可以,对于右值来说就是生成一个临时变量,延长声明周期,避免低效拷贝。
通用引用
在有类型推导的情况下,也就是类似下面情况:
template<typename T>
void f(T&& param); //param 是一个通用引用
auto&& var2 = var1; //var2 是一个通用引用这种可能是左值也可能是右值的情况叫做通用引用。
引用坍缩规则
| 形参类型 | 实参类型 | 推导后最终类型 | 说明 |
|---|---|---|---|
T& | &(左值引用) | T& | 左值引用 + 左值引用 → 左值引用 |
T& | &&(右值引用) | T& | 左值引用 + 右值引用 → 左值引用 |
T&& | &(左值引用) | T& | 右值引用 + 左值引用 → 左值引用 |
T&& | &&(右值引用) | T&& | 右值引用 + 右值引用 → 右值引用 |
移动语义
使用 std::move 可以把左值变成右值,实际上就是把资源所有权交出去了,这也叫做移动语义。源码上其实就是个转化,所以移动语义不移动。
注意如果类没有实现移动构造,默认调用还是拷贝构造,因为拷贝构造能接受右值(const)。
移动语义只有在移动对象实现移动操作,且值比较大(比如大于两个指针的大小),且进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为 noexcept,这三种情况都满足的时候,建议使用。
也不必如此严苛,日常尽量写移动,稍微注意一点这些情况就行。
完美转发
完美转发就是保持原本的参数类型,下面是例子
void process(const Widget& lvalArg); //处理左值
void process(Widget&& rvalArg); //处理右值
template<typename T> //用以转发 param 到 process 的模板
void logAndProcess(T&& param)
{
auto now = //获取现在时间
std::chrono::system_clock::now();
makeLogEntry("Calling 'process'",now);
process(std::forward<T>(param));
}
param 是通用引用,它指向的值会有相应类型,但是它本身是一个左值(把它当成指针)。但是我们通过 forward,就可以继续保留原有左右类型。
在以下情况会失败:
- 用花括号初始化
- 0 和 NULL 作为空指针
- 仅有声明的 static const 数据成员.
- 重载函数名或者模板名
- 位域
通用引用看来很完美,但是有个问题,如果写了通用引用的函数后,又写了一个重载函数,我们的目标数据有可能走的是通用引用而不是重载。比如说对于 1,short 明显比 int 更适合,而通用引用就可以做到这点,根据重载的规则,更能匹配的优先。
所以请不要对通用引用函数重载,或者你可以下面方法来解决:
- 对于移动成本的且总是被拷贝的可拷贝形参,按值传递
- SFINAE 和
enalbe if
智能指针
类别
| 智能指针 | 所有权 | 引用计数 | 主要用途 | 缺点 |
|---|---|---|---|---|
unique_ptr | 独占 | 无 | 管理独占资源,替代原始指针 | 不可拷贝,仅可移动。 |
shared_ptr | 共享 | 有 | 多所有者共享资源 | 有额外内存开销,可能循环引用 |
weak_ptr | 无(弱引用) | 不影响 | 解决循环引用,临时访问资源 | 不能直接访问对象,需通过 lock() |
实际开发中,应根据资源的所有权关系选择合适的智能指针:优先使用 unique_ptr(效率最高),需共享时使用 shared_ptr,配合 weak_ptr 解决循环引用问题。
shared_ptr 需要避免循环引用(如两个 shared_ptr 互相指向对方),否则引用计数永远不会归零,导致内存泄漏(需配合 weak_ptr 解决)。
另外相比 new,make_xxx 更具有优越性。具体原因无需说明,单从概念即可看出,new 的话要构造两次。
使用
智能指针能如普通指针一样使用, 因为被重载了。
.get() 获取原始指针
.release 取消托管,需要自己手动释放
.reset(xxx) 重置指针托管的地址,即释放掉原本托管的内存,用参数智能代替,如 xxx 为空则是直接释放
自定义删除器
我们会发现智能指针设置得很自由,有些销毁方式不是单纯的 delete 能解决的,所以智能指针是有自定义删除器的功能的。
有三种可以作为删除器:
- 函数指针
- 仿函数
- Lambda: 无状态的 Lambda(不捕获任何变量)或空的函数对象作为删除器不会增加
unique_ptr的大-小,这是一种零成本抽象。但如果使用函数指针或者有状态的删除器,unique_ptr的大小会增加。
std::unique_ptr<T,DeleterType> 和 std::shared_ptr<T> 不同,单从类型可以看出,如果 unique 要自定义删除器,要实例化的时候写上相应删除器类型。
自定义指针类型
通常,当我们写 std::unique_ptr<T,Deleter> 时,unique_ptr 内部会认为它管理的是一个类型为 T* 的指针。它提供的成员函数,比如 get(),就会返回一个 T* 类型。
例如,对于 std::unique_ptr<int>,它内部管理的指针类型是 int*。
但是问题出现了,如果当 T 本身就是句柄或指针类型时怎么办,编译器会因为没有可以匹配的删除器报错的。
故而在 std::unique_ptr<T,Deleter> 会检查其删除器类型 Deleter 中是否存在一个名为 pointer 的嵌套类型,来告诉它 T 应该是什么。
下例:
struct HandleDeleter {
// 告诉 unique_ptr,你管理的“指针”类型其实是 HANDLE
using pointer = HANDLE;
void operator()(HANDLE handle) const {
if (handle && handle != INVALID_HANDLE_VALUE) {
CloseHandle(handle);
}
}
};
// T 是 HANDLE,Deleter 是 HandleDeleter
// 因为 HandleDeleter:: pointer 存在,所以 UniqueHandle 内部的指针类型是 HANDLE,而不是 HANDLE*
// 否则编译不通过。
using UniqueHandle = std::unique_ptr<HANDLE,HandleDeleter>;函数
返回值
返回值类型也可以使用 &,但是不能是局部变量,所以用处也不大。
const 修饰返回值类型,会抑制移动语义。
参数
在声明中使用 const 来表示这个参数不可修改。同时也禁止 const 的地址赋给非 const 指针。
在声明中使用 & 表示传递了值本身,而不是拷贝。
在声明中可以设置默认参数值,但必须从右向左添加默认值,这是 C++ 识别顺序导致的。实参按从左到右的顺序依次被赋给相应的形参,而不能跳过任何参数。下例:
int harpo(int n,int m=4,int j=5); //VALID
int chico(int n,int m=6,int j); //INVALID
int groucho(int k=1,int m=2,int n=3); //VALID
beeps harpo(3,,8); // invalid,doesn't set m to 4重载
重载即通过构造多个同名不同参数列表的函数,达到调用同名函数使用相应参数列表的效果。
在 C++ 中重载是通过编译器构造不同函数名字完成的。如果使用 C 库,务必使用 extern "C",因为 C 语言的函数没有改变名字,所以如果只是正常调用编译器也不知道。
重载只和参数列表(类型,个数,顺序)有关,和返回值无关。故而返回值不同但是参数列表一致的函数是无效重载。
函数容器
建议单一需求使用 Lambda,需要在一个容器内容纳多种函数存在的时候使用 std::function,不要用 std::bind。
Lambda
底层实现是仿函数,也就是类对 () 的重载。其定义为:
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {/*函数体*/}捕获列表:
- [] 空捕获列表
- [name1,name2,…] 捕获一系列变量,被捕获的变量在 Lambda 表达式被创建时拷贝,而非调用时才拷贝。
- [&name1,&name2] 引用捕获。
- [&] 引用捕获,从函数体内的使用确定引用捕获列表
- [=] 值捕获,从函数体内的使用确定值捕获列表
- 表达式捕获 lambda 在 C++14 可以初始化,可以在里面写表达式。比如说原本值捕获只能捕获左值,C++ 14 后就可以使用 move 语义来初始化右值。
需要注意的是,如果按引用捕获,要注意是否悬空捕获,比如说 lambda 把一个局部变量引用了,结果 lambda 又被一个全局容器使用了,那么就会出现这个问题。所以相比默认 &,显式地 &name 更为地好,至少会提醒你。
也许有人会想,那我默认按值引用就好了,但是如果这个值本身是个指针,后续被 delete 了,那也是有问题的。甚至即使你使用智能指针,也有可能。所以建议还是显式捕获。
默认情况下,Lambda 表达式不能修改按值捕获的变量(捕获的副本被视为 const)。mutable 关键字的作用是解除这种限制,允许在 Lambda 内部修改按值捕获的变量副本。
在参数列表中运用 auto 的被称为泛型 lambda,因为编译器会生成一堆模板。
对于泛型 lambda 有个小问题,就是如果参数是按值引用,形参是左值,传进去是拷贝,效率会有点问题。可以用转发来实现,简单来说是,对 auto&& 形参使用 decltype 以 std::forward 它们。
std::functional
std::functional 是相比直接调用函数指针更安全(类型安全),更方便(可以容纳各种函数)的方式,换言之即函数的容器。下例:
#include <functional>
#include <iostream>
int foo(int para) {
return para;
}
int main() {
// std:: function 包装了一个返回值为 int,参数为 int 的函数
std::function<int(int)> func = foo;
int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}由于其是用虚函数表实现,在性能上会有一定问题。
std::bind
std::bind 则是用来绑定函数调用的参数的,由于其各种问题,现在已经被抛弃了,不做介绍。
重载运算符
您可以重定义或重载大部分 C++ 内置的运算符。这样,您就能使用自定义类型的运算符。
下面是不可重载的运算符列表:
.:成员访问运算符./->:成员指针访问运算符:::域运算符sizeof:长度运算符?::条件运算符#: 预处理符号
重载的运算符是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的。与其他函数一样,重载运算符有一个返回类型和一个参数列表。下例:
Box operator+(const Box&);面向对象
类的大部分情况和结构体差不多,相似东西不介绍。
成员函数
定义:
void Stock::updata(double price)this
成员函数中可以使用被称为 this 的特殊指针。该指针指向用来调用成员函数的对象(this 被作为隐藏参数传递给方法)。如果需要引用整个调用对象,可以用 *this,在函数括号后面使用 const 将 this 限定为 const,这样就不能使用 this 修改对象函数。
友元函数
friend
const
const Stock & topval(const Stock &s) const;
返回值 const 代表调用者不能通过返回的引用修改该 Stock 对象
最后一个 const 就代表常量成员函数,带有这个 const 的成员函数可以访问不能修改调用对象的非静态不带 mutable 修饰数据成员,只能修改 static 成员变量。
常量对象只能调用常量函数(这就是为什么大多数模板要实现常量和非常量两个函数),对非常量对象没有限制。
mutable 不能修饰常量成员函数。
构造与析构
构造函数有以下几种,而且有些时候会根据有无存在相应构造函数来实现相关功能,需要严格按照其定义实现:
- 普通构造函数:与类名同名,没有返回值类型(包括
void也不能有)。可以有参数,也可以没有参数,用于为对象的成员变量赋初值。 - 默认构造函数:可以是用户自定义的没有参数的构造函数,也可以是编译器自动生成的(前提是类中没有定义任何构造函数,且所有非静态成员都有默认初始化方式 )。默认构造函数用于在创建对象时,如果用户没有提供初始化参数,就按照默认的方式对对象进行初始化。
- 拷贝构造函数:函数形参是本类对象的引用(通常是
const引用,以避免不必要的拷贝),用于使用一个已存在的同类型对象来初始化新创建的对象。如果用户没有定义拷贝构造函数,编译器也会自动生成一个默认的拷贝构造函数,它会逐个成员进行浅拷贝。 - 移动构造函数:C++11 引入,函数形参是本类对象的右值引用,用于在对象移动时高效地转移资源所有权,而不是像拷贝构造函数那样进行数据拷贝,从而提高性能,避免不必要的资源复制。如果用户没有定义移动构造函数,在满足类中没有用户自定义的移动构造函数,拷贝构造函数,拷贝赋值运算符,析构函数且非静态变量和基类都符合移动语义时,编译器会自动生成一个默认的移动构造函数。
- 委托构造函数:一个构造函数通过
this关键字调用同一个类中的其他构造函数来完成对象的部分或全部初始化工作。这种方式可以避免在多个构造函数中重复编写相同的初始化代码。 - 转换构造函数:是只有一个参数(或者除了第一个参数外,其他参数都有默认值)的构造函数,它可以将参数类型隐式转换为本类对象。这种构造函数常用于实现类型转换。
关于编译器生成默认移动构造函数这么多的要求原因为:
- 如果类中已经定义了一个移动构造函数(包括显式定义和删除的移动构造函数),编译器就不会再生成默认的移动构造函数。因为编译器认为用户已经对对象移动时的资源处理有了明确的规划
- 若定义了拷贝构造函数,编译器会认为用户对对象的复制操作有特定的要求,这种要求可能和移动操作存在关联,或者用户想对对象的复制和移动行为做统一控制,此时编译器不会生成默认移动构造函数。
- 拷贝赋值运算符用于将一个对象的值赋给另一个对象。当定义了拷贝赋值运算符,编译器会认为用户对对象之间的赋值操作有特殊需求,和移动操作可能存在冲突,就不会生成默认移动构造函数。
- 析构函数用于在对象生命周期结束时释放资源。如果定义了析构函数,意味着对象内部可能涉及到一些复杂的资源管理,编译器无法简单地确定移动操作的安全性和正确性,也就不会生成默认移动构造函数。
- 类的每个非静态成员变量类型都必须支持移动构造或者拷贝构造。如果存在不支持移动构造且也没有合适拷贝构造函数的成员变量类型,编译器就无法生成默认移动构造函数。例如,类中有一个成员变量是自定义的不支持移动构造的类类型,且没有定义合适的拷贝构造函数,编译器就不会生成默认移动构造函数。
- 如果类继承自其他类,基类也需要满足上述条件,即基类要么有默认移动构造函数,要么有用户自定义的合适的移动构造函数、拷贝构造函数等。
析构是销毁对象时候要用的,析构的名字是 ~对象名字()。有动态变量时有着重注意实现。
下面为构造和析构例子:
class Widget {
private:
int* data;
int size;
std::string label;
public:
// 1. 普通构造函数(带参数)
Widget(int* p, int n, const std::string& s)
: data(p), size(n), label(s) {}
// 2. 默认构造函数(无参)
// 若类中未定义任何构造函数,编译器会自动生成一个(基本类型成员值不确定)
// 此处显式定义,故编译器不再自动生成
Widget() : data(nullptr), size(0), label("default") {}
// 3. 拷贝构造函数
// 使用已有同类型对象初始化新对象
// 若未定义,编译器自动生成(逐成员浅拷贝)
Widget(const Widget& other)
: size(other.size), label(other.label)
{
data = new int[size];
for (int i = 0; i < size; ++i)
data[i] = other.data[i];
}
// 4. 移动构造函数(C++11)
// 形参为非 const 右值引用,转移资源所有权
// 若未定义且无自定义拷贝/析构/拷贝赋值,编译器可能自动生成
Widget(Widget&& other) noexcept
: data(other.data), size(other.size), label(std::move(other.label))
{
other.data = nullptr; // 置空源对象,防止重复释放
other.size = 0;
}
// 5. 委托构造函数
// 调用同类的其他构造函数完成初始化,避免重复代码
Widget(const std::string& s) : Widget() // 委托给默认构造函数
{
label = s; // 然后仅修改 label
}
Widget(int n) : Widget(new int[n]{}, n, "array") {} // 委托给普通构造函数
// 6. 转换构造函数
// 单参数(或其他参数有默认值),支持隐式类型转换
Widget(const std::string& s) // 从 string 隐式转为 Widget
: data(nullptr), size(0), label(s) {}
// 用法: Widget w = std::string("hello"); 或 Widget w("hello");
// 若想禁止隐式转换,加 explicit:
// explicit Widget(int n) : Widget(new int[n]{}, n, "array") {}
~Widget() { delete[] data; }
};另外在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、 复制构造、赋值算符以及析构函数。 另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。 当程序员有需要时,可以重载这部分函数。
这就引发了一些需求:无法精确控制默认函数的生成行为。 例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private。 尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。
并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。
C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。 例如:
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
} 类的初始化方式
有以下几种:
- 默认初始化:在定义对象时不提供任何初始化值,对于内置类型成员变量,其值是未定义的(对于全局或静态存储期的内置类型变量,会初始化为 0 );对于类类型成员变量,会调用其默认构造函数进行初始化。如果类没有默认构造函数,就无法进行默认初始化。
- 直接初始化:使用圆括号
(),根据构造函数的参数列表,提供相应的初始化值,直接调用匹配的构造函数来初始化对象。 - 列表初始化:优先匹配接受
std::initializer_list参数的构造函数(如果有的话),如果没有则按照成员变量声明顺序依次进行初始化,对于没有默认构造函数的成员变量,必须在初始化列表中提供初始值。 - 拷贝初始化:故名思意,就是拷贝,实际上移动也算在拷贝初始化中。
- 委托构造初始化:一个构造函数通过
this关键字调用同一个类中的其他构造函数来完成对象的部分或全部初始化工作,减少代码重复。
class Point {
int main(){
Point p; // 默认初始化,调用默认构造函数
Point p(); // 直接初始化
Point p{}; // 值初始化
Point p1 = p; // 拷贝初始化
return 0;
}类的继承
定义
class father{...};
class son:public father{
...
};
//father 为父类,public 代表继承关系,可以继承多个类.继承关系:
| 类成员 / 继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
子类会默认调用父类的相应成员函数,无需操心。
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。
如果想要某个类不被继承,可以使用 final 关键字。
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
继承中的作用域
在继承体系中基类和派生类都有独立的作用域。
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显式访问)
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。所以在实际中注意继承体系里面最好不要定义同名的成员。
切片
派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
当然,基类对象不能赋值给派生类对象。除非基类的指针或者引用通过强制类型转换赋值给派生类的指针或者引用。但是此处基类的指针必须是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(RunTime Type Information)的 dynamic_cast 来进行识别后进行安全转换。
继承后的同名函数会根据指针的类型决定调用哪个,特别是运用引用的情况下。
虚拟继承
如果有这种情况:
classDiagram
direction RL
class Base {
+Base()
#int base_data
+void base_func()
}
class Derived1 {
+Derived1()
#int d1_data
+void d1_func()
}
Derived1 --|> Base : 继承
class Derived2 {
+Derived2()
#int d2_data
+void d2_func()
}
Derived2 --|> Base : 继承
class FinalDerived {
+FinalDerived()
#int d1_data
+void final_func()
#int d2_data
}
FinalDerived --|> Derived1
FinalDerived --|> Derived2
note for FinalDerived "出现问题,最后的类对于相同的数据要实现两份"
note for Base "基类:被多个派生类继承"可以看到 d1_data 和 d2_data 都被实现了.当然我们可以显式地访问相应数据,但是我们肯定不乐意这样做,所以有了 virtual,当 derived1 和 2 都虚拟继承 base 时,finalderived 直接继承两者,两个类型中一样的部分的指向改为同一个地址。
最好不要出现菱形继承关系,这样关系也很乱。
类的多态和 RTTI(runtime type identication,运行时多态)
override final 最好直接用,让编译器检查。
如果存在虚函数,无论实例化几个类,都会多出空间存放虚表的指针,但是虚表是同一张。
我对虚函数不太喜欢,所以不打算多介绍。
模板
类型模板参数
// `template` `typename` 都是必要的,除非用 `class` 替换 `typename`(两者等价),而且必须用尖括号
// 可带有默认类型
template <typename AnyType, typename T = int>
// 类也可以
void Swap(AnyType &a,AnyType &b){
AnyType temp;
temp = a;
a = b;
b = temp;
}
//template < 形参列表 > 函数声明编译器遇到模板会马上实例化,导致多个文件实例化同一个类型,浪费时间。在 C++ 11 能 extern 模板,在一个地方统一实例化,来节省模板实例化时间。
变长参数模板
template<typename... Ts> class Magic;
// 个数不限,0 也可以
void printf(const std::string &str,Ts... args) {
sizeof...(args); // 可获得函数中调用的参数个数
}
// Ts 是每个参数的类型,args是每个参数的值可以通过递归解包,或者初始化列表,或者使用在 C++17 的展开语法,下面是两个例子:
// 递归
template<typename T>
void printf(T v) {
std::cout << v << std::endl;
}
template<typename T, typename... Ts>
void printf(T v,Ts... args) {
std::cout << v << std::endl;
printf(args...);
}
// 初始化列表
template<typename T,typename... Ts>
auto printf(T v,Ts... args) {
std::cout << v << std::endl;
// 通过初始化列表,`(lambda 表达式,value)...` 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。 为了避免编译器警告,我们可以将 `std::initializer_list` 显式的转为 `void`。
// 需要注意,这里之所以能够实现是因为 `…` 是作用与一个表达式的,或者说就是代表要展开参数。而 lambda 作为一个表达式就自然反复被调用。再通过 initializer_list 变成列表强制在编译期展开。
(void) std::initializer_list<T>{([&args] {
std::cout << args << std::endl;
}(),v)...};
}
// C++ 17 展开语法
template<typename T,typename... Ts>
void printf2(T v,Ts... args) {
std::cout << v << std::endl;
//注意这是编译期的事情,所以不能简单用 if 解决,故而 17 才成功了
if constexpr (sizeof...(args) > 0) printf2(args...);
}C++ 17 中将变长参数这种特性进一步带给了表达式,有四种类别: ( 形参包 运算符 … ) 一元右折叠 ( … 运算符 形参包 ) 一元左折叠 ( 形参包 运算符 … 运算符 初值 ) 二元右折叠 ( 初值 运算符 … 运算符 形参包 ) 二元左折叠
下例:
#include <iostream>
// 一元右折叠:(a + ...) 相当于 a1 + (a2 + (a3 + ...))
template<typename... Args>
auto sum_right(Args... args) {
return (args + ...);
}
// 一元左折叠:(... * a) 相当于 (((a1 * a2) * a3) * ...)
template<typename... Args>
auto product_left(Args... args) {
return (... * args);
}
// 二元右折叠:(a + ... + 10) 相当于 a1 + (a2 + (a3 + 10))
template<typename... Args>
auto sum_right_with_init(Args... args) {
return (args + ... + 10);
}
// 二元左折叠:(100 - ... - a) 相当于 (((100 - a1) - a2) - a3)
template<typename... Args>
auto subtract_left_with_init(Args... args) {
return (100 - ... - args);
}
int main() {
std::cout << sum_right(1,2,3,4) << std::endl; // 1+(2+(3+4))= 10
std::cout << product_left(1,2,3,4) << std::endl; // ((1 *2)* 3)*4 = 24
std::cout << sum_right_with_init(1,2,3) << std::endl; // 1+(2+(3+10))= 16
std::cout << subtract_left_with_init(10,20,30) << std::endl; // ((100-10)-20)-30 = 40
return 0;
}非类型模板参数推导
让不同字面量成为模板参数,即非类型模板参数:
// 在 C++17 后可以使用 auto 在模板参数中推导
template <typename T,auto value, int BufSize>
class buffer_t {
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
T a = value;
}
buffer_t<int, 1, 100> buf; // value 被推导成 int,100 作为模板参数特殊模板
类模板不是类,只有实例化类模板,编译器才能生成实际的类。变量模板同理,二者语法和函数模板差不多。
主要讲一下用户定义的推导指引,格式为 模板名称(类型a)->模板名称<想要让类型a被推导为的类型>。 如果涉及的是一类类型,那么就需要加上 template,然后使用它的模板形参。
下例:
template<typename T>
struct Test{...};
Test(int) -> Test<std::size_t>;
Test t(1); // t 是 Test <size_t>
Test(T*) -> Test<T[]>;
Test t(p); // t 是 Test <char[]>
// 变量模板,没啥用处
template<typename T>
T v; // 可以有各种修饰待决名和SFINAE
待决名感觉是一种过于复杂语法,学了一遍感觉用处不大,不做说明
SFINAE 则完完全全是语言设计不佳导致的结果,没必要学习,如果有类似需求,直接使用 C++20 的约束。
约束和概念
C++ 20 引入了 concept 和 requires。
requires expresion:
// 条件都满足返回true,不满足返回false
requires(Type1 param1, Type2 param2, ...){
a + b; // 比如a和b是int,那么这个语句合法,这个条件通过
{a*1} -> std::convertible_to<T>; // 这是复合要求,花括号里检查语句是否合法,箭头右边检查结果是否合法
typename Type1::iterator // 类型要求,检查类型中是否有要求类型
requires std::is_floating_point_v<T> // 可嵌套
}requires clause:
template <typename T>
requires xxx && xxx // 满足所有
requires xxx || xxx // 满足一个
concept a = xxx // 把条件作为一个概念
func...模板推导规则
没必要记,看 lsp 的显示结果即可。
初始化
以下几种:
- 等号初始化(拷贝初始化 / 复制初始化):对于内置类型,和 C 语言一样,允许截断和隐式转化;对于对象就是拷贝初始化(有可能会被优化掉)。
- 括号初始化(直接初始化):对内置类型类似于等号,对于类来说等于直接调用构造函数,对于
new来说就是括号初始化 - 列表初始化:对内置数值类型会进行窄化检查(narrowing conversion),不允许精度丢失的隐式缩窄。对聚合类型(POD 结构体、数组等)按成员顺序或数组下标顺序依次初始化,未指定的元素置 0。若类有
std::initializer_list构造函数优先使用它;否则按参数列表(构造函数)匹配。(C++11) - 默认初始化
- 值初始化
int n = 1; // 等号
int n(1) // 括号
int n{1} // 列表
int n; // 默认关于列表初始化需要注意这两点:
auto会将大括号初始化列表推断为std::initializer_list类型,而非预期的目标类型。(C++14 起修改后才合乎常理,但必须是单变量)- C++ 的初始化列表必须在编译期完全展开,保证从左向右传入参数。
- C++11 后,对于类来说
T x{...};→ 列表直接初始化,而T x = {...};→ 列表拷贝初始化
最推荐列表初始化
其余新语法
new 和 delete,字面意思
using 相较于 typedef 更简单,所以更推荐
typedef int (*process)(void *);//定义了一个函数指针类型 process
using NewProcess = int(*)(void *);//定义了一个函数指针类型 NewProcessconst 在 cpp 是默认内部链接。
const int *const p 这个变量的声明,这样去解析:
- p: 它名叫 P
- const p :p 是常量,它不可变。
- *const p : p 是常量指针,它不可变。
- int *const p :p 是常量指针,它不可变,它指向一个 int。
- const int *const p :p 是常量指针,它不可变,它指向一个常量 int。
constexpr 和 consteval 一个是可以在编译期求值,一个是在 C++20 引入的必须在编译期求值
被这两个修饰的函数可以使用递归,在 C++17 后可以在 if 后面作为编译器判断
C++17 后,if/switch 可直接定义变量,下例
if (const std::vector<int>::iterator itr = std::find(vec.begin(),vec.end(),3);
itr != vec.end()) {
*itr = 4;
}C++11 后,for 允许对数组类或容器类(vector,array 等等)的每个元素执行相同的操作:
double prices[5] {1.22,2.22,3.33,4.44,5.55};
// 如果要修改元素则需要 for(double &x : prices)
for (double x : prices)
cout << x << std::endl;还有基于列表和范围的初始化方式:
for(int x : {1,2,3})
cout << x << "";
cout << endl;异常处理
noexcept
C++11 后将异常的声明简化为以下两种情况:函数可能抛出任何异常; 函数不能抛出任何异常.
使用 noexcept(代表绝对不会抛出异常)修饰过的函数如果抛出异常,编译器会立即终止程序运行。未声明代表可能,需要用 try,catch,throw 来解决.
noexcept 还能使用表达式来判断.
在明确不会抛出异常,比如: 简单的数值计算函数,析构(析构已经隐式 noexcept 了),移动操作相关函数,频繁调用或者性能敏感的函数,接口(给调用方了解情况,这是必要的),最好使用.
如果有个函数调用其他函数,并且不知道调用的可否,那就是异常中立,就不要使用.
try,catch,throw
用到时候在搜索,这块 C++ 有一堆标准类型,没必要花太多精力
STL
容器
- 对于
vector,emplace_back有更好的性能,应当优先使用. string_view在没有需要修改字符串的情况下,比如string更好。
容器适配器
迭代器
| 迭代器类型 | 缩写 | 读 | 写 | 主要运算符 | 典型用途 |
|---|---|---|---|---|---|
| 输入迭代器 | InIt | 是 | 否 | * -> ++ == != | 只读单向遍历,如从输入流读取 |
| 输出迭代器 | OutIt | 否 | 是 | * ++ | 只写单向输出,如写入容器或输出流 |
| 前向迭代器 | FwdIt | 是 | 是 | * -> ++ == != | 可多次单向遍历,读写皆可 |
| 双向迭代器 | BidIt | 是 | 是 | * -> ++ -- == != | 可前后移动遍历 |
| 随机访问迭代器 | RanIt | 是 | 是 | * -> [] ++ -- + - += -= == != < > <= >= | 像指针一样随机跳转,支持下标和比较 |
除了上面五种基本迭代器外,STL还提供了一些迭代器的适配器,用于一些特殊的操作,如:
- 反向迭代器(reverse iterator):用于对容器元素从尾到头进行反向遍历,可以通过容器类的成员函数
rbegin和rend可以获得容器的尾和首元素的反向迭代器。需要注意的是,对反向迭代器,++操作是往容器首部移动,--操作是往容器尾部移动。 - 插入迭代器(insert iterator):用于在容器中指定位置插入元素,其中包括:
back_insert_iterator(用于在尾部插入元素);front_insert_iterator(用于在首部插入元素);insert_iterator(用于在任意指定位置插入元素)。它们可以分别通过函数back_inserter、front_inserter和inserter来获得,函数的参数为容器。 - const 迭代器:容器新增了
cbegin()、cend()方法,直接就能拿到const_iterator,这是一种只读的迭代器
对于vector、deque以及basic_string容器类,与它们关联的迭代器类型为随机访问迭代器 对于list、map/multimap以及set/multiset容器类,与它们关联的迭代器类型为双向迭代器 queue、stack和priority_queue容器类,不支持迭代器!