0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで〇×ゲームのAIを一から作成する その93 継承を利用したGUIの定義

Last updated at Posted at 2024-06-27

目次と前回の記事

これまでに作成したモジュール

以下のリンクから、これまでに作成したモジュールを見ることができます。

ルールベースの AI の一覧

ルールベースの AI の一覧については、下記の記事を参照して下さい。

ゲーム木を視覚化する GUI の作成

前回の記事で、ゲーム木の視覚化を行うために必要となる、特定のノードを指定して、下記のような部分木を描画するように draw_tree メソッドを修正しました。

  • ルートノードからそのノードの局面までの推移を表す、祖先ノードを描画する
  • 親ノードの子ノードを描画する
  • 特定の深さまで子孫ノードを描画する

今回の記事では、上記の処理を使って ゲーム木の視覚化を行う GUI の作成を開始することにします。そのためには、どのような GUI でゲーム木の視覚化を行うかを決める必要があります。その方法について少し考えてみて下さい。

ゲーム木を視覚化する GUI の仕様

draw_tree メソッドは、特定のノードを指定 し、その 周辺のノードの部分木を描画 する処理を行います。従って、draw_tree を使ってゲーム木を視覚化する GUI では、その特定のノードを変更 することで、描画する部分木を移動 できるようにする必要があります。

具体的には、draw_tree を描画する際に指定した下図の 赤丸のノード を、GUI の操作 によって 別のノードに移動して draw_tree で描画 することで、描画するゲーム木の 部分木の範囲を自由に変更できる ようにします。

その際に、遠くのノードに移動 すると、部分木の 描画が大きく変わってしまう ため、見た目が 直観的にわかりづらくなる ので、移動は 近いノード に対して行ったほうが良いでしょう。そこで、本記事では下記の 4 種類の移動を GUI で行えるようにすることにします。

  • 親ノードへ移動
  • 一つ前の兄弟ノードへ移動
  • 一つ後の兄弟ノードへ移動
  • 子孫ノードの中の先頭のノードへ移動

また、それぞれの移動は、〇×ゲームの GUI で手数を移動する GUI と同様に、ボタンとキーによって行う ことにします。ボタンに表示する文字列とキーは、draw_tree が下記のように部分木の描画を行うので、下記の表のように設定することにします。

  • 左から右に、深さ 0 のルートノードから浅い順にノードを描画する
  • 兄弟ノードは、上から下に順番に描画する
ボタンの表示と対応するカーソルキー
親ノードへ移動
一つ前の兄弟ノードへ移動
一つ後の兄弟ノードへ移動
子孫ノードの中の先頭のノードへ移動

Mbtree_GUI の定義

ゲーム木を視覚化する GUI は、〇×ゲームを遊ぶための GUI を実装した際に定義した Marubatsu_GUI クラスと同様の方法 で実装することができます。そこで、ゲーム木を GUI で視覚化するための Mbtree_GUI というクラスを定義して実装する ことにします。

下記は、Marubatsu_GUI クラスの __init__ メソッドの定義です。

 1  class Marubatsu_GUI:
 2      def __init__(self, mb, ai_dict=None, seed=None, size=3):
 3          # JupyterLab からファイルダイアログを開く際に必要な前処理
 4          root = Tk()
 5          root.withdraw()
 6          root.call('wm', 'attributes', '.', '-topmost', True)  
 7
 8          # save フォルダが存在しない場合は作成する
 9          if not os.path.exists("save"):
10               os.mkdir("save")        
11        
12          # ai_dict が None の場合は、空の list で置き換える
13          if ai_dict is None:
14              ai_dict = {}
15
16          self.mb = mb
17          self.ai_dict = ai_dict
18          self.seed = seed
19          self.size = size
20
21          # ai_dict が None の場合は、空の list で置き換える
22          if ai_dict is None:
23              self.ai_dict = {}
24
25          # %matplotlib widget のマジックコマンドを実行する
26          get_ipython().run_line_magic('matplotlib', 'widget')
27        
28          self.disable_shortcutkeys()
29          self.create_widgets()
30          self.create_event_handler()
31          self.display_widgets() 
他のメソッドの定義は省略

上記のプログラムのうち Mbtree_GUI必要な処理必要でない処理 は以下の通りです。

  • 必要 な処理

    • 26 行目:GUI で maplotlib の Figure を表示する場合は %matplotlib widget のマジックコマンドを実行する必要がある
    • 28 ~ 31 行目:デフォルトのショートカットキーの禁止、ウィジェットの作成、イベントハンドラの定義、ウィジェットの表示に関する処理は、Mbtree_GUI でも必要
  • 必要でない 処理

    • 4 ~ 10 行目Mbtree_GUI はファイルを扱わない
    • 13 ~ 23 行目:〇×ゲームの GUI では必要だが、Mbtree_GUI では不必要

上記から、Mbtree_GUI__init__ メソッドを下記のように定義する方法が考えられます。なお、他の処理に関しては、今後の記事で実装を行います。

class Mbtree_GUI:
    def __init__(self):       
        # %matplotlib widget のマジックコマンドを実行する
        get_ipython().run_line_magic('matplotlib', 'widget')
        
        self.disable_shortcutkeys()
        self.create_widgets()
        self.create_event_handler()
        self.display_widgets() 

しかし、これまで何度も説明したように、上記の 全く同じ処理 を Marubatsu_GUI と Mbtree_GUI の 複数の個所に記述しないほうが良い でしょう。また、上記の処理は、ウィジェットを利用して GUI を作成する際に必須 となる処理なので、別の GUI を作成 しようと思ってクラスを定義する際も、上記と 同じ処理を記述する必要 があります。そのため、上記の処理は何らかの方法で 1 箇所にまとめて記述したほうが良いでしょう。

複数のクラス共通する処理をまとめる方法 に、委譲継承 があります。今回の記事では、委譲と継承について説明し、継承を使って GUI のクラスを定義する事にします。

委譲(composition)

委譲 とは、クラスに新たな機能を追加 する際に、その 新たな機能の処理を行う別のクラスを定義 し、そのクラスの インスタンスを使って機能の追加を行う というものです。あるクラスで行う処理を、他のクラスに委譲(委任)することからそのように呼ばれます。

composition を集約、合成と呼び、委譲をデリゲーション(delegation)と呼ぶ場合もあり、それぞれの意味も本記事で説明したものと若干異なる場合があるようですが、本記事では委譲という用語だけを、上記の意味で使うことにします。

実は、委譲は 以前の記事 で Marubatsu クラスの play メソッドから GUI の機能を分離 する際に、下記のような方法で既に行っています。

  • 〇×ゲームの GUI の機能だけを扱う、Marubatsu_GUI クラスを定義する
  • Marubatsu クラスの play メソッドの中で mb_gui = Marubatsu_GUI(略) を記述して Marubatsu_GUI クラスの インスタンスを作成 し、その メソッドを呼び出す ことで 〇×ゲームの GUI の処理を行う

