C++ 中的数组

数组的声明与定义

数组是一种复合类型。
数组的声明形如 **a[d] **其中 a 是数组的名字,d 是数组的维度。维度说明数组中元素的个数,因此必须大于 0
数组中的元素个数也是属于数组类型的一部分,编译的时候应该是已知的,也就是说,维度必须是一个常量表达式

unsigned cnt = 42;				// 普通变量
constexpr unsigned sz = 42;			// 常量表达式
int arr[10];					// 普通数组
int* parr[sz];					// parr 是指针,指向一个长度 42 的 int 数组
string bad[cnt];				// 错误 cnt 不是常量
string strs[get_size()];			// get_size 如果是 constexpr 时,正确

默认情况下,数组的元素被默认初始化
和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。
定义数组的时候必须指定数组的类型,不允许用 auto 关键字由初始值的列表推断类型。
另外与 vector 一样,数组的元素应为对象,不存在引用的数组。

显示初始化数组
可以对数组的元素进行列表初始化,此时允许忽略数组的维度。
也就是说,如果在声名时,没有指明维度,编译器会根据初始值计算并推测出来;
相反,如果指明了维度,那么初始值的总数量不应该超出指定的大小。如果维度比提供的初始值数量大,则用提供的初始值初始化靠前的元素,剩下的元素被初始化成默认值。

const unsigned sz = 3;
int ia1[sz] = {0, 1, 2};		// ia1 有 3 个元素,分别是 0 1 2
int a2[] = {0, 1, 2};			// a2 有 3 个元素,分别是 0 1 2
int a3[5] = {0, 1, 2};			// a3 有 5 个元素,分别是 0 1 2 0 0
string a4[3] = {"hi", "bye"};		// a4 有 4 个元素,分别是 "hi" "bye" "" ""
int a5[2] = {0, 1, 2};			// compile error 超出维度了

字符数组的特殊性
字符数组有一种额外的初始化形式,我们可以用字符串字面值对此类数组初始化。
当我们使用这种方式时,一定要注意字符串字面值的结尾处还有一个空字符,这个空字符也会像字符串的其他字符一样被拷贝到字符数组中去:

char a1[] = {'C', '+', '+'};		// 列表初始化,a1 有 3 个元素
char a2[] = {'C', '+', '+', '\0'};	// 列表初始化,a2 有 4 个元素
char a3[] = "C++";			// 特殊初始化,a3 的元素与 a2 相同,有 4 个元素
const char a4[6] = "Daniel";		// compile error 超出维度了,字符串带有终止符 '\0'

a1 的维度是 3,a2 和 a3 的维度都是 4,a4 的定义是错误的。尽管字符串字面值 "Daniel" 看起来只有 6 个字符,但是数组的大小至少是 7,其中 6 个位置存放字面值的内容,另外一个存放结尾处的空字符。

不允许拷贝和赋值
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值:

int a[] = {0, 1, 2};
int a2 = a;		// compile error
a2 = a;			// compile error

值得注意的是:在一些编译器环境中,是支持数组的赋值的,这就是所谓的 编译器扩展 。但是一般来说,最好避免使用非标准特性,因为含有非标准特性的程序很可能在其他编译器上无法正常工作。

理解复杂的数组声明
和 vector 一样,数组能存放大多数类型的对象。例如,可以定义一个存放指针的数组。又因为数组本身就是对象,所以允许定义数组的指针及数组的引用。

int* ptrs[10];				// ptrs 是数组,存储 int 类型的指针,维度是 10
int& refs[10];				// compile error
int(*parray)[10] = &arr;	// parray 是指针,指向维度是 10 且存储 int 类型的数组
int(&arrRef)[10] = arr;		// arr 是数组的引用,原数据是维度是 10 且存储 int 类型的数组

默认情况下,类型修饰符从右到左依次绑定。对于 ptrs 来说,从右向左理解其含义比较简单,首先我们定义的是一个大小是 10 的数组,它的名字是 ptrs,然后知道数组中存放的是指向 int 的指针。
但是对于 parray 这类复杂的数组来说,由内向外阅读要比从右向左更好理解 parray 的含义:首先是括号括起来的部分,*parray 意味着 parray 是一个指针,接下来观察后边,可以知道 parray 指向的是一个数组,数组的大小是 10,最后观察左边,得知数组的元素是 int 类型数据。当然,对修饰符的数量没有特殊限制
int*(&array)[10] = ptrs;
这个特殊的声明由内而外解读是 array 是一个引用,引用的对象是一个数组,这个数组的维度是 10,元素是 int 类型的指针
要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。

访问数组元素

