😎

SOLID原則なるものを学んでみた

2024/04/29に公開

📚SOLID原則と呼ばれているものみたいだ

https://ja.wikipedia.org/wiki/SOLID
最近、SOLED原則なるものが話題なので気になって調べてみることにした。

Wikipediaによれば:

SOLID(ソリッド)は、ソフトウェア工学の用語であり、特にオブジェクト指向で用いられる五つの原則の頭字語である。

SOLIDは、次の5つの原則からなる。
単一責任の原則
開放/閉鎖原則
リスコフの置換原則
インターフェース分離の原則
依存性逆転の原則

🪛単一責任の原則

単一責任の原則 (たんいつせきにんのげんそく、英: single-responsibility principle) は、プログラミングに関する原則であり、モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきであるという原則である。モジュール、クラスまたは関数が提供するサービスは、その責任と一致している必要がある。

単一責任の原則は、ロバート・C・マーティン(英語版)によって定義された。この原則について、彼は、「クラスを変更する理由は、ひとつだけであるべきである」 と表し、「変更する理由」に関して、「この原則は、人についてのものである」と述べ、アクターについてのものであると補足した。

最近聞いた言葉で「クラスは一つの責務」を持つべきと聞いたことある。

クラスの外で使わない変数があれば、_をつければ、プライベイトになるのでカプセル化されている。staticをつけると、Javaの本で読んだことあるけど、グローバル変数になるらしい。今回だとグローバルではなくて、プライベイトだから、カプセル化されているか。

もしFirebaseのインスタンスが使える変数とコレクション名をつけることができるゲッターを使いたければ、クラスを継承したら使うことができる。元のクラスのコードを変更してはいないので、「クラスは1つの責務」しか果たしていないと思う。

import 'package:cloud_firestore/cloud_firestore.dart';

class SingleTask {
  // private variable
  FirebaseFirestore _firestore = FirebaseFirestore.instance;
  // collectionName getter
  String get collectionName => 'tasks';

  // add task data method
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  // get task data method
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  } 
}

🔓開放/閉鎖原則

開放/閉鎖原則(かいほうへいさげんそく、open/closed principle、OCP)とは、オブジェクト指向プログラミングの設計への提言である。

メイヤーの開放/閉鎖原則(1988年):

メイヤーは、親クラスで不変の仕様を定義をして、それを継承する各子孫クラスで実装の修正または拡張を行なっていくべきとした。親クラスの変数には、親クラスまたは各子孫クラスのインスタンスが代入される。クライアントはその親クラス変数を恒久的に使えて、その変数に子孫インスタンスが代入されていても支障をきたさない

ソフトウェア要素(クラス、モジュール、関数など)は、拡張に対しては開いており、修正に対しては閉じているべきである。 software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

マーチンの開放/閉鎖原則(1996年):

抽象メソッドだけで構成される不変のインターフェースを定義して、それをコード実装するための兄弟クラスを様々に定義し、ランタイムでインターフェース変数への各兄弟インスタンスの代入と交換を行って、実行時ポリモーフィックするべきとした。

マーチンの原則では、抽象メソッド(シグネチャだけ)の界面継承(interface inheritance)が基本になる。継承関係はインターフェースの実装に留めて、クラスの継承は抑えることが基本になる。

サンプルコードだと :
親クラスで変わることのない使用を定義して、子孫クラスでは、継承して実装をすることになる。Firebaseのインスタンスと、コレクション名のゲッターは継承して使ったり、上書きするので、原則通りのコードにはなるか。

import 'package:cloud_firestore/cloud_firestore.dart';

// Base class
class SingleResponsibility {
  // private variable
  FirebaseFirestore _firestore = FirebaseFirestore.instance;
  // collectionName getter
  String get collectionName => '';
}

// TaskSingle class
class TaskSingle extends SingleResponsibility {

  
  String get collectionName => 'tasks';

  // add task data method
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  // get task data method
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  } 
}

👩‍⚕️リスコフの置換原則

リスコフの置換原則(りすこふのちかんげんそく、英: Liskov substitution principle)は、オブジェクト指向プログラミングにおいて、サブタイプのオブジェクトはスーパータイプのオブジェクトの仕様に従わなければならない、という原則である。リスコフの置換原則を満たすサブタイプを behavioral subtype(英語版) と呼ぶ。

この解説を自分なりに解釈すると、親クラスのスーパークラスと同じロジックだけど、子孫クラスのサブクラスはFirestoreのコレクション IDの名前だけ変更して使えということなのだと思う。

import 'package:cloud_firestore/cloud_firestore.dart';

// Super Type Object
class LiskovTask {
  // private variable
  FirebaseFirestore _firestore = FirebaseFirestore.instance;
  // collectionName getter
  String get collectionName => 'tasks';

  // add task data method
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  // get task data method
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  } 
}

// Sub Type Object
class LiskovSubTask extends LiskovTask {

  
  String get collectionName => 'sub_tasks';

  // add task data method
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  // get task data method
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  } 
}

🌏インターフェース分離の原則

ソフトウェア エンジニアリングの分野では、インターフェイス分離原則 (ISP) により、コードが使用しないメソッドに依存することを強制されるべきではないと述べられています。ISP は、非常に大きなインターフェイスをより小さく、より具体的なインターフェイスに分割するため、クライアントは関心のあるメソッドのみを知る必要があります。このような縮小されたインターフェイスは、ロール インターフェイスとも呼ばれます。ISP は、システムを分離した状態に保ち、リファクタリング、変更、再デプロイを容易にすることを目的としています。 ISP は、オブジェクト指向設計の 5 つの SOLID 原則の 1 つであり、GRASP の高凝集性原則と同様です。ISP は、オブジェクト指向設計を超えて、分散システム一般、特にマイクロサービスの設計における重要な原則でもあります。 ISP は、マイクロサービス設計の 6 つの IDEALS 原則の 1 つです。

📝オブジェクト指向設計における重要性

オブジェクト指向設計内では、インターフェイスはコードを簡素化し、依存関係への結合を防ぐ障壁を作成する抽象化レイヤーを提供します。システムが複数のレベルで結合されすぎて、多くの追加変更を必要とせずに 1 か所を変更することができなくなる場合があります。インターフェイスまたは抽象クラスを使用すると、この副作用を防ぐことができます。

勉強会で、疎結合という言葉を聞いたことあるのですが、あるクラスに依存しないというものだそうです。私は、最近は、抽象クラスやインターフェースで機能を実装していないクラスを作って、継承したクラスで、機能を実装して使ってますね。

import 'package:cloud_firestore/cloud_firestore.dart';

// interface
abstract interface class Segregation {
  Future<void> addTask(String title, String body);
  Future<QuerySnapshot> getTasks();
}

// class that implements the interface
class SegregationImpl implements Segregation {

  FirebaseFirestore _firestore = FirebaseFirestore.instance;

  String get collectionName => 'tasks';

  
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  }
}

💉依存性逆転の原則

オブジェクト指向における従来の依存関係とは、上位モジュールから下位モジュールへの方向性であり、仕様定義を担う上位モジュールを、詳細実装を担う下位モジュールから独立させて、各下位モジュールを別個保存するというものだったが、それに対して依存性逆転原則は以下二点を提唱している。

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
"High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces)."
抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。
"Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions."
この上位モジュールと下位モジュールの双方が抽象に依存しなければならないという内容は、それまでの人々のオブジェクト指向の常識を覆しているものだった。

この二点の背景にある考えとは、上位モジュールと下位モジュールの相互作用を設計する際は、その相互作用自体も抽象的に考える必要があるということである。上位モジュールの抽象化だけではなく、それを詳細化する下位モジュールへの見方も変えて、インターフェースの使い方も変えることを求めている。多くの場合、相互作用を抽象的に捉えることは、追加のコーディングパターンを増やすことなくコンポーネント間の結合を減らせることに繋がる。これはより軽量で小規模な実装依存性相互作用スキーマを実現する。

さっき紹介したコードと同じ書き方になる。抽象的に考えると解説に書いてあるので、インターフェースに依存するべきと書いてあるから、抽象クラスを作って継承して、機能実装して、上書きして使うということでしょうね。
私は、普段から無意識にやってます。

import 'package:cloud_firestore/cloud_firestore.dart';

// interface
abstract interface class Segregation {
  Future<void> addTask(String title, String body);
  Future<QuerySnapshot> getTasks();
}

// class that implements the interface
class SegregationImpl implements Segregation {

  FirebaseFirestore _firestore = FirebaseFirestore.instance;

  String get collectionName => 'tasks';

  
  Future<void> addTask(String title, String body) async {
    await _firestore.collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  
  Future<QuerySnapshot> getTasks() async {
    return await _firestore.collection(collectionName).get();
  }
}

まとめ

よくSOLIDの原則なる言葉を見て気になって調べてみますが、もしかしたら、インターフェース分離の原則依存性逆転の原則は普段から無意識にやっていたかもしれないです。昔は全然書いてませんでしたけどね。riverpod使えばいいだろうって感じで、プラベイトな変数にFirebaseのインスタンスを入れてカプセル化したクラスを継承したりはしていないです。
副業で携わっている仕事だと、Supabaseを使っているのですがそれは、Supabaseはriverpodでどこからでも呼ぶプロバイダーを作れなかったので、カプセル化したクラスを作って、継承して機能を拡張したり実装してます。

以前から私が書いているFirebase + riverpodを使ったコードもご紹介しておきます。Providerでインスタンス化したFirebaseFirestoreをグローバル変数として定義する。ていうかこれしか今は使わない。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'firebase_provider.g.dart';



FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref){
  return FirebaseFirestore.instance;
}

インタフェースを実装したクラスで、refメソッドを使って、プロバイダーを参照して、FirebaseFirestoreを呼び出すと、collection, add, get が使えます。ライブラリに依存しないオブジェクト思考のパターンで書くのも良いですが、あんまり見かけないですね。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hello_app/solid/firebase_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'task_service.g.dart';

abstract interface class TaskService {
  Future<void> addTask(String title, String body);
  Future<QuerySnapshot> getTasks();
}

(keepAlive: true)
TaskServiceImpl taskServiceImpl(TaskServiceImplRef ref) {
  return TaskServiceImpl(ref);
}

class TaskServiceImpl implements TaskService {
  TaskServiceImpl(this.ref);
  final Ref ref;

  String get collectionName => 'tasks';

  
  Future<void> addTask(String title, String body) async {
    await ref.read(firebaseFirestoreProvider).collection(collectionName).add({
      'title': title,
      'body': body,
    });
  }

  
  Future<QuerySnapshot> getTasks() async {
    return await ref.read(firebaseFirestoreProvider).collection(collectionName).get();
  }
}

Discussion