山之口洋の
極・楽 python 講座
【応用編】

step03 クラスとオブジェクト

前stepの終わりで、キャンバス上にいろいろな図形をとして描くだけなら従来のモジュール関数だけでできるが、それらにモノとして多種多様な動作をさせるには、新たな構造化パラダイムが必要だといった。
本stepでは、従来より1段上位の構造化パラダイムであるオブジェクト指向プログラミングと、その構成要素としてのクラスオブジェクトについて学ぶ。引き続き図形描画をテーマに、その威力を実感してもらう。

なぜ構造化するのか

そもそも、なぜプログラムを構造化し、整理しなければならないのだろうか。
実用的で大規模なプログラムになるほど、動作も構造も複雑になり、それに比例してプログラムの論理ロジックも錯綜し、ステップ数も増大する※1。そして、これが重要だが、プログラムに無駄な部分があるのでない限り、どのように構造化し整理しようと、複雑さの総量は変わらない。

1 ステップ数は直線的(線形)にではなく、指数関数的(非線形)に増大するという説もある。システムの動作なり目標を数値化する信頼できる手法がないので当否は論じられないが、講師のSE経験からすると事実である。

それでも構造化とか整理が必要なのは、一度に考えなければならない複雑さの程度を減らすためである。人間が一度に思考できる物事の複雑さには限界がある。プログラマとして経験を積めば、その能力はかなり拡張されるにしても、システム全体が要求する複雑さを小さく分け、手に負える程度までブレイクダウンしなければ、そのプログラムは作れないのである。
たとえば、前stepで扱ったさまざまな図形 に、さまざまな動作をさせるプログラムを考えてみよう。想定される動作の種類としては、 などが挙げられる。
1種類の図形に対する1種類の動作が、1つの関数に対応すると仮定すれば、そのプログラムには、

【図形の種類数】×【動作の種類数】
だけの関数定義が必要になるので、プログラムは非常に複雑に、長大になってしまう。直線と多角形、楕円と長方形のように、図形同士の共通点があっても、それを利用してロジックを簡素化することもできず、似たような関数をたくさん定義しなくてはならない。
オブジェクト指向プログラミングは、このような場合に効果的な、モノの種類に着目した構造化パラダイムであり、つぎの基本概念から構成される。
クラス
モノの種類を表す概念。そのモノに共通するパラメータ(メンバ変数)と、動作(メソッド)をまとめて記述できる。たとえば、楕円クラスを定義する場合、左上と右下の座標、線の色、塗りつぶしの色はメンバ変数となり、作成(描画)・移動・回転などの操作は、楕円クラスに属するメソッド(そのクラス専用の関数)として定義する。
オブジェクト
インスタンスともいう。具体的な1つの楕円は、楕円クラスのオブジェクトとして作成する。たとえば画面上に3つの楕円が表示されている時、楕円クラスは1つだが楕円オブジェクトは3つあることになる。
継承
円は楕円の1種であり、さらに図形の1種である。このような関係を上位下位(a-kind-of)関係という。上位クラスで定義されたメンバ変数とメソッドのいくつかは、下位クラスでそのまま利用できることもあるし、独自に定義しなおす(メソッドのオーバーライドという)こともできる。これにより、図形同士の共通点を利用し、複数の図形に共通なパラメータや操作をまとめられる。
オブジェクト指向プログラミングの考え方を用いてプログラムを整理すれば、図形のような、共通性の多い対象領域ではプログラムの大幅な削減と簡単化が可能になる。また、単に量的な削減ばかりではなく、たとえば回転とドラッグ(移動)のように、相互に干渉する動作について、論理的に思考する基盤ができるのである。
また今回、tkinterライブラリの制約により、PILという別のライブラリを使ってラスター画像の表示を実現しているが、一旦実現してしまえば、そうした詳細な情報は、図形を使うアプリをデザインする時には、かえって不要になり、思考を妨げさえする。そこで、画像クラスライブラリ(モジュール)カプセル化し、アプリはそれをモジュールとしてimportするようにすれば、そのような詳細を隠蔽できる※2ので、アプリは自分の機能を実現することに専念できるようになる。そうした、特定のクラス(およびメンバ変数、メソッド)を記述したモジュールを、クラスライブラリという。

