表格的作用範圍

lua-users home
wiki

摘要

本文介紹運用各式各樣的解決方案,讓表格結構包覆作用範圍,使表格內的變數具有特殊意義。換言之,我們要做到以下這類事情

local obj = struct "foo" {
              int "bar";
              string "baz";
            }
assert(obj == "foo[int[bar],string[baz]]")
assert(string.lower("ABC") == "abc")
assert(int == nil)

問題

Lua 能順利完成資料定義(如 Lua 程式設計中所述)

local o = rectangle { point(0,0), point(3,4) }

-- another example:
html{ h1"This is a header", p"This is a paragraph",
      p{"This is a ", em"styled", " paragraph"} }

-- another example:
struct "name" {
  int    "bar";
  string "baz";
}

Lua 語法很友善,語義卻不盡然如此:字串是很重要的全域變數,範圍位於 struct 之外,即使它只應存在於 struct 內部才有意義。這可能會造成問題。例如,上述程式要求重新定義標準表格 string

若強制設定前置詞會解決問題,但可能會很麻煩又難看,因為資料定義試圖描述的問題領域中沒有這個前置詞

local struct = require "struct"
...
local o = struct "name" {
            struct.int  "bar";
            struct.string "baz";
          }

我們可以透過區域變數限制範圍,但定義變數也可能很麻煩,特別是如果資料定義語言包含上百個標籤時

local struct = require "struct"
...
local o; do
  local int = struct.int
  local string = struct.string
  o = struct "name" {
        int  "bar";
        string "baz";
      }
end

事實上,我們可能想讓某些字詞根據巢狀背景具有不同的意義

