🌟

設計原則とSOLIDについてのノート

2021/10/05に公開

やれやれ、私事で色々あって、あっという間に3週間経ちました。

最近SOLID原則とかいろいろと本を読んでいます。以下の内容は主にこちらの本からの抜粋と感想となります。

実体験でもありますが、これらのフィロソフィーレベルの話を軽視、疎かにする人も周りに割といます。結局的にチーム全体にマイナスになってしまうケースもしばしば。やはり常に念頭に置いておかないとですね!

一般論的に「良いコード」とは

まず一般的にいわば「良いコード」にはどういう特徴があるかについての内容です。本には第3章辺りの内容となります。

契約式設計(Design by Contract)

システムを設計する際に、数多くのモジュールがそれぞれの役割を担当していて、同時に互いに協同作業しています。協同作業する時に他のクラスからメソッドを実行、いわばAPIを使う時に、クライエント側(実行側)とサーバー側(API提供側)がインプット、アウトプット、エラーについて「契約を結ぶ」ことです。契約違反になると、エラーを出して実行を中止することになります。

当たり前とは言え、これはほとんどのプログラミング言語、低レベルのシステムなどのソフトに見られます。もっと具体的に言えば:

  • 前置き条件:つまり実際に処理を行うコードが実行される前に、一度引き渡された引数が契約通りに、タイプがあっているかどうか、nullのチェックとか、副作用の有無とか。
  • 後置き条件:処理コードが実行された後に、逆にクライエント側が求めている結果または処理できるデータなのかどうかを検証。

これでもし何か間違いがあれば、どこでエラーが出たのがはすぐにわかるだと考えられます。前置き条件に満たすことができなければ、処理コードの実行すら進めない方が良いです。

ただ、前置き条件、つまり処理まえのデータ検証のステップについて、クライエント側で検証するのか、それともサーバー側で検証するのか、という問題もあります。以前はよく両方書いていましたが、これでDRY原則にも違反となり、変更する時に両方変更しなければなりません。どうやらクライエント側でAPIを実行する前に検証した方が良いらしい(本ではこういうふうに読み取れていますが要確認な気もします)。いずれにしても、重複しないことが先決になります。

エラー防止設計

契約式設計と一見衝突しているようにも見えます:契約式では前置き条件で処理コードの実行に検証していますが、エラー防止設計ではtry/exceptなどでエラーを実行中に捉えようとしています。

ただ、実際は衝突するというより、短所を補うことが可能。契約式の前置き条件検証では全てのエラーを防止することが当然無理で、実行中にエラーが出てればプログラムがクラッシュします。それを補う為にエラー防止の設計が必要になると。例えばエラーになることが可能な入力値nullなどをデフォルト値に入れ替えたり、exceptで可能なエラーを捉えて、ログに記録した後プログラムを中止したりするなど。

関心の分離(Separation of Concerns)

これはおそらくどこにも見当る原則であり、要はドミノ式の連鎖効果を防止する為に、モジュール・クラスはできる限り一つ独立の機能を巡って作り上げた方が良い。

ここで2つ重要な相反する概念があり、cohesion(凝集度)とcoupling(結合度)。良い設計は凝集度を高めて、結合度を下げる。いわばデカプリング(decoupling)。

もしどっかを変更したら他のところに問題が出るといった典型的な症状があれば、それは結合度が高いことになるでしょう。結合度が高くなるとこういった問題があります:

  • 重複利用できるコードがない
  • ドミノ式の連鎖効果
  • 抽象化レベルが低い

以上の問題点は関連しあっていて、つまり凝集度の高いコードになると、抽象化レベルが高いから、コードの再利用はもちろん、連鎖効果も防止できるようになる。

よく使われる略語について

ソフトウェア設計のフィロソフィー関連の略語が色々とあります。

DRYとOAOO

Don't Repeat Yourself
Once and Only Once

DRYがおそらく一番よく知られているかもしれません。後者はDRYより認識度が下がるかもしれませんが、重複しない点では共通しています。

同じコードの繰り返しはメンテナンスの問題に直結します。

  • 間違いやすい。どこかを変更すると全てのところを変更しなければなりません。1箇所でも忘れたらエラーになってしまいます。
  • コストが高い。1点目と関わるが、重複していますから、開発でもテストでももっと時間がかかってしまいます。
  • 信頼性が低い。一つの変更で複数の箇所を変えないといけないというのは開発者が全て把握しておく必要がある。ただもし本人ではなく、チームメンバーや他の開発者が引き継ぐとそれを把握できるかは限らない。

