Effective STL~1 容器(条款1~12)

第1条:慎重选择容器类型

C++提供容器

  • 标准STL顺序容器:vector、string、deque、list;
  • 标准STL关联容器:set、multiset、map和multimap;
  • 非标准顺序容器slist和rope。

slist(单向链表), rope(可持久化平衡树)是SGI STL(STL的一个实现)的内容,属于STL的扩展,C++11已结有更好的替代品,slist可以用forward_list替代,rope是“重型”string。(条款50)
参见:https://www.bbsmax.com/A/QV5ZZ2bV5y/

  • 非标准的关联容器:hash_set、hash_multiset、hash_map、hash_multimap;(条款25)
  • vector作为string的替代;(条款13)
  • vector作为标准关联容器的替代。(条款23)
  • 几种标准的非STL容器:数组、bitset、valarray、stack、queue、priority_queue。(条款16,18)

vector,动态数组,默认应该使用的序列类型;
list,双向循环链表,当需要频繁插入和删除数据时,应使用的序列类型;
deque,双端队列,头部和尾部插入和删除数据。

2类STL容器

连续内存容器(contiguous-memory container):又称基于数组的容器(array-based container),把元素存放在一块或多块(动态分配的)内存中,每个块内存有多个元素,按地址顺序存储。这种类型容器有标准容器vector、string、deque,也有非标准的rope。

基于节点的容器(node-based container):在每个(动态分配的)内存块中只存放一个元素,元素的插入和删除只影响指向节点的指针,不影响节点内容。这类型容器有链表容器,如list,slist;还有标准的关联容器(通常是AVL树实现)。

如何选择容器?

主要从 1)插入、删除元素的方式和效率;2)查询元素方式和效率;3)是否支持引用计数;4)是否关心排序;5)插入、删除异常,是否需要回滚;6)是否一定需要标准STL(是否支持扩展STL)。等几个方面考察,以决定使用哪个容器。

[======]

第2条:不要试图编写独立于容器类型的代码

当编写自己的容器、迭代器、算法时,不要把容器的概念泛化
比如为vector保留deque或list特有的功能代码,这部分代码称为独立于容器的代码(container-independent code)。为一种容器编写兼容另一种容器的泛化代码,通常是无意义的。比如,只有顺序容器才支持push_back/push_front,只有关联容器才支持count和lower_bound(map::lower_bound是返回第一个指向key >= 参数的元素),为其他容器编写这些是没有意义的。
如果要编写支持大多数通用的顺序容器的代码,那么就只能使用它们的交集,意味着放弃reserve、capacity等特定容器的操作。

建议的方式:为不同容器编写不同代码。

为不同容器类型使用封装技术,定义新类型名称

对容器类型和迭代器类型使用类型定义(typedef for 具体类型,using for template)

// 不要使用这种又长又臭的绑定具体类型的代码
class Widget{ ... };
vector<Wdiget> vw;
Widget bestWidget;
... // set bestWidget
vector<Widget>::iterator i = find(vw.begin(), vw.end(), bestWidget);

而要这样写:

// 为vector<Widget>和其迭代器定义类型别名
class Widget{ ... };
typedef vector<Widget> WidgetContainer;
typedef WidgetContainer::iterator WCIterator;
WidgetContainer cw;
Widget bestWidget;
...// set bestWidget
WCIterator i = find(cw.begin(), cw.end(), bestWidget);

好处是:
1)当哪天发现vector不适合用来作为WidgetContainer时,可以很容易修改。
2)将类型名称长度简化,节省编写代码时间。
3)将选择的容器隐藏起来,封装到class中,客户无需关心这些细节。

[======]

第3条:确保容器中的对象拷贝正确而高效

STL容器存放(如insert或push_back)、取出(如front或back)的是对象的拷贝。排序算法如sort,全排列next_permutation或prev_permutation,移除一段元素remove,移除相邻重复元素unique或类似操作,旋转rotate或反转reverse,等等操作,对象将会被移动(拷贝)。

对象的拷贝利用的是copy constructor,或者copy assignment operator。如果class没有定义,当需要时,编译器会合成默认版本。

存在的问题:
1)大量容器对象的拷贝将造成内存和时间的浪费,拷贝对象可能会成为性能瓶颈。
2)在继承体系下,拷贝动作会导致剥离(slicing)。即将derived class对象放入base class对象的容器中,会导致derived class特有的那部分数据丢失,而且将无法使用virtual函数。

