Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

13. 复制对象务必尽善尽美

Posted on 2018-04-09 | In Effective C++

前言

</br>
设计良好的OO System一般只留有两个函数负责对象拷贝,那就是copy构造函数和copy assignment操作符,暂称它们为copying函数。正如前文所说,编译器会在必要的时候为我们的classes创建copying函数,其行为是:将被copy对象的所有成员变量都做一份拷贝。


手动处理拷贝可能存在的风险

</br>
如果我们自己声明和实现了copying函数,但却并没有完全拷贝成员变量,造成了partial copy,此时编译器根本不会报错,所以需要仔细地排查:

1
2
3
4
5
6
7
8
class Widget{
public:
Widget(const Widget& rhs)
:name(rhs.name) {};//忘记了copy age 不会报错
private:
string name;
int age;
}

由于这种特性,每一次新的成员变量的加入,copying函数,构造函数,以及任何重载的operator=都必须要做出修改。你就不用指望编译器会提醒你了。


继承体系下的partial copy

问题实例

假设我们的继承体系中存在着Customer与PCustomer两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Customer{
public:
...
private:
string name;
Date lastTransaction;
}
class PCustomer:public Customer{
public:
...
PCustomer(const PCustomer &rhs);
PCustomer& operator=(const PCustomer &rhs);
...
private:
int priority;
};
PCustomer::PCustomer(const PCustomer &rhs)
:priority(rhs.priority) {}

看起来确实执行了copy,但实际上dc内部的bc成分并未被copy。在这种情况下,bc成分将会被默认初始化。
如果是operator=,则仅仅复制了dc成分,bc成分保持不变。

解决方案

在任何时候我们一旦承担起为derived class编写copying函数的责任,必须要注意一定要记得复制base class的成员变量 ,一般情况下它们是private,所以我们调用base class的copying函数来完成它们。针对上面的实例,两个copying函数的正确写法如下所示:

1
2
3
4
5
6
7
8
PCustomer::PCustomer(const PCustomer &rhs)
:Customer(rhs),priority(rhs.priority) {}
PCustomer&
PCustomer::operator=(const PCustomer &rhs){
Customer::operator=(rhs);
priority=rhs.priority;
return *this;
}


copy构造函数与copy assignment运算符之间的关系

</br>
一般来说,copying 函数往往有近似的实现本体,这可能会让我们想到在一个中调用另一个。但这并不可取。

令copy assignment调用copy construct函数并不科学,原因很简单,你在试图构造一个已经存在的对象。
令copy construct函数调用copy assignment同样毫无意义。构造函数用来初始化新对象,而assignment操作符只施行于已经被初始化的对象。对还没构造好的对象赋值完全毫无意义。

如果它们两个之间真的存在相近的代码,那应该编写一个private的init函数,给他们两个调用,这样就完美消除了代码重复的问题。


总结

  1. copying函数应该确保复制对象内所有成员,以及所有base class成分(调用bc对应的copying函数)
  2. 不要尝试以某个copying函数实现另一个copying 函数。应该新建一个private init函数给二者调用。

13. vector与string相较于动态数组的优越性

Posted on 2018-04-09 | In Effective STL

如果我们决定使用new进行动态分配,那么我们必须肩负以下责任:

  1. 有new必然要delete
  2. new[]必然要对应着delete[]
  3. 避免重复delete

实际上上述操作总是很麻烦,所以我们应该尽可能使用vector或者string来代替动态数组。另外,这两大容器可以配合STL算法,效果绝佳。

事实上除了在多线程中使用引用计数字符串会导致性能下降外,其他情况下vector与string都具有绝对优势。就算这样,我们也可以试图关闭引用计数,或者使用vector<char>,尽管那失去了一些string的专有成员函数。

12. 在operator=中处理自赋值

Posted on 2018-04-09 | In Effective C++

前言

 
我们在重载operator=时需要注意自我赋值的情况。可能有人会认为这种情况很少发生,因为很少有人会写诸如w=w的句子。但实际上,代码中出现a[i]=a[j](i==j),或者p=q(指向同一对象)的例子屡见不鲜。
事实上,只要你使用了pointers或者references,而且它们被用来指向多个相同类型的对象,我们就需要考虑 是否会出现自赋值的情况。确切地说,只要有多个对象来自同一个继承体系,比如一个指针指向base class类型,而另一个指向derived class类型。它们完全可能指向的是同一个对象。


