関数とかクラスとかを切り出すときに考えていること
私の書くコードは読みやすさにまあまあそこそこ定評がある気がしています。
そんな私が読み手の脳に染み込みやすい設計にするために個人的に気にしていることを書きます。
About Me
- 株式会社ヘンリーでエンジニア的なことをしつつ、個人開発してます。
- Social accounts:
- Developing:
関数やクラスを切り出すということ
なぜ分割するのか
なぜコードを分割するのかというと、コードベース全体の大きさや複雑さは人間の認知能力の限界を遥かに超えるからです。巨大で複雑なものを扱うには分割統治が基本戦略です。
コードを分割する手段として、関数、クラス、コンポーネント、パッケージ、モジュール、マイクロサービス、etc… など様々な方法がありますが、これらは「詳細や手続きを隠蔽して別の見せ方をする」ための仕組みという点でだいたい同じです。
関数やクラスの切り出しは、単なる共通化・移動ではなく、理解可能な抽象化であるべきです。
コードのデザインと UI のデザインはだいたい同じ
UI デザインでは、ユーザーが知らなくてもいい技術や複雑さを隠蔽し、よく練られた別の概念として見せ、直感的に理解・操作できるようにします。
関数、クラス、コンポーネントやモジュールをデザインするのも、「どういう詳細をどういうインターフェイスの裏に押し込めるか」を考えるという点で UI デザインと同じです。
抽象化のフラクタル構造
自動車にはアクセルやハンドルといったインターフェイスがあり、内部の機械を隠蔽し車の動かす方法を提供します。運転者視点ではアクセル操作などが目的に思えますが、「A地点からB地点へ移動する」という上位の視点では、それらの操作も手段に過ぎません。
私達が開発するシステムも、「ユーザーに価値を提供する」という大目的に対する1つの手段であり、システムを作るというのはその内部に何層もの目的(インターフェイス)-手段(実装)の構造を作り込むことでもあります。
脳にやさしい関数やクラスの切り出し方
ここでは、関数、クラス、コンポーネント、パッケージ、モジュール、マイクロサービス、etc… などの抽象化単位のことを「関数やクラス」と呼びます。
実装を読みたいと思わせたら負け
よくできたAPIやライブラリは、中の実装を読まなくてもインターフェイス(I/F)から何をしているかやどのように使えばいいかがわかります。自分で関数やクラスを作る場合の理想形もこれと同じです。
コードを切り出し、インターフェイスの裏側に押し込めるのは、「ここから先は読まなくていい」「ここから先のことは考えなくていい」部分を作っていくことで、認知負荷を減らす営みです。
実装を読みに行かないと理解ができないのであれば、極端に言えばその切り出しは失敗しています。
// NG: どのようなフィルタをしているのか中を見ないとわからない
function filterUsers(users: User[]): User[]
// OK: 関数名やシグネチャから挙動が推測できる
function filterActiveUsers(users: User[]): User[]
抽象化するなら詳細を隠蔽しきる
Kotlin や C# のような Nominal Typing な言語では、プリミティブな値を直接使う代わりに、それをラップして抽象化した値オブジェクトを用いることがあります。
例えばメールアドレスを表す Email
という値オブジェクトがあったとします。そしてこのメールアドレスからドメイン名を取り出したいとします。
// Kotlinでの実装例
class Email(val value: String) // メールアドレス型
val email: Email = ...
val domain = email.value.substringAfter("@") // ドメイン名を取り出す
一見良さそうですが、これは値オブジェクトが隠蔽している内部の値を直接利用しています。例えるなら、車の運転手がアクセルやハンドルを使わずに、内部のエンジンやシャフトを取り出して直接操作しているようなものです。
抽象化するなら、使う側は抽象そのものに対してインタラクションすべきです。
class Email(val value: String) {
// 値オブジェクトができることを公開する
fun domain(): String { // 必要であれば Domain 型みたいなのを用意してもいいかもしれない
return this.value.substringAfter("@")
}
}
val email: Email = ...
val domain = email.domain()
別の例で、以下は通知を表示する React コンポーネントの2通りの I/F です。
// A
<Alert onClickDismissButton={...} >なにかのお知らせ</Alert>
// B
<Alert onDismiss={...} >なにかのお知らせ</Alert>
A は onClickDismissButton
というプロパティ名から、「Alert
コンポーネントには通知を閉じるためのボタンが配置されていること」「それをクリックすることでコールバックが呼ばれること」がわかります。これらは使う側にとっては知る必要のない内部の詳細です。
一方で B は、onDismiss
をどのようにハンドリングするかや、そのためのボタンを配置するかどうかはAlert
コンポーネントに一任され隠蔽されています。ボタン押下の他に、キーボードショートカットによっても onDismiss
をトリガーするように自由に変更できます。
詳細を露出すると変更に対して脆弱になり、インターフェイスの安定性を損ないます。
その関数やクラス自身の都合でインターフェイスを決める
関数やクラスの設計方法は、呼び出し元との関係性において2つに分類できます。
- A. 関数やクラスのシグネチャは、その呼び出し元の都合で決める
- B. 関数やクラス側が適切なシグネチャを定義し、呼び出し元がそれに従う
A を選択する場合、その関数やクラスは「自身の責務を全うしなければならない」し「呼び出し元の都合も考慮しなければならない」ので、単一責任の原則を実現しにくくなります。そのため、B の方が基本的にはシンプルで理解が容易になります。
(プライベートな関数の場合は手数を優先して A を選ぶことはよくあります。呼び出し元との距離が遠いほど B にすべきです。)
ダメな例
以下は、手持ちのポケモンを検索して返すユースケースを TypeScript で書いたものです。
type SearchMyPokemonRequest = {
query: string; // 検索文字列
limit: number // 取得件数
type: PokemonType | null; // ポケモンのタイプ
outputFormat: 'json' | 'xml' | 'csv'; // 出力方法
}
// ユースケース: 自分のポケモンを検索する(トレードによって捕まえたものを除く)
async function searchMyPokemons(
request: SearchMyPokemonRequest,
authContext: AuthContext, // 認証情報 (フレームワーク依存)
) {
const pokemons = await findPokemons(request, authContext);
return outputPokemons(pokemons, request);
}
// データアクセス層
function findPokemons(
input: SearchMyPokemonRequest,
authContext: AuthContext,
): Promise<Pokemon[]> {
return prisma.pokemon.findMany({
where: {
name: { contains: input.query },
type: input.type,
ownerUserId: authContext.userId, // 所有者が自分
capturedUserId: authContext.userId, // 捕まえたのも自分 (=トレードを除く)
},
take: input.limit,
})
}
このDBアクセスを担う findPokemons
にはいくつかの問題点があります。
- 不要な情報を知りすぎている
- ユースケースのインプットである
SearchMyPokemonRequest
をそのまま受け取っている -
AuthContext
全体を受け取っている(が、必要なのはuserId
だけ) - → 不要な情報(スタンプ結合)によって、関数がどのような責務を果たすのか曖昧
- ユースケースのインプットである
- 特定のユースケースのことを頭に入れておかないと理解できない
- 「トレードによって捕まえたポケモンを除く」という挙動はユースケース依存
- 「
query
はポケモンの名前に対する部分一致である」というのもユースケース依存 (というかUIのフォーム名依存) - これらは関数のシグネチャから推測困難
改善例
呼び出す側の都合ではなく、呼び出される関数自身が必要なパラメータや戻り値を規定します。
// ユースケース: 自分のポケモンを検索する(トレードによって捕まえたものを除く)
async function searchMyPokemons(
request: SearchMyPokemonsRequest,
authContext: AuthContext,
) {
// findPokemons のシグネチャに合わせて呼び出す(「丸投げする」ではなく「利用する」)
// 関数のシグネチャから挙動が自明なので、中身を見に行かなくて理解できる
const pokemons = await findPokemons({
nameContains: request.query,
type: request.type,
ownerUserId: authContext.userId,
capturedUserId: authContext.userId,
}, request.limit);
return outputPokemons(pokemons, request);
}
// データアクセス層
function findPokemons(
// 「条件に一致するポケモンを取得する」という責務視点で必要な情報を要求する
criteria: {
nameContains?: string;
type?: PokemonType | null;
ownerUserId?: UserId;
capturedUserId?: UserId;
},
limit?: number,
): Promise<Pokemon[]> {
...
}
呼び出す側の都合だけで考えると、自身が持っている変数を子関数にそのまま渡すのが楽ですが、そうすると呼び出し階層が下位のコードほど、余計な文脈情報に依存することになり、コードの理解が難しくなります。
巻き込む文脈の量を極力減らす
関数やクラスにはそれが定義され利用される文脈があります。
- 実行環境やインフラ、フレームワークの文脈
- 呼び出し元、呼び出し元の呼び出し元、…の文脈
- 特定の業務フローやユースケースの文脈
- etc…
先の章で見た「呼び出される側の関数自身がシグネチャを決める」も、呼び出し元という文脈情報を削ぎ落とすことと言えます。
巻き込む文脈情報が少ない関数やクラスには多くの利点があります:
- それ単体で理解できる
- 依存する文脈が少ない = コードを理解するために頭に入れるべき余計な情報がない
- 資産性が高い
- 文脈情報を減らす = 汎用寄りになる
- 特定の文脈に依存しないので再利用や再配置が容易
- 安定する
- 巻き込む文脈が最小 = 変更される理由が少ない
- 単体テストを書きやすい
- テスト対象の I/F が安定しているので書きやすい
- 不要なインプットがないので、テストデータを用意しやすい
- 生成 AI との相性が良い
- 依存する文脈情報がない = 汎用である = 生成容易
切り出しのオーバーヘッドを気にする
ここに同じことをする2つの TypeScript のコードがあります。どちらが読みやすいですか?
function dumpActiveUsers() {
const users = findUsers();
const activeUsers = filterActiveUsers(users);
console.log(activeUsers)
}
function filterActiveUsers(users: User[]) {
return users.filter((user) => user.isActive); // この関数が隠蔽する情報量がほぼない
}
function dumpActiveUsers() {
const users = findUsers();
const activeUsers = users.filter((user) => user.isActive); // 十分宣言的。意図をダイレクトに表現できている
console.log(activeUsers)
}
私は後者の方が読みやすいと感じます。
関数(やクラスやコンポーネント)の切り出しにはオーバーヘッドが伴います。
- コード量の増大(関数を定義するためのボイラープレートコード等)
- 切り出した先の関数の管理
- それをジャンプして見に行くための負荷
関数が隠蔽する情報量が少ない場合は、わざわざ切り出さない方が良いでしょう。
コメントはやや親切目に書く
良い設計にはコメントはいらないみたいな主張はありますが、迷ったら「やや親切目に書いておく」のをオススメします。(特に JavaDoc や JSDoc のようなドキュメンテーションコメント)
- 関数やクラスのインターフェイスだけですべてを表現できないことはやはりある
- メンバーの設計力やメンタルモデル、英語力のばらつき
- 人によって「良い設計」の方向性や到達点が異なる
- パッと分かるメタファーや概念、英単語に差がある
- 読み解く時間の短縮
- 良い設計は確かに意味を推測しやすいが、それでも労力がかかる
- 読み手の負担を減らすためにできることがあるならやっておくべき
- Why の記録
- 諸事情で不完全またはトリッキーなロジックを書かざるをえないときがある
- 他人や未来の自分へ「なぜ今こういう設計判断をしたのか」を伝えるためにコメントを書く
- コメントを書くことで、客観的なわかりにくさに気づく
- 説明が難しいと感じたらそのデザインが妥当ではないのかも
Think Twice
関数やクラスのインターフェイスが妥当かどうかは、「関数やクラス側の視点」と「それを使う側の視点」の両方を行き来して考えます。
- 呼び出される側の視点:
- 巻き込む文脈情報は最小か
- 責務を果たすために何を知っているべきか/知らないべきか
- 呼び出す側の視点:
- I/F だけ見て意味の推測ができるか
- これが他人の作ったものだとしてもそう思うか
- 間違って解釈される/使われる余地はないか
- I/F だけ見て意味の推測ができるか
また、適切な設計というものは先に進む中で徐々に見出されていくものです。迷ったらまずは素朴な設計から始めるのをオススメします。
力を入れるところと手を抜くところ
すべてのコードに対して等しく良い設計をするのは大変なので、ちゃんとやるところとそうでないところで差をつけましょう。
力を入れるとき:
- それを利用する箇所が多いとき(テストコードも含む)
- それを利用するサービスやチームが遠いとき。レイヤーや機能をまたいで呼び出されるとき。
- 長生きしそうなコード、コアドメインのコード
おわりに
色々書きましたが、すべての状況で常に正しい手法や原則はないので、いろいろ試して失敗して直してみるのが一番だと思います。ここで書いたことがその試行錯誤の役に立てれば幸いです。
Discussion
抽象の対義語は具象では……?(※抽象的の対義語は具体的ですが
確かに...!
直しました、ありがとうございます🙏
対応ありがとうございます!