vlambda博客
学习文章列表

Lua中的函数式编程

最近在用Lua实现Websocket协议时,碰到了一个直击我的思维惯性的弱点的Bug。代码大约如下(实际实现较为复杂,比如还支持wss协议,因此定位到问题也着实花费了一些功夫,毕竟GC的执行是异步的.):


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
--websocket.lua
local M = {}
local mt = {
__index = M,
__gc = function (sock)
         close_via_c_layer(sock[1])
end }
function M:connect(url)
         local ip,port = parse from url
         local fd = connect_via_c_layer(ip,port);
         local sock = setmetatable ({fd}, mt)
         return sock
end
....
return M
--foo.lua
local ws = require " websocket"
local sock = ws:connect("ws://127.0.0.1")
print (sock)
 
--main.lua
require " foo"
while true do
         collectgarbage ("step", 1024)
         sleep for a while
end


起初,我发现foo.lua中建立的链接会被莫名关闭,各种排查websocket的实现。最后才发现竟然是sock对象的__gc函数被触发了。


查到的问题后,我足足想了有5分钟才明白过来为什么sock会被GC掉。


因为潜意识中,foo.lua类似于下面C代码,其中sock变量是与整个C代码的生命周期一致的。而在C语言中,代码是不会被回收的。因此sock是作用域有限的全局变量。


1
2
3
4
5
6
7
#include "websocket"
static websocket sock;
void exec()
{
         sock = websocket::connect( "ws://127.0.0.1:8001" );
         print(sock);
}


为什么在Lua中sock变量会被GC掉,就要从Lua的基本规则说起:


在Lua中,一共有8种基本类型: nil、boolean、number、string、function、userdata、 thread 和 table。


其中’string,function,userdata,thread,userdata’等需要额外分配内存的数据类型均受Lua中的GC管理。


而require "foo" 的本质工作(如果你没有修改packaeg.preload的话)是在合适的路径找到foo.lua,并将其编译为一个chunk(一个拥有不定参数的匿名函数),然后执行这个chunk来获取返回值,并将返回值赋给package.loaded["foo"]。


在这个chunk被执行之后,整个LuaVM再无一处引用着此chunk. 因而此chunk可以被GC掉,而顺带着,被chunk引用的sock变量也一并被GC掉(因为sock变量仅被此chunk引用)。


一切都是这么的自然和谐,惟一不和谐的就是,我犯了这个错误。


以往在研究GC时,就单纯的研究GC,在一张图上经过若干步骤进行mark,再进行若干步骤进行sweep。


在编写Lua代码时,却往往根据以往的c/c++经验来判断变量的生命周期, 毕竟就算在如java,C#这些带GC的面向对象语言中,这些经验依然适用。


也因此,在我面向对象编程范式(也许叫‘基于对象’更合适,毕竟我极少使用继承)的思维惯性下,潜意识竟然将这两个紧密相关的部分,强行割裂开来。


以往写Lua代码时,我一直以为Lua是“原型对象”编程范式,然而这个“大跟头”让我发现,原来Lua的底层基石竟然是“函数式编程”范式(非纯函数式编程语言,Lua中的函数有副作用)。




在我们写代码之初,就被人谆谆教导:“程序=算法+数据结构”。


过一段时间(也许很久),我们又被教导各种编程范式,如:“面向对象编程范式,函数式编程范式”。


接着你就会问:“什么是函数式编程,什么是面向对象编程?”


会有很多人告诉你:“在函数式编程语言中,函数是一等公民。在面向对象编程中,万物皆对象”。


然后你(主要是我自己)就开始似懂非懂的用这些概念去“忽悠”其他人。


却从来没在意过,整个编程范式中,数据的生命周期是以何种方式被管理着,以及数据在以何种方式进行转换和通信。


借着这个Bug的契机,我从数据的视角来重新审视了一下这些话,有了一些意想不到的发现。这次终于打破了以往的范式惯性(上次学Lua时,我也是自信满满的认为我懂了函数式编程,结果摔了个大跟头)。


先来大致看看面向对象的哲学。


在纯面向对象编程语言中(C++显然不算),所有的逻辑交互均是在对象之间产生的,不允许变量产生在对象之外。


即使他们在努力的模仿函数式编程,比如所谓的委托,匿名函数。然而这些函数背后却总是逃不开this指针的影子。比如下面代码:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System;
using System.IO;
namespace test {
class TestClass {
         public string a;
         public delegate int foo_t();
         public int bar() {
                 foo_t func = ()=>{Console.Write(a + "\n" ); return 0 ;};
                 func();
                 return 1 ;
         }
};
class Program {
         static void Main(string[] args)
         {
                 TestClass tc = new  TestClass();
                 tc.a = "foo" ;
                 TestClass.foo_t cb = tc.bar;
                 cb();
         }
}}


再来看看函数式编程范式中一等公民的定义:"如果一个语言支持将函数作为参数传入其他函数,将其作为值从其他函数中返回,并且将它们向变量赋值或将他们存储在数据结构中,就在这门语言中,函数是一等公民。


我认为对于有C/C++背景的人来讲,这不足以解释函数式编程的特点。


因为在C/C++语言中,函数指针同样可以做到上述所有的事情。


惟一的区别就是函数式编程语言中的函数其实是闭包(所需要的上下文+指令码(也许是CPU指令,也许是VM的OPCODE)),而C语言中的函数就真的是一段CPU指令。这两种函数有着本质上的区别。


类比面向对象是万物皆对象,函数式编程就应该是万物皆函数。


而实现万物皆函数,闭包是函数式编程必不可少的条件(这里不讨论纯函数式编程范式,连LISP都不是纯函数式编程语言)。


在函数式编程范式中,所有的逻辑交互均是以函数(闭包)为主体来运行。


每一个函数会携带自身所需的环境变量,以便在任何需要执行的地方执行。


自身的GC机制会保证,在函数(闭包)没有被回收前,其携带的环境变量永远有效。


在Lua的require和chunk的机制中我摔的跟头充分验证了这一点。


最后让我绞尽脑汁举一个不太恰当的例子收尾吧(毕竟我也是刚刚(自以为)重新认识了函数式编程):


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local function travel(tbl, process)
         return function ()
                 for k, v in pairs (tbl) do
                         process(k,v)
                 end
         end
end
 
local function printx(title)
         return function (x, y)
                 print (title, x, '=>', y)
         end
end
local tbl = { "foo" , "bar" }
local func = travel(tbl, printx( "index:" ))
tbl = { "newfoo" , "newbar" }
----other logic
func()