読みやすいコードを書く
読みやすいコードとは何か
読みやすいコードとは、脳に負荷がかからないコードである。脳に負荷がかからないコードとは、人間の脳の特性に配慮して書かれたコードである。したがって読みやすいコードを書くには、まず人間の脳の特性を把握する必要がある。読みやすいコードの特徴は、この人間の脳の特性から論理的に導かれる。
また、「コードを読む」とは過去から未来への情報伝達、または自分から他者への情報伝達であり、情報理論における以下の2つの数学的原理にも支配される。
- 頻出する情報には共通の符号を割り当てることで情報を圧縮することができる。
- 失われた情報を復元することはできない。
この記事に書かれた内容はプログラムに止まらず、ドキュメント、記事の執筆など、プレインテキストによって情報を伝達する際には一般に適用可能である。
もしもこの記事を読むのが面倒であれば、以下の5つだけを覚えておけばよい。
- ひとつの処理の単位はディスプレイで一度に表示できる範囲に収めること[1]。
- 意味を持つデータには適切な名前をつけて変数化すること。
- 意味を持つ処理には適切な名前をつけて関数化すること。
- 同じ役割・意味を持つ変数や関数には一貫した名前をつけ、異なる役割・意味を持つ変数や関数には同じ名前をつけないこと[2]。
- 2, 3, 4 を守ってもコードに反映されない「そのコードを書いた意図」はコメントとして残すこと。
上記5つの条件の根拠
- 目に見えていないコードは記憶しておくしかなく、処理の流れを想像するときに記憶リソースを圧迫するため。ディスプレイに表示されていれば、コードを目で追いながら考えることができる。
- データに名前がつけられていれば、何に対して処理をしようとしているか想像できるため。
- 処理に名前がつけられていれば、データに対してどんな処理をしようとしているか想像できるため。
- 一貫した命名が行われていれば覚えるべき名前が少なくて済み、関連するコードをエディタで簡単にハイライト・検索できるため。また、初めて見る変数や関数の名前から役割を推測したり、逆に役割から見たことがない変数や関数の名前を推測して検索できる。
- コードは HOW(データに対してどのような処理がどのような順序で行われるか)を記述するが WHY(なぜここでその処理がその順序で行われるか)は記述しないため。人間にとっては HOW から WHY を推測することが難しい[3]。
経験上、上記の条件が満たされていればかなり読みやすく、ほとんど英語の文章のようなコードになる。
逆にひとつでも欠けていると可読性が大きく低下する。たとえば、長ったらしくて読む気が起きないコードになっていたり、変数や関数が持つ役割や意味が不明確で周辺のコードから推測するしかなかったり、コード検索が機能しなかったり、なぜそのコードを書いたかを本人しか把握していないためにそのコードの書き方が正しいのかを客観的に判断できないといった結果に帰結する。
「コードを読む」という行為が発生する時点で、それは必ず「未来の自分または他者が内容を理解する」という目的に付随するものであり、「その書き方および挙動が正しいと客観的に判断できる」ことを要請する。
この記事ではこの要請に応えるための知識やノウハウを解説する。
目次(チェックリスト)
Zenn の目次に表示する深さが調整できないので目次を書いておく。この目次はチェックリストとしても使用できる。
そもそもコードを読むとはどのような作業か
人間がコードを読むという行動を取るとき、その目的は大きく以下の2つに分けられる。
- まったく知らないコードを読んで理解する。
- 一度理解したことがあるコードを読んで思い出す。
読みやすいコードとはこの2つの目的およびそれに付随する行動をサポートするような工夫が施されたコードである。したがって読みやすいコードは以下の2つの条件を満たす。
- まったく知らない状態からでもすぐに読んで理解できる。
- 一度読んだことがあればすぐに欲しい情報にアクセスして思い出すことができる。
逆に、どこから読み始めたらよいか分からないし、検索性が悪く正確な情報に辿り着くことが困難なコードは読みづらいコードと言える[4]。
1. まったく知らないコードを読んで理解する
まったく知らないコードを読んで理解しようとするとき、人間によるコードの読み方は以下の2つに大別される。
- トップダウン・アプローチ
- ボトムアップ・アプローチ
多くの場合、人間は無意識のうちにこの両方を同時に行いながら全体像と詳細をバランスよく把握しようとするだろう。したがって、読みやすいコードはこのどちらのアプローチに対しても配慮することを要請される。
トップダウン・アプローチ
トップダウン・アプローチは、コードの全体像を把握してからプログラムの全体を構成するコンポーネントの相互関係を把握し、詳細な部分に踏み込んでいく読み方である。プログラマがプログラムの全体に対して興味や責任を持つ場合に行われるだろう。
トップダウン・アプローチは一度に大量のソースコードを読むことになるため、速読の性質を持つ。
人間がトップダウン・アプローチを取るときに行う行動は主に以下の3つであり、大抵はこの順に行われる。
- プログラムの全体像およびそれを構成する概念、そしてその概念どうしの関連性を簡単に説明する図やドキュメントを参照する。
- プログラムを構成するディレクトリ構造、ファイル名とディレクトリ名を参照する。
- プログラムの全体を統合する役割を持つファイルを上から下へ流し読みする。
したがって、読みやすいコードは単純にプログラムの本体たるソースコードだけでなく、その内外に付随する情報によっても支えられている。上記3つの行動をサポートする方法はいろいろ考えられるが、広く採用されている方法のうち、最低限の品質を担保するには以下の4つの条件を満たせばよい。
1. README を用意する
プロジェクトのトップディレクトリには README ファイルを配置しておく。
熟練のプログラマが腰を据えてソースコードを読む場合はおおよそ以下の手順を辿る[5]。
- コミュニティの記事を読んで概要を把握する。
- 公式リポジトリの README を参照し、そこに貼られた公式ドキュメントや example へのリンクを辿る。
- 公式リポジトリのソースコードを読む。
したがって README には、最低限公式ドキュメントへのリンクを必要とし、プロジェクトの概要を把握するために有益な記事へのリンクや、すぐに使用可能な example へのリンクが貼ってあると親切である。
また、ディレクトリ構造についてはファイル名やディレクトリ名にしか情報を持たせられないため、それだけでは不足する情報は README(または README から辿れる公式ドキュメント)に記載するとよい。
my_project/
├── README.md
...
# My Project
## 概要
My Project は OO を目的としたツールです。
...
## 特徴
...
## ドキュメント
https://...
## インストール方法
...
2. ディレクトリ構造は特別な理由がない限り慣習に倣う
プロジェクトのディレクトリ構造は、特別な理由がない限り慣習に倣っておく[6]。
熟練のプログラマはプログラミング言語全体またはそのプログラミング言語でよく使われるディレクトリ構造や命名規則について把握している。慣習通りの構造であれば、特定の機能についてのソースコードが読みたいとき、初見のプログラムでもほとんどディレクトリ名とファイル名だけを辿って目的のコードに辿り着けるだろう。逆に、慣習や直感からあまりにも外れたディレクトリ構造を取ると、理解に時間がかかって読みづらさの原因となる。
慣習に従うメリットは大きく分けて2つある。
- 複数のプロジェクトを抱えているプログラマがいる場合は、慣習通りのディレクトリ構造を採用することで記憶すべき事項を削減し、プロジェクト間での作業の切り替えをスムーズに行うことができる[7]。
- 慣習的なディレクトリ構造は多くの場合コミュニティによって洗練された構造であり、そのプログラミング言語で何を行うにしても、そのまま、または多少のカスタマイズで対応できるように設計されている[8]。
一方でプロジェクトごとに事情は異なるため、ディレクトリ構造を変えることでこれらのメリットを上回る恩恵があるのであれば、ディレクトリ構造は変更しても問題ない。特にプロジェクトが発展してコード量が増えてくると、必ずしも慣習通りの構造には収まらなくなるだろう。
ディレクトリ構造を慣習から変えるのであれば、その思想、必然性、各ディレクトリに与えられた役割といった情報を README などに記載しておくほうが親切である[9]。
ディレクトリ構造を変更してもよいかどうかの簡単な判断基準を挙げておく。
- 慣習を知らない程度の成熟度であればまずは慣習を調べて従ったほうがよい。
- 何度か慣習に従ってきた上でその違和感や問題点を指摘できるレベルのプログラマであれば適宜作業しやすいように変更してよい。
- その道のプロフェッショナルであれば慣習に捉われない大胆なディレクトリ構造によって大きな恩恵を生み出すことができるかもしれない。
守破離である。
慣習を把握する方法
多くの場合は「python cookiecutter」のように「プログラミング言語名」+「cookiecutter」や「プログラミング言語名」+「library template」で検索すれば、典型的なディレクトリ構造を作成するためのツールやテンプレートに辿り着くことができる。
ツールやテンプレートが存在しない場合でも、自分が作成するプログラムで使用するライブラリやモジュールをいくつか見に行ってそれらに共通しているディレクトリ構造を見出せばよいし、現在であればLLMに「典型的なディレクトリ構造を教えてください」と命令してもよい。
my_project/
├── README.md # プロジェクトの概要・使い方・ドキュメントリンク等を記載する。
├── bin/ # 実行可能なスクリプトやCLIツールなど。
├── src/ # アプリケーションのソースコード本体。
├── tests/ # ユニットテストや統合テストなど。
├── docs/ # ドキュメントや設計資料、API仕様書など。
├── examples/ # 実際にコードを使用するサンプル。
├── scripts/ # 補助的なスクリプト(ビルド、デプロイ、メンテナンス用など)。
├── config/ # 設定ファイル類(YAML, JSON, TOML, .envなど)。
├── data/ # テストや開発時に使う固定データ(CSV、JSON、DBファイルなど)。
├── .github/ # GitHub Actions や ISSUE テンプレートなど CI/CD 設定用。
└── Makefile # ビルド・テスト・静的解析などの定型操作。
3. プログラムはなるべく上から下へ読めばよいように書く
プログラムは上から下へ流し読みすれば、そのファイルの役割や、そのファイルにどんな関数がまとめられているか分かるように書く。
流し読みの段階で、どの関数がどの関数を呼んでいるかといった複雑な関係性を理解することは難しい。このときコードの配置と関数どうしの関係性を規定する自明なルールは2つしかない。
- 着目している関数は、それよりも上で定義された関数から構成される。
- 着目している関数は、それよりも下で定義された関数から構成される。
どちらを採用してもよいと思うが、人間の自然言語への慣れの問題で「上から下へ」読むほうが早い[10]。
したがって1つのファイルは慣習やそのときどきの必要性によって多少の前後はあろうものの、概ね以下のような順序で構成される。
- 冒頭に書かねばならない特殊な機能の呼び出し
- ファイル自体の役割を示す docstring
- 別のファイルで定義された機能のインポート
- マクロやヘルバー関数の定義
- クラスなどの関数をまとめた構造の定義[11]
上記の順序を守るよう LLM に命令して適当に生成させたコードが以下。実際に読んでみるとよい。
from __future__ import annotations # 1. 冒頭に書かねばならない特殊な機能の呼び出し
# 型アノテーションの先読みを有効化(循環参照や型ヒントの柔軟化に有用)
# 2. ファイル自体の役割を示す docstring
"""
ml_pipeline.py
機械学習パイプラインを構成するための基本モジュール。
前処理、モデル定義、予測の一連の流れを管理するクラスを提供します。
"""
# 3. 別のファイルで定義された機能のインポート
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from typing import Optional, Tuple
# 4. マクロやヘルパー関数の定義
def _normalize(df: pd.DataFrame) -> pd.DataFrame:
"""数値列を0〜1の範囲に正規化する"""
...
def _split_features_and_labels(df: pd.DataFrame, label_col: str) -> Tuple[np.ndarray, np.ndarray]:
"""特徴量とラベルを分離して返す"""
...
# 5. クラスなどの関数をまとめた構造の定義
class MLModel:
"""
シンプルな機械学習モデルの訓練・予測を行うクラス
"""
def __init__(self, label_col: str = "target"):
self.label_col = label_col
self.model: Optional[LogisticRegression] = None
def train(self, df: pd.DataFrame) -> None:
"""データフレームを使ってモデルを訓練する"""
X, y = _split_features_and_labels(_normalize(df), self.label_col)
self.model = LogisticRegression()
self.model.fit(X, y)
def predict(self, df: pd.DataFrame) -> np.ndarray:
"""学習済みモデルを使って予測を行う"""
if self.model is None:
raise RuntimeError("モデルが訓練されていません")
X, _ = _split_features_and_labels(_normalize(df), self.label_col)
return self.model.predict(X)
4. その処理の単位が持つ役割や意味を docstring とコメントに書く
ファイルごと、クラスごと、関数ごとなど、その処理の単位が持つ役割や意味を docstring やコメントなどの自然言語で記述しておく[12]。
大抵の人間にとって、コードを流し読みするときに拾える情報は自然言語、クラス名、関数名、変数名くらいである。逆に言えば、これらの情報さえ拾えばそのファイルに何が書かれているかを理解できるようにコードが書かれているならば人間は「読みやすい」と感じるはずである。
分岐やループのような細かい処理は流し読みの段階では読み飛ばすことになる。なぜならば、分岐やループの存在は一見して「そこに分岐/ループが存在している」という事実以上の情報を持たないからである[13]。
プログラムの大半がひとつの関数に押し込められて分岐やループで構成されているのみならず、コメントも書かれていないようなプログラムは、流し読みによって情報を得ることができないので、書いてはならない[14]。
書いてはならないコードの例
LLMに生成させたので内容は適当。読む気が失せる雰囲気を感じ取ってほしい。
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
def main():
df = pd.read_csv("experiment_results.csv")
a = []
b = []
c = []
for i in range(len(df)):
if df.iloc[i]["status"] == "valid":
if df.iloc[i]["trial"] % 2 == 0:
a.append(df.iloc[i]["x"] * 1.5 + df.iloc[i]["y"] / 2)
b.append(df.iloc[i]["score"] ** 0.5)
else:
a.append(df.iloc[i]["x"] - df.iloc[i]["y"])
b.append(np.log1p(df.iloc[i]["score"]))
if df.iloc[i]["group"] == "X":
c.append(1)
else:
c.append(0)
else:
continue
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111)
for i in range(len(a)):
if c[i] == 1:
ax.scatter(a[i], b[i], color="blue", alpha=0.5)
else:
ax.scatter(a[i], b[i], color="red", alpha=0.5)
ax.set_title("Scatter Plot")
ax.set_xlabel("A")
ax.set_ylabel("B")
plt.savefig("result.png")
plt.close()
s = 0
for i in range(len(b)):
if b[i] > 2:
s += 1
print("count:", s)
改善例
『書いてはならないコードの例』を改善するよう命じて生成させた。処理として同じかどうかも確認していないので、雰囲気だけ感じてほしい。docstring と main
関数の中身を見ただけでもある程度やりたいことが把握できる。
from __future__ import annotations
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
from typing import Tuple
def load_valid_trials(path: str) -> pd.DataFrame:
"""
CSVファイルを読み込み、有効な試行のみを抽出する
"""
df = pd.read_csv(path)
return df[df["status"] == "valid"]
def transform_features(df: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
特徴量A・Bおよびグループラベルを計算する
- A: X, Y の演算によって生成される特徴量
- B: スコアの変換(偶奇によって異なる)
- group: グループXなら1、そうでなければ0
"""
a_list = []
b_list = []
group_list = []
for _, row in df.iterrows():
if row["trial"] % 2 == 0:
a = row["x"] * 1.5 + row["y"] / 2
b = np.sqrt(row["score"])
else:
a = row["x"] - row["y"]
b = np.log1p(row["score"])
group = 1 if row["group"] == "X" else 0
a_list.append(a)
b_list.append(b)
group_list.append(group)
return np.array(a_list), np.array(b_list), np.array(group_list)
def plot_scatter(x: np.ndarray, y: np.ndarray, groups: np.ndarray, out_path: str) -> None:
"""
グループごとに色分けされた散布図を描画し、画像として保存する
"""
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x[groups == 1], y[groups == 1], color="blue", alpha=0.5, label="Group X")
ax.scatter(x[groups == 0], y[groups == 0], color="red", alpha=0.5, label="Other Groups")
ax.set_title("Transformed Features Scatter Plot")
ax.set_xlabel("Feature A")
ax.set_ylabel("Feature B")
ax.legend()
plt.savefig(out_path)
plt.close()
def count_high_scores(scores: np.ndarray, threshold: float = 2.0) -> int:
"""
特定の閾値を超えるスコアの件数を数える
"""
return int(np.sum(scores > threshold))
def main():
df = load_valid_trials("experiment_results.csv")
a, b, groups = transform_features(df)
plot_scatter(a, b, groups, "result.png")
high_score_count = count_high_scores(b)
print(f"Score > 2.0: {high_score_count} entries")
if __name__ == "__main__":
main()
ボトムアップ・アプローチ
ボトムアップ・アプローチは、興味があるコードの特定の部分から、プログラムの全体のうち関連する部分を辿って着目する挙動の機序を把握していく読み方である。エラーに遭遇したときのデバッグ作業としてプログラマが日常的かつもっとも頻繁に行う読み方だろう。
ボトムアップ・アプローチは限られたコードを注意深く読むため、精読の性質を持つ。
人間がボトムアップ・アプローチを取るときに行う行動は主に以下の3つであり、大抵はこの順に行われる。
- 着目すべき部分を特定する。多くはエラーメッセージにヒントが表示され、それでも特定できない場合にはデバッガを用いる。
- 着目するコードの範囲を「特定の行 → 関数 → 関連する関数 → 関連するファイル」のように広げていく。
- プログラムが実行される流れを頭の中や紙に描き、吟味して、必要な改良を加えたりエラーの原因を修正したりする。
したがって、読みやすいコードはコードのごく限られた部分に着目するだけで、そのコードが与えられた役割およびその他のコードとの関連性を推測できるように書かれている。上記3つの行動をサポートする方法はいろいろ考えられ、プログラマによって異なる工夫がなされていることが多いが、最低限の品質を担保するには以下の4つの条件を満たせばよい。
1. ひとつの処理の単位はディスプレイで一度に表示できる範囲に収める
ひとつの処理の単位はディスプレイで一度に表示できる範囲に収めておく。
当然のことであるが、ディスプレイに表示されていない部分のソースコードの挙動を想像するためには、脳内にソースコードや処理の流れを記憶しておいて、それを思い出しながら考える必要がある。これは非常に脳に負荷のかかる作業で、そのパフォーマンスには個人差やそのときどきの体調が著しく影響する[15]。
このパフォーマンスの差は、処理の全体を目で見えている範囲の中に収めておくことで是正できる。理解に必要な部分を都度目で追うことができるならば、記憶しておくべき事柄は少なくて済むからである。
長大で複雑な処理を理解する場合、多くの人間は1枚の紙にメモとしてまとめたり図として整理することを考えるだろう。そうであるならば、最初からディスプレイで一度に表示できる範囲に処理を収めておけばよい[16]。
コードを読むにあたって最悪なコードのひとつは、ディスプレイからはみ出るような長大な多重ループである。ループ処理はループ内の全体で整合性を取る必要があるため、ディスプレイからはみ出ている部分については都度思い出すか、スクロールしながら確認し、完璧に挙動を把握しなければならない。ループ内の処理は役割ごとに関数として切り出し、見た目上はせいぜい二重ループとなるように抑えることでコードの可読性は大きく改善する。
最悪な例
ディスプレイに収まらない長大な多重ループ。LLMに生成させた。
import os
import csv
import matplotlib.pyplot as plt
import numpy as np
def process():
data = {}
for category in os.listdir("data"): # 1つ目のループ
category_path = os.path.join("data", category)
if os.path.isdir(category_path):
for filename in os.listdir(category_path): # 2つ目のループ
if filename.endswith(".csv"):
path = os.path.join(category_path, filename)
with open(path) as f:
reader = csv.DictReader(f)
for row in reader: # ← 一番内側のループ
group = row["group"]
x = float(row["x"])
y = float(row["y"])
z = float(row["z"])
score = float(row["score"])
quality = float(row["quality"])
flag = row["flag"]
if group not in data:
data[group] = {"x": [], "y": [], "z": [], "score": [], "high_quality": 0}
data[group]["x"].append(x)
data[group]["y"].append(y)
data[group]["z"].append(z)
data[group]["score"].append(score)
if quality > 0.9 and flag == "yes":
data[group]["high_quality"] += 1
if score < 0 or score > 100:
print(f"警告: 不正なスコア {score} in {filename}")
if x == 0 and y == 0 and z == 0:
print(f"無効な座標検出: {filename}")
if score > 80 and quality < 0.2:
print(f"低品質で高スコアの異常値: {filename}")
# ...さらに10〜15行の if/elif/処理が延々と続くと想定
改善例
import os
import csv
import matplotlib.pyplot as plt
from typing import Any, Dict
def collect_csv_paths(root_dir: str) -> list[str]:
"""カテゴリごとのCSVファイルパスを収集する"""
paths = []
for category in os.listdir(root_dir):
category_path = os.path.join(root_dir, category)
if os.path.isdir(category_path):
for file in os.listdir(category_path):
if file.endswith(".csv"):
paths.append(os.path.join(category_path, file))
return paths
def initialize_group(data: Dict[str, Any], group: str) -> None:
"""グループごとのデータ構造を初期化"""
if group not in data:
data[group] = {
"x": [],
"y": [],
"z": [],
"score": [],
"high_quality": 0
}
def validate_and_record(row: dict[str, str], filename: str, group_data: dict[str, Any]) -> None:
"""1行のデータを検証・集計し、異常を出力"""
x = float(row["x"])
y = float(row["y"])
z = float(row["z"])
score = float(row["score"])
quality = float(row["quality"])
flag = row["flag"]
group_data["x"].append(x)
group_data["y"].append(y)
group_data["z"].append(z)
group_data["score"].append(score)
if quality > 0.9 and flag == "yes":
group_data["high_quality"] += 1
if score < 0 or score > 100:
print(f"警告: 不正なスコア {score} in {filename}")
if x == 0 and y == 0 and z == 0:
print(f"無効な座標検出: {filename}")
if score > 80 and quality < 0.2:
print(f"低品質で高スコアの異常値: {filename}")
# 他にも複数の検証処理がある場合、ここに追加
def process_csv_file(path: str, data: Dict[str, Any]) -> None:
"""1ファイルを処理し、データを集計"""
with open(path) as f:
reader = csv.DictReader(f)
for row in reader:
group = row["group"]
initialize_group(data, group)
validate_and_record(row, path, data[group])
def main():
data: Dict[str, Any] = {}
file_paths = collect_csv_paths("data")
for path in file_paths:
process_csv_file(path, data)
# ここで data を用いた可視化や出力処理などを行う
print("処理完了。集計結果:", {k: len(v["score"]) for k, v in data.items()})
if __name__ == "__main__":
main()
2. 適切なエラーメッセージを書く
そのエラーが何を示すか、エラーの原因は何か、どうすればエラーを解消できるのかをエラーメッセージに記述しておく。
人間がコードのうち特定の部分に着目するタイミングの大半は以下の状況であり、この順序で行われる。
- エラーメッセージを見て、自分がいま書いているコード(呼び出した関数の側)に着目する。
- エラーメッセージを見て、自分または他人がかつて書いたコード(呼び出された関数の側)に着目する。
したがってエラーメッセージには、そのエラーメッセージを見ただけで関数を呼んだ側と呼ばれた側のどちらに原因があるのかを判断するために十分な情報が含まれているとよい。
関数の冒頭でバリデーションを行い、早期にエラーをリターンするか、例外を発生させることは多くの場合にベターなプラクティスである。関数内のバリデーションによってエラーが発生している場合、それはその関数の作成者が想定した範囲の異常入力であることを意味し、関数の呼び出し側に責任があることが明白となるためである。
すべての可能性を網羅することは一般に困難であるし、過剰なバリデーションはかえってコードを肥大させ可読性を下げる原因となることもあるため、どの程度のバリデーションを行うかは書き手のバランス感覚に委ねられる。
def extract_coordinates(data: dict[str, float]) -> tuple[float, float, float]:
"""
辞書から x, y, z 座標を取り出す。すべてのキーが存在していなければ例外を投げる。
"""
for key in ("x", "y", "z"):
if key not in data:
raise ValueError(f"extract_coordinates(): 必須キー '{key}' が data に存在しません。入力: {data}")
return data["x"], data["y"], data["z"]
3. 役割・意味を把握できる変数名や関数名をつける
名前を見ただけでその役割・意味を把握できる変数名や関数名をつけておく。
エラーの発生などによってプログラマが特定のコードを読むとき、多くの場合はエラーが発生した関数の全体を把握した上でエラーの原因を探ろうとする。このとき、エラーを起こした変数や関数がプログラムの全体においてどのような役割を果たしているかのもっとも大きな手がかりのひとつが、その変数名と関数名である。
たとえば flag
という変数を見てもそれがいつアクティブになるフラグなのかを推測するにはプログラムの全体を読んで解読しなければならないが、is_context_empty
という変数名であったならば、何らかの処理に用いられるコンテキストが空であるときにアクティベートされることがその周囲を読まなくても容易に想像できる。続く条件分岐が if is_context_empty
となっていれば、書き手が何を意図してそのフラグと条件分岐を作成したかは自明である。
特定の箇所に着目するとき、そこで何が行われているかを、着目している箇所とそのごく限られた近傍のコードを読めば推測できるように変数名や関数名を整理すると、コードの可読性が向上する。
また、関数名およびその引数名は、その関数が関数外とどのような関連性を持つかを明示できる数少ない領域である。
たとえば入力としては一般の Response
型を取るが、特定のサイトからのレスポンスを取り扱うことを想定している関数を考える。この関数に process_response
という名前をつけるとユーザーは意図したサイト以外からのレスポンスを入力する可能性があるが、 process_response_from_google_maps
のような名前であれば Google Maps 以外のサイトからのレスポンスを入力しようとするユーザーはまずいないだろう。この関数がプログラムの全体において、特に Goole Maps からのレスポンスのみを処理する役割と責任を持ち、それ以外のサイトからのレスポンスは他の関数で処理されている/処理すればよいだろうことが容易に想像できる。
読みにくい例
def is_valid(data: dict) -> bool:
return not data.get("results")
def get_data(data: dict) -> Optional[dict]:
res = data.get("results")
if not res:
return None
return res[0].get("geometry", {}).get("location")
def make_result(loc: dict, cfg: dict) -> dict:
return {
"x": round(loc["lat"], cfg.get("p", 6)),
"y": round(loc["lng"], cfg.get("p", 6)),
"u": cfg.get("u", "degrees")
}
def run(data: dict, cfg: dict) -> Optional[dict]:
if is_valid(data):
result = get_data(data)
if result:
return make_result(result, cfg)
return None
改善例
def is_context_empty(response: dict) -> bool:
return not response.get("results")
def extract_location_data(response: dict) -> Optional[dict]:
results = response.get("results")
if not results:
return None
return results[0].get("geometry", {}).get("location")
def format_coordinates(location_data: dict, settings: dict) -> dict:
# 緯度経度をユーザー設定に合わせて整形する処理
return {
"latitude": round(location_data["lat"], settings.get("precision", 6)),
"longitude": round(location_data["lng"], settings.get("precision", 6)),
"unit": settings.get("unit", "degrees")
}
def convert_location_response_to_coordinates(response: dict, settings: dict) -> Optional[dict]:
if is_context_empty(response):
raw_location_data = extract_location_data(response)
if raw_location_data:
return format_coordinates(raw_location_data, settings)
return None
4. 関数はそれ自身を見れば想定される入力と出力を把握できるようにする
関数はそれ自身のソースコードを見れば、想定される入力と出力を把握できるようにしておく。
人間はすべてのケースを網羅することが苦手であるため、関数は基本的に特定の条件を満たす入力に対してのみ正常に動作するよう設計されることになる。親切なプログラマであればバリデーションによって不適切な入力に対しては異常終了するよう設計しているかもしれないが、そうでない場合は関数は不適切な動作を引き起こし、バグとなる。
関数の入出力がどのような条件を満たすべきかを関数内で記述できるのは以下の5カ所しかないため、これらを駆使して必要十分な情報を記載する。
- 関数名
- 引数名
- 関数の型(=引数と戻り値の型)
- バリデーション
- docstring とコメント
この5カ所に書かれていない情報は関数の外にあるコードやドキュメントを読まなければ把握できない。また、そうしなければならないことを関数内のコードから推測できないため、その関数の挙動を正しく把握するために関連するすべてのコードを読まなければならず、読むのに手間がかかるコードとなる。
たとえば関数の入力が型で規定されている以上の条件を必要とする[17]が、それを明示するためのコメントやバリデーションが書かれていないコードは典型的な読みづらいコード(もはや読んでも分からないコード)であると言える。
コードレビューまで見据えるのであれば、関数のユニットテストは関数外に書かれた有用な情報伝達手段である。ユニットテストによって書き手は以下の情報を他者に伝えることができる。
- 書き手はどのような入力を正常な入力と想定し、それに対してどのような出力が期待されるか。
- 書き手はどのような入力を異常な入力と想定し、それに対してどのようなエラーが期待されるか。
ユニットテストについては『2. 一度理解したことがあるコードを読んで思い出す』でより詳細に触れる。
def normalize_scores(scores: list[float]) -> list[float]:
"""
Normalize a list of scores to the range [0, 1].
Args:
scores: List of non-negative float values. Must contain at least one element.
Returns:
A list of float values scaled such that the highest score becomes 1.0.
Raises:
ValueError: If scores is empty or contains negative values.
"""
if not scores:
raise ValueError("scores must not be empty")
if any(s < 0 for s in scores):
raise ValueError("scores must be non-negative")
max_score = max(scores)
return [s / max_score for s in scores]
2. 一度理解したことがあるコードを読んで思い出す
一度理解したことがあるコードを読んで思い出す作業は、忘れた部分を思い出すだけであり、状況としても以下の2つに限られる。
- コードを拡張する。
- リファクタリングする。
いずれのケースにおいても『1. まったく知らないコードを読んで理解する』で挙げた条件をすべて満たしていれば十分に読みやすいコードであると言える。
プログラムの全体像や各コンポーネントの役割といった情報は一度理解してしまえば長期間記憶に残りやすく、忘れても流し読みによってほとんど思い出すことが可能であるため、思い出さなければならない項目は以下の2つしかない。
- どこに何が書かれているか。
- 各要素に想定される「正常な動作」はどのような挙動であるか。
これら2つを思い出す作業をサポートするための工夫をいくつか列挙しておく。
1. 変数や関数には一貫した命名を行う
プログラム内で同じ役割や意味を持つ変数や関数には一貫した命名を行う[18]。より詳細には以下の2つを徹底する。
- 同じ役割や意味を持つ変数や関数には同じ名前をつける。
- 異なる役割や意味を持つ変数や関数には異なる名前をつける。
これは人間の脳の特性として以下のような直感的推論が働くためである。
- 同じ名前を持つということは同じ役割や意味を持つはずだ。
- 異なる名前を持つということは異なる役割や意味を持つはずだ。
たとえばデータを変換するために transform
という関数と convert
という関数が存在するならば、その微妙なニュアンスの違いから異なる意味を持たせようとしたのだろうと読み手は推測する。この仮説を検証するためには周辺のコードを読んだり調べたりせねばならず、時間が消費されるため、意味が同じならば異なる名前をつけてはならない。
逆に、複数の対象が同じ名前を持っているとき、読み手はそれらが同じ役割や意味を持っているだろうと推測する。この仮説を検証するタイミングはほとんどない(問題が起こったあとで初めて気づくことになる)ため、意味が異なる対象に同じ名前をつけてはならない。
補足事項として、プログラマはコードを読む際にエディタの検索機能を多用し、リファクタリング時には置換機能を多用する。たとえば以下のような行動を取る。
- 変数に対する処理を追跡したい場合はその変数名の一部または全部をクエリとして文字列検索を行い、ハイライトされた部分を重点的に読む[19]。
- コードの可読性を上げるためによりよい命名を思いついた場合は一括置換によって変数名や関数名を書き換える[20]。
変数や関数の命名に揺れがあると、これらのエディタによる支援がうまく働かないため、この観点でも命名の一貫性は可読性に影響を与える。
def load_user_data(filepath: str) -> dict:
with open(filepath) as f:
return json.load(f)
def export_user_info(info: dict, path: str):
# load の対としてよく使われる単語は save である
# 取り扱う対象は同じなのに user_data と user_info で揺れている
with open(path, 'w') as f:
json.dump(info, f)
def load_user_data(filepath: str) -> dict:
# ユーザ情報をJSONファイルから読み込む
with open(filepath) as f:
return json.load(f)
def save_user_data(data: dict, filepath: str):
# ユーザ情報をJSONファイルとして保存する
with open(filepath, 'w') as f:
json.dump(data, f)
2. コードに反映されない「そのコードを書いた意図」はコメントとして残す
コードに反映されない「そのコードを書いた意図」は docstring やコメントとして残しておく[21]。
ほとんどのプログラミング言語は「データに対してどのような処理をするか」のみを記述するものであり、その処理がなぜ記述されているかを非自明なものにする。プログラミングのコードとして残るのは問題に対する解決手段のみであり、解決手段のみからもとの問題が何であったかの情報を復元することはほとんどの場合原理的に不可能である[22]。
また、コードだけを見て「他にもやり方はあるがこの書き方でよい」のか「他のやり方ではなくこの書き方でないとダメ」なのかを判断することはできない。特に後者は検証するために複数のやり方を実際に試さねばならず、時間と労力を消費するため、コメントで言及しておくとよい。
以下の3つの情報はそのコードを書いた人間しか知り得ず、失われた場合には復元することが不可能であるか、可能であってもコストがかかるため、docstring やコメントに残しておくべきである。
- その処理によってどんな目的を達成しようとしたのか。
- なぜそのコードが書かれているのか。
- 他の書き方ではダメな場合、なぜその書き方で書かれているのか。
def normalize_user_input(input_text: str) -> str:
"""
Normalize user input by converting full-width kana to half-width
and hiragana to katakana for consistent text matching.
Args:
input_text (str): A raw string input from the user.
Returns:
str: A normalized string with kana converted for uniformity.
This function is useful when matching user input against a dataset
where text is standardized to katakana and half-width characters.
"""
import jaconv
# 入力には全角・半角やひらがな・カタカナが混在していることが多いため、
# データベース検索での表記ゆれを減らす目的で正規化を行う。
#
# 全角→半角 の変換は jaconv.z2h を使用している。
# 標準の unicodedata.normalize では半角カナの扱いが不十分だったため。
#
# ひらがな→カタカナ 変換も併用することで、カタカナに統一された検索対象にマッチしやすくなる。
# 全角→半角(カナのみ)、ひらがな→カタカナの順で正規化
return jaconv.hira2kata(jaconv.z2h(input_text, kana=True, digit=False, ascii=False))
3. ユニットテストを書く
書き手が想定している入力とそれに対する出力については、具体例をユニットテストに書いておく[23]。
ユニットテストには以下の2つの情報を記述できる。
- 書き手はどのような入力を正常な入力と想定し、それに対してどのような出力が期待されるか。
- 書き手はどのような入力を異常な入力と想定し、それに対してどのようなエラーが期待されるか。
ユニットテストは書き手が想定していない入力に対する挙動については何ら保証しない。すべてのケースを想定することは原理的にも困難である[24]ため、ユニットテストは「最低限どのような挙動をすれば正常と考えられるのか」の情報伝達と、「どこまでが想定されていてどこからが想定外なのか」という責任境界の分離を目的とする。
docstring、コメント、ユニットテストなどで「想定される正常な挙動」の情報が残されていない場合、ほとんどはリファクタリングが不可能となる。
import re
def normalize_username(name: str) -> str:
"""
ユーザー名を正規化する。
- 前後の空白を除去する
- 小文字に変換する
- 英数字とアンダースコア以外の文字を削除する
- 2文字以上であることを保証する(そうでなければ例外)
Args:
name (str): ユーザーが入力した名前
Returns:
str: 正規化されたユーザー名
Raises:
ValueError: 有効なユーザー名に変換できない場合
"""
if not isinstance(name, str):
raise TypeError("名前は文字列である必要があります")
trimmed = name.strip().lower()
normalized = re.sub(r"[^\w]", "", trimmed)
if len(normalized) < 2:
raise ValueError("ユーザー名は少なくとも2文字必要です")
return normalized
import pytest
def test_normalize_username_valid_cases():
assert normalize_username(" Alice ") == "alice"
assert normalize_username("Bob_123") == "bob_123"
assert normalize_username(" ユーザー123 ") == "123" # 非ASCII文字は除去される
assert normalize_username("X!@#$%^&*()_+x") == "xx"
def test_normalize_username_invalid_cases():
with pytest.raises(ValueError):
normalize_username("!!") # 全部削除されると長さ不正
with pytest.raises(ValueError):
normalize_username(" a ") # 1文字になる
def test_normalize_username_type_errors():
with pytest.raises(TypeError):
normalize_username(123)
with pytest.raises(TypeError):
normalize_username(None)
おしまい
LLMの台頭によって人間がコードを読む機会は減っていくこの時代に逆行した記事となった気もするが、GW にやることもないのでなんとなく書いてみた。コードを読まずにコピペして致命的なバグを仕込んだことがない人間だけが石を投げなさい。
少しでも皆様のお役に立てば幸いです。
-
ディスプレイに一度に表示できる範囲は個人や職場の環境に合わせて決まるが、おおよそ 30〜100 行で、1行あたり 60〜100 文字程度である。プログラミング言語によっては、たとえばC言語などではこの条件を満たすことが困難な場合もあるが、その場合は言語における慣習を優先する。 ↩︎
-
個人単位、またはプロジェクト単位で一貫していればよい。熟練者は読んでいるうちに書き手の「クセ」を把握していくので、変数名の付け方の巧拙よりは表記揺れのほうが脳のリソースを消費する。たとえ変数名が1文字であっても、その役割と意味に重複がないことのほうが重要である。なぜならば同じ名前が付いているのに役割が異なる変数名がどの意味で使われているかを把握するのは困難を極めるし、可読性を上げるためにあとで検索・一括置換を行ったときに、いくつかは書き手がもともと意図していた意味からズレてしまうかもしれないからである。 ↩︎
-
HOW から WHY を想像することは、最適解だけを見てもとの最適化問題を求めろ、という問題になっており、大抵の場合は原理的に不可能である。たとえば二次関数
の最小値を求めよという最適化問題を与えられたとき、その解は頂点y = x ^ 2 として容易に求められるが、何の文脈もなしに単に点(0, 0) を与えられたとき、それがいかなる最適化問題の解であるかを確定させることはできない。 ↩︎(0,0) -
巨大であったり本質的に複雑なプログラムのコードは必然的に「読みづらく」なり、人間の脳の処理能力が有限である以上は、巨大さや複雑さに起因して発生する「読みづらさ」を軽減する手段は存在しない。したがってプログラムが巨大であるか本質的に複雑である場合、人間が感じる「読みづらさ」と「コードの品質」はイコールではない。 ↩︎
-
基本的にプログラマは怠惰なので楽をして記事などに頼るが、それでもうまくいかない場合は最終的にもっとも信頼のおける公式ドキュメント、それでもうまくいかない場合はソースコードを読むことになる。 ↩︎
-
頻出する情報には共通の符号を割り当てることで情報を圧縮することができる。すなわち、共通のディレクトリ構造を採用することで覚えておくことが少なくて済む。 ↩︎
-
作業者の個々が抱えているプロジェクトが 2〜3 個までならプロジェクトごとのディレクトリ構造の違いはほとんど問題にならない。 ↩︎
-
我流でディレクトリ構造を設計すると、開発中に生じた問題に対応することができずにディレクトリ構造を再構築する必要が生じることもしばしばある。 ↩︎
-
HOW から WHY は推測できないため。 ↩︎
-
多くのプログラミング言語において関数内のコードは上から下へ記述するものであるし、別のファイルに書かれた関数やクラスをインポートする際には使用する行よりも上に書かねばならないため、「上から下へ」の流れが強い。 ↩︎
-
ファイルの冒頭に全体をまとめるクラスを書いておき、その下で詳細を記述していくスタイルも存在する。このスタイルは最初に全容を大雑把に掴んだあとで詳細を読み進められるというメリットがある。プロジェクト全体で一貫していればどちらでもよい。 ↩︎
-
失われた情報を復元することはできない。0 bit の情報から有益な情報を取り出すことはできないので、役割や意味のような「コードの中に書かれなかった情報」は失われ、あとから復元することは原理的に不可能である。 ↩︎
-
たとえば「検索」も「カウント」も「ソート」も分岐とループによって構成されるが、そのどれであるのか、またはそれら以外であるのかは、プログラムを注意深く読まないと分からない。また、その後のロジックにおいて重要な役割を果たす分岐やループを読み飛ばすことはできないので、読み飛ばしてよいか判断するために読むというある種矛盾した作業が生じる。これはループの外側か冒頭に
// OOの条件で配列をフィルター
などと一言コメントを書いておけば避けられる手間である。 ↩︎ -
この記事の冒頭で示した5つの条件を満たす限り、そのようなプログラムにはならないはずである ↩︎
-
私がいままで見てきたプログラマに限れば、頭のいい人(学業の成績がよい人)ほどこの作業が得意であり、長大な処理が小分けせずに書かれていても問題を感じない傾向があるように見えた。この能力は経験によって多少は伸びるものの、生得的な能力の差が大きいと思われる。私はあまり頭がよくないので経験年数が 15 年を超えたいまでも通常の集中力で詳細を把握できる単位は数十行くらいである。上を見上げれば、100〜200 行に及ぶ処理でも全く気にならない人は存在する。 ↩︎
-
ディスプレイのサイズ、解像度、文字サイズなどによって一度に表示できる範囲は異なる。特にイベントハンドラが長大になりがちなフロントエンドやバックエンドのコードを取り扱う場合は、ディスプレイを縦置きすることで表示できる範囲を広げるとよいだろう。 ↩︎
-
典型的には辞書が特定のキーを持たねばならないとか、特定のキーに対応する値がある範囲に収まらねばらないといった条件である。強力な型システムを持つ言語であればこのような条件を型として容易に記述できることもあるが、スクリプト言語ではバリデーションなどで記述するしかない場合が多い。 ↩︎
-
頻出する情報には共通の符号を割り当てることで情報を圧縮することができる。異なる情報に共通の符号を割り当ててしまうと、圧縮された情報の解凍に失敗する。 ↩︎
-
最近のエディタでは検索をしなくとも、クリック、マウスオーバー、またはカーソルがその名前の上にあるときに、同名の対象を薄くハイライトする機能がある場合もある。 ↩︎
-
異なる対象に対して同じ名前が割り当てられていると、一括置換によって不適切な変換をしてしまう可能性があるため、ひとつひとつ目で確認しながら置換しなければならない。この意味でも大変不便なので、異なる対象に同じ名前をつけてはならない。 ↩︎
-
失われた情報を復元することはできない。 ↩︎
-
たとえば生まれて初めてバールを見て、それが釘を抜くための道具であると推測することは難しいだろう。防具を貫通して人間を一撃で確実に殺傷するための道具を設計しても同様の道具が作成されるかもしれないのだから。 ↩︎
-
失われた情報を復元することはできない。また、情報は圧縮が可能だが、その圧縮率には理論的な下限が存在する。自然言語によるメモはほとんどの場合においてその下限を下回る情報量であり、自然言語によるメモのみから関数に期待される正確な挙動を復元することはできない。ユニットテストは関数の挙動に関する情報を未来または他者に伝達する上で、必要最低限の情報量であると言える。 ↩︎
-
たとえば宇宙線の影響により入力のうち1ビットが突然反転してデータが壊れたりするかもしれない。 ↩︎
Discussion