Good Code, Bad Codeの擬似コードをDart(Flutter)でリライトして理解を深める 〜エラー編〜

IGDのテックチームに所属するエンジニアの江澤です。

本記事はGood Code, Bad Codeを正しく深く理解するために、書籍内で登場する擬似コードをDartでリライトして要点を解説する記事です。
※書籍名:Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考 – 秀和システム あなたの学びをサポート!

本記事は3記事目で、過去記事は以下になります。

1記事目:

2記事目:

1. エラー編の要点

1-1. エラーの種類について

普段私たちが開発するソフトウェアは、私たちの記述したコードだけでなく、ユーザの不正な入力や外部システムのダウンなど、バグを含む可能性が大量に潜んでいます。

これらのエラーをすべて避けることはできないので、エラーが発生した際にソフトウェアがどう振る舞うかを十分に検討しなければ、堅牢で信頼できるコードは書けません。

エラーへの対処を考えるにあたって、エラーは大きく2種類に分けられます。
それはプログラムにとって回復可能なエラーと回復不可能なエラーで、この2つを区別することでエラーを適切に処理することができます。

回復可能なエラー

正しく対処することで回復が可能なエラーのことで、多くのエラーがこれに該当します。

例えば、ユーザが正しくない形式の電話番号を入力するケースではたいてい、無効な電話番号入力を受け付けてアプリ全体がクラッシュするようなことはなく、不正な入力であることをユーザに通知した上でシステムを継続します。他にも、ネットワークエラーやログ送信のような深刻ではないタスクエラーなどが回復可能なエラーに含まれます。

回復不可能なエラー

エラーから回復する方法が思いつかないようなエラーに該当します。
多くの場合、エンジニアの実装ミスによるプログラミングエラーによって引き起こされます。

例:

・コードと一緒に読み込むべき画像やテキストファイルなどのリソースがない
・無効な入力や必要な初期化の欠如などのコードの誤用

1-2. エラーの扱いについて

エラーが発生した際は、次の選択肢のどれかを選択する必要があります。

・処理に失敗したと判断して上位レイヤーにエラーの処理を任せるか、プログラム全体をクラッシュさせる
・エラーを処理して動作を続ける

動作を続けるほうがコードの堅牢性につながる一方で、エラーが無視されることで想定外の挙動が発生することにもつながります。
そのため、失敗の度合いに応じて堅牢性を優先するのか、失敗を優先するのかを判断することが重要になります。

失敗に対処する基本方針としては、エラーが起きた際は早く失敗したほうがよく、回復不可能ならばさらに目立って失敗したほうがよいです。

早く失敗するとは、プログラムが処理に失敗したまさにその場所で失敗することで、これによってエンジニアのデバッグを容易にします。

目立って失敗するとは、失敗したことをエンジニアが見逃さないような形で失敗することです。回復不可能なエラーはリリース前であればすべて取り除かれるべきですし、リリース後であればできるだけすぐに修正する必要があります。そのために、失敗した場合にはプログラム全体をクラッシュさせたり、エラーのログをエンジニアが確実に気づける方法で記録・監視することで失敗を見逃さないようにすることが大切になってきます。

補足

Dartでは、エラーの種類に応じてExceptionオブジェクトとErrorオブジェクトを使い分けることを推奨しています。

・Exception:プログラム自体に問題はない(あるべき姿になっている)ものの、外部システム等に起因して実行時に発生するエラー。発生元はExceptionをthrowして呼び出し元に伝え、呼び出し元はExceptionをキャッチして回復を試みる。

・Error:プログラム自体のエラー(あるべき姿ではない)。発生元はErrorをthrowし、呼び出し元はErrorをキャッチせずプログラムを失敗させる。エンジニアはプログラムの失敗を知り、コードを修正する。

Exceptionは回復可能なエラーなので早く失敗して回復を試みるべきで、Errorは回復不可能なエラーなので早く目立って失敗することで修正を目指すことを意図していると解釈できます。

参照元

・Exception: ・Error: Effective Dart: Usage – Error handling – DO throw objects that implement Error only for programmatic errors:

エラーは隠さない

