C++ 学习之八、复合类型之指针

指针是一个在 C 和 C++ 语言中很重要的概念。

什么是指针

指针是用于存储值的地址的变量。

对于常规变量,值是指定的量,而地址是派生量,地址值可通过对变量应用地址运算符(&)取得。

对于指针刚好相反,将地址视为指定的量,而将值视为派生量。* 运算符被称为间接值或解除引用运算符,将其应用于指针,可以得到该地址处存储的值。

指针初始化

指针与数组一样都是与类型强关联的。如 int *pt 表示 int 类型的指针 pt

Tips: 一定要在对指针应用解除引用运算符(*)之前,将指针初始化为一个确定的、适当的地址,这是关于使用指针的金科玉律。

先声明再赋值

1
2
3
int temp = 1000;    // temp 为 int 类型
int *pt; // 声明 int 指针 pt,也可写成 int * pt,空格在哪里都可以,或者不写空格
pt = &temp; // 将 temp 变量的地址赋值给 int 指针 pt

声明时直接赋值

注意此语法是给指针赋值,并不是给指针指向的值赋值。此种类型初始化同样适用于使用 new 初始化指针时。

1
2
int temp = 1000;
int *pt = &temp; // 注意此时是给 int 指针 pt 赋值,而不是 *pt(指针指向的值)

Tips: 声明多个指针时,每个指针变量都要有一个 *,注意区别:

1
2
int *pt1, pt2;  // 声明 int 指针 pt1 和 int 整型 pt2
int *pt1, *pt2; // 这才是声明两个 int 指针 pt1 和 pt2,所以为防止错误 * 最好跟着变量名一起

指针的特性

指针与整数

指针不等同于整数,即使指针变量中存储的地址值确实是一个整数,但从概念上看,这两个是截然不同的类型。

整数是可以执行加、减、乘、除等算术运算的数字,而指针描述的是位置,将两个地址相乘除没有任何意义,因此,不能简单地将整数赋给指针。

1
2
3
int *pt;
pt = 0xB8000000; // 非法,不能直接将整数赋值给指针
pt = (int *)0xB8000000; // 合法,如果确实要将一个整数赋给指针变量,可以使用强制类型转换

Tips: pt 是 int 值的地址并不意味着 pt 本身的类型是 int,例如,在有些平台中,int 类型是个 2 字节值,而地址是个 4 字节值。

指针的算术运算

如上所述,指针中存储的是地址值,将两个地址相乘除是没有任何意义的。但指针是可以进行加减运算的。

指针加 1 的结果等于原来的地址值加上指向的对象占用的字节数。同理减 1 类似。

1
2
int temp = 1;
int *p = &temp; // 如果 int 为 4 个字节,p=0x123,此时 P+1=0x127

还可以将一个指针减去另外一个指针,获得两个指针的差,这将运算将得到一个整数,仅当两个指针指向同一个数组(也可以指向超出结尾的一个位置)时,这种运算才有意义,这将得到两个元素的间隔。

1
2
3
4
5
6
int tacos[10] = {5, 2, 3, 5, 7, 8, 90, 20, 1, 90};
int *pt = tacos; // int 指针 pt 指向数组的第一个元素
pt = pt + 1; // 加 1 后指向数组的第二个元素 2
int *pe = &tacos[9]; // int 指针 pe 指向数组的第十个元素
pe = pe - 1; // 减 1 后指向数组的第九个元素 1
int diff = pe - pt; // diff 等于 7,这两个指针相隔七个元素,注意相减的结果并不是这两个指针相差的字节数,而是元素个数

new 运算符

在 C 语言中使用库函数 malloc() 来分配内存,在 C++ 中仍然可以这样做,但更推荐使用 new 运算符。

使用 new 运算符在运行阶段分配未命名的内存以存储值。在这种情况下,只能通过指针来访问内存。

初始化为基本类型对象的地址

语法:typeName * pointer_name = new typeName;

1
int *pt = new int;  // new 运算符初始化指针

new int 告诉程序,需要适合存储 int 的内存。new 运算符根据类型来确定需要多少字节的内存,然后,它找到这样的内存,并返回其地址。接下来,将地址赋给 ptpt 是被声明为指向 int 的指针。(如果因为各种原因,new 运算符无法取得所需要的地址时,将返回一个空指针)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// use_new.cpp -- new 运算符应用
#include <iostream>
int main()
{
using namespace std;
int nights = 1001;
int *pt = new int; // new 运算符给 int 指针 pt 分配内存
*pt = 1001; // 分配的内存中存储一个 int 值

cout << "nights value = " << nights << ": location " << &nights << endl;
cout << "int value = " << *pt << ": location " << pt << endl;

double *pd = new double; // new 运算符给 double 指针 pd 分配内存
*pd = 10000001.0;

cout << "double value = " << *pd << ": location " << pd << endl;
cout << "location of pointer pd: " << &pd << endl; // 打印存储指针变量 pd 的值的地址

delete pt; // 使用 delete 释放内存 pt
delete pd; // 使用 delete 释放内存 pd
}

