分享

理解 Lua 的那些坑爹特性

 quasiceo 2014-01-16

理解 Lua 的那些坑爹特性

 

按:最近看到了依云的文章,一方面,为Lua被人误解而感到十分难过,另一方面,也为我的好友,依云没有能够体会到Lua的绝妙和优雅之处而感到很遗憾,因此我写了这篇文章,逐条款地说明了依云理解中出现的一些问题。希望能够帮助到大家!

1. 协程只能在Lua代码中使用

    是的,协程在当你需要挂起一个C函数的时候无法使用。但是,在提出这个缺陷的时
候,是不是应该想一想:为什么Lua会有这个缺陷
    原因很简单:这一点完全避不开,这是C的问题,C无法保存函数执行的某个现场用于
返回这个现场继续执行,因此完全没有办法在Lua的协程被唤醒的时候,回到这个现场。
    那么怎么办呢?Lua5.2做出了很优秀的设计。既然无法回到C的现场,那么我们不回
去了,而是采取“事件通知”的方式告诉你,“hey哥们,你前面的逻辑被切了,想办法
补救吧”,这就是所谓的CPS——继续风格的编程。继续在这里是一个Scheme/Lisp术
语,意思是“当前的操作执行完了以后,下面该做什么?”这种风格是Lua能支持任意
Yield 的必要条件。在C的限制下,只有这一种方法能突破这个限制。
    至于你说的“比异步回调更复杂”,我想你弄混了两点:1.这只是C API层面的修改
完全不影响到Lua代码层面,你的Lua代码完全不必做出任何修改,而且,你对
coroutine的用法完全错了!等会儿我会教你coroutine到底怎么用。2.上面提到了,
这是唯一一种能支持coroutine的方式,既然是唯一一种,就无所谓复杂与否了。3.
我下面会演示给你,为什么说coroutine完全解放了程序员,使用coroutine的代码会带来
革命性的简化。
    我们分两步来说明这个问题:第一步,我们先来看你的例子:你想做的事情是,在执
行 c.callback的时候,能够yield出来,继续其他的流程。这里必须要说明,你的API设
计本身就是callback式的,因此这种API本身就犯不着coroutine,Lua本身能完全地处理
。这里我会给出一个支持coroutine的C模块设计,让这个模块能支持coroutine,第二步
,我会告诉你coroutine实际上是用在什么方面的,是如何取代事件回调机制的。在完成
这个说明后,我们来说明coroutine到底有什么好处,为什么说coroutine比事件回调机制
有着 革命性的优秀之处。
    你的例子是这样的:
co.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
c = require('c')                
                                  
co = coroutine.create(function()
  print('coroutine yielding')   
  c.callback(function()         
    coroutine.yield()           
  end)                          
  print('coroutine resumed')    
end)                            
                                  
coroutine.resume(co)            
coroutine.resume(co)            
                                  
print('the end')                
    先说一下,将模块放到全局变量里通常不是一个好主意。所以第一行如果写成
1
local c = require 'c'
    就更好了。
    其他的地方倒是没什么需要修改的了。
    再看看你的C模块代码:
c.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<stdio.h>                                       
#include<stdlib.h>                                      
#include<lua.h>                                         
#include<lualib.h>                                      
#include<lauxlib.h>                                     
                                                          
static int c_callback(lua_State *L){                    
  int ret = lua_pcall(L, 0, 0, 0);                      
  if(ret){                                              
    fprintf(stderr, "Error: %s\n", lua_tostring(L, -1));
    lua_pop(L, 1);                                      
    exit(1);                                            
  }                                                     
  return 0;                                             
}                                                       
                                                          
static const luaL_Reg c[] = {                           
  {"callback", c_callback},                             
  {NULL, NULL}                                          
};                                                      
                                                          
