跳转至

指針

變量的地址、指針

在程序中,我們的數據都有其存儲的地址。在程序每次的實際運行過程中,變量在物理內存中的存儲位置不盡相同。不過,我們仍能夠在編程時,通過一定的語句,來取得數據在內存中的地址。

地址也是數據。存放地址所用的變量類型有一個特殊的名字,叫做「指針變量」,有時也簡稱做「指針」。

指針變量的大小

指針變量的大小在不同環境下有差異。在 32 位機上,地址用 32 位二進制整數表示,因此一個指針的大小為 4 字節。而 64 位機上,地址用 64 位二進制整數表示,因此一個指針的大小就變成了 8 字節。

地址只是一個刻度一般的數據,為了針對不同類型的數據,「指針變量」也有不同的類型,比如,可以有 int 類型的指針變量,其中存儲的地址(即指針變量存儲的數值)對應一塊大小為 32 位的空間的起始地址;有 char 類型的指針變量,其中存儲的地址對應一塊 8 位的空間的起始地址。

事實上,用户也可以聲明指向指針變量的指針變量。

假如用户自定義了一個結構體:

1
2
3
4
5
struct ThreeInt {
  int a;
  int b;
  int c;
};

ThreeInt 類型的指針變量,對應着一塊 3 × 32 = 96 bit 的空間。

指針的聲明與使用

C/C++ 中,指針變量的類型為類型名後加上一個星號 *。比如,int 類型的指針變量的類型名即為 int*

我們可以使用 & 符號取得一個變量的地址。

要想訪問指針變量地址所對應的空間(又稱指針所 指向 的空間),需要對指針變量進行 解引用(dereference),使用 * 符號。

1
2
3
4
5
int main() {
  int a = 123;  // a: 123
  int* pa = &a;
  *pa = 321;  // a: 321
}

對結構體變量也是類似。如果要訪問指針指向的結構中的成員,需要先對指針進行解引用,再使用 . 成員關係運算符。不過,更推薦使用「箭頭」運算符 -> 這一更簡便的寫法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct ThreeInt {
  int a;
  int b;
  int c;
};

int main() {
  ThreeInt x{1, 2, 3}, y{6, 7, 8};
  ThreeInt* px = &x;
  (*px) = y;    // x: {6,7,8}
  (*px).a = 4;  // x: {4,7,8}
  px->b = 5;    // x: {4,5,8}
}

指針的偏移

指針變量也可以 和整數 進行加減操作。對於 int 型指針,每加 1(遞增 1),其指向的地址偏移 32 位(即 4 個字節);若加 2,則指向的地址偏移 2 × 32 = 64 位。同理,對於 char 型指針,每次遞增,其指向的地址偏移 8 位(即 1 個字節)。

使用指針偏移訪問數組

我們前面説過,數組是一塊連續的存儲空間。而在 C/C++ 中,直接使用數組名,得到的是數組的起始地址。

1
2
3
4
5
6
7
8
9
int main() {
  int a[3] = {1, 2, 3};
  int* p = a;  // p 指向 a[0]
  *p = 4;      // a: [4, 2, 3]
  p = p + 1;   // p 指向 a[1]
  *p = 5;      // a: [4, 5, 3]
  p++;         // p 指向 a[2]
  *p = 6;      // a: [4, 5, 6]
}

當通過指針訪問數組中的元素時,往往需要用到「指針的偏移」,換句話説,即通過一個基地址(數組起始的地址)加上偏移量來訪問。

我們常用 [] 運算符來訪問數組中某一指定偏移量處的元素。比如 a[3] 或者 p[4]。這種寫法和對指針進行運算後再引用是等價的,即 p[4]*(p + 4) 是等價的兩種寫法。

空指針

在 C++11 之前,C++ 和 C 一樣使用 NULL 宏表示空指針常量,C++ 中 NULL 的實現一般如下:

1
2
// C++11 前
#define NULL 0
C 語言對 NULL 的定義

