【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

Linux系统学习【深入剖析Git的原理和使用(下)】

Linux系统学习【深入剖析Git的原理和使用(下)】

🔥承渊政道:个人主页 ❄️个人专栏: 《C语言基础语法知识》《数据结构与算法》 《C++知识内容》《Linux系统知识》 ✨逆境不吐心中苦,顺境不忘来时路!🎬 博主简介: 引言:在深入剖析Git的原理和使用(上)中,我们已经搭建起Git的基础认知框架—从Git的诞生背景、核心设计理念出发,掌握了初始化仓库、提交版本、查看日志、简单分支创建与切换等基础操作,也初步触及了Git“分布式版本控制”的核心优势.但这些表层操作,仅仅是Git强大功能的冰山一角:当我们面对多人协作中的代码冲突、复杂分支的合并与管理、误操作后的版本回滚难题,或是想弄明白“Git如何高效存储版本数据”“远程仓库与本地仓库的同步逻辑是什么”时,仅靠基础操作往往无从下手,背后的核心原理才是解决这些问题的关键.本篇将聚焦远程仓库的进阶协作(拉取、推送、复刻、协同开发流程).将坚持“原理+实操”结合的思路,真正发挥Git在版本控制、团队协作中的核心价值,为后续的高效开发、规模化协作筑牢基础.接下来,

By Ne0inhk

深度解析:Qwen3.5-9B如何用1/13的参数量在5大基准中超越GPT-oss-120B?混合架构、基准测试、开源许可全分析

一、前言:AI圈的"小模型奇迹" 1.1 2025-2026年最热门的AI话题 如果你问AI领域从业者,2025-2026年最热门的话题是什么? 答案很明确:"小模型超越大模型"的技术突破。 而其中最震撼的,莫过于阿里通义千问(Qwen)团队在2026年初发布的Qwen3.5-9B模型。 1.2 核心数据对比 模型参数量推理任务得分视觉推理得分Qwen3.5-9B9B(90亿)81.770.1gpt-oss-120B约120B(12000亿)80.159.7 核心事实: * Qwen3.5-9B的参数量只有gpt-oss-120B的1/13.5 * 但在推理任务上得分超越gpt-oss-120B(81.7 vs 80.1) * 在视觉推理任务上也超越(70.1 vs 59.

By Ne0inhk
GitHub热榜----上帝视角玩转未来!MiroFish:基于群体智能的万物预测引擎

GitHub热榜----上帝视角玩转未来!MiroFish:基于群体智能的万物预测引擎

摘要:你是否想过像《黑客帝国》或《西部世界》那样,构建一个平行的数字世界?或者在小说写到瓶颈时,让书中人物自己“活”过来推演结局?今天介绍的开源项目 MiroFish,正是一个基于**多智能体(Multi-Agent)**技术的通用群体智能引擎。它能通过你上传的“种子信息”,自动生成成千上万个具备独立人格和记忆的智能体,在数字沙盘中演化未来。 🚀 前言:当 AI 拥有了“社会属性” 在 ChatGPT 单打独斗的时代,我们问它:“如果发生X,会产生什么后果?”它只能基于训练数据给出概率性的回答。 但在 MiroFish 构建的多智能体系统 (MAS) 中,AI 不再是一个孤独的对话框。MiroFish 让无数个 AI 智能体组成一个社会,它们有记忆、有性格、有社交关系。当你在系统中投入一个变量(比如一条突发新闻),你会看到这些智能体如何反应、

By Ne0inhk
LLM - Claude-Mem 让 Claude Code 拥有长期记忆

LLM - Claude-Mem 让 Claude Code 拥有长期记忆

文章目录 * 一、概述 * 二、痛点:为什么 AI 编程助手必须要“长期记忆”? * 2.1 日常真实场景有多难受? * 2.2 问题本质:每次会话都是一张白纸 * 2.3 开发者已经开始“自救” * 三、Claude-Mem 是什么? * 四、整体架构:它怎么把“碎片操作”变成“长期记忆”? * 4.1 事件驱动:在不打扰你的情况下,捕获所有关键动作 * 4.2 本地混合存储:结构化 + 全文 + 语义 * 4.3 记忆压缩:别把“原始流水账”端给模型看 * 五、三层渐进式披露:Token 成本怎么砍到

By Ne0inhk