首页 > 代码库 > 【C++ Primer每日一刷之六】数组

【C++ Primer每日一刷之六】数组



引言

 

C++ 语言提供了两种类似于vector 和迭代器类型的低级复合类型——数组和指针。与vector 类型相似,数组也可以保存某种类型的一组对象;而它们的区别在于,数组的长度是固定的。数组一经创建,就不允许添加新的元素。指针则可以像迭代器一样用于遍历和检查数组中的元素。

 

现代 C++ 程序应尽量使用vector 和迭代器类型,而避免使用低级的数组和指针。设计良好的程序只有在强调速度时才在类实现的内部使用数组和指针。

数组是 C++ 语言中类似于标准库vector 类型的内置数据结构。与 vector类似,数组也是一种存储单一数据类型对象的容器,其中每个对象都没有单独的名字,而是通过它在数组中的位置对它进行访问。

与 vector 类型相比,数组的显著缺陷在于:数组的长度是固定的,而且程序员无法知道一个给定数组的长度。数组没有获取其容量大小的 size 操作,也不提供 push_back 操作在其中自动添加元素。如果需要更改数组的长度,程序员只能创建一个更大的新数组,然后把原数组的所有元素复制到新数组空间中去。

与使用标准 vector 类型的程序相比,依赖于内置数组的程序更容易出错而且难于调试。

在出现标准库之前,C++ 程序大量使用数组保存一组对象。而现代的 C++ 程序则更多地使用 vector 来取代数组,数组被严格限制于程序内部使用,只有当性能测试表明使用 vector 无法达到必要的速度要求时,才使用数组。然而,在将来一段时间之内,原来依赖于数组的程序仍大量存在,因此,作为C++ 程序员,我们还是必须掌握数组的使用方法。

 

 

 

数组

 

数组是由类型名、标识符和维数组成的复合数据类型,类型名规定了存放在数组中的元素的类型,而维数则指定数组中包含的元素个数。数组定义中的类型名可以是内置数据类型或类类型;除引用之外,数组元素的类型还可以是任意的复合类型。没有所有元素都是引用的数组。

 

数组的定义和初始化

 

 

数组的维数必须用值大于等于 1 的常量表达式定义(第 2.7 节)。此常量表达式只能包含整型字面值常量、枚举常量(第 2.7 节)或者用常量表达式初始化的整型 const 对象。非 const 变量以及要到运行阶段才知道其值的 const变量都不能用于定义数组的维数。

 

数组的维数必须在一对方括号 [] 内指定:

 

// both buf_size and max_files are const

const unsigned buf_size = 512, max_files =20;

int staff_size = 27; // nonconst

const unsigned sz = get_size(); // constvalue not known until

run time

char input_buffer[buf_size]; // ok: constvariable

string fileTable[max_files + 1]; // ok:constant expression

double salaries[staff_size]; // error: nonconst variable

int test_scores[get_size()]; // error: nonconst

expression

int vals[sz]; // error: size not knownuntilrun time

 

虽然 staff_size 是用字面值常量进行初始化,但 staff_size 本身是一个非 const 对象,只有在运行时才能获得它的值,因此,使用该变量来定义数组维数是非法的。而对于 sz,尽管它是一个 const 对象,但它的值要到运行时调用 get_size 函数后才知道,因此,它也不能用于定义数组维数。

 

max_files + 1

 

另一方面,由于 max_files 是const 变量,因此表达式是常量表达式,编

译时即可计算出该表达式的值为21。

 

 

显式初始化数组元素

在定义数组时,可为其元素提供一组用逗号分隔的初值,这些初值用花括号{ }括起来,称为初始化列表:

 

const unsigned array_size = 3;

int ia[array_size] = {0, 1, 2};

 

 

如果没有显式提供元素初值,则数组元素会像普通变量一样初始化(第 2.3.4节):

 

? 在函数体外定义的内置数组,其元素均初始化为 0。

? 在函数体内定义的内置数组,其元素无初始化。

? 不管数组在哪里定义,如果其元素为类类型,则自动调用该类的默认构造

函数进行初始化;如果该类没有默认构造函数,则必须为该数组的元素提

供显式初始化。

 

除非显式地提供元素初值,否则内置类型的局部数组的元素没有初始化。此时,除了给元素赋值外,其他使用这些元素的操作没有定义。

显式初始化的数组不需要指定数组的维数值,编译器会根据列出的元素个数来确定数组的长度:

 

int ia[] = {0, 1, 2}; // an array ofdimension 3

 

如果指定了数组维数,那么初始化列表提供的元素个数不能超过维数值。如果维数大于列出的元素初值个数,则只初始化前面的数组元素;剩下的其他元素,若是内置类型则初始化为0,若是类类型则调用该类的默认构造函数进行初始化:

 