如何解决?
让容器存放指针而不是对象。指针的拷贝速度非常快,而且支持调用virtual函数。不过,指针容器也存在一些让人头疼的问题(见条款7和条款33)。用智能指针(smart pointer)来替代普通指针,见条款7。

STL容器设计目标
STL容器的设计思想是为了避免不必要的拷贝,而非疯狂拷贝。

比如,我们使用数组来存放若干Widget对象,不得不多次调用元素的默认构造函数构造对象

Widget w[MAX_NUM]; // 创建MAX_NUM个Widget对象的数组,每个对象都用默认构造函数构造对象
如果换成STL容器vector,创建时并不会调用任何元素的构造函数

vector vec; // 创建vector时,包含0个Widget对象,当需要时尺寸会增长

[======]

第4条:调用empty而不是检查size()是否为0

因为empty对所有标准容器都是常数时间O(1),常被实现为内联函数(inline function)。而对于一些list实现,size需要遍历链表,耗费线性时间O(n)。

list<int> lst;
if (lst.size() == 0) { ... } // 不推荐使用

if (lst.empty()) { ... } // 推荐使用

[======]

第5条:区间成员函数优先于与之对应的单元素成员函数

假设我们要将vector v2的后半部内容,拷贝到vector v1,要怎么办?
1)最直接想到使用显示循环遍历v2,然后push_back到v1:

vector<Widget> v1, v2; // 假设v1,v2是Widget的vector
...
v1.clear();
for (auto it = v2.begin() + v2.size() / 2; it != v2.end(); ++it) {
    v1.push_back(*it);
}

然而,条款43告诉我们要尽量避免写显式的循环,因为这样的代码会比assign多做很多工作。

2)或许,会想到更简洁的办法copy,避免显式循环

v1.clear();
copy(v2.begin() + v2.size() / 2, v2.end(), back_inserter(v1));

// 上面copy 实际上等价于
v1.insert(v1.end(), v2.begin() + v2.size() / 2, v2.end());

3)还有效率更高的办法,使用member function assign

v1.clear();
v1.assign(v2.begin() + v2.size() / 2, v2.end());

为什么使用区间member函数,如assign,而不是其相应的单元素成员函数,如push_back,单元素insert?
1)使用区间member函数,通常可以少些代码;
2)使用区间member函数通常会得到意图清晰,和更加直接的代码;

单元素版本insert在3个方面影响了效率,如果使用区间版本的insert,则3种影响都不存在。
1)不必要的函数调用。把元素插入容器,会导致多次调用insert函数;而区间版的insert,只做了一次函数调用(内联可能避免)。
2)把已有元素频繁移动到插入点之后;区间版的insert只会移动一次(若干个元素)。
3)重复的单元素插入,可能导致如vector内存重新分配,然后拷贝元素到新内存中,造成大量时间浪费;区间版的insert知道最终会插入多少元素,不会频繁申请内存、拷贝数据。

为什么assign比copy更高效?
1)copy掩盖了insert数据的事实,而直接调用insert member函数清晰表达了这点;
2)copy模板被实例化后,基于copy的代码和使用显式循环的代码几乎相同。因为插入迭代器也是调用的push_back。

哪些member函数支持区间操作?

  • 区间创建:用1个标准容器区间,构造另1个标准容器
// 所有标准容器都提供构造函数形式
container::container(InputIterator begin, InputIterator end);

// 顺序容器区间创建示例
list<int> v2 = { 1,2,3 };
list<int> v1(v2.begin(), v2.end());
  • 区间插入:insert第1个参数指明在哪插入,第2、3个参数指明源区间的开始和结束位置
// 顺序容器提供insert区间形式
void container::insert(iterator position, InputIterator begin, InputIterator end);
// 关联容器提供insert区间形式,省去了position参数
void container::insert(InputIterator begin, InputIterator end);

// 顺序容器insert区间示例
list<int> v2 = { 1,2,3 };
v1.insert(v1.end(), v2.begin(), v2.end());
  • 区间删除:erase对于顺序和关联容器,返回值不同
// 顺序容器形式
iterator container::erase(iterator begin, iterator end);
// 关联容器形式
void container::erase(iterator begin, iterator end);

注意:erase内存反复分配,对vector和string不适用,后者调用erase时内存大小不变,因为后者能自动扩展内存,没有必要反复释放、申请内存。

  • 区间赋值:assign
