Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

18.以独立语句将newed对象置入智能指针

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

问题实例

</br>
假设我们有一个函数用来揭示处理程序的优先权,另一个用来在根据优先级处理某个动态分配的对象。很自然地,我们认为需要使用RAII管理资源:

1
2
int priority();
void processWidget(shared_ptr<Widget> pw,int priority);

其执行操作如下:
1
processWidget(new Widget,priority());

上述代码不能通过编译,因为到shared_ptr的构造函数是explict构造函数,无法通过隐式转换构造,因此必须写为如下类型:
1
processWidget(shared_ptr<Widget>(new Widget),priority());

遗憾的是,尽管我们使用了RAII,但它照样可能导致资源泄漏。


问题说明

</br>
在调用process函数之前,编译器必须完成以下三件事:

  1. 调用priorty();
  2. 执行new Object;
  3. 调用shared_ptr构造函数

毫无疑问,2必然在3之前发生,关键在于无法确定1何时发生,也无法确定其是否会抛出异常。
举例而言,如果发生的顺序是213,并且在调用priorty()的时候抛出了一个异常,那new Widget返回的指针会遗失,因为在资源被创建和资源被转换为资源管理对象两个时间点间发生了异常干扰。


解决方案

</br>
解决方法很简单,就是分开执行:

  1. 以独立语句初始化RAII对象
  2. 将RAII对象作为参数传入函数
1
2
shared_ptr<Widget> pw(new Widget);
processWidget(pw,priority());

其能够正确实现的根本原因在于:编译器仅在单一语句内拥有决定执行先后次序的自由度。


总结

</br>
以独立语句将newed对象存入智能指针中,否则抛出的异常可能会导致资源泄漏。

18.避免使用vector<bool>

Posted on 2018-04-11 | In Effective STL

前言

</br>
vector<bool>只有两个问题:

  1. 它不是一个STL容器
  2. 它没有容纳bool

vector<bool>的特点

STL的一个必要条件

STL容器的一个要求是:如果c是stl容器,且支持operator[],则以下代码可编译:

1
T *p = &c[0]; // 无论operator[]返回什么,都可以用这个地址初始化一个T*

具体来说就是,如果你可以使用operator[]来得到Container<T>中的一个T对象,那你必然可以通过取它的地址而获得指向那个对象的指针。
看起来很正常的要求,但vector<bool>做不到。

vector<bool>的内存分布

vector是一个伪容器,并不保存真正的bool,而是打包bool以节约空间。确切的说,bool在vector内仅仅只占用了一个bit,我们无法创建指向单个bit的指针。

同样的,也不可能存在单个bit引用.为了解决这个问题,vector<bool>使用了proxy class(详见More Effective C++ 30),从本质上来说,vector看起来像这样:

1
2
3
4
5
6
7
template <typename Allocator>
vector<bool, Allocator> {
public:
class reference {...}; //proxy class
reference operator[](size_type n); //return a proxy object
...
}

这也说明了为什么上面的代码无法编译,因为返回的并不是bool类型,而是一个代理对象。


使用deque<bool>与bitset来替换vector

  1. deque<bool>
    deque提供了几乎所有vector所提供的成员函数(缺是reserve和capacity)。并且,deque<bool>是一个STL容器,它保存真正的bool值。
  2. bitset
    bitset不是一个STL容器,但它是C++标准库的一部分。其内存分布和vector<bool>差不多,其具体实现与应用可参照数据结构·Bitamap篇。

17.new与delete成对使用时必须形式相同

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

问题实例

1
2
string * stringarray = new string[100];
delete stringarray;

在上述代码中我们成对地使用了new与delete,似乎避开了资源泄露,但其实并非如此。
上文动态分配了100个string对象,却只删除了第一个,其它string对象的析构函数根本没有被调用。


new与delete

调用new与delete时发生了什么?

当你使用new的时候,有两件事会发生:

  1. 内存通过operator new被分配出来(new操作符详见Effective C++ 50 52)
  2. 针对此内存有一个或多个构造函数被调用

对应的,使用delete的时候也会发生两件事:

  1. 一个或多个析构函数被调用
  2. 通过operator delete释放内存

delete如何判定对象个数?

delete的最大问题在于:即将被删除的内存之内究竟存有多少对象?这决定了会调用多少次析构函数。换句话说,delete需要明确:被删除的指针究竟指向单一对象还是对象数组?
在编译器的具体实现中,单一对象的内存布局不同于数组的内存布局,数组的内存布局中记录了数组大小,这得以让delete函数知道需要调用多少次析构函数。它们的内存布局大概是这样:
image_1cap38n6l1a205v11tnn1t9017gu9.png-11.9kB

