【アンチパターン】全部nil(null)かもしれない症候群

はじめに:Rubyの&.演算子やtryメソッドについて

Rubyには&.という演算子があります。
この演算子を使うと、メソッドのレシーバがnil(他の言語でいうところのnull)であっても、メソッド呼び出し時にエラーが起きません。(nilが返る)

# ユーザが見つかる場合
user = find_user('Alice')
user&.name #=> "Alice"

# ユーザが見つからない場合(userがnilの場合)
user = find_user('Foo')
user&.name #=> nil

同じようなメソッドに、Railsのtryメソッドがあります。

# ユーザが見つかる場合
user = find_user('Alice')
user.try(:name) #=> "Alice"

# ユーザが見つからない場合(userがnilの場合)
user = find_user('Foo')
user.try(:name) #=> nil

&.演算子やtryメソッドを多用しすぎることの問題

&.tryを使うと、nilかどうかの条件分岐を書かなくて済むので、非常に便利です。
ですが、こうしたテクニックを多用しすぎると、奇妙なコードを作り出す原因になります。

たとえば次のようなコードです。

def find_friend_message(name)
  friend = self.user&.friends&.find_by(name: name)
  if friend&.message.blank?
    'no message'
  else
    friend&.message
  end
end

上のコードでは&.が4箇所も出てきます。

とりあえず、一番最後に出てくるfriend&.message&.は不要です。
なぜなら上の条件分岐(if friend&.message.blank?)で、friendnilでないことが保証されているからです(にもかかわらず、&.をおまじないのように付けまくる人はよく見かけます)。

しかし、それを無視したとしても、まだ&.が3箇所も出てきます。
&.だとさらっと書けてしまうので、問題が見えづらいですが、上のコードは次のようなコードを書いていることと同じです。

def find_friend_message(name)
  unless self.user.nil?
    unless self.user.friends.nil?
      friend = self.user.friends.find_by(name: name)
      unless friend.nil?
        unless friend.message.blank?
          return friend.message
        end
      end
    end
  end
  'no message'
end

こんなロジック見せられたら、普通は「うえっ」ってなりますよね。
でも&.によって消臭されてるだけで、実際やってることはこれと同じなわけです。

オブジェクトがnil/nullになる可能性がある言語は、大なり小なり、「nil/nullに対してメソッドを呼びだして爆死💥☠️」のリスクがあります。
しかし、そのリスクを過剰に恐れてしまうと、上のコードのように「これはnilかもしれない、これもnilかもしれない、全部nilかもしれない・・・!!!」と思って、&.tryを連発してしまうことになります。

いわば「全部nil(null)かもしれない症候群」です。

この問題の処方箋

「全部nil(null)かもしれない症候群」を治すためには、次のようなアプローチを取るのが良いでしょう。

モデルやメソッドの設計をきちんと確認する

まず、&.tryを使う前に「これはnilかもしれない」ではなく、「ここはnilが来る可能性が十分ある」なのか、「nilは通常あり得ない」なのかをハッキリさせる必要があります。

そのためにシステムのデータ構造(DB設計やDB制約、バリデーション処理等)や、呼びだしたメソッドの戻り値をきちんと確認して、nilが来るのか、来ないのかをハッキリさせましょう。

エラーを恐れない

nilは通常あり得ない」のであれば、やみくもに&.tryを付けるのはやめましょう。
通常あり得ないのに、nilがやってきたのであれば、それは異常事態です。
実行時エラーを発生させてプログラムを停止させ(&.を使わなければエラーが発生します)、nilがやってきた原因を調査すべきです。

下手にnilを許容すると、そこで問題が起きなくても、他の場所で別の問題を引き起こす恐れがあります。

また、コードを読んだ人間も「ここ、&.を使ってるけど、nilのケースがあるのかな・・・??」と首をかしげることになります。
つまり、一種の可読性の低下につながります。

ガード条件をうまく使う

nilがありえる、なおかつ、nilでないときだけ処理したい」という場合は、&.を連発するよりも、ガード条件を使った方が可読性がよくなることがあります(まあ条件分岐が多い問題は解決してないのですが)。

def find_friend_message(name)
  no_msg = 'no message'

  return no_msg if self.user.nil?

  friend = self.user.friends.find_by(name: name)
  return no_msg if friend.nil?

  friend.message.blank? ? no_msg : friend.message
end

そもそもの実装を見直す

もし、&.を連発しているのであれば、そもそも何か根本的な間違いをしている可能性もあります。
クラス設計やメソッドの責務を見直すことで、きれいな実装に直せるかもしれません。

まとめ

&.演算子やtryメソッドは便利ですが、多用しすぎると逆にデメリットが出てきます。
「全部&.を付けておけば、エラーが起きないからあんしーん!」ではなく、

  • 原則、&.は付けない。本当にnilを考慮すべきタイミングに限って使用する
  • &.を連発してしまったら、自分の設計や実装を疑ってみる

と考えるようにしてください。