const unsigned array_size = 5;

// Equivalent to ia = {0, 1, 2, 0, 0}

// ia[3] and ia[4] default initialized to 0

int ia[array_size] = {0, 1, 2};

// Equivalent to str_arr = {"hi","bye", "", "", ""}

// str_arr[2] through str_arr[4] defaultinitialized to the

empty string

string str_arr[array_size] ={"hi", "bye"};

 

 

 

 

特殊的字符数组

 

字符数组既可以用一组由花括号括起来、逗号隔开的字符字面值进行初始化,也可以用一个字符串字面值进行初始化。然而,要注意这两种初始化形式并不完全相同,字符串字面值(第 2.2 节)包含一个额外的空字符(null)用于结束字符串。当使用字符串字面值来初始化创建的新数组时,将在新数组中加入空字符:

 

char ca1[] = {‘C‘, ‘+‘, ‘+‘}; // no null

char ca2[] = {‘C‘, ‘+‘, ‘+‘, ‘\0‘}; //explicit null

char ca3[] = "C++"; // nullterminator added automatically

 

ca1 的维数是 3,而 ca2 和 ca3 的维数则是 4。使用一组字符字面值初始化字符数组时,一定要记得添加结束字符串的空字符。例如,下面的初始化将导致编译时的错误:

 

const char ch3[6] = "Daniel"; //error: Daniel is 7 elements

 

上述字符串字面值包含了 6 个显式字符,存放该字符串的数组则必须有 7个元素——6 个用于存储字符字面值,而 1 个用于存放空字符 null。

 

 

不允许数组直接复制和赋值

与 vector 不同,一个数组不能用另外一个数组初始化,也不能将一个数组赋值给另一个数组,这些操作都是非法的:

 

int ia[] = {0, 1, 2}; // ok: array of ints

int ia2[](ia); // error: cannot initializeone arraywith another

 

int main()

{

const unsigned array_size = 3;

int ia3[array_size]; // ok: but elements are

uninitialized!

ia3 = ia; // error: cannot assign one array to

another

return 0;

}

 

一些编译器允许将数组赋值作为编译器扩展。但是如果希望编写的程序能在不同的编译器上运行,则应该避免使用像数组赋值这类依赖于编译器的非标准功能。

 

 

注意:数组的长度是固定的

与 vector 类型不同,数组不提供push_back 或者其他的操作在数组中添加新元素,数组一经定义,就不允许再添加新元素。如果必须在数组中添加新元素,程序员就必须自己管理内存:要求系统重新分配一个新的内存空间用于存放更大的数组,然后把原数组的所有元素复制到新分配的内存空间中。

 

 

数组操作

 

与 vector 元素一样,数组元素可用下标操作符(第 3.3.2 节)来访问,数组元素也是从 0 开始计数。对于一个包含 10 个元素的数组,正确的下标值是从 0 到 9,而不是从 1 到 10。

在用下标访问元素时,vector 使用vector::size_type 作为下标的类型,而数组下标的正确类型则是 size_t(第 3.5.2 节)。在下面的例子中,for 循环遍历数组的 10 个元素,并以其下标值作为各个元素的初始值:

 

int main()

{

const size_t array_size = 10;

int ia[array_size]; // 10 ints, elements are uninitialized

// loop through array, assigning value of its index to each

element

for (size_t ix = 0; ix != array_size; ++ix)

ia[ix] = ix;

return 0;

}

 

 

 

使用类似的循环,可以实现把一个数组复制给另一个数组:

 

int main()

{

const size_t array_size = 7;

int ia1[] = { 0, 1, 2, 3, 4, 5, 6 };

int ia2[array_size]; // local array, elements

uninitialized

// copy elements from ia1 into ia2

for (size_t ix = 0; ix != array_size; ++ix)

ia2[ix] = ia1[ix];

return 0;

}

 

 

 

 

 

 

检查数组下标值

正如 string 和 vector 类型,程序员在使用数组时,也必须保证其下标值在正确范围之内,即数组在该下标位置应对应一个元素。

除了程序员自己注意细节,并彻底测试自己的程序之外,没有别的办法可防止数组越界。通过编译并执行的程序仍然存在致命的错误,这并不是不可能的。导致安全问题的最常见原因是所谓“缓冲区溢出(buffer overflow)”错误。当我们在编程时没有检查下标,并且引用了越出数组或其他类似数据结构边界的元素时,就会导致这类错误。

 

 

 

指针的引入

 

vector 的遍历可使用下标或迭代器实现,同理,也可用下标或指针来遍历数组。指针是指向某种类型对象的复合数据类型,是用于数组的迭代器:指向数组中的一个元素。在指向组元素的指针上使用解引用操作符 *(dereferenceoperator)和自增操作符 ++(increment operator),与在迭代器上的用法类似。对指针进行解引用操作,可获得该指针所指对象的值。而当指针做自增操作时,则移动指针使其指向数组中的下一个元素。在使用指针编写程序之前,我们需进一步了解一下指针。

 

 

