重要的事情说三遍:
在前面的章节中,变量被解释为计算机内存中的位置,可以通过它们的标识符(它们的名称)进行访问。这样,程序无需关心数据在内存中的物理地址;只需在需要引用变量时使用标识符即可。
对于 C++ 程序来说,计算机的内存就像是一系列内存单元,每个单元大小为一个字节,并且每个单元都有一个唯一的地址。这些单字节内存单元按照一定的顺序排列,使得大于一个字节的数据表示可以占用具有连续地址的内存单元。
这样,每个单元都可以通过其唯一的地址轻松定位。例如,地址为 1776
的内存单元总是紧跟在地址为 1775
的单元之后,并在地址为 1777
的单元之前,并且恰好位于 776
单元之后一千个单元处和 2776
单元之前一千个单元处。
当声明一个变量时,用于存储其值的内存被分配到内存中的一个特定位置(即其内存地址)。通常,C++ 程序不会主动决定其变量存储的确切内存地址。幸运的是,这项任务由程序运行的环境决定——通常是一个在运行时决定特定内存位置的操作系统。然而,程序能够在运行时获取变量的地址,以访问相对于它的某个位置的数据单元可能是有用的。
取地址运算符(&)
可以通过在变量名之前加上一个 &
符号(称为取地址运算符)来获取变量的地址。例如:
foo = &myvar;
这将把变量 myvar
的地址赋给 foo
;通过在变量 myvar
名称前加上取地址运算符(&
),我们不再将变量本身的内容赋给 foo
,而是赋其地址。
变量在内存中的实际地址在运行时之前是未知的,但为了帮助澄清一些概念,假设 myvar
在运行时被放置在内存地址 1776
。
在这种情况下,考虑以下代码片段:
myvar = 25;
foo = &myvar;
bar = myvar;
执行后每个变量中包含的值如下图所示:
首先,我们将值 25
赋给了 myvar
(我们假设其内存地址为 1776
的变量)。
第二条语句将 myvar
的地址赋给了 foo
,我们假设其地址为 1776
。
最后,第三条语句将 myvar
中包含的值赋给了 bar
。这是一种标准的赋值操作,之前的章节中已经多次进行过。
第二条和第三条语句之间的主要区别在于取地址运算符(&
)的出现。
存储另一个变量地址的变量(如上例中的 foo
)在 C++ 中称为指针。指针是该语言的一项非常强大的功能,在底层编程中有许多用途。稍后,我们将看到如何声明和使用指针。
解引用运算符(*
)
如前所述,存储另一个变量地址的变量称为指针。指针被认为“指向”它们存储地址的变量。
指针的一个有趣属性是,它们可以用于直接访问它们指向的变量。这是通过在指针名称前加上解引用运算符(*
)来完成的。该运算符本身可以读作“被指向的值”。
因此,继续前面的例子,以下语句:
baz = *foo;
可以读作:“baz
等于 foo
指向的值”,该语句实际上会将值 25
赋给 baz
,因为 foo
是 1776
,而地址 1776
所指向的值(根据上例)是 25
。
重要的是要清楚地区分 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
时,其属性与指向 int
或 float
时的属性不同。一旦解引用,类型需要知道。为此,指针的声明需要包括指针将要指向的数据类型。
指针的声明遵循以下语法:
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;
}
注意,尽管程序中 firstvalue
和 secondvalue
都没有被直接赋值,但它们的值都通过使用 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;
}
每个赋值操作包括一个注释,说明每行如何读取:即用“地址的”替换 &
,用“指向的值”替换 *
。
注意,有些表达式中有指针 p1
和 p2
,有些带解引用运算符(*
),有些没有。带解引用运算符(*
)的表达式与不带解引用运算符的表达式有非常不同的含义。当该运算符在指针名称前时,表达式引用被指向的值,而当指针名称出现而没有该运算符时,它引用指针本身的值(即指针所指向的地址)。
另一个可能引起注意的地方是这一行:
int * p1, * p2;
这声明了前面示例中使用的两个指针。但注意,每个指针都有一个星号(*
),以便两个都具有 int*
类型(指向 int
的指针)。这是由于优先级规则的要求。注意,如果代码是:
int * p1, p2;
p1
确实是 int*
类型,但 p2
将是 int
类型。空格在此目的上完全没有关系。但无论如何,只需记住每个指针一个星号即可满足大多数希望在每个语句中声明多个指针的用户。或者更好的是:每个变量使用不同的语句。
指针和数组
数组的概念与指针有关。实际上,数组非常像指向其第一个元素的指针,并且实际上,数组可以始终隐式转换为正确类型的指针。例如,考虑以下两个声明:
int myarray [20];
int * mypointer;
以下赋值操作是有效的:
mypointer = myarray;
之后,mypointer
和 myarray
将是等效的,并且将具有非常相似的属性。主要区别在于 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
通常比其大,而 int
和 long
更大;这些的确切大小取决于系统。例如,假设在给定系统中,char
占用 1 字节,short
占用 2 字节,而 long
占用 4 字节。
假设我们现在在这个编译器中定义了三个指针:
char *mychar;
short *myshort;
long *mylong;
并且我们知道它们分别指向内存位置 1000
、2000
和 3000
。
因此,如果我们写:
++mychar;
++myshort;
++mylong;
mychar
会包含值 1001。但不那么显而易见的是,myshort
会包含值 2002,而 mylong
会包含值 3004,尽管它们每个只被递增了一次。原因是,当向指针添加一个值时,该指针将指向下一个相同类型的元素,因此将该类型的大小(以字节为单位)添加到指针。
这在向指针添加或减去任何数值时均适用。如果我们写:
mychar = mychar + 1;
myshort = myshort + 1;
mylong = mylong + 1;
关于递增(++
)和递减(--
)运算符,它们都可以用作表达式的前缀或后缀,行为略有不同:作为前缀,递增发生在表达式求值之前;作为后缀,递增发生在表达式求值之后。这也适用于递增和递减指针的表达式,这些表达式可以成为包含解引用运算符(*
)的更复杂表达式的一部分。记住运算符优先级规则,我们可以回想起,后缀运算符(如递增和递减)比前缀运算符(如解引用运算符 *
)具有更高的优先级。因此,以下表达式:
*p++
等效于 *(p++)
。它所做的是增加 p
的值(因此它现在指向下一个元素),但因为 ++
作为后缀使用,所以整个表达式被求值为原始指针指向的值(在递增之前它指向的地址)。
基本上,这些是解引用运算符与递增运算符前缀和后缀版本的四种可能组合(同样适用于递减运算符):
*p++ // 等同于 *(p++):递增指针,并解引用未递增的地址
*++p // 等同于 *(++p):递增指针,并解引用递增后的地址
++*p // 等同于 ++(*p):解引用指针,并递增其指向的值
(*p)++ // 解引用指针,并后置递增其指向的值
一个典型但不太简单的语句涉及这些运算符是:
*p++ = *q++;
由于 ++
的优先级高于 *
,所以 p
和 q
都会递增,但由于两个递增运算符(++
)都作为后缀而不是前缀使用,因此赋给 *p
的值是 *q
,这是在 p
和 q
递增之前的值。然后两者都递增。大致相当于:
*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 开始的内存位置,我们可以将前面的声明表示为:
请注意,这里的 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;
假设每个变量的内存位置分别为 7230
、8092
和 10502
,可以表示为:
每个变量的值表示在相应的单元格中,其内存中的地址值表示在它们下面。
这个例子中新的是变量 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; // 超出范围的元素
p
和 q
都不指向已知包含值的地址,但以上语句都不会导致错误。在 C++ 中,允许指针获取任何地址值,无论该地址上是否确实有某些东西。可能导致错误的是解引用这样的指针(即实际访问它们指向的值)。访问这样的指针会导致未定义行为,从运行时错误到访问某些随机值不等。
但是,有时指针确实需要明确指向空地址,而不仅仅是一个无效地址。在这种情况下,存在一种特殊值,任何指针类型都可以采用:空指针值。这个值可以通过两种方式在 C++ 中表示:要么用零的整数值,要么用 nullptr
关键字:
int * p = 0;
int * q = nullptr;
这里,p
和 q
都是空指针,表示它们明确地指向空地址,并且它们实际上相等:所有空指针与其他空指针比较都相等。在旧代码中还经常看到使用定义常量 NULL
来引用空指针值:
int * r = NULL;
NULL
在标准库的几个头文件中定义,并定义为某个空指针常量值(如 0
或 nullptr
)的别名。
不要将空指针与 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;