LoginSignup
47
40

どうしてあなたの共通化は間違っているのか:第2章「抽象度と文脈」

Last updated at Posted at 2024-03-10

どうしてあなたの共通化は間違っているのかの目次はこちら


はじめに

この記事では、モジュール分割において最も重要な概念でありながら、あまり言語化されることのない2種類の主要な性質である、抽象度と文脈依存度について解説します。見慣れない概念だとは思いますが、基本的な例から始めて分かりやすく解説するので、ぜひついてきてください。

いいね・ストックが励みになります!

モジュールとは

ソフトウェアを分割する何らかの単位のことです。クラス・関数・パッケージなど様々な設計単位があります。
モジュールは別のモジュールを呼び出すことが出来ます。呼び出した側を上位モジュール、親モジュールと呼び、呼び出された側を下位モジュール、サブモジュール、子モジュールと呼びます。

サブモジュールに分割するメリット

一般的に、モジュールを分割してサブモジュールを作成するときは以下に列挙するようなメリットがあると言われています。しかしながら、これらのメリットは「うまくモジュールを分割できた場合は」という但し書きが付きます。@hirokidaichi さんの新人プログラマに知っておいてもらいたい人類がオブジェクト指向を手に入れるまでの軌跡 | モジュラプログラミング でもこの但し書きについては述べられています。この記事では、うまく分割するための指針として、結合度及び凝集度という概念で「同一の責任をもつものをまとめるべきだ」「異なる責任を持つものは分離するべきだ」という説明がされていますが、このような説明は第1章でも述べた通り、設計の指針として不十分です。

変数のスコープを減らすことが出来る

特定の役割でしか使われない変数をサブモジュールのローカル変数にすることが出来ます。これにより、以下のようなメリットが得られます。

  • 変更の影響範囲をスコープ内に閉じ込める
  • 可読性の向上:意味の単位ごとに関連する変数をまとめることが出来るため、変数の役割が所属するサブモジュールによって限定され、可読性が向上する。また、呼び出し元のモジュールからしてみれば自分とは関連の薄い変数が登場しなくなるため、呼び出し元の可読性も向上する
  • デバッグが容易になる:ローカル変数が関与する可能性のある問題を追跡するのがより単純になる
  • 名前の衝突の回避:Configとかflagとかの汎用的な名前は衝突しがちですが、スコープを閉じることでモジュール名.Configという名前になるため、汎用的な名前でも意図が明確になる

処理に名前が付けられる

ソフトウェアにおいて、名前は重要視されています。例えば、プログラマが知るべき97のこと 名前重要 (Matz)などでも述べられています。モジュールの分割により処理を命名できることから、次のようなメリットが得られます。

  • 全体の流れが分かりやすくなり、可読性が向上する
  • エラーが起きたときにスタックトレースが詳細になり、「全部処理メソッドの3653行目」に比べて「○○部分の××処理メソッドの14行目」というエラーになるため読みやすくなる

再利用しやすくなる

適切なサブモジュールを作成すれば、再利用を行って開発効率を向上させることが出来ます。

  • ミスを防げる:同じような処理を人間が繰り返すと、ひとつぐらいはミスをしてもおかしくない
  • 品質保証が容易:複数回利用されるモジュールは、テストのコスパが良い
  • コードリーディングのコストの低減:一度理解したモジュールが複数回利用されるため、コードを読みやすくなる
  • 最適化が出来る:高度な最適化は手間がかかり、コードの読み取りにも手間を要するが、複数回利用されるモジュールであれば、最適化の価値が高まる
  • コードの規模が小さくなる

変更の影響範囲を閉じられる

モジュールを分割すると、処理に名前を付けて「意味」と「処理」を分離できるようになります。意味が変わるとき、必ず処理が変わりますが、処理が変わっても意味が変わらない場合があります。
例えば、最短距離探索モジュールを、最短移動時間探索モジュールに書き換えなければならない場合、明らかに処理内容も書き換える必要がありますが、最短距離検索のアルゴリズムを差し替えても全体としてモジュールが果たす役割、つまりモジュールの意味は変化しません。