堅牢性を意識しすぎるあまりに、あるいはコードの見た目のシンプルさを気にするあまりに、エラーを隠してしまうことがあります。しかし、エラーを隠すことでエラーを無視したまま動作を継続させることに繋がり、ソフトウェアが適切な役割を果たせなくなってしまいます。

回復可能/不可能なエラーのどちらの場合でもエラーを隠すことは問題になりうるため、基本的には避けるようにしましょう。

エラーを隠すパターンはいくつかあります。これらのパターンはエラー処理以外の場面では役に立つことがありますが、エラー処理の場面では基本的に不適切です。いずれのパターンも、エラー発生コードの呼び出し元はコードが正常に処理されたと判断して後続の処理を続けてしまうため、早い失敗と目立つ失敗の原則を破ることになります。

概要

デフォルトの値を返す

エラーが起きて期待された値を返すことができない場合に、デフォルトの値を返す

nullオブジェクトパターン

デフォルト値の概念を拡張して、空のリストなどデフォルト値よりも複雑なオブジェクトを返す

何もしない

問題のコードが何も返さないコードであれば、エラーが起きたことを通知しない

エラーを握りつぶす

下位レイヤーのコードから通知されたエラーをキャッチしたうえで、上位レイヤーのコードに対してエラーが起きたことを通知しない(ログを記録する処理を施したとしても、呼び出し元からエラーを隠していることには変わりない)

エラーは通知する

エラーが起きた際は、回復可能/不可能なエラーのどちらにおいても、一般には上位レイヤーのプログラムに通知する必要があります。
利用している言語のサポート有無に依存するところはあるものの、エラーを通知する方法として取りうる選択肢としては大まかには以下の2種類に分けられます。

■明示的

・エラーが起こり得るコードは、そのエラー処理を直近の呼び出し元に強制する

・コードでの契約により、呼び出し元は自身でエラーを処理するか、次の呼び出し元にエラー処理を委ねるか、あるいは無視するかを決めなければならない

■暗黙的

・エラーが起こり得るコードから通知されるエラーについて、上位レイヤーの呼び出し元は気にする必要がない

・呼びだしたコードでエラーが起こるかどうか、というところからドキュメントを読むなどして意識的に確認する必要がある

この2種類の通知テクニックを駆使することで、エラーを認識させる必要があるシナリオとそうでないシナリオの両方に対応することができます。

明示的にエラーを通知するテクニック

暗黙的にエラーを通知するテクニック

コードでの契約上の位置

確実に無視できない場所

ドキュメントやコメント、あるいはそれも存在しない

呼び出し元はエラーが起こることを認識できるか

確実にできる

できない場合もある

テクニックの例

検査例外
null許容型の戻り値
オプショナル型の戻り値
Result型の戻り値
エラーを示す戻り値
Swiftのエラー

非検査例外
マジックバリューの戻り値
アサーション
Promise型/Future型の戻り値
検査
パニック

回復不可能なエラーは、早く目立って失敗することが最善です。
暗黙的なテクニックを使うことで、呼び出し元の呼び出し元といったチェーンの上位レイヤーにあたるすべての呼び出し元がエラーを認識して処理する必要がなくなります。
回復不可能なエラーでは、次の呼び出し元にエラーを通知する以外に呼び出し元ができることはないため、無駄なコードを減らすという観点で合理的です。

回復可能なエラーは、エラーを通知するためのベストプラクティスについてソフトウェアエンジニアの間で意見が分かれています。

支持する方法

理由

詳細

非検査例外

コードの構造を改善する

エラーを処理できない途中のレイヤーがエラー処理コードを無駄に抱えなくて良くなる

エンジニアの問題行動に目をつむる

すべての呼び出し元でエラーを処理することにうんざりして、エラーをキャッチしても何もしないなどの問題のあるコードを書き始めることを回避できる

明示的テクニック

適切なエラー処理:呼び出し元に潜在的なエラーを強制的に認識させることは、それらのエラーを適切に処理する機会を増やすことにつながる

呼び出し元に潜在的なエラーを強制的に認識させることは、それらのエラーを適切に処理する機会を増やすことにつながる

レビュワーがエラーを見落とすことがない

エラーをキャッチして何もしないなどの間違いは、コードでの契約を露骨に違反する形でしかできないため、レビュー段階で取り除かれる可能性が高い

