タートルグラフィックスで学ぶ Python のキホン 第 23 章
教材コード
第23章・クラス設計①

状態と操作をまとめる

使っていた Turtle と同じように,自分で種類を作る.

この章でできるようになること
  • クラスとインスタンスを区別する
  • __init__ と self の役割を説明する
  • 2個のインスタンスを独立して変更し,テストする

23.1 辞書と関数から出発する

第21章では図形の辞書を move_shape(shape, dx, dy) に渡しました.図形に関する値と操作が増えたので,Square という種類にまとめます.クラスは必須ではありません.数個の値を計算するだけなら,辞書と関数のままでも十分です.

辞書と関数クラスでの書き方
shape['size']shape.size(属性)
create_shape(...)Square(...)(インスタンス生成)
move_shape(shape, dx, dy)shape.move(dx, dy)(メソッド呼び出し)

23.2 1個ずつ初期化する

class Square: が種類の定義です.Square(...) を呼ぶと新しいインスタンスが作られ,__init__ が初期値を設定します.self は今操作しているインスタンスを受け取る引数です.a.move(0, 50) では a が self に渡されるので,呼ぶ側で self を書く必要はありません.__init__ は値を return する関数ではありません.

p23_square.py
class Square:
    def __init__(self, x, y, size, color):
        if size <= 0:
            raise ValueError("大きさは正にしてください")
        self.x = x
        self.y = y
        self.size = size
        self.color = color

    def move(self, dx, dy):
        self.x += dx
        self.y += dy

    def area(self):
        return self.size ** 2

    def draw(self, pen):
        pen.penup()
        pen.goto(self.x, self.y)
        pen.setheading(0)
        pen.pencolor(self.color)
        pen.pendown()
        for _ in range(4):
            pen.forward(self.size)
            pen.left(90)

from turtle import Turtle, done
a = Square(-120, 0, 50, "blue")
b = Square(0, 0, 80, "orange")
a.move(0, 50)
assert (b.x, b.y) == (0, 0)
assert a.area() == 2500
pen = Turtle()
pen.hideturtle()
pen.speed(0)
a.draw(pen)
b.draw(pen)
print(a.area(), b.area())
done()

23.3 変更する操作・答える操作

move は自分の座標を変更し,area は状態を変更せず面積を返します.第21章の move_shape は新しい辞書を返していました.名前だけで決めず,どちらの仕様かを説明して使います.draw は渡されたペンで描きますが,図形の座標を変えません.

p23_alias.py
class Square:
    def __init__(self, x, y, size, color):
        if size <= 0:
            raise ValueError("大きさは正にしてください")
        self.x = x
        self.y = y
        self.size = size
        self.color = color

    def move(self, dx, dy):
        self.x += dx
        self.y += dy

    def area(self):
        return self.size ** 2

    def draw(self, pen):
        pen.penup()
        pen.goto(self.x, self.y)
        pen.setheading(0)
        pen.pencolor(self.color)
        pen.pendown()
        for _ in range(4):
            pen.forward(self.size)
            pen.left(90)

a = Square(0, 0, 10, "blue")
alias = a
other = Square(0, 0, 10, "blue")
alias.move(5, 0)
assert a.x == 5
assert other.x == 0
assert alias is a
assert other is not a
print(a.x, other.x)
注意各個体が持つリストは __init__ の中で self.history = [] と作ります.class の直下に history = [] と書くと全個体で共有されます.また __init__(self, history=[]) のような変更可能な既定値も共有の原因になります.

実習:小さなクラスから段階的に組み立てる

前半はコンソールで状態の変化を確かめ,後半で図形のクラスへつなげます.例題を開く前に結果を予測し,実行後にその理由を説明してください.各練習は独立して実行できます.

実習23-1 値が増えたとき,何をひとまとめにするか

この節の問い四角形が増えたら、どの値を一緒に管理する?

まず、1個の四角形の位置・大きさ・色を同じまとまりにします。aの位置とbの大きさを取り違えないためです。

表し方何をまとめるか向いている場面
個別の変数x1, y1, size1, color1…対象が少ない小さな実験。ただし個数とともに名前が増える
辞書(第18章){"x": 10, "y": 20, "size": 30, "color": "blue"}設定や保存用のデータ。キーを柔軟に増やせる
クラス状態(位置・大きさ・色)+ 操作(移動・面積計算)同じ種類の個体を作り、共通の手順で操作する
判断の基準辞書を使う設計も有効です。単に数を2個足すなら関数で十分です。「長くなったから」「classを使いたいから」だけでクラスへ変える必要はありません。

