Robust Codeを書くためのプログラミング姿勢について

Robust Python Programming 1/N

公開日: 2024-03-12
更新日: 2024-03-12

  Table of Contents

この記事のスコープ

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を書くこと

  1. 変更は不可避なので, 変更を受け入れること
    • 自分のコードが修正されるだけでなく, 状況に応じて削除や再作成されることを覚悟する
    • 将来のrewrite processを意識して, 将来の誰かがrewriteしやすいコードを書く
    • 将来のmaintainersが自分のコードを見たとき, 自分の意図を推察することができるコードを書く
    • thoughts, reasoning, and cautionsを伝える
  2. 柔軟性をもって開発に当たること
    • 作業時には想定してなかった状況に対応しなくてはいけないのが開発
  3. 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_proportionFraction介して行われている
    • 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']のことです. Listdictは基本的に, dynamicに要素が変わっていくので static indexを用いた処理の用いられるdata typeとしては適していないとされています.

使用したとしても

  • sequenceの最初や最後に意味がある場合
  • 長さが一定のデータを扱う場合
  • Performance上の理由

という例外的なケースに留めるべき(=何かしら明確な意図を持ち & それをコメントなどで伝える)となります.

References



Share Buttons
Share on:

Feature Tags
Leave a Comment
(注意:GitHub Accountが必要となります)