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

Rubyのシンボルをなくせるか考えてみた(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Rubyのシンボルをなくせるか考えてみた(翻訳)

この記事を読んでいる皆さまの場合はわかりませんが、私は個人的に文字列とシンボルの区別に由来するバグを余裕で10回以上は踏んでいます。このバグは私の書いたコードでも、他のライブラリを使うときにも起きました。私はコードに表示されるシンボルの外観は好きなのですが、シンボルと文字列の特定の区別のされ方が好きではありません。(こういうことを書くと炎上しそうですが)この区別は問題を解決するよりも作り出す方が多いと思います。

そこで私も考えてみました。いっそシンボルをなくしてしまえばどうだろう。過激な主張でしょうか?かといって、何千というRubyライブラリを書き直して、そこに含まれる:symbolを片っ端から削除して回るという戦略に勝ち目があるとは思えません。おそらくシンボルリテラルはfrozenかつイミュータブルな文字列として使うこともできるでしょう。どのような仕組みになっているのでしょうか。

もしも世界がこうだったら

問題の解決法を長いパラグラフでみっちり記述するのはつらいので、私が空想する性質をデモでお見せして、コードそのものに語らせたいと思います。もしもの世界へようこそ。

:foo == :foo  # true
:foo == "foo" # true

これが私の出発点であり、目指すゴールでもあります。文字列かシンボルかという区別はもううんざりです。もちろんこんな簡単な話ではありません。私の空想する動作を完全に説明するにはもっと多くの性質(テストケース)が必要です。

私のユースケースでは多くの場合、ハッシュからの値の取り出しやハッシュへの値の代入を使います。ハッシュで表してみましょう。

{"foo" => 1}[:foo] == 1 # true
{foo: 1}["foo"]    == 1 # true

こんなふうにできたらどんなに楽だったことでしょう。

要するに、以下が欲しいのです。

:foo.hash == "foo".hash # true

Hash(またはSet)で何かを出し入れすると、Rubyは常に入力をObject#hashでいわゆるハッシュ関数として扱います。2つのオブジェクトが等しい場合、両者は同じhashを返すべきです。さもないとRubyがHashからオブジェクトをうまく見つけられなくなります。次の例をご覧ください。

class Something
  def initialize(val)
    @val == val
  end
  attr_reader :val

  def ==(another)
    val == another.val
  end
end

a = Something.new(1)
b = Something.new(1)

hash = {a => "text"}
hash[a] # => "text"
hash[b] # => nil

これはいわゆるValue Objectを定義しています。あるクラスがその1つ以上の属性によって定義され、それを比較に用いています。しかし私たちはhashメソッドをまだ実装していないので、RubyはそれらがHashのキーとしても利用される可能性を認識しません。

a.hash
# => 2172544926875462254

b.hash
# => 2882203462531734027

2つのオブジェクトが返すhashが同じ場合、両者が等しいとは限りません。利用できるハッシュ値の個数には上限があり、衝突はめったなことでは起きません。しかし2つのオブジェクトが等しい場合は同じhashを返すべきです。

class Something
  BIG_VALUE = 0b111111000100000010010010110011101011000100010101001100100110000
  def hash
    [@val].hash ^ BIG_VALUE
  end
end

普通ハッシュ値の計算では、すべての属性が正確に一致する配列との衝突を回避するために、すべての属性の配列のhashと巨大な乱数値とのXORを取ります。
言い換えると、欲しいのは以下です。

Something.new(1).hash != 1.hash
Something.new(1).hash != [1].hash

しかしこれは脱線でした。メリットの話に戻りましょう。

繰り返しますが、私は以下だったらよいのにと思っています。

{"foo" => 1}[:foo] == 1 # true
{foo: 1}["foo"]    == 1 # true

そのためには以下が必要です。

:foo.hash == "foo".hash # true

しかしここが重要です。現時点ではシンボルのハッシュ値算出は文字列のハッシュ値算出の2〜3倍高速であるようです。私はその理由を知りません。シンボルはイミュータブルですが、おそらくシンボルは事前に算出済みのハッシュ値を持っているか、メモ化されたハッシュ値を持っているのではないでしょうか。ハッシュ値が変わらないのがその根拠ですが、私にはまだよくわかっていません。しかしこれが理由であれば、frozenかつイミュータブルな文字列が遅延算出またはメモ化されたハッシュ値も持てばよいのではないかと想像できます。

世の中には、以下の事実に依存しているライブラリやアプリが多数あると信じています。

:foo.object_id == :foo.object_id

明らかにこの動作は変えるべきではありません。しかし私は、もし仮にRubyのシンボルが文字列であり、かつRuby内部にそれらの一意なリストが保持されるのであれば、上で行ったように何の問題もなく動作すると信じています。

結局、常に同じシンボルを得られるという事実は、Ruby実装のどこかで単に以下の対応付けがなされていることを示しています。

{"foo" => Symbol.new("foo")}

なお、かつてのシンボルはガベージコレクションすら行われていませんでしたが、現在は行われています。

{"foo" => "foo".freeze}

仮にRuby内部のどこかで上のようになっているしたら、:fooを求めたときにも同じオブジェクトを得られるでしょう。

:foo.object_id == :foo.object_id # true
:foo.equal?(:foo)                # true

先を続けましょう。問題があるのはこのあたりです。

foo = "foo"
foo.equal?(foo.to_s) # true

RubyのString#to_sは基本的にselfを返します。したがって、仮にシンボルがfrozenな文字列だったとしたら、以下は動かないでしょう(実際には動きますが)。

foo = :foo
bar = foo.to_s
bar << " baz"

動かないであろう理由は、barは新しい文字列ではなく、(現在のシンボルがそうであるように)fooと同じオブジェクトになるはずだからです。

ここにはもうひとつ問題が潜んでいます。次のようにオブジェクトがシンボルかどうかをチェックしているライブラリが多数ある可能性が考えられます。

if var.is_a?(Symbol)
  # 何かする
else
  # 別のことをするか、何もしない
end

これをどうにか解決できないか考えました。:foo"foo"を本当に区別しなければならなくなった場合に、どうやって区別したらよいのでしょうか。

2つの選択肢が考えられます。ひとつは、Symbolを、Stringに変換せずにStringのように動作させること(そういうメソッドをすべて追加するかSymbol = Stringというエイリアスにすることによって)で、もうひとつは、SymbolStringから継承する、すなわちSymbol < Stringとすることです。

もしそうできれば、以下はtrueになるでしょう。

:foo.is_a?(Symbol)

しかしその場合、以下もtrueになるでしょう。

:foo.is_a?(String)

この違いは、Symbol#to_sが再定義され、(同一の文字列ではなく)新しい一意のfrozenでない文字列を返すことで生じるでしょう。

つまり以下のような感じになるでしょう。

class Symbol < String
  def initialize(val)
    super
    freeze
  end

  def to_s
    "#{self}"
  end

  def hash
    @hash ||= super
  end
end

こんなふうに動くかどうか、私は疑わしく思っています。今の段階でこのような変更を導入すれば、おそらく膨大なエッジケースが発生するでしょう。しかしFixnumBignumをなくせるなら、Symbolだってなくせるのではないでしょうか?

訳注: FixnumBignumはRuby 2.4で既にIntegerのエイリアスに移行しました。

皆さんもSymbolをなくしたいですか?皆さんはどうお考えですか?コードにSymbolクラスがないとだめですか?それとも皆さんはシンボルの記法が好きなだけでしょうか?

締めくくりに、Matzのコメントを引用します。

(Rubyの)シンボルはLispのシンボルを取り入れたもので、Lispのシンボルは文字列とは根本的に異なっていました。(Lispの)シンボルは文字列表現としてはイケてません(し速くもありません)が、Rubyはシンボルに関して独自路線を取ったため、シンボルと文字列の違いは(Rubyの)ユーザーからはそれほど認識されてこなかったのです。

(シンボルをなくすという)アイデアはだめだとお考えの方には、Matzもシンボルを廃止しようとした(が、できなかった)ことを一応申し上げておきます。

私は、オブジェクトがSymbolかどうかのチェックに依存するライブラリが多すぎると思っています。

ついでに申し上げると、Smalltalkのシンボルは文字列を継承しています。

追伸

本記事をお楽しみいただけた方や、大規模なRailsアプリを手がけている方には、Domain-Driven Railsも楽しくお読みいただけるかと思います。ぜひご覧ください。

関連記事

あまり知られてないRuby/Railsの便利メソッド5つ(翻訳)

Ruby2.0でnil.object_idの値が4から8に変わった理由

Rubyのクラスメソッドをclass << selfで定義している理由(翻訳)


CONTACT

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