Ruby: 自分のクラスに`#to_a`を実装すべきじゃない(翻訳)

概要

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

Ruby: 自分のクラスに#to_aを実装すべきじゃない(翻訳)

えっと、このブログをときどき読んでる人なら言葉のキツさをご存知と思います。また書いてしまった。

挨拶代わりに、Rubyの便利なイディオムのひとつであるKernel#Arrayメソッドについて軽くおさらいしておきましょう。またの名を「知らんけど、んなもんArrayに決まってんだろ!」というやつです。

あるデータを用いて作業しているときに、1個の値か値の配列のどちらをも受け取るまたは返す可能性のあるメソッドが使えるとしましょう。その次に、両者の区別をなくして確実に1つの配列として扱う必要が生じたとしたら、次のように書けるでしょう。

Array(1)         # => [1]
Array('test')    # => ["test"]
Array([1, 2, 3]) # => [1, 2, 3]  -- 1個の配列は何も変わらない
Array(nil)       # => []         -- ま、わかるっちゃわかる

このステキなコード片の挙動は簡単に見落とされがちですが、ありがたいものですし、ほとんどの場合直感を裏切りません。Array(nil)はここでは割りといい感じですし、[nil]を返すのと比べて少々論理的ではないとはいえ、現場で欲しいものとしては十分過ぎるぐらいでしょう。

しかし、直感はときに裏切られます。

val = Time.now
Array(val)
# => [3, 21, 10, 2, 2, 2018, 5, 33, false, "EET"]

な、何だこれは?Arrayメソッドは最初にval#to_aryを呼ぼうとし、応答がなければ最終的にTimeが最終的に実装するval#to_aを呼びます。

メモ: #to_ary#to_aの違いがなぜ重要かについてはこちらの記事をご覧ください。

ここから貴重な教訓を得られました。

コレクション「でないもの」については、自分のクラスで#to_aを絶対に実装してはならない(特にValue Objectを表す場合は)

Q: 一体全体どうして…?

逆に皆さんにお尋ねします。オブジェクトがコレクションでない場合、#to_aにはいったいどんな意味があるんですか?

ほとんどの場合(Timeの場合とか)、このメソッドの存在によって引き起こされる問題は、Rubyでは「配列とタプル」を区別しないという事実が原因なので、ここでは「有意義な内部値を含むタプルに変換する」ということです。

訳注: ここで「タプル」が指すものが今ひとつはっきりわからなかったので、社内Slackで相談しました。

しかしこの変換を行う場合、専門に特化した名前を用いる場合と比べて、「標準的な」名前を用いるメリットはありません。少なくとも私が想像する限りでは、種類の異なるさまざまなオブジェクトを含む配列が1個あってarray.map(&:to_a)を呼んだときに、そのうちのいずれかがTimeのインスタンスであれば、「timeを構成する要素のリスト」が返されることを期待するものです。しかしそれでは何らかのバグが発生するということになります(配列が返されるのを期待しているところにTimeが返されるなど)。

