Go 切片是胖指針

使用 C 語言時,常見的一個難題就是要理解指針除了表示一個內存地址以外什麼都不是。一個傳入了指針的被調函數只知道這個指針指向什麼類型的對象——也許包含了內存對齊和指針可以被如何使用之類的信息。如果這是一個指向 void 的指針(即 void *),那麼就連這類信息也是無法獲知的。

指針指向多少個連續的元素也是無法獲知的。可能是零個,這樣的話解引用就是非法的。即使當指針非空時這種情況也可能發生。指針可以超出數組的末尾,指向零個元素。比如:

void foo(int *);

void bar(void)
{
 int array[4];
 foo(array + 4);  // 指針指向數組末尾後一個位置
}

在某些情況下,元素的個數可知,至少對程序員來說是這樣。比如,函數可能會規定必須傳入至少 N 個或正好 N 個元素。這種信息可以用文檔來傳遞:

/** Foo 接受 4 個整數。 */
void foo(int *);

或者通過函數原型來傳達這樣的信息。儘管下面的函數表面上接受一個數組,實際上卻是一個指針,“4” 和函數原型並沒有關係。

void foo(int[4]);

雖然 C99 引入了一個使這種寫法成爲原型正式部分的特性,但不幸的是我從沒見過有哪個編譯器真的會使用這一信息。

void foo(int[static 4]);  // >= 4 個元素, 不能爲空

另一種常見的模式是讓被調函數接受一個計數器參數。比如,POSIX write() 函數:

ssize_t write(int fd, const void *buf, size_t count);

描述緩衝區大小的必要信息被兩個參數隔開了。這看起來冗長,而且如果這兩個參數不一致的話還會導致嚴重的 bug (緩衝區溢出、信息泄露 [2] 等)。如果這些信息能整合到指針當中,豈不會好一些?這就是_胖指針_的定義。

本文是 Go 語言中文網組織的 GCTT 翻譯,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

通過位運算實現胖指針

如果我們對目標平臺做出一些假設,我們就可以通過一些指針的 “奇技淫巧”[3],利用指針當中未被使用的位,將胖指針整合到一個普通指針中。比如,在目前的 x86-64 平臺上,一個指針中只有低 48 位被真正使用到。其餘 16 位可以被謹慎地用於傳遞其他信息,比如元素個數或者字節數:

// 注意:只能在 x86-64 平臺上這麼做!
unsigned char buf[1000];
uintptr addr = (uintptr_t)buf & 0xffffffffffff;
uintptr pack = (sizeof(buf) << 48) | addr;
void *fatptr = (void *)pack;

接收方可以解譯出這些信息。顯然,只有 16 位用於計數,這通常是不夠的,所以這一方法更可能被用於邊界檢查 [4]。

更進一步,如果我們知道內存對齊的情況——比如說 16 字節對齊——那麼我們也可以在低位中編碼信息,比如類型標籤。

通過結構體實現胖指針

上面所提到的方法不安全、不可移植,而且相當侷限。一個更健壯的方法是用更大的類型來包裝指針,比如結構體。

struct fatptr {
 void *ptr;
 size_t len;
};

以胖指針作爲參數的函數不再需要計數器參數,而且通常胖指針是值傳遞。

fatptr_write(int fd, struct fatptr);

在典型的 C 語言的實現中,結構體字段確實會被傳遞,如果不是這樣的話,就相當於每個字段單獨作爲參數進行傳遞,所以效率也沒有低多少。

爲了更直接一些,我們可以使用宏:

#define COUNTOF(array) \
 (sizeof(array) / sizeof(array[0]))

#define FATPTR(ptr, count) \
 (struct fatptr){ptr, count}

#define ARRAYPTR(array) \
 FATPTR(array, COUNTOF(array))

/* ... */

unsigned char buf[40];
fatptr_write(fd, ARRAYPTR(buf));

這種方法存在明顯的缺陷,比如 void 指針帶來的類型混淆、不能使用 const,而且這種寫法對 C 而言很怪。在一個真實的程序中我不會這麼寫,但現在請暫時忍耐。

在我往下說之前,我想往胖指針結構體中添加一個字段:容量。

struct fatptr {
 void *ptr;
 size_t len;
 size_t cap;
};

