Priest Tomb



Lua语言学习(四)——协程(Coroutine)、元表(Metatable)、元方法(Metamethod)

这一篇同样是根据 Lua-Users wiki 梳理的一些细节


协程(Coroutine)

Lua 中的协程不是操作系统线程或进程,而是 Lua 中创建的代码块,协程有像线程一样的控制流。同一时间仅有一个协程在运行,一直运行直到它激活另一个协程或挂起(返回调用它的协程)。协程以一种方便自然的方式表达了多个协作线程,但是不能并行执行,从而没有获得多 CPU 的性能优势。然而,由于协程转换比操作系统线程更快,并且通常不需要复杂且有时昂贵的锁机制,所以使用协程通常比使用完整 OS 线程的等效程序快

为了使多个协程能共享执行,它们必须停止执行(在执行了合理的处理后)然后把控制权传给另一个协程,这个行为称为挂起(yielding)。这是通过调用 Lua 的一个函数 coroutine.yield() 来实现的,这个函数和在函数中使用 return 类似,两者的区别在于:挂起后,还可以从挂起的地方重新进入线程,return 后则会销毁域,不能再重新进入

简单使用

为了创建一个协程就必须有一个代表它的函数

> function foo()
>>   print("foo", 1)
>>   coroutine.yield()
>>   print("foo", 2)
>> end

使用 coroutine.create(fn) 创建协程

> co = coroutine.create(foo)
> = coroutine.status(co)
suspended

这时的协程状态是暂停的(suspended),意思是线程是活着的,只是什么都没做。创建后想启动协程需要使用 coroutine.resume() 函数

> = coroutine.resume(co)
foo 1

可以看出函数内执行到第一条打印语句就停止了,这时我们可以再次执行 coroutine.resume() 函数恢复协程

> = coroutine.resume(co)
foo 2

协程恢复到刚才挂起的下一行继续执行到结束,这时如果再执行 coroutine.resume() 函数则会发现,线程已经死了,不能再恢复

> = coroutine.resume(co)
false   cannot resume dead coroutine

更多细节

这里直接附网站上的这个例子吧,看了好几遍才捋清楚。。

> function odd(x)
>>   print('A: odd', x)
>>   coroutine.yield(x)
>>   print('B: odd', x)
>> end
>
> function even(x)
>>   print('C: even', x)
>>   if x==2 then return x end
>>   print('D: even ', x)
>> end
>
> co = coroutine.create(
>>   function (x)
>>     for i=1,x do
>>       if i==3 then coroutine.yield(-1) end
>>       if i % 2 == 0 then even(i) else odd(i) end
>>     end
>>   end)
>
> count = 1
> while coroutine.status(co) ~= 'dead' do
>>   print('----', count) ; count = count+1
>>   errorfree, value = coroutine.resume(co, 5)
>>   print('E: errorfree, value, status', errorfree, value, coroutine.status(co))
>> end
----    1
A: odd  1
E: errorfree, value, status     true    1       suspended
----    2
B: odd  1
C: even 2
E: errorfree, value, status     true    -1      suspended
----    3
A: odd  3
E: errorfree, value, status     true    3       suspended
----    4
B: odd  3
C: even 4
D: even         4
A: odd  5
E: errorfree, value, status     true    5       suspended
----    5
B: odd  5
E: errorfree, value, status     true    nil     dead

网站接下来的内容不直接翻译了,说几个我理解的比较核心的点吧:

  • 最外围是一个 while 循环,循环的条件是协程 co 的状态不是 dead

  • 协程函数内部也是一个循环,一个循环 x 次的 for 循环,x 的值是在使用 resume() 方法时传入的

  • 使用 resume() 方法重启协程时,第一个参数后的其他参数会被协程接收,这个例子里只体现了一次,就是第一次启动协程时,传入的参数 5 被赋值给协程函数的 x,所以这个例子中协程内部会循环 5 次;之后的 resume() 会使协程返回到上一次 yield() 的地方继续执行

  • 使用 yield() 方法挂起协程时,会返回到重启当次协程的 resume() 方法的地方,所以 yield(x) 和 yield(-1) 时,这个 x 和 -1 都被 while 循环中的 value 接收,errorfree 则接收了当次 resume() 操作的结果,在本例中所有的重启都是成功的,所以 errorfree 都是 true

  • 在内部和外部的循环中来回跳转的关键就是在 yield() 和 resume() 之间跳转

插播一个栗子

wiki 上的例子缺少了一些”赋值”的细节,这里再来一个w3cschool教程上的例子

function foo (a)
    print("foo 函数输出", a)
    return coroutine.yield(2 * a)
end

co = coroutine.create(function (a , b)
    print("第一次协同程序执行输出", a, b)
    local r = foo(a + 1)

    print("第二次协同程序执行输出", r)
    local r, s = coroutine.yield(a + b, a - b)

    print("第三次协同程序执行输出", r, s)
    return b, "结束协同程序"
end)

print("main", coroutine.resume(co, 1, 10))
print("---分割线----")
print("main", coroutine.resume(co, "r"))
print("---分割线---")
print("main", coroutine.resume(co, "x", "y"))
print("---分割线---")
print("main", coroutine.resume(co, "x", "y"))
print("---分割线---")

