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を使っている場合、データの完全性を確実にする方法はかなりシンプルです。ここで押さえておくべきは、テーブルの新規作成時に適切な制約を追加することです。これはデータベースレベルで行う必要があることを忘れてはいけません。モデルでのバリデーションは簡単にバイパスされてしまうことがあるため、まったく不十分です。
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: "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
これで、必要に応じていつでも通知を送信できるようになりました。さて、通知を送信したくないときはどうすればよいでしょうか。そんなときの残念な方法といえば、mailer
にnil
を渡して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_id
がnil
になる可能性があるとします。
上のコードは次のように書き直せます。
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なら評価されない
概要
原著者の許諾を得て翻訳・公開いたします。
原文タイトルは、よくあるヨーダのセリフのもじりです。