使用lua过程中需要注意事项

最近一段时间写了不少lua代码,由于这一次的代码量比以往都多,因此也就遇到了以前没有遇到问题, 需要说明的是,这就是一篇lua问题大集合, 并不涉及其他。

先说说遇到的问题及解决方案。

在lua中没有多余的数据结构,全靠一张表,这也是他简洁的根本所在。

当索引为全为数字时即可当数组使用,然而需要注意的是lua假设数组的索引一定是连续的,在此基础上lua尽可能快的优化了取数据长度的算法,因此lua对‘#’操作符的描述是“当table[i]不为nil, 而table[i+1]为nil时,则数组的长度为i”.

因此如果table = {1, nil, 2, 3}, 那么使用#table得到的结果会是1.因为table[1]不为nil, 而table[1+1]为nil。其实这一点在”Programming in Lua”是有明确说明的,只不过在写代码时我忘记了。

如果想删除数组中的元素而又保证#table的正确性,可以使用table.remove函数来删除指定位置的值,table.remove所做的事情其实就是把所有元素向前移动一位,因此在使用table.remove时一定要估算好移动元素带来的开销是否超出了预期。如果超出了预期就要考虑其他方案,而其他方案也与‘#’引起的问题没有关系了。

‘#’的语义带来的麻烦还不仅仅于此。考虑下面一段代码:

        function test1(...)
                local param = {...}
                local p1, p2, p3, p4 = table.unpack(param)
        end

当调用test(1, nil, 2, 3)之后,得到的结果是p1 = 1, p2 = nil, p3 = nil, p4 = nil。

之所以这样是因为table.unpack(param) 等价于table.unpack(param, 1, #param), 而’#’的语义导致了#param得到的结果是1,所以table.unpack其实只unpack出了第一个值而已。

即然存在此类问题,显然也存在着解决方案。lua的table库中提供了一个函数table.pack。table.pack(…)与{…}惟一不一样的地方就是,table.pack会自动识别…中有几个参数,并把这个值赋给返回的表中的”n”字段。

所以上述代码要想支持传递nil就必须改成如下:

        function test1(...)
                local param = table.pack(...)
                local p1, p2, p3, p4 = table.unpack(param, 1, param.n)
        end

再说说优化问题。

3R在很多文章上都有提到,就不再多说什么了,但是对于Reuse需要注意的是,并不是所有的值都值得Reuse,需要考虑这个表的Reuse的代价有多大。

考虑一个表从产生到销毁的过程(不包含对元表的__gc函数的调用过程)。

local xx = {} 就代表着要构造一个新表,这就代表要调用malloc, 当这个表不再被使用时,就会被gc回收free掉。

如果我们可能重用xx表,那么就可以省去malloc/free的开销,要知道malloc的开销是相当大的。

但是这里需要注意的一个问题是,xx已经有了预分配槽位,假设之前xx表中被设置过xx[1]~xx[100]个数据,那么就意味着重用xx表后,向xx[1]~xx[100]的位置赋值是是不会触发malloc进行扩容的。

之所以说这是一个问题而不是一个优点,是因为在不同的情况下,这种重用有时有利有时有害。这取决于被重用的xx表的用途,如果xx几乎是固定大小,那么显然这种不需要扩容就是有利的,反之如果xx大部分情况下都很小,就会浪费很多空间。

当重用的表是一个模块的upvalue时,还需要考虑的是当你为这个表的每一个槽位都赋为nil的代价与构造一个表的代价哪个更高,如果无法评估则应该选取语言更简单的模式“直接构造一个表”。

next函数常用来进行无序遍历整张表,同样你必须知道他的底层实现,才能够很好的使用它。从lua源码中可以找到next函数具体实现,当传递给next的key为nil时,事实上next函数会从table的第一个空槽位开始遍历,直接找到第一个值为非nil的key并返回。因此在使用next进行遍历时,必须对整个table中的布局做到心中有数。

考虑一种极端情况,有一个table有1M个数组空间,只有在索引接近1M时,值才不为nil,其他值都为nil,如果这时候需要频繁的调用next来遍历整个表,势必会造成程序性能急剧下降。这种情况下就需要在表的基础上构造出相应的数据结构来提高程序性能,比如记录第一个非nil的起始索引。

在lua中有很多基本库和函数是存在于_ENV表中,比如table库,math库等,如果调用这些函数很频繁,可以考虑将函数局部化,能显著提高程序性能。

下面对比一下两段代码,和编译后的OPCODE

--使用全局函数
local tbl = {}
for i = 1, 100 do
        table.insert(tbl, i)
end
--OPCODE
main <a.lua:0,0> (12 instructions at 0x7fc748c043f0)
0+ params, 8 slots, 1 upvalue, 5 locals, 4 constants, 0 functions
	1	[1]	NEWTABLE 	0 0 0
	2	[3]	LOADK    	1 -1	; 1
	3	[3]	LOADK    	2 -2	; 100
	4	[3]	LOADK    	3 -1	; 1
        --for i = 1, 100 do
	5	[3]	FORPREP  	1 5	; to 11
	6	[4]	GETTABUP 	5 0 -3	; _ENV "table"
	7	[4]	GETTABLE 	5 5 -4	; "insert"
	8	[4]	MOVE     	6 0
	9	[4]	MOVE     	7 4
	10	[4]	CALL     	5 3 1
	11	[3]	FORLOOP  	1 -6	; to 6
        -- end
	12	[5]	RETURN   	0 1

--全局函数局部化

local tinsert = table.insert
local tbl = {}
for i = 1, 100 do
        tinsert(tbl, i)
end
--OPCODE
main <b.lua:0,0> (13 instructions at 0x7fb5a94043f0)
0+ params, 9 slots, 1 upvalue, 6 locals, 4 constants, 0 functions
	1	[1]	GETTABUP 	0 0 -1	; _ENV "table"
	2	[1]	GETTABLE 	0 0 -2	; "insert"
	3	[2]	NEWTABLE 	1 0 0
	4	[3]	LOADK    	2 -3	; 1
	5	[3]	LOADK    	3 -4	; 100
	6	[3]	LOADK    	4 -3	; 1
        -- for i = 1, 100 do
	7	[3]	FORPREP  	2 4	; to 12
	8	[4]	MOVE     	6 0
	9	[4]	MOVE     	7 1
	10	[4]	MOVE     	8 5
	11	[4]	CALL     	6 3 1
	12	[3]	FORLOOP  	2 -5	; to 8
        -- end
	13	[5]	RETURN   	0 1

对比可以看出使用局部变量tinsert代替table.insert之后在for循环内少了两条对table的操作(GETTABUP, GETTABLE) 效率大概提高了10%

当语言内置操作和标准库一样时,应该优先选择语言内置操作。其实这很好理解语言内置操作执行一起一般比调用标准库要生成更少的OPCODE,在功能一样的情况下一般OPCODE数目越少,应该就越高效才对。
在循环100W次的情况下table[#table+1]=i要比table.insert(table, i)快30%左右。

btw, 在看lvm.c的过程中,越来越觉得脚本语言是比C/C++更高层次的抽象,不过也只有这样才能达到简少代码的目的。然而高层次抽象就意味着我们失去了很多优化的能力。



发表评论