LUALIB_API int luaopen_c (lua_State *L) {               
  luaL_register(L, "c", c);                             
  return 1;                                             
}                                                       
    首先,因为这是Lua的C模块,所以你得声明这的确是一个C模块,应该在
        #include <lua.h>
    之前加入这一行:
        #define LUA_LIB
    编译的时候就可以用下面的命令行了:
        gcc -mdll -DLUA_BUILD_AS_DLL c.c -oc.dll
    然后,Lua5.2已经没有luaL_register函数了,因为Lua不鼓励将模块设置到全局域,
而luaL_register会做这件事。所以将这行改为:
        luaL_newlib(L, c);
    最后一点不是问题,只是一个小建议:Lua只是会用luaL_Reg里的内容,但是却不会
保留里面的任何内容,所以你可以直接将其放在luaopen_c里面,并去掉static,这样可
以节省一点内存。
    我们来看看一个支持coroutine的C模块应该怎么写:
c.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include<stdio.h>                                                    
#include<stdlib.h>                                                   
                                                                       
#define LUA_LIB /* 告诉Lua,这是一个LIB文件 */                       
#include<lua.h>                                                      
#include<lualib.h>                                                   
#include<lauxlib.h>                                                  
                                                                       
static int c_cont(lua_State *L) {                                    
  /* 这里什么都不用做:因为你的原函数里面就没做什么 */               
  return 0;                                                          
}                                                                    
                                                                       
static int c_callback(lua_State *L){                                 
  /* 使用 lua_pcallk,而不是lua_pcall */                             
  int ret = lua_pcallk(L, 0, 0, 0, 0, c_cont);                       
  if(ret) {                                                          
    fprintf(stderr, "Error: %s\n", lua_tostring(L, -1));             
    lua_pop(L, 1);                                                   
    exit(1);                                                         
  }                                                                  
  /* 因为你这里什么都没做,所以c_cont里面才什么都没有。如果这里需要做
   * 什么东西,将所有内容挪到c_cont里面去,然后在这里简单地调用      
   * return c_cont(L);                                               
   * 即可。                                                          
   */                                                                
  return 0;                                                          
}                                                                    
                                                                       
static const luaL_Reg c[] = {                                        
  {"callback", c_callback},                                          
  {NULL, NULL}                                                       
};                                                                   
                                                                       
LUALIB_API int luaopen_c (lua_State *L) {                            
  /* 使用新的 luaL_newlib 函数 */                                    
  luaL_newlib(L, c);                                                 
  return 1;                                                          
}
    现在,你的例子可以完美运行了:
1
2
3
4
lua  -- co.lua    
coroutine yielding
coroutine resumed 
the end           
    我们看到,让C模块支持yield是非常简单的:首先,你需要将lua_call/lua_pcall改
成对应的k版本,将函数其后的所有内容剪切到对应的cont函数里去,然后将原先的内容
改为return func_cont(L);即可。
    为什么要这么设计API?上面说了,这是为了解决C自身的问题,如是而已。
    现在我们来讨论第二个问题:Lua的coroutine用在什么地方呢?
    假设我们要书写游戏的登陆逻辑,我们需要干这样的事情:
        1. 登陆游戏
        2. 获取玩家角色数据
        3. 让玩家移动到上次退出前的坐标
    如果是事件回调引擎,你会怎么设计API呢?可能是这样的:
1
2
3
4
5
6
7
8
9
function do_login(server)                                                
    server:login(function(data)                                          
        -- 错误处理先不管,假设有一个全局处理错误的机制(后面会提到,实际
        -- 上就是newtry/protect机制)                                    
        server:get_player_info(function(data)                            
            player:move_to(data.x, data.y)                               
        end)                                                             
    end, "username", "password")                                         
end
    看到了么?因为登陆需要等待网络请求,而等待的时候你不能把事件循环给阻塞了,
所以你不得不用回调机制,但是,一旦你一次要做几件事情,回调立即就会让你的代码狼
狈不堪。这还只是简单的顺序代码。如果是判断或者是循环呢?我告诉你,上面的代码是
一个真实的例子,是我以前设计的手机网游里面关于登陆部分的实际例子,而另一个例子
是在客户端批量购买N个道具!可以想象这会是一个很复杂的递归代码了,而实际上你仅
仅是想做for在做的事情而已!
    那么怎么办呢?coroutine提供了解决这个问题的一个极端优雅的办法。我们想想最