C 語言在 C23 前有兩個 NULL 的定義,只有類型不同:一個是整型常量表達式,一個是轉換為 void * 類型的常量表達式,但其值都為 0,編譯器可任選一個實現。

空指針和整數 0 的混用在 C++ 中會導致許多問題,比如:

1
2
int f(int x);
int f(int* p);

在調用 f(NULL) 時,實際調用的函數的類型是 int(int) 而不是 int(int *).

NULL 在 C 語言中造成的問題

比起在 C++ 中,因為有兩個定義,在 C 語言中 NULL 造成的問題更為嚴重:如果在一個傳遞可變參數的函數中,函數編寫者想要接受一個指針,但是函數調用者傳遞了一個定義為整型的 NULL,則會造成未定義行為,因在函數內使用傳入的可變參數時,要進行類型轉換,而從整型到指針類型的轉換是未定義行為。1

為了解決這些問題,C++11 引入了 nullptr 關鍵字作為空指針常量。

C++ 規定 nullptr 可以隱式轉換為任何指針類型,這種轉換結果是該類型的空指針值。

nullptr 的類型為 std::nullptr_t, 稱作空指針類型,可能的實現如下:

1
2
3
namespace std {
typedef decltype(nullptr) nullptr_t;
}

另外,C++11 起 NULL 宏的實現也被修改為了:

1
2
// C++11 起
#define NULL nullptr
C 語言對空指針常量的改進

基於類似的原因,C23 也引入了 nullptr 作為空指針常量,同時引入了 nullptr_t 作為其類型1

指針的進階使用

使用指針,使得程序編寫者可以操作程序運行時中各處的數據,而不必侷限於作用域。

指針類型參數的使用

在 C/C++ 中,調用函數(過程)時使用的參數,均以拷貝的形式傳入子過程中(引用除外,會在後續介紹)。默認情況下,函數僅能通過返回值,將結果返回到調用處。但是,如果某個函數希望修改其外部的數據,或者某個結構體/類的數據量較為龐大、不宜進行拷貝,這時,則可以通過向其傳入外部數據的地址,便得以在其中訪問甚至修改外部數據。

下面的 my_swap 方法,通過接收兩個 int 型的指針,在函數中使用中間變量,完成對兩個 int 型變量值的交換。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void my_swap(int *a, int *b) {
  int t;
  t = *a;
  *a = *b;
  *b = t;
}

int main() {
  int a = 6, b = 10;
  my_swap(&a, &b);
  // 調用後,main 函數中 a 變量的值變為 10,b 變量的值變為 6
}

C++ 中引入了引用的概念,相對於指針來説,更易用,也更安全。詳情可以參見 C++:引用 以及 C 與 C++ 的區別:指針與引用

動態實例化

除此之外,程序編寫時往往會涉及到動態內存分配,即,程序會在運行時,向操作系統動態地申請或歸還存放數據所需的內存。當程序通過調用操作系統接口申請內存時,操作系統將返回程序所申請空間的地址。要使用這塊空間,我們需要將這塊空間的地址存儲在指針變量中。

在 C++ 中,我們使用 new 運算符來獲取一塊內存,使用 delete 運算符釋放某指針所指向的空間。

1
2
3
int* p = new int(1234);
/* ... */
delete p;

上面的語句使用 new 運算符向操作系統申請了一塊 int 大小的空間,將其中的值初始化為 1234,並聲明瞭一個 int 型的指針 p 指向這塊空間。

同理,也可以使用 new 開闢新的對象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class A {
  int a;

 public:
  A(int a_) : a(a_) {}
};

int main() {
  A* p = new A(1234);
  /* ... */
  delete p;
}

如上,「new 表達式」將嘗試開闢一塊對應大小的空間,並嘗試在這塊空間上構造這一對象,並返回這一空間的地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct ThreeInt {
  int a;
  int b;
  int c;
};

int main() {
  ThreeInt* p = new ThreeInt{1, 2, 3};
  /* ... */
  delete p;
}
列表初始化

