[Ruby] Ruby 3.0 の特大の非互換について

タイトルは釣りです。すみません。Ruby 3.0 はかなり先の将来の話なので、最終的にどうなるかはわかりません。でも Ruby 3.0 に重大な変更が予定されているのは事実なので、一緒に考えて欲しいと思います。

immutable string literal

Ruby 3.0 では文字列リテラルをデフォルトで immutable (破壊的変更不可) にする、という方針が『決定』しました。(Feature #11473: Immutable String literal in Ruby 3)

つまり、次のようなプログラムが動かなくなります。(当チケットから少し改変して引用)

sql = "SELECT #{sec_id}, pt.path, st.doc_count "
sql << "FROM #{stats_tablename} AS st "  #### ←ここで例外: can't modify frozen String (RuntimeError)
sql << "JOIN #{path_tablename} AS pt ON (st.path_id = pt.id)"

この『決定』の背景を、自分が理解している範囲で書き、そのあと、思うところを書いてみます。*1

背景 1: Ruby の "".freeze 最適化

今の Ruby は、文字列リテラルに .freeze がついているとき、ちょっとした最適化を行います。"".freeze は毎回文字列オブジェクトを生成せず、常に同じオブジェクトを返す、というものです。

2.times { p "".object_id }
  #=> 69973751800160, 69973752856520 (異なるオブジェクト ID)

2.times { p "".freeze.object_id }
  #=> 69973751802120, 69973751802120 (同じオブジェクト ID)

顕著な例。2 行目は 1 行目の 2 倍くらい速いです。

10000000.times { "" }        # 約 1.0 秒
10000000.times { "".freeze } # 約 0.5 秒

immutable なオブジェクトは改変されないため、このような最適化を行っても(ほぼ)問題ありません。そしてこの最適化は、プログラム内のホットスポットを高速化するためには確かに有用です。

背景 2: Rails 界隈の『社会問題』

しかし Rails 界隈を中心として、ボトルネックかどうかを考えること無く、文字列リテラルに片っ端から .freeze をつける、ということが行われているそうです。

以下、ひどい例。

明らかにホットスポットではなさそうなところ(ていうかクラス定義の文脈)で .freeze が行われています。*2

そしてさらに悪いことに、「すべての文字列リテラルにアホのように .freeze をつけるプルリクエスト」とかが送られてきて、Rails の開発者を悩ませているそうです。immutable string literal 推進者たちはこれを『社会問題』と呼んでいます。

今回の『決定』と移行パス

上記の『社会問題』を解決するため、「Ruby 3.0 ではいちいち .freeze をつけなくてもデフォルトで .freeze をつけたことにしてしまおう」という『決定』がなされました。

当然、既存のプログラムがいろいろ動かなくなる可能性があります。どうすればいいかというと、

sql = "SELECT #{sec_id}, pt.path, st.doc_count ".dup
sql << "FROM #{stats_tablename} AS st "
sql << "JOIN #{path_tablename} AS pt ON (st.path_id = pt.id)"

というように、.dup をつけて、書き換え可能な文字列オブジェクトを明示的に生成する必要がでてきます。なお、Ruby の標準添付ライブラリでこの対応作業をやってみたところ、約 700 行に 1 箇所程度修正が必要だったということです。


いきなりこのように変えると互換性問題がひどすぎるということで、移行パスとして、Ruby 2.3 に frozen_string_literal というマジックコメントが導入される予定です。すでに trunk には導入されています。(Feature #8976: file-scope freeze_string directive

これは、ファイル冒頭に

# -*- frozen_string_literal: true -*-

...

sql = "SELECT #{sec_id}, pt.path, st.doc_count "
sql << "FROM #{stats_tablename} AS st "  #### ←ここで例外: can't modify frozen String (RuntimeError)
sql << "JOIN #{path_tablename} AS pt ON (st.path_id = pt.id)"

と書くことで、Ruby 3.0 の環境を先行して試せるというものです。これで既存コードの修正箇所を探せるようにし、Ruby 3.0 までにみんなに対応してもらおう、ということです。

これで問題が報告されなければ、Ruby 3.0 では immuable がデフォルトになり、逆に破壊的変更を可能 (mutable) にしたい人が mutable_string_literal みたいなマジックコメントを入れることになる、という計画です。

思うところ

以下、自分の意見です。考えが整理しきれてはいませんが、端的に言えば大反対です。最初に聞いたとき、耳を疑いました。Ruby にはこれまでも不思議な決定がなかったわけではないですが、今回は突出して過去最悪な判断だと直感が告げています。

先に書いておくと、理性的に一番問題だと思うのは「問題 2」、感情的には「問題 1」と「問題 6」です。

問題 1: 動機が納得しがたい

今回の『決定』の動機は、「Rails の高速化のため」ではなく、「Rails を高速化するという名目でコードを汚す人たちを止めるため」とされています。そんな理由で言語仕様を歪めて利便性と互換性を損ねるのは、正直理解しがたいです。

定量的に効果を確認しないで片っ端から .freeze をつけてまわる行為は、まさに「早すぎる最適化」です。そういう勤勉な愚行を行う人は、.freeze が解決したら他の愚行を探すだけです。この『社会問題』は、言語仕様ではなく教育によって解決されるべきです。

プログラミング言語というのはユーザインターフェイスなので、「わかってない」プログラマでも違和感なく使えるにこしたことはありません。しかし、「早すぎる最適化は悪」みたいな最低限のプログラミングリテラシがない人にあわせて、「わかっている」プログラマが妥協を強いられるのは、程度問題として納得しがたいものがあります。

こんなことで言語仕様を変え始めるとキリがないと思います。浮動小数点数を知らないクレーマーが数値誤差を見てバグだと主張し、PHP 開発者が折れて round の仕様の方をアドホックに歪めてしまった話を思い出します。*3

問題 2: 手順が不適切

今回の『決定』は Rails の高速化のためではないと言いましたが、やはり最終的には Rails の高速化のための機能として捉えるべきだと思います。しかし、immutable string literal によってどの程度の高速化が見込めるのか、信頼できる定量的なデータはありません。

「背景 1」の節で書いたもっとも極端なマイクロベンチマークでも 2 倍なので、すごくよくても 10% 程度ではないでしょうか。*4

このような状況でいきなり immutable string literal を既定路線にするではなく、まずはマジックコメントの導入で実験してもらい、まずは高速化の効果を測るべきです。

「結局導入するなら同じじゃん?」と思うかもですが、効果のレポートがなければ、デフォルトで「やらない」を選ぶというところは大きな違いです。この手の実験って、結局だれもやらないことが多いので。実は効果がないのに、既存コードをぶち壊して多くの人に修正対応を迫り、さらに今後の利便性も下げるのは、それこそ社会的損失ですよね。

追記:matz は「immutable string literal のアイデアを検証するためにマジコメを入れる」と言っているので、「デフォルトではやらない」と解釈できるのでは、という指摘がありました(akr さんのツイート)。しかし自分としては、「マジコメを見てうまく行くかどうかを見る(=文句が来ないか見る)」は全然満足できるものではありません。マジコメを入れても特に誰も使わない → (必然的に)文句が出ない → 「文句がないので問題ないようだ、GO」という流れが見えるので。「まずは実験のためにマジコメを入れる。無視できないほどの効果が確認できるなら考える(反応がなければやらない)」という手順をとるべきだと思います。

また、問題 4 にも関連しますが、リテラルのデフォルト挙動を変えなくても、マジックコメントより適切なインターフェイスを提供することで「Rails では事実上デフォルトで immutable」という住み分けをできるのでは、とも思っています。

問題 3: 修正対応コストが過小評価されている気がする

約 700 行に 1 箇所程度の修正を多いと見るか少ないと見るかは、人とコードと高速化の効果次第だと思います(個人的には定量化できるほどあるんじゃ多すぎると思う)が、そもそもこの数値は注意して受け取る必要があります。

というのも、「文字列リテラルがある場所」と「破壊的変更しようとして例外が起きる場所」は異なるからです。どこから渡されたかわからない文字列オブジェクトに破壊的変更を試みて例外が起きた場合、文字列オブジェクトの出自を探る必要があります。これは、やったことがある人ならわかると思いますが、しばしば辛い作業になります。

s = ""
s << "foo" # 例外

とかは一目瞭然ですが、

def add_foo(s)
  s << "foo" # 例外
end

とかだと、スタックトレースデバッグプリントを駆使したコードリーディングの旅へ出発です。

そして、文字列の生成元を見つけたら終わりではないことにも注意すべきです。生成元から例外箇所までのパスの間で、どこに dup をつけるべきかは、上位の設計に基づきます。そのへんのことがわかってるアクティブなメンテナでなければ、結構難しい判断になると思います。

もうひとつ気になるのは、"...".b と書くだけで修正不要ということになっていることです(メソッドが返す文字列は原則として書き換え可能なので)。【文が途中切れな感じだったので、以下追記】.b は文字列リテラルにくっつけて使うことが想定されたメソッドなので、こっちも将来的には freeze したくなるのでは(ひいては他の文字列メソッドも徐々に freeze したくなるのでは)という予感がしていて、そうすると最終的な作業量はもっと増えます。ただしこれは実際に提案・議論されているわけではなく、個人的な恐れです。

問題 4: 別のインターフェイスがほとんど検討されていない気がする

(この辺からあんまり考えがまとまってないです。)

どっかで議論されてるのかもですが、マジックコメントというインターフェイスは本当に適切なんですかね。移行パスとしても、ひょっとしたら将来的にマジコメのまま落ち着く機能としても、微妙だと思っています。

まず、ファイル単位にすることに意義がある気がしません。マジコメ付きのファイル a.rb で作った文字列を、マジコメなしのファイル b.rb で使ったら、結局例外になるので。

プロセス単位で挙動を変えるために、コマンドライン引数として --enable-frozen-string-literal を渡す、という手段も用意されています。移行パスとしてはそれだけでいい気も。

プロセス単位の挙動をプログラム的に決定する方法も提供すべきだと思うんですよね。デフォルトは mutable のままにして、require にオプションつけて

require "app.rb", immutable: true

とロードしたら app.rb の中の文字列は immutable になるとか、

RubyVM::ImmutableStringLiteral = true

としたらそれ以後 eval/require/load される文字列は immutable になるとかの方が、Rails みたいなフレームワークと相性がいい気がするのですが。

どうも煮詰められていないまま『決定』してしまった印象があります。

【追記】RubyVM::InstructionSequence.compile_option= {frozen_string_literal: true} があるそうです(@_ko1 さんのツイート)。こっち使うといいんですかね。例に出しておいてなんですが、RubyVM は安心して依存できなさそうな気配満載の API なので、もうちっと他の名前の方がいいとは思います。

問題 5: Rails ユーザ以外は全然嬉しくない

この問題はあまり同意されなさそうだけど一応書きます。

そもそも Ruby って書き捨てプログラム用言語だと思うのですが、そう考えているような人には、この『決定』は煩わしいだけで、何の恩恵もありません。ruby -e から始まるワンライナーで mutable_string_literal マジコメを入れるとかありえない。。。

もちろん程度問題なので、「これによって Rails が 10 倍速くなることが確認されています!」とか言うのであれば妥協の余地もあると思うのですが、『社会問題』のためというのは、あまりにも納得しがたいものがあります。

問題 6: RubyRuby でなくなる

Ruby とは何か」を決めるのは Matz ですが、敢えて個人的な思いも。

Ruby はあまりこれといった特徴のない言語ですが、他の言語から大きな差異として挙げられるのは、「やばいくらい動的」ということだと思います。

open class(組み込みクラスを含めた既存クラスのメソッドの再定義)ができ、しかもそれがわりと日常的に行われている言語は、なかなかないんじゃないでしょうか。それこそ、メソッド再定義を禁止すれば、いろんな最適化が可能になってかなりの高速化が期待できそうです。言語の「安全性」も上がります。

それでも open class を持たせているのは、「わかってない」ユーザへの配慮より、「わかっている」ユーザの足を引っ張らないことを Ruby は重視していたからだと思います。一言で言えば「利便性至上主義」。

そんな中で、「わかってない」ユーザが引き起こしている『社会問題』のために、他の言語でも書き換えられることが珍しくない文字列オブジェクトをデフォルトで書き換え不能にするというのは、Rubyアイデンティティーを大きく損なうものです。

一言で言えば、Ruby が輝きを失っていくようで、とても悲しい。

想定反論

「そもそも破壊的変更はよくない。immutable は安全でいいものなので慣れるべきだ」

そう思う人は破壊的変更をしなければいいのではないでしょうか。自分は immutable のよさは、Ruby コミッタの中でも理解してる方なんじゃないかなあと思いますし(String#force_encoding が導入されるときに、破壊的インターフェイスなのに明確に反対したのって自分だけではなかったっけ)、実際、破壊的変更を乱用しているわけではないつもりです。

「文字列」から immutable を言うのは順番がおかしいです。open class という、性能と安全の両面で害をなすやつがいるので、そっちからどうにかすべきなんじゃないでしょうか。「それを変えたら Ruby じゃない」というなら、なぜ文字列はいいのか、自分にはわかりません。

また、破壊的変更で簡潔に書けるシチュエーションは実際に数多くあるし、上述の通り Ruby は「わかっている」ユーザの足を引っ張るべきでないと思ってます。

【一応補足】リテラルの freeze 化自体は String から始まったわけではなく、すでに false 、Fixnum 、Bignum という先例があります(akr さんのツイート)。ただ、これらは高速化を目的としたものではないことと、そもそもコンテナ的な意味を持たないものなので、今回の件とは別の話だと思っています。少なくとも自分は。

【書いたと思ったのに消えてたこと補足】あと、この『決定』は文字列リテラルを immutable にするというもので、文字列全体を immutable にするものではありません。なので .dup するだけで mutable になります。いわゆる参照透明性みたいな安全性を目指すものではないです。全部 mutable にするのは、コミッタも含めてほぼすべての Ruby ユーザが反対すると思う。

「超絶技巧プログラミングなんかのために反対するのよくない」

自分はジョークプログラムをいっぱい書いてきたので、そのために反対していると思われることがありますが、そっちは無関係のつもりです。深層意識でも無関係かと言われると否定はできませんが。

超絶技巧プログラミングは言語の重箱の隅を積極的につつくので、将来的に動かなくなることは覚悟してます。しかし今回の変更は「重箱の隅」ではなく「ど真ん中」を変えてくるものです。なので反対。

というか、.dup をつけるだけで解決するので、超絶技巧プログラミングに大きな支障は無いんじゃないかなあと思ってます。なお "foo"*1 の方が格好いい気はする。困るのはコードゴルフでしょうね。といっても、コードゴルファーは 1.9 で ?a が "a" になったあたりですでに Ruby を見限ってると思います。

別の案

「'foo' は immutable 、"foo" は mutable にすればいいんじゃん!」というのは、自分も含めて何人もが思いついている道ですが、改行を immutable で作れない('\n' は改行にならず \ と n の 2 文字の文字列になる)という問題が致命的です。

新たな文字列リテラル記号を導入して、「@'foo' は immutable 、'foo' は mutable」みたいなのはいいような気がしますが、文法の非互換が嫌われてあまり検討されていない印象。個人的には、今の『決定』よりよっぽどいいと思いますが。

まとめ

Ruby 3.0 で予定されている immutable string literal の概要と、それに対する問題意識をいろいろ書きました。まだ自分の中で整理しきれていないですが、書いているうちに一番強く思ったのは、「効果が確認されない限り、デフォルトではやらない」と宣言してほしいなあということです。もちろん互換性をなくさない別案の方向に進むともっといいと思います。お願い Matz 。

*1:なお自分は Ruby のコミッタですが、最近開発者会議などに顔を出していないので、オンラインの議論しか見ておらず、この『決定』にも全く関与していません。

*2:ここに挙げた例は grep で見つけただけのものなので、ひょっとしたら高速化以外の目的でこうなっている可能性はあります。定数をうっかり書き換えられたくないから、とか。ただ、Ruby では定数を再定義できるので、所属クラスも freeze しないとあんまり意味ないですが。

*3:PHP の話は実は誤解で、「PHPは無知なクレーマーへの対策としてバグ修正をしたわけではありません」という続報もあります(CPU 内部の 80bit 表現が絡む問題に対する対策だったけど、その対策方法がイマイチだった、と解釈することもできるみたい)。しかし今回の Ruby の『決定』はこの話を地で行くものだと思います。

*4:オブジェクトが増えすぎて GC がテンパる効果の方が問題という説なので、この評価がフェアでない可能性はあります。