一般的に、クラスの 機能を増やすと、そのクラスは 複雑になります。その結果、バグが発生しやすくなったり、クラスの修正を行うことが困難になります。委譲を行うことで、クラスの機能を拡張する際に、新しい機能を別のクラスで定義することになるので、元のクラスの複雑化を緩和することができるようになります。

委譲はイメージ的には、機械に機能を追加する際に、その機械の設計図に新しい機能を書き加えるのではなく、その機能を持つ 部品の設計図を作成 し、その設計図から作られた 部品を機械に組み込む ということに相当するでしょう。

また、委譲を行うことで、新しい機能の処理 が、新しいクラスの定義の中でまとまって記述される ので、プログラムがわかりやすくなるという利点もあります。例えば、Marubatsu クラスでは、最初は play メソッド内に GUI の機能を記述していましたが、その場合は、Marubatsu クラスの定義の中のどの部分に GUI の処理が記述されているかを、プログラムの作者以外の人1が見つけることは簡単ではないでしょう。一方、Marubatsu_GUI クラスを定義する事で、GUI の処理がどこに記述されているかがわかりやすくなります。

ただし、クラスに新しい機能を追加する際に、常に委譲を行えば良いというわけではありません。委譲を行いすぎると今度は小さなクラスが増えすぎてプログラムがかえってわかりづらくなる場合があるので バランスが重要 ですが、そのバランス感覚は様々なプログラムを実際に実装することでしか身に付けることはできません。委譲は今後の記事でも利用することになるので、その際にまた説明を行う予定です。

継承(inheritance)

クラスの 継承 は、あるクラスが、別のクラスの属性やメソッドなどを利用できるようにするための仕組みです。継承元 のクラスを 基底(base)クラス、継承先 のクラスを 派生 (derived)クラスと呼びます。

なお、基底クラスと派生クラスには、下記のような呼び方もありますが、本記事では Python のドキュメントに倣って、基底クラスと派生クラスという用語を使うことにします。

別の呼び方
基底クラス 親(parent)クラス、スーパー(super)クラス
派生クラス 子(child)クラス、サブ(sub)クラス

クラスの継承の詳細については、下記のリンク先を参照して下さい。

継承の説明に関する補足

継承の説明を行う前に、いくつかの補足説明を行います。

プログラム言語によってクラスの継承 が具体的にどのように行われるかが 異なる場合があります。また、クラスの継承の使い方 に関しては、様々な意見があり、統一されているとは言えない状況にある と思います。そのため、継承に関する説明は、書籍や記事によってかなり異なる場合が多いようです。

正直に言いますが、筆者も継承を完璧に使いこなすことができる自信はありません。従って、この記事での継承の説明は、Python での継承の使い方 に関して、筆者なりの理解を説明したもの だと考えて下さい。

継承の利用目的

継承の利用目的の一つに、複数のクラスで共通する処理を基底クラスに定義することで、プログラムの重複を解消する というものがあり、昔はこの目的で継承が使われていたことが多かったようですが、現在では そのような使い方は弊害が多いため一般的には 推奨されなくなっています。そのような場合は、先程説明した 委譲を使うことが推奨 されています。

継承によって 弊害が起きる原因 の一つは、継承は委譲と異なり、派生クラスが 基底クラスの性質をすべて受け継ぐ ことになるため、派生クラス は基底クラスを 複雑にしたものになる からです。そのため、共通の性質を持つという理由だけで継承を乱用してしまうと、クラスの構成が非常に複雑になってしまい、保守性の低いプログラムになってしまいます。

もう一つの利用目的は、共通の性質を持つもの別々のクラスで定義 する際に、それらの 共通の性質 を表すクラスを 基底クラスとして定義 し、その基底クラスから 継承を行うことで個々のクラスを定義する というものです。そのようにすることで、同じ基底クラスから 派生した様々なクラス を、共通した方法で扱うことができる ようになります。

例えば、様々な図形を扱うことができるお絵かきソフトを作成する場合のことを考えてみて下さい。図形には共通する性質として、枠の太さ、枠の色、塗りつぶしの色などがあります。また、お絵かきソフトは図形を画面に描画する必要があります。そこで、下記のような Figure という名前のクラスを基底クラスとして定義します。

  • 枠の太さ、枠の色、塗りつぶしの色などを表す属性を持つ
  • 図形を描画するメソッドを持つ

そして、三角形、四角形などの、そのお絵かきソフトで扱うことができる様々な図形を扱うクラスを、Figure を基底クラスとする派生クラスとして定義することで、すべての図形が共通する属性と、図形を描画する共通のメソッドを利用できるようになるという利点が得られます。なお、この説明では意味がわからない人が多いと思いますが、言葉の説明だけでこの利点を理解するのはほぼ不可能だと思います。現時点ではそういうものだという、雰囲気だけを理解して、後で意味がわかるようになってから復習すればよいでしょう。

クラスの継承は、オブジェクト指向プログラミングを特徴づけるものの一つで、うまく利用すると非常に便利ですが、乱用するとプログラムがかえって複雑になったり、プログラムの修正が思わぬ悪影響を与えてしまうことがあります。そのため、クラスの継承はなるべく行うべきではないという人もいるようです。

例えば、上記で説明した、現在推奨されていない目的での継承は、継承の使い方を学べば誰でも簡単に記述できてしまうため、初心者が乱用しがちです。その結果、良くないプログラムを記述する悪い癖がついてしまうため、継承を嫌う人がいます。

ただし、継承をうまく使えば便利であることは間違いはありませんし、モジュールの中には、継承の利用を前提とするようなものもあるので、継承の使い方を学んでおく必要はあると思います。

そこで、本記事では継承を行って GUI の処理を行うクラスを定義する事にします。

初心者の方は、どのような場合にクラスの継承を利用すべきであるかの判断は難しいと思いますが、何でもかんでもクラスの継承を使えば良いというわけではない事は覚えておいてください。例えば、ちょっとした共通点だけを見つけた時に、クラスの継承の機能を行うのは避けたほうが良いでしょう。

完璧な指針ではありませんが、どのような場合に継承を行うべきかの指針はあるので、それについてはこの後で説明します。

クラスの継承と is-a、has-a 関係

クラスの継承を行うべきか どうかを見分ける 指針 の一つに is-ahas-a 関係があります。

is-a 関係

is-a 関係は、A is a B2、すなわち A は B(の一種)である という関係で、クラス A、B に この関係がない場合 はクラスの 継承を行うべきではない という指針があります。

例えば、三角形図形 は、三角形は図形(の一種)である という関係があるので、三角形を表す Triangle と、図形を表す Figure というクラスを定義した場合は、Figure を基底クラス、Triangle を派生クラスとしてクラスの継承を行っても良い3ということになります。

クラスが A is a B という is-a の関係であれば、共通する性質を基底クラスにまとめることで、継承によって共通の性質を持つ派生クラスを多数記述できるという利点が得られます。

