为什么通常将模板实现代码放在头文件中而非源文件?

2026-05-05 20:582阅读0评论SEO资源
  • 内容介绍
  • 文章标签
  • 相关推荐

本文共计2801个文字,预计阅读时间需要12分钟。

为什么通常将模板实现代码放在头文件中而非源文件?

C++从编译机制到模板实现原理的角度出发,通过实例分析,解释了为什么C++通常将模板实现放在头文件中。同时,也给出了不将模板实现放在头文件中的解决方案。

在C++中,模板是一种泛型编程技术,它允许编写与数据类型无关的代码。模板实现通常放在头文件中,原因如下:

1. 编译时实例化:模板在编译时被实例化为特定数据类型的代码。如果模板实现放在源文件中,每次包含该源文件时都会重新实例化模板,导致代码冗余和编译时间增加。

2. 避免重复实例化:将模板实现放在头文件中,可以确保模板在程序中只被实例化一次。当头文件被包含时,模板代码只会编译一次,避免了重复实例化。

3. 模块化:将模板实现放在头文件中,有助于模块化代码。头文件只包含接口和声明,而实现细节放在源文件中,使得代码更易于维护和理解。

不将模板实现放在头文件中的解决方案包括:

为什么通常将模板实现代码放在头文件中而非源文件?

1. 使用源文件:将模板实现放在源文件中,而不是头文件。这样,模板代码只在编译包含该源文件的文件时实例化。

2. 条件编译:使用预处理器指令,如`#ifdef`和`#endif`,来控制模板实现的编译。例如,可以将模板实现放在一个特定的宏定义下,只在需要时包含。

3. 延迟实例化:使用延迟实例化技术,在需要模板实例的地方才进行实例化。这可以通过在模板函数或类中使用静态成员来实现。

总之,将模板实现放在头文件中是C++编程中的一个常见做法,但也可以根据具体需求选择其他解决方案。

本文从C/C++编译机制以及C++模板实现原理的角度出发,通过实例分析,解释了为什么C++一般将模板实现放在头文件中。同时给出了不将模板实现放在头文件中的解决方案。 写在前面

本文通过实例分析与讲解,解释了为什么C++一般将模板实现放在头文件中。这主要与C/C++的编译机制以及C++模板的实现原理相关,详情见正文。同时,本文给出了不将模板实现放在头文件中的解决方案。

正文 例子

现有如下3个文件:

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b); 4 5 // add.cpp 6 #include "add.h" 7 8 template <typename T> 9 T Add(const T &a, const T &b) 10 { 11 return a + b; 12 } 13 14 // main.cpp 15 #include "add.h" 16 #include <iostream> 17 18 int main() 19 { 20 int res = Add<int>(1, 2); 21 std::cout << res << "\n"; 22 return 0; 23 } 示例代码

现象

使用g++ -c add.cpp编译生成add.o,使用g++ -c main.cpp编译生成main.o,这两步都没有问题。

使用g++ -o main.exe main.o add.o生成main.exe时,报错undefined reference to 'int Add(int const&, int const&)'。

当然,直接g++ add.cpp main.cpp -o main.exe肯定也会报错,这里把编译和链接分开是为了更好地展示与分析问题。​

原因

出现上述问题的原因是:

(1)C/C++源文件是按编译单元(translation unit)分开、独立编译的。所谓translation unit,其实就是输入给编译器的source code,只不过该source code是经过预处理(pre-processed​,包括去掉注释、宏替换、头文件展开)的。在本例中,即便你使用g++ add.cpp main.cpp -o main.exe,编译器也是分别编译add.cpp和main.cpp(注意是预处理后的)的。在编译add.cpp时,编译器根本感知不到main.cpp的存在,反之同理。

(2)C++模板是通过实例化(instantiation)来实现多态(polymorphism)的。以函数模板为例,首先需要区分“函数模板”和“模板函数”。本例中,上面代码的第8~12行是函数模板,顾名思义,它就是一个模子,不是具体的函数,是不能运行的;当用具体的类型,如int,实例化模板参数T后,会生成函数模板的一个具体实例,称为模板函数,这是真正可以运行的函数。“函数模板”和“模板函数”的关系,可以类比“类”和“对象”的关系。以int为例,生成的实例/模板函数大概长这样(细节上肯定和编译器的实际实现有出入,但核心意思不会变)。

1 int Add_int_int(const int &a, const int &b) 2 { 3 return a + b; 4 } 模板函数(示例)

