这篇笔记主要写一下 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();
}
比起返回 -1、nullptr 或者 bool tryGet(T& out),optional 把"可能失败"写进了类型里,调用者无法忽略。
它不分配堆内存——值直接存在 optional 内部,所以 sizeof(std::optional<int>) 通常是 8(4 字节 int + 1 字节标志 + 填充)。
注意 *opt 和 opt.value() 的区别:前者在空的时候是未定义行为,后者抛 std::bad_optional_access。
std::variant 与 std::visit
类型安全的联合体。相比 C++ #2 里讲的 C 风格 union,variant 知道自己当前存的是哪个类型:
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 的代码库读起来年轻好几岁。