よくある対策として:

  • 重複・共通する部分を抽出して関数を作り、若干変わる部分のみ変数として定義する
  • 場合によって新しいクラスを作る
  • デコレーターの設計パターンも役立つ
  • イテレーターとジェネレーター
  • コンテキストマネジャーを使う

初心者にはよく見かける問題ですが、少し経験のある開発者にも、「聞いたことあるけど避ける方法がわからないしコピペ簡単だし」と思う人がいるみたいですね。一気にできる方法はないが、オープンソースのコードを読んだり、本やビデオ、ブログなどで勉強したりして、それで実践してみるしかありません。

個人的にこれはすごく良いヒントと道標となってくれて:もし「あれ?デジャブじゃねぇ?」のような瞬間があれば、自分をリピートしていることがわかりますので、絶対もっと良いやり方があるとわかります。

YAGNI

You Ain't Gonna Need It.

開発する時によく「先を見て作っておこう」といった考えで、要求にもない機能をつけたり、「将来的に役立つ」かもしれないコードを加えたりします。ある程度は良いものの、やりすぎると逆に問題を複雑化してしまい、結局要らないハメになってしまうことです。その為、「これを入れたら〇〇に役立つだろう」とかの考えが浮かんできたら、YANGI、あなたはこれを要らない、と思い出しましょう、と。

メンテナンス可能なソフトを開発するのは、未来の要求を予測する為ではなく、目前の問題を解決するのが目的なので、自分の判断に縛られないように、目前の問題解決に効かないコードをやめましょうと。

自分もよく「今後のために」とか言って、時にいらないやつを書いてしまいます。反省しています。

2023/08/19 追記

最近これについて一点明確にしないといけないと思ったのは、YAGNIで反対しているのはあくまでも現段階に必要とされない「機能・仕様」のことで、「設計」を不要とするかどうかはまた別だと考えている。もちろん、デザインパターンを運用するために、シンプルなアプリにも関わらず、過度な設計に落ちいてしまう可能性があります。いわゆる方法が目的になっているパターン。それにしても、拡張性と保守性への投資との意味で設計は、仮に今のスケールでいらないものであっても、実装する意味が十分あると考えている。それを考えると、多くのプロダクトが一回リリースで終わるのではなく、様々な機能拡張が予想されるので、時間の許す限り拡張性と保守性を上げるための要らないコードを実装しても良いかなと考えている。

KIS

Keep It Simple.

これもYAGNIと関わっていますが、要するにやり過ぎは禁止だと。別バージョンもあり、KISS = Keep it simple, stupid ですが、いずれにしてもシンプルさを求めることがモチーフです。

シンプルに設計するほど、メンテナンスがやりやすい。まあ一年とは言わずに、1ヶ月前に自分が書いたコードを理解するにも時間がかかります。複雑な設計にすると何やっているかを理解するだけでも災難ですね。

もう一つのKISの考えとして、できるだけ既存のライブラリー・パッケージ、もしくはビルトインの関数、メソッドを使うのが良いと。言語自身やフレームワーク、3rdパーティパッケージは洗練されていて、問題をシンプルに解決するには、自分で必死に複雑な設計を考えるより、前人の知恵をうまく利用するのが良いでしょう。

EAFPとLBYL

Easier to Ask Forgiveness than Permission.

つまり許可よりも、許しを求めるのがより簡単。実際にコードを書く時に、try/exceptを利用し、コードが上手くいくと期待しながらも、万が一の時にexceptionを捕らえて、その状況をまた対処する考え方だと。

Look Before You Leap.

これはEAFPと若干相反する考え方で、熟考の上で行動するとの意味だと。例えばファイルを操作する前に、まずはファイルが存在するかどうかを検証してから操作に入るとか。

正直どちらかが正しい・間違いとの判定はなく、コードのスタイルとして成り立っています。むしろどちらかのみにするケースはなく、ほとんどの場合兼用されているでしょう。PythonのスタイルではEAFPが勧められていますが。

継承

OOPの3本柱の一つとして、継承は確かに強力な問題解決手段です。ベースとなる親クラスを作り、それを継承する子クラスを作っていくと。

