容器类
为什么我应该使用容器类而不是简单的数组?
就时间和空间而言,任何类型的连续数组都是访问内存中一系列对象的最佳构造,如果你认真对待任何语言的性能,你将“经常”使用数组。
然而,有好的数组(例如,具有连续存储的容器,如 std::array
和 std::vector
)和坏的数组(例如,C []
数组)。简单的 C []
数组是邪恶的,因为 C 数组是一个非常低级的数据结构,具有巨大的误用和错误潜力,在几乎所有情况下,都有更好的替代方案——“更好”意味着更容易编写,更容易阅读,更不容易出错,而且速度一样快。
C 数组的两个基本问题是:
- C 数组不知道它自己的大小
- C 数组的名称在最轻微的刺激下就会转换为指向其第一个元素的指针
考虑一些例子
void f(int a[], int s)
{
// do something with a; the size of a is s
for (int i = 0; i<s; ++i) a[i] = i;
}
int arr1[20];
int arr2[10];
void g()
{
f(arr1,20);
f(arr2,20);
}
第二个调用将覆盖不属于 arr2
的内存。当然,程序员通常会正确设置大小,但这需要额外的工作,而且时不时会有人犯错。你应该选择使用标准库 vector
或 array
的更简单、更清晰的版本
void f(vector<int>& v)
{
// do something with v
for (int i = 0; i<v.size(); ++i) v[i] = i;
}
vector<int> v1(20);
vector<int> v2(10);
void g()
{
f(v1);
f(v2);
}
template<size_t N> void f(array<int, N>& v)
{
// do something with v
for (int i = 0; i<N; ++i) v[i] = i;
}
array<int, 20> v1;
array<int, 10> v2;
void g()
{
f(v1);
f(v2);
}
由于 C 数组不知道其大小,因此无法进行数组赋值
void f(int a[], int b[], int size)
{
a = b; // not array assignment
memcpy(a,b,size); // a = b
// ...
}
同样,优先使用 vector
或 array
// This one can result in a changing size
void g(vector<int>& a, vector<int>& b)
{
a = b;
// ...
}
// In this one, a and b must be the same size
template<size_t N>
void g(array<int, N>& a, array<int, N>& b)
{
a = b;
// ...
}
vector
的另一个优点是,对于带有复制构造函数的元素(例如字符串),memcpy()
不会做正确的事情。
void f(string a[], string b[], int size)
{
a = b; // not array assignment
memcpy(a,b,size); // disaster
// ...
}
void g(vector<string>& a, vector<string>& b)
{
a = b;
// ...
}
普通的 C 数组的大小在编译时确定(忽略 C99 VLA,它目前在 ISO C++ 中没有类似物)
const int S = 10;
void f(int s)
{
int a1[s]; // error
int a2[S]; // ok
// if I want to extend a2, I'll have to change to an array
// allocated on free store using malloc() and use realloc()
// ...
}
对比一下
const int S = 10;
void g(int s)
{
vector<int> v1(s); // ok
vector<int> v2(S); // ok
v2.resize(v2.size()*2);
// ...
}
C99 允许局部数组的可变数组边界,但这些 VLA 有它们自己的问题。数组名称“衰减”为指针的方式是它们在 C 和 C++ 中使用的基础。然而,数组衰减与继承交互得非常糟糕。考虑一下
class Base { void fct(); /* ... */ };
class Derived : Base { /* ... */ };
void f(Base* p, int sz)
{
for (int i=0; i<sz; ++i) p[i].fct();
}
Base ab[20];
Derived ad[20];
void g()
{
f(ab,20);
f(ad,20); // disaster!
}
在最后一次调用中,`Derived[]` 被视为 `Base[]`,当 `sizeof(Derived)!=sizeof(Base)` 时,下标不再正确工作——在大多数情况下都会如此。如果我们改用 `vector`,错误将在编译时捕获。
void f(vector<Base>& v)
{
for (int i=0; i<v.size(); ++i) v[i].fct();
}
vector<Base> ab(20);
vector<Derived> ad(20);
void g()
{
f(ab);
f(ad); // error: cannot convert a vector<Derived> to a vector<Base>
}
我们发现 C 和 C++ 中惊人数量的初学者编程错误与 C 数组的(误)用有关。请改用 std::vector
或 std::array
。
让我们假设最好的情况:你是一位经验丰富的 C 程序员,这几乎可以定义为你在处理数组方面相当出色。你知道你能处理复杂性;你已经做了好几年了。而且你很聪明——团队中最聪明的——整个公司最聪明的。但即使具备所有这些,请阅读这整个常见问题解答,并在进入“照常营业”模式之前仔细思考。
根本上,这归结为这样一个简单的事实:C++ 不是 C。这意味着(这可能对你来说很痛苦!!)你需要放下你从 C 的丰富经验中辛辛苦苦积累的一些智慧。这两种语言根本不同。在 C 中做某件事的“最佳”方式并不总是与在 C++ 中做某件事的“最佳”方式相同。如果你真的想用 C 编程,请帮自己一个忙,用 C 编程。但如果你想真正精通 C++,那么学习 C++ 的做事方式。你可能是 C 大师,但如果你只是在学习 C++,你只是在学习 C++——你是一个新手。(哎哟;我知道这肯定很伤人。抱歉。)
以下是关于容器与数组你需要了解的一些事情
- 容器类能提高程序员的生产力。因此,如果你坚持使用数组,而你周围的人愿意使用容器类,那么你的生产力可能会比他们低(即使你比他们更聪明、更有经验!)。
- 容器类让程序员编写更健壮的代码。因此,如果你坚持使用数组,而你周围的人愿意使用容器类,那么你的代码可能比他们的代码有更多的 bug(即使你比他们更聪明、更有经验)。
- 如果你是如此的聪明和如此的经验丰富,以至于你可以像他们使用容器类一样快速和安全地使用数组,那么很可能其他人会来维护你的代码,而他们可能会引入 bug。更糟糕的是,你将是唯一一个能够维护你的代码的人,所以管理层会把你从开发中拉出来,把你调到全职维护岗位——这正是你一直想要的!
以下是数组的一些具体问题
- 下标不会被检查是否越界。(请注意,一些容器类,如
std::vector
,有方法可以访问元素,无论是否进行下标边界检查。) - 数组通常需要你从堆中分配内存(参见下面的示例),在这种情况下,你必须手动确保分配的内存最终被
delete
(即使有人throw
异常)。当你使用容器类时,这种内存管理是自动处理的,但当你使用数组时,你必须手动编写一堆代码(而且不幸的是,这些代码通常微妙而棘手)来处理这个问题。例如,除了编写销毁所有对象和delete
内存的代码之外,数组通常还会强制你编写一个额外的try
块,其中包含一个catch
子句,用于销毁所有对象,delete
内存,然后重新抛出异常。这真的很令人头疼,如此处所示。当使用容器类时,事情就容易多了。 - 你不能在数组中间插入元素,甚至不能在末尾添加元素,除非你通过堆分配数组,即便如此,你也必须分配一个新数组并复制元素。
- 容器类让你选择按引用或按值传递它们,但数组不给你这种选择:它们总是按引用传递。如果你想用数组模拟按值传递,你必须手动编写代码,显式复制数组的元素(可能从堆中分配),以及在你使用完副本后清理副本的代码。如果你使用容器类,所有这些都将自动为你处理。
- 如果你的函数有一个非
static
的局部数组(即一个“自动”数组),你不能返回该数组,而容器类的对象则没有这个问题。
以下是使用容器时需要考虑的一些事项
- 不同的 C++ 容器有不同的优点和缺点,但对于任何特定的任务,通常都有一个容器比数组更好——更清晰、更安全、更容易/更便宜维护,而且通常更高效。例如:
- 你可能考虑使用
std::map
而不是手动编写查找表的代码。 std::map
也可以用于稀疏数组或稀疏矩阵。std::array
是标准容器类中最像数组的,但它也提供了各种额外功能,例如通过at()
成员函数进行边界检查,即使有人抛出异常也能自动进行内存管理,能够按引用和按值传递等。std::vector
是标准容器类中第二像数组的,它在std::array
的基础上提供了额外的功能,例如元素的插入/删除。std::string
几乎总是优于char
数组(在本讨论中,你可以将std::string
视为一种“容器类”)。
- 你可能考虑使用
- 容器类并非对所有事物都最适合,有时你可能需要使用数组。但这应该非常罕见,如果/当发生这种情况时
- 请以这样一种方式设计你的容器类的
public
接口:使用该容器类的代码不知道内部存在数组这一事实。 - 目标是将数组“埋藏”在容器类中。换句话说,确保只有极少数行代码直接操作数组(只有你的容器类自己的方法),这样其他人(你的容器类的用户)就可以编写不依赖于你的容器类内部有数组的代码。
- 请以这样一种方式设计你的容器类的
总而言之,数组确实是邪恶的。如果你是 C++ 新手,你可能不这么认为。但是当你写了一大堆使用数组的代码后(特别是如果你让你的代码防泄漏和异常安全),你就会——通过艰难的方式——学到。或者,你将通过相信那些已经做过类似事情的人来轻松学习。选择权在你。
我如何在 C++ 中创建类似 perl
的关联数组?
使用标准类模板 std::map<Key,Val>
#include <string>
#include <map>
#include <iostream>
int main()
{
// age is a map from string to int
std::map<std::string, int, std::less<std::string>> age;
age["Fred"] = 42; // Fred is 42 years old
age["Barney"] = 37; // Barney is 37
if (todayIsFredsBirthday()) // On Fred's birthday...
++ age["Fred"]; // ...increment Fred's age
std::cout << "Fred is " << age["Fred"] << " years old\n";
// ...
}
备注:std::map<Key,Val>
中元素的顺序是基于键的排序顺序,因此从严格意义上讲,这与 Perl 的无序关联数组不同。如果你想要一个未排序的版本以更接近匹配,你可以改用 std::unordered_map<Key,Val>
。
std::vector<T>
或 std::array<T,N>
的存储是否保证是连续的?
是的。
这意味着以下技术是安全的
#include <vector>
#include <array>
#include "Foo.h" /* get class Foo */
// old-style code that wants an array
void f(Foo* array, unsigned numFoos);
void g()
{
std::vector<Foo> v;
std::array<Foo, 10> a;
// ...
f(v.data(), v.size()); // Safe
f(a.data(), a.size()); // Safe
}
一般来说,这意味着你被保证 &v[0] + n == &v[n]
,其中 v
是非空的 std::vector<T>
或 std::array<T,N>
,n
是范围 0 .. v.size()-1
内的整数。
然而 v.begin()
不保证是 T*
,这意味着 v.begin()
不保证与 &v[0]
相同
void g()
{
std::vector<Foo> v;
// ...
f(v.begin(), v.size()); // error, not guaranteed to be the same as &v[0]
↑↑↑↑↑↑↑↑↑ // cough, choke, gag; use v.data() instead
}
此外,如果 std::vector
或 std::array
为空,使用 &v[0]
是未定义行为,而使用 .data()
函数总是安全的。
注意:上述代码今天可能在你的编译器上编译通过。如果你的编译器厂商恰好将 std::vector
或 std::array
迭代器实现为 T*
,那么上述代码可能在该编译器上偶然工作——而且,可能只在发布版本中工作,因为厂商通常提供带有比 T*
更多信息的调试迭代器。但是,即使这段代码今天恰好在你的编译器上编译通过,那也只是因为特定实现而偶然发生的。它不是可移植的 C++ 代码。对于这些情况,请使用 .data()
。
为什么 C++ 不提供异构容器?
C++ 标准库提供了一组有用的、静态类型安全且高效的容器。例如 vector
、list
和 map
vector<int> vi(10);
vector<Shape*> vs;
list<string> lst;
list<double> l2
map<string,Record*> tbl;
unordered_map<Key,vector<Record*> > t2;
这些容器在所有好的 C++ 教科书中都有描述,除非有充分理由不这样做,否则应优先于数组和“自制”容器。
这些容器是同质的;也就是说,它们持有相同类型的元素。如果你希望容器持有几种不同类型的元素,你必须将其表示为联合(union)或(通常好得多)表示为指向多态类型的指针容器。典型的例子是
vector<Shape*> vi; // vector of pointers to Shapes
在这里,vi
可以保存任何从 Shape
派生的类型的元素。也就是说,vi
是同质的,因为它的所有元素都是 Shape
(准确地说,是指向 Shape
的指针),并且是异质的,因为 vi
可以保存各种 Shape
(例如 Circle
、Triangle
等)的元素。
因此,从某种意义上说,所有容器(在每种语言中)都是同质的,因为要使用它们,所有元素都必须有一个共同的接口供用户依赖。提供被认为是异构容器的语言只是提供了所有元素都提供标准接口的容器。例如,Java 集合提供了 Object
(的引用)的容器,你使用(共同的)Object 接口来发现元素的真实类型。
C++ 标准库提供同质容器,因为它们在绝大多数情况下最易于使用,提供最佳的编译时错误消息,并且不施加不必要的运行时开销。
如果你在 C++ 中需要异构容器,请为所有元素定义一个公共接口,并创建一个这些元素的容器。例如
class Io_obj { /* ... */ }; // the interface needed to take part in object I/O
vector<Io_obj*> vio; // if you want to manage the pointers directly
vector<shared_ptr<Io_obj>> v2; // if you want a "smart pointer" to handle the objects
除非必要,否则不要深入到最低级的实现细节
vector<void*> memory; // rarely needed
一个很好的迹象表明你“层次过低”是你的代码中充满了类型转换。
在某些程序中,使用 Any
类(例如 boost::any
)可能是一种替代方案
vector<any> v = { 5, "xyzzy", 3.14159 };
如果你想存储在容器中的所有对象都公开派生自一个共同的基类,那么你可以声明/定义你的容器来存储指向基类的指针。你通过将对象的地址作为元素存储在容器中,间接存储派生类对象。然后,你可以通过指针间接访问容器中的对象(享受多态行为)。如果你需要知道容器中对象的精确类型,可以使用 dynamic_cast<>
或 typeid()
。你可能需要虚构造函数习语来复制不同对象类型的容器。这种方法的缺点是它使内存管理变得有点麻烦(谁“拥有”指向的对象?如果你在销毁容器时 delete
这些指向的对象,你如何保证没有其他人拥有这些指针的副本?如果你在销毁容器时不 delete
这些指向的对象,你如何确保最终会有人 delete
它们?)。它还会使容器的复制更复杂(可能会破坏容器的复制函数,因为你不想复制指针,至少在容器“拥有”指向的对象时是这样)。在这种情况下,你可以使用 std::shared_ptr
来管理对象,并且容器将正确复制。
第二种情况发生在对象类型不相交时——它们不共享共同的基类。这里的方法是使用句柄类。容器是一个句柄对象的容器(按值或按指针,由你选择;按值更容易)。每个句柄对象都知道如何“持有”(即,维护指向)你想放入容器中的对象之一。你可以使用一个带有几种不同类型指针作为实例数据的单个句柄类,或者一个句柄类层次结构,它反映你想包含的各种类型(要求容器是句柄基类指针)。这种方法的缺点是,每次更改可包含的类型集时,它都会使句柄类(们)面临维护。好处是你可以使用句柄类(们)来封装内存管理和对象生命周期的大部分丑陋。
我如何构建一个不同类型对象的异构<favorite container>?
请参阅异构容器。
为什么我不能将 vector<Apple*>
赋值给 vector<Fruit*>
?
因为那会给类型系统开一个漏洞。例如
class Apple : public Fruit { void apple_fct(); /* ... */ };
class Orange : public Fruit { /* ... */ }; // Orange doesn't have apple_fct()
vector<Apple*> v; // vector of Apples
void f(vector<Fruit*>& vf) // innocent Fruit manipulating function
{
vf.push_back(new Orange); // add orange to vector of fruit
}
void h()
{
f(v); // error: cannot pass a vector<Apple*> as a vector<Fruit*>
for (int i=0; i<v.size(); ++i) v[i]->apple_fct();
}
如果调用 f(v)
是合法的,那么我们就会有一个 Orange
假装是 Apple
。
另一种语言设计决策可能是允许不安全的转换,但依赖动态检查。那将要求每次访问 v
的成员时都进行运行时检查,并且 h()
在遇到 v
的最后一个元素时必须抛出异常。
如何从链表/哈希表等中插入/访问/更改元素?
最重要的一点是:除非有充分的理由,否则不要从头开始自己实现。换句话说,与其创建自己的列表或哈希表,不如使用标准类模板之一,例如 std::vector<T>
或 std::list<T>
等等。
假设你有充分的理由构建自己的容器,以下是处理元素插入(或访问、更改等)的方法。
为了使讨论具体,我将讨论如何将元素插入到链表中。这个例子足够复杂,可以很好地推广到向量、哈希表、二叉树等。
链表使得在列表的第一个元素之前或最后一个元素之后插入元素变得容易,但是如果我们限制自己这样做,将会产生一个过于弱的库(弱库几乎比没有库更糟糕)。这个答案对于 C++ 新手来说可能有点难以理解,所以我将提供几个选项。第一个选项最简单;第二和第三个更好。
- 赋予
List
一个“当前位置”,以及成员函数,如advance()
、backup()
、atEnd()
、atBegin()
、getCurrElem()
、setCurrElem(Elem)
、insertElem(Elem)
和removeElem()
。虽然这在小型示例中有效,但“一个”当前位置的概念使得难以在列表中的两个或多个位置访问元素(例如,“对于所有对 x,y 执行以下操作…”)。 - 从
List
本身移除上述成员函数,并将它们移动到一个单独的类ListPosition
中。ListPosition
将充当列表中的“当前位置”。这允许在同一个列表中存在多个位置。ListPosition
将是class
List
的friend
,因此List
可以向外部世界隐藏其内部结构(否则List
的内部结构将不得不通过List
中的public
成员函数公开)。注意:ListPosition
可以使用运算符重载来实现advance()
和backup()
等功能,因为运算符重载是普通成员函数的语法糖。 - 将整个迭代视为一个原子事件,并创建一个体现此事件的类模板。这通过允许在访问期间避免公共访问成员函数(可能是
virtual
函数)来提高性能,并且此访问通常发生在内部循环中。不幸的是,类模板会增加目标代码的大小,因为模板通过复制代码来获得速度。更多信息请参见 [Koenig,“Templates as interfaces,”JOOP,4,5 (Sept 91)] 和 [Stroustrup,“The C++ Programming Language Third Edition,”“Comparator”一节]。
我可以使用智能指针容器来存储我的对象吗?
是的,你确实应该这样做,因为与替代方案相比,智能指针使你的生活更轻松,并使你的代码更健壮。
注意:忘记 std::auto_ptr
曾经存在过。真的。你不想使用它,永远不想,尤其是在容器中。它在太多方面都是有缺陷的。
让我们用一个例子来引入这个讨论。第一部分展示了为什么你首先要使用智能指针——这是不应该做的
#include <vector>
class Foo {
public:
// ...blah blah...
};
void foo(std::vector<Foo*>& v) // ← BAD FORM: a vector of dumb pointers to Foo objects
{
v.push_back(new Foo());
// ...
delete v.back(); // you have a leak if this line is skipped
v.pop_back(); // you have a "dangling pointer" if control-flow doesn't reach this line
}
如果控制流没有到达最后两行中的任何一行,无论是由于你的代码中没有它们,还是你执行了 return
或抛出了异常,你都将出现内存泄漏或“悬空指针”;坏消息。std::vector
的析构函数会清理 std::vector
对象本身所做的任何分配,但它不会清理你分配 Foo
对象时所做的分配,即使你将指向该已分配 Foo
对象的指针放入了 std::vector
对象中。
这就是你为什么要使用智能指针的原因。
现在我们来谈谈如何使用智能指针。有许多智能指针可以被复制并且仍然保持共享所有权语义,例如 std::shared_ptr
和许多其他。在这个例子中,我们将使用 std::shared_ptr
,尽管你可能会根据你想要的语义和性能权衡选择另一个。
typedef std::shared_ptr<Foo> FooPtr; // ← GOOD: using a smart-pointer
void foo(std::vector<FooPtr>& v) // ← GOOD: using a container of smart-pointer
{
// ...
}
这只是安全地适用于所有操作。当最后一个指向它的 shared_ptr
被销毁或被设置为指向其他东西时,对象就会被销毁。
在 std::vector
中使用 std::unique_ptr
是安全的,但它有一些限制。unique_ptr
是一种仅可移动类型,它不能被复制。这个仅可移动的限制也适用于包含它们的 std::vector
。
void create_foo(std::vector<std::unique_ptr<Foo>> &v)
{
v.emplace_back(std::make_unique<Foo>(/* ... */));
}
如果你想把这个向量中的一个元素放入另一个向量,你必须将它移动到另一个向量,因为一次只有一个 unique_ptr
可以指向同一个 Foo
对象。
关于这个通用主题有很多好文章,例如Herb Sutter 在 Dr. Dobbs 上的文章以及许多其他文章。
为什么标准容器这么慢?
它们不是,它们是地球上最快的之一。
可能“与什么相比?”是一个更有用的问题(和答案)。当人们抱怨标准库容器的性能时,我们通常会发现三个真正的问题之一(或者许多神话和烟幕弹之一)
- 我遭受复制开销
- 我遭受查找表速度慢的困扰
- 我的手写(侵入式)列表比
std::list
快得多
在尝试优化之前,请考虑你是否存在真正的性能问题。在我收到的案例中,性能问题通常是理论性的或想象性的:先测量,然后仅在需要时进行优化。
让我们依次看看这些问题。通常,`vector
vector<int> vi;
vector<Image> vim;
// ...
int i = 7;
Image im("portrait.jpg"); // initialize image from file
// ...
vi.push_back(i); // put (a copy of) i into vi
vim.push_back(im); // put (a copy of) im into vim
现在,如果 portrait.jpg
有几兆字节,并且 Image
具有值语义(即复制赋值和复制构造进行复制),那么 vim.push_back(im)
确实会很昂贵。但是——俗话说——如果它如此痛苦,就不要做。
如果向量将拥有该对象,并且你不需要在其他地方复制它,那么移动语义和就地构造可以抵消许多这些成本。
vector<Image> vim;
vim.emplace_back("portrait.jpg"); // create image from file in place in the vector
或者,使用句柄容器或指针容器。例如,如果 `Image` 具有引用语义,上面的代码将只产生复制构造函数调用的成本,这与大多数图像处理操作符相比微不足道。如果某个类,比如 `Image`,出于充分的理由具有复制语义,那么指针容器通常是一个合理的解决方案。
vector<int> vi;
vector<Image*> vim;
// ...
Image im("portrait.jpg"); // initialize image from file
// ...
vi.push_back(7); // put (a copy of) 7 into vi
vim.push_back(&im); // put (a copy of) &im into vim
当然,如果你使用指针,你必须考虑资源管理,但指针容器本身可以是有效且廉价的资源句柄(通常,你需要一个带析构函数来删除“拥有”对象的容器),或者你可以简单地使用智能指针容器。
第二个经常出现的真正性能问题是为大量 (string,X)
对使用 map<string,X>
。Map 对于相对较小的容器(例如几百或几千个元素——访问一个 10000 个元素的 map
中的元素大约需要 9 次比较)来说很好,在这些情况下,小于运算符的开销很小,并且无法构建好的哈希函数。如果你有很多字符串并且有一个好的哈希函数,请使用 unordered_map
。
有时,你可以通过使用 (const char*,X)
对而不是 (string,X)
对来加快速度,但请记住,<
不会对 C 风格字符串进行字典比较。此外,如果 X
很大,你可能也会遇到复制问题(用常见方法解决)。
侵入式列表确实可以非常快。但是,请考虑你是否根本需要一个列表:在许多情况下,vector
更紧凑,因此更小更快——即使你进行插入和删除。例如,如果你逻辑上有一个包含几个整数元素的列表,vector
比列表(任何列表)快得多。此外,侵入式列表不能直接保存内置类型(int
没有链接成员)。因此,假设你确实需要一个列表,并且可以为每种元素类型提供一个链接字段。标准库 list
默认情况下,每次插入元素的操作都会执行一次分配和一次复制(每次删除元素的操作都会执行一次释放)。对于使用默认分配器的 std::list
,这可能很重要。对于复制开销不大的小元素,请考虑使用优化的分配器。只有在需要列表和最后一丝性能时,才使用手写侵入式列表。
人们有时担心 std::vector
增量增长的成本。许多 C++ 程序员过去常常担心这一点,并使用 reserve()
来优化增长。在测量他们的代码并反复难以在实际程序中找到 reserve()
的性能优势后,他们停止使用它,除非它需要避免迭代器失效(在大多数代码中很少见)。再次强调:在优化之前先测量。
在 C++11 中,std::vector
增量增长的成本可能比 C++98/03 中小得多,当你使用支持移动的类型时,例如 std::string
甚至 std::vector<T>
,因为当 vector
重新分配时,对象会被移动到新的存储空间中,而不是被复制。