C++ 里的“数组”

2024-04-16 13:03:26 浏览数 (1)

C 数组的问题

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

问题一:传参退化问题

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

代码语言:javascript复制
#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))

void Test(int a[8])
{
    cout << ARRAY_LEN(a) << endl;
}
代码语言:javascript复制

如果函数 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 数组不能被复制(所以传参有退化)。下面的代码无法通过编译:

代码语言:javascript复制
int a[3] = {1, 2, 3};
int b[3] = a;  // 不能编译
b = a;         // 不能编译
代码语言:javascript复制

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

问题三:语法问题

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

代码语言:javascript复制
int (*fpa[3])(const char*);
int (*(*fp)(const char*))[3];
代码语言:javascript复制

(下面会给出回答。)

问题四:动态问题

最早的 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 的成员在内存里连续存放。beginend 成员函数返回的迭代器构成了一个半闭半开区间,而 frontback 成员函数则返回指向首项和尾项的引用,如下图所示:

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

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

代码语言:javascript复制
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
代码语言:javascript复制

上面的代码里我们首先构造了一个内容为 {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 适合在尾部操作,这是它的内存布局决定的。只有在尾部插入和删除时,其他元素才会不需要移动,除非内存空间不足导致需要重新分配内存空间。

push_backinsertreserveresize 等函数导致内存重分配时,或当 inserterase 导致元素位置移动时,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 有了合理的复制行为。下面的代码完全合法:

代码语言:javascript复制
array<int, 3> a{1, 2, 3};
array<int, 3> b = a;  // OK
b = a;                // OK
代码语言:javascript复制

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

代码语言:javascript复制
// 函数指针的数组
int (*fpa[3])(const char*);
array<int (*)(const char*), 3> fpa;

// 返回整数数组指针的函数的指针
int (*(*fp)(const char*))[3];
array<int, 3>* (*fp)(const char*);
代码语言:javascript复制

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

0 人点赞