指针

在前面的章节中,变量被解释为计算机内存中的位置,可以通过其标识符(即名称)进行访问。这样,程序就不需要关心数据在内存中的物理地址;它只需要在需要引用变量时使用标识符。

对于C++程序而言,计算机的内存就像一系列内存单元,每个单元大小为一字节,并具有唯一的地址。这些单字节内存单元的排列方式使得大于一字节的数据表示可以占据具有连续地址的内存单元。

这样,每个单元都可以通过其唯一的地址轻松定位在内存中。例如,地址为1776的内存单元紧跟在地址为1775的单元之后,并位于地址为1777的单元之前,它比地址776晚一千个单元,比地址2776早一千个单元。

当声明一个变量时,存储其值所需的内存会被分配到内存中的一个特定位置(其内存地址)。通常,C++程序不会主动决定其变量存储的确切内存地址。幸运的是,这项任务留给了程序运行的环境——通常是操作系统,它会在运行时决定特定的内存位置。然而,程序在运行时能够获取变量的地址以访问相对于其位置的某些数据单元可能会很有用。

取地址运算符(&)

可以通过在变量名前加上一个名为取地址运算符的“&”符号来获取变量的地址。例如:

1
foo = &myvar;

这将把变量myvar的地址分配给foo;通过在变量myvar名前加上取地址运算符&),我们不再是将变量本身的内容分配给foo,而是其地址。

变量在内存中的实际地址在运行时是无法得知的,但为了帮助阐明一些概念,我们假设在运行时myvar被放置在内存地址1776

在这种情况下,请考虑以下代码片段:

1
2
3
myvar = 25;
foo = &myvar;
bar = myvar;

下面这张图显示了执行此操作后每个变量中包含的值:



首先,我们将值25赋给了myvar(我们假设其内存地址为1776的变量)。

第二个语句将myvar的地址(我们假设为1776)赋给了foo

最后,第三个语句将myvar中包含的值赋给了bar。这是一个标准的赋值操作,正如在前面的章节中已经多次完成的那样。

第二个和第三个语句之间的主要区别在于取地址运算符&)的出现。

在C++中,存储另一个变量地址的变量被称为指针。指针是语言中一个非常强大的特性,在底层编程中有许多用途。稍后,我们将学习如何声明和使用指针。

解引用运算符(*)

如前所述,存储另一个变量地址的变量被称为指针。指针“指向”它们所存储地址的变量。

指针的一个有趣特性是它们可以用来直接访问它们所指向的变量。这是通过在指针名称前加上解引用运算符*)来实现的。该运算符本身可以读作“指向的值”。

因此,根据上一个示例中的值,以下语句:

1
baz = *foo;

可以读作:“baz等于foo指向的值”,并且该语句实际上会将值25赋给baz,因为foo1776,而1776指向的值(根据上面的示例)是25


明确区分foo指的是值1776,而*foo(在标识符前有一个星号*)指的是存储在地址1776的值(在本例中为25)非常重要。请注意包含或不包含解引用运算符的区别(我已经添加了关于这两个表达式如何读取的解释性注释)。

1
2
baz = foo;   // baz equal to foo (1776)
baz = *foo;  // baz equal to value pointed to by foo (25)  

取地址和解引用运算符因此是互补的。
  • &取地址运算符,可以简单地读作“地址”。
  • *解引用运算符,可以读作“指向的值”。

因此,它们的意思是相反的:用&获得的地址可以用*解引用。

前面,我们执行了以下两个赋值操作:

1
2
myvar = 25;
foo = &myvar;

在这两条语句之后,所有以下表达式的结果都将为真:

1
2
3
4
myvar == 25
&myvar == 1776
foo == 1776
*foo ==

第一个表达式很清楚,考虑到对myvar执行的赋值操作是myvar=25。第二个表达式使用了取地址运算符(&),它返回myvar的地址,我们假设其值为1776。第三个表达式有些显而易见,因为第二个表达式为真,并且对foo执行的赋值操作是foo=&myvar。第四个表达式使用了解引用运算符*),可以读作“指向的值”,而foo指向的值确实是25

因此,在这一切之后,您还可以推断,只要foo指向的地址保持不变,以下表达式也将为真:

1
*foo ==

声明指针

由于指针能够直接引用它所指向的值,指针指向char时与指向intfloat时具有不同的属性。一旦解引用,就需要知道类型。为此,指针的声明需要包含指针将指向的数据类型。

指针的声明遵循以下语法:

type * name;