なお、A は B の中の一つであることから、A を B の 特化 と呼びます。また、B は A を一般化したものであると考えることができるので B を A の 汎化 と呼びます。

has-a 関係

is-a 関係と似た関係に has-a 関係というものがあります。A has a B という関係は、「A は B(の機能)を持っている」、「A には B がある」という 機能や特性 の関係です。

例えば「車にはドアがある」、「車にはタイヤがある」、「車は運転ができる」などは has-a の関係で、このような関係の事を 包含関係 と呼びます。

「ドアは車である」という文は間違っているので、車とドアには has-a の関係はありますが、is-a の関係はありません。このような場合は、車にはドアという共通の要素があるからと言って、ドアを表す Door を基底クラス、車を表す Car を派生クラスとしてクラスの継承を 行うべきではなく、先程説明した 委譲を使うべき です。

残念ながら、is-a や has-a の関係を持っているかどうかを判別しづらい場合があったり、一見すると is-a の関係を持っている継承を行うべきではない場合がある のでややこしいのですが、原則 として、is-a の関係を持たないクラス は、一般的にはクラスの継承を行うべきではない と考えると良いでしょう。

委譲と継承の違い

クラスの継承と委譲の主な違いは以下の通りです。

  • 継承は 基底クラスを拡張 するという形で新たな機能を付け加えた派生クラスを作成する。派生クラスは 基底クラスの全ての属性とメソッドを利用できる
    is-a の関係にない場合は継承を利用すべきではない
  • 委譲は付け加えたい機能を処理する 別のクラスを定義 し、そのクラスの インスタンスを作成して利用する という形でクラスに新しい機能を付け加える。定義した別のクラスは独立しているので、元のクラスの機能 を直接 利用することはできない
    has-a の関係にある場合は委譲を利用すべきである

Marubatsu_GUI は、〇×ゲームの GUI の機能の処理を行うクラスで、Marubatsu は 〇×ゲームの処理を行うクラスです。「〇×ゲームの GUI は、〇×ゲームである」も、「〇×ゲームは、〇×ゲームの GUI である」も 成り立たない ので、この 2 つのクラスの関係は is-a でありません。一方、〇×ゲームの GUI は、〇×ゲームを遊ぶ際の 機能の一つ なので、has-a の関係にあります。このような場合は、継承ではなく、委譲を使うべき で、実際に本記事でも委譲を使って、Marubatsu クラスに GUI の機能を追加しました。

クラスの継承の記述方法

クラスの継承の概要を説明したので、次は具体的な記述方法について説明します。

クラスの継承を行った派生クラスは、下記のプログラムのように定義します。

class 派生クラス名(基底クラス名):
    # 以下、派生クラスの定義を記述する

上記のように記述することで、継承クラスは、基底クラスのすべての属性やメソッドを利用することができるようになります。

Python では、複数のクラスを同時に継承する派生クラスを定義することができ、そのような場合の事を 多重継承 と呼びます。ただし、多重継承は、基底クラスに同じ属性やメソッドが定義されていた場合、どちらを派生クラスで利用できるかがわかりづらくなるという大きな問題があるので、あまり好まれてはいないようです。

本記事でも、どうしても必要になる場合を除いて多重継承は利用しません。

これだけではピンとこないと思いますので、具体例を示します。

まず、継承を行うために必要となる、基底クラスとなるクラス A を定義します。クラス A は、__init__ メソッドで xy という属性に 1 と 2 を代入し、属性 x を表示する printx というメソッドを持ちます。従って、下記のプログラムの 9、10 行目のように クラス A のインスタンスを作成し、printx を呼び出すと、実行結果のように 1 が表示されます。

 1  class A:
 2      def __init__(self):
 3          self.x = 1
 4          self.y = 2
 5          
 6      def printx(self):
 7          print(self.x)
 8
 9  a = A()
10  a.printx()
行番号のないプログラム
class A:
    def __init__(self):
        self.x = 1
        self.y = 2
        
    def printx(self):
        print(self.x)

a = A()
a.printx()

実行結果

1

下記のプログラムは、A を基底クラス とする B という派生クラスを定義 するプログラムです。クラス B は クラス A を継承 しているので、クラス A の属性とメソッドをすべて利用することができます。そのため、5 、6 行目のように、クラス B のインスタンスを作成し、printx を呼び出すと、実行結果のように 1 が表示されます。また、7 行目のように 属性 x を直接 print で表示することもできます。

また、クラス B には クラス A の属性 y を表示する printy メソッドが定義されているので、8 行目のように printy を呼び出すと、実行結果のように 2 が表示されます。

1  class B(A):
2      def printy(self):
3          print(self.y)
4     
5  b = B()
6  b.printx()
7  print(b.x)
8  b.printy()
行番号のないプログラム
class B(A):
    def printy(self):
        print(self.y)
        
b = B()
b.printx()
print(b.x)
b.printy()

実行結果

1
1
2

なお、継承元 となった クラス A のインスタンス は、派生先の クラス B で定義された メソッドを利用することはできません。下記のプログラムのように クラス B で定義された printya に対して呼び出そうとすると実行結果のようなエラーが発生します。

a.printy()

実行結果

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[3], line 1
----> 1 a.printy()

AttributeError: 'A' object has no attribute 'printy'

クラスの継承によって派生クラスのインスタンスは、基底クラスの属性とメソッドを利用できるようになるが、基底クラスのインスタンスが派生クラスのインスタンスを利用することはできない。

つまり、クラスの継承は、基底クラスから派生クラスへの一方通行 である。

メソッドのオーバーライド(override)

派生クラス には、基底クラス で定義されたメソッドと 同じ名前のメソッドを定義 することができます。その場合に派生クラスのインスタンスからそのメソッドを呼び出すと、派生クラスで定義されたメソッドが呼び出される ようになります。

このように、派生クラスで基底クラスと 同じ名前のメソッドを定義 することを、メソッドの オーバーライド(override)、または上書きと呼びます。

例えば、下記のプログラムのように、基底クラスである A が持つ printx と同じ名前のメソッドをクラス B に定義することができます。なお、クラス A が持つ printx 区別できるように 2 行目でメッセージを表示するようにしてみました。

def printx(self):
    print("printx of class B")
    print(self.x)

B.printx = printx

上記のプログラムを実行した後で、下記のプログラムのように、クラス B のインスタンスから printx を呼び出すと、実行結果にクラス B の printx で表示するメッセージが表示されることから、クラス B の printx が呼び出されている ことがわかります。

b.printx()

実行結果

printx of class B
1

また、下記のプログラムのように、クラス A のインスタンスから printx を呼び出すと、実行結果から、クラス A の printx が呼び出されてることがわかります。

a.printx()

実行結果

1

オーバーライドの目的と基底クラスのメソッドの呼び出し

メソッドをオーバーライドすることの 目的 には、以下のようなものがあります。

  • そのメソッドで、基底クラスのメソッドと 全く異なる処理を行いたい 場合
  • そのメソッドで、基底クラスのメソッドで 行った処理に加えて、別の処理を付け加える という、メソッドの機能の拡張 を行いたい場合