不处理自赋值情况的风险

 
如果我们在class中手动地进行了资源管理,那么自赋值的处理不当可能会带来一些问题。举例而言:

1
2
3
4
5
6
7
8
9
10
11
12
class Bitmap{...}
class Widget{
...
private:
Bitmap* pb;
};
Widget&
Widget::operator=(const Widget& rhs){
delete pb;
pb = new Bitmap(*rhs.pb);//如果是自赋值,pb指向的对象已经释放
return *this;
}

上述程序既不具备异常安全性,也直接导致了“在停止使用资源前意外地释放了它”。


解决方案

证同测试

其原理很简单:在函数最前端放上这样一句话:

1
if(this==&rhs) return *this;

这个解决方案保证了自赋值安全性,但是不具备异常安全性。在这句话中:
1
pb=new Bitmap(*rhs.pb);//已通过证同测试,此时已经执行delete

如果new导致了一个异常(内存不足或者copy构造函数抛出异常),那么pb始终会指向一块被删除的Bitmap。我们无法安全地删除pb,甚至读取它。

手动保证异常安全

因为只要解决了异常安全性,那么自赋值安全性就会必然得到解决,所以我们倾向于使用足够完美的逻辑避免异常的发生,其原理也很简单:在赋值pb前别删pb

1
2
3
4
bitnap * porig = pb;
pb =new bitmap(rhs.pb);
delete porig;
return *this;

这样的好处不言自明,如果pb没能正确赋值,其对象也没有被析构。(异常发生时跳出了operator=函数,因此没有执行delete porig)。如果正确赋值,pb指向的对象被释放。

copy&&swap

还有一个十分灵巧的operator=撰写技巧:

1
2
3
4
5
6
7
8
9
10
class Widget{
...
void swap(Widegt& rhs);//彻底交换数据
}
Widget&
Widget::operator=(const Widget& rhs){
Widget temp(rhs);
swap(temp);
return *this;
}

如果operator=接受的参数是by value而非by reference,那我们连temp都可以省略:

1
2
3
4
5
Widget&
Widget::operator=(Widget rhs){
swap(rhs);
return *this;
}

总结

  1. 确保自赋值安全性。其技术包括比较lhs与rhs的地址,先做backup,以及copy-and-swap
  2. 确定任何操作一个以上的对象的函数,如果其操作对象是同一个对象时,还能保持正确性。

12. STL容器的线程安全性

Posted on 2018-04-09 | In Effective STL

STL提供的线程安全性

 
基本上STL容器提供的线程安全性只有以下两点:

  1. 多个线程读取是安全的
    多个线程可以读同一个容器内的数据,读时不允许写操作
  2. 多个线程对不同的容器写入是安全的

如何实现线程安全性

 
完全的线程安全很难实现,一个库可能试图以下列方式实现这样完全线程安全的容器:

  1. 对于容器成员函数的每一次调用都锁住该容器直到调用完成。
  2. 在容器返回的迭代器生存期结束之前锁住容器
  3. 对于作用于容器的每个算法,在算法执行期间都锁住容器。(实际上这一点毫无必要,因为算法无法识别它们正在操作的容器)

实例

 
现在考虑一个例子:将vector内的第一个5改成0,如果它存在的话。

1
2
3
4
5
vector<int> v;
auto first5 = find(v.begin(), v.end(), 5);
if (first5 != v.end()){
*first5 = 0;
}

在多线程运行环境中,另一个线程可能在find操作完成后立刻修改v中的数据。那样第三行与v.end的检测将毫无意义,因为v的元素已经不再是原来的元素。并且如果执行了插入或者删除操作,first5也已经失效。
之前列举的3个方法都无法防止上述问题,迭代器调用返回得很快,生存期只有一行,find也是,无法帮助锁定。


解决方案

 
为了保证上述代码的线程安全,v必须从行1到行3都保持锁定,STL无法做到自动判断容器是否需要锁定,因此必须手动完成:

1
2
3
4
5
6
7
8
vector<int> v;
...
getMutexFor(v);//需要自己实现的容器锁操作
auto first5 = find(v.begin(), v.end(), 5);
if (first5 != v.end()) {
*first5 = 0;
}
releaseMutexFor(v);

