函式鍊包覆器

lua-users home
wiki

有時候我們希望加入客製化的函式至內建類型,像是字串或函式,特別是在使用函式鍊的時候 [1][2]

("  test  "):trim():repeatchars(2):upper() --> "TTEESSTT"

(function(x,y) return x*y end):curry(2) --> (function(y) return 2*y end)

我們可以使用偵錯函式庫(debug.setmetatable[5] 來做到這件事。缺點是每個內建類型只有一個通用的 metatable。修改這個 metatable 會造成全域性的副作用,這可能是程式中獨立維護的模組之間發生衝突的潛在問題。出於這個原因,偵錯函式庫的函式通常不建議在常規程式碼中使用。許多人避免將程式注入至這些全域性的 metatable,但也有人認為這種方式太方便而無法避免 [3][6][ExtensionProposal]。甚至有些人會詢問為什麼內建類型物件沒有自己的 metatable [7]

...
debug.setmetatable("", string_mt)
debug.setmetatable(function()end, function_mt)

我們可以只使用獨立的函式

(repeatchars(trim("test"), 2)):upper()

curry(function(x,y) return x*y end, 2)

這是最簡單的解法。簡單的解法通常是好的。但是,有些操作以函式呼叫的方式進行,而有些操作以獨立的全域函式的形式進行,加上重新排序的結果,可能會導致一定程度的不協調。

避免影響全域 metatable 的其中一種解法是將物件包覆在我們自己的類別中,在函式呼叫鍊中對包覆器執行操作,然後解開包覆的物件。

範例如下所示

S"  test  ":trim():repeatchars(2):upper()() --> TTEESSTT

S"  TEST  ":trim():lower():find('e')() --> 2 2

S 函式將指定的物件包覆成包覆器物件。在包覆器物件上進行的函式呼叫鍊會就地對包覆的物件進行操作。最後,使用函式呼叫 () 解開包覆器物件。

對於會傳回單一值的函式,還有一種解開方式是使用一元減號

-S"  test  ":trim():repeatchars(2):upper() --> TTEESSTT

要根據字串函式 stringx 表格定義 S,可以使用以下程式碼

local stringx = {}
for k,v in pairs(string) do stringx[k] = v end
function stringx.trim(self)
  return self:match('^%s*(%S*)%s*$')
end
function stringx.repeatchars(self, n)
  local ts = {}
  for i=1,#self do
    local c = self:sub(i,i)
    for i=1,n do ts[#ts+1] = c end
  end
  return table.concat(ts)
end

local S = buildchainwrapbuilder(stringx)

buildchainwrapbuilder 函式很普遍,它會實作我們的設計模式

-- (c) 2009 David Manura. Licensed under the same terms as Lua (MIT license).
-- version 20090430
local select = select
local setmetatable = setmetatable
local unpack = unpack
local rawget = rawget

-- https://lua-users.dev.org.tw/wiki/CodeGeneration
local function memoize(func)
  return setmetatable({}, {
    __index = function(self, k) local v = func(k); self[k] = v; return v end,
    __call = function(self, k) return self[k] end
  })
end

-- unique IDs (avoid name clashes with wrapped object)
local N = {}
local VALS = memoize(function() return {} end)
local VAL = VALS[1]
local PREV = {}

local function mypack(ow, ...)
  local n = select('#', ...)
  for i=1,n do ow[VALS[i]] = select(i, ...) end
  for i=n+1,ow[N] do ow[VALS[i]] = nil end
  ow[N] = n
end

local function myunpack(ow, i)
  i = i or 1
  if i <= ow[N] then
    return rawget(ow, VALS[i]), myunpack(ow, i+1)
  end
end

local function buildchainwrapbuilder(t)
  local mt = {}
  function mt:__index(k)
    local val = rawget(self, VAL)
    self[PREV] = val -- store in case of method call
    mypack(self, t[k])
    return self
  end
  function mt:__call(...)
    if (...) == self then -- method call
      local val = rawget(self, VAL)
      local prev = rawget(self, PREV)
      self[PREV] = nil
      mypack(self, val(prev, select(2,...)))
      return self
    else
      return myunpack(self, 1, self[N])
    end
  end
  function mt:__unm() return rawget(self, VAL) end

  local function build(o)
    return setmetatable({[VAL]=o,[N]=1}, mt)
  end
  return build
end

local function chainwrap(o, t)
  return buildchainwrapbuilder(t)(o)
end

測試套件

-- simple examples
assert(-S"AA":lower() == "aa")
assert(-S"AB":lower():reverse() == "ba")
assert(-S"  test  ":trim():repeatchars(2):upper() == "TTEESSTT")
assert(S"  test  ":trim():repeatchars(2):upper()() == "TTEESSTT")

-- basics
assert(S""() == "")
assert(S"a"() == "a")
assert(-S"a" == "a")
assert(S(nil)() == nil)
assert(S"a":byte()() == 97)
local a,b,c = S"TEST":lower():find('e')()
assert(a==2 and b==2 and c==nil)
assert(-S"TEST":lower():find('e') == 2)

-- potentially tricky cases
assert(S"".__index() == nil)
assert(S"".__call() == nil)
assert(S""[1]() == nil)
stringx[1] = 'c'
assert(S"a"[1]() == 'c')
assert(S"a"[1]:upper()() == 'C')
stringx[1] = 'd'
assert(S"a"[1]() == 'd') -- uncached
assert(S"a".lower() == string.lower)

-- improve error messages?
--assert(S(nil):z() == nil)

print 'DONE'

上述實作具有以下特質和假設

我們可以有其他方法來表示鏈結

S{"  test  ", "trim", {"repeatchars",2}, "upper"}

S("  test  ", "trim | repeatchars(2) | upper")

不過這樣看來比較不傳統。(注意:最後一行的第二個引數是 point-free [4]。)

Method Chaining Wrapper Take #2 – 物件在鏈結的尾端

我們也可以像這樣表達呼叫鏈

chain(stringx):trim():repeatchars(5):upper()('  test   ')

其中操作的物件會放在最尾端。這樣可以降低忘記 unpack 的機會,並允許分離和重複使用

f = chain(stringx):trim():repeatchars(5):upper()
print ( f('  test  ') )
print ( f('  again  ') )

有很多方法可以實作這個(函式、CodeGeneration 和 VM)。這裡採用後者方法。

-- method call chaining, take #2
-- (c) 2009 David Manura. Licensed under the same terms as Lua (MIT license).
-- version 20090501

-- unique IDs to avoid name conflict
local OPS = {}
local INDEX = {}
local METHOD = {}

-- table insert, allowing trailing nils
local function myinsert(t, v)
  local n = t.n + 1; t.n = n
  t[n] = v
end

local function eval(ops, x)
  --print('DEBUG:', unpack(ops,1,ops.n))
  local t = ops.t

  local self = x
  local prev
  local n = ops.n
  local i=1; while i <= n do
    if ops[i] == INDEX then
      local k = ops[i+1]
      prev = x  -- save in case of method call
      x = t[k]
      i = i + 2
    elseif ops[i] == METHOD then
      local narg = ops[i+1]
      x = x(prev, unpack(ops, i+2, i+1+narg))
      i = i + 2 + narg
    else
      assert(false)
    end
  end
  return x
end

local mt = {}
function mt:__index(k)
  local ops = self[OPS]
  myinsert(ops, INDEX)
  myinsert(ops, k)
  return self
end

function mt:__call(x, ...)
  local ops = self[OPS]
  if x == self then -- method call
    myinsert(ops, METHOD)
    local n = select('#', ...)
    myinsert(ops, n)
    for i=1,n do
      myinsert(ops, (select(i, ...)))
    end
    return self
  else
    return eval(ops, x)
  end
end

local function chain(t)
  return setmetatable({[OPS]={n=0,t=t}}, mt)
end

初級測試程式碼

local stringx = {}
for k,v in pairs(string) do stringx[k] = v end
function stringx.trim(self)
  return self:match('^%s*(%S*)%s*$')
end
function stringx.repeatchars(self, n)
  local ts = {}
  for i=1,#self do
    local c = self:sub(i,i)
    for i=1,n do ts[#ts+1] = c end
  end
  return table.concat(ts)
end

local C = chain
assert(C(stringx):trim():repeatchars(2):upper()("  test  ") == 'TTEESSTT')
local f = C(stringx):trim():repeatchars(2):upper()
assert(f"  test  " == 'TTEESSTT')
assert(f"  again  " == 'AAGGAAIINN')
print 'DONE'

Method Chaining Wrapper Take #3 – 使用有範圍感知元表的詞法注入

另一種想法是修改字串元表,讓字串方法的延伸只在詞法範圍內可見。下列並不完美(例如:巢狀函式),但是一個開始。範例

-- test example libraries
local stringx = {}
function stringx.trim(self)  return self:match('^%s*(%S*)%s*$') end
local stringxx = {}
function stringxx.trim(self) return self:match('^%s?(.-)%s?$') end

-- test example
function test2(s)
  assert(s.trim == nil)
  scoped_string_methods(stringxx)
  assert(s:trim() == ' 123 ')
end
function test(s)
  scoped_string_methods(stringx)
  assert(s:trim() == '123')
  test2(s)
  assert(s:trim() == '123')
end
local s = '  123  '
assert(s.trim == nil)
test(s)
assert(s.trim == nil)
print 'DONE'

函式 scoped_string_methods 會將給定的函式表指定給目前執行函式的範圍。範圍內的字串索引作業會全部經過給定的表

上方使用這個架構程式碼

-- framework
local mt = debug.getmetatable('')
local scope = {}
function mt.__index(s, k)
  local f = debug.getinfo(2, 'f').func
  return scope[f] and scope[f][k] or string[k]
end
local function scoped_string_methods(t)
  local f = debug.getinfo(2, 'f').func
  scope[f] = t
end

Method Chaining Wrapper Take #4 – 使用 Metalua 進行詞法注入

我們可以透過 MetaLua,對上述內容執行更穩健的類似動作。下方是一個範例。

-{extension "lexicalindex"}

-- test example libraries
local stringx = {}
function stringx.trim(self)  return self:match('^%s*(%S*)%s*$') end

local function f(o,k)
  if type(o) == 'string' then
    return stringx[k] or string[k]
  end
  return o[k]
end

local function test(s)
  assert(s.trim == nil)
  lexicalindex f
  assert(s.trim ~= nil)
  assert(s:trim():upper() == 'TEST')
end
local s = '  test  '
assert(s.trim == nil)
test(s)
assert(s.trim == nil)

print 'DONE'

語法延伸加入一個新的關鍵字 lexicalindex,用來指定一個函式,在目前的範圍內每次索引一個值時呼叫。

這裡是對應的純粹 Lua 原始碼

--- $ ./build/bin/metalua -S vs.lua
--- Source From "@vs.lua": ---
local function __li_invoke (__li_index, o, name, ...)
   return __li_index (o, name) (o, ...)
end

local stringx = { }

function stringx:trim ()
   return self:match "^%s*(%S*)%s*$"
end

local function f (o, k)
   if type (o) == "string" then
      return stringx[k] or string[k]
   end
   return o[k]
end

local function test (s)
   assert (s.trim == nil)
   local __li_index = f
   assert (__li_index (s, "trim") ~= nil)
   assert (__li_invoke (__li_index, __li_invoke (__li_index, s, "trim"), "upper"
) == "TEST")
end

local s = "  test  "

assert (s.trim == nil)

test (s)

assert (s.trim == nil)

print "DONE"

lexicalindex Metalua 延伸實作如下

-- lexical index in scope iff depth > 0
local depth = 0

-- transform indexing expressions
mlp.expr.transformers:add(function(ast)
  if depth > 0 then
    if ast.tag == 'Index' then
      return +{__li_index(-{ast[1]}, -{ast[2]})}
    elseif ast.tag == 'Invoke' then
      return `Call{`Id'__li_invoke', `Id'__li_index', unpack(ast)}
    end
  end
end)

-- monitor scoping depth
mlp.block.transformers:add(function(ast)
  for _,ast2 in ipairs(ast) do
    if ast2.is_lexicalindex then
      depth = depth - 1; break
    end
  end
end)

-- handle new "lexicalindex" statement
mlp.lexer:add'lexicalindex'
mlp.stat:add{'lexicalindex', mlp.expr, builder=function(x)
  local e = unpack(x)
  local ast_out = +{stat: local __li_index = -{e}}
  ast_out.is_lexicalindex = true
  depth = depth + 1
  return ast_out
end}

-- utility function
-- (note: o must be indexed exactly once to preserve behavior
return +{block:
  local function __li_invoke(__li_index, o, name, ...)
    return __li_index(o, name)(o, ...)
  end
}

--DavidManura

另見


RecentChanges · 偏好設定
編輯 · 歷程
最後編輯 2009 年 12 月 9 日上午 1:38 GMT (差異)