T.TAO
返回博客
/7 min read/Programming

C++ #11 C++17 新特性

#C++#Programming#ComputerSystems

这篇笔记主要写一下 C++17。和 C++14 那种补丁式更新不同,C++17 里有几个特性会实质性改变你每天写代码的方式。

结构化绑定

把一个聚合体、pair、tuple 或数组的成员一次性解包到具名变量:

C++std::map<std::string, int> scores;

// C++14
for (const auto& kv : scores)
    std::cout << kv.first << ": " << kv.second << '\n';

// C++17
for (const auto& [name, score] : scores)
    std::cout << name << ": " << score << '\n';

first / second 这种没有含义的名字终于可以消失了。它对返回多个值的函数同样有效:

C++auto [it, inserted] = scores.insert({"tony", 100});
if (inserted) { /* ... */ }

两点注意:绑定的数量必须和成员数量完全一致,不能只绑定一部分;而且结构化绑定引入的名字不能被 Lambda 捕获(C++20 才放开)。

带初始化语句的 if / switch

C++if (auto it = cache.find(key); it != cache.end()) {
    use(it->second);
}
// it 在这里已经离开作用域

这把变量的作用域收紧到了它真正需要存在的范围,和 for 循环里的初始化语句是同一个道理。写查找、加锁、打开文件这类"先取得再检查"的代码时特别顺手。

if constexpr

模板里的编译期分支,终于不用靠 SFINAE 和标签分发了:

C++template <typename T>
void print(const T& value) {
    if constexpr (std::is_pointer_v<T>) {
        std::cout << *value;
    } else {
        std::cout << value;
    }
}

关键在于未被选中的分支不会被实例化。上面的代码里,当 T 不是指针时,*value 这一句根本不需要合法——普通的 if 做不到这一点,因为两个分支都要通过编译。

这一个特性消灭了大量模板元编程的样板代码。以前需要写两个重载加 std::enable_if 才能做到的事,现在是一个函数里的一个 if

std::optional

表达"可能没有值",不用再靠特殊值或输出参数:

C++std::optional<Config> loadConfig(const std::string& path);

if (auto cfg = loadConfig("app.toml")) {
    apply(*cfg);
} else {
    useDefaults();
}

比起返回 -1nullptr 或者 bool tryGet(T& out)optional 把"可能失败"写进了类型里,调用者无法忽略。

它不分配堆内存——值直接存在 optional 内部,所以 sizeof(std::optional&lt;int>) 通常是 8(4 字节 int + 1 字节标志 + 填充)。

注意 *optopt.value() 的区别:前者在空的时候是未定义行为,后者抛 std::bad_optional_access

std::variant 与 std::visit

类型安全的联合体。相比 C++ #2 里讲的 C 风格 unionvariant 知道自己当前存的是哪个类型:

C++using Value = std::variant<int, double, std::string>;

Value v = 3.14;

std::visit([](const auto& x) { std::cout << x << '\n'; }, v);

std::visit 会根据当前持有的类型派发到对应的处理分支。如果 visitor 没有覆盖全部可能的类型,编译期就会报错——这正是它相对于虚函数继承体系的优势:封闭的类型集合、无堆分配、无虚表,而且加一个新类型时编译器会指出所有需要更新的地方。

配合一个常见的辅助模板可以写成分支形式:

C++template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

std::visit(overloaded{
    [](int i)                { std::cout << "int " << i; },
    [](double d)             { std::cout << "double " << d; },
    [](const std::string& s) { std::cout << "string " << s; },
}, v);

std::string_view

一个不拥有数据的字符串视图:一个指针加一个长度。

C++void log(std::string_view msg);   // 接受 const char*、std::string、字面量,全都零拷贝

log("literal");                   // 不构造 std::string
log(someStdString);               // 不拷贝
log(std::string_view(buf, len));  // 子串也不拷贝

在此之前,一个接受 const std::string& 的函数,传字面量进去会构造一个临时 string(可能堆分配)。改成 string_view 后这个开销消失了。对于任何只读字符串参数,string_view 应该是默认选择。

代价是生命周期完全由你负责:string_view 不延长它指向的数据的寿命。

C++std::string_view bad() {
    std::string s = "temp";
    return s;          // 悬垂:s 在返回后就没了
}

同理,不要把 string_view 存进成员变量,除非你能确定被指向的数据活得更久。

其它值得知道的

类模板参数推导(CTAD)。 构造时不用再写模板参数:

C++std::pair p(1, 2.0);               // 推导为 std::pair<int, double>
std::lock_guard lk(mutex);         // 不用写 std::lock_guard<std::mutex>
std::vector v{1, 2, 3};            // std::vector<int>

内联变量。 头文件里可以直接定义全局变量,不再需要 .cpp 里那行定义:

C++inline constexpr int kMaxUsers = 1024;

struct Config { inline static int instances = 0; };

折叠表达式。 变参模板终于不用递归展开:

C++template <typename... Args>
auto sum(Args... args) { return (args + ...); }   // (a1 + (a2 + (a3 + ...)))

std::filesystem 路径、目录遍历、文件状态进入标准库,终于不用为每个平台写一套。

嵌套命名空间。 namespace a::b::c { } 代替三层嵌套。

[[nodiscard]] 返回值被忽略时警告。对返回错误码或 optional 的函数非常有用:

C++[[nodiscard]] bool tryConnect();
tryConnect();   // 警告:返回值被丢弃

保证的复制省略。 从函数返回一个纯右值时,标准现在要求不产生拷贝或移动。这意味着不可拷贝、不可移动的类型也可以从工厂函数返回了。

小结

如果只挑三个 C++17 特性来用,我会选结构化绑定(可读性)、if constexpr(消灭模板样板)和 string_view(消灭隐式字符串拷贝)。这三个加在一起,就足以让一份 C++11 的代码库读起来年轻好几岁。

本系列文章

C++ 程序设计
  1. 01C++ #1 数据与内存
  2. 02C++ #2 Struct 和 Union
  3. 03C++ #3 指针与数组
  4. 04C++ #4 函数
  5. 05C++ #5 对象与类
  6. 06C++ #6 继承
  7. 07C++ #7 拷贝控制与运算符重载
  8. 08C++ #8 多态
  9. 09C++ #9 C++11 新特性
  10. 10C++ #10 C++14 新特性
  11. 11C++ #11 C++17 新特性