LoginSignup
12
2

More than 3 years have passed since last update.

Dart の基本的な言語機能のみを使用してパターンマッチングもどきを実装し、静的に網羅的な検査をする手法

Last updated at Posted at 2020-08-08

Dart 言語においてパターンマッチングの仕様策定が進行中ですが、現状でも現行の言語機能で、疑似的に Union Type を作成してパターンマッチング "もどき" を実装し、静的に網羅的な検査ができるようにしてみます。enum や「Dart 2.9 で新しく追加された exhaustive_cases Lint が便利」で紹介した exhaustive_cases Lint を使用する手法よりも表現力が増しますし、実装はやや冗長ながらも単純で明白です。あくまで基本的な機能を組み合わせただけの、かなり不完全で欠陥もある "もどき" ですが、網羅的な検査を switch 文で行う目的ならばこの手法で不満がないので、私はパターンマッチングを実装したパッケージよりもこの手法を好みます。以下、サンプルコードともに紹介します。

サンプルコードとその解説

import 'package:meta/meta.dart';

enum NetworkResponseType { success, error }

@sealed
abstract class NetworkResponse {
  NetworkResponseType get type;
  int get statusCode;
  Map<String, dynamic> get body;
  factory NetworkResponse.success(Map<String, dynamic> body, int statusCode) =
      Success;
  factory NetworkResponse.error(Map<String, dynamic> body, int statusCode,
      String someAdditionalInfoForError) = Error;
}
  • enum を定義します。これを用いて switch 文において網羅的な検査をします。
  • abstract class を定義して、そのひとつの public interface に、enum の getter を指定します。この例では、NetworkResponseType get type; です。
  • Interface を満たす実装それぞれに共通するメンバーがあれば定義します。この例では、int get statusCode;Map<String, dynamic> get body; です。
  • NetworkResponse interface を満たす実装をした Class のインスタンスを生成する constructor をそれぞれ定義します。この例では、factory redirect constructor にしています。(Dart 言語では、class を定義すると暗黙的にそれに対応する interface が定義されます。

なお、@sealed は、異なる package の class に extends, implements, mixin されることを禁止するように静的解析器に指示するものです。指定が面倒ならばつけなくても良いです。アプリケーションコードには通常は不要です。

つぎに、NetworkResponse interface を満たす実装をした Class を定義します。なお、implements でなく extends でも構いません。NetworkResponse がなんらかの具象メンバー( = 実装ありのメンバー。たとえばメソッドボディがあるメソッド)を含むならば、extendsを使用します。

@sealed
class Success implements NetworkResponse {
  @override
  final NetworkResponseType type = NetworkResponseType.success;
  @override
  final int statusCode;
  @override
  final Map<String, dynamic> body;
  const Success(this.body, this.statusCode) : assert(statusCode < 400);
}

@sealed
class Error implements NetworkResponse {
  @override
  final NetworkResponseType type = NetworkResponseType.error;
  @override
  final int statusCode;
  @override
  final Map<String, dynamic> body;
  final String someAdditionalInfoForError;
  const Error(this.body, this.statusCode, this.someAdditionalInfoForError)
      : assert(statusCode >= 400);
}

Class の名前と enum 値の名前が対応していると、誤った enum 値を指定するミスが起こり得ないくらい分かりやすくなるので、そう名付けることを強くおすすめします。

上記のサブクラスも、他の package の class にさらに継承することを想定していないので、一応 @sealed をつけていますが、通常は必要ありません。また、この例では const constructor にしていますので、@immutable もつけていません。それらのアノテーションは実装の必要に応じて選択してください。


擬似的な Union Type

Dart の型システムに Union Type はありませんが、abstract class と その implements または extends を定義すれば、制約がありつつも、実質的に Union Type として機能します。もちろん、自分が管理しているコード中のユーザー定義型である class に限られます。基本的な型である int や String 等には適用できません。( もし可能でも、ほとんどの場合はやるべきではありませんし、万が一どうしても必要なレアケースに遭遇したならば、is で実行時に型検査し、加えて DocComment で説明すれば十分です。)また、自分が管理していないパッケージのなかの class についても、それを継承して implements を付加することは技術上は可能ですが、あまりやりたくないでしょう。

なお、Future については特別に、FutureOr<T> という、 Future と Future でない型 T の Union Type として機能する Class が用意されています。また、int と double のためには num が用意されています。


ともあれ、これで定義が完了しました。

以下は、この NetworkResponse の利用方法です。

void handleResponse(NetworkResponse response) {
  switch (response.type) {
    case NetworkResponseType.success:
      final success = response as Success;
      print(success.runtimeType);
      print(success.body);
      break;
    case NetworkResponseType.error:
      final error = response as Error;
      print(error.runtimeType);
      print(error.body);
      print(error.someAdditionalInfoForError);
      break;
  }
}

void main() {
  final response = NetworkResponse.success({'a': 'b'}, 200);
  handleResponse(response);
}

NetworkResponseType enum を switch 文の引数に指定して、静的に網羅的な検査を可能にします。そして、来たる Null Safety 時代では implicit downcast が禁止されるので、as 句 で downcast しましょう。

Dart の基本的な機能のみを使用しているので、DartPad ですら動きます。


注意点

注意点として、success と error の class と enum 値の意味が一致していなかったり、enum 値が重複してしまったりというバグを静的に検出できません。つまり、as 句 で downcast する時点でランタイムエラーが発生する可能性があります。しかしそこはさすがに人間が気をつけたら問題ないと思います。ふたつの情報の位置がとても近いので、普通は定義時にすぐに気づきそうです。それでも本当に不安ならば、テストコードを書いたらよいと思います。このように書けるでしょう。

import 'dart:mirrors';

import 'package:recase/recase.dart';
import 'package:test/test.dart';
import 'package:exhaustive_cases/pattern_matching_like_pattern.dart'
    hide NetworkResponse;

// Because in Dart 2 there is a mirror system bug on redirect factory constructor,
// using Normal static method instead is necessary for testing with dart:mirrors.
// https://github.com/dart-lang/sdk/issues/33041#issuecomment-390096556
// https://github.com/dart-lang/sdk/issues/41915
abstract class NetworkResponse {
  NetworkResponseType get type;
  int get statusCode;
  Map<String, dynamic> get body;
  static Success success(Map<String, dynamic> body, int statusCode) =>
      Success(body, statusCode);
  static Error error(Map<String, dynamic> body, int statusCode,
          String someAdditionalInfoForError) =>
      Error(body, statusCode, someAdditionalInfoForError);
}

void main() {
  group(NetworkResponse, () {
    test(
        '$NetworkResponse has exhaustive factory method-like static methods'
        'which names are corresponding to $NetworkResponseType values.'
        'and its returnType string is a Pascal case of the name', () {
      final classMirror = reflectClass(NetworkResponse);

      for (final v in NetworkResponseType.values) {
        final name = v.toString().split('.').last;
        final methodMirror = classMirror.staticMembers[Symbol(name)];
        expect(methodMirror.simpleName, Symbol(name));
        expect(methodMirror.returnType.simpleName,
            Symbol(ReCase(name).pascalCase));
      }
    });
  });

  test('$Success instance has corresponding $NetworkResponseType', () {
    final instance = Success({}, 200);
    expect(instance.runtimeType.toString(),
        ReCase(instance.type.toString().split('.').last).pascalCase);
  });

  test('$Error instance has corresponding $NetworkResponseType', () {
    final instance = Error({}, 400, '');
    expect(instance.runtimeType.toString(),
        ReCase(instance.type.toString().split('.').last).pascalCase);
  });
}

なお、Dart 2 の dart:mirrors のバグに遭遇して、factory redirect constructor の情報を取得できなかったので、しかたなく static method に変更しています...(残念)。

今回の dart:mirrors のバグはともかく、テストコードを書くのが静的解析での検査と比べて面倒になりがちなのはまた別の話で、Dart 言語に限らずどの言語でも大きな課題だと思います。


実装とその解説は以上です。

もっと単純な例

Field (インスタンス変数) と method (インスタンスメソッド) が同じならば、実装はもっと単純になります。abstract class を定義する必要もありません。

import 'package:meta/meta.dart';

enum NetworkResponseType { success, error }

@sealed
@immutable
class NetworkResponse {
  final NetworkResponseType type;
  final int statusCode;
  final Map<String, dynamic> body;

  const NetworkResponse._(this.type, this.body, this.statusCode);

  factory NetworkResponse.success(Map<String, dynamic> body, int statusCode) =>
      NetworkResponse._(NetworkResponseType.success, body, statusCode);
  factory NetworkResponse.error(Map<String, dynamic> body, int statusCode) =>
      NetworkResponse._(NetworkResponseType.error, body, statusCode);
}

void handleResponse(NetworkResponse response) {
  switch (response.type) {
    case NetworkResponseType.success:
      print(response.body);
      break;
    case NetworkResponseType.error:
      print(response.body);
      break;
  }
}

void main() {
  final response = NetworkResponse.success({'a': 'b'}, 200);
  handleResponse(response);
}

Dart 2.9 で追加された、exhaustive_cases Lint を利用する手法 とお好きなほうを選択したら良いのではないでしょうか。

3つの手法の使い分け

コード量と表現力にトレードオフの関係がありますので、使い分けます。

  1. enum
  2. exhaustive_cases Lint を利用する手法
  3. 本記事で紹介した擬似的なパターンマッチングを実装する手法

それでも満足できないならば、package:freezed などのパッケージの採用を検討します。

もし静的に網羅的な検査をすることを気にしないならば、if - else if文や Map<SomeKeyType, Function> で書くでしょうし、品質はテストコードで担保します。Map<SomeKeyType, Function>の場合、SomeKeyTypeが String や int などのリテラルならば、重複については静的解析器が警告してくれます。

まとめ

本記事の実装は、パターンマッチング "もどき" であり、機能が足りていないし、ランタイムエラーが起こりえます。がっかりした人もいると思います。しかし、私は実用上これに大きな不満を感じません。そして実装はやや冗長ながらも単純で明白です。私は、パターンマッチングを実装したコードジェネレーションに依存し多機能なパッケージに依存するよりも、たとえ不完全ですこしの欠陥があろうとも、言語の基本的な機能のみに依存したこの手法を好みます。

また、Dart の enum に不満を感じたことはほぼないです。ほしいなと思っていたのは describeEnum 関数 くらいです。本記事の手法と enum を使い分けているからです。

パターンマッチングをサポートする パッケージ の成熟具合を横目で見つつ、本命の言語サポートをゆっくりと待とうと思います。

以下はパターンマッチングの仕様追加についての issue のリンクです。

Patterns and related features · Issue #546 · dart-lang/language

個人的には、パターンマッチングの追加それ自体よりも、それに関連した Value (deeply immutable object) などの言語サポートとともに、Isolate をもっと軽量にして Erlang の Actor 並の Green Process 的な位置づけに進化させたらとても面白いと思います。また、Meta Class の追加や Mixin の制限緩和、さらには dart:mirror の復権 ( package:reflectable の完成でもいいので ) でもっと簡単手軽にリフレクションコードを書けるようになればとてもいいのになと思います。

リンク

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2