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

はじめに

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

モバイルチームでは日々良いコードを書くためにレビューや議論を積極的に行っているのですが、今回Good Code, Bad Codeという書籍が話題に上がり、それを正しく深く理解し共有するためにブログ化しようということになりました。

内容をわかりやすく簡潔にまとめるとともに書籍内で登場する擬似コードをDartでリライトして要点を解説します。

Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考

Good Code, Bad Codeは、Googleでテックリードを務めるTom Long氏が、プロフェッショナルなソフトウェアエンジニアとして、信頼性が高く、メンテナンスしやすいコードを書くための概念やテクニックを解説した本です。

中堅ソフトウェアエンジニアになるための概念やテクニックが必要最低限網羅されているところが特徴的で、良い本だなと感じています。

今回が初回でChapter2 抽象化レイヤーを対象とします。続編も執筆予定です。

本記事の特徴と読者のメリット

この記事の特徴はコード品質 × Dartという組み合わせだと考えています。

DartFlutter自体やその周辺技術(Riverpodなど)の使い方を解説する記事は多く存在しますが、Dartで保守性の高いコードを書くための体系的かつ網羅性がある記事はあまり見かけません。

この記事はそういった部分を学びたい方には良いきっかけとなると思います。

また、Dartのリライトがテーマとなっていますが、Good Code, Bad Codeの本質をなるべく分かりやすく解説することをサブテーマとしているので、Dartに馴染みのない方でも十分にメリットを享受できると思います。

なるべく自分の見解は入れずに、Good Code, Bad Codeが主張したいことをより幅広い方に分かり易く伝えることを意識しています

DartPadで動くコードも掲載するので、実際に手元で動かしてより理解を深めてもらうことができるようになっています。

本編の進め方

以下のように順を追って解説を進めていきます。

1. 抽象化レイヤー編の要点
2. 題材のコードについて
3. Bad Codeの解説
4. Good Codeの解説

1. 抽象化レイヤー編の要点

抽象化レイヤーとは

抽象化レイヤーという日本語をそのまま解釈しようとすると難しいので、擬似コードを用いながら説明します

// HTTPコネクションの接続
HttpConnection connection = HttpConnection.connect("<http://example.com/server>");

// 送信処理
connection.send("Hello server")

// HTTPコネクションの切断
connection.close();

上記の疑似コードではサーバーにメッセージを送る一連の流れが記述されており、接続・送信・切断という3つの処理がされていることが容易にわかります。

実際にはそれぞれの処理は内部で以下のような複雑なコードが書かれているはずですが、それがここには記述されていません。

  • 文字列から送信可能なフォーマットへのシリアライズ
  • HTTPプロトコルに関わる全ての複雑な問題
  • TCP通信
  • ユーザーがWi-Fiを使用しているのか携帯電話のデータ通信を使用しているのか
  • データ送信時のエラーと訂正

このように、各処理の中身の実装を知らなくても、抽象的な概念として把握していればよい、というのが抽象化レイヤーの考え方です。

抽象化レイヤーがどのようにコード品質を向上させるか

上記のように綺麗に構造化された抽象化レイヤーを作ることで、それぞれのクラスでは簡単に理解できる概念だけを取り扱うことになるため、コードが簡潔になります。

Good Code, Bad Codeではコード品質を6つに分類・定義していますが、抽象化レイヤーはそのうちの4つを達成することに寄与します。以下にまとめます。

読みやすさ

一つのクラスに少数の抽象化レイヤーのみを扱うため、読みやすさや理解のしやすさが向上します。例えば、上記の擬似コードの中にHTTPプロトコルに関わる全ての複雑な問題を解くための大量のコードが入ってくると想像するだけで、読む気がなくなりますよね。

モジュール性

抽象化レイヤー同士がお互いに干渉しなければ、他のレイヤーやコードに影響を与えることなく、実装を交換できます。
上述のHttpConnectionの例で言えば、ユーザーがWi-Fiを使用していればWi-Fi用のモジュールを使用し、キャリアデータ通信を使用していればキャリアデータ通信用のモジュールを使用します。
しかしこのレイヤーにおいては、そのような下位のシナリオを気にする必要はないのです。(素晴らしい👏)

再利用性と汎用化

適切に抽象化レイヤーを作成していれば、他の問題の解決策として再利用ができます。
例えば、TCP通信の確立を担うモジュールが適切に抽象化レイヤーとして作成されていれば、今回の例のHTTP通信だけでなく、WebSocketなど他のタイプの通信にも再利用できることが推測できるでしょう。

テスタビリティ

