Lua數據文件和序列化


    在處理數據文件時,寫數據通常比讀數據簡單很多。當向一個文件中寫時,我們擁有絕對的控制權;但是,當從一個文件中讀時,我們並不知道會讀什麼東西。一個健壯的程序除了能夠處理一個合法文件中所包含的所有類型的數據外,還應該能夠優雅地處理錯誤的文件。因此,編寫一個健壯的處理輸入的程序總是比較困難的。
    Lua語言自1993年發佈以來,其主要用途之一就是描述數據。在那個年代,主要的文本數據描述語言之一是SGML。對於很多人來說,SGML既臃腫又複雜。在1998年,有些人將其簡化成XML,但以我們的眼光看仍然臃腫又複雜。有些人跟我們的觀點一直,進而在2001年開發了JSON。JSON基於JavaScript,類似於一種精簡過的Lua語言數據文件。一方面,JSON的一大優勢在於它是國際標準,包括Lua語言在內的多種語言都具有操作JSON文件的標準庫。另一方面,Lua語言數據文件的讀取更加容易和靈活。
    使用一門全功能的編程語言來描述數據確實非常靈活,但也會帶來兩個問題。問題之一在於安全性,這是因爲“數據”文件能夠肆意地在我們的程序中運行。我們可以通過沙盒中運行程序來解決這個問題。
    另一個問題是性能問題。Lua語言不僅運行得快,編譯也很快。例如,在筆者的新機器上,Lua5.3可以在4秒以內,佔用240MB內存,完成1000萬條賦值語句的讀取、編譯和運行。作爲對比,Perl5.18需要21秒、佔用6GB內存,Python2.7和Python3.4直接崩潰,Node.js0.10.25在運行8秒後拋出“內存溢出”異常,Rhino1.7在運行6分鐘後也拋出了“內存溢出”異常。

數據文件

    對於文件格式來說,表構造器提供了一種有趣的替代方法。只需在寫入數據時做一點額外的工作,就能使得讀數據變得容易。這種技巧就是將數據文件寫成Lua代碼,當這些代碼運行時,程序也就把數據重建了。使用表構造器時,這些代碼段看上去會非常像是一個普通的數據文件。
    下面通過一個示例來進一步展示處理數據文件的方式。如果數據文件使用的是諸如CSV或XML等預先定義好的格式,那麼我們能夠選擇的方法不多。不過,如果處理的是處於自身需求而創建的數據文件,那麼就可以將Lua語言的構造器用於格式定義。此時,我們把每條數據記錄表示爲一個Lua構造器。這樣,原來類似

Donald E. Knuth,Literate Programming,CSLI,1992
Jon Bentley,More Programming Pearls, Addison-Wesley,1990

的數據文件就可以改爲:

Entry{"Donald E. Knuth","Literate Programming","CSLI",1992}
Entry{"Jon Bentley","More Programming Pearls","Addison-Wesley",1990}

請注意,Entry{code}與Entry({code})是相同的,後者以表作爲唯一的參數來調用函數Entry。因此,上面這段數據也是一個Lua程序。當需要讀取該文件時,我們只需要定義一個合法的Entry,然後運行這個程序即可。例如,以下代碼用於計算某個數據文件中數據條目的個數:

local count = 0
function Entry() count = count + 1 end
dofile("data")
print("number of entries:" .. count)

下面的程序獲取某個數據文件中所有作者的姓名,然後打印出這些姓名:

local authors = {}		-- 保存作者姓名的集合
function Entry (b) authors[b[1]] = true end
dofile("data")
for name in pairs(authors) do print(name) end

請注意,上述的代碼段中使用了事件驅動的方式:函數Entry作爲一個回調函數會在函數dofile處理數據文件中的每個條目時被調用。
    當文件的大小並不是太大時,可以使用鍵值對的表示方法:

Entry{
	author = "Donald E. Knuth",
	title = "Literate Programming",
	publisher = "CSLI",
	year = 1992
}
Entry{
	author = "Jon Bentley",
	title = "More Programming Pearls",
	year = 1990,
	pyblisher = "Addison-Wesley"
}

這種格式是所謂的自描述數據格式,其中數據的每個字段都具有一個對應其含義的簡略描述。自描述數據比CSV或其他壓縮格式的可讀性更好;同時,當需要修改時,自描述數據也已於手工編輯;此外,自描述數據還允許我們在不改變數據文件的情況下對基本數據格式進行細微的修改。例如,當我們想要增加一個新字段時,只需要對讀取數據文件的程序稍加修改,使其在新字段不存在時使用默認值。
    此時,字段的次序就無關緊要了。即使有些記錄沒有作者字段,我們也只需要修改Entry函數:

function Entry(b)
	authors[b.author or "unknown"] = true
end

