Lua中的函数式编程

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

--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是作用域有限的全局变量。

#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,table’等需要额外分配内存的数据类型均受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指针的影子。比如下面代码:

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的机制中我摔的跟头充分验证了这一点。

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

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()

发表评论

nine × = eighteen