RubyのHashにおけるSymbolキーとStringキーの違いを学び直した話 | Wantedly Engineer Blog


こんにちは。ウォンテッドリーでバックエンドエンジニアをしている小室 (@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として内部で扱おうとしていたため、以下のように書いていました。


parsed_data = JSON.parse('{"example_key": "example_value"}')


if parsed_data[:example_key].present?
puts "response can use!"
else
puts "no response exists."
end

Railsを書いていた頃は 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は常に一意の識別子

Symbolは : から始まる記法(例: :name)で表されます。

最大の特徴は、「イミュータブル(変更不可)であり、かつRubyの実行環境全体で常に同一のオブジェクトである」ことです。これは「識別子(Identifier)」や「名前」としての役割を担うために設計されています。

object_id (メモリ上のアドレスを示すようなもの) 確認すると理解しやすいです。

irb(main):001:0> :example_key.object_id
=> 30674908
irb(main):002:0> :example_key.object_id
=> 30674908

また、よく見ると特にexample_keyを宣言せずに使用できています。これは明らかに変数ではないことを示しており、これ自体が:example_keyという名前を示すオブジェクトであるといえます。

Stringは毎回生成されうるデータ

Stringは、"(ダブルクォート)や ''(シングルクォート)で囲まれた「データ」です。

重要な特徴は、「ミュータブル (変更可能) であり、かつ (frozen_string_literal が有効でない限り) 書かれるたびに新しいオブジェクトとしてメモリ上に生成される」ことです。

irb(main):001:0> "example_key".object_id
=> 98620
irb(main):002:0> "example_key".object_id
=> 101080

irb(main):003:0> example_string_key = "example_key"
=> "example_key"
irb(main):004:0> example_string_key.upcase!
=> "EXAMPLE_KEY"


irb(main):005:0> :example_key.upcase!
(irb):5:in `

': undefined method `
upcase!' for an instance of Symbol (NoMethodError)
Did you mean? upcase

RubyのHashはキーに指定したオブジェクトのObject#hashメソッドが返却するハッシュ値とObject#eql?メソッドが返却する同一性比較の結果により値を管理しています。

irb(main):001:0> :example_key.hash
=> 363044077936704442

irb(main):002:0> "example_key".hash
=> -4420574026112658298


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?メソッドが返却する結果により同一のキーであるかを判定します。

なぜSymbolキーが使われるのか

では、なぜ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は 「username属性」「メソッドのオプション名 (:class:method)」など、プログラム上での「識別子」や「名前」としての役割で使用されることが多いです。

Hashのキーは、多くの場合「データを指すための識別子」として使われます。したがって、{ user_id: 1 } という記法は「:user_idという名前 (識別子)で 1 というデータを格納する」ことを意味し、Symbolの役割と完全に一致します。

Railsで発生させたnilバグとその対処法

ここまでの話を踏まえて、冒頭の nil バグの話に戻ります。
「でも、Railsでは params[:id] で普通に動くじゃないか🤔」と思われるかもしれないですが、これは初学者や復帰者を混乱させる元となっています。Railsの params は、素のHashではなくActiveSupport::HashWithIndifferentAccessクラスでHashがラップされています。これは「SymbolキーとStringキーを区別しない (Indifferentである)」Hashです。

irb(main):001:0> params = ActiveSupport::HashWithIndifferentAccess.new
=> {}


irb(main):002:0> params["example_key"] = "example_value"
=> "example_value"


irb(main):003:0> params["example_key"]
=> "example_value"


irb(main):004:0> params[:example_key]
=> "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?
=> true

2. 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?
=> true

3. 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?
=> true

4. JSON.parsesymbolize_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?
=> true

5. 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?
=> true

まとめ

5年ぶりにRubyに復帰して陥った nil バグから、以下のことを学び直しました。

  • Symbolは、Rubyの実行環境全体で常に一意なObjectであり、Immutableな「識別子」である
  • Stringは、(frozen_string_literalが有効でない限り) メモリ上に再生成されうるObjectであり、Mutableな「データ」である
  • Railsの params がどちらでもアクセスできるのは HashWithIndifferentAccess クラスのおかげであり、「外部APIのレスポンスをJSONでparseするなどで”素のHash”を扱う際は、キーがStringかSymbolかを意識する必要がある

この違いをセマンティクス(意味論)レベルで理解することで、nilバグに悩まされず堅牢なコードに繋がります。

参考文献

class Symbol

シンボルを表すクラス。シンボルは任意の文字列と一対一に対応するオブジェクトです。

class String

文字列のクラスです。 ヌル文字を含む任意のバイト列を扱うことができます。 文字列の長さにはメモリ容量以外の制限はありません。

class Hash

ハッシュテーブル(連想配列とも呼ぶ)のクラスです。ハッシュは任意の種類のオブ ジェクト(キー)から任意の種類のオブジェクト(値)への関連づけを行うことができます。

ActiveSupport::HashWithIndifferentAccess

Hash With Indifferent Access¶ ↑ Implements a hash where keys :foo and “foo” are considered to be the same. rgb = ActiveSupport::HashWithIndifferentAccess.

Active Support Core Extensions – Ruby on Rails Guides

Active Support is the Ruby on Rails component responsible for providing Ruby language extensions and utilities.It offers a richer bottom-line at the language level, targeted both at the development of Rails applications, and at the development of Ruby on

JSON.#parse (Ruby 3.4 リファレンスマニュアル)

与えられた JSON 形式の文字列を Ruby オブジェクトに変換して返します。

他言語からのRuby入門

Rubyのコードを目にすると、 他の言語と似た部分があることに気が付くはずです。 構文の多くは、(他の言語の中でも特に)PerlやPython、 Javaプログラマーにとって馴染みのあるものになっています。 もしあなたがそうした言語に慣れ親しんでいるのなら、 Rubyを学ぶのはおそらくどうってことないはずです。 このドキュメントは2部構成になっています。 このページでは、プログラミング言語 X からRubyへ移ってくる際に役立つ情報をざっと紹介します。 個別のページでは、Rubyの主な言語機能を紹介しつつ


元の記事を確認する

関連記事