序列化

    我們常常需要將某些數據序列化/串行化,即將數據轉換爲字節流動或字符流,以便將其存儲到文件中或者通過網絡傳輸。我們也可以將序列化後的數據表示爲Lua代碼,當這些代碼運行時,被序列化的數據就可以在讀取程序中得到重建。
    通常,如果想要恢復一個全局變量的值,那麼可能會使用形如varname = exp這樣的代碼。其中,exp是用於創建這個值的Lua代碼,而varname是一個簡單的標識符。接下來,讓我們學習如何編寫創建值的代碼。例如,對於一個數值類型而言,可以簡單地使用如下代碼:

function serialize(o)
	if type(o) == "number" then
		io.write(tostring(o))
	else other cases
	end
end

    不過,用十進制格式保存浮點數可能損失精度。此時,可以利用十六進制格式來避免這個問題,使用格式"%a"可以保留被讀取浮點型樹洞額原始精度。此外,由於從Lua5.3開始就對浮點類型和整數類型進行了區分,因此通過使用正確的子類型就能夠恢復它們的值:

local fmt = {integer = "%d",float = "%a"}
function serialize(o)
	if type(o) == "number" then
		io.write(string.format(fmt[math.type(o)],o))
	else other cases

對於字符串類型的值,最簡單的序列化方式形如:

if type(o) == "string" then
	io.write("'",o,"'")

不過,字符串包含特殊字符,那麼結果就會是錯誤的。
也許有人會告訴讀者通過修改引號來解決這個問題:

if type(o) == "string" then
	io.write("[[",o,"]]")

這裏,要當心代碼諸如!如果某個惡意用戶設法使讀者的程序保存了形如"]]..os.execute('rm *')..[["這樣的內容,那麼最終被保存下來的代碼將變成:

varname = [[]] .. os.execute('rm *')..[[]]

一旦這樣的“數據”被加載,就會導致意想不到的後果。
    我麼可以使用一種安全的方法來括住一個字符串,那就是使用函數string.format的"%q"選項,該選項被設計爲一種能夠讓Lua語言安全地反序列化字符串的方式來序列化字符串,它使用雙引號括住字符串並正確地轉義其中的雙引號和換行符等其他字符。

a = 'a "problematic" \\ string'
print(string.format("%q",a))		--"a \"problematic\" \\ string"

通過使用這個特行,函數serialize將變爲:

function serialize(o)
	it type(o) == "number" then
		io.write(string.format(fmt[math.type(o)],o))
	elseif type(o) == "string" then
		io.write(string.format("%q",o))
	else other cases
	end
end

    Lua5.3.3對格式選項"%q"進行了擴展,使其也可以用於數值、nil和Boolean類型,進而使它們能夠正確地被序列化和反序列化。因此,從Lua5.3.3開始,我們還能夠再對函數serialize進行進一步的簡化和發展:

function serialize(o)
	local t = type(o)
	if t == "number" or t == "string" or t == "boolean" or t == "nil" then
		io.write(string.format("%q",o))
	else other cases
	end
end

    另一種保存字符串的方式是使用主要用於長字符串的[=[...]=]。不過,這種方式主要是爲不用改變字符串常量的手寫代碼提供的。在自動生成的代碼中,像函數string.format那樣使用"%q"選項來轉義有問題的字符更加簡單。
    儘管如此,如果要在自動生成的代碼中使用[=[...]=],那麼還必須注意幾個細節。首先,我們必須選擇恰當數量的等號,這個恰當的數量應比原字符串中出現的最長等號序列的長度大1.由於在字符串中出現長等號序列很常見,因此我們應該把注意力集中在以方括號開頭的等號序列上。其次,Lua語言總是會忽略長字符串開頭的換行符,要解決這個問題可以通過一種簡單方式,即總是在字符串開頭多增加一個換行符。

示例: 引用任意字符串常量

function quote(s)
	--尋找最長等號序列的長度
	local n = -1
	for w in string.gmatch(s,"]=*") do
		n = math.max(n,#w - 1)     -- -1用於移除']'
	end

	-- 生成一個具有'n'+1個等號的字符串
	local eq = string.rep("=",n + 1)

	-- 創建被引起來的字符串
	return string.format(" [%s[\n%s]%s " , eq,s,eq)
end

    該函數可以接收任意一個字符串,並返回按長字符串對其進行格式化後的結果。函數gmatch創建一個遍歷字符串s中所有匹配模式’]=*'之處的迭代器(即右方括號後跟零個或多個等號)。在每個匹配的地方,循環會用當前所遇到的最大等號數量更新變量n。循環結束後,使用函數string.rep重複等號n+1次,也就是生成一個比原字符串中出現的最長等號序列的長度大1的等號序列。最後,使用函數strig.format將s放入一對具有正確數量等號的括號中,並在字符串s的開頭插入一個換行符。