其中type是该指针指向的数据类型。此类型不是指针本身的类型,而是指针指向的数据的类型。例如:

1
2
3
int * number;
char * character;
double * decimals;

这是三个指针声明。每个指针都旨在指向不同的数据类型,但实际上,它们都是指针,并且很可能占用相同的内存空间(指针在内存中的大小取决于程序运行的平台)。尽管如此,它们指向的数据不占用相同的空间,也不是相同的类型:第一个指向int,第二个指向char,最后一个指向double。因此,尽管这三个示例变量都是指针,但它们的类型实际上是不同的:分别为int*char*double*,具体取决于它们指向的类型。

请注意,声明指针时使用的星号(*)仅表示它是一个指针(它是其类型复合说明符的一部分),不应与前面看到的解引用运算符混淆,后者也用星号(*)表示。它们只是用同一个符号表示的两个不同的东西。

让我们看一个关于指针的例子:

// my first pointer
#include <iostream>
using namespace std;

int main ()
{
  int firstvalue, secondvalue;
  int * mypointer;

  mypointer = &firstvalue;
  *mypointer = 10;
  mypointer = &secondvalue;
  *mypointer = 20;
  cout << "firstvalue is " << firstvalue << '\n';
  cout << "secondvalue is " << secondvalue << '\n';
  return 0;
}
firstvalue is 10
secondvalue is 20

请注意,即使在程序中没有直接为firstvaluesecondvalue设置任何值,它们最终都通过使用mypointer间接设置了值。它是这样发生的:

首先,使用取地址运算符(&)将firstvalue的地址赋给mypointer。然后,将mypointer指向的值赋为10。由于此刻mypointer指向firstvalue的内存位置,这实际上会修改firstvalue的值。

为了证明指针在程序生命周期中可以指向不同的变量,该示例使用相同的指针mypointer重复了对secondvalue的过程。

这是一个稍微更详细的例子:

// more pointers
#include <iostream>
using namespace std;

int main ()
{
  int firstvalue = 5, secondvalue = 15;
  int * p1, * p2;

  p1 = &firstvalue;  // p1 = address of firstvalue
  p2 = &secondvalue; // p2 = address of secondvalue
  *p1 = 10;          // value pointed to by p1 = 10
  *p2 = *p1;         // value pointed to by p2 = value pointed to by p1
  p1 = p2;           // p1 = p2 (value of pointer is copied)
  *p1 = 20;          // value pointed to by p1 = 20
  
  cout << "firstvalue is " << firstvalue << '\n';
  cout << "secondvalue is " << secondvalue << '\n';
  return 0;
}
firstvalue is 10
secondvalue is 20

每个赋值操作都包含一个关于如何读取该行的注释:即用“地址”替换“&”,用“指向的值”替换星号(*)。

请注意,存在带有指针p1p2的表达式,其中包含和不包含解引用运算符*)。使用解引用运算符(*)的表达式的含义与不使用该运算符的表达式截然不同。当该运算符出现在指针名称之前时,表达式引用的是所指向的值;而当指针名称没有该运算符时,它引用的是指针本身的值(即,指针指向的地址)。

另外可能引起您注意的是这一行:

1
int * p1, * p2;

这声明了前面示例中使用的两个指针。但请注意,每个指针都有一个星号(*),以便两者都能拥有类型int*(指向int的指针)。这是必需的,因为优先级规则。请注意,如果代码是:

1
int * p1, p2;

p1确实是int*类型,但p2int类型。在这种情况下,空格完全无关紧要。但无论如何,对于对声明多个指针感兴趣的大多数指针用户而言,记住每个语句有一个星号就足够了。甚至更好:为每个变量使用不同的语句。

指针和数组

数组的概念与指针的概念相关。事实上,数组的工作方式非常类似于指向其第一个元素的指针,并且实际上,数组总是可以隐式转换为适当类型的指针。例如,考虑这两个声明:

1
2
int myarray [20];
int * mypointer;

以下赋值操作将是有效的:

1
mypointer = myarray;

之后,mypointermyarray将是等效的,并且将具有非常相似的属性。主要区别在于mypointer可以被赋以不同的地址,而myarray永远不能被赋以任何值,并且将始终代表相同大小的20个int类型元素的块。因此,以下赋值无效:

1
myarray = mypointer;

让我们看一个混合了数组和指针的例子:

// more pointers
#include <iostream>
using namespace std;