さらに言うと、(訳注: #to_aのような一般的過ぎる名前ではなく)もっと特化したメソッド名を選ぶほうが常に設計がクリーンになります。例を挙げると、私のGeo::Coord gemのデータ型で最近これと同じ問題が起きたのです。設計がTimeの設計に近かったために#to_aメソッドが実装されてしまったことがあり、これが[lat, lng]を返したのです(訳注: 緯度と経度)。やはりArray()のイディオムとどうにも相性が合わないので、メソッド名を#to_aから#latlngに変更することにしました。

ところでこの書き方なら、対称的な(訳注: 順序を入れ替えた)#lnglatメソッドが存在することの察しが付きますし、実際、座標をこの順序で渡すことを期待するAPIもあったりするのです。to_a(order: :lnglat)みたいな方法で順序を入れ替える手もありますが、(#to_aなんかではなく)メソッドを2つにする方が最も明快かつ見つけやすいように思えます。

訳注: 個人的には、せめて#lat_lng#lng_latのようにアンダースコアを入れて欲しい気がしました。

Q: 他の#to_<x>系メソッドはどうよ?

#to_aがそんなに邪悪なら、#to_hとか#to_sも捨てないとダメなの?」ということですか。そちらは捨てるべきではありません。理由は以下のとおりです。

  • 他の#to_<x>系メソッドはセマンティクス(意味)が違いますし、疑問の余地もさほどありません。
    • #to_sは、オブジェクトをputsや文字列の式展開で表現する方法です。
    • #to_hをJSON形式の時刻で使う場合、「意味を明確に保ったまま、オブジェクト全体を基本的なデータ型にダンプする」便利なイディオムとして使えます。実際、「オブジェクト全体を何らかのタプルにダンプする」適切かつ現代的な代替メソッドになっています。
  • Hash()メソッドというものもあるにはありますが、こちらは(訳注: Array()と比べて)ずっと制約が強くなっていますし、変換しようのない値オブジェクトがある可能性が考えられる場合に何でもかんでも値をハッシュに変換するとはちょっと想像しにくいことです。

Q: #to_aTimeに実装されている理由って?

歴史的な理由であろうと信じています。Timeが設計されたときは(Timeとtimeのダジャレにあらず)、キーワード引数という概念もなければ、key: valというハッシュ構文ショートハンドすらありませんでした(訳注: 1.9より前のRubyにはkey => valしかありませんでした: 参考)。つまり、暗黒時代に設計されたコアクラスたちは、(Timeクラスがそうであるように)多数の値を構成できるようにするときには、このような長ったらしい値のリストをコンストラクタで受けられるようにするしかなかったのです。たとえばTimeには未だにnewだのgmだのmktimeだのlocalだのさまざまなやり方があります。それというのも、位置で表現される引数だけでは、値の個数が1個から10個までありうる場合に正しいコンストラクタを設計するのが困難だったからです。

そうしたわけで、コンストラクタがあるなら、その逆の操作を簡単に行える「デコンストラクタ」的なメソッドも使える方が筋がとおっているように思えます。

Time.mktime(*Time.now.to_a)
# => 2018-02-02 06:57:57 +0200

ここで妙なのは、現在の日時を表現するためのキーワード引数コンストラクタもなければ、#to_hメソッドすらないことです。

Q: 他のコアクラスのto_aの実装って?

Object.constants.map(&Object.method(:const_get))
  .grep(Class).select { |c| c.instance_methods.include?(:to_a) }
# => [Array, Hash, NilClass, File, Struct, Enumerator, MatchData, Dir, Time, Range, IO, StringIO]

既に言及したTimeの流れで言うと、特筆すべきはStructです。これはあらゆるValue Objectの基礎となる言語のコアです。ご多分に漏れず、これも「これは配列なのか?」という疑問に陥っています。しかし(皆さんがValue Objectを念頭に置いていただけるなら)Structはさらに変です。#to_aを実装しているだけではなく、#eachも実装してEnumerable全体をインクルードしているのです!。Structにもし心があれば、「私はValue Objectというよりはむしろ、ドット記法が使える自由なハッシュである」と考えることでしょう。

さて、ここからもうひとつ、疑問の余地が残るルールが生まれます。

おまけ: (コレクションでないものに)#eachを実装すべきではない

理由は同じです。Xがコレクションでない場合に「Xのそれぞれの値」を問う意味がどこにあるのでしょうか?相当曖昧でありながら、一部のコアクラスは未だにこの機能を誇示しています。

端的に言えば、IO#eachが何を列挙するのかという話です。

ほとんどの皆さんはもう答えをご存知でしょう($\または$OUTPUT_FIELD_SEPARATOR、あるいは文字列セパレータとして渡されたもので区切られた行が列挙されます)。しかしこれは直感的に言えば実装も不可能ですし、一番欲しいものにもならないでしょう。まあ、「スクリプト」とプレーンテキストの時代ならそのとおりでしたが、もうそんな時代ではないことをしかと認めましょう。今ならI/Oストリームやeach(ナントカ)はバイトだったりUnicodeコードポイントだったりバッファのチャンクだったりそうでなかったりします。IO#each_byteIO#each_line(separator)はどちらも柔軟性と可読性が高くなっています。

歴史に関するメモ: かつてStringには#eachがあり、Enumerableでした(I/Oで言うのと同じ意味で)が、この考え方は改められて現在は#each_byte/#each_char/#each_codepoint/#each_lineとなり、むき出しの#eachはなくなりました。これはよいことです。

というわけで、ぜひArkencyの極上ブログ「Stop including Enumerable, return Enumerator instead」をお読みいただき、メソッド名をちゃんと意味のある#each_ナントカにしましょう。そしてinclude Enumerableは使わないでください。

対象がコレクションでないのであれば、です。

Q: じゃコレクションだったらどうしろと?

だったら#eachを実装してinclude Enumerableしましょう。こうすれば#to_aがタダで手に入りますし、必要であれば効率よく再実装することもできます。さらに「自分のコレクション」というコンテキストでうまく動く#to_aryおそらく実装されるでしょう。

こぼれ話: さらに大胆に申し上げると、私はHash#each#to_aをこれっぽっちもよいと思いません。Array({some: 'hash'}) # => [[:some, "hash"]]のような出力が最も期待される状況は想像しにくいですし、場合によってはinclude Enumerableするよりも#each_pair # => Enumeratorの方がましなコードになるように思えます。もしかすると私は「Architectural Astronauts」みたいにのぼせ上がってるのかもしれませんね。


訳注: Architectural Astronautsは、古参ブログ「Joel on Software」の記事に登場する、実装や設計をやらずに現実離れしたアーキテクチャばかり提唱する人を揶揄した言葉から来ています。「空飛ぶアーキテクト」とか「浮世離れのアーキテクト」とでも呼びましょうか。

社内Slackより

関連記事

RubyのArray(配列)の使い方

Rubyスタイルガイドを読む: コレクション(Array、Hash、Setなど)

[Ruby] each_with_objectもmapも使わずにto_hだけで配列をハッシュに変換する

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