C++学习-入门到精通【17】自定义的模板化数据结构
目录
一、自引用类
一个自引用类包含一个指向与它同类的对象的指针成员。例如:
cpp
class Node
{
public:
explicit Node(int); // 声明为explicit,保证该构造函数不会被隐式的转换成转换构造函数
void setData(int);
int getData() const;
void setNextNode(Node*);
Node* getNextNode() const;
private:
int data;
Node* nextPtr;
};
这个类包含两个数据成员,一个int类型的成员data和一个指向与所声明的类同类型对象的指针。这个指针成员可以指向另一个Node类型的对象,因此称Node为自引用类
。nextPtr
成员被称为链接
。
自引用类的对象可以链接在一起组成一些有用的数据结构,如链表、队列、堆栈和树等。
二、链表
链表就是一个自引用类对象的线性集合,其中的对象被称为节点(node),它们通过指针链连接起来,因此被称为链表。链表只能通过指向链表第一个元素的指针进行访问,要访问后续节点,必须从第一个元素开始,通过它的指针成员来找到下一个元素,因为链表在内存中的分布是不连续的。所以链表末尾节点的指针域通常被置为空,以此来表明链表的结束。
链表很适合存储那些一开始难以预计数据项有多少的数据,因为链表是动态的,它的长度可以根据需要动态的增加或减少,如一条链表要增加一个新的节点,只需创建一个新的节点,并将当前链表的末尾节点的指针成员指向该新节点即可。
除此之外,由于链表的特性,它在内部插入新节点较数组而言是方便的,它只需改变其中一些节点的指针域的值即可完成插入操作,而数组则需要移动一些元素才可以实现。关于链表的更多具体内容,请在"数据结构"中学习。
链表的实现
下面实现的链表提供4种操作:
- 在链表开始处插入一个元素;
- 在链表尾部插入一个元素;
- 在链表开始处删除一个元素;
- 在链表尾部删除一个元素;
ListNode.h,定义链表的一个节点
cpp
#pragma once
// List类模板的声明,虽然并未定义,
// 但是告诉编译器存在该类型
template<class NODETYPE> class List;
// 创建一个类模板来描述一个链表的一个节点
template<class NODETYPE>
class ListNode
{
// 声明类模板特化 List<NODETYPE> 是类模板特化 ListNode<NODETYPE> 的友元类
// 该类中的所有成员函数皆可访问本类中的所有成员
friend class List<NODETYPE>;
public:
explicit ListNode(const NODETYPE& info)
: data(info), nextPtr(nullptr)
{
}
NODETYPE getData() const
{
return data;
}
private:
NODETYPE data;
ListNode<NODETYPE>* nextPtr; // 指向链表中下一个节点的指针
};
List.h,定义链表
cpp
#pragma once
#include <iostream>
#include "ListNode.h"
template<class NODETYPE>
class List
{
public:
List()
: firstPtr(nullptr), lastPtr(nullptr)
{
}
~List()
{
// 链表中还有数据
if (!isEmpty())
{
std::cout << "Destorying nodes ...\n";
ListNode<NODETYPE>* currentPtr = firstPtr;
ListNode<NODETYPE>* tempPtr = nullptr;
// 当前的第一个指针指向一个有效节点
while (currentPtr != nullptr)
{
tempPtr = currentPtr;
std::cout << tempPtr->data << '\n';
currentPtr = currentPtr->nextPtr;
delete tempPtr;
}
}
std::cout << "All nodes destoryed\n\n";
}
// 头插法
void insertAtFront(const NODETYPE& value)
{
// 创建一个新节点
ListNode <NODETYPE>* newPtr = getNewNode(value);
// 当前是一个空链表
if (isEmpty())
{
firstPtr = lastPtr = newPtr;
}
else
{
newPtr->nextPtr = firstPtr;
firstPtr = newPtr;
}
}
// 尾插法
void insertAtBack(const NODETYPE& value)
{
ListNode<NODETYPE>* newPtr = getNewNode(value);
if (isEmpty())
{
firstPtr = lastPtr = newPtr;
}
else
{
lastPtr->nextPtr = newPtr;
lastPtr = newPtr; // 将last指针指向最新的尾节点
}
}
bool removeFromFront(NODETYPE& value)
{
if (isEmpty())
{
return false;
}
else
{
ListNode<NODETYPE>* tempPtr = firstPtr;
// 链表中只有一个节点
if (firstPtr == lastPtr)
{
firstPtr = lastPtr = nullptr;
}
else
{
firstPtr = firstPtr->nextPtr;
}
value = tempPtr->data;
delete tempPtr;
return true;
}
}
bool removeFromBack(NODETYPE& value)
{
if (isEmpty())
{
return false;
}
else
{
ListNode<NODETYPE>* tempPtr = lastPtr;
if (firstPtr == lastPtr)
{
firstPtr = lastPtr = nullptr;
}
else
{
ListNode<NODETYPE>* currentPtr = firstPtr;
// 找到尾节点的前一个节点
while (currentPtr->nextPtr != lastPtr)
{
currentPtr = currentPtr->nextPtr;
}
lastPtr = currentPtr;
currentPtr->nextPtr = nullptr;
}
value = tempPtr->data;
delete tempPtr;
return true;
}
}
// 链表为空链表,首节点的指针域肯定为空
bool isEmpty() const
{
return firstPtr == nullptr;
}
void print() const
{
if (isEmpty())
{
std::cout << "The list is empty\n\n";
return ;
}
ListNode<NODETYPE>* currentPtr = firstPtr;
std::cout << "The list is: ";
while (currentPtr != nullptr)
{
std::cout << currentPtr->data << ' ';
currentPtr = currentPtr->nextPtr;
}
std::cout << "\n\n";
}
private:
ListNode<NODETYPE>* firstPtr; // 指向链表的首节点
ListNode<NODETYPE>* lastPtr; // 指向链表的尾节点
// 工具函数,创建一个新节点
ListNode<NODETYPE>* getNewNode(const NODETYPE& value)
{
return new ListNode<NODETYPE>(value);
}
};
测试客户程序,test.cpp
cpp
#include <iostream>
#include <string>
#include "List.h"
using namespace std;
// 提示用户输入指令
void instructions()
{
cout << "Enter one of the following:\n"
<< " 1 - insert at beginning of list\n"
<< " 2 - insert at ending of list\n"
<< " 3 - delete from beginning of list\n"
<< " 4 - delete from ending of list\n"
<< " 0 - end list processing\n";
}
// 声明一个函数模板,用于对list对象进行测试
template<class T>
void testList(List<T>& listObject, const string& typeName)
{
cout << "Testing a list of " << typeName << " values\n";
instructions();
int choice;
T value; // 用于保存对链表操作的值
do
{
cout << "? ";
cin >> choice;
switch (choice)
{
case 1: // 在首部插入
cout << "Enter " << typeName << ": ";
cin >> value;
listObject.insertAtFront(value);
listObject.print();
break;
case 2:
cout << "Enter " << typeName << ": ";
cin >> value;
listObject.insertAtBack(value);
listObject.print();
break;
case 3:
if(listObject.removeFromFront(value))
cout << value << " removed from list\n";
listObject.print();
break;
case 4:
if (listObject.removeFromBack(value))
cout << value << " removed from list\n";
listObject.print();
break;
case 0:
cout << "End list test\n\n";
break;
default:
cout << "Wrong choice\n";
break;
}
}while(choice);
}
int main()
{
List<int> integerList;
testList(integerList, "integer");
List<double> doubleList;
testList(doubleList, "double");
}
运行结果:

在上面的程序中我们创建了两个类模板,ListNode
和List
,前者描述了链表中一个节点应该具有的功能及存储的数据,后者则是给出了一个链表的一些基本操作方式。
类模板ListNode
在该模板的开始处,我们将一个类模板特化List<NODETYPE>
声明为类模板特化ListNode<NODETYPE>
的友元类,使得前者可以访问后者的私有数据成员,这使得这两个类紧耦合,且只有类模板List
能够操作类模板ListNode
的对象,并没有极大的破坏封装性。
类模板List
该类模板中提供了对于单链表的一些基本操作,头插法、尾插法、删除第一个节点、删除最后一个节点、显示整个链表、析构函数(用以保证当List对象被销毁时,其中所有的ListNode对象也全部被销毁)、判空函数以及用于创建一个新节点的工具函数。
除了上面实现的单链表之外,还有循环单链表、双向链表以及循环双向链表。它们具体的实现,请通过"数据结构"学习。
三、堆栈
我们之前已经使用几次堆栈了,这种数据结构的特点就是只允许在栈的顶部增加或删除节点,也就是后进先出(LIFO)
。栈的一种实现方法是将它作为一种有限制的单链表,将链表的尾节点作为栈底,只允许使用头插法和删除首部元素。
对堆栈而言,主要就是两种操作,压栈(push)
和出栈(pop)
。
利用堆栈和链表的关系,基于List继承实现模板类Stack
Stack.h
cpp
#pragma once
#include "List.h"
template<class STACKTYPE>
class Stack : private List<STACKTYPE>
{
using List<STACKTYPE>::insertAtFront;
public:
void push(const STACKTYPE& data)
{
// 注意虽然这里使用的参数是一个从属名称,但是仍然需要指明此处调用的函数是一个从属名称
// 因为函数名的查找发生在参数类型检查之前,如果此处不进行显式指明该函数名是一个从属名称
// 那么它就是一个非从属名称,会在模板定义阶段就被解析,所以会出错
insertAtFront(data);
}
bool pop(STACKTYPE& data)
{
return List<STACKTYPE>::removeFromFront(data);
}
bool isStackEmpty() const
{
return List<STACKTYPE>::isEmpty();
}
void printStack() const
{
this->print();
}
};
上面的代码中声明的类模板Stack,它private继承了类模板特化List<STACKTYPE>
,这是因为Stack只会使用到部分List的成员函数,有一些成员函数的功能应该受到限制,所以使用private继承,如此List中的public成员函数在Stack中全部变成了声明为private的接口,只在类内可以使用,保证了封装性。并且我们使用了委托
的方式来实现模板类Stack中的成员函数,即通过这些成员函数去调用List类的成员函数来实现功能。
类模板的从属名称
在模板编程中,从属名称是一个非常重要的概念。这是因为编译器对于模板的处理分为两个阶段:
- 模板定义阶段,处理不依赖于模板参数的部分;
- 模板实例化阶段,处理依赖于模板参数的部分;
这应该很容易理解,模板在定义阶段,仅仅是使用在模板参数列表中定义的一个类型占位符来表示该模板在使用过程中可能会出现的类型。但是到底是什么类型只有将一个模板进行特化之后才能确定,所以会分成两个阶段进行处理。
从上面的描述中大家大概也能猜出什么是从属名称了吧,没错,模板中依赖于模板参数的标识符都是从属名称
。
那么知道了什么是从属名称之后,这个从属名称到底有什么用呢?我们先来看下面的例子:
cpp
template<typename T>
class Base
{
T::value_type elem;
// 对elem的使用
// ...
};
在这个例子中,当我们的模板参数类型T是一个类模板特化,比如vector<int>
时,此时的T::value_type elem
想表达的意思是,创建一个与模板参数类型的内部元素相同类型的变量elem,value_type
是容器中预定义的类型别名。但是在容器中该类型是一个从属名称(它依赖于容器类模板的模板参数),但是在上面的代码中编译器并不知道它是什么,此时会产生二义,value_type是T内部的一个成员变量,或是一个类型。所以此时必须对它进行修改,可以有以下三种修改方式:
cpp
template<typename T>
class Base
{
// 1
typename T::value_type elem;
// 对elem的使用
// ...
// 2
typedef typename T::value_type value_type;
value_type elem;
// 3
using ValueType = typename T::value_type;
ValueType elem;
};
从上面的代码中我们了解到了从属名称的作用,并且在某些情况下,程序员需要显式的指定一个标识符是一个从属名称,以使得它的解析被推迟到模板实例化时才进行,保证程序的正确运行,那么有哪些情况呢?
- 当在模板中使用的标识符会使用到模板参数内部的子类型(比如,
vector<int>
中元素的类型为int类型)、模板参数内部的成员函数模板、或使用模板参数内部的子模板(嵌套模板)时,在上述的这些情况中,都需要显示指明该标识符是一个从属名称(使用typename、template或结合使用两者)。 - 当模板继承了另一个类模板时,在该需要使用到基类模板中的任何成员时,全部都需要显式指明当前标识符是一个从属名称(使用this指针、作用域限定符或using声明),如果使用的基类模板中的嵌套类型、嵌套函数模板、嵌套类模板时除了前面的this指针之类的指明它是一个从属名称的标识符之外,还需要使用typename之类的关键字。
从上面的两个情况我们可以发现其实从属名称也可以分为三类:
-
从属类型名称:使用关键字
typename
进行显式声明,注意class
是不行的,在模板参数列表中可以使用class作为关键字来声明一个模板参数,是因为在模板编程的早期使用的就是class,因为在当时模板大多用于类,但实际上一个模板的参数可以是任何类型(只要符合模板要求),使用class容易产生误会,所以引用了typename
关键字,所以在模板内部要声明一个从属类型名称必须使用typename,模板的参数列表中则是为了兼容性继续保留了class的使用。 -
从属函数名称:要使用关键字
template
进行声明。这是因为在没有显式的声明template让编译器将语句当作一个模板来使用的话,编译器会将模板中用于指定类型的尖括号<
当成一个小于运算符来处理。cpptemplate <typename T> class Sample { T obj; public: void run() { obj.template function<T>(); } };
-
从属模板名称:使用关键字typename+template的组合来显式声明。对于从属模板名称而言,也是相同的原因;
cpptemplate <typename T> class Sample { // T::template testClass<double>是一个特化的类型,所以在它的前面使用typename关键字 // testClass<>是一个类模板,在使用它之前需要让编译器明确知道这是一个类模板,所以使用template关键字 typename T::template testClass<double> obj; };
对于从基类模板继承而来的类模板而言,在模板定义阶段,整个基类是依赖于模板参数的,所以在该阶段,整个基类都是不确定的,如果要在派生类中使用基类的成员,必须使该标识符成为一个从属名称,这可以使用以下几种方式:
-
this指针,派生类的this指针的类型为
Derived <T>*
,它依赖于模板参数,所以它是一个从属名称,使用它调用的成员会推迟到模板实例化阶段才进行确定。上面的模板类Stack中的printStack
就是一个例子。 -
显式作用域限定,在使用基类成员前面显式的指明该标识符是基类的成员,例如,上面的模板类Stack中的
isStackEmpty
函数。 -
使用using声明,例如,isStackEmpty函数可以改成如下代码:
cpp// 在派生类内部 using List<STACKTYPE>::empty; // isStackEmpty函数内部 bool isStackEmpty() { return empty(); }
测试堆栈类模板Stack
cpp
#include <iostream>
#include "Stack.h"
using namespace std;
int main()
{
Stack<int> intStack;
cout << "Processing an integer Stack" << endl;
for (int i = 0; i < 3; ++i)
{
intStack.push(i);
intStack.printStack();
}
int popInteger;
while (!intStack.isStackEmpty())
{
intStack.pop(popInteger);
cout << popInteger << " popped from stack" << endl;
intStack.printStack();
}
Stack<double> doubleStack;
double value = 1.1;
cout << "Processing an double Stack" << endl;
for (int i = 0; i < 3; ++i)
{
doubleStack.push(value);
doubleStack.printStack();
value += 1.1;
}
double popDouble;
while (!doubleStack.isStackEmpty())
{
doubleStack.pop(popDouble);
cout << popDouble << " popped from stack" << endl;
doubleStack.printStack();
}
cout << endl;
}
运行结果:

利用List对象组合实现类模板Stack
下面的代码是使用了一个List类对象来实现Stack。
cpp
#pragma once
#include "List.h"
template <typename STACKTYPE>
class Stack
{
public:
void push(const STACKTYPE& data)
{
stackList.insertAtFront(data);
}
bool pop(STACKTYPE& data)
{
return stackList.removeFromFront(data);
}
bool isStackEmpty() const
{
return stackList.isEmpty();
}
void printStack() const
{
stackList.print();
}
private:
List<STACKTYPE> stackList;
};
测试代码与上面的相同,结果如下:

四、队列
基于List实现类模板Queue
通过类模板继承来实现类模板Queue,代码如下:
Queue.h
cpp
#pragma once
#include "List.h"
template <typename QUEUETYPE>
class Queue : private List<QUEUETYPE>
{
public:
// 入队
void enqueue(const QUEUETYPE& data)
{
this->insertAtBack(data);
}
// 出队
bool dequeue(QUEUETYPE& data)
{
return this->removeFromFront(data);
}
bool isQueueEmpty() const
{
return this->isEmpty();
}
void printQueue() const
{
this->print();
}
};
test.cpp
cpp
#include <iostream>
#include "Queue.h"
using namespace std;
int main()
{
Queue<int> intQueue;
cout << "Processing an integer Queue" << endl;
for (int i = 0; i < 3; ++i)
{
intQueue.enqueue(i);
intQueue.printQueue();
}
int popInteger;
while (!intQueue.isQueueEmpty())
{
intQueue.dequeue(popInteger);
cout << popInteger << " popped from Queue" << endl;
intQueue.printQueue();
}
Queue<double> doubleQueue;
double value = 1.1;
cout << "Processing an double Queue" << endl;
for (int i = 0; i < 3; ++i)
{
doubleQueue.enqueue(value);
doubleQueue.printQueue();
value += 1.1;
}
double popDouble;
while (!doubleQueue.isQueueEmpty())
{
doubleQueue.dequeue(popDouble);
cout << popDouble << " popped from Queue" << endl;
doubleQueue.printQueue();
}
}
运行结果:

五、树
上面介绍的链表、堆栈和队列都是线性结构。树是非线性的、二维的数据结构。树的节点可以包含两个或更多个链接,下面只讨论有两个链接的树,即二叉树。
实现一个二叉查找树
一个二叉查找树的基本特征是,左子树中的值一定小于其父节点的值,右子树的值一定大于其父节点的值。
我们在下面的代码中创建了一棵二叉树,并提供了三种遍历算法:中序
、前序
、后序
。
TreeNode.h
cpp
#pragma once
// 提前声明存在类Tree
template <typename NODETYPE> class Tree;
template <typename NODETYPE>
class TreeNode
{
// 注意此处只有与TreeNode类使用相同类型的特化才是友元
// friend class template Tree<NODETYPE>是错误的写法
// friend template class Tree<NODETYPE>也是错误的写法
// template关键字在友元声明中只能用于引用模板参数列表,不能直接放在class的前面
// 正确的用法是template <参数> friend class X;
// 所以如果要表明另一个类模板所有的特化都是友元,则使用下面的写法
// template <typename U> friend class Tree;
friend class Tree<NODETYPE>; // Tree<NODETYPE>已经是一个完整的模板实例化类型,不需要额外标记template来说明它是模板
public:
TreeNode(const NODETYPE& d)
:leftPtr(nullptr),
data(d),
rightPtr(nullptr)
{
}
NODETYPE getDate() const
{
return data;
}
private:
TreeNode<NODETYPE>* leftPtr;
NODETYPE data;
TreeNode<NODETYPE>* rightPtr;
};
Tree.h
cpp
#pragma once
#include <iostream>
#include "TreeNode.h"
template <typename NODETYPE>
class Tree
{
public:
Tree()
:rootPtr(nullptr)
{
}
void insertNode(const NODETYPE& value)
{
insertNodeHelper(&rootPtr, value);
}
void preOrderTraversal() const
{
preOrderHelper(rootPtr);
}
void inOrderTraversal() const
{
inOrderHelper(rootPtr);
}
void postOrderTraversal() const
{
postOrderHelper(rootPtr);
}
private:
TreeNode<NODETYPE>* rootPtr;
// 要接收一个指向节点的指针,且避免传参时复制整个节点
// 使用一个二级指针来接收
void insertNodeHelper(
TreeNode<NODETYPE>** ptr, const NODETYPE& value)
{
// ptr指向的子树是一棵空树
if (*ptr == nullptr)
{
// 创建一个新节点,将值在存储在这个新节点中
*ptr = new TreeNode<NODETYPE>(value);
}
else // 比较要插入的值和当前节点中的值的大小
{
// 要插入节点的值小于当前节点的值,插入左子树
if (value < (*ptr)->data)
{
// 递归尝试将节点插入左子树中
insertNodeHelper(&(*ptr)->leftPtr, value);
}
else // 插入右子树
{
if (value > (*ptr)->data)
{
insertNodeHelper(&(*ptr)->rightPtr, value);
}
else
{
// 重复的值,忽略
std::cout << value << " dup " << std::endl;
}
}
}
}
// 先(根)序遍历
void preOrderHelper(TreeNode<NODETYPE>* ptr) const
{
// 判定ptr指向的子树是否为空树
if (ptr == nullptr)
{
return; // 查找结束条件
}
else
{
// 输出根节点的值
std::cout << ptr->data << ' ';
// 对左子树进行先序遍历
preOrderHelper(ptr->leftPtr);
// 对右子树进行先序遍历
preOrderHelper(ptr->rightPtr);
}
}
// 中(根)序遍历
void inOrderHelper(TreeNode<NODETYPE>* ptr) const
{
// 判定ptr指向的子树是否为空树
if (ptr == nullptr)
{
return; // 查找结束条件
}
else
{
// 对左子树进行先序遍历
inOrderHelper(ptr->leftPtr);
// 输出根节点的值
std::cout << ptr->data << ' ';
// 对右子树进行先序遍历
inOrderHelper(ptr->rightPtr);
}
}
// 后(根)序遍历
void postOrderHelper(TreeNode<NODETYPE>* ptr) const
{
// 判定ptr指向的子树是否为空树
if (ptr == nullptr)
{
return; // 查找结束条件
}
else
{
// 对左子树进行先序遍历
postOrderHelper(ptr->leftPtr);
// 输出根节点的值
// 对右子树进行先序遍历
postOrderHelper(ptr->rightPtr);
std::cout << ptr->data << ' ';
}
}
};
测试程序,test.cpp
cpp
#include <iostream>
#include <string>
#include <iomanip>
#include <cstdlib>
#include "Tree.h"
using namespace std;
template <typename T>
void testTree(Tree<T>& tree, const string& typeName)
{
cout << "Enter 10 " << typeName << " values:\n";
for (int i = 0; i < 10; ++i)
{
T value = 0;
cin >> value;
tree.insertNode(value);
}
cout << "\nPreorder traversal\n";
tree.preOrderTraversal();
cout << "\nInorder traversal\n";
tree.inOrderTraversal();
cout << "\nPostorder traversal\n";
tree.postOrderTraversal();
}
int main()
{
cout << fixed << setprecision(2);
Tree<int> intTree;
Tree<double> doubleTree;
testTree(intTree, "int");
cout << "\n\n";
testTree(doubleTree, "double");
cout << endl;
}
运行结果:

上面使用递归的方法实现新值的插入和三种方式的遍历。至于这三种遍历到底是什么,这里就不再赘述。