公司动态

C++函数模板调用规则解析:从参数推导到重载决议

📅 2026/8/24 9:30:05
C++函数模板调用规则解析:从参数推导到重载决议
1. 从一次编译错误说起为什么我的模板函数调用失败了最近在带新人做项目一个刚上手C没多久的同事跑过来问我他写了一个简单的模板函数用来交换两个值但在调用的时候编译器报了一堆看不懂的错误。代码大概是这样的templatetypename T void mySwap(T a, T b) { T temp a; a b; b temp; } int main() { int x 5, y 10; mySwap(x, y); // 这行没问题 double m 3.14, n 2.71; mySwap(m, n); // 这行也没问题 // 问题来了 mySwap(x, m); // 编译错误 std::cout x x , m m std::endl; return 0; }编译器给出的错误信息通常是“没有匹配的函数调用”或者“模板参数推导失败”。他很不解“我这个模板不是接受两个T类型的参数吗int和double不都是类型吗为什么不能换”这个问题恰恰触及了C函数模板调用规则的核心。函数模板Function Template是C泛型编程的基石它让我们能写出与类型无关的通用算法。但“通用”不等于“随意”编译器在背后有一套严格的规则来决定如何将我们写的模板“蓝图”实例化成具体的函数代码这个过程就是模板实例化Template Instantiation。而调用规则就是指导编译器如何完成从“调用语句”到“生成具体函数”这一系列动作的法则。理解这些规则不仅能帮你快速定位和解决编译错误更能让你写出更灵活、更健壮的模板代码避免很多潜在的坑。2. 模板参数推导编译器如何“猜”出你的类型当我们写下mySwap(x, y)时并没有显式地告诉编译器T是什么。是int是double还是别的什么这个“猜”的过程就是模板参数推导Template Argument Deduction。这是调用函数模板的第一步也是最关键的一步。2.1 推导的基本规则一一对应与类型匹配编译器进行推导的核心原则是根据函数调用时提供的实参Arguments类型来推导函数模板形参Parameters中对应的模板类型参数Template Type Parameters。在我们mySwap的例子中模板声明是templatetypename T void mySwap(T a, T b)。当我们调用mySwap(x, y)且x和y都是int时编译器进行如下推理第一个实参x的类型是int它对应第一个形参T a。因此推导出T必须是int这样T才能绑定到int类型的x上。第二个实参y的类型也是int它对应第二个形参T b。因此推导出T也必须是int。两次推导的结果一致都是int。因此本次调用成功推导出T int。编译器随后会生成一个void mySwapint(int, int)的函数实例并调用它。这个过程听起来很自然但其中蕴含了几个容易出错的细节规则一所有可推导的模板参数必须能从函数实参中推导出来。模板参数分为可推导和不可推导的。通常出现在函数形参类型中的模板参数如T在T a中是可推导的。如果有一个模板参数没有出现在函数形参列表中或者出现的形式无法推导那么在调用时就必须显式指定。templatetypename T, int N // T可推导N不可推导它是一个非类型模板参数 void printSize(T (arr)[N]) { // 这里N可以从数组引用推导出来 std::cout Array size: N std::endl; } templatetypename T, typename U T sum(T a, U b) { // 返回类型T无法从参数推导如果U和T不同T的推导可能不明确 return a b; } int main() { int arr[5]; printSize(arr); // 正确T被推导为intN被推导为5 // auto result sum(1, 2.0); // 可能有问题T从第一个参数推导为int但返回int可能丢失精度。 auto result sumdouble(1, 2.0); // 正确显式指定T为doubleU从2.0推导为double }规则二推导过程中会进行有限的类型转换。为了成功推导编译器允许一些标准的类型转换但这些转换比普通函数重载决议时的转换要严格得多。主要包括左值到右值转换如果形参是按值传递T实参是左值会忽略其“左值性”。数组到指针、函数到指针的转换数组名会退化成指向其首元素的指针。顶层const的添加或忽略形参的顶层const不影响推导。派生类到基类的转换仅适用于指针或引用这在处理继承体系时很重要。但是算术类型转换如int到double、用户定义的转换构造函数、转换运算符在模板参数推导阶段是不被考虑的。这就是开头那个mySwap(x, m)失败的根本原因。编译器试图从x推导T得到int从m推导T得到double两者矛盾推导失败。实操心得当你遇到“模板参数推导失败”的错误时第一件事就是检查调用语句中所有实参的类型看它们推导出的模板参数是否一致。不一致是这类错误最常见的原因。对于需要不同类型参数的情况可以考虑使用多个模板参数如templatetypename T1, typename T2或者使用通用引用T配合std::forward进行完美转发但这属于更高级的议题。2.2 引用和指针类型的推导引用折叠与完美转发的基础当模板参数以引用形式出现时推导规则会有些微妙的变化这是理解现代C中移动语义和完美转发的关键前提。templatetypename T void f(T param); // 形参是左值引用 templatetypename T void g(T param); // 形参是通用引用注意只有当T是推导类型时才是通用引用 int main() { int x 10; const int cx x; const int rx x; f(x); // T被推导为int, param类型是int f(cx); // T被推导为const int, param类型是const int f(rx); // T被推导为const int, param类型是const int g(x); // x是左值T被推导为int, param类型是int (引用折叠) g(cx); // cx是const左值T被推导为const int, param类型是const int g(42); // 42是右值T被推导为int, param类型是int }对于f(T param)传递int左值T推导为int。传递const int左值T推导为const int这是为了保持实参的常量性。如果你希望函数能修改参数这就会导致编译错误。传递const int同样推导为const int引用部分被忽略因为形参已经是引用。对于g(T param)通用引用这是C11引入的魔法。当T是推导类型时T并不一定代表右值引用。它的推导规则是如果传入实参是左值如xT被推导为左值引用类型int然后发生引用折叠int 折叠为int所以param是左值引用。如果传入实参是右值如42T被推导为int非引用param类型就是int。这个特性使得std::forward能够实现完美转发将参数以其原始的值类别左值或右值传递给其他函数。避坑指南区分“右值引用”和“通用引用”。void foo(int param)中的param是确定的右值引用。而templatetypename T void bar(T param)中的param才是通用引用其行为取决于传入的实参。混淆两者会导致对移动语义和std::forward的错误理解。3. 重载决议当多个候选函数“竞争”时模板参数推导成功后编译器生成了一个具体的函数实例如mySwapint。但这个实例并不是立刻就被调用的。如果存在多个同名的普通函数或函数模板编译器需要决定到底调用哪一个。这个过程叫做重载决议Overload Resolution。对于涉及函数模板的重载决议规则比普通函数更复杂一些。核心思想是编译器会找出所有可行的函数包括模板实例化后的函数然后从中选出“最佳匹配”。3.1 函数模板 vs. 普通函数当一个调用既能匹配一个普通函数又能通过推导匹配一个函数模板时编译器优先选择谁答案是优先选择普通函数除非模板能提供明显更好的匹配。void process(int x) { // 普通函数 std::cout 普通函数 process(int) std::endl; } templatetypename T void process(T x) { // 函数模板 std::cout 模板函数 process(T), T typeid(T).name() std::endl; } int main() { process(10); // 调用普通函数 process(int) process(10.0); // 调用模板实例 processdouble(double) process(a); // 调用模板实例 processchar(char) }当调用process(10)时普通函数process(int)是完美匹配。模板虽然也能推导出Tint生成processint(int)但根据规则非模板函数优先。当调用process(10.0)时普通函数需要将double转换为int涉及算术转换而模板可以精确推导出Tdouble此时模板提供了更好的匹配不需要转换因此选择模板。3.2 函数模板 vs. 函数模板当多个函数模板都能匹配同一个调用时编译器有一套复杂的偏序规则来决定哪个“更特化”。一个简单的判断原则是更特化更具体的模板优先于更泛化更通用的模板。// 模板1更通用 templatetypename T void func(T t) { std::cout 通用模板 func(T) std::endl; } // 模板2更特化针对指针类型 templatetypename T void func(T* t) { std::cout 特化模板 func(T*) std::endl; } // 模板3更更特化针对特定类型的指针 template void funcint*(int* t) { std::cout 全特化模板 funcint* std::endl; } int main() { int x 5; int* p x; func(x); // 调用模板1: funcint(int) func(p); // 调用模板2: funcint(int*) // 指针版本比通用版本更特化 func((int*)p); // 调用模板3: funcint*(int*) // 全特化版本最特化 }编译器通过比较模板的“形参类型”来决定特化程度。T*比T更特化因为所有能匹配T*的调用指针都能匹配T指针也是类型的一种但反过来不行int能匹配T但不能匹配T*。全特化版本template void funcint*(int* t)是针对特定类型的特化因此是最特化的。注意事项重载决议是一个非常复杂的环节尤其是在结合了自动类型转换、引用、const、可变参数模板等特性后。当出现“调用不明确”的错误时往往意味着有两个或多个候选函数被编译器认为匹配度“一样好”。这时你需要仔细检查每个候选函数的签名并考虑是否可以通过显式指定模板参数、使用static_cast改变实参类型、或者重新设计函数签名来消除歧义。4. 显式指定与SFINAE主动干预推导过程我们并不总是依赖编译器的自动推导。有时我们需要主动告诉编译器“别猜了就用这个类型。”有时我们需要引导编译器让某些模板在推导失败时安静地退出候选列表而不是报错。4.1 显式指定模板实参在函数名后使用尖括号来显式提供模板参数。这通常在以下情况使用模板参数无法推导比如它只出现在返回类型中。希望覆盖推导结果比如你想用double版本来处理int参数以避免溢出。调用特定特化版本。templatetypename T, typename U T max(T a, U b) { // 返回类型T只由第一个参数决定可能不是我们想要的 return (a b) ? a : b; } templatetypename RT, typename T1, typename T2 RT max2(T1 a, T2 b) { // 返回类型RT完全无法从参数推导 return (a b) ? a : b; } int main() { int i 5; double d 3.14; auto m1 max(i, d); // 返回类型是intd被截断可能丢失信息 std::cout m1 std::endl; // 输出 5 auto m2 maxdouble(i, d); // 显式指定T为doubleU从d推导为double std::cout m2 std::endl; // 输出 5.0 (i被提升为double参与比较) auto m3 max2double(i, d); // 错误max2需要三个模板参数只提供了一个。 auto m4 max2double, int, double(i, d); // 正确但冗长 // C14以后更优雅的做法是让返回类型自动推导 }显式指定参数时必须提供所有无法推导的模板参数并且顺序要与模板声明一致。4.2 SFINAE substitution failure is not an error这是一个高级但极其重要的概念。直译是“替换失败并非错误”。它的核心思想是在模板参数推导和重载决议过程中如果某个模板的实例化会导致编译错误比如无效的类型操作那么这个模板实例会被直接从候选集中默默丢弃而不会导致整个程序编译失败。只有当没有任何候选函数时编译器才会报错。SFINAE是C模板元编程和类型特质Type Traits的基础。在C11之前SFINAE主要通过复杂的表达式和sizeof技巧实现。C11引入了std::enable_if和decltype使其更易用。C17/20又提供了if constexpr和Concepts进一步简化了相关操作。#include type_traits #include iostream // 版本1适用于有size()成员函数的类型如容器 templatetypename T auto getSize(const T obj) - decltype(obj.size(), void()) { std::cout 使用 size() 成员函数大小是: obj.size() std::endl; } // 版本2适用于数组 templatetypename T, std::size_t N void getSize(T (arr)[N]) { std::cout 是数组大小是: N std::endl; } // 版本3通用回退版本使用SFINAE排除前两种情况 templatetypename T auto getSize(const T obj) - decltype(std::begin(obj), std::end(obj), void()) { std::cout 是迭代器范围大小需计算... std::endl; // 计算begin到end的距离 } struct Dummy {}; int main() { std::vectorint vec{1,2,3}; int arr[5] {0}; Dummy d; getSize(vec); // 匹配版本1 getSize(arr); // 匹配版本2 // getSize(d); // 错误三个版本都无法匹配。 // 版本1d没有.size() // 版本2d不是数组 // 版本3d没有begin/end SFINAE在此发生这个版本被丢弃 // 最终没有可行函数编译错误。 }在上面的例子中当我们尝试用Dummy调用getSize时版本3在尝试推导返回类型decltype(std::begin(obj), std::end(obj), void())时会失败因为Dummy类型没有begin和end。这个失败发生在推导阶段因此版本3被SFINAE规则静默地从重载集中移除。由于版本1和版本2也匹配失败最终没有候选函数编译器报错。经验之谈SFINAE是编写健壮、灵活的模板库的利器。它允许你根据类型的特性是否有某个成员、是否支持某种操作来提供不同的函数实现。但在日常开发中除非你在编写基础库或框架否则应优先考虑使用更直观的if constexprC17或ConceptsC20它们的意图更清晰代码也更易维护。将SFINAE视为一种底层机制来理解而不是首选的工具。5. 实例化与两阶段查找模板代码的“编译时刻”函数模板本身不是函数它是一份蓝图。只有当编译器根据推导或显式指定的模板参数生成具体类型的函数代码时才发生实例化Instantiation。这个过程发生在编译期并且有一个非常重要的特性两阶段查找Two-phase lookup。5.1 实例化的时机与地点实例化可以是隐式的由编译器在需要时自动进行也可以是显式的程序员手动要求。实例化发生的地点就是模板被使用的位置通常是调用点所在的编译单元。// my_template.h templatetypename T T add(const T a, const T b) { return a b; } // main.cpp #include my_template.h #include string int main() { int sum_i add(1, 2); // 此处隐式实例化 addint double sum_d add(3.14, 2.71); // 此处隐式实例化 adddouble // std::string sum_s add(std::string(hello), world); // 错误world是const char[6]类型不匹配 std::string sum_s add(std::string(hello), std::string(world)); // 实例化 addstd::string }每个不同类型的调用都会生成一份独立的机器代码。这就是为什么滥用模板可能导致“代码膨胀”Code Bloat——生成的可执行文件变大。5.2 两阶段查找为什么模板里的名字有时找不到这是模板编程中最令人困惑的问题之一。编译器处理模板分为两个阶段模板定义阶段在首次看到模板定义时编译器会检查不依赖于模板参数的语法和名字。例如检查基本的语法错误、查找非依赖名称不依赖于T的名称。模板实例化阶段在知道模板参数T的具体类型后编译器再次检查模板查找那些依赖于T的名称依赖名称并生成最终代码。#include iostream void globalFunc() { std::cout globalFunc\n; } templatetypename T void myTemplate() { globalFunc(); // 非依赖名称在定义阶段查找。 T::staticFunc(); // 依赖名称依赖于T在实例化阶段查找。 // unknownFunc(); // 非依赖名称但定义阶段找不到直接报错。 dependentFuncT(); // 依赖名称在实例化阶段查找。 } struct MyType { static void staticFunc() { std::cout MyType::staticFunc\n; } }; // 假设有一个 dependentFunc 模板 templatetypename U void dependentFunc() { std::cout dependentFunc\n; } int main() { myTemplateMyType(); // 实例化时TMyType此时查找 T::staticFunc 和 dependentFuncMyType }关键点在于非依赖名称在模板定义阶段就必须是可见的。这就是为什么我们经常需要在模板头文件里包含很多其他头文件——因为模板定义需要看到那些不依赖于T的类或函数的声明。一个常见的坑是在模板内部调用一个“看起来”应该存在的函数比如std::swap。如果你希望你的模板在实例化时能使用针对特定类型优化的swap比如std::vector有自己的swap实现你需要使用using std::swap;然后调用swap(a, b)而不是直接调用std::swap(a, b)。因为std::swap是一个非依赖名称在定义阶段就绑定了。而通过using声明引入的swap结合依赖于参数的查找ADL可以在实例化阶段找到更合适的、在参数所在命名空间中定义的swap版本。避坑指南在编写函数模板时如果要在内部调用其他函数特别是希望支持ADL参数依赖查找的运算符或swap请使用“两段式调用”templatetypename T void myAlgorithm(T a, T b) { using std::swap; // 引入std::swap作为后备 swap(a, b); // 非限定调用允许ADL找到T所在命名空间的最佳swap }这样如果类型T所在的命名空间比如std有特化的swap就会调用那个特化版本否则就回退到std::swap。6. 实战中的典型问题与调优策略理解了基本规则我们来看看在实际项目中调用函数模板时最容易踩的坑以及如何写出更安全、更高效的模板代码。6.1 类型推导不符合预期auto与模板推导的差异很多人认为auto的推导规则和模板参数推导完全一样但在处理初始化列表和引用时存在关键区别。templatetypename T void f(T param) {} templatetypename T void g(std::initializer_listT param) {} int main() { // 对于普通变量 int x 27; const int cx x; const int rx x; f(x); // T 和 param 都是 int f(cx); // T 和 param 都是 int (顶层const被忽略) f(rx); // T 和 param 都是 int (引用被忽略) auto ax x; // auto int auto acx cx; // auto int (顶层const被忽略) auto arx rx; // auto int (引用被忽略) // 与模板推导一致 // 关键区别1初始化列表 // auto a1 {1, 2, 3}; // auto 被推导为 std::initializer_listint // f({1, 2, 3}); // 错误模板无法推导出initializer_listT中的T。 // 但如果你明确声明参数类型是initializer_list模板可以推导T g({1, 2, 3}); // 正确T被推导为int // 关键区别2引用和auto引用 auto a_ref x; // a_ref 是 int // templatetypename T void h(T param); h(x) 中 T被推导为int, param是int。这里auto整体作为“类型”。 // 通用引用场景 auto uref1 x; // x是左值uref1是int auto uref2 27; // 27是右值uref2是int // 这与模板 templatetypename T void j(T param) 的推导规则一致。 }核心差异auto在遇到花括号初始化列表{}时会将其推导为std::initializer_list。而函数模板的参数如果不是std::initializer_list类型则无法从{}推导出T。这是auto比模板推导更“聪明”的一点。但在处理引用、const等方面两者逻辑是相通的。6.2 性能陷阱按值传递 vs. 按引用传递在函数模板中选择按值传递T还是按引用传递const T或T至关重要直接影响性能和语义。#include chrono #include vector #include string #include iostream // 版本1按值传递 templatetypename T void processByValue(T obj) { // 对obj进行操作... } // 版本2按const引用传递 templatetypename T void processByRef(const T obj) { // 对obj进行只读操作... } // 版本3转发引用用于完美转发 templatetypename T void relay(T arg) { // 将arg完美转发给其他函数 // otherFunc(std::forwardT(arg)); } int main() { std::vectorstd::string largeVec(1000000, hello world); auto start std::chrono::high_resolution_clock::now(); processByValue(largeVec); // 触发一次vector的拷贝构造代价巨大 auto end std::chrono::high_resolution_clock::now(); std::chrono::durationdouble diff end - start; std::cout By value: diff.count() s\n; start std::chrono::high_resolution_clock::now(); processByRef(largeVec); // 只传递引用零拷贝 end std::chrono::high_resolution_clock::now(); diff end - start; std::cout By const ref: diff.count() s\n; }选择策略void func(T param)当函数需要获取参数的独立副本且参数类型是内置类型、小型或移动成本低的类型时使用。对于大型对象拷贝代价高。void func(const T param)当函数只需要读取参数不需要修改且希望避免任何拷贝时使用。适用于所有类型尤其是大型对象。void func(T param)当函数需要修改传入的参数时使用。void func(T param)用于实现完美转发通常在编写泛型包装器、工厂函数时使用。需要配合std::forward。性能心得对于模板函数除非你确定类型很小或者需要内部副本否则默认使用const T作为只读参数。如果函数需要存储参数或修改副本再考虑按值传递或右值引用。C11的移动语义使得按值传递某些可移动类型如std::unique_ptr,std::string在C11后的小字符串优化也变得可以接受但需要具体分析。6.3 与auto返回类型、decltype的协作现代C中函数模板常常与auto返回类型和decltype结合用于推导复杂的返回类型特别是在实现泛型库时。// C11 风格尾置返回类型与 decltype templatetypename Container auto getFirstElement(Container c) - decltype(c.front()) { if (c.empty()) { throw std::out_of_range(Container is empty); } return c.front(); // 返回类型是 c.front() 的类型可能是引用 } // C14 风格直接使用 auto (推导规则与模板参数推导类似但注意引用性) templatetypename Container auto getFirstElementSimple(Container c) { return c.front(); // 注意如果c.front()返回引用auto会去掉引用返回一个拷贝 } // C14 风格使用 decltype(auto) 来保留引用和cv限定符 templatetypename Container decltype(auto) getFirstElementCorrect(Container c) { return c.front(); // 完美保留返回类型如果是引用则返回引用。 } int main() { std::vectorint vec {1, 2, 3}; std::vectorstd::string strVec {hello, world}; auto a getFirstElement(vec); // a 是 int auto b getFirstElementSimple(vec); // b 是 int (拷贝) auto c getFirstElementCorrect(vec); // c 是 int a 100; // 修改 vec[0] std::cout vec[0] std::endl; // 输出 100 b 200; // 只修改了b不影响vec std::cout vec[0] std::endl; // 输出 100 c 300; // 修改 vec[0] std::cout vec[0] std::endl; // 输出 300 }auto作为返回类型时其推导规则类似于模板参数按值传递会丢弃引用和顶层const。这可能导致意外的拷贝。decltype(auto)是C14引入的利器它使用decltype的规则来推导返回类型能够完美保留表达式的类型包括引用和const。在需要“原样返回”某个表达式结果的模板函数中应优先考虑使用decltype(auto)。7. 从C11到C20新特性如何简化模板调用C标准的演进一直在努力让模板编程更简单、更安全。7.1auto参数C14 泛型Lambda / C20 简写函数模板C14允许Lambda表达式使用auto参数这本质上创建了一个函数模板。// C14 泛型Lambda auto genericLambda [](auto x, auto y) { return x y; }; std::cout genericLambda(1, 2) std::endl; // 3 std::cout genericLambda(1.5, 2.3) std::endl; // 3.8 // 它等价于一个匿名类其operator()是一个模板。C20引入了简写函数模板语法让定义单参数函数模板变得更简洁。// C20 简写函数模板 auto add(auto a, auto b) { // 等价于 templatetypename T1, typename T2 auto add(T1 a, T2 b) return a b; } void printVec(const auto container) { // 等价于 templatetypename Container void printVec(const Container) for (const auto elem : container) std::cout elem ; }这大大减少了模板的语法噪音特别适合编写简单的泛型工具函数。7.2 ConceptsC20为模板参数添加约束这是革命性的特性。Concepts允许你为模板参数指定必须满足的约束条件将许多编译错误从晦涩的模板实例化深处提前到调用点并生成清晰得多的错误信息。// 没有Concepts的旧时代 templatetypename T void sortContainer(T container) { std::sort(std::begin(container), std::end(container)); // 如果T没有begin/end错误信息可能很深层。 } // 使用Concepts的C20 #include concepts #include ranges templatestd::ranges::random_access_range R // 约束R必须是一个支持随机访问的范围 void sortContainer(R container) { std::ranges::sort(container); } // 或者使用 requires 子句定义更复杂的约束 templatetypename T requires requires(T t) { // 要求T类型对象t支持 操作并且可以交换。 { t t } - std::convertible_tobool; std::swap(t, t); } void mySort(T container) { // ... 排序实现 }当调用sortContainer传入一个不支持随机访问的容器如std::list时编译器会在调用点直接报错指出“约束不满足”而不是在std::sort内部报一堆关于迭代器类型的错误。7.3if constexprC17编译期条件分支在模板函数内部我们经常需要根据类型T的不同特性执行不同的代码路径。C17之前的做法是使用标签分发或SFINAE代码冗长。if constexpr允许在编译期进行条件判断未走的分支不会实例化。// 旧方法标签分发 templatetypename T void processImpl(T val, std::true_type) { /* 处理整数 */ } templatetypename T void processImpl(T val, std::false_type) { /* 处理非整数 */ } templatetypename T void process(T val) { processImpl(val, std::is_integralT{}); } // C17 方法if constexpr templatetypename T void processModern(T val) { if constexpr (std::is_integral_vT) { // 这部分代码只有在T是整型时才会被实例化和编译 std::cout val 是整数\n; // 可以安全使用整型特有的操作如位运算 } else if constexpr (std::is_floating_point_vT) { std::cout val 是浮点数\n; } else { std::cout val 是其他类型\n; } // 所有分支共享的代码 }if constexpr让基于类型的条件编译代码变得直观且易于维护是编写复杂模板函数时的首选工具。回到文章开头那个问题mySwap(x, m)为什么失败现在我们可以给出完整的解释了因为函数模板mySwap只有一个模板参数T而两个函数形参a和b的类型都是T。在模板参数推导时编译器从第一个实参xint推导出T int从第二个实参mdouble推导出T double。推导结果冲突根据C标准这是非良构的ill-formed编译器必须报错。解决方案可以是使用两个模板参数templatetypename T1, typename T2但这样交换操作可能就不符合直觉了交换两个不同类型或者确保传入的两个实参类型相同。理解规则就能读懂编译器的“语言”从而写出正确、高效的代码。