エンジニアの問題行動に目をつむる

非検査例外についてドキュメントに適切に記述されることは著者の経験上ほとんどないが、それによる様々な問題を回避できる(ドキュメントなしでエラーを処理できるため)

著者の意見は明示的なテクニックを使うというものでした。ぜひ本書を手にとってその理由を確認してみてください!

2. コードの解説

2-1. 題材のコードについて

以下の、何のエラー処理も記述されていない平方根の値を計算する関数を題材として、エラー処理に関するGood CodeとBad Codeについて学びます。

※Bad Codeに関しては、書籍では銀行口座のサービスを模したコードを使ってわかりやすく例示されていますが、この記事ではGood Codeとの違いを示すために平方根の値を計算する関数に統一して説明しています。

// 平方根の値を計算する関数
Double getSquareRoot(Double value) {
    return Math.sqrt(value);
}

2-2. Bad Codeの解説

デフォルト値でエラーを隠すパターン

デフォルト値でエラーを隠すパターンのコードを以下に記載します。

Double getSquareRoot(Double value) {
    if (value < 0.0) {
        return 0.0;
    }
    return Math.sqrt(value);
}

エラーが起きた際の対応として、デフォルトの値を返すようにすれば、一見コードの見た目はシンプルになった気がします。
エラーを通知・処理するコードを記述するのと比べると考えることが少なくて良いようにも思えます。

ですが、デフォルト値を返すことで問題になるのは、エラーが起こったという事実を隠し、まるで処理が成功しているように振る舞ってしまうことです。
上記のコードでは、マイナスの入力を受け取った場合にまるで0の値を受け取ったかのように振る舞ってしまうので、後続の処理で思わぬ障害が発生してしまうかもしれません。
このことから、エラー処理の文脈においてエラーの代わりにデフォルト値を返すことは基本的には避けるべきです。

ちなみに書籍では、銀行口座の残高にアクセスした際にエラーが発生するとデフォルト値の0が返されるという例え話が説明されていました。

エラーを握りつぶすパターン

次に、エラーが起きた際にエラーを握りつぶすパターンについて紹介します。
前提として、エラーが起きた際にそれを通知しないことは基本的に良くありません。デフォルト値でエラーを隠すパターンと同様、呼び出し元が「処理が正常に完了した」と想定してしまうことでバグが発生する可能性があります。

では、少し改善してエラーが発生した際にログを記録するようにしましょう。

Double getSquareRoot(Double value) {
    try {
        var sqrtValue = Math.sqrt(value);
    } catch (SqrtException e) {
        logger.logError(e);
    }
    return sqrtValue;
}

これでも本質的にはエラーを通知しないこととほとんど変わりません。

エンジニアがログを見ればエラーに気づけるかもしれないので何もしないよりはほんの少しマシですが、呼び出し元は相変わらず処理に失敗したことに気づけないので、バグが発生する可能性があります。

2-3. Good Codeの解説

エラーが起きた際にそのことを上位レイヤーに通知する方法は、明示的なテクニックと暗黙的なテクニックに分けれられることを1章で説明しました。

ここでは、それぞれのテクニックについていくつかの方法を紹介します。

明示的なテクニック

検査例外

検査例外では、コンパイラはコードに例外処理を書かせるか、あるいは関数のシグネチャーとして例外が発生することを明示させることによって、呼び出し元に例外が起こる可能性を強制的に認識させます。

この検査例外という概念は、JavaやSwiftでサポートされています。残念ながらDartではサポートされていません(書籍ではSwiftが検査例外を提供すると明言されていませんが、検査例外的な仕組みを有しています)。

・エラーを通知する

以下では、getSquareRoot()は負の値が渡された場合に、NegativeNumberExceptionという検査例外をスローしています。NegativeNumberExceptionは、Javaのパラダイムを利用してExceptionクラスを拡張して作成しています。

getSquareRoot()のシグネチャーにはthrows NegativeNumberExceptionが定義されており、検査例外ではこれがないとコンパイルできません。

