背景
あれこれやるのは苦手だ。とりあえず本を買った。コーディング含めテストや運用について簡単にまとめられた本を買ったのでもう少し本を読み返すのではなくここでアウトプットしてみようと思った。
厳密にはコーディング規約についてはPEPを参照すべきだろうが、まずこの記事では本の内容に従うこととしよう。
コード実装
1.関数に利用する英単語
関数名で具体性のある単語を命名する.
◆取得を意味する英語シリーズ
名称
load
fetch / retrieve
search
calc
increase / decrease
merge
render
filter
aggregate
build / constract
escape / sanitize
目的
読み込み.
ネット経由で外部からデータを取得.
条件を指定して取得.
数値の算出
タイの加算または減産
複数のデータを1つのデータにする
描画処理.
複数のデータから要素を絞り込む
複数の情報から集計・計算する
情報をもとにオブジェクトを生成する.
スケープ、サニタイズ処理.
◆保存を意味する英語シリーズ
名称
dump
create
update
patch
remove / delete
sync
memoize
publish
目的
対象のデータを別ファイルへ保存
新規作成
更新
分的な更新
削除
作成・更新・削除を行い複数のデータソースを同期
メモリに一時的に記録
外部にデータを公開
◆送信を意味する英語シリーズ
名称
notify
目的
外部のサービス/オブジェクトに対し通知
◆その他を意味する英語シリーズ
名称
flatten
minimize
validate / verify
目的
階層構造を持つオブジェクトを1階層に変換
値の最小化
値が正しいか検証
どうしても名称を決めるのが面倒な人は…。
codicというサービスがありました。便利です!
関数名で予測できる処理のみ実装する
一つ、返り値は関数名で予測できるものとする.
二つ、関数名で予測できない処理は実装しない.
以上、2点は守ったほうが良い、と思う。以下のNG例では年齢を更新した上で、フルネームを返り値としている。
これは、よくない!
# OKな例
class Name:
    def __init__(self, name1, name2):
        self.name1 = name1
        self.name2 = name2
    
    # OKな例
    def getFullName():
        return self.name1 + self.name2
    # NGな例
    def getFullName(birthday):
        from datetime import date
        today = date.today()
        self.age = (birthday - today).years).years
        return self.name1 + self.name2意味の単位で関数を設計する
関数は意味の単位でまとめよう!処理単位でまとめるようなことをすると、第3者(将来の私)はどんな処理で何を意味にしているのか分からなくなる。
以下の例だとOKな例とNGな例、どちらが分かりやすい?
# OKな例
data_list = [
    { 'item': 'sample1', 'value': 100 }
    , { 'item': 'sample2', 'value': 200 }
]
def read_datas():
    for item in data_list:
        yied item
def is_sample1(item):
    return item == 'sample1'
def main():
    sum = 0
    for item, value in read_datas():
        if is_sample1(item): sum = sum + (value / 2)
# NGな例
def repeat():
    sum = 0
    for item in data_list:
        if is_sample1(item['item']):
            sum = sum + (value / 2)
def main():
    repeat()mutableな値をデフォルト引数にしない
mutableな値をデフォルト引数にした場合、初期化が行われないため処理をするたびにデフォルト引数の中身が変わってしまいます。
やめましょう!
# NGな例
def defaultArg(val=[]):
    val.append('x')
# 実行結果
defaultArg() # -> '[x]'
defaultArg() # -> '[x,x]'
defaultArg() # -> '[x,x,x]'
# OKな例(必要であれば)
def defaultArg(val=None):
    val = [] if val is None else []引数をオブジェクトの類にしない
よく私自身やりがちなのが、dictやlistなどを引数に渡してしまうやつです。引数に渡された関数でdictにキーが入ってなかったら?listに指定のインデックスがなかったら?なんてことを考え始めないといけません。関数は、特に引数はシンプルにしましょう!
# OKな例
def calc_times(item1, item2):
    return item1 * item2
items = [3,4]
calc_times(items[0], items[1]])
# NGな例
def calc_times(items):
    return items[0] * items[1]
# 関数を利用するのにデータ構造が異なる場合に調整が必要
items = { 'item1': 3, 'item2': 4 }
calc_times( [items['item1'], items['item2']])
データの塊を扱うときは、クラスや辞書を使おう!
データの塊を扱うとき、クラスや辞書を使いましょう。特にその塊がある程度の意味や目的にまとめられる時には有効です。
こういったときはlistはよくないです。インデックスに意味を持たせてしまうことになりますので可読性が損なわれます。データの追加・削除などでインデックスが変わってしまって不具合を起こす原因にもなりえます。
# OK
@dataclass
class User:
    id: int
    name: int
    age: int
def check_user_info(user):
    if user.id is None: False
    if user.name is None: False可変長引数は控えよう!
以下の場合、関数の仕様上”first”ではなく”first_name”を必要としています。にもかかわらず呼び出しで”first”としているため予期せぬ値Noneが入ることとなります。
# NG
def get_nameset(**items):
    return {
        'first_name': items.get('first') # Noneになる可能性があるよ!
        , 'last': items['last']
    }
