コード設計について

コードは優れた書籍のように

一般にコードは書かれるよりも読まれることのほうが多く、コードを書くときはその読みやすさが最も優先されるべきである。コードが読みやすいと何をしているのかの理解が容易になり、コードの変更(新機能の追加や処理速度の改善)も実施しやすくなる。一方で、コードが読みにくいとコードの変更に時間がかかるだけでなく、変更の影響が予期しない場所に伝搬してソフトウェアの振る舞いを破壊してしまったり、変更を繰り返すたびに更に複雑で読みにくいものになりったりして、コードが維持管理は困難になる。

処理をまとめてモジュール(あるいは関数、クラス)として切り出し、モジュールをその処理の抽象度レベルを揃えて適切に構造化すると、読み手はコードを優れた書籍のように読むことができる。

例:コードと書籍のアナロジー

# コード

def high():
    middle1()
    middle2()

def middle1():
    low1()
    low2()

def low1():
    # 処理1

def low2():
    # 処理2

def middle2():
    low3()

def low3():
    # 処理3
# 書籍

--- 目次 ---

・high
 - middle1
  + low1
  + low2
 - middle2
  + low3

名前の付け方

名前の重要性

コード設計において、名前は極めて重要である。適切な名前を付けることができたということは、その要素が正しく理解され、正しく設計されているということである。逆に、適切な名前が付けられないということは、その要素についてプログラマ自身が十分理解できていないということである。適切な名前を付けることができたら、設計の大部分が完成したと言っても過言ではない。

読み手にとって、名前は「短いコメント」であり、コードを読むための道標である。関数名を見ただけで処理内容がわかれば、関数内部の詳細を解析しなくて済み、処理の流れの解析に集中できる。

良い名前とは

良い名前の条件は「明確で短い」こと。

意図が明確な名前を付ける

書き手の意図が読み手に伝わるような名前を付けるのが良い。

例:処理内容をより明確に表現している

# get_html() # download_htmlの方がこの関数が何をするのかを明確に表現している
download_html()

当然、無意味な記号を名前にしたり、偽情報を名前に埋め込んだり、違いがわからないような紛らわしい名前を付けたり、分かりづらい略語を使ったりするのは避けた方が良い。

例:無意味な記号

aaa = 1 + 2 # aaaは意味をなさない

例:偽情報を含む

name_list = "Bob" # "Bob"はString型であり、List型ではない、偽情報

例:違いが不明瞭

Account() # AccountとAccountInfoの違いがわからない
AccountInfo()

例:分かりづらい略語

prod = "car" # productを省略していてわかりにくい

一つの概念に一つの名前

一つの抽象概念には一貫して一つの名前を使う方が良い。

同じ処理をしている関数に異なる名前をつけることや、逆に異なる処理をしている関数に同じ名前を付けることは避けた方が良い。

例:同じ処理なのに名前が異なる

class Memo(object):
    def to_csv(self): # csvに吐き出す処理
        ...

class Note(object):
    def write_csv(self): # csvに吐き出す処理
        ...

例:異なる処理なのに名前が同じ

class Memo(object):
    def add(self): # メモ帳に追記
        ...

class Note(object):
    def add(self): # ノートに新しいページを追加
        ...

必要十分な文脈を加える

必要に応じて文脈の情報を追加する。今までは名字だけで識別できていても、同じ名字の人が現れるとフルネームで呼ばなければいけなくなる。

例:必要に応じて文脈の情報を追加する

# email = "admin@example.com" # ← 誰のemailかわからない
admin_email = "admin@example.com"
gest_email = "gest@example.com"

逆に余計な文脈は削除するのが良い。家族の中で呼び合うのに名字を呼ぶ必要はない。

例:余計な文脈は削除する

class Gest():
    def __init__(self, email):
        # self.gest_email = email # ← gestのemailであることは自明
        self.email = email

エンコーディングは避ける

名前には本当に伝えたい情報だけを込めた方が良い。型やスコープ情報なども名前の中に埋め込むのは避けた方が良い。型情報などはIDEの型推定機能で確認できる。

Notion Image

クラス名には名詞、メソッド名には動詞を付ける

クラス設計では、クラス名には名詞(主語)、メソッド名には動詞を付けるのが一般的である。特別な理由がない限り、読み手のメンタルモデルを壊さない方が良い。

フォーマットに情報を込める

クラス名と変数名はどちらも名詞を用いることが多い。クラス名と変数名はフォーマットを区別して、見分けやすくするのが良い。

Pythonの命名規則はPEP8と呼ばれるコーディング規約で定められている。

命名規則
パッケージ名すべて小文字で表記。mypackage
クラス名・例外名単語の頭文字のみ大文字で表記MyClass
モジュール名・関数名・メソッド名・変数名すべて小文字で表記。単語の区切りにアンダースコア(_)を使用。my_function
定数名すべて大文字で表記。単語の区切りにアンダースコア(_)を使用。MY_CONSTANT

コーディング規約の詳細については以下を参照されたい。

解決領域の用語と問題領域の用語を使用する

