【C++】模板的两大特性

【C++】模板的两大特性

文章目录


前言

本文探讨了C++模板编程中的两个关键问题。第一部分介绍了typename在模板中的特殊使用场景,指出当模板参数访问内嵌类型时必须使用typename关键字来消除编译器歧义。第二部分分析了模板分离编译导致链接错误的原因,通过对比普通函数和模板函数的编译链接过程,解释了模板定义必须放在头文件中才能被实例化的原理。文章结合代码示例和编译链接过程图解,帮助读者理解模板编译机制和常见错误的解决方法。


1. 关于 typename 的使用场景

在刚开始聊模板时我们说过 typename 或者 class是用来定义模板参数的关键字,但在一些场景下,必须用 typename,比如我们要写一个通用的打印容器的函数模板

#include<iostream>#include<vector>#include<list>usingnamespace std;template<classContainer>voidPrint(const Container& con){ Container::const_iterator it = con.begin();while(it != con.end()){ cout <<*it <<" "; it++;} cout << endl;}intmain(){ vector<int> v ={1,2,3,4,5,6}; list<int> lt ={1,2,3,4,5,6};Print(v);Print(lt);return0;}

这里的 Container 可以是任意的容器,通过迭代器去遍历打印,这里用的容器、迭代器这些都是 STL 中的东西,现在先简单了解一下:

STL(standard template libaray-标准模板库):是C++标准库的重要组成部分,不仅是一个可复用的组件库,而且是一个包罗数据结构与算法的软件框架。
在 STL 中,容器负责存储和管理数据;而迭代器负责访问数据,它的底层有可能是指针,也有可能不是

它们的用法这里不过多赘述,下面我们来运行一下上面的代码,看一下结果:

在这里插入图片描述


我们看到编译报错了,这里报错的原因就在 Container::const_iterator it = con.begin(); 这句代码上面。因为模板的缘故,当编译器从上往下编译到这句代码的时候,编译器并不知道这个 Container 是什么东西,如果按照代码的写法来看可能是命名空间或者是一个类,但不管是哪一种,编译器都认为这个地方有可能不合法:

因为 const_iterator 有可能是一个类型,也有可能是一个变量,如果是一个类型的话,这个地方就是合法的,但如果是一个变量,那这里就不合法,我们这里是一个类,那 const_iterator 有可能是一个内部类或者 typedef 定义的一个类型,这个时候是符合语法要求的,但是 const_iterator 也有可能是一个静态成员变量(访问静态成员变量通过类名::静态成员 或者 对象.静态成员),这个时候就不合法!

我们解决的办法就是在这句代码前面加一个 typename,即 typename Container::const_iterator it = con.begin(); 这样代码就可以正常运行了

在这里插入图片描述

总结就是,模板参数取内嵌类型,前面都要加 typename,因为编译器分不清到底是类型还是变量,加上之后就是告诉编译器,这是个类型,先让它过,等到实例化的时候再去确认这个类型!

2. 模板的分离编译问题

2.1 简述程序编译链接的过程

在C/C++中,我们写好的程序只是一堆文本信息,要把这些文本信息变成计算机能识别的二进制指令,就要经过编译、链接两大过程。其中编译又可分为预处理、编译、汇编三个部分。(编译链接的过程后续会详细讲解!)

我们以C语言为例:

在这里插入图片描述
  1. ⼀个C语言的项目中可能有多个 .c 文件一起构建,多个.c 文件单独经过编译器,编译处理生成对应的目标文件。
    注:在Windows环境下的目标文件的后缀是 .obj ,Linux环境下目标文件的后缀是 .o
  2. 多个目标文件和链接库一起经过链接器处理生成最终的可执行程序。
    注:链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库。

下面是各个阶段的详细过程:

2.1.1 预处理