4.2.1. 什么是指针

 

 

对初学者来说,指针通常比较难理解。而由指针错误引起的调试问题连富有经验的程序员都感到头疼。然而,指针是大多数C 程序的重要部分,而且在许多C++ 程序中仍然受到重用。

指针的概念很简单:指针用于指向对象。与迭代器一样,指针提供对其所指对象的间接访问,只是指针结构更通用一些。与迭代器不同的是,指针用于指向单个对象,而迭代器只能用于访问容器内的元素。

 

具体来说,指针保存的是另一个对象的地址:

 

string s("hello world");

string *sp = &s; // sp holds theaddress of s

 

第二条语句定义了一个指向 string 类型的指针 sp,并初始化 sp 使其指向string 类型的对象s。*sp 中的 * 操作符表明 sp 是一个指针变量,&s中的 & 符号是取地址操作符,当此操作符用于一个对象上时,返回的是该对象的存储地址。取地址操作符只能用于左值(第 2.3.1 节),因为只有当变量用作左值时,才能取其地址。同样地,由于用于vector 类型、string 类型或内置数组的下标操作和解引用操作生成左值,因此可对这两种操作的结果做取地址操作,这样即可获取某一特定对象的存储地址。

 

 

 

建议:尽量避免使用指针和数组

指针和数组容易产生不可预料的错误。其中一部分是概念上的问题:指针用于低级操作,容易产生与繁琐细节相关的(bookkeeping)错误。其他错误则源于使用指针的语法规则,特别是声明指针的语法。许多有用的程序都可不使用数组或指针实现,现代C++程序采用vector类型和迭代器取代一般的数组、采用string 类型取代C 风格字符串。

 

 

4.2.2. 指针的定义和初始化

每个指针都有一个与之关联的数据类型,该数据类型决定了指针所指向的对

象的类型。例如,一个 int 型指针只能指向 int 型对象。

 

 

指针变量的定义

C++ 语言使用 * 符号把一个标识符声明为指针:

 

vector<int> *pvec; // pvec can pointto a vector<int>

int *ip1, *ip2; // ip1 and ip2 can point toan int

string *pstring; // pstring can point to astring

double *dp; // dp can point to a double

 

理解指针声明语句时,请从右向左阅读。从右向左阅读 pstring 变量的定义,可以看到string *pstring;语句把 pstring 定义为一个指向 string 类型对象的指针变量。类似地,

语句

 

int *ip1, *ip2; // ip1 and ip2 can point toan int

 

把 ip1 和 ip2 都定义为指向 int 型对象的指针。

在声明语句中,符号 * 可用在指定类型的对象列表的任何位置:

 

double dp, *dp2; // dp2 is a ponter, dp isan object: both type double

 

该语句定义了一个 double 类型的dp 对象以及一个指向 double 类型对

象的指针dp2。

 

 

另一种声明指针的风格

 

在定义指针变量时,可用空格将符号 * 与其后的标识符分隔开来。下面的写法是合法的:

 

string* ps; // legal but can be misleading

 

也就是说,该语句把 ps 定义为一个指向 string 类型对象的指针。这种指针声明风格容易引起这样的误解:把 string* 理解为一种数据类型,认为在同一声明语句中定义的其他变量也是指向 string 类型对象的指针。然而,语句

 

string* ps1, ps2; // ps1 is a pointer tostring, ps2 is a string

 

实际上只把 ps1 定义为指针,而ps2 并非指针,只是一个普通的 string对象而已。如果需要在一个声明语句中定义两个指针,必须在每个变量标识符前再加符号 * 声明:

 

string* ps1, *ps2; // both ps1 and ps2 arepointers to string

 

 

 

 

连续声明多个指针易导致混淆

 

 

连续声明同一类型的多个指针有两种通用的声明风格。其中一种风格是一个声明语句只声明一个变量,此时,符号 * 紧挨着类型名放置,强调这个声明语句定义的是一个指针:

 

string* ps1;

string* ps2;

 

另一种风格则允许在一条声明语句中声明多个指针,声明时把符号 * 靠近标识符放置。这种风格强调对象是一个指针:

 

string *ps1, *ps2;

 

关于指针的声明,不能说哪种声明风格是唯一正确的方式,重要的是选择一种风格并持续使用我们将采用第二种声明风格:将符号 * 紧贴着指针变量名放置。

 

 

指针可能的取值

 

