C++17 新特性

在目前的开源项目与企业级工程中,C++17 是最常选用的最低版本基线。

相比以细节修补为主的 C++14,C++17 在日常语法和标准库工具上迈出了一大步。它不仅让多成员解包和条件判断写起来更简短,还补齐了只读字符串视图、可选值包装以及跨平台文件系统等高频工具。

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() 即可。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号