2 政治の世界では「隠蔽」は悪いこととされているが、プログラミングの世界では、分かりやすいプログラムを書くための常套手段である。

誤解してほしくないが、オブジェクト指向プログラミングと、従来の構造化プログラミングという2つのパラダイムは、決して対立する概念ではない。新しいパラダイムに移行したから、従来のそれが不要になるとか、捨て去るという性質のものでもない。そうではなく、従来の構造化プログラミングという構造化の階層の上に、新たにオブジェクト指向プログラミングという別の階層が乗せられたというのが正確である。この先、さらに複雑なシステム構築に対処するために、たとえばデザインパターンのような、さらに上位の構造化の階層が必要になることもあり得るのだ。

図形クラスライブラリの開発

では実際に、各種図形とそれらへの操作をまとめたクラスライブラリshapes.pyを作ってみよう。
まずやることは、ライブラリに含めるべき図形クラスの種類を決め、それらの間の上位下位関係を決めることである。ここでは、図3-1に示す単純なクラス階層としたが、この他にもたとえば、中空図形中実図形正多角形などのクラスを含めることも考えられる。

図3-1: shapes.pyにおけるクラス階層

つぎに、各クラスで定義すべきメソッドを整理しよう。

クラスとメソッドの対応

下記の表に、クラス間の継承(上位下位関係に基づく)と、メソッドの関係をまとめた。
は、上位クラスからメソッドが継承されてくることを示す。
×は、図形と操作の組合せが不適切なため、メソッドを定義しないことを示す。たとえば、文字列の回転や、画像の拡大・縮小など※3
他のメソッドから利用される内部メソッドについては、この表では省略した。

3 文字列を斜めにする操作を想像することはもちろんできるが、tkinterライブラリの枠内では実現困難である。画像や長方形の回転も同様である。

図形
Shape
直線
Line
多角形
Poly
gon
楕円
Oval

Circle
円弧、弓、扇
Arc
長方形
Rect
angle
文字列
Text
画像
Bitmap
作成と表示
__init__
ドラッグ
drag
drag
移動
move
回転
rotate
× × × × × ×
拡大、縮小
scale
scale ×
色を選択
choose_color
×
文字列を変更
×
× × × × × × change_text ×

表から分かる通り、継承オーバーライドによって、各図形に対する多種多様な操作が、最小限のメソッド定義で実現できる。コードはクラス毎にまとめられているため、将来の機能拡張などにも容易に対応できる。これが、クラスオブジェクトという仕組みの威力である。

図形クラスライブラリの詳細

図形クラスライブラリのソースコードを、リスト3-1に示す。プログラムはやや長いが、個々のメソッドは短く、クラス毎に論理的に整理されているため、理解しやすいはずだ。メソッドの継承関係が、表の通りに構成されていることも確かめよう。