delete的正确使用

我们通过人为指定的方式让delete明确其操作:使用delete或delete[];
前者认定当前指向的是单一对象,后者则认定当前指向数组:

1
2
3
4
string* pstr1 = new string;
string* pstr2 = new string[100];
delete pstr1;
delete[] pstr2;

误用delete形式的后果

如果我们对pstr1使用delete[],结果未定义,可想而知它会误认为当前指向某一个对象数组,然后读取某块内存将其解释为数组大小,然后反复调用析构函数。

如果我们对pstr2没有使用delete[],结果亦未有定义,但肯定调用的析构函数不足。事实上,即使针对没有析构函数的内置类型,这种写法也是有害的。

new、delete与typedef

本节的规则十分简单,无非是成对使用new与delete时必须保证形式一致,读者想必会认为稍加注意不难做到。但对于某些重度typedef爱好者而言,还是要多加留心为是:

1
2
3
4
typedef string AddressLines[4];
string* pal = new AddressLines;//pal指向的是数组对象
delete pal;//error!
delete[] pal;

我认为C++语言应当尽量减少对数组使用typedef定义,在降低了可读性的同时还容易引发错误。在能够使用STL容器的地方坚决不使用动态数组。

17.swap去除多余容量

Posted on 2018-04-11 | In Effective STL

前言

</br>
当我们的容器内存储了海量的元素之后,可能在某个阶段会进行大量的删除,此时容器已经没有必要再持有过多的内存,为了避免浪费,应该使用某种shrink_to_fit的手法将其cap缩减到合适的大小(resize与reserve都无法减小cap)。

Swap技巧

Swap与shrink_to_fit

收缩容器的技巧在操作上十分简单:

1
2
3
4
class Sth{...};
vector<Sth> v;
...//Now v need to be shrinked to the fit capacity
vector<Sth>(v).swap(v);

该技巧的执行流程:

  1. 建立了一个临时变量,并调用拷贝构造函数复制了v中元素
    拷贝构造只使用了需要的内存,并没有分配所有内存
  2. 将临时对象与v交换。
  3. 完成此表达式后临时对象被析构。

同理,收缩string也是如此:

1
2
3
string s;
...
string(s).swap(s);

Swap与容器清除

Swap技巧同样也能够直接清除容器内部所有元素,并减小其容量至最小值,其原理是使用刚初始化过的临时变量与需要清除的vector或string交换,如下所示:

1
2
3
4
5
vector<Sth> v;
string s;
...
vector<Sth>().swap(v);
string().swap(s);

16.在资源管理类中提供对原始资源的访问

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

前言

</br>
资源管理类是排除资源泄漏的壁垒,是良好设计系统的根本性质。但有许多api直接指涉资源,有时不得不绕过资源管理对象直接访问原始资源。


问题实例

</br>
举例而言,假设有一个shared_ptr保存了factory函数生成的资源,

1
shared_ptr<Investment> spInv(createInvestment());

但现在API如下:
1
int daysHeld(const Investment* pi);

我们肯定没法把spInv传入进去,因为该函数需要的是Investment*指针,而不是shared_ptr<Investment>类型的对象。


解决方案

</br>
有两个做法可以把RAII对象转换为原始资源:显式转换和隐式转换。

显式转换

shared_ptr与auto_ptr都提供一个get成员函数,用来执行显式转换。该函数返回智能指针内部的原始指针(的副本)。我们可以写作:

1
daysHeld(spInv.get());

另外,智能指针并非仅有显式转换,它们重载了解引用操作符(operator*,operator->),这两种操作符执行了隐式转换。(我以为是调用了get().opertaor*,inline之后调用成本也不是很高,不过生成的文件可能变大了)

隐式转换

对于需要频繁转换的RAII classes来说,不断调用get()未免繁琐,因此我们可以提供一个隐式转换操作符。举例而言:

1
2
3
4
5
6
7
8
9
class Widget {
public:
explict Widget(WidegtHandle wh):w(wh) {}
~Widget() {release(w);}
operator WidegtHandle() const {return w;}//隐式转换
...
private:
WidgetHandle w;
}

如此一来,在API中直接可以将RAII对象作为参数传入。
这样有时候会发生崩盘,比如原意是拷贝一个RAII对象,但实际上却(发生隐式转换)拷贝了资源。


