模板初阶
从重复的函数说起
假设我们要写一个交换函数。交换两个整数不难,交换两个浮点数也不难,问题是:它们的逻辑明明一样,只是类型不同,我们却要把同一套过程写上好几遍。
C++ 有函数重载,可以让这些函数使用同一个名字,但函数体还是要一个一个写。
cpp
// swap.hpp
#pragma once
namespace wind {
inline void swap(int& here, int& there) {
int tmp = here;
here = there;
there = tmp;
}
inline void swap(double& here, double& there) {
double tmp = here;
here = there;
there = tmp;
}
inline void swap(char& here, char& there) {
char tmp = here;
here = there;
there = tmp;
}
} // wind 命名空间
这里把函数定义写进了 swap.hpp。.hpp 是 C++ 头文件常用的扩展名,通常用来存放类、模板等内容。
这个头文件可能被多个 .cpp 包含,所以三个普通函数都加上了 inline,使符合条件的同一函数定义可以出现在多个翻译单元中。这与前面讨论过的单一定义规则(ODR)有关。
如果以后还要交换其他类型,又得再添一份重载。我们真正希望表达的是:类型可以先不确定,交换过程只写一次,等使用时再把类型填进去。
这种把具体类型抽出来、让同一套逻辑适用于不同类型的写法,就是泛型编程的一种方式。C++ 的模板可以帮助我们做到这一点。
函数模板:把类型也作为参数
写出交换函数模板
把交换函数中具体的类型换成一个类型参数:
cpp
// swap.hpp
#pragma once
namespace wind {
template <typename T>
void swap(T& here, T& there) {
T tmp = here;
here = there;
there = tmp;
}
} // wind 命名空间
template <typename T> 表示下面定义的是一个模板,T 是其中的类型参数。在这个定义里,原来写 int、double 的地方,现在都可以用 T 表示。
这里也可以写成 template <class T>。声明这种类型模板参数时,class 与 typename 的作用相同,T 都可以代表 int、double 等内置类型,也可以代表自定义类型。
T 也不是固定名字。只要是合法标识符就行,能表达用途更好。如果需要多个类型参数,可以写成:
cpp
template <typename T1, typename T2>
上面的函数模板没有加 inline,仍然可以把定义放在头文件中。先记住这种写法,等弄清实例化之后,再回头解释它与 ODR 的关系。
使用时确定类型
cpp
// main.cpp
#include "swap.hpp"
int main() {
int i = 1, j = 10;
double m = 1.25, n = 6.25;
wind::swap(i, j);
wind::swap(m, n);
return 0;
}
第一处调用传入两个 int,编译器据此确定 T 为 int;第二处传入两个 double,于是 T 为 double。这种根据调用实参确定模板参数的过程,叫作模板实参推导。
类型确定以后,编译器就能依据模板得到对应的具体函数。这两个调用使用的是 wind::swap<int> 和 wind::swap<double>,各自的参数类型、临时变量类型也就随之确定了。
这份交换逻辑用到了复制初始化和赋值,因此传入的类型需要支持这两种操作。模板确定了具体类型以后,函数体中的操作也会按这个类型接受检查。
模板实例化:从模板得到具体的函数或类
从调试记录观察实例化后的函数调用
模板描述的是一套生成规则。程序运行时,执行的是据此得到的具体函数。依据模板和模板实参形成具体函数或类的过程,叫作模板实例化。
下面通过调试记录观察交换函数的调用。截图中的代码与上面的示例略有差异;反汇编的地址、偏移和行号对应这次实验使用的可执行文件。