在预处理阶段,源文件和头文件会被处理成为 .i 为后缀的文件。
预处理阶段要完成的工作:

  1. 宏替换,即将所有的 #define 删除,并展开所有的宏定义。
  2. 处理所有的条件编译指令,如:#if、#ifdef、#elif、#else、#endif 。
  3. 处理 #include 预编译指令,即将包含的头文件的内容插入到该预编译指令的位置。被包含的头文件也可能包含其他文件,所以这个过程是递归进行的。
  4. 删除所有的注释
  5. 添加行号和文件名标识,方便后续编译器生成调试信息等。
  6. 或保留所有的#pragma的编译器指令,编译器后续会使用。

2.1.2 编译

编译过程就是将预处理后的文件进行一系列的:词法分析、语法分析、语义分析及优化,生成相应的汇编代码文件(.s 为后缀的文件)。

2.1.3汇编

汇编器是将汇编代码转转变成机器可执行的指令(.o 为后缀的目标文件),每一个汇编语句几乎都对应一条机器指令。就是根据汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。

2.1.4 链接

链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序。链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。链接解决的是一个项目中多文件、多模块之间互相调用的问题。

2.2 模板分离编译为什么会链接报错

2.2.1 什么是分离编译

一般在大型项目里面,如果所有代码都写在一个 .c 文件里面,就有一些问题,比如难以做到团队协作、其次代码会变得难以维护、而且项目比较大的话程序编译的时间会很长…而分离编译的出现就是为了应对软件开发日益增长的复杂性。

一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。

C/C++ 一般会声明定义分离到两个文件,即在 .h 文件中放声明,具体的实现放在 .cpp 文件里,这么做的原因有很多,这里简单说一下:

  1. 接口与实现分离,实现信息隐藏。在一些商业型项目,内部的实现细节一般都放在源文件中,打成动态库(一般动态库用的多),最后只提供头文件和编译好的库(动态库 或者 静态库),即将模块的公开接口暴露给使用者,但是不包含我的核心实现。
  2. 如果把定义都放到 .h 文件,首先多个文件包含会存在重复链接的问题;其次不方便阅读源代码,但如果声明定义分离到两个文件,因为 .h 文件中只有声明,所以要看哪些成员变量或者核心的成员函数这些就很方便。
  3. 能提高编译的效率。如果是一些大型的项目,从头到尾完整的编译一遍是很费时间的,所以一般在项目中,功能相关的 .h 和 .cpp 会放到一个模块里,这些模块又会打成动态库,然后相互之间去进行链接。实践当中,我们是可以只控制编译其中的一个模块的,这样当只修改某个模块的代码时,就无需重新编译所有源文件,只需编译该模块然后重新链接即可。

2.2.2 模板分离编译存在的问题

模板分离编译的问题不是指它不能分离编译,而是不能分离编译到两个文件,否则会链接报错。

先来看看普通函数的分离编译:

// test.cpp#include"func.h"intmain(){func();return0;}// func.h#pragmaonce#include<iostream>usingnamespace std;voidfunc();// func.cpp#include"func.h"voidfunc(){ cout <<"void func();"<< endl;}

运行截图:

在这里插入图片描述


我们看到在正常定义的情况下,程序是能跑起来的,但如果没有定义 func 函数,而是只有它的声明呢?

// func.cpp#include"func.h"//void func()//{// cout << "void func();" << endl;//}

这个时候就会报链接错误

在这里插入图片描述


那为什么会报链接错误呢?这就要看看程序编译链接的过程了:

在这里插入图片描述

首先在预处理阶段,头文件会在被包含的地方展开,即会把头文件中的内容拷贝到对应的 .cpp 文件中去,这里我们只看 func.h 头文件

在这里插入图片描述


头文件展开之后,在 func.cpp 文件中,就既有 func 函数的声明,又有定义、而在 test.cpp 中,就只有 func 函数的声明。
我们调用 func 函数,在编译阶段检查语法时,看到有函数的声明,参数这些也能对得上,那编译这个阶段就没问题,而 func 函数的定义只能说明在其它文件。