もちろん、親クラスのメソッド、属性などを継承できて、コードの重複問題は避けられるのがメリットですが、そのためだけで継承するのは逆におすすめではありません。

継承する時にリスクも伴い、親クラスと非常に高い結合度の持つ子クラスを作っているからです。つまり、親クラスの何かを変更すると、全ての子クラスが連鎖で引っかかってしまう可能性があります。また、本当に親クラスの全てのメソッド、属性を利用するかどうかというと、そうでないケースも多くあります。

コードの再利用の正しいやり方というのは、高い凝集度の持つオブジェクトを抽出し、異なるコンテキストで組み合わせの形で機能することです。

という時に、インターフェースとの概念が浮かんでくるのではないでしょうか。インターフェースは、抽象的なメソッドのみ定義し、中身の処理は、インターフェースの継承先に実装してもらうことになります。それだけではなく、多重継承が可能になることも大きなメリットです。

インターフェース以外に、言語によって異なるが、mixin(python, rubyなど)とtrait(php,scalaなど)を実装している言語も見られるが、多重継承の機能で言えば、共通する部分とも言えます(ただ、traitとminxinは継承ではなく、組み合わせと考えられる場合もありますが、ここで継承と組み合わせを総合的に「継承」の枠組みにします)。

特徴 interface trait mixin
関数シグネチャ
多重継承・組み合わせ
関数中身
書き換え必須
インスタンス状態保持(変数)
インスタンス化

いずれにしても、継承の目的というのは、機能の専門化にあるはず。抽象的な親クラスから、より具体的な問題解決のための子クラスを作ると。この意味で考えると、traitとmixinはすでに専門化した関数などを提供しているため、厳密に言えば、継承とは多少違うのも事実ですね。

いつ継承するか、いつinterface/trait/mixinを利用するか、は多少難しい問題ですが、言語自身のスタンダードライブラリー、フレームワークなどを参考にすれば、抽象度の高い、メソッド・属性が必ず継承先に利用されるものがあれば、ベースの親クラスとして定義するのが多く見られます。逆に、is aの関係を求めず、xx-ableを求めるのであれば、interface/trait/mixinが適切かもしれません。

SOLID原則について

オブジェクト指向のソフトデザインの導きとも考えられる原則。本の第4章にあたります。

Single Responsibility Principle

単一責任の原則と訳される。一つのクラスは一つの仕事だけをする。なので、このクラスを変更しようとするならば、一つの理由しかないはず。複数の理由で変更しているなら、このクラスは多くの仕事を担当していることが分かると。

この原則は凝集性の高いプログラムを書くことに役立つ。一つのクラスにあるメソッドは、お互いに関連しあい、一つの目的達成のために集められていると。ある意味で、db設計時の正規化(normalization)とも共通していて、無関連のデータを別々のテーブルに分け、一つのテーブルにあるコラムは一つのオブジェクトのみと関わると。

逆に言えば、もし一つのクラスにあるメソッドは、別のメソッドと関係なく、独立した機能をはたすのであれば、これは単一責任原則に違反となるでしょう。というときに、独立した機能を元に、より小さいクラスに分割した方が良い。

クラスだけの話にとどまることはないと思います。むしろ、Robert C. Martin氏(通称Uncle Bob)がクラシックの著作Clean Codeで強調していたように、関数もできるだけ小さく、一つの仕事だけをすること。

正直数百行の関数を書くコードも時々見かけますが、なんとかならないか?おかしいと思わないか?と、毎回言いたくなりますね。これって、文章を読むときにこの節のメインな論点をまとめてください、との問題を解くのが苦手な人がやりそうなことですね。

Open/Closed Principle

開放閉鎖の原則と訳される。大まかに、拡張には開放的でありながら、変更には閉鎖的であるとのことです。ただ、これだけでは誤解されやすいかもしれません。実際にイベント探知の例を見ながら考えましょう。

仮に一つのモニタリングモジュールを設計しています。ユーザーがログイン、ログアウトするたびにそのイベントを認識すると。

class Event:

  def __init__(self, raw_data):

    self.raw_data = raw_data

class UnknownEvent(Event):

  """A type of event that cannot be identified from its data."""

class LoginEvent(Event):

  """A event representing a user that has just entered the system."""

class LogoutEvent(Event):

  """An event representing a user that has just left the system."""

