6.2 ユーザー定義クラス

(1/1)
カジノのディーラーのイラスト(女性)
現実世界や架空世界をコンピュータの中に忠実に再現するために、Python ではユーザーがクラスを定義することができる。ユーザー定義クラスを大別すると、面積や体積といった公式をクラス化する場合と、オブジェクトをつくるための設計書としてのクラスの2つに分かれる。

そこで今回は、前半では面積を求める公式のクラス化するプログラムを、後半ではカード(トランプ)のシャフルと配布をクラス化するプログラムを紹介しながら、ユーザー定義クラスについて学ぶ。

目次

サンプル・プログラム

圧縮ファイルの内容
calcArea.pyサンプル・プログラム:円の面積を求める。
pahooCalcArea.pyクラス・ファイル:面積を求める公式集。
cardGame1.pyサンプル・プログラム:カードをシャフルし、配る。
pahooCardGame.pyクラス・ファイル:カードゲーム基本クラス。
pahooValidate.pyバリデーション・モジュール。

どのようなときにクラスを書くか

数学者のイラスト
Python は、ユーザーがクラスを定義することができる。どのような時にクラスを書くかというと、大きく2つのケースに分けられる。

1つは、たとえば面積や体積を求めたり、運動方程式といった公式を関数にして、それをライブラリにまとめるケース――標準ライブラリの mathモジュールはクラスではないが、同じような目的のクラスライブラリを自前で用意する場合だ。
映画「マトリックス」
プログラムは、現実世界や架空世界をコンピュータの中に忠実に再現する。ビッグデータを使ったシミュレーションはもちろんのこと、Excelにつけている家計簿も、現実のお金の流れをコンピュータの中で再現している。ファンタジー世界のクリーチャーをCGで動かすときも、物理法則の範囲内で動かすことが多い。
現実をコンピュータの中で再現しようというと、映画「マトリックス」を思い出すが、オブジェクト指向は、マトリックスのように現実と寸分違わないコンピュータ世界を作ることにある。前回の「コラム:Smalltalkのオブジェクト指向」で紹介したオブジェクト指向プログラミング言語の元祖Smalltalkがシミュレーション用プログラミング言語Simulaの影響を受けているのも、そうした背景があるからだ。
そして、オブジェクトを作る設計書が、もう1つのクラスの使い方――そして、こちらがクラスの本領である。

公式をクラス化する

5.2 ユーザー定義関数」で三角形の面積を求める関数 calcAreaTriangle を作ったが、これをクラス化してみよう。
import math

class pahooCalcArea:
	"""面積を求める公式
	
	Copyright:
		(c)studio pahoo, パパぱふぅ
	
	Environment:
		Python 3.7 or after
	
	Note:
		Python入門 - 6.2 ユーザー定義クラス
		https://www.pahoo.org/e-soul/webtech/python00/py00-06-02.html
	"""
	@classmethod
	def calcAreaTriangle(self, base, height):
		"""三角形の面積を求める.
		
		Args:
			base(float):	底辺
			height(float):	高さ
		
		Returns:
			float: 三角形の面積
		"""
		return base * height / 2
class pahooCalcArea: で始まるブロックがユーザー定義クラスである。
そのあとに def calcAreaTriangle として、三角形の面積を求める関数が書かれているが、インデントがあることを除けば、「5.2 ユーザー定義関数」で書いたプログラムと同じだ。クラスの配下にある関数はメソッドと呼ぶ。
メソッドは、後述するインスタンス化を行わないと実行できないのだが、ここでは @classmethod を書くことにで、クラスメソッドに指定している。こうすることで、インスタンス化不要で実行できるようになる。
メソッドの第1引数はインスタンス(またはクラス)を指定する。Python では通例 self を引数名とするが、thisでもmyでもかまわない。メソッドは、第1引数を必ず書かなければいけない
クラスの基本構造 (1)
クラスの基本構造 (1)
クラス pahooCalcArea はファイル "pahooCalcArea.py" に書いてある。このクラスの基本構造は上図のような形になっている。
三角形の面積を求めるクラスメソッド calcAreaTriangle 以外にも、正方形の面積を求めるクラスメソッド calcAreaSquare や、長方形の面積を求めるクラスメソッド calcAreaRectangle などを用意した。
クラスの中には、メソッド名が重複しない限り、いくつでもメソッドを書くことができる。インデントの位置に注意をしてほしい。
では、プログラム "calcArea.py" を実行してみよう。円の半径を入力すると、面積を求めるクラスメソッド calcAreaCircle を実行し、結果を画面に表示する。
import pahooValidate as pv
from pahooCalcArea import pahooCalcArea as pca