# 図形クラスライブラリ from time import sleep from math import sin, cos, radians import tkinter as tk from tkinter import colorchooser import tkinter.simpledialog as sd from PIL import Image, ImageTk # 図形の親クラス class Shape(): # 作成と表示(全図形の共通部分) def __init__(self, coords, cvs): self.cvs = cvs # キャンバスウィジェット self.coords = coords # 定義時の関連座標(回転用に保存) self.deg = 0 # 現在の回転角度(定義時からの積算) self.ox, self.oy = self.get_center() # 定義時の中心座標(回転用に保存) self.cx, self.cy = self.get_center() # 現在の中心座標 # マウス操作のイベントハンドラ cvs.tag_bind(self.id, '<B1-Motion>', self.drag) cvs.tag_bind(self.id, '<B2-Motion>', lambda evt:self.scale(evt, 1.05)) cvs.tag_bind(self.id, '<B3-Motion>', lambda evt:self.scale(evt, 0.95)) cvs.tag_bind(self.id, '<Double-Button-1>', self.choose_color) # マウスでドラッグ def drag(self, event): x = event.x # マウスのクリック位置 y = event.y self.move(x - self.cx, y - self.cy) # マウス位置へ移動 self.cx += x - self.cx # 現在の中心座標を更新 self.cy += y - self.cy # (vx, vy)だけ移動 def move(self, vx, vy): self.cvs.move(self.id, vx, vy) # deg度だけ回転(できない図形もある) def rotate(self, deg): self.deg = (self.deg + deg) % 360 # 回転角度を積算(0 ~ 359) rad = radians(self.deg) # ラディアン単位に変換 it = iter(self.coords) # 定義時の座標から回転させる new_coords = list() # 新しい関連座標のリスト作成用 for x, y in zip(it, it): # 頭から2要素ずつ処理 nx = (x - self.ox) * cos(rad) - (y - self.oy) * sin(rad) + self.cx ny = (x - self.ox) * sin(rad) + (y - self.oy) * cos(rad) + self.cy new_coords.extend([nx, ny]) # リスト末尾にappend self.cvs.coords(self.id, *new_coords) # 図形の関連座標を更新 # deg度の位置に回転 def rotate_to(self, deg): self.rotate(deg - self.deg) # scale倍に拡大または縮小 def scale(self, event, scale = 1.0): x = self.cx y = self.cy self.cvs.scale(self.id, x, y, scale, scale) # 現在の図形を拡大・縮小する it = iter(self.coords) # 定義時の座標も拡大・縮小する new_coords = list() # 新しい関連座標のリスト作成用 for x, y in zip(it, it): # 頭から2要素ずつ処理 nx = (x - self.ox) * scale + self.ox ny = (y - self.oy) * scale + self.oy new_coords.extend([nx, ny]) # リスト末尾にappend self.coords = new_coords # 図形の関連座標を取得 def get_coords(self): return self.cvs.coords(self.id) # 図形の中心座標を取得 def get_center(self): x0, y0, x1, y1 = self.cvs.bbox(self.id) # 図形に外接する長方形 return (x0 + x1) / 2, (y0 + y1) / 2 # 中心のx, y座標を返す # ダイアログを表示して図形の色を選択 def choose_color(self, event): sleep(0.2) # ダイアログ表示時に図形が動いてしまうのを防止 rgb, color = colorchooser.askcolor(self.cvs.itemcget(self.id, 'fill')) self.cvs.itemconfigure(self.id, fill = color) # 直線 class Line(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.id = cvs.create_line(*coords, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # 多角形 class Polygon(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.id = cvs.create_polygon(*coords, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # 楕円 class Oval(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.id = cvs.create_oval(*coords, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # 回転はできない def rotate(self, deg): pass # 円 class Circle(Oval): # 作成と表示 def __init__(self, coords, cvs, **options): x, y, r = coords # 円を楕円に変換 super().__init__([x - r, y - r, x + r, y + r], cvs, **options) # 円弧(弓形、扇形) class Arc(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.id = cvs.create_arc(*coords, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # 回転はできない def rotate(self, deg): pass # 長方形 class Rectangle(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.id = cvs.create_rectangle(*coords, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # 回転はできない def rotate(self, deg): pass # super.rotate()からの継承を抑止する # 文字列 class Text(Shape): # 作成と表示 def __init__(self, coords, cvs, **options): self.font = ('MSゴシック', '36') # フォントのデフォルト値を決める self.id = cvs.create_text(*coords, **options, font = self.font) super().__init__(coords, cvs) # あとはShapeクラスに任せる self.cvs.tag_bind(self.id, '<B2-Motion>', lambda evt:self.scale(evt, 1)) self.cvs.tag_bind(self.id, '<B3-Motion>', lambda evt:self.scale(evt, -1)) self.cvs.tag_bind(self.id, '<Double-Button-3>', self.change_text) # scaleピクセルだけフォントを拡大または縮小 def scale(self, event, scale): size = str(int(self.font[1]) + scale) self.cvs.itemconfigure(self.id, font = (self.font[0], size)) self.font = (self.font[0], size) # 文字列の内容を変更 def change_text(self, event): text = sd.askstring('新たなテキスト', 'テキストを入力してください', initialvalue = self.cvs.itemcget(self.id, 'text')) self.cvs.itemconfigure(self.id, text = text) # 回転はできない def rotate(self, deg): pass # super.rotate()からの継承を抑止する # 画像 class Bitmap(Shape): # 作成と表示 def __init__(self, coords, cvs, imgsrc, **options): self.img = ImageTk.PhotoImage(Image.open(imgsrc)) self.id = cvs.create_image(*coords, image = self.img, **options) super().__init__(coords, cvs) # あとはShapeクラスに任せる # マウスでドラッグ def drag(self, event): x = event.x # マウスのクリック位置 y = event.y cx, cy = self.get_center() # 現在の中心位置を取得 self.move(x - cx, y - cy) # マウス位置へ移動 # 回転はできない def rotate(self, deg): pass # super.rotate()からの継承を抑止する # 拡大・縮小はできない def scale(self, event): pass # super.scale()からの継承を抑止する # 色を選択できない def choose_color(self, event): pass # super.choose_color()からの継承を抑止する
リスト3-1: 図形クラスライブラリshapes.py

