Ruby 3.0 の Ractor を自慢したい

Ruby の開発をしている技術部の笹田です。娘が自転車に乗り始め、まだ不安なためずっとついていなければならず、少し追っかけまわしただけで息切れがヤバい感じになっています。運動しないと。

ここ数年、Ruby で並列処理を気軽に書くための仕組みである Ractor を Ruby 3.0 で導入するという仕事を、クックパッドでの主務として行ってきました(クックパッドから、これ、と言われていたわけではなく、Ruby を前進させるというミッションの上で行ってきました)。

Ractor は、もともと Guild という名前で開発をはじめ、2020年の春頃、Ractor という名前に変更することにしました。いくつかの機会で発表しています。下記は、RubyKaigi での発表の記録です。

そして、昨日リリースされた Ruby 3.0 で導入されました。やった! ただ、まだ仕様が変わりそうなことと、色々実装がこなれていないので、実験的機能として導入されており、使うと警告が出る状態です。

本稿では、Ractorの簡単なご紹介と、Ractor の(私の考える)位置づけ、そして将来の Ruby (主語が大きい)についてご紹介します。あまり how to な内容ではありません。

Ractor 自体の詳細は、ruby/ractor.md at master · ruby/ruby にあるのでご参考になさってください。また、先日の本ブログ記事 Ruby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログ にも、いくつか基本的な使い方が載っています。

簡単な Ractor の紹介

例を用いて、Ractor の機能と現状について簡単にご紹介します。

Ractor での並列処理で、実際に速くなる例

Ruby 3.0 のリリース文(Ruby 3.0.0 リリース)にある、Ractor プログラムの例を見てみましょう。ここ、私が書きました。引用します。

def tarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require 'benchmark'
Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }

  # parallel version
  x.report('par'){
    4.times.map do
      Ractor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end

(1行 def と呼ばれる新機能を使っているのがオシャレポイントです。定義自体は4行だけど)

このプログラムでは、ベンチマークでよく用いられる竹内関数(竹内関数 - Wikipediatarai(14, 7, 0) を、4回実行するか(seq)、Ractor を用いて4並列実行するか(par)で、実行時間を測っています。

Ractor.new { tarai(14, 7, 0) } が、新しい Ractor で tarai() 関数を実行する部分です。Thread.new{} のように、ブロックの部分を新しい Ractor(の中で作った Thread)で実行します。Ractor をまたいだスレッドは並列に実行されるので、この tarai() も並列に実行されるというわけです。

Ractor#take によって、その Ractor が値を返すのを待つことができます。

さらに、結果をリリース文から引用します。

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)

結果は Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 cores, 8 hardware threads) で実行したものになります。逐次実行したときよりも、並列化によって3.87倍の高速化していることがわかります。

このマシン、笹田の自宅にあるマシンなんですが、ちゃんと4並列で4倍近い性能が出ていてよかったね、という結果になっています。こんな感じで、Ractor を用いることで、並列計算機上で並列処理を行うことができ、うまくいけば並列実行による速度向上が狙えます。

現状の Ractor

先ほどの例では、4倍近い高速化を達成することができました。ただ、これベストケースというか、チャンピオンデータというか、うまくいく例でして、多くの場合、Ractor 自体は、まだまだうまいこと性能が出せていません。

例えば、リリース直前に発見した、性能上の大きな問題。デモのために、あまり意味がありませんが、tarai 関数の先頭で、Object を参照してみましょう。

def tarai(x, y, z) = Object &&
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))

必ず真になるので、不要な参照です。では、同じようにベンチマークをとってみましょう。

          user     system      total        real
seq  79.807530   0.000000  79.807530 ( 79.807818)
par 902.635763 432.107713 1334.743476 (343.626728)

なんと桁違い。4倍速いならぬ、4倍遅い、という残念な結果になってしまいました。なぜこんなことになってしまうかというと、定数(Object)の参照が遅いためです。

理由を少し解説すると、次のようになります。

  • (1) 定数参照時に利用するインラインキャッシュがスレッドセーフでなかったため、main Ractor 以外ではキャッシュを無効にしていた
  • (2) 定数参照時、定数テーブルは Ractor 間で共有するため、ロックを行うが、ロックが競合するとむっちゃ遅い

(1) と (2) の相乗効果でだいぶ遅くなってしまっています。残念無念。リリース直前に発覚したので、これから直そうと思っています(修正自体は、そんなに難しくない)。Ractor 自体は、こういうのがチョイチョイありそう、というクオリティーになっています。

これに限らず、これからいろんなフィードバック(主に苦情)を受けると思います。それらに対処していくことで、完成度をあげていこうと思っています。というわけで、「これおかしいんじゃないの?」とか、「ここが遅いんだけど」といったフィードバックを歓迎します。伸びしろしかないRactorを、一緒に育てていってください。

というわけで、まだそういうクオリティなので、Ractor.new{} すると警告が出ます。

warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

書いてある通り、仕様も fixed というわけではないので、変わるかもしれません。こちらもフィードバックをお待ちしております。

Ractor の基礎

