[{"content":"C++ 的构建问题一直是热门话题，尤其在各种语言论战中，常常被当作反面教材。有趣的是，大多数 C++ 程序员往往是参与到现有系统的维护中，面对的是高度固化、无法改动的构建流程，真正需要从零搭建项目的人反倒是少数。\n这就导致了一个尴尬的局面：当你真的需要从零搭建，想要寻找参考案例时，会发现根本没有所谓的最佳实践 (Best Practice)，只能搜到各种不成体系的 workaround，非常令人沮丧。\nclice 也是一个从零开始的 C++ 项目，当然不可避免地，我们把前人踩过的坑几乎都踩了一遍。最近，我们总算摸索出了一套自认为比较优雅的 workflow。所以，我们想借此机会把这套方案分享一下，顺便科普一些 C++ 构建背后的原理和知识，希望对你有所帮助！\nWhere does complexity come from? 在讨论解决方案之前，我们还是先来分析问题。C++ 的构建复杂度到底从何而来？如果有包管理那么所有问题就都解决了吗？\n我认为复杂度主要来自于两个不同的维度，工具链 (toolchain) 与构建系统 (build system)。\nToolchain 所以什么是工具链？除了编译器和链接器，它还包含更多被多数教程忽略的细节，我们可以通过一个简单的命令来拆解这些概念。\n考虑如下文件，执行 clang++ -std=c++23 main.cpp -o main，即可获得可执行程序\n// main.cpp #include \u0026lt;print\u0026gt; int main() { std::println(\u0026#34;Hello world!\u0026#34;); return 0; } 那首先，第一个疑问，我们都知道对于传统的 C/C++ 编译模型来说，分为编译 (compile) 和链接 (link) 两个过程。先调用编译器编译出中间的 .obj 文件，再使用链接器链接成 executable。为什么这里我们一个命令就搞定了？\n这是因为 clang++ 只是一个驱动程序 (driver)，它会帮你调用编译器和链接器完成全部的工作。如何验证这一点呢？Clang 有一个命令行选项 -### 可以用于仅输出底层要执行的命令，而不执行具体的任务。\n例如，执行 clang++ -### -std=c++23 main.cpp -o main，我的 Linux 环境上输出如下（不重要的信息已使用 ... 省略）：\n\u0026#34;/usr/lib/llvm-20/bin/clang\u0026#34; \u0026#34;-cc1\u0026#34; ... \u0026#34;-triple\u0026#34; \u0026#34;x86_64-pc-linux-gnu\u0026#34; \u0026#34;-resource-dir\u0026#34; \u0026#34;/usr/lib/llvm-20/lib/clang/20\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/include/c++/14\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/include/x86_64-linux-gnu/c++/14\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/include/c++/14/backward\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/lib/llvm-20/lib/clang/20/include\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/local/include\u0026#34; \u0026#34;-internal-isystem\u0026#34; \u0026#34;/usr/x86_64-linux-gnu/include\u0026#34; \u0026#34;-internal-externc-isystem\u0026#34; \u0026#34;/usr/include/x86_64-linux-gnu\u0026#34; \u0026#34;-internal-externc-isystem\u0026#34; \u0026#34;/include\u0026#34; \u0026#34;-internal-externc-isystem\u0026#34; \u0026#34;/usr/include\u0026#34; ... \u0026#34;-std=c++23\u0026#34; ... \u0026#34;-o\u0026#34; \u0026#34;/tmp/main-a82bce.o\u0026#34; ... \u0026#34;main.cpp\u0026#34; \u0026#34;/usr/bin/ld\u0026#34; ... \u0026#34;-dynamic-linker\u0026#34; \u0026#34;/lib64/ld-linux-x86-64.so.2\u0026#34; ... \u0026#34;/usr/lib/x86_64-linux-gnu/Scrt1.o\u0026#34; \u0026#34;/usr/lib/x86_64-linux-gnu/crti.o\u0026#34; \u0026#34;/usr/lib/gcc/x86_64-linux-gnu/14/crtbeginS.o\u0026#34; \u0026#34;/usr/lib/gcc/x86_64-linux-gnu/14/crtendS.o\u0026#34; \u0026#34;/usr/lib/x86_64-linux-gnu/crtn.o\u0026#34; \u0026#34;-L/usr/lib/gcc/x86_64-linux-gnu/14\u0026#34; \u0026#34;-L/usr/lib64\u0026#34; \u0026#34;-L/usr/lib/x86_64-linux-gnu\u0026#34; \u0026#34;-L/usr/lib/llvm-20/lib\u0026#34; \u0026#34;-L/usr/lib\u0026#34; \u0026#34;-lstdc++\u0026#34; \u0026#34;-lm\u0026#34; \u0026#34;-lgcc_s\u0026#34; \u0026#34;-lgcc\u0026#34; \u0026#34;-lc\u0026#34; \u0026#34;/tmp/main-a82bce.o\u0026#34; 可以发现 clang++ 确实在底层分别调用编译器和链接器完成了任务。更值得注意的是，它注入了大量**隐式参数 (implicit flags) **！实际上 toolchain 中常常被忽略的部分就是这些隐式的编译参数。\nGNU 风格的编译器比如 g++ 和 clang++ 往往可以直接调用链接器并注入这些隐式参数。因此构建系统会直接调用它们而不是链接器完成链接，可以通过 -fuse-ld 这种选项来切换 driver 启动的链接器。这也可以解释为什么用 Clang 而不是 clang++ 编译 C++ 程序会有很多 C++ 标准库的 undefined reference。实际上，在很多发行版上，Clang 和 clang++ 都只是 /usr/lib/llvm-20/bin/clang 的符号链接，而这个二进制程序会根据程序名等参数注入不同的隐式参数。\n而 MSVC 风格的编译器比如 cl.exe 或者 Clang-cl 则更倾向于通过环境变量来传递这些隐式的状态（如 INCLUDE、LIB 和 LIBPATH）。因此，在使用这些编译器之前，通常必须先运行 Visual Studio 提供的初始化脚本 vcvarsall.bat 来「激活」当前终端的环境，或者直接在 Developer Command Prompt 中进行构建，否则编译器将因找不到标准库头文件或系统库而报错。这种情况下，构建系统一般也会直接调用链接器来完成链接。\n一个完整的工具链可以认为由工具 (Tools)，运行时库 (Runtime) 和环境 (Environment) 三部分组成。\nTools 就是在构建过程中用到各种工具，包括\nCompiler Drivers：负责调度整个流程，例如 g++ 和 clang++ Translators：真正的编译器和汇编器，负责将 C++ 代码翻译成机器码，如 cc1 和 as Linkers：负责将碎片化的 .o 文件和库文件拼接在一起，例如 ld，lld 和 mold Binutils：负责归档、格式转换、符号处理等辅助工作，例如 ar, objcopy, strip, 和 nm Runtime 就是上面选项里隐式链接的各种库，它们是必不可少的：\nC Runtime (CRT) Startup Objects：即日志中看到的 Scrt1.o, crti.o, crtn.o 等。操作系统加载程序后，跳转的第一个地址通常是 CRT 中的 _start。这些对象文件负责初始化栈、堆、运行全局构造函数（C++ 特性），最后才调用 main。并在 main 返回后执行清理工作 C Standard Library：对应日志中的 -lc。也就是 C 标准库的实现，提供了 malloc, printf, open 等与操作系统内核交互的 POSIX 或系统 API 封装。常见的实现有 GNU 的 glibc 和 musl，Windows 上的 UCRT，还有 LLVM 社区正在开发的 LLVM libc C++ Standard Library：对应日志中的 -lstdc++，它提供 std::vector、std::iostream 等高层 C++ 标准库的实现。值得注意的是，它通常依赖于更底层的 Compiler Support Libraries 来实现异常和 RTTI 等功能，主要的实现有 libstdc++（GCC 标准库）、libc++（Clang 标准库）、MSVC STL Compiler Support Libraries：对应日志中的 -lgcc_s，是一类容易被忽视但至关重要的库，它们主要负责两件事 内建函数 (Builtins)：处理目标 CPU 指令集无法直接支持的操作。例如在 32 位 CPU 上进行 64 位除法，或在不支持浮点的 CPU 上进行软浮点运算，编译器会将这些操作翻译成对 __udivdi3 等函数的调用 语言运行时支持 (Language Runtime Support)：C++ 中一些高级特性的实现，比如 Exception Handling（异常捕获与栈展开）通常由 libunwind 或 libgcc_eh 提供；而 C++ ABI（如 dynamic_cast、RTTI）则由 libcxxabi 或 libsupc++ 提供。在 Windows MSVC 环境下，这些通常被统一封装在 vcruntime140.dll 中 Sanitizer Runtimes：当你开启 -fsanitize=address/thread/memory 时链接的库（如 libclang_rt.asan.so）。它们通过在编译期插入桩代码 (Instrumentation)，并在运行时接管内存分配器 (malloc/free)，利用 Shadow Memory 技术来检测内存越界、数据竞争等未定义行为 Environment 就是编译执行的上下文，包括：\nTarget Triple：对应日志中的 -triple x86_64-pc-linux-gnu。它定义了目标平台的详细「身份」，格式通常为 \u0026lt;arch\u0026gt;-\u0026lt;vendor\u0026gt;-\u0026lt;sys\u0026gt;-\u0026lt;abi\u0026gt;。它决定了编译器生成什么指令集（x86 vs ARM）、使用什么对象格式（ELF vs PE），以及调用约定的细节 Cross Compilation (交叉编译)：这是现代构建中非常重要的概念。当 Host（运行编译器的机器）与 Target（运行产物的机器）不一致时，就是在进行交叉编译。这里的不一致不仅指 CPU 架构（如在 x86 上编译 ARM），也指操作系统甚至 C 运行时库的版本（例如，在运行 glibc 2.35 的系统上编译依赖 glibc 2.17 的产物） Sysroot (System Root)：为了解决交叉编译时的环境污染问题，Sysroot 应运而生。它是一个逻辑上的根目录，模拟了目标机器的文件系统结构。当你指定 --sysroot=/path/to/sysroot 时，编译器会忽略本机系统的 /usr/include，转而去 Sysroot 中寻找依赖 值得注意的是大部分的平台都会有一套默认的工具链，比如 Windows 的 MSVC 工具链，包含编译器，链接器，各种工具以及运行时库的一整套工具。Linux 上的 gnu 工具链，mac 上的 apple Clang 工具链。很多平台还不止一套，Windows 上还有 mingw 工具链，而且所有这些工具链还可以部分切换到 LLVM 的工具链。\nBuild System 解决了单文件的 toolchain 问题，我们通过编译器驱动程序搞定了编译和链接。但现实世界中，项目往往包含成千上万个源文件。构建系统 (Build System) 的核心任务，就是解决如何高效、正确地指挥 toolchain 将这成千上万个文件组装成最终产物。\n我们可以沿着时间线，从「复杂度」演进的视角来审视 C++ 构建系统的发展：\n1. 原始时代：脚本 (Shell Scripts)\n在最早期，构建项目就是写一个 Shell 脚本。逻辑非常粗暴：把所有 .c 文件列出来，写死编译器路径，直接调用。随着项目膨胀，每次修改一行代码都要全量重新编译几百个文件，等待时间从几秒变成几十分钟，开发体验极差。\n2. 构建系统的基石 (1976)\n为了解决重复编译的问题，Stuart Feldman 在贝尔实验室写出了 Make。通过引入了依赖图 (Dependency Graph) 和增量构建 (Incremental Build)。通过对比文件的时间戳 (mtime)，如果 main.cpp 的修改时间晚于 main.o，那就重编，否则跳过。这一简单的规则奠定了构建系统的基石。\n3. 移植性危机 (1990s)\n90 年代操作系统百花齐放（Solaris, HP-UX, Linux, BSD, Windows）。Make 虽然解决了自动化，但 Makefile 是不可移植的。不同 OS 的 Shell 命令、编译器参数、库路径完全不同。世界分裂成了两派：\nUnix 阵营 - Autotools (GNU)：著名的 ./configure \u0026amp;\u0026amp; make。它的核心思路是”探测”——在构建前运行大量脚本扫描系统环境（有没有 unistd.h？libz 在哪？），然后动态生成适配当前系统的 Makefile。 IDE 阵营 (Visual Studio / Xcode)：Windows 和 Mac 选择了另一条路——将构建系统与编辑器深度绑定。Visual Studio 的 .sln 和 Xcode 的 .xcodeproj 提供了开箱即用的体验，但代价是牺牲了自动化和灵活性，且完全无法跨平台使用。 4. 真正的跨平台 (CMake, 2000s)\n随着开源软件爆发，代码需要同时在 Linux 服务器和 Windows 桌面运行。为了结束”维护两套构建脚本”的噩梦，CMake 诞生了。CMake 不是构建工具，它是一个元构建系统 (Meta-Build System)，或者叫构建系统生成器 (Generator)。开发者编写抽象的 CMakeLists.txt，CMake 负责将其”翻译”成各平台的原生方言——在 Windows 上生成 .sln，在 Mac 上生成 .xcodeproj，在 Linux 上生成 Makefile。\n5. 现代工程化：规模与可复现的挑战 (2010s - Present)\n进入移动互联网与云原生时代，巨头（Google/Meta）的代码仓库膨胀到亿行级别（Monorepo），多语言混合编程成为常态。新的场景当然会带来新的问题：\n构建速度：Makefile 的解析速度太慢，且不支持分布式。我们需要将编译任务分片发送到集群，也就是所谓的分布式构建（Distributed Build），并且实现远程缓存 (Remote Caching)——如果同事 A 已经编译过 base_lib，同事 B 就应该直接下载缓存，而不是消耗本地 CPU 重新编译 环境一致性 (Hermetic Build)：本地能过，CI 或者其他机器挂了。这是现代开发最大的痛点，通常源于使用了宿主机系统目录（如 /usr/include）下版本不一致的依赖。现代构建追求密封性 (Hermeticity)——构建过程必须像运行在沙盒中，严格禁止访问未声明的系统库，确保可复现构建 (Reproducible Build) 多语言混合 (Polyglot)：一个现代项目往往 C++ 做后端，Python 做胶水，Rust 做安全组件，前端是 TypeScript。CMake 处理非 C/C++ 语言非常痛苦 依赖管理 (Dependency Management)：一个项目无论大型或者小型，往往都需要引入第三方库。然而 C++ 长期缺失像 Rust Cargo 或 Node npm 那样统一的包管理器。开发者不得不手动处理源码下载、编译参数匹配（Debug/Release, Static/Shared）以及复杂的 ABI 兼容性问题。传统的 git submodule 或系统级包管理器（如 apt/brew）在跨平台和多版本并存场景下往往力不从心 为了解决这些问题，很多新的工具涌现了出来：\nNinja：新的构建后端，Make 的替代者，极快的构建速度 FetchContent/Conan/vcpkg：旨在降低 CMake 引入依赖的难度 ccache/sccache：基于编译输入（编译器版本/参数、预处理结果等）计算 cache key，实现跨项目/跨机器复用（sccache 还能做远程 cache） distcc/icecream：分布式构建，将编译任务分发到其他机器 Bazel/Buck2：google 和 meta 基于内部场景编写的构建系统，在沙盒中执行构建，自带编译缓存，实现了良好的密封性和跨语言支持 Meson/XMake：内置包管理的现代构建系统，使用 python like dsl/lua 作为构建语言，旨在比 CMake 提供更高的易用性 Summary 到这里可以回答最开始的问题了，C++ 的构建复杂度从何而来？其实就来源于自由度带来的组合爆炸。C++ 有这么多的工具链，同时还有这么多的构建系统。很容易出现这套构建系统的配置在我的工具链能跑，换一套工具链就出错的情况。再加上各种隐式编译参数可能将问题藏匿其中，你可能根本没意识到。不过现在你大概有一个直观的认知了。\nPurpose 现在，我们可以来正式的讨论 clice 的构建问题了。首先要明确目标，我们想要达到什么样的目标？我们希望有如下三套环境用于构建。\nDevelop：用于开发者本地进行开发，我们希望本地构建/编译速度尽可能快，减少因为等待编译而打断开发的次数。同时确保保留调试信息，能方便的使用调试器进行调试。确保开启 address sanitizer 这类消毒器，尽早捕获开发过程中产生的错误 CI：用于在 GitHub Action 这类平台上自动构建，运行单元测试/集成测试保证可靠性。同样我们希望构建/编译速度尽可能快。尽可能测试不同的平台/环境，防止因为意外依赖一些平台特性，导致崩溃等情况。同时希望能保持和 Develop 的环境一致，能在本地复现 CI 中的错误 Release：用于构建最终分发的二进制产物，我们希望产物的速度尽可能快，确保使用 LTO 来进行构建。希望在程序 crash 的时候在日志中打印出函数调用栈，用户在提 issue 等时候方便定位现场。同时分发给用户的程序二进制尽可能小，可以将调试信息剥离成单独的文件（这大概可以减少 2⁄3 的程序体积），在有需要的时候，再去根据相对地址来获取对应的符号，还希望运行时依赖尽可能少，会静态链接整个程序 首先考虑 clice 的构建依赖，目前有 llvm, libuv, spdlog, toml++, croaring, flatbuffers 和 cpptrace。它使用 C++23 构建，依赖高版本的 C++ 编译器。并且在不同平台上使用不同的 C++ 标准库：\nWindows: MSVC stl Linux: libstdc++ macOS: libc++ 可以发现，clice 的依赖其实并不多，依赖管理的复杂度并不高。我们有两套构建系统，CMake 使用 FetchContent 来管理这些依赖。xmake 则通过自带的包管理 xrepo 来管理这些依赖。由于我们的依赖数量并不多，所以这里的复杂度并不高。CMake 和 xmake 都支持拉取源码并在本地现编现用（从源码构建依赖），可以满足我们对构建一致性的需求。并且大部分依赖的源文件数量都很少，对构建速度没有什么影响，除了 LLVM！\nPrebuilt Libraries clice 依赖 Clang libraries 来解析 AST，即使只构建需要的 target，要构建的文件数量也多达 3000。在 GitHub CI 上构建需要平均两小时，而我们想要尽可能快速的 CI，需要考虑优化 LLVM 的构建速度，可以很容易想到两种方式：\nGitHub Action 支持 cache，我们可以使用 ccache 缓存 LLVM 的构建结果在不同的 workflow 之间复用。但是这种方式并不稳定，尤其是 LLVM 的构建结果需要占用大量磁盘空间，很容易将 GitHub 的缓存占满 提前编译好 LLVM 并将二进制发布在 GitHub Release，在构建的时候去下载就好了，这样不仅 CI 构建可以使用，如果有用户想要本地编译开发 clice 也可以使用 在一开始我们使用 GitHub Action 进行缓存，踩了坑之后果断切换到了维护预编译二进制的方案。但是构建预编译二进制并不是一件简单的事情，最大的问题就是 ABI 兼容性，C++ 的工具链/构建参数组合非常多，有很多选项会影响 ABI。关于 C++ ABI 的讨论可以参考 彻底理解 C++ ABI。\n我们要支持 Windows，Linux，macOS 三个平台，每个平台要构建三种不同版本的产物以满足我们的需求：\nDebug + Address Sanitizer 尽早的暴露代码中的 undefined behavior ReleaseWithDebInfo 测试开启优化下的代码行为 ReleaseWithDebInfo + LTO 用于构建最终的二进制产物 其中 address sanitizer 依赖 compiler-rt，不同版本的 compiler-rt 不能混用，不同编译器的更不能，这就要求我们得锁死编译器版本。同时还有著名的 glibc 版本问题，在高版本 glibc 上构建的程序会由于依赖高版本的 glibc 符号无法运行在低版本的 glibc 上。而我们的 C++ 编译器版本又很高，一般支持它们的 Linux 发行版的 glibc 版本也很高，比如 ubuntu 24.04，如何解决预编译二进制 glibc 版本的问题？同时还要确保 CI 环境与本地开发环境一致。为了能够优雅的解决这个问题，我们进行了很多探索。\nExploration 首先静态链接 glibc 是非常不推荐的行为，原因很复杂，可以参考 Why is statically linking glibc discouraged? 这个讨论。与之相对的，另外一个 C 标准库 musl 对静态链接就十分友好，但是使用它也并非易事，也需要从头构建 C++ 标准库，runtime 等工作，还可能有潜在的性能下降，为了解决主流 Linux 发行版的问题，我们仍然先尝试解决 glibc 的问题。\nDocker 首先最容易想到的方案就是 Docker 了，借助 Docker 我们理论上可以统一不同平台的开发环境，只需要为每个平台提供对应的装好所有依赖的 Docker 镜像就好了。但是由于我们环境的特殊性，依赖高版本的 C++ 工具链，但是又想要低版本的 glibc，所以现成的 Linux 发行版的 C++ 工具链我们不能使用，因为他们的 libstdc++ 是使用高版本的 glibc 编译的。怎么解决呢？\n一开始的解决方案是找一个低版本的 glibc，然后自己编译出低版本的 glibc。再使用这个低版本的 glibc 去编译高版本的 libstdc++，然后使用这两个产物去编译 LLVM 和 clice。我觉得这个方案复杂度太高了，容易出问题，而且我们对 glibc 和 libstdc++ 的编译选项都不太熟悉，可能会踩到一些坑。\n除此之外，Docker 最大的痛点在于其原生跨平台体验不佳（尤其是在 Windows/macOS 上依赖虚拟机）。clice 要在 Window, Linux, macOS 三个平台上编译\u0026amp;运行，如果要维护镜像，肯定得这三个平台各一份，而我们又会经常更新工具链的配置和版本，导致构建镜像可能非常频繁，这样维护成本就变得非常高。根据我的观察，大部分用 Docker 来管理开发环境都是仅 Linux 的场景，也就是不考虑跨平台，那这种情况相对来说负担会轻很多。\n总之这个方案理论可行，但是因为叠加成本太高，被我否决了，我打算去看看有没有其他更轻量的方式。\nZig zig 是一门新兴的编程语言，定位是 better C。为了增强与 C/C++ 的互操作性，zig 在源码层面直接集成了 Clang，通过 zig cc/zig c++ 这两个命令，你可以将 zig 作为 C/C++ 编译器使用。并且 zig 直接将各个 target 的 sysroot 集成到了安装包中，使得我们能极为方便的进行交叉编译，可以在 zig-bootstrap 来获取各个 target 的 支持情况。例如，使用如下命令进行交叉编译\nzig c++ -target x86_64-linux-gnu.2.17 main.cpp -o main 生成的 main 就是使用 glibc 2.17 编译出来的了，无需任何额外的设置，这简直太方便了。由于 zig C++ 就是 Clang 的包装器，所以也是能用于编译 clice 的，这意味着我们有望通过 zig 来统一不同平台的开发环境的同时解决 glibc 的版本问题。\n然而在实际尝试之后，我失败了，主要原因有如下几点：\nzig 把所有 glibc 版本的头文件都打包在一起，然后通过宏来控制是否使用某些头文件。但是 C++17 支持了使用 __has_include 来检测一个头文件是否存在，一个在低版本 glibc 本不应该存在的头文件，在 zig 打包的头文件里面却会存在。导致 __has_include 误判，进而导致编译失败 zig 同样直接集成了 libc++ libunwind libcxxabi 等 LLVM 生态的 runtime，并现场编译使用，我尝试了各种方法想换到其他 runtime 都不行。后面看了源码，它直接强制注入编译参数，并且目前确实没有提供方法让你修改 clice 在未来自身支持 C++20 module 的特性后，也打算将源码迁移到 module 上。而 zig 并不支持使用 import std，由于它强制隐式以非 module 的方式构建 libc++，我无法控制它使用我构建的 libc++ 模块 zig 目前还不支持 Windows-MSVC 的交叉编译，并且 macOS 上强制使用自己的链接器，目前开启 LTO 会直接在命令行解析阶段报错 总之就是踩了很多坑，为了未来考虑，最后也是不打算使用 zig 了。不过如果你没遇到我们这些问题的话，还是可以用的，zig cc 确实是一个非常方便的交叉编译工具，尤其是当你需要将代码发布到多个不同平台的时候。但是 clice 其实并没有强烈的交叉编译需求，所以这点优势还不足以覆盖上面我们遇到的问题。\nPixi 于是我仔细思考了我们的问题，现在的主要难点就是 Linux 上低版本的 glibc 和高版本的编译器之间的冲突。自己构建又太麻烦，如果有人专业的人构建好了，那我们直接拿来用不就解决了？抱着这样的想法，我开始搜索是否存在这样的东西，AI 告诉我可以使用 micromamba，它使用 conda-forge 的包，上面的软件大部分都是基于 glibc 2.17 编译的。\nconda？我对它的印象只有在 Windows 上使用 Anaconda 来装深度学习的依赖，装了很久很久，启动还很慢。我还被告知过不要在公司里使用 conda，它是收费的。总之全是坏印象，难用还收费。但是抱着试一试的态度还是安装了一下，发现确实，它上面有 sysroot_linux-64 的包，直接指定版本 ==2.17 就可以获取低版本的 glibc 了。而且它环境里面的高版本编译器会自动使用这个 sysroot，无需额外的参数配置，和 zig 有异曲同工之妙，都是开箱即用的。\n仔细看了看 Anaconda 的收费 政策，conda 软件本身是开源的，conda-forge 这类由开源社区维护的 channel 上的包也是免费的，只有在使用默认的官方 default 源的时候才对商业公司收费。可以在这个博客 Towards a Vendor-Lock-In-Free conda Experience 找到相关的讨论。\n更进一步，我发现了 pixi，这是一个基于 conda-forge 的包管理器。它允许声明式的方式来安装 package。然后仔细查看了 conda-forge 的包，发现 Windows，Linux，macOS 的包都很齐全。于是我立马想到我们可以用 pixi 来统一不同平台的开发环境！同时解决 glibc 的问题。\n编写如下的 pixi.toml 描述文件：\n[workspace] name = \u0026#34;clice\u0026#34; version = \u0026#34;0.1.0\u0026#34; channels = [\u0026#34;conda-forge\u0026#34;] platforms = [\u0026#34;win-64\u0026#34;, \u0026#34;linux-64\u0026#34;, \u0026#34;osx-arm64\u0026#34;] [dependencies] python = \u0026#34;\u0026gt;=3.13\u0026#34; cmake = \u0026#34;\u0026gt;=3.30\u0026#34; ninja = \u0026#34;*\u0026#34; clang = \u0026#34;==20.1.8\u0026#34; clangxx = \u0026#34;==20.1.8\u0026#34; lld = \u0026#34;==20.1.8\u0026#34; llvm-tools = \u0026#34;==20.1.8\u0026#34; compiler-rt = \u0026#34;==20.1.8\u0026#34; [target.linux-64.dependencies] sysroot_linux-64 = \u0026#34;==2.17\u0026#34; gcc = \u0026#34;==14.2.0\u0026#34; gxx = \u0026#34;==14.2.0\u0026#34; 使用 pixi shell 激活环境，即可在这三个平台上自动安装上面的 package。同时在 Linux 上自动安装低版本的 glibc sysroot 和高版本的 libstdc++，一个如此轻量级的工具，统一了不同平台的开发环境。这才是我心目中的完美解决方案！比 Docker 好太多了。\n不仅解决了工具链一致性问题，pixi 还有很多其他锦上添花的实用功能，首先它同样能用于管理 Python 依赖（通过源码集成 uv 来管理 pypi 的依赖），而 clice 刚好使用 Python 进行一些集成测试，也能使用 pixi 顺便管理了（在这之前，我们还使用 uv 来安装和管理 Python，虽然 uv 挺好用的，但是如果能用一个工具搞定，我们不想装第二个）。\n[feature.test.pypi-dependencies] pytest = \u0026#34;*\u0026#34; pytest-asyncio = \u0026#34;\u0026gt;=1.1.0\u0026#34; pre-commit = \u0026#34;\u0026gt;=4.3.0\u0026#34; 除此之外，它拥有基于 deno_task_shell 的非常灵活的 task runner。之前我总是编写一些本地的 shell 脚本来方便我本地开发，但是从来不传到仓库里，因为没法在 Windows 上用。现在通过 pixi 的 tasks 可以很方便的定义一些跨平台的方便任务，也方便其他开发者使用，比如构建，运行单元测试，集成测试之类的。\n[tasks.ci-cmake-configure] args = [\u0026#34;build_type\u0026#34;] cmd = [\u0026#34;cmake\u0026#34;, \u0026#34;-B\u0026#34;, \u0026#34;build\u0026#34;, \u0026#34;-G\u0026#34;, \u0026#34;Ninja\u0026#34;, \u0026#34;-DCMAKE_BUILD_TYPE={{ build_type }}\u0026#34;, \u0026#34;-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain.cmake\u0026#34;, \u0026#34;-DCLICE_ENABLE_TEST=ON\u0026#34;, \u0026#34;-DCLICE_CI_ENVIRONMENT=ON\u0026#34;, ] 除此之外，pixi 还支持灵活的环境组合，可以轻松为不同的环境定义不同的依赖，总之就是非常契合我们的需求。于是我立马使用 pixi 开始管理 clice 的开发环境。在能轻松的保证本地环境和 CI 环境一致之后，构建预编译二进制也就不是什么难事了。于是终于 clice 可以支持在 glibc 2.17 的操作系统上运行了。\nSummary 这篇文章主要讨论了 C++ 构建复杂度从何而来，以及在通过预编译二进制来加快 CI 构建速度的时候所遇到的一系列与工具链版本有关的构建问题，最后在不断地试错后发现可以使用 pixi 来锁死工具链版本从而降低复杂度。这套 workflow 的关键就是使用 pixi 来创建可复现的构建环境，实际的构建和包管理还是交给 CMake/xmake 来完成。现在开发者可以轻松的复现 CI 环境，而 CI 的可靠性我们早就在无数次的测试中保证了，于是他们可以非常快速的配置环境进行开发，也降低了新的开发者贡献的门槛。\n现在在 Linux 上可以做到不依赖任何系统里的工具链，全部使用 pixi 安装的工具链进行编译，可以说是完全可复现了。\n但值得注意的是 Windows 和 macOS 由于 SDK 许可证的问题，并不是可分发的，目前还需要开发者电脑上装了相关的开发工具。也就是说，如果要在这两个平台上编译的话，开发者必须自行安装并配置系统原生的构建工具链 (如 Windows 的 MSVC/Windows SDK 或 macOS 的 Xcode Command Line Tools)。这个问题暂时没有完美的替代方案。或许等到 LLVM libc 正式发布并成熟后，我们可以统一切换到 LLVM 全套工具链，从而通过工具链自举来彻底消除对操作系统原生 SDK 的依赖。但在另一方面，这两个平台拥有更优异的 ABI 稳定性和 libc 兼容性。与 Linux 上常见的 glibc 版本依赖问题不同，Windows 和 macOS 即使在最新版本的系统上进行构建，通常也只需简单的配置，即可让产物兼容较低版本的操作系统。\n那么 pixi 是银弹吗？显然不是。实际上它的隔离性和可复现性并不如 Docker 或者 nix 这样的方案，毕竟只是基于环境变量做了一些隔离。如果有人在构建脚本里硬编码了系统里的依赖，或者修改系统里的配置，那 pixi 当然是无能为力的了。但这是我们在易用性和可复现性之间的 trade-off，能以较低成本实现如此高的跨平台可复现性已经相当值得。\n另外一点是，很多 C++ 开发者在意的包管理器话题在文章里却寥寥几笔带过，为什么呢？如前文所说，这里已经有了很多的 C++ 包管理工具，但是包管理的可用性取决于是否有足够可靠的人打包。C++ 混乱的工具链和构建系统注定了这样的结局，中心化的仓库一定无法满足大家形态各异的需求。不过对于个人开发来说使用 xmake 这样的工具已经十分够用了。\n我个人的观点是，虽然中心化的包管理不太现实，但是指定一些标准来降低不同生态之间互相沟通的成本是非常可行并且有巨大价值的。举个例子，很多开发者在写构建系统的时候可能根本没有考虑不同工具链的问题，添加编译选项也都是硬编码的，换了一个工具链就出错了。遇到这种情况，打包的人就只能 patch 构建系统来解决，效率很低。如果这里有某种标准化的工具链，内容很简单就是主流工具链的交集，你想添加一个功能，比如开启 sanitize，不是通过直接在 CMake 字符串里添加编译选项，而是有一个标准化的接口，自动根据工具链不同选择不同的开关，那样不是很方便吗？\nxmake 中其实有 toolchain 的抽象，也有一些 set_policy 可以实现我上面提到的效果，虽然并不多。不过我想说的是这其实是一个上下游共同努力的过程，仅靠构建系统侧做抽象很容易遇到一些 corner case，这时候就需要上游能够及时修复相关工具链的错误了。\n类似的，虽然包管理不能做到中心化的，但是不同构建系统的包是否能互相方便的使用呢？其实没什么难度，C++ 主要的引用方式还是 include + lib，很简单，关键是提供一些额外的元信息，确保 package 的可用性。目前有这样的标准存在 Common Package Specification (CPS)，但是并不被 C++ 社区广泛承认。\n","permalink":"https://www.ykiko.me/zh-cn/articles/1985940996270339378/","summary":"\u003cp\u003eC++ 的构建问题一直是热门话题，尤其在各种语言论战中，常常被当作反面教材。有趣的是，大多数 C++ 程序员往往是参与到现有系统的维护中，面对的是高度固化、无法改动的构建流程，真正需要从零搭建项目的人反倒是少数。\u003c/p\u003e","title":"打造优雅的 C++ 跨平台开发与构建 Workflow"},{"content":"Why do we need AOT for CuTe DSL? CUTLASS C++ 是一个用来编写高性能 CUDA 算子的库，以复杂难学著称。为了降低学习成本，NVIDIA 推出了基于 Python 的 CuTe DSL。使用 Python 而不是 C++ 模板来进行元编程具有很多好处，首先就是用户不必和 C++ 那些晦涩难懂的模板报错作斗争了，这对于 C++ 初学者来说是非常头疼的一件事情，现在他们可以专注于代码逻辑。另外，nvcc 编译很慢，而其中大部分时间是花在编译器前端，也就是解析 C++ 代码上。尤其是对于 CUTLASS 这样 template heavy 的库，主要的时间都花在处理模板实例化上了，使用 CuTe DSL 可以绕过这个问题。相比于使用 CUTLASS 的 C++ 代码，它编译的速度能提升几十甚至上百倍。除此之外，现在算子和单测都可以一起在 Python 里写了，也方便了很多。\n使用 Python 来编写原型是很好的，但是在部署推理服务时，我们希望依赖尽可能简单，像 Python 那样要装一大堆随时可能因为版本问题导致崩溃的依赖就不好了。如果能把使用 CuTe DSL 编写的算子编译成 library 供 C++ 代码调用就好了。这正是我们想要为 CuTe DSL 支持 AOT 的目的。\nExport Binary CuTe DSL 在 v4.3 添加了导出编译好的 kernel 对应的 ptx 和 cubin 的选项。设置下面几个环境变量即可\nexport CUTE_DSL_KEEP_PTX=1 export CUTE_DSL_KEEP_CUBIN=1 export CUTE_DSL_DUMP_DIR=/tmp 直接访问 kernel 对应的 __ptx__ 或者 __cubin__ 属性，即可获取对应的值：\ncompiled_foo = cute.compile(foo, ...) print(f\u0026#34;PTX: {compiled_foo.__ptx__}\u0026#34;) with open(\u0026#34;foo.cubin\u0026#34;, \u0026#34;wb\u0026#34;) as f: f.write(compiled_foo.__cubin__) 所以现在我们有了算子对应的 cubin 文件，剩下的问题就是：\n如何在 C++ 代码中加载 cubin 格式的算子 如何把 cubin 文件嵌入到 C++ 代码中一起编译成 library 生成 h 头文件供下游用户调用 CUDA Driver API 对于问题 1，我们可以调用 CUDA Driver API 来实现。\nCUresult CUDAAPI cuModuleLoadData(CUmodule* module, const void* image); CUresult CUDAAPI cuModuleGetFunction(CUfunction* hfunc, CUmodule hmod, const char* name); 通过 cuModuleLoadData 加载 cubin 文件，cuModuleGetFunction 获取其中的 kernel 函数\nCUresult CUDAAPI cuLaunchKernel(CUfunction f, unsigned int gridDimX, unsigned int gridDimY, unsigned int gridDimZ, unsigned int blockDimX, unsigned int blockDimY, unsigned int blockDimZ, unsigned int sharedMemBytes, CUstream hStream, void** kernelParams, void** extra); 再通过 cuLaunchKernel 启动这个 kernel 即可，值得注意的点是 kernel 参数都通过 void** 也就是 void* 数组传递，也就是我们需要知道 kernel 的函数签名，才能启动 kernel。\nEmbed Binary 对于问题 2，我们需要某种将二进制文件嵌入到 C++ 文件中的手段，然后直接在 C++ 文件中引用这个 kernel 即可。关于如何在 C++ 代码中嵌入二进制文件的讨论也值得单开一个文章进行介绍了，这里不过多展开。只说一下我这里选用的方法。使用 objcopy 将二进制文件变成 ELF 格式的文件，同时会在其中插入几个符号用于引用二进制数据，比如\nobjcopy -I binary test.txt -O elf64-x86-64 -B i386:x86-64 test.o 再使用 nm test.o 查看里面的符号便可以得到\n000000000000000d D _binary_test_txt_end 000000000000000d A _binary_test_txt_size 0000000000000000 D _binary_test_txt_start 注意这里生成的符号名和输入的文件的路径有关，会将输入路径中的所有 / 和 . 替换成 _，推荐使用相对路径来获取可控的符号名。\n只需在 C++ 里面声明 _binary_test_txt_start 上面这些符号，同时最终把 test.o 文件和源文件链接在一起即可。\n/// main.cpp #include \u0026lt;iostream\u0026gt; #include \u0026lt;string_view\u0026gt; extern \u0026#34;C\u0026#34; { extern const char _binary_test_txt_start[]; extern const char _binary_test_txt_end[]; } int main() { std::cout \u0026lt;\u0026lt; std::string_view(_binary_test_txt_start, _binary_test_txt_end - _binary_test_txt_start) \u0026lt;\u0026lt; std::endl; return 0; } 使用如下命令编译运行，就会输出 test.txt 里面的内容了\n$ g++ -std=c++17 main.cpp test.o -o main $ ./main Function Signature 从上面讨论中可以看出，无论是导出 kernel 函数的头文件，还是给 cuLaunchKernel 函数传递 kernel 函数，我们都需要获取到 kernel 的函数签名才行。然而在 CuTe DSL v4.3 中，这件事情做不完美。考虑下面这个简单的示例\nimport torch import cutlass.cute as cute @cute.kernel def test_kernel(tensor): cute.printf(tensor) @cute.jit def test(tensor): kernel = test_kernel(tensor) kernel.launch((1, 1, 1), (1, 1, 1)) a = torch.zeros([4, 3, 5]).to(\u0026#34;cuda\u0026#34;) kernel = cute.compile(test, a) print(kernel.__ptx__) 根据官网的文档，如果直接用 torch.Tensor 来实例化函数编译，那么会把它默认当做 dynamic layout。检查生成的 ptx 可以发现，kernel 的签名是\n.visible .entry kernel_cutlass_test_kernel_tensorptrf32_gmem_o_1_0( .param .align 8 .b8 kernel_cutlass_test_kernel_tensorptrf32_gmem_o_1_0_param_0[40] ) 也就是一个 40 字节的结构体，前 8 字节显然是一个 float 的指针。剩下 32 字节呢？通过进一步分析汇编可以发现，shape 用了 3 个 u32 来传参，然后有 4 字节的 padding。stride 用了两个 u64 进行传递，由于最后一维的 stride 是 1，所以被省略了。嗯 …… 这其实只是一个非常简单的情况，对于一些动静态 layout 混杂的情况目前我没发现通用的方法来自动生成可靠的签名。\n除了 Tensor 直接做函数签名以外还有一些问题，比如在官方示例的 flash attn 算子里面，算子的函数签名是这样的：\n@cute.kernel def kernel( self, mQ: cute.Tensor, mK: cute.Tensor, mV: cute.Tensor, mO: cute.Tensor, softmax_scale_log2: cutlass.Float32, sQ_layout: cute.ComposedLayout, sKV_layout: cute.ComposedLayout, sO_layout: cute.ComposedLayout, gmem_tiled_copy_QKV: cute.TiledCopy, gmem_tiled_copy_O: cute.TiledCopy, tiled_mma: cute.TiledMma, SharedStorage: cutlass.Constexpr, ): 这么多函数参数哪些是常量会被保留，哪些是变量不会保留呢？遗憾的是，后面这些参数在 Python 侧都是不透明的，无法进行判断，因为它们是从 C++ 侧通过 nanobind 绑定来的类型。如果你调试进去查看 kernel 最初的 mlir 的话，会发现确实会为后面这些类型生成参数，但是在后续的 pass 会删掉，而这些 pass 也是不透明的。所以我放弃了自动为 kernel 生成函数签名这一念头。\nFinal Effect 采用的 workaround 则是，手动指定签名。比如我们可以人为的限制所有的算子签名都使用 cutlass.Pointer 和 cute.Integer 然后在 kernel 里面创建 tensor，效果上没有区别，只是人工降低了函数签名的复杂程度。或者直接看生成的 ptx 来把签名硬编码。基于这种假设和前面的步骤，我们最终可以实现如下的效果\ncc = Compiler() t = from_dlpack(torch.randn(M, N, device=\u0026#34;cuda\u0026#34;, dtype=torch.bfloat16), assumed_align=16) cc.compile(naive_elementwise_add, [ (\u0026#34;nv_bfloat16*\u0026#34;, \u0026#34;a\u0026#34;), (\u0026#34;nv_bfloat16*\u0026#34;, \u0026#34;b\u0026#34;), (\u0026#34;nv_bfloat16*\u0026#34;, \u0026#34;o\u0026#34;)], t, t, t) t = from_dlpack(torch.randn(M, N, device=\u0026#34;cuda\u0026#34;, dtype=torch.float32), assumed_align=16) cc.compile(naive_elementwise_add, [ (\u0026#34;float*\u0026#34;, \u0026#34;a\u0026#34;), (\u0026#34;float*\u0026#34;, \u0026#34;b\u0026#34;), (\u0026#34;float*\u0026#34;, \u0026#34;o\u0026#34;)], t, t, t) cc.link() compile 就是收集对应的 kernel 生成的 cubin 以及 cubin 里面的函数名。link 就是把 cubin 变成 .o 文件，再生成一个 C++ 文件里面有所有的这些二进制数组的符号。它会为每个 kernel 生成一个对应的 wrapper，就是调用 cuLaunchKernel 执行对应的 kernel。最后调用 nvcc 把它们一起编译成动态库。\n最后会生成这样一个头文件，以及一个动态库，供 C++ 程序调用。\nnamespace cutedsl_aot { struct LaunchParams { dim3 gridDim; dim3 blockDim; unsigned int sharedMemBytes = 0; cudaStream_t hStream = nullptr; }; void naive_elementwise_add(const LaunchParams\u0026amp; params, nv_bfloat16* a, nv_bfloat16* b, nv_bfloat16* o); void naive_elementwise_add(const LaunchParams\u0026amp; params, float* a, float* b, float* o); } // namespace cutedsl_aot 这样的实现很不优雅，但从用户侧来说似乎只能做到这样了。据小道消息，CuTe DSL 的 AOT 已经正在支持了，让我们期待未来的更新！\n","permalink":"https://www.ykiko.me/zh-cn/articles/1971691994037334904/","summary":"\u003ch2 id=\"why-do-we-need-aot-for-cute-dsl\"\u003eWhy do we need AOT for CuTe DSL?\u003c/h2\u003e\n\u003cp\u003eCUTLASS C++ 是一个用来编写高性能 CUDA 算子的库，以复杂难学著称。为了降低学习成本，NVIDIA 推出了基于 Python 的 \u003ca href=\"https://docs.nvidia.com/cutlass/latest/media/docs/pythonDSL/overview.html\"\u003eCuTe DSL\u003c/a\u003e。使用 Python 而不是 C++ 模板来进行元编程具有很多好处，首先就是用户不必和 C++ 那些晦涩难懂的模板报错作斗争了，这对于 C++ 初学者来说是非常头疼的一件事情，现在他们可以专注于代码逻辑。另外，nvcc 编译很慢，而其中大部分时间是花在编译器前端，也就是解析 C++ 代码上。尤其是对于 CUTLASS 这样 template heavy 的库，主要的时间都花在处理模板实例化上了，使用 CuTe DSL 可以绕过这个问题。相比于使用 CUTLASS 的 C++ 代码，它编译的速度能提升几十甚至上百倍。除此之外，现在算子和单测都可以一起在 Python 里写了，也方便了很多。\u003c/p\u003e","title":"为 CuTe DSL 支持 AOT"},{"content":"昨晚买下了 clice.io 这个域名，并且在它上面部署了 clice 的文档网站，心底有一种难以言喻的喜悦。一方面是我真的很喜欢 clice.io 这个域名，看起来很精致漂亮。其实还有几个候选项，比如 .dev 等，最终还是选了相对较贵（一年 500）但看起来更好看的这个域名。另一方面，它代表着 clice 又进入了一个新的阶段。\n新的阶段，是否代表着 clice 能初步使用了呢？很遗憾，还不能。那 clice 什么时候能用？这确实是我这段时间被问过最多的问题。距离 clice 的第一篇 博客 发布已经过去了七个月，按照当时的估计现在 clice 应该处于高度可用的状态才对，但事情的发展并不总是按照我的预期前进。所以写篇文章给关心 clice 进度的读者介绍下过去几个月的情况。\n时间问题 首先，最主要的原因就是时间不太够。作者是大三学生，大三上学校里面没什么课，并且人也比较宅，平常没事干的时候就在宿舍写代码，所以上学期有大量的时间可以投入在 clice 上，每天大概 6 ~ 10 小时。而且当时我也没有什么别的事情要做，可以一心一意的扑在 clice 上，每天就是读代码和写代码，不需要额外的上下文切换处理不同的事情，寒假也基本还是这个状态。\n然后时间就到了大三下，也就是 36 月这段时间。3 月份的时候主要是在找暑期实习，当时的想法比较简单，因为作者并不是科班的（学化学的），如果以后想要找份计算机相关的工作，还是有份暑期实习更有说服力一些。另外之前没实习过，也想体验一下工作究竟是什么感觉的。由于运气比较好，找暑期实习的过程还是比较顺利的。刚好有个学长的组在招实习生，做的也是 C++ 编译器和工具链相关的内容，是我比较熟悉也很感兴趣的方向，于是我就投了这个岗位。面试过程非常顺利，面试体验也非常好，没有任何八股文，是根据简历上写的内容来问的，算法题也很简单（一面考了个拓扑排序，二面没有算法题，三面是两题 easy）。唯一出了点小插曲就是他们希望能尽快入职，而不是等到暑期的时候。大三下学校里排的课还挺多的，但是考虑到机会难得，最后就商量了下，一周实习四天。能翘的课就翘了，不能翘的课基本都集中在上午，而我们部门上午一般 10.30 之前到就行，所以有时上完早八再去上班。好消息是公司离学校很近，校门口地铁坐十分钟就到了，通勤不用花什么时间。于是 46 月，我就边上课边实习，周末虽然不上班，但有时要用来处理学校里面的事情，所以最后留给 clice 的时间就非常少了。而且要在不同的上下文之间来回切换，效率远不如之前专注于一件事的时候。\n现在回过头来看当时确实非常幸运，第一次投简历，第一次面试就通过了，工作地点很合适，工作内容也是比较感兴趣的。\n然后时间就到了现在 7 月，放暑假了，学校里面的事情应该告一段落了吧？但是又通知 7.13 - 8.4 要参加小学期的课程，这个还不太好缺勤。于是思考再三，决定辞职了，回来完成学业，也是给自己喘口气，前段时间边上课边实习累的够呛，最后 7.11 last day。所谓小学期，其实也就是在下面听讲座，于是我就把笔记本带过去在下面写 clice。\n那 8 月之后，学校这边应该没啥事了吧？话虽如此可是 8、9 月份又得忙秋招的事情，该找工作了吧！其实最近我也投了点简历了，做了些量化公司的笔试，做自闭了。每天就是看脉脉，投简历，还刷题，背八股，焦虑的不行。昨天和群友讨论了下，突然有点想不明白为什么我要这么做呢，不想去的岗位还投什么啊，还非要背八股刷题，自己给自己找不快了属于是，喜欢当 m 吗？所以后续秋招的策略大概是随遇而安，简历大概还是会投的，但是笔试/面试能不能过就听天由命了，我也不想再去背八股，刷很多 hard 题了，很折磨。如果非要考很难的算法题，喜欢考八股，那这公司/部门就算我去了，应该待的也比较折磨，找工作本来也比较看运气，双向选择，还是多花点时间在 clice 上吧！\n既然都说到这了，也给自己打个广告，如果有什么有意思的岗位，可以考虑下我！我主要对 C++ 工具链和编译器比较熟悉，当然也并非一定要做这个方向。别的方向，如果不嫌弃我没经验的话，也会考虑的！\n乐观估计 除了个人时间上的问题外，另外一个因素大概就是程序员经常犯的错误了，那就是错误估计项目进度。在《人月神话》中，作者指出，我们习惯用「人月」这种看似线性的单位来衡量非线性的、充满复杂度的软件开发工作。一个功能的实现，其工作量并非简单的编码，还包括了背后大量的调试、测试和与现有系统集成的隐形成本。我就在实践之前的索引设计中发现一个非常严重的缺陷，以至于这部分的代码都需要重写。不过好消息是我们的项目没有交付日期，有充足的时间找到最好的解决方式。\n尽管很忙，没有时间去写具体的代码。但是还是可以在闲暇时间多思考思考之前代码中设计的不足，并把它们记录下来，看看后面怎么解决，这些倒是什么时候都可以做的。\n经过过去几个月的缓慢推进，现在 clice 已经可以基本的使用，处理文档事件，高亮，代码补全等等功能。但是距离发布第一个版本还需要很多的测试和完善，我觉得至少得等我自己把 clangd 换成 clice 使用一段时间后，我才会考虑发布第一个 release。\n还有就是关于多人合作的问题了，其实我对多人合作一直没什么信心。对于 clice 这种比较复杂的项目，尤其是几个月前，代码经常还可能有大规模的变化，还不太适合合作。不过好消息是，经过这几个月之后，已经到了可以比较轻松的合作的地步了，总体的代码框架已经稳定。也确实已经有群友深度参与进来，并负责一些功能的实现，一切都在往好的方向发展！争取在最近两个月内能发布第一个 release，在我大学毕业前能彻底取代 clangd！\n","permalink":"https://www.ykiko.me/zh-cn/articles/1931855290430624907/","summary":"\u003cp\u003e昨晚买下了 \u003ca href=\"https://clice.io/\"\u003eclice.io\u003c/a\u003e 这个域名，并且在它上面部署了 clice 的文档网站，心底有一种难以言喻的喜悦。一方面是我真的很喜欢 \u003ccode\u003eclice.io\u003c/code\u003e 这个域名，看起来很精致漂亮。其实还有几个候选项，比如 \u003ccode\u003e.dev\u003c/code\u003e 等，最终还是选了相对较贵（一年 500）但看起来更好看的这个域名。另一方面，它代表着 clice 又进入了一个新的阶段。\u003c/p\u003e","title":"clice 最近怎么样？"},{"content":"在昨天刚才结束的 C++26 Sofia 会议上，有关静态反射 (Static Reflection) 的七个提案：\nReflection for C++26 Function Parameter Reflection Annotations for Reflection Splicing a base class subobject Expansion Statements definestatic{string,object,array} Error Handling in Reflection 全都通过了 plenary 得以被正式纳入 C++26 标准，这是一个令人激动的时刻。在我看来，静态反射无疑是 20 年来 C++ 最重要的一个新特性。它彻底改变了以前使用模板进行元编程的模式，让元编程 (meta programming) 的代码可以像普通的代码逻辑一样易于阅读、编写、使用，而不再是以前基于模板的 DSL。\n在一年多前，P2996R1 的时候我就编写过一篇 文章 来介绍静态反射这个令人激动的提案。过了这么久，静态反射提案本身的内容有了较大的改变，上面文章的内容已经过时了，而且还新增了很多的附属提案。所以我决定编写一篇新文章来介绍静态反射及其附属提案的内容。\n如果想体验静态反射，有两种方式，一种是通过 Compiler Explorer 这个在线编辑器，把上面的编译器调成 P2996 Clang 就行了。另外一种是自己编译 https://github.com/bloomberg/clang-p2996/tree/p2996 这个 P2996 分支的 Clang 和 libc++。然后，参考 use libc++ 这个页面，在编译的时候使用刚编译出的 libc++ 作为标准库，就可以本地使用了，记得要开启 C++26 标准。\nWhat is Static Reflection? 首先反射是指什么呢？这个词就像计算机科学领域很多其他的惯用词一样，并没有详细而准确的定义。关于这个问题，我的反射专栏进行了较多的讨论，感兴趣的读者可以自行阅读，本文的重点是 C++ 的 static reflection。为什么强调 static 呢？主要是因为平常我们谈论到反射的时候几乎总是指 Java，C#，Python 这些语言中的反射，而它们的实现方式无一不是把类型擦除，在运行期进行元信息的查询。这种方式当然有不可避免的运行时开销，而这种开销显然是违背了 C++ zero cost abstraction 的原则的。为了和它们的反射区分开来，故加上 static 作为限定词，也指示了 C++ 的反射是在编译期完成的。\nEverything as Value 静态反射引入了两种新的语法，可以用反射运算符 (reflection operator): ^^ 将绝大多数 name entity 映射到 std::meta::info\nconstexpr std::meta::info rint = ^^int; std::meta::info 是一种新的、特殊的、consteval only 的 builtin 类型。它只能存在于编译期，你可以把它当成编译器中对这个 name entity 的 handle，后续可以基于这个不透明的 handle 做一些其他的操作。\n具体来说 ^^ 支持下面四种 name entity，\n::：全局命名空间 namespace-name：普通命名空间 type-id：类型 id-expression：绝大多数具有名字的东西，比如变量，静态成员变量，字段，函数，模板，枚举等 那怎么用这个 handle 还原回去呢？欸，可以的，使用拼接器 (splicer)：[: :] 将 std::meta::info 还原回 name entity。\n例如\nconstexpr std::meta::info rint = ^^int; using int2 = [:rint:]; 使用 [:rint:] 就将 rint 映射回了 int 类型，对于其他的 name entity 也是类似的，使用 [:rint:] 可以将它们映射回去。注意在某些可能造成歧义的上下文中需要在 [: :] 前面加上 typename 或者 template 关键字来消除歧义。\n需要消歧义的地方基本上还是 dependent name 的情况，也就是说当 r 是模板参数的时候，没法直接确定 [:r:] 是表达式，还是类型，还是模板，所以要手动来消除歧义。\n总结一下，静态反射引入了两种新的运算符，^^ 用于获取 name entity 的 handle，[: :] 用于把 handle 映射回对应的 name entity。\nMeta Function 我们都知道，仅仅获取一个 handle 并没有什么用，关键在于基于 handle 的一些操作。例如获取了一个文件的 handle，可以基于这个 handle 读取内容或者关闭文件什么的。在静态反射中，对这些 handle 的操作就是元函数 (meta function)。在 \u0026lt;meta\u0026gt; 头文件中，提供了一组非常广泛的函数用于操作这些 handle。下面对其中一些非常常用的元函数进行介绍\n反射目前使用编译期的异常来处理元函数中遇到的错误\nmembers namespace std::meta { consteval vector\u0026lt;info\u0026gt; members_of(info r, access_context ctx); consteval vector\u0026lt;info\u0026gt; bases_of(info type, access_context ctx); consteval vector\u0026lt;info\u0026gt; static_data_members_of(info type, access_context ctx); consteval vector\u0026lt;info\u0026gt; nonstatic_data_members_of(info type, access_context ctx); consteval vector\u0026lt;info\u0026gt; enumerators_of(info type_enum); consteval bool has_parent(info r); consteval info parent_of(info r); } // namespace std::meta 在序列化 (serialization) 和反序列化 (deserialization) 中的一个常见诉求就是获取到某个 struct 的 members，然后递归进行序列化。在静态反射之前，我们只能通过各种 hack 的方式来做到这一点，而且并不完美。例如 reflect-C++ 支持 C++20 下获取聚合类 (aggregate class) 的数据成员 和 magic-enum 支持枚举值在 [-127, 128] 范围内的枚举成员。实现方式非常的 hack 而且对编译器不友好，实例化大量模板导致编译速度降低，而且限制也很多。\n现在在静态反射中，我们可以轻松的利用这几个元函数来获取命名空间或者类型的成员，而且不仅限于数据成员，成员函数和别名之类的成员也可以轻松获取，还可以获取基类信息，这在之前无论如何也是做不到的。也支持反向操作，通过 parent_of 获取某个成员的 parent，也就是定义这个 entity 的 namespace, class 或者 function。\nstruct Point { int x; int y; }; int main() { Point p = {1, 2}; constexpr auto no_check = meta::access_context::unchecked(); constexpr auto rx = meta::nonstatic_data_members_of(^^Point, no_check)[0]; constexpr auto ry = meta::nonstatic_data_members_of(^^Point, no_check)[1]; p.[:rx:] = 3; p.[:ry:] = 4; std::println(\u0026#34;p: {}, {}\u0026#34;, p.x, p.y); } 输出 p: 3, 4，成功通过反射访问成员！\naccess_context 参数用于控制访问权限，它决定了我们是否能「看到」私有或保护成员，unchecked() 则代表拥有完全的访问权限，也就是说不进行任何访问检查。除了 unchecked 以外还有 current 表示使用当前作用域的访问权限，以及 unprivileged 只能访问非私有成员。上述获取成员的元函数会根据 access_context 对返回结果进行过滤。\nidentifiers namespace std::meta { consteval bool has_identifier(info r); consteval string_view identifier_of(info r); consteval u8string_view u8identifier_of(info r); consteval string_view display_string_of(info r); consteval u8string_view u8display_string_of(info r); consteval source_location source_location_of(info r); } // namespace std::meta 这个功能也是 C++ 程序员心心念念已久的功能了，获取变量名，函数名，字段名。\nconstexpr auto rx = meta::nonstatic_data_members_of(^^Point, no_check)[0]; constexpr auto ry = meta::nonstatic_data_members_of(^^Point, no_check)[1]; static_assert(meta::identifier_of(rx) == \u0026#34;x\u0026#34;); static_assert(meta::identifier_of(ry) == \u0026#34;y\u0026#34;); 这样在序列化到 json 这样需要字段名的格式的时候也很简单了。identifier_of 一般只能用于拥有简单名字的 entity，并且直接返回这个 named entity 的不带限定符 (qualifier) 的名字。而 display_string_of 则可能更倾向于返回带全称限定的名字，比如它的命名空间前缀，也可以用于处理 vector\u0026lt;int\u0026gt; 这样的模板特化。source_location_of 则进一步突破了 C++20 加的 std::source_location::current() 只能获取当前源码位置的限制。\noffsets namespace std::meta { struct member_offset { ptrdiff_t bytes; ptrdiff_t bits; constexpr ptrdiff_t total_bits() const { return CHAR_BIT * bytes + bits; } auto operator\u0026lt;=\u0026gt; (const member_offset\u0026amp;) const = default; }; consteval member_offset offset_of(info r); consteval size_t size_of(info r); consteval size_t alignment_of(info r); consteval size_t bit_size_of(info r); } // namespace std::meta offset_of 返回给定字段 offset 信息，由两部分构成：字节数 bytes 和位数 bits，用 total_bits 就可以获取具体的偏移了。这样设计的主要是考虑到字段可能是位域，偏移量不一定就是字节数。size_of 和 alignment_of 顾名思义就是获取 size 和 alignment。而 bit_size_of 则是获取位域的大小。\n通过这一组元函数，也不再需要通过各种 hack 的手段获取字段偏移量了，比如 bit_cast 成员指针来根据 ABI 细节获取到偏移量。在某些二进制序列化的场景是十分有用的。\ntype operations 接下来是有关 type 的操作了，这些操作就是简化模板元编程的关键所在。在这之前，由于类型只能作为模板参数，我们不得不基于丑陋的模板 DSL 来对类型做计算。一个纯函数式，没有变量，通过模板特化来表示分支，通过模板递归来表示循环的丑陋的 DSL，这也是模板元编程长期被人所诟病的原因。现在有了静态反射，我们可以把类型映射到值，只需要对值进行操作，普通的编写 consteval 函数就好了，和正常的代码逻辑没什么区别，只是 handle 变成了 std::meta::info。\n首先这里要谈谈 std::meta::info 的相等性，考虑如下代码\nusing int1 = int; constexpr auto rint = ^^int; constexpr auto rint1 = ^^int1; 这里的 rint 和 rint1 应该相等吗？毫无疑问它们表示相同的类型，但是前面我们说过，std::meta::info 是一个编译器内部表示的 handle，显然编译器会单独的跟踪类型别名的信息，所以 rint 和 rint1 其实是不同的 name entity 的 handle，那么它们不相等。关于判断两个 std::meta::info 是否相等的完整规则这里就略去了，有一些其他的 case 需要考虑，具体的细节可以之后去 cppreference 或者标准草案上查阅。对于本文的例子，理解上述这个别名的例子就足够了。\nnamespace std::meta { consteval auto type_of(info r) -\u0026gt; info; consteval auto dealias(info r) -\u0026gt; info; } // namespace std::meta 可以使用 type_of 来获取结构体字段等 typed entity 的类型，使用 dealias 获取一个别名的底层 entity，例如类型别名和命名空间别名原本的 entity，这个过程是递归的，会解开所有的别名。\n例如\nusing X = int; using Y = X; static_assert(^^int == dealias(^^Y)); 原本定义在 \u0026lt;type_traits\u0026gt; 头文件中的模板形式的 trait 现在都在 \u0026lt;meta\u0026gt; 中有了对应的反射版本，命名规则是把后缀从 _v 改成 _type，例如 is_same_v 就变成了 is_same_type，_t 后缀则是直接删去后缀\n这部分函数太多了，下面列出一些作为示例\nnamespace std::meta { consteval info remove_const(info type); consteval info remove_volatile(info type); consteval info remove_cv(info type); consteval info add_const(info type); consteval info add_volatile(info type); consteval info add_cv(info type); consteval info remove_pointer(info type); consteval info add_pointer(info type); consteval info remove_cvref(info type); consteval info decay(info type); } // namespace std::meta 所以现在可以方便的把以前的 type_traits 版本的处理，等价的换成反射的版本了。代码会好理解很多，在文章的最后我会给出几个这样的案例。\ntemplate arguments 除了上述对类型的操作以外，现在我们也能方便的对模板进行操作了\nnamespace std::meta { consteval info template_of(info r); consteval vector\u0026lt;info\u0026gt; template_arguments_of(info r); template \u0026lt;reflection_range R = initializer_list\u0026lt;info\u0026gt;\u0026gt; consteval bool can_substitute(info templ, R\u0026amp;\u0026amp; arguments); template \u0026lt;reflection_range R = initializer_list\u0026lt;info\u0026gt;\u0026gt; consteval info substitute(info templ, R\u0026amp;\u0026amp; arguments); } // namespace std::meta 假设 r 是一个模板特化 (template specialization)，template_of 返回它的模板，template_arguments_of 返回它的模板参数。substitute 则是根据给定的模板和参数，返回替换结果的模板特化的反射（不触发实例化）。通过这组函数，我们不再需要通过偏特化的方式来萃取模板特化的模板参数，轻而易举就可以拿到参数列表了。\n还可以通过它们编写一个 is_specialization_of 用来判断某个类型是不是某个模板的特化，而这在以前无论如何是做不到的\nconsteval bool is_specialization_of(info templ, info type) { return templ == template_of(dealias(type)); } 为什么之前做不到这一点呢？这是因为模板参数可以是类型 (typename)，值 (auto)，模板模板参数 (template)，而你没法穷举出这三种参数的所有组合，这样的话在编写 is_specialization_of 的时候里面待判断的模板签名就是固定的了。假设是 \u0026lt;typename T, template\u0026lt;typename...\u0026gt; HKT\u0026gt; ，这样 HKT 就只能填入类型模板参数，比如 std::array 它就处理不了了。\nreflect value namespace std::meta { template \u0026lt;typename T\u0026gt; consteval auto reflect_constant(const T\u0026amp; expr) -\u0026gt; info; template \u0026lt;typename T\u0026gt; consteval auto reflect_object(T\u0026amp; expr) -\u0026gt; info; template \u0026lt;typename T\u0026gt; consteval auto reflect_function(T\u0026amp; expr) -\u0026gt; info; template \u0026lt;typename T\u0026gt; consteval auto extract(info) -\u0026gt; T; } // namespace std::meta 这些元函数产生一个所提供表达式的求值结果的反射。这类反射最常见的用例之一是作为 std::meta::substitute 的参数，用来构建一个模板特化 (specialization)。\nreflect_constant(expr) 等价于下面的代码\ntemplate \u0026lt;auto P\u0026gt; struct C {}; 那么有\nstatic_assert(reflect_constant(V) == template_arguments_of(^^C\u0026lt;V\u0026gt;)[0]); constexpr auto rarray5 = substitute(^^std::array, {^^int, std::meta::reflect_constant(5)}); static_assert(rarray5 == ^^std::array\u0026lt;int, 5\u0026gt;); reflect_object(expr) 产生一个由 expr 所指代的对象的反射。这经常被用来获取一个子对象的反射，然后该反射可以被用作一个引用类型的非类型模板参数。\ntemplate \u0026lt;int\u0026amp;\u0026gt; void fn(); int p[2]; constexpr auto r = substitute(^^fn, {std::meta::reflect_object(p[1])}); reflect_function(expr) 产生一个由 expr 所指代的函数的反射。当只有一个函数的引用可用时，它对于反射该函数的属性非常有用。\nconsteval bool is_global_with_external_linkage(void (*fn)()) { std::meta::info rfn = std::meta::reflect_function(*fn); return (has_external_linkage(rfn) \u0026amp;\u0026amp; parent_of(rfn) == ^^::); } extract 则是上述 reflect_xxx 系列的反向操作，可以用于把一个 value 的反射，还原到对应的 C++ 中的值\n如果 r 是一个值的反射，extract\u0026lt;ValueType\u0026gt;(r) 返回该值 如果 r 是一个对象的反射，extract\u0026lt;ObjectType\u0026amp;\u0026gt;(r) 返回该对象的引用 如果 r 是一个函数的反射，extract\u0026lt;FuncPtrType\u0026gt;(r) 返回该函数的指针 如果 r 是一个非静态成员的反射，extract\u0026lt;MemberPtrType\u0026gt;(r) 返回成员指针 define aggregate namespace std::meta { struct data_member_options { struct name_type { template \u0026lt;typename T\u0026gt; requires constructible_from\u0026lt;u8string, T\u0026gt; consteval name_type(T\u0026amp;\u0026amp;); template \u0026lt;typename T\u0026gt; requires constructible_from\u0026lt;string, T\u0026gt; consteval name_type(T\u0026amp;\u0026amp;); }; optional\u0026lt;name_type\u0026gt; name; optional\u0026lt;int\u0026gt; alignment; optional\u0026lt;int\u0026gt; bit_width; bool no_unique_address = false; }; consteval auto data_member_spec(info type, data_member_options options) -\u0026gt; info; template \u0026lt;reflection_range R = initializer_list\u0026lt;info\u0026gt;\u0026gt; consteval auto define_aggregate(info type_class, R\u0026amp;\u0026amp;) -\u0026gt; info; } // namespace std::meta 可以用 define_aggregate 给一个不完整的类型生成成员定义，这对于实现 tuple 或者 variant 这样的可变成员数量的类型很有用，例如\nunion U; consteval { define_aggregate(^^U, { data_member_spec(^^int), data_member_spec(^^char), data_member_spec(^^double), }); } 相当于\nunion U { int _0; char _1; double _2; }; 这样就可以方便的实现一个 variant 类型而无需任何模板递归实例化了。\nother functions 除了上面列出的这些函数以外，还有非常多的函数用于查询 r 的某些特性，基本上是见名知义，仅列出\nconsteval auto is_public(info r) -\u0026gt; bool; consteval auto is_protected(info r) -\u0026gt; bool; consteval auto is_private(info r) -\u0026gt; bool; consteval auto is_virtual(info r) -\u0026gt; bool; consteval auto is_pure_virtual(info r) -\u0026gt; bool; consteval auto is_override(info r) -\u0026gt; bool; consteval auto is_final(info r) -\u0026gt; bool; consteval auto is_deleted(info r) -\u0026gt; bool; consteval auto is_defaulted(info r) -\u0026gt; bool; consteval auto is_explicit(info r) -\u0026gt; bool; consteval auto is_noexcept(info r) -\u0026gt; bool; consteval auto is_bit_field(info r) -\u0026gt; bool; consteval auto is_enumerator(info r) -\u0026gt; bool; consteval auto is_const(info r) -\u0026gt; bool; consteval auto is_volatile(info r) -\u0026gt; bool; consteval auto is_mutable_member(info r) -\u0026gt; bool; consteval auto is_lvalue_reference_qualified(info r) -\u0026gt; bool; consteval auto is_rvalue_reference_qualified(info r) -\u0026gt; bool; consteval auto has_static_storage_duration(info r) -\u0026gt; bool; consteval auto has_thread_storage_duration(info r) -\u0026gt; bool; consteval auto has_automatic_storage_duration(info r) -\u0026gt; bool; consteval auto has_internal_linkage(info r) -\u0026gt; bool; consteval auto has_module_linkage(info r) -\u0026gt; bool; consteval auto has_external_linkage(info r) -\u0026gt; bool; consteval auto has_linkage(info r) -\u0026gt; bool; consteval auto is_class_member(info r) -\u0026gt; bool; consteval auto is_namespace_member(info r) -\u0026gt; bool; consteval auto is_nonstatic_data_member(info r) -\u0026gt; bool; consteval auto is_static_member(info r) -\u0026gt; bool; consteval auto is_base(info r) -\u0026gt; bool; consteval auto is_data_member_spec(info r) -\u0026gt; bool; consteval auto is_namespace(info r) -\u0026gt; bool; consteval auto is_function(info r) -\u0026gt; bool; consteval auto is_variable(info r) -\u0026gt; bool; consteval auto is_type(info r) -\u0026gt; bool; consteval auto is_type_alias(info r) -\u0026gt; bool; consteval auto is_namespace_alias(info r) -\u0026gt; bool; consteval auto is_complete_type(info r) -\u0026gt; bool; consteval auto is_enumerable_type(info r) -\u0026gt; bool; consteval auto is_template(info r) -\u0026gt; bool; consteval auto is_function_template(info r) -\u0026gt; bool; consteval auto is_variable_template(info r) -\u0026gt; bool; consteval auto is_class_template(info r) -\u0026gt; bool; consteval auto is_alias_template(info r) -\u0026gt; bool; consteval auto is_conversion_function_template(info r) -\u0026gt; bool; consteval auto is_operator_function_template(info r) -\u0026gt; bool; consteval auto is_literal_operator_template(info r) -\u0026gt; bool; consteval auto is_constructor_template(info r) -\u0026gt; bool; consteval auto is_concept(info r) -\u0026gt; bool; consteval auto is_structured_binding(info r) -\u0026gt; bool; consteval auto is_value(info r) -\u0026gt; bool; consteval auto is_object(info r) -\u0026gt; bool; consteval auto has_template_arguments(info r) -\u0026gt; bool; consteval auto has_default_member_initializer(info r) -\u0026gt; bool; consteval auto is_special_member_function(info r) -\u0026gt; bool; consteval auto is_conversion_function(info r) -\u0026gt; bool; consteval auto is_operator_function(info r) -\u0026gt; bool; consteval auto is_literal_operator(info r) -\u0026gt; bool; consteval auto is_constructor(info r) -\u0026gt; bool; consteval auto is_default_constructor(info r) -\u0026gt; bool; consteval auto is_copy_constructor(info r) -\u0026gt; bool; consteval auto is_move_constructor(info r) -\u0026gt; bool; consteval auto is_assignment(info r) -\u0026gt; bool; consteval auto is_copy_assignment(info r) -\u0026gt; bool; consteval auto is_move_assignment(info r) -\u0026gt; bool; consteval auto is_destructor(info r) -\u0026gt; bool; consteval auto is_user_provided(info r) -\u0026gt; bool; consteval auto is_user_declared(info r) -\u0026gt; bool; 可以看出可以查询的信息非常多，包括储存期 (storage class) 和 链接 (linkage)，乃至 user_declared 和 user_provided 这样的信息。\nFunction Reflection 上面介绍了反射主体提案的内容，没有涉及的函数参数反射的部分，也就是说你没法获取到诸如函数参数名这样的信息。但是这个信息在某些场景比如在 pybind11 将 C++ 函数绑定到 Python 中还是非常有用的。P3096R12 允许引入了如下的元函数从而对允许反射函数参数\nnamespace std::meta { consteval vector\u0026lt;info\u0026gt; parameters_of(info r); consteval info variable_of(info r); consteval info return_type_of(info r); } // namespace std::meta 如果 r 是函数或者函数类型的反射，那么 return_type_of 返回它的返回值类型的反射，parameters_of 返回它的函数参数的反射。例如\nvoid foo(int x, float y); constexpr auto param0 = meta::parameters_of(^^foo)[0]; static_assert(identifier_of(param0) == \u0026#34;x\u0026#34;); static_assert(type_of(param0) == ^^int); constexpr auto param1 = meta::parameters_of(^^foo)[1]; static_assert(identifier_of(param1) == \u0026#34;y\u0026#34;); static_assert(type_of(param1) == ^^float); static_assert(return_type_of(^^foo) == ^^void); 欸，既然这样都已经能获取参数名和参数类型了，variable_of 有什么用呢？variable_of 只能在被反射的函数内部使用，用于获取函数定义中该函数参数对应的变量的反射，例如\nvoid foo(const int x, float y) { constexpr auto param0 = meta::parameters_of(^^foo)[0]; static_assert(type_of(param0) == ^^int); static_assert(param0 != ^^x); constexpr auto var0 = meta::variable_of(param0); static_assert(type_of(var0) == ^^const int); static_assert(var0 == ^^x); } 从这个例子中就可以看出二者的区别了。C++ 会隐式忽略掉类型中函数参数上的 const，例如 decltype(foo) 的结果就是 void(int, float)，于是你在函数的外部是永远观察不到这一点的，parameters_of 反射的是函数的接口，用于从函数外部反射观察函数，它的行为和上述行为保持一致。而 variable_of 反射的是函数定义，用于从函数内部观察函数，如果在 foo 内部 decltype(x) 的话，会发现是 const int，没有忽略 const，于是 variable_of 也是这样。\n还有其他一些细致的区别，比如同一个函数的多次声明中，某个函数参数的名称不同：\nvoid foo(int x); void foo(int y); 那么对 identifier_of(parameter) 会求值失败，不知道选择多个结果中的哪一个。但是 identifier_of(variable_of(parameter)) 则不是，它返回函数定义中对应变量声明的参数。\nnamespace std::meta { consteval bool is_function_parameter(info r); consteval bool is_explicit_object_parameter(info r); consteval bool has_ellipsis_parameter(info r); consteval bool has_default_argument(info r); } // namespace std::meta 剩下这几个函数则是对函数参数的某些性质进行查询了，见名知义：\nis_function_parameter：判断某个反射是不是函数参数的反射 is_explicit_object_parameter：判断某个函数参数的反射是不是 C++23 新加入的显式对象参数 (explicit this) has_ellipsis_parameter：判断一个函数或函数类型是否包含 ...，即 C 风格的可变参数，例如 C 的 printf(const char*, ...) has_default_argument：检测某个参数是否有默认值 Annotations 元编程的目的是为了编写通用的代码，比如自动为某个类型生成序列化的代码逻辑，从而一行代码就能完成序列化，比如\nstruct Point { int x; int y; }; Point p = {1, 2}; auto data = json::serialize(p); 通过静态反射，json::serialize 可以遍历 Point 的字段自动生成序列化逻辑，从而一行代码就能完成序列化。我们不再需要自己去编写重复的、繁琐的序列化样板代码。通用性是好的，但是有时候我们也想要一些定制的能力。\n仍然是上面 json 序列化的例子，假设我们从服务器接收的 json 字段名是 \u0026quot;first-name\u0026quot;，但 C++ 的标识符不能包含 -，所以我们可能将成员命名为 first_name。如果能在序列化时特殊处理它，将 first_name 成员重命名为 \u0026quot;first-name\u0026quot; 就好了。\n在别的语言中，可以通过 attribute 或 annotation 来附加元数据，然后在代码中读取这些元数据。C++ 也加入了 attribute，语法为 [[...]]，比如 [[nodiscard]]。但它主要的设计意图是为编译器提供额外的信息，而不是让用户附加额外的元数据并获取。\n为了解决这个问题，P3394R4(Annotations for Reflection) 提案为 C++26 引入了可反射的注解 (annotation)。它的语法非常直观，使用 [[=...]] 为某个 entity 添加注解，任意的可以作为模板参数的常量表达式都可以作为注解的内容。\n例如：\nstruct[[= \u0026#34;A simple point struct\u0026#34;]] Point { [[= serde::rename(\u0026#34;point_x\u0026#34;)]] int x; [[= serde::rename(\u0026#34;point_y\u0026#34;)]] int y; }; 它额外添加了下面这三个函数用于和注解进行交互\nnamespace std::meta { consteval bool is_annotation(info); consteval vector\u0026lt;info\u0026gt; annotations_of(info item); consteval vector\u0026lt;info\u0026gt; annotations_of_with_type(info item, info type); } // namespace std::meta is_annotation 判断一个反射是不是注解的反射。annotations_of 获取给定 entity 上的所有注解的反射，annotations_of_with_type 则是获取给定 entity 上所有类型为 type 的注解的反射。获取到注解后再使用前面提到的 extract 解开值然后使用就行了。\n例如\nstruct Info { int a; int b; }; [[= Info(1, 2)]] int x = 1; constexpr auto rs = annotations_of(^^x)[0]; constexpr auto info = std::meta::extract\u0026lt;Info\u0026gt;(rs); static_assert(info.a == 1 \u0026amp;\u0026amp; info.b == 2); 这样的话我们就可以在序列化库中预先定义一些类型，比如前文案例中的 serde::rename，然后检测用户的字段上有没有这些 annotation 从而进行一些特殊的处理。这样既保证了整体的通用性，又提供了局部的定制性，两全其美。\nExpansion Statement 传统的 range-for 循环遍历运行期的序列，而在元编程中，遍历编译期序列的需求越来越常见。比如遍历 tuple，这种编译期序列和运行期序列的最大区别是元素的类型可能不同。\n在 C++17 之前我们只能通过模板递归来完成这样的遍历，C++17 加入的折叠表达式稍微缓解了这一情况，但是仍然需要编写大量复杂的模板代码来完成这个目的。鉴于遍历编译期序列的操作如此之常见，P1306R5(Expansion Statements) 引入了新的语法 template for 来解决这个问题\n现在你可以轻松直观地遍历一个 tuple 了，在效果上相当于编译期展开循环，并对其中的每个元素实例化一次循环体\nvoid print_all(std::tuple\u0026lt;int, char\u0026gt; xs) { template for(auto elem: xs) { std::println(\u0026#34;{}\u0026#34;, elem); } } 精确的语法定义如下：\ntemplate for (init-statement(opt) for-range-declaration : expansion-initializer) compound-statement init-statement(opt)：前置的初始化语句 for-range-declaration: 循环变量的声明 expansion-initializer: 用于循环的序列 template for 支持三种不同类型的序列，优先级从高到低：\n表达式列表 (Expression List)：{ expression-list }，遍历列表中的每一个元素 template for(auto elem: {1, \u0026#34;hello\u0026#34;, true}) { ... } 包展开也是支持的，还能轻松的往参数包中添加内容\nvoid foo(auto\u0026amp;\u0026amp;... args) { template for(auto elem: {args...}) { ... } template for(auto elem: {0, args..., 1}) { ... } } 常量范围 (Constant Range)： 要求 range 的长度是编译期确定的\nvoid foo() { constexpr static std::array arr = {1, 2, 3}; constexpr static std::span\u0026lt;const int\u0026gt; view = arr; template for(constexpr auto elem: view) { ... } } 元组式解构 (Tuple-like Destructuring) 如果上述两种情况都不满足，编译器会尝试将 expansion-initializer 视为一个元组式 (tuple-like) 的实体进行解构（就像结构化绑定 auto [a, b] = ... 那样）\nstd::tuple t(1, \u0026#34;hello\u0026#34;, true); template for(auto elem: t) { ... } 循环变量声明上有可选的 constexpr，如果标记则要求循环中的每个元素都是 constexpr 的\ntemplate for 还支持 continue 和 break 语句，可以跳过剩余部分未实例化的代码\ndefine_static_array 好的，你现在已经学会 template for 了，于是想要兴致冲冲的编写一个能打印任何结构体的函数，用于调试\nvoid print_struct(auto\u0026amp;\u0026amp; value) { constexpr auto info = meta::remove_cvref(^^decltype(value)); constexpr auto no_check = meta::access_context::unchecked(); template for(constexpr auto e: meta::nonstatic_data_members_of(info, no_check)) { constexpr auto type = type_of(e); auto\u0026amp;\u0026amp; member = value.[:e:]; if constexpr(is_class_type(type)) { print_struct(member); } else { std::println(\u0026#34;{} {}\u0026#34;, identifier_of(e), member); } } } 发现报错了，说 template for 的初始化表达式不是常量表达式，这是为什么呢？这个事情就说来话长了。你会发现 nonstatic_data_members_of 的返回值竟然是一个 vector。我们前面说过 C++ 的反射是在编译期完成的，编译期还有 vector 用吗？还真有，C++20 允许了编译期的动态内存分配，于是你可以在 constexpr/consteval 函数中使用 vector 来处理中间状态了。但限制是编译期分配的内存必须在同一段编译期求值上下文中释放，如果在一次编译期求值中，有未释放的内存，则会导致编译错误。这个也可以理解，毕竟编译期分配的内存保留到运行期没任何含义了对吧。而每个 top level 的 constexpr 变量，模板参数等，包括 template for 的初始化表达式都视为一次单独的常量求值。\n所以上面的错误就很好理解了，template for 的初始化表达式被视为一次单独的常量求值，但是返回 vector 导致还有未释放的编译期内存，于是报错了。那怎么解决呢？P3491R3(define_static_{string,object,array}) 引入了一组函数作为这个问题的临时解决方案：\nnamespace std { template \u0026lt;ranges::input_range R\u0026gt; consteval const ranges::range_value_t\u0026lt;R\u0026gt;* define_static_string(R\u0026amp;\u0026amp; r); template \u0026lt;ranges::input_range R\u0026gt; consteval span\u0026lt;const ranges::range_value_t\u0026lt;R\u0026gt;\u0026gt; define_static_array(R\u0026amp;\u0026amp; r); template \u0026lt;class T\u0026gt; consteval const remove_cvref_t\u0026lt;T\u0026gt;* define_static_object(T\u0026amp;\u0026amp; r); } // namespace std 它们可以将编译期分配的内存提升到静态储存期，也就是说和全局变量的储存期相同，并返回该静态储存期的指针或者引用，从而解决这个问题，所以上面的代码只需要额外在获取 members 的时候使用 std::define_static_array 把 vector 转成 span 就行了\nvoid print_struct(auto\u0026amp;\u0026amp; value) { constexpr auto info = meta::remove_cvref(^^decltype(value)); constexpr auto no_check = meta::access_context::unchecked(); constexpr auto members = std::define_static_array(meta::nonstatic_data_members_of(info, no_check)); template for(constexpr auto e: members) { constexpr auto type = type_of(e); auto\u0026amp;\u0026amp; member = value.[:e:]; if constexpr(is_class_type(type)) { print_struct(member); } else { std::println(\u0026#34;{} {}\u0026#34;, identifier_of(e), member); } } } 每个 vector 和 template for 的地方都需要这样交互，看起来有些冗余。不过也没办法，这其实只是一个临时的 workaround。真正完善的解决方案是 persistent constexpr allocation，它可以自动把编译期未释放的内容提升到静态储存，但是由于种种原因没有推进。关于它又可以写一篇文章来介绍了，这里就不继续展开了，感兴趣的读者可以阅读：The History of constexpr in C++! (Part Two)。\nExample 最后来编写一个简单的 to_string 函数作为结尾吧：\n#include \u0026lt;meta\u0026gt; #include \u0026lt;print\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;vector\u0026gt; namespace meta = std::meta; namespace print_utility { struct skip_t {}; constexpr inline static skip_t skip; struct rename_t { const char* name; }; consteval rename_t rename(std::string_view name) { return rename_t(std::define_static_string(name)); } } // namespace print_utility /// annotations_of =\u0026gt; annotations_of_with_type consteval std::optional\u0026lt;std::meta::info\u0026gt; get_annotation(std::meta::info entity, std::meta::info type) { auto annotations = meta::annotations_of_with_type(entity, type); if(annotations.empty()) { return {}; } else if(annotations.size() == 1) { return annotations.front(); } else { throw \u0026#34;too many annotations!\u0026#34;; } } consteval auto fields_of(std::meta::info type) { return std::define_static_array(meta::nonstatic_data_members_of(type, meta::access_context::unchecked())); } template \u0026lt;typename T\u0026gt; auto to_string(const T\u0026amp; value) -\u0026gt; std::string { constexpr auto type = meta::remove_cvref(^^T); if constexpr(!meta::is_class_type(type)) { return std::format(\u0026#34;{}\u0026#34;, value); } else if constexpr(meta::is_same_type(type, ^^std::string)) { return value; } else { std::string result; result += meta::identifier_of(type); result += \u0026#34; { \u0026#34;; bool first = true; template for(constexpr auto member: fields_of(type)) { if constexpr(get_annotation(member, ^^print_utility::skip_t)) { continue; } if(!first) { result += \u0026#34;, \u0026#34;; } first = false; std::string_view field_name = meta::identifier_of(member); constexpr auto rename = get_annotation(member, ^^print_utility::rename_t); if constexpr(rename) { constexpr auto annotation = *rename; field_name = meta::extract\u0026lt;print_utility::rename_t\u0026gt;(annotation).name; } result += std::format(\u0026#34;{}: {}\u0026#34;, field_name, to_string(value.[:member:])); } result += \u0026#34; }\u0026#34;; return result; } } 我们这个简单的 to_string 函数支持两种 annotation，一种是 skip 跳过输出某个字段，一种是 rename 用于对这个字段进行重命名。get_annotation 用于判断给定的 entity 是否只有一个给定类型的 annotation，如果有就返回那个 annotation，否则返回空或者报错。在 to_string 函数中的处理逻辑也很直接，如果 value 是基本类型或者 string，简单的调用 format 返回结果。否则递归的转换它的字段，先检查字段有没有 skip 这个 annotation，有就跳过。如果没有的话，就检查它有没有 rename，如果有就使用 rename 的名字否则使用字段名。\n尝试使用\nstruct User { int id; std::string username; [[= print_utility::skip]] std::string password_hash; }; struct Order { int order_id; [[= print_utility::rename(\u0026#34;buyer\u0026#34;)]] User user_info; }; int main() { User u = {101, \u0026#34;Alice\u0026#34;, \u0026#34;abcdefg\u0026#34;}; Order o = {20240621, u}; std::println(\u0026#34;{}\u0026#34;, to_string(u)); std::println(\u0026#34;{}\u0026#34;, to_string(o)); } 输出\nUser { id: 101, username: Alice } Order { order_id: 20240621, buyer: User { id: 101, username: Alice } } 符合预期！代码放在 Compiler Explorer 上了。\nConclusion 到这里这篇关于静态反射的介绍文章就结束了，我尽量涵盖了反射中一些较为重要的核心特性，并附上合适的案例。静态反射是简洁的，强大的和易于理解的。这也象征着 C++ 数十年来 constexpr 演进取得了阶段性的里程碑。在文章的最后，让我引用 Herb Sutter 的 一段话 来结束这篇文章：\n在今天之前，C++ 历史上最重要的单项特性投票或许是 2007 年 7 月在多伦多举行的那次，该投票决定将 Bjarne Stroustrup 和 Gabriel Dos Reis 的第一份 「constexpr」 提案纳入 C++11 草案。如今回首，我们可以看到那为 C++ 带来了多么巨大的结构性转变。\n我坚信，在未来的许多年里，当我们回望今天，这个反射特性首次被采纳为标准 C++ 的日子，会视其为该语言历史上的一个关键日期。反射将从根本上改善我们编写 C++ 代码的方式，其对语言表达能力的扩展将超过我们至少 20 年来所见的任何特性，并将极大地简化现实世界中的 C++ 工具链和环境。即使仅凭我们今天拥有的部分反射能力，我们已经能够反射 C++ 类型，并利用这些信息加上普通的 std::cout 来生成任意额外的 C++ 源代码，这些代码基于反射信息，并且可以在程序构建时被编译并链接到同一程序中（未来我们还将获得 token injection 功能，从而可以在同一源文件中直接生成 C++ 源码）。但我们实际上可以生成任何东西：任意的二进制元数据，例如 .WINMD 文件；任意的其他语言代码，例如自动生成用于封装 C++ 类型的 Python 或 JS 绑定。所有这一切都可以通过可移植的标准 C++ 实现。\n这是一件非常了不起的大事。听着，大家都知道我偏爱说 C++ 的好话，但我不喜欢夸大其词，也从未说过这样的话。今天确实是独一无二的：反射带来的变革性，比我们以往投票纳入标准的所有其他 10 个主要特性的总和还要大。在未来的十年（甚至更久）里，它将主导 C++ 的发展，我们将通过增加更多功能来完善这一特性（就像我们随着时间推移为 constexpr 添加功能以使其完备一样），并学习如何在我们的程序和构建环境中使用它。\n","permalink":"https://www.ykiko.me/zh-cn/articles/1919923607997518115/","summary":"\u003cp\u003e在昨天刚才结束的 C++26 Sofia 会议上，有关\u003cstrong\u003e静态反射 (Static Reflection)\u003c/strong\u003e 的七个提案：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P2996R13.html\"\u003eReflection for C++26\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P3096R12.pdf\"\u003eFunction Parameter Reflection\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P3394R4.html\"\u003eAnnotations for Reflection\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P3293R3.html\"\u003eSplicing a base class subobject\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P1306R5.html\"\u003eExpansion Statements\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P3491R3.html\"\u003edefine\u003cem\u003estatic\u003c/em\u003e{string,object,array}\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"https://isocpp.org/files/papers/P3560R2.html\"\u003eError Handling in Reflection\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e全都通过了 plenary 得以被\u003cstrong\u003e正式纳入 C++26 标准\u003c/strong\u003e，这是一个令人激动的时刻。在我看来，静态反射无疑是 20 年来 C++ 最重要的一个新特性。它彻底改变了以前使用模板进行元编程的模式，让\u003cstrong\u003e元编程 (meta programming)\u003c/strong\u003e 的代码可以像普通的代码逻辑一样易于阅读、编写、使用，而不再是以前基于模板的 DSL。\u003c/p\u003e","title":"Reflection for C++26!!!"},{"content":"在上一篇关于 clice 的 文章 发布后，收到的反响远超我的预期，也有很多朋友说想要参与到开发中。有这份心是好的，但是参与进来的门槛却不低，主要难点就是在于和 Clang 的 API 进行交互。难在哪呢？一方面在于无论是中文还是英文社区，互联网上关于 Clang 的资料相对较少（这是预期的，因为相关的需求非常少，自然而然也就没什么人讨论了）。另一方面，由于 C++ 语言本身的复杂性，有很多细节需要对语言本身有较为深入的理解然后才能理解，把理论和实现联系起来也并不那么容易。\n所以我决定编写两篇关于 Clang 的文章，第一篇的内容是介绍如何基于 Clang 编写相关的代码生成或者静态检查工具以及对 Clang AST 较为全面的介绍。第二篇则会更加深入的介绍 Clang 作为编译器本身的架构，具体到各个流程的实现细节，以及 clice 是如何使用 Clang 的。希望这两篇文章能扫清读者们参与到 clice 的开发中的障碍。如果你是想为 Clang 做贡献或者编写 Clang 工具，那么也可以继续往下阅读。\nDevelopment environment 第一步，首先就要搭建好开发环境。Clang 是 LLVM 的一个子项目，而 LLVM 项目的构建是由 CMake 编写的，并且相对过时，只能通过 find_package 的方式来引入。这就意味着我们要提前编译安装好 LLVM 和 Clang。一种方式是下载编译好的二进制文件，LLVM 的 release 这里就有针对各个平台的预编译包。\n不过我更推荐的方式是从源码构建 Debug 版本的二进制方便进行调试开发。具体的构建方式可以在 LLVM 的官方文档 找到，这里不过多赘述。\n通过文档不难发现，它的构建有很多参数，这里是比较容易踩坑的地方。我这里把一些比较重要的参数重点强调一下：\nLLVM_ENABLE_PROJECTS 是用来指定要构建除了 LLVM 本身之外的哪些子项目，我们这里只需要 clang 就行了 LLVM_TARGETS_TO_BUILD 用于指定编译器支持的目标平台，开的越多编译 LLVM 时间越长，对于开发来说一般选择 X86 就行了 LLVM_BUILD_LLVM_DYLIB 用于指定是否把 LLVM 构建产物都构建为动态库，强烈推荐开启。构建为动态库之后，链接速度就会非常快，可以改善开发体验。此外，如果不打开这个选项，那么最后在调试模式下构建出来的二进制文件会非常大，可能会有上百 GB。不过遗憾的是，这个选项目前在 Windows 上的 MSVC target 下不支持，相关的工作仍然进行中，参阅 LLVM-Windows-support。所以推荐开发平台是 Linux 或者 Windows 上使用 MinGW 或者 WSL LLVM_USE_SANITIZER 用于指定要开启的 sanitizer，该选项在 MSVC target 下也几乎不可用。 另外千万不要使用 GNU 的 ld 来链接，它的内存占用非常高，并发链接的情况下很容易爆内存。然后呢，作为参考我在 Linux 上的构建命令如下\ncmake \\ -G Ninja -S ./llvm \\ -B build-debug \\ -DLLVM_USE_LINKER=lld \\ -DCMAKE_C_COMPILER=clang \\ -DCMAKE_CXX_COMPILER=clang++ \\ -DCMAKE_BUILD_TYPE=Debug \\ -DBUILD_SHARED_LIBS=ON \\ -DLLVM_TARGETS_TO_BUILD=X86 \\ -DLLVM_USE_SANITIZER=Address \\ -DLLVM_ENABLE_PROJECTS=\u0026#34;clang\u0026#34; \\ -DCMAKE_INSTALL_PREFIX=./build-debug-install 构建成功的话，最后的二进制文件应该就位于 LLVM 项目的 build-debug-install 目录下了。然后新创建一个目录用于编写工具的代码，在该目录下创建一个 CMakeLists.txt 文件，内容如下\ncmake_minimum_required(VERSION 3.10) project(clang-tutorial VERSION 1.0) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED True) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_PREFIX_PATH \u0026#34;${LLVM_INSTALL_PATH}\u0026#34;) find_package(LLVM REQUIRED CONFIG) find_package(Clang REQUIRED CONFIG) message(STATUS \u0026#34;Found LLVM ${LLVM_INCLUDE_DIRS}\u0026#34;) add_executable(tooling main.cpp) set(CMAKE_CXX_FLAGS \u0026#34;${CMAKE_CXX_FLAGS} -fno-rtti -fno-exceptions -fsanitize=address\u0026#34;) set(CMAKE_EXE_LINKER_FLAGS \u0026#34;${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address\u0026#34;) target_include_directories(tooling PRIVATE ${LLVM_INCLUDE_DIRS}) target_link_libraries(tooling PRIVATE LLVMSupport clangAST clangBasic clangLex clangFrontend clangSerialization clangTooling ) 内容很简单，就是引入 LLVM 和 Clang 的头文件和库文件。注意这里的 LLVM_INSTALL_PATH 就是你刚才安装的 LLVM 路径，可以留意 message 函数输出的路径是否是预期的。\n再创建一个 main.cpp 文件，内容如下\n#include \u0026#34;clang/Tooling/Tooling.h\u0026#34; class ToolingASTConsumer : public clang::ASTConsumer { public: void HandleTranslationUnit(clang::ASTContext\u0026amp; context) override { context.getTranslationUnitDecl()-\u0026gt;dump(); } }; class ToolingAction : public clang::ASTFrontendAction { public: std::unique_ptr\u0026lt;clang::ASTConsumer\u0026gt; CreateASTConsumer(clang::CompilerInstance\u0026amp; instance, llvm::StringRef file) override { return std::make_unique\u0026lt;ToolingASTConsumer\u0026gt;(); } }; int main() { const char* content = R\u0026#34;( int main() { return 0; } )\u0026#34;; bool success = clang::tooling::runToolOnCode(std::make_unique\u0026lt;ToolingAction\u0026gt;(), content, \u0026#34;main.cpp\u0026#34;); return !success; } 编译运行，预期输出应该是\nTranslationUnitDecl 0x7dabb8df5508 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; |-TypedefDecl 0x7dabb8e4e238 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; implicit __int128_t \u0026#39;__int128\u0026#39; | `-BuiltinType 0x7dabb8df5d90 \u0026#39;__int128\u0026#39; |-TypedefDecl 0x7dabb8e4e2b0 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; implicit __uint128_t \u0026#39;unsigned __int128\u0026#39; | `-BuiltinType 0x7dabb8df5dc0 \u0026#39;unsigned __int128\u0026#39; |-TypedefDecl 0x7dabb8e4e698 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; implicit __NSConstantString \u0026#39;__NSConstantString_tag\u0026#39; | `-RecordType 0x7dabb8e4e3b0 \u0026#39;__NSConstantString_tag\u0026#39; | `-CXXRecord 0x7dabb8e4e310 \u0026#39;__NSConstantString_tag\u0026#39; |-TypedefDecl 0x7dabb8df61e0 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; implicit __builtin_ms_va_list \u0026#39;char *\u0026#39; | `-PointerType 0x7dabb8df6190 \u0026#39;char *\u0026#39; | `-BuiltinType 0x7dabb8df55e0 \u0026#39;char\u0026#39; |-TypedefDecl 0x7dabb8e4e1c0 \u0026lt;\u0026lt;invalid sloc\u0026gt;\u0026gt; \u0026lt;invalid sloc\u0026gt; implicit __builtin_va_list \u0026#39;__va_list_tag[1]\u0026#39; | `-ConstantArrayType 0x7dabb8e4e160 \u0026#39;__va_list_tag[1]\u0026#39; 1 | `-RecordType 0x7dabb8df62e0 \u0026#39;__va_list_tag\u0026#39; | `-CXXRecord 0x7dabb8df6240 \u0026#39;__va_list_tag\u0026#39; `-FunctionDecl 0x7dabb8e4e768 \u0026lt;main.cpp:2:5, line:4:5\u0026gt; line:2:9 main \u0026#39;int ()\u0026#39; `-CompoundStmt 0x7dabb8e4e8e8 \u0026lt;col:16, line:4:5\u0026gt; `-ReturnStmt 0x7dabb8e4e8d0 \u0026lt;line:3:9, col:16\u0026gt; `-IntegerLiteral 0x7dabb8e4e8a8 \u0026lt;col:16\u0026gt; \u0026#39;int\u0026#39; 0 自此，开发环境就搭建好了，可以愉快的进行 Clang 的开发了。\nAST AST（Abstract Syntax Tree，抽象语法树）是编译器在编译过程中生成的一个数据结构，用来表示源代码的语法结构。它是源代码的一个抽象层次，用于捕捉源代码的语法信息，同时去除具体的细节，比如分号、括号等。上面我们编写的小工具的作用就是编译输入的字符串文本并打印它的 AST。其实无论是静态检查工具还是代码生成工具，都是通过操作 AST 来实现的。我们需要知道如何从 AST 中筛选出我们感兴趣的节点，以及如何进一步获取节点的相关的更详细的信息。\n所有 AST 节点的生命周期都是相同的，并且相互之间可能有复杂的引用关系。对于 Clang AST 来说，虽然名义上叫做 Abstract Syntax Tree 是一个 Tree，但实际上是一个有环的 Graph。在这个特殊的场景下，使用内存池统一分配所有 AST 节点的内存可以大大简化节点生命周期的管理。Clang 也是这么做的，所有的 AST 节点都是通过 clang::ASTContext 来分配的。通过 ASTContext::getTranslationUnitDecl 我们就可以获取到 AST 的根节点，顾名思义，也就是代表一个编译单元。\n在遍历 AST 之前，我们首先需要理解 Clang AST 的结构。Clang 中最基本的两个节点类型是 Decl 和 Stmt，而 Expr 是 Stmt 的子类。Decl 代表的是声明，比如变量声明、函数声明等。Stmt 代表的是语句，比如赋值语句、函数调用语句等。Expr 代表的是表达式，比如加法表达式、函数调用表达式等。\n以 int x = (1 + 2) * 3; 为例，它的 AST 结构如下\n`-VarDecl 0x7e0b3974e710 \u0026lt;main.cpp:2:1, col:19\u0026gt; col:5 x \u0026#39;int\u0026#39; cinit `-BinaryOperator 0x7e0b3974e898 \u0026lt;col:9, col:19\u0026gt; \u0026#39;int\u0026#39; \u0026#39;*\u0026#39; |-ParenExpr 0x7e0b3974e848 \u0026lt;col:9, col:15\u0026gt; \u0026#39;int\u0026#39; | `-BinaryOperator 0x7e0b3974e820 \u0026lt;col:10, col:14\u0026gt; \u0026#39;int\u0026#39; \u0026#39;+\u0026#39; | |-IntegerLiteral 0x7e0b3974e7d0 \u0026lt;col:10\u0026gt; \u0026#39;int\u0026#39; 1 | `-IntegerLiteral 0x7e0b3974e7f8 \u0026lt;col:14\u0026gt; \u0026#39;int\u0026#39; 2 `-IntegerLiteral 0x7e0b3974e870 \u0026lt;col:19\u0026gt; \u0026#39;int\u0026#39; 3 可以发现，还是非常清晰的，能和源代码中的语法结构一一对应起来。由于 C++ 的语法非常复杂，自然对应的节点类型也是非常多的。可以在 Clang 源码目录下的 clang/AST/DeclNodes.td 和 clang/AST/StmtNodes.td 找到所有节点类型的继承图。注意是源码目录，而不是安装目录，.td 后缀的文件是 LLVM TableGen 语言，是一种特殊格式的配置文件，用于进行代码生成之类的工作。由于 LLVM 是关闭异常和 RTTI 的，但是诸如 dynamic_cast 这样的类型转换在对 AST 节点进行操作的时候又是非常常见的，所以 LLVM 就通过代码生成自己实现了一套类似的机制。相关的内容可以参考 LLVM Programmer\u0026rsquo;s Manual。\n下面对于 AST 中一些比较重要的节点和 API 进行介绍（暂时只有很少一些，之后会根据反馈进行补充）\nCast 首先最基础也是最重要的一个操作就是节点类型的 downcast 了，如前文所述相关的操作在 LLVM 中共用一套逻辑。用得最多的 API 就是 llvm::dyn_cast 了，例如我们想判断一个声明是不是函数声明\nvoid foo(clang::Decl* decl) { if(auto FD = llvm::dyn_cast\u0026lt;clang::FunctionDecl\u0026gt;(decl)) { llvm::outs() \u0026lt;\u0026lt; FD-\u0026gt;getName() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } } 用法和 C++ 标准库的 dynamic_cast 几乎完全一致。\nDeclContext 在 C++ 中，有一些声明可以在其内部定义其他声明，例如\nnamespace foo { int x = 1; } 这里 foo 就作为 x 的声明上下文，为了描述这种关系，在 Clang 中所有能作为声明上下文的 Decl 都会继承 DeclContext。典型案例就是上面说的 NamespaceDecl 了。可以通过 DeclContext::decls() 这个成员获取上下文中所有的声明。\nTemplate 对于模板的处理可谓是 Clang AST 中最复杂的部分了，这里做一些简略的介绍。所有未实例化的模板声明都会继承 clang::TemplateDecl 这个类。通过查看继承图，有如下几个类型 ClassTemplate, FunctionTemplate, VarTemplate, TypeAliasTemplate, TemplateTemplateParm 和 Concept。分别对应 C++ 标准中的类模板、函数模板、变量模板、类型别名模板、模板模板参数和概念。\n欸，我有一个疑问。类模板的成员函数是如何表示的呢？\ntemplate \u0026lt;typename T\u0026gt; class Foo { void bar(); }; 比如这里的 bar 在 AST 里面是什么节点。dump AST 就可以发现它是一个普通的 CXXMethodDecl，和普通的成员函数是一样的。实际上 TemplateDecl 有一个成员 getTemplatedDecl，可以获取到该模板类型的底层类型。其实就是把 T 当成一个普通类型来处理的。获取到底层声明之后一切就和普通的非模板类型一样了。只是 Parser 在解析 dependent name 的时候会有一些特殊的处理。\n所有模板的实例化同样在 AST 中有对应的表示，无论是显式实例化还是隐式实例化。可以通过 getSpecializationKind 来获取该模板实例化的类型。TemplateSpecializationKind 是一个枚举类型，有 TSK_Undeclared, TSK_ImplicitInstantiation, TSK_ExplicitInstantiationDeclaration, TSK_ExplicitInstantiationDefinition, TSK_ExplicitSpecialization 和 TSK_ExplicitInstantiation。分别对应未声明的实例化（模板实例化是 lazy 的）、隐式实例化、显式实例化声明、显式实例化定义、显式特化和显式实例化。\nVisitor 在对 AST 的结构有了一些基本认识后，我们就可以来遍历 AST 收集信息了。进行这一步的关键类是 clang::RecursiveASTVisitor。\nRecursiveASTVisitor 是一个 CRTP 的典型应用。它默认对整个 AST 进行深度优先遍历，并且提供了一组接口允许子类重写以改变默认行为。例如下面的代码就会遍历 AST 并且 dump 所有的函数声明。\n#include \u0026#34;clang/Tooling/Tooling.h\u0026#34; #include \u0026#34;clang/AST/RecursiveASTVisitor.h\u0026#34; class ToolingASTVisitor : public clang::RecursiveASTVisitor\u0026lt;ToolingASTVisitor\u0026gt; { public: bool VisitFunctionDecl(clang::FunctionDecl* FD) { FD-\u0026gt;dump(); return true; } }; class ToolingASTConsumer : public clang::ASTConsumer { public: void HandleTranslationUnit(clang::ASTContext\u0026amp; context) override { ToolingASTVisitor visitor; visitor.TraverseAST(context); } }; 关于 RecursiveASTVisitor 的工作原理这里就不过多介绍了，它的注释文档足够详细。简而言之，如果只是希望访问某个节点，重写 VisitFoo，如果想自定义遍历行为，例如过滤掉不感兴趣的节点以加快遍历速度，重写 TraverseFoo 就好了，其中 Foo 是节点的类型。\n注意，直觉上来说遍历应该是个只读操作，应该是线程安全的。但是 Clang AST 在遍历的时候会对一些结果做 cache，所以多线程并发遍历同一个 AST 并不线程安全。\nPreprocess 可以发现，在 AST 节点的定义中并没有任何和宏有关的节点。当然也没有预处理指令之类的节点。实际上 Clang 的 AST 的构建是在完整的预处理之后进行的，无论是宏展开还是预处理指令都已经被处理掉了。但是如果我们想要获取相关的信息怎么办呢？\nClang 为我们提供了 PPCallback 这个类，允许我们重写里面的相关接口来获取一些信息。\n#include \u0026#34;clang/Tooling/Tooling.h\u0026#34; #include \u0026#34;clang/AST/RecursiveASTVisitor.h\u0026#34; class ToolingPPCallbacks : public clang::PPCallbacks { public: void MacroDefined(const clang::Token\u0026amp; MacroNameTok, const clang::MacroDirective* MD) override { llvm::outs() \u0026lt;\u0026lt; \u0026#34;MacroDefined: \u0026#34; \u0026lt;\u0026lt; MacroNameTok.getIdentifierInfo()-\u0026gt;getName() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } }; class ToolingAction : public clang::ASTFrontendAction { public: std::unique_ptr\u0026lt;clang::ASTConsumer\u0026gt; CreateASTConsumer(clang::CompilerInstance\u0026amp; instance, llvm::StringRef file) override { return std::make_unique\u0026lt;ToolingASTConsumer\u0026gt;(); } bool BeginSourceFileAction(clang::CompilerInstance\u0026amp; instance) override { llvm::outs() \u0026lt;\u0026lt; \u0026#34;BeginSourceFileAction\\n\u0026#34;; instance.getPreprocessor().addPPCallbacks(std::make_unique\u0026lt;ToolingPPCallbacks\u0026gt;()); return true; } }; 上面的示例的效果就是打印出所有的宏定义。PPCallbacks 还提供了很多其他的接口，相关的注释也较为详细，可以按需使用。\nLocation Clang 会在 AST 中详细的记录节点的位置信息。表示位置信息的核心类是 clang::SourceLocation。为了在减少内存占用的同时能储存尽量多的信息，它本身只相当于一个 ID，大小只有 int 非常轻量。实际的位置信息则被储存在 clang::SourceManager 中。在需要详尽的信息的时候，需要通过 SourceManager 来进行解码。\n通过 ASTContext::getSourceManager 可以获取到对应的 SourceManager。然后我们就可以通过如下代码 dump 出节点的位置信息\nvoid dump(clang::SourceManager\u0026amp; SM, clang::FunctionDecl* FD) { FD-\u0026gt;getLocation().dump(SM); } 在 SourceManager 的成员函数中，你会发现很多 API 的前缀都带了 spelling 或者 expansion。比如 getSpellingLineNumber 和 getExpansionLineNumber。这是什么意思呢？首先要意识到一件事，AST 中所有的 SourceLocation 都代表的是一个 token 开始的起始位置。而一个 token 的来源有两种，一种就是直接对应源码中的一个 token，另一种则是来自于宏展开。可以通过 SourceLocation::isMacroID 来判断这个 token 的位置是不是由宏展开产生的。\n对于由宏展开产生的 token，Clang 会跟踪它的两个信息。一个是宏展开的位置也就是 ExpansionLocation，另一个则展开产生该 token 的 token 的位置，也就是 SpellingLocation。\n例如对于下述代码\n#define Self(name) name int Self(x) = 1; 使用如下代码打印出变量声明的位置信息\nvoid dump(clang::SourceManager\u0026amp; SM, clang::SourceLocation location) { llvm::outs() \u0026lt;\u0026lt; \u0026#34;is from macro expansion: \u0026#34; \u0026lt;\u0026lt; location.isMacroID() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; llvm::outs() \u0026lt;\u0026lt; \u0026#34;expansion location: \u0026#34;; SM.getExpansionLoc(location).dump(SM); llvm::outs() \u0026lt;\u0026lt; \u0026#34;spelling location: \u0026#34;; SM.getSpellingLoc(location).dump(SM); } 预期输出是\nis from macro expansion: 1 expansion location: main.cpp:2:5 spelling location: main.cpp:2:10 该变量声明中 x 是由宏展开产生的，所以 isMacroID 就是 true。它的 expansion location 就是宏展开的位置，也就是 Self(x) 的起始位置。而 spelling location 则是展开产生 x 的 token 的位置，也就是 Self(x) 中 x 的位置。\n除此之外，根据 C++ 标准，还可以通过 #line 预处理指令来改变行号和文件名，例如\n#include \u0026lt;string_view\u0026gt; #line 10 \u0026#34;fake.cpp\u0026#34; static_assert(__LINE__ == 10); static_assert(__FILE__ == std::string_view(\u0026#34;fake.cpp\u0026#34;)); 这个会影响 Clang 的位置记录吗？答案是肯定的。那么如果我想获取到真实的行号和文件名呢？可以通过 getPresumedLoc 来获取被 #line 指令修改过后或者没有修改过的位置信息。\nvoid dump(clang::SourceManager\u0026amp; SM, clang::SourceLocation location) { /// The second argument determines whether the location is modified by /// `#line` directives. auto loc = SM.getPresumedLoc(location, true); llvm::outs() \u0026lt;\u0026lt; loc.getFilename() \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; loc.getLine() \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; loc.getColumn() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; loc = SM.getPresumedLoc(location, false); llvm::outs() \u0026lt;\u0026lt; loc.getFilename() \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; loc.getLine() \u0026lt;\u0026lt; \u0026#34;:\u0026#34; \u0026lt;\u0026lt; loc.getColumn() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } 第一个输出的就是被 #line 指令修改过后的位置信息，而第二个输出的就是真实的位置信息。注意，Clang 默认使用的文件编码是 UTF-8，计算 line 和 column 也是按照 UTF-8 来计算的。 那假设我想获取到 UTF-16 编码下的 column 信息呢？实际上 VSCode 默认用的就是这个，可以通过 getDecomposedLoc 来把 SourceLocation 分解为 clang::FileID 和相对于文件起始位置的 offset。有了 offset 之后结合源文件的文本内容，我们就可以自己根据 UTF-16 编码来计算 column 了。\n既然提到了 clang::FileID 那我们就接着说它。它和 SourceLocation 类似，也是一个 ID，只不过它代表的是一个文件。我们可以通过 getIncludeLoc 来获取到引入该文件的位置（也就是 #include 当前文件的预处理指令位置）。使用 getFileEntryRefForID 获取到它所引用的文件的信息，包括文件名，大小等。\nvoid dump(clang::SourceManager\u0026amp; SM, clang::SourceLocation location) { auto [fid, offset] = SM.getDecomposedLoc(location); auto loc = SM.getIncludeLoc(fid); llvm::outs() \u0026lt;\u0026lt; SM.getFileEntryRefForID(fid)-\u0026gt;getName() \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; } 一个头文件可能被包含多次，每次被包含都是一个新的 FileID，但是底层引用的是相同的文件。\nConclusion 在理解了上面这些内容之后，读者应该不难编写一个基于 Clang 的工具了，例如一个基于 Clang 的反射代码生成器。\n对于 clice 来说，很多的语言服务器请求都是通过遍历 AST 来完成的。例如 SemanticTokens，它的效果是为源码中的 token 提供代码 kind 和 modifier 修饰，使得编辑器可以进一步根据主题进行高亮。\n那么如何实现它呢？其实就是遍历 AST，然后根据节点类型，返回 LSP 标准中定义的 kind，比如 variable 和 function。最后再根据 token 的位置进行排序就行了。原理十分简单，剩下的内容就是处理由于 C++ 语法的复杂性所带来的很多 corner case 了。\n到这里文章就结束了，感谢阅读。这里还有一些来自于 Clang 官方的参考文档：\nLibTooling AST Matcher Transformer Introduction to the Clang AST Clang CFE Internals Manual ","permalink":"https://www.ykiko.me/zh-cn/articles/21319978959/","summary":"\u003cp\u003e在上一篇关于 clice 的 \u003ca href=\"https://www.ykiko.me/zh-cn/articles/13394352064\"\u003e文章\u003c/a\u003e 发布后，收到的反响远超我的预期，也有很多朋友说想要参与到开发中。有这份心是好的，但是参与进来的门槛却不低，主要难点就是在于和 Clang 的 API 进行交互。难在哪呢？一方面在于无论是中文还是英文社区，互联网上关于 Clang 的资料相对较少（这是预期的，因为相关的需求非常少，自然而然也就没什么人讨论了）。另一方面，由于 C++ 语言本身的复杂性，有很多细节需要对语言本身有较为深入的理解然后才能理解，把理论和实现联系起来也并不那么容易。\u003c/p\u003e","title":"深入探索 clang（上）"},{"content":"距离上一次发布博客已经过去几个月了，之所以这么久没有更新呢，是因为我这段时间一直忙于 clice —— 一个全新的 C++ 语言服务器 (language server)。\n可能有的读者对语言服务器这个概念有些陌生。不过你肯定使用过 IDE，比如 Visual Studio 或者 CLion，体验过这些 IDE 提供的代码补全、跳转、重构等功能。在传统的 IDE 中，上述特性是由 IDE 的插件或者内置功能实现的，这种方式导致每种语言需要为每个编辑器单独开发支持，维护成本很高。而当初微软在发布 Visual Studio Code 时，希望能解决这个问题，于是提出了 LSP(Language Server Protocol) 这个概念。LSP 提出了标准的客户端-服务器模型。语言功能由一个独立的语言服务器提供，VSCode 只需要实现一个通用的客户端，与语言服务器通信即可。这种方法解耦了编辑器和语言支持，使得 VSCode 能轻松支持多种语言。例如，你想要查看一个 method 的 implementation，那么你的编辑器就会向语言服务器发送一个 go to implementation 的请求，请求的具体内容呢，可能就是文件的路径和当前光标在源文件中的位置。语言服务器会对这个请求进行处理，同样返回给你一个文件位置一个坐标，编辑器从而根据这个结果打开对应的文件进行跳转。\nclice 就是这样一个语言服务器，用于处理 C/C++ 相关代码的请求。这个名字来源于我的头像 alice，把首字母替换为代表 C/C++ 的 c 就得到了 clice。\n经过了几个月的设计和开发，项目已经颇具雏形，但是预计离投入使用还需要几个月的时间来进行完善。这篇文章的主要内容是对 clice 的设计和实现进行介绍，也是我个人对于当前开发的一个阶段性总结。虽然是语言服务器相关的内容，但其实涉及到大量 C/C++ 编译相关知识的科普，感兴趣的读者可以继续往下阅读。\n同时，如果你有任何功能上的请求/建议，欢迎评论区留言讨论。在下一阶段的开发中，我会尽可能考虑它们。\nWhy a new language server? 那么第一个问题，为什么要去开发一个新的语言服务器？重复造轮子有必要吗？\n这个问题值得去认真回答。在这个项目之前，我自己也编写过或大或小的很多项目。但它们绝大多数都是 toy project，只是为了验证某个想法或者个人学习而编写的，并没有解决任何的实际问题。clice 并不是这样的，它确实打算解决现有的问题（具体的问题后面再说），而不是为了重写而重写。\n在今年年初，我想参与到 LLVM 项目的开发中。我想从我较为熟悉的地方，C++，也就是 Clang 开始。但是，没需求的话，总不能干瞪源码吧。一般这种时候的正常流程是从一些 first issue 开始，一点点熟悉项目。但是我觉得这样很无聊，一上来我就想来干点大的，比如实现某个 C++ 新标准的特性。但是，我发现这里几乎没有我能插手的地方，新特性的实现几乎总是由几位 Clang 的核心开发者完成。好吧，既然这里没什么机会，那就看看别的地方吧。注意力自然而然地转移到了 clangd 身上去了，毕竟我主要使用 VSCode 进行开发，而 VSCode 上最好用的 C++ 语言服务器就是 clangd 了。\n当时我对 clangd 一无所知，只是发现它似乎对关键字的高亮渲染并不正确。然后呢我就开始一边阅读 clangd 的源码，一边翻翻 clangd 数量众多的 issue 看看有没有什么我能解决的。在翻了几百个 issue 过后，我发现这里的问题还真不少。当时我特别感兴趣的是一个有关模板内代码补全的 issue，为什么我对这个感兴趣呢？熟悉我的读者可能知道，我算是一个资深的元编程玩家了，在这之前也写过很多相关的文章。那很自然的，我不仅仅好奇模板元它本身是如何运作的，也好奇 Clang 作为一个编译器是如何实现相关的特性的，这个 issue 对我来说是一个很好的切入点。在花了几个星期摸索原型实现后，我初步解决了那个 issue，但是，这时我发现根本没有人可以 review 相关的代码！\n一番调查过后，我发现 clangd 目前的情况很糟糕。让我们来捋一捋时间线，clangd 最初只是 LLVM 内部一个简单的小项目，在功能性和易用性上都不出色。正如 MaskRay 在 ccls 中的这篇博客中所说，clangd 在当时只能处理单个编译单元，跨编译单元的请求无法处理。这篇博客发布的时间是 2017 年，这也是为什么 MaskRay 选择编写 ccls 的一个原因。ccls 也是一个 C/C++ 语言服务器，在当时那个节点上是强于 clangd 的。但是，后来，Google 开始派人对 clangd 进行改进以满足他们内部的大型代码库需求。与此同时，LSP 标准的内容也在不断地扩充，clangd 在不断地跟进新标准的内容，但是 ccls 的作者似乎逐渐忙于其他事情没有太多的时间去维护 ccls。于是最后，总体上 clangd 已经超越了 ccls。事情的转折发生在大概 2023 年，clangd 对 Google 内部来说，似乎已经达到了一个高度可用的状态，原先负责 clangd 的几位员工也被调离去做其他事情了。目前来说，clangd 的 issue 主要只有 HighCommander4 一个人在负责处理，纯粹出于热爱，并没有被任何人雇佣。由于并没有被专门雇佣来维护 clangd，所以他只能在有限的空闲时间来处理 issue，而且也仅限于答疑和十分有限的 review。正如他在这条 评论 中提到的一样：\nThe other part of the reason is lack of resources to pursue the ideas we do have, such as the idea mentioned above of trying to shift more of the burden to disk usage through more aggressive preamble caching. I\u0026rsquo;m a casual contributor, and the limited time I have to spend on clangd is mostly taken up by answering questions, some code reviews, and the occasional small fix / improvement; I haven\u0026rsquo;t had the bandwidth to drive this type of performance-related experimentation.\n另一部分原因是缺乏资源去实践我们已有的一些想法，例如上面提到的通过更积极的预编译缓存，将更多的负担转移到磁盘使用上的想法。我只是一个非正式的贡献者，我能投入到 clangd 上的时间非常有限，主要用于回答问题、进行一些代码审查以及偶尔的小修复或改进；我没有足够的精力来推动这种与性能相关的实验。\n既然如此，那么像为 clangd 初步支持 C++20 module 这样的大型 PR 被拖了将近一年也就不奇怪了。意识到这个现状之后，我萌生了自己编写一个语言服务器的想法。我估计了一下项目大小，去除测试代码，大概 2w 行就能完成，是一个人花一段时间能完成的工作量，而且也有先例，例如 ccls 和 Rust analyzer。另外一点就是 clangd 的代码已经上了年代了，尽管有非常多的注释，但是相关的逻辑仍然很绕，进行大范围修改所花费的时间可能还不如重写来得快。\n于是说干就干，我对 clangd 的几百个 issue 进行了分类，看看有没有一些问题是因为 clangd 一开始的架构设计错误而导致很难解决，然后被搁置的。如果有的话，是否能在重新设计的时候就考虑这个问题来解决呢？我发现，确实有一些！于是接下来的时间里，我花了大概两个月的时间来学习研究 Clang 里面相关的机制，摸索相关问题的解决方法，探索原型实现，在确定相关的问题基本都可以解决之后，正式开始了 clice 的开发。\nImportant improvement 前面说了那么多，还是先来看看 clice 到底解决了 clangd 中现存的哪些重大问题。主要侧重于功能介绍，至于实现原理会放在 Design 小节。除了这些重要的改进之外，当然也还有很多小功能上的改进，这里就不一一列出了。\nBetter template support 那首先，就是更好的模板支持，这也是我最开始想要 clangd 支持的特性。具体来说目前在处理模板上有什么问题呢？\n以代码补全为例，考虑如下的代码，^ 代表光标位置：\ntemplate \u0026lt;typename T\u0026gt; void foo(std::vector\u0026lt;T\u0026gt; vec) { vec.^ } 在 C++ 中，如果一个类型依赖于模板参数，那么在模板实例化之前，我们并不能对它做出任何准确的假设。例如这里的 vector 即可能是主模板也可能是 vector\u0026lt;bool\u0026gt; 的偏特化，选哪一个呢？对于代码编译来说，准确性永远是最重要的，不能使用任何可能导致错误的结果。但是对于语言服务器来说，提供更多可能的结果往往比什么都不提供更好，我们可以假设用户在更多时候使用主模板而不是偏特化，从而基于主模板来提供代码补全的结果。目前 clangd 也确实是这么做的，在上述情况下它会根据 vector 的主模板为你提供代码补全。\n再考虑一个更加复杂的例子：\ntemplate \u0026lt;typename T\u0026gt; void foo(std::vector\u0026lt;std::vector\u0026lt;T\u0026gt;\u0026gt; vec2) { vec2[0].^ } 从用户的角度来说，这里也应该提供补全，毕竟 vec2[0] 的类型不也是 vector\u0026lt;T\u0026gt; 吗？和前面一个例子一样。但是 clangd 在这里却不会为你提供任何补全，问题出在哪里？根据 C++ 标准，std::vector\u0026lt;T\u0026gt; 的 operator[] 返回的类型是 std::vector\u0026lt;T\u0026gt;::reference，这其实是一个 dependent name，它的结果似乎相当直接，就是 T\u0026amp;。但是 libstdc++ 中它的定义却嵌套了十几层模板，似乎是为了兼容旧标准？那为什么 clangd 不能处理这种情况呢？\n它基于主模板假设，不考虑偏特化可能会使查找无法进行下去 它只进行名称查找而不进行模板实例化，就算找到了最后的结果，也没法把它和最初的模板参数映射起来 不考虑默认模板参数，无法处理由默认模板参数导致的依赖名 尽管我们可以对标准库的类型开洞来提供相关的支持，但是我希望用户的代码能和标准库的代码有相同的地位，那么我们就需要一种通用的算法来处理依赖类型。为了解决这个问题，我编写了一个伪实例化器（pseudo instantiator）。它能在没有具体类型的前提下对依赖类型进行实例化，从而达到化简的目的。比如上面这个例子里面的 std::vector\u0026lt;std::vector\u0026lt;T\u0026gt;\u0026gt;::reference 就能被化简为 std::vector\u0026lt;T\u0026gt;\u0026amp;，进一步就能为用户提供代码补全选项。\nHeader context 为了让 clangd 正常工作，用户往往需要提供一份 compile_commands.json 文件（后文简称 CDB）。C++ 的传统编译模型的基本编译单元是一个源文件（例如 .c 和 .cpp 文件），#include 只是把头文件的内容粘贴复制到源文件中对应的位置。而上述 CDB 文件里面就储存了各个源文件对应的编译命令，当你打开一个源文件的时候，clangd 会使用其在 CDB 中对应的编译命令来编译这个文件。\n那很自然的就有一个疑问，既然 CDB 里面只有源文件的编译命令，没有头文件的，那么 clangd 是如何处理头文件的呢？其实 clangd 会把头文件当做一个源文件进行处理，然后呢，根据一些规则，比如使用对应目录下的源文件的编译命令作为该头文件的编译命令。这样的模型简单有效，但是却忽略了一些情况。\n由于头文件是源文件的一部分，那么就会出现它的内容根据它在源文件中前面的内容不同而不同的情况。例如：\n// a.h #ifdef TEST struct X { }; #else struct Y { }; #endif // b.cpp #define TEST #include \u0026#34;a.h\u0026#34; // c.cpp #include \u0026#34;a.h\u0026#34; 显然，a.h 在 b.cpp 和 c.cpp 中具有不同的状态，一个定义了 X，一个定义了 Y，如果简单的把 a.h 当做一个源文件进行处理，那么就只能看得到 Y。\n一个更极端的情况是 non-self-contained 头文件，例如：\n// a.h struct Y { X x; }; // b.cpp struct X {}; #include \u0026#34;a.h\u0026#34; a.h 自身不能被编译，但是嵌入到 b.cpp 中的时候就编译正常了。这种情况下 clangd 会在 a.h 中报错，找不到 X 的定义。显然这是因为它把 a.h 当成一个独立的源文件了。在 libstdc++ 的代码中就有很多这样的头文件，现在流行的一些 C++ 的 header-only 的库也有些有这样的代码，clangd 目前无法处理它们。\nclice 将会支持头文件上下文 (header context)，支持自动和用户主动切换头文件的状态，当然也会支持非自包含的头文件。我们想要实现如下的效果，以最开始那份代码为例。当你从 b.cpp 跳转到 a.h 的时候使用 b.cpp 作为 a.h 的上下文。同理，当你从 c.cpp 跳转到 a.h 的时候则使用 c.cpp 作为 a.h 的上下文。\nFully C++20 module support C++20 引入了 module 这个新特性，用于加速编译。与传统的编译模型不同，模块单元之间可能具有依赖关系。这要求我们进行一些额外的处理，尽管为 clangd 初步支持 module 的 PR 已经合并了，但它处于相当早期的状态。\n不同文件之间不会共享预编译的模块，这导致了模块的重复编译 其他配套的 LSP 设施没有及时跟进，比如为模块名提供高亮和跳转，还有提供类似头文件的补全 只支持 Clang 不支持其他的编译器 clice 将会提供编译器和构建系统无关的 C++20 module 支持，项目本身之后也会完全迁移到 module 上。\nBetter index format 有一些 ccls 的用户可能会抱怨，明明同样是预先索引整个项目，ccls 可以在打开文件的瞬间进行跳转，但是 clangd 仍需要等待文件解析完成才行。为什么会造成这种结果？这其实是 clangd 的索引格式设计缺陷导致的。什么是索引？由于 C/C++ 支持向前声明，于是声明和定义可能在不同的源文件，于是我们需要处理跨编译单元的符号关系。\n但是解析文件是一个相当耗时的操作，如果等到需要查询的时候再去解析文件，那么查询的时间将会是一个天文数字。为了支持快速查找符号关系，language server 一般会预先索引整个项目。但是究竟采用何种格式储存相关的数据？这个并没有标准规定。\nclice 充分参考了现有的索引设计，设计了一种更加高效的索引格式。也可以达到 ccls 的效果，如果预先索引了项目，不需要等待就能立马获取响应。\nDesign 这一小节将会更加具体的谈谈 clice 的设计与实现。\nServer 首先 language server 也是一个 server，在这方面和一个传统的服务器没什么区别。使用事件驱动的编程模型，接收服务器的请求并进行处理。由于可以使用 C++20，那当然要体验一下使用无栈协程来进行异步编程了。clangd 的代码中有大量的回调函数，这部分代码可读性相当差。使用无栈协程可以避免类似的回调地狱出现。\n比较值得注意的是，在库的选取方面，并没有选择现成的协程库，而是自己使用 C++20 的协程设施对 libuv 封装出了一个简单的协程库。原因有以下几点：\nLLVM 项目是不使用异常的，我们尽量和它保证一致，直接封装 C 语言的库可以让我们更好的控制这一点 language server 的事件模型相当简单，一对一连接。在主线程处理 IO 相关的请求，线程池负责耗时任务执行就完全足够了。在这个模型下，甚至不需要使用任何锁这样的同步原语来进行线程间通信。所以一般的网络库的模型对于 clice 来说过于复杂了 最后也成功在 C++ 中复刻类似 Python 和 JS 中的异步编程体验，非常的愉快和轻松。\nHow it works? 接下来我们来详细谈谈，clice 是如何处理某些特定的请求的。\n首先当用户在编辑器中打开或者更新某个文件的时候，编辑器会发送相关的通知给 clice。clice 在收到请求后，会 parse 该文件。更具体的来说，会将该文件 parse 成 AST(Abstract Syntax Tree)。由于 C++ 的语法相当复杂，靠自己手写一个 parser 是不现实的，我们和 clangd 一样选择使用 Clang 提供的接口来 parse 源文件。\n在获取 AST 之后，对 AST 进行遍历收集我们感兴趣的信息即可。以 SemanticTokens 为例，我们需要遍历 AST 去为源码中的每个 token 添加语义信息，是 variable 还是 function？是不是 const？是不是 static？等等。总之一切这些信息都可以从 AST 中获取。想要对这个有更深入理解的话，可以阅读我之前写过的一个关于 Clang AST 的入门 文章。\n绝大多数请求都可以通过类似上述的方式实现，比较特殊的是代码补全（CodeCompletion）和签名帮助（SignatureHelper）。由于补全点的语法可能并不完整，在常规的编译流程中，如果语法节点不完整，Clang 可能会直接把它当做一个错误节点，甚至整个丢掉，又或者直接 fatal error 终止编译。无论如何，都是我们不能接受的。一般来说，为了实现代码补全的功能，需要 parser 开洞进行特殊的处理。Clang 也不例外，它提供了一个特殊的 code completion 模式，通过继承 CodeCompleteConsumer 并重写其中相关的方法来获取相关的信息。\n可以通过一个特殊的编译选项来体验这一功能：\n-std=c++20 -fsyntax-only -Xclang -code-completion-at=\u0026#34;example.cpp:1:3\u0026#34; 假设源文件是\ncon 则预期输出是\nCOMPLETION: const COMPLETION: consteval COMPLETION: constexpr COMPLETION: constinit 可以发现结果就是四个 C++ 关键字的补全，并且没有任何的错误警告。\n嗯，这就是整个流程了，是不是听起来相当简单。的确，这部分遍历 AST 的逻辑是相当清晰的。只是有很多 corner case 需要考虑，只需要慢慢堆时间实现功能然后慢慢迭代修 BUG 就行了。\nIncremental compilation 由于用户可能频繁变更文件，如果每次都需要重新 parse 整个文件，当文件非常大的时候，parse 时间会非常慢，响应请求时间会非常长（考虑到 #include 就是粘贴复制，很容易就可以造出一个巨大的文件）。想象一下，如果按下一个字母，过了几秒钟代码补全结果才出来，那么体验将会多么糟糕！\n怎么办呢？答案就是增量编译 (Incremental Compilation)。也许你在学习 CMake 等构建工具的时候听说过这个词，但是它们是有一些区别的。构建工具所指的增量编译粒度是一个文件，只重新编译有变更的文件。但显然这对我们来说是不够的，LSP 的最基本请求单位就是文件，我们需要更细粒度的增量编译。\nClang 提供了一种叫做 Precompiled Header(PCH) 的机制，可以用于将某一段代码在编译成 AST 之后序列化到磁盘上，然后在之后编译的时候进行复用。\n例如\n#include \u0026lt;vector\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;iostream\u0026gt; int main() { std::vector\u0026lt;int\u0026gt; vec; std::string str; std::cout \u0026lt;\u0026lt; \u0026#34;Hello, World!\u0026#34; \u0026lt;\u0026lt; std::endl; } 我们可以将该文件代码的前三行编译成 PCH 缓存起来，这样即使用户频繁修改文件内容，但是只要不修改前三行，我们就可以直接复用 PCH 来进行编译，从而大大减少编译时间，这部分代码就叫 preamble。如果变更了 preamble 则需要重新生成一个新的 PCH 文件。现在你应该理解为什么在第一次打开文件的时候 clangd 会需要反应很久，但是之后的响应就会非常快了，正是这种 preamble 的优化在起作用。如果你希望优化项目的构建时间，也可以考虑使用 PCM，不仅 Clang，GCC 和 MSVC 也都支持类似的机制来进行细粒度的增量编译。\nPCH 好是好，但是呢，它的依赖关系只能是线性的。你可以用一个 PCH 去构建一个新的 PCH，只要它位于另外一个文件的前面几行。但是你不能说，用两个 PCH 去构建一个新的 PCH。那如果有这种有向无环图的依赖关系怎么办呢？答案就是 C++20 加入的 module。C++20 加入的 module 基本就是 PCH Pro 版，实现原理是完全类似的，只是放开了依赖链的限制，允许一个 module 依赖其他几个 module。\n至于关于如何支持 C++20 的 module 呢？话题有些大，值得单独开一个文章讨论，这里就不详细展开了。\nConclusion 嗯，暂时就先写到这里吧。其实还有很多话题没有谈到，但是细想过后，发现每个单独展开都能写一篇长文出来了。就留到日后慢慢补充吧，这篇文章就当开个头。我在项目的 issue 中也会定期更新一些进展，感兴趣的读者可以关注一下。\n","permalink":"https://www.ykiko.me/zh-cn/articles/13394352064/","summary":"\u003cp\u003e距离上一次发布博客已经过去几个月了，之所以这么久没有更新呢，是因为我这段时间一直忙于 \u003ca href=\"https://github.com/clice-project/clice\"\u003eclice\u003c/a\u003e —— 一个全新的 C++ 语言服务器 (language server)。\u003c/p\u003e","title":"一个新 C++ language server 的设计与实现"},{"content":"因为某些机缘巧合参与了上周的 WG21 会议（C++ 标准委员会会议）。虽然我经常浏览 C++ 标准的新提案，但是确实没想到有一天真的能参加 WG21 会议，实时了解 C++ 标准的最新进展。当然，这也是第一次参加，非常激动，在这里记录一下自己的感受和会议的进展。\n起因 事情的起因是，今年一月份，当时我正在琢磨怎么写一个高效的 small_vector。去参考了下 LLVM 的源码，发现里面对满足 trivially destructible 的类型特化了实现，采用 bitwise copy 进行扩容之类的操作。当时不太理解为什么能这么做。后来了解到 trivially copyable 这个概念，进一步了解到了 relocatable 的概念。又阅读了几篇相关的提案，于是就有了这篇讨论 trivially relocatable 的 文章。\n没过几天，我的一个好友 blueloveTH ，他就问我能不能帮他的项目写一个轻量级的 small_vector 呢？这个项目就是 pocketpy，一个轻量级的 Python 解释器。那我一想，这不巧了吗，我前几天刚刚研究过这个东西，于是花了几个小时就写好了一个支持 trivially relocatable 优化的非常轻量的 small_vector。很巧的是，这个项目也是我今年 GSoC 申请参加的项目。\n五月一号那天，我收到了两封邮件，一份是 GSoC 委员会告知申请通过的邮件。另一份就是来自 P1144(trivially relocatable) 作者 Arthur O\u0026rsquo;Dwyer 的邮件。我当时很困惑，他怎么突然给我发邮件呢，我和他并不认识啊。原来他会在 GitHub 上定期使用 trivially relocatable 作为关键字搜索相关的 C++ 项目，并和项目的作者交流一些想法。因为搜索到了 pocketpy 里面的代码，所以就给我们发了邮件，他好像也搜到了我个人博客中那篇讨论 trivially relocatable 的 文章。一开始通过邮件简单的交流了一下，后来我们又在 slack 上讨论了提案里面的一些内容。\n在讨论结束的时候，他邀请我参加这次的 WG21 会议。原因是当时 C++ 中关于 trivially relocatable 现状是，委员会打算采用一个不靠谱的提案 P2786，而不是更完整的提案 P1144。Arthur O\u0026rsquo;Dwyer 希望我们这些 P1144 的支持者，能表达一些赞成。后来我就写了一封邮件给 ISO 申请作为游客 (guest) 线上参与会议，过了三个星期都没回复，我本来都快以为参加不了了。结果在会议开始前三天，Herb Sutter 终于给我回复了一封邮件说：他以为所有的邮件都已经回复了，但不知怎么的忘记了我的，然后说我的申请通过了，欢迎参与会议。\n这里有一点小乌龙，后来在开幕活动的时候 Herb Sutter 在统计参与的国家个数。具体方式就是一个个国家喊，有参与的话就举个手。喊到 China 的时候，我有点激动，一直没找到举手键。最后，他发现没有人举手的时候还说，他明明记得这次会议有中国人参与的。\nC++ 标准演进方式 为了之后方便介绍会议进展，先简单介绍一下 C++ 委员会的运作方式。\nC++ 有 SG1~SG23 一共 23 个研究小组，分别负责讨论不同的主题。例如编译时元编程就是由 SG7 小组负责讨论的。\n在小组讨论通过之后，根据提案内容是有关语言特性还是标准库特性，分别交给 EWG(Evolution Working Group) 和 LEWG(Library Evolution Working Group) 进行审核。如果审核通过，再进一步提交给 CWG(Core Working Group) 和 LWG(Library Working Group) 来修正提案中的相关措辞，使得其能纳入到 C++ 标准中。\n最后，通过 CWG 或 LWG 的提案会在全体会议上 (plenary) 进行投票，如果投票通过，就会正式加入到 C++ 标准中。\n这次 St. Louis 会议的流程是，周一早上开幕活动。下午的时候，各小组就开始分别讨论自己的议程了，都是同时进行的。而我主要待在 EWG 会议室里面，然后 guest 是可以参与小组投票的，但是不能参与最后的全体会议投票。\n会议进展 先简单说说确定通过的提案，然后谈谈一些重要提案现在的进展。\n通过的提案 核心语言方面主要通过了下面这几个提案：\nconstexpr placement new 支持在常量求值中直接使用 placement new 来调用对象的构造函数，在此之前只能使用 std::construct_at，而它相当于 placement new 的一个小括号特化版本。关于这一点的详细讨论，可以阅读一下我这篇介绍 constexpr 发展史的 博客 deleting a pointer to an incomplete type should be ill-formed 现在 delete 一个不完整类型的指针会直接编译错误，而不是导致未定义行为 ordering of constraints involving fold expressions 明确了涉及到折叠表达式的约束的偏序规则 structured binding declaration as a condition 结构化绑定现在可以用于 if 语句的条件中 标准库方面主要通过了下面这几个提案：\ninplace_vector 注意 inplace_vector 与 small_vector 不同，后者在 SBO 的容量不足的时候会进行动态内存分配，而前者则不会。它相当于一个 dynamic array，可以方便的当做 buffer 使用。 std::is_virtual_base_of 用于判断一个类是否是另一个类的虚基类 std::optional range support 支持 optional 的 range 操作 std::execution 争论了很久的 std::execution 终于进入标准 有重大进展的提案 这几天我基本上一直待在 EWG 会议室，所以就主要说说核心语言方面的一些进展。\n周一下午和周二一整天，EWG 都在讨论 Contract。相比于上次的 Tokyo 会议，就 Contract 的某些争论达成了一些共识，但是仍然有没有达成共识的地方。我个人认为加入 C++26 的希望仍然不大。\n周三上午，EWG 在讨论 Reflection for C++26。最后以 0 票反对的结果（其中也有我的一票 super favor）通过，移交给 CWG 进行措辞的修改以纳入 C++ 标准。周四和周五 CWG 已经 review 了一部分内容，但是提案内容太多了，没有 review 完。如果一切顺利的话，预计再过两到三次会议可以正式加入 C++26。从投票结果可以看出，所有人都认为 C++ 需要反射，反射非常有希望加入 C++26。\n周五上午，EWG 主要在讨论 trivially relocatable。在之前的会议中 P2786 已经通过了 EWG 投票被移交给 CWG 了。然而，它并不完善，有很多问题，这样的提案加入标准无疑是对 C++ 发展有害的。自上次 Tokyo 会议之后，出现几份新的讨论 trivially relocatable 的提案：\nIssues with P2786 Please reject P2786 and adopt P1144 Analysis of interaction between relocation, assignment, and swap 显然，它们都把矛头指向了 P2786。在这几份提案作者演讲结束后，需要投票来决定是否要将 P2786 从 CWG 返回到 EWG，也就是重新考虑 C++26 将会采用的 trivially relocatable 模型。最后以压倒性的优势，P2786 被返回到了 EWG，当然我投的是 super favor，毕竟这就是我参加这次会议的主要目的。至于 P1144，可能要等下次会议了，这次会议并没有讨论它。\n剩下的就是一些小的提案进展了，值得一提的是有许多有关 constexpr 的提案都通过了 EWG，它们分别是：\nLess transient constexpr allocation Allowing exception throwing in constant-evaluation Emitting messages at compile time 但是之后通过 CWG 的希望有多大还不好说。如果对某个提案的最新进展感兴趣的话，直接在 ISO C++ GitHub 的 issues 里面搜索提案号就可以了，里面会详细记录对应提案的最新进展。\n一些感受 会议进展说完了，现在来说说个人的一些感受。\n首先就是关于 trivially relocatable 的投票，其实在投完之后，我突然有一种负罪感。原因是，在最后的投票之前 P2786 的作者说：\nif other people want to make modifications and bring forward their own paper, you know, as an author, I am not going to say, \u0026lsquo;No, don\u0026rsquo;t; it\u0026rsquo;s my paper.\u0026rsquo; If it\u0026rsquo;s a good change, you know, that\u0026rsquo;s good.\n我可以明显听出他说这段话的时候是带着哭腔的。换位思考一下，我想我也可以理解他的心情，他肯定倾注了很多心血在这份提案上，以如此不光彩的方式撤回提案实在是让人难以接受。但其实 P1144 的作者付出了更多心血，提案版本都已经出到了 R11，内容本身也更完善，但却一直被忽视。我很难理解为什么会出现这样的局面。\n另外就是全体会议投票上的一些情况，Structured Bindings can introduce a Pack 这个提案，也就是支持结构化绑定的时候引入参数包\nauto [x, ... pack] = std::tuple{1, 2, 3, 4}; 本来它都通过 CWG 了，但在全体会议上，一位编译器供应商临时指出了措辞中的某些示例在该提案中的参考实现会导致编译器崩溃。于是最后全体会议投票就没有通过。\n类似的情况发生在 std::execution 上，在全体会议投票之前，有人指出 std::execution 不应该加入 C++26，它过于复杂且不够成熟，作者们只是在空谈，没有考虑过实际的应用场景。此外，重度使用模板导致编译速度十分缓慢，经常造成编译器 internal compiler error。虽然最后投票的结果是赞同大于反对，但是 C++ 委员会强调的是达成共识 (consensus)，而不是少数服从多数，比例要达到一定程度才算通过，所以按理说该提案这次会议是不该通过的，但是最后由于某些原因还是通过了。具体咋回事，我当时没太注意听，也不太好说。\n说实话，这次参会，知识性的收获不多，见识倒是涨了不少。有些争论我感觉和平常网络上对线也没啥区别，从各自的角度来看，双方的观点都是对的，这也很合理，不是所有的事情都有绝对的对与错，很多问题都没有一个完美的解决方案，在软件工程领域尤其如此。那为了加入标准，总要做出一些妥协，究竟在哪些地方妥协，谁向谁妥协呢？往往伴随着一些激烈的争论，甚至由一些其他的场外（问题本身之外）的因素决定。\n之后如果有机会的话可能还会继续参加，最好能线下参加一次。不过肯定不会像这次会议一样，几乎每天都准时准点的出席每一场会议了（第一次参加有点激动）。可能主要听听感兴趣的部分，我实在太想看到反射进入 C++26 的那一刻了\n好了，到这里文章就结束了，感谢阅读。\n","permalink":"https://www.ykiko.me/zh-cn/articles/706509748/","summary":"\u003cp\u003e因为某些机缘巧合参与了上周的 WG21 会议（C++ 标准委员会会议）。虽然我经常浏览 C++ 标准的新提案，但是确实没想到有一天真的能参加 WG21 会议，实时了解 C++ 标准的最新进展。当然，这也是第一次参加，非常激动，在这里记录一下自己的感受和会议的进展。\u003c/p\u003e","title":"St. Louis WG21 会议回顾"},{"content":"参加了 Google Summer of Code 2024，主要的任务就是为一个 Python 解释器 实现 pybind11 的兼容性接口。说是实现兼容性接口，实际上相当于重写 pybind11 了，所以最近一直在读它的源码。\n可能有的读者不太清楚 pybind11 是什么，简单来说 pybind11 是一个中间件，让你可以方便进行 Python 与 C++ 代码之间的交互。比如在 C++ 中内嵌 Python 解释器，或者把 C++ 代码编译成动态库以供 Python 调用。具体的内容还请见官方文档。\n最近基本把框架大体的运作逻辑理清了。现在回过头来看，pybind11 不愧是 C++ 和 Python 绑定的事实标准，有很多巧妙的设计。它这套交互逻辑也完全可以套用到 C++ 和其他有 GC 的语言的交互上，比如 JS 和 C#（虽然现在并没有 jsbind11 和 csharpbind11 之类的东西）。最近可能我会写一系列相关的文章，去掉一些繁琐的细节，介绍其中一些共用的思想。\n这篇文章主要是讨论 pybind11 对象设计中一些有意思的点。\nPyObject 我们都知道 Python 中，一切皆对象，全都是 object。但是 pybind11 实际上是需要和 CPython 这种 Python 的具体实现打交道的。那一切皆对象在 CPython 中的体现是什么呢？答案是 PyObject*。接下来让我们「看见」 Python，理解实际的 Python 代码是如何运作在 CPython 中的。\n创建一个对象实际上就是创建一个 PyObject*\nx = [1, 2, 3] CPython 中有专门的 API 来创建内建类型的对象，上面这句话大概就会被翻译成\nPyObject* x = PyList_New(3); PyList_SetItem(x, 0, PyLong_FromLong(1)); PyList_SetItem(x, 1, PyLong_FromLong(2)); PyList_SetItem(x, 2, PyLong_FromLong(3)); 这样的话，is 的作用就很好理解了，就是用来判断两个指针的值是否相同。而所谓的默认浅拷贝的原因也就是因为默认的赋值只是指针的赋值，不涉及它指向的元素。\nCPython 也提供了一系列的 API 用来操作 PyObject* 指向的对象，例如\nPyObject* PyObject_CallObject(PyObject* callable_object, PyObject* args); PyObject* PyObject_CallFunction(PyObject* callable_object, const char* format, ...); PyObject* PyObject_CallMethod(PyObject* o, const char* method, const char* format, ...); PyObject* PyObject_CallFunctionObjArgs(PyObject* callable, ...); PyObject* PyObject_CallMethodObjArgs(PyObject* o, PyObject* name, ...); PyObject* PyObject_GetAttrString(PyObject* o, const char* attr_name); PyObject* PyObject_SetAttrString(PyObject* o, const char* attr_name, PyObject* v); int PyObject_HasAttrString(PyObject* o, const char* attr_name); PyObject* PyObject_GetAttr(PyObject* o, PyObject* attr_name); int PyObject_SetAttr(PyObject* o, PyObject* attr_name, PyObject* v); int PyObject_HasAttr(PyObject* o, PyObject* attr_name); PyObject* PyObject_GetItem(PyObject* o, PyObject* key); int PyObject_SetItem(PyObject* o, PyObject* key, PyObject* v); int PyObject_DelItem(PyObject* o, PyObject* key); 这些函数在 Python 中基本都有直接对应，看名字就知道是干什么用的了。\nhandle 由于 pybind11 要支持在 C++ 中操作 Python 对象，首要任务就是对上述这些 C 风格的 API 进行封装。具体是由 handle 这个类型来完成的。handle 是对 PyObject* 的简单包装，并且封装了一些成员函数\n大概像下面这样\nclass handle { protected: PyObject* m_ptr; public: handle(PyObject* ptr) : m_ptr(ptr) {} friend bool operator== (const handle\u0026amp; lhs, const handle\u0026amp; rhs) { return PyObject_RichCompareBool(lhs.m_ptr, rhs.m_ptr, Py_EQ); } friend bool operator!= (const handle\u0026amp; lhs, const handle\u0026amp; rhs) { return PyObject_RichCompareBool(lhs.m_ptr, rhs.m_ptr, Py_NE); } // ... }; 大部分函数都是像上面这样简单包装一下，但有一些函数比较特殊。\nget/set 根据 C++ 之父 Bjarne Stroustrup 在《The Design and Evolution of C++》中的说法，引入引用（左值）类型的部分原因是为了使得用户能够对返回值进行赋值，让 [] 这样的运算符的重载变的更加自然。例如：\nstd::vector\u0026lt;int\u0026gt; v = {1, 2, 3}; int x = v[0]; // get v[0] = 4; // set 如果没有引用，就只能返回指针，那么上面的代码就得写成这样\nstd::vector\u0026lt;int\u0026gt; v = {1, 2, 3}; int x = *v[0]; // get *v[0] = 4; // set 相比之下，使用引用是不是美观的多呢？这个问题在其他编程语言中也存在，但不是所有语言都采用这种解决办法。例如，Rust 选择自动解引用，编译器在合适的时机自动添加 * 来解引用，这样也就不需要多写上面那个 * 了。但是，这两种方法对 Python 来说都不行，因为 Python 中根本没有解引用这个说法，也不区分什么左值和右值。那怎么办呢？答案是区分 getter 和 setter。\n例如，如果要重载 []：\nclass List: def __getitem__(self, key): print(\u0026#34;__getitem__\u0026#34;) return 1 def __setitem__(self, key, value): print(\u0026#34;__setitem__\u0026#34;) a = List() x = a[0] # __getitem__ a[0] = 1 # __setitem__ Python 会检查语法结构，如果 [] 出现在 = 的左边，就会调用 __setitem__，否则就会调用 __getitem__。实际上有挺多语言采用类似的设计的，例如 C# 的 this[] 运算符重载。\n甚至连 . 运算符都可以重载，只需要重写 __getattr__ 和 __setattr__：\nclass Point: def __getattr__(self, key): print(f\u0026#34;__getattr__\u0026#34;) return 1 def __setattr__(self, key, value): print(f\u0026#34;__setattr__\u0026#34;) p = Point() x = p.x # __getattr__ p.x = 1 # __setattr__ pybind11 希望 handle 也能实现这样的效果，即在合适的时机调用 __getitem__ 和 __setitem__。例如：\npy::handle obj = py::list(1, 2, 3); obj[0] = 4; // __setitem__ auto x = obj[0]; // __getitem__ x = py::int_(1); 对应的 Python 代码是\nobj = [1, 2, 3] obj[0] = 4 x = obj[0] x = 1 accessor 接下来就让我们重点讨论如何实现这样的效果。首先考虑 operator[] 的返回值，由于可能要调用 __setitem__，所以这里我们返回一个代理对象。里面会把 key 存下来以备后续调用\nclass accessor { handle m_obj; ssize_t m_key; handle m_value; public: accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) { m_value = PyObject_GetItem(obj.ptr(), key); } }; 下面一个问题就是如何区分 obj[0] = 4 和 x = int_(1)，使得前面一种情况调用 __setitem__，后面一种情况就是简单的对 x 赋值。注意到上面两种情况的关键性区别，左值和右值\nobj[0] = 4; // assign to rvalue auto x = obj[0]; x = 1; // assign to lvalue 如何让 operator= 根据操作数的值类别 (value category) 调用不同的函数呢？这就要用到一个比较少见的小技巧了，我们都知道可以在成员函数上加上 const 限定符，从而允许这个成员函数在 const 对象上调用。\nstruct A { void foo() {} void bar() const {} }; int main() { const A a; a.foo(); // error a.bar(); // ok } 除此之外，其实还可以加引用限定符 \u0026amp; 和 \u0026amp;\u0026amp;，效果就是要求 expr.f() 的这个 expr 是左值还是右值。这样我们就可以根据左值和右值调用不同的函数了。\nstruct A { void foo() \u0026amp; {} void bar() \u0026amp;\u0026amp; {} }; int main() { A a; a.foo(); // ok a.bar(); // error A().bar(); // ok A().foo(); // error } 利用这个特性我们就能实现上面的效果了\nclass accessor { handle m_obj; ssize_t m_key; handle m_value; public: accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) { m_value = PyObject_GetItem(obj.ptr(), key); } // assign to rvalue void operator= (handle value) \u0026amp;\u0026amp; { PyObject_SetItem(m_obj.ptr(), m_key, value.ptr()); } // assign to lvalue void operator= (handle value) \u0026amp; { m_value = value; } }; lazy evaluation 更进一步，我们希望这个代理对象仿佛就像一个 handle 一样，可以使用 handle 的所有方法。这很简单，直接继承 handle 就行了。\nclass accessor : public handle { handle m_obj; ssize_t m_key; public: accessor(handle obj, ssize_t key) : m_obj(obj), m_key(key) { m_ptr = PyObject_GetItem(obj.ptr(), key); } // assign to rvalue void operator= (handle value) \u0026amp;\u0026amp; { PyObject_SetItem(m_ptr, m_key, value.ptr()); } // assign to lvalue void operator= (handle value) \u0026amp; { m_ptr = value; } }; 到这似乎就结束了，但是注意到我们的 __getitem__ 是在构造函数中调用的，也就是说即使后面没用到获取到的值，也会调用。感觉有进一步优化的空间，能不能通过一些手段把这个求值 lazy 化呢？只在需要调用 handle 里面这些函数的时候才去调用 __getitem__ 呢？\n目前这样直接继承 handle 肯定是不行的，不可能在每次成员函数调用之前插入一次判断，然后决定要不要调用 __getitem__。可以让 handle 和 accessor 都继承一个基类，这个基类里面有一个接口，用来实际获取要操作的指针\nclass object_api { public: virtual PyObject* get() = 0; bool operator== (const handle\u0026amp; rhs) { return PyObject_RichCompareBool(get(), rhs.ptr(), Py_EQ); } // ... }; 然后 handle 和 accessor 都继承这个基类，这时候 accessor 就可以在这里对 __getitem__ 进行 lazy evaluation 了。\nclass handle : public object_api { PyObject* get() override { return m_ptr; } }; class accessor : public handle { PyObject* get() override { if(!m_ptr) { m_ptr = PyObject_GetItem(m_obj.ptr(), m_key); } return m_ptr; } }; 这样并不涉及到类型擦除，只是需要子类暴露出一个接口，所以理所应当的我们可以使用 CRTP 来去虚化\ntemplate \u0026lt;typename Derived\u0026gt; class object_api { public: PyObject* get() { return static_cast\u0026lt;Derived*\u0026gt;(this)-\u0026gt;get(); } bool operator== (const handle\u0026amp; rhs) { return PyObject_RichCompareBool(get(), rhs.ptr(), Py_EQ); } // ... }; class handle : public object_api\u0026lt;handle\u0026gt; { PyObject* get() { return m_ptr; } }; class accessor : public object_api\u0026lt;accessor\u0026gt; { PyObject* get() { if(!m_ptr) { m_ptr = PyObject_GetItem(m_obj.ptr(), m_key); } return m_ptr; } }; 这样我们就在不额外引入其他运行时开销的情况下把 __getitem__ 的调用 lazy 化了。\nConclusion 我们常说 C++ 实在是太复杂了，各种眼花缭乱的特性太多了，不同特性之间还经常打架。那换一个角度来看待，特性多，意味着用户就有更多的选择，有更多的设计空间，就能组装出上述这样精彩的设计。我想很难有另外一门语言能实现这样的效果。或许这就是 C++ 的魅力所在吧。\n文章到这里就结束了，感谢你的阅读，欢迎评论区讨论交流。\n","permalink":"https://www.ykiko.me/zh-cn/articles/702197261/","summary":"\u003cp\u003e参加了 \u003ca href=\"https://summerofcode.withgoogle.com/programs/2024/projects/Ji2Mi97o\"\u003eGoogle Summer of Code 2024\u003c/a\u003e，主要的任务就是为一个 \u003ca href=\"https://pocketpy.dev/\"\u003ePython 解释器\u003c/a\u003e 实现 \u003ca href=\"https://github.com/pybind/pybind11\"\u003epybind11\u003c/a\u003e 的兼容性接口。说是实现兼容性接口，实际上相当于重写 pybind11 了，所以最近一直在读它的源码。\u003c/p\u003e","title":"Python 与 C++ 的完美结合：pybind11 中的对象设计"},{"content":"单例模式 (Singleton Pattern) 是一种常见的设计模式，往往应用于配置系统，日志系统，数据库连接池等需要确保对象唯一性的场景。但是单例模式真的能保证单例吗？如果唯一性得不到保证会产生什么后果呢？\n既然写了这篇文章，那答案肯定是否定的。知乎上已经有很多相关的讨论了，比如 C++单例模式跨 DLL 是不是就是会出问题？ 和 动态库和静态库混合使用下的单例模式 BUG。不过大部分都是遇到问题以后，贴一下解决方案，很零散，并没有系统分析问题产生的原因。于是，我写了这篇文章来详细讨论一下这个问题。\n明确问题 首先我们要明确讨论的问题，以 C++11 常见的单例模式实现为例：\nclass Singleton { public: Singleton(const Singleton\u0026amp;) = delete; Singleton\u0026amp; operator= (const Singleton\u0026amp;) = delete; static Singleton\u0026amp; instance() { static Singleton instance; return instance; } private: Singleton() = default; }; 我们将默认构造设置为 private 并且显式 delete 拷贝构造和赋值运算符，这样的话用户只能通过 instance 这个函数来获取我们预先创建好的对象，不能自己通过构造函数创建一个对象。而使用静态局部变量是为了保证这个变量的初始化线程安全。\n但其实，单例对象和一个普通的全局变量并没有什么区别。在 C++ 中，它们都属于 静态储存期 (static storage duration)，编译器对它们的处理是类似的（只是初始化方式上有点区别）。而所谓的单例模式，只是在语言层面通过一些手段，防止用户不小心创建多个对象。\n那我们讨论的问题其实可以等价为：C++ 中的全局变量是唯一的吗？\n一个定义 首先得区分变量的声明和定义。我们都知道，头文件中一般是不能写变量定义的。否则如果这个头文件被多个源文件包含，就会出现多个定义，链接的时候就会报 multiple definition of variable 的错误。所以我们一般会在头文件中使用 extern 声明变量，然后在对应的源文件中定义变量。\n那编译器是如何处理全局变量定义的呢？\n假设我们定义一个全局变量\nint x = 1; 其实不会产生任何的指令，编译器会在这个编译单元（每个源文件）编译产物的符号表中，增加一个符号 x。在静态储存（具体的实现可能是 bss 段或者 rdata 段等等）中给符号 x 预留 4 字节的空间。视初始化方式（静态初始化 或者 动态初始化）来决定这块内存的数据如何填充。\n由于只有一个定义，那么这种情况肯定是全局唯一的了。\n多个定义 我们都知道 C++ 并没有官方的构建系统，不同的库使用不同的构建系统，就不方便互相使用了（目前的事实标准来看是 CMake）。这个现状使得 header-only 库变得越来越流行，include 即用，谁不喜欢呢？但是 header-only 也就意味着所有的代码都写在头文件中，如何在头文件中定义变量并且使得它能直接被多个源文件包含而不导致链接错误呢？\n在 C++17 之前，并没有直接的办法。但有一些间接的办法，考虑到 inline 函数或者模板函数的定义都可以出现在多个源文件中，并且 C++ 标准保证它们具有相同的地址（相关的讨论可以参考 C++ 究竟代码膨胀在哪里？）。于是只需要在这些函数中定义静态局部变量，效果上就相当于在头文件中定义变量了\ninline int\u0026amp; x() { static int x = 1; return x; } template \u0026lt;typename T = void\u0026gt; int\u0026amp; y() { static int y = 1; return y; } 在 C++17 之后，我们可以直接使用 inline 来标记变量，使得这个变量的定义可以出现在多个源文件中。使用它，我们就可以直接在头文件中定义变量了\ninline int x = 1; 我们知道，把变量标记为 static 也可以使得它在多个源文件中出现定义。那 inline 和 static 有什么区别呢？关键就在于，static 标记的变量是内部链接的，每个编译单元都有自己的一份实例，你在不同的编译单元取的地址是不一样的。而 inline 标记的变量是外部链接的，C++ 标准保证你在不同编译单元取同一个 inline 变量的地址是一样的。\n真的单例吗 实践是检验真理的唯一标准，我们来实验一下，C++ 标准有没有骗我们呢？\n示例代码如下\n// src.cpp #include \u0026lt;cstdio\u0026gt; inline int x = 1; void foo() { printf(\u0026#34;addreress of x in src: %p\\n\u0026#34;, \u0026amp;x); } // main.cpp #include \u0026lt;cstdio\u0026gt; inline int x = 1; extern void foo(); int main() { printf(\u0026#34;addreress of x in main: %p\\n\u0026#34;, \u0026amp;x); foo(); } 先简单一点，把这两个源文件一起编译成一个可执行文件，在 Windows(MSVC) 上和 Linux(GCC) 上分别尝试\n# Windows: addreress of x in main: 00007FF7CF84C000 addreress of x in src: 00007FF7CF84C000 # Linux: addreress of x in main: 0x404018 addreress of x in src: 0x404018 可以发现确实是相同的地址。下面我们试一下把 src.cpp 编译成动态库，main.cpp 链接这个库，编译运行。看看是不是像很多人说的那样，一遇到动态库就不行了呢？注意在 Windows 上要显式给 foo 加上 __declspec(dllexport)，否则动态库不会导出这个符号。\n# Windows: addreress of x in main: 00007FF72F3FC000 addreress of x in src: 00007FFC4D91C000 # Linux: addreress of x in main: 0x404020 addreress of x in src: 0x404020 夭寿啦，为什么 Windows 和 Linux 的情况不一样呢？\n符号导出 一开始，我简单的以为是动态库默认符号导出规则的问题。因为 GCC 编译动态库的时候，会默认把所有符号导出。而 MSVC 恰恰相反，默认不导出任何符号，全部都要手动导出。显然只有一个符号被导出了，链接器才能「看见」它，然后才能合并来自不同动态库的符号。\n抱着这个想法，我尝试寻找在 GCC 上自定义符号导出的手段，最终找到了 Visibility - GCC Wiki。在编译的时候使用 -fvisibility=hidden，这样的话符号就都是默认 hidden（不导出）了。然后使用 __attribute__((visibility(\u0026quot;default\u0026quot;))) 或者它在 C++ 的等价写法 [[gnu::visibility(\u0026quot;default\u0026quot;)]] 来显式标记需要导出的符号。于是我修改了代码\n// src.cpp #include \u0026lt;cstdio\u0026gt; inline int x = 1; [[gnu::visibility(\u0026#34;default\u0026#34;)]] void foo() { printf(\u0026#34;addreress of x in src: %p\\n\u0026#34;, \u0026amp;x); } // main.cpp #include \u0026lt;cstdio\u0026gt; inline int x = 1; extern void foo(); int main() { printf(\u0026#34;addreress of x in main: %p\\n\u0026#34;, \u0026amp;x); foo(); } 注意，我只导出了 foo 用于函数调用，这两个 inline 变量都没有导出。编译运行\naddreress of x in main: 0x404020 addreress of x in src: 0x7f5a45513010 就像我们预期的那样，地址果然不一样。这就验证了：符号被导出，是链接器合并符号的必要条件，但是并不充分。如果在 Windows 上能通过改变默认符号导出规则，使得 inline 变量具有相同的地址，那么充分性就得到验证。当我满怀激动的开始尝试，却发现事情并非这么简单。\n注意到 Windows 上的 GCC（MinGW64 工具链）仍然默认导出所有符号，按照设想，变量地址应该相同。尝试结果如下\naddreress of x in main: 00007ff664a68130 addreress of x in src: 00007ffef4348110 可以发现结果并不相同，我不理解，并认为是编译器的 BUG。转而使用 MSVC，并且发现 CMake 提供了一个 CMake_Windows_EXPORT_ALL_SYMBOLS 选项，打开之后会自动导出所有符号（通过 dumpbin 实现的）。遂尝试，编译运行，结果如下\naddreress of x in main: 00007FF60B11C000 addreress of x in src: 00007FFEF434C000 哦，结果还是不同，我意识到我的猜测出问题了。但是查阅了很久资料，也没找到为什么。后来还是在 TG 的 C++ 群提问，才得到了答案。\n简单来说，在 ELF 不区分符号是来自哪个 .so 的，先加载谁就用谁，所以遇到多个 inline 变量就使用第一个加载的。但是 PE 文件的符号表指定了某个符号从哪个 dll 引入，这样就会导致只要一个变量 dllexport 了，那么这个 dll 一定会使用自己的变量。即使多个 dll 同时 dllexport 同一个变量，也没法合并，Windows 上 dll 的格式就限制了这件事情是做不到的。\n动态库链接时的符号解析问题实际上可能还要复杂得多，还有很多其他的情况，例如通过 dlopen 等函数主动加载动态库。之后有时间的话，可能会专门写一篇文章来分析这个事情，这里就不多说了。\n不唯一如何？ 为什么要保证「单例」变量的唯一性呢？这里拿 C++ 标准库来举例子\n我们都知道 type_info 可以用于运行时区分不同的类型，标准库的 std::function 和 std::any 这些类型擦除的设施就依赖于它来实现。它的 constructor 和 operator= 就被 deleted 了，我们只能通过 typeid(T) 来获取对应 type_info 对象的引用，对象的创建则由编译器来负责。\n怎么样，是不是完全符合单例模式呢？下一个问题是，编译器是如何判断两个 type_info 对象是否相同的呢？一个典型的实现如下\n#if _PLATFORM_SUPPORTS_UNIQUE_TYPEINFO bool operator== (const type_info\u0026amp; __rhs) const { return __mangled_name == __rhs.__mangled_name; } #else bool operator== (const type_info\u0026amp; __rhs) const { return __mangled_name == __rhs.__mangled_name || strcmp(__mangled_name, __rhs.__mangled_name) == 0; } #endif 上面的代码很好理解，如果保证 type_info 的地址是唯一的，那么直接比较 __mangled_name 就行了（它是 const char* 所以是指针比较）。若不然，就先比较地址然后比较类型名。具体到三大标准库的实现：\nlibstdc++ 使用 __GXX_MERGED_TYPEINFO_NAMES 来控制是否启用 libc++ 使用 _LIBCPP_TYPEINFO_COMPARATION_IMPLEMENTATION 来决定采用的方式（实际上还有一种特殊的 BIT_FLAG 模式） MSVC stl (crt/src/vcruntime/std_type_info.C++) 由于前面提到的 Windows 上 dll 的限制，总是使用第二种方式 举这个例子的目的是，为了说明，单例变量地址的唯一性会影响我们代码的编写方式。如果不唯一我们可能被迫要书写一些代码进行防御，可能会影响性能，而如果没写的话，甚至会直接导致逻辑错误。\n解决方案 只提出问题可不行，得要解决，如何确保单例唯一呢？\n在 Linux 上就很简单了，如果同一个变量出现在多个动态库中，只要确保这些动态库都把这个符号设置为对外可见就行了。而编译器默认的行为也就是对外可见，所以基本上不用担心这个问题。\n在 Windows 上呢？非常麻烦了，必须要确保只有一个 dll 使用 dllexport 导出了这个符号，其他所有的 dll 必须要使用 dllimport。这件事情常常不太好做，你可能写着写着就忘记，是哪个 dll 负责导出的这个符号了。怎么办呢？那就是专门用一个 dll 来管理所有的单例变量，也就是说这个 dll 负责 dllexport 所有的单例变量，除此之外的 dll 都只 dllimport 就行了。之后添加和修改都在这个 dll 中进行，这样就比较好管理了。\n到这文章就结束了，说实话我并不确定上面的讨论有没有覆盖所有的情形。如果有错误欢迎评论区留言讨论。\n","permalink":"https://www.ykiko.me/zh-cn/articles/696878184/","summary":"\u003cp\u003e\u003cstrong\u003e单例模式 (Singleton Pattern)\u003c/strong\u003e 是一种常见的设计模式，往往应用于配置系统，日志系统，数据库连接池等需要确保对象唯一性的场景。但是单例模式真的能保证单例吗？如果唯一性得不到保证会产生什么后果呢？\u003c/p\u003e","title":"C++ 中的单例模式真的“单例”吗？"},{"content":"Compiler Explorer 是一个非常流行的 C++ 在线编译器，可用于测试不同的编译执行环境，或者分享代码。作为一个 C++ 爱好者，我几乎每天都要和它打交道，使用频率之高远超我的想象。同时，我也是一个重度 VSCode 用户，几乎所有的事情都在 VSCode 中完成。考虑到经常在本地写代码然后拷贝到 Compiler Explorer 上去，总觉得不太舒服，有时候直接就在它的网页编辑器上改了，但是又没有代码补全，也不舒服。所以我和 @iiirhe 合作编写了这个插件 Compiler Explorer for VSCode，基于 Compiler Explorer 提供的 API 将 Compiler Explorer 集成到 VSCode 中，使得用户可以在 VSCode 中直接使用 Compiler Explorer 的功能。\n现在你可以在 VSCode 插件市场搜索到这个插件\n效果展示 单文件支持 让我们从上往下依次介绍\n这三个按钮的功能依次是：\nCompile All：编译所有的编译器实例 Add New：添加一个新的编译器实例 Share Link：根据当前的编译器实例生成一个链接，并复制到剪贴板 这四个按钮的功能依次是：\nAdd CMake：添加一个 CMake 编译器实例（后面会细说） Clear All：关闭所有用于展示的 webview 面板 Load Link：根据输入的链接加载编译器实例的信息 Remove All：删除所有的编译器实例 这三个按钮的功能依次是：\nRun：编译这个编译器实例 Clone：克隆这个编译器实例 Remove：删除这个编译器实例 下面这些用于设置编译器实例的参数：\nCompiler：点击右侧的按钮可以选择编译器版本 Input：选择源代码文件，默认是 active 即当前活跃的编辑器 Output：输出编译结果的文件，默认使用 webview Options：编译选项，点击右侧按钮可以打开输入框 Execute Arguments：传递给可执行文件的参数 Stdin：用于标准输入的缓冲区 Filters：一些选项 多文件支持 使用 Add CMake 按钮可以添加一个 CMake 编译器实例，这个实例可以用于编译多个文件。\n大部分选项和单文件的编译器实例一样，额外多出了两个\nCMake Arguments：传递给 CMake 的参数 Source：CMakelists.txt 所在的文件夹路径 注意，由于多文件编译需要把所有用到的文件都上传到服务器，所以我们默认会读取你指定的目录下的所有文件（无论后缀名），所以目前请不要指定文件数量过多的文件夹。之后可能会添加一些选项允许用户过滤掉一些文件，但是目前还没有。\n一些用户设置 compiler-explorer.default.options：使用 + 号创建编译器时的默认参数\n\u0026#34;compiler-explorer.default.options\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;object\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;The default compiler configuration\u0026#34;, \u0026#34;default\u0026#34;: { \u0026#34;compiler\u0026#34;: \u0026#34;x86-64 gcc 13.2\u0026#34;, \u0026#34;language\u0026#34;: \u0026#34;c++\u0026#34;, \u0026#34;options\u0026#34;: \u0026#34;-std=c++17\u0026#34;, \u0026#34;exec\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;stdin\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;cmakeArgs\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;src\u0026#34;: \u0026#34;workspace\u0026#34;, \u0026#34;filters\u0026#34;: { \u0026#34;binaryObject\u0026#34;: false, \u0026#34;binary\u0026#34;: false, \u0026#34;execute\u0026#34;: false, \u0026#34;intel\u0026#34;: true, \u0026#34;demangle\u0026#34;: true, \u0026#34;labels\u0026#34;: true, \u0026#34;libraryCode\u0026#34;: true, \u0026#34;directives\u0026#34;: true, \u0026#34;commentOnly\u0026#34;: true, \u0026#34;trim\u0026#34;: false, \u0026#34;debugCalls\u0026#34;: false } } } compiler-explorer.default.color：用于指定高亮汇编代码时的颜色\n\u0026#34;compiler-explorer.default.color\u0026#34;:{ \u0026#34;symbol\u0026#34;: \u0026#34;#61AFEF\u0026#34;, \u0026#34;string\u0026#34;: \u0026#34;#98C379\u0026#34;, \u0026#34;number\u0026#34;: \u0026#34;#D19A66\u0026#34;, \u0026#34;register\u0026#34;: \u0026#34;#E5C07B\u0026#34;, \u0026#34;instruction\u0026#34;: \u0026#34;#C678DD\u0026#34;, \u0026#34;comment\u0026#34;: \u0026#34;#7F848E\u0026#34;, \u0026#34;operator\u0026#34;: \u0026#34;#ABB2BF\u0026#34; } compiler-explorer.default.url：用于打开插件时默认加载的链接，默认是空\n\u0026#34;compiler-explorer.default.url\u0026#34;: { \u0026#34;default\u0026#34;: \u0026#34;\u0026#34; } 问题反馈 该插件还处于早期阶段，如果你在使用过程中遇到了问题，或者有任何建议，欢迎在 GitHub 上留言讨论。或者添加 QQ 群：662499937。\nhttps://qm.qq.com/q/DiO6 rvnbHi (二维码自动识别)\n另外 Output 窗口可能会提供一些有用的信息，可以注意检索\n","permalink":"https://www.ykiko.me/zh-cn/articles/694365783/","summary":"\u003cp\u003e\u003ca href=\"https://godbolt.org/\"\u003eCompiler Explorer\u003c/a\u003e 是一个非常流行的 C++ 在线编译器，可用于测试不同的编译执行环境，或者分享代码。作为一个 C++ 爱好者，我几乎每天都要和它打交道，使用频率之高远超我的想象。同时，我也是一个重度 VSCode 用户，几乎所有的事情都在 VSCode 中完成。考虑到经常在本地写代码然后拷贝到 Compiler Explorer 上去，总觉得不太舒服，有时候直接就在它的网页编辑器上改了，但是又没有代码补全，也不舒服。所以我和 \u003ca href=\"https://www.zhihu.com/people/32ffceca937677f7950b64e5186bb998\"\u003e@iiirhe\u003c/a\u003e 合作编写了这个插件 \u003ca href=\"https://marketplace.visualstudio.com/items?itemName=ykiko.vscode-compiler-explorer\"\u003eCompiler Explorer for VSCode\u003c/a\u003e，基于 Compiler Explorer 提供的 \u003ca href=\"https://github.com/compiler-explorer/compiler-explorer/blob/main/docs/API.md\"\u003eAPI\u003c/a\u003e 将 Compiler Explorer 集成到 VSCode 中，使得用户可以在 VSCode 中直接使用 Compiler Explorer 的功能。\u003c/p\u003e","title":"超好用的 C++ 在线编译器（VSCode 版）"},{"content":"Application Binary Interface，也就是我们常说的 ABI，是个让人感觉到既熟悉又陌生的概念。熟悉在哪里？讨论问题的时候经常会讨论到它，看文章的时候经常会提到它，有时候又要处理它导致的兼容性。陌生在哪里？如果有人问你什么是 ABI，你会发现你知道它是怎么一回事，但是要用严谨的语言去描述它有些困难。最后只好照着 WIKI 说：ABI 就是两个二进制程序模块之间的接口。有问题吗？没有问题，作为一个概括性的描述，已经足够了。但是让人感觉到有些空洞。\n这一情况在 CS 领域并不少见，笔者之前写的讨论 反射 的文章也遇到完全相同的情况。究其根本，CS 本来就不是一门力求严谨性的学科，很多概念都没有严格的定义，更多的是约定俗成的说法。所以我们就不去纠结定义，而是就实际出发，来看看这些所谓的二进制接口究竟有哪些，又有哪些因素会影响它们的稳定性。\nCPU \u0026amp; OS 最终的可执行文件最后都是要运行在特定 CPU 上的特定操作系统的。如果 CPU 的指令集不同，那肯定会导致二进制不兼容，比如 ARM 上的程序没法直接运行在 x64 处理器上（除非借助一些虚拟化技术）。如果指令集兼容呢？比如 x64 处理器就兼容 x86 的指令集，那 x86 程序一定能运行在 x64 操作系统上吗？这时候就要看操作系统了，具体来说，要考虑到 Object File Format（目标文件格式），Data Representation（数据表示）， Function Calling Convention（函数调用约定）和 Runtime Library（运行时库）等因素。这几点就可以看做是操作系统层面的 ABI 规定。第四点我们后面有专门的一节来讨论，下面以 x64 平台为例，就前三点进行讨论。\nx64, x86-64, x86_64, AMD64 和 Intel 64 是一个意思，都是指 x86 指令集的 64 位版本。\nx64 平台上主要有两套常用的 ABI：\n用于 64 位 Windows 操作系统上的 Windows x64 ABI 用于 64 位 Linux 以及一众 UNIX-like 的操作系统上的 x86-64 System V ABI 而从一个动态库里面调用某个函数可以简单的看成下面这个三个步骤：\n按照某种格式解析动态库 根据符号名从解析结果中查找函数地址 函数参数传递，调用函数 Object File Format 以何种格式解析动态库？这就是 ABI 中对 Object File Format 的规定起作用的地方了。如果你希望自己写一个链接器，那么最后生成的可执行文件就需要满足对应平台的格式要求。Windows x64 使用的可执行文件格式是 PE32+ ，也就是 PE32（Portable Executable 32-bit）格式的 64 位版本。System V ABI 使用的则是 ELF（Executable Linkable Format） 格式的可执行文件。通过使用一些 parse 库（当然感兴趣的话也可以自己写），例如 pe-parse 和 elfio，对实际的可执行文件进行解析，得到其中的符号表，我们便能拿到函数名与函数地址的映射关系了。\nData Representation 拿到函数地址之后，接下来就是怎么进行调用了。在调用之前，首先得传参对吧。那传参的时候就特别要注意 Data Representation（数据表示）表示的一致性，什么意思呢？\n假设我把下面这个文件编译成动态库\nstruct X { int a; int b; }; int foo(X x) { return x.a + x.b; } 结果后续版本升级导致结构体内容发生变动了，用户代码里面看到的结构体定义变成了\nstruct X { int a; int b; int c; }; 然后仍然去尝试链接旧版本代码编译出的动态库，并调用里面的函数\nint main() { int n = foo({1, 2, 3}); printf(\u0026#34;%d\\n\u0026#34;, n); } 能成功吗？当然会失败了。这种错误可以看成所谓的 ODR（One Definition Rule）违反，更多的示例会在后面的章节中讨论。\n上面的情况属于用户主动变更代码导致的 ODR 违反，那如果我不主动变更代码，能确保结构体布局的稳定性吗？那这就由 ABI 中 Data Representation 来进行相关保证了。例如：规定一些基础类型的大小和对齐， Windows x64 规定 long 是 32 位，而 System V 则规定 long 是 64 位。规定 struct 和 union 的大小和对齐等等。\n注意 C 语言标准仍然是不规定 ABI 的，对于 System V ABI 来说，其主要使用 C 语言的术语和概念编写，所以可以认为提供了针对 C 语言的 ABI。而 Windows x64 ABI 在 C 和 C++ 之间并没有太过明显的界限。\nFunction Calling Convention 接下来就到函数传参这一步了。我们知道，函数不过就是一段二进制数据，执行函数其实就是跳转到函数的入口地址，然后执行那一段代码，最后执行完了再跳转回来就行了。而传参无非就是找一块地方，存放数据，使得调用前后都能访问到这个地方来取数据。有哪些位置可以选择呢？主要有下面四个选项：\nglobal（全局变量） heap（堆） register（寄存器） stack（栈） 使用全局变量进行传参，听起来很魔幻，实际上平常写代码的时候经常把一些需要反复传递的参数改成全局变量，例如 config 这种的。但是，显然不是所有参数都适合使用全局变量传参，如果考虑到线程安全就要更加注意了。\n使用堆进行传参，似乎也很不可思议，但其实 C++20 加入的无栈协程就把协程的状态（函数参数，局部变量）保存在堆上。不过对于普通的函数调用来说，如果每次传参都要动态内存分配，确实有些奢侈了。\n所以我们主要还是考虑使用寄存器和栈进行传参。多一种选择总是好的，但是在这里并不好。如果调用方觉得应该使用寄存器传参，于是把参数存到寄存器里面去了。而被调用方觉得应该使用栈传参，所以取数据的时候是从栈里面取的。不一致就出现了，很可能从栈里面读到的就是垃圾值，导致代码逻辑错误，程序直接崩溃。\n如何保证调用方和被调用方传参的位置一致呢？相信你已经猜到了，这就是 Function Calling Convention（函数调用约定）发挥作用的地方。\n具体来说，调用约定规定下面这些内容：\n函数参数传递顺序，从左到右还是从右到左？ 函数参数和返回值传递的方式，通过栈还是寄存器？ 哪些寄存器在调用者调用前后是保持不变的？ 谁负责清理栈帧，调用者还是被调用者？ 如何处理 C 语言的 variadic 函数？ ... 在 32 位程序中，有很多调用约定，像什么 __cdecl，__stdcall，__fastcall，__thiscall 等等，当时的程序可谓是饱受兼容性之苦。而在 64 位程序中，已经基本完成统一。主要有两种调用约定，也就是 Windows x64 ABI 和 x86-64 System V ABI 分别规定的调用约定（不过并没有个正式的名字）。需要强调的是函数传参方式只和调用约定有关，和代码优化等级无关。你也不想不同优化等级编译出来的代码，链接到一起之后跑不起来吧。\n介绍具体的规定是有些无聊的，感兴趣的读者可以自行查阅对应文档的相关小节，下面主要讨论一些比较有意思的话题。\n注意：下面这些讨论只适用于函数调用实际发生的情况，如果函数被完全内联，函数传参这一行为并不会发生。目前 C++ 代码的内联优化主要发生在同一编译单元内（单个文件），对于跨编译单元的代码，必须要打开 LTO（Link Time Optimization）才行，跨动态库的代码目前还不能内联。\n小于 16 字节大小的结构体值传递效率比引用效率更高 这个说法由来已久，但是我始终没有找到依据。终于，最近在研究调用约定的时候，让我找到原因了。首先如果结构体大小小于等于 8 字节，那么可以直接塞进一个 64 位寄存器里面传参，通过寄存器传参比通过引用传参要少几次访存，效率要高一些，没什么问题。那对于 16 字节呢？System V ABI 允许将一个 16 字节大小的结构体拆两个 8 个字节的部分，然后分别使用寄存器传递。那么在这种情况下传值确实比传引用要高一些，观察下面的 代码\n#include \u0026lt;cstdio\u0026gt; struct X { size_t x; size_t y; }; extern void f(X); extern void g(const X\u0026amp;); int main() { f({1, 2}); // pass by value g({1, 2}); // pass by reference } 最后生成的代码如下所示\nmain: sub rsp, 24 mov edi, 1 mov esi, 2 call f(X) movdqa xmm0, XMMWORD PTR .LC0[rip] mov rdi, rsp movaps XMMWORD PTR [rsp], xmm0 call g(X const\u0026amp;) xor eax, eax add rsp, 24 ret .LC0: .quad 1 .quad 2 System V ABI 规定了前六个整形参数，依次可以使用 rdi，rsi，rdx，rcx，r8，r9 寄存器传递，而 Windows x64 ABI 规定了前四个整形参数，依次可以使用 rcx，rdx，r8，r9 寄存器传递。如果过寄存器用完了，就通过栈传递。整形参数即 char，short，int，long，long long 等基础整数类型外加指针类型。浮点参数和 SIMD 类型的参数则有专门的寄存器负责，这里不过多涉及了。\n可以发现 1,2 分别通过寄存器 edi 和 esi 传递给了 f 函数，而 g 则是把临时变量的地址传递给了 g 函数。但是这只是 System V ABI，对于 Windows x64 ABI 来说，**只要结构体的大小大于 8 字节，只能通过引用传递。**同样的代码，在 Windows 上编译的结果如下\nmain: sub rsp, 56 lea rcx, QWORD PTR [rsp+32] mov QWORD PTR [rsp+32], 1 mov QWORD PTR [rsp+40], 2 call void f(X) lea rcx, QWORD PTR [rsp+32] mov QWORD PTR [rsp+32], 1 mov QWORD PTR [rsp+40], 2 call void g(X const \u0026amp;) xor eax, eax add rsp, 56 ret 0 可以看到两次函数调用产生的代码完全相同，也就是说对于 Windows x64 ABI 来说，大于 8 字节的结构体无论是通过引用传递还是值传递，生成的代码都是一样的。\nunique_ptr 和 raw_ptr 的效率完全一致 好吧在此之前我一直对此深信不疑，毕竟 unique_ptr 只是对裸指针简单包装一层嘛。直到看了 CPPCON 上 There are no zero-cost abstractions 这个令人深省的 talk，才意识到完全是我想当然了。这里不谈异常导致的额外开销（析构函数导致编译器必须额外生成清理栈帧的代码），仅仅讨论一个 C++ 对象（小于 8 字节）能使用寄存器传参吗？对于一个完全 trivial 的类型来说，是没问题的，它表现得和一个 C 语言的结构体几乎完全一样。不过不满足呢？\n比如自定义了拷贝构造函数，还能放寄存器里面吗？其实从逻辑上就不能，为什么呢？我们知道，C++ 是允许我们对函数参数取地址的，那如果参数是整形，那么它通过寄存器传参，那取地址的结果哪里来的呢？实验一下，就知道了\n#include \u0026lt;cstdio\u0026gt; extern void f(int\u0026amp;); int g(int x) { f(x); return x; } 生成的对应汇编如下\ng(int): sub rsp, 24 mov DWORD PTR [rsp+12], edi lea rdi, [rsp+12] call f(int\u0026amp;) mov eax, DWORD PTR [rsp+12] add rsp, 24 ret 可以发现，这里把 edi（用于传递第一个整形参数）里面的值拷贝到了 rsp+12 这个地址，也就是栈上，之后把这个地址传递给了 f。也就是说，如果一个函数参数通过寄存器传递，如果在某些情况下需要它的地址，编译器会把这个参数拷贝到栈上。但是无论如何，用户是观察不到这些拷贝过程的，因为它们的拷贝构造函数是 trivial 的。不影响最终代码执行结果的任何优化都是符合 as if 原则的。\n那么如果这个对象有用户定义的拷贝构造函数，假设参数通过寄存器传递，就可能会导致额外的拷贝构造函数调用，并且用户可以观察到这个副作用。显然这是不合理的，所以不允许拥有自定义拷贝构造函数的对象通过寄存器传参，那通过栈传递呢？实际上也会遇到类似的拷贝困境。于是最终这类对象只能通过引用传递了。注意，给拷贝构造显式标记为 delete 也算是自定义拷贝构造函数。\n所以对于 unique_ptr 来说，只能通过引用传递，无论你函数签名写成 void f(unique_ptr\u0026lt;int\u0026gt;) 还是 void f(unique_ptr\u0026lt;int\u0026gt;\u0026amp;)，最后在传参处生成的二进制代码都是一样的。但是裸指针却可以通过寄存器安全的传递。综上所述，unique_ptr 和裸指针的效率并不是完全一致的。\n实际上对于一个非 trivial 的 C++ 对象，究竟能否使用寄存器传参的实际情况更复杂一些，相关的内容参考对应 ABI 中的相关小节，这里不过多描述。另外 C++ 对象如何传递这部分规定，究竟属于操作系统的 ABI 还是 C++ 编译器 ABI 这个问题也并不是很明确。\nC++ Standard 终于说完了操作系统层面的保证，由于偏向底层，涉及到较多汇编，对于不那么熟悉汇编的读者，读起来可能有些困难。不过接下来的内容基本就和汇编没什么关系了，可以放心阅读。\n我们都知道 C++ 标准没有明确规定 ABI，但并不是完全没有规定，它对于编译器的实现是有一些要求的，例如：\n结构体成员地址按照声明顺序 递增，这保证了编译器不会对结构体成员进行重新排序 满足 Standard Layout 约束的结构体需要与相应的 C 结构体布局兼容 满足 Trivially Copyable 约束的结构体可以使用 memmove 或者 memcpy 进行拷贝得到一个完全相同的全新对象 ... 另外，由于 C++ 一直在推出新的版本。同一份代码，我使用新标准和旧标准分别进行编译，得到的结果相同吗（不考虑使用宏控制 C++ 版本进行条件编译的影响）？这就要看 C++ 标准层面对 ABI 兼容性的保证了，事实上，C++ 标准尽可能的保证向后兼容性。也就是说，两段代码，使用旧标准和新标准编译出来的代码是完全一样的。\n然而，也有极少数的例外，例如（我只找得到这些，欢迎评论区补充）：\nC++17 把 noexcept 作为函数类型的一部分，这会影响函数最后生成的 mangling name C++20 引入的 no_unique_address，MSVC 目前仍然没直接支持，因为会导致 ABI Broken 更多时候，C++ 新版本会在加入新的语言特性的同时带来新的 ABI，而不会影响旧的代码，例如 C++23 加入的两个新特性：\nExplicit Object Parameter 在 C++23 之前，事实上没有合法的手段获取一个成员函数的地址，我们唯一能做的就是获取成员指针（关于成员指针是什么，可以参考这篇 文章 的内容）\nstruct X { void f(int); }; auto p = \u0026amp;X::f; // p is a pointer to member function of X // type of p is void (X::*)(int) 想要获取使用成员函数作为回调函数，只能使用 lambda 表达式包装一层\nstruct X { void f(int); }; using Fn = void (*)(X*, int); Fn p = [](A* self, int x) { self-\u0026gt;f(x); }; 这其实很麻烦，没有任何必要，而且这层包装可能会导致额外的函数调用开销。某种程度上这算是个历史遗留问题，32 位系统上对成员函数的调用约定有些特殊（广为人知的 thiscall），而 C++ 中并没有调用约定相关的内容，所以搞了个成员函数指针这么个东西。旧的代码为了 ABI 兼容性已经无法改变，但是新的可以，C++23 加入了显式对象参数，我们现在可以明确 this 的传入方式了，甚至可以使用值传递\nstruct X { // 这里的 this 只是个标记作用，为了和旧语法区分开来 void f(this X self, int x); // pass by value void g(this X\u0026amp; self, int x); // pass by reference }; 被显式 this 标记的函数也可以直接获取函数地址了，就和普通的函数一样\nauto f = \u0026amp;X::f; // type of f is void(*)(X, int) auto g = \u0026amp;X::g; // type of g is void(*)(X*, int) 所以新代码可以都采用这种写法，只有好处，没有坏处。\nStatic Operator() 标准库中有一些仿函数，里面什么成员都没有，只有一个 operator()，例如 std::hash\ntemplate \u0026lt;class T\u0026gt; struct hash { std::size_t operator() (const T\u0026amp; t) const; }; 尽管这是个空的结构体，但是由于 operator() 是成员函数，所以有一个隐式 this 参数。在非内联调用的情况下仍然需要传递一个无用的空指针。这个问题在 C++23 中得到了解决，可以直接定义 static operator()，从而避免这个问题\ntemplate \u0026lt;class T\u0026gt; struct hash { static std::size_t operator() (const T\u0026amp; t); }; static 也就意味着这是个静态函数了，使用上还是和原来一样\nstd::hash\u0026lt;int\u0026gt; h; std::size_t n = h(42); 但这里只是拿 hash 举个例子，实际上标准库的代码为了 ABI 兼容性已经不会改动了。新代码可以使用这个特性，来避免不必要的 this 传递。\nCompiler Specific 接下来就到了重头戏了，实现定义的部分，这部分似乎是被人诟病最多的内容了。然而事实真的如此吗？让我们一点点往下看。\nDe Facto Standard C++ 中的一些抽象最终是要落实到实现上的，而标准有没有规定如何实现，那这部分内容就由编译器自由发挥，例如：\nname mangling 的规则（为了实现函数重载和模板函数） 复杂类型的布局（例如含有虚继承） 虚函数表的布局 RTTI 的实现 异常处理 ... 如果编译器对这些部分的实现不同，那么最后不同编译器编译出的二进制产物自然是互不兼容，不能混用的。\n在上世纪 90 年代，那时候还是 C++ 发展的黄金时期，各个厂商都致力于实现自己的编译器并扩大基本盘，争夺用户。出于竞争关系，不同编译器之间使用不同的 ABI 是很常见的行为。随着时代的发展，它们中的大多数已经退出了历史舞台，要么停止更新，要么仅做维护，不再跟进 C++ 的新标准。浪潮过后，留下的只有 GCC，Clang 和 MSVC 这三大编译器。\n在今天，C++ 编译器的 ABI 已经基本得到统一，主流的 ABI 只有两套：\nItanium C++ ABI，具有公开透明的 文档 MSVC C++ ABI，并没有官方的文档，这里有一份非正式的 版本 尽管名为 Itanium C++ ABI，但它实际上是用于 C++ 的跨架构 ABI，除了 MSVC 之外，几乎所有的 C++ 编译器都在使用它，尽管在异常处理方面的细节略有不同。历史上，C++ 编译器都以各自的方式处理 C++ ABI。当英特尔大力推广 Itanium 时，他们希望避免不兼容问题，因此，他们为 Itanium 上的所有 C++ 供应商创建了一个标准化的 ABI。后来，由于各种原因，GCC 需要修改其内部 ABI，而且鉴于它已经支持了 Itanium ABI（为 Itanium 处理器），他们选择将 ABI 定义扩展到所有架构，而不是创建自己的 ABI。从那时起，所有主要的编译器除了 MSVC 都采用了跨架构的 Itanium ABI，并且即使 Itanium 处理器本身不再接收维护，该 ABI 仍然得到了维护。\n在 Linux 平台上，GCC 和 Clang 都使用 Itanium ABI，所以两个编译器编译出来的代码就具有互操作性，可以链接到一起并运行。而在 Windows 平台上，情况则稍微复杂些，默认的 MSVC 工具链使用自己的 ABI。但是除了 MSVC 工具链以外，还有人把 GCC 移植到 Windows 上了，也就是我们熟知的 MinGW 工具链，它使用的仍然是 Itanium ABI。这两套 ABI 互不兼容，编译出来的代码不能直接链接到一起。而 Windows 平台上的 Clang 可以通过编译选项控制使用这两种 ABI 中的一种。\n注意：MinGW 既然在 Windows 上运行，那它生成的代码的调用约定自然是尽量遵守 Windows x64 ABI 的，最终生成的可执行文件格式也是 PE32+。但是它的使用的 C++ ABI 仍然是 Itanium ABI，这两者并没有必然关联。\n考虑到 C++ 巨大的 codebase，这两套 C++ ABI 已经基本稳定，不会再改动了，所以我们现在其实可以说 C++ 编译器具有稳定的 ABI。怎么样，是不是和网上主流的说法不同？但是事实的确就摆在这里。\nMSVC 从 2015 的版本往后开始保证 ABI 稳定。GCC 从 3.4 开始使用 Itanium ABI 并保证 ABI 稳定。\nWorkaround 尽管基础的 ABI 不再改变，但是升级编译器版本仍然可能会导致编译出来的库发生 ABI Broken，为什么呢？\n这其实不难理解，首先编译器也是软件，只要是软件就可能有 BUG。有时候为了修复 BUG，会被迫做出一些 ABI Broken（一般会在新版本的发行介绍中详细说明）。例如 GCC 有一个编译选项 -fabi-version 用于专门控制这些不同的版本，其中一些内容如下：\n版本 7 首次出现在 G++ 4.8 中，它将 nullptr_t 视为内建类型，并修正了默认参数作用域中 Lambda 表达式的名称编码 版本 8 首次出现在 G++ 4.9 中，修正了带有函数 CV 限定符的函数类型的替换行为 版本 9 首次出现在 G++ 5.2 中，修正了 nullptr_t 的对齐方式 另外对于用户来说，也可能之前为了绕过编译器的 BUG，编写了一些特殊的代码，我们一般把这个叫做 workaround。当 BUG 被修复之后，这些 workaround 很可能起到反作用。从而导致 ABI 出现不兼容\nImportant Options 除此之外，编译器还提供了一些列选项用来控制编译器的行为，这些选项可能会影响 ABI，比如：\n-fno-strict-aliasing：关闭严格别名 -fno-exceptions：关闭异常 -fno-rtti：关闭 RTTI ... 给不同选项编译出来的库链接到一起的时候，尤其要注意兼容性问题。例如你的代码关闭了严格别名，但是依赖的外部库开启了严格别名，很可能指针错误的传播，从而导致程序出错。\n我最近就遇到了这种情况，我在给 LLVM 的一些函数编写 Python Wrapper，通过 pybind11。而 pybind11 要求必须打开 RTTI，但是 LLVM 默认构建是关闭异常和 RTTI 的，所以最后代码就链接不到一块去了。一开始我是自己编译了一份开 RTTI 的 LLVM，这会导致二进制膨胀，后来发现没必要这样做。我其实没有用到 LLVM 里面类型的 RTTI 信息，只是由于写在同一个文件里面，编译器认为我用到了。于是把使用到 LLVM 部分的代码单独编译成一个动态库，再和使用 pybind11 部分的代码一起链接就解决了。\nRuntime \u0026amp; Library 这一小节主要讨论的就是，一个 C++ 程序依赖的库的 ABI 稳定性。理想情况下是，对于一个可执行程序，使用新版本的动态库替换旧版本的动态库，仍然不影响它运行。\n三大 C++ 编译器都有自己的标准库\nMSVC 对应的是 MSVC stl GCC 对应的是 libstdc++ Clang 对应的是 libc++ 我们在前面提到过，C++ 标准尽量保证 ABI 向后兼容。即使是从 C++98 到 C++11 这样的大更新，旧代码的 ABI 也没有受到太大影响，导致 ABI Break Change 的措辞改变更是完全找不到。\n但是对于 C++ 标准库来说情况就有些不一样了，从 C++98 到 C++11，标准库经历了一次大的 ABI Break Change。标准库中修改了对一些容器实现的要求，例如 std::string。这导致原来广泛使用的 COW 实现不符合新标准，于是在 C++11 中不得不采用新实现。这也就导致了 C++98 和 C++11 之间的标准库 ABI Broken。不过在这之后，标准库的 ABI 一般相对稳定，各家实现也尽量保证。参考 stl，libstdc++ 和 libc++ 相关的页面以获取详细介绍。\n另外由于 RTTI 和 Exception 一般可以关掉，所以这两项功能可能由单独的运行时库来负责，比如 MSVC 的 vcruntime 和 libc++ 的 libcxxabi。\n值得一提的是，libcxxabi 中还包含了对静态局部变量初始化的支持，涉及到的主要函数是 **cxa_guard_acquire, **cxa_guard_release。使用它们来保证静态局部变量只在运行时初始化一次，如果对具体的实现感到好奇，可以查阅相关源码。\n还有就是负责一些底层功能的运行时库，比如 libgcc 和 compiler-rt。\n除了标准库以外，C++ 程序一般还需要链接 C 运行时\n在 Windows 上，必须链接 CRT 在 Linux 上 取决于所使用的发行版和编译环境，可能会链接 glibc 或者 musl C 运行时除了提供 C 标准库的实现外，还负责程序的初始化和清理。它负责调用 main 函数，并管理程序的启动和终止过程，包括执行一些必要的初始化和清理工作。对于大多数在操作系统上的软件来说，链接它是必须的。\n最理想的状态自然是，升级编译器的时候把这些对应的运行时库版本也升级，避免不必要的麻烦。但是在实际项目中，依赖关系可能十分复杂，可能会引发连锁反应。\nUser Code 最后我们来谈谈用户代码自身的改变导致的 ABI 问题，如果希望将你的库以二进制形式进行分发，那么当用户量达到一定程度之后，ABI 兼容性就很重要了。\n在第一小节讨论调用约定的时候，就提到过变更结构体定义导致的 ABI 不兼容问题。那如果既想要保证 ABI 兼容，又想要为以后的扩展留下空间怎么办呢？答案就是在运行时处理了\nstruct X { size_t x; size_t y; void* reserved; }; 通过一个 void* 指针为以后的扩展预留空间。可以根据它来判断不同的版本，比如\nvoid f(X* x) { Reserved* r = static_cast\u0026lt;Reserved*\u0026gt;(x-\u0026gt;reserved); if(r-\u0026gt;version == ...) { // do something } else if(r-\u0026gt;version == ...) { // do something else } } 这样就能在添加新的功能的同时而不影响原有的代码。\n在对外暴露接口的时候，对于函数参数中有自定义析构函数的类型，也要格外注意。假设我们要暴露 std::vector 作为返回值，例如把下面这个简单的代码编译成动态库，并且使用 \\MT 选项来静态链接 Windows CRT。\n__declspec(dllexport) std::vector\u0026lt;int\u0026gt; f() { return {1, 2, 3}; } 然后我们写一个源文件，链接到刚才编译的这个动态库，调用这个函数\n#include \u0026lt;vector\u0026gt; std::vector\u0026lt;int\u0026gt; f(); int main() { auto vec = f(); } 编译运行，发现直接崩溃了。如果关闭 \\MT 重新编译一遍动态库，然后运行，发现一切正常。很奇怪，为什么依赖的动态库静态链接 CRT 会导致代码崩溃？\n思考一下上面的代码不难发现，vec 的构造实际上发生在动态库里面，而析构则是发生在 main 函数里面。更进一步，其实就是内存是在动态库里面分配的，释放是在 main 函数里面。但是每一份 CRT 都有自己的 malloc，free（类似于不同进程间的内存）。你不能把 CRT A 分配的内存交给 CRT B 释放，这就是问题的根源。所以之后不静态链接到 CRT 就没事了，它们用的都是同一个 malloc，free。不仅仅是 Windows CRT，对于 Linux 上的 glibc 或者 musl 也是一样的。示例代码放在 这里，感兴趣的可以自己试试。\nextern \u0026ldquo;C\u0026rdquo; 对于任何带有自定义析构函数的 C++ 类型都可能出现上面那种情况，由于种种原因，构造函数和析构函数的调用跨越动态库边界，RAII 的约定被打破，导致严重的错误。\n如何解决呢？那自然是函数参数和返回值都不使用带有析构函数的类型了，只使用 POD 类型。\n例如上面那个例子需要改成\nusing Vec = void*; __declspec(dllexport) Vec create_Vec() { return new std::vector\u0026lt;int\u0026gt;; } __declspec(dllexport) void destroy_Vec(Vec vec) { delete static_cast\u0026lt;std::vector\u0026lt;int\u0026gt;*\u0026gt;(vec); } 然后使用就得这样\nusing Vec = void*; Vec create_Vec(); void destroy_Vec(Vec vec); int main() { Vec vec = create_Vec(); destroy_Vec(vec); } 其实我们就是在按照 C 风格的 RAII 来进行封装。更进一步，如果想要解决 C 和 C++ 由于 mangling 不同而导致的链接问题，可以使用 extern \u0026quot;C\u0026quot; 来修饰函数\nextern \u0026#34;C\u0026#34; { Vec create_Vec(); void destroy_Vec(Vec vec); } 这样的话 C 语言也可以使用上述的导出函数了。\n但是如果代码量很大的话，把全部的函数都封装成这样的 API 显然不太现实，那就只能把 C++ 的类型暴露在导出接口中，然后小心地管理依赖项（比如所有依赖库全都静态链接）。具体选择哪一种方式，还是要看项目大小和复杂度，然后再做定夺。\nConclusion 到这里，我们终于讨论完了影响 C++ 程序 ABI 的主要因素。可以清楚地看到，C++ 标准、编译器厂商和运行时库都在尽力维护 ABI 的稳定性，C++ ABI 并没有很多人说的那么不堪，那么不稳定。对于小型项目而言，带源码静态链接，几乎不会有任何的兼容性问题。对于那些历史悠久的大型项目来说，由于复杂的依赖关系，升级某些库的版本可能会导致程序崩溃。但这并不是 C++ 的错，对于大型项目的管理，早已超出了单纯的语言层面，不能指望通过更换编程语言来解决这些问题。实际上，学习软件工程就是在学习如何应对巨大的复杂度，如何保证复杂系统的稳定性。\n文章到这就结束了，感谢您的阅读。作者水平有限，并且这篇文章内容跨度较大，如有错误欢迎评论区留言讨论。\n一些其他的参考资料：\nAn Overview of ABI in Different Platforms WIndows x64 ABI System V x64 ABI Itanium C++ ABI MinGW x64 Software Convention macOS x64 ABI ARM ABI WIndows ARM64 ABI RISCV ABI Go Internal ABI ","permalink":"https://www.ykiko.me/zh-cn/articles/692886292/","summary":"\u003cp\u003eApplication Binary Interface，也就是我们常说的 ABI，是个让人感觉到既熟悉又陌生的概念。熟悉在哪里？讨论问题的时候经常会讨论到它，看文章的时候经常会提到它，有时候又要处理它导致的兼容性。陌生在哪里？如果有人问你什么是 ABI，你会发现你知道它是怎么一回事，但是要用严谨的语言去描述它有些困难。最后只好照着 \u003ca href=\"https://en.wikipedia.org/wiki/Application_binary_interface\"\u003eWIKI\u003c/a\u003e 说：ABI 就是两个二进制程序模块之间的接口。有问题吗？没有问题，作为一个概括性的描述，已经足够了。但是让人感觉到有些空洞。\u003c/p\u003e","title":"彻底理解 C++ ABI"},{"content":"相信读者经常能听见有人说 C++ 代码二进制膨胀严重，但是一般很少会有人指出具体的原因。在网络上一番搜索过后，发现深入讨论这个问题的文章并不多。上面那句话更像是八股文的一部分，被口口相传，但是没什么人能说出个所以然。今天小编 ykiko 就带大家一起来探秘 C++ 代码膨胀那些事 (^ω^)\n首先要讨论的是，什么叫做代码膨胀？如果一个函数被大量内联，那相比于不被内联，最终生成的可执行文件是更大了对吧。那这样算膨胀吗？我认为不算，这是我们预期范围内的，可接受的，正常行为。那反过来，不在我们预期范围内的，理论上能消除，但迫于现有的实现却没有消除的代码膨胀，我把它叫做\u0026quot;真正的代码膨胀\u0026quot;。后文所讨论的膨胀都是这个意思。\n用 inline 标记函数会导致膨胀吗？ 首先要明确，这里的 inline 是 C++ 中的 inline，标准中规定的语义是，允许一个函数的在多个源文件中定义。被 inline 标记的函数可以直接定义在头文件中，即使被多个源文件 #include，也不会导致链接错误，这样可以方便的支持 header-only 的库。\n多份实例的情况 既然可以在多个源文件中定义，那是不是就意味着每个源文件都有一份代码实例，会不会导致代码膨胀呢?\n考虑如下示例，开头的注释表示文件名\n// src1.cpp inline int add(int a, int b) { return a + b; } int g1(int a, int b) { return add(a, b); } // src2.cpp inline int add(int a, int b) { return a + b; } int g2(int a, int b) { return add(a, b); } // main.cpp #include \u0026lt;cstdio\u0026gt; extern int g1(int, int); extern int g2(int, int); int main() { return g1(1, 2) + g2(3, 4); } 先尝试不开优化编译前两个文件，看看他们是不是各自保留了一份 add 函数\n$ g++ -c src1.cpp -o src1.o $ g++ -c src2.cpp -o src2.o 分别查看这两个文件里面的符号表\n$ objdump -d src1.o | c++filt $ objdump -d src2.o | c++filt 本地验证都通过上述命令直接查看符号表进行。但是为了方便展示，我会把 godbolt 对应的链接和截图放上来，它把很多影响阅读的不关键符号都省略了，看起来更加清晰。\n可以看到这两个 源文件 分别保留了一份 add 函数的实例。然后我们将它们链接成可执行文件\n$ g++ main.o src1.o src2.o -o main.exe $ objdump -d main.exe | c++filt 结果如下图所示\n发现链接器只保留了两份 add 实例中的一份，所以并没有额外的代码膨胀。并且 C++ 标准要求，内联函数在不同编译单元的定义必须相同，所以无论选哪一份代码保留都没区别。但是如果你问：万一定义不同呢？那就会导致 ODR 违反，严格意义来说算 undefined behavior，究竟保留哪一个可能就看具体实现了，甚至和链接顺序有关。关于 ODR 违反相关的内容，我最近可能会单独写一个文章介绍，这里就不说太多了。只需要知道 C++ 标准保证 inline 函数在不同编译单元定义相同就行了。\n完全内联的情况 前面我特意强调了，不打开优化，如果打开了优化会怎么样呢？仍然是上面的代码，我们尝试打开 O2 优化。最后的 结果 如下图所示\n可能让人有点吃惊，打开 -O2 优化之后，add 调用被完全内联。编译器最后连符号都没有给 add 生成，链接的时候自然也没有 add。按照我们之前的定义来看，这种函数内联不属于代码膨胀，所以是没有额外的二进制膨胀开销的。\n稍微偏个题，既然这两个文件都不生成 add 这个符号，那万一有别的文件引用了 add 这个符号，不就会导致链接失败吗？\n考虑如下代码\n// src1.cpp inline int add(int a, int b) { return a + b; } int g1(int a, int b) { return add(a, b); } // main.cpp inline int add(int a, int b); int main() { return g1(1, 2) + add(3, 4); } 尝试编译链接上面的代码。发现不开优化可以链接通过。开了优化就会导致链接失败了。链接器会告诉你 undefined reference to add(int, int)。三大编译器的行为都是如此，具体的原因上面已经解释过了，开了优化之后，编译器压根没生成 add 这个符号，链接的时候自然无法找到了。\n但是我们想知道的是，这样做符合 C++ 标准吗？\n三大编译器都这样，似乎没有不符合的道理。但是在 inline 那一小节并没有明确说明，而在 One Definition Rule 这里有如下两句话\nFor an inline function or inline variable(since C++17), a definition is required in every translation unit where it is odr-used. a function is odr-used if a function call to it is made or its address is taken 两句话啥意思呢？意思就是，一个 inline 函数，如果在某个编译单元被 odr-used 了，那么这个编译单元必须要有该函数的定义。啥情况是 odr-used 呢？后面一句话就是在解释，如果函数被调用或者取函数的地址就算是 odr-used。\n那我们看看之前的代码，在 main.C++ 中调用一个 inline 函数，但是却没有定义，所以其实是违背了 C++ 标准的约定的。到这里，算是松了一口气了。虽然有点反直觉，但是事实的确如此，三大编译器都没错！\n其他情况 我们这一小节主要讨论了两种情况：\n第一种即 inline 函数在多个编译单元都有实例（生成符号），那么这时候目前主流的链接器都只会选择其中一份保留，不会有额外的代码膨胀 第二种情况是 inline 函数被完全内联，并且不生成符号。这时候就如同普通的函数被内联一样，不属于\u0026quot;额外的开销\u0026quot; 可能会有人觉得 C++ 优化怎么规则这么多啊。但是实际上核心的规则只有一条，那就是 as-if 原则，也就是编译器可以对代码进行任何优化，只要最后生成的代码运行效果和不优化的一样就行了。编译器绝大部分时候都是按照这个原则来进行优化的，只有少数几个例外可以不满足这个原则。上述对 inline 函数的优化也是满足这个原则的，如果不显式对 inline 函数取地址，那的确没必要保留符号。\n另外， inline 虽然标准层面没有强制内联的语义了，但是实际上它会给编译器一些 hint，使得这个函数更容易被内联。这个 hint 是如何作用的呢？前面提到了，标准的措辞表明 inline 函数可以不生成符号。那相比之下，没有任何说明符限定的函数，则默认被标记为 extern ，必须要生成符号。编译器肯定是更愿意内联可以不生成符号的函数的。从这个角度出发，你可能会猜测 static 也会有类似的 hint 效果，实际情况的确如此。当然了，这些只是一个方面，实际上，判断函数是否被内联的计算会复杂的多。\n注意：本小节，只讨论了仅被 inline 标记的函数，除此之外还有 inline static 和 inline extern 这样的组合，感兴趣的读者可以阅读官方文档或者自行尝试效果如何。\n模板导致代码膨胀的真正原因？ 如果有人给出 C++ 二进制膨胀的理由，那么几乎它的答案一定是模板。果真如此吗？模板究竟是怎么导致二进制膨胀的？在什么情况导致的？难道我用了就导致吗？\n隐式实例化如同 inline 标记 我们知道模板实例化发生在当前编译单元，实例化一份就会产生一份代码。考虑下面这个例子\n// src1.cpp template \u0026lt;typename T\u0026gt; int add(T a, T b) { return a + b; } float g1() { return add(1, 2) + add(3.0, 4.0); } // src2.cpp template \u0026lt;typename T\u0026gt; int add(T a, T b) { return a + b; } float g2() { return add(1, 2) + add(3.0, 4.0); } // main.cpp extern float g1(); extern float g2(); int main() { return g1() + g2(); } 仍然不开优化，尝试编译 编译结果 如下\n可以看见就像被 inline 标记的函数那样，这两个编译单元都实例化了 add\u0026lt;int, int\u0026gt; 和 add\u0026lt;double, double\u0026gt;，各有一份代码。然后在最终链接的时候，链接器只为每个模板实例化保留了一份代码。那我们尝试打开 -O2，然后再看看情况。结果 如下\n也和 inline 标记的效果一样，编译器直接把函数内联了，然后实例化出的函数的符号都扔了。那这样的话，要么内联了符号都没生成，要么生成了符号，最后函数合并了。和 inline 一样，这种情况似乎没有额外的膨胀啊，那经常说的模板膨胀，究竟膨胀在哪呢？\n显式实例化和 extern 模板 在介绍真正膨胀的原因之前，我们先来讨论一下显式实例化。\n虽然链接器最后能合并多份相同的模板实例化。但是模板定义的解析，模板实例化，以及生成最终的二进制代码和链接器去除重复代码，这些都要编译时间的啊。有些时候，我们能确定，只是使用某几种固定模板参数的实例化，比如像标准库的 basic_string 几乎只有那几种固定的类型作为模板参数，如果每次个文件用到它们，都要进行模板实例化可能会大大增长编译时间。\n那我们可以像非模板函数一样，把实现放在某一个源文件，其他文件引用这个源文件的函数吗？从上一小节的讨论来看，既然会生成符号，那应该就有办法链接到。但是不能保证一定生成啊，有什么办法保证生成符号吗？\n答案就是 —— 显式实例化！\n什么叫显式实例化？简单来说，如果一个模板，你直接使用。而不提前声明具体到何种类型，由编译器帮你生成声明，那就算隐式实例化。反之就叫做显式实例化。以函数模板为例，\ntemplate \u0026lt;typename T\u0026gt; void f(T a, T b) { return a + b; } template void f\u0026lt;int\u0026gt;(int, int); // 显式实例化 f\u0026lt;int\u0026gt; 定义 void g() { f(1, 2); // 调用之前显式实例化的 f\u0026lt;int\u0026gt; f(1.0, 2.0); // 隐式实例化 f\u0026lt;double\u0026gt; } 相信还是很好理解的，而且显式实例化定义的话，编译器一定会为你保留符号。那接下来就是外部如何链接到这个显式实例化的函数了，有两种办法\n一种是，直接显式实例化一个函数声明\ntemplate \u0026lt;typename T\u0026gt; void f(T a, T b); template void f\u0026lt;int\u0026gt;(int, int); // 显式实例化 f\u0026lt;int\u0026gt; 仅声明 另一种是直接使用 extern 关键字实例化一个定义\ntemplate \u0026lt;typename T\u0026gt; void f(T a, T b) { return a + b; } extern template void f\u0026lt;int\u0026gt;(int, int); // 显式实例化 f\u0026lt;int\u0026gt; 声明 // 注意不加 extern 就会显式实例化一个定义了 这两种都能正确引用到上面那个函数 f，这样就可以调用其他文件的模板实例化了！\n真正的模板膨胀开销 接下来是最重要的部分了，我们将会介绍模板膨胀的真正原因。由于一些历史遗留问题，C++ 中 char,unsigned char,signed char 三种类型永远互不相同\nstatic_assert(!std::is_same_v\u0026lt;char, unsigned char\u0026gt;); static_assert(!std::is_same_v\u0026lt;char, signed char\u0026gt;); static_assert(!std::is_same_v\u0026lt;unsigned char, signed char\u0026gt;); 但是如果落实到编译器最终实现上来，char 要么 signed，要么 unsigned。假设我们编写一个模板函数\ntemplate \u0026lt;typename T\u0026gt; void f(T a, T b) { return a + b; } void g() { f\u0026lt;char\u0026gt;(\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;); f\u0026lt;unsigned char\u0026gt;(\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;); f\u0026lt;signed char\u0026gt;(\u0026#39;a\u0026#39;, \u0026#39;a\u0026#39;); } 实例化三种类型的函数模板，那么其中必然有两个实例化是相同的代码。编译器会把函数类型不同，但是最后生成的二进制代码相同的两个函数合并吗？尝试一下，结果 如下\n可以看到这里生成了两个完全一样的函数，但是并没有合并。当然，如果我们打开 -O2 优化，这样短的函数就会被内联掉了，也不会生成最终符号。就和第一小节说的那样，也就没有所谓的\u0026quot;模板膨胀开销\u0026quot;。实际代码编写中有很多这样的短小的模板函数，比如 vector 这种容器的 end,begin,operator[] 等等，它们大概率会被完全内联，从而没有\u0026quot;额外的膨胀\u0026quot;开销。\n现在问题来了，如果函数没有被内联呢？假设模板函数比较复杂，函数体较大。为了方便演示，我们暂时使用 GCC 的一个 attribute [[gnu::noinline]] 来实现这种效果，然后打开 O2，再次编译上面的 代码\n可以看到虽然被优化的只剩一条指令，但是编译器还是生成了三份函数。实际上，真的不被编译器内联的函数体积可能比较大，情况可能比这个「伪装的大函数」糟糕的多。于是，这样的话就产生了所谓的\u0026quot;模板膨胀\u0026quot;。本来能合并的代码却没有合并，这就是真正的模板膨胀开销所在。\n如果非常希望编译器/链接器合并这些相同的二进制代码怎么办呢？很遗憾，主流的工具链 ld / lld / ms linker 都不会做这种合并。目前唯一支持这个特性的链接器是 gold，但是它只能用于链接 ELF 格式的可执行文件，所以没法在 Windows 上面使用了。下面我展示一下：如何使用它合并相同的二进制代码\n// main.cpp #include \u0026lt;cstdio\u0026gt; #include \u0026lt;utility\u0026gt; template \u0026lt;std::size_t I\u0026gt; struct X { std::size_t x; [[gnu::noinline]] void f() { printf(\u0026#34;X\u0026lt;%zu\u0026gt;::f() called\\n\u0026#34;, x); } }; template \u0026lt;std::size_t... Is\u0026gt; void call_f(std::index_sequence\u0026lt;Is...\u0026gt;) { ((X\u0026lt;Is\u0026gt;{Is}).f(), ...); } int main(int argc, char* argv[]) { call_f(std::make_index_sequence\u0026lt;100\u0026gt;{}); return 0; } 我这里通过模板生成了 100 个不同的类型，但是实际上它们底层都是 size_t 类型，所以进行最终编译生成的二进制代码是完全相同的。使用如下命令尝试编译它\n$ g++ -O2 -ffunction-sections -fuse-ld=gold -Wl,--icf=all main.cpp -o main.o $ objdump -d main.o | c++filt 使用 -fuse-ld=gold 指定链接器，-Wl,--icf=all 指定链接器选项。icf 即意味着 identical code folding，即相同代码折叠。因为链接器只在 section 级别上工作，所以 GCC 则需要配合开启 -ffunction-sections，上面的编译器也可以替换成 clang\n0000000000000740 \u0026lt;X\u0026lt;99ul\u0026gt;::f() [clone .isra.0]\u0026gt;: 740: 48 89 fa mov %rdi,%rdx 743: 48 8d 35 1a 04 00 00 lea 0x41a(%rip),%rsi 74a: bf 01 00 00 00 mov $0x1,%edi 74f: 31 c0 xor %eax,%eax 751: e9 ca fe ff ff jmp 620 \u0026lt;_init+0x68\u0026gt; 756: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1) 75d: 00 00 00 0000000000000760 \u0026lt;void call_f\u0026lt;0..99\u0026gt;(std::integer_sequence\u0026lt;unsigned long, 0..99\u0026gt;) [clone .isra.0]\u0026gt;: 760: 48 83 ec 08 sub $0x8,%rsp 764: 31 ff xor %edi,%edi 766: e8 d5 ff ff ff call 740 \u0026lt;X\u0026lt;99ul\u0026gt;::f() [clone .isra.0]\u0026gt; ... # 重复 98 次 b48: e9 f3 fb ff ff jmp 740 \u0026lt;X\u0026lt;99ul\u0026gt;::f() [clone .isra.0]\u0026gt; b4d: 0f 1f 00 nopl (%rax) 对输出内容进行了一些筛选，可以发现，gold 把二进制完全相同的 100 个模板函数合并成一个了，所谓的\u0026quot;模板膨胀\u0026quot;消失了。相比之下，前面那些不做这种合并的链接器，自然就有额外的开销了。\n但是 gold 并不是万能的，有些情况不能很好的处理。假设这 100 个函数，前 90% 的代码相同，但是最后 10% 的代码不相同，那么它就无能为力了。它只是简单的对比最终生成的二进制，然后合并完全相同的函数。那么还有其他的解决办法吗？**自动挡没有，咱们还有手动挡呢，咱写 C++ 的没什么别的擅长的，就擅长开手动挡。 **\n手动优化模板膨胀问题 下面以大家最常用的 vector 为例，展示一下解决模板膨胀的主要思路。前面已经提到了，像迭代器接口这样的短函数，我们是不需要去管的。我们主要来处理那些逻辑比较复杂的函数，对 vector 来说，首当其冲的就是扩容函数了\n假设我们有如下 vector 代码\ntemplate \u0026lt;typename T\u0026gt; struct vector { T* m_Begin; T* m_End; T* m_Capacity; void grow(std::size_t n); }; 考虑一个 vector 扩容的朴素实现，暂不考虑异常安全\ntemplate \u0026lt;typename T\u0026gt; void vector\u0026lt;T\u0026gt;::grow(std::size_t n) { T* new_date = static_cast\u0026lt;T*\u0026gt;(::operator new (n * sizeof(T))); if constexpr(std::is_move_constructible_v\u0026lt;T\u0026gt;) { std::uninitialized_move(m_Begin, m_End, new_date); } else { std::uninitialized_copy(m_Begin, m_End, new_date); } std::destroy(m_Begin, m_End); ::operator delete (m_Begin); } 逻辑看起来还挺简单的。但是毫无疑问，它算是一个较复杂的函数了，尤其是当对象的构造函数被内联的话，代码量也是比较大的。那如何合并呢？注意，合并模板的前提是找出不同模板实例的相同部分，如果一个函数为不同的类型生成完全不同的代码，那是没法合并的。\n那对于 vector 来说，如果 T 里面的元素类型不同，扩容逻辑还能相同吗？考虑到构造函数调用，似乎没任何办法。关键点来了，这里需要介绍一个 trivially_relocatable 的概念，具体的讨论可以参考：全新的构造函数，C++ 中的 relocate 构造函数。\n我们这里只说结果，如果一个类型是 trivially_relocatable 的，那么可以使用 memcpy 把它从旧内存移动到新内存，不需要调用构造函数了。\n考虑编写如下的扩容函数\nvoid trivially_grow(char*\u0026amp; begin, char*\u0026amp; end, char*\u0026amp; capacity, std::size_t n, std::size_t size) { char* new_data = static_cast\u0026lt;char*\u0026gt;(::operator new (n * size)); std::memcpy(new_data, begin, (end - begin) * size); ::operator delete (begin); begin = new_data; end = new_data + (end - begin); capacity = new_data + n; } 然后将原来的 grow 实现转发到这个函数\ntemplate \u0026lt;typename T\u0026gt; void vector\u0026lt;T\u0026gt;::grow(std::size_t n) { if constexpr(is_trivially_relocatable_v\u0026lt;T\u0026gt;) { trivially_grow(reinterpret_cast\u0026lt;char*\u0026amp;\u0026gt;(m_Begin), reinterpret_cast\u0026lt;char*\u0026amp;\u0026gt;(m_End), reinterpret_cast\u0026lt;char*\u0026amp;\u0026gt;(m_Capacity), n, sizeof(T)); } else { // 原来的实现 } } 这样就完成了抽取公共逻辑。于是所有的 T 只要满足 trivially_relocatable，就可以全都这共享一份代码了。而几乎所有不含有自引用的类型都符合这个条件，于是 99% 的类型都使用同一套扩容逻辑！这样的优化效果是非常显著的！实际上 LLVM 很多容器的源码，比如 SmallVector,StringMap 等等，都使用了这样的技巧。另外如果你觉得上面的 reinterpret_cast 破坏了严格别名，用起来有点害怕，你可以通过继承来实现相同的效果（基类成员用 void*），具体的代码就不展示了。\n异常导致的代码膨胀！ 为什么 LLVM 源码禁用异常？很多人可能会下意识的认为，原因是异常很慢，效率很低。但其实，根据 LLVM Coding Standard 里面的内容，关闭异常和 RTTI 的主要目的是为了减少二进制大小。据说，打开异常和 RTTI 会导致 LLVM 的编译结果膨胀 10%-15%，那么实际情况究竟如何？\n目前主要的异常实现有两种，一种是 Itanium ABI 的实现，另一种则是 MS ABI 的实现。简单来说 MS ABI 采用运行时查找的办法，这样会导致异常在 Happy Path 执行也有的额外运行时开销，但是优点是最终生成的二进制代码相对较小。而 Itanium ABI 则是我们今天的主角，它号称零开销异常，Happy 路径没有任何额外的运行时开销。那古尔丹，代价是什么？代价就是非常严重的二进制膨胀。为什么会产生膨胀呢？简单来说，就是如果不想完全等到运行时去查找，那就得预先打表。由于异常的隐式传播特性，会导致表占用空间很大。具体实现细节非常复杂，不是本文的主题，放张图，大概感受一下\n那我们主要讨论什么呢？异常会导致二进制膨胀，这个没什么好怀疑的。我们主要看看如何减少异常产生的二进制膨胀，以 Itanium ABI 为例\n先来看下面这段示例代码\n#include \u0026lt;vector\u0026gt; void foo(); // 外部链接函数，可能抛出异常 void bar() { std::vector\u0026lt;int\u0026gt; v(12); // 拥有 non-trivial 的析构函数 foo(); } 注意，这里 foo 是一个外部链接的函数，可能会抛出异常。另外就是 vector 的析构函数调用是在 foo 之后的。如果 foo 抛出异常，控制流不知道跳转到什么地方了，那么 vector 的析构函数可能被跳过调用了，如果编译器不做些特殊处理的话，就会导致内存泄露了。先只打开 -O2 看看程序编译的结果\nbar(): ... call operator new(unsigned long) ... call foo() ... jmp operator delete(void*, unsigned long) mov rbp, rax jmp .L2 bar() [clone .cold]: .L2: mov rdi, rbx mov esi, 48 call operator delete(void*, unsigned long) mov rdi, rbp call _Unwind_Resume 省略掉不重要的部分，和我们刚才猜的大致相同。那这个 .L2 是干嘛的呢？这个其实就是异常被 catch 处理完后会跳转到这个 L2 把之前没处理完的工作做完（这里就是析构之前未析构的对象），之后再 Resume 回到先前的位置。\n我们稍微调整下代码，把 foo 调用移动到 vector 构造的前面，其他什么都不变\nbar(): sub rsp, 8 call foo() mov edi, 48 call operator new(unsigned long) ... jmp operator delete(void*, unsigned long) 可以发现没有生成清理栈的代码了，很合理。原因很简单，如果 foo 抛出异常，控制流直接跳转走了，那 vector 都没构造呢，自然也不需要析构了。通过简单的调整调用顺序就减少了二进制大小！但是，只有这种特别简单的情况下，依赖关系才比较明显。如果实际抛出异常的函数很多的话，就很难分析了。\nnoexcept 先讨论 C++11 加入的这个 noexcept。注意即使加了 noexcept，这个函数还是可能会抛出异常的，如果该函数抛出异常，程序直接 terminate。那你可能要问了，这玩意有啥用呢？我异常抛了，不捕获不也是 terminate 吗？\n其实这个和 const 有点类似，你想改 const 变量，虽然是 undefined behavior，但是运行时随便改呀，限制不多。那你要问了， const 有什么意义？一个重要的意义是给编译器提供优化指示信息。编译器可以利用这个做 constant folding（常量折叠） 和 common subexpression elimination（公共子表达式消除）。\nnoexcept 也是类似的，它让编译器假设这个函数不会抛出异常，从而可以进行一些额外的优化。 还是第一个例子里面的代码为例，唯一的改变是把 foo 函数声明为了 noexcept，然后再次编译\nbar(): push rbx mov edi, 48 call operator new(unsigned long) ... call foo() ... jmp operator delete(void*, unsigned long) 可以发现，用于异常处理的代码路径，同样没有了，这就是 noexpect 的功劳。\nfno-exceptions 终于讲到重头戏了：-fno-exceptions，注意这个选项非标准。但是三大编译器都有提供，不过具体的实现效果有些许差异。好像并没有十分详细的文档，我仅凭经验说一下 GCC 相关的，对于 GCC 来说，该选项会禁止用户的代码里面使用 try,catch,throw 等关键字，如果使用则导致编译错误。但是特别的，允许使用标准库。如果异常被抛出，就和 noexcept 一样，程序直接 terminate。所以如果打开了这个选项，GCC 会默认假设所有函数不会抛出异常。\n仍然是上面的例子，我们尝试打开 -fno-exceptions，然后再次编译\nbar(): push rbx mov edi, 48 call operator new(unsigned long) ... call foo() ... jmp operator delete(void*, unsigned long) 可以发现和 noexcept 产生的效果类似，它们都会让编译器假设某个函数不会抛出异常，从而不需要生成清理栈的额外代码,达到减少程序二进制大小的效果。\n这篇文章涉及到的话题跨度有点大，某些地方有错误在所难免，欢迎评论区讨论交流。\n","permalink":"https://www.ykiko.me/zh-cn/articles/686296374/","summary":"\u003cp\u003e相信读者经常能听见有人说 C++ 代码二进制膨胀严重，但是一般很少会有人指出具体的原因。在网络上一番搜索过后，发现深入讨论这个问题的文章并不多。上面那句话更像是八股文的一部分，被口口相传，但是没什么人能说出个所以然。今天小编 ykiko 就带大家一起来探秘 C++ 代码膨胀那些事 (^ω^)\u003c/p\u003e","title":"C++ 究竟代码膨胀在哪里？"},{"content":"前情提要：The History of constexpr in C++! (Part One)\n2015-2016：模板的语法糖 在 C++ 中支持 全特化 (full specialization) 的模板很多，但是支持 偏特化 (partial specialization) 的模板并不多，事实上其实只有类模板 (class template) 和变量模板 (variable template) 两种支持，而变量模板其实可以看做类模板的语法糖，四舍五入一下其实只有类模板支持偏特化。不支持偏特化会导致有些代码十分难写\n假设我们想实现一个 destroy_at 函数，效果就是调用对象的析构函数。特别的，如果析构函数是 trivial 的，那我们就省去这次无意义的析构函数调用。\n直觉上我们能写出下面这样的代码\ntemplate \u0026lt;typename T, bool value = std::is_trivially_destructible_v\u0026lt;T\u0026gt;\u0026gt; void destroy_at(T* p) { p-\u0026gt;~T(); } template \u0026lt;typename T\u0026gt; void destroy_at\u0026lt;T, true\u0026gt;(T* p) {} 很可惜，clangd 已经可以智慧的提醒你：Function template partial specialization is not allowed。函数模板不能偏特化，那咋办呢？当然了，可以包一层类模板解决，但是每次遇到这种情况都额外包一层实在是让人难以接受。\n旧时代的做法是利用 SFINAE 来解决这个问题\ntemplate \u0026lt;typename T, std::enable_if_t\u0026lt;(!std::is_trivially_destructible_v\u0026lt;T\u0026gt;)\u0026gt;* = nullptr\u0026gt; void destroy_at(T* p) { p-\u0026gt;~T(); } template \u0026lt;typename T, std::enable_if_t\u0026lt;std::is_trivially_destructible_v\u0026lt;T\u0026gt;\u0026gt;* = nullptr\u0026gt; void destroy_at(T* p) {} 具体的原理这里就不叙述了，虽然少了一层包装，但是仍然有很多与代码逻辑无关的东西出现。这里的 std::enable_if_t 就是典型例子，严重影响了代码的可读性。\n提案 N4461 希望引入 static_if（借鉴自 D 语言）可以用来编译期控制代码生成，只会把实际用到的分支编译进最终的二进制代码。这样就可以写出下面这样的代码，其中 static_if 的条件必须是常量表达式\ntemplate \u0026lt;typename T\u0026gt; void destroy_at(T* p) { static_if(!std::is_trivially_destructible_v\u0026lt;T\u0026gt;) { p-\u0026gt;~T(); } } 可以发现逻辑非常清晰，但是委员会一般对于加新的关键字比较谨慎。后来 static_if 被重命名为 constexpr_if，再后来变成了我们今天熟悉的这种形式并且进入 C++17\nif constexpr(...) { ... } else if constexpr(...) { ... } else { ... } 巧妙地避免了加新的关键字，C++ 委员会还真是喜欢关键字复用呢。\n2015：constexpr lambda 提案 N4487 讨论了支持 constexpr lambda 可能性，尤其希望能在 constexpr 计算中能够使用 lambda 表达式，并附带了一个实验性实现。\n其实支持 constexpr 的 lambda 表达式并不困难，我们都知道 lambda 在 C++ 里面是很透明的，基本上完全就是一个匿名的函数对象。函数对象都能是 constexpr 的，那么支持 constexpr 的 lambda 也就是理所当然的事情了。\n唯一需要注意的就是，lambda 是可以进行捕获的，捕获 constexpr 的变量会怎么样呢？\nvoid foo() { constexpr int x = 3; constexpr auto foo = [=]() { return x + 1; }; static_assert(sizeof(foo) == 1); } 从直觉上来说，由于 x 是常量表达式，没有必要给它分配空间来储存。那么 f 其实里面没有任何成员，在 C++ 中空类的 size 至少是 1。上面的代码挺合理的，但是在文章的上篇也说到了，constexpr 变量其实也是可以占用内存的，我们可以显式取它的地址\nvoid foo() { constexpr int x = 3; constexpr auto foo = [=]() { return \u0026amp;x + 1; }; static_assert(sizeof(foo) == 4); } 可以发现这种情况下，编译器不得不给 x 分配内存。实际上的判断规则更复杂一些，感兴趣的可以自行参考 lambda capture。最终这个提案被接受，进入了 C++17。\n2017-2019：编译期和运行期……不同? 通过不断放宽 constexpr 的限制，越来越多的函数可以在编译期执行。但是具有外部链接（也就是被 extern 的函数）无论如何是无法在编译期执行的。绝大部分从 C 继承过来的函数都是这样的，例如 memcpy, memmove 等等。\n假设我写了一个 constexpr 的 memcpy\ntemplate \u0026lt;typename T\u0026gt; constexpr T* memcpy(T* dest, const T* src, std::size_t count) { for(std::size_t i = 0; i \u0026lt; count; ++i) { dest[i] = src[i]; } return dest; } 虽然能在编译期用了，编译期执行效率倒是无所谓，但是运行期效率肯定不如标准库的实现。如果能在编译期使用我的实现，运行期使用外部链接的标准库函数就好了。\n提案 P0595 希望加入一个新的 magic function 也就是 constexpr() 用来判断当前的函数是否在编译期执行，后来被更名为 is_constant_evaluated 并且进入 C++20。使用起来就像下面这样\nconstexpr int foo(int x) { if(std::is_constant_evaluated()) { return x; } else { return x + 1; } } 这样的话编译期和运行期就可以采用不同的逻辑实现了，我们可以对外部链接的函数进行一层封装，使得它们在内部暴露为 constexpr 的函数接口，既可以代码复用又可以保证运行期效率，两全其美。\n唯一的问题是，假设上面的 foo 在运行期运行，你会发现第一个分支仍然被编译了，虽然可能编译器最终应该会把 if(false) 这个分支优化掉。但是这个分支里面仍然会进行语法检查之类的工作，如果里面用到了模板，那么模板实例化仍然会被触发（甚至产生预料外的实例化导致编译错误），显然这不是我们想要的结果。尝试使用 if constexpr 改写上面的代码呢？\nconstexpr int foo(int x) { if constexpr(std::is_constant_evaluated()) { // ... } } 这种写法被认为是 obviously incorrect，因为 if constexpr 的条件只能在编译期执行，所以这里 is_constant_evaluated 永远会返回 true，这与我们最开始的目的相悖了。 所以提案 P1938R3 提议加入新的语法来解决这个问题\nif consteval /* !consteval */ { // ... } else { // ... } 代码看上去是一目了然的，两个分支一个编译期一个运行期。这个升级过后的版本最终被接受并加入 C++23。\n2017-2019： 高效的调试 C++ 模板一个最被人诟病的问题就是报错信息非常糟糕，而且难以调试。内层模板实例化失败之后，会把整个实例化栈打印出来，能轻松产生成百上千行报错。但是事情在 constexpr 函数这里其实也并没有变好，如果 constexpr 函数常量求值失败，也会把整个函数调用堆栈打印出来\nconstexpr int foo() { return 13 + 2147483647; } constexpr int bar() { return oo(); } constexpr auto x = bar(); 报错\nin \u0026#39;constexpr\u0026#39; expansion of \u0026#39;bar()\u0026#39; in \u0026#39;constexpr\u0026#39; expansion of \u0026#39;foo()\u0026#39; error: overflow in constant expression [-fpermissive] 233 | constexpr auto x = bar(); 如果函数嵌套多了，报错信息也非常糟糕。不同于模板的地方在于，constexpr 函数也可以在运行期运行。所以我们可以在运行期调试代码，最后在编译期执行就好了。但是如果考虑到上一小节加的 is_constant_evaluated，就会发现这种做法并不完全可行，因为编译期和运行期的代码逻辑可能不同。提案 P0596 希望引入 constexpr_trace 和 constexpr_assert 来方便编译期调试代码，虽然投票一致赞成，但是暂时未进入 C++ 标准。\n2017： 编译期可变容器 尽管在先前的提案中，允许了 constexpr 函数使用和修改变量，但是动态内存分配还是不允许的。如果有未知长度的数据需要处理，一般就是在栈上开一个大数组，这没什么问题。但是从实践上来说，有特别多的函数依赖于动态内存分配，支持 constexpr 函数中使用 vector 势在必得。\n在当时，直接允许在 constexpr 函数中使用 new/delete 似乎过于让人惊讶了，所以提案 P0597 想了一个折中的办法，先提供一个 magic container 叫做 std::constexpr_vector，它由编译器实现，并且支持在 constexpr 函数中使用和修改。\nconstexpr constexpr_vector\u0026lt;int\u0026gt; x; // ok constexpr constexpr_vector\u0026lt;int\u0026gt; y{1, 2, 3}; // ok constexpr auto series(int n) { std::constexpr_vector\u0026lt;int\u0026gt; r{}; for(int k; k \u0026lt; n; ++k) { r.push_back(k); } return r; } 这并不彻底解决问题，用户仍然需要重写它的代码以支持常量求值。从在 constexpr 函数支持循环的那一节来看，这种加重语言不一致性的东西，很难被加入标准。最终有更好的提案取代了它，后面会提到。\n2018：真正的编译期多态？ 提案 P1064R0 希望在常量求值中支持虚函数调用。哎，还不支持动态内存分配呢，咋就要支持虚函数调用了？其实不依赖动态内存分配也可以弄出来多态指针嘛，指向栈上的对象或者静态储存就可以了。\nstruct Base { virtual int foo() const { return 1; } }; struct Derived : Base { int foo() const override { return 2; } }; constexpr auto foo() { Base* p; Derived d; p = \u0026amp;d; return p-\u0026gt;foo(); } 似乎没有任何理由拒绝上面这段代码编译通过。由于是在编译期执行，编译器当然能知道 p 指向的是 Derived，然后调用 Derived::f，实践上没有任何难度。的确如此，之后又有一个新的提案 P1327R1 进一步希望 dynamic_cast 和 typeid 也能在常量求值中使用，最终它们都被接受并且加入了 C++20，现在可以自由的在编译期使用这些特性了。\n2017-2019： 真正的动态内存分配！ 在 constexpr everything 的这个演示视频中，展示了一个能在编译期处理 JSON 对象的例子\nconstexpr auto jsv = R\u0026#34;({ \u0026#34;feature-x-enabled\u0026#34;: true, \u0026#34;value-of-y\u0026#34;: 1729, \u0026#34;z-options\u0026#34;: {\u0026#34;a\u0026#34;: null, \u0026#34;b\u0026#34;: \u0026#34;220 and 284\u0026#34;, \u0026#34;c\u0026#34;: [6, 28, 496]} })\u0026#34;_json; if constexpr(jsv[\u0026#34;feature-x-enabled\u0026#34;]) { // feature x } else { // feature y } 希望能直接通过解析常量字符串起到配置文件的作用（字符串文本可以由 #include 引入）。作者们因为不能使用 STL 的容器受到了严重影响，并且自己编写了替代品。通过 std::array 来实现 std::vector 和 std::map 这样的容器，由于没有动态内存分配，只能预先计算出需要的大小（可能导致多次遍历）或者在栈上开块大内存。\n提案 P0784R7 重新讨论了在常量求值中支持标准库容器的可能性\n主要有以下三个难点：\n析构函数不能被声明为 constexpr（对于 constexpr 对象，它们必须是 trivial 的） 无法进行动态内存分配/释放 无法在常量求值中使用 placement new 来调用对象的构造函数 针对第一个问题，作者们与 MSVC，GCC，Clang，EDG 等前端开发人员快速讨论并解决了这个问题。C++20 起，可以符合 literal type 要求的类型具有 constexpr 修饰的析构函数，而不是严格要求平凡的析构函数。\n针对第二个问题，处理起来并不简单。C++ 有很多未定义行为都是由于错误的内存处理导致的，相比之下，不能直接操作内存的脚本语言则安全的多。但是为了复用代码，C++ 编译器中的常量求值器不得不直接操作内存，不过由于所有信息都是编译期已知的，理论上可以保证常量求值中不会出现内存错误 (out of range, double free, memory leak, \u0026hellip;)，如果出现应该中止编译并报告错误。\n常量求值器需要跟踪许多对象的元信息，并找出这些错误\n记录 union 哪个 field 是 active 的，访问 unactive 的成员导致未定义行为，这由 P1330 阐明 正确记录对象的 lifetime，访问未初始化的内存和已经析构的对象都是不允许的 当时还不允许在常量求值中把 void* 转换成 T*，所以理所当然的\nvoid* operator new (std::size_t); 不支持在常量求值中使用，取而代之的是\n// new =\u0026gt; initialize when allocate auto pa = new int(42); delete pa; // std::allocator =\u0026gt; initialize after allocate std::allocator\u0026lt;int\u0026gt; alloc; auto pb = alloc.allocate(1); alloc.deallocate(pb, 1); 它们返回的都是 T*，并且由编译器实现，这对于支持标准库容器来说已经足够了。\n对于第三个问题，则是添加了一个 magic function 即 std::construct_at，它的作用是在指定的内存位置上调用对象的构造函数，用来在常量求值中取代 placement new。这样的话我们就可以先通过 std::allocator 分配内存，再通过 std::construct_at 来构造对象了。该提案最终被接受，进入了 C++20，同时使得 std::vector，std::string 在常量求值中可用（其他的容器理论上也行，但是目前的实现还没支持，如果非常想要只能自己搓一个了）。\n虽然支持了动态内存分配，但并不是毫无限制。在一次常量求值中分配的内存必须要在这次常量求值结束之前释放完全，不能有内存泄漏，否则会导致编译错误。这种类型的内存分配被叫做 transient constexpr allocations（瞬态内存分配）。该提案也讨论了 non-transient allocation（非瞬态内存分配），在编译期未被释放的内存，将被转为静态储存（其实就是存在数据区，就像全局变量那样）。但是，委员会认为这种可能性 \u0026ldquo;too brittle\u0026rdquo;，出于多种原因，目前尚未采纳。\n2018：更多的 constexpr 提案 P1002 希望在 constexpr 函数中支持 try-catch 块。但是不能 throw，这样是为了能把更多的标准库容器的成员函数标记为 constexpr。\nconstexpr int foo() { throw 1; return 1; } constexpr auto x = foo(); // error // expression \u0026#39;\u0026lt;throw-expression\u0026gt;\u0026#39; is not a constant expression // 233 | throw 1; 如果在编译期 throw 会直接导致编译错误，由于 throw 不会发生，那自然也不会有异常被捕获。\n2018：保证编译期执行！ 有些时候我们想保证一个函数在编译期执行\nextern int foo(int x); constexpr int bar(int x) { return x; } foo(bar(1)); // evaluate at compile time ? 事实上 bar 无论是在编译期还是运行期执行，理论上都可以。为了保证它在编译期执行，我们需要多写一些代码\nconstexpr auto x = bar(1); foo(x); 这样就保证了 bar(1) 在编译期执行，同样，这种没意义的局部变量实在是多余。提案 P1073 希望增加一个标记 constexpr! 来确保一个函数在编译期执行，如果不满足则导致编译错误。最终该标记被更名为 consteval 并进入了 C++20。\nextern int foo(int x); consteval int bar(int x) { return x; } foo(bar(1)); // ensure evaluation at compile time consteval 函数不能在常量求值上下文外获取指针或引用，编译器后端既不需要，也不应该知道这些函数的存在。事实上该提案也为未来打算加入标准的 static reflection 做了铺垫，它将会添加非常多的只能在编译期执行的函数。\n2018：默认 constexpr ？ 在当时，有很多提案的内容仅仅是把标准库的某个部分标记为 constexpr，在本文中没有讨论它们，因为它们具有相同的模式。\n提案 P1235 希望把所有函数都标记为 implicit constexpr 的\nnon：如果可能，将方法标记为 constexpr。 constexpr：与当前行为相同 constexpr(false)：不能在编译时调用 constexpr(true)：只能在编译时调用 该提案最终没有被接受。\n2020：更强的动态内存分配？ 正如之前提到的，在 constexpr 函数中支持内存分配已经被允许了，也可以在 constexpr 函数中使用 std::vector 这样的容器，但是由于是瞬态内存分配，无法创建全局的 std::vector\nconstexpr std::vector\u0026lt;int\u0026gt; v{1, 2, 3}; // error 所以如果一个 constexpr 函数返回一个 std::vector，只能额外包装一层把这个 std::vector 转成 std::array 然后作为全局变量\nconstexpr auto f() { return std::vector\u0026lt;int\u0026gt;{1, 2, 3}; } constexpr auto arr = []() { constexpr auto len = f().size(); std::array\u0026lt;int, len\u0026gt; result{}; auto temp = f(); for(std::size_t i = 0; i \u0026lt; len; ++i) { result[i] = temp[i]; } return result; }; 提案 P1974 提议使用 propconst 来支持非瞬态内存分配，这样上述的额外的包装代码就不需要了。\n非瞬态内存分配的原理很简单\nconstexpr std::vector vec = {1, 2, 3}; 编译器会将上述代码编译为类似下面这样\nconstexpr int data[3] = {1, 2, 3}; constexpr std::vector vec{.begin = data, .end = data + 3, .capacity = data + 3}; 其实就是把本来应该指向动态分配的内存的指针改为指向静态内存。原理并不复杂，真正的难点是如何保证程序的正确性。显然上述的 vec 即使在程序结束的时候也不应该调用析构函数，否则会导致段错误。这个问题要解决很简单，我们可以约定，任何 constexpr 标记的变量都不会调用析构函数。\n但是考虑如下情况：\nconstexpr unique_ptr\u0026lt;unique_ptr\u0026lt;int\u0026gt;\u0026gt; ppi{new unique_ptr\u0026lt;int\u0026gt;{new int{42}}}; int main() { ppi.reset(new int{43}); // error, ppi is const auto\u0026amp; pi = *ppi; pi.reset(new int{43}); // ok } 由于 pp1 是 constexpr 的，那么它的析构函数不应该调用。对 ppi 尝试调用 reset 是不允许的，因为 constexpr 标记的变量隐含 const，而 reset 并不是一个 const 方法。但是对 pi 调用 reset 是允许的，因为外层 const 不影响内层指针。\n如果允许 pi 调用 reset，显然这是一次运行期调用，会在运行期动态内存分配，而由于 ppi 不会调用析构函数，里面的 pi 当然也不会调用析构函数，于是内存就泄露了，显然这种做法不应该被允许。\n解决办法自然是想办法禁止 pi 调用 reset，提案提出了 propconst 关键字，它可以把外层的 constexpr 传递给内层，这样 pi 也是 const 的了，也就不能调用 reset 了，就不会出现代码逻辑问题了。\n可惜的是暂时还未被标准接受，在那之后还有一些新的提案希望能够支持这个特性比如 P2670R1，相关的讨论还在继续。\n2021：constexpr 类 C++ 标准库中的很多类型，比如 vector, string, unique_ptr 中的所有方法都被标记为 constexpr，并且真正可以在编译期执行。很自然的，我们希望能直接标记整个类为 constexpr，这样可以省去那些重复的说明符编写。\n提案 P2350 希望支持这个特性，constexpr 标记的 class 中的所有方法都被隐式标记为 constexpr\n// before class struct { constexpr bool empty() const { /* */ } constexpr auto size() const { /* */ } constexpr void clear() { /* */ } }; // after constexpr struct SomeType { bool empty() const { /* */ } auto size() const { /* */ } void clear() { /* */ } }; 有一个有趣的故事与这个提案有关 - 在不知道它的存在之前，我（文章原作者）在 stdcpp.ru 提出了同样的想法。\n在标准制定过程中，很多几乎相同的提案几乎可以同时出现。这证明了 多重发现理论的正确性：某些思想或概念会在不同的人群中独立地出现，就像它们在空气中漂浮一样，并且谁先发现的并不重要。如果社区的规模足够大，这些思想或概念自然会发生演变。\n2023：编译期类型擦除！ 在常量求值中，一直不允许把 void* 转换成 T*，这样导致诸如 std::any，std::function 等类型擦除实现的容器无法在常量求值中使用。原因呢，是因为我们可以通过 void* 来绕过类型系统，把一个类型转换为不相干的类型\nint* p = new int(42); double* p1 = static_cast\u0026lt;float*\u0026gt;(static_cast\u0026lt;void*\u0026gt;(p)); 如果对 p1 解引用实际上是未定义的行为，所以禁止了这种转换（注意 reinterpret_cast 一直在常量求值中禁用）。但是显然这种做法已经误伤了正确的写法了，因为像 std::any 这种实现，显然不会把一个从 void* 转换成无关的类型，而是会把它转换回原来的类型，完全不允许这种转换是不合理的。提案 P2738R0 希望在常量求值中支持这种转换，编译器理论上能在编译期记录一个 void* 指针原本的类型，如果转换的不是原本的类型，就报错。\n最终该提案被接受，并且加入 C++26，现在可以进行 T* -\u0026gt; void* -\u0026gt; T* 的转换了\nconstexpr void f() { int x = 42; void* p = \u0026amp;x; int* p1 = static_cast\u0026lt;int*\u0026gt;(p); // ok float* p2 = static_cast\u0026lt;float*\u0026gt;(p); // error } 2023：支持 placement new？ 前面我们提到，为了支持 vector 在常量求值中使用，加入了 construct_at 用于在常量求值中调用构造函数。它具有如下形式\ntemplate \u0026lt;typename T, typename... Args\u0026gt; constexpr T* construct_at(T* p, Args\u0026amp;\u0026amp;... args); 虽然一定程度上解决了问题，但是它并不能完全提供 placement new 的功能\nvalue initialization new (p) T(args...); // placement new version construct_at(p, args...); // construct_at version default initialization new (p) T; // placement new version std::default_construct_at(p); // P2283R1 list initialization new (p) T{args...}; // placement new version // construct_at version doesn\u0026#39;t exist designated initialization new (p) T{.x = 1, .y = 2}; // placement new version // construct_at version cannot exist 提案 P2747R1 希望在常量求值中直接支持 placement new。暂时还未被加入标准。\n2024-∞：未来无极限！ 截止目前，C++ 的常量求值已经支持了非常丰富的功能，支持条件，变量，循环，虚函数调用，动态内存分配等等一系列特性。但是受限于日常开发使用的 C++ 版本，有很多功能可能暂时没法使用，可以在 这里 方便的查看哪个版本支持了什么特性。\n未来的 constexpr 中仍然有很多可能性，比如像 memcpy 这样的函数或许也能在常量求值中使用？又或者目前的 small_vector 的某些实现不能在不改动任何代码的前提下变成 constexpr 的，因为它们使用 char 数组为栈上的对象提供储存（为了避免默认构造）\nconstexpr void foo() { std::byte buf[100]; std::construct_at(reinterpret_cast\u0026lt;int*\u0026gt;(buf), 42); // no matter what } 但是目前在常量求值中无法直接在 char 数组上构造对象。更进一步，在 C++20 加入的 implicit lifetime 是否可能在常量求值中表现出来呢？这些理论上都是可能实现的，只是要求编译器记录更多的元信息。而在未来，一切皆有可能！最终我们或许真的能 constexpr everything！\n","permalink":"https://www.ykiko.me/zh-cn/articles/683463723/","summary":"\u003cp\u003e前情提要：\u003ca href=\"https://www.ykiko.me/zh-cn/articles/682031684\"\u003eThe History of constexpr in C++! (Part One)\u003c/a\u003e\u003c/p\u003e\n\u003ch2 id=\"2015-2016模板的语法糖\"\u003e2015-2016：模板的语法糖\u003c/h2\u003e\n\u003cp\u003e在 C++ 中支持 \u003ca href=\"https://en.cppreference.com/w/cpp/language/template_specialization\"\u003e全特化 (full specialization)\u003c/a\u003e 的模板很多，但是支持 \u003ca href=\"https://en.cppreference.com/w/cpp/language/partial_specialization\"\u003e偏特化 (partial specialization)\u003c/a\u003e 的模板并不多，事实上其实只有类模板 (class template) 和变量模板 (variable template) 两种支持，而变量模板其实可以看做类模板的语法糖，四舍五入一下其实只有类模板支持偏特化。不支持偏特化会导致有些代码十分难写\u003c/p\u003e","title":"The History of constexpr in C++! (Part Two)"},{"content":"几个月前，我写了一篇介绍 C++ 模板的文章：雾里看花：真正意义上的理解 C++ 模板。\n理清了现代 C++ 中模板的地位。其中用 constexpr function 替代模板进行编译期计算可以说是现代 C++ 最重要的改进之一了。 constexpr 本身其实并不难以理解，非常直观。但是由于几乎每个 C++ 版本都在改进它，所以不同的 C++ 版本可以使用的内容差别很大，有时候可能给人一种 inconsistency 的感觉。\n刚好最近我偶然间读到了这篇文章：Design and evolution of constexpr in C++，全面介绍了 C++ 中 constexpr 的发展史，写的非常好。于是便想将其翻译到中文社区。\n但是有趣的是，这篇文章其实也是翻译的。文章的原作者是一位俄罗斯人，文章最初也是发表在俄罗斯的论坛上。这是作者的邮箱：izaronplatz@gmail.com，我已经和他联系过了，他回复到：\nIt\u0026rsquo;s always good to spread knowledge in more languages.\n也就是允许翻译了。但是我并不懂俄文，所以主要参考了原文结构，而主体部分，基本都是我重新叙述的。\n原文内容较长，故分为上下两篇，这是上篇\n很神奇吗？ constexpr 是当代 C++ 中最神奇的关键字之一。它使得某些代码可以在编译期执行。\n随着时间的推移，constexpr 的功能越来越强大。现在几乎可以在编译时计算中使用标准库的所有功能。\nconstexpr 的发展历史可以追溯到早期版本的 C++。通过研究标准提案和编译器源代码，我们可以了解这一语言特性是如何一步步地构建起来的，为什么会以这样的形式存在，实际上 constexpr 表达式是如何计算的，未来有哪些可能的功能，以及哪些功能可能会存在但没有被纳入标准。\n本文适合于任何人，无论你是否了解 constexpr ！\nC++98/03：我比你更 const 在 C++ 中，有些地方需要整数常量（比如内建数组类型的长度），这些值必须在编译期就确定。C++ 标准允许通过简单的表达式来构造常量，例如\nenum EPlants { APRICOT = 1 \u0026lt;\u0026lt; 0, LIME = 1 \u0026lt;\u0026lt; 1, PAPAYA = 1 \u0026lt;\u0026lt; 2, TOMATO = 1 \u0026lt;\u0026lt; 3, PEPPER = 1 \u0026lt;\u0026lt; 4, FRUIT = APRICOT | LIME | PAPAYA, VEGETABLE = TOMATO | PEPPER, }; template \u0026lt;int V\u0026gt; int foo(int v = 0) { switch(v) { case 1 + 4 + 7: case 1 \u0026lt;\u0026lt; (5 | sizeof(int)): case(12 \u0026amp; 15) + PEPPER: return v; } } int f1 = foo\u0026lt;1 + 2 + 3\u0026gt;(); int f2 = foo\u0026lt;((1 \u0026lt; 2) ? 10 * 11 : VEGETABLE)\u0026gt;(); 这些表达式在 [expr.const] 小节中被定义，并且被叫做常量表达式（constant expression）。它们只能包含：\n字面量：1,'A',true,... 枚举值 整数或枚举类型的模板参数（例如 template\u0026lt;int v\u0026gt; 中的 v） sizeof 表达式 由常量表达式初始化的 const 变量 前几项都很好理解的，对于最后一项稍微有点复杂。如果一个变量具有 静态储存期，那么在常规情况下，它的内存会被填充为 0，之后在程序开始执行的时候改变。但是对于上述的变量来说，这太晚了，需要在编译结束之前就计算出它们的值。\n在 C++98/03 当中有两种类型的 静态初始化：\n零初始化 内存被填充为 0，然后在程序执行期间改变 常量初始化 使用常量表达式进行初始化，内存（如果需要的话）立即填充为计算出来的值 所有其他的初始化都被叫做 动态初始化，这里我们不考虑它们。\n让我们看一个包含两种静态初始化的例子\nint foo() { return 13; } const int v1 = 1 + 2 + 3 + 4; // const initialization const int v2 = 15 * v1 + 8; // const initialization const int v3 = foo() + 5; // zero initialization const int v4 = (1 \u0026lt; 2) ? 10 * v3 : 12345; // zero initialization const int v5 = (1 \u0026gt; 2) ? 10 * v3 : 12345; // const initialization 变量 v1, v2 和 v5 都可以作为常量表达式，可以用作模板参数，switch 的 case，enum 的值，等等。而 v3 和 v4 则不行。即使我们能明显看出 foo() + 5 的值是 18，但在那时还没有合适的语义来表达这一点。\n由于常量表达式是递归定义的，如果一个表达式的某一部分不是常量表达式，那么整个表达式就不是常量表达式。在这个判断过程中，只考虑实际计算的表达式，所以 v5 是常量表达式，但 v4 不是。\n如果没有获取常量初始化的变量的地址，编译器就可以不为它分配内存。所以我们可以通过取地址的方式，来强制编译器给常量初始化的变量预留内存（其实如果没有显式取地址的话，普通的局部变量也可能被优化掉，任何不违背 as-if 原则的优化都是允许的。可以考虑使用 [[gnu::used]] 这个 attribute 标记避免变量被优化掉）。\nint main() { std::cout \u0026lt;\u0026lt; v1 \u0026lt;\u0026lt; \u0026amp;v1 \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; v2 \u0026lt;\u0026lt; \u0026amp;v2 \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; v3 \u0026lt;\u0026lt; \u0026amp;v3 \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; v4 \u0026lt;\u0026lt; \u0026amp;v4 \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; v5 \u0026lt;\u0026lt; \u0026amp;v5 \u0026lt;\u0026lt; std::endl; } 编译上述代码并查看符号表（环境是 Windows x86-64）\n$ g++ --std=c++98 -c main.cpp $ objdump -t -C main.o (sec 6)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000000 v1 (sec 6)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000004 v2 (sec 3)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000000 v3 (sec 3)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000004 v4 (sec 6)(fl 0x00)(ty 0)(scl 3) (nx 0) 0x0000000000000008 v5 ---------------------------------------------------------------- (sec 3)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x0000000000000000 .bss (sec 4)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x0000000000000000 .xdata (sec 5)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x0000000000000000 .pdata (sec 6)(fl 0x00)(ty 0)(scl 3) (nx 1) 0x0000000000000000 .rdata 可以发现在我的 GCC 14 上，零初始化的变量 v3 和 v4 被放在 .bss 段，而常量初始化的变量 v1, v2,v5 被放在 .rdata 段。操作系统会对 .rdata 段进行保护，使其处于只读模式，尝试写入会导致段错误。\n从上述的差异可以看出，一些 const 变量比其他的更加 const。但是在当时我们并没有办法检测出这种差异（后来的 C++20 引入了 constinit 来确保一个变量进行常量初始化）。\n0-∞：编译器中的常量求值器 为了理解常量表达式是如何求值的，我们需要简单了解编译器的构造。不同编译器的处理方法大致相同，接下来将以 Clang/LLVM 为例\n总的来说，编译器可以看做由以下三个部分组成：\n前端（Front-end）：将 C/C++/Rust 等源代码转换为 LLVM IR（一种特殊的中间表示）。Clang 是 C 语言家族的编译器前端 中端（Middle-end）：根据相关的设置对 LLVM IR 进行优化 后端（Back-end）：将 LLVM IR 转换为特定平台的机器码： x86/ARM/PowerPC 等等 对于一个简单的编程语言，通过调用 LLVM，1000 行就能实现一个编译器。你只需要负责实现语言前端就行了，后端交给 LLVM 即可。甚至前端也可以考虑使用 lex/yacc 这样的现成的语法解析器。\n具体到编译器前端的工作，例如这里提到的 Clang，可以分为以下三个阶段：\n词法分析：将源文件转换为 Token Stream，例如 []() { return 13 + 37; } 被转换为 [, ], (, ), {, return, 13, +, 37, ;, } 语法分析：产生 Abstract Syntax Tree（抽象语法树），就是将上一步中的 Token Stream 转换为类似于下面这样的递归的树状结构 lambda-expr └── body └── return-expr └── plus-expr ├── number 13 └── number 37 代码生成：根据给定的 AST 生成 LLVM IR 因此，常量表达式的计算（以及相关的事情，如模板实例化）严格发生在 C++ 编译器的前端，而 LLVM 不涉及此类工作。这种处理常量表达式（从 C++98 的简单表达式到 C++23 的复杂表达式）的工具被称为常量求值器 (constant evaluator)。\n多年来，对常量表达式的限制一直在不断放宽，而 Clang 的常量求值器相应地变得越来越复杂，直到管理 memory model（内存模型）。有一份旧的 文档，描述 C++98/03 的常量求值。由于当时的常量表达式非常简单，它们是通过分析语法树进行 constant folding（常量折叠）来进行的。由于在语法树中，所有的算术表达式都已经被解析为子树的形式，因此计算常量就是简单地遍历子树。\n常量计算器的源代码位于 lib/AST/ExprConstant.C++，在撰写本文时已经扩展到将近 17000 行。随着时间的推移，它学会了解释许多内容，例如循环（EvaluateLoopBody），所有这些都是在语法树上进行的。\n常量表达式与运行时代码有一个重要的区别：它们必须不引发 undefined behavior（未定义行为）。如果常量计算器遇到未定义行为，编译将失败。\nerror: constexpr variable \u0026#39;foo\u0026#39; must be initialized by a constant expression 2 | constexpr int foo = 13 + 2147483647; | ^ ~~~~~~~~~~~~~~~ note: value 2147483660 is outside the range of representable values of type \u0026#39;int\u0026#39; 2 | constexpr int foo = 13 + 2147483647; 因此在有些时候可以用它们来检测程序中的潜在错误。\n2003：真的能 macro free 吗？ 标准的改变是通过 proposals（提案）进行的\n在哪里可以找到提案？它们是由什么组成的？\n所有的有关 C++ 标准的提案都可以在 open-std.org 上找到。它们中的大多数都有详细的描述并且易于阅读。通常由如下部分组成： - 当前遇到的问题 - 标准中相关措辞的链接 - 上述问题的解决方案 - 建议对标准措辞进行的修改 - 相关提案的链接（提案可能有多个版本或者需要和其他提案进行对比） - 在高级提案中，往往还会附带上实验性实现的链接\n可以通过这些提案来了解 C++ 的每个部分是如何演变的。并非存档中的所有提案最终都被接受，但是它们都对 C++ 的发展有着重要的影响。\n通过提交新提案，任何人都可以参与到 C++ 的演变过程中来。\n2003 年的提案 N1521 Generalized Constant Expressions 指出一个问题。如果一个表达式中的某个部分含有函数调用，那么整个表达式就不能是常量表达式，即使这个函数最终能够被常量折叠。这迫使人们在处理复杂常量表达式的时候使用宏，甚至一定程度上导致了宏的滥用\ninline int square(int x) { return x * x; } #define SQUARE(x) ((x) * (x)) square(9) std::numeric_limits\u0026lt;int\u0026gt;::max() // 理论上可用于常量表达式, 但是实际上不能 SQUARE(9) INT_MAX // 被迫使用宏代替 因此，建议引入常值 (constant-valued) 函数的概念，允许在常量表达式中使用这些函数。如果希望一个函数是常值函数，那么它必须满足\ninline ，non-recursive，并且返回类型不是 void 仅由单一的 return expr 语句组成，并且在把 expr 里面的函数参数替换为常量表达式之后，得到的仍然是一个常量表达式 如果这样的函数被调用，并且参数是常量表达式，那么函数调用表达式也是常量表达式\nint square(int x) { return x * x; } // constant-valued long long_max(int x) { return 2147483647; } // constant-valued int abs(int x) { return x \u0026lt; 0 ? -x : x; } // constant-valued int next(int x) { return ++x; } // non constant-valued 这样的话，不需要修改任何代码，最开始的例子中的 v3 和 v4 也可以被用作常量表达式了，因为 foo 被认为是常值函数。\n该提案认为，可以考虑进一步支持下面这种情况\nstruct cayley { const int value; cayley(int a, int b) : value(square(a) + square(b)) {} operator int() const { return value; } }; std::bitset\u0026lt;cayley(98, -23)\u0026gt; s; // same as bitset\u0026lt;10133\u0026gt; 因为成员 value 是 totally constant 的，在构造函数中通过两次调用常值函数进行初始化。换句话说，根据该提案的一般逻辑，此代码可以大致转换为以下形式（将变量和函数移到结构体之外）：\n// 模拟 cayley::cayley(98, -23)的构造函数调用和 operator int() const int cayley_98_m23_value = square(98) + square(-23); int cayley_98_m23_operator_int() { return cayley_98_m23_value; } // 创建 bitset std::bitset\u0026lt;cayley_98_m23_operator_int()\u0026gt; s; // same as bitset\u0026lt;10133\u0026gt; 但是和变量一样，程序员无法确定一个函数是否为常值函数，只有编译器知道。\n提案通常不会深入到编译器实现它们的细节。上述提案表示，实现它不应该有任何困难，只需要稍微改变大多数编译器中存在的常量折叠即可。然而，提案与编译器实现密切相关。如果提案无法在合理时间内实现，很可能不会被采纳。从后来的视角来看，许多大的提案最后被分成了多个小的提案逐步实现\n2006-2007：当一切浮出水面 幸运的是，三年后，这个提案的后续修订版 N2235 认识到了过多的隐式特性是不好的，程序员应该有办法确保一个变量可以被用作常量，如果不满足相应的条件应该导致编译错误。\nstruct S { const static int size; }; const int limit = 2 * S::size; // dynamic initialization const int S::size = 256; // const initialization const int z = std::numeric_limits\u0026lt;int\u0026gt;::max(); // dynamic initialization 根据程序员的设想，limit 应该被常量初始化，但事实并非如此，因为 S::size 被定义在 limit 之后，定义的太晚了。可以通过 C++20 加入的 constinit 来验证这一点，constinit 保证一个变量进行常量初始化，如果不能进行常量初始化，则会编译错误。\n在新的提案中，常值函数被重命名为 constexpr function，对它们的要求保持不变。但现在，为了能够在常量表达式中使用它们，必须使用 constexpr 关键字进行声明。此外，如果函数体不符合相关的要求，将会编译失败。同时建议将一些标准库的函数（如 std::numeric_limits 中的函数）标记为 constexpr，因为它们符合相关的要求。变量或类成员也可以声明为 constexpr，这样的话，如果变量不是通过常量表达式进行初始化，将会编译失败。\n用户自定义 class 的 constexpr 构造函数也合法化了。该构造函数必须具有空函数体，并用常量表达式初始化成员。隐式生成的构造函数将尽可能的被标记为 constexpr。对于 constexpr 的对象，析构函数必须是平凡的，因为非平凡的析构函数通常会在正在执行的程序上下文中做一些改变，而在 constexpr 计算中不存在这样的上下文。\n以下是包含 constexpr 的示例类：\nstruct complex { constexpr complex(double r, double i) : re(r), im(i) {} constexpr double real() { return re; } constexpr double imag() { return im; } private: double re; double im; }; constexpr complex I(0, 1); // OK 在提案中，像 I 这样的对象被称为用户自定义字面量。\u0026ldquo;字面量\u0026rdquo; 是 C++ 中的基本实体。就像 \u0026ldquo;简单\u0026rdquo; 字面量（数字、字符等）立即被嵌入到汇编指令中，字符串字面量存储在类似 .rodata 的段中那样，用户定义的字面量也在其中占有一席之地。\n现在 constexpr 变量不仅可以是数字和枚举，还可以是 literal type，在此提案中引入了（尚不支持引用类型）。literal type 是可以传递给 constexpr 函数的类型，这些类型足够简单，以至于编译器可以在常量计算中支持它们。\nconstexpr 关键字最后成为了一个 specifier（说明符），类似于 *override *这样仅用作标记。在讨论后，决定不创建新的 储存期类型 和新的类型限定符，并且也决定不允许将其用于函数参数，以免使得函数的 overload resolution 规则变得过于复杂。\n2007：试着让标准库更加 constexpr？ 在这一年，提案 N2349 Constant Expressions in the Standard Library 被提出，其中标记了一些函数和常量为 constexpr，还有一些容器的函数，例如：\ntemplate \u0026lt;size_t N\u0026gt; class bitset { // ... constexpr bitset(); constexpr bitset(unsigned long); // ... constexpr size_t size(); // ... constexpr bool operator[] (size_t) const; }; 构造函数通过 constant-expression 初始化类的成员，其他函数内部含有单个 return 语句，符合当前的规定。\n所有关于 constexpr 的提案中，超过一半是建议将标准库中的某些函数标记为 constexpr。就内容而言，其实并不是十分有趣，因为它们并没有导致核心语言规则的改变。\n2008 年：停停……机问题？我才不管！ constexpr unsigned int factorial(unsigned int n) { return n == 0 ? 1 : n * factorial(n - 1); } 最初，提案提出者希望允许在 constexpr 函数中进行递归调用，但出于谨慎起见，这一做法被禁止了。然而，在审查过程中，由于措辞的变化，意外地允许了这种做法。CWG 认为递归具有足够的使用情景，因此应该允许它们。如果允许函数之间相互递归调用，还需要允许 constexpr 函数的 forward declaration（向前声明）。在 constexpr 函数中调用未定义的 constexpr 函数时，应该在需要常量求值的上下文中进行诊断。这一点在 N2826 被澄清\n既然有递归，那就可能出现无穷递归。一个函数究竟会不会无穷递归？在一些简单的情况下，静态分析工具可以分析无穷递归是否会发生。而在一般情况下，这其实是个 停机问题，无法解决。\n一般来说，编译器会设置一个默认递归层数。如果递归层数超过这个默认的层数，则会编译错误\nconstexpr int foo() { return f() + 1; } constexpr int x = foo(); 上述代码编译错误\nerror: \u0026#39;constexpr\u0026#39; evaluation depth exceeds maximum of 512 (use \u0026#39;-fconstexpr-depth=\u0026#39; to increase the maximum) 24 | constexpr int x = foo(); 在 Clang 中默认的层数是 512，可以通过 -fconstexpr-depth 来修改，其实模板实例化也会有类似的层数限制。从效果上而言，这个限制可以看成类似运行时函数调用的栈大小，超过这个大小就会「爆栈」了，其实也是挺合理的。\n2010：引用还是指针？ 当时，许多函数都无法被标记为 constexpr，因为它们的参数中含有引用。\ntemplate \u0026lt;class T\u0026gt; constexpr const T\u0026amp; max(const T\u0026amp; a, const T\u0026amp; b); // error constexpr pair(); // ok pair(const T1\u0026amp; x, const T2\u0026amp; y); // error 提案 N3039 Constexpr functions with const reference parameters 希望允许函数参数和返回值出现常量引用。\n事实上，这是个非常巨大的改变。在此之前，常量求值中只有值，没有引用（指针）。只需要简单的对值进行运算就行了，引用的引入让常量求值器不得不建立一个内存模型。如果要支持 const T\u0026amp;，编译器需要在编译期创建一个临时对象，然后将引用绑定到它上面。任何对该对象不合法的访问都应该导致编译错误。\ntemplate \u0026lt;typename T\u0026gt; constexpr T self(const T\u0026amp; a) { return *(\u0026amp;a); } template \u0026lt;typename T\u0026gt; constexpr const T* self_ptr(const T\u0026amp; a) { return \u0026amp;a; } template \u0026lt;typename T\u0026gt; constexpr const T\u0026amp; self_ref(const T\u0026amp; a) { return *(\u0026amp;a); } template \u0026lt;typename T\u0026gt; constexpr const T\u0026amp; near_ref(const T\u0026amp; a) { return *(\u0026amp;a + 1); } constexpr auto test1 = self(123); // OK constexpr auto test2 = self_ptr(123); // 失败，指向临时对象的指针不是常量表达式 constexpr auto test3 = self_ref(123); // OK constexpr auto tets4 = near_ref(123); // 失败，指针越界访问 2011：为什么不能有声明？ 前文提到过，constexpr 函数只能由单个 return 语句构成。这就意味着，里面甚至不允许任何不影响求值的声明。但是至少有三种声明有助于编写此类函数：静态断言，类型别名和常量表达式初始化的局部变量\nconstexpr int f(int x) { constexpr int magic = 42; return x + magic; // should be ok } 提案 N3268 static_assert and list-initialization in constexpr functions 希望在 constexpr 函数中支持这些静态声明。\n2012：我需要分支！ 有许多简单的函数，希望能够在编译时计算，例如计算 a 的 n 次方：\nint pow(int a, int n) { if(n \u0026lt; 0) throw std::range_error(\u0026#34;negative exponent for integer power\u0026#34;); if(n == 0) return 1; int sqrt = pow(a, n / 2); int result = sqrt * sqrt; if(n % 2) return result * a; return result; } 然而，在当时（C++11），为了它能够变成 constexpr 的，程序员需要按照纯函数式风格（没有局部变量和循环）写一份全新的代码\nconstexpr int pow_helper(int a, int n, int sqrt) { return sqrt * sqrt * ((n % 2) ? a : 1); } constexpr int pow(int a, int n) { return (n \u0026lt; 0) ? throw std::range_error(\u0026#34;negative exponent for integer power\u0026#34;) : (n == 0) ? 1 : pow_helper(a, n, pow(a, n / 2)); } 提案 N3444 Relaxing syntactic constraints on constexpr functions 希望进一步放宽 constexpr 函数的限制，以便能够编写任意的代码\n允许声明具有 literal type 类型的局部变量，如果它们是通过构造函数进行初始化的，则该构造函数也必须被标记为 constexpr。这样，常量求值器可以缓存这些变量，避免重复求值相同的表达式，提高常量求值器的执行效率，但是不允许修改这些变量 允许局部类型声明 允许使用 if 和多个 return 语句，并且要求每个分支至少有一个 return 语句 允许 expression statement（仅由表达式构成的语句） 允许静态变量的地址或引用作为常量表达式 constexpr mutex\u0026amp; get_mutex(bool which) { static mutex m1, m2; if(which) return m1; else return m2; } constexpr mutex\u0026amp; m = get_mutex(true); // OK 但是，不允许 for/while 循环，goto，switch，try，这些可能产生复杂控制流，甚至产生无穷循环的语句。\n2013：小孩子才做选择，循环我也要！ 然而，CWG 认为在 constexpr 函数中支持循环（至少支持 for）是必须的。2013 年提案 Relaxing constraints on constexpr functions 发布了修订版本。\n实现 constexpr for 考虑了四种选项。\n添加全新的循环语法，新语法与 constexpr 所需的函数式编程风格良好交互。虽然解决了缺乏循环的问题，但并未消除程序员对现有语言的不满（为了支持 constexpr，需要将原有的代码重新改写） 仅支持传统 C 语言风格的 for 循环，为此，至少需要支持 constexpr 函数中对变量进行更改 仅支持 range-based for loop，这样的循环不能与用户定义的迭代器类型一起使用，除非进一步放宽语言规则 允许在 constexpr 函数中使用 C++ 的一致和广泛的子集，可能包括所有 C++ 最后选择的是最后一个选项，这极大的影响了 constexpr 在 C++ 中的后续发展。\n为了支持这个选项，我们不得不在 constexpr 函数中引入变量的可变性，即支持修改变量的值。根据该提案，现在可以更改在常量求值过程中创建的对象，直到求值过程或对象的 lifetime 结束。这些求值过程将在类似虚拟机的沙箱中进行，不会影响外部的代码。因此理论上，输出相同的 constexpr 参数将会输出相同的结果。\nconstexpr int f(int a) { int n = a; ++n; // ++n 不是一个常量表达式 return n * a; } int k = f(4); // OK，这是一个常量表达式 // f 中的 n 可以被修改，因为其生存期 // 在表达式求值期间开始 constexpr int k2 = ++k; // 错误，不是一个常量表达式，不能修改 k // 因为其生存期没有在这个表达式内开始 struct X { constexpr X() : n(5) { n *= 2; // 不是一个常量表达式 } int n; }; constexpr int g() { X x; // x 的初始化是一个常量表达式 return x.n; } constexpr int k3 = g(); // OK，这是一个常量表达式 // x.n 可以被修改，因为 // x 的生存期在 g() 的求值期间开始 另外，我想指出现在这样的代码也能编译通过：\nconstexpr void add(X\u0026amp; x) { x.n++; } constexpr int g() { X x; add(x); return x.n; } 常量求值中，局部的副作用也是允许的！\n2013：constexpr 不是 const 的子集！ 目前，类的 constexpr 函数会自动标记为 const\n在提案 constexpr member functions and implicit const 中指出：如果一个成员函数是 constexpr 的，它不一定要是 const 的。随着 constexpr 计算中的可变性变得越来越重要，这一点变得更加突出。但即使在此之前，它也妨碍了在 constexpr 和非 constexpr 代码中使用相同的函数：\nstruct B { A a; constexpr B() : a() {} constexpr const A\u0026amp; getA() const /*implicit*/ { return a; } A\u0026amp; getA() { return a; } // 代码重复 }; 有趣的是，提案提供了三个选项，其中选择了第二个：\n维持现状 -\u0026gt; 导致代码重复 被 constexpr 标记的函数不是隐式 const 的 -\u0026gt; 破坏 ABI，成员函数的 const 签名是函数类型的一部分 使用 mutable 进行标记 constexpr A \u0026amp;getA() mutable { return a; }; -\u0026gt; 更加不协调了 最终，方案 2 被接受了，现在如果一个成员函数被 constexpr 标记，不代表它是隐式 const 的成员函数了。\n下篇在这里：C++ 中 constexpr 的发展史（下）。\n","permalink":"https://www.ykiko.me/zh-cn/articles/682031684/","summary":"\u003cp\u003e几个月前，我写了一篇介绍 C++ 模板的文章：\u003ca href=\"https://www.ykiko.me/zh-cn/articles/655902377\"\u003e雾里看花：真正意义上的理解 C++ 模板\u003c/a\u003e。\u003c/p\u003e\n\u003cp\u003e理清了现代 C++ 中模板的地位。其中用 constexpr function 替代模板进行编译期计算可以说是现代 C++ 最重要的改进之一了。 constexpr 本身其实并不难以理解，非常直观。但是由于几乎每个 C++ 版本都在改进它，所以不同的 C++ 版本可以使用的内容差别很大，有时候可能给人一种 \u003ccode\u003einconsistency\u003c/code\u003e 的感觉。\u003c/p\u003e","title":"The History of constexpr in C++! (Part One)"},{"content":"no hard code 定义一个 enum\nenum Color { RED, GREEN, BLUE }; 尝试打印\nColor color = RED; std::cout \u0026lt;\u0026lt; color \u0026lt;\u0026lt; std::endl; // output =\u0026gt; 0 如果需要枚举作为日志输出，我们不希望在查看日志的时候，还要人工去根据枚举值去查找对应的字符串，麻烦并且不直观。我们希望直接输出枚举值对应的字符串，比如 RED，GREEN，BLUE。\n手动编写 switch 完成枚举转字符串\nstd::string enum_to_string(Color color) { switch(color) { case Color::RED: return \u0026#34;RED\u0026#34;; case Color::GREEN: return \u0026#34;GREEN\u0026#34;; case Color::BLUE: return \u0026#34;BLUE\u0026#34;; } return \u0026#34;Unknown\u0026#34;; } 但是当枚举数量很多的时候，手写并不方便，非常繁琐。具体表现为，如果我们想增加若干枚举定义，那字符串映射表相应的内容也需要修改，当数量达到上百个的时候，很可能会有疏漏。或者接手一个别人的项目，发现他有一大堆枚举，内容太多，手写非常耗时间。\n需要寻找解决办法，能自动的进行相关的修改。在别的语言中，如 Java，C#，Python，可以轻松的通过反射实现这个功能。但是 C++ 目前并没有反射，故此路不通。目前这个问题主要有三种解决方案。\ntemplate 这一小节介绍的内容已经有人提前封装好了，可以直接使用 magic enum 这个库。下面主要是对这个库的原理进行解析，为了方便展示，将用 C++20 实现，实际上 C++17 就可以。\n在三大主流编译器中，有一些特殊宏变量。GCC 和 Clang 中的 __PRETTY_FUNCTION__，MSVC 中的 __FUNCSIG__。这几个宏变量会在编译期间被替换成函数的签名，如果该函数是模板函数则会将模板实例化的信息也输出（也可以使用 C++20 加入标准的 source_location，它具有和这些宏类似的效果）\ntemplate \u0026lt;typename T\u0026gt; void print_fn() { #if __GNUC__ || __clang__ std::cout \u0026lt;\u0026lt; __PRETTY_FUNCTION__ \u0026lt;\u0026lt; std::endl; #elif _MSC_VER std::cout \u0026lt;\u0026lt; __FUNCSIG__ \u0026lt;\u0026lt; std::endl; #endif } print_fn\u0026lt;int\u0026gt;(); // gcc and clang =\u0026gt; void print_fn() [with T = int] // msvc =\u0026gt; void __cdecl print_fn\u0026lt;int\u0026gt;(void) 特别的，当模板参数是枚举常量的时候，会输出枚举常量的名称\ntemplate \u0026lt;auto T\u0026gt; void print_fn() { #if __GNUC__ || __clang__ std::cout \u0026lt;\u0026lt; __PRETTY_FUNCTION__ \u0026lt;\u0026lt; std::endl; #elif _MSC_VER std::cout \u0026lt;\u0026lt; __FUNCSIG__ \u0026lt;\u0026lt; std::endl; #endif } enum Color { RED, GREEN, BLUE }; print_fn\u0026lt;RED\u0026gt;(); // gcc and clang =\u0026gt; void print_fn() [with auto T = RED] // msvc =\u0026gt; void __cdecl print_fn\u0026lt;RED\u0026gt;(void) 可以发现，在特定的位置出现了枚举名。通过简单的字符串裁剪，便能得到我们想要的内容了\ntemplate \u0026lt;auto value\u0026gt; constexpr auto enum_name() { std::string_view name; #if __GNUC__ || __clang__ name = __PRETTY_FUNCTION__; std::size_t start = name.find(\u0026#39;=\u0026#39;) + 2; std::size_t end = name.size() - 1; name = std::string_view{name.data() + start, end - start}; start = name.rfind(\u0026#34;::\u0026#34;); #elif _MSC_VER name = __FUNCSIG__; std::size_t start = name.find(\u0026#39;\u0026lt;\u0026#39;) + 1; std::size_t end = name.rfind(\u0026#34;\u0026gt;(\u0026#34;); name = std::string_view{name.data() + start, end - start}; start = name.rfind(\u0026#34;::\u0026#34;); #endif return start == std::string_view::npos ? name : std::string_view{name.data() + start + 2, name.size() - start - 2}; } 进行测试\nenum Color { RED, GREEN, BLUE }; int main() { std::cout \u0026lt;\u0026lt; enum_name\u0026lt;RED\u0026gt;() \u0026lt;\u0026lt; std::endl; // output =\u0026gt; RED } 成功满足我们的需求。但是事情并没有结束，这种形式要求枚举是模板参数，那就只支持编译期常量。但是其实绝大部分时候，我们用的枚举都是运行期变量，怎么办呢？静态转动态，只要打个表就行了，考虑通过模板元编程生成一个 array，其中每个元素就是 index 对应枚举的字符串表示。一个问题是，这个数组应该多大，这就需要我们来获取枚举项的数量了。一种比较直接的办法是，直接在枚举中定义一对用来标记的首尾项，这样直接相减就能获取到枚举的最大数量了。但是很多时候，我们并不能修改枚举定义，还好这里有一个小 trick 能解决这个问题\nconstexpr Color color = static_cast\u0026lt;Color\u0026gt;(-1); std::cout \u0026lt;\u0026lt; enum_name\u0026lt;color\u0026gt;() \u0026lt;\u0026lt; std::endl; // output =\u0026gt; (Color)2 可以发现，如果这个整数没有对应的枚举项，那么最后就不会输出对应的枚举名，而是带有括号的强制转换表达式。这样只需要判断下得到的字符串中有没有 ) 就知道对应的枚举项是否存在了。递归判断就可以找出最大的枚举值了（这样查找适用范围有限，如分散枚举值，可能相对困难一点）\ntemplate \u0026lt;typename T, std::size_t N = 0\u0026gt; constexpr auto enum_max() { constexpr auto value = static_cast\u0026lt;T\u0026gt;(N); if constexpr(enum_name\u0026lt;value\u0026gt;().find(\u0026#34;)\u0026#34;) == std::string_view::npos) return enum_max\u0026lt;T, N + 1\u0026gt;(); else return N; } 然后通过 make_index_sequence 生成一个对应的长度数组就行了\ntemplate \u0026lt;typename T\u0026gt; requires std::is_enum_v\u0026lt;T\u0026gt; constexpr auto enum_name(T value) { constexpr auto num = enum_max\u0026lt;T\u0026gt;(); constexpr auto names = []\u0026lt;std::size_t... Is\u0026gt;(std::index_sequence\u0026lt;Is...\u0026gt;) { return std::array\u0026lt;std::string_view, num\u0026gt;{enum_name\u0026lt;static_cast\u0026lt;T\u0026gt;(Is)\u0026gt;()...}; }(std::make_index_sequence\u0026lt;num\u0026gt;{}); return names[static_cast\u0026lt;std::size_t\u0026gt;(value)]; } 测试一下\nenum Color { RED, GREEN, BLUE }; int main() { Color color = RED; std::cout \u0026lt;\u0026lt; enum_name(color) \u0026lt;\u0026lt; std::endl; // output =\u0026gt; RED } 更进一步可以考虑支持 bitwidth enum，也就是 RED | BLUE 这种形式的枚举，这里就不继续展开了。\n这种方法的缺点很明显，通过模板实例化来打表，其实会很大的拖慢编译速度。如果 enum 中的数量较多，在一些对常量求值效率较低的编译器上，如 MSVC，可能会增加几十秒甚至更长的编译时间。所以一般只适用于小型枚举。优点是轻量级，开箱即用，其他的什么也不用做。\ncode generation 既然手写字符串转枚举很麻烦，那么写个脚本生成代码不就行了？的确如此，我们可以使用 libclang 的 Python bind 轻松的完成这项工作。具体如何使用这个工具，可以参考 使用 Clang 工具自由的支配 C++ 代码吧，下面只展示实现效果的代码\nimport clang.cindex as CX def generate_enum_to_string(enum: CX.Cursor): branchs = \u0026#34;\u0026#34; for child in enum.get_children(): branchs += f\u0026#39;case {child.enum_value}: return \u0026#34;{child.spelling}\u0026#34;;\\n\u0026#39; code = f\u0026#34;\u0026#34;\u0026#34; std::string_view {enum.spelling}_to_string({enum.spelling} color) {{ switch(color) {{ {branchs} }} }}\u0026#34;\u0026#34;\u0026#34; return code def traverse(node: CX.Cursor): if node.kind == CX.CursorKind.ENUM_DECL: print(generate_enum_to_string(node)) return for child in node.get_children(): traverse(child) index = CX.Index.create() tu = index.parse(\u0026#39;main.cpp\u0026#39;) traverse(tu.cursor) 测试代码\n// main.cpp enum Color { RED, GREEN, BLUE }; 这是最后生成的代码，可以直接生成 .cpp 文件，放在固定目录下面，然后构建之前运行一下这个脚本就行了\nstd::string_view enum_to_string(Color color) { switch(color) { case 0: return \u0026#34;RED\u0026#34;; case 1: return \u0026#34;BLUE\u0026#34;; case 2: return \u0026#34;GREEN\u0026#34;; } } 优点，非侵入式，可以用于大数量的枚举。缺点，有外部依赖，需要将代码生成加入到编译流程里面。可能会使编译流程变得很复杂。\nxmacro 上面的两种方式都是非侵入式的。也就是说，可能你拿到了一个别人的库，不能修改它的代码，只好这么做了。如果是完全由自己定义枚举呢？其实可以在定义阶段就特殊处理，以方便后续的使用。比如（代码开头的注释表示当前文件名）：\n// Color.def #ifndef COLOR_ENUM #define COLOR_ENUM(...) #endif COLOR_ENUM(RED) COLOR_ENUM(GREEN) COLOR_ENUM(BLUE) #undef COLOR_ENUM 然后在要使用的地方，通过修改宏定义来生成代码就行了\n// Color.h enum Color { #define COLOR_ENUM(x) x, #include \u0026#34;Color.def\u0026#34; }; std::string_view color_to_string(Color value) { switch(value) { #define COLOR_ENUM(x) \\ case x: return #x; #include \u0026#34;Color.def\u0026#34; } } 这样的话，只要在 def 文件里面进行相关的增加和修改就行了。之后如果要遍历 enum 什么的，也可以直接定义一个宏来生成代码就行了，非常方便。事实上，对于大数量的枚举，有很多开源项目都采取这种方案。例如 Clang 在定义 TokenKind 的时候，就是这么做的，相关的代码请参考 Token.def。由于 Clang 要适配多种语言前端，最后总计的 TokenKind 有几百个之多。如果不这样做，进行 Token 的增加和修改会十分困难。\nconclusion 非侵入式且枚举数量较少，编译速度不是很重要，那就使用模板打表（至少要求 C++17） 非侵入式且枚举数量较多，编译速度很重要，那就使用外部代码生成 侵入式，可以直接使用宏 年年月月盼反射，还是不知道什么时候才能进入标准呢。想要提前了解 C++ 静态反射的小伙伴，可以看 C++26 静态反射提案解析。或者还不知道反射是什么的小伙伴，可以参考这篇文章的内容：写给 C++ 程序员的反射教程。\n","permalink":"https://www.ykiko.me/zh-cn/articles/680412313/","summary":"\u003ch2 id=\"no-hard-code\"\u003eno hard code\u003c/h2\u003e\n\u003cp\u003e定义一个 \u003ccode\u003eenum\u003c/code\u003e\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eenum\u003c/span\u003e \u003cspan class=\"nc\"\u003eColor\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"n\"\u003eRED\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eGREEN\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"n\"\u003eBLUE\u003c/span\u003e \u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e尝试打印\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-cpp\" data-lang=\"cpp\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eColor\u003c/span\u003e \u003cspan class=\"n\"\u003ecolor\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"n\"\u003eRED\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003estd\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003ecout\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u0026lt;\u003c/span\u003e \u003cspan class=\"n\"\u003ecolor\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u0026lt;\u003c/span\u003e \u003cspan class=\"n\"\u003estd\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"n\"\u003eendl\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// output =\u0026gt; 0\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e如果需要枚举作为日志输出，我们不希望在查看日志的时候，还要人工去根据枚举值去查找对应的字符串，麻烦并且不直观。我们希望直接输出枚举值对应的字符串，比如 \u003ccode\u003eRED\u003c/code\u003e，\u003ccode\u003eGREEN\u003c/code\u003e，\u003ccode\u003eBLUE\u003c/code\u003e。\u003c/p\u003e","title":"C++ 中如何优雅进行 enum 到 string 的转换 ？"},{"content":"众所周知，现在 C++ 里面有两种特殊的构造函数，即 copy constructor 和 move constructor\ncopy constructor 早在 C++98 的时候就加入了，用来拷贝一个对象，像 vector 这种拥有资源的类型，拷贝的时候会把它拥有的资源也拷贝一份\nstd::vector\u0026lt;int\u0026gt; v1 = {1, 2, 3}; std::vector\u0026lt;int\u0026gt; v2 = v1; // copy 当然了，拷贝的开销有些时候非常大，而且完全没必要。于是在 C++11 加入了 move constructor，用来把一个对象的资源转移到另一个对象上。这样相对于直接拷贝，开销是小得多的\nstd::vector\u0026lt;int\u0026gt; v1 = {1, 2, 3}; std::vector\u0026lt;int\u0026gt; v2 = std::move(v1); // move 注意 C++ 中的 move 被叫做 non-destructive move。_C++ 标准规定了，被移动过后的对象状态是一种 _valid state，实现需要保证它能够正常调用析构函数。被移动的对象仍然可能被再次使用（具体能否使用取决于实现）。\n结束了？ 有这两个构造函数就足够了吗？当然没有。事实上还有另一种广泛使用的操作，可以把它叫做 relocate 操作。考虑如下场景\n假设你正在实现一个 vector，扩容是必要的，于是你写了一个私有成员函数 grow 用来进行扩容（下面的代码示例暂时忽略异常安全）\nvoid grow(std::size_t new_capacity) { auto new_data = malloc(new_capacity * sizeof(T)); for(std::size_t i = 0; i \u0026lt; m_Size; ++i) { new (new_data + i) T(std::move(m_Data[i])); m_Data[i].~T(); } free(m_Data); m_Data = new_data; m_Capacity = new_capacity; } 上面的代码很简单，先通过 malloc 分配新的内存，然后通过 placement new 在新分配的内存上调用移动构造进行初始化。注意，正如前文提到的： C++ 中的 move 是 non-destructive 的，所以需要在调用完移动构造之后，原对象还需要调用析构函数，来正确的结束生存期。最后释放原来的内存，更新成员变量的值就行了。\n注：构造和析构的步骤也可以采用 C++20 加入的 std::construct_at 和 std::destroy_at，其实就是对 placement new 和 destroy 的封装。\n但是这样的实现并不高效，在 C++ 中有一个 trivially copyable 的概念，可以通过 is_trivially_copyable 这个 triat 来进行判断。满足这个约束的类型，可以直接使用 memcpy 或者 memmove 来进行拷贝得到一个新的对象。考虑下面这个例子：\nstruct Point { int x; int y; }; static_assert(std::is_trivially_copyable_v\u0026lt;Point\u0026gt;); Point points[3] = {{1, 2}, {3, 4}, {5, 6}}; Point new_points[3]; std::memcpy(new_points, points, sizeof(points)); 不仅仅省去了多次函数调用，而且 memcpy 和 memmove 本身就是高度优化的 builtin 函数（可以通过 SIMD 进行向量化）。所以效率相比于直接调用拷贝构造进行复制效率会高很多。\n为了让我们的 vector 更快，我们也可以做一下这种优化，利用 C++17 加入的 if constexpr 来做编译期判断，很轻松的写出下面的代码\nvoid grow(std::size_t new_capacity) { auto new_data = malloc(new_capacity * sizeof(T)); if constexpr(std::is_trivially_copyable_v\u0026lt;T\u0026gt;) { std::memcpy(new_data, m_Data, m_Size * sizeof(T)); } else if constexpr(std::is_move_constructible_v\u0026lt;T\u0026gt;) { for(std::size_t i = 0; i \u0026lt; m_Size; ++i) { std::construct_at(new_data + i, std::move(m_Data[i])); std::destroy_at(m_Data + i); } } else if constexpr(std::is_copy_constructible_v\u0026lt;T\u0026gt;) { for(std::size_t i = 0; i \u0026lt; m_Size; ++i) { std::construct_at(new_data + i, m_Data[i]); std::destroy_at(m_Data + i); } } free(m_Data); m_Data = new_data; m_Capacity = new_capacity; } _注：也可以考虑直接使用 C++17 加入的 uninitialized_move_n 和 destroy_n 避免重新造轮子，这些函数已经进行过类似的优化了。不过由于指针 alisa 的问题，它们可能最多优化成 memmove，而在这个 vector 扩容的场景，可以进一步优化成 memcpy，所以还是自己优化效果更好。 _\n大材小用 这样总感觉怪怪的，我们主要的目的是把旧内存上的对象全部移动到新内存上，但是用的居然是 trivially copyable 这个 trait，似乎约束过强了。完全创建一个新对象和把原来的对象放置到新的位置，感觉差别还挺大的。考虑下面这个例子。似乎直接对 std::string 这样的类型进行 memcpy 也是可以的。由于内存都是我们手动管理，析构函数也是我们手动调用，并不会出现多次调用析构函数的情况\nstd::byte buffer[sizeof(std::string)]; auto\u0026amp; str1 = *std::construct_at((std::string*)buffer, \u0026#34;hello world\u0026#34;); std::byte new_buffer[sizeof(std::string)]; std::memcpy(new_buffer, buffer, sizeof(std::string)); auto\u0026amp; str2 = *(std::string*)new_buffer; str2.~basic_string(); 仔细思考一下数据的流向和析构函数的调用，发现没有任何不妥。似乎我们应该寻找一种叫做 trivially moveable 的概念，用来放宽松条件，从而使更多的类型得到优化。很可惜，目前 C++ 标准中并没有这样的概念。为了和 C++ 已经存在的 move 操作区分开来，我们把这种操作叫做 relocate，即把原本的对象放置在一个全新的位置。\n事实上有很多著名的开源组件也都通过模板特化来实现了类似的功能，例如\nBSL 的 bslmf::IsBitwiseMoveable\u0026lt;T\u0026gt; Folly 的 folly::IsRelocatable\u0026lt;T\u0026gt; QT 的 QTypeInfo\u0026lt;T\u0026gt;::isRelocatable 通过对特定的类型进行标记，使得它们可以拥有这种优化。但是，上面的优化只是在我们逻辑上认为相等，严格来说目前这样写在 C++ 中算是 undefined behavior。那怎么办？只能想办法通过新提案，修改标准措辞，来支持上面的优化。\n现状 首先这个问题早就被发现了，例如知乎上很久之前就有相关的讨论：\n比起 malloc new / free old，realloc 在性能上有多少的优势? C++ vector 的 push_back 扩容机制为什么不考虑在尾元素后面的空间申请内存? 类似的问题还有挺多的。realloc 会尝试在原地扩容，如果失败。就会尝试分配一块新的内存，然后用 memcpy 把原来的数据拷贝到新的内存上。所以在目前的 C++ 标准中，如果你想要直接使用 realloc 进行扩容的话，必须要保证对象是 trivially copyable 的。当然，前面已经说了，这个条件是比较苛刻的，需要引入新的概念来放宽条件。\n相关的提案最早在 2015 年就被提出了，在 2023 年仍然活跃的提案主要有下面四个（目标都是 C++26）：\nstd::is_trivially_relocatable Trivial Relocatability For C++26 Relocating prvalues Nontrivial Relocation via a New owning reference Type 大概可以分为两派，保守派和激进派\n保守派 保守派的解决方案是添加 relocatable 和 trivially-relocatable 的概念，以及用来判断的相关 trait。\n如果一个类型是 move-constructible 且 destructible 的，那么它就是 relocatable 的\n如果一个类型满足下列条件之一，那么它就是 trivially-relocatable 的\n是一个 trivially-copyable 的类型 是一个 trivially-relocatable 类型的数组 是一个用具有值为 true 的 trivially_relocatable 属性声明的类类型 是一个类类型，满足以下条件： 没有用户提供的移动构造函数或移动赋值运算符 没有用户提供的复制构造函数或复制赋值运算符 没有用户提供的析构函数 没有虚拟成员函数 没有虚基类 每个成员都是引用或者 trivially-relocatable 类型，并且所有基类都是 trivially-relocatable 类型 可以通过新的 attribute ——trivially_relocatable 来显式标记一个类型为 trivially-relocatable，它可以用常量表达式作为参数，来支持泛型类型\ntemplate \u0026lt;typename T\u0026gt; struct [[trivially_relocatable(std::std::is_trivially_relocatable_v\u0026lt;T\u0026gt;)]] X { T t; }; 还增加了一些新的操作：\ntemplate \u0026lt;class T\u0026gt; T* relocate_at(T* source, T* dest); template \u0026lt;class T\u0026gt; [[nodiscard]] remove_cv_t\u0026lt;T\u0026gt; relocate(T* source); // ... template \u0026lt;class InputIterator, class Size, class NoThrowForwardIterator\u0026gt; auto uninitialized_relocate_n(InputIterator first, Size n, NoThrowForwardIterator result); 这些函数都是由编译器实现的，效果上等同于 move + destroy 原对象。并且允许编译器在满足 as-if 规则的前提下，把对 trivially_relocatable 的类型的操作优化成 memcpy 或者 memmove。对于那些不能优化的结构，比如含有自引用的结构，就正常调用移动构造 + 析构函数就行了。这样在实现 vector 的时候，直接使用这些标准库提供的函数就可以享受优化了。\n该提案之所以被称作保守派，最大的原因就是它既不影响原来的 API，也不影响原来的 ABI，具有较强的兼容性，引入进来十分方便。\n激进派 更为激进的就是今天的主角了，它主张引入 relocate constructor，并且引入了新的关键字 reloc\nreloc 是一个一元运算符，可以用于函数非静态局部变量，reloc 用于执行如下操作\n如果变量是引用类型，则进行完美转发 如果不是则把源对象变成纯右值并返回 并且被 reloc 过后的对象，如果再次使用被认为是编译错误（实际判定的规则会更加详细，详见提案里面的相关小节）\n然后引入了一个新的构造函数，即 relocate constructor（重定位构造函数），具有如下形式 T(T)，函数参数是 T 类型的纯右值。选择这个作为函数签名是为了完善 C++ value category 体系。目前（C++17）及以后，C++ 的拷贝构造函数从 lvalue 创建对象，移动构造函数从 xvalue 创建对象，而重定位构造函数则是从 prvalue 创建对象。这样就完整的覆盖了所有的 value category，对于重载决议来说是十分友好的，语义上也十分和谐融洽。\nstruct X { std::string s; X(X x) : s(std::move(x.s)) {} } 另外一个好处是，目前这种 T(T) 声明的构造函数是不允许的，所以不会和现有的代码冲突。有一点需要注意，相信之前大家可能听人这样解释过，为什么拷贝构造函数的参数必须是引用？因为如果不是引用的话，函数传参也需要拷贝，就会导致无限递归。\n事实上这种解释已经过时了，由于 C++17 引入的强制性的 copy elision。即使一个类型没有拷贝构造函数和移动构造函数，它也可以直接从纯右值构造，并且没有任何拷贝/移动构造函数的调用\nstruct X { X() = default; X(const X\u0026amp;) = delete; X(X\u0026amp;\u0026amp;) = delete; }; X f() { return X{}; }; X x = f(); 上述的代码在开启 C++17 之后各大编译器都能编译通过。所以这里 T(T) 的这种构造函数的形式并不会导致无限递归。该提案也引入了重定位赋值函数，具有如下形式 T\u0026amp; operator=(T)，函数参数是 T 类型的纯右值。当然，也还有 trivially-relocatable 的概念，允许满足这个条件的重定位构造函数被优化为 memcpy。但是，这是通过重定位构造函数等规则来进行判断的，用户不能显式通过 attribute 进行标记。我觉得这一点并不好，应该允许用户手动标记一个类型为 trivially-relocatable。tuple 就是由于目前的实现限制，必须要写一个构造函数，从而导致永远不能是 trivially-copyable 的了，pair 居然也不是 trivially-copyable 的，显然这不合理。所以希望该提案以后能支持通过 attribute 来标记一个类型为 trivially-relocatable。\n我个人是比较喜欢这个提案的，有了它以后，我甚至感觉 C++ 的 value category 系统能够和优雅挂钩了。在这之前，我一直觉得 value category 这个系统是混乱邪恶的，是为了兼容以前的旧代码打的烂补丁。但是如果该提案通过以后\n左值 —— 拷贝构造 亡值 —— 移动构造 纯右值 —— 重定位构造 有一种逻辑完全自洽的美感。提案中其他的细节，就比较琐碎了，这里就省略了。感兴趣的读者可以自己阅读。\n为什么过了这么久还没进入标准 关于为什么过了这么多年这个问题仍然没有解决，其实这是一段相当长的历史，是 C++ 的对象模型存在缺陷导致的。直到 C++20 的 隐式生存期提案 被接受之前，在最开始的扩容函数实现中，连把 trivially-copyable 的类型优化为 memcpy 都是 undefined behavior。\n当然，不要听到 undefined behavior 就害怕，觉得心里面有道坎一样。事实上这一直被认为是标准的缺陷，这种优化早已经广泛实践各大代码库之中了，可靠性已经得到验证。只是 C++ 标准一直没有合适的措辞来描述这种情况，完全认为是 UB 肯定是不对的，不加限制的使用也是不对的，所以问题的关键就是如何在这两者之间如何找出一个合适的边界了。最近我会专门写一篇文章来介绍 C++ 对象模型相关的内容，这里就不展开了。\n其他语言 C++ 固然有各种不足，考虑到历史兼容性等因素，导致设计放不开手脚。那新语言呢？它们是如何解决这些问题的？\nRust 首先先看最近比较火热的 Rust。其实，只要结构中不含有自引用的成员，那么使用 memcpy 把旧的对象移动到新的内存上，几乎总是可行的。另外，Rust 并没有什么多继承虚函数（虚表结构复杂）啦，虚继承啦，这种比较奇怪的东西（并且实际用到的地方很少），所以几乎所有的类型都可以直接使用 memcpy 来从旧对象创建一个新对象。刚好 Safe Rust 中的 move 语义还是 destructive move，所以它的 move 的默认实现就是直接 memcpy，是清爽很多。\n但是默认的移动只能移动局部非静态变量，如果一个变量是引用，那么你就没法移动它。不过还好 Safe Rust 提供了一个 std::mem::take 函数用来解决这个问题：\nuse std::mem; let mut v: Vec\u0026lt;i32\u0026gt; = vec![1, 2]; let old_v = mem::take(\u0026amp;mut v); assert_eq!(vec![1, 2], old_v); assert!(v.is_empty()); 效果是，移动 + 原对象置空，比较类似于 C++ 中的 move。还有 std::mem::swap 和 std::mem::replace 用于其他需要从引用处进行移动的场景。\n虽然可能情况不多，但是如果一个类型含有自引用的结构怎么办？事实上，允许用户自定义构造函数是一个比较简单的解决办法，但是 Rust 社区对此似乎比较反感。目前的解决方案是通过 Pin，不过 Rust 社区似乎对这个解决方案也不是很满意，它很难理解且很难使用。未来全新的设计应该和 linear type 有关，相关的讨论详见 Changing the rules of Rust。\nMojo 这个语言前些日子在知乎上也宣传过一波，但是目前还处于完全早期的状态，不过一开始人家就考虑提供四种构造函数\n__init__() __copy__() __move__() __take__() 其中 copy 就类似于 拷贝构造函数，move 类似于重定位构造函数，take 则类似于现在的移动构造函数。更多的细节就无从得知了。\n","permalink":"https://www.ykiko.me/zh-cn/articles/679782886/","summary":"\u003cp\u003e众所周知，现在 C++ 里面有两种特殊的构造函数，即 copy constructor 和 move constructor\u003c/p\u003e\n\u003cp\u003ecopy constructor 早在 C++98 的时候就加入了，用来拷贝一个对象，像 \u003ccode\u003evector\u003c/code\u003e 这种拥有资源的类型，拷贝的时候会把它拥有的资源也拷贝一份\u003c/p\u003e","title":"C++ 中的 relocate 语义"},{"content":"Introduction 在 C++17 中引入了叫做「结构化绑定」的特性也就是 Struct Binding，这一特性类似于别的语言中的模式匹配，可以让我们方便的对结构体的成员进行访问\nstruct Point { int x; int y; }; Point p = {1, 2}; auto [x, y] = p; // x = 1, y = 2 利用它我们能实现一些有趣的功能，包括 *有限的 *对结构体的反射功能，比如实现一个 for_each 函数\nvoid for_each(auto\u0026amp;\u0026amp; object, auto\u0026amp;\u0026amp; func) { using T = std::remove_cvref_t\u0026lt;decltype(object)\u0026gt;; if constexpr(std::is_aggregate_v\u0026lt;T\u0026gt;) { auto\u0026amp;\u0026amp; [x, y] = object; for_each(x, func); for_each(y, func); } else { func(object); } } 这样的话对于任意的含有两个成员的聚合类型，我们都可以对其进行遍历\nstruct Line { Point start; Point end; }; Line line = { {1, 2}, {3, 4}, }; int main() { for_each(line, [](auto\u0026amp;\u0026amp; object) { std::cout \u0026lt;\u0026lt; object \u0026lt;\u0026lt; std::endl; }); return 0; } 但是这样有一个问题那就是，只能递归的支持结构体字段数量为 2 的情况，如果你尝试填入一个字段数量为 3 的结构体，那么编译器就会抛出一个 hard error。即结构化绑定数量错误，它不能被 SFINAE 或者 requires 处理，会直接导致编译中止\nstruct Vec3 { float x; float y; float z; }; template \u0026lt;typename T\u0026gt; constexpr bool two = requires { []() { auto [x, y] = T{1, 2, 3}; }; }; static_assert(two\u0026lt;Vec3\u0026gt;); // !hard error 我们可以通过手动分发的方式来解决这个问题\nif constexpr(N == 1) { auto\u0026amp;\u0026amp; [x] = object; // ... } else if constexpr(N == 2) { auto\u0026amp;\u0026amp; [x, y] = object; // ... } else if constexpr(N == 3) { auto\u0026amp;\u0026amp; [x, y, z] = object; // ... } else { // ... } 你可以自由枚举到你想要支持的数量，这里面的 N 就是结构体字段数量了，你可能需要把它作为模板参数显式传入，或者给每个类型都特化一个模板，里面存上它的字段数量。但是这仍然很麻烦，那么有没有一种方法可以让编译器自动的帮我们计算出结构体的字段数量呢？\nAntony Polukhin 初步解决方案在 boost/pfr 中就已经给出了，其作者 Antony Polukhin 在 CppCon2016 和 CppCon2018 中对此做了详细的介绍，不过作者采用的版本是 C++14/17，其中的代码较为晦涩难懂，在我使用 C++20 进行重写之后可读性提高了不少。\n首先在 C++ 中我们可以写一个 Any 类型，它支持向任意类型进行转换，其实就是把它的 类型转换函数 写成模板函数就行了\nstruct Any { constexpr Any(int) {}; template \u0026lt;typename T\u0026gt; constexpr operator T() const; }; static_assert(std::is_convertible_v\u0026lt;Any, int\u0026gt;); // true static_assert(std::is_convertible_v\u0026lt;Any, std::string\u0026gt;); // true 之后我们可以利用聚合初始化的特性，那就是对于超出聚合初始化最大数量的表达式，requires 语句会返回 false\ntemplate \u0026lt;typename T, std::size_t N\u0026gt; constexpr auto test() { return []\u0026lt;std::size_t... I\u0026gt;(std::index_sequence\u0026lt;I...\u0026gt;) { return requires { T{Any(I)...}; }; }(std::make_index_sequence\u0026lt;N\u0026gt;{}); } static_assert(test\u0026lt;Point, 0\u0026gt;()); // true static_assert(test\u0026lt;Point, 1\u0026gt;()); // true static_assert(test\u0026lt;Point, 2\u0026gt;()); // true static_assert(!test\u0026lt;Point, 3\u0026gt;()); // false 注意到这里 Point 只有两个成员，当我们传入了三个参数给初始化列表的时候，requires 就会返回 false。利用这个特性，我们可以把上面的尝试过程改成递归的，也就是线性查找这个序列直到找到 false 为止。\ntemplate \u0026lt;typename T, int N = 0\u0026gt; constexpr auto member_count() { if constexpr(!test\u0026lt;T, N\u0026gt;()) { return N - 1; } else { return member_count\u0026lt;T, N + 1\u0026gt;(); } } 如果 test\u0026lt;T, N\u0026gt; 为真说明 N 个参数可以成功构造 T，那么我们就递归的尝试 N + 1 个参数，直到 test\u0026lt;T, N\u0026gt; 为假，那么 N - 1 就是 T 的成员数量了。这样我们就可以通过 member_count\u0026lt;T\u0026gt;() 来获取 T 的成员数量了。测试一下效果\nstruct A { std::string a; }; static_assert(member_count\u0026lt;A\u0026gt;() == 1); struct B { std::string a; int b; }; static_assert(member_count\u0026lt;B\u0026gt;() == 2); 很好啊，大获成功！事情到这里就结束了吗？\nJoão Baptista 考虑下面这三个例子\n左值引用 struct A { int\u0026amp; x; }; static_assert(member_count\u0026lt;A\u0026gt;() == 1); /// error 默认构造函数被删除 struct A { A() = delete; }; struct B { A a1; A a2; }; static_assert(member_count\u0026lt;B\u0026gt;() == 2); // error 数组 struct C { int x[2]; }; static_assert(member_count\u0026lt;C\u0026gt;() == 1); // error 遇到这三种情况，原来的方法完全失效了，为什么会这样？\n这一小节的主要内容参考自 João Baptista 的两篇博客\nCounting the number of fields in an aggregate in C++20 Counting the number of fields in an aggregate in C++20 — part 2 他总结了 boost/pfr 中的问题，并提出了解决方案，解决了上述提到的三个问题\nLValue Reference 第一个问题相对比较好理解，主要就是因为 T() 类型产生的转换产生的都是纯右值，左值引用没法绑定到纯右值，如果是右值引用就可以了\nstatic_assert(!std::is_constructible_v\u0026lt;int\u0026amp;, Any\u0026gt;); // false static_assert(std::is_constructible_v\u0026lt;int\u0026amp;\u0026amp;, Any\u0026gt;); // true 怎么办呢？其实有一种很巧妙的写法，可以解决这个问题\nstruct Any { constexpr Any(int) {} template \u0026lt;typename T\u0026gt; constexpr operator T\u0026amp;() const; template \u0026lt;typename T\u0026gt; constexpr operator T\u0026amp;\u0026amp;() const; }; 一个转换成左值引用，一个转换成右值引用。如果它们俩只有一个能匹配，那就会选择那一个能匹配的。如果两个都能匹配，左值引用转换的优先级比右值引用高，会被优先选择，不会有重载决议的问题。\nstatic_assert(std::is_constructible_v\u0026lt;int, Any\u0026gt;); // true static_assert(std::is_constructible_v\u0026lt;int\u0026amp;, Any\u0026gt;); // true static_assert(std::is_constructible_v\u0026lt;int\u0026amp;\u0026amp;, Any\u0026gt;); // true static_assert(std::is_constructible_v\u0026lt;const int\u0026amp;, Any\u0026gt;); // true 很好，这样的话第一个问题，解决！\nDefault Constructor 为什么把默认构造函数删了就不行了呢？还记得我们最开始的那个 Point 类型吗？\nstruct Point { int x; int y; }; 我们尝试的结果是 0,1,2 都可以，3 不行。可是，如果说，{ } 里面的数量多于 Point 的成员数量导致失败我能理解，为啥少于里面的成员数量可以成功呢？其实原因很简单，那就是你没有显式初始化的成员会被值初始化。于是 { } 里面的参数，可以少于实际的字段数量。但是如果字段禁止了默认构造函数，就没法进行值初始化，就会编译错误\nstruct A { A() = delete; }; struct B { A a1; A a2; int x; }; 对于下面这个类型，我们如果用 Any 尝试的话，应该是 0,1 不行,2,3 可以,4,5,... 以及往后的都不行。也就是说至少要让所有不能默认初始化的成员都初始化之后才行。 如果一个类型支持默认初始化，那么搜索它的有效区间是 [0, N] 其中 N 就是它的最大字段数量。如果不支持默认初始化，那其实搜索区间就变成了 [M, N]，M 是保证其不能默认初始化的成员全都初始化的最小数量。\n我们之前的搜索策略是从 0 开始搜索，如果当前这个是 true，那就求下一个，直到 false 停止。显然这种搜索策略不适合现在这种情况了，因为在 [0, M) 之间，也符合之前的搜索策略搜索失败的情况。我们现在要改成，如果当前这个是 true 并且下一个是 false 才停止搜索，这样刚好能搜到这个区间的上界。\ntemplate \u0026lt;typename T, int N = 0\u0026gt; constexpr auto member_count() { if constexpr(test\u0026lt;T, N\u0026gt;() \u0026amp;\u0026amp; !test\u0026lt;T, N + 1\u0026gt;()) { return N; } else { return member_count\u0026lt;T, N + 1\u0026gt;(); } } 测试一下\nstruct A { int\u0026amp; x; }; static_assert(member_count\u0026lt;A\u0026gt;() == 1); struct B { A a1; A a2; }; static_assert(member_count\u0026lt;B\u0026gt;() == 2); OK，第二个问题也解决了，实在是太酷了！\nBuiltin Array 如果在结构体的成员里面有数组，那么计算的时候最终得到的结果就是把数组的每一个成员都当成一个字段来计算，其实就是因为对标准数组的聚合初始化开了后门\nstruct Array { int x[2]; }; Array array{1, 2}; // ok 注意到没有，只有一个字段却可以填两个值。但是对数组开洞就导致了这样的困境，如果结构体里面含有数组就会最终得到错误的计数。那有没有什么办法能解决这个问题？\n注意：下面这部分可能有点难以理解\n考虑下面这个例子\nstruct D { int x; int y[2]; int z[2]; } 举例子，来看一下它初始化的情况：\nD{1, 2, 3, 4, 5}; // ok // 第 0 个位置 D{{1}, 2, 3, 4, 5}; // ok, 0 号位置最多放置 1 个元素 D{{1, 2}, 3, 4, 5}; // error // 第 1 个位置 D{1, {2}, 3, 4, 5}; // error D{1, {2, 3}, 4, 5}; // ok, 1 号位置最多放置 2 个元素 D{1, {2, 3, 4}, 5}; // error // 第 3 个位置 D{1, 2, 3, {4}, 5}; // error D{1, 2, 3, {4, 5}}; // ok, 3 号位置最多放置 2 个元素 没错，我们可以利用嵌套初始化，来解决这个问题！我们先用原本的方法求出最大的可能的结构体字段数量（包含数组展开的，这里就是 5 个），然后再在每个位置尝试把原本的序列塞到这个嵌套初始化里面去，通过不停尝试就能找到这个位置所能放置的元素的最大数量，如果最大数量超过 1 的话，说明这个位置是个数组。这个最大数量就是数组的元素数量，我们在最后的结果中，把多余数量减掉就行了。\n听起来简单，实现起来还是有点复杂的哦。\n先写一个函数用来辅助，通过填不同的 N1,N2,N3 就能对应到上面不同情况了，注意 I2 那里的 Any 那里是嵌套初始化，多了一层括号\ntemplate \u0026lt;typename T, std::size_t N1, std::size_t N2, std::size_t N3\u0026gt; constexpr bool test_three_parts() { return []\u0026lt;std::size_t... I1, std::size_t... I2, std::size_t... I3\u0026gt;(std::index_sequence\u0026lt;I1...\u0026gt;, std::index_sequence\u0026lt;I2...\u0026gt;, std::index_sequence\u0026lt;I3...\u0026gt;) { return requires { T{Any(I1)..., {Any(I2)...}, Any(I3)...}; }; }(std::make_index_sequence\u0026lt;N1\u0026gt;{}, std::make_index_sequence\u0026lt;N2\u0026gt;{}, std::make_index_sequence\u0026lt;N3\u0026gt;{}); } 接下来我们要写一个函数，用来测试在指定位置用二层 { } 放置 N 个元素是不是可行的\ntemplate \u0026lt;typename T, std::size_t position, std::size_t N\u0026gt; constexpr bool try_place_n_in_pos() { // 可能的最大字段数量 constexpr auto Total = member_count\u0026lt;T\u0026gt;(); if constexpr(N == 0) { // 放置 0 个和原本的效果是一样的,肯定可行 return true; } else if constexpr(position + N \u0026lt;= Total) { // 元素数量之和的肯定不能超过总共的 return test_three_parts\u0026lt;T, position, N, Total - position - N\u0026gt;(); } else { return false; } } 由于内容有点多，可能有点难以理解，我们这里先展示一下这个函数的测试结果，方便理解，这样如果你看不懂函数实现也没问题。 还是以之前那个结构体 D 为例子\ntry_place_n_in_pos\u0026lt;D, 0, 1\u0026gt;(); // 这其实就是在测试 D{ {1}, 2, 3, 4, 5 } 这种情况 // 在 0 号位置放置 1个元素 try_place_n_in_pos\u0026lt;D, 1, 2\u0026gt;(); // 这其实就是在测试 D{ 1, {2, 3}, 4, 5 } 这种情况 // 在 1 号位置放置 2 个元素 好了，看懂这个函数是在做什么事情就行了，在某一个位置不停地尝试就行了，然后就能找到这个位置能放置的最大的元素数量了。\ntemplate \u0026lt;typename T, std::size_t pos, std::size_t N = 0\u0026gt; constexpr auto search_max_in_pos() { constexpr auto Total = member_count\u0026lt;T\u0026gt;(); std::size_t result = 0; [\u0026amp;]\u0026lt;std::size_t... Is\u0026gt;(std::index_sequence\u0026lt;Is...\u0026gt;) { ((try_place_n_in_pos\u0026lt;T, pos, Is\u0026gt;() ? result = Is : 0), ...); }(std::make_index_sequence\u0026lt;Total + 1\u0026gt;()); return result; } 这里就是在这个位置搜索能放置的元素最大数量\nstatic_assert(search_max_in_pos\u0026lt;D, 0\u0026gt;() == 1); // 0 号位置最多放置 1 个元素 static_assert(search_max_in_pos\u0026lt;D, 1\u0026gt;() == 2); // 1 号位置最多放置 2 个元素 static_assert(search_max_in_pos\u0026lt;D, 3\u0026gt;() == 2); // 3 号位置最多放置 2 个元素 这与我们最开始的手动测试结果一致，接下来就是遍历所有位置，找出所有的额外的数组元素数量，然后从一开始的那个最大数量里面减掉这些多余的就行了。\ntemplate \u0026lt;typename T, std::size_t N = 0\u0026gt; constexpr auto search_all_extra_index(auto\u0026amp;\u0026amp; array) { constexpr auto total = member_count\u0026lt;T\u0026gt;(); constexpr auto num = search_max_in_pos\u0026lt;T, N\u0026gt;(); constexpr auto value = num \u0026gt; 1 ? num : 1; array[N] = value; if constexpr(N + value \u0026lt; total) { search_all_extra_index\u0026lt;T, N + value\u0026gt;(array); } } 这里就是递归的找，结果储存在数组里面。注意这里 N + value，如果这里找到两个元素了，我们可以直接往后挑两个位置。例如 1 号位置可以放置 2 个元素，那我直接找 3 号位置就行了，不用找 2 号位置了。\n接下来就是把结果都存到数组里面然后，把多余的减掉就行了。\ntemplate \u0026lt;typename T\u0026gt; constexpr auto true_member_count() { constexpr auto Total = member_count\u0026lt;T\u0026gt;(); if constexpr(Total == 0) { return 0; } else { std::array\u0026lt;std::size_t, Total\u0026gt; indices = {1}; search_all_extra_index\u0026lt;T\u0026gt;(indices); std::size_t result = Total; std::size_t index = 0; while(index \u0026lt; Total) { auto n = indices[index]; result -= n - 1; index += n; } return result; } } 测试一下结果\nstruct D { int x; int y[2]; int z[2]; }; static_assert(true_member_count\u0026lt;D\u0026gt;() == 3); struct E { int\u0026amp; x; int y[2][2]; int z[2]; int\u0026amp;\u0026amp; w; }; static_assert(true_member_count\u0026lt;E\u0026gt;() == 4); 拿这里的 E 类型最后生成的数组举一下例子吧，可以都 print 出来看看\nindex: 0 num: 1 // 0 号位置对应 x， 数量是 1 合理 index: 1 num: 4 // 1 号位置对应 y， 数量是 4 合理 index: 5 num: 2 // 5 号位置对应 z， 数量是 2 合理 index: 7 num: 1 // 7 号位置对应 w， 数量是 1 合理 完美谢幕！我很佩服这个作者的想法，真的是太巧妙了，让人叹为观止。然而，在文章的末尾他却说道，\nAs it could be seen, I ran into some inconsistencies between GCC and Clang (and for some reason I haven’t managed to make it work on MSVC at all, but that is another story).\n他说，他遇到了 Clang 和 GCC 的行为不一致的情况，而且完全没法让这种方法在 MSVC 上工作。\n看来事情远远没有结束！\nYKIKO 我花了一些时间读懂了刚才这位作者的文章，说实话他的模板写的我很难读懂，他不喜欢用 if constexpr 来做分支选择，用了很多特化来做选择，给可读性造成了很大影响。所以刚才那些代码并不完全是原作者中的代码，是我用我认为的，更好阅读的形式进行转译的。\n哪些情况会 break 第二位作者的代码呢？\n移动构造被删除 struct X { X(X\u0026amp;\u0026amp;) = delete; }; struct F { X x; }; static_assert(true_member_count\u0026lt;F\u0026gt;() == 1); // error 结构体中含有其他结构体成员 struct Y { int x; int y; }; struct G { Y x; int y; }; static_assert(true_member_count\u0026lt;G\u0026gt;() == 2); // error MSVC 和 GCC 的 BUG Move Constructor 这一切都源于 C++17 加入的一条新规则，是关于 copy elision 的。\nSince C++17, a prvalue is not materialized until needed, and then it is constructed directly into the storage of its final destination. This sometimes means that even when the language syntax visually suggests a copy/move (e.g. copy initialization), no copy/move is performed — which means the type need not have an accessible copy/move constructor at all.\n什么意思呢，举例子说明最清晰\nstruct M { M() = default; M(M\u0026amp;\u0026amp;) = delete; }; M m1 = M(); // ok in C++17, error in C++14 M m2 = std::move(M()); // error 啊？为什么会这样，第一个可以编译通过，第二个不行，难道我写 std::move 还多余了吗？\n其实第二个编译不通过的原因是很好理解的，因为移动构造函数被删除了，所以没法调用移动构造函数了，于是就编译失败了。注意到第一种情况在 C++14 和 C++17 中的行为是不一样的，C++14 是先产生临时对象，然后调用移动构造函数，初始化 m1，但是这样的行为其实是多余的，所以编译器可能会优化掉这步多余的步骤。但是这里还是有调用移动构造函数的可能性，所以删除构造函数了就 GG 了，编译失败。到了 C++17 这个优化直接变成语言强制性的要求了，所以完全没有移动构造这一步了，自然也不需要可访问的构造函数了，所以在 C++17 可以编译通过。\n这也就意味着，右值之间亦有差距。prvalue 即纯右值可以直接复制消除构造对象（比如这里的非引用类型的函数返回值就是纯右值），但是 xvalue 也即亡值必须得有可调用的移动构造函数才行，也不行进行复制消除（右值引用类型的函数返回值就是亡值）。所以这里 std::move 反倒起了负面效果。\n回到我们的问题，注意到 Any 有一个转化成右值引用类型的转换函数，所以如果遇到了这种情况就没办法了。但是再次通过巧妙地修改，又能解决这个问题：\nstruct Any { constexpr Any(int) {} template \u0026lt;typename T\u0026gt; requires std::is_copy_constructible_v\u0026lt;T\u0026gt; operator T\u0026amp;(); template \u0026lt;typename T\u0026gt; requires std::is_move_constructible_v\u0026lt;T\u0026gt; operator T\u0026amp;\u0026amp;(); template \u0026lt;typename T\u0026gt; requires (!std::is_copy_constructible_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_move_constructible_v\u0026lt;T\u0026gt;) operator T(); }; 注意到我们这里对类型做了约束，如果是不可移动的类型（移动构造被删除），那就对应到了最后一个类型转换函数。直接产生 prvalue 构造对象，这样就巧妙地解决了这个问题了。写拷贝构造的约束是为了防止重载决议歧义（同时在最后可以顺便修复 MSVC 的 BUG）。\nNested Struct 事实上作者原本的思路很好，但是忽略了一个问题，那就是不只有数组类型可以使用二重{{ }} 初始化，结构体也是可以的\nstruct A { int x; int y; }; struct B { A x; int y; }; B{{1, 2}, 3}; // ok 所以如果这个位置有是结构体成员的话，就会导致错误的计数。所以我们需要先判断一下这个位置是不是结构体，如果是的话，就不用对这个位置尝试求最大放置数量了，直接去求下一个位置就行了\n那怎么判断当前位置成员是不是结构体呢？考虑下面这个例子\nstruct A { int x; int y; }; struct B { A x; int y[2]; }; 手动枚举一下测试情况\nB{any, any, any}; // ok B{{any}, any, any}; // ok B{{any, any}, any, any}; // ok B{any, {any}, any}; // error B{any, {any, any}, any}; // error OK 其实答案很显然了，那就是如果当前位置是结构体的话，可以往这个位置额外添加元素。注意到原本的 Total 即最大可能的元素数量是 3，但是如果当前位置是结构体的话，放 4 个元素也是可以，但是如果是数组就不行了。我们利用这个特性来判断当前位置的是不是结构体，如果是的话，就跳去下一个位置，如果不是就在这个位置搜索最大能放置的元素。\n其实就是在这个位置递归尝试放置元素，但是这里有一个问题是，当前位置的结构体成员中仍然可能含有不能默认初始化的成员。所以究竟放几个才能确定这个位置能被初始化呢？这还是不确定的，我这里设置的最大上限是 10 个，如果子结构体中不能默认初始化的成员位置在 10 之后的话这个方法就失败了。\ntemplate \u0026lt;typename T, std::size_t pos, std::size_t N = 0, std::size_t Max = 10\u0026gt; constexpr bool has_extra_elements() { constexpr auto Total = member_count\u0026lt;T\u0026gt;(); if constexpr(test_three_parts\u0026lt;T, pos, N, Total - pos - 1\u0026gt;()) { return false; } else if constexpr(N + 1 \u0026lt;= Max) { return has_extra_elements\u0026lt;T, pos, N + 1\u0026gt;(); } else { return true; } } 有了这个函数之后再把原来那个 search 函数逻辑稍微改一下就行了\ntemplate \u0026lt;typename T, std::size_t pos, std::size_t N = 0\u0026gt; constexpr auto search_max_in_pos() { constexpr auto Total = member_count\u0026lt;T\u0026gt;(); if constexpr(!has_extra_elements\u0026lt;T, pos\u0026gt;()) { return 1; } else { // ... unchanged } } 就是加一个分支判断，如果当前位置没有额外的元素就直接返回 1，如果有的就去搜索（数组的）最大边界。这样的话就解决了原作者的代码中的问题了\n仍然测试一下\nstruct Y { int x; int y; }; struct G { Y x; int y; }; static_assert(true_member_count\u0026lt;G\u0026gt;() == 2); // ok 成功了！\nCompiler Bug 作者在原文中提到的 GCC 和 MSVC 的问题我也一并找出来了，MSVC 目前有一个 缺陷：\nstruct Any { template \u0026lt;typename T\u0026gt; // requires std::is_copy_constructible_v\u0026lt;T\u0026gt; operator T\u0026amp;() const; }; struct A { int x[2]; }; A a{Any{}}; 上述的代码可以正常编译，这意味着 MSVC 允许直接从数组的引用聚合初始化数组成员。但是这是 C++ 标准所不允许的，这个 BUG 会导致在 MSVC 上对成员计数错误，解决办法其实很简单，前面我们已经顺便解决过这个问题了，只要把注释的那行加上就行了。因为数组是不可拷贝构造的类型，所以约束会把这个重载函数排除掉，这样就不会出现这个问题了。\nGCC13 也有一个严重的 缺陷，直接会导致 ICE，这个 BUG 用下面几行代码就能复现出来：\nstruct Number { int x; operator int\u0026amp;() { return x; } }; struct X { int\u0026amp; x; }; template \u0026lt;typename T\u0026gt; concept F = requires { T{{Number{}}}; }; int main() { static_assert(!F\u0026lt;X\u0026gt;); // internal compiler error } 这个显然是不应该导致 ICE 的，而且只在 GCC13 才有这个 BUG 实在是很奇怪。测试代码在 godbolt 。Clang 没任何问题，但是 GCC 就直接内部编译器错误了。而 GCC12 和 Clang 的编译结果不一样，但是其实 clang 是对的。这也就是原作者文章里面说的 Clang 和 GCC 不一致的地方。\n_注：后经评论区提醒，Clang15 也会遇到类似的内部编译器错误。 _\nAfterword 后来又和评论区的各位讨论了一番，上面的处理仍然有些欠缺考虑。一个典型的例子是，当成员变量的构造函数是模板函数的时候就会出错，例如 std::any，原因是不知道调用类型转换函数和模板构造函数中的哪一个（重载决议失败）\nstd::any any = Any(0); // conversion from \u0026#39;Any\u0026#39; to \u0026#39;std::any\u0026#39; is ambiguous // candidate: \u0026#39;Any::operator T\u0026amp;() [with T = std::any]\u0026#39; // candidate: \u0026#39;std::any::any(_Tp\u0026amp;\u0026amp;) 但是目前还没有一个完美的解决办法能解决这个问题，不能直接检测 T 能不能由 Any 构造来解决这个问题，这会涉及到递归的约束，最后导致无法求解，从而编译错误。这里用了一个比较取巧的办法\nstruct Any { constexpr Any(int) {} template \u0026lt;typename T\u0026gt; requires (std::is_copy_constructible_v\u0026lt;T\u0026gt;) operator T\u0026amp;(); template \u0026lt;typename T\u0026gt; requires (std::is_move_constructible_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_copy_constructible_v\u0026lt;T\u0026gt;) operator T\u0026amp;\u0026amp;(); struct Empty {}; template \u0026lt;typename T\u0026gt; requires (!std::is_copy_constructible_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_move_constructible_v\u0026lt;T\u0026gt; \u0026amp;\u0026amp; !std::is_constructible_v\u0026lt;T, Empty\u0026gt;) operator T(); }; 就是声明了一个空类，然后尝试用这个空类能不能转换成类型 T，如果不行就能说明 T 的构造函数应该不是模板函数，于是类型转换可以生效。如果可以，则说明 T 的构造函数是模板函数，要排除这个类型转换函数。当然了，如果 T 的构造函数有一些奇怪的约束，比如直接把 Empty 排掉，但是接受 Any。这样话还是会导致错误，但是这属于刻意为之了，正常情况下基本是不会遇到这个问题的，这个问题可以算是解决了\n除此之外还有一个和引用相关的问题，如果结构体中含有不可拷贝/复制类型的引用成员，那么也会失败，下面就拿左值引用举例子吧\nstruct CanNotCopy { CanNotCopy(const CanNotCopy\u0026amp;) = delete; }; struct X { CanNotCopy\u0026amp; x; }; X x{Any(0)}; // error 这里 T 就会实例化成 CanNotCopy 类型。显然因为它不可拷贝，导致重载决议选到了 operator T() 上，然后实际产生的是右值没法绑定到左值引用，就编译错误了。那这个问题可能解决吗？非常困难。事实上，我们无法让下面两个表达式同时成立\nstruct X { CanNotCopy\u0026amp; x; }; struct Y { CanNotCopy x; }; X x{Any(0)}; Y y{Any(0)}; 在这两个聚合初始化里面，类型转换函数实例化的 T 都是 CanNotCopy 类型，但是如果想让 x，y 都良构，那么就意味对于同一个 T 要选择两个不同的重载函数，第一个选 operator T\u0026amp;()，第二个选 operator T()，但是这两个函数之间并没有哪个更优先，C++ 也没法对返回值进行重载，所以这是做不到的。一个可能的解决方案是写三种 Any,分别转化成 T\u0026amp;，T\u0026amp;\u0026amp;，T 然后在每个位置使用这三种进行尝试，这样的话倒是可以解决这个问题，但是可能会导致模板实例化个数以 3^N 次方的速度进行指数增长。这种实现比之前的遍历方式加起来开销都要大，所以这里我就不做展示了，理论可行，实践上会累垮编译器。\nConclusion 本文的全部代码都在 Compiler Explorer 上，三大编译器均通过（GCC 版本是 12），有很多测试代码，如果你找到其他的 corner case 欢迎留言讨论。\n好了，这篇文章到这里就结束了。如果你耐心看完了全文，想必你也是和我一样，喜欢这些好玩的东西。这种东西最有趣的地方就在于，利用 C++ 暴露的一点点接口，去一步步扩展它，最后实现非常漂亮的接口出来。当然对于作者来说其实并不漂亮 OvO。总之这种东西就像是游戏一样，是日常的消遣，没事给 C++ 编译器找找 bug，钻研这些犄角旮旯的特性，也是一份乐趣。如果非要谈实际价值，其实这种东西几乎不可能在实际的代码生产环境中使用。首先通过实例化大量模板来寻找结构体的字段数量，会大大拖慢编译速度，而且即使花费如此大的功夫，也只是实现了对聚合类型的遍历，还不支持非聚合类型。不仅副作用强，而且主要功能也不强。权衡一下考虑也是非常不值当了，对于这种需要类似反射的需求的时候，在 C++ 加入静态反射之前，目前真正可行的自动化方案是采用代码生成来做这个事情。\n我也有相关的文章详细介绍了相关的原理，不依赖于这些奇淫巧技，真正可用于实际项目中的方案： 写给 C++ 程序员的反射教程。\n当然如果用这些功能仅仅是为了日志，调试或者学习模板工作的原理的话，而不是用于任何核心的代码部分，又不想引入很重的依赖，那这些东西用一用也未尝不可。\n","permalink":"https://www.ykiko.me/zh-cn/articles/674157958/","summary":"\u003ch2 id=\"introduction\"\u003eIntroduction\u003c/h2\u003e\n\u003cp\u003e在 \u003ccode\u003eC++17\u003c/code\u003e 中引入了叫做「\u003cstrong\u003e结构化绑定\u003c/strong\u003e」的特性也就是 \u003ccode\u003eStruct Binding\u003c/code\u003e，这一特性类似于别的语言中的模式匹配，可以让我们方便的对结构体的成员进行访问\u003c/p\u003e","title":"跨越 7 年的接力赛：获取 C++ 结构体字段数量"},{"content":"静态与动态 静态类型和动态类型这两个词语相信大家都不陌生了，区分二者的关键在于类型检查的时机。什么意思呢？\n假设我们有如下的 C++ 代码\nstd::string s = \u0026#34;123\u0026#34;; int a = s + 1; 那我们知道，string 是不能和 int 直接相加的，所以这里应该有一个 TypeError。C++ 在编译期检查类型错误，所以这段代码会触发一个 compile time error（编译时错误）。\n考虑对应的 Python 代码\ns = \u0026#34;123\u0026#34; a = s + 1 而 Python 则是在运行期检查错误，上述代码实际上会产生一个 runtime error（运行时错误）。\n有必要强调一下这里的编译期 compile time 和 runtime 指代的含义。这些词可能经常会见到，但是在不同的上下文中可以含义不太一样，在我们这里：\ncompile time：泛指将一种代码编译为目标代码的时候，这时候程序还没有运行起来 对于 AOT 编译的语言，例如 C++，就是把 C++ 编译成机器码的过程 对于 JIT 编译的语言，例如 C#/Java，一般是指把源码编译成 IR 的过程 对于转译语言来说，例如 TypeScript，则是把 TypeScript 编译成 JavaScript 的过程 runtime：泛指程序实际运行的时候，比如机器码在 CPU 上执行的时候，或者字节码在虚拟机上执行的时候 因此 C++，Java，C#，TypeScript 被称作静态类型的语言。而 Python 虽然也有把源码编译到字节码这个阶段，但是这个阶段不进行类型检查，所以 Python 被称作动态类型的语言。\n然而这并不绝对，静态语言和动态语言之间的界限并没有那么清晰，虽然 C++，Java，C#，TypeScript 是静态类型的语言，但是都提供了若干方法来绕过静态类型检查，比如 C++ 的 pointer，Java/C# 的 Object， TypeScript 的 Any。而动态类型语言也逐渐在引入静态类型检查，比如 Python 的 type hint，JavaScript 的 TypeScript 等等，二者都在相互借鉴对方的特性。\n目前 C++ 只提供了 std::any 来进行类型擦除，但是很多时候它不够灵活。我们想要一些更加高级的功能，比如通过字段名访问成员，通过函数名调用函数，通过类型名创造类实例。 本文的目标就是在 C++ 中构建出类似 Java/C# 中的 Object 那样的动态的类型。\n元类型 我们这里不采用类似 Java/C# 中 Object 那种侵入式设计（继承），而是采用被叫做 fat pointer 的非侵入式设计。所谓 fat pointer 其实就是一个结构体，包含了一个指向实际数据的指针，以及一个指向类型信息的指针。如果是继承的话，则是这个虚表指针存在对象头部。\nclass Any { Type* type; // type info, similar to vtable void* data; // pointer to the data uint8_t flag; // special flag public: Any() : type(nullptr), data(nullptr), flag(0) {} Any(Type* type, void* data) : type(type), data(data), flag(0B00000001) {} Any(const Any\u0026amp; other); Any(Any\u0026amp;\u0026amp; other); ~Any(); template \u0026lt;typename T\u0026gt; Any(T\u0026amp;\u0026amp; value); // box value to Any template \u0026lt;typename T\u0026gt; T\u0026amp; cast(); // unbox Any to value Type* GetType() const { return type; } // get type info Any invoke(std::string_view name, std::span\u0026lt;Any\u0026gt; args); // call method void foreach(const std::function\u0026lt;void(std::string_view, Any\u0026amp;)\u0026gt;\u0026amp; fn); // iterate fields }; 其中的成员函数将会在后面的章节逐步实现，接下来我们先来考虑这个 Type 类型里面存的是什么。\n元信息 struct Type { std::string_view name; // type name void (*destroy)(void*); // destructor void* (*copy)(const void*); // copy constructor void* (*move)(void*); // move constructor using Field = std::pair\u0026lt;Type*, std::size_t\u0026gt;; // type and offset using Method = Any (*)(void*, std::span\u0026lt;Any\u0026gt;); // method std::unordered_map\u0026lt;std::string_view, Field\u0026gt; fields; // field info std::unordered_map\u0026lt;std::string_view, Method\u0026gt; methods; // method info }; 这里的内容很简单，我们在 Type 里面存了类型名，析构函数，移动构造，拷贝构造，字段信息和方法信息。字段信息里面存的是字段类型和字段名，方法信息里面存的是方法名和函数地址。如果希望进一步扩展的话，还可以把父类的信息和重载函数的信息也存进来。由于这里只是做一个示例，就暂时不考虑它们了。\n函数类型擦除 为了把不同类型的成员函数存在同一个容器里面，我们必须要对函数类型进行擦除。所有类型的函数都被擦除成了 Any(*)(void*, std::span\u0026lt;Any\u0026gt;) 这个类型。这里的 Any 类型就是我们上面定义的 Any 类型，这里的 void* 其实代表就是 this 指针，而 std::span\u0026lt;Any\u0026gt; 则是函数的参数列表。现在我们要考虑如何进行这种函数类型擦除。\n以下面给定的成员函数 say 为例：\nstruct Person { std::string_view name; std::size_t age; void say(std::string_view msg) { std::cout \u0026lt;\u0026lt; name \u0026lt;\u0026lt; \u0026#34; say: \u0026#34; \u0026lt;\u0026lt; msg \u0026lt;\u0026lt; std::endl; } }; 首先为了方便书写，我们把 Any 的 cast 实现一下：\ntemplate \u0026lt;typename T\u0026gt; Type* type_of(); // type_of\u0026lt;T\u0026gt; returns type info of T template \u0026lt;typename T\u0026gt; T\u0026amp; Any::cast() { if(type != type_of\u0026lt;T\u0026gt;()) { throw std::runtime_error{\u0026#34;type mismatch\u0026#34;}; } return *static_cast\u0026lt;T*\u0026gt;(data); } 利用 C++ 中无捕获的 lambda 能隐式转换成函数指针这个特性，可以轻松实现这种擦除。\nauto f = +[](void* object, std::span\u0026lt;Any\u0026gt; args) { auto\u0026amp; self = *static_cast\u0026lt;Person*\u0026gt;(object); self.say(args[0].cast\u0026lt;std::string_view\u0026gt;()); return Any{}; }; 其实原理很简单，只要写一个 wrapper 函数进行一下类型转换，然后转发调用就行了。但是如果每个成员函数都要手写这么一大段转发代码还是很麻烦的。我们可以考虑通过模板元进行代码生成，自动生成上面的代码，简化类型擦除的这个过程。\ntemplate \u0026lt;typename T\u0026gt; struct member_fn_traits; template \u0026lt;typename R, typename C, typename... Args\u0026gt; struct member_fn_traits\u0026lt;R (C::*)(Args...)\u0026gt; { using return_type = R; using class_type = C; using args_type = std::tuple\u0026lt;Args...\u0026gt;; }; template \u0026lt;auto ptr\u0026gt; auto* type_ensure() { using traits = member_fn_traits\u0026lt;decltype(ptr)\u0026gt;; using class_type = typename traits::class_type; using result_type = typename traits::return_type; using args_type = typename traits::args_type; return +[](void* object, std::span\u0026lt;Any\u0026gt; args) -\u0026gt; Any { auto self = static_cast\u0026lt;class_type*\u0026gt;(object); return [=]\u0026lt;std::size_t... Is\u0026gt;(std::index_sequence\u0026lt;Is...\u0026gt;) { if constexpr(std::is_void_v\u0026lt;result_type\u0026gt;) { (self-\u0026gt;*ptr)(args[Is].cast\u0026lt;std::tuple_element_t\u0026lt;Is, args_type\u0026gt;\u0026gt;()...); return Any{}; } else { return Any{(self-\u0026gt;*ptr)(args[Is].cast\u0026lt;std::tuple_element_t\u0026lt;Is, args_type\u0026gt;\u0026gt;()...)}; } }(std::make_index_sequence\u0026lt;std::tuple_size_v\u0026lt;args_type\u0026gt;\u0026gt;{}); }; } 这里的代码我就不解释了，如果看不懂也没关系。其实就是通过模板元，把成员函数类型擦除的这个过程自动化了一下。只要知道如何使用就行了，使用起来是非常简单的。这里的 \u0026amp;Person::say 是 pointer to member 的写法，不太熟悉的可以参考 C++ 成员指针完全解析。\nauto f = type_ensure\u0026lt;\u0026amp;Person::say\u0026gt;(); // decltype(f) =\u0026gt; Any (*)(void*, std::span\u0026lt;Any\u0026gt;) 类型信息注册 事实上我们需要给每个类型都生成一个对应的 Type 结构来保存它的信息，这样的话才能正确访问。而这个功能就由上文提到的 type_of 函数负责。\ntemplate \u0026lt;typename T\u0026gt; Type* type_of() { static Type type; type.name = typeid(T).name(); type.destroy = [](void* obj) { delete static_cast\u0026lt;T*\u0026gt;(obj); }; type.copy = [](const void* obj) { return (void*)(new T(*static_cast\u0026lt;const T*\u0026gt;(obj))); }; type.move = [](void* obj) { return (void*)(new T(std::move(*static_cast\u0026lt;T*\u0026gt;(obj)))); }; return \u0026amp;type; } template \u0026lt;\u0026gt; Type* type_of\u0026lt;Person\u0026gt;() { static Type type; type.name = \u0026#34;Person\u0026#34;; type.destroy = [](void* obj) { delete static_cast\u0026lt;Person*\u0026gt;(obj); }; type.copy = [](const void* obj) { return (void*)(new Person(*static_cast\u0026lt;const Person*\u0026gt;(obj))); }; type.move = [](void* obj) { return (void*)(new Person(std::move(*static_cast\u0026lt;Person*\u0026gt;(obj)))); }; type.fields.insert({\u0026#34;name\u0026#34;, {type_of\u0026lt;std::string_view\u0026gt;(), offsetof(Person, name)}}); type.fields.insert({\u0026#34;age\u0026#34;, {type_of\u0026lt;std::size_t\u0026gt;(), offsetof(Person, age)}}); type.methods.insert({\u0026#34;say\u0026#34;, type_ensure\u0026lt;\u0026amp;Person::say\u0026gt;()}); return \u0026amp;type; }; 我们提供一个默认实现，这样的话如果用到了内置的基础类型可以自动注册一些信息。然后可以通过特化给自定义的类型提供实现，好了，现在有了这些元信息我们可以把 Any 的成员函数实现补充完整了。\nAny 完整实现 Any::Any(const Any\u0026amp; other) { type = other.type; data = type-\u0026gt;copy(other.data); flag = 0; } Any::Any(Any\u0026amp;\u0026amp; other) { type = other.type; data = type-\u0026gt;move(other.data); flag = 0; } template \u0026lt;typename T\u0026gt; Any::Any(T\u0026amp;\u0026amp; value) { type = type_of\u0026lt;std::decay_t\u0026lt;T\u0026gt;\u0026gt;(); data = new std::decay_t\u0026lt;T\u0026gt;(std::forward\u0026lt;T\u0026gt;(value)); flag = 0; } Any::~Any() { if(!(flag \u0026amp; 0B00000001) \u0026amp;\u0026amp; data \u0026amp;\u0026amp; type) { type-\u0026gt;destroy(data); } } void Any::foreach(const std::function\u0026lt;void(std::string_view, Any\u0026amp;)\u0026gt;\u0026amp; fn) { for(auto\u0026amp; [name, field]: type-\u0026gt;fields) { Any any = Any{field.first, static_cast\u0026lt;char*\u0026gt;(data) + field.second}; fn(name, any); } } Any Any::invoke(std::string_view name, std::span\u0026lt;Any\u0026gt; args) { auto it = type-\u0026gt;methods.find(name); if(it == type-\u0026gt;methods.end()) { throw std::runtime_error{\u0026#34;method not found\u0026#34;}; } return it-\u0026gt;second(data, args); } foreach 的实现就是遍历所有的 Field 然后获取偏移量和类型，然后把它包装成 Any 类型。注意这里只是简单包装一下，实际上由于我们设置了 flag，这个包装并不会导致多次析构。invoke 就是从成员函数列表里面找出对应的函数，然后调用。\n示例代码 int main() { Any person = Person{\u0026#34;Tom\u0026#34;, 18}; std::vector\u0026lt;Any\u0026gt; args = {std::string_view{\u0026#34;Hello\u0026#34;}}; person.invoke(\u0026#34;say\u0026#34;, args); // =\u0026gt; Tom say: Hello auto f = [](std::string_view name, Any\u0026amp; value) { if(value.GetType() == type_of\u0026lt;std::string_view\u0026gt;()) { std::cout \u0026lt;\u0026lt; name \u0026lt;\u0026lt; \u0026#34; = \u0026#34; \u0026lt;\u0026lt; value.cast\u0026lt;std::string_view\u0026gt;() \u0026lt;\u0026lt; std::endl; } else if(value.GetType() == type_of\u0026lt;std::size_t\u0026gt;()) { std::cout \u0026lt;\u0026lt; name \u0026lt;\u0026lt; \u0026#34; = \u0026#34; \u0026lt;\u0026lt; value.cast\u0026lt;std::size_t\u0026gt;() \u0026lt;\u0026lt; std::endl; } }; person.foreach(f); // name = Tom // age = 18 return 0; } 完整代码放在 GitHub 上了，至此我们就已经实现了一个极度动态，非侵入式的 Any 了。\n扩展和优化 本文给出的只是非常简单的原理介绍，考虑的情况也十分简单。比如这里没有考虑继承和函数重载，在运行效率上也有若干可以优化的地方。尽管如此，可能我写的功能对你来说仍然是过多的。本文主要想表达的是，对于 C++ 这种非常注重性能的语言来说，有时候的确会在一些场景需要这些比较动态的特性。然而高效性和通用性往往是矛盾的，语言层面因为要考虑通用性，所以效率往往不尽如人意。例如 RTTI 和 dynamic_cast 常常被人抱怨，不过好在编译器提供选项来关闭它们。同样的，我的实现也不一定完全符合你的场景，但是懂得这并不困难的原理之后你完全可以根据你的场景来实现一个更加适合你的版本。\n可以扩展的点：\n支持根据 name 来修改成员 添加一个全局的 map 用于记录所有类型的信息，从而支持根据类名创造类的实例 ... 可以优化的点：\n减少 new 的次数，或者自己实现一个对象池 或者目前储存的元信息过多，根据你自己的需求进行裁剪 除此之外，现在还有一个痛点是，这些元信息我们都要手写，很难维护。如果要修改类内的定义还得把这些注册代码一并修改，否则就会出错。这里一个实际可行的方案是使用代码生成器来自动生成这些机械的代码。关于如何进行这些操作，可以参考本系列的其他文章。\n","permalink":"https://www.ykiko.me/zh-cn/articles/670191053/","summary":"\u003ch2 id=\"静态与动态\"\u003e静态与动态\u003c/h2\u003e\n\u003cp\u003e静态类型和动态类型这两个词语相信大家都不陌生了，区分二者的关键在于类型检查的时机。什么意思呢？\u003c/p\u003e","title":"在 C++ 中实现 Object!"},{"content":"首先什么是元信息？ 来看下面一段 python 代码，我们希望能够根据传入的字符串来自动修改对应的字段值\nclass Person: def __init__(self, age, name): self.age = age self.name = name person = Person(10, \u0026#34;xiaohong\u0026#34;) setattr(person, \u0026#34;age\u0026#34;, 12) setattr(person, \u0026#34;name\u0026#34;, \u0026#34;xiaoming\u0026#34;) print(f\u0026#34;name: {person.name}, age: {person.age}\u0026#34;) # =\u0026gt; name: xiaoming, age: 12 setattr 是 python 内置的一个函数，刚好可以实现我们的需求。根据输入的字段名，修改对应值。\n如果想要在 C++ 中实现应该怎么办呢？C++ 可没有内置 setattr 这种函数。代码示例如下。（暂时就先考虑可以直接 memcpy 的类型了，也就是 trivially copyable 的类型）\nstruct Person { int age; std::string_view name; }; // 名字 -\u0026gt; 字段偏移量，字段大小 std::map\u0026lt;std::string_view, std::pair\u0026lt;std::size_t, std::size_t\u0026gt;\u0026gt; fieldInfo = { {\u0026#34;age\u0026#34;, {offsetof(Person, age), sizeof(int)}}, {\u0026#34;name\u0026#34;, {offsetof(Person, name), sizeof(std::string_view)}}, }; void setattr(Person* point, std::string_view name, void* data) { if(!fieldInfo.contains(name)) { throw std::runtime_error(\u0026#34;Field not found\u0026#34;); } auto\u0026amp; [offset, size] = fieldInfo[name]; std::memcpy(reinterpret_cast\u0026lt;char*\u0026gt;(point) + offset, data, size); } int main() { Person person = {.age = 1, .name = \u0026#34;xiaoming\u0026#34;}; int age = 10; std::string_view name = \u0026#34;xiaohong\u0026#34;; setattr(\u0026amp;person, \u0026#34;age\u0026#34;, \u0026amp;age); setattr(\u0026amp;person, \u0026#34;name\u0026#34;, \u0026amp;name); std::cout \u0026lt;\u0026lt; person.age \u0026lt;\u0026lt; \u0026#34; \u0026#34; \u0026lt;\u0026lt; person.name \u0026lt;\u0026lt; std::endl; // =\u0026gt; 10 xiaohong } 可以发现我们基本上自己实现了 setattr 这个函数，而且这样的实现似乎可以是通用的。只要为特定的类型提供属于它的 fieldInfo 就行了。这个 fieldInfo 里面存了字段名，字段的偏移量，字段的类型大小。它就可以被看做元信息,除此之外可能还有变量名，函数名，等等。这些信息不直接参与程序的运行，而是提供关于程序结构、数据、类型等方面的附加信息。元信息里面存的东西似乎也都是死套路，对于我们都是已知信息。因为它们就存在程序的源代码里面。那 C/C++ 编译器提供这种功能吗？答案是：对于 debug 模式下的程序可能会保留一部分用于程序调试，而在 release 模式下什么都不会存。这样做的好处是很显然的，因为这些信息并不是程序运行起来必须要的信息，不保留它们可以显著减少二进制可执行文件的大小。\n为什么这些信息是不必要的，什么时候需要？ 接下来我会以 C 语言为例，将它的源码与二进制表示对应起来。看看执行代码究竟需要哪些信息？\n变量定义 int value; 事实上变量声明并没有直接对应的二进制表示，它仅仅是告诉编译器需要分配一块空间来存储名为 value 的变量，究竟分配多大的内存则由它的类型决定。所以如果变量声明的时候类型大小是未知的，则会编译错误。\nstruct A; A x; // error: storage size of \u0026#39;x\u0026#39; isn\u0026#39;t known A* y; // ok the size of pointer is always known struct Node { int val; Node next; }; // error Node is not a complete type // 其实意思就是定义 Node 类型的时候它的大小还是未知的 struct Node { int val; Node* next; }; // ok 相信你想到了这和 malloc 似乎有点像，的确如此。区别在于，malloc 是在运行时的堆上分配内存。而直接的变量声明一般是在数据区或者栈上分配内存。编译器可能在内部会维护一个符号表，将变量名与它的地址映射起来，在你后续对这个变量进行操作的时候，实际上是对这块内存区域进行操作。\n内置运算符 C 语言内置的运算符一般直接和 CPU 指令直接对应，至于 CPU 是如何实现这些运算的，可以学习下数电相关知识。以 x86_64 为例，可能的对应如下\n| Operator | Meaning | Operator | Meaning | |----------|---------|----------|---------| | + | add | * | mul | | - | sub | / | div | | % | div | \u0026amp; | and | | \\| | or | ^ | xor | | ~ | not | \u0026lt;\u0026lt; | shl | | \u0026gt;\u0026gt; | shr | \u0026amp;\u0026amp; | and | | || | or | ! | not | | == | cmp | != | cmp | | \u0026gt; | cmp | \u0026gt;= | cmp | | \u0026lt; | cmp | \u0026lt;= | cmp | | ++ | inc | -- | dec | 赋值则可能是通过 mov 指令来完成的，比如\na = 3; // mov [addressof(a)] 3 结构体 struct Point { int x; int y; }; int main() { Point point; point.x = 1; point.y = 2; } 结构体的大小一般可以由特定规则算出从它的成员算出，往往要考虑内存对齐，而且是编译器决定的。例如 MSVC。但总之在编译的时候结构体的大小就是已知的了，我们也可以通过 sizeof 获取类型或者变量的大小。那么这里的 Point point 变量定义就很好理解，类型大小已知，相对于在栈上分配了一块内存。\n下面来关注一下结构体成员访问，事实上 C 语言有一个宏可以获取结构体成员相对于结构体起始地址的偏移量，叫做 offsetof（就算我们获取不到，编译器里面也是会计算字段偏移量的，所以偏移量信息对编译器总是已知的）。例如在这里 offsetof(Point, x) 就是 0，offsetof(Point, y) 就是 4。所以上面的代码可以理解为\nint main() { char point[sizeof(Point)]; // 8 = sizeof(Point) *(int*)(point + offsetof(Point, x)) = 1; // point.x = 1 *(int*)(point + offsetof(Point, y)) = 2; // point.y = 2 } 编译器同样可能会维护一个字段名-\u0026gt;偏移量的符号表，字段名最终会替换为 offset。也没有必要在程序中保留了。\n函数调用 一般通过函数调用栈实现，这个太常见了，就不仔细说了。函数名最后会直接被替换为函数地址。\n总结 通过上面的分析，相信你已经发现了，C 语言中的符号名，类型名，变量名，函数名，结构体字段名等等信息都被替换成了数字，地址，偏移量等等。缺少了它们对程序运行并没有什么影响。所以选择把它们抛弃掉，减少二进制文件的大小。对于 C++ 来说情况基本也是类似的，C++ 只会在一些特殊的情况下保留部分元信息，比如 type_info，而且可以手动选择关闭掉 RTTI 从而确保不会产生这种信息。\n那什么时候我们需要使用这些信息？显然最开始介绍的 setattr 是需要的。在程序调试的时候，我们得知道一个地址对应的变量名，函数名，成员名等等，方便我们调试，这时候我们也是需要的。当把结构体序列化为 json 的时候，我们需要知道它的字段名，我们也需要这些信息。把类型擦除成 void* 了之后，我们还是需要知道它实际对应的类型是什么，这时候我们也是需要的。总之，为了在运行期区分这串二进制内容到底原本是什么东西的时候，我们就需要这些信息（当然在编译期想要利用这些信息进行代码生成，也是需要的）。\n如何获取这些信息？ C/C++ 编译器并没有提供给我们接口让我们获取这些信息，但是前面已经说了，这些信息显然就在源代码里面啊。变量名，函数名，类型名，字段名。我们可以选择通过人工理解代码，然后手动去存储元信息。几千个类，几十个成员函数，可能写个几个月就好了吧。开玩笑的，或者我们可以写一些程序，比如正则表达式匹配之类的帮我们获取到这些信息？不过，其实我们有更好的选择来获取这些信息，那就是通过 AST。\nAST(Abstract Syntax Tree) AST 是抽象语法树（Abstract Syntax Tree）的缩写。它是编程语言处理中的一种数据结构，用于表示源代码的抽象语法结构。AST 是源代码经过解析器（parser）处理后的结果，它捕捉了代码中的语法结构，但不包含所有细节，比如空白字符或注释。在 AST 中，每个节点代表源代码中的一个语法结构，例如变量声明、函数调用、循环等。这些节点之间通过父子关系和兄弟关系连接，形成了一棵树状结构，这样的结构更容易被计算机程序理解和处理。如果你的电脑里面装了 clang 编译器，可以使用下面这个命令查看一个源文件的语法树\nclang -Xclang -ast-dump -fsyntax-only \u0026lt;your.cpp\u0026gt; 输出如下，我筛选出了重要的信息，无关的已经被删除了\n|-CXXRecordDecl 0x2103cd9c318 \u0026lt;col:1, col:8\u0026gt; col:8 implicit struct Point |-FieldDecl 0x2103cd9c3c0 \u0026lt;line:4:5, col:9\u0026gt; col:9 referenced x \u0026#39;int\u0026#39; |-FieldDecl 0x2103e8661f0 \u0026lt;line:5:5, col:9\u0026gt; col:9 referenced y \u0026#39;int\u0026#39; `-FunctionDecl 0x2103e8662b0 \u0026lt;line:8:1, line:13:1\u0026gt; line:8:5 main \u0026#39;int ()\u0026#39; `-CompoundStmt 0x2103e866c68 \u0026lt;line:9:1, line:13:1\u0026gt; |-DeclStmt 0x2103e866b30 \u0026lt;line:10:5, col:16\u0026gt; | `-VarDecl 0x2103e866410 \u0026lt;col:5, col:11\u0026gt; col:11 used point \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; callinit | `-CXXConstructExpr 0x2103e866b08 \u0026lt;col:11\u0026gt; \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; \u0026#39;void () noexcept\u0026#39; |-BinaryOperator 0x2103e866bb8 \u0026lt;line:11:5, col:15\u0026gt; \u0026#39;int\u0026#39; lvalue \u0026#39;=\u0026#39; | |-MemberExpr 0x2103e866b68 \u0026lt;col:5, col:11\u0026gt; \u0026#39;int\u0026#39; lvalue .x 0x2103cd9c3c0 | | `-DeclRefExpr 0x2103e866b48 \u0026lt;col:5\u0026gt; \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; lvalue Var 0x2103e866410 \u0026#39;point\u0026#39; \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; | `-IntegerLiteral 0x2103e866b98 \u0026lt;col:15\u0026gt; \u0026#39;int\u0026#39; 1 `-BinaryOperator 0x2103e866c48 \u0026lt;line:12:5, col:15\u0026gt; \u0026#39;int\u0026#39; lvalue \u0026#39;=\u0026#39; |-MemberExpr 0x2103e866bf8 \u0026lt;col:5, col:11\u0026gt; \u0026#39;int\u0026#39; lvalue .y 0x2103e8661f0 | `-DeclRefExpr 0x2103e866bd8 \u0026lt;col:5\u0026gt; \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; lvalue Var 0x2103e866410 \u0026#39;point\u0026#39; \u0026#39;Point\u0026#39;:\u0026#39;Point\u0026#39; `-IntegerLiteral 0x2103e866c28 \u0026lt;col:15\u0026gt; \u0026#39;int\u0026#39; 2 或者如果你的 vscode 装了 clangd 这个插件，可以右键选择一块代码，然后右键 show AST 来看这块代码片段的 ast。可以发现上面的确是把源码内容以树的方式呈现给我们了，既然是一颗树，我们就可以自由的遍历树的节点，然后筛选获取我们想要的信息。上面两例都是可视化的输出，通常情况下也会有直接的代码接口来直接获取。比如 python 内置就有 ast 模块来获取，C++ 一般是通过 clang 相关的工具来获取这些内容。如果想知道具体该如何使用 clang 工具，可以参考文章：使用 Clang 工具自由的支配 C++ 代码吧！\n如果你好奇编译器究竟是如何把源代码变成 ast 的，你可以去学习一下编译原理前端的内容。\n以何种方式存储这些信息？ 这个问题听起来让人有些困惑，实际上这个问题可能只有 C++ 程序员需要考虑\n其实一切原因都是 constexpr 引起的。把信息像下面这样存储起来\nstruct FieldInfo { std::string_view name; std::size_t offset; std::size_t size; }; struct Point { int x; int y; }; constexpr std::array\u0026lt;FieldInfo, 2\u0026gt; fieldInfos = {{ {\u0026#34;x\u0026#34;, offsetof(Point, x), sizeof(int)}, {\u0026#34;y\u0026#34;, offsetof(Point, y), sizeof(int)}, }}; 就意味着我们不仅仅能在运行期查询这些信息，还能在编译期查询这些信息\n更有甚者，还可以存到模板参数里面去，这样的话连类型也能存了\ntemplate \u0026lt;fixed_string name, std::size_t offset, typename Type\u0026gt; struct Field {}; using FieldInfos = std::tuple\u0026lt;Field\u0026lt;\u0026#34;x\u0026#34;, offsetof(Point, x), int\u0026gt;, Field\u0026lt;\u0026#34;y\u0026#34;, offsetof(Point, y), int\u0026gt;\u0026gt;; 这样无疑给了我们更大的操作空间，那么有了这些信息之后，下一步该做些什么？事实上我们可以选择基于这部分信息进行代码生成，相关的内容可以浏览系列文章中的其他小节。\n","permalink":"https://www.ykiko.me/zh-cn/articles/670190357/","summary":"\u003ch2 id=\"首先什么是元信息\"\u003e首先什么是元信息？\u003c/h2\u003e\n\u003cp\u003e来看下面一段 \u003ccode\u003epython\u003c/code\u003e 代码，我们希望能够根据传入的字符串来自动修改对应的字段值\u003c/p\u003e","title":"为什么说 C/C++ 编译器不保留元信息？"},{"content":"Clang 是 LLVM 项目提供的一个 C 语言家族的编译器前端。它最初开发的目的是替代 GNU Compiler Collection (GCC) 的 C 语言前端，目标是提供更快的编译速度、更好的诊断信息和更灵活的架构。Clang 包含一个 C、C++ 和 Objective-C 编译器前端，这些前端设计为可以嵌入到其他项目中。Clang 的一个重要特点是其模块化架构，使开发者能够更轻松地扩展和定制编译器的功能。Clang 被广泛应用于许多项目，包括 LLVM 自身、一些操作系统内核的开发以及一些编程语言的编译器实现。\n除了作为编译器使用之外，Clang 还可以作为一个库提供，使开发者能够在其应用程序中利用编译器的功能，例如源代码分析和生成。Clang 可以用来获取 C++ 源文件的抽象语法树 (AST)，以便进一步处理这些信息。本文将介绍如何使用 Clang 工具。\nInstallation \u0026amp; Usage 目前，Clang 被划分为以下库和工具：libsupport、libsystem、libbasic、libast、liblex、libparse、libsema、libcodegen、librewrite、libanalysis。由于 Clang 本身是用 C++ 编写的，所以相关的接口都是 C++ 的。然而，由于 C++ 接口本身的复杂性和不稳定性（例如：在 Windows 上由 GCC 编译出来的 DLL 无法给 MSVC 使用，或者 Clang 自身版本升级导致 API 变动，从而出现不兼容性），官方并不推荐优先使用 C++ 接口。\n除了 C++ 接口之外，官方还提供了一个叫做 libclang 的 C 语言接口，这个接口不仅使用起来相对简单，而且本身也比较稳定。唯一的缺点是无法获取完整的 C++ 抽象语法树 (AST)，不过鉴于 C++ 完整的语法树本身就极度复杂，很多时候我们只需要其中的一小部分信息，所以这个问题通常可以忽略，除非你真的有这方面的需求。\n如果你想要使用 libclang，你需要先安装 LLVM 和 Clang。在 LLVM Release 页面，有若干预发布的二进制包可以下载。如果你有定制化需求，请参考 Getting Started 页面进行手动编译。安装完成后，只需将 llvm/lib 目录下的 libclang.dll 链接到程序中，并包含 llvm/include 目录下的 clang-c/Index.h 头文件即可使用。\n然而，由于 C 语言没有一些高级抽象，操作字符串都很麻烦。如果大规模使用，还需要我们自己用 C++ 封装一层。幸好，官方基于这套 C 接口还提供了一个 Python 绑定，即 Clang 这个包，这使得使用起来更加方便。然而，官方提供的 Python 绑定并没有打包 libclang 的这个 DLL，因此你仍然需要在电脑上手动配置 LLVM 的环境，这可能会有些麻烦。不过，社区中有人在 PyPI 上提供了打包好的包：libclang。\n于是如果你想使用 libclang 来获取 C++ 语法树，只需要\npip install libclang 什么额外的事情都不用做。本文就基于这个 Python binding 的版本进行介绍。C 版本的 API 和 Python 版本的 API 基本是完全一致的，如果你觉得 Python 性能不够，你也可以参考这个教程对照着写 C 版本的代码。另外官方提供的包并没有 type hint，这样的话用 Python 写就没有代码补全，用起来也不舒服。我自己补了一个类型提示的 cindex.pyi，下载下来之后直接和 放在同一文件夹内就能有代码提示了。\nQuick Start 示例的 C++ 源文件代码如下\n// main.cpp struct Person { int age; const char* name; }; int main() { Person person = {1, \u0026#34;John\u0026#34;}; return 0; } 解析它的 Python 代码如下\nimport clang.cindex as CX def traverse(node: CX.Cursor, prefix=\u0026#34;\u0026#34;, is_last=True): branch = \u0026#34;└──\u0026#34; if is_last else \u0026#34;├──\u0026#34; text = f\u0026#34;{str(node.kind).removeprefix(\u0026#39;CursorKind.\u0026#39;)}: {node.spelling}\u0026#34; if node.kind == CX.CursorKind.INTEGER_LITERAL: value = list(node.get_tokens())[0].spelling text = f\u0026#34;{text}{value}\u0026#34; print(f\u0026#34;{prefix}{branch} {text}\u0026#34;) new_prefix = prefix + (\u0026#34; \u0026#34; if is_last else \u0026#34;│ \u0026#34;) children = list(node.get_children()) for child in children: traverse(child, new_prefix, child is children[-1]) index = CX.Index.create(excludeDecls=True) tu = index.parse(\u0026#39;main.cpp\u0026#39;, args=[\u0026#39;-std=c++20\u0026#39;]) traverse(tu.cursor) 输出结果如下\nTRANSLATION_UNIT: main.cpp ├── STRUCT_DECL: Person │ ├── FIELD_DECL: age │ └── FIELD_DECL: name └── FUNCTION_DECL: main └── COMPOUND_STMT: ├── DECL_STMT: │ └── VAR_DECL: person │ ├── TYPE_REF: struct Person │ └── INIT_LIST_EXPR: │ ├── INTEGER_LITERAL: 1 │ └── STRING_LITERAL: \u0026#34;John\u0026#34; └── RETURN_STMT: └── INTEGER_LITERAL: 0 前面的是语法树节点类型，后面是节点的内容。可以发现还是非常清晰的，几乎能和源代码一一对应。\nBasic Types 注意，本文假定读者对语法树有一定的认识，不在这里做过多介绍了。如果不知道语法树是什么的话，可以看一下 为什么说 C/C++ 编译器不保留元信息。下面对 cindex 中的一些常用类型做一些介绍\nCursor 相当于语法树的基本节点，整个语法树都是由 Cursor 组成的。通过 kind 属性返回一个 CursorKind 类型枚举值，就代表了这个节点实际对应的类型。\nfor kind in CursorKind.get_all_kinds(): print(kind) 这样可以打印出所有支持的节点类型，也可以直接去源码查看。Cursor 还有一些其他的属性和方法让我们使用，常用的有如下这些：\n@property def spelling(self) -\u0026gt; str: @property def displayname(self) -\u0026gt; str: @property def mangled_name(self) -\u0026gt; str: 获取节点的名字，例如一个变量声明的节点，它的 spelling 就是这个变量的名字。而 displayname 则是节点的简短名字，大多数时候和 spelling 是一样的。但是有些时候会有区别，例如一个函数的 spelling 会带上参数类型，例如 func(int)，但是它的 displayname 就只是 func。而 mangled_name 就是该符号经过 name mangling 之后用于链接的名字。\n@property def type(self) -\u0026gt; Type: 节点元素的类型，例如一个变量声明的节点，它的 type 就是这个变量的类型。或者一个字段声明的节点，它的 type 就是这个字段的类型。返回类型为 Type。\n@property def location(self) -\u0026gt; SourceLocation: 节点的位置信息，返回类型为 SourceLocation，其中携带了该节点在源码中的行数，列数，文件名等信息。\n@property def extent(self) -\u0026gt; SourceRange: 节点的范围信息，返回类型为 SourceRange，由两个 SourceLocation 组成，其中携带了该节点在源码中的起始位置和结束位置\n@property def access_specifier(self) -\u0026gt; AccessSpecifier: 节点的访问权限，返回类型为 AccessSpecifier。有 PUBLIC, PROTECTED, PRIVATE, NONE, INVALID 五种。\ndef get_children(self) -\u0026gt; iterable[Cursor]: 获取所有子节点，返回类型为 Cursor 的 iterable。这个函数是最常用的，因为我们可以通过递归的方式遍历整个语法树。\ndef get_tokens(self) -\u0026gt; iterable[Token]: 获取代表该节点的所有 token，返回类型为 Token 的 iterable。token 是语法树的最小单位，例如一个变量声明的节点，它的 token 就是 int，a，; 这三个。这个函数可以用来获取一些细节信息，例如获取整数字面量和浮点数字面量的值。\ndef is_definition(self) -\u0026gt; bool: def is_const_method(self) -\u0026gt; bool: def is_converting_constructor(self) -\u0026gt; bool: def is_copy_constructor(self) -\u0026gt; bool: def is_default_constructor(self) -\u0026gt; bool: def is_move_constructor(self) -\u0026gt; bool: def is_default_method(self) -\u0026gt; bool: def is_deleted_method(self) -\u0026gt; bool: def is_copy_assignment_operator_method(self) -\u0026gt; bool: def is_move_assignment_operator_method(self) -\u0026gt; bool: def is_mutable_field(self) -\u0026gt; bool: def is_pure_virtual_method(self) -\u0026gt; bool: def is_static_method(self) -\u0026gt; bool: def is_virtual_method(self) -\u0026gt; bool: def is_abstract_record(self) -\u0026gt; bool: def is_scoped_enum(self) -\u0026gt; bool: 这些函数基本就见名知意了，例如 is_definition 就是判断该节点是否是一个定义，is_const_method 就是判断该节点是否是一个 const 方法。\nType 如果该节点有类型的话，代表该节点的类型。常用的属性有\n@property def kind(self) -\u0026gt; TypeKind: 类型的类型，返回类型为 TypeKind。例如 INT, FLOAT, POINTER, FUNCTIONPROTO 等等。\n@property def spelling(self) -\u0026gt; str: 类型的名字，例如 int, float, void 等等。\ndef get_align(self) -\u0026gt; int: def get_size(self) -\u0026gt; int: def get_offset(self, fieldname: str) -\u0026gt; int: 获取类型的对齐，大小，字段偏移量等等。\n以及一些 is 开头的函数，例如 is_const_qualified, is_function_variadic, is_pod 等等。这里也就不多说了。\nTranslationUnit 一般来说一个 C++ 源文件就代表一个 TranslationUnit，也就是我们常说的编译单元。\n常用的有\n@property def cursor(self) -\u0026gt; Cursor: 获取该 TranslationUnit 的根节点，也就是 TRANSLATION_UNIT 类型的 Cursor。\n@property def spelling(self) -\u0026gt; str: 获取该 TranslationUnit 的文件名。\ndef get_includes(self, depth: int = -1) -\u0026gt; iterable[FileInclusion]: 获取该 TranslationUnit 的所有 include，返回类型为 FileInclusion 的 list，注意由于 include 的文件里面可能还会包含别的文件所以，可以用 depth 这个参数来限制，比如我只想获取第一层也就是直接包含的头文件可以这么写。\nindex = CX.Index.create() tu = index.parse(\u0026#39;main.cpp\u0026#39;, args=[\u0026#39;-std=c++20\u0026#39;]) for file in tu.get_includes(): if file.depth == 1: print(file.include.name) 这样就会打印出所有直接使用的头文件了。\nIndex 一个 Index 就是一个 TranslationUnit 的集合，并且最终被链接到一起，形成一个可执行文件或者库。\n有一个静态方法 create 用于创建一个新的 Index ，然后成员方法 parse 可以解析一个 C++ 源文件，返回一个 TranslationUnit。\ndef parse(self, path: str, args: list[str] | None = ..., unsaved_files: list[tuple[str, str]] | None = ..., options: int = ...) -\u0026gt; TranslationUnit: path 是源文件路径，args 是编译参数，unsaved_files 是未保存的文件，options 是一些定义在 TranslationUnit.PARSE_XXX 中的参数，例如 PARSE_SKIP_FUNCTION_BODIES 和 PARSE_INCOMPLETE。可以用来定制化解析过程，加快解析速度，或者保留宏信息等。\nExamples Namespace 由于 Clang 在解析的时候会把所有的头文件都展开，全部输出内容太多了。但是我们主要可能只是想要我们自己代码的信息，这时候就可以利用命名空间进行筛选了。示例如下：\n#include \u0026lt;iostream\u0026gt; namespace local { struct Person { int age; std::string name; }; } // namespace local 解析代码如下\nimport clang.cindex as CX def traverse_my(node: CX.Cursor): if node.kind == CX.CursorKind.NAMESPACE: if node.spelling == \u0026#34;local\u0026#34;: traverse(node) # forward to the previous function for child in node.get_children(): traverse_my(child) index = CX.Index.create() tu = index.parse(\u0026#39;main.cpp\u0026#39;, args=[\u0026#39;-std=c++20\u0026#39;]) traverse_my(tu.cursor) 写一个函数对命名空间名进行筛选，然后转发到我们之前那个函数就行，这样就只会输出我们想要的命名空间里面的内容了。\nClass \u0026amp; Struct 我们主要是获取它们里面的字段名，类型，方法名，类型等，示例如下：\nstruct Person { int age; const char* name; void say_hello(int a, char b); }; 解析代码如下\ndef traverse_class(node: CX.Cursor): match node.kind: case CX.CursorKind.STRUCT_DECL | CX.CursorKind.CLASS_DECL: print(f\u0026#34;Class: {node.spelling}:\u0026#34;) case CX.CursorKind.FIELD_DECL: print(f\u0026#34; Field: {node.spelling}: {node.type.spelling}\u0026#34;) case CX.CursorKind.CXX_METHOD: print(f\u0026#34; Method: {node.spelling}: {node.type.spelling}\u0026#34;) for arg in node.get_arguments(): print(f\u0026#34; Param: {arg.spelling}: {arg.type.spelling}\u0026#34;) for child in node.get_children(): traverse_class(child) # Class: Person: # Field: age: int # Field: name: const char * # Method: say_hello: void (int, char) # Param: a: int # Param: b: char Comment 可以获取 Doxygen 风格的注释\n@property def brief_comment(self) -\u0026gt; str: @property def raw_comment(self) -\u0026gt; str: brief_comment 获取 @brief 后面的内容，raw_comment 获取整个注释的内容。\n/** * @brief func description * @param param1 * @return int */ int func(int param1) { return param1 + 10000000; } 解析代码如下\ndef traverse_comment(node: CX.Cursor): if node.brief_comment: print(f\u0026#34;brief_comment =\u0026gt; {node.brief_comment}\u0026#34;) if node.raw_comment: print(f\u0026#34;raw_comment =\u0026gt; {node.raw_comment}\u0026#34;) for child in node.get_children(): traverse_comment(child) # brief_comment =\u0026gt; func description # raw_comment =\u0026gt; /** # * @brief func description # * @param param1 # * @return int # */ Enum 获取枚举名以及对应的枚举常量值，还有它的底层类型\nenum class Color { RED = 0, GREEN, BLUE }; 解析代码如下\ndef traverse_enum(node: CX.Cursor): if node.kind == CX.CursorKind.ENUM_DECL: print(f\u0026#34;enum: {node.spelling}, underlying type: {node.enum_type.spelling}\u0026#34;) print(f\u0026#34;is scoped?: {node.is_scoped_enum()}\u0026#34;) for child in node.get_children(): print(f\u0026#34; enum_value: {child.spelling}: {child.enum_value}\u0026#34;) for child in node.get_children(): traverse_enum(child) # enum: Color, underlying type: int # is scoped?: True # enum_value: RED: 0 # enum_value: GREEN: 1 # enum_value: BLUE: 2 Attribute C++11 加入了新的 attribute 语法：[[ ... ]]，可以用来给函数或者变量添加额外的信息。例如 [[nodiscard]] 和 [[deprecated]]。但是我们有时候会自己定义一些标记来给预处理工具使用，比如标记一个类型需要不需要生成元信息，我们也希望这些标记也能被 libclang 识别出来。但是遗憾的是如果直接写不被标准支持的属性会被 libclang 忽略，也就是最终的 AST 中是没有它的\nstruct [[Reflect]] Person {}; // ignored 一个可行的解决办法是利用 get_tokens 获取声明中的所有 token，然后自己裁剪出来。比如这里获取到的结果就是 struct,[,[,Reflect,],],Person,{,}，我们可以从中获取出我们想要的信息。\n但是 Clang 给我们提供了一种更好的办法。那就是利用 clang::annotate(...) 这个 Clang 的扩展属性，例如像下面这样\n#define Reflect clang::annotate(\u0026#34;reflect\u0026#34;) struct [[Reflect]] A {}; 这样对于 A 这个 Cursor 来说，它的子节点中就会有一个 ANNOTATE_ATTR 的类型的 Cursor，而 spelling 就是里面存的信息，这里就是 reflect。这样我们就可以很方便的获取到我们自定义的属性了。而且 C++ 标准规定了，当编译器遇到一个不认识的 attribute 的时候，它会忽略这个 attribute，而不是报错。这样的话，这个属性它就只作用于我们的预处理器，不会影响到正常编译。\nMacro Clang 在实际解析语法树之前，会把所有的预处理指令都替换成实际的代码。所以最后的语法树信息中就没有它们了。但是有些时候我们的确想要获取到这些信息，比如我们想要获取到 #define 的信息，这里需要把 parse 的 options 参数设为 TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD。如果想要获取宏的内容就用 get_tokens 就行了。\n#define CONCAT(a, b) a #b auto x = CONCAT(1, 2); 解析代码如下\ndef traverse_macro(node: CX.Cursor): if node.kind == CX.CursorKind.MACRO_DEFINITION: if not node.spelling.startswith(\u0026#39;_\u0026#39;): # Exclude internal macros print(f\u0026#34;MACRO: {node.spelling}\u0026#34;) print([token.spelling for token in node.get_tokens()]) elif node.kind == CX.CursorKind.MACRO_INSTANTIATION: print(f\u0026#34;MACRO_INSTANTIATION: {node.spelling}\u0026#34;) print([token.spelling for token in node.get_tokens()]) for child in node.get_children(): traverse_macro(child) # MACRO: CONCAT # [\u0026#39;CONCAT\u0026#39;, \u0026#39;(\u0026#39;, \u0026#39;a\u0026#39;, \u0026#39;,\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;)\u0026#39;, \u0026#39;a\u0026#39;, \u0026#39;#\u0026#39;, \u0026#39;b\u0026#39;] # MACRO_INSTANTIATION: CONCAT # [\u0026#39;CONCAT\u0026#39;, \u0026#39;(\u0026#39;, \u0026#39;1\u0026#39;, \u0026#39;,\u0026#39;, \u0026#39;2\u0026#39;, \u0026#39;)\u0026#39;] Rewrite 有时候我们希望对源代码进行一些简单的修改，在某个位置插入一段代码或者删除一段代码。这时候我们可以使用 Rewriter 这个类。示例如下：\nvoid func() { int a = 1; int b = 2; int c = 3; } 使用下面的代码对源文件进行修改\ndef rewrite(node: CX.Cursor, rewriter: CX.Rewriter): if node.kind == CX.CursorKind.VAR_DECL: if node.spelling == \u0026#34;a\u0026#34;: rewriter.replace_text(node.extent, \u0026#34;int a = 100\u0026#34;) elif node.spelling == \u0026#34;b\u0026#34;: rewriter.remove_text(node.extent) elif node.spelling == \u0026#34;c\u0026#34;: rewriter.insert_text_before(node.extent.start, \u0026#34;[[maybe_unused]]\u0026#34;) for child in node.get_children(): rewrite(child, rewriter) index = CX.Index.create() tu = index.parse(\u0026#39;main.cpp\u0026#39;, args=[\u0026#39;-std=c++20\u0026#39;]) rewriter = CX.Rewriter.create(tu) rewrite(tu.cursor, rewriter) rewriter.overwrite_changed_files() 运行之后，main.cpp 的内容就变成了\nvoid func() { int a = 100; ; [[maybe_unused]] int c = 3; } Conclusion 如果要获取类型的 size, align, offset 等 ABI 相关的内容，需要注意 platform。不同 ABI 的情况下它们的值可能不同，例如 MSVC 和 GCC 一般关于这些内容就不同，可以通过在编译参数中指定 -target 来指定目标平台。如果需要和 MSVC 一致的结果，可以使用 --target=x86_64-pc-windows-msvc。如果是 GCC 的话，可以使用 --target=x86_64-pc-linux-gnu。\n前文提到，libclang 无法提供完整的 C++ 语法树。例如，它在解析 Expr 方面缺少许多接口。这意味着，如果你需要解析具体的表达式内容，那么使用其 C++ 接口可能更为适合，因为它提供了完整且复杂的语法树。\n国内关于 Clang 工具具体使用的文章较少。本文尝试对一些常用功能进行了具体介绍，尽管并不十分完善。若有任何疑问，可直接阅读 Index.h 源码，其中的注释非常详尽。或者也可以在评论区留言，我会尽力解答。此外，若需要获取 libclang 不提供的信息，可使用 get_tokens 函数自行获取。例如，libclang 不支持获取整数和浮点数面值的值，这时可通过 get_tokens 手动获取。\n在从语法树中提取这些信息后，你可以进一步处理它们，如生成元信息或直接生成代码等。当然，这些都是后话，具体取决于你的需求。\n本文到这里就结束了，这是反射系列中的其中一篇，欢迎阅读系列中的其他文章！\n","permalink":"https://www.ykiko.me/zh-cn/articles/669360731/","summary":"\u003cp\u003eClang 是 LLVM 项目提供的一个 C 语言家族的编译器前端。它最初开发的目的是替代 GNU Compiler Collection (GCC) 的 C 语言前端，目标是提供更快的编译速度、更好的诊断信息和更灵活的架构。Clang 包含一个 C、C++ 和 Objective-C 编译器前端，这些前端设计为可以嵌入到其他项目中。Clang 的一个重要特点是其模块化架构，使开发者能够更轻松地扩展和定制编译器的功能。Clang 被广泛应用于许多项目，包括 LLVM 自身、一些操作系统内核的开发以及一些编程语言的编译器实现。\u003c/p\u003e","title":"使用 Clang 工具自由的支配 C++ 代码吧"},{"content":"引入 刚好拿最近的一个需求作为引入吧。我们都知道 markdown 可以用 lang 来填入代码块，并支持代码高亮。可是我想支持自己定义的代码高亮规则，遇到了如下问题：\n有些网站对 markdown 渲染是静态的，不能运行脚本，所以没法直接调用那些 Javascript 的代码高亮库。例如 GitHub 上面对 markdown 文件的渲染 究竟支持哪些语言一般是由渲染引擎决定的，比如 GitHub 的渲染支持和 的所支持的就不同。如果要针对不同的渲染引擎写扩展，每个都得写一份，工作量太大了，而且相关的资料很少 那真就没有办法了吗？唉，办法还是有的，幸好大多数引擎都支持直接用 html 的规则，比如 \u0026lt;code\u0026gt; 来进行渲染\n\u0026lt;code style=\u0026#34;color: #5C6370;font-style: italic;\u0026#34;\u0026gt; # this a variable named \u0026amp;#x27;a\u0026amp;#x27; \u0026lt;/code\u0026gt; 这为我们添加自定义样式提供了可能。但是我们写 markdown 的源文件不能手写这种代码的啊。如果一个语句有三种不同颜色，如果是 let a = 3; 这样的语句，意味着光一句话我们就得写三个不同的 \u0026lt;span\u0026gt;。非常难写，后面维护起来也不好维护，\n事实上我们可以这么做，读取 markdown 的源文件，源文件就按照正常的 markdown 语法写，然后我们在读取的时候，遇到 lang 的时候，把文本提取出来，然后交给负责渲染的库渲染成 dom 文本，我选择的是 highlight.js 这个库。然后把原来的文本替换掉，单独输出在新的文件夹里，比如原来的叫文件夹叫 src，新的叫 out。这样的话源文件不需要任何修改，然后实际渲染的是 out 文件夹里面的内容就好了。每次我们更改完源文件，运行一下这个程序做一下转换就行了。\n什么是 Code Generation 其实上面的案例就是一个典型的使用『代码生成』也即 code generation 来解决问题的案例。那究竟什么是代码生成呢？这其实也是一个含义相当广泛的词汇。一般来说\n代码生成是指通过使用计算机程序来生成其他程序或代码的过程\n包括但不限于：\n编译器生成目标代码： 这是最典型的例子，其中编译器将高级编程语言的源代码翻译成机器可执行的目标代码 使用配置文件或 DSL 生成代码：通过特定的配置文件或领域特定语言 (DSL)，生成实际的代码。一个示例是使用 XML 配置文件来定义 UI 界面，然后生成相应的代码 语言内建特性生成代码： 一些编程语言具有内建的特性，如宏、泛型等，可以在编译时或运行时生成代码。这样的机制可以提高代码的灵活性和重用性。 外部代码生成器： 某些框架或库使用外部代码生成器来创建所需的代码。例如，Qt 框架使用元对象编译器 (MOC) 来处理元对象系统，生成与信号和槽相关的代码。 下面就这几点来举一些具体的例子：\n编译时代码生成 宏 C 语言的 macro 就是一种最经典，也最简单的编译期代码生成技术。纯文本替换，比如我们想重复 \u0026quot;Hello World\u0026quot; 这个字符串 100 次。那怎么办呢？显然我们不想手动粘贴复制。考虑使用宏来完成这个工作\n#define REPEAT(x) (REPEAT1(x) REPEAT1(x) REPEAT1(x) REPEAT1(x) REPEAT1(x)) #define REPEAT1(x) REPEAT2(x) REPEAT2(x) REPEAT2(x) REPEAT2(x) REPEAT2(x) #define REPEAT2(x) x x x x int main() { const char* str = REPEAT(\u0026#34;Hello world \u0026#34;); } 这里主要运用了 C 语言中的一个特性就是 \u0026quot;a\u0026quot;\u0026quot;b\u0026quot; 等价于 \u0026quot;ab\u0026quot;。然后通过宏展开 5*5*4 刚好一百次，然后就轻松的完成了这个任务。 当然了 C 语言的宏由于其本质上只是 Token 替换，而且不允许使用者获取 Token 流进行输入分析，所以功能十分有限。尽管如此，还是有一些比较有意思的用法的。感兴趣的可以阅读下这篇文章 C/C++ 宏编程的艺术。当然了宏可不止 C 语言有，其他的编程语言也是有的，而且还可以支持更强的特性。例如 Rust 中的宏灵活性就比 C 语言强很多，关键就在于 Rust 允许你对输入的 Token Stream 进行分析，而不是简简单单的执行替换了，你可以根据输入 Token 的不同选择生成不同的代码。更有甚者像 Lisp 中的宏就超级灵活了。\n泛型/模板 在一些编程语言中**泛型 (Generic) **也被看作是一种代码生成的技术，根据不同的类型生成实际不同的代码。当然这是最基础的了，一些编程语言还支持更强大的特性，比如在 C++ 中还可以通过模板元编程进行一些高级的代码生成。典型的案例是在编译期打一个函数指针表（跳转表）\ntemplate \u0026lt;std::size_t N, typename T, typename F\u0026gt; void helper(T t, F f) { f(std::get\u0026lt;N\u0026gt;(t)); } template \u0026lt;typename Tuple, typename Func\u0026gt; constexpr void access(std::size_t index, Tuple\u0026amp;\u0026amp; tuple, Func\u0026amp;\u0026amp; f) { constexpr auto length = std::tuple_size\u0026lt;std::decay_t\u0026lt;decltype(tuple)\u0026gt;\u0026gt;::value; using FuncType = void (*)(decltype(tuple), decltype(f)); constexpr auto fn_table = []\u0026lt;std::size_t... I\u0026gt;(std::index_sequence\u0026lt;I...\u0026gt;) { std::array\u0026lt;FuncType, length\u0026gt; table = {helper\u0026lt;I, decltype(tuple), decltype(f)\u0026gt;...}; return table; }(std::make_index_sequence\u0026lt;length\u0026gt;{}); return fn_table[index](std::forward\u0026lt;Tuple\u0026gt;(tuple), std::forward\u0026lt;Func\u0026gt;(f)); } int main() { std::tuple a = {1, \u0026#39;a\u0026#39;, \u0026#34;123\u0026#34;}; auto f = [](auto\u0026amp;\u0026amp; v) { std::cout \u0026lt;\u0026lt; v \u0026lt;\u0026lt; std::endl; }; std::size_t index = 0; access(index, a, f); // =\u0026gt; 1 index = 2; access(index, a, f); // =\u0026gt; 123 } 这样我们就实现了根据运行期的 index 来访问 tuple 中的元素的效果了，具体原理就是手动打了一个函数指针表，然后根据索引来进行分派。\n代码生成器 上面两点说的都是语言内建的特性。然而在很多场景，语言内置的特性，不够灵活，并不能满足我们的需求。比如在 C++ 中想整块整块的生成函数和类型，那么无论是宏还是模板都做不到。\n但是代码就是源文件中的字符串而已，基于这一点想法。我们完全可以编写一个专门的程序用来生成这样的字符串。例如写一个 python 代码来生成上面那个 100 次 Hello World 的 C 程序\ns = \u0026#34;\u0026#34;; for i in range(100): s += \u0026#39;\u0026#34;Hello World \u0026#34;\u0026#39; code = f\u0026#34;\u0026#34;\u0026#34; int main() {{ const char* str = {s}; }}\u0026#34;\u0026#34;\u0026#34; with open(\u0026#34;hello.c\u0026#34;, \u0026#34;w\u0026#34;) as file: file.write(code) 好了，这样的话就生成了上面那个源文件。当然这只是最简单的应用。亦或者我们可以用 Protocol Buffer 来进行自动生成序列化和反序列化的代码。又或者我们可以从 AST 中获取信息，连类型的元信息都由代码生成器生成，这种程序的原理很简单，就是字符串拼接，而它的上限完全取决于你的代码是怎么写的。\n但是更多时候还是语言内建的特性使用的更加方便一些，使用外部的代码生成器会让编译流程变得复杂一些。然而也有一些语言，将这个特性作为了语言内置的特性之一，比如 C# 的 code generation。\n运行期代码生成 exec 好了，说了很多静态语言的特征。接下来让我们来看看足够动态的代码生成。 首先向我们走来的是 Python/JavaScript 等语言中的 eval 和 exec 等特性，这些特性允许我们在运行期直接把字符串加载为了代码并执行\neval 是一种将字符串解析为可执行代码的机制。在 Python 中，eval 函数可以接受一个字符串作为参数，并执行其中的表达式，返回结果。这为动态计算和代码生成提供了强大的工具。 result = eval(\u0026#34;2 + 3\u0026#34;) print(result) # 输出: 5 exec 与 eval 不同的是，exec 可以执行多个语句，甚至包含函数和类的定义。 Copy code code_block = \u0026#34;\u0026#34;\u0026#34; def multiply(x, y): return x * y result = multiply(4, 5) \u0026#34;\u0026#34;\u0026#34; exec(code_block) print(result) # 输出: 20 毫无疑问，仅仅通过字符串拼接就能在运行期生成代码，在合适的场景使用它们，可以轻松完成一些比较苛刻的需求。\n动态编译 现在有一个问题，C 语言能做到上面的动态编译特性吗？当然你可能会说我们可以实现一个 C 语言的解释器，那自然不就行了。但其实有更简单的办法。\n主要有两点：\n运行期编译代码 如果你的电脑上装了 gcc，则可以通过下面运行两条命令\n# 将源文件编译成目标文件 gcc -c source.c source.o # 将目标文件中的.text段提取出来，生成二进制文件 objcopy -O binary -j .text source.o source.bin 通过这样的方式就能获取 source.c 文件中代码的二进制形式了，但是光有代码还不行，我们需要执行它。\n**申请可执行内存 ** 代码也是二进制数据，只要把刚才得到的代码数据写入一块内存，然后 jmp 过去执行不就行了？想法很直接，但是很遗憾，大多数操作系统对内存都是有保护的，一般的申请内存是不可执行的。如果尝试写入数据然后执行则会直接段错误。但是我们可以通过 VirtualAlloc 或者 mmap 来申请一块有执行权限内存，然后把代码写入进去，再执行就行了。\n// Windows VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); // Linux mmap(NULL, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); 结合这两点然后稍作处理，就可以实现从命令行读取代码和输入，然后直接运行输出结果了\n#include \u0026lt;fstream\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;string\u0026gt; #ifdef _WIN32 #include \u0026lt;Windows.h\u0026gt; #define Alloc(size) VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE) #elif __linux__ #include \u0026lt;sys/mman.h\u0026gt; #define Alloc(size) mmap(NULL, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0) #endif int main(int argc, char* argv[]) { std::ofstream(\u0026#34;source.c\u0026#34;) \u0026lt;\u0026lt; argv[1]; system(\u0026#34;gcc -c source.c \u0026amp;\u0026amp; objcopy -O binary -j .text source.o source.bin\u0026#34;); std::ifstream file(\u0026#34;source.bin\u0026#34;, std::ios::binary); std::string source((std::istreambuf_iterator\u0026lt;char\u0026gt;(file)), {}); auto p = Alloc(source.size()); memcpy(p, source.c_str(), source.size()); using Fn = int (*)(int, int); std::cout \u0026lt;\u0026lt; reinterpret_cast\u0026lt;Fn\u0026gt;(p)(std::stoi(argv[2]), std::stoi(argv[3])) \u0026lt;\u0026lt; std::endl; return 0; } 最后的效果\n.\\main.exe \u0026#34;int f(int a, int b){ return a + b; }\u0026#34; 1 2 # output: 3 .\\main.exe \u0026#34;int f(int a, int b){ return a - b; }\u0026#34; 1 2 # output: -1 完美实现\n结束 本文主要介绍了代码生成的一些基本概念和示例，以及一些简单的应用。代码生成是一种非常强大的技术，如果仅仅把眼光局限在编程语言内建的特性，很多时候我们无法完成一些复杂的需求，如果将眼光放宽广一些，则会意外发现新世界。这是反射系列文章中的一篇，欢迎阅读系列其他文章！\n","permalink":"https://www.ykiko.me/zh-cn/articles/669359855/","summary":"\u003ch2 id=\"引入\"\u003e引入\u003c/h2\u003e\n\u003cp\u003e刚好拿最近的一个需求作为引入吧。我们都知道 markdown 可以用 \u003ccode\u003elang\u003c/code\u003e 来填入代码块，并支持代码高亮。可是我想支持自己定义的代码高亮规则，遇到了如下问题：\u003c/p\u003e","title":"各种姿势进行代码生成"},{"content":"What is Reflection? 反射 (Reflection) 这个词相信大家都不陌生了，也许你没用过但是你一定听过。然而，就像 CS 领域很多其他的惯用词一样，对于反射，并没有一个清晰而准确的定义。于是就会出现这种情况：对于 C#, Java, Python 这些拥有反射的语言，谈论到反射可以很自然的联想到对应语言中相关的设施，API 和代码示例，非常的具体。而对于 C, C++, Rust 这些没有反射的语言，当谈论起反射的时候，大家都不确定对方指的是什么，非常的不具体。比如有人告诉我说 Rust 有反射，他给出的例子是 Rust 的官方的文档中对 std::Any 模块 的介绍。里面提到了\nUtilities for dynamic typing or type reflection 用于动态类型或类型反射的工具\n但是尴尬就尴尬在，你说它是反射吧，功能非常鸡肋，你说它不是吧，硬要说有这种体现也不是不行。\n类似的情况在 C++ 中也经常发生。相信你也经常能听到如下观点：C++ 只有非常弱的反射即 RTTI(Run Time Type Information)，但是 C++ 的一些框架比如 QT，UE 自己实现了反射。在最近的讨论中，网上的博客中又或者 C++ 新标准的提案中，你可能又会听到所谓：\n静态反射 (static reflection) 动态反射 (dynamic reflection) 编译期反射 (compile time reflection) 运行期反射 (runtime reflection) 这样一大堆名词实在是让人听得云里雾里，晕头转向。而且 static, dynamic, compile time, runtime 这些前缀词本身也都是惯用词，经常和各种词组合起来，根据语境不同有非常多的含义。\n有的读者可能会说，我查了 WIKI，反射 明明就是有定义的啊，如下：\nIn computer science, reflective programming or reflection is the ability of a process to examine, introspect, and modify its own structure and behavior. 反射是程序具有自省，检查和修改它自身结构和行为的一种能力。\n那首先，WIKI 也是人写的，不具有绝对的权威性，如果你对这个定义不满意，是可以自己修改的。其次这里的用词也是很模糊的，什么叫自省 (introspect)？自我反省，在 CS 中这个词又是什么意思呢？所以这个定义也是很尴尬的。那怎么办呢？我选择把它拆分成几个过程进行解释，这样我们就不用去纠结「反射究竟是什么」这个概念问题了。取而代之的是，弄明白了这几个过程，自然而然的你就明白反射是在做什么事情了。\nHow to Understand Reflection? 所有语言的反射都可以看成下面这三步：\nGenerate Metadata 首先什么是元数据 (Metadata) 呢？我们在写代码的时候都会给变量，类型，结构体字段什么的取名字。这些名字主要是为了方便程序员理解和维护源代码。对于 C/C++ 来说，这些名字在编译之后通常会被丢弃，为了节省二进制空间嘛，可以理解。详细的讨论请见 为什么说 C/C++编译器不保留元信息。\n但是渐渐地，我们发现某些情况下是需要这些数据的。比如把结构体序列化成 json 的时候就需要结构体字段名，在打印日志的时候不希望打印枚举值，而是直接打印对应的枚举名。怎么办呢？早期，只能通过 hard code 的方式，也就是手写，高级点的可能来点宏。这样其实是很不方便的，不利于后续的代码维护。\n后来有一些语言，例如 Java 和 C#。他们的编译器在编译的时候会保留包括这些名字在内的很多数据，这些数据就叫做元数据（Metadata）。同时，也有一些手段允许程序员自己附加元数据在某些结构上，例如 C# 的 attribute，Java 的 annotation。\n对于 C++ 来说呢？目前 C++ 编译器只会保留类型名用于实现 RTTI，即标准中 std::type_info 的相关设施。其他的信息，编译器都会抹除掉。怎么办呢？手动编写元数据对于少量的类来说还可以接受，但当项目规模增大时，例如有几十或上百个类时，将变得非常繁琐和容易出错。其实，我们可以在实际编译之前，运行一个脚本来负责生成这些数据，也就是所谓的代码生成 (Code Generation)。相关内容请参考 使用 Clang 工具自由的支配 C++ 代码吧。\nQuery Metadata 生成完之后，接下来就是查询元数据了。很多语言内置的反射模块，例如 Python 的 inspect，Java 的 Reflection，C# 的 System.Reflection，其实就是封装了一些操作，使得用户不用直接接触原始的元数据，用起来更加方便。\n值得注意的是，上面这些案例中的查询都是发生在运行时的。在运行时根据字符串进行搜索和匹配，这其实是一个比较慢的过程，所以我们常说反射调用方法比正常调用方法慢。\n对于 C++ 来说，编译器提供了一些有限的接口让我们在编译时访问（反射）一些信息，例如使用 decltype 可以获取一个变量的类型，进一步还能判断两个变量类型是否相等，是否是某个类型的子类等等，但是功能十分有限。\n不过，可以按照上一小节的方法自己生成元信息，把它们都标记为 constexpr，然后就可以在编译期进行查询。事实上 C++26 的静态反射也就是这个思路，由编译器生成元信息，暴露给用户一些接口进行查询。相关内容请见 C++26 静态反射提案解析。而查询的时机也就是所谓的动态反射和静态反射的区别。\n当然了，编译期可做的事情肯定是没有运行期那么多的，例如希望根据运行时的类型名创建类实例，无论如何编译期肯定没法做到。但你可以基于这些静态的元信息构建动态反射。相关内容请见 在 C++ 中实现 Object。\nOperate Metadata 然后，就是根据元数据进行进一步的操作，比如代码生成。这个在 C++ 中可以理解为编译期的代码生成，在 Java 和 C# 则可以认为是运行期的代码生成。详见 各种姿势进行代码生成 。\nConclusion 最后，我们来用上面三个步骤分解一下不同语言中的反射：\nPython, JavaScript, Java, C#：由编译器/解释器生成元数据，标准库提供接口，用户可以在运行时查询元数据，同时由于有虚拟机（VM），可以方便地生成代码。 Go：由编译器生成元数据，标准库提供接口，用户可以在运行时查询元数据。但是由于 Go 主要是 AOT（Ahead-of-Time）编译，运行时生成代码并不方便。 Zig, C++26 静态反射：由编译器生成元数据，标准库提供接口，用户可以在编译时查询元数据。同样由于是 AOT 编译，运行时生成代码并不方便，但是可以在编译期进行代码生成。\n而 QT 和 UE 则是通过代码生成，自己生成了元数据，封装了接口，用户可以在运行时查询元数据。实现原理上类似 Go 的反射。\n希望这个系列教程对你有所帮助！如果有错误欢迎评论区讨论，感谢你的阅读。\n","permalink":"https://www.ykiko.me/zh-cn/articles/669358870/","summary":"\u003ch2 id=\"what-is-reflection\"\u003eWhat is Reflection?\u003c/h2\u003e\n\u003cp\u003e反射 (Reflection) 这个词相信大家都不陌生了，也许你没用过但是你一定听过。然而，就像 CS 领域很多其他的\u003cstrong\u003e惯用词\u003c/strong\u003e一样，对于反射，并没有一个清晰而准确的定义。于是就会出现这种情况：对于 C#, Java, Python 这些拥有反射的语言，谈论到反射可以很自然的联想到对应语言中相关的设施，API 和代码示例，非常的具体。而对于 C, C++, Rust 这些没有反射的语言，当谈论起反射的时候，大家都不确定对方指的是什么，非常的不具体。比如有人告诉我说 Rust 有反射，他给出的例子是 Rust 的官方的文档中对 \u003ca href=\"https://doc.rust-lang.org/stable/std/any/index.html\"\u003estd::Any 模块\u003c/a\u003e 的介绍。里面提到了\u003c/p\u003e","title":"写给 C++ 程序员的反射教程"},{"content":"最近打算写一个系列文章详细讨论反射（reflection）这一概念，刚好 C++26 有了新的反射提案，发现知乎上又没有相关的文章，而这个话题又经常被讨论。所以借此机会来聊一聊属于 C++ 的静态反射（static reflection），作为系列预热了。\n本文已经过时，静态反射已经正式进入 C++26，请移步 Reflection for C++26!!!\nWhat is Static Reflection? 首先反射是指什么呢？这个词就像计算机科学领域很多其他的惯用词一样，并没有详细而准确的定义。关于这个问题，我不打算在这个文章讨论，后续的文章我会详细的解释。本文的重点是 C++ 的 static reflection，为什么强调 static 呢？主要是因为平常我们谈论到反射的时候几乎总是指 Java，C#，Python 这些语言中的反射，而它们的实现方式无一不是把类型擦除，在运行期进行信息的查询。这种方式当然有不可避免的运行时开销，而这种开销显然是违背了 C++ zero cost abstraction 的原则的。为了和它们的反射区分开来，故加上 static 作为限定词，也指示了 C++ 的反射是在编译期完成的。当然，这种说法仍然缺乏一些严谨性。详细的讨论在后续的文章给出，你只需要知道 C++ 的静态反射和 Java，C#，Python 的反射不同，并且主要是在编译期完成的就行了。\nWhat can static reflection do? Type as Value 我们都知道随着 C++ 版本的不断更新，编译期计算的功能在不断的增强，通过 constexpr/consteval 函数我们能很大程度上直接复用运行期的代码，方便的进行编译期计算。完全取代了很久之前使用模板元进行编译期计算的方法。不仅写起来更加方便，编译速度也更快。\n观察下面几段编译期计算阶乘的代码：\n在 C++03/98 的时候，我们只能通过模板递归实例化来实现，而且无法将代码复用到运行期\ntemplate \u0026lt;int N\u0026gt; struct factorial { enum { value = N * factorial\u0026lt;N - 1\u0026gt;::value }; }; template \u0026lt;\u0026gt; struct factorial\u0026lt;0\u0026gt; { enum { value = 1 }; }; C++11 中第一次引入了 constexpr 函数的概念，使得我们可以编写编译期和运行期复用的代码。但是限制很多，没有变量和循环，我们只能按照纯函数式的风格来编写代码\nconstexpr int factorial(int n) { return n == 0 ? 1 : n * factorial(n - 1); } int main() { constexpr std::size_t a = factorial(5); // 编译期计算 std::size_t\u0026amp; n = *new std::size_t(6); std::size_t b = factorial(n); // 运行期计算 std::cout \u0026lt;\u0026lt; a \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; b \u0026lt;\u0026lt; std::endl; } 随着 C++14/17 的到来，constexpr 函数中的限制被进一步放开，现在能在 constexpr 函数中使用局部变量和循环了，就像下面这样\nconstexpr std::size_t factorial(std::size_t N) { std::size_t result = 1; for(std::size_t i = 1; i \u0026lt;= N; ++i) { result *= i; } return result; } C++20 之后，我们还可以在编译期使用 new/delete，我们可以在编译期代码里面使用 vector。很多运行期的代码可以直接在编译期复用，而不需要任何更改，只需要在函数前面加上一个 constexpr 标记，再也不用为了进行编译期计算而使用晦涩难懂的模板元编程了。但是，上面的示例仅仅适用于 value，在 C++ 里面除了 value 还有 type 和 higher kind type\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list; template \u0026lt;typename T, typename U, typename... Ts\u0026gt; struct find_first_of { constexpr static auto value = find_first_of\u0026lt;T, Ts...\u0026gt;::value + 1; }; template \u0026lt;typename T, typename... Ts\u0026gt; struct find_first_of\u0026lt;T, T, Ts...\u0026gt; { constexpr static std::size_t value = 0; }; static_assert(find_first_of\u0026lt;int, double, char, int, char\u0026gt;::value == 2); 由于 type 和 higher kind type 只能是 template arguments，所以还是只能通过模板递归匹配处理它们。要是我们能像 value 一样操作它们就好了，这样的话 constexpr 函数也能处理它们了。但是 C++ 又不是像 Zig 那样的语言，type is value。怎么办呢？没关系，我们把 type 映射到 value 不就行了？实现 type as value 的效果。在静态反射加入之前，我们可以通过一些 trick 来实现这个效果。可以在编译期把类型映射到类型名，于是只要对类型名进行计算就好了。关于如何进行这种映射，可以参考 C++ 中如何优雅进行 enum 到 string 的转换。\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list {}; template \u0026lt;typename T, typename... Ts\u0026gt; constexpr std::size_t find(type_list\u0026lt;Ts...\u0026gt;) { // type_name 用于获取编译期类型名 std::array arr{type_name\u0026lt;Ts\u0026gt;()...}; for(auto i = 0; i \u0026lt; arr.size(); i++) { if(arr[i] == type_name\u0026lt;T\u0026gt;()) { return i; } } } 非常直观的代码，但是如果我们想把值映射回类型就比较困难了。不过没关系，在即将到来的 static reflection 中，这种类型和值的双向映射已经成为语言特性了，我们不再需要去手动处理了。\n使用 ^ 运算符将类型映射到值\nconstexpr std::meta::info value = ^int; 使用 [: ... :] 将它映射回去，注意这是 symbol 级别的映射\nusing Int = typename[:value:]; // 在此语境下，typename 可以省略 typename[:value:] a = 3; // 相当于 int a = 3; 现在我们就能写出下面这样的代码了。\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list { constexpr static std::array types = {^Ts...}; template \u0026lt;std::size_t N\u0026gt; using at = typename[:types[N]:]; }; using Second = typename type_list\u0026lt;int, double, char\u0026gt;::at\u0026lt;1\u0026gt;; static_assert(std::is_same_v\u0026lt;Second, double\u0026gt;); 再也不用递归匹配了，我们可以把类型像值一样计算。只要理解了这种映射关系，代码写起来非常简单。用于类型计算的模板元可以退出历史舞台了！\n^ 其实不仅能够映射类型，主要有下面这些功能:\n^:: —— 代表全局命名空间 ^namespace-name—— 命名空间名称 ^type-id—— 类型 ^cast-expression —— 特殊表达式，目前包括包括： 表示函数或者成员函数的主表达式 表示变量，静态成员变量，结构化绑定的主表达式 表示非静态成员的主表达式 表示模板的主表达式 常量表达式 同样的 [: ... :] 也能还原成对应的东西，注意是还原到对应的符号，所以这个运算符被叫做，拼接器（Splicers）\n[: r :] —— 还原成对应的实体或者表达式 typename[: r :] —— 还原成对应的类型 template[: r :] —— 还原成模板参数 namespace[: r :] —— 还原成命名空间 [:r:]:: —— 还原成对应的命名空间，类，枚举嵌套说明符 看下面的使用示例\nint x = 0; void g() { [:^x:] = 42; // Okay. Same as: x = 42; } 如果还原的东西和原本储存的不一样，则会编译错误\ntypename[:^:::] x = 0; // Error metainfo 光是上面一个特性，就足以让人心动了。然而远远不止如此，获取 class 等实体元信息的功能也有了。\n最基础的，获取类型名（变量名，字段名都可以用这个函数）\nnamespace std::meta { consteval auto name_of(info r) -\u0026gt; string_view; consteval auto display_name_of(info r) -\u0026gt; string_view; } // namespace std::meta 比如可以\ndisplay_name_of(^std::vector\u0026lt;int\u0026gt;); // =\u0026gt; std::vector\u0026lt;int\u0026gt; name_of(^std::vector\u0026lt;int\u0026gt;); // =\u0026gt; std::vector\u0026lt;int, std::allocator\u0026lt;int\u0026gt;\u0026gt; 判断一个模板是不是另一个高阶模板的特化 和 萃取高阶模板里面的参数\nnamespace std::meta { consteval auto template_of(info r) -\u0026gt; info; consteval auto template_arguments_of(info r) -\u0026gt; vector\u0026lt;info\u0026gt;; } // namespace std::meta std::vector\u0026lt;int\u0026gt; v = {1, 2, 3}; static_assert(template_of(type_of(^v)) == ^std::vector); static_assert(template_arguments_of(type_of(^v))[0] == ^int); 把模板参数填到高阶模板中去\nnamespace std::meta { consteval auto substitute(info templ, span\u0026lt;const info\u0026gt; args) -\u0026gt; info; } constexpr auto r = substitute(^std::vector, std::vector{^int}); using T = [:r:]; // Ok, T is std::vector\u0026lt;int\u0026gt; 获取 struct、class、union、enum 的成员信息\nnamespace std::meta { template \u0026lt;typename... Fs\u0026gt; consteval auto members_of(info class_type, Fs... filters) -\u0026gt; vector\u0026lt;info\u0026gt;; template \u0026lt;typename... Fs\u0026gt; consteval auto nonstatic_data_members_of(info class_type, Fs... filters) -\u0026gt; vector\u0026lt;info\u0026gt; { return members_of(class_type, is_nonstatic_data_member, filters...); } template \u0026lt;typename... Fs\u0026gt; consteval auto bases_of(info class_type, Fs... filters) -\u0026gt; vector\u0026lt;info\u0026gt; { return members_of(class_type, is_base, filters...); } template \u0026lt;typename... Fs\u0026gt; consteval auto enumerators_of(info class_type, Fs... filters) -\u0026gt; vector\u0026lt;info\u0026gt;; template \u0026lt;typename... Fs\u0026gt; consteval auto subobjects_of(info class_type, Fs... filters) -\u0026gt; vector\u0026lt;info\u0026gt;; } // namespace std::meta 待会用这个我们就可以实现遍历结构体，枚举等功能。进一步就可以实现序列化，反序列化等高级功能。后文会有一些示例。除此之外，还有一些其他的功能的编译期函数，上面只展示了一部分内容，更多的 API 可以参考提案中的内容。由于提供了直接获取高级模板里面参数的函数，再也不用用模板去进行类型萃取了！用于类型萃取的模板元也可以退出历史舞台了。\nBetter compile facilities 反射的主题部分大致已经介绍完了，现在来聊聊其他的。虽然这部分是其他提案的内容，但是它们可以使代码写起来更加轻松，让代码有更强的表达能力。\ntemplate for 在 C++ 里面如何生成大量的代码段是一个非常不好解决的问题，得益于 C++ 独（逆）特（天）的机制，目前的代码片段生成几乎都是基于 lambda 表达式 + 可变参数包展开。看下面的例子\nconstexpr auto dynamic_tuple_get(std::size_t N, auto\u0026amp; tuple) { constexpr auto size = std::tuple_size_v\u0026lt;std::decay_t\u0026lt;decltype(tuple)\u0026gt;\u0026gt;; [\u0026amp;]\u0026lt;std::size_t... Is\u0026gt;(std::index_sequence\u0026lt;Is...\u0026gt;) { auto f = [\u0026amp;]\u0026lt;std::size_t Index\u0026gt; { if(Index == N) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;Index\u0026gt;(tuple) \u0026lt;\u0026lt; std::endl; } }; (f.template operator()\u0026lt;Is\u0026gt;(), ...); }(std::make_index_sequence\u0026lt;size\u0026gt;{}); } int main() { std::tuple tuple = {1, \u0026#34;Hello\u0026#34;, 3.14, 42}; auto n1 = 0; dynamic_tuple_get(n1, tuple); // 1 auto n2 = 3; dynamic_tuple_get(n2, tuple); // 42 } 一个很经典的例子，原理是通过多个分支判断，将运行期变量分发到编译期常量。实现根据运行期的 index 来访问 tuple 里面的元素。注：这里效率更高的办法是，编译期生成一个函数指针数组，然后直接根据 index 进行跳转，不过这里只是做个展示，不用纠结太多。\n上面的代码展开后相当于\nconstexpr auto dynamic_tuple_get(std::size_t N, auto\u0026amp; tuple) { if(N == 0) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;0\u0026gt;(tuple) \u0026lt;\u0026lt; std::endl; } // ... if(N == 3) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;3\u0026gt;(tuple) \u0026lt;\u0026lt; std::endl; } } 可以发现，我们用了极其别扭的写法只是为了实现极其简单的效果。而且由于 lambda 其实是个函数，其实没法直接从 lambda 里面直接返回到上一级函数。导致我们多做了很多多余的 if 判断。\n换成 template for 则代码看起来清爽很多\nconstexpr void dynamic_tuple_get(std::size_t N, auto\u0026amp; tuple) { constexpr auto size = std::tuple_size_v\u0026lt;std::decay_t\u0026lt;decltype(tuple)\u0026gt;\u0026gt;; template for(constexpr auto num: std::views::iota(0, size)) { if(num == N) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;num\u0026gt;(tuple) \u0026lt;\u0026lt; std::endl; return; } } } 可以认为 template for 是 lambda 展开的语法糖加强版，反正非常好用就是了。如果这个加入了，利用模板元生成函数（代码）就可以退休了。\nnon-transient constexpr allocation 这个提案主要是将两个问题联合起来讨论了。\nC++ 可以通过控制模板实例化 static 成员在数据段预留位置，可以看作编译期内存分配 template \u0026lt;auto... items\u0026gt; struct make_array { using type = std::common_type_t\u0026lt;decltype(items)...\u0026gt;; inline static type value[sizeof...(items)] = {items...}; }; template \u0026lt;auto... items\u0026gt; constexpr auto make_array_v = make_array\u0026lt;items...\u0026gt;::value; int main() { constexpr auto arr = make_array_v\u0026lt;1, 2, 3, 4, 5\u0026gt;; std::cout \u0026lt;\u0026lt; arr[0] \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; arr[1] \u0026lt;\u0026lt; std::endl; // 成功在数据段预留位置，存放的是 1 2 3 4 5 } C++20 允许了 constexpr 中进行 new，但是编译期 new 的内存必须在编译期 delete。 constexpr auto size(auto... Is) { std::vector\u0026lt;int\u0026gt; v = {Is...}; return v.size(); } 那就不能在编译期 new 之后，不 delete？实际数据放在数据段？这就是这个提案要解决的问题，它希望我们能使用\nconstexpr std::vector\u0026lt;int\u0026gt; v = {1, 2, 3, 4, 5}; // 全局的 主要难点是，在数据段分配的内存不像在堆上的内存一样有所有权，不需要 delete。只要解决了这个问题，就能使用编译期的 std::map，std::vector 并且保留到运行期。这个作者的做法是进行标记。具体的细节这里就不说了。如果这个加入了，利用模板元打常量表也可以退出了。\nSome examples 好了，上面说了那么多，让我们看看用反射我们都能干些什么\nprint any type template \u0026lt;typename T\u0026gt; constexpr auto print(const T\u0026amp; t) { template for(constexpr auto member: nonstatic_data_members_of(type_of(^t))) { if constexpr(is_class(type_of(member))) { // 如果是 class 就递归遍历成员 println(\u0026#34;{}= \u0026#34;, name_of(member)); print(t.[:member:]); } else { // 非类类型可以直接打印 std::println(\u0026#34;{}= {}\u0026#34;, name_of(member), t.[:member:]); } } } enum to string template \u0026lt;typename E\u0026gt; requires std::is_enum_v\u0026lt;E\u0026gt; constexpr std::string enum_to_string(E value) { template for(constexpr auto e: std::meta::members_of(^E)) { if(value == [:e:]) { return std::string(std::meta::name_of(e)); } } return \u0026#34;\u0026lt;unnamed\u0026gt;\u0026#34;; } conclusion 花费了很长的篇幅介绍 C++ 的 static reflection。其实我非常喜欢 C++ 的编译期计算，对它的发展史也非常感兴趣。C++ 的编译期计算是一步步摸索出来的，有很多富有智慧的大师提出他们的独特想法，让不可能的事情变成现实。从 C++03 的变态模板元，到 C++11 的 constexpr 变量，到 C++14 ~23 对 constexpr 函数中的限制逐渐放开，把越来越多的操作移到编译期。再到如今的 static reflection，C++ 正在逐步脱离模板元的魔爪。之前那些老旧的模板元写法全都可以淘汰掉了！！！如果你没写过以前的老式模板元代码，大概是体会不到它有多可怕的。\n为了让静态反射能早点进入标准，作者团队特地选了原本提案的一部分核心子集。希望如作者所愿，静态反射能在 C++26 进入标准！当然，核心部分先进入，之后再补充更多更加有用的功能，所以这绝不是反射的全部内容。\n实验编译器：\n在线尝试： https://godbolt.org/z/13anqE1Pa 本地构建： Clang-p2996 反射系列文章：写给 C++ 程序员的反射教程\n","permalink":"https://www.ykiko.me/zh-cn/articles/661692275/","summary":"\u003cp\u003e最近打算写一个系列文章详细讨论反射（reflection）这一概念，刚好 C++26 有了新的反射提案，发现知乎上又没有相关的文章，而这个话题又经常被讨论。所以借此机会来聊一聊属于 C++ 的静态反射（static reflection），作为系列预热了。\u003c/p\u003e","title":"C++26 静态反射提案解析"},{"content":"Introduction 在 C++ 中，形如 \u0026amp;T::name 的表达式返回的结果就是成员指针。写代码的时候偶尔会用到，但是这个概念可能很多人都并不熟悉。考虑如下代码\nstruct Point { int x; int y; }; int main() { Point point; *(int*)((char*)\u0026amp;point + offsetof(Point, x)) = 20; *(int*)((char*)\u0026amp;point + offsetof(Point, y)) = 20; } 在 C 语言中，我们经常通过这样计算 offset 的方式来访问结构体成员。如果把它封装成函数，还能用来根据传入的参数动态访问结构体的成员变量。然而上面的代码在 C++ 中是 undefined behavior，具体的原因可以参考 Stack Overflow 上的这个讨论。但是如果我们确实有这样需求，那该怎么合法的实现需求呢？C++ 为我们提供了一层抽象：pointers to members，用来合法进行这样的操作。\nUsage pointer to data member 一个指向类 C 非静态成员 m 的成员指针可以用 \u0026amp;C::m 进行初始化。当在 C 的成员函数里面使用 \u0026amp;C::m 会出现二义性。即既可以指代对 m 成员取地址 \u0026amp;this-\u0026gt;m，也可以指代成员指针。为此标准规定，\u0026amp;C::m 表示成员指针，\u0026amp;(C::m) 或者 \u0026amp;m 表示对 m 成员取地址。可以通过运算符 .* 和 -\u0026gt;* 来访问对应的成员。示例代码如下\nstruct C { int m; void foo() { int C::* x1 = \u0026amp;C::m; // pointer to member m of C int* x2 = \u0026amp;(C::m); // pointer to member this-\u0026gt;m } }; int main() { int C::* p = \u0026amp;C::m; // type of a member pointer is: T U::* // T is the type of the member, U is the class type // here, T is int, U is C C c = {7}; std::cout \u0026lt;\u0026lt; c.*p \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // same as c.m, print 7 C* cp = \u0026amp;c; cp-\u0026gt;m = 10; std::cout \u0026lt;\u0026lt; cp-\u0026gt;*p \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // same as cp-\u0026gt;m, print 10 } 指向基类的数据成员指针 可以隐式转换成 **非虚继承 **的派生类数据成员指针 struct Base { int m; }; struct Derived1 : Base {}; // non-virtual inheritance struct Derived2 : virtual Base {}; // virtual inheritance int main() { int Base::* bp = \u0026amp;Base::m; int Derived1::* dp = bp; // ok, implicit cast int Derived2::* dp2 = bp; // error Derived1 d; d.m = 1; std::cout \u0026lt;\u0026lt; d.*dp \u0026lt;\u0026lt; \u0026#39; \u0026#39; \u0026lt;\u0026lt; d.*bp \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // ok, prints 1 1 } 根据传入的指针，动态访问结构体字段 struct Point { int x; int y; }; auto\u0026amp; access(Point\u0026amp; point, auto pm) { return point.*pm; } int main() { Point point; access(point, \u0026amp;Point::x) = 10; access(point, \u0026amp;Point::y) = 20; std::cout \u0026lt;\u0026lt; point.x \u0026lt;\u0026lt; \u0026#39; \u0026#39; \u0026lt;\u0026lt; point.y \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 10 20 } } pointer to member function 一个指向非静态成员函数 f 的成员指针可以用 \u0026amp;C::f 进行初始化。由于不能对非静态成员函数取地址，\u0026amp;(C::f) 和 \u0026amp;f 什么都不表示。类似的可以通过运算符 .* 和 -\u0026gt;* 来访问对应的成员函数。如果成员函数是重载函数，想要获取对应的成员函数指针，请参考 如何获取重载函数的地址。示例代码如下\nstruct C { void foo(int x) { std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } }; int main() { using F = void(int); // function type using MP = F C::*; // pointer to member function using T = void (C::*)(int); // pointer to member function static_assert(std::is_same_v\u0026lt;MP, T\u0026gt;); auto mp = \u0026amp;C::foo; T mp2 = \u0026amp;C::foo; static_assert(std::is_same_v\u0026lt;decltype(mp), T\u0026gt;); C c; (c.*mp)(1); // call foo, print 1 C* cp = \u0026amp;c; (cp-\u0026gt;*mp)(2); // call foo, print 2 } 指向基类的成员函数指针 可以隐式转换成 **非虚继承 **的派生类成员函数指针 struct Base { void f(int) {} }; struct Derived1 : Base {}; // non-virtual inheritance struct Derived2 : virtual Base {}; // virtual inheritance int main() { void (Base::*bp)(int) = \u0026amp;Base::f; void (Derived1::*dp)(int) = bp; // ok, implicit cast void (Derived2::*dp2)(int) = bp; // error Derived1 d; (d.*dp)(1); // ok } 根据传入参数动态调用成员函数 struct C { void f(int x) { std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } void g(int x) { std::cout \u0026lt;\u0026lt; x + 1 \u0026lt;\u0026lt; std::endl; } }; auto access(C\u0026amp; c, auto pm, auto... args) { return (c.*pm)(args...); } int main() { C c; access(c, \u0026amp;C::f, 1); // 1 access(c, \u0026amp;C::g, 1); // 2 } Implementation 首先要明确的是，C++ 标准并没有规定成员指针是什么实现的。在这一点上和虚函数一样，即标准没有规定虚函数是怎么实现的，只规定了虚函数的行为。所以成员指针相关的实现完全是 implementation defined。本来只需要了解怎么使用就足够了，不要关心底层实现。但是奈何网络上相关话题的错误文章太多了，已经严重的产生了误导，所以有必要进行澄清。\n对于三大主流编译器，GCC 遵循 Itanium C++ ABI ，MSVC 则遵守 MSVC C++ ABI，Clang 通过不同的编译选项可以分别设置为这两种 ABI。关于 ABI 的详细讨论请移步 彻底理解 C++ ABI 和 MSVC 与 GCC 产生的动态库如何才能相互替换，这里不过多介绍。\nItanium ABI 具有公开的文档，之后的相关描述主要参考这个文档 MSVC ABI 没有公开的文档，之后的相关描述主要参考 MSVC C++ ABI Member Function Pointers 这篇博客 请注意：文章具有时效性，未来的实现可能会改变，仅作参考，以官方文档为准。\n首先尝试打印一个成员指针的值\nstruct C { int m; void foo(int x) { std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } }; int main() { int C::* p = \u0026amp;C::m; void (C::*p2)(int) = \u0026amp;C::foo; std::cout \u0026lt;\u0026lt; p \u0026lt;\u0026lt; std::endl; // 1 std::cout \u0026lt;\u0026lt; p2 \u0026lt;\u0026lt; std::endl; // 1 } 输出的结果都是 1。鼠标移到 \u0026lt;\u0026lt; 就会发现，这是发生了到 bool 的隐式类型转换。\u0026lt;\u0026lt; 并没有重载成员指针类型。我们只能通过一些手段查看它的二进制值表示。\nItanium C++ ABI pointer to data member 一般来说可以用下述结构体表示，数据成员指针。表示相对于对象首地址的偏移量。如果是 nullptr 则里面存的是 -1。此时成员指针大小就是 sizeof(ptrdiff_t)。\nstruct data_member_pointer { ptrdiff_t offset; }; 如前文所述，C++ 标准不允许沿着虚继承链进行成员指针转换。所以在编译期根据继承关系就可以算出转换需要的 offset，而不需要在运行期去查虚表。\nstruct A { int a; }; struct B { int b; }; struct C : A, B {}; void log(auto mp) { std::cout \u0026lt;\u0026lt; \u0026#34;offset is \u0026#34; \u0026lt;\u0026lt; *reinterpret_cast\u0026lt;ptrdiff_t*\u0026gt;(\u0026amp;mp) // or use std::bit_cast after C++20 // std::bit_cast\u0026lt;std::ptrdiff_t\u0026gt;(mp) \u0026lt;\u0026lt; std::endl; } int main() { auto a = \u0026amp;A::a; log(a); // offset is 0 auto b = \u0026amp;B::b; log(b); // offset is 0 int C::* c = a; log(c); // offset is 0 // implicit cast int C::* c2 = b; log(c2); // offset is 4 } pointer to member function 在主流的平台上，一般来说可以用下述结构体表示，成员函数指针:\nstruct member_function_pointer { std::ptrdiff_t ptr; // function address or vtable offset // if low bit is 0, it\u0026#39;s a function address, otherwise it\u0026#39;s a vtable offset ptrdiff_t offset; // offset to the base(unless multiple inheritance, it\u0026#39;s always 0) }; 这个实现依赖于一些大多数平台的假定：\n考虑到地址对齐，非静态成员函数的地址最低位几乎总是 0 空的函数指针是 0，所以空函数指针可以和虚表偏移量区分开来 体系结构是字节寻址，并且指针大小是偶数，所以虚表偏移量是偶数 只要知道虚表的地址，虚表偏移量和函数类型就可以进行函数调用，具体的实现细节由编译器根据 ABI 来决定 当然也有一些平台不满足上述假设，例如 ARM32 平台的某些情况，这时候它的实现方式就和我们刚才说的不同了。所以你现在应该能更加理解什么叫实现定义的行为了，即使编译器相同，但是目标平台不同，实现都有可能不同。\n在我的环境 x64 Windows 上，符合主流实现的要求。于是对着这个 ABI，进行了\u0026quot;解糖\u0026quot;。\nstruct member_func_pointer { std::size_t ptr; ptrdiff_t offset; }; template \u0026lt;typename Derived, typename Ret, typename Base, typename... Args\u0026gt; Ret invoke(Derived\u0026amp; object, Ret (Base::*ptr)(Args...), Args... args) { Ret (Derived::*dptr)(Args...) = ptr; member_func_pointer mfp = *(member_func_pointer*)(\u0026amp;dptr); using func = Ret (*)(void*, Args...); void* self = (char*)\u0026amp;object + mfp.offset; func fp = nullptr; bool is_virtual = mfp.ptr \u0026amp; 1; if(is_virtual) { auto vptr = (char*)(*(void***)self); auto voffset = mfp.ptr - 1; auto address = *(void**)(vptr + voffset); fp = (func)address; } else { fp = (func)mfp.ptr; } return fp(self, args...); } struct A { int a; A(int a) : a(a) {} virtual void foo(int b) { std::cout \u0026lt;\u0026lt; \u0026#34;A::foo \u0026#34; \u0026lt;\u0026lt; a \u0026lt;\u0026lt; b \u0026lt;\u0026lt; std::endl; } void bar(int b) { std::cout \u0026lt;\u0026lt; \u0026#34;A::bar \u0026#34; \u0026lt;\u0026lt; a \u0026lt;\u0026lt; b \u0026lt;\u0026lt; std::endl; } }; int main() { A a = {4}; invoke(a, \u0026amp;A::foo, 3); // A::foo 43 invoke(a, \u0026amp;A::bar, 3); // A::bar 43 } MSVC C++ ABI MSVC 对于此的实现非常复杂，还对 C++ 标准进行了扩展。如果想要细致全面的了解，还是建议阅读上面那篇博客。\nC++ 标准不允许虚基类成员指针向子类成员指针转换，但是 MSVC 允许。\nstruct Base { int m; }; struct Derived1 : Base {}; // non-virtual inheritance struct Derived2 : virtual Base {}; // virtual inheritance int main() { int Base::* bp = \u0026amp;Base::m; int Derived1::* dp = bp; // ok, implicit cast int Derived2::* dp2 = bp; // ok in MSVC， error in GCC } 为了不浪费空间，即使在同一程序中 MSVC 的成员指针大小也可能各不相同（Itanium 中由于统一实现，所以都是一样大的）。MSVC 对不同情况做了不同处理。\n另外请注意 MSVC 对于虚继承的是实现和 Itanium 也是不一样的。可以参考 C++中虚函数、虚继承内存模型 这篇文章中的相关介绍。\npointer to data member 对于非虚继承的情况下，实现的和 GCC 类似。除了大小有点区别。64 位程序中 GCC 是 8 字节，MSVC 是 4 字节。都是用 -1 表示 nullptr。\nstruct data_member_pointer { int offset; }; 对于虚继承的情况下（标准扩展），需要额外存储一个 voffset。用于运行期从虚表里面找到对应虚基类成员的 offset。\nstruct Base { int m; }; struct Base2 { int n; }; struct Base3 { int n; }; struct Derived : virtual Base, Base2, Base3 {}; struct dmp { int offset; int voffset; }; template \u0026lt;typename T\u0026gt; void log(T mp) { dmp d = *reinterpret_cast\u0026lt;dmp*\u0026gt;(\u0026amp;mp); std::cout \u0026lt;\u0026lt; \u0026#34;offset is \u0026#34; \u0026lt;\u0026lt; d.offset \u0026lt;\u0026lt; \u0026#34;, voffset is \u0026#34; \u0026lt;\u0026lt; d.voffset \u0026lt;\u0026lt; std::endl; } int main() { int Derived::* dp = \u0026amp;Base::m; log(dp); // offset is 0, voffset is 4 dp = \u0026amp;Base3::n; log(dp); // offset is 4, voffset is 0 } pointer to member function 对于成员函数指针就更复杂了，有四种情况：\n非虚继承，非多继承 struct member_function_ptr { void* address; }; 非虚继承，多继承 struct member_function_ptr { void* address; int offset; }; 虚继承，多继承 struct member_function_ptr { void* address; int offset; int vindex; }; 未知继承 struct member_function_ptr { void* address; int offset; int vadjust; // use to find vptr int vindex; }; 还要注意：32 程序中成员函数的调用约定和普通函数不一样。所以如果希望转换成函数指针并调用，需要在函数指针里面把函数调用约定写上才行，不然会导致调用失败。\nConclusion 讨论 C++ 问题千万不要想当然，你在特定平台上的测试结果，不代表所有可能的实现。而且 MSVC 已经告诉你了，即使是同一个程序内，你的测试也可能没有覆盖到所有的 case。之前发现 MSVC 的成员函数指针大小变来变去的时候给我吓了一跳，以为是我的代码出了问题。如果希望自己写一个类似 std::function 的容器，并希望执行 SBO 优化，最好把 SBO 大小设置在 16 字节以上，这样能覆盖掉绝大部分的成员函数指针。\n如果需要成员函数作为回调函数的，推荐使用 lambda 表达式包裹一层。 像下面这样\nstruct A { int a; void bar(int b) { std::cout \u0026lt;\u0026lt; \u0026#34;A::bar \u0026#34; \u0026lt;\u0026lt; a \u0026lt;\u0026lt; b \u0026lt;\u0026lt; std::endl; } }; int main() { auto f = +[](A\u0026amp; a, int b) { a.bar(b); }; // + is unary plus operator, use to cast a non-capturing lambda to a function pointer // f is function pointer } 在 C++23 之后，如果使用 explicit this 定义成员函数，则 \u0026amp;C::f 可以直接获取对应成员函数的函数指针，不需要像上面那样多一层包裹了\nstruct A { void bar(this A\u0026amp; self, int b); }; auto p = \u0026amp;A::bar; // p is function pointer, rather than member function pointer ","permalink":"https://www.ykiko.me/zh-cn/articles/659510753/","summary":"\u003ch2 id=\"introduction\"\u003eIntroduction\u003c/h2\u003e\n\u003cp\u003e在 C++ 中，形如 \u003ccode\u003e\u0026amp;T::name\u003c/code\u003e 的表达式返回的结果就是成员指针。写代码的时候偶尔会用到，但是这个概念可能很多人都并不熟悉。考虑如下代码\u003c/p\u003e","title":"C++ 成员指针完全解析"},{"content":"在 C++ 中，模板（Template）这个概念已经存在二十多年了。作为 C++ 最重要的一个语言构成之一，相关的讨论数不胜数。很可惜的是，深入有价值的讨论很少，尤其是以多个视角来看待这个特性。很多文章在谈论模板的时候往往会把它和各种语法细节缠绕在一起，容易给人一种云里雾里的感觉。类似的例子还发生在其他话题上面，比如介绍协程就和各种 IO 混在一起谈，谈到反射似乎就限定了 Java，C# 中的反射。这样做并不无道理，但是往往让人感觉抓不到本质。看了很多内容，但却不得其要领，反倒容易把不同的概念混淆在一起。\n就我个人而言，讨论一个问题喜欢多层次，多角度的去讨论，而不仅限于某一特定的方面。这样一来，既能更好的理解问题本身，也不至于让自己的视野太狭隘。故本文将尝试从模板诞生之初开始，以四个角度来观察，理清模板这一特性在 C++ 中的发展脉络。注意，本文并不是教学文章，不会深入语法细节。更多的谈论设计哲学和 trade-off 。掌握一些模板的基础知识就能看懂，请放心阅读。当然，这样可能严谨性有所缺失，如有错误欢迎评论区讨论。\n我们主要讨论四个主题：\n代码生成 (Code Generation) 类型约束 (Type Constraint) 编译时计算 (Compile-time Computing) 操纵类型 (Type Manipulation) 其中第一个主题一般认为就是普通的 Template。而后三者一般被规划到 TMP 中去。TMP 即 Template meta programming 也就是模板元编程。因为模板设计之初的意图并不是实现后面这三个功能，但是最后却通过一些奇怪的 trick 实现了这些功能，代码写起来也比较晦涩难懂，所以一般叫做元编程。\nCode Generation Generic 泛型 (Generic) 编程，也就是为不同的类型编写相同的代码，实现代码复用。在加入模板之前，我们只能通过宏来模拟泛型。考虑下面这个简单的示例：\n#define add(T) _ADD_IMPL_##T #define ADD_IMPL(T) \\ T add(T)(T a, T b) { \\ return a + b; \\ } ADD_IMPL(int); ADD_IMPL(float); int main() { add(int)(1, 2); add(float)(1.0f, 2.0f); } 它的原理很简单，就是把普通函数中的类型替换成宏参数，通过宏符号拼接来为不同的类型参数生成不同的名字。再通过 IMPL 宏来为特定的函数生成定义，这个过程可以叫做实例化 (instantiate)。\n当然，这只是一个最简单的例子，也许你觉得看上去还不错。但如果要用宏来实现一个 vector 呢？想想就有点可怕。具体来说，用宏来实现泛型主要有如下几个缺点\n代码可读性差，宏的拼接和代码逻辑耦合，报错信息不好阅读 很难调试，打断点只能打到宏展开的位置，而不是宏定义内部 需要显式写出类型参数，参数一多起来就会显得十分冗长 必须手动实例化函数定义，在较大的代码库中，往往一个泛型可能有几十个实例化，全部手动写出过于繁琐 这些问题，在模板中都被解决了：\ntemplate \u0026lt;typename T\u0026gt; T add(T a, T b) { return a + b; } template int add\u0026lt;\u0026gt;(int, int); // explicit instantiation int main() { add(1, 2); // auto deduce T add(1.0f, 2.0f); // implicit instantiation add\u0026lt;float\u0026gt;(1, 2); // explicitly specify T } 模板就是占位符，不需要字符拼接，和普通的代码别无二致，仅仅多了一项模板参数声明 报错和调试都能准确的指向模板定义的位置，而不是模板实例化的位置 支持模板参数自动推导，不需要显式写出类型参数，同时也支持显式指定类型参数 支持隐式实例化 (implicit instantiation)，即由编译器自动实例化使用到的函数。也支持显式实例化 (explicit instantiation)，即手动实例化。 除此之外，还有诸如偏特化 (partial specialization)，全特化 (full specialization)，可变模板参数 (variadic template)，变量模板 (variable template) 等等一系列特性，这些仅凭宏都是做不到的。正是由于模板的出现，才使 STL 这样的泛型库的实现成为可能。\nTable Gen 上面提到的泛型，可以看成模板最直接的用法。基于它们我们可以有一些更加高级的代码生成，例如在编译期生成一个确定的表以供运行期查询。标准库中 std::visit 的实现就利用了这种技巧，下面是它的一个简单模拟\ntemplate \u0026lt;typename T, typename Variant, typename Callback\u0026gt; void wrapper(Variant\u0026amp; variant, Callback\u0026amp; callback) { callback(std::get\u0026lt;T\u0026gt;(variant)); } template \u0026lt;typename... Ts, typename Callback\u0026gt; void visit(std::variant\u0026lt;Ts...\u0026gt;\u0026amp; variant, Callback\u0026amp;\u0026amp; callback) { using Variant = std::variant\u0026lt;Ts...\u0026gt;; constexpr static std::array table = {\u0026amp;wrapper\u0026lt;Ts, Variant, Callback\u0026gt;...}; table[variant.index()](variant, callback); } int main() { auto callback = [](auto\u0026amp; value) { std::cout \u0026lt;\u0026lt; value \u0026lt;\u0026lt; std::endl; }; std::variant\u0026lt;int, float, std::string\u0026gt; variant = 42; visit(variant, callback); variant = 3.14f; visit(variant, callback); variant = \u0026#34;Hello, World!\u0026#34;; visit(variant, callback); return 0; } 尽管 variant 中储存的元素类型是在运行时才能确定的，但是它可能取值的类型集合在编译期就可以确定，所以我们用 callback 给集合中每一个可能的类型都实例化一个对应的 wrapper 函数，并且存到一个数组里面。在运行时直接用 variant 的 index 访问数组里面对应的成员即可完成调用了。\n当然，利用 C++17 加入的折叠表达式 (folding expression) 我们其实有更好的做法\ntemplate \u0026lt;typename... Ts, typename Callback\u0026gt; void visit(std::variant\u0026lt;Ts...\u0026gt;\u0026amp; variant, Callback\u0026amp;\u0026amp; callback) { auto foreach = []\u0026lt;typename T\u0026gt;(std::variant\u0026lt;Ts...\u0026gt;\u0026amp; variant, Callback\u0026amp; callback) { if(auto value = std::get_if\u0026lt;T\u0026gt;(\u0026amp;variant)) { callback(*value); return true; } return false; }; (foreach.template operator()\u0026lt;Ts\u0026gt;(variant, callback) || ...); } 利用逻辑运算符的短路特性，我们可以提前退出后续折叠表达式的求值，短的函数也更加有利于内联。\nType Constraint 别的我都同意，但是模版的报错信息明明一点都不好读啊。和宏比起来，难道不是五十步笑百步吗？甚至有过之而无不及。轻松产生几百，几千行的报错，我想只有 C++ 的模板能做到吧。\n这就是接下来要讨论的问题，为什么 C++ 的编译错误信息这么长？而且有时候非常难读懂。\nFunction Overload 考虑下面这个只有几行的简单示例\nstruct A {}; int main() { std::cout \u0026lt;\u0026lt; A{} \u0026lt;\u0026lt; std::endl; return 0; } 在我的 GCC 编译器上，足足产生了 239 行报错信息。不过好消息是 GCC 把关键部分标记出来了，如下所示：\nno match for \u0026#39;operator\u0026lt;\u0026lt;\u0026#39; (operand types are \u0026#39;std::ostream\u0026#39; {aka \u0026#39;std::basic_ostream\u0026lt;char\u0026gt;\u0026#39;} and \u0026#39;A\u0026#39;) 9 | std::cout \u0026lt;\u0026lt; A{} \u0026lt;\u0026lt; std::endl; | ~~~~~~~~~ ^~ ~~~ | | | | | A | std::ostream {aka std::basic_ostream\u0026lt;char\u0026gt;} 那大概还是能读懂的，意思就是没有找到匹配的重载函数，也就是说我们需要为 A 重载 operator\u0026lt;\u0026lt;。但我们好奇的是，剩下的两百行报错在干嘛呢？其实关键就在于重载决议 (Overload Resolution)。让我们来看其中一段信息\nnote: template argument deduction/substitution failed: note: cannot convert \u0026#39;A()\u0026#39; (type \u0026#39;A\u0026#39;) to type \u0026#39;const char*\u0026#39; 9 | std::cout \u0026lt;\u0026lt; A{} \u0026lt;\u0026lt; std::endl; 意思就是尝试用 A 类型匹配 const char* 这个重载（通过隐式类型转换），结果失败了。标准库类似中这样的函数，都有非常多的重载，比如这个 operator\u0026lt;\u0026lt; 就重载了 int,bool,long,double 等等，将近几十个函数。结果报错信息就是把所有重载函数尝试失败的原因都列出来，于是轻松就有几百行了，再加上标准库诡异的命名，看起来就像天书一样。\nInstantiation Stack 函数重载是导致报错信息难以读懂的一部分原因，但不是全部。实际上如上所示，仅仅是把所有可能性枚举出来，不过几百行报错。要知道我们还能产出上千行呢，量级上的差距可不是能用数量轻松弥补的。况且本小节要说的是类型约束，和编译器报错有什么关系呢。 考虑下面这个示例：\nstruct A {}; struct B {}; template \u0026lt;typename T\u0026gt; void test(T a, T b) { std::cout \u0026lt;\u0026lt; a \u0026lt;\u0026lt; b \u0026lt;\u0026lt; std::endl; } int main() { test(A{}, B{}); // #1: a few lines test(A{}, A{}); // #2: hundred lines } 在上面的实例中，#1 处仅有短短的几行报错信息，而 #2 处却有上百行。为什么会出现如此大的差距呢？还记得我们在第一部分里面说到的模板相比于宏的两个优点吗？一个是自动类型推导，一个是隐式实例化。只有模板参数推导成功了，才会触发模板实例化，才会去检查函数体中出现的错误。\ntest(A{}, B{}) 这里模板参数推导失败了。因为 test 函数隐含了一个重要的条件，那就是 a 和 b 的类型是一样的，于是实际上它报的错误是找不到匹配的函数。而第二个函数 test(A{}, A{}) 则是模板参数推导成功了，进入到实例化的阶段了，但是在实例化的阶段出错了。也就是说 T 已经被推断为 A 了，尝试把 A 代入函数体的时候，出错了。于是就只能把函数体中替换失败的原因列出来了。\n这会导致一个问题，当出现很多层模板嵌套的时候，可能是最内层的模板函数出错，而编译器不得不把整个模板实例化栈都打印出来。\n那对类型做约束有什么用呢？看下面这个例子\nstruct A {}; template \u0026lt;typename T\u0026gt; void print1(T x) { std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } template \u0026lt;typename T\u0026gt; // requires requires (T x) { std::cout \u0026lt;\u0026lt; x; } void print2(T x) { print1(x); std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } int main() { print2(A{}); return 0; } 短短几行，在我的 GCC 上产生了 700 行的编译错误。稍微改动一下，把注释掉的那行代码加上。相比之下这种情况的代码报错只有短短几行：\nIn substitution of \u0026#39;template\u0026lt;class T\u0026gt; requires requires(T x) {std::cout \u0026lt;\u0026lt; x;} void print2(T) [with T = A]\u0026#39;: required from here required by the constraints of \u0026#39;template\u0026lt;class T\u0026gt; requires requires(T x) {std::cout \u0026lt;\u0026lt; x;} void print2(T)\u0026#39; in requirements with \u0026#39;T x\u0026#39; [with T = A] note: the required expression \u0026#39;(std::cout \u0026lt;\u0026lt; x)\u0026#39; is invalid 15 | requires requires (T x) { std::cout \u0026lt;\u0026lt; x; } 意思就是 A 类型的实例 x 不满足 requires 语句 std::cout \u0026lt;\u0026lt; x。事实上通过这样的语法，我们就可以把错误限制在类型推断的阶段，而不去进行实例化。于是相对的报错就会简洁很多了。\n也就是说通过 requires 我们能阻止编译错误的传播。 但是可惜的是，约束相关的语法是在 C++20 才加入的。那在这之前呢？\nBefore C++20 在 C++20 之前，我们并没有这么好用的方法。只能通过一种叫做 SFINAE 的技术来实现类似的功能，对类型实现约束。比如上面那个功能，在 C++20 之前只能这么写：\ntemplate \u0026lt;typename T, typename = decltype(std::cout \u0026lt;\u0026lt; std::declval\u0026lt;T\u0026gt;())\u0026gt; void print2(T x) { print1(x); std::cout \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; } 具体的规则在这里就不介绍了，感兴趣的可以去搜搜相关的文章看看。\n结果就是\ntypename = decltype(std::cout \u0026lt;\u0026lt; std::declval\u0026lt;T\u0026gt;()) 这行代码，让人捉不着头脑，完全不知道是在表达什么含义。只有深入了解 C++ 模板相关的规则之后才能看懂这究竟是在干嘛。关于为什么 requires 直到 C++20 才被加入，可以阅读 C++ 之父本人写的 自述。\nCompile-time Computing Meaning 首先要肯定的一点是，编译期计算肯定是有用的。具体到特定场景，意义有多大，这肯定就不能一概而论了。有很多人谈编译期计算色变，什么代码难懂，屠龙技，没有价值云云。这样的确很容易误导初学者。事实上相关的需求的确存在。如果编程语言没有这个功能，但是确有需求，程序员也会想方设法的通过其他的办法来实现。\n我将举两个例子来说明：\n首先是编译器对常量表达式的优化，相信这个大家都并不陌生。极其简单的情况，像 1+1+x 这样的表达式，编译器会把它优化成 2+x。事实上现代编译器对于类似的情况能做的优化非常多，比如这个 问题。提问者问 C 语言的 strlen 函数在参数是常量字符串的时候，会不会把函数调用直接优化成一个常量。比如 strlen(\u0026quot;hello\u0026quot;) 会不会直接优化成 5。从主流编译器的实验结果来看，答案是肯定的。类似的情况数不胜数，不知不觉中你就在使用编译期计算。只是它被归到编译器优化的一部分去了。而编译器的优化能力总归是有上限的，允许使用者自己定义这种优化规则，会更加灵活和自由。比如在 C++ 里面明确了 strlen 是 constexpr 的，这种优化必然会发生。 其二是在程序语言发展早期，编译器优化能力还没那么强的时候。就已经开始广泛的使用外部脚本语言提前算好数据（甚至生成好代码）用来减少运行时开销了。典型的例子是算好三角函数表这种常量表，然后运行期直接用就行了。例如在编译代码之前，运行一段脚本用来生成一些需要的代码。 C++ 的编译期计算有明确的语义保证，并且内嵌于语言之中，能和其他部分良好的交互。从这个角度来说，很好的解决了上面两点问题。当然很多人对它的讨伐并不无道理，通过模板元编程进行的编译期计算。代码丑陋且晦涩难懂，牵扯到的语法细节多，并且大大拖慢编译时间，增加二进制文件大小。无可否认的是，这些问题的确存在。但是随着 C++ 版本的不断更新，编译期计算现在已经非常容易理解了，不再需要去写那些复杂的模板元代码，新手也能很快学会。因为和运行期代码几乎一样了。接下来伴随着它的发展史，我们将逐步阐明。\nHistory 从历史上看，TMP 是一个偶然事件。在标准化 C++ 语言的过程中发现它的模板系统恰好是图灵完备的，即原则上能够计算任何可计算的东西。第一个具体演示是 Erwin Unruh 编写的一个程序，该程序计算素数，尽管它实际上并未完成编译：素数列表是编译器在尝试编译代码时生成的错误消息的一部分。具体的示例，请参考 这里。\n作为入门级别的编程案例，可以展示一个编译期计算阶乘的方法：\ntemplate \u0026lt;int N\u0026gt; struct factorial { enum { value = N * factorial\u0026lt;N - 1\u0026gt;::value }; }; template \u0026lt;\u0026gt; struct factorial\u0026lt;0\u0026gt; { enum { value = 1 }; }; constexpr auto value = factorial\u0026lt;5\u0026gt;::value; // =\u0026gt; 120 这段代码即使在 C++11 之前也能通过编译，在那之后 C++ 引入了很多新的东西用于简化编译期计算。最重要的就是 constexpr 关键字了。可以发现在 C++11 之前，我们并没有合适的办法表示编译期常量这一概念，只能借用 enum 来表达。而 C++11 之后，我们可以这么写：\ntemplate \u0026lt;int N\u0026gt; struct factorial { constexpr static int value = N * factorial\u0026lt;N - 1\u0026gt;::value; }; template \u0026lt;\u0026gt; struct factorial\u0026lt;0\u0026gt; { constexpr static int value = 1; }; 尽管进行了一些简化，但实际上我们仍然是借助模板来进行编译期计算。这样写出的代码是难以读懂的，主要原因有以下两点：\n模板参数只能是编译期常量，并没有编译期变量的概念，无论是全局还是局部都没有 只能通过递归而不能通过循环来进行编程 想象一下，如果平常写代码，把变量和循环给你禁了，那写起来是有多难受啊。\n那有没有满足上面两个特征的编程语言呢？其实满足上面两点的编程语言，一般称为 pure functional 也即纯函数式的编程语言。Haskell 就是一个典型的例子。 但是 Haskell 它有强大的模式匹配，在熟悉了 Haskell 的思维之后，也能写出短小优美的代码（而且 Haskell 本身也能用 do 语法模拟出局部变量，因为使用局部变量，其实就相当于把它作为函数参数一级级传递下去）。而 C++ 这些都没有，属于是把别人缺点都继承来了，优点一个没有。幸运的是，上面这些问题都在 constexpr function 中被解决了。\nconstexpr std::size_t factorial(std::size_t N) { std::size_t result = 1; for(std::size_t i = 1; i \u0026lt;= N; ++i) { result *= i; } return result; } int main() { constexpr auto a = factorial(5); // compile-time std::size_t\u0026amp; n = *new std::size_t(6); auto b = factorial(n); // run-time } C++ 允许在一个函数前面直接加上 constexpr 关键字修饰。表示这个函数既可以在运行期调用，也可以在编译期调用，而函数本身的内容几乎不需要任何改变。这样一来，我们可以直接把运行期的代码复用到编译期。也允许使用循环和局部变量进行编程，可以说和平常写的代码没有任何区别。很令人震惊对吧，所以编译期计算在 C++ 里面早已经是一件司空见惯的事情了，用户压根就不需要去写复杂的模板元。在 C++20 之后几乎所有的标准库函数也都是 constexpr 的了，我们可以轻松的调用它们，比如编译期排序。\nconstexpr auto sort(auto\u0026amp;\u0026amp; range) { std::sort(std::begin(range), std::end(range)); return range; } int main() { constexpr auto arr = sort(std::array{1, 3, 4, 2, 3}); for(auto i: arr) { std::cout \u0026lt;\u0026lt; i; } } 真正意义上的代码复用！如果你想要这个函数只在编译期执行，你也可以用 consteval 标记它。同时，在 C++20 中还允许了编译期动态内存分配，可以在 constexpr function 中使用 new 来进行内存分配，但是编译期分配的内存必须要在编译期释放。你也可以直接在编译期使用 vector 和 string 这样的容器。而且请注意，相比于利用模板进行编译期计算，constexpr 函数的编译速度会快很多 。如果你好奇编译期是如何实现这一强大的特性的，可以认为，C++ 编译器内部内嵌了一个小的解释器，这样遇到 constexpr 函数的时候用这个解释器解释一下，再把计算结果返回就行了。\n相信你已经充分见识到 C++ 在编译期计算方面所做的努力，编译期计算早就和模板元脱离关系了，在 C++ 中已经成为一种非常自然的特性，不需要特殊的语法，却能发挥强大的威力。所以以后千万不要一谈到 C++ 的编译期计算就十分恐慌，以为是什么屠龙之技。现在它早已经变得十分温柔美丽。\n尽管编译期计算已经脱离了模板元的魔爪，但是 C++ 并没有。还有两种情况，我们不得不编写蹩脚的模板元代码。\nType Manipulation Match Type 如何判断两个类型相等呢，或者说判断两个变量的类型相等。可能有人会想，这不是多此一举吗，变量的类型都是编译期已知的，还需要判断吗？其实这个问题可以说是伴随着泛型编程而出现的，考虑下面的示例：\ntemplate \u0026lt;typename T\u0026gt; void test() { if(T == int) { /* ... */ } } 这样的代码是符合我们直觉的，可惜 C++ 并不允许你这么写。不过在 Python / Java 等语言中确实有这种写法，但是它们的判断大多都是在运行时的。C++ 的确允许我们在编译期对类型进行操作，但是可惜的是类型并不能作为一等公民，作为普通的值，只能作为模板参数。我们只能写出如下的代码：\ntemplate \u0026lt;typename T\u0026gt; void test() { if constexpr(std::is_same_v\u0026lt;T, int\u0026gt;) { /* ... */ } } 类型只能存在于模板参数里面，这直接导致上一小节的 constexpr 编译计算提到的优势全都消失了。我们又回到了刀耕火种的时代，没有变量和循环。\n下面是判断两个 type_list 满足不满足子序列关系的代码:\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list {}; template \u0026lt;typename SubFirst, typename... SubRest, typename SuperFirst, typename... SuperRest\u0026gt; constexpr auto is_subsequence_of_impl(type_list\u0026lt;SubFirst, SubRest...\u0026gt;, type_list\u0026lt;SuperFirst, SuperRest...\u0026gt;) { if constexpr(std::is_same_v\u0026lt;SubFirst, SuperFirst\u0026gt;) if constexpr(sizeof...(SubRest) == 0) return true; else return is_subsequence_of(type_list\u0026lt;SubRest...\u0026gt;{}, type_list\u0026lt;SuperRest...\u0026gt;{}); else if constexpr(sizeof...(SuperRest) == 0) return false; else return is_subsequence_of(type_list\u0026lt;SubFirst, SubRest...\u0026gt;{}, type_list\u0026lt;SuperRest...\u0026gt;{}); } template \u0026lt;typename... Sub, typename... Super\u0026gt; constexpr auto is_subsequence_of(type_list\u0026lt;Sub...\u0026gt;, type_list\u0026lt;Super...\u0026gt;) { if constexpr(sizeof...(Sub) == 0) return true; else if constexpr(sizeof...(Super) == 0) return false; else return is_subsequence_of_impl(type_list\u0026lt;Sub...\u0026gt;{}, type_list\u0026lt;Super...\u0026gt;{}); } int main() { static_assert(is_subsequence_of(type_list\u0026lt;int, double\u0026gt;{}, type_list\u0026lt;int, double, float\u0026gt;{})); static_assert(!is_subsequence_of(type_list\u0026lt;int, double\u0026gt;{}, type_list\u0026lt;double, long, char, double\u0026gt;{})); static_assert(is_subsequence_of(type_list\u0026lt;\u0026gt;{}, type_list\u0026lt;\u0026gt;{})); } 写起来非常难受，我把相同的代码逻辑用 constexpr 函数写一遍，把类型参数换成 std::size_t\nconstexpr bool is_subsequence_of(auto\u0026amp;\u0026amp; sub, auto\u0026amp;\u0026amp; super) { std::size_t index = 0; for(std::size_t i = index; index \u0026lt; sub.size() \u0026amp;\u0026amp; i \u0026lt; super.size(); i++) { if(super[i] == sub[index]) { index++; } } return index == sub.size(); } static_assert(is_subsequence_of(std::array{1, 2}, std::array{1, 2, 3})); static_assert(!is_subsequence_of(std::array{1, 2, 4}, std::array{1, 2, 3})); 瞬间清爽一万倍，仅仅是因为在 C++ 中类型不是一等公民，只能作为模板参数，在涉及到类型相关的计算的时候，我们就不得不编写繁琐的模板元代码。事实上对类型做计算的需求一直都存在，典型的例子是 std::variant。在编写 operator= 的时候，我们需要从一个类型列表里面（variant 的模板参数列表）里面查找某个类型并返回一个索引，其实就是从一个数组里面查找一个满足特定条件的元素。相关的实现这里就不展示了。其实可怕的并不是使用模板元编程本身，而是就 C++ 自身而言，把类型当作值这样的改动是完全不可接受 unacceptable 的。也就是说这样的状况会一直持续下去，以后都不会有什么本质上的改变，这一事实才是最让人悲伤的。不过仍然要清楚的一个事实是，支持对类型做计算的语言并不多，像 Rust 对于这方面的支持几乎没有。C++ 的代码虽然写起来蹩脚，但是至少能写。\n但是还好这里还有另外一条路可以走。就是通过一些手段把类型映射到值。例如把类型映射到字符串，匹配类型可以类似于匹配字符串，只要对字符串进行计算就好了，也能实现一定程度上的 type as value。C++23 之前 并没有标准化的手段进行这种映射，通过一些特殊的编译器扩展能做到，可以参考 C++ 中如何优雅进行 enum 到 string 的转换\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list {}; template \u0026lt;typename T, typename... Ts\u0026gt; constexpr std::size_t find(type_list\u0026lt;Ts...\u0026gt;) { // type_name returns the name of the type std::array arr = {type_name\u0026lt;Ts\u0026gt;()...}; for(auto i = 0; i \u0026lt; arr.size(); i++) { if(arr[i] == type_name\u0026lt;T\u0026gt;()) { return i; } } } 在 C++23 之后也可以直接用 typeid 实现映射，而不使用字符串映射。但是类型映射到值简单，把值映射到类型回去可一点都不简单，除非你利用 STMP 这种黑魔法，才能方便的把值映射回类型。但是，如果静态反射将来被引入，那么这种从类型和值的双向映射会非常简单。这样的话虽然不能直接支持把类型当成值来进行操作，但是也基本差不多了。不过还有很长一段路要走，具体什么时候能加入标准，还是个未知数。如果对静态反射感兴趣，可以阅读 C++26 静态反射提案解析。\nComptime Variable 除了上面说的对类型做计算不得不用到模板元编程之外，如果需要在编译期计算的同时实例化模板，也不得不用模板元编程。\nconsteval auto test(std::size_t length) { return std::array\u0026lt;std::size_t, length\u0026gt;{}; // error length is not constant expression } 报错的意思就是 length 不是编译期常量，一般认为它属于编译期变量。这样就很让人讨厌了，考虑如下需求：我们要实现一个完全类型安全的 format。也就是说根据第一个常量字符串的内容，来约束后面函数参数的个数。比如是 \u0026quot;{}\u0026quot; 的话，后面 format 的函数参数个数就是 1 个\nconsteval auto count(std::string_view fmt) { std::size_t num = 0; for(auto i = 0; i \u0026lt; fmt.length(); i++) { if(fmt[i] == \u0026#39;{\u0026#39; \u0026amp;\u0026amp; i + 1 \u0026lt; fmt.length()) { if(fmt[i + 1] == \u0026#39;}\u0026#39;) { num += 1; } } } return num; } template \u0026lt;typename... Args\u0026gt; constexpr auto format(std::string_view fmt, Args\u0026amp;\u0026amp;... args) requires (sizeof...(Args) == count(fmt)) { /* ... */ } 事实上我们并没有办法保证一个函数参数是编译期常量，所以上面的代码是没法编译通过的。想要编译期常量，只能把这部分内容填到模板参数里面去，比如上面的函数可能会最后修改成 format\u0026lt;\u0026quot;{}\u0026quot;\u0026gt;(1) 这样的形式。虽然只是形式上的差别，但这无疑给使用者带来了困难。这样来看，也就不难理解为什么 std::make_index_sequence 这样的东西大行其道了。想要真正意义上可以做模板参数的编译期变量，也可以通过 STMP 这种黑魔法做到，但是如前文所述，难以在日常的编程中真正使用它。\nType is Value 非常值得一提的是，有一个比较新的语言叫 Zig。它解决了上述提到的问题，不仅支持编译期变量，还支持把类型作为一等公民来进行操作。得益于 Zig 独特的 comptime 机制，被它标记的变量或代码块都是在编译期执行的。这样一来，我们就可以写出如下的代码：\nconst std = @import(\u0026#34;std\u0026#34;); fn is_subsequence_of(comptime sub: anytype, comptime super: anytype) bool { comptime { var subIndex = 0; var superIndex = 0; while(superIndex \u0026lt; super.len and subIndex \u0026lt; sub.len) : (superIndex += 1) { if(sub[subIndex] == super[superIndex]) { subIndex += 1; } } return subIndex == sub.len; } } pub fn main() !void { comptime var sub = [_] type { i32, f32, i64 }; comptime var super = [_] type { i32, f32, i64, i32, f32, i64 }; std.debug.print(\u0026#34;{}\\n\u0026#34;, .{comptime is_subsequence_of(sub, super)}); comptime var sub2 = [_] type { i32, f32, bool, i64 }; comptime var super2 = [_] type { i32, f32, i64, i32, f32 }; std.debug.print(\u0026#34;{}\\n\u0026#34;, .{comptime is_subsequence_of(sub2, super2)}); } 写出了我们梦寐以求的代码，啊，实在是太优雅了！在对类型计算这方面 Zig 可以说是完胜目前的 C++，感兴趣的读者可以自己去 Zig 官网了解一下，不过在类型计算以外的其他方面，比如泛型和代码生成，Zig 其实做的并不好，这并不是本文的重点，所以就不讨论了。\nConclusion 可以发现，在一开始模板承担了太多角色，而且根本不是当初设计它的时候所期望的用法，是通过一些 trick 来弥补语言表达能力的不足。随着 C++ 的不断发展，这些额外的角色渐渐地被更简单，更直接，更易懂的语法所取代。类型约束由 concept 和 requires 来完成，编译期计算由 constexpr 来完成，操作类型则由未来的 static reflection 来完成。模板逐渐回到了它最初的模样，负责代码生成。那些晦涩难懂的 workaround 也逐渐被淘汰，这是一个好的信号，虽然我们往往还是不得不和老代码打交道。但是至少我们知道，未来会更好！\n","permalink":"https://www.ykiko.me/zh-cn/articles/655902377/","summary":"\u003cp\u003e在 C++ 中，模板（Template）这个概念已经存在二十多年了。作为 C++ 最重要的一个语言构成之一，相关的讨论数不胜数。很可惜的是，深入有价值的讨论很少，尤其是以多个视角来看待这个特性。很多文章在谈论模板的时候往往会把它和各种语法细节缠绕在一起，容易给人一种云里雾里的感觉。类似的例子还发生在其他话题上面，比如介绍协程就和各种 IO 混在一起谈，谈到反射似乎就限定了 Java，C# 中的反射。这样做并不无道理，但是往往让人感觉抓不到本质。看了很多内容，但却不得其要领，反倒容易把不同的概念混淆在一起。\u003c/p\u003e","title":"雾里看花：真正意义上的理解 C++ 模板"},{"content":"上一篇 文章 我们初步了解了 STMP 的原理，并且利用它实现了简单的一个编译期的计数器。然而，它的威力远不止如此，这篇文章就来讨论一些基于 STMP 的高级应用。\ntype \u0026lt;=\u0026gt; value 在 C++ 中，对类型做计算的需求却一直存在，例如\nstd::variant 允许模板参数重复，但是这样必须要用 in_place_index 构造了，很麻烦。我们可以在使用 variant 前对类型列表去重，来解决这个问题 需要对 variant 类型列表进行排序，排序后相同的类型，例如 std::variant\u0026lt;int, double\u0026gt; 和 std::variant\u0026lt;double, int\u0026gt; 可以共用一份代码，减少二进制膨胀 根据给定索引获取一个类型列表中的类型 变序对函数参数进行映射，常用于跨语言自动生成绑定 等等等，这里就不一一列举了。但在 C++ 中，类型并不是一等公民，只能作为模板参数传递。为了对类型进行计算，我们往往不得不进行晦涩难懂的模板元编程。如果类型能像值一样传递给 constexpr 函数进行计算就好了，这样对类型的计算就会变得很简单了。直接传递肯定是不可能了，考虑建立类型和值之间的一一映射，在计算之前将类型映射到值，计算完之后再将值映射回类型，这样也能实现我们的需求。\ntype -\u0026gt; value 首先考虑将类型映射到值\nstruct identity { int size; }; using meta_value = const identity*; template \u0026lt;typename T\u0026gt; struct storage { constexpr inline static identity value = {sizeof(T)}; }; template \u0026lt;typename T\u0026gt; consteval meta_value value_of() { return \u0026amp;storage\u0026lt;T\u0026gt;::value; } 利用不同模板实例化的静态变量地址也不同的特性，我们可以轻松的把类型映射到唯一的值（地址）。\nvalue -\u0026gt; type 反过来如何把值映射回类型呢？考虑使用朴素的模板特化\ntemplate \u0026lt;meta_value value\u0026gt; struct type_of; template \u0026lt;\u0026gt; struct type_of\u0026lt;value_of\u0026lt;int\u0026gt;()\u0026gt; { using type = int; }; // ... 的确可以，但是这要求我们提前特化好所有要使用到的类型，对于绝大多数程序来说这是不现实的。有没有什么办法能在求值的时候添加这个特化呢？答案就是我们上一篇文章提到的 friend inject 了。\ntemplate \u0026lt;typename T\u0026gt; struct self { using type = T; }; template \u0026lt;meta_value value\u0026gt; struct reader { friend consteval auto to_type(reader); }; template \u0026lt;meta_value value, typename T\u0026gt; struct setter { friend consteval auto to_type(reader\u0026lt;value\u0026gt;) { return self\u0026lt;T\u0026gt;{}; } }; 然后我们只需在实例化 value_of 的同时实例化一个 setter 即可完成注册\ntemplate \u0026lt;typename T\u0026gt; consteval meta_value value_of() { constexpr auto value = \u0026amp;storage\u0026lt;T\u0026gt;::value; setter\u0026lt;value, T\u0026gt; setter; return value; } 最后直接通过 reader 读取注册的结果即可实现 type_of\ntemplate \u0026lt;meta_value value\u0026gt; using type_of = typename decltype(to_type(reader\u0026lt;value\u0026gt;{}))::type; sort types! 话不多说，我们赶紧来试试用 std::sort 对 type_list 进行排序\n#include \u0026lt;array\u0026gt; #include \u0026lt;algorithm\u0026gt; template \u0026lt;typename... Ts\u0026gt; struct type_list {}; template \u0026lt;std::array types, typename = std::make_index_sequence\u0026lt;types.size()\u0026gt;\u0026gt; struct array_to_list; template \u0026lt;std::array types, std::size_t... Is\u0026gt; struct array_to_list\u0026lt;types, std::index_sequence\u0026lt;Is...\u0026gt;\u0026gt; { using result = type_list\u0026lt;type_of\u0026lt;types[Is]\u0026gt;...\u0026gt;; }; template \u0026lt;typename List\u0026gt; struct sort_list; template \u0026lt;typename... Ts\u0026gt; struct sort_list\u0026lt;type_list\u0026lt;Ts...\u0026gt;\u0026gt; { constexpr inline static std::array sorted_types = [] { std::array types{value_of\u0026lt;Ts\u0026gt;()...}; std::ranges::sort(types, [](auto lhs, auto rhs) { return lhs-\u0026gt;size \u0026lt; rhs-\u0026gt;size; }); return types; }(); using result = typename array_to_list\u0026lt;sorted_types\u0026gt;::result; }; type_list 是一个简单的类型容器，array_to_list 用于将 std::array 中的类型映射回 type_list，sort_list 就是排序的具体实现，过程就是先把类型都映射到一个 std::array 中，然后用 std::ranges::sort 对这个数组排序，最后再将排序后的 std::array 映射回 type_list。\n实验一下\nusing list = type_list\u0026lt;int, char, int, double, char, char, double\u0026gt;; using sorted = typename sort_list\u0026lt;list\u0026gt;::result; using expected = type_list\u0026lt;char, char, char, int, int, double, double\u0026gt;; static_assert(std::is_same_v\u0026lt;sorted, expected\u0026gt;); 三大编译器 C++20 均编译通过！代码放在 Compiler Explorer 上了，为了防止链接失效。在 GitHub 上也放了一份。\n非常值得一提的是，这种类型和值的双向映射在 Reflection for C++26 中已经成为语言内置的功能。我们不再需要去利用 friend injection 这种奇淫巧技，直接使用 ^^ 和 [: :] 运算符即可完成映射，详见 Reflection for C++26!!!。\nthe true any std::any 常常用于类型擦除，可以把完全不同的类型擦除，并放在同一个容器里面。但是擦除容易，还原难，尤其是有些时候想把 any 里面存的对象打印出来看看，还得一个个类型去 cast。有没有一种可能，能编写出一个真正的 any 类型呢？不需要我们去手动 cast，直接就可以调用它里面的类型对应的成员函数呢？\n对于单个编译单元来说，这是完全可能的，因为单个编译单元内的构造为 any 的类型集合是编译时确定的，只需要记录下所有实例化的类型，然后使用模板元编程自动的对每个类型进行尝试即可。\ntype register 先考虑如何注册类型\ntemplate \u0026lt;typename T\u0026gt; struct self { using type = T; }; template \u0026lt;int N\u0026gt; struct reader { friend consteval auto at(reader); }; template \u0026lt;int N, typename T\u0026gt; struct setter { friend consteval auto at(reader\u0026lt;N\u0026gt;) { return self\u0026lt;T\u0026gt;{}; } }; template \u0026lt;typename T, int N = 0\u0026gt; consteval int lookup() { constexpr bool exist = requires { at(reader\u0026lt;N\u0026gt;{}); }; if constexpr(exist) { using type = decltype(at(reader\u0026lt;N\u0026gt;{}))::type; if constexpr(std::is_same_v\u0026lt;T, type\u0026gt;) { return N; } else { return lookup\u0026lt;T, N + 1\u0026gt;(); } } else { setter\u0026lt;N, T\u0026gt; setter{}; return N; } } template \u0026lt;int N = 0, auto seed = [] {}\u0026gt; consteval int count() { constexpr bool exist = requires { at(reader\u0026lt;N\u0026gt;{}); }; if constexpr(exist) { return count\u0026lt;N + 1, seed\u0026gt;(); } else { return N; } } 仍然使用 setter 来注册类型。lookup 用于查找某个类型在类型集合中的索引，原理就是遍历这个集合，然后一个个 is_same_v 比较，找到了就返回对应的索引。如果到最后都没有找到，就注册一个新的类型。count 用于计算类型集合的大小。\nany type 接下来我们定义一个简单的 any 类型，并定义一个 make_any 函数，用于构造 any 对象\nstruct any { void* data; void (*destructor)(void*); std::size_t index; constexpr any(void* data, void (*destructor)(void*), std::size_t index) noexcept : data(data), destructor(destructor), index(index) {} constexpr any(any\u0026amp;\u0026amp; other) noexcept : data(other.data), destructor(other.destructor), index(other.index) { other.data = nullptr; other.destructor = nullptr; } constexpr ~any() { if(data \u0026amp;\u0026amp; destructor) { destructor(data); } } }; template \u0026lt;typename T, typename Decay = std::decay_t\u0026lt;T\u0026gt;\u0026gt; auto make_any(T\u0026amp;\u0026amp; value) { constexpr int index = lookup\u0026lt;Decay\u0026gt;(); auto data = new Decay(std::forward\u0026lt;T\u0026gt;(value)); auto destructor = [](void* data) { delete static_cast\u0026lt;Decay*\u0026gt;(data); }; return any{data, destructor, index}; } 为什么要额外写一个 make_any，而不是直接写一个模板构造函数呢？这是因为在我实际尝试之后，发现三大编译器对于模板构造函数实例化的位置都不一样，并且有些奇怪，导致求值结果不同。但对于普通的模板函数，实例化位置都是一样的，所以写成了一个单独的函数。\nvisit it! 重头戏来了，我们可以实现一个类似 std::visit 的函数，用于访问 any 对象。它接受一个回调函数，然后遍历 any 对象的类型集合，如果找到了对应的类型，把 any 转换成对应的类型，然后调用回调函数。\ntemplate \u0026lt;typename Callback, auto seed = [] {}\u0026gt; constexpr void visit(any\u0026amp; any, Callback\u0026amp;\u0026amp; callback) { constexpr std::size_t n = count\u0026lt;0, seed\u0026gt;(); [\u0026amp;]\u0026lt;std::size_t... Is\u0026gt;(std::index_sequence\u0026lt;Is...\u0026gt;) { auto for_each = [\u0026amp;]\u0026lt;std::size_t I\u0026gt;() { if(any.index == I) { callback(*static_cast\u0026lt;type_at\u0026lt;I\u0026gt;*\u0026gt;(any.data)); return true; } return false; }; return (for_each.template operator()\u0026lt;Is\u0026gt;() || ...); }(std::make_index_sequence\u0026lt;n\u0026gt;{}); } 然后让我们尝试一下\nstruct String { std::string value; friend std::ostream\u0026amp; operator\u0026lt;\u0026lt; (std::ostream\u0026amp; os, const String\u0026amp; string) { return os \u0026lt;\u0026lt; string.value; } }; int main() { std::vector\u0026lt;any\u0026gt; vec; vec.push_back(make_any(42)); vec.push_back(make_any(std::string{\u0026#34;Hello world\u0026#34;})); vec.push_back(make_any(3.14)); for(auto\u0026amp; any: vec) { visit(any, [](auto\u0026amp; value) { std::cout \u0026lt;\u0026lt; value \u0026lt;\u0026lt; \u0026#39; \u0026#39;; }); // =\u0026gt; 42 Hello world 3.14 } std::cout \u0026lt;\u0026lt; \u0026#34;\\n-----------------------------------------------------\\n\u0026#34;; vec.push_back(make_any(String{\u0026#34;\\nPowerful Stateful Template Metaprogramming!!!\u0026#34;})); for(auto\u0026amp; any: vec) { visit(any, [](auto\u0026amp; value) { std::cout \u0026lt;\u0026lt; value \u0026lt;\u0026lt; \u0026#39; \u0026#39;; }); // =\u0026gt; 42 Hello world 3.14 // =\u0026gt; Powerful Stateful Template Metaprogramming!!! } return 0; } 三大编译器都按照我们的预期输出了结果！代码同样放在 Compiler Explorer 和 GitHub 上了。\nconclusion 这两篇关于 STMP 的文章，算是了却了我一直以来的心愿。在这之前，我一直在思考，如何像上面的代码这样，实现一个真的 any 类型，无需使用者提前注册。我尝试了很多方法，最后都未能如愿。但是 STMP 的出现，让我看到了希望。在意识到它所能到达的高度之后，我立马通宵写完了文章和案例。\n当然了，不推荐在实际的项目中使用这种技术。由于这种代码十分依赖于模板实例化的位置，非常容易造成 ODR 违背，并且多次重复实例化会大大增长编译时间。对于这种需要有状态的代码需求，我们往往可以将其改成无状态的代码，但是，纯手写工作量可能十分巨大，更推荐使用代码生成器进行额外的代码生成来完成这项需求。比如我们可以用 libclang 收集所有编译单元中 any 的实例化信息，然后打一个对应的表就行了。\n","permalink":"https://www.ykiko.me/zh-cn/articles/646812253/","summary":"\u003cp\u003e上一篇 \u003ca href=\"https://www.ykiko.me/zh-cn/articles/646752343\"\u003e文章\u003c/a\u003e 我们初步了解了 STMP 的原理，并且利用它实现了简单的一个编译期的计数器。然而，它的威力远不止如此，这篇文章就来讨论一些基于 STMP 的高级应用。\u003c/p\u003e","title":"C++ 禁忌黑魔法：STMP （下）"},{"content":"众所周知，传统的 C++ 的常量表达式求值既不依赖也不改变程序全局的状态。对于任意相同的输入，它的输出结果总是相同的，被认为是纯函数式 (purely functional) 的。模板元编程 (Template Meta Programming) 作为常量求值的一个子集，也应该遵守这个规则。\n但事实真的如此吗？在不违背 C++ 标准的情况下，下面的代码可能通过编译吗？\nconstexpr auto a = value(); constexpr auto b = value(); static_assert(a != b); 下面这样的编译期计数器可能实现吗？\nconstexpr auto a = next(); constexpr auto b = next(); constexpr auto c = next(); static_assert(a == 0 \u0026amp;\u0026amp; b == 1 \u0026amp;\u0026amp; c == 2); 每次常量求值得到的结果不同，说明求值改变了全局的状态。这种有状态的元编程，就叫做状态元编程。如果再与模板联系起来，就叫做 Stateful Template Meta Programming(STMP)。\n其实借助一些编译器内置的宏，我们是可以实现这样的效果的，比如\nconstexpr auto a = __COUNTER__; constexpr auto b = __COUNTER__; constexpr auto c = __COUNTER__; static_assert(a == 0 \u0026amp;\u0026amp; b == 1 \u0026amp;\u0026amp; c == 2); 编译器在预处理的时候，会对 __COUNTER__ 宏的替换结果进行递增。如果你对源文件执行预处理，会发现源文件变成了这样\nconstexpr auto a = 0; constexpr auto b = 1; constexpr auto c = 2; static_assert(a == 0 \u0026amp;\u0026amp; b == 1 \u0026amp;\u0026amp; c == 2); 这与我们想要实现的效果还是有很大区别的，毕竟预处理并不涉及到 C++ 程序的语义部分。而且这样的计数器是全局唯一的，我们并不能创建很多个计数器，那还有别的办法吗？\n答案是肯定的。不管多么难以置信，相关的 讨论 其实早在 2015 年的时候就有了，知乎上也有相关的 文章。但这篇文章是 2017 年发布的，使用的 C++ 版本还是 14，时过境迁，文章里面已经有很多的内容不适用了。更何况现在 C++26 的相关标准都开始制定了，有很多东西需要被重新讨论。我们将要选择的版本是 C++20。\n如果你只对代码感兴趣，我已经将相关的代码放在 Compiler Explorer 上。三大编译器 C++20 均编译通过，你可以直接看到编译器的输出结果。为了防止链接失效，也放到 GitHub 上。如果你想要了解它的原理，欢迎继续往下阅读。C++ 标准非常复杂，作者也没法保证文章内容完全正确，如果有任何错误，欢迎评论区讨论交流。\n根据 CWG 2118，相关的代码被认为是非良构的 (ill formed)。但是后来引入的 C++26 静态反射，提案本身就提供了类似的计数器示例，似乎又肯定了这种做法。总的来说，我认为这是 C++ 区分声明顺序导致的固有缺陷，假设像许多现代编程语言那样做 lazy parse，不区分声明顺序，进行两遍扫描，或许这种编译期可变状态才能真正消除。如果你打算在代码中尝试它，务必十分谨慎，STMP 较容易造成 ODR 违背。\nobservable state 在改变之前，我们首先得能在编译期观测到全局状态的变化。由于 C++ 支持向前声明 (forward declaration)，而一个 struct 在看到 definition 之前被认为是不完整类型 (incomplete type)，即类的完整性在不同的上下文中是不同的。\n而 C++ 标准规定 sizeof 只能对完整类型使用（毕竟不完整类型没有定义无法计算 size）。如果对不完整类型使用会导致编译错误，并且这个错误不是一个硬错误 (hard error)，所以可以利用 SFINAE 或 requires 来捕获到这个错误。于是我们就能通过如下的方式检测类的完整性\ntemplate \u0026lt;typename T\u0026gt; constexpr inline bool is_complete_v = requires { sizeof(T); }; 可能有读者会问，都 C++20 了为什么不使用 concept 呢？这里用 concept 会有一些奇怪的效果，是标准中有关原子约束 (atomic constraint) 的措辞导致的。就不深究了，感兴趣的读者可以自行尝试。\n尝试使用它来观测类型完整性\nstruct X; static_assert(!is_complete_v\u0026lt;X\u0026gt;); struct X {}; static_assert(is_complete_v\u0026lt;X\u0026gt;); 实际上，上面的代码会编译错误，第二个静态断言失败了。太奇怪了，怎么回事呢？分开试一下\n// first time struct X; static_assert(!is_complete_v\u0026lt;X\u0026gt;); struct X {}; // second time struct X; struct X {}; static_assert(is_complete_v\u0026lt;X\u0026gt;); 分开试发现都行，但是放一起就不行了，究竟为什么会这样呢？这其实是因为编译器会缓存模板第一次实例化的结果，之后再遇到相同的模板就会直接使用第一次实例化的结果。在最开始的那个例子中，第二个 is_complete_v\u0026lt;X\u0026gt; 仍然使用了第一次模板实例化的结果，所以仍然求值为 false 导致编译失败。\n编译器这样做合理吗？是合理的。因为模板最终可能会产生外部链接的符号，如果两次实例化的结果不同，在链接的时候选哪一个呢。但这确实影响到了我们去观测编译期的状态，如何解决呢？答案是加一个模板参数作为种子，每次求值的时候填入不同的参数，从而让编译器实例化新的模板\ntemplate \u0026lt;typename T, int seed = 0\u0026gt; constexpr inline bool is_complete_v = requires { sizeof(T); }; struct X; static_assert(!is_complete_v\u0026lt;X, 0\u0026gt;); struct X {}; static_assert(is_complete_v\u0026lt;X, 1\u0026gt;); 每次都手动填入一个不同的参数是很麻烦的，有没有什么办法能自动填入呢？\n注意到如果用 lambda 表达式作为 Non Type Template Parameter(NTTP) 默认模板参数，则该模板每次实例化的时候都是不同的类型\n#include \u0026lt;iostream\u0026gt; template \u0026lt;auto seed = [] {}\u0026gt; void test() { std::cout \u0026lt;\u0026lt; typeid(seed).name() \u0026lt;\u0026lt; std::endl; } int main() { test(); // class \u0026lt;lambda_1\u0026gt; test(); // class \u0026lt;lambda_2\u0026gt; test(); // class \u0026lt;lambda_3\u0026gt; return 0; } 这个特性很好的满足了我们的需求，它可以每次自动填入一个不同的种子。于是最终的 is_complete_v 实现如下\ntemplate \u0026lt;typename T, auto seed = [] {}\u0026gt; constexpr inline bool is_complete_v = requires { sizeof(T); }; 再次尝试使用它来观测类型完整性\nstruct X; static_assert(!is_complete_v\u0026lt;X\u0026gt;); struct X {}; static_assert(is_complete_v\u0026lt;X\u0026gt;); 编译通过！至此，我们成功观察到了编译期全局状态的变化。\nmodifiable state 在可以观测到状态变化之后，下面我们要考虑能否通过代码来主动进行状态更改。很可惜，对于绝大多数 declaration 来说，你唯一能改变它们的状态的办法就是通过修改源代码来添加 definition，没有其他的手段实现这个效果。\n唯一的例外是友元函数。但在考虑友元函数如何发挥作用之前，先让我们考虑一下如何观测到一个函数有没有被定义。对于绝大多数的函数是无法观测的，考虑到函数可能定义在其他编译单元，调用一个函数并不要求其定义可见。\n例外就是返回值类型为 auto 的函数，如果看不到它的函数定义，则无法推导出返回值类型，进而无法进行函数调用。下面的代码就可以检测 foo 函数是否有定义\ntemplate \u0026lt;auto seed = [] {}\u0026gt; constexpr inline bool is_complete_v = requires { foo(seed); }; auto foo(auto); static_assert(!is_complete_v\u0026lt;\u0026gt;); auto foo(auto value) { return sizeof(value); } static_assert(is_complete_v\u0026lt;\u0026gt;); 接下来让我们谈谈如何通过友元函数来改变全局的状态。\n友元函数与普通函数最大的不同就在于不要求函数定义与函数声明在同一 scope 中，考虑如下示例\nstruct X { friend auto foo(X); }; struct Y { friend auto foo(X) { return 42; } }; int x = foo(X{}); 上面的代码三大编译器都可以编译通过，并且完全符合 C++ 标准。这就给了我们操作的空间，我们可以在实例化类模板的同时实例化其内部定义的友元函数，从而给其他位置的函数声明添加定义。这种技术也被叫做友元注入 (friend injection)。\nauto foo(auto); template \u0026lt;typename T\u0026gt; struct X { friend auto foo(auto value) { return sizeof(value); } }; static_assert(!is_complete_v\u0026lt;\u0026gt;); // #1 X\u0026lt;void\u0026gt; x; // #2 static_assert(is_complete_v\u0026lt;\u0026gt;); // #3 注意到 #1 处模板 X 没有任何的实例化，故此时 foo 函数还未有定义，于是 is_complete_v 返回 false。而在 #2 处，我们实例化了一个 X\u0026lt;void\u0026gt;，进而导致 X 内的 foo 函数被实例化，给 foo 添加了一个定义，于是 #3 处的 is_complete_v 返回 true。当然了，函数定义最多只能有一个，如果你再尝试实例化一个 X\u0026lt;int\u0026gt;，这时候编译器就会报 foo 被重定义的错误了。\nconstant switch 结合上面提到的技巧，我们可以轻松实例化一个编译时的开关了\nauto flag(auto); template \u0026lt;auto value\u0026gt; struct setter { friend auto flag(auto) {} }; template \u0026lt;auto N = 0, auto seed = [] {}\u0026gt; consteval auto value() { constexpr bool exist = requires { flag(N); }; if constexpr(!exist) { setter\u0026lt;exist\u0026gt; setter; } return exist; } int main() { constexpr auto a = value(); constexpr auto b = value(); static_assert(a != b); } 它的原理很简单。第一次的时候，setter 尚未有任何实例化，所以 flag 函数也没有定义，于是 exist 求值为 false，走到了 if constexpr 里面那个分支，实例化了一个 setter\u0026lt;false\u0026gt;，并且返回 false。第二次的时候，setter 有了一个实例化，flag 函数也有了定义，于是 exist 求值为 true，直接返回 true。\n注意，这里的 N 的类型必须写成 auto，而不能使用 std::size_t。只有这样 flag(N) 才是 dependent name，才能被 requires 检测表达式合法性。由于模板的 two phase lookup，如果写成 flag(0)，会在第一阶段就进行查找，然后发现调用失败，产生一个 hard error，导致编译错误。\nconstant counter 更进一步，我们可以直接实现一个编译期的计数器\ntemplate \u0026lt;int N\u0026gt; struct reader { friend auto flag(reader); }; template \u0026lt;int N\u0026gt; struct setter { friend auto flag(reader\u0026lt;N\u0026gt;) {} }; template \u0026lt;int N = 0, auto seed = [] {}\u0026gt; consteval auto next() { constexpr bool exist = requires { flag(reader\u0026lt;N\u0026gt;{}); }; if constexpr(!exist) { setter\u0026lt;N\u0026gt; setter; return N; } else { return next\u0026lt;N + 1\u0026gt;(); } } int main() { constexpr auto a = next(); constexpr auto b = next(); constexpr auto c = next(); static_assert(a == 0 \u0026amp;\u0026amp; b == 1 \u0026amp;\u0026amp; c == 2); } 它的逻辑是，从 N 为 0 开始，检测 flag(reader\u0026lt;N\u0026gt;) 是否有定义，如果没有定义就实例化一个 setter\u0026lt;N\u0026gt;，也就是给 flag(reader\u0026lt;N\u0026gt;) 添加定义，并返回 N。否则递归调用 next\u0026lt;N + 1\u0026gt;()，检测 N+1 的情况。所以这个计数器记录的实际上是 setter 的实例化次数。\n§: access private 首先要明确一个观点：类的访问权限说明符 private, public, protected 仅仅只作用于编译期的检查。如果能通过某种手段绕过这个编译期检查，那完全就可以合法的访问类的任意成员。\n那么存在这样的方法吗？有的：模板显式实例化的时候会忽略类作用域的访问权限：\nThe C++11/14 standards state the following in note 14.7.2/12 [temp.explicit]: The usual access checking rules do not apply to names used to specify explicit instantiations. [ Note: In particular, the template arguments and names used in the function declarator (including parameter types, return types and exception speciﬁcations) may be private types or objects which would normally not be accessible and the template may be a member template or member function which would not normally be accessible. — end note ]\n也就是说在模板显式实例化 (explicit instantiate) 的时候，我们可以直接访问类的私有成员。\n#include \u0026lt;iostream\u0026gt; class Bank { double money = 999\u0026#39;999\u0026#39;999\u0026#39;999; public: void check() const { std::cout \u0026lt;\u0026lt; money \u0026lt;\u0026lt; std::endl; } }; template \u0026lt;auto mp\u0026gt; struct Thief { friend double\u0026amp; steal(Bank\u0026amp; bank) { return bank.*mp; } }; double\u0026amp; steal(Bank\u0026amp; bank); // #1 template struct Thief\u0026lt;\u0026amp;Bank::money\u0026gt;; // #2 int main() { Bank bank; steal(bank) = 100; // #3 bank.check(); // 100 return 0; } 其中 #2 处的语法就是模板显式实例化了，我们可以直接访问到 Bank 的私有成员 money。通过 \u0026amp;Bank::money 从而取得该成员对应的成员指针。与此同时，通过模板显式实例化，给 #1 处的 steal 函数添加了一个定义，从而可以直接在 #3 处调用该函数并获取到 money 的引用。最后成功输出 100。\n","permalink":"https://www.ykiko.me/zh-cn/articles/646752343/","summary":"\u003cp\u003e众所周知，传统的 C++ 的常量表达式求值既不依赖也不改变程序全局的状态。对于任意相同的输入，它的输出结果总是相同的，被认为是\u003cstrong\u003e纯函数式 (purely functional)\u003c/strong\u003e 的。\u003cstrong\u003e模板元编程 (Template Meta Programming)\u003c/strong\u003e 作为常量求值的一个子集，也应该遵守这个规则。\u003c/p\u003e","title":"C++ 禁忌黑魔法：STMP （上）"},{"content":"std::variant 于 C++17 加入标准库，本文将讨论其加入标准的背景，以及一些使用上的问题。\nsum type 首先来讨论一下和类型 (sum type)，或者叫做 tagged union。和类型就是只能在几种可能的类型中取值的类型。\n例如我们有如下两个类型\nstruct Circle { double radius; }; struct Rectangle { double width; double height; }; 那么 Circle 和 Rectangle 的和类型，比如我们就叫 Shape 吧，在 C 语言中可以这么实现\nstruct Shape { enum Type { Circle, Rectangle } type; union { struct Circle circle; struct Rectangle rectangle; }; }; 这里使用了叫做 anonymous union 的特性，相当于声明了一个对应类型的 union 成员，并且把字段名字注入到当前作用域。\n这样我们就可以给 Shape 类型的变量赋不同类型的值，同时更新记录下赋值时的 type。访问的时候反过来根据 type 来决定按照哪种类型访问即可。例如\nvoid foo(Shape shape) { if(shape.type == Shape::Circle) { Circle c = shape.circle; printf(\u0026#34;circle: radius is %f\\n\u0026#34;, c.radius); } else if(shape.type == Shape::Rectangle) { Rectangle r = shape.rectangle; printf(\u0026#34;rectangle: width is %f, height is %f\\n\u0026#34;, r.width, r.height); } } int main() { Shape shape; shape.type = Shape::Circle; shape.circle.radius = 1.0; foo(shape); shape.type = Shape::Rectangle; shape.rectangle.width = 1.0; shape.rectangle.height = 2.0; foo(shape); } not trivial 但在 C++ 中事情就没这么简单了，考虑如下代码\nstruct Settings { enum class Type { int_, double_, string } type; union { int i; double d; std::string s; }; }; int main() { Settings settings; settings.type = Settings::Type::String; settings.s = std::string(\u0026#34;hello\u0026#34;); } 这段代码其实没法通过编译，编译器会报错 use of deleted function Settings::Settings()。为什么 Settings 的构造函数被删除了呢？这其实是因为 std::string 的构造函数是 not trivial 的，当 union 中含有 not trivial 的类型的成员的时候，编译器无法正确的生成构造函数和析构函数（不知道你要初始化或者析构哪个成员）。详细原因可以参考 cppreference 上对 union 的介绍。\n怎么解决呢？那就是我们自己来定义 union 的构造函数和析构函数。比如我们可以给它定义一个空的构造函数和析构函数，也就是什么都不做\nunion Value { int i; double d; std::string s; Value() {} ~Value() {} }; struct Settings { enum class Type { int_, double_, string } type; Value value; }; 使用的时候则要求我们通过 placement new 显式调用构造函数来初始化某个成员，同样的，我们也要手动调用析构函数来销毁某个成员。\nint main() { Settings settings; settings.type = Settings::Type::string; new (\u0026amp;settings.value.s) std::string(\u0026#34;hello\u0026#34;); std::cout \u0026lt;\u0026lt; settings.value.s \u0026lt;\u0026lt; std::endl; settings.value.s.~basic_string(); settings.type = Settings::Type::int_; new (\u0026amp;settings.value.i) int(1); std::cout \u0026lt;\u0026lt; settings.value.i \u0026lt;\u0026lt; std::endl; settings.value.i.~int(); } 注意，这里不能直接赋值 (assign)。因为赋值操作其实是在调用成员函数 operator=，而只有已经初始化过后的对象才能调用成员函数。\n从上面的代码不难看出，如果要在 C++ 里面直接使用 union 来表示 sum type，非常麻烦。不仅要及时更新 type，还要正确调用构造函数和析构函数，还要留意赋值的时机问题。如果其中的某一步忘记了，就会导致 undefined behavior，这非常让人头疼。不过还好，C++17 给我们提供了 std::variant 来解决这个问题。\nstd::variant 直接看代码\n#include \u0026lt;string\u0026gt; #include \u0026lt;variant\u0026gt; using Settings = std::variant\u0026lt;int, bool, std::string\u0026gt;; int main() { Settings s = {1}; s = true; s = std::string(\u0026#34;hello\u0026#34;); } 上面的代码完全是 well defined，通过模板元编程，variant 会在合适的时机处理对象的构造和析构。\n它有一个 index 成员函数可以获取当前类型在你写的类型列表里面的索引。\nSettings s; s = std::string(\u0026#34;hello\u0026#34;); // s.index() =\u0026gt; 2 s = 1; // s.index() =\u0026gt; 0 s = true; // s.index() =\u0026gt; 1 使用 std::get 可以从 variant 里面取出对应的值\nSettings s; s = std::string(\u0026#34;hello\u0026#34;); std::cout \u0026lt;\u0026lt; std::get\u0026lt;std::string\u0026gt;(s); // =\u0026gt; hello 有些人可能会疑惑，我都提前知道里面存的是 string 了，为什么还要用 std::variant 呢？注意到 get 还有一个模板参数是整数的重载，它能解决这个问题吗？\nstd::cout \u0026lt;\u0026lt; std::get\u0026lt;2\u0026gt;(s); // =\u0026gt; hello 哦，我懂了。那既然能直接用 index 来获取，那直接下面这样写不就好了？\nstd::cout \u0026lt;\u0026lt; std::get\u0026lt;s.index()\u0026gt;(s); 很遗憾，想法是好的，但是这样做是不行的。模板参数必须是编译期常量，而 variant 作为一种类型擦除的手段，其 index 肯定是运行时的值。怎么办呢？动态转静态，只能一个个分发。例如\nif(s.index() == 0) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;0\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } else if(s.index() == 1) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;1\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } else if(s.index() == 2) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;2\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } 用数字的可读性是比较糟糕的，我们可以用 std::holds_alternative 来根据类型做判断\nif(std::holds_alternative\u0026lt;std::string\u0026gt;(s)) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;std::string\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } else if(std::holds_alternative\u0026lt;int\u0026gt;(s)) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;int\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } else if(std::holds_alternative\u0026lt;bool\u0026gt;(s)) { std::cout \u0026lt;\u0026lt; std::get\u0026lt;bool\u0026gt;(s) \u0026lt;\u0026lt; std::endl; } 虽然能行，但是太多冗余代码了，有没有什么更好的办法来操作 variant 里面的值呢？\nstd::visit visit 这个名字其实就来源于设计模式里面的那个 visitor 模式。利用它，我们可以写出如下代码\nSettings s; s = std::string(\u0026#34;hello\u0026#34;); auto callback = [](auto\u0026amp;\u0026amp; value) { std::cout \u0026lt;\u0026lt; value \u0026lt;\u0026lt; std::endl; }; std::visit(callback, s); // =\u0026gt; hello settings = 1; std::visit(callback, s); // =\u0026gt; 1 是不是很神奇呢？只需要传入一个 callback，就能直接访问到 variant 里面的值了，不需要手动进行任何分发。软件工程领域有一条铁律：复杂度不会消失，只会转移，这里也不例外。其实 visit 内部帮你把 callback 根据 variant 里面的每个类型实例化了一份函数，预先打好了函数表，然后在运行时根据 index 直接调用函数表里面的函数就行了。\n但更多时候，我们其实是想根据不同类型做不同的事情。这在其他语言中可以方便的通过模式匹配做到\nHaskell:\ndata Settings = IntValue Int | BoolValue Bool | StringValue String deriving (Show, Eq) match :: Settings -\u0026gt; IO () match (IntValue x) = putStrLn $ \u0026#34;Int: \u0026#34; ++ show (x + 1) match (BoolValue x) = putStrLn $ \u0026#34;Bool: \u0026#34; ++ show (not x) match (StringValue x) = putStrLn $ \u0026#34;String: \u0026#34; ++ (x ++ \u0026#34; \u0026#34;) Rust:\nenum Settings{ Int(i32), Bool(bool), String(String), } fn main(){ let settings = Settings::Int(1); match settings{ Settings::Int(x) =\u0026gt; println!(\u0026#34;Int: {}\u0026#34;, x + 1), Settings::Bool(x) =\u0026gt; println!(\u0026#34;Bool: {}\u0026#34;, !x), Settings::String(x) =\u0026gt; println!(\u0026#34;String: {}\u0026#34;, x + \u0026#34; \u0026#34;), } } 很可惜，截止 C++23，C++ 还是没有模式匹配。想要在 C++ 写出类似上面代码的效果，目前有两种方案来自己模拟：\nfunction overload:\ntemplate \u0026lt;typename... Ts\u0026gt; struct Overload : Ts... { using Ts::operator()...; }; template \u0026lt;typename... Ts\u0026gt; Overload(Ts...) -\u0026gt; Overload\u0026lt;Ts...\u0026gt;; int main() { using Settings = std::variant\u0026lt;int, bool, std::string\u0026gt;; Overload overloads{ [](int x) { std::cout \u0026lt;\u0026lt; \u0026#34;Int: \u0026#34; \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; }, [](bool x) { std::cout \u0026lt;\u0026lt; \u0026#34;Bool: \u0026#34; \u0026lt;\u0026lt; std::boolalpha \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; }, [](std::string x) { std::cout \u0026lt;\u0026lt; \u0026#34;String: \u0026#34; \u0026lt;\u0026lt; x \u0026lt;\u0026lt; std::endl; }, }; Settings settings = 1; std::visit(overloads, settings); } if constexpr:\nint main() { using Settings = std::variant\u0026lt;int, bool, std::string\u0026gt;; auto callback = [](auto\u0026amp;\u0026amp; value) { using type = std::decay_t\u0026lt;decltype(value)\u0026gt;; if constexpr(std::is_same_v\u0026lt;type, int\u0026gt;) { std::cout \u0026lt;\u0026lt; \u0026#34;Int: \u0026#34; \u0026lt;\u0026lt; value + 1 \u0026lt;\u0026lt; std::endl; } else if constexpr(std::is_same_v\u0026lt;type, bool\u0026gt;) { std::cout \u0026lt;\u0026lt; \u0026#34;Bool: \u0026#34; \u0026lt;\u0026lt; !value \u0026lt;\u0026lt; std::endl; } else if constexpr(std::is_same_v\u0026lt;type, std::string\u0026gt;) { std::cout \u0026lt;\u0026lt; \u0026#34;String: \u0026#34; \u0026lt;\u0026lt; value \u0026lt;\u0026lt; std::endl; } }; Settings settings = 1; std::visit(callback, settings); } 无论是哪种方法都比较别扭，用模板来做这种 trick，不仅编译慢报错还不好看。这也意味着目前的 variant 非常不好用，没有配套的语言设施来简化其操作，和模板深深地纠缠在一起，让人望而却步。\n","permalink":"https://www.ykiko.me/zh-cn/articles/645810896/","summary":"\u003cp\u003e\u003ccode\u003estd::variant\u003c/code\u003e 于 C++17 加入标准库，本文将讨论其加入标准的背景，以及一些使用上的问题。\u003c/p\u003e","title":"std::variant 很难用！"}]