// クラスは特定の検査例外の型を表す
class NegativeNumberException extends Exception {
    // 追加情報(エラーを起こした値)を包むメンバ変数
    private final Double erroneousNumber;
    NegativeNumberException(Double erroneousNumber) {
        this.erroneousNumber = erroneousNumber;
    }
    Double getErroneousNumber() {
        return erroneousNumber;
    }
}
// 関数は検査例外をスローすることを示す(これがないとコンパイルが通らない)
Double getSquareRoot(Double value) throws NegativeNumberException {
    if (value < 0.0) {
        // エラーが起きた際は検査例外をスローする
        throw new NegativeNumberException(value);
    }
    return Math.sqrt(value);
}

・エラーを処理する

以下のdisplaySquareRoot()では、getSquareRoot()を呼び出してその結果をUIに表示する関数を示しています。例外がスローされた場合、問題のある値をエラーメッセージに表示します。

void displaySquareRoot() {
    Double value = ui.getInputNumber();
    try {
        ui.setOutput("平方根は " + getSquareRoot(value));
    } catch (NegativeNumberException e) {
        // 例外からのエラーの情報を表示する
        ui.setError("次の値の平方根を計算できません: " + e.getErroneousNumber());
    }
}

例外をキャッチしない場合、displaySquareRoot()は例外がスローされることをシグネチャーで示す必要があります。どのように例外を処理するかは、displaySquareRoot()の呼び出し元に委ねられます。

// NegativeNumberExceptionをdisplaySquareRoot()のシグネチャーに示す
void displaySquareRoot() throws NegativeNumberException {
    Double value = ui.getInputNumber();
    ui.setOutput("平方根は " + getSquareRoot(value));
}

Result型

null許容型でエラーを通知することの問題点として、エラーの理由(つまり具体的なErrorオブジェクト)を伝えることができない点があります。
Result型を使うことで、呼び出し元が値を利用できないことに加えてエラーの理由も合わせて伝えることができます。

RustやSwiftなどResult型をサポートする言語もありますが、そうでない場合に独自にResult型を定義するなら以下のようなコードになります。

// ジェネリクスを利用して任意の型の値とエラーを利用できるようにする
class Result<V, E> {
    private final Optional<V> value;
    private final Optional<E> error;
    // 呼び出し元に静的なファクトリー関数を使わせるためにprivateにする
    private Result(Optional<V> value, Optional<E> error) {
        this.value = value;
        this.error = error;
    }
    // 値かエラーのどちらかでしかResult型オブジェクトを生成できないようにする
    static Result<V, E> ofValue(V value) {
        return new Result(Optional.of(value), Optional.empty());
    }
    static Result<V, E> ofError(E error) {
        return new Result(Optional.empty(), Optional.of(error));
    }
    Boolean hasError() {
        return error.isPresent();
    }
    V getValue() {
        return value.get();
    }
    E getError() {
        return error.get();
    }
}

言語がResult型をサポートしていたり、独自にResult型を実装したうえでエンジニアがその扱いに馴染みがあるならば、Result型を戻り値として利用することはエラーが起きることを明確にします。null許容型ではコメントでその意味を説明する必要がありましたが、Result型ではその意味が内包されているのでエラーを通知する型であることは明らかです。

したがって、Result型の利用はエラーを通知する明確なテクニックになります。

・エラーを通知する

Result型を使ってエラーを通知します。

ここでは、戻り値をResult型としています。NegativeNumberErrorは独自のエラーで、getSquareRoot()で起きる可能性があるエラーに関する追加の情報を持つクラスとして定義しています。


// 負の値を受け取った場合のエラーを定義
class NegativeNumberError extends Error {
    private final Double erroneousNumber;
    NegativeNumberError(Double erroneousNumber) {
        this.erroneousNumber = erroneousNumber;
    }
    Double getErroneousNumber() {
        return erroneousNumber;
    }
}
// Result型を返し、NegativeNumberErrorが起こりうることを示す
Result<Double, NegativeNumberError> getSquareRoot(Double value) {
    if (value < 0.0) {
        return Result.ofError(new NegativeNumberError(value));
    }
    return Result.ofValue(Math.sqrt(value));
}

このようにResult型を使うことで、呼び出し元に対してエラーに関する追加の情報を通知することができます。

・エラーを処理する

書き手にとってgetSquareRoot()がResult型を返すことは明らかなので、呼び出し元はエラーが起こっていないかを検査し、エラーが起きているならその詳細にアクセスできます。