// 所有标准容器的区间赋值形式
voidcontainer::assign(iterator begin, iterator end);

注意:assign只是赋值,并不会改变内存大小。使用assign的前提是调用对象有足够内存大小。

[======]

第6条:当心C++编译器最烦人的分析机制

一个常见的错误:

class Widget{ ... } // 假定Widget有default构造函数
Widget w();         // 这句是做了什么?

上面的语句并非调用default构造函数来定义Widget对象w,而是声明了一个名为w的函数,不带任何参数,并返回Widget对象。
这是因为一个C++普遍规律:将表达式尽可能解释为函数声明

声明一个double参数并返回int的函数:

// 下面3个都是函数f的等价声明式
int f(double d);   // 常用的函数声明
int f(double (d)); // d两边括号被忽略
int f(double);     // 参数名被忽略

声明一个函数,接受的参数是一个指向不带任何参数的函数指针,参数指向的函数返回double:

// 下面3个都是函数g 的等价声明式
int g(double (*pf)()); // g以指向函数的指针为参数
int g(double pf());    // pf为隐式指针
int g(double ());      // 省去参数名

我们再看一个例子:
假设我有一个存放int整数的文件,想把这些整数都拷贝到一个list中。下面做法能实现码?

ifstream dataFile("ints.data");
list<int> data(istream_iterator<int>(dataFile), istream_iterator<int>()); // 本想定义对象data,然而此处声明了函数data

想法是将一对istream_iterator传入list的区间构造函数,从而把文件中的整数全部拷贝到list。
然而,并不会如愿,因为第二句声明了一个函数data,返回值是list,函数data包含2个参数:
1)第一个参数是dataFile 类型是istream_iterator,dataFile两边的括号是多余的,可以忽略;
2)第二个参数没有名称,是一个指向不带参数的函数指针,该函数返回一个istream_iterator

如何解决这个问题?让编译器将data识别为对象定义?
方法1)通过为“形式参数”加上括号,让其非法,从而绕过分析机制。

list<int> data((istream_iterator<int>(dataFile)), istream_iterator<int>()); // 形参用"()"括起来是非法的,从而避免被解释为函数声明

缺点:并非所有编译器都支持,部分编译器可能会报错。

方法2)在对data的定义中,避免使用匿名istream_iterator对象,而是给这些迭代器一个名词:

ifstream dataFile("ints.data");
istream_iterator<int> dataBegin(dataFile);
istream_iterator<int> dataEnd;
list<int> data(dataBegin, dataEnd);

可以有效避免产生二义性。

[======]

第7条:如果容器中包含了通过new操作创建的指针,切记在容器对象析构前将指针delete掉

STL容器自身析构时,会自动释放包含的对象。然而,当容器存储的是指针时,却不会自动析构指针所指对象,也不会调用delete。

{
    Widget{...};
    vector<Widget*> vec;
    for (int i = 0; i < VEC_NUM; ++i) 
        vec.push_back(new Widget);
    ... 
} // 会发生Widget的泄漏

为避免发生内存泄漏,可通过以下2种方式解决:
1)在容器释放前,主动删除指针所指对象
为避免显式循环,以用for_each为例,释放vector

// 形式1:继承自unary_function,将class变成一个函数对象。适用于C++11以前,C++11以后不推荐,C++17以后移除
template<typename T>
struct DeleteObject : public unary_function<const T*, void> {
       void operator()(const T* ptr) const
       {
              delete ptr;
       }
};
// 形式2:直接定义一个可调用对象,重载operator()。适用于C++11以后
template<typename T>
struct DeleteObject2{
       void operator()(const T* ptr) const
       {
              delete ptr;
       }
};
// 形式3:定义lambda表达式。适用于C++11以后

// 客户端使用3种形式释放容器指针所指对象
// 3种形式作用是等价的
void f()
{
    ...
    for_each(vec.begin(), vec.end(), DeleteObject<int>());
    for_each(vec.begin(), vec.end(), DeleteObject2<int>());
    for_each(vec.begin(), vec.end(), [](int *p) { delete p; }); // lambda表达式
}

上面的做法有一个缺陷:通过class template指明了要删除对象的类型如string,没有virtual析构函数,而实际存储的是指向string派生类对象的指针,这样通过指针删除派生类对象会产生不确定行为。

