概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Could we drop Symbols from Ruby?
- 原文公開日: 2017/10/02
- 著者: Robert Pankowecki
- サイト: Arkency
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
というエイリアスにすることによって)で、もうひとつは、Symbol
をString
から継承する、すなわち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
こんなふうに動くかどうか、私は疑わしく思っています。今の段階でこのような変更を導入すれば、おそらく膨大なエッジケースが発生するでしょう。しかしFixnum
やBignum
をなくせるなら、Symbol
だってなくせるのではないでしょうか?
訳注:
Fixnum
やBignum
はRuby 2.4で既にInteger
のエイリアスに移行しました。
皆さんもSymbol
をなくしたいですか?皆さんはどうお考えですか?コードにSymbol
クラスがないとだめですか?それとも皆さんはシンボルの記法が好きなだけでしょうか?
締めくくりに、Matzのコメントを引用します。
(Rubyの)シンボルはLispのシンボルを取り入れたもので、Lispのシンボルは文字列とは根本的に異なっていました。(Lispの)シンボルは文字列表現としてはイケてません(し速くもありません)が、Rubyはシンボルに関して独自路線を取ったため、シンボルと文字列の違いは(Rubyの)ユーザーからはそれほど認識されてこなかったのです。
(シンボルをなくすという)アイデアはだめだとお考えの方には、Matzもシンボルを廃止しようとした(が、できなかった)ことを一応申し上げておきます。
We tried & failed.
Link: Could we drop Symbols from Ruby? | Arkency Blog https://t.co/um0xnWSpcQ— Yukihiro Matsumoto (@yukihiro_matz) October 5, 2017
私は、オブジェクトがSymbol
かどうかのチェックに依存するライブラリが多すぎると思っています。
ついでに申し上げると、Smalltalkのシンボルは文字列を継承しています。
Hey, Just a comment on the article: In Smalltalk, Symbols actually do inherit from string: pic.twitter.com/YtQO91N0p7
— Tobias (@krono) October 6, 2017
追伸
本記事をお楽しみいただけた方や、大規模なRailsアプリを手がけている方には、Domain-Driven Railsも楽しくお読みいただけるかと思います。ぜひご覧ください。