0.2. 基本指针¶
0.2.1. 什么是指针?¶
即使不了解指针,你也可以编写许多优美、整洁的代码。但一旦你学会运用指针的强大功能,就再也无法回头了。有太多事情只能通过指针来完成。然而,能力越大,责任也越大。指针会引发新型且更隐蔽的错误,而指针错误可能以随机方式导致程序崩溃,这使得调试更加困难。尽管如此,即便存在这些问题,指针仍是一种令人难以抗拒的强大编程构造。(以下解释在需要语法时采用 C 语言语法;有关 Java 的讨论见相应章节。)
指针解决了两个常见的软件问题。首先,指针允许代码的不同部分轻松共享信息。虽然通过来回复制信息也能达到相同效果,但指针能更好地解决这一问题。其次,指针支持链表和二叉树等复杂的链式数据结构。
简单 int 和 float 变量的操作非常直观。一个 int 变量就像一个盒子,可以存储单个 int 值,例如 42。在图示中,简单变量是一个盒子,其当前值绘制在内部。
指针的工作方式略有不同,它不直接存储一个简单的值。相反,指针存储一个 引用 指向另一个值。指针所引用的变量有时被称为其 被指向对象 。在图示中,指针是一个方框,其中包含一支箭头的起点,该箭头指向其 pointee。(对于 pointee—这一概念,并没有单一、官方的术语;pointee 只是这些解释中使用的词。)
下图展示了两个变量: num 和 numPtr 。简单变量 num 以常规方式包含值 42。变量 numPtr 是一个指针,其中包含对变量 num 的引用。 numPtr 变量是指针,而 num 是其指向的对象。 numPtr 中存储的是什么?它的值不是一个 int 。它的值是对一个 int 的引用。
0.2.2. 指针引用与解引用¶
解引用 操作遵循指针的引用来获取其指向对象的值。上述 numPtr 解引用的值为 42。当正确使用解引用操作时,它很简单。它只是访问指向对象的值。唯一的限制是指针必须具有指向对象才能进行解引用访问。指针代码中的几乎所有错误都涉及违反这一限制。在进行解引用操作之前,必须为指针分配一个指向对象。
常量 NULL 是一个特殊的指针值,用于编码“指向空”的概念。拥有一个定义明确的指针值来表示指针没有指向对象的想法是很方便的。解引用 NULL 指针会导致运行时错误。在图示中,NULL 值通常绘制为指针变量方框角落之间的一条对角线。
C 语言使用符号 NULL 来实现此目的。NULL 等于整数常量 0,因此 NULL 可以充当布尔值 false 的角色。官方 C++ 不再使用 NULL 符号常量—,而是直接使用整数常量 0。Java 使用符号 null。
0.2.3. 指针赋值¶
两个指针之间的赋值操作 ( = ) 会使它们指向同一个被指对象。这是一条针对潜在复杂情况的简单规则,因此值得重复:将一个指针赋值给另一个指针会使它们指向同一事物。下面的示例添加了第二个指针 second ,并通过语句 second = numPtr; 进行赋值。结果是 second 指向与 numPtr 相同的被指对象。在图中,这意味着 second 和 numPtr 框都包含指向 num 的箭头。指针之间的赋值不会改变甚至触及被指对象,它只是改变指针所引用的被指对象。
赋值后, == 测试比较两个指针将返回 true。例如,上面的 (second==numPtr) 为 true。赋值操作也适用于 NULL 值。使用 NULL 指针的赋值操作会将 NULL 值从一个指针复制到另一个指针。
内存图示是思考指针代码的关键。当你查看代码并思考其在运行时如何使用内存时,请快速绘制图示来梳理你的思路。本教程当然会使用图示来展示指针的工作原理。这正是正确的方法。
0.2.3.1. 共享¶
两个指向同一被指对象的指针被称为“共享”。两个或多个实体能够协作共享单个内存结构,这是所有计算机语言中指针的关键优势。指针操作只是技术—共享往往是真正的目标。稍后我们将看到如何利用共享来实现程序各部分之间的高效通信。
0.2.3.2. 浅拷贝与深拷贝¶
特别是,共享能够实现两个函数之间的通信。一个函数将指向目标值的指针传递给另一个函数。两个函数都可以访问该目标值,但目标值本身不会被复制。这种通信称为 浅拷贝 ,因为它不是制作并发送目标值的(大)副本,而是发送一个(小)指针并共享目标值。接收方需要明白自己拥有的是浅拷贝,因此知道不应修改或删除它,因为它是共享的。另一种制作并发送完整副本的方式称为 深拷贝 。深拷贝在某种意义上更简单,因为每个函数都可以修改自己的副本而不干扰另一个副本,但由于涉及大量复制操作,深拷贝运行速度较慢。下图展示了两个函数 A() 和 B() 之间的浅拷贝与深拷贝。在浅拷贝情况下,笑脸通过在这两个函数之间传递指针而被共享。在深拷贝情况下,笑脸被复制,每个函数都获得自己的副本。
下一个模块将详细解释上述共享技术。
0.2.4. 错误的指针¶
当指针首次分配时,它没有指向对象。该指针为 未初始化 或简称为“坏”。对坏指针进行解引用操作是严重的运行时错误。如果幸运,解引用操作会立即崩溃或停止(Java 就是这样表现的)。如果不幸,坏指针的解引用将破坏随机内存区域,轻微改变程序的运行,使其在之后的某个不确定时间出错。每个指针在支持解引用操作之前必须被赋予一个指向对象。在此之前,指针是坏的,不得使用。在我们的内存图示中,坏指针值用 XXX 值表示。
无效指针非常常见。事实上,每个指针初始时都包含一个无效值。正确的代码会用指向被指对象的有效引用来覆盖该无效值,此后指针即可正常工作。没有任何机制会自动为指针分配有效的被指对象。
恰恰相反—大多数语言使得省略这一重要步骤变得很容易。你只需谨慎编程即可。如果代码崩溃,首先应怀疑是指针错误。Perl、LISP 和 Java 等动态语言中的指针工作方式略有不同。运行时系统会在分配时将每个指针设置为 NULL,并在每次解引用时进行检查。因此,代码仍可能出现指针错误,但它们会在出错的行上优雅地停止,而不是像 C 那样随意崩溃。因此,在动态语言中定位和修复指针错误要容易得多。运行时检查也是这类语言的运行速度总是至少比 C 或 C++ 等编译型语言稍慢的原因之一。
理解指针代码的一种方式是将其视为在两个层级上运作—指针层级和指向对象层级。关键在于, 两者 都必须初始化并连接才能正常工作。(1) 必须分配指针,(1) 必须分配指向对象,以及 (3) 必须将指针赋值为指向该指向对象。很少有人会忘记步骤 (1)。但如果忘记 (2) 或 (3),整个程序会在第一次解引用时崩溃。请记住要兼顾这两个层级—在设计过程中绘制内存图示以确保正确无误。
0.2.5. 语法¶
上述关于指针、被指向对象、解引用和赋值的基本特性,是构建指针代码所需的全部概念。然而,为了讨论指针代码,我们需要使用一种已知的语法,而这种语法的趣味性大概就和……语法本身差不多。我们将使用 Java 语言语法,它的优势在于影响了多种语言的语法。
0.2.5.1. 指针类型语法¶
C 语言中的指针类型只是在所指向类型后加一个星号(*)。
int* 类型:指向 int 的指针
float* 类型:指向 float 的指针
struct fraction* 类型:指向 struct fraction 的指针
struct fraction** 类型: pointer to struct fraction*
0.2.5.2. 指针变量¶
指针变量的声明方式与其他任何变量相同。声明指定了新变量的类型和名称,并预留内存以存储其值。该声明并未为指针—分配指向对象,因此指针初始时具有无效值。
int* numPtr; // Declare the int* (pointer to int) variable numPtr.
// This allocates space for the pointer, but not the pointee.
// The pointer starts out "bad"
0.2.5.3. & 运算符—引用¶
有几种方法可以计算一个适合存储在指针中的指向对象的引用。最简单的方法是使用 & 运算符。& 运算符可以放在任何变量的左侧,它会计算该变量的引用。下面的代码使用了一个指针和一个 & 来生成前面的 num/numPtr 示例。
void NumPtrExample() {
int num;
int* numPtr;
num = 42;
numPtr = #
// Compute a reference to num, and store it in numPtr
// At this point, memory looks like drawing above
}
可以使用 & 的方式能够顺利编译,但会在运行时产生问题。关于如何正确使用 & 的完整讨论将在下一个模块中进行。目前我们仅以简单方式使用 &。
0.2.5.4. “*” 运算符—解引用¶
星号运算符 ( ) 对指针进行解引用。 是一元运算符,位于其解引用的指针左侧。指针必须指向一个目标对象,否则将导致运行时错误。
0.2.6. 指针代码示例¶
语法定义完成后,我们现在可以编写一些指针代码来演示所有指针规则。
void PointerTest() {
// allocate three integers and two pointers
int a = 1;
int b = 2;
int c = 3;
int* p;
int* q;
// Here is the state of memory at this point.
// T1 -- Notice that the pointers start out bad.
p = &a;
// set p to refer to a
q = &b;
// set q to refer to b
// T2 -- The pointers now have pointees
// Now we mix things up a bit
c = *p;
// retrieve p's pointee value (1) and put it in c
p = q;
// change p to share with q (p's pointee is now b)
*p = 13;
// dereference p to set its pointee (b) to 13 (*q is now 13)
// T3 -- Dereferences and assignments mix things up
0.2.6.1. 错误的指针示例¶
包含最常见指针错误的代码 看起来 会与上述正确代码相似,但缺少为指针分配所指对象这一中间步骤。错误代码可以顺利编译,但在运行时,每次通过坏指针进行解引用都会以某种方式破坏内存。程序迟早会崩溃。程序员有责任确保每个指针在使用前都已分配了所指对象。以下示例展示了一段简单的错误代码及其内存可能反应的示意图。
void BadPointer() {
int* p;
// allocate the pointer, but not the pointee
*p = 42;
// this dereference is a serious runtime error
}
// What happens at runtime when the bad pointer is dereferenced?
0.2.7. 指针规则总结¶
无论指针结构变得多么复杂,规则列表始终很短。
指针存储对其指向对象的引用。该指向对象反过来存储有用的内容。
对指针的解引用操作会访问其所指向的对象。指针只有在被赋值为指向某个对象后才能进行解引用。大多数指针错误都源于违反这一规则。
分配指针并不会自动使其指向某个被指对象。将指针赋值为指向特定被指对象是一个独立的操作,很容易被遗忘。
两个指针之间的赋值会使它们指向同一个被指对象,从而引入共享。
0.2.8. Java 中的指针是如何工作的?¶
Java 拥有指针,但无法使用如 * 和 & 之类的显式运算符对其进行操作。在 Java 中,简单数据类型如 int 和 char 的操作方式与 C 语言相同。数组和对象等更复杂的类型则自动通过指针实现。对于此类复杂类型,该语言会在后台自动使用指针,无需特定的指针语法。程序员只需意识到,如果 a 和 b 是数组或对象,那么像 a=b; 这样的操作将自动通过指针实现。换句话说,程序员需要记住,涉及数组和对象的赋值及参数传递本质上是浅层共享的—(参见上方的“深层 vs. 浅层”内容)。以下代码展示了一些 Java 对象引用。请注意,代码中并没有用于创建指针的 * 或 & 。该代码本质上使用了指针。此外,垃圾回收器会在函数结束时自动处理内存释放。
public void JavaShallow() {
Foo a = new Foo();
// Create a Foo object (no * in the declaration)
Foo b = new Foo();
// Create another Foo object
b=a;
// This is automatically a shallow assignment --
// a and b now refer to the same object.
a.Bar();
// This could just as well be written b.Bar();
// There is no memory leak here -- the garbage collector
// will automatically recycle the memory for the two objects.
}
Java 方法具有两个主要特点。
更少的错误。由于该语言准确且自动地实现了指针操作,最常见的指针错误不再可能发生,太好了!此外,Java 运行时系统每次使用指针值时都会进行检查,因此空指针解引用会在发生的行立即被捕获。这可以大大提高程序员的生产效率。
较慢。由于语言在运行时负责实现大量指针机制,Java 代码的运行速度慢于等效的 C 代码。(Java 运行缓慢还有其他原因。目前正积极研究以有趣的方式提升 Java 速度—,例如 Sun 公司的"Hot Spot"项目。)无论如何,对于某些应用而言,提高程序员效率并减少错误所带来的吸引力,足以弥补其速度上的不足。
0.2.9. 指针在机器中是如何实现的?¶
指针是如何实现的?简短的解释是,机器中的每个内存区域都有一个数字地址,如 1000 或 20452。指向内存区域的指针实际上只是一个存储该区域地址的整数。解引用操作会查看该地址,并前往该内存区域检索存储在那里的被指对象。指针赋值只是将数字地址从一个指针复制到另一个指针。NULL 值通常只是数字地址 0—,计算机从不在地址 0 处分配被指对象,因此该地址可用于表示 NULL。坏指针实际上只是一个包含随机地址—的指针,就像未初始化 int 的变量一开始具有随机 int 值一样。该指针尚未被赋值为有效被指对象的特定地址。这就是为什么使用坏指针进行解引用操作如此不可预测的原因。它们会对碰巧拥有其地址的任何随机内存区域进行操作。
0.2.10. 术语“引用”¶
单词 引用 的含义与"pointer to struct fraction*"几乎相同。区别在于,"reference"一词通常用于讨论不特定于任何语言或实现的指针问题。而"pointer"一词则暗示了 C/C++ 中将指针实现为地址的常见方式。"reference"一词也用于短语 引用参数 中,这是一种利用指针参数在函数之间进行双向通信的技术。该技术将是后续模块的主题。
0.2.11. 为什么坏指针错误如此常见?¶
为什么程序员经常分配指针,却忘记将其设置为指向点对象?指针的规则似乎并不复杂,但每位程序员都会反复犯这个错误。为什么?问题在于我们被所使用的工具所训练。简单变量不需要任何额外设置。你可以分配一个简单变量,例如 int ,并立即使用它。你编写的所有 int 、 char 、struct fraction 代码都合理地训练了你:变量一旦声明即可使用。不幸的是,指针看起来像简单变量,但在使用前需要额外的初始化。在某种程度上,指针碰巧看起来与其他变量相似,这令人遗憾,因为它让人容易忘记其使用规则截然不同。算了。请尽量记得将指针赋值为指向点对象。当你忘记时,也不要感到惊讶。
