目次と前回の記事
Python のバージョンとこれまでに作成したモジュール
本記事のプログラムは Python の バージョン 3.13 で実行しています。
以下のリンクから、これまでに作成したモジュールを見ることができます。
リンク | 説明 |
---|---|
marubatsu.py | Marubatsu、Marubatsu_GUI クラスの定義 |
ai.py | AI に関する関数 |
mbtest.py | テストに関する関数 |
util.py | ユーティリティ関数の定義 |
tree.py | ゲーム木に関する Node、Mbtree クラスなどの定義 |
gui.py | GUI に関する処理を行う基底クラスとなる GUI クラスの定義 |
AI の一覧とこれまでに作成したデータファイルについては、下記の記事を参照して下さい。
異なるゲーム盤を表すデータ構造の切り替え
現状の Marubatsu クラスは、下記の ゲーム盤の初期化 を行う initialize_board
の中で記述されているように、2 次元の list でゲーム盤のデータを表現してきました。
def initialize_board(self):
self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
このデータ構造は (x, y) のマス を board[x][y]
のように わかりやすく記述できる という利点がありますが、処理速度 の面では 最善のデータ構造ではありません。ゲーム盤のデータは 様々なデータ構造で表現 することができ、データ構造 とそれを処理する アルゴリズム によって 処理速度が大きく変わります。そこで、本記事から ゲーム盤を表現するデータ構造をいくつか紹介 し、それらの 性質や処理速度などを比較 することにします。
ゲーム盤のデータ構造の切り替え
様々なデータ構造 でゲーム盤を表現する際に、それらの 性質やその処理速度を比較 するためには Marubatsu クラスがそれぞれのゲーム盤のデータ構造を 切り替えて利用できる ようにする必要があります。同様の切り替え は前回の記事で place_mark
メソッドと remove_mark
メソッドで 座標のチェックを行うか どうかの切り替えを行う場合などで行ってきましたが、それと同じ方法 で ゲーム盤のデータ構造の切り替えを行う 場合は、Marubatsu クラスのメソッドを下記のプログラムのように修正する必要があります。
-
1、3 行目:
__init__
メソッドにゲーム盤のデータ構造を表す文字列を代入する仮引数boardtype
を追加し、同名の属性に代入する。下記では従来の 2 次元の list でゲーム盤を表す文字列を "list" とし、それをデフォルト値とした -
6 ~ 12 行目:if 文で
boardtype
属性に代入された文字列に応じたゲーム盤のデータをboard
属性に代入する。下記の 8 ~ 12 行目では仮に "typeA"、"typeB" という文字列で表されるゲーム盤のデータ構造があるものとしてプログラムを記述した -
17 ~ 26 行目:if 文で
boardtype
属性に代入された文字列に応じた (x, y) のマスにマークを配置する処理を記述する
1 def __init__(self, 略, boardtype="list"):
2 略
3 self.boardtype = boardtype
4
5 def initialize_board(self):
6 if self.boardtype == "list":
7 self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
8 elif self.boardtype == "typeA":
9 self.board = typeA のゲーム盤のデータの初期化処理
10 elif self.boardtype == "typeB":
11 self.board = typeB のゲーム盤のデータの初期化処理
12 (以下ゲーム盤のデータ構造の種類だけ記述する)
13
14 def place_mark(self, x: int, y: int, mark: str) -> bool:
15 if self.check_coord:
16 if 0 <= x < self.BOARD_SIZE and 0 <= y < self.BOARD_SIZE:
17 if self.boardtype == "list":
18 if self.board[x][y] == Marubatsu.EMPTY:
19 self.board[x][y] = mark
20 return True
21 else:
22 print("(", x, ",", y, ") のマスにはマークが配置済です")
23 return False
24 elif self.boardtype == "typeA":
25 typeA のゲーム盤の (x, y) にマークを配置する処理
26 (以下ゲーム盤のデータ構造の種類だけ記述する)
typeA のような抽象的な説明ではピンとこない人は、具体例として 1 次元の list でゲーム盤のデータを表現する方法を今回の記事で紹介しているのでそちらを見て下さい。また、後で説明するように 1 次元の list でゲーム盤を表現する場合は (x, y) のマス を self.board[x][y]
とは 異なる方法で記述 する必要があります。
また、上記以外の場所 でもゲーム盤に対して下記の処理を行う際に if 文などでゲーム盤の種類ごとに異なる処理を記述 する必要が生じます。
- ゲーム盤の 特定のマス の内容を 参照 する
- ゲーム盤の 特定のマス の内容を 変更 する
そのため、ゲーム盤のデータ構造の 種類が増える とそれに 対応する処理 を Marubatsu クラスの 複数の場所に記述 する必要が生じ、面倒なだけでなくバグが発生しやすい という問題が発生します。この問題の解決方法の一つに、ポリモーフィズム があります。
ポリモーフィズム
ポリモーフィズム(polymorphism。多態性)は プログラム言語の概念 の一つで、Wikipedia には「それぞれ異なる型に一元アクセスできる共通接点の提供」のように説明されています。ポリモーフィズムには いくつかの種類 があり、プログラム言語によって ポリモーフィズムを 実現する方法が大きく異なる 点がわかりづらいのですが、Python でのポリモーフィズム は 比較的簡単な方法で実現 されています。Python でのポリモーフィズム を簡単に説明すると、異なるデータ型を扱うクラス が 共通するメソッドを持つ ようにすることで、それらのクラスのインスタンスを 共通して扱うことができるようにする ことを意味します。
ゲーム盤のデータ構造 に対する ポリモーフィズム は、以下のような方法で実現します。
- ゲーム盤に対する 処理を行うために必要な処理 を考えて 列挙する
- ゲーム盤のデータ構造を表すクラス を、上記で 列挙した処理 を行う 共通の名前のメソッドを持つ ように定義する
ゲーム盤に対する処理を行うために 必要なメソッドが共通の名前で定義 されていれば、ゲーム盤の データ構造が異なっていて も 同じ記述方法で処理を行うことができる ようになるため、先程のプログラムは下記のプログラムのように 簡潔に記述できる ようになります。修正する部分は下記のプログラムだけ で、他のプログラムの変更を行う必要はありません。
-
1、3 行目:
__init__
メソッドに ゲーム盤のデータ構造を表すクラス を代入する仮引数boardclass
を追加して同名の属性に代入する。下記ではこれまでの 2 次元の list でゲーム盤のデータを表す ListBoard という クラスが定義されている ものとする。ListBoard の定義の方法については後述する -
6 行目:
boardclass
に代入された クラスのインスタンスを作成 することで ゲーム盤のデータを作成 してboard
属性に代入 する
1 def __init__(self, 略, boardclass=ListBoard):
2 略
3 self.boardtype = boardclass
4
5 def initialize_board(self):
6 self.board = boardclass()
このように、ポリモーフィズムを利用することで、同じ機能を持つ異なるデータ型 のデータを 複数扱う 必要がある際に データ型とその機能を定義するクラス と、そのデータ型の データを扱う側のプログラム を 独立して記述 することができるようになり、プログラムの 柔軟性 や 生産性が向上する という利点が得られます。
この説明ではピンとこない人が多いと思いますので、複数の 〇× ゲームの ゲーム盤のデータ を ポリモーフィズムを利用して実装 するという具体例をあげながら説明します。
なお、Python では ポリモーフィズムが頻繁に利用 されており、for 文 に list だけでなく、tuple、set、dict などの 反復可能オブジェクト を記述できる のもポリモーフィズムの仕組みによるものです。具体的には、反復可能オブジェクトに分類 されるオブジェクトには、for 文による繰り返し処理 の際に 次の要素を取り出す ために呼び出される __iter__
またはシーケンス として実装された __getitem__
という特殊メソッドが定義されています1。
他にも print
で様々なオブジェクトを 文字で表示できる のは、オブジェクトの内容を 文字列に変換 する __str__
というメソッドが定義されているからです2。
反復可能オブジェクト(iterable)の詳細に関しては下記のリンク先を参照して下さい。
現実の例でポリモーフィズムに 似たもの としては 電池 などの 部品の規格 が挙げられると思います。例えば 単一電池 にはマンガン電池、アルカリ電池、充電可能な電池のように いくつかの種類 がありますが、単一電池の 規格で決められた以下の点が必ず共通 しています。
- 電池の形状
- プラス極とマイナス極をつなぐことで 1.5 V の電圧が発生する
上記の 規格を満たせば、電池の 中身がどのようなものであっても 単一電池を必要とする電化製品で 利用することが可能 です。例えば、電池のアダプターを利用することで形状が異なる単三電池を単一電池として利用する事さえできます3。他にもインクのカートリッジ、様々なテレビで利用できるリモコンなど、決められた規格に従って作られたもの はその 規格にあう製品に対して利用 することができます。また、いずれの場合でも、その際に 製品のほうに対して 何らかの 調整を行う必要は一切ありません。
参考までに Wikipedia のポリモーフィズムのリンクを下記に示します。
Python のように、必要なメソッドを共通して持つことで実現するポリモーフィズムのことをダックタイピングと呼び、Python のドキュメントには「あるオブジェクトが正しいインターフェースを持っているかを決定するのにオブジェクトの型を見ないプログラミングスタイルです」のように説明されています。
ダックタイピングは "If it walks like a duck and quacks like a duck, it must be a duck"(もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルに違いない)という「ダックテスト」と呼ばれる推論の方法が由来で、「違いない」という説明からわかるように、推測が間違っていた場合はバグの原因になることがあります。例えば偶然必要なメソッドを持つ全く異なるデータ型の処理を行うことができてしまい、それが原因でバグが発生する可能性があります。そのため、より厳密な方法でポリモーフィズムを実現しているプログラム言語が多数あります。
一方でダックタイピングは厳密ではない代わりに、プログラミングを柔軟に記述できるという利点があります。他にも様々な利点と欠点があるので興味がある方は調べてみて下さい。
参考までにダックタイピングのリンクを下記に示します。
ゲーム盤のデータ構造を表すクラスに必要なメソッド
Python でゲーム盤のデータに対するポリモーフィズムを実現するためには、ゲーム盤を表すデータ に対して 行う必要がある処理を列挙 する必要があります。ゲーム盤のデータは board
属性に代入 されており、これまでに記述してきたプログラムで board
属性に対して行う処理 は以下の 2 種類だけです。なお、本記事では以後は board[x][y]
のように、list や dict などの後ろに記述する []
とその中身 の事を 添字 と表記することにします。
- 式の中で
board[x][y]
のように、2 つの添字 を記述して (x, y) のマスの中身を 参照 する -
board[x][y] = Marubatsu.CIRCLE
のように 2 つの添字 を記述したものに 値を代入 することで、(x, y) のマスに 値を設定 する
従って、ゲーム盤を表すデータ が 上記の方法で特定のマスに対する参照と代入 を行うことができれば、ポリモーフィズムを実現することができます。
list や dict などのデータ型では 後ろに添字を記述 することで 特定の要素の参照と代入 を行うことができますが、同様の処理 をクラスに __getitem__
と __setitem__
という 特殊メソッドを定義 する事で、そのクラスの インスタンスに対して行う ことができます。これらの特殊メソッドの定義の方法については後述します。
従って、ゲーム盤のデータ構造を表すクラス には少なくとも __getitem__
と __setitem__
メソッドが必要 であることがわかりました。他にも必要なメソッドがあるかもしれませんが、必要であることがわかり次第、その都度 追加、修正していく ことにします。
抽象クラスの定義
Python でポリモーフィズムを実現 する際には、以前の記事で説明した 抽象クラスを定義 し、その抽象クラスから 継承する ことでクラスを定義するのが一般的です。また、その抽象クラスでは、共通して必要となるメソッド を 抽象メソッド として定義します。
以前の記事で説明したように、抽象メソッドが定義された 抽象クラスを継承したクラス は、抽象メソッドを定義しないと インスタンスの作成時に エラーが発生 するので、必要なメソッド の 定義のし忘れを防ぐ ことができます。
以前の記事では 〇× ゲームに関する様々な GUI を作成する際の基底クラスとして利用するために抽象クラスを定義しましたが、抽象クラスはポリモーフィズムを実現するためにも利用することができます。
そこで、〇× ゲームの ゲーム盤のデータ構造を定義 するための 基底クラス として Board という名前の 抽象クラス を下記のプログラムのように定義することにします。__getitem__
と __setitem__
メソッドの仮引数についてはこの後で説明します。
from abc import ABCMeta, abstractmethod
class Board(metaclass=ABCMeta):
@abstractmethod
def __getitem__(self, key):
pass
@abstractmethod
def __setitem__(self, key, value):
pass
__getitem__
メソッドの利用方法
次に __getitem__
と __setitem__
メソッドについて説明します。__getitem__
メソッドが定義されたクラス から作成されたインスタンスは 添字を 1 つ記述できる ようになり4、添字を記述した場合 は下記の処理が行われます。
-
__getitem__
メソッドが呼び出され、仮引数key
に添字の値が代入される -
__getitem__
メソッドの返り値で置き換えられる
具体例を挙げます。下記のプログラムは 常に key
の値を返り値 として返す __getitem__
メソッドだけが定義 された A というクラスを定義しています。
class A:
def __getitem__(self, key):
return key
そのため、下記のプログラムのように A のインスタンスを作成し、添字を 1 つ記述して print
で表示 すると 添字の値が key
に代入 されて __getitem__
が呼び出され、key
の値をそのまま返り値として返す 処理が行われるので、実行結果のように 添字に記述した値がそのまま表示 されます。
a = A()
print(a[100])
print(a["abc"])
実行結果
100
abc
上記の A のインスタンスのように属性が一つも存在せず、何のデータも記録していない場合 でも __getitem__
を定義 することができます。ほとんどのクラス では 添字の記述 によってインスタンスが 記録するデータの一部が参照 されますが、上記のように 記録するデータと関係のない値 が返り値となる場合があります。
下記のようなクラスを作成する意味は特にありませんが、list と同じようなデータ構造 を持つ 自作の Mylist クラスを下記のように定義することができます。
-
2、3 行目:仮引数
data
を持つ__init__
メソッドを定義し、同名の属性にdata
を代入する。なお、data
には list が代入されることを想定している -
5、6 行目:
self.data[key]
を返り値として返す処理を行う__getitem__
を定義する
1 class Mylist:
2 def __init__(self, data):
3 self.data = data
4
5 def __getitem__(self, key):
6 return self.data[key]
行番号のないプログラム
class Mylist:
def __init__(self, data):
self.data = data
def __getitem__(self, key):
return self.data[key]
下記のプログラムを実行して Mylist
のインスタンスを作成 すると、[1, 2, 3, 4, 5]
という list が l
の data
属性に代入 され、l[0]
を記述することで l.data[0]
が返り値として返ります。list と同様 に l[2]
と l[1:3]
で l.data[2]
と l.data[1:3]
の値も参照できます。
l = Mylist([1, 2, 3, 4, 5])
print(l[0])
print(l[2])
print(l[1:3])
実行結果
1
3
[2, 3]
Mylist は list とは異なり下記のプログラムの実行結果の 1 行目のように print(l)
を実行しても list の内容は表示されません5。また、後述の __setitem__
メソッドが定義されていな いので、list とは異なり l[0] = 2
のような 添字による代入処理 を行うと実行結果のように エラーが発生 します。このように、list と全く同じ機能を持つようにするためには Mylist に多くの特殊メソッドを定義する必要があります。
print(l)
l[0] = 2
実行結果
<__main__.Mylist object at 0x000001579E1FEF90>
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[6], line 2
1 print(l)
----> 2 l[0] = 2
TypeError: 'Mylist' object does not support item assignment
__getitem__
メソッドの詳細については下記のリンク先を参照して下さい。
__setitem__
メソッドの利用方法
__setitem__
メソッドが定義されたクラスから作成されたインスタンスは、添字を記述した場合に 代入を行える ようになり、そのような記述を行った場合は下記の処理が行われます。
-
__setitem__
メソッドが呼び出され、仮引数key
に添字に記述した値が代入され、value
に代入文の式の値が代入される -
__getitem__
とは異なり、return 文による返り値を返す必要はない
下記は先ほどの Mylist クラスに __setitem__
メソッドを追加して、list のように 添字を記述 して data
属性の添字の要素に値を代入 できるようにしたプログラムです。
-
8、9 行目:
data
属性に代入された list のkey
の要素にvalue
を代入する処理を行う__setitem__
を定義する
1 class Mylist:
2 def __init__(self, data):
3 self.data = data
4
5 def __getitem__(self, key):
6 return self.data[key]
7
8 def __setitem__(self, key, value):
9 self.data[key] = value
行番号のないプログラム
class Mylist:
def __init__(self, data):
self.data = data
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, value):
self.data[key] = value
上記の修正によって、下記のプログラムの 3 行目のように l[0] = 5
のような 添字を記述した代入処理 によって l.data[0] = 5
という代入処理が行われる ようになります。そのことは、実行結果の l[0]
と l.data
の表示から確認することができます。
l = Mylist([1, 2, 3, 4, 5])
print(l[0])
l[0] = 5
print(l[0])
print(l.data)
実行結果
1
5
[5, 2, 3, 4, 5]
__setitem__
メソッドの詳細については下記のリンク先を参照して下さい。
ListBoard クラスの定義
ここまでの内容を元に 2 次元の list でゲーム盤を表す ListBoard クラスを定義 することにします。ゲーム盤のデータは board
属性 に代入し、__init__
メソッドでその 初期化を行う ことにします。下記は ListBoard の定義です。ゲーム盤のサイズを変更できる ように __init__
メソッドには 仮引数 board_size
を用意しました。継承した Board クラス の 抽象メソッドは必ず定義する必要がある ので、__getitem__
メソッドと __setitem__
メソッドは 何も行わないメソッドとして定義 し、後で修正する ことにします。
-
4、5 行目:デフォルト値を 3 とする仮引数
board_size
を持つ__init__
メソッドを定義し、同名の属性にboard_size
を代入する -
6 行目:
board
属性に Marubatsu クラスのinitialize_board
と同じ方法で 2 次元の list でゲーム盤のデータを初期化する
1 from marubatsu import Marubatsu
2
3 class ListBoard(Board):
4 def __init__(self, board_size=3):
5 self.BOARD_SIZE = board_size
6 self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
7
8 def __getitem__(self, key):
9 pass
10
11 def __setitem__(self, key, value):
12 pass
行番号のないプログラム
from marubatsu import Marubatsu
class ListBoard(Board):
def __init__(self, board_size=3):
self.BOARD_SIZE = board_size
self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
def __getitem__(self, key):
pass
def __setitem__(self, key, value):
pass
上記の定義後に下記のプログラムで ListBoard のインスタンスを作成 し、board
属性を表示 すると実行結果のようにゲーム盤を表す 2 次元の list が代入される ことが確認できます。
board = ListBoard()
print(board.board)
実行結果
[['.', '.', '.'], ['.', '.', '.'], ['.', '.', '.']]
__getitem__
メソッドと ListBoardwithFirstkey クラスの定義
次に、board[x][y]
を記述することで (x, y) のマスのマークを参照できる ように __getitem__
メソッドを定義 する必要がありますが、__getitem__
メソッドは 1 つの添字にしか対応していない ため、board[x][y]
のような 2 つの添字の記述に対応6するためは 工夫を行なう必要 があります。本記事ではその工夫の一つを紹介しますが、もっと良い方法があるかもしれませんので、ご存じの方がいればコメントなどで教えて頂ければ助かります。
その方法は、下記のような クラスを定義 し、ListBoard クラスの __getitem__
メソッドが そのクラスのインスタンスを返す ようにするというものです。
- ゲーム盤のデータを表す
board
属性 と 1 つ目の添字の値を記録 する -
__getitem__
メソッドを定義し、その中で 記録した 1 つ目の添字 の値と、仮引数key
に代入された 2 つ目の添字 を用いて 2 つの添字に対応するマスの値を返り値として返す処理を行う
上記のクラスは ListBoard のゲーム盤のデータ と 最初の添字の値 を記録するので ListBoardwithFirstkey という名前にし、下記のプログラムのように定義します。
-
2 ~ 4 行目:ゲーム盤のデータ と、1 つ目の添字の値 を代入する仮引数
board
とkey
を持つ__init__
メソッドを定義し 同名の属性に代入 する -
6、7 行目:1 つ目の添字の値 を表す
self.key
と 2 つ目の添字の値 を表すkey
を用いて (self.key, key) のマスの値を返り値として返す__getitem__
メソッドを定義する
1 class ListBoardwithFirstkey:
2 def __init__(self, board, key):
3 self.board = board
4 self.key = key
5
6 def __getitem__(self, key):
7 return self.board[self.key][key]
行番号のないプログラム
class ListBoardwithFirstkey:
def __init__(self, board, key):
self.board = board
self.key = key
def __getitem__(self, key):
return self.board[self.key][key]
ListBoardwithFirstkey のインスタンスは ゲーム盤のデータ の中の key
列目を表す list のような 機能を持つ ため、添字を記述 することで、key
列 の 添字に記述した行 のデータを 参照することができます。
ListBoard の __getitem__
メソッドは下記のプログラムのように、ゲーム盤のデータ である self.board
と 1 つ目の添字の値 を表す key
を実引数に記述して ListBoardwithFirstkey のインスタンスを作成 し、その インスタンスを返す ように定義します。
def __getitem__(self, key):
return ListBoardwithFirstkey(self.board, key)
ListBoard.__getitem__ = __getitem__
上記の定義を行うことで、下記のプログラムのように ListBoard のインスタンス に対して board[0][1]
のような 2 つの添字を記述 することで、board.board[0][1]
に代入された 2 次元の list から ゲーム盤の 2 つの添字の座標のマスの値を参照 できるようになります。
board = ListBoard()
print(board[0][1])
実行結果
.
board[0][1]
を記述した際に行われる処理
おそらくここまでの説明を読んで、上記で行われる処理が理解できない人が多いのではないかと思いますので、board[0][1]
を記述した際に 行われる処理について詳しく説明 します。
board[0][1]
のような 2 つ以上の添字が記述 されている場合の処理は、添字に対する処理が一度に行われるのではなく、下記の手順で 先頭の添字から順番に処理 が行われます。
-
board[0]
に対する__getitem__
の処理 が行われる - 上記の
__getitem__
の返り値 に対して、次の添字 である[1]
に対する__getitem__
の処理 が行われる
そのことは、下記のプログラムのように board[0]
を b0
に代入 し、b0[1]
を print
で表示 することで、print(board[0][1])
と同じ結果が表示 されることからも確認できます。
b0 = board[0]
print(b0[1])
実行結果
.
上記の b0
に代入 された baord[0]
は、下記の ListBoard クラスの __getitem__
メソッドの 返り値の値 になります。
def __getitem__(self, key):
return ListBoardwithFirstkey(self.board, key)
その際に仮引数 key
には 添字の値 である 0
が代入 されるので、__getitem__
の返り値は下記のようになります。
ListBoardwithFirstkey(self.board, 0)
ListBoardwithFirstkey クラスの __init__
メソッドは下記のように定義されているので、board
属性に ゲーム盤を表す 2 次元の list が、key
属性に 1 つ目の添字 の値である 0
が代入 された ListBoardwithFirstkey クラスのインスタンスが b0
に代入されます。
def __init__(self, board, key):
self.board = board
self.key = key
そのことは下記のプログラムで確認することができます。なお、ListBoardwithFirstkey には __str__
メソッドが定義されていない ので、b0
を print
で表示 すると下記の実行結果の 1 行目のような表示が行われますが、その中に ListBoardwithFirstkey クラスのインスタンスである ことを表す __main__.ListBoardwithFirstkey
が表示されます。
print(b0)
print(b0.board)
print(b0.key)
実行結果
<__main__.ListBoardwithFirstkey object at 0x000002897F6D8190>
[['.', '.', '.'], ['.', '.', '.'], ['.', '.', '.']]
0
この b0
に対して b0[1]
のように 1 という添字を記述 すると、下記の ListBoardwithFirstkey クラスの __getitem__
メソッドが呼び出されます。
def __getitem__(self, key):
return self.board[self.key][key]
また、上記の return 文のそれぞれの変数の値は下記のようになります。
-
self.board
には ゲーム盤を表す 2 次元の list が代入されている -
self.key
には 1 つ目の添字の値 である0
が代入されている -
key
には 2 つ目の添字の値 である1
が代入されている
従って、上記の __getitem__
の返り値 は self.board[0][1]
となり、これはゲーム盤の (0, 1) の座標のマスの値 を表します。
上記が、board[0][1]
を記述した際の 処理の流れ です。
上記のような かなりわかりづらく、まわりくどい処理 を行う必要があるのは、__getitem__
メソッドが残念ながら 1 つの添字の記述に対する処理にしか対応していない ためです。
実は 2 次元の list でゲーム盤のデータを表現する場合は ListBoardwithFirstkey クラスを定義する必要はありません。その代わりに下記のプログラムのように ListBoard クラスの __getitem__
メソッドの返り値を self.board[key]
を返すように定義することができます。興味がある方はそのように定義してみて下さい。
def __getitem__(self, key):
return self.board[self.key]
上記のように定義することで、board[0][1]
を記述した場合の board[0]
の部分が __getitem__
メソッドの返り値である board.board[0]
となるので、board[0][1]
の値が board.board[0][1]
となり、(0, 1) のマスを表すことになります。
ただし、この手法はゲーム盤のデータを 2 次元の list で表現した場合でしか利用できません。例えばこの後で説明する 1 次元の list でゲーム盤を表現する場合は ListBoardwithFirstkey のようなクラスを定義する必要があります。
__setitem__
メソッドの定義
次に、board[0][1] = Marubatsu.CIRCLE
のような 2 つの添字 を記述した場合の 代入処理を行える ように __setitem__
メソッドを定義します。残念ながら __setitem__
メソッドも 1 つの添字の記述に対する処理にしか対応していない ので、__getitem__
と 同様の工夫を行う必要 があります。どのように定義すればよいかについて少し考えてみて下さい。
初心者の方は ListBoard クラスの __setitem__
メソッドを定義する必要があると思ったかもしれませんが、ListBoard クラスの __setitem__
メソッドは board[1] = Marubatsu.CIRCLE
のような 1 つ目の添字に対応する処理 を行うメソッドなので、board[0][1] = Marubatsu.CIRCLE
のような 2 つ以上の添字 に対する 代入処理を行うことはできません。
board[0][1] = Marubatsu.CIRCLE
という 代入文を実行 する際には、先程の board[0][1]
の 参照の場合と同様 に、最初に board[0]
の計算 が行われます。具体的には、下記のプログラムの手順 で処理が実行されます。なお、現状では __setitem__
メソッドが定義されていないので下記のプログラムを実行するとエラーが発生するので実行はしません。
b0 = board[0]
b0[1] = Marubatsu.CIRCLE
上記のうちの、b0 = board[0]
の処理 は先ほどの board[0][1]
の参照 で行われる処理と 全く同じ です。そのため、ListBoard の __getitem__
メソッドを 修正する必要はありません。
従って、b0
には ListBoardwithFirstkey クラスのインスタンスが代入 されているので、b0[1] = Marubatsu.CIRCLE
を実行すると、ListBoardwithFirstkey クラスの __setitem__
メソッドが呼び出されます。その中で 2 つの添字 で指定した マスの値を更新 するためには、下記のプログラムのように __setitem__
メソッドを定義します。
-
1、2 行目:1 つ目の添字の値 を表す
self.key
と 2 つ目の添字の値 を表すkey
を用いて、添字が指定するboard
属性の要素に 代入文の式の値 であるvalue
を代入 する -
4 行目:ListBoardwithFirstkey クラスのメソッドとして
__setitem__
を定義する
1 def __setitem__(self, key, value):
2 self.board[self.key][key] = value
3
4 ListBoardwithFirstkey.__setitem__ = __setitem__
行番号のないプログラム
def __setitem__(self, key, value):
self.board[self.key][key] = value
ListBoardwithFirstkey.__setitem__ = __setitem__
なお、ListBoard の __setitem__
メソッドは board[1] = Marubatsu.CIRCLE
のような 添字を 1 つだけ記述した代入文 を実行した際に呼び出されるので、修正する必要はありません。
2 次元の list の場合は board[1] = ["o", "x", "o"]
のような、ゲーム盤の特定の列を代入することができますが、ListBoard ではそのような代入処理を行うことはできません。現時点ではそのような代入処理を行う必要がないので実装しませんが、そのような代入処理が必要となる場合は ListBoard クラスの __setitem__
メソッドの中にそのような代入処理を行うプログラムを記述する必要があります。
上記の定義後に下記のプログラムを実行すると、実行結果から board[0][1] = Marubatsu.CIRCLE
によって (1, 2) のマスに 〇 が配置された ことが確認できます。
board = ListBoard()
board[0][1] = Marubatsu.CIRCLE
print(board[0][1])
print(board.board)
実行結果
o
[['.', 'o', '.'], ['.', '.', '.'], ['.', '.', '.']]
以上で ListBoard クラスの インスタンス に対して、2 次元の list で直接ゲーム盤のデータを記録した場合と 同様 に 2 つの添字を記述 してゲーム盤のマスの 参照と代入を行う ことができるようになりました。
Marubatsu クラスの修正
上記の ListBoard を利用した ゲーム盤のデータを作成 して board
属性を記録 するように Marubatsu クラスを修正します。修正方法は 先ほど説明 した方法と ほぼ同じ ですが、ListBoard クラスのインスタンスを作成する際に、ゲーム盤の大きさを表す board_size
を 実引数に記述 できるようにした点が 異なります。
下記は __init__
メソッドを修正したプログラムです。
-
1、3 行目:デフォルト値を ListBoard とする、ゲーム盤のデータ構造を代入する仮引数
boardclass
を追加して同名の属性に代入する
1 def __init__(self, boardclass=ListBoard, board_size=3, count_linemark=False, check_coord=True):
2 # ゲーム盤のデータ構造を定義するクラス
3 self.boardclass = boardclass
4 # ゲーム盤の縦横のサイズ
5 self.BOARD_SIZE = board_size
元と同じなので省略
6
7 Marubatsu.__init__ = __init__
行番号のないプログラム
def __init__(self, boardclass=ListBoard, board_size=3, count_linemark=False, check_coord=True):
# ゲーム盤のデータ構造を定義するクラス
self.boardclass = boardclass
# ゲーム盤の縦横のサイズ
self.BOARD_SIZE = board_size
# 各直線上のマークの数を数えるかどうか
self.count_linemark = count_linemark
# move と unmove メソッドで座標などのチェックを行うかどうか
self.check_coord = check_coord
# 〇×ゲーム盤を再起動するメソッドを呼び出す
self.restart()
Marubatsu.__init__ = __init__
修正箇所
-def __init__(self, board_size=3, count_linemark=False, check_coord=True):
+def __init__(self, boardclass=ListBoard, board_size=3, count_linemark=False, check_coord=True):
# ゲーム盤のデータ構造を定義するクラス
+ self.boardclass = boardclass
# ゲーム盤の縦横のサイズ
self.BOARD_SIZE = board_size
元と同じなので省略
Marubatsu.__init__ = __init__
下記は initialize_board
メソッドを修正したプログラムです。
-
2 行目:
board
属性にboardclass
属性に代入された クラスのインスタンス を代入する。その際に、ゲーム盤のサイズを表す値を実引数に記述するようにした
1 def initialize_board(self):
2 self.board = self.boardclass(self.BOARD_SIZE)
3
4 Marubatsu.initialize_board = initialize_board
行番号のないプログラム
def initialize_board(self):
self.board = self.boardclass(self.BOARD_SIZE)
Marubatsu.initialize_board = initialize_board
修正箇所
def initialize_board(self):
- self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
+ self.board = self.boardclass(self.BOARD_SIZE)
Marubatsu.initialize_board = initialize_board
動作の確認
上記の修正後に、下記のプログラムで play
メソッドを利用して ai2s
VS ai14s
の対戦を 1 回行う と、実行結果のように プログラムが正しく動作 することが確認できました。
from ai import ai2s, ai14s
mb = Marubatsu()
mb.play(ai=[ai2s, ai14s])
実行結果(対戦結果はランダムなので下記と異なる場合があります)
Turn o
...
...
...
Turn x
.O.
...
...
Turn o
.o.
.X.
...
Turn x
.oO
.x.
...
Turn o
Xoo
.x.
...
Turn x
xoo
.xO
...
winner x
xoo
.xo
..X
1 次元の list でゲーム盤を表現するクラスの定義
ゲーム盤を表す 別のデータ構造 として、1 次元の list でゲーム盤を表現するという方法が考えられます。具体的には以前の記事で紹介した 数値座標 をインデックスとした 1 次元の list でゲーム盤を表現します。下記は、そのようなデータ構造でゲーム盤を表現する List1dBoard と List1dBoardwithFirstkey というクラスの定義です。定義の方法は ListBoard と ListBoardwithFirstkey と ほぼ同様 です。なお、クラスの名前の中の 1d の d は 次元 を表す dimension の頭文字 です。
下記は List1dBoard クラスの定義です。先ほどの ListBoard との違いについて説明します。
-
4 行目:ゲーム盤のマスの数 は「ゲーム盤のサイズの 2 乗」なので、1 次元の list で ゲーム開始時のゲーム盤 を表現する場合は、マスの数だけ
Marubatsu.EMPTY
を要素とする list を作成すればよい。そのような list をboard
属性に代入するように修正した7 -
7 行目:List1dBoardwithFirstkey の
__getitem__
と__setitem__
メソッドでは、(x, y) の 2 次元の座標 から「x + y × ゲーム盤のサイズ」という式で 数値座標を計算 する必要がある。そのため ゲーム盤のサイズの情報が必要 となるので、その値を 2 つ目の実引数に記述 してインスタンスを作成するようにした -
9、10 行目:ListBoard の場合と同様に
__setitem__
メソッドでは処理は行わない
1 class List1dBoard(Board):
2 def __init__(self, board_size=3):
3 self.BOARD_SIZE = board_size
4 self.board = [Marubatsu.EMPTY] * (self.BOARD_SIZE ** 2)
5
6 def __getitem__(self, key):
7 return List1dBoardwithFirstkey(self.board, self.BOARD_SIZE, key)
8
9 def __setitem__(self, key, value):
10 pass
行番号のないプログラム
class List1dBoard(Board):
def __init__(self, board_size=3):
self.BOARD_SIZE = board_size
self.board = [Marubatsu.EMPTY] * (self.BOARD_SIZE ** 2)
def __getitem__(self, key):
return List1dBoardwithFirstkey(self.board, self.BOARD_SIZE, key)
def __setitem__(self, key, value):
pass
修正箇所
-class ListBoard(Board):
+class List1dBoard(Board):
def __init__(self, board_size=3):
self.BOARD_SIZE = board_size
- self.board = [[Marubatsu.EMPTY] * self.BOARD_SIZE for y in range(self.BOARD_SIZE)]
+ self.board = [Marubatsu.EMPTY] * (self.BOARD_SIZE ** 2)
def __getitem__(self, key):
- return ListBoardwithFirstkey(self.board, key)
+ return List1dBoardwithFirstkey(self.board, self.BOARD_SIZE, key)
def __setitem__(self, key, value):
pass
下記は List1dBoardwithFirstkey クラスの定義です。先ほどの ListBoard との違いについて説明します。
-
2、4 行目:ゲーム盤のサイズを代入する仮引数
board_size
を追加 し、BOARD_SIZE
属性に代入 するように修正した -
8 行目:(x, y) の 2 次元の座標 から 数値座標を計算する式 は
x + y × ゲーム盤のサイズ
なので、その式を使って 1 次元の list の インデックスを計算 してその 要素の値を返す - 10 行目:上記と同様の方法で数値座標を計算し、その要素に値を代入する
1 class List1dBoardwithFirstkey:
2 def __init__(self, board, board_size, key):
3 self.board = board
4 self.BOARD_SIZE = board_size
5 self.key = key
6
7 def __getitem__(self, key):
8 return self.board[self.key + key * self.BOARD_SIZE]
9
10 def __setitem__(self, key, value):
11 self.board[self.key + key * self.BOARD_SIZE] = value
行番号のないプログラム
class List1dBoardwithFirstkey:
def __init__(self, board, board_size, key):
self.board = board
self.BOARD_SIZE = board_size
self.key = key
def __getitem__(self, key):
return self.board[self.key + key * self.BOARD_SIZE]
def __setitem__(self, key, value):
self.board[self.key + key * self.BOARD_SIZE] = value
修正箇所
-class ListBoardwithFirstkey:
+class List1dBoardwithFirstkey:
- def __init__(self, board, key):
+ def __init__(self, board, board_size, key):
self.board = board
+ self.BOARD_SIZE = board_size
self.key = key
def __getitem__(self, key):
- return self.board[self.key][key]
+ return self.board[self.key + key * self.BOARD_SIZE]
def __setitem__(self, key, value):
- self.board[self.key][key] = value
+ self.board[self.key + key * self.BOARD_SIZE] = value
上記の修正後に、下記のプログラムで boardclass=List1dBoard
を実引数に記述することで List1dBoard のデータ構造でゲーム盤を記録する ようにした Marubatsu クラスのインスタンスで play
メソッドを利用して ai2s
VS ai14s
の対戦を 1 回行う と、実行結果のように プログラムが正しく動作 することが確認できました。
mb1d = Marubatsu(boardclass=List1dBoard)
mb1d.play(ai=[ai2s, ai14s])
実行結果(対戦結果はランダムなので下記と異なる場合があります)
Turn o
...
...
...
Turn x
.O.
...
...
Turn o
.o.
.X.
...
Turn x
.oO
.x.
...
Turn o
Xoo
.x.
...
Turn x
xoo
.xO
...
winner x
xoo
.xo
..X
また、下記のプログラムで ListBoard と List1dBoard のそれぞれを利用する場合の ゲーム盤のデータを表す list を表示 すると、実際にゲーム盤のデータが 2 次元の list と 1 次元の list という 異なるデータで記録されている ことが確認できます。
print(mb.board.board)
print(mb1d.board.board)
実行結果
[['x', '.', '.'], ['o', 'x', '.'], ['o', 'o', 'x']]
['x', 'o', 'o', '.', 'x', 'o', '.', '.', 'x']
上記の ListBoard と List1dBoard の定義からわかるように、ゲーム盤に対する処理 を行うために 必要なメソッドを定義 した ゲーム盤のデータを表すクラスを定義 することで、Marubatsu クラス のプログラムを 変更することなく、異なるデータ構造のゲーム盤 で Marubatsu クラスのプログラムを 動作する ことができます。このようなことが行えるのが ポリモーフィズムの大きな利点の一つ です。
なお、1 次元の list でゲーム盤のデータを表現するというデータ構造には プログラムのわかりやすさ や 処理速度 の点で 大きなメリットはありません。List1dBoard を実装 したのは 簡単に実装できる ため、ポリモーフィズムの利点 がわかりやすく 実感できるのではないかと思ったから です。プログラムのわかりやすさや処理速度に優れる他のデータ構造については次回の記事以降で紹介する予定です。
List1dBoard のインスタンスに対して board[0:2][1:3]
のようなスライス表記で添字を記述するとエラーが発生します。その理由は、List1dBoardwithFirstkey の key
属性や、__getitem__
と __setitem__
メソッドの仮引数 key
に数値型とは異なる、組み込みクラスである slice
のインスタンスが代入されるため、__getitem__
メソッドの self.board[self.key + key * self.BOARD_SIZE]
の処理で slice
型と整数型という異なるデータ型の演算が行われてしまうからです。
現状ではそのような処理を行う必要がないので本記事では実装しませんが、添字にスライス表記を記述した場合の処理に対応する必要がある場合は、self.key
と key
に slice
型のデータが代入されているかどうかを判定し、代入されていた場合にスライス表記に対応した処理を記述する必要があります。
なお、ListBoard の場合は ListBoardwithFirstkey の __getitem__
と __setitem__
で self.board[self.key][key]
のように、どちらの添字に対してもスライス表記を利用できる 2 次元の list に対して添字をそのまま記述しているので、スライス表記を記述してもエラーは発生しません。
slice
クラスの詳細については下記のリンク先を参照して下さい。
ListBoard の問題点
実は上記で定義した ListBoard にはいくつかの問題があります。
処理速度の低下
その一つは 処理速度が遅くなる というものです。下記は ai2s
VS ai2s
の対戦を ListBoard を利用 した Marubatsu クラスのインスタンスで 10000 回行うプログラムです。
from ai import ai_match
ai_match(ai=[ai2s, ai2s], match_num=5000)
実行結果
ai2s VS ai2s
100%|██████████| 5000/5000 [00:03<00:00, 1645.19it/s]
count win lose draw
o 2940 1442 618
x 1475 2899 626
total 4415 4341 1244
ratio win lose draw
o 58.8% 28.8% 12.4%
x 29.5% 58.0% 12.5%
total 44.1% 43.4% 12.4%
下記は 前回の記事 で 同じプログラムを実行 した場合の実行結果です。
ai2s VS ai2s
100%|██████████| 5000/5000 [00:01<00:00, 2812.18it/s]
count win lose draw
o 2923 1410 667
x 1444 2942 614
total 4367 4352 1281
ratio win lose draw
o 58.5% 28.2% 13.3%
x 28.9% 58.8% 12.3%
total 43.7% 43.5% 12.8%
2 つの結果を見比べると、対戦成績はほぼ同じ なので、ListBoard を利用した場合の 処理が正しく行われている ことが確認できますが、1 秒間の 対戦回数の平均 が 2812.18 回から 1645.19 回に大幅に減っている ことがわかります。両者の違い は ListBord を利用しているか どうかなので、明らかに ListBoard が 処理速度の低下の原因 であることがわかります。
プログラムが正しく動作しない場合がある
本記事を記述後に gui_play
を実行して GUI での対戦を行ってみたところ、正しく動作しない ことが判明しました。また、ai_abs_dls
などの ゲーム木の探索を行う AI で 置換表を利用する場合 でも 正しく動作しない ことが判明しました。その理由は、__getitem__
と __setitem__
以外にも 定義する必要があるメソッド があり、そのメソッドを 定義していない からです。そのメソッドと修正方法については次回の記事で説明します。
次回の記事では ListBoard の処理速度の改善と、定義していない必要なメソッドについて説明します。余裕がある方はその方法について考えてみて下さい。
今回の記事のまとめ
今回の記事では ポリモーフィズム について説明し、必要なメソッドを定義 した 2 次元の list と 1 次元の list で ゲーム盤を表現するクラスを定義 することで、Marubatsu クラスの プログラムを変更せず に、異なるデータ構造のゲーム盤で処理を行うことができる という ポリモーフィズムの利点 を紹介しました。ただし、今回行った実装にはいくつかの問題があることがわかりましたので、次回の記事ではその修正を行う予定です。
本記事で入力したプログラム
リンク | 説明 |
---|---|
marubatsu.ipynb | 本記事で入力して実行した JupyterLab のファイル |
marubatsu.py | 本記事で更新した marubatsu_new.py |
次回の記事
-
__iter__
については必要があれば今後の記事で紹介します。シーケンスについては次回の記事で説明し、__getitem__
については今回の記事で紹介します ↩ -
__str__
メソッドが定義されていない場合は代わりに<__main__.Mylist object at 0x00000188459C4510>
のような、そのオブジェクトの作成元のクラス名が表示されます ↩ -
電池のアダプターを知らない方は「電池 アダプター」で検索してみて下さい ↩
-
__getitem__
メソッドが定義されていない場合に添字を記述するとエラーが発生します ↩ -
print
で list の内容を表示するためには__str__
メソッドを定義する必要があります ↩ -
1 つの添字で座標を表す方法がありますが、その場合は
board[x][y]
とは異なる方法で (x, y) のマスを記述する必要があるため、Marubatsu クラスのプログラムを修正する必要が生じます。その方法については次回の記事で紹介します ↩ -
**
はべき乗を計算する演算子で、a ** 2
は $a ^ 2$ を計算します ↩