class SystemMonitor:

  """Identify events that occurred in the system."""
  def __init__(self, event_data):
    self.event_data = event_data

  def identify_event(self):

    if ( self.event_data["before"]["session"] == 0 and self.event_data["after"]["session"] == 1 ):
      return LoginEvent(self.event_data)

    elif ( self.event_data["before"]["session"] == 1 and self.event_data["after"]["session"] == 0 ):
      return LogoutEvent(self.event_data)

    return UnknownEvent(self.event_data)

この実装がおそらく1番目に浮かんでくるのではないでしょうか。ただ、この問題も明らかで、もし新しいイベント種類が追加されると、identify_eventでもう一つのif文分岐を追加しなければなりません。もし100個のイベントもあれば、とんでもなく長いメソッドになってしまいます。まさに、変更に閉鎖的との原則に違反しています。

拡張に開放、変更に閉鎖するために、システムモニターは具体的なイベントではなく、抽象的なイベントクラスとやりとりすべきです。

class Event:

  def __init__(self, raw_data):
    self.raw_data = raw_data

  @staticmethod
  def meets_condition(event_data: dict):
    return False

class UnknownEvent(Event):

  """A type of event that cannot be identified from its data"""

class LoginEvent(Event):

  @staticmethod
  def meets_condition(event_data: dict):
    return ( event_data["before"]["session"] == 0 and event_data["after"]["session"] == 1 )

class LogoutEvent(Event):

  @staticmethod
  def meets_condition(event_data: dict):
    return ( event_data["before"]["session"] == 1 and event_data["after"]["session"] == 0 )

class SystemMonitor:
  """Identify events that occurred in the system."""

  def __init__(self, event_data):
    self.event_data = event_data

  def identify_event(self):
    for event_cls in Event.__subclasses__():
      try:
        if event_cls.meets_condition(self.event_data):
          return event_cls(self.event_data)
      except KeyError:
        continue

    return UnknownEvent(self.event_data)

ここはポリモーフィズムも利用し、Eventクラスの子クラスにはそれぞれのmeets_conditionメソッドを実装し、identify_eventでは子クラスではなく、親クラスと直接やりとりすれば良い。これだと、イベントがどのくらい増えても、identify_eventを変更する必要がありません。増えたイベントのクラスをまた作り、meets_conditionを実装しておけば問題は解決できます。

こういった、「抽象向け」の設計こそ、開放・閉鎖の原則の真髄でもあるでしょう。

Liskov's Substitution Principle

リスコフの置換原則。もしSがTの子クラスならば、TのオブジェクトをSに置き換えされても、プログラムを破壊することはないはず、とのことです。

これはインターフェイスの設計に強調されます。共通のインターフェイスを持っていれば、プログラムは正しく実行できると。

また、契約設計にも共通点がありますが、前置き条件と後置き条件について:

  • 子クラスは親クラスより厳しい前置き条件を設定してはならない
  • 子クラスは親クラスより緩い後置き条件を設定してはならない

いずれに違反すると、子クラスで親クラスを置き換えする時にプログラムが破壊される可能性があります(契約違反になるため)。前節のシステムモニターの例で考えると:


class Event:

  def __init__(self, raw_data):
    self.raw_data = raw_data

  @staticmethod
  def meets_condition(event_data: dict):
    return False

  @staticmethod
  def meets_condition_pre(event_data: dict):
    """Precondition of the contract of this interface.
    Validate that the "event_data" parameter is properly formed.
    """

    for moment in ("before", "after"):
      assert moment in event_data, f"{moment} not in {event_data}"
      assert isinstance(event_data[moment], dict)

class SystemMonitor:
  """Identify events that occurred in the system."""

  def __init__(self, event_data):
    self.event_data = event_data

  def identify_event(self):
    Event.meets_condition_pre(self.event_data)
    event_cls = next(
      ( event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data) ),
      UnknownEvent,
    )
    return event_cls(self.event_data)

class TransactionEvent(Event):
  """Represents a transaction that has just occurred on the system."""

  @staticmethod
  def meets_condition(event_data: dict):
    return event_data["after"].get("transaction") is not None

class LoginEvent(Event):

  @staticmethod
  def meets_condition(event_data: dict):
    return event_data["before"].get("session") == 0 && event_data["after"].get("session") == 1

class LogoutEvent(Event):

  @staticmethod
  def meets_condition(event_data: dict):
    return event_data["before"].get("session") == 1 && event_data["after"].get("session") == 0

