不變物件

lua-users home
wiki

此頁面討論 Lua 中的不變性 [6] / const-ness [4] 主題。

概念概要

根據 [Papurt, 1],「不變實體永不變更值,但有不變性以兩種方式表現。嚴格不變實體在建立時取得初值,且在終止前維持此值。而在情境不變性中,可修改的實體在特定情境中維持可修改性。」。Lua 中的某些值擁有嚴格不變性,例如字串、數字和布林值(以及封閉變數和環境除外之函式)。Lua 表格為可變動的,但嚴格不變性可透過 metamethods 在某種程度上模擬(bar rawset),如 ReadOnlyTables 所示。使用者資料能提供更強大的強制執行。相比之下,Lua 區域變數為可變動的(就變數與值之間的關聯,而非值本身來說),而全域變數是當作表格實作的,因此與表格共享相同的條件。儘管可以將變數視為常數(例如,在實務上通常不會變異的 math.pi),這僅是慣例,並非由 Lua 編譯器或執行階段強制執行的。情境不變性(或「const 正確性」)在語言中較不常見,但廣泛應用於 C/C++ 中,並在 D 中予以擴充([維基百科:const 正確性])。情境不變性提供物件的唯讀「檢視」。

我們還有一個 不變性的遞移性 問題,也就是說,若物件是不變的,則從該物件可存取的其他物件是否也都是不變的。例如,若 document.page[i] 取回文件中的頁面 i,且文件具有情境不變性,那麼從 document.page[i] 取回的頁面是否也有情境不變性?在 C 中,我們區分了 const 指標、指向 const 的指標,以及指向 const 的 const 指標,還有傳回 const 指標的 const 方法(允許在先前的範例中傳遞)。在 D 中,完整的遞移性更為內建。[4][1][5]

以下是模擬 Lua 中各種不變性效果的方法。

常數的不變性

一個常見的慣例(並非由編譯器強制執行)是將從未在 ALL_CAPS 中修改的變數命名。請參閱 LuaStyleGuide

local MAX_SIZE = 20

for i=1,MAX_SIZE do print(i) end

表格的不變性(唯讀表格)

表格可以透過 metamethods 變得幾乎是不變的。請參閱 ReadOnlyTables

使用者資料的不變性

使用者資料具備一些表格的特性。然而,一個可能的優點是不變性的使用者資料可以相較於 Lua 表格以更嚴格的方式在 Lua 端強制執行。

函式物件的不變性

函式可以在 upvalue、環境變數或透過其他函式呼叫 (例如資料庫) 存取的儲存區中儲存資料。函式在為純函式時,可以視為不可變的 [1]。某些行為可能會讓函式變為不純函式,包括變更 upvalue (如果它有 upvalue)、變更環境 (如果它使用環境變數),以及呼叫其他不純函式 (儲存在 upvalue 或環境變數中)。函式實作可以採取一些簡單的步驟來防止上述行為。

do
  local sqrt = math.sqrt -- make the environment variable "math.sqrt"
                         -- a lexical to ensure it is never redefined
  function makepoint(x,y)
    assert(type(x) == 'number' and type(y) == 'number')
    -- the upvalues x and y are only accessible inside this scope.
    return function()
      return x, y, sqrt(x^2 + y^2)
    end
  end
end
local x,y = 10,20
local p = makepoint(x,y)
print(p()) --> 10  20  22.360679774998
math.sqrt = math.cos  -- this has no effect
x,y = 50,60           -- this has no effect
print(p()) --> 10  20  22.360679774998

外部常式仍可能可以存取此類函式的環境變數和 upvalue。可以透過 setfenv [2] 來變更函式的環境。儘管函式 f 的實作可能沒有使用環境變數,這仍會影響函數外部所有 getfenv(f) 呼叫的結果,因此函式並非不可變的。其次,upvalue 其實可以透過 debug.setupvalue [3] 來修改,但除錯介面被視為後門。

請參閱 SimpleTuples,進一步說明如何使用函式表示不可變元組。

字串的不可變性

Lua 字串不可變且內部保存。這有一些含意 [4]。要建立只有一個字元不同於現有 100 MB 字串的字串,需要建立一個全新的 100 MB 字串,因為原始字串不可修改。

有許多使用自訂資料已實作出 (可變的) 字串緩衝區。

在 Lua C API 中,字串緩衝區 [5] 是可變的。

在執行時期模擬 Lua 中的上下文不可變性

以下是關於如何模擬 Lua 執行時期的上下文不可變性的快速範例: [2]

-- converts a mutable object to an immutable one (as a proxy)
local function const(obj)
  local mt = {}
  function mt.__index(proxy,k)
    local v = obj[k]
    if type(v) == 'table' then
      v = const(v)
    end
    return v
  end
  function mt.__newindex(proxy,k,v)
    error("object is constant", 2)
  end
  local tc = setmetatable({}, mt)
  return tc
end

-- reimplementation of ipairs,
-- from https://lua-users.dev.org.tw/wiki/GeneralizedPairsAndIpairs
local function _ipairs(t, var)
  var = var + 1
  local value = t[var]
  if value == nil then return end
  return var, value
end
local function ipairs(t) return _ipairs, t, 0 end


-- example usage:

local function test2(library)  -- example function
  print(library.books[1].title)
  library:print()

  -- these fail with "object is constant"
  -- library.address = 'brazil'
  -- library.books[1].author = 'someone'
  -- library:addbook {title='BP', author='larry'}
end

local function test(library)  -- example function
  library = const(library)

  test2(library)
end

