cover_effective_python

【 Effective Python, 2nd Edition 】組み込みタイプ ( built-in types ) を利用していてネストが深くなってきたらクラス ( class ) を作成する頃合いです、の巻 投稿一覧へ戻る

Tags: Python , dictionary , Effective , namedtuple , defaultdict , class , oop , refactoring

Published 2020年7月6日22:30 by T.Tsuyoshi

Python はやはり OOP (Object-Oriented Programming) 言語ですから、いかにうまくクラスを設計、利用するかで将来的な拡張性、メンテナンス性が大きく左右されます。


基本的なことではありますけど「最重要」と言い切っても過言ではないと思いますので、今回からはクラスやインターフェース関連の話題を中心に取り上げていきます。


Python における辞書型 ( dictionary type ) は、オブジェクトの属性の保存、更新はもちろん、ある意味新たな属性の追加等にも利用可能な非常に優れた型ですね。


さてさて、生徒全員の成績を保存しておくプログラムを組みました。
辞書を利用することで、前もって生徒の名前や人数が分からなくても全然困ることはありません。

class SimpleGradebook:
def __init__(self):
self._grades = {}

def add_student(self, name):
self._grades[name] = []

def report_grade(self, name, score):
self._grades[name].append(score)

def average_grade(self, name):
"""該当する生徒の平均点を返します"""
grades = self._grades[name]
return sum(grades) / len(grades)


book = SimpleGradebook()
book.add_student('Nana')
book.report_grade('Nana', 90)
book.report_grade('Nana', 95)
book.report_grade('Nana', 85)
book.report_grade('Nana', 70)

print(book.average_grade('Nana'))
# 85.0



辞書や関連する組み込み型はあまりに使い勝手が良過ぎて、油断しているとプログラムがすぐにゴチャゴチャになってきちゃいます。


例えば、SimpleGradebook クラスの機能を拡張して、教科ごとに成績を保存できるようにします。
そこで、辞書の「生徒名」キーの値として、{ "教科名" : [得点、得点、...] } という構造の辞書を割り当てることにしました。
「教科名」キーが存在していなかった場合の対処として、デフォルトで空のリストを割り当てる defaultdict メソッドを利用しています。


from collections import defaultdict


class BySubjectGradebook:
def __init__(self):
self._grades = {}

def add_student(self, name):
self._grades[name] = defaultdict(list)

def report_grade(self, name, subject, score):
by_subject = self._grades[name]
grade_list = by_subject[subject]
grade_list.append(score)

def average_grade(self, name):
"""
全ての教科をひとまとめにした平均点を求めます
"""
total, count = 0, 0
by_subject = self._grades[name]
for grade_list in by_subject.values():
total += sum(grade_list)
count += len(grade_list)
return total / count


book = BySubjectGradebook()
book.add_student('Nana')
book.report_grade('Nana', 'Math', 90)
book.report_grade('Nana', 'Math', 95)
book.report_grade('Nana', 'Programming', 85)
book.report_grade('Nana', 'Programming', 70)

print(book.average_grade('Nana'))
# 85.0



少しデータ構造が深くなった分、成績を追加する report_grade() と平均点を求める average_grade() がちょっと複雑になりましたが、まだ大丈夫ですね。


さて、ここで再びアップデートです。
同じ教科におけるテストでも、中間テスト、期末テスト、突然ビックリ抜き打ちテスト、それぞれにおける「重み」を変えて平均点(加重平均点)を求められるようにしましょう。
こうすることで、抜き打ちテストの結果よりも期末テストの結果をより重視する、といったような使い方ができるようになります。


これを実現するためにデータ構造に手を加えることにしました。
{ "教科名" : [得点、得点、...] } を { "教科名" : [(得点、重み), (得点、重み), ...]} というように、
(得点、重み) というタプルからなるリストを利用します。


class WeightedGradebook:

def __init__(self):
self._grades = {}

def add_student(self, name):
self._grades[name] = defaultdict(list)

def report_grade(self, name, subject, score, weight):
by_subject = self._grades[name]
by_subject[subject].append((score, weight))

def average_grade(self, name):
"""
まず一教科内での加重合計得点 (sub_total) を求め、一教科全体で重みが 1 となるように重み合計 (weight) で割ります。
これがこの教科におけるこの生徒の得点となるので、合計得点 (total) に追加し、教科の数 (count) も +1 します。
上のステップを教科数分繰り返し、最終的に total/count で全教科の平均点を求めます。
"""
total, count = 0, 0
by_subject = self._grades[name]
for grade_list in by_subject.values():
sub_total, sub_weight = 0, 0
for score, weight in grade_list:
sub_total += (score * weight)
sub_weight += weight
total += sub_total / sub_weight
count += 1
return total / count



新たなテスト結果を保存する report_grade() は一見ほとんど変わっていませんが、
辞書があって辞書があって、リストがあってタプルがある、という構造を処理しなければいけなくなった average_grade() はかなりワチャワチャです。
辞書が辞書を含む、という構造は読解性の面からもできる限り避けるべきです。


