はじめに
可読性の高いコードを書くためには、開発者の意図をコード上で表現することが重要です。この記事ではコードに意図を込めるいくつかの方法について説明します。いずれも基礎的なものであり、かつ粒度に若干ばらつきがありますがご容赦ください。
方法
適切な命名をする
適切な命名はコードの意図を伝える単純かつ最も強力な方法。変数や関数の役割や機能を十分表現するような具体的な命名を心がける。例えばリーダブルコードによると、適切な命名のために以下のような指針が示されている。
指針 | 例 |
---|---|
より明確な単語を選ぶ | 状況に応じてget よりもfetch やdownload などのより狭い意味の単語を使う |
汎用的な名前を避ける |
tmp などの情報のない命名を避ける |
具体的な名前で物事を説明する |
server_can_start よりもcan_listen_on_port のような具体的な名前を付ける |
命名と変数のスコープは関係する。変数の寿命が短いほど利用するコンテキストが明確なため、端的で簡潔な命名が許容される。例えば、内包表記内で現れる変数は利用されるスコープが明確なため、簡潔な命名でも理解することができる。
# k, vという簡潔な表現でも役割は明らか
{k: v for k, v in some_dict.items() if v % 2 == 0}
また、以下のような慣例的によく用いられる名付けに従うことも有効。
慣例 | 例 |
---|---|
is_XXX やhas_XXX でbool値を返す関数であることを表現する |
is_empty |
複数形でリストを表現する | items |
キーと名前の対応付けで辞書(マッピング)を表現する | id2token |
逆に、命名から実装を見直すこともできる。例えば、関数に対してうまく命名ができない場合、複数の処理を同時に行っており、関心の分離ができていない可能性が高い。この場合、適切に命名できる単位に処理を分割する。
型ヒントを付ける
型ヒントがない関数は、引数や返り値がどのようなオブジェクトなのかを推測する必要があり、読み手の認知負荷が高まる。これに対し、型ヒントのある関数は、トップラインを見るだけで関数がどのようなオブジェクトを受け取り、どのようなオブジェクトを返すのかが明確に伝わる。
# 入出力の型がわからない
def add_item(user_purchased_items, user, item):
...
# 入出力の型が明確で関数の機能が明確
def add_item(
user_purchased_items: Dict[str, List[str]],
user: str,
item: str
) -> Dict[str, List[str]]:
...
型ヒントはあくまでアノテーションに過ぎず、実行に影響を与えることはないが、mypyなどの型チェッカを利用することで、コードに潜むバグを検知することにもつながり一石二鳥。
型ヒントを付けて関数・引数に適切な命名をするだけでも、docstringの詳細な記述なしに関数の役割を十分伝えられる。
データフレームのスキーマを明示する
型ヒントは処理の入出力のオブジェクトの型を教えるが、その中身については教えてくれない。特に、分析タスクではしばしばデータフレームを受け取りデータフレームを返すような関数を定義するが、型ヒントだけでは関数がどのようなカラムを持った入力を前提とし、どのようなカラムを持った出力を返すかを読み取ることができない。
# データフレームの中身がわからない
def preprocessing(df: pd.DataFrame) -> pd.DataFrame:
...
pandasのバリデーションツールであるpanderaを使うと、この問題は解決できる。panderaでは、データフレームのスキーマを定義したクラスを型ヒントに付け、@pa.check_types
でデコレートすることで、自動的に関数の入出力をバリデーションしてくれる。panderaを利用することは、入出力のデータフレームのバリデーションのみならず、データフレームの中身が何であるかを伝えるためにも役立つ。
import pandera as pa
from pandera.typing import DataFrame, Series
class InputSchema(pa.DataFrameModel):
user_name: Series[str]
item_name: Series[str]
unit_price: Series[int]
qnantity: Series[int]
class OutputSchema(pa.DataFrameModel):
user_name: Series[str]
total_price: Series[int]
# データフレームの中身が明確
@pa.check_types
def preprocessing(df: DataFrame[InputSchema]) -> DataFrame[OutputSchema]:
...
panderaのより詳細な使い方はこの記事では解説しませんが、以下のような優れた多くの記事があるためそちらをご参照ください。
【pandera】pandasでも型をしっかりつけたい! - Qiita
適切なデータ型を選択する
適切なデータ型の選択は、そのオブジェクトがどのようなデータを持っているかの表明になる。
例えば、異なる種類の型のオブジェクト集めたコレクションとして、辞書を使うとする。辞書を使うことの問題点の一つは、保持しているデータが外からは見えないことである。型ヒントもってしても、コレクションがどのようなキーでどのような型を保持しているかを読み手に伝えることはできない。
user = {
"name": "Alice",
"age": 24,
"birthday": datetime.date(2000, 1, 1),
}
# 何のキーが存在してどの型に対応しているかわからない
user: Dict[str, Union[str, int, date]]
これに対し、データクラスを利用すると、コレクションの中にどのようなキーが存在するか、またそれらがどのような型のオブジェクトに紐づいているかが明瞭になる。
# 存在する属性とその対応する型が明確
@dataclass
class User:
name: str
age: int
birthday: datetime.date
実行時のバリデーションの機能をもつPydanticでもよい。
ロバストPythonはデータ型の選択について、以下の指針を与えている。
データ型 | ユースケース |
---|---|
列挙型(Enum) | 値自体には必ずしも意味を持たない離散的なスカラー値の集まりを表現する場合 |
辞書 | 値が全て同じ種類の型を持つコレクションを表現する場合 |
データクラス | 値が異なる種類の型を持つコレクションを表現する場合 |
クラス | オブジェクトの生存を通して維持したい特定の条件(不変式)がある場合 |
このように、データのまとまりに応じて適切なデータ型は異なる。逆に言うと、適切なデータ型の選択を通じて、それがどのようなデータを保持しているかを表現することができる。
属性・メソッドのアクセスレベルを明示する
クラスの属性やメソッドをprotected(アンダースコア一つ)で定義することは、「このメソッドや属性はクラス内でしか参照しない」という明確な意図の表明になる。
# クラス外からも参照されうるという想定を持って読まれる
class SomeClass:
def method_only_used_in_class(self, x, y):
...
# クラス外からは参照されないという意図が明確
class SomeClass:
def _method_only_used_in_class(self, x, y):
...
Pythonの属性・メソッドは本質的にはすべてpublicであり、protectedな宣言が実際にアクセスを制限することはないため、使い方は利用者側に委ねられる。しかし、意図するアクセスレベルを明示することは、クラスの使い方についてのガイドを与え、また読み手が属性やメソッド間のつながりを理解するための助けになる。
staticmethodを活用する
@staticmethod
でデコレートしたメソッドは第1引数にself
を取らない、すなわちインスタンスの状態に依存しないし影響もしない。したがって、クラス内のみで使うユーティリティ関数のようなメソッドは@staticmethod
を付けてstaticmethodとして定義することで、インスタンスの状態とは無関係であることを明示できる。「処理がインスタンスに関係しない」という情報は、読み手にとってメソッドの影響範囲を理解するための重要な手がかりとなる。
# どこでselfにアクセスしているのかに注意する必要がある
class SomeClass:
def util_func(self, x, y):
...
# selfとは無関係な処理であることが明らか
class SomeClass:
@staticmethod
def util_func(x, y):
...
そもそもインスタンスの状態に依存・影響しない処理はクラスのメソッドではなく独立した関数として定義すべきだ、という意見もあるが、容量用法を守って利用する分には可読性向上につながると思う。
インターフェースと実装を分離する
クラスが備えるべき機能(インターフェース)を抽象基底クラスとして定義し、具体的な実装はそれ継承した具象クラスで行うことで、クラスの機能を明確にできる。これは特に、振る舞いは同一だが異なる内部ロジックを持つような複数のクラスを定義する場合に役立ち、それらが共通した機能を備えているということを明確に伝えることができる。
from abc import ABC, abstractmethod
# クラスに期待するインターフェースが明確
class BaseModel(ABC):
@abstractmethod
def fit(self, X, y):
pass
@abstractmethod
def predict(self, X):
pass
class GBDTModel(BaseModel):
def fit(self, X, y):
...
def predict(self, X):
...
class NNModel(BaseModel):
def fit(self, X, y):
...
def predict(self, X):
...
抽象基底クラスを継承する具象クラスは、@abstractmethod
でデコレートされた抽象メソッドをオバーライドしないとクラスを初期化できない(TypeError
が送出される)。したがって、抽象基底クラスを継承することで、各具象クラスが共通したインターフェースを持つことが必然的に保証される。これは、クラスを利用する側にとっても内部ロジックを気にかけることなく、異なるクラスを統一的に使うことができるという点で嬉しさがある。
テストを書く
テストは、対象の関数にどのような振る舞いを期待していて、逆にどのような振る舞いを期待していないかを表す、一種のドキュメントとしての役割を果たす。テストを書くことは、コードの品質を担保するとともに、関数の仕様を表明することにもつながる。
加えて、テストを書くことには以下のような開発上のメリットもある。
- 単体テストを書くことで関数単位でデバッグができるようになる
- 「テストをしやすいか」という視点を持つことで設計を見つめ直すきっかけになる
今の時代はChatGPTやGitHub Copilotなどのツールのおかげでテストを書くハードルは大きく下がっているため、必要に応じて積極的にテストを書くことで様々なメリットを享受できるように思う。
おわりに
コードに意図を込めるいくつかの方法について説明しました。説明した方法の多くは開発コストとのトレードオフになるので、コストと相談しつつ利用することが重要かと思います。この記事が可読性の高いコードを書くための参考になれば幸いです。