当需要内存时,可以使用 new 来请求,使用完内存后,使用 delete 来释放内存,归还或释放的内存可供程序的其他部分使用。使用 delete 时,后面要加上指向内存块的指针(这些内存块最初是用 new 分配的)。

1
2
3
4
int *ps = new int;  // 给 int 指针 ps 分配内存
*ps = 1001; // 向分配的内存中存储 int 值
...
delete ps; // 释放内存

这将释放 ps 指向的内存,但不会删除指针 ps 本身。可以将 ps 重新指向另一个新分配的内存块。

注意一定要配对地使用 newdelete,否则将发生内存泄漏(memory leak),也就是说,被分配的内存再也无法使用了。如果内存泄漏严重,则程序将由于不断寻找更多的内存而终止。

不要尝试释放已经释放的内存块,C++ 标准指出,这样做的结果将是不确定的,这意味着什么情况都可能发生。另外,不能使用 delete 来释放声明变量所获得的内存:

1
2
3
4
5
6
int *ps = new int;  // new 运算符分配内存
delete ps; // 合法,释放内存
delete ps; // 非法,不要释放已经释放的内存
int jugs = 5; // 初始化基本数据类型
int *pi = &jugs; // 指针赋值为指向基本数据类型的地址
delete pi; // 非法,int 指针 pi 所指向的内存不是由 new 运算符分配的,不能使用 delete 释放内存

Tips: 只能使用 delete 来释放使用 new 分配的内存。然而,对空指针使用 delete 是安全的。

使用 delete 的关键在于,将它用于 new 分配的内存,即使该内存几经转手!

1
2
3
int * ps = new int;
int * pq = ps; // int 指针 pq 也指向 ps 所指的内存
delete pq; // 合法,可以释放该内存,但通常不要创建两个指向同一个内存块的指针

初始化为复合数据类型地址

如果程序只需要一个值,则可能会声明一个简单变量,这样也便于理解和管理。但是,通常对于大型数据(如数组、字符串和结构),应使用 new 运算符来动态分配内存,达到灵活的目的。使用 new[] 运算符创建数组时,将采用动态联编(动态数组),即将在运行时为数组分配空间,其长度也将在运行时设置,还可以将长度设置为变量赋值。使用完这种数组后,应使用 delete[] 释放占用的内存。

使用 new 创建动态数组

语法:type_name * pointer_name = new type_name[num_elements];

new 运算符将返回第一个元素的地址。

1
int *p_int = new int[10];   // 初始化一个指向 int 数组的指针,该指针指向数组的第一个元素

释放 new 创建的数组地址:delete [] p_int;,使用 new[] 为数组分配的内存,则应使用 delete[] 来释放。

使用动态数组

创建动态数组后,如何使用它呢?

C 和 C++ 内部都使用指针来处理数据,数组和指针基本等价。通常,使用数组表示法时,C++ 都执行以下转换:

arrayname[i] 转换为 *(arrayname + i) // arrayname 为数组名

如果使用指针,而不是数组名,则 C++ 也将执行同样的转换:

pointername[i] 转换为 *(pointername + i) // pointername 为指向数组的指针

1
2
3
4
int *p_some = new int[10];
*p_some = 1; // 指向数组中第一个元素,此时相当于给数组中第一个元素赋值为 1;
*(p_some + 1) = 17; // 使用指向数组的指针时,指针加 1 将使指针指向下一个元素,此时相当于给数组中第二个元素赋值为 17
psome[2] = 36; // C 和 C++ 内部都使用指针来处理数组,数组和指针基本等价,此时相当于给数组中第三个元素赋值为 36;

指针与数组的区别

C 和 C++ 将数组名解释为第一个元素的地址,所以指针和数组基本等价,都可以使用下标来访问数组元素,但两者之间还是有一些微小的差别的。

  • 指针是一个变量,数组名相当于是一个常量。指针和数组加 1 都将指向下一个元素,可以给指针重新赋值,但将值赋给数组名是非法的。

  • sizeof 运算符。对数组应用 sizeof 运算符得到的是整个数组占用的字节数,而对指针应用 sizeof 得到的是指针变量的长度(一般为 4 个字节)。这种情况下,C++ 不会将数组名解释为地址,自然也不会解释为第 0 个元素的地址。

    1
    2
    3
    4
    int *array_point = new int[10];
    int array[10]{}; // 全部元素初始化为 0
    cout << sizeof(array_point); // 打印出指针变量 array_point 的长度,一般为 4
    cout << sizeof(array); // 打印出整个 array 占用字节数,如果 int 在这个系统中占用 4 个字节,sizeof(array) 值就为 40
  • 对数组取地址时,数组名也不会被解释为其地址。数组名被解释为第 1 个元素的地址,而对数组名应用地址运算符(&)时,将得到的是整个数组的地址:

    1
    2
    3
    short tell[10]; // tell 是一个宽度 20 个字节的 short 数组
    cout << tell << endl; // 打印第一个元素的地址,也即 &tell[0]
    cout << &tell << endl; // 打印整个数组的地址

    从数字上说,这两个地址相同,但从概念上说,&tell[0] (即 tell )是一个 2 字节内存块的地址,而 &tell 是一个 20 字节内存块的地址。因此 tell+1 将地址加 2,而表达式 &tell+1 将地址加 20。