void displaySquareRoot() {
    Result<Double, NegativeNumberError> squareRoot = getSquareRoot(ui.getInputNumber());
    if (squareRoot.hasError()) {
        ui.setError("負の値から平方根は計算できません: " + squareRoot.getError().getErroneousNumber());
    } else {
        ui.setOutput("平方根は " + squareRoot.getValue());
    }
}

null許容型

関数がnullを返すことは、ある値を計算できないことを示すうえで有効です。
使用する言語がnull安全をサポートする場合、呼び出し元は戻り値がnullである可能性を認識し、適切に処理する必要があります。したがって、null安全がサポートされる言語であれば、null許容型を戻り値の型として使用することで、明示的にエラーを通知できます。

・エラーを通知する

null許容型を使ってエラーを通知します。

ここでは、負の値が渡された際にnullを返すようにしています。nullを使ってエラーを通知する際に気をつけるべき点として、nullがどういう意味を持つのかをコメントやドキュメントで説明する必要があります。nullはエラーを通知するためだけの値ではないので、意味が説明されていないとコードの読み手はエラーであることを認識できません。

// ポイント:↓なぜnullを返すのかを説明するコメントが必要
// 負の値が渡された場合はnullを返す
Double? getSquareRoot(Double value) {  // Double?型の名前にある「?」はnullが返される可能性を示す
    if (value < 0.0) {
        // エラーが起きた場合はnullを返す
        return null;
    }
    return Math.sqrt(value);
}

・エラーを処理する

null安全をサポートする言語の場合、呼び出し元はgetSquareRoot()の戻り値を利用する際にnullの可能性を検査することを強制されます。

void displaySquareRoot() {
    Double? squareRoot = getSquareRoot(ui.getInputNumber());
    // ポイント:getSquareRoot()の戻り値がnullかどうかの検査が強制される
    if (squareRoot == null) {
        ui.setError("負の値から平方根は計算できません");
    } else {
        ui.setOutput("平方根は " + squareRoot);
    }
}

呼び出し元は戻り値のnull許容型を非null許容型にキャストできるので、必ずしも戻り値がnullかどうかの検査が強制されるとは限りません。
しかし、キャスト自体は戻り値がnullである可能性を強制的に認識させられた上で意図的になされるので、エラーを通知するための明示的な方法となります。

※書籍では、null安全をサポートしない言語のためにオプショナル型を使った明示的なテクニックについても触れています。

暗黙的なテクニック

非検査例外

非検査例外を使う場合、コードの読み手はエラーがスローされることに気付けない可能性があります。
コードでの契約として呼び出し元がエラーに気づくための強制的な仕組みが用意されていないので、非検査例外は暗黙的なテクニックとなります。

・エラーを通知する

エラーを通知する方法が非検査例外に変わったので、getSquareRoot()はエラーをスローすることをシグネチャで示す必要がなくなりました。
ただ、エラーをスローする可能性があることに変わりはないので、コメントに記載することが推奨されます。

class NegativeNumberException extends RuntimeException {
    private final Double erroneousNumber;
    NegativeNumberException(Double erroneousNumber) {
        this.erroneousNumber = erroneousNumber;
    }
    Double getErroneousNumber() {
        return erroneousNumber;
    }
}
// スローする可能性がある非検査例外をドキュメントとして残すことを推奨
/*
@throws NegativeNumberException 値が負の場合
*/
Double getSquareRoot(Double value) {
    if (value < 0.0) {
        throw new NegativeNumberException(value);
    }
    return Math.sqrt(value);
}

・エラーを処理する

エラーを処理する場合は、コードは検査例外と変わりません。

void displaySquareRoot() {
    Double value = ui.getInputNumber();
    try {
        ui.setOutput("平方根は " + getSquareRoot(value));
    } catch (NegativeNumberException e) {
        ui.setError("次の値の平方根を計算できません: " + e.getErroneousNumber());
    }
}

エラーを処理しない場合でも、エラーをスローすることをシグネチャで示す必要はありません。
それでも、非検査例外なので問題なくコンパイルできます。
NegativeNumberExceptionがgetSquareRoot()からスローされた場合は、エラーをキャッチする上位レイヤーの呼び出し元まで伝播するか、キャッチされなければプログラムが終了します。

