现代 C++ 特性
这一课你将能做到
- 用
auto、范围 for、结构化绑定写出更短的循环与遍历代码,并说出 auto 的推导边界 - 用
std::optional表达「可能没有结果」,替代「返回 -1 / 空指针」的隐式约定 - 用
std::variant+std::visit表达「多种可能结果之一」,理解它和 Java Optional/switch 表达式的对应关系
为什么要学
C++11/17/20 三波更新让 C++ 的书写体验追平甚至部分超越 Java:lambda、auto、范围 for 这些你在 Java 里熟悉的(var、增强 for、lambda)全有了 C++ 版;std::optional、std::variant 更是解决 Java 社区还在用库模拟的问题。
判题沙箱的现代代码里这些特性随处可见:解析提交记录用 optional 表达「这行可能解析失败」,评测结果用 variant 表达「AC / WA / TLE 三选一」,遍历统计用范围 for + 结构化绑定。本课之后,你写的 C++ 就不再是「带类的 C」,而是现代 C++。
先建立图像
同一件事的旧写法 vs 新写法:
旧:for (std::vector<Submission>::iterator it = v.begin(); it != v.end(); ++it)
新:for (const auto& s : v) ← 范围 for + auto
旧:int idx = find(subs, "li"); if (idx == -1) ... ← -1 既是数据又是信号,埋雷
新:auto r = find(subs, "li"); if (r.has_value()) ... ← optional 把「没有」说在类型里
旧:结果码 int verdict,0/1/2/3 全靠注释记
新:std::variant<AC, WA, TLE> result ← 类型系统替你记住所有可能
跟着做
步骤 1:auto——让编译器替你写类型
新建 modern.cpp:
#include <iostream>
#include <map>
#include <string>
#include <vector>
int main() {
auto n = 42; // int
auto d = 3.14; // double
auto s = std::string("AC"); // std::string(auto s = "AC" 推导出 const char*,要小心!)
std::vector<int> scores{60, 87, 95};
auto it = scores.begin(); // std::vector<int>::iterator——没有 auto 就得写全这一长串
std::cout << *it << "\n";
// 整数默认推导成 int;要 64 位就写 auto n = 42LL
return 0;
}
auto 对应 Java 10 的 var,但 C++ 的 auto 能用在更多位置(返回类型、lambda 参数、模板),且没有装箱陷阱——它是纯编译期推导,运行期零差异。
步骤 2:范围 for + 结构化绑定
#include <iostream>
#include <map>
#include <string>
#include <vector>
int main() {
std::vector<std::string> users{"zhang", "li", "wang"};
for (const auto& u : users) { // 值拷贝用 auto u;只读用 const auto&(首选)
std::cout << u << "\n";
}
std::map<std::string, int> costs{{"zhang", 120}, {"li", 45}};
// C++17 结构化绑定:把 pair 拆成两个命名变量
for (const auto& [user, cost] : costs) {
std::cout << user << " 耗时 " << cost << "ms\n";
}
return 0;
}
结构化绑定对 std::pair、std::tuple、struct(按成员顺序)都能拆,Java 的 record 解构(Java 21 模式匹配)对应的就是这个。
步骤 3:lambda——匿名函数
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> costs{120, 45, 200, 88};
int limit = 100; // 捕获外部变量
auto count = std::count_if(costs.begin(), costs.end(),
[limit](int c) { return c > limit; }); // [limit]:按值拷贝
std::cout << "超时个数 " << count << "\n";
int total = 0;
std::for_each(costs.begin(), costs.end(),
[&total](int c) { total += c; }); // [&total]:按引用,能修改外部
std::cout << "总耗时 " << total << "\n";
// lambda 本质是编译器生成的匿名类对象(重载了 operator()),对应 Java 的函数式接口实例
auto square = [](int x) { return x * x; };
std::cout << square(9) << "\n";
return 0;
}
与 Java lambda 的差异:Java 捕获的局部变量必须 effectively final;C++ 允许按引用捕获并修改外部变量,但要自己承担悬垂引用的风险(见第 9 课)。
步骤 4:std::optional——「可能没有」
判题里经典需求:按 ID 查提交,可能查不到。
#include <iostream>
#include <optional>
#include <string>
#include <vector>
struct Submission {
int id;
std::string user;
int cost;
};
// 返回 optional:类型层面声明「可能没有结果」
std::optional<Submission> findById(const std::vector<Submission>& subs, int id) {
for (const auto& s : subs) {
if (s.id == id) return s; // 隐式构造 optional<Submission>
}
return std::nullopt; // 「没有」的标准答案,对应 Java 的 Optional.empty()
}
int main() {
std::vector<Submission> subs{{101, "zhang", 120}, {102, "li", 45}};
auto r = findById(subs, 102);
if (r.has_value()) { // 也可写 if (r)
std::cout << "找到: " << r->user << " " << r->cost << "ms\n";
}
auto miss = findById(subs, 999);
std::cout << miss.value_or(Submission{0, "未知", 0}).user << "\n"; // 兜底值
return 0;
}
对比 Java:Optional<Submission> + isPresent()/orElse() 几乎逐字对应。关键区别:Java 的 optional.get() 不检查就取会抛异常,C++ 的 *r/r-> 不检查就是 UB——取值前必判空的纪律两边都一样。旧写法(返回 -1、nullptr、全局错误标志)的问题都是「信号混进数据」,optional 把语义写进函数签名。
步骤 5:std::variant——「多选一」的类型安全
真实判题还有 TLE、RE、CE。variant 让「一组可能」成为类型:
#include <iostream>
#include <string>
#include <variant>
#include <vector>
struct AC { int cost; };
struct WA { int caseIdx; };
struct TLE { int limitMs; };
using Verdict = std::variant<AC, WA, TLE>; // 三选一
void report(const Verdict& v) {
// std::visit:像 switch 一样按「当前装的是什么类型」分派,全部分支必须覆盖
std::visit([](const auto& result) {
using T = std::decay_t<decltype(result)>; // 取出实际类型的技巧
if constexpr (std::is_same_v<T, AC>) { // C++17 if constexpr:编译期 if
std::cout << "AC,耗时 " << result.cost << "ms\n";
} else if constexpr (std::is_same_v<T, WA>) {
std::cout << "WA,第 " << result.caseIdx + 1 << " 个测试点\n";
} else {
std::cout << "TLE,限制 " << result.limitMs << "ms\n";
}
}, v);
}
int main() {
std::vector<Verdict> results{AC{128}, WA{2}, TLE{1000}};
for (const auto& v : results) report(v);
Verdict v = WA{1};
std::cout << std::get<AC>(v).cost; // 危险写法:装的是 WA 却按 AC 取 → 抛异常
return 0;
}
对照 Java:等价于 sealed interface + switch 模式匹配(Java 21)。variant 保证你不可能拿到第四种状态,也没有「注释与现实脱节」的问题。
讲清楚要点
- auto 是编译期推导,不是动态类型:类型在编译那一刻就定死,运行期与手写类型完全一致。它消灭的是冗长类型名,不是类型系统。
- 范围 for 的底层就是 begin()/end() 迭代器:第 6 课的「迭代器失效」在范围 for 里同样存在——遍历时往 vector 里 push_back 是 UB。
- optional 是值语义的「或无」:内存 = T 的大小 + 一个标志位。
*opt、opt->、opt.value()都要求确有值,安全取值用value_or(默认值)或先has_value()。 - variant 是类型安全的 union:同一时刻持有其中一种类型;
std::get<T>类型不匹配会抛异常,安全取值用get_if或 visit。 - lambda 捕获即闭包成员:
[limit]复制成闭包成员,[&]存引用;按值捕获的副本默认不可改,要改写mutable。
你可能会懵的地方
-
auto x = {1, 2}会推导成什么?std::initializer_list<int>,常见的意外。列表初始化场景别用 auto,直接写明类型。 -
结构化绑定能修改值吗?
auto& [k, v] : m绑定引用就能改(map 的 value 可改,key 不行);auto [k, v]是拷贝,改了也不影响原容器。 -
variant 什么时候比继承多态合适? 类型集合固定且简单(结果状态、消息类型、配置项二选一)、无需运行期扩展新类型时,variant 无虚表开销、分支可穷尽检查。需要「第三方随时注册新子类」的开放扩展才用继承。
-
if constexpr和普通if的区别? 普通 if 两个分支都要编译通过;if constexpr的假分支在模板实例化时直接丢弃,所以result.caseIdx只在 WA 分支出现也能编译。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
auto s = "hello"; | 推导为 const char*,不是 string | auto s = std::string("hello"); 或显式类型 |
不判空就 *opt | 空 optional 解引用 → UB | if (opt) 或 value_or 兜底 |
范围 for 中 v.push_back(...) | 可能触发扩容,迭代器失效 UB | 先收集要加的,遍历完再插 |
[&] 捕获后 lambda 被异步使用 | 原作用域已结束 → 悬垂引用 | 按值捕获 [=] 或明确列出捕获项 |
std::get<AC>(v) 不 try | 类型不符抛异常 | std::get_if<AC>(&v) 返回指针判空,或用 visit |
卡点急救
- 范围 for 报错「no match for operator++」:遍历的对象不是容器(比如 C 数组传参退化成指针后没有 begin/end)。改用指针 + 长度,或换 std::array/vector。
visit编译报错「未处理所有备选类型」:visit 的调用对象必须能接受 variant 的每一种类型。检查 lambda 是否漏了某个分支。- 结构化绑定报「cannot be decomposed」:类型不是 pair/tuple/聚合 struct。先确认成员,或手动
kv.first/kv.second。
面试常问
-
std::optional 解决什么问题?和指针判空的区别? optional 用值语义显式表达「可能有值可能没有」,函数签名即文档,强迫调用方处理空分支;指针判空靠约定,且指针还背负「所有权」歧义(要不要 delete?)。optional 适合「函数结果可能缺席」,不适合表达「对象在别处、我只是引用」。
-
std::variant 与虚函数多态如何取舍? variant:类型集合编译期固定、无堆分配、无虚表开销、visit 可做穷尽性检查;继承:开放扩展、支持跨模块多态。封闭集合选 variant,开放扩展选继承——与 Rust enum、Java sealed + 模式匹配的取舍一致。
-
lambda 的捕获列表有哪些形式?按引用捕获有什么坑?
[v]按值、[&v]按引用、[=]/[&]隐式全捕获、[v = expr]初始化捕获(C++14)。坑:按引用捕获的局部变量若先于 lambda 销毁(异步场景)就是悬垂引用,按值捕获或捕获智能指针规避。
自测三问
[limit]、[&limit]、[&]三种捕获分别什么语义?opt.value_or(x)在 opt 有值和无值时分别返回什么?if constexpr相比普通if在模板里多给了你什么能力?
小结一句话
auto 消灭冗长类型、范围 for 与结构化绑定简化遍历、lambda 表达行为、optional 与 variant 把「可能没有」「多选一」写进类型系统——C++17/20 的语法让 C++ 的表达力对齐甚至反超现代 Java。
延伸
- cppreference:std::optional
- cppreference:std::variant
- 下一课:多线程——std::thread、互斥锁与原子操作。