クラスを検討する3つのサイン

  • 同じ状態と操作の組が繰り返し現れる。
  • aとbのように、個体ごとの違いを保ちたい。
  • 初期化の漏れを防ぎ、作り方の約束をそろえたい。
  1. 次の辞書版で、squareに入る4つの値を確認する。
  2. areaが読む値を探す。面積に必要なのはsizeだけ。色と座標は計算には使わないが、同じ四角形の属性として保持する。
  3. 実行前に面積を予測し、コンソールで900になることを確かめる。
  4. 辞書版を比較用に残す。クラス版へ変えても、同じ大きさから同じ面積が得られるか確かめる。

この章の目標は、何を1個の対象として扱い、どの操作を任せるかを説明できることです。

w23_1_demo.py
square = {"x": 10, "y": 20, "size": 30, "color": "blue"}
def area(shape):
    return shape["size"] ** 2
print(square["x"], square["y"], area(square))
演習辞書版と同じ面積を返すクラス

辞書版のsquareと同じ値を持つSquareクラスを作ってください.__init__でx・y・size・colorをselfの属性へ保存し,areaメソッドはself.sizeの2乗を返します.Square(10, 20, 30, 'blue').area()が辞書版のarea(square)と一致することをassertで確かめ,色や座標がareaに使われない理由も説明してください.

解き方を考える手がかり辞書のshape["size"]が,クラスではself.sizeになります.辞書版は比較の基準として消さずに残します.

実習23-2 クラスという種類と,インスタンスという個体

classの後に書く名前は,これから作る種類の名前です.

例ではMarkerという種類を作ります.その定義を読み終わった時点では,個々の目印はまだ作っていません.

Marker()という式を評価したとき,初めて1個のインスタンスが作られます.続けてもう一度Marker()を評価すると,もう1個のインスタンスができます.

設計図と実物というたとえを使う場合も,「何回の呼び出しで何個作られるか」までコードと結び付けてください.

aとbはインスタンスそのものの名前というより,それを参照する変数です.

a = Marker()では,新しく作った個体をaから参照できるようにします.a.x = 10は,aが参照する個体へxという属性を設定する操作です.

点の左側が誰を指しているかに注目すると,a.xとb.xの違いが分かります.属性名が同じでも,別の個体の属性なら別々の値を持てます.

最初の例ではpassだけのクラスを使います.

passは「ここでは何もしない」という文で,空のクラス本体を構文として成立させています.これは完成した設計ではなく,個体と属性の関係を観察するための実験です.

属性を外からばらばらに追加する方法では,xを設定し忘れた個体ができるからです.次の節で,作成時に必要な属性が必ずそろうように初期化を加えます.

実行前に,a.xを変更した後のb.xを予測してください.

次に,画面上の絵を使わず,コンソールの数値だけでその予測を確かめます.こうした小さい実験は,複雑なゲームの中でクラスを使う前に行うと効果的です.

どの変数がどの個体を指すかを紙に箱と矢印で描いても構いません.クラスの文法を暗記するだけでなく,状態が置かれている場所を説明することが大切です.

w23_2_demo.py
class Marker:
    pass
a = Marker()
b = Marker()
a.x = 10
b.x = 20
a.x += 5
print(a.x, b.x)
print(a is b)
演習3個目の個体を作る

cという3個目のMarkerを作り,xを30にしてください.aだけを100に変更した後,b.xが20,c.xが30のままであることをassertで確認します.クラスの定義が1回なのに個体を3個作れる理由も説明してください.

解き方を考える手がかりMarkerを3回定義する必要はありません.Marker()を評価するたびに新しい個体が作られます.

実習23-3 __init__で初期状態をそろえる

初期化とは,これから使う個体に必要な状態を用意することです.

今回のPointにはxとyが必要なので,__init__の引数で両方を受け取り,self.xとself.yへ保存します.__init__は前後に下線が2本ずつある特別な名前です.

Point(10, 20)という呼び出しの際に,自動的に実行されます.初学者の段階では,この名前の別の特殊メソッドを一度に覚える必要はありません.

まず生成と初期化の順序に集中します.

引数のxと属性のself.xは役割が違います.

xは今回の呼び出しで受け取った値を指し,self.xは,これからも個体に覚えさせておく値です.x = xと書いても,個体の属性は設定されません.

右辺の値を,点を使って指定した保存先へ渡す必要があります.同じ名前を使うのは対応を読みやすくするためで,言語の決まりで同じにしなければならないわけではありません.

__init__の中では,通常returnを書きません.

Point(...)という式から個体が得られるのは,__init__がその個体をreturnするからではありません.ここを通常の「値を計算して返す関数」と混同すると,return selfやreturn 0を書いてしまいます.