​对于每一个用到的具体类型,编译器都会生成对应版本的实例,当函数调用时,会调用到该实例。如用到了Add<int>,就会生成Add_int_int,用到了Add<double>,就会生成Add_double_double,等等。本例中,当编译器编译到第20行,即int res = Add<int>(1, 2);一句时,编译器就会试图生成int版本的模板实例(即模板函数)。

(3)编译器为模板生成实例的必要条件是:1. 知道模板的具体定义/实现;2. 知道模板参数对应的实际类型。

分析

下面把上面两节内容结合起来分析。

(1)当编译add.cpp时,相当于编译

1 template <typename T> 2 T Add(const T &a, const T &b); 3 4 template <typename T> 5 T Add(const T &a, const T &b) 6 { 7 return a + b; 8 } 预处理后的add.cpp

此时编译器虽然知道模板的具体定义,却不知道模板参数T的具体类型,因此不会生成任何的实例化代码。

(2)当编译main.cpp时,相当于编译

1 #include <iostream> 2 3 template <typename T> 4 T Add(const T &a, const T &b); 5 6 int main() 7 { 8 int res = Add<int>(1, 2); 9 std::cout << res << "\n"; 10 return 0; 11 } 预处理后的main.cpp

当编译到int res = Add<int>(1, 2);时,编译器想要生成int版本的函数实例,但它找不到函数模板的具体定义(即Add的“函数体”),只好作罢。好在编译器看到了函数模板的声明,于是通过了编译,将寻找int版本函数实例的任务留给了链接器。​

至此,编译add.cpp时,只知模板定义,不知模板类型参数,无法生成具体的函数定义;编译main.cpp时,只知模板类型参数,不知模板定义,同样无法生成具体的函数定义。​

(3)没什么好说的,链接器在add.o和main.o中都没找到int版本的Add定义,直接报错。​

解决方案 方案一

传统方法:把模板实现也放在头文件中。

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b) 4 { 5 return a + b; 6 } 7 8 // main.cpp 9 #include "add.h" 10 #include <iostream> 11 12 int main() 13 { 14 int res = Add<int>(1, 2); 15 std::cout << res << "\n"; 16 return 0; 17 } 解决方案一

当编译main.cpp时,相当于编译​

1 #include <iostream> 2 3 template <typename T> 4 T Add(const T &a, const T &b) 5 { 6 return a + b; 7 } 8 9 int main() 10 { 11 int res = Add<int>(1, 2); 12 std::cout << res << "\n"; 13 return 0; 14 } 预处理后的main.cpp

此时编译器既知道函数模板的定义,又知道具体的模板类型参数int,因此可以生成int版本的函数实例,不会出错。​

这种方式的优缺点如下:

  • 优点:可以按需生成。假如我们在main.cpp中调用了Add<double>(1.0, 2.0);,编译器就会为我们生成double版本的函数实例。
  • 缺点:不得不把实现细节暴露给用户。
方案二

模板声明和定义分离的方案。​

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b); 4 5 // add.cpp 6 #include "add.h" 7 8 template <typename T> 9 T Add(const T &a, const T &b) 10 { 11 return a + b; 12 } 13 14 template int Add(const int &a, const int &b); 15 16 // main.cpp 17 #include "add.h" 18 #include <iostream> 19 20 int main() 21 { 22 int res = Add<int>(1, 2); 23 std::cout << res << "\n"; 24 return 0; 25 } 解决方案二

注意,template int Add(const int &a, const int &b);是函数模板实例化(function template instantiation)[1],template关键字不能省略,否则,int Add(const int &a, const int &b);会被编译器当做普通函数的声明,从而在链接时又会报undefined reference to 'int Add(int const&, int const&)'错误。​

对于这种写法,编译器在编译add.cpp时,既能看到函数模板的定义,又能看到具体的模板类型参数int,于是生成了int版本的函数实例,整个程序可以正常编译运行。​

很显然,这种情况下编译器只生成了int版本的函数实例,所以,在main.cpp中使用Add<double>(1.0, 2.0);这样的代码肯定是不可以的。这种情况的优缺点可以辩证看待:​

  • 优点:1. 可以隐藏实现细节(我们可以把add.cpp做成.lib或.dll);2. 也可以限制只实例化特定的版本。​
  • 缺点:就是只能使用特定的几个版本,不能像方案一那样在编译main.cpp时根据具体的调用情况按需生成。​

从这里也可以看出,模板实现不一定非得放在头文件中

参考

[1]Function template - cppreference.com
[2]c++ - Why can templates only be implemented in the header file? - Stack Overflow

写在后面

本文从C/C++编译机制以及C++模板实现原理的角度,结合具体实例,讲解了为什么一般将模板实现放在头文件中。由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。

