🌝

【Swift】Lua言語環境をアプリに組み込む【Cygnus】

2024/03/18に公開1

はじめに

プログラミング言語「Lua」。


公式より引用

高い移植性と組込みの容易さから、ゲーム[1]やクリエイター向けツール[2]等様々なソフトウェアのプラグインに採用されているスクリプト言語です。本体はCで記述されており、Selene(C++)や LuaJ(Java)など様々な言語に向けたバインディングが有志により開発・公開されています。

先日Swift packageでC/C++のコードをパッケージとしてビルドできることを知ったので、Lua言語環境を丸ごと組み込むパッケージ「Cygnus」を作ってみました。
Cygnus - bannerEnchan1207/Cygnus

Cygnusは日本語で「はくちょう座」を意味します。Swift(鳥)とLua(月)から連想して命名しました。

パッケージと併せて、Processing風のグラフィカルプログラミング環境をLuaで再現したmacOS向けのサンプルアプリも提供しています。

app.gif
サンプルアプリの動作の様子

若干重いですが一応60FPSは出ます(サンプル内では余裕をみて30FPSに収めています)。結構色々な使い方が考えられるかと思います。

Cygnus

Cygnusの基本となるクラスは Lua です。こちらは内部でLuaステート(struct lua_State)をもち、インスタンスごとに異なる環境が用意されます。

import Cygnus
let lua = Lua()

以降、このインスタンスを用いて説明していきます。

基本的な使い方

直接コードを渡して実行する

メソッド eval にLuaコードを直接渡すのが最も簡単な方法です:

let luaCode = "print(\"Hello, Lua!\")"
do {
    // Luaコードを評価
    try lua.eval(luaCode)
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

実行すると、Xcodeのコンソールに Hello, Lua! と表示されます。

勿論、複数行のコードをまとめて投入することも可能です:

let limit = 100
let fizzbuzzCode = """
--
-- FizzBuzz
--

-- 最大値を設定
local limit = \(limit)

--カウントアップ
for n = 1, limit do
    if n % 3 == 0 and n % 5 == 0 then
        print("FizzBuzz")
    elseif n % 3 == 0 then
        print("Fizz")
    elseif n % 5 == 0 then
        print("Buzz")
    else
        print(n)
    end
end
"""

do {
    // Luaコードを評価
    try lua.eval(fizzbuzzCode)
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

Swiftの関数を登録し、Luaから呼び出す

Swiftの関数をLuaインスタンスに登録し、Luaコード側から呼び出すことも可能です。3段階の手順を踏みます。

  1. Luaインスタンスのスタックに関数オブジェクトを積む。
  2. 積んだ関数を命名し、グローバルに登録する。
  3. Luaからグローバル関数として呼び出す。
階乗を行う関数 factorial をLuaに追加する例

実装:

import Cygnus
import CygnusCore // 関数lua_errorを呼び出すのに必要です

do {
    // 1. 関数オブジェクトをpushする
    try lua.push({state in
        // 既存のlua_Stateを元にLuaインスタンスを生成
        let lua = Lua(state: state!, owned: false)
        
        // Luaスタックから引数を取得する 1つの整数が積まれていることを想定
        guard let limit = try? lua.get() as Int else {
            try? lua.push("Unexpected argument")
            lua_error(state)
            return 0
        }
        
        // 階乗を計算する
        let factorial: (Int) -> Int = {(1...$0).reduce(1, *)}
        let result = factorial(limit)
        
        // 結果をスタックに積む
        do {
            try lua.push(result)
        } catch {
            try? lua.push("Failed to push result")
            lua_error(state)
            return 0
        }
        
        // 戻り値がいくつあるかを返す
        return 1
    })
    
    // 2. pushした関数オブジェクトを命名
    try lua.setGlobal(name: "factorial")
    
    // 3. 関数名で呼び出す
    try lua.eval("print(factorial(10))")
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

コンソール出力:

3628800.0

Luaの関数を取得し、Swiftから呼び出す

CygnusはLuaが提供する標準モジュール[3]を自動でインポートします[4]。例えば、数学関数を提供するライブラリ math の関数を呼び出す場合は以下のような手順を踏みます。

  1. グローバルからライブラリmathを取得し、スタックに積む。
  2. 取得したライブラリから目的の関数を取得し、スタックに積む。
  3. 引数をスタックに積み、関数を呼び出す。
sin(rad(90)) を計算する例
do{
    // 1. グローバルからライブラリを取得
    try lua.getGlobal(name: "math")
    
    // 2. 関数オブジェクトを取得
    try lua.getField(key: "rad")
    
    // 3. 引数を積んで、関数を呼び出す
    let angle = 90.0
    try lua.push(angle)
    try lua.call(argCount: 1, returnCount: 1)
    let radian: Double = try lua.get()
    try lua.pop() // この時点で、スタックトップにはライブラリのみが残る
    
    // 2. 関数オブジェクトを取得
    try lua.getField(key: "sin")

    // 3. 引数を積んで、関数を呼び出す
    try lua.push(radian)
    try lua.call(argCount: 1, returnCount: 1)
    let sinValue: Double = try lua.get()
    
    print("sin(\(angle)) = \(sinValue)")
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

コンソール出力:

sin(90.0) = 1.0

…ですが、わざわざここまでするくらいなら lua.eval に直接渡してしまう方が楽でしょう。
ただし実行速度は犠牲になります

try lua.eval("print(\"sin(90.0) = \" .. math.sin(math.rad(90)))")

特定の関数がLua側で実装されていることを期待し、アプリ側からそれらを呼び出す…といったケースでは、関数オブジェクトを取得して呼び出す手法の方がパフォーマンスは良さそうです(サンプルアプリではそうしています)。

応用: 標準入出力のキャプチャ

ここまではLuaとSwiftの基本的な連携について説明しました。
これらに加え、Cygnusは 標準入出力のキャプチャ も提供しています。

実装の詳細

Luaは入出力を担うライブラリとして io を持っており、書出しにはio.write()、読込みには io.read() がそれぞれ関数として用意されています。
これらメソッドは引数を渡さずに呼び出すと標準入出力オブジェクト、すなわち io.stdout および io.stdin にアクセスします。つまり、io.write("Hello, Lua!")print("Hello, Lua!") は同じ結果となります[5]

これらの標準入出力オブジェクトは内部で struct luaL_Stream として定義されています。この構造体はファイルポインタおよび close の際に呼ばれるべき関数のポインタを保持しています。

typedef struct luaL_Stream {
  FILE *f;  /* stream (NULL for incompletely created streams) */
  lua_CFunction closef;  /* to close stream (NULL for closed streams) */
} luaL_Stream;

ここで、「標準入出力」はアプリケーションのそれと同一のものを指しています(ここまでのサンプルでXcodeのコンソールに直接出力されていたことからもお分かりいただけると思います)。

しかし、これではちょっと困ることがあります。

例えば、ユーザがアプリ上で printio.read() を実行するようなコードを記述した場合、アプリはそれらの情報にアクセスすることができません。 freopen() で標準入出力そのものを置き換えてしまうという方法もありますが、それではどのデータがLuaによるものなのかわからなくなってしまいます。

そこでCygnusではpipe()を用いてLuaインスタンスとのパイプを構成し、io.stdinおよびio.stdout自体をカスタムオブジェクトに置換する機能を実装しました(メソッドconfigureStandardInput, configureStandardOutput)。
これにより、ユーザはLuaインスタンスのプロパティ stdin, stdout を用いてLuaの標準入出力にアクセスすることが可能です。

標準出力をキャプチャする場合はこのようになり……:

do{
    // 標準入出力のキャプチャを構成
    try lua.configureStandardIO()
    
    // Luaから出力
    let message = "Hello, Lua!"
    try lua.eval("print(\"\(message)\")")
    
    // stdoutから読み出す
    let luaOutputData = lua.stdout!.availableData
    let luaOutputString = String(data: luaOutputData, encoding: .ascii)!
    
    print("Lua says: \(message)")
    print("Captured output: \(luaOutputString)")
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

コンソール出力:

Lua says: Hello, Lua!
Captured output: Hello, Lua!
Lua state 0x0000000103016208: stdout closed
Lua state 0x0000000103016208: stdin closed

標準入力に値を流し込む場合はこのようになります:

do{
    // 標準入力のキャプチャを構成
    try lua.configureStandardInput()
    
    // Luaへ入力
    try lua.stdin!.write(contentsOf: "hello, lua!\n".data(using: .ascii)!)
    
    // stdinから読み出す
    try lua.eval("print(string.upper(io.read()))")
} catch let luaError as LuaError {
    print(luaError)
} catch {
    print("Unexpected error: \(error)")
}

コンソール出力:

HELLO, LUA!
Lua state 0x0000000102029e08: stdin closed

ここでio.read()はいわゆるgetlineであるため、データ末尾に改行を入れる必要がある点に注意してください。

インストール時の注意点

CygnusはLua言語環境を提供しますが、masterブランチにはLuaのソースは含まれていません。これはLuaの更新への追従を容易にするためです。
Luaソースを含む(ビルド可能な)完成品はreleaseブランチに置かれています(リリース時にLuaのミラーから特定のバージョンをcloneして注入、pushするようになっています)。

そのため、Xcodeから依存関係を追加する場合はreleaseブランチまたはバージョン範囲を指定してください。masterブランチを指定して追加するとビルドに失敗します。

add package

コントリビュートいただく場合は、cloneしたローカルリポジトリで startup.sh を実行してください。これによりLuaのソースが配置され、ビルド可能になります。

パッケージが提供するモジュール

はじめに述べたとおり、Cygnusはアプリケーション内でユーザが直接Luaコードを記述し、アプリの動作をカスタマイズする…といった用途を想定しています。それゆえLua C APIが提供する関数はなるべく隠蔽し、Swiftらしく書けるような設計となっています。しかし、Luaをバインドしたいが別にリッチな機能は必要ない、ただAPIを呼べればよいという需要もあるかと思います。

そこで、Cygnusはその機能ごとにモジュールを分割し、需要に合わせて選択できる形としました。Cygnusをプロジェクトに追加すると、以下の3モジュールが使用可能になります:

  • Cygnus : パッケージの主体となるモジュール。後述する入出力キャプチャ等の機能を提供します。
  • CygnusCore : LuaをSwift向けにバインドしたモジュール。Lua APIが提供するほぼすべての関数を直接呼び出すことができます。
  • CygnusMacros : CygnusCoreを補完するモジュール。Coreと分かれている理由は後述します。

Cygnusの機能のみを使用する場合はCygnusを、Lua APIのみを使用する場合はCygnusCoreおよびCygnusMacrosをインポートします。これにより必要な機能のみを導入することが可能です。

おわりに

ここまで読んでいただきありがとうございました。

アプリ上に言語環境を丸ごと用意するとなると結構ハードルが高そうに聞こえますが、Luaはソースファイルも少なくバインドも容易で流石だなあという感じでした。
Cygnus自体はSwift packageとしてビルドしているので、おそらくiOS上でも動作するはずです(ちょっと具体的なユースケースが想像できないですが……)

今回はmacOS向けのサンプルとして開発環境のようなものを作りましたが、アプリの動作をコードによりカスタマイズできるのは大きな可能性を感じました。大手ソフトウェアがやっていることを自分のプロジェクトでできるというのはやはり面白いですね。

それでは。

fflush(stdout)

脚注
  1. Roblox, ドラゴンクエストX(開発言語として採用)等 ↩︎

  2. Adobe Photoshop Lightroom, AviUtl等 ↩︎

  3. The Standard Libraries - Lua 5.4 Reference ↩︎

  4. stateを外部から注入した場合はこの動作は行われません ↩︎

  5. 厳密には同じ実装ではありません ↩︎

Discussion

tana00tana00

macOS Command Line Toolプロジェクトを作って記事のCygnus PackageをAdd Dependenciesしてみた。
Addする際に記事にあるようにmaster Brunchを選択してはいけない。
Swiftの関数をLuaから呼べるってのが面白い。

Swiftの関数を登録し、Luaから呼び出すサンプルの変数に対するコメント

state: Lua言語エンジン, cf. lua.push { state in ... }
lua: Lua実行環境, cf. let lua = Lua(state: state, owned: false)

第 5 回: Lua を組み込み用の言語として利用する方法 (関数編) — WTOPIA v1.0 documentation
Luaプログラミング   C言語ソフトウェアからLuaを呼び出す方法
Programming in Lua : 24.1