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

はじめに

MSDのモバイルチームに所属するエンジニアの酒井です。

本記事はGood Code, Bad Codeを正しく深く理解するために、書籍内で登場する擬似コードをDartでリライトして要点を解説する記事です。

本記事は2記事目で、1記事目は以下です。

1.コードでの契約編の要点

コードでの契約とは

日本語でそのまま理解しようとすると難しいのですが、簡単に言うとコードの使い方や挙動を伝える方法のことです。契約を適切に伝えることで、コードの誤用を防ぐことができ品質の向上につながります。

コードの使い方や挙動を伝えるには様々な方法があります。いくつかの具体例を見てみましょう。

  1. 関数の挙動を適切に伝えるために、fetchItems()という名前をつけた。
  2. 関数の引数に型を定義して、誤った型を指定するとコンパイルが通らないようにした。
  3. 関数の使い方のコメントを書いた。
  4. チームメンバーに口頭で丁寧に使い方を伝えた。

上記の方法はいずれも誤用を防ぐ手法ですが、強制力に差があります。
例えば、2は誤った使い方をするとコンパイルが通らないため確実に誤用を防げますが、4は口頭でいくら伝えてもヒューマンエラーが発生する可能性が残ります。

どのように契約をするとコード品質が上がるか

できるだけ強制力の高い契約を適用するコードを書くことで、極力誤用を防ぎコード品質を高めることが重要です。

以下にコードの使い方や挙動を伝える種類と強制力について整理しておきます。下に行くほど強制力が上がっていきます。

手法のパターン 強制力 理由
直接会話して伝える 非常に低い ・聞き手が解釈を誤る可能性がある
・担当者がいなくなるとこの手法は使えない
ドキュメントやコメントを読ませる 低い ・読み手が解釈を誤る可能性がある
・更新されずにコードの実態と合わなくなる可能性がある
関数やクラスに適切な名前をつける ・命名によっては読み手が解釈を誤る可能性がある
検査やアサーションを埋め込む そこそこ高い ・誤用を実行じに機械的に検知できる
・シナリオによっては検出できない場合がある
・コンパイラと比較して検出が遅れる
誤用した場合はコンパイラが通らなくする 高い ・最も信頼できるアプローチ
・契約違反がそもそも行えない

まとめると以下の方針・優先順位でコードの契約を結んでいくのが良いと考えます。

  1. 1.極力コンパイラによる強制を考える
  2. 2.コンパイラによる強制が難しい場合は、検査やアサーションを検討する
  3. 3.上記に加えて、適切な命名と必要十分なドキュメントを用意する

補足(既に概念を理解できた人は読み飛ばしていただいて結構です!)

この書籍では電動スクーターをアナロジーとして解説しているので、より具体化してみたいと思います。

この電動スクーターは時速50kmを超えて走行してはいけないというルールがあり、それを守らせるために誤用しない手法(契約)を考える必要があります。

上記の手法のパターンに当てはめてみると、イメージがしやすくなると思います。

手法のパターン 強制力 電動スクータの場合
直接会話して伝える 非常に低い 販売員がユーザーに説明する
ドキュメントやコメントを読ませる 低い 注意書きをスクーターに貼り付けておく
関数やクラスに適切な名前をつける いい例えが見つからなかったです。。。
検査やアサーションを埋め込む そこそこ高い 時速50kmを越えるとスクーターのアラートが鳴る・光る仕組みを導入する
誤用した場合はコンパイラが通らなくする 高い 時速50kmを越えることがそもそもできない作りにする

確かに、下にいくほど強制力が上がることが直感的にわかると思います。

注:上記の例えはあくまでイメージを掴んでもらうためなので、厳密性などは考慮しておりません。

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

お題

何らかのアプリにおけるユーザー設定(テーマカラーなど)を管理するクラス

具体的な機能

役割

  • ユーザーの設定ファイルをロードする
  • 設定ファイルをインプットに初期化処理を行う
  • ユーザーの設定したテーマカラーを返却する

システム制約

  • 初期化処理の前にユーザーの設定ファイルをロードする必要がある
  • 初期化処理の前にテーマカラーの返却はできない

3. Bad Codeの解説

まずは以下の擬似コードにサッと目を通してみてください。

class UserSettings {
 UserSettings() { ... }

 // この関数を使って設定が正しく読み込まれるまで、他の関数を呼ばないこと
 // 正しく設定が読み込まれたらtrueを返す
 Boolean loadSettings(File location) { ... }

 // init()は、他の関数よりも先に呼ばなければならない
 // ただし、先にloadSettings()を呼び出して設定を読み込むこと
 void init() { ... }

 // ユーザーが選択したUIの色を返す。もしくは、ユーザーが色を選択していない場合や
 // 設定を読み込んでいない、あるいは初期化していない場合はnullを返す。
 Color? getUiColor() { ... }
}

正しい順序で関数を呼ばないとシステム上不具合が生じるにも関わらず、呼び出し順序を開発者に伝える方法がドキュメント(コメント)任せになっています。

また、getUiColor()ではnullが返却された際に「ユーザーが色を選択していないだけ」か「初期化されていない」のどちらか不明確です。

もし呼び出し側でnullの場合はテーマカラーをデフォルトカラーにするというロジックを記述した場合、なんらかの理由で初期化がされなかった際にユーザーが色を設定しているのにデフォルトカラーになる、というバグが発生します。

4. Good Codeの解説

4-1. 擬似コード

以下の擬似コードに目を通してください。

class UserSettings {

 // ==== ポイント① ====
 // コンストラクタをプライベートにすることで、利用者側にcreate()の利用を強制する
 private UserSettings() { ... }

