Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

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

概要

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

原文タイトルは、よくあるヨーダのセリフのもじりです。

  • 2017/11/07: 初版公開
  • 2022/04/07: 更新

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

Object#tryは、Railsアプリでnil値を扱う可能性がある場合をカバーするときや、与えられたメソッドがオブジェクトで必ずしも実装されていないといった場合に柔軟なインターフェイスを提供するときに、かなりよく使われています。#tryのおかげでNoMethodErrorを回避できるのですから、これで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ではこういう場合にうまい解決法があるでしょうか?答えは「イエス」です。Active Supportは、まさにこういう場合にうってつけの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コレクションにActive Recordの何らかのスコープ(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を使っている場合、データの完全性を確実にする方法はかなりシンプルです。ここで押さえておくべきは、テーブルの新規作成時に適切な制約を追加することです。これはデータベースレベルで行う必要があることを忘れてはいけません。モデルでのバリデーションは簡単にバイパスされてしまうことがあるため、まったく不十分です。

clientnilになってしまう問題を回避するには、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: "some name"
    }
  }
}

しかしAPIのユーザーが提供するペイロードが正しいかどうかがどうしてもわからない場合、ペイロードの構造が正しくないという仮定が成り立つことがあります。

こういう場合の残念な対応方法といえば、もうおわかりですね。Object#tryです。

params[:data].try(:[], :attributes).try(:[], :name)

お世辞にも美しいとは言い難いコードです。しかし面白いことに、このコードをきれいに書き直すのは実に簡単です。

1つの方法は、途中のステップに明示的な変換を適用することです。

params[:data].to_h[:attributes].to_h[:name]

さっきより改善されましたが、もう少し表現力が欲しいところです。理想的な方法は、それ専用のメソッドを使うことです。そうした専用メソッドはいくつかありますが、たとえばHash#fetchは、指定のキーがハッシュにない場合の値も指定できます。

params.fetch(:data).fetch(:attributes, {}).fetch(:name)

これでずっとよくなりましたが、ネストしたハッシュを掘ることにもう少し特化したメソッドがあればさらによいでしょう。幸いなことに、Ruby 2.3.0からまさにこのためのHash#digメソッドが使えるようになりました。このメソッドはネストしたハッシュをくまなくチェックし、中間のキーがない場合にも例外をraiseしません。

params.dig(:data, :attributes, :name)

6. 正しいインターフェイスとダックタイピングを使う

最初に使った、必要な場合に通知を送信する例に立ち戻ります。

class MyService
  def call(object)
    object.save!
    object.try(:send_success_notification, "saved from MyService")
  end
end

このコードの改善方法は2とおりあります。

  • Serviceを2つ実装する: 1つのServiceは通知を送信し、もう1つは送信しません。
class MyServiceA
  def call(object)
    object.save!
  end
end

class MyServiceB
  def call(object)
    object.save!
    object.send_success_notification("saved from MyService")
  end
end

リファクタリングしたことでコードがずっと明確になり、Object#tryも取り除けました。しかし今度は、MyServiceAを使う必要があるオブジェクトの種類とMyServiceBを使う必要があるオブジェクトの種類を知る方法が必要になります。これはこれで理解できますが、別の問題となる可能性もあります。この場合は2番目の方法がよいでしょう。

  • ダックタイピングを使う: MyServiceに渡されるすべてのオブジェクトに単にsend_success_notificationメソッドを追加します。このメソッドは何もせず、メソッドの内容は空のままにします。
class MyService
  def call(object)
    object.save!
    object.send_success_notification("saved from MyService")
  end
end

この方法なら、オブジェクトで共通する振舞いを明示的に示せるので、そうした振舞いを認識しやすくなるというメリットも得られます。元のObject#tryにはドメイン概念が多数潜んでいるため、コードの意図がわかりにくくなるという問題があります。そうしたドメイン概念が存在しないのではなく、ちゃんと認識されていないということです。Object#tryを使うとドメイン(概念)も損なわれてしまうことをお忘れなく。

7.「Null Object」パターン

上の通知送信の例をもう一度使うことにします。モデルの形はある程度残しつつ、少し変更しました。メソッドの引数をmailerにし、それに対してsend_success_notificationを呼びます。

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.send_success_notification(object, "saved from MyService")
  end
