Table of Contents
この記事のスコープ
- ロバストPython ― クリーンで保守しやすいコードを書く, Patrick Viafore 著、鈴木 駿 監訳、長尾 高弘 訳を教材にPythonによるシステム開発におけるcodingのあるべき姿を Robustness という観点から勉強するノートのシリーズ.
Python ProgrammingにおけるRobustness
システム開発を行うにあたって望ましい姿は, capable of performing without failureなシステム = robustなシステムです.
しかし現実の開発は
- バグの修正
- ユーザーインターフェースの改善
- featuresの追加, 削除, 再追加
- フレームワークの変更
- 古くなり更新が必要なcomponentsの発生
- セキュリティー関係のバグの発生
といった出来事が頻繁に発生し, ソフトウェアの変更が合わせて頻繁に発生するものです. このような現実の中で, bugに対してrobustであることを含むrobustなシステムをどのように構築することが求められます. その完成品をRobust codeと呼ぶとするならば,
1
A robust codebase is resilient and error-free in spite of constant change.
このようなコードをどのように書くかが開発者にとって問題となります. 多くの場合, 幾通りの試行錯誤や検証と他人からのレビューに揉まれて(PDCAを何回も回して)Robust Codeは生まれて来ます. そのため, Robust Codeは他の人が「意図がわかりやすく, バグをみつけたり, 修正しやすい」コード = improvableであることが必要条件であると言えます.
improvableなコードを書くにあたっての姿勢として以下のことを挙げています.
Improvableなコードを書くにあたっての姿勢
下記の意味を踏まえて, clean and maintainable codeを書くこと
- 変更は不可避なので, 変更を受け入れること
- 自分のコードが修正されるだけでなく, 状況に応じて削除や再作成されることを覚悟する
- 将来のrewrite processを意識して, 将来の誰かがrewriteしやすいコードを書く
- 将来のmaintainersが自分のコードを見たとき, 自分の意図を推察することができるコードを書く
- thoughts, reasoning, and cautionsを伝える
- 柔軟性をもって開発に当たること
- 作業時には想定してなかった状況に対応しなくてはいけないのが開発
- Law of Least Surpriseの遵守
- Surpriseは混乱の元
- 後の開発者がバグなのか仕様書通りの挙動なのか判別できるようにすること
Clean and maintainable Codeの重要性
Robust Codeがシステム開発で達成すべき結果です. そして, Clean and maintainable CodeはそのRobust Codeを実現するにあたって重要なアクションです. ではClean and maintainable Codeの具体的なアクションとはなんでしょうか?
Clean and maintainable Codeのアクション
- コードを適切な粒度で整理する(Organizing your code in an appropriately granular fashion)
- 良いDocumentを整備する
- 変数/関数/Typesに適切な名前をつける
- 関数やクラスを短く & シンプルにする
What’s Your Intent?: Understanble Code
自分がメインの開発者でもmaintainersでもClean and maintainable Codeを書くことで
stateDiagram
direction LR
Clean --> Readable
Readable --> Understanble
maintainable --> Improvable
Understanble --> Improvable
Improvable --> Robust
というパスを実現することができるからこそ, Clean and maintainable Codeを書く意義があります.
この流れの中で Understanble Code
とは具体的には何を指すのか問題を通して考えてみます.
Question 1
以下のコードの意図を読み取り改善例を示せ:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Take a meal recipe and change the number of servings
# by adjusting each ingredient
# A recipe's first element is the number of servings, and the remainder
# of elements is (name, amount, unit), such as ("flour", 1.5, "cup")
def adjust_recipe(recipe, servings):
new_recipe = [servings]
old_servings = recipe[0]
factor = servings / old_servings
recipe.pop(0)
while recipe:
ingredient, amount, unit = recipe.pop(0)
# please only use numbers that will be easily measurable
new_recipe.append((ingredient, amount * factor, unit))
return new_recipe
解説
問題分のコードを見ると以下のような疑問が出てきます
recipe[0]
を呼んだ後にwhile
の外でrecipe.pop(0)
を実行する意図はなにか?# please only use numbers that will be easily measurable
の意図はなにか?
よくわからないまま次のような改善コードを提案したとします
1
2
3
4
5
6
7
def adjust_recipe(recipe, servings):
old_servings = recipe.pop(0)
factor = servings / old_servings
new_recipe = {ingredient: (amount*factor, unit)
for ingredient, amount, unit in recipe}
new_recipe["servings"] = servings
return new_recipe
- 返り値についてもともとのコードでは
List
型であったところをレシピのデータ型としてよりふさわしいと缶がられるdict
型へ変換
という形で改善しています. 一見良さそうに思えますが, 次のようなバグをもたらすリスクがあります
recipe
の中にingredient
の重複がある場合, もともとのコードではどちらのnew_recipe
に格納されるが, 改善コードの方では上書き処理されてしまう(=改善版では重複がない仮定をおいている)servings
という名前のingredient
が存在する場合, name collisionが発生してしまう
これらがバグかどうかは, もともとのオリジナルコードの意図によります. もともとの開発者がこれらの問題についてわかってた上で, もともとのコードを書いたのかどうかがわからないと改善コードとして提案することは難しく, したとしても結合テストのときにどでかいバグを引いてしまうことを覚悟しながら作業しなくてはなりません.
Question 2
Question 1の元と比較して, 以下のコードはどのような意図が読み取れるか解説せよ.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def adjust_recipe(recipe, servings):
"""
Take a meal recipe and change the number of servings
:param recipe: a `Recipe` indicating what needs to be adusted
:param servings: the number of servings
:return Recipe: a recipe with serving size and ingredients adjusted
for the new servings
"""
# create a copy of the ingredients
new_ingredients = list(recipe.get_ingredients())
recipe.clear_ingredients()
for ingredient in new_ingredients:
ingredient.adjust_proportion(Fraction(servings, recipe.servings))
return Recipe(servings, new_ingredients)
解説
Recipe
classを用いているので挙動をmethodを通じて抽象的に理解することができるRecipe
classの定義にservings
が重要であることがわかるrecipe.clear_ingredients()
より初期化(Q1におけるpop(0)
)が重要であることがわかるingredients
が別のclassで定義されており, fractionの計算がadjust_proportion
やFraction
介して行われている- Error handlingのロジックが書いてあると推察できる
コードからそれぞれのラインの意図が明確なので, もともとの開発者がなにを考えていたのか?想定inputはなにか?修正したい場合はどこを見ればいいのか?をもともとの開発者に直接聞かなくても推察することができる(=ちゃんと機能している非同期コミュニケーションが実現できている).
同期コミュニケーションと非同期コミュニケーション
自分の意図を他人に伝えるにあたってコミュニケーションをどのように行うか?も重要なトピックです. コミュニケーションの発生という観点で「同期コミュニケーション」と「非同期コミュニケーション」と分けて考えることができます.
- 同期コミュニケーション: リアルタイムで情報の交換をするコミュニケーション
- 非同期コミュニケーション: 情報の発生とその活用がそれぞれ独立に発生するコミュニケーション
他人が書いたコードから意図を推察するには「非同期コミュニケーション」の一例と考えることができます. 一見, 同期コミュニケーションの方が良さそうに見えますが, 同期コミュニケーションは
- スケールしない
- 質問があるときにその場にいるとは限らない
というウィークポイントがあります. 「同期コミュニケーション」と「非同期コミュニケーション」どちらが適切かを判断するにあたって, コミュニーケーションのproximityとcostの2軸による分類を書籍では紹介しています:
- proximity: 近くにいるか?
- cost: コミュニケーションのための労力, 機会費用
- 情報の探索時間
- メンテナンスコスト
- コミュニケーション設定時間
Cost | Proximity | 説明 |
---|---|---|
Low | High | 簡単に行えるがスケールしないコミュニケーション |
High | High | どちらも高いのでvalueが確実に存在する場合のみ実施すれば良い(実行計画のアラインメントやIdea共有など) |
High | Low | snapshotsを保存するためのコミュニケーション スケールはするが, 頻繁にupdatesがあるトピックには向かない |
Low | Low | いつでもみられるし, 記録も残しやすい |
Codeで理解するunderstanble code
Question: Collections
以下のコードの意図を読み取れ
1
2
3
4
5
6
7
def create_author_count_mapping(cookbooks: list[Cookbook]):
counter = {}
for cookbook in cookbooks:
if cookbook.author not in counter:
counter[cookbook.author] = 0
counter[cookbook.author] += 1
return counter
解説
上のコードからは以下のことが読み取れる
- cookbooksの
List
をinputにしていることから, cookbooksにはcookbook
の重複が考えられる dict
を戻り値としていることから, 別の箇所でauthor
の登場回数を調べたり,author
のsetでiterationすることを予定していると考えられる
一般的にcollectionの選び方は以下のような情報を読み手に伝える傾向がある
Collection Type | 説明 |
---|---|
List |
iterable , mutable , static indexを用いたを値の抽出は基本的にない, 要素に重複があり得る |
Generator |
iterable , ただしすべてのiteration実行には時間がかかる, 最初の10件のみ抽出したい場合などに用いる |
Tuple |
iterable , immutable , static indexを用いたを値の抽出することが予定されている可能性が高い |
Set |
iterable , no dupulicates. 要素の格納順番は信用できない |
frozenset |
imuutable set |
dict |
key-value mappingを予定している. keysはunique and iterable. |
OrderedDict |
keyの順番に意味がある場合 |
defaultdict |
keyがmissingのときはdefault値を返すdict |
Counter |
出現回数を記録するためのdict |
1
2
3
4
5
6
7
8
9
10
11
12
## defaultdict
from collections import defaultdict
def create_author_count_mapping(cookbooks: list[Cookbook]):
counter = defaultdict(lambda: 0)
for cookbook in cookbooks:
counter[cookbook.author] += 1
return counter
## Counter
from collections import Counter
def create_author_count_mapping(cookbooks: list[Cookbook]):
return Counter(book.author for book in cookbooks)
necessary complexity vs accidental cimplexity
Understandable codeを書くにあたって, the law of least surpriseは重要ですが, そのsurpriseはどこからやってくるのかを感がてみます. 多くの場合, surpriseはcomplexityから発生します. ではcomplexityが一概に悪か?と考えると, 必要に迫られて発生したnecessary complexityと不必要にも関わらず発生したaccidental complexityの2つがあります. 前者は開発上仕方ない存在ですが, 後者は悪の存在です.
たとえば, MLや統計モデルの実装コードはinherentlyにcomplexになる傾向があり, 基本的には回避することは難しい = necesarry complexityとなります.
逆にaccidental complexityは
- 過度な抽象化
- Excessive Configuration
- 不必要な前処理の実施
というような現れ方をします.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 過度な抽象化: a + bで十分
class MathOperations:
def add(self, a, b):
return a + b
result = MathOperations().add(3, 4)
# Excessive Configuration: 普通にpackageを使おう
class DatabaseConnector:
def __init__(self, host, port, username, password):
# ... complex configuration code ...
def connect(self):
# ... complex connection logic ...
# Using the complex database connector
db = DatabaseConnector(host='localhost', port=5432, username='user', password='pass')
db.connect()
# 不必要な前処理の実施: 統計処理に不必要な前処理を担当したクラスの実装
class AdvancedDataProcessor:
def __init__(self, data):
# ... complex initialization ...
def process_data(self):
# ... complex data processing ...
# Using an overly complex data processor
data_processor = AdvancedDataProcessor(data)
data_processor.process_data()
Appendix: Dynamic Versus Static Indexing
static indexとはcollectionのindexを参照するためのconstant literalのことで
my_List[4]
や my_dict['Python']
のことです. List
やdict
は基本的に, dynamicに要素が変わっていくので
static indexを用いた処理の用いられるdata typeとしては適していないとされています.
使用したとしても
- sequenceの最初や最後に意味がある場合
- 長さが一定のデータを扱う場合
- Performance上の理由
という例外的なケースに留めるべき(=何かしら明確な意図を持ち & それをコメントなどで伝える)となります.
References
- Robust Python > Chapter 1. Introduction to Robust Python
- Ryo’s Tech Blog > Coding Style Guide Part 1
(注意:GitHub Accountが必要となります)