欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

[C++]高效使用容器的一些建议

程序员文章站 2022-06-28 11:26:40
本文介绍一些在使用容器中常见的问题,并给出其解决方法从而提升对容器的认识和使用。 1. 不要试图编写独立于容器类型的代码 stl是以泛化原则为基础的:数组被泛化为”以其包含的对象的类型为...

本文介绍一些在使用容器中常见的问题,并给出其解决方法从而提升对容器的认识和使用。

1. 不要试图编写独立于容器类型的代码

stl是以泛化原则为基础的:数组被泛化为”以其包含的对象的类型为参数“的容器;函数被泛化为“以其使用的迭代器的类型为参数”的算法;指针被泛化为“以其指向的对象的类型为参数”的迭代器。

如果我们试图编写独立于容器类型的代码,例如编写一个既能够满足序列容器又满足关联容器的代码,我们最后会发现我们使用的只是他们功能交集而已,而这个交集几乎没有什么用。

但是对于客户代码却不见得如此。对于客户代码为了提高可维护性,我们需要使用封装的技术。最简单的方法就是通过对容器类型和其迭代器类型使用类定义。因此,不要写:

class widget {...};
vector vw;
widget bestwidget;
//  assign a value to bestwidget
....
vector::iterator i = find(vw.begin(), vw.end(), bestwidget);

而要写成:

class widget {...};
typedef vector widgetcontainer;
widgetcontainer cw;
widget bestwidget;
...
widgetcontainer::iterator i = find(cw.begin(), cw.end(), bestwidget);

如此,当你想要改变容器类型时,就会简单很多。尤其是当这种改变只是增加一个自定义的分配子时,就显得更加方便了。

class widget {...};
template 
specialallocator { ... };
typedef vector> widgetcontainer;
widgetcontainer cw;
widget bestwidget;
...
widgetcontainer::iterator i = find(cw.begin(), cw.end(), bestwidget);

类型定义只不过是其他类型的别名,所以它带来的封装纯粹是词法上的,但可以很有效地减少代码量。

类型定义并不能阻止客户去做他们原本无法做到的事情。如果你不想把自己选择的容器暴露给客户,就得使用类。例如把容器隐藏在一个类中,并尽量减少那些通过类接口可见的,与容器相关的信息。

class customerlist {
    private:
        typedef list customercontainer;
        typedef customerconatiner::iterator cciterator;
        ...
        customercontainer customers;
    public:
        ...
};

当你需要改变类的容器时,需要注意检查类的相关函数是否会受到影响。

2. 确保容器中的对象拷贝正确而高效

容器中保存了对象,但并不是你提供给那些容器的对象,而是他们的拷贝。

无论是对象的赋值,移动,排序都是基于拷贝来实现的。拷贝对象就是stl的工作方式。

值得注意的是,对于存在继承关系的情况下,拷贝动作会导致剥离(slicing)。也就是说,如果你创建了一个存放基类对象的容器,却向其中放入派生类的对象,那么派生类的对象被拷贝进容器时,它的特有部分将会丢失

剥离问题意味着向含有基类对象的容器中放入派生类对象总是错误的,如果你希望其正确的使用,那么应该使用存放基类指针。(动态绑定)

容器的设计思想是为了避免不必要的拷贝,它的总体目标总是避免创建不必要的对象。例如在内置数组中,只要指定了大小,它总是会调用缺省构造函数来使每一个对象实例化,但对于容器却不一定会如此。

#include 
#include 
using namespace std;
class widget {
public:
    widget() {
        cout << "widget constructed!" << endl;
    }   
};

int main(int argc, char *argv[]) {
    widget widgets[1];
    cout << endl;
    vector widget_vec(1);
    widget_vec.reserve(12);
    cout << widget_vec.capacity() << endl;
    return 0;
}
/*
widget constructed!

widget constructed!
12
*/

从以上代码清楚看出,对于内置数组他会实例了对象,同样地如果在声明容器时提供了容器大小,他也一样会对实例化对象。但对于reserve函数则不会实例化。这是因为reserve只是告诉了这个容器,里面可以存放对象的数量,并没有告诉你已经可以使用这个对象,这有利于让使用者确定需要创建他时才创建他,更加地高效。

3. 调用empty()而不是检查size() == 0

对于任何的容器调用empty总是消耗常数时间。但计算size可并不一定如此。

问题就在于,并不是每个容器都给了size这个变量用来存储对象数量。特别是那些为了提高区间连接函数的执行效率的容器,他们并不会提供size这种常量。

