C++ lambda与std::function底层实现:从匿名仿函数到类型擦除容器
1. 项目概述:从lambda到可调用对象容器的探索
最近在重构一个老项目的异步任务队列时,我又一次被C++11引入的lambda表达式和 std::function 给“救”了。场景很简单:需要把一堆不同类型的回调函数(有普通函数、有成员函数、还有临时写的逻辑)塞到一个容器里,然后按顺序触发。放在以前,要么得定义一堆函数指针和仿函数类,要么就得用上那些让人头大的模板和继承体系,代码又臭又长。但现在,一个 std::vector<std::function<void()>> 就能轻松搞定, lambda 写起来更是行云流水。用多了之后,好奇心就上来了:这 lambda 到底是个什么“魔法”? std::function 这个“万能容器”又是怎么做到能装下这么多不同“形状”的可调用对象的?光是会用,总觉得差点意思。于是,我决定顺着编译器的思路,自己动手“拆解”一遍lambda,并尝试实现一个简化版的 MyFunction 容器,把里面的门道摸个清楚。这个过程,不仅是对C++对象模型和模板编程的一次深度复习,更能让你在下次遇到相关编译错误或性能问题时,心里跟明镜似的。
简单来说,这个“项目”的目标就是 深入理解C++11 lambda表达式的底层实现原理,并基于此原理,手动实现一个类似 std::function 的通用可调用对象包装器 。我们会看到,lambda本质上就是编译器为我们生成的一个匿名仿函数类(Functor),而 std::function 则是一个运用了类型擦除(Type Erasure)技术的高度模板化的容器。适合阅读这篇笔记的你,可能已经用过 lambda 和 std::function ,但对它们“为何能工作”心存疑问;或者你正想深入C++模板和对象模型,寻找一个有趣又实用的切入点。接下来,我们就从最基础的lambda开始,一步步揭开它的面纱。
2. lambda表达式:编译器的“语法糖”与匿名仿函数
初看 [capture] (params) mutable -> retType { body } 这套语法,可能会觉得有点复杂。但它的核心思想很简单: 就地定义一个函数对象 。我们写的lambda代码,绝大部分工作都交给了编译器去展开。
2.1 lambda的底层转换:一个具体的例子
让我们写一个最简单的lambda,并想象编译器为它做了什么。
int threshold = 10;
auto isAboveThreshold = [threshold](int value) -> bool {
return value > threshold;
};
对于编译器(比如GCC或Clang)来说,它大致会把这个lambda转换成一个 局部匿名类 ,这个类的行为和下面我们手写的类几乎一样:
// 编译器生成一个类似这样的匿名类
class __AnonymousLambdaClass_1 {
private:
int threshold; // 捕获的变量
public:
// 构造函数,用于初始化捕获的变量
__AnonymousLambdaClass_1(int th) : threshold(th) {}
// 重载函数调用运算符,这就是仿函数的本质
bool operator()(int value) const { // 注意:默认是const的
return value > threshold;
}
};
// 然后,我们的 `auto isAboveThreshold = ...` 这一行,实际上相当于:
__AnonymousLambdaClass_1 isAboveThreshold(threshold);
看明白了吗? isAboveThreshold 这个我们以为的“lambda变量”,其实是一个 类对象 ,一个仿函数对象。我们调用 isAboveThreshold(15) ,实际上是在调用这个匿名类对象的 operator() 。
关键理解 :lambda的类型是编译器生成的、唯一的、匿名的。这就是为什么你不能直接用
decltype(lambda)作为模板参数去声明另一个同类型变量(除非用auto),因为每个lambda表达式,即使看起来一样,在编译器看来类型也可能不同。
2.2 捕获列表的奥秘:值捕获、引用捕获与初始化捕获
捕获列表 [] 决定了这个匿名类有哪些数据成员。
- 值捕获
[var]:如上例,匿名类会有一个对应类型的数据成员var,并在构造函数中 拷贝 传入的值。对lambda体内var的修改,不会影响外部的原始变量(除非使用了mutable,但修改的也是内部副本)。 - 引用捕获
[&var]:匿名类会有一个对应类型的 引用成员int& var。构造函数用传入的引用初始化它。lambda体内对var的操作,直接作用于外部变量。 - 隐式捕获 :
[=]表示以值方式捕获所有外部变量;[&]表示以引用方式捕获所有。方便,但不够精确,可能引发不必要的拷贝或悬空引用问题,需谨慎使用。 - 初始化捕获(C++14)
[var = expr]:这非常强大。它允许你为捕获的成员指定一个初始化表达式,这个表达式甚至可以移动(std::move)一个只移动类型。例如[ptr = std::make_unique<int>(5)],这直接在匿名类内部创建并持有了一个unique_ptr。
std::unique_ptr<int> up = std::make_unique<int>(42);
// C++14 初始化捕获,移动语义
auto lambda = [data = std::move(up)]() { return *data; };
// 转换后的类大致如下:
class __AnonymousLambdaClass_2 {
std::unique_ptr<int> data;
public:
__AnonymousLambdaClass_2(std::unique_ptr<int>&& d) : data(std::move(d)) {}
int operator()() const { return *data; }
};
2.3 mutable 与 const operator()
这是新手常踩的坑。默认情况下,lambda的 operator() 是 const成员函数 。这意味着,对于 值捕获 的变量,你在lambda体内是不能修改的(因为const成员函数不能修改类的非mutable数据成员)。
int counter = 0;
auto increment = [counter]() { counter++; }; // 编译错误!不能修改值捕获的counter
要修改值捕获的变量,必须加上 mutable 关键字:
auto increment = [counter]() mutable { counter++; };
// 转换后的类,operator() 不再是 const
class __AnonymousLambdaClass_3 {
int counter;
public:
__AnonymousLambdaClass_3(int c) : counter(c) {}
int operator()() { // 注意,没有const了
return ++counter; // 现在可以修改了
}
};
需要注意的是,即使加了 mutable ,修改的也是匿名类内部的那个副本,外部的 counter 变量依然不变。 mutable 对引用捕获的变量没有影响,因为修改引用指向的内容并不违反const成员函数的规则(引用本身是“常量引用”,不能绑定到别的变量,但可以修改其指向的值)。
2.4 返回类型推断与尾置返回类型
大多数情况下,编译器可以自动推断lambda的返回类型。但当函数体中有多个 return 语句且类型不完全相同,或者是一个复杂的表达式时,最好使用尾置返回类型 -> retType 来明确指定,避免意外的类型推导结果。
// 编译器推断为 double
auto divide = [](double a, double b) { return a / b; };
// 需要明确指定返回类型
auto safe_divide = [](double a, double b) -> double {
if (b == 0.0) return 0.0; // 两个return语句,但类型一致,仍可推断
return a / b;
};
3. std::function:类型擦除的魔法容器
理解了lambda是仿函数,我们就能明白,为什么 std::function 能存储它。因为 std::function 的设计目标就是: 存储任何符合给定签名(Callable Signature)的可调用对象 。这包括了普通函数、函数指针、成员函数指针、lambda表达式(即匿名仿函数对象)、以及任何重载了 operator() 的类对象。
它的核心挑战在于:这些可调用对象的 类型各不相同 。一个 std::function<void()> ,既要能存一个无捕获的lambda(一种类型),又要能存一个有捕获的lambda(另一种类型),还要能存一个全局函数(又是另一种类型)。 std::function 的解决方案是经典的 类型擦除(Type Erasure) 。
3.1 类型擦除的基本思想
类型擦除,简单说就是 在运行时保留操作(这里是“调用”),而丢弃具体的类型信息 。它通常通过一个基类接口和模板派生类来实现:
- 定义一个通用的调用接口 (抽象基类)。
- 为每一种需要存储的具体类型,创建一个模板派生类 。这个派生类知道具体类型,并实现调用接口。
- 在包装器(如
std::function)内部,用一个指向基类的指针(或智能指针)来管理这个派生类对象 。 - 当需要调用时,通过基类指针进行虚函数调用,最终派发到具体的派生类对象上。
这样,从 std::function 的外部看,它只有一个统一的类型(如 std::function<void(int)> ),但内部却可以持有任意多种不同类型的可调用对象。
3.2 拆解std::function的可能实现
让我们尝试勾勒一个极度简化的 MyFunction ,只支持 R(Args...) 这样的签名。为了安全管理内存,我们使用 std::unique_ptr 。
首先,定义调用接口:
template<typename R, typename... Args>
class CallableBase {
public:
virtual ~CallableBase() = default;
virtual R invoke(Args... args) = 0;
virtual std::unique_ptr<CallableBase> clone() const = 0; // 用于拷贝
};
接着,创建模板派生类,用于持有具体类型的可调用对象 F :
template<typename F, typename R, typename... Args>
class CallableImpl : public CallableBase<R, Args...> {
F f_; // 实际存储的可调用对象
public:
explicit CallableImpl(F&& f) : f_(std::forward<F>(f)) {}
R invoke(Args... args) override {
// 关键!调用存储的f_,并完美转发参数
return f_(std::forward<Args>(args)...);
}
std::unique_ptr<CallableBase<R, Args...>> clone() const override {
// 假设F是可拷贝的,这里需要F也支持拷贝构造
return std::make_unique<CallableImpl<F, R, Args...>>(f_);
}
};
最后,构建我们的 MyFunction 类:
template<typename Signature>
class MyFunction; // 前向声明
template<typename R, typename... Args>
class MyFunction<R(Args...)> {
std::unique_ptr<CallableBase<R, Args...>> callable_;
public:
// 默认构造,空状态
MyFunction() = default;
// 模板构造函数,接受任何可调用对象F
template<typename F,
typename = std::enable_if_t<
!std::is_same_v<std::decay_t<F>, MyFunction> &&
std::is_invocable_r_v<R, F, Args...>
>>
MyFunction(F&& f) {
// 用具体类型F创建CallableImpl
callable_ = std::make_unique<CallableImpl<std::decay_t<F>, R, Args...>>(
std::forward<F>(f)
);
}
// 调用运算符
R operator()(Args... args) const {
if (!callable_) {
throw std::bad_function_call();
}
return callable_->invoke(std::forward<Args>(args)...);
}
// 拷贝操作(需要深拷贝)
MyFunction(const MyFunction& other) {
if (other.callable_) {
callable_ = other.callable_->clone();
}
}
MyFunction& operator=(const MyFunction& other) {
if (this != &other) {
if (other.callable_) {
callable_ = other.callable_->clone();
} else {
callable_.reset();
}
}
return *this;
}
// 移动操作
MyFunction(MyFunction&&) noexcept = default;
MyFunction& operator=(MyFunction&&) noexcept = default;
// 显式bool转换,检查是否为空
explicit operator bool() const noexcept {
return static_cast<bool>(callable_);
}
};
这就是 MyFunction 的核心骨架。它通过模板构造函数擦除了输入可调用对象 F 的具体类型,将其“隐藏”在 CallableBase 指针之后。 operator() 通过虚函数表,将调用正确路由到实际存储的 CallableImpl<F, ...> 对象上。
3.3 实现中的关键细节与陷阱
-
std::decay_t<F>的使用 :在模板构造函数中,我们用std::decay_t<F>来存储。这很重要,因为它会去掉引用和cv限定符(const/volatile),并处理函数到指针的转换,确保我们存储的是一个“干净”的值类型,避免引用悬空等问题。 -
std::is_invocable_r_v类型约束 :这个SFINAE约束确保传入的可调用对象F能够用Args...参数调用,并且其返回类型可以转换为R。这是保证类型安全的关键。 - 防止用
MyFunction构造MyFunction:!std::is_same_v<std::decay_t<F>, MyFunction>这个条件防止了模板构造函数匹配到拷贝/移动构造函数,否则会导致无限递归。 - 拷贝语义 :
std::function要求是可拷贝的。由于我们存储的类型F可能是不可拷贝的(比如捕获了unique_ptr的lambda),所以std::function在拷贝时,如果底层对象不可拷贝,它会在运行时抛出std::bad_function_call(或类似异常)。我们的简化版通过clone()虚函数实现了深拷贝,但前提是F必须可拷贝。一个更完善的实现会像标准库一样,在CallableImpl中根据F的is_copy_constructible特性来有条件地提供clone(),或者存储一个可选的拷贝函数指针。 - 小对象优化(Small Buffer Optimization, SBO) :真实的
std::function实现(如libstdc++, libc++)通常不会直接使用堆分配。它们内部有一个小的缓冲区(例如16或24字节),如果存储的可调用对象尺寸小于这个缓冲区,就将其直接放在std::function对象内部(placement new),避免昂贵的堆内存分配。只有大对象才使用堆。这是一个重要的性能优化,但实现起来复杂得多。
4. 动手实现:一个具备基本功能的MyFunction
基于上面的分析,我们来写一个更完整、更健壮一点的 MyFunction ,并测试它。我们将专注于核心功能,暂时不实现SBO。
4.1 完整代码实现
#include <memory>
#include <type_traits>
#include <utility>
// 调用接口基类
template <typename R, typename... Args>
class CallableBase {
public:
virtual ~CallableBase() = default;
virtual R invoke(Args... args) = 0;
virtual std::unique_ptr<CallableBase> clone() const = 0;
};
// 调用接口实现类
template <typename F, typename R, typename... Args>
class CallableImpl : public CallableBase<R, Args...> {
F f_;
public:
explicit CallableImpl(F f) : f_(std::move(f)) {}
R invoke(Args... args) override {
// 使用std::invoke以实现更通用的调用(支持成员函数指针等)
return std::invoke(f_, std::forward<Args>(args)...);
}
std::unique_ptr<CallableBase<R, Args...>> clone() const override {
// 只有F可拷贝时才能克隆,否则编译失败或运行时处理。
// 这里简化处理,假设F都是可拷贝的。生产代码需要更复杂的检查。
return std::make_unique<CallableImpl>(f_);
}
};
// 主模板声明
template <typename>
class MyFunction;
// 特化,匹配函数签名 R(Args...)
template <typename R, typename... Args>
class MyFunction<R(Args...)> {
std::unique_ptr<CallableBase<R, Args...>> callable_;
public:
using result_type = R;
// 默认构造(空函数)
MyFunction() noexcept = default;
MyFunction(std::nullptr_t) noexcept : MyFunction() {}
// 模板构造函数,类型擦除发生在这里
template <typename F,
typename = std::enable_if_t<
!std::is_same_v<std::decay_t<F>, MyFunction> &&
std::is_invocable_r_v<R, F, Args...>>>
MyFunction(F&& f) {
// 使用decay_t获取F的值类型
using DecayedF = std::decay_t<F>;
callable_ = std::make_unique<CallableImpl<DecayedF, R, Args...>>(
std::forward<F>(f));
}
// 调用操作符
R operator()(Args... args) const {
if (!callable_) {
throw std::bad_function_call();
}
return callable_->invoke(std::forward<Args>(args)...);
}
// 拷贝构造函数(深拷贝)
MyFunction(const MyFunction& other) {
if (other.callable_) {
callable_ = other.callable_->clone();
}
}
// 拷贝赋值运算符
MyFunction& operator=(const MyFunction& other) {
if (this != &other) {
// 使用copy-and-swap idiom可能更优雅,这里为清晰直接实现
if (other.callable_) {
callable_ = other.callable_->clone();
} else {
callable_.reset();
}
}
return *this;
}
// 移动操作(默认即可)
MyFunction(MyFunction&&) noexcept = default;
MyFunction& operator=(MyFunction&&) noexcept = default;
// 从nullptr赋值
MyFunction& operator=(std::nullptr_t) noexcept {
callable_.reset();
return *this;
}
// 与nullptr比较
bool operator==(std::nullptr_t) const noexcept { return !callable_; }
bool operator!=(std::nullptr_t) const noexcept { return static_cast<bool>(callable_); }
// 显式bool转换
explicit operator bool() const noexcept { return static_cast<bool>(callable_); }
// 交换
void swap(MyFunction& other) noexcept {
callable_.swap(other.callable_);
}
};
// 特化swap算法
template <typename R, typename... Args>
void swap(MyFunction<R(Args...)>& lhs, MyFunction<R(Args...)>& rhs) noexcept {
lhs.swap(rhs);
}
4.2 测试我们的MyFunction
现在,让我们用各种可调用对象来测试它。
#include <iostream>
#include <cassert>
// 1. 普通函数
int add(int a, int b) { return a + b; }
// 2. 函数对象(仿函数)
struct Multiplier {
int factor;
Multiplier(int f) : factor(f) {}
int operator()(int x) const { return x * factor; }
};
// 3. Lambda表达式(值捕获)
int main() {
// 测试1: 存储和调用普通函数
MyFunction<int(int, int)> func1 = add;
std::cout << "func1(2, 3) = " << func1(2, 3) << std::endl; // 输出 5
// 测试2: 存储和调用仿函数对象
Multiplier timesTwo{2};
MyFunction<int(int)> func2 = timesTwo;
std::cout << "func2(5) = " << func2(5) << std::endl; // 输出 10
// 也可以直接传入临时对象
MyFunction<int(int)> func3 = Multiplier{3};
std::cout << "func3(5) = " << func3(5) << std::endl; // 输出 15
// 测试3: 存储lambda表达式
int base = 100;
auto lambda = [base](int offset) -> int { return base + offset; };
MyFunction<int(int)> func4 = lambda;
std::cout << "func4(25) = " << func4(25) << std::endl; // 输出 125
// 测试4: 直接传入lambda
MyFunction<int()> func5 = []() { return 42; };
std::cout << "func5() = " << func5() << std::endl; // 输出 42
// 测试5: 拷贝语义
auto func6 = func4; // 调用拷贝构造函数
std::cout << "func6(25) = " << func6(25) << std::endl; // 输出 125
assert(func4); // func4 应该仍然有效
assert(func6); // func6 也应该有效
// 测试6: 移动语义
MyFunction<int(int)> func7 = std::move(func2);
std::cout << "func7(5) = " << func7(5) << std::endl; // 输出 10
// assert(!func2); // func2 被移动后应为空(实际取决于unique_ptr移动后的状态,通常为空)
// 测试7: 空状态与bool转换
MyFunction<void()> emptyFunc;
if (!emptyFunc) {
std::cout << "emptyFunc is empty (as expected)." << std::endl;
}
emptyFunc = []() { std::cout << "Now I'm not empty!" << std::endl; };
if (emptyFunc) {
emptyFunc(); // 输出 "Now I'm not empty!"
}
// 测试8: 赋值nullptr
emptyFunc = nullptr;
if (!emptyFunc) {
std::cout << "emptyFunc is empty again." << std::endl;
}
// 测试9: 错误处理 - 调用空函数
MyFunction<int()> badFunc;
try {
int val = badFunc(); // 应该抛出 std::bad_function_call
(void)val;
} catch (const std::bad_function_call& e) {
std::cout << "Caught expected exception: " << e.what() << std::endl;
}
std::cout << "All tests passed!" << std::endl;
return 0;
}
这个测试覆盖了主要的使用场景,验证了我们的 MyFunction 具备了和 std::function 相似的基础能力。
5. 性能考量、适用场景与陷阱
自己实现一遍之后,对 std::function 的性能和限制就有了更直观的感受。
5.1 性能开销分析
std::function (以及我们的 MyFunction )的性能开销主要来自三个方面:
- 间接调用开销 :由于使用了虚函数(通过基类指针调用),会有一层虚函数表跳转的开销。这通常是一次指针解引用和一次跳转,在现代CPU上开销很小,但在极高性能敏感的循环中可能需要考虑。
- 动态内存分配开销 :除非实现使用了小对象优化(SBO),否则每次构造一个
std::function(除非从另一个移动构造)都可能伴随一次堆内存分配。这是主要的性能瓶颈。 - 拷贝开销 :拷贝
std::function可能涉及底层可调用对象的拷贝(如果它可拷贝)以及一次新的内存分配。如果底层对象很大或拷贝成本高,开销会很大。
优化建议 :
- 对于生命周期短、频繁创建的小型可调用对象,可以考虑直接使用模板,让编译器在编译期完成类型绑定和内联优化,完全消除运行时开销。例如,使用
template<typename F> void apply(F&& f)这样的函数模板。 - 如果确需存储,且可调用对象很小(例如一个无捕获的lambda),可以期待标准库实现的SBO生效,避免堆分配。
- 优先使用移动语义来传递
std::function,避免不必要的拷贝。
5.2 与模板、函数指针、auto的对比
| 特性 | std::function |
函数模板 ( template<typename F> ) |
函数指针 | auto 变量 |
|---|---|---|---|---|
| 类型 | 具体类型,可存储 | 不是类型,是蓝图 | 具体类型 | 推导出的具体类型 |
| 类型擦除 | 是,运行时多态 | 否,编译时多态 | 是(但签名严格固定) | 否 |
| 存储能力 | 强,可放入容器 | 弱,本身不能存储 | 弱,只能存特定函数地址 | 强,但类型固定 |
| 性能 | 有运行时开销(虚调用,可能堆分配) | 最优,常可内联 | 开销小(一次指针调用) | 最优,与原始对象同 |
| 灵活性 | 高,可存储任何签名匹配的可调用对象 | 最高,接受任何可调用对象 | 最低,只能匹配精确函数签名 | 高,但类型在初始化后固定 |
| 典型用途 | 回调注册、事件系统、需要类型擦除的容器 | 通用算法、高阶函数、需要最佳性能处 | C接口、需要明确函数地址处 | 局部存储推导出的可调用对象 |
简单决策流 :
- 需要将 不同类型的可调用对象放入同一个容器 (如
vector) → 用std::function。 - 写一个 通用算法或工具函数 ,接受一个操作 → 用 函数模板 (性能最好,最灵活)。
- 只需要调用一个 已知签名的特定函数 → 可以用 函数指针 ,但通常直接用函数名或
std::function也行。 - 在局部作用域 存储一个lambda或其它可调用对象,且后续类型不变 → 用
auto。
5.3 常见陷阱与避坑指南
-
陷阱一:捕获引用导致的悬空引用
std::function<void()> createFunction() { int localVar = 42; return [&localVar]() { std::cout << localVar; }; // 危险! } // localVar 被销毁 auto func = createFunction(); func(); // 未定义行为!访问已销毁的局部变量。避坑 :谨慎使用引用捕获
[&],确保被捕获引用的对象的生命周期长于lambda本身。对于需要返回或长期存储的lambda,优先考虑值捕获[=]或显式传递shared_ptr。 -
陷阱二:默认捕获
[=]或[&]的隐蔽性 默认捕获可能无意中捕获了this指针(在成员函数内定义的lambda),同样有悬空风险。class Processor { std::function<void()> callback_; int data_; public: void setCallback() { // [=] 可能捕获了this! callback_ = [=]() { std::cout << data_; }; } ~Processor() { /* 如果callback_被复制到别处并使用,this可能已无效 */ } };避坑 :尽量使用显式捕获列表。如果需要在lambda内使用成员变量,考虑捕获
this(C++17后建议使用[*this]以值方式捕获当前对象的副本)或传递必要的成员变量值。 -
陷阱三:
std::function与 重载函数std::function无法直接从一个重载函数名初始化,因为存在歧义。void foo(int) {} void foo(double) {} std::function<void(int)> f = foo; // 错误:哪个foo?避坑 :使用静态转换或lambda来明确指定。
std::function<void(int)> f1 = static_cast<void(*)(int)>(foo); std::function<void(int)> f2 = [](int x) { return foo(x); }; // 利用模板参数推导 -
陷阱四:性能敏感处的滥用 在每秒调用数百万次的循环内部构造或拷贝
std::function,会带来巨大的性能损失。 避坑 :在循环外构造好std::function,在循环内只进行调用。或者,如果可能,使用模板替代。
6. 从MyFunction到生产级代码的思考
我们实现的 MyFunction 是一个教学模型,揭示了核心原理。但要达到 std::function 的生产级别,还有很长的路要走:
- 小对象优化(SBO) :这是最大的挑战。需要设计一个联合体(union)或类似结构,内部包含一个小缓冲区和一个指向堆内存的指针。根据对象大小决定使用哪种存储。这涉及到对齐(alignment)、placement new、显式析构等底层操作。
- 完善的异常安全 :所有操作(构造、赋值、交换)都需要提供强异常安全保证。例如,拷贝赋值操作通常采用“copy-and-swap”惯用法。
- 支持更多调用形式 :真正的
std::function支持const、volatile、&、&&等修饰的调用运算符(即R(Args...) const等)。这需要更复杂的模板特化和继承体系。 - 更精确的类型检查与错误信息 :我们的实现使用了
std::is_invocable_r_v,但标准库的实现可能包含更复杂的SFINAE或concept(C++20)来提供更清晰的编译错误信息。 - 自定义分配器支持 :标准库的许多组件都支持分配器,
std::function也可以,允许用户控制内存分配行为。
实现这些需要深厚的C++功底,但通过这个自制的轮子,我们已经彻底理解了 std::function 和lambda是如何协同工作,为C++带来了强大的运行时多态调用能力。下次当你流畅地写下 std::function 和lambda时,你会知道,这简洁语法的背后,是编译器精妙的代码生成和标准库精巧的类型擦除设计在为你保驾护航。
更多推荐
所有评论(0)