【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

『AI辅助Skill』UI-UX-Pro-Max Skill完全指南:让开发者秒变UI设计师

『AI辅助Skill』UI-UX-Pro-Max Skill完全指南:让开发者秒变UI设计师

📣读完这篇文章里你能收获到 1. 📁 理解UI-UX-Pro-Max Skill的核心价值和设计资源库 2. 🐍 掌握在Claude Code中安装和配置该技能的方法 3. 🌐 学会通过自然语言对话让AI自动生成专业级UI代码 4. 🖥️ 通过实战案例了解如何快速构建精美界面 文章目录 * 前言 * 一、什么是UI-UX-Pro-Max Skill? * 1.1 核心设计资源库 * 1.2 工作流程 * 1.3 技术栈支持 * 二、安装与配置 * 2.1 环境要求 * 2.2 CLI工具安装(推荐) * 2.3 手动安装 * 2.4 验证安装 * 三、使用指南与实战案例 * 3.1 基本使用方法 * 创建完整页面 * 创建特定组件 * 3.2 实战案例1:

By Ne0inhk
AI Agent 面试八股文100问:大模型智能体高频考点全解析(附分类指南和简历模板)

AI Agent 面试八股文100问:大模型智能体高频考点全解析(附分类指南和简历模板)

AI Agent 面试八股文100问:大模型智能体高频考点全解析(附分类指南和简历模板) 如果你对学成归来的简历没有概念,可以看看以下的模板先,毕竟先看清眼前的路,比奔跑更重要: 最终的AI Agent简历模板,点我跳转! 适用人群:LLM Agent、RAG、AutoGPT、LangChain、Function Calling 等方向的求职者与开发者 随着大模型技术的飞速演进,AI Agent(智能体) 已成为工业界和学术界共同关注的焦点。无论是 AutoGPT、LangChain 还是 LlamaIndex,背后都离不开对 Agent 架构、推理机制、工具调用等核心能力的深入理解。 本文系统整理了 AI Agent 方向的 100 道高频面试问题,覆盖 基础概念、架构设计、推理决策、工具调用、记忆管理、评估方法、安全对齐、

By Ne0inhk
2026年Python+AI学习路线完整指南:从零基础到实战专家

2026年Python+AI学习路线完整指南:从零基础到实战专家

✨道路是曲折的,前途是光明的! 📝 专注C/C++、Linux编程与人工智能领域,分享学习笔记! 🌟 感谢各位小伙伴的长期陪伴与支持,欢迎文末添加好友一起交流! 📊 目录 * 为什么选择Python+AI * AI技术领域分布 * 完整学习路径 * 分阶段学习指南 * 实战代码示例 * 学习资源推荐 * 常见问题解答 为什么选择Python+AI? Python已成为人工智能领域最主流的编程语言,根据Stack Overflow 2024年开发者调查,Python在AI/ML领域的使用率超过85%。 Python在AI领域的优势 优势说明🐍 语法简洁上手快,专注算法本身而非语法细节📦 生态丰富NumPy、Pandas、PyTorch等成熟库👥 社区活跃海量教程、开源项目和问题解答🔧 工具完善Jupyter、Colab等优秀开发环境🚀 部署便捷Flask/FastAPI快速构建AI服务 AI技术领域分布 了解AI各领域的占比,帮助你更好地规划学习重点: 35%30%15%12%5%3%2025年AI技术领域市场需求分布机器

By Ne0inhk
人工智能:自然语言处理在法律领域的应用与实战

人工智能:自然语言处理在法律领域的应用与实战

自然语言处理在法律领域的应用与实战 学习目标 💡 理解自然语言处理(NLP)在法律领域的应用场景和重要性 💡 掌握法律领域NLP应用的核心技术(如法律文本分类、实体识别、合同分析) 💡 学会使用前沿模型(如LegalBERT、LexGLUE)进行法律文本分析 💡 理解法律领域的特殊挑战(如专业术语、法律规范、数据稀缺) 💡 通过实战项目,开发一个合同分析应用 重点内容 * 法律领域NLP应用的主要场景 * 核心技术(法律文本分类、实体识别、合同分析) * 前沿模型(LegalBERT、LexGLUE)在法律领域的使用 * 法律领域的特殊挑战 * 实战项目:合同分析应用开发 一、法律领域NLP应用的主要场景 1.1 法律文本分类 1.1.1 法律文本分类的基本概念 法律文本分类是将法律文本划分到预定义类别的过程。在法律领域,法律文本分类的主要应用场景包括: * 判例分类:将判例分为不同的类别(如民事、刑事、行政) * 法律文件分类:

By Ne0inhk