从面向对象的角度而言,解决方法是构建一个lock类,其类模板大致如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
Template<typename Container>
class Lock {
public:
Lock(const Containers container)
: c(container){
getMutexFor(c);
}
~Lock(){
releaseMutexFor(c);
}
private:
const Container& c;
};

显然,这种方法是在用类来管理资源的生存期(RAII),其使用案例如下:
1
2
3
4
5
6
7
8
9
10
vector<int> v;
...
{
Lock<vector<int> > lock(v);
auto first5 = find(v.begin(), v.end(), 5);
if (first5 != v.end()) {
*first5 = 0;
}
...//lock对象析构,v解锁
}

另外,这种解决方案是异常安全的,因为在异常发生的情况下,局部对象会被销毁,此时lock也会释放互斥量。


总结

 
STL允许在一个容器上的多线程读取和不同容器上的多线程写入,除此之外不能依赖任何库自带的线程安全性。

11. 令operator=返回reference to *this

Posted on 2018-04-09 | In Effective C++

operator= 的一大特点在于可以写成连锁形式:

1
2
int x,y,z;
x=y=z=15;

operator= 采用右结合律,所以上式被解析为:
1
x=(y=(z=15));

为了实现这种连锁赋值,operator=必须返回一个reference指向操作符的左侧实参,这是实现operator=的协议要求:
1
2
3
4
5
6
7
8
9
class Widget{
public:
...
Widget& operator=(const Widget& rhs){
...
return *this;
}
...
}

该协议不仅仅适用于operator=,也适用于所有赋值相关操作,例如operator+=之类。(关于操作符的复合形式,其实现亦有特点,具体可见More Effective C++ 22)

11. 自定义allocator的合理用法

Posted on 2018-04-09 | In Effective STL

(本节内容并未完全掌握,建议学习STL源码剖析相关章节后再次复习本章)

何时需要自定义allocator?

 
如果你对STL中默认的allocator感到不满,因为:

  1. 你发现它太慢、浪费内存或造成过度的碎片
  2. 本次编写的程序无需考虑多线程,没必要支付同步开销
  3. 某些容器里的对象通常一同被使用,所以需要在一个特别的堆里把它们放得很近,使引用的区域性最大化
  4. 需要建立一个相当于共享内存的唯一的堆,然后把一个或多个容器放在那块内存里,使其他进程得以共享

那你需要一种自定义allocator的方案(包括但不限定于上述几种情况)。


问题实例

建立一个相当于共享内存的堆

假定我们有一些特殊过程,采用malloc与free内存模型来管理一个位于共享内存的堆,并且需要把STL容器的内容放置于共享内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void* mallocShared(size_t bytesNeeded);
void freeShared(void *ptr);
...
template<typename T>
class SharedMemoryAllocator {
public:
...
pointer allocate(size_type numObiects, const void *localityHint = 0){
return static_cast<pointer>(mallocShared(numObiects * sizeof(T)));
}
void deallocate(pointer ptrToMemory, size_ type numObjects){
freeShared(ptrToMiemory);
}
...
};

SharedMemoryAllocator的使用方式如下:
1
2
3
4
5
6
typedef vector<double, SharedMemoryAllocator<double> > SharedDoubleVec;
...
{ // 开始某一个块
SharedDoubleVec v; //v在共享内存中
... // 结束这个块
}

但值得注意的是:v使用了自定义allocator作为分配器,因此所分配的容纳元素的内存必然来自共享内存。但是v自己,包括其所有数据成员,必然不会出现在共享内存中,它们存在于栈内。
为了把v的内容与v自身一起放入共享内存,有操作如下:
1
2
3
4
5
void *pVectorMemory =  mallocShared(sizeof(SharedDoubleVec));// 分配内存容纳SharedDoubleVec对象
SharedDoubleVec *pv = new (pVectorMemory) SharedDoubleVec;//placement new 操作详见Effective C++ 52
...//使用pv
pv->~SharedDoubleVec(); //销毁共享内存中的容器
freeShared(pVectorMemory); // 销毁共享内存块

总地来说,使用步骤依次为:
分配➡️构造➡️析构➡️释放

聚合容器内的元素

假设我们有Heap1与Heap2两个堆,每个堆都有相应的静态成员函数来执行内存分配和释放操作:

1
2
3
4
5
6
7
8
class Heap1 {
public:
...
static void* alloc(size_t numBytes, const void *memoryBlockToBeNear);
static void dealloc(void *ptr);
...
};
class Heap2 { ... }; //接口与Heap1类似