初期化の役割は,受け取った個体の属性を設定することです.詳しい生成の内部機構は後の学習に回し,今回は属性をそろえる処理として読みます.

初期化を使うと,xだけ設定してyを忘れた個体を作りにくくなります.

ただし,初期化を書けば入力が自動的に正しくなるわけではありません.文字列を渡してよいのか,座標の範囲を制限するのかは,設計側が決めて検査します.

この節では数値を渡す約束で進め,後の節で大きさの検査を加えます.文法の習得と入力検証の設計を一度に混ぜず,段階を分けて確認しましょう.

w23_3_demo.py
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        print("初期化", x, y)
a = Point(10, 20)
b = Point(-30, 40)
print(a.x, a.y, b.x, b.y)
演習保存先の間違いを直す

ひな形のPointは,呼び出しが終わると座標を取り出せません.なぜp.xでAttributeErrorになるかを説明し,xとyを個体の属性に保存するように修正してください.修正後は(5,8)が表示されることを確かめます.

解き方を考える手がかり引数だけに代入しても,呼び出し後に個体から取り出せる属性は増えません.エラー行だけでなく初期化まで戻って調べます.

実習23-4 selfは,いま操作している相手

selfはPythonの予約語ではなく,インスタンスを受け取る最初の引数につける慣用的な名前です.

別の名前も使えますが,教材や他の人のコードと読み方をそろえるためselfを使います.重要なのは文字列としてのselfを暗記することではなく,呼び出しごとにどの個体が渡されるかを追えることです.

a.move(5, 0)ならa,b.move(5, 0)ならbが,それぞれの呼び出しのselfになります.

メソッドの定義にはselfを書きますが,通常の呼び出し側には書きません.

a.move(5, 0)の点の左側から,相手が自動的に渡されるためです.学習用にPoint.move(a, 5, 0)という書き方と比較すると,引数が対応する様子を確認できます.

通常の作品ではa.moveの形を使い,種類名から直接呼ぶ形は仕組みを調べる実験にとどめて構いません.

メソッドの中でx += dxと書くと,self.xとは別の局所変数を扱おうとします.

インスタンスの属性を読み書きしたいならself.x += dxです.Pythonは,クラスの中に書いたからといって,裸のxを自動的に属性と解釈しません.

この明示的な書き方のおかげで,どの操作が個体の状態を変えるかをコードから読み取れます.点の付いた式と,局所的な計算用変数を区別してください.

以下の実験では,2つの呼び出し方で同じ移動を行います.

初期位置をそろえて結果を比べると,selfの対応だけに注目できます.さらにaとbを違う初期位置にして,移動量が同じでも移動後の位置は違うことを確かめましょう.

相対的な移動量と絶対的な位置は,第3章で学んだ変数の役割の違いにつながります.クラスを使っても,数値の意味を考える必要は変わりません.

w23_4_demo.py
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
a = Point(0, 0)
b = Point(0, 0)
a.move(5, 8)
Point.move(b, 5, 8)
assert (a.x, a.y) == (b.x, b.y)
print(a.x, a.y)
演習原点に戻すメソッド

Pointにhome(self)を追加して,その個体のxとyを0へ戻してください.aとbを作り,a.home()だけを呼んだとき,bの位置が変わらないことを確認します.homeには移動量の引数は要りません.

解き方を考える手がかりクラス本体の字下げと,メソッド本体の字下げは2段階になります.homeをクラスの外に書いてしまわないようにします.

実習23-5 状態を変える操作と,値を返す操作

メソッドは,クラスの中に定義した関数です.

したがって,第13・14章の引数と戻り値の考え方がそのまま使えます.ただし,引数としてselfを受け取り,その個体の状態を参照できる点が加わります.

Counterのaddは状態を変更し,valueは現在の値を返します.どちらも呼び出しですが,呼び出した後に何が変わるかは違います.

この違いを利用側へ説明できるようにしましょう.

returnを書かないメソッドの戻り値はNoneです.

c = c.add(3)と書くと,もとの個体への参照をNoneで上書きすることがあります.リストのappendやsortで経験したことと同じです.

「新しい個体を返す操作」と「その場で変更する操作」を混同しないよう,メソッドの説明には,何を受け取り,何を変え,何を返すかを分けて書きます.1行の説明でも,この3点があると使いやすくなります.

状態を変えずに値を返す操作は,テストしやすいという利点があります.

例えばareaを2回呼んだだけで面積が変わると,使う側は驚きます.面積を求める操作は現在のsizeを読むだけにし,拡大する操作はscaleなど別の名前にすると,呼び出しの意図が分かります.

これはPythonが強制する規則ではなく,読み手の期待をそろえるための設計上の約束です.

