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

Rubyにシンボルがある理由(翻訳)

概要

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

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とその使われ方を調べることにします。

whitequark/parser - GitHub

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つのメソッド(validatesencrypts)の引数として使われている場合と、メソッド名(.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は別のオブジェクトへのメッセージ送信を扱い、putselfselfの値を変更するといった具合です。

原注

技術的には、バイトコード(より正確には「VMが実行するコード」)インストラクションは2バイト以上になることもありますが、これは一般的な形式の1つです。

ASTそのものをプログラムとして実行するのはありでしょうか?それも可能ですが、パフォーマンスは落ちるでしょう。インタプリタがメモリ上に巨大なオブジェクトグラフを保持しなければならなくなり、何かを実行するたびにその中を移動しなければならなくなります。これと対照的に、バイトコードを構成するシンプルなインストラクションセットはとても小さく、プロセッサのキャッシュに収まるほどです。

Ruby VMはシンボルをどのように保存するか

トークンの中には、利用頻度が高く決して変化しないものもあることがわかっています。そういうものをメモリ上にすべて置く必要をなくす方法はないものでしょうか?Rubyはまさにそれをやっているのです。各トークンはハッシュテーブルに保存され、IDで表現されます。これによって保存に必要なメモリ量が削減されます。

原注

ハッシュテーブルは、プログラミング言語を構成するうえで最も重要なデータ構造です。

Rubyでシンボルが使われると、ハッシュテーブルの中にそのシンボルがあるかどうかを探索し(ハッシュテーブル読み込みの計算量O(1)です)、必要であればハッシュテーブルにシンボルを追加します。benchmark/memory gemを使って、シンボルのメモリ使用量が少ないことを確かめてみましょう。

michaelherold/benchmark-memory - GitHub

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.crb_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_sto_symで)互いに変換可能ですが、文字列はアロケーションされるたびにメモリが消費され、シンボルは重複を排除することで高速に比較できることです。

関連記事

『Polished Ruby Programming』(Jeremy Evans著)を読みました


CONTACT

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