LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


コードの可読性についてのプレゼンテーション紹介 vol. 4: "依存関係" 編

はじめに

こんにちは。コミュニケーションアプリ "LINE" の Android クライアントチームの石川です。

この記事は、 "コードの可読性についてのプレゼンテーション紹介" の不定期連載記事の第四回です。前回の記事はこちらです。

今回は、型と型の "依存関係" の話として、第六章と第七章の解説をします。

設計やコーディングを行う際、型同士の依存関係というものは避けられません。例えば、ある型を継承する、インスタンスを引数として受け取る・返り値として使う・メソッドを呼ぶなどをした時点で、その型に対する依存関係が発生します。ただし、無計画に依存関係を作ると可読性や頑健性を損なってしまいます。ここでは、依存関係をどのように扱ったらよいかということを、結合度・方向・重複・明示性の四つの観点から解説します。

結合度

結合度とは依存関係の強さを示す指標です。ここでは、特に避けるべき、もしくは緩和させるべき強さの結合として、内容結合・共通結合・制御結合の3つを取り上げます。それぞれの結合の解決方法の詳細については、プレゼンテーションを参照してください。

1.  内容結合 (content coupling)

内容結合とは、対象の内部実装に直接依存することです。極端な例ですと、ある手続きの特定のラベルに、外部から直接ジャンプしてしまうことなどが挙げられます。近代的なプログラミング言語においては、ジャンプの方法を制限したりすることで、この内容結合を起こしにくくしていることが多いです。ただし、あるオブジェクトの内部状態に依存するコードを書いてしまうと、内容結合と同等に強い結合になります。ここではその例として、不正な状態が存在する設計例を示します。

まず、 `Calculator` という数値計算をするクラスを作りたいとします。もし、この `Calculator` を使う際に、前処理や後処理に特別な手順が必要であったり、事前・事後条件が必要であったりすると、呼び出し側は `Calculator` の内部状態に依存してしまいます。

class Caller {
    fun callCalculator() {
        calculator.parameter = 42
 
        calculator.prepare()
        calculator.calculate()
        calculator.tearDown()
 
        val result = calculator.result
        // result を使うコード
    }
}

この例では、値の取得や結果の返却にプロパティを使い、前処理や後処理にそれぞれ `prepare()` と `tearDown()` を呼んでいます。このように、使用の手順が決まっているクラスを作ってしまうと、`prepare()` を呼び忘れるといった不正な使い方をしたときにバグの原因になります。また、不要な状態を作ることで、並行して複数回この手続きを呼ぶと、競合状態を発生させてしまいます。

このような結合は、内部状態を削除・隠蔽したり、値の受け渡しに引数や返り値を使うことで解消できます。

2.  共通結合 (common coupling)

共通結合とは、大域的な状態を使うような依存関係です。具体的には、大域変数や可変なシングルトンオブジェクト、ファイルなどの単一の外部リソースを使って状態を共有すると発生する結合です。ファイルなどのリソースは使わざるを得ないことが多いのですが、大域変数や可変シングルトンオブジェクトは、可能な限り使用を避けるべきです。

ここでは、レポジトリクラスのインスタンスを大域変数に保持する例を挙げます。

val USER_DATA_REPOSITORY = UserDataRepository()
 
class UserListUseCase {
    suspend fun invoke(): List <User> = withContext(...) {
        val result = USER_DATA_REPOSITORY.query()
        // snip
    }
}

可変オブジェクトを大域変数に保持すると、その管理が困難になります。例えば、ライフサイクルや参照を制限できなくなる上、仕様変更やテストのために他のオブジェクトに差し替えることも困難になります。

これを解消するには、大域変数を使わずに、コンストラクタ引数として対象のオブジェクトを渡すと良いでしょう。その結果、対象オブジェクトのライフサイクルや参照などは、コンストラクタ呼び出し側で管理することができます。

3.  制御結合 (control coupling)

制御結合とは、引数に応じて動作を分岐させるような依存関係のことです。例えば、真偽値や列挙型の値を受け取り、それに応じて異なる動作を行う手続きを作ると、制御結合が発生します。特に、その異なる動作間で共通する部分が少ない場合や、分岐が広い範囲にまたがる場合は、結合を緩和するべきです。制御結合の緩和策の例として、以下のような方法があります。

  • 手続き自体を分離し、条件分岐を削除する
  • ロジックを条件で分けずに、対象で分ける
  • ストラテジーパターンを利用する

ここでは、"ロジックを条件で分けずに、対象で分ける" 方法について説明します。これは、各条件で操作の対象が共通する場合に有効です。例として、以下のような UI の更新を行う手続きがあるとします。

