指针(Pointers)

重要的事情说三遍:

强烈建议按照目录结构中的顺序学习!!!点我查看教程目录结构

强烈建议按照目录结构中的顺序学习!!!点我查看教程目录结构

强烈建议按照目录结构中的顺序学习!!!点我查看教程目录结构

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

对于 C++ 程序来说,计算机的内存就像是一系列内存单元,每个单元大小为一个字节,并且每个单元都有一个唯一的地址。这些单字节内存单元按照一定的顺序排列,使得大于一个字节的数据表示可以占用具有连续地址的内存单元。

这样,每个单元都可以通过其唯一的地址轻松定位。例如,地址为 1776 的内存单元总是紧跟在地址为 1775 的单元之后,并在地址为 1777 的单元之前,并且恰好位于 776 单元之后一千个单元处和 2776 单元之前一千个单元处。

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

取地址运算符(&)

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

foo = &myvar;

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

变量在内存中的实际地址在运行时之前是未知的,但为了帮助澄清一些概念,假设 myvar 在运行时被放置在内存地址 1776

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

myvar = 25;
foo = &myvar;
bar = myvar;

执行后每个变量中包含的值如下图所示:

reference_operator.jpg

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

第二条语句将 myvar 的地址赋给了 foo,我们假设其地址为 1776

最后,第三条语句将 myvar 中包含的值赋给了 bar。这是一种标准的赋值操作,之前的章节中已经多次进行过。

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

存储另一个变量地址的变量(如上例中的 foo)在 C++ 中称为指针。指针是该语言的一项非常强大的功能,在底层编程中有许多用途。稍后,我们将看到如何声明和使用指针。

解引用运算符(*

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

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

因此,继续前面的例子,以下语句:

baz = *foo;

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

dereference_operator.jpg

重要的是要清楚地区分 foo*foo 的含义:foo 引用值 1776,而 *foo(在标识符前加星号 *)引用存储在地址 1776 的值,在此例中为 25。注意包含或不包含解引用运算符的区别(我添加了一些解释性的注释以说明这两个表达式如何读取):

baz = foo;   // baz 等于 foo (1776)
baz = *foo;  // baz 等于 foo 指向的值 (25)  

取地址运算符和解引用运算符是互补的:

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

因此,它们具有相反的含义:用 & 获取的地址可以用 * 进行解引用。

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

myvar = 25;
foo = &myvar;

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

myvar == 25
&myvar == 1776
foo == 1776
*foo == 25

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

因此,经过这些操作,您还可以推断,只要 foo 指向的地址保持不变,以下表达式也将为真:

*foo == 25

声明指针

由于指针可以直接引用其指向的值,当指针指向 char 时,其属性与指向 intfloat 时的属性不同。一旦解引用,类型需要知道。为此,指针的声明需要包括指针将要指向的数据类型。

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

type * name;

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

int * number;
char * character;
double * decimals;

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

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

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

// 我的第一个指针
#include <iostream>
using namespace std;

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

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

注意,尽管程序中 firstvaluesecondvalue 都没有被直接赋值,但它们的值都通过使用 mypointer 间接设置。这是如何发生的:

首先,使用取地址运算符(&)将 mypointer 赋值为 firstvalue 的地址。然后,将 mypointer 指向的值赋为 10。因为此时 mypointer 指向 firstvalue 的内存位置,所以实际上修改了 firstvalue 的值。

为了演示指针在程序的生命周期内可以指向不同的变量,示例使用相同的指针 mypointer 重复了这个过程,但指向 secondvalue

这里是一个更复杂的例子:

// 更多指针
#include <iostream>
using namespace std;

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

  p1 = &firstvalue;  // p1 = firstvalue 的地址
  p2 = &secondvalue; // p2 = secondvalue 的地址
  *p1 = 10;          // p1 指向的值 = 10
  *p2 = *p1;         // p2 指向的值 = p1 指向的值
  p1 = p2;           // p1 = p2(复制指针的值)
  *p1 = 20;          // p1 指向的值 = 20
  
  cout << "firstvalue 是 " << firstvalue << '\n'; // 10
  cout << "secondvalue 是 " << secondvalue << '\n'; // 20
  return 0;
}