与标准库 vector、 string 相似的,数组的元素也可以通过范围 for 语句或者下标运算符来访问。
在使用数组下标的时候,通常将其定义为 size_t 类型。size_t 是一种机器相关的无符号类型,它被设计得足够大以便能表示内存中任意对象的大小。在 cstddef 头文件中定义了 size_t 的类型,这个文件是 C 标准库 stddef.h 头文件的 C++ 版本。
检查下标的值
与 vector 和 string 一样,数组的下标是否在合理范围内由程序员负责检查,所谓合理范围就是说下标应该大于等于 0 而且小于数组的大小。想要防止数组下标越界,除了小心谨慎注意细节以及对代码进行彻底的测试之外,没有其他好办法。
大多数常见的安全问题都源于缓冲区溢出错误。当数组或其他类似数据结构的下标越界并尝试访问非法内存区域时,就会产生此类错误。

指针和数组

在 C++ 中,指针与数组有非常紧密的联系。使用数组的时候,编译器一般会把它转换为指针。
在很多用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针:

string nums[] = { "one","two","three" };
string* p = &nums[0];
string* p2 = nums;		// *p2 与 *p 结果一致。 等同于 string* p2 = &nums[0];

在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。
在一些情况下数组的操作实际上是指针的操作,这一结论有很多隐含的意思。其中一层意思是当使用数组作为一个 auto 变量的初始值时,推断得到的类型是指针而非数组:

int ia[] = { 0,1,2,3,4,5,6,7,8,9 };
auto ia2(ia);
ia2 = 42;	// compile error ia2 是指针,不能直接赋值

尽管 ia 是由 10 个 int 元素组成的数组,但当使用 ia 作为初始值时,编译器实际执行的初始化过程类似于下面的形式。
auto ia2(&ia[0]);
那么 ia2 的类型是 int*
当使用 decltype 关键字时,上述转换不会发生, decltype(ia) 是一个大小为 10 的 int 类型数组。
decltype(ia) ia3;
ia3 是一个大小为 10 的 int 类型数组

指针也是迭代器

指向数组元素的指针拥有更多功能。 vector 和 string 的迭代器支持的运算,数组的指针全都支持。
示例

int arr[] = { 0,1,2,3,4,5,6,7,8,9 };
int* p = arr;
++p;

就像迭代器一样,指针也支持递增运算,递增之后指向下一个位置。
同样的,数组也存在指向尾元素的下一个位置的指针,获取尾后指针要用到数组的另外一个特殊性质,我们可以设法获取数组尾元素之后的那个不存在的元素的地址。
int* e = &arr[10]; // 指向 arr 尾元素的下一个位置的指针
这里显示使用下标运算符索引了一个不存在的元素,从而得到了尾指针 e 。就像 vector 的尾后迭代器一样,尾指针不能执行解引用或者递增的操作。

标准库函数 begin 和 end

尽管可以通过下标或者计算拿到尾指针,但是这种用法极易出错。为了让指针的使用更简单,更安全, C++ 11 新标准引入了两个名为 begin 和 end 的函数。这两个函数与容器中的两个同名成员功能类似,不过数组毕竟不是类类型,因此这两个函数不是成员函数。正确的使用形式是将数组作为参它们的参数:

int arr[] = { 0,1,2,3,4,5,6,7,8,9 };
auto pbeg = begin(arr);		// 指向 arr 首元素的指针
auto pend = end(arr);		// 指向 arr 尾元素的下一个位置的指针
while (pbeg != pend)
{
	cout << *pbeg << " ";
	pbeg++;
}

begin 函数返回指向 arr 首元素的指针,end 函数返回指向 arr 尾元素的下一个位置的指针。这两个函数定义在 iterator 头文件中。
值得注意的是,尾后指针不能执行解引用和递增操作。

指针运算

指针的运算与迭代器的运算基本一致:解引用、递增、比较、与整数相加、两个指针相减。
示例

constexpr size_t sz = 5;
int arr[sz] = {1, 2, 3, 4, 5};
int *p = arr + sz;		// p 指向 arr 的最后元素的下一个位置,属于是尾后指针,不能解引用
int *p2 = arr + 10;		// p2 不指向任何实际的数据