Ractor の仕様は、下記のポイントを基礎としています。かいつまんでご紹介します。

  • Ractor.new{} で複数の Ractor を作ることができ、それらは並列の実行される
  • Ractor 間のオブジェクトの共有はだいたい禁止されている
    • 共有不可 (unshareable) オブジェクト
    • 特殊な共有可能 (shareable) オブジェクトだけ共有可能
      • Immutable オブジェクト
      • Class/Module オブジェクト
      • その他
  • 2種類のメッセージ交換方式
    • push型: r.send(obj) -> Ractor.receive
    • pull型: Ractor.yield(obj) -> r.take
r = Ractor.new do
  Ractor.receive # :ok を受診し、それをブロックの返値とする
end  

r.send(:ok) # r へ :ok を送る(push)
p r.take #=> r のブロックの返値 :ok を取得する(pull)
  • Ractor.select による同時待ち
r1 = Ractor.new{ :r1 }
r2 = Ractor.new{ :r2 }
r, msg = Ractor.select(r1, r2)
# どっちか早く終わったほうのメッセージが得られる
  • メッセージの送信方法
    • 複製: ディープコピーして送信
      • r.send(obj)
    • 移動: 浅いコピーを行うが、送信元ではそのオブジェクトを利用不可(使うと、どんなメソッドも method_missingになる) ruby r.send(obj, move: true) obj.inspect #=> `method_missing': can not send any methods # to a moved object (Ractor::MovedError)
      • 情報の世界では、自動的にコピーになることが多いので、「移動」という概念は面白いと思う(これが、Guild という言葉の由来だった)
  • 複数 Ractor を動かす場合、いくつかの機能に制限(後述)

詳細はドキュメント(ruby/ractor.md at master · ruby/ruby)、もしくはRuby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログ の冒頭の例などをご覧ください。

以降は、最近入って、まだあまり紹介されていない機能についてご紹介します。

Ractor#receive_if による選択的受信

Ractor.receive は Ractor に送られたメッセージを、FIFO で取り出すという機能でした。ただし、これだと、複数の Ractor から順不同で送られてくるメッセージを、区別して扱うことができません。

Erlang/Elixir などの言語では、ここでパターンマッチを用います。

Processes - The Elixir programming languageから引用)

# elixir の例
iex> receive do
...>   {:hello, msg} -> msg
...>   {:world, _msg} -> "won't match"
...> end
"world"

この例では、receiveで、pat -> expr のように、pat にマッチしたメッセージが見つかれば、expr を実行する、のように記述することができます。

Ractor.receive で似たようなことをすると、マッチしなかったとき、incoming queueにメッセージを戻すことができないため、似たような機能を作ることができません(receive済みのメッセージをためておく仕組みと、そこから取り出す仕組みを作って、receive は直接用いない、とすればできんこともないです)。

そこで、Ractor.receive_if が(結構リリース直前に)導入されました。

Ractor.receive_if{|msg| /foo/ =~ msg}

この例では、受信したメッセージのうち、/foo/にマッチする場合、ブロックが true を返し、そのときはじめて incoming queue からメッセージを削除します。

この機能を用いることで、あるパターンに合致したメッセージのみ受信することができます。

ただ、Erlang/Elixir にあったような、パターンA なら処理A、パターンBなら処理B、というようなことは書けません。というのも、このブロックは述語として true/false を返すべきものであるからです。

無りやり書くとすると、こんな感じで Proc (labmda) を返し、それをブロックの外側で実行する、として記述することが可能です(break などでブロックを抜けると、true を返したときのように incoming queue からメッセージを抜きます)。そして、その後に実行したい処理を Proc で返しているので、それを呼べば対応する処理(taskA か taskB)を実行できる、というものです。

Ractor.receive_if do |msg|
  case msg
  when patA
    break -> { taskA(msg) }
  when patB
    break -> { taskB(msg) }
  end
end.call

が、これも正直書きたくないので、Ruby 3.1 以降にマクロが入れば、なんかいい感じにできそうだなぁ、と考えています。

複数 Ractor を動かす場合、いくつかの機能に制限

これまで、Ractor がなければ問題なく使えてきた機能が、Ractor 間でのオブジェクトの共有を排除するため、複数 Ractor 環境において制限されました。Ractor を使わなければ(main Ractor だけで利用するなら)、これまで通り制限はありません。

具体的には、次の操作が main Ractor だけで利用可能になります。

  • (1) グローバル変数、クラス変数の設定・参照
  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

どの機能も、使われていると一発で main Ractor 以外で利用できなくなります。この中で、一番はまりそうなのは、(2) と (3) でしょうか。

C = ["foo", "bar"]  # NG: (2) 定数に共有不可オブジェクトを設定

class C
  @bar = 42     # NG: (3) 共有可能オブジェクトのインスタンス変数を設定
  def self.bar
    @bar        # NG: (3) 共有可能オブジェクトのインスタンス変数を参照
  end
  def self.bar=(obj)
    @bar = obj  # NG: (3) 共有可能オブジェクトのインスタンス変数を設定
  end
end

よく使われていそうなプログラムです。この制限により、多くのライブラリを、複数 Ractor 上で利用することが、現在できません。今後、うまいこと書き換えが進むと、Ractor は利用しやすいものになっていくと思います。

