TODO
左右值
碎碎念
我用的是 C++20 标准,更新的标准如果有提到都会标注,如果我的 C 语言笔记已经写过就不会再写。
对于 C++这个古老的语言,市面上有很多教材和教程,但是良莠不齐,着实不好。虽然寻找资源是 CSer 的基本素养,但是如果有人能先收集好,那自然是更好的方式。至少简中互联网我很难说有一个比较好的全系列教程,不过这也和 C++的语言发展有关系。
故而下面一一举例我学习过个人觉得较好的 Cpp 教程,仅供参考。
资料来源:《Effective Modern C++(社区翻译版本)》、《Modern C++ Tutorial: C++11/14/17/20 On the Fly》、《现代 C++ 模板教程》、《C++20 实践入门》、《C++20 高级编程》、《Google 开源项目风格指南——中文版》、《C++ STL Tutorial》、C++之继承详解(万字讲解)_c++继承、掌握虚函数、纯虚函数与抽象类:C++多态基石详解
总之非常感谢写书的人和社区的参与,能让我能继续学习 C++这个有活力的老东西,虽然它是一坨屎。
代码风格
命名约定
变量/函数:小写+下划线
类型名(类 / 结构体 / 枚举 / typedef / using):大写驼峰(PascalCase)
常量(全局或静态)+ 枚举常量:k 前缀 + 驼峰:kRed, kGreen
类的私有成员变量名:xxx_
命名空间:用全小写单词,必要时用下划线
文件组织(h / cc)
头文件 .h 里应该放:
- 类 / 结构体 / 枚举的声明
- 函数声明(原型)
- 常量、类型别名和宏定义
- 模板定义
- 简单的
inline/ 模板函数的定义
不要放:
- 非模板的函数定义(实现应放到
.cc) - 不必要的全局变量定义
#prama once
#include "this_file_own_header.h" // 自己对应的头文件
#include <cstdio> // C 标准库
#include <string> // C++ 标准库
#include <vector>
#include "other_project_header.h" // 其他项目的头
#include "myproject/util/logging.h" // 自己项目的其他模块
格式化
- 两个空格缩进
- 大括号通常与控制语句放在同一行
- 每行长度不要超过 120 列,除非好看。
类
需要相应构造显示写出,不需要则删除。
头文件
有些 C 头文件被转换为 C++头文件,这些文件被重新命名,去掉了扩展名 h(使之成为 C++风格的名称),并在文件名称前面加上前缀 c(表明来自 C 语言)。例如,C+版本的 math.h 为 cmath。
有时 C 头文件的 C 版本和 C++版本相同,而有时候新版本做了一些修改。对于纯粹的 C++头文件(如 iostream)来说,去掉 h 不只是形式上的变化,没有 h 的头文件也可以包含名称空间.
然后说明一下 #include<> 和 #include"" 的区别,<> 是直接去系统的库源文件找,"" 是去当前目录找,如果没有再去库源文件找。
命名空间
在 C/C++中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染, namespace 关键字的出现就是针对这种问题的。
namespace Jill{
double bucket(double n){ ... }
double fetch;
int pal;
struct Hill{ ... };
} // 命名空间可以定义变量,函数,类型。
namespace Jill{ // 可以命名同样名称的命名空间,但是最后会合并成同一个。
void fuck;
}
namespace outer{
namespace inter{
}
} // 可以嵌套
namespace outer::inter{} // 等效于上面
// 全局变量真实写法
int g = 42;
void f();
// 逻辑上可以想象成:
namespace /* global */ { // cpp 有个隐式的名称空间,被成为全局名称空间,同名字意思。
int g = 42;
void f();
}
int main(){
::f(); // 所以可以用:: 指是全局函数
}
现在该如何访问命名空间域里的内容呢?其实有 3 种方法:
- 加命名空间名称及作用域限定符“
::” - 使用 using namespace 命名空间名称 全部展开
- 使用 using 将命名空间中成员 部分展开
主要讲讲第三点:
void a(){
using std::cout;
cout << 1;
}
int main(){
a();//cout 可以
cout << 1;//cout 不可以
}
namespace 也可以匿名定义,其潜在作用域为声明点到声明区域末尾,也是一种常用的改内部链接型的方式。
namespace{
xxx;
}
数据类型
字面量
C++11 允许用户自定义字面量, 可以理解为相应的字面量会按照用户的想法处理,但是这个必须符合 cpp 标准给出的形参格式。
一般来说这个可以用来对字面量做一些处理和符号重载。
用户自定义字面量 (自 C++11 起) - cppreference.cn - C++参考手册
类型转化
| 运算符 | 主要用途 / 语义 | 检查时机 | 典型用法场景 | 风险与注意事项 |
|---|---|---|---|---|
static_cast |
编译期类型转换(“正常的”类型间转换) | 编译期 | 内置类型之间:double → int、int → double 等指针/引用在 继承层次内 的上行/下行转换(无 RTTI 检查) |
仅做语义合理的转换,编译器会做基本检查下行转换不做运行时检查,类型不匹配会产生未定义行为 |
dynamic_cast |
运行时带检查的多态类型转换(安全 downcast) | 运行期(RTTI) | 在 有虚函数的基类 层次中,将基类指针/引用转换为派生类根据实际动态类型判断转换是否成功 | 只能用于类的指针/引用,且类必须是多态类型(至少一个虚函数)指针转换失败返回 nullptr,引用失败抛 std::bad_cast |
reinterpret_cast |
底层“重解释”转换(仅重解释比特位) | 编译期(几乎不检查语义) | 完全无关的指针类型之间转换:A* ↔ B*指针与整数类型(如 uintptr_t)之间的转换 - 某些底层/硬件/序列化场景 |
极易导致未定义行为和不可移植代码仅在非常清楚底层布局时使用,普通业务代码应尽量避免 |
const_cast |
改变表达式的 const / volatile 属性 | 编译期 | 从 const T* / const T& 去掉 const 以调用旧接口(保证实际对象本身非 const)给非 const 对象“加上” const 视图 |
若原始对象本身是 const,通过去 const 后修改它 → 未定义行为只应用于“本体非 const,只是接口写成 const”的场景 |
一句话记忆:
static_cast:正常、安全的编译期转换,能用它就先用它。dynamic_cast:多态场景下“带安全检查”的 downcast,必须在类至少包含一个虚函数启用。下行转化安全。reinterpret_cast:生硬地重解释比特,几乎只给非常底层代码用。const_cast:只改 const/volatile 属性,不做类型变更,用错就 UB。
普通类型
| 类别 | 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_t 类型 |
std::nullptr_t(在 <cstddef> 中) |
C 无对应类型,只能用 void* 或整型表示“空指针值” |
便于模板与重载区分“真·空指针”与整数 0。 |
多类型结构
结构体
在 Cpp 中,struct 等价于类 class。
联合体
union
而且 union 也可以像类一样使用。
如果添加一个有非平凡构造析构的类型,需要自己手动析构。
union U{
public: // C++ 添加了访问权限
private:
protected:
}
std::variant 和 std::visit
std::variant 是 C++17 引入的一个更类型安全型的联合体。
std::visit 是一个相关的访问器,避免重复写一堆 std::get
#include <iostream>
#include <string>
#include <variant>
// 小工具:把多个 lambda 组合成一个“重载访问器”
template<class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
int main() {
// 1. std:: variant:类型安全的“联合体”(union 替代品)
// 这里 v 可以存 int / double / std:: string 其中之一
std::variant<int, double, std::string> v;
v = 42; // 现在 v 里是 int
std::cout << std::get<int>(v) << "\n"; // 取出 int
v = 3.14; // 现在 v 里是 double
std::cout << std::get<double>(v) << "\n";
v = std::string("hi"); // 现在 v 里是 std:: string
std::cout << std::get<std::string>(v) << "\n";
// 安全访问:get_if 返回指针,类型不匹配则返回 nullptr
if (const int* pi = std::get_if<int>(&v)) {
std::cout << "It's int: " << *pi << "\n";
} else {
std::cout << "It's not int\n"; // 这里会走这条
}
// 2. std:: visit:根据当前实际类型调用合适的处理逻辑
// 2.1 用结构体访问器
struct Visitor {
void operator()(int i) const {
std::cout << "int: " << i << "\n";
}
void operator()(double d) const {
std::cout << "double: " << d << "\n";
}
void operator()(const std::string& s) const {
std::cout << "string: " << s << "\n";
}
};
v = 123;
std::visit(Visitor{}, v); // 根据当前类型是 int,调用 Visitor:: operator()(int)
v = std::string("world");
std::visit(Visitor{}, v); // 当前是 string,调用 string 重载
// 2.2 更常用写法:lambda + overloaded
std::variant<int, double, std::string> v2 = 3.14;
auto printer = overloaded{
[](int i) { std::cout << "lambda int: " << i << "\n"; },
[](double d) { std::cout << "lambda double: " << d << "\n"; },
[](const std::string& s){ std::cout << "lambda string: " << s << "\n"; }
};
std::visit(printer, v2); // 输出:lambda double: 3.14
// 3. std:: visit 有返回值:由所有重载的“共同返回类型”决定
std::variant<int, std::string> v3 = "OK";
auto make_msg = overloaded{
[](int i) {
return std::string("from int: ") + std::to_string(i);
},
[](const std::string& s) {
return std::string("from string: ") + s;
}
};
std::string r = std::visit(make_msg, v3);
std::cout << r << "\n"; // 输出:from string: OK
// 4. 多个 variant 一起 visit:处理组合情况
std::variant<int, float> v4 = 10;
std::variant<long, float> v5 = 2.5f;
auto combiner = overloaded{
// 泛型版本:处理 (int, long) 以外的任意组合
[](auto a, auto b) {
std::cout << "sum: " << (a + b) << "\n";
},
// 特例:专门处理 (int, long)
[](int i, long l) {
std::cout << "int + long: " << (i + l) << "\n";
}
};
std::visit(combiner, v4, v5); // 当前 (int, float) → 调用泛型 lambda
v5 = long{20};
std::visit(combiner, v4, v5); // 当前 (int, long) → 调用专门的 (int, long) 重载
return 0;
}
枚举
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
};
右值引用, 移动语义和完美转发
左右值
左值,顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
右值,右边的值,是指表达式结束后就不再存在的临时对象。 而中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。
纯右值,纯粹的右值,要么是纯粹的字面量,例如;要么是求值结果相当于字面量或匿名临时对象,例如。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、表达式都属于纯右值。但是字符串字面量不算右值, 这是特例.
简单来说, 左值, 即可以用&取地址的值, 右值就不可以, 包括 return 返回的临时变量.
右值引用
使用 && 可以引用右值, 这里实际上这个符号是左值了, 因为可以取地址.比如 int &&a = 19, a 是左值,19 为右值.
常量引用 const T& 左值右值都可以, 对于右值来说就是生成一个临时变量, 延长声明周期, 避免低效拷贝.
通用引用
在有类型推导的情况下, 准确来说
template<typename T>
void f(T&& param); //param 是一个通用引用
auto&& var2 = var1; //var2 是一个通用引用
这两种情况下, 会出现通用引用, 意思就是这可能是左值也可能是右值.原因就是存在类型推导.
如果类型推导不是标准的 type&& 那么它就不是通用引用了.
引用坍缩规则
| 形参类型 | 实参类型 | 推导后形参最终类型 | 说明 |
|---|---|---|---|
| T& | &(左值引用) | T& | 左值引用 + 左值引用 → 左值引用 |
| T& | &&(右值引用) | T& | 左值引用 + 右值引用 → 左值引用 |
| T&& | &(左值引用) | T& | 右值引用 + 左值引用 → 左值引用 |
| T&& | &&(右值引用) | T&& | 右值引用 + 右值引用 → 右值引用 |
移动语义
当 return 一个消失的值, 使用&&可以直接移动地址而非拷贝两次(return a == (tmp = a; b = tmp)).当然现在编译器会为我们隐式生成右值转化, 来降低开销, 我们不用操心这个.
使用 std::move 可以把左值变成右值, 又称为将亡值, 等于结束了它的生命周期, 直接把所有权交出去了.这也叫做移动语义.源码上其实就是个转化, 没有啥移动的.
但是注意如果类没有实现移动构造, 默认调用还是拷贝构造, 因为拷贝构造能接受右值(const).
当然它也有 缺点:
- 没有移动操作:要移动的对象没有提供移动操作,所以移动的写法也会变成复制操作。
- 移动不会更快:要移动的对象提供的移动操作并不比复制速度更快。
- 移动不可用:进行移动的上下文要求移动操作不会抛出异常,但是该操作没有被声明为
noexcept。
值得一提的是,还有另一个场景,会使得移动并没有那么有效率:
- 源对象是左值:除了极少数的情况外(例如 Item25),只有右值可以作为移动操作的来源。
完美转发
完美转发就是保持原本的参数类型, 下面是例子
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));
}
在这里我们希望能通过重载 process 来分别处理左值右值, 假设这里没有 forward, 那么会出现什么情况呢?
param 是通用引用, 它指向的值会有相应类型, 但是它本身是一个左值(把它当成指针).那么重载的意义就没了, 都只会经过左值引用的函数.
为了解决这个问题做到完美转发, 就需要 forward, 用法可以看上面格式.
在以下情况会失败:
- 用花括号初始化
- 0 和 NULL 作为空指针
- 仅有声明的 static const 数据成员.
- 重载函数名或者模板名
- 位域
什么时候使用?
凡是需要区分左值右值的, 统一用通用引用来重载, 第一代码更可读, 否则每个都要写一个重载, 太麻烦了; 第二对于某些量(比如字符串字面量), 性能更好, 可以避免一次构造, 原因就是它是 const, 数组形式不会退化.
凡是用通用引用的, 统一用 forward 转发; 凡是用右值引用的, 统一用 move 移动.
注意点
通用引用看来很完美, 但是有个问题, 如果写了通用引用的函数后, 又写了一个重载函数.我们的目标数据有可能走的是通用引用而不是重载.
比如说对于 1, short 明显比 int 更适合, 而通用引用就可以做到这点, 根据重载的规则, 更能匹配的优先.
所以请不要对通用引用函数重载
还有什么解决办法呢?有的兄弟, 有的.
- 对于移动成本的且总是被拷贝的可拷贝形参, 按值传递.
- SFINAE 和
enalbe if
智能指针
| 智能指针 | 所有权 | 引用计数 | 主要用途 | 缺点 |
|---|---|---|---|---|
unique_ptr |
独占 | 无 | 管理独占资源,替代原始指针 | 不可拷贝,仅可移动。 |
shared_ptr |
共享 | 有 | 多所有者共享资源 | 有额外内存开销,可能循环引用 |
weak_ptr |
无(弱引用) | 不影响 | 解决循环引用,临时访问资源 | 不能直接访问对象,需通过 lock() |
shared_ptr 需要避免循环引用(如两个 shared_ptr 互相指向对方),否则引用计数永远不会归零,导致内存泄漏(需配合 weak_ptr 解决)。
实际开发中,应根据资源的所有权关系选择合适的智能指针:优先使用 unique_ptr(效率最高),需共享时使用 shared_ptr,配合 weak_ptr 解决循环引用问题。
另外相比 new, make_xxx 更具有优越性。 具体原因无需说明, 单从概念即可看出, new 的话要构造两次.
使用
智能指针能如普通指针一样使用, 因为被重载了。
.get() 获取原始指针
.release 取消托管, 需要自己手动释放.
.reset(xxx) 重置指针托管的地址, 即释放掉原本托管的内存, 用参数智能代替, 如 xxx 为空则是直接释放.
自定义删除器
RAII(Resource Acquisition Is Initialization)是由 c++之父 Bjarne Stroustrup 提出的,中文翻译为资源获取即初始化.
但是我们会发现智能指针设置得很自由, 有些销毁方式不是单纯的 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++重载了 &, 用来声明引用,引用必须声明的时候初始化。
这个用在函数传参时, 可以如同指针一样直接修改。而不是复制一遍。一般的编译器使用常量指针实现这个功能的。
如果赋值为常量, 那么也应该加上 const。如果是强制转化变量也需要, 比如:
double a = 1.1;
const int &b = (int)a; // 大多数的解释是 a 被转化后, 是把转化值放入一个临时常量, 把这个常量赋值, 所以 b 必须用 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 库, 务必使用 extern "C", 因为 C 语言的函数没有改变名字, 所以正常调用编译器也不知道,。
重载只和 参数列表(类型,个数,顺序)有关,和返回值无关。故而返回值不同但是参数列表一致的函数是无效重载。
Lambda
底层实现是仿函数。
基本语法
[捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {/*函数体*/}
捕获列表(指直接使用相关变量)
-
[] 空捕获列表
-
[name1, name2, …] 捕获一系列变量, 被捕获的变量在 Lambda 表达式被创建时拷贝,而非调用时才拷贝。
-
[&name1,&name2] 引用捕获。
-
[&] 引用捕获,从函数体内的使用确定引用捕获列表
-
[=] 值捕获,从函数体内的使用确定值捕获列表
-
表达式捕获 lambda 在 C++14 可以初始化, 那么就可以在里面写表达式.
需要注意的是,如果按引用捕获,要注意是否悬空捕获,比如说 lambda 把一个局部变量引用了,结果 lambda 又被一个全局容器使用了,那么就会出现这个问题。所以相比默认 &,显式地 &name 更为地好,至少会提醒你。
也许有人会想, 那我默认按值引用就好了, 但是如果这个值本身是个指针, 后续被 delete 了.那也是有问题的.甚至即使你使用智能指针, 也有可能.所以还是显式指出来算了。
总而言之显式捕获。
mutable
默认情况下,Lambda 表达式 不能修改按值捕获的变量(捕获的副本被视为 const)。mutable 关键字的作用是 解除这种限制,允许在 Lambda 内部修改按值捕获的变量副本。
#include <iostream>
int main() {
int x = 10;
// 不使用 mutable:按值捕获的 x 是 const,无法修改
auto func1 = [x]() {
// x = 20; // 错误:不能修改按值捕获的变量
std::cout << "func1: " << x << std::endl;
};
// 使用 mutable:允许修改按值捕获的 x 副本
auto func2 = [x]() mutable {
x = 20; // 合法:修改的是副本,不影响外部 x
std::cout << "func2 内部: " << x << std::endl;
};
func1(); // 输出:func1: 10
func2(); // 输出:func2 内部: 20
std::cout << "外部 x: " << x << std::endl; // 输出:外部 x: 10(原变量未变)
return 0;
}
异常属性
异常属性主要通过 noexcept 关键字声明,用于告诉编译器 Lambda 表达式 是否可能抛出异常,帮助编译器进行优化或强制异常安全。
noexcept:表示 Lambda 绝对不会抛出任何异常。noexcept(表达式):根据表达式的布尔结果决定是否可能抛出异常(表达式为true表示不抛异常)。
列表初始化
值捕获只能捕获左值, 但在 C++14 后允许在捕获列表初始化了, 就可以使用 move 语义来初始化右值.
泛型 lambda
在参数列表中运用 auto 的被称为泛型 lambda, 因为编译器会生成一堆模板.
对于泛型 lambda 有个小问题, 就是如果参数是按值引用, 形参是左值, 传进去是拷贝, 效率会有点问题.
那么可以用转发来实现, 简单来说是, 对 auto&& 形参使用 decltype 以 std::forward 它们.
函数容器
std::functional
std::functional 是相比直接调用函数指针更安全(类型安全), 更方便(可以容纳各种函数)的方式, 换言之即函数的容器.(不知道为什么, 我写代码时不用调用这个库)
C++11 std::function 是一种通用、多态的函数封装, 它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作, 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的), 换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。 例如:
#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::placeholder
而 std::bind 则是用来绑定函数调用的参数的, 它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数, 我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。
而 std::placeholder 是配合, 这个是有顺序的.
完整例子:
void foo(int a, int b, int c) {
std::cout << a << b << c << std::endl;
}
int main() {
// 将参数 1,2 绑定到函数 foo 上,
// 但使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, std::placeholders::_2,2);
auto bindFoo_ = std::bind(foo, std::placeholder::_2, std::placerholders::_1, 2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1,2);// 输出 122
bindFoo_(1,2); // 输出 212
}
对于成员函数也可以, 不过有些特殊规则, 下例:
class w{int add(int a, int b){return a+b;}};
w w1;
auto add1 = std::bind(&w::add, &w, 10, 1)//第一个参数的引用必须保留, 第二个参数必须是类对象, 等于说代替 this 指针.
最困难的是传递左值右值会发生什么?结论是无论你的函数是如何传参, 传递左值一定是拷贝, 传递右值一定是移动.大概原因(没看源码)是, bind 是一个闭包, 如果是引用传递, 就不符合闭包的定义了, 那么只能采取按值传递, 也就是移动和拷贝.
Lambda 和 bind
相比之下, 最好都用 Lambda, 因为它能实现 bind 的功能而且更简单更好.
bind 初始化时会调用参数的函数
auto L = [](int x){f1(f2())}; //这里只是封装, 没用调用 f2()
auto B = std::bind(f1, f2, _1); //这里会调用 f2 来传递, 正确写法看下面
auto B = std::bind(f1, std::bind(f2), _1);
孰优孰劣可以看出
bind 无法直接识别重载
void foo(int a);
void foo(int a, int b);
auto L = [](int b){foo(1, b)};
auto B = std::bind(foo, 1, b);//报错, 除非对 foo 强制类型转化
Lambda 可以内联, 而 bind 是函数指针
性能的差距
适合 bind 的情况
C++11 的情况, 但是应该不会用这么老的,14 出现初始化列表就可以全用 Lambda 了.
仿函数
类通过重载 () 来模拟函数的方式.
重载运算符
您可以重定义或重载大部分 C++ 内置的运算符。这样,您就能使用自定义类型的运算符。
下面是不可重载的运算符列表:
- .:成员访问运算符
- .*, ->*:成员指针访问运算符
- :::域运算符
- sizeof:长度运算符
- ?::条件运算符
- #: 预处理符号
重载的运算符是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的。与其他函数一样,重载运算符有一个返回类型和一个参数列表。
Box operator+(const Box&);
声明加法运算符用于把两个 Box 对象相加,返回最终的 Box 对象。大多数的重载运算符可被定义为普通的非成员函数或者被定义为类成员函数。如果我们定义上面的函数为类的非成员函数(因为类可以用 this),那么我们需要为每次操作传递两个参数,如下所示:
Box operator+(const Box&, const Box&);
其余方面和正常函数没有任何区别。
面向对象
类可以嵌套。另外 struct 也可以当成类来实现, 它是变量默认全 public, 而 class 默认全 private。
类作用域
在声明里如果想初始化一个常量可以用枚举,或者 static const,但是只有 const 不行,C++11 后可以直接使用 const 了。
内联
使用inline 函数,允许函数定义出现在头文件中,被多个翻译单元包含,而不多重定义
inline static 数据成员:允许变量定义出现在头文件中,被多个翻译单元包含,而不多重定义
成员函数
定义
void Stock::updata(double price)
常量成员函数
const Stock & topval(const Stock &s) const; 最后一个 const 就代表常量成员函数, 代表着一种不变性.
意思就是, 带有这个 const 的成员函数不能改变但是可以访问调用对象的 非静态 数据成员(除非它带上 mutable 关键字), 可以修改 static 成员变量;
并且它只能调用常量成员函数, mutable 此处不生效.
常量对象只能调用常量函数(这就是为什么大多数模板要实现常量和非常量两个函数), 对非常量对象没有限制.
构造与析构
构造函数有以下几种, 而且有些时候会根据有无存在相应构造函数来实现相关功能, 需要严格按照其定义实现:
普通构造函数
与类名同名,没有返回值类型(包括 void 也不能有)。可以有参数,也可以没有参数,用于为对象的成员变量赋初值。
class Rectangle {
private:
int width;
int height;
public:
// 带参数的普通构造函数
Rectangle(int w, int h) : width(w), height(h) {}
// 不带参数的普通构造函数(默认构造函数的一种形式)
Rectangle() : width(0), height(0) {}
int getArea() const {
return width * height;
}
};
默认构造函数
可以是用户自定义的没有参数的构造函数,也可以是编译器自动生成的(前提是类中没有定义任何构造函数,且类满足一定条件,比如所有非静态成员都有默认初始化方式 )。默认构造函数用于在创建对象时,如果用户没有提供初始化参数,就按照默认的方式对对象进行初始化。
class Point {
private:
int x;
int y;
public:
// 用户自定义的默认构造函数
Point() : x(0), y(0) {}
};
//下面是没有生成构造函数的例子
class Circle {
private:
double radius;
};
// 编译器自动生成的默认构造函数大致等价于:
// Circle:: Circle() {
// // 对基本类型成员,默认值不确定,对类类型成员,会调用其默认构造函数
// }
拷贝构造函数
函数形参是本类对象的引用(通常是 const 引用,以避免不必要的拷贝),用于使用一个已存在的同类型对象来初始化新创建的对象。如果用户没有定义拷贝构造函数,编译器也会自动生成一个默认的拷贝构造函数,它会逐个成员进行浅拷贝。
class Student {
private:
std::string name;
int age;
public:
Student(const std::string& n, int a) : name(n), age(a) {}
// 自定义拷贝构造函数
Student(const Student& other) : name(other.name), age(other.age) {}
//编译器会生成的类型
};
移动构造函数
C++11 引入,函数形参是本类对象的右值引用(形如 ClassName(ClassName&& other) ),用于在对象移动时高效地转移资源所有权,而不是像拷贝构造函数那样进行数据拷贝,从而提高性能,避免不必要的资源复制。如果用户没有定义移动构造函数,在满足类中没有用户自定义的移动构造函数, 拷贝构造函数, 拷贝赋值运算符, 析构函数且非静态变量和基类都符合移动语义时,编译器会自动生成一个默认的移动构造函数。
原因分别为:
- 如果类中已经定义了一个移动构造函数(包括显式定义和删除的移动构造函数),编译器就不会再生成默认的移动构造函数。因为编译器认为用户已经对对象移动时的资源处理有了明确的规划
- 若定义了拷贝构造函数,编译器会认为用户对对象的复制操作有特定的要求,这种要求可能和移动操作存在关联,或者用户想对对象的复制和移动行为做统一控制,此时编译器不会生成默认移动构造函数。
- 拷贝赋值运算符用于将一个对象的值赋给另一个对象。当定义了拷贝赋值运算符,编译器会认为用户对对象之间的赋值操作有特殊需求,和移动操作可能存在冲突,就不会生成默认移动构造函数。
- 析构函数用于在对象生命周期结束时释放资源。如果定义了析构函数,意味着对象内部可能涉及到一些复杂的资源管理,编译器无法简单地确定移动操作的安全性和正确性,也就不会生成默认移动构造函数。
- 类的每个非静态成员变量类型都必须支持移动构造或者拷贝构造。如果存在不支持移动构造且也没有合适拷贝构造函数的成员变量类型,编译器就无法生成默认移动构造函数。例如,类中有一个成员变量是自定义的不支持移动构造的类类型,且没有定义合适的拷贝构造函数,编译器就不会生成默认移动构造函数。
- 如果类继承自其他类,基类也需要满足上述条件,即基类要么有默认移动构造函数,要么有用户自定义的合适的移动构造函数、拷贝构造函数等。
委托构造函数
一个构造函数通过 this 关键字调用同一个类中的其他构造函数来完成对象的部分或全部初始化工作。这种方式可以避免在多个构造函数中重复编写相同的初始化代码。
class Complex {
private:
double real;
double imag;
public:
Complex(double r) : real(r), imag(0) {}
// 委托构造函数,调用上面的单参数构造函数
Complex(double r, double i) : Complex(r) {
imag = i;
}
};
//Complex(double r, double i)构造函数委托 Complex(double r)构造函数先初始化 real 成员,然后再初始化 imag 成员。
转换构造函数
是只有一个参数(或者除了第一个参数外,其他参数都有默认值)的构造函数,它可以将参数类型隐式转换为本类对象。这种构造函数常用于实现类型转换。
class Integer {
private:
int value;
public:
// 转换构造函数,将 int 转换为 Integer 对象
Integer(int num) : value(num) {}
int getValue() const {
return value;
}
};
Integer num = 10; // 隐式调用转换构造函数,将 int 10 转换为 Integer 对象
析构 是销毁对象时候要用的, 析构的名字是 ~对象名字().有动态变量时有着重注意实现.
显式禁用默认函数
在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、 复制构造、赋值算符以及析构函数。 另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。 当程序员有需要时,可以重载这部分函数。
这就引发了一些需求:无法精确控制默认函数的生成行为。 例如禁止类的拷贝时,必须将复制构造函数与赋值算符声明为 private。 尝试使用这些未定义的函数将导致编译或链接错误,则是一种非常不优雅的方式。
并且,编译器产生的默认构造函数与用户定义的构造函数无法同时存在。 若用户定义了任何构造函数,编译器将不再生成默认构造函数, 但有时候我们却希望同时拥有这两种构造函数,这就造成了尴尬。
C++11 提供了上述需求的解决方案,允许显式的声明采用或拒绝编译器自带的函数。 例如:
class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
}
类的初始化方式
默认初始化
在定义对象时不提供任何初始化值,对于内置类型成员变量,其值是未定义的(对于全局或静态存储期的内置类型变量,会初始化为 0 );对于类类型成员变量,会调用其默认构造函数进行初始化。如果类没有默认构造函数,就无法进行默认初始化。
class Point {
public:
int x;
int y;
Point() = default; // 使用编译器生成的默认构造函数
};
int main() {
Point p; // 默认初始化,x 和 y 值不确定(这里使用默认构造函数)
return 0;
}
值初始化
对于内置类型数组,会初始化为 0;对于类类型对象,会调用默认构造函数,如果类没有默认构造函数,会使用占位符初始化(对于基本类型初始化为 0,对于类类型会尝试调用无参构造函数,如果没有则报错)。常见的形式有:使用圆括号 () 对单个对象初始化、使用花括号 {} 进行列表初始化(空列表时) 。
class Rectangle {
public:
int width;
int height;
Rectangle(int w = 0, int h = 0) : width(w), height(h) {}
};
int main() {
Rectangle r1(); // 值初始化,等价于 Rectangle r1(0, 0);
Rectangle r2{}; // 值初始化,width 和 height 被初始化为 0
int arr[3]{}; // 值初始化,数组元素都为 0
return 0;
}
直接初始化
使用圆括号 (),根据构造函数的参数列表,提供相应的初始化值,直接调用匹配的构造函数来初始化对象。
class Circle {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double getArea() const {
return 3.14 * radius * radius;
}
};
int main() {
Circle c(5.0); // 直接初始化,调用 Circle(double r)构造函数
return 0;
}
拷贝初始化
使用赋值号 =,先创建一个临时对象,然后通过拷贝构造函数将临时对象的值拷贝给新对象(C++11 后引入了移动语义,在满足条件时会使用移动构造函数来提高效率 )。
class Student {
private:
std::string name;
int age;
public:
Student(const std::string& n, int a) : name(n), age(a) {}
Student(const Student& other) : name(other.name), age(other.age) {}
};
int main() {
Student s1("Alice", 20);
Student s2 = s1; // 拷贝初始化,调用拷贝构造函数
return 0;
}
列表初始化
使用花括号 {},可以对类对象进行初始化,它会优先匹配接受 std::initializer_list 参数的构造函数(如果有的话),如果没有则按照成员变量声明顺序依次进行初始化,对于没有默认构造函数的成员变量,必须在初始化列表中提供初始值。
class Person {
private:
std::string name;
int age;
public:
Person(const std::initializer_list<int>& list) {
if (list.size() == 2) {
age = *list.begin();
// 这里假设简单转换,实际中可能需要更复杂逻辑
name = std::to_string(*(list.begin() + 1));
}
}
Person(const std::string& n, int a) : name(n), age(a) {}
};
int main() {
Person p1{"Bob", 25}; // 列表初始化,调用 Person(const std:: string&, int)构造函数
Person p2{28, 3}; // 列表初始化,调用 Person(const std:: initializer_list <int>&)构造函数
return 0;
}
委托构造初始化
一个构造函数通过 this 关键字调用同一个类中的其他构造函数来完成对象的部分或全部初始化工作,减少代码重复。
class Time {
private:
int hour;
int minute;
int second;
public:
Time(int h) : hour(h), minute(0), second(0) {}
Time(int h, int m) : Time(h) { // 委托 Time(int h)构造函数
minute = m;
}
Time(int h, int m, int s) : Time(h, m) { // 委托 Time(int h, int m)构造函数
second = s;
}
};
this 指针
使用被称为 this 的特殊指针。该指针指向用来调用成员函数的对象(this 被作为隐藏参数传递给方法)。如果需要引用整个调用对象, 可以用 *this, 在函数括号后面使用 const 将 this 限定为 const, 这样就不能使用 this 修改对象函数.
如果没有歧义的话, this 是可以省略的, 直接调用成员变量.
友元函数
friend
类的继承
定义
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 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强.
如果想要某个类不被继承, 可以使用 final 关键字.
继承中的作用域
在继承体系中基类和派生类都有独立的作用域。
子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显式访问)
需要注意的是如果是成员函数的隐藏,只需要 函数名相同 就构成隐藏。
注意在实际中在继承体系里面最好不要定义同名的成员。
切片
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
当然, 基类对象不能赋值给派生类对象。除非基类的指针或者引用通过强制类型转换赋值给派生类的指针或者引用。但是此处基类的指针必须是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(RunTime Type Information)的 dynamic_cast 来进行识别后进行安全转换。
继承后的同名函数会根据指针的类型决定调用哪个, 特别是运用引用的情况下.
派生类的默认成员函数
子类会默认调用父类的相应成员函数, 无需操心
友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。
虚拟继承
如果有这种情况:
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
RTTI(runtime type identication)运行时多态
override final 最好直接用.
如果存在虚函数, 无论实例化几个类, 都会多出空间存放虚表的指针, 但是虚表是同一张.
classDiagram
direction LR
class 基类 {
+虚表指针
+基类成员变量: int
+虚析构函数()
+虚函数1()
+虚函数2()
}
class 派生类 {
+虚表指针
+基类成员变量: int
+派生类成员变量: int
+虚析构函数()
+重写虚函数1()
+新增虚函数3()
}
class 基类虚表 {
虚表
+[0]: &基类::~基类()
+[1]: &基类::虚函数1()
+[2]: &基类::虚函数2()
}
class 派生类虚表 {
虚表
+[0]: &派生类::~派生类()
+[1]: &派生类::重写虚函数1() vfptr指向函数
+[2]: &基类::虚函数2() // 继承未修改
+[3]: &派生类::新增虚函数3() // 扩展
}
基类 --> 基类虚表 : 虚表指针指向 vtable
派生类 --> 派生类虚表 : 虚表指针指向 vtable
模板
模板的规则
模板也可以重载。
推断规则见下面类型推断。
外部模板
传统 C++ 中,模板只要被实现后并且被使用就会被编译器实例化。换句话说,只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。并且,我们没有办法通知编译器不要触发模板的实例化。
问题在于如果多个文件都使用了同个模板的同个实例, 每个都会生成一遍, 虽然链接的时候会剔除, 但是生成时已经浪费了时间.
为此,C++11 引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,使我们能够显式的通知编译器何时进行模板的实例化:
template class std::vector<bool>; // 强行实例化
extern template class std::vector<double>; // 不在该当前编译文件中实例化模板
以后就可以专门在一个地方实例化 (注意不是定义) 即可.
template 返回类型 名字 < 实参列表 > ( 形参列表 ) ; // (1)
template 返回类型 名字 ( 形参列表 ) ; // (2)
extern template 返回类型 名字 < 实参列表 > ( 形参列表 ) ; // (3) (C++11 起)
extern template 返回类型 名字 ( 形参列表 ) ; // (4) (C++11 起)
- 显式实例化定义(显式指定所有无默认值模板形参时不会推导模板实参)
- 显式实例化定义,对所有形参进行模板实参推导
- 显式实例化声明(显式指定所有无默认值模板形参时不会推导模板实参)
- 显式实例化声明,对所有形参进行模板实参推导
template 类关键词 模板名 < 实参列表 > ; // (1)
extern template 类关键词 模板名 < 实参列表 > ;// (2)(C++11 起)
尖括号 “>”
在传统 C++ 的编译器中,>> 一律被当做右移运算符来进行处理。但实际上我们很容易就写出了嵌套模板的代码:
std::vector<std::vector<int>> matrix;
这在传统 C++ 编译器下是不能够被编译的,而 C++11 开始,连续的右尖括号将变得合法,并且能够顺利通过编译。甚至于像下面这种写法都能够通过编译:
template<bool T>
class MagicType {
bool magic = T;
};
// in main function:
std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码
函数模板
格式
template <typename AnyType>
void Swap(AnyType &a, AnyType &b){
AnyType temp;
temp = a;
a = b;
b = temp;
}
//template < 形参列表 > 函数声明
template typename 都是必要的, 除非用 class 替换 typename(两者等价), 而且必须用尖括号.
变长参数模板
在 C++11 之前,无论是类模板还是函数模板,都只能按其指定的样子, 接受一组固定数量的模板参数;而 C++11 加入了新的表示方法, 允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定。
template<typename... Ts> class Magic;
模板类 Magic 的对象,能够接受不受限制个数的 typename 作为模板的形式参数,例如下面的定义:
class Magic<int,
std::vector<int>,
std::map<std::string,
std::vector<int>>> darkMagic;
既然是任意形式,所以个数为 0 的模板参数也是可以的:class Magic<> nothing;。
如果不希望产生的模板参数个数为 0,可以手动的定义至少一个模板参数:
template<typename Require, typename... Args> class Magic;
变长参数模板也能被直接调整到到模板函数上。传统 C 中的 printf 函数, 虽然也能达成不定个数的形参的调用,但其并非类别安全。 而 C++11 除了能定义类别安全的变长参数函数外, 还可以使类似 printf 的函数能自然地处理非自带类别的对象(下面就是一个实现定义)。 除了在模板参数中能使用 ... 表示不定长模板参数外, 函数参数也使用同样的表示法代表不定长参数, 这也就为我们简单编写变长参数函数提供了便捷的手段,例如:
template<typename... Args> void printf(const std::string &str, Args... args);//Args 为模板类型包, args 为参数包, 分别存储类型和值, 名字可更换
那么我们定义了变长的模板参数,如何对参数进行解包呢?
首先,我们可以使用 sizeof... 来计算参数的个数,:
template<typename... Ts>
void magic(Ts... args) {
std::cout << sizeof...(args) << std::endl;
}
我们可以传递任意个参数给 magic 函数:
magic(); // 输出 0
magic(1); // 输出 1
magic(1, ""); // 输出 2
其次,对参数进行解包,到目前为止还没有一种简单的方法能够处理参数包,但有两种经典的处理手法:
1. 递归模板函数
递归是非常容易想到的一种手段,也是最经典的处理方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的:
#include <iostream>
template<typename T0>
void printf1(T0 value) {
std::cout << value << std::endl;
}//如果不写, 那么下面的就会出现输出一个空参数的 printf1, 但是我们没有实现, 故而会报错.
template<typename T, typename... Ts>
void printf1(T value, Ts... args) {
std::cout << value << std::endl;
printf1(args...);
}
int main() {
printf1(1, 2, "123", 1.1);
return 0;
}
2. 变参模板展开
你应该感受到了这很繁琐,在 C++17 中增加了变参模板展开的支持,于是你可以在一个函数中完成 printf 的编写:
template<typename T0, typename... T>
void printf2(T0 t0, T... t) {
std::cout << t0 << std::endl;
if constexpr (sizeof...(t) > 0) printf2(t...); //注意这是编译期的事情, 所以不能简单用 if 解决, 故而 17 才成功了.
}
事实上,有时候我们虽然使用了变参模板,却不一定需要对参数做逐个遍历,我们可以利用
std::bind及完美转发等特性实现对函数和参数的绑定,从而达到成功调用的目的。
3. 初始化列表展开
递归模板函数是一种标准的做法,但缺点显而易见的在于必须定义一个终止递归的函数。
这里介绍一种使用初始化列表展开的黑魔法:
template<typename T, typename... Ts>
auto printf3(T value, Ts... args) {
std::cout << value << std::endl;
(void) std::initializer_list<T>{([&args] {
std::cout << args << std::endl;
}(), value)...};
}
通过初始化列表,(lambda 表达式, value)... 将会被展开。由于逗号表达式的出现,首先会执行前面的 lambda 表达式,完成参数的输出。 为了避免编译器警告,我们可以将 std::initializer_list 显式的转为 void。
[!NOTE]
需要注意, 这里之所以能够实现是因为
…是作用与一个表达式的, 或者说就是代表要展开参数.而 lambda 作为一个表达式就自然反复被调用.再通过 initializer_list 变成列表强制在编译期展开.
折叠表达式
C++ 17 中将变长参数这种特性进一步带给了表达式,考虑下面这个例子:
#include <iostream>
template<typename ... T>
auto sum(T ... t) {
return (t + ...);//此处则是...的另一个用法, 不过核心还是展开参数
}
int main() {
std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
}
( 形参包 运算符 ... ) (1)
( ... 运算符 形参包 ) (2)
( 形参包 运算符 ... 运算符 初值 ) (3)
( 初值 运算符 ... 运算符 形参包 ) (4)
- 一元右折叠
- 一元左折叠
- 二元右折叠
- 二元左折叠
分别的例子
#include <iostream>
// 一元右折叠:(a + ...) 相当于 a1 + (a2 + (a3 + ...))
template<typename... Args>
auto sum_right(Args... args) {
return (args + ...);
}
int main() {
std::cout << sum_right(1, 2, 3, 4) << std::endl; // 1+(2+(3+4))= 10
return 0;
}
// 一元左折叠:(... * a) 相当于 (((a1 * a2) * a3) * ...)
template<typename... Args>
auto product_left(Args... args) {
return (... * args);
}
int main() {
std::cout << product_left(1, 2, 3, 4) << std::endl; // ((1 *2)* 3)*4 = 24
return 0;
}
// 二元右折叠:(a + ... + 10) 相当于 a1 + (a2 + (a3 + 10))
template<typename... Args>
auto sum_right_with_init(Args... args) {
return (args + ... + 10);
}
int main() {
std::cout << sum_right_with_init(1, 2, 3) << std::endl; // 1+(2+(3+10))= 16
return 0;
}
// 二元左折叠:(100 - ... - a) 相当于 (((100 - a1) - a2) - a3)
template<typename... Args>
auto subtract_left_with_init(Args... args) {
return (100 - ... - args);
}
int main() {
std::cout << subtract_left_with_init(10, 20, 30) << std::endl; // ((100-10)-20)-30 = 40
return 0;
}
有默认实参的模板类型形参
格式:
template<typename T = int>
void f();
f(); // 默认为 f <int>
f<double>(); // 显式指明为 f <double>
有些好玩的:
using namespace std::string_literals;
template<typename T1,typename T2,typename RT =
decltype(true ? T1{} : T2{}) >
RT max(const T1& a, const T2& b) { // RT 是 std:: string
return a > b ? a : b;
}
可以这样写(是一种落伍的写法, 只是了解一二)
这里 decltype(true ? T1{}: T2{}) 什么意思呢?, 就是三目表达式要求第二项和第三项之间能够隐式转换,然后整个表达式的类型会是 “公共”类型。也就是最能概括两者的类型.
至于 T{} 带着花括号是用来构造临时对象, 通过这个形式可以获得一个类型.(C++规则真离谱)
非类型模板参数推导
前面我们主要提及的是模板参数的一种形式:类型模板参数。
template <typename T, typename U>
auto add(T t, U u) {
return t+u;
}
其中模板的参数 T 和 U 为具体的类型。 但还有一种常见模板参数形式可以让不同字面量成为模板参数,即非类型模板参数:
template <typename T, int BufSize>
class buffer_t {
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
}
buffer_t<int, 100> buf; // 100 作为模板参数
在这种模板参数形式下,我们可以将 100 作为模板的参数进行传递。 在 C++11 引入了类型推导这一特性后,我们会很自然的问,既然此处的模板参数 以具体的字面量进行传递,能否让编译器辅助我们进行类型推导, 通过使用占位符 auto 从而不再需要明确指明类型? 幸运的是,C++17 引入了这一特性,我们的确可以 auto 关键字,让编译器辅助完成具体类型的推导, 例如:
template <auto value> void foo() {
std::cout << value << std::endl;
return;
}
int main() {
foo<10>(); // value 被推导为 int 类型
}
类模板
类模板不是类,只有实例化类模板,编译器才能生成实际的类。
语法和函数模板差不多
模板成员函数
普通类和模板类都可以有模板成员函数.
用户定义的推导指引
举个例子,我要让一个类模板,如果推导为 int,就让它实际成为 size_t:
template<typename T>
struct Test{
Test(T v) :t{ v } {}
private:
T t;
};
Test(int) -> Test<std::size_t>;
Test t(1); // t 是 Test <size_t>
如果要类模板 Test 推导为指针类型,就变成数组呢?
template<typename T>
Test(T*) -> Test<T[]>;
char* p = nullptr;
Test t(p); // t 是 Test <char[]>
推导指引的语法还是简单的,如果只是涉及具体类型,那么只需要:
模板名称(类型a)->模板名称<想要让类型a被推导为的类型>
如果涉及的是一类类型,那么就需要加上 template,然后使用它的模板形参。
变量模板
template<typename T>
T v; //可以有各种修饰
模板全特化
给出这样一个函数模板 f,你可以看到,它的逻辑是返回两个对象相加的结果,那么如果我有一个需求:“如果我传的是一个 double 一个 int 类型,那么就让它们返回相减的结果”。
template<typename T,typename T2>
auto f(const T& a, const T2& b) {
return a + b;
}
C++14 允许函数返回声明的 auto 占位符自行推导类型。
这种定制的需求很常见,此时我们就需要使用到模板全特化:
template<>
auto f(const double& a, const int& b){
return a - b;
}
当特化函数模板时,如果模板实参推导能通过函数实参提供,那么就 可以忽略它的模板实参。
语法很简单,只需要先写一个 template<> 后面再实现这个函数即可。
不过我们其实有两种写法的,比如上面那个示例,我们还可以写明模板实参。
template<>
auto f<double, int>(const double& a, const int& b) {
return a - b;
}
个人建议写明更加明确,因为很多时候模板实参只是函数形参类型的 一部分 而已,比如上面的 const double&、const int& 只有 double 、int 是模板实参。
模板偏特化
模板偏特化这个语法让 模板实参具有一些相同特征 可以自定义,而不是像全特化那样,必须是 具体的 什么类型,什么值。
比如:指针类型,这是一类类型,有 int*、double*、char*,以及自定义类型的指针等等,它们都属于指针这一类类型;可以使用偏特化对指针这一类类型进行定制。
- 模板偏特化使我们可以对具有相同的一类特征的类模板、变量模板进行定制行为。
举例(变量模板):
template<typename T>
const char* s = "?"; // 主模板
template<typename T>
const char* s<T*> = "pointer"; // 偏特化,对指针这一类类型
template<typename T>
const char* s<T[]> = "array"; // 偏特化,但是只是对 T [] 这一类类型,而不是数组类型,因为 int [] 和 int [N] 不是一个类型
std::cout << s<int> << '\n'; // ?
std::cout << s<int*> << '\n'; // pointer
std::cout << s<std::string*> << '\n'; // pointer
std::cout << s<int[]> << '\n'; // array
std::cout << s<double[]> << '\n'; // array
std::cout << s<int[1]> << '\n'; // ?
待决名
简单说,待决名 就是模板里 “意义暂时定不下来的名字”。因为模板要等实例化时才知道具体类型,有些名字(比如 T::type、S<T>::foo)在写模板时没法确定它到底是类型、变量还是函数模板,这种 “悬而未决” 的名字就叫待决名。
为了让编译器正确理解待决名,C++ 引入了两个关键字:
typename:告诉编译器 “这是个类型”template:告诉编译器 “这是个模板”
typename:解决 “是不是类型” 的歧义
当你在模板里用 X::Y 这种形式,且 X 依赖模板参数时,编译器默认不认为 Y 是类型,必须用 typename 声明。下例:
template<typename T>
void func() {
// T:: type 是待决名,必须用 typename 声明它是类型
typename T::type value; // 正确:告诉编译器 T:: type 是类型
// T:: type value; // 错误:编译器会以为 type 是变量
}
// 测试用的类型
struct MyType {
using type = int; // 这里 type 是类型别名
};
int main() {
func<MyType>(); // 实例化后,T:: type 就是 int
return 0;
}
#include <vector>
template<typename T>
void print(const std::vector<T>& v) {
// std:: vector <T>:: iterator 是待决名,必须加 typename
typename std::vector<T>::iterator it = v.begin(); // 正确
// std:: vector <T>:: iterator it; // 错误:编译器不认它是类型
}
template:解决 “是不是模板” 的歧义
当你在模板里用 X::Y<...> 这种形式,且 X 依赖模板参数时,编译器默认不认为 Y 是模板,必须用 template 声明。下例:
template<typename T>
struct MyStruct {
template<typename U>
void foo(U x) { // 成员函数模板
cout << x << endl;
}
};
template<typename T>
void call_foo() {
MyStruct<T> s;
// s.foo <int>(5); // 错误:编译器会把 < 当成小于号
s.template foo<int>(5); // 正确:用 template 声明 foo 是模板
}
int main() {
call_foo<int>(); // 输出 5
return 0;
}
template<typename T>
struct Container {
template<typename U>
using SubType = pair<T, U>; // 嵌套的模板类型
};
template<typename T>
void test() {
// 访问 Container <T> 的嵌套模板 SubType
typename Container<T>::template SubType<double> data; // 正确
// 解释:
// 1. typename:声明 Container <T>:: SubType 是类型
// 2. template:声明 SubType 是模板(需要传入 double)
}
待决名的查找规则
- 非待决名:在模板定义时就确定含义,后面加新定义也不影响。
- 待决名:在模板实例化时才确定含义,会找实例化时可见的定义。
#include <iostream>
// 全局函数
void print(int x) { cout << "全局print: " << x << endl; }
template<typename T>
struct Base {
void print(int x) { cout << "Base::print: " << x << endl; }
};
template<typename T>
struct Derived : Base<T> {
void call_print() {
print(1); // 非待决名:定义时绑定到全局 print
this->print(2); // 待决名:实例化时找 Base <T> 的 print
}
};
int main() {
Derived<int> d;
d.call_print();
// 输出:
// 全局 print: 1
// Base::print: 2
return 0;
}
解释:
print(1):非待决名,在Derived定义时就找到了全局的print,绑死了。this->print(2):this依赖模板参数T,所以是待决名,实例化时才去Base<T>里找print。
总结
- 待决名是模板中 “含义暂时不确定” 的名字,依赖模板参数。
- 用
typename声明待决名是类型(如typename T::type)。 - 用
template声明待决名是模板(如s.template foo<int>())。 - 非待决名在定义时绑定,待决名在实例化时才查找。
记住:看到 X::Y 且 X 是模板参数,先想是不是需要 typename;看到 X::Y<...>,再想是不是需要 template。
SFINAE
“代换失败不是错误” (Substitution Failure Is Not An Error)
在 函数模板的重载决议 中会应用此规则:当编译器尝试用具体类型替换模板参数时,如果替换失败了,不会直接报错,而是会忽略这个不匹配的模板,继续找其他可能的重载版本。
此特性被用于模板元编程。
核心概念:代换失败 vs 硬错误
- 代换失败:替换模板参数时,在 “立即语境”(比如函数参数类型、返回类型)中发现不合法(比如
T::type不存在),这是 SFINAE 错误,只会排除当前模板。 - 硬错误:替换后触发了副作用(比如实例化了另一个模板导致错误),这是真正的错误,会导致编译失败。
下例:
#include <iostream>
// 模板 1:要求 T 必须有 type 成员
template<typename T>
void func(typename T::type) {
std::cout << "T有type成员" << std::endl;
}
// 模板 2:通用版本
template<typename T>
void func(T) {
std::cout << "通用版本" << std::endl;
}
struct HasType { using type = int; };
struct NoType {};
int main() {
func<HasType>(1); // 调用模板 1:HasType 有 type
func<NoType>(2); // 调用模板 2:NoType 无 type,模板 1 代换失败但不报错
return 0;
}
//输出:
//T 有 type 成员
//通用版本
标准库工具
C++ 标准库提供了几个工具简化 SFINAE 写法:
std::enable_if:条件满足才启用模板
#include <type_traits>
// 只有 T 是 int 时才启用这个函数
template<typename T>
std::enable_if_t<std::is_same_v<T, int>, void>
print(T x) {
std::cout << "int: " << x << std::endl;
}
// 只有 T 是 double 时才启用这个函数
template<typename T>
std::enable_if_t<std::is_same_v<T, double>, void>
print(T x) {
std::cout << "double: " << x << std::endl;
}
int main() {
print(10); // 匹配 int 版本
print(3.14); // 匹配 double 版本
// print("hi"); // 无匹配版本,编译错误
return 0;
}
原理:std::enable_if<条件, 类型> 只有条件为 true 时才有 type 成员,否则代换失败。
std::void_t:检查类型是否有特定成员
#include <type_traits>
// 检查 T 是否有 size()成员函数
template<typename T, typename = void>
struct HasSize : std::false_type {};
template<typename T>
struct HasSize<T, std::void_t<decltype(std::declval<T>().size())>>
: std::true_type {};
// 测试
#include <vector>
#include <iostream>
int main() {
std::cout << HasSize<std::vector<int>>::value << std::endl; // 1(有 size())
std::cout << HasSize<int>::value << std::endl; // 0(无 size())
return 0;
}
原理:std::void_t<...> 接受任意类型,只要里面的表达式合法就不会失败,否则代换失败。
std::declval:无需构造对象即可调用成员
#include <type_traits>
// 检查 T 是否有 operator+,即使 T 没有默认构造函数
template<typename T>
struct HasPlus {
template<typename U>
static auto check(U u) -> decltype(u + u, std::true_type{});
static std::false_type check(...);
static constexpr bool value = decltype(check(std::declval<T>()))::value;
};
struct A { int operator+(A) { return 0; } };
struct B { B(int) {} int operator+(B) { return 0; } }; // 无默认构造
int main() {
std::cout << HasPlus<A>::value << std::endl; // 1(A 有+且可默认构造)
std::cout << HasPlus<B>::value << std::endl; // 1(B 有+,用 declval 避开构造)
return 0;
}
原理:std::declval<T>() 能在不构造 T 对象的情况下生成一个 T 类型的引用,方便在 decltype 中调用成员。
为什么需要 SFINAE?
- 精确控制重载:让编译器只选择符合条件的模板版本。
- 友好的错误提示:替换失败时编译器会说 “找不到匹配的函数”,而不是报一堆模板实例化错误。
- 编译期类型检查:在编译时就能确认类型是否满足要求(比如是否有某个成员)。
约束和概念
概念
C++20 的引入的.
有了它,我们的模板可以有更多的静态检查,语法更加美观,写法更加容易,而不再需要利用古老的 SFINAE。
请务必学习完了上一章节内容;本节会一边为你教学约束与概念的语法,一边用 SFINAE 对比,让你意识到:这是多么先进、简单的核心语言特性。
template<typename T>
concept Add = requires(T a) {
a + a; // "需要表达式 a+a 是可以通过编译的有效表达式"
};
template<Add T>
auto add(const T& t1, const T& t2){
std::puts("concept +");
return t1 + t2;
}
这里的 Add 就是一个概念, 只要概念合法, 就可行.
每个概念都是一个 谓词,它在 编译时求值,并在将之用作约束时成为模板接口的一部分。
也就是可以下面如此:
std::cout << std::boolalpha << Add<int> << '\n'; // true
std::cout << std::boolalpha << Add<char[10]> << '\n'; // false
constexpr bool r = Add<int>; // true
进一步发展:
decltype(auto) max(const auto& a, const auto& b) { // const T&
return a > b ? a : b;
}
我想要约束:传入的对象 a b 必须都是整数类型,应该怎么写?。
#include <concepts> // C++20 概念库标头
decltype(auto) max(const std::integral auto& a, const std::integral auto& b) {
return a > b ? a : b;
}
max(1, 2); // OK
max('1', '2'); // OK
max(1u, 2u); // OK
max(1l, 2l); // OK
max(1.0, 2); // Error! 未满足关联约束
如你所见,我们没有自己定义 概念(concept),而是使用了标准库的 std::integral,它的实现非常简单:
template< class T >
concept integral = std::is_integral_v<T>;
这也告诉各位我们一件事情:定义概念(concept) 时声明的约束表达式,只需要是编译期可得到 bool 类型的表达式即可。
总结一下, 无论模板还是 auto, 都可以用概念约束.只要编译器可以得到 bool 类型就可以作为概念
requires 子句
关键词 requires 用来引入 requires 子句,它指定对各模板实参,或对函数声明的约束。
也就是说我们多了一种使用 概念(concept)或者说约束的写法。
template<typename T>
concept add = requires(T t) {
t + t;
};
template<typename T>
requires std::is_same_v<T, int>
void f(T){}
template<typename T> requires add<T>
void f2(T) {}
template<typename T>
void f3(T)requires requires(T t) { t + t; }
{}
requires子句期待一个能够编译期产生bool值的表达式。
以上示例展示了 requires 子句的用法,我们一个个解释
f的requires子句写在template之后,并空四格,这是我个人推荐的写法;它的约束是:std::is_same_v<T, int>,意思很明确,约束T必须是 int 类型,就这么简单。f2的requires子句写法和f其实是一样的,只是没换行和空格;它使用了我们自定义的 概念(concept)add,约束T必须满足add。f3的requires子句在函数声明符的末尾元素出现;这里我们连用了两个requires,为什么?其实很简单,我们要区分,第一个requires是requires子句,第二个requires是 约束表达式,它会产生一个编译期的bool值,没有问题。(如果T类型带入 约束表达式 是良构,那么就返回true、否则就返回false)。
类模板、变量模板等也都同理
requires 子句中,关键词 requires 必须后随某个常量表达式。
template<typename T>
requires true
void f(T){}
完全可行,各位其实可以直接带入,说白了 requires 子句引入的约束表,必须是可以编译期返回 bool 类型的值的表达式,我们前面的三个例子:std::is_same_v、add、requires 表达式 都如此。
约束
合取
两个约束的合取是通过在约束表达式中使用 && 运算符来构成的:
template<class T>
concept Integral = std::is_integral_v<T>;
template<class T>
concept SignedIntegral = Integral<T> && std::is_signed_v<T>;
template<class T>
concept UnsignedIntegral = Integral<T> && !SignedIntegral<T>;
很合理,约束表达式 可以使用 && 运算符连接两个约束,只有在两个约束都被满足时才会得到满足
我们先定义了一个 概念(concept)Integral,此概念要求整形;又定义了 概念(concept)SignedIntegral,它的约束表达式用到了先前定义的 概念(concept)Integral,然后又加上了一个 && 还需要满足 std:: is_signed_v。
概念(concept)SignedIntegral 是要求有符号整数类型,它的 约束表达式 是:Integral<T> && std::is_signed_v<T>,就是这个表达式要返回 true 才成立,就这么简单。
析取
两个约束的析取,是通过在约束表达式中使用 || 运算符来构成的:
template<typename T>
concept number = std::integral<T> || std::floating_point<T>;
和 || 运算符本来的意思一样, std::integral<T>、std::floating_point 满足任意一个,那么整个约束表达式就都得到满足。
void f(const number auto&){}
f(1); // OK
f(1u); // OK
f(1.2); // OK
f(1.2f); // OK
f("1"); // 未找到匹配的重载函数
requires 表达式
没气力了, 以后用到再说
类型推断
类型推断
auto
auto 只能初始化用, 用于自动推断类型, 编译期开始.
注意, auto 只是推断值类型, const, 引用这种修饰是直接去掉的.当然也可以搭配这些关键字
从 C++ 14 起,auto 能用于 lambda 表达式中的函数传参,而 C++ 20 起该功能推广到了一般的函数和模板返回值。考虑下面的例子:
auto add14 = [](auto x, auto y) -> int {
return x+y;
}
auto add20(auto x, auto y) {
return x+y;
//编译器自动生成一堆模板.
auto i = 5; // type int
auto j = 6; // type int
std::cout << add14(i, j) << std::endl;
std::cout << add20(i, j) << std::endl;
[!WARNING]
auto还不能用于推导数组类型:auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型 2.6.auto.cpp:30:19: error: 'auto_arr2' declared as array of 'auto' auto auto_arr2[10] = {arr};也不允许在生成模板时直接使用, 比如
vector<auto>
decltype
decltype 关键字是为了解决 auto 关键字只能对变量进行类型推导的缺陷而出现的。用于处理表达式.
所以 decltype 可以推断 const, 引用等值类别, 会严格保留相应表达式类别, 而不只是类型.
比较常见用法就是:
int a = 10;
double b = 100.1;
decltype (a+b) c = a + b;
//decltype 可以推导表达式结果的类型, 虽然这里用 auto 也可以啦.
C++11 还引入了一个叫做尾返回类型(trailing return type),利用 auto 关键字将返回类型后置来推断模板返回值类型:
template<typename T, typename U>
auto add2(T x, U y) -> decltype(x+y){
return x + y;
}
令人欣慰的是从 C++14 开始是可以直接让普通函数具备返回值推导,因此下面的写法变得合法:
template<typename T, typename U>
auto add3(T x, U y){
return x + y;
}
decltype(auto)
decltype(auto) 会像 decltype 一样分析表达式的类型和值类别,但语法上像 auto 一样不需要显式指定表达式。
类型推导规则
实际上类型推导规则是比较符合人的第一感觉的, 这也正是它追求的目标, 所以不用特意记, 了解一下即可.
模板类型推断
auto 的类型推断实际上基于模板, 所以我们来看看模板的规则
假设一个基本情况:
template<Typename T>
f(ParamT param);
f(expr);
ParamT 和 T 本身的推断是不同的, 原因很简单, ParamT 可以带上各种修饰符号, 比如指针, 比如 const.
这里有三种情况:
ParamType是一个指针或引用,但不是通用引用ParamType是一个通用引用ParamType既不是指针也不是引用
第一种情况
expr 如果是引用, 忽略引用部分, 然后与 ParamT 进行模式匹配决定 T
举个例子,如果这是我们的模板,
template<typename T>
void f(T& param); //param 是一个引用
我们声明这些变量,
int x=27; //x 是 int
const int cx=x; //cx 是 const int
const int& rx=x; //rx 是指向作为 const int 的 x 的引用
在不同的调用中,对 param 和 T 推导的类型会是这样:
f(x); //T 是 int,param 的类型是 int&
f(cx); //T 是 const int,param 的类型是 const int&
f(rx); //T 是 const int,param 的类型是 const int&
第二种模式
- 如果
expr是左值,T和ParamType都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种T被推导为引用的情况。第二,虽然ParamType被声明为右值引用类型,但是最后推导的结果是左值引用。 - 如果
expr是右值,就使用正常的(也就是 情景一)推导规则
举个例子:
template<typename T>
void f(T&& param); //param 现在是一个通用引用类型, 带有推导的&&的都是通用引用.
int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //x 是左值,所以 T 是 int&,
//param 类型也是 int&
f(cx); //cx 是左值,所以 T 是 const int&,
//param 类型也是 const int&
f(rx); //rx 是左值,所以 T 是 const int&,
//param 类型也是 const int&
f(27); //27 是右值,所以 T 是 int,
//param 类型就是 int&&
通用引用的情况就是如果左值传递就是左值引用, 如果右值传递就是右值引用.
其实这里的 T 带有&, 可以直接带入在 paramT 中, 去掉重复(引用折叠/坍缩规则)就可以发现就是 paramT
第三种情况
这意味着无论传递什么 param 都会成为它的一份拷贝——一个完整的新对象。事实上 param 成为一个新对象这一行为会影响 T 如何从 expr 中推导出结果。
- 和之前一样,如果
expr的类型是一个引用,忽略这个引用部分 - 如果忽略
expr的引用性(reference-ness)之后,expr是一个const,那就再忽略const。如果它是volatile,也忽略volatile
template<typename T>
void f(T param); //以传值的方式处理 param
int x=27; //如之前一样
const int cx=x; //如之前一样
const int & rx=cx; //如之前一样
f(x); //T 和 param 的类型都是 int
f(cx); //T 和 param 的类型都是 int
f(rx); //T 和 param 的类型都是 int
因为这是一个拷贝.
特殊情况
如果数组是采取第一种情况传递进去的, 会保留数组类型, 不会退化, 保留程度, 于是我们可以写出这个:
//在编译期间返回一个数组大小的常量值(//数组形参没有名字,
//因为我们只关心数组的大小)
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
return N;
}
虽然没啥用就是了.
auto 类型推断
与模板差不多, auto 类型推导和模板类型推导有一个直接的映射关系。它们之间可以通过一个非常规范非常系统化的转换流程来转换彼此。
template<typename T>
void f(ParmaType param);
f(expr); //使用一些表达式调用 f
//当一个变量使用 auto 进行声明时,auto 扮演了模板中 T 的角色,变量的类型说明符扮演了 ParamType 的角色。
只有一个例外, 那就是初始化列表.
如何查看类型推断
用你的 lsp,我的伙计。
初始化
因为 C++ 沟槽的初始化,所以单开一章。什么傻逼语言能有二十种初始化方式啊喂。
等号初始化(拷贝初始化 / 复制初始化)
对于内置类型,和 C 语言一样,允许截断和隐式转化。
对于对象就是拷贝初始化(有可能会被优化掉)。
形式如下:
int n = 3.6; // 截断为 3(允许,有时有警告)
std::string s = "abc"; // 用 "abc" 构造一个临时 string,再拷贝/移动给 s(通常优化掉)
括号初始化(直接初始化)
对内置类型类似于等号,对于类来说等于直接调用构造函数,没有临时对象一说。
int n(3.6); // 截断为 3(允许)
std::string s("abc"); // 调用 string(const char*)
MyClass obj(1,2,3); // 调用匹配的 MyClass 构造函数
std::vector<int> v(5, 42); // 5 个元素,每个是 42
列表初始化
对内置数值类型会进行 窄化检查(narrowing conversion),不允许精度丢失的隐式缩窄。
对聚合类型(POD 结构体、数组等)按 成员顺序 或 数组下标顺序 依次初始化,未指定的元素进行值初始化(置 0)。
若类有 std::initializer_list 构造函数优先使用它;否则按参数列表(构造函数)匹配。(C++11)
int b{3.6}; // 错误:窄化转换(double -> int)不允许
struct P { int x; double y; };
P p1{1, 2.5}; // x = 1, y = 2.5
int a[3]{1,2,3}; // 数组初始化
注意:
auto会将大括号初始化列表推断为std::initializer_list类型,而非预期的目标类型。(C++14 起修改后才合乎常理,但必须是 单变量)- C++ 的初始化列表必须在编译期完全展开, 保证从左向右传入参数。
默认初始化
即只是声明。
值初始化
对内置类型 0 初始化
对类调用默认构造函数
对 new 来说等于括号初始化
int x{}; // x = 0
double d{}; // d = 0.0
int* p{}; // p = nullptr
struct Foo {
int a;
Foo() : a(5) {}
};
Foo f1{}; // 调用 Foo 的默认构造函数 → a = 5
Foo f2(); // 这是函数声明,不是对象!(最易踩的坑:Most Vexing Parse)
int* p1 = new int; // 默认初始化:未定义值
int* p2 = new int(); // 值初始化:*p2 = 0
Foo* f1 = new Foo; // 默认初始化:调用 Foo() 构造
Foo* f2 = new Foo(); // 值初始化:对于类,效果同 Foo()
等号 + 列表(C++11)
主要是对于类的区别。
T x{...};→ 列表直接初始化(direct-list-init)T x = {...};→ 列表拷贝初始化(copy-list-init)
当然在编译器优化下实践上几乎等价。但仍然推荐列表初始化。
其余关键字
new 和 delete
using
在 C++11 使用 using 引入了下面这种形式的写法(模板也可以使用别名了),并且同时支持对传统 typedef 相同的功效。
通常我们使用 typedef 定义别名的语法是:typedef 原名称 新名称;,但是对函数指针等别名的定义语法却不相同,这通常给直接阅读造成了一定程度的困难。故而在可读性上,using 相较 typedef 有优越性。
typedef int (*process)(void *);//定义了一个函数指针类型 process
using NewProcess = int(*)(void *);//定义了一个函数指针类型 NewProcess
const
const int *const p 这个变量的声明,这样去解析:
- p: 它名叫 P
- const p :p 是常量,它不可变。
- *const p : p 是常量指针,它不可变。
- int *const p :p 是常量指针,它不可变,它指向一个 int。
- const int *const p :p 是常量指针,它不可变,它指向一个常量 int。
注意 void f(int a){} 和 void f(const int a){} 不构成重载, 原因是这里是拷贝, 所以实际上是一样的, 如果你用引用就可以了.
而且 const 在 cpp 是默认内部链接
constexpr
老标准中编译器无法判断常量表达式, 故而不能直接是用此类东西初始化数组之类.
C++11 提供了 constexpr 让用户显式的声明函数或对象构造函数在编译期会成为常量表达式,这个关键字明确的告诉编译器应该去验证 len_foo 在编译期就应该是一个常量表达式。
此外,constexpr 修饰的函数可以使用递归:
constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}
需要说明一下, 如果不是需要常量表达式的地方, 函数依旧是运行时计算, 除非你在那个地方强制再次使用 constexpr.
在此之上, 还有 if constexpr 的特殊用法.一个很自然的想法是,如果我们把这一特性引入到条件判断中去,让代码在编译时就完成分支判断,岂不是能让程序效率更高?C++17 将 constexpr 这个关键字引入到 if 语句中,允许在代码中声明常量表达式的判断条件,考虑下面的代码:
#include <iostream>
template<typename T>
auto print_type_info(const T& t) {
if constexpr (std::is_integral<T>::value) {
return t + 1;
} else {
return t + 0.001;
}
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}
在编译时,实际代码就会表现为如下:
int print_type_info(const int& t) {
return t + 1;
}
double print_type_info(const double& t) {
return t + 0.001;
}
int main() {
std::cout << print_type_info(5) << std::endl;
std::cout << print_type_info(3.14) << std::endl;
}
控制流
if/switch 可直接定义变量
C++17 后,我们可以在 if(或 switch)中定义变量:
// 将临时变量放到 if 语句内
if (const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3);
itr != vec.end()) {
*itr = 4;
}
for 区间
C++11 中, for 允许对数组类或容器类(vector, array 等等)的每个元素执行相同的操作:
double prices[5] {1.22, 2.22, 3.33, 4.44, 5.55};
for (double x : prices)
cout << x << std::endl;
如果要修改元素则需要 for(double &x : prices)
还有基于列表和范围的初始化方式:
for(int x : {1,2,3})
cout << x << "";
cout << endl;
异常处理
noexcept
C++11 后将异常的声明简化为以下两种情况:函数可能抛出任何异常; 函数不能抛出任何异常.
使用 noexcept(代表绝对不会抛出异常)修饰过的函数如果抛出异常,编译器会立即终止程序运行。未声明代表可能, 需要用 try, catch, throw 来解决.
noexcept 还能使用表达式来判断.
在明确不会抛出异常, 比如: 简单的数值计算函数, 析构(析构已经隐式 noexcept 了), 移动操作相关函数, 频繁调用或者性能敏感的函数, 接口(给调用方了解情况, 这是必要的), 最好使用.
如果有个函数调用其他函数, 并且不知道调用的可否, 那就是异常中立, 就不要使用.
try…catch 块
传统的异常处理是有错误码来实现的, 通过 if 来层层检查.不过这有个问题, 会有很多判断, 较难看(因人而异).
故而 C++发明了 try…catch 块, 它的用法是:
int main(...) {
try {
// ---------------------------------
// 所有可能抛出异常的“正常”业务逻辑都在这里
// ---------------------------------
}
catch (const std::exception& e) {
// ---------------------------------
// 如果 try 块中的任何地方抛出了异常,
// 程序会立即跳转到这里来处理
// ---------------------------------
std::cerr << "Error: " << e.what() << '\n';
return 1;
}
return 0;
}
- try 块:包裹了程序的主要逻辑。它像一个“安全网”,表示“请尝试执行这里的代码,如果出了问题,我知道如何处理”。
- catch 块:这是异常的“捕手”。 catch (const std:: exception& e) 表示它能捕获所有继承自 std:: exception 的标准异常类型。当异常被抛出时,程序会立即停止在 try 块中的执行,跳转到类型匹配的 catch 块。
e.what():std:: exception 类及其所有子类都有一个名为 what() 的虚函数,它返回一个描述错误的 C 风格字符串(const char*)。这是获取异常信息的基本方式。
throw 和栈回退
可以用 throw 抛出一个异常(或者 try 块内部出异常时也会自动捕获), 这时候会跟随调用栈一路回去, 然后跑到 catch 块中, 保证内存安全.
1. 错误码抛出
在 Windows API 中, 可以用 GetLastError() 来获取当前线程上一个 API 调用失败的错误码:
#include <windows.h>
#include <iostream>
void demonstrate_getlasterror() {
// 尝试打开一个不存在的文件,这必然会失败
HANDLE hFile = CreateFileA(
"C:\\this_file_does_not_exist.tmp",
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hFile == INVALID_HANDLE_VALUE) {
// CreateFileA 失败了。我们必须立即调用 GetLastError()
DWORD errorCode = GetLastError();
// 此刻,errorCode 的值通常是 2
std::cout << "CreateFileA failed. The raw error code is: " << errorCode << std::endl;
// 如果我们现在调用另一个 API,比如 Sleep,错误码可能会被覆盖
// Sleep(100);
// DWORD potentiallyChangedErrorCode = GetLastError(); // 结果可能不再是 2
}
}
``` **输出**:
`CreateFileA failed. The raw error code is: 2`
**结论**:`GetLastError()` 返回一个 `DWORD`(一个整数)。这个数字 `2` 本身没有程序化的可读性,需要被解释。
在Linux API中,使用一个叫做errno的全局变量来实现的,每一个线程都有自己单独的errno
在标准的C函数用 strerror(),它接受一个int类型的错误码,返回一个描述该错误的字符串 (const char*)。就可以用它来解释errno,当然这是C语言的实现.
2. std::system_category()
功能:这是一个C++标准库函数,返回一个const std::error_category&对象。这个对象是一个全局的“翻译器”,专门用于解释特定于操作系统的错误码。
用途:为原始的、无意义的数字错误码提供一个“上下文”或“解释机制”。它知道如何将数字2翻译成字符串"The system cannot find the file specified."。
代码示例:
#include <system_error>
#include <iostream>
void demonstrate_category() {
// 获取系统错误码的“翻译器”
const std:: error_category& category = std:: system_category();
// 使用这个翻译器来解释数字 2 和 5
int errorCode_FileNotFound = 2;
int errorCode_AccessDenied = 5;
std:: cout << "Code " << errorCode_FileNotFound << " means: "
<< category.message(errorCode_FileNotFound) << std:: endl;
std:: cout << "Code " << errorCode_AccessDenied << " means: "
<< category.message(errorCode_AccessDenied) << std:: endl;
}
在Windows上的输出:
Code 2 means: The system cannot find the file specified.
Code 5 means: Access is denied.
结论:std::system_category()提供了一个标准化的接口(.message()方法)来将平台相关的错误数字翻译成人类可读的字符串。
3. std::error_code
功能:这是一个C++标准库类,用于封装一个错误码的数值和它的解释类别(std::error_category)。
用途:将一个原始的错误数字(如2)和它的翻译官(std::system_category()的返回结果)绑定在一个对象中,使其成为一个自包含、可移植的错误信息单元。
代码示例:
#include <system_error>
#include <iostream>
#include <windows.h> // For GetLastError
void demonstrate_error_code() {
// 模拟 API 失败
SetLastError(2); // 手动设置当前线程的错误码为 2
DWORD rawErrorCode = GetLastError();
// 创建一个 std:: error_code 对象
std:: error_code ec(rawErrorCode, std:: system_category());
// 现在 ec 对象同时包含了数值和解释方法
std:: cout << "ec.value() returns the number: " << ec.value() << std:: endl;
std:: cout << "ec.message() returns the text: " << ec.message() << std:: endl;
std:: cout << "ec.category().name() returns the translator's name: " << ec.category().name() << std:: endl;
}
输出:
ec.value() returns the number: 2
ec.message() returns the text: The system cannot find the file specified.
ec.category().name() returns the translator's name: system
结论:std::error_code是一个比int更强大的错误容器,因为它自身就携带了解释自己的能力。
4. std::system_error
功能:这是一个C++标准库异常类,继承自std::runtime_error。它被设计用来抛出包含了std::error_code的异常。
用途:将一个底层的、具体的std::error_code与一个高层的、描述程序意图的字符串消息组合在一起,形成一个信息量极大的异常对象。
代码示例:
#include <system_error>
#include <iostream>
#include <string>
void demonstrate_system_error() {
// 1. 创建一个代表底层错误的 error_code
std:: error_code ec(2, std:: system_category());
// 2. 提供一个高层上下文消息
std:: string context_message = "Error while attempting to load configuration from file";
// 3. 用这两部分信息构造一个 system_error 异常对象
std:: system_error se(ec, context_message);
// 4. 检查这个异常对象包含的信息
std:: cout << "The full error message is: " << se.what() << std:: endl;
std:: cout << "The original error code is: " << se.code().value() << std:: endl;
}
输出:
The full error message is: Error while attempting to load configuration from file: The system cannot find the file specified.
The original error code is: 2
结论:std::system_error是用于throw的理想对象,因为它将“做了什么”(字符串)和“具体为什么失败”(error_code)结合在了一起。
5. 自定义异常(下例为我让ai帮我写的一个自定义异常,关于PE文件的读取)
功能:一个您自己定义的、继承自std::system_error的异常类。
用途:
- 代码复用:通过继承,PeException自动获得了
std::system_error的所有功能,尤其是那个能智能拼接字符串的what()方法,无需重复编写代码。 - 类型区分:它允许你在catch块中精确地捕获只与PE文件处理相关的错误,从而实现更精细的错误处理逻辑。
代码示例:
#include <system_error>
#include <iostream>
#include <string>
// 您的 PeException 类定义
class PeException : public std:: system_error {
public:
using std::system_error:: system_error; // 继承构造函数
};
void process_data() {
// 模拟一个非 PE 相关的系统错误
throw std:: system_error(std:: error_code(5, std:: system_category()), "Failed to allocate memory");
}
void process_pe_file() {
// 模拟一个 PE 文件处理相关的错误
throw PeException(std:: error_code(2, std:: system_category()), "Failed to open PE file");
}
void demonstrate_pe_exception() {
try {
process_pe_file(); // 试着调用这个
// process_data(); // 或者试着调用这个,会进入不同的 catch 块
}
catch (const PeException& e) {
// 这个块只会捕获 PeException 类型的异常
std:: cerr << "[PE Handler] Caught a PE-specific error: " << e.what() << std:: endl;
}
catch (const std:: system_error& e) {
// 这个块会捕获其他所有 system_error (但不会是 PeException,因为它已被上面捕获)
std:: cerr << "[Generic System Handler] Caught a generic system error: " << e.what() << std:: endl;
}
}
输出:
[PE Handler] Caught a PE-specific error: Failed to open PE file: The system cannot find the file specified.
结论:自定义异常类型是进行分类和路由错误处理的最佳实践。
STL库
含义和内容
除了从C标准库保留下来的一些功能之外,C++还提供了一个基于模版实现的标准模版库(Standard Template Library, 简称STL)。其包含了:
- 容器类模板:容器用于存储序列化的数据,如:向量、队列、栈、集合等。
- 算法(函数)模板:算法用于对容器中的数据元素进行一些常用操作,如:排序、统计等。
- 迭代器类模板:迭代器实现了抽象的指针功能,它们指向容器中的数据元素,用于对容器中的数据元素进行遍历和访问;迭代器是容器和算法之间的桥梁:传给算法的不是容器,而是指向容器中元素的迭代器,算法通过迭代器实现对容器中数据元素的访问。这样使得算法与容器保持独立,从而提高算法的通用性。
flowchart LR
classDef thickArrow stroke-width: 4px, stroke:#e67e22, color:#000;
A[容器]:::thickArrow
B[算法]:::thickArrow
A -- 提供迭代器给算法操作 --> B
B -- 通过迭代器操作容器 --> A
容器
- 容器:是一种可存储和管理对象集合的数据结构,直接实现了基本的数据存储和访问机制。例如,
vector是动态数组,list是双向链表,set是有序集合,unordered_map是哈希表形式的键值对集合。 它们各自按照不同的数据结构原理,来组织和存储元素。 - 容器适配器:是对已有的容器进行封装,改变其接口以满足特定的需求,本质上是一种包装器。它并不重新实现数据存储和管理机制,而是依赖于其他容器来完成这些工作。常见的容器适配器有
stack(栈)、queue(队列)、priority_queue(优先队列)。
容器:
flowchart TD
G{"指向元素的指针或迭代器,无论添加或删除元素,始终有效"}
subgraph 序列容器
H{动态大小}
H -- 否 --> array
H -- 是 --> I{保持有序吗?}
I -- 否 --> J{是否在中间插入/删除元素?}
J -- 是 --> K{频繁遍历吗?}
J -- 否 --> L{是否在前端插入/删除元素?}
K -- 是 --> M{位置是否持久*?}
K -- 否 --> N{大小变化大吗?}
L -- 是 --> deque
L -- 否 --> vector
M -- 是 --> llist**
N -- 否 --> vector
N -- 是 --> llist**
end
subgraph 有序容器
I -- 是 --> O{主要用途?}
O -- 中序遍历 --> P[vector<sorted> 或 flat_set]
O -- 按键搜索 --> Q{允许重复元素吗?}
Q -- 否 --> R{键映射到值吗?}
Q -- 是 --> S{键映射到值吗?}
R -- 是 --> map
R -- 否 --> set
S -- 是 --> multimap
S -- 否 --> multiset
end
容器适配器:
flowchart TD
subgraph 自适应容器
A{顺序是否重要?}
A -- 是 --> B{后进先出}
A -- 否 --> C{先进先出}
B -- 是 --> stack
C -- 是 --> queue
C -- 否 --> priority_queue
end
subgraph 无序容器
D{允许重复元素吗?}
A -- 否 --> D
D -- 否 --> E{键映射到值吗?}
D -- 是 --> F{键映射到值吗?}
E -- 是 --> unordered_map
E -- 否 --> unordered_set
F -- 是 --> unordered_multimap
F -- 否 --> unordered_multiset
end
- 对于
vector,emplace_back有更好的性能,应当优先使用. string_view在没有需要修改字符串的情况下,比如string更好。
迭代器
| 迭代器类型 | 英文缩写 | 能否读元素 | 能否改元素 | 支持的主要运算符 | 典型用途/说明 |
|---|---|---|---|---|---|
| 输入迭代器 | InIt |
是 | 否 | *、->、++、==、!= |
只读、单向遍历,如从输入流读取 |
| 输出迭代器 | OutIt |
否 | 是 | *、++ |
只写、单向输出,如写入容器或输出流 |
| 前向迭代器 | FwdIt |
是 | 是 | *、->、++、==、!= |
可多次单向遍历,读写皆可 |
| 双向迭代器 | BidIt |
是 | 是 | *、->、++、--、==、!= |
可前后移动遍历 |
| 随机访问迭代器 | RanIt |
是 | 是 | *、->、[]、++、--、+、-、+=、-=、==、!=、<、>、<=、>= |
像指针一样随机跳转,支持下标和比较大小 |
-
对于
vector、deque以及basic_string容器类,与它们关联的迭代器类型为随机访问迭代器(RanIt) -
对于
list、map/multimap以及set/multiset容器类,与它们关联的迭代器类型为双向迭代器(BidIt)
queue、stack和priority_queue容器类,不支持迭代器!
除了上面五种基本迭代器外,STL还提供了一些迭代器的适配器,用于一些特殊的操作,如:
- 反向迭代器(reverse iterator):用于对容器元素从尾到头进行反向遍历,可以通过容器类的成员函数
rbegin和rend可以获得容器的尾和首元素的反向迭代器。需要注意的是,对反向迭代器,++操作是往容器首部移动,--操作是往容器尾部移动。 - 插入迭代器(insert iterator):用于在容器中指定位置插入元素,其中包括:
back_insert_iterator(用于在尾部插入元素);front_insert_iterator(用于在首部插入元素);insert_iterator(用于在任意指定位置插入元素)。它们可以分别通过函数back_inserter、front_inserter和inserter来获得,函数的参数为容器。
const迭代器
简单说,const_iterator 是一种 “只读” 的迭代器,它指向的数据不能被修改。
从 C++11 开始,这个才开始好用:
- 容器新增了
cbegin()、cend()方法,直接就能拿到const_iterator - 插入、删除等操作也支持用 const_iterator 来指定位置了
比如原来的代码可以改成这样(更简单且安全):
// 用 cbegin()/cend() 得到 const_iterator
auto it = std:: find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1998); // 现在可以直接用 const_iterator 了
C++11 还有个小遗憾:对于一些特殊的数据结构(比如原生数组),没有提供全局的 cbegin()、cend() 函数。C++14 补上了这个漏洞,让代码可以更通用。
总之:
- 写代码时,只要不需要修改迭代器指向的数据,就优先用
const_iterator - 获取迭代器时,优先用
cbegin()、cend()(而不是普通的begin()、end()) - 在通用代码中,优先用全局的
begin()、end()、cbegin()等函数(而不是容器的成员函数)
算法
==TODO==