そのため、モジュールの外部から見た振る舞いさえ変えなければ、モジュール内容は好き勝手に書き換えても問題ありません

サブモジュールごとに分業して開発することが出来る

サブモジュールと呼び出し元のモジュールのシグネチャが適切に定義されていた場合、サブモジュールの実装はどうあっても良いため、分業できるようになります。これにより開発がスケールするようになります。

モジュール分割の注意点

教科書的にはこのように説明されます。しかし、具体的に何がどうなったからモジュール化のメリットを享受できなくなったのでしょうか。それを理解するためには、サブモジュールに分割することによって、サブモジュールが元のモジュールとどのように変化したか、元のモジュールがサブモジュールの処理を失ったことでどのように変化したかを理解する必要があります。

意味と抽象度

モジュールの意味を次のように定義します。

モジュールの意味
モジュールの内容から推定できるモジュール自体の処理の役割や目的

モジュールの抽象度を次のように定義します。

モジュールの抽象度・抽象化

  • 上位モジュールが、下位モジュールの具体的な内容を知らなくても動作するとき、上位モジュールの抽象度は下位モジュールよりも高いという。またこのとき、下位モジュールの内容は抽象化されているという。また、上位モジュールは下位モジュールよりも抽象的である、ともいう
  • 逆に、下位モジュールがモジュールの意味以上の知識を上位モジュールに要求する場合、抽象化に失敗しているという

文脈と文脈依存性

定義

モジュールの持つ文脈を次のように定義します。

モジュールの文脈
モジュールの内容から推定できる上位モジュールの内容

文脈依存性については次節で解説します。

定義の解説

定義の文章が簡潔すぎて少し混乱すると思うので、詳しく解説します。
モジュールを読んだときに分かる情報は次の3点のはずです。

  • モジュール自体が何をしているのかの動作内容
  • モジュール単体でどのような役割・目的があるのか
  • このモジュールを呼び出す処理は何者なのか、どうしてこの処理を呼び出しているのか

これらの内容を上から順に「内容」「意味」「文脈」というように区別して呼んでいるのがこれまでの定義です。具体例を見ていきましょう。

  • 例1:Webアプリ
    Webアプリにおいて、「保存したい内容を指定すると暗号化・署名してCookieに保存する機能」をモジュール化したとします。
    • 内容:署名、暗号化のアルゴリズムは?鍵はどう保存する?Cookieの大きさの4KB制限はどう対処する?Cookieのドメインは?名前は?どの順番でどの処理をする?などがモジュール内部に書かれているはずです
    • 意味:保存したい内容を指定すると暗号化・署名してCookieに保存する、がそのままモジュールの意味です
    • 文脈:何を保存しているのか、どのようなタイミングで保存処理が必要なのかについて知りません。しかし、最低限Cookieを使っているので、ブラウザと対話するWebアプリケーションであることは分かります
  • 例2:ターン制対戦ゲーム
    ターン制対戦ゲームにおいて、「ダメージ計算モジュール」から呼び出される「攻撃力計算モジュール」があったとします。
    • 内容:武器の攻撃力とキャラクターの攻撃力を足すのかもしれません、乱数幅があるのかもしれません
    • 意味:武器の攻撃力とキャラクターの攻撃力から、特定のゲームにおける特定のダメージ計算における攻撃力を計算する、という意味があります
    • 文脈:どのゲームの処理なのかを知っていますしダメージ計算で使うことも知っています。また、どのタイミングで使われる攻撃力なのかも知っています。例えば、PvP用の攻撃力計算とレイドバトル用の攻撃力計算では処理が違うかもしれません

文脈の強さ

さて、前節では上位モジュールは下位モジュールの内容を知らないという状態を指して抽象化されていると呼びました。逆に、下位モジュールはどの程度まで上位モジュールの情報を知っているのでしょうか。前節の2つの例を思いだしてください。Webアプリの例では、文脈に含まれる情報は比較的少なかったです。対して、ターン制対戦ゲームでは、文脈はどのモジュールが自分を呼び出すのかについてまで詳細に把握していました。つまり、上位モジュールに関する下位モジュールから得られる情報については、強弱が存在します。文脈には強さが存在する、と言い換えてもいいでしょう。強い文脈ほど複数のモジュールに共有されやすく、弱い文脈ほど一部のモジュールだけにしか共有されません。文脈の強さを次のように定義します。

