在目前的开源项目与企业级工程中,C++17 是最常选用的最低版本基线。
相比以细节修补为主的 C++14,C++17 在日常语法和标准库工具上迈出了一大步。它不仅让多成员解包和条件判断写起来更简短,还补齐了只读字符串视图、可选值包装以及跨平台文件系统等高频工具。
C++17 新特性概览
在 C++17 中,语言语法与标准库都迎来了不少实用更新,日常开发最常遇到的代表性特性如下表所示。
| 代表特性 | 所属类别 / 头文件 | 主要变化与作用 |
|---|---|---|
| 结构化绑定 | 语言特性 | 使用 auto [a, b] 一次解包 pair、tuple、结构体或数组中的多个成员 |
| if / switch 初始化语句 | 语言特性 | 支持在 if 或 switch 条件前定义一个仅限当前语句块使用的局部变量 |
| 类模板实参推导(CTAD) | 语言特性 | 创建类模板对象时可由初始值自动推导类型,如 std::vector v = {1, 2} |
| if constexpr | 语言特性 | 在编译期根据常量条件裁剪代码分支,简化函数模板的分支编写 |
| std::string_view | <string_view> | 提供不拥有内存的轻量只读字符串视图,避免只读传参时的拷贝开销 |
| std::optional | <optional> | 直接表达 “有值” 或 “无值” 两种状态,无需再用 -1 等特殊值表示失败 |
| std::variant | <variant> | 类型安全的现代联合体,可在预设的几种候选类型中安全保存其中一种 |
| std::filesystem | <filesystem> | 内置跨平台的文件路径解析、目录创建、文件复制与删除等操作 |
日常语法改进
在编写日常逻辑和模板代码时,C++17 提供了 3 项非常直观的语法简化,下面我们结合具体示例逐一来看。
1. 结构化绑定与类模板实参推导
在 C++17 中,我们可以使用结构化绑定写成 auto [id, name] = student;,一行代码就能把 std::pair、元组或结构体里的多个成员同时拆出来,不再需要逐个访问 .first 和 .second。
示例 1:结构化绑定解包成员
#include <iostream>
#include <string>
#include <utility>
int main() {
std::pair<int, std::string> student = {1001, "Jack"};
// 一次拆出两个成员
auto [id, name] = student;
std::cout << "学号:" << id << '\n';
std::cout << "姓名:" << name << '\n';
return 0;
}运行结果如下。
学号:1001
姓名:Jack分析:
auto [id, name] = student; 这句代码表示将 student.first 和 student.second 分别绑定到变量 id 和 name 上。后面我们直接按名字读取即可,代码含义清晰直观。
此外,C++17 还支持 “类模板实参推导(CTAD)”。当我们创建 std::pair 或 std::vector 等类模板对象时,编译器可以根据初始值自动推导出尖括号里的类型。比如定义整数容器时,下面 2 种方式是等价的。
// 方式 1
std::vector<int> nums = {10, 20, 30};
// 方式 2
std::vector nums = {10, 20, 30};在方式 2 中,编译器看到花括号里的初始值都是 int 整数,就会自动将 nums 推导为 std::vector<int> 类型,省去了手动书写 <int> 的步骤。
2. 带初始化的 if 与 switch 语句
平时调用查找函数后,我们往往紧接着就要用 if 判断有没有找到目标。如果把接收结果的临时变量定义在 if 外面,哪怕后面再也用不到,它也会一直留在外层作用域里。
在 C++17 中,我们可以在 if 或 switch 的圆括号里先写一条初始化语句,语法格式为 if (初始化语句; 条件表达式),把临时变量严格限制在当前判断语句块内部。请看下面例子。
示例 2:带初始化语句的 if 判断
#include <iostream>
#include <string>
int main() {
std::string url = "https://www.lvyenet.com";
// 先查找子串位置,再判断结果
if (auto pos = url.find("lvye"); pos != std::string::npos) {
std::cout << "找到子串,起始下标:" << pos << '\n';
} else {
std::cout << "未找到目标子串\n";
}
return 0;
}运行结果如下。
找到子串,起始下标:12分析:
在这个例子中,圆括号前半部分的 auto pos = url.find("lvye"); 先查找子串下标,没找到时会返回特殊标记 std::string::npos。分号后面紧接着判断 pos != std::string::npos,确认是否查找成功。
这样一来,变量 pos 只在这个 if...else 语句块内部有效。一旦离开 else 的右花括号,pos 就会自动销毁,不会干扰外层 main() 函数里的其他代码。
3. 编译期条件分支(if constexpr)
普通的 if 语句是在程序运行时才判断条件的。如果我们在函数模板里使用普通 if,编译器会同时检查两个分支的语法。一旦某个分支写了当前类型不支持的操作,比如对整数调用 .size(),哪怕运行时根本不会走到那里,编译阶段也会直接报错。
为了解决这个问题,C++17 引入了 if constexpr 编译期条件分支。编译器会在编译阶段判断常量条件,只保留条件成立的分支,把不成立的分支直接裁掉。下面我们结合 <type_traits> 头文件中的 std::is_integral_v<T> 来看一个例子,它可以用来判断 T 是否为整数类型。
示例 3:编译期裁剪模板分支
#include <iostream>
#include <string>
#include <type_traits> // 提供 is_integral_v
template <typename T>
void showDetail(T val) {
if constexpr (std::is_integral_v<T>) {
std::cout << "整数翻倍:" << val * 2 << '\n';
} else {
std::cout << "文本长度:" << val.size() << '\n';
}
}
int main() {
showDetail(50);
showDetail(std::string("lvyenet"));
return 0;
}运行结果如下。
整数翻倍:100
文本长度:7分析:
在上面例子中,当我们调用 showDetail(50) 时,编译器将 T 推导为 int。由于 std::is_integral_v<int> 在编译期算出 true,编译器只保留第 1 个分支的 val * 2,直接裁掉了 else 分支里的 val.size(),因此不会因为 int 没有 .size() 方法而报错。
当我们传入字符串调用 showDetail(std::string("lvyenet")) 时,条件在编译期为 false,编译器则只保留 else 分支,顺利算出文本长度为 7。小伙伴们如果尝试把代码里的 constexpr 删掉改成普通 if,编译时马上就会报错,大家可以动手试一下。
常用标准库新类型
在标准库方面,C++17 新增了 std::string_view、std::optional 和 std::variant 三个高频工具类型,分别用来解决只读传参拷贝、空结果表达与多类型安全存储问题。
1. 只读字符串视图(std::string_view)
以前我们编写只读字符串函数时,如果把形参写成 const std::string&,传入 "Hello" 这类字符串字面量时往往还会触发一次临时的内存分配与字符拷贝。
定义在 <string_view> 头文件中的 std::string_view 就像一个轻量的 “只读观察窗口”:你可以直接拿它观察已有的字符序列,它自己不分配内存、也不拷贝字符,非常适合用作只读字符串形参。
示例 4:只读字符串视图传参
#include <iostream>
#include <string>
#include <string_view>
// 接收只读字符串视图
void printTitle(std::string_view sv) {
std::cout << "标题:" << sv << "(长度 " << sv.size() << ")\n";
}
int main() {
std::string str = "C++17 Tutorial";
printTitle("Hello"); // 传入字符串字面量
printTitle(str); // 传入 std::string 对象
return 0;
}运行结果如下。
标题:Hello(长度 5)
标题:C++17 Tutorial(长度 14)分析:
在这个例子中,无论我们传入字符串字面量 "Hello" 还是 std::string 对象 str,形参 sv 都只是记录原有字符的起始地址与长度,全程没有复制字符数据。不过要注意,由于 std::string_view 自己不保存字符副本,我们使用时必须确保被观察的原字符串还没有销毁。
2. 可选值包装(std::optional)
当我们编写查找或解析函数时,如果没查到目标,早期代码常习惯约定返回 -1 来表示失败。但遇到零下温度、扣分记录这类 -1 本身也是合法数值的场景,这种约定就很容易引起误解。
在 C++17 中,<optional> 头文件提供了 std::optional,用来直接包装一个 “可能存在、也可能不存在” 的值。有结果时直接返回正常数值,没有结果时返回专门的空状态 std::nullopt。下面我们来看一个例子。
示例 5:包装可能为空的查找结果
#include <iostream>
#include <optional>
// 查找成功返回分数,失败返回空状态
std::optional<int> findScore(int id) {
if (id == 1001) {
return 95;
}
return std::nullopt;
}
int main() {
auto res = findScore(1001);
if (res.has_value()) {
std::cout << "查到分数:" << res.value() << '\n';
}
if (!findScore(9999).has_value()) {
std::cout << "学号 9999 无成绩记录\n";
}
return 0;
}运行结果如下。
查到分数:95
学号 9999 无成绩记录分析:
当我们传入 1001 时,findScore() 返回有效整数 95,先通过 res.has_value() 确认有值,再调用 res.value() 即可取出结果。当我们传入不存在的学号 9999 时,函数返回 std::nullopt 表示无值,判断逻辑一目了然。
注意 当 std::optional 处于无值状态时,直接调用 .value() 会抛出异常导致程序终止。因此在不确定是否有结果时,小伙伴们一定要先通过 .has_value() 判断,或者改用带默认值的 .value_or(默认值)。
3. 类型安全联合体(std::variant)
定义在 <variant> 头文件中的 std::variant 相当于带有类型检查的现代版联合体。比如一个 std::variant<int, std::string> 变量,既可以保存 int 整数,也可以随时切换为保存 std::string 字符串。
示例 6:安全切换多种候选类型
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> data = 1001;
std::cout << "当前整数:" << std::get<int>(data) << '\n';
data = "绿叶网"; // 切换为保存字符串
std::cout << "当前文本:" << std::get<std::string>(data) << '\n';
return 0;
}运行结果如下。
当前整数:1001
当前文本:绿叶网分析:
在上面例子中,变量 data 先保存整数 1001,执行 data = "绿叶网"; 后切换为保存字符串,读取时调用 std::get<类型>(data) 即可。如果读取时指定的类型与当前实际保存的类型不一致,标准库会抛出异常,不会像传统 union 那样读出乱码。
跨平台文件系统:std::filesystem
早期的 <fstream> 头文件只能读写文件内部的数据。如果想要拆解文件路径、判断文件是否存在或创建文件夹,就得去调用不同操作系统各自的底层接口,代码很难跨平台通用。
从 C++17 开始,标准库正式加入了 <filesystem> 头文件,提供了跨平台的 std::filesystem 文件系统库。我们来看一个路径拆解的例子。
示例 7:拆解文件路径信息
#include <filesystem>
#include <iostream>
int main() {
std::filesystem::path p = "data/user/config.json";
std::cout << "父目录路径:" << p.parent_path() << '\n';
std::cout << "完整文件名:" << p.filename() << '\n';
std::cout << "文件主名称:" << p.stem() << '\n';
std::cout << "文件后缀名:" << p.extension() << '\n';
return 0;
}运行结果如下。
父目录路径:"data/user"
完整文件名:"config.json"
文件主名称:"config"
文件后缀名:".json"分析:
在这个例子中,我们把路径交给 std::filesystem::path 对象 p 后,不用再手写字符串查找去切分斜杠和小数点,直接调用 parent_path()、filename()、stem() 和 extension() 就能分别取出父目录、完整文件名、主名称和后缀名。
小伙伴们可能会注意到,直接使用 std::cout 打印 path 对象时,输出外层会自动带上英文双引号,比如 "config.json"。这是为了防止路径包含空格时产生歧义,如果想拿到不带引号的普通字符串,调用 p.filename().string() 即可。
