資源取得實作初始化

lua-users home
wiki

此頁面將說明如何於 Lua 中達成資源取得實作初始化 (RAII) [1] 的效果。RAII 是一個非常有用的範例,Lua 5.1 中並未直接支援,不過有方法可以近似處理。一些討論與提議的解決方案見於 Lua 清單

問題

非常適合於 RAII 的典型問題如下

function dostuff()
  local f = assert(io.open("out", "w"))
  domorestuff()  -- this may raise an error
  f:close() -- this is not called if that error was raised
end

dostuff()

如果會引發錯誤,檔案就不會立刻關閉 (RAII 本來會這麼做)。是的,垃圾收集器最後會關閉檔案,但我們不知道什麼時候。程式的成功或正確性可能取決於是否立即釋放檔案鎖定。不過,這時在 pcall 外呼叫 collectgarbage('collect') 可能會有幫助,在這種情況下,Lua 會呼叫 __gc (終結器) 元方法來關閉檔案,不過你可能必須呼叫 collectgarbage 超過一次 [*1]。此外,Lua 不允許以純 Lua 實作的物件 (沒有 C 使用者資料的協助) 定義它們自己的 __gc 元方法。

透過維持可毀損物件堆疊來模擬 RAII

以下是在純 Lua 中的一種做法,它會維持需要回收的所有物件的堆疊。在範圍結束或處理例外狀況時,會從堆疊中移除要回收的物件並使其完成 (也就是呼叫 close,如果存在;否則將其作為函式呼叫),以釋放它們的資源。

-- raii.lua
local M = {}

local frame_marker = {} -- unique value delimiting stack frames

local running = coroutine.running