クラスライブラリのプログラムについて簡単に解説する。

特殊メソッド__init__()

各図形クラスの作成と表示を行うメンバ関数は、「__init__()」という特殊な名前を持つ。コンストラクタと呼ばれることもあるが、それはC++やjavaなど、クラスをサポートする他の言語の用語だ。
たとえば直線クラスの例をみてほしい。


# 直線
class Line(Shape):
    # 作成と表示
    def __init__(self, coords, cvs, **options):
        self.id = cvs.create_line(*coords, **options)
        super().__init__(coords, cvs)            # あとはShapeクラスに任せる
__init__()は初期化メソッドであり、オブジェクトが生成されるときにpythonによって自動的に呼び出される。したがって、明示的に呼びされることはない。リスト3-2(後述)で、直線オブジェクトは、以下のように生成されている。

    line = sh.Line([100, 100, 300, 500, 400, 550], cvs, fill = 'red', width = 3, arrow = tk.LAST, arrowshape = (16, 20, 6))
この時に、__init__()が自動的に呼び出され、各引数の受け渡しと処理が行われるのである。ここは少し込み入っているが、細部までちゃんと理解していないと、クラスを用いたプログラミングが苦手になってしまうので、詳細に解説する。
__init__()メソッドでは、4つの引数が渡されることを想定している。これらを仮引数といい、実際に呼び出される時に指定された実引数の値が、呼び出しの際に代入される。それは一般の関数でも同じなのだが、クラスのメソッドではselfという第1引数が加わり、この初期化メソッドでは、さらに巧妙な処理がなされているので、注意深く読んでほしい。
self
生成されたオブジェクト、つまり自分自身が実引数として代入されている。代入は、python言語が自動的に行う。メンバ変数やメソッドの参照や呼び出しに必ず必要な、最重要の変数である。
coords
これは通常の引数。実引数として代入された値は[100, 100, 300, 500, 400, 550]である。tkinterライブラリのcanvasオブジェクトでは、さまざまな図形の定義に何点かの座標値を用いる。点の個数は図形の種類毎に異なるので、この図形クラスライブラリでは1つのリストにまとめた。ここでは、引数渡しに関する離れ業が2つも用いられているので、注意が必要だ。
これらの座標値から、tkinterのcanvasオブジェクトを生成している行は、

        self.id = cvs.create_line(*coords, **options)
のようになっている。リファレンスマニュアルを見ると、lineオブジェクトの生成メソッドの仕様は、