转换接口的合理性

</br>
是否应该提供转换,以及哪一种转换取决于你是否需要转换或者转换的频率。要记住一切操作都是为了令接口更加灵活且不易被误用。
你可能会认为转换接口与封装发生了矛盾。但事实并非如此,RAII对象负责资源管理,而非起封装数据的作用。作为类的设计者,我们有责任隐藏用户不需要了解的内容,同时提供给用户一切他们需要的东西。


总结

  1. APIs往往要求访问原始资源,所以每一个RAII class应该提供一个获取原始资源的函数。
  2. 显式转换比较安全,但隐式转换对客户比较方便,当然也更容易出错。

16.STL容器与C API

Posted on 2018-04-10 | In Effective STL

获取指针

</br>
假设有一个vector对象v,而你需要得到一个指向v中数据的指针,这让v可以被当作一个数组在C中使用。
我们只需要使用&v[0]即可获得指向首元素的指针。如果是string,请使用s.c_str().
实际上这样做可能会有一些问题,具体而言就是v可能是空的,所以需要先对v进行empty判定。

有人说可以用begin这种迭代器来代替&v[0],实际上这完全是胡说八道,因为并不总是能做到迭代器和指针之间的互相转换。
&*v.begin()倒是真的可以等价于&v[0],但这种写法除了让别人一目了然你的智力水平外并没有其他好处。

有人会疑惑为什么vctor就可以直接取址,而string不行,原因有以下两点:

  1. string的数据并没有保证被存储于连续内存(详见 Effective STL 15)
  2. string并不保证以null结尾

因此,string必须使用c_str:

1
2
void doSomething(const char *pString);
void doSomething(s.c_str());

C API初始化STL容器

用C API返回的元素初始化vector

1
2
3
4
5
// C API数需要一个指向数组的指针,数组最多有arraySize个double
// 功能为向数组内部写入数据,返回写入数据的个数
size_t fillArray(double *pArray, size_t arraySize);
vector<double> vd(maxNumDoubles);,
vd.resize(fillArray(&vd[0], vd.size()));//数据写入后调整大小

用C API返回的元素初始化string

上述写法并不能应用于string类型,因为只有vector保证了与数组具有相同内存分布。(顺序表)
但用用C API初始化string也不难,具体来说就是先初始化一个vector<char>,然后再用vector初始化string:

1
2
3
4
size_t fillString(char *pArray, size_t arraySize);
vector<char> vc(maxNumChars);
size_t charsWritten = fillString(&vc[0], vc.size());
string s(vc.begin(), vc.begin()+charsWritten);

用C API返回的元素初始化STL中的任何容器

因为和数组内存分布一致的只有vector,所以我们的策略十分简单:把数组内的元素传入vector,然后再用vector初始化STL容器:

1
2
3
4
5
6
size_t fillArray(double *pArray, size_t arraySize);
vector<double> vd(maxNumDoubles);
vd.resize(fillArray(&vd[0], vd.size()));
deque<double> d(vd.begin(), vd.end()); // 拷贝数据到deque
list<double> l(vd.begin(), vd.end()); // 拷贝数据到list
set<double> s(vd.begin(), vd.end()); // 拷贝数据到set


STL容器传递数据至C API

</br>
从上文中自然能领会到反向传递的方法:用STL容器内的元素初始化vector,然后再用vector传入C API:

1
2
3
4
void doSomething(const int* pints, size_t numInts);
set<int> intSet; //要传递给API数据的set
vector<int> v(intSet.begin(), intSet.end());
if (!v.empty()) doSomething(&v[0], v.size());

15.资源管理类中的copying行为

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

前言

</br>
在上一节的末尾,我已经提到RAII和智能指针并无直接关联,并且给出了例证。事实上,smart_ptr适用于管理heap-based资源。如果你所需要的资源不是heap-based,往往需要建立自己的资源管理类。


问题实例

</br>
假定我们有类型为murtex的互斥器对象,共有lock与unlock两个函数作用其上:

1
2
void lock(Mutex* pm);//锁定互斥器
void unlock(Mutex* pm);//解除锁定

基于RAII思想,我们建立一个资源管理类Lock:
1
2
3
4
5
6
7
8
class Lock{
public:
explicit Lock(Mutex* pm)
:mutexPtr(pm) {lock(mutexPtr);}
~Lock() {unlock(mutexPtr);}
private:
Mutex *mutexPtr;
}