fun updateView(isError: Boolean) {
    if (isError) {
        resultView.isVisible = true
        errorView.isVisible = false
        iconView.image = CROSS_MARK_IMAGE
    } else {
        resultView.isVisible = false
        errorView.isVisible = true
        iconView.image = CHECk_MARK_IMAGE
    }
}

この手続きの良くない点としては、次の二点が挙げられるでしょう。

  1. 手続きの概要を把握するために、各条件における動作の詳細を見る必要がある
  2. 対象や条件が増えた場合にコードが煩雑になり、かつ、バグを起こしやすくなる

この手続は、 `isError` の値に関わらず、 `resultView`, `errorView`, `iconView` の三つの対象を共通して操作しています。この場合は、`isError` による分岐を対象毎に分割することで、手続きを簡潔かつ理解しやすくすることができます。以下に改善例を示します。

fun updateView(isError: Boolean) {
    resultView.isVisible = isError
    errorView.isVisible = !isError
    iconView.image = getIconImage(isError)
}
 
fun getIconImage(isError: Boolean): Image =
    if (!isError) CHECk_MARK_IMAGE else CROSS_MARK_IMAGE

依存の方向

原則として、依存関係は一方向であるべきです。依存を循環させた場合、手続きの流れを追跡しにくくなったり、状態の管理が煩雑になったりします。依存の循環を避けるためには、個々の依存関係の方向が正しいかを調べると良いでしょう。以下に、正しい依存関係の例を示します。

  • 呼び出す側が呼び出される側に依存する
  • 具象が抽象に依存する
  • 複雑なものが単純なものに依存する
  • アルゴリズムがデータ型に依存する
  • 頻繁に変更されるものがあまり変更されないものに依存する

しかしながら、コールバックを使いたいときなど、依存の循環が必要になる場合があります。その場合でも、そのコールバック自体を削除する・コールバック部分を展開して依存を半順序関係にする・プロミスパターンを使って緩和することができないかを考えてください。また、依存の循環が発生したとしても、できる限りその範囲を局所的にすると良いでしょう。

関係の重複

依存関係は、常に一対一の関係であるとは限りません。例えば、基本的なユーティリティやデータ型は、様々な型に依存されるでしょう。この時、ある依存対象の組み合わせが、多くの型に共通して出現することがあります。この共通する依存対象の組をまとめるための層を作ると、より頑健かつ可読性の高い設計になることがあります。

例として、ユーザのデータを提供するレポジトリクラスを、ローカルキャッシュ用とリモートデータ用に二つ作る場合を仮定します。この時、ユーザの名前の表示機能・ユーザ画像の表示機能など、ユーザに関わる様々な機能は共通して、ローカルキャッシュとリモートデータの両方のレポジトリに依存することになります。その結果、ローカルとリモートのどちらのデータを使うかを選択する手続きが、それぞれの機能に重複して実装されてしまうでしょう。また、新たな機能やレポジトリを追加する際の実装も煩雑になります。

このようなときは、ローカルキャッシュとリモートデータを隠蔽する新たなレポジトリの層を作ると良いでしょう。その結果、データの選択をする手続きの重複を避けられ、新たな機能やレポジトリも追加しやすくなります。ただし、ここで以下の二点に気をつける必要があります。

  • 隠蔽する層は必要になったときに初めて実装する (YAGNI, KISS)
  • 隠蔽された型を露出する方法を提供しない

今回の例では、ローカルキャッシュしかない場合は隠蔽する層を作るべきではなく、また、隠蔽する層は直接ローカルキャッシュやリモートデータのレポジトリの参照を提供すべきでないと言えます。

依存の明示性

多くの場合、クラス図などを描くことで型の依存関係を明示できますが、中には図に現れない依存関係が存在します。例えば、抽象型を引数の型として定義しておきながら、実際にはある具象型が渡されることを期待する手続きを書くと、その具象型への暗黙の依存関係が発生します。他にも、任意の文字列を引数に取る手続きがあり、すべての実引数はあるデータ型から変換された文字列である場合、その手続きはデータ型に暗黙的に依存していると言えます。このような暗黙的な依存関係は、クラス図では現れることはなく、静的解析ツールでも発見が困難であることが多いです。

暗黙的な依存は、手続きの流れの追跡を困難するため、コードの可読性を低下させます。また、暗黙的に依存されている型を変更した場合は、依存している側が期待する前提を壊すことでバグの原因となる上に、その検出も難しくなります。暗黙的な依存を削減するためには、不要な継承関係を削除したり、引数や返り値の変域を明示するための型を定義すると良いでしょう。

おわりに

今回は "依存関係" 編として、結合度・方向・重複・明示性の四つの観点から解説しました。

次回は最終回、 "レビューとまとめ" 編として、コードレビューの方法の解説と本連載のまとめをします。→ 公開いたしました。