🔖

[初心者ok]Dart(Flutter)におけるSOLID原則

2024/08/02に公開

今回はDart(Flutter)におけるSOLIDの原則について説明したいと思います。
riverpodを使う人が多く、依存性注入(DI)は頻繁に行われると思います。
本日よりFlutterで依存性注入に関する記事を順番にかいていこうと思います。

まずはその土台となるSOLIDについてまとめていこうと思います。

こんな人におすすめ

・DIを学習中
・SOLIDの原則についてDart(Flutter)でみたい
・インターフェイスについて学習したい

⭐️ 身近な例でSOLID原則を理解できるようになります

SOLID原則とは

SOLID 原則は、ソフトウェアの保守性、拡張性、柔軟性を高めることを目的としたソフトウェア開発原則をさします。
以下では、いい例と悪い例を比べながら5つの原則を理解していきましょう。

S: 単一責任原則 (SRP)

モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきであるという原則です。

✖ 悪い例

class UserManager {
  void addUser(User user) {
    // データベースにユーザーを追加
    print('Adding user to database: ${user.name}');
  }
  void updateUser(User user) {
    // データベースのユーザー情報を更新
    print('Updating user in database: ${user.name}');
  }
  void printUser(User user) {
    // ユーザー情報を表示
    print('User: ${user.name}, Age: ${user.age}');
  }
}

⭕ いい例

class UserPrinter {
  void printUser(User user) {
    print('User: ${user.name}, Age: ${user.age}');
  }
}
class UserRepository {
  void addUser(User user) {
    // データベースにユーザーを追加
  }
  void updateUser(User user) {
    // データベースのユーザー情報を更新
  }
}

✅ リファクタリングポイント

悪い例では、データ操作とUser情報を出力する処理が単一のクラスに混ざってます。
これを データ操作を抽象化する repositoryクラスと、User情報を出力するクラスに分けました。

https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html

O: オープン/クローズド原則 (OCP)

クラスは拡張に対してオープンであるべきであるが、変更に対してはクローズであるべきであるという原則です。

✖ 悪い例

// animalTypeを受け取り
// それに応じて声を発する関数
class Animal {
  void makeSound(String animalType) {
    if (animalType == 'dog') {
      print('dog sound');
    } else if (animalType == 'cat') {
      print('cat sound');
    } else {
      print('Unknown animal');
    }
  }
}
void main() {
  Animal animal = Animal();
  animal.makeSound('dog');  // dog sound
  animal.makeSound('cat');  // cat sound
}

⭕ いい例

// 抽象クラスで動物は鳴き声を発するよと定義
abstract class Animal {
  void makeSound();
}

// 具象クラスでそれぞれの鳴き声をオーバライドする
class Dog implements Animal {
  @override
  void makeSound() {
    print('dog sound');
  }
}
class Cat implements Animal {
  @override
  void makeSound() {
    print('cat sound');
  }
}

class AnimalSound {
  void playSound(Animal animal) {
    animal.makeSound();
  }
}

void main() {
  AnimalSound animalSound = AnimalSound();  
  Animal dog = Dog();
  Animal cat = Cat();

  animalSound.playSound(dog);  // dog sound
  animalSound.playSound(cat);  // cat sound
}

✅ リファクタリングポイント

悪い例では、Animalクラスのなかで具体的な実装をおこなうことにより、動物が追加されたときにAnimalクラスに対して変更を加えるため、クラス変更に対してはクローズであるべき という原則を守れなくなります。
そこで登場するのが interfaceです。インターフェイスを新しい動物クラスに継承することで追加できるようになるということです。

※ また、インターフェイスは依存性逆転の概念で、アーキテクチャを組むときに使用するか検討することが多いです。

L: リスコフの置換原則 (LSP)

サブタイプのオブジェクトはスーパータイプのオブジェクトの仕様に従わなければならない、という原則です。

✖ 悪い例

// 基底クラス:Robot
abstract class Robot {
  void introduce();
  void makeCoffee();
}
// 派生クラス:Sam
class Sam extends Robot {
  @override
  void introduce() {
    print('こんにちは、私はサムです。コーヒーを淹れることができます');
  }
  @override
  void makeCoffee() {
    print('サムがコーヒーを淹れます');
  }
}
// 派生クラス:Eden
class Eden extends Robot {
  @override
  void introduce() {
    print('こんにちは、私はサムの子供、エデンです');
  }
  // EdenはmakeCoffeeメソッドを持っていない、もしくは異なる実装を持つ
  @override
  void makeCoffee() {
    throw Exception('エデンはコーヒーを淹れられません');
  }
}
void main() {
  Robot sam = Sam();
  Robot eden = Eden();

  // Samはコーヒーを作ることができる
  sam.introduce(); // 出力: こんにちは、私はサムです。コーヒーを淹れることができます
  sam.makeCoffee(); // 出力: サムがコーヒーを淹れます

  // Edenはコーヒーを作れないため、例外が発生する
  eden.introduce(); // 出力: こんにちは、私はサムの子供、エデンです
  eden.makeCoffee(); // 例外: Exception: エデンはコーヒーを淹れられません
}

⭕ いい例

// 基底クラス:Robot
abstract class Robot {
  void introduce();
  void makeCoffee();
}
// 派生クラス:Sam
class Sam extends Robot {
  @override
  void introduce() {
    print('こんにちは、私はサムです。コーヒーを淹れることができます');
  }
  @override
  void makeCoffee() {
    print('サムがコーヒーを淹れます');
  }
}
// 派生クラス:Eden
class Eden extends Sam {
  @override
  void introduce() {
    print('こんにちは、私はサムの子供、エデンです');
  }
  // EdenはSamからmakeCoffeeメソッドを継承し、そのまま使う
}

void main() {
  Robot sam = Sam();
  Robot eden = Eden();

  // SamとEdenはどちらもコーヒーを作ることができる
  sam.introduce(); // 出力: こんにちは、私はサムです。コーヒーを淹れることができます
  sam.makeCoffee(); // 出力: サムがコーヒーを淹れます

  eden.introduce(); // 出力: こんにちは、私はサムの子供、エデンです
  eden.makeCoffee(); // 出力: サムがコーヒーを淹れます(エデンも同様にコーヒーを作れる)
}

✅ リファクタリングポイント

悪い例では、サムを継承したロボットエデンであるにも関わらず、コーヒーを入れる機能が使えない部分です。これはスーパータイプのオブジェクトの仕様に従わなければならないに反します。
そのため、いい例ではちゃんとエデンにもコーヒーを淹れられるようにしました。
こちらは、継承したクラスを使用する安心感が格段に変わってきます。

L: インターフェース分離原則 (ISP)

クラスは使用しないインターフェースを実装することを強制されるべきではないといる原則です。

✖ 悪い例

// 鳥の基本的な動作を定義する基底クラス
abstract class Bird {
  void eat();
  void fly(); // すべての鳥が飛ぶことを期待
}
// スズメは鳥であり、飛ぶことができる
class Sparrow extends Bird {
  @override
  void eat() {
    print('Sparrow is eating');
  }
  @override
  void fly() {
    print('Sparrow is flying');
  }
}
// ペンギンは鳥であるが、飛ぶことはできない
class Penguin extends Bird {
  @override
  void eat() {
    print('Penguin is eating');
  }
  @override
  void fly() {
    // ペンギンは飛べないため、何もしないか例外を投げる
    throw UnsupportedError('Penguins cannot fly');
  }
}

⭕ いい例

// 鳥の基本的な動作を定義する基底クラス
abstract class Eatable {
  void eat();
}
// 飛ぶ動作を定義するインターフェース
abstract class Flyable {
  void fly();
}
// スズメは鳥であり、飛ぶことができる
class Sparrow  implements Eatable, Flyable {
  @override
  void eat() {
    print('Sparrow is eating');
  }
  @override
  void fly() {
    print('Sparrow is flying');
  }
}
// 
class Penguin implements Eatable {
  @override
  void eat() {
    print('Penguin is eating');
  }
}

✅ リファクタリングポイント

悪い例では、ペンギンに空を飛ばそうとして、無理だからエラーを吐かせているところです。
それは、かわいそうなので、Birdの抽象クラスから 鳥はみんな飛べるという前提でコードを書かないようにしましょう。 そのため、Flyable, Eatable という抽象クラスを別で定義して、ペンギンにはEatableのインターフェイスだけ付けて、Flyableのインターフェイスをつけないようにしてあげましょう。

D: 依存性逆転の原則 (DIP)

具象クラスではなく抽象クラスやインターフェースに依存することで、モジュールの結合度を下げ、柔軟性と保守性を高めることです。

✖ 悪い例

// 具象クラス:SimpleCoffeeMaker
class SimpleCoffeeMaker {
  void makeCoffee() {
    print('シンプルなコーヒーメーカーでコーヒーを淹れます');
  }
}
// 高レベルのモジュール:Robot
class Robot {
  final SimpleCoffeeMaker coffeeMaker = SimpleCoffeeMaker();

  void introduce() {
    print('こんにちは、私はロボットです');
    coffeeMaker.makeCoffee();
  }
}
void main() {
  Robot robot = Robot();
  robot.introduce(); // 出力: こんにちは、私はロボットです シンプルなコーヒーメーカーでコーヒーを淹れます
}

⭕ いい例

// 抽象クラス
abstract class CoffeeMaker {
  void makeCoffee();
}

// 具象クラス:SimpleCoffeeMaker
class SimpleCoffeeMaker implements CoffeeMaker {
  @override
  void makeCoffee() {
    print('シンプルなコーヒーメーカーでコーヒーを淹れます');
  }
}

// 具象クラス:AdvancedCoffeeMaker
class AdvancedCoffeeMaker implements CoffeeMaker {
  @override
  void makeCoffee() {
    print('高度なコーヒーメーカーでコーヒーを淹れます');
  }
}

// 高レベルのモジュール:Robot
class Robot {
  final CoffeeMaker coffeeMaker;

  Robot(this.coffeeMaker);

  void introduce() {
    print('こんにちは、私はロボットです');
    coffeeMaker.makeCoffee();
  }
}

void main() {
  CoffeeMaker simpleMaker = SimpleCoffeeMaker();
  CoffeeMaker advancedMaker = AdvancedCoffeeMaker();

  Robot robotWithSimpleMaker = Robot(simpleMaker);
  Robot robotWithAdvancedMaker = Robot(advancedMaker);

  robotWithSimpleMaker.introduce(); // 出力: こんにちは、私はロボットです シンプルなコーヒーメーカーでコーヒーを淹れます
  robotWithAdvancedMaker.introduce(); // 出力: こんにちは、私はロボットです 高度なコーヒーメーカーでコーヒーを淹れます
}

✅ リファクタリングポイント

悪い例では、SimpleCoffeeMakerに直接依存しています(蜜結合)。それを改善するためにコンストラクタで注入するように変更します。これはRobotの引数にsimpleMaker or advancedMaker と切り替えられることです(疎結合)。これはテストコードをかくとき mockに置き換えられることを意味します(テストダブル)。

こちらは、一般的なクリーンアーキテクチャの概念でも重要です。


出典: Nuits.jpの記事

こちらでも述べられており、かなり参考にさせていただきました。
一読されることをおすすめします。

https://www.nuits.jp/entry/easiest-clean-architecture-2019-09

最後に

DIは、慣れればかなり重宝します。
特に学習を進めると、SQLiteのリポジトリをProviderで依存注入するときに、ほかの言語だとメモリリークを気にしたりしますが、riverpodは autodisposeに頼れたりするので、riverpodのすごさが実感できます。

こちらの本 すごく参考になりますので是非⭐️

なぜ依存を注入するのか DIの原理・原則とパターン (Compass Booksシリーズ)
https://www.amazon.co.jp/なぜ依存を注入するのか-DIの原理・原則とパターン-Compass-Booksシリーズ-Steven-Deursen/dp/4839983062

Discussion