以下ではaddの戻り値と,valueの戻り値をそれぞれ表示します.

結果を見る前に,コンソールへ出る3つの値を書き出してください.次に,addの呼び出しを2回に増やし,状態の変化を時系列で追います.

メソッドを1回ずつ正しく読めても,呼び出し順が変わると結果が変わることがあります.順次処理の理解と,個体が過去の変更を保持することを結び付ける実験です.

w23_5_demo.py
class Counter:
    def __init__(self):
        self.count = 0
    def add(self, amount):
        self.count += amount
    def value(self):
        return self.count
c = Counter()
result = c.add(3)
print(result)
print(c.value())
print(c.value())
演習面積を返す操作

Squareにareaを定義し,sizeの2乗を返してください.printは呼び出し側で行います.2回呼んでもsizeが変わらず,同じ値が返ることをassertで確認します.表示するだけのメソッドにすると,ほかの計算で再利用しにくくなる理由も説明してください.

解き方を考える手がかり値を返す処理と表示する処理を分けると,面積の合計・大小比較・テストにも同じメソッドを使えます.

実習23-6 別名を作ることと,別の個体を作ること

a = Square(10)の後にb = aと書いても,Squareはもう1個作られません.

aが参照している個体を,bからも参照できるようになるだけです.この関係はリストの参照と同じです.

a.sizeとb.sizeは,同じ個体の同じ属性を見ているので,片方から変更した結果がもう片方からも見えます.「変数が2つあるから,個体も2つある」と考えないようにしてください.

別の個体が必要なら,Square(...)をもう一度呼びます.

初期値が同じでも個体は別です.この章では同じ個体かどうかをisで調べます.

値の等しさを判定する==と,個体の同一性を判定するisは目的が違います.自分で作ったクラスの==の意味を変更する話は,今は扱いません.

まず,代入が個体の複製ではないことを,isと属性の変更で確かめます.

共有そのものが悪いわけではありません.

画面に表示している図形と,選択中の図形を指す変数が,同じ個体を参照しているのは自然です.選択中の図形を動かすと,画面側でも同じ図形の座標が変わってほしいからです.

逆に,作品を複製して別々に編集したい場合は,共有していると困ります.必要なのは,共有するか独立させるかを用途から決めることです.

例ではa,alias,otherの関係を調べます.

紙にaとaliasから同じ箱へ矢印を描き,otherから別の箱へ矢印を描いてください.そのうえでalias.size = 50を実行した後の3つの値を予測します.

結果が予測と違ったら,変数名を眺めるだけでなく,Square()が何回評価されたかを数えましょう.生成の回数と代入の回数を分けると,原因を絞れます.

w23_6_demo.py
class Square:
    def __init__(self, size):
        self.size = size
a = Square(10)
alias = a
other = Square(10)
alias.size = 50
print(a.size, alias.size, other.size)
print(a is alias, a is other)
演習複製する操作を設計する

Squareにcopy(self)を追加し,同じsizeを持つ別のSquareを返します.copyで得たbだけを変更してもaが変わらないことをテストしてください.戻り値をselfにした場合と何が違うかも説明します.

解き方を考える手がかり今回の属性は数値だけなので,新しいSquareへ値を渡せば独立します.属性がリストなら,そのリストをどう複製するかも設計します.

実習23-7 個体ごとの履歴と,共有される属性

座標の移動履歴を残したいときは,各個体にhistoryというリストを持たせます.

__init__の中にself.history = []と書くと,初期化のたびに新しいリストが作られます.クラスの直下にhistory = []と書いた場合は,クラスに属する1個のリストになります.

個体に同名の属性がなければ,その共有リストが参照されるため,別々の図形の履歴が混ざる原因になります.

クラス属性は常に間違いというわけではありません.

全個体で共有する説明や設定を置きたい場合には使えます.ただし,変更可能なリストや辞書を置くと,ある個体からの変更が他の個体に見えることを理解しておく必要があります.

今日の履歴は「個体ごとに違う情報」なので,インスタンス属性として__init__で用意します.書く場所は,覚えたい情報の所属に対応しています.

似た問題は引数の既定値でも起こります.

def __init__(self, history=[]):のリストは,呼び出しのたびに作られるわけではありません.既定値が評価されたときのリストが使い回されます.

必要ならhistory=Noneを既定値にし,関数の中で新しいリストを作ります.既定値の仕組みを今すべて覚える必要はありませんが,「個体ごとのリストは個体を作るときに作る」という判断は身につけてください.

この節では2個のTraceを作り,片方だけへ位置を追加します.

正しい設計なら,もう片方のhistoryは空のままです.1個だけで試すと共有の不具合は見えないため,必ず2個で検証します.