{} 運算符可以用來初始化沒有構造函數的結構。除此之外,使用 {} 運算符可以使得變量的初始化形式變得統一。詳見「list initialization (since C++11)」。

需要注意,當使用 new 申請的內存不再使用時,需要使用 delete 釋放這塊空間。不能對一塊內存釋放兩次或以上。而對空指針 nullptr 使用 delete 操作是合法的。

動態創建數組

也可以使用 new[] 運算符創建數組,這時 new[] 運算符會返回數組的首地址,也就是數組第一個元素的地址,我們可以用對應類型的指針存儲這個地址。釋放時,則需要使用 delete[] 運算符。

1
2
3
size_t element_cnt = 5;
int *p = new int[element_cnt];
delete[] p;

數組中元素的存儲是連續的,即 p + 1 指向的是 p 的後繼元素。

二維數組

在存放矩陣形式的數據時,可能會用到「二維數組」這樣的數據類型。從語義上來講,二維數組是一個數組的數組。而計算機內存可以視作一個很長的一維數組。要在計算機內存中存放一個二維數組,便有「連續」與否的説法。

所謂「連續」,即二維數組的任意一行(row)的末尾與下一行的起始,在物理地址上是毗鄰的,換言之,整個二維數組可以視作一個一維數組;反之,則二者在物理上不一定相鄰。

對於「連續」的二維數組,可以僅使用一個循環,藉由一個不斷遞增的指針即可遍歷數組中的所有數據。而對於非連續的二維數組,由於每一行不連續,則需要先取得某一行首的地址,再訪問這一行中的元素。

二維數組的存儲方式

這種按照「行(row)」存儲數據的方式,稱為行優先存儲;相對的,也可以按照列(column)存儲數據。由於計算機內存訪問的特性,一般來説,訪問連續的數據會得到更高的效率。因此,需要按照數據可能的使用方式,選擇「行優先」或「列優先」的存儲方式。

動態創建二維數組

在 C/C++ 中,我們可以使用類似下面這樣的語句聲明一個 N 行(row)M 列(column)的二維數組,其空間在物理上是連續的。

描述數組的維度

更通用的方式是使用第 n 維(dimension)的説法。對於「行優先」的存儲形式,數組的第一維長度為 N,第二維長度為 M。

1
int a[N][M];

這種聲明方式要求 N 和 M 為在編譯期即可確定的常量表達式。

在 C/C++ 中,數組的第一個元素下標為 0,因此 a[r][c] 這樣的式子代表二維數組 a 中第 r + 1 行的第 c + 1 個元素,我們也稱這個元素的下標為 (r,c)

不過,實際使用中,(二維)數組的大小可能不是固定的,需要動態內存分配。

常見的方式是聲明一個長度為 N × M 的 一維數組,並通過下標 r * M + c 訪問二維數組中下標為 (r, c) 的元素。

1
int* a = new int[N * M];

這種方法可以保證二維數組是 連續的

數組在物理層面上的線性存儲

實際上,數據在內存中都可以視作線性存放的,因此在一定的規則下,通過動態開闢一維數組的空間,即可在其上存儲 n 維的數組。

此外,亦可以根據「數組的數組」這一概念來進行內存的獲取與使用。對於一個存放的若干數組的數組,實際上為一個存放的若干數組的首地址的數組,也就是一個存放若干指針變量的數組。

我們需要一個變量來存放這個「數組的數組」的首地址——也就是一個指針的地址。這個變量便是一個「指向指針的指針」,有時也稱作「二重指針」,如:

1
int** a = new int*[5];

接着,我們需要為每一個數組申請空間:

1
2
3
for (int i = 0; i < 5; i++) {
  a[i] = new int[5];
}

至此,我們便完成了內存的獲取。而對於這樣獲得的內存的釋放,則需要進行一個逆向的操作:即先釋放每一個數組,再釋放存儲這些數組首地址的數組,如:

1
2
3
4
for (int i = 0; i < 5; i++) {
  delete[] a[i];
}
delete[] a;

需要注意,這樣獲得的二維數組,不能保證其空間是連續的。

還有一種方式,需要使用到「指向數組的指針」。

數組名和數組首元素地址的區別

我們之前説到,在 C/C++ 中,直接使用數組名,值等於數組首元素的地址。但是數組名錶示的這一變量的類型實際上是整個數組,而非單個元素。

1
int main() { int a[5] = {1, 2, 3, 4, 5}; }

從概念上説,代碼中標識符 a 的類型是 int[5];從實際上來説,a + 1 所指向的地址相較於 a 指向的地址的偏移量為 5 個 int 型變量的長度。

1
2
3
4
5
6
int main() {
  int(*a)[5] = new int[5][5];
  int* p = a[2];
  a[2][1] = 1;
  delete[] a;
}

這種方式獲得到的也是連續的內存,但是可以直接使用 a[n] 的形式獲得到數組的第 n + 1 行(row)的首地址,因此,使用 a[r][c] 的形式即可訪問到下標為 (r, c) 的元素。

由於指向數組的指針也是一種確定的數據類型,因此除數組的第一維外,其他維度的長度均須為一個能在編譯器確定的常量。不然,編譯器將無法翻譯如 a[n] 這樣的表達式(a 為指向數組的指針)。

指向函數的指針

關於函數的介紹請參見 C++ 函數 章節。

簡單地説,要調用一個函數,需要知曉該函數的參數類型、個數以及返回值類型,這些也統一稱作接口類型。

可以通過函數指針調用函數。有時候,若干個函數的接口類型是相同的,使用函數指針可以根據程序的運行 動態地 選擇需要調用的函數。換句話説,可以在不修改一個函數的情況下,僅通過修改向其傳入的參數(函數指針),使得該函數的行為發生變化。

假設我們有若干針對 int 類型的二元運算函數,則函數的參數為 2 個 int,返回值亦為 int。下邊是一個使用了函數指針的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

int (*binary_int_op)(int, int);

int foo1(int a, int b) { return a * b + b; }

int foo2(int a, int b) { return (a + b) * b; }

int main() {
  int choice;
  std::cin >> choice;
  if (choice == 1) {
    binary_int_op = foo1;
  } else {
    binary_int_op = foo2;
  }

  int m, n;
  std::cin >> m >> n;
  std::cout << binary_int_op(m, n);
}
&* 和函數指針

在 C 語言中,諸如 void (*p)() = foo;void (*p)() = &foo;void (*p)() = *foo;void (*p)() = ***foo 等寫法的結果是一樣的。

因為函數(如 foo)是能夠被隱式轉換為指向函數的指針的,因此 void (*p)() = foo; 的寫法能夠成立。

使用 & 運算符可以取得到對象的地址,這對函數也是成立的,因此 void (*p)() = &foo; 的寫法仍然成立。

對函數指針使用 * 運算符可以取得指針指向的函數,而對於 **foo 這樣的寫法來説,*foo 得到的是 foo 這個函數,緊接着又被隱式轉換為指向 foo 的指針。如此類推,**foo 得到的最終還是指向 foo 的函數指針;用户儘可以使用任意多的 *,結果也是一樣的。

同理,在調用時使用類似 (*p)()p() 的語句是一樣的,可以省去 * 運算符。

參考資料:Why do function pointer definitions work with any number of ampersands '&' or asterisks '*'? - stackoverflow.com

可以使用 typedef 關鍵字聲明函數指針的類型。

1
typedef int (*p_bi_int_op)(int, int);

這樣我們就可以在之後使用 p_bi_int_op 這種類型,即指向「參數為 2 個 int,返回值亦為 int」的函數的指針。

可以通過使用 std::function 來更方便的引用函數。(未完待續)

使用函數指針,可以實現「回調函數」。(未完待續)

參考資料與註釋