前者の場合は単にオーバーライドすればよいだけですが、後者の場合は、基底クラスのメソッドを呼び出す 必要があります。そのような場合は、下記のいずれかの方法で基底クラスのメソッドを呼び出すことができるようになります。

  • 基底クラスの名前.メソッド名(self, 実引数) を記述する
  • super という組み込み関数を使って、super().メソッド名(実引数) を記述する

「基底クラスの名前を記述しなくても良い」、「実引数に self を記述しなくても良い」などの理由で、super のほうが良く使われている と思います。

組み込み関数 super の詳細については、下記のリンク先を参照して下さい。

具体例を挙げます。

先程 B というクラスで定義した printx は、メッセージを表示してから属性 x の内容を表示しましたが、属性 x の内容を表示する処理 は、基底クラスである A の printx で実装済 です。従って、クラス B の printx には、下記の処理を記述することで、基底クラス A の printx の機能を利用 しながら 先程と同じ処理を行う ことができます。

  • メッセージを表示する
  • super().printx() によって、基底クラスの printx を呼び出して属性 x の値を表示する

2024/09/05 追記

superクラスの定義の外で記述されたメソッドで利用できる ことが判明しました。詳細は こちらの記事 を参照して下さい。なお、super に関する内容を記事をさかのぼってすべて修正するのは大変なので、そのまま残しておくことにします。

ただし、super は、クラスの定義の中で記述されたメソッドの中でしか利用できない ので、下記のような、通常の関数としてメソッドを定義し、その関数をクラスの属性に代入するという方法は利用できない点に注意が必要です。

def printx(self):
    print("printx of class B")
    super().printx()

B.printx = printx

上記のプログラムを VSCode で記述すると、下図のように super の部分にエラーを表す赤い波線が表示されます。

上記のプログラムを実行してもエラーは発生しませんが、下記のプログラムを実行して printx を呼び出すと、super の処理で実行結果のようなエラーが発生します。

b.printx()

実行結果

---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[8], line 1
----> 1 b.printx()

Cell In[7], line 3
      1 def printx(self):
      2     print("printx of class B")
----> 3     super().printx()

RuntimeError: super(): __class__ cell not found

super を利用するためには、super が記述されたメソッドがどのクラスで定義されているかの情報が必要になりますが、通常の関数として printx を定義した場合は、その情報を得ることができません。class cell not found というエラーメッセージは、上記の printx がどのクラスで定義されたメソッドであるかの情報を得ることができなかったという意味を表します。

従って、正しくは下記のプログラムのように クラスの定義の中で printx を記述します。

class B(A):
    def printx(self):
        print("printx of class B")
        super().printx()

クラス B を定義し直した ので、下記のプログラムの 1 行目のように、クラス B のインスタンスを作成し直す 必要があります。2 行目で printx を呼び出すと、実行結果のようにメッセージを表示した後に、基底クラス A の printx が呼び出されて 1 が表示されます。

b = B()
b.printx()

実行結果

printx of class B
1

super は、super が記述されたメソッドが定義されたクラスの 基底クラスを探す という処理を行いますが、super を使わずに、下記のプログラムのように、基底クラスの名前と実引数 self を記述した場合は、基底クラスを探す必要がないので、通常の関数として printx を定義しても、実行結果のようにエラーは発生しません。

def printx(self):
    print("printx of class B")
    A.printx(self)

B.printx = printx

b.printx()

実行結果

printx of class B
1

仮想関数

派生クラスオーバーライドできるメソッド の事を、仮想関数(virtual function)と呼びます。プログラム言語によっては、基底クラスで定義したメソッドが、派生クラスでオーバーライドできるか どうかを 設定できる 場合があります。そのようなプログラム言語では、仮想関数でないメソッドを、派生クラスでオーバーライドしようとするとエラーが発生します。ただし、Python ではすべてのメソッドが派生クラスで必ずオーバーライドできるので、すべてのメソッドは仮想関数である と言えます。

抽象メソッドと抽象クラス

is-a の関係 にあるクラスを継承する場合は、同じ基底クラスから派生した様々なクラス を、共通した方法で扱う ことができるようにすると 便利 です。

具体的には、基底クラス で定義した属性やメソッドと 同じ名前の属性やメソッド派生クラスで利用する ようにします。そのようにすることで、同じ基底クラスから派生したクラスを、共通した方法で扱う ことができるようになるという 大きな利点 が得られます。

具体例を挙げます。先ほどの例で挙げた、様々な図形を扱う ことができるお絵かきソフトを作成する場合は、図形を表す Figure という 基底クラス に、図形を描画 する draw という 仮想関数を定義 します。ただし、図形の種類によって描画の方法が完全に異なるので、基底クラスの draw に図形を描画する処理を記述することは不可能です。そこで、下記のプログラムのように、Figure の draw何の処理も行わないメソッドとして定義 します。

class Figure:
    def draw(self):
        pass

上記の draw のように、派生クラスでオーバーライドすることを前提として定義 された、処理が定義されていない仮想関数を、抽象メソッド、純粋(完全)仮想関数などと呼びます。

また、抽象メソッドが定義されたクラス の事を、抽象クラス、抽象クラスから派生した、具体的な処理を実装 するクラスの事を 具象クラス と呼びます。

上記の draw メソッドは、何もしないという処理が定義されている ので、実は純粋な 抽象メソッドではありません。また、Python というプログラム言語は、本当の意味で処理が定義されていない、厳密な 抽象メソッドを定義する機能を持ちません

同様に、Python は 抽象クラスを定義する機能を持ちません。これらのことは、この後で説明します。

実際の描画を行う処理は、例えば Figure を継承 した 三角形を表す Triangle というクラスの中に、下記のプログラムのように draw をオーバーライドすることで定義 します。

class Triangle(Figure):
    def draw(self):
        # 三角形を描画する処理をここに記述する

このように、基底クラスの中 で、派生クラスで利用する メソッドとなる 抽象メソッドを定義 し、派生クラスで具体的な処理を行うメソッドを定義 して オーバーライドする という方法でプログラムを記述することで、基底クラス によって、派生クラスに共通する操作環境(インターフェース)を 決める ことができるようになります。

抽象クラスによって、具象クラスの操作環境が決められるので、そのようなクラスの事を インターフェース と呼びます。

抽象メソッドの機能を持つプログラム言語での抽象メソッドの性質

抽象メソッドを定義する機能を持つプログラム言語の多くでは、抽象メソッドは 具体的な処理が定義されていないメソッド なので、抽象メソッドを 呼び出して実行することはできません。そのため、そのようなプログラム言語では、抽象メソッドが含まれる、抽象クラスからインスタンスを作成することができない ようになっています。

また、そのことは 抽象クラスから派生したクラスでも同様 で、すべての基底クラスの抽象メソッド をオーバーライドして 具体的な処理を定義する 事ではじめてインスタンスを作成できるようになっています。

