Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: frozen_string_literalの歴史と現状、未来を考察する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
frozenは基本的に英ママとしました。

なお、Ruby 3.4以降ではRUBYOPT環境変数でRUBYOPT="--enable-frozen-string-literal"のように指定すれば、その環境で文字列リテラルをデフォルトでfrozenにできます。

Ruby: frozen_string_literalの歴史と現状、未来を考察する(翻訳)

Rubyistの皆さんなら、Rubyソースコードのほどんどのファイル冒頭に# frozen_string_literal: trueというマジックコメントをせっせと書いたり、少なくとも他のプロジェクトで目にしたことぐらいはあるでしょう。

カンファレンスやオンラインでの非公式な議論を見る限りでは、どうもこのマジックコメントの本当の意義が十分に理解されているとは言えないようです。そこで、このマジックコメントがなぜ存在するのか、このマジックコメントが一体何をやっているのか、そして将来マジックコメントはどうなるのか、について話しておく価値があると思いました。

🔗 Rubyの文字列はミュータブル

frozen_string_literalのどんな点が特殊であるかについて深掘りする前に、RubyのString型について話しておく必要があります。RubyのStringは、広く使われている他のプログラミング言語で使われている同種の型とは、かなり趣が異なっています。

一般的なプログラミング言語は、文字列をイミュータブル(immutable: 不変、改変不可)なものとして扱うのが圧倒的多数です(Java、JavaScript、Python、Goなど)。

しかしわずかな例外として、PerlやPHP1、C/C++(リテラルは除く)、そしてもちろんRubyがあります。

>> str = String.new
=> ""
>> str.object_id
=> 24952
>> str << "foo"
=> "foo"
>> str
=> "foo"
>> str.capitalize!
=> "Foo"
>> str.upcase!
=> "FOO"
>> str
=> "FOO"
>> str.object_id
=> 24952

Rubyの文字列は実装上は単なるバイト配列であり、これらのバイトをどう解釈すべきかを知るためのエンコーディング情報がアタッチされています。

class String
  attr_reader :encoding

  def initialize
    @bytes = []
    @encoding = Encoding::UTF_8
  end
end

文字列のこうした扱いは、プログラミング言語ではかなり珍しいと言えます。

訳注

Rubyの文字列については以下の記事もどうぞ。

Rubyの内部文字コードはUTF-8ではない...だと...?!

🔗 文字列のエンコーディングについて

ほとんどのメジャーなプログラミング言語(特に上述のもの)は、Rubyのように文字列のエンコーディングを選択可能にせず、特定の内部エンコーディングに固定されているので、文字列のエンコーディングは一律で同じになります。

たとえばJavaやJavaScriptの文字列はUTF-16エンコーディングに設定されてます。その理由は、JavaやJavaScriptが誕生したときはちょうどUnicode仕様の初期の時代でもあったためです。当時多くの人がエンコードには16ビットもあれば十分だと甘く考えていましたが、やがてそれが過ちであったことが判明しました。それより新しいプログラミング言語のほとんどは、UTF-8か、いくつかの限定的な内部エンコーディングを採用しています。

たとえばPythonの文字列は、ISO-8859-1(Latin 1とも呼ばれます)、UTF-16、UTF-32のいずれかにエンコーディングできます。しかしこれは、ユーザーにしてみれば実装の詳細であり、文字列で使われているエンコーディングが実際はどれなのかを後から厳密には区別できません。意味論的には、文字列とはUnicodeのシーケンスであり、文字列をメモリ上でどのようにエンコードするかという情報は抽象化されていて、手が届かなくなっているのです。

そうした言語で別エンコードのテキストを扱うには、いったんその言語の内部表現に再エンコードするしかありません。

しかしRubyの文字列では、同一プログラム内に複数の内部エンコードが共存できる仕組みになっています。Rubyがサポートしているエンコードは100種類以上にのぼります。

>> Encoding.list.size
=> 103

Rubyはなぜこの方法を選んだのでしょう。100%の確信があるわけではありませんが、Rubyが日本で生まれたプログラミング言語であることが大きな要因なのかもしれません。

訳注

これについてはMatz自らがQuoraで回答しています。
参考: RubyではなぜUCS正規化を採用していないのでしょうか?に対するYukihiro Matsumotoさんの回答 - Quora

Unicode仕様の初期には、中国語、韓国語、日本語で共通する一部の文字を統一しようとする動きがありました(これは現在ではUnihanと呼ばれています)。このような統一への動きがあったために、日本のIT業界は欧米のIT業界に比べてUnicodeの採用が遅れていて、長らくShift_JISなどの日本語固有のエンコーディングが広く使われていました。