void displaySquareRoot() {
    Double value = ui.getInputNumber();
    ui.setOutput("平方根は " + getSquareRoot(value));
}

2-4. Dartによるリライト

2-3で説明した擬似コードのうち、検査例外を除いた3つ(非検査例外、null許容型、Result型)をDart用にリライトしました。

まず準備として、今後共通で使うコードを準備します。

// mathライブラリのインポート
import "dart:math";
// UIクラスの定義
class UI {
  double getInputNumber() {
    return 4;
  }
  void setOutput(String output) {
    print(output);
  }
  void setError(String error) {
    print(error);
  }
}
// UIクラスをインスタンス化
final ui = UI();
// エラー型の定義
class NegativeNumberException implements Exception {
  const NegativeNumberException(this._erroneousNumber);
  final double _erroneousNumber;
  double getErroneousNumber() => _erroneousNumber;
  
  String toString() {
    return "負の値は計算できません: $_erroneousNumber";
  }
}

非検査例外

Dartでエラー処理をする場合、まず検討するのはこの非検査例外を使う方法だと思います。

// 非検査例外をスローする
// ポイント①:エラーをスローする旨のコメントを記載する
/*
@throws NegativeNumberException 値が負の場合
*/
double getSquareRoot(double value) {
  if (value < 0) {
    throw NegativeNumberException(value);
  }
  return sqrt(value);
}
// 非検査例外をキャッチする
void displaySquareRoot() {
  final value = ui.getInputNumber();
  try {
    final squareRoot = getSquareRoot(value);
    ui.setOutput("平方根は $squareRoot");
  } on NegativeNumberException catch (e) {  // ポイント②:on句で非検査例外をキャッチする
    ui.setError(e.toString());
  }
}
// 非検査例外をキャッチしない
void displaySquareRoot() {
  final value = ui.getInputNumber();
  try {
    final squareRoot = getSquareRoot(value);
    ui.setOutput("平方根は $squareRoot");
  } on NegativeNumberException catch (e) {
    rethrow;  // ポイント③:非検査例外をキャッチしない場合はrethrowで呼び出し元に通知する意図を明確にする
  }
}

ポイント①

エラーをスローする場合には、その旨のコメントを記載することが大切です。
コメントを記載することで、コードの読み手は処理を追加する際にエラーを処理する必要があるかどうかを認識できます。

ポイント②

Dartでtry-catchを使う場合には、on句で明示的に非検査例外をキャッチすることが推奨されます。
そうすることで、実装者が認識していないエラーをキャッチしてしまい想定外の処理が発生する事態を回避できます。
また、たとえ認識していないエラーをキャッチしたいニーズがあったとしても、on句でExceptionをキャッチすることが推奨されます。
Exceptionはすべての実行時エラーの基本クラスであり、Exceptionをキャッチすることで実行時エラーはすべてキャッチされることになります。
プログラミングエラーであるErrorは含まれないので、回復不可能なエラーを早く目立って失敗することは妨げられません。

参照:

ポイント③

擬似コードでは、非検査例外をキャッチしない場合は特に何の処理も記述していませんでしたが、Dartではrethrowを使うことで「エラーを処理せずに呼び出し元に委ねる」という意図を示すことができます。
検査例外とは違い強制されるものではなく、あくまで意識的に記述する必要がありますが、Dartではエラー処理を呼び出し元に委ねる場合はrethrowを使うことが推奨されます。
上記のリライトしたコードでは意図を示す以上の意味を持ちませんが、スタックトレースを維持したまま上位レイヤーに通知するなどの利点があります。

参照:

null許容型

次に、null許容型を使ったエラー通知のコードを紹介します。
Dartはnull安全をサポートしているので、null許容型によるエラー通知ができます。

// nullを返す
double? getSquareRoot(double value) {
  // ポイント:負の値が渡された場合はnullを返す
  if (value < 0) {
    return null;
  }
  return sqrt(value);
}
// nullを処理する
void displaySquareRoot() {
  double? squareRoot = getSquareRoot(ui.getInputNumber());
  if (squareRoot == null) {
    ui.setError("負の値から平方根は計算はできません");
  } else {
    ui.setOutput("平方根は $squareRoot");
  }
}

