lua unpack 陷阱

spacewander發表於2019-02-16

先看一則示例:

local ary_with_hole = {1, 3, 5, 7, 9}
print(#ary_with_hole) -- => 5,這正是我想要的

ary_with_hole[4] = nil
print(#ary_with_hole) -- => 3,不是 4?!

ary_with_hole[2] = nil
print(#ary_with_hole) -- => 1,更離奇了!

ary_with_hole[1] = nil
print(#ary_with_hole) -- => 0,現在直接變 0 了?

拜 Lua 內部實現上的細節所賜,如果傳遞的陣列中帶有 nil 值空洞,# 操作符返回的數值並不能反映真實的大小。

直接引用 Lua 5.1 manual 上的說法(Lua 5.2 和 LuaJIT 也是一樣的定義):

https://www.lua.org/manual/5…. The Length Operator

The length of a table t is defined to be any integer index n such that t[n] is not nil and t[n+1] is nil; moreover, if t[1] is nil, n can be zero. …If the array has “holes” (that is, nil values between other non-nil values), then #t can be any of the indices that directly precedes a nil value (that is, it may consider any such nil value as the end of the array).

如果讓我來編寫文件,我一定會把上面一段話加粗、調大字號、標紅,因為這個實在太坑了。

簡單說,Lua 裡面 table 的長度的定義跟其他語言的不同。table 的長度,被定義成第一個值為 nil 的整數鍵(而不是像通常認為那樣,等價於元素的數量)。
如果一個 array-like table 裡面存在空洞,那麼任意 nil 值前面的索引都有可能是 # 操作符返回的值。

在示例中,之所以會有 5 3 1 0 這樣的遞減,是因為 Lua 在找 nil 值時,大致採用的是從後往前半分查詢的方式。如果改變 nil 值的位置,顯示的結果則大相徑庭。
總而言之,帶 nil 的陣列的“長度”並不能反映裡面元素的數量。

幾天前,專案裡出了一個需要線上救火的問題,經追查就是因為 unpack 一個帶 nil 陣列導致的。
unpack(table) 返回的值是 table[1], ..., table[#table]。如果 #table 返回的值不等價於實際的元素數量,unpack 操作返回的值就會被截斷。
這顯然會導致意料之外的後果。

受帶 nil 陣列的長度影響的,除了 #unpack 操作,還包括 table.getnipairs 等。

解決方法有二:

  1. 改變對可能會帶有 nil 的陣列的處理方式。把這樣的陣列,當作鍵剛好是整數的 record-like table 去處理。比如改用 pair 而非 ipair 來迭代它。
    這麼做有個限制。由於 Lua 是動態語言,而且不提供型別標記之類的語法,要分辨哪些函式返回的是“可能帶 nil 的陣列”,除了在註釋中註明,好像也沒別的法子。事實上,除非專案中的老司機做好 code review,不然總免不了會有不瞭解內情的新人掉坑。

  2. 採用 NullObject pattern,對於空資料,不採用 nil,而是採用其他約定俗成、因地制宜的空值。這裡有兩個比較適合的候選:false 或 ngx.null。
    前者跟 nil 一樣,也是個假值。所以呼叫程式碼可以依舊保持 res or default_value 這樣的方式,而無需變動。如果 false 也是可能的返回值,則可以用 ngx.null。不過要注意 ngx.null 是一個真值,呼叫程式碼需要判斷返回的值是否等於 ngx.null,來判斷返回值是否為空。