当前位置: 欣欣网 > 码农

C++ 里的「数组」

2024-03-21码农

者 | 吴咏炜

出品 | 程序人生(ID:coder_life)

C 数组的问题

C 里面就有数组。但是,C 数组具有很多缺陷,使用 中有很多的陷阱。我们先来看一下 其中的几个问题。

问题一:传参退化问题

你可以一眼看出下面代码的问题吗?

#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))voidTest(int a[8]){cout << ARRAY_LEN(a) << endl;}

如果函数 Test 被调用的话,它的输出结果一般不是 8,而是 2。C 的老手一定能看出问题所在,但新手很容易就迷糊了。

幸运的是,编译器现在一般能直接对这个问题进行告警。你应该会见到类似下面这样的告警信息:

warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int *’ [-Wsizeof-array-argument]

cout << ARRAY_LEN(a) << endl;

编译器会明确告诉你, a 被理解成了 int* ,而不是数组。

问题二:复制问题

跟上面退化问题紧密相关的一点是,C 数组不能被复制(所以传参有退化)。下面的代码无法通过编译:

int a[3] = {1, 2, 3};int b[3] = a; // 不能编译b = a; // 不能编译

复制和退化这两个问题是紧密相关的,但这种语言的不规则性还是带来了学习和理解上的困难。如果我们想要一个数组能够被复制,就得把它放到结构体(或联合体)里面去。这至少会带来语法上的不便。

问题三:语法问题

C 数组的语法设计也绝对称不上有良好的可读性。你能一眼看出下面两个声明分别是什么意思吗?

int (*fpa[3])(constchar*);int (*(*fp)(constchar*))[3];

(下面会给出回答。)

问题四:动态问题

最早的 C 数组大小是完全固定的,这实际上既不方便又不安全。当然,我们可以用 malloc 来动态分配内存,到了 C99 还可以用变长数组,但它们要么使用不够方便,要么长度不能在创建后变化(如动态增长)。这些问题使得 C 的代码里常常在不该使用定长数组的时候也使用了定长数组,并很容易导致安全问题,如 缓冲区溢出

C++ 的解决方案

