编程三大错觉:
我比编译器聪明
我超越了标准库
我能管好内存
问题描述
由于最近在看《算导》,我就萌生了实现算导的想法。之前实现的线表List.h文件一直稳定运作,但在经历了单元测试并稳定运行数天后,当我将它应用到我的新代码部分时,出现了一个bug。
我的代码文件如下:
List.h
template<typename T>
class List {
public:
List() : head(new class Node<T>), tail(new class Node<T>) {
head->nextNode = tail;
tail->prevNode = head;
}
List(T rs) : List() {
insert(rs);
}
~List() {
for (class Node<T> *pointer = tail; pointer != head;) {
auto temp = pointer;
pointer = pointer->prevNode;
delete temp;
}
delete head;
}
void insert(T rs){
auto pointer = new class Node<T>(rs, tail->prevNode, tail);
tail->prevNode = pointer;
pointer->prevNode->nextNode = pointer;
}
}
test.cpp
template<class Vertex, class Edge>
class AdjacentList{
private:
std::map<shared_ptr<Vertex>,List<Edge>>;
}
template<class Vertex, class Edge>
void AdjacentList<Vertex, Edge>::insert(std::shared_ptr<Vertex> origin, std::shared_ptr<Vertex> next, double w) {
if (!graph[origin])
graph[origin] = List<Edge>();
auto x = Edge(next, w);
graph[origin].insert(x);
}
接下来给你三分钟,试试能不能找出bug在哪?
如果你找不出bug在哪里,请接着往下看:
C++为我们提供了强大的智能指针,用来管理资源的生命周期。大部分情况下我们只要用好智能指针来管理资源就好。不过在某些情况下,我们可能会遇到空间不足,或是需要自定制的资源管理类。然而,编写资源管理类很困难,你很难检测和排除内存泄漏或空悬指针的现象。
回到我们上面的这个函数,bug实际上出现在
if (!graph[origin])
graph[origin] = List<Edge>();
这里。在这里,我们不是初始化,而是使用复制赋值运算符
ClassType &operate=(ClassType &rs);
为map的second值赋值。由于我们的List类并没有自定义复制赋值运算符,C++的默认复制赋值运算符的操作是将原来类的每一个成员变量赋值给新的类。所以这里,graph[origin]
得到了一个List<Edge>()
类的head和tail指针(注意,这样非常危险!要么两个List在同一块链表上进行插入删除操作,要么造成内存泄漏的后果)
我们在List的insert函数处打一个断点,看看会发生什么:
可以看到,graph[origin]
的head和tail的前后指针良好。但我们再执行一步看看:
你会发现:变量pointer竟指向一个“已分配”的指针graph[origin].head
!这是为什么呢?
其实到这里问题已经比较清晰了:为graph[origin]
赋值的List<Edge>()
是一个右值,在完成赋值语句之后就被析构了。我们自定义的析构函数从tail开始逐个向前,将所有指针指向的资源释放掉。那为什么在dubugger这里还能看到head、tail和它们指向的值呢?这是因为编译器在将资源返回给动态内存池的时候不会执行置零操作,而是简单地将该地址入栈等待下一次调用。所以指向该地址的类仍然可以解释————虽然它早已被析构了。
那这种问题怎样解决呢?一种方式是正常调用它的构造函数以便之后调用成员函数:
if (!graph[origin])
//construct graph[origin]
graph[origin];
但另一个更重要的操作是,更改List的类函数
0/3/5规则
零规则
规则的零部分规定,在创建类时,你可以不编写任何特殊成员函数(而由编译器默认生成)。
三规则
如果你的类需要任何
- 一个复制构造函数,
- 赋值运算符,
- 析构函数,
明确定义,那么很可能需要这三个。
因为它们三个通常都用于管理资源,如果你的类用来管理资源,则通常需要管理复制和释放。
如果复制类管理的资源没有良好的语义,则考虑通过将复制构造函数和赋值运算符声明为=delete;
(鼓励)或放入 private scope 并不进行定义。
五规则
在三规则的基础上,C++11标准引入了右值。所以还需要考虑移动构造函数和移动赋值函数,即
class ClassExample{
public:
~ClassExample() = {/*details or default*/}
ClassExample(ClassExample &) = {/*details or default*/}
ClassExample(ClassExample &&) = {/*details or default*/}
ClassExample &operator=(ClassExample &) = {/*details or default*/}
ClassExample &operator=(ClassExample &&) = {/*details or default*/}
}
代码改进
我们增加移动赋值操作的定义,可以练习一下其他几个函数的定义。
由于List类的head
和tail
为哨兵元素,我们无需考虑,也无需复制。而是将List类中的元素复制过来。
template<typename T>
class List {
public:
List<T> &operator=(List<T> &&rs){
//free this class's element but reserve guard elements
for(auto pointer = tail->prevNode; pointer != head;){
auto temp = pointer;
pointer = pointer->prevNode;
delete temp;
}
//duplicate rs' elements
for(auto pointer = rs.head->nextNode; pointer != rs.tail; pointer = pointer->nextNode){
insert(*pointer);
}
}
}
写在结尾
如果这篇文章对你有帮助,不要忘了帮我点个赞~你的赞是我更新的最大动力。