このように、派生クラスで抽象メソッドのオーバーライドを強制 することで、派生クラスでの 共通のインターフェースを保証 することができます。

Python でのメソッドの性質

しかし、先程ノートで説明したように、Python には、抽象メソッド抽象クラス定義する機能を持ちません。下記のプログラムの、一見すると抽象メソッドのように見える draw というメソッドは、何も行わないという処理が定義されている ため 抽象メソッドではありません。従って、Figure は抽象クラスではないので 5 行目のようにインスタンスを作成することができ、6 行目のように draw を呼び出して実行しても、実行結果のように エラーは発生しません

1  class Figure:
2      def draw(self):
3          pass
4
5  f = Figure()
6  f.draw()
行番号のないプログラム
class Figure:
    def draw(self):
        pass

f = Figure()
f.draw()

実行結果

また、Python には、抽象メソッドの仕組みがないので、基底クラスで定義されたメソッドを、派生クラスで オーバーライドしなくてもエラーが発生しません。そのため、例えば、下記のプログラムのように、Figure を継承した Triangle というクラスに、draw とは別の名前 で、図形を描画するメソッドを定義してもエラーは発生しません。

また、下記のような、基底クラスの メソッドと異なる名前 でそのメソッドが行う処理を定義すると、基底クラスから派生したクラスに 共通する操作環境が得られなくなってしまいます。例えば、同じ Figure クラスから派生したクラスの図形を描画するメソッドが、クラスによって draw_triangle だったり、draw_circle だったりすると非常に不便です。

class Triangle(Figure):
    def draw_triangle(self):
        # 三角形を描画する処理をここに記述する

Python での抽象メソッド、抽象クラスの機能の利用方法

このままでは不便なので、Python には abc という 抽象メソッドと抽象クラスとほぼ同様の機能を利用できるようにする ための組み込みモジュールが用意されています。この機能を利用することで、他のプログラム言語の抽象メソッドと同様に、派生クラスでオーバーライドしないとエラーが発生するようなメソッドを定義することができるようになります。

なお、「抽象メソッドと同様の」という表現は冗長なので、以後はその方法で定義されたメソッドとクラスを、抽象メソッド、抽象クラスのように表記します。

abc は抽象基底クラスを表す abstract base class の略です。

具体的には、以下のような方法で抽象メソッドと抽象クラスを定義します。

  • abc というモジュールから ABCMetaabstractmethod をインポートする
  • 抽象クラスとして定義 するクラスを定義する際に、(metaclass=ABCMeta) を記述する
  • 抽象メソッドとして定義 するメソッドの 定義の直前に、@abstractmethod という デコレータを記述 する。実際のメソッドの定義は何でもよいので記述する必要がある

下記は、具体的な記述例です。

from abc import ABCMeta, abstractmethod

class Figure(metaclass=ABCMeta):
    @abstractmethod
    def draw(self):
        pass

なお、Figure(metaclass=ABCMeta) の部分は、Python のメタクラスという機能を使った記述ですが、抽象メソッドの機能を利用する際に、メタクラスの意味を正しく理解する必要はないでしょう。正直に言うと、筆者はメタクラスを理解していません。興味がある方は下記のリンク先を参照して下さい。

上記の定義後に、下記のプログラムのように、Figure を基底クラスとする Triangle クラスを、draw メソッドを定義せずに記述し、Triangle クラスのインスタンスを作成すると、実行結果のようなエラーが発生します。

class Triangle(Figure):
    pass

t = Triangle()

実行結果

---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[14], line 4
      1 class Triangle(Figure):
      2     pass
----> 4 t = Triangle()

TypeError: Can't instantiate abstract class Triangle with abstract method draw

上記のエラーメッセージは、抽象メソッド(abstract method)である draw がオーバーライドされていないため、Triangle クラスが抽象クラス(abstract class)になっており、クラスのインスタンス化が行えなかったことを意味します。

下記のプログラムのように、Triangle クラスに draw メソッドを定義してオーバーライド すれば、上記の エラーは発生しなくなります。なお、どのような形であれ、オーバライドを行えば良いので、下記のプログラムのように、何の処理も行わない draw メソッドの定義でも問題はありません。また、Triangle クラスには __init__ メソッドを定義していないので、インスタンス化を行った際には何の処理も行われず、実行結果には何も表示されません。

class Triangle(Figure):
    def draw(self):
        pass

t = Triangle()

実行結果

abc モジュールの詳細については、下記のリンク先を参照して下さい。

クラスの継承を利用した Marubatsu_GUI クラスの定義

GUI の処理を、クラスの継承を使って記述するための知識を説明したので、今回の記事では Marbatsu_GUI クラスを、クラスの継承を使って定義し直す ことにします。なお、大変だと思う人がいるかもしれませんが、実際に行う作業はあまり多くはありません。

まず、基底クラス として、GUI に関する 共通の処理 と、派生クラスで必要となる 抽象メソッドなどの定義 を行う、GUI という名前の クラスを定義 する事にします。Marubatsu_GUI が行う処理は、GUI の一種であるため、Marubatsu_GUI クラスと GUI クラスは is-a の関係にある ため、クラスの継承を行っても構わないでしょう。

GUI クラスには、すべての GUI で共通する処理 を記述するので、Marubatsu_GUI の定義から、すべての GUI で共通する処理を抜き出して記述する ことで定義することができます。

GUI クラスの定義

下記は、GUI クラスの定義で、以下の方針でそれぞれのメソッドを定義しました。

  • class GUI(metaclass=ABCMeta) を記述することで、抽象メソッドを持つ、抽象クラスとして GUI クラスを定義できるようにする
  • __init__ メソッドには、GUI で必要な処理だけを抜き出して記述 する
  • create_widgetscreate_event_handlerdisplay_widgetsupdate_guiupdate_widgets_status は、いずれも GUI の処理で共通する必須となる処理 だが、GUI の種類によって行う処理が異なるので、何も処理を行わない 抽象メソッド として、@abstractmethod を記述して定義 する
  • disable_shortcutkeyscreate_buttonset_button_status は、GUI の処理を行う上で、どの GUI でも 同じ処理を行う、あると便利なメソッド なので定義した。具体的な処理を記述 するので、抽象メソッドではなく、通常のメソッドとして定義 する
  • draw_boarddraw_mark など、〇× ゲームの GUI に特化したメソッドは定義しない

なお、disable_shortcutkeyscreate_buttonset_button_status は、Figure でキー入力操作を行わない場合や、ボタンを表示しない場合などでは必要がない処理なので、GUI に必須となる処理ではありません。そのため、基底クラスに定義するのではなく、別のクラスで定義して委譲を使ったほうが良い と考える人がいるかもしれません。筆者もその点については迷ったのですが、今回は基底クラスに組み込むことにしました。委譲の方が良いと思った方は、別のクラスを定義して委譲するように修正して下さい。