さて、ではどのようにすればいいでしょうか。

(2) については、# shareable_constant_value: ... というプラグマが新設されました。

# shareable_constant_value: literal

C = ["foo", "bar"]

このオプションで none(デフォルトのモード)以外を選ぶと、定数が共有可能オブジェクトを参照している、ということを保証できます。この例では、literal を選んでいます。これは、定数の右辺値、つまり代入するオブジェクトがリテラルのように記述されたオブジェクトなら、再帰的にfreezeしていくことで、immutable な共有可能オブジェクトを生成し、定数に代入します。リテラルでなければ、共有可能であるか実行時にチェックすることで、共有不可オブジェクトが定数に代入されることを防ぎます。

指定できるオプションは、noneliteral 以外に、この2つが指定できます。

  • experimental_everything
    • 右辺値の値を共有可能オブジェクトに変換する
  • experimental_copy
    • 右辺値の値をまずコピーし、コピーに対して共有可能オブジェクトに変換処理を行う

everything は副作用が気になりますが、copy は元のオブジェクトに影響を与えないため、副作用がほぼ起こりません。ただし、コピーによって若干時間がかかるかもしれません。

(3) の、共有したい mutable な値については、gem になりますが、Ractor::TVarractor-tvar | RubyGems.org)を用いると良いと思っています。

class C
  BAR = Ractor::TVar.new 42
  def self.bar
    Ractor::atomcally{ BAR.value }
  end
  def self.bar=obj
    Ractor::atomcally{ BAR.value = obj }
  end
end

Ractor::atomcally を毎回書かないといけないのは冗長な気もしますが、ここで Ractor 間にまたがる共有状態を操作している、というのが明示できて、長い目で見ると利点になるのではないかと思っています。

拡張ライブラリの Ractor 対応

C などで記述された拡張ライブラリは、デフォルトでは main-Ractor 以外では動きません(提供されているメソッドを呼ぼうとすると、Ractor::UnsafeError になります)。

対応させるためには、複数 Ractor で実行していいことを確認して、rb_ext_ractor_safe(true) で、この拡張ライブラリが Ractor のサポートをしていることをインタプリタに教えてあげることが必要です。

対応させるためのチェックポイントについて、詳細は、ruby/extension.rdoc at master · ruby/ruby にまとめてあります。ただ、あんまり変なことしてなければ、たいてい Ractor 対応は簡単じゃないかなと思っています。

Ractor の背景

ここからは、具体的なコードの話ではなく、Ractor に関する検討について、その一端をご紹介します。

複数コアのCPUが普通になってきた昨今、並列計算を記述する、というニーズはどんどん高まっています。というフレーズは、私が大学で研究していた10年以上前から定番の前振りでした。実際、高性能なソフトウェアを書くのに、並列計算は必須であることにどなたも異論はないでしょう。

並列計算を行うためには、プログラムが並列計算に対応していなければなりません。そのためには、並列プログラミングが必要になります。すでに、多くのプログラミング言語が並列計算のための仕組みを備えています。

スレッドプログラミングは難しい

ただ、並列計算を行うプログラムは、だいぶ面倒くさいことが知られています。とくに、スレッドプログラミングは、いろいろな理由から、正しいプログラムを書くことが困難です。Ruby でも、スレッドは Thread.new{ ... } と書くことで、簡単に作ることができます。

たとえば、同じメモリ領域に、複数のスレッドが同時に読み書きすると、おかしなことになります。「この順番で読み書きしているから大丈夫」とすごく考えてプログラムをかいても、コンパイラの最適化によって読み書きの順序が変わったりして、逐次処理しているときには気づかなかった問題が生じることも多いです。この問題を、よくスレッド安全の問題といいます。

デバッグ時には、非決定的(non-deterministic)な挙動が問題になります。逐次処理は、2度実行すれば、だいたい同じ結果になります(そういう筋の良いバグが多いです)。しかし、複数のスレッドがどのように動くかは、スレッドマネージャの仕事になり、一般的には制御することは難しく、2度目の実行では異なる結果になることが多いです。そのため、問題が発覚しても、その問題を再現することが難しく、つまりデバッグがすごくしんどいわけです。

この非決定性は、ほんとうにタイミングよく何かしないと起きないバグなんかだと、めったに再現しないので、がんばって修正をしても、本当にその問題が解決したのかわからない、といった話もあります。

メモリを共有した同時の読み書きは難しい

スレッドプログラミングの1つの問題点は、複数スレッドでメモリを同時に読み書きが可能である、という点が挙げられます。同時に読み書きが起こる可能性があるメモリ領域においては、ロックをかけて排他制御するなど、他のスレッドと同期しながら処理を進める必要があります。

が、往々にして、こういう「ちゃんとアクセスする前にはロックをとる」みたいなものは、忘れがちです。人間は、ウッカリをするものです。私はしょっちゅう忘れて痛い目にあっています。

「私は大丈夫、ちゃんと同期とか仕込める」という人も、うっかりやっちゃう可能性はいくらでもあります。いくつか、うっかりしそうな例を並べてみます。

  • プログラムの規模が大きくなり、想定と別の用途でデータを用いて、うっかりロックが必要であることを忘れる
  • データ構造が複雑化し、共有されていることに気づかず、うっかりロックを忘れる
  • 別の人(将来の自分かも)がうっかりロックを忘れてアクセスする