典型的就是list的splice函数衔接功能。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.splice(l1.end(), l2, find(l2.begin(), l2.end(), 5), find(l2.rbegin(), l2.rend(), 10).base());
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}
/*
11
1 2 3 4 5 5 6 7 8 9 10 
*/

在splice函数中,他总是可以直接找到一段区间来完成相应的操作,而这只消耗常数时间,但如果list还有size变量,那么他就必须遍历整个区间来确定size的值,而这会消耗线性时间。

因此,是否为了提高splice的效率而不使用size是stl的编写者需要考虑的。对于不同平台而言,他们的实现可能是不同的。

但是无论有没有size变量,empty()函数总是消耗常数时间的。所以调用empty()总是更高效的。

4. 区间成员函数优先于单元素成员函数

调用区间函数可以极大地提高效率。

我们常常会忘记了赋值函数assign的作用。assign函数可以很快地把一段区间的值赋值给另一个容器,从而提高效率。例如:

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.assign(l2.begin(), l2.end());
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

一个简单赋值操作可以用其他低效率但十分常见的方法完成。

例如,用循环。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.clear();
    for (list::iterator iter = l2.begin();iter != l2.end();iter++) {
        l1.push_back(*iter);
    }
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

这种写法即要用大量的代码,同样也非常地低效。后文见说明。

又或则使用copy函数。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.clear();
    copy(l2.begin(), l2.end(), back_inserter(l1));
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

毫无疑问在copy函数里面也同样使用了循环。但更要的问题是,insert函数清晰地说明了数据插入到了l1里,而copy则掩盖了这一点只说明了对象被拷贝。对象被拷贝是显然的,stl就是建立在数据被拷贝的基础上的。

所以说,使用区间函数的原因:

通过区间函数调用,可以减少代码。 使用区间函数通常会得到意图明确清晰和更加直接的代码。

简单地说,就是更加易懂。

除了assign函数之外,还有就是insert函数。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.clear();
    l1.insert(l1.begin(), l2.begin(), l2.end());
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

如果使用循环,则会复杂很多。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.clear();
    list::iterator iter_1 = l1.begin();
    for (list::iterator iter = l2.begin();iter != l2.end();iter++) {
        iter_1 = l1.insert(iter_1, *iter);
        iter_1++;
    }
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

又或则使用copy函数。

#include 
#include 
#include 
using namespace std;
int main(int argc, char *argv[]) {
    list l1{1, 2, 3, 4, 5};
    list l2{5, 6, 7, 8, 9, 10};
    l1.clear();
    copy(l2.begin(), l2.end(), inserter(l1, l1.begin()));
    cout << l1.size() << endl;
    for (auto l : l1) {
        cout << l << " ";
    }
    return 0;
}

而后面两个总是低效率的。因为他们是一次一次地往里加入新元素,而这必然会导致大量的元素移动、拷贝和创建的开销。但对于区间函数则可以一次性地往里加入最终需要的元素,减少了不必要的开销。这听起来比较难理解,但仔细想想应该能明白。

总而言之,对于

区间创建 区间插入 区间删除 区间赋值

都不应该使用单元素的操作而应该使用区间函数来完成,这几乎必然会减少开销。

5. c++编译器的分析机制

c++总是把声明尽量理解为函数。

假设你想把一个存有整数的文件的数据存入list中,并写出一下代码:

    ifstream datafile("ints.dat");
    list data(istream_iterator (datafile), istream_iterator ());

这种做法的想法是把一对istream_iterator作为区间传入到list中。但问题是,编译器并不会按照我们的想法来做。

首先我们先来解释一些奇怪的现象:

    int f(double d);

这是一个函数声明。

    int f(double (d));

这同样是个函数声明。参数两边的括号是可以忽略的。

    int f(double);

这还是一个函数声明。

    int f(double (*pf)());

这是一个函数声明,它的参数是一个指向不带任何参数的函数的指针,该函数返回double值。

    int f(double pf());

这和上面的函数的意思是一样的,只不过pf用非指针的形式来声明。

    int f(double ());

这是第三种声明,其中参数名pf被忽略了。

请注意围绕参数名的括号和独立的括号的区别。围绕参数名的括号被忽略,而独立的括号则表明参数列表的存在,他们说明存在一个函数指针参数。

所以,对于这段代码

    ifstream datafile("ints.dat");
    list data(istream_iterator (datafile), istream_iterator ());