id = C.create_line(x0, y0, x1, y1, ..., xn, yn, option, ...)
のようである。定義に用いる点の座標は、リストではなくそのまま位置引数として並び、その後にオプション引数(名前付き引数)が並ぶようになっている。lineは折れ線オブジェクトなので、点の個数には制限がない。また、オプション引数は、線の色や矢頭の形状など、多種多様のものが、しかもオブジェクトの種類毎に定義されている。
そのような千変万化の引数インタフェースを持つメソッドや関数を、他のメソッドや関数でラップしようとすると、通常の引数渡しではうまくいかない。そこで、ここに示すような特殊な手法(引数リストのアンパック)が登場する。create_lineメソッドに実引数として渡している*coordsは、リストcoordsを1個ずつの要素の値にバラしてcreate_lineに渡すのである。この方法がなければ、__init__()自体を任意個数の引数で呼び出せるように書くしかなく、それも可能だがあまりよい方法ではない。
cvs
これもまったく普通の引数。図形が表示されるべきcanvasウィジェットである。
options
ここでも位置引数の場合と同様の、特殊な手法(オプション引数の辞書化)が登場する。リファレンスマニュアルを見れば明らかなとおり、各canvasオブジェクトは豊富なオプション引数で各図形を修飾している。それらのオプション引数を、ここで定義しているLineクラスの初期化メソッド__init__()でも指定できるようにする手法が、この辞書化アンパックである。
__init__()が呼ばれた時、optionsには、オプション引数の名前と値を「キー:値」ペアにしてまとめられたディクショナリが渡される。ここではそれをそっくりそのまま、create_lineへと渡している。ただしその際、ディクショナリはふたたび「オプション名 = 値」の形式にバラされてcreate_line側に渡されるのである。この方法がなければ、想定されるすべてのオプション引数をデフォルト値付きで__init__()の仮引数として準備しなければならず、それは言うのは勝手だが、誰もやる気になれないほど煩雑な作業だ。位置引数のリスト化とアンパック、オプション引数の辞書化とアンパックという2種類の技法によって、どんなに複雑な引数インタフェースを持つ関数やメソッドでも、リストとディクショナリという2つの引数ですっきりとラップできるのだから、これはクラスライブラリを開発しているプログラマにとっては、まさに神機能である。

空のメソッドによる継承抑止

ある図形で、特定の操作が存在しない場合のプログラム実装(書き方)には、少しだけ注意が必要だ。前述のように、文字列の回転や、画像の拡大・縮小はこのライブラリでは想定していないので、表中にも×が記載されている。しかし、それらの図形クラスにも、上位クラスから回転や拡大・縮小メソッドは継承されてくるので、実際に呼び出しはできるが、実行時にエラーが発生する。それを防止するために、空のメソッドを定義しなければならないのである。
たとえば、楕円(Oval)クラス回転(rotate)メソッドを見てみよう。これが空のメソッドの書き方で、上位クラスからの継承をクラス階層のこの位置で断ち切る働きをもつ。実際に呼び出してみても、何も起きない。passというキーワードは、空の実行ブロックを表す。pythonではプログラムの実行ブロックをインデントで表すために、他の言語ではついぞ見かけないこのようなキーワードが要るのである※4
楕円クラスの子クラスである円(Circle)クラスでは、空のrotate()メソッドはもはや必要ない。楕円クラスの(空の)メソッドが継承されてくるからだ。

4 多くの言語(C系やjavaなど)では、空の実行ブロックとして{ }と書くのが一般的である。

テスト用のマウスイベントハンドラ

せっかくさまざまなメソッドを定義したので、テスト用プログラム(リスト3-2:moveshapes.pyw)を書きやすくするために、いくつかの操作をマウスイベントに結びつけるイベントハンドラをあわせて定義した。具体的には、

マウス左ボタンでドラッグ
図形をドラッグする。
中ボタン(ホイール)でドラッグ
図形を拡大する。
右ボタンでドラッグ
図形を縮小する。
左ボタンをダブルクリック
図形の色を変更する。
右ボタンをダブルクリック
文字列の内容を編集する。
の5種類である。Canvas.tag_bindメソッドの第2引数で使われているイベントの記法については、tkinterリファレンスマニュアルの157ページ 54.Events: responding to stimuliを参照のこと。

図形クラスライブラリのテストプログラム