进一步地,我们想自定义容器元素容纳于哪一个堆。因此,我们必须设定一个分配器,使用像heap1,heap2那样真实管理内存的类:
1
2
3
4
5
6
7
8
9
10
11
template<typenameT, typename Heap>
class SpecificHeapAllocator {
public:
pointer allocate(size_type numObjects, const void *localityHint = 0){
return static_cast<pointer>(Heap::alloc(numObjects * sizeof(T),localityHint));
}
void deallocate(pointer ptrToMemory, size_type numObjects){
Heap::dealloc(ptrToMemory);
}
...
};

然后再使用allocator把容器的元素集合到一起:
1
2
3
4
vector<int, SpecificHeapAllocator<int,Heap1> > v;
set<int, SpecificHeapAllocator<int,Heap1> > s;
list<Widget,SpecificHeapAllocator<Widget,Heap2> > L;
map<int, string, less<int>,SpecificHeapAllocator<pair<const int, string>,Heap2> > m;

值得注意的是,Heap1和Heap2是类型而不是对象。如果Heap1和Heap2是对象而不是类型,那么它们将是不等价的分配器,那就违反了分配器的等价约束。

10.绝不在构造和析构过程中调用virtual函数

Posted on 2018-04-08 | In Effective C++

前言

C++禁止在构造和析构期间调用virtual函数,这是它与Java或者C#的一大不同之处。


实例

假设我们现有一个class用来模拟股市交易,显然每一笔订单都需要经过审计,那么在审计日志中必然也需要创建一笔交易记录,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Transaction{
public:
Transaction();
virtual void logTransaction() const = 0;//因交易类型不同做出不同的记录
...
};
Transaction::Transaction(){
...
logTransaction();//构造的最后执行记录
}
class BuyTransaction:public Transaction{
public:
virtual void logTransaction();//因交易类型不同做出不同的记录
...
};

试想一下,当这行语句执行时会发生什么?
1
BuyTransaction b;


构造、析构次序与virtual函数

当我们创建derived class时,无疑bc的构造函数优先被调用,因为base class成分会在derived class自身成分被构造之前先构造完毕。这个时候问题来了,bc的构造函数最后使用了virtual function,但它调用的并不是derived class定义的版本,而是bc的pure virtual版。有一个不太恰当的解释:在构造base class构造期间,virtual函数并不是virtual函数。

因为base class的构造早于derived class构造函数,当base class构造函数执行时derived class的成员变量尚未初始化。如果在这期间调用derived class版本的virtual function,无疑该函数几乎必然使用local成员变量,而他们尚未初始化。

更根本的原因在于,在derived class的base成分构造期间,对象的类型是base class而非derived class。如果我们试图用dynmaic_cast或者typeid,这些函数也会把对象视为base class类型。这么对待对象是合理的:因为derived class的专属成分尚未被初始化,那么面对它们最安全的做法就是视而不见。对象在derived class构造函数开始执行前不会成为一个derived class对象。

相同道理也适用于析构函数。当进入了base class的析构函数后,析构对象已经变成了一个base class对象。你不可能在一个bc对象上调用dc的成员函数。


潜在危险

并非每一种virtual函数都明明白白地写在构造或者析构函数中等你改正,考虑下面这种情况:由于构造函数往往有多个,我们通常倾向于把构造函数们共同使用的那一部分代码放进一个初始化函数,但是init函数可能会包含一个virtual函数

1
2
3
4
5
6
7
8
9
10
11
class Transaction{
public:
Transaction() {init();}
virtual void logTransaction() const = 0;
...
private:
init(){
...
logTransaction();//此处调用了virtual
}
};

这种方法十分毒瘤,因为不会有任何连接器和编译器报错我们只能认真地检查构造函数和析构函数,确保不管是它们自身还是它们所调用的那些函数都不含有任何virtual函数。


根治策略

上述解决方法本质上仍然无法解决每当对象被创建就会自发记录的问题,我们已经了解无法在构造/析构函数中使用virtual函数,所以解决方法是将其改为non-virtual,要求dc构造函数必须传递必要信息给Transaction构造函数,而后那个构造函数就可以安全地调用non-virtual logTransaction。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Transaction{
public:
explicit Transaction(const string& logInfo) {}
void logTransaction(const string& logInfo) const;
...
};
Transaction::Transaction(const string& logInfo){
...
logTransaction(logInfo);
}
class BuyTransaction:public Transaction{
public:
BuyTransaction(parameters):
Transaction(createLogString(parameters))
{...}
...
private:
static string createLogString(parameters);
};

