返回介绍

4.8 指针、数组和指针算术

发布于 2024-10-08 23:14:02 字数 17005 浏览 0 评论 0 收藏 0

指针和数组基本等价的原因在于指针算术(pointer arithmetic)和 C++内部处理数组的方式。首先,我们来看一看算术。将整数变量加 1 后,其值将增加 1;但将指针变量加 1 后,增加的量等于它指向的类型的字节数。将指向 double 的指针加 1 后,如果系统对 double 使用 8 个字节存储,则数值将增加 8;将指向 short 的指针加 1 后,如果系统对 short 使用 2 个字节存储,则指针值将增加 2。程序清单 4.19 演示了这种令人吃惊的现象,它还说明了另一点:C++将数组名解释为地址。

程序清单 4.19 addpntrs.cpp

下面是该程序的输出:

4.8.1 程序说明

在多数情况下,C++将数组名解释为数组第 1 个元素的地址。因此,下面的语句将 pw 声明为指向 double 类型的指针,然后将它初始化为 wages—wages 数组中第 1 个元素的地址:

和所有数组一样,wages 也存在下面的等式:

为表明情况确实如此,该程序在表达式&stacks[0]中显式地使用地址运算符来将 ps 指针初始化为 stacks 数组的第 1 个元素。

接下来,程序查看 pw 和*pw 的值。前者是地址,后者是存储在该地址中的值。由于 pw 指向第 1 个元素,因此*pw 显示的值为第 1 个元素的值,即 10000。接着,程序将 pw 加 1。正如前面指出的,这样数字地址值将增加 8,这使得 pw 的值为第 2 个元素的地址。因此,*pw 现在的值是 20000—第 2 个元素的值(参见图 4.10,为使改图更为清晰,对其中的地址值做了调整)。

图 4.10 指针加法

此后,程序对 ps 执行相同的操作。这一次由于 ps 指向的是 shor t 类型,而 short 占用 2 个字节,因此将指针加 1 时,其值将增加 2。结果是,指针也指向数组中下一个元素。

注意:

将指针变量加 1 后,其增加的值等于指向的类型占用的字节数。

现在来看一看数组表达式 stacks[1]。C++编译器将该表达式看作是*(stacks + 1),这意味着先计算数组第 2 个元素的地址,然后找到存储在那里的值。最后的结果便是 stacks [1]的含义(运算符优先级要求使用括号,如果不使用括号,将给*stacks 加 1,而不是给 stacks 加 1)。

从该程序的输出可知,*(stacks + 1)和 stacks[1]是等价的。同样,*(stacks + 2)和 stacks[2]也是等价的。通常,使用数组表示法时,C++都执行下面的转换:

如果使用的是指针,而不是数组名,则 C++也将执行同样的转换:

因此,在很多情况下,可以相同的方式使用指针名和数组名。对于它们,可以使用数组方括号表示法,也可以使用解除引用运算符(*)。在多数表达式中,它们都表示地址。区别之一是,可以修改指针的值,而数组名是常量:

另一个区别是,对数组应用 sizeof 运算符得到的是数组的长度,而对指针应用 sizeof 得到的是指针的长度,即使指针指向的是一个数组。例如,在程序清单 4.19 中,pw 和 wages 指的是同一个数组,但对它们应用 sizeof 运算符得到的结果如下:

这种情况下,C++不会将数组名解释为地址。

数组的地址

对数组取地址时,数组名也不会被解释为其地址。等等,数组名难道不被解释为数组的地址吗?不完全如此:数组名被解释为其第一个元素的地址,而对数组名应用地址运算符时,得到的是整个数组的地址:

从数字上说,这两个地址相同;但从概念上说,&tell[0](即 tell)是一个 2 字节内存块的地址,而&tell 是一个 20 字节内存块的地址。因此,表达式 tell + 1 将地址值加 2,而表达式&tell + 2 将地址加 20。换句话说,tell 是一个 short 指针(* short),而&tell 是一个这样的指针,即指向包含 20 个元素的 short 数组(short (*) [20])。

您可能会问,前面有关&tell 的类型描述是如何来的呢?首先,您可以这样声明和初始化这种指针:

如果省略括号,优先级规则将使得 pas 先与[20]结合,导致 pas 是一个 short 指针数组,它包含 20 个元素,因此括号是必不可少的。其次,如果要描述变量的类型,可将声明中的变量名删除。因此,pas 的类型为 short (*) [20]。另外,由于 pas 被设置为&tell,因此*pas 与 tell 等价,所以(*pas) [0]为 tell 数组的第一个元素。

总之,使用 new 来创建数组以及使用指针来访问不同的元素很简单。只要把指针当作数组名对待即可。然而,要理解为何可以这样做,将是一种挑战。要想真正了解数组和指针,应认真复习它们的相互关系。

4.8.2 指针小结

刚才已经介绍了大量指针的知识,下面对指针和数组做一总结。

1.声明指针

要声明指向特定类型的指针,请使用下面的格式:

下面是一些示例:

其中,pn 和 pc 都是指针,而 double *和 char *是指向 double 的指针和指向 char 的指针。

2.给指针赋值

应将内存地址赋给指针。可以对变量名应用&运算符,来获得被命名的内存的地址,new 运算符返回未命名的内存的地址。

下面是一些示例:

3.对指针解除引用

对指针解除引用意味着获得指针指向的值。对指针应用解除引用或间接值运算符(*)来解除引用。因此,如果像上面的例子中那样,pn 是指向 bubble 的指针,则*pn 是指向的值,即 3.2。

下面是一些示例:

另一种对指针解除引用的方法是使用数组表示法,例如,pn[0]与*pn 是一样的。决不要对未被初始化为适当地址的指针解除引用。

4.区分指针和指针所指向的值

如果 pt 是指向 int 的指针,则*pt 不是指向 int 的指针,而是完全等同于一个 int 类型的变量。pt 才是指针。

下面是一些示例:

5.数组名

在多数情况下,C++将数组名视为数组的第一个元素的地址。

下面是一个示例:

一种例外情况是,将 sizeof 运算符用于数组名用时,此时将返回整个数组的长度(单位为字节)。

6.指针算术

C++允许将指针和整数相加。加 1 的结果等于原来的地址值加上指向的对象占用的总字节数。还可以将一个指针减去另一个指针,获得两个指针的差。后一种运算将得到一个整数,仅当两个指针指向同一个数组(也可以指向超出结尾的一个位置)时,这种运算才有意义;这将得到两个元素的间隔。

下面是一些示例:

7.数组的动态联编和静态联编

使用数组声明来创建数组时,将采用静态联编,即数组的长度在编译时设置:

使用 new[ ]运算符创建数组时,将采用动态联编(动态数组),即将在运行时为数组分配空间,其长度也将在运行时设置。使用完这种数组后,应使用 delete [ ]释放其占用的内存:

8.数组表示法和指针表示法

使用方括号数组表示法等同于对指针解除引用:

数组名和指针变量都是如此,因此对于指针和数组名,既可以使用指针表示法,也可以使用数组表示法。

下面是一些示例:

4.8.3 指针和字符串

数组和指针的特殊关系可以扩展到 C-风格字符串。请看下面的代码:

数组名是第一个元素的地址,因此 cout 语句中的 flower 是包含字符 r 的 char 元素的地址。cout 对象认为 char 的地址是字符串的地址,因此它打印该地址处的字符,然后继续打印后面的字符,直到遇到空字符(\0)为止。总之,如果给 cout 提供一个字符的地址,则它将从该字符开始打印,直到遇到空字符为止。

这里的关键不在于 flower 是数组名,而在于 flower 是一个 char 的地址。这意味着可以将指向 char 的指针变量作为 cout 的参数,因为它也是 char 的地址。当然,该指针指向字符串的开头,稍后将核实这一点。

前面的 cout 语句中最后一部分的情况如何呢?如果 flower 是字符串第一个字符的地址,则表达式“s are red\n”是什么呢?为了与 cout 对字符串输出的处理保持一致,这个用引号括起的字符串也应当是一个地址。在 C++中,用引号括起的字符串像数组名一样,也是第一个元素的地址。上述代码不会将整个字符串发送给 cout,而只是发送该字符串的地址。这意味着对于数组中的字符串、用引号括起的字符串常量以及指针所描述的字符串,处理的方式是一样的,都将传递它们的地址。与逐个传递字符串中的所有字符相比,这样做的工作量确实要少。