前面有提到编译阶段会生成相应的汇编代码文件,test.s 和 func.s,而 func(); 转成汇编代码之后是 call 一个地址(func 函数的地址)。这里我们就要知道函数被编译好了之后是一串指令,而函数的地址就是第一句指令的地址。如果在 test.cpp 中有函数的定义,那么函数的地址在编译阶段就确定了,但现在我们只有函数的声明,这个时候函数的地址就只能在链接的时候去其它文件里面找。

那它在链接阶段又是怎么找的呢?简单说一下,在链接之前,每一个 .cpp 文件在经过编译、汇编形成 test.o 和 func.o 的目标文件中,都会有一个叫符号表的东西。每一个符号表中就会把当前文件的函数和它的地址填进来,如果没有,就空下来,比如在 func.o 中:

在这里插入图片描述

然后在链接的时候,会发生符号表的合并。所以在编译阶段 test.o 中空下来的地址,在链接这个阶段会去其它的符号表里找到并更新,如果找不到,就会报链接错误!


前置知识聊完,下面我们来看模板的声明定义分离,我们还是写一个通用的打印容器的模板,然后把声明和定义分离:

// test.cpp#include"func.h"intmain(){func(); vector<int> v ={1,2,3,4,5,6}; list<int> lt ={1,2,3,4,5,6};Print(v);Print(lt);return0;}// func.h#pragmaonce#include<iostream>#include<vector>#include<list>usingnamespace std;voidfunc();template<classContainer>voidPrint(const Container& con);// func.cpp#include"func.h"voidfunc(){ cout <<"void func();"<< endl;}template<classContainer>voidPrint(const Container& con){typenameContainer::const_iterator it = con.begin();while(it != con.end()){ cout <<*it <<" "; it++;} cout << endl;}

运行之后它会报链接错误:

在这里插入图片描述


我们看到模板声明定义分离之后也会报链接错误,但是我们明明已经定义了,那问题在哪?

这里的问题就在于模板它并不能直接调用,而是要实例化的,且模板是按需实例化的。现在就有一个问题,在 func.cpp 中,我们不知道它具体要实例化成什么,也无法生成对应的指令;而在 test.cpp 中,我们知道要实例化成什么,但是只有声明。

由于在链接之前,每个文件之间各自是不交互的,所以编译阶段都没问题,但在链接的时候,我们找 Print 的地址就会找不到,因为 Print 没有实例化。这就是模板不能分离编译到两个文件的原因!

3. 解决办法

  1. 方法一:最佳实践:建议模板直接定义在 .h 文件,或者在 .h 文件中做声明定义分离,不要分离到两个文件!
// func.h#pragmaonce#include<iostream>#include<vector>#include<list>usingnamespace std;voidfunc();template<classContainer>voidPrint(const Container& con){typenameContainer::const_iterator it = con.begin();while(it != con.end()){ cout <<*it <<" "; it++;} cout << endl;}

这样头文件会在调用的地方展开,直接就有定义,编译器也就知道实例化成什么,在编译阶段就能确定它的地址,也就没链接什么事了。
运行截图:

在这里插入图片描述
  1. 方法二:模板不能分离编译到两个文件是因为模板没有实例化,所以我们可以在定义的地方显示实例化!
// func.cpp#include"func.h"voidfunc(){ cout <<"void func();"<< endl;}template<classContainer>voidPrint(const Container& con){typenameContainer::const_iterator it = con.begin();while(it != con.end()){ cout <<*it <<" "; it++;} cout << endl;}// 显示实例化templatevoidPrint<vector<int>>(const vector<int>& v);templatevoidPrint<list<int>>(const list<int>& lt);

本来编译器不知道这里要实例化成什么,但我们显示实例化就是告诉编译器实例化出一份 Container 是 vector<int> 和 Container 是 list<int> 的,但一般这种方法不常用,了解一下即可!

完!


说了这么多,最后想听听你对模板的真实感受–是真爱,是双刃剑,还是能躲就躲?👇👇动动手指,选一个最符合你心境的选项~

Read more

【C++藏宝阁】C++入门:命名空间(namespace)详解

【C++藏宝阁】C++入门:命名空间(namespace)详解