なお、下記のプログラムは、JupyterLab のセルではなく、gui.py に記述しました。util.py に記述しようと最初は思っていたのですが、util.py に記述すると marubatsu.py から GUI クラスをインポートした時に循環インポートが発生してしまうので、gui.py に保存しました。

from abc import ABCMeta, abstractmethod
import matplotlib as mlp
import ipywidgets as widgets

class GUI(metaclass=ABCMeta):
    def __init__(self):
        # %matplotlib widget のマジックコマンドを実行する
        get_ipython().run_line_magic('matplotlib', 'widget')
        
        self.disable_shortcutkeys()
        self.create_widgets()
        self.create_event_handler()
        self.display_widgets() 
   
    @abstractmethod
    def create_widgets(self):
            pass
    
    @abstractmethod
    def create_event_handler(self):
        pass
    
    @abstractmethod
    def display_widgets(self):
        pass

    @abstractmethod
    def update_gui(self):
        pass

    @abstractmethod
    def update_widgets_status(self):
        pass

    @staticmethod
    def disable_shortcutkeys():
        attrs = [ "fullscreen", "home", "back", "forward", "pan", "zoom", "save", "help",
                "quit", "quit_all", "grid", "grid_minor", "yscale", "xscale", "copy"]
        for attr in attrs:
            mlp.rcParams[f"keymap.{attr}"] = []     
            
    @staticmethod
    def create_button(description, width):   
        return widgets.Button(
            description=description,
            layout=widgets.Layout(width=f"{width}px"),
            style={"button_color": "lightgreen"},
        )   
        
    @staticmethod
    def set_button_status(button, disabled):   
        button.disabled = disabled
        button.style.button_color = "lightgray" if disabled else "lightgreen"    

Marubatsu_GUI クラスの修正

次に、Marubatsu_GUI クラスを、GUI クラスを基底クラスとして、下記のプログラムのように定義し直します。具体的には、以下の修正を行いました。

  • class Marubatsu_GUI(GUI): と記述することで、GUI クラスを継承した、派生クラスとして定義 する
  • __init__ メソッドの最後で super().__init__() を記述することで、〇×ゲームの GUI の処理に必要な処理を行った後で、基底クラスである GUI クラスの __init__ メソッドの処理を実行 するようにする
  • disable_shortcutkeyscreate_buttonset_button_status は基底クラスで定義したので削除する

なお、基底クラスで抽象メソッドとして定義した update_widget_status などの関数は、特に変更する必要はありません。

なお、下記のプログラムは、JupyterLab ではなく、marubatsu_new.py に記述しました。