そのため、日本語圏ではUnicodeへの変換を強制せずに日本語テキストを扱えるようにすることが、Rubyのコアコントリビュータの多くにとって重要な機能だったのでした。

しかしここでミュータブルの話に戻りましょう。

🔗 文字列がミュータブルであることの長所と短所

エンジニアリングでありがちな話ですが、文字列がイミュータブルであることとミュータブル(mutable: 改変可能)であることには、どちらにも長所と短所があります。したがって、どちらが本質的に優秀であるかというものではありません。

文字列をイミュータブルにするメリットの1つは、文字列が改変される心配なしに安心して手軽に共有できることです。以下の例を考えてみましょう。

sliced_string = very_long_string[1..-1]

上のコードは、このものすごく長い文字列がミュータブルな場合は、very_long_stringsliced_string変数に(1バイトを除いて)すべてコピーしなければならなくなり、これはコスト高になる可能性があります。
しかし文字列がイミュータブルであれば、sliced_string変数は内部的にvery_long_stringの内容をオフセットとして指すだけで済みます。これは一部の言語で文字列のビュー(view)や文字列のスライス(slice)と呼ばれています。

イミュータブルな文字列のもうひとつのメリットは、インターン(interning)が可能になることです。インターンの考え方はシンプルで、文字列がイミュータブルであれば、中身が完全に同一の文字列インスタンスが複数できてしまったときに強制的に1個のインスタンスに統合できるというものです。メモリ節約のために重複解除する文字列を探索するCPUタイムをどれだけ許容するかはトレードオフとなるので、インターンによるこの重複解除をどのぐらい積極的に行うかは状況によります。

その他に、マルチスレッドのコードや辞書キーで値が改変される心配がなくなるというメリットもあります。文字列は辞書キーとしてよく使われるので、文字列がミュータブルだと文字列を変更したときにハッシュコードも変更され、ハッシュテーブルが壊れてしまいます。

一方、文字列がミュータブルだと、たとえば文字列を次々に追加して最終的な文字列をビルドする場合にとても便利です。

buffer = ""
10.times do
  buffer << "hello"
end

逆に、Javaのように文字列がイミュータブルな言語では、同じようにループ内で文字列を安易に結合して文字列をビルドしようとするとパフォーマンスが悪化することが昔から知られています。

String buffer = "";
for (int i = 0; i < 10; i++) {
  buffer += "hello";
}

上のJavaコードの例では、ループを回すたびに+=演算子によって新しい文字列がアロケーションされ、その内容がコピーされます。これは文字列が長くなるに連れて指数関数的にコストが増大するので、代わりに以下のようにStringBuilderで別のオブジェクトをバッファとして使うのが普通です。

StringBuilder buffer = new StringBuilder();
for (int i = 0; i < 10; i++) {
  buffer.append("hello");
}
buffer.toString();

Javaのこのコードは、配列に文字列をappend(追加)してからarray.join("")を呼び出すのと同等です。

Javaではこの手の間違いをしがちなので、Javaコンパイラはこのパターンを検出してStringBuilderで同等のコードに自動的に置き換えるようになりました(参考)。

別のバッファを使わなければいけないというのは大した手間ではないものの、Rubyではそういうことをしなくて済む点がとても気に入っています。

しかしミュータブル文字列のより一般的なメリットは、文字列をその場で改変できるアルゴリズムを使うとメモリのアロケーションやコピーを大きく節約できることです。

🔗 実はRubyではミュータブル・イミュータブルどちらも使える

本記事冒頭で、Rubyの文字列はミュータブルだと述べましたが、これは正確ではありません。実はRubyでは、ミュータブルな文字列もイミュータブルな文字列も使えます。Rubyでは、あらゆるミュータブルなオブジェクトをfrozenにできるので、Rubyにはミュータブルな文字列もイミュータブルな文字列もあると言えます。そしてRubyはそれを利用しています。

Ruby内部を調べる面白い方法のひとつとして、ObjectSpace.dumpメソッドを使う方法があります。

require "json"
require "objspace"

def dump(obj)
  JSON.pretty_generate(JSON.parse(ObjectSpace.dump(obj)))
end

str = "Hello World" * 80
puts dump(str)

上のスクリプトを実行すると、以下のような結果が得られます。

{
  "address": "0x105068e10",
  "type": "STRING",
  "slot_size": 40,
  "bytesize": 880,
  "memsize": 921,
  ...
}

この結果から、文字列コンテンツのサイズが880Bbytesizeフィールド)あることと、Rubyが割り当てているスロットのサイズが40Bslot_sizeフィールド)あるので、文字列コンテンツが外部バッファに保存されていて、合計サイズは921Bmemsizeフィールド)であることがわかります。

