方法链包装器

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]。缺点是每个内置类型只有一个公共元表。修改此元表会导致全局副作用,这可能是程序中独立维护的模块之间冲突的潜在来源。调试库中的函数通常在常规代码中被不鼓励使用,原因很充分。许多人避免注入这些全局元表,而另一些人则发现它太方便了,无法避免 [3][6][扩展提案]。有些人甚至问为什么内置类型的对象没有自己的元表 [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)

这是最简单的解决方案。简单的解决方案通常是好的解决方案。然而,某些操作是方法调用,而另一些操作是独立的全局函数,以及由此产生的重新排序,可能会导致一定程度的不协调。

避免触碰全局元表的一种解决方案是将对象包装在我们自己的类中,在包装器中以方法调用链的方式执行操作,然后解开对象。

示例如下

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.lua.ac.cn/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")

但这看起来不太常规。(注意:最后一行中的第二个参数是无点式 [4]。)

方法链式包装器方案 #2 - 对象位于链的末尾

我们可以用以下方式来表达调用链

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

其中被操作的对象放在最后。这降低了忘记解包的可能性,并且允许分离和重用

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

有各种方法可以实现这一点(函数式、代码生成 和 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'

方法链式包装器方案 #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

方法链式包装器方案 #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

另请参阅


最近更改 · 偏好设置
编辑 · 历史记录
最后编辑于 2009 年 12 月 9 日凌晨 1:38 GMT (差异)