抽象は契約
- #Learning
- #Engineering Philosophy
- #Architecture
- #Design Philosophy
ソフトウェア開発の現場では、「抽象」というワードが度々登場します。
JavaやKotlinのようなオブジェクト指向言語では、class, interface, abstract classを上手に設計することで、抽象化の設計を行うことができますね。
抽象化は、設計の初期フェーズで登場することが多いですが、機能レベルでも抽象化を意識することはあると思っています。
今回の記事では、抽象という言葉について、ソフトウェア開発の文脈で掘り下げてみたいと思います。
抽象というワードの意味
抽象というワードは実に抽象的にワードです。
実際に「抽象的」と言われたときに上手に説明することは難しいです。
意味合いのニュアンスだと、
- 物事の本当に大切な側面だけを切り離すこと
- あいまいだが、本質的なこと
- 規則や概念を一般化すること
という感じだと思います。
似た言葉に「共通化」というワードもありますね。
私は抽象化と共通化の違いは概念のレベルだと考えています。
共通化は概念のレベルに関係なく、共通項目をまとめ上げる作業です。
一方で、抽象化は共通化よりも概念のレイヤが高く、より本質的なものだけをまとめ上げ、切り離す作業だと考えています。
ソフトウェアにおける共通化
プログラムを書いていると、共通化ということには意識が回りますね。
これは、同じようなロジックがいろいろなところに散らばっているのは、保守性の観点から良くないということが直感的にわかりやすいからだと思います。
例えば、金額を税込みに変換するロジックが必要だとしましょう。 税込みに変換するのは、単体の画面ではなく、システム全体に横断する可能性があります。 そのため、税込みに変換するロジックを様々な場所に散りばめてしまうと、税率が変更したときに修正箇所が多くなります。
日本では、軽減税率という食品とそれ以外で消費税率が異なるという仕組みもあるため、税率だけを共通の定数化するだけでは足りません。
この税率は将来的には変更される可能性は非常に高いため、共通化しておけば、変更箇所が少なくなり、影響箇所も特定しやすいという思考になるわけです。
このように、共通化に対しては判断がつきやすいと感じます。
一方で、抽象化というのは感覚的には共通化よりは捉えにくい概念だと感じます。
ソフトウェアにおける抽象化
機能レベルの共通化は意識しやすいが、抽象化と言われるとなかなか想像しにくいというのは自然な感覚です。
なぜなら抽象化が扱うものが抽象的なものだからです。とりわけソフトウェア開発の文脈ではかなり抽象的なものです。
DDD/CA構成を使って、例で考えてみましょう。
あるシステムは、「ドメイン層」「インフラストラクチャ層」「ユースケース層」「アプリケーション層」の4層から構成されているとします。
このときの依存関係は、
- ドメイン層はどこにも依存してはいけない
- インフラストラクチャはドメイン層にのみ依存するが、他の層から依存されてはいけない
- ユースケース層はドメイン層にのみ依存する
- アプリケーション層はユースケース層にのみ依存する
というルールにすると綺麗に層の責務が分離されます。
各層は、抽象的な概念ですね。具体的に〇〇層という実体があるわけではありません。
そして、このルールを実現するための手段が「抽象化」です。
DDD/CAと相性の良い、OOPを使って考えたいと思います。
ドメイン層は単純なclass, interfaceです。実装は持ちません。
data class XxxEntity {
...
}
interface XxxRepository {
...
}
インフラストラクチャ層は実装を持ちます。ドメイン層には依存して良いルールです。
class XxxRepositoryImpl : XxxRepository {
overide ...
}
ユースケース層はドメイン層には依存して良いルールですが、インフラストラクチャ層には依存してはいけません。なので、依存関係は外部から注入します。
class XxxUseCase(xxxRepository: XxxRepository) {
...
}
そしてアプリケーション層はユースケース層にのみ依存します。
class XxxApp(xxxUseCase: XxxUseCase) {
...
}
このように概念レベルでまとめることは抽象化だと考えています。
ここで一つ問題が見えてきます。
それは、アプリケーション層がユースケースを介して、ドメイン層へ依存しているのではないかという点です。 依存している状態というのは、変更したときに、依存しているコンポーネントも変更しなければいけない可能性がある状態を指します。
これを回避するためには、ユースケース層に抽象化層を追加して、アプリケーション層は外から依存性を注入すれば解決できることになります。
つまり
interface BaseUseCase {
...
}
class XxxUseCase(xxxRepository: XxxRepository) : BaseUseCase {
override ...
}
class XxxApp(useCase: BaseUseCase) {
...
}
とすれば、interfaceで定義したルール化されたメソッドを呼び出すという契約が強制できるわけです。 interfaceが契約となり、抽象化のルールとなります。 これは、実装は固定せずに責務を固定するという意味になりますね。
これこそがソフトウェア開発における抽象化の力です。
抽象化することで何が良いのか
最後に、なぜ抽象化することがよく語られ、推奨されるのかということについて考えます。
私の考えでは、小規模のシステム、小規模の開発チームや個人の場合は、抽象化はコストだと考えています。
抽象化することで実装コストは大きくなりやすいですし、認知負荷も高くなることがあります。
これを過剰な抽象化と呼んでいます。
一方で、大規模なシステムや多くの概念を取り扱うシステム、復数の開発チームが関わる開発環境などでは、抽象化設計はうまく機能すると考えています。
そして、上記のようなケースでは、抽象化することで実装コストを下げることも認知負荷を下げることもできます。
そのため一概に抽象化はコストと認識することは誤りです。 ケースによって抽象化をうまく選択するということが重要なポイントです。
小規模なケースで抽象化がコストとなることは想像しやすいと思いますので割愛しますが、大規模なケースで抽象化が結果的にコストを下げるということについて私の考えを書いていきます。
層の責務が明確になると、
- 障害時のエラーの場所を見つけやすくなる
- チーム開発でも下位レイヤーを知らなくても開発ができるようになる
- 別々にデプロイできるようになる
- 認知負荷が小さくなる
といったメリットがもたらされます。
結局のところ、ソフトウェア設計で重要なのは、メンテナンス性と認知負荷が低いという2点に集約されると思っています。
メンテナンス性とは保守性、可用性、拡張性を含み、簡単に言えば変更しやすいということです。
大規模な開発では、多くの人が多くの箇所を並行的に変更していきます。 そのため、それぞれのコンポーネントの変更が他へ影響を与えない、あるいは影響が特定できるようにしておくのが将来的なメンテナンス性に大きく寄与します。
抽象化はそのための道具です。 あるいは、ルール、契約と捉えた方がわかりやすいかもしれませんね。
私の考えでは、ルールや契約はできるだけ堅く、厳しくしておいた方が将来的な拡張性に寄与します。 これは認知負荷が下がるためです。
ルールがあまく自由度が高い環境の方が拡張性が高くなりそうな雰囲気があるのですが、実際は逆であるということを主張しておきます。
そのためプログラミング言語にしても動的型付けの言語より静的型付け言語の方が、開発の初速は出ませんが、型による契約によって将来的にはスケールします。 変数の型一つとってもmutableよりimmutableの方がスケールします。 依存関係がぐちゃぐちゃになっているより、層レベルでルール化されている方がスケールします。
これらは、変更するときに影響範囲が小さくなることに寄与するため、結果的に認知不可が低くなるためです。
結論として、抽象化は契約であり、大規模開発のケースにおいては結果的にコストを下げる。 抽象は未来への投資という考え方です。 一方で、小規模開発のケースにおいては抽象化はコストになる。 というのが私の考えです。
- 変更頻度
- チーム人数
- 実装の差し替え可能性
- システムの寿命
- 取り扱う概念の数 という観点で抽象化の使い方を検討するのが良いと考えています。
最後に
今回は「抽象」ということについて、ソフトウェア開発の文脈で考えてみました。 ソフトウェア設計の答えは、実際に運用、保守をしてみて初めてわかります。
設計のタイミングで選択が必要となりますが、このタイミングでは答えはわかりません。 だからこそ面白いところもあり、重要性が高いところでもあります。
アーキテクチャ構成のみを真似るシステムというのもよく見かけるのですが、 設計のタイミングでシステムのアウトラインと照らし合わせて適切な構成を取ることが重要であり、 すべてこのアーキテクチャで解決できるという考えはよくないと思います。