# メイン・プログラム =======================================================
# 初期値
RADIUS_MIN = 0.0		# 半径の最小値
RADIUS_MAX = 10000		# 半径の最大値

# キーボードから入力する.
radiusStr  = input("半径=")

# 入力バリデーションを行う.
try:
	radius = pv.validateNumber(radiusStr, "float", RADIUS_MIN, RADIUS_MAX, "半径")

# 失敗したらエラーメッセージを表示してプログラムを終了する.
except ValueError as e:
	pv.dispErrorMessageAndExit(str(e))

# 円の面積を求める.
area = pca.calcAreaCircle(radius)

# 画面に表示する.
print(f"半径 {radius} の円の面積は {area}")
クラス pahooCalcArea は別ファイル "pahooCalcArea.py" にある。これを import文を使って読み込み、クラス pahooCalcAreapca という名前で使うという指示が、from pahooCalcArea import pahooCalcArea as pca の1行である。
from クラス名 import Pythonファイル名(拡張子不要) as 別名
のように指示することで、クラスメソッドを pca.calcAreaCircle として呼び出している。クラスメソッドの呼び出しは、クラスメイトメソッド名の間にドット . を挟んで、
クラス名(または別名).クラスメソッド名(引数)
のようにする。
ここで、メソッド calcAreaTriangle の第1引数を指定しないことに留意したい。メソッドの定義では第1引数として self を書いたが、呼び出すときには省略する。

また、これまで何回も使ってきた数値バリデーション関数 validateNumber とエラー・メッセージ出力+プログラム終了関数 dispErrorMessageAndExit も別ファイル "pahooValidate.py" に分離した。
こちらはクラス化していない関数のままだが、pv.validateNumber と名前が変わっている。import文を使って別ファイルにある関数を呼び出すときは
ファイル名(または別名;拡張子不要).関数名(引数)
とする決まりだからだ。
1つの pyファイルに全てのプログラムを書くのではなくて、クラスやよく使う関数を別ファイルにすることで、それらのクラスや関数を別のプログラムで再利用しやすくなる。
応用として、"pahooCalcArea.py", "pahooValidate.py" を利用し、円の面積を求めるプログラムを作ってみてほしい。

カードゲームをクラス化する

次に、現実世界をクラス化する例として、「5.4 リストと配列」で紹介したカードゲーム(トランプ)を取り上げる。
ジョーカーを除く52枚のカードをデータ化し、山札、場札、手札の初期化と、山札から手札への配布、手札から何枚かを場札に捨てる、といった操作をメソッドとして実装する。

カードゲーム基本クラスとして、ファイル "pahooCalcArea.py" にクラス pahooCardGame を用意し、メイン・プログラムは "cardGame1.py" に分離した。バリデーション関連件数が入ったファイル "pahooValidate.py" も利用する。
まずメイン・プログラムから見ていこう。
# メイン・プログラム =======================================================
# 初期化
try:
	id = 0		# プレイヤーID
	cg = pahooCardGame(1)
	cg.shuffleStocks()
except ValueError as errmsg:
	pv.dispErrorMessageAndExit(errmsg)

# ゲーム開始
cg.stocks2hands(5, id)
while (True):
	printHands(cg.hands[id])
	print("コマンド入力:交換(例:1,3), やり直し(c), 終了(q)")
	strCommand = input("> ")
	(res, message) = execCommand(strCommand, cg, id)
	if message != "": print(message)
	if not res: break
cg = pahooCardGame(1) という行に注目してほしい。これは、クラス pahooCardGameインスタンスを生成しろという命令である。

冒頭で、クラスは「オブジェクトを作る設計書」と書いたが、この設計書にもとづいてオブジェクトを生成するのが、この命令である。Python では、生成されたオブジェクトをインスタンスと呼ぶ。
言い方を変えると、変数 cg にはクラス pahooCardGame に書かれているプログラムのコピーが格納される。メソッドや、あとで説明するクラス変数、インスタンス変数のコピーが格納されるのだ。