他にもいろいろあると思います。

ちなみに、「ちゃんと動くプログラムを書く」というのも難しいですが、さらに「速いプログラムを書く」というのも難しい問題です。例えば、異なるメモリには、異なるロックを本当に必要な時にだけ用いたほうが(つまり、細粒度ロックを用いるほうが)並列度はあがり、並列処理の性能向上をますが、ロックの処理(獲得と開放)を頻繁に行う必要が出てきて、下手に作ると遅くなってしまいます。

難しさに対する対応策

もちろん人類は賢いので、様々な対策を考えてきました。

  • ロックなどをきちんと使っているか、チェックするツールの利用(valgrin/helgrind、thread-sanitizer、...)
  • ロックなどを自然に使うことができるデータ構造の導入(同期キュー、Transactional memory、...)
  • 型によるデータの所有系の明示(Rustなど)
  • 書き込みを禁止して、同時に読み書きを起こさない(Erlang/Elixir, Concurent-haskell など)
  • そもそもプロセスなどで分離して、共有しない(shell, make)

が、どれも完全に解決するのが難しいか、Ruby に導入するのは困難です(個人の見解です。別の見方もあると思います)。

  • ツールは漏れが生じます。また、MRI の構成上、(現実的なコストで)実現がなかなか困難です
  • データ構造を正しく扱えば問題なくても、ロックを忘れるのと同様にうっかり正しくない使い方をしてしまいます
  • Ruby にはこの手の型を記述する方法がないため困難です(文法を入れるのはきっと難しい)
  • 書き込み禁止(例えば、インスタンス変数への代入禁止)は、互換性を大いに壊します

最後の「そもそもプロセスなどで分けて、原則状態を共有しない」という shell などで利用されているアプローチは、Ruby でもマルチプロセスプログラミングとしてすでに行われています。dRubyやUnicorn、paralle.gem のプロセスモードなどがこれですね。通信する場合にひと手間かける、というアプローチになっています。

このモデルでは、それぞれのコンポーネントを単純に作ることができ、まさに UNIX 流の開発の利点が効いてきます。パイプなどでうまくつなげることで、それぞれが独立に並列実行させることができたり、make で依存関係のルールを記述することで、それぞれのタスクを良い感じに並列実行させることができます。また、別の計算機に処理を分散させることも、比較的容易です。

ただ、プロセスを複数いい感じに並べるだけだと、パイプだけだとちょっと表現力が弱く(パイプライン並列処理に特化している)、make も、あまり複雑なことは書けません。先述した Unicorn なども、あるパターンに特化していますね。

それから、コミュニケーションを主にパイプで行うため、通信のための手間が、複雑なコミュニケーションを行う場合は結構大変になります。また、実行単位がプロセスになることが多いので、タスクが多い場合、リソース消費が問題になる可能性があります。

Ractor の狙い

Rubyのモットーは「たのしいプログラミング」というところだと思います。できるだけ、難しいこと、面倒なことはしなくても良いようにするといいと思っています。

(現在、Ruby でも行うことができる)スレッドプログラミングは、その点で考えることがたくさんで、いざバグが入ると直すのが難しいという、「たのしさ」からは離れた機能ではないかと思うようになりました(難しいスレッドプログラミングをきちんとやる「たのしさ」もあると思うので、まぁ一概には言えないのですが)。この話は、手動メモリ管理と自動メモリ管理の話に似ていると思っています。つまり、ちゃんと作れば手動メモリ管理は効率的だったりしますが、うっかり間違えてしまったり、バグの発見は難しいし、というような。

そのため、多少性能を犠牲にしても(最高性能は出ないにしても)、なんとなく書けばちゃんと並列に動く、というのを目指すと良いのではないかと思い、Ractor を設計しています。

前節で述べた並列並行プログラミングの問題点を解決するために、Ractor はどのようなアプローチをとっているかご紹介します。

共有しない、がちょっと共有する

並列プログラミング言語において、あるメモリに対する read/write を混ぜない、というのは大事な観点であることをご紹介しました。並行並列に実行する処理が、共有状態を持たないと、問題が簡単にいなるわけです。

そこで、Ruby でこれを実現するのが Ractor です。Ractor という単位でオブジェクト空間を「だいたい」分けて、お互いに干渉させないようにさせます。これで、いわゆる同期漏れによるスレッド安全の問題が、だいぶ解決されます。

ただし、全部分けるとプロセスと同じでそれはそれで不便となるので、いくらか共有してもだいたい大丈夫だろうと思われるものを共有します。これが、プロセスで完全に分離してしまうことに対する利点になります。

この、ちょっとだけ共有することで、下記の利点が生じます。

  • Ractor 間の通信が、少し書きやすくなる
  • Ractor 間の通信が、少し速くなる
  • Ractor 間でメモリを共有することで、メモリ消費が減る

ウェブアプリケーションサーバにおいて、スレッドモデルが好まれるのが、「メモリ消費が減る」ではないでしょうか。Ractorでは、そのへんをそこそこ狙っています。