一个有效的指针必然是以下三种状态之一:保存一个特定对象的地址;指向某个对象后面的另一对象;或者是0 值。若指针保存0 值,表明它不指向任何对象。未初始化的指针是无效的,直到给该指针赋值后,才可使用它。下列定义和赋值都是合法的:

 

int ival = 1024;

int *pi = 0; // pi initialized to addressno object

int *pi2 = & ival; // pi2 initializedto address of ival

int *pi3; // ok, but dangerous, pi3 isuninitialized

pi = pi2; // pi and pi2 address the sameobject, e.g.

ival

pi2 = 0; // pi2 now addresses no object

 

避免使用未初始化的指针

很多运行时错误都源于使用了未初始化的指针。就像使用其他没有初始化的变量一样,使用未初始化的指针时的行为C++标

准中并没有定义使用未初始化的指针,它几乎总会导致运行时崩溃。然而,导致

崩溃的这一原因很难发现。对大多数的编译器来说,如果使用未初始化的指针,会将指针中存放的不确定值视为地址,然后操纵该内存地址中存放的位内容。使用未初始化的指针相当

于操纵这个不确定地址中存储的基础数据。因此,在对未初始化的指针进行解引用时,通常会导致程序崩溃。

 

C++ 语言无法检测指针是否未被初始化,也无法区分有效地址和由指针分配到的存储空间中存放的二进制位形成的地址。建议程序员在使用之前初始化所有的变量,尤其是指针。

如果可能的话,除非所指向的对象已经存在,否则不要先定义指针,这样可避免定义一个未初始化的指针。

如果必须分开定义指针和其所指向的对象,则将指针初始化为 0。因为编译器可检测出 0 值的指针,程序可判断该指针并未指向一个对象。

 

 

指针初始化和赋值操作的约束

 

 

对指针进行初始化或赋值只能使用以下四种类型的值:

 

1.     0 值常量表达式(第 2.7 节),例如,在编译时可获得 0 值的整型 const对象或字面值常量 0。

2. 类型匹配的对象的地址。

3. 另一对象末的下一地址。

4. 同类型的另一个有效指针。

 

把 int 型变量赋给指针是非法的,尽管此 int 型变量的值可能为 0。但允许把数值 0 或在编译时可获得 0 值的const 量赋给指针:

 

int ival;

int zero = 0;

const int c_ival = 0;

int *pi = ival; // error: pi initializedfrom int value of ival

pi = zero; // error: pi assigned int valueof zero

pi = c_ival; // ok: c_ival is a const withcompile-time value

of 0

pi = 0; // ok: directly initialize toliteral constant 0

 

除了使用数值0 或在编译时值为 0 的 const 量外,还可以使用 C++ 语言从 C 语言中继承下来的预处理器变量 NULL(第 2.9.2 节),该变量在 cstdlib头文件中定义,其值为 0。如果在代码中使用了这个预处理器变量,则编译时会自动被数值 0 替换。因此,把指针初始化为 NULL 等效于初始化为 0 值:

 

// cstdlib #defines NULL to 0

int *pi = NULL; // ok: equivalent to int*pi = 0;

 

正如其他的预处理器变量一样(第 2.9.2 节),不可以使用 NULL 这个标识符给自定义的变量命名。预处理器变量不是在 std 命名空间中定义的,因此其名字应为NULL,而非 std::NULL。

 

除了稍后会介绍的两种例外情况之外,指针只能初始化或赋值为同类型的变量地址或另一指针:

 

double dval;

double *pd = &dval; // ok: initializeris address of a double

double *pd2 = pd; // ok: initializer is apointer to double

int *pi = pd; // error: types of pi and pddiffer

pi = &dval; // error: attempt to assignaddress of a double

to int *

 

由于指针的类型用于确定指针所指对象的类型,因此初始化或赋值时必须保证类型匹配。指针用于间接访问对象,并基于指针的类型提供可执行的操作,例如,int 型指针只能把其指向的对象当作 int 型数据来处理,如果该指针确实指向了其他类型(如 double 类型)的对象,则在指针上执行的任何操作都有可能出错。

 

 

 

void* 指针

 

 

C++ 提供了一种特殊的指针类型void*,它可以保存任何类型对象的地址:

double obj = 3.14;

double *pd = &obj;

// ok: void* can hold the address value ofany data pointer type

void *pv = &obj; // obj can be an objectof any type

pv = pd; // pd can be a pointer to any type

 

void* 表明该指针与一地址值相关,但不清楚存储在此地址上的对象的类型。

void* 指针只支持几种有限的操作:与另一个指针进行比较;向函数传递

void* 指针或从函数返回void* 指针;给另一个 void* 指针赋值。不允许使用

void* 指针操纵它所指向的对象。在后面讨论如何重新获取存储在 void* 指针中的地址。

 

【C++ Primer每日一刷之六】数组