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

Blog


コードの可読性についてのプレゼンテーション紹介 vol. 3: "状態と手続き" 編

はじめに

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

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

今回は、一つの型に閉じた話として、第四章 "状態" と第五章 "手続き" の解説をします。

第四章: 状態

プログラムの実行状態の数やその遷移を減らすことで、プログラム全体の振る舞いが理解しやすくなることがあります。特に、"不正な状態" や "不正な遷移" がある場合、それらを排除することで、可読性と頑健性が大きく向上するでしょう。ただし、状態の削減はあくまでも可読性や頑健性を向上させる手段であり、それ自体を目標にしてはいけないことに留意してください。

この章では、可読性と頑健性を向上させる方法として、"不正な状態を削減する" ことと "状態遷移を単純化する" ことの二点について解説します。

1. 不正な状態の削減

ここで、二つの変数間の関係として、 "orthogonal/non-orthogonal な関係" という概念を導入します。二つの変数があり、一方の値域がもう片方の値に影響を受けないならば、それら二変数の関係を "orthogonal" とします。反対に、どちらかの値域がもう片方に影響を受けるならば、"non-orthogonal" とします。例えば `userId` と `layoutVisibility` は、互いに独立して自由な値を持てるので、これら二値の関係は "orthogonal" です。一方で `userId` と `userName` という変数の場合は、`userId` を変更すると同時に `userName` も更新しなくてはいけないので、これらの関係は "non-orthogonal" です。

この "non-orthogonal" な関係を解消することで、不正な値の組み合わせを削減することができます。その方法は大きく分けて二つあります。一つ目はゲッタープロパティ・関数を使うことで、二つ目は代数的データ型を使うことです。

1-A: ゲッタープロパティ・関数

一方の値が他方の値によって計算可能な場合は、その値をゲッタープロパティ・関数に置き換えることができます。例えば、`userName` の値が `"Alice"` で, `welcomeMessage` の値が `"Hello Alice"` という値ならば、`welcomeMessage` は `"Hello $userName"`(= `"Hello " + userName`) とすることができます。このようにすることで、変更可能な値を `userName` だけに制限できます。そして、`userName` が `"Alice"` なのにも関わらず `welcomeMessage` が `"Hello Bob"` となるような不正な値の組み合わせを排除できます。

1-B: 代数的データ型

二つの値に関連があり、かつ、一方から他方の値を計算できない場合、代数的データ型が有効な場合があります。例えば、クエリーの結果として `resultMessage` と `errorCode` の nullable な2つの値があるとします。ここでクエリーが成功した場合は `resultMessage` が値を持ち、 `errorCode` が `null` になるとしましょう。逆に、クエリーが失敗したら、`errorCode` が値をもち、 `resultMessage` が `null` になるでしょう。これらの二変数において、両方が `null` になる場合や、両方が値を持つ場合は不正になります。このような不正な組み合わせを、Kotlin では以下のような sealed class を用いることで排除できます。

sealed class Result {
  class Success(message: String): Result()
  class Error(errorType: ErrorType): Result()
}

この `Result` は、`Success` か `Error` のどちらかであることが保証され、結果的に `message` か `errorType` のどちらかが丁度一つだけ存在することが保証されます。

少し不正確な表現をすると、ある抽象型が "列挙された型のどれか" であることが保証されるならば、その抽象型のことを代数的データ型と言います。代数的データ型を使うなら、Scala では sealed class, Swift では associated value, C++ では variant で実現できます。言語によっては代数的データ型がサポートされていませんが(例: Java)、その場合は不正な値の組み合わせを隠蔽するように小さなデータ型を作り、コンストラクターやセッターを制限することで代数的データ型に近いものを作れます。

また、不正な値を排除する際に、代数的データ型では強力すぎる場合があります。そのときは列挙型を使えるかを試してみてください。例えば二つの真偽値があり、両方とも `true` になることがないならば、代わりに三状態の列挙型を使うべきです。

2. 状態遷移の単純化

状態の数を減らす以外に、状態遷移を単純化することでも、可読性や頑健性が向上することがあります。ここでは状態遷移のループ、特に値の再利用性について取り挙げます。値を再利用可能にすると性能向上に役立つことがある一方で、可読性や頑健性は低下する場合があります。例えば、`VideoPlayer` というクラスがあるとしましょう。このとき、取りうる選択肢は二つあります。

  1. 一度だけ `VideoPlayer` のインスタンスを作成し、再生する動画を変えたい場合は `play` メソッドに動画のパスを渡す。
  2. `VideoPlayer` のインスタンスを作成する際に動画のパスを指定し、その値は不変にする。再生する動画を変えたい場合は、新たなインスタンスを作成する。

前者は`play` を呼ぶ直前の状態の管理が煩雑になるため、性能に問題がない限りは後者の選択肢を選ぶべきでしょう。仮に、すべての状態 (ロード中・完了・エラーなど) から `play` を呼べるようにすると、`play` の動作が複雑になります。一方で、`play` を呼べる状態を制限する (ロード中の `play` の呼び出しは禁止するなど) と、 `VideoPlayer` の使用方法そのものが煩雑になり、それがバグの原因になりえます。さらに、その両方の実装手段において、状態数が増えたときのコードの変更量が大きくなります。

