C++将模板实现放入头文件原理解析

 更新时间:2022年06月06日 09:12:24   作者:同勉共进  
这篇文章主要为大家介绍了C++将模板实现放入头文件原理解析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

写在前面

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

例子

现有如下3个文件:

// add.h
 template <typename T>
 T Add(const T &a, const T &b);
 // add.cpp
 #include "add.h"
 template <typename T>
 T Add(const T &a, const T &b)
 {
     return a + b;
 }
 // main.cpp
 #include "add.h"
 #include <iostream>
 int main()
 {
     int res = Add<int>(1, 2);
     std::cout << res << "\n";
     return 0;
 }

现象

使用 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 为例,生成的实例/模板函数大概长这样(细节上肯定和编译器的实际实现有出入,但核心意思不会变)。

 int Add_int_int(const int &a, const int &b)
 {
     return a + b;
 }

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

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

分析

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

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

 template <typename T>
 T Add(const T &a, const T &b);
 template <typename T>
 T Add(const T &a, const T &b)
 {
     return a + b;
 }

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

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

 #include <iostream>
 template <typename T>
 T Add(const T &a, const T &b);
 int main()
 {
     int res = Add<int>(1, 2);
     std::cout << res << "\n";
     return 0;
 }

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

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

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

解决方案

方案一

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

// add.h
 template <typename T>
 T Add(const T &a, const T &b)
 {
     return a + b;
 }
 // main.cpp
 #include "add.h"
 #include <iostream>
 int main()
 {
     int res = Add<int>(1, 2);
     std::cout << res << "\n";
     return 0;
 }

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

 #include <iostream>
 template <typename T>
 T Add(const T &a, const T &b)
 {
     return a + b;
 }
 int main()
 {
     int res = Add<int>(1, 2);
     std::cout << res << "\n";
     return 0;
 }

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

这种方式的优缺点如下:

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

方案二

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

 // add.h
 template <typename T>
 T Add(const T &a, const T &b);
 // add.cpp
 #include "add.h"
 template <typename T>
 T Add(const T &a, const T &b)
 {
     return a + b;
 }
 template int Add(const int &a, const int &b);
 // main.cpp
 #include "add.h"
 #include <iostream>
 int main()
 {
     int res = Add<int>(1, 2);
     std::cout << res << "\n";
     return 0;
 }

注意, 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++模板实现原理的角度,结合具体实例,讲解了为什么一般将模板实现放在头文件中。由于在下才疏学浅,能力有限,错误疏漏之处在所难免,恳请广大读者批评指正,您的批评是在下前进的不竭动力,更多关于C++头文件放入模板实现的资料请关注脚本之家其它相关文章!

相关文章

  • c++实现简单随机数的代码

    c++实现简单随机数的代码

    在本篇文章里小编给大家整理的是一篇关于c++实现简单随机数的代码内容,有需要的朋友们可以跟着学习下。
    2021-05-05
  • C++ 前置声明详解及实例

    C++ 前置声明详解及实例

    这篇文章主要介绍了C++ 前置声明详解及实例的相关资料,需要的朋友可以参考下
    2017-06-06
  • 重构-C++实现矩阵的简单实例

    重构-C++实现矩阵的简单实例

    下面小编就为大家带来一篇重构-C++实现矩阵的简单实例。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-06-06
  • C++ 单链表的基本操作(详解)

    C++ 单链表的基本操作(详解)

    下面小编就为大家带来一篇C++ 单链表的基本操作(详解)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2016-12-12
  • Qt实现UDP多线程数据处理及发送的简单实例

    Qt实现UDP多线程数据处理及发送的简单实例

    本文主要介绍了Qt实现UDP多线程数据处理及发送的简单实例,文中通过示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-10-10
  • C++全面细致讲解复数类

    C++全面细致讲解复数类

    本文章向大家介绍C++ 标准库中的复数类,主要包括C++ 标准库中的复数类使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下
    2022-06-06
  • c++ sqlite3如何利用事务(BEGIN;COMMIT;)批量操作

    c++ sqlite3如何利用事务(BEGIN;COMMIT;)批量操作

    这篇文章主要介绍了c++ sqlite3如何利用事务(BEGIN;COMMIT;)批量操作,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2023-08-08
  • C语言俄罗斯方块游戏课程设计

    C语言俄罗斯方块游戏课程设计

    这篇文章主要为大家详细介绍了C语言俄罗斯方块游戏课程设计,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-06-06
  • c++ For循环执行顺序流程图解

    c++ For循环执行顺序流程图解

    for 循环允许您编写一个执行特定次数的循环的重复控制结构,这里为大家分享一下具体的执行流程,需要的朋友可以参考下
    2021-10-10
  • C语言-I/O流设计实验

    C语言-I/O流设计实验

    编程语言的I/O类库中常常使用流这个抽象的概念,它代表任何有能力产生数据的数据源对象或时有能力接收数据的接收端对象,本文为大家介绍C语言中I/O系统基础知识
    2021-07-07

最新评论