因为我们做不到virtual函数从base class向下调用,在构造期间,我们只能令derived class将必要的构造信息传递至base class的构造函数。
比起在成员初值列给予base class所需要的数据,利用辅助函数创建一个值传给base class往往更加可读。令此函数为static,也就不可能指向derived class内尚未初始化的成员变量。


总结

不要在构造和析构过程中调用virtual函数,因为此类调用从不会下降至dc层。

10.了解allocator的约定与限制

Posted on 2018-04-08 | In Effective STL

(本节内容并未完全掌握,建议学习STL源码剖析相关章节后再次复习本章)
allocator最初作为内存模型的抽象而产生,但最终失败了。后来又被设计成促进全功能内存管理器,但又发现可能会造成效率低下,最终,allocator被弱化成为了对象。


allocator的特点

类似于operator new 和operator new[],allocator也负责分配和回收内存,但其接口和new,new[],或者malloc毫无相似之处。并且,大部分容器从未向它们相关的allocator索取内存。这是相当奇怪的一点。

正如前文提到的,allocator最初作为内存模型的抽象而产生,那么它必须为其所定义的内存模型中的指针和引用提供类型定义,一般而言,一个类型为T的对象,它的默认allocator提供了allocator::pointer与allocator::reference;

如果你对C++十分了解,你会发现其实我们无法模拟一个引用。这需要重载operator. (该操作符禁止重载)而且模拟引用最好的方式是使用代理对象(proxy object详见More Effective C++ 30),代理对象会带来许多问题。

c++标准明确指出,允许库实现者假定每个分配子的指针等价于T*,而其引用等价于T&.也就是说,库实现可以忽视typedef并直接使用原始指针和引用。

allocator是对象这一性质意味着它可以拥有成员函数、嵌套类型和类型定义(如reference与pointer)。同时c++标准再一次规定,STL的实现可以假定所有属于同一种类型的allocator对象等价,且比较结果相等,这非常很奇怪,但是是可以理解并接受的,举例如下:

1
2
3
4
5
6
7
template<typename T> // 一个用户定义的分配器模板
class SpecialAllocator {...};
typedef SpecialAllocator<Widget> SAW;
list<Widget, SAW> L1;
list<Widget, SAW> L2;
...
L1.splice(L1.begin(), L2); // 把L2的节点移到L1前端

众所周知,当list的元素从一个list链接到另一个时,并没有复制任何东西,而是仅仅调整了一些指针。当L1被析构时,L1的allocator必须析构自己的所有节点并释放内存,但其现在包含了L2的节点,所以它必须释放最初由L2的allocator分配的节点,这也就是allocator同类型等价的原因,允许一个allocator对象分配的内存可以由另一个allocator对象安全删除。
这一点也禁止了allocator不允许存在自己的state,更直白的说,allocator不允许有任何non-static成员,如果存在则无法等价。


allocator与内存分配

allocator与new在分配内存上有一点相似之处:

1
2
void* operator new(size_t bytes);
pointer allocator<T>::allocate(size_type numObjects);//pointer 等价于T*

对于new而言,它的参数是所需字节的大小,而allocator的参数是分配多少个对象。它们的返回值也不同,operator new返回void*,而allocator::allocate则是返回T*,而且这个返回值并没有指向某个T对象,因为根本还没有构造。


容器与它们对应的allocator

大多数标准容器从未单独调用过对应的allocator,这种情况普遍发生于基于节点的容器:

1
2
list<int,allocator<int> > L; //allocator<int>从未分配内存
set<Widget, SAW> s; //SAW从未分配内存

list<T>的一个可能实现如下:
1
2
3
4
5
6
7
8
9
10
11
template<typename T,typename Allocator = allocator<T> >
class list{
private:
Allocator alloc; // 用于T类型对象的分配器
struct ListNode{ // 链表里的节点
T data:
ListNode *prev;
ListNode *next;
};
...
}

