1. 程式人生 > >Lua筆記8-模組和包

Lua筆記8-模組和包

從Lua5.1版本開始,就對模組和包添加了新的支援,可是使用require和module來定義和使用模組和包。require用於使用模組,module用於建立模組。簡單的說,一個模組就是一個程式庫,可以通過require來載入。然後便得到了一個全域性變數,表示一個table。這個table就像是一個名稱空間,其內容就是模組中匯出的所有東西,比如函式和常量,一個符合規範的模組還應使require返回這個table。現在就來具體的總結一下require和module這兩個函式。

require函式
Lua提供了一個名為require的函式用來載入模組。要載入一個模組,只需要簡單地呼叫require “<模組名>”就可以了。這個呼叫會返回一個由模組函式組成的table,並且還會定義一個包含該table的全域性變數。但是,這些行為都是由模組完成的,而非require。所以,有些模組會選擇返回其它值,或者具有其它的效果。那麼require到底是如何載入模組的呢? 首先,要載入一個模組,就必須的知道這個模組在哪裡。知道了這個模組在哪裡以後,才能進行正確的載入。當我們寫下require “mod”這樣的程式碼以後,Lua是如何找這個mod的呢?這裡面就有說道了,我這裡就詳細的說一說。 在搜尋一個檔案時,在windows上,很多都是根據windows的環境變數path來搜尋,而require所使用的路徑與傳統的路徑不同,require採用的路徑是一連串的模式,其中每項都是一種將模組名轉換為檔名的方式。require會用模組名來替換每個“?”,然後根據替換的結果來檢查是否存在這樣一個檔案,如果不存在,就會嘗試下一項。路徑中的每一項都是以分號隔開,比如路徑為以下字串:

?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua

那麼,當我們require “mod”時,就會嘗試著開啟以下檔案:
mod
mod.lua
c:\windows\mod
/usr/local/lua/mod/mod.lua

可以看到,require函式只處理了分號和問號,其它的都是由路徑自己定義的。在實際程式設計中,require用於搜尋的Lua檔案的路徑存放在變數package.path中,在我的電腦上,print(package.path)會輸出以下內容:
;.\?.lua;D:\Lua\5.1\lua\?.lua;D:\Lua\5.1\lua\?\init.lua;D:\Lua\5.1\?.lua;D:\Lua\5.1\?\init.lua;D:\Lua\5.1\lua\?.luac
如果require無法找到與模組名相符的Lua檔案,那Lua就會開始找C程式庫;這個的搜尋地址為package.cpath對應的地址,在我的電腦上,print(package.cpath)會輸出以下值:
.\?.dll;.\?51.dll;D:\Lua\5.1\?.dll;D:\Lua\5.1\?51.dll;D:\Lua\5.1\clibs\?.dll;D:\Lua\5.1\clibs\?51.dll;D:\Lua\5.1\loadall.dll;D:\Lua\5.1\clibs\loadall.dll
當找到了這個檔案以後,如果這個檔案是一個Lua檔案,它就通過loadfile來載入該檔案;如果找到的是一個C程式庫,就通過loadlib來載入。loadfile和loadlib都只是載入了程式碼,並沒有執行它們,為了執行程式碼,require會以模組名作為引數來呼叫這些程式碼。如果lua檔案和C程式庫都找不到,怎麼辦?我們試一下,隨便require一個東西,比如:
require "jellythink"
lua: test.lua:1: module 'jellythink' not found:
     no field package.preload['jellythink']
     no file '.\jellythink.lua'
     no file 'D:\Lua\5.1\lua\jellythink.lua'
     no file 'D:\Lua\5.1\lua\jellythink\init.lua'
     no file 'D:\Lua\5.1\jellythink.lua'
     no file 'D:\Lua\5.1\jellythink\init.lua'
     no file 'D:\Lua\5.1\lua\jellythink.luac'
     no file '.\jellythink.dll'
     no file '.\jellythink51.dll'
     no file 'D:\Lua\5.1\jellythink.dll'
     no file 'D:\Lua\5.1\jellythink51.dll'
     no file 'D:\Lua\5.1\clibs\jellythink.dll'
     no file 'D:\Lua\5.1\clibs\jellythink51.dll'
     no file 'D:\Lua\5.1\loadall.dll'
     no file 'D:\Lua\5.1\clibs\loadall.dll'

是的,會報錯的。以上就是require的一般工作流程。

技巧:

可以看到,上面總結的都是通過模組的名稱來使用它們。但有的時候需要將一個模組改名,以避免名稱衝突。比如有這樣的場景,在測試中需要載入同一模組的不同版本,而獲得版本之間的效能區別。那麼我們如何載入同一模組的不同版本呢?對於一個Lua檔案來說,我們可以很輕易的改掉它的名稱,但是對於一個C程式庫來說,我們是沒有辦法編輯其中的luaopen_*函式的名稱的。為了這種重新命名的需求,require用到了一個小的技巧:如果一個模組名中包含了連字元,require就會用連字元後的內容來建立luaopen_*函式名。比如:如果一個模組的名稱為a-b,require就會認為它的open函式名為luaopen_b,並不是luaopen_a-b。現在好了,對於上面提出的不同版本進行測試的需求,就可以迎刃而解了。

寫一個模組:

在Lua中建立一個模組最簡單的方法是:建立一個table,並將所有需要匯出的函式放入其中,最後返回這個table就可以了。相當於將匯出的函式作為table的一個欄位,在Lua中函式是第一類值,提供了天然的優勢。來寫一個我們自己的模組,程式碼如下:

complex = {}    -- 全域性的變數,模組名稱

function complex.new(r, i) return {r = r, i = i} end

-- 定義一個常量i
complex.i = complex.new(0, 1)

function complex.add(c1, c2)
    return complex.new(c1.r + c2.r, c1.i + c2.i)
end

function complex.sub(c1, c2)
    return complex.new(c1.r - c2.r, c1.i - c2.i)
end

return complex  -- 返回模組的table

上面就是一個最簡單的模組。在編寫程式碼的過程中,會發現必須顯式地將模組名放到每個函式定義中;而且,一個函式在呼叫同一個模組中的另一個函式時,必須限定被呼叫函式的名稱,然而我們可以稍作變通,在模組中定義一個區域性的table型別的變數,通過這個區域性的變數來定義和呼叫模組內的函式,然後將這個區域性名稱賦予模組的最終的名稱,程式碼如下:
local M = {}    -- 區域性的變數
complex = M     -- 將這個區域性變數最終賦值給模組名

function M.new(r, i) return {r = r, i = i} end

-- 定義一個常量i
M.i = M.new(0, 1)

function M.add(c1, c2)
    return M.new(c1.r + c2.r, c1.i + c2.i)
end

function M.sub(c1, c2)
    return M.new(c1.r - c2.r, c1.i - c2.i)
end

return complex  -- 返回模組的table

這樣,我們在模組內部其實使用的是一個區域性的變數。這樣看起來比較簡單粗暴,但是每個函式仍需要一個字首。實際上,我們可以完全避免寫模組名,因為require會將模組名作為引數傳給模組。讓我們來做個試驗:
-- 列印引數
for i = 1, select('#', ...) do
     print(select(i, ...))
end

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名
complex = M

function M.new(r, i) return {r = r, i = i} end

-- 定義一個常量i
M.i = M.new(0, 1)

function M.add(c1, c2)
    return M.new(c1.r + c2.r, c1.i + c2.i)
end

function M.sub(c1, c2)
    return M.new(c1.r - c2.r, c1.i - c2.i)
end

return complex  -- 返回模組的table

將上述程式碼儲存為test1.lua。再寫一個檔案,程式碼如下:
require "test1"

c1 = test1.new(0, 1)
c2 = test1.new(1, 2)

ret = test1.add(c1, c2)
print(ret.r, ret.i)

將上述程式碼儲存為test2.lua 將上述程式碼放在同一個資料夾下,執行test2.lua檔案,列印結果如下:
test1
1     3

 經過這樣的修改,我們就可以完全不用在模組中定義模組名稱,如果需要重新命名一個模組,只需要重新命名定義它的檔案就可以了。 細心的同學可能注意到了模組結尾處的return語句,這樣的一個return語句,在定義模組時,是非常容易漏寫的,怎麼辦?如果將所有與模組相關的設定任務都集中在模組開頭,就會更好了。消除return語句的一種方法是,將模組table直接賦值給package.loaded,程式碼如下:
local moduleName = ...

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名

package.loaded[moduleName] = M
-- 後續程式碼省略

package.loaded是什麼?

require會將返回值儲存到table package.loaded中;如果載入器沒有返回值,require就會返回table package.loaded中的值。可以看到,我們上面的程式碼中,模組沒有返回值,而是直接將模組名賦值給table package.loaded了。這說明什麼,package.loaded這個table中儲存了已經載入的所有模組。現在我們就可以看看require到底是如何載入的呢?

  1. 先判斷package.loaded這個table中有沒有對應模組的資訊;
  2. 如果有,就直接返回對應的模組,不再進行第二次載入;
  3. 如果沒有,就載入,返回載入後的模組。

再說“環境”


大家可能注意到了,當我訪問同一個模組中的其它函式時,都需要限定名稱,就比如上面程式碼中的M。當我把模組內部的一個local函式由私有改變成公有以後,相應的呼叫local函式的地方都需要修改,加上限定名稱。怎麼辦?總不能每次都修改程式碼吧。如何一次搞定? 我們可以讓模組的主程式塊有一個獨佔的環境,這樣不僅它的所有函式都可共享這個table,而且它的所有全域性變數也都記錄在這個table中,還可以將所有公有函式宣告為全域性變數,這樣它們就都自動地記錄在一個獨立的table中。而模組所要做的就是將這個table賦予模組名和package.loaded。比如以下程式碼就可以完成:

local moduleName = ...

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名

package.loaded[moduleName] = M
setfenv(1, M)

這之後,當我們寫下下面的程式碼:
function add(c1, c2)
    return new(c1.r + c2.r, c1.i + c2.i)
end

它其實是和下面的程式碼是等價的:
function M.add(c1, c2)
    return M.new(c1.r + c2.r, c1.i + c2.i)
end
當我呼叫同一個模組中的函式new時,也不用指定M了。這樣就可以讓我們在寫自己的模組時,省去了字首;還有其它好處,你可以自己想想。但是,當我們呼叫setfenv之後,將一個空table M作為環境後,就無法訪問前一個環境中全域性變量了。這該如何是好?現在提供幾種方法。

方法一: 使用元表,設定__index,模擬繼承來實現。程式碼如下:

local moduleName = ...

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名

package.loaded[moduleName] = M

setmetatable(M, {__index = _G})
setfenv(1, M)

由於需要設定元表,所有會有一定的開銷,但是可以忽略的。

方法二:

local moduleName = ...

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名

package.loaded[moduleName] = M

local _G = _G -- 儲存了全域性的環境變數
setfenv(1, M)

這樣在自己的模組中儲存一個全域性的環境變數,當我們訪問前一個環境中的變數時,就需要新增字首_G,貌似有點小麻煩。但是,由於沒有涉及到元方法,這種方法會比方法一略快。

方法三: 這種方法是最正規的方法,就是將那些需要用到的函式或模組宣告為區域性變數,看以下程式碼:

local moduleName = ...

local M = {}    -- 區域性的變數
_G[moduleName] = M     -- 將這個區域性變數最終賦值給模組名

package.loaded[moduleName] = M

local sqrt = math.sqrt -- 在我們自己的模組中需要用到math.sqrt這個函式,所以就先儲存下來
local io = io -- 需要用到io庫,也儲存下來
setfenv(1, M) -- 設定完成以後,就不能再使用_G table中的內容了

方法三需要做的工作是最多的,而且也是最麻煩的,但是效能是最好的。怎麼用,你自己看著辦吧。

module函式

大家可能也注意到了,在定義一個模組時,前面的幾句程式碼都是一樣的,就分為以下幾步:

  1. 從require傳入的引數中獲取模組名;
  2. 建立一個空table;
  3. 在全域性環境_G中新增模組名對應的欄位,將空table賦值給這個欄位;
  4. 在已經載入table中設定該模組;
  5. 設定環境變數。

就是這幾步,在每一個模組的定義之前都需要加上,是不是有點麻煩,在Lua5.1中提供了一個新函式module,它包括了以上這些步驟完成的功能。在編寫一個模組時,可以直接用以下程式碼來取代前面的設定程式碼:

module(...)

就上面這一小句程式碼,它會建立一個新的table,並將其賦予給模組名對應的全域性欄位和loaded table,最後還會將這個table設為主程式塊的環境。預設的情況下,module不提供外部的訪問的,也就是說,你無法訪問前一個環境了,在再說“環境”一節,我專門說了三種解決方案。在使用module時是這樣解決的:
module(..., package.seeall)

這句話的功能就好比之前的功能再加上了setmetatable(M, {__index = _G})。有了這一句程式碼,基本上就可以說萬事不愁了。

子模組與包

Lua支援具有層級性的模組名,可以用一個點來分隔名稱中的層級。假設一個模組名為mod.sub,那麼它就是mod的一個子模組。因此,可以認為模組mod.sub會將其所有值都定義在table mod.sub中,也就是一個儲存在table mod中,且key為sub的table。就好比下述的定義:

local mod = {sub = {}}
注意:
當require一個模組mod.sub時,require會用原始的模組名“mod.sub”作為key來查詢table package.loaded和package.preload,其中,模組名中的點在搜尋時沒有任何意義。當搜尋一個定義子模組的檔案時,require會將點轉換成另一個字元,通常就是系統的目錄分隔符,轉換之後require就像搜尋其他名稱一樣來搜尋這個名稱。比如路徑為以下字串:
?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua
那麼,當我們require “mod.sub”時,就會嘗試著開啟以下檔案:
mod\sub
mod\sub.lua
c:\windows\mod\sub
/usr/local/lua/mod/mod/sub.lua

通過這樣的載入策略,就可以將一個包中的所有模組組織到一個目錄中。像這些小的功能,都會組合成很多的奇淫技巧,雖然在實際專案中用的不會很多,但是玩起來還是很有意思的。