ポイントは、nullを返す意味をコメントに記載することでした。

null自体はエラーを通知するためだけの値ではないので、コメントでその意味を読み手に伝える必要があります。

Result型

最後に、Result型を使ったエラー通知について紹介します。
DartではResult型がサポートされていませんが、Dartの文法を使えばスマートに定義できます。

// Result型の定義
// ポイント①:sealed classでResult型を定義することで、Success型とFailure型の2つのサブクラスを持たせる
sealed class Result<V, E> {
  factory Result.success(V value) = Success._;
  factory Result.failure(E exception) = Failure._;
}
final class Success<V, E> implements Result<V, E> {
  const Success._(this.value);
  final V value;
}
final class Failure<V, E> implements Result<V, E> {
  const Failure._(this.exception);
  final E exception;
}
// Result型を返す
Result<double, NegativeNumberException> getSquareRoot(double value) {
  if (value < 0) {
    // ポイント②:呼び出し元にエラーの理由を伝えることができる
    return Result.failure(NegativeNumberException(value));
  }
  return Result.success(sqrt(value));
}
// Result型を処理する
// ポイント③:Result型はsealed classで定義したので、パターンマッチングを利用して処理できる
void displaySquareRoot() {
  final result = getSquareRoot(ui.getInputNumber());
  switch (result) {
    case Success(value: final value):
      ui.setOutput('平方根は $value');
    case Failure(exception: final exception):
      ui.setError(exception.toString());
  }
}

ポイント①
これはDartならではのテクニックですね。

Result型をsealed classとして定義し、そのサブクラスとしてSuccess型とFailure型を持たせます。
値やエラーはそれぞれのサブクラスごとに、ジェネリクス型のプロパティとして定義します。
これによって、擬似コードのofValueやofErrorのようなメソッドを実装する必要がなくなります。

また、SuccessとFailureをprivateコンストラクタで実装し、Resultからfactoryコンストラクタでオブジェクトを取得できるように実装することで、コードでの契約(前章を参照)によってSuccessとFailureはResult経由でしか取得できなくなります。

これによってResult型を返す関数の実装者は、ResultとSuccess/Failureの関係性を知らなくてもResult型だけに着目して実装することができます(コード補完が効くようになります)。

補足:ジェネリクス型の制約について
ポイント①の内容からは少し外れますが、ジェネリクス型のEは意図的にException型の制約をつけていません。これは、Dartの言語設計として任意のオブジェクトをthrowできるようになっていることを踏まえているためで、Dartでthrow・catchできるオブジェクトがResult型では許容されないというのは言語設計に反します。

NegativeNumberExceptionのようなエラー内容を説明するオブジェクトをthrowしてon句でcatchすべきという基本方針には変わりないので、Failureのプロパティにはそのようなオブジェクトを与えることが推奨されますが、強制はされません。

ポイント②
null許容型ではなくResult型でエラーを通知することのメリットとして、呼び出し元にエラーの理由を伝えることができる点がありました。

Dartでも、Failureサブクラスにexceptionを持たせてreturnすることで、呼び出し元にエラーの理由を伝えることができます。

ポイント③
ポイント①でResult型はsealed classで定義したことで、パターンマッチングの恩恵を受けることができます。

擬似コードではif/else文で処理を記述していたのに対し、このコードではコードでの契約によってサブタイプの完全な列挙を強制できるので、余計な条件分岐が入り込む余地がありません。
また、擬似コードのhasErrorのようなメソッドを実装する必要もなくなります。

おわりに

ここまでPart1~3を通して「Good Code, Bad Code」で紹介されているコード品質を向上するための戦略について解説しながら、Dartのリライトをテーマに保守性の高いコードを書く方法をまとめてみました。Dart(Flutter)でモバイルアプリを開発している方や、Dartに馴染みのない方にも役立つ記事になっていると嬉しいです。

本ブログで紹介したPart1~3は書籍における理論編に位置付けられており、この後は実践編としてより具体的な事例を挙げながらコード品質を改善する方法について述べられています。ブログを読んで興味を持っていただけた方は、ぜひ書籍を手に取ってより実践的な方法も確かめてみてください!