問題はこれだけではなく、非同期処理の管理の点についても問題が起きます。例えば "一定時間で動画を停止する" といった処理を入れる場合、その停止するタスクの対象の動画と現在再生している動画の同一性を確認するか、`play` を呼んだときに正しくタスクをキャンセルする必要があります。特にマルチスレッド化する際は、競合状態の検証の難易度が上がります。

ただし、状況によってはどうしても状態遷移のループが必要になるときがあります。`VideoPlayer` の例ですと、動画の一時停止と再生を行いたいときがあります。その場合は、状態遷移のループを出来る限り小さくし、巨視的にはループがないかのように振る舞わせると、可読性や頑健性の低下を緩和できます。

第五章: 手続き

手続きの可読性を確認する手段として、ドキュメンテーションの要約を書いてみるという方法があります。第三章で示した要約が書きにくい場合は、手続きの責任と流れが明確であるかを確認するとよいでしょう。

1. 手続きの責任範囲

一つの手続きの責任範囲は、一言で動作を説明できる程度にとどめておくのが好ましいです。それを実現するための原則の一つとして、 "command-query separation" が挙げられます。これは、手続きを値の変更を行う "コマンド" と何かの値を返す "クエリー" に分類し、その両方を行う手続きは作るべきではないという原則です。

例えば、 `List` に `append(List)` というメソッドがあるとします。もし、この返り値が `void` や `Unit` ならば、このメソッドはレシーバそのものを変更する "コマンド" であると予測できます。一方で、返り値が `List` であるならば、このメソッドはレシーバを変更せず、新しく結合されたリストを作成して返すという、"クエリー" のメソッドであると予想できます。ここで、 `List` を返すのにも関わらずレシーバの変更も行ってしまうと、多くの開発者の予想を裏切ることになり、バグの原因になりえます。

しかし、この command-query separation も過剰に適用すると、却って可読性や頑健性を悪化させかねません。例えば、何かの値を変更するコマンドがあり、その値の変更が成功したかどうかを確認する場合、そのコマンド自体が成功したかを示す真偽値を返しても問題ありません。この真偽値を別のクエリーとして切り分けるには、どこかにその真偽値を保存しておく必要があるため、結果としてコードが複雑になります。このように、変更が成功したかを示す真偽値やエラーコード、メタデータといった副次的な値については、コマンドに伴う返り値としても許容されるでしょう。ただし、そのような手続きには、ドキュメンテーションが必須になります。

その他にも、手続きの形式がデファクトスタンダードとして広く知られている場合は、返り値を伴うコマンドを作ることが許容されます。例えば、多くのスタックの実装において `pop` メソッドは、最新の値を取り除くコマンドであると同時に、取り除かれた値を返すクエリーでもあり、このインターフェイスは広く知られています。

2. 手続きの流れ

手続きの流れを明確化するためには、特に以下の3点に気をつけると良いでしょう。

  1. Definition based programming をする
  2. Early return をする
  3. ロジックを場合で分けずに対象で分ける

ここでは特に、本プレゼンテーションで新たに導入した概念の "definition based programming" を解説します。

Definition based programming は、無名関数や実引数、レシーバ、コールチェインといったものを、名前のあるローカル変数やプライベート関数で置き換えるプログラミングスタイルです。この概念がどのように可読性に影響するかを、実引数がネストしている例を用いて説明します。

showImage(convertImage(cropImage(loadImage(imageUri), Shape.CIRCLE), ImageFormat.PNG))

このコードは、イメージを円形に切りとり、PNG に変換して表示するというコードです。しかし、このコードは二つ問題があります。まず、最終的に何が表示されるかを調べるには、コードの詳細を読まなくてはいけません。また、"どのフォーマットに変換するか" や "なんの形に切り取るか" をなどの特定の値を調べる、もしくはそれを変更する場合に、全体を把握しなくてはいけません。

この場合、以下のようにローカル変数を使うと読みやすくなるでしょう。

val originalBitmap = loadImage(imageUri)
val croppedBitmap = cropImage(originalBitmap, Shape.CIRCLE)
val croppedPng = convertImage(croppedBitmap, ImageFormat.PNG)
showImage(croppedPng)

このように書くことで、コードの左側だけを見ればななめ読みができ、詳細を知りたければ右側まで見ればよいという形になります。そして、最終的に何を表示するのかという疑問に対しても、コードの詳細を見ることなく概要を知ることができます。

この definition based programming を実践する際には、どの範囲のコードを置き換えるかについて深く考える必要があります。例えばプライベート関数を作る場合は、副作用は呼び出し側に残し、新しい関数は参照透過にすると、可読性が向上するでしょう。

おわりに

今回は "状態と手続き" 編として、ある型に閉じた範囲での構造について解説しました。特に、"orthogonal/non-orthogonal" と "definition based programming" は本プレゼンテーションで導入した概念なので、少し詳しく説明しました。

次回は "依存関係" 編として、第六章と第七章の内容を紹介します。
公開いたしました。