Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Do. Or Do Not. There Is No Try – Object#try Considered Harmful 公開日: 2017/09/24 著者: Karol Galanciak サイト: BookingSync 原文タイトルは、よくあるヨーダのセリフのもじりです。 RailsのObject#tryがダメな理由と効果的な代替手段(翻訳) Object#tryは、Railsアプリでnil値を扱う可能性がある場合をカバーするときや、与えられたメソッドがオブジェクトで必ずしも実装されていないといった場合に柔軟なインターフェイスを提供するときに、かなりよく使われています。#tryのおかげでNoMethodErrorを回避できるのですから、これで問題はなくなったように見えます。NoMethodError例外が起きなくなったからといって、本当に問題がなくなったと言えるのでしょうか? 実際にはそうとは言えません。Object#tryにはいくつかの重大な問題がつきまといますし、たいていの場合もっとよいソリューションをかなり簡単に実装できるのです。 Object#tryのしくみ Object#tryの基本となるアイデアはシンプルです。nilオブジェクトにメソッド呼び出しを行った場合や、そのオブジェクトにメソッドが実装されていないnilでないオブジェクトにメソッド呼び出しを行った場合に、NoMethodError例外をraiseせず、単にnilを返すというものです。 たとえば、最初のユーザーのメールアドレスを取りたいとします。ユーザーが1人もいない場合に失敗しないようにするために、次のように書くことができます。 user.first.try(:email) このとき、さまざまな種類のオブジェクトを渡せる一般的なサービスを実装していたとしたらどうでしょう。たとえば、オブジェクトを保存した後、オブジェクトがたまたまそのための正しいメソッドを実装した場合に通知を送信するにはどうしたらよいでしょうか。Object#tryを使うと次のように書けます。 class MyService def call(object) object.save! object.try(:send_success_notification, “MyServiceから保存されました”) end end コードを見れば、このメソッドに引数を与えられることもわかります。 途中でnilを受け取る可能性のあるステップごとに、何らかのメソッドをチェインする必要があるときにはどうすればよいでしょうか。ご心配なく、Object#tryでできます。 payment.client.try(:addresses).try(:first).try(:country).try(:name) Object#tryの何が問題なのか 一見するとObject#tryはさまざまなケースを扱えそうですが、使うとどんな問題が起きるのでしょうか。 その答えは「たくさん起きる」です。多くの場合、Object#tryの最大の問題は、問題を完全に握りつぶしてしまうことであり、nilが問題である場合にも問題を「解決」してしまいます。他にも、Object#tryを使うと意図がはっきりしなくなる点も挙げられます。次のコードが何をしようとしているかおわかりでしょうか。 payment.client.try(:address) 見た目どおり、支払い(payment)にクライアントがいない場合にnilになるケースを扱っているのでしょうか。それとも、単にクライアントがたまたまnilになったときにNoMethodErrorで失敗したくないから「念のため」使っているだけなのでしょうか。もっと悪い場合を考えると、クライアントがたまたまポリモーフィック関連付けになっていて、しかもaddressesメソッドがモデルに実装されているとは限らないとしたらどうでしょう。あるいは、データ完全性にある問題が生じ、何らかの理由でクライアントが削除された支払いがいくつか発生してしまい、しかも残っていないとしたらどうでしょう。 Object#tryの使いみちの可能性があまりに多いため、上のコードを見ただけでは真の意図を知ることは不可能です。 幸いなことに、Object#tryを取り除いて明確で表現力の高いコードにできる別のさまざまなソリューションがあります。そうしたソリューションを使えば、コードのメンテナンス性と読みやすさがより高まり、バグが発生しにくくなり、二度と意図があいまいにならないようにできます。 Object#tryを使わないソリューション Object#tryの利用状況に応じた「パターン」をいくつかご紹介します。 1. デメテルの法則を尊重する デメテルの法則は、構造的な結合を回避するのに便利な規則です(個人的には「法則」というほどではない気がします)。要するに、仮想のオブジェクトAは自分に直接関連することにのみ関心を持つべきであり、協同または関連する相手の内部構造に立ち入るべきではないという規則です。この規則は「メソッド呼び出しのドット.は1つだけにする」と解釈されることが多いのですが、デメテルの法則は本来ドットの数の規則ではなく、オブジェクト間の結合についての規則なので、操作や変換のチェインについてはまったく問題にはなりません。たとえば次の例は法則に違反しません。 input.to_s.strip.split(” “).map(&:capitalize).join(” “) しかし次の例は違反です。 payment.client.address デメテルの法則を尊重することで、多くの場合明確でメンテナンス性の高いコードを得ることができます。法則に違反する十分な理由がない限り、法則を守って密結合を回避するようにすべきです。 先のpayment/client/addressを使ったコード例に戻ります。次のコードはどのようにリファクタリングできるでしょうか。 payment.client.try(:address) 最初に行うのは、構造的な結合を減らしてPayment#client_addressメソッドを実装することです。 class Payment def client_address client.try(:address) end end これでさっきよりずっとよくなりました。payment.client.try(:address)で無理やりaddressを参照するのではなく、payment.client_addressを実行するだけで済みます。Object#tryが1箇所だけになったので既に1つ改善されました。リファクタリングを続けましょう。 ここから先は2つの選択肢があります。clientがnilになるのが正当か、そうでないかです。clientがnilになるのが正しいのであれば、自信を持って明示的にnilを早期に返します(訳注: いわゆるguard構文です)。こうすることで、clientが1つもないのは有効なユースケースであることがはっきりします。 class Payment def client_address return nil if client.nil? client.address end end clientは決してnilになってはならないのであれば、先のguard構文をスキップできます。 class Payment def client_address client.address end end このような委譲はかなり一般的に行われます。Railsではこういう場合にうまい解決法があるでしょうか?答えは「イエス」です。ActiveSupportは、まさにこういう場合にうってつけのActiveSupport#delegateマクロを提供しています。このマクロを使えば、nilをさっきとまったく同じように扱える委譲を定義できます。 最初の例では、nilになってもよいユースケースを次のように書き換えます。 class Payment delegate :address, to: :client, prefix: true, allow_nil: true end nilになってはならない場合は次のように書き換えます。 class Payment delegate :address, to: :client, prefix: true end これで先ほどよりもずっとコードが明確になり、結合も弱まりました。Object#tryをまったく使わずにエレガントに解決するという目的を達成できたのです。 しかし、他の場所ではpaymentにclientがないことを予測しきれていない可能性がまだ残されています(完了していないトランザクションのpaymentなど)。たとえば、トランザクションが完了したpaymentのデータを表示するときになぜかNoMethodError例外でつまづいてしまうことがあります。このような場合に、必ずしもdelegateマクロでallow_nil: trueオプションが必要になるとは限りません。もちろん、Object#tryを使わなければならないということでもありません。この場合の解決法はいくつか考えられます。 2. スコープ付きデータを操作する 完了したトランザクションのpaymentを扱うときにclientが存在することを保証するなら、単に正しいデータセットを扱えるようにするのが手です。Railsアプリではこういう場合に、PaymentコレクションにActiveRecordの何らかのスコープ(with_completed_transactionsなど)を適用します。 Payment.with_completed_transactions.find_each do |payment| do_something_with_address(payment.client_address) end 完了していないトランザクションのpaymentでclientのaddressを使って何かする計画はまったくないので、ここでnilを明示的に取り扱う必要はありません。 にもかかわらず、paymentの作成にclientが常に必須になっているとしても、このコードでNoMethodErrorが発生する可能性は残されています(関連付けられたclientレコードが誤って削除されてしまった場合など)。この場合は修正が必要になるでしょう。 3. データ完全性 特にPostgreSQLなどのRDBMSを使っている場合、データの完全性を確実にする方法はかなりシンプルです。ここで押さえておくべきは、テーブルの新規作成時に適切な制約を追加することです。これはデータベースレベルで行う必要があることを忘れてはなりません。モデルでのバリデーションは簡単にバイパスされてしまうことがあるため、まったく不十分です。clientがnilになってしまう問題を回避するには、paymentsテーブル作成時にNOT NULL制約とFOREIGN KEY制約を追加して、clientがまったく割り当てられない状況や、関連付けが一部のpaymentに残っているclientレコードが削除されるような状況を防ぐべきです。 create_table :payments do |t| t.references :client, index: true, foreign_key: true, null: false end 以上で制約の追加はオシマイです。制約の追加を忘れないようにすることで、nilで起きる予想外のユースケースの多くを回避できます。 4. 明示的な変換で型を確定する 私は次のようなかなり風変わりなObject#tryの使い方を何度か目にしたことがあります。 params[:name].try(:upcase) このコードから、params内のnameキーから何らかの文字列が取り出せることを期待しているのが読み取れます。それならto_sメソッドで明示的に変換することで文字列型を確定させればよいのではないでしょうか。 params[:name].to_s.upcase これで意図がわかりやすくなりました。 ただし、上の2つのコードは同等ではありません。前者はparams[:name]が文字列であれば文字列を返しますが、nilの場合にはnilを返します。後者は常に文字列を返します。場合によってnilが戻ることが期待されるかどうかは元のコードからははっきりしないので(これはObject#tryのあからさまな問題ですが)、ここでは2つの選択肢が考えられます。 params[:name]がnilならnilを返すことが期待される場合: 文字列の代わりにnilを扱うのはかなり面倒になるので、あまりよいアイデアとはいえませんが、nilを扱う必然性がどうしても生じることもあるかもしれません。そのような場合はguard構文を追加してparams[:name]がnilになる可能性があることを明示的に示す方法が考えられます。 return if params[:name].nil? params[:name].to_s.upcase 文字列を返すことが期待される場合: この場合はguard構文は不要です。先の明示的な変換をそのまま使いましょう。 params[:name].to_s.upcase もっと複雑な状況では、Form Objectを使うか、dry-rbなどのもっと安全な型管理を導入する(あるいは両方)のがよいかもしれません。ただしこれらは明示的な型変換と本質的に同じなので、設計を損なわない限りは有用だと思います。 5. 正しいメソッドを使う ネストしたハッシュの取り扱いは、API開発やユーザー提供のペイロードを扱うときにかなりよく見かけるユースケースです。JSONAPI互換のAPIを扱っていて、更新時にclientの名前を取得したいとしましょう。この場合は次のようなペイロードが考えられます。 { data: { id: 1, type: “clients”, attributes: { name: … Continue reading Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)