この変更では、契約設計の考え方で、前置き条件として、
1)event_datadictタイプかつ、
2)beforeafterとのキーが中にある、
3)さらにそのバリューもdictタイプであることを設定しました。
前置き条件に満足できると、イベントのクラスをジェネレーターから取得しています(ここのmeets_conditionは各自の子クラスが書き換えたメソッド)。

このリスコフの置換原則が契約設計、前節の開放・閉鎖原則と深く関わっています。継承のクラス階級を考え、抽象向けに、契約を守るように設計するとより丈夫な(robust)コードになるでしょう。

Interface Segregation Principle

インターフェース分離の原則。簡単に言えばインターフェースに盛りすぎずに、小さく分割すること。場合によってインターフェースの抽象化・継承も必要。

これは単一責任とも関わりますが、インターフェイスを作る際に、できるだけ小さくした方(メソッドを少なくないし1つのみ)が良い。コードの再利用、高い凝集度にもつながります。というのは、メソッドの間の関連度に関わるが、メソッドが多いインターフェースになると、仮に継承先が全てのメソッドを使わなくても、実装しなければなりません。それに、インターフェースを変更するには、もし関連度が低いないし直交になってしまうと、二つ以上の理由でこのインターフェイスを変更することになります。

例えばシステムモニターにイベントローデータの解析のために、インターフェースを作るとします:

import ABC, abstractmethod

class EventParser(ABC):

  @abstractmethod
  def from_xml():
    pass

  @abstractmethod
  def from_json():
    pass

これをそれぞれのイベントに継承させ、xmlやjsonのデータの解析を実装してもらうと。ただ、実際にこの二つのメソッド、from_xmlとfrom_jsonは並行していて、関係がないとも言えます。なので、一つのインターフェースにするよりも、二つに分割した方が良いとのことです。

結局どのくらい小さくするか、必ず1つでないといけないのか、とも言えません。メソッドの間の関連度・結合度を考慮した上で決めるべきでしょう。

もう一つの例はこちらの文章にありますが、構造の図だけ載せます:

ここから

ここへと

個人的にこちらの例が本の記述よりはっきりしていてわかりやすいと思います。

Dependency Inversion Principle

依存性逆転の原則。非常に興味深い原則であり、個人的に一番衝撃的だと思いました。

一つの例を考えると、AとBの二つのクラスがあるとします。AはBのインスタンスを処理していますが、直接Bをコントロールしているわけではなく、Bが他のメンバー・ライブラリー・パッケージだとします。これでもしBで何か変更があれば、Aの処理コードが実行不可になってしまいます。つまり、Aは強くBを依存していることです。

こういう現象を防ぐために、依存性を逆転しなければなりません。AがBを依存するのではなく、BがAに適応していくように。先ほどのシステムモニターの例を考えると、例えばイベントのデータをログに記録するとします。ここでEventStreamerSyslogのクラスがあり、イベントデータをシスログに送る。

この設計では、EventStreamerSyslogに強く依存している。もしデータを送る方法、送り先を変更しようとすると、EventStreamerを変えなければなりません。

解決策として、EventStreamerにインターフェースを与えて、具体的なSyslogと分離させることです。

この設計では、EventStreamerSyslogと直接やりとりせず、Syslogがただsendメソッドを実装したクラスの一つになります。これで、EventStreamerが全てのsendを実装したクラスと協働できます。Syslogと他のsendを実装するクラスが、DataTargetClientのインターフェースを拡張し、sendを定義しなければなりません。つまり、EventStreamerSyslogに対する依存関係を、Syslogなどが、インターフェースを通してEventStreamerへ依存するように逆転させました。

SOLID原則はそれぞれ独立したものではなく、良いコードの設計の異なる側面を強調しています。実生活の例で考えると、コンセントと電気製品は一部のSOLID原則にしたがっている:電気製品の電源がプラグの形状を守る限り、コンセントを変更する必要がありません。プラグの形状は電気製品に依存するのではなく、全ての電気製品電源が決まったプラグにしなければなりません。これは、エンジニアリング全体的に共通できる積極的な意味があるとも考えられるでしょう。

こういったフィロソフィーを念頭において、実践を繰り返しながらより良いコードを書いていけば良いなと思っていますが、まだ先が長そうですね。

ではでは、より良いコーディングライフを!

Discussion