🌈个人主页:聆风吟 🔥系列专栏:C++藏宝阁 🔖少年有梦不应止于心动,更要付诸行动。 文章目录 * 📚专栏订阅推荐 * 📋前言:为什么需要命名空间? * 一、命名空间的定义 * 二、命名空间的使用 * 三、命名空间的特性 * 3.1 命名空间的嵌套定义 * 3.2 命名空间的定义可以不连续 * 四、命名空间的本质:独立的作用域 * 4.1 命名空间是C++的一种作用域类型 * 4.2 命名空间作用域的特点 * 4.3 域作用限定符 `::` 的作用 * 4.4 编译器的查找规则 * 五、命名空间的价值 * 5.1 解决命名冲突 * 5.2 模块化组织代码 * 5.3

By Ne0inhk
C/C++ 全局变量跨文件真相:一句话实验与底层原理

C/C++ 全局变量跨文件真相:一句话实验与底层原理

一句话总结:能否跨文件取决于符号的链接属性——外部链接可跨文件,内部链接不可跨文件;static 正是把外部链接改成内部链接的关键字。 目录 1. 三个实验:30 秒看懂全局变量跨文件能力 2. 底层原理:链接属性决定生死 3. 常见误区:#include 到底算不算跨文件? 4. 类静态成员变量:披着“类作用域”外衣的全局变量 1. 三个实验:30 秒看懂全局变量跨文件能力 实验变量定义链接属性extern 能否跨文件访问?结果1️⃣ 普通全局变量int g = 10;外部链接✅ 可以成功链接2️⃣ static 全局变量static int s = 20;内部链接❌ 不行链接报错:undefined reference3️⃣ #include 假装跨文件#include "a.cpp&

By Ne0inhk
软件解耦与扩展:插件式开发方式(基于 C++ 与 C# 的实现)

软件解耦与扩展:插件式开发方式(基于 C++ 与 C# 的实现)

软件解耦与扩展:插件式开发方式 * 🤔 什么是插件式开发? * 🧩 为何选择插件式开发?—— 解耦与扩展的艺术 * 1. 高度解耦 * 2. 极致的扩展性 * 3. 增强可维护性 * 4. 支持动态加载与卸载 * 🏗️ 插件系统的核心架构 * 💻 实践篇:C# 下的插件式开发 * 1. 定义插件契约 * 2. 实现一个具体插件 * 3. 构建宿主程序(插件加载器) * 应用案例:可扩展的日志系统 * ⚙️ 实践篇:C++ 下的插件式开发 * 1. 定义插件契约 * 2. 实现一个具体插件 * 3. 构建宿主程序(插件加载器) * 📊 C# 与 C++ 实现对比 * ⚠️ 挑战与注意事项 * 🎯 总结:何时使用插件式架构? 🚀在软件工程的漫长演进中,我们始终在追求一个核心目标:构建稳定而灵活的系统。一个优秀的软件架构,如同人体的骨骼,既要坚实稳固,又要具备生长与适应的能力。

By Ne0inhk
华为OD技术面八股文_C++_01

华为OD技术面八股文_C++_01

文章目录 * C语言和C++的区别 * C++11引入哪些新特性 * 什么是面向对象?面向对象的三大特性 * malloc和new的区别 * delete和free的区别 * delete和delete[]的区别 * 什么是虚函数?什么是纯虚函数 * 什么是虚函数表?什么是虚函数指针? * 介绍一下虚函数实现机制 * 构造函数和构析函数能不能写为虚函数,为什么 * 说一下构造、析构函数的调用顺序 C语言和C++的区别 1. C++有新增的关键字和语法,还允许自定义命名空间。 2. C++新增类的概念,C语言中只有struct的概念。C++中添加访问权限概念,struct 的默认访问权限和继承权限都是 public,但是 class 的默认访问权限和默认继承权限都是 private. 3. C++引入了类、封装、继承、多态、模板、重载、异常处理机制等特性。而C没有 4.

By Ne0inhk