內置數據結構 table
很多語言都會設計一種通用數據結構,最常見的是數組。對於 C 語言來說,使用數組及其工具指針,可以構造出非常多其他種類的數據結構,譬如鏈表、二叉樹、哈希表等等。而 PHP 則自帶一種通用的數據結構,也叫數組,但實際上是一種通用的哈希表。一個自帶內存管理的 key-value 類型的數據結構,其實可以解決非常多的編程實際問題。所以 Lua 也提供了這種能力,就是 table 類型的變量。
靈活的下標
table 類型的變量可以做很多事情,譬如它可以是“數組”:
a = {2, 4, 6, 8} print(a[1], a[2]) --> 顯示:2 4
Lua 沒有用大括號用來標記代碼塊,留著用來表示 table 的常量了。上面這個代碼記錄了一個類似數組或者鏈表,需要注意的是 Lua 中數組的第一個元素是從 1 開始計算的,所以 a[1] 表示取第一個元素。我們用中括號 [] 來讀取 table 的元素,table 不但可以用數字作為下標來讀取元素,還可以用其他的值,譬如字符串,這種情況下 table 又類似了“哈希表”:
p = {a=1, b=3, c=9}print(a["b"]) --> 顯示:3 p.d = 10 print(p.a) --> 顯示:9 print(p["d"]) --> 顯示:10
Lua table 如果以字符串作為下標進行初始化,甚至連雙引號都不需要;而且能用 . 的寫法來代替["xx"]。這種 . 的寫法也很類似於其他語言的面向對象語法的“取成員”符號——這也為 Lua 的面向對象特性提供了支持。
Lua 的 table 實際上是一個 key-value 對(pair)的集合,所以我們可以隨意的添加任何類型值的 key 和 value “記錄”到一個 table 裡。
k = {} k[2]="hello" k["haha"]="world" print(k[2],k["haha"]) --> 顯示:hello world
遍歷 table
雖然 table 可以用下標來賦值和讀取內容,但作為一種“key-value 表”,肯定是不夠的。Lua 也提供了一批功能更完全的函數,幫我們做更多的事情。我們先來看看如何遍歷一個 table,循環語法中的 for 專門為了這個設計了一個寫法,以及一個專門的內置函數 pairs():
a={} a[1]="one" a["second"]="two" a[78]="three" a["last"]="four" for k,v in pairs(a) do print(k,v) end
運行結果是:
1 onelast four 78 three second two
用 for k,v in pairs(...) do 的寫法,可以很方便的遍歷並且提取整個 table 的內容。在早期的一些語言中,沒有把 for 和語言常用的數據結構結合,只能通過下標來操作數據結構,代碼寫起來就非常麻煩。如果要遍歷一個哈希表之類的結構,先要獲得一個哈希表的“迭代器”對象,然後通過調用迭代器身上的方法(或者迭代函數)才能遍歷。由於 table 的下標可以是各種類型的值,所以要簡單的獲得 table 的記錄總數,一般只能通過遍歷來計算,而沒有什麼特別簡單的方法。
由於 Lua 中的變量賦值為 nil 就是刪除,所以 table 中,對一個下標賦值為 nil 也是刪除。由於在 table 中的記錄,都是會佔用內存的,所以不用的記錄一定要及時“刪除”,否則就會造成內存洩漏。
列表形 table
雖然 Lua 的 table 下標可以是任何的數據類型,但如果這些下標是從 1 開始的連續數字,我們就能以“列表”(list)的方法來使用 table:
- 用 # 來獲得長度。需要特別注意不要用賦值 nil 來刪除列表裡的元素,否則#的結果是不準確的。
- 用 for i,v in ipairs(...) do 來進行順序遍歷
- 用 table.insert(tb, pos, var) 來追加和插入元素
- 用 table.remove(tb, pos) 來刪除元素
- 用 table.sort(tb) 來排序,這個尤其有用,甚至可以用來排序漢字(因為漢字的內碼是按拼音順序設計的)
你可以用上面的函數來操作一個混合著數字 key 和字符串 key 的 table,但是上的函數只會對數字 key 部分生效。所以最好還是隻對那些 “只有數字 key,並且從 1 開始” 的 table 使用這些函數。
一點小結
- 符號 # 在其他語言裡常常是單行註釋符號,Lua 用來作為長度運算符了。
- 符號 // 在其他語言裡面也常常是單行註釋符號,Lua 用來作為整除運算符了(Lua5.3之後支持)。所以 Lua 用了 -- 作為註釋符號。
- Lua 也沒有遞增 ++ 和遞減 -- 運算符。其實這兩個運算符在 a = b++ 這種寫法下,運行的結果比較反直覺,所以Python 也不支持。
- Lua 的 table 用起來和 PHP 的數組還挺像的,都是挺“萬能”的。
面向對象
面向對象曾經是軟件業最流行的思潮,很多語言為了跟上這個思潮,都設計了所謂面向對象的語法支持。所有這些語法支持,最核心需要完成的任務有以下幾點:
- 封裝:就是支持一種叫對象的變量,這個變量是可以同時攜帶屬性值(成員變量)和方法(成員函數)的。開發者不但可以通過對象讀寫其攜帶的屬性,還可以調用對象所攜帶的方法。很多語言還設置了類似 this 或者 self 這種關鍵字,以便在方法中調用攜帶此方法的對象變量。
- 繼承:支持對象的屬性和方法定義可以被重用,可以讓一類對象的定義,通過繼承語法自動帶上另外一類對象的定義,從而避免大量的重複而類似的定義。
- 多態:支持多個不同類型(定義)的對象,只要它們都滿足其中一種類型定義,就可以“作為”這類對象來使用。這種特性最常用於取代複雜的 switch...case 語法,做法就是為不同類型的對象,都定義同一個名字的方法,這些不同類型的對象,都可以被調用這個相同名字的方法,但執行的代碼則會被自動調用那些應該屬於那種類型的方法。等於自動以對象的類型為 switch 的選擇,每個對象的方法為 case 的代碼段。
為了完成上面的幾種任務,不同的語言使用了不同的設計。JAVA/C++/C#/Python 這類語言選擇的是所謂“對象模板”的方案,其特徵就是語言中會有 class 關鍵字。開發者通過定義各種 class 來設計對象的內容,在運行時通過這些 class 實例化出很多對象來使用。這種做法的好處是,class 的設計非常明確且固定,在複雜的邏輯下比較不容易寫錯代碼。但是缺點是使用這些 class 的時候比較死板和囉嗦,每個對象都必須和他的“模板”一模一樣,如果有任何一點不同,就需要定義一個新的 class,如果不仔細設計,很容出現所謂“類爆炸”的情況——一個程序裡面定義的 class 實在太多了,導致開發者已經分辨不過來了。這種設計對於“多態”的實現上,尤其複雜,為了讓不同“類”的對象能作為同一個類來使用,就必須運行對象同時繼承多個類,而同時繼承多個類又比較容易出現邏輯衝突,還要設計類似“接口”這種特殊的,專門用於多態的類,總之就是越搞越複雜。
而腳本語言,因為天生就有動態對象的能力,可以在運行時對內存中的對象進行各種修改,所以好像 JavaScript 和 Lua 這種語言,採用了另外一種面向對象語義的實現方法:“原型鏈”。在這類語言中,一般會有關鍵字 object,但不會有 class
。如果要構造一個對象,就直接聲明一個 object 變量就好;這個對象需要有什麼成員屬性,就直接添加上去就好;方法也可以通過添加一個 function 類型的成員一樣加上去。繼承要怎麼辦呢?用一個對象充當“元對象”添加上去,當前對象找不到的成員,就去這個“元對象”身上找。元對象自己也可以掛一個元對象,這樣就實現了多層次的“繼承”了。
Lua 的 table 完全有能力通過“原型鏈”的方式,來實現面向對象的使用方法。Lua 的面向對象實現,最核心就是它的 metatable 能力。所謂的 metatable,意思就是“元表”,它能讓開發者,對任何一個 Lua 的 table 變量,通過代碼來自定義各種行為,包括:
除了上面這些,還有 ^ // & | ~ << >> # 等各種運算符的重定義。具體如何自定義 table 對象的運算符,可以查詢 Lua 的元表和元函數手冊。但是這裡面,有一個最常用,也是對面向對象最重要的“元表”成員:__index。
通過原型獲得屬性
一旦為一個 table 變量,設置了包含 __index 成員的元表,那麼所有的 table[key] 操作,都會去調用 __index 的值對象——你用一個 table 作為原型對象來提供“被繼承對象(父類)”的屬性,也可以寫一個函數來返回 key 對應屬性的值。
father = { last_name="Han", first_name="Da" } --> 被繼承的父對象 son = {first_name="Xiao"} --> 子對象 meta_table = {__index=father} --> 建立去 father 查找成員的元表 setmetatable(son, meta_table) --> 設置 son 對象的元表,以便通過 father 查找未知屬性 print(son.first_name, son.last_name) --> Xiao Han
方法:table 身上的函數
面向對象的屬性,可以很方便通過 Lua 的 table 的 key-value 對來實現。而面向對象的方法,從原理上來說,其實也可以通過 table 的 key-value 記錄,只不過 key 是方法名,而 value 是一個 function 類型的變量罷了。
obj = {} obj.method = function(a, b) return a+b end result = obj.method(1, 2) print(result) --> 顯示 3
然後,上面這種方法,並不能稱為真正的“方法”,因為攜帶方法的對象,並不能在方法代碼裡被使用。一個簡單的做法,就是為作為方法的函數,設置一個參數,然後我們把對象傳進去就好了。
obj = {a=1} obj.method = function(my_obj, b) return my_obj.a + b end print(obj.method(obj, 2)) --> 顯示 3
由於“方法”這麼寫的話有點囉嗦:調用方法的時候,對象變量要寫兩遍;定義方法的時候,需要增加一個參數代表本對象——所以 Lua 提供另外一種等價的寫法,但是看起來就簡潔很多了:
obj = {a=1} function obj:method(b) --> 定義方法用冒號 : 會自動加上 self 參數作為第一個參數 return self.a + b end print(obj:method(2)) --> 調用方法用冒號 : 會自動傳入本對象 obj 作為第一個參數
上面這種寫法,用了一個冒號 :,就代替了把對象變量寫到函數參數裡,而且使用了固定的 self 來代替本對象。一切看起來就和其他面嚮對象語言一樣,只是需要注意:定義和使用方法用 :,使用屬性用 .
obj.a 等價於 obj["a"]
obj.method(obj, 2) 等價於 obj:method(2)
這個語法糖,比 Python 的要甜,Python 要我不厭其煩的寫 self 參數
構造器
通過原型鏈的思想來構造對象,我們一般會設計一個“原型對象”,代表這類對象的初始狀態。然後通過複製這個原型對象來構造實際的使用對象。由於有了上面介紹的“方法”語法糖,我們完全可以寫一個類似其他語言的“構造函數”來做這個事情。
-- 定義 Person 原型對象(類) Person = { Name = "default", Age = 18 }-- 構造器函數 function Person:New(name) local new_obj = {} setmetatable(new_obj, {__index = self}) new_obj.Name = name --> 此處對 new_obj 插入了屬性 Name,不再需要從元表查找此屬性了 return new_obj end function Person:Show() print(self.Name, self.Age) end me = Person:New("HanDa") --> 通過原型Person對象,構造一個使用對象 me:Show() --> 通過原型Person對象的Show()方法打印屬性:HanDa 18
在這個例程中,通過設置元表的 __index table,讓新對象和原型對象“鏈接”了起來。對於屬性來說,如果構造新對象的時候,有賦值原型對象的同名屬性,那麼這個記錄,就直接存在了新對象身上,因此不會去再影響原型對象;如果一個原型對象的屬性沒有在構造新對象時賦值,那麼後面對此屬性的訪問,就會訪問到原型對象上。——所以也需要注意,非必要時不要在運行時修改原型對象的屬性值,那會讓很多以它為原型的對象的行為一起發生改變。除非你確定需要通過這種手段來“熱更新”你的程序。
繼承
面向對象的繼承,在 Lua 這裡一般是一個原型對象“繼承”另外一個原型對象。用的方法和上面的構造類似,也是通過設置元表的 __index 屬性,來組裝原型鏈。
-- Person 原型對象的定義 -- Person = { Name = "default", Age = 18 } function Person:New(name) local new_obj = {} setmetatable(new_obj, {__index = self}) new_obj.Name = name --> 此處對 new_obj 插入了屬性 Name,不再需要從元表查找此屬性了 return new_obj end function Person:Show() print(self.Name, self.Age) end -- Student 原型對象繼承於 Person -- Student = Person:New("default student") Student.School = "211or985" -->添加自己的屬性 function Student:New(name, school) local new_obj = {} setmetatable(new_obj, {__index = self}) new_obj.School = school new_obj.Name = name --> 覆蓋掉Student原型的默認值,此處無法通過調用 Persion:New() 來進行覆蓋 return new_obj end function Student:Show() print(self.Name, self.Age, self.School) end me = Student:New("HanDa","三本") --> 通過原型Student對象,構造一個使用對象 me:Show() --> 通過原型Person對象的Show()方法打印屬性:HanDa 18 三本
在其他的面嚮對象語言中,在子類構造器函數中,調用父類構造器,用來構造完整的對象,都是一個不容易的過程——C++ 使用了專門的語法稱為“初始化列表”,Java 則在構造器中以“聲明”的方式調用父類構造器,但調用的實際時間在子類構造器之前。而以上面的 Lua 為例,如果要想要通過父類(原型)的 New() 方法來構造子類(原型)的對象,就需要構造出很多不同的子類原型對象來,這顯然是把問題複雜化了的。所以最好的方法,是不要在我們視為構造器的 New() 方法中寫太多的邏輯,最多就是進行一些屬性的賦值就好——因為子類原型對象的 New() 可能無法對每個子類對象調用此父類 New()。如果一個對象,在使用之前真的有很多屬性需要專門的計算和設置,那就不要放到 New() 裡面,而是專門寫一個 Class.Init(self) 的方法來初始化,這樣子類對象就可以手工的去調用這種方法了。
function Person:Init(name) self.Name = name end me = Student:New("Anything", "大專") me:Init("HanDa") <--此處調用父類原型對象的 Init() 方法來設置自己 me:Show() --> HanDa 18 大專
如果子類原型對象也有一個 Init() 方法,那麼可以通過 Parent.Init(self, ...) 的寫法,通過小數點 . 的方法,去調用父類原型對象的初始化方法。
反思
原型鏈方式實現面向對象的語義,如果不考慮“繼承”這個特性,而是採用“組合”的方式來複用原型對象,那麼其實會更加簡單。元表的 __index 屬性可以是一個函數,通過定義這個函數,我們可以組合很多其他的對象,讓一個對象複用大量其他已有對象的功能。所以即便 Lua 的繼承有很多種不同的寫法,但我們也可以完全不去實現所謂的“繼承”,直接通過組合對象和元表定義來實現面向對象的代碼複用。
模板和泛型
模板和泛型一般是對強類型語言有用,就是為了讓一批不同的類,都能被開發者以類似的方法所使用的技術。開發者定義了模板,這個模板可以通過使用時結合不同的類型,在編譯時生成各種不同的具體模板類,但是這些具體的類,都會以類似的方法工作。——這種情況最適合就是各種容器數據結構了,譬如鏈表、哈希表、二叉樹這些,因為這些數據結構存放的數據,往往是不同數據類型的。但是對於 Lua 這種動態類型語言了,基本上可以不管這些,因為 table 裡面可以放各種數據類型的值,你如果想做一個什麼容器,肯定也是用 table 來做的。所以 Lua 在這一部分基本上就跳過吧。當然也有人會研究 for 循環的“泛型迭代器”的細節,其實也是很好的。不過實際開發中我們很少需要自己定義迭代器就是了。
內存管理/垃圾收集
有些語言要求用戶手工進行內存管理,所以每個變量都會需要選擇是建立在“棧”還是“堆”上,並且用戶寫代碼去回收“堆”上的變量——C語言就是這種。而 Java、C# 這類語言,由於代碼是在一個“虛擬機”裡面運行的,這個“虛擬機”程序會跟蹤記錄“堆”上的變量,並且自己找時間去清理那些無用的變量,所以關鍵字裡面只有 new 而沒有 delete——這對於開發者來說尤其友好。像 Go 這種語言,則連 new 關鍵字都不存在,只要返回一個“函數內的局部變量”,也就是“棧”上的變量,就自動會編譯成堆上的變量,並且標記為需要跟蹤清理。在程序的運行中,Go 寫的程序也會自動回收“無用”的堆變量。Lua 和很多腳本語言一樣,由於是通過解析器運行,所以可以認為所有的變量,都是解析器在管理,因此也無需關注到底是“堆”變量還是“棧”變量,總之會自動進行垃圾回收就可以,所以既沒有 new 也沒有 delete。
不過 Lua 中需要注意的是“全局變量”和“局部變量”,如果我們直接聲明一個變量,那就是“全局變量”,這個變量很可能在某個其他代碼段中,因為“重名”而被修改。所以我們建議儘量使用“局部變量”——聲明的時候前面加上關鍵字:
local。
a = 10 function f() a = 11 b = 20 end f() print(a,b)
上面的代碼會打印出結果 11 20,可以看到,變量 a 作為全局變量,在函數 f() 裡面被修改了內容,而且還增加了一個全局變量 b。這對於習慣於其他語言的開發者來說,是非常“坑爹”的設計,因為其他語言變量默認都是局部的。默認是全局變量很容易導致比較大型的項目中,全局變量的名字“碰撞”到重名,導致莫名其妙的內容就被改掉了。所以請一定記住儘量多用 local 關鍵字,下面是正確示範:
a = 10 function f() local a = 11 local b = 20 end f() print(a,b) -- 打印結果:10 nil
至於為什麼 Lua 要設計“默認都是全局變量”這麼“坑”的東西呢?我覺得有一種可能,就是 Lua 的源文件,可以很方便的用來作為其他程序的“配置文件”。對於“配置文件”來說,變量就是配置項,肯定是“全局”的比較方便使用。另外,如果我們用 Lua 源文件作為某些數據的載體,譬如用來作為遊戲的腳本,本身代碼裡面的大量常量,譬如道具名稱、武器威力這些,都是全局使用的。
雖然 local 變量是在函數內獨立隔離的,但如果我們返回了一個 local 又會怎樣呢?其實不會怎樣,既不會好像 C 語言那樣出故障,也不會導致內存洩漏。因為 Lua 的變量大部分類型都可以直接複製其數值,所以就是複製了一下而已。而可能存放大量數據,而且成員複雜的變量類型 Table,返回的只是 Table 變量的“引用”,並不會整個 Table 裡面的成員都複製一份。而 Table 本身的數據,其實是存在類似“堆”上的,所以返回出去之後,一樣是可以使用的。
那麼 Lua 的又是如何知道哪些是垃圾需要回收的呢?其實規則很簡單:
- 所有的全局變量都不會被回收
- 所有的非 Table 類型的 local 變量,都會被回收。
- 正在被使用的 Table 變量,包括全局變量和局部變量——其內容的 key 和 value 都不會被回收,除非這個 Table 被聲明為 weak 弱引用的。
- 如果有一個變量,所有包含它的 Table 的引用(包括 key 和 value)都是弱引用,那麼它就會被回收。
簡單來說,如果你不想你的 Lua 程序內存洩漏,只需要注意:
- 儘量使用 local 局部變量
- 不要往一個全局的 Table 變量裡面無限制的加入內容
- 如果一個變量,明確已經在某個 Table 裡面包含了,那麼其他使用這個變量的其他 Table,可以考慮作為 weak 弱引用 Table 來包含它。這樣有助於減少洩漏的可能性,如果使用大量 Table 包含一個變量,這裡面任何一個 Table 不小心放到某個全局變量或者全局 Table 裡面去了,就會洩漏了。
錯誤處理
程序在運行時,總是會碰到各種各樣的“異常情況”,我們稱之為“錯誤”。代碼除了預期一切正常下,要處理的事情以外,還有非常多的代碼,用來處理各種各樣非預期情況下的事情。譬如你希望用戶輸入一個日期,但是用戶可能輸入了一段不能解析成日期的其他文字;又或者程序要從文件或者網絡讀取某些數據,作為程序處理的依據,但是文件可能被刪除了,或者是網絡斷開了。早期的語言,並沒有針對這些“錯誤”的處理,提供什麼特別的支持,所以以上問題,都需要開發者自己來解決。最常見的處理方法就是所謂“錯誤碼”:如果某個函數的運行出現了問題,就通過返回值來提示調用的程序出問題了,調用程序通過判斷這個“錯誤碼”返回值進行“錯誤處理”。隨著這種情況越來越成為代碼的“主要部分”,語言的設計者也把處理錯誤的事情放到語言設計中。
對於錯誤處理,一般需要兩個步驟:
- 發現“錯誤”後,生成一個錯誤信息,讓調用代碼知道這個情況。對於 C 語言我們常見的有返回錯誤碼,設置一個全局的 errno 變量等法子;Java 語言則設計了異常,可以通過throw關鍵字作為一種特殊的返回值拋給上層。
- 調用代碼在發生“錯誤”後,編寫一段特別的代碼來處理它。C 語言的代碼通常通過 if 判斷返回值是否為 0,來發現函數中是否發生了錯誤;Java 代碼則被要求必須編寫 try...catch 代碼來捕獲和處理拋出的異常(或者繼續拋出這些異常)。
對於 Lua 來說,錯誤處理更像 C 和 JAVA 的一種折中:
- 你可以通過 assert(expr, "err msg") 函數來進行判斷,如果表達式為 false,則會“拋出”一個 Lua 定義的錯誤;你也可以直接調用 error("err msg") 來拋出一個 Lua 錯誤。只要拋出了錯誤,Lua 程序在默認情況下,就會中斷執行,並且打印你定義的錯誤消息內容,以及錯誤發生的源碼位置,函數調用(棧)信息等等。
- 你可以通過使用 pcall(func_name, ...) 的方式來發起對一個函數的調用,如果這個函數會拋出錯誤,Lua 程序將不會中斷運行;而是讓 pcall() 返回兩個返回值:false 和 errinfo,其中 errinfo 返回值是你通過 error() 函數傳入的參數,不必是一個字符串,可以是一個 table,這樣你可以設計任意的內容以便上層調用者進行處理。如果 pcall() 調用的函數沒有拋出錯誤,那麼它返回的一個返回值會是 true,而後面的返回值則是具體調用函數的返回值。
-- 此函數會拋出錯誤 function add(a,b) assert(type(a) == "number", "a is not a number") if type(b) ~= "number" then error("b is not a number") end return a+b end -- 通過 pcall() 捕獲錯誤 rs, err = pcall(add, 1, "kk") print(rs, err) rs, err = pcall(add, "ww", 2) print(rs, err) rs, err = pcall(add, 1, 2) print(rs, err)
代碼運行結果:
false test.lua:4: b is not a number false test.lua:2: a is not a number true 3
Lua 通過 pcall() 捕獲“異常”(錯誤),其實和 JAVA 有點像:拋出的錯誤如果在上一層調用者函數沒有被 pcall() 捕獲,則會繼續往更上一層的調用者函數捕獲,直到有一個 pcall() 進行處理。如果最後都找不到處理者,Lua 虛擬機最終的處理方式,就是中斷程序並且打印錯誤信息。
運行時錯誤,作為一種特殊的“函數返回”,一直是編程語言不得不面對的問題。Lua 其實和 C++ C# 一樣,都是採用的自願處理原則,也就是程序員可以不處理,但程序最終會中斷。而 Java 採用的是強迫處理原則,如果一個函數聲明瞭可能拋出異常,調用者必須處理這些異常,否則代碼編譯不過——這個做法確實能強迫開發者編寫錯誤處理代碼,但代價就是程序寫起來費勁很多。而 Go 和 C 語言則顯得非常佛系,你可以選擇處理返回值裡面代表錯誤的變量,也可以忽略,程序依然會繼續運行,直到一個開發者無法預料的結果。這三種設計到底哪種最好,其實並無定論。你也可以在 Lua/C++/C# 這樣的語言中,使用 GO 和 C 語言的錯誤處理策略,但重要的是必須要處理各種錯誤而不是漏掉。
多任務支持
現代計算機系統,基本上都支持“同時”運行多個程序。除了操作系統上,我們可以啟動多個進程同時運行,我們編寫程序,也往往會需要在一個程序裡面處理多個事情,譬如遊戲裡面,我們需要一邊讓畫面上的角色活動,一邊等待和處理玩家的輸入;我們在加載或者處理一個大型文件內容的時候,同時需要在屏幕上顯示正在處理的進度信息;網絡服務器程序,需要同時處理多個玩家的操作等等。——所有這些需要同時運行的代碼,實際上是對計算機 CPU 的運行時間分配和管理提出了需求。如何解決這個需求,不同的計算機語言也提出了不同的方案。
通過 C 語言,我們能利用 Linux 操作系統的 fork() 等系統調用函數,比較方便生成子進程,這樣當前運行的函數,就會在兩個進程裡“同時”運行了。但是這種機制有很多問題,譬如這兩個進程運行的函數都是同一個,雖然可以通過寫代碼的方式在後續的運行中再選擇不同的代碼,但是用起來也太麻煩了點。另外,操作系統的子進程建立和銷燬,從性能上來說還有改進的餘地。所以後來也出現了 pthread 庫這種“多線程”的功能庫,讓使用者可以直接啟動任何一個需要“同時”運行的函數,到後來的 JAVA C# 以及更新版本的 C++,都對多線程進行了更好的支持。
儘管多線程概念非常容易理解,就是多個同時運行的函數,但是還是存在一些待改進的問題:
- 同時運行的多個函數,對於可以讀寫的同一塊堆內存(或者叫變量),可能造成不可預期的結果。因此誕生了很多種類的“鎖”,來試圖解決多個程序同時修改、讀取同一組變量造成的故障。
假如有一個變量存放了銀行存款餘額,有一個購買商品的函數,它們都會先讀這個變量來看是否足夠來購買商品,然後再減少這個餘額,一旦兩個購買行為同時運行,這個函數被同時調用,那麼可能都會認為此餘額是足夠的,但最後兩個購買行為一起去扣減,最後導致餘額變成負數。
- 同時運行的多個函數,也許不是性能最好的做法,因為計算機需要在這多個函數的運行環境之間反覆切換,這種切換本身也需要成本。
為了解決上述的問題,另外一個解決多任務的思路,就是不試圖去同時運行幾個函數。而是讓函數可以運行到某些代碼之後,就切換到運行其他函數,當條件滿足之後,在切換回來繼續運行。這種做法比較典型的是,當函數運行到一個需要 CPU 等待的操作的時候,譬如等待讀取網絡、等待讀取文件、等待用戶輸入這類操作的函數之後,就切換出去運行其他需要“同時”運行的函數上,直到之前在“等待”的事情有結果了,再切換回來繼續運行這些代碼。
在這種手工切換函數運行時機的概念下,這種函數運行時機,我們稱為“協程”。從“協程”切換出去的操作稱為 yield,而從其他協程或者主線程切換回來的操作,稱為 resume。在這種情況下,雖然“同時”有多個函數在運行,但始終只有一個線程在運行。程序不再是僅以 return 語句為結束退出函數運行,而是增加了一種特殊的退出手段:yield。而函數的運行也不再僅僅是從函數開始,而是通過 resume 操作,可以從最近一次 yield 的地方開始繼續運行。通過這種方法,對於上面多線程的問題,有了新的解決方法:
- 對於同時讀寫變量的問題。由於協程是可以自己寫代碼進行切換的,所以把可能出現同時操作的代碼,有意識的不要用 yield 斷開。由於始終只有一個線程在運行,只要別切換出去,變量就不會被其他程序修改。——這樣也不需要什麼鎖了。
- 由於只有一個線程在運行,協程之間切換的代價會大大降低,而且切換的代碼是手工編寫的,也提供了更細緻的調優的手段。
Lua 用的就是這種方法進行多任務的支持:
function foo() print("協同程序 foo 開始執行") local value = coroutine.yield("暫停 foo 的執行") print("協同程序 foo 恢復執行,傳入的值為: " .. tostring(value)) return "協同程序 foo 結束執行" end -- 創建協同程序 local co = coroutine.create(foo) -- 啟動協同程序 local status, result = coroutine.resume(co) print(result) -- 恢復協同程序的執行,並傳入一個值 status, result = coroutine.resume(co, 42) print(result)
用圖來說明這個最簡單協程的運行流程:
- coroutine.resume() 用來進入一個協程函數,輸入參數會成為協程函數的入參,或者 coroutine.yield() 的返回值。Lua 支持多返回值的好處這裡就看見了,多個 resume() 參數可以自然的成為 yield() 的多個返回值。
- coroutine.yield() 用來以一種特殊的 return 切出函數,其參數是 coroutine.resume() 的返回值。如果協程函數結束,return 的返回值也會以 resume() 的返回值形式提供給調用者。
在使用 Lua 的協程時,必須要注意所有的函數,都必須是非阻塞的,因為只有一個線程,所以任何一個阻塞就會把所有協程都阻塞了。因此對於網絡 IO 等操作,就要用類似 epoll 這類異步 API。同步的阻塞 API 只能在 C 語言那側啟動多線程,然後再發起回調到 Lua 這一側。
框架支持
隨著軟件項目的越來越大,很多時候我們寫的代碼,並不僅僅是給具體的用戶來使用的,而是會為其他開發者也寫很多代碼。這種提供給開發者使用的代碼,是通過代碼的各種編程接口來提供的。最簡單的就是我們寫了一個函數,然後提供開發者調用。這些函數如果有很多,我們會整理成為一個集合,稱之為“函數庫”(Library)。庫裡面的有一部分函數,是為了實現庫的功能而存在,是不希望被開發者使用的,而另外一部分,則是設計用來專門提供給開發者使用的——這些函數我們稱之為 API(Application Programming Interface)。但是現在 API 這個名詞已經被庸俗化的限定成 RESTful API 的含義了,讓我很不爽。
除了 API 以外,還有一種提供開發者使用的代碼方式,就是“框架”(Framework)。所謂框架,就是讓開發者按某個規則來寫一段代碼,放到我(框架開發者)的代碼裡面來運行。這是和 API 庫一樣歷史悠久。寫過 C/JAVA 程序的人都知道,程序的啟動都需要寫一個 main() 函數,而函數的參數就是命令行參數,返回值會被操作系統記錄為進程的結束狀態。這就是一種“框架”的設計,main() 的格式就是一種框架的接口要求,啟動進程這個過程就是框架在工作,然後調用使用者的代碼。
回調函數
最簡單的框架接口,一般就是一個約定的函數格式,包含:這個函數的參數有幾個,返回值有什麼作用。框架代碼在使用者傳入按照約定格式的函數之後,根據自己的功能要求,在合適的時機調用這個使用者的函數。這種函數也被稱為“回調函數”(Callback Function)。
C 語言裡面一般通過定義一個“函數指針”來定義這個約定的回調函數格式。在 Lua 裡,函數 function 是一種值的類型,所以可以直接像定義一個變量一樣,定義一個函數。框架接受這種 function 類型的變量值,就可以用來發起回調了。
-- 回調函數 add = function(a, b) return a+b end sub = function(a, b) return a-b end -- 框架代碼,主要功能是打印計算結果 function cal(cal_fun, a, b) print(cal_fun(a,b)) end --使用框架 cal(add, 3, 2) --> 5 cal(sub, 3, 2) --> 1
上面的例子中,add sub 兩個變量就是函數類型的變量,使用者可以定義任何的 function(a, b) 並且有一個返回值的函數,來構造一個這樣的變量,用作參數傳入框架 cal() 中使用。
反射
雖然我們可以讓使用者,通過代碼的方法傳入“回調函數”,來提供框架運行的功能,但是如果框架功能比較複雜,需要根據不同的情況,傳入大量不同的回調函數,這就很容易出現傳入回調函數出錯的情況。譬如,用戶輸入“加減乘除”的命令行參數,對應執行加減乘除的回調函數。——這種情況下我們必須設計四個用戶輸入的命令,然後傳入對應的回調函數,這個過程如果讓使用者去做,難保不會理解錯命令到函數的含義,這個時候,我們可能會想到:函數的名字本身就是一個“字符串”,我們能不能直接用這個函數名字,來作為對應的輸入操作指令呢?作為一種腳本語言,Lua 顯然可以很簡單的做到這點:
-- 框架代碼:從命令行參數執行命令 function run_cmd() -- arg 是一個全局 table 變量,記錄了全部的命令行參數 if not arg[1] or not arg[2] or not arg[3] then print("Usage: cmd arg1 arg2") return end local f=load("return "..arg[1].."("..arg[2]..","..arg[3]..")") print(f()) end -- 回調函數 function add(a, b) return a+b end function sub(a, b) return a-b end -- 運行框架 run_cmd()
運行示例:
$ lua test.lua add 1 3 4 $ lua test.lua sub 1 3 -2
在上面的例子中,我們直接用函數名字 add, sub 作為指令進行調用,關鍵是調用了load()這個函數,這個函數可以解析傳入的字符串,按照 Lua 代碼的格式進行處理,並且把這段代碼構造成一個函數返回。如果我們輸入命令lua test.lua add 1 3,實際上動態的構造了一個如下的函數:
-- local f=load("return ".."add".."(".."1"..",".."3"..")") local f = function(...) return add(1,3) end
上面說了用一個字符串調用同名函數,其實源碼中的變量,也是可以字符串中的名字進行訪問的。最典型的就是 table 支持的語法:
tb = {a=1, b=2} pirnt(tb["a"]) --> 1
Lua 中的全局變量,都是存在一個大 table 裡面的,這個 table 的名字是 _G,所以全局變量都是可以用字符串名字訪問的:
a = 100 print(_G["a"]) --> 100
所謂反射,就是可以在運行時,通過在字符串變量名中的函數名字來調用這個函數;通過字符串變量中的變量名字來讀寫變量。
註解
當我們編寫一份程序源代碼的時候,我們的這些代碼,除了編譯運行以外,還有可能需要和其他的一些程序產生關聯,或者根據這份源碼自動生成其他一些代碼以便共同工作。譬如我們在定義一個數據結構體(由多個不同類型的變量組成的一個複合變量)的時候,可能需要同時讓這個數據結構能被序列化成 JSON 以便在網絡上傳輸,或者記錄成文件;又或者我們在定義一個函數或者方法的時候,希望這個函數能夠在遊戲中的某類物品碰撞時被調用。——傳統的做法是我們直接通過框架的函數,去“註冊”這些數據結構或者函數,但是有一些語言,支持我們可以在源碼上加上一些特殊的代碼(主要是標記在數據結構和函數上),然後框架代碼可以通過語言提供的能力,提取這些特殊代碼,及其被標記的數據結構和函數,產生新的代碼或者處理程序。在 Java 裡叫 annotation,在 C# 裡叫 attribute,在 Go 裡叫 tags。
至於 Lua,語言上似乎並沒有這種特殊的設計。但是得益於腳本語言的好處,我們可以獲得運行時整個 Lua 虛擬機裡面的所有變量、函數對象,也可以通過 dofile() 函數執行任意的 Lua 文件。所以完全可以很簡單的編寫代碼來對已經載入虛擬機的所有 Lua 代碼進行二次處理,譬如對這個源文件裡面的所有變量和函數進行登記和註冊,然後生成任意新的 Lua 源碼文件,做任何註解可以做的事情。
模塊管理
現在大部分語言的源碼,都不會僅僅放在一個文件裡面。那麼如何才能讓多個不同的源文件,合併到一起工作呢?這就是所謂“模塊管理”要解決的問題。很多時候,我們可以把一個源文件視為一個“模塊”,那麼這個源文件要能作為模塊被其他源文件使用,必須要遵守一定的規則。不同的語言對於這個問題有不同的設計,比較奇葩的是 C 語言就沒有對此有任何的標準的規定,可能是因為這門語言實在是太古老了。
一般來說一門語言需要在模塊管理方面,定義以下三個方面的內容:
- 定義模塊:怎樣寫代碼,怎樣命名文件才能成為一個合法的“模塊”
- 引用模塊:怎樣寫代碼才能調用另外一個模塊裡面的功能,譬如調用函數或者構造類對象
- 編譯或運行的方法:怎樣操作才能讓各個模塊的代碼一起運行
對於 Lua 來說,定義模塊的方法是:
- 建立一個以模塊名為文件名的源碼,譬如 aaa.lua,就代表了你想要設計一個叫 aaa 的模塊
- 在模塊源文件內,定義一個 table,如 tb = {},然後把你希望提供給使用者的變量、函數,都存放到這個 table 變量(tb)裡面,最後在源文件末尾寫上 return tb 來返回這個 table
引用模塊的寫法也很簡單,就是一句:require("aaa"),這裡的 "aaa" 是你定義的模塊的名字,也就是那個模塊文件的名字。一旦你運行了這行代碼,你在之後的代碼中,就可以使用 aaa.XXX() 或者 aaa.YY 的方法來使用定義在 aaa.lua 裡面的各種函數和變量了。——這基本上就是一個通過文件名讀取一個 Lua 的 table 的過程。
最後要注意的是,你的模塊文件應該放在使用者的 .lua 文件同一個目錄,或者,放在環境變量 LUA_PATH 裡面定義的多個文件路徑裡。在編寫環境變量的路徑的時候,可以用 ? 號來代替模塊名,以指定實際的完整 .lua 文件路徑。
模塊文件例子,文件名為 module.lua
module = {} -- 定義一個常量 module.constant = "這是一個常量" -- 定義一個函數 function module.func1() io.write("這是一個公有函數!\n") end local function func2() print("這是一個私有函數!") end function module.func3() func2() end return module
調用模塊的例子
-- module 模塊為上文提到到 module.lua require("module") print(module.constant) module.func3()
運行結果:
這是一個常量 這是一個私有函數!
可以用作搜索模塊的路徑定義例子:
export LUA_PATH="./?.lua;/usr/local/share/lua/5.1/?.lua;/usr/local/share/lua/5.1/?/init.lua;/usr/local/lib/lua/5.1/?.lua;/usr/local/lib/lua/5.1/?/init.lua"
搜索的路徑:
./module.lua /usr/local/share/lua/5.1/module.lua /usr/local/share/lua/5.1/module/init.lua /usr/local/lib/lua/5.1/module.lua /usr/local/lib/lua/5.1/module/init.lua
最後,對於想用 Lua 實現“熱更新”的開發者,Lua 也提供了一個手段。因為 Lua 的 require() 操作,是會先去全局變量 package.loaded["aaa"] 裡面找一下,是否存在同名(aaa)的模塊,然後再決定是否加載模塊文件的內容。因此我們只要把 package.loaded["aaa"] 設置成 nil,就可以重複調用 require() 進行重新加載了。
雖然,通過 dofile() 也可以實現類似“熱更新”的方案,但其實這是每次都去運行解析一次 Lua 源文件,並不能妥善的處理加載前和加載後的內容,所以還是使用package.loaded 這個 table 比較好一點。
小結
Lua 的高級特性,很多都是依賴於其通用數據結構 table 來實現的。所以只要理解了 table 的原理,很多事情都可以自己去實現。我們也不必非要按照其他的某種語言的慣例,去改造 Lua 的用法,而是應該去思考,怎樣才是最快實現功能的方法。因為 Lua 本身就是為了提高開發效率而設計的一種語言,如果我們為了符合某種範式,寫了一大堆的架子代碼,可能反而喪失了這門語言的優點。
下一篇介紹 Lua 為數不多但靈活好用的庫函數