class Symbol
シンボルを表すクラス。シンボルは任意の文字列と一対一に対応するオブジェクトです。
https://docs.ruby-lang.org/ja/latest/class/Symbol.html
Photo by Scott Graham on Unsplash
こんにちは。ウォンテッドリーでバックエンドエンジニアをしている小室 (@nekorush14) です。5年ぶりに触れたRubyで詰まったところを改めて学び直した話をします。今回はHashのSymbolキーとStringキーの違いについてまとめます。
はじめに
:example_keyと"example_key"の違い
Symbolは常に一意の識別子
Stringは毎回生成されうるデータ
なぜSymbolキーが使われるのか
パフォーマンスの優位性
セマンティクス(意味論) 的な使い分け
Railsで発生させたnilバグとその対処法
対処法
まとめ
参考文献
ウォンテッドリーへ入社し、5年ぶりにRubyの世界に戻ってきましたが、Symbolの扱い方や正しい考え方を忘れていました。ソース上でHashを使う機会があり、その時点では「RubyだからHashからデータを抽出するためのキーはSymbolにしておけば、よしなにやってくれるはずだ」という考えでいました。
実際に、機能開発を行う中で外部APIからのJSONレスポンスをHashとして内部で扱おうとしていたため、以下のように書いていました。
# サンプルコード
# APIレスポンスのイメージ
parsed_data = JSON.parse('{"example_key": "example_value"}')
# APIから返されたJSONに example_key が含まれていたら処理する
if parsed_data[:example_key].present?
puts "response can use!"
else
puts "no response exists." #=> こちらが出力される🤔
endRailsを書いていた頃は params[:example_key] のようにSymbolキーでアクセスするのが当たり前だと思っていたのに、なぜ parsed_data[:example_key].present? がfalse (parsed_data[:example_key]がnilである) となるのか理解できませんでした。また、復帰直後の私は「:example_key は変数?でも宣言してないのになんで使えるだっけ…?」と、Symbolの概念自体が曖昧になっていることに気づきました。
本稿では、Hashの誤ったキーの型によるnilバグをきっかけに学び直した、RubyにおけるSymbolキーとStringキーの根本的な違いと、Rails開発における「落とし穴」について解説します。
:example_keyと"example_key"の違いRubyにおけるHashでは、Symbolキー(:example_key)とStringキー("example_key")を全く異なるキーとして扱います。これは、Rubyの内部でSymbolとStringが根本的に異なるオブジェクトとして扱われるためです。
Symbolは : から始まる記法(例: :name)で表されます。
最大の特徴は、「イミュータブル(変更不可)であり、かつRubyの実行環境全体で常に同一のオブジェクトである」ことです。これは「識別子(Identifier)」や「名前」としての役割を担うために設計されています。
object_id (メモリ上のアドレスを示すようなもの) 確認すると理解しやすいです。
irb(main):001:0> :example_key.object_id
=> 30674908
irb(main):002:0> :example_key.object_id # 何回呼んでも同一のobject_idを返している
=> 30674908また、よく見ると特にexample_keyを宣言せずに使用できています。これは明らかに変数ではないことを示しており、これ自体が:example_keyという名前を示すオブジェクトであるといえます。
Stringは、"(ダブルクォート)や ''(シングルクォート)で囲まれた「データ」です。
重要な特徴は、「ミュータブル (変更可能) であり、かつ (frozen_string_literal が有効でない限り) 書かれるたびに新しいオブジェクトとしてメモリ上に生成される」ことです。
irb(main):001:0> "example_key".object_id
=> 98620
irb(main):002:0> "example_key".object_id # 呼ぶだびにobject_idが変わる
=> 101080
irb(main):003:0> example_string_key = "example_key"
=> "example_key"
irb(main):004:0> example_string_key.upcase! # 文字列なので破壊的変更も可能
=> "EXAMPLE_KEY"
# シンボルは破壊的変更不可 (upcase!メソッドは存在しない)
irb(main):005:0> :example_key.upcase!
(irb):5:in `<main>': undefined method `upcase!' for an instance of Symbol (NoMethodError)
Did you mean? upcaseRubyのHashはキーに指定したオブジェクトのObject#hashメソッドが返却するハッシュ値とObject#eql?メソッドが返却する同一性比較の結果により値を管理しています。
irb(main):001:0> :example_key.hash
=> 363044077936704442 # 実行環境により値は異なる
irb(main):002:0> "example_key".hash
=> -4420574026112658298 # 実行環境により値は異なる
##=>ハッシュ値が異なるので、この時点で:example_keyと"example_key"が別のキーとして扱われる
irb(main):003:0> :example_key.eql?("example_key")
=> false
irb(main):004:0> "example_key".eql?(:example_key)
=> false:example_keyと"example_key"ではハッシュ値が異なるため、Hashはこれらを完全に別物として扱います。仮にハッシュ値が同一であれば、Object#eql?メソッドが返却する結果により同一のキーであるかを判定します。
では、なぜRuby (特にRails) においてHashのキーとしてSymbolが好まれるのでしょうか。主に後述する2つの理由があると考えられます。
かつてのRuby (特に2.1以前) では、メモリ効率と比較速度の観点でSymbolに大きなパフォーマンス上の利点がありました。Stringキーを多用すると、同じ "name" でも都度オブジェクトが生成されメモリを消費していました。一方でSymbolは常に一意であるため、メモリ効率が圧倒的に良好でした。また、比較速度についてはHashがキーを比較する際、Stringは文字列全体を比較する必要がありますが、Symbolは object_id (整数) を比較するだけで済むため、非常に高速でした。
※ 現代のRuby、特に2.2.0でSymbol GC (ガベージコレクション)が導入されて以降、メモリ効率の差は縮まっています。またString自体の最適化も進んでおり、パフォーマンス差はかつてほど絶対的なものではなくなっています。
パフォーマンス差が縮まった現代において、より重要なのが「意味論 (セマンティクス)」的な使い分けです。Stringは「ユーザーが入力した名前」「記事の本文」など、「内容が変わりうる、あるいは外部から来るデータ」を意味することが多いです。一方で、Symbolは 「userのname属性」「メソッドのオプション名 (:classや:method)」など、プログラム上での「識別子」や「名前」としての役割で使用されることが多いです。
Hashのキーは、多くの場合「データを指すための識別子」として使われます。したがって、{ user_id: 1 } という記法は「:user_idという名前 (識別子)で 1 というデータを格納する」ことを意味し、Symbolの役割と完全に一致します。
ここまでの話を踏まえて、冒頭の nil バグの話に戻ります。
「でも、Railsでは params[:id] で普通に動くじゃないか🤔」と思われるかもしれないですが、これは初学者や復帰者を混乱させる元となっています。Railsの params は、素のHashではなくActiveSupport::HashWithIndifferentAccessクラスでHashがラップされています。これは「SymbolキーとStringキーを区別しない (Indifferentである)」Hashです。
irb(main):001:0> params = ActiveSupport::HashWithIndifferentAccess.new
=> {}
# StringキーでHashに値をセットする
irb(main):002:0> params["example_key"] = "example_value"
=> "example_value"
# Stringキーで参照してみる
irb(main):003:0> params["example_key"]
=> "example_value"
# Symbolキーで参照してみる
irb(main):004:0> params[:example_key] # nilにならずに同じ結果が返ってくる
=> "example_value"このクラスを使用することで、キーの型を意識せずに開発することができます。
一方で、素のStringキーHashを返却するJSON.parseや、YAMLの読み込みなどで、「ActiveSupport::HashWithIndifferentAccessではない素のHash」を意識せず扱うと、params と同じ感覚でSymbolキーでアクセスしてしまい、nil バグに遭遇してしまいます。
この nil バグに遭遇したら、以下のような対処法が考えられます。
1. with_indifferent_access を使う
Rails環境のparamsと同じ挙動(キーを区別しない)で問題ない場合には有効な方法です。
irb(main):001:0> response_body = '{"example_key": "example_value"}'
=> "{\"example_key\": \"example_value\"}"
irb(main):002:0> parsed_data = JSON.parse(response_body).with_indifferent_access
=> {"example_key"=>"example_value"}
irb(main):003:0> parsed_data[:example_key].present?
=> true2. transform_keys を使う
キーをSymbolに統一する場合、より明確になります。
irb(main):001:0> response_body = '{"example_key": "example_value"}'
=> "{\"example_key\": \"example_value\"}"
irb(main):002:0> parsed_data = JSON.parse(response_body).transform_keys(&:to_sym)
=> {:example_key=>"example_value"}
irb(main):003:0> parsed_data[:example_key].present?
=> true3. deep_symbolize_keysを使うtransform_keysは1階層分のキーをSymbolに変換できますが、ネストしていると同一の事象が発生します。Rails環境内であればdeep_symbolize_keysを使用することができ、to_sym化可能なキーをルートからネストした先まで全てSymbol化できます。
irb(main):001:0> nested_example = '{"example_obj": { "example_key": "example_value"
}}'
=> "{\"example_obj\": { \"example_key\": \"example_value\" }}"
irb(main):002:0> parsed_data = JSON.parse(nested_example).deep_symbolize_keys
=> {:example_obj=>{:example_key=>"example_value"}}
irb(main):003:0> parsed_data[:example_obj].present?
=> true
irb(main):004:0> parsed_data[:example_obj][:example_key].present?
=> true4. JSON.parseのsymbolize_namesオプションを使う
JSON.parseメソッドにはHashキーをSymbolの状態で返却させるsymbolize_namesオプションがあります。
irb(main):001:0> nested_example = '{"example_obj":{ "example_key": "example_value"}}
'
=> "{\"example_obj\":{ \"example_key\": \"example_value\"}}"
irb(main):002:0> parsed_data = JSON.parse(nested_example, symbolize_names: true)
=> {:example_obj=>{:example_key=>"example_value"}}
irb(main):003:0> parsed_data[:example_obj].present?
=> true
irb(main):004:0> parsed_data[:example_obj][:example_key].present?
=> true5. StringキーでアクセスするJSON.parseメソッドがStringキーを返却することを理解した上で、そのままStringキーでアクセスするのも正しい対処法です。
irb(main):001:0> response_body = '{"example_key": "example_value"}'
=> "{\"example_key\": \"example_value\"}"
irb(main):002:0> parsed_data = JSON.parse(response_body)
=> {"example_key"=>"example_value"}
irb(main):003:0> parsed_data["example_key"].present?
=> true5年ぶりにRubyに復帰して陥った nil バグから、以下のことを学び直しました。
Symbolは、Rubyの実行環境全体で常に一意なObjectであり、Immutableな「識別子」であるStringは、(frozen_string_literalが有効でない限り) メモリ上に再生成されうるObjectであり、Mutableな「データ」であるparams がどちらでもアクセスできるのは HashWithIndifferentAccess クラスのおかげであり、「外部APIのレスポンスをJSONでparseするなどで"素のHash"を扱う際は、キーがStringかSymbolかを意識する必要があるこの違いをセマンティクス(意味論)レベルで理解することで、nilバグに悩まされず堅牢なコードに繋がります。