优雅的设计会是什么样子的:
1
2
3
4
5
function d_login(server)                 
    server:login("username", "password"
    local data = server:get_player_info()
    player:move_to(data.x, data.y)       
end                                      
    是不是简单多了?慢着!看起来login等函数是阻塞的,这样的阻塞难道不会阻塞事
件循环,导致界面僵死么?好!现在coroutine上场了!看看我们是如何实现login的!
1
2
3
4
5
6
7
8
9
10
11
local current                                         
function server:login(name, password)                 
    assert(not current, "already send login message!")
    server:callback_login(function(data)              
        local cur = current                           
        current = nil                                 
        coroutine.resume(cur, data)                   
    end, name, password)                              
    current = coroutine.running()                     
    coroutine.yield()                                 
end                                                   
    看到了吗?login先用正常的方式调用了基于回调的callback_login,然后设置当前
在等待的coroutine为自身,最后yield掉自己。在回调执行的时候,回调会resume那个上
次被yield掉的coroutine,这样就完美的支持了阻塞的语法并且还能够满足事件循环的约
束!能够重新整理程序的执行流程,这就是coroutine的强大之处。最奇妙的是,在
这个设计之中,回调中唯一会做的事情只有resume,而不是yield,这意味着**即使不修
改一行代码,现有的模型也可以完美支持这个模式**!
    可以看出将回调模式的函数改造成协程模式的函数是很简单的,我们甚至可以写一个
高阶函数来做这件事:
1
2
3
4
5
6
7
8
9
10
11
12
13
function coroutinize(f, reenter_errmsg)    
    local current                          
    return function(...)                   
        assert(not current, reenter_errmsg)
        f(function(...)                    
            local cur = current            
            current = nil                  
            coroutine.resume(cur, ...)     
        end, ...)                          
        current = coroutine.running()      
        coroutine.yield()                  
    end                                    
end                                        
    这样,上面的login函数就很简单了:
        server.login = coroutinize(server.login)
    看到Lua在表达复杂逻辑时的巨大优势了吗?coroutine机制同样也是可以支持函数重
入的:如果一个函数被调用多次,那么对应被调用的回调调用时,对应的那个coroutine
会被resume。至于如何实现,就交给读者作为练习了。提示:Programming in Lua这本书
已经说明了该如何去做。
    我们总结一下:
        1. coroutine无法穿越C边界是C语言的固有缺陷,Lua无法在保持其代码是Clean
           C的前提下完成这个impossible的任务。
        2. 那么,要支持这个特性,就只有要求C模块的编写者能采用CPS的方式编程了
           。当然Lua的代码可以完全不做任何修改。
        3. 而,coroutine很少需要在C函数内部yield(可能有实际场景会需要,但事实
           是在我所书写的上万行的Lua富coroutine的代码中,完全没有用到过这种策
           略)。
        4. 如果你能深入了解coroutine,你会发现即使coroutine无法在C内部yield,
           coroutine依然可以展现其绝大多数的威力。
        5. Lua本身的设计可以让Lua在表现极端复杂的逻辑关系时游刃有余。

2. 幽灵一般的 nil

    我不否认,在我刚刚学习Lua的时候,我的确被nil坑过很多遍。我们先抛弃掉luaJIT
关于NULL设计的问题(这个设计本身也是一种无奈,而且LuaJIT毕竟并不能完全继承Lua
作者对Lua的理念),先来看看nil究竟是什么——从nil中,我学习到了,在遇到坑爹特
性之前,先不要急着抱怨,想想为什么作者会设计这么坑爹的特性。要么作者是比你低能
的傻逼,要么这么设计就的确是有充分的考虑和不得已的苦衷的。这点你想到过吗?
    nil是一个表示“没有”的值。是的,就是真的“没有”,因此nil本身就是一个幽灵
——它除了表示“这里没有东西”以外,没有其他的任何含义!它不是None(None是一个
表示“空”的对象),它也不是NULL(NULL表示没指向任何地方的指针——总所周知指针
本身必定是有值的,哪怕那个值是NULL)。Lua的作者十分聪明的将“没有”这个概念也
引入了语言,并且还保持了语言的一致性:请问,将“没有”存入一个表里面,它如果不
消失,还能发生什么事呢?
    那么如何表示“空”或者“没有指向任何地方的引用”呢?两个办法,你可以存入
false,或者可以用下面这个巧妙的方法:
1
2
3
4
5
6
7
undefined = {}                             
-- 指定一个全局变量存在,但不指向任何地方:
a = undefined                              
-- 判断这个全局变量是否不指向任何地方:    
if a == undefine then ... end              
-- 彻底删除这个变量:                      
a = nil                                    
    看!Lua可以灵活的控制一个变量的实际作用域!让一个变量真正的凭空消失掉!这
一点即使是C或者C++都是做不到的(变量只有在作用域外才会消失),这也是Lua强大的
灵活性的一个佐证。
    千万不要弄错了,nil代表“没有”,它不代表“空”,也不代表“没有被初始化的
值”,它只是没有而已。因此它就应该是幽灵般的。这是Lua的语言设计一致性带来的优
势,而不是坑爹的特性。如果说学不会的特性就是坑爹的特性,那么是不是C语言的指针
也是坑爹的特性呢?
    其次,各种库定义的null值,本质上是代表微妙但不同的东西。仔细地体会其中的不
同,能让你更得心应手的使用那些库。如果你在这些库的互操作上感到困扰,请给库的作
者写邮件抱怨:Lua有一个很热情友好的社区!

3. 没有continue

    是的,Lua一直不肯加入continue。为什么呢?因为repeat until。而为什么强调“
不添加不必要的特性”的Lua作者会舍弃掉“那么常见”的continue,却保留不那么常见
的repeat呢?是Lua的作者傻么?
    不是。这是经过仔细设计的。我先告诉你答案,再仔细地分析:事实上,在加入
repeat以后,continue是逻辑上不可能实现的,而repeat语句实现了一个用其他的特性完
全无法取代的特性。
    注意看repeat的结构:
        repeat <block> until <exp>
    问题就在<exp>上了。Lua规定,<exp>是在<block>的作用域下计算的!这也就意味着
1
2
local a = true          
repeat a = false until a
    会是一个死循环!看到了么?这种“表达式在一个block内的上下文计算”的循环特
性,是无法被其他任何特性模拟的!这种特性导致Lua作者为了语言的完整性,不得不将
repeat语句添加入Lua语言。
    那么continue呢?花开两朵,各表一枝,我们先介绍一下Lua5.2的新特性:goto语句
。lua5.2支持跳转语句了!看看你之前的那个例子吧,在没有continue的情况下,我改如
何写那个循环呢?答案是这样:
1
2
3
4
5
6
7
for line in configfile do                
    if string.sub(line, 1, 1) == '#' then
        goto next                        
    end                                  
    parse_config(line)                   
    ::next::                             
end                                      
    看上去有点小题大做,连goto都用上了!呵呵,其实还有一个细节你不知道哦,在
Lua5.2里,甚至连break都没有了!break语句只是goto break的一个语法糖而已。而
break标签会被自动插入到你的源代码中。
    那么,你可能会问了,事已至此,为什么不也加个continue的语法糖呢?毕竟break
都是语法糖了!好,我们试着在repeat里面用一下我们手写的“continue”:
1
2
3
4
5
6
7
8
9
10
local i                          
repeat                           
    if i == 5 then               
        goto next                
    end                          
    local j = i * i              
    print("i = "..i..", j = "..j)
    i = i + 1                    
    ::next::                     
until i == 10                    
    这个例子造的有点刻意了,但是至少也有continue的意思了吧!好,现在执行一下—
—出错了……
1
2
3
4
lua  -- "noname\2013-01-03-1.lua"                                                       
lua: noname\2013-01-03-1.lua:10: <goto next> at line 4 jumps into the scope of local 'j'
shell returned 1                                                                        
Hit any key to close this window...
    这是怎么回事??
    我们知道,在C里面,goto可以跳入任何地方,包括跳过变量的初始化,这会导致一
个变量可能在用的时候,是未初始化的。Lua为了避免这样的错误,要求goto不允许跳入
一个变量的作用域内部!而看看我们的代码,goto跳入了变量j的内部!
    在这种情况下,是根本没有办法continue的!
    这就是Lua作者不提供continue的真实意思。continue是根本不可能实现的。因为从
完整性考虑,必须提供支持作用域扩展的repeat语句(记得C/C++对for的三个子语句中的
作用域规定么?),而continue可能会在repeat的条件表达式中用到的变量还没有初始化
的时候,就开始条件表达式的计算!这是不允许的!
    现在我们知道了:
        1. 不是Lua作者故意不提供continue,而是continue和当前的一个关键特性冲突
           了,导致continue语句完全无法实现。
        2. 为了弥补这个问题,Lua作者为Lua加入了goto语句。然而该有的限制仍然存
           在,但是编译器会为你检查这个限制!
        3. 所以,现在在Lua里的大多数情况下,你仍然能使用你自己手工打造的
           continue,而且功能更为强大(labeled break,labeled continue都是可以
           模拟出来的)。
    关于goto语言可能的坏处以及作者的考虑,请参考Lua作者的novelties-5.2.pdf文件。
    对了,还要说一句:有读者可能为问:既然Lua已经把break做成语法糖了,为什么不
把continue也做成语法糖呢?如果遇到不合法的情况,直接出错不行么?
    这个问题我也没想明白。也许会有自己的原因吧,不过如果把这个想法当作Lua的坑
爹设计也未尝不可以,不过其“坑爹指数”已经大为降低了。

4. 错误信息的表达

    我只想说一句话:其实大多数在预见到会对错误进行处理的场合里面,错误的返回方
式其实并不是nil, errmsg,而是nil, errmsg, errno。别的你懂了。

5. 下标

    参看 novelties-5.2.pdf,说的非常明白了。

6. 提前返回

    这是一个语法问题,事实上return语句不跟着end的话,那么编译器就根本无法编译
return语句了。这是Lua“行无关语法”的一个必然折衷,我开始也不爽,但事实是,在
我数万行的Lua开发中,除了测试必要要注释一部分代码以外,我根本没用过do return
end这种表达——至于为什么,你实际开发一下就知道了:因为这种代码一定会导致完全
无法被执行到的死代码。

7. 方法调用

8. 面向对象

    这两点恰好就是Lua的优势啊!!有时间我会写一篇文章来讨论。这实际上是Lua能以
比其他语言小巧灵活得多地去处理复杂逻辑的一个必然原因了。这里只说一点:Lua5.2中
,表所具有的元方法已经和C API能处理的完全一样多了。纯Lua已经不必对着__len和
__gc而望洋兴叹了。
    关于这一点,MikePall(LuaJIT实现者)还专门和Lua作者吵了一架,因为让表支持
__gc会导致luaJIT的jit编译非常难写= =||||

9. 结论

    我开始学习Lua的时候,也几乎得到了跟你一样的结论。然而,在长达两年的Lua开发
中,我逐渐认识到了Lua的美,认识到了Lua实现的优雅和严谨。现在如果有新手想学习C
语言开发的诀窍和技巧,我通常会建议他去拜读Lua的C实现源代码。Lua的实现太优雅了
。而Lua的设计也凝聚着作者的一点一滴的心血。Lua精准绝妙的设计是Lua强大的表达能
力的表现。继续学习下去吧,我向你保证,你一定会发现,Lua实际上脚本语言里面表达
能力最强,概念最统一,设计最优雅的语言了。Lua无愧脚本语言之王!
Category: 未分类 | Tags: Lua | Read Count: 4979
Small_feed 评论 (9)
Avatar_small
依云 说:
大约 1 年前

关于协程,我当然知道协程该怎么用。Lua C API 确实有些细节上不太清楚,文档太简略了。pcallk 只能解决一次 yield 吧?如果 yield 的次数不定该怎么办?我有个库的函数,在运行过程中可能需要调用一个回调函数来取某些数据。在 Lua 绑定中,这个回调函数就是调用一个 Lua 函数,然后由于涉及网络操作,它是会 yield 不定次数的。

你说的所有这些,要么是 LuaJIT 2.0.0 还没实现的特性(LuaJIT 比 Lua 快太多了),要么是要求作者对 Lua 该怎么编程很熟悉(如果我接手的那些代码是你写的就好了)。至于表达能力,还是不要太强的好,不然每个人的错误返回方式和面向对象的实现都不一样,概念是统一了,实现千差万别、各不相容。

没错,「do return end」就是调试时用的。

Avatar_small
亚弥 说:
大约 1 年前

@依云: 恩,说句实话,只看reference的确很难搞明白k系列函数内部的核心思想。我是一开始就跟着邮件列表的讨论才比较清楚的。不过你真的可以看看novelties-5.2.pdf,这里面有很详细的说明。
另外不明白“一次yield”和“多次yield”有什么区别。只要用了k系列函数,你多少yield都没问题的,因为Lua自己会帮你维护Lua内部yield时候的状态。无论你如何yield,回到C层面(即从内部的coroutine返回)只会有一次,因此k系列函数一定能做到你想要的,而且并不需要特别的设计。
你仔细看看LuaJIT,很多特性已经实现了,包括goto。k系列函数没实现是基于两个原因:1.LuaJIT关注纯Lua应用,甚至用ffi库取代了C API的必要性;2.LuaJIT因为与Lua作者的巨大分歧(邮件里面吵了好几架),所以不打算实现5.2兼容了。至少短期内是不想的。sigh……快的话,其实快不了多少,只是科学计算方面的确快了很多,如果你的代码是C模块密集的,那么LuaJIT很难提高效率,其次是如果你用了NYI的特性,那么也是不会快的(比如字符串模式匹配和coroutine),从我的经验看,网游逻辑书写用luaJIT对效率的提升不大,甚至可能比原Lua更慢。
表达能力问题的确是个双刃剑,但有个朋友说得好“做得到总比做不到好”,这个就看怎么解读了。
错误返回是有标准模式的,文章里面提到了newtry/protect模式,不过写到后来写忘了= =有时间补上吧,OO的话也是有标准模式的,而且是两套。关键是,因为底层概念统一,所以即使是千差万别的实现,最终也一定是兼容的。你如果处理过实现之间的纠葛就会体会到底层概念统一带来的巨大好处。
do return end的话也就是一个词和三个词的区别吧……sigh……就当多打字了,实在不行做个imap或者iab呗……

Avatar_small
Wayne 说:
12 个月前

哇,偶像你也在这里!!

Avatar_small
Larry Xu 说:
12 个月前

newtry/protect??
luasocket中的那套,我当时看了也觉得蛮有意思的
可否补充介绍下实际过程的使用方式

Avatar_small
4T-Shirt 说:
10 个月前

你好,能给我lua邮件列表的邮箱吗?谢谢~~

Avatar_small
wtyqm 说:
7 个月前

您好,想问下,如果想在pcall里使用coroutine,有什么办法嘛? 文中的protect,指的就是luaSocket里那种封装pcall的方式吧

Avatar_small
太阳神上 说:
5 个月前

我觉得 lua 还有一个坑爹特性,table 当哈希表时,无法以 O(1) 的时间复杂度取得其元素个数。

Avatar_small
荒野无灯 说:
2 个月前

从lily那里过来的。看了你这文章,受益颇多。

Avatar_small
skyblue 说:
大约 1 个月前

求解释coroutinize中这部分的必要性

local cur = current
current = nil

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多