本文共计2801个文字,预计阅读时间需要12分钟。

为什么通常将模板实现代码放在头文件中而非源文件?

C++从编译机制到模板实现原理的角度出发,通过实例分析,解释了为什么C++通常将模板实现放在头文件中。同时,也给出了不将模板实现放在头文件中的解决方案。

在C++中,模板是一种泛型编程技术,它允许编写与数据类型无关的代码。模板实现通常放在头文件中,原因如下:

1. 编译时实例化:模板在编译时被实例化为特定数据类型的代码。如果模板实现放在源文件中,每次包含该源文件时都会重新实例化模板,导致代码冗余和编译时间增加。

2. 避免重复实例化:将模板实现放在头文件中,可以确保模板在程序中只被实例化一次。当头文件被包含时,模板代码只会编译一次,避免了重复实例化。

3. 模块化:将模板实现放在头文件中,有助于模块化代码。头文件只包含接口和声明,而实现细节放在源文件中,使得代码更易于维护和理解。

不将模板实现放在头文件中的解决方案包括:

为什么通常将模板实现代码放在头文件中而非源文件?

1. 使用源文件:将模板实现放在源文件中,而不是头文件。这样,模板代码只在编译包含该源文件的文件时实例化。

2. 条件编译:使用预处理器指令,如`#ifdef`和`#endif`,来控制模板实现的编译。例如,可以将模板实现放在一个特定的宏定义下,只在需要时包含。

3. 延迟实例化:使用延迟实例化技术,在需要模板实例的地方才进行实例化。这可以通过在模板函数或类中使用静态成员来实现。

总之,将模板实现放在头文件中是C++编程中的一个常见做法,但也可以根据具体需求选择其他解决方案。

本文从C/C++编译机制以及C++模板实现原理的角度出发,通过实例分析,解释了为什么C++一般将模板实现放在头文件中。同时给出了不将模板实现放在头文件中的解决方案。 写在前面

本文通过实例分析与讲解,解释了为什么C++一般将模板实现放在头文件中。这主要与C/C++的编译机制以及C++模板的实现原理相关,详情见正文。同时,本文给出了不将模板实现放在头文件中的解决方案。

正文 例子

现有如下3个文件:

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b); 4 5 // add.cpp 6 #include "add.h" 7 8 template <typename T> 9 T Add(const T &a, const T &b) 10 { 11 return a + b; 12 } 13 14 // main.cpp 15 #include "add.h" 16 #include <iostream> 17 18 int main() 19 { 20 int res = Add<int>(1, 2); 21 std::cout << res << "\n"; 22 return 0; 23 } 示例代码

现象

使用g++ -c add.cpp编译生成add.o,使用g++ -c main.cpp编译生成main.o,这两步都没有问题。

使用g++ -o main.exe main.o add.o生成main.exe时,报错undefined reference to 'int Add(int const&, int const&)'。

当然,直接g++ add.cpp main.cpp -o main.exe肯定也会报错,这里把编译和链接分开是为了更好地展示与分析问题。​

原因

出现上述问题的原因是:

(1)C/C++源文件是按编译单元(translation unit)分开、独立编译的。所谓translation unit,其实就是输入给编译器的source code,只不过该source code是经过预处理(pre-processed​,包括去掉注释、宏替换、头文件展开)的。在本例中,即便你使用g++ add.cpp main.cpp -o main.exe,编译器也是分别编译add.cpp和main.cpp(注意是预处理后的)的。在编译add.cpp时,编译器根本感知不到main.cpp的存在,反之同理。

(2)C++模板是通过实例化(instantiation)来实现多态(polymorphism)的。以函数模板为例,首先需要区分“函数模板”和“模板函数”。本例中,上面代码的第8~12行是函数模板,顾名思义,它就是一个模子,不是具体的函数,是不能运行的;当用具体的类型,如int,实例化模板参数T后,会生成函数模板的一个具体实例,称为模板函数,这是真正可以运行的函数。“函数模板”和“模板函数”的关系,可以类比“类”和“对象”的关系。以int为例,生成的实例/模板函数大概长这样(细节上肯定和编译器的实际实现有出入,但核心意思不会变)。

1 int Add_int_int(const int &a, const int &b) 2 { 3 return a + b; 4 } 模板函数(示例)

​对于每一个用到的具体类型,编译器都会生成对应版本的实例,当函数调用时,会调用到该实例。如用到了Add<int>,就会生成Add_int_int,用到了Add<double>,就会生成Add_double_double,等等。本例中,当编译器编译到第20行,即int res = Add<int>(1, 2);一句时,编译器就会试图生成int版本的模板实例(即模板函数)。