文脈の強さ
文脈Aの持つ情報が、文脈Bの持つ情報よりも少ないとき、AはBよりも強い

文脈の強さは常に決められるとは限りません。

英単語暗記アプリで、単語帳の情報を文字列に書き換える処理を作っていたとします。単語帳は暗記カード(単語と意味の組を表すもの)とメタデータ(単語帳の色、タイトルなど)から構成されているとします。このとき、単語帳を文字列で書き換える処理は内部的に2つのモジュールを呼び出します。

  1. 暗記カードの文字列化
  2. メタデータの文字列化

このとき、これらの下位モジュールは、暗記カード・メタデータという概念は単語帳という概念の下位概念であることから、文脈として単語帳の文字列化の一部として呼び出されるはずだという情報を持ちます。(暗記カード単体で文字列化するようなことは起こらないというドメイン知識を想定してください)したがって、これらのサブモジュールは以下の文脈を持ちます。

  • 単語帳の文字列化モジュールがもともと持っていた文脈
  • 単語帳を文字列化するという文脈

抽象化によって、どのように文字列化するかの具体的な処理は隠されますが、少なくとも暗記カード・メタデータを文字列化するということ自体は分かります。

このとき、単語帳の文字列化の文脈は、暗記カードの文字列化モジュールの文脈よりも強いです。なぜなら、単語帳の文字列化モジュールの文脈に含まれる情報はサブモジュールよりも完全に少ないからです。

この英単語アプリの別のモジュールとして「暗記カードの情報を画面に表示する」というものがあったとします。このとき、表示モジュールは文脈として「英単語暗記アプリ」「ブラウザで画面を表示する」「主要画面を表示する」といったものを知っているはずです。この表示モジュールと前述の文字列化モジュールは強弱が決定できません。片方だけが知っている上位モジュールに関する情報を互いに持っているからです。

文脈依存性

モジュールの文脈依存性を次のように定義します。

モジュールの使える場所が、文脈となるモジュールの配下全てであるとき、モジュールに文脈依存性がない。
逆に、文脈となるモジュールの直下のみであるとき、モジュールに文脈依存性がある。

文脈依存性がある例

  • アプリケーションのmain関数で、initConfig()を呼び出したとき、このモジュールが読み込むのは、あくまで読み込んだアプリケーションの設定です。この世のすべてのソフトウェアがとある共通の設定を持っていて、それを読み込む、というわけではありません。したがって、このinitConfig()は特定のアプリケーションの初期化時の設定読み込み処理のときにしか使いません
  • インタプリタを自作するときに、main関数が呼び出している「字句解析」モジュールは、あなたの言語で使われるトークンを解析するためのものです

文脈依存性がない例

  • 「フラグ文字列のパース」はコマンドライン風のインターフェースを持つツールならばどこで呼び出してもかまいません
  • 「アプリケーション独自ヘッダーの書き込み」は作成しているHTTPベースのWebアプリケーションのどこで呼び出しても構いません
  • 「暗号化」は暗号を使うソフトウェア全てが好きな場所で呼び出して良いです
  • ライブラリとして提供されている「字句解析」モジュールは、トークンの内容を示す正則表現を与えることで任意の言語の字句解析に対応できるようなモジュールです。つまり、任意の言語の自作に対応することが出来ます

同じ名前でも、文脈によって全く違う内容になります。 文脈依存性があるタイプの「字句解析」は、自作言語のインタプリタの処理系のmain関数から呼び出されており、自作言語のトークンの特徴が書かれているため、「これはXX言語の字句解析処理だ」という文脈が分かり、その結果文脈依存性のある処理になります。対して、文脈依存性がないタイプの字句解析はモジュールの中身からはすべての字句解析が必要なプログラムに用いられる情報しか読み取れません。つまり、文脈依存性のある「字句解析」に比べて文脈が強いのです。

