Lua 技術備忘錄 5

用於將 C++ 繫結至 Lua 的範本類別

作者 Lenny Palozzi

摘要

此備忘錄說明了一種將 C++ 類別繫結至 Lua 的方法。Lua 並未直接支援此功能,但它提供了低階 C API 和擴充機制,使其成為可能。我所描述的方法利用 Lua 的 C API、C++ 範本和 Lua 的擴充機制,建立一個小而簡單,但有效的靜態範本類別,提供類別註冊服務。此方法對您的類別施加了一些限制,即只有簽章為 int(T::*)(lua_State*) 的類別成員函式才能註冊。但正如我將展示的,此限制是可以克服的。最終結果是註冊類別的乾淨介面,以及 Lua 中類別的熟悉 Lua 表語意。這裡說明的解決方案基於我編寫的名為 Luna 的範本類別。

問題

Lua 的 API 並非設計為將 C++ 類別註冊至 Lua,僅註冊簽章為 int()(lua_State*) 的 C 函式,也就是說,一個將 lua_State 指標作為引數並傳回整數的函式。實際上,那是 Lua 在註冊中支援的唯一 C 資料類型。若要註冊任何其他類型,您必須使用 Lua 提供的擴充機制、標籤方法、封閉函式等。在建立允許我們將 C++ 類別註冊至 Lua 的解決方案時,我們必須利用這些擴充機制。

解決方案

有四個組成解決方案的元件,類別註冊、物件實例化、成員函式呼叫和垃圾回收。

類別註冊是透過使用類別名稱註冊表建構函式來完成的。表建構函式是範本類別的靜態方法,傳回表物件。

注意:假設簽章相同,靜態類別成員函式與 C 函式相容,因此我們可以在 Lua 中註冊它們。下列程式碼片段是範本類別的成員函式,'T' 是要繫結的類別。

  static void Register(lua_State* L) {
    lua_pushcfunction(L, &Luna<T>::constructor);
    lua_setglobal(L, T::className);

    if (otag == 0) {
      otag = lua_newtag(L);
      lua_pushcfunction(L, &Luna<T>::gc_obj);
      lua_settagmethod(L, otag, "gc"); /* tm to release objects */
    }
  }
物件實例化是透過將使用者傳遞給表建構函式的任何引數傳遞給 C++ 物件的建構函式來完成的,建立一個代表物件的表,將類別的任何成員函式註冊至該表,最後將表傳回 Lua。物件指標儲存在表中索引 0 的使用者資料中。成員函式對應的索引儲存在每個函式的封閉函式值中。稍後會進一步說明成員函式對應。
  static int constructor(lua_State* L) {
    T* obj= new T(L); /* new T */
    /* user is expected to remove any values from stack */

    lua_newtable(L); /* new table object */
    lua_pushnumber(L, 0); /* userdata obj at index 0 */
    lua_pushusertag(L, obj, otag); /* have gc call tm */
    lua_settable(L, -3);

    /* register the member functions */
    for (int i=0; T::Register[i].name; i++) {
      lua_pushstring(L, T::Register[i].name);
      lua_pushnumber(L, i);
      lua_pushcclosure(L, &Luna<T>::thunk, 1);
      lua_settable(L, -3);
    }
    return 1; /* return the table object */
  }
與 C 函式不同,C++ 成員函式需要類別的物件才能呼叫函式。成員函式呼叫是由函式完成的,該函式透過取得物件指標和成員函式指標,並進行實際呼叫來「thunk」呼叫。成員函式指標會透過封閉值從成員函式對應中索引,而物件指標則會從索引 0 處的表格中索引。請注意,Lua 中的所有類別函式都會使用此函式進行註冊。
  static int thunk(lua_State* L) {
    /* stack = closure(-1), [args...], 'self' table(1) */
    int i = static_cast<int>(lua_tonumber(L,-1));
    lua_pushnumber(L, 0); /* userdata object at index 0 */
    lua_gettable(L, 1);
    T* obj = static_cast<T*>(lua_touserdata(L,-1));
    lua_pop(L, 2); /* pop closure value and obj */
    return (obj->*(T::Register[i].mfunc))(L);
  }
垃圾回收是透過在表格中的使用者資料設定垃圾回收標記方法來完成的。當垃圾回收器執行時,將會呼叫「gc」標記方法,而它只會刪除物件。「gc」標記方法會在類別註冊期間使用新的標記進行註冊。在上述的物件實例化中,使用者資料會標記標記值。
  static int gc_obj(lua_State* L) {
    T* obj = static_cast<T*>(lua_touserdata(L, -1));
    delete obj;
    return 0;
  }
考量到這一點,您可能已經注意到類別必須符合一些需求 注意:這些需求是我做出的設計選擇,您可以決定使用不同的介面;只需對程式碼做一些調整即可。

Luna<T>::RegType 是函式對應。name 是成員函式 mfunc 將註冊為的函式名稱。

  struct RegType {
    const char* name;
    const int(T::*mfunc)(lua_State*);
  };
以下是如何將 C++ 類別註冊到 Lua 的範例。呼叫 Luna<T>::Register() 會註冊類別;這是範本類別唯一的公開介面。若要在 Lua 中使用類別,請呼叫其表格建構函式來建立它的實例。
  class Account {
    double m_balance;
   public:
    Account(lua_State* L) {
      /* constructor table at top of stack */
      lua_pushstring(L, "balance");
      lua_gettable(L, -2);
      m_balance = lua_tonumber(L, -1);
      lua_pop(L, 2); /* pop constructor table and balance */
    }

    int deposit(lua_State* L) {
      m_balance += lua_tonumber(L, -1);
      lua_pop(L, 1);
      return 0;
    }
    int withdraw(lua_State* L) {
      m_balance -= lua_tonumber(L, -1);
      lua_pop(L, 1);
      return 0;
    }
    int balance(lua_State* L) {
      lua_pushnumber(L, m_balance);
      return 1;
    }
    static const char[] className;
    static const Luna<Account>::RegType Register
  };

  const char[] Account::className = "Account";
  const Luna<Account>::RegType Account::Register[] = {
    { "deposit",  &Account::deposit },
    { "withdraw", &Account::withdraw },
    { "balance",  &Account::balance },
    { 0 }
  };

  [...]

  /* Register the class Account with state L */
  Luna<Account>::Register(L);

  -- In Lua
  -- create an Account object
  local account = Account{ balance = 100 }
  account:deposit(50)
  account:withdraw(25)
  local b = account:balance()
Account 實例的表格如下所示
  0 = userdata(6): 0x804df80
  balance = function: 0x804ec10
  withdraw = function: 0x804ebf0
  deposit = function: 0x804f9c8

說明

有些人可能不喜歡使用 C++ 範本,但它們在此處的使用非常合適。它們為最初看起來很複雜的問題提供了快速且嚴謹的解決方案。由於使用範本,因此類別的類型非常安全;例如,不可能在成員函式對應中混合不同類別的成員函式,編譯器會抱怨。此外,範本類別的靜態設計使其易於使用,當您完成時,沒有範本實例化物件需要清除。

thunk 機制是類別的核心,因為它會「thunk」呼叫。它會從函式呼叫關聯的表格中取得物件指標,並為成員函式指標索引成員函式對應。(Lua 表格函式呼叫 table:function()table.function(table) 的語法糖。當進行呼叫時,Lua 會先將表格推入堆疊,然後再推入任何引數)。成員函式索引是封閉值,最後推入堆疊(在任何引數之後)。最初,我將物件指標設為封閉值,這表示每個實例化的類別的每個函式都有 2 個封閉值,一個指向物件的指標(void*)和成員函式索引(int);這似乎相當昂貴,但可以快速存取物件指標。此外,表格中需要使用者資料物件才能進行垃圾回收。最後,我選擇為物件指標索引表格並節省資源,結果增加了函式呼叫的負擔;表格查詢物件指標。

綜合所有事實,此實作僅使用 Lua 的一些可用延伸機制,封閉值用於儲存成員函式的索引、「gc」標記方法用於垃圾回收,以及函式註冊用於表格建構函式和成員函式呼叫。

為何只允許註冊具有簽章 int(T::*)(lua_State*) 的成員函數?這允許您的成員函數直接與 Lua 互動;擷取引數並將值傳回 Lua、呼叫任何 Lua API 函數等。此外,它提供與 C 函數在註冊至 Lua 時相同的介面,讓希望使用 C++ 的人更容易使用。

缺點

此範本類別解決方案只繫結具有特定簽章的成員函數,如前所述。因此,如果您已經撰寫類別,或打算在 Lua 和 C++ 環境中使用類別,這可能不是最適合您的解決方案。在摘要中,我提到我會說明這其實不是問題。使用代理模式,我們封裝真實類別,並將對其進行的任何呼叫委派給目標物件。代理類別的成員函數將引數和傳回值轉換為 Lua 的格式,並將呼叫委派給目標物件。您會將代理類別註冊至 Lua,而不是真實類別。此外,您也可以使用繼承,其中代理類別繼承自基本類別,並將函數呼叫委派至基本類別,但有一個但書,基本類別必須具有預設建構函式;您無法在代理建構函式的初始化清單中從 Lua 取得建構函式引數至基本類別。代理模式解決了我們的問題,我們現在可以在 C++ 和 Lua 中使用類別,但在這樣做的過程中,需要我們撰寫代理類別並維護它們。

建立物件時只是使用 new,使用者應該擁有更多控制權來決定如何建立物件。例如,使用者可能希望註冊單例類別。一個解決方案是讓使用者實作靜態 create() 成員函數,傳回物件指標。這樣一來,使用者可以實作單例類別,只需透過 new 分配物件,或其他任何方式。可以修改 constructor 函數,呼叫 create() 而不是 new 來取得物件指標。這會將更多政策推給類別,但靈活性也更高。垃圾回收的「掛鉤」對某些人來說也可能有用。

結論

此備忘錄說明了將 C++ 類別繫結至 Lua 的簡單方法。實作相當簡單,讓您有機會修改它以符合自己的目的,同時滿足任何一般用途。還有許多其他工具可以將 C++ 繫結至 Lua,例如 tolua、SWIGLua 和其他像這樣的小型實作。每個工具都有自己的優點、缺點和適合您特定問題的程度。希望此備忘錄能對較細微的問題有所說明。

範本類別的完整原始碼,約 70 行原始碼,可從 Lua 外掛程式頁面取得。

參考資料

[1] R. Hickey,使用範本函式的 C++ 回呼,C++ Report February 95


最後更新:2003 年 3 月 12 日星期三上午 11:51:13 EST,由 lhf 更新。