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。
これは次のように、ライブラリのローダを作る。ローダはアクセスされたモジュール・クラス名から、それが定義されているファイル名を特定して、ローダ((実体は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が悪いとか、そういうフィードバックがあればぜひ教えてほしい。