- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
4.7 指针和自由存储空间
在第 3 章的开头,提到了计算机程序在存储数据时必须跟踪的 3 种基本属性。为了方便,这里再次列出了这些属性:
- 信息存储在何处;
- 存储的值为多少;
- 存储的信息是什么类型。
您使用过一种策略来达到上述目的:定义一个简单变量。声明语句指出了值的类型和符号名,还让程序为值分配内存,并在内部跟踪该内存单元。
下面来看一看另一种策略,它在开发 C++类时非常重要。这种策略以指针为基础,指针是一个变量,其存储的是值的地址,而不是值本身。在讨论指针之前,我们先看一看如何找到常规变量的地址。只需对变量应用地址运算符(&),就可以获得它的位置;例如,如果 home 是一个变量,则&home 是它的地址。程序清单 4.14 演示了这个运算符的用法。
程序清单 4.14 address.cpp
下面是该程序在某个系统上的输出:
显示地址时,该实现的 cout 使用十六进制表示法,因为这是常用于描述内存的表示法(有些实现可能使用十进制表示法)。在该实现中,donuts 的存储位置比 cups 要低。两个地址的差为 0x0065fd44 – 0x0065fd40(即 4)。这是有意义的,因为 donuts 的类型为 int,而这种类型使用 4 个字节。当然,不同系统给定的地址值可能不同。有些系统可能先存储 cups,再存储 donuts,这样两个地址值的差将为 8 个字节,因为 cups 的类型为 double。另外,在有些系统中,可能不会将这两个变量存储在相邻的内存单元中。
使用常规变量时,值是指定的量,而地址为派生量。下面来看看指针策略,它是 C++内存管理编程理念的核心(参见旁注“指针与 C++基本原理”)。
指针与 C++基本原理
面向对象编程与传统的过程性编程的区别在于,OOP 强调的是在运行阶段(而不是编译阶段)进行决策。运行阶段指的是程序正在运行时,编译阶段指的是编译器将程序组合起来时。运行阶段决策就好比度假时,选择参观哪些景点取决于天气和当时的心情;而编译阶段决策更像不管在什么条件下,都坚持预先设定的日程安排。
运行阶段决策提供了灵活性,可以根据当时的情况进行调整。例如,考虑为数组分配内存的情况。传统的方法是声明一个数组。要在 C++中声明数组,必须指定数组的长度。因此,数组长度在程序编译时就设定好了;这就是编译阶段决策。您可能认为,在 80%的情况下,一个包含 20 个元素的数组足够了,但程序有时需要处理 200 个元素。为了安全起见,使用了一个包含 200 个元素的数组。这样,程序在大多数情况下都浪费了内存。OOP 通过将这样的决策推迟到运行阶段进行,使程序更灵活。在程序运行后,可以这次告诉它只需要 20 个元素,而还可以下次告诉它需要 205 个元素。
总之,使用 OOP 时,您可能在运行阶段确定数组的长度。为使用这种方法,语言必须允许在程序运行时创建数组。稍后您看会到,C++采用的方法是,使用关键字 new 请求正确数量的内存以及使用指针来跟踪新分配的内存的位置。
在运行阶段做决策并非 OOP 独有的,但使用 C++编写这样的代码比使用 C 语言简单。
处理存储数据的新策略刚好相反,将地址视为指定的量,而将值视为派生量。一种特殊类型的变量—指针用于存储值的地址。因此,指针名表示的是地址。*运算符被称为间接值(indirect velue)或解除引用(dereferencing)运算符,将其应用于指针,可以得到该地址处存储的值(这和乘法使用的符号相同;C++根据上下文来确定所指的是乘法还是解除引用)。例如,假设 manly 是一个指针,则 manly 表示的是一个地址,而*manly 表示存储在该地址处的值。*manly 与常规 int 变量等效。程序清单 4.15 说明了这几点,它还演示了如何声明指针。
程序清单 4.15 pointer.cpp
下面是该程序的输出:
从中可知,int 变量 updates 和指针变量 p_updates 只不过是同一枚硬币的两面。变量 updates 表示值,并使用&运算符来获得地址;而变量 p_updates 表示地址,并使用*运算符来获得值(参见图 4.8)。由于 p_updates 指向 updates,因此*p_updates 和 updates 完全等价。可以像使用 int 变量那样使用*p_updates。正如程序清单 4.15 表明的,甚至可以将值赋给*p_updates。这样做将修改指向的值,即 updates。
图 4.8 硬币的两面
4.7.1 声明和初始化指针
我们来看看如何声明指针。计算机需要跟踪指针指向的值的类型。例如,char 的地址与 double 的地址看上去没什么两样,但 char 和 double 使用的字节数是不同的,它们存储值时使用的内部格式也不同。因此,指针声明必须指定指针指向的数据的类型。
例如,前一个示例包含这样的声明:
这表明,* p_updates 的类型为 int。由于*运算符被用于指针,因此 p_updates 变量本身必须是指针。我们说 p_updates 指向 int 类型,我们还说 p_updates 的类型是指向 int 的指针,或 int*。可以这样说,p_updates 是指针(地址),而*p_updates 是 int,而不是指针(见图 4.9)。
图 4.9 指针存储地址
顺便说一句,*运算符两边的空格是可选的。传统上,C 程序员使用这种格式:
这强调*ptr 是一个 int 类型的值。而很多 C++程序员使用这种格式:
这强调的是:int*是一种类型—指向 int 的指针。在哪里添加空格对于编译器来说没有任何区别,您甚至可以这样做:
但要知道的是,下面的声明创建一个指针(p1)和一个 int 变量(p2):
对每个指针变量名,都需要使用一个*。
注意:
在 C++中,int *是一种复合类型,是指向 int 的指针。
可以用同样的句法来声明指向其他类型的指针:
由于已将 tax_ptr 声明为一个指向 double 的指针,因此编译器知道*tax_ptr 是一个 double 类型的值。也就是说,它知道*tax_ptr 是一个以浮点格式存储的值,这个值(在大多数系统上)占据 8 个字节。指针变量不仅仅是指针,而且是指向特定类型的指针。tax_ptr 的类型是指向 double 的指针(或 double *类型),str 是指向 char 的指针类型(或 char *)。尽管它们都是指针,却是不同类型的指针。和数组一样,指针都是基于其他类型的。
虽然 tax_ptr 和 str 指向两种长度不同的数据类型,但这两个变量本身的长度通常是相同的。也就是说,char 的地址与 double 的地址的长度相同,这就好比 1016 可能是超市的街道地址,而 1024 可以是小村庄的街道地址一样。地址的长度或值既不能指示关于变量的长度或类型的任何信息,也不能指示该地址上有什么建筑物。一般来说,地址需要 2 个还是 4 个字节,取决于计算机系统(有些系统可能需要更大的地址,系统可以针对不同的类型使用不同长度的地址)。
可以在声明语句中初始化指针。在这种情况下,被初始化的是指针,而不是它指向的值。也就是说,下面的语句将 pt(而不是*pt)的值设置为&higgens:
程序清单 4.16 演示了如何将指针初始化为一个地址。
程序清单 4.16 init_ptr.cpp
下面是该程序的示例输出:
从中可知,程序将 pi(而不是*pi)初始化为 higgens 的地址。在您的系统上,显示的地址可能不同,显示格式也可能不同。
4.7.2 指针的危险
危险更易发生在那些使用指针不仔细的人身上。极其重要的一点是:在 C++中创建指针时,计算机将分配用来存储地址的内存,但不会分配用来存储指针所指向的数据的内存。为数据提供空间是一个独立的步骤,忽略这一步无疑是自找麻烦,如下所示:
fellow 确实是一个指针,但它指向哪里呢?上述代码没有将地址赋给 fellow。那么 223323 将被放在哪里呢?我们不知道。由于 fellow 没有被初始化,它可能有任何值。不管值是什么,程序都将它解释为存储 223323 的地址。如果 fellow 的值碰巧为 1200,计算机将把数据放在地址 1200 上,即使这恰巧是程序代码的地址。fellow 指向的地方很可能并不是所要存储 223323 的地方。这种错误可能会导致一些最隐匿、最难以跟踪的 bug。
警告:
一定要在对指针应用解除引用运算符(*)之前,将指针初始化为一个确定的、适当的地址。这是关于使用指针的金科玉律。
4.7.3 指针和数字
指针不是整型,虽然计算机通常把地址当作整数来处理。从概念上看,指针与整数是截然不同的类型。整数是可以执行加、减、除等运算的数字,而指针描述的是位置,将两个地址相乘没有任何意义。从可以对整数和指针执行的操作上看,它们也是彼此不同的。因此,不能简单地将整数赋给指针:
在这里,左边是指向 int 的指针,因此可以把它赋给地址,但右边是一个整数。您可能知道,0xB8000000 是老式计算机系统中视频内存的组合段偏移地址,但这条语句并没有告诉程序,这个数字就是一个地址。在 C99 标准发布之前,C 语言允许这样赋值。但 C++在类型一致方面的要求更严格,编译器将显示一条错误消息,通告类型不匹配。要将数字值作为地址来使用,应通过强制类型转换将数字转换为适当的地址类型:
这样,赋值语句的两边都是整数的地址,因此这样赋值有效。注意,pt 是 int 值的地址并不意味着 pt 本身的类型是 int。例如,在有些平台中,int 类型是个 2 字节值,而地址是个 4 字节值。
指针还有其他一些有趣的特性,这将在合适的时候讨论。下面看看如何使用指针来管理运行阶段的内存空间分配。
4.7.4 使用 new 来分配内存
对指针的工作方式有一定了解后,来看看它如何实现在程序运行时分配内存。前面我们都将指针初始化为变量的地址;变量是在编译时分配的有名称的内存,而指针只是为可以通过名称直接访问的内存提供了一个别名。指针真正的用武之地在于,在运行阶段分配未命名的内存以存储值。在这种情况下,只能通过指针来访问内存。在 C 语言中,可以用库函数 malloc( ) 来分配内存;在 C++中仍然可以这样做,但 C++还有更好的方法—new 运算符。
下面来试试这种新技术,在运行阶段为一个 int 值分配未命名的内存,并使用指针来访问这个值。这里的关键所在是 C++的 new 运算符。程序员要告诉 new,需要为哪种数据类型分配内存;new 将找到一个长度正确的内存块,并返回该内存块的地址。程序员的责任是将该地址赋给一个指针。下面是一个这样的示例:
new int 告诉程序,需要适合存储 int 的内存。new 运算符根据类型来确定需要多少字节的内存。然后,它找到这样的内存,并返回其地址。接下来,将地址赋给 pn,pn 是被声明为指向 int 的指针。现在,pn 是地址,而*pn 是存储在那里的值。将这种方法与将变量的地址赋给指针进行比较:
在这两种情况(pn 和 pt)下,都是将一个 int 变量的地址赋给了指针。在第二种情况下,可以通过名称 higgens 来访问该 int,在第一种情况下,则只能通过该指针进行访问。这引出了一个问题:pn 指向的内存没有名称,如何称呼它呢?我们说 pn 指向一个数据对象,这里的“对象”不是“面向对象编程”中的对象,而是一样“东西”。术语“数据对象”比“变量”更通用,它指的是为数据项分配的内存块。因此,变量也是数据对象,但 pn 指向的内存不是变量。乍一看,处理数据对象的指针方法可能不太好用,但它使程序在管理内存方面有更大的控制权。
为一个数据对象(可以是结构,也可以是基本类型)获得并指定分配内存的通用格式如下:
需要在两个地方指定数据类型:用来指定需要什么样的内存和用来声明合适的指针。当然,如果已经声明了相应类型的指针,则可以使用该指针,而不用再声明一个新的指针。程序清单 4.17 演示了如何将 new 用于两种不同的类型。
程序清单 4.17 use_new.cpp
下面是该程序的输出:
当然,内存位置的准确值随系统而异。
程序说明
该程序使用 new 分别为 int 类型和 double 类型的数据对象分配内存。这是在程序运行时进行的。指针 pt 和 pd 指向这两个数据对象,如果没有它们,将无法访问这些内存单元。有了这两个指针,就可以像使用变量那样使用*pt 和*pd 了。将值赋给*pt 和*pd,从而将这些值赋给新的数据对象。同样,可以通过打印*pt 和*pd 来显示这些值。
该程序还指出了必须声明指针所指向的类型的原因之一。地址本身只指出了对象存储地址的开始,而没有指出其类型(使用的字节数)。从这两个值的地址可以知道,它们都只是数字,并没有提供类型或长度信息。另外,指向 int 的指针的长度与指向 double 的指针相同。它们都是地址,但由于 use_new.cpp 声明了指针的类型,因此程序知道*pd 是 8 个字节的 double 值,*pt 是 4 个字节的 int 值。use_new.cpp 打印*pd 的值时,cout 知道要读取多少字节以及如何解释它们。
对于指针,需要指出的另一点是,new 分配的内存块通常与常规变量声明分配的内存块不同。变量 nights 和 pd 的值都存储在被称为栈(stack)的内存区域中,而 new 从被称为堆(heap)或自由存储区(free store)的内存区域分配内存。第 9 章将更详细地讨论这一点。
内存被耗尽?
计算机可能会由于没有足够的内存而无法满足 new 的请求。在这种情况下,new 通常会引发异常—一种将在第 15 章讨论的错误处理技术;而在较老的实现中,new 将返回 0。在 C++中,值为 0 的指针被称为空指针(null pointer)。C++确保空指针不会指向有效的数据,因此它常被用来表示运算符或函数失败(如果成功,它们将返回一个有用的指针)。将在第 6 章讨论的 if 语句可帮助您处理这种问题;就目前而言,您只需如下要点:C++提供了检测并处理内存分配失败的工具。
4.7.5 使用 delete 释放内存
当需要内存时,可以使用 new 来请求,这只是 C++内存管理数据包中有魅力的一个方面。另一个方面是 delete 运算符,它使得在使用完内存后,能够将其归还给内存池,这是通向最有效地使用内存的关键一步。归还或释放(free)的内存可供程序的其他部分使用。使用 delete 时,后面要加上指向内存块的指针(这些内存块最初是用 new 分配的):
这将释放 ps 指向的内存,但不会删除指针 ps 本身。例如,可以将 ps 重新指向另一个新分配的内存块。一定要配对地使用 new 和 delete;否则将发生内存泄漏(memory leak),也就是说,被分配的内存再也无法使用了。如果内存泄漏严重,则程序将由于不断寻找更多内存而终止。
不要尝试释放已经释放的内存块,C++标准指出,这样做的结果将是不确定的,这意味着什么情况都可能发生。另外,不能使用 delete 来释放声明变量所获得的内存:
警告:
只能用 delete 来释放使用 new 分配的内存。然而,对空指针使用 delete 是安全的。
注意,使用 delete 的关键在于,将它用于 new 分配的内存。这并不意味着要使用用于 new 的指针,而是用于 new 的地址:
一般来说,不要创建两个指向同一个内存块的指针,因为这将增加错误地删除同一个内存块两次的可能性。但稍后您会看到,对于返回指针的函数,使用另一个指针确实有道理。
4.7.6 使用 new 来创建动态数组
如果程序只需要一个值,则可能会声明一个简单变量,因为对于管理一个小型数据对象来说,这样做比使用 new 和指针更简单,尽管给人留下的印象不那么深刻。通常,对于大型数据(如数组、字符串和结构),应使用 new,这正是 new 的用武之地。例如,假设要编写一个程序,它是否需要数组取决于运行时用户提供的信息。如果通过声明来创建数组,则在程序被编译时将为它分配内存空间。不管程序最终是否使用数组,数组都在那里,它占用了内存。在编译时给数组分配内存被称为静态联编(static binding),意味着数组是在编译时加入到程序中的。但使用 new 时,如果在运行阶段需要数组,则创建它;如果不需要,则不创建。还可以在程序运行时选择数组的长度。这被称为动态联编(dynamic binding),意味着数组是在程序运行时创建的。这种数组叫作动态数组(dynamic array)。使用静态联编时,必须在编写程序时指定数组的长度;使用动态联编时,程序将在运行时确定数组的长度。
下面来看一下关于动态数组的两个基本问题:如何使用 C++的 new 运算符创建数组以及如何使用指针访问数组元素。
1.使用 new 创建动态数组
在 C++中,创建动态数组很容易;只要将数组的元素类型和元素数目告诉 new 即可。必须在类型名后加上方括号,其中包含元素数目。例如,要创建一个包含 10 个 int 元素的数组,可以这样做:
new 运算符返回第一个元素的地址。在这个例子中,该地址被赋给指针 psome。
当程序使用完 new 分配的内存块时,应使用 delete 释放它们。然而,对于使用 new 创建的数组,应使用另一种格式的 delete 来释放:
方括号告诉程序,应释放整个数组,而不仅仅是指针指向的元素。请注意 delete 和指针之间的方括号。如果使用 new 时,不带方括号,则使用 delete 时,也不应带方括号。如果使用 new 时带方括号,则使用 delete 时也应带方括号。C++的早期版本无法识别方括号表示法。然而,对于 ANSI/ISO 标准来说,new 与 delete 的格式不匹配导致的后果是不确定的,这意味着程序员不能依赖于某种特定的行为。下面是一个例子:
总之,使用 new 和 delete 时,应遵守以下规则。
- 不要使用 delete 来释放不是 new 分配的内存。
- 不要使用 delete 释放同一个内存块两次。
- 如果使用 new [ ]为数组分配内存,则应使用 delete [ ]来释放。
- 如果使用 new [ ]为一个实体分配内存,则应使用 delete(没有方括号)来释放。
- 对空指针应用 delete 是安全的。
现在我们回过头来讨论动态数组。psome 是指向一个 int(数组第一个元素)的指针。您的责任是跟踪内存块中的元素个数。也就是说,由于编译器不能对 psome 是指向 10 个整数中的第 1 个这种情况进行跟踪,因此编写程序时,必须让程序跟踪元素的数目。
实际上,程序确实跟踪了分配的内存量,以便以后使用 delete [ ]运算符时能够正确地释放这些内存。但这种信息不是公用的,例如,不能使用 sizeof 运算符来确定动态分配的数组包含的字节数。
为数组分配内存的通用格式如下:
使用 new 运算符可以确保内存块足以存储 num_elements 个类型为 type_name 的元素,而 pointer_name 将指向第 1 个元素。下面将会看到,可以以使用数组名的方式来使用 pointer_name。
2.使用动态数组
创建动态数组后,如何使用它呢?首先,从概念上考虑这个问题。下面的语句创建指针 psome,它指向包含 10 个 int 值的内存块中的第 1 个元素:
可以将它看作是一根指向该元素的手指。假设 int 占 4 个字节,则将手指沿正确的方向移动 4 个字节,手指将指向第 2 个元素。总共有 10 个元素,这就是手指的移动范围。因此,new 语句提供了识别内存块中每个元素所需的全部信息。
现在从实际角度考虑这个问题。如何访问其中的元素呢?第一个元素不成问题。由于 psome 指向数组的第 1 个元素,因此*psome 是第 1 个元素的值。这样,还有 9 个元素。如果没有使用过 C 语言,下面这种最简单的方法可能会令您大吃一惊:只要把指针当作数组名使用即可。也就是说,对于第 1 个元素,可以使用 psome[0],而不是*psome;对于第 2 个元素,可以使用 psome[1],依此类推。这样,使用指针来访问动态数组就非常简单了,虽然还不知道为何这种方法管用。可以这样做的原因是,C 和 C++内部都使用指针来处理数组。数组和指针基本等价是 C 和 C++的优点之一(这在有时候也是个问题,但这是另一码事)。稍后将更详细地介绍这种等同性。首先,程序清单 4.18 演示了如何使用 new 来创建动态数组以及使用数组表示法来访问元素;它还指出了指针和真正的数组名之间的根本差别。
程序清单 4.18 arraynew.cpp
下面是该程序的输出:
从中可知,arraynew.cpp 将指针 p3 当作数组名来使用,p3[0]为第 1 个元素,依次类推。下面的代码行指出了数组名和指针之间的根本差别:
不能修改数组名的值。但指针是变量,因此可以修改它的值。请注意将 p3 加 1 的效果。表达式 p3[0]现在指的是数组的第 2 个值。因此,将 p3 加 1 导致它指向第 2 个元素而不是第 1 个。将它减 1 后,指针将指向原来的值,这样程序便可以给 delete[ ]提供正确的地址。
相邻的 int 地址通常相差 2 个字节或 4 个字节,而将 p3 加 1 后,它将指向下一个元素的地址,这表明指针算术有一些特别的地方。情况确实如此。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论