10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

VimAdvent Calendar 2022

Day 12

関数を返す関数を呼び出せる関数を返す関数を書く

Last updated at Posted at 2022-12-11

どうも意味分からんタイトルですみません。でもこのタイトルのことをやるのがこの記事の目的です。

packer.nvim でよく書くこれ

packer.nvim でプラグインを追加する際、setup 関数を設定の度に呼び出すことがよくあります。

use {
  "rcarriga/nvim-notify",
  config = function()
    require("notify").setup {}
  end,
}

こういうのしょっちゅうあると思うんですが、なんかいちいち function() …… end で囲むのダルくないですか? function って文字数多いしイヤなんですよね。

use {
  "rcarriga/nvim-notify",
  config = lazy_require("notify").setup {},
}

こう書けたらスマートですよね。これができるようになるプラグインを作りました。

ふーん……で、何がすごいの?

いや意外と難しいんですよ。これ。理由は packer.nvim の「コンパイル」機能にあります。例えば上記の rcarriga/nvim-notify のための設定は PackerCompile コマンドによって以下のようなコードに「コンパイル」されます。

["nvim-notify"] = {
  config = { "\27LJ\2\n8\0\0\3\0\3\0\a6\0\0\0'\2\1\0B\0\2\0029\0\2\0004\2\0\0B\0\2\1K\0\1\0\nsetup\vnotify\frequire\0" },
  loaded = false,
  needs_bufread = false,
  only_cond = false,
  path = "/Users/jinnouchi.yasushi/.local/share/nvim/site/pack/packer/opt/nvim-notify",
  url = "https://github.com/rcarriga/nvim-notify"
},

config 関数が訳分かんない文字列になってますね。これバイトコードなんです。packery.nvim は高速化のため、ユーザーの設定を中間表現にコンパイルして保持し、Neovim 起動時には高速に読み込んでくれるんですね。

Lua でコードを中間表現に変換し、それをまた実行するには次のようにします。

local function print_hoge()
  print "hogehogeo"
end

-- 中間表現に変換
local byte_code = string.dump(print_hoge)

-- もう一度関数に変換
local hoge_fn = assert(loadstring(byte_code))
hoge_fn()

これをファイルに保存して実行すると hogehogeo と表示されます。ここでもう一度上述の設定行を見てみます。

  config = function()
    require("notify").setup {}
  end,

packer.nvim は config に関数が指定されるとこれを string.dump でバイト列に変換し、Neovim 実行時は逆に loadstring で関数に変換しているのですね。

任意のモジュールを require して結果を返す関数を返す関数

まずはこれを書かないといけません。どういうことかと言うと、packer.nvim でプラグインを追加している部分では生の require "notify" を書いてしまうと notify モジュール自体を表してしまいます。欲しいのは実行時に notify モジュールを返してくれる関数であって、notify モジュール自体ではありません。

function lazy_require(module)
  return function()
    require(module)
  end
end

lazy_require "notify"  --> notify モジュールを返す関数
lazy_require "notify" ()  --> notify モジュール

となっていい感じです。

任意のモジュールの関数を呼び出せるようにする

しかし、最終的にやりたいのはモジュール自体を手に入れることではなく、モジュールの setup 関数を呼び出したい、ということでした。

function lazy_require(module)
  return function()
    return {
      setup = function(opts)
        return function()
          require(module).setup(opts)
        end
      end,
    }
  end
end

lazy_require("notify").setup {}  --> require("notify").setup {} を行う関数

setup 以外の任意の関数を呼び出せるようにするなら、ちょっと複雑ですがメタテーブルを使えば良いです。

function lazy_require(module)
  return function()
    return setmetatable({}, {
      __index = function(_, method)
        return function(opts)
          return function()
            require(module)[method](opts)
          end
        end
      end,
    })
  end
end

lazy_require("notify").setup {}  --> require("notify").setup {} を行う関数
lazy_require("notify").history(1)  --> require("notify").history(1) を行う関数

できましたね。これで完成!……ではないんですね。

loadstring するとクロージャは使えない

そうなんです。loadstring で作成された関数はあくまでコード自体を中間表現にしたものを表しているだけで、呼び出された時のコンテキストは保持していません。上記の実装の場合、

config = lazy_require("notify").setup {}

これが packer.nvim によって string.dumploadstring されると、以下のような意味になってしまいます。

-- 以下は疑似コードです。
config = function()
  require(module)[method](opts)
end

module, method, opts のような、関数外で定義された変数は参照できないのですね。つまり、クロージャが扱えないんです。

これをどう解決するのか悩んだんですが、クロージャをただの関数にしてしまうことにします。どうするかと言えば、変数の値を含め、バイナリ(中間表現)にして保持してしまうのです。

クロージャを中間表現で表す

先ずは、lazy_require "notify" の部分を考えます。

function lazy_require(module)
  return require(module)
end

こういうやつですね。これを中間表現にする関数は以下のように書けます。

local function byte_code_require(module)
  local f = assert(loadstring(('return require "%s"'):format(module)))
  return string.dump(f)
end

byte_code_require "notify" などとすると、require "notify" の結果を返す関数、を中間表現にして返してくれます。実際に見てみると次のようなバイナリです。

local byte_code = byte_code_require "notify"
print(vim.inspect(byte_code))
--> "\27LJ\2\b\23return require \"notify\")\2\0\3\0\2\0\3\4\0\0016\0\0\0'\2\1\0D\0\2\0\vnotify\frequire\1\1\1\0\0"
--> と表示されます。

バイナリの中に return require \"notify\" のような文字列があり、引数として指定したモジュール名が埋め込まれていることが分かりますね。これなら loadstring してもコンテキストが再現可能です。

次に、lazy_require("notify").setup {} のように、任意のプロパティの関数を呼び出す関数の中間表現を返してみましょう。

local function index(self, method)
  return function(opts)
    local f_str = ([[
      local module = assert(loadstring(%s))()
      local opts = assert(loadstring(%s))()
      module.%s(opts)
    ]]):format(vim.inspect(self.load_module), vim.inspect(serialize(opts)), method)
    return assert(loadstring(f_str))
  end
end

return function(module)
  return setmetatable({
    load_module = byte_code_require(module),
  }, {
    __index = index,
  })
end

ううむ。一気に難しくなりましたね。まず、ここではメタテーブルの __index に関数をセットし、プロパティを参照した時の動作を定義しています。関数 index() には self、つまりそのテーブル自身と、method、参照しようとした関数名が渡されます。例えば、lazy_require("notify").setup {} という記述は、

変数 内容
self { load_module = byte_code_require "notify" }
method "setup"
opts {}

のようになる訳です。関数の中では Lua のコードを文字列で作り、それを中間表現で返しています。

ここで、中間表現のバイナリを実行し、結果を得る方法をおさらいしておきましょう。あるバイナリが変数 foo に入っている時、そのコードを実行して結果を得るには次のような構文を使います。

local result = assert(loadstring(foo))()

上述の index() では次のようにコードを構成していますが、

local module = assert(loadstring(%s))()
local opts = assert(loadstring(%s))()

%s にはそれぞれ byte_code_require "notify" の返り値と、opts をシリアライズした文字列が入ります。これによりモジュール本体と、最終的に渡すオプション opts が手に入ります。そして最後に、

module.%s(opts)

のようにして呼び出しているのですね。%s には関数の名前、つまり今回の場合は "setup" が入ります。これで求めるものが手に入りました。

「シリアライズ」ってサラッと書きましたが、Lua ではこれも中々熱いテーマです。何しろ言語として提供されている機能ではないので様々な実装が存在します。今回は Tony Finch さんの実装を使いました。同種の実装・ライブラリは沢山ありますので以下のページを参照してください。

こんなに苦労したけど……

個人的には結構複雑なことやった気がするんですが、結果として得られたのは、

config = function()
  require("notify").setup {}
end

これを、

config = lazy_require("notify").setup {}

こう書けるようになっただけです。それでも苦労したことを書いとかないと多分自分でも忘れちゃうのでここに纏めておきます。

こんなに苦労する原因は、packer.nvimconfig オプションに与えられた関数を「コンパイル」してしまうからです。このような分かりにくさを嫌ってか、packer.nvim の次期バージョンは「コンパイル機能」を全く使わないものになる予定です。現在リポジトリの main ブランチで開発されているので参照してください。

こっちも見てね!

今回は Advent Calendar にもう 1 個参加してます。この記事はネタみたいなものですが、↓はかなり本気で書いたので是非読んでください。

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?