ただし、ちょっと共有することで、スレッド安全に関する問題が残ります。これは、本当に難しい問題で、利点を取るか欠点を取るか、ずいぶん悩んだのですが、今回は利点を優先することにしました。「まぁ、だいたい大丈夫だろう」というやつです。

ほかの言語では、Racket という言語で place という、Ractor と似た isolation を行う仕組みがあります(Places: adding message-passing parallelism to racket | Proceedings of the 7th symposium on Dynamic languages )。Ractor とよく似ていますが(だいぶ参考にしました)、通信の方法が、Go 言語のように、チャンネルを用いるというのが Ractor と異なります。

Actor model と CSP (Communicating Sequential Processes)

よく Erlang と Go の比較で、前者が Actor、後者が CSP を採用している、みたいな話があります。大雑把に言うと、前者が通信対象を並行実行単位(アクター)に、後者を並行実行単位をつなぐチャンネルに対して行うのが特長になるかと思います(厳密には多分違うと思うんですが、ここではそうとらえてみます)。

Ractor は、名前の通り Actor model を強く意識して設計されています。実は、2016年の開発当初では、とくに何も考えずに CSP 的なモデルを考えていました。ただ、数年色々考えた結果、Actor model のほうがいいかな、と思って、現在の設計になっています。

Actor model の利点はスケールがしやすいことと言われています。これ、作ってみるとわかるんですが、待ちが生じるのが、いわゆるアクターへ送られたメッセージを受信する操作に限定されるんですよね。複数のチャンネルを待ったりするより、自分自身に送られてきたメッセージを監視するだけのほうが楽なのです。他にも、コンポーネントを疎にしやすい(例えば、アクターが別の計算機にいてもよい)といった良い性質を持ちます。

が、あまりそのへんが Actor model 型のインターフェースにした理由ではなく、例外の伝搬を適切に行うことができるか、という観点から、現在のデザインにしました。

相手を指定する操作において(具体的には、Ractor#send による送信時に、もしくは Ractor#take による受信時)、相手の Ractor がすでに例外などで終了していた場合、エラーで気づくことができます。つまり、エラーが出ていることを、Ractor 間で適切に伝搬させることができるわけです。

CSPでは、処理の対象がチャンネルなので、その先につながっている並行実行単位の状況はわかりません(そもそもつながっていないかもしれない)。適切にチャンネルをクローズする、という手もありますが、ひと手間かかります(つまり、一手間を忘れる可能性があり、そして可能性があれば人は忘れる)。ソケットなんかは似たようなモデルですが、プロセスに紐づいているので相手側に状況が伝わります。こういうモデルでもよかったかなと思うのですが、うまいこと簡単に扱うAPIに落とし込めませんでした(チャンネルをさらにほかのRactorに渡すような用途で、うまいことモデリングできませんでした)。

いくつかのパターンでは、CSP のほうが書きやすい、というのがわかっていたのですが、Ractor 自体をチャンネルのように使えば、性能を気にしなければ、実は CSP とほぼ同じようなことができることがわかったので、とりあえず Actor model 風のインターフェースをベースにしました。性能はあとでなんとかしよう、と思っています。Actor っぽい push 型のコミュニケーション手段と、Actor っぽくない pull 型のコミュニケーション手段が混ざっているのは、この辺を作りやすくするためです。

余談ですが、pull 型のコミュニケーションは、Promise を簡単に作れる、というような感じにしています。

r = Ractor.new{ expr }
do_some_task()
r.take # ちょうどいいタイミングで値を得る

Promise には並列に実行する、という意味はあんまりないんですが、Ractor ではそれを実現しています。さらに余談ですが、先ほど紹介した Racket の Future (Promise みたいなやつ) はもっとかっこよくて、スレッドで並列に動かすんだけど、thread-safety を危うくする処理(つまり、共有状態への操作)を検出すると、そこで止まるというかっこいい奴になっています。かっこいいなぁ、真似したいなぁ、と思うのですが、Ruby だといろいろ難しくて断念しました。

コピーと移動

互いに分離された環境で通信するとき、コピーによってメッセージを渡すのは、よくある方法です。ただ、それだけだと他と同じで面白くないな、と思って考え付いたのが移動です。

情報の分野において、送ったメッセージが、その後参照できなくなるとういうのは、あまり聞いたことがないので面白いなぁと思って、Guild といってた時の目玉機能と思っていました。そもそも、Guild という名前は、Guild のメンバーが移籍する(moveする)という意図で見つけた名前でありました。

が、まぁ普段は使わない(コピーで十分)ということで、あまり前面に出さないようにして、そうすると Guild という名前もなんだね、ということで、Ractor に改名されました。

Ractor が使えるようになるまでの、まだまだ長い道のり

このように、とりあえず入った Ractor ですが、便利に利用するにはいくつかのハードルがあります。

利用者としての課題

まずは、ライブラリの対応がたくさん必要になります。とくに、先に述べた2点

  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

については、だいぶ書き換えが必要になると思います。本当に大丈夫か、ってくらい。あと、説明してませんでしたが、define_methodによるメソッド定義で使うブロックが、ふつうのブロックだと他の Ractor では使えないというのがあります。まずそうな点です。

ライブラリがないと Ruby の魅力はものすごく下がってしまいます。そのため、これらの変更に追従していただけるかどうかが、Ractor が成功するかどうかの分水嶺になるかと思います。

使いづらいところがあれば、Ractor 側で改良したり、便利なライブラリを提供していったりしていきたいと思います。フィードバックをお待ちしております。

書き換えはいろいろ面倒なのですが、これは、スレッド安全を解決するための、見直すための良い指針の一つになる可能性があります。いままで、スレッド安全について、テストで問題ないし、なんとなく平気かな、と思っていたところが、ぜったい大丈夫、という安心感に代わるんではないかと思います。

並行並列処理時代の Ruby に書き換えるという、個人的には Ruby の性質を変える話じゃないかと思います。

実装上の課題

最初にご紹介した通り、性能上の問題、そしてバグが残っています。随時直していこうと思いますので、こちらもフィードバック頂ければと思います。

以下、実装の課題について、箇条書きで並べておきます。

  • 性能改善
      * ObjectSpace の物理的な分離+分散GC
      * Fiber context による Thread の実装)
      * 単一ロックではなく、細粒度ロックによる並列度の改善
      * その他もろもろ(定数アクセスのキャッシュとか)
    
  • パターンの収集とライブラリ化(OTP, TBB的な)
  • デバッグ