当 arr 加上 sz 时,编译器自动将 arr 转换成指向数组 arr 中首元素的指针。
和迭代器一样,两个指针相减的结果是它们之间的距离。参与运算的两个指针必须指向同一个数组当中的元素:
auto n = begin(arr) - end(arr); // n 是 arr 的大小
两个指针相减的结果的类型是一种名为 ptrdiff_t 的类型,和 size_t 一样 ptrdiff_t 也是一种定义在 cstddef 头文件中的机器相关的类型。因为差值可能为负值,所以 ptrdiff_t 是一种带符号的类型。
只要两个指针指向同一个数组的元素,或者指向该数组的尾元素的下一个位置,就能利用关系运算符对其进行比较。比较的结果与下标类似。
值得注意的是:指针的运算同样适用于空指针,如果 p 是空指针,那么允许 p 加上或者减去一个值为 0 的常量表达式,两个空指针也允许彼此相减,结果当然是 0

解引用和指针运算的交互
在解引用和指针运算的时候,需要在必要的地方加上括号

int ia[] = {0, 2, 4, 6, 8};
int num1 = *(ia + 4);	// 8
int num2 = *ia + 4;		// 0 + 4 结果为 4 

在很多时候,使用数组的名字的时候,实际上是使用的首元素的指针。有一个典型的例子就是当对数组使用下标运算符时,编译器会自动执行上述转换操作。例如,有一个数组 int ia[] = {0, 2, 4, 6, 8};
此时 ia[0] 是使用了数组名字的表达式,对数组执行下标运算其实是对指向数组元素的指针执行下标运算:

int i = ia[2];
iny *p = ia;
i = *(p + 2);	// 等价于 i = ia[2]

只要指针指向的是数组的元素(或者数组尾部元素的下一个位置),都可以执行下标运算:

int* p = &ia[2];	// p 指向索引值为 2 的元素
int j = p[1];		// j 是索引值为 3 的元素
int k = p[-2];		// k 是索引值为 0 的元素

虽然标准库 vector 和 string 都支持下标运算,但是数组与他们相比还是有所不同。
标准库类型限定使用的下标必须是无符号类型,而内置的下标运算无此要求,上面的最后一个例子很好地说明了这一点。内置的下标运算符可以处理负值。
内置的下标运算符所用的索引不是无符号类型,这一点与 vector 和 string 不一样。

C 风格字符串

字符串字面值是一种通用结构的实例,这种结构即是 C++ 由 C 继承而来的 C 风格字符串。C 风格字符串不是一种类型,而是为了表达和使用字符串而形成的一种约定俗称的写法。按此习惯书写的字符串存在字符数中并以空字符结束。以空字符结束的意思是在字符串最后一个字符后面跟着一个空字符('\0')。一般用指针来操作操作这些字符串。

C 标准库 String 函数
下表列举了 C 语言标准库提供的一组函数,可以用于操作 C 风格字符串,它们定义在 cstring 头文件中,cstring 是 C 语言头文件 string.h 的 C++ 版本。

C 风格字符串的函数
strlen(p) 返回 p 的操作,空字符不计算在内
strcmp(p1, p2) 比较 p1 和 p2 的相等性。如果 p1 == p2 返回 0;如果 p1 > p2 返回一个正值,如果 p1 < p2 返回一个负值
strcat(p1, p2) 将 p2 附加到 p1 之后,返回 p1
strcpy(p1, p2) 将 p2 拷贝给 p1,返回 p1

值得注意的是:上述的函数不会验证字符串参数。
传入的指针必须是指向以空字符结尾的字符数组。否则的话会沿着字符一直找到空字符才会停止。

比较字符串
比较两个 C 风格字符串与标准库 string 对象大相径庭。如果使用的是标准库 string 的对象的时候,用的是普通的关系运算符和相等性运算符:

string s1 = "A string example";
string s2 = "A different string";
if (s1 < s2) // false : s2 小于 s1

如果把这些运算符用在两个 C 风格的字符串上,实际比较的将是指针,而非字符串本身:

const char ca1[] = "A string example";
const char ca2[] = "A different string";
if (ca1 < ca2) // 未定义的 试图比较两个无关指针

直接比较是没有意义的,一般来说,在后面申明的变量的地址要大于前面申明的变量地址,即 ca2 > ca1 但是这种比较是没有意义的。
进一步说明了当使用数组名字的时候,其实真正用的是指向数组首元素的指针。
连接或者拷贝 C 风格字符串也与标准库 string 差别很大。在使用上面表格的函数对 C 风格字符串进行连接或者拷贝时,需要程序员确保存放结果的字符数组的空间必须能够放得下结果字符串以及末尾的空字符。
对于大多数应用来说,使用标准库 string 要比使用 C 风格字符串更安全、更高效。

与旧代码的接口

C 风格字符串与标准库 string 之间的转换

  • 允许使用以空字符结束的字符数组来初始化 string 对象,或者为 string 对象赋值
  • string 标准库定义了 c_str 的成员函数,可以将 string 对象转换为 C 风格字符串