-- define an object representing a library, as an example.
local library = {
  books = {}
}
function library:addbook(book)
  self.books[#self.books+1] = book
  -- warning: rawset ignores __newindex metamethod on const proxy.
  -- table.insert(self.books, book)
end
function library:print()
  for i,book in ipairs(self.books) do
    print(i, book.title, book.author)
  end
end

library:addbook {title='PiL', author='roberto'}
library:addbook {title='BLP', author='kurt'}

test(library)

關鍵代碼行為「library = const(library)」,它將可變參數轉換為不可變參數。const 會傳回包裝給定物件的代理物件,可讀取物件的欄位但不可寫入。它會提供物件的「檢視」(想像:資料庫)。

請注意遞迴呼叫 `v = const(v)` 提供某種類型的傳遞性給不可變性。

上述程式碼中註明了一些此方法的注意事項。原始物件的方法並未傳遞原始物件,反而是傳遞物件的代理。因此,這些方法必須避免原始取得和設定 (不會觸發 metamethod)。在 LuaFiveTwo 之前,我們也有 `pairs` / `ipairs`/ `#` (請參閱 GeneralizedPairsAndIpairs) 的問題。上述內容可以延伸以支援運算子重載。

上述內容會造成一些執行時期的負擔。不過,在實際生產中,你可以將 `const` 定義為身分函式,甚至從原始程式碼中移除這些函式。

請注意:上述是一種概念驗證,在實務上不建議一般用途。

在 Lua 中於編譯期間模擬上下文不變性

可能更需要在編譯期間 (靜態分析)執行常數正確性檢查,就像在 C/C++ 中。這方面的工具可以這樣撰寫,例如使用 MetaLua 的 gg/mlp、Leg 等 (請參閱 LuaGrammar),或許也可以使用 luac -p -l 技巧(例如參閱 DetectingUndefinedVariables)。這類程式碼「可能」長這樣

local function test2(library)
  print(library.books[1].title)
  library:print()

  -- these fail with "object is constant"
  -- library.address = 'brazil'
  -- library.books[1].author = 'someone'
  -- library:addbook {title='BP', author='larry'}
  library:print()
end

local function test(library)  --! const(library)
  test2(library)
end

local library = {
  books = {}
}
function library:addbook(book)
  table.insert(self.books[#self.books+1], book)
end
function library:print()
  for i,book in ipairs(self.books) do
    print(i, book.title, book.author)
  end
end

library:addbook {title='PiL', author='roberto'}
library:addbook {title='BLP', author='kurt'}

test(library)

上面特別格式化的註解(--!)表示靜態分析檢查工具,用於指出應將給定參數視為常數。這在實務上如何運作還不太清楚。顯然地,由於 Lua 本質上是動態的,這只能在受限的案例中執行(例如大量使用未修改的局部變數,並假設像 table.insert 那類全域變數會保留其慣用語意)。

表格中的執行期間常數檢查

在偵錯期間可以使用下列程式庫,以確保以「_c」為字尾的名稱的功能引數在執行功能呼叫期間都不會變更。

-- debugconstcheck.lua
-- D.Manura, 2012-02, public domain.
-- Lua 5.2. WARNING: not well tested; "proof-of-concept"; has big performance impact
-- May fail with coroutines.

local stack = {}
local depth = 0

-- Gets unique identifier for current state of Lua object `t`.
-- This implementation could be improved.
-- Currently it only does a shallow table traversal and ignores metatables.
-- It could represent state with a smaller hash (less memory).
-- Note: false negatives are not serious problems for purposes here.
local function checksum(t)
  if type(t) ~= 'table' then
    return ("(%q%q)"):format(tostring(type(t)), tostring(t))
  end
  local ts = {}
  for k, v in next, t do
    ts[#ts+1] = ('%q%q'):format(tostring(k), tostring(v))
  end
  return '("table"'..table.concat(ts)..')'
end

-- Checks function parameters on call/returns with a debug hook.
debug.sethook(function(op)
  if op ~= 'return' then

    depth = depth + 1
    local info = debug.getinfo(2, 'ufn')
    --print('+', depth, info.name, op)
    local nfound = 0
    for i=1, info.nparams do
      local name, val = debug.getlocal(2, i)
      if name:match('_c$') then -- record state of param on function entry
        stack[#stack+1] = val
        stack[#stack+1] = checksum(val)
        --print(name, stack[#stack])
        nfound = nfound + 1
      end
    end
    if nfound ~= 0 then stack[#stack+1] = nfound; stack[#stack+1] = depth end
  
  else -- 'call' or 'tail call'

    --print('-', depth, debug.getinfo(2, 'n').name)
    if depth == stack[#stack] then -- check state of params on function exit
      table.remove(stack)
      local count = table.remove(stack)
      for i=count,1,-1 do
        local sum = table.remove(stack)
        local val = table.remove(stack)
        local current_sum = checksum(val)
        --print('check', '\n'..sum, '\n'..current_sum)
        if sum ~= current_sum then error("const variable mutated") end
      end
    end
    depth = depth - 1
  
  end
end, 'cr')

return {}

範例(使用 lua -ldebugconstcheck example.lua 執行

-- example.lua

local function g(a,b,c)
  b.x=1 -- ok
  table.sort(a) -- bad
end

local function f(a_c, b, c_c)
  return g(a_c, b, c_c)
end

f({'b','a'}, {}, {})

結果

lua52: ./debugconstcheck.lua:46: const variable mutated
stack traceback:
	[C]: in function 'error'
	./debugconstcheck.lua:46: in function <./debugconstcheck.lua:17>
	example.lua:10: in main chunk
	[C]: in ?

另請參閱/參考


近期變更 · 喜好設定
編輯 · 歷程
最後編輯:2016 年 5 月 6 日晚上 9:52,格林威治標準時間 (差異)