モジュール分割と文脈

親モジュール

モジュール分割を行うことで、親モジュールの文脈は維持されます。なぜなら、サブモジュールに適切な名前がついている限りは、抽象度が上がるだけで結局親モジュール内部でサブモジュールが何を行うかは記述されるからです。例えば、Cookieに保存する機能の例ではSaveSessionDataToCookie()が呼び出されるため、結局どのような内容のモジュールであるかについて得られる本質的な情報は変わりません。抽象化によって隠蔽されるのはあくまで「クッキーに暗号化・署名したデータを保存する」という処理のためだけに必要なローカル変数だけなので、抽象化で隠蔽される部分は結局関数名で伝わる情報だけを伝えることになります。(上位モジュールにとって、保存先がCookieであることが重要でない場合はSaveSessionData()になるかもしれませんが、その場合も親モジュールの情報は特に変わらないです)

子モジュール

モジュール分割を行うことで、子モジュールは親モジュールの文脈のうち一定以上の強さを持つものを受け継ぎます
子モジュールの文脈依存度の変化はかなり変則的なので、図を用いて詳しく説明します。
chap2fig1(1).jpg

抽象的なモジュールが持っている文脈は当然抽象的であるため、変わりにくいという特性を持ちます。したがって、強い文脈ほど維持されやすいのです。
上記の図において、処理Eの親は、文脈ABCを持つ処理Dであるため、処理Eの文脈としてありうるものは次のいずれかです。

  • ABCD
  • ABC
  • AB
  • A
  • 無し

図の例においては、Aが文脈として選ばれています。つまり、処理Eは処理Aの子ノードの処理ではどこに現れてもおかしくない汎用的な処理であるということを意味します。

処理Fの親は、文脈Aを持つ処理Eであるため、処理Fの文脈としてありうるものは次のいずれかです。

  • AE
  • A
  • 無し

図の例において、AEが文脈として選ばれています。つまり、処理Fは処理Eの子として以外ありえないと分かります。

文脈依存性に関する重要な性質

これまでの説明と文脈依存性の定義から、次の重要な性質が導き出せます。

子モジュールの文脈が、親モジュールの文脈から想定されるもっとも情報が多いものでない場合、子モジュールは文脈依存性がない。

文脈依存性とは文脈となるモジュールの直下以外で呼び出せない性質なので、前節の処理Eの文脈がAでしたが、仮に文脈依存性があるならば処理Eは処理Aの直下に存在しなければならず、矛盾します。

最上位の文脈

モジュール分割では通常、分割結果の木の最上位には実装するソフトウェア自体が来ます。しかし、文脈依存度の観点からすると、より上位のノードが理論上存在します。それは、ソフトウェアを種類ごとに分割した抽象的なカテゴリーです。

  • Cookieを使うWebアプリ
  • 暗号化を使うソフトウェア
  • テキストベースのソフトウェア
  • UnityのGUIフレームワークであるuGUIで画面を制御するソフトウェア

このようなカテゴリは仮想的により抽象度が高い場所に存在しており、実装するソフトウェアにより強い文脈を提供します。

具体例

ある程度大きなソフトウェアでの抽象化・文脈の実例を見てみましょう。下図の図は、CLIアプリケーションを作成するためのフレームワークをモジュールに分割した様子を示しています。アプリケーションの内容は、Golang製のOSSであるCobraのようなものをイメージしてください。

chap2fig1.jpg

途中までは「そのCLIフレームワークにおけるフラグと指定された値の解析」「そのCLIフレームワークにおけるフラグと指摘された値の解析のためのコマンドラインから指定された情報の抽出」というように、文脈依存性のない抽象化が行われていますが、最後にはテキスト処理全般で使えるであろう、文脈が少なく、文脈依存性のないモジュールが現れています。

抽象化によるメリット

