LUA: 使用递归和尾调用优化代码

LUA: 使用递归和尾调用优化代码

为方便阅读,先上结论:可以优化,但没必要; 这篇文章基于以下前提:

  1. 使用递归+尾调用优化可以在不影响程序效率的前提下,改善代码结构,体高代码可读性
  2. 当前函数栈的无需要保留的信息,才会发生尾调用

一个对象转字符串的例子

考虑将下面的一个容纳了多个table的数组快速拼接成一个长字符串

local list = {}
local mt = { __tostring = function(v) return tostring(v.value) end}
-- 初始化
for i = 1, math.random(8, 10) do
    table.insert(list, setmetatable({
                remove = false,
                value = i
            }, mt))
end

for循环实现

table.concat 只接受元素类型为string或number的数组,不会为非上述类型的元素调用tostring,上面元事件__tostring不会被触发,所以需要创建一个字符串数组,每一个元素为前述数组元素的tostring调用返回值; 下面是最基础的实现方式:

local function genfor(t)
    local b = {}
    for i, v in ipairs(t) do
        table.insert(b, tostring(v))
    end
    return b
end
print(table.concat(genfor(list), ","))

看着还行, 但是表的初始化可以一步完成吗?先指定数组容量,可以避免数组在扩展时候的内存重新分配。除了C API 中的void lua_createtable (lua_State *L, int narray, int nrec)函数,初始化指定容量只有标量表达式(如: t = {1,2,3,4,}) ,以及使用变长参数来构造表(如:local t = {...})。

递归实现

也就是说,只要有一个函数,类似于table.unpack,可以将数组中每一元素执行tostring之后再返回所有内容,就可以在确定数组长度时候构造table;下面是一个使用递归实现的例子:

local function genrecur(t, i)
    if not i then
        -- 递归的最外层,此处接受N个返回值,并构造了表
        return {genrecur(t, 1)}
    end
    if i > #list then
        -- 递归的终止条件
        return
    end
    -- 递归的状态转移, genrecur(i) = list[i], genrecur(i)
    return tostring(t[i]), genrecur(t, i + 1)
end
print(table.concat(genrecur(list), ","))

也还行, 看着还很厉害,测试下性能:

local function cost(f, ...)
    local a = os.clock()
    f(...)
    return os.clock() - a
end

local function testwithN(N)
    local list = {}
    for i = 1, N do
        table.insert(list, setmetatable({
                    remove = false,
                    value = i
                }, mt))
    end
    local a = cost(genfor, list)
    local b = cost(genrecur, list)
    print(("%6d: %.2f"):format(N, b/a))
end
print(("%6s: %s"):format("size", "recur/forloop"))
for __, n in ipairs({0, 4, 8, 64, 128, 1024, 4096, 8192}) do
    testwithN(n)cost
end

测试结果:

  size: recur/forloop
     0: 1.50
     4: 0.54
     8: 1.27
    64: 1.42
   128: 1.04
  1024: 1.69
  4096: 1.86
  8192: 4.67

尾调用递归实现

当表的长度越来越大的时候,递归的效率就较for循环越发低下, 考虑原因,是随着数组长度增加,递归的层数也单调增加了;再试试使用尾调用的递归实现:

local function genrecurx(t, i, ...)
    if not i then
        -- 递归的最外层,提供初始化参数
        return genrecurx(t, #t) 
    end
    if i < 1 then
        -- 递归的终止条件,将变长参数打包成表
        return {...}
    end
    -- 递归的状态转移, genrecurx(n - 1, ...) = genrecurx(n - 1, t[n], ...)
    return genrecurx(t, i - 1, tostring(t[i]), ...)
end 

这里使用了一个技巧,将表内容解出压在了变长参数列表头部, 避免递归函数栈中存有保留信息,进而可以享受到lua的尾调用优化;这样,无论数组有多长,递归的堆栈都只有一层;测试一下性能如何:

  size: recur/forloop recurx/forloop
     0: 0.50 0.50
     4: 0.59 0.50
     8: 0.78 0.74
    64: 1.19 0.80
   128: 1.09 1.07
  1024: 1.64 1.61
  4096: 1.92 1.81
  8192: 5.94 10.44

晴天霹雳,使用了尾调用,效率反而比有常规递归实现还要差

分析原因,发现上面递归代码中,虽然旧的函数栈已经清空,但是每次插入新元素到变长参数列表中,都会将变长参数所有元素后移一位! 见下图:

尾调用插入

而常规递归实现效率更高的原因在于,每次添加元素,新元素是插入到之前栈尾部的,没有移位操作;见下图:
常规递归实现

虽然常规递归会展开N个函数栈,但是在到达最后一个元素时候,所有的函数栈收缩,依次合并所有的返回值,反而效率比尾调用优化实现要高;当然函数栈也是占有了临时空间的。

最终实现

折腾了那么多,所谓代码结构优化,反而拖累了运行效率,那么最终的可用的代码结构是怎样的呢?如下:

local function genrecurt(t, r, i)
    if not r then
        -- 递归最外层,分配结果表,和起始下标
        return genrecurt(t, {}, 1)
    end
    if i > #t then
        -- 递归终止条件,返回结果表
        return r
    end
    -- 递归状态转移, genrecurt(t, r, i)  = genrecurt(t, {r[:#r], t[i]}, i+1)
    table.insert(r, tostring(t[i]))
    return genrecurt(t, r, i + 1)
end 

性能测试:

  size: recur/forloop recurx/forloop recurt/forloop
     0: 0.67 0.56 0.56
     4: 0.90 0.57 0.62
     8: 1.45 0.82 0.95
    64: 1.01 0.52 0.57
   128: 1.07 1.00 0.95
  1024: 1.54 1.83 0.91
  4096: 1.87 1.95 0.24
  8192: 5.64 10.06 0.95

可以明显看到,最终实现的性能非常稳定,不会因数组长度增加而急剧地碰撞;而且让我挺惊讶的是,这个实现居然比for循环还快(虽然差异不高,基本持平)。

结论

综合看下来,还是for循环最好, 性能不差,而且直观——最笨往往是最好的;但是递归和尾调用,在拆解比较复杂的逻辑的场景,能在不影响效率的前提下,很大程度抽象出代码逻辑(比如迭代器);希望这篇文章可以给您带来这方面的启发

posted @ 2020-12-15 15:53  🍉二二二几  阅读(397)  评论(0)    收藏  举报