如果资源管理类发生了复制行为
1
2
3
Mutex m;
Lock m1(&m);
Lock m2(m1);

将会产生何种后果?


资源管理类的复制行为

</br>
常见做法有以下两种:

  1. 禁止复制
    有时允许RAII对象被复制并不合理,所以我们应该禁止它们的copy行为。如何禁止,详见Effective C++ 6
  2. 对底层资源使用引用计数
    这种方法的最佳实现就是使用shared_ptr。通常我们只要内含一个shared_ptr成员变量,RAII classes便可以实现出reference-counting copying行为

引用计数在RAII中的应用

针对Lock class,可以把接受的指针从mutex*,转为shared_ptr<Mutex>。
但shared_ptr的default析构行为是“引用次数为0时删除所指物”,也就是析构时delete指针。(我们仅仅需要解除互锁)为了改变默认行为,我们必须手动地将删除器unlock作为shared_ptr的第二参数。具体实现如下:

1
2
3
4
5
6
7
8
class Lock{
public:
explicit Lock(Mutex* pm)
:mutexSP(pm,unlock) {lock(mutexSP.get());}
//无需析构函数
private:
shared_ptr<Mutex> mutexSP;
}

Lock类此时无需再声明析构函数,因为当shared_ptr析构时,unlock就会被调用。


总结

  1. 复制RAII对象必须一并复制它所管理的资源,资源的copying行为决定了RAII对象的copying行为。
  2. 对于RAII class copying,其常见做法无非是禁止拷贝和引用计数。

15.string实现的多样性

Posted on 2018-04-10 | In Effective STL

(本章并未完全理解 建议结合More Effective C++ 29 写一个带有引用计数的string类以加深认识)

前言

</br>
sizeof(string)返回多少?不同的实现得到的答案不同。有的string大小等价于char*,而有的则是它的7倍大小。


string的内部组成

每一个string的实现都容纳了以下信息:

  • 字符串大小(size)
  • 该字符串所需的内存容量(capacity)
  • 字符串的值

另外,它们可能含有

  • allocator的拷贝
  • 值的引用计数

不同的string实现以不同的方式把这些信息放在一起,为了证明此言非虚,下文给出了string的四种实现


具体实现

Implement A

image_1can0lrfspfg12ulrsh1f66nu59.png-28.3kB
在实现A中,每个string有4个部分,配置器拷贝,大小,容量,以及一个指针。该指针指向引用计数以及字符串值的缓冲区。如果使用默认allocator,该实现的size是指针的四倍,而自定义allocator的话则会变得更大一些。

Implement B

image_1can0qdv7lmfirra5r1vcbhasm.png-32.4kB
实现B的string对象和内置指针一样大(这里假定使用的是默认allocator),因为其内存结构中确实只包含一个指针。该指针指向的对象包含字符串的大小、容量和引用计数,以及容纳字符串值的动态分配缓冲区的指针。对象也包含在多线程系统中与并发控制有关的一些附加数据。我们把这些数据标注为“other”.

Implement C

image_1can120f91jpa1sh91goa1j7a115113.png-22.7kB
因为与allocator无关,实现C的大小总是等价于指针,X的部分是一些关于值可共享性的数据。(详见More Effective C++ 29)

Implement D

image_1can16lj2647uae1g5gkrc1muo1g.png-43.1kB
实现D的大小是指针的7倍。该实现没有使用引用计数,但每个srtring包含了一个最多可以表现15个字符的内部缓冲区。所以小字符串可以直接保留在对象中,如果大于15,则缓冲器的第一部分变成一个指向动态分配位置的指针,字符串的值存放于该内存中。(不可避免地浪费了一部分内存)


不同实现下的动态分配

1
string("abc");

当写下上述的表达式,我们可以清楚判断出D没有作动态分配,AC则做了一次,B则作了两次(分配对象,分配对象所指的字符缓冲)

14.以对象管理资源

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

问题实例

</br>
假设我们有一个关于投资的继承体系,其基类为

1
class Investment {...};

我们惯用factory function来产生特定的对象,并且有责任释放该对象,现有某个f函数执行了该操作:
1
2
3
4
5
void f(){
Inverstment* pInv = createInvestment();//调用factory function
...
delete pInv;
}

上述程序看起来没毛病,但是实际使用中可能会发生内存泄漏。原因可能但不仅仅如下:

  1. …处有了一个过早的return
  2. delete位于某个循环或者判断内并未能执行到
  3. …处抛出了一个异常