int main ()
{
  int numbers[5];
  int * p;
  p = numbers;  *p = 10;
  p++;  *p = 20;
  p = &numbers[2];  *p = 30;
  p = numbers + 3;  *p = 40;
  p = numbers;  *(p+4) = 50;
  for (int n=0; n<5; n++)
    cout << numbers[n] << ", ";
  return 0;
}
10, 20, 30, 40, 50, 

指针和数组支持相同的操作集,并且对于两者来说意义都相同。主要区别在于指针可以被赋值为新的地址,而数组则不能。

在关于数组的章节中,方括号([])被解释为指定数组元素的索引。实际上,这些方括号是已知的偏移运算符的解引用运算符。它们就像*一样解引用其后面的变量,但它们还将括号内的数字加到被解引用的地址上。例如:

1
2
a[5] = 0;       // a [offset of 5] = 0
*(a+5) = 0;     // pointed to by (a+5) = 0  

这两个表达式是等效且有效的,不仅当a是指针时,当a是数组时也是如此。请记住,如果是一个数组,它的名称可以用作指向其第一个元素的指针。

指针初始化

指针可以在定义时初始化为指向特定位置:

1
2
int myvar;
int * myptr = &myvar;

此代码执行后的变量状态与以下代码执行后的状态相同:

1
2
3
int myvar;
int * myptr;
myptr = &myvar;

当初始化指针时,初始化的是它们指向的地址(即myptr),而不是所指向的值(即*myptr)。因此,上面的代码不应与以下代码混淆:

1
2
3
int myvar;
int * myptr;
*myptr = &myvar;

无论如何,这都没有多大意义(并且不是有效的代码)。

指针声明中的星号(*)(第2行)仅表示它是一个指针(它是其类型复合说明符的一部分),不应与解引用运算符(第3行)混淆。这两者碰巧都使用相同的符号:*。一如既往,空格无关紧要,并且从不改变表达式的含义。

指针可以初始化为指向变量的地址(如上面的情况),或者指向另一个指针(或数组)的值:

1
2
3
int myvar;
int *foo = &myvar;
int *bar = foo;

指针算术

对指针进行算术运算与对普通整数类型进行运算略有不同。首先,只允许加法和减法运算;其他运算在指针的世界中没有意义。但是,加法和减法对指针的行为略有不同,具体取决于它们指向的数据类型的大小。

当介绍基本数据类型时,我们看到类型具有不同的尺寸。例如:char的大小始终为1字节,short通常比char大,而intlong更大;这些的确切大小取决于系统。例如,让我们假设在一个给定的系统中,char占用1字节,short占用2字节,long占用4字节。

现在假设我们在该编译器中定义了三个指针:

1
2
3
char *mychar;
short *myshort;
long *mylong;

并且我们知道它们指向内存位置100020003000,分别。

因此,如果我们写:

1
2
3
++mychar;
++myshort;
++mylong;

不出所料,mychar将包含值1001。但并非显而易见的是,myshort将包含值2002,而mylong将包含3004,尽管它们都只被递增了一次。原因是,当向指针加一时,指针被指向相同类型的下一个元素,因此,它指向的类型的大小(以字节为单位)会被添加到指针中。


这既适用于向指针加任何数字,也适用于从指针减去任何数字。如果我们写:

1
2
3
mychar = mychar + 1;
myshort = myshort + 1;
mylong = mylong + 1;

关于递增(++)和递减(--)运算符,它们都可以作为表达式的前缀或后缀使用,行为略有不同:作为前缀时,递增发生在表达式求值之前;作为后缀时,递增发生在表达式求值之后。这也适用于递增和递减指针的表达式,这些表达式可以成为更复杂的表达式的一部分,而这些表达式还包括解引用运算符(*)。回想一下运算符优先级规则,我们可以记得后缀运算符(如递增和递减)的优先级高于前缀运算符(如解引用运算符(*))。因此,表达式:

1
*p++

等同于*(p++)。它的作用是增加p的值(因此它现在指向下一个元素),但由于++被用作后缀而不是前缀,因此整个表达式的求值结果是先前由指针指向的值(在递增之前它指向的地址)。

本质上,这是解引用运算符与递增运算符的前缀和后缀版本的所有四种可能组合(同样适用于递减运算符):

1
2
3
4
*p++   // same as *(p++): increment pointer, and dereference unincremented address
*++p   // same as *(++p): increment pointer, and dereference incremented address
++*p   // same as ++(*p): dereference pointer, and increment the value it points to
(*p)++ // dereference pointer, and post-increment the value it points to 