システム変更の依頼者はいつも業務の専門知識を持ったユーザーであり、コードの読み手はいつもプログラマである。 読み手に理解しやすいコードにするために、問題領域の用語(業務の専門用語)と解決領域の用語(コンピュータサイエンスの用語、アルゴリズムの名前、パターンの名前、数学用語など)を用いた方が良い。

コメントの書き方

コメントの役割

コメントはコードとは異なり、プログラム実行時には無視され実行されることはない。そのため、コメントの内容が正しいかどうかテストされることはなく、間違っている可能性もある。

そこで、コメントに頼るのではなく、プログラム実行時に毎回実行・テストされるコード自身に処理内容を表現させる(自己文章化)ことが良いとされている。

自己文書化されたコードはコメントが少なくても読みやすく、コードとコメントの整合性が取れないといった事態も防ぐことができる。

コメントは、コードでうまく表現することに「失敗」したときのみ記載するようにするのが良い。名著Clean Code(Robert C. Martin)にも、「適切なコメントの使用方法とは、コードでうまく表現することに失敗したときに、それを補うのに使うことです。」と記載されている。

良くないコメントとは

読み手の理解を補助する情報を与えないようなコメントはすべて良くないコメントである。冗長なコメント、ひどいコードを補足するコメント、ノイズコメントはいずれも良くないコメントである。

冗長なコメント

読み手に新しい情報を与えないコメントは冗長なコメントである。冗長なコメントは読者からコードを読む時間を奪うだけなので削除した方が良い。

例:コードからすぐに分かることを説明するだけのコメント

# ','で区切って0番目をfirst_nameに代入
first_name = name.split(',')[0]
# Sampleクラスの定義
class Sample(object):

    # コンストラクタの定義
    def __init__(self):
        pass

    # runメソッドの定義
    def run(self):
        pass

ひどいコードを補足するコメント

一見、読み手に新しい情報を与えているようにみえるコメントも、コード自体で表現(自己文章化)するのが良い。

例:ひどい名前を補うためのコメント

# 一時的に平均値を変数に格納しておく
aaa = np.mean(num_list)

↓ 解決策:aaaをわかりやすい名前に変える

mean_temp = np.mean(num_list)

例:長いコードを補うコメント

# 極座標表示を直交座標表示に直して、2点間の距離を求める
distance = distance.euclidean(
    (r1*np.cos(theta1), r1*np.sin(theta1)),
    (r2*np.cos(theta2), r2*np.sin(theta2))
)

↓ 解決策:変数(あるいは関数)を使用して式を分割する

vec1 = (r1*np.cos(theta1), r1*np.sin(theta1))
vec2 = (r2*np.cos(theta2), r2*np.sin(theta2))
distance = distance.euclidean(vec1, vec2)

例:要約コメント※

# sin曲線グラフの出力
x = np.linspace(0, 2*np.pi, 500)
plt.plot(x, np.sin(x))
plt.show()

↓ 関数(あるいはクラス)として括り出す

def sin_plot():
    x = np.linspace(0, 2*np.pi, 500)
    plt.plot(x, np.sin(x))
    plt.show()

sin_plot()

※ ただし、小さい関数をたくさん作るより、要約コメントを用いた方が可読性が高くなることもある。要約コメントを使いたくなった場合、コード設計を見直すのがベストだが、時間的制約によりコード設計を見直す余裕がなく、要約コメントを使用するのはやむを得ないこともある。

ノイズコメント

コメントとして残すのは不適切なコメントはノイズコメントである。日誌コメントや属性や著名、過去のコードなどはソースコード管理システムで管理するのが良い。

例:日誌コメント

# 変更履歴
# ------------------------------------
# 2019/10/09:get_description()メソッドの追加
# 2019/10/15:output_log()メソッドの追加
# 2019/10/23:Managementクラスの削除
# 2019/11/02:get_description()メソッドのエラーの修正

例:属性や著名

# ボブによる追加

例:コメントアウトされたコード

# mean = np.mean(num_list)
# var = np.var(num_list)

例:長すぎるコメント

# このクラスはフィボナッチ数列生成ジェネレータです。
# フィボナッチ数列は1番目と2番目の数値は 1であり、
# 3番目以降の数値は直前の2数の和である数列です。
# イタリアの数学者レオナルド・フィボナッチに因んで名付けられました。
# ...

例:意味のわからないコメント

# 課長の気が変わったらこれを追加する → 10016, 10034, 10177
# 部長の気が変わったらこれを追加する → 20016, 20034, 20177

良いコメントとは

書かずに済ますよりも良いコメントはない。しかし、時間の都合などで優れた設計ができない場合もある。読み手が「なぜこんな実装になっているのか」と疑問に思うコードにはコメントをつけておくと良い。

コードの欠陥を示すコメント

コードは絶えず進化していくものであり、常に欠陥を含んでいる。その欠陥について説明するコメントは必要なコメントである。この手のコメントはアノテーションコメントと呼ばれていて、以下のように使用する。

 # TODO: 引数の型チェック機能の追加
 # HACK: 冗長なコード。関数で括りだす
 # NOTE: ~という最適化を実施。処理速度が30分から10分に改善

その他よく使われる記法を、以下に示す。