テスト入力の選び方が重要であることは,第22章の境界条件と同じです.正常な結果が1回出たことではなく,混同しやすい関係を確かめるテストを考えましょう.

w23_7_demo.py
class Trace:
    def __init__(self):
        self.history = []
    def add(self, x, y):
        self.history.append((x, y))
a, b = Trace(), Trace()
a.add(10, 20)
print(a.history, b.history)
assert b.history == []
演習混ざる履歴を直す

ひな形は意図的にhistoryを共有しています.aへ追加した位置がbからも見えることを確認した後,個体ごとに独立する設計へ直してください.aへ2回,bへ1回追加し,それぞれの長さと内容をテストします.

解き方を考える手がかり共有されたリストをあとでclearする方法では直りません.状態を置く場所と,リストを作るタイミングを直します.

実習23-8 作成時と変更時の両方で条件を守る

図形の大きさが正であることを,クラスの約束にするとします.

__init__でsize <= 0を拒否すれば,不正な初期状態を防げます.しかし,後からsizeへ直接負の値を代入すれば,その約束は破られます.

クラスを使うだけで,すべての不正な変更が自動的に禁止されるわけではありません.どの操作を通して変更するかを決め,変更の入口でも条件を確認する必要があります.

この節ではset_sizeという操作を用意します.

初期化からもその操作を呼び,検査を1か所へまとめます.同じ条件を__init__と別の変更メソッドに二重に書くと,後で上限を変えたときに片方だけ直し忘れるかもしれません.

共通の約束は1か所で表し,使う場所から呼び出すと,変更の影響を追いやすくなります.関数による処理の共通化と同じ考え方です.

入力が不正なときは,属性を変更する前にValueErrorを出します.

先に代入してから検査すると,例外を受け止めた後にも不正な値が残ってしまいます.「失敗した操作では,元の状態を保つ」という仕様なら,検査と代入の順番が重要です.

正常な値へ変えられることに加えて,不正な値を渡した後の状態もテストしてください.

ここでは説明を絞るため,入力は数値という前提で,大きさの範囲を検査します.

JSONなど外部から受け取る場合には,型の検査も必要になります.それは第25章で扱います.

何を検査済みと考えてよいかは,データがどこから来るかによって変わります.クラスの説明に前提条件を書くと,使う側が必要な検査を判断できます.

例外を何でも無視するのではなく,どの失敗なら利用側で対応するかを決めましょう.

w23_8_demo.py
class Square:
    def __init__(self, size):
        self.set_size(size)
    def set_size(self, size):
        if size <= 0:
            raise ValueError("大きさは正")
        self.size = size
s = Square(20)
try:
    s.set_size(-5)
except ValueError as error:
    print(error)
assert s.size == 20
print(s.size)
演習大きさの上限も決める

大きさを1以上100以下に制限してください.1と100は受け付け,0と101は拒否します.拒否された後も直前の正常な値が残ることを確認します.検査が初期化と後からの変更の両方に効くようにしてください.

解き方を考える手がかりエラーが出たことだけでなく,状態が壊れていないことまで確認します.assertは教材中のテスト用で,入力検証そのものにはifと例外を使います.

実習23-9 リストに入れたインスタンスを順に操作する

インスタンスは,リストの要素にもできます.

数値や辞書をリストへ入れたときと同様に,forで1個ずつ取り出して操作します.boxesの各要素がBoxなら,ループ変数boxもその都度1個のBoxを参照します.

box.move(10)と書けば,その回で取り出した個体だけが移動します.クラスを使うことで,繰り返しそのものが別の仕組みになるわけではありません.

すでに習ったデータ構造と組み合わせて使います.

注意したいのは,要素数と個体数が必ずしも同じではないことです.

[Box(0)] * 3では,Box(0)は1回だけ評価され,その1個への参照が3か所に入ります.各要素へmove(10)を呼ぶと,同じ個体を3回動かします.

一方,[Box(0) for _ in range(3)]は,繰り返しのたびにBox(0)を評価するので3個の個体ができます.第19章の多次元リストと同じ問題が,クラスでも現れます.

独立した個体のリストを作るときは,生成する式が繰り返しの内側にあるかを確認してください.

逆に,同じ個体を複数の一覧から探せるようにしたい場合は,共有しているほうが自然です.例えば,作品中の全図形と,選択中の図形の一覧が同じ個体を参照していれば,選択した図形を移動すると作品側にも反映されます.

共有の有無を,データを使う目的から決めます.

以下の例題では,最初の位置が0,20,40の3個を,それぞれ10だけ動かします.

結果を予測した後,初期位置をすべて0にして再実行してください.同じ値から始めても,各個体が独立していれば,一方をさらに動かしても他方は動きません.