在下面这份 LLDB 记录中,可以直接看到 main 分别调用了 wind::swap<int> 和 wind::swap<double>:
text
(lldb) disassemble --frame
main-debug`main:
0x555555555130 <+0>: pushq %rbp
0x555555555131 <+1>: movq %rsp, %rbp
0x555555555134 <+4>: subq $0x20, %rsp
0x555555555138 <+8>: movl $0x0, -0x4(%rbp)
-> 0x55555555513f <+15>: movl $0x1, -0x8(%rbp)
0x555555555146 <+22>: movl $0xa, -0xc(%rbp)
0x55555555514d <+29>: movsd 0xebb(%rip), %xmm0
0x555555555155 <+37>: movsd %xmm0, -0x18(%rbp)
0x55555555515a <+42>: movsd 0xea6(%rip), %xmm0
0x555555555162 <+50>: movsd %xmm0, -0x20(%rbp)
0x555555555167 <+55>: leaq -0x8(%rbp), %rdi
0x55555555516b <+59>: leaq -0xc(%rbp), %rsi
0x55555555516f <+63>: callq 0x555555555190 ; wind::swap<int> at swap.hpp:23
0x555555555174 <+68>: leaq -0x18(%rbp), %rdi
0x555555555178 <+72>: leaq -0x20(%rbp), %rsi
0x55555555517c <+76>: callq 0x5555555551c0 ; wind::swap<double> at swap.hpp:23
0x555555555181 <+81>: xorl %eax, %eax
0x555555555183 <+83>: addq $0x20, %rsp
0x555555555187 <+87>: popq %rbp
0x555555555188 <+88>: retq
(lldb)
两条 callq 的目标分别是 0x555555555190 和 0x5555555551c0,调试器标出了 wind::swap<int> 和 wind::swap<double>。这次构建分别生成了两个类型对应的函数,main 通过各自的入口调用它们。

同一份模板就这样得到了不同类型对应的具体函数。开启优化后,调用还可能被展开,最终的机器码会随构建配置变化。
隐式实例化与显式指定模板实参
前面的代码没有专门要求编译器生成某个函数,只是在调用时需要它,编译器便据此实例化。这叫隐式实例化。
在这个过程中,模板实参推导负责确定类型参数,实例化则依据确定的模板实参形成对应的函数。前面的调用把这两步连在了一起。
用加法函数来看会更清楚:
cpp
// add.hpp
#pragma once
namespace wind {
template <typename T>
T add(const T& left, const T& right) {
return left + right;
}
} // wind 命名空间
cpp
// main.cpp
#include "add.hpp"
#include <iostream>
int main() {
int a1 = 10, a2 = 20;
double d1 = 10.0, d2 = 20.0;
wind::add(a1, a2); // T 推导为 int
wind::add(d1, d2); // T 推导为 double
// wind::add(a1, d1); // 错误: 两处对 T 的推导不一致
double i = wind::add<double>(6, 1.25);
int j = wind::add(6, (int)1.25);
std::cout << i << '\n'; // 7.25
std::cout << j << '\n'; // 7
return 0;
}
对于 add(a1, d1),第一个参数要求 T 是 int,第二个参数要求 T 是 double,而模板里只有同一个 T。这两处推导对不上,调用就不能成立。
这个模板要求两个参数使用同一个 T。遇到不同类型的实参,需要由调用者确定采用哪种类型;这个选择也决定了后面的计算和返回结果。
解决方法有两种。
一种是自己先转换调用实参,例如 add(6, (int)1.25)。传入推导过程的已经是两个 int,所以 T 可以确定为 int,结果是 7。
另一种是在函数名后的尖括号中写出模板实参,例如 add<double>(6, 1.25)。这时 T 已经明确是 double,具体函数的形参就是两个 const double&。整数 6 可以转换为 double,再参与调用,结果是 7.25。
同样,add<int>(a1, d1) 会把 d1 转换为 int,再让形参 const int& 绑定转换产生的临时对象。这里使用常引用,也就允许这种传参方式。
下面是加法函数的实验截图,可以对照其中的调用与输出:

函数模板与普通函数怎样匹配
同名的普通函数和函数模板可以同时存在。一次调用到底使用哪一个,需要一起参与重载决议,最后选择最合适的。
cpp
// add.hpp
#pragma once
#include <iostream>
namespace wind {
inline int add(int v1, int v2) {
std::cout << "int" << '\n';
return v1 + v2;
}
template <typename T>
T add(const T& v1, const T& v2) {
std::cout << "template" << '\n';
return v1 + v2;
}
} // wind 命名空间
普通的整型加法直接按值传参。模板还可能用于较大的自定义类型,这里选择常引用,避免传参时复制整个对象。
cpp
// main.cpp
#include "add.hpp"
#include <iostream>
int main() {
auto i = wind::add(1, 5);
std::cout << i << '\n';
auto j = wind::add<int>(1, 5);
std::cout << j << '\n';
auto k = wind::add(1.25, 2.5);
std::cout << k << '\n';
return 0;
}
add(1, 5) 中,普通函数与模板对应的 int 版本匹配程度相同,最终优先选择普通函数。这样,单独为整数提供的实现就能优先用于整数调用,方便针对某种类型安排专门的处理。
add<int>(1, 5) 明确使用模板实参列表,选择的是函数模板对应的版本。
add(1.25, 2.5) 则不同:普通函数需要把两个 double 转换成 int,模板可以推导出 T 为 double,直接匹配。此时模板对应的函数更合适,即使已经存在同名的普通函数,仍然会选中模板版本。
这几个调用可以按同一个顺序理解:先比较参数的匹配程度,匹配程度相同时,普通函数优先。
下面的实验截图展示前两个调用的结果;第三个调用用于对比匹配程度不同的情况,截图未包含这一项。

类模板:让类中的类型也可以变化
函数的逻辑可以复用,类的结构和成员函数也可以。只要一个类的结构或操作需要随某种类型变化,就可以考虑用类模板表达;数据结构是很常见的应用,其他用途的类也同样可以使用模板。这里仍然拿熟悉的栈来看。
例如,一个栈原来通过 typedef 确定元素类型:
cpp
typedef int SDate;
如果类中的存储指针、入栈参数等位置都使用 SDate,改这一处就能让整份实现换一种元素类型。但这样的别名仍然只代表一个确定的类型。如果同一个程序既要一个存整数的栈,又要一个存浮点数的栈,单靠修改这处别名就不够了。
把元素类型改成模板参数,就可以让同一份栈的定义用于不同类型:
cpp
// Stack.hpp
#pragma once
#include <cstddef>
#include <iostream>
namespace wind {
template <typename SDate>
class Stack;
template <typename SDate>
std::ostream& operator<<(std::ostream& out, const Stack<SDate>& s);
template <typename SDate>
class Stack {
public:
// explicit 禁止通过隐式类型转换调用这个构造函数.
explicit Stack(std::size_t sz = 4, const SDate* pArr = nullptr);
~Stack();
Stack(const Stack& s);
Stack& operator=(const Stack& s);
void Push(const SDate& val);
void Extend();
friend std::ostream& operator<< <SDate>(std::ostream& out, const Stack& s);
private:
SDate* _val;
std::size_t _top;
std::size_t _capacity;
};
} // wind 命名空间
现在 SDate 是每次使用模板时确定的类型参数。存储指针使用 SDate*,入栈参数也要相应地使用 const SDate&。如果仍然写成 Push(int),即使创建的是浮点型栈,浮点数也会在进入函数时先被转换为整数,这就和我们想要的行为对不上了。
上面先给出了类模板和输出函数模板的声明,再在类模板中声明接口。成员函数与输出函数的定义都放在后面,补齐之后就能运行下面的例子。
cpp
#include "Stack.hpp"
int main() {
wind::Stack<int> i;
wind::Stack<double> j;
i.Push(10);
j.Push(1.25);
std::cout << i << '\n';
std::cout << j << '\n';
return 0;
}
Stack<int> 和 Stack<double> 是两个不同的类类型,分别保存 int 和 double。这里的 i、j 则是各自类型的对象。
如果再创建一个 Stack<int> 对象,它和 i 使用同一个类类型,两个对象各自拥有自己的成员数据。
C++17:根据构造实参推导类模板实参
上面写 Stack<int>,是直接告诉编译器元素类型为 int。从 C++17 开始,在某些创建对象的地方,也可以省略模板实参,让编译器根据初始化时提供的信息推导。这叫作类模板实参推导。
继续使用上面的栈:
cpp
#include "Stack.hpp"
int main() {
const int ints[] = {1, 2, 3};
const double doubles[] = {1.25, 2.5};
wind::Stack i(3, ints); // 推导为 wind::Stack<int>
wind::Stack j(2, doubles); // 推导为 wind::Stack<double>
std::cout << i << '\n';
std::cout << j << '\n';
return 0;
}
构造函数的声明是:
cpp
explicit Stack(std::size_t sz = 4, const SDate* pArr = nullptr);
第二个形参的类型包含 SDate。传入 ints 时,数组在这里转换为 const int*,与形参类型 const SDate* 对应,就可以推导出 SDate 为 int;传入 doubles 时,同样根据 const double* 推导出 SDate 为 double。确定具体的类类型之后,再使用对应的构造函数创建对象。
提供推导依据的形参不必放在最后,也不必是指针。例如,构造函数的某个形参直接写成 SDate value,也可以根据对应实参推导 SDate。这里使用数组指针,只是因为栈的这个构造函数需要接收初始元素。
第一个形参 std::size_t sz 的类型已经固定,无论传入的容量是多少,都不能从中得知元素类型。这就是两个形参在推导过程中承担的不同作用。
这里可以先把它理解为:编译器根据构造函数声明建立推导依据,再拿初始化实参与之匹配。这个例子的构造函数声明已经提供了所需信息。
不过,推导需要有依据:
cpp
// 推导失败的示例:
// wind::Stack a; // 没有能确定 SDate 的实参
// wind::Stack b(4); // 4 只决定容量, 不能确定元素类型
nullptr 可以作为不同指针类型的空值,本身没有提供元素类型。在这种默认构造的情况下,写出 Stack<int> 就明确了类型。
对象的类型在定义时确定,因此类模板实参推导使用的是初始化时提供的信息。后面的 Push 等操作,使用的都是此时已经确定的类型。
类模板成员函数写到类外时,怎样表示所属类型
为了集中展示接口,可以把成员函数声明放在类里,实现放在类外。下面都写在同一个 Stack.hpp 中,通过定义的位置来观察类型名称和模板参数的写法。
普通类的类外成员定义会写 Stack::。现在面对的是类模板,需要继续说明它依赖哪些模板参数,再用 Stack<SDate>:: 表示成员所属的类型:
cpp
template <typename SDate>
void wind::Stack<SDate>::Push(const SDate& val) {
// 具体实现见下文
}
开头的 template <typename SDate> 表示这仍然是一份模板定义;wind::Stack<SDate>:: 则把成员函数归到使用该类型参数的栈中。
赋值运算符还有返回值,因此可以更明显地看到两处类型名称:
cpp
template <typename SDate>
wind::Stack<SDate>& wind::Stack<SDate>::operator=(const Stack& s) {
// 具体实现见下文
}
开头的返回类型位于成员所属作用域之外,所以这里写出完整的 wind::Stack<SDate>&。后面经过 wind::Stack<SDate>:: 以后,已经进入相应的类作用域,参数中的 Stack 可以简写,代表当前的 Stack<SDate>。
这也解释了类内为什么能写 Stack(const Stack& s):在当前类的作用域里,Stack 可以表示当前的 Stack<SDate> 类型。
以后阅读和编写 STL 相关代码时,还会遇到在类内为当前类型起别名的写法,例如:
cpp
typedef Stack<SDate> Self;
Self 是给当前类型起的别名,后面可以用它代替 Stack<SDate>。名字由程序员选择,Self、self 都是常见写法。
输出函数在类外定义时的对应关系
这里的输出函数也写成函数模板,定义和成员函数一起放在类外。为了让声明互相衔接,头文件开头先声明类模板 Stack,再声明接收 Stack<SDate> 的 operator<< 函数模板。
类内这一行:
cpp
friend std::ostream& operator<< <SDate>(std::ostream& out, const Stack& s);
用 <SDate> 指定当前元素类型,对应到前面已经声明的函数模板。例如,在 Stack<int> 中,这一行把接收 const Stack<int>& 的输出函数声明为友元。
这样,类内只保留声明,具体的输出过程在后面定义,也与其他函数的组织方式保持一致。
栈的成员函数实现
下面把这些函数的定义接在类模板之后,组成同一个 Stack.hpp。从中可以看到,元素类型怎样通过 SDate 贯穿接口、存储和各个操作。
这里把声明和定义分开,是为了展示类外定义时的写法。代价也很明显:每个成员函数前面都要重新写 template <typename SDate>,所属类型也得写完整。掌握这种形式以后,对于规模不大的模板,后面通常直接在类内定义成员函数,省掉这些重复。
构造函数的参数约定是:pArr 为空时创建容量为 sz 的空栈;不为空时,复制它指向的 sz 个元素,调用者需要保证这些元素有效。栈管理自己的存储,不接管传入数组的所有权。
这里沿用 malloc、free 管理存储,示例用于 int、double 等简单数值类型。涉及类对象的构造与析构时,存储管理也需要相应调整,留到 C++ 内存管理部分再讨论。
复制和扩容时都逐元素赋值。赋值运算符也采用直接申请空间、复制元素、释放旧空间的写法。
cpp
// 接在 Stack.hpp 的类模板定义之后
#include <cstdlib>
// pArr 为空时创建空栈, 否则复制 sz 个元素.
template <typename SDate>
wind::Stack<SDate>::Stack(std::size_t sz, const SDate* pArr)
: _val(nullptr), _top(0), _capacity(0) {
if (sz == 0) {
return;
}
std::size_t maxCount = (std::size_t)-1 / sizeof(SDate);
if (sz > maxCount) {
std::cerr << "错误: 栈容量过大.\n";
return;
}
SDate* p = (SDate*)std::malloc(sizeof(SDate) * sz);
if (p == nullptr) {
std::cerr << "错误: 栈内存申请失败.\n";
return;
}
_val = p;
_capacity = sz;
if (pArr != nullptr) {
for (std::size_t i = 0; i < sz; ++i) {
_val[i] = pArr[i];
}
_top = sz;
}
}
template <typename SDate>
wind::Stack<SDate>::~Stack() {
std::free(_val);
}
template <typename SDate>
wind::Stack<SDate>::Stack(const Stack& s)
: _val(nullptr), _top(0), _capacity(0) {
if (s._capacity == 0) {
return;
}
SDate* p = (SDate*)std::malloc(sizeof(SDate) * s._capacity);
if (p == nullptr) {
std::cerr << "错误: 拷贝构造时内存申请失败.\n";
return;
}
for (std::size_t i = 0; i < s._top; ++i) {
p[i] = s._val[i];
}
_val = p;
_top = s._top;
_capacity = s._capacity;
}
template <typename SDate>
wind::Stack<SDate>& wind::Stack<SDate>::operator=(const Stack& s) {
if (this == &s) {
return *this;
}
SDate* p = nullptr;
if (s._capacity != 0) {
p = (SDate*)std::malloc(sizeof(SDate) * s._capacity);
if (p == nullptr) {
std::cerr << "错误: 赋值时内存申请失败.\n";
return *this;
}
for (std::size_t i = 0; i < s._top; ++i) {
p[i] = s._val[i];
}
}
// 复制完成后, 再释放原来的空间.
std::free(_val);
_val = p;
_top = s._top;
_capacity = s._capacity;
return *this;
}
template <typename SDate>
void wind::Stack<SDate>::Push(const SDate& val) {
if (_top == _capacity) {
// 先保存 val, 避免扩容后引用失效.
SDate saved = val;
Extend();
if (_top == _capacity) {
return;
}
_val[_top] = saved;
} else {
_val[_top] = val;
}
++_top;
}
template <typename SDate>
void wind::Stack<SDate>::Extend() {
std::size_t maxCount = (std::size_t)-1 / sizeof(SDate);
if (_capacity > maxCount / 2) {
std::cerr << "错误: 扩容后的容量过大.\n";
return;
}
std::size_t newCapacity = _capacity == 0 ? 4 : _capacity * 2;
if (newCapacity > maxCount) {
std::cerr << "错误: 扩容后的容量过大.\n";
return;
}
SDate* p = (SDate*)std::malloc(sizeof(SDate) * newCapacity);
if (p == nullptr) {
std::cerr << "错误: 扩容时内存申请失败.\n";
return;
}
// 逐个赋值元素, 不按字节直接搬运.
for (std::size_t i = 0; i < _top; ++i) {
p[i] = _val[i];
}
std::free(_val);
_val = p;
_capacity = newCapacity;
}
template <typename SDate>
std::ostream& wind::operator<<(std::ostream& out, const wind::Stack<SDate>& s) {
for (std::size_t i = 0; i < s._top; ++i) {
out << s._val[i] << ' ';
}
return out;
}
赋值运算符先判断是否是自己给自己赋值。需要复制时,先申请一片独立空间,把源栈的有效元素逐个赋值过去,完成后再释放当前对象原来的空间,并更新指针、元素数量和容量。两个栈各自管理自己的数组,不会因为复制而共同释放同一片空间。
扩容的过程也类似:申请更大的空间,逐元素赋值,再释放旧空间。Push 在扩容前先保存一份 val,避免它恰好引用旧空间中的元素、扩容后却还继续使用已经失效的引用。只有元素写入完成以后,才增加 _top。
(std::size_t)-1 得到 std::size_t 能表示的最大值,再除以单个元素的大小,就得到了字节数计算所允许的元素数量上限。申请和扩容前先用它检查容量,防止乘法计算溢出。
申请内存失败时,函数输出错误提示并返回:构造或拷贝构造留下空对象,赋值或扩容保留原有数据,Push 在扩容失败后结束本次入栈。这份示例以正常路径为主,失败时的操作没有完成,调用处的成功状态反馈留待后面完善。
类模板的成员函数体通常按需实例化。例如,调用入栈操作时会用到 Push 的定义;使用流输出时,才会进一步要求元素支持相应的 << 操作。
模板定义放在头文件中,为什么不违反 ODR
头文件被多个源文件包含后,其中的内容会分别进入不同的翻译单元。于是我们自然会遇到一个问题:普通的非内联函数定义这样放可能重复,模板定义为什么可以?
理解这个问题,可以先从模板的使用需求出发,再看相应的语言规则和编译链接过程。
模板的用途,是把同一套逻辑交给不同类型、不同调用处复用。在本篇采用的组织方式中,定义放在头文件里,多个源文件都可能使用同一个模板,也可能用相同的模板实参得到同一个具体函数。
如果这些正常的使用每次都被当成互相冲突的定义,模板就很难方便地在多个源文件之间复用。我们希望保留这种便利,同时又要求各处描述的是同一个东西,相同模板实参对应的函数具有统一的身份。这就需要语言规则允许符合条件的重复定义,也需要工具链有办法协调重复生成的实例。
语言规则需要服务于这些实际需求。语言会随着需求和实现经验发展,也会包含兼容性与工程上的取舍。理解这里时,可以沿着"希望怎样使用模板、需要什么规则、怎样实现"的顺序往下看。
从语言规则来看:符合条件的模板允许跨翻译单元定义
ODR 允许同一个模板的定义出现在多个翻译单元中,条件是各处定义满足相应的一致性要求。
cpp
// add.hpp
#pragma once
namespace wind {
template <typename T>
T add(const T& left, const T& right) {
return left + right;
}
}
当 a.cpp、b.cpp 都包含这个头文件时,各自的翻译单元中都会出现模板定义。这些定义共同描述同一个模板,正是上述规则所允许的情况。
这种一致性具体体现在定义的记号序列、其中名字的查找结果等方面。例如,如果不同的宏配置把模板函数体展开成了不同内容,就可能破坏一致性。
把定义集中写在一个头文件中,再由各个源文件包含,可以让这些翻译单元共享同一份实现,便于保持一致。
#pragma once 则处理单个翻译单元内的重复包含:一个源文件通过不同路径多次包含这个头文件时,头文件只展开一次。其他源文件包含它时,会在各自的翻译单元中展开。
前面的普通函数通过 inline 支持这种头文件定义方式,函数模板则适用模板的相应规则。至于调用是否在优化时展开,由编译器根据具体情况决定。
从编译和链接来看:同一个实例可能被分别生成
假设 a.cpp 和 b.cpp 都包含 add.hpp,也都调用了 wind::add<int>。
这两个源文件通常是分别编译的。编译器处理 b.cpp 时,通常不知道 a.cpp 是否已经生成了 add<int>,因此两个翻译单元都可能各自生成对应的代码。
到了链接阶段,工具链会按照模板实体的规则协调处理这些重复实例。常见的实现方式,是把相应代码放进可以合并的节区,或者使用允许协调重复定义的符号机制,最终让各处使用对应于同一个函数实体。
这种方案允许各翻译单元独立生成需要的代码,再在链接时统一处理。前面的一致性要求使这些代码能够作为同一个实例协调起来。
类模板也是类似的。同一个类模板使用相同模板实参,在符合规则的前提下表示同一个类类型;其中按需实例化的成员函数也可能由不同翻译单元分别生成,再由工具链协调。至于用这个类型创建的不同对象,它们仍然是各自独立的对象。
语言规则允许符合条件的定义分布在多个翻译单元中,工具链协调各处生成的实例。两者配合,使同一份模板能够在多个源文件之间使用,相同的模板实参也能对应到同一个实体。
参考资料
以下资料对应正文中涉及的规则,可在需要时查阅。