2024年3月20日
田籠 聡 (tagomoris)
Rubyに、通常のグローバルな階層化名前空間をもつモジュール等のライブラリを、読み込み時に規定した仮想的なトップレベル名前空間に読み込む機能を追加することを目的としてパッチ開発を行う。
この名前空間に読み込まれたライブラリはグローバルな名前空間とは独立した存在となるため、名前衝突の回避、共有されたモジュール・クラスの操作・変更に関する競合の回避、ならびに将来的にはアプリケーションが依存するライブラリ間でのバージョン競合の回避の実現を可能にすると期待している。
プロジェクト期間中において、次の成果を得た。
Namespaceの機能としてどのようなものが期待されるか、それぞれの機能の実現可能性があるかについては、機能についての詳細な議論、および実際に各機能を実装してみることでおおむね確認されたと言ってよいと考える。内容については、後の「Namespaceの機能」で詳述する。
Namespace(あるいは類似)の機能をRubyにおいて実現するにあたり、他の拡張ライブラリからグローバルに公開されたシンボルを拡張ライブラリ内において直接呼び出すようなことはできなくなるものと予想される。このため、他の拡張ライブラリのシンボルを解決し取得するようなC APIの整備が、Namespaceの実装前に行われていることが望ましいことがわかった。
この機能は単独でも価値があると思われたため、別個に提案し、実装した。これはRuby本体に取り込まれ、Ruby 3.3として既にリリースされている。
Namespaceの機能を検討するにあたり、どのような機能が必要か、各機能は何を考慮して実装されなければならないかを明らかにするということを主目的として、Ruby上でのNamespaceの実装を行った。
この実装は現段階である程度動作するものの、不具合や未サポートの機能などがあり、完全な状態ではない。ただし、プロジェクト開始当初の時点では実装が難しいのではないかと思われた機能などについても、実際には実現可能性がかなり高いのではないかということが実証された。また実装上の技術的困難についても多くのことを明らかにすることができた。内容については、後の「Namespaceの実装」で詳述する。
Namespaceの機能とは、Rubyスクリプトならびに拡張ライブラリを読み込むにあたり、仮想的なトップレベル空間を作成してその内部で読み込みを行い、Rubyスクリプトならびに拡張ライブラリによって行われる変更は作成された空間内に限定して行われ、その他には影響を与えないというものである。このとき、読み込みの対象であるRubyスクリプトもしくは拡張ライブラリが依存するスクリプト・拡張ライブラリは同様にこの空間内で読み込まれるものとする。 Rubyスクリプト実行時にクラス・モジュールが作成される名前空間をここではNamespaceと呼び、Rubyプロセス実行時に自動的に作成され使われるものをグローバルNamespace、仮想的なトップレベル空間として必要に応じて作成・使用されるものをローカルNamespaceと呼ぶことにする。
Namespaceはrequire
、require_relative
、load
の各メソッドを備えており、これらのメソッドによりRubyスクリプトならびに拡張ライブラリを読み込む。
当プロジェクトにおいて、Rubyスクリプトならびに拡張ライブラリによって実行される変更のうち、Namespaceがその内部に限定して行うとしたものは、以下のものがある。これらの変更はNamespace内部に限定して行われ、他のNamespaceには影響しない。
- クラス・モジュールの追加
- 組込みクラス・モジュールの変更
- トップレベルメソッドの追加
- 定数の追加
- グローバル変数の参照、変更、追加
ローカルNamespace内で定義・作成された処理(メソッド、ブロック)は、どのNamespaceから呼ばれても定義・作成されたNamespaceの中の処理として動作する。
クラス・モジュールを定義する構文class A
、module B
については、ローカルNamespace内に指定した名前のものが存在しない場合、グローバルNamespaceや他のローカルNamespaceに存在するかどうかに関わらず、該当の名前でクラス・モジュールを追加する。これはグローバルNamespaceからは、ローカルNamespaceの名前をns
だとするとns::A
、ns::B
という名前で参照できる。ローカルNamespace内に指定した名前のものが存在する場合にはそのクラス・モジュールをreopenして変更することは通常のRubyと同様である。
ただし、ローカルNamespace内で指定されたA
やB
に相当する名前のものがRubyの組込みクラスとして存在する場合には、クラス・モジュールを追加せず、組込みクラスをreopenする。これは組込みクラスに対する既存のモンキーパッチを行うコードをNamespace内でも同じように動作させるための挙動である。
なお、当報告書の時点においては、Namespaceの実装は組込みクラス・モジュールに対する特異メソッドの追加を正しく処理できない。詳しくは「Refinementsによる実装の弊害」において述べる。
Rubyの機能のうち、以下のものはNamespace内においても挙動を調整しておらず、Namespaceによる変更の限定などは行われない。
- オブジェクトのメソッド呼び出し
- オブジェクトに対する特異メソッドの追加
- クラス変数の参照、変更、追加
- クラスインスタンス変数の参照、変更、追加
オブジェクトのメソッド呼び出し、オブジェクトに対する特異メソッドの追加に関しては、そもそもオブジェクトに何がしかの変更を行うことがライブラリやアプリケーション等の目的であることは珍しくなく正当であるため、変更の影響をNamespace内に限定しようとすることは本来の動作を損うこととなる。
クラス変数、クラスインスタンス変数については、ローカルNamespaceの内部から外部のクラス・モジュールを参照できる場合に限って影響がある。しかし前述したとおり、Namespace内においてclass
やmodule
構文を用いて参照できるのは組込みクラス・モジュールに限られ、また組込みクラス・モジュールはクラス変数、クラスインスタンス変数を用いていないであろうことから、Namespaceにおいて考慮する必要はないものと考えられる。
Namespaceを作成し、その内部で読み込むファイルを明示的に指定していくのは、多くのRubyユーザにとっては詳細に過ぎる可能性がある。当プロジェクトにおいてNamespaceについての議論を行う上では、Namespaceの機能をより抽象度の高い方法でくるんで提供するべきではないかという点が指摘された。
現段階で考えられるAPIとしては、現在広く使われているRubyGemsの機能とgemファイルを、Namespace内で読み込むための以下のようなメソッドであろう。これは想定としては、該当および依存先のgemを新しく作成したローカルNamespace内で読み込み、export
によって指定した定数のみをグローバルNamespaceに取り出して使用可能とするものである。
pkg = require_package('my-awesome-gem')
MyAwesomeGem = pkg.export(:MyAwesomeGem)
MyAwesomeGem.execute_something()
これにgemのバージョン指定機能やデフォルトでexportするシンボルの指定などを加えれば、Namespaceの機能的詳細を知らなくとも、グローバルNamespaceへの影響をほとんど与えない形でのgemの読み込みが便利に行えるようになる。
このAPIについては、Namespaceの実装をさらに進めた段階でbugs.ruby-lang.orgにて実際に提案することを検討している。
Namespace内でのclass
、module
構文による既存クラス・モジュールのreopenについては、対象を組込みクラスに限定することとした。
しかし、ごく特殊な状況においてローカルNamespaceからグローバルNamespaceのクラス・モジュールをreopenしたいという要求がないとは言えない。この場合にclass
やmodule
構文では新規クラス・モジュールが作成されてしまうため、そのままでは使用できない。よって、明示的にクラス・モジュールをreopenするための構文が必要となる。
これについても当プロジェクト中に議論を行った。アイデア自体は[Feature #20093]において既に提案されているが、さらに行われた議論の結果、以下のような構文のほうがより好ましいかもしれない、という結論が得られた。
class + String
# ...
end
module + Kernel
# ...
end
class extension String
などの中置キーワードを用いるアイデアは、中置であってもキーワードの追加が必要となり整理が難しいこと、class String
に対して文字数の増加が著しく記述時、視認時ともに従来記法からの乖離が大きいことなどの点から難があり、対して+
を用いるのであればいずれの点についても問題が小さくなるであろうという評価である。
実際には、この機能の要不要を含めて再度の議論が必要となると思われるが、当報告書として暫定的なアイデアを記すものである。
実装の詳細についてはpull-requestを参照していただきたく、ここで全てを解説するものではないが、概略を記す。
まず組込みクラスとしてNamespace
を定義する。これはModule
を継承する。またこれに対応する内部用の構造体rb_namespace_t
を定義し、内部実装としてはこの構造体がNamespaceを表現する。rb_namespace_t
はメンバとして、require
やload
が行われる際に必要となるLOAD_PATH
、LOADED_FEATURES
ならびに関連する内部情報をひと揃いで有している。ローカルNamespace内部でファイルの読み込みが行われるときには、Ruby VMが従来より持っているLOAD_PATH
やLOADED_FEATURES
などのかわりに、rb_namespace_t
のメンバを用いて動作することとしている。またRubyスクリプトの読み込み時には、Rubyに従来から存在するload_wrapper
としてNamespace自身を指定することで、新たに定義されるクラス・モジュールをこのNamespaceの下に作成するようにしている。
クラス、モジュール、メソッド、ブロック(Proc)の各オブジェクトは、作成されたNamespaceをそれぞれ保持する。これは不可視のインスタンス変数を用いるもの(クラス、モジュール)と構造体に追加されたメンバを用いるもの(メソッド、Proc)がある。またこのNamespaceがセットされていないものは、Namespaceの初期化が行われる前に作成・定義されたもので、Ruby組込みのものだと判定できる。
実行中の処理がどのNamespaceで行われているかについては、まずrb_thread_t
構造体にrb_namespace_t *ns
メンバとして現在のNamespace、ならびにVALUE namespaces
としてNamespaceのスタックを保持しており、Namespace#require
などを実行することで新しいNamespaceをセット(スタックにpush)する。
現在のNamespaceはrb_thread_t
構造体のメンバをth->ns
のように参照することで確認するが、加えてメソッドコール時には実行中メソッドの定義がどのNamespaceで行われたかを優先的に取得し確認する必要がある。Proc作成時の情報は実行中に参照できないため、Procの呼び出しが行われるときにはrb_control_frame_t
に追加したメンバns
にProc作成時のNamespaceをセットし、これを確認することで現在のNamespaceを決定することとしている。
組込みクラスの変更をNamespace内に限定する手法としてはRefinementを用いている。rb_namespace_t
構造体にはrefiner
というモジュールがひとつ保持されており、Namespaceが行う暗黙的なRefinementを定義するためのモジュールとして使用される。
ローカルNamespace内で組込みクラスに対するclass
が指定されたとき、これを自動的にrefiner
内でのrefine
メソッド呼び出しに読み替えて実行することで、新たに定義されるメソッドを暗黙のRefinementとしてNamespaceに限定した形で追加できる。定数宣言についても、新たな定数はRefinementに対して追加される。
ローカルNamespace内でRubyスクリプトが評価されるときにはすべて暗黙にusing refiner
が実行されており、暗黙のRefinementにより追加されたメソッドがすべて使用できるようになっている。
なおトップレベルメソッドについても、Object
クラスの暗黙のRefinementとして処理することでNamespace内部でのトップレベルメソッドの追加を実現している。
Refinementにより自動的に解決されるメソッド呼び出しと異なり、定数とグローバル変数については個別に対応を行う必要がある。
定数参照時に、それがNamespace内で変更された組込みクラスであれば、まず暗黙のRefinementを参照し、それから通常の定数参照を行う。
グローバル変数については、Namespaceごとにグローバル変数のテーブルを保持しており、ここに独自の値を保持する。グローバル変数の参照が行われた場合、まずプロセス全体のグローバル変数を参照し、その値が複製可能であれば複製した結果を、複製不可能であればそのままの値をNamespaceのテーブルに保存する。以降、同じグローバル変数の参照があればNamespaceのテーブルの値を返し、異なる値をセットする場合にもNamespaceのテーブルのみを変更する。なお値の複製にはclone
メソッドを用いる。
単純な機能から実装を始めた際にスレッドを用いたため、現在のNamespaceを確定するには複数の箇所を参照する必要がある。これは性能に問題を与える可能性があり、またデータ構造としても冗長でメンテナンスのうえで問題がある。
最終的には、ProcのNamespaceを保持するため制御フレーム(rb_control_frame_t
)にメンバを足す必要があった。しかし統一して制御フレームに実行中Namespaceを保持させるのであれば、スレッドに現在実行中のNamespaceを保持する必要はなく、全体の構造と処理がより単純なものにできる可能性がある。
一方で制御フレームにNamespaceを保持させるのは全体の効率やコードのメンテナンス性に問題を与える可能性もあり、判断にはRuby VMに詳しいコミッタのレビューを要する。
組込みクラスに対する変更の制御にはRefinementの機能を用いているが、これには以下のとおりの弊害があり、理想的なものとは言えない。
Refinementは宣言の順序に影響を受けるため、組込みクラスを変更してメソッドを追加するコードは、追加されたメソッドを使用するコードよりも前に書かれている必要がある。しかし通常のRubyのオープンクラスを用いる際にはそのような制約は存在しない。このため、既存のコードを実行すると順序の問題でエラーを起こす可能性がある。
これはNamespace内で宣言されたメソッドを実行するとき、必ず一度暗黙のusing
を実行する、などの方法で回避できる。ただしこの手法には性能的に問題があるように思われる。
Refinementはinclude
とextend
を禁止している。この制約をNamespaceに限り外しても、通常のクラス・モジュールでinclude
やextend
したようには動作しない。これは[Bug #17374]において議論されている理由による。
現在の実装ではinclude
をRefinement#import_methods
に置き換えて実行している。組込みクラスの変更において継承順位が問題になることはおそらくないため多くのケースでそれらしい挙動をするであろうが、しかし正しい挙動ではない。またinclude
されるべきモジュールの定数が読み込めないという問題もある。
組込みクラスに対する特異メソッドの追加を行うとき、class
構文内でclass << self
を用いたりdef self.method_name
を用いたりできるが、これを現在の実装で解釈すると暗黙のRefinementをネストさせることとなる。しかしRefinementにはネストに意味がある挙動をするため、ローカルNamespace内では定義されたはずの特異メソッドが参照できないという結果となる。
これは現在解決策が見付かっておらず、このため現在の実装ではNamespace内での組込みクラスに対する特異メソッドの追加が正常に行えない。
これらの問題を踏まえ、Namespaceの実装においてはRefinementを使うべきか、あるいは他に何らかの機構を新しく用いるべきかを再度評価する必要がある。
当プロジェクトでは、自分が提案する機能"Namespace on read"を言語機能として検討し、かつそれらの機能が実装可能かどうかを実際に実装してみることで評価した。
結果として、現在のままRubyに適用できるような品質となっていないのは残念だが、まず言語仕様上の機能としてどのようなものであればよいか、どのようなものが実現できそうかということは示せたと考える。また、これからどのような問題を解決しなければならないか、実装上の改善がどこに必要かということも、現状の実装を評価することで明らかにできていると考える。
今後はこの実装を改善するか、あるいはいくつかの点で根本的な改善を行って作り直すかを検討し、実施する予定である。
まつもとゆきひろさんには、メンターとして、また言語設計者として議論の時間を何度もとっていただき、言語機能としての検討をどのように進めるべきか、機能を検討する上で何を考慮する必要があるかなどについて多くの示唆をいただきました。ありがとうございました。
また笹田耕一さん、柴田博志さんをはじめ、多くのRubyコミッタの方々にアドバイスをいただくなどご助力いただきました。ありがとうございました。