C++ 有两种常用的替换 C 数组的方式:

  • vector

  • array

  • vector

    C++ 标准模板库(STL)的主要组成部分是:

  • 容器

  • 迭代器

  • 算法

  • 函数对象

  • 而说到容器,我们通常第一个讨论的就是 vector 。它的名字来源于数学术语,直接翻译是「向量」的意思,但在实际应用中,我们把它当成动态数组更为合适。Alex Stepanov 在设计 STL 时借鉴 Scheme 和 Common Lisp 语言起了这个名字,但他后来承认这是个错误——这个容器不是数学里的向量,名字起得并不好。它基本相当于 Java 的 ArrayList 和 Python 的 list 。C++ 里有更接近数学里向量的对象,名字是 valarray (很少有人使用,我也不打算介绍)。

    vector 的成员在内存里连续存放。 begin end 成员函数返回的 迭代器 构成了一个半闭半开区间,而 front back 成员函数则返回指向首项和尾项的 引用 ,如下图所示:

    因为 v ector 的元素放在堆上,它也自然可以受益于现代 C++ 的移动语义——移动 vector 具有很低的开销,通常只是操 作六个指针而已。

    下面的代码展示了 vector 的基本用法:

    vector<int> v{1, 2, 3, 4};v.push_back(5);v.insert(v.begin(), 0);for (size_t i = 0; i < v.size(); ++i) {cout << v[i] << ' '; // 输出 0 1 2 3 4}cout << '\n';int sum = 0;for (auto it = v.begin(); it != v.end(); ++it) { sum += *it;}cout << sum << '\n'; // 输出 15

    上面的代码里我们首先构造了一个内容为 {1, 2, 3, 4} vector ,然后在尾部追加一项 5 ,在开头插入一项 0 。接下来,我们使用传统的下标方式来遍历,并输出其中的每一项。随即我们展示了 C++ 里通用的使用迭代器遍历的做法,对其中的内容进行累加。最后输出结果。

    当一个容器存在 push _… pop_… 成员函数时,说明容器对指定位置的删除和插入性能较高。 vector 适合在尾部操作,这是它的内存布局决定的(它只支持 push_back 而不支持 push_front )。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。

    除了容器类的共同点, vector 允许下面的操作(不完全列表):

  • 可以使用中括号的下标来访问其成员

  • 可以使用 data 来获得指向其内容的裸指针

  • 可以使用 capacity 来获得当前分配的存储空间的大小,以元素数量计

  • 可以使用 reserve 来改变所需的存储空间的大小,成功后 capacity() 会改变

  • 可以使用 resize 来改变其大小,成功后 size() 会改变

  • 可以使用 pop_back 来删除最后一个元素

  • 可以使用 push_back 在尾部插入一个元素

  • 可以使用 insert 在指定位置前插入一个元素

  • 可以使用 erase 在指定位置删除一个元素

  • 可以使用 emplace 在指定位置构造一个元素

  • 可以使用 emplace_back 在尾部新构造一个元素

  • 大家可以留意一下 push_… pop_… 成员函数。它们存在时,说明容器对指定位置的删除和插入性能较高。 vector 适合在尾部操作,这是它的内存布局决定的。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。

    pus h_back insert reserve resize 等函数导致内存重分配时,或当 insert erase 导致元素位置移动时, vector 会试图把元素「移动」到新的内存区域。 vector 的一些重要操作(如 push_back )试图提供强异常安全保证,即如果操作失败(发生异常)的话, vector 的内容完全不发生变化,就像数据库事务失败发生了回滚一样。如果元素类型没有提供一个保证不抛异常的移动构造函数, vector 此时通常会使用拷贝构造函数。因此,我们如果需要用移动来优化自己的元素类型的话,那不仅要定义移动构造函数(和移动赋值运算符,虽然 push_back 不要求),还应当将其标为 noexcept ,或只在容器中放置对象的智能指针。

    C++11 开始提供的 emplace… 系列函数是为了提升容器的插入性能而设计的。如果你的代码里有 vector<obj> v; v.push_back(Obj()) ,那把后者改成 v.emplace_back() v 的结果相同,而性能则有所不同——使用 push_back 会额外生成临时对象,多一次(移动或拷贝)构造和析构。如果是移动的情况,那会有小幅性能损失;如果对象没有实现移动的话,那性能差异就可能比较大了。——作为简单的使用指南,当且仅当我们见到 v.push_back(Obj(…)) 这样的代码时,我们就应当改为 v.emplace_back(…)

    array

    vector 解决了 C 数组的所有问题,但它毕竟不等价于 C 数组——堆内存分配的开销还是要比栈高得多。性能完全等同于 C 数组的 array 容器要到 C++11 才引入,虽然迟了点,但它最终在保留 C 数组性能的同时消除了前面列的头三个 C 数组的问题。

    首先, array 没有不会自动退化。如果你希望高效传参,就应当用标准的引用传参的方式,如 void foo(const array<int, 100>& a) 。如果你希望把指针传给 C 接口,你也可以写 foo(a.data()) 。如果函数接口就是想复制一个小数组,那使用 void foo(array<short, 3> a) 这样的形式也完全没有问题。

    其次,跟上面的问题关联, array 有了合理的复制行为。下面的代码完全合法:

    array<int, 3> a{1, 2, 3};array<int, 3> b = a; // OKb = a; // OK

    再次,从可读性角度,你来自己看一下你更喜欢读哪种风格的代码吧:

    // 函数指针的数组int (*fpa[3])(constchar*);array<int (*)(constchar*), 3> fpa;// 返回整数数组指针的函数的指针int (*(*fp)(constchar*))[3];array<int, 3>* (*fp)(constchar*);

    array 的好处还不止这些。由于它的接口跟其他的容器更一致,更容易被使用在泛型代码中。你也可以直接拿两个 array 来进行 ==、< 之类的比较,结果不是 C 数组的无聊指针比较,而是真正的逐元素比较!

    我的培训课程

    【现代 C++ 实战】是一个我讲过很多次的培训课程,重点在 C++ 语言提供的「现代」特性上,包括了 C++ 的主要惯用法和常用新特性。我会在课程里讨论:

  • 资源管理

  • 移动语义

  • 智能指针

  • 容器

  • 迭代器

  • 现代 C++ 的易用性改进

  • 模板

  • ……

  • 当然,课程是死的,课程里的交流、课后你自己的练习和拓展才是成长的关键。我希望我的课程能带给你一个看待 C++ 新视角,能在实践中加以应用;我希望你多多提出问题,由我来为你答疑解惑;我更希望你学完不是就那么结束了,而是牢牢记住一定要「学而时习之」,把课程的结束当成一个新的学习阶段的开始。只有这样,我的授课才不是白费力气。

    课前热身直播

    如果对以上的 C++培训有兴趣,也有疑问,非常 欢迎你来参加我在 3 月 27 日的免费直播课程【C++ 的序列容器】。届时我会讨论 C++ 里的序列容器——包括上面提到的 vector array ,以及 deque list forward_list ——并进行更深入 的讲解和讨论。也欢迎在评论区留下你的问题,我会详细解答。