Rubyにシンボルがある理由(翻訳)
Rubyを使い始めた初日からシンボルを使う人は大勢います。何しろRailsガイドの「Railsをはじめよう」でも、最初に生成するマイグレーションで早々にシンボルが登場しているほどです。シンボルは一見文字列のように見え(:hi
と"hi"
)、しかも相互に変換可能です。両者の違いを素朴に説明すると、文字列は何らかのデータ(定数や変数の値など)を表すのに使い、シンボルはコードの一部として出現する(メソッド呼び出しの引数など)のが通例ということになります。しかし文字列とシンボルが両方ある理由は何でしょうか?それを解明してみましょう!
Rubyでは文字列とシンボルがどう使われているか
とあるRailsアプリケーションで、データモデルにユーザーを追加したくなったとしましょう。以下はそのマイグレーションです。
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email
t.text :password
t.timestamps
end
end
end
テーブルスキーマができたら、対応するモデルを以下のように作成する必要があります。
class Article < ApplicationRecord
validates :email, :password, presence: true
encrypts :password
end
後でコントローラを作成してspecを書くことになりますが(普通書きますよね?🙂)、コーディングはここでいったんお休みしましょう。さて、ここまでに:email
や:password
というシンボルが何回出現したでしょうか?:email
は2回、:password
は3回ですね。実際のアプリケーションではもっとたくさんのシンボルが出現します。
しかしここで、email
カラムに保存される値について考えてみましょう。ユーザーごとに一意のメールアドレスを持ちますが、これらの値はコードベースには一度も出現しません。これらがメモリ上で初期化されるのは、ユーザーから送信されたときか、データベースから読み込まれたときだけです。
RubyインタプリタとVMのどちらも、シンボルと文字列の用途が異なっているという事実をある程度活用しているようです。理解のために、私たちのコードがどのように実行されているかを解明してみましょう。
コードがどのようにRubyで実行されるか
Rubyのプログラムを実行するときは、最初にバイトコードにコンパイルされる必要があります。仮想マシン(通常はVMと略されます)は、具体的なOS上でバイトコードをどう実行するかを理解しています。
原注
コンパイルのステップはRuby 1.9で導入されました。
まず、インタプリタはプログラムをパース(parse: 解析)して抽象構文木(Abstract Syntax Tree: AST)を取得します。本記事ではparserという名前のgemを用いてArticle
クラスのASTとその使われ方を調べることにします。
require 'parser/current'
Parser::CurrentRuby.parse <<~RUBY
class Article < ApplicationRecord
validates :email, :password, presence: true
encrypts :password
end
Article.find(id).password
RUBY
上のコードから以下のASTが得られます。
s(:begin,
s(:class,
s(:const, nil, :Article),
s(:const, nil, :ApplicationRecord),
s(:begin,
s(:send, nil, :validates,
s(:sym, :email),
s(:sym, :password),
s(:hash,
s(:pair,
s(:sym, :presence),
s(:true)))),
s(:send, nil, :encrypts,
s(:sym, :password)))),
s(:send,
s(:send,
s(:const, nil, :Article), :find,
s(:send, nil, :id)), :password))
ここで:password
というトークンが異なるコンテキストで用いられていることにご注意ください。2つのメソッド(validates
とencrypts
)の引数として使われている場合と、メソッド名(.password
)として使われている場合です。
原注
Wikipediaによると、「字句トークン(または単にトークン)とは、割り当てることで意味が特定される文字列のことである。トークンは、トークン名とオプションのトークン値からなるペアによって構造化される。」だそうです。
ASTが構築されると、そのASTが意味をなしているかどうかが検証され、バイトコードに変換されます。バイトコードとは、任意のハードウェア上のVMによって実行される言語です。
バイトコード表現は以下のRubyソースコードで確認できます。
code = <<~RUBY
class Article < ApplicationRecord
validates :email, :password, presence: true
encrypts :password
end
Article.find(id).password
RUBY
RubyVM::InstructionSequence.disasm(RubyVM::InstructionSequence.compile(code))
以下は、バイトコードそのものです。
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(6,25)> (catch: FALSE)
0000 putspecialobject 3 ( 1)[Li]
0002 opt_getinlinecache 11, <is:0>
0005 putobject true
0007 getconstant :ApplicationRecord
0009 opt_setinlinecache <is:0>
0011 defineclass :Article, <class:Article>, 16
0015 pop
0016 opt_getinlinecache 25, <is:1> ( 6)[Li]
0019 putobject true
0021 getconstant :Article
0023 opt_setinlinecache <is:1>
0025 putself
0026 opt_send_without_block <calldata!mid:id, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0028 opt_send_without_block <calldata!mid:find, argc:1, ARGS_SIMPLE>
0030 opt_send_without_block <calldata!mid:password, argc:0, ARGS_SIMPLE>
0032 leave
== disasm: #<ISeq:<class:Article>@<compiled>:1 (1,0)-(4,3)> (catch: FALSE)
0000 putself ( 2)[LiCl]
0001 putobject :email
0003 putobject :password
0005 putobject true
0007 opt_send_without_block <calldata!mid:validates, argc:3, kw:[presence], FCALL|KWARG>
0009 pop
0010 putself ( 3)[Li]
0011 putobject :password
0013 opt_send_without_block <calldata!mid:encrypts, argc:1, FCALL|ARGS_SIMPLE>
0015 leave ( 4)[En]
バイトコードには、== disasm
で始まるセクションが2つあります。それぞれのセクションは、1個のレキシカルスコープ(lexical scope)を表します(1つはトップレベルのコードのレキシカルスコープ、もう1つはクラス内のコードのレキシカルスコープです)。1つの行は1つのバイトコードインストラクションを表します。たとえばputobject
は引数をスタックに追加し、opt_send_without_block
は別のオブジェクトへのメッセージ送信を扱い、putself
はself
の値を変更するといった具合です。
原注
技術的には、バイトコード(より正確には「VMが実行するコード」)インストラクションは2バイト以上になることもありますが、これは一般的な形式の1つです。
ASTそのものをプログラムとして実行するのはありでしょうか?それも可能ですが、パフォーマンスは落ちるでしょう。インタプリタがメモリ上に巨大なオブジェクトグラフを保持しなければならなくなり、何かを実行するたびにその中を移動しなければならなくなります。これと対照的に、バイトコードを構成するシンプルなインストラクションセットはとても小さく、プロセッサのキャッシュに収まるほどです。
Ruby VMはシンボルをどのように保存するか
トークンの中には、利用頻度が高く決して変化しないものもあることがわかっています。そういうものをメモリ上にすべて置く必要をなくす方法はないものでしょうか?Rubyはまさにそれをやっているのです。各トークンはハッシュテーブルに保存され、IDで表現されます。これによって保存に必要なメモリ量が削減されます。
原注
ハッシュテーブルは、プログラミング言語を構成するうえで最も重要なデータ構造です。
Rubyでシンボルが使われると、ハッシュテーブルの中にそのシンボルがあるかどうかを探索し(ハッシュテーブル読み込みの計算量はO(1)
です)、必要であればハッシュテーブルにシンボルを追加します。benchmark/memory gemを使って、シンボルのメモリ使用量が少ないことを確かめてみましょう。
require "benchmark/memory"
Benchmark.memory do |x|
x.report("strings") do
100_000.times { "string" }
end
x.report("symbols") do
100_000.times { :symbol }
end
x.compare!
end
ここでは、同じ文字列と同じシンボルをそれぞれ100,000回初期化してアロケーション数をカウントします。予想どおり:symbol
は解釈中にアロケーションされたので、シンボルのアロケーションはゼロになっていることがわかります。
Calculating -------------------------------------
strings 4.000M memsize ( 0.000 retained)
100.000k objects ( 0.000 retained)
1.000 strings ( 0.000 retained)
symbols 0.000 memsize ( 0.000 retained)
0.000 objects ( 0.000 retained)
0.000 strings ( 0.000 retained)
Comparison:
symbols: 0 allocated
strings: 4000000 allocated
文字列がイテレーションのたびに新たに生成されますが、シンボルのアロケーションは1回しか行われないことは、以下のように簡単に確かめられます。それぞれのobject_id
を見比べてみてください。
5.times.map { "string".object_id }
# => [13700, 13720, 13740, 13760, 13780]
5.times.map { :symbol.object_id }
# => [921948, 921948, 921948, 921948, 921948]
残念ながら、Rubyコード内のシンボルテーブルにはアクセスできません(それ用のAPIは公開されていません)。しかしシンボルのリスト表現なら以下のように見ることが可能です(リストがかなり大きいので10回にとどめています)。
Symbol.all_symbols.sample(10)
# =>
# [:binding_irb,
# :@errors,
# :backtrace_locations,
# :on_string_embexpr,
# :details_for_unwind,
# :build_details_for_unwind,
# :_enumerable_filter_map,
# :on_const_path_ref,
# :confirm_multiline_termination,
# :conflicting]
実行中もこのテーブルにシンボルを追加できます。
Symbol.all_symbols.last # => :las
:my_new_symbol
Symbol.all_symbols.last # => :my_new_symbol
私がSymbol.all_symbols.include?(:my_new_symbol)
のように書かなかった理由は、このような呼び出しは常にtrue
を返すからです。この:my_new_symbol
というシンボルは、.include?
呼び出しより前に初期化されてテーブルに追加されます。関数型プログラミングの強者なら、この振る舞いが純粋さのかけらもないように思えてモヤモヤするでしょう🙂
原注
詳しくはJeremy Evans著『Polished Ruby Programming』の第1章"Understanding how symbols differ from strings"をお読みください。
シンボルに初めてアクセスした時点でシンボルリストが空になっていない理由を考えてみましょう。まず、Rubyの標準ライブラリには、既に読み込み済みのシンボルもいくつか含まれています。次に、シンボルリストを詳しく観察してみると、:sort
のような懐かしいメソッドを見かけることがあります(:sort
はenumerableをソートするメソッドです)。つまり、「定数名」「変数名」「メソッド名」「クラス名」を表すトークンはどれも同じテーブル内に保存されていることがわかります。
Symbol.all_symbols.last
を初めて呼び出したときは:las
を返しました。シンボルリストの末尾にあるシンボルをいくつか調べてみると、:la
と:las
があることに気づきます。これは一体何でしょうか?どうやらハッシュテーブルは以下のように実装されているようです。
Rubyは文字(char)をノードとしてツリーを構築します。このとき、最初の2つのノードから構築を開始し(そうしないとツリーの第1階層に27個ものノードができてしまいます)、シンボルがツリーのリーフ(leaf: 葉)になります。
原注
ガベージコレクションは不要なデータをメモリから削除するので、これについて気にする必要はありません。大型コンピュータの時代は、ツリーが大きくなると手動で明示的に削除していたものです。まあC言語などでは現在でも手動で削除している人がいたりしますが。
ガベージコレクタ(GCと略されることもよくあります)は、メモリ上で使われていない文字列を回収できますが、シンボルも回収されるのでしょうか?Ruby 2.2.0では2種類のシンボルが導入されました。1つは「メソッド」「インスタンス変数」「定数」を動的に作成するときに使われる「イモータル(immortal: 不死身)」なシンボルで、これは決して回収されません。それ以外はすべて、GCの対象になる「モータル(mortal: いつかは死ぬ)」なシンボルです。
文字列のインターン化
文字列をハッシュテーブルに保存して整数のIDで参照するアプローチは「文字列のインターン化(string interning)」と呼ばれます。メモリ上にある2つの異なる文字列オブジェクトを比較する必要が生じた場合、VMが1バイトずつ比較しなければならず、パフォーマンスが落ちてしまいます。代わりに文字列のインターン化では、一部の文字列(またはすべての文字列)をハッシュテーブルに保存して文字列IDを比較します。もちろん、テーブル上のすべての値は重複しないことが保証されています。2つの整数を比較するだけで文字列を比較できるので、きわめてシンプルになります。
原注
本セクションについてはRobert Nystrom『Crafting Interpreters』から多大なヒントをいただきました。凄い本なのでとにかく一読してみてください。
文字列インターン化の利用率は言語によって異なります。たとえばLuaはすべての文字列をインターン化しますし、Javaは定数をインターン化します。Rubyには、ご存知のとおりシンボルがあります。ところでRubyは文字列をフリーズしてイミュータブルにできますが、文字列をインターン化してGCで回収可能にすることはできません。
文字列をインターン化する特別なバイトコード演算子というものはあるのでしょうか?いいえ、他のもっと高度な演算の一部として発生する可能性のある、さまざまな最適化の1つに過ぎません。たとえば# 0001 putobject :email
は、シンボルテーブルに:email
が存在しない場合は追加する可能性があります。
Rubyのソースコードそのものを紐解いて仕組みを確認してみましょう。シンボルのアロケーションはsymbol.cのrb_id_attrset
関数で行われます。この関数は、コンパイル時と解釈時のどちらでも呼び出せます。
ID
rb_id_attrset(ID id)
{
VALUE str, sym;
int scope;
if (!is_notop_id(id)) {
switch (id) {
case tAREF: case tASET:
return tASET; /* only exception */
}
rb_name_error(id, "cannot make operator ID :%"PRIsVALUE" attrset",
rb_id2str(id));
}
else {
scope = id_type(id);
switch (scope) {
case ID_LOCAL: case ID_INSTANCE: case ID_GLOBAL:
case ID_CONST: case ID_CLASS: case ID_JUNK:
break;
case ID_ATTRSET:
return id;
default:
{
if ((str = lookup_id_str(id)) != 0) {
rb_name_error(id, "cannot make unknown type ID %d:%"PRIsVALUE" attrset",
scope, str);
}
else {
rb_name_error_str(Qnil, "cannot make unknown type anonymous ID %d:%"PRIxVALUE" attrset",
scope, (VALUE)id);
}
}
}
}
/* make new symbol and ID */
if (!(str = lookup_id_str(id))) {
static const char id_types[][8] = {
"local",
"instance",
"invalid",
"global",
"attrset",
"const",
"class",
"junk",
};
rb_name_error(id, "cannot make anonymous %.*s ID %"PRIxVALUE" attrset",
(int)sizeof(id_types[0]), id_types[scope], (VALUE)id);
}
str = rb_str_dup(str);
rb_str_cat(str, "=", 1);
sym = lookup_str_sym(str);
id = sym ? rb_sym2id(sym) : intern_str(str, 1);
return id;
}
今回は以上です。学びの結果をまとめてみましょう。
文字列 | シンボル | |
---|---|---|
アロケーション | 毎回メモリをアロケーションする(注) | 重複することはない |
比較 | バイトごとの比較、低速 | 実行時に整数で比較、高速 |
GC | 回収可能 | モータルなシンボルのみ可能 |
注: 別の文字列最適化として、文字列が変更されたときだけコピーを作成する手法があります。これはコピーオンライト(Copy on Write: CoW)と呼ばれます。
CoWの仕組みについては、Kernighによる素晴らしい例があります(詳しくはレス全体を参照)。
require 'objspace'
GC.disable
# 文字列とそのtailsを初期化する。tailsはストレージを共有する。
strings = ('a'..'j').map { |char| char * 2_000_000 }
tails = strings.map { |s| s[1_000_000, 1_000_000] }
GC.start
size0 = ObjectSpace.memsize_of_all
# tailsを変更してCoWをトリガーする
tails.each { |s| s[0] = 'm' }
GC.start
size1 = ObjectSpace.memsize_of_all
printf "before CoW: %s bytes\n", size0
printf " after CoW: %s bytes\n", size1
このスクリプトは、2,000万バイトの文字列と、1,000万バイトのtails
(以前初期化された文字列とストレージを共有する)を作成します。次にメモリサイズを記憶してtails
を変更し、文字列がストレージを共有できないようにします(つまりコピー操作をトリガーする)。続いて、以下のように新しいメモリサイズを取り出して比較してみます。
bash before CoW: 23531454 bytes after CoW: 33538113 bytes
本記事で覚えていただきたい最も重要な点は、文字列とシンボルは(to_s
とto_sym
で)互いに変換可能ですが、文字列はアロケーションされるたびにメモリが消費され、シンボルは重複を排除することで高速に比較できることです。
概要
原著者の許諾を得て翻訳・公開いたします。