注意:

在 cout 和多数 C++表达式中,char 数组名、char 指针以及用引号括起的字符串常量都被解释为字符串第一个字符的地址。

程序清单 4.20 演示了如何使用不同形式的字符串。它使用了两个字符串库中的函数。函数 strlen( ) 我们以前用过,它返回字符串的长度。函数 strcpy( ) 将字符串从一个位置复制到另一个位置。这两个函数的原型都位于头文件 cstring(在不太新的实现中,为 string.h)中。该程序还通过注释指出了应尽量避免的错误使用指针的方式。

程序清单 4.20 ptrstr.cpp

下面是该程序的运行情况:

程序说明
程序清单 4.20 中的程序创建了一个 char 数组(animal)和两个指向 char 的指针变量(bird 和 ps)。该程序首先将 animal 数组初始化为字符串“bear”,就像初始化数组一样。然后,程序执行了一些新的操作,将 char 指针初始化为指向一个字符串:

记住,“wren”实际表示的是字符串的地址,因此这条语句将“wren”的地址赋给了 bird 指针。(一般来说,编译器在内存留出一些空间,以存储程序源代码中所有用引号括起的字符串,并将每个被存储的字符串与其地址关联起来。)这意味着可以像使用字符串“wren”那样使用指针 bird,如下面的示例所示:

字符串字面值是常量,这就是为什么代码在声明中使用关键字 const 的原因。以这种方式使用 const 意味着可以用 bird 来访问字符串,但不能修改它。第 7 章将详细介绍 const 指针。最后,指针 ps 未被初始化,因此不指向任何字符串(正如您知道的,这通常是个坏主意,这里也不例外)。

接下来,程序说明了这样一点,即对于 cout 来说,使用数组名 animal 和指针 bird 是一样的。毕竟,它们都是字符串的地址,cout 将显示存储在这两个地址上的两个字符串(“bear”和“wren”)。如果激活错误地显示 ps 的代码,则将可能显示一个空行、一堆乱码,或者程序将崩溃。创建未初始化的指针有点像签发空头支票:无法控制它将被如何使用。

对于输入,情况有点不同。只要输入比较短,能够被存储在数组中,则使用数组 animal 进行输入将是安全的。然而,使用 bird 来进行输入并不合适:

  • 有些编译器将字符串字面值视为只读常量,如果试图修改它们,将导致运行阶段错误。在 C++中,字符串字面值都将被视为常量,但并不是所有的编译器都对以前的行为做了这样的修改。
  • 有些编译器只使用字符串字面值的一个副本来表示程序中所有的该字面值。

下面讨论一下第二点。C++不能保证字符串字面值被唯一地存储。也就是说,如果在程序中多次使用了字符串字面值“wren”,则编译器将可能存储该字符串的多个副本,也可能只存储一个副本。如果是后面一种情况,则将 bird 设置为指向一个“wren”,将使它只是指向该字符串的唯一一个副本。将值读入一个字符串可能会影响被认为是独立的、位于其他地方的字符串。无论如何,由于 bird 指针被声明为 const,因此编译器将禁止改变 bird 指向的位置中的内容。

试图将信息读入 ps 指向的位置将更糟。由于 ps 没有被初始化,因此并不知道信息将被存储在哪里,这甚至可能改写内存中的信息。幸运的是,要避免这种问题很容易—只要使用足够大的 char 数组来接收输入即可。请不要使用字符串常量或未被初始化的指针来接收输入。为避免这些问题,也可以使用 std::string 对象,而不是数组。

警告:

在将字符串读入程序时,应使用已分配的内存地址。该地址可以是数组名,也可以是使用 new 初始化过的指针。

接下来,请注意下述代码完成的工作:

它将生成下面的输出:

一般来说,如果给 cout 提供一个指针,它将打印地址。但如果指针的类型为 char *,则 cout 将显示指向的字符串。如果要显示的是字符串的地址,则必须将这种指针强制转换为另一种指针类型,如 int *(上面的代码就是这样做的)。因此,ps 显示为字符串“fox”,而(int *)ps 显示为该字符串的地址。注意,将 animal 赋给 ps 并不会复制字符串,而只是复制地址。这样,这两个指针将指向相同的内存单元和字符串。

要获得字符串的副本,还需要做其他工作。首先,需要分配内存来存储该字符串,这可以通过声明另一个数组或使用 new 来完成。后一种方法使得能够根据字符串的长度来指定所需的空间:

字符串“fox”不能填满整个 animal 数组,因此这样做浪费了空间。上述代码使用 strlen( ) 来确定字符串的长度,并将它加 1 来获得包含空字符时该字符串的长度。随后,程序使用 new 来分配刚好足够存储该字符串的空间。

接下来,需要将 animal 数组中的字符串复制到新分配的空间中。将 animal 赋给 ps 是不可行的,因为这样只能修改存储在 ps 中的地址,从而失去程序访问新分配内存的唯一途径。需要使用库函数 strcpy( ):

strcpy( ) 函数接受 2 个参数。第一个是目标地址,第二个是要复制的字符串的地址。您应确定,分配了目标空间,并有足够的空间来存储副本。在这里,我们用 strlen( ) 来确定所需的空间,并使用 new 获得可用的内存。

通过使用 strcpy( ) 和 new,将获得“fox”的两个独立副本:

另外,new 在离 animal 数组很远的地方找到了所需的内存空间。

经常需要将字符串放到数组中。初始化数组时,请使用=运算符;否则应使用 strcpy( ) 或 strncpy( )。strcpy( ) 在前面已经介绍过,其工作原理如下:

注意,类似下面这样的代码可能导致问题,因为 food 数组比字符串小:

在这种情况下,函数将字符串中剩余的部分复制到数组后面的内存字节中,这可能会覆盖程序正在使用的其他内存。要避免这种问题,请使用 strncpy( )。该函数还接受第 3 个参数—要复制的最大字符数。然而,要注意的是,如果该函数在到达字符串结尾之前,目标内存已经用完,则它将不会添加空字符。因此,应该这样使用该函数:

这样最多将 19 个字符复制到数组中,然后将最后一个元素设置成空字符。如果该字符串少于 19 个字符,则 strncpy( ) 将在复制完该字符串之后加上空字符,以标记该字符串的结尾。

警告:

应使用 strcpy( ) 或 strncpy( ),而不是赋值运算符来将字符串赋给数组。

您对使用 C-风格字符串和 cstring 库的一些方面有了了解后,便可以理解为何使用 C++ string 类型更为简单了:您不用担心字符串会导致数组越界,并可以使用赋值运算符而不是函数 strcpy( ) 和 strncpy( )。

4.8.4 使用 new 创建动态结构

在运行时创建数组优于在编译时创建数组,对于结构也是如此。需要在程序运行时为结构分配所需的空间,这也可以使用 new 运算符来完成。通过使用 new,可以创建动态结构。同样,“动态”意味着内存是在运行时,而不是编译时分配的。由于类与结构非常相似,因此本节介绍的有关结构的技术也适用于类。

将 new 用于结构由两步组成:创建结构和访问其成员。要创建结构,需要同时使用结构类型和 new。例如,要创建一个未命名的 inflatable 类型,并将其地址赋给一个指针,可以这样做:

这将把足以存储 inflatable 结构的一块可用内存的地址赋给 ps。这种句法和 C++的内置类型完全相同。

比较棘手的一步是访问成员。创建动态结构时,不能将成员运算符句点用于结构名,因为这种结构没有名称,只是知道它的地址。C++专门为这种情况提供了一个运算符:箭头成员运算符(−>)。该运算符由连字符和大于号组成,可用于指向结构的指针,就像点运算符可用于结构名一样。例如,如果 ps 指向一个 inflatable 结构,则 ps−>price 是被指向的结构的 price 成员(参见图 4.11)。

图 4.11 标识结构成员

提示:

有时,C++新手在指定结构成员时,搞不清楚何时应使用句点运算符,何时应使用箭头运算符。规则非常简单。如果结构标识符是结构名,则使用句点运算符;如果标识符是指向结构的指针,则使用箭头运算符。

另一种访问结构成员的方法是,如果 ps 是指向结构的指针,则*ps 就是被指向的值—结构本身。由于*ps 是一个结构,因此(*ps).price 是该结构的 price 成员。C++的运算符优先规则要求使用括号。

程序清单 4.21 使用 new 创建一个未命名的结构,并演示了两种访问结构成员的指针表示法。

程序清单 4.21 newstrct.cpp

下面是该程序的运行情况:

1.一个使用 new 和 delete 的示例

下面介绍一个使用 new 和 delete 来存储通过键盘输入的字符串的示例。程序清单 4.22 定义了一个函数 getname( ),该函数返回一个指向输入字符串的指针。该函数将输入读入到一个大型的临时数组中,然后使用 new [ ]创建一个刚好能够存储该输入字符串的内存块,并返回一个指向该内存块的指针。对于读取大量字符串的程序,这种方法可以节省大量内存(实际编写程序时,使用 string 类将更容易,因为这样可以使用内置的 new 和 delete)。

假设程序要读取 100 个字符串,其中最大的字符串包含 79 个字符,而大多数字符串都短得多。如果用 char 数组来存储这些字符串,则需要 1000 个数组,其中每个数组的长度为 80 个字符。这总共需要 80000 个字节,而其中的很多内存没有被使用。另一种方法是,创建一个数组,它包含 1000 个指向 char 的指针,然后使用 new 根据每个字符串的需要分配相应数量的内存。这将节省几万个字节。是根据输入来分配内存,而不是为每个字符串使用一个大型数组。另外,还可以使用 new 根据需要的指针数量来分配空间。就目前而言,这有点不切实际,即使是使用 1000 个指针的数组也是这样,不过程序清单 4.22 还是演示了一些技巧。另外,为演示 delete 是如何工作的,该程序还用它来释放内存以便能够重新使用。

程序清单 4.22 delete.cpp

下面是该程序的运行情况:

2.程序说明

来看一下程序清单 4.22 中的函数 getname( )。它使用 cin 将输入的单词放到 temp 数组中,然后使用 new 分配新内存,以存储该单词。程序需要 strle(temp)+ 1 个字符(包括空字符)来存储该字符串,因此将这个值提供给 new。获得空间后,getname( ) 使用标准库函数 strcpy( ) 将 temp 中的字符串复制到新的内存块中。该函数并不检查内存块是否能够容纳字符串,但 getname( ) 通过使用 new 请求合适的字节数来完成了这样的工作。最后,函数返回 pn,这是字符串副本的地址。

在 main( ) 中,返回值(地址)被赋给指针 name。该指针是在 main( ) 中定义的,但它指向 getname( ) 函数中分配的内存块。然后,程序打印该字符串及其地址。

接下来,在释放 name 指向的内存块后,main( ) 再次调用 getname( )。C++不保证新释放的内存就是下一次使用 new 时选择的内存,从程序运行结果可知,确实不是。

在这个例子中,getname( ) 分配内存,而 main( ) 释放内存。将 new 和 delete 放在不同的函数中通常并不是个好办法,因为这样很容易忘记使用 delete。不过这个例子确实把 new 和 delete 分开放置了,只是为了说明这样做也是可以的。

为了解该程序的一些更为微妙的方面,需要知道一些有关 C++是如何处理内存的知识。下面介绍一些这样的知识,这些知识将在第 9 章做全面介绍。

4.8.5 自动存储、静态存储和动态存储

根据用于分配内存的方法,C++有 3 种管理数据内存的方式:自动存储、静态存储和动态存储(有时也叫作自由存储空间或堆)。在存在时间的长短方面,以这 3 种方式分配的数据对象各不相同。下面简要地介绍每种类型(C++11 新增了第四种类型—线程存储,这将在第 9 章简要地讨论)。

1.自动存储

