参考文章:

C++ 模板保姆级详解——template<class T>

深入理解C++中的泛型编程、函数模板与类模板

菜鸟教程 C++ 模板

C++模板的特化详解(函数模版特殊,类模版特化)

Autolabor-ROS基础教程

C++之STL(标准模板库)介绍

什么是泛型编程

将泛型编程之前,我们来看一个简单的需求:有 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 尝试编译上述代码会报如下错误:

img

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;
}

编译运行上面的代码,能看到结果如下:

img

编译器在调用时,有现成的就调用现成的,没有就套用模板

但如果使用显式实例化,则会强制调用模板函数

int main() {
    int a = Add(1, 2);
    int b = Add<int>(1, 2);
    return 0;
}

img

重载与特化

函数模板重载

模板函数可以像普通函数一样被重载,你可以为不同数量或类型的参数提供不同的模板版本

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,intchar,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 节点通信中十分常用的模式之一,其工作原理基于下图的 发布订阅模型

img

publisher 节点通过主题来发布消息(message),而我们想要发送的消息的类型不是一成不变的,ROS 在这里使用了泛型编程来对发布逻辑进行泛化

这里是 Publisher::publish API 的部分源码,ros 通过将消息类型抽象为 <typename M> 来实现一个 publish 函数发布多种多样类型的消息

img

使用 NodeHandle::advertise API 来创建订阅者的时候也用到泛型,目的和前面一样,将消息类型抽象出来以泛化发布者的逻辑

img

创建 Subscriber 的 API 也是同理

img

在使用这些 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 为我们生成自定义消息的中间文件

img

结合前面的知识,我们能很容易写出支持传递 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 通信模式,其工作原理基于下面的 请求响应模型

img

客户端请求可能发送的数据类型是不固定的,同时服务端可能返回的数据类型也不固定,ROS 对该通信模式的 API 也进行了泛化处理

例如创建服务端的 API,这里用 MReq 来泛化了响应的类型,用 MRes 泛化了请求的类型

img

而创建客户端的 API 则直接用 Service 泛化了整个服务类型(包括了请求和相应)

img

下面是一个简单的请求响应模式例程,客户端提交两个整数至服务端,服务端求和并响应结果到客户端

首先定义请求和响应的数据;创建 srv 文件夹,然后创建 Addints.srv 文件

# 客户端请求时发送的数据
int32 num1
int32 num2
---
# 服务段响应时发送的数据
int32 sum

然后编辑 package.xml 和 CMakeLists.txt 等配置文件(篇幅原因这里不展开),让 ROS 为我们生成自定义消息的中间文件

img

服务端代码

#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 的客户端

正是泛型编程的灵活性,使得我们可以灵活定义各种请求和相应的格式