示例

const char c_s[] = "hello world!";
string cp_s(c_s);
const char* c_s_2 = cp_s.c_str();

如果执行完 c_str() 函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

使用数组初始化 vector 对象
C++ 不允许使用一个数组初始化另外一个数组,也不允许使用 vector 初始化数组。但是可以使用数组初始化 vector 对象。要实现这一目的,只需要指明拷贝区域的首元素地址和尾元素的地址:

int arr[] = {0, 1, 2, 3, 4, 5};
vector<int> ivec(begin(arr), end(arr));

ivec 会有 6 个元素,分别是 0 1 2 3 4 5 与数据源 arr 相同
建议:尽量使用标准库类型而不是数组。现在的 C++ 程序应当尽量使用 vector 和迭代器,避免使用内置数组和指针;应该尽量使用 string, 避免使用 C 风格的字符串。

多维数组

严格来说,C++ 语言中没有多维数组,通常所说的多维数组其实是数组的数组。
对于二维数组来说,常把第一个维度成为行,第二个维度称为列。

多维数组的初始化
有多种初始化方式

int ia[3][4] = {	// 3 个元素,每个元素是大小为 4 的数组
	{0, 1, 2, 3},
	{4, 5, 6, 7},
	{8, 9, 10, 11}
};
int ia[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};	// 与上面等价

如果仅仅想初始化每一行的第一个元素,也可以这样初始化
int ia[3][4] = {{0}, {4}, {8}};
或者仅仅初始化第一行
int ia[3][4] = {0, 1, 2, 3};
其他没有显示初始化的元素将执行 int 类型的默认初始化,数值为 0

使用范围 for 语句处理多维数组

对于二维数组 ia ,可以通过范围 for 语句进行遍历

int ia[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};	// 与上面等价
for (auto& row : ia) {
    for (auto& col : row) {
        cout << col << " ";
    }
    cout << endl;
}	

第一层循环需要将 row 设置为引用类型,这是为了防止数组名自动转换为指针。
因此,要使用范围 for 语句处理多维数组,除了内层的循环之外,其他所有循环的控制变量都应该是引用类型。如果要在循环中修改数值,那么最内存的也建议使用引用类型

指针和多维数组
当程序使用多维数组的名字时,也会自动将其转换成指向数组首元素的指针。

int ia[3][4];
int(*p)[4] = ia;	// ia 实际上是指向第一个子数组的指针
p = &ia[2];			// p 将指向 ia 的尾元素

上述声明中括号必不可少,否则就变成了 int* p[4] 这是一个整形指针的数组
普通遍历二维数组

for (int(&p)[4] : ia) {
    for (int& q : p) {
        cout << q << ' ';
    }
    cout << endl;
}
// or
for (int(*p)[4] = ia; p != ia + 3; p++) {
	for (int* q = *p; q != *p + 4; q++)
	{
		cout << *q << ' ';
	}
	cout << endl;
}

在 C++ 11 通过使用 auto 或者 decltype 就能尽可能地避免在数组面前加上一个指针类型:

// 输出 ia 中每个元素
for (auto p = ia; p != ia + 3; p++) {
	for (auto q = *p; q != *p + 4; q++) {
		cout << *q << ' ';
	}
	cout << endl;
}

当然也可以使用 iterator 中的标准库函数 begin end 来代替,具有更好的可读性

// p 指向 ia 中的子数组
for (auto p = begin(ia); p != end(ia); p++) {
    // q 指向子数组中的元素
	for (auto q = begin(*p); q != end(*p); q++) {
		cout << *q << ' ';
	}
	cout << endl;
}

类型别名简化多维数组的指针
读、写和理解一个指向多维数组的指针是一个让人不胜其烦的工作,使用类型别名能让这项工作变得简单一点儿。示例:

using int_array = int[4];	// 新标准下的类型别名
typedef int int_array[4];	// 等价的 typedef 声明
int ia[3][4] = { 0,1,2,3,4,5,6,7,8,9,10,11 };
for (int_array* p = ia; p != end(ia); p++) {
	for (int* q = *p; q != end(*p); q++) {
		cout << *q << ' ';
	}
	cout << endl;
}
// or
for (int_array& p : ia) {
    for (int& q : p) {
        cout << q << ' ';
    }
    cout << endl;
}

程序将类型“4 个整数组成的数组”命名为 int_array 用类型名 int_array 定义外层循环的控制变量,让程序显得简洁明了。

posted @ 2023-04-13 18:35  文工程序  阅读(180)  评论(0编辑  收藏  举报