値が同じかどうかを調べるテストと,独立しているかを調べるテストを分けることが重要です.一覧の長さだけで確認を終えないようにします.

w23_9_demo.py
class Box:
    def __init__(self, x):
        self.x = x
    def move(self, dx):
        self.x += dx
boxes = [Box(x) for x in (0, 20, 40)]
for box in boxes:
    box.move(10)
print([box.x for box in boxes])
演習同じ個体を3回動かす不具合

ひな形は,3個をそれぞれ10動かすつもりなのに,すべて30になります.Boxが作られる回数を数え,3個が独立するように直してください.修正後,先頭だけを5動かしたとき,結果が[15,10,10]になることも確認します.

解き方を考える手がかりリストのコピーだけでは,中の個体まで新しくなりません.まず最初に生成する場所を確認します.

実習23-10 メソッドを,あとで呼ぶ処理として渡す

第10章では,ontimer(step, 100)のように関数名を渡して,後で呼び出す処理を予約しました.

インスタンスのメソッドも,同じようにあとで呼ぶ処理として扱えます.a.stepは,stepという処理だけでなく,操作する相手がaであることも結び付いた値です.

a.step()は今すぐ呼び出す式,a.stepはあとで呼べる処理を取り出す式です.括弧の有無による違いを,もう一度確認します.

例題ではcallback = counter.stepとし,後の行でcallback()を2回呼びます.

callbackは,呼び出すたびに同じcounterへ働きかけます.第10章では,外のcountへ代入するためglobalを使いました.

クラスなら,self.countという個体の属性を更新できるので,この状態を共有の大域変数へ置かなくても済みます.複数のカウンタを同時に動かしたい場合にも,個体ごとに状態を分けられます.

ここでcallback = counter.step()としてしまうと,代入の行でstepが実行され,戻り値のNoneがcallbackへ入ります.

あとでcallback()を呼ぶと,Noneを関数のように呼べないためTypeErrorになります.エラーが出た行だけを見ると分かりにくいので,callbackへ何を代入したかまで戻って調べます.

処理を渡す場面で,実行結果を渡していないか確認してください.

タイマーへつなぐ前に,まずコンソールだけで2回呼び出す実験を行います.

これなら時間の経過や描画速度に影響されず,状態の変化を順に確認できます.その後でontimerやonkeyへ渡せば,クラスの不具合と入力の不具合を切り分けやすくなります.

複数の仕組みを組み合わせる際は,部品が単独で動くことを確かめてから統合するのが基本です.

w23_10_demo.py
class Counter:
    def __init__(self):
        self.count = 0
    def step(self):
        self.count += 1
        print(self.count)
counter = Counter()
callback = counter.step
callback()
callback()
assert counter.count == 2
演習別々の相手を覚えた処理

Counterをaとbの2個作り,a.stepとb.stepをリストへ入れてください.先頭を2回,2番目を1回呼び,a.countが2,b.countが1になることを確認します.リストに入れているのが呼び出しの結果ではないことに注意します.

解き方を考える手がかりa.stepという値には,呼び出す処理と対象の個体が対応付けられています.aとbが別の個体なら,変更する属性も別です.

実習23-11 図形の状態と,描画する道具を分ける

Squareを描画するとき,SquareそのものがTurtleである必要はありません.

Squareは座標と大きさを覚え,描くときだけペンを受け取る設計にできます.この設計では,図形を100個持っていても,ペンは1本を使い回せます.

図形が持つ情報と,画面へ線を出すための道具を分けることで,どの状態を保存すればよいかも分かりやすくなります.

drawメソッドは,描画の開始位置と向きを必ず設定します.

前に描いた図形がペンをどの位置や向きに残したかに依存してはいけません.penupで移動中の線を消し,gotoで左下へ移動し,setheading(0)で右向きにしてから描きます.

こうして描画の前提をそろえると,図形の順番を変更しても,各図形自体の形は変わりません.共有の道具を使う場合には,道具の残った状態へ注意します.

また,drawを呼んでもSquareの座標や大きさは変えません.

画面へ表示するために値を読む操作と,作品を編集するために値を変更する操作を分けます.2回表示しただけで図形が移動してしまうと,再描画するたびに作品が変わってしまいます.

この約束を守ると,画面サイズを変更した場合や,別のペンで表示した場合にも,同じデータを再利用できます.

以下の例では,drawの前後で属性を比較します.

描画される絵を眺めるだけでなく,状態が維持されているかをassertで確認します.絵が正しくても内部の状態が変わっていることはあり得るからです.

次に,描く順を入れ替えてみましょう.図形の線の重なり順は変わっても,それぞれの位置と大きさは同じになるはずです.

