C++17
$17$ 是 C ++ 编程语言的 $2017$ 版本标准,它在 C++$11$ 和 C++$14$ 的基础上引入了许多新特性和改进,旨在简化语言,提升性能,并增强开发者的生产力。
构造函数模板推导
模板类的构造函数在创建对象时能够自动推导出类型,无需显式指定模板参数。这与模板函数的推导类似,但应用于类的构造函数。
- 下面有一个类
template<class T>
struct A {
A(T x) {}
};
- 在 $17$ 以前,构造类模板实例时必须显示指定类的模板参数的类型
A<int> a(1);
- 在 $17$ 可以省略,和函数模板一样自动推导模板参数类型
A a(1);
- 编译器能够根据传给 构造函数的参数类型 推导出类模板的类型参数。这就是构造函数模板推导.
- 注意事项
- 使用构造函数模板推导,要满足 所有的模板类型都能被构造函数参数推导 。
- 只有一个构造函数:构造函数模板推导仅适用于类中存在一个构造函数,且该构造函数符合推导规则。
- 如果类中有多个构造函数,且它们的参数列表无法唯一地推导出模板参数,编译器将会报错。
- 使用场景,例如使用
vector
动态创建2
维或多维dp
数组时
// 二维
std::vector dp(n, std::vector<int>(m));
// 三维
std::vector ndp(n, std::vector(m, std::vector<int>(k)));
结构化绑定
C++$17$ 引入的结构化绑定是一项非常强大的特性,它使得从多个值中解构并分配到多个变量变得更加简洁和清晰。通过结构化绑定,您可以直接将一个复杂的数据结构(如元组、对、数组等)解包到多个变量中,从而避免了手动逐个访问每个元素的繁琐。
- 结构化绑定允许将一个结构化的类型
(如std::tuple、std::pair、std::array等)
解包成单独的变量,并同时获得这些元素。它使得类似的操作更加简洁和直观
std::tuple<int, std::string, double> t = std::make_tuple(1, "234", 5.6);
- 在 $17$ 以前,只能使用
std::get<index>
或者成员操作符.
来访问成员
std::cout << std::get<1>(t) << "\n"; // 234
- 现在使用结构化绑定简化这个过程
auto [a, b, c] = t;
std::cout << a << ' ' << b << ' ' << c << "\n";
- 结构化绑定不仅能解包值,还可以通过引用或者常量引用来避免不必要的拷贝,提高性能。
auto &[a, b, c] = t;
const auto &[a, b, c] = t;
- 注意事项
- 非公有成员无法绑定:如果尝试对一个结构体进行结构化绑定,那么这个结构体的所有成员都必须是公有的。对于包含私有或受保护成员的结构体,结构化绑定将无法编译通过
- 无法直接应用于动态数组:结构化绑定不能直接应用于动态分配的数组,或是它们的指针。它主要应用于静态分配的数组、
STL
容器(例如std::array
),以及其他可被分解为 固定数量 元素的类型 - 无法用于类型转换:结构化绑定不支持任何形式的类型转换。即,不允许在绑定的同时进行类型强制转换
$if、switch$ 语句初始化
C++$17$ 引入了一项非常有用的特性,它允许在 $if$ 或 $switch$ 语句中进行初始化。之前,在 $if$ 或 $switch$ 语句中只能进行条件判断,而无法在判断时同时初始化变量,这在某些情况下会导致代码重复或不简洁。C++$17$ 通过允许在 $if$ 和 $switch$ 语句的条件中进行初始化,极大地提高了代码的简洁性和可读性。
- 语法
if (initialization; condition) {
// 使用初始化的变量
}
initialization:在 if 或 switch 中进行的变量初始化,可以是任意表达式。
condition:原本的条件表达式,它通常是一个布尔值
- 下面代码
auto t1 = solve(x);
if (t1.get()) { // 使用t1 }
auto t2 = solve(y);
if (t2.get()) { //使用t2 }
...
- $t1$ 和 $t2$ 创建出来后,只有 $t$ 满足条件才会被使用。继续存在只会污染作用域,在 $if$ 中使用完后就可以被释放
if (auto t1 = solve(); t1.get()) { }
if (auto t2 = solve(); t2.get()) { }
...
- $switch$ 也是同样
- 单一语句限制:在$if$或$switch$的初始化部分只能包含一条语句。如果需要执行多条语句,必须将它们封装到一个作用域块中,或者使用逗号运算符来连接
内联变量
在 C++ 中,通常当你在头文件中声明并定义一个变量时,可能会遇到一个问题:多个源文件包含了同一个头文件,这样会导致链接器报错,因为在多个源文件中对同一个变量的定义可能导致重复定义。$17$ 引入了 $inline$ 变量,使得你可以在头文件中定义变量,而链接器会处理不同翻译单元中该变量的合并问题,避免重复定义错误
- 语法
inline <type> <variable_name> = <initial_value>;
inline
变量通常用于定义常量,特别是当常量值在多个翻译单元中共享时
// header.h
#pragma once
inline constexpr int MAX = 1E8;
// main.cpp
#include <bits/stdc++.h>
#include "header.h"
int main() {
std::cout << MAX << "\n";
}
- 你可以在头文件中安全地定义全局变量或静态成员变量,编译器会保证在整个程序中这个变量只有一个实例。即使头文件被多个源文件包含,也不会产生链接错误
inline int N = 100;
class Max {
public:
static inline int N = 1E8;
};
- 内联变量的一个常见用途是定义类模板的静态数据成员。在$17$之前,这通常需要特别的定义在一个源文件中,而现在可以直接在类定义中内联定义它们,这样每个模板实例化都会得到正确的链接。
template<class T>
class Max {
public:
static inline T N = 1E8;
};
折叠表达式
用于对一个参数包(通常是变长模板参数)进行归约操作。它让开发者能够以简洁和直观的方式对变长参数进行求值、计算或聚合,极大地简化了模板编程,尤其是在需要对多个参数进行某种操作时。
- 折叠表达式通过 折叠运算符来实现,主要有两种类型:
- 一元折叠:对单一的参数包进行折叠。
- 二元折叠:对多个参数进行折叠,通常是一个递归运算。
- 基本语法
// 一元折叠
(... op expr)
// 二元折叠
(init op ... op expr)
...:展开操作符,用来展开变长模板参数
op:二元运算符,通常是加法、乘法、逻辑运算符等。
expr:对参数包中的每一个参数操作的表达式
args: 变成模板参数包
init:在进行折叠时,初始值充当起点,并与参数包中的每个元素进行计算
- 一元折叠 用于处理参数包的每个元素,通常会对每个元素进行某种操作
template<class...Args> // 定义一个模板类型参数包(多个模板类型打包在一起, class T, class U, class F ....)
int sum(Args...args) { // 通过模板类型定义一个函数参数包(多个形参)。如果不传递模板参数,可以通过args推导Args的类型
return (... + args); // 一元折叠,对参数包进行加法
// return (0 + ... + args) // 二元折叠
}
int main() {
std::cout << sum(1, 2, 3, 4); // 返回10,计算过程 1 + (2 + (3 + 4))
}
- 从左到右折叠依次与参数包中的每个元素应用运算符
init op ... op args
在加法折叠 (0 + ... + args) 中,首先将初始值 0 与第一个参数相加,然后将结果与下一个参数继续相加,依此类推
- 从右到左折叠依次与参数包中的每个元素应用运算符
args op ... op init
在加法折叠 (args + ... + 0) 中,折叠顺序是从右到左,即首先将最后一个参数与 0 相加,然后继续折叠其他参数
$constexpr\ lambda$ 表达式
回顾 $17$ 之前, 只要被 $constexpr$ 修饰的表达式或者函数是基于常量的,就可以在编译器计算出表达式的结果。$17$ 将其与 $lambda$ 相结合,意味允许你在编译时对 $lambda$ 表达式进行求值,只要它满足 $constexpr$ 的约束条件。
- 语法
auto lambda = []()constexpr {};
- 注意
- 有常量表达式才能用于计算
- 不能使用动态内存分配(如
new
或malloc
),不能有throw
或其他可能导致运行时计算的操作 - 不允许有副作用:
constexpr
函数不应包含改变函数外部状态的副作用,例如修改非局部变量、进行输入输出操作或调用非constexpr
函数等 - 不允许捕获外部 非基于常量的变量
$namespace$ 嵌套
嵌套命名空间定义,允许开发者以更简洁的方式定义嵌套命名空间。这项特性通过减少代码冗余,提高了代码的可读性和书写效率。
- 在 $17$ 以前,旧的嵌套命名空间书写方式
namespace A {
namespace B {
namespace C{
}
}
}
- $17$ 支持
namespace A {}
namespace A::B{}
namespace A::B::C{}
- 不允许局部嵌套命名空间:嵌套命名空间的定义语法不能在函数内部或任何局部作用域内部使用。它们只能在全局作用域或其他命名空间内使用。
- 不支持命名空间别名定义中使用:不能在命名空间别名的定义中使用嵌套命名空间的语法。例如,$namespace XYZ = A::B::C$; 是有效的,但不能在这种别名定义中进一步引入嵌套
$\_\_has\_include$ 表达式
预处理表达式
__has_include
,这是一个条件编译特性,用于检查编译环境中是否存在指定的头文件或模块。
- 它是一个非常有用的工具,可以在编译时检查某个头文件是否存在,从而帮助编写更加兼容的代码,特别是当某些库或功能在不同的编译器或平台上可用性不一致时
#if __has_include(<header>) / "header"
// 头文件存在时的代码
#else
// 头文件不存在时的代码
#endif
在 $lambda$ 表达式用 $*this$ 捕获对象副本
这一改进增加了 $lambda$ 表达式的灵活性,并提供了一种更加直观和安全的方式来使用当前对象的成员变量和成员函数,特别是在异步编程和多线程环境下
- 避免副作用:捕获副本而不是引用,可以避免修改
lambda
外部的对象,尤其是在多线程环境中,可以避免数据竞争。 - 持久化状态:在某些情况下,
lambda
需要保存对象的某个状态,而不依赖于原始对象的生命周期。在这种情况下,捕获*this
是非常有用的,尤其是当你希望lambda
在执行期间不依赖外部对象的变化时 - 简化异步编程和多线程使用:在需长时间运行或在不确定原始对象生命周期的环境中,使用
*this
捕获确保了操作的对象在lambda
执行期间始终有效,大大降低了编程的复杂性和出错率。
class Task {
public:
void worke() {
std::thread([*this]() {});
}
};
void test() {
Task task;
}
这样就不会因为task对象生命周期结束,而导致线程中的task对象失效
新增 $Attribute$
属性是被方括号
[[ ]]
包围的注解,可以应用于代码中几乎任何地方,包括类型声明、语句、表达式等,用于提供关于代码行为的额外信息给编译器。在 $11$ 和 $14$ 中已经引入了部分属性,$17$ 则进一步扩展了这个概念
[[nodiscard]]
属性可以用来指示函数的返回值不应该被忽略。当编译器检测到一个被标记为[[nodiscard]]
的函数的返回值没有被使用时,它将发出警告。这对于那些执行重要任务,其返回值表明操作成功与否的函数特别有用,确保了程序逻辑的正确性
[[nodiscard]]
int sum(int x, int y) {
return x + y;
}
int main() {
sum(1, 2); // 警告
}
[[maybe_unused]]
在某些情况下,变量、函数、类型等可能声明了但未被使用,这通常会引发编译器警告。通过使用[[maybe_unused]]
属性,可以告诉编译器该实体可能不被使用,从而抑制出现相关警告[[fallthrough]]
在switch
语句中,一般认为每个case
后都应该跟随一个break
来阻止代码继续向下“跌落”。如果某个case
故意设计为需要“跌落”到下一个case
,[[fallthrough]]
属性就可以用来消除编译器针对这种有意为之的情况所发出的警告。[[noreturn]]
属性指示函数不会通过正常返回来返回到调用者。这主要用于那些通过抛出异常或终止程序来“返回”的函数。- 运行时影响为零:这些属性是在编译时处理的,它们不会改变程序的运行时行为。需要注意的是,虽然它们可以帮助改进代码的质量和避免某些类型的错误,但它们并不替代良好的编程实践和代码审查
字符串转换
在C++$17$中,标准库引入了 $std::to_chars$ 和 $std::from\_chars$ 函数,它们提供了比 $std::to\_string$ 和 $std::stoi$ 等函数更高效的数字转换功能。这两个函数的主要特点是:
无堆分配:它们直接在给定的字符缓冲区中操作,不会进行内存分配。
更高效:相较于传统的 $std::to\_string$ 和 $std::stoi$,这两个函数通常速度更快,因为它们避免了字符串创建和内存管理的开销。
- $std::to\_chars$ 将一个数值转换为字符数组(C风格字符串)
std::array<char, 10> str;
int value = 1234;
auto [ptr, ec] = std::to_chars(str.begin(), str.end(), value);
if (ec == std::errc()) {
std::string res(str.begin(), ptr);
std::cout << res << "\n";
}
- $std::from\_chars$ 将一个C风格字符串转换为一个数值
const char *str = "12345";
int value = 0;
auto [ptr, ec] = std::from_chars(str, str + std::strlen(str), value);
if (ec == std::errc()) {
std::cout << value << "\n";
}
$std::variant$
C++$17$ 引入了一个非常有用的特性 $std::variant$,它是一个类型安全的联合体$(union)$,可以容纳不同类型的数据,但与传统的 $union$ 类型不同,$std::variant$ 可以确保类型的安全性和提供更易于使用的接口。但是在任何时候它只会存储一个类型的值。
- 定义时需要提供一组类型,这些类型是可以存储的。比如,可以将整数、字符和浮动类型作为类型的选择
std::variant<int, double> v = 10;
- 访问 使用
std::get<T>(v)
来获取variant
存储的当前类型的值,如果类型不匹配,会抛出std::bad_variant_access
异常
std::cout << std::get<int>(v) << "\n";
std::visit
是C++17
中引入的一个非常强大的功能,可以用来访问variant
中存储的值。std::visit
会根据存储的实际类型,调用一个合适的函数
struct F {
void operator()(int x) const {
std::cout << x << "\n";
}
void operator()(double x) const {
std::cout << x << "\n";
}
};
F f;
std::visit(f, v);
std::visit 需要一个可调用对象(例如一个函数对象或 Lambda 函数),并且它会根据 variant 当前存储的类型调用合适的重载
- 可以使用
std::holds_alternative<T>(v)
来检查variant
是否当前存储了某个类型的值
if (std::holds_alternative<double>(v) {}
- 替代
union
类型:当你需要存储多种不同类型的数据,但不希望用传统的union
类型时,std::variant
是一个更安全的选择。 - 类型安全的多态处理:可以结合
std::visit
使用,进行类型安全的多态操作,避免传统的if-else
或者switch
语句来判断类型。 - 表达不同结果的类型:例如,
std::variant
可以用来表示函数可能的多种返回值,或者存储成功/失败的不同状态(比如std::error_code
和结果)。
-
$std::make\_from\_tuple$
实用特性,允许你从一个元组(
tuple
)中直接构造对象。这种能力特别有用于场景,其中对象的构造函数参数被存储或转发为一个元组,而需要从这个元组中构造对象。 -
基本用法
struct Data {
Data(int v, std::string s) : v(v), s(s) {}
int v;
std::string s;
};
std::tuple t(1, "234");
auto d = std::move(std::make_from_tuple<Data>(t));
std::cout << d.v << ' ' << d.s << "\n";
std::make_from_tuple
试图使用元组中的值来构造一个对象,对象的构造函数的参数类型和数量必须与元组中的元素相匹配。如果没有匹配的构造函数,代码将无法编译。这要求构造函数必须可以接受元组中提供的所有参数。- 每个元组中的元素类型必须与目标对象构造函数中对应参数的类型精确匹配,或者至少能够隐式转换。如果类型间不兼容,导致不能隐式转换,那么构造过程将失败。
- 处理引用和指针时需要小心。因为
std::make_from_tuple
直接按值传递元组内的元素到构造函数,如果目标构造函数期望一个引用或指针作为参数,那么元组中相应的元素也必须是引用或指针,否则可能不会按预期工作。 - 如果目标对象的构造函数需要的是一个非拷贝和非移动类型的参数,
std::make_from_tuple
可能无法正常使用。由于元组内的元素在传递给构造函数时本质上是被拷贝或移动的,如果你的类型不能拷贝或移动,那么将不能使用std::make_from_tuple
$std::string\_view$ 更加高效的字符串视图
C++$17$新引入的一个类型,它提供了一种对现有字符串的轻量级、非拥有($non-owning$)视图,不需要复制字符串数据。它非常适合用于不改变字符串内容的情况下传递字符串数据,从而避免不必要的内存分配和复制
- 无需拷贝:使用
std::string_view
可以避免在字符串操作中进行不必要的拷贝。例如,提取子字符串或传递字符串到函数中时,不需要创建字符串的副本。 - 非拥有性:
std::string_view
只是提供对字符串(或任何字符序列)的视图,它不拥有字符串的数据,因此不负责管理数据的生命周期。 - 灵活性:它能够以统一的方式访问不同类型的字符串或字符数组,包括
std::string
、字符串字面量、字符数组等。 - 高效的字符串操作:提供了一系列用于字符串处理的方法,如
substr
、find
、compare
等,这些操作通常比std::string
更高效,因为它们不需要创建字符串副本。
void f(std::string_view str) {
std::cout << str.substr(0, 2) << "\n";
}
int main() {
std::string s1 = "123";
f(s1);
f("123");
char s2[] = "123";
f(s2);
}
$std::optional$
C++17 引入了
std::optional
类型,这是一个非常有用的标准库类型,用于表示一个值 可能存在 或 可能不存在。它是 C++ 标准库中引入的一种类型,可以用于处理一些情况,其中一个函数可能没有有效的返回值,或者某个值可以缺失(比如在处理用户输入、数据库查询或计算过程中)。
std::optional
可以认为是一个容器,能够存储一个值(如果存在)或者不存储任何值(为空)。它能避免一些常见的编程问题,比如使用指针来表示可能不存在的值,或者通过返回特殊值(比如nullptr
或负数)来表示错误或空值。
- 定义在
<optional>
头文件中 - 声明和初始化:
std::optional
可以用来声明一个可选的变量,初始化时可以选择给它赋值,也可以不赋值(表示它为空)。
std::optional<int> opt1, opt2 = 1;
- 检查是否有值:可以通过
has_value()
或operator bool()
来检查std::optional
是否包含一个有效值。
if (opt) { // 等价于opt.has_value()
} else {
}
- 访问值
- 你可以使用解引用操作符 * 来获取
std::optional
中存储的值。 - 如果
std::optional
为空,解引用会导致未定义行为,因此必须首先检查它是否有值 - 另一种访问值的方法是使用
value()
,如果optional
为空,则会抛出std::bad_optional_access
异常。
- 你可以使用解引用操作符 * 来获取
if (opt.has_value() {
std::cout << *opt << "\n";
}
- 提供默认值:如果你希望在
optional
为空时提供一个默认值,可以使用value_or()
方法。
std::cout << opt.value_or(10);
std::optional
的用途- 函数返回值 如果某个函数可能没有有效返回值,
std::optional
可以作为返回类型来表示这一情况。 - 数据结构中的可选值 在数据结构中,有些字段可能是可选的,比如数据库记录中的可选字段。使用
std::optional
可以显式地表示字段是否有值。 - 避免返回空指针 在一些老的代码中,我们经常用
nullptr
或特定的错误码(例如 -1)来表示“无效值”或“没有找到”。这种方法通常不太直观,容易导致程序出现错误。使用std::optional
可以更清晰地表达是否有有效值
- 函数返回值 如果某个函数可能没有有效返回值,
$std::scoped\_lock$
$shared\_mutex$
这是一个用于线程同步的互斥量,支持共享锁和独占锁。与传统的
std::mutex
不同,std::shared_mutex
提供了 共享锁(shared lock
) 和 独占锁(exclusive lock
) 两种不同的锁策略,允许多个线程同时读取共享数据,同时保证写入操作的互斥性。它主要用于多线程环境下,允许多个线程读取数据而不互相阻塞,但在写入数据时仍然需要独占锁
- 它允许多个线程同时持有 共享锁(读取锁),但当一个线程持有 独占锁(写入锁)时,其他线程无法获取共享锁或独占锁。这使得
std::shared_mutex
特别适合于 读多写少 的场景。 - 共享锁:允许多个线程同时读取共享资源。
-
独占锁:只允许一个线程修改共享资源,其他线程无法获取任何类型的锁,直到独占锁被释放。
-
基本用法
- 共享锁:
使用 std::unique_lock<std::shared_mutex> 来独占锁定资源
- 独占锁:
使用 std::shared_lock<std::shared_mutex> 来共享锁定资源
- 获取共享锁(读锁)
- 多个线程可以同时获取共享锁,这意味着多个线程可以并行读取数据。
int value; // 共享数据
std::shared_mutex mutex; // 共享锁
// 读操作:多个线程可以同时进行读操作
void work() {
std::shared_lock<std::shared_mutex> locker(mutex); // 获取共享锁
std::cout << value << "\n";
}
- 获取独享锁(写锁)
- 当需要修改共享数据时,必须使用 独占锁。只有一个线程能够持有独占锁,其他线程不能读取或写入。
int value; // 共享数据
std::shared_mutex mutex; // 共享锁
// 写操作:只有一个线程可以执行写操作
void work() {
std::unique_lock<std::shared_mutex> locker(mutex); // 获取独占锁
std::cout << ++value << "\n";
}