[重新理解 C++] Array 和 Pointer 的差異


前言

本來這個系列文主要是希望把焦點放在一些 C++ compiling time 的議題上,因為這些議題較少人能理解並正確的使用。
其原因在於你必須對 PL, compiler 有一點初步了解之後你才能體會為什麼這些語法要這樣設計,他們想解決什麼問題。

然而近日發現像是 Array/Pointer 這種基礎議題還是經常存在一些普遍誤解
其中不乏一些本科生或者已經出社會的人士

包括我自身也或多或少曾經歷過這些誤解、被這些誤解所迷惑
所以決定回頭討論一下這個議題

首先、來公布答案 XD

Array 和 Pointer 是兩種概念,完全不同的東西
其關係可類比於 C++ 的 vector 和 vector::iterator
它們其實是連型別都不同的東西,自然不應比較其行為
只是湊巧下標運算子(operator[])採用了類似的定義而已

每一次的 array assign to pointer 都應該視為一種「轉型」
若你有以上概念,本篇文章大概對你沒有任何幫助
可以跳過不看

誤解:Array 就是 Pointer

這是一種非常普遍的誤解,包括很多學校的老師可能都是這樣教
這個誤解的由來很大程度就來自於下標運算子

其思路大概是這樣的:

Pointer 的下標運算子本質上是一個 base address 加上一個 offset

  1. 你要取得 array 的 element 你必須使用下標運算子
  2. 下標運算子的使用語法上 array[i] 中的 變數名稱 array 似乎可以象徵 base address, 而理所當然的 i 就是 offset
  3. 相同型別的 array 可以直接 assign 給 pointer
  4. 所以 array 就是 pointer

這套思路是錯誤的
其危險之處在於它其實並不全錯,半真半假
array 的存取確實是用 base address from offset
但其 base address 卻不是 array 開頭的位置,詳細情況後面會提到。

值(value)

lvalue 的全名是什麼呢?
left hand side value?
這是很多人知道的俗名
但是更正確的叫法應該是 locatable value

因為放在右手邊的 expression 其實也有可能是一個佔有空間帶有名稱的變數, 其當然也是 lvalue
(這裡先不討論 C++11 以後的 glvalue, xvalue, ... 的模型)

我們都知道,pointer 的 value 就是 address, 那麼 array 的 value 是 address 嗎?
如果 array 的 value 是 address 那 array 的儲存空間該怎麼定義呢?
無法定義。

所以很顯然答案是否定的,array 的 value 其實就是 elements 構成的儲存空間,而不是儲存空間開頭的 address

而一個既有儲存空間又具備 symbol (就是 array 的變數名稱) 的變數
顯然就是妥妥的 lvalue 了

那麼如果我們對它取&(address/location of)又應該得到什麼結果呢?

&array (address of an array)

一個好的程式語言,其算子行為應該有邏輯上的一致性
所以我們來做一點簡單的類推

int n;
std::cout << &n << std::endl;

這裡我們得到的是 n 的 value 儲存位置的 address
同理

int a[10];
std::cout << &a << std::endl;

我們應該得到 a 的 value 儲存位置的 address

根據我們剛剛得到的結論:
array 的 value 其實就是 elements 構成的儲存空間
這個儲存空間的 address 自然就會是 elements 最開頭的位置。

於是造就了一系列詭異的等式

#include <cstdio>
int main() {
    int n[10] = {};
    int* pn = n;
    printf("%d\n", pn == (void*)n); // output 1
    printf("%d\n", pn == (void*)&n); // output 1
    printf("%d\n", &(n[0]) == (void*)&n); // output 1
    printf("%d\n", &(n[0]) == (void*)n); // output 1
    return 0;
}

此乃語言算子一致性所得之天然正確的結果 XD。

所以不要再說 array 就是 pointer 了
pointer 的 value 是 address
你對它取 address 你就會得到 addess of address, 而不是 address of value.

千萬不要再搞錯了。

附錄:怎麼從 assembly 看這件事

其實這個問題就是問 「compiler 怎麼編譯 array」

很多人根據 下標 = offset from base address 的原則
以為 compiler 會對 local array 生成一個空間儲存 base address 然後再用 offset 計算它的 element。

這個觀念其實不符合 compiler 普遍採用的原則。

對 compiler 來說,stack 只能用來儲存 programmer 編寫程式時候定義的 local variable
而 array 的本體就僅僅是它的 elements, 不應該占用 elements 以外的空間,
如果要占用,就必須要有足夠充分的理由,否則大概率會被開 bug

對於 array 是 local variable 的情況也完全沒有必要存在 base address
因為所有的 offset 都用 rbp 暫存器(stack base pointer) 去計算就好了
而所有的 local variable 其實都是用 rbp 去存取的(no optimization 的情況)
沒有理由生成任何變數去儲存 base address

