我曾隐约意识到闭包可能带来多种性能影响;但没想到C和C++扩展中选用的及潜在的设计方案竟如此……欠佳。
不过在探讨这些方案的性能表现及其设计代价之前,我们需要先厘清闭包的本质。
“闭包”?§
此处的闭包指编程语言构造,其包含数据与指令,这些指令既不直接关联输入参数,也不直接关联返回值。可将其视为函数或函数调用概念的“泛化”——函数调用实为闭包的“子集”(例如,不包含来自参数和返回值之外额外数据的闭包集合)。这些泛化函数与泛化函数对象具备处理未直接传递的“实例”数据的能力(即栈外闭包环境中的变量),通常还能携带超出其函数签名所暗示的数据量。
几乎所有现代语言都包含闭包机制,除非开发者刻意针对特定受众或过于“底层”的源代码设计(如栈式编程语言、字节码语言,或自诩为汇编语言或接近汇编的语言)。然而我们将重点聚焦于C和C++中的闭包,因为本文旨在探索如何在ISO C标准中实现适用于所有人的闭包解决方案,并最终推动其标准化。
首先,让我们通过展示C代码中常见的问题,说明为何闭包解决方案在C生态系统中层出不穷,随后结合各类解决方案进行探讨。
闭包困境§
闭包问题可简洁概括为:“如何在qsort调用中获取额外数据?” 例如,考虑通过命令行操作设置变量in_reverse来改变排序方式:
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
static int in_reverse = 0;
int compare(const void* untyped_left, const void* untyped_right) {
const int* left = untyped_left;
const int* right = untyped_right;
return (in_reverse) ? *right - *left : *left - *right;
}
int main(int argc, char* argv[]) {
if (argc > 1) {
char* r_loc = strchr(argv[1], 'r');
if (r_loc != NULL) {
ptrdiff_t r_from_start = (r_loc - argv[1]);
if (r_from_start == 1 && argv[1][0] == '-' && strlen(r_loc) == 1) {
in_reverse = 1;
}
}
}
int list[] = { 2, 11, 32, 49, 57, 20, 110, 203 };
qsort(list, (sizeof(list)/sizeof(*list)), sizeof(*list), compare);
return list[0];
}
此处使用static变量使其在qsort调用的两次compare函数调用之间保持有效,同时确保main调用(可能)将其值从0改为1。遗憾的是,对于超出单个代码片段范围的复杂程序,这种做法并非总是最佳选择:
- 无法创建
static变量的不同“副本”,这意味着程序中所有能访问in_reverse的模块都必须自行管理变量状态的变更(例如对可能不属于自身控制/无法可见的状态进行高强度状态化编程); - 在复杂程序中操作
static数据可能引发线程竞争/竞态条件; - 用
_Thread_local替代static仅能解决竞态条件问题,却无法解决“同一线程内多处共享”的问题; - 无法访问特定数据片段或局部数据(如
list本身);
诸如此类。这正是问题的核心所在。当涉及更复杂的函数与数据操作时(如唐纳德·克努斯的“Man-or-Boy”测试代码),该问题将愈发凸显。
C/C++代码中主要存在四类解决方案:
- 直接重写问题函数使其接受用户数据指针,从而传递任意数据(典型C语言方案,例如将排序函数
qsort替换为BSD的qsort_r[1]或Annex K的qsort_s[2]) * 利用 GNU 嵌套函数实现任意数据引用。 - 借助 Apple 块实现任意数据引用。
- 通过 C++ 闭包配合手工编译实现任意数据引用。
每种方案在可用性和设计上各有优劣,但为快速概述,我们将展示使用qsort(或适用场景下的qsort_r/qsort_s)的效果。首先,Apple代码块如下所示:
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
int main(int argc, char* argv[]) {
// local, non-static variable
int in_reverse = 0;
// value changed in-line
if (argc > 1) {
char* r_loc = strchr(argv[1], 'r');
if (r_loc != NULL) {
ptrdiff_t r_from_start = (r_loc - argv[1]);
if (r_from_start == 1 && argv[1][0] == '-' && strlen(r_loc) == 1) {
in_reverse = 1;
}
}
}
int list[] = { 2, 11, 32, 49, 57, 20, 110, 203 };
qsort_b(list, (sizeof(list)/sizeof(*list)), sizeof(*list),
// Apple Blocks are Block Expressions, meaning they do not have to be stored
// in a variable first
^(const void* untyped_left, const void* untyped_right) {
const int* left = untyped_left;
const int* right = untyped_right;
return (in_reverse) ? *right - *left : *left - *right;
}
);
return list[0];
}
而GNU嵌套函数如下所示:
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
int main(int argc, char* argv[]) {
// local, non-static variable
int in_reverse = 0;
// modify variable in-line
if (argc > 1) {
char* r_loc = strchr(argv[1], 'r');
if (r_loc != NULL) {
ptrdiff_t r_from_start = (r_loc - argv[1]);
if (r_from_start == 1 && argv[1][0] == '-' && strlen(r_loc) == 1) {
in_reverse = 1;
}
}
}
int list[] = { 2, 11, 32, 49, 57, 20, 110, 203 };
// GNU Nested Function definition, can reference `in_reverse` directly
// is a declaration/definition, and cannot be used directly inside of `qsort`
int compare(const void* untyped_left, const void* untyped_right) {
const int* left = untyped_left;
const int* right = untyped_right;
return (in_reverse) ? *right - *left : *left - *right;
}
// use in the sort function without the need for a `void*` parameter
qsort(list, (sizeof(list)/sizeof(*list)), sizeof(*list), compare);
return list[0];
}
最后是C++风格的Lambda表达式:
#define __STDC_WANT_LIB_EXT1__ 1
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
int main(int argc, char* argv[]) {
int in_reverse = 0;
if (argc > 1) {
char* r_loc = strchr(argv[1], 'r');
if (r_loc != NULL) {
ptrdiff_t r_from_start = (r_loc - argv[1]);
if (r_from_start == 1 && argv[1][0] == '-' && strlen(r_loc) == 1) {
in_reverse = 1;
}
}
}
// lambdas are expressions, but we can assign their unique variable types with `auto`
auto compare = [&](const void* untyped_left, const void* untyped_right) {
const int* left = (const int*)untyped_left;
const int* right = (const int*)untyped_right;
return (in_reverse) ? *right - *left : *left - *right;
};
int list[] = { 2, 11, 32, 49, 57, 20, 110, 203 };
// C++ Lambdas don't automatically make a trampoline, so we need to provide
// one ourselves for the `qsort_s/r` case so we can call the lambda
auto compare_trampoline = [](const void* left, const void* right, void* user) {
typeof(compare)* p_compare = user;
return (*p_compare)(left, right);
};
qsort_s(list, (sizeof(list)/sizeof(*list)), sizeof(*list), compare_trampoline, &compare);
return list[0];
}
为解决这堆问题,几乎所有半现代语言(非汇编类语言或基于状态/栈编程的语言)都提供了将数据集与一个或多个函数调用关联的机制。特别是闭包机制,其实现方式无需显式传递参数即可在局部作用域内完成。事实证明,所有这些设计选择——包括C语言中的方案——不仅对易用性,更对性能产生了深远影响。
并非全面概述§
本文不会深入探讨所有替代方案或其他语言的设计细节。我们专注于扩展功能的实际成本及其意义。关于设计权衡、安全影响及其他问题的详细概述,可参阅ISO C闭包函数提案; 该文档还深入探讨了安全影响、ABI、现有实现影响及多种设计方案。文中讨论相当详尽,针对每种方案从设计层面到实现细节都剖析了数十个方面。我们建议您深入研读该提案,从中找出您特别关注的具体设计细节。但本文只关注一件事:
性能测试 :3
为衡量性能代价,我们将采用克努斯的“男子汉测试”,通过C/C++语言结合不同扩展特性,对闭包问题的多种实现风格进行基准测试。“男人还是男孩”测试能高效评估编程语言在深度递归与自我引用场景下处理特定实体的能力。它可对程序创建和传递函数调用相关数据的各个环节进行压力测试——若语言设计存在缺陷,导致无法引用变量或函数参数的具体实例,最终将产生错误结果并引发灾难性崩溃。
基准测试剖析:原始C语言实现§
以下是Man-or-Boy测试的核心实现,采用原始C语言编写。该实现[3]及其他所有版本均可在线查阅,欢迎大家审阅并指正我的疏漏,确保本文未对您青睐的闭包解决方案进行诋毁。
// ...
static int eval(ARG* a) {
return a->fn(a);
}
static int B(ARG* a) {
int k = *a->k -= 1;
ARG args = { B, &k, a, a->x1, a->x2, a->x3, a->x4 };
return A(&args);
}
static int A(ARG* a) {
return *a->k <= 0 ? eval(a->x4) + eval(a->x5) : B(a);
}
// ...
你会注意到所有函数都挂着一个又大又丑的ARG*参数。正如前文所述,纯ISO C语言无法处理参数传递,除非数据属于函数参数的一部分。由于“男人还是男孩”实验的核心在于能够引用程序递归运行过程中存在的特定k值,我们需要实际修改函数签名,从而规避“男人还是男孩”实验中不直接传递值的隐含要求。以下是ARG的具体实现:
typedef struct arg {
int (*fn)(struct arg*);
int* k;
struct arg *x1, *x2, *x3, *x4, *x5;
} ARG;
static int f_1(ARG* _) {
return -1;
}
static int f0(ARG* _) {
return 0;
}
static int f1(ARG* _) {
return 1;
}
static int eval(ARG* a) {
// ...
}
// ...
以下是在函数主体中使用该参数计算正确答案并进行基准测试的方式:
static void normal_functions_rosetta(benchmark::State& state) {
const int initial_k = k_value();
const int expected_k = expected_k_value();
int64_t result = 0;
for (auto _ : state) {
int k = initial_k;
ARG arg1 = { f1, NULL, NULL, NULL, NULL, NULL, NULL };
ARG arg2 = { f_1, NULL, NULL, NULL, NULL, NULL, NULL };
ARG arg3 = { f_1, NULL, NULL, NULL, NULL, NULL, NULL };
ARG arg4 = { f1, NULL, NULL, NULL, NULL, NULL, NULL };
ARG arg5 = { f0, NULL, NULL, NULL, NULL, NULL, NULL };
ARG args = { B, &k, &arg1, &arg2, &arg3, &arg4, &arg5 };
int value = A(&args);
result += value == expected_k ? 1 : 0;
}
if (result != state.iterations()) {
state.SkipWithError("failed: did not produce the right answer!");
}
}
BENCHMARK(normal_functions_rosetta);
for (auto _ : state) { ... } 内的所有内容均被纳入基准测试。若您留意代码细节并感到似曾相识,那是因为该代码正是所有Google Benchmark[4]代码的基本结构。我长期计划切换至Catch2[5]以采用其基准测试框架, 但因基于Google Benchmark的JSON输出开发了大量图表工具,且尚未验证Catch2的JSON输出是否包含消除重复运行和计算统计所需的所有必要元素,故仍沿用Google Benchmark。
外部代码均属于初始化(for循环上方)或清理/测试修正(for循环下方)。ARG args的初始化无法移出测量循环,因为每次调用A(即Man-or-Boy实验的核心)都会修改ARG参数的k值,因此必须全部置于循环内部。理论上可将arg1 .. 5移出循环,但我实在厌倦了反复查看这段代码的八九种变体。若有人能将其移出并告知:Clang或GCC是否因编译器优化机制复杂而未能识别这5个argI可被移出循环。
值 k 为 10,而 expected_k 为 -67。预期返回的 k 值取决于输入的 k 值,该值控制着“男人还是男孩”测试为生成答案而进行自我递归的深度。因此,为防止GCC、Clang等超级强大的核心编译器将整个过程优化掉,直接用ret -67替换基准测试循环,k_value()和expected_k_value()均来自动态链接库(MacOS平台为.dylib, 在*nix平台上为.so,在Windows平台上为.dll),确保C或C++编译器的任何优化(链接时优化/链接时代码生成、内联优化、跨翻译单元优化以及常量表达式自动优化)都无法完全取代所有形式的计算。
这使我们能够确信,测试确实测量了实际内容,而非仅仅检验编译器将数字加载到寄存器并将其与state.iterations()进行比较的速度。正因如此,我们才能探讨通用测试方法论。
方法论§
测试在一台垂危的2020款13英寸MacBook Pro M1上进行,该设备曾多次遭受幼儿泼洒液体并经历两次严重摔落。测试时设备配备16GB内存,运行macOS 15.7.2 Sequoia系统,采用原生macOS AppleClang编译器及brew install gcc安装的编译器,以生成2025年12月6日的测试数据。
本次测试包含两项指标:实际运行时间与CPU占用时间。通过在for循环中重复执行单次迭代(次数范围从数千次至数十万次)来获取可靠数据,最终取平均值作为首项指标。该过程重复执行50次,每次均进行相同次数的迭代以增强测量可靠性。所有50个均值作为数据点,其平均值则作为条形图中柱状图的高度。
条形图采用横向排列形式,共呈现11类C/C++代码的测量结果。11个类别分别为:
no-op:完全无操作。仅用于检测环境噪声,确保基准测试不会因偏差过大而误测噪声而非计算结果,帮助我们立足现实。Lambdas (No Function Helpers):采用C++风格lambda表达式的解决方案。不同于使用f0、f1和f_1等辅助函数,我们直接计算原始lambda表达式,将“Man-or-Boy”测试的预期返回值(return i;)存储在lambda内部,再将该独特类型的lambda传递至测试核心。整个测试采用模板化设计,并使用伪参数recursion在达到指定递归深度后终止递归。Lambda表达式:与上述方案相同,但初始阶段实际使用int f0(void)等辅助函数而非Lambda表达式。通过采用“常规”类型(不增加生成的Lambda类型递归模板函数调用数量),有效减轻了内联优化压力。Lambdas (std::function_ref):与上述相同,但不再使用函数模板像呵护珍贵幼鸟般处理每个独特类型的lambda,而是通过std::function_ref<int(void)>掩盖lambda。这使递归函数能精确保留唯一签名。Lambdas (std::function):与上述相同,但将std::function_ref<int(void)>替换为std::function<int(void)>。这是其分配内存的C++03风格类型。Lambdas (Rosetta Code):直接取自Man-or-Boy Rosetta Code实现中C++11 Rosetta Code Lambda章节的代码。Apple Blocks:使用Apple Blocks实现测试,并借助__block限定符直接引用栈上特定变量。GNU嵌套函数(Rosetta Code):直接摘自Man-or-Boy Rosetta Code实现中C语言部分的代码。GNU嵌套函数:与Rosetta Code实现类似的GNU嵌套函数,但进行了微调——通过使用f0、f1和f_1等常规辅助函数,试图在可能的情况下缓解栈压力。自定义C++类:采用区分联合体实现的自定义C++类,用于判断当前操作是直接函数调用还是尝试执行Man-or-Boy递归。C++03 shared_ptr (Rosetta Code):通过std::enable_shared_from_this与std::shared_ptr配合虚函数调用,在递归过程中动态调用“正确”函数的C++类实现。
测试的两个编译器是Apple Clang 17和GCC 15。图示分为两张,分别对应Apple Clang和GCC。这点尤为关键,因为两者均未实现对方的闭包扩展特性(Clang支持Apple Blocks但不支持嵌套函数,而GCC仅在其C前端实现嵌套函数,却未支持Apple Blocks[6])。
结果§
瞧!