指针与字符串

我们知道字符串是一种特殊的 char 数组。在 C++ 中,用引号括起的字符串也像数组名一样,表示第 1 个元素的地址

1
2
char flower[10] = "rose";
cout << flower << "s are red\n"; // 给 cout提供一个字符的地址,则它将从该字符开始打印,直到遇到空字符为止

Tips: 在 cout 和多数 C++ 表达式中,char 数组名、char 指针以及用引号括起的字符串常量被解释为字符串第一个字符的地址。

所以引出另外一个问题。一般来说,如果给 cout 提供一个指针,它将打印该指针变量的地址值。但如果指针的类型为 char *,则 cout 将显示指针指向的字符串。如果要显示的是字符串的地址,则必须将这种指针强制转换为另一种指针类型。

使用 new 创建动态结构

在运行时创建数组优于在编译时创建数组,对于结构也是如此。需要在程序运行时为结构分配所需的空间,这也可以使用 new 运算符来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// newstruct.cpp -- 使用 new 运算符来创建结构
#include <iostream>
struct inflatable // 定义一个结构
{
char name[20];
float volume;
double price;
};
int main()
{
using namespace std;
inflatable * ps = new inflatable; // 为结构指针分配内存
cout << "Enter name of inflatable itme: ";
cin.get(ps->name, 20); // 箭头运算符访问成员
cout << "Enter volume in cubic feet: ";
cin >> (*ps).volume; // *ps 就表示结构实体,使用句点运算符访问成员
cout << "Enter price: $";
cin >> ps->price;
cout << "name: " << (*ps).name << endl;
cout << "Volume: " << ps->volume << " cubic feet\n";
cout << "Price: $" << ps->price << endl;
delete ps; // 释放 new 分配的内存
return 0;
}

Tips: 应使用句点运算符还是箭头运算符,规则非常简单,如果结构标识符是结构名,则使用句点运算符;如果标识符是指向结构的指针,则使用箭头运算符。

自动存储、静态存储和动态存储

根据用于分配内存的方法,C++ 有 3 种管理数据内存的方式:自动存储、静态存储和动态存储(有时也叫作自由存储空间或堆)。在存在时间的长短方面,以 3 种方式分配的数据对象各不相同。先简要概括这些知识。

自动存储

在函数内部定义的常规变量使用自动存储空间,被称为自动变量(automatic variable),这意味着它们在所属的函数被调用时自动产生,在该函数结束时消亡。

实际上,自动变量是一个局部变量,其作用域为包含它的代码块。

自动变量通常存储在栈内存中。这意味着执行代码块时,其中的变量将依次加入到栈中,而在离开代码块时,将按相反的顺序释放这些变量,这被称为后进先出(LIFO)。因此,在程序执行过程中,栈将不断地增大和缩小。

静态存储

静态存储是整个程序执行期间都存在的存储方式。使变量成为静态的方式有两种:一种是在函数外面定义它;另一种是在声明变量时使用关键字 static:

1
static double fee = 56.50;

自动存储和静态存储的关键在于:这些方法严格地限制了变量的寿命。变量可能存在于程序的整个生命周期(静态变量),也可能只是在特定函数被执行时存在(自动变量)。

动态存储

newdelete 运算符提供了一种比自动变量和静态变量更灵活的方法。它们管理了一个内存池,这在 C++ 中被称为自由存储空间(free store)或堆(heap)。该内存池同用于静态变量和自动变量的内存是分开的。newdelete 让我们可以自由控制内存的分配和释放,可以在这个函数中分配内存,到另一个函数中再释放,但通常不那么做。因此,数据的生命周期不完全受程序或函数的生存时间控制。与使用常规变量相比,使用 newdelete 让程序员对程序如何使用内存有更大的控制权,然而,内存管理也更复杂了。在栈中,自动添加和删除机制使得占用的内存总是连续的,但 newdelete 的相互影响可能导致占用的自由存储区不连续,这使得跟踪新分配内存的位置更困难。

Tips: 要避免内存泄漏问题,最好养成同时使用 newdelete 运算符,在自由存储空间上动态分配内存,随后便释放它。C++ 后续学习智能指针有助于自动完成这种任务。