 // ==== ポイント② ====
 // UserSettingsのインスタンスを生成する唯一の方法
 static UserSettings? create(File location) {
   UserSettings settings = new UserSettings();
   if (!settings.loadSettings(location)) {
    // ==== ポイント③ ====
    // 読み込みに失敗した場合nullを返すことで、不正な状態のインスタンスを取得できなくする。
     return null;
   }
   settings.init();
   reeturn settings;
 }

 // ==== ポイント④ ====
 // クラスの状態を変える全ての関数がプライベートで、利用者が呼び出すことを許さない
 private Boolean loadSettings(File location) { ... }
 private void init() { ... }

 // ==== ポイント⑤ ====
 // nullを返す意味は、ユーザーがテーマカラーを設定していない場合のみとなる
 Color? getUiColor() { ... }
}

細かなポイントは上記のコード内に記述していますが、要点は以下です。

  • 順序関係のある関数(laodSettingsinit)は、利用側が呼び出せないように(privateメソッド化)し、インスタンス生成の際に内部的に呼び出す。
  • インスタンス生成はcreateメソッドだけにより可能。
  • nullに明確で一意な意味を持たせる。

上記により、コメントによる注意喚起がなくとも強制的に正しくインスタンスを生成させられることがわかるのではないでしょうか。

4-1. Dartでリライトしたコード

以下にDartでリライトしたコードを載せますが、基本的には疑似コードとほぼ変わらないので、コード内のコメントを確認してもらえればと思います。

↓DartPadで実際に動かすことができます👩‍💻

https://dartpad.dev/?id=d1575bd49bd5aa102d47d7746c7ad9db

import 'package:flutter/material.dart';
import 'dart:async';

class UserSettings {
  // ==== ポイント① ====
  // コンストラクタをプライベートにすることで、利用者側にcreate()の利用を強制する
  UserSettings._();

  // ==== ポイント② ====
  // UserSettingsのインスタンスを生成する唯一の方法
  static Future<UserSettings?> create() async {
    final settings = UserSettings._();
    if (!await settings._loadSettings()) {
    // ==== ポイント③ ====
    // 読み込みに失敗した場合nullを返すことで、不正な状態のインスタンスを取得できなくする。
      return null;
    }
    settings._init();
    return settings;
  }

  // ==== ポイント④ ====
  // クラスの状態を変える全ての関数がプライベートで、利用者が呼び出すことを許さない
  Future<bool> _loadSettings() async {
    // ファイルの存在チェック(ダミー)
    return true;
  }

  // ==== ポイント④ ====
  // クラスの状態を変える全ての関数がプライベートで、利用者が呼び出すことを許さない
  void _init() {
    // 初期化処理(ダミー)
    print("UserSettings initialized.");
  }

  // ==== ポイント⑤ ====
  // nullを返す意味は、ユーザーがテーマカラーを設定していない場合のみとなる
  Color? getUiColor() {
    // UIカラーの取得処理(ダミー) 
    // - ユーザーが設定したカラーが返却された場合
    return Colors.blue;
    
    // - ユーザーが設定していない場合
    // return null;
  }
}

void main() async {
  // ==== ポイント② ====
  // UserSettingsのインスタンスを生成する唯一の方法で生成
  final settings = await UserSettings.create();

  if (settings == null) {
    print("Failed to load settings.");
    return;
  }

  print("Settings loaded successfully.");

  // UIカラーの取得。nullの場合はデフォルトカラー(Color(0xFFFFFFFF))とする。
  final uiColor = settings.getUiColor() ?? const Color(0xFFFFFFFF);

  // ここでuiColorを使用する
  print("UI Color: $uiColor");
}

今回、Dartならではの部分でピックアップしたいのは、UserSettingsのインスタンスを生成する唯一の方法をstaticメソッドで実現している部分です。

factoryコンストラクタ or staticメソッド

Dartでは、今回のように利用者に明示的にインスタンスの生成方法を指定したい場合に、デフォルトのコンストラクタではなく、factoryコンストラクタやstaticメソッドを利用することがベストプラクティスの一つとなっています。

では、その使い分けはどのようになっているのでしょうか?

 

注:ここからは一部私見が入ります。

細かなメリデメはあると思いますが、基本的な考え方としてインスタンスの生成はコンストラクタで実施する方がstaticメソッドと比較してより役割に忠実であると思っています。コンストラクタはインスタンスを生成するために存在するものですが、staticメソッドは他の用途にも使うことが想定されているからです。

なので、インスタンスを生成するユースケースにおいては、基本的にはfactoryコンストラクタで実装するのが良いと考えています。

ではなぜ、今回はstaticメソッドなのかというと、インスタンス生成時にawait settings._loadSettings()を実行したいが、Dartではコンストラクタ内で非同期処理を実施することができないからです。

なぜDartではコンストラクタ内で非同期処理を実施できないようになっているか、については色々議論されているようですが、コンストラクタはインスタンスを返却するものであり、Future<インスタンス>を返却するものではない、という主張が主なようです。

以下で議論されているので、興味ある方は読んでみてください。

https://github.com/dart-lang/sdk/issues/23115

少し話はそれましたが、factoryコンストラクタ or staticメソッドについては、上記で述べたように基本的にはfactoryコンストラクタを検討し、それが不可能な場合にstaticメソッドを検討するのが良いのではないかと考えています。

ちなみに、Flutterのcook bookでもfactoryコンストラクタによるインスタンスの生成を採用していますね。

https://docs.flutter.dev/cookbook/networking/fetch-data#create-an-album-class

おわりに

次回はPart3として、Chapter4 エラー編を執筆する予定ですのでお楽しみに!
いいねをたくさんもらえるとやる気がでます🙌