このようなクラスライブラリをモジュールとして作成しておけば、図形を扱いたい他のプログラムからimport shapeするだけで利用できる。例えばリスト3-3に示した簡単なテストプログラムを実行してみよう(図3-2参照)。各種の図形をマウスで容易に操作できる。それぞれの図形が、単にキャンバス上のではなく、さまざまなメソッド(振る舞い)を備えたオブジェクト(モノ)になったことが実感できるのではないだろうか。


# 図形を表示し、動かす import sys import tkinter as tk import shapes as sh # メインルーチン def main(): global cvs # GUI設定 root = tk.Tk() root.title("図形を動かす") # キャンバスを作成 cvs = tk.Canvas(root, width = 800, height = 600) cvs.pack() # 図形を作成・表示する line = sh.Line([100, 100, 300, 500, 400, 550], cvs, fill = 'red', width = 3, arrow = tk.LAST, arrowshape = (16, 20, 6)) poly = sh.Polygon([350, 350, 450, 500, 250, 600, 200, 400, 250, 300], cvs, fill = 'yellow') girl = sh.Bitmap([500, 300], cvs, imgsrc = 'girl.png', anchor = tk.CENTER) circle = sh.Circle([200, 200, 50], cvs, fill = 'blue') rect = sh.Rectangle([400, 50, 600, 250], cvs, fill = 'green') arc = sh.Arc([300, 300, 500, 500], cvs, fill = 'orange', start = 30, extent = 120, style = tk.PIESLICE) text = sh.Text([100, 300], cvs, fill = 'pink', text = '日本語') # 動かしてみる vx = 2 vy = -1 while True: x, y = girl.get_coords() if x < 64 and vx < 0 or x > 736 and vx > 0: vx *= -1 if y < 300 and vy < 0 or y > 400 and vy > 0: vy *= -1 girl.move(vx, vy) line.rotate(2) poly.rotate(1) arc.rotate(3) cvs.after(20) cvs.update() root.mainloop() # メインルーチンを呼び出し(モジュール直接実行時) if __name__ == '__main__': main()
リスト3-2: moveshapes.pyw
図3-2: moveshapes.pywの実行画面

アニメーションアプリの改良

最後に、前stepで作ったアニメーションのアプリを、図形クラスライブラリを利用するように改良してみよう。リスト3-3に示すように、変更点はわずか2~3行に過ぎない。


# キャラクターを動かしてみる(shapesモジュール利用) import sys import tkinter as tk import shapes as sh vx = 2 vy = -1 # GUI設定 root = tk.Tk() root.title("アニメーション") # キャンバスを作成 cvs = tk.Canvas(root, width = 640, height = 480) cvs.pack() # 画像を作成・表示 sky = sh.Bitmap([0, 0], cvs, imgsrc = "sky.bmp", anchor = tk.NW) girl = sh.Bitmap([0, 400], cvs, imgsrc = "girl.png", anchor = tk.CENTER) # 透明度つきPNG画像 # 女の子を動かしてみる while True: x, y = girl.get_coords() if x < 0 and vx < 0 or x > 640 and vx > 0: vx *= -1 if y < 300 and vy < 0 or y > 400 and vy > 0: vy *= -1 girl.move(vx, vy) cvs.after(20) cvs.update() root.mainloop()
リスト3-3: anime3.pyw

図3-3の実行画面を見ても、図2-4の画面とまったく区別はつかないが、この女の子は図形クラスライブラリからやってきた子なので、マウスで自由にドラッグできる!

図3-3: anime3.pywの実行画面】

演習1:円弧の回転

現在の図形クラスライブラリでは、円弧(弓形、扇形)の回転操作はサポートされていないが、やり方によっては「回転」を定義できるかもしれない。tkinterのCanvas arc objectの仕様を調べ、Arc.rotate()メソッドを定義しよう。

演習2:長方形の回転

tkinterモジュールでは、長方形はx軸とy軸に平行なものだけに制限されている。左上と右下の2点の座標だけで定義されているからだ。だが、実際に図形を利用するプログラムでは、斜めの長方形や、長方形の回転が必要な場合もある。どういう解決策が考えられるだろうか? 考察してみよう(ざっと3通りくらいの選択肢がありそうだ)。