では、この文字列をスライスするとどうなるか見てみましょう。

require "json"
require "objspace"

def dump(obj)
  JSON.pretty_generate(JSON.parse(ObjectSpace.dump(obj)))
end

str = "Hello World" * 80
puts "initial str: #{dump(str)}\n"

slice = str[40..-1]

puts "str after:\n#{dump(str)}\n"
puts "slice:\n#{dump(slice)}\n"

結果は以下のようになります。

str after:
{
  "address": "0x105178e18",
  "type": "STRING",
  "slot_size": 40,
  "shared": true,
  "references": [ "0x1051786c0" ],
  "memsize": 40,
  ...
}

slice:
{
  "address": "0x1051786e8",
  "type": "STRING",
  "slot_size": 40,
  "shared": true,
  "references": [ "0x1051786c0" ],
  "memsize": 40,
  ...
}

すると、strsliceの両方にshared: trueが設定されているので、これらは文字列の実際の内容を所有しておらず、別のStringオブジェクトの中を指していることがわかります。

また、strsliceが保持している参照は、どちらも0x1051786c0にある同じオブジェクトを指していることもわかります。

つまり、Rubyの文字列はミュータブルですが、イミュータブルな文字列を持つプログラミング言語で行っているように、一部の操作を「文字列ビュー」で最適化できます。
ただしstrはあくまでミュータブルなので、strを参照する文字列ビューを直接作成することはできません。代わりにバッファの所有権を第3のStringオブジェクト(イミュータブル)に転送します。
しかしstrがfrozenであれば、Rubyはslicestr内部のビューとして直接作成できたはずです。

同様に、先ほどミュータブルな文字列のメリットとデメリットをいくつかリストアップしたときに、ハッシュテーブルのキーにミュータブル文字列を使うと問題が生じることを指摘しました。

おそらく皆さんはご存じないかと思いますが、Rubyはこの問題を回避するために、実はHashの文字列キーを自動的にfrozenにしているのです。

>> str = "test"
=> "test"
>> str.frozen?
=> false
>> hash = { str => 1 }
=> {"test" => 1}
>> hash.keys.first
=> "test"
>> hash.keys.first.frozen?
=> true
>> [str.object_id, hash.keys.first.object_id]
=> [16, 24]

ご覧の通り、Rubyはここでstr文字列を直接Hashキーとして利用できなかったため、文字列をfrozenにしたコピーを最初に作らなければなりませんでした。
ここでも、strがfrozenの場合は文字列を複製する余分な作業をしなくて済むようにできたはずです。

ここには、ミュータブルな文字列を使うときによくあるトレードオフが示されていると思います。文字列のインプレース改変が可能になって効率がずっと高まる一方、改変から保護するためのアロケーションやコピーが余分に行われることになります。

🔗 frozen_string_literalの歴史を振り返る

昔は、余分なコピーでオーバーヘッドが発生するのを避けるために、frozenにした文字列リテラルを(変数ではなく)定数に保存するという最適化がよく使われていたことがあります。

