概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: You should not implement #to_a for your classes
- 原文公開日: 2018/02/02
- 著者: zverok -- 名サイト「rubyreferences.github.io」の作者でもあります。
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では「配列とタプル」を区別しないという事実が原因なので、ここでは「有意義な内部値を含むタプルに変換する」ということです。
しかしこの変換を行う場合、専門に特化した名前を用いる場合と比べて、「標準的な」名前を用いるメリットはありません。少なくとも私が想像する限りでは、種類の異なるさまざまなオブジェクトを含む配列が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_a
がTime
に実装されている理由って?
歴史的な理由であろうと信じています。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_byteやIO#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」の記事に登場する、実装や設計をやらずに現実離れしたアーキテクチャばかり提唱する人を揶揄した言葉から来ています。「空飛ぶアーキテクト」とか「浮世離れのアーキテクト」とでも呼びましょうか。