🦔

[Swift] 戻り値nilの意図を明確にする

2021/04/13に公開

戻り値nilの意図を考える

メソッドの戻り値をOptionalにする理由は大きく分けて二つあります。

  1. nilが結果が空であることを示すとき (e.g. Array.first)
  2. nilがエラーを示すとき

まず、このどちらであるのかがメソッド利用者にとって明確である必要があります。

func findSomething(a: A) -> B? {

この例は、引数aを使ってB型の何かを探し、結果が見つからない時にnilが返るのだろうと推測できます。(もしそうでなかったら大問題です。)

func doSomeWork(a: A) -> B? {

一方、こちらはnilが何を示すのか伝わらない可能性があります。できればコード自身を改善すべきですが、コメントが役に立つ場合もあります。

// ○○を行う。○○の場合nilを返す。
func doSomeWork(a: A) -> B? {

戻り値nilでエラーを示す

意図が明確である限り、結果が空であることを示す用途は通常問題ありません。エラーを示す場合について細かく見てみましょう。エラーを返したいのは次のような場合です。

  1. 引数が不正な値である場合
  2. ディスクアクセスなどの排除不能な実行時エラー

引数が不正な値の時にnilを返す

引数が不正な値である場合、その責任はメソッド呼び出し側にあります。まず第一に、不正な引数ではメソッドを呼べないようにすることを検討すべきです。

func someMethod(n: Int?, url: String) -> String? {
    guard let n = n, let url = URL(string: url) else {
        return nil
    }
    // do something
}

このようなコードはよく見かけるものです。n: Int?n: Intに、url: Stringurl: URLに変更することで戻り値をStringにすることができるならそうするべきです。

func someMethod(n: Int, url: URL) -> String {
    // do something
}

このようになります。引数としてn: Int?url: Stringを受け付ける方がメソッド呼び出し側のコードがスッキリするということもあるかもしれませんが、そのような理由で処理できない引数を受け付けてはいけません。呼び出し側のコードの不都合は呼び出し側で解決するべきです。

この場合はどうでしょう?

func someMethod(n: Int) -> String? {
    guard n >= 10 else {
        return nil
    }
    // do something
}

値が10以上であることを保証する新しい型を定義して使用することも考えられますが、やりすぎだと感じるならこのような方法もあります。

func someMethod(n: Int) -> String {
    precondition(n >= 10)
    // do something
}

preconditionは与えられた条件をチェックし、条件を満たさない場合はクラッシュさせてしまいますからreturn nilを書く必要がありません。またより重要なこととして、条件を満たさない引数を受け付けないことを明確に主張する効果があります。return nilは、正常に処理した結果のnilなのか正常に処理できないためのnilなのかわかりにくくなりがちです。処理できない引数ならば明確に拒絶の意思を示すべきです。

実行時エラーを示すnilを返す

nilで実行時エラーの発生を示すのは、以下の全ての条件を満たす限り問題ありません。

  1. nilがエラーを示すことがメソッド利用者にとって明確であること
  2. メソッド内部でエラー処理ができないこと
  3. エラー処理にあたってエラーの詳細が必要ないこと

これらの条件を満たさない場合について考えてみます。

エラーであることが明確ではない戻り値nil

この場合、戻り値をOptionalにする代わりにthrowする、あるいはResult型を返す方法があります。エラー種類や詳細が必要なかったとしても意図を明確にするためにこの方法を採る価値があります。既に示した様に、コメントでnilの意味を明記する方法もあります。

メソッド内でエラー処理できる場合

例えば、以下のメソッドはファイルからデータを読み込み、何らかの処理をして結果を返します。

func doSomeWork() -> String? {
   do {
       let data = try Data(contentsOf: self.fileURL)
       // do something
       return result
   } catch {
       return nil
   }
}

一例として、エラーが発生した場合に備えてデフォルト値を用意しておくことができるかもしれません。処理を継続できるようになり、戻り値をOptionalにする必要がなくなります。

func doSomeWork() -> String {
    let data = (try? Data(contentsOf: self.fileURL)) ?? defaultValue()
    // do something
    return result
}

エラー処理において「誰が処理する責務を負うのか」を考えることは重要です。エラーを返すということは「エラーを処理する責務をメソッド呼び出し側に負わせる」ことを意味します。そうしなくて済むように工夫ができる場合もあります。

エラー処理のためにエラーの詳細が必要な場合

この場合はthrowする、あるいはResult型を返す必要があるでしょう。詳細が必要かどうかメソッド自身にとって明確ではない場合は返しておいたほうが無難です。

func doSomeWork() -> String? {
   do {
       let data = try Data(contentsOf: self.fileURL)
       // do something
       return result
   } catch {
       return nil
   }
}

上のメソッドよりも、下のメソッドの方が単純かつ優れている可能性が高いです。

func doSomeWork() throws -> String {
   let data = try Data(contentsOf: self.fileURL)
   // do something
   return result
}

メソッドを呼び出す側にとって、throwsをOptionalに変換するのはtry?するだけですから、非常に簡単なことです。逆の変換は不可能ですから、throwsの方が汎用性があります。メソッド内で直接tryする代わりに別のエラー型を定義してラップすることもできます。

意図がわからないnilが紛れ込む場合

プロジェクト内の全てのコードが上のような方針に沿って書かれていれば良いのですが、そうではない場合もあるでしょう。

func someMethod() -> String? {
    guard let someValue = self.otherClassInstance.otherClassMethod() else {
        return nil
    }
    // do something
}

otherClassInstance.otherClassMethod()の戻り値がOptionalであるためにguardが必要になっています。nilが返ってくることは稀なのですが、nilがどういう意味なのかははっきりしません。意味がはっきりしないため、どのように処理すればいいかもわからなくなってしまいます。詳細にコードを調べればわかるかもしれませんが、とても時間がかかります。この場合簡単で典型的な対処方法はありません。時間をかけて広範囲のコードを改善していく必要があるでしょう。

この例のように、「よくわからないからnilを返しておく」あるいは「深く考えずにとりあえずnilを返してしまう」コードはよく見られますが、これは極めて危険なものです。これをやってしまうと、連鎖的に意図不明のOptionalを返すメソッドを増やしてしまうことになります。こうならないために、Optionalを戻り値にする場合には必ずnilの意図が明確になるように書くべきです。

Discussion