たとえば、17年前にRackに当てたパッチでこのイディオムが使われていました(8b8690b

module Rack
  class MethodOverride
    METHOD_OVERRIDE_PARAM_KEY = "_method".freeze
    HTTP_METHOD_OVERRIDE_HEADER = "HTTP_X_HTTP_METHOD_OVERRIDE".freeze

    def call(env)
      # ...
      method = req.POST[METHOD_OVERRIDE_PARAM_KEY] ||
        env[HTTP_METHOD_OVERRIDE_HEADER]
      # ...
    end
  end
end

このパターンがきっかけとなって、GitHubのHailey Somervilleが文字列リテラルを%fでfrozenにするという新しい構文をRubyに提案したことがあります(#8579)。

req.POST[%f(_method)] || env[%f(HTTP_X_HTTP_METHOD_OVERRIDE)]

この構文は受理されませんでしたが、Yusuke Endoh (mame)が文字列リテラルにfをサフィックスする構文を提案する形で反対しました。

req.POST["_method"f] || env["HTTP_X_HTTP_METHOD_OVERRIDE"f]

こちらは受理されて2.1.0devで実装されました。

しかしコア開発者たちの多くがこの新構文を気に入らなかったため、実装後もさまざまな対案が飛び交いました。
特にAkira Tanaka (akr)による# freeze_string: trueというファイルベースのディレクティブ(#8976)を提案しましたが、これは普及しませんでした。

しかし2.1.0最終リリース直前になって、Charles Nutterが別のフィーチャーリクエストとして、String#freezeのコンパイラ最適化を実装し、新しい構文を導入せずに同じ機能を導入することを提案しました(#8992)。

Rubyの仮想マシン(VM)のしくみや仮想マシン全般に詳しくない方は、Rubyにコンパイラがあると知ったら驚くかもしれません。しかしRubyは間違いなくコンパイラを持っているのです。

Ruby 2.1より前は、"Hello World".freezeというRubyプログラムを実行すると、以下のように2つのインストラクションのシーケンスにコンパイルされます。

>> puts RubyVM::InstructionSequence.compile(%{"Hello World".freeze}).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,19)>
0000 putstring                              "Hello World"             (   1)[Li]
0002 opt_send_without_block                 <calldata!mid:freeze, argc:0, ARGS_SIMPLE>
0004 leave

1つ目のputstringインストラクションは文字列"Hello World"をVMスタックに積みます。
2つ目のopt_send_without_blockはその文字列に対して#freezeメソッドを呼び出します。

def putstring(frozen_string)
  @stack.push(frozen_string.dup)
end

このputstringインストラクションが呼び出されると、frozenのStringオブジェクト(これはRubyコンパイラによって生成されます)への参照を受け取ります。
しかし文字列の#freezeメソッドはミュータブルなオブジェクトに対して呼び出されなければならないというセマンティクスがあるため、このputstringインストラクションは文字列をdupで複製して、文字列のミュータブルなコピーをスタックに積みます。

私見ですが、このputstringというインストラクションの命名は適切ではないと思います。
putobjectなどの他のput*で始まるインストラクションは、オブジェクトをdupせずに直接スタックに積むので、putstringもオブジェクトをdupせずに直接スタックに積むのかと思いきや、実はdupしているからです。これは不統一です。

def putobject(object)
  @stack.push(object)
end

duparrayduphashなどのようにdup*で始まるインストラクションは、putstringと同様にdupしてから処理するので、やはり不統一です。

def duparray(array)
  @stack.push(array.dup)
end

そういうわけで、このインストラクション名がputstringではなくdupstringだったら、ずっと明確になったはずです。

ともあれ、Charlesの提案は、文字列リテラルに対して#freezeメソッドが呼び出されたときに、コンパイラが生成するVMインストラクションセットを以下のように変更するというものでした。

>> puts RubyVM::InstructionSequence.compile(%{"Hello World".freeze}).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,20)>
0000 opt_str_freeze                         "Hello World", <calldata!mid:freeze, argc:0, ARGS_SIMPLE>(   1)[Li]
0003 leave

さらに新しいRubyでは、putstringopt_send_without_blockという2つのインストラクションが、上のようにopt_str_freezeという1個のインストラクションに置き換わります。
この新しいインストラクションを擬似Rubyで表現すると以下のような感じになります。

def opt_str_freeze(frozen_string)
  if RubyVM.string_freeze_was_redefined?
    @stack.push(frozen_string.dup.freeze)
  else
    @stack.push(frozen_string)
  end
end

ご覧のように、このインストラクションがセマンティクスを壊さないためには、String#freezeが再定義されていないことをチェックしておかなければなりません。しかしそんなちっぽけな事前条件を別にすれば、この新しいインストラクションによって処理量は厳密に削減されます。

これが、最終的に2013年12月にリリースされたRuby 2.1.0の機能です。

🔗 さらなる最適化

