🐈

Dart/FlutterにRustのResult型を導入する

2024/10/08に公開

はじめに

Dartの例外処理にはtry-catchが採用されていますが、エラーハンドリングが煩雑に感じたことはないでしょうか。
try-catchでは、メソッドを呼び出す際にエラーが発生するかどうかや、どんなエラーが起こり得るかは中のコードを詳細に見なければわかりません。
また、大域脱出が可能なため、try-catchを多用しないとエラーがどこまで伝播するのかが不明瞭になりがちです。[1]

そこで、より安全で表現力の高いエラーハンドリングを実現するために、Result型を導入する方法があります。
Result型は複数のプログラミング言語で採用されていますが、その中でも特にRustのResult型は強力で実用的です。

今回はRustのResult型の仕組みをDartに導入し、効率的なエラーハンドリングを実現する方法を紹介します。

RustのResult型の概要

RustのResult型は、エラー処理を安全かつ明示的に行うための標準的な列挙型(enum)です。
Result型は、操作が成功する場合の値と失敗する場合のエラー情報を扱います。
これにより、プログラムの動作が予期しないエラーで中断するのを防ぎ、エラーを事前に処理することが可能になります。

Result型は以下の2つのバリアントを持ちます:

  • Ok(T):操作が成功した場合の結果。Tは成功時に返される値の型です。
  • Err(E):操作が失敗した場合のエラー。Eはエラー時に返されるエラーの型です。

基本的な定義

enum Result<T, E> {
  Ok(T),
  Err(E),
}
  • T: 成功時に返される値の型
  • E: 失敗時に返されるエラーの型

次に、Result型を使ったファイルの読み込み例を示します。

use std::fs::File;
use std::io::{self, Read};

fn read_file(file_path: &str) -> Result<String, io::Error> {
    let mut file = File::open(file_path)?;  // ファイルを開く
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;  // ファイル内容を読み込む
    Ok(contents)  // 成功時にはファイルの内容を返す
}

fn main() {
    match read_file("example.txt") {
        Ok(content) => println!("File content: {}", content),
        Err(e) => println!("Error reading file: {}", e),
    }
}
  • File::openfile.read_to_stringが成功した場合、Okに内容が入って返されます。
  • 失敗した場合、Errにエラー情報が入ります。

?演算子