なぜインスタンス化が必要かというと、カードゲームの場合、初期状態では山札にある52枚のカードがあるわけだが、これをプレイヤーに手札として配るなどして、ゲームの進捗に応じてクラスの中の状態が時々刻々と変化していく。
しかし、設計図を現場の進捗状況に合わせて書き換えるのはおかしい。設計図は設計図として保管しておき、状態変化は別のモノに記録していかなければならない。設計図にもとづき記録を担うのがインスタンスだと考えてほしい。

一方、前述のクラス pahooCalcArea は公式をクラス化しただけなので、その内容が変化することはない。だから、インスタンス化せずに、設計書であるクラスをそのまま呼び出して使った。
もちろんインスタンス化することもできるが、ここまでの説明でお分かりのように、インスタンス化するには相応のメモリを消費する。無駄なメモリ消費を抑えるために、pahooCalcAreaインスタンス化しなかった。

さて、次にあらわれる shuffleStocksstocks2hands は、クラス pahooCardGame にに定義されているメソッドだ。これらのメソッドをインスタンスから呼び出すには、インスタンス名.メソッド名(引数) のようにする。ここではインスタンス名は変数 cg である。

hands は、このあと説明するインスタンス変数で、プレイヤーの手札を格納するリストである。この内容は、ゲームの進行に応じて時々刻々と変化していくから、インスタンス名.インスタンス変数名 とすることで、設計図(クラス)にある変数には影響を及ぼさないようにした。

では、クラス pahooCardGame が書かれているファイル "pahooCardGame.py" の中身を見ていこう。
import random

class pahooCardGame:
	"""カードゲーム基本クラス
	
	Copyright:
		(c)studio pahoo, パパぱふぅ
	
	Environment:
		Python 3.7 or after
	
	Attributes:
		players(int):	プレイヤー数
		stocks(list):	山札
		layouts(list):	場札
		hands(list):	手札

	Note:
		Python入門 - 6.2  ユーザー定義クラス
		https://www.pahoo.org/e-soul/webtech/python00/py00-06-02.html
		ジョーカーを除く4×13枚のカードを扱う.
		カードの配置は,山札,場札(捨て札),手札の3カ所である.
		手札の配置場所は,プレイヤー数に応じて増減する.
	"""
	# スート一覧
	SUITS = ( "spade", "heart", "diamond", "club" )
	# 最小プレイヤー人数
	PLAYERS_MIN = 1
	# 最大プレイヤー人数
	PLAYERS_MAX = 10

	def __init__(self, players=1):
		"""初期化メソッド
		
		Args:
			players(int), optional): プレイヤー数(省略時は1)
		
		Returns:
			None
		"""
		self.initStocks()			# 山札を初期化する
		self.layouts = []			# 場札を初期化する
		self.initHands(players)		# 手札を初期化する

	def initStocks(self):
		"""山札を初期化する.
		
		Args:
			None
		
		Returns:
			None
		"""
		self.stocks = []
		for s in pahooCardGame.SUITS:
			for i in range(1, 14):
				self.stocks.append((s, i))

	def initHands(self, players=1):
		"""手札を初期化する.
		
		Args:
			players(int), optional): プレイヤー数(省略時は1)
		
		Returns:
			None
		"""
		self.hands = []
		if not isinstance(players, int):
			raise ValueError("プレイヤー数は整数で指定してください")
		elif (players < pahooCardGame.PLAYERS_MIN):
			raise ValueError(f"プレイヤー数は {pahooCardGame.PLAYERS_MIN} 以上で指定してください")
		elif (players > pahooCardGame.PLAYERS_MAX):
			raise ValueError(f"プレイヤー数は {pahooCardGame.PLAYERS_MAX} 以下で指定してください")
		else:
			self.players = players
			self.hands = [[] for _ in range(players)]
クラスの定義が class ではじまり、インデントを空けるのは pahooCalcArea と同じだ。
次に、SUITS という変数にタプルを代入している。クラスの直下、メソッドの外側で定義される変数をクラス変数と呼び、このクラス及びインスタンスで共通変数として利用することができる。
3.1 変数と命名規則」で、Python は定数を定義できないと書いたとおり、クラス変数も定数ではないのだが、クラスに紐付く定数の代用としてとして用いることが多い。ここでは4種類のスート(スペード、ハード、ダイヤ、クラブ)という不変の値をタプルを代入しているので、実質的に、クラス pahooCardGame に紐付く定数として機能する。クラス変数は クラス名.クラス変数名 のようにして利用する。

