C++23:更好的C++20

发布时间:2026/7/7 8:34:57

C++23:更好的C++20 引言到现在为止我们应该已经发现C20 这个版本的规模和 C11 等同甚至更加庞大。一方面它变得更加现代、健壮和安全。另一方面自然也存在很多不足之处。因此就像 C14/17 改进、修复 C11 那样C23 必然会进一步改进 C20 中“遗留”的问题。令人高兴的是C23 标准已经于 22 年特性冻结。除了作为 C20 的补丁它还引入了大量新特性主要是标准库部分成为了一个至少中等规模的版本更新。既然 C23 的特性已经冻结年底发布的正式标准最多只是标准文本的细节差异现在正是一个了解 C23 主要变更的好时机。给你一点提示现阶段各个编译器尚未针对 C23 提供完善的支持。因此对于这一讲涉及的代码主要是讲解性质暂时无法保证能够编译执行。接下来就让我们从语言特性变更、标准库变更两个角度开始漫游 C23 吧。语言特性变更C23 的语言特性变更真的不多不过即使如此也有一些非常亮眼的特性变更比如我们即将了解的几个新特性。显式 this 参数要明白这个语言特性变更得先弄清楚什么是显式 this 参数。让我们来看一下这段代码。#include iostream #include cstdint class Counter { public: Counter(int32_t count) : _count(count) {} Counter increment(this Counter self) { self._count; return self; } template typename Self auto getCount(this Self self) { return self._count; } private: int32_t _count; }; int main() { Counter counter(10); std::cout counter.getCount() std::endl; counter.increment().increment(); std::cout counter.getCount() std::endl; const Counter counterRef counter; std::cout counterRef.getCount() std::endl; return 0; }诶看完代码你是不是有些奇怪为什么 this 关键字会出现在上面的函数参数列表中其实这就是所谓的显式 this。现在我就来解释一下。我们第一次学习 C 和 Java 时可能有一个疑问为什么访问成员变量或成员函数时会出现一个没有定义过的指针—— this。众所周知在一门强类型静态语言中任何符号都需要提前定义那么这个 this 是什么又是从哪里来的呢没错这的确是一个 C 引入的和自身哲学非常不匹配的特性而且持续至今也进一步影响了 Java、C# 和 JavaScript 等现代语言当然 JS 的 this 更加诡异影响不可谓不大。但在同样的一些语言中——比如 Python成员方法必须要在函数列表中显式写出来这也就是 C 中引入显式 this 参数的目的让所有的使用到的符号都需要提前定义。比如前面代码里的第 8 到 12 行如果在 C23 之前我们应该这样写。Counter increment() { _count; return *this; }看完之后你可能更加疑惑了这样不是让代码变得更复杂了吗毕竟我们都已经习惯了使用隐式的 this。那么从前面代码 14 到 17 行我们就可以看到显式 this 的价值了——通过模板让代码变得更简单。这段代码这样写的目的是替代以前的传统写法后面是它的等价实现。const int32_t getCount() const { return _count; } int32_t getCount() { return _count; }我们在需要返回内部引用时即使代码的实现一模一样也经常需要定义一个 const 版本和非 const 版本。这种情况下显式 this 的确帮助我们解决了一个问题——因为模板函数可以根据传入的参数自动匹配参数类型。同时使用显式 this 还可以实现递归 Lambda 函数。我们还是结合代码来理解。#include iostream #include cstdint int main(){ auto fibonacci [](this auto self, int32_t value) { if (value 0) { return 0; } if (value 2) { return 1; } return self(value-1) self(value-2); }; auto result fibonacci(10); std::cout result std::endl; return 0; }可以看到显式 this 让原本很多繁琐的工作变得更加简单了。相信你现在也体会到了这是一个重要的特性变更。多元 operator[]多元 operator 是为了支持标准中引入的多维数组类型而提出的比如后面这段示例就采用了多元 operator[]实现多维数组。#include iostream #include cstdint #include vector #include initializer_list #include concepts #include ranges #include algorithm #include format namespace views std::views; namespace ranges std::ranges; template typename Element class MArray { public: MArray(const std::initializer_liststd::size_t dims): _dims(dims), _size(0) { if (!dims.size()) { return; } std::size_t prevDimSize 1; std::vectorstd::size_t dimSizes; for (auto dim : views::reverse(_dims)) { dimSizes.push_back(prevDimSize); prevDimSize * dim; } ranges::copy(views::reverse(dimSizes), std::back_inserter(_dimSizes)); _size prevDimSize; _elements.resize(_size); } template typename Self, std::integral... Indexes auto operator[](this Self self, Indexes... remainingIndexes) { std::size_t acutalIndex self.calcIndex(0, remainingIndexes...); return self._elements[acutalIndex]; } template std::integral Index std::size_t calcIndex(std::size_t dimIndex, Index firstIndex) { return static_caststd::size_t(firstIndex) * _dimSizes[dimIndex]; } template std::integral Index, std::integral... Indexes std::size_t calcIndex(std::size_t dimIndex, Index currentIndex, Indexes... remainingIndexes) { return static_caststd::size_t(currentIndex) * _dimSizes[dimIndex] calcIndex(dimIndex 1, remainingIndexes...); } std::size_t size() const { return _size; } std::vectorElement elements() { return _elements; } private: std::vectorElement _elements; std::vectorstd::size_t _dims; std::vectorstd::size_t _dimSizes; std::size_t _size; }; int main() { MArrayint32_t array {2, 3, 4, 5}; auto elements array.elements(); for (std::size_t index 0; index ! array.size(); index) { elements[index] static_castint32_t(index * 2); } auto a1 array[1]; std::cout a1 std::endl; auto a2 array[1, 2]; std::cout a2 std::endl; auto a3 array[1, 2, 3]; std::cout a3 std::endl; auto a4 array[1, 2, 3, 4]; std::cout a4 std::endl; return 0; }这段代码其他部分很好理解你可以重点留意代码 35 到 40 行就是一个多参数的 operator[]定义。与以往不同C23 中 operator 可以定义任意数量的参数我们同时使用了显式 this 简化了对 const 的处理。因此我们可以像 75—85 行一样通过[]访问某个元素。这对访问多维数组来说极为方便语法也就和 Python 的 NumPy 中下标访问更像了在数值计算和向量计算中会有大量应用。标准库特性变更相比于语言特性变更C23 中更多的是标准库级别的变更接下来我会带你深入了解几个重要的标准库特性变更。标准模块std 与 std.compact第一个必须提及的变更就是 std 这个标准 Module。C Module 是 C20 中引入的最为重要的特性但我们也知道C20 中的标准库并非设计成简单的 Module而且不同编译器的支持完善程度也相差甚多比如 gcc 模块嵌套层次深了之后标准库的 import 就失效了这导致在 Module 中使用标准库并不是很方便不过标准对此不做强制要求。但在 C23 中就明确设定了名为 std 的模块该模块会将标准库中所有的符号全部引入当前模块这样一来在使用标准库的时候就会非常方便了我们终于再也不用去记忆什么函数在什么库中了。同时所有继承自 C 标准库的符号都被放到了 std.compact 模块中在使用 std::memcpy 等函数的时候就需要 import 这个模块。虽然直接引入 std 和 std.compact 会导入很多的模块但因为编译器实现一定会针对这些标准库模块提供二进制缓存。其实最后的编译速度可能比现在 #include 某个标准库头文件还快得多。因此在 C23 中使用 C Module 会惬意不少——当然前提是编译器能够提供良好支持。expected 与异常处理在了解 C23 Expected 前我们先看一下传统 C 的异常处理方式。一般来说有两种你可以参考后面的表格。可以看出传统的两种异常处理方式不难发现它们的缺点都非常明显比如错误码导致代码冗余容易忽略掉错误处理而通过 try/catch 处理异常又有致命的性能和资源管理问题甚至导致 Google 不提倡采用 try/catch那么是否有更现代化的异常处理方式呢C23 终于仿照 Rust 等语言提出了 expected 类型并通过 Monadic interfaces 实现新的异常处理风格。后面这段代码就演示了如何使用 expected 处理异常。#include iostream #include cstdint #include expected #include fstream #include vector #include string #include filesystem #include numeric namespace fs std::filesystem; std::expectedstd::vectorstd::string, std::errc readListFile(const std::string filePath); std::expectedstd::vectorstd::uintmax_t, std::errc getFileSizes(const std::vectorstd::string fileList); std::expectedstd::uintmax_t, std::errc sumFileSize(const std::vectorstd::uintmax_t fileSizes); int main() { auto result readListFile(ObjectList.txt) .and_then(getFileSizes) .and_then(sumFileSize) .or_else([](auto e) { std::cout Error! std::endl; return std::unexpected(e); }); if (result) { std::cout Result: result std::endl; } return 0; } std::expectedstd::vectorstd::string, std::errc readListFile(const std::string filePath) { std::ifstream inputFile(filePath.c_str()); if (!inputFile.is_open()) { return std::unexpected(std::errc::io_error); } std::vectorstd::string lines; while (inputFile) { std::string line; if (std::getline(inputFile, line)) { lines.push_back(line); } } return lines; } std::expectedstd::vectorstd::uintmax_t, std::errc getFileSizes(const std::vectorstd::string fileList) { std::vectorstd::uintmax_t fileSizes; for (const auto filePath : fileList) { if (!fs::exists(filePath)) { return std::unexpected(std::errc::no_such_file_or_directory); } fileSizes.push_back(fs::file_size(filePath)); } return fileSizes; } std::expectedstd::uintmax_t, std::errc sumFileSize(const std::vectorstd::uintmax_t fileSizes) { return std::accumulate(fileSizes.begin(), fileSizes.end(), static_caststd::uintmax_t(0u)); }看完代码我们发现所有函数的返回类型都变成了 expected这个类型类似于 optional是一个模板类。它包含两个模板参数一个是正常情况的返回值类型另一个是错误码类型。错误码类型类似于 optional包含一个成员函数 has_value 用于判断对象是否包含正常的返回值只不过约定了第二个类型作为错误码。另外expected 类型还提供了 and_then 与 or_else 成员函数。成员函数 and_then 的参数是下一个处理函数该函数一般也会返回 expected 类型。如果 e标题二xpected 对象正常返回那么就会调用 and_then否则调用 or_else。此外expected 的 and_then 可以像 17 到 24 行这样链式调用执行多个业务逻辑时会非常有用。最后的结果包含正常的值就可以调用 value 成员函数获取内部包含的值如代码 27 行。这种风格的异常处理类似于 Rust 等现代语言。其优点是异常处理逻辑清晰可以将异常处理集中在调用链的某些节点实现业务处理和异常处理关注点分离。缺点是缺乏处理分支逻辑的手段同时因为 and_then 等函数采用模板函数实现会造成生成代码膨胀等问题。但无论如何我们的确有了一种现代化的新异常处理手段在数据流的处理场景非常实用。Ranges 扩展Ranges 是 C20 的一大特性但通过前面课程的学习我们也发现了使用 Ranges 的一些问题。适配器依然不够丰富。只有标准库内的适配器闭包对象支持通过视图管道连接开发者自定义的符合适配器闭包对象的类型无法支持视图管道连接需要采用变通的方案。无法将 ranges 简单转换成某种类型的 STL 容器。C 标准委员会也非常清楚这些问题因此在 C23 中对 Ranges 补充了大量支持。首先 Ranges 增加了大量适配器我把它们用表格的方式做了梳理供你参考。有了这些适配器可以让我们的编码变得更加便捷。接着C23 允许使用视图管道连接自定义的符合适配器闭包对象的类型不需要再通过变通方法来实现。最后C23 提供了一个非常实用的转换函数 to它允许我们将视图转换成任意类型的标准容器这其中包括多层嵌套的 range 对象。比如说下面这段代码里我们就将 range 转换成了 vector 数组。#include iostream #include vector #include string #include ranges #include cstdint class Article { public: std::string title; std::vectorstd::string paragraphs; }; std::vectorArticle getArticles(); namespace views std::views; namespace ranges std::ranges; int main() { auto paragraphCounts getArticles() | // 筛选多于3个段落的文章 views::filter([](const auto article) { return article.paragraphs.size() 3; }) | // 将文章转换为段落 views::transform([](const auto article) { return article.paragraphs | views::take(3); }) | // 统计段落长度 views::transform([](const auto paragraphs) { return paragraphs | views::transform( [](const std::string paragraph) { return paragraph.size(); } ) | ranges::tostd::vector(); }) // 转换为vector | ranges::tostd::vector() // 使用join合并 | views::join; for (const auto paragraphCount : paragraphCounts) { std::cout paragraphCount std::endl; } return 0; }在这段代码中我们只需要指定类型to 会帮助我们完成繁杂的转换工作。而且由于 to 本身可以是一个适配器闭包对象因此可以使用视图管道连接使得代码更加赏心悦目。多维数组视图事实上C 标准库一直在解决有关数组的各类问题。比如在 C11 中引入了静态长度的 std::array。再比如在 C20 中引入了 span 作为一维数组视图支持动态长度。在不使用 std::array 的时候我们也能引用数组并存储数组的长度信息完成边界检查。为了便于理解我写了一段代码我们先来看看。#include iostream #include span #include cstdint int main() { int32_t array1[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; std::spanint32_t span1 std::span(array1); std::spanint32_t, 10 span2 std::span(array1); // 等同于std::spanint, 10 auto span3 std::span(array1); // 如果模板参数不包含长度只能在运行时获取长度 //static_assert(span1.size() 10); std::cout span1.size() std::endl; // 如果在模板参数指定长度可以在编译器获取长度 static_assert(span2.size() 10); static_assert(span3.size() 10); return 0; }相较于 C11 和 C20C23 引入了 mdspan 作为多维数组视图。那么我们为什么需要多维数组视图呢这是因为C 中的多维数组支持一直不够完善比如不支持边界检查只能通过 C99 的 VLA 语法指定第一维长度后续维度无法动态扩展等等。如果使用多层指针比如 int***则需要自己循环创建每一层的动态数组还会遇到释放内存的问题。为此我们甚至不得不经常使用一维数组来模拟多维数组比如下面这段代码。#include iostream #include cstdint int main() { int32_t array1[]{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; for (std::size_t row 0; row ! 2; row) { for (std::size_t col 0; col ! 5; col) { // 可以通过C23的多元operator[]自动计算索引访问到元素 std::cout array1[row * 5 col] ; } std::cout std::endl; } return 0; }C23 提出了 mdspan 后就不需要我们自己手动通过一维数组模拟多维数组了。后面这段代码演示了如何使用 mdspan 包装一维数组来模拟二维数组你可以结合代码体会一下。#include iostream #include span #include mdspan #include cstdint int main() { int32_t array1[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; // mdspan可以设定多个维度相当于array[2][5] auto mdspan1 std::mdspan(array1, 2, 5); for (std::size_t row 0; row ! 2; row) { for (std::size_t col 0; col ! 5; col) { // 可以通过C23的多元operator[]自动计算索引访问到元素 std::cout mdspan1[row, col] ; } std::cout std::endl; } return 0; }其实 C23 的 mdspan 还具备更多的特性包括设置动态长度修改数组内存布局也就是元素不一定需要连续存储也支持控制元素访问比如如何检查边界支持原子访问。print 与 format在 C20 中引入的 format 虽然只是一个库特性但是它对解决 C20 之前的文本格式化问题很有帮助。不过format 只能将文本格式化到字符串中这导致在实际输出时我们还是需要通过 C 的输出流来输出字符串——这有些不方便。对于很多其他的高级编程语言来说它们基本都已经支持直接在输出函数中进行文本格式化了这显得 C 实在有些不够“现代”。不过在 C23 中终于引入了 print 和 println接口基本和 Rust 的 print/println 函数一样代码如下所示。#include print #include cstdint #include iostream int main() { std::print({}: {}\n, Name, S1); std::println({}: {}, Name, S2); std::println(std::cerr, {}: {}, Name, S3); return 0; }这个函数的本质是调用 format 生成文本然后将文本直接输出到输出流中。print 和 println 默认会将文本输出到标准输出流 std::cout 中我们可以将其修改为其他的输出流比如 std:cerr 或者 ofstream 等其他的输出流对象中。虽然这个特性看起来很简单但我觉得还是有必要专门了解一下。这是因为这从一定程度上会改变我们输出内容的习惯——也许在十年之后我们就很少能在新的 C 代码中看到使用 输出文本的行为了。堆栈跟踪最后一个特性是 C23 提供的堆栈跟踪库——它终于来了C 从一开始就提出了“异常”这是一种替代 C 语言错误码的异常处理机制。但遗憾的是C 的异常处理能力其实一直有很多缺憾包括后面这三个问题。1. 如果顶层没有 try/catch 程序会直接崩溃可能无法获知任何的异常信息。2. 在 catch 中无法通过 exception 获取抛出异常处的调用堆栈。3. 没有提供现代语言的 finally 等特性需要利用 C 的 RAII 机制实现类似行为还是有点不便。C20 中提出的 source_location 可以帮助我们部分解决第二个问题抛出异常的函数可以在异常中包装 source_location 对象这样 catch 时就可以获取到抛出异常的位置。不过问题在于source_location 只包含抛出异常所在点的信息无法获取调用堆栈信息了解程序是通过什么路径调用到函数的。因此 C23 中终于提供了 stacktrace 库补充了 source_location 不足之处。因此现在我们可以像下面的代码一样抛出异常并 catch 异常。#include stacktrace #include iostream #include vector #include cstdint #include format class StacktraceException : public std::exception { public: // 通过默认构造函数获取创建异常时的堆栈 StacktraceException(const char* message, std::stacktrace stacktrace std::stacktrace::current()) : std::exception(message), _stacktrace(stacktrace) {} const std::stacktrace getStacktrace() const { return _stacktrace; } private: std::stacktrace _stacktrace; }; int32_t visitVector(const std::vectorint32_t values, std::size_t index) { // 如果索引越界那么抛出异常 if (index values.size()) { throw StacktraceException(out_of_range); } return values[index]; } int main() { try { std::vectorint32_t values{ 1, 2, 3 }; std::cout visitVector(values, 1) std::endl; std::cout visitVector(values, 3) std::endl; } catch (const StacktraceException e) { std::cerr std::format(Error: {}\n, e.what()) std::endl; // 通过标准输出流直接输出堆栈标准格式由标准库实现自行决定 std::cerr Standard Stacktrace: \n e.getStacktrace() std::endl; std::cerr Custom Stacktrace: \n; // 自定义输出格式 std::size_t index 0; for (const auto stacktraceEntry : e.getStacktrace()) { std::cerr std::format( {}. {}:{} - {}, index, stacktraceEntry.source_file(), stacktraceEntry.source_line(), stacktraceEntry.description() ) std::endl; } } catch (...) { const auto e std::current_exception(); std::cerr Unexpected exception std::endl; } return 0; }在这段代码中我们可以看出stacktrace 类似于 source_location必须我们自己手动构造。stacktrace 可以视为 stacktrace_entry 的一个序列我们不仅可以通过标准输出流输出 stacktrace也可以通过 stacktrace_entry 的成员函数获取到我们想要获取的信息。不过现在我们必须手动构造一个 exception 类包装 stacktrace_entry还无法在标准的 exception 中获取堆栈。如果想要直接通过 exception 获取调用堆栈可能要等到 C26 中的补充了已有相关提案。如果 C26 能够完善这一点那么 C 的异常处理大概就真的满足一个现代编程语言应该具备的特性了。总结今天这一讲我带你漫游了 C23 标准并从语言特性和标准库特性两个方面介绍了 C23 中比较重要的一些变化。C23 重要的语言特性变更乏善可陈我们着重学习了会给编码习惯带来较大变化的“显式 this 参数”和“多元 operator[]”还了解了它们的使用场景。我们按重要程度梳理一下 C23 中的重要库的变更包括以下几类。极为重大的变更标准的 std 与 std.compact 模块。重要的变更expected、多维数组视图、print、堆栈跟踪。对 C20 的补充Ranges 扩展。由于 C23 标准 23 年底才会正式发布因此现有编译器对 C23 尚未提供完善的支持。现在你可以先做了解在接下来的 2 到 3 年我们或许就能用上较为稳定的、支持 C23 的编译器享受 C23 带来的改变了让我们拭目以待。

相关新闻