当添加一个node到list时,我们需要从分配器为其获取内存,我们要的并非是T的内存,而是包含了一个T的ListNode的内存。那allocator<T>基本上就是废了。
list需要的是从他的分配器类型那里获取用于Listnode的对应分配器的方法。按照协定,分配器需要提供完成那部分工作的typedef,实际上这个东西叫other.是嵌入一个叫做rebind的结构体的typedef,rebind自己是一个嵌入分配器的模板—分配器自己也是一个模板。
1
2
3
4
5
6
7
8
9
template<typename T> 
class allocator {// 分配器模板
public:
template<typename U>
struct rebind{
typedef allocator<U> other;
}
...
};

在list的实现代码里,需要确定我们持有的T的分配器所对应的ListNode的分配器类型,所以,list的allocator类型为:
1
Allocator::rebind<ListNode>::other


总结:

如果你需要自己实现一个allocator,你需要明确:

  1. allocator是一个模板
  2. 提供pointer与reference的typedef
  3. 不要给allorator分配state,通常不含有non-static成员
  4. 传给allocate的是对象个数而非字节数,同时记得其返回T*指针。
  5. 一定要提供标准容器依赖的内嵌rebind模板

9.禁止异常离开析构函数

Posted on 2018-04-08 | In Effective C++

C++并不禁止析构函数抛出异常,但需要明确:析构函数抛出异常存在风险。

问题描述

考虑如下代码:

1
2
3
4
5
6
7
8
9
class Widget{
public:
...
~Widget() {...}//可能抛出异常
}
void doSth(){
vector<Widget> v;
...//v在此处被自动销毁
}

当vector被销毁时,所有的Widget对象都会被销毁。于是我们一一调用析构函数.假设在析构第一个元素时,有一个异常被抛出,但剩下的还是应该被销毁(否则就会资源泄漏),因此会接着销毁第二个,但如果第二个Widget被析构时又抛出了异常,于是现在有了两个同时作用的异常,C++不允许处理多个异常同时存在的情况。在两个异常同时存在的情况下,c++不是立刻结束运行就是导致不明确行为。在本例中就是会导致不明确行为。
我们需要记住的是:只要析构抛出异常,程序就可能过早结束或者行为不明确。


问题实例及解决方案

问题实例

假设我们建立了一个数据库连接类,其行为如下:

1
2
3
4
5
6
class DBConnection{
public:
...
static DBConnection create();//返回对象 static成员函数不属于对象而是属于类
void close();//关闭连接,关闭失败则抛出异常
}

为了确保DBConnection对象必然会关闭,很自然地,我们会建立一个类来管理DBConnection对象:
1
2
3
4
5
6
7
8
class DBConn{
public:
~DBConn(){
db.close();//析构函数中可能抛出异常
}
private:
DBConnection db;
}

如果析构时抛出异常,DBConn会传播该异常(允许其离开析构函数),那么结果相当麻烦。

解决方案

修改析构函数

抛出异常就终结程序(调用abort)

1
2
3
4
5
6
7
DBConn::~DBConn(){
try{ db.close():}
catch(...){
...//记录失败至日志
std::abort();
}
}

虽然这直接导致了程序突然结束,但至少它阻止了异常的传播。

直接吞下异常,不对它进行处理

1
2
3
4
5
6
DBConn::~DBConn(){
try{ db.close():}
catch(...){
...//记录失败至日志
}
}

这基本上会导致雪崩,但有时我们不能立刻终结程序,毕竟能跑总比不能用要好。

重新设计DBConn类接口(较优策略)

我们可以重新设计DBConn,使用户可以对发生异常做出反应。或者说追踪connection是否被关闭,如果未被关闭则由析构函数关闭。但如果析构函数调用close失败,我们还是又将退回“强迫结束程序”或者“吞下异常”的老路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DBConn{
public:
...
void close(){//供客户使用的关闭函数
db.close();//调用时不会在析构函数中抛出异常
closed = true;
}
~DBConn(){
if(!closed){//如果用户没有手动关闭连接
try{db.close();}
catch(...){
//使用上述两种处理方式之一
}
}
}
private:
DBConnection db;
bool closed;
}

这种写法把调用close的责任从析构函数转移到了客户手中。可能有人会认为这令接口变得不方便使用,但实际上如果某个操作可能在失败时抛出异常,又存在某种必要必须处理该异常,那么这个异常必须来自析构函数之外的某个函数。因为析构函数抛出异常必然会导致过早结束程序或者发生不明确行为的风险。
如果用户没用close来关闭连接,那至少析构函数中也适当的调用了它,这时候再发生错误用户也没资格抱怨,因为毕竟我们提供了接口,而他们选择了放弃使用。


