C++20 高级编程这个标题,很容易让人第一时间想起 concepts、ranges、coroutines、modules 这些名词。尤其当你在 MCU 开发群里看到有人为了在 Keil 中支持 C++20/23,开始折腾外部 GCC 工具链时,更会感叹:原来这个标准已经不只是服务端和桌面端的“新玩具”,连嵌入式开发者都开始计算迁移成本了。
但以我见过的大量代码评审来看,真正让项目卡住的,从来不是“会不会写协程”或者“认不认识 concept 关键字”。反而是那些看起来非常基础的底层问题:这个对象到底什么时候被构造、什么时候被销毁,拷贝和 move 之后原来的对象还合法吗,string_view底层只是“指针 + 长度”,函数返回它之后是不是已经悬垂了。这些问题没想清楚,C++20 越新,代码越危险。因为它给了你更多“隐藏复杂性”的语法工具,但语言本身的内存模型和对象模型并没有改变。
所以这篇文章不打算把 C++20 新特性像说明书一样平铺一遍。我会从“底层基本功”这个视角切入,挑 concepts、三路比较、span、string_view、constexpr/consteval 这几个真正影响设计思维的机制来讲,配合完整可运行的示例、环境配置和排错思路。目标是让你读完不仅知道这些特性“是什么意思”,还能在自己的项目里判断“什么时候该用、什么时候不该用、用错了会出什么问题”。
作为 C++20 高级编程系列的第 002 篇,本篇更强调“底层视角”,适合三类读者:正准备从 C++11/14 向 C++20 过渡的开发者;写过不少业务代码、但希望加深对对象生命周期和模板机制理解的工程师;以及被新特性名字吸引、却总在真实项目中踩坑的学习者。
1. 这篇文章真正要解决的问题
先做一个判断:C++20 难,难的不是语法,而是它默认你已经理解内存、生命周期、值语义和编译期求值这些底层机制。
很多人学 C++20 时都有一种体验。看官方示例,concept 写法很简单,ranges也比想象中顺手。一旦放进自己的模块里,马上会遇到一类奇怪的问题:为什么模板约束明明满足,编译器还是报错?为什么结构体加了operator<=>之后,比较结果和预期不一致?为什么一个函数返回std::string_view,有时候正常,有时候打印出乱码?
这些问题有一个共同根源:你只看到新特性“表现”出来的能力,没有理解它在底层依赖哪些机制。
拿std::span来说,它看起来像一个轻量容器,但本质上只是一对“指针 + 长度”。你不会因为把函数参数从std::vector<T>&改成std::span<T>,就真的获得了数据所有权或安全保障。如果你没有想清楚“底层数据由谁持有、生命周期到哪里结束”,span就是一颗随时会引爆的悬垂指针。
再拿 concepts 来说,它让模板约束从 SFINAE 那套绕口的写法里解放出来。但概念判断依然发生在编译期,类型仍然要经过完整的模板实例化流程。换句话说,concept 可以让你写出更清晰的编译错误,却不能降低你对“模板到底怎么实例化”的理解要求。
这篇文章真正想解决的,就是这种“新语法与旧基础断层”的问题。连续几个主题会帮你建立一条线:对象生命周期、所有权边界、值语义、编译期与运行期分工。然后再看 C++20 的特性,你会更容易判断它适合放在哪里、边界在哪、坑在哪。
还有一点要提前说明。很多人把“高级编程”理解成“我会的语法比别人多”。其实你去看经典的《C# 高级编程》或者《UNIX 环境高级编程》,它们和入门书的本质区别,从来不是多列了几个类和方法,而是开始讨论对象模型、资源管理、系统边界和异常路径如何塑造代码结构。C++20 的进阶学习也是一样的逻辑。
2. 现代 C++ 的“底层基本功”到底指什么
这一节要先建立一个共同语言,否则后面聊span、聊三路比较时,很容易停留在 API 层,看不清问题本质。
计算机领域有一个经典误区:把“底层”等同于“汇编、内存地址、字节对齐”。这些当然重要,但现代 C++ 的底层基本功,范围要更宽一些。在我看来,至少包含四个层面。
第一个层面是对象模型。每创建一个对象,构造函数、析构函数是什么时候被调用的?对象可能被放在栈上、堆上,也可能被编译器优化掉。函数返回值时,到底有没有发生拷贝,有没有发生省略,move 之后原对象是否仍处在可析构状态?如果你对这些没有判断力,任何高级特性都可能写出未定义行为。
第二个层面是所有权与生命周期。一个指针或引用,底层数据是谁分配的,谁负责释放,调用方和被调用方是否对释放责任达成一致?过去我们用裸指针和注释来管理这件事,后来用unique_ptr、shared_ptr,现在则可以通过std::span、std::string_view这类视图类型来表达“我只借用,不拥有”。
第三个层面是值语义与引用语义。C++ 默认是值语义,拷贝一个对象通常意味着复制完整数据。移动语义出现后,我们又在值语义之上引入了“资源转移”的概念。而引用、指针、视图则让你在不拷贝的前提下访问数据。什么时候应该拷贝,什么时候可以借用,这是设计决策,不只是一个语法选择。
第四个层面是编译期与运行期的分工。模板、concept、constexpr 都是在编译期或求值期发生作用的机制。你写template<typename T>时,编译器会为每种实例化类型生成一份代码。concept 只是提前校验约束,并不是给运行期加了一层检查。若你把“编译期约束”误当成“运行期防御”,思路会从一开始就跑偏。
这四个层面放一起,其实就是 C++ 世界里最常见的“成本分析框架”:它花多少内存、有多少次拷贝、生命周期边界在哪、这段逻辑应该编译期完成还是运行期完成。
为了方便后面展开,用下面这张表把 C++20 常见特性与底层基本功的对应关系梳理一下。
| 底层基本功 | 核心问题 | C++20 相关特性 |
|---|---|---|
| 对象模型 | 何时构造析构、是否发生拷贝与移动 | 三路比较、默认比较 |
| 所有权与生命周期 | 数据由谁持有、何时释放 | span、string_view |
| 值语义与视图语义 | 数据借用还是复制 | string_view、span、concepts |
| 编译期与运行期分工 | 逻辑何时求值、能否编译期做 | consteval、constexpr、concept |
| 模板实例化机制 | 类型如何被替换、约束如何被检查 | concepts、requires 表达式 |
这张表是后面所有章节的索引。你会看到,concepts 不能脱离模板实例化来理解;span/string_view 不能脱离生命周期来理解;三路比较不能脱离默认生成的成员访问顺序来理解。
3. 环境准备:让编译器真正进入 C++20 模式
C++20 已经不是新标准。从主流通用工具链看,GCC 10 以后开始支持大部分核心特性,Clang 10 之后的版本也逐步完善,MSVC 在较新的 Visual Studio 构建工具里通过/std:c++20开启。嵌入式方向用 GCC 交叉工具链时,较新的arm-none-eabi-gcc也能支持-std=c++20,但如果版本太旧,可能需要使用过渡名-std=c++2a,并且要自己处理标准库与启动代码的适配。
环境准备建议你直接用“最小项目”来验证,不要一开始就套到自己几十万行的工程里,否则新特性报错了,你根本分不清是语法问题、标准库问题还是工程配置问题。
3.1 命令行直接编译
先写一个最简单的源文件,比如main.cpp:
#include <iostream> #include <string_view> int main() { std::string_view msg = "C++20 works"; std::cout << msg << '\n'; return 0; }如果你用的是 Linux、macOS 或 Windows 下的 MinGW,可以在终端里直接编译:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o demo main.cpp或者使用 Clang:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -o demo main.cpp如果编译器还是 C++17 模式,std::string_view可能还可以用(C++17 已经有它),但你一旦写 concept 或三路比较,就会报requiresis a C++20 extension 或expected unqualified-id before 'auto'之类的错误。所以请先确认当前默认标准确实是 C++20。
3.2 用 CMake 管理标准选项
在稍微正式一点的项目里,推荐用 CMake 统一管理。一个最小CMakeLists.txt可以这样写:
cmake_minimum_required(VERSION 3.20) project(cpp20_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) add_executable(demo main.cpp) if(MSVC) target_compile_options(demo PRIVATE /W4 /permissive-) else() target_compile_options(demo PRIVATE -Wall -Wextra -Wpedantic) endif()这里有三点值得解释。
第一,CMAKE_CXX_STANDARD写成 20,CMake 会把它转换成编译器对应的参数,编译器支持的情况下等价于手动加-std=c++20。第二,CMAKE_CXX_STANDARD_REQUIRED ON的作用是:如果编译器不支持 C++20,直接报配置错误,而不是静默降级。第三,CMAKE_CXX_EXTENSIONS OFF是为了避免使用 GNU 扩展,让代码保持更好的可移植性。
如果你在老的 CMake 版本里发现set(CMAKE_CXX_STANDARD 20)不生效,先升级 CMake,再检查编译器版本,不要急着怀疑代码写错。
3.3 Windows 下的 MSVC
在 Visual Studio 的工程属性中,把“C++ 语言标准”改成“ISO C++20 标准”即可。命令行编译时对应参数是:
cl /std:c++20 /EHsc main.cpp注意/EHsc是开启 C++ 异常处理模型,很多新特性示例并不会直接需要异常,但标准库某些代码路径可能受异常开关影响。保持默认开启是最省心的方式。
3.4 嵌入式方向:外部 GCC 工具链的思路
如果你在 Keil、IAR 这类 IDE 里工作,发现默认编译器对 C++20/23 支持不够,一个常见方案是给工程配置外部 GCC 工具链,例如自行下载并配置arm-none-eabi-gcc。
这样做确实能获得对 C++20/23 更多特性的支持,但代价是需要自己处理工具链切换后的配套问题:启动文件是否匹配、链接脚本里的堆栈和内存布局是否符合新工具链默认行为、标准库采用哪种实现、是否裁剪异常和 RTTI。这些问题不解决,哪怕代码语法在 PC 上跑通了,烧到板子里也可能异常复位。
一个示意性的交叉编译命令是:
arm-none-eabi-g++ -std=c++20 -mcpu=cortex-m4 -mthumb \ -fno-exceptions -fno-rtti -O2 \ -I./include \ main.cpp startup.cpp -Tlinker.ld -o app.elf具体参数要以你的芯片和工具链版本为准。这里真正想提醒的是:新标准本身不是最大成本,迁移到新工具链后的“工具链适配”才是。
4. concepts:把模板约束变成编译期契约
C++20 的 concepts 常被称为“概念”。它的作用是给模板参数加上约束,让“哪些类型能参与这个模板”这件事从文档和注释变成编译器可检查的规则。
理解 concepts 之前,先看你过去怎么写模板的。一个加和模板可能是这样:
template <typename T> T add_three(T a, T b, T c) { return a + b + c; }这个模板对任何支持+的类型都可用。如果你误传入一个没有+的类型,编译器会在一堆实例化错误中告诉你:无法为某个类型调用operator+。错误信息长且绕。
C++20 concepts 把约束前置化。你可以先定义“什么是可加类型”:
#include <concepts> #include <iostream> #include <type_traits> #include <vector> template <typename T> concept Addable = requires(T a, T b) { { a + b } -> std::convertible_to<T>; }; template <Addable T> T add_three(T a, T b, T c) { return a + b + c; } static_assert(Addable<int>); static_assert(!Addable<std::vector<int>>); int main() { add_three(1, 2, 3); std::cout << "add_three(1,2,3) compiles\n"; return 0; }requires表达式在这里说得很清楚:给定两个T类型的值,要求表达式a + b合法,并且结果可以转换成T。如果T是std::vector<int>,它没有operator+,那么Addable<std::vector<int>>为假。
运行后只会看到一行输出,程序正常结束。如果你把std::vector<int>传给add_three,编译器会给出明确提示:约束未满足。
从底层基本功角度看,concepts 有两个真正重要的性质。
第一,concept 不是“运行期检查”,它是编译期约束。模板实例化仍然会把具体类型代入实现代码中,concept 只是在这一步之前做了一次更清晰的门禁。这并不意味着你可以把不支持的非法操作“等到运行期再报错”,因为模板代码一旦实例化失败,错误仍然发生在编译期。
第二,concept 的价值更多体现在接口表达上。一个函数如果声明成下面这样:
template <NumberLike T> T average(const std::vector<T>& values);读者不需要翻几十行模板实现,就知道这个函数能接受哪些类型。这种自解释能力,正是模板库设计里长期缺失的。所以把 concepts 引入自己的工具库时,可以先从公共模板函数的参数约束做起,而不是把所有模板内部到处加约束。
标准库也提供了一批常用概念:std::integral、std::floating_point、std::same_as、std::convertible_to、std::invocable等。在你自定义 concept 之前,先找标准库里是否存在对应概念,避免重复造轮子。
5. 三路比较:编译器替你生成了什么
C++20 引入的<=>运算符,学名“三路比较”,常被称为“飞船运算符”。它让一个类型可以通过一行默认定义,同时获得<、>、<=、>=、==、!=的完整比较能力。
但你必须清楚,这行默认定义的底层逻辑是什么。
看一个例子:
#include <compare> #include <iostream> #include <string> struct Item { std::string name; int seq; bool operator==(const Item&) const = default; auto operator<=>(const Item&) const = default; }; int main() { Item a{"build", 1}; Item b{"build", 2}; std::cout << std::boolalpha; std::cout << "a < b = " << (a < b) << '\n'; std::cout << "a == b = " << (a == b) << '\n'; std::cout << "(a <=> b) < 0 = " << ((a <=> b) < 0) << '\n'; return 0; }输出结果:
a < b = true a == b = false (a <=> b) < 0 = true为什么会这样?因为默认生成的比较逻辑,是按照成员在类中的声明顺序逐个比较。这里先比较name,两个Item的name都是"build",再比较seq,1 小于 2,所以a < b成立。
底层上,operator<=>并不是什么魔法。默认实现只是为每一个成员做一次三路比较,然后按照字典序合并结果。你手动写一个比较函数时,通常也会做同样的事,只是编译器替你把这些重复代码生成了。
这里有几个实践中容易踩的坑。
第一个坑:成员声明顺序就是比较顺序。如果你把结构体的成员顺序调整一下,排序结果就可能改变。所以不要把比较语义敏感的类成员随便重排。
第二个坑:默认三路比较并不总是返回 “strong ordering”。如果成员里有浮点数,浮点比较在遇到NaN时是不确定的,返回类型可能是std::partial_ordering。你在业务逻辑里如果假设任何两个对象都能严格排序,这种假设就会出错。
第三个坑:不要为了让类支持排序,就把operator<=>定义成一种“看起来合理但对业务没意义”的顺序。比如一个实体类有id和name,你也许只想比较id,却意外把name也纳入比较。更好的做法是显式写出比较逻辑,或把默认比较限制在真正意义完整的轻量值类型上。
从底层基本功角度看,默认比较背后依赖的是对“值类型”的理解。只有当你的类表达的是一个完整值,且所有成员都参与这个值的相等性判断时,默认生成才安全。那些带有缓存字段、资源句柄、或者业务上不参与相等语义的成员,就要谨慎使用默认比较。
6. span、string_view:视图类型与所有权边界
std::string_view是 C++17 引入的,std::span在 C++20 引入。它们解决的问题非常相似:希望在“不拷贝底层数据”的前提下,传递一段连续数据的视图。
你必须把这两个类型当成“非拥有型”类型来理解。所谓非拥有,就是指对象内部只保存“指向数据的信息”,不负责释放数据。
它们的底层结构可以近似理解为:
// 仅示意:实际实现可能有差异 template <typename T> class span { T* ptr; std::size_t size; }; class string_view { const char* data_ptr; std::size_t size; };所以它们很轻量,拷贝成本低,适合作为只读参数类型。但风险也在这里:如果底层数据已经析构,而span或string_view还活着,那它持有的就是一个悬垂的“指针 + 长度”。
看一个安全的正向示例:
#include <iostream> #include <span> #include <string> #include <string_view> #include <vector> void show_span(std::span<const double> values) { for (size_t i = 0; i < values.size(); ++i) { std::cout << values[i] << ' '; } std::cout << '\n'; } bool starts_with(std::string_view text, std::string_view prefix) { return text.substr(0, prefix.size()) == prefix; } int main() { std::vector<double> vec{1.0, 2.0, 3.0}; show_span(vec); std::string s = "modern_cpp"; std::cout << starts_with(s, "modern") << '\n'; return 0; }这里,std::vector<double>可以隐式转换为std::span<const double>,std::string可以隐式转换为std::string_view。这个设计让函数可以统一处理数组、vector、string等连续存储容器,且不会发生数据拷贝。
接下来看危险场景。这句代码是典型错误:
std::string_view sv = std::string("temporary");右边先构造了一个临时std::string,然后又因为赋值语句结束而析构了。此时sv里的指针指向一块已经被释放的内存。如果你后面读取sv,就是未定义行为。
另一个危险场景和vector扩容有关:
std::vector<int> v{1, 2, 3}; std::span<int> s(v); v.push_back(0); // 如果触发重新分配,s 内部指针失效如果push_back导致vector重新分配了内部缓冲区,旧的缓冲区被释放,s仍然指向旧地址。接下来的任何访问都可能读到脏数据或直接崩溃。
最后一个经典错误是把视图类型当成返回值:
std::string_view get_name() { std::string local = "temp"; return local; // 悬垂:local 在函数返回时析构 } std::span<int> get_data() { std::vector<int> v{1, 2, 3}; return v; // 悬垂:v 在函数返回时析构 }从底层看,return local只是把local的内部指针信息复制给了返回值。真正持有字符串数据的local已经析构,于是返回值成了悬垂引用。
那么实际工程里应该怎么用视图类型?我的建议有三条。
第一,它们最适合做函数参数。函数只是读取数据,不修改、不存储,就用std::string_view或std::span<const T>。调用方传入的string、vector、数组都能无缝适配,性能也好。第二,不要作为类成员长期保存,除非你能用严格的生命周期约束证明外部数据一定比这个类活得久。第三,如果返回值可能来自函数内部临时创建的数据,请返回拥有所有权的类型,例如std::string、std::vector<char>,不要硬返回视图。
7. consteval、constexpr 与编译期计算
C++20 在 constexpr 之外增加了consteval。两者都和“编译期求值”有关,但语义不同,容易混淆。
constexpr函数的意思是:这个函数允许在编译期求值,也允许在运行期求值。什么时候走哪条路,取决于调用上下文。如果参数是编译期常量,并且结果被用在需要常量表达式的地方,编译器就会尽量在编译期完成。如果参数只是运行期变量,那它仍然可以作为普通函数在运行期执行。
consteval函数则强制要求:每次调用都必须在编译期完成。如果调用参数不是常量表达式,编译器直接报错。
一个最能体现区别的例子是递归计算斐波那契数列:
#include <iostream> constexpr long long fib(int n) { return n <= 2 ? 1 : fib(n - 1) + fib(n - 2); } consteval long long fib_cs(int n) { return n <= 2 ? 1 : fib_cs(n - 1) + fib_cs(n - 2); } int main() { constexpr long long a = fib(40); // 编译期求值 long long b = fib(20); // 运行期或编译期都可以 constexpr long long c = fib_cs(30); // 强制编译期,OK // long long d = fib_cs(30); // 不报错,因为 30 仍是非常量表达式? // 注意:30 是字面量,天然是常量表达式, // 所以这里其实也会在编译期计算。 std::cout << a << '\n'; std::cout << b << '\n'; std::cout << c << '\n'; return 0; }如果写long long d = fib_cs(num);,其中num是运行期变量,那么编译器会拒绝编译。这就是consteval和constexpr最重要的差别。
从底层基本功角度看,这里要建立的概念是:编译期计算是有成本的。模板实例化、concept 检查、constexpr 求值,都要占用编译时间。大量使用递归 constexpr 函数时,编译时间可能明显上升。你要有一个基本判断:哪些逻辑值得放到编译期?
值得放到编译期的典型场景包括:配置表计算、哈希值计算、需要作为模板参数或数组大小的常量。不值得的场景包括:复杂 I/O、动态内存分配频繁、依赖运行期用户输入的逻辑。consteval适合那些必须在编译期得到结果、否则没有意义的场景,比如生成一个编译期校验过的标识符。
还有一个很实际的建议:如果函数只想支持编译期求值,并且调用处几乎都是常量上下文,优先用constexpr而不是consteval。因为constexpr更宽容,不会让无意间传入运行期变量的代码彻底编译失败。consteval适合作为团队约定和强制约束的工具,要在理解其代价之后再使用。
8. 常见问题与排查思路
写了这么多,下面把环境切换和新特性使用中最常见的几个问题整理成一张表,直接对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 | | --- | --- | --- |