概要
私は普段Pythonでコーディングをすることが多いです。
Pythonはざっと書いてもなんとなく動いてくれる事が多いので、私はついつい適当に書き流してしまいます。
ただ、そういうコードを後から見直すときや、他人に見せるときは、「これ、何してるんだっけ...」とか「この関数、どうやって使うんだっけ...」とかなってしまいます。
というわけで、備忘録として「取り敢えずこれらに気を付けていれば、ある程度はわかりやすくなる」という事柄を列挙しておきます。
コメント
処理内容をコメントするのではなく、処理目的を記述する
処理内容は、関数名や変数名で追えるようにしておき、それだけでは追いづらい処理目的を自然言語でコメントするよう意識していますが度々忘れます。
例えると以下のようなコードです。
# 1/4に縮小する ← 関数名や変数名から想像できる
small_image = resize(image, scale=1/4)
# モデルに流し込むにあたって安定してメモリに乗せるために縮小する
small_image = resize(image, scale=1/4)
処理内容をコメント化するのが間違っているとは思いません。複数行に跨ったり、最適化のためにパッと見で理解できないコードになっていたりする時に、処理内容のコメントがあるのはとても嬉しいです。
ただ、上の例のように、コードを読むのが手間でない時にコードと同じ内容をコメントするのは冗長かと思っています。
「処理を行う動機や目的はコードから追いづらい」という意識を持ってコメントを書くと、時間が経ってから見返しても思い出しやすいコードになるかなと思っています。
型アノテーション
関数の引数と返り値だけでもやっておいて損はない
Pythonでの型アノテーションはコメント+αで、実行時の型の一致を保証してくれるものではありませんが、それでも可能な限りアノテーションするべきだと思っています。
型アノテーションすると良い場所
# メソッドの引数と返り値に対するアノテーションはとても良いもの(メソッドの中身を見なくて良くなる)
class Person:
def __init__(self, first_name: str, last_name: str, age: int): # メソッドの引数に対するアノテーション
self._name: str = first_name + ' ' + last_name # 変数に対するアノテーション
self._age = age
def is_older_than(self, age: int) -> bool: # メソッドの返り値に対するアノテーション
return self._age > age
特に公開している関数の引数と返り値は型アノテーションされていないと使用方法が分からないので、必須かなと思っています。もちろんdocstring
で記述しても良いと思います。ある程度高機能なエディタならdocstring
もパースしてくれるはずなので。
日常的に使う型アノテーション
int
やfloat
のような組み込み型を持つ変数や、クラスをインスタンス化した変数を型アノテーションする際の方法がこちらです。
age: int = 0
weight: float = 0.0
name: str = 'string'
is_student: bool = True
taro: Person = Person('taro', 'suzuki', 20)
他の組み込み型で、よく使うものと言えば、list
やdict
やtuple
があります。
これらも同じように
friends: list = [daisuke, tomoko]
parents: tuple = (mother, father)
contacts: dict = {'email': 'xxx@mail', 'phone_number': 'XXXX'}
のようにアノテーション可能ですが、typingモジュールを使えば、より細かくアノテーションできます。
例えば上の例だとfriends
がリストであることは分かりますが、具体的にどんな要素を入れればよいのか、が分かりません。typing
を使って要素までアノテーションする方法がこちらです。
typingを使った日常的に使う型の詳細アノテーション
from typing import List, Tuple, Dict # 型アノテーションのためのimport
friends: List[Person] = [daisuke, tomoko] # Personインスタンスを要素に持つリスト
parents: Tuple[Person, Person] = (mother, father) # Personインスタンスを2つ要素に持つタプル
contacts: Dict[str, str] = {'email': 'xxx@mail', 'phone_number': 'XXXX'} # keyがstr, valueがstrである辞書
typing
によって、より詳細な型アノテーションが可能になりました。
個人的には、特にDict
の型アノテーションでkeyとvalueにアノテーションがあると、どのような辞書型を期待しているのか分かるのでとても安心します。
また、これらは入れ子構造でアノテーションすることも可能です。例えば、emailアドレスや電話番号を複数持つ人もいるはずです。そうすると、contacts
のvalueはstr
ではなく、List[str]
にしたくなってきます。そういう時は
# keyがstr, valueがstrのリストである辞書
contacts: Dict[str, List[str]] =
{
'email':
['xxx@mail', 'yyy@mail'],
'phone_number':
['XXXX', 'YYYY']
}
のようにアノテーションすることが可能です。
typingを使った便利な型アノテーション
typing
は上で挙げたアノテーション以外にも様々なアノテーションを可能にします。よく使うものとして、Union
とOptional
を紹介します。
Union[A, B, C]: A, B, Cのどれか
Person
インスタンスの体重を変化させる関数を書くことを考えてみます。とても単純な実装ですが、受け取った重さ分だけ体重を変化させます。
class Person:
...
def update_weight(self, weight: float) -> float
self.weight += weight
return self.weight
一見よさそうですが、変化分としてfloat
しか受け付けないのは少し悲しい気もします。int
も受け取れるとちょっと便利かもしれません。int
もしくはfloat
ならOK,という型アノテーションをしたくなります。そういった「この中のどれかの型ならOK」という時に使えるのがUnion
です。
class Person:
...
def update_weight(self, weight: Union[int, float]) -> float
self.weight += weight
return self.weight
これにより、int
もfloat
もupdate_weight
の引数に入れることが可能になります。
Optional[A]: AもしくはNone
Union
を使うようになると、よくコード中にUnion[A, None]
という表記をするようになることに気付くかもしれません。
例えば、職業を表すOccupation
クラスを定義したとします。Person
クラスに、その人の職業を持たせることにしましょう。しかし、ひょっとしたらPerson
が学生で、職業がないかもしれません。職業はOccupation
もしくはNone
である、ということにしたいです。こういう時にUnion
が使えますね。
class Person:
def __init__(..., occupation: Union[Occupation, None]):
self._occupation = occupation
また、別の例としてその人のパスポートIDを文字列として取得する関数をはやしたいとします。しかしパスポートを持っていないかもしれません。空文字列を返すのも一つの手でしょう。しかし、存在しないことをはっきりさせたいなら、None
を返すことを検討するのもいいかもしれません。
class Person:
def get_passport_id(self) -> Union[str, None]:
if self.has_passport:
return self._passport._id
else:
return None
こういった型アノテーションをする機会が頻繁になってくると、だんだんめんどくさくなってきます。そんなときのために、Optional
が用意されています。Optional
は、Optional[A]
のように使用し、A
もしくはNone
という意味になります。Optional[A] = Union[A, None]
です。
Optional
を使うと、先程の例はこうなります。
class Person:
def __init__(..., occupation: Optional[Occupation]):
self._occupation = occupation
def get_passport_id(self) -> Optional[str]:
if self.has_passport:
return self._passport._id
else:
return None
Optional
という単語が割り振られたからか、ちょっとコードの意図が伝わりやすくなっている気がします。
typingを使った便利かもしれない型アノテーション
そこまで使うわけではないですが、あると便利かもしれない
NewType: 新たな型の作成
新しい型を定義することができます。例えば、int
型のperson_id
から、該当するPerson
インスタンスを探してくる関数として、
def find_by_id(person_id: int) -> Person:
...
のような表記ができると思います。ここでは引数名がperson_id
という分かりやすい名前なので、あまり混同しないかもしれませんが、うっかり、同じint
型で定義したoccupation_id
なんかを引数に渡してしまうミスをしてしまうかもしれません。
そういったうっかりミスを防ぐために、敢えてPersonId
クラスを定義して
class PersonId(int):
pass
def find_by_id(person_id: PersonId) -> Person:
...
p = find_by_id(PersonId(10))
としても良いかもしれませんが、これはインスタンス作成のオーバーヘッドが生じます。
NewType
を使用すると、
from typing import NewType
PersonId = NewType('PersonId', int)
def find_by_id(person_id: PersonId) -> Person:
...
p = find_by_id(PersonId(10))
と書けますが、これは実行時には何もせず10を返す関数が呼ばれるだけなのでオーバーヘッドが小さいです。また、コードの意味として、int
だけどここは別の型として定義し直すことでヒューマンエラーを防いでます、というのが見えやすい気がします。
TypeAlias: 複雑な型に対する別名
例えばDict[str, List[str]]
くらいなら「えーと、str
がkeyでstr
が要素のリストがvalueの辞書だな」と読めるかもしれませんが、これがList[Dict[str, Union[int, float, None]]]
とかなってくるとよく分からなくなってきますし、この型をやり取りする関数で毎回これだけの型アノテーションをつけるのはしんどいです。そういう際に、TypeAliasを使えば、
TypeReportCard = List[Dict[str, Union[int, float, None]]]]
def set_report_card(report_card: TypeReportCard):
...
set_report_card([{'math': 9.5}, {'english': 7}, {'science': None}])
# 間違い例 -> set_report_card(TypeReportCard([{'math': 9.5}, {'english': 7}, {'science': None}]))
のようにスッキリ書けます。単にエイリアスを作るだけで、特別にimportは必要ないです。NewType
とは違って、あくまでエイリアスなので実際に引数として用いる際に、エイリアス名で包む必要はありません。
PEP 484を一読するのも面白いかもしれません
データクラス
辞書型を使う際は、一度データクラスも検討してみる
ある人の連絡先をデータとして持ちたくなったので、辞書型で表現してみます。
contacts: Dict[str, Optional[str]] =
{
'email': 'xxx@mail',
'phone_number': None
}
これだけ見ると別になんてないよくあるコードです。
ある日、「連絡先に電話番号が設定されているかどうか」を知りたくなったとします。
def has_phone_number(contacts: Dict[str, Optional[str]]) -> bool:
return contacts.get('phone_number', None) is not None
# return 'phone_number' in contacts and contacts['phone_number'] is not None でも良い
特に問題なく動くと思います。2週間後、この関数が再び必要になりました。型アノテーションのおかげでcontacts
がどのようなデータか思い出すことができ、無事関数を呼び出すことに成功します。
has_phone_number({'email': 'xxx@mail', 'phone-number': 'XXXX'})
しかし、この関数の返り値は、False
になります。よく見るとphone_number
をphone-number
にしてしまっています。そのせいで、phone_number
が存在しないことになり、結果がFalse
になります。Dict[str, Optional[str]]
の型アノテーションでは、必要なkeyの名前までは分からなかったので、2週間前に決めたkey名を正確に思い出すことが出来なかったのです。
この例はすぐ上にhas_phone_number
の実装が書いてあるので分かりやすいかもしれません。しかし、もしこの関数の実装場所が離れていたら? 結果がFalse
となっていることにすぐ気付けなかったら? デバッグはしんどくなると思います。
コード中に出てくる直接埋め込まれた定数はなるべく避ける、というのが定番ですが、辞書型のkeyに関しても注意が必要です。
そういう際は一度データクラスを検討すると良いかもしれません。
データクラスによる辞書の置き換え
# dataclassで代替させる
import dataclasses
@dataclasses.dataclass
class Contacts:
email: Optional[str]
phone_number: Optional[str]
dataclasses.dataclass
は__init__
の自動生成を行ってくれるので、クラスとしての表記は上で十分です。
他にも、__eq__
の自動生成、__repr__
の自動生成など、様々な機能を備えていますが今回は割愛します。
上のようにデータクラスとして連絡先を定義すると、has_phone_number
は下のように実装できます。
c = Contacts(email='xxx@mail', phone_number='XXXX')
def has_phone_number(contacts: Contacts) -> bool:
return contacts.phone_number is not None
このようにデータクラスのフィールドとしてアクセスするので、(エディタがチェックやサジェストをサポートしてくれるので)タイプミスをすることがなくなります。
また、Dict[str, Optional[str]]
で定義されていた頃と異なり、key名(フィールド名)が固定され、さらに各keyごとに型がつくことになったので、どのようなデータを要求しているのかがより具体化されています。
補足: NamedTupleによる辞書の置き換え
データクラスは、Python3.7からの機能なので、もし3.6以下を使う場合は、typing
のNamedTuple
を検討するのもありかもしれません。NamedTuple
はこのように使います。
# NamedTupleで代替させる
from typing import NamedTuple
class Contacts(NamedTuple):
email: Optional[str]
phone_number: Optional[str]
c = Contacts(email='xxx@mail', phone_number='XXXX')
def has_phone_number(contacts: Contacts) -> bool:
return contacts.phone_number is not None
記述はほとんどdataclass
と変わりませんが、dataclass
はより細かな設定が可能なパワフルさがあるのに比べ、NamedTuple
はあくまで名前付きタプルというだけです。
列挙型(enum)
最後は列挙型です。
ある変数がAかBかCしか取り得ないんだよな...という時にとても便利です。例えば、こういうコードを流し書きしてしまうことが私はよくあります。
def display(object_shape: str):
if object_shape == 'circle':
...
elif object_shape == 'rectangle':
...
elif object_shape == 'triangle':
...
else:
raise NotImplementedError
display('circle')
状態に応じて処理を分けるときにこんな感じで書いてしまい、後から状態の管理が煩雑化したり、状態名が分からなくて実装を探したり...ということをやってしまいます。また型だけ見るとstr
なら何でも良いように見えますが、実際はいくつかの文字列以外はエラーになります。
そういう時に、列挙型(enum
)を使うとスッキリ書けるかもしれません。
from enum import Enum
class ObjectShape(Enum):
CIRCLE = 0
RECTANGLE = 1
TRIANGLE = 2
def display(object_shape: ObjectShape):
if object_shape is ObjectShape.CIRCLE:
...
elif object_shape is ObjectShape.RECTANGLE:
...
elif object_shape is ObjectShape.TRIANGLE:
...
else:
raise NotImplementedError
display(ObjectShape.CIRCLE)
これでタイプミスの心配はなくなりましたし、型を見れば何が許されているのかが一目瞭然です。
また、今回はEnum
の識別子に全て手動で番号を割り振りましたが、この番号には特に意味がないはずなので、
import enum
class ObjectShape(enum.Enum):
CIRCLE = enum.auto()
RECTANGLE = enum.auto()
TRIANGLE = enum.auto()
のように自動でユニークな値を割り振ってもらうのがベストかなと思います。