おわりに

本稿では Ractor についてご紹介しました。

自慢したいことは、まだ仕様・実装ともに不十分ではありますが、Ractor を導入までもっていったこと、それから娘が自転車に乗れることです。

新しい Ruby の一つの形ということで、楽しんでいただければ幸いです。

では、よいお年をお迎えください。

プロと読み解く 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_PAGERPAGER が空でない文字列で設定されていれば、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ヶ月前)に変わっているので、非互換問題がおきないといいなあ。個人的には、ArrayStringのようなコアクラスはあまり継承しないほうがいいと思います。

(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#backtraceFiber#backtrace_locations が導入された

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

Thread#backtrace は、そのスレッドが現在実行中のバックトレースを出す機能でしたが、これを Fiber ごとに得る Fiber#backtraceFiber#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/yieldFiber#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#clonefreeze: 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 では、b1Procを生成し、それをbar(&b1)として渡しても、すでにProcが生成されているので、単にその Proc を渡すだけでした。そのため、b1.equal? b2 は true でした。

しかし、Lazy Proc Allocation によって、Proc の生成が遅延されてしまうので、bar で初めて Proc を作り、そしてその情報は foo 側には渡らないので foo でも新たに Proc を作り、b1 == b2b1.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)

定数のTRUEFALSENILが定義されないようになった

  • 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#grepgrep_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は未定義のようです。

Compositional LayoutとDiffable Data Sourceを使ってiOSアプリのつくれぽ詳細画面を実装する

クックパッドの事業開発部でiOSエンジニアをしている角田(id:muchan611)です。普段はクックパッドiOSアプリの検索に関する機能を開発しています。

クックパッドの基本的な機能のひとつである「つくれぽ」を表示する「つくれぽ詳細画面」を、UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを使って実装したので、その過程や実装方針についてご紹介します。

背景

つくれぽとは、クックパッドのレシピを見て料理をした人が、その料理を他の人におすすめするために投稿するもので、検索ユーザーはつくれぽ通してレシピを探せるようになっています。

事業開発部では「つくれぽからレシピを決める」体験を増やす取組みを行っていますが、各施策の方針を決定するために、多くのユーザーインタビュー(※)や数値分析を実施し判断材料を得ています。
そのインタビューの中で「レシピを決定するには材料情報が必要だが、つくれぽ詳細画面にはそれが表示されておらず、レシピ決定の障壁になっている可能性がある」という課題が明らかとなり、つくれぽ詳細画面に材料を表示する施策が決まりました。

今回の開発では、これまでの実装を拡張するのではなく、CollectionViewを用いて画面を作り替えることとなったため、その際に得た知見や実装方針について、ひとつの例としてご紹介できればと思います。

課題と実装方針

実はiOSクックパッドでは、2020年の春に大きなリニューアルを実施し、その際につくれぽ詳細画面を大きく変更しました。
ただ、この時に実装されたつくれぽ詳細画面では、コンテンツが追加されることを想定していなかったため、スクロールができない画面となっていました。変更前後のつくれぽ詳細画面は以下のような見た目で、以前はViewControllerの上に直接各パーツが配置されていました。

以前のつくれぽ詳細画面

f:id:muchan611:20201223222235p:plain:w160

新しいつくれぽ詳細画面

f:id:muchan611:20201223222420p:plain:w160 f:id:muchan611:20201223222452p:plain:w160

そして、今回材料コンテンツを実装するにあたって、以下の問題をクリアする必要がありました。

  • スクロールしないことを前提にした制約が多く、そのまま構造を変えずに実装を進めると、非常に複雑でメンテナンスしにくい状態になりかねない
  • 今後、材料以外にもレシピ決定に必要なコンテンツを追加していく可能性が高く、継続的にコンテンツを増やせるような構造にする必要がある