文字列のアロケーションをさらに削減するために、2014年にGitHubのAman Karmani(tmm1)とHailey Somerville(haileys)が提出したパッチ(#9382)は、opt_aref_withおよびopt_aset_withというさらに最適化の進んだ2つのインストラクションを追加するものでした。

パッチ適用前は、文字列キーを持つハッシュにアクセスすると文字列のアロケーションが発生する可能性がありました。

>> puts RubyVM::InstructionSequence.compile(%{some_hash["str"]}).disasm
...
0003 putstring                              "str"
0005 opt_aref                               <calldata!mid:[], argc:1, ARGS_SIMPLE>[CcCr]
0007 leave

パッチ適用後は、上の2つのインストラクションが1個のopt_aref_withに置き換わります。

>> puts RubyVM::InstructionSequence.compile(%{some_hash["str"]}).disasm
...
0003 opt_aref_with                          "str", <calldata!mid:[], argc:1, ARGS_SIMPLE>
0006 leave

opt_str_freezeの場合と同様に、これらのインストラクションも、このメソッドがハッシュで呼び出されているかどうか、およびHash#[]が再定義されていないかどうかをチェックします。条件が2つとも満たされると、このopt_aref_withインストラクションは、文字列を最初にコピーせずにハッシュを探索可能にします。

def opt_aref_with(frozen_string)
  if RubyVM.hash_aref_was_redefined? || !@stack.last.is_a?(Hash)
    # フォールバック
    @stack.push(frozen_string.dup)
    value = RubyVM.call_method(:[], 1)
    @stack.push(value)
  else
    # 高速なパス
    hash = @stack.pop
    value = hash[frozen_string]
    @stack.push(value)
  end
end

Aman Karmaniによると、GitHubはこのパッチによってアロケーションを3%削減したそうです。比較的小さなパッチにもかかわらず、かなり大きな成果です。

余談ですが、この最適化インストラクションはAaron Pattersonによって最近Ruby trunkから削除されました(#21553)。理由は、パフォーマンスが重要なコードでは既にマジックコメントの利用が普及しているので、今やこの最適化のメリットがほとんどなくなったためです。

🔗 Ruby 3.0とfrozen_string_literal

おそらくこの新機能のおかげで(もしくは別の理由か何かで)、文字列の無駄なdupによってRubyアプリケーションのパフォーマンスを悪化することが2014年あたりから知られるようになり、Rubyコミュニティの一部メンバー(特にRichard Scheenman)がRails(#21057)やRack(#737)などさまざまなgemに改善プルリクを投げたことで、codetriage.comのレイテンシが11.9%削減されるなど、かなりの成果が出るようになりました。

#freezeによるこうしたパフォーマンス改善は無視できないほどの素晴らしさでしたが、にもかかわらず、改善されたコードに対して多くの人が「見苦しい」と感じました。その結果、Rubyの文字列をデフォルトでfrozenにしようという議論が定期的に持ち上がりましたが、ことごとく却下されました。


しかしその後、Akira Matsuda(amatsuda)がこの問題を2015年8月のRubyコア開発者ミーティングで再び取り上げ(議事録)、それを受けてMatzがRuby 3.0で文字列リテラルをfrozenにするという決定を下しました(リンク切れ)。

移行を支援するためのさまざまな機能も決定されました。
第一弾は、Ruby 3.0までにgem側での準備を支援するための# frozen_string_literal: trueというマジックコメントです。

第二弾は、Ruby 3.0と互換性を取れないあらゆるコードが引き続き動くようにするため、Rubyに--enable-frozen-string-literal--disable-frozen-string-literalという2つのコマンドラインオプションが追加されました。

こうして準備が整ったことで、Ruby 3.0が実際にリリースされたときに既存のコードや依存ライブラリの互換性がない場合でも、RUBYOPT="--disable-frozen-string-literal"を設定しておけば引き続き動くようになるという寸法でした。

開発者を支援するための--debug-frozen-string-literalコマンドラインオプションも同様です。

これらの新機能は、2015年12月のRuby 2.3でリリースされました。

--enable-frozen-string-literalを指定したり# frozen_string_literal: trueマジックコメントをコードに追加したりすると、以下のようにコンパイラで生成されるバイトコードが従来のものと異なるようになります。

>> puts RubyVM::InstructionSequence.compile(%{# frozen_string_literal: true\n"Hello World"}).disasm
== disasm: #<ISeq:<compiled>@<compiled>:2 (2,0)-(2,13)>
0000 putobject                              "Hello World"             (   2)[Li]
0002 leave

こうしてコンパイラは従来のputstringインストラクション(dupあり)に代えてputobjectインストラクション(dupなし)を生成するようになります。既に述べたように、このputobjectインストラクションは、コンパイル時に作成されたfrozen文字列をスタックにそのまま積むので、余分なdupが発生しません。

ここで理解していただきたい重要な点は、frozen文字列リテラルは、従来のミュータブルな文字列リテラルよりもRubyの処理量を厳密に削減するということです。

🔗 コミュニティではどう使われたか

Ruby 2.3がリリースされてから、将来のRuby 3.0に備えるべく、RuboCopプロジェクトにも# frozen_string_literal: trueマジックコメントの利用を強制する新しいcopが追加されました(#2542)。

その後数年間で、Rails(#29506)やrake(#209)や2018年のRack(#1250)など多くのプロジェクトがfrozen_string_literalに移行しました(もちろん他にも多くの長期プロジェクトが移行しています)。

機能が実際にどこまで普及しているかを知るのはいつの時代も困難ですが、私見では、あえて移行しないことを選択した少数のプロジェクトを除けば、開発が活発なgemの大半はfrozen_string_literalに移行完了したと言っても過言ではないでしょう。しかしながら、なまじ安定していて開発があまり盛んでないgemはそうではありませんでした。

当時Ruby 3.0がいつリリースされるかが示されていなかったのと、このままでは互換性が失われることが実行時の警告文などで周知されなかったために、自分たちのところで依存関係を更新する必要があるかどうかを把握している人たちすらほとんどいませんでした。

時とともに、例のマジックコメントはRubyistたちがとりあえず唱える呪文としてゆっくりと定着していきました(多くはRuboCopのおかげで)。しかし--enable-frozen-string-literalオプションの方は、私の知る限りでは、自分たちのアプリケーションで試した人は皆無であり、そんなコマンドオプションが存在することを知っている人すらほとんどいませんでした。

🔗 そして計画は白紙に

しかし、2019年12月、Ruby 2.7リリースの直前になって、Matzは文字列リテラルをデフォルトでfrozenにする計画を断念しました(#11473)。

この件について長年考えてきた。文字列リテラルをfrozenにすることについては本当によいと思うのだが、Ruby 1.9のときよりも大規模な互換性の問題が大量に発生する可能性が拭えない。そういうわけで、(Ruby 3で)文字列リテラルをデフォルトでfrozenにする計画を正式に断念することにした。
-- Matz

正直に申し上げると、当時この決定にたいそう驚きました。Python 3の有名な非互換性問題のような事態を避けたいのは十分理解できるのですが、私にはRubyの文字列リテラルをfrozenにすることでそうなるとはとても思えませんでした。何かあったらいつでもRUBYOPT="--disable-frozen-string-literal"で最終的に回避できますし、どうしても必要ならアプリケーションを一切変更せずに運用し続けることも可能なのですから。

仮に当時のPython 3にPython 2のコードを実行する手段が存在していたとしたら、Python 3への移行があれほど問題になることもなかっただろうと私は思っています。

私の驚きがさらに大きかった理由は、Ruby 3.0で変更されることが決まっていたキーワード引数については、Ruby 2.7で非推奨警告を追加していたからです。私に言わせれば、breaking changesの規模としては、キーワード引数の変更の方が文字列リテラルをfrozenにするよりずっと大きかったと思います。

実際、これによって発生する非推奨警告があまりに大量だったため、後にこの非推奨警告をオフにするためだけにRuby 2.7.2がリリースされたほどです(リリースノート)。

さらに言えば、コードの変更量は、新しいキーワード引数のロジックをサポートする方がfrozen文字列リテラルをサポートするよりもよほど複雑でした。キーワード引数の移行ガイドもかなり長くて複雑であることが見て取れるでしょう。一方、frozen文字列リテラルをサポートするには、.dupをぽつりぽつりと戦略的に配置すれば済む話です。

参考までに、私はShopifyのモノリスで、Ruby 3.0のキーワード引数対応の件と--enable-frozen-string-literalの件で、ざっと700個もの依存gemの移行を担当しました。
キーワード引数の対応では、100個近いgemにプルリクを投げまくり、モノリス側のコードも大量に変更しなければなりませんでした(中には修正がめちゃくちゃ難しいものもありました)。
しかしfrozen文字列リテラルでは、私がプルリクを投げたgemはたった12個で、しかも.dup呼び出しを2つ3つ追加すれば済むようなものばかりでした。

しかしいずれにせよ、最初の計画策定からRuby 3.0が実際にリリースされるまで5年間の猶予があったのと、パフォーマンスが重要な多くのコードでマジックコメントが導入済みだったこともあり、Ruby 3.0で文字列リテラルをデフォルトでfrozenにする件が白紙になったことは大した議論にもならず、議論があったことすらほぼ気づかれませんでした。

🔗 新たなstandardrbではどう扱われているか

4年後の2024年1月に、standardrbというlintがfrozen_string_literalマジックコメントを強制しないことが話題になり始めていました(#181)。実際、一部のプロジェクトでfrozen_string_literalマジックコメントを撤去したり、新規プロジェクトではfrozen_string_literalマジックコメントは不要とみなして、あえて使わないという方針を決めるといった動きもちらほらと目にしていました。

実を言うと私も同感です。私もあのマジックコメントは大嫌いです。

私がRubyを使い始めたのは1.8のときでしたが、当時のデフォルトエンコーディングはASCIIだったので、RubyファイルのエンコードがUTF-8であることを示すために、ファイルの冒頭にしょっちゅう以下のマジックコメントを書かなければなりませんでした。

# encoding: utf-8

当時このコメントも嫌で仕方がありませんでした。今も昔もRubyで大好きな点は、ソースコードに邪魔くさい定型文をほぼまったく書かずに済むことです。だからこそ、Ruby 2.0でデフォルトエンコーディングがUTF-8になって、この目障りなマジックコメントと完全におさらばできたときは、心から嬉しかったものです。

同様にfrozen_string_literalマジックコメントも書かずに済ませたいところですが、ひとたび無用なアロケーションやコピーが発生していることを知ってしまったら、そのことに目をつぶるのは相当難しいものです。VMに慣れてきたおかげで、コードにfrozen_string_literalマジックコメントを書かなかったときに暗黙のdup呼び出しが発生する様子をかなりの程度見通せるようになりました。

たとえば以下のようなコードを見かけたら、

env["HTTPS"] == "on" ? "https" : "http"

今の私にはこんなふうに見えてしまいます。

env["HTTPS".dup] == "on".dup ? "https".dup : "http".dup

こういうのを見るたびにむかつきます。もちろん、1つ1つは小さな文字列に過ぎませんし、ここ数年のGCは目覚ましく高速になりましたが、文字列リテラルはあらゆる場所に顔を出しているので、そうしたちっぽけなアロケーションも積もりに積もればやがて死に至ります。

そういうわけで、Rubyコミュニティがこの教訓をゆっくりと忘れつつあることが私には耐え難かったため、この取り組みを復活させることを決意しました。

🔗 "チルド"文字列リテラルというアイデア

私見ですが、frozen文字列リテラルの当初の計画には、非推奨化のための適切な道筋が示されていなかったと思います。Rubyユーザーの多くは、Ruby 3.0で文字列リテラルのデフォルトが変更されるということは何となく見聞きしていたものの、肝心のRuby自身が、更新の必要なコードに非推奨警告を表示しなかったために、移行に必要な準備がほとんどなされていなかったのです。

そこで、Matzに再挑戦してもらうために、リテラル文字列を改変するコードがあったら必ず有用な非推奨警告を表示する方法を何とかして考えつく必要がありました。こうして思いついたのが、チルド文字列という概念です(#20205)。

Ruby 3.4からは、frozen_string_literalマジックコメントが(truefalseにかかわらず)コードに存在しない場合は、putstringインストラクションではなくputchilledstringインストラクションを生成するようになります。

>> puts RubyVM::InstructionSequence.compile(%{puts "Hello World"}).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,18)>
0000 putself                                                          (   1)[Li]
0001 putchilledstring                       "Hello World"
0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
0005 leave

この新しいインストラクションの振る舞いはputstringと同一ですが、アロケーションされた文字列に新しいSTR_CHILLEDフラグを立てる点が異なります。

次に私はrb_check_frozen関数を変更しました。これはfrozenオブジェクトが改変されたらFrozenErrorをraiseする責務を持っています。

チルド文字列が改変されたら、エラーの代わりに非推奨警告を出力します。このときフラグを削除するので、警告が表示されるのは最初の改変時だけで済みます。

>> Warning[:deprecated] = true
=> true
>> "test" << "a" << "b"
(irb):3: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
=> "testab"

移行計画は、これらの非推奨警告をデフォルトで表示するようにし(今後どのバージョンで行うかはまだ定義されていませんが)、さらにその後どこかのバージョンでfrozen文字列リテラルをデフォルトにすることになるでしょう。

🔗 パフォーマンスへの影響を測定する

2014年の議論と同様に、Yusuke Endoh(mame)がこの変更に反対しました。2014年当時は多くのコードの互換性がなくて測定しようがなかったのだから、frozen文字列リテラルのメリットはこれまで適切に測定されたことがないという主張でした。

仮にすべてのコードから# frozen_string_literal: trueを削除したら、yjit-benchでどの程度パフォーマンスが悪化するか?

そこで、マジックコメントが効かないように改造したRubyインタプリタをビルドして、メインのRubyとベンチマークを比較してみました(#20205)。

結果は、frozen文字列リテラルを使うことで、Lobster(オープンソースのRails製ディスカッションボード)が8〜9%高速になりました。また、railsbench(測定用Railsアプリケーション)も4〜6%高速になり、liquid-renderにいたっては11%も高速化しました。

ここで注目したいのは、ベンチマーク対象のコードベースや依存ライブラリ(Rackなど)は、frozen文字列リテラルが登場する前からアロケーションを回避するための手動最適化を多数含んでいたことです。つまり、frozenでないミュータブルな文字列リテラルが回避策なしで使われていたとしたら、この差はさらに広がっていたはずです。

似たような話で、erubi-railsは大量の文字列を扱うにもかかわらず、ベンチマークがたった1〜2%しか改善されなかったのを不思議に思ったことがあります。今思えば、erubyが高速であることの最大の秘密は、内部でopt_str_freezeインストラクションを使うことでミュータブルな文字列リテラルを回避するというものだったので、それなら改善度合いが少なくても無理はありません。

>> puts Erubi::Engine.new("Hello <% name%>!").src
_buf = ::String.new; _buf << 'Hello '.freeze; name; _buf << '!'.freeze;
_buf.to_s

以上すべての理由から、文字列リテラルをfreezeするメリットを明確に測定することは困難です。frozen文字列リテラルをデフォルトにする現時点の理由は、主にRubyユーザーがより美しく自然なコードを書けるようにするためであり、パフォーマンスの強化はメインではありません。

そこからいくつかの議論を経て、ついにMatzが)プロポーザルを受理しました(#20205)(具体的なタイムラインは定かではありませんが)。そして私とÉtienne Barriéが実装した機能がRuby 3.4.0に同梱されたというわけです。

🔗 これで終わりですか?

今の時点では、終わったも同然に思われるかもしれません。非推奨化は既に進行中ですし、あとはスイッチをいつ切り替えるかを決めるばかりです。

しかし過去を振り返ってみれば、これで片付いたとは決めつけられません。Matzが今後決定をひるがえす可能性もないわけではありませんし、Rubyコアメンバーの一部にはfrozen文字列リテラルに積極的に反対している人たちもいます。

個人的には、これに関する議論はもうたくさんです。私の立場によるバイアスもあるかもしれませんが、ここ10年ほど私が触っているコードの圧倒的多数がfrozen文字列リテラルに対応していることを考えれば、Rubyコミュニティでfrozen文字列リテラルが広範囲に採用されていると見てよさそうなので、これをデフォルトにするのは当然の流れだと思えてきます。

しかしRubyコアメンバー全員が見解を同じくしているわけではありません。コアメンバーにはMameのようにクワイン(Quine))やTRICKのような技巧を凝らしたプログラミングとの関わりが深い人たちもいます。ミュータブルな文字列はTRICKでは常套手段なので、文字列リテラルのデフォルトがfrozenに切り替わったら、彼が大切にしている多数の歴史的なプログラムが動かなくなってしまうことも理解しています。

いつものことですが、Rubyの行く先は最終的にMatzの決定次第ということになります。今のところMatzは移行計画を公式に承認していますが、具体的なタイムラインについては明言していませんし、この件に関するRubyコミュニティ全体の総意をMatzが把握しているかどうかも定かではありません。Ruby 4.0は今年リリースされる流れになっていますが、この移行計画が数年間塩漬けになって最終的に白紙に返る可能性も十分ありうるのです。

🔗 別案について

突き詰めれば、私は文字列リテラルをデフォルトでfrozenにすることについてはさほどこだわりはありません。私は、ファイルの冒頭にあの見苦しいマジックコメントを足さなくても、パフォーマンスが落ちたり定数をいちいち明示的にfreezeして回ったりしなくて済むようにしたいだけなのです。

デフォルトを変更する代わりに、コンパイラオプションでディレクトリ全体を一括でfrozenにできるようにするという案も一応考えられます。これなら、文字列リテラルをfrozenにする指定を一箇所で行えるようになるでしょう(gemspecやRailsコンフィグファイルが典型)。

しかし、これではRubyがますます分断されてしまいます。コードスニペットは同じなのに、どこに置くかによって動いたり動かなかったりすることになるからです。これはfrozen_string_literalマジックコメントのときにも既に懸念されていましたが、ディレクトリベースのコンパイラオプションではさらに深刻な問題になるでしょう。そういうわけで、Matzがこの別案に賛成してくれるかどうかはわかりません。

🔗 まとめ

Rubyの文字列リテラルがデフォルトでfrozenになるかどうか、私には予測できません。私としては今から数年以内にfrozenになってくれればと願うばかりですが、期待しているわけではありません。

gem作者の皆さんには、それまでの間--enable-frozen-string-literalで自分たちのgemをチェックすることをぜひおすすめします(#78)。

しかし確かなのは、この変更はパフォーマンスについては「メリットしかない」ということです。Ruby VMの負荷は厳密に減少しますが、パフォーマンスが重要な依存ライブラリでは既に対策済みかもしれませんし、少なくともホットパス(実行頻度の高い場所)についてはミュータブルな文字列リテラルを既に回避しているかもしれません。
つまり、アプリケーションでRUBYOPT="--enable-frozen-string-literal"を指定してもパフォーマンスの大きな違いが目に付く可能性は低いでしょう。ただし、測定の結果パフォーマンスが悪化しているとしたら、間違いなく測定自身が誤っていると言えます。

関連記事

Ruby が JIT コードを実行するメカニズムを読み解く(翻訳)

Rubyの内部文字コードはUTF-8ではない...だと...?!


  1. 原注: 本記事の過去バージョンでは、誤ってPHPを「文字列がイミュータブルな言語」であると記載していました。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。