抽象化レイヤー毎に閉じた範囲で品質を気にすればよくなります。例えば、HttpConnection.connect()ではHTTP接続に関する部分のみ保証できればよくなります。もし、抽象化レイヤーが作成されておらずにWi-Fiとキャリアデータ通信の使い分けをこのクラスでする必要がある場合、気にしなければならないテストケースは大幅に増えてしまいます。

ちなみに、残りの2つは想定外の事態をなくすと誤用しにくいコードを書くです。興味がある方はGood Code,Bad CodeのChapter1 コードの品質を読んでみてください。

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

お題

文章を要約する機能

具体的な機能

インプット プロセス アウトプット
要約したい文章 1. 文章を段落ごとに分割する
2. 段落ごとに重要度を計算する
3. 重要度の高い段落を結合する
要約された文章

3. Bad Codeの解説

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

class TextSummarizer {
    ...

    String summarizeText(String text) {
        return splitIntoParagraphs(text)
            .filter(paragraph -> calculateImportance(paragraph) >= IMPORTANCE_THRESHOLD)
            .join("\n\n");
    }

    private Double calculateImportance(String paragraph) {
        List<String> nouns = extractImportantNouns(paragraph);
        List<String> verbs = extractImportantVerbs(paragraph);
        List<String> adjectives = extractImportantAdjectives(paragraph);
        ... 複雑な方程式 ...
        return importanceScore;
    }

    private List<String> extractImportantNouns(String text) { ... }
    private List<String> extractImportantVerbs(String text) { ... }
    private List<String> extractImportantAdjectives(String text) { ... }

    private List<String> splitIntoParagraphs(String text) {
        List<String> paragraphs = [];
        Int? start = detectParagraphStartOffset(text, 0);
        while (start != null) {
            Int? end = detectParagraphEndOffset(text, start);
            if (end == null) {
                break;
            }
            paragraphs.add(text.substring(start, end));
            start = detectParagraphStartOffset(text, end);
        }
        return paragraphs;
    }

    private Int? detectParagraphStartOffset(String text, Int fromOffset) { ... }
    private Int? detectParagraphEndOffset(String text, Int fromOffset) { ... }

    ...
}

一つのクラス内で扱う概念(メソッド)が多く、構造化されていない(抽象化レイヤーがない)ため、可読性、モジュール性、再利用性、汎用性、テスタビリティが低下しています。

例えば、段落毎の重要度を算出するcalculateImportance()と、インプットした文章を段落に分割する際に段落の先頭を見つけるdetectParagraphStartOffset()が同じレイヤー(クラス内)に同居しています。

各メソッドを適切に構造化(抽象化レイヤーを作成)すると以下となり、上記の擬似コードでは異なる層のメソッドが並列に記述されてしまっていることがわかります。

4. Good Codeの解説

4-1. 擬似コード

以下の擬似コードに目を通してください。
細かい部分は一旦サラッと読み飛ばして、クラスがどのように分割・整理されたかに注目してみてください。

class TextSummarizer {
    private final ParagraphFinder paragraphFinder;
    private final TextImportanceScorer importanceScorer;

    TextSummarizer(
        ParagraphFinder paragraphFinder,
        TextImportanceScorer importanceScorer
    ) {
        this.paragraphFinder = paragraphFinder;
        this.importanceScorer = importanceScorer;
    }

    static TextSummarizer createDefault() {
        return new TextSummarizer(
            new ParagraphFinder(),
            new TextImportanceScorer());
    }

    String summarizeText(String text) {
        return paragraphFinder.find(text)
            .filter(paragraph ->
                importanceScorer.isImportant(paragraph))
            .join("\n\n");
    }
}

class ParagraphFinder {
    List<String> find(String text) {
        List<String> paragraphs = [];
        Int? start = detectParagraphStartOffset(text, 0);
        while (start != null) {
            Int? end = detectParagraphEndOffset(text, start);
            if (end == null) {
                break;
            }
            paragraphs.add(text.substring(start, end));
            start = detectParagraphStartOffset(text, end);
        }
        return paragraphs;
    }

    private Int? detectParagraphStartOffset(String text, Int fromOffset) { ... }
    private Int? detectParagraphEndOffset(String text, Int fromOffset) { ... }
}

class TextImportanceScorer {
    ...

    Boolean isImportant(String text) {
        return calculateImportance(text) >= IMPORTANCE_THRESHOLD;
    }

    private Double calculateImportance(String text) {
        List<String> nouns = extractImportantNouns(text);
        List<String> verbs = extractImportantVerbs(text);
        List<String> adjectives = extractImportantAdjectives(text);
        ...複雑な方程式...
        return importanceScore;
    }