以下舉例:

#include <cstdio>
int main() {
    int a = 0;
    int n[3] = {5, 1, 2};
    printf("%d\n", n[1]);
    printf("%d\n", a);
    return 0;
}

其在 gcc7.5 -g -O0 的編譯結果為

.LC0:
        .string "%d\n"
main:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movl    $0, -4(%rbp)
        movl    $5, -16(%rbp)
        movl    $1, -12(%rbp)
        movl    $2, -8(%rbp)
        movl    -12(%rbp), %eax
        movl    %eax, %esi
        movl    $.LC0, %edi
        movl    $0, %eax
        call    printf
        movl    -4(%rbp), %eax
        movl    %eax, %esi
        movl    $.LC0, %edi
        movl    $0, %eax
        call    printf
        movl    $0, %eax
        leave
        ret

其中 movl 這段代碼便是 array 初始化的操作,其 access 方式為 rbp + offset

movl    $5, -16(%rbp)
movl    $1, -12(%rbp)
movl    $2, -8(%rbp)

而另一段呼叫 printf 印出 n[1] 的 code 則翻譯為

movl    -12(%rbp), %eax
movl    %eax, %esi
movl    $.LC0, %edi
movl    $0, %eax
call    printf

同樣是使用 local variable 的存取方式 (offset from rbp) 去存取 array element
所以即便從 assembly 的角度,仍然不支持 array 即是 pointer 的說法
切莫再混淆

2021/6/28 更新 - The C programming language 書中的描述

日前網友提到 The C programming language 這本書對於 pointer/array 的描述跟我說的好像不是一回事
並附上書的截圖

其實整段文章看下來跟本文並沒有太大衝突,特別文中的操作在本文也有 example code 可以參考。

唯有一點

Thus after the assignment pa = &a[0]; pa and a have identical value.

乍看之下似乎C作者本人直接說明了 array 的 value 就是 pointer address.

然而我們回頭看 Standard 怎麼說的:

6.3.2.1 - 3
Except when it is the operand of the sizeof operator or the unary & operator, or is a
string literal used to initialize an array, an expression that has type ‘‘array of type’’ is
converted to an expression with type ‘‘pointer to type’’ that points to the initial element of
the array object and is not an lvalue.

先姑且不論關於 string literal 的描述,以免失焦

整條內文大概意思就是:
除了 sizeof 或者 unary & operator (也就是取址運算以外),所有對於 array 的運算將使 array 轉換為 pointer,且不是 lvalue.
更白話一點就是除了 sizeof 和 & 以外的情況,array 會轉換為 rvalue pointer 進行運算。

其實到這裡已經基本說明了

這就是轉型

但再更嚴謹一點,根據 standard 的另一個條文:

6.5.1 - 2
An identifier is a primary expression, provided it has been declared as designating an
object (in which case it is an lvalue) or a function (in which case it is a function
designator).

基本上存有 identifier 的 primary expression 就是 lvalue.
這裡也講明了另一個常見誤解就是 array 經常被認為是 rvalue,
然而此處說明只要有 identifier 那就是 lvalue

事實上,這只是針對 identifier 的描述,而非 lvalue 的定義。
lvalue 乃至於 Mordern C++ 提到 glvalue 定義的範圍遠遠不只如此。

所以書中 array a 本身是 lvalue, 再某個運算發生轉型之後運算結果為 rvalue pointer
其實 rvalue 的部分也是很好理解的,因為任何運算的回傳值只要不是 lvalue reference 基本上就一定是 rvalue

回到書中的描述

我認為書並沒有寫錯,The C programming language 的定位是一本啟蒙級別的教科書
事實上,你讓我去教 0 基礎的初學者,我也會告訴他「先把 array 當成 pointer」
(當然同時我也會告訴他這是不同的東西,僅僅是便於理解)
在這個背景之下提及 value categories 或所謂 expression type, 太過為難初學者
而將這個概念掩蓋掉在此處為的辦法就是「把 array 當成 pointer」。

然而本系列文並不適合 0 基礎的人,詳見開篇

另外本篇用的 standard 都是 C standard, 其原因為 array/pointer 是自 C 時代留下來的歷史包袱,
很多行為都是以 C standard 為基礎進行設計並擴展。
而討論基礎概念的時候這裡想做到的是盡可能排除掉高級的"雜念"所以回到根本進行討論。

reference

The C Programming language
ISO/IEC 9899:TC3

#C #C++ #assembly #Computer Science #Compiler







你可能感興趣的文章

Leetcode 1. Two sum

Leetcode 1. Two sum

自動化測試 x Puppeteer - 玩偶QA參一咖 Day03

自動化測試 x Puppeteer - 玩偶QA參一咖 Day03

React input re-render 問題

React input re-render 問題






留言討論