58
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Pythonコーディングで私が気をつけてること:コメント・型アノテーション・データクラス・列挙型(enum)

Last updated at Posted at 2020-03-11

概要

私は普段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もパースしてくれるはずなので。

日常的に使う型アノテーション

intfloatのような組み込み型を持つ変数や、クラスをインスタンス化した変数を型アノテーションする際の方法がこちらです。

日常的に使う型のアノテーション
age: int = 0
weight: float = 0.0
name: str = 'string'
is_student: bool = True

taro: Person = Person('taro', 'suzuki', 20)

他の組み込み型で、よく使うものと言えば、listdicttupleがあります。
これらも同じように

friends: list = [daisuke, tomoko]
parents: tuple = (mother, father)
contacts: dict = {'email': 'xxx@mail', 'phone_number': 'XXXX'}

のようにアノテーション可能ですが、typingモジュールを使えば、より細かくアノテーションできます。
例えば上の例だとfriendsがリストであることは分かりますが、具体的にどんな要素を入れればよいのか、が分かりません。typingを使って要素までアノテーションする方法がこちらです。

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は上で挙げたアノテーション以外にも様々なアノテーションを可能にします。よく使うものとして、UnionOptionalを紹介します。

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

これにより、intfloatupdate_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を使えば、

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_辞書

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_numberphone-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以下を使う場合は、typingNamedTupleを検討するのもありかもしれません。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の識別子に全て手動で番号を割り振りましたが、この番号には特に意味がないはずなので、

auto
import enum


class ObjectShape(enum.Enum):
    CIRCLE = enum.auto()
    RECTANGLE = enum.auto()
    TRIANGLE = enum.auto()

のように自動でユニークな値を割り振ってもらうのがベストかなと思います。

58
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
58
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?