// 错误示例:通过基类指针delete删除派生类对象,而基类又没有virtual析构函数
// 虽然不建议继承没有virtual析构函数的class,但有人却这么做了,而编译器也不会阻止
class SpecialString: public string { ... };

void f()
{
    deque<SpecialString*> dssp;
    ...
    for_each(dssp.begin(), dssp.end(), DeleteObject<string>()); // 会导致不确定行为!因为基类string没有virtual析构函数,通过基类的指针删除派生类对象
    for_each(dssp.begin(), dssp.end(), DeleteObject<SpecialString>()); // OK
}

我们没办法避免有人设计继承自没有virtual析构函数的class,但可以确保容器实际存放的类型与DeleteObject函数对象中要delete的对象类型保持一致。那就是将class template修改为function template。

// 形式4:形式1和2的改进,以函数模板形式定义可调用函数对象,参数类型由实参决定
struct DeleteObject {
    template<typename T> // 注意这里的变化:将类模板修改成了函数模板
    void operator()(const T* ptr) const
    {
        delete ptr;
    }
};

void f()
{
    deque<SpecialString*> dssp;
    for_each(dssp.begin(), dssp.end(), DeleteObject()); // OK: 实参是什么类型,函数模板参数T就是什么类型
}

2)使用智能指针,自动释放对象。
方式1)还存在异常安全的问题,比如SpecialString创建后、for_each调用前就抛出异常,则会发生资源泄漏。而使用引用计数功能的智能指针,可以避免这个问题,也无需手动释放内存。

void f()
{
    typedef shared_ptr<Widget> SPW;
    
    vector<SWP> vwp;
    for (int i = 0; i < VEC_NUM; ++i)
        vwp.push_back(SPW(new Widget)); // 容器中存放 shared_ptr<Widget>,容器销毁时,由于引用计数归0,所绑定对象也会自动释放
    ...
}

[======]

第8条:切勿创建包含auto_ptr的容器对象

auto_ptr 自C++11后,已被丢弃。建议使用unique_ptr替代。
原因:
1)auto_ptr是不可移植的。

2)auto_ptr会导致奇怪的行为。
拷贝一个auto_ptr时,所指对象所有权被移交给拷入的auto_ptr上,自身被置为NULL。=> 拷贝一个auto_ptr意味着改变它的值。
例,

// auto_ptr之间的拷贝会导致奇怪的行为
auto_ptr pw1(new Widget>; // pw1指向一个Widget对象
auto_ptr pw2(pw1); // pw2指向pw1的Widget;pw1被置为NULL

pw1 = pw2; // pw1指向pw2的Widget;pw2被置为NULL

[======]

第9条:慎重选择删除元素的方法

删除容器中元素取决于具体的容器类型,推荐的删除元素方法概括:

  • 要删除容器中特定值的所有对象

如果容器是vector、string或的确,则使用erase-remove;
如果容器是list,则使用list::remove;
如果容器是一个标准关联容器,则使用member函数erase;

  • 要删除容器中满足特定判别式(条件)的所有对象

如果容器是vector、string或deque,则使用erase-remove_if;
如果容器是list,则使用list::remove_if;
如果容器一个标准关联容器,则使用remove_copy_if和swap,或者写一个循环来遍历容器中的元素,当把迭代器传给erase时,要对它进行后缀递增;

  • 要在循环内部做某些(除删除对象外的)操作

如果容器是一个标准顺序容器,则写一个循环来遍历容器中的元素,记住每次用erase时,要用它的返回值更新迭代器;
如果容器是一个标准关联容器,则写一个循环来遍历容器中的元素,记住当把迭代器传给erase时,要对迭代器做后缀递增;

删除特定值的所有对象

例如,对于连续内存的顺序容器(vector、deque或string),最好的办法是用erase-remove(条款32):

Container<int> c;
c.erase(remove(c.begin(), c.end(), 100)); // 当c是vector、string、deque时,erase-remove习惯用法是删除特定值元素的最好办法

对于(非连续内存)list、forward_list,上面方法也适用,不过使用list::remove更有效(条款44)。

c.remove(100);

对于关联容器,应该使用erase。因为关联容器没有member函数remove(条款22),使用remove算法可能会覆盖容器的值(条款32),同时破坏容器。

c.erase(100);

关联容器member函数erase的2个优点:1)效率高,需要O(logn)时间开销。2)基于等价而不是相等(见条款19)。

