プロと読み解く Ruby 3.0 NEWS

技術部の笹田(ko1)と遠藤(mame)です。クックパッドで Ruby (MRI: Matz Ruby Implementation、いわゆる ruby コマンド) の開発をしています。お金をもらって Ruby を開発しているのでプロの Ruby コミッタです。

本日 12/25 に、ついに Ruby 3.0.0 がリリースされました。一昨年、昨年に続き、今年も Ruby 3.0 の NEWS.md ファイルの解説をします。NEWS ファイルとは何か、は一昨年の記事を見てください(なお Ruby 3.0.0 から、NEWS.md にファイル名を変えました)。

Ruby 3.0 は、Ruby にとってほぼ 8 年ぶりのメジャーバージョンアップとなります(Ruby 2.0 は 2013/02/24)。高速化(Ruby 3x3)、静的型解析、並列並行の3大目標をかかげて開発されてきた記念すべきバージョンですが、NEWS.mdはわりと淡々と書かれているので、この記事も淡々と書いていきます。

他にも Ruby 3.0 を解説している記事はいくつかあります。見つけたものだけリンクを置いておきます。

なお、本記事は新機能を解説することもさることながら、変更が入った背景や苦労などの裏話も記憶の範囲で書いているところが特徴です。

■言語の変更

キーワード引数の分離

  • Keyword arguments are now separated from positional arguments. Code that resulted in deprecation warnings in Ruby 2.7 will now result in ArgumentError or different behavior. [Feature #14183]

Ruby 3では、キーワード引数が通常の引数とは独立した引数になりました。これは非互換な変更になっています。

# キーワード引数を受け取るメソッド
def foo(key: 42)
end

foo(key: 42)      # OK: キーワード引数を渡している

opt = { key: 42 }
foo(opt)          # NG: 普通の引数を渡しているのでエラー(2.7では警告付きで動いていた)

foo(**opt)        # OK: ハッシュを明示的にキーワードに変換している

2.7では普通の引数をキーワード引数に暗黙的に変換していましたが、3.0からはこの暗黙的変換を行わないようになりました。多くのケースは上記の例のように、foo(opt)をfoo(**opt)のように書き換える、で対応できると思います。

なお、キーワード引数から普通の引数への暗黙的変換は維持されています(削除するには互換性の影響が大きすぎたため)。次のコードはRuby 3.0でも動作します。

# 普通のオプショナル引数を受け取るメソッド
def foo(opt = {})
end

foo(key: 42) # OK: キーワード引数が暗黙的に普通の引数に変換される

# # ↑は動きますが、今後は次のように書くのがおすすめです
# def foo(**opt)
# end

この変更についての詳細は、昨年の『プロと読み解くRuby 2.7 NEWS』や、Ruby公式サイトの移行ガイドを参照してください。

裏話

これの裏話を語りだすととても長いので、かいつまんで。

昨年の記事でも書いたことですが、Ruby 2.0でキーワード引数を最初に実装したのは私(遠藤)です。当時はRuby 1.8との連続性・互換性を意識しすぎたため、やや無理のある言語設計となっていました。そのため、非直感的挙動が頻繁に報告される(しかも本質的に壊れているので場当たり的な対応しかできない)という設計不良になっていました。これをどうにかすることは、Ruby設計者のmatzだけでなく、自分にとっても積年の悲願でした *1 。

とはいえ、多くのケースではそれなりに期待通りに動いてきた機能なので、2.7で変更を予告する警告を導入したところ、数多くの悲鳴や不満の声があがりました。変更の延期や中止も視野に入れつつ、Ruby on Railsの交流サイトにmatzがスレッドを立てて、ユーザの声を直接聞くことにしました。延べ40件ほどのさまざまなコメントをいただいたので、遠藤がすべてのご意見を何度も読み返し、分類集計しました。その結果、「変更予告の警告が出ること自体が不満 *2」「実務的な対応ノウハウが共有されていない *3」ということが不満の源泉で、問題の変更自体には意外と前向きな人が多いことがわかりました。そこで、前者の問題に対しては最善の対応ということで 2.7.2 でデフォルトで警告を無効にしました。後者の問題に対しては、コメント内で上げられた個別の問題に対して対処方法を一緒に考えていきました。また、警告を柔軟に非表示にできるdeprecation_toolkit gemがスレッド内で共有されたことも大きかったです。一方でRuby on Rails本体は(kamipoさんというすごい人やamatsudaさんなどのご尽力で)キーワード引数の分離に成功しました。分離を延期させるとRuby on Railsのリリーススケジュールに悪影響になる可能性がある *4 ということもヒアリングでわかったので、熟考に熟考を重ねた上で、3.0で変更を決行することになりました。

(文責:mame)

deprecated警告がデフォルトで出ないことになった

  • Deprecation warnings are no longer shown by default (since Ruby 2.7.2). Turn them on with -W:deprecated (or with -w to show other warnings too). Feature #16345

「廃止予定である」という警告は原則として$VERBOSEモードでしか表示されないことになりました。キーワード引数分離の警告だけではなく、すべてのdeprecated警告が対象です。3.0.0 からではなく、2.7.2 も変更されています。

前節で延べたように、キーワード引数分離の経験がきっかけで、deprecated警告のありかたが見直されたためです。昔は原則として、「まず$VERBOSEモードでだけ警告を出す」「次に無条件で警告を出す」「最後に変更する」という3バージョンを経て廃止を行っていました。しかしこれは変更までに時間がかかるわりに、無条件警告のフェーズはエンドユーザ(Rubyで書かれたプログラムを使うだけのユーザ)に見せても詮無い警告を見せるだけになるのでかえって不便、というフィードバックを多数得たので、無条件警告フェーズをなくすということになりました。

(mame)

引数委譲の記法の拡張

  • Arguments forwarding (...) now supports leading arguments. [Feature #16378]

キーワード引数の分離の悪影響の1つに、引数を委譲するのがめんどうになることがあります。そのため、Ruby 2.7では引数を委譲するための構文が導入されたのですが、引数を一切変更できないので使えるケースが限定されていました。

Ruby 3.0では、次のように、先頭の引数を取り除いたり、新しい値を追加したりすることが許されるようになりました。

def method_missing(meth, ...)
  send(:"do_#{meth}", ...)
end

先頭の引数以外はやはり変更できないのですが、これだけでも多くのケースが救われるという声が前述のヒアリングスレッドなどでも聞かれたため、導入されました。

(mame)

ブロックがキーワード引数を受け取る場合の意味の整理

  • Procs accepting a single rest argument and keywords are no longer subject to autosplatting. This now matches the behavior of Procs accepting a single rest argument and no keywords. [Feature #16166]

あまり知られていないかもしれませんが、Rubyのブロックの引数は伏魔殿です。

proc {|a, b|    a }.call([1]) #=> 1
proc {|a, k:42| a }.call([1]) #=> 1

上記のように、2引数以上を受け取るブロックに配列をひとつだけ渡して呼び出すと、配列の中身が引数として解釈されます。なので、上記の例ではaに[1]ではなく1が入ります。この挙動はautosplatなどと呼ばれることもあります(正式な機能名なのかは知らない)。

1引数のブロックではautosplatはされません。

proc {|a| a }.call([1]) #=> [1]

また、可変長引数を受け取るブロックでもautosplatはされません。

proc {|*a| a }.call([1]) #=> [[1]]

ただし、普通の引数に加えて可変長引数を受け取るブロックではautosplatがされます。

proc {|x, *a| a }.call([1]) #=> []  # xに1が入り、可変長引数のaは空配列になる

正直、autosplatの条件は遠藤も正確に理解していません(コードを読んでも理解できません)。非常にややこしい挙動ですが、多くの場合でうまく動くので、熟考に熟考を重ねた上でなんとなくこうなっています。

さて今回の変更は、可変長引数とキーワード引数を組み合わせた場合の話です。2.7まではautosplatがされていましたが、3.0からはautosplatがされないことになりました。

proc {|*a, k:42| p a }.call([1]) #=> [1]    # 2.7
proc {|*a, k:42| p a }.call([1]) #=> [[1]]  # 3.0

難しいですね……。

(mame)

$SAFE削除

  • $SAFE is now a normal global variable with no special behavior. C-API methods related to $SAFE have been removed. [Feature #16131]

古のセキュリティ機構である $SAFE 機能は、Ruby 2.7 で廃止されましたが(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ 「$SAFE の廃止」)、まだ対応していないコードに警告などを出すため、$SAFE自体を特別扱いして、何か代入されたら警告を出す、もしくは例外を出す、という挙動になっていました。このような特別使いを Ruby 3.0 からやめて、本当にただのグローバル変数になった、ということです。

$SAFE = 42
# Ruby 2.7 までは、エラー(0 or 1 しか許さなかった)、Ruby 3.0 からは素通し
p $SAFE #=> 42

(ko1)

$KCODE削除

  • $KCODE is now a normal global variable with no special behavior. No warnings are emitted by access/assignment to it, and the assigned value will be returned. [Feature #17136]

$SAFE と同じような話ですが、Ruby 1.9.0(ずいぶんと古いですね)から値を設定しても何も意味がなかった$KCODEについて、値を代入したり参照したりすると警告をだしていたのを、Ruby 3.0 からは特別扱いしないようにしました。

$KCODE = 42
p $KCODE
#=> Ruby 2.7 以前
# warning: variable $KCODE is no longer effective; ignored
# warning: variable $KCODE is no longer effective
# nil
#
#=> Ruby 3.0
# 42

(ko1)

シングルトンクラス定義の中での yield が禁止に

  • yield in singleton class definitions in methods is now a SyntaxError instead of a warning. yield in a class definition outside of a method is now a SyntaxError instead of a LocalJumpError. [Feature #15575]

次のようなコードがエラー(LocalJumpError)になるようになりました。

def foo
  class << Object.new
    yield
  end
end

foo{ p :ok } #=> :ok

Ruby 2.7で廃止予定となり(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ 「シングルトンクラスの中で yield は廃止予定」 )、順当に廃止された、という感じです。

(ko1)

パターンマッチが正式機能に

Ruby 2.7で試験的に導入されたパターンマッチですが、正式な機能となりました。

具体的な変更としては、パターンマッチを使うと出ていた警告が 3.0 では出なくなりました。

case [1, 2, 3]
in [x, y, z]
end
# Ruby 2.7 では警告が出ていた(3.0 では出ない)
#=> warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

(mame)

右代入が導入された

  • One-line pattern matching is redesigned. [EXPERIMENTAL]
    • => is added. It can be used as like rightward assignment. [Feature #17260]

一部で待望の機能とされている、右代入が導入されました。

{ a: 1, b: 2, c: 3 } => hash

p hash #=> [1, 2, 3]

さて、これはパターンマッチの一部と言うことになっています。よって、右側には任意のパターンが書けます。ただし下記の通り、experimentalであるという警告が出ます(パターンが単一の変数のときだけは導入が確定的なので、experimental警告は出ません)。

{ a: 1, b: 2, c: 3 } => { a:, b:, c: }
# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

p a #=> 1
p b #=> 2
p c #=> 3

{ a: 1, b: 2, c: 3 } => { a:, b:, c:, d: }  # NoMatchingPatternError(キーワード `d` がないため)

裏話

自分は右代入の使いどころがよくわかっていないのですが、複数行に渡るメソッドチェーンの最後に代入するときなどに便利という人が何人かいる(matzを含む)ので導入されたようです。正直、無理に使う必要はないと思います。

いくつか注意点だけ書いておきます。

パターンマッチの一部として実現されているため、インスタンス変数などに右代入することはできません(インスタンス変数はパターンとして書けないので)。

{ a: 1, b: 2, c: 3 } => @a  # SyntaxError

また、普通の代入と違って、返り値は利用できません。

ret = ({ a: 1, b: 2, c: 3 } => hash) #=> SyntaxError (void value expression)

さらに、うっかり引数に右代入を書こうとすると、キーワード引数になってしまうので注意です。

foo(val = expr)   # OK
foo(expr => val)  # NG: expr をキー、val を値とするキーワード引数

(mame)

一行パターンマッチが再設計された

前項の右代入は、Ruby 2.7では=>ではなくinという演算子で導入されていたものです。しかし、思ったほど使われなさそうということで、より右代入らしい記法で再試験導入することになりました。

そしてin演算子自体は、マッチの成否をtrue/falseを返すものに変わりました。

{ a: 1, b: 2, c: 3 } in { a:, b:, c: }     #=> true
{ a: 1, b: 2, c: 3 } in { a:, b:, c:, d: } #=> false
# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

Ruby 2.7に引き続き、experimental警告が出ます。

なお、inと=>は返り値以外は同じです。

(mame)

findパターンが追加された

配列の中でマッチする箇所を探索するパターンが試験導入されました。

case ["a", 1, "b", "c", 2, "d", "e", "f", 3]
in [*pre, String => x, String => y, *post]
  p pre  #=> ["a", 1]
  p x    #=> "b"
  p y    #=> "c"
  p post #=> [2, "d", "e", "f", 3]
end

ちょっとややこしいですが、[*pre, String => x, String => y, *post]というパターンは、Stringが2連続で登場する箇所を探すパターンです。上記の例では、"b", "c"の箇所にマッチしています(最初にマッチしたところで探索は止まります)。

裏話

matzの肝いりの新機能です。ユースケースがあまり明確ではないのですが、matzの一声で入りました。

探索を行うパターンは、あまり一般的なパターンマッチにはない機能ですが、線形探索しか行わないようになっているので、そこまで複雑な挙動にはならないと思います。

(mame)

スーパークラスでクラス変数を再定義してしまったとき、サブクラスで参照したときに例外が出るようになった

  • When a class variable is overtaken by the same definition in an ancestor class/module, a RuntimeError is now raised (previously, it only issued a warning in verbose mode). Additionally, accessing a class variable from the toplevel scope is now a RuntimeError. [Bug #14541]

実は継承が絡むと難しいクラス変数ですが、わかりづらい例で例外が出るようになりました。

まず、例外が出ないケースをご紹介します。

class C
  @@foo = :C
end

class D < C
  @@foo = :D # C の @@foo を変更している
end

class C
  p @@foo #=> :D
end

このとき、@@foo というのは、Dでも定義しているように見えて、実はCのクラス変数を変更しています。継承元を見るわけですね。ぱっと見た感じわかりづらい。

さて、Cの@@fooがあったときは、Dの文脈でクラス変数を設定する、ということはわかりました。では、先にDに設定したあと、その基底クラスであるCのクラス変数を設定したらどうなるでしょうか。

class C
end

class D < C
  @@foo = :D # D の @@foo に代入
end

class C
  @@foo = :C # C の @@foo に代入
end

class D
  p @@foo
  # Ruby 2.7 以前
  #=> warning: class variable @@foo of D is overtaken by C
  #=> :C
  #
  # Ruby 3.0
  #=> class variable @@foo of D is overtaken by C (RuntimeError)
end

Ruby 2.7までは、Cに上書きされてしまったぞ、というような警告を出して、Cの@@fooが参照されるようになりました(そのため、警告の後、:Cが返る)。しかし、ちょっとわかりづら過ぎるだろう、ということで、警告の代わりにエラーが出るようになりました(RuntimeError)。

それからついでに(?)、トップレベルでクラス変数を設定したり参照したりすることが禁止(RuntimeError)されました。

# 設定も禁止
@@foo = 1
#=> class variable access from toplevel (RuntimeError)

class Object
  @@bar = 2
end

# 参照も禁止
p @@bar
#=> class variable access from toplevel (RuntimeError)

正直よくわかんないんで、クラス変数はなるべく使わない方がいいと思いますねぇ(とくに、継承を絡めて)。Ractorで使えないし。

(ko1)

Numbered parameter への代入が警告から禁止に

  • Assigning to a numbered parameter is now a SyntaxError instead of a warning.

1.times{|i| p i} の代わりに 1.times{p _1}のように、ブロック仮引数の名前を暗黙の引数名で書けるというNumbered parameterという機能が Ruby 2.7 から導入されました(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ 「Numbered parameters」)。

_1などを特別扱いするにあたって、既存のコードで_1などの名前を利用している例について議論があったのですが、Ruby 2.7の段階では「まぁ、そんなに使ってないだろうから、警告だけ出しとこ」、となりました。Ruby 3.0 では、利用している箇所を全部エラーにするようにしました。

_1 = 10   # ローカル変数名として利用

def a _1  # 仮引数名として利用
end

def _1 # メソッド名として利用
end

1.times{|_1| p _1} # ブロックの仮引数名として利用

この例では、

  • Ruby 2.6 までは、問題なく利用可能
  • Ruby 2.7 では、パース時にそれぞれ "warning: `_1' is reserved for numbered parameter; consider another name" という警告
  • Ruby 3.0 では、パース時にそれぞれ "_1 is reserved for numbered parameter" というエラーメッセージで構文解析失敗

となります。最後の例は、意味が変わらないので通ってもよさそうですが、まぁ自分で変数名として使う分には一律禁止になりました。

(ko1)

一行メソッド定義が追加された

endのないメソッド定義の新文法が導入されました。

def square(x) = x * x

p square(5) #=> 25

次のように書くのとまったく同じ意味です。

def square(x)
  x * x
end

こんな単純なメソッドのために3行も書かなくて良くなりました。嬉しいですよね??

無引数のメソッドも素直に書けますが、=の前にスペースが必須です。

def answer = 42 # OK

def answer=42   # NG: SyntaxError

なぜなら、setterメソッドとの区別ができないためです。また、setterメソッドは見た目がややこしくなることもあり、一行メソッド定義では書けなくなっています。

def set=(x) = @x = x
#=> setter method cannot be defined in an endless method definition

裏話

もともと私(遠藤)が提案した機能です。「Rubyの文法はendを多用するので、Ruby が終わりそうで縁起が悪い」というエイプリルフールネタでした。

しかしmatzはエイプリルフールネタということを理解した上で「細かい点を除けば真面目にポジティブ」といい、nobuが細かい問題を解決した *5 ので、入ってしまいました。

真面目な話、上記の square メソッドのように簡単なメソッド定義で 3 行も書くのは無駄なような気はしていました。Rubyのパッケージに含まれているコードで調べると、なんと 24% のメソッド定義が 3 行であることがわかりました。まあ、それでも新文法を導入するのは躊躇しそうなものですが、「一部のプログラムで便利な可能性がありそう」というmatzの直感により導入されました。

Rubyの新機能提案ではユースケースを強く求められますが、matzだけは例外です。直感に基づく決断は、言語仕様を委員会制で決める言語ではできないと思うので、面白いなあと思っています。

なお、一行メソッド定義は十分シンプルな場合に使われることを想定しているので、副作用を伴う式などは書かないほうがよいです。setterメソッドが定義できなくなっているのには、そういう理由もあります。

(mame)

式展開を含む文字列リテラルは、frozen-string-literal: true で freeze しなくなった

  • Interpolated String literals are no longer frozen when # frozen-string-literal: true is used. [Feature #17104]

# frozen-string-literal: true を指定しておくと、その後にくる文字列リテラルがすべて frozen な状態となります。

# frozen-string-literal: true

p "foo".frozen? #=> true

これは、式展開を含む文字列リテラル(埋め込み文字列)も frozen にしていました。

# frozen-string-literal: true

p "foo#{42}bar".frozen? #=> true

Ruby 3.0からは、埋め込み文字列については freeze せんでもいいだろ、ってことで freeze されなくなりました(この例では false が出力される)。

frozen-string-literal: true 自体は、最初から freeze しておくことで何度も同じ文字列を生成しなくても済む、ということを意図していたけれど、埋め込み文字列はそういうわけにもいかないので、毎回生成しています。つまり、この利点はないのにわざわざ freeze しなくてもいいだろう、という提案です。

私の記憶が確かなら、この埋め込み文字列の挙動は、埋め込み文字列でも文字列リテラルの一種なので、文字列リテラルが frozen である、という指定なら、埋め込み文字列も freeze しちゃったほうが理解はしやすいよね、という意図で freeze していたと思うのですが、Matz が、まぁ freeze せんでもいいよね、って言ったので freeze しないようになりました。

個人的には、freeze したままのほうが良かったなぁ。

(ko1)

静的解析基盤が導入された

  • A static analysis foundation is introduced. See "Static analysis" section in detail.
    • RBS is introduced. It is a type definition language for Ruby programs.
    • TypeProf is experimentally bundled. It is a type analysis tool for Ruby programs.

RBSとTypeProfが導入されました。この辺はすでに別記事を書いているのでご参照ください。

techlife.cookpad.com

techlife.cookpad.com

(mame)

コマンドラインオプション

--helpとページャ

  • When the environment variable RUBY_PAGER or PAGER is present and has non-empty value, and the standard input and output are tty, --help option shows the help message via the pager designated by the value. [Feature #16754]

細かい話です。環境変数 RUBY_PAGER か PAGER が空でない文字列で設定されていれば、ruby --helpという詳細ヘルプを出力するとき、それをページャーとして利用して出力するようになりました。最近、git とかでも見る挙動ですね(git では環境変数が設定されてなくても less を起動しちゃうけど)。

関係ないけど、ruby -h で簡易版ヘルプ、ruby --help で詳細版ヘルプが出ます。

(ko1)

--backtrace-limitオプション

  • --backtrace-limit option limits the maximum length of backtrace. [Feature #8661]

例外発生時のバックトレースの最大行数を指定するオプションが導入されました。

def f6 = raise
def f5 = f6
def f4 = f5
def f3 = f4
def f2 = f3
def f1 = f2
f1

みたいなコードを次のように実行すると ... 3 levels... のように省略されます。

$ ruby --backtrace-limit=3 test.rb
-e:6:in `f6': unhandled exception
        from -e:5:in `f5'
        from -e:4:in `f4'
        from -e:3:in `f3'
         ... 3 levels...

これは、後述するバックトレースの再逆転に際して導入されました。

(mame)

■組み込みクラスのアップデート

Array のサブクラスのメソッドが、サブクラスではなく、Array クラスのオブジェクトを返すようになった

  • The following methods now return Array instances instead of subclass instances when called on subclass instances: [Bug #6087]
    • Array#drop
    • Array#drop_while
    • Array#flatten
    • Array#slice!
    • Array#slice / Array#[]
    • Array#take
    • Array#take_while
    • Array#uniq
    • Array#*

何を言ってるかと言うと、Arrayを継承したクラスを定義した場合の話です。

class MyArray < Array
end

ary = MyArray.new([1, 2, 3]).drop(1)

p ary       #=> [2, 3]
p ary.class #=> MyArray  # 2.7
p ary.class #=> Array    # 3.0

上記の通り、MyArray#dropなどはMyArrayのインスタンスを返していました。 一方で、MyArray#rotateは2.7でもArrayのインスタンスを返していたので、一貫性がない状態になっていました。 3.0からは、このようなメソッドは一貫してArrayを返すようになりました。

この問題はRuby 2.0のころに指摘されましたが、「直したい気もするけど非互換が気になるので次のメジャーバージョンのときに考えよう(=忘れてしまおう)」という判断になっていました。が、たまたま今年思い出してしまったので、直すことになりました。9年越しの修正。

わりと直前(リリース2ヶ月前)に変わっているので、非互換問題がおきないといいなあ。個人的には、ArrayやStringのようなコアクラスはあまり継承しないほうがいいと思います。

(mame)

String のサブクラスのメソッドが、サブクラスではなく、String クラスのオブジェクトを返すようになった

  • The following methods now return or yield String instances instead of subclass instances when called on subclass instances: [Bug #10845]
    • String#*
    • String#capitalize
    • String#center
    • String#chomp
    • String#chop
    • String#delete
    • String#delete_prefix
    • String#delete_suffix
    • String#downcase
    • String#dump
    • String#each_char
    • String#each_grapheme_cluster
    • String#each_line
    • String#gsub
    • String#ljust
    • String#lstrip
    • String#partition
    • String#reverse
    • String#rjust
    • String#rpartition
    • String#rstrip
    • String#scrub
    • String#slice!
    • String#slice / String#
    • String#split
    • String#squeeze
    • String#strip
    • String#sub
    • String#succ / String#next
    • String#swapcase
    • String#tr
    • String#tr_s
    • String#upcase

前項と同じ変更は文字列の方でも行われています。

なお、この変更で Rails の SafeBuffer クラスが動かなくなっていました。Rails の最新版では修正されています。

(mame)

Dir.globの結果がソートされるようになった

  • Dir.glob and Dir. now sort the results by default, and accept sort: keyword option. [Feature #8709]

そのままです。

# Rubyのパッケージディレクトリで実行する

Dir.glob("*.c") #=> ["marshal.c", "symbol.c", "regparse.c", "st.c", ...]  # 2.7
Dir.glob("*.c") #=> ["addr2line.c", "array.c", "ast.c", "bignum.c", ...]  # 3.0

Ruby 2.7まではDir.globはファイルシステムに依存する順序でファイルを列挙していましたが、Ruby 3.0からはデフォルトでソートされるようになりました。もしソートしてほしくない場合は、Dir.glob("*.c", sort: false)としてください

ファイル列挙はO(n)でできるのに、ソートをするとO(n log n)になってしまう、ということで若干の躊躇がありましたが、現実的にはファイルアクセスに比べて文字列ソートは無視できるほど速いこと、また、Linuxのglob(3)もデフォルトでソートするらしいことが決め手となり、変更されました。

「globの結果はsortして使え」というRubocopのルールがあるらしいですが、Ruby 3.0からは無意味になるのでやめたほうが良さそうです。

(mame)

Windows のデフォルト外部エンコーディングが UTF-8 になった

  • Windows: Read ENV names and values as UTF-8 encoded Strings [Feature #12650]
  • Changed default for Encoding.default_external to UTF-8 on Windows [Feature #16604]

Windowsでは、ロケールによらずに、デフォルトの外部エンコーディング(-E オプションが指定されないときの Encoding.default_external)が UTF-8 になりました。

> ruby -e 'p Encoding.default_external'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p Encoding.default_external'
#<Encoding:Windows-31J>

また、環境変数の値は、ロケールによらず UTF-8 になりました。

> ruby -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

(ko1)

IBM720 というエンコーディングの追加

IBM720 と、そのエイリアス CP720 というエンコーディングが追加されたそうです。

(ko1)

Fiber scheduler が導入された

  • Fiber
    • Fiber.new(blocking: true/false) allows you to create non-blocking execution contexts. [Feature #16786]
    • Fiber#blocking? tells whether the fiber is non-blocking. [Feature #16786]
    • Introduce Fiber.set_scheduler for intercepting blocking operations and Fiber.scheduler for accessing the current scheduler. See doc/scheduler.md for more details. [Feature #16786]
  • ConditionVariable
    • ConditionVariable#wait may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • IO
    • IO#nonblock? now defaults to true. [Feature #16786]

    • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets) may invoke the scheduler hook #io_wait(io, events, timeout) in a non-blocking execution context. [Feature #16786]

  • Kernel
    • Kernel.sleep invokes the scheduler hook #kernel_sleep(...) in a non-blocking execution context. [Feature #16786]
  • Mutex
    • Mutex is now acquired per-Fiber instead of per-Thread. This change should be compatible for essentially all usages and avoids blocking when using a scheduler. [Feature #16792]
  • Queue / SizedQueue
    • Queue#pop, SizedQueue#push and related methods may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • Thread
    • Thread#join invokes the scheduler hooks block/unblock in a non-blocking execution context. [Feature #16786]

I/O 処理など、実行するとブロックする処理では、それを待っている間に他の独立した処理を行うと効率が良くなることが知られています。これまでは、スレッドを使うか、EventMachine などを使って自分で組み立てていく必要がありました(いわゆる、ノンブロッキングなプログラミング)。これを、I/O などでブロックしたら、他の独立した Fiber を実行するようなスケジューラを、Ruby で記述するための仕組みが Fiber scheduler です。

機能の紹介

Fiber scheduler によって、I/O などの、待ちを多く含んだ大量の処理を並行に行わなければならない用途で、Fiber を使って、スレッドよりも軽量に扱うことができます。このために、イッパイ変更が並んでいますね。

現在の MRI のスレッドは、1つのRubyスレッドに対して1つのOSスレッドを作ります。そのため、生成が重い、上限がけっこうすぐくる、という問題があります。

$ time ruby27 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m7.305s
user    0m6.726s
sys     0m20.182s

$ time ruby30 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m14.677s
user    0m5.722s
sys     0m10.415s

このシステムだと、3万個程度で上限がきます(OSのプロセス数の上限)。あれ、Ruby 3で時間が倍くらいになってますね...なんでだろ。

Fiber ですと、こんな感じ。

$ time ruby27 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.452s
user    0m0.244s
sys     0m0.208s

$ time ruby30 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.497s
user    0m0.277s
sys     0m0.220s

あれ、数は3万個程度ですね。これは、メモリプロテクションのためにmmapを使っているのですが、この生成上限にあたっているのではないかと思います(Cannot allocate memory とあるのがそれ)。数はおいといて、生成速度を比べると、1桁違います。あと、ちゃんと書いていないですが、メモリ消費も Fiber のほうが少ないです。

このへんが、Fiber は軽量といっている理由になります。

Fiber もスレッドも、どちらも並行処理(たとえば、独立したIO処理、典型的にはウェブリクエストをうけてレスポンスする処理)を行うのは同じですが、スレッドはテキトーなタイミング(処理系依存のタイミングともいう)勝手に切り替わるのに対し、Fiber は自分で "resume/yield" などを利用して切り替えを行う必要があります。これは、勝手に切り替わらない、という Fiber のメリットでもあるのですが、Fiber を用いて IO 処理をやっていると、read などでブロックしてしまうと切り替えるタイミングを逸してしまうので(他の実行可能な Fiber に処理をうつすことができないので)、read などブロックするような処理を避けてプログラミングする必要がありました。

Fiber scheduler は、典型的なブロッキングをするような処理(readとか)が起こったら、ユーザーレベル(つまり、Ruby)で記述されたハンドラに飛ばして、自分で non-blocking IO 処理を書いて他の Fiber に処理をうつす、といったことを自分で書くことができるようにする仕組みです。このハンドラを定義するオブジェクトを、ここではスケジューラーと呼んでいます。

現在実行中のスレッドのスケジューラを設定するには、Fbier.set_scheduler(scheduler_object) のように指定します(スレッドローカルな属性です)。

ブロッキングするような処理が起きるとスケジューラーのハンドラが起動されます。現在次のような処理で、スケジューラを呼び出します。

  • ConditionVariable#wait
  • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets)
  • Kernel.sleep
  • Mutex#lock and related methods
  • Queue#pop, SizedQueue#push and related methods
  • Thread#join

どのメソッドが、どのようなスケジューラーのフックを呼ぶかどうかは、詳細なので立ち入りません(詳細は ruby/fiber.md at master ・ ruby/ruby をご覧ください)。

少し試してみましょう。sleepすると、スケジューラーのハンドラが呼ばれるので確認してみます。method_missing だけを定義したスケジューラを用意して、どのようなフックが呼ばれるか確認してみましょう。

class MyScheduler
  def method_missing *args
    p args
  end
end

Fiber.set_scheduler(MyScheduler.new)

Fiber.new{
  sleep(10)
}.resume

#=> [:kernel_sleep, 10]

MyScheduler#kernel_sleep(10) というメソッドが呼ばれていることがわかります。スケジューラーは、別の実行可能な Fiber に処理を移してもいいですし、実際に Kernel#sleep(10) を呼びだしてスリープしても良いわけです。

この機能の導入に際し、次のような変更が入っています。

  • Mutex が Fiber local になるといった変更がありました。つまり、Fiber scheduler を利用するプログラムは、スレッドプログラミングと同様に、注意深くロックを行うなどする必要があります。
  • IO は基本的に non-blocking モードになりました(が、普通に使う分には何も変わりません。IO#read してもブロックするように見えます。システム側の設定の話になります)
  • Fiber.new(blocking: false) というパラメータが増えました。true だと、スケジューラが呼ばれなくなります。root fiber (スレッドとセットで生成される Fiber)は、true になっています。
  • スレッド終了時、スケジューラがあり、そのスケジューラに #close が定義されていれば、それが呼ばれることになりました。

難しそうな機能ですが、実際これを直接使うのは、多分とても難しいので、このインターフェースを直接使うのはあまりおすすめしません。これを利用して非同期 IO を実現する async gem(仕様提案者の Samuel さんが作っているライブラリ)などを利用するといいと思います。

この機能(を使ってスケジューラを提供する gem)を使うべきかどうかですが、既存のプログラムを直接動かせることを目的としているため、いろいろなハックが入っており、動かすことができる可能性は高いです。そして、スレッドの代わりに Fiber を用いることで、高い並行性を達成することができるかもしれません。ただ、これまでのプログラミングモデルと微妙に異なる部分がソコソコあるので、はまると大変だと思います。なので、小さなプログラムから試していくとよいのではないかと思います。目的に合致すると、良いものだと思います。

この新機能をまとめると、Ruby レベルで Fiber を切り替えて動かすスケジューラーを記述するための機能ということができます。この機能により、たとえば大量のウェブリクエストを同時にさばかなくてはならないという、C10K 問題が、Ruby で問題なく処理することができるようになると期待されます。

機能についての個人的な意見

この機能を導入するため、非常に多くの議論がなされました。もっとも本質的には、このスケジューラーを Ruby ユーザーに記述させることができる、という点です。

利点としては、同じスケジューラ実装を、このインターフェースを備えた MRI 以外の実装でも共有できるというものです。また、プログラムに適したスケジューラを自分で書くことができるというのも利点の一つだと思います(90年代のマイクロカーネル研究を思い出します)。

が、個人的にはRubyでかけないようにしたほうが良かったんじゃないかなと思っています。スケジューラが備えるべきインターフェースが何であるか、非常に難しい問題で、現在は結構アドホックな印象を受けます。また、ブロッキングするかもしれない処理には様々なものがあり、Ruby だけでなんとかできるものばかりではありません。というわけで、この方針で進むのは難しいんじゃないかなぁと思っています。最初は、I/O 限定で切り替わる限定的なスケジューラという話だったので、限定的なシチュエーションにおいては良さそうと思ったんですが、汎用的なスケジューラにむかっているので、大丈夫かなぁと少し不安に思っています。

将来的には、スレッドのバックエンドを Fiber が用いている context を用いて良い感じにスケジューリングする(いわゆるM:Nモデル)ものを作って、スレッド自体が Fiber scheduler と同等の性能になるようにしていくと良いのではないかなぁと思っています(基本的な設計はできているので、あとは作るだけ! いつできるだろう)。

(ko1)

Fiberごとのバックトレース情報が取れる Fiber#backtrace と Fiber#backtrace_locations が導入された

  • Fiber#backtrace & Fiber#backtrace_locations provide per-fiber backtrace. [Feature #16815]

Thread#backtrace は、そのスレッドが現在実行中のバックトレースを出す機能でしたが、これを Fiber ごとに得る Fiber#backtrace と Fiber#backtrace_locations が導入されました。

def foo = Fiber.yield
def bar = Fiber.yield

f1 = Fiber.new{ foo }; f1.resume
f2 = Fiber.new{ bar }; f2.resume

pp f1.backtrace
#=> ["t.rb:1:in `yield'", "t.rb:1:in `foo'", "t.rb:4:in `block in <main>'"]
pp f2.backtrace
#=> ["t.rb:2:in `yield'", "t.rb:2:in `bar'", "t.rb:5:in `block in <main>'"]

これも、Fiber scheduler で(というか、スケジューラのデバッグで)便利に使うための機能ですね。

(ko1)

Fiber#transfer の制限が緩和された

  • The limitation of Fiber#transfer is relaxed. [Bug #17221]

これまで、Fiber#resume/yieldとFiber#transferを混ぜることは禁止していたのですが(この Fiber は resume/yield、この Fiber は transfer 専用、のように使ってほしかった)、この制限を緩和して、良い感じに使えるようにしました。詳細はチケットを見てください。簡単にいうと、resume/yield中の Fiber には transfer できない、transfer している Fiber には resume できないなどという制約だけでよさそうだ、というものです(本当はもう少し詳細)。

もともと、「なんかよくわからんけど resume/yield の関係が壊れるから transfer 混ぜられない」というのが、混ぜるの禁止にしていた理由なんですが、きちんと考えると、混ぜてはいけない理由がはっきりしてきたので、よく整理できたということです。

Fiber scheduler まわりでこの制限を緩和してほしい、というリクエストがあり、遠藤さんと延々と議論していたとき、「これで整理できるんじゃない?」というのがふってきて、二人で半日くらい議論して条件を洗い出すことができました。10年くらい気になっていた問題がきれいに解決して、とても嬉しい改善です(でも、影響はほとんどない)。

(ko1)

compaction GC を自動でやってくれる GC.auto_compact = true が追加された

  • GC.auto_compact= and GC.auto_compact have been added to control when compaction runs. Setting auto_compact= to true will cause compaction to occur during major collections. At the moment, compaction adds significant overhead to major collections, so please test first! [Feature #17176]

Ruby 2.7 から、ヒープの中身をコンパクションする GC.compact が導入されました。これは、手動で好きなタイミングで行おう、というものですが、これを major GC(世代別GC で、時々行うヒープ全体を対象にする GC。遅い)のときに行おうというものです。

GC.compact については、開発者の Aaron さんが解説する Rubyconf 2020 の動画がアップロードされていました: Automatic GC Compaction in MRI - Aaron Patterson - YouTube

GC.auto_compact = true とすることで、major GC が起こるとコンパクションも実行してくれます。そのため、定期的にメモリの掃除をしてくれることになり、メモリ効率の向上、および局所性向上による性能改善が期待できます。が、ここにも書いてある通り、コンパクション自体が結構なオーバヘッドになるので、自分のアプリで効くかどうか確認してみるといいと思います。デフォルトは、そういうことで false です。

テクニカルには read-barrier とか導入していてマジかって感じです。色々大変そうで避けていたんですが、ちゃんと動くんだなぁ。

正直、まだ実装がこなれていないような気がするので(拡張ライブラリあたりが怪しいです)、みんながすぐにこれを使うってのには、ならない気がします(はまらなければ、使ってもいいと思います)。

(ko1)

Hash#except が導入された

  • Hash#except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]
  • ENV.except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]

ActiveSupportにあるHash#exceptが組み込みになりました。

{ a: 1, b: 2, c: 3 }.except(:b) #=> {:a=>1, :c=>3}

ENV#exceptも同様に追加されています。

要望は以前からありましたが、「名前がしっくり来ない、組み込みにするほどのユースケースがあるのかよくわからない」ということで先送りになっていました。excludeのような名前も検討されたようですが、結局ActiveSupportに従うことになりました。なお、exceptは「~を除いて」という前置詞しか知りませんでしたが、「除外する」という動詞の用法もあるようです。

(mame)

Hash#transform_keysが ハッシュを受け取るように

  • Hash#transform_keys now accepts a hash that maps keys to new keys. [Feature #16274]

ハッシュのキーを変換するHash#transform_keysが、変換の対応をHashで示せるようになりました。

# ↓新機能
{ a: 1, b: 2, c: 3 }.transform_keys({ a: :A })              #=> { A: 1, b: 2, c: 3 }

# ↓従来の機能で同じことをやるとしたら
{ a: 1, b: 2, c: 3 }.transform_keys {|k| k == :a ? :A : k } #=> { A: 1, b: 2, c: 3 }

JSONの変換のようなときに便利のような気はします。

(mame)

Kernel#clone で freeze: true としたら freeze されるようになった

  • Kernel#clone when called with freeze: false keyword will call #initialize_clone with the freeze: false keyword. [Bug #14266]
  • Kernel#clone when called with freeze: true keyword will call #initialize_clone with the freeze: true keyword, and will return a frozen copy even if the receiver is unfrozen. [Feature #16175]

2つの変更が語られています。いずれも細かい内容です。

まず1つめの変更について。Kernel#cloneはオブジェクトを複製するメソッドですが、freezeされたオブジェクトをcloneしたらfreezeされた複製を返します。

ary = [1, 2, 3].freeze
p ary.clone.frozen? #=> true

しかし、cloneでfreeze状態は保存してほしくないケースがあり、Ruby 2.4でfreeze: falseというキーワード引数が導入されました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: false).frozen? #=> false

このとき、freeze: trueというのは「従来どおり、freeze状態を保存する」という意味になりました。よって、元のオブジェクトがfreezeされていない場合、freezeされていない複製が返されていました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: true).frozen? #=> true

s = "str" # freeze されていない
p s.clone(freeze: true).frozen? #=> false

が、「freeze: trueと書いてあるのにfreezeされていない複製を返すのはバグでは?」という指摘が来たので、そうするようになりました。

s = "str" # freeze されていない
p s.clone(freeze: true).frozen? #=> Ruby 3.0 では true

なんだかレトロニムみたいな話ですね。

もうひとつの話の変更をかいつまんで。これはSetクラスを clone(freeze: false) したときに起きた問題に関する話です。Setクラスは内部的にHashで集合を表現しているのですが、Set#freezeすると内部のHashもfreezeします。よって、freezeしたSetインスタンスをclone(freeze: false)で複製しても、内部的なHashはfreezeされたままになるという問題がありました。そこで、clone時に呼ばれるinitialize_cloneメソッドにfreeze:キーワードを渡すようにして、内部的なHashのcloneにfreeze:キーワードを渡せるように変更されました。

(mame)

eval内のファイル名や行番号をbindingから継承しないようになった

  • Kernel#eval when called with two arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352]
  • Binding#eval when called with one arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352] [Bug #17419]

evalの中での__FILE__や__LINE__が微妙に変わります。次の例を見てください。

1: # eval-test.rb
2: b = binding
3:
4: eval("p __LINE__", b) #=> Ruby 2.7では警告とともに2、Ruby 3.0では1

このコードは、Ruby 2.7で実行すると、次のように(警告とともに)2が出ていました。

$ ruby eval-test.rb
eval-test.rb:2: warning: __LINE__ in eval may not return location in binding; use Binding#source_location instead
eval-test.rb:4: warning: in `eval'
2

Ruby 2.7までのevalはデフォルトで、渡されたbindingのファイル名や行番号を継承していました。ここで表示される2は、bindingが作られた行番号です。

しかしこれは時として混乱の元でした。次の例を見てください。

1: b = binding
2:
3: eval(<<END, b)
4:
5: raise
6: END

これをRuby 2.7で実行すると、次のようなバックトレースが出ます。

$ ruby2.7 eval-test.rb
Traceback (most recent call last):
        2: from eval-test.rb:3:in `<main>'
        1: from eval-test.rb:3:in `eval'
eval-test.rb:2:in `<main>': unhandled exception

eval-test.rbの2行目で例外が出たことになっていますが、その行は空行です。謎でしかない。これは、bindingのファイル名と行番号を暗黙的に引き継いだ結果です。

Ruby 3.0からは、この引き継ぎを行わないようになりました。

$ ruby3.0 eval-test.rb
(eval):2:in `<main>': unhandled exception
        from eval-test.rb:3:in `eval'
        from eval-test.rb:3:in `<main>'

紛らわしい結果がなくなりました。

なお、もし従来どおりの挙動にしたい場合は、eval("p __LINE__", b, *b.source_location)のようにBinding#source_locationを使ってください。また、Binding#evalも同様に変わっています。b.eval(src)はb.eval(src, *b.source_location)としてください。

(mame)

Kernel#lambda にブロック引数を渡したら警告を出すようになった

  • Kernel#lambda now warns if called without a literal block. [Feature #15973]

どうやら、lambda(&pr) のように渡すと、Procオブジェクトを lambda に変換してくれる、という誤解があったようで、いくつかの場所で実際に使われていました。が、実はそんな機能は無いので、lambda{ ... } のようにブロックを指定するのではなく、lambda(&pr) のように Proc を渡した場合には警告を出すようになりました。

lambda(&proc{})
#=> warning: lambda without a literal block is deprecated; use the proc without lambda instead

将来的にはエラーになるのかなぁ。

(ko1)

後から行った Module#include が無視されなくなった

  • Module#include and Module#prepend now affect classes and modules that have already included or prepended the receiver, mirroring the behavior if the arguments were included in the receiver before the other modules and classes included or prepended the receiver. [Feature #9573]

モジュールのincludeの順序によっては、includeが無視されるように見えるケースがありました。それが修正されたという内容です。

# モジュールを 2 つ作る
module M1; end
module M2; end

# クラス C は M1 を include する
class C
  include M1
end

# M1 が後から M2 を include する
module M1
  include M2
end

# C のスーパークラスに M2 が入っていなかったが、3.0 から入るようになった
p C.ancestors #=> [C, M1, Object, Kernel, BasicObject]      # 2.7
p C.ancestors #=> [C, M1, M2, Object, Kernel, BasicObject]  # 3.0

このように、あとから M2 を include しているのが無視されていました。無視されていたのは実装の都合でしたが、気合で修正されました。

個人的なオススメは、このように、あとからモジュールを include するようなことはしないことです。あとから include/prepend は他にも問題があることが知られています(include の順序によっては、ancestors に同じモジュールが複数回現れてしまうとか、prepend を絡めると意味がわからなくなるとか)。

(mame)

private attr_reader :fooと書けるようになった

  • Module#public, Module#protected, Module#private, Module#public_class_method, Module#private_class_method, toplevel "private" and "public" methods now accept single array argument with a list of method names. [Feature #17314]

  • Module#attr_accessor, Module#attr_reader, Module#attr_writer and Module#attr methods now return an array of defined method names as symbols. [Feature #17314]

  • Module#alias_method now returns the defined alias as a symbol. [Feature #17314]

表題のとおり、private な attr_reader などをシンプルに書ける様になりました。

具体的な変更としては、(1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになった、(2) public や private が配列を引数に受け取れるようになった、です。

class Foo
  # (1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになった
  attr_accessor :foo, :bar #=> [:foo, :foo=, :bar, :bar=]

  # (2) public や private が配列を引数に受け取れるようになった
  private [:foo, :foo=, :bar, :bar=]

  # 2 つを組み合わせると、次のように書いても同じ意味になる
  private attr_accessor :foo, :bar
end

また、alias_methodメソッドも定義されたメソッドのシンボルを返すようになりました。これも private alias_method :foo, :bar と書けることを狙ったものです。

(mame)

Proc の等価判定(Proc#==, Proc#eql?)が少し緩和された

  • Proc#== and Proc#eql? are now defined and will return true for separate Proc instances if the procs were created from the same block. [Feature #14267]

これまで、Proc#== は、同じオブジェクトかどうかで判断していました(というか、Proc#== はなくて、Object#== が使われていた)。が、この制限を緩和し、同じメソッド呼び出しのブロックパラメータで作られたProcは、Proc#==でtrueを返すようになりました。正直、これを読んでも意味わからないと思うのですが、これが関係するところはマレだと思うので、あまり気にしなくていいと思います。基本的には、Proc#== なんて使わないでください。また、Hash のキーにするべきでもないでしょう。

一応、ちゃんと書いておきますと、これは Ruby 2.5 で導入された lazy proc allocation(Ruby 2.5 の改善を自慢したい - クックパッド開発者ブログ 「Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化」 )の非互換を解消するためのものです。

def bar &b
  b
end

def foo(&b1)
  b2 = bar(&b1)
  p b1 == b2
  p b1.equal? b2
end

foo{}

#=>              b1 == b2   b1.equal? b2
# Ruby 2.4 以前  true       true
# Ruby 2.5-2.7   false      false
# Ruby 3.0       true       false

Ruby 2.4 では、b1 はProcを生成し、それをbar(&b1)として渡しても、すでにProcが生成されているので、単にその Proc を渡すだけでした。そのため、b1.equal? b2 は true でした。

しかし、Lazy Proc Allocation によって、Proc の生成が遅延されてしまうので、bar で初めて Proc を作り、そしてその情報は foo 側には渡らないので foo でも新たに Proc を作り、b1 == b2 と b1.equal? b2 ともに false になってしまっていたのでした。この挙動自体は非互換として当時から認識していたのですが、「まー誰も困らんやろ」と思っていたら、なんか RSpec で踏んだらしいんですよね。

ということで、どうするかと思っていたら、Proc#==を変えればいいのでは(違うオブジェクトでも、こういうケースなら true になるような Proc#== にすれば良いのでは)という素晴らしい解決策を得て、解決したのでした。

(ko1)

Ractor による並列並行プログラミングのサポート

  • New class added to enable parallel execution. See doc/ractor.md for more details.

Rubyで簡単に並列並行プログラミングを行うための Ractor が導入されました。

まだ、実験的機能(仕様が不安定、実装が不安定)なので、最初に Ractor.new で Ractor を生成するとき、警告が出るようになっています。

細かい仕様については、別の資料をご参考にしてください。下記に、私の発表した資料へのリンクを掲載しておきます。

(追記)解説する記事をかきました:

techlife.cookpad.com

(ko1)

Random::DEFAULT が非推奨に

  • Random::DEFAULT now refers to the Random class instead of being a Random instance, so it can work with Ractor. [Feature #17322]

  • Random::DEFAULT is deprecated since its value is now confusing and it is no longer global, use Kernel.rand/Random.rand directly, or create a Random instance with Random.new instead. [Feature #17351]

デフォルトの乱数生成器 Random::DEFAULT が非推奨になりました。代わりに Random クラスオブジェクトが利用できます。また、Random::DEFAULT は、Random クラスのインスタンスだったのが、Random クラス自体が返るようになりました。

p Random::DEFAULT == Random #=> true

Random::DEFAULT.srand(0)    # seed を指定して
p Random::DEFAULT.rand(10)  # => 5
p Random::DEFAULT.bytes(3) #=> "\xC0\xCC!"

# Random クラスで同じことができる

Random.srand(0)
p Random.rand(10) #=> 5
p Random.bytes(3) #=> "\xC0\xCC!"

非推奨になったので、-w 付きで実行しているときに Random::DEFAULT を参照すると警告が出るようになりました。

$ ruby -w -e 'p Random::DEFAULT'
-e:1: warning: constant Random::DEFAULT is deprecated
Random

もともと、Randomクラスには randなどのメソッドがくっついていました。これらのメソッドは、Random::DEFAULTと同じ乱数生成器を参照して実行します。そのため、Random::DEFAULTの代わりに Random を用いれば、だいたいうまくいくようになっています。ただ、クラスになったので、Marshal などに対応しなくなったのが若干の非互換になっています(一応、公開されている gem を調べた限り、そのようなことをしているものはありませんでした)。

なんで Random クラスが特異メソッドとして rand などを持っているのかわからなかったのですが(私は初めて知った)、聞いてみると、デフォルトの乱数生成器を用いるメソッドを置く場所が欲しかった、ということでした(Random.rand() などがついたのは 1.9.2、Random::DEFAULT ができたのは 1.9.3で、ちょっと後なんですね)。すでに Kernel#rand などはありましたが、Random#bytes などは、確かに置く場所が困りそうでした。

この変更の背景をご紹介します。

Random::DEFAULT は、これまで Kernel#rand などが利用する疑似乱数生成器をさしていました。つまり、rand(10) などを実行すると、この Random::DEFAULT の生成器の乱数を消費していたわけです。

しかし、Ractor が入ると、同時に複数の Ractor が生成器を利用してしまうため、生成器の実装をスレッドセーフにする必要がありました。ただ、その対応は結構大変だなぁ、というので、生成器は Ractor ローカルとするのが良さそう、となりました(つまり、乱数生成器は Ractor をまたいで共有されない)。

現在の定義だと、Kernel#rand などは、唯一存在する Random::DEFAULTを乱数生成器として利用する、という定義なので、これがネックになりました。Ractor ごとに持つためには、Random::DEFAULT を使う、というわけにはいかないものですから。そこで、Random::DEFUALT の意味を変更する必要が出てきました。候補としては、次の二つです。

  • (1) Random::DEFAULT に特殊な Random インスタンスを設定して、それは Ractor local なデフォルトの乱数生成器を参照する
  • (2) Rnadom クラスオブジェクトは、なぜか Random インスタンスがもつメソッドを実装しているので、Random::DEFAULT = Random という定義にしてしまい、Random.rand などは Ractor local な乱数生成器を参照する、という意味に変更する

というわけで、実装の面倒が少ない (2) を選ぶことにしました。特異メソッドなら、Ractor local なものを参照する、という特別な意味があります、と言い張っても受け入れらそうだし。

あまり、乱数生成器を意識することはないのではないかと思うのですが、ちょっと変わっているということはご承知おきください。

(ko1)

Symbol#to_proc が lambda を返すようになった

Symbol#to_proc で生成する Proc が lambda となるようになりました。

Proc は proc{}/Proc.new{}およびメソッドのブロック仮引数でうけて生成する場合と、lambda{}や->{}で生成する場合で挙動が異なります。ここでは、前者をproc、後者を lambda と呼ぶことにします。Proc#inspect で、lambda の場合 lambdaと出ます。

p ->{} #=> #<Proc:0x00000280db845220 t.rb:1 (lambda)>

proc と lambda のもっともわかりやすい違いは、引数の数のチェック機能でしょう。proc は曖昧に解釈するので、渡された実引数の数と仮引数の数が違っても、何もなくなんとく良い感じに(この良い感じがバグというか混乱を呼んでいるんですが...)解釈します。lambda は違うとエラーになります。

proc{|a| p a}.call(1, 2)
#=> 1
->a{p a}.call(1, 2)
#=> `block in <main>': wrong number of arguments (given 2, expected 1) (ArgumentError)

で、Symbol#to_procで作ったProcオブジェクトは、lambda っぽい挙動になるのに、inspect しても lambda って出てこないのは変だよね、ということで、lambda になりました。

pr = :object_id.to_proc
p pr
#=> #<Proc:0x00000236441f1270(&:object_id) (lambda)> # ruby 3.0 から (lambda) がついた

p pr.call(1) # 1.object_id と同じ
#=> 1.object_id の結果 3 が返る

p pr.call(1, 2) # 1.object_id(2) と同じ
#=> in `object_id': wrong number of arguments (given 1, expected 0) (ArgumentError)

(ko1)

シンボルの名前に対応する文字列が返る Symbol#name の追加

  • Symbol#name has been added, which returns the name of the symbol if it is named. The returned string is frozen. [Feature #16150]

:sym.name #=> "sym" となるような Symbol#name が導入されました。でも、String#to_s でも同じような挙動だったんですよね。何が違うかというと、返ってくる文字列が frozen になったのでした。frozen になっているから、重複排除、つまり何回読んでも同じ文字列オブジェクトを返すことが可能になりました。みんな、文字列生成を排除したくてしょうがないんですね。

もともと、Symbol#to_s を freeze にしてしまおう、って提案があって、チャレンジされてたんですが、非互換がつらいということで reject になりました。なんか別の方法がないか、ということで、Symbol#nameという別案が用意されました。これ、RubyKaigi takeout 2020 のあとの zoom で、なんか盛り上がって入れたんでしたっけかね?

(ko1)

デッドロック検知を無効にするオプションが導入された

  • Thread.ignore_deadlock accessor has been added for disabling the default deadlock detection, allowing the use of signal handlers to break deadlock. [Bug #13768]

スレッドでロックをお互い待ってしまってにっちもさっちもいかなくなるような場合、デッドロックと呼ばれます。Ruby には簡単なデッドロック検出機能があり、すべてではないですが、デッドロックになったときに例外を発生させ、(多分バグでしょうから)デバッグに有用な情報を出力します。

q = Queue.new

Thread.new{
  q.pop
}
q.pop

__END__
t.rb:6:in `pop': No live threads left. Deadlock? (fatal)
2 threads, 2 sleeps current:0x000001b07776b280 main thread:0x000001b0721a80b0
* #<Thread:0x000001b07221ca68 sleep_forever>
   rb_thread_t:0x000001b0721a80b0 native:0x0000000000000128 int:0
   
* #<Thread:0x000001b0777790d8 t.rb:3 sleep_forever>
   rb_thread_t:0x000001b07776b280 native:0x0000000000000184 int:0
   
   from t.rb:6:in `<main>'

この例では、1つの Queue をすべてのスレッドが待っているので、誰も起こすことは無いだろうということで、デッドロックと認定し、エラーを出力しています。

さて、世の中にはシグナルの到着により、スレッド実行を復帰させたい、というプログラムがあります。

q = Queue.new

trap(:INT){ q << 1 }
q.pop

__END__
t.rb:4:in `pop': No live threads left. Deadlock? (fatal)
1 threads, 1 sleeps current:0x0000019cecf68630 main thread:0x0000019cecf68630
* #<Thread:0x0000019cecfdca98 sleep_forever>
   rb_thread_t:0x0000019cecf68630 native:0x0000000000000128 int:0
   
   from t.rb:4:in `<main>'

このような場合でも、trap の存在に気づかず、デッドロックと判定してしまいます。でも、プログラマー的にはデッドロックじゃないので何とかしてほしい、というリクエストが来ていました。

いろいろ議論したのですが(trap が1つでも設定されていれば deadlock 検知をスキップするとか、いやでもそれがプログラムの実行を再開するとは限らないしな、とか)、結局「デッドロック検知自体をオフにする」機能でいいのではないか、となりました。それが Thread.ignore_deadlock = true です。

Thread.ignore_deadlock = true
q = Queue.new

trap(:INT){ q << 1 }
q.pop                 # Ctrl-C で終了する

まぁ、あんまり難しいことしないほうがいいですよ、シグナルとか難しい。

(ko1)

警告周りのメソッドが category キーワードを受け取るようになった

  • Warning#warn now supports a category keyword argument. [Feature #17122]

Ruby 2.7から、警告にカテゴリという概念が導入されました。いまのところ:deprecatedと:experimentalと「なし」という3種類のカテゴリだけです。 :deprecatedや:experimentalのカテゴリに属す警告はRubyのインタプリタ内部でしか作れなかったのですが、ユーザもカテゴリに属す警告を出せるようになりました。

warn("foo is deprecated", category: :deprecated)

上の警告は、Warning[:deprecated] = trueを有効にしていないと表示されません。

また、警告発生をフックするメソッドWarning.warnがあるのですが、これにもcategoryの情報が渡されるようになりました。

def Warning.warn(msg, category: nil)
  p [msg, category]
end

warn("foo is deprecated", category: :deprecated)
  #=> ["foo is deprecated", :deprecated]

(mame)

■標準ライブラリのアップデート

ライブラリも、いろいろアップデートしました。NEWS にいくつか載っていますが、今回は調べるのが面倒なので、スキップします。

  • BigDecimal

    • Update to BigDecimal 3.0.0

    • This version is Ractor compatible.

  • Bundler

    • Update to Bundler 2.2.3
  • CGI

    • Update to 0.2.0

    • This version is Ractor compatible.

  • CSV

    • Update to CSV 3.1.9
  • Date

    • Update to Date 3.1.1

    • This version is Ractor compatible.

  • Digest

    • Update to Digest 3.0.0

    • This version is Ractor compatible.

  • Etc

    • Update to Etc 1.2.0

    • This version is Ractor compatible.

  • Fiddle

    • Update to Fiddle 1.0.5
  • IRB

    • Update to IRB 1.2.6
  • JSON

    • Update to JSON 2.5.0

    • This version is Ractor compatible.

  • Set

    • Update to set 1.0.0

    • SortedSet has been removed for dependency and performance reasons.

    • Set#join is added as a shorthand for .to_a.join.

    • Set#<=> is added.

  • Socket

  • Net::HTTP

    • Net::HTTP#verify_hostname= and Net::HTTP#verify_hostname have been added to skip hostname verification. [Feature #16555]

    • Net::HTTP.get, Net::HTTP.get_response, and Net::HTTP.get_print can take the request headers as a Hash in the second argument when the first argument is a URI. [Feature #16686]

  • Net::SMTP

    • Add SNI support.

    • Net::SMTP.start arguments are keyword arguments.

    • TLS should not check the host name by default.

  • OpenStruct

    • Initialization is no longer lazy. [Bug #12136]

    • Builtin methods can now be overridden safely. [Bug #15409]

    • Implementation uses only methods ending with !.

    • Ractor compatible.

    • Improved support for YAML. [Bug #8382]

    • Use officially discouraged. Read OpenStruct@Caveats section.

  • Pathname

    • Ractor compatible.
  • Psych

    • Update to Psych 3.3.0

    • This version is Ractor compatible.

  • Reline

    • Update to Reline 0.1.5
  • RubyGems

    • Update to RubyGems 3.2.3
  • StringIO

    • Update to StringIO 3.0.0

    • This version is Ractor compatible.

  • StringScanner

    • Update to StringScanner 3.0.0

    • This version is Ractor compatible.

■非互換

正規表現リテラル、および Range オブジェクトが freeze された

だいたい Ractorの都合なんですが、正規表現リテラルとRangeオブジェクトのすべてが freeze されることになりました。

p /abc/.frozen?             #=> Ruby 3.0 から true
p /a#{42}c/.frozen?         #=> Ruby 3.0 から true

p Regexp.new('abc').frozen? #=> 変わらず false

p (1..2).frozen?            #=> Ruby 3.0 から true
p Range.new(1, 2).frozen?   #=> Ruby 3.0 から true

まぁ、誰もこれらのオブジェクトを変更しないよね、と思うので、普通の人には気にしなくてもいい変更じゃないかと思います。

Regexp.new('abc') が freeze されていないのは、実際にこれを変更する人がいたためです(特異メソッドを追加していた)。そんな非互換気にしなくていいよ、どんどん変更しようぜー、という意見もあったんですが(Matzとか)、ここは保守的にいきました。やる気のある人がいれば、これも freeze されるかもしれません。

こんな感じで、Immutable っぽいオブジェクトはどんどん freeze されています。

関係ないけど、その freeze 化の最初のほう、Symbolは 2013 年に freeze されました。

* include/ruby/ruby.h: make Symbol objects frozen. ・ ruby/ruby@1e27eda

コミットメッセージで "I want to freeze this good day, too." って寿いでますけど、これ、私が結婚した日だったんですよね。記念コミット。

(ko1)

Hash#each が常に2要素配列をyieldするように

  • EXPERIMENTAL: Hash#each consistently yields a 2-element array [Bug #12706]
    • Now { a: 1 }.each(&->(k, v) { }) raises an ArgumentError due to lambda's arity check.

一言で言えば、最適化のバグ修正です。順に説明します。

Hash は基本的に、キーと値をタプルにした配列を yield します。

{ a: 1 }.each {|ary| p ary } #=> [:a, 1]

しかし、引数が2つあるときはautosplatされます。

{ a: 1 }.each {|k, v| p k } #=> :a

このとき、いちいち配列を作って分解するのは無駄なので、引数が2つあるときは内部的に配列を作らないようにする最適化が行われていました。

しかしこの最適化は、ブロックがlambdaであるときでも適用されてしまっていました。lambdaはautosplatをしないので、引数の数が間違っているという例外が出るのが正しかったです。3.0では原則に従い、ブロックがlambdaのときは例外を投げるようになりました。

# Ruby 2.7
{ a: 1 }.each(&-> (k, v) { p k }) #=> :a

# Ruby 3.0
{ a: 1 }.each(&-> (k, v) { p k }) #=> ArgumentError (wrong number of arguments (given 1, expected 2))

(mame)

標準出力がクローズされた後に出力しようとしてもEPIPE例外を投げないようになった

  • When writing to STDOUT redirected to a closed pipe, no broken pipe error message will be shown now. [Feature #14413]

細かい改善です。Ruby 2.7までは、rubyの出力をheadなどで途中で止めると、rubyの例外バックトレースを見かけることがあったと思います。

$ ruby -e 'loop { puts "foo" }' | head
foo
foo
foo
foo
foo
foo
foo
foo
foo
foo
Traceback (most recent call last):
        5: from -e:1:in `<main>'
        4: from -e:1:in `loop'
        3: from -e:1:in `block in <main>'
        2: from -e:1:in `puts'
        1: from -e:1:in `puts'
-e:1:in `write': Broken pipe @ io_writev - <STDOUT> (Errno::EPIPE)

これは、クローズされたパイプに書き込みを行っていたためでした。しかし、このバックトレースは特に便利ではないこと、他 のインタプリタでは何も言わずに終了することから、Ruby 3.0からは同様に何も言わずに終了するようになりました。

(mame)

定数のTRUEとFALSEとNILが定義されないようになった

  • TRUE/FALSE/NIL constants are no longer defined.

よく知らないんですが、非常に古代のrubyでは、trueやfalseやnilは、TRUEやFALSEやNILでした *6 。それが現代でも互換性のためになんとなく残され続けていたのですが、ついに削除されました。お疲れさまでした。

(mame)

Integer#zero? が改めて定義された

  • Integer#zero? overrides Numeric#zero? for optimization. [Misc #16961]

これまで、Integer#zero? はなくて、スーパークラスの Numeric#zero? が使われてきていたんですが、高速化のために Integer#zero? を改めて定義しました、という話です。ほぼ影響はないんですが、万が一 Numeric#zero? を再定義しても、Integer#zero? には影響を与えないことになります。

(ko1)

Enumerable#grepとgrep_vに正規表現を渡してブロックを渡さなかった場合、$~を更新しなくなった

  • Enumerable#grep and grep_v when passed a Regexp and no block no longer modify Regexp.last_match [Bug #17030]

見出しの通りです。

["foo", "bar", "baz", "qux"].grep(/ba./)

p $~  #=> #<MatchData "baz"> in 2.7
p $~  #=> nil in 3.0

ary.grep(REGEXP) が ary.select {|e| e.match?(REGEXP) } より遅い(MatchData オブジェクトを生成するため?)、という問題に対する対応のようです。非互換を入れずに最適化できるところを探していこう、という雰囲気だった気がするのですが、気づいたら非互換が入ってました。大丈夫かな。

(mame)

open-uri が Kernel#open を上書き定義しなくなった

  • Requiring 'open-uri' no longer redefines Kernel#open. Call URI.open directly or use URI#open instead. [Misc #15893]

みんなが愛した open-uri の Kernel#open が消えました。今後は URI.open を使ってください。

require "open-uri"

# 2.7 では警告付きで動いていた、3.0 ではエラー
open("https://example.com") {|f| f.read }
  #=> No such file or directory @ rb_sysopen - https://example.com (Errno::ENOENT)

# 2.7 でも 3.0 でも動く
URI.open("https://example.com") {|f| f.read }
  #=> "<!doctype html>\n<html>\n..."

セキュリティ向上のためだそうです。Kernel#openはファイルを開くだけでなく、パイプ経由でコマンドを実行できたり、open-uriの拡張でHTTPフェッチができたりする大変便利なメソッドです。しかしこれは攻撃者にとっても便利すぎるきらいがあるということで、ファイルを開く機能専用のFile.open("...")や、URIをフェッチする機能専用のURI.open("...")などに分割整理が進んでいます。その一環として、open-uriがKernel#openを上書きするのもやめたようです。

(mame)

SortedSetが削除された

  • SortedSet has been removed for dependency and performance reasons.

set.rb に抱き合わせで実装されていた SortedSet が別の gem に分離されました。

SortedSet にアクセスすると例外が出ます。

require "set"
SortedSet
#=> The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives. (RuntimeError)

削除された理由は、SortedSet が標準添付でない rbtree gem に依存していること(rbtree がないときは pure Ruby の実装が動くけれど、それは遅いこと)だそうです。

gem install sorted_setすれば、そのまま動くようになります。実は、rbtree gem が 3.0.0 対応していないために直前まで動かなかった(本記事を書いて試したことで気づけた)のですが、メンテナの knu さんがリリースまでに対処してくれました。

(mame)

■標準ライブラリの非互換

Default gem 化

  • Default gems
    • The following libraries are promoted the default gems from stdlib.
      • English
      • abbrev
      • base64
      • drb
      • debug
      • erb
      • find
      • net-ftp
      • net-http
      • net-imap
      • net-protocol
      • open-uri
      • optparse
      • pp
      • prettyprint
      • resolv-replace
      • resolv
      • rinda
      • set
      • securerandom
      • shellwords
      • tempfile
      • tmpdir
      • time
      • tsort
      • un
      • weakref
    • The following extensions are promoted the default gems from stdlib.
      • digest
      • io-nonblock
      • io-wait
      • nkf
      • pathname
      • syslog
      • win32ole

これらのライブラリが default gem 化されました。Gemfile にバージョン指定があると、そちらが利用されます。

(ko1)

Ruby インストール時に、インストールされなくなったライブラリ

上記ライブラリが、Ruby インストール時にインストールされなくなりました。gem として別途インストールする必要があります。

WEBrick が一緒にインストールされなくなるのは、結構大きい変更ですね。時代を感じます。

(ko1)

C API updates

いくつか、C 拡張ライブラリを書くための C API が更新されています。

  • C API functions related to $SAFE have been removed. [Feature #16131]

$SAFE に関する C API が削除されています。

  • C API header file ruby/ruby.h was split. [GH-2991] Should have no impact on extension libraries, but users might experience slow compilations.

今まで、ruby.h という大きなヘッダファイルにいろいろ書いてあったのを、複数のファイルに分割しています。 ただ、ruby.h がこれまで通り、すべてを include しているので、拡張ライブラリのビルドに利用する分には変更ありません。

  • Memory view interface [EXPERIMENTAL]

    • The memory view interface is a C-API set to exchange a raw memory area, such as a numeric array and a bitmap image, between extension libraries. The extension libraries can share also the metadata of the memory area that consists of the shape, the element format, and so on. Using these kinds of metadata, the extension libraries can share even a multidimensional array appropriately. This feature is designed by referring to Python's buffer protocol. [Feature #13767] [Feature #14722]

メモリ上の(多次元)配列データを、プロセス内の他のライブラリなどとメタデータ付きで交換するための Memory view interface が追加されました。主に、大きな行列データや画像データなどを、あるライブラリで処理しているときに、別のライブラリに渡して処理をしてもらう、といった用途で利用されます。Python だと buffer protocol と呼ばれている機能を参照して追加されたそうです。

対象となるライブラリが X と Y の2つであれば、X->Y、Y->X のデータの変換器を作るだけでよさそうですが、これが数が増えると変換器の数がどんどん増えていきます。Memory view interface を用いれば、統一されたメタデータのもとで交換することができるので、変換器を作らなくても良くなります。また、生のメモリをそのまま渡すことができるので、何か冗長なフォーマット(例えば CSV)に変換して渡す、といったことが不要になるので、性能的な利点もありそうです。

開発された mrkn さんによる記事も公開されています:MemoryView: Ruby 3.0 から導入される数値配列のライブラリ間共有のための仕組み - Speee DEVELOPER BLOG

  • Ractor related C APIs are introduced (experimental) in "include/ruby/ractor.h".

Ractor に関する C API が少し追加されました。正直、これで足りているのかわからないのですが、とりあえず必要かな、と思うところを足しています。

(ko1)

■実装の改善

メソッドキャッシュが刷新された

  • New method cache mechanism for Ractor [Feature #16614]

    • Inline method caches pointed from ISeq can be accessed by multiple Ractors in parallel and synchronization is needed even for method caches. However, such synchronization can be overhead so introducing new inline method cache mechanisms, (1) Disposable inline method cache (2) per-Class method cache and (3) new invalidation mechanism. (1) can avoid per-method call synchronization because it only uses atomic operations. See the ticket for more details.

メソッド探索のたびに、クラス継承木を辿ってメソッドを探し当てるのは時間がかかるので、メソッド探索の結果をある程度キャッシュするというのがメソッドキャッシュです。

Ruby 2.7 までは、二つのメソッドキャッシュを使っていました。

  • インラインメソッドキャッシュ:バイトコードにキャッシュを突っ込んでおく。Ruby 1.9 (YARV) から導入
  • グローバルメソッドキャッシュ:固定長の1個のテーブルを用意して、そこにメソッド探索結果を保存しておく。すごい古い Ruby からほぼ同じものを利用

それぞれちょっとずつ改善していっていたのですが、今回がらっと変更しました。というのも、複数の Ractor から同時にアクセスすると、最もヒットすることが期待される(実際、90%以上はだいたいヒットする)インラインメソッドキャッシュにおいて、毎回ロックが必要になる、という構造だったからです。ロックを扱うと、オーバヘッドがすごいので、ここではロックの不要なデータ構造が必要になります。

そこで、次のように変更しました。

  • (1) インラインメソッドキャッシュを、毎回ロックを取らなくてもよい仕組みにした
  • (2) グローバルメソッドキャッシュをやめ、クラスごとのキャッシュにした

仕組みをちゃんと説明するのはとても面倒なんですが、(1) インラインキャッシュについてのアイディアとしては、これまで1つのインラインキャッシュを都度更新してきたのが、キャッシュに必要な情報を1オブジェクトとしてまとめておいて、キャッシュするときには、バイトコードからそのオブジェクトへの参照を保存するというアトミックな処理で済むようにした、というものです。

(1) の変更のために、既存のグローバルメソッドキャッシュでは不足があり(そもそも色々不満があった)、この度 (2) クラスごとのメソッドキャッシュを用意しました。

性能改善セクションにあるんですが、実は Ruby 3.0 でマイクロベンチマークの性能が (JIT なしの場合) 少し落ちていて、これがその原因の一つです。ごめんよ。でも並列化してるから許して。

(ko1)

super で必要なメソッド探索を、結果をキャッシュすることで高速化した

  • super is optimized when the same type of method is called in the previous call if it's not refinements or an attr reader or writer.

superで呼び出すメソッドは、Ruby 2.7 以前では毎回メソッド探索をまじめにしていたのですが、今回探索結果をほかのメソッド呼び出しと同じく、キャッシュすることにして性能改善を行いました。

(ko1)

キーワード引数を渡すときに無駄なハッシュの複製をやめた

  • The number of hashes allocated when using a keyword splat in a method call has been reduced to a maximum of 1, and passing a keyword splat to a method that accepts specific keywords does not allocate a hash.

たとえばこういうコード。Ruby 2.7 では foo(**opt) の呼び出しでハッシュを 2 回複製していたのですが、3.0 では 1 回になりました。

def foo(**opt)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 2 回複製していた、3.0 では 1 回になった

また、次のコードでは、複製回数が 1 回から 0 回に改善しました。

def foo(a: 1)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 1 回複製していた、3.0 では 0 回になった

キーワード引数まわりで貢献しまくってくれた Jeremy らしい細やかな最適化です。

(mame)

JIT

  • Performance improvements of JIT-ed code
    • Microarchitectural optimizations
      • Native functions shared by multiple methods are deduplicated on JIT compaction.
      • Decrease code size of hot paths by some optimizations and partitioning cold paths.
    • Instance variables
      • Eliminate some redundant checks.
      • Skip checking a class and a object multiple times in a method when possible.
      • Optimize accesses in some core classes like Hash and their subclasses.
    • Method inlining support for some C methods
      • Kernel: #class, #frozen?
      • Integer: #-@, #~, #abs, #bit_length, #even?, #integer?, #magnitude, #odd?, #ord, #to_i, #to_int, #zero?
      • Struct: reader methods for 10th or later members
    • Constant references are inlined.
    • Always generate appropriate code for ==, nil?, and ! calls depending on a receiver class.
    • Reduce the number of PC accesses on branches and method returns.
    • Optimize C method calls a little.
  • Compilation process improvements
    • It does not keep temporary files in /tmp anymore.
    • Throttle GC and compaction of JIT-ed code.
    • Avoid GC-ing JIT-ed code when not necessary.
    • GC-ing JIT-ed code is executed in a background thread.
    • Reduce the number of locks between Ruby and JIT threads.

いろんな仕組みで JIT についての性能改善を行いました。詳細は今度開発者の国分さんが記事をかくらしいので、そちらをお待ちください。

追記:国分さんが書いてくれました。

qiita.com

(ko1)

■その他

そのほかの変更です。

ruby2_keywordが空のキーワード引数ハッシュを維持しなくなった

  • Methods using ruby2_keywords will no longer keep empty keyword splats, those are now removed just as they are for methods not using ruby2_keywords.

次のような挙動の違いが入りました。

ruby2_keywords def proxy_foo(*a)
  p a
end

proxy_foo(**{}) #=> [{}]  # 2.7
proxy_foo(**{}) #=> []    # 3.0

なぜこのような違いが必要になったは、すごくややこしいので、読み飛ばしてもらって大丈夫です。Ruby 2のキーワード引数がいかに壊れていたかが感じ取れるエピソードです。

素直な期待としては、**{} は何も指定していないのと同じ扱いであって欲しいです。しかしRuby 2では、**{}が「最後のハッシュの引数がキーワードでないことを示すためのトリック」として稀に必要になっていました。

# Ruby 2.7 での意味

def foo(opt = nil, k: "default")
  p [opt, k]
end

# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?

# これはダメ
foo({k: "val"})       #=> [nil, "val"]             # キーワードとして解釈されてしまっている

# これが正解
foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

そして、このようなfooをターゲットとして委譲を行うproxy_fooというメソッドをruby2_keywords付きで宣言したケースを考えます。

ruby2_keywords def proxy_foo(*a)
  foo(*a)
end

# 次のように動かないといけない
proxy_foo({k: "val"})       #=> [nil, "val"]
proxy_foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"]

つまり、proxy_fooは**{}が渡されたかどうかを勝手に忘れるわけにはいかなかったということです。そのためにRuby 2.7では、呼び出し元で**{}がついているときに可変長引数の最後に空のハッシュを残すようになっていました。

さてRuby 3.0では、キーワード引数を渡したいときはfoo(k: "val")、ハッシュを普通の引数として渡したいときはfoo({ k: "val" })と書き分けることができるようになりました。よって、先のfooメソッドにオプション引数としてハッシュを渡したいときは、素直にfoo({ k: "val" })と書くだけで大丈夫です。

# Ruby 3.0 での意味

def foo(opt = nil, k: "default")
  p [opt, k]
end

# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?

# 素直にこれだけでOK
foo({k: "val"}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

これにより、**{}を使うトリックが不要になりました。よって、proxy_fooも**{}が渡されたかどうかを覚えておく必要はなくなったので、簡潔にするために冒頭の変更がなされました。

(mame)

バックトレースの順序が再逆転

  • When an exception is caught in the default handler, the error message and backtrace are printed in order from the innermost. [Feature #8661]

バックトレースの順序はRuby 2.5で逆転したのですが、Ruby 3.0で再逆転しました(古い順に戻った)。

次のコードでのバックトレースを見ればわかると思います。

def foo; raise; end
def bar; foo; end
bar

Ruby 2.7の出力。<main>が一番上。

$ ruby test.rb
Traceback (most recent call last):
        2: from test.rb:3:in `<main>'
        1: from test.rb:2:in `bar'
test.rb:1:in `foo': unhandled exception

Ruby 3.0の出力。<main>が一番下。

$ ruby test.rb
test.rb:1:in `foo': unhandled exception
        from test.rb:2:in `bar'
        from test.rb:3:in `<main>'

Ruby 2.5で逆転した動機は、バックトレースが長すぎるときに例外メッセージを見つけるために端末出力をスクロールしなければならないのがいやだったことでした。この問題を軽減するために、前述の --backtrace-limit が導入されました。

再逆転したのにはいくつか理由があります。

  • バックトレースの順序がツールや設定によってバラバラになってしまい、統一が進む様子もなかった ((仮にツールが対応してくれても、p *caller というコードで擬似的にバックトレースを出力させる技などがあり、これを逆転させるのは難しかった。))
  • 一部のRailsユーザから逆転させて欲しいという要望があって変わったが、本当に多くのRailsユーザが逆転を望んでいたのか怪しくなった
  • 「古い方の順に戻してほしい」という文句を3年間言い続けた人がいた(私です)

もし「Ruby 2.7の順序が本当に本当によかったのに!」という人がいたら、声を上げ続けるとよいと思います(流石に再々逆転はむずかしいと思いますが……)。

(mame)

未初期化インスタンス変数にアクセスしても警告が出ないようになった

  • Accessing an uninitialized instance variable no longer emits a warning in verbose mode. [Feature #17055]

未初期化のインスタンス変数を参照すると、-w 付きで実行していると警告が出てきてましたが、この警告が出なくなりました。挙動としては、単に nil が返ります。

$ ruby_2_7_ruby -we 'p @foo'
-e:1: warning: instance variable @foo not initialized
nil

$ ruby -we 'p @foo'
nil

この警告は、インスタンス変数名を typo に気づけるかも、ということで導入されていましたが、

  • この警告を排除するために、事前に初期化が必要で面倒
    • 書くのが面倒
    • 実行時に初期化コードが遅くなるのが面倒
  • そもそも、-w つきであんまり実行しないから、普段から気づかないよね

ということで、警告を出さなくなりました。そのため、initialize メソッドでの nil 初期化は、このためには不要になりました。

(ko1)

■おわりに

8 年ぶりにメジャーバージョンアップした Ruby 3.0 、年末年始のお休みにでも、ぜひ楽しんでみてください。

Ruby 3 では、静的検証や並行並列処理のサポートなど、大きな機能の導入がありました。 また、目標としていた Ruby 2.0 よりも3倍速い、Ruby 3x3 を JIT コンパイラの導入により達成しました。

Ruby はこれからも進化を続けていきます。ご期待ください!

では、ハッピーホリデー!

PS: 明日 12/26 (土) 13 時から、Ruby 3.0 のリリースについて、まつもとさんを交えて語るイベントを開催します(Ruby 3.0 release event - connpass)。もしよかったらご参加ください。

*1:また、後述する静的型解析のためにキーワード引数を扱いやすくしたいという狙いもありました。

*2:productionに投入できない、Rubyで書かれたツールを使っているだけの人に警告を見せても不安を煽るだけ、など。

*3:警告を止める方法は提供していたのですが、コミュケーションが不足していたり、より柔軟な警告除外指定が必要だったり、より簡単な方法が望ましかったり。

*4:将来のRails 7はRuby 3.0以降を要求する公算が高いので、Ruby 3.0で未分離、Ruby 3.1で分離、となると都合が悪い。

*5:遠藤の実力では def: foo(a) = expression というように def の後にコロンを必要とする文法しか実装できなかったのですが、bison を母語のように話せる nobu が一瞬でコロンなしで再実装してくれました。

*6:軽く調べたところ、少なくともruby-0.69(1995年頃)ではTRUEがあり、trueは未定義のようです。