参考文章:
C++ 模板保姆级详解——template<class T>
什么是泛型编程
将泛型编程之前,我们来看一个简单的需求:有 a, b 两个 未知类型 的变量,我想要实现一个 Swap
函数执行对 a、b 的交换操作
我们当然可以针对每一种类型的变量编写一个 Swap
函数,就像这样:
void Swap(int &left, int &right) {
int temp = left;
left = right;
right = temp;
}
void Swap(double &left, double &right) {
double temp = left;
left = right;
right = temp;
}
void Swap(char &left, char &right) {
char temp = left;
left = right;
right = temp;
}
上面的例子只考虑了 a,b 为 int/double/char 的情况,如果涉及的数据类型更多呢?我们要再写几个 Swap
函数吗?
有时候我们想要实现一些拥有某功能的函数,这些功能与传入参数的数据类型无关,为了增加代码的复用性,出现了泛型编程的概念
泛型编程是一种通过编写与具体数据类型无关的代码,以实现代码重用的编程范式
在C++中,泛型编程的核心思想是通过模板(template)实现类型参数化,使得函数或类能够适用于多种数据类型。
Cpp 函数模板基本用法
函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本
通过函数模板,可以编写一种通用的函数定义,使其能够适用于多种数据类型,从而提高代码的复用性和灵活性。
模板函数定义的一般形式如下所示:
template <typename type1, typename type2, ...>
return_type func_name(parameter list)
{
// 函数的主体
}
其中 type1、type2… 是函数所使用的数据类型的占位符名称。该名称可以在函数定义中使用
上面的 Swap
函数使用模板编程可以改写成这样:
template <typename T>
void Swap(T &a, T &b) {
T temp = a;
a = b;
b = temp;
}
在使用的时候我们就能够对函数模板进行 实例化 调用
而实例化的方式有两种:1、隐式实例化 2、显式实例化
隐式实例化
让编译器根据实参推演模板参数的实际类型
继续以我们实现的 Swap
函数为例,我们可以像使用正常函数一样调用
#include <iostream>
using namespace std;
template <typename T>
void Swap(T &a, T &b) {
T temp = a;
a = b;
b = temp;
}
int main() {
int i = 1, j = 2;
Swap(i, j);
cout << i << j << endl;
double n = 1.1, m = 2.2;
Swap(n, m);
cout << n << m << endl;
return 0;
}
在调用
Swap(i, j)
时,由于 i、j 的类型为 int,所以编译器会自动生成一个对应 int 类型的Swap(int &a, int &b)
供我们调用在调用
Swap(n, m)
时,由于 n、m 的类型为 double,所以编译器会自动生成一个对应 double 类型的Swap(double &a, double &b)
供我们调用
在传入参数的类型不匹配时,编译器会报错,例如:
#include <iostream>
using namespace std;
template <typename T>
T Add(T a, T b) {
return a + b;
}
int main() {
int i = 1;
double n = 1.1;
cout << Add(i, n) << endl;
return 0;
}
使用 MinGW 尝试编译上述代码会报如下错误:
vscode C++ 插件也会有如下提示:
我们可以使用类型转换来避免上述报错
int main() {
int i = 1;
double n = 1.1;
cout << Add(i, (int)n) << endl; // 2
cout << Add((double)i, n) << endl;// 2.1
return 0;
}
显式实例化
显式地指定模板使用的参数类型,在调用时使用 func_name<type1, type2, ...>
的形式来指定类型占位符的实际类型
对上面实现的 Add
函数进行显式实例化的写法如下
int main() {
int i = 1;
double n = 1.1;
// 这里由于指定了 T 的类型为 int, 所以 n 会转换为 int
cout << Add<int>(i, n) << endl; // 2
cout << Add<double>(i, n) << endl;// 2.1
return 0;
}
当模板函数具有多个模板参数时,显式实例化的例子:
#include <iostream>
using namespace std;
template <typename T1, typename T2>
void Print(T1 &a, T2 &b) {
cout << a << ',' << b << endl;
}
int main() {
Print<int, char>(1, '2');
Print<int, double>(1, 1.1);
return 0;
}
普通函数与函数模板的调用优先级
一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数
我们可以实现一个专门处理 int 的 Add 函数,同时也能用模板定义通用的 Add 函数
#include <iostream>
using namespace std;
int Add(int left, int right) {
cout << "Add(" << left << ", " << right << ") (not template)" << endl;
return left + right;
}
template <typename T>
T Add(T left, T right) {
cout << "Add(" << left << ", " << right << ") (template)" << endl;
return left + right;
}
int main() {
// 会调用哪个 Add 呢?
int a = Add(1, 2);
float b = Add(1.1, 2.2);
return 0;
}
编译运行上面的代码,能看到结果如下:
编译器在调用时,有现成的就调用现成的,没有就套用模板
但如果使用显式实例化,则会强制调用模板函数
int main() {
int a = Add(1, 2);
int b = Add<int>(1, 2);
return 0;
}
重载与特化
函数模板重载
模板函数可以像普通函数一样被重载,你可以为不同数量或类型的参数提供不同的模板版本
template <typename T>
int Compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
// 参数个数不一样,构成重载
template <typename T>
int Compare(const T &v1, const T &v2, int n) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
// 参数类型不一样,构成重载
template <typename T, typename T1>
int Compare(const T &v1, const T1 &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
// 参数顺序不一样,构成重载
template <typename T, typename T1>
int Compare(const T1 &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
函数模板特化
有时你可能需要为某个特定类型提供一个模板的专门版本,这就是模板特化
我们编写目标的目的就是希望创造一个“通用的”函数,但实际情况是,总有些类型不能很好被模板函数所支持,例如
#include <iostream>
#include <string>
using namespace std;
class Student {
public:
Student(int id, string name)
:id(id)
,name(name)
{}
~Student();
private:
int id;
string name;
};
template <typename T>
int Compare(const T &v1, const T &v2) {
if (v1 < v2) return -1;
if (v2 < v1) return 1;
return 0;
}
int main() {
mike = Student(0, "Mike");
john = Student(1, "John");
cout << Compare(mike, john) << endl;
return 0;
}
由于 Student
类没有重载 <
运算符,我们的 Compare 函数失效了
此时我们就可以针对 Student
类型进行模板特化
函数模板特化的步骤:
1、函数原型前加 template <>
2、函数名后加 <Type>
,Type 为要特化的类型(这一步可以省略)
template <>
int Compare<Student>(Student &v1, Student &v2) {
if (v1.id < v2.id) return -1;
if (v2.id < v1.id) return 1;
return 0;
}
这时候再调用 Compare(mike, john)
就能根据 id 值来进行 Student 类的比较了
但是,为什么我不能直接写一个普通函数呢,就像这样
int Compare(Student &v1, Student &v2) {
if (v1.id < v2.id) return -1;
if (v2.id < v1.id) return 1;
return 0;
}
在这个例子中,两种实现方式的结果是一样的,对于单个函数的特化,直接定义一个普通函数看起来更简洁。这个问题我没有在互联网上找到权威的答案,只能贴出 GPT 的参考回答
模板特化在某些情况下提供了一些普通函数无法实现的优势:
1、与模板重载
模板特化:可以与模板的其他实例一起参与重载决策(注:这里存疑)编译器会根据调用的上下文选择最合适的模板实例或特化版本
普通函数:只能参与普通函数的重载,不能与模板函数一起考虑
2、模板元编程
模板特化:在模板元编程中,特化可以用于控制编译时的行为,比如在编译时根据类型特性选择不同的模板实例
普通函数:无法参与模板元编程
3、接口一致性
模板特化:即使提供了特化,也可以保持接口的一致性,因为所有特化和非特化版本都有相同的函数签名
普通函数:如果函数签名不同,则无法直接替换模板函数
在《C++ Templates》的附录 B 部分对函数重载解析的优先级介绍(假设有普通函数,模版函数,模版函数特化等等复杂情况)
step1. 普通函数,如果类型匹配,优先选中,重载解析结束;
step2. 如果没有普通函数匹配,那么所有的基础函数模版进入候选,编译器开始平等挑选,类型最匹配的,则被选中;
step3. 如果第二步里面选中了一个模版基础函数,则查找这个模版基础函数是否有特化版本,如果有且类型匹配,则选中特化版,否则使用 step2 里面选中的模版函数,重载解析结束;
step4. 如果第二步里面没有选中任何函数基础模版,那么匹配失败,编译器会报错;
外网有一些文章指出了什么场景下需要模板函数特化、什么场景下不宜使用模板函数特化
文章指路:Why Not Specialize Function Templates?
Cpp 类模板基本用法
和函数一样,有时候类也会面临着要重复造轮子的窘境,例如这里有一个 Stack
类,实现了一个支持 int 类型的栈结构
class Stack {
public:
Stack(int capacity = 3)
:_array(new int[capacity])
,_capacity(capacity)
,_size(0)
{}
void push(int data) {
// Check if the stack full
_array[_size] = data;
_size++;
}
int pop() {
// Check if the stack empty
_size--;
return _array[_size];
}
~Stack() {
delete[]_array;
_array = nullptr;
_size = _capacity = 0;
}
private:
int* _array;
int _capacity;
int _size;
};
那如果我要实例化一个栈用来存放 double 型数据呢?还要再创建一个存放 char 型数据的 Stack 呢?难道我们要为每个类型的数据都写一个专门的 Stack 类吗?
与函数模板类似,Cpp 使用类模板来解决这一问题
一个使用类模板实现的 Stack
如下:
template <typename DataType>
class Stack {
public:
Stack(int capacity = 3)
:_array(new DataType[capacity])
,_capacity(capacity)
,_size(0)
{}
void push(DataType data) {
// Check if the stack full
_array[_size] = data;
_size++;
}
DataType pop() {
// Check if the stack empty
_size--;
return _array[_size];
}
~Stack() {
delete[]_array;
_array = nullptr;
_size = _capacity = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
此时我们想要实例化一个存放 char 类型的 Stack,只需要使用 Stack<char>
来指定 DataType 为 char,其他类型也同理
int main() {
Stack<int> IntStack;
Stack<double> DoubleStask;
Stack<char> CharStack;
return 0;
}
类模板的分离式实现
类模板也是能够使用分离式实现的,只不过编译器由于历史原因,对于类模板的分离式编译的支持比较弱
所以还是建议类模板的声明与实现最好放在同一个头文件中,当然了,如果一定要使用分离式实现也是可以的
首先我们创建 Stack.h 进行类模板的声明
#include <iostream>
using namespace std;
template <typename DataType>
class Stack {
public:
Stack(int capacity = 3);
void push(DataType data);
DataType pop();
~Stack();
private:
DataType* _array;
int _capacity;
int _size;
};
然后创建 Stack.cpp 进行类模板的实现
#include "Stack.h"
template <typename DataType>
Stack<DataType>::Stack(int capacity)
:_array(new DataType[capacity])
,_capacity(capacity)
,_size(0)
{}
template<typename DataType>
void Stack<DataType>::push(DataType data) {
_array[_size] = data;
_size++;
}
template<typename DataType>
DataType Stack<DataType>::pop() {
_size--;
return _array[_size];
}
template<typename DataType>
Stack<DataType>::~Stack() {
delete[]_array;
_array = nullptr;
_capacity = _size = 0;
}
然后就能像下面这样调用类模板(视编译器对模板分离式实现的支持程度,支持度低的可能会报链接编译错误
#include "Stack.h"
int main() {
Stack<int> IntStack;
Stack<double> DoubleStask;
Stack<char> CharStack;
return 0;
}
全特化与偏特化
类模板全特化
全特化即将模板类型里的所有类型参数全部具体指明之后处理,例如
#include <iostream>
using namespace std;
template <typename T1, typename T2>
class A {
public:
A() {
cout << "模板函数 A" << endl;
}
};
template <>
class A<int, int> {
public:
A() {
cout << "<int, int> 全特化" << endl;
}
};
template <>
class A<char, char> {
public:
A() {
cout << "<char, char> 全特化" << endl;
}
};
int main() {
A<int, int> a1; // 使用 <int, int> 特化版本
A<char, char> a2; // 使用 <char, char> 特化版本
A<int, char> a3; // 使用未特化的模板
return 0;
}
template <>
尖括号里面为空,代表所有类型都在下面特殊化处理,上面相当于对 int,int
和 char,char
两种类型进行了分别的处理,其他类型依然是未特化的版本(泛化版本)
除了对整个类进行全特化,我们还可以只特化模板类中的某个成员函数,例如
#include <iostream>
using namespace std;
template <typename T1, typename T2>
class A {
public:
A() {
cout << "模板函数 A" << endl;
}
void func() {
cout << "模板函数 A::func" << endl;
}
};
template <>
void A<int, double>::func() {
cout << "<int, double> 全特化::func" << endl;
}
int main()
{
A<int,double> a; // 调用泛化版本的构造函数
a.func(); // 调用 <int, double> 特化版本 func 函数
return 0;
}
类模板偏特化
偏特化只特殊化部分参数或者只特殊化一定的参数范围,下面是最简单的模板类偏特化例子
#include <iostream>
using namespace std;
template <typename T1, typename T2>
class A {
public:
A() {
cout << "模板函数 A" << endl;
}
};
template <typename T1>
class A<T1, int> {
public:
A() {
cout << "<T1, int> 偏特化" << endl;
}
};
template <typename T2>
class<char, T2> {
public:
A() {
cout << "<char, T2> 偏特化" << endl;
}
}
int main() {
A<double, double> a1; // 使用泛化版本
A<double, int> a2; // 使用 <T1, int> 偏特化版本
A<char, char> a3; // 使用 <char, char> 偏特化版本
return 0;
}
有些比较复杂的偏特化会修改参数范围,例如将
扩展:函数模板是没有偏特化的,因为使用函数重载的方式即可实现与偏特化相同的效果
类模板的继承
父类是模板
子类是模板
父类和子类均为模板
STL 标准模板库
STL 是 C++ 标准库的一部分,实现了大量的数据结构和算法,并且大量使用了模板技术。下面从几个比较经典的数据结构来看看 STL 标准库是怎么应用泛型编程的
向量容器 vector
双端队列 deque
列表 list
哈希表 map
ROSCPP 中使用的泛型编程
ros 是十分流行的机器人操作系统,支持使用多种编程语言开发。roscpp 的 api 中多次出现泛型编程,接下来通过几个简单的例子来展示泛型编程在机器人开发中的应用
发布订阅模式 API
发布订阅模式是 ROS 节点通信中十分常用的模式之一,其工作原理基于下图的 发布订阅模型
publisher 节点通过主题来发布消息(message),而我们想要发送的消息的类型不是一成不变的,ROS 在这里使用了泛型编程来对发布逻辑进行泛化
这里是 Publisher::publish
API 的部分源码,ros 通过将消息类型抽象为 <typename M>
来实现一个 publish 函数发布多种多样类型的消息
使用 NodeHandle::advertise
API 来创建订阅者的时候也用到泛型,目的和前面一样,将消息类型抽象出来以泛化发布者的逻辑
创建 Subscriber 的 API 也是同理
在使用这些 API 的时候遵从模板编程的基本方法即可
举个例子,下面是一个简单的 ros 发布订阅模式例程
发布者代码
#include "ros/ros.h"
#include "std_msgs/String.h"
using namespace std;
string ros_node = "publisher";
string topic = "test";
int main(int argc, char *argv[]) {
setlocale(LC_ALL, "");
ros::init(argc, argv, ros_node);
ros::NodeHandle nh;
// 使用函数模板显示实例化, 创建了一个消息类型为 std_msgs::String 的发布者
ros::Publisher pub = nh.advertise<std_msgs::String>(topic, 100);
std_msgs::String msg;
msg.data = "Hello World";
ros::Rate r(2);
while (ros::ok()) {
pub.publish(msg);
r.sleep();
ros::spinOnce();
}
return 0;
}
上面的代码使用函数模板显示实例化, 创建了一个消息类型为 std_msgs::String
的发布者
订阅者代码
#include "ros/ros.h"
#include "std_msgs/String.h"
using namespace std;
string ros_node = "subscriber";
string topic = "test";
void callback(const std_msgs::String::ConstPtr &msg_ptr) {
ROS_INFO("Message received: %s", msg_p->data.c_str());
}
int main(int argc, char *argv[]) {
setlocale(LC_ALL, "");
ros::init(argc, argv, ros_node);
ros::NodeHandle nh;
// 使用函数模板显示实例化, 创建了一个消息类型为 std_msgs::String 的订阅者
ros::Subscriber sub = nh.subscribe<std_msgs::String>(topic, 10, callback);
while (ros::ok()) {
ros::spinOnce();
}
return 0;
}
上面的代码使用函数模板显示实例化, 创建了一个消息类型为 std_msgs::String
的订阅者
该例程中使用的 std_msgs
是官方自带的消息包,我们使用了其中的 String
消息类型
由于泛型编程所带来的灵活性,该 API 还能够支持自定义消息类型
在功能包下新建 msg 目录,创建文件 Person.msg
string name
uint16 age
float64 height
然后编辑 package.xml 和 CMakeLists.txt 等配置文件(篇幅原因这里不展开),让 ROS 为我们生成自定义消息的中间文件
结合前面的知识,我们能很容易写出支持传递 Person 类型消息的代码
创建发布者
#include "msg_test/Person.h"
ros::Publisher pub = nh.advertise<msg_test::Person>(topic, 100);
创建订阅者
#include "msg_test/Person.h"
ros::Subscriber sub = nh.subscribe<msg_test::Person>(topic, 100, callback);
请求响应模式 API
请求响应模式也是非常常用的 ROS 通信模式,其工作原理基于下面的 请求响应模型
客户端请求可能发送的数据类型是不固定的,同时服务端可能返回的数据类型也不固定,ROS 对该通信模式的 API 也进行了泛化处理
例如创建服务端的 API,这里用 MReq
来泛化了响应的类型,用 MRes
泛化了请求的类型
而创建客户端的 API 则直接用 Service
泛化了整个服务类型(包括了请求和相应)
下面是一个简单的请求响应模式例程,客户端提交两个整数至服务端,服务端求和并响应结果到客户端
首先定义请求和响应的数据;创建 srv 文件夹,然后创建 Addints.srv 文件
# 客户端请求时发送的数据
int32 num1
int32 num2
---
# 服务段响应时发送的数据
int32 sum
然后编辑 package.xml 和 CMakeLists.txt 等配置文件(篇幅原因这里不展开),让 ROS 为我们生成自定义消息的中间文件
服务端代码
#include "ros/ros.h"
#include "srv_test/AddInts.h"
using namespace std;
string ros_node = "server";
bool request(srv_test::AddInts::Request &req, srv_test::AddInts::Response &resp) {
int num1 = req.num1;
int num2 = req.num2;
resp.sum = num1 + num2;
return true;
}
int main(int argc, char *argv[]) {
setlocale(LC_ALL, "");
ros::init(argc, argv, ros_node);
ros::NodeHandle nh;
// 创建了一个消息类型为 srv_test::AddInts 的客户端
// 这里没有出现尖括号, 是因为这里使用了函数模板的隐式实例化
// advertiseService 会根据 request 函数的参数类型进行模板匹配
ros::ServiceServer server = nh.advertiseService("AddInts", request);
ros::spin();
return 0;
}
这里创建了一个消息类型为 srv_test::AddInts
的客户端,没有出现尖括号, 是因为使用了函数模板的隐式实例化,advertiseService
会根据入参 request 的参数类型进行模板匹配
客户端代码
#include "ros/ros.h"
#include "srv_test/AddInts.h"
using namespace std;
string ros_node = "client_test";
string ros_server = "AddInts";
int main(int argc, char *argv[]) {
setlocale(LC_ALL, "");
if (argc != 3) {
ROS_ERROR("Please submit two parameters");
return 1;
}
ros::init(argc, argv, ros_node);
ros::NodeHandle nh;
// 使用函数模板显示实例化, 创建了一个消息类型为 srv_test::AddInts 的客户端
ros::ServiceClient client = nh.serviceClient<srv_test::AddInts>(ros_server);
ros::service::waitForService("AddInts");
srv_test::AddInts addints;
addints.request.num1 = atoi(argv[1]);
addints.request.num2 = atoi(argv[2]);
bool flag = client.call(addints);
if (flag) {
ROS_INFO("%s>> %d", ros_server.c_str(), addints.response.sum);
}
else {
ROS_ERROR("请求失败");
return 1;
}
return 0;
}
上面的代码使用函数模板显示实例化, 创建了一个消息类型为 srv_test::AddInts
的客户端
正是泛型编程的灵活性,使得我们可以灵活定义各种请求和相应的格式