指針
變量的地址、指針
在程序中,我們的數據都有其存儲的地址。在程序每次的實際運行過程中,變量在物理內存中的存儲位置不盡相同。不過,我們仍能夠在編程時,通過一定的語句,來取得數據在內存中的地址。
地址也是數據。存放地址所用的變量類型有一個特殊的名字,叫做「指針變量」,有時也簡稱做「指針」。
指針變量的大小
指針變量的大小在不同環境下有差異。在 32 位機上,地址用 32 位二進制整數表示,因此一個指針的大小為 4 字節。而 64 位機上,地址用 64 位二進制整數表示,因此一個指針的大小就變成了 8 字節。
地址只是一個刻度一般的數據,為了針對不同類型的數據,「指針變量」也有不同的類型,比如,可以有 int 類型的指針變量,其中存儲的地址(即指針變量存儲的數值)對應一塊大小為 32 位的空間的起始地址;有 char 類型的指針變量,其中存儲的地址對應一塊 8 位的空間的起始地址。
事實上,用户也可以聲明指向指針變量的指針變量。
假如用户自定義了一個結構體:
1 2 3 4 5 | |
則 ThreeInt 類型的指針變量,對應着一塊 3 × 32 = 96 bit 的空間。
指針的聲明與使用
C/C++ 中,指針變量的類型為類型名後加上一個星號 *。比如,int 類型的指針變量的類型名即為 int*。
我們可以使用 & 符號取得一個變量的地址。
要想訪問指針變量地址所對應的空間(又稱指針所 指向 的空間),需要對指針變量進行 解引用(dereference),使用 * 符號。
1 2 3 4 5 | |
對結構體變量也是類似。如果要訪問指針指向的結構中的成員,需要先對指針進行解引用,再使用 . 成員關係運算符。不過,更推薦使用「箭頭」運算符 -> 這一更簡便的寫法。
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
指針的偏移
指針變量也可以 和整數 進行加減操作。對於 int 型指針,每加 1(遞增 1),其指向的地址偏移 32 位(即 4 個字節);若加 2,則指向的地址偏移 2 × 32 = 64 位。同理,對於 char 型指針,每次遞增,其指向的地址偏移 8 位(即 1 個字節)。
使用指針偏移訪問數組
我們前面説過,數組是一塊連續的存儲空間。而在 C/C++ 中,直接使用數組名,得到的是數組的起始地址。
1 2 3 4 5 6 7 8 9 | |
當通過指針訪問數組中的元素時,往往需要用到「指針的偏移」,換句話説,即通過一個基地址(數組起始的地址)加上偏移量來訪問。
我們常用 [] 運算符來訪問數組中某一指定偏移量處的元素。比如 a[3] 或者 p[4]。這種寫法和對指針進行運算後再引用是等價的,即 p[4] 和 *(p + 4) 是等價的兩種寫法。
空指針
在 C++11 之前,C++ 和 C 一樣使用 NULL 宏表示空指針常量,C++ 中 NULL 的實現一般如下:
1 2 | |
C 語言對 NULL 的定義
C 語言在 C23 前有兩個 NULL 的定義,只有類型不同:一個是整型常量表達式,一個是轉換為 void * 類型的常量表達式,但其值都為 0,編譯器可任選一個實現。
空指針和整數 0 的混用在 C++ 中會導致許多問題,比如:
1 2 | |
在調用 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 | |
另外,C++11 起 NULL 宏的實現也被修改為了:
1 2 | |
C 語言對空指針常量的改進
基於類似的原因,C23 也引入了 nullptr 作為空指針常量,同時引入了 nullptr_t 作為其類型1。
指針的進階使用
使用指針,使得程序編寫者可以操作程序運行時中各處的數據,而不必侷限於作用域。
指針類型參數的使用
在 C/C++ 中,調用函數(過程)時使用的參數,均以拷貝的形式傳入子過程中(引用除外,會在後續介紹)。默認情況下,函數僅能通過返回值,將結果返回到調用處。但是,如果某個函數希望修改其外部的數據,或者某個結構體/類的數據量較為龐大、不宜進行拷貝,這時,則可以通過向其傳入外部數據的地址,便得以在其中訪問甚至修改外部數據。
下面的 my_swap 方法,通過接收兩個 int 型的指針,在函數中使用中間變量,完成對兩個 int 型變量值的交換。
1 2 3 4 5 6 7 8 9 10 11 12 | |
C++ 中引入了引用的概念,相對於指針來説,更易用,也更安全。詳情可以參見 C++:引用 以及 C 與 C++ 的區別:指針與引用。
動態實例化
除此之外,程序編寫時往往會涉及到動態內存分配,即,程序會在運行時,向操作系統動態地申請或歸還存放數據所需的內存。當程序通過調用操作系統接口申請內存時,操作系統將返回程序所申請空間的地址。要使用這塊空間,我們需要將這塊空間的地址存儲在指針變量中。
在 C++ 中,我們使用 new 運算符來獲取一塊內存,使用 delete 運算符釋放某指針所指向的空間。
1 2 3 | |
上面的語句使用 new 運算符向操作系統申請了一塊 int 大小的空間,將其中的值初始化為 1234,並聲明瞭一個 int 型的指針 p 指向這塊空間。
同理,也可以使用 new 開闢新的對象:
1 2 3 4 5 6 7 8 9 10 11 12 | |
如上,「new 表達式」將嘗試開闢一塊對應大小的空間,並嘗試在這塊空間上構造這一對象,並返回這一空間的地址。
1 2 3 4 5 6 7 8 9 10 11 | |
列表初始化
{} 運算符可以用來初始化沒有構造函數的結構。除此之外,使用 {} 運算符可以使得變量的初始化形式變得統一。詳見「list initialization (since C++11)」。
需要注意,當使用 new 申請的內存不再使用時,需要使用 delete 釋放這塊空間。不能對一塊內存釋放兩次或以上。而對空指針 nullptr 使用 delete 操作是合法的。
動態創建數組
也可以使用 new[] 運算符創建數組,這時 new[] 運算符會返回數組的首地址,也就是數組第一個元素的地址,我們可以用對應類型的指針存儲這個地址。釋放時,則需要使用 delete[] 運算符。
1 2 3 | |
數組中元素的存儲是連續的,即 p + 1 指向的是 p 的後繼元素。
二維數組
在存放矩陣形式的數據時,可能會用到「二維數組」這樣的數據類型。從語義上來講,二維數組是一個數組的數組。而計算機內存可以視作一個很長的一維數組。要在計算機內存中存放一個二維數組,便有「連續」與否的説法。
所謂「連續」,即二維數組的任意一行(row)的末尾與下一行的起始,在物理地址上是毗鄰的,換言之,整個二維數組可以視作一個一維數組;反之,則二者在物理上不一定相鄰。
對於「連續」的二維數組,可以僅使用一個循環,藉由一個不斷遞增的指針即可遍歷數組中的所有數據。而對於非連續的二維數組,由於每一行不連續,則需要先取得某一行首的地址,再訪問這一行中的元素。
二維數組的存儲方式
這種按照「行(row)」存儲數據的方式,稱為行優先存儲;相對的,也可以按照列(column)存儲數據。由於計算機內存訪問的特性,一般來説,訪問連續的數據會得到更高的效率。因此,需要按照數據可能的使用方式,選擇「行優先」或「列優先」的存儲方式。
動態創建二維數組
在 C/C++ 中,我們可以使用類似下面這樣的語句聲明一個 N 行(row)M 列(column)的二維數組,其空間在物理上是連續的。
描述數組的維度
更通用的方式是使用第 n 維(dimension)的説法。對於「行優先」的存儲形式,數組的第一維長度為 N,第二維長度為 M。
1 | |
這種聲明方式要求 N 和 M 為在編譯期即可確定的常量表達式。
在 C/C++ 中,數組的第一個元素下標為 0,因此 a[r][c] 這樣的式子代表二維數組 a 中第 r + 1 行的第 c + 1 個元素,我們也稱這個元素的下標為 (r,c)。
不過,實際使用中,(二維)數組的大小可能不是固定的,需要動態內存分配。
常見的方式是聲明一個長度為 N × M 的 一維數組,並通過下標 r * M + c 訪問二維數組中下標為 (r, c) 的元素。
1 | |
這種方法可以保證二維數組是 連續的。
數組在物理層面上的線性存儲
實際上,數據在內存中都可以視作線性存放的,因此在一定的規則下,通過動態開闢一維數組的空間,即可在其上存儲 n 維的數組。
此外,亦可以根據「數組的數組」這一概念來進行內存的獲取與使用。對於一個存放的若干數組的數組,實際上為一個存放的若干數組的首地址的數組,也就是一個存放若干指針變量的數組。
我們需要一個變量來存放這個「數組的數組」的首地址——也就是一個指針的地址。這個變量便是一個「指向指針的指針」,有時也稱作「二重指針」,如:
1 | |
接着,我們需要為每一個數組申請空間:
1 2 3 | |
至此,我們便完成了內存的獲取。而對於這樣獲得的內存的釋放,則需要進行一個逆向的操作:即先釋放每一個數組,再釋放存儲這些數組首地址的數組,如:
1 2 3 4 | |
需要注意,這樣獲得的二維數組,不能保證其空間是連續的。
還有一種方式,需要使用到「指向數組的指針」。
數組名和數組首元素地址的區別
我們之前説到,在 C/C++ 中,直接使用數組名,值等於數組首元素的地址。但是數組名錶示的這一變量的類型實際上是整個數組,而非單個元素。
1 | |
從概念上説,代碼中標識符 a 的類型是 int[5];從實際上來説,a + 1 所指向的地址相較於 a 指向的地址的偏移量為 5 個 int 型變量的長度。
1 2 3 4 5 6 | |
這種方式獲得到的也是連續的內存,但是可以直接使用 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 | |
&、* 和函數指針
在 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() 的語句是一樣的,可以省去 * 運算符。
可以使用 typedef 關鍵字聲明函數指針的類型。
1 | |
這樣我們就可以在之後使用 p_bi_int_op 這種類型,即指向「參數為 2 個 int,返回值亦為 int」的函數的指針。
可以通過使用 std::function 來更方便的引用函數。(未完待續)
使用函數指針,可以實現「回調函數」。(未完待續)
參考資料與註釋
本页面最近更新:,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:tsagaanbar, Enter-tainer, Xeonacid
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用