クラスの基本構造を下図に示す。
クラスの基本構造 (2)
クラスの基本構造 (2)
メソッド __init()__ は、インスタンス化 するときに実行する特別なメソッドで、初期化メソッドと呼ぶ。ここでは、メイン・プログラム側で cg = pahooCardGame(1) が実行されたときに、メソッド __init()__ が実行される。
メソッド __init()__ はプレイヤー数を引数として、山札の初期化 initStocks、場札の初期化、手札の初期化 initHands を実行する。
山札と手札の初期化は、次に定義するメソッドを呼び出す。
場札の初期化は、場札の変数(リスト) layouts を空にするだけだ。ここで self.layouts という書き方にしているが、これをインスタンス変数と呼ぶ。インスタンス変数クラス変数と異なり、インスタンス毎に異なる変数が用意される。メイン・プログラム側では cg.layouts のように呼び出して、インスタンス cg に紐付いたインスタンス変数としてアクセスができる。インスタンス変数は インスタンス名.インスタンス変数名 のようにして利用する。

山札を初期化するメソッド initStocks、手札を初期化するメソッド initHands は、「5.4 リストと配列」で紹介したプログラムをメソッドとして分割・整理しただけなので、説明は割愛する。
	def shuffleStocks(self):
		"""山札をシャフルする.
		
		Args:
			None
		
		Returns:
			None
		"""
		random.shuffle(self.stocks)

	def countStockes(self):
		"""山札の枚数を返す.
		
		Args:
			None
		
		Returns:
			int: 山札の枚数
		"""
		return len(self.stocks)
山札をシャフルするメソッド shuffleStocks、山札の枚数を返すメソッド countStockes は、実質1行の簡単な処理だ。
	def stocks2hands(self, n, id):
		"""山札の上からn枚を取り出してプレイヤーidの手札に加える.
		
		Args:
			n(int):  取り出す枚数
			id(int): プレイヤーID
		
		Returns:
			bool: True 成功 / False 失敗(山札が足りない)
		"""
		# 山札が足りているかどうか
		if (self.countStockes() < n):
			return False

		# 山札から取り出す
		sn = slice(None, n)
		# 手札に加える
		self.hands[id].extend(self.stocks[sn])
		# 山札から削除する
		del self.stocks[sn]

		return True
山札の上からn枚を取り出してプレイヤーidの手札に加えるメソッド stocks2hands も、「5.4 リストと配列」で紹介したプログラムをメソッドとして分割・整理した。手札として取り出すときに山札が足りているかどうかをチェックするために、先ほどのメソッド countStockes を呼び出している。
	def countLayouts(self):
		"""場札の枚数を返す.
		
		Args:
			None
		
		Returns:
			int: 場札の枚数
		"""
		return len(self.layouts)

	def countHands(self, id):
		"""場札の枚数を返す.
		
		Args:
			id(int): プレイヤーID
		
		Returns:
			int: 手札の枚数
		"""
		return len(self.hands[id])

	def hands2layouts(self, i, id):
		"""手札のi番目を1枚捨て,場札に加える.
		
		Args:
			i(int):  捨てるカード番号
			id(int): プレイヤーID
		
		Returns:
			bool: True 成功 / False 失敗(手札が足りない)
		"""
		# 手札が足りているかどうか
		if self.countHands(id) <= i:
			return False;

		# 場札に加える
		self.layouts.extend(self.hands[id][i])
		# 手札から削除する
		del self.hands[id][i]

		return True