Rustには?演算子があり、Resultを扱う際にエラー処理を簡潔に書くことができます。
?はResultがOkなら値をアンラップし、Errならそのまま呼び出し元にエラーを返します。(早期リターン

DartにおけるResult型の実装

DartでRustのResult型を定義すると以下のようになります。

sealed class Result<T, E extends Object> {
}

final class Ok<T, E extends Object> implements Result<T, E> {
  const Ok(this.value);
  final T value;
}

final class Err<T, E extends Object> implements Result<T, E> {
  const Err(this.error);
  final E error;
}

RustのenumはDartのsealed classで表現できます。
sealed classで定義したResult型をOkErrにそれぞれ派生させると、RustのResult型と同様の構造を持つことができます。

また、これだけだとswitchのパターンマッチで分岐することしかできないので、Result型が持つ強力なエラーハンドリング機能を活かすために早期リターンの仕組みを再現します。

sealed class Result<T, E extends Object> {
  factory Result(
    Result<T, E> Function(EarlyReturnSymbol<E> $) f, {
    required Result<T, E> Function(dynamic error, StackTrace stackTrace) onError,
  }) {
    try {
      return f(const EarlyReturnSymbol._());
    } on _EarlyReturnException catch (e) {
      return e.error.cast();
    } catch (e, s) {
      return onError(e, s);
    }
  }

  T operator [](EarlyReturnSymbol<E> $);
}

final class Ok<T, E extends Object> implements Result<T, E> {
  const Ok(this.value);
  final T value;

  
  T operator [](EarlyReturnSymbol<E> $) {
    return value;
  }
}

final class Err<T, E extends Object> implements Result<T, E> {
  const Err(this.error);
  final E error;

  
  T operator [](EarlyReturnSymbol<E> $) {
    throw _EarlyReturnException(this);
  }
}

final class EarlyReturnSymbol<E extends Object> {
  const EarlyReturnSymbol._();
}

class _EarlyReturnException<E extends Object> implements Exception {
  _EarlyReturnException(this.error);

  final Err<dynamic, E> error;
}

このファクトリコンストラクタは、try-catchの例外処理をResultに変換しつつ、早期リターンを実現します。
早期リターンはコンストラクタから提供される$をResult型のインデックスアクセサに渡すことでOkなら中の値を取り出す、Errなら_EarlyReturnExceptionをthrowし、直後にキャッチして中のErrを返すことで早期リターンを再現しています。[2]

このResultを使ったコード例を示します。

void main() {
  final result = Result<int, Exception>(
    ($) {
      final number = doSomethingToOk()[$]; // 戻り値がOkなので値を取り出す
      final value = doSomethingToErr(number)[$]; // 戻り値がErrなのでここで早期リターンする
      return Ok(value); // ここには到達しない
    },
    onError: (e, s) {
      return Err(e);
    },
  );

  print(result); // Err(Exception('Error'))
}

Result<int, Exception> doSomethingToOk() {
  return Ok(1);
}

Result<int, Exception> doSomethingToErr(int n) {
  return Err(Exception('Error'));
}

関数型メソッド

Result型は元々関数型言語で使われていたため、Resultの中の値を変換する関数型メソッドが豊富に用意されています。
関数型に慣れていないと馴染みがないかもしれませんが、使いこなせるとよりシンプルに記述することも可能です。

その中でもよく使う変換メソッドを抜粋して紹介します。

メソッド 説明 利用例
map Okの中身を入れ替える Okの中の日付文字列をDateTimeに変換する。
map_or Okの中身を入れ替える。Errの場合はデフォルト値を返す Okの中の日付文字列をDateTimeに変換し、Errの場合はDateTime.now()を返す。
map_err Errの中身を入れ替える APIのエラーをカスタムエラーでラップする。
and_then Okを別のResultに変換する try-catchの代わりにResultを返す手続を記述する。
or_else Errを別のResultに変換する 値がなかったときにキャッシュをOkで返し、キャッシュがなければそのままErrを返す。
inspect Okのときに処理を行うが、値はそのまま返す 処理が成功したトーストを表示する。
inspect_err Errのときに処理を行うが、値はそのまま返す 失敗した内容をログに書き込む。

try-catchとResult型の比較

以下はtry-catchとResultの比較したテーブルです。

観点 try-catch Result型
習得コスト
コードの可読性
デバッグのしやすさ △ (※)
エラーハンドリングの厳密さ

説明

  • 習得コスト: try-catchは簡単で直感的ですが、Result型は慣れるまで少し時間がかかります。
  • コードの可読性: Result型はエラー処理が明示的ですが、try-catchは複数のブロックが混在すると可読性が低下しがちです。
  • デバッグのしやすさ: try-catchはスタックトレースでエラー箇所を特定しやすいですが、Result型はスタックトレースの情報がないためデバッグしづらい可能性があります。ただし、カスタムエラーにスタックトレースの情報を持たせることで解決できるため、デバッグのしやすさは改善可能です。
  • エラーハンドリングの厳密さ: Result型はエラー処理を強制するため、厳密なエラーハンドリングが可能です。try-catchはなくても動いてしまうので、常に意識してエラーハンドリングを行う必要があります。

Result型は戻り値でエラーが発生する可能性があることを明示的に示すため、エラーハンドリングを意識して行いやすい反面、
エラーハンドリングを厳密に行う必要がない場面では冗長に感じるかもしれません。

ただ、使い捨てのスクリプト等でなければ、エラーハンドリングを厳密に行わなくていい場面はほとんどないので、
try-catchよりもResult型を使ったエラーハンドリングの方が安全で保守性が高いと考えます。

まとめ

RustのResult型をDartに導入することで、より明確で安全なエラーハンドリングを実現する方法を紹介しました。

Result型は操作の成功と失敗を明示的に扱うため、エラーハンドリングが容易になり、コードの保守性が向上します。
さらに関数型メソッドや早期リターンを活用することで、Dartのエラーハンドリングをシンプルかつ強力に拡張できます。

try-catchに比べて若干習得コストが高い面はあるものの、Result型を活用することで安全性や可読性を向上させることができ、特に大規模プロジェクトでは大きな利点となるでしょう。

最後に、これまでのResult型のコードに関数型メソッド、Future対応、NullableからResultに変換するコードを追加した完成形を示します。

完成形のコード
import 'dart:async';

/// 処理の成否を表すクラス
sealed class Result<T, E extends Object> {
  /// 処理をラップして[Result]を返す
  factory Result(
    Result<T, E> Function(EarlyReturnSymbol<E> $) f, {
    required Result<T, E> Function(dynamic error, StackTrace stackTrace)
        onError,
  }) {
    try {
      return f(const EarlyReturnSymbol._());
    } on _EarlyReturnException catch (e) {
      return e.error.cast();
    } catch (e, s) {
      return onError(e, s);
    }
  }

  /// 非同期処理をラップして[Result]を返す
  static Future<Result<T, E>> async<T, E extends Object>(
    FutureOr<Result<T, E>> Function(EarlyReturnSymbol $) f, {
    required FutureOr<Result<T, E>> Function(
      dynamic error,
      StackTrace stackTrace,
    ) onError,
  }) async {
    try {
      return await f(const EarlyReturnSymbol._());
    } on _EarlyReturnException catch (e) {
      return e.error.cast();
    } catch (e, s) {
      return await onError(e, s);
    }
  }

  bool get isOk;
  bool get isErr;

  /// [Ok]の場合は値を返す
  T? ok();

  /// [Err]の場合はエラーを返す
  E? err();

  /// [Ok]の場合は関数を適用して新しい[Ok]を返す
  Result<U, E> map<U>(U Function(T value) f);

  /// [Ok]の場合は関数を適用して新しい値を返して、[Err]の場合はデフォルト値を返す
  U mapOr<U>(U defaultValue, U Function(T value) f);

  /// [Err]の場合は関数を適用して新しい[Err]を返す
  Result<T, F> mapErr<F extends Object>(F Function(E error) f);

  /// [Ok]の場合は引数の[Result]を返す
  Result<U, E> and<U>(Result<U, E> res);

  /// [Ok]の場合は関数を適用して新しい[Result]を返す
  Result<U, E> andThen<U>(Result<U, E> Function(T value) f);

  /// [Err]の場合は引数のResultを返す
  Result<T, F> or<F extends Object>(Result<T, F> res);

  /// [Err]の場合は関数を適用して新しい[Result]を返す
  Result<T, F> orElse<F extends Object>(Result<T, F> Function(E error) f);

  /// キャストして新しい[Result]を返す
  Result<U, F> cast<U, F extends Object>();

  /// [Ok]の値をキャストして新しい[Result]を返す
  Result<U, E> castOk<U>();

  /// [Err]の値をキャストして新しい[Result]を返す
  Result<T, F> castErr<F extends Object>();

  /// [Ok]の場合は関数を実行して自身を返す
  Result<T, E> inspect(void Function(T value) f);

  /// [Err]の場合は関数を実行して自身を返す
  Result<T, E> inspectErr(void Function(E error) f);

  T operator [](EarlyReturnSymbol<E> $);
}

/// `Ok<void, E>(null)`のシンタックスシュガー
Ok<void, E> ok<E extends Object>() => Ok<void, E>(null);

/// 処理の成功を表すクラス
final class Ok<T, E extends Object> implements Result<T, E> {
  const Ok(this.value);

  final T value;

  
  bool get isOk => true;

  
  bool get isErr => false;

  
  T? ok() => value;

  
  E? err() => null;

  
  Result<U, E> map<U>(U Function(T value) f) {
    return Ok(f(value));
  }

  
  U mapOr<U>(U defaultValue, U Function(T value) f) {
    return f(value);
  }

  
  Result<T, F> mapErr<F extends Object>(F Function(E error) f) {
    return cast();
  }

  
  Result<U, E> and<U>(Result<U, E> res) {
    return res;
  }

  
  Result<U, E> andThen<U>(Result<U, E> Function(T value) f) {
    return f(value);
  }

  
  Result<T, F> or<F extends Object>(Result<T, F> res) {
    return cast();
  }

  
  Result<T, F> orElse<F extends Object>(Result<T, F> Function(E error) f) {
    return cast();
  }

  
  Result<U, F> cast<U, F extends Object>() {
    return Ok(value as U);
  }

  
  Result<U, E> castOk<U>() {
    return cast();
  }

  
  Result<T, F> castErr<F extends Object>() {
    return cast();
  }

  
  Result<T, E> inspect(void Function(T value) f) {
    f(value);
    return this;
  }

  
  Result<T, E> inspectErr(void Function(E error) f) {
    return this;
  }

  
  T operator [](EarlyReturnSymbol<E> $) {
    return value;
  }

  
  bool operator ==(Object other) => (other is Ok) && other.value == value;

  
  int get hashCode => value.hashCode;

  
  String toString() {
    return 'Ok($value)';
  }

  static Ok<T, Object> infer<T>(T value) {
    return Ok(value);
  }
}

/// 処理の失敗を表すクラス
final class Err<T, E extends Object> implements Result<T, E> {
  const Err(this.error);

  final E error;

  
  bool get isOk => false;

  
  bool get isErr => true;

  
  T? ok() => null;

  
  E? err() => error;

  
  Result<U, E> map<U>(U Function(T value) f) {
    return cast();
  }

  
  U mapOr<U>(U defaultValue, U Function(T value) f) {
    return defaultValue;
  }

  
  Result<T, F> mapErr<F extends Object>(F Function(E error) f) {
    return Err(f(error));
  }

  
  Result<U, E> and<U>(Result<U, E> res) {
    return cast();
  }

  
  Result<U, E> andThen<U>(Result<U, E> Function(T value) f) {
    return cast();
  }

  
  Result<T, F> or<F extends Object>(Result<T, F> res) {
    return res;
  }

  
  Result<T, F> orElse<F extends Object>(Result<T, F> Function(E error) f) {
    return f(error);
  }

  
  Result<U, F> cast<U, F extends Object>() {
    return Err(error as F);
  }

  
  Result<U, E> castOk<U>() {
    return cast();
  }

  
  Result<T, F> castErr<F extends Object>() {
    return cast();
  }

  
  Result<T, E> inspect(void Function(T value) f) {
    return this;
  }

  
  Result<T, E> inspectErr(void Function(E error) f) {
    f(error);
    return this;
  }

  
  T operator [](EarlyReturnSymbol<E> $) {
    throw _EarlyReturnException(this);
  }

  
  bool operator ==(Object other) => (other is Err) && other.error == error;

  
  int get hashCode => error.hashCode;

  
  String toString() {
    return 'Err($error)';
  }

  static Err<dynamic, E> infer<E extends Object>(E error) {
    return Err(error);
  }
}

typedef FutureResult<T, E extends Object> = Future<Result<T, E>>;

extension ResultToFutureExtension<T, E extends Object> on Result<T, E> {
  FutureResult<T, E> toAsync() => Future.value(this);
}

/// [Future]にラップされた[Result]をそのまま扱うための拡張メソッド
extension FutureResultExtension<T, E extends Object> on FutureResult<T, E> {
  Future<bool> get isOk async => (await this) is Ok;
  Future<bool> get isErr async => (await this) is Err;

  Future<T?> ok() async {
    return (await this).ok();
  }

  Future<E?> err() async {
    return (await this).err();
  }

  FutureResult<U, E> map<U>(FutureOr<U> Function(T value) f) async {
    return switch (await this) {
      Ok(:final value) => Ok(await f(value)),
      Err(:final error) => Err(error),
    };
  }

  Future<U> mapOr<U>(U defaultValue, FutureOr<U> Function(T value) f) async {
    return (await this).mapOr(defaultValue, f);
  }

  FutureResult<T, F> mapErr<F extends Object>(
    FutureOr<F> Function(E error) f,
  ) async {
    return switch (await this) {
      Ok(:final value) => Ok(value),
      Err(:final error) => Err(await f(error)),
    };
  }

  FutureResult<U, E> and<U>(Result<U, E> res) async {
    return (await this).and(res);
  }

  FutureResult<U, E> andThen<U>(
    FutureOr<Result<U, E>> Function(T value) f,
  ) async {
    return switch (await this) {
      Ok(:final value) => await f(value),
      Err(:final error) => Err(error),
    };
  }

  FutureResult<T, F> or<F extends Object>(Result<T, F> res) async {
    return (await this).or(res);
  }

  FutureResult<T, F> orElse<F extends Object>(
    FutureOr<Result<T, F>> Function(E error) f,
  ) async {
    return switch (await this) {
      Ok(:final value) => Ok(value),
      Err(:final error) => await f(error),
    };
  }

  FutureResult<U, F> cast<U, F extends Object>() async {
    return (await this).cast();
  }

  FutureResult<U, E> castOk<U>() => cast();

  FutureResult<T, F> castErr<F extends Object>() => cast();

  FutureResult<T, E> inspect(void Function(T value) f) async {
    return (await this).inspect(f);
  }

  FutureResult<T, E> inspectErr(void Function(E error) f) async {
    return (await this).inspectErr(f);
  }

  Future<T> operator [](EarlyReturnSymbol<E> $) async {
    return (await this)[$];
  }
}

/// Nullableな値を[Result]に変換するための拡張メソッド
extension NullableToResultExtension<T> on T? {
  Result<T, E> okOr<E extends Object>(Result<T, E> error) {
    return switch (this) {
      final value? => Ok(value),
      null => error,
    };
  }

  Result<T, E> okOrElse<E extends Object>({required E Function() orElse}) {
    return switch (this) {
      final value? => Ok(value),
      null => Err(orElse()),
    };
  }
}

final class EarlyReturnSymbol<E extends Object> {
  const EarlyReturnSymbol._();
}

class _EarlyReturnException<E extends Object> implements Exception {
  _EarlyReturnException(this.error);

  final Err<dynamic, E> error;
}

参考資料

脚注
  1. 原則、プログラムの大元でcatchすべき。とはいえ、クライアント側のコードはエラー時の処理が多くなりがち。 ↩︎

  2. DartにRust特有のクラスを輸入するライブラリrust-coreで使われていた手法でとても勉強になりました。 ↩︎

Discussion