記法典型的な意味
TODO後で追加、修正するべき機能がある
FIXME既知の不具合があり、修正が必要なコード
HACKあまりキレイではない、リファクタリングが必要なコード
XXX動くけど大きな問題がある危険なコード
REVIEW意図通りに動くか、見直す必要があるコード
OPTIMIZE非効率で最適化が必要なコード
CHANGEコードをどのように変更したか情報を残す
NOTEなぜこうなったかという情報を残す
WARNING注意が必要なコード

コードの最適化に対するコメント

リファクタリングの際、コードの不用意な変更により、パフォーマンスが下がってしまうことがある。不用意なリファクタリングを避けるため、最適化を実施した箇所にはコメントを残すのが良い。

# NOTE: 0.67ならファイルサイズと品質の面で妥協できる
image_quality = 0.67
# NOTE: 処理時間の最適化を実施している
# TODO: 最適化の際に、可読性が低くなったのでリファクタリングする
def sort(list):
    ...
    return result

良い設計とは

設計は全体を部分に切り分けて、具体化していく作業である。

Notion Image

良い設計では、内部のやり取りを最大に、外部とのやり取りを最小にするように各モジュールを境界づける。言い換えると、モジュールの中には関係性の高い要素を集め(高凝集)、モジュール同士の関係が小さくなるようにする(疎結合)必要がある。これによって、問題が局所化され全体を壊さずに対象のモジュールだけを継続的に改良していくことが可能になる。

逆に、ある変更が発生した際に修正が必要なコードが複数のモジュールに分散して存在したり(低凝集)、他のモジュールを呼び出す際にそのモジュールについて多くのことを知っている必要があったり(密結合)、情報を引き出すために呼び出し数が多くなっていたり(密結合)するほど、問題が分散していて全体が壊れやすくなる。

Notion Image

適切に設計されたコードは関心事毎に分離されていて、一つのモジュールは一つの責務のみを担当する(単一責務の原則)。そして、外部のモジュールはそのモジュールの呼び出し方のみを知っていれば良く、そのモジュールが抱える多くの詳細は隠蔽される(情報隠蔽)。

このようなモジュール化の最も基本的な例は、関数への切り出しである。例えば、次に示すコード(リスト内の数値を偶数に絞って平均値を計算する)は、どのような処理をしているのかがわかりづらく、コードをすべてを読まなければ、全体像を把握できない。

def main():
    numbers = [10, 15, 20, 25, 30, 35, 40]
    even_numbers = []
    for num in numbers:
        if num % 2 == 0:
            even_numbers.append(num)
    if len(even_numbers) > 0:
        total = sum(even_numbers)
        average = total / len(even_numbers)
        print(f"偶数の平均は {average} です。")
    else:
        print("偶数がリストに含まれていません。")


if __name__ == "__main__":
    main()

このコードをいくつかのまとまりに分割し、各まとまりを関数として切り出すと次のようになる。

def filter_even_numbers(numbers):
    even_numbers = []
    for num in numbers:
        if num % 2 == 0:
            even_numbers.append(num)
    return even_numbers


def calculate_average(numbers):
    if len(numbers) == 0:
        return None
    total = sum(numbers)
    return total / len(numbers)


def display_average(even_numbers):
    if even_numbers is None:
        print("偶数がリストに含まれていません。")
    else:
        print(f"偶数の平均は {even_numbers} です。")


def main():
    numbers = [10, 15, 20, 25, 30, 35, 40]
    even_numbers = filter_even_numbers(numbers)
    average = calculate_average(even_numbers)
    display_average(average)


if __name__ == "__main__":
    main()

関数への切り出しが適切に行われていれば、main関数を見ただけで処理内容の全体像が把握できる。さらに、フィルターを偶数から奇数に変更したい場合にも、どの関数を変更すれば良いかが簡単にわかる上に、その変更の範囲は関数内のみに限定される。

関数へ切り出す際には、処理のまとまりや関連性を意識して、一つの関数が一つの役割を担うようにすると良い。そして、その関数の名前とその関数が担っている役割とがしっかり馴染んでいるかを確認し、関数が名前以上の仕事をしていたり、名前と異なる仕事をしていたりする場合には、再度検討が必要である。

切り出した関数の数が多くなった場合には、関数をその関連性に応じてまとめてモジュール(.pyファイル)として切り出すことでさらにモジュール化が進み、コードの記述量が増えても可読性を維持できるようになる。

from filter_utils import filter_even_numbers
from math_utils import calculate_average
from display_utils import display_average


def main():
    numbers = [10, 15, 20, 25, 30, 35, 40]
    even_numbers = filter_even_numbers(numbers)
    average = calculate_average(even_numbers)
    display_average(average)


if __name__ == "__main__":
    main()

構造化プログラミング

構造化プログラミングはコード設計の基盤となる考え方である。構造化プログラミングは「構造化定理」と「分割統治法」に基づいていて、連接、分岐、反復の3つを組み合わせた「手続き」を階層構造に整理することでコードを設計していく。

構造化定理

全てのアルゴリズムは、連接、分岐、反復の3つの基本制御構造の組み合わせで記述できることを示す定理。コラド・ベームとジュゼッペ・ヤコピーニによって数学的に証明された。ベーム-ヤコピーニの定理とも呼ばれる。