這樣一來,傳遞的信息就不僅包括目前有多少個元素(len),而且包括緩衝區當中還剩下多少額外的空間。比如,這讓被調函數知道有多少剩餘空間可用於追加新元素。

// 往緩衝區剩下的空間中填充值。
void
fill(struct fatptr ptr, int value)
{
 int *buf = ptr.ptr;
 for (size_t i = ptr.len; i < ptr.cap; i++) {
  buf[i] = value;
 }
}

既然被調函數修改了胖指針,就應該返回胖指針:

struct fatptr
fill(struct fatptr ptr, int value)
{
 int *buf = ptr.ptr;
 for (size_t i = ptr.len; i < ptr.cap; i++) {
  buf[i] = value;
 }
 ptr.len = ptr.cap;
 return ptr;
}

恭喜,現在你有了切片!Go 語言與其的差別在於,切片是 Go 語言本身的一部分,所以無需依賴於危險的技巧或者冗長的額外信息。上面的 fatptr_write() 函數幾乎和 Go 中接受一個切片的 Writer.Write() 函數有相同的功能:

type Writer interface {
 Write([]byte) (n int, err error)
}

Go 切片

Go 廣爲人知的一個特性是擁有指針,包括_內部_指針,但是不支持指針運算。你(幾乎)可以獲取任何東西的地址,但你不能讓這個指針指向別的東西,即使你獲取的是一個數組元素的地址。指針運算會危害 Go 的類型安全,所以它只能通過 unsafe 包中提供的一些特殊機制實現。

但是指針運算確實有用!獲取一個數組元素的地址,傳給一個函數,然後允許函數修改數組的一個切片,這樣的操作會很方便。切片就是支持這類指針運算的指針,但很安全。不同於 & 操作符會創建一個簡單的指針,切片操作符會派生出一個胖指針。

func fill([]int, int) []int

var array [8]int

// len == 0, cap == 8, 相當於 &array[0]
fill(array[:0], 1)
// array 現在變成 [1, 1, 1, 1, 1, 1, 1, 1]

// len == 0, cap == 4, 相當於 &array[4]
fill(array[4:4], 2)
// array 現在是 [1, 1, 1, 1, 2, 2, 2, 2]

fill 函數可以接受一個切片的切片,高效地通過指針運算移動指針而不會破壞內存安全,因爲有額外的 “胖指針” 信息。換句話說,胖指針可由別的胖指針派生得到。

至少就目前而言,切片並不像胖指針那麼常見。你可以用 & 獲取任何變量的地址,但是你不能獲取任意變量的_切片_,即使在邏輯上這行得通。

var foo int

// 試圖創建一個在底層指向 foo,len = 1,cap = 1 的切片
var fooslice []int = foo[:] // 編譯錯誤!

總而言之這麼做沒多大用處。然而,如果你非要這麼做,那麼 unsafe 包可以實現。我相信得到的切片可以放心地使用:

// 先轉換成只有一個元素地數組,再轉換成切片
fooslice = (*[1]int)(unsafe.Pointer(&foo))[:]

更新:Chris Siebenmann 關於爲什麼這需要 unsafe 包的推測 [5]。

當然,切片十分靈活,在許多使用場景中看起來不那麼像胖指針,但當我寫 Go 時我仍然會用這種方式來看待切片。


via: https://nullprogram.com/blog/2019/06/30/

作者:Chris Wellons[6] 譯者:maxwellhertz[7] 校對:polaris1119[8]

本文由 GCTT[9] 原創編譯,Go 中文網 [10] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。

參考資料

[1]

Hacker News: https://news.ycombinator.com/item?id=20321116

[2]

信息泄露: https://nullprogram.com/blog/2017/07/19/

[3]

“奇技淫巧”: https://nullprogram.com/blog/2016/05/30/

[4]

邊界檢查: https://www.usenix.org/legacy/event/sec09/tech/full_papers/akritidis.pdf

[5]

Chris Siebenmann 關於爲什麼這需要 unsafe 包的推測: https://utcc.utoronto.ca/~cks/space/blog/programming/GoVariableToArrayConversion

[6]

Chris Wellons: https://github.com/skeeto

[7]

maxwellhertz: https://github.com/maxwellhertz

[8]

polaris1119: https://github.com/polaris1119

[9]

GCTT: https://github.com/studygolang/GCTT

[10]

Go 中文網: https://studygolang.com/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/oYuzB6i2ynRx44rGf6N2xw