删除满足特定判别式(predicate)的对象

见条款39,。使用下面的判别式(predicate)返回true的每个对象:

bool badValue(int x); // 返回x是否为“坏值”

对于顺序容器(vector、string、deque、list、forward_list),用remove_if做特定判别式

// 当c是vector、string、deque时,remove_if + erase是删除使badValue返回true的对象的最好办法
c.erase(remove_if(c.begin(), c.end(), badValue(), c.end());
// 当c是list时,remove_if是删除使badValue返回true的对象的最好办法
c.remove_if(badValue);

对于关联容器(map、set),有两种办法:
1)用remove_copy_if 将需要的值拷贝到新容器,然后把原来容器和新容器互换(swap)。
优点是易于编码,缺点是效率低,因为要拷贝所有不被删除的元素。

AssocContainer<int> c;
AssocContainer<int> goodValues;
...
// 保存不被删除的元素到新容器goodValues
remove_copy_if(c.begin(), c.end(), insert(goodValues, goodValues.end()), badValue); 
// 交换c和goodValues
c.swap(goodValues);
2)直接擦除要删除的元素
代码简单,但容易出错

AssocContainer<int> c;
...
for (auto i = c.begin(); i != c.end(); ) {
    if (badValue(*i)) 
        i = c.erase(i); // 等价于 c.erase(i++);
    else ++i;
}

因为成员函数erase删除元素时,原来的迭代器i就被破坏了,并且erase返回的迭代器代表的就是删除元素的下个位置。因此对要删除元素的迭代器i++,或者让i = c.erase(i) 都是可行的。

每次删除对象时,都写log记录

对于连续内存的顺序容器(vector、string、deque),不能再用erase-remove惯用方法,因为无法用erase或remove向日志中写信息。对这类容器也不能单独调用erase,不仅会使被删除元素的迭代器无效,也会使被删除元素之后的所有迭代器都无效。用i++、++i形式,也是一样无效。可以利用erase的返回值,指向被删除元素的下一个元素的迭代器。

for (auto i = c.begin(); i != c.end(); ) {
    if (badValue(*i)) {
        logFile << "Erasing " << *i << '\n';
        i = c.erase(i); // 把erase返回值赋值给i,使i值保持有效
    }
    else ++i;
}

这种方法对非连续内存的顺序容器、关联容器也都适用。

关于std::remove,std::remove_if,详见:https://www.cnblogs.com/fortunely/p/15694743.html

[======]

第10条:了解分配子(allocator)的约定和限制

STL内存分配子allocator负责分配和释放原始内存(raw memory)。

分配子(allocator)为类型为T的对象提供2个类型定义:allocator::pointer,allocator::reference,分别提供对指针和引用的类型定义。

class Widget { ...};
allocator<Widget>::pointer p; // p的类型为Widget*
allocator<Widget>::reference r = &p; // r的类型为Widget&

STL的实现可以假定,所有属于同一种类型的分配子对象都是等价的,并且相互比较的结果总是相等的。
什么意思?请看下面的代码:

list<Widget, SAW> l1;
list<Widget, SAW> l2;
// ...
l1.splice(l1.begin(), l2); // 将l2的节点移动到l1前面

但是,假定同一类型的分配子是等价的,就会有非常苛刻的限制:意味着可移植的分配子对象,在不同的STL下都能正确工作,是不能有状态(state)的。简单来说,就是可移植的分配子不可以有任何非静态的数据成员,不能有会影响其行为的数据成员。这是程序员应该遵守的规则,而非编译器要求。

C++标准指出:
鼓励实现者提供......支持不相等实例的库。在这样的实现中,......当对分配子实例的比较不相等时,容器和算法的语义取决于该实现。

----这有利于STL实现者,但对STL使用者毫无帮助。
这段说明适用条件:
1)你知道你正在使用的STL实现支持不等价的分配子;
2)你愿意钻到它们的文档中,以决定该实现锁定义的“不相等”的分配子行为对你来说是否可以接受;
3)你不考虑把你的代码移植到那些利用了C++标准明确给予的扩展能力的实现;

allocator<T>::allocate与operator new

相同点在于:分配原始内存。
不同点在于:
1)operator new指明具体要分配多少byte内存,而allocator::allocate只需要提供需要多少个T对象的内存。
函数原型见:

void* operator new(size_t bytes);
pointer allocator<T>::allocate(size_type numObjects);

2)返回值不同,operator new返回void,allocator::allocate返回T。这里其实是蓄意欺骗,因为allocator::allocate返回的指针并没有指向T,此时T的构造函数尚未调用,它隐含假设调用者会在返回的内存中构造一个或多个T对象(调用allocator::construct,uninitialized_fill,或者uninitialized_copy等)。也有特殊情况,vector::reserve,string::reserve这种构造可能没发生过。可以理解成两者返回值区别关于未初始化内存的概念模型的一个转变。

为什么很少使用T的分配子?

考虑自定义llist容器,用来表示一个链表,链表节点ListNode。当实例化模板llist时,T的类型为int,但是我们实际上需要的是ListNode的分配子,而不是T的分配子。

template <typename T, typename Allocator = std::allocator<T>>
class llist {
private:
       struct ListNode // 链表节点
       {
              T data;
              ListNode *prev;
              ListNode *next;
       };
       Allocator alloc; // 类型为T的对象的分配子
};

使用rebind嵌套分配子,就可以得到ListNode的分配子

// rebind嵌套分配子声明
template <typename T>
class myallocator {
public:
       template<typename U>
       struct rebind
       {
              using other = myallocator<U>;
       };
};

template <typename T, typename Allocator = myallocator<T>>
class llist {
private:
       struct ListNode
       {
              T data;
              ListNode *prev;
              ListNode *next;
       };

       using _allocator_type = typename Allocator::template rebind<ListNode>::other; // ListNode分配子类型
       _allocator_type alloc; // ListNode分配子对象
};

小结

  • 你的分配子是一个模板,模板参数T代表你为它分配内存的对象类型;
  • 提供类型定义pointer和reference,但始终让pointer为T*,reference为T&;
  • 别让你的分配子拥有随对象而不同的状态(per-object state),即分配子通常不应该有非static数据成员,除非你明确可以接受它;
  • 传给allocator::allocate成员函数的参数是内存T对象的个数,而非byte数。同时,函数返回T*指针,即使尚未调用T构造函数;
  • 一定要提供嵌套的rebind模板,因为标准容器依赖该模板;

[======]

第11条:理解自定义分配子的合理用法

如果STL默认内存管理器(std::allocator)不符合需求,想要考虑自己写一个分配子(allocator),原因可能是:标准allocator太慢、浪费内存、使用STL导致大量内存碎片,或者不需要线程安全,又或者想把某些容器中对象放在一个共享内存中。

  1. 假定你想使用malloc和free内存模型来管理一个位于共享内存的堆
// 自定义内存分配、回收算法
void* mallocShared(size_t bytesNeeded);
void* freeShared(void* ptr);

// 共享内存分配子模板
template<typename T>
class SharedMemoryAllocator
{
public:
       using pointer = T*;
       using size_type = size_t;
       pointer allocate(size_type numberObjects, const void* localityHint = 0)
       {
              return static_cast<pointer>(mallocShared(numberObjects *  sizeof(T)));
       }
       void deallocate(pointer ptrToMemory, size_type numberObjects)
       {
              freeShared(ptrToMemory);
       }
       ...
};

这样使用SharedMemoryAllocator:

typedef vector<double, SharedMemoryAllocator<double>> SharedDoubleVec;
...
// 某个代码块
{
       ...
       SharedDoubleVec v; // 创建一个vector,其元素位于共享内存中,而v自身位于stack中
       ...
}

v使用SharedMemoryAllocator分配内存,而SharedMemoryAllocator的分配内存方法allocate是使用mallocShared在共享内存上分配的。而v自己是位于函数栈的对象。为了让v的内容和v自身都放到共享内存中,可以这样:

{
       void* pVectorMemory = mallocShared(sizeof(SharedDoubleVec)); // 为SharedDoubleVec对象分配足够内存
       SharedDoubleVec* pv = new (pVectorMemory) SharedDoubleVec; // 使用placement new在指定内存pVectorMemory中创建一个SharedDoubleVec
       
       // ...
       pv->~SharedDoubleVec(); // 析构共享内存中的对象(vector中的内容)
       freeShared(pVectorMemory); // 释放最初分配的那一块共享内存(vector自身)
}