-- Close current stack frame for RAII, releasing all objects.
local function close_frame(stack, e)
  assert(#stack ~= 0, 'RAII stack empty')
  for i=#stack,1,-1 do  -- release in reverse order of acquire
    local v; v, stack[i] = stack[i], nil
    if v == frame_marker then
      break
    else
      -- note: assume finalizer never raises error
      if type(v) == "table" and v.close then
        v:close()
      else
        v(e)
      end
    end
  end
end

local function helper1(stack, ...) close_frame(stack); return ... end
-- Allow self to be used as a function modifier
-- to add RAII support to function.
function M.__call(self, f)
  return function(...)
    local stack, co = self, running()
    if co then  -- each coroutine gets its own stack
      stack = self[co]
      if not stack then
        stack = {}
        self[co] = stack
      end
    end
    stack[#stack+1] = frame_marker -- new frame
    return helper1(stack, f(...))
  end
end

-- Show variables in all stack frames.
function M.__tostring(self)
  local stack, co = self, running()
  if co then stack = stack[co] end
  local ss = {}
  local level = 0
  for i,val in ipairs(stack) do
    if val == frame_marker then
      level = level + 1
    else
      ss[#ss+1] = string.format('[%s][%d] %s', tostring(co), level, tostring(val))
    end
  end
  return table.concat(ss, '\n')
end

local function helper2(stack, level, ok, ...)
  local e; if not ok then e = select(1, ...) end
  while #stack > level do close_frame(stack, e) end
  return ...
end

-- Construct new RAII stack set.
function M.new()
  local self = setmetatable({}, M)

  -- Register new resource(s), preserving order of registration.
  function self.scoped(...)
    local stack, co = self, running()
    if co then stack = stack[co] end
    for n=1,select('#', ...) do
      stack[#stack+1] = select(n, ...)
    end
    return ...
  end

  -- a variant of pcall
  -- that ensures the RAII stack is unwound.
  function self.pcall(f, ...)
    local stack, co = self, running()
    if co then stack = stack[co] end
    local level = #stack
    return helper2(stack, level, pcall(f, ...))
  end

  -- Note: it's somewhat convenient having scoped and pcall be
  -- closures....  local scoped = raii.scoped

  return self
end

-- singleton.
local raii = M.new()

return raii

範例用法

local raii = require "raii"
local scoped, pcall = raii.scoped, raii.pcall

-- Define some resource type for testing.
-- In practice, this is a resource we acquire and
-- release (e.g. a file, database handle, Win32 handle, etc.).
local Resource = {}; do
  Resource.__index = Resource
  function Resource:__tostring() return self.name end
  function Resource.open(name)
    local self = setmetatable({name=name}, Resource)
    print("open", name)
    return self
  end
  function Resource:close() print("close", self.name) end
  function Resource:foo()   print("hello", self.name) end
end

local test3 = raii(function()
  local f = scoped(Resource.open('D'))
  f:foo()
  print(raii)
  error("opps")
end)

local test2 = raii(function()
  scoped(function(e) print("leaving", e) end)
  local f = scoped(Resource.open('C'))
  test3(st)
end)

local test1 = raii(function()
  local g1 = scoped(Resource.open('A'))
  local g2 = scoped(Resource.open('B'))
  print(pcall(test2))
end)

test1()


--[[ OUTPUT:
open    A
open    B
open    C
open    D
hello   D
[nil][1] A
[nil][1] B
[nil][2] function: 0x68a818
[nil][2] C
[nil][3] D
close   D
close   C
leaving complex2.lua:23: opps
complex2.lua:23: opps
close   B
close   A
]]

使用 coroutine 的範例

local raii = require "raii"
local scoped, pcall = raii.scoped, raii.pcall

-- Define some resource type for testing.
-- In practice, this is a resource we acquire and
-- release (e.g. a file, database handle, Win32 handle, etc.).
local Resource = {}; do
  Resource.__index = Resource
  local running = coroutine.running
  function Resource:__tostring() return self.name end
  function Resource.open(name)
    local self = setmetatable({name=name}, Resource)
    print(running(), "open", self.name)
    return self
  end
  function Resource:close() print(running(), "close", self.name) end
  function Resource:foo()   print(running(), "hello", self.name) end
end

local test3 = raii(function(n)
  local f = scoped(Resource.open('D' .. n))
  f:foo()
  print(raii)
  error("opps")
end)

local test2 = raii(function(n)
  scoped(function(e) print(coroutine.running(), "leaving", e) end)
  local f = scoped(Resource.open('C' .. n))
  test3(n)
end)

local test1 = raii(function(n)
  local g1 = scoped(Resource.open('A' .. n))
  coroutine.yield()
  local g2 = scoped(Resource.open('B' .. n))
  coroutine.yield()
  print(coroutine.running(), pcall(test2, n))
  coroutine.yield()
end)

local cos = {coroutine.create(test1), coroutine.create(test1)}
while true do
  local is_done = true
  for n=1,#cos do
    if coroutine.status(cos[n]) ~= "dead" then
      coroutine.resume(cos[n], n)
      is_done = false
    end
  end
  if is_done then break end
end
-- Note: all coroutines must terminate for RAII to work.

--[[ OUTPUT:
thread: 0x68a7f0        open    A1
thread: 0x68ac10        open    A2
thread: 0x68a7f0        open    B1
thread: 0x68ac10        open    B2
thread: 0x68a7f0        open    C1
thread: 0x68a7f0        open    D1
thread: 0x68a7f0        hello   D1
[thread: 0x68a7f0][1] A1
[thread: 0x68a7f0][1] B1
[thread: 0x68a7f0][2] function: 0x68ada0
[thread: 0x68a7f0][2] C1
[thread: 0x68a7f0][3] D1
thread: 0x68a7f0        close   D1
thread: 0x68a7f0        close   C1
thread: 0x68a7f0        leaving complex3.lua:24: opps
thread: 0x68a7f0        complex3.lua:24: opps
thread: 0x68ac10        open    C2
thread: 0x68ac10        open    D2
thread: 0x68ac10        hello   D2
[thread: 0x68ac10][1] A2
[thread: 0x68ac10][1] B2
[thread: 0x68ac10][2] function: 0x684258
[thread: 0x68ac10][2] C2
[thread: 0x68ac10][3] D2
thread: 0x68ac10        close   D2
thread: 0x68ac10        close   C2
thread: 0x68ac10        leaving complex3.lua:24: opps
thread: 0x68ac10        complex3.lua:24: opps
thread: 0x68a7f0        close   B1
thread: 0x68a7f0        close   A1
thread: 0x68ac10        close   B2
thread: 0x68ac10        close   A2
]]

--DavidManura

範圍管理員

JohnBelmonteLuaList:2007-05/msg00354.html [*2] 中建議實作類似於 D 語言 scope 保護陳述 [3][4] 結構的某種東西。想法是變數類別 (例如 local) 名為 scoped,它在提供函式 (或可呼叫表格) 時,它會在範圍結束時呼叫它

function test()
  local fh = io:open()
  scoped function() fh:close() end
  foo()
end

這可以在純 Lua 中實作。這在 Lua 編程珍寶中 Gem #13 「Lua 中的例外情況」 [5] 中有說明,以允許類似以下內容

function dostuff()
  scope(function()
    local fh1 = assert(io.open('file1'))
    on_exit(function() fh1:close() end)
    ...
    local fh2 = assert(io.open('file2'))
    on_exit(function() fh2:close() end)
    ...
  end)
end

範圍保護陳述的可能語法延伸

這需要建立一個匿名函式,不過這麼做的話從效率角度來看會有好處。

以下是另一種非常基本的構想 («finally ... end» 結構)

function load(filename)
   local h = io.open (filename)
   finally if h then h:close() end end
   ...
end

請注意 scope 建構函式在 D 中的實作在句法上類似於 if 陳述式,在作用域結束時執行。也就是說,假設我們將 exitsuccessfailure 視為真實的條件式;事實上,不妨做這樣的概化。我為 Lua 提出下列語法擴充

stat :: scopeif exp then block {elseif exp then block} [else block] end

其中 err 是隱式變數(例如 self),可在 expblock 中使用,代表引發的錯誤,如果未引發錯誤,則為 nil。(註解:在幾個月後重訪該語法時,我發現語意不太直覺,特別是 err 的特殊用法。)

「例外安全程式設計」中的範例 [3] 可轉譯為 Lua 語法如下

function abc()
  local f = dofoo();
  scopeif err then dofoo_undo(f) end

  local b = dobar();
  scopeif err then dobar_undo(b) end

  local d = dodef();

  return Transaction(f, b, d)
end
-----
function bar()
  local verbose_save = verbose
  verbose = false
  scopeif true then verbose = verbose_save end

  ...lots of code...
end
-----
function send(msg)
  do
    local origTitle = msg.Title()
    scopeif true then msg.SetTitle(origTitle) end
    msg.SetTitle("[Sending] " .. origTitle)
    Copy(msg, "Sent")
  end
  scopeif err then
    Remove(msg.ID(), "Sent")
  else
    SetTitle(msg.ID(), "Sent", msg.Title)
  end
  SmtpSend(msg)	-- do the least reliable part last
end

scopeif true then ... end 有點冗長,但與 while true do ... end 類似。使用 scopeif 而不是 scope if 遵循 elseif 的模式。

JohnBelmonte的資料庫範例縮短為

function Database:commit()
  for attempt = 1, MAX_TRIES do
    scopeif instance_of(err, DatabaseConflictError) then
      if attempt < MAX_TRIES then
        log('Database conflict (attempt '..attempt..')')
      else
        error('Commit failed after '..attempt..' tries.')
      end
    end -- note: else no-op
    self.commit()
    return
  end
end

以下是如何模擬一般的 RAII(D 文章並未說明 RAII 永遠沒有用)

function test()
  local resource = Resource(); scope if true then resource:close() end
  foo()
end

但是,這個比建議的更冗長

function test()
  scoped resource = Resource()
  foo()
end

也許可以在 Metalua [6] 中建立此原型。

--DavidManura

try/finally/作用域防護程式修補程式

已發布幾個 Lua 修補程式來處理這種類型的問題

(2008-01-31) 修補程式:由 Hu Qiwei 發布,提供try/catch/finally 支援 [10][11][12]try 區塊禁止使用 returnbreak

(2008-01-07) 修補程式:由 Nodir Temirhodzhaev 發布,完成物件化[13] 這是上述「try/catch/finally」例外處理機制的替代方案,與 [3]ResourceAcquisitionIsInitialization 相關。

2009-02-14 更新: LuaList:2009-02/msg00258.html ; LuaList:2009-03/msg00418.html

(2008-02-12) 修補程式:由 Alex Mania 發布,實驗性的 finalize/guard [14] 支援 RAII 的 finalizeguard 區塊。

LuaList:2009-03/msg00418.html

with 陳述式 (Metalua、Python)

MetaLua 0.4 提供名為「withdo」的 RAII 擴充。適用於透過呼叫方法 :close() 釋放的所有資源。防護受保護區塊的正常終止、區塊內部的傳回,以及錯誤。以下會在關閉檔案 filename1 和 filename2 的處理常式之後,回傳這兩個檔案大小的總和

with h1, h2 = io.open 'filename1', io.open 'filename2' do
   local total = #h1:read'*a' + #h2:read'*a'
   return total
end

請注意,Metalua 的設計受到資源物件具備特定方法(在這裡是「close()」)的要求所限制。在 Python 中它被拒絕而採用「with ... as ...」語法,允許資源管理物件與資源本身分開 [7]。此外,Python 陳述允許省略指派,因為在許多情況下不需要資源變數--例如如果你只是想在區塊中鎖定一個物件。

其他說明

[*1]視用戶資料如何相互交雜,它可能為兩次以上,但如果用戶資料是某個其他物件的最後參照,且該物件本身是/參照一個用戶資料,則確定需要兩次收集才能移除具有 __gc 元資料的用戶資料,然後循環會繼續。(由 RiciLake 指出)

[*2] RAII 模式會遇到需要建立臨時類別來管理資源,以及取得順序資源時所需的笨拙巢狀結構的問題。請參閱 http://www.digitalmars.com/d/exception-safe.html。更好的模式在 Lua 範例 #13「Lua 中的例外狀況」中 [5]。 --JohnBelmonte

以下是我提議的範例程式碼,說明 Google Go defer 和 D scope(exit) 的語法。--DavidManura

-- Lua 5.1, example without exceptions
local function readfile(filename)
  local fh, err = io.open(filename)
  if not fh then return false, err end
  local data, err = fh:read'*a'
  -- note: in this case, the two fh:close()'s may be moved here, but in general that is not possible
  if not data then fh:close(); return false, err end
  fh:close()
  return data
end
-- Lua 5.1, example with exceptions, under suitable definitions of given functions.
local function readfile(filename)
  return scoped(function(onexit)  -- based on pcall
    local fh = assert(io.open(filename)); onexit(function() fh:close() end)
    return assert(fh:read'*a')
  end)
end
-- proposal, example without exceptions
local function readfile(filename)
  local fh, err = io.open(filename); if not fh then return false, err end
  defer fh:close()
  local data, err = fh:read'*a'; if not data then return false, err end
  return data
end
  -- note: "local val, err = io.open(filename); if not val then return false, err end" is a common
  -- pattern and perhaps warrants a syntax like "local val = returnunless io.open(filename)".
-- proposal, example with exceptions
local function readfile(filename)
  local fh = assert(io.open(filename)); defer fh:close()
  return assert(fh:read'*a')
end
-- proposal, example catching exceptions
do
  defer if class(err) == 'FileError' then
    print(err)
    err:suppress()
  end
  print(readfile("test.txt"))
end

-- alternate proposal - cleanup code by metamechanism
local function readfile(filename)
  scoped fh = assert(io.open(filename)) -- note: fh:close() or getmetatable(fh).__close(fh) called on scope exit
  return assert(fh:read'*a')
end

另請參閱


最近變更 · 喜好設定
編輯 · 歷史記錄
上次編輯時間為 2012 年 3 月 13 日星期二下午 4:36 (UTC) (差異)