总结

  1. 析构函数绝对不能吐出异常,如果一个被析构函数调用的函数可能会抛出异常,那我们的析构函数应该捕捉任何异常,然后吞下它们(不传播)或者结束程序。
  2. 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

9.慎重选择删除元素的方法

Posted on 2018-04-08 | In Effective STL

删除某个值

假设我们有一个容器,其内部元素类型为int.现在需要删除容器c内所有值为1963的元素,值得注意的是,完成这项任务的方法因不同的容器类型而不同,无通用解。

连续内存容器(vector,deque,string)

最好的删除操作是erase—remove惯用法:

1
c.erase(remove(c.begin(),c.end(),1963),c.end());

list

list也可以用erase_remove,但是最好直接使用remove:

1
c.remove(1963);

标准关联容器

标准关联容器没有remove成员函数,其删除操作的正确做法是使用erase

1
c.erase(1963);

这种做法兼具高效性与正确性,对数时间开销,同时关联容器的erase基于等价而非相等。(详见Effective STL 19)


删除所有符合条件的值

序列容器(连续内存容器与list)

我们只需要把remove改为remove_if

1
2
3
bool badValue(int);
c.erase(remove_if(c.begin(),c.end(),badValue),c.end());//连续内存容器
c.remove_if(badValue); //list

关联容器

对于关联容器,一般有两种方法来处理这一情况,一个容易编码,一个高效。

容易编码的方法

其原理为:remove_copy_if把我们需要的值拷贝到新容器中,然后把原容器内容与新容器的交换:

1
2
3
4
5
AssocContainer<int> c; 
...
AssocContainer<int> goodValues; //临时容器
remove_copy_if(c.begin(), c.end(), inserter(goodValues, goodValues.end()), badValue);
c.swap(goodValues); // 交换c和goodValues

这种方法的缺点是它拷贝了所有不删除的元素,直接造成了效率极低。

高效的方法

其直接从容器中删除元素,不过关联容器没有remove_if之类的成员函数,所以必须通过循环来完成,首先给出一个错误案例:

1
2
3
4
5
AssocContainer<int> c;
...
for (auto i = c.begin(); i!= c.end(); ++i) {
if (badValue(*i)) c.erase(i);//未考虑迭代器失效
}

为了避免迭代器失效,我们必须保证在调用erase之前就得到了c中下一个元素的迭代器,所以有正确写法如下:
1
2
3
4
5
6
AssocContainer<int> c;
...
for (auto i = c.begin(); i != c.end();/*nothing*/ ){
if (badValue(*i)) c.erase(i++); //如果需要删除,删除当前位置并自增迭代器
else ++i;//不需要删除则直接递增
}


删除元素后记录日志

关联容器

对于关联容器,只要在循环内部增加一条语句即可:

1
2
3
4
5
6
7
8
9
10
ofstream logFile; //日志文件
AssocContainer<int> c;
...
for (auto i = c.begin();i !=c.end();){
if (badValue(*i)){
logFile << "Erasing " << *i <<'\n'; //写日志文件
c.erase(i++); // 删除元素
}
else ++i;
}

连续内存容器

与关联容器不同,连续容器的erase不仅令当前迭代器失效,也同时令后面的迭代器失效。所以我们必须利用erase返回被删除元素之后的元素的迭代器的特性,完成操作:

1
2
3
4
5
6
7
for (auto i = c.begin();i != c.end();){
if (badValue(*i)){
logFile << "Erasing " << *i << '\n';
i = c.erase(i);//利用返回值,也是C++ primer描述容器删除时使用的方法
}
else ++i;
}

list

对于list,上述两种方法都行。


总结:

  1. 去除容器内有特定值的对象,连续容器使用erase-remove,list使用remove,关联容器使用erase
  2. 去除满足某个判定式的所有对象,连续容器使用erase-remove_if,list使用remove_if,关联容器则使用remove_cooy_if与swap,或者使用循环(记得后置递增)
  3. 需要在循环中做操作时,连续内存容器记得要利用erase返回值更新迭代器,关联容器同2。
<i class="fa fa-angle-left"></i>1…24252627<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4