工作流程:
1)获取一块共享内存;
2)然后在其中构造一个将共享内存作为自己内部内存使用的vector;
3)当用完了该vector后,调用它的析构函数,然后释放它锁占用的内存;
除非确实需要一个位于共享内存中的容器(而不仅仅是把它的元素放到共享内存中),否则,建议避免这种手工的“分配、构造、析构、释放”(allocate、construct、destroy、deallocate)四部曲。

  1. 假设你有2个堆:class Heap1和class Heap2,每个堆都有相应的静态成员函数执行内存分配和释放操作
class Heap1
{
public:
       static void* alloc(size_t numBytes, const void* memoryBlockToBeNear);
       static void dealloc(void* ptr);
};
class Heap2
{
public: // 同Heap1的alloc/dealloc接口
       static void* alloc(size_t numBytes, const void* memoryBlockToBeNear);
       static void dealloc(void* ptr);
};

如果你想把一些STL容器的内容放在不同的堆中,可以编写一个分配子SpecialHeapAllocator,可以使用像Heap1/2这样的class来完成实际的内存管理:

template<typename T, typename Heap>
class SpecialHeapAllocator
{
public:
       typedef T* pointer;
       typedef size_t size_type;
       pointer allocate(size_type numObjects, const void* localtyHint = 0)
       {
              return static_cast<pointer>(Heap::alloc(numObjects * sizeof(T)),  localtyHint);
       }
       void deallocate(pointer ptrToMemory, size_type numObjects)
       {
              Heap::dealloc(ptrToMemory);
       }
       ...
};

最后,使用SpecialHeapAllocator把容器的元素聚集到一起:

class Widget {...};

// 在v和s的元素都在Heap1中
vector<int, SpecialHeapAllocator<int, Heap1>> v;
set<int, SpecialHeapAllocator<int, Heap1>> s;

// 在L和M的元素都在Heap2中
list<Widget, SpecialHeapAllocator<Widget, Heap2>> L;
map<int, string, less<int>,
       SpecialHeapAllocator<pair<const int, string>,
       Heap2>> m;

[======]

第12条:切勿对STL容器的线程安全性有不切实际的依赖

关于线程安全,对于一个STL实现你最多只能期望2点:

  • 多个线程读是安全的。

多个线程可以同时读一个容器的内容,并且保证是正确的。自然地,在读的过程中,不能对容器有任何写入操作。

  • 多个线程对不同的容器写入操作是安全的。

多个线程可以同时对不同的容器做写入操作。

然而,STL实现并不保证上面2点,因为做到线程安全,将会很困难。

考虑当一个库试图实现完全的容器线程安全性时,可能采取的方式:

  • 对容器成员函数的每次调用,都锁住容器直到调用结束;
  • 在容器所返回的每个迭代器的生存期结束前,都锁住容器(如通过begin或end调用);
  • 对于作用于容器的每个算法,都锁住该容器,直到算法结束。

这样做起始没有意义,因为算法无法知道所操作的容器(条款32)。这里仅讨论。

这里介绍两种方法,手工实现STL在多线程中的同步控制。

  1. 自定义Lock类,用RAII管理互斥锁
#include <mutex>
static mutex mtx;

void getMutexFor(vector<int> &vec)
{
       mtx.lock();
}
void releaseMutexFor(vector<int> &vec)
{
       mtx.unlock();
}
// RAII管理互斥锁资源
template<typename Container>
class Lock {
public:
       Lock(const Container& container) : c(container)
       {
              getMutexFor(c); // 构造函数中获得互斥锁
       }
       ~Lock()
       {
              releaseMutexFor(c); // 析构函数中释放互斥锁
       }
private:
       const Container& c;
};

// 客户端
{
       vector<int> v = { 1, 2, 3, 4, 5, 6, 7 };
       
       Lock<vector<int>> lck(v);
       vector<int>::iterator first5(find(v.begin(), v.end(), 5));
       if (first5 != v.end())
       {
              *first5 = 0;
       }
}
  1. 使用STL库lock_guard
#include <mutex>
static mutex mtx;

// 客户端
{
       vector<int> v = { 1, 2, 3, 4, 5, 6, 7 };
       lock_guard<mutex> lock(mtx);
       vector<int>::iterator first5(find(v.begin(), v.end(), 5));
       if (first5 != v.end())
       {
              *first5 = 0;
       }
}
posted @ 2021-12-19 09:26  明明1109  阅读(87)  评论(0编辑  收藏  举报