ここまでで、文脈依存性の有無と抽象化という概念を区別して説明しました。これにより、「適切に分割していれば」得られるモジュール化のメリットについて、具体的にどのようなモジュールについて得られるのかを明確に説明することが出来ます。

  • 変数のスコープを減らすことが出来る:抽象化によって具体的な実現手段に関連する変数については上位モジュールから隠蔽することが出来ます
  • 処理に名前が付けられる:抽象化するときは、処理の塊の意味と内容を分離して、モジュールに意味を表す名前を付けます
  • 変更の影響範囲を閉じられる:抽象化によって上位モジュールは具体的な処理内容を知らないようになります。知らないということは処理内容を書き換えても、それによって影響されることが原理的にない、ということです
  • サブモジュールごとに分業して開発することが出来る:適切に抽象化が出来ていれば、モジュール間の依存関係を大きく減らすことが出来るため、独立して実装していい場所が生まれます。変更の影響範囲が閉じている範囲では分業することが出来るのです

抽象化のよくある誤解

抽象化しても再利用性は向上するとは限りません

ここで説明したように、抽象化とは上位モジュールから見たときの下位モジュールの処理の意味を隠ぺいすることです。ここでは、再利用がどのような場合に可能かなどについては全く考えていません。そのため、再利用性が向上しないが抽象化の恩恵だけを得られる場合が存在します。例えば、とあるアプリケーションのmain関数で、initConfig()を呼び出した例では、当然帰ってくる設定は特定のアプリケーション限定の設定なので再利用性は皆無ですが、ファイルはどこにあるのか、ファイルのスキーマはなにかなどのコンフィグ読み込みの詳細は隠蔽できているため、抽象化のメリットは得られています。
そのため、条件抜きに「抽象化したほうが再利用性が上がるよ」と説明することは間違っています。どのような場合になぜ再利用性が上がるのかを明確にすることが必要です。
どのような場合に再利用性が向上するのかについて、詳しくは第3章の文脈を保持する抽象化と保持しない抽象化で解説します。

文脈が強いことのメリット

文脈が強いと、モジュールが変更される可能性が下がります。なぜなら、モジュールが変更される理由は次の2通りだからです。

モジュールの変更理由

  1. 上位モジュールの仕様変更によって文脈を満たす上位モジュールが存在しなくなること
  2. モジュールの内容のうち、下位モジュールによって抽象化されていない部分が仕様変更をうけること

1つ目の変更理由は、自分が文脈を持っている処理がなくなることに起因しています。文脈とはすなわち、上位モジュールに関してモジュールが持っている仮定です。したがって、文脈を破壊する形で上位モジュールが変更されてしまうと、そのモジュールも呼ばれなくなってしまいます。

2つ目の変更理由は、単にモジュールでやっている内容が変更されたので、モジュール自体を書き換えなければならないというものです。

1つ目が「上位のせいで下位が書き変わる」、2つ目が「自分自身のせいで書き変わる」なので、論理的には3つ目の「下位のせいで上位が書き変わる」という変更理由が想定できます。しかし、抽象化によって3つ目の変更は起こらないようになっています。逆に言えば、抽象化に失敗してしまうと、モジュールの変更理由が1つ無駄に増えてしまい、保守性が下がります。

文脈依存性があるモジュール分割における仕様変更

文脈依存性があるモジュールは、上位モジュールが変更されれば自分の文脈が知っている範囲が影響を受けるため、自分自身も仕様変更を受けることがあります。仮に上位モジュールの修正が大規模なものであれば、モジュール自体が削除されます。

例えば、対戦ゲームで、ダメージ計算処理を止めよう、というレベルの仕様変更が入った場合、攻撃力計算サブモジュールは文脈として「ダメージ計算をする、ダメージ計算に攻撃力を使う」ことを知っているため、捨てる必要があります。
他にも、画面から吹っ飛ばされたら負けではなくHPが0になったら負けにしよう、というレベルの仕様変更が入った場合、画面から吹っ飛ばされたときの演出サブモジュールは捨てる必要があります。

仕様変更の起こりやすさ