保存不帶循環的表

    接下來,更難一點的需求是保存表。保存表有幾種方法,選用哪種方法取決於對具體表結構的假設,但沒有一種算法使用與所有的情況。對於簡單的表來說,不僅可以使用更簡單的算法,而且輸出也會更簡潔和清晰。

示例:不使用循環序列化表

function serialize(o)
	local t = type(o)
	if t == "number" or t == "string" or t == "boolean" or t == "nil" then
		io.write(string.format("%q",o))
	elseif t == "table" then
		io.write("{\n")
			for k,v in pairs(o) do
				io.write("",k," = ")
				serialize(v)
				io.write(",\n")
			end
		io.write("}\n")
	else
		error("cannot serialize a " .. type(o))
	end
end

    儘管這個函數很簡單,但它卻可以合理地滿足需求。只要表結構是一棵樹,那麼該函數甚至能處理嵌套的表。
    上例中的函數假設了表中的所有鍵都是合法的標識符,如果一個表的鍵是數字或者不是合法的Lua標識符,那麼就會有問題。解決該問題的一種簡單方法是像下列代碼一樣處理每個鍵:

io.write(string.format(" [%s] = ",serialize(k)))

經過這樣的修改後,我們提高了該函數的健壯性,但卻犧牲了結果文件的美觀性。考慮如下的調用:

serialize{a = 12, b = 'Lua',key = 'another "one"'}

第1版的函數serialize會輸出:

{
a = 12,
b = 'Lua'
key = "another \"one\"",
}

與之對比,第2版的函數serialize則會輸出:

{
["a"] = 12,
["b"] = "Lua",
["key"] = "another \"one\"",
}

通過測試每個鍵是否需要方括號,可以在健壯性和美觀性之間得到平衡。

保存帶有循環的表

    由於表構造器不能創建帶循環的或共享子表的表,所以如果要處理表示通過拓撲結構的表,就需要採用不同的方法。我們需要引入名稱來表示循環。因此,下面的函數把值外加其名稱一起作爲參數。另外,還必須使用一個額外的表來存儲已保存表的名稱,以便在發現循環時對其進行復用。這個額外的表使用此前已被保存的表作爲鍵,以表的名稱作爲值。

示例:保存帶有循環的表

function basicSerialize(o)
	-- 假設'o'是一個數字或字符串
	return string.format ("%q",o)
end

function save (name,value,saved)
	saved = saved or {}			-- 初始值
	io.write(name," = ")
	if type(value) == "number" or type(value) == "string" then
		io.write(basicSerialize(value),"\n")
	elseif type(value) == "table" then
		if saved[value] then				-- 值是否被保存?
			io.write(saved[value],"\n")		-- 使用之前的名稱
		else
			saved[value] = name		-- 保存名稱供後續使用
			io.write("{}\n")		-- 創建新表
			for k,v in pairs(value) do -- 保存表的字段
				k = basicSerialize(k)
				local fname = string.format("%s[%s]",name,k)
				save(fname,v,saved)
			end
		end
	else
		error("cannot save a " .. type(value))
	end
end

    我們將設要序列化只使用字符串或數值作爲鍵。函數basicSerialize用於對這些基本類型進行序列化並返回序列化後的結果,另一個函數save則完成具體的工作,其參數saved就是之前所說的用於存儲已保存表的表。例如,假設要創建一個如下所示的表:

a = {x = 1, y = 2;{3,4,5}}
a[2] = a -- 循環
a.z = a[1]	-- 共享子表

調用save(“a”,a)會將其保存爲:

a = {}
a[1] = {}
a[1][1] = 3
a[1][2] = 4
a[1][3] = 5

a[2] = a
a["y"] = 2
a["x"] = 1
a["z"] = a[1]

取決於表的遍歷情況,這些賦值語句的實際執行順序可能會有所不同。不過儘管如此,上述算法能夠保證任何新定義節點中所用到節點都是已經被定義過的。
    如果想保存具有共享部分的幾個表,那麼可以在調用函數save時使用相同的表saved函數,例如,假設有如下兩個表:

a = {{"one","two"},3}
b = {k = a[1]}

如果以獨立的方式保存這些表,那麼結果中不會有共同的部分。不過,如果調用save函數時使用同一個表saved,那麼結果就會共享共同的部分:

local t = {}
save("a",a,t)
save("b",b,t)

-- a = {}
-- a[1] = {}
-- a[1][1] = "one"
-- a[1][2] = "two"
-- a[2] = 3
-- b = {}
-- b["k"] = a[1]

    在Lua語言中,還有其他一些比較常見的方法。例如,我們可以保存一個值時不指定全局名稱而是通過一段代碼來創建一個局部值並將其返回,也可以在可能的時候使用列表的語法等等。Lua預壓給我們提供了構建這些機制的工具。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章