MIDTERM_TEST_WEIGHT = 0.3 # 中間テスト用重み
FINAL_TEST_WEIGHT = 0.5 # 期末テスト用重み
SURPRISE_TEST_WEIGHT = 0.15 # 抜き打ちテスト用重み

book = WeightedGradebook()
book.add_student('Nana')
book.report_grade('Nana', 'Math', 90, SURPRISE_TEST_WEIGHT)
book.report_grade('Nana', 'Math', 95, FINAL_TEST_WEIGHT)
book.report_grade('Nana', 'Programming', 85, MIDTERM_TEST_WEIGHT)
book.report_grade('Nana', 'Programming', 70, FINAL_TEST_WEIGHT)

print(book.average_grade('Nana'))
# 84.73557692307692



しかも、このクラスを利用する方でも、生徒の名前は繰り返し渡さにゃならん、教科名も繰り返し渡さにゃならん、引数が多くて順番間違えそう、と、かなり混乱の域に達しています。


このように、dictionary, tuple, set, list といった組み込みタイプのネストが深くなってきて処理が難しくなってきた、と感じたときは、全体の構造を見直して階層的なクラスでの構成を検討しましょう。


つまり リファクタリング ( refactoring ) するわけですが、今回はデータ構造の一番深いところからクラス構成へと移行させていってみましょう。


ということで、第一段階は、1回分のテスト結果をどう持つか、です。
保持すべきデータは「得点」と「重み」という immutable な値だけですから、クラスを作成するのは大袈裟ですね。
これはタプルで良さそうです ・・・ (score, weight)。


でもちょっと待ってください。
もし将来的に、ちょっとしたコメントを付け加える (score, weight, comment)、等の要素の追加がある場合はどうなるでしょうか?
データを取り出すためにアンパック構文を利用している全ての箇所を書き換えなければならなくなってしまいます、これはうまくありません。


クラスを作成するよりも気軽に、かつ、このような用途に最適なものが collections 組み込みモジュールに含まれている namedtuple 型です。


from collections import namedtuple


# 1回分のテストデータ
Grade = namedtuple('Grade', ('score', 'weight'))



ご存知のように namedtuple の各要素にはキーワードを利用してアクセスできますから、将来的にクラスに変更することも簡単です。
また、Python 3.7 からは namedtuple の引数にデフォルト値を設定することが可能 になり、ますます用途が広がりました。


続いて、1教科分のテストデータを象徴するクラスです。複数のテスト結果 ( Grade 名前付きタプル ) を扱います。


class Subject:
"""1教科分のデータ"""
def __init__(self):
self._grades = []

def report_grade(self, score, weight):
"""テストデータの追加"""
self._grades.append(Grade(score, weight))

def average_grade(self):
"""この教科における加重平均点を返す"""
total, weight = 0, 0
for grade in self._grades:
total += grade.score * grade.weight
weight += grade.weight
return total / weight



続いて、1人の生徒を象徴するクラスです。複数の教科のテストデータ (Subject クラス ) を扱います。


class Student:
"""1生徒分のデータ"""
def __init__(self):
self._subjects = defaultdict(Subject)

def get_subject(self, name):
return self._subjects[name]

def average_grade(self):
"""この生徒の全教科の平均点を返す"""
total, count = 0, 0
for subject in self._subjects.values():
total += subject.average_grade()
count += 1
return total / count



そして最後に、生徒全員の成績表を象徴するクラスになります。複数の生徒 (Student クラス ) から構成されます。


class Gradebook:
"""全生徒のデータ"""
def __init__(self):
self._students = defaultdict(Student)

def get_student(self, name):
return self._students[name]



複数のクラスに分割して構成したために、プログラム全体の行数はほぼ2倍に膨れ上がりました。


でも、いかがですか?
各エンティティが明確な階層構造になっているので非常に分かりやすいのではないでしょうか?
また、将来的な機能追加についても、どの部分に手を入れればよいのか、一目瞭然ではないですか?


そして、これらのクラスを利用する段階でも...


book = Gradebook()
nana = book.get_student('Nana')
math = nana.get_subject('Math')
math.report_grade(90, SURPRISE_TEST_WEIGHT)
math.report_grade(95, FINAL_TEST_WEIGHT)
programming = nana.get_subject('Programming')
programming.report_grade(85, MIDTERM_TEST_WEIGHT)
programming.report_grade(70, FINAL_TEST_WEIGHT)

print(nana.average_grade())
# 84.73557692307692



いかがでしょうか?
現在実行していることが明白になっていませんか?


まとめ:

1: 辞書の値が辞書、要素数の多いタプル、複雑に入り組んだ複数の組み込みタイプ、といった構造は避けましょう。

2: ちょっとした immutable なデータコンテナが必要な場合、namedtuple でクラスの代わりができないか検討してみましょう。

3: 内部属性を保持する dictionary の構造が複雑になるようであれば、複数のクラスを階層的に利用する方法を検討しましょう。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)