はじめに
ソフトウェア設計におけるモジュール結合について学んでおり、
その中で表題のアンチパターンについて知ったので、
今回はこれを記事にしてみたいと思います。
共通結合とは
共通結合とはグローバルに共有されたデータ構造を参照する場合に発生するモジュール結合のアンチパターンのことです。
データ構造とはIntやStringのような型で定義された単一の値のことではなく、
オブジェクトや共有ストレージ・データベースなどの塊のことを指します。
ただし、クラスをモジュールとして、その中のプロパティやメソッドもまたモジュールであるとみなす時、この場合はプロパティやメソッドもまたデータ構造であると言えます。
ここで具体的なコードを見てみましょう。
具体例:グローバルなシングルトンの参照
class Repository {
Future<String> fetchData() async {
await Future.delayed(const Duration(seconds: 2));
return "Fetched Data";
}
}
/// グローバルに参照可能なシングルトン
final repository = Repository();
class UseCase1 {
Future<String> execute() async {
return await repository.fetchData();
}
}
class UseCase2 {
Future<String> execute() async {
return await repository.fetchData();
}
}
二つのユースケースはグローバルに定義されたRepositoryオブジェクトというシングルトンに依存しています。
この時、このシングルトンは本当にこれらのユースケースだけで使われているのでしょうか。
グローバルに定義されており依存先が限定化されていないので、いつでもどこからでも呼び出せてしまいます。
この時、Repositoryに修正が入ったとして、その影響範囲を正確に捕捉・推測することは難しくなってしまいます。
そもそもユースケースだけから呼び出せる、と言った構造にはなっていないからです。
また、そのシングルトンはいつまで生きているのでしょうか。
このような設計の場合、シングルトンがいつまでもメモリを占有してしまい、
その生存期間を適切にコントロールすることが難しくなってしまいパフォーマンスを悪化させかねません。
加えて、上記のようなコードの自動テストを書くとして、Repositoryのモックを使いたい場合は、上記のコードだとそれが難しいという側面もあり、テスタビリティの上でも問題があるのです。
上記のような課題を持つコードは以下のように実装される必要があります。
class Repository {
Future<String> fetchData() async {
await Future.delayed(const Duration(seconds: 2));
return "Fetched Data";
}
}
/// コンストラクタインジェクションを使用して依存関係を注入
class UseCase1 {
final Repository repository;
UseCase1(this.repository);
Future<String> execute() async {
return await repository.fetchData();
}
}
class UseCase2 {
final Repository repository;
UseCase2(this.repository);
Future<String> execute() async {
return await repository.fetchData();
}
}
グローバルなシングルトンを廃することでRepositoryの依存先が限定化され、その影響範囲の補足や推測も容易になりました。
同時にRepositoryのオブジェクト生存期間も二つのユースケースの参照元がコントロールすることができるようになり、モックも差し込みやすくなりました。
副次的には、ユースケースがRepositoryオブジェクトの生成方法を知らなくても自身の関心事を実装できる、という利点も生まれています。
具体例:共有ストレージのファイルの読み書き
では次に以下の図を見てください。
これは共有ストレージ上にあるファイルに対して、複数のモジュールがそのファイルの参照・更新をしようとしているイメージ図です。
この時、そのファイルの現在の状態はどうなっているかについて、追跡が困難になってしまいます。
先述のグローバルなシングルトンの参照以外でも上記のような共通結合も起こり得るのです。
具体例:クラスのプロパティを複数のメソッドが使用できる
ここまで、共通結合による設計・アーキテクチャ上のアンチパターンを紹介いたしました。
では、ここからは少し視点を変えて共通結合が必ずしもそれだけではアンチパターンにはならないコード例を考えてみましょう。
以下のコードを見てください。
/// 状態(データ)とそれを操作するロジックが一体となった高凝集なクラス
/// クラス内部で見れば「共通結合」の構造(同じプロパティを複数メソッドが触る)だが、
/// 密結合による弊害がクラス内に閉じ込められているため、優れた設計のパターン。
class ShoppingCart {
// 共通のプロパティ(外部からは隠蔽されている:カプセル化)
final List<String> _items = [];
double _totalPrice = 0.0;
// 操作メソッド1: 共通プロパティを参照・更新
void addItem(String item, double price) {
_items.add(item);
_totalPrice += price;
}
// 操作メソッド2: 共通プロパティを参照・更新
void removeItem(String item, double price) {
if (_items.remove(item)) {
_totalPrice -= price;
}
}
// 外部へは安全に公開
double get totalPrice => _totalPrice;
List<String> get items => List.unmodifiable(_items);
}
上記のコードはこれまでのコードとは異なり、とあるクラスShoppingCartの中の共通のプロパティ(_items, _totalPrice)をモジュールと見なしています。
モジュールには関数やクラス、名前空間、外部依存パッケージなどさまざまなものが該当し、
大局的にも局所的にも見て取れるものだと考えられます。
この時、クラスやその中のメソッド、プロパティも同様にモジュールであると捉えることができます。
カプセル化が効いていて、内部実装には踏み込ませないものと仮定すると(公開インターフェース経由で利用する)、プロパティの状態はその窓口のインスタンスメソッドによって操作可能になっており、
あらゆるオブジェクトが参照・更新できるようになっています。
一見すると共通結合ゆえに悪いコードに見えますが実はそうではありません。
というのも、
- このクラスの関心事、役割、目的、責務を自身だけで完結できるようにクラス設計がなされている、という単一責任の原則に基づくカプセル化が効いており
- それゆえに凝集度が高くなっている(高凝集)
というモジュール設計上のベストプラクティスも同時に満たしているからです。
このように相反する原理原則が競合することで、どちらの軸に則って考えるべきかを問われるときがあるかと思われます。
ただ、実はここで重要なのはその全か無かといった二元論的な思考ではなく、モジュール設計における原理原則を組み合わせたバランスにあったりします。
考えるべきは、
- モジュール同士の結合強度
- モジュール同士の距離感
- モジュールの変更頻度
これらの掛け算です。
先のコード例に準えますと、モジュール同士の結合度は共通結合なので密結合になっていますが、モジュール同士の距離は近いので高凝集になっています。
モジュールの変更頻度はここからは見て取れませんが、仮に変更頻度が高いものであったとしても、カプセル化や単一責任の原則により、変更領域がこのクラスShoppingCartに限定されるはずなので、
影響範囲の調査もしやすいものになっているかと思われます。
こういった状態にある時、この共通結合にあるコードはその高凝集性によってカプセル化や単一責任が効いた優れたコードになっている、と言えるものと思われます。
共通結合と聞くとアンチパターンに通ずるものと考えられますが、見方を変えて、さまざまは原理原則で捉え直し、設計を組み立ていると、実はベストプラクティスになっている場合もあるので、
設計・アーキテクチャ、コードの判断の際はその尺度が必要になってくるのでしょう。
参考