我们泄漏的不仅仅只是内含投资对象的那一块内存,还包括任何它所保存的资源。

当然,完美的逻辑可以避免上述问题,但关键在于代码总是处于不断修改和升级的过程中,单纯依赖delete语句总被执行是不可能的。


解决方案

</br>
为了确保资源总是被释放,我们需要将资源放进对象内。当控制流离开程序块时,该对象的析构函数则会自动释放那些资源。

auto_ptr

许多资源被动态地分配于heap内,然后被用于单一区块或函数内。这些资源应该在控制流离开那个区块或者函数时被释放。标准库提供的auto_ptr正是针对这种形式而设计的。
auto_ptr是一个智能指针,其析构函数自动对其所指对象调用delete。(auto_ptr的具体描述及特性,详见C++ Primer)
其具体使用大致如下:

1
2
3
4
void f(){
auto_ptr<Investment> pInv(createInvestment());
...
}

以对象管理资源的关键思路在于:

  1. 获得资源后立刻放入管理对象。
    这也被称为“资源获取之日即是初始化之时”(resource acquisition initialization;RAII)
    有时候我们也把拿到的资源用来赋值而不是初始化。但不管怎么说,每一笔资源总在获得之时就被立刻放入管理对象中。
  2. 管理对象(managing object)运用析构函数确保资源释放。
    除非管理对象析构函数里会抛出异常,否则资源总能被正确释放。针对唯一的这一种例外,Effective C++ 8 对此有详细的描述

auto_ptr的缺陷

如果通过copy构造函数或者copy assignment操作符来复制auto_ptr,那它们会变成null,新复制的智能指针将获得资源的唯一使用权。
这种诡异的复制行为直接导致STL容器不能使用auto_ptr(具体论述见Effective STL 8)

shared_ptr

auto_ptr的替代方案是用引用计数型智能指针。也就是reference-counting smart pointer(RCSP)。它持续地追踪有多少对象指向某个资源,没人指向就释放资源。

shared_ptr的缺陷

无法打破环状引用(例如两个没人用的对象彼此互指,编译器判断对象仍然处于使用状态)。

智能指针的缺陷

智能指针的析构函数中做的是delete而非delete[],这意味着在动态分配而得到的array身上用它们效果感人。

1
shared_ptr<int> spi(new int[1024]);//析构时只释放了一个

但这个问题不大,Effective STL 13提及,应当尽可能使用vector与string替换动态数组。


总结

</br>
本节虽然大量提及了智能指针,但实际上RAII和智能指针并无关联,考虑Effective STL 12中提到的多线程实例,其中Lock对象也负责了资源的管理。

14.使用reverse避免重新分配

Posted on 2018-04-10 | In Effective STL

(本章内容笔者认为可通过合理的初始化完成)

vector与string可以动态增长,其增长操作大概等价于realloc,分为4个部分(关于具体实现,C++ Primer 以及 数据结构·向量篇 均有涉及)

  1. 分配当前cap*2的内存
  2. 把元素拷贝到新内存起始处
  3. 销毁旧有对象
  4. 收回原内存

这些步骤的开销极大,并且执行了这些后同时还需要更新现在正在使用的迭代器或者指针之类。reserve可以最大幅度避免这些开销。

在说reverse之前,首先复习4个vector或string的成员函数

  • size:当前容器内的元素个数
  • capacity:最大容纳个数
  • resize:强制将元素个数变为n个,调用构造或者析构,不影响cap.如果n>cap,则realloc.
  • reserve:将cap改为至少n,如果n<cap,无动作,否则realloc.同时不改变size.

为了避免大量的realloc操作,我们可以一旦建立容器就立刻reserve到一个合适的大小(为啥不好好初始化)

1
2
vector<int> v;
for (int i = 1; i <= 1000; ++i) v.push_back(i);

上述操作大约会导致2到10次重新分配。
1
2
3
vector<int> v;
v.reserve(1000);
for (int i = 1; i <= 1000; ++i) v.push_back(i);

这则不会导致重新分配。

通常有两种情况可以使用reverse来避免重新分配:

  1. 你了解容器最终大致的元素个数(不如好好初始化 然后赋值 不过一旦估算少了会越界)
  2. 保留你可能需要的最大空间,添加完成后修整掉多余的容量。(修整操作见Effective STL 17)
<i class="fa fa-angle-left"></i>1…232425…27<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