…哦。这看起来糟透了。
事实证明某些解决方案糟糕得像狗屎,彻底破坏了我们的可视化图表。但这也让我们清楚:采用罗塞塔密码风格的Lambda表达式简直糟糕得难以置信,其计算成本比其他所有方案高出好几个数量级!真让人好奇代码片段里到底在搞什么鬼,不过首先得让图表更清晰些。为此我们将采用(略带欺骗性的)对数缩放。这种方法颇具风险,容易误导人们对变化幅度的判断,因此请务必注意不同柱状图之间可能存在的数量级增减差异。


至此我们已完成基础铺垫。现在我们可以探讨各种解决方案,特别是为何“lambda表达式”会出现4种截然不同的性能表现。首先,让我们聚焦性能表现最突出的方案。
lambda表达式:性能之冠!§
对熟悉C++的人而言,直接使用且未进行类型擦除的lambda表达式表现最佳并不意外。这意味着函数调用与具体执行段之间存在一对一映射。我们通过使用常量参数阻止具有唯一类型的lambda表达式在函数中无限递归,从而使Man-or-Boy函数呈现如下形态:
template <int recursion = 0>
static int a(int k, const auto& x1, const auto& x2, const auto& x3, const auto& x4, const auto& x5) {
if constexpr (recursion == 11) {
::std::cerr << "This should never happen and this code should never have been generated." << std::endl;
::std::terminate();
return 0;
}
else {
auto B = [&](this const auto& self) { return a<recursion + 1>(--k, self, x1, x2, x3, x4); };
return k <= 0 ? x4() + x5() : B();
}
}
每个B都具有独立类型,且当用该表达式初始化B时不会擦除其独特类型。这意味着当再次用B调用a时(此处lambda中的self使用了C23特性Deduced This,该特性无法在C版lambda中实现),必须使用auto参数(模板参数的简写形式)来接收它。然而由于每个参数都是唯一的,且每个B都是唯一的,递归调用最终将导致C编译器彻底崩溃/抛出内存不足错误/提示编译时递归过度等问题。因此额外的模板化recursion参数必须通过编译时if constexpr进行任意限制。由于本次测试中k初始值为10,我们仅设置了“11”这个虚构上限。
这将导致极其冗长的递归函数调用链,其中生成的模板函数名称远比a复杂得多——若放任recursion值过高,不仅会耗尽编译器资源,还会引发大量实例化操作。但一旦添加限制,编译器就能获取递归调用的完整信息直至每个叶节点,从而不仅能极致优化该调用,还能拒绝生成那些已知无用的冗余代码。
即使类型擦除后,Lambda 仍保持高速§
当 Lambda 被 std::function_ref 擦除时,性能开销会略有上升。这是一种低级别的、不分配内存、不拥有对象的精简“视图”类型,类似于C语言中基于语言的宽函数指针类型。由此我们得以推测:即使必须将C语言中的Lambda隐藏在非唯一类型背后,其性能表现依然出色。
性能指标大致相当于手写一个使用区分联合体的自定义operator()的C++类,无论使用何种编译器实现。显然,这种方式不如直接函数调用并进行slurp-inline优化高效,但当你不想对泛型例程或类型进行大量“单态化”处理时,性能差异尚可接受。事实上,除宏之外,C语言本身并无非运行时实现此功能的内在途径。
堪称优质解决方案的有力竞争者!
Lambda表达式:同样处于……底层?§
那么人们不禁要问:为何std::function的Lambda和Rosetta Code的Lambda要么平庸无奇,要么糟糕得令人潸然泪下?
首先,std::function的Lambda之所以糟糕,恰恰在于std::function本身。std::function并非“廉价”闭包,而是可能分配内存、结构复杂且具有所有权的函数抽象。这意味着创建、传递、存储和调用它都很安全,但代价显而易见——当类型足够大时,你需要为内部存储分配内存。部分问题可通过使用const std::function<int(void)>&参数缓解——采用引用传递,仅在必要时生成新对象,从而避免每次函数调用都进行复制。然而无论是Rosetta Lambda还是常规std::function Lambda代码都实现了引用参数机制,差异究竟何在?关键在于捕获机制。以下是std::function Lambda定义递归自引用Lambda并使用它的示例:
using f_t = std::function<int(void)>;
inline static int A(int k, const f_t& x1, const f_t& x2, const f_t& x3, const f_t& x4, const f_t& x5) {
f_t B = [&] { return A(--k, B, x1, x2, x3, x4); };
return k <= 0 ? x4() + x5() : B();
}
而Rosetta Code Lambda定义递归自引用Lambda并使用它的示例如下:
using f_t = std::function<int(void)>;
inline static int A(int k, const f_t& x1, const f_t& x2, const f_t& x3, const f_t& x4, const f_t& x5) {
f_t B = [=, &k, &B] { return A(--k, B, x1, x2, x3, x4); };
return k <= 0 ? x4() + x5() : B();
}
关键问题在于=的使用方式。当=单独出现在lambda捕获子句开头时,其含义是“复制所有可见变量并保留副本”(除非后续变量捕获被&var地址捕获覆盖)。而&则相反:表示“直接通过地址引用所有可见变量,不进行复制”。因此,虽然std::function lambda(明智地)通过直接引用实现免复制(因“Man-or-Boy”测试证实直接引用并非不安全操作),但通用=导致函数数十次递归迭代时,每次都复制全部五个分配的std::function参数。首次调用会创建一个复制所有内容的B,再将其传递给后续调用;下一次调用则复制前一个B及四个普通函数,再传递给下一个B;接着它会复制两个前置B,如此循环直至调用图深度(初始k=10时约10次)。
不难想象这种机制如何彻底破坏性能,这也解释了为何Rosetta Code的Lambda代码表现如此糟糕。但这引发了一个问题:既然通过引用访问能大幅提升速度,为何GNU嵌套函数(及其所有变体)性能如此低下?毕竟嵌套函数通过引用/地址捕获所有内容,与Lambda使用[&]的机制完全一致。
同样地,若反复分配内存如此耗费资源,为何苹果块和C++03 shared_ptr实现的Rosetta Code风格Man-or-Boy测试性能远优于Rosetta Code Lambda?难道我们不是将参数值复制到新创建的苹果块中,从而导致性能指标崩溃吗?事实证明,这些现象背后存在多重原因,让我们从GNU嵌套函数说起。
嵌套函数与栈机制§
我已经就此话题撰文数十次,但嵌套函数最主流且最常见的实现方式仍是基于可执行栈。这种实现方式存在诸多安全隐患及其他影响,但你只需理解GCC采用此方案的根本原因:当时这是一种巧妙编码变量位置与程序本身的方案。从当前编程栈分配数据块意味着“环境上下文”/“闭包”指针与程序本体共享锚定地址。这意味着你可以将数据位置(用于确定访问目标)与函数入口点地址编码为单一实体,该实体能与调用标准ISO C函数指针时采用的典型设置-调用约定兼容。
但请从优化角度思考一下。
你正在使用程序中该精确位置的函数栈帧作为可执行代码的“基址”。该基址还意味着所有关联变量必须从该基址处可达:即变量不能被塞入寄存器,而是指代由嵌套函数外围函数修改的相同变量。本质上,这意味着你的函数必须同时满足以下条件,才能让 GNU 嵌套函数真正生效:
- 可执行的栈空间,以便跳转函数的基址能高效运行
- 内存中实际存在的函数帧,作为跳转函数的基址
- 内存中存在真实对象,为被捕获变量名称提供访问支持。
这些看似常规的后果,在考虑优化带来的次级影响时才显现:
- 栈中数据与指令现已混杂共存。
- 真实函数帧的存在意味着无法省略帧指针,且该函数帧不可被折叠/内联。
- 所有真实对象的地址均与函数帧绑定,这些对象必须可被内存访问,而编译器现在难以判断它们是否可通过寄存器交换,还是必须实际驻留在内存中的某个位置。
换言之:GNU嵌套函数为可能最致命的优化器杀手制造了完美的小型风暴。其性能崩溃程度(甚至比在std::function内分配lambda表达式,或在臃肿恶劣的C++ std::shared_ptr中调用C++03式虚函数更糟)足足低一个数量级以上,根本原因在于嵌套函数及其当前实现的每个细节都堪称优化器的噩梦。当编译器无法看穿所有细节——特别是当k和expected_k采用非常量值时——GNU嵌套函数性能便急剧恶化。它将过去三十年间我们研究并极致优化的所有核心技术尽数摧毁,一旦无法预先计算k和expected_k,便如同被抵着脑袋开枪。
好消息是GCC已完成GNU嵌套函数的新底层实现,采用基于堆的跳板函数。此类跳板机制不干扰栈空间,可省略帧指针直接访问数据(这可能避免破坏特定类型的内联优化),且无需可执行栈(只需标记为可执行的✨任意位置✨内存片段)。其性能可能接近Apple Blocks,但我们尚未获得最新GCC版本进行测试。待获取后,只需在CMake的两个源文件中添加编译标志-ftrampoline-impl=heap,重新运行基准测试即可对比效果!
最后,由于基准测试软件采用C++编写,而该扩展仅存在于GCC的C前端,因此存在轻微性能损耗。这意味着我必须在基准测试循环中使用extern函数调用才能触达实际代码。不过在函数调用内部,所有操作都应被优化掉,因此单次函数调用的栈帧开销不应太严重。但我仍计划深入排查,确保C函数调用的extern标记不会导致性能大幅恶化。鉴于这是不同的翻译单元,且未作为独立静态/动态库编译,理论上仍应能正常链接并优化。但考虑到当前性能如此糟糕?所有潜在问题都值得排查。
Apple Blocks的情况如何?§
Apple Blocks 并非最快的解决方案,但在 C 扩展中表现最佳,同时却是“快速”方案中最差的选择。遗憾的是,它们的速度甚至不如直接将 ARG* 注入函数签名并使用常规 C 函数调用的方式,这很可能源于其共享、类似堆的特性。关于苹果块最令人沮丧的是,它依赖的块运行时已达到极限优化:Clang和苹果官方文档均指出,尽管块运行时管理着自动引用计数(ARC)堆中的块指针,但块初始创建时其内存实则存储在栈上而非堆中。要将其移至堆内存,必须调用Block_copy才能触发“常规”的堆内存操作机制。由于我们从未调用Block_copy,因此实现了变量访问与管理的极致速度,且分配操作极少。
令人稍感遗憾的是:采用ARG*数据块的常规C函数、使用区分联合体和operator()的自定义C++类、任何稍加考量的lambda表达式使用场景,乃至其他类似操作,其性能表现均优于苹果Blocks提供的最佳方案。可以推测,所有用于复制int^(void)“帽子式”函数指针的ARC管理函数——即便它们对栈上数据的实际操作有限——都影响了测试结果。但这也算是个好消息:由于Apple Block的“帽子”指针是低成本可复制实体(本质上只是指向Block对象的指针),这意味着即使每次函数调用都将所有参数复制到闭包中,这种复制操作的开销也已降至最低。显然,正如常规“Lambda”和“Lambda(无函数辅助)”所示,通过[&]按地址/引用一次性读取所有内容(包括可见函数参数)能节省极微量时间[7]。
int^(void) 帽子指针函数类型的低开销,很可能是苹果块在此基准测试中最大的救赎。在唯一需要谨慎处理的位置,我们将输入参数k重命名为arg_k,并创建一个__block变量来实际引用共享的int k(从而获得正确结果):
static int a(int arg_k, fn_t ^ x1, fn_t ^ x2, fn_t ^ x3, fn_t ^ x4, fn_t ^ x5) {
__block int k = arg_k;
__block fn_t ^ b = ^(void) { return a(--k, b, x1, x2, x3, x4); };
return k <= 0 ? x4() + x5() : b();
}
所有 x1、x2 和 x3(如同不良 Lambda 案例)都被反复复制。虽然可将所有参数重命名为arg_xI,并在内部设置标记为__block的xI变量,但此方案不仅耗费更多精力,且极不可能对代码产生实质影响,反而可能因需为多个共享变量配置ARC引用计数,并在每个新建的b块内存储这些变量而导致性能下降。
简要补充:自引用函数/闭包§
需要特别注意的是,直接编写如下代码:
static int a(int arg_k, fn_t ^ x1, fn_t ^ x2, fn_t ^ x3, fn_t ^ x4, fn_t ^ x5) {
__block int k = arg_k;
fn_t ^ b = ^(void) { return a(--k, b, x1, x2, x3, x4); };
return k <= 0 ? x4() + x5() : b();
}
(未对b变量添加__block修饰符)实际上会引发重大错误。苹果的Block机制与早期C++ Lambda类似,在内部无法直接引用“自身”。必须通过捕获其赋值目标变量来指代“自身”。对于熟悉C++ lambda的开发者而言,这类似于确保以引用方式捕获lambda初始化变量,同时必须确保该变量具有具体类型。唯一规避方式是使用auto并进行类型推导,或采用其他引用组合方式。例如:
auto x = [&x](int v) { if (v != limit) x(v + 1); return v + 8; }无法编译,因类型auto尚未推导完成;std::function_ref<int(int)> x = [&x](int v) { if (v != limit) x(v + 1); return v + 8; }虽能编译,但因 C++ 机制会产生指向临时 lambda 的悬空引用(该 lambda 在表达式完成后即销毁);std::function<int(int)> x = [&x](int v) { if (v != limit) x(v + 1); return v + 8; }可编译且运行正常(无段错误),因为std::function会分配内存,而对自身的引用&x完全有效。- 最后,
auto x = [](this const auto& self, int v) { if (v != limit) self(v + 1); return v + 8; }能编译并正常运行且不发生段错误,因为不可见的self参数只是对当前对象的引用。
上述最新 Apple Blocks 代码片段的问题在于,其等效于执行:
std::function<int(int)> x = [x](int v) { if (v != limit) x(v + 1); return v + 8; }
请注意,该lambda初始化器的捕获列表中未包含&x。这相当于将一个(未初始化的)变量按值复制到lambda中。这正是Apple Blocks将对象赋值给未声明__block修饰符的变量时产生的问题,正如我们错误代码中b变量的情况。
所有允许自引用的实现中,此类变体的所有变体均支持此操作并能编译出某种形式的实现。你可能会认为某些实现会对此发出警告,但这其实是允许变量在初始化时引用自身的遗留问题。C和C中出现这种情况的明显原因在于可创建自引用结构,但遗憾的是这两种语言均未提供普遍安全的方法实现此功能。C23的推导this机制在普通函数和非对象上下文中无效,因此将其应用于其他场景或扩展时需谨慎[8]。唯一不受此限制的扩展是GNU嵌套函数,因其生成的是函数声明/定义而非带初始化的变量。因此基准测试中的这段代码能正常运行:
inline static int gnu_nested_functions_a(int k, int xl(void), int x2(void), int x3(void), int x4(void), int x5(void)) {
int b(void) {
return gnu_nested_functions_a(--k, b, xl, x2, x3, x4);
}
return k <= 0 ? x4() + x5() : b();
}
其语义符合预期,不同于块、lambda等默认按值复制的实现方式。
在通用场景下,这本是论文__self_func[9]试图解决的问题,但…我需要时间说服WG14工作组,证明这确实是个好主意。我们或许可以继续在递归场景中重复编写几十次有缺陷的代码,任其存在错误风险,但我会尽力再次说服他们:上述情况确实非常不妥。
重新思考§
尽管“男人还是男孩”测试并非万能的性能终极测试——毕竟它同时涉及自引用数据和递归局部副本的利用——但它意外地适合评估闭包设计在中高级编程语言中的合理性。该测试还让我确信:至少对于静态已知、编译时可解析、未类型擦除的可调用闭包对象而言,其性能基准在ISO C这类语言中必然具备最优的实现质量与性能权衡——无论编译器如何实现。
未来某个时刻,我必须阐述这种现象的根本原因。对本博客读者而言,先讨论性能再剖析设计确实有些颠倒顺序,但确保我们在整个探索之旅伊始就避免陷入性能死胡同的设计陷阱,这本身具有重要意义。
洞见总结§
毫不意外的是,编译器能获取的信息越多(Lambda设计),其加速代码的能力就越强。更令人意外的是,采用精简紧凑的类型擦除层(而非臃肿的虚函数调用集——如C++03 shared_ptr的Rosetta Code设计),实际开销微乎其微(使用std::function_ref的Lambda)。这揭示了ISO C闭包提案中另一项未正式明文规定的核心特性:宽函数指针。
若能在C语言中实现由编译器支持的轻量级{ some_function_type* func; void* context; }类型,将极具革命性意义。Martin Uecker的提案已在委员会获得关注并通过初步审批,但若能推动其朝理想方向发展。我建议采用%作为修饰符,这样就能轻松使用——毕竟宽函数指针是个极其普遍的概念。能像下面这样写代码会非常方便实用:
typedef int(compute_fn_t)(int);
int do_computation(int num, compute_fn_t% success_modification);
此类宽函数指针类型也传统上可与现有扩展互通,例如GNU嵌套函数、Apple代码块、C++风格Lambda等均能生成对应的宽函数指针类型,实现低成本调用。此外,该机制同样适用于FFI:Go闭包等功能已通过GCC的__builtin_call_with_static_chain在C语言中传递Go函数。其他语言的众多函数也能借此实现低成本高效桥接,无需费心设计将void* userdata或隐式上下文指针/环境指针放置何处的复杂方案。
现有扩展?§
遗憾的是——除了Borland闭包注解之外——现有C语言扩展方案存在太多性能缺陷。难怪GCC试图在GNU嵌套函数中加入-ftrampoline-impl=heap选项;此举或许能优化性能,使其更具竞争力,足以与Apple Blocks抗衡。但遗憾的是,由于其基于堆内存的特性,其最高性能上限很可能仅与Apple Blocks相当,而远不及C++风格的Lambda表达式。
无论是GNU嵌套函数还是Apple Blocks——以当前实现形式——在ISO C环境中都表现欠佳。前者因基础设计和主流实现导致性能糟糕,后者则因管理ARC指针的Blocks存在复制和间接调用开销,在复杂场景下形成了性能提升的天花板。
而常规C代码在此场景下的表现处于中等水平。虽非最差,却远非最佳,这意味着C代码的运行效率仍有提升空间。尽管难以完全信赖Rosetta Code上C语言的“Man-or-Boy”代码作为最佳范例,但它清晰展示了“普通”C开发者的实现方式,以及这种方式在该场景下无法达到性能极限的现状。
我本想添加一个常规C代码版本,采用动态数组配合static变量传输数据,或使用大量thread_local变量,但实在提不起劲去设计复杂的关联方案——既要处理递归函数a的具体调用,又要映射代表闭包数据的动态数据槽位。我确信存在解决方案,自己也能构思出几种方案,但此时要实现方案需要如此扭曲的操作,我认为根本不值得付出这番努力。不过,一如既往地
欢迎提交拉取请求。💚
- 标题横幅与封面照片由 Lukas, from Pexels 提供
- 参见https://man.freebsd.org/cgi/man.cgi?query=qsort_r。 ↩︎
- 参见 https://en.cppreference.com/w/c/algorithm/qsort。 ↩︎
- 参见:https://github.com/soasis/idk/tree/main/benchmarks/closures。 ↩︎
- 参见 https://github.com/google/benchmark。 ↩︎
- 参见https://github.com/catchorg/Catch2/blob/devel/docs/benchmarks.md。不妨亲自尝试,效果相当不错,只是我还没腾出时间切换过去。 ↩︎
- 苹果块(Apple Blocks)曾有过GCC实现方案,可通过启用块运行时(Blocks Runtime)实现。但据我所知,当NeXT支持和Objective-C相关功能因长期无人维护而被移除时,该方案也被彻底废弃。虽然有重启该功能的讨论,但显然需要有人坐下来重新实现(从头开始更有利,因为苹果已更改了块的ABI接口)或尝试修复旧支持机制。 ↩︎
- 出于某些原因,Apple Blocks无法将“按地址捕获”机制(即
__block存储类修饰符)应用于函数参数。因此所有函数参数都会被实际复制到块表达式中,除非在函数体中块前保存临时变量,再通过__block实现按引用捕获。 ↩︎ - 该机制同样基于模板推导
this——const auto&作为模板参数,通常用于实现生成时成员函数在可能情况下同时支持const与非const的特性。 ↩︎ - 遗憾的是,WG14在上次会议中以动机不足为由否决了该提案。有趣的是,就在会议结束后不久,我便遭遇了这个漏洞的猛烈冲击。遗憾的是,即便是最狂热的C语言爱好者,也未必具备预见性和“未雨绸缪”的品质,而大多数行业供应商往往更倾向于采取保守立场而非大胆创新。 ↩︎
本文文字及图片出自 The Cost Of a Closure in C