見た目の変化とデータの変化を区別する練習です.

w23_11_demo.py
from turtle import Turtle, done
class Square:
    def __init__(self, x, size):
        self.x, self.size = x, size
    def draw(self, pen):
        pen.penup()
        pen.goto(self.x, 0)
        pen.setheading(0)
        pen.pendown()
        for _ in range(4):
            pen.forward(self.size)
            pen.left(90)
pen = Turtle()
pen.speed(0)
pen.hideturtle()
a, b = Square(-100, 40), Square(30, 70)
before = (a.x, a.size)
a.draw(pen)
b.draw(pen)
assert (a.x, a.size) == before
done()
演習描画前の向きをそろえる

例題からsetheading(0)を一時的に外し,描画前にペンを45度へ向けるとどうなるか予測してください.その後,drawが外の向きに依存しないように戻し,2つの四角形を描きます.完成コードには,向きを設定する理由をコメントで残してください.

解き方を考える手がかりクラスの外で1回だけ向きを設定する方法では,別の処理がペンを回したときに壊れます.描画メソッド自身が必要な前提を整えます.

実習23-12 クラスを使う側から,操作の約束を読む

クラスを定義できたら,次は使う側のコードを短く書いてみます.

ここではStampが位置と色を持ち,moveで位置を変え,describeで説明文を返すとします.利用側は,内部で辞書を使っているか,個別の属性を使っているかより,どの操作が何をするかを知りたいはずです.

このため,メソッドの名前と引数は,利用場面を思い浮かべて決めます.

操作の約束は,呼び出し前と呼び出し後の関係で書くと具体的になります.

例えばmove(dx)なら,「現在のxへdxを加え,色は変えない.戻り値はNone」と書けます.

describeなら,「状態を変えず,位置と色を含む文字列を返す」です.「図形を処理する」だけでは,何を期待してよいか分かりません.

引数,状態への影響,戻り値を一つずつ確認してください.

クラスの外から属性を読むこともできますが,作品が大きくなるにつれて,変更の入口をそろえたほうが管理しやすくなります.

例えば移動履歴を残すように変更したとき,すべての利用側がmoveを呼んでいれば,moveの中へ記録を追加できます.利用側が各所でxへ直接代入していると,履歴に残らない変更が発生します.

次章で扱う役割分担は,この変更しやすさにつながります.

以下の例題は描画をしませんが,図形のクラスにも同じ考え方を適用できます.

テストでは位置が変わること,色が変わらないこと,説明文が現在の値を反映することを確かめます.最後に,クラスを初めて使う人へ3行で使い方を説明してみましょう.

文法の読み方だけでなく,何を任せられる部品かが伝われば,設計として一歩進んでいます.

w23_12_demo.py
class Stamp:
    def __init__(self, x, color):
        self.x, self.color = x, color
    def move(self, dx):
        self.x += dx
    def describe(self):
        return f"位置={self.x}, 色={self.color}"
s = Stamp(10, "blue")
s.move(5)
print(s.describe())
assert s.color == "blue"
演習操作の前後をテストする

Stampにrecolor(color)を追加します.色だけを変更し,位置は変えません.moveとrecolorを順に呼び,両方の約束が守られることをテストしてください.describeの戻り値を変数へ保存し,文字列として利用できることも確認します.

解き方を考える手がかりテストでは変更されるべき値だけでなく,変更されてはいけない値も確認します.これは次章の複数クラスの設計でも役立ちます.

23.4 操作を追加して確かめる

演習大きさを変える操作

Square に scale(self, factor) を加えます.factor が0以下なら ValueError,正なら辺を倍率倍にします.左下の角 (self.x, self.y) は動かしません.

  • 出発点の a(辺 50)を a.move(0, 50) の後で a.scale(2) により2倍にし,a.size が 100,a.area() が 10000(元の 2500 の4倍)になることを assert で確かめる.b(辺 80)の面積は 6400 のまま変わらないことも確かめる.
  • a.scale(0) が ValueError になること(try/except で受け止める)と,拒否した後も a.size が 100 のままであることを assert で確かめる.
  • これもやりましょう:b.scale(0.5) で b を半分にし,b.size が 40,b.area() が 1600(元の 6400 の4分の1)になることを assert で確かめる.a.scale(-1) も ValueError になることを確かめる.最後に a と b を draw で描き,大きさの違いを見て確かめる.
評価の目安(10 段階)