end

これで、必要に応じていつでも通知を送信できるようになりました。さて、通知を送信したくないときはどうすればよいでしょうか。そんなときの残念な方法といえば、mailernilを渡してObject#tryを使うことです。

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.try(:send_success_notification, object, "saved from MyService")
  end
end

Service.new.call(object, mailer: nil)

ここまでお読みになった方は、この方法を使うべきでないことがおわかりいただけると思います。ありがたいことに、Null Objectパターンを適用すれば、何もしないsend_success_notificationメソッドを実装する何らかのNullMailerのインスタンスを渡せます。

class NullMailer
  def send_success_notification(*)
  end
end

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.send_success_notification(object, "saved from MyService")
  end
end


MyService.new.call(object, mailer: NullMailer.new)

これでObject#tryよりずっとよいコードになりました。

🔗 ぼっち演算子&.とは何か

Ruby 2.3.0で新しく導入された&.は「ぼっち演算子」や「safe navigation operator」などと呼ばれます(訳注: 以下ぼっち演算子で統一)。ぼっち演算子は一見Object#tryとよく似ていますが、Object#tryほどあいまいではありません。nil以外のオブジェクトに対してメソッド呼び出しを行い、かつオブジェクトにそのメソッドが実装されていない場合はNoMethodErrorがraiseされます(Object#tryはそうではありません)。次の例をご覧ください。

User.first.try(:unknown_method)  # `user`がnilであるとする
=> nil

User.first&.unknown_method
=> nil

User.first.try(:unknown_method!) # `user`はnilでないとする
=> nil

User.first&.unknown_method
=> NoMethodError: undefined method `unknown_method' for #<User:0x007fb10c0fd498>

ぼっち演算子なら安全に使えるからよいのでしょうか?そうでもありません。Object#tryの重大な問題が1つ減っただけで、他の問題はそのまま変わらないからです。

しかしながら、私はぼっち演算子を使ってもよいケースが1つあると考えています。次のコード例をご覧ください。

Comment.create!(
  content: content,
  author: current_user,
  group_id: current_user&.group_id,
)

ここでは、current_userに属するコメントを1つ作成したいと考えています。current_userは作者(author)になることがあり、current_userからgroup_idを代入しますが、このgroup_idnilになる可能性があるとします。

上のコードは次のように書き直せます。

Comment.create!(content: content, author: current_user) do |c|
  c.group_id = current_user&.group_id if current_user
end

次のように書き直すこともできます。

comment_params = {
  content: content,
  author: current_user,
}

comment_params[:group_id] = current_user.group_id if current_user

Comment.create!(comment_params)

しかし、書き直したコードが、元のぼっち演算子&.を使ったサンプルより読みやすくなったとは思えません。このように、意図があいまいになるのと引き換えに読みやすさを優先したい場合には、ぼっち演算子&.が有用なこともあります。

まとめ

私は次の理由から、Object#tryの有効なユースケースはひとつもないと信じています。Object#tryを使うと意図があいまいになってしまい、ドメインモデルに負の影響が生じます。

Object#tryは問題を美しくない方法で「解決」してしまいますが、同じ問題をもっとスマートに解決できる方法が「デメテルの法則の尊重と委譲」から、「スコープが正しく設定されたデータを扱う」、「データベースに正しい制約を適用する」、「明示的な変換で型を確定させる」、「正しいメソッドを使う」、「ダックタイピングを利用する」「Null Objectパターン」に至るまで数多く存在するという単純な事実があります。ぼっち演算子&.すら、用途を限定すればずっと安全に使うことができます。

訳注

本記事では言及されていませんが、!付きのObject#try!は実質ぼっち演算子と同じに使えます。
ぼっち演算子が#try!と少し異なるのは、引数付きだとnilのときに引数が評価されないという点です。
参考: Safe Navigation Operator で呼ばれるメソッドの引数はレシーバが nilなら評価されない

関連記事

Railsの`CurrentAttributes`は有害である(翻訳)

Rubyスタイルガイドを読む: クラスとモジュール(2)クラス設計・アクセサ・ダックタイピングなど


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。