Notion Image

なお、「分岐」や「反復」は諸刃の剣で、ネストが深くなるほどコードの理解は難しくなっていく。

Notion Image

ネストが深くなってしまったときは、制御構造をもっとシンプルに表現できないか検討し、どうしても必要な本質的な制御構造には適切な名前を付け、モジュールとして切り出すようにすると良い。

以下にネストを浅くするためのいくつかの方法を紹介する。

  • 複雑な条件を関数に切り出す
  • # Before
    numbers = [3, 12, 7, 20, 15, 8, 30, 5]
    result = []
    for number in numbers:
        if number % 2 == 0:
            if number >= 10:
                result.append(number)
                
    print(f"結果: {result}")
    
    
    # 結果: [12, 20, 30]

    # After
    def is_valid_number(num):
        return num % 2 == 0 and num >= 10
    
    numbers = [3, 12, 7, 20, 15, 8, 30, 5]
    result = []
    for number in numbers:
        if is_valid_number(number):
            result.append(number)
            
    print(f"結果: {result}")
    
    
    # 結果: [12, 20, 30]

  • 深いネストの部分を関数で切り出す
  • # Before
    students = [
        ("Alice", [80, 90, 85]),
        ("Bob", [70, 75, 80]),
        ("Charlie", [90, 95, 100])
    ]
    
    total_scores = []
    for student in students:
        total = 0
        for score in student[1]:
            total += score
        total_scores.append((student[0], total))
        
    print(f"合計: {total_scores}")
    
    
    # 合計: [('Alice', 255), ('Bob', 225), ('Charlie', 285)]

    # After
    def calculate_total(score_list):
        total = 0
        for score in score_list:
            total += score
        return total
    
    students = [
        ("Alice", [80, 90, 85]),
        ("Bob", [70, 75, 80]),
        ("Charlie", [90, 95, 100])
    ]
    
    total_scores = []
    for student in students:
        total = calculate_total(student[1])
        total_scores.append((student[0], total))
    
    print(f"合計: {total_scores}")
    
    
    # 合計: [('Alice', 255), ('Bob', 225), ('Charlie', 285)]

  • 辞書オブジェクトで分岐を代替する
  • # Before
    annual_income = 0
    for month in range(1, 13):
        if month == 1:
            last_day = 31
        elif month == 2:
            last_day = 28
        elif month == 3:
            last_day = 31
        elif month == 4:
            last_day = 30
        elif month == 5:
            last_day = 31
        elif month == 6:
            last_day = 30
        elif month == 7:
            last_day = 31
        elif month == 8:
            last_day = 31
        elif month == 9:
            last_day = 30
        elif month == 10:
            last_day = 31
        elif month == 11:
            last_day = 30
        elif month == 12:
            last_day = 31
        salary = last_day * 9000
        annual_income += salary
    
    print(f"年間収入: {annual_income}")
    
    
    # 年間収入: 3285000

    # After
    last_days = {
        1: 31,
        2: 28,
        3: 31,
        4: 30,
        5: 31,
        6: 30,
        7: 31,
        8: 31,
        9: 30,
        10: 31,
        11: 30,
        12: 31,
    }
    
    annual_income = 0
    for month in range(1, 13):
        salary = last_days[month] * 9000
        annual_income += salary
    
    print(f"年間収入: {annual_income}")
    
    
    # 年間収入: 3285000

  • 早期リターンを使う
  • # Before
    def process_data(data):
        if data:  # データが空でないかチェック
            if isinstance(data, list):  # データがリスト型かチェック
                if len(data) > 0:  # リストに要素があるかチェック
                    # すべての条件を満たす場合の処理
                    total = sum(data)
                    print(f"合計は{total}です。")
                else:
                    print("リストが空です。")
            else:
                print("データはリストではありません。")
        else:
            print("データが存在しません。")
    
    process_data([2, 4, 6])
    
    
    # 合計は12です。

    # After
    def process_data(data):
        if not data:  # データが存在しない場合
            print("データが存在しません。")
            return
    
        if not isinstance(data, list):  # データがリストでない場合
            print("データはリストではありません。")
            return
    
        if len(data) == 0:  # リストが空の場合
            print("リストが空です。")
            return
    
        # すべての条件を満たす場合の処理
        total = sum(data)
        print(f"合計は{total}です。")
    
    process_data([2, 4, 6])
    
    
    # 合計は12です。
    

    分割統治法

    大きくて複雑な問題を、簡単に解けるサイズにまで分割し、分割された問題を個別に解くことによって効率的に解決策を得ようとする手法。

    Notion Image

    オブジェクト指向プログラミング

    コード設計の考え方の一つにオブジェクト指向プログラミングがある。オブジェクト指向プログラミングの代表的な概念には、「継承」、「ポリモーフィズム」、「カプセル化」、「集約」がある。

    継承

    継承とは、子クラスが親クラスの機能を受け継いで、親クラスと同じ機能を獲得すること。

    オブジェクト指向における継承の本質は、置換可能な部品を作るための型をインターフェースとして規格化できる点にある。親クラスでインターフェースを規定して、子クラスでそのインターフェースを継承することで兄弟クラスと置換可能な部品を作ることができる。

    下の例では、同じ親を持つDogクラスとCatクラスは同じインターフェースを持っていて、それゆえに互いに置換可能である。

    例:抽象クラスAnimalとそれを継承した具象クラスDogとCat

    class Animal:
        def walk(self):
            pass
    
        def run(self):
            pass
    
    class Dog(Animal):
        def walk(self):
            print('Dog is walking.')
    
        def run(self):
            print('Dog is running.')
    
    class Cat(Animal):
        def walk(self):
            print('Cat is walking.')
    
        def run(self):
            print('Cat is running.')
    
    
    animal = Dog()
    animal.walk()
    animal.run()
    
    animal = Cat()
    animal.walk()
    animal.run()
    
    
    # Dog is walking.
    # Dog is running.
    # Cat is walking.
    # Cat is running.

    集約(委譲)

    前述のように、継承の本質は、置換可能な部品を作るための型をインターフェースとして規格化できる点にある。ただし、継承を使うと子クラスが親クラスの機能を受け継ぐこともできてしまう。継承を用いる目的が、インターフェースの規格化ではなく、他のクラスの機能を利用することなら、継承の代わりに次に述べる集約を用いることが推奨される。

    集約では、他のクラスを呼び出して、部品として利用する。集約は継承よりつながりがゆるく、簡単に他の部品と切り替えられる。一方で継承は、子クラスが親クラスだけでなくすべての祖先クラスと結合していて、ある祖先クラスを変更すると容易に子クラスが壊れてしまう。そのため、多くの場合は集約を用いて他クラスに処理を委譲するほうがよい。

    下の例では、CarクラスはEngineクラスを属性として持っており、Engineクラスの機能を自由に利用できる。

    例:Engineクラスとそれを集約したCarクラス

    class Car:
        def __init__(self):
            self.engine = Engine()
    
        def drive(self):
            self.engine.start()
            self.engine.stop()
    
    class Engine:
        def start(self):
            print ("Engine is starting.")
    
        def stop(self):
            print ("Engine is stopping.")
    
    car = Car()
    car.drive()
    
    
    # Engine is starting.
    # Engine is stopping.

    ポリモーフィズム

    詳細な処理の実行はクラスにまかせて、抽象(インターフェース)に対してプログラムを記述すること。実装の詳細については呼び出し先のクラスが責任を持ち、呼び出し元は呼び出し方(インターフェース)のみを知っていればよいため、呼び出し先のクラスの切り替えが容易になる。

    例えば実世界でいうと、ユーザーはコンセントに差すことによって電力を利用できることを知っていれば良く、その電力が火力発電で得られたものでも、風力発電で得られたものでも関係ない。

    下の例では、exercise関数はDogやCatなどの具象クラスには依存せず、抽象クラスで定義しているインタフェースのみに依存している。そのため、仮にDogクラス内での処理内容を変更しても、exercise関数は変更する必要がない。

    例:抽象に対してプログラムを記述するexercise関数

    class Animal:
        def walk(self):
            pass
    
        def run(self):
            pass
    
    class Dog(Animal):
        def walk(self):
            print('Dog is walking.')
    
        def run(self):
            print('Dog is running.')
    
    class Cat(Animal):
        def walk(self):
            print('Cat is walking.')
    
        def run(self):
            print('Cat is running.')
    
    def exercise(animal):
        animal.walk() # dog.walk()でないため抽象に対してプログラムを記述できている
        animal.run() # dog.run()でないため抽象に対してプログラムを記述できている
    
    exercise(Dog())
    exercise(Cat())
    
    
    # Dog is walking.
    # Dog is running.
    # Cat is walking.
    # cat is running.

    カプセル化

    パルナスの規則に基づいて、インターフェースと実装を切り離し、実装部分を隠蔽してインターフェースのみを公開すること。適切にカプセル化することで、内部の情報や手続きを意識する必要がなくなるため、コードの利用や拡張が容易になる。

    下の例では、外部から属性__balanceを直接書き換えたりすることはできず、depositメソッド、withdrawメソッド、get_balanceメソッドを通してのみ操作できる。ユーザーは入出金処理の実装の詳細について知っている必要はなく、各メソッドの呼び出し方法のみを知っていれば良い。

    例:BankAccountクラスのカプセル化

    class BankAccount:
        def __init__(self):
            self.__balance = 0
    
        def deposit(self, amount):
            if amount > 0:
                self.__balance += amount
                print(f"入金:{amount}")
    
        def withdraw(self, amount):
            withdrawal_fee = 250
            if 0 < amount <= self.__balance:
                self.__balance -= amount
                self.__balance -= withdrawal_fee
                print(f"出金:{amount}")
                print(f"手数料:{withdrawal_fee}")
    
        def get_balance(self):
            print(f"残高:{self.__balance}")
    
    
    # インスタンスの作成
    account = BankAccount()
    account.deposit(12000)
    account.withdraw(4000)
    account.withdraw(2000)
    account.get_balance()
    
    
    # 入金:12000
    # 出金:4000
    # 手数料:250
    # 出金:2000
    # 手数料:250
    # 残高:5500

    デザインパターン

    ソフトウェア設計の中でしばしば現れる設計パターンを、再利用しやすいように名前をつけて、カタログ化したものをデザインパターンと呼ぶ。デザインパターンについて理解を深めてもらうため、有名なGoFのデザインパターンの中の一つであるStrategyパターンについて紹介する。

    Strategyパターンでは戦略(アルゴリズム)の部分をクラスとして抽出し、戦略の切り替えが容易にできるようにしたデザインパターンである。

    実装例として「じゃんけんプログラム」を紹介する。

    「じゃんけんプログラム」の機能要件

    ・EmiとKenがじゃんけんを実施

    ・Emiはグーしか出さない

    ・Kenはランダムに出す

    ・勝敗を画面上に出力する

    「じゃんけんプログラム」のクラス図

    Notion Image

    「じゃんけんプログラム」の処理イメージ

    ① PlayerクラスからEmiとKenを作成

    Notion Image

    ② GameFieldクラスからEmiとKenを配置したGameFieldを作成

    Notion Image

    ③ GameFieldクラスのfightメソッドでじゃんけんを実施

    Notion Image

    「じゃんけんプログラム」のコード(概略)

    # Playerクラスを定義
    
    import abc
    
    
    # Playerクラスを定義
    class Player(object):
        ...(実装部分は省略)
    
    # Strategyクラスを定義
    class Strategy(metaclass=abc.ABCMeta):
        ...(実装部分は省略)
    
    # ConstantStrategyクラスを定義
    class ConstantStrategy(Strategy):
        ...(実装部分は省略)
    
    # RandomStrategyクラスを定義
    class RandomStrategy(Strategy):
        ...(実装部分は省略)
    
    # GameFieldクラス定義
    class GameField(object):
        ...(実装部分は省略)
    
    
    if __name__ == "__main__":
    
        # プレイヤーEmiを作成
        emi = Player(name='Emi', strategy=ConstantStrategy('Goo'))
    
        # プレイヤーKenを作成
        ken = Player(name='Ken', strategy=RandomStrategy())
    
        # EmiとKenを配置したGameFieldを作成
        game = GameField(players=[emi, ken])
    
        # じゃんけんを実施
        game.fight()

    この例では「じゃんけんプログラム」の戦略としてConstantStrategyクラスとRandomStrategyクラスしか実装していないが、新たにクラスを作ることで戦略の追加、変更が容易にできる。

    以下に、「じゃんけんプログラム」のコードの実装部分の詳細も記載しておくので参照されたい。

    「じゃんけんプログラム」のコード(詳細)

    import abc
    import random
    
    
    # Playerクラスを定義
    class Player(object):
        def __init__(self, name, strategy):
            self.name = name
            self.strategy = strategy
    
        def next_hand(self):
            return self.strategy.next_hand()
    
    
    # Strategyクラスを定義
    class Strategy(metaclass=abc.ABCMeta):
        @abc.abstractmethod
        def next_hand(self):
            pass
    
    
    # ConstantStrategyクラスを定義
    class ConstantStrategy(Strategy):
        def __init__(self, hand):
            self._hand = hand
    
        def next_hand(self):
            return self._hand
    
    
    # RandomStrategyクラスを定義
    class RandomStrategy(Strategy):
        def next_hand(self):
            self._hand = random.choice(['Goo', 'Choki', 'Par'])
            return self._hand
    
    
    # GameFieldクラスを定義
    class GameField(object):
        def __init__(self, players):
            self._players = players
    
        def fight(self):
            # フィールドを定義
            self.field = {}
            for player in self._players:
                self.field[player.name] = player.next_hand()
    
            # 勝敗判定メソッド呼び出し
            self.__judge(self.field)
    
        def __judge(self, field):
            """勝敗判定メソッド"""
            # フィールド上の手を格納
            hands = set(field.values())
            strong_hand = self.__get_strong_hand(hands)
    
            # あいこの場合
            if strong_hand is None:
                for player, hand in field.items():
                    print(f'{player}: {hand}    <Draw>')
    
            # 勝敗判定
            else:
                judgements = [
                    'Win' if hand == strong_hand else 'Lose'
                    for hand in field.values()
                ]
                for (player, hand), judgement in zip(field.items(), judgements):
                    print(f'{player}: {hand}    <{judgement}>')
    
        def __get_strong_hand(self, hands):
            """強い手判定メソッド"""
            # フィールド上の手がグーとチョキ → グーの勝ち
            if hands == set(['Goo', 'Choki']):
                return 'Goo'
            # フィールド上の手がチョキとパー → チョキの勝ち
            elif hands == set(['Choki', 'Par']):
                return 'Choki'
            # フィールド上の手がグーとパー → パーの勝ち
            elif hands == set(['Goo', 'Par']):
                return 'Par'
            # 上記以外 → あいこ
            else:
                return None
    
    
    if __name__ == "__main__":
    
        # プレイヤーEmiを作成
        emi = Player(name='Emi', strategy=ConstantStrategy('Goo'))
    
        # プレイヤーKenを作成
        ken = Player(name='Ken', strategy=RandomStrategy())
    
        # EmiとKenを配置したGameFieldを作成
        game = GameField(players=[emi, ken])
    
        # じゃんけんを実施
        game.fight()
    
    
    # Emi: Goo    <Win>;
    # Ken: Choki    <Lose>;

    関数型プログラミング

    前述の通り、オブジェクト指向プログラミングでは、オブジェクト内にデータ(属性)と振る舞い(メソッド)をまとめて記述する。そのため、オブジェクトは属性という形で状態を保持していて、状態によって振る舞いを変化させることができる。これは一種の擬人化といっても良く、人による理解が容易になる。

    その一方で、状態によって返ってくる値が変化するためテストの記述が複雑になり、処理の正確性の保証が難しくなったり、ケースごとの分岐が増え、それがビジネスロジックに絡まって読み手の注意がビジネスの関心事からそれてしまったりといった問題が生じる。

    Notion Image

    関数型プログラミングの考え方を取り入れることでコードが管理しやすくなる場合がある。関数型プログラミングの代表的な概念には、「純粋関数」、「ファーストクラスオブジェクトとしての関数」がある。

    純粋関数

    純粋関数とは、同じ入力に対して常に同じ出力を返す関数のことである。数学で用いられる「関数」と同義で、命令形プログラミングで用いられる「関数」と区別して「純粋関数」と呼ぶ。

    例えば、以下の関数は純粋関数である。

    def inc(x):
        return x + 1
    print(inc(1))
    print(inc(1))
    print(inc(1))
    
    
    # 2
    # 2
    # 2

    引数の値が同じなのに、実行されるたびに結果が変わる場合には、コンピュータの状態を変化させる副作用が存在していることになる。副作用はI/OやAPIの呼び出しなどコンピュータ処理に欠かせないものではあるが、副作用をビジネスロジックと絡ませてしまうとコードの管理が難しくなってしまう。

    純粋関数でない関数には、以下のようなものがある。

    def add(a, x=[]):
        x.append(a) # appendメソッドで値を書き換えている(副作用)
        return x
    print(add(1))
    print(add(1))
    print(add(1))
    
    
    # [1]
    # [1, 1]
    # [1, 1, 1]
    def add(y):
        return x + y # 引数以外に依存している
    x = 0
    print(add(1))
    x = 1
    print(add(1))
    x = 2
    print(add(1))
    
    
    # 1
    # 2
    # 3
    import random
    
    def get_random():
        return random.random() # 副作用を持つメソッドを呼び出している
    
    print(get_random())
    print(get_random())
    print(get_random())
    
    
    # 0.7926564511312606
    # 0.2541096699442461
    # 0.15854627659651466
    def write_and_read(file, text):
        with open(file, mode='a') as f:
            f.write(text)
    
        with open(file, mode='r') as f:
            return f.read() # I/Oを含む処理
            
    
    print(write_and_read("sample.txt", "1"))
    print(write_and_read("sample.txt", "1"))
    print(write_and_read("sample.txt", "1"))
    
    
    # 1
    # 11
    # 111

    ファーストクラスオブジェクトとしての関数

    「関数がファーストクラスオブジェクトである」とは、関数を値のように扱えることを意味していて、具体的には次のような操作が可能である。

  • 関数を変数にバインドする
  • inc = (lambda x: x + 1)
    print(inc(1))
    
    
    # 2
  • 関数をデータ構造に埋め込む
  • math = {
        'add': (lambda n, m: n + m) 
    }
    
    print(math['add'](1, 2))
    
    
    # 3
  • 引数として関数を渡す
  • numbers = [1, 2, 3, 4, 5]
    results = list(map((lambda x: x * 2), numbers))
    print(results)
    
    
    # [2, 4, 6, 8, 10]
  • 返り値として関数を返す
  • def adder(n):
        return (lambda x: x + n)
    add_10 = adder(10)
    print(add_10(1))
    
    
    # 11

    関数がファーストクラスオブジェクトとして扱えることで、クラスを使わずとも関数の組み合わせのみで複雑な処理の実装が可能になる。クラスを使わずにデータ(属性)と振る舞い(メソッド)を分離して実装することで、ビジネスロジックを状態から分離でき、読み手が関心事に集中しやすくなる。

    再び「じゃんけんプログラム」のコード

    以下に、関数型プログラミングの考え方を取り入れて「じゃんけんプログラム」を実装した場合の例を記載する。

    「じゃんけんプログラム」のコード(関数型プログラミング)

    import random
    from typing import Optional, Literal, TypedDict, Callable
    from collections.abc import Iterator
    
    
    # 型の定義
    Hand = Literal['Goo', 'Choki', 'Par']
    Judge = Literal['Win', 'Drow', 'Lose']
    
    class Player(TypedDict):
        name: str
        hand_generator: Iterator[Hand]
    
    class PlayerSnapshot(TypedDict):
        name: str
        hand: Hand
    
    class Result(TypedDict):
        name: str
        hand: Hand
        judge: Judge
    
    
    # 強い手を返す関数の定義
    def get_strong_hand(hands: list[Hand]) -> Optional[Hand]:
        unique_hands = set(hands)
        if unique_hands == set(['Goo', 'Choki']):
            return 'Goo'
        elif unique_hands == set(['Choki', 'Par']):
            return 'Choki'
        elif unique_hands == set(['Goo', 'Par']):
            return 'Par'
        else:
            return None
        
        
    # 勝敗を判定する関数の定義
    def judge_win_lose(hand: Hand, strong_hand: Optional[Hand]) -> Judge:
        if strong_hand is None:
            return 'Drow'
        elif hand==strong_hand:
            return 'Win'
        else:
            return 'Lose'
            
            
    # 次の手を生成する関数の定義
    def constant_hand_generator(hand: Hand) -> Iterator[Hand]:
        while True:
            yield hand
        
    
    # 次の手を「グー→チョキ→パー」の順に生成する関数
    def turn_hand_generator() -> Iterator[Hand]:
        count = 0
        turn_hands: list[Hand] = ['Goo', 'Choki', 'Par']
        while True:
            yield turn_hands[count%3]
            count += 1
    
    
    # 次の手をランダムに生成する関数の定義
    def randam_hand_generator() -> Iterator[Hand]:
        while True:
            yield random.choice(['Goo', 'Choki', 'Par'])
    
    
    # 次の手を生成する関数を実行する関数の定義
    def get_player_snapshot(player: Player) -> PlayerSnapshot:
        player_snapshot: PlayerSnapshot = {
            'name':player['name'],
            'hand':next(player['hand_generator'])
        }
        return player_snapshot
        
        
    # じゃんけんを実施する関数
    def fight(players: list[Player]) -> list[Result]:
        players_snapshot = [get_player_snapshot(player) for player in players]
        hands = [player_snapshot['hand'] for player_snapshot in players_snapshot]
        strong_hand = get_strong_hand(hands)
        results: list[Result] = [{
            'name':player_snapshot['name'],
            'hand':player_snapshot['hand'],
            'judge':judge_win_lose(player_snapshot['hand'], strong_hand)
        } for player_snapshot in players_snapshot]
        return results
        
        
    if __name__ == "__main__":
        player1: Player = {'name':'Emi', 'hand_generator':constant_hand_generator('Goo')}
        player2: Player = {'name':'Ken', 'hand_generator':randam_hand_generator()}
        players = [player1, player2]
        results = fight(players)
        for result in results:
            print(f"{result['name']}: {result['hand']}    <{result['judge']}>")
            
            
    # Emi: Goo    <Lose>
    # Ken: Par    <Win>

    安全性の高いコードを書く

    安全性が高く不具合を起こしにくいコードを書くためのいくつかのテクニックや考え方が必要になる。安全性が高いコードを書くためのテクニックや考え方については以下を参照されたい。

    テスト駆動開発

    コードをいきなり実装するのではなく、まずその振る舞いをチェックするコード(テストコード)を先に記述する開発手法をテスト駆動開発という。テスト駆動開発には、テストコードが生きたドキュメント(仕様書)になる、テストコードがあることで安心してリファクタリングできる、先にテストコード(仕様書)を書くことでコードの設計を改善できるといったメリットがある。

    とりわけ、関数型プログラミングの考え方を取り入れて、ビジネスロジックを純粋関数の中に閉じ込めて、状態から分離しておくと、テストコードがシンプルに記述できるようになる。

    テスト駆動開発の詳細については以下を参照されたい。

    ソフトウェアは完成しない

    作家や画家と同様、プログラマはコードを書きながら作品(プロダクトとその仕様)についてより深く理解してゆくものだし、ソフトウェアを取り巻く環境、ユーザーのニーズ、求められるセキュリティレベルなどは日々変化していくので、ソフトウェアが完成することはない。ソフトウェアの振る舞いを変更しない場合でも、コードがうまくなじんでいないと感じたときには、適宜リファクタリングを実施してコードを綺麗に保っておく必要がある。

    以下に該当する場合はリファクタリングを検討した方が良いかもしれない。

  • DRY原則に反している
  • 要求が変わる
  • システムが実際に使用され、より深く理解される
  • パフォーマンスを向上させたい
  • リファクタリングの詳細については以下を参照されたい。

    アーキテクチャ設計

    ソフトウェアアーキテクチャには明確な定義はなく、専門家によって定義が異なることも少なくない。そのため、どこまでがアーキテクチャ設計に含まれるのか混乱を招くことも多い。ソフトウェアアーキテクチャの詳細については、本コースの範囲を超えるため省略するが、興味がある方は以下を参照されたい。

    達人プログラマーへの道

    プログラマーの仕事は、「クライアントが何を欲しているのかをクライアント自身に気付いてもらえるようサポートすること」である。

    クライアントが最初に語るニーズは「真の要求」ではない事が多い。クライアントの語る要求から引き起こされる結果をクライアント自身にフィードバックすることで、クライアントが自ら思考を洗練し、「真の要求」を見つけ出すことを支援できる。

    さらに踏み込んで、クライアントと一緒に働き、クライアントになりきるという方法もある。クライアントの横で一週間ほど仕事を見せてもらうことで、クライアントの信頼を得ることにもつながるかもしれない。

    参考資料


    著者画像

    ゆうき

    2018/04からITエンジニアとして活動、2021/11から独立。主な使用言語はPython, TypeScript, SAS, etc.