必須(すべて満たして正しく動けば 8)

  1. Square に scale(self, factor) メソッドを定義し,self.size を倍率倍にしている
  2. factor が0以下(0 と -1)のとき ValueError を出し,拒否した後も size が変わっていないことを確かめている
  3. a だけを2倍にして,a の面積が 10000・b の面積が 6400 のままであることを assert で確かめている
  4. b.scale(0.5) で b.size が 40,面積が 1600 になることを assert で確かめ,2個を draw で描いている

発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)

  • 左下ではなく中心を保ったまま拡大する方法も考え,説明している
  • 倍率を何回かけたかを数える属性を持たせ,その値も確かめている

必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6

演習図形に点が入っているか

contains(self, x, y) を追加します.四角形は左下が(self.x,self.y),辺はsize,境界を含みます.点 (x, y) が入っていれば True,外なら False を返します.画面を見なくても確かめられます.

  • 出発点の a = Square(10, 20, 30, "blue") は,左下が (10, 20),右上が (40, 50) の四角形になる.次を assert で確かめる:内部 (20, 30) は True,左下の角 (10, 20) と右上の角 (40, 50) は True(境界を含む),外部 (41, 50) と (9, 20) は False.
  • 位置や大きさの違う2個目の四角形 b = Square(-50, -50, 10, "red") でも,(-45, -45) が True,(20, 30) が False になることを確かめる.
  • これもやりましょう:a.move(100, 0) で a を右に100動かした後,判定が新しい位置に追従することを確かめる.動かした後は (20, 30) が False,(120, 30) が True,右上の角 (140, 50) が True になる.
評価の目安(10 段階)

必須(すべて満たして正しく動けば 8)

  1. Square に contains(self, x, y) メソッドを定義し,self.x・self.y・self.size を使って判定している
  2. 内部・境界(左下と右上の角を含む)・外部をそれぞれ assert で確かめている
  3. 位置や大きさの違う2個目のインスタンスでも正しく判定できることを確かめている
  4. a.move(100, 0) の後,(20, 30) が False・(120, 30) と (140, 50) が True になることを確かめている

発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)

  • クリックした位置の図形を選ぶ処理(onscreenclick)へつないでいる
  • 2個の四角形が重なっているかを調べる操作も設計し,テストしている

必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6

次回に向けて,Square が覚える値と担当する処理を説明してください.『すべての図形を管理する』『作品全体を保存する』まで Square に入れるべきかを考えます.

復習:虫食いで確かめる

コードの ____ を埋めてください.実行前に結果を予測し,実行後に問題文の条件と照合しましょう.assert は条件が成り立つかを調べ,成り立たないとエラーになります.

虫食い虫食い・復習 1 初期化メソッド

生成時に大きさを保存するメソッド名を補います.Shape(40).size が40になることを確認してください.

fill_review_classes1_1.py
class Shape:
    def ____(self, size):
        self.size = size
shape = Shape(40)
assert shape.size == 40
print(shape.size)
虫食い虫食い・復習 2 自分の状態を読む

インスタンスの x に dx を足します.操作対象を表す引数を補い,移動後が15になることを確認してください.

fill_review_classes1_2.py
class Shape:
    def __init__(self):
        self.x = 10
    def move(____, dx):
        ____.x += dx
shape = Shape()
shape.move(5)
assert shape.x == 15
print(shape.x)
虫食い虫食い・復習 3 別々のインスタンス

a と b を別々に生成します.a の値を変えても b が0のままになる呼び出しを補ってください.

fill_review_classes1_3.py
class Shape:
    def __init__(self):
        self.x = 0
a = Shape()
b = ____
a.x = 50
assert b.x == 0
print(a.x, b.x)
虫食い虫食い・復習 4 リストを各自に持たせる

作品ごとの図形リストを初期化時に作ります.a に追加しても b が空のままになる空欄を補ってください.

fill_review_classes1_4.py
class Drawing:
    def __init__(self):
        ____ = []
a = Drawing()
b = Drawing()
a.shapes.append("square")
assert b.shapes == []
print(a.shapes, b.shapes)
虫食い虫食い・復習 5 メソッドの戻り値

正方形の面積を返します.辺6のインスタンスで面積36になるようにしてください.

fill_review_classes1_5.py
class Square:
    def __init__(self, size):
        self.size = size
    def area(self):
        ____ self.size ** ____
square = Square(6)
assert square.area() == 36
print(square.area())
虫食い虫食い・復習 6 生成時に検証する

0以下の大きさを拒否し,正の大きさを保存します.条件を補い,Shape(0) が拒否されることを確認してください.

fill_review_classes1_6.py
class Shape:
    def __init__(self, size):
        if size ____ 0:
            raise ValueError("大きさは正")
        self.size = size
assert Shape(10).size == 10
try:
    Shape(0)
except ValueError:
    print("0を拒否しました")
else:
    assert False