编译器实际上会理解为:

第一个参数的名称是datafile,它的类型是istream_iterator,datafile两边的括号是多余的,会被忽略。 第二个参数没有名称,是一个指向不带参数的函数的指针,该函数返回一个istream_iterator。

更常见的错误就是,

public:
    widget() {
        cout << "construct!" << endl;
    }   
};
widget w();

这总是会理解为函数声明。

所以对于上述的代码,正确的做法是。

    ifstream datafile("ints.dat");
    list data((istream_iterator (datafile)), istream_iterator());

但更加常见的做法是,避免使用匿名的istream_iterator对象。

    ifstream datafile("ints.dat");
    istream_iterator databegin(datafile);
    istream_iterator dataend;
    list data(databegin, dataend);

这样可以避免许多的错误。

6. 在容器如何管理自己创建的对象

容器很智能,但并没有智能到可以帮你解决所有的问题,特别是不能帮你避免内存泄露。

典型的例子是,你通过循环给容器中添加创建的对象。

void dosomething() {
    list lw;
    for (int i = 0;i != max_num;i++) {
        lw.push_back(new widget);
    }
}

这个函数执行结束之后就出现了内存泄露。

当然了,避免内存泄露非常地简单。

void dosomething() {
    list lw;
    for (int i = 0;i != max_num;i++) {
        lw.push_back(new widget);
    }
    //....
    for (list::iterator iter = lw.begin();iter != lw.end();iter++) {
        delete *iter;
    }
}

但这不是异常安全的,只要在填充指针和删除指针过程中有异常抛出一样会有内存泄露。

如果使用for_each函数,则需要先实现一个函数对象。

template 
struct deleteobject : public unary_function {
    void operator()(const t* ptr) const {
        delete ptr;
    }
};
void dosomething() {
    list lw;
    for (int i = 0;i != max_num;i++) {
        lw.push_back(new widget);
    }
    //....
    for_each(lw.begin(), lw.end(), deleteobject());
}

但这有个问题就是deleteobject需要在每次使用时给出模板类型,这是非常不好的。因为在大量的代码过后,你很有可能会忘记类型是什么,所以更好的方法是,让他自己去推断类型。

#define max_num 100
struct deleteobject {
    template 
    void operator()(const t* ptr) const {
        delete ptr;
    }
};
void dosomething() {
    list lw;
    for (int i = 0;i != max_num;i++) {
        lw.push_back(new widget);
    }
    //....
    for_each(lw.begin(), lw.end(), deleteobject());
}

这样就简单了很多。但这依然不是异常安全的。所以归根结底地方法还是应该选用智能指针,但注意,要使用shared_ptr而不要用auto_ptr.

因为auto_ptr有个很重要的特性是,他会在每一次复制过后把自己定为null,从而保证只有一个指针指向它,但实际上在容器中会有大量的复制操作,这会导致容器无法使用,所以不可以在容器中存放auto_ptr对象。

7. 删除元素的方法

对于不同的容器,有不同的删除元素的方法。

对于连续内存容器的删除

(vector, deque, string) 使用erase-remove

c.erase(remove(c.begin(), c.end(), 50), c.end());
bool badvalue(int);
....
c.erase(remove_if(c.begin(), c.end(), badvalue), c.end());

如果要记录被删除的元素,则一定要使用循环,但问题没那么简单:

for (seqcontainer::iterator i = c.begin(); i != c.end();) {
    if (badvalue(*i) {
        //.....
        i = c.erase(i); // 序列容器的erase会返回被删除的下一个迭代器。
    } else {
        i++;
    }
}

list使用remove

    c.remove(50);
bool badvalue(int);
...

c.remove_if(badvalue);

对于关联容器使用erase

    c.erase(50);

对于使用函数来判断是否要删除,有两种方法:

1.

associatecontainer c;
...
associatecontainer goodvalue;
remove_copy_if(c.begin(), c.end(), insert(goodvalue, goodvalue.end()), badvalue);
c.swap(goodvalue);

缺点是需要把所有不需要删除的元素复制一遍。

2.

associatecontainer c;
。。
for (associatecontainer::iterator i = c.begin(); i != c.end(); /* do nothing. */) {
    if (badvalue(*i)) {
        //  如果还需要做其他操作
        c.erase(i++);
    } else {
        i++;
    }
}

关联容器的erase函数返回void,所以用后置递增,返回旧值,而作为副作用,i递增。