在函数内部定义的常规变量使用自动存储空间,被称为自动变量(automatic variable),这意味着它们在所属的函数被调用时自动产生,在该函数结束时消亡。例如,程序清单 4.22 中的 temp 数组仅当 getname( ) 函数活动时存在。当程序控制权回到 main( ) 时,temp 使用的内存将自动被释放。如果 getname( ) 返回 temp 的地址,则 main( ) 中的 name 指针指向的内存将很快得到重新使用。这就是在 getname( ) 中使用 new 的原因之一。

实际上,自动变量是一个局部变量,其作用域为包含它的代码块。代码块是被包含在花括号中的一段代码。到目前为止,我们使用的所有代码块都是整个函数。然而,在下一章将会看到,函数内也可以有代码块。如果在其中的某个代码块定义了一个变量,则该变量仅在程序执行该代码块中的代码时存在。

自动变量通常存储在栈中。这意味着执行代码块时,其中的变量将依次加入到栈中,而在离开代码块时,将按相反的顺序释放这些变量,这被称为后进先出(LIFO)。因此,在程序执行过程中,栈将不断地增大和缩小。

2.静态存储

静态存储是整个程序执行期间都存在的存储方式。使变量成为静态的方式有两种:一种是在函数外面定义它;另一种是在声明变量时使用关键字 static:

在 K&R C 中,只能初始化静态数组和静态结构,而 C++ Release 2.0(及后续版本)和 ANSI C 中,也可以初始化自动数组和自动结构。然而,一些您可能已经发现,有些 C++实现还不支持对自动数组和自动结构的初始化。

第 9 章将详细介绍静态存储。自动存储和静态存储的关键在于:这些方法严格地限制了变量的寿命。变量可能存在于程序的整个生命周期(静态变量),也可能只是在特定函数被执行时存在(自动变量)。

3.动态存储

new 和 delete 运算符提供了一种比自动变量和静态变量更灵活的方法。它们管理了一个内存池,这在 C++中被称为自由存储空间(free store)或堆(heap)。该内存池同用于静态变量和自动变量的内存是分开的。程序清单 4.22 表明,new 和 delete 让您能够在一个函数中分配内存,而在另一个函数中释放它。因此,数据的生命周期不完全受程序或函数的生存时间控制。与使用常规变量相比,使用 new 和 delete 让程序员对程序如何使用内存有更大的控制权。然而,内存管理也更复杂了。在栈中,自动添加和删除机制使得占用的内存总是连续的,但 new 和 delete 的相互影响可能导致占用的自由存储区不连续,这使得跟踪新分配内存的位置更困难。

栈、堆和内存泄漏

如果使用 new 运算符在自由存储空间(或堆)上创建变量后,没有调用 delete,将发生什么情况呢?如果没有调用 delete,则即使包含指针的内存由于作用域规则和对象生命周期的原因而被释放,在自由存储空间上动态分配的变量或结构也将继续存在。实际上,将会无法访问自由存储空间中的结构,因为指向这些内存的指针无效。这将导致内存泄漏。被泄漏的内存将在程序的整个生命周期内都不可使用;这些内存被分配出去,但无法收回。极端情况(不过不常见)是,内存泄漏可能会非常严重,以致于应用程序可用的内存被耗尽,出现内存耗尽错误,导致程序崩溃。另外,这种泄漏还会给一些操作系统或在相同的内存空间中运行的应用程序带来负面影响,导致它们崩溃。

即使是最好的程序员和软件公司,也可能导致内存泄漏。要避免内存泄漏,最好是养成这样一种习惯,即同时使用 new 和 delete 运算符,在自由存储空间上动态分配内存,随后便释放它。C++智能指针有助于自动完成这种任务,这将在第 16 章介绍。

注意:

指针是功能最强大的 C++工具之一,但也最危险,因为它们允许执行对计算机不友好的操作,如使用未经初始化的指针来访问内存或者试图释放同一个内存块两次。另外,在通过实践习惯指针表示法和指针概念之前,指针是容易引起迷惑的。由于指针是 C++编程的重要组成部分,本书后面将更详细地讨论它。本书多次对指针进行了讨论,就是希望您能够越来越熟悉它。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文