このような背景を踏まえて今後の継続的な開発を検討した結果、 UICollectionViewで画面を作り替えUICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを利用する方針で開発を進めることにしました。主な理由は以下の通りです。

  • コンテンツの追加が容易に行える
    • 前述した通り、今後もレシピ決定に必要なコンテンツを追加する可能性があり、レイアウトの変更に強くシンプルで分かりやすい実装が実現できるCollectionViewが最適だった
  • UICollectionViewCompositionalLayout を利用することで、section毎のカラム数指定や各コンテンツのサイズ指定が柔軟で容易になる
    • 例えば、材料sectionは2カラム、それ以外は1カラムで表示するといった、文字数によるコンテンツの高さ計算を自前で行う必要がなく、それらの調整をAutoLayoutに任せることが可能
  • UICollectionViewDiffableDataSourceを利用することで、データへのアクセスも容易で安全になる
    • 表示データをインスタンス変数に保持して利用するケースと比較すると、UICollectionViewDiffableDataSourceを利用することでデータの保持をフレームワーク側に任せることができ実装が簡素化できる
    • 型による制約が強いため、データとUIの不整合を防止できる

実装内容

全てのコードを載せると全体が分かりにくくなってしまうため、一部割愛しながら実装内容についてご紹介します。

DataSourceの定義

まずdataSourceですが、以下のような定義になっています。

var dataSource: UICollectionViewDiffableDataSource<Section, Item>!

SectionIdentifierTypeにはSectionを、ItemIdentifierTypeにはItemというenumを指定しています。 それぞれのenumの定義は以下の通りです。(TsukurepoViewItemは、APIから取得したつくれぽ情報をViewにとって都合の良い形に変換した構造体です)

enum Section: CaseIterable {
    case media
    case margin
    case recipeTitle
    case recipeDescription
    case ingredientsHeader
    case ingredients
    case showMore
}

enum Item: Hashable {
    case media(media: TsukurepoViewItem.Media?, tsukurepo: TsukurepoViewItem.Tsukurepo?)
    case margin
    case recipeTitle(TsukurepoViewItem.RecipeOverview?)
    case recipeDescription(String)
    case ingredientsHeader
    case ingredients(TsukurepoViewItem.Ingredients)
    case showMore
}

このように分けた背景についてですが、まず、UICollectionViewCompositionalLayoutでは、section毎にレイアウトを組む仕組みになっているため、Sectionはレイアウト単位で分けることにしました。

そして、Itemはcell単位で分けており、cellに渡したいデータをenumのassociated valueで持つようにしています。 UICollectionViewDiffableDataSourceの初期化時に指定するcellProvider内で、各cellの更新処理を実装するため、その際に必要なデータへ簡単にアクセスできるようにするためです。

dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { [weak self] (collectionView: UICollectionView, indexPath: IndexPath, identifier: Item) -> UICollectionViewCell? in
    guard let self = self else { return nil }
    switch identifier {
    case let .media(media, tsukurepo):
        let cell = collectionView.dequeue(TsukurepoDetailsMediaCell.self, for: indexPath)
        cell.configure(media: media, tsukurepo: tsukurepo)
        cell.delegate = self
        return cell
        //..以下省略..
    }
}

dataSourceへsnapshotをapplyする処理は、下記のapply(tsukurepo: TsukurepoViewItem?)内で実装しており、この関数はviewDidLoad()内やつくれぽ情報の取得が完了した際に呼びだされます。

override func viewDidLoad() {
  super.viewDidLoad()
  //..途中省略..

  apply(tsukurepo: nil)

  presenter.tsukurepo
    .drive(onNext: { [weak self] tsukurepo in
        self?.apply(tsukurepo: tsukurepo)
    })
    .disposed(by: disposeBag)
}

viewDidLoad()が呼び出された時点では、まだつくれぽ情報を取得していないので、引数のtsukurepoがnilとなります。その場合は、media margin recipeTitleItemIdentifierTypeのみを追加し、それぞれのcellではempty viewを表示するように実装しています。
つくれぽ情報取得後は全てのsectionにItemIdentifierTypeを追加し、材料については存在する材料の数だけingredientsを追加します。

func apply(tsukurepo: TsukurepoViewItem?) {
    var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
    snapshot.appendSections(Section.allCases)

    snapshot.appendItems([.media(media: tsukurepo?.media, tsukurepo: tsukurepo?.tsukurepo)], toSection: .media)
    snapshot.appendItems([.margin], toSection: .margin)
    snapshot.appendItems([.recipeTitle(tsukurepo?.recipeOverview)], toSection: .recipeTitle)
    if let tsukurepo = tsukurepo {
        if let description = tsukurepo.recipeOverview.description {
            snapshot.appendItems([.recipeDescription(description)], toSection: .recipeDescription)
        }
        snapshot.appendItems([.ingredientsHeader], toSection: .ingredientsHeader)
        let ingredients: [Item] = tsukurepo.ingredients.map { .ingredients($0) }
        snapshot.appendItems(ingredients, toSection: .ingredients)
        snapshot.appendItems([.showMore], toSection: .showMore)
    }

    dataSource.apply(snapshot, animatingDifferences: false)
}

レイアウトの生成

