プログラミングを学び、初級コースをクリア。徐々に複雑で長いコードが書けるようになる。
その辺りで、必ず登場するのが、モジュール分割の話です。
いままでは、どんどんコード継ぎ足しで書いていた。でも延々と続くなって、さすがに読みにくい。
なにかしら、コードを分けて管理しないと。でもどうやって?どんな考えで分割すればいいの?
簡単な答えはありません。状況によっても変わります。
でも、押さえておくべき基本的な考えはあるのです。
モジュール分割ってなに?
モジュール分割といって、世間でよく見る説明は、こんな感じ。
なんか、ふわっとした説明。もう少し、具体的にイメージできるようにしましょう。
そもそも「モジュール」って?
そもそも思う疑問がこれ。
モジュールってなんなのさ?
独立した部品って言われても、機械とかじゃないんだから。
なにかしら、まとまった単位のことではあるんでしょう。プログラミングで出てくるものだと、こんなもの?
- 関数やメソッド
- クラス
- ソースファイル
- ライブラリ
実は、言ってしまえば、それぞれが全部モジュール、だとは言えます。
モジュール=ソースファイル、でイメージしよう
全部がモジュール、だと身も蓋もありません。具体的に定義しましょう。
だいたい、よく見かけるのはこのイメージ。
「モジュール分割」=「ソースファイル分割」
関数やメソッドなら関数分割。クラスならクラス分割。複数ソースファイルになるとパッケージ分割。という言い方のほうがしっくりきますね。
以降では、モジュール分割=ソースファイル分割と考えて、説明していきます。
サンプルコードで考えよう
つらつら文章を並べてもイメージわきません。サンプルを見て考えましょう。
例題:成績管理プログラム
簡単な成績管理プログラムで考えましょう。
- 生徒の成績を管理するプログラム
- 操作はコマンドで行う。生徒の登録や平均点の算出ができる
- 生徒データは終了時に保存しておき、次回起動時に自動的に読み込む
Pythonで作ってみました。
import json
def main():
# 生徒データ
students = []
# 生徒データファイル
filename = "database.json"
# 生徒データファイルを読み込む
try:
with open(filename, 'r', encoding='utf-8') as file:
students = json.load(file)
except FileNotFoundError:
print(f"ファイル {filename} がありません。空リストで起動します。")
except Exception as e:
print(f"ファイル読み込みに失敗しました。 {e}")
# メニュー処理
while True:
print("\nメニュー:")
print("1. 生徒を追加")
print("2. 生徒データを表示")
print("3. 平均点を表示")
print("4. 終了")
choice = input("選択してください: ")
# 生徒を追加
if choice == '1':
name = input("生徒名: ")
scores = input("生徒の点数 (コンマ区切り): ")
scores = [float(score) for score in scores.split(',')]
students.append({"name": name, "scores": scores})
# 生徒データを表示
elif choice == '2':
for student in students:
name = student["name"]
scores = student["scores"]
average = sum(scores) / len(scores)
print(f"生徒: {name}, 点数: {scores}, 平均点: {average:.2f}")
# 平均点を表示
elif choice == '3':
total_scores = []
for student in students:
total_scores.extend(student["scores"])
if total_scores:
average_score = sum(total_scores) / len(total_scores)
print(f"全生徒の平均点: {average_score:.2f}")
else:
print("データがありません。")
# 終了
elif choice == '4':
break
else:
print("選択が違います。やり直してください。")
# 生徒データファイルを保存する
try:
with open(filename, 'w', encoding='utf-8') as file:
json.dump(students, file, ensure_ascii=False, indent=4)
print(f"生徒データを {filename} に保存しました。")
except Exception as e:
print(f"ファイル書き込みに失敗しました。 {e}")
if __name__ == "__main__":
main()
動作イメージ
ファイル database.json がありません。空リストで起動します。
メニュー:
1. 生徒を追加
2. 生徒データを表示
3. 平均点を表示
4. 終了
選択してください: 1
生徒名: 山田
生徒の点数 (コンマ区切り): 52,38,64
メニュー:
1. 生徒を追加
2. 生徒データを表示
3. 平均点を表示
4. 終了
選択してください: 1
生徒名: 田中
生徒の点数 (コンマ区切り): 79,94,65
メニュー:
1. 生徒を追加
2. 生徒データを表示
3. 平均点を表示
4. 終了
選択してください: 2
生徒: 山田, 点数: [52.0, 38.0, 64.0], 平均点: 51.33
生徒: 田中, 点数: [79.0, 94.0, 65.0], 平均点: 79.33
メニュー:
1. 生徒を追加
2. 生徒データを表示
3. 平均点を表示
4. 終了
選択してください: 3
全生徒の平均点: 65.33
メニュー:
1. 生徒を追加
2. 生徒データを表示
3. 平均点を表示
4. 終了
選択してください: 4
生徒データを database.json に保存しました。
いまは、main関数1つ、ソースファイル1つの状態です。
モジュール分割してみよう
今のままでも、まあ読めます。けど、そろそろ読みやすさも限界な感じです。
このプログラムを、モジュール分割(ソースファイル分割)してみましょう。
といっても、main関数ひとつだとファイルに分けようがないですね。
まずは関数分割し、次にソースファイル分割していきます。
モジュール分割の大事な考え:「くりぬく」
モジュール分割にあたり最初に、最も重要な基本を確認しましょう。
これ、大事な概念です。
「切る」のではなく「くりぬく」
プログラムを分割しよう…としたときに、ここの部分で切り分けよう、とか考えがち。
ここの位置で、スパッと切り分けよう
これ、違うんです。
モジュール分割といったときのイメージは、これです。
ここからここの範囲を、くりぬこう
まず、「くりぬく」というイメージが大事です。
分割した後も動きを変えないこと
なぜ、切り分けるのだと、ダメなのでしょう。
モジュール分割するときに、守らなければいけないことが、1つあります。
同じ動きをする、というときに、プログラムはたいてい
- なにかしらを入力して
- なんやかんややって
- なにかしらを出力する
ということをします。
ここで、「同じ動き」というのは、すなわち「同じ入力をしたら同じ出力を返す」こと。
そうすると、コードの頭とお尻は、変えられないわけです。途中で切ったら、入力や出力が変わっちゃいますからね。
だからモジュール分割とは、前後に切るのでなく、中間をくりぬく、というイメージになるのです。
まずは、関数を分割してみよう
それでは、例題のコードで、まずは関数分割をしてみます。
ある部分をくりぬいて、別の関数にするわけですが…どこをくりぬけばいいの?
どこでもいいってわけじゃありません、基本的な考え方があります。
考え方1:入力、変換、出力に分けてくりぬく
モジュール分割しても「同じ入力をしたら同じ出力を返す」ことが大事、といいました。
で、プログラムはたいてい、
- なにかしらを入力して
- なんやかんややって
- なにかしらを出力する
というお話をしました。
…そう、くりぬく単位としてまず思いつくのは、「入力」「変換」「出力」、この3つ、です。
例題で考えると、
- 生徒データファイルを読み込んで
- いろいろなメニューを処理して
- 生徒データファイルを保存する
という感じ。
さっそく分割してみましょう。mainの処理を
- load_students(生徒データ読み込み)
- processing_menu(メニュー処理)
- save_students(生徒データ保存)
に分割します。
import json
def load_students(filename):
"""
生徒データ読み込み
"""
try:
with open(filename, 'r', encoding='utf-8') as file:
students = json.load(file)
return students
except FileNotFoundError:
print(f"ファイル {filename} がありません。空リストで起動します。")
return []
except Exception as e:
print(f"ファイル読み込みに失敗しました。 {e}")
return []
def save_students(filename, students):
"""
生徒データ保存
"""
try:
with open(filename, 'w', encoding='utf-8') as file:
json.dump(students, file, ensure_ascii=False, indent=4)
print(f"生徒データを {filename} に保存しました。")
except Exception as e:
print(f"ファイル書き込みに失敗しました。 {e}")
def processing_menu(students):
"""
メニュー処理
"""
while True:
print("\nメニュー:")
print("1. 生徒を追加")
print("2. 生徒データを表示")
print("3. 平均点を表示")
print("4. 終了")
choice = input("選択してください: ")
# 生徒を追加
if choice == '1':
name = input("生徒名: ")
scores = input("生徒の点数 (コンマ区切り): ")
scores = [float(score) for score in scores.split(',')]
students.append({"name": name, "scores": scores})
# 生徒データを表示
elif choice == '2':
for student in students:
name = student["name"]
scores = student["scores"]
average = sum(scores) / len(scores)
print(f"生徒: {name}, 点数: {scores}, 平均点: {average:.2f}")
# 平均点を表示
elif choice == '3':
total_scores = []
for student in students:
total_scores.extend(student["scores"])
if total_scores:
average_score = sum(total_scores) / len(total_scores)
print(f"全生徒の平均点: {average_score:.2f}")
else:
print("データがありません。")
# 終了
elif choice == '4':
break
else:
print("選択が違います。やり直してください。")
def main():
# 生徒データ
students = []
# 生徒データファイル
filename = "database.json"
# 生徒データファイルを読み込む
students = load_students(filename)
# メニュー処理
processing_menu(students)
# 生徒データファイルを保存する
save_students(filename, students)
if __name__ == "__main__":
main()
すこし見やすくなりましたね。
このように、ひとつの処理を
- 入力(Source)
- 変換(Transfer)
- 出力(Sink)
の3つに分ける手法のことを、STS分割といいます。
考え方2:操作単位でくりぬく
もう少し関数を分割してみましょう。
やりかたで、もうひとつ思いつくのは、
- 生徒データを表示
- 平均点を表示
といった、メニュー処理の部分。
それぞれのメニュー処理は、完結した一連の処理。なので、くりぬきやすいでしょう。
メニュー処理をそれぞれ、
- add_students(生徒追加)
- display_students(生徒データ表示)
- display_average_score(平均点表示)
に分割します。
import json
def load_students(filename):
"""
生徒データ読み込み
"""
try:
with open(filename, 'r', encoding='utf-8') as file:
students = json.load(file)
return students
except FileNotFoundError:
print(f"ファイル {filename} がありません。空リストで起動します。")
return []
except Exception as e:
print(f"ファイル読み込みに失敗しました。 {e}")
return []
def save_students(filename, students):
"""
生徒データ保存
"""
try:
with open(filename, 'w', encoding='utf-8') as file:
json.dump(students, file, ensure_ascii=False, indent=4)
print(f"生徒データを {filename} に保存しました。")
except Exception as e:
print(f"ファイル書き込みに失敗しました。 {e}")
def add_student(name, scores, students):
"""
生徒を追加
"""
scores = [float(score) for score in scores.split(',')]
students.append({"name": name, "scores": scores})
def display_students(students):
"""
生徒データ表示
"""
for student in students:
name = student["name"]
scores = student["scores"]
average = sum(scores) / len(scores)
print(f"生徒: {name}, 点数: {scores}, 平均点: {average:.2f}")
def display_average_scores(students):
"""
平均点表示
"""
total_scores = []
for student in students:
total_scores.extend(student["scores"])
if total_scores:
average_score = sum(total_scores) / len(total_scores)
print(f"全生徒の平均点: {average_score:.2f}")
else:
print("データがありません。")
def processing_menu(students):
"""
メニュー処理
"""
while True:
print("\nメニュー:")
print("1. 生徒を追加")
print("2. 生徒データを表示")
print("3. 平均点を表示")
print("4. 終了")
choice = input("選択してください: ")
# 生徒を追加
if choice == '1':
name = input("生徒名: ")
scores = input("生徒の点数 (コンマ区切り): ")
add_student(name, scores)
# 生徒データを表示
elif choice == '2':
display_students(students)
# 平均点を表示
elif choice == '3':
display_average_scores(students)
# 終了
elif choice == '4':
break
else:
print("選択が違います。やり直してください。")
def main():
# 生徒データ
students = []
# 生徒データファイル
filename = "database.json"
# 生徒データファイルを読み込む
students = load_students(filename)
# メニュー処理
processing_menu(students)
# 生徒データファイルを保存する
save_students(filename, students)
if __name__ == "__main__":
main()
このように、完結した処理単位で分割する手法を、トランザクション分割(TR分割)といいます。
次はソースファイルを分割してみよう
関数単位の分割はできました。次は、ソースファイルの分割です。
複数に分けた関数のそれぞれを、複数のソースに振り分ける。
正直、どこのソースにどの関数を入れても、プログラムとしては動きます。
でもやっぱり、適当にやっていいわけじゃない。これも基本的な考え方があります。
考え方:「意図」と「実装」に分ける
ソースファイル分割でよく使われる考えが、これです。
こういう解釈になります。
- 意図:(本当に)やりたいこと
- 実装:(どうしても)やらないといけないこと
まずは大きく、この2つに振り分けます。
やらないといけないこと?全部、やらないといけないことなのでは?
意図なのか実装なのかは、仕様の要約で確認する
「本当にやりたいこと」と「どうしてもやらないといけないこと」の違いって、なんでしょう?
それを見分けるいい方法は、「そのプログラムの仕様を見る」ことです。
仕様をあらためて確認しましょう。
- 生徒の成績を管理するプログラム
- 操作はコマンドで行う。生徒の登録や平均点の算出ができる
- 生徒データは終了時に保存しておき、次回起動時に自動的に読み込む
ここで、
- 仕様に必ず登場するのが、「やりたいこと」
- 仕様に必ずしも登場しないのが、「やらないといけないこと」
ということになります。
例題で考えましょう。
- 生徒データを保存する
- 平均点を算出する
これは、実際にそういうことをしたいので、「やりたいこと」。
一方、
- JSON形式で
- database.jsonへ保存する
これは仕様に直接登場しませんが、やる必要はあるので、「やらないといけないこと」。
仕様に直接登場しない「やらなければいけないこと」を、別のモジュール(=ソースファイル)に切り分けます。
例題でいうと、このあたりの関数ですね。
- load_students(生徒データ読み込み)
- save_students(生徒データ保存)
実際に分割してみよう
例題を、実際にモジュール分割してみましょう。
ソースファイルを次のように分けます。
- 「意図」を表現する、main.py
- 「実装」を表現する、student_data.py
student_data.pyに、load_studentsとsave_studentsを移動します。
import json
def load_students(filename):
"""
生徒データ読み込み
"""
try:
with open(filename, 'r', encoding='utf-8') as file:
students = json.load(file)
return students
except FileNotFoundError:
print(f"ファイル {filename} がありません。空リストで起動します。")
return []
except Exception as e:
print(f"ファイル読み込みに失敗しました。 {e}")
return []
def save_students(filename, students):
"""
生徒データ保存
"""
try:
with open(filename, 'w', encoding='utf-8') as file:
json.dump(students, file, ensure_ascii=False, indent=4)
print(f"生徒データを {filename} に保存しました。")
except Exception as e:
print(f"ファイル書き込みに失敗しました。 {e}")
main.pyには、save_studentsの関数を使うためのfromを追加します。
from student_data import load_students, save_students
def add_student(name, scores, students):
"""
生徒を追加
"""
scores = [float(score) for score in scores.split(',')]
students.append({"name": name, "scores": scores})
def display_students(students):
"""
生徒データ表示
"""
for student in students:
name = student["name"]
scores = student["scores"]
average = sum(scores) / len(scores)
print(f"生徒: {name}, 点数: {scores}, 平均点: {average:.2f}")
def display_average_scores(students):
"""
平均点表示
"""
total_scores = []
for student in students:
total_scores.extend(student["scores"])
if total_scores:
average_score = sum(total_scores) / len(total_scores)
print(f"全生徒の平均点: {average_score:.2f}")
else:
print("データがありません。")
def main():
# 生徒データ
students = []
# 生徒データファイル
filename = "database.json"
# 生徒データファイルを読み込む
students = load_students(filename)
# メニュー処理
while True:
print("\nメニュー:")
print("1. 生徒を追加")
print("2. 生徒データを表示")
print("3. 平均点を表示")
print("4. 終了")
choice = input("選択してください: ")
# 生徒を追加
if choice == '1':
name = input("生徒名: ")
scores = input("生徒の点数 (コンマ区切り): ")
add_student(name, scores)
# 生徒データを表示
elif choice == '2':
display_students(students)
# 平均点を表示
elif choice == '3':
display_average_scores(students)
# 終了
elif choice == '4':
break
else:
print("選択が違います。やり直してください。")
# 生徒データファイルを保存する
save_students(filename, students)
if __name__ == "__main__":
main()
モジュール分割が、無事できました!
モジュール分割のときに気をつけること
このような手順でモジュール分割していくわけですが、大事なポイントをもう少し説明します。
モジュールの依存関係を単方向にすること
モジュール分割すると、そのモジュール間には、必ず依存関係が生まれます。
依存関係とは、このような定義。
モジュールAにある関数が、モジュールBにある関数を呼び出しているとき、「AはBに依存している」
Bがないと、Aは動かないわけですからね。
AがBに依存、BがAに依存というような、お互いに依存している関係にしてはいけません。
モジュールというのはできるだけ、他に依存していないほうがよいわけです。依存先の影響が及ぶ可能性あるから。
両方向に依存すると、お互いの影響が他方に及びます。
影響範囲を少なくするため、必ず単方向の依存関係にしましょう。
そのモジュールが「知るべきでないこと」を決めること
例えばメインモジュールmain.pyは、生徒データが保存されることはしっていても、保存形式の詳細は知りません。
どんなファイル名か、どのような形式で保存されているかは「知るべきでないこと」です。
他方で、生徒データモジュールstudent_data.pyは、生徒データの保存や読み込みができればよい。
それがどのような操作で変更されたのかは「知るべきでないこと」です。
そのモジュールが「知るべきでない知識を決めておく」。これを意識しましょう。
なぜか?前にもあるように、モジュールはできるだけ他の影響を受けないほうが安定します。知っていることが多いとそれだけ他の影響を受けてしまいますからね。
「知るべきでないこと」を決めておけば、新しい関数を作ったときでも、どこのモジュールに入れるべきかが判断しやすくなります。
モジュール分割することの意義
モジュール分割によって、見通しのよいコードになりました。
モジュール分割の最大のメリットは「分かりやすくなる」です。が、さらにほかの意義もあるのです。
何かあったときの影響範囲が絞られる
プログラミングには、常に修正がつきまといます。バグの発生、仕様の変更。修正したからには、その影響範囲を確認しないといけません。
このとき、できるだけ他の影響を受けないようにモジュール分割しておきます。
すると、その修正が及ぶ範囲を限定的にできます。
そして、修正していないモジュールは、基本的には影響はないはずですね。(必ずしもそうとも限りませんが)
複数人で同時開発できる
ソースファイルを分割すれば、各々のソースファイルを、違う人が編集できます。
そう、モジュール分割は、複数人でプログラム開発するときには必須なのです。
モジュール分割には、大きな問題を複数の小さな問題に分割する、という意味があります。
それぞれの小さな問題をそれぞれ解決すれば、大きな問題が解決できる。このような考えを「分割統治」と言います。
まとめ
プログラミングをするうえで避けられない、モジュール分割。
どう分割するかに決まった正解はなく、どうしてもケースバイケースにはなります。
といっても、なんとなく分割した、では後で大変なことになります。
大事なことは、「どういう考え方で分割したのか」を、ちゃんと説明できること。
基本的な考え方を踏まえつつ、丁寧にモジュール分割をしていきましょう。
コメント