(3)编译器为模板生成实例的必要条件是:1. 知道模板的具体定义/实现;2. 知道模板参数对应的实际类型。

分析

下面把上面两节内容结合起来分析。

(1)当编译add.cpp时,相当于编译

1 template <typename T> 2 T Add(const T &a, const T &b); 3 4 template <typename T> 5 T Add(const T &a, const T &b) 6 { 7 return a + b; 8 } 预处理后的add.cpp

此时编译器虽然知道模板的具体定义,却不知道模板参数T的具体类型,因此不会生成任何的实例化代码。

(2)当编译main.cpp时,相当于编译

1 #include <iostream> 2 3 template <typename T> 4 T Add(const T &a, const T &b); 5 6 int main() 7 { 8 int res = Add<int>(1, 2); 9 std::cout << res << "\n"; 10 return 0; 11 } 预处理后的main.cpp

当编译到int res = Add<int>(1, 2);时,编译器想要生成int版本的函数实例,但它找不到函数模板的具体定义(即Add的“函数体”),只好作罢。好在编译器看到了函数模板的声明,于是通过了编译,将寻找int版本函数实例的任务留给了链接器。​

至此,编译add.cpp时,只知模板定义,不知模板类型参数,无法生成具体的函数定义;编译main.cpp时,只知模板类型参数,不知模板定义,同样无法生成具体的函数定义。​

(3)没什么好说的,链接器在add.o和main.o中都没找到int版本的Add定义,直接报错。​

解决方案 方案一

传统方法:把模板实现也放在头文件中。

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b) 4 { 5 return a + b; 6 } 7 8 // main.cpp 9 #include "add.h" 10 #include <iostream> 11 12 int main() 13 { 14 int res = Add<int>(1, 2); 15 std::cout << res << "\n"; 16 return 0; 17 } 解决方案一

当编译main.cpp时,相当于编译​

1 #include <iostream> 2 3 template <typename T> 4 T Add(const T &a, const T &b) 5 { 6 return a + b; 7 } 8 9 int main() 10 { 11 int res = Add<int>(1, 2); 12 std::cout << res << "\n"; 13 return 0; 14 } 预处理后的main.cpp

此时编译器既知道函数模板的定义,又知道具体的模板类型参数int,因此可以生成int版本的函数实例,不会出错。​

这种方式的优缺点如下:

  • 优点:可以按需生成。假如我们在main.cpp中调用了Add<double>(1.0, 2.0);,编译器就会为我们生成double版本的函数实例。
  • 缺点:不得不把实现细节暴露给用户。
方案二

模板声明和定义分离的方案。​

1 // add.h 2 template <typename T> 3 T Add(const T &a, const T &b); 4 5 // add.cpp 6 #include "add.h" 7 8 template <typename T> 9 T Add(const T &a, const T &b) 10 { 11 return a + b; 12 } 13 14 template int Add(const int &a, const int &b); 15 16 // main.cpp 17 #include "add.h" 18 #include <iostream> 19 20 int main() 21 { 22 int res = Add<int>(1, 2); 23 std::cout << res << "\n"; 24 return 0; 25 } 解决方案二

注意,template int Add(const int &a, const int &b);是函数模板实例化(function template instantiation)[1],template关键字不能省略,否则,int Add(const int &a, const int &b);会被编译器当做普通函数的声明,从而在链接时又会报undefined reference to 'int Add(int const&, int const&)'错误。​

对于这种写法,编译器在编译add.cpp时,既能看到函数模板的定义,又能看到具体的模板类型参数int,于是生成了int版本的函数实例,整个程序可以正常编译运行。​

很显然,这种情况下编译器只生成了int版本的函数实例,所以,在main.cpp中使用Add<double>(1.0, 2.0);这样的代码肯定是不可以的。这种情况的优缺点可以辩证看待:​

  • 优点:1. 可以隐藏实现细节(我们可以把add.cpp做成.lib或.dll);2. 也可以限制只实例化特定的版本。​
  • 缺点:就是只能使用特定的几个版本,不能像方案一那样在编译main.cpp时根据具体的调用情况按需生成。​

从这里也可以看出,模板实现不一定非得放在头文件中

参考

[1]Function template - cppreference.com
[2]c++ - Why can templates only be implemented in the header file? - Stack Overflow

写在后面

本文从C/C++编译机制以及C++模板实现原理的角度,结合具体实例,讲解了为什么一般将模板实现放在头文件中。由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力。