これらのメソッドも、「5.4 リストと配列」で紹介したプログラムをメソッドとして分割・整理し、枚数チェックなどのバリデーションを加えている。
コマンドの解釈と実行
execCommand: コマンドの解釈と実行
execCommand: コマンドの解釈と実行
def execCommand(command, cg, id):
	"""コマンドを解釈し実行する.
	
	Args:
		command(str): コマンド
		cg(cardGame): cardGameオブジェクト

	Returns:
		bool: True 実行成功 / False 処理終了, str: メッセージ

	Note:
		終了コマンドでプログラムを終了する.
	"""
	# 終了コマンド
	if (command == "q"):
		# webviewダイアログを開いていれば閉じる
		if "window" in globals():
			window.destroy()
		# プログラム終了
		sys.exit()
	# やり直しコマンド
	elif (command == "c"):
		cg.__init__()
		cg.shuffleStocks()
		cg.stocks2hands(5, id)
		return True, ""

	# 交換する手札番号リストを生成
	numList = [item.strip() for item in command.split(",")]
	maxNum = cg.countHands(id)
	nums = []
	for ss in numList:
		try:
			nums.append(pv.validateNumber(ss, "int", 1, maxNum, label="手札"))
		except ValueError as errmsg:
			return True, errmsg
	# 手札番号リストに重複がないかどうかバリデーション
	if isDuplivacates(nums): return True, "交換する手札番号が重複しています"

	# 手札番号をソートする
	nums.sort()

	# 手札を1枚ずつ交換する
	cnt = 1
	for i in nums:
		cg.hands2layouts(i - cnt, id)
		cnt += 1
		if not cg.stocks2hands(1, id): return True, "山札がなくなりました"

	print("command = " + command)
	print(cg.hands[0])

	return True, ""

# メイン・プログラム =======================================================
やや長いプログラムだが、これまで学んできたデータ構造や関数を使って、コマンドの解釈と実行を行っている。

練習問題

次回予告

今回作ったカードゲームの基本プログラム "cardGame1.py" だが、画面に文字を表示し、キーボードから交換するカード番号を入力するという CUI(Character User Interface)は大昔の電子計算機のようで味気ない。せめてブラウザアプリ程度のグラフィックを使った GUI(Graphical User Interface)を導入したい。
そこで次回は、「1.4 ブラウザを使ったGUI」で紹介した外部ライブラリ pywebview を利用し、OSを問わずにGUIできるようにする。

コラム:オブジェクト指向データベース

データベースサーバー
本文で、オブジェクト指向の目的は「現実世界や架空世界をコンピュータの中に忠実に再現する」ことだと記した。ということは、プログラムがデータ処理の対象とするデータベースもまた、オブジェクト指向になるべきだろう。
実際、オブジェクト指向データベース管理システムOODBMS;Object Oriented DBMS)として、Caché (キャシェ) ObjectStore といった製品が販売されている。だが、今ひとつ知名度が低い。なぜか――。
データベース業界では、さまざまなリレーショナルデータベース管理システムRDBMS;Relational Database Management System)がある。商用製品としては Microsoft SQL ServerOracleIBM Db2 が有名だ。オープンソースとしては、MySQLPostgreSQL)が有名だ。
これらはいずれも SQL という共通言語を使ってデータベースを操作することができる。SQL は、製品によって拡張コマンドはあるものの、基本的なコマンドは各製品共通である。SQL は数学・論理学的にみても単純で美しいコマンド体系であり、登場から半世紀を経た2024年(令和6年)現在も現役だ。
ビジネスとしてみると、RDBMSによるデータベース設計手法が製品に依存しないという点が大きなアドバンテージになっている。つまり、同じ設計で、当初はフリーソフトやオープンソースを利用し、ビジネスが大きくなり、データベースの負荷も大きくなったら、商用製品に移行するといったスケーラビリティの変更が容易だ。

一方の OODBMS は、1980年代半ばから製品リリースが始まるのだが、RDBMS の設計手法が通用せず、製品によってコマンド体系が全く異なっていた。このため、RDBMS から OODBMS へ移行するには、まず、データベース技術者の教育コストがかかる。
RDBMS はシンプルで、大量データや大量トランザクション処理での応答速度を上げることが容易で、最適化のためのノウハウも多く蓄積されていたが、初期の OODBMSRDBMS に比べて高価なのに性能が劣っていたために、ビジネスとして導入しづらい状況に陥ってしまった。

結果的に、バックエンドは RDBMS のままで、オブジェクト指向プログラミング言語から RDBMS にアクセスするための ORMツール(Object Relational Mapper)が普及した。
Python にも SQLAlchemy という外部ライブラリが用意されており、SQL を直接書くことなく、Pythonインスタンスとしてデータベースを扱うことができる。
(この項おわり)
header