こんにちは、遠藤(@mametter)です。
Ruby 4.0.0 では Ruby::Box が導入されました([Feature #21311])。
Ruby::Box は導入提案時には Namespace と呼ばれていたもので、Matz が「Namespace か ZJIT が入れば次のリリースを Ruby 4.0.0 とする」と RubyKaigi などで宣言していたくらい、肝いりの機能です。
ということで「なにやらすごい機能」という雰囲気だけは知っている人もいるかもしれませんが、実際のところ何ができるか、現状はどうか、は知らない人がほとんどだと思います。
そこで、遠藤が Ruby::Box について知っていることを、少ししっかり解説してみます。Ruby::Box で遊ぶ人の手助けになれば幸いです。
なお、Ruby::Box の設計・実装者は tagomoris さんで、遠藤は基本的に口出ししていただけの立場です。 この記事は公開前に tagomoris さんに目を通していただきましたが、文責は遠藤にあります。
Ruby::Box ひとめぐり
Ruby::Box は、ざっくり言えばクラスやメソッドの定義を隔離する機能です。
まずは、次のコードをじっくり読んでください。
# test.rb # このコードは ruby 4.0.0 で、`RUBY_BOX=1 ruby test.rb` と実行すること # Ruby::Box のインスタンス b を作る b = Ruby::Box.new # b の中でクラス Foo を定義する b.eval <<'RUBY' class Foo def foo = 42 end RUBY # クラス Foo は原則として、b の中でだけアクセスできる b.eval <<'RUBY' p Foo.new.foo #=> 42 RUBY # b の外では Foo は定義されていない Foo.new #=> uninitialized constant Foo (NameError)
Foo というクラスの定義が、Ruby::Box b の中に閉じ込められていることが感じられると思います。
少し付け足すと、b の外から b 内の Foo にアクセスするには、b::Foo と書けばよいです。
# 外から b 内の Foo にアクセスするには、b::Foo とする p b::Foo.new.foo #=> 42
また、eval の代わりに Ruby::Box#require というメソッドも使えます。
その Ruby::Box 内にファイルをロードします。
# b 内に ./foo.rb をロードする b.require "./foo"
これが Ruby::Box の基本です。
なお、Ruby::Box で遊ぶときは環境変数 RUBY_BOX=1 を設定してください。このようにしないと Ruby::Box は有効になりません。
$ ruby -e 'p Ruby::Box.new'
-e:1:in 'Ruby::Box#initialize': Ruby Box is disabled. Set RUBY_BOX=1 environment variable to use Ruby::Box. (RuntimeError)
from -e:1:in '<main>'
$ RUBY_BOX=1 ruby -e 'p Ruby::Box.new'
ruby: warning: Ruby::Box is experimental, and the behavior may change in the future!
See https://docs.ruby-lang.org/en/v4.0.0/Ruby/Box.html for known issues, etc.
#<Ruby::Box:3,user,optional>
Ruby::Box のユースケース
Ruby::Box の典型的なユースケースは、同名のライブラリを複数回ロードすることです。具体的には、次のようなケースが考えられます。
- 異なるバージョンの gem を同時に使う
- グローバル設定を持つ gem を複数の設定で使う
順に説明します。
ユースケース 1: 異なるバージョンの gem を同時に使う
次のような greeting gem を考えます。
# greeting-1.0.0.rb class Greeting VERSION = "1.0.0" def say_hello = puts("Hello, user!") end
# greeting-2.0.0.rb class Greeting VERSION = "2.0.0" def say_hello(name) = puts("Hello, #{name}!") end
greeting gem のバージョンが 1.0.0 から 2.0.0 に上がって、Greeting#say_hello メソッドの引数が非互換に変わっています。
そのため、greeting gem を 1.0.0 から 2.0.0 に上げるには、既存コードの say_hello の呼び出しをすべて say_hello("user") などに修正する必要があります。
しかし、複数人で開発しているモノリスでそれを一度にやるのは大変ですね。
Ruby::Box を使えば、1.0.0 と 2.0.0 を両方ロードして使い分けることができます。
# Ruby::Box b1 に 1.0.0 の greeting をロードする b1 = Ruby::Box.new b1.require "./greeting-1.0.0" # Ruby::Box b2 に 2.0.0 の greeting をロードする b2 = Ruby::Box.new b2.require "./greeting-2.0.0" # 各 Ruby::Box に、それぞれのライブラリがロードされている p b1::Greeting::VERSION #=> "1.0.0" p b2::Greeting::VERSION #=> "2.0.0" # それぞれの say_hello が呼び分けられる p b1::Greeting.new.say_hello #=> "Hello" p b2::Greeting.new.say_hello("mame") #=> "Hello, mame!"
大規模コードの一部では greeting 2.0.0 を使い、その他では 1.0.0 を使うことができます。 つまり、gem のバージョンアップデートを段階的にやっていく選択肢が生まれます。
注意
現実的には、say_hello 呼び出しの単位でバージョンを使い分けるのは、管理不能になりうるのでやめたほうがいいと思います(また、後述する理由で実際にはむずかしいとも思います)。
大規模コードを機能単位でざっくり分けておき(たとえば packwerk の pack 単位?)、それぞれに Ruby::Box を対応させておく、そして gem のバージョンアップは機能単位ごとに行うこと、がベストプラクティスになるのではないかと思ってます。 が、まだこのような経験を実際にやった人類はいないはずなので、本当にうまくいくかは不明です。今後のノウハウ共有に期待。
また、「このようなマルチバージョン対応はそもそも筋が良くない」という意見もあるようです。それでも、Ruby::Box とは何かを理解し、その応用を考えるための題材として最も良いと思うので、紹介しました。
なお、このような例を見ていると、Gemfile を Ruby::Box ごとに持たせたくなるかも知れません。 しかし、Ruby::Box と RubyGems/Bundler とのいい感じの連携は現時点ではまだありません。これも今後に期待。
ユースケース 2: グローバル設定を持つ gem を複数の設定で使う
もう 1 つ例を。設定がグローバルに保持されるライブラリを考えましょう。
先程の Greeting ライブラリで、Greeting.polite = true とすることでメッセージを丁寧にできるとします。
# greeting.rb class Greeting @@polite = false def self.polite=(polite) @@polite = polite end def say_hello if @@polite puts "Hello. How are you today?") else puts "Yo!" end end end
@@polite はグローバル設定なので、どちらかにしか固定できません。
途中で切り替えることはできますが、マルチスレッドのプログラムではスレッドセーフでなくなります。
require "./greeting" Greeting.new.say_hello #=> "Yo!" # 途中で切り替えることはできるが…… Greeting.polite = true Greeting.new.say_hello #=> "Hello. How are you today?" # スレッド内で使われると危うい Thread.new { Greeting.new.say_hello } #=> 何が出るかわからない Greeting.polite = false
Ruby::Box でライブラリを複数ロードすることで、複数の設定をスレッドセーフに持つことができます。
b1 = Ruby::Box.new b1.require "./greeting" b2 = Ruby::Box.new b2.require "./greeting" b2::Greeting.polite = true # スレッド内でも安全に使い分けられる Thread.new { b1::Greeting.new.say_hello } #=> "Yo!" Thread.new { b2::Greeting.new.say_hello } #=> "Hello. How are you today?"
個人的には「グローバル設定を持つライブラリがダサい」と言いたくなりますし、やるとしてもスレッドローカルストレージに保存すればいいのにと思いますが、現実にこういうライブラリはしばしばありそうですね。
Ruby::Box はオブジェクト空間の隔離ではない
Ruby::Box を概観して来ましたが、ここまでの説明を見て「Ruby::Box はオブジェクト空間を隔離する」と思った人がいるかもしれません。 これは誤解なので注意してください。
Ruby::Box をまたがってオブジェクトを受け渡す例(非推奨)
Ruby::Box はあくまで、クラス名(定数名)の空間を分けているだけです。 各々のオブジェクトは、「所属する Ruby::Box」というような概念はもちませんし、Ruby::Box をまたがって受け渡すことも許されています。
次のコードを見てください。
# b1 内に Foo クラスを定義する b1 = Ruby::Box.new b1.eval <<'RUBY' class Foo def say_hello = puts("Hello") end RUBY # b2 内に Test クラスを定義する b2 = Ruby::Box.new b2.eval <<'RUBY' class Test # accept メソッドで b1::Foo のインスタンスを受け取ることができる def self.accept(b1_foo) b1_foo.say_hello end end RUBY # b1::Foo のインスタンスを作る b1_foo = b1::Foo.new # b2 内のメソッドに渡すことができる b2::Test.accept(b1_foo) #=> "Hello"
b1_foo は「b1 に属す」というような概念はありません。
b2 内のメソッドが b1_foo のオブジェクトを受け取り、b1::Foo#say_hello を呼び出せています。
ある Ruby::Box で定義されたクラスのインスタンスを、別の Ruby::Box のメソッドに渡すことは制限されていません。
つまり、オブジェクト空間の分離ではないということです。
(ちなみに、メソッド経由以外に、b2::X = b1::Foo.new のように定数定義で強引に渡す技もあります)
Ruby::Box で同名のクラスを定義した場合の挙動
このように、Ruby::Box をまたがったオブジェクトの受け渡しは許されていますが、(遠藤の私見では)決して推奨されているわけではありません。 というのも、非常にややこしいバグの元になりうるからです。
次の Data クラスを使った例を見てください。
# b1 内に User クラスを定義する b1 = Ruby::Box.new b1.eval <<'RUBY' class User < Data.define(:name) def greeting = "Hello #{self.name}" end RUBY # b2 内にも User クラスを定義する # 注意:b1::User と b2::User はまったく別のクラス! b2 = Ruby::Box.new b2.eval <<'RUBY' class User < Data.define(:name) def greeting = "こんにちは #{self.name}" end class Test def self.test(a) = a.greeting end RUBY # b1::User のインスタンスと b2::User のインスタンスはまるで同じに見えるが、実際には全くの別物 p b1::User.new("mame") #=> #<data User name="mame"> p b2::User.new("mame") #=> #<data User name="mame"> # 比較すると全く別のオブジェクトと判定される p b1::User.new("mame") == b2::User.new("mame") #=> false # .greeting メソッドはそれぞれのクラスの定義が尊重される p b1::User.new("mame").greeting #=> "Hello mame" p b2::User.new("mame").greeting #=> "こんにちは mame" # .greeting メソッドを b2 内から呼び出しても、それぞれの定義が尊重される p b2::Test.test(b1::User.new("mame")) #=> "Hello mame" p b2::Test.test(b2::User.new("mame")) #=> "こんにちは mame"
Data クラスのインスタンスは、メンバの値が同じならば全体としても同じと判定されるのですが、ここでは b1::User と b2::User そのものが食い違っているため、それらのインスタンス同士も(中身は同じなのですが)別物と判定されています。
このように、別の Ruby::Box で定義されたクラスのインスタンスを混ぜて使うのは大変な混乱の元なので、避けるのが賢明だと思います。 そして、この理由により、メソッド呼び出しのような細かい単位で gem バージョンを使い分けるのはリスクがあります。 機能単位など、オブジェクトのやり取りが把握できる範囲で gem バージョンアップするのがよいでしょう。
なお、上記の挙動はあまりにもわかりにくいということで、#<data b1::User name="mame"> のように Ruby::Box を表示できないか?という検討はあったりなかったりします。
心構え
原則として、「Ruby::Box をまたがったオブジェクトの受け渡しはするな」と覚えておくとよいと思います。
厳密なことを言えば、b1::User のようにクラスオブジェクトを b1 から読み出すのも「Ruby::Box をまたがったオブジェクトの受け渡し」になるので、どこまで避けるべきかは今後模索していくことになると思います。
Integer や String のような組み込みクラスは Ruby::Box 間で共有されているので、それらのインスタンスは受け渡ししても比較的安全です。これに関する話を次に説明していきます。
モンキーパッチの隔離
さて、Ruby::Box にはもう 1 つ、とても愉快な機能があります。 それは、組み込みクラスのモンキーパッチの隔離です。
モンキーパッチとは
Ruby は組み込みクラスにメソッドを追加したり、既存メソッドを上書き定義したりできます。これを俗にモンキーパッチといいます。
Ruby では、整数同士の除算は切り捨てです。算数の好きな一部の人は、これを有理数を返すようにしたいと言います。このようなとき、次のようにモンキーパッチが使えます。
p 5 / 3 #=> 1 class Integer def /(other) = self.quo(other) end p 5 / 3 #=> (5/3)
しかし実際にこのようなことをすると、切り捨てを期待しているほとんどのコード・ライブラリが動かなくなってしまいます。
実際、太古の Ruby にはこのようなモンキーパッチをあてる "mathn" というライブラリが標準添付されていたのですが、他コードとの相性の悪さから、徐々に廃れて消えてしまいました。
Ruby::Box によるモンキーパッチの隔離
Ruby::Box を使えば、このようなモンキーパッチの影響を隔離・局所化することができます。
b = Ruby::Box.new # b の中で Integer#/ をモンキーパッチする b.eval <<'RUBY' class Integer # Rational を返す def /(other) = self.quo(other) end RUBY # b 内では、除算が有理数を返す b.eval <<'RUBY' p 5 / 3 #=> (5/3) RUBY # b の外では、除算が従来通り切り捨てをする p 5 / 3 #=> 1
モンキーパッチを隔離する機能としては refinement がすでにありましたが、使いたい文脈でいちいち using SomeRefinement などと書かないといけません。Ruby::Box はそのような明示が必要ありません。
ユースケース
この機能は、ActiveSupport を Ruby::Box 内に隔離するために導入されました(ただし、まだその用途では動いてないようです)。
ActiveSupport や mathn はもちろんモンキーパッチ隔離のユースケースの 1 つなのですが、個人的にはこの機能は他にもとても使い出があると考えています。
たとえば、irb でユーザコードを実行する環境を隔離するのに使えそうです。
今の irb では、String#+ を不用意に再定義すると、irb が次のように落ちてしまいます。
ユーザコードを評価する Ruby::Box を分けておけば、このようなやんちゃなコードもいい感じに扱えるのではないかと思います。
$ irb
irb(main):001> class String; def +(_) = raise; end
An error occurred when inspecting the object: RuntimeError
Result of Kernel#inspect: #<Symbol:0x0000000000002b0c>
=> :+
(irb):1:in 'String#+': unhandled exception
from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:658:in 'block in IRB::Irb#format_prompt'
from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:626:in 'String#gsub'
from /home/mame/.rbenv/versions/ruby-dev/lib/ruby/gems/4.0.0+0/gems/irb-1.15.3/lib/irb.rb:626:in 'IRB::Irb#format_prompt'
...
from /home/mame/.rbenv/versions/ruby-dev/bin/irb:25:in '<main>'
$
また最近、Ruby の組み込みメソッドが C から Ruby で書き直される動きがあるのですが*1、このせいでモンキーパッチが思わぬメソッドに影響することがあります。
たとえば現状では、Integer#times の Ruby 実装は #succ を使うので、Integer#succ をモンキーパッチすると Integer#times が動かなくなってしまします。
class Integer def succ = raise end # Integer#times の Ruby 実装は #succ を呼ぶので、エラーになる 1.times { p "ok" } #=> RUBY_BOX=0: in 'Integer#succ': unhandled exception # RUBY_BOX=1: "ok"
Ruby::Box はこの問題をすでに解決しています。上のコードを RUBY_BOX=1 で実行すると、Integer#times は Integer#succ の再定義を無視して動きます。
これは、RUBY_BOX=1 が指定されたとき、組み込みメソッドを定義する Ruby::Box(Ruby::Box.root)と、ユーザコードの Ruby::Box(Ruby::Box.main)が分離されているからです。
ユーザコードの中で行わなわれた Integer#succ へのモンキーパッチは、組み込みの Ruby::Box 空間から見て隔離されているということです。
注意点
以上のモンキーパッチの隔離は、組み込みクラスだけのスペシャル機能です。
組み込みクラスとは、Ruby インタプリタを起動した直後から利用可能なクラス、つまり Array 、Integer 、String などなどです。
これらは、ユーザが定義するクラスとは根本的に扱いが違うことに注意してください。
別の Ruby::Box で同名のクラスを定義しても、それらは全くの別物だと言いました(b1::User と b2::User の例)。
しかし、Integer のような組み込みクラスは特別に Ruby::Box 間で共有されます。
そのかわり、同じ Integer クラスのメソッドテーブルが Ruby::Box ごとに分離されています。
次の例をじっくり研究するとなんとなく見ててくるかもしれません。
# b1 は Integer#/ をモンキーパッチする b1 = Ruby::Box.new b1.eval <<'RUBY' class Integer def /(other) = self.quo(other.n) end ONE = 1 TWO = 2 RUBY # b2 は引数に対して / を呼ぶ b2 = Ruby::Box.new b2.eval <<'RUBY' class Test def self.test(a, b) = a / b end RUBY # b1::ONE と b1::TWO を b2 内で除算しても、切り捨てになる # (つまり、b1 内の Integer のモンキーパッチは b2 内では有効にならない) p b2::Test.test(b1::ONE, b1::TWO) #=> 0
要するに、モンキーパッチの定義は Ruby::Box をまたがって持ち越されることはありません。
注意点 (2)
微妙なのが Date です。
Time は組み込みクラスなので、Ruby::Box を超えて混ぜても違和感なく扱えるのですが、Date は組み込みクラスではない(require "date" しないと利用可能にならない)ので、b1::User / b2::User のように非直感的な動きが起きてしまいます。
# b1 と b2 それぞれで date ライブラリを require する # (別々の Date クラスが定義される) b1 = Ruby::Box.new b1.require "date" b2 = Ruby::Box.new b2.require "date" # b1::Time と b2::Time は実体として同じクラス t1 = b1::Time.new(2025, 12, 25) t2 = b2::Time.new(2025, 12, 25) p t1 #=> 2025-12-25 00:00:00 +0900 p t2 #=> 2025-12-25 00:00:00 +0900 # Time インスタンスは比較してもおかしいことは起きない p t1 == t2 #=> true # b1::Date と b2::Date は別物 d1 = b1::Date.new(2025, 12, 25) d2 = b2::Date.new(2025, 12, 25) p d1 #=> #<Date: 2025-12-25 ((2461035j,0s,0n),+0s,2299161j)> p d2 #=> #<Date: 2025-12-25 ((2461035j,0s,0n),+0s,2299161j)> # これらの Date インスタンスは、比較すると異なるものと判定される p d1 == d2 #=> false
個人的には Date を使わないのがおすすめですが、短期的にはそうも行かないので、どうしたもんだか、というとこで止まっています。
その他の話
Ruby::Box には他にもいろいろ注意点や逸話があります。 あまりコーナーケースに立ち入らない範囲で、駆け足で説明しておきます。
トップレベル定数参照は Ruby::Box で閉じる
::A とやるとトップレベルの定数を参照できますが、Ruby::Box 内では、その Ruby::Box 内でのトップレベル定数を探します。
Foo = "toplevel" b = Ruby::Box.new b.eval <<'RUBY' Foo = 42 p ::Foo #=> 42("toplevel" ではない) RUBY
これにより、::Foo という参照がある既存ライブラリをそのまま Ruby::Box#require で読んでも動きます。たぶん。
Ruby::Box はグローバル変数も隔離する
これまで定数の話ばかりしていましたが、グローバル変数も同様に隔離されます。
$foo = 42 b = Ruby::Box.new b.eval <<'RUBY' p $foo #=> nil RUBY
グローバル変数はもはやグローバルではありません($VERBOSE とか、いくつかのグローバル変数は共有するようにしよう、という議論はあります)。
Ruby::Box はそれぞれ $LOAD_PATH/$LOADED_FEATURES を持つ
$LOAD_PATH と$LOADED_FEATURES はグローバル変数なので、Ruby::Box がそれぞれ定義を持つことになります。
Ruby::Box#require はそれぞれの定義に従うのでご注意ください。
Ruby::Box.new はまっさらな環境を作る
Ruby::Box.new をする前にクラスやメソッドを定義していても、新しい Ruby::Box にその定義は引き継がれません。
class Foo end b = Ruby::Box.new b.eval <<'RUBY' Foo #=> uninitialized constant Foo (NameError) RUBY
定義を引き継ぎたいケースも結構あるんじゃないかな、と個人的には思っていますが、仕様も実装もだいぶややこしくなることから、一旦このようになってます。
Ruby::Box はサンドボックスにはならない
なりません。Ruby::Box はセキュリティ対策のために作られているものではありません。
たとえば、ObjectSpace などを使えば外のオブジェクトを引っ張り出すことは可能ですし、その他にもいろいろ技があると思います。
Ruby::Box の API
今のところ、次のメソッドがあるようです。
Ruby::Box.enabled?: Ruby::Box の機能が有効になっているかどうかRuby::Box#eval: Ruby::Box 内でコードを実行するRuby::Box#require: Ruby::Box 内でソースコードファイルをロードするRuby::Box#require_relative: ↑のKernel#require_relativeバージョンRuby::Box#load: ↑のKernel#loadバージョンRuby::Box#load_path: Ruby::Box 内の$LOAD_PATH?(よくわかってない)Ruby::Box.current: 現在の Ruby::Box を返すRuby::Box.main: トップレベルの Ruby::Box を返すRuby::Box#main?: ↑かどうかを返すRuby::Box.root: 組み込みクラスが定義されている Ruby::Box を返す(むやみに使わないほうがよい)Ruby::Box#root?: ↑かどうかを返す
Kernel.load の第 2 引数との関係
実は、load メソッドの第 2 引数にモジュールを渡すことで、定数の定義先を指定する機能は以前からありました。
m = Module.new # モジュール m の文脈で a.rb をロードする load("a.rb", m) p m::A #=> #<Module:0x000078d927d4a520>::A
これは Ruby::Box の原形とも言える機能なのですが、あまり洗練されていなかったので、実際にはほとんど使えなかったのではないかと思います。
たとえば、トップレベル定数 ::A がそのままグローバルトップレベルの定数を参照するとか。
Ruby::Box は、この機能をめちゃくちゃ洗練させた機能と言えなくもないかもしれません。
"Namespace" という名前はどうなった?
当初 "Namespace" という名前で議論されていたこの機能ですが、Matz によると、名前の用法の衝突からこの名前を避けたとのことです。 というのも、たとえば my_app gem を書くとき、
module MyApp ... end
のようにコード全体を module MyApp でくくると思います。
これを俗に "Namespace" や "Namespace module" と呼ぶ言葉の用法はすでに確立しているので、これとの混乱を避けるため、別の名前になりました。
また、一部の Rails アプリ(具体的には GitLab)で Namespace というトップレベル定数がすでに定義されていて、変更するには DB テーブル名の変更が必要そうという事情も判明したので、それも多少は影響していると思われます。
で、もう使えるの?
使えません、少なくとも production では。
RUBY_BOX=1 で ruby 4.0.0 を起動すると warning: Ruby::Box is experimental, and the behavior may change in the future! という警告が表示される通り、現時点では production で使って良いクオリティには達していません。実際、RUBY_BOX=1 では Ruby のテストもまだ完走していないはず(たぶん)。
ただ、遊びに使えるクオリティには達していると思われるので、ぜひ遊んでみてください。
高レベル API ?
Matz 構想によると、この Ruby::Box は、今後 Ruby に導入していく(かもしれない)パッケージングシステムの低レベル API だそうです。 よって今後、Ruby::Box を土台にしたパッケージングシステムの高レベル API が爆誕する、かもしれません。
それは、Ruby 組み込み機能かもしれないし、autoload に対する zeitwerk のような(Ruby 標準ではない)デファクトライブラリになるかもしれません。 どちらにしても、誰かが現状の Ruby::Box を使ってそういうものを作ろうと試みて、Ruby::Box の足りない機能の洗い出しや必要な仕様調整をしていく必要がありそうです。
まとめ
ということで、Ruby 4.0.0 現状の Ruby::Box についての、遠藤視点でのダイジェスト紹介でした。
これはあくまでダイジェストです。説明を割愛したコーナーケースはまだまだありますし、遠藤が知らない(なんなら作者の tagomoris さんも認識してない)コーナーケースや問題もきっと山のようにあると思います。
近年これ以上ないくらいの特大コントリビューションチャンスなので、ぜひいろいろ遊び倒してみてください。
*1:Ruby で書いたほうが JIT が効いて高速になることがあるため。