get_nameset(first='Taro', last='Sample')吾輩は猫である。名前はまだない。どこで生れたか頓と見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。
# OK
class User:
    def __init__(self, first, last, tel=None):
        self.first = first
        self.last = last
        self.tel = tel
    
    # 一緒に、フルネームを取得する処理を定義できる
    def get_fullname():
        return self.first + self.last
def as_json(obj, **items):
    data = { key: getattr(obj, key, default) for key, default in items.items() }
1つの関数に山ほど処理を書かない!
そんなことをした時には、可読性が損なわれ、単体テストが複雑・困難になる。
そんなことをするなら意味や目的別で関数を細かく作り分けよう!
# ちょっと面倒なので省略積極的にdataclassアノテーションを利用しよう!
私は”何となく”などという理由で辞書型をよく使ってしまいがちです。この方法は特定のキーを持つことを前提としているので異なる構造の辞書型が渡されると何となく処理され間違っていることに気づかなかったり、予期せぬタイミングで例外処理が発生してしまう可能性があります。
そこでdataclassです!
データを定義するクラスとしていい感じです!データが増えるほど長ったらしくなる、__init__()を書かずに済みます。
# 
@dataclass
class User:
    first: str
    last: str = 'last name'無駄に属性を増やさない!
無駄に何でも保存させたくない、と考えませんか?以下のような場合どちらの方がよいでしょうか?保存するデータは最低限に、そのデータから計算できるものは後で必要になった時だけ計算させることが可能なんです!それを可能にするのが、”propaty”です。
ただし、毎度計算されることになるようなので、頻度の高い場合は控えるべきかもしれません。ただ、”propaty”の計算結果をメモリーにキャッシュさせることも可能です。”functools.lru_cache”です!まぁ、計算元のデータに変更があっても自動で更新されたりしないみたいだが。(未調査)
# OK
@dataclass
class User:
    first: str
    last: str = 'last name'
    
    @propaty
    def fullname():
        return self.first + self.last
# NG
class User:
    def __init__(first, last):
        self.first = first
        self.last = last
        self.fullname = self.first + self.last
クラスメソッドでインスタンス化しよう!
ここまで”dataclass”を進めてきたわけですが、以下のように自分のクラスをインスタンス化するメソッドが作れたらめちゃ便利じゃないですか!?
# 
from dataclasses import dataclasses
@dataclasses
class Product:
    id: int
    name: str
    
    @classmethod
    def retrieve(cls, id: int) -> 'Product':
        data = retrieve_data(id)
        return cls(
            id=data['id']
            , name=data['name']
        )ジュールに具体的な名前を付ける(汎用的な名前を付けない)!
Pythonモジュール(Pythonのファイル)を意味ごとに分割して作成するべきですが、名前を余りいい加減に決めない方がよいです。例えば、”common.py”や”utils.py”など。
きちんと分けるのであれば、”models.py”や“urls.py”、“request.py”などありますよね。
クラスは自ずと意味づけされているので、先ほどの”common.py”などには不適切です。
ビジネスロジックはモジュール化!
ビジネスロジックとは、業務システムで扱う業務上の処理を表す、と思っている。在庫管理や支払など。
これらは当然、”common.py”でも”utils.py”でもなくビジネスロジックとしてモジュール化しましょう!というお話ですね!はいっ!
単体テストから逆算して、実装を設計する!
ここまでの話に出たように、基本的に意味や目的ごとに関数・クラスに処理を分けましょう!
そうしなければ、自動テストで1関数・クラスの処理がデカいために、テストケースが増大し、ケースごとに用意するデータ量も著しく多くなります。
逆に分割した場合、より細かい規模の関数ではそれだけ詳細なテストケースを用意し、より大きい規模の関数では細かい規模のテストケースが済んでいるのでその分テストケースの用意が少なくできる。
以下の例だと、”validate”や”calc_data”の自動テストを行うにあたり、CSVファイルを必要としなくなります。
import csv
from dataclasses import dataclass
from typing import List
@dataclass
class Data:
    id: int
    value: int
    bias: int
    
    def validate(self):
        if self.bias =! 0:
            raise ValueError("Invalid data.bias")
    
    @property
    def calc_data(self):
        return self.value + self.bias
@dataclass
class DataSet:
    dataset: List[Data]
    
    @property
    def calc_sum(self):
        return sum(data.calc_data for data in self.dataset)
    
    @classmethod
    def read_from(cls, path):
        data = []
        with open(path, encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                try:
                    data = Data(**row)
                except Exception:
                    continue
                data.append(data)
        return cls(data=data)import pytest
class TestDATA:
    def test_validata_invalid_price(self):
        data = Data(1, 1, 0)
        with pytest.raises(ValueError):
            data.validate()
    
    def test_calc_data(self):
        pass
class TestDataset:
    def test_calc_sum(self):
        pass