> 难怪GCC试图在GNU嵌套函数中加入-ftrampoline-impl=heap选项;这或许能优化性能,使其更具竞争力,足以与Apple的Blocks抗衡。
[免责声明] 虽未深入研究细节,但我强烈怀疑此举更侧重于消除可执行栈的需求而非性能优化。将跳板函数分配在栈上而非堆上反而有利于效率提升。
如今许多GNU/Linux发行版在工具链配置中默认禁用了可执行栈功能,这既适用于发行版构建过程,也适用于系统提供给用户的工具链。
当使用GCC本地函数时,它会覆盖链接器行为,使可执行文件被标记为支持可执行栈。
当然,这是一种安全妥协——当栈可执行时,恶意远程执行代码便能通过缓冲区溢出注入代码,诱使进程跳转执行该代码。
若跳板函数可在堆中分配,则无需可执行栈。但必须为这类分配配置可执行堆或专用堆(跳板函数大小固定,可打包存放于数组中)。
依赖 GCC 本地函数的程序无法感知跳板的存在。当函数返回、longjmp 或 C++ 异常传递时,跳板会在栈回滚过程中自然释放。
堆分配的跳板存在明显的释放难题;其具体处理策略值得探讨。
> 堆分配的跳板存在明显的释放问题;其具体策略值得探究。
当启用 -ftrampoline-impl=heap 选项时,GCC 会自动插入[1]对基于 mmap/munmap 构建的构造函数/析构函数例程(源自 libgcc)。
[1] https://godbolt.org/z/7s5nooMPz
这些例程似乎以嵌套方式按后进先出(LIFO)调用。例如
__gcc__nested__func__ptr__deleted函数能识别当前销毁的是最近创建的跳板结构。该机制可能包含处理多线程的逻辑,以及应对longjmp或类似机制丢弃栈帧的处理方案。
这部分内容极具启发性,从绝大多数论述中不难看出作者对这些语言的实现细节、基准测试陷阱等领域有着深厚造诣。确实如此!
因此在首个C代码示例后出现以下内容令人极度不适:
此处使用静态变量使其在qsort调用的两次比较函数之间保持存续,同时避免主函数调用(可能)将其值从0改为1
这段描述完全像是杜撰,且暴露了作者对本应熟知的内容存在认知混乱。
实际上,在这种用法(全局最外层作用域)中,
static与值的持久性毫无关系。它仅使变量成为翻译单元(C术语中指“C源代码文件”)的“私有”成员。值之所以“持久”,是因为全局最外层作用域在程序运行期间不会出域。若在函数内部使用则不同——此时它使值在函数调用间保持存续,实际实现通常通过将变量从栈区移至“全局数据区”(程序加载时通常在堆区分配)。需注意C语言规范未提及局部变量栈的存在,但现代系统普遍采用此实现方式。
我此刻陷入矛盾境地:虽然不同意博文中诸多观点(至少是我愿意阅读的部分),但将该变量声明为static以实现持久化确实是正确的。
你对术语使用的质疑恰恰表明你对ISO C标准不够熟悉。作者所指的是静态存储期。无论声明(兼具定义作用)中是否使用“static”关键字,该对象的存储期始终保持“静态”。人们通常称其为“全局变量”,但标准术语应为“静态存储期”。从这个意义上说,作者用“static”描述对象生命周期是正确的。
编辑补充:若声明中省略“static”,变化的是标识符的关联范围(从内部变为外部)。
>此处使用静态变量使其在两次qsort调用的比较函数中保持存在,同时在主函数调用中(可能)将其值从0改为1
唯一可能造成误解的是文章中将‘static’标注为等宽字体(此效果在HN上不可见)。除此之外,‘静态变量’完全可以指代具有静态存储期的对象——这正是C标准中的正式称谓。
将变量从栈移至“全局数据区”,该区域通常在程序加载时分配于堆内存
它并非堆分配,因为你无法调用free()释放它。非零静态数据甚至不会被匿名映射,而是采用文件后备机制并启用写时复制。
我读这句话时的理解截然不同。我拥有20多年C语言编程经验,对作者讨论的问题非常熟悉。当提到“静态变量”时,我立刻明白是指翻译单元私有的文件静态变量。这完全不显得牵强或杜撰,恰恰体现了作者的专业素养——语言的精准性。
作者参与ISO C和ISO C++工作组,其最新贡献是#embed标准。
不仅如此,作者还是WG14工作组的项目编辑。
这并非意味着不可能出错,但依然值得肯定。
这意味着他能编辑LaTeX。当然JeanHeyd资质深厚,但担任ISO标准项目编辑并不要求此项技能。
我的意思是,你比我更接近委员会,但即便字面意义如此,我仍认为你们不会让一个懂LaTeX却不懂C语言的人担任该职位。
假设我们有选择余地。愿意投入时间做这项工作的人本就不多——工作量大且乏味。企业也不会为C语言投入太多资源。
我反复读了几遍才意识到,文中提及static其实是转移视线。作者应该清楚链接类型对后续说明无关紧要——只是碰巧是static类型才这么称呼。但刻意强调这一点,初读时确实让人觉得作者对static的作用存在困惑。
这评论很奇怪,你只是在展示知识储备,却没真正指出文章中可改进之处。
按你的意思,作者本可用非静态全局变量替代,从而避免后续提及“static”关键字?
哦!谢谢指正,我表达得不如想象中具体。抱歉。
是的,
static完全可以省略,对于这种单文件代码片段它并无额外作用。我尝试用Compiler Explorer分析过,带/不带
static确实会生成略有差异的代码,但要快速理解并应用到这里有些复杂。抱歉。我用x86-64 GCC 15.2编译器在-O2优化下,无论是否添加
static,都得到完全相同的汇编代码——这很合理。但若同时添加-fPIC(如同编译动态库)却不添加-fvisibility=hidden,两者就会产生差异,这源于Linux动态链接机制的设计缺陷。TU级概念(大多)在链接阶段会消失。你需要使用-c选项编译生成目标文件才能看到区别。
此外,差异体现在符号表中,而非汇编代码。
澄清一下,我指的是经Compiler Explorer处理的反汇编结果,与我回复的评论内容一致。
基准测试表明,现代C++的“Lambda”方法(创建包含捕获变量字段的独特结构体)本质上是编译时计算的静态链接。由于编译器能看到完整定义,可将“链接”扁平化为直接成员访问,这正是其胜出的原因。作者在GCC中观察到的性能开销部分源于操作系统/CPU管理可执行栈的开销,而不仅是代码效率问题。作者准确指出C语言缺失了低级语言数十年前就完善的原始特性:绑定方法(宽)指针。
最令人震惊的是std::function与std::function_ref之间的性能鸿沟。事实证明std::function(拥有容器)在递归过程中强行实施了“按值复制”语义。在“Man-or-Boy”测试中,这导致每次递归步骤中闭包状态的复制量呈指数级爆炸增长。而std::function_ref(非拥有视图)则完全规避了此问题。
即使从未复制 std::function,其开销依然巨大。GCC(至少14版)似乎无法省略分配操作,也无法内联函数本身——即便在函数调用后立即使用对象且对象从未逃逸出函数。当条件允许时,GCC似乎能完全移除一层 function_ref,但两层结构时便会失败。
这完全正确,而“Man-or-Boy”基准测试恰恰触发了libstdc++的最坏情况。优化在此处失效。我所说的“按值复制”指的是所有权语义。由于std::function拥有其存储空间,且Man-or-Boy递归会将闭包传递至下一层(通常通过值传递或将其捕获到新闭包中),因此触发了复制构造函数。若超出SBO限制,该复制构造函数将执行新的堆分配并深度复制状态。
GCC(libstdc++)与其他主流C运行时(libc、MSVC)均对std::function实现了小对象优化:当可调用对象足够小,其将直接存储于std::function的状态中而非堆上。在这些实现中,你可以确信无需动态分配即可捕获两个指针。
看似如此,实则不然。据我上次检查,libstdc++ 仅能优化 std::bind 闭包。通过无状态lambda的简单测试可知,GCC14和15仍存在此问题。事实上,我甚至无法触发库对bind的优化。
与GCC14不同,GCC15本身似乎能在简单场景下优化分配(及整个std::function),且与库行为无关。
很高兴看到Borland的__closure扩展被提及。
最近我一直在思考为“有状态”函数声明变量引入“state”关键字的可能性。其作用类似于“static”,但不同之处在于:每个变量不会生成单一的全局实例,而是被添加到自动定义的结构体中。该结构体的类型可通过“statetype(foo)”或其他机制获取,随后可像调用实例化状态对象那样调用foo(在C语言中,这相当于显式传递标记为“state”的第一个参数)。带状态函数具有“染色”特性:若调用嵌套的带状态函数,其状态会累加到调用者的状态中。不过这种机制在独立编译环境下可能行不通。
是的,尽管只是简短提及。我记得Borland大约在2002年左右曾尝试将其标准化,*同时还涉及属性机制。(我当时担任C++Builder产品经理,但那次尝试已过去十五年。)
C++Builder的整个UI系统都基于__closure构建,其效率极为出色:本质上是将对象实例与方法封装成精巧的胖指针。
[*] 编辑补充:论文中提及两个日期,但“绑定成员指针”概念已明确,且特别指出其与事件系统的关联:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n13…
阅读作者的闭包提案时,我构思出类似方案[1],其原理也与异步协程极为接近。
[1] https://github.com/ThePhD/future_cxx/issues/55#issuecomment-…
> 为“有状态”函数中的变量声明引入“state”关键字
Raku(原名Perl 6)已实现此功能!https://docs.raku.org/language/variables#The_state_declarato…
这是否类似于Rust处理异步的方式?编译器会创建一个状态机,表示每个await点及其作用域内的变量。函数恢复执行时,该状态机将传递给另一个函数,该函数根据状态匹配并继续异步函数的执行,最终返回另一个状态或最终值。
听起来很酷,但很快就会变得复杂。需要解决的几个方面:
– 自动定义的结构体存放在哪里?数据段可能适用于静态情况,但无法支持动态使用。若闭包存活时间超过函数上下文(如回调、未来),栈空间将变成垃圾。堆可能可行,但如何在没有C++/Rust RAII机制的情况下防止内存泄漏?
– 函数指针可复制或移动,但状态区域通常不可移动。它可能包含指向栈对象的指针或指向自身的指针(类似Rust的pin机制)
– 你已提及递归和编译问题
– …
我认为C语言的解决方案是允许用户显式管理上下文区域,类似posix的ucontext.h或作者闭包提案[1]中处理闭包分配的方式。[1] https://thephd.dev/_vendor/future_cxx/papers/C%20-%20Functio…
顺便说一句:我在此处阐述了lambda设计为何不适合C语言:
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3654.pdf
(而且我对微基准测试并不感冒)
从引言来看,你的论文似乎是种反向提案:支持闭包,但反对他人提议的方式。但论文似乎默认直接在语言层面支持闭包/嵌套函数对C语言而言是“好事”。我对此持异议。这种共识究竟何时如何形成的?
目前尚未就相关议题达成任何共识。但多数人认同闭包具有积极意义,因其能使某些重复出现的编程模式更安全、更易实现。你为何持反对意见?
我认为类似GNU扩展的局部函数(通过C++式按引用捕获(&)的lambda表达式实现)才是C语言的最佳选择。
你可以直接调用局部函数,同时享受专属代码的优势。
该函数类型无法明确标注,也无法存储在任何位置——普通函数同样如此!
若需传递,必须使用类型擦除的“胖指针”版本。
我认为C语言别无他法。
> 无法明确描述此函数的类型,也无法将其存储在任何地方。普通函数同样如此!
普通函数会衰变为函数指针。当然,C语言中可以实现与std::function_ref(或类似的Borland _closure)等效的机制,让闭包衰变为该类型。
GCC实现嵌套(局部)函数的代价是可执行文件栈中存在“跳板”机制。
我虽推崇嵌套函数,但认为这种可执行栈黑客手段得不偿失,采用“显示器”方案才是更优解。
参见《龙书》或Louden的《编译器构造:原理与实践》(1984年版)
你误解了我的评论。GNU局部函数是语法层面的,而C++[&]lambda是行为层面的(即隐藏结构体)。
确实如此,我的评论仅针对C语言。
GCC需要可执行跳转函数的唯一原因,是让程序能创建普通函数指针并携带所有捕获数据。该提案旨在复用嵌套函数的语法,但改变其语义:不再通过普通函数指针调用,而是使用“胖指针”同时引用捕获数据和原始函数地址。这类似于C++的方法,且无需跳转函数。
很久以前我写过C语言。能否有人解释为何第一个代码片段采用这种参数解析方式?
int main(int argc, char* argv[]) {
}
为什么不改成
if (argc > 1 && strcmp(argv[1], “-r”) == 0) {
}
这样的写法?
当代码已检查’-‘位于索引0时,使用strchr来确定’r’的位置根本毫无意义。
你的解决方案完全没问题。即使因某种原因无法使用strchr,原始代码片段也实在过于迂回。
若真有必要,直接写成 (strlen(argv[1]) > 1 && argv[1][0] == ‘-’ && argv[1][0] == ‘r’) 即可。
使用strchr或许有些道理,因为在典型的UNIX工具中,单字符命令行选项可以组合使用。但这也意味着后续代码不应依赖特定位置进行检测。
若你真要处理命令行解析,请使用getopt()。它能可靠处理所有边界情况,且与其他工具保持一致。
当然,C语言中的
&&本身具有短路特性,因此只要参数存在(即非NULL),即使省略strlen()也安全。另外,使用复杂的
if语句来条件赋值布尔常量属于代码异味(个人观点),建议移除if并改写为:除非需要更前瞻性/严格的检查。
你的代码实际存在两个错误。第一个我认为是笔误,你本意应为[1][1] == ‘r’。第二个错误是代码会同时接受“-rblah”这类参数。
我怀疑这是从更大代码片段移植而来,该片段支持将“-abc”解析为“-a -b -c”等形式。
更不用说(*right – *left)和(*left – *right)可能引发有符号整数溢出,这属于未定义行为。即使依赖常见的补码溢出机制,结果也可能错误——例如(INT_MAX – (-1))按数学原理应得正值,但函数却返回负值INT_MIN。
然后我们有了这种“现代”的指针写法:“const int* right”(注意空格)。在C语言中,声明语法应与使用方式一致,因此应为“const int *right”,因为“*right”本身就是一个“const int”。
搞这些破玩意儿真让我感觉自己老了。:(
不知能否仅用算术运算实现,完全避免比较操作?
在C语言中定义回调接口却不提供用户上下文参数,简直是滔天大罪。
说实话,我对C闭包的唯一期待就是提供更优雅的语法实现这种功能。无论最终方案如何(如果真要加入),必须确保能与“传统”函数指针+用户上下文API互操作。(包括告知编译器用户上下文指针在闭包参数中的位置。)否则就完全没用。
仅此而已。我不理解大家对闭包的痴迷。
我在现代C++中大量使用过lambda表达式,对此深恶痛绝。
我也用过OCaml。这门语言太棒了,这类特性在其中显得极其自然而优美。
我不明白为何有人要硬把函数式编程塞进C语言。C++原本就够糟糕了,现在更糟了。
> 我们将重点关注C和C++中的闭包,因为这关乎尝试实现并最终为ISO C制定一套适用于所有人的标准方案。
唉。心都凉了。
这确实显得过于复杂。我认为更好的方案是直接采用指针大小的双元类型。这种模式反复出现——上下文指针与函数指针、数组及其大小、内存分配与实际容量等。将两部分拆分到不同变量的问题在于,很容易搞不清数据的具体位置。若整合为单一单元,使用起来会简单得多。具体设计确实需要深入考量——既要比到处写匿名结构体(我现已能做到)更简洁,又要足够灵活以适应多数场景。
> 若整合为单一单元,使用起来会简单得多。
我常见到这种现象;我称这种奇妙的重复聚合模式为“封装”。或许我写篇博客探讨它,能让这个概念流行起来。
Stewart Lynch 在他的10x视频课程中提到了他在C中的自定义函数抽象。这种设计极其简洁明确,避免了Clambda表达式对
auto的依赖。使用方式类似于:我从未亲自实现过,毕竟很少用到C++特性,但作为个人项目总想尝试。不知这种方案与现有实现相比如何?
这不就是把函数传递给std::bind_front,再存储到std::function或std::function_ref里吗?
我正考虑用C++做个人项目,主要是看中lambda和RAII特性。
我有个场景需要创建静态模板lambda,作为指针传递给C语言。这在最初考虑的Rust中是无法实现的。
没错,Rust中捕获数据的闭包本质是胖指针{ fn*, data* },因此需要通过复杂操作将其转换为C语言的瘦指针。
由于没有分配机制或可执行栈魔法为每个数据实例提供唯一函数指针,因此C函数需要userdata参数。另一方面,这种实现成本为零。泛型make_trampoline会内联闭包代码,因此不存在额外间接操作。
> 捕获数据的Rust闭包是胖指针 { fn*, data* }
此说法不够严谨。在你的示例中,
&mut C的布局实际上与usize相同,并非胖指针。C是具体类型,本质上只是实现FnMut的匿名结构体。你可能想的是
&mut dyn FnMut,这种类型才是胖指针——它将数据指针与虚函数表指针配对存储。因此在你的具体示例中,双重间接引用实属多余。
以下通过miri验证:https://play.rust-lang.org/?version=nightly&mode=debug&editi…
(在手机上操作,请原谅可能的凌乱)。
这其实是所有捕获闭包的通病,不仅限于Rust。纯函数指针参数无法携带状态,若无用户数据参数则无法构建跳板函数。若C++调用C API时受相同限制,也会遇到同样问题。
当然,这里指的是像C++lambda或Rust闭包那样实现的捕获闭包。可执行堆栈犯罪确实会创建带状态的薄函数指针。
若Rust在函数参数中对data*的位置(推测是首位?)有稳定的ABI规范,且该规范与C代码预期的函数签名(包括用户上下文参数)匹配,则无需额外操作。
遗憾的是,大量现有C API不会将用户参数置于所需位置——它们可能出现在首部、末尾,甚至夹杂在中间。
我了解这种技术,但它使用了过多unsafe语法,不符合我的偏好。并非技术本身有问题,纯属个人喜好。
在Rust中完全可以实现100%安全的代码(若使用
dyn Fn类型替代c_void)。此处唯一不安全的部分是演示C/C++ FFI的兼容性(其中void* userdata实际不具备类型安全性)
在Rust中,能否改用模板结构体封装函数指针并配合#[repr(C)]实现?
我认为测试结果更多反映了测试方法和内联设置的问题。
实际应用中,除涉及内存分配的lambda方案(这种设计本身就值得商榷)外,其余方案在内联条件下完全等效。
特别是类型擦除/辅助函数变体存在一个关键缺陷:它们会阻碍内联优化。但当所有内容位于同一翻译单元且非运行时驱动时,编译器仍可能执行反虚化操作。
我认为更有价值的测试方案是:在显式控制内联行为或函数类型能否静态推导的情况下进行性能测量。
在具备足够优秀™的编译器下,经过反虚化和堆内存省略后,所有变体确实应生成完全相同的代码。但实际情况更为复杂。反虚化需在(可能涉及跨过程的)常量传播之后执行,此时可能错失其他优化机会——除非编译器持续重启优化管道。
简单测试表明,GCC14能完美消除std::function_ref的开销,但普通std::function却问题重重。
终将实现目标[1],但在此之前我倾向于不依赖虚函数消解,而堆内存消除更像是花哨的把戏。
编辑:关于早期与后期内联的对比:虽然 gcc 14 能移除一层 function_ref,但似乎无法移除两层,因为它显然不会重新运行必要的优化阶段来利用新出现的优化机会。当然,它移除任意数量(但有限)的普通 lambda 层时毫无问题。
编辑2:GCC15能移除std::function的简单用法,但实现非常脆弱。它依然无法移除两层函数引用。
[1] 例如25年前编译器移除STL抽象开销的效果极差,如今成本已微乎其微。
只需设计基准测试使优化无法生效即可。
线程局部变量确实能解决问题。你需要为原始函数创建封装器,设置全局线程局部用户数据,并传入一个函数——该函数调用接受用户数据的函数指针时会使用全局数据。
线程局部变量并不能完全解决问题。当你立即调用闭包时效果良好,但若想存储闭包并稍后调用呢?
因为我们在创建
normal_sort和调用它之间创建了reverse_sort,结果导致明明要求正常排序却得到了反向排序。解决方法是进行封装,并且在线程局部变量存储的内容不再需要时才返回
没错。线程局部变量可能比其他方案都更快。
让我困惑的是,既说线程局部变量“在小片段之外并非最佳选择”,顶部解决方案却采用递归深度模板化并设置11的constexpr限制。
在函数中使用静态变量存储状态的方法在ANSI C书籍中被大量采用。坦白说,当谨慎使用时,这确实是种优雅的技术。
可重入性。
它不会为后续操作存储状态。这种行为完全无法被察觉。
我其实挺喜欢C语言的跳板函数,这是我偶尔会用到的GNU扩展之一。
这篇帖子讨论的是Man or Boy…唯一拼写错误是…单词_son_。我敢肯定它应该写成“on”
C++终将胜出!!终于!!
lambda表达式、代码块和嵌套函数的解析表明,除了语法外,实现细节和ABI规范同样至关重要。我认为C标准应包含直接支持的一流全局函数指针,并完善闭包机制,才能杜绝这些半便携又诡异的扩展。
深表赞同。
但愿JS专家们能理解这点,别再盲目追捧钩子机制,让每个Web应用的运行时占用空间都膨胀得离谱