    private List<String> extractImportantNouns(String text) { ... }
    private List<String> extractImportantVerbs(String text) { ... }
    private List<String> extractImportantAdjectives(String text) { ... }
}

抽象化レイヤーにて構造化されている

Bad Codeで解説した以下の適切な構造に合わせてParagraphFinderクラスとTextImportanceScorerクラスが作成されています。そのためTextSummarizerクラスの中では、具体的な重要度計算ロジックや段落の先頭を見つける処理の記述が不要になり、抽象化レイヤー(paragraphFinder.find()importanceScorer.isImportant())の使い方さえ把握すれば良くなりました。

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

4-1で説明した擬似コードをDartにリライトし、更に改善を加えました。
TextImportanceScorerをインターフェース化することで、重要度判定ロジックを容易に切り替えることができるようにしています。

まずは以下のコードをポイントの部分に着目しつつサラッと読んでいただければと思います。コードの次にポイントを解説しています。

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

DartPad

/// 文章を要約するクラス
class TextSummarizer {
  /// ==== ポイント① ====
  /// 適切に抽象化レイヤーを作成したため、TextSummarizerクラス内に小さな問題を解決する具体的な
  /// コード(ex extractImportantNouns)の記述がなく、同レイヤーのメソッドを把握していればよい。
  /// ->非常に見通しが良い!

  final ParagraphFinder _paragraphFinder;
  final TextImportanceScorer _importanceScorer;

  TextSummarizer._(this._paragraphFinder, this._importanceScorer);  

  // 単語ベースで重要度を算出し、要約するファクトリコンストラクタ
  factory TextSummarizer.createWordBased() => TextSummarizer(
        ParagraphFinder(),
        WordBasedScorer(),
      );

  // 機械学習モデルで重要度を算出し、要約するファクトリコンストラクタ
  factory TextSummarizer.createModelBased() => TextSummarizer(
        ParagraphFinder(),
        ModelBasedScorer(),
      );

  String summarizeText(String text) =>
      paragraphFinder.find(text).where(importanceScorer.isImportant).join('\n');
}

/// 文章を段落毎に分割するクラス
class ParagraphFinder {
  List<String> find(String text) {
    List<String> paragraphs = [];
    int? start = 0;
    while (start != null && start < text.length) {
      int? end = _detectParagraphEndOffset(text, start);
      end ??= text.length;
      paragraphs.add(text.substring(start, end).trim());
      start = _detectParagraphStartOffset(text, end);
    }
    return paragraphs;
  }

  /// ==== ポイント② ====
  /// OffsetDetectorというインターフェースを用意して、ParagraphStartOffDetectorとParagraphEndOffDetector
  /// を実装することで、もう一階層抽象化レイヤーを作ることも可能だが、以下の理由より推奨されない。
  /// - detectParagraphStartOffsetはParagraphFinder以外から使われる可能性が低いと考えられるため
  ///   別クラスにすることで凝集性が下がる。
  /// - 抽象化レイヤーが薄すぎるため、ボイラープレートが多く、コードの把握やデバッグに多くの労力が必要になる。

  // 次の段落の開始位置を見つける
  int? _detectParagraphStartOffset(String text, int fromOffset) {
    int nextOffset = text.indexOf('\n', fromOffset);
    return nextOffset == -1 ? null : nextOffset + 1;
  }

  // 現在の段落の終了位置を見つける
  int? _detectParagraphEndOffset(String text, int fromOffset) {
    int endOffset = text.indexOf('\n', fromOffset);
    return endOffset == -1 ? null : endOffset;
  }
}

/// 文の重要度を判定する抽象クラス
/// ==== ポイント③ ====
/// TextImportanceScorerを抽象クラスにし、重要度を算出するロジックを具象クラスに実装することで
/// ロジックの変更が容易になる。
abstract class TextImportanceScorer {
  bool isImportant(String paragraph);
}

/// 文の重要度を「ロジックベース」算出し、判定する具象クラス
class WordBasedScorer implements TextImportanceScorer {
  final double _importanceThreshold = 1;

  @override
  bool isImportant(String paragraph) {
    // 「重要」という単語が1つ以上入っていれば重要な文と判定する
    return _calculateImportance(paragraph) >= _importanceThreshold;
  }

  double _calculateImportance(String text) {
    final nouns = _extractImportantNouns(text);
    final verbs = _extractImportantVerbs(text);
    final adjectives = _extractImportantAdjectives(text);

    // 本来は上記の単語数から重要度を判定するロジックが記述されるが本質ではないためここでは省略する。
    // 「重要」という単語が1つ以上入っていれば重要な文と判定する。
    // ・・・
    return '重要'.allMatches(text).length.toDouble();
  }

