快速掌握 Modern C++ 标准库特性,基于 Vittorio Romeo 发布的教程编撰。
C++ 后面跟着的数字越来越大,才发现漏学了亿个特性,赶紧捡起来重新看一遍。
1. Value Categories and Move Semantics
1.1 L-values and R-values
区分左值与右值
1 | int a; |
1 | int&& rv_ref0 = 5; |
函数的左、右值参数重载
1 | void foo(int &) { std::cout<<"non-const lvalue ref\n"; } |
- C++ 表达式分为两类:左值与右值。
- 左值可以出现在内建赋值号左侧,右值不能。
- 左值引用只能与左值绑定
- 右值引用只能与右值绑定
1.2 R-value refences and std::move
右值的意义:表示一个没有标识的临时对象,可以认为一个右值能将自己的资源所有权转移给他人。
std::move
- 顾名思义,
std::move
将一个左值(或其他)映射为右值引用 - 实际上并没有发生内存变化
- 一般来说使用一个被move后的值是不安全的
- moving when returning is unnecessary and sometimes detrimental
1 | // C++03 |
使用move
后的值可能会导致未定义行为(undefined behavior)错误。
1 | std::vector<data> v0; |
RVO(Return value optimization),返回值优化,一种编译器技术:https://en.wikipedia.org/wiki/Copy_elision
在return
时使用std::move
会导致编译器不启用 RVO,从而降低效率。
1 | std::vector<data return_example() { |
当然如果需要刻意地将成员所有权移出对象,择机而用。
1 | struct foo { |
1.3 Practical Uses of std::move
Move Semantics in the Standard Library
- Many classes in the standard library are “move aware”
- Some classes represents “unique ownership” and are move-only
1 | std::pair<foo, int> p; |
std::vector
以及其他 container 的索引特性也会随着传入值变化
1 | std::vector<foo> v; |
有些类型具有 Unique Ownership 特性,运行时只能由一个标识持有所有权,只能使用std::move
来赋值。
1 | std::thread t{[]{ std::cout<<"thread!"; }}; |
避免std::move
导致的无谓消耗
将对象移入容器
设想一种情况:将文件以
std::string
的形式读入内存并放入std::vector
:1
2
3
4
5
6std::vector<std::string> files;
for (int i = 0; i < 10; ++i) {
std::string s = read_large_file(i);
// ...do some processing on `s`...
files.push_back(s);
}那么问题来了,上面的代码有无缺陷?该如何优化?
line 1
后插入files.reserve(10);
能预先将容器大小设置为 10 以避免容器扩展时的内存复制行为。line 5
修改为files.push_back(std::move(s));
,当然就是我们本章所述的优化重点。
用
move
取代复制容器1
2
3
4
5
6
7
8void consume_multiples(std::map<int, std::vector<int>);
std::map<int, std::vector<int>> multiples_of;
for (int i = 0; i < 100; ++i) {
auto i_multiples = get_multiples(i);
multiples_of[i] = i_multiples;
}
consume_multiples(multiples_of);改进:
line 6
为i_multiples
添加std::move
,或者可以直接将line 5-6
合并为multiples_of[i] = get_multiples(i);
。line 8
也可以套一层std::move
,但需要注意,consume_multiples
形参为左值形式,所以依然会复制内存。
关于 Sink Argument:https://dzone.com/articles/c-sink-parameter-passing
在为函数配置参数的时候,需要同时考虑左值与右值的情况。
1
2
3
4
5
6
7
8
9
10struct person {
std::string _name;
// before c++ 11
person(const std::string& name) : _name{name} { }
void set_name(const std::string& name) { _name = name; }
// after c++ 11, added below overloads
person(std::string&& name) : _name{std::move(name)} { }
void set_name(std::string&& name) { _name = std::move(name); }
}如上写法在传入左值时只执行一次复制,传入右值时只执行一次移动,确实难以挑刺。可是当参数个数为 N 时,我们需要重载 2^N 个函数以保证每个参数都能匹配左右值。
所以有了一个基本范式:
1
2
3
4
5struct person {
std::string _name;
person(std::string name) : _name{std::move(name)} { }
void set_name(std::string name) { _name = std::move(name); }
}- 传入左值时,将值复制到
std::string name
中,接着移动给成员(1 次复制 + 1 次移动)。 - 传入右值时,将值移动到
std::string name
中,接着移动给成员(1 次移动 + 1 次移动)。
这样一来能够牺牲极为微小的性能(一次移动),来节省指数级个数的重载函数。
1.4 Value Categories: The Full Picture
只把值分为左值、右值远远不能满足 c++ 移动语法的要求。事实上我们需要使用一个树来定义各个值的详细类型。
官方定义的值分类:https://en.cppreference.com/w/cpp/language/value_category
叶子节点(子分类)由三个类别组成:
PR-value (“Pure R-value”) 纯右值
L-value 左值
X-value (“eXpiring r-value”) 将亡值
X-value 与 L-value 统称为 GL-value,泛左值,Generalized L-value。
X-value 与 PR-value 统称为 R-value,右值。
1 | struct foo { }; |
1.5 Perfect Forwarding
当没有使用参数推导(argument deduction)时,&
意为左值引用,&&
意为右值引用。
1 | void take_lvalue(int&); |
而仅在使用模板参数推导时,&&
意为转发引用(forwarding reference)。哪里有 type deduction,哪里才有 forwarding reference。
转发引用研究:https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
Chinese Ver.:https://www.cnblogs.com/ConfuciusPei/p/12420082.html
1 | template <typename T> |
注意下面这个例子,将解释为什么需要转发引用以及完美转发的实现方式:
1 | class dictionary { |
std::forward
是怎样工作的?Cppref 中显式地定义为static_cast<T&&>(t)
。
1 | void sink(int&); |
可以这样认为:std::forward
是一种带有条件的std::move
,它仅在传入值为右值时才执行移动操作。
C++11 引入了如下 2 条规则:
模板函数对右值引用参数的推导
向一个模板函数传递一个左值实参,同时该模板函数的对应形参是右值引用,编译器会把该实参推导为左值引用。
引用折叠
由于存在引用的引用,而 C++ 不允许显式地定义它们,故编译器会将这种 double reference 折叠成 single reference。
原类别 折叠类别 T& &&,T&& &,T& & T& T&& && T&&
Used
std::exchange
to elegantly implement move operations for your classes
1
2
3
4
5
6
7 template<class T, class U = T>
constexpr // since C++20
T exchange(T& obj, U&& new_value) {
T old_value = std::move(obj); // 将`obj`转移至`old_value`上
obj = std::forward<U>(new_value); // 为`obj`完美转发`new_value`的值
return old_value;
}
2. Smart Pointer
2.1 What problem do they solve?
- Problems with manual dynamic memory allocation
- Why new/delete are considered harmful
传统使用new
与delete
时,如不规范会造成:
- 未
delete
造成的内存溢出 - 多次
delete
,即 Double-free Error - 访问已被
delete
的指针
1 | int memory_leak() { |
函数中若传入一个原生指针,我们无法分辨它是否需要在函数内delete
。选择销毁,外部若再销毁一次就会出现错误;选择不销毁,外部若不销毁则造成内存溢出。真是两难。
1 | void bar(int* x) { |
混乱的所有权需要写好多文档来声明:
1 | struct abc { |
这些问题在智能指针面前不攻自破。
2.2 Unique ownership: std::unique_ptr
需要明确以下概念:
- 所有资源都需要被申请与释放
- 动态内存、文件、线程都是资源的一种
- 唯一所有权(unique ownership)指有且只有一个所有者(owner)负责申请、释放资源
- 只能通过其所有者来访问资源
std::unique_ptr
是一个 move-only 的类型,以表示对资源的唯一所有权。
1 | // 欲使用智能指针,需包含 <memory> 头文件 |
std::unique_ptr
对象包装一个原始指针,并负责其生命周期。当该对象被销毁时,会在其析构函数中删除关联的原始指针。上例两个函数的操作与性能开销完全相同,不过后者能大幅减少写出 bug 的可能。
1 | std::unique_ptr<foo> f{new foo}; |
在f
将所有权交给f2
后,f
的销毁不会导致任何副作用。在函数中也是如此:
1 | std::unique_ptr<foo> gen_ptr() { |
然而!混合使用new
与智能指针,也仍会有一些运行时的潜在问题发生。阅读如下代码片段:
1 | void foo(std::unique_ptr<int>, int); |
在foo
被调用前有三种可能的执行顺序:
#0 | #1 | #2 |
---|---|---|
申请资源new int[5] 构造 unique_ptr 执行 err() 抛出异常 |
执行err() 抛出异常申请资源 new int[5] 构造 unique_ptr |
申请资源new int[5] 执行 err() 抛出异常构造 unique_ptr |
上述三种情况中,#0 与 #1 相对来说是内存安全的,而 #2 会导致内存溢出!unique_ptr
没能接管申请的new int[5]
,而是被异常中断了,意味着产生了被申请却未被释放的资源。
这时候就需要std::make_unique
的帮助了:
1 | foo(std::make_unique<int>(5), err()); |
How it works?
我们来尝试自己实现一个unique_ptr
吧!
1 | class UniquePtr { |
Run-Time Overhead:https://godbolt.org/g/EdBxSu
吐槽一下,最新的 GCC 能把未使用的变量直接优化消失……(-O2 人类大脑发光.jpg)
2.3 Shared ownership: std::shared_ptr
and std::weak_ptr
相较于std::unique_ptr
,std::shared_ptr
多了一个“共享”的属性。std::shared_ptr
能够被复制,同时使用”引用计数“来确认还有多少个智能指针可以访问被申请的资源。直到”引用计数“归零,即没有任何std::shared_ptr
能够访问该资源时,才将该资源析构释放。
来看下面这个例子理解何时释放资源:
1 | void sharing() { |
同样的,共享指针也有一个配套函数std::make_shared
。
1 | template< class T, class... Args > |
Run-Time Overhead: https://godbolt.org/g/hkjiyi
std::weak_ptr
是一种不控制对象生命周期的智能指针,它指向一个std::shared_ptr
来对其进行访问与管理。其中一些重要的成员函数:
.expired() -> bool
:检测所管理的对象是否被释放。.lock() -> std::shared_ptr<T>
:若对象未被释放,则返回一个强引用,否则返回一个空的std::shared_ptr
。.use_count() -> long
:返回所管理对象的引用计数值。
其中operator*
与operator->
没有重载,并且它在拷贝或赋值时不会增加引用计数。
1 | void checking_existence() { |
std::weak_ptr
还能够避免std::shared_ptr
的循环引用。
1 | void cycle() { |
执行完上例代码后,sa
和sb
依然没有被释放。将line 8
修改为std::weak_ptr<a> _a;
,便能成功执行两个析构函数。
2.4 Guidelines
No Allocation Is Better then Allocation
- Makeing use of the stack and value-semantic types should be preferred to dynamic allocation
- Objects on the stacl are easier to reason about and more “predictable”
- Dynamic memory usage can hava a significant cost: allocations are not free, they can reduce locality, and the compiler is often not able to aggressively optimize
- Sometimes allocations are necessary - allocate only when you need to
- Always use smart pointers - do not use
new
/delete
std::unique_ptr
as Your First Choice
- If dynamic allocation is necessary,
std::unique_ptr
should be your first choise - It is a zero-cost abstraction over
new
/delete
(except for some rare cases) - It models unique ownership, which is simple and easy to reason about
std::shared_ptr
Should Be Used Sparingly
- It is not a zero-cost abstraction over
new
/delete
- it can have significant overhead due to atomic reference-counting operations - Shared ownership can be harder to reason about
Always Use std::make_xxx
Functions to Create Smart Pointers
- They prevent potential memory leaks
- They make the code terser and more readable
- They do not require
new
to be explicitly called - They can greatly improve performance for
std::shared_ptr
Role of Raw Pointers in Mordern C++
- In Mordern C++, raw pointers are “optional reference” to ab object
- A raw pointer should never imply ownership
- As long as raw pointers are not managing memory, they’re fine to use
最佳实践
1 | template <typename T> |
3. Creating Movable Classes
3.1 Standard Library Support for Movable Types
- Implemeted move operations for your types allows your code to be faster, safer, and more expressive.
- Learned that the standard library provides a huge number of move-aware containers and utilities, which make use of move operations where posiible.
- Studied that the mordern C++ libraries follow the steps of the standard library and will strive to provide more-awareness.
- Understood that always think about move semantics and move-awareness when creating your own types.
回顾一下类型构造时采取的不同参数,先来看一个例子:
1 | struct foo { |
可以看见,在一些对象被构造时出现了很多无必要的移动语义。这种情况可以通过使用一些容器(Container)来避免、甚至完全回避移动语义。std::vector::emplace_back
就是一个很好的例子:
1 | struct bar { |
emplace_back
能将参数完美转发到对应的容器内并进行构造。而对于一些嵌套的构造来说,就不可避免地发生移动语义:
1 | struct bar_wrapper { |
除了
std::vector
,还有很多标准库中的容器支持emplace
,譬如std::map
、std::set
、std::list
。
也有很多通用函数与类支持移动语义,例如:
- 通用功能:
std::swap
和std::exchange
- 封装类:
std::pair
和std::tuple
- 计数器:
std::move_iterator
Utility Functions
拿std::swap
来说,在 C++11 之前,它通常如下定义:
1 | template <typename T> |
居然执行了三次复制操作,可谓十分消耗性能了。在 C++11 之后,开发者们就将复制语义全部更换为移动语义:
1 | template <typename T> |
熟悉这些通用功能还能简化我们的代码。还记得我们之前实现的UniquePtr
吗?它的移动构造函数就可以如下定义:
1 | // ownership transfer |
Wrappers
std::pair
与std::tuple
是将数个对象封装在一起的通用组件。由于tuple
涵盖了绝大部分pair
的特性,后文将仅以tuple
举例。
tuple
实例可以通过std::make_tuple
或是赋值创建。
1 | struct foo { }; |
封装类内部的元素可以通过std::get
来获取。
1 | void retrieving_by_index() { |
从 C++14 开始,在没有重复类型的封装容器内,std::get
支持通过传入类型来获取元素。
1 | void retriving_by_type() { |
std::tie
可以为封装器解包。当需要为函数返回值解包时还是挺有用的。
1 | std::tuple<foo, bar> get_t(); |
C++17 起出现了std::apply
函数,它能将函数应用于tuple
中。
1 | void apply_example (){ |
类似的,std::make_from_tuple
可以通过tuple
元素来传入构造函数以创建对象。
1 | struct foobar { |
善用tuple
可以增加代码粘性,且能快速创建数据结构及其功能原型。
1 | struct two_ints { |
上述中==
可以替换为>
、>=
、<
、<=
等,按顺序比较。
std::move_iterator
STL 中提供了大量的基于迭代器的预置算法函数,但大部分算法不支持移动语义。幸运的是大佬们还是将std::move_iterator
与std::make_move_iterator
添入了<iterator>
头文件中。这使得开发者能够将现存的迭代器“适配”于移动语义。
1 | std::vector<std::string> src{"hello", "world", "iik"}; |
3.2 Rule of Five and Rule of Zero
C++11 之前其实只有 Rule of Three:
在一个类中需要显式地定义以下三项之一时,你应该同时显式地定义其它两个:
- 析构函数(Destructor)
- 拷贝构造函数(Copy constructor)
- 重载拷贝赋值函数(Copy assignment)
遵循这一规则能有效地避免开发者申请资源时的错误与疏忽。
而 C++11 之后的 Rule of Five 实际上只是新增了两个约束:移动构造函数(Move constructor)与重载移动赋值函数(Move assignment)。
1 |
|
Rule of Zero 是一种遵循单一职责原则(Sigle Responsibility Principle, SRP)的编码法则,它要求满足 Rule of Five 的类应该专门处理所有权,而其他业务类均不应该有自定义的析构、拷贝/移动构造函数、重载拷贝/移动赋值函数。这一原则作用与上例即将file_handle
的处理单独写成一个类型,并只负责资源调度,而负责业务的file
不能自定义上述五个函数。
当然我们也可以利用智能指针的特性来封装这个文件类:
1 |
|
注意
shared_ptr
与unique_ptr
中 Deletor 的定义。
3.3 Example: Implementing std::vector
Rule of Five
1 | template <typename T> |
Rule of Zero
1 | template <typename T> |
4. Discover Lambdas
4.1 Lambda Expressions: What Are They?
- A lambda expression produces a closure
- A closure is an unnamed function object capable of captureing variables in scope
1 | [函数对象参数] (操作符重载函数参数) mutable 或 exception 声明 -> 返回值类型 {函数体} |
各项语法与作用详阅:https://www.cnblogs.com/jimodetiantang/p/9016826.html
4.2 Lambda: Function Objects in Disguise
Performance Overhead:https://godbolt.org/g/MTX3mW
- Every lambda expresgsion produce a unique anonymous type
- There is no type-erasure: the compiler is able to inline aggresively
- Lambda expression are zero-overhead abstracions
4.3Anatomy of a Lambda
1 | [=, &b, &c](int a, float b)mutable -> int { return a+b; } |
[/* */]
is the capture-list- The capture-list is followed by the parameter-list
(/* */)
- The parameter-list is optionally followed by
mutable
and/or a trailing return type - The body of the lambda is always at the end
加上 mutable
修饰符后,可以修改传递进来的拷贝(注意是能修改拷贝,而不是值本身)。exception
声明用于指定函数抛出的异常,如抛出整数类型的异常,可以使用 throw(int)
。
从 C++14 开始,参数表可以写auto
:
1 | const auto l = [](const auto& x) { std::cout << x; }; |
再举几个有意思的例子:
1 | // 转发引用至目标函数`sink` |
函数对象参数
[ ] { }
- 不捕捉任何对象[ =] { }
- 按值传递所有局部变量(危险)[ &] { }
- 按引用传递所有局部变量(危险)[ a] { }
- 将a
进行值传递[ &a] { }
- 将a
进行引用传递[ &, a] { }
- 将a
进行值传递,其余所有局部变量按引用传递[ =, &a] { }
- 将a
进行引用传递,其余所有局部变量按值传递
从 C++14 开始允许向 Lambda 表达式中初始化一些项目,例如:
[i = 0] { }
[x = std::move(foo)] { }
[a{10}, b{15}] { }
1 | int b; |
结合移动语义
1 | auto up = std::make_unique<foo>(); |
mutable
的有无直接影响这个函数的运作:
1 | struct without_mutable { |
constexpr
与 C++17
C++17 中,Lambda 表达式均被隐式地加上了constexpr
标识。下面三者均等价:
1 | []{ return 10; } |
同时 Lambda 可以被塞进模板声明了(下列代码在 C++11/14 会发生编译期错误):
1 | std::array<int, []{ return 10; }()> ints; |
Summary
- Learned that lambdas can flexibly capture the surrounding environment
- Studied that new closure data members can be defined with “generalized lambda capures”
- Discussed that
mutable
can be used to remove theconst
quealifier from the closure’s generatedoperator()
- Learned that auto parameters can be used to generate closure with a templated
operator()
- Lambdas can be
constexpr
in C++17
5. Lambdas as First-Class Citizens
5.1 Lambdas: Versatile Tools
什么是第一类对象(First-class citizen)?指可以在执行器创造并作为参数传递给其他函数或存入变量的实体。一般第一类对象所持有的特性为:
- 可以被存入变数或其他结构
- 可以被作为参数传递给其他函数
- 可以被作为函数的返回值
- 可以在执行期创造,而无需完全在设计期全部写出
- 即使没有被系结至某一名称,也可以存在
绝大多数语言中,数值与基础型别都是第一类对象,然而不同语言中对函数的区别很大,例如C语言与C++中的函数不是第一类对象,因为在这些语言中函数不能在执行期创造,而必须在设计时全部写好。相比之下,Scheme中的函数是第一类对象,因为可以用 Lambda 函数并作为第一类对象来操作。
C++ 中函数名实际上意味着函数的引用,当一个普通的函数void func(){}
作为参数时,func
与&func
是等价的。这也就意味着指向该函数的指针也能使用operator()()
执行,即func_ptr()
等价于(*func_ptr)()
。
正因为这个特性,面对参数重载的函数,编译器会不明白选取哪个函数:
1 | bool predicate(const Foo&); |
在<functional>
库中有很多功能于 C++14 被弃用了,而这些都能用 Lambda 表达式重新实现:
1 | // Deprecated: |
“
<functional>
: What’s New, And Proper Usage”:https://www.youtube.com/watch?v=zt7ThwVfap0
与重载、 模板共同使用:
1 | int add(int a, int b) { return a + b; } |
5.2 Strong Callable Objects
1 | void foo() { std::cout << "hello!\n"; } |
正如之前所提到的,Lambda 表达式可以类比为一个匿名的 struct,它具有自己的operator()
重载,这意味着每一个匿名函数都会产生一个全新的类型:
1 | // 由于类型不确定,Lambda 表达式必须要与`auto`或者模板相关联 |
即使两个匿名函数拥有完全一致的定义、参数表等,它们的类型还是不一样!
1 | auto l0 = []{}; auto l1 = []{}; |
一个没有指定任何捕获的 Lambda 函数,可以显式转换成一个具有相同声明形式函数指针。所以,像下面这样做是合法的:
1 | auto a_lambda_func = [](int x) { /*...*/ }; |
而下面这个例子则会引发错误:
1 | int i; |
无参 Lambda 表达式可以通过一元运算符 +
,显式地转换为对应的函数指针。这样就不需要把函数指针对应形式写出来了,直接 auto
走起:
1 | auto l0 = []{ }; |
Concepts
Concept 指一类数据类型能满足一定条件的模板集合,它可以通过
require
声明需满足的表达式来为template
作出一系列限定。
FunctionObject
就是原生的一个 Concept,如下定义:
类型 T 如果是
FunctionObject
,需要满足:
类型 T 满足
std::is_object
给定如下条件
f
, 为T
的一个变量或常量实例args
, 一个匹配的参数表该表达式需合法:
f(args)
,表现为函数的调用
而 Callable
则是需要满足 std::invoke
的调用。详见Cppref。
1 | std::invoke(add, -9, 23); |
标准库中也有很多同 Callable
共同起作用的函数:
std::function
std::bind
std::result_of
std::thread::thread
std::call_once
std::async
std::packaged_task
std::reference_wrapper
Type Erasure
鉴于即使使用了auto f = +[]{ };
也难以推断f
的实际类型,标准库中引入了std::function
来解决这个问题。
- Type erasure is the idea of “erasing” the concrete type of an object into some common type that can store anything satisfying a particular concept
- Usually requires indirection and/or dynamic allocations
1 | struct A {void foo(); }; |
std::function
能为 Callable
对象进行类型擦除。
1 | int add(int a, int b) { return a + b; } |
- 类模板 std::function 是通用多态函数封装器。
- std::function 的实例能存储、复制及调用任何可调用 (Callable) 目标——函数、 lambda 表达式、 bind 表达式或其他函数对象,还有指向成员函数指针和指向数据成员指针。
- 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的)
1 | // no-member functions |
std::function
甚至可以为空,并任意重新绑定:
1 | int add(int a, int b); |
std::function
也可以直接储存在容器等数据结构中:
1 | struct button { |
Run-Time Overhead
std::function
is not a zero-cost abstraction
- Invoking the stored object requires indirection
- Can dynamically allocate if the stored object is big
- Hard to inline/optimize
- https://godbolt.org/g/i9vyyP
Summary
- Understood that functions can be stored in variables using function pointers
- Learned that the hand-written function objects can be easily stored as theirtype is known
- Learned that closures must be stored with
auto
as their type is “anonymous” - Understood that stateless closures can be implicitly converted to functionpointers
- Studied that the unary operator
+
can be used to explicitly produce functionpointers from stateless lambda expressions - Learned about the Standard Library provides two concepts:
Function0bject
andCallable
- Understood that
Callable
is more general as it includes member functionpointers and member data pointers std::invoke
can be used to invoke anyCallable
object- Looked at many Standard Library utilities that accept
Callable
objects for genericity std::function
is a general-purpose polymorphicCallable
wrapperlt supports copyableCallable
objects- lt uses type erasure and can potentially allocate memory
- lt is copyable, can be stored in containers, can be “empty” , and can bearbitrarily rebound to other
Callable
objects
Guideline
- Never use
std::function
unless you need type erasure - Use
auto
and templates instead - they can refer to arbitrary Callable` objects without type erasure - Use
std::function
when you need flexibility at run-time or need to storeCallable
objects with the same signature but different types homogeneously
5.3 Passing Functions to Functions
Mastering Lambdas
Lambdas and the Standard Library
Lambdas as Local Functions
Safer Interfaces with Higher-Order Function
Programming at Compile-Time
Comstant Expressions
constexpr
in The Standard Library
Exceptions in constexpr
Functions
Computations on Type
Metafunctions
Metaprogramming Utilities in the Standard Library
Example: Creating a Compile-Time Set Data Structure
E | C | E | C |
---|---|---|---|
type manipulation | 类型转换 | paradigms | 范式 |