action { point { at = location { point(3,4) } }

因應之道可能有

struct "name" {
  int = "bar";
  string = "baz";
}

此時 intstring 會變成字串,而不是全域變數。但是,這樣會遺失引數的順序和重複次數(這在結構中很重要的)。另一種方法是

struct "name" .
  int "bar" .
  string "baz" .
endstruct

語義上來說,這樣比較好(但不可忘記 endstruct),但點語法有點不同尋常。在語義上,我們可能想要類似於 S-表達式

struct {
  "name",
  {"int", "bar"},
  {"string", "baz"}
}

但語法上來說,這個方法不完整。

解決方案:Lua 的表格作用範圍修正程式

有一個「表格作用範圍」修正程式允許執行這種類型的運算

local function struct(name)
  local scope = {
    int = function(name) return "int[" .. name .. "]" end;
    string = function(name) return "string[" .. name .. "]" end
  }
  return setmetatable({}, {
    __scope = scope;
    __call = function(_, t)
               return name .. "[" .. table.concat(t, ",") .. "]"
             end
  })
end

local obj = struct "foo" {
              int "bar";
              string "baz";
            }
assert(obj == "foo[int[bar],string[baz]]")
assert(string.lower("ABC") == "abc")
assert(int == nil)
print "DONE"

修正程式下載:[tablescope.patch](適用於 Lua 5.1.3)

這項修正程式不會變更 Lua 語法或 Lua 位元組碼。唯一的變更就是新增支援新的後設方法 __scope。如果表格結構用作函數呼叫的最後一個引數,而且被呼叫的物件包含一個 __scope 後設方法,且該後設方法是一個表格,那麼表格結構中提到的全域變數會首先在 __scope 中搜尋。只有在 __scope 中找不到時,才會像往常一樣在環境表格中搜尋變數。

此修正程式會根據位元組碼的順序來進行一些假設,以便推論表格是如何巢狀存取全域變數的。如果位元組碼不是由 luac 編譯(例如,由 MetaLua 編譯),則有可能不會符合這些假設。然而,在某個函數中不符合這些假設的結果通常不會將表格作用範圍套用至該函數,不過可能會發生非常罕見的情況,對安全性造成影響。

可能有方法可以透過使用此修正程式來降低對全域存取的效能影響。歡迎提供建議。例如,表格作用範圍搜尋可以在特定函數中選擇性啟用或停用。如果你擔心效能,你應該改用局部變數。

MetaLua 會產生與 luac 完全相同的位元組碼,除非你使用 Goto 或 Stat。此外,如果你使用 MetaLua,你也可以使用它來處理表格範圍,而非修補 Lua -- FabienFleutot

解決方案:全域環境技巧

避免修補程式,我們可以用 Lua 環境表格執行一些技巧。可以使用下列模式(原始概念由 RiciLake 建議)

-- shapes.lua
local M = {}

local Rectangle = {
  __tostring = function(self)
    return string.format("rectangle[%s,%s]",
      tostring(self[1]), tostring(self[2]))
  end
}
local Point = {
  __tostring = function(self)
    return string.format("point[%f,%f]",
      tostring(self[1]), tostring(self[2]))
  end
}

function M.point(x,y)
  return setmetatable({x,y}, Point)
end
function M.rectangle(t)
  local point1 = assert(t[1])
  local point2 = assert(t[2])
  return setmetatable({point1, point2}, Rectangle)
end

return M

-- shapes_test.lua

-- with: namespace, [level], [filter] --> (lambda: ... --> ...)
function with(namespace, level, filter)
  level = level or 1; level = level + 1

  -- Handle __with metamethod if defined.
  local mt = getmetatable(namespace)
  if type(mt) == "table" then
    local custom_with = mt.__with
    if custom_with then
      return custom_with(namespace, level, filter)
    end
  end

  local old_env = getfenv(level)  -- Save

  -- Create local environment.
  local env = {}
  setmetatable(env, {
    __index = function(env, k)
      local v = namespace[k]; if v == nil then v = old_env[k] end
      return v
    end
  })
  setfenv(level, env)

  return function(...)
    setfenv(2, old_env)       -- Restore
    if filter then return filter(...) end
    return ...
  end
end

local shapes = require "shapes"

local o = with(shapes) (
  rectangle { point(0,0), point(3,4) }
)
assert(not rectangle and not point) -- note: not visible here
print(o)
-- outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

重點在於 with 函式,它提供區域存取權給指定的名稱空間。它的目的類似於其他語言(例如 VB)中的「with」子句,且與 C++ 中的 using namespace 或 Java 中的 import static[1] 有些關聯。它也可能類似於 XML 名稱空間。

下列特殊狀況會正確輸出相同的結果

point = 2
function calc(x) return x * point end
local function calc2(x) return x/2 end
local o = with(shapes) ( rectangle { point(0,0), point(calc2(6),calc(2)) } )
print(o)
-- outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

with 的選用參數在定義包裝器以簡化語句時可能很有用

function shape_context(level)
  return with(shapes, (level or 1)+1, function(x) return x[1] end)
end

local o = shape_context() {
  rectangle { point(0,0), point(3,4) }
}
print(o)
-- outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

自動呼叫 with 在存取全域金鑰時,可以進一步簡化

setmetatable(_G, {
  __index = function(t, k)
    if k == "rectangle" then
       return with(shapes, 2, function(...) return shapes.rectangle(...) end)
    end
  end
})

local o = rectangle { point(0,0), point(3,4) }
print(o)
-- outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

一個注意事項是,這種方法依賴於未記錄的 Lua 行為。函式名稱 with 必須在對函式參數進行解析前解析,這是 Lua 5.1 目前版本的行為。

此外,儘管希望 with 提供類型化詞法範圍,且它模擬了不錯的結果,但實作其實更動態。由於實際詞法(區域)會覆寫全域變數,所以下列會導致執行時間錯誤

local point = 123
local o = with(shapes) ( rectangle { point(0,0), point(3,4) } )

此外,我們假設可以暫時更換呼叫者的環境而不造成不良影響。

遺漏最後一個呼叫(意外地)不一定要導致錯誤,但會讓環境改變

local o = with(shapes)
assert(rectangle) -- opps! rectangle is visible now.

但那確實表示可以使用此用法(雖然不見得是個好主意)

local f = with(shapes) -- begin scope
local o1 = rectangle { point(0,0), point(3,4) }
local o1 = rectangle { point(0,0), point(5,6) }
f() -- end scope
assert(not rectangle) -- rectangle no longer visible

另一個問題是,如果有在評估參數時有例外狀況,環境將不會還原

local last = nil
function test()
  last = rectangle
  local o = with(shapes) ( rectangle { point(3,4) } ) -- raises error
end
assert(not pcall(test))
assert(not last)
assert(not pcall(test))
assert(last)  -- opps! environment not restored

很不幸的是,似乎沒有任何不顯眼的辦法可以使用 pcall 包裝參數。我們可以使用新的 pwith 函式來執行此動作,它接受以笨拙的 function() return ... end 語法包裝的資料,供 pcall 在稍後評估

local o = pwith(shapes)(function() return
  rectangle { point(3,4) } -- raises error
end)

事實上,此方法可以避免依賴於未記錄行為,遺漏第二次呼叫的危險,以及觸及呼叫者環境的危險,如上述所討論的。我們只需要符合語法即可(請參閱上述的「全域收集器」模式,它套用類似的語法和語意)。

另一個變形是事後呼叫 pwith(例如在組態檔案之外)

rect = function() return rectangle { point(3,4) } end
...
pwith(shapes)(rec)

或者 pwith 可能可以在 _G 上的 __newindex metamethod 事件觸發後執行。

非 pcall 形式可能沒問題。只要注意到它的使用條款:如果它引發例外,請拋棄呼叫它的函數。對於設定語言來說,這可能是個好方法。

不過要注意的是,上述方法並不適用於部分 DetectingUndefinedVariables 方法。rectanglepoint 可能被辨識為未定義變數,特別是在對於未定義全域變數的靜態檢查之下(這些是在頂層腳本中未定義的全域/環境變數)。

如果我們不需要特別存取 upvalue,我們可以將上述資料函數轉換為字串(詳見 ShortAnonymousFunctions 詳細資訊中的「字串化匿名函數」模式)

local o = pwith(shapes)[[
  rectangle { point(x,y) }
]]{x = 3, y = 4}

我們喪失對呼叫程式中詞彙的直接存取權限,但 pwith 可以將 local 變數加到資料字串之前,讓 rectanglepoint(以及 xy)變成詞彙。如果未達到最大詞彙限制的話,pwith 可以這樣實作

local code = [[
  local rectangle, point, x, y = ...
]] .. datasttring
local f = loadstring(code)(namespace.rectangle, namespace.point, x, y)

解決方案:使用 Metalua

以下是 Metalua 中的實作方式

-- with.lua

function with_expr_builder(t)
  local namespace, value = t[1], t[2]
  local tmp = mlp.gensym()
  local code = +{block:
    local namespace = -{namespace}
    local old_env = getfenv(1)
    local env = setmetatable({}, {
      __index = function(t,k)
        local v = namespace[k]; if v == nil then v = old_env[k] end
        return v
      end
    })
    local -{tmp}
    local f = setfenv((|| -{value}), env)
    local function helper(success, ...)
      return {n=select('#',...), success=success, ...}
    end
    let -{tmp} = helper(pcall(f))
    if not -{tmp}.success then error(-{tmp}[1]) end
  }
  -- NOTE: Stat seems to only support returning a single value.
  --       Multiple return values are ignored (even though attempted here)
  return `Stat{code, +{unpack(-{tmp}, 1, -{tmp}.n)}}
end

function with_stat_builder(t)
  local namespace, block = t[1], t[2]
  local tmp = mlp.gensym()
  local code = +{block:
    local namespace = -{namespace}
    local old_env = getfenv(1)
    local env = setmetatable({}, {
      __index = function(t,k)
        local v = namespace[k]; if v == nil then v = old_env[k] end
        return v
      end
    })
    local -{tmp}
    local f = setfenv(function() -{block} end, env)
    local success, msg = pcall(f)
    if not success then error(msg) end
  }
  return code
end

mlp.lexer.register { "with", "|" }

mlp.expr.primary.add {
  "with", "|", mlp.expr, "|", mlp.expr,
  builder=with_expr_builder
}

mlp.stat.add {
  "with", mlp.expr, "do", mlp.block, "end",
  builder=with_stat_builder
}

使用範例

-{ dofile "with.luac" }

local shapes = require "shapes"
rectangle = 123  -- no problem

local o = with |shapes|
          rectangle { point(0,0), point(3,4) }
print(o)
--outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

local o
with shapes do
  o = rectangle { point(0,0), point(3,4) }
end
print(o)
--outputs: rectangle[point[0.000000,0.000000],point[3.000000,4.000000]]

local other = {double = function(x) return 2*x end}

local o = with |other|
          with |shapes|
          rectangle { point(0,0), point(3,double(4)) }
print(o)
--outputs: rectangle[point[0.000000,0.000000],point[3.000000,8.000000]]

local o
local success, msg = pcall(function()
  o = with |shapes| rectangle { point(0,0) }
end)
assert(not success)
assert(rectangle == 123) -- original environment

更新 (2010-06):上面的 setfenv 可避免使用,也會在 Lua 5.2.0-work3 中必要地避免(見下文)。

Lua 5.2

Lua-5.2.0-work3 移除了 setfenv(不過保留了 debug 函式庫中對它的部分支援)。這使得上面許多「環境技巧」技術無效,雖然這些技術本來就不怎麼好。

在 Lua-5.2.0-work3 中,_ENV 會允許

local o = (function(_ENV) return rectangle { point(0,0), point(3,4) } end)(shapes)

這具備與上述 5.1 解決方案「pwith(shapes)(function() return ..... end)」類似的品質,但是不需要 pcall。上述額外函數只用來在表達式的中間新增詞彙(_ENV),不過如果我們在另一個陳述式中定義 _ENV 的話,我們就可以移除這個函數。

local o; do local _ENV = shapes
  o = rectangle { point(0,0), point(3,4) }
end

語法上來說可能比較不混亂,但其實還好,除非我們需要大量切換環境(例如個別在 shape、rectangle 和 point 各自的環境中評估引數)。


--DavidManura,2007/2008

另請參閱


RecentChanges · 偏好設定
編輯 · 歷程
上次編輯為 2010 年 6 月 15 日凌晨 3:11(格林威治標準時間)(diff)