一个典型的(但并非那么简单的)涉及这些运算符的语句是:

1
*p++ = *q++;

由于++的优先级高于*,因此pq都会递增,但由于两个递增运算符(++)都用作后缀而不是前缀,因此赋给*p的值是*q,然后在pq都递增之前。然后两者都递增。这大致相当于:

1
2
3
*p = *q;
++p;
++q;

一如既往,括号通过增加表达式的可读性来减少混淆。

指针和const

指针可以用于通过地址访问变量,并且这种访问可能包括修改所指向的值。但也可以声明指针,这些指针可以访问所指向的值进行读取,但不能进行修改。为此,只需将指针指向的类型限定为const即可。例如:

1
2
3
4
5
int x;
int y = 10;
const int * p = &y;
x = *p;          // ok: reading p
*p = x;          // error: modifying p, which is const-qualified 

在这里,p指向一个变量,但以const限定的方式指向它,这意味着它可以读取所指向的值,但不能修改它。另请注意,表达式&y的类型是int*,但这被赋给了一个类型为const int*的指针。这是允许的:指向非const的指针可以隐式转换为指向const的指针。但反过来则不行!作为一种安全功能,指向const的指针不能隐式转换为指向非const的指针。

指向const元素的指针的一个用途是作为函数参数:一个接受指向非const的指针作为参数的函数可以修改作为参数传递的值,而一个接受指向const的指针作为参数的函数则不能。

// pointers as arguments:
#include <iostream>
using namespace std;

void increment_all (int* start, int* stop)
{
  int * current = start;
  while (current != stop) {
    ++(*current);  // increment value pointed
    ++current;     // increment pointer
  }
}

void print_all (const int* start, const int* stop)
{
  const int * current = start;
  while (current != stop) {
    cout << *current << '\n';
    ++current;     // increment pointer
  }
}

int main ()
{
  int numbers[] = {10,20,30};
  increment_all (numbers,numbers+3);
  print_all (numbers,numbers+3);
  return 0;
}
11
21
31

请注意,print_all使用指向常量元素的指针。这些指针指向它们无法修改的常量内容,但它们本身不是常量:也就是说,指针仍然可以递增或被赋以不同的地址,尽管它们不能修改它们所指向的内容。

在这里,我们将const的第二维添加到指针:指针本身也可以是const的。这是通过将const附加到指向的类型(在星号之后)来指定的:

1
2
3
4
5
int x;
      int *       p1 = &x;  // non-const pointer to non-const int
const int *       p2 = &x;  // non-const pointer to const int
      int * const p3 = &x;  // const pointer to non-const int
const int * const p4 = &x;  // const pointer to const int 

const与指针的语法确实很棘手,并且识别最适合每种用途的案例通常需要一些经验。无论如何,尽早正确掌握const与指针(和引用)的结合非常重要,但如果您是第一次接触const和指针的混合,则不必过于担心掌握所有内容。后续章节将出现更多用例。

为了增加const与指针语法的混淆度,const限定符可以放在指向类型的前面或后面,具有完全相同的含义:

1
2
const int * p2a = &x;  //      non-const pointer to const int
int const * p2b = &x;  // also non-const pointer to const int 

与星号周围的空格一样,此情况下的const顺序只是风格问题。本章使用前缀const,因为出于历史原因,这似乎更为普遍,但两者完全等效。各自风格的优点仍在互联网上激烈争论。

指针和字符串字面量

如前所述,字符串字面量是包含以 null 结尾的字符序列的数组。在前面的章节中,字符串字面量已被用于直接插入cout,初始化字符串以及初始化字符数组。

但它们也可以被直接访问。字符串字面量是具有正确数组类型的数组,可以包含所有字符加上终止的 null 字符,每个元素都是const char类型(作为字面量,它们永远不能被修改)。例如:

1
const char * foo = "hello";

这声明了一个具有"hello"字面量表示的数组,然后将指向其第一个元素的指针赋给了foo。如果我们设想"hello"存储在从地址1702开始的内存位置,我们可以将前面的声明表示为:


请注意,此处foo是指针,其值为1702,而不是'h'"hello",尽管1702确实是这两者的地址。

指针foo指向一个字符序列。由于指针和数组在表达式中的行为基本相同,因此foo可以用来访问字符,方式与以 null 结尾的字符序列的数组相同。例如:

1
2
*(foo+4)
foo[4]

这两个表达式的值都是'o'(数组的第五个元素)。

指向指针的指针

C++允许使用指向指针的指针,这些指针又指向数据(甚至是指向其他指针)。语法仅需要在声明指针时为每个间接级别添加一个星号(*):

1
2
3
4
5
6
char a;
char * b;
char ** c;
a = 'z';
b = &a;
c = &b;

假设为每个变量随机选择的内存位置分别为7230809210502,这可以表示为:


其中每个单元中都表示了变量的值,而它们各自在内存中的地址则由其下方的数值表示。

此示例中的新内容是变量c,它是一个指向指针的指针,可以用于三个不同的间接级别,每个级别都对应一个不同的值:

  • c的类型是char**,值为8092
  • *c的类型是char*,值为7230
  • **c的类型是char,值为'z'

void指针

void指针是一种特殊的指针类型。在C++中,void表示类型的缺失。因此,void指针是指向没有类型的值的指针(因此长度和解引用属性也是不确定的)。

这使得void指针具有极大的灵活性,因为它们可以指向任何数据类型,从整数值或浮点数到字符字符串。作为交换,它们有一个很大的限制:它们指向的数据不能直接解引用(这是合乎逻辑的,因为我们没有类型可以解引用),因此,void指针中的任何地址都需要在解引用之前转换为指向具体数据类型的其他指针类型。

它的一个可能用途是向函数传递通用参数。例如:

// increaser
#include <iostream>
using namespace std;

void increase (void* data, int psize)
{
  if ( psize == sizeof(char) )
  { char* pchar; pchar=(char*)data; ++(*pchar); }
  else if (psize == sizeof(int) )
  { int* pint; pint=(int*)data; ++(*pint); }
}

int main ()
{
  char a = 'x';
  int b = 1602;
  increase (&a,sizeof(a));
  increase (&b,sizeof(b));
  cout << a << ", " << b << '\n';
  return 0;
}
y, 1603

sizeof是C++语言集成的一个运算符,它返回其参数的大小(以字节为单位)。对于非动态数据类型,此值是常量。因此,例如,sizeof(char)为1,因为char的大小始终为1字节。

无效指针和空指针

原则上,指针旨在指向有效地址,例如变量的地址或数组中元素的地址。但指针实际上可以指向任何地址,包括不引用任何有效元素的地址。这方面的典型例子是未初始化的指针和指向数组不存在元素的指针:

1
2
3
4
int * p;               // uninitialized pointer (local variable)

int myarray[10];
int * q = myarray+20;  // element out of bounds 

pq均未指向已知包含值的地址,但以上任何语句都不会导致错误。在C++中,指针可以取任何地址值,无论该地址处是否实际存在内容。可能导致错误的是解引用此类指针(即实际访问它们指向的值)。访问此类指针会导致未定义的行为,从运行时错误到访问随机值。

但是,有时指针确实需要显式地指向“无”,而不仅仅是无效地址。对于这种情况,任何指针类型都可以采用一个特殊的值:空指针值。在C++中,此值可以通过两种方式表示:整数值零,或关键字nullptr

1
2
int * p = 0;
int * q = nullptr;

在这里,pq都是空指针,这意味着它们显式地指向“无”,并且它们都相等比较:所有空指针都与其他空指针相等。在旧代码中,经常可以看到定义的常量NULL被用来表示空指针值:

1
int * r = NULL;

NULL在标准库的几个头文件中定义,并被定义为某个空指针常量值(如0nullptr)的别名。

不要将空指针void指针混淆!空指针是任何指针都可以取的值,表示它指向“无”;而void指针是一种指针类型,它可以指向没有特定类型的位置。一个是指针中存储的值,另一个是指针指向的数据类型。

函数指针

C++允许对函数进行指针操作。这通常用于将一个函数作为参数传递给另一个函数。函数指针的声明语法与常规函数声明相同,不同之处在于函数名被括在括号()中,并在名称前插入一个星号(*):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// pointer to functions
#include <iostream>
using namespace std;

int addition (int a, int b)
{ return (a+b); }

int subtraction (int a, int b)
{ return (a-b); }

int operation (int x, int y, int (*functocall)(int,int))
{
  int g;
  g = (*functocall)(x,y);
  return (g);
}

int main ()
{
  int m,n;
  int (*minus)(int,int) = subtraction;

  m = operation (7, 5, addition);
  n = operation (20, m, minus);
  cout <<n;
  return 0;
}
8

在上面的示例中,minus是指向具有两个int类型参数的函数的指针。它被直接初始化为指向subtraction函数:

1
int (* minus)(int,int) = subtraction;
Index
目录