每个赋值操作包括一个注释,说明每行如何读取:即用“地址的”替换 &,用“指向的值”替换 *

注意,有些表达式中有指针 p1p2,有些带解引用运算符(*),有些没有。带解引用运算符(*)的表达式与不带解引用运算符的表达式有非常不同的含义。当该运算符在指针名称前时,表达式引用被指向的值,而当指针名称出现而没有该运算符时,它引用指针本身的值(即指针所指向的地址)。

另一个可能引起注意的地方是这一行:

int * p1, * p2;

这声明了前面示例中使用的两个指针。但注意,每个指针都有一个星号(*),以便两个都具有 int* 类型(指向 int 的指针)。这是由于优先级规则的要求。注意,如果代码是:

int * p1, p2;

p1 确实是 int* 类型,但 p2 将是 int 类型。空格在此目的上完全没有关系。但无论如何,只需记住每个指针一个星号即可满足大多数希望在每个语句中声明多个指针的用户。或者更好的是:每个变量使用不同的语句。

指针和数组

数组的概念与指针有关。实际上,数组非常像指向其第一个元素的指针,并且实际上,数组可以始终隐式转换为正确类型的指针。例如,考虑以下两个声明:

int myarray [20];
int * mypointer;

以下赋值操作是有效的:

mypointer = myarray;

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

myarray = mypointer;

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

// 更多指针
#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] << ", "; // 10, 20, 30, 40, 50
  return 0;
}

指针和数组支持相同的操作集,两者的含义相同。主要区别在于指针可以被赋予新地址,而数组不能

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

a[5] = 0;       // a [偏移量 5] = 0
*(a+5) = 0;     // 指向 (a+5) 的值 = 0  

这两个表达式是等效的并且有效,不仅适用于指针,还适用于数组。记住,如果是数组,其名称可以像指向其第一个元素的指针一样使用。

指针初始化

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

int myvar;
int * myptr = &myvar;

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

int myvar;
int * myptr;
myptr = &myvar;

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

int myvar;
int * myptr;
*myptr = &myvar;

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

指针声明中的星号(*)仅表示它是一个指针,不是解引用运算符(如第 3 行)。这两个东西恰好使用相同的符号:*。一如既往,空格在此无关紧要,并且从不改变表达式的含义。

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

int myvar;
int *foo = &myvar;
int *bar = foo;

指针运算

对指针进行算术运算与对常规整数类型进行运算略有不同。首先,只允许加法和减法运算;其他运算在指针世界中没有意义。但加法和减法在指针上有稍微不同的行为,根据它们指向的数据类型的大小。

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

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

char *mychar;
short *myshort;
long *mylong;

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

因此,如果我们写:

++mychar;
++myshort;
++mylong;

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

pointer_arithmetics.jpg

这在向指针添加或减去任何数值时均适用。如果我们写:

mychar = mychar + 1;
myshort = myshort + 1;
mylong = mylong + 1;

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

*p++

等效于 *(p++)。它所做的是增加 p 的值(因此它现在指向下一个元素),但因为 ++ 作为后缀使用,所以整个表达式被求值为原始指针指向的值(在递增之前它指向的地址)。

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

*p++   // 等同于 *(p++):递增指针,并解引用未递增的地址
*++p   // 等同于 *(++p):递增指针,并解引用递增后的地址
++*p   // 等同于 ++(*p):解引用指针,并递增其指向的值
(*p)++ // 解引用指针,并后置递增其指向的值

一个典型但不太简单的语句涉及这些运算符是:

*p++ = *q++;

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

*p = *q;
++p;
++q;

像往常一样,使用括号可以通过增加表达式的可读性来减少混淆。

指针和 const

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

int x;
int y = 10;
const int * p = &y;
x = *p;          // 可以:读取 p
*p = x;          // 错误:修改 p,这是 const 限定的

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

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

