C++17 特性详解
1. 概述
2017 年,C++17 标准正式发布。它不是一次革命性的更新,而是一次深思熟虑的进化。在 C++14 奠定了现代 C++ 的基础之后,C++17 承担起了"完善者"的角色——它没有引入什么惊天动地的新概念,而是让已有的概念变得更加自然、更加易用。
如果你曾经使用过 C++14,你可能会遇到这样的情况:你想从函数返回多个值,但不得不使用元组或结构体;你想在编译时进行条件判断,但需要复杂的模板元编程;你想简化可变参数模板,但需要递归展开。C++17 正是为了解决这些"痛点"而生的。它不是强迫你改变编程方式,而是让好的编程方式变得更加简单。
从语言哲学的角度来看,C++17 体现了"润物细无声"的设计理念。它的改进不是那种让你眼前一亮的创新,而是那种让你用起来越来越顺手、越来越自然的优化。当你习惯了 C++17 的特性后,再回到 C++14,你会发现自己已经回不去了。
2. 主要特性
C++17 在语言层面引入了多项重要改进,这些改进不是孤立的技术点,而是一个有机的整体。它们共同指向一个目标:让代码更简洁、更易读、更高效。接下来,我们将深入探讨这些特性,理解它们的设计动机、实现机制,以及如何在实际项目中发挥最大价值。
2.1 结构化绑定
结构化绑定是 C++17 中最令人兴奋的特性之一。为什么这么说?因为它解决了一个长期困扰 C++ 开发者的问题:如何优雅地从函数返回多个值?
在 C++14 的世界里,如果你想从函数返回多个值,你不得不使用元组或结构体,然后通过 std::get 或成员访问来获取这些值。这种方式既繁琐又容易出错。而 C++17 的结构化绑定让你能够直接将元组、结构体或数组的成员绑定到多个变量中,就像 Python 的解包一样自然。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看结构化绑定如何改变我们的编程方式。
假设你需要编写一个函数,返回一个点的坐标。
在 C++14 中,你不得不这样做:
选择一:使用元组
std::tuple<int, int> get_point() {
return std::make_tuple(10, 20);
}
auto point = get_point();
int x = std::get<0>(point);
int y = std::get<1>(point);这种方式的问题显而易见:你需要记住索引(0 和 1),而且代码不够直观。
选择二:使用结构体
struct Point {
int x, y;
};
Point get_point() {
return {10, 20};
}
auto point = get_point();
int x = point.x;
int y = point.y;这种方式虽然更清晰,但需要定义一个结构体,而且如果你只是想临时使用这些值,定义结构体显得有些过度设计。
而在 C++17 中,你只需要一行代码:
auto [x, y] = get_point();这就是结构化绑定的魅力:简洁、直观、优雅。
结构化绑定的实现机制
理解结构化绑定的关键在于认识到它本质上就是一种语法糖。当你写下 auto [x, y] = get_point(); 时,编译器会自动为你生成类似这样的代码:
auto __temp = get_point();
auto& x = std::get<0>(__temp);
auto& y = std::get<1>(__temp);这个过程完全发生在编译时,没有任何运行时开销。结构化绑定不是什么神秘的魔法,而是一种让编译器为你生成解包代码的便捷方式。
这种理解对于深入掌握结构化绑定至关重要,因为它让你能够预测编译器会生成什么样的代码,从而更好地理解其性能特征。例如,你知道结构化绑定可以绑定到引用,从而避免不必要的拷贝。
实际应用场景
结构化绑定的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:遍历 map
std::map<std::string, int> scores = {
{"Alice", 90},
{"Bob", 85},
{"Charlie", 95}
};
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << std::endl;
}场景二:从函数返回多个值
std::tuple<int, double, std::string> get_data() {
return {42, 3.14, "Hello"};
}
auto [i, d, s] = get_data();
// i = 42, d = 3.14, s = "Hello"场景三:解包结构体
struct Point {
int x, y, z;
};
Point p{1, 2, 3};
auto [px, py, pz] = p;
// px = 1, py = 2, pz = 3场景四:解包数组
int arr[] = {10, 20, 30};
auto [a, b, c] = arr;
// a = 10, b = 20, c = 30这些场景展示了结构化绑定的通用性:它可以让代码更加简洁,减少重复,同时保持类型安全。
结构化绑定的权衡与思考
结构化绑定虽然强大,但也带来了一些值得深思的权衡。
权衡一:可读性 vs 简洁性
当你看到 auto [x, y] = get_point(); 时,你能够立即理解它的功能,但你是否知道 x 和 y 的类型?这取决于 get_point() 的返回类型,而返回类型又可能很复杂。这种隐式的类型信息有时会让代码的理解变得更加困难。
一个实用的建议是:当返回类型简单且显而易见时,使用结构化绑定;当返回类型复杂或不明确时,考虑显式指定类型或添加注释。
权衡二:生命周期
结构化绑定创建的变量引用的是临时对象的成员。如果你将结构化绑定用于右值,临时对象的生命周期可能会比预期的短。
例如,如果你写:
auto& [x, y] = get_point();这里,get_point() 返回的临时对象会在语句结束时被销毁,x 和 y 会变成悬空引用。这是一个常见的陷阱,需要特别注意。
结构化绑定的实践智慧
使用结构化绑定的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:简单且局部
当返回的值简单且只在局部使用时,结构化绑定是理想的选择。例如:
auto [x, y] = get_point();
std::cout << x << ", " << y << std::endl;这里,结构化绑定的逻辑非常简单(解包两个值),而且只在局部使用,结构化绑定是完美的选择。
原则二:复杂且复用
当返回的值复杂或需要在多个地方使用时,传统的元组或结构体可能更合适。例如:
// 复杂的返回值,使用结构体
struct ComplexData {
int id;
std::string name;
std::vector<int> values;
};
ComplexData get_complex_data() {
// ...
return data;
}
// 而不是结构化绑定
auto [id, name, values] = get_complex_data();原则三:有意义的命名
虽然结构化绑定很简洁,但给变量选择有意义的名称可以大大提高代码的可读性。例如:
// 不推荐:名称不够清晰
auto [a, b] = get_point();
// 推荐:名称清晰表达意图
auto [x, y] = get_point();结构化绑定的注意事项
使用结构化绑定时,需要注意以下几点:
注意一:引用 vs 拷贝
结构化绑定默认是拷贝,如果你想避免拷贝,需要使用引用:
// 拷贝
auto [x, y] = get_point();
// 引用
auto& [x, y] = get_point();注意二:const 引用
如果你想修改绑定的值,需要使用非 const 引用;如果你只想读取,使用 const 引用:
// 可以修改
auto& [x, y] = get_point();
// 只读
const auto& [x, y] = get_point();注意三:生命周期
当使用引用绑定时,确保被绑定的对象的生命周期足够长:
// 危险:临时对象的生命周期
auto& [x, y] = get_point(); // 临时对象在语句结束时被销毁
// 安全:绑定到左值
Point p = get_point();
auto& [x, y] = p; // p 的生命周期足够长总结
结构化绑定是 C++17 中一个看似简单但非常强大的特性。它解决了 C++14 中多值返回的许多问题,让代码变得更加简洁和直观。
通过解包元组、结构体和数组,结构化绑定让开发者能够编写更简洁、更易读的代码。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要从函数返回多个值、遍历 map 或解包复杂数据结构的场景时,结构化绑定是你的最佳选择。
2.2 if constexpr
if constexpr 是 C++17 中最令人兴奋的特性之一。为什么这么说?因为它解决了一个长期困扰 C++ 开发者的问题:如何在编译时进行条件判断?
在 C++14 的世界里,如果你想根据类型的不同执行不同的代码,你需要使用复杂的模板元编程技巧,如 SFINAE 或标签分发。这些技巧虽然强大,但难以理解和维护。而 C++17 的 if constexpr 让你能够直接在函数体中使用 if constexpr 进行编译时条件判断,就像普通的 if 语句一样自然。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 if constexpr 如何改变我们的编程方式。
假设你需要编写一个函数,根据类型的不同执行不同的操作。
在 C++14 中,你不得不这样做:
选择一:使用 SFINAE
template <typename T>
typename std::enable_if<std::is_pointer<T>::value, T>::type
get_value(T t) {
return *t;
}
template <typename T>
typename std::enable_if<!std::is_pointer<T>::value, T>::type
get_value(T t) {
return t;
}这种方式的问题显而易见:你需要编写两个重载函数,而且代码非常冗长和复杂。
选择二:使用标签分发
template <typename T>
auto get_value_impl(T t, std::true_type) {
return *t;
}
template <typename T>
auto get_value_impl(T t, std::false_type) {
return t;
}
template <typename T>
auto get_value(T t) {
return get_value_impl(t, std::is_pointer<T>{});
}这种方式虽然更清晰,但仍然需要编写额外的辅助函数。
而在 C++17 中,你只需要一行代码:
template <typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>) {
return *t;
} else {
return t;
}
}这就是 if constexpr 的魅力:简洁、直观、优雅。
if constexpr 的实现机制
理解 if constexpr 的关键在于认识到它本质上就是一种编译时的条件判断。当你写下 if constexpr (condition) 时,编译器会在编译时评估 condition,然后只编译满足条件的分支,忽略其他分支。
这个过程完全发生在编译时,没有任何运行时开销。if constexpr 不是什么神秘的魔法,而是一种让编译器为你选择编译分支的便捷方式。
这种理解对于深入掌握 if constexpr 至关重要,因为它让你能够预测编译器会生成什么样的代码,从而更好地理解其性能特征。例如,你知道不满足条件的分支不会生成任何代码,从而避免不必要的类型错误。
实际应用场景
if constexpr 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:类型相关的操作
template <typename T>
auto process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else if constexpr (std::is_floating_point_v<T>) {
return value * 2.0;
} else {
return value;
}
}场景二:编译时优化
template <typename T>
auto sum(T a, T b) {
if constexpr (std::is_same_v<T, std::string>) {
return a + b;
} else {
return a + b;
}
}场景三:条件编译
template <typename T>
void print_info(T value) {
if constexpr (std::is_pointer_v<T>) {
std::cout << "Pointer: " << *value << std::endl;
} else {
std::cout << "Value: " << value << std::endl;
}
}场景四:避免编译错误
template <typename T>
auto get_size(T&& container) {
if constexpr (requires { container.size(); }) {
return container.size();
} else {
return std::size(container);
}
}这些场景展示了 if constexpr 的通用性:它可以让代码更加简洁,减少重复,同时保持类型安全。
if constexpr 的权衡与思考
if constexpr 虽然强大,但也带来了一些值得深思的权衡。
权衡一:可读性 vs 简洁性
当你看到 if constexpr (std::is_pointer_v<T>) 时,你能够立即理解它的功能,但你是否知道不满足条件的分支不会生成任何代码?这种隐式的编译时行为有时会让代码的理解变得更加困难。
一个实用的建议是:当条件简单且显而易见时,使用 if constexpr;当条件复杂或不明确时,考虑添加注释。
权衡二:编译时 vs 运行时
if constexpr 在编译时评估条件,这意味着条件必须是常量表达式。如果条件不是常量表达式,编译器会报错。
例如,如果你写:
int x = 10;
if constexpr (x > 5) { // 编译错误
// ...
}这里,x > 5 不是常量表达式,编译器无法在编译时评估它。
if constexpr 的实践智慧
使用 if constexpr 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:编译时条件
当条件是编译时常量时,if constexpr 是理想的选择。例如:
template <typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>) {
return *t;
} else {
return t;
}
}这里,std::is_pointer_v<T> 是编译时常量,if constexpr 是完美的选择。
原则二:运行时条件
当条件是运行时变量时,使用普通的 if 语句。例如:
int x = 10;
if (x > 5) { // 运行时条件
// ...
}原则三:避免重复代码
if constexpr 可以帮助你避免重复代码。例如,如果你有多个类似的函数,只是类型不同,可以使用 if constexpr 来合并它们:
// 不推荐:重复代码
template <typename T>
auto process_int(T value) {
return value * 2;
}
template <typename T>
auto process_float(T value) {
return value * 2.0;
}
// 推荐:使用 if constexpr
template <typename T>
auto process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else {
return value * 2.0;
}
}if constexpr 的注意事项
使用 if constexpr 时,需要注意以下几点:
注意一:常量表达式
if constexpr 的条件必须是常量表达式。如果条件不是常量表达式,编译器会报错。
注意二:分支选择
if constexpr 只会选择一个分支进行编译,其他分支会被忽略。这意味着不满足条件的分支中的代码不会生成,也不会产生编译错误。
注意三:类型检查
if constexpr 会在编译时进行类型检查。如果满足条件的分支中的代码有类型错误,编译器会报错;如果不满足条件的分支中的代码有类型错误,编译器会忽略它。
总结
if constexpr 是 C++17 中一个看似简单但非常强大的特性。它解决了 C++14 中编译时条件判断的许多问题,让代码变得更加简洁和直观。
通过编译时条件判断,if constexpr 让开发者能够编写更简洁、更易读的代码,同时避免复杂的模板元编程技巧。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要根据类型的不同执行不同的操作、进行编译时优化或避免编译错误的场景时,if constexpr 是你的最佳选择。
2.3 折叠表达式
折叠表达式是 C++17 中最令人兴奋的特性之一。为什么这么说?因为它解决了一个长期困扰 C++ 开发者的问题:如何优雅地展开可变参数模板?
在 C++14 的世界里,如果你想展开可变参数模板,你需要使用递归模板或复杂的模板元编程技巧。这些技巧虽然强大,但难以理解和维护。而 C++17 的折叠表达式让你能够直接使用折叠运算符(...)来展开参数包,就像普通的运算符一样自然。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看折叠表达式如何改变我们的编程方式。
假设你需要编写一个求和函数,它可以接受任意数量的参数。
在 C++14 中,你不得不这样做:
选择一:使用递归模板
template <typename T>
T sum(T t) {
return t;
}
template <typename T, typename... Args>
T sum(T t, Args... args) {
return t + sum(args...);
}这种方式的问题显而易见:你需要编写两个重载函数,而且递归的深度受限于编译器的限制。
选择二:使用初始化列表
template <typename... Args>
auto sum(Args... args) {
return (args + ... + 0);
}这种方式虽然更简洁,但需要提供一个初始值,而且不够直观。
而在 C++17 中,你只需要一行代码:
template <typename... Args>
auto sum(Args... args) {
return (args + ...);
}这就是折叠表达式的魅力:简洁、直观、优雅。
折叠表达式的实现机制
理解折叠表达式的关键在于认识到它本质上就是一种参数包展开的方式。当你写下 (args + ...) 时,编译器会自动展开为 ((arg1 + arg2) + arg3) + ...。
这个过程完全发生在编译时,没有任何运行时开销。折叠表达式不是什么神秘的魔法,而是一种让编译器为你展开参数包的便捷方式。
这种理解对于深入掌握折叠表达式至关重要,因为它让你能够预测编译器会生成什么样的代码,从而更好地理解其性能特征。例如,你知道折叠表达式会按照特定的顺序展开,从而避免运算符优先级的问题。
实际应用场景
折叠表达式的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:求和
template <typename... Args>
auto sum(Args... args) {
return (args + ...);
}
int result = sum(1, 2, 3, 4); // 10场景二:求积
template <typename... Args>
auto product(Args... args) {
return (args * ...);
}
int result = product(1, 2, 3, 4); // 24场景三:逻辑与
template <typename... Args>
auto all_true(Args... args) {
return (args && ...);
}
bool result = all_true(true, true, false); // false场景四:逻辑或
template <typename... Args>
auto any_true(Args... args) {
return (args || ...);
}
bool result = any_true(false, false, true); // true场景五:打印所有参数
template <typename... Args>
void print_all(Args... args) {
((std::cout << args << " "), ...);
}
print_all(1, 2.5, "Hello"); // 1 2.5 Hello这些场景展示了折叠表达式的通用性:它可以让代码更加简洁,减少重复,同时保持类型安全。
折叠表达式的权衡与思考
折叠表达式虽然强大,但也带来了一些值得深思的权衡。
权衡一:可读性 vs 简洁性
当你看到 (args + ...) 时,你能够立即理解它的功能,但你是否知道它会按照什么顺序展开?这种隐式的展开顺序有时会让代码的理解变得更加困难。
一个实用的建议是:当展开顺序显而易见时,使用折叠表达式;当展开顺序复杂或不明确时,考虑添加注释。
权衡二:运算符重载
折叠表达式依赖于运算符的重载。如果参数类型的运算符没有正确重载,编译器会报错。
例如,如果你写:
struct Point {
int x, y;
};
template <typename... Args>
auto sum(Args... args) {
return (args + ...);
}
Point p1{1, 2}, p2{3, 4};
auto result = sum(p1, p2); // 编译错误:Point 没有 + 运算符折叠表达式的实践智慧
使用折叠表达式的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:简单且直观
当参数包的展开简单且直观时,折叠表达式是理想的选择。例如:
template <typename... Args>
auto sum(Args... args) {
return (args + ...);
}这里,折叠表达式的逻辑非常简单(求和),而且展开顺序显而易见,折叠表达式是完美的选择。
原则二:复杂且需要控制
当参数包的展开复杂或需要控制展开顺序时,递归模板可能更合适。例如:
// 复杂的展开逻辑,使用递归模板
template <typename T>
void process(T t) {
// 处理最后一个参数
}
template <typename T, typename... Args>
void process(T t, Args... args) {
// 处理第一个参数
process(args...);
}原则三:使用正确的折叠类型
折叠表达式有四种类型:一元右折叠、一元左折叠、二元右折叠、二元左折叠。选择正确的折叠类型很重要。
// 一元右折叠
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // ((arg1 + arg2) + arg3) + ...
}
// 一元左折叠
template <typename... Args>
auto product(Args... args) {
return (... * args); // (... * (arg1 * arg2)) * arg3
}
// 二元右折叠
template <typename... Args>
auto sum_with_initial(int initial, Args... args) {
return (initial + ... + args); // ((initial + arg1) + arg2) + ...
}
// 二元左折叠
template <typename... Args>
auto product_with_initial(int initial, Args... args) {
return (... * args * initial); // (... * (arg1 * arg2)) * initial
}折叠表达式的注意事项
使用折叠表达式时,需要注意以下几点:
注意一:空参数包
对于一元折叠,空参数包的行为取决于运算符。例如,(args + ...) 对于空参数包是未定义的,但 (args || ...) 对于空参数包是 false。
注意二:运算符优先级
折叠表达式的展开顺序可能会影响运算符优先级。例如,(args + ...) 会展开为 ((arg1 + arg2) + arg3) + ...,而不是 arg1 + (arg2 + (arg3 + ...))。
注意三:类型推导
折叠表达式的返回类型由运算符和参数类型决定。如果参数类型不同,可能会发生隐式类型转换。
总结
折叠表达式是 C++17 中一个看似简单但非常强大的特性。它解决了 C++14 中可变参数模板展开的许多问题,让代码变得更加简洁和直观。
通过折叠运算符,折叠表达式让开发者能够编写更简洁、更易读的代码,同时避免复杂的递归模板。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要展开可变参数模板、进行批量操作或实现通用算法的场景时,折叠表达式是你的最佳选择。
2.4 内联变量
内联变量是 C++17 中最令人兴奋的特性之一。为什么这么说?因为它解决了一个长期困扰 C++ 开发者的问题:如何在头文件中定义全局变量?
在 C++14 的世界里,如果你想 in 头文件中定义全局变量,你不得不使用 extern 声明,然后在某个源文件中定义它。这种方式既繁琐又容易出错。而 C++17 的内联变量让你能够直接在头文件中定义全局变量,就像内联函数一样自然。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看内联变量如何改变我们的编程方式。
假设你想在头文件中定义一个全局常量。
在 C++14 中,你不得不这样做:
选择一:使用 extern
// header.h
extern const int global_var;
// source.cpp
const int global_var = 42;这种方式的问题显而易见:你需要在头文件中声明,在源文件中定义,而且如果忘记定义,链接器会报错。
选择二:使用 constexpr
// header.h
constexpr int global_var = 42;这种方式虽然更简洁,但 constexpr 变量必须是常量表达式,而且不能用于所有类型的变量。
而在 C++17 中,你只需要一行代码:
// header.h
inline const int global_var = 42;这就是内联变量的魅力:简洁、直观、优雅。
内联变量的实现机制
理解内联变量的关键在于认识到它本质上就是一种特殊的变量定义。当你写下 inline const int global_var = 42; 时,编译器会为每个包含这个头文件的翻译单元生成一个定义,但链接器会合并这些定义,只保留一个。
这个过程完全发生在链接时,没有任何运行时开销。内联变量不是什么神秘的魔法,而是一种让编译器为你处理链接的便捷方式。
这种理解对于深入掌握内联变量至关重要,因为它让你能够预测编译器和链接器会生成什么样的代码,从而更好地理解其性能特征。例如,你知道内联变量可以在多个翻译单元中定义,而不会导致链接错误。
实际应用场景
内联变量的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:全局常量
// header.h
inline const int MAX_SIZE = 100;
inline const std::string VERSION = "1.0.0";场景二:配置变量
// config.h
inline bool DEBUG_MODE = true;
inline int LOG_LEVEL = 2;场景三:单例模式
// singleton.h
inline Singleton& get_singleton() {
static Singleton instance;
return instance;
}场景四:全局状态
// state.h
inline int global_counter = 0;
inline std::mutex global_mutex;这些场景展示了内联变量的通用性:它可以让代码更加简洁,减少重复,同时保持类型安全。
内联变量的权衡与思考
内联变量虽然强大,但也带来了一些值得深思的权衡。
权衡一:可读性 vs 简洁性
当你看到 inline const int global_var = 42; 时,你能够立即理解它的功能,但你是否知道它可以在多个翻译单元中定义?这种隐式的链接行为有时会让代码的理解变得更加困难。
一个实用的建议是:当变量是全局常量或配置变量时,使用内联变量;当变量是复杂的状态或需要特殊初始化时,考虑使用其他方式。
权衡二:初始化顺序
内联变量的初始化顺序是不确定的。如果你有多个内联变量,它们的初始化顺序可能与预期不同。
例如,如果你写:
inline int x = 10;
inline int y = x + 10;这里,y 的初始化依赖于 x,但初始化顺序是不确定的,可能会导致未定义行为。
内联变量的实践智慧
使用内联变量的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:全局常量
当变量是全局常量时,内联变量是理想的选择。例如:
// header.h
inline const int MAX_SIZE = 100;
inline const std::string VERSION = "1.0.0";这里,内联变量的逻辑非常简单(定义常量),而且只在头文件中使用,内联变量是完美的选择。
原则二:配置变量
当变量是配置变量时,内联变量是理想的选择。例如:
// config.h
inline bool DEBUG_MODE = true;
inline int LOG_LEVEL = 2;原则三:避免复杂初始化
内联变量的初始化应该是简单的,避免复杂的初始化逻辑。例如:
// 不推荐:复杂的初始化
inline std::vector<int> primes = []() {
std::vector<int> result;
for (int i = 2; i < 100; ++i) {
if (is_prime(i)) {
result.push_back(i);
}
}
return result;
}();
// 推荐:简单的初始化
inline const int MAX_SIZE = 100;内联变量的注意事项
使用内联变量时,需要注意以下几点:
注意一:初始化顺序
内联变量的初始化顺序是不确定的。如果一个内联变量的初始化依赖于另一个内联变量,可能会导致未定义行为。
注意二:线程安全
内联变量的初始化是线程安全的。如果多个线程同时访问一个内联变量,编译器会保证初始化只执行一次。
注意三:ODR 违规
内联变量可以在多个翻译单元中定义,但所有定义必须相同。如果不同翻译单元中的定义不同,会导致未定义行为。
总结
内联变量是 C++17 中一个看似简单但非常强大的特性。它解决了 C++14 中头文件中定义全局变量的许多问题,让代码变得更加简洁和直观。
通过内联变量,开发者能够直接在头文件中定义全局变量,避免繁琐的 extern 声明和源文件定义。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要在头文件中定义全局常量、配置变量或单例模式的场景时,内联变量是你的最佳选择。
2.5 模板参数推导
模板参数推导是 C++17 中最令人兴奋的特性之一。为什么这么说?因为它解决了一个长期困扰 C++ 开发者的问题:如何让类模板像函数模板一样自动推导模板参数?
在 C++14 的世界里,如果你想使用类模板,你必须显式指定模板参数。这种方式既繁琐又容易出错。而 C++17 的模板参数推导让你能够像使用函数模板一样使用类模板,编译器会自动推导模板参数。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看模板参数推导如何改变我们的编程方式。
假设你想使用一个模板类来包装一个值。
在 C++14 中,你不得不这样做:
template <typename T>
struct Wrapper {
T value;
Wrapper(T v) : value(v) {}
};
// 使用时
Wrapper<int> w1(42);
Wrapper<double> w2(3.14);
Wrapper<std::string> w3("Hello");这种方式的问题显而易见:你必须显式指定模板参数,即使构造函数的参数已经足够推导出模板参数。
而在 C++17 中,你只需要一行代码:
Wrapper w1(42);
Wrapper w2(3.14);
Wrapper w3("Hello");这就是模板参数推导的魅力:简洁、直观、优雅。
模板参数推导的实现机制
理解模板参数推导的关键在于认识到它本质上就是一种类型推导机制。当你写下 Wrapper w1(42); 时,编译器会根据构造函数的参数类型自动推导出模板参数 T 为 int。
这个过程完全发生在编译时,没有任何运行时开销。模板参数推导不是什么神秘的魔法,而是一种让编译器为你推导类型的便捷方式。
这种理解对于深入掌握模板参数推导至关重要,因为它让你能够预测编译器会推导出什么样的类型,从而更好地理解其性能特征。例如,你知道模板参数推导依赖于构造函数的参数类型,而不是其他因素。
实际应用场景
模板参数推导的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:pair
std::pair p(42, 3.14); // 推导为 std::pair<int, double>场景二:tuple
std::tuple t(42, 3.14, "Hello"); // 推导为 std::tuple<int, double, const char*>场景三:vector
std::vector v{1, 2, 3, 4, 5}; // 推导为 std::vector<int>场景四:自定义类模板
template <typename T>
struct Container {
std::vector<T> data;
Container(std::initializer_list<T> init) : data(init) {}
};
Container c{1, 2, 3, 4, 5}; // 推导为 Container<int>这些场景展示了模板参数推导的通用性:它可以让代码更加简洁,减少重复,同时保持类型安全。
模板参数推导的权衡与思考
模板参数推导虽然强大,但也带来了一些值得深思的权衡。
权衡一:可读性 vs 简洁性
当你看到 Wrapper w1(42); 时,你能够立即理解它的功能,但你是否知道 w1 的类型?这种隐式的类型信息有时会让代码的理解变得更加困难。
一个实用的建议是:当类型显而易见时,使用模板参数推导;当类型复杂或不明确时,考虑显式指定模板参数或添加注释。
权衡二:推导失败
模板参数推导可能会失败,如果构造函数的参数类型无法唯一确定模板参数。
例如,如果你写:
template <typename T>
struct Wrapper {
T value;
Wrapper(T v) : value(v) {}
Wrapper(T v, int) : value(v) {}
};
Wrapper w(42); // 编译错误:无法确定使用哪个构造函数模板参数推导的实践智慧
使用模板参数推导的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:类型显而易见
当类型显而易见时,模板参数推导是理想的选择。例如:
std::pair p(42, 3.14); // 类型显而易见:std::pair<int, double>这里,模板参数推导的逻辑非常简单(从参数类型推导),而且类型显而易见,模板参数推导是完美的选择。
原则二:类型复杂或不明确
当类型复杂或不明确时,显式指定模板参数可能更合适。例如:
// 类型复杂,显式指定
std::vector<std::shared_ptr<MyClass>> v;
// 类型不明确,显式指定
std::function<void(int)> f = [](int x) { std::cout << x << std::endl; };原则三:使用推导指南
如果默认的模板参数推导不符合你的需求,你可以使用推导指南来自定义推导规则。
template <typename T>
struct Wrapper {
T value;
Wrapper(T v) : value(v) {}
};
// 推导指南
template <typename T>
Wrapper(T) -> Wrapper<T>;
// 自定义推导
template <typename T>
Wrapper(std::vector<T>) -> Wrapper<std::vector<T>>;模板参数推导的注意事项
使用模板参数推导时,需要注意以下几点:
注意一:推导失败
模板参数推导可能会失败,如果构造函数的参数类型无法唯一确定模板参数。
注意二:显式指定
如果模板参数推导不符合你的需求,你可以显式指定模板参数。
注意三:推导指南
如果默认的模板参数推导不符合你的需求,你可以使用推导指南来自定义推导规则。
总结
模板参数推导是 C++17 中一个看似简单但非常强大的特性。它解决了 C++14 中类模板使用的许多问题,让代码变得更加简洁和直观。
通过自动推导模板参数,开发者能够像使用函数模板一样使用类模板,避免繁琐的显式指定。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要使用类模板、减少代码重复或提高代码可读性的场景时,模板参数推导是你的最佳选择。
2.8 命名空间嵌套
C++17 引入了命名空间嵌套的简化语法,让嵌套命名空间的定义变得更加简洁。这个特性虽然简单,但体现了 C++17 的一贯理念:让好的实践变得更加容易。
在 C++14 的世界里,如果你想定义嵌套命名空间,你需要使用嵌套的命名空间定义。这种方式虽然可行,但代码会变得冗长和重复。而 C++17 的命名空间嵌套简化语法让你能够在一行中定义多层嵌套的命名空间。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看命名空间嵌套如何改变我们的编程方式。
在 C++14 中,你不得不这样做:
namespace A {
namespace B {
int x = 42;
}
}这种方式虽然可行,但有一个明显的问题:你需要重复写 namespace 关键字,而且代码会变得冗长。
而在 C++17 中,你可以这样写:
namespace A::B {
int x = 42;
}这就是命名空间嵌套简化的魅力:简洁、直观、优雅。
命名空间嵌套的实现机制
理解命名空间嵌套简化的关键在于认识到它本质上就是一种语法糖。当你写下 namespace A::B { 时,编译器会自动为你生成类似这样的代码:
namespace A {
namespace B {
// ...
}
}这个过程完全发生在编译时,没有任何运行时开销。命名空间嵌套简化不是什么神秘的魔法,而是一种让编译器为你生成嵌套命名空间定义的便捷方式。
实际应用场景
命名空间嵌套简化的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:深度嵌套的命名空间
// C++14
namespace Company {
namespace Product {
namespace Module {
int value = 42;
}
}
}
// C++17
namespace Company::Product::Module {
int value = 42;
}场景二:避免重复
// C++14
namespace A {
namespace B {
int x = 1;
}
namespace C {
int y = 2;
}
}
// C++17
namespace A::B {
int x = 1;
}
namespace A::C {
int y = 2;
}这些场景展示了命名空间嵌套简化的通用性:它可以让代码更加简洁,减少重复,同时保持可读性。
命名空间嵌套的权衡与思考
命名空间嵌套简化虽然简单,但也带来了一些值得深思的权衡。
权衡一:简洁性 vs 兼容性
当你使用命名空间嵌套简化时,你获得了简洁性,但你也失去了与旧编译器的兼容性。如果你的项目需要支持 C++14 或更早的版本,你仍然需要使用旧的语法。
一个实用的建议是:当你的项目使用 C++17 或更高版本时,使用命名空间嵌套简化;当需要兼容旧版本时,使用旧的语法。
权衡二:可读性 vs 简洁性
当你看到 namespace A::B { 时,你能够立即理解它的功能,但你是否知道它定义的是一个嵌套命名空间?这种简化的语法有时会让代码的理解变得更加困难。
一个实用的建议是:当命名空间嵌套层次较浅时,使用简化语法;当命名空间嵌套层次较深时,考虑使用旧的语法或添加注释。
命名空间嵌套的实践智慧
使用命名空间嵌套简化的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:浅层嵌套
当命名空间嵌套层次较浅时,命名空间嵌套简化是理想的选择。例如:
namespace A::B {
int x = 42;
}这里,命名空间嵌套非常浅(只有两层),命名空间嵌套简化是完美的选择。
原则二:深层嵌套
当命名空间嵌套层次较深时,旧的语法可能更合适。例如:
// 不推荐:深层嵌套
namespace Company::Product::Module::Submodule::Feature {
int value = 42;
}
// 推荐:使用旧的语法或避免深层嵌套
namespace Company {
namespace Product {
namespace Module {
namespace Submodule {
namespace Feature {
int value = 42;
}
}
}
}
}命名空间嵌套的注意事项
使用命名空间嵌套简化时,需要注意以下几点:
注意一:编译器支持
命名空间嵌套简化需要 C++17 或更高版本的编译器支持。如果你的编译器不支持 C++17,你需要使用旧的语法。
注意二:定义顺序
命名空间嵌套简化要求外层命名空间必须已经定义。例如,你不能直接定义 namespace A::B,如果 A 还没有定义。
注意三:与 using 指令的区别
命名空间嵌套简化是定义命名空间,而 using 指令是引用命名空间。不要混淆这两个概念。
总结
命名空间嵌套简化是 C++17 中一个看似简单但非常实用的特性。它解决了 C++14 中嵌套命名空间定义的许多问题,让代码变得更加简洁和直观。
通过简化命名空间嵌套语法,开发者能够在一行中定义多层嵌套的命名空间,避免重复的 namespace 关键字。它与 C++17 的其他特性相辅相成,共同构成了 C++17 中现代 C++ 的完整图景。
在实际编程中,当你遇到需要定义嵌套命名空间的场景时,命名空间嵌套简化是你的最佳选择。
2.9 标准库更新
C++17 的标准库更新虽然不像语言特性那样引人注目,但它们同样体现了 C++17 的核心理念:让好的实践更容易实现。这些更新不是革命性的创新,而是对现有实践的标准化和简化——它们将开发者已经在做的事情(通过 Boost 库或其他第三方库)变成了标准的一部分。
这个特性的哲学意义在于它体现了"标准化即简化"的理念。当一个功能成为标准的一部分时,开发者不再需要评估不同的第三方实现,不再需要担心兼容性问题,不再需要学习不同的 API。标准库更新让代码变得更加一致、更加可靠。
标准库更新的深层价值
理解标准库更新的价值,需要思考软件开发中的一个基本问题:重复造轮子。在 C++14 的世界里,如果你想表示一个可能不存在的值,你必须使用指针或特殊值。而 C++17 的 std::optional 让这一切变得简单而安全。
这种标准化的价值不仅在于便利性,更在于一致性。当所有开发者都使用相同的工具时,代码变得更加易于理解和维护。考虑一个团队项目:如果每个开发者都使用不同的方式表示可选值,代码会变得混乱不堪。而有了 std::optional,所有人都使用相同的方式,代码变得更加统一。
2.9.1 std::optional
std::optional 是一个用于表示可选值的工具,它让可选值的处理变得更加自然。在 C++14 中,如果你想表示一个可能不存在的值,你需要使用指针或特殊值。而 std::optional 提供了一种更直观的方式:
std::optional<int> find_value() {
return 42;
}
auto value = find_value();
if (value) {
std::cout << *value << std::endl;
}这种"可能存在也可能不存在"的方式让可选值的处理变得更加直观和安全。它不是什么复杂的机制,而是一种让编译器为你处理可选值细节的便捷方式。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 std::optional 如何改变我们的编程方式。
假设你需要编写一个函数,查找一个值,如果找不到则返回一个特殊值。
在 C++14 中,你不得不这样做:
选择一:使用指针
int* find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return &it->second;
} else {
return nullptr;
}
}这种方式虽然可行,但有一个明显的问题:你需要处理指针,而且如果值是基本类型,指针的使用会变得复杂。
选择二:使用特殊值
int find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return it->second;
} else {
return -1; // 特殊值
}
}这种方式虽然更简单,但有一个明显的问题:你需要选择一个特殊值,而且如果所有可能的值都是有效的,就无法选择特殊值。
而在 C++17 中,你可以使用 std::optional 来表示可选值:
std::optional<int> find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return it->second;
} else {
return std::nullopt;
}
}这里,std::optional<int> 明确表示"可能是一个 int,也可能不存在"。这种方式更加清晰,而且不需要选择特殊值。
std::optional 的实现机制
理解 std::optional 的关键在于认识到它本质上就是一个包装器。当你使用 std::optional<int> 时,编译器会生成类似这样的代码:
template <typename T>
struct optional {
bool has_value;
T value;
optional() : has_value(false) {}
optional(T v) : has_value(true), value(v) {}
explicit operator bool() const { return has_value; }
T& operator*() { return value; }
};这个包装器的巧妙之处在于它明确表示了"可能存在也可能不存在"的语义。如果 has_value 为 true,则 value 有效;如果 has_value 为 false,则 value 无效。
实际应用场景
std::optional 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:查找操作
std::optional<int> find(const std::vector<int>& v, int target) {
for (int x : v) {
if (x == target) {
return x;
}
}
return std::nullopt;
}场景二:配置读取
std::optional<std::string> get_config(const std::string& key) {
auto it = config.find(key);
if (it != config.end()) {
return it->second;
} else {
return std::nullopt;
}
}场景三:解析操作
std::optional<int> parse_int(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::nullopt;
}
}场景四:计算操作
std::optional<double> divide(double a, double b) {
if (b == 0) {
return std::nullopt;
} else {
return a / b;
}
}这些场景展示了 std::optional 的通用性:它可以让代码更加简洁,减少错误,同时保持类型安全。
std::optional 的权衡与思考
std::optional 虽然强大,但也带来了一些值得深思的权衡。
权衡一:简洁性 vs 性能
当你使用 std::optional 时,你获得了简洁性和安全性,但你也付出了一些性能代价。std::optional 需要额外的空间来存储"是否有值"的标志,而且访问值需要额外的检查。
一个实用的建议是:当可选值的使用场景复杂时,使用 std::optional;当可选值的使用场景简单时,考虑使用指针或特殊值。
权衡二:异常安全
std::optional 提供了一种异常安全的方式来处理可选值。你不需要使用异常来表示"值不存在",而是使用 std::optional 的语义。
std::optional 的实践智慧
使用 std::optional 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:可选值
当值可能不存在时,std::optional 是理想的选择。例如:
std::optional<int> find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return it->second;
} else {
return std::nullopt;
}
}这里,std::optional<int> 明确表示"可能是一个 int,也可能不存在",std::optional 是完美的选择。
原则二:避免过度使用
虽然 std::optional 很方便,但也不应该过度使用。如果值总是存在,应该使用普通的类型。
// 不需要可选值时,使用普通类型
int get_value() {
return 42;
}
// 需要可选值时,使用 optional
std::optional<int> find_value() {
// ...
}std::optional 的优势
与指针或特殊值相比,std::optional 具有以下优势:
- 更安全:明确表示"可能存在也可能不存在"的语义,避免空指针或特殊值的问题
- 更简洁:不需要选择特殊值,也不需要处理指针
- 更一致:提供了一致的接口,可以用于任何类型
// 使用指针(不推荐)
int* find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return &it->second;
} else {
return nullptr;
}
}
// 使用 optional(推荐)
std::optional<int> find_value(const std::map<std::string, int>& m, const std::string& key) {
auto it = m.find(key);
if (it != m.end()) {
return it->second;
} else {
return std::nullopt;
}
}2.9.2 std::variant
std::variant 是一个用于表示类型安全的联合体的工具,它让类型安全的联合体变得更加自然。在 C++14 中,如果你想表示一个可能是多种类型的值,你需要使用 union 或继承层次结构。而 std::variant 提供了一种更直观的方式:
std::variant<int, double, std::string> v;
v = 42; // int
v = 3.14; // double
v = "Hello"; // string这种"可能是多种类型之一"的方式让类型安全的联合体的处理变得更加直观和安全。它不是什么复杂的机制,而是一种让编译器为你处理类型安全细节的便捷方式。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 std::variant 如何改变我们的编程方式。
假设你需要编写一个函数,接受多种类型的参数。
在 C++14 中,你不得不这样做:
选择一:使用 union
union Value {
int i;
double d;
char* s;
};
Value v;
v.i = 42;这种方式虽然可行,但有一个明显的问题:union 不是类型安全的,你需要手动跟踪当前存储的类型。
选择二:使用继承层次结构
struct Value {
virtual ~Value() {}
};
struct IntValue : Value {
int value;
};
struct DoubleValue : Value {
double value;
};
struct StringValue : Value {
std::string value;
};这种方式虽然更安全,但需要定义多个类,而且使用起来比较复杂。
而在 C++17 中,你可以使用 std::variant 来表示类型安全的联合体:
std::variant<int, double, std::string> v;
v = 42; // int
v = 3.14; // double
v = "Hello"; // string这里,std::variant<int, double, std::string> 明确表示"可能是 int、double 或 string"。这种方式更加清晰,而且类型安全。
std::variant 的实现机制
理解 std::variant 的关键在于认识到它本质上就是一个类型安全的联合体。当你使用 std::variant<int, double, std::string> 时,编译器会生成类似这样的代码:
template <typename... Types>
struct variant {
size_t index;
std::aligned_storage_t<max_size<Types...>> storage;
template <typename T>
variant(T v) : index(type_index<T, Types...>{}), storage() {
new (&storage) T(v);
}
template <typename T>
T& get() {
if (index != type_index<T, Types...>{}) {
throw std::bad_variant_access();
}
return *reinterpret_cast<T*>(&storage);
}
};这个联合体的巧妙之处在于它保证了类型安全。如果你尝试访问错误的类型,编译器会抛出异常。
实际应用场景
std::variant 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:配置值
std::variant<int, double, std::string, bool> config_value;
config_value = 42; // int
config_value = 3.14; // double
config_value = "Hello"; // string
config_value = true; // bool场景二:解析结果
std::variant<int, double, std::string> parse_value(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {}
try {
return std::stod(s);
} catch (...) {}
return s;
}场景三:AST 节点
struct NumberNode { int value; };
struct StringNode { std::string value; };
struct BinaryOpNode { char op; };
using ASTNode = std::variant<NumberNode, StringNode, BinaryOpNode>;场景四:事件处理
struct MouseEvent { int x, y; };
struct KeyEvent { int key; };
struct TimerEvent { int id; };
using Event = std::variant<MouseEvent, KeyEvent, TimerEvent>;这些场景展示了 std::variant 的通用性:它可以让代码更加简洁,减少错误,同时保持类型安全。
std::variant 的权衡与思考
std::variant 虽然强大,但也带来了一些值得深思的权衡。
权衡一:类型安全 vs 性能
当你使用 std::variant 时,你获得了类型安全性,但你也付出了一些性能代价。std::variant 需要额外的空间来存储类型索引,而且访问值需要额外的检查。
一个实用的建议是:当类型安全很重要时,使用 std::variant;当性能更重要时,考虑使用 union。
权衡二:异常安全
std::variant 提供了一种异常安全的方式来处理类型错误。如果你尝试访问错误的类型,编译器会抛出异常,而不是未定义行为。
std::variant 的实践智慧
使用 std::variant 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:类型安全的联合体
当需要类型安全的联合体时,std::variant 是理想的选择。例如:
std::variant<int, double, std::string> v;
v = 42; // int
v = 3.14; // double
v = "Hello"; // string这里,std::variant<int, double, std::string> 明确表示"可能是 int、double 或 string",std::variant 是完美的选择。
原则二:使用 std::visit
当需要访问 std::variant 的值时,使用 std::visit 来处理所有可能的类型:
std::variant<int, double, std::string> v = 42;
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << std::endl;
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << arg << std::endl;
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << std::endl;
}
}, v);std::variant 的优势
与 union 或继承层次结构相比,std::variant 具有以下优势:
- 更安全:类型安全的联合体,避免未定义行为
- 更简洁:不需要定义多个类,也不需要手动跟踪类型
- 更高效:没有虚函数开销,访问值更快
// 使用 union(不推荐)
union Value {
int i;
double d;
char* s;
};
Value v;
v.i = 42;
// 使用 variant(推荐)
std::variant<int, double, std::string> v;
v = 42;2.9.3 std::any
std::any 是一个用于存储任意类型的值的工具,它让类型擦除变得更加自然。在 C++14 中,如果你想存储任意类型的值,你需要使用 void* 或模板。而 std::any 提供了一种更直观的方式:
std::any a;
a = 42; // int
a = 3.14; // double
a = std::string("Hello"); // string这种"可以是任何类型"的方式让类型擦除的处理变得更加直观和安全。它不是什么复杂的机制,而是一种让编译器为你处理类型擦除细节的便捷方式。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 std::any 如何改变我们的编程方式。
假设你需要编写一个容器,可以存储任意类型的值。
在 C++14 中,你不得不这样做:
选择一:使用 void*
struct Any {
void* value;
void (*deleter)(void*);
template <typename T>
Any(T v) : value(new T(v)), deleter([](void* p) { delete static_cast<T*>(p); }) {}
~Any() { deleter(value); }
};这种方式虽然可行,但有一个明显的问题:你需要手动管理内存,而且类型不安全。
选择二:使用模板
template <typename T>
struct Container {
std::vector<T> data;
};这种方式虽然更安全,但每个类型都需要一个单独的容器。
而在 C++17 中,你可以使用 std::any 来存储任意类型的值:
std::vector<std::any> data;
data.push_back(42); // int
data.push_back(3.14); // double
data.push_back(std::string("Hello")); // string这里,std::any 明确表示"可以是任何类型"。这种方式更加清晰,而且类型安全。
std::any 的实现机制
理解 std::any 的关键在于认识到它本质上就是一个类型擦除的容器。当你使用 std::any 时,编译器会生成类似这样的代码:
struct any {
struct placeholder {
virtual ~placeholder() {}
virtual const std::type_info& type() const = 0;
virtual placeholder* clone() const = 0;
};
template <typename T>
struct holder : placeholder {
T value;
holder(T v) : value(v) {}
const std::type_info& type() const override {
return typeid(T);
}
placeholder* clone() const override {
return new holder(value);
}
};
placeholder* content;
template <typename T>
any(T v) : content(new holder<T>(v)) {}
~any() { delete content; }
const std::type_info& type() const {
return content->type();
}
};这个容器的巧妙之处在于它使用类型擦除来存储任意类型的值。每个值都被包装在一个 holder 中,而 holder 继承自 placeholder,从而实现了类型擦除。
实际应用场景
std::any 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:异构容器
std::vector<std::any> data;
data.push_back(42); // int
data.push_back(3.14); // double
data.push_back(std::string("Hello")); // string场景二:消息传递
std::queue<std::any> message_queue;
message_queue.push(42); // int
message_queue.push(3.14); // double
message_queue.push(std::string("Hello")); // string场景三:插件系统
std::map<std::string, std::any> plugins;
plugins["plugin1"] = Plugin1();
plugins["plugin2"] = Plugin2();场景四:配置系统
std::map<std::string, std::any> config;
config["port"] = 8080;
config["host"] = std::string("localhost");
config["debug"] = true;这些场景展示了 std::any 的通用性:它可以让代码更加简洁,减少错误,同时保持类型安全。
std::any 的权衡与思考
std::any 虽然强大,但也带来了一些值得深思的权衡。
权衡一:类型安全 vs 灵活性
当你使用 std::any 时,你获得了灵活性,但你也失去了类型安全性。你需要使用 std::any_cast 来提取值,如果类型不匹配,会抛出异常。
一个实用的建议是:当需要存储任意类型的值时,使用 std::any;当类型已知时,使用普通的类型。
权衡二:性能 vs 便利性
std::any 需要动态内存分配和虚函数调用,这会带来一些性能开销。如果你知道类型,使用普通的类型会更高效。
std::any 的实践智慧
使用 std::any 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:异构容器
当需要存储任意类型的值时,std::any 是理想的选择。例如:
std::vector<std::any> data;
data.push_back(42); // int
data.push_back(3.14); // double
data.push_back(std::string("Hello")); // string这里,std::any 明确表示"可以是任何类型",std::any 是完美的选择。
原则二:使用 std::any_cast
当需要从 std::any 提取值时,使用 std::any_cast 来安全地提取值:
std::any a = 42;
int value = std::any_cast<int>(a); // 42
try {
std::string s = std::any_cast<std::string>(a); // 抛出异常
} catch (const std::bad_any_cast& e) {
std::cout << "bad cast" << std::endl;
}std::any 的优势
与 void* 或模板相比,std::any 具有以下优势:
- 更安全:类型安全的类型擦除,避免未定义行为
- 更简洁:不需要手动管理内存,也不需要为每个类型定义单独的容器
- 更一致:提供了一致的接口,可以用于任何类型
// 使用 void*(不推荐)
void* value = new int(42);
int* int_value = static_cast<int*>(value);
delete int_value;
// 使用 any(推荐)
std::any a = 42;
int value = std::any_cast<int>(a);2.9.4 std::filesystem
std::filesystem 是一个用于处理文件系统路径和操作的工具,它让文件系统的处理变得更加自然。在 C++14 中,如果你想处理文件系统,你需要使用平台特定的 API 或第三方库。而 std::filesystem 提供了一种更直观的方式:
std::filesystem::path p = "test.txt";
if (std::filesystem::exists(p)) {
std::cout << "文件存在" << std::endl;
}这种"跨平台的文件系统操作"的方式让文件系统的处理变得更加直观和安全。它不是什么复杂的机制,而是一种让编译器为你处理文件系统细节的便捷方式。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 std::filesystem 如何改变我们的编程方式。
假设你需要编写一个函数,遍历目录中的所有文件。
在 C++14 中,你不得不这样做:
选择一:使用平台特定的 API
#ifdef _WIN32
#include <windows.h>
#else
#include <dirent.h>
#endif
void list_files(const std::string& dir) {
#ifdef _WIN32
WIN32_FIND_DATA data;
HANDLE hFind = FindFirstFile((dir + "\\*").c_str(), &data);
if (hFind != INVALID_HANDLE_VALUE) {
do {
std::cout << data.cFileName << std::endl;
} while (FindNextFile(hFind, &data));
FindClose(hFind);
}
#else
DIR* dp = opendir(dir.c_str());
if (dp) {
struct dirent* ep;
while ((ep = readdir(dp))) {
std::cout << ep->d_name << std::endl;
}
closedir(dp);
}
#endif
}这种方式虽然可行,但有一个明显的问题:你需要为每个平台编写不同的代码,而且代码非常冗长和复杂。
选择二:使用第三方库
#include <boost/filesystem.hpp>
void list_files(const std::string& dir) {
boost::filesystem::path p(dir);
for (auto& entry : boost::filesystem::directory_iterator(p)) {
std::cout << entry.path() << std::endl;
}
}这种方式虽然更简洁,但需要依赖第三方库。
而在 C++17 中,你可以使用 std::filesystem 来处理文件系统:
void list_files(const std::string& dir) {
std::filesystem::path p(dir);
for (auto& entry : std::filesystem::directory_iterator(p)) {
std::cout << entry.path() << std::endl;
}
}这里,std::filesystem 提供了跨平台的文件系统操作,不需要为每个平台编写不同的代码。
std::filesystem 的实现机制
理解 std::filesystem 的关键在于认识到它本质上就是一个跨平台的文件系统抽象。当你使用 std::filesystem::path 时,编译器会生成类似这样的代码:
class path {
std::string native_path;
public:
path(const std::string& p) : native_path(p) {}
std::string string() const {
return native_path;
}
path operator/(const path& other) const {
return path(native_path + "/" + other.native_path);
}
};这个抽象的巧妙之处在于它隐藏了平台特定的细节,提供了统一的接口。
实际应用场景
std::filesystem 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:检查文件存在
std::filesystem::path p = "test.txt";
if (std::filesystem::exists(p)) {
std::cout << "文件存在" << std::endl;
}场景二:创建目录
std::filesystem::path p = "test_dir";
if (!std::filesystem::exists(p)) {
std::filesystem::create_directory(p);
}场景三:复制文件
std::filesystem::path src = "source.txt";
std::filesystem::path dst = "destination.txt";
std::filesystem::copy_file(src, dst);场景四:遍历目录
std::filesystem::path p = "test_dir";
for (auto& entry : std::filesystem::directory_iterator(p)) {
std::cout << entry.path() << std::endl;
}这些场景展示了 std::filesystem 的通用性:它可以让代码更加简洁,减少错误,同时保持跨平台兼容性。
std::filesystem 的权衡与思考
std::filesystem 虽然强大,但也带来了一些值得深思的权衡。
权衡一:跨平台 vs 性能
当你使用 std::filesystem 时,你获得了跨平台兼容性,但你也付出了一些性能代价。std::filesystem 需要进行额外的抽象和转换,这会带来一些性能开销。
一个实用的建议是:当跨平台兼容性很重要时,使用 std::filesystem;当性能更重要时,考虑使用平台特定的 API。
权衡二:简洁性 vs 功能性
std::filesystem 提供了简洁的接口,但可能不包含所有平台特定的功能。如果你需要使用平台特定的功能,可能需要使用平台特定的 API。
std::filesystem 的实践智慧
使用 std::filesystem 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:跨平台操作
当需要跨平台的文件系统操作时,std::filesystem 是理想的选择。例如:
std::filesystem::path p = "test.txt";
if (std::filesystem::exists(p)) {
std::cout << "文件存在" << std::endl;
}这里,std::filesystem 提供了跨平台的文件系统操作,std::filesystem 是完美的选择。
原则二:使用路径操作
当需要操作路径时,使用 std::filesystem::path 来安全地操作路径:
std::filesystem::path p = "dir/file.txt";
std::cout << p.parent_path() << std::endl; // "dir"
std::cout << p.filename() << std::endl; // "file.txt"
std::cout << p.extension() << std::endl; // ".txt"std::filesystem 的优势
与平台特定的 API 或第三方库相比,std::filesystem 具有以下优势:
- 更跨平台:提供统一的接口,不需要为每个平台编写不同的代码
- 更安全:类型安全的路径操作,避免缓冲区溢出等问题
- 更一致:提供了一致的接口,可以用于所有平台
// 使用平台特定的 API(不推荐)
#ifdef _WIN32
#include <windows.h>
#else
#include <dirent.h>
#endif
void list_files(const std::string& dir) {
#ifdef _WIN32
WIN32_FIND_DATA data;
HANDLE hFind = FindFirstFile((dir + "\\*").c_str(), &data);
if (hFind != INVALID_HANDLE_VALUE) {
do {
std::cout << data.cFileName << std::endl;
} while (FindNextFile(hFind, &data));
FindClose(hFind);
}
#else
DIR* dp = opendir(dir.c_str());
if (dp) {
struct dirent* ep;
while ((ep = readdir(dp))) {
std::cout << ep->d_name << std::endl;
}
closedir(dp);
}
#endif
}
// 使用 std::filesystem(推荐)
void list_files(const std::string& dir) {
std::filesystem::path p(dir);
for (auto& entry : std::filesystem::directory_iterator(p)) {
std::cout << entry.path() << std::endl;
}
}2.9.5 std::string_view
std::string_view 是一个用于表示字符串的视图的工具,它让字符串的处理变得更加高效。在 C++14 中,如果你想避免不必要的字符串拷贝,你需要使用 const std::string& 或 const char*。而 std::string_view 提供了一种更直观的方式:
void print_string(std::string_view sv) {
std::cout << sv << std::endl;
}
int main() {
std::string s = "Hello, World!";
print_string(s); // 不需要拷贝
print_string("Hello, World!"); // 直接使用字符串字面量
return 0;
}这种"字符串视图"的方式让字符串的处理变得更加高效和安全。它不是什么复杂的机制,而是一种让编译器为你处理字符串视图细节的便捷方式。
从 C++14 到 C++17:一个演进的故事
让我们通过一个具体的例子来看看 std::string_view 如何改变我们的编程方式。
假设你需要编写一个函数,接受一个字符串参数。
在 C++14 中,你不得不这样做:
选择一:使用 const std::string&
void print_string(const std::string& s) {
std::cout << s << std::endl;
}
int main() {
std::string s = "Hello, World!";
print_string(s); // 不需要拷贝
print_string("Hello, World!"); // 需要创建临时 string 对象
return 0;
}这种方式虽然可行,但有一个明显的问题:当你传递字符串字面量时,需要创建临时的 std::string 对象,这会带来额外的开销。
选择二:使用 const char*
void print_string(const char* s) {
std::cout << s << std::endl;
}
int main() {
std::string s = "Hello, World!";
print_string(s.c_str()); // 需要调用 c_str()
print_string("Hello, World!"); // 直接使用字符串字面量
return 0;
}这种方式虽然更高效,但需要调用 c_str(),而且不能直接使用 std::string 对象。
而在 C++17 中,你可以使用 std::string_view 来避免不必要的字符串拷贝:
void print_string(std::string_view sv) {
std::cout << sv << std::endl;
}
int main() {
std::string s = "Hello, World!";
print_string(s); // 不需要拷贝
print_string("Hello, World!"); // 直接使用字符串字面量
return 0;
}这里,std::string_view 提供了一种高效的方式来查看字符串,不需要拷贝字符串内容。
std::string_view 的实现机制
理解 std::string_view 的关键在于认识到它本质上就是一个指向字符串的指针和长度。当你使用 std::string_view 时,编译器会生成类似这样的代码:
class string_view {
const char* data_;
size_t size_;
public:
string_view(const char* data, size_t size) : data_(data), size_(size) {}
string_view(const std::string& s) : data_(s.data()), size_(s.size()) {}
const char* data() const { return data_; }
size_t size() const { return size_; }
};这个视图的巧妙之处在于它不拥有字符串的所有权,只是查看字符串的内容。这意味着你可以在不拷贝字符串的情况下查看字符串的内容。
实际应用场景
std::string_view 的真正威力在实际应用中才能充分体现。让我们看看几个常见的应用场景。
场景一:避免字符串拷贝
void print_string(std::string_view sv) {
std::cout << sv << std::endl;
}
int main() {
std::string s = "Hello, World!";
print_string(s); // 不需要拷贝
print_string("Hello, World!"); // 直接使用字符串字面量
return 0;
}场景二:字符串分割
std::vector<std::string_view> split(std::string_view s, char delimiter) {
std::vector<std::string_view> result;
size_t start = 0;
size_t end = s.find(delimiter);
while (end != std::string_view::npos) {
result.push_back(s.substr(start, end - start));
start = end + 1;
end = s.find(delimiter, start);
}
result.push_back(s.substr(start));
return result;
}场景三:字符串比较
bool starts_with(std::string_view s, std::string_view prefix) {
return s.size() >= prefix.size() &&
s.compare(0, prefix.size(), prefix) == 0;
}场景四:字符串解析
int parse_int(std::string_view s) {
int result = 0;
for (char c : s) {
if (c >= '0' && c <= '9') {
result = result * 10 + (c - '0');
}
}
return result;
}这些场景展示了 std::string_view 的通用性:它可以让代码更加简洁,减少拷贝,同时保持高效。
std::string_view 的权衡与思考
std::string_view 虽然强大,但也带来了一些值得深思的权衡。
权衡一:效率 vs 安全性
当你使用 std::string_view 时,你获得了效率,但你也失去了一些安全性。std::string_view 不拥有字符串的所有权,如果原始字符串被销毁,std::string_view 会变成悬空引用。
一个实用的建议是:当字符串的生命周期足够长时,使用 std::string_view;当字符串的生命周期不确定时,使用 std::string。
权衡二:灵活性 vs 复杂性
std::string_view 提供了灵活的字符串视图,但它的使用也有一些限制。例如,你不能直接修改 std::string_view 的内容,也不能直接将 std::string_view 转换为 std::string。
std::string_view 的实践智慧
使用 std::string_view 的艺术在于知道何时使用,何时避免。以下是一些实用的建议:
原则一:避免拷贝
当需要避免字符串拷贝时,std::string_view 是理想的选择。例如:
void print_string(std::string_view sv) {
std::cout << sv << std::endl;
}这里,std::string_view 明确表示"这是一个字符串视图,不需要拷贝",std::string_view 是完美的选择。
原则二:生命周期
当使用 std::string_view 时,确保原始字符串的生命周期足够长:
// 危险:临时字符串的生命周期
std::string_view sv = get_temporary_string(); // 临时字符串在语句结束时被销毁
// 安全:绑定到长生命周期的字符串
std::string s = "Hello, World!";
std::string_view sv = s; // s 的生命周期足够长std::string_view 的优势
与 const std::string& 或 const char* 相比,std::string_view 具有以下优势:
- 更高效:不需要拷贝字符串,也不需要创建临时对象
- 更灵活:可以接受
std::string、const char*或字符串字面量 - 更一致:提供了一致的接口,可以用于所有字符串类型
// 使用 const std::string&(不推荐)
void print_string(const std::string& s) {
std::cout << s << std::endl;
}
// 使用 std::string_view(推荐)
void print_string(std::string_view sv) {
std::cout << sv << std::endl;
}3. 总结
C++17 是一个重要的标准更新,它在 C++14 的基础上增加了许多实用的特性,使得代码更加简洁、易读和高效。这些特性包括结构化绑定、if constexpr、折叠表达式、内联变量、模板参数推导等。
C++17 的设计理念是"让好的设计更容易实现",它简化了许多 C++11 和 C++14 中较为复杂的特性,同时保持了 C++ 的高性能和灵活性。
3.1 C++17 的核心价值
C++17 的核心价值在于它体现了"润物细无声"的设计理念。它不是通过引入惊天动地的新概念来吸引眼球,而是通过让已有的概念变得更加自然、更加易用来提升开发体验。
这种设计理念的价值在于它降低了现代 C++ 的学习曲线。当你学习 C++17 时,你不需要掌握什么全新的概念,你只需要学习如何更自然地使用你已经熟悉的概念。这让 C++17 变得更加平易近人。
3.2 C++17 的实际影响
C++17 的实际影响是深远的。它不仅让代码变得更加简洁和易读,更重要的是,它改变了我们思考问题的方式。
当你习惯了结构化绑定后,你会发现自己不再需要使用复杂的元组解包;当你习惯了 if constexpr 后,你会发现自己不再需要使用复杂的模板元编程;当你习惯了折叠表达式后,你会发现自己不再需要使用复杂的递归模板。
这些改变不是表面的,而是深层次的。它们让现代 C++ 变得更加自然、更加直观。
3.3 C++17 的未来展望
C++17 为 C++ 的未来发展奠定了坚实的基础。它证明了 C++ 可以在不牺牲性能的前提下变得更加易用,在不牺牲灵活性的前提下变得更加简洁。
这种平衡是 C++ 的核心优势,也是 C++ 能够持续发展的关键。C++17 展示了如何在保持 C++ 核心优势的同时,让 C++ 变得更加现代化、更加易用。
3.4 如何使用 C++17
要充分利用 C++17 的特性,你需要:
- 理解设计动机:理解每个特性的设计动机,知道它解决了什么问题
- 掌握实现机制:掌握每个特性的实现机制,知道它如何工作
- 熟悉应用场景:熟悉每个特性的应用场景,知道何时使用
- 注意权衡与思考:注意每个特性的权衡与思考,知道它的优缺点
- 遵循实践智慧:遵循每个特性的实践智慧,知道如何正确使用
通过这种方式,你可以充分利用 C++17 的特性,编写出更加简洁、易读和高效的代码。
3.5 结语
C++17 是一个重要的里程碑。它标志着 C++ 从"功能强大但难以使用"向"功能强大且易于使用"的转变。这种转变不是一蹴而就的,而是通过 C++11、C++14、C++17 等一系列标准逐步实现的。
C++17 的成功在于它证明了 C++ 可以在不牺牲性能和灵活性的前提下,变得更加现代化、更加易用。这为 C++ 的未来发展指明了方向,也为现代 C++ 的发展奠定了坚实的基础。
当你使用 C++17 时,你会发现编程变得更加自然、更加直观。这就是 C++17 的魅力所在——它让好的编程方式变得更加简单。