执行结果:

第一次协同程序执行输出	1	10
foo 函数输出	2
main	true	4
---分割线----
第二次协同程序执行输出	r
main	true	11	-9
---分割线---
第三次协同程序执行输出	x	y
main	true	10	结束协同程序
---分割线---
main	false	cannot resume dead coroutine
---分割线---

在这个例子中可以更明显的看出 resume() 和 yield() 时怎么传参赋值:

  • 第一次 resume(co, 1, 10) 时,把 1 和 10 传递给了协程函数,赋值给 a 和 b

  • 在 foo() 函数中 yield(2 * a) 时,把 4 传回了上一次启动的地方,即 resume(co, 1, 10),所以第一次输出 main 时,是 main true 4

  • 第二次 resume(co, “r”) 时,把 “r” 传回了上一次挂起的地方,即 yield(2 * a),foo() 函数的返回值也就变成了 “r”,所以 local r = “r”

  • 同理,yield(a + b, a - b) 时,把 11 和 -9 传回了 resume(co, “r”) 的地方,第二次输出 main 时,是 main true 11 -9

  • 同理,第三次 resume(co, “x”, “y”) 时,把 “x” 和 “y” 传回,赋值给了 local r, s


元表和元方法

Lua 中有类似其他语言中”运算符重载”一样的概念,元表是包含一些元方法的常规表,在 Lua 中执行某些操作时会触发事件,这些元方法就和这些事件有关

举个例子就像两个表相加的操作,会触发 __add 方法

元表(Metatable)

使用 setmetatable() 方法设定一个表作为元表

local x = {value = 5}

local mt = {
  __add = function (lhs, rhs)
    return { value = lhs.value + rhs.value }
  end
}

setmetatable(x, mt)

local y = x + x

print(y.value) --10

local z = y + y --error

设置 mt 为 x 的元表,元表中拥有 __add 元方法,所以 x 才可以使用 + 操作符,y 没有元方法,不能使用 + 操作符

如果其中一个操作数是数字,同样也可以触发元表中的方法,并且,左边的操作数就是函数中的第一个参数,右边的操作数就是第二个参数,所以具有元表的表也不一定是元方法的第一个参数:

local x = {value = 5}

local mt = {
	__add = function(l,r)
		return {value = l + r.value}
	end
}

setmetatable(x,mt)

local y = 5 + x

print(y.value) --10

元方法(Metamethod)

这里直接搬运 wiki 上的元方法的示例

0. __index

使用 __index 键可以接备用表(fallback table)或者函数,如果接函数,则第一个参数是查找失败的表,第二个参数是查找的键。备用表同样可以触发它的 __index 键,所以可以创建一个很长的备用表链

local func_example = setmetatable({}, {__index = function (t, k)
  return "key doesn't exist"
end})

local fallback_tbl = setmetatable({
  foo = "bar",
  [123] = 456,
}, {__index=func_example})

local fallback_example = setmetatable({}, {__index=fallback_tbl})

print(func_example[1]) --> key doesn't exist
print(fallback_example.foo) --> bar
print(fallback_example[123]) --> 456
print(fallback_example[456]) --> key doesn't exist
1. __newindex

当向表中新分配一个不存在的键时,会触发 __newindex 元方法,如果该键已存在,则不会触发

local t = {}

local m = setmetatable({}, {__newindex = function (table, key, value)
  t[key] = value
end})

-- 注:上面使用函数等同于直接指定 __newindex 为表 t
-- local m = setmetatable({}, {__newindex = t})

m[123] = 456
print(m[123]) --> nil
print(t[123]) --> 456
2. 比较
local mt
mt = {
  __add = function (lhs, rhs)
    return setmetatable({value = lhs.value + rhs.value}, mt)
  end,
  __eq = function (lhs, rhs)
    return lhs.value == rhs.value
  end,
  __lt = function (lhs, rhs)
    return lhs.value < rhs.value
  end,
  __le = function (lhs, rhs)
    return lhs.value <= rhs.value
  end,
}
3. __metatable

如果想保护一个元表不被程序修改,可以使用 __metatable 键

wiki 上简短的一两句话还没有示例,有点让人摸不着头脑,我一开始以为是说一个表设置了 __metatable 之后,就不能被设置为其他表的元表了,测试后发现不对,看了这篇Lua中的metatable详解的内容后才明白。。

local mt = {__metatable = "error"}

local t = setmetatable({}, mt)

print(getmetatable(t)) --error

setmetatable(t,{}) --stdin:1: cannot change a protected metatable

原来是一个已经被设置为元表的表,设置这个键后就不允许通过 getmetatable() 获取,从而不允许被修改,反例:

local mt = {test=1}

local t = setmetatable({}, mt)

local mt2 = getmetatable(t)

print(mt.test,mt2.test) --1 1

-- 修改元表中的数据
mt2.test = 2

print(mt.test,mt2.test) --2 2
4. 元方法手册

详细的元方法列表可以看这里