  List<String> _extractImportantNouns(String text) {
    // 重要な名詞を抽出するロジック
    // ・・・
    return [];
  }

  List<String> _extractImportantVerbs(String text) {
    // 重要な動詞を抽出するロジック
    // ・・・
    return [];
  }

  List<String> _extractImportantAdjectives(String text) {
    // 重要な形容詞を抽出するロジック
    // ・・・
    return [];
  }
}

/// 文の重要度を「機械学習モデルベースで」算出し、判定する具象クラス
class ModelBasedScorer implements TextImportanceScorer {
  @override
  bool isImportant(String paragraph) {
    // 本来は機械学習のモデルで重要度を判定するロジックが記述されるが本質ではないためここでは省略する。
    // 「重要」や「大事」を含んでいれば重要な文と判定する。
    // ・・・
    return paragraph.contains("重要") || paragraph.contains("大事");
  }
}

void main() {
  /// 要約したい文章
  const String text = """
  1行目の重要な文章です。
  2行目のあまり意味のない文章です。
  3行目の大事な文章です。
  """;


  /// ==== ポイント④ ====
  /// ファクトリコンストラクタを活用し、名前がついたコンストラクタを利用させ誤用させにくくする。
  final summarizer = TextSummarizer.createWordBased();

  final summary = summarizer.summarizeText(text);

  print('要約された文章:\n$summary');
}
// 単語ベースで重要度計算の場合 (TextSummarizer.createWordBased()で生成)
要約された文章:
1行目の重要な文章です。

// 機械学習ベースで重要度計算をした場合 (TextSummarizer.createModelBased()で生成)
要約された文章:
1行目の重要な文章です。
3行目の大事な文章です。

擬似コードで解説済みの部分も含め、改めてポイントを解説していきます。

ポイント<抽象化レイヤーの作成>

適切に抽象化レイヤーを作成したため、TextSummarizer()クラス内に小さな問題を解決する具体的なコード(extractImportantNouns(text)など)の記述がなく、同レイヤーのメソッドを把握していればよいです。非常に見通しが良い👏

ポイント<薄すぎる抽象化レイヤーの作成をやめる>

抽象化レイヤーについて理解してきた方はParagraphFinder()クラスの中に_detectParagraphStartOffset(){・・・}_detectParagraphEndOffset(){・・・}が存在することが気になっているかもしれません。OffsetDetector()というインターフェースを用意して、ParagraphStartOffDetector()ParagraphEndOffDetector()を実装することで、もう一階層抽象化レイヤーを作りより良くできると考えているかもしれません。

しかしながら、この場合は以下の理由より推奨されません。

  • detectParagraphStartOffset(){・・・}detectParagraphEndOffset(){・・・}ParagraphFinder()以外から使われる可能性が低いと考えられるため、別クラスにすることで凝集性が下がってしまう。
  • 薄すぎる抽象化レイヤーが増えると、ボイラープレートの割合が多くなり、コードの把握やデバッグに多くの労力が必要になってしまう。

ポイント<インターフェースを定義して具体的な実装を切り替え易くする>

上述の擬似コード(Good Code) には無かった改善点です。TextImportanceScorer()を抽象クラスとし、重要度を算出するロジックを具象クラスに実装することで変更を容易にしています。具体的なユースケースとしては、単語数ベースで重要度を計算する単純なロジックと、機械学習で計算するロジックを容易に切り替えることが可能です。同様にテスト用のモックなどに置き換えることも可能なのでテスタビリティも向上します。

ポイント<ファクトリコンストラクタを活用しクラスの誤用を防ぐ>

ファクトリコンストラクタにより、TextSummarizer.createWordBased()TextSummarizer.createModelBased()でしかインスタンスを生成できないように強制しています。

更に、TextSummarizer(ParagraphFinder(),
WordBasedScorer())
のように通常の形でインスタンスを生成させないために、コンストラクタをプライベート化しています。

※Dartではアンダースコアをクラス名の先頭につけることでプライベートになります。この場合は名前をあえてつける必要がないためアンダースコアのみのコンストラクタになっています。

最後に

Flutterの定番パッケージであるRiverpodFreezedを活用することで、更にコードの品質を上げることも可能ですが今回は対象にしませんでした。気が向いたら別記事で書くかもしれません😀

次回はChapter3 コードでの契約編です!お楽しみに!

関連記事