ARTICLE · 1185940
模板:写给编译器看的"代码模板"——泛型编程入门
假设要写一个求最大值的函数:int 版、double 版、string 版……逻辑一模一样,就是类型不同。复制粘贴三份,看似省事;等你要改逻辑——比如要求"相等时返回后一个"——就要改三处,漏一处就是一处隐蔽的偏差。
STL 为什么能一个vector装数字、一个vector装字符串?因为它没有为每种类型各写一份代码——它写了一份"模板",让编译器在用到什么类型时,现场生成对应版本。
这篇就讲清楚这件事:函数模板、类模板,以及一个容易被忽略的非类型模板参数。看完你会明白,那些"动辄几百行"的模板报错(第 1 篇的劝退现场之一),本质上都是编译器在努力替你"定制代码"时发出的怨言。
复制粘贴地狱:三个几乎一样的函数
先重温痛点长什么样:
#include<iostream>#include<string>intmy_max(int a, int b){ return a > b ? a : b; }doublemy_max(double a, double b){ return a > b ? a : b; }std::string my_max(const std::string& a, const std::string& b){return a > b ? a : b;}
注意我用的是my_max,因为标准库已经有std::max了(第 7 篇的 STL 里就有它)。上面三个函数只有类型不同,逻辑完全一样;每多支持一种类型就复制一份。类型再多一点、逻辑再复杂一点,这里就是事故现场。
复制粘贴的真正代价不只是"改三处",更是心智负担:每多一份拷贝,读代码的人就得多确认一次"它和另外几份是不是一样的"。十几个类型、十几份几乎相同的函数,某次只改了其中五份,剩下几份在某个隐蔽分支里默默用着旧逻辑——这种"改了等于没改"的隐患,比编译报错难查得多。
模板的思路非常朴素:把"类型"也当作一个参数,写一份,剩下的交给编译器。
函数模板:一个版本,搞定所有类型
#include<iostream>#include<string>template <typename T>T my_max(const T& a, const T& b){return a > b ? a : b;}intmain(){std::cout << my_max(3, 5) << '\n'; // T 自动推导为 intstd::cout << my_max(2.5, 1.8) << '\n'; // doublestd::cout << my_max(std::string("aa"), std::string("ab")) << '\n';std::cout << my_max<double>(3, 2.5) << '\n'; // 显式指定 T = doublereturn 0;}
语法要点:template开头,typename和class在这里等价,现代写法习惯用typename。调用时可以靠实参自动推导(my_max(3, 5)的 T 就是 int),也可以显式指定my_max(...)——当几个实参类型不一致、推导不出唯一 T 时,显式指定就是出口。
关键在"推导"这一步:编译器遇到my_max(3, 5),就实例化一份 int 版本;遇到 string 版本,再生成一份。整个过程发生在编译期,运行期什么都不知道。这带来一个直接好处:类型检查在编译期完成——传错类型当场报错,而不是运行到一半才崩溃,这就是模板带来的类型安全。
这里有个常见的推导失败现场:实参类型不一致时,比如my_max(3, 2.5)——T 到底是 int 还是 double?编译器推导不出唯一答案,直接报错,报错信息里常带着 "template argument deduction failed" 之类的字样。解法就是显式指定类型。以后看到这类报错,先想两件事:是不是参数类型没对齐,或者哪个头文件没包含。
类模板:一个 Box,装天下
STL 的容器都是类模板。我们自己造一个最简的"盒子"看看内部结构:
#include<iostream>#include<string>template <typename T>class Box {public:explicitBox(const T& value) : value_(value) {}const T& get()const{ return value_; }voidset(const T& value){ value_ = value; }private:T value_;};intmain(){Box<int> ib(42);Box<std::string> sb("hello");ib.set(7);std::cout << ib.get() << '\n'; // 7std::cout << sb.get() << '\n'; // helloreturn 0;}
注意:Box本身不是类型,Box、Box才是类型。这就是为什么你一直写std::vector而不是std::vector——类模板要实例化之后才能当类型用(C++17 起Box b(42)可以让编译器自动推导,但先掌握传统写法不吃亏)。
类模板的成员函数也可以挪到类外定义,但必须重新带上模板参数:template void Box::set(const T& value) { value_ = value; }。这个写法在工程头文件里很常见,见到别慌,它只是"把定义搬出去"而已。
这一刻回头看第 7 篇的容器们,它们的神秘感基本消失了:vector、map、unique_ptr,全是同一个套路——一份模板,按类型现场生成。
不只是类型:非类型模板参数
模板参数并非只能是"类型",还可以是编译期常量——整数、枚举这些。这类参数叫"非类型模板参数":
#include<cstddef>#include<iostream>// T 是类型参数; N 是编译期整数参数template <typename T, std::size_t N>class FixedArray {public:std::size_tsize()const{ return N; }T& operator[](std::size_t i) { return data_[i]; }const T& operator[](std::size_t i) const { return data_[i]; }private:T data_[N]; // N 编译期已知, 所以能作为数组维度};intmain(){FixedArray<int, 5> arr; // 编译期就知道: 5 个 intarr[0] = 10;arr[4] = 99;std::cout << arr[0] << " " << arr[4] << " size=" << arr.size() << '\n';return 0;}
std::array就是这么实现的——第二参数是编译期大小,不分配堆内存。因为 N 必须在编译期确定,你没法写std::size_t n; ... FixedArray,这既是限制,也是"零运行时开销"的来源。
顺着这个思路,std::array不只是"定长数组",它把编译期固定大小包装成能范围 for 遍历、能取 size() 的容器——选容器时,定长、不扩容就是 array 的定位(第 7 篇选型原则的一个实例)。
两个工程上的提醒。其一,模板的定义必须能被实例化的地方看见——所以模板实现一般直接写在头文件里,和普通函数"声明放头文件、实现放 cpp"的习惯不一样,第一次遇到别以为自己写错了。其二,模板参数"什么都能装"是双刃剑:想把"必须支持 > 运算"这种约束写进代码,老标准只能靠报错提示。C++20 起标准提供了concept(概念),可以在编译期对模板参数做更明确、更容易读的约束,那是另一个话题,入门阶段知道有这件事就行。
总结
模板的本质,是把"类型"也参数化的编译期代码生成:你写一份,编译器按需生成无数份。代价是编译变慢、报错变长,换来的是类型安全与零运行时开销。理解"实例化发生在编译期"这几个字,你就迈过了 C++ 进阶路上的一道大坎——模板不再是天书,而是你手上趁手的"代码生成器"。
下一篇,我们处理一个"代码多起来必会碰到"的问题:名字冲突。命名空间,让代码组织得井井有条。点个"在看",我们下篇见。















































