非常に長いのでクリックしてみて下さい。
class Marubatsu_GUI(GUI):
    """ 〇×ゲームの GUI.

    Attributes:
        インスタンス属性。mb 以外は play メソッドの仮引数と同じ
        mb:
            GUI を表示する Marubatsu ゲームのインスタンス
        ai (list):
            それぞれの手番を誰が担当するかを指定する list
        ai_dict (dict):
            Dropdown で選択できる AI の一覧を表す dict
        seed (int|None):
            乱数の種
        size (int):
            gui が True の場合に描画するゲーム盤の画像のサイズ
    """
    
    def __init__(self, mb, ai_dict=None, seed=None, size=3):
        """ GUI のウィジェットの作成などの初期設定を行う.
        
        Args:
            Marubatsu_GUI の属性と同じ
        """
        
        # JupyterLab からファイルダイアログを開く際に必要な前処理
        root = Tk()
        root.withdraw()
        root.call('wm', 'attributes', '.', '-topmost', True)  

        # save フォルダが存在しない場合は作成する
        if not os.path.exists("save"):
            os.mkdir("save")        
        
        # ai_dict が None の場合は、空の list で置き換える
        if ai_dict is None:
            ai_dict = {}

        self.mb = mb
        self.ai_dict = ai_dict
        self.seed = seed
        self.size = size

        # ai_dict が None の場合は、空の list で置き換える
        if ai_dict is None:
            self.ai_dict = {}
        
        super().__init__()
                   
    def create_dropdown(self):
        """AI を選択する Dropdown を作成する."""
 
        # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
        select_values = []
        # 〇 と × の Dropdown を格納する list
        self.dropdown_list = []
        # ai に代入されている内容を ai_dict に追加する
        for i in range(2):
            # ラベルと項目の値を計算する
            if self.mb.ai[i] is None:
                label = "人間"
                value = "人間"
            else:
                label = self.mb.ai[i].__name__        
                value = self.mb.ai[i]
            # value を select_values に常に登録する
            select_values.append(value)
            # value が ai_values に登録済かどうかを判定する
            if value not in self.ai_dict.values():
                # 項目を登録する
                self.ai_dict[label] = value
        
            # Dropdown の description を計算する
            description = "" if i == 0 else "×"
            self.dropdown_list.append(
                widgets.Dropdown(
                    options=self.ai_dict,
                    description=description,
                    layout=widgets.Layout(width="100px"),
                    style={"description_width": "20px"},
                    value=select_values[i],
                )
            )      
               
    def create_figure(self):
        """ゲーム盤を描画する Figure を作成する."""
        
        with plt.ioff():
            self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
        self.fig.canvas.toolbar_visible = False
        self.fig.canvas.header_visible = False
        self.fig.canvas.footer_visible = False
        self.fig.canvas.resizable = False     
        
    def create_widgets(self):
        """ウィジェットを作成する."""

        # 乱数の種の Checkbox と IntText を作成する
        self.checkbox = widgets.Checkbox(value=self.seed is not None, description="乱数の種",
                                        indent=False, layout=widgets.Layout(width="100px"))
        self.inttext = widgets.IntText(value=0 if self.seed is None else self.seed,
                                    layout=widgets.Layout(width="100px"))   

        # 読み書き、ヘルプのボタンを作成する
        self.load_button = self.create_button("開く", 80)
        self.save_button = self.create_button("保存", 80)
        self.help_button = self.create_button("", 30)
        
        # AI を選択する Dropdown を作成する
        self.create_dropdown()
        # 変更、リセット、待ったボタンを作成する
        self.change_button = self.create_button("変更", 50)
        self.reset_button = self.create_button("リセット", 80)
        self.undo_button = self.create_button("待った", 60)    
        
        # リプレイのボタンとスライダーを作成する
        self.first_button = self.create_button("<<", 50)
        self.prev_button = self.create_button("<", 50)
        self.next_button = self.create_button(">", 50)
        self.last_button = self.create_button(">>", 50)     
        self.slider = widgets.IntSlider(layout=widgets.Layout(width="200px"))
        # ゲーム盤の画像を表す figure を作成する
        self.create_figure()

        # print による文字列を表示する Output を作成する
        self.output = widgets.Output()         
    
    def display_widgets(self):
        """ ウィジェットを配置して表示する."""

        # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
        hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, 
                            self.save_button, self.help_button])
        # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
        hbox2 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
        # リプレイ機能のボタンを横に配置した HBox を作成する
        hbox3 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
        # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
        display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))    
            
    def update_widgets_status(self):
        """ウィジェットの状態を更新する."""
        
        self.inttext.disabled = not self.checkbox.value
        self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
        self.set_button_status(self.first_button, self.mb.move_count <= 0)
        self.set_button_status(self.prev_button, self.mb.move_count <= 0)
        self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
        self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)    
        # value 属性よりも先に max 属性に値を代入する必要がある点に注意!
        self.slider.max = len(self.mb.records) - 1
        self.slider.value = self.mb.move_count
       
    def create_event_handler(self):
        """イベントハンドラを定義し、ウィジェットに結び付ける."""
 
        # 乱数の種のチェックボックスのイベントハンドラを定義する
        def on_checkbox_changed(changed):
            self.update_widgets_status()
            
        self.checkbox.observe(on_checkbox_changed, names="value")

        # 開く、保存ボタンのイベントハンドラを定義する
        def on_load_button_clicked(b=None):
            path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                            initialdir="save")
            if path != "":
                with open(path, "rb") as f:
                    data = pickle.load(f)
                    self.mb.records = data["records"]
                    self.mb.ai = data["ai"]
                    change_step(data["move_count"])
                    for i in range(2):
                        value = "人間" if self.mb.ai[i] is None else self.mb.ai[i]
                        self.dropdown_list[i].value = value               
                    if data["seed"] is not None:                   
                        self.checkbox.value = True
                        self.inttext.value = data["seed"]
                    else:
                        self.checkbox.value = False
                        
        def on_save_button_clicked(b=None):
            name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
                    for i in range(2)]
            timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
            fname = f"{name[0]} VS {name[1]} {timestr}"
            path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                                initialdir="save", initialfile=fname,
                                                defaultextension="mbsav")
            if path != "":
                with open(path, "wb") as f:
                    data = {
                        "records": self.mb.records,
                        "move_count": self.mb.move_count,
                        "ai": self.mb.ai,
                        "seed": self.inttext.value if self.checkbox.value else None
                    }
                    pickle.dump(data, f)
                    
        def on_help_button_clicked(b=None):
            self.output.clear_output()
            with self.output:
                print("""操作説明

    マスの上でクリックすることで着手を行う。
    下記の GUI で操作を行うことができる。
    ()が記載されているものは、キー入力で同じ操作を行うことができることを意味する。
    なお、キー入力の操作は、ゲーム盤をクリックして選択状態にする必要がある。

    乱数の種\tチェックボックスを ON にすると、右のテキストボックスの乱数の種が適用される
    開く(-,L)\tファイルから対戦データを読み込む
    保存(+,S)\tファイルに対戦データを保存する
    ?(*,H)\t\tこの操作説明を表示する
    手番の担当\tメニューからそれぞれの手番の担当を選択する
    \t\tメニューから選択しただけでは担当は変更されず、変更またはリセットボタンによって担当が変更される
    変更\t\tゲームの途中で手番の担当を変更する
    リセット\t手番の担当を変更してゲームをリセットする
    待った(0)\t1つ前の自分の着手をキャンセルする
    <<(↑)\t\t最初の局面に移動する
    <(←)\t\t1手前の局面に移動する
    >(→)\t\t1手後の局面に移動する
    >>(↓)\t\t最後の着手が行われた局面に移動する
    スライダー\t現在の手数を表す。ドラッグすることで任意の手数へ移動する

    手数を移動した場合に、最後の着手が行われた局面でなければ、リプレイモードになる。
    リプレイモード中に着手を行うと、リプレイモードが解除され、その着手が最後の着手になる。""")
                
        self.load_button.on_click(on_load_button_clicked)
        self.save_button.on_click(on_save_button_clicked)
        self.help_button.on_click(on_help_button_clicked)
        
        # 変更ボタンのイベントハンドラを定義する
        def on_change_button_clicked(b):
            for i in range(2):
                self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
            self.mb.play_loop(self)

        # リセットボタンのイベントハンドラを定義する
        def on_reset_button_clicked(b=None):
            # 乱数の種のチェックボックスが ON の場合に、乱数の種の処理を行う
            if self.checkbox.value:
                random.seed(self.inttext.value)
            self.mb.restart()
            self.output.clear_output()
            on_change_button_clicked(b)

        # 待ったボタンのイベントハンドラを定義する
        def on_undo_button_clicked(b=None):
            if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
                self.mb.move_count -= 2
                self.mb.records = self.mb.records[0:self.mb.move_count+1]
                self.mb.change_step(self.mb.move_count)
                self.update_gui()
            
        # イベントハンドラをボタンに結びつける
        self.change_button.on_click(on_change_button_clicked)
        self.reset_button.on_click(on_reset_button_clicked)   
        self.undo_button.on_click(on_undo_button_clicked)   
        
        # step 手目の局面に移動する
        def change_step(step):
            self.mb.change_step(step)
            # 描画を更新する
            self.update_gui()        

        def on_first_button_clicked(b=None):
            change_step(0)

        def on_prev_button_clicked(b=None):
            change_step(self.mb.move_count - 1)

        def on_next_button_clicked(b=None):
            change_step(self.mb.move_count + 1)
            
        def on_last_button_clicked(b=None):
            change_step(len(self.mb.records) - 1)

        def on_slider_changed(changed):
            if self.mb.move_count != changed["new"]:
                change_step(changed["new"])
                
        self.first_button.on_click(on_first_button_clicked)
        self.prev_button.on_click(on_prev_button_clicked)
        self.next_button.on_click(on_next_button_clicked)
        self.last_button.on_click(on_last_button_clicked)
        self.slider.observe(on_slider_changed, names="value")
        
        # ゲーム盤の上でマウスを押した場合のイベントハンドラ
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.mb.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                with self.output:
                    self.mb.move(x, y)                
                # 次の手番の処理を行うメソッドを呼び出す
                self.mb.play_loop(self)

        # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
        def on_key_press(event):
            keymap = {
                "up": on_first_button_clicked,
                "left": on_prev_button_clicked,
                "right": on_next_button_clicked,
                "down": on_last_button_clicked,
                "0": on_undo_button_clicked,
                "enter": on_reset_button_clicked,            
                "-": on_load_button_clicked,            
                "l": on_load_button_clicked,            
                "+": on_save_button_clicked,            
                "s": on_save_button_clicked,            
                "*": on_help_button_clicked,            
                "h": on_help_button_clicked,            
            }
            if event.key in keymap:
                keymap[event.key]()
            else:
                try:
                    num = int(event.key) - 1
                    event.inaxes = True
                    event.xdata = num % 3
                    event.ydata = 2 - (num // 3)
                    on_mouse_down(event)
                except:
                    pass
                
        # fig の画像イベントハンドラを結び付ける
        self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
        self.fig.canvas.mpl_connect("key_press_event", on_key_press)        

    @staticmethod
    def draw_mark(ax, x:int, y:int, mark:str, color:str="black", lw:float=2):
        """マークを描画する.
        
        (x, y) のマスに、mark で指定したマークの画像を color で指定した色で描画する
        
        Args:
            ax:
                マークを描画する Axes
            x:
                マークを描画する、ゲーム盤の x 座標
            y:
                マークを描画する、ゲーム盤の y 座標
            mark:
                描画するマーク
            color:
                描画するマークの色
            lw:
                描画する図形の線の太さ
        """    
        
        if mark == Marubatsu.CIRCLE:
            circle = patches.Circle([x + 0.5, y + 0.5], 0.35, ec=color, fill=False, lw=lw)
            ax.add_artist(circle)
        elif mark == Marubatsu.CROSS:
            ax.plot([x + 0.15, x + 0.85], [y + 0.15, y + 0.85], c=color, lw=lw)
            ax.plot([x + 0.15, x + 0.85], [y + 0.85, y + 0.15], c=color, lw=lw)
    
    @staticmethod
    def draw_board(ax, mb:Marubatsu, show_result:bool=False, dx:float=0, dy:float=0, lw:float=2):
        """ゲーム盤を描画する
        
        Args:
            ax:
                ゲーム盤を描画する Axes
            mb:
                ゲーム盤を表す Marubatsu クラスのインスタンス
            show_result:
                決着がついている場合に、背景色を変えて描画する
            dx:
                描画するゲーム盤の左上の点の Axes の x 座標
            dy:
                描画するゲーム盤の左上の点の Axes の y 座標
            lw:
                描画する図形の線の太さ
        """
        
        # 結果によってゲーム盤の背景色を変更する
        if show_result:
            if mb.status == Marubatsu.PLAYING:
                bgcolor = "white"
            elif mb.status == Marubatsu.CIRCLE:
                bgcolor = "lightcyan"
            elif mb.status == Marubatsu.CROSS:
                bgcolor = "lavenderblush"
            else:
                bgcolor = "lightyellow"
            rect = patches.Rectangle(xy=(dx, dy), width=mb.BOARD_SIZE,
                                    height=mb.BOARD_SIZE, fc=bgcolor)
            ax.add_patch(rect)
        
        # ゲーム盤の枠を描画する
        for i in range(1, mb.BOARD_SIZE):
            ax.plot([dx, dx + mb.BOARD_SIZE], [dy + i, dy + i], c="k", lw=lw) # 横方向の枠線
            ax.plot([dx + i, dx + i], [dy, dy + mb.BOARD_SIZE], c="k", lw=lw) # 縦方向の枠線

        # ゲーム盤のマークを描画する
        for y in range(mb.BOARD_SIZE):
            for x in range(mb.BOARD_SIZE):
                color = "red" if (x, y) == mb.last_move else "black"
                Marubatsu_GUI.draw_mark(ax, dx + x, dy + y, mb.board[x][y], color, lw=lw)
                    
    def update_gui(self):
        """GUI の表示や設定を更新する"""
        
        ax = self.ax
        ai = self.mb.ai
        
        # Axes の内容をクリアして、これまでの描画内容を削除する
        ax.clear()
        
        # y 軸を反転させる
        ax.invert_yaxis()
        
        # 枠と目盛りを表示しないようにする
        ax.axis("off")   
        
        # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
        is_replay =  self.mb.move_count < len(self.mb.records) - 1 
        if self.mb.status == Marubatsu.PLAYING:
            facecolor = "lightcyan" if is_replay else "white"
        else:
            facecolor = "lightyellow"

        ax.figure.set_facecolor(facecolor)
            
        # 上部のメッセージを描画する
        # 対戦カードの文字列を計算する
        names = []
        for i in range(2):
            names.append("人間" if ai[i] is None else ai[i].__name__)
        ax.text(0, 3.5, f"{names[0]} VS {names[1]}", fontsize=20)   
        
        # ゲームの決着がついていない場合は、手番を表示する
        if self.mb.status == Marubatsu.PLAYING:
            text = "Turn " + self.mb.turn
        # 引き分けの場合
        elif self.mb.status == Marubatsu.DRAW:
            text = "Draw game"
        # 決着がついていれば勝者を表示する
        else:
            text = "Winner " + self.mb.status
        # リプレイ中の場合は "Replay" を表示する
        if is_replay:
            text += " Replay"
        ax.text(0, -0.2, text, fontsize=20)
        
        self.draw_board(ax, self.mb)
        
        self.update_widgets_status() 
主な修正箇所
-class Marubatsu_GUI():
+class Marubatsu_GUI(GUI):
    def __init__(self, mb, ai_dict=None, seed=None, size=3):
        # JupyterLab からファイルダイアログを開く際に必要な前処理
        root = Tk()
        root.withdraw()
        root.call('wm', 'attributes', '.', '-topmost', True)  

        # save フォルダが存在しない場合は作成する
        if not os.path.exists("save"):
            os.mkdir("save")        
        
        # ai_dict が None の場合は、空の list で置き換える
        if ai_dict is None:
            ai_dict = {}

        self.mb = mb
        self.ai_dict = ai_dict
        self.seed = seed
        self.size = size

        # ai_dict が None の場合は、空の list で置き換える
        if ai_dict is None:
            self.ai_dict = {}
        
        # %matplotlib widget のマジックコマンドを実行する
-       get_ipython().run_line_magic('matplotlib', 'widget')
        
-       self.disable_shortcutkeys()
-       self.create_widgets()
-       self.create_event_handler()
-       self.display_widgets() 

+      super().__init__()
以下略

行結果は省略しますが、GUI を gui.py に、上記の Marubatsu_GUI を marubatsu.py に記述し、下記のプログラムを実行することで、修正した Marubatsu_GUI が正しく動作することを確認することができます。なお、gui_play は、marubatsu.py から Marubatsu クラスをインポートするので、今回は利用することはできません。

from marubatsu_new import Marubatsu

mb = Marubatsu()
mb.play(ai=[None, None], gui=True)

今回の記事のまとめ

今回の記事では、委譲と継承について説明し、基底クラスとして GUI クラスを作成し、Marubatsu_GUI クラスを継承を使って定義しなおしました。

次回の記事では、今回の記事で定義した GUI クラスを継承することで、ゲーム木の視覚化を行う GUI を作成します。

本記事で入力したプログラム

以下のリンクから、本記事で入力して実行した JupyterLab のファイルを見ることができます。

以下のリンクは、今回の記事で更新した marubatsu.py です。

以下のリンクは、今回の記事で作成した gui.py です。

次回の記事

更新日時 更新内容
2024/09/05 オーバーライドの目的と基底クラスのメソッドの呼び出しのノートsuper を クラスの定義の外で記述されたメソッドで利用できることを追記しました
  1. 作者であっても、時間がたてば見つけることが難しくなることが多いでしょう

  2. A is B だと、A と B は同値(全く同じもの)という意味が強くなりますが、A is a B とすることで、A は B の一種であるという意味になります

  3. 行っても良いというだけで、行うべきであるという意味ではない 点に注意して下さい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?