// 指针作为参数
#include <iostream>
using namespace std;

void increment_all (int* start, int* stop)
{
  int * current = start;
  while (current != stop) {
    ++(*current);  // 递增指向的值
    ++current;     // 递增指针
  }
}

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

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

请注意,print_all 使用指向常量元素的指针。这些指针指向它们不能修改的常量内容,但它们本身不是常量:即指针仍然可以递增或分配不同的地址,尽管它们不能修改指向的内容。

这就是指针增加 const 性的第二个维度的地方:指针本身也可以是 const 的。这是通过在指针类型后面添加 const 来指定的(在星号之后):

int x;
int *p1 = &x;  // 非 const 指针指向非 const int
const int *p2 = &x;  // 非 const 指针指向 const int
int * const p3 = &x;  // const 指针指向非 const int
const int * const p4 = &x;  // const 指针指向 const int

带有 const 的指针语法确实很棘手,识别最适合每种使用情况的情况往往需要一些经验。无论如何,尽早掌握指针(和引用)的 const 性很重要,但如果这是您第一次接触 const 和指针的混合使用,请不要太担心理解所有内容。更多的用例将在后续章节中显示。

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

const int * p2a = &x;  // 非 const 指针指向 const int
int const * p2b = &x;  // 也是非 const 指针指向 const int

与星号周围的空格一样,const 的顺序在这种情况下只是风格问题。本章使用前缀 const,历史原因似乎更广泛,但两者完全等效。关于每种风格的优点在互联网上仍然存在激烈的辩论。

指针和字符串字面量

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

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

const char * foo = "hello";

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

pointer_assignment.jpg

请注意,这里的 foo 是一个指针,包含值 1702,而不是 'h'"hello",尽管 1702 实际上是它们的地址。

指针 foo 指向一个字符序列。由于指针和数组在表达式中基本上以相同的方式工作,foo 可以像访问以空字符结尾的字符序列数组一样访问字符。例如:

*(foo+4)
foo[4]

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

指向指针的指针

C++ 允许使用指向指针的指针,这些指针反过来指向数据(甚至指向其他指针)。语法只需要在指针声明中每级间接引用一个星号(*):

char a;
char * b;
char ** c;
a = 'z';
b = &a;
c = &b;

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

pointer_to_pointer.jpg

每个变量的值表示在相应的单元格中,其内存中的地址值表示在它们下面。

这个例子中新的是变量 c,它是一个指向指针的指针,可以在三个不同的间接级别中使用,每个级别对应不同的值:

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

void 指针

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

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

它的一个可能用途是将泛型参数传递给函数。例如:

// 增量器
#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;
}

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

无效指针和空指针

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

int * p;               // 未初始化指针(局部变量)

int myarray[10];
int * q = myarray+20;  // 超出范围的元素

pq 都不指向已知包含值的地址,但以上语句都不会导致错误。在 C++ 中,允许指针获取任何地址值,无论该地址上是否确实有某些东西。可能导致错误的是解引用这样的指针(即实际访问它们指向的值)。访问这样的指针会导致未定义行为,从运行时错误到访问某些随机值不等。

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

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

这里,pq 都是空指针,表示它们明确地指向空地址,并且它们实际上相等:所有空指针与其他空指针比较都相等。在旧代码中还经常看到使用定义常量 NULL 来引用空指针值:

int * r = NULL;

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

不要将空指针与 void 指针混淆!空指针是任何指针可以采用的值,表示它指向“空地址”,而 void 指针是一种指针类型,可以指向某个没有特定类型的地址。一个指的是指针中存储的值,另一个指的是它指向的数据的类型。

指向函数的指针

C++ 允许使用指向函数的指针。这通常用于将函数作为参数传递给另一个函数。指向函数的指针的声明与常规函数声明相同,只是函数名称用括号括起来,并在名称前插入一个星号(*):

// 指向函数的指针
#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;
}

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

int (* minus)(int,int) = subtraction;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值