应知应会 C++ 小技巧
- 应知应会 C++ 小技巧
- 交换两个变量
- 安全地分配一段内存空间
- 地板除与天花板除
- 别再 [] 啦!
- 别再写构造函数啦!
- 别再写拷贝构造函数啦!
- 继承构造函数
- 提前返回
- 立即调用的 Lambda
- Lambda 复用代码
- 类内静态成员 inline
- 别再 make_pair 啦!
- insert 不会替换现有值哦
- 一边遍历 map,一边删除?
- 高效删除单个 vector 元素
- 批量删除部分 vector 元素
- 保持有序的 vector 用于二分法
- C++ 随机数的正确生成方式
- const 居然应该后置…
- 函数参数也可以 auto
- 读取整个文件到字符串
- 逐行读取文本文件
- 字符串切片
- cout 不需要 endl
- 多线程中 cout 出现乱序?
- cerr 与 cout 的抉择
- 智能指针防止大对象移动
- optional 实现延迟初始化
- if-auto 与 while-auto
- bind 是历史糟粕,应该由 Lambda 表达式取代
- forward 迷惑性地不好用,建议改用 FWD 宏
- bind 绑定成员函数是陋习,改用 lambda 或 bind_front
- 救命!为什么我的全局函数不能作为函数对象?
- map + any 外挂属性
- 自定义 shared_ptr 的 deleter
- CHECK_CUDA 类错误检测宏
- 函数默认参数求值的位置是调用者
- 设置 locale 为 .utf8
- 花括号实现安全的类型转换检查
- 成员函数针对 this 的移动重载
- CHECK_CUDA 类错误检测宏
- 函数默认参数求值的位置是调用者
- 花括号实现安全的类型转换检查
- 临时右值转左值
- ostringstream 格式化字符串
- ADL 机制实现静态多态
- shared_from_this
- requires 语法检测是否存在指定成员函数
- 设置 locale 为 .utf8 解决 Windows 编码难问题
- 成员函数针对 this 的移动重载
- 位域(bit-field)
- vector + unordered_map = LRU cache
- Lambda 捕获 unique_ptr 导致 function 报错怎么办
- 多线程通信应基于队列,而不是共享全局变量
- RAII 的 finally 帮手类
- swap 缩小 mutex 区间代价
- namespace 别名
交换两个变量
int a = 42;
int b = 58;
现在你想交换这两个变量。
int tmp = a;
a = b;
b = tmp;
但是标准库提供了更好的方法:
std::swap(a, b);
这个方法可以交换任意两个同类型的值,包括结构体、数组、容器等。
只需要
#include <utility>
就可以使用!
安全地分配一段内存空间
小彭老师:不要出现 new 和 delete,不安全。
同学:我想要分配一段内存空间,你不让我 new,我还能怎么办呢?
char *mem = new char[1024]; // 同学想要 1024 字节的缓冲区
read(1, mem, 1024); // 用于供 C 语言的读文件函数使用
delete[] mem; // 需要手动 delete
可以看到,他所谓的“内存空间”实际上就是一个“char 数组”。
小彭老师:有没有一种可能,vector 就可以分配内存空间。
vector<char> mem(1024);
read(1, mem.data(), mem.size());
vector 一样符合 RAII 思想,构造时自动申请内存,离开作用域时自动释放。
只需在调用 C 语言接口时,取出原始指针:
- 用 data() 即可获取出首个 char 元素的指针,用于传递给 C 语言函数使用。
- 用 size() 取出数组的长度,即是内存空间的字节数,因为我们的元素类型是 char,char 刚好就是 1 字节的,size() 刚好就是字节的数量。
此处 read 函数读完后,数据就直接进入了 vector 中,根本不需要什么 new。
更现代的 C++ 思想家还会用
vector<std::byte>
,明确区分这是“字节”不是“字符”。如果你读出来的目的是当作字符串,可以用std::string
。注意:一些愚蠢的教材中,用
shared_ptr
和unique_ptr
来管理数组,这是错误的。
shared_ptr
和unique_ptr
智能指针主要是用于管理“单个对象”的,不是管理“数组”的。
vector
一直都是数组的管理方式,且从 C++98 就有。不要看到 “new 的替代品” 只想到智能指针啊!“new [] 的替代品” 是vector
啊!
此处放出一个利用 std::wstring
分配 wchar_t *
内存的案例:
std::wstring utf8_to_wstring(std::string const &s) {
int len = MultiByteToWideChar(CP_UTF8, 0,
s.data(), s.size(),
nullptr, 0); // 先确定长度
std::wstring ws(len, 0);
MultiByteToWideChar(CP_UTF8, 0,
s.data(), s.size(),
ws.data(), ws.size()); // 再读出数据
return ws;
}
地板除与天花板除
众所周知,C语言中 int
相除 /
,得到的结果也是 int
,如果除法产生了余数,那么只会保留整数部分。
例如 14 / 5
,本来应该得到 2.8。但是因为 C 语言的除法返回 int
,结果会自动向下取整,导致得到 2。
int a = 14, b = 5;
int c = a / b; // c = 14 / 5 = 2
等价于
int c = floor((float)a / b); // c = floor(2.8) = 2
如果 a
除以 b
除不尽,那么会找到比他小的第一个整数作为结果,这就是地板除 (floor div)。
C 语言默认的就是地板除。
如果我想要的是向上取整,该怎么写?
最原始的写法是先转成浮点数来除,然后ceil函数向上取整:
int c = ceil((float)a / b);
但是浮点数不仅低效,还有糟糕的浮点数精度误差!对于很大的整数(大于 2^{23})会产生错误的结果。
更合理的写法是先把 a
加上 b - 1
,然后再下取整地除以 b
:
int c = (a + b - 1) / b;
这样就能产生一个向上取整的除法了。
如果 a
除以 b
除不尽,那么会找到比他大的第一个整数作为结果,这就是天花板除 (ceil div)。
试试看:14 除以 5,应该得到 2.8;如果用地板除,会得到 2;如果用天花板除,会得到 3。
14 / 5 = 2
(14 + 5 - 1) / 5 = (14 + 4) / 5 = 18 / 5 = 3
试试看:10 除以 5,应该得到 2;那么无论是地板除还是天花板除,都应该得到 2。
10 / 5 = 2
(10 + 5 - 1) / 5 = (10 + 4) / 5 = 14 / 5 = 2
这就是 C 语言中实现天花板除的业界公认方式。
别再 []
啦!
你知道吗?在 map 中使用 []
查找元素,如果不存在,会自动创建一个默认值。这个特性有时很方便,但如果你不小心写错了,就会在 map 中创建一个多余的默认元素。
map<string, int> table;
table["小彭老师"] = 24;
cout << table["侯捷老师"];
table 中明明没有 “侯捷老师” 这个元素,但由于 []
的特性,他会默认返回一个 0,不会爆任何错误!
改用更安全的 at()
函数,当查询的元素不存在时,会抛出异常,方便你调试:
map<string, int> table;
table.at("小彭老师") = 24;
cout << table.at("侯捷老师"); // 抛出异常
[]
真正的用途是“写入新元素”时,如果元素不存在,他可以自动帮你创建一个默认值,供你以引用的方式赋值进去。
检测元素是否存在可以用 count
:
if (table.count("小彭老师")) {
return table.at("小彭老师");
} else {
return 0;
}
即使你想要默认值 0 这一特性,count
+ at
也比 []
更好,因为 []
的默认值是会对 table 做破坏性修改的,这导致 []
需要 map
的声明不为 const
:
map<string, int> table;
return table["小彭老师"]; // 如果"小彭老师"这一键不存在,会创建"小彭老师"并设为默认值 0
const map<string, int> table;
return table["小彭老师"]; // 编译失败![] 需要非 const 的 map 对象,因为他会破坏性修改
更多 map 知识请看我们的 map 专题课。
别再写构造函数啦!
// C++98
struct Student {
string name;
int age;
int id;
Student(string name_, int age_, int id_) : name(name_), age(age_), id(id_) {}
};
Student stu("侯捷老师", 42, 123);
C++98 需要手动书写构造函数,非常麻烦!而且几乎都是重复的。
C++11 中,平凡的结构体类型不需要再写构造函数了,只需用 {}
就能对成员依次初始化:
// C++11
struct Student {
string name;
int age;
int id;
};
Student stu{"小彭老师", 24, 123};
这被称为聚合初始化 (aggregate initialize)。只要你的类没有自定义构造函数,没有 private 成员,都可以用 {}
聚合初始化。
好消息:C++20 中,聚合初始化也支持 ()
了,用起来就和传统的 C++98 构造函数一样!
// C++20
Student stu("小彭老师", 24, 123);
聚合初始化还可以指定默认值:
// C++11
struct Student {
string name;
int age;
int id = 9999;
};
Student stu{"小彭老师", 24};
// 等价于:
Student stu{"小彭老师", 24, 9999};
C++20 开始,{}
聚合初始化还可以根据每个成员的名字来指定值:
Student stu{.name = "小彭老师", .age = 24, .id = 9999};
// 等价于:
Student stu{"小彭老师", 24, 9999};
好处是,即使不慎写错参数顺序也不用担心。
Student stu{.name = "小彭老师", .age = 24, .id = 9999};
Student stu{.name = "小彭老师", .id = 9999, .age = 24};
别再写拷贝构造函数啦!
只有当你需要有“自定义钩子逻辑”的时候,才需要自定义构造函数。
struct Student {
string name;
int age;
int id;
Student(string name_, int age_, int id_) : name(name_), age(age_), id(id_) {}
Student(Student const &other) : name(other.name), age(other.age), id(other.id) {
std::cout << "拷贝构造\n";
}
Student &operator=(Student const &other) {
name = other.name;
age = other.age;
id = other.id;
std::cout << "拷贝赋值\n";
return *this;
}
};
Student stu1("侯捷老师", 42, 123);
Student stu2 = stu1; // 拷贝构造
stu2 = stu1; // 拷贝赋值
如果你不需要这个 std::cout
,只是平凡地拷贝所有成员,完全可以不写,让编译器自动生成拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数:
struct Student {
string name;
int age;
int id;
Student(string name_, int age_, int id_) : name(name_), age(age_), id(id_) {}
// 编译器自动生成 Student(Student const &other)
// 编译器自动生成 Student &operator=(Student const &other)
};
Student stu1("侯捷老师", 42, 123);
Student stu2 = stu1; // 拷贝构造
stu2 = stu1; // 拷贝赋值
assert(stu2.name == "侯捷老师");
总之,很多 C++ 教材把拷贝/移动构造函数过于夸大,搞得好像每个类都需要自己定义一样。
实际上,只有在“自己实现容器”的情况下,才需要自定义拷贝构造函数。可是谁会整天手搓容器?
大多数情况下,我们只需要在类里面存 vector、string 等封装好的容器,编译器默认生成的拷贝构造函数会自动调用他们的拷贝构造函数,用户只需专注于业务逻辑即可,不需要操心底层细节。
对于持有资源的 RAII 类,我们都会直接删除其拷贝构造函数和拷贝赋值函数:
struct RAIIHandle {
int handle;
RAIIHandle() {
handle = CreateObject();
}
RAIIHandle(RAIIHandle const &) = delete;
RAIIHandle &operator=(RAIIHandle const &) = delete;
RAIIHandle() {
DeleteObject(handle);
}
};
继承构造函数
C++ 特色:子类不会自动继承父类的构造函数!(除非父类的构造函数是没有参数的默认构造函数)
struct Parent {
Parent(int age, const char *name) { ... }
void parent_func() { ... }
};
struct Child : Parent {
void child_func() { ... }
};
Child child(23, "peng"); // 错误!Child 没有构造函数!
C++11 中可以在子类里面写 using 父类::父类
,就能自动继承父类所有的构造函数了。
struct Parent {
Parent(int age, const char *name) { ... }
void parent_func() { ... }
};
struct Child : Parent {
using Parent::Parent; // 加上这一行!
void child_func() { ... }
};
Child child(23, "peng"); // 编译通过,自动调用到父类的构造函数 Parent(int, const char *)
在 C++98 中,没有 using 的这个语法,只能自己定义一个构造函数,然后使用“委任构造”的语法转发所有参数给父类,非常繁琐。
struct Parent {
Parent(int age, const char *name) { ... }
void parent_func() { ... }
};
struct Child : Parent {
Child(int age, const char *name)
: Parent(age, name)
{ ... }
void child_func() { ... }
};
Child child(23, "peng"); // 编译通过,调用到子类的构造函数后转发到父类
提前返回
void babysitter(Baby *baby) {
if (!baby->is_alive()) {
puts("宝宝已经去世了");
} else {
puts("正在检查宝宝喂食情况...");
if (baby->is_feeded()) {
puts("宝宝已经喂食过了");
} else {
puts("正在喂食宝宝...");
puts("正在调教宝宝...");
puts("正在安抚宝宝...");
}
}
}
这个函数有很多层嵌套,很不美观。用提前返回的写法来优化:
void babysitter(Baby *baby) {
if (!baby->is_alive()) {
puts("宝宝已经去世了");
return;
}
puts("正在检查宝宝喂食情况...");
if (baby->is_feeded()) {
puts("宝宝已经喂食过了");
return;
}
puts("正在喂食宝宝...");
puts("正在调教宝宝...");
puts("正在安抚宝宝...");
}
立即调用的 Lambda
有时,需要在一个列表里循环查找某样东西,也可以用提前返回的写法优化:
bool find(const vector<int> &v, int target) {
for (int i = 0; i < v.size(); ++i) {
if (v[i] == target) {
return true;
}
}
return false;
}
可以包裹一个立即调用的 Lambda 块 [&] { ... } ()
,限制提前返回的范围:
void find(const vector<int> &v, int target) {
bool found = [&] {
for (int i = 0; i < v.size(); ++i) {
if (v[i] == target) {
return true;
}
}
return false;
} ();
if (found) {
...
}
}
Lambda 复用代码
vector<string> spilt(string str) {
vector<string> list;
string last;
for (char c: str) {
if (c == ' ') {
list.push_back(last);
last.clear();
} else {
last.push_back(c);
}
}
list.push_back(last);
return list;
}
上面的代码可以用 Lambda 复用:
vector<string> spilt(string str) {
vector<string> list;
string last;
auto push = [&] {
list.push_back(last);
last.clear();
};
for (char c: str) {
if (c == ' ') {
push();
} else {
last.push_back(c);
}
}
push();
return list;
}
类内静态成员 inline
在头文件中定义结构体的 static 成员时:
struct Class {
static int member;
};
会报错 undefined reference to 'Class::member'
。这是说的你需要找个 .cpp 文件,写出 int Class::member
才能消除该错误。
C++17 中,只需加个 inline
就能解决!
struct Class {
inline static int member;
};
别再 make_pair 啦!
map<string, int> table;
table.insert(pair<string, int>("侯捷老师", 42));
为避免写出类型名的麻烦,很多老师都会让你写 make_pair:
map<string, int> table;
table.insert(make_pair("侯捷老师", 42));
然而 C++11 提供了更好的写法,那就是通过 {}
隐式构造,不用写出类型名或 make_pair:
map<string, int> table;
table.insert({"侯捷老师", 42});
即使你出于某种“抖m”情节,还想写出类型名,也可以用 C++17 的 CTAD 语法,免去模板参数:
map<string, int> table;
table.insert(pair("侯捷老师", 42));
// tuple 也支持 CTAD:
auto t = tuple("侯捷老师", 42, string("小彭老师"));
// 等价于:
auto t = make_tuple("侯捷老师", 42, string("小彭老师"));
println("{}", typeid(t).name()); // tuple<const char *, int, string>
insert 不会替换现有值哦
map<string, int> table;
table.insert({"小彭老师", 24});
table.insert({"小彭老师", 42});
这时,table["小彭老师"]
仍然会是 24,而不是 42。因为 insert 不会替换 map 里已经存在的值。
如果希望如果已经存在时,替换现有元素,可以使用 []
运算符:
map<string, int> table;
table["小彭老师"] = 24;
table["小彭老师"] = 42;
C++17 提供了比 []
运算符更适合覆盖性插入的 insert_or_assign
函数:
map<string, int> table;
table.insert_or_assign("小彭老师", 24);
table.insert_or_assign("小彭老师", 42);
好处:insert_or_assign
不需要值类型支持默认构造,可以避免一次默认构造函数 + 一次移动赋值函数的开销。
建议把
insert_or_assign
改名成set
,at
改名成get
;只是由于历史原因名字迷惑了。
一边遍历 map,一边删除?
map<string, int> table;
for (auto it = table.begin(); it != table.end(); ++it) {
if (it->second < 0) {
table.erase(it);
}
}
会发生崩溃!看来 map 似乎不允许在遍历的过程中删除?不,只是你的写法有错误:
map<string, int> table;
for (auto it = table.begin(); it != table.end(); ) {
if (it->second < 0) {
it = table.erase(it);
} else {
++it;
}
}
C++20 引入了更好的 erase_if 全局函数,不用手写上面这么麻烦的代码:
map<string, int> table;
erase_if(table, [](pair<string, int> it) {
return it.second < 0;
});
高效删除单个 vector 元素
vector<int> v = {48, 23, 76, 11, 88, 63, 45, 28, 59};
众所周知,在 vector 中删除元素,会导致后面的所有元素向前移动,十分低效。复杂度:O(n)
// 直接删除 v[3]
v.erase(v.begin() + 3);
如果不在乎元素的顺序,可以把要删除的元素和最后一个元素 swap,然后 pop_back。复杂度:O(1)
// 把 v[3] 和 v[v.size() - 1] 位置对调
swap(v[3], v[v.size() - 1]);
// 然后删除 v[v.size() - 1]
v.pop_back();
这样就不用移动一大堆元素了。这被称为 back-swap-erase。
批量删除部分 vector 元素
vector 中只删除一个元素需要 O(n)。如果一边遍历,一边删除多个符合条件的元素,就需要复杂度 O(n^2) 了。
标准库提供了 remove
和 remove_if
函数,其内部采用类似 back-swap-erase 的方法,先把要删除的元素移动到末尾。然后一次性 erase
掉末尾同样数量的元素。
且他们都能保持顺序不变。
删除所有值为 42 的元素:
vector<int> v;
v.erase(remove(v.begin(), v.end(), 42), v.end());
删除所有值大于 0 的元素:
vector<int> v;
v.erase(remove_if(v.begin(), v.end(), [](int x) {
return x > 0;
}), v.end());
现在 C++20 也引入了全局函数 erase 和 erase_if,使用起来更加直观:
vector<int> v;
erase(v, 42); // 删除所有值为 42 的元素
erase_if(v, [](int x) {
return x > 0; // 删除所有值大于 0 的元素
});
保持有序的 vector 用于二分法
如果你想要维护一个有序的数组,用 lower_bound
或 upper_bound
来插入元素,保证插入后仍保持有序:
vector<int> s;
s.push_back(1);
s.push_back(2);
s.push_back(4);
s.push_back(6);
// s = { 1, 2, 4, 6 }
s.insert(lower_bound(s.begin(), s.end(), 3), 3);
// s = { 1, 2, 3, 4, 6 }
s.insert(lower_bound(s.begin(), s.end(), 5), 5);
// s = { 1, 2, 3, 4, 5, 6 }
有序数组中,可以利用 lower_bound
或 upper_bound
快速二分查找到想要的值:
vector<int> s;
s.push_back(1);
s.push_back(2);
s.push_back(4);
s.push_back(6);
// s = { 1, 2, 4, 6 }
lower_bound(s.begin(), s.end(), 3); // s.begin() + 2;
lower_bound(s.begin(), s.end(), 5); // s.begin() + 3;
有序 vector 应用案例:利用 CDF 积分 + 二分法可以实现生成任意指定分布的随机数。
例如数值策划要求的抽卡概率分布是:
- 2% 出金卡
- 10% 出蓝卡
- 80% 出白卡
- 8% 出答辩
那么你转换一下任务。变成随机生成一个 0 到 1 的浮点数,然后判断:
- 小于 0.02 时,出金卡
- 小于 0.12 时,出蓝卡
- 小于 0.92 时,出白卡
- 小于 1.00 时,出答辩
这个转换过程就是 CDF 积分。如果你把这 4 个数按照顺序排列,就是一个有序 vector。
标准库提供了 std::partial_sum
(不精准)或 std::inclusive_scan
(更精准,C++17 引入)都可以计算一个数组的 CDF 离散积分。
vector<double> probs = {0.02, 0.1, 0.8, 0.08};
vector<double> cdf;
// 计算 probs 的 CDF 积分,存入 cdf 数组
std::inclusive_scan(probs.begin(), probs.end(), std::back_inserter(cdf));
// cdf = {0.02, 0.12, 0.92, 1.00} 是一个有序 vector,可以运用二分法定位
vector<string> result = {"金卡", "蓝卡", "白卡", "答辩"};
// 生成 100 个随机数:
for (int i = 0; i < 100; ++i) {
double r = rand() / (RAND_MAX + 1.0);
int index = lower_bound(cdf.begin(), cdf.end(), r) - cdf.begin();
cout << "你抽到了" << result[index] << endl;
}
顺便一提,CDF 积分的逆运算是离散微分:
std::adjacent_difference
,可以从cdf
数组复原出probs
数组。
C++ 随机数的正确生成方式
// 错误的写法:
int r = rand() % 10; // 这样写是错误的!
rand() 的返回值范围是 [0, RAND_MAX],RAND_MAX 在不同平台下不同,在 Windows 平台的是 32767,即 rand() 只能生成 0~32767 之间的随机数。
如果想要生成 0~9 之间的随机数,最简单的办法是:
int r = rand() % 10;
然而这种方法有个致命的问题:不同的随机数生成概率不一样。
例如把 [0, RAND_MAX] 均匀地分成 10 份,每份 3276.7。那么 0~6 之间的数字出现的概率是 3276.7 / 32767 = 10.0003%,而 7~9 之间的数字出现的概率是 3276.7 / 32767 = 9.997%。
这样就不是真正的均匀分布,这可能会影响程序的正确性。
- 当模数大的时候不均匀性会变得特别明显,例如
rand() % 10000
。 - RAND_MAX 在不同平台不同的特性也让跨平台开发者很头大。
rand
使用全局变量存储种子,对多线程不友好。- 无法独立的为多个生成序列设定独立的种子,一些游戏可能需要用到多个随机序列,各自有独立的种子。
- 只能生成均匀分布的整数,不能生成幂率分布、正太分布等,生成浮点数也比较麻烦。
- 使用
srand(time(NULL))
无法安全地生成随机数的初始种子,容易被黑客预测并攻击。 rand
的算法实现没有官方规定,在不同平台各有不同,产生的随机数序列可能不同。
为此,C++ 提出了更加专业的随机数生成器:<random>
库。
// 使用 <random> 库生成 0~9 之间的随机数:
#include <random>
#include <iostream>
int main() {
uint64_t seed = std::random_device()();
std::mt19937 gen(seed);
std::uniform_int_distribution<int> dis(0, 9);
for (int i = 0; i < 100; ++i) {
int r = dis(gen);
std::cout << r << " ";
}
}
这样就可以生成 0~9 之间的均匀分布的随机数了。
std::random_device
是一个随机数种子生成器,它会利用系统的随机设备(如果有的话,否则会抛出异常)生成一个安全的随机数种子,黑客无法预测。std::mt19937
是一个随机数生成器,它会利用初始种子生成一个随机数序列。并且必定是 MT19937 这个高强度的随机算法,所有平台都一样。std::uniform_int_distribution
是一个分布器,它可以把均匀分布的随机数映射到我们想要的上下界中。里面的实现类似于gen() % 10
,但通过数学机制保证了绝对均匀性。
类似的还有 std::uniform_real_distribution
用于生成浮点数,std::normal_distribution
用于生成正太分布的随机数,std::poisson_distribution
用于生成泊松分布的随机数,等等。
如果喜欢老式的函数调用风格接口,可以封装一个新的 C++ 重置版安全 rand
:
thread_local std::mt19937 gen(std::random_device{}()); // 每线程一个,互不冲突
int randint(int min, int max) {
return std::uniform_int_distribution<int>(min, max)(gen);
}
float randfloat(float min, float max) {
return std::uniform_real_distribution<float>(min, max)(gen);
}
const 居然应该后置…
众所周知,const
在指针符号 *
的前后,效果是不同的。
const int *p;
int *const p;
你能看出来上面这个 const 分别修饰的是谁吗?
const int *p; // 指针指向的 int 不可变
int *const p; // 指针本身不可改变指向
为了看起来更加明确,我通常都会后置所有的 const 修饰。
int const *p; // 指针指向的 int 不可变
int *const p; // 指针本身不可改变指向
这样就一目了然,const 总是在修饰他前面的东西,而不是后面。
为什么 int *const
修饰的是 int *
也就很容易理解了。
int const i;
int const *p;
int *const q;
int const &r;
举个例子:
int i, j;
int *const p = &i;
*p = 1; // OK:p 指向的对象可变
p = &j; // 错误:p 本身不可变,不能改变指向
int i, j;
int const *p = &i;
*p = 1; // 错误:p 指向的对象不可变
p = &j; // OK:p 本身可变,可以改变指向
int i, j;
int const *const p = &i;
*p = 1; // 错误:p 指向的对象不可变
p = &j; // 错误:p 本身也不可变,不能改变指向
int const *
和const int *
等价!只有int *const
是不同的。
函数参数也可以 auto
大家都知道,函数的返回类型可以声明为 auto
,让其自动推导。
auto square() { // int square();
return 1;
}
但你知道从 C++20 开始,参数也可以声明为 auto 了吗?
auto square(auto x) { // T square(T x);
return x * x;
}
square(1); // square(int)
square(3.14); // square(double)
等价于以下“模板函数”的传统写法:
template <typename T>
T square(T x) {
return x * x;
}
square(1); // square<int>(int)
square(3.14); // square<double>(double)
因为是模板函数,所以也很难分离声明和定义,只适用于头文件中就地定义函数的情况。
auto
参数还可以带有引用:
auto square(auto const &x) { // T square(T const &x);
return x * x;
}
square(1); // square(int const &)
square(3.14); // square(double const &)
等价于:
template <typename T>
T square(T const &x) {
return x * x;
}
auto
参数最好的配合莫过于是与同样 C++20 引入的 concept:
auto square(std::integral auto x) { // T square(T x) requires std::integral<T>
return x * x;
}
square(1); // square(int)
square(3.14); // 错误:double 不是整数类型
等价于:
template <typename T>
requires std::integral<T>
T square(T x) {
return x * x;
}
或者:
template <std::integral T>
T square(T x) {
return x * x;
}
读取整个文件到字符串
std::string file_get_content(std::string const &filename) {
std::ifstream ifs(filename, std::ios::in | std::ios::binary);
std::istreambuf_iterator<char> iit(ifs), iite;
std::string content(iit, iite);
return content;
}
void file_put_content(std::string const &filename, std::string const &content) {
std::ofstream ofs(filename, std::ios::out | std::ios::binary);
ofs << content;
}
这样就可以把整个文件读取到内存中,很方便地进行处理后再写回文件。
推荐用
std::ios::binary
选项打开二进制文件,否则字符串中出现'\n'
时,会被 MSVC 标准库自动转换成'\r\n'
来写入,妨碍我们跨平台。
逐行读取文本文件
std::ifstream fin("test.txt");
std::string line;
while (std::getline(fin, line)) {
std::cout << "读取到一行:" << line << '\n';
}
字符串切片
#include <sstream>
#include <string>
#include <vector>
std::vector<std::string> split_str(std::string const &str, char ch) {
std::stringstream ss(str);
std::string line;
std::vector<std::string> res;
while (std::getline(ss, line, ch)) {
res.push_back(std::move(line));
}
return res;
}
auto res = split_str("hello world", ' '); // res = {"hello", "world"}
cout 不需要 endl
int a = 42;
printf("%d\n", a);
万一你写错了 %
后面的类型,编译器不会有任何报错,留下隐患。
int a = 42;
printf("%s\n", a); // 编译器不报错,但是运行时会崩溃!
C++ 中有更安全的输出方式 cout
,通过 C++ 的重载机制,无需手动指定 %
,自动就能推导类型。
int a = 42;
cout << a << endl;
double d = 3.14;
cout << d << endl;
cout << "Hello, World!" << endl;
endl 是一个特殊的流操作符,作用等价于先输出一个 '\n'
然后 flush
。
cout << "Hello, World!" << '\n';
cout.flush();
但实际上,输出流 cout 默认的设置就是“行刷新缓存”,也就是说,检测到 '\n'
时,就会自动刷新一次,根本不需要我们手动刷新!
如果还用 endl 的话,就相当于刷新了两次,浪费性能。
可见,endl 是一个被很多无脑教材错误宣传,实际上根本多此一举的东西。
我们只需要输出 '\n'
就可以了,每次换行时 cout 都会自动刷新。
cout << "Hello, World!" << '\n';
endl 是一个典型的以讹传讹错误写法,只有当你的输出是指向另一个进程的管道时,其附带的刷新功能才有作用。
- 当输出是管道或文件时,
cout
需要endl
才能刷新。 - 当输出是普通控制台时,
cout
只需'\n'
就能刷新了,根本用不着endl
。
而且,管道或文件实际上也不存在频繁刷新的需求,反正 ifstream
析构时总是会自动刷新写入磁盘。
因此,endl
操纵符大多时候都是冗余的:控制台输出的 cout
只需要字符或字符串中含有 '\n'
就刷新了,即使是文件读写也很少会使用 endl
。
如果确实需要强制刷新,也可以用 flush
这种更加可读的写法:
int num;
cout << "please input the number: " << flush;
cin >> num;
ofstream fout("log.txt");
fout << "immediate write 1\n" << flush;
sleep(1);
fout << "immediate write 2\n" << flush;
fout.close(); // 关闭文件时总是自动 flush,不会有残留未写入的字符
多线程中 cout 出现乱序?
同学:小彭老师,我在多线程环境中使用:
cout << "the answer is " << 42 << '\n';
发现输出乱套了!这是不是说明 cout 不是多线程安全的呢?
小彭老师:cout 是一个“同步流”,是多线程安全的,错误的是你的使用方式。
如果他不多线程安全,那多线程地调用他就不是输出乱序,而是程序崩溃了。
但是,cout 的线程安全,只能保证每一次 operator<<
都是原子的,每一次单独的 operator<<
不会被其他人打断。
但众所周知,cout 为了支持级联调用,他的 operator<<
都是返回自己的,上面的代码实际上等价于分别三次调用 cout
的 operator<<
。
cout << "the answer is " << 42 << '\n';
// 等价于:
cout << "the answer is ";
cout << 42;
cout << '\n';
变成了三次 operator<<
,每一次都是“各自”原子的,但三个原子加在一起就不是原子了。
而是分子了 :)
他们中间可能穿插了其他线程的 cout,从而导致你 "the answer is"
打印完后,被其他线程的 '\n'
插入进来,导致换行混乱。
std::cout
的operator<<
调用是线程安全的,不会被打断,但多个operator<<
的调用在多线程环境中可能会 交错 ,导致输出结果混乱。更多细节请看我们的 多线程专题。
解决方法是,先创建一个只属于当前线程的 ostringstream
,最后一次性调用一次 cout 的 operator<<
,让“原子”的单位变成“一行”而不是一个字符串。
ostringstream oss;
oss << "the answer is " << 42 << '\n';
cout << oss.str();
或者,使用 std::format
:
cout << std::format("the answer is {}\n", 42);
总之,就是要让 operator<<
只有一次,自然就是没有交错。
在 C++20 中,可以改用 std::osyncstream(std::cout)
代替 std::cout
:
std::osyncstream(std::cout) << "the answer is " << 42 << '\n';
std::osyncstream
可以保证:1. 不会产生数据竞争;2. 不会发生穿插和截断。可以理解为 std::osyncstream
在构造时对缓冲区上锁,在析构时解锁。
如果你的标准库支持 C++23,还可以用 std::println
,这个函数的输出也是原子的(第三方库如 fmt::println
亦可):
std::println("the answer is {}", 42);
cerr 与 cout 的抉择
如果你的目的是调试和报错,可以考虑用 cerr
!
他会在每次 <<
时刷新,cerr
才是最适合打印错误和调试信息的流。
cout
的优点是不需要时刻刷新,有更好的性能。
cout << "hello\n";
cout << "the answer is ";
cout << 42;
*(int *)1 = 1; // 崩溃!
cout << "!\n"; // 因为还没有抵达 \n 产生刷新就崩溃,导致之前尚未刷新的 the answer is 42 丢失
可能的输出:
hello[换行]
cerr << "hello\n";
cerr << "the answer is ";
cerr << 42;
*(int *)1 = 1; // 崩溃!
cerr << "!\n";
输出:
hello[换行]
the answer is 42
还有一个特点:cout
输出到“标准输出流”,可以被输出重定向到文件管道。而 cerr
输出到“标准错误流”,通常不会被重定向到文件或管道。
例如,可以把程序预订的计算结果写到 cout
,把调试和报错信息写到 cerr
,这样用户就可以通过 >
重定向计算结果,而调试和报错信息则正常输出到屏幕上,不受重定向影响。
cout << "1 3 5 7\n";
cerr << "ERROR: this is an error message!\n";
cout << "11 13 17 19\n";
$ g++ prime.cpp -o prime
$ ./prime
1 3 5 7
ERROR: this is an error message!
11 13 17 19
$ ./prime > output.txt
ERROR: this is an error message!
$ cat output.txt
1 3 5 7
11 13 17 19
智能指针防止大对象移动
我们说一个类型大,有两种情况。
- 类本身很大:例如 array
- 类本身不大,但其指向的对象大,且该类是深拷贝,对该类的拷贝会引起其指向对象的拷贝:例如 vector
sizeof(array<int, 1000>); // 本身 4000 字节
sizeof(vector<int>); // 本身 24 字节(成员是 3 个指针),指向的数组可以无限增大
sizeof(vector)
为 24 字节仅为x86_64-pc-linux-gnu
平台libstdc++
库的实测结果,在 32 位系统以及 MSVC 的 Debug 模式 STL 下可能得出不同的结果,不可以依赖这个平台相关的结果来编程。
对于 vector,我们可以使用 std::move
移动语义,只拷贝该类本身的三个指针成员,而不对其指向的 4000 字节数组进行深拷贝。
对于 array,则 std::move
移动语义与普通的拷贝没有区别:array 作为静态数组容器,不是通过“指针成员”来保存数组的,而是直接把数组存在他的体内,对 array 的移动和拷贝是完全一样的!
总之,移动语义的加速效果,只对采用了“指针间接存储动态数据”的类型(如 vector、map、set、string)有效。对“直接存储静态大小数据”的类型(array、tuple、variant、成功“小字符串优化”的 string)无效。
所以,让很多“移动语义”孝子失望了:“本身很大”的类,移动和拷贝一样慢!
那么现在我们有个超大的类:
using BigType = array<int, 1000>; // 4000 字节大小的平坦类型
vector<BigType> arr;
void func(BigType x) {
arr.push_back(std::move(x)); // 拷贝 4000 字节,超慢,move 也没用
}
int main() {
BigType x;
func(std::move(x)); // 拷贝 4000 字节,超慢,move 也没用
}
如何加速这种本身超大的变量转移?使用 const
引用:
void func(BigType const &x)
似乎可以避免传参时的拷贝,但是依然不能避免 push_back
推入 vector
时所不得已的拷贝。
小技巧:改用 unique_ptr<BigType>
using BigType = array<int, 1000>; // 4000 字节大小的平坦类型
using BigTypePtr = unique_ptr<BigType>;
vector<BigTypePtr> arr;
void func(BigTypePtr x) {
arr.push_back(std::move(x)); // 只拷贝 8 字节的指针,其指向的 4000 字节不用深拷贝了,直接移动所有权给 vector 里的 BigTypePtr 智能指针
// 由于移走了所有权,x 此时已经为 nullptr
}
int main() {
BigTypePtr x = make_unique<BigType>(); // 注意:用智能指针的话,需要用 make_unique 才能创建对象了
func(std::move(x)); // 只拷贝 8 字节的指针
// 由于移走了所有权,x 此时已经为 nullptr
}
上面整个程序中,一开始通过 make_unique
创建的超大对象,全程没有发生任何移动,避免了无谓的深拷贝。
对于不支持移动构造函数的类型来说,也可以用这个方法,就能在函数之间穿梭自如了。
// 热知识:std::mutex 不支持移动
void func(std::mutex lock);
int main() {
std::mutex lock;
func(std::move(lock)); // 错误:mutex(mutex &&) = delete
}
void func(std::unique_ptr<std::mutex> lock);
int main() {
std::unique_ptr<std::mutex> lock = std::make_unique<std::mutex>();
func(std::move(lock)); // OK:调用的是 unique_ptr(unique_ptr &&),不关 mutex 什么事
}
更好的是 shared_ptr
,连 std::move
都不用写,更省心。
void func(std::shared_ptr<std::mutex> lock);
int main() {
std::shared_ptr<std::mutex> lock = std::make_shared<std::mutex>();
func(lock); // OK:调用的是 shared_ptr(shared_ptr const &),不关 mutex 什么事
func(lock); // OK:shared_ptr 的拷贝构造函数是浅拷贝,即使浅拷贝发生多次,指向的对象也不会被拷贝或移动
}
optional 实现延迟初始化
假设我们有一个类,具有自定义的构造函数,且没有默认构造函数:
struct SomeClass {
int m_i;
int m_j;
SomeClass(int i, int j) : m_i(i), m_j(j) {}
};
当我们需要“延迟初始化”时怎么办?
SomeClass c;
if (test()) {
c = SomeClass(1, 2);
} else {
c = SomeClass(2, 3);
}
do_something(c);
可以利用 optional 默认初始化为“空”的特性,实现延迟赋值:
std::optional<SomeClass> c;
if (test()) {
c = SomeClass(1, 2);
} else {
c = SomeClass(2, 3);
}
do_something(c.value()); // 如果抵达此处前,c 没有初始化,就会报错,从而把编译期的未初始化转换为运行时异常
就类似于 Python 中先给变量赋值为 None,然后在循环或 if 里条件性地赋值一样。
如果要进一步避免 c =
时,移动构造的开销,也可以用 unique_ptr
或 shared_ptr
:
std::shared_ptr<SomeClass> c;
if (test()) {
c = std::make_shared<SomeClass>(1, 2);
} else {
c = std::make_shared<SomeClass>(2, 3);
}
do_something(c); // 如果抵达此处前,c 没有初始化,那么传入的就是一个 nullptr,do_something 内部需要负责检测指针是否为 nullptr
如果 do_something
参数需要的是原始指针,可以用 .get()
获取出来:
do_something(c.get()); // .get() 可以把智能指针转换回原始指针,但请注意原始指针不持有引用,不会延伸指向对象的生命周期
实际上,Java、Python 中的一切对象(除 int、str 等“钦定”的基础类型外)都是引用计数的智能指针
shared_ptr
,只不过因为一切皆指针了,所以看起来好像没有指针了。
if-auto 与 while-auto
需要先定义一个变量,然后判断某些条件的情况,非常常见:
extern std::optional<int> some_func();
auto opt = some_func();
if (opt.has_value()) {
std::cout << opt.value();
}
C++17 引入的 if-auto 语法,可以就地书写变量定义和判断条件:
extern std::optional<int> some_func();
if (auto opt = some_func(); opt.has_value()) {
std::cout << opt.value();
}
对于支持 (bool)opt
的 optional
类型来说,后面的条件也可以省略:
extern std::optional<int> some_func();
if (auto opt = some_func()) {
std::cout << opt.value();
}
// 等价于:
auto opt = some_func();
if (opt) {
std::cout << opt.value();
}
类似的还有 while-auto:
extern std::optional<int> some_func();
while (auto opt = some_func()) {
std::cout << opt.value();
}
// 等价于:
while (true) {
auto opt = some_func();
if (!opt) break;
std::cout << opt.value();
}
if-auto 最常见的配合莫过于 map.find:
std::map<int, int> table;
int key = 42;
if (auto it = table.find(key); it != table.end()) {
std::cout << it->second << '\n';
} else {
std::cout << "not found\n";
}
bind 是历史糟粕,应该由 Lambda 表达式取代
众所周知, std::bind
可以为函数绑定一部分参数,形成一个新的函数(对象)。
int func(int x, int y) {
printf("func(%d, %d)\n", x, y);
return x + y;
}
auto new_func = std::bind(func, 1, std::placeholders::_1);
new_func(2); // 调用 new_func(2) 时,实际上调用的是 func(1, 2)
}
输出:
func(1, 2)
当我们绑定出来的函数对象还需要接受参数时,就变得尤为复杂:需要使用占位符(placeholder)。
int func(int x, int y, int z, int &w);
int w = rand();
auto bound = std::bind(func, std::placeholders::_2, 1, std::placeholders::_1, std::ref(w)); //
int res = bound(5, 6); // 等价于 func(6, 1, 5, w);
这是一个绑定器,把 func
的第二个参数和第四个参数固定下来,形成一个新的函数对象,然后只需要传入前面两个参数就可以调用原来的函数了。
这是一个非常旧的技术,C++98 时代就有了。但是,现在有了 Lambda 表达式,可以更简洁地实现:
int func(int x, int y, int z, int &w);
int w = rand();
auto lambda = [&w](int x, int y) { return func(y, 1, x, w); };
int res = lambda(5, 6);
Lambda 表达式有许多优势:
- 简洁:不需要写一大堆看不懂的
std::placeholders::_1
,直接写变量名就可以了。 - 灵活:可以在 Lambda 中使用任意多的变量,调整顺序,而不仅仅是
std::placeholders::_1
。 - 易懂:写起来和普通函数调用一样,所有人都容易看懂。
- 捕获引用:
std::bind
不支持捕获引用,总是拷贝参数,必须配合std::ref
才能捕获到引用。而 Lambda 可以随意捕获不同类型的变量,按值([x]
)或按引用([&x]
),还可以移动捕获([x = move(x)]
),甚至捕获 this([this]
)。 - 夹带私货:可以在 lambda 体内很方便地夹带其他额外转换操作,比如:
auto lambda = [&w](int x, int y) { return func(y + 8, 1, x * x, ++w) * 2; };
bind 的历史
为什么 C++11 有了 Lambda 表达式,还要提出 std::bind
呢?
虽然 bind 和 lambda 看似都是在 C++11 引入的,实际上 bind 的提出远远早于 lambda。
标准委员会:我们不生产库,我们只是 boost 的搬运工。
当时还是 C++98,由于没有 lambda,难以创建函数对象,“捕获参数”非常困难。
为了解决“捕获难”问题,在第三方库 boost 中提出了 boost::bind
,由于当时只有 C++98,很多有益于函数式编程的特性都没有,所以实现的非常丑陋。
例如,因为 C++98 没有变长模板参数,无法实现 <class ...Args>
。所以实际上当时 boost 所有支持多参数的函数,实际上都是通过:
void some_func();
void some_func(int i1);
void some_func(int i1, int i2);
void some_func(int i1, int i2, int i3);
void some_func(int i1, int i2, int i3, int i4);
// ...
这样暴力重载几十个函数来实现的,而且参数数量有上限。通常会实现 0 到 20 个参数的重载,更多就不支持了。
例如,我们知道现在 bind 需要配合各种 std::placeholders::_1
使用,有没有想过这套丑陋的占位符是为什么?为什么不用 std::placeholder<1>
,这样不是更可扩展吗?
没错,当时 boost::bind
就是用暴力重载几十个参数数量不等的函数,排列组合,嗯是排出来的,所以我们会看到 boost::placeholders
只有有限个数的占位符数量。
糟糕的是,标准库的 std::bind
把 boost::bind
原封不动搬了过来,甚至 placeholders
的暴力组合也没有变,造成了 std::bind
如今丑陋的接口。
人家 boost::bind
是因为不能修改语言语法,才只能那样憋屈的啊?可现在你码是标准委员会啊,你可以修改语言语法啊?
然而,C++ 标准的更新是以“提案”的方式,逐步“增量”更新进入语言标准的。即使是在 C++98 到 C++11 这段时间内,内部也是有一个很长的消化流程的,也就是说有很多子版本,只是对外看起来好像只有一个 C++11。
比方说,我 2001 年提出 std::bind
提案,2005 年被批准进入未来将要发布的 C++11 标准。然后又一个人在 2006 年提出其实不需要 bind,完全可以用更好的 lambda 语法来代替 bind,然后等到了 2008 年才批准进入即将发布的 C++11 标准。但是已经进入标准的东西就不会再退出了,哪怕还没有发布。就这样 bind 和 lambda 同时进入了标准。
所以闹了半天,lambda 实际上是 bind 的上位替代,有了 lambda 根本不需要 bind 的。只不过是由于 C++ 委员会前后扯皮的“制度优势”,导致 bind 和他的上位替代 lambda 同时进入了 C++11 标准一起发布。
这下看懂了。
很多同学就不理解,小彭老师说“lambda 是 bind 的上位替代”,他就质疑“可他们不都是 C++11 提出的吗?”
有没有一种可能,C++11 和 C++98 之间为什么年代差了那么久远,就是因为一个标准一拖再拖,内部实际上已经迭代了好几个小版本了,才发布出来。
再举个例子,CTAD 和
optional
都是 C++17 引入的,为什么还要make_optional
这个帮手函数?不是说 CTAD 是make_xxx
的上位替代吗?可见,C++ 标准中这种“同一个版本内”自己打自己耳光的现象比比皆是。所以,现在还坚持用 bind 的,都是些 2005 年前后在象牙塔接受 C++ 教育,但又不肯“终身学习”的劳保。这批劳保又去“上岸”当“教师”,继续复制 2005 年的错误毒害青少年,实现了劳保的再生产。
thread 膝盖中箭
糟糕的是,bind 的这种荼毒,甚至影响到了线程库:std::thread
的构造函数就是基于 std::bind
的!
这导致了 std::thread
和 std::bind
一样,无法捕获引用。
void thread_func(int &x) {
x = 42;
}
int x = 0;
std::thread t(thread_func, x);
t.join();
printf("%d\n", x); // 0
为了避免踩到 bind 的坑,我建议所有同学,构造 std::thread
时,统一只指定“单个参数”,也就是函数本身。如果需要捕获参数,请使用 lambda。因为 lambda 中,捕获了哪些变量,参数的顺序是什么,哪些捕获是引用,哪些捕获是拷贝,非常清晰。
void thread_func(int &x) {
x = 42;
}
int x = 0;
std::thread t([&x] { // [&x] 表示按引用捕获 x;如果写作 [x],那就是拷贝捕获
thread_func(x);
});
t.join();
printf("%d\n", x); // 42
举个绑定随机数生成器例子
bind 写法:
std::mt19937 gen(seed);
std::uniform_real_distribution<double> uni(0, 1);
auto frand = std::bind(uni, std::ref(gen));
double x = frand();
double y = frand();
改用 lambda:
std::mt19937 gen(seed);
std::uniform_real_distribution<double> uni(0, 1);
auto frand = [uni, &gen] {
return uni(gen);
};
double x = frand();
double y = frand();
forward 迷惑性地不好用,建议改用 FWD 宏
众所周知,当你在转发一个“万能引用”参数时:
template <class Arg>
void some_func(Arg &&arg) {
other_func(arg);
}
如果此处 arg
传入的是右值引用,那么传入 other_func
就会变回左值引用了,不符合完美转发的要求。
因此引入了 forward
,他会检测 arg
是否为“右值”:如果是,则 forward
等价于 move
;如果不是,则 forward
什么都不做(默认就是左值引用)。
这弄得 forward
的外观非常具有迷惑性,又是尖括号又是圆括号的。
template <class Arg>
void some_func(Arg &&arg) {
other_func(std::forward<Arg>(arg));
}
实际上,forward 的用法非常单一:永远是 forward<T>(t)
的形式,其中 T
是 t
变量的类型。
又是劳保的魅力,利用同样是 C++11 的 decltype
就能获得 t
定义时的 T
。
void some_func(auto &&arg) {
other_func(std::forward<decltype(arg)>(arg));
}
所以 std::forward<decltype(arg)>(arg)
实际才是 forward
的正确用法,只不过因为大多数时候你是模板参数 Arg &&
,有的人偷懒,就把 decltype(arg)
替换成已经匹配好的模板参数 Arg
了,实际上是等价的。
这里需要复读 arg
太纱币了。实际上,我们可以定义一个宏:
#define FWD(arg) std::forward<decltype(arg)>(arg)
这样就可以简化为:
void some_func(auto &&arg) {
other_func(FWD(arg));
}
少了烦人的尖括号,看起来容易懂多了。
但是,我们同学有一个问题,为什么
std::forward
要写成std::forward<T>
的形式呢?为什么不是std::forward(t)
呢?因为这样写的话,forward
也没法知道你的t
是左是右了(函数参数始终会默认推导为左,即使定义的t
是右)因此必须告诉forward
,t
的定义类型,也就是T
,或者通过decltype(t)
来获得T
。
总之,如果你用的是 auto &&
参数,那么 FWD
会很方便(自动帮你 decltype
)。但是如果你用的是模板参数 T &&
,那么 FWD
也可以用,因为 decltype(t)
总是得到 T
。
bind 绑定成员函数是陋习,改用 lambda 或 bind_front
使用“成员函数指针”语法(这一奇葩语法在 C++98 就有)配合 std::bind
,可以实现绑定一个类型的成员函数:
struct Class {
void world() {
puts("world!");
}
void hello() {
auto memfn = std::bind(&Class::world, this); // 将 this->world 绑定成一个可以延后调用的函数对象
memfn();
memfn();
}
}
不就是捕获 this 吗?我们 lambda 也可以轻易做到!且无需繁琐地写出 this 类的完整类名,还写个脑瘫 &::
强碱你的键盘。
struct Class {
void world() {
puts("world!");
}
void hello() {
auto memfn = [this] {
world(); // 等价于 this->world()
};
memfn();
memfn();
}
}
bind 的缺点是,当我们的成员函数含有多个参数时,bind 就非常麻烦了:需要一个个写出 placeholder,而且数量必须和 world
的参数数量一致。每次 world
要新增参数时,所有 bind 的地方都需要加一下 placeholder,非常沙雕。
struct Class {
void world(int x, int y) {
printf("world(%d, %d)\n");
}
void hello() {
auto memfn = std::bind(&Class::world, this, std::placeholders::_1, std::placeholders::_2);
memfn(1, 2);
memfn(3, 4);
}
}
而且,如果有要绑定的目标函数有多个参数数量不同的重载,那 bind 就完全不能工作了!
struct Class {
void world(int x, int y) {
printf("world(%d, %d)\n");
}
void world(double x) {
printf("world(%d)\n");
}
void hello() {
auto memfn = std::bind(&Class::world, this, std::placeholders::_1, std::placeholders::_2);
memfn(1, 2);
memfn(3.14); // 编译出错!死扣占位符的 bind 必须要求两个参数,即使 world 明明有单参数的重载
auto memfn_1arg = std::bind(&Class::world, this, std::placeholders::_1);
memfn_1arg(3.14); // 必须重新绑定一个“单参数版”才 OK
}
}
而 C++14 起 lambda 支持了变长参数,就不用这么死板:
struct Class {
void world(int x, int y) {
printf("world(%d, %d)\n");
}
void world(double x) {
printf("world(%d)\n");
}
void hello() {
auto memfn = [this] (auto ...args) { // 让 lambda 接受任意参数
world(args...); // 拷贝转发所有参数给 world
};
memfn(1, 2); // 双参数:OK
memfn(3.14); // 单参数:OK
}
}
更好的是配合上文提到的 FWD
宏实现参数的完美转发:
struct Class {
void world(int &x, int &&y) {
printf("world(%d, %d)\n");
++x;
}
void world(double const &x) {
printf("world(%d)\n");
}
void hello() {
auto memfn = [this] (auto &&...args) { // 让 lambda 接受万能引用做参数
world(FWD(args)...); // 通过 FWD 完美转发给 world,避免引用退化
};
int x = 1;
memfn(x, 2); // 双参数:OK
memfn(3.14); // 单参数:OK
}
}
同样可以定义一个称手的宏:
#define BIND(func, ...) [__VA_ARGS__] (auto &&..._args) { func(FWD(_args)...); }
这里使用了宏参数包,此处
__VA_ARGS__
就是宏的...
中的内容。注意区分宏的...
和 C++ 变长模板的...
是互相独立的。
struct Class {
void world(int &x, int &&y) {
printf("world(%d, %d)\n");
++x;
}
void world(double const &x) {
printf("world(%d)\n");
}
void hello() {
auto memfn = BIND(world, this);
int x = 1;
memfn(x, 2);
memfn(3.14);
}
}
int main() {
// 捕获非 this 的成员函数也 OK:
Class c;
auto memfn = BIND(c.world, &c); // [&c] 按引用捕获 c 变量
// 展开为:
auto memfn = [&c] (auto &&..._args) { c.world(std::forward<decltype(_args)>(_args)...); }
memfn(3.14);
}
BIND
这个名字是随便取的,取这个名字是为了辱std::bind
。
为了解决 bind 不能捕获多参数重载的情况,C++17 还引入了 std::bind_front
和 std::bind_back
,他们不需要 placeholder,但只能用于要绑定的参数在最前或者最后的特殊情况。
其中 std::bind_front
对于我们只需要把第一个参数绑定为 this
,其他参数如数转发的场景,简直是雪中送炭!
struct Class {
void world(int x, int y) {
printf("world(%d, %d)\n");
}
void world(double x) {
printf("world(%d)\n");
}
void hello() {
auto memfn = std::bind_front(&Class::world, this);
memfn(1, 2);
memfn(3.14); // OK!
}
}
auto memfn = std::bind_front(&Class::world, this); // C++17 的 bind 孝子补救措施
auto memfn = BIND(world, this); // 小彭老师的 BIND 宏,C++14 起可用
你更喜欢哪一种呢?
救命!为什么我的全局函数不能作为函数对象?
当你的全局函数是模板函数,或带有重载的函数时:
template <class T>
T square(T const t) {
return t * t;
}
template <class Fn>
void do_something(Fn &&fn) {
fn(2);
fn(3.14);
}
int main() {
do_something(square); // 编译错误:有歧义的重载
}
就会出现这样恼人的编译错误:
test.cpp: In instantiation of 'void do_something(Fn&&) [with Fn = T (*)(T) [with T = double]]':
test.cpp:18:21: required from here
test.cpp:14:9: error: no matching function for call to 'do_something(<unresolved overloaded function type>)'
do_something(square);
^~~~~~~~~~~~~
test.cpp:7:3: note: candidate: 'template<class Fn> void do_something(Fn&&) [with Fn = T (*)(T) [with T = double]]'
void do_something(Fn &&fn) {
^~~~~~~~~~~~~
test.cpp:7:3: note: template argument deduction/substitution failed:
test.cpp:14:21: note: couldn't deduce template parameter 'Fn'
do_something(square);
~~~~~~~~~~~~~^~~~~~
这是因为,模板函数和有重载的函数,是“多个函数对象”的“幻想联合体”,而
do_something
的Fn
需要“单个”具体的函数对象。一般来说是需要
square<int>
和square<double>
才能变成“具体”的“单个”函数对象,传入do_something
的Fn
模板参数。但是在“函数调用”的语境下,因为已知参数的类型,得益于 C++ 的“重载”机制,带有模板参数的函数,可以自动匹配那个模板参数为你参数的类型。
但现在你并没有指定调用参数,而只是指定了一个函数名
square
,那 C++ “重载”机制无法确定你需要的是square<int>
还是square<double>
中的哪一个函数指针,他们的类型都不同,就无法具象花出一个函数对象类型Fn
来,导致<unresolved overloaded function type>
错误。
有趣的是,只需要套一层 lambda 就能解决:
do_something([] (auto x) { return square(x); }); // 编译通过
或者用我们上面推荐的 BIND
宏:
#define FWD(arg) std::forward<decltype(arg)>(arg)
#define BIND(func, ...) [__VA_ARGS__] (auto &&..._args) { func(FWD(_args)...); }
do_something(BIND(square)); // 编译通过
有时候,如果你想传递 this
的成员函数为函数对象,也会出现这种恼人的错误:
struct Class {
int func(int x) {
return x + 1;
}
void test() {
do_something(this->func); // 这里又会产生烦人的 unresolved overload 错误!
}
};
同样可以包一层 lambda,或者用小彭老师提供的 BIND
宏,麻痹的编译器就不狗叫了:
#define FWD(arg) std::forward<decltype(arg)>(arg)
#define BIND(func, ...) [__VA_ARGS__] (auto &&..._args) { func(FWD(_args)...); }
void test() {
do_something(BIND(func, this)); // 搞定
}
建议修改标准库,把小彭老师这两个真正好用的宏塞到
<utility>
和<functional>
里,作为 C++26 标准的一部分。
map + any 外挂属性
TODO
自定义 shared_ptr 的 deleter
CHECK_CUDA 类错误检测宏
函数默认参数求值的位置是调用者
设置 locale 为 .utf8
花括号实现安全的类型转换检查
成员函数针对 this 的移动重载
CHECK_CUDA 类错误检测宏
函数默认参数求值的位置是调用者
花括号实现安全的类型转换检查
临时右值转左值
C++ 有个特性:支持纯右值(prvalue)隐式转换成 const 的左值引用。
翻译:int &&
可以自动转换成 int const &
。
void func(int const &i);
func(1); // OK:自动创建一个变量保存 1,然后作为 int const & 参数传入
实际上就等价于:
const int tmp = 1;
func(tmp);
但是,int &&
却不能自动转换成 int &
。
void func(int &i);
func(1); // 错误:无法从 int && 自动转换成 int &
C++ 官方设置这个限制,是出于语义安全性考虑,因为参数接受
int &
的,一般都意味着这个是用作返回值,而如果func
的参数是,func(1)
。
为了绕开这个规则,我们可以定义一个帮手函数:
T &temporary(T const &t) {
return const_cast<T &>(t);
}
// 或者:
T &temporary(T &&t) {
return const_cast<T &>(t);
}
然后,就可以快乐地转换纯右值为非 const 左值了:
void func(int &i);
func(temporary(1));
在 Libreoffice 源码中就有应用这个帮手函数。
临时变量的生命周期是一行
ostringstream 格式化字符串
std::string name = "你好";
int answer = 42;
auto str = std::format("你好,{}!答案是 {},十六进制:0x{:02x}\n", name, answer, answer);
没有 C++20 之前,要么使用第三方的 fmt::format
,要么只能使用字符串的 +
运算符拙劣地拼接:
auto str = std::string("你好,") + name + "!答案是 " + std::to_string(answer) + ",十六进制:0x" + std::to_string(answer) + "\n";
这样做效率低下,且不易阅读。而且也无法实现数字按“十六进制”转字符串。
可以用 std::ostringstream
,其用法与 std::cout
相同。只不过会把结果写入一个字符串(而不是直接输出),可以用 .str()
取出那个字符串。
#include <sstream>
std::ostringstream oss;
oss << "你好," << name << "!答案是 " << answer << ",十六进制:0x" << std::hex << std::setfill('0') << std::setw(2) << answer << "\n";
auto str = oss.str();
利用临时变量语法,可以浓缩写在一行里,做个 format 拙劣的模仿者:
auto str = (std::ostringstream() << "你好," << name << "!答案是 " << answer << ",十六进制:0x" << std::hex << std::setfill('0') << std::setw(2) << answer << "\n").str();
ADL 机制实现静态多态
TODO
shared_from_this
requires 语法检测是否存在指定成员函数
设置 locale 为 .utf8 解决 Windows 编码难问题
system("chcp 65001");
setlocale("LC_ALL", ".utf-8");
详见 Unicode 专题章节。
成员函数针对 this 的移动重载
位域(bit-field)
在互联网编程和各种与硬盘、序列化打交道的场景中,常常需要按位拆分单个字节。
C 语言有专门照顾此类工作的语法糖:位域。
位域是一种特殊的结构体成员,可以对位进行分组,方便读取。例如,我们想要从一个字节中读取三个状态位:
struct Flag {
uint8_t a : 4; // 低 4 位
uint8_t b : 4; // 高 4 位
};
sizeof(Flag); // 1 字节大小(共 8 位)
Flag f = std::bit_cast<Flag>(0x21);
f.a; // 0x1
f.b; // 0x2
以上的代码等价于:
uint8_t f = 0x21;
int a = f & 0xF; // 0x1
int b = f >> 4; // 0x2
vector + unordered_map = LRU cache
Lambda 捕获 unique_ptr 导致 function 报错怎么办
多线程通信应基于队列,而不是共享全局变量
RAII 的 finally 帮手类
swap 缩小 mutex 区间代价
namespace 别名
有些嵌套很深的名字空间每次都要复读非常啰嗦。
#include <filesystem>
int main() {
std::filesystem::path p = "/var/www/html";
...
}
如果 using namespace
的话,又觉得污染全局名字空间了。
#include <filesystem>
using namespace std::filesystem;
int main() {
std::filesystem::path p = "/var/www/html";
...
}
可以用 C++11 的 namespace =
语法,给名字空间取个别名。
#include <filesystem>
namespace fs = std::filesystem;
int main() {
fs::path p = "/var/www/html";
...
}
这样以后就可以 fs
这个简称访问了。