対戦ゲームにおける次のような文脈依存性があるモジュールの分割を考えます。
[図]
このソフトウェアにおいて、次の3種類の仕様変更を比べてみます。

  • ダメージ計算をしなくなる
  • 攻撃力という値を使わなくなる
  • 攻撃力計算の方法が足し算ではなくなる

この3つは抽象度が異なるモジュールの仕様変更です。最も抽象的なダメージ計算をしなくなるような仕様変更では、もはやゲーム性が完全に別になってしまいます。逆に、攻撃力計算の具体的な方法についてはゲームバランスを調整するうえで変更される可能性が高いでしょう。このように、具体性が高い処理の詳細ほどソフトウェアの仕様を設計する際にどうするべきか確定しきらないため、変更確率が高くなります。

文脈依存性がないモジュール分割における仕様変更

文脈依存性がないサブモジュールは、上位モジュールの仕様が変更されても、自分の文脈に関係ない範囲であれば、単にモジュール自体が呼び出されなくなるだけで済みます。

Cookieにデータを保存するモジュールは、Cookieという仕組みがブラウザで使われなくなるまでは削除されませんし、Cookieの仕様が変わらない限り仕様は変更されません。上位モジュールの文脈が「業務アプリのWebサーバー」などのより弱い文脈をもつ場合には、上位モジュールの仕様が変更されても、単に特定のアプリケーションがCookieを使わないというだけのことであり、Cookie自体の仕組みを変更する理由になりません。

抽象度と変更可能性のよくある誤解

「抽象度が高いモジュールの変更可能性は低い」という説明は本質的に誤りです

確かに、文脈依存性があるモジュール分割においては正しいため、この説明は一定の指針を提供します。しかし、本質的にモジュールの変更可能性を規定しているのは内部で呼び出すサブモジュールがどれだけ隠蔽されているかという抽象度では決まりません。モジュールの変更可能性はあくまでモジュールのもつ文脈の強さによって決まります。

例えば、足し算という処理はプログラマが実装する大多数の処理よりかは具体的なはずです。プログラマはただの足し算を呼び出す内容の処理に何らかの意味を与えて抽象化を行っているからです。しかし、足し算の処理内容が大きく変わることは考えにくいです。これは、足し算というのがほぼすべてのソフトウェアで日常的に行われている非常に強い文脈のみを持っているからです。文脈と抽象化を正しく理解していない場合は「足し算は抽象的な処理だから変更可能性が低い」といった誤った考え方をしてしまいます。大切なのは文脈です。

文脈依存性が無いことのメリット

文脈依存性が無いモジュールは再利用できます。文脈から自分を呼び出す上位モジュールは何かを読み取ることが出来ないからです。再利用できる範囲は、そのモジュールの文脈の範囲に限定されます。例えば、IsValidURL()を利用できるのはURLを利用するアプリケーションに限られます。

安定依存原則

安定依存原則
モジュールは、自分以上に安定しているモジュールに依存するべきである

Unity設計入門:第1章(補足)「変わりやすいものと変わらないものの分離」でゲームに特化した内容を説明しています。
ここでは、依存先の内容が書き変わったときに依存元は書き換える必要があるのだから、依存するものは自分よりも安定したものを選ぼうという設計思想が述べられています。
これまでの説明してきた文脈に関する理論からすれば、これはより分かりやすい2つの原則に書き換えられます。

新・安定依存原則
モジュールが依存してよいのは、次の2種類である。

  • 自分よりも強い文脈を持つ文脈依存性のないモジュール
  • 文脈依存性を持つモジュールの名前

Cookieの例で説明した通り、強い文脈だけを持つモジュールは、比較的安定している共通の仕様が変わらない限りはモジュールの意味を変えません。したがって、安定依存原則から、文脈依存性を持たない強い文脈だけに依存するモジュールは直接呼び出すことで依存してもよい、という結論が得られます。

おわりに

この記事では、基礎的なモジュールに関する性質について学びました。抽象度と文脈の概念について把握することによって、モジュールの呼び出しがどのように設計上の意味を持つのかをより深く理解できるようになるでしょう。

47
40
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
40