たごもりすメモ

コードとかその他の話とか。

ご意見募集: Rubyに名前空間サポート的なものが欲しいという話

LFAを書いたときの話にあるKernel#loadの第2引数で名前空間的なものを作れるんだけど、loadした先のファイルでrequireされてたらダメなんだよね、という話の続き。ダメなんだよねー、で終わってたんだけどRubyKaigi2023で@shioyamaさんのMultiverse Rubyを聞いて、ここに仲間がいた!!! ってなって、さらにそのあとバーで飲みながらやろうやろうって盛り上がったので、なんか色々考えている。

RubyKaigiの話は別途書くとして、いまはとりあえずこっち。

後半に、こんなものが欲しい、という話、および読んだ人の意見が欲しいということが書いてあるので、このあたりに何か思うところがある人はぜひ読んでみてください。どっちかというと、自分以外のRubyユーザがどう考えているのかを、bugsに出す前にまず知りたいなと思っています。

å‹•æ©Ÿ

Rubyにはみなさんご存知の通り、名前空間的なものがない。どんなスクリプト・ライブラリ・アプリケーションでも、クラス・モジュールを定義するとき、基本的には名前空間のトップレベルに置くか、あるいは既存のモジュール・クラスの下に置く。しかしこれには以下に説明するような問題がある。

名前の衝突

複数のライブラリが同じ名前を使っている場合、もちろん競合する。同じ種類(クラスorモジュール)だった場合はお互いを上書きするし、異なる種類だった場合にはふたつめを読み込んだ時点で例外となる。

またRubyの世界で支配的なRuby on Railsが「基本的にアプリケーションのクラス・モジュールはすべてトップレベルに置く」という規約を用いているのがこの制約を破滅的にしている。トップレベルの名前空間が膨大な数のユーザー定義クラスに消費されている「かもしれない」。このため、ライブラリ作者がライブラリを作るときには、トップレベルにはUserもAccountもStrategyもGuildもRecipeも使えない。*1

トップレベルで名前が衝突したとき、片方がアプリケーションであれば名前を変更できるかもしれないが、ふたつのライブラリが同じ名前を使っていた場合、ひとつのアプリケーションから両方を使うことは選択肢から無くなる。現状であれば、どちらかを諦めなくてはいけない。

複数バージョンの使用不可

ライブラリはバージョンアップを行っても、多くの場合、当然同じ名前を用いる。このため現在のRubyではどうがんばっても特定ライブラリの複数バージョンを1プロセス内には共存させられない。これは、アプリケーションが依存する複数のライブラリ(A, B)が同じライブラリ(C)に依存するが、しかしそれぞれバージョン制約が異なる、という状況を解決できないことを意味する。

App ---> A ---> C (~> 1.4.0)
    |
    +--> B ---> C (~> 2.0)

これはアプリケーション開発者にとってはかなり厳しい。上記の状況であればライブラリAの開発者に依存関係を更新してもらうようお願いすることになる。Aの開発者は依存関係を更新してその状況でAの動作確認とリリースを行い、その上でアプリケーション開発者がまた依存関係の更新とテストを行うことになる。

危険なライブラリグローバル

ライブラリに設定を行うことがあるが、これはクラス変数を用いて実装されている可能性がある。例えば動作が速いからと使われることも多いojは、以下のように設定を行う。

Oj.default_options[:allow_gc] #=> true
Oj.default_options = {allow_gc: false}

Oj.default_options[:allow_gc] #=> false

これはもちろんOj.parseしたときの動作を変更する。つまり、どこか(自分に責任のない)アプリケーションやライブラリの片隅で設定を変更されると、プロセス内すべてにおけるライブラリの動作に影響を与える。

名前空間による解決

ここに書いた問題は、基本的に名前空間があれば解決できる、と自分は信じている。Rubyの動作をいきなり変える必要はない。オプショナルに指定できる名前空間があればよい、と思っている。

Imを使ってみた

名前空間をRubyで実現する試み(Multiverse Rubyと表現されている)として@shioyamaさんが作ったものがIm*2。

github.com

これは次のように、ライブラリのローダを作る。ローダはアクセスされたモジュール・クラス名から、それが定義されているファイル名を特定して、ローダ((実体はModuleの派生モジュール))自身の下部に読み込む。

require "im"
loader = Im::Loader.for_gem
loader.setup # ready!

loader::MyGem # ロードパス中の 'my_gem.rb' が特定され自動的に読み込まれる

これをKaigi中に試してみたんだけど、LFAでは以下の理由があって使えない。またgemなどのライブラリに使うのにも現状だと厳しいと思う。

loader.setupする前の時点でロードパスを確定する必要がある

これはImがZeitwerkをforkして作られているからという実装上の理由が大きいかもしれない。内部的にloader.setupした時点でロードパスにある全てのファイルをリストアップして定数名とautoloadする対象ファイル名の対応表を作るため、loader.setupした後で別のディレクトリにあるファイルからクラス・モジュール定義を読み込もうと思っても難しい。((unloadしてロードパスを追加後にsetupしなおせば可能だけど、なかなか常用には厳しいと思う。))

LFAはアプリケーションの起動時にロードパスを確定できるのでこの点は問題ではないが、通常のRubyに入れる、という機能としては厳しい制約だと思う。

ロード対象のクラス・モジュール名とファイル名の関係に強い制約がある

これもZeitwerk由来だからだが、参照された定数名をフックにして読み込むファイル名を特定する関係上、サポートできないものが多くなる。

loader::MyGem # -> my_gem
loader::MessagePack # -> message_pack だが msgpack.rb を読んでほしい……
loader::StringIO # -> requireする対象は stringio だよね

LFAでは読み込み対象のファイルもそこに書かれているクラス・モジュール名も設定ファイルから指定する*3ため、名前ベースで読み込み対象を決定する規約は不要というか、制約となって使用できない。また一般的に、gemでも難しいと思う。Ruby本体の標準添付ライブラリでもこの命名規約に従っていないものは多い。

拡張ライブラリに対応できない

Kernel#loadの第2引数を用いたやりかたはpure Rubyなライブラリでrequireを使っていないものにはうまく動くんだけど、そうでない場合にはうまくいかない。まあrequireを使われていてもその先がpure Rubyコードならなんとかなるかなと思う((具体的にはKernel#requireを上書きしてloadをwrapモジュールと一緒に呼ぶようにする))けど、拡張ライブラリの場合にはそういった方法も使えない。

欲しいもの

自分が欲しいものは以下のように使える名前空間オブジェクトだ。実体としては、拡張された機能のrequireおよびloadメソッドをもつModuleのサブモジュールになる。これが実現できれば、例に示すように、トップレベル名前空間での衝突の回避、異なるバージョンのライブラリの読み込み、クラス変数の使用による意図しない挙動変化の防止、どれもが実現できる。

この名前空間を実現するモジュールを、以下のコード例ではModuleBoxと呼ぶ。なお@shioyamaさんと話していたときには「Hakoと呼ぶのがよいのでは」ということになっていた*4。

box1 = ModuleBox.new # or ModuleBox.new(load_path: $LOAD_PATH + ['...'])
box2 = ModuleBox.new

基本的にはインスタンスを作り、その名前空間内でのみ何かをしたいとき、そのインスタンスに対して操作する。

隠蔽された名前空間での読み込み

# 隠蔽された空間でのスクリプトのload
box1.load('my_client.rb')
box1::MyClient #=> MyClient

box2.require('guild')
box2::Guild #=> Guild

MyClient #=> NameError
Guild #=> NameError

box1::Guild #=> NameError
box2::MyClient => NameError

# import to a different name can be done
MyGuild = box2::Guild
MyGuild.build(...)

異なるバージョンのライブラリの読み込み

box1.require('msgpack', version: '1.6.0')
box2.require('msgpack') #=> latest one

msgpack1 = box1.const_get(:MessagePack)
msgpack1::VERSION #=> "1.6.0"
msgpack2 = box2::MessagePack
msgpack2::VERSION #=> "1.7.0"

クラス変数を用いた設定変更の影響の局所化

require('oj')
box1.require('oj')

oj1 = box1.const_get(:Oj)
oj1.default_options[:allow_gc] #=> true
oj1.default_options = oj1.defualt_options.merge({allow_gc: false})
oj1.default_options[:allow_gc] #=> false

Oj.default_options[:allow_gc] #=> true

これから

自分のアイデアはもちろんただのアイデアなので、まだ何ひとつ実現できていない。関係しそうなコードは読んでみた結果、Ruby本体を変更しないと実現できそうにないのはわかっているので、bugs.ruby-lang.orgにFeatureを出してみて、自分の手元で実装にチャレンジしてみるかなあと思っている。

これを読んだ人には、上記のアイデアがどんなもんに見えるかを考えてみてほしい。印象を聞きたい。 またこの機能だけでは不十分だとか、APIが悪いとか、そういうフィードバックがあればぜひ教えてほしい。

*1:トップレベルにそんなの置くなよ、というのはあるが、まあ……。

*2:イムと読む。ImportのImだから。

*3:AWS Lambdaがそのようになっているので、LFAの設定も当然それを踏襲している

*4:英語話者からしてもHakoというのは響きがよい……らしい。