ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++函数模板实现数组排序输出:类型抽象与泛型编程入门

2026/9/29 8:31:05 拓冰建站 浏览量
C++函数模板实现数组排序输出:类型抽象与泛型编程入门 1. 题目背后的真实意图为什么数组排序要扯上函数模板不知道你有没有这种感觉明明就是一道“把数组排个序再输出”的练习题偏偏题目后面要加个括号写上“函数模板”四个字。很多同学第一反应是“哦就是用一个模板来写排序呗”然后照着书上抄一遍就完事了。但如果你真的只是抄一次、跑通、交作业那这道题九成的价值就浪费了。教材把“数组排序输出”和“函数模板”放在一起真正的考点根本不是排序本身——排序算法在C语言阶段早就练过了——而是让你在重复劳动里体会到“类型抽象”这件事到底解决了什么问题。我见过太多初学者在这个地方翻车。不是代码写不出来而是写完之后脑子里对函数模板的理解仍然停留在“一种奇怪的语法”层面。问一句如果把数组从int换成double你的排序函数还能用吗如果换成string呢换成结构体呢如果你平时的做法是“再复制一份代码把参数类型改掉”那这道题就是专门来治这个毛病的。函数模板的意义说得直白一点把“逻辑”和“类型”解耦。排序的核心逻辑是什么是比较大小、交换位置、循环遍历。这些逻辑不依赖于某个具体类型int能比大小double也能char也能甚至自定义的结构体只要定义了比较规则也能。既然逻辑本身和类型无关为什么每次换一个类型就要把同样的逻辑重写一遍函数模板就是帮你把“类型”从逻辑里抽出去写一份代码编译器帮你生成所有你需要的版本。回到这道题本身。题目要求“数组排序输出”常规流程会是先写一个排序函数再写一个输出函数然后在main里定义数组、调用函数。如果再加一个“函数模板”的要求本质上就是写一个templatetypename T void sort(T arr[], int len)或者更讲究一点的templatetypename T, int N void sort(T (arr)[N])再写一个输出函数模板把任意类型的数组内容打印出来。int、double、float、char、string……一份代码全部搞定。这就是函数模板在这个题目里的价值。所以别把它当成一个课外附加题它就是C泛型编程的第一块敲门砖。2. 函数模板的编译期机制先把原理啃下来再动手写这一节不讲虚的直接看语法但看完语法之后我要带你把编译器“心里想的事情”也过一遍。否则你写出来的模板只是“碰巧能跑”一旦换个场景你完全不知道模板会怎么展开、会推导成什么类型、为什么有时候必须写尖括号。2.1 一个最朴素的函数模板长什么样先看最基本的写法#include iostream using namespace std; templatetypename T T myMax(T a, T b) { return a b ? a : b; } int main() { cout myMax(3, 5) endl; // int cout myMax(3.14, 2.71) endl; // double cout myMax(a, b) endl; // char return 0; }templatetypename T是模板声明头T可以理解成一个“占位类型”——你在定义时不知道它具体是什么先用一个名字占住位置。当你调用myMax(3, 5)时编译器看到实参是int就自动把T替换成int生成一份int版本的函数。调用myMax(3.14, 2.71)又生成一份double版本。这个过程叫做模板实例化。注意模板本身不是函数它只是一张“图纸”。编译器根据调用处给出的实参类型去“照图施工”生成真正的函数代码。不调用就不生成。这就是为什么模板的定义通常要写在头文件里而不是单独的.cpp文件里——因为编译器在编译每个.cpp文件时需要看到完整的模板定义才能实例化。2.2 参数推导的两种形式上面这种myMax(3, 5)不写尖括号的调用方式叫模板实参推导编译器根据你传进来的参数自己猜T是什么。还有另一种方式叫做显式指定模板实参cout myMaxint(3, 5) endl;myMaxint告诉编译器别猜了T就是int。对于简单的题目推导就够用了。但如果模板参数不在函数参数列表里出现或者你想强制把double转成int来计算就需要显式指定。比如templatetypename T void printArray(T arr[], int len); // 注意T没有出现在参数类型推导里 // 这里有一个容易被忽略的问题等一下void printArray(T arr[], int len)——T确实出现在参数列表里啊因为arr的类型是T[]传一个int arr[]进来编译器就能推导出T是int。但有一种情况推导不出来如果模板参数完全不在参数列表中出现templatetypename T void foo() { // 函数体里用到T } // 调用时必须写成 fooint()因为没有任何参数能让编译器猜出T是什么这种在题目里一般用不到但你要知道。2.3 “数组”作为模板参数时编译器做了什么这是整个题目最核心也最容易踩坑的地方。你要明白一件事数组在作为函数参数传递时会自动退化成指针。这是C语言留下来的老规矩C为了兼容C也继承了这个行为。看这段代码templatetypename T void sort(T arr[], int len) { // 在函数体里arr 的实际类型是 T*而不是 T[N] }写T arr[]和写T* arr在这行代码里是完全等价的。也就是说你表面上写了一个数组参数实际上传进去的是一个指向T类型首元素的指针。数组的大小信息在这一步被丢掉了。所以在函数体里你不能用sizeof(arr) / sizeof(arr[0])来求长度——因为sizeof(arr)得到的是指针的大小而不是数组的大小。那模板能不能拿到数组的大小能但需要换一种写法templatetypename T, int N void sort(T (arr)[N]) { // 这里的 arr 是“数组的引用”保留了数组长度信息 // N 就是数组的长度编译器推导出来的 }T (arr)[N]这种语法读起来很别扭但它干了一件漂亮的事通过引用接收数组避免数组退化成指针。当你调用sort(myArr)时编译器看到myArr是一个长度为N的T类型数组就自动推导出T和N然后在函数体里arr仍然是真正的数组N就是它的长度。这个写法的好处是排序函数不需要额外传一个len参数也不会出现“传错长度”这种低级错误。坏处是你只能传真正的数组不能传指针也不能传动态分配的数组比如new int[10]的结果。所以做题时用哪种写法取决于你对这道题的场景判断。如果要做一个通用的、能接收任意长度数组的排序工具数组引用写法更优雅如果未来要兼容动态数组、指针场景T arr[], int len更务实。提示数组引用写法T (arr)[N]里的一定不能丢。写了T arr[N]作为参数编译器照样把它当成T*处理长度信息还是拿不到。这是模板语法里最容易写错的点。3. 完整实现与逐段讲解选择排序加输出函数模板原理理解了下面进入正题——把这道题的完整代码写出来并且每一段都拆开揉碎了讲清楚。我用两道工序来做这件事第一道工序是排序第二道工序是输出。两道工序都用函数模板封装main函数里只负责定义数组、调用函数。3.1 为什么选选择排序而不是冒泡排序这道题经典解析里教材大多用选择排序这绝不是随便选的。先回顾一下两种排序的核心逻辑冒泡排序相邻两个元素两两比较如果顺序不对就交换每一轮把一个最大值“冒”到末尾。最坏情况下每一轮都要多次交换。选择排序每一轮遍历未排序部分找到最小值的下标然后跟未排序部分的第一个元素做一次交换。从代码实现来看选择排序的交换次数远少于冒泡排序。对于一个长度为n的数组冒泡最坏情况要交换n*(n-1)/2次而选择排序每一轮最多交换1次总共最多n-1次。虽然两者的时间复杂度都是O(n²)但选择排序在“写操作”上更节省。更重要的是选择排序的模板化更自然。它的核心操作是“找最小值下标”然后交换数组中两个位置的值。这个过程只依赖于两种基本操作比较arr[j] arr[minIndex]交换swap(arr[i], arr[minIndex])。这两个操作对任何基本类型都是成立的。而冒泡排序虽然也依赖比较和交换但它每一轮都涉及“相邻交换”如果遇到严格相等的元素还可能涉及稳定性的处理模板化时需要额外考虑的东西更多。题目只要“排序”没提“稳定”那选择排序就是教学场景下最顺手的方案。3.2 完整代码实现#include iostream #include string using namespace std; // 交换两个元素 templatetypename T void mySwap(T a, T b) { T temp a; a b; b temp; } // 选择排序接收数组引用长度由编译器推导 templatetypename T, int N void selectionSort(T (arr)[N]) { for (int i 0; i N - 1; i) { int minIndex i; for (int j i 1; j N; j) { if (arr[j] arr[minIndex]) { minIndex j; } } if (minIndex ! i) { mySwap(arr[i], arr[minIndex]); } } } // 输出数组同样接收数组引用 templatetypename T, int N void printArray(T (arr)[N]) { for (int i 0; i N; i) { cout arr[i] ; } cout endl; } int main() { int intArr[] {34, 12, 5, 78, 23, 9}; selectionSort(intArr); printArray(intArr); double doubleArr[] {3.14, 1.28, 2.71, 0.96}; selectionSort(doubleArr); printArray(doubleArr); string strArr[] {banana, apple, pear, orange}; selectionSort(strArr); printArray(strArr); return 0; }这个代码里selectionSort和printArray都用了数组引用模板。让我逐个解释关键点。mySwap函数模板交换两个同类型变量的值。注意参数是T a——引用类型。如果不加交换的就是局部拷贝对原数组没有任何影响。selectionSort函数模板templatetypename T, int N声明了两个模板参数T是数组元素类型N是数组长度。这两个参数都是编译器从实参intArr里推导出来的——intArr是int[6]所以TintN6。函数体内部N直接可用不需要再传长度参数也不会出现长度算错的问题。printArray函数模板同样的逻辑遍历数组并输出。arr[i]对于int是整数输出对double是浮点数输出对string是直接输出字符串内容——因为operator已经为这些类型定义好了。编译指令随便你怎么编译g就直接来g -stdc11 -o demo demo.cpp ./demo输出结果5 9 12 23 34 78 0.96 1.28 2.71 3.14 apple banana orange pear注意观察第三个输出string数组按字母序排好了。这个问题我在后面第5节还会专门展开——字符串排序有个“坑”很多人在这个环节翻车。3.3 如果不用数组引用用传统写法怎么写我也把传统写法放出来方便对比两种方案的适用场景templatetypename T void selectionSort(T arr[], int len) { for (int i 0; i len - 1; i) { int minIndex i; for (int j i 1; j len; j) { if (arr[j] arr[minIndex]) { minIndex j; } } if (minIndex ! i) { T temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } } } templatetypename T void printArray(T arr[], int len) { for (int i 0; i len; i) { cout arr[i] ; } cout endl; } int main() { int intArr[] {34, 12, 5, 78, 23, 9}; int len sizeof(intArr) / sizeof(intArr[0]); selectionSort(intArr, len); printArray(intArr, len); // ... }注意sizeof(intArr) / sizeof(intArr[0])是在main函数里求的长度这里的intArr还是真数组所以能拿到正确长度6。一旦进入selectionSort函数体内arr就退化成指针了sizeof(arr)是864位系统上指针大小再除以sizeof(arr[0])就是2完全不对。这是很多初学者常犯的错。对比之下你会发现数组引用模板让函数“自带长度”这种写法在代码阅读和调用体验上确实更舒服。但也别觉得传统写法就一无是处——它有一个数组引用写法不具备的优势可以接收指针和动态数组。比如你想对new int[100]创建的堆数组排序数组引用模板就无能为力了因为堆数组的变量本质上是一个指针不是数组类型。而传统写法T arr[], int len可以完美兼容int *dynamicArr new int[5]{5, 3, 9, 1, 7}; selectionSort(dynamicArr, 5); // OK printArray(dynamicArr, 5); // OK delete[] dynamicArr;所以做这个题目的时候你应该想想老师出题的真正场景是什么。如果只是为了演示函数模板的“类型通用性”两种写法都行。如果是为了展示模板能“推导出数组长度”这种编译期能力数组引用写法更切题。我自己在批改类似作业时更喜欢看到同学用数组引用写法——因为这说明你对模板参数的理解不止停留在“把类型T替换一下”的层面。4. 从整数扩展到字符串与自定义类型类型边界在哪里函数模板最大的诱惑就是你写了一份代码仿佛能对所有类型通用。但“仿佛”这个词很关键——模板不是魔法。它能不能用取决于类型是否支持函数体里涉及的操作。4.1 基本数据类型全部通吃int、double、float、char、short、long……这些类型都原生支持比较运算符也支持cout 输出。所以对它们来说上面的selectionSort和printArray是零成本复用的char charArr[] {c, a, d, b}; selectionSort(charArr); printArray(charArr); // 输出a b c d这一段跑起来毫无悬念。但如果你因此以为“模板可以处理一切”后面就有你哭的时候。4.2 字符串类型看起来能用实际上有个陷阱用C标准库的std::string数组上面代码是可以直接跑的因为std::string不仅重载了运算符还重载了运算符。这正是C风格字符串和C风格字符串的本质区别。但如果你的数组是C风格字符串数组也就是const char*类型的数组情况就完全不一样了const char* strArr[] {banana, apple, pear}; selectionSort(strArr, 3); // 跑起来大概也能出结果但是错的为什么因为const char*是一个指针。arr[j] arr[minIndex]比较的并不是字符串内容的字典序而是指针变量的数值大小——也就是字符串在内存中的地址。不同字符串分配在哪个地址取决于编译器、运行环境、字符串字面量的存储方式排序结果完全随机毫无实际意义。正确的做法有两种。第一种用std::string而不是const char*string strArr[] {banana, apple, pear, orange}; selectionSort(strArr); // 正确因为 string 重载了 第二种仍然用const char*但给排序函数单独写一个特化版本用strcmp比较#include cstring template void selectionSortconst char*, 3(const char* (arr)[3]) { for (int i 0; i 2; i) { int minIndex i; for (int j i 1; j 3; j) { if (strcmp(arr[j], arr[minIndex]) 0) { minIndex j; } } if (minIndex ! i) { const char* temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } } }这种写法叫做模板特化。你不用管函数模板对这个类型本来会生成什么代码直接针对const char*[3]这个具体类型写一个专用版本编译器看到调用处类型匹配时会优先选用这个专用版本。当然生产代码更推荐的做法是写一个比较器函数作为参数传入这在STL的std::sort里很常见但那已经超出这道题的范畴了。4.3 自定义结构体必须告诉编译器“怎么比大小”如果数组元素是你自己定义的结构体比如struct Student { string name; int score; };直接拿selectionSort去排Student数组编译器会报错——因为它不知道arr[j] arr[minIndex]中的该怎么作用在Student类型上。C里没有魔法可以自动把“学生”按分数或者按姓名排好序你必须告诉它比较规则。方案一给Student重载运算符。struct Student { string name; int score; bool operator(const Student other) const { return score other.score; // 按分数升序 } };重载之后selectionSort就能正常工作了。编译器遇到arr[j] arr[minIndex]时看到Student类型已经定义了operator它就会去调用这个函数。方案二不用模板硬排而是用STL的std::sort加自定义比较函数。这已经超出了本题的“函数模板”主题不再展开后面第6节我会做一次对比分析。这些案例说明了函数模板的一个重要边界模板的通用性建立在“类型支持所需操作”的前提上。类型越具体你越要主动提供配套的操作运算符重载、特化版本、函数参数等才能让模板真正可用。5. 实测中最容易栽的坑数组退化、模板推导失败与报错信息这一节说点真正实操层面的东西。我见过太多人在这个题目上卡住不是不懂排序而是被模板的语法细节和编译器报错绕晕了。下面这些问题全是实战里反复出现的。5.1 三个版本的数组参数有何不同先做一个对比表格一张表说清楚参数写法实际收到的类型能否获得数组长度适用场景T arr[]T*指针不能需额外传len通用兼容指针和动态数组T arr[N]T*指针N被忽略不能和上面等价迷惑性强T (arr)[N]T数组的引用能N为数组长度只能接收真正的数组安全性高关键在于第二行。很多同学听老师讲了“数组作为参数会退化成指针”知道T arr[]其实是指针过两天又看到T arr[N]这种写法觉得带上了长度应该就是数组了吧不是的。在函数参数列表中T arr[N]里的N只是一个“装饰”编译器直接忽略它把它当成T*处理。你写T arr[100]传一个长度为50的数组进来编译器不会报错——因为长度根本没参与类型检查。这就是为什么不少人在传数组时经常“越界操作”却浑然不觉。5.2 模板推导失败的经典报错场景看这段调用int a 10; int *p a; selectionSort(p, 1); // 报错无法推导T (arr)[N]中的NselectionSort(T (arr)[N])要求参数是“一个数组的引用”而你传过去的是int*——指针不是数组编译器推导不出N直接报错。再比如这个templatetypename T T myMax(T a, T b) { return a b ? a : b; } cout myMax(3, 5.5) endl; // 报错T被推导成int和double冲突T要么是int要么是double编译器不知道你打算按哪个类型来算。解决方法是显式指定myMaxdouble(3, 5.5)这样3会被隐式转成3.0两个参数都是double。这就是为什么我在前面说“显式指定模板实参”在混合类型场景下很关键。5.3 编译器的“天书”报错怎么读模板报错一向以“长”和“晦涩”著称。比如上面selectionSort(p, 1)的报错编译器会输出一大段error: no matching function for call to selectionSort(int*, int) note: candidate is templateclass T, int N void selectionSort(T ()[N]) note: mismatched types T [N] and int*很多人看到templateclass T, int N和mismatched types就开始慌其实这两行信息量很足。第一行告诉你你调用时传入的参数类型是int*指针的引用。第二行告诉你候选模板期望第二个参数是T[N]类型。最后一行明确点出T[N]数组和int*指针不匹配。翻译成人话就是你给我一个指针但我要求一个数组引用不行。遇到模板报错不要从头一看到尾先找error:关键字再看candidate行最后看note:里的类型匹配信息。这种阅读能力比记住模板语法更值钱。5.4 交换函数写成mySwap(arr[i], arr[minIndex])时别漏了引用我在批改学生作业时经常看到有人把交换逻辑写在排序函数里但交换函数却定义错了templatetypename T void wrongSwap(T a, T b) { // 传值不改原变量 T temp a; a b; b temp; }然后排序函数里调用wrongSwap(arr[i], arr[minIndex])运行结果数组一点没变。原因很简单wrongSwap收到的只是数组元素的一个拷贝交换的是拷贝不是原数组里的变量。记住交换函数要生效参数必须是T a, T b。这个坑之所以隐蔽是因为编译器不会报任何错代码能编译、能运行但输出结果和预期完全不同排出来的数组跟没排一样。排查时一定要先确认交换函数有没有用引用传递。6. 题目的延伸从函数模板到泛型编程的完整拼图一道“6-2 数组排序输出函数模板”练完之后如果只是背下来代码那性价比太低了。这道题应该成为你理解C泛型机制的地基。下面几条延伸方向是我认为从这道题出发最值得花时间琢磨的。6.1 函数模板与std::sort的定位差异你可能会问既然C标准库已经有std::sort我还写这个排序函数模板干什么这个问题的答案其实是这道练习题和真实工程代码之间的桥梁。std::sort本身就是一个函数模板但它比我们在题目里写的selectionSort通用得多。它接受两个迭代器可以排数组、排vector、排list的部分区间还可以接受自定义比较器#include algorithm #include vector int arr[] {34, 12, 5, 78, 23, 9}; sort(begin(arr), end(arr)); // 默认升序 vectorint vec {5, 2, 8, 1}; sort(vec.begin(), vec.end()); // 排vector也没问题 Student students[] {{Tom, 88}, {Jerry, 95}, {Bob, 72}}; sort(begin(students), end(students), [](const Student a, const Student b) { return a.score b.score; // 按分数降序 });看清楚了吗在真实C项目里几乎不会有人自己写选择排序模板去排数组。标准库的std::sort实现的是内省排序时间复杂度平均O(n log n)远远快于选择排序的O(n²)。那为什么课程还要让你练函数模板因为std::sort本身就是一个函数模板你对模板的理解越深就越能理解标准库的设计哲学。说到底selectionSort这个手写模板展示的是“怎么写一个模板”std::sort展示的是“模板能强大到什么程度”。两者是阶段性的关系不是替代关系。在初学阶段把前者写透比直接背后者API列表更有意义。6.2 动态数组和二维数组怎么办如果数据结构是动态数组new int[n]或者二维数组模板怎么处理这是不少学习者在延伸时遇到的问题。动态数组的本质是int*没有数组长度信息所以数组引用模板用不了。这时你需要一个接受“指针长度”的排序函数模板templatetypename T void sortPtr(T *arr, int len) { // 同样实现选择排序arr 就是指针len 是长度 } int size 10; int *dynamicArr new int[size]; // 填充数据... sortPtr(dynamicArr, size); delete[] dynamicArr;二维数组则有两个层面一是把每一行都排个序二是按某列的整体排序。前者相当于对每一行调用一次一维排序模板int matrix[3][4] {{4, 3, 2, 1}, {8, 7, 6, 5}, {12, 11, 10, 9}}; // 对每行排序 for (int i 0; i 3; i) { sortPtr(matrix[i], 4); // matrix[i] 可以隐式转换成 int* }后者则更复杂一般需要自定义排序策略或者把二维数组转换成结构体数组再排序。这在“按列排序”这类需求里最常见比如Excel表格按某一列排序也是新手从一维数组走向真实数据处理的一个自然跳板。6.3 函数模板、类模板和STL容器函数模板只是泛型编程的入口。往上走一步还有类模板、变量模板、模板特化、模板偏特化、变参模板……这些概念在STL容器里大量使用。比如vectorT就是一个类模板它也有排序需求于是std::sort这个函数模板就需要能跟vectorT::iterator兼容。当你把函数模板和类模板放在一起看时C泛型编程的全貌就开始浮现了函数模板抽象的是“算法”类模板抽象的是“数据结构”。排序是一个算法它不应该关心数据到底是什么容器——数组也好vector也好list也好——只要能遍历、能比较就能排。这个“算法与数据结构解耦”的思想正是C标准库的核心设计哲学。所以我一直建议大家做完这道数组排序题之后给自己加一个进阶练习把selectionSort改成接受“两个指针/迭代器”的版本让它可以同时处理数组和vector。这比单纯刷十道排序题收获大得多。最后分享一点个人体会在学习和教别人写这道题的过程中我反复感受到一个现象很多人会写模板但写出来的模板很“脆”——换个类型就崩或者代码里到处是类型转换和临时变量。根本原因是对“模板生成代码”这个过程没有直观体感。我的建议是把编译展开的步骤在纸上手动推演几遍。就拿int intArr[] {34, 12, 5, 78, 23, 9}; selectionSort(intArr);来说你尝试把这一段代码里所有的T都替换成int把N替换成6看看运行逻辑是否和你手写一个int版排序完全一致。等你推演完两三遍对“模板是图纸调用是施工”这句话的理解就会上一个台阶写出来的模板也会稳得多。