つくれぽ詳細画面の構造を簡略化するとこのようになります。(2枚目はスクロール後です)

f:id:muchan611:20201223222700p:plain:w300 f:id:muchan611:20201223222719p:plain:w300

これを実現しているコードは下記の通りですが、section毎にコンテンツの高さを割合や絶対値、推定値で指定しています。
例えば、mediaはつくれぽ画像を含むsectionで、仕様上縦横比が3:4になるように表示したいのですが、この場合はgroupのサイズに次のような指定をします。

let groupSize = NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalWidth(1.33))
let group = NSCollectionLayoutGroup.horizontal(Layoutize: groupSize, subitem: item, count: 1)

.fractionalWidth.fractionalHeight を指定することで、幅や高さに対する割合でコンテンツのサイズを決めることができるためです。また、説明文や材料などは文字数によって高さを可変にしたり、文字サイズ変更の際に適切な高さを適用したりするため、.estimatedを指定しています。そうすることで、コンテンツサイズが変更される時にシステム側で実際の値を計算し調整してくれます。また、最下部に表示する「このレシピを詳しく見る」ボタンの高さは固定にしたいため、絶対値で指定ができる.absoluteを利用しています。
これらのDimensionについては公式ドキュメントに詳細が記載されています。

let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex: Int, _: NSCollectionLayoutEnvironment) -> NSCollectionLayoutection? in
    guard let self = self else { return nil }
    let sectionKind = self.dataSource.snapshot().sectionIdentifiers[sectionIndex]

    let itemHeight: NSCollectionLayoutDimension
    let groupHeight: NSCollectionLayoutDimension
    switch sectionKind {
    case .media:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalWidth(1.33)
    case .margin:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.03)
    case .recipeTitle:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.15)
    case .recipeDescription:
        let height = NSCollectionLayoutDimension.estimated(72)
        itemHeight = height
        groupHeight = height
    case .ingredientsHeader:
        let height = NSCollectionLayoutDimension.estimated(40)
        itemHeight = height
        groupHeight = height
    case .ingredients:
        let height = NSCollectionLayoutDimension.estimated(35)
        itemHeight = height
        groupHeight = height
    case .showMore:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .absolute(108)
    }

    let itemSize = NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension: itemHeight)
    let item = NSCollectionLayoutItem(Layoutize: itemSize)
    let groupSize = NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension: groupHeight)
    let group = NSCollectionLayoutGroup.horizontal(Layoutize: groupSize, subitem: item, count: sectionKind.columnCount)

    return NSCollectionLayoutection(group: group)
}

そして、材料のsectionでは1行に2つのitemを表示したいため、countを指定することでsectionによって表示するitemの数を変えています。
sectionKind.columnCountは、材料sectionの場合に2、それ以外は1を返します。

let group = NSCollectionLayoutGroup.horizontal(Layoutize: groupSize, subitem: item, count: sectionKind.columnCount)

このようにUICollectionViewCompositionalLayoutを使う事で、カラム数を変えたりコンテンツサイズを柔軟に指定したりすることができ、複雑なレイアウトもシンプルで簡単に実現することができます。

iOS12以下のサポートについて

UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceはiOS12以下で利用できないため、iOS12以下で同じような実装を実現したい場合はIBPCollectionViewCompositionalLayoutDiffableDataSourcesなどのバックポートライブラリを使用する必要があります。

クックパッドでも、主要な画面においては、iOS12で表示できるようにこれらのバックポートライブラリを利用するケースがありました。しかし、公式の仕組みとの挙動の違いから少なからずサポートコストがかかっていたため、今回はiOS13以上の端末でのみ新しいつくれぽ詳細画面を表示しiOS12以下をサポートしない、という事業判断を行いました。
(本実装を行った2020年11月時点において、クックパッドアプリではiOS12をサポートしていましたが、現在はサポート対象をiOS13.1以上に引き上げています)

まとめ

ここまでに述べたように、UICollectionViewCompositionalLayoutを用いることでsection毎のカラム数指定や各コンテンツのサイズ指定を柔軟で容易に行えるため、レイアウトの実装がシンプルかつ比較的簡単になります。また、UICollectionViewDiffableDataSourceを利用する事で、データの保持をフレームワーク側に任せることができ実装が簡素化できるほか、データとUIの不整合の防止にも繋がるため、より安全な実装が実現できます。
そして、これらの仕組みを利用してつくれぽ詳細画面を作り替えることで、新しいコンテンツの追加が容易となり、スムーズに追加開発を進められる状況になっています。

施策の結果については、(レシピ決定のひとつの指標である、つくれぽ詳細から遷移したレシピ画面での)クリップ率上昇やつくれぽ一覧画面の3日以内再訪率が上昇したことが分かり、「つくれぽからレシピを決める」体験を増やすことができたと評価しています。

このように、クックパッドではユーザーインタビューや数値分析を通して施策を考え開発を進めており、一緒にサービス開発を盛り上げてくれるiOSエンジニアを大募集しております!!
カジュアル面談なども実施しておりますので、少しでもご興味がある方はぜひお気軽にお問い合わせください!

https://info.cookpad.com/careers/


※現在、ユーザーインタビューはオンラインで実施しています