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

役割を分けて組み合わせる

図形を知る Square と,作品を管理する Scene.

この章でできるようになること
  • クラスごとの責任と協力関係を説明する
  • 継承を使わず,部品を持つ設計を作る
  • 1個の部品と全体を別々に検証する

24.1 何でも担当するクラスを避ける

担当覚えること公開する操作
Square座標・辺・色move / area / draw
Scene順序つきの図形リストadd / move_all / total_area / draw / count
メイン処理ペンと作品部品を作り,操作を呼ぶ

Scene は Square の一種ではなく,Square を持ちます.この関係を合成と呼びます.今回は class Scene(Square) のような継承は使いません.Turtle も Square が継承する親ではなく,draw に渡す描画用の道具です.

24.2 使う側との約束を決める

Scene.add は受け取った図形そのものを保持します.同じ図形を2つの作品に入れれば状態を共有します.別々に動かしたいなら別の Square を作ります._shapes の先頭の下線は『内部で使う』という慣習で,Python がアクセスを禁止する仕組みではありません.使う側は add や count を通じて扱います.

p24_scene.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)

class Scene:
    def __init__(self):
        self._shapes = []

    def add(self, shape):
        self._shapes.append(shape)

    def move_all(self, dx, dy):
        for shape in self._shapes:
            shape.move(dx, dy)

    def total_area(self):
        return sum(shape.area() for shape in self._shapes)

    def draw(self, pen):
        for shape in self._shapes:
            shape.draw(pen)

    def count(self):
        return len(self._shapes)

from turtle import Turtle, done
scene = Scene()
scene.add(Square(-100, 0, 40, "blue"))
scene.add(Square(0, 0, 60, "orange"))
scene.move_all(0, 30)
assert scene.count() == 2
assert scene.total_area() == 5200
assert Scene().count() == 0
pen = Turtle()
pen.hideturtle()
pen.speed(0)
scene.draw(pen)
print("図形数:", scene.count(), "面積の合計:", scene.total_area())
done()
ヒントtotal_area は各図形の面積の合計です.重なった部分も数えます.画面上で塗られた領域の面積とは違うので,仕様と名前を対応させます.

24.3 部品だけでも,全体でも確かめる

p24_test.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)

class Scene:
    def __init__(self):
        self._shapes = []

    def add(self, shape):
        self._shapes.append(shape)

    def move_all(self, dx, dy):
        for shape in self._shapes:
            shape.move(dx, dy)

    def total_area(self):
        return sum(shape.area() for shape in self._shapes)

    def draw(self, pen):
        for shape in self._shapes:
            shape.draw(pen)

    def count(self):
        return len(self._shapes)

empty = Scene()
assert empty.count() == 0
assert empty.total_area() == 0
empty.move_all(10, 20)
a = Square(0, 0, 10, "blue")
first = Scene()
first.add(a)
second = Scene()
second.add(Square(0, 0, 10, "red"))
first.move_all(10, 20)
assert (a.x, a.y) == (10, 20)
assert first.total_area() == 100
assert second.count() == 1
assert first.count() == 1
print("空・移動・独立したリストのテスト成功")

実習:役割と協力関係を一つずつ確かめる

ここでは完成したSceneを暗記するのではなく,要求が増えたときに誰の責任にするかを考えます.小さな部品で確かめた後,章の作品へ同じ設計を適用してください.

実習24-1 変更したい要求から,担当を分ける

前章では1個の図形を表すクラスを作りました.

次は,図形を何個も配置した作品を編集します.作品には「すべての図形を移動する」「色が同じ図形を数える」「最後に追加した図形を消す」といった操作が必要です.

これらをSquareへ入れると,1個の図形を表すはずの部品が,ほかの図形まで知る必要が出てきます.まず,誰が何を知っていればその操作を実行できるかを考えましょう.

1個の図形を移動するには,その図形の位置だけを知れば十分です.

全図形を移動するには,図形の一覧を知る必要があります.そこで,Squareは個々の位置と大きさを持ち,Sceneは図形の一覧を持つことにします.

Sceneが全体の操作を引き受け,各Squareへ必要な処理を依頼します.このように,変更の種類ごとに担当を分けると,追加の要求が来たときに,どこを直すかを判断しやすくなります.

担当を分けるとき,画面に登場する名詞をすべてクラスにする必要はありません.

色は文字列で十分かもしれませんし,座標はタプルで扱えるかもしれません.専用の操作や守りたい約束が増えてから,クラスへ分ける判断もできます.

今回は,状態と操作のまとまりがはっきりしているSquareとSceneを選びます.クラス数を増やすことを設計の良さと取り違えないようにしてください.

次の短い例では,図形の代わりにItemを使って,一覧を持つ側の役割だけを観察します.

Catalogは追加と個数の取得を担当し,Itemは自分の名前を覚えます.Catalogのcountは,Itemの名前の付け方を知る必要がありません.

反対にItemは,自分が何番目に追加されたかを知らなくても使えます.互いに必要以上の知識を持たせないことが,部品としての使いやすさにつながります.

w24_1_demo.py
class Item:
    def __init__(self, name):
        self.name = name
class Catalog:
    def __init__(self):
        self.items = []
    def add(self, item):
        self.items.append(item)
    def count(self):
        return len(self.items)
c = Catalog()
c.add(Item("青い四角"))
c.add(Item("赤い四角"))
print(c.count())
演習名前の一覧を返す担当

Catalogへnames(self)を追加し,保持しているItemの名前をリストで返してください.Itemに全体の一覧を持たせずに実装します.空のCatalogでは空リスト,2個追加した後は追加順に2つの名前が返ることをテストしてください.

設計を確かめる観点一覧を知っているのはCatalogです.Itemは名前を提供するだけでよく,すべてのItemを探し回る必要はありません.

実習24-2 『である』関係と『を持つ』関係

SceneはSquareを集めたものですが,Scene自体が1個のSquareなのではありません.

この場合は,Sceneの属性へSquareのリストを置きます.部品を持って全体を作る関係を合成と呼びます.

継承は,ある種類の特性を引き継いだ別の種類を作る仕組みですが,関係があるという理由だけで使うものではありません.まず「SceneはSquareである」と言えるかを考えると,関係の違いが見えてきます.

例えば,Sceneへsizeという属性を持たせても,どの図形の辺を指すか分かりません.

Scene.areaを定義する場合にも,1個の図形の面積と,作品内の図形の面積合計は意味が違います.名前や処理が少し似ているだけで継承すると,利用側が期待する意味を保てなくなります.

今回はScene.total_areaという名前にして,全体を集計する操作であることを明確にします.

合成はクラス同士だけの特別な技法ではありません.

リストの中へ数値を入れることや,辞書へ座標のタプルを入れることも,部品を組み合わせる発想につながります.クラスを導入したからといって,急にすべてを継承関係で表す必要はありません.

必要な情報と操作を持つ部品を作り,それを受け取って使うことで,かなり多くの作品を整理できます.

以下ではBagがCounterを2個持ちます.

Bag自身をCounterの子にせず,各部品の値を合計しています.部品が3個へ増えても,合計の処理はforで一般化できます.

個々の値を変更する操作と,合計を問い合わせる操作を分けて考えてください.次の演習では,空の部品リストを受け取った場合の意味も決めます.

「部品がないからエラー」か「合計0」かは,言語ではなく設計する側が決める約束です.

w24_2_demo.py
class Counter:
    def __init__(self, value):
        self.value = value
class Bag:
    def __init__(self, counters):
        self.counters = list(counters)
    def total(self):
        return sum(c.value for c in self.counters)
bag = Bag([Counter(3), Counter(7)])
print(bag.total())
演習空の作品から部品を加える

Bagにadd(counter)を追加し,Bag()と引数を省略しても空の作品として作れるようにしてください.既定値には[]を使わずNoneを使います(第22章).Bag()に3と-7のCounterを順に加えたときtotalが0,3,-4と変わり,別に作ったBag()は0のままであることを確かめます.合計の処理に条件分岐を増やさずに済む理由も説明してください.

設計を確かめる観点sumの初期値は0です.空の場合も含めて仕様に合う部品を選ぶと,不要な場合分けを減らせます.

実習24-3 追加した部品は,誰と共有するのか

Scene.add(shape)で受け取ったshapeをリストへ追加する場合,通常はその個体への参照を保存します.

呼び出した側のshapeと,Sceneの中の要素は同じ個体を指します.そのため,追加した後に呼び出し側からshape.moveを実行すると,Scene内の図形の位置も変わります.

これは前章の別名の問題が,クラスの境界をまたいで現れたものです.

どちらの挙動を選ぶかは,作品の仕様によります.

図形を選択して編集するアプリでは,同じ図形を共有しているほうが都合がよい場合があります.一方,テンプレートから独立した作品を作る場合には,元のテンプレートを変えないほうが自然です.

addは共有し,duplicateは独立した複製を作る,というように操作を分ける方法もあります.名前と説明で,利用側が違いを判断できるようにします.

一覧のリストだけをコピーしても,中の個体は共有されたままです.

list(shapes)は要素を入れる箱を新しくしますが,要素のSquareまで作り直しません.このため,元のリストへappendしてもコピー側の個数は変わりませんが,共有したSquareの座標を変えると両方から変化が見えます.

入れ物の独立性と,要素の独立性を別々にテストしてください.

例題では,元のリストへ新しい部品を追加する変更と,最初の部品の値を変更する操作を分けています.

結果を予測する際は,リストを表す箱を2つ,部品を表す箱を必要な数だけ描いてください.矢印がどこで共有されているかを確認すれば,結果を説明できます.

複雑なクラス構造でも,参照の基本は変わりません.「コピーしたから安全」と一括りにせず,何を新しくしたかを具体的に述べます.

w24_3_demo.py
class Item:
    def __init__(self, value):
        self.value = value
original = [Item(10)]
copy = list(original)
original.append(Item(20))
original[0].value = 99
print(len(original), len(copy), copy[0].value)
演習要素も独立させる

originalの各Itemから値を取り出して,新しいItemを作ることで独立したリストを作ってください.original[0].valueを変更しても,新しい側の値が10のままであることを確認します.今回の属性は数値だけという前提で解いてください.

設計を確かめる観点もしItemがさらにリストを持つなら,そのリストもどこまで独立させるか決めます.複製の深さはデータ構造と仕様に依存します.

実習24-4 内部の一覧を直接渡すと何が起こるか

Sceneの図形一覧を利用側へ返すとき,内部のリストをそのまま返すと,利用側が自由にappendやclearを実行できます.

Scene.addで個数や型を検査していても,その窓口を通らない変更ができてしまいます.読み取り用に一覧を見せたいだけなら,リストのコピーやタプルを返す方法があります.

何を変更してよいかを,返す値の形と説明で示します.

属性名の先頭に下線を付けた_sh​apesのような名前は,内部の実装として扱ってほしいという慣習を表します.

アクセスを機械的に禁止するものではありません.使う側は,下線の付いた属性へ直接依存せず,公開された操作を使います.

これにより,内部の保存方法をリストから別の形へ変更しても,使う側の修正を少なくできます.これは協力して開発する際の約束でもあります.

タプルに変換して返しても,中の図形の属性まで変更不能になるわけではありません.

tuple(self._items)は一覧の追加・削除を防ぎますが,要素が可変の個体ならその状態は共有されています.完全に観察用の値がほしい場合は,名前や座標など必要な値だけを取り出して返す方法もあります.

一覧の構造を守りたいのか,要素の状態も守りたいのかを分けて考えてください.

以下の例では数値だけの一覧を扱います.

valuesが返すリストを変更しても,内部の件数は変わりません.この単純な例で窓口の意味を確認した後,図形のリストへ戻って考えます.

設計の説明では「外から変更できない」と断言せず,「公開するvaluesは新しいリストを返すので,そのリストへの追加は内部に影響しない」と,保証する範囲を具体的に書きましょう.

w24_4_demo.py
class Scores:
    def __init__(self):
        self._values = []
    def add(self, value):
        self._values.append(value)
    def values(self):
        return list(self._values)
s = Scores()
s.add(10)
view = s.values()
view.append(99)
print(s.values(), view)
演習返された一覧の変更を検証する

ひな形のvaluesは内部のリストをそのまま返しています.まずScoresへ10と20を追加し,valuesで得たリストをclearすると内部まで空になることを確かめてください.その後,返されたリストを変更しても内部の値が[10,20]のまま残るようにvaluesを直し,assertで確かめます.

設計を確かめる観点内部リストへ直接触れるテストではなく,利用側に公開した操作だけで確かめると,保存方法を変更してもテストを保ちやすくなります.

実習24-5 同じ操作を持つ部品を組み合わせる

作品に四角形だけでなく円も加えたい場合,Sceneが図形の種類ごとに長いif文を持つ設計が考えられます.

しかし,図形が増えるたびにSceneを変更する必要が出てきます.別の方法として,各図形にareaという同じ名前の操作を用意し,Sceneはその操作を呼ぶだけにできます.

図形自身が,自分の面積の計算方法を知っているという分担です.

同じ操作を用意する際は,名前だけでなく意味もそろえます.

Square.areaが数値を返すのにCircle.areaが画面へ文字を表示するだけでは,合計できません.引数の取り方,戻り値の種類,状態を変えるかどうかをそろえる必要があります.

今回の約束は「引数を追加で受け取らず,状態を変えず,面積の数値を返す」です.これが部品を差し替えて使うための接点になります.

この例では,継承を使わなくても共通の操作を呼べます.

Pythonでは,実際に必要な操作を持つ相手であれば,その操作を実行できます.ただし,名前を間違えたり,必要な操作がなかったりすると実行時にエラーになります.

小さいテストで,各部品が約束を満たすか確認してから,全体へ組み込みます.種類が違っても同じ呼び出し方で扱えることを,まず具体例で理解してください.

以下はSquareとRectangleです.

面積の計算は違いますが,呼び出す側のforは同じです.新しいTriangleを追加するとき,合計する側を変更せずに済むか考えてみましょう.

一方,すべての図形に無理に同じ操作を持たせる必要はありません.共通に扱いたい場面で,本当に同じ意味を持つ操作だけをそろえます.

名前をそろえることと,責任を無理に押し付けることは別です.

w24_5_demo.py
class Square:
    def __init__(self, size):
        self.size = size
    def area(self):
        return self.size ** 2
class Rectangle:
    def __init__(self, width, height):
        self.width, self.height = width, height
    def area(self):
        return self.width * self.height
shapes = [Square(10), Rectangle(10, 20)]
print(sum(shape.area() for shape in shapes))
演習三角形を追加する

底辺と高さを持つTriangleを追加し,areaで底辺×高さ÷2を返します.Square(10),Rectangle(10,20),Triangle(10,6)を同じリストへ入れ,合計330になることを確認してください.集計側に図形名のifを追加しないで解きます.

設計を確かめる観点面積の式を知るのはTriangleです.Sceneに計算式を集める方法と比べて,新しい種類を追加するときに変更する場所を確認します.

実習24-6 画面を開かずに,描画の依頼を検査する

描画が正しいかを調べるには,実際の絵を確認することが必要です.

ただし,毎回画面を目で見るだけでは,図形数が増えた場合や,描画順の問題を調べるときに時間がかかります.Sceneが各図形のdrawを正しい順に呼ぶかどうかは,線の見た目とは別にテストできます.

テスト用の小さな部品を用意し,呼ばれた順を記録する方法を試します.

Probeは本物の図形ではありませんが,draw(pen)という同じ操作を持ちます.

呼び出されると,自分の名前をlogへ追加します.SceneがProbeを3個描けば,logを見て呼び出しの順番と回数を確認できます.

この方法で確かめられるのは,Sceneの依頼の仕方です.Squareの線の角度が正しいかまでは保証しません.

テストが確認する範囲を限定して考えることが大切です.

このように,部品の代わりを小さなものに置き換えて調べると,問題の場所を絞れます.

Sceneのテストでは呼び出し順,Squareのテストでは属性の変化や描画命令,最後の画面確認では見た目と操作を調べます.一つの大きなテストだけに頼るより,どの約束が破れたかが分かりやすくなります.

実際のTurtleを使う確認を省略するためではなく,組み合わせる前の確認を増やすための方法です.

例題では同じlogを3個のProbeへ渡しています.

ここでは共有が意図的です.全体の順序を1つの一覧へ記録したいからです.

前章で避けた共有と何が違うかを説明してください.共有を機械的に禁止するのではなく,何をまとめて観察したいかによって選びます.

設計上の判断には,状態を独立させる場合と,協力のために共有する場合の両方があります.

w24_6_demo.py
class Probe:
    def __init__(self, name, log):
        self.name, self.log = name, log
    def draw(self, pen):
        self.log.append(self.name)
class Scene:
    def __init__(self, shapes):
        self.shapes = list(shapes)
    def draw(self, pen):
        for shape in self.shapes:
            shape.draw(pen)
log = []
scene = Scene([Probe("A", log), Probe("B", log), Probe("C", log)])
scene.draw(None)
assert log == ["A", "B", "C"]
print(log)
演習2回の描画を区別する

Scene.drawを2回呼んだ場合,logが[A,B,A,B]になることを確認します.次に,各回の前にlogをclearして,1回分の呼び出しだけを確認する方法へ変更してください.draw自体が図形一覧を消していないことも確かめます.

設計を確かめる観点NoneはこのProbeがペンを使わないため渡せます.実際のSquare.drawへNoneを渡しても描画できる,という意味ではありません.

実習24-7 図形を探す操作と,見つからない場合

作品が大きくなると,名前やIDを指定して図形を探したくなります.

探す処理を使うたびにfor文を書き直すと,見つからなかった場合の扱いが場所ごとに違ってしまいます.そこで,図形一覧を持つSceneへfindという操作を用意します.

1個の図形は自分のIDを持ちますが,全体からそのIDを探す責任はSceneに置きます.責任を分ける基準は,必要な情報を誰が持っているかです.

見つからなかった場合には,Noneを返す仕様が考えられます.

利用側は結果がNoneかどうかを確認してから操作します.別の設計として,必ず存在するはずのIDなら例外を出す方法もあります.

どちらが正しいかは利用場面によります.この章では,ユーザが未登録の名前を入力することもあるので,見つからないことを通常の結果としてNoneで表します.

IDを使うなら,重複を許すかも決めます.

同じIDが2個ある場合,最初の1個を返すのか,すべてを返すのかで使い方が変わります.ここでは追加時に重複を拒否し,IDが1個の図形を特定する約束にします.

重複の検査をfind側だけへ押し付けると,不正な一覧が残ります.状態が作られる入口で約束を守る,という前章の考え方を全体へ広げます.

以下では,存在するIDと存在しないIDの両方を試します.

テストでは,見つかった個体が元の個体そのものであることもisで確かめます.コピーを返す仕様にすると,見つけた結果を移動しても作品側へ反映されません.

検索結果を編集に使いたいので,今回は共有した個体を返します.操作の戻り値に何を期待するかを,利用目的から説明してください.

w24_7_demo.py
class Item:
    def __init__(self, key):
        self.key = key
class Scene:
    def __init__(self):
        self.items = []
    def find(self, key):
        for item in self.items:
            if item.key == key:
                return item
        return None
    def add(self, item):
        if self.find(item.key) is not None:
            raise ValueError("IDが重複")
        self.items.append(item)
s = Scene()
a = Item("a")
s.add(a)
assert s.find("a") is a
assert s.find("missing") is None
print("検索成功")
演習重複時にも元の状態を保つ

同じIDのItemを追加したときにValueErrorが出て,元のItemが検索できることをテストしてください.重複した追加が失敗した後も,要素数は1のままです.検査より先にappendすると何が起こるかを説明します.

設計を確かめる観点検査はappendより前に置きます.重複を拒否した後もitemsが変わっていないことを,件数と検索の両方で確かめます.

実習24-8 削除と並べ替えは,全体を知る側へ

ある色の図形をすべて削除する操作は,図形一覧を知っているSceneが担当します.

Squareには自分の色を問い合わせれば十分で,自分を一覧から消すためにSceneの内部へ触らせる必要はありません.部品から管理側の内部を変更する設計は,関係が複雑になりやすいので,まず管理側が部品を調べて一覧を作り直す形を考えます.

削除では,第22章で確認した「走査中のリストを変更しない」方法を使います.

残す要素だけを新しいリストに集め,最後に一覧を置き換えます.同じ色が隣り合っていても取りこぼしません.

戻り値として削除した個数を返すと,利用側は「2個削除しました」と表示できます.何も一致しない場合は0を返し,エラーにしない仕様にします.

並べ替えも,全体の順序に関する操作です.

図形自身の大きさは変えず,一覧での位置だけを変えます.描画順が変わると,重なり方は変わるかもしれませんが,各図形の座標や形は変わりません.

状態のどの部分を変更する操作かを,個体の属性と一覧の構造に分けて説明してください.一覧を返すだけのsortedと,その場で変更するsortの違いも復習します.

例題は数値の一覧を使い,フィルタと順序の変更を観察します.

その後で図形の色や面積へ置き換えれば,同じ設計が使えます.少ない数値でアルゴリズムを確かめてから,描画する作品へ組み込む流れです.

テストには該当なし,全件該当,一部だけ該当する入力を用意します.通常の1例だけでは,空になった場合の扱いを見落としやすいからです.

w24_8_demo.py
class Values:
    def __init__(self, values):
        self.items = list(values)
    def remove_small(self, limit):
        before = len(self.items)
        self.items = [x for x in self.items if x >= limit]
        return before - len(self.items)
    def sort_desc(self):
        self.items.sort(reverse=True)
v = Values([1, 1, 3, 2])
print(v.remove_small(2), v.items)
v.sort_desc()
print(v.items)
演習削除の境界をテストする

例題を見ずにremove_smallを実装し,空の一覧,削除なし,全部削除,連続した小さい値の4例を確認してください.走査中のリストからremoveせず,残す値で一覧を作り直します.limitと等しい値は残す仕様です.戻り値と変更後の一覧を両方検査します.

設計を確かめる観点空になった一覧でも,countやdrawが正常に使える設計にしておけば,削除後だけの特別な状態を減らせます.

実習24-9 元に戻す操作は,どの状態を残すかから考える

編集できる作品には,操作を元に戻す機能があると便利です.

ただし,undoというメソッド名を付けただけで戻せるわけではありません.変更前の状態を保存するのか,逆向きの操作を記録するのかを決める必要があります.

最初は,変更前の座標をタプルで残す方式が分かりやすいでしょう.数値とタプルなら,後の移動によって過去の記録が変わることを避けられます.

履歴は,直近の操作から取り消すのでスタックとして使います.

moveの前に現在位置をappendし,undoでは最後の位置をpopして戻します.履歴が空の場合は何もせずFalseを返す仕様にします.

戻せた場合はTrueを返せば,利用側はボタンの表示やメッセージを変えられます.履歴が空のときにIndexErrorになるまま放置しないようにします.

作品全体のundoを作る場合,どの範囲を1回の操作と数えるかも決めます.

図形3個の一括移動を1回で戻したいなら,個々の図形へ別々の履歴を持たせるだけでは扱いにくいかもしれません.全体操作を引き受けるSceneが履歴を管理する設計が考えられます.

ここではまず1個の位置で仕組みを確かめ,その後で責任をどこへ置くか比較します.

以下の例では2回移動して1回戻します.

実行前に位置と履歴の中身を紙へ書いてください.undo後にさらに新しい移動を行った場合,どの位置が履歴に入るかも考えます.

この例にはredoはありません.元に戻す機能と,戻した操作をやり直す機能は別の要求です.

最初から全部を詰め込まず,まず明確な仕様を1つ実装し,テストしてから次の要求へ進みます.

w24_9_demo.py
class Position:
    def __init__(self):
        self.x, self.y = 0, 0
        self.history = []
    def move(self, dx, dy):
        self.history.append((self.x, self.y))
        self.x += dx
        self.y += dy
    def undo(self):
        if not self.history:
            return False
        self.x, self.y = self.history.pop()
        return True
p = Position()
p.move(10, 0)
p.move(0, 20)
p.undo()
print(p.x, p.y)
演習絶対位置への移動も戻せるようにする

Positionにmove_to(x, y)を追加してください.指定した位置へ直接移動し,undoで移動前の位置へ戻せるようにします.move(10,0),move_to(50,50)の順に操作した後,undoを3回呼ぶと,位置が(10,0),(0,0)と戻り,3回目はFalseを返すことを確かめます.

設計を確かめる観点move_toでも,状態を変える前に現在位置を履歴へ積みます.位置を可変リストで共有して記録すると過去の位置まで変わる場合があるので,数値のタプルで記録します.

実習24-10 データへ変換する責任と,保存する責任

作品を保存するために,図形の情報を辞書へ変換したいとします.

Squareは,自分が持つ座標・大きさ・色を知っているので,to_dataという操作でそれらを返せます.Sceneは,すべての図形のto_dataを呼び,作品全体のリストへまとめます.

ファイルへ書く処理は,さらに外側へ置くことができます.データを作る処理と,保存先を選ぶ処理を分ける設計です.

この分担なら,画面へ表示するためにも,JSONへ保存するためにも,同じデータを利用できます.

Squareがファイル名や文字コードまで知る必要はありません.保存先がブラウザの一時ファイルから手元のフォルダへ変わっても,Squareの座標の扱いは変わりません.

変更の理由が違うものを分けると,後の機能追加で修正する範囲を絞れます.

to_dataで返す辞書には,必要な属性だけを明示します.

個体の内部を何でも取り出して保存すると,描画用のペンや一時的な選択状態まで混ざるかもしれません.保存したい作品の意味を表すデータと,動作中だけ必要な道具を区別してください.

第25章では形式のバージョンと検証を加えますが,その前に,何を外へ渡すかを設計しておくことが大切です.

返した辞書を書き換えたとき,元の個体へ影響するかも確かめます.

この例は数値と文字列を新しい辞書へ入れるため,辞書のxを変えても元のxは変わりません.ただし,属性にリストを含めてそのまま返す場合は共有が残ります.

公開するデータの構造に応じてテストを追加しましょう.変換処理があるというだけで,すべてが独立するとは限りません.

w24_10_demo.py
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def to_data(self):
        return {"x": self.x, "y": self.y}
p = Point(10, 20)
data = p.to_data()
data["x"] = 99
assert p.x == 10
print(data, p.to_data())
演習作品全体のデータを作る

Pointを2個持つSceneを作り,Scene.to_dataが各Pointの辞書をリストへまとめて返すようにしてください.Scene側でp.xやp.yを直接読む代わりに,Point.to_dataを呼ぶ設計にします.空のSceneでも空リストを返します.

設計を確かめる観点Sceneは各Pointの保存形式の細部を知りません.Pointの属性が増えたとき,変更する場所をどこに集められるか考えます.

実習24-11 入力を受け取る係と,状態を更新する係

キーを押して図形を動かす作品では,入力イベントと座標の更新が同じ関数へ混ざりやすくなります.

最初はそれでも動きますが,マウス操作を追加したり,自動テストから動かしたりすると,同じ更新処理を複数の場所へ書くことになります.入力を「右へ10移動する」という操作へ変換する係と,実際に位置を更新する係を分ける方法を考えます.

Positionはmove(dx,dy)だけを担当し,どのキーが押されたかを知りません.

Controllerは入力名を受け取り,対応する移動量を決めてPosition.moveを呼びます.こうすると,キーの割り当てを変更してもPositionを直す必要はありません.

また,Positionをテストするときはキーボードを使わず,直接moveを呼べます.部品ごとに異なる変更の理由を持たせる設計です.

Controllerは必ずクラスでなければならないわけではありません.

入力変換が小さく,状態を持たないなら関数で十分です.ここでは,操作対象のPositionを覚える例としてクラスを使います.

クラスが必要かどうかを判断する際は,どの状態を保持して,どの操作から使うのかを確認してください.クラスを使う練習でも,不要な部品まで増やさない意識が重要です.

例題では,実際のキーの代わりに文字列の入力列を渡します.

この方法なら,同じ操作を何度でも再現できます.入力列から最終位置を予測し,未知の入力を入れても位置が変わらないことを確かめてください.

その後,onkeyのコールバックからController.handleを呼ぶ形へつなげられます.画面や入力装置を使う前に,操作の意味を小さな値で検証する流れです.

w24_11_demo.py
class Position:
    def __init__(self):
        self.x, self.y = 0, 0
    def move(self, dx, dy):
        self.x += dx
        self.y += dy
class Controller:
    def __init__(self, target):
        self.target = target
    def handle(self, key):
        delta = {"right": (10,0), "up": (0,10)}.get(key)
        if delta is not None:
            self.target.move(*delta)
p = Position()
c = Controller(p)
for key in ["right", "up", "right", "unknown"]:
    c.handle(key)
print(p.x,p.y)
演習上下左右の入力をそろえる

Controllerへleftとdownを追加してください.right,right,left,up,downという入力列を与えると,最終位置が(10,0)になります.Positionは変更せず,入力の対応だけを拡張します.

設計を確かめる観点操作対象の位置を直接書き換えるより,moveという公開した窓口を使うほうが,後から履歴を付ける変更にも対応しやすくなります.

実習24-12 設計を変える前に,比較できる結果を残す

動いているプログラムを,役割が分かる形へ整理し直すことをリファクタリングと呼びます.

このとき,作品の振る舞いは保つことを目指します.例えば,辞書と関数で動かしていた図形をクラスへ移す場合,移動後の座標や面積は同じであるべきです.

先に比較用の入力と結果を残しておくと,整理の途中で機能まで変えてしまったことに気付けます.

比較するのは,内部の変数名やコードの行数ではありません.

利用側が受け取る結果や,画面に現れる振る舞いです.辞書版とクラス版で内部表現が違う場合は,最終座標をタプルへそろえて比較できます.

これを基準にしてから,少しずつ処理を移します.一度に描画方式,操作方法,データ構造をすべて変更すると,どの変更で結果が変わったか分かりにくくなります.

設計レビューでは,クラス図をきれいに描いたかだけでなく,具体的な変更要求を当てはめます.

「色を増やす」「移動量を変える」「保存先を変える」「図形を1種類増やす」といった要求について,どの部品を直すかを説明します.無関係な部品まで修正する必要があるなら,責任が混ざっていないか見直します.

ただし,将来のあらゆる要求に備えて複雑にする必要はありません.

以下では,同じ移動列を辞書版とクラス版へ適用します.

結果が一致した後で,クラス版に履歴などの新しい機能を追加できます.まず既存の動作を保つ変更と,新しい機能の追加を分けて進めるのが重要です.

最後の演習では,空の操作列,負の移動量,元へ戻る移動も比較してください.日常的な操作だけでなく,値の端や打ち消し合う操作が,設計の確認に役立ちます.

w24_12_demo.py
def old_run(actions):
    p = {"x":0,"y":0}
    for dx,dy in actions:
        p["x"] += dx
        p["y"] += dy
    return p["x"],p["y"]
class Position:
    def __init__(self):
        self.x,self.y = 0,0
    def move(self,dx,dy):
        self.x += dx
        self.y += dy
def new_run(actions):
    p = Position()
    for dx,dy in actions:
        p.move(dx,dy)
    return p.x,p.y
actions = [(10,0),(0,20),(-5,0)]
assert old_run(actions) == new_run(actions)
print(new_run(actions))
演習複数の操作列で比較する

例題のold_runとnew_runを使い,空の操作列,1回の移動,負方向への移動,往復で原点へ戻る移動の4種類を比較してください.2つの実装が同じ間違いをしている可能性もあるため,期待する最終位置とも比較します.

設計を確かめる観点実装同士の一致だけで正しいとは限りません.仕様から独立に決めた期待値も用意すると,両方に共通した不具合を見つけられます.

24.4 どちらの責任か考えて拡張する

演習色を指定して図形を除く

Scene に remove_color(self, color) を追加してください.同じ色が連続していてもすべて除き,取り除いた個数を返します.1個の Square にこの処理を入れない理由も,コメントで1行説明してください.

  • 走査しているリストから remove で消すと,連続した2個目を飛ばしてしまう.残す図形だけを内包表記で選んで self._shapes を作り直す.
  • 次の3つの場合を assert で確かめる.空の Scene で remove_color("blue") が 0.Square(0, 0, 10, "blue")・Square(20, 0, 10, "blue")・Square(40, 0, 10, "red") の順に add した後,remove_color("green") が 0(該当なし),remove_color("blue") が 2(2個連続),その後 count() が 1.
  • これもやりましょう:blue を除いた後,total_area() が 100(残った red 1個ぶん)になること,もう一度 remove_color("blue") を呼ぶと 0 が返ることも確かめる.確かめるときは _shapes に直接触れず,count・total_area などの公開した操作だけを使う.
評価の目安(10 段階)

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

  1. Scene に remove_color(self, color) を定義し,走査中のリストを変えずに,残す図形で self._shapes を作り直している
  2. 取り除いた個数を返し,空(0)・該当なし(0)・同じ色2個連続(2)の3つの場合を assert で確かめている
  3. 削除後の count() が 1,total_area() が 100,2回目の remove_color("blue") が 0 であることを,公開した操作だけで確かめている
  4. Square には削除の処理を入れず,その理由をコメントで書いている

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

  • 削除した後に draw して,残った図形だけが描かれることを確かめている
  • 複数の色をまとめて受け取れるようにしている
  • 削除した図形の一覧を返す別の操作も設計し,使い分けを説明している

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

演習移動を元に戻す

Scene に移動の履歴を持たせ,最後の move_all を取り消す undo_move() を追加してください.履歴は作品ごとに __init__ で作り,move_all のたびに (dx, dy) を記録します.undo_move は最後の記録を取り出して全図形を逆向きに動かし,戻せたら True,履歴が空なら False を返します.

  • 履歴の属性名は self.history(タプル (dx, dy) のリスト)とする.
  • 2つの作品 a と b を作り,a にだけ s = Square(0, 0, 10, "blue") を add して a.move_all(10, 20) を呼ぶ.s の位置が (10, 20) になり,b.history が [] のままであることを assert で確かめる.
  • a.undo_move() が True を返して s が (0, 0) に戻ること,もう一度呼ぶと(履歴が空なので)False を返すことを assert で確かめる.
  • これもやりましょう:移動を2回続けたときに新しいほうから順に戻ることを確かめる.a.move_all(10, 20) と a.move_all(5, -5) の後は s が (15, 15).1回目の undo_move() で (10, 20),2回目で (0, 0) に戻る.
評価の目安(10 段階)

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

  1. Scene の __init__ で作品ごとの self.history を作り,move_all のたびに (dx, dy) を記録している
  2. undo_move() で最後の移動を取り消し,全図形が移動前の位置へ戻ることを assert で確かめている
  3. 履歴が空のときの undo_move() は False を返し,エラーにならない
  4. 2つの作品のうち片方だけを動かしても,もう片方の履歴が空のままであることを確かめている
  5. 2回移動した後,undo_move() で (15, 15) → (10, 20) → (0, 0) と新しいほうから順に戻ることを確かめている

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

  • 取り消した移動をやり直す redo_move も作っている
  • 図形の追加(add)も取り消せるように,履歴に操作の種類を記録している
  • キー操作(onkey)から undo_move を呼び,画面を描き直している

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

設計レビュー:図形を移動する変更と,図形を削除する変更がそれぞれどのクラスに収まるか説明します.次章では画面のペンを保存せず,作品のデータだけをJSONにします.

提出前の設計レビュー

完成した作品について,まず『図形を1種類増やす』『キーの割り当てを変える』『保存する属性を増やす』という3つの変更を想定してください.それぞれ,変更するクラスと変更しないクラスを挙げ,なぜその分担になるかを説明します.実際にすべてを追加する必要はありませんが,変更箇所をコード上で示せるようにします.

次に,友人が利用する部品として,生成方法,公開するメソッド,引数,戻り値,失敗時の挙動をまとめます.内部の属性一覧だけでは,使い方の説明になりません.例えばremove_colorなら『何色を消すか受け取り,該当する図形をすべて除き,削除数を返す.該当なしは0』まで書きます.説明とテストが同じ仕様を指しているか確認してください.

復習:虫食いで確かめる

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

虫食い虫食い・復習 1 作品が図形を持つ

Drawing に Shape のインスタンスを追加します.作品の最初の図形の大きさが30になるようにしてください.

fill_review_classes2_1.py
class Shape:
    def __init__(self, size):
        self.size = size
class Drawing:
    def __init__(self):
        self.shapes = []
    def add(self, shape):
        self.shapes.____(shape)
work = Drawing()
work.add(____)
assert work.shapes[0].size == 30
print(work.shapes[0].size)
虫食い虫食い・復習 2 操作を図形に任せる

作品側は各図形の move を呼びます.x がそれぞれ5と15になるようにしてください.

fill_review_classes2_2.py
class Shape:
    def __init__(self, x):
        self.x = x
    def move(self, dx):
        self.x += dx
shapes = [Shape(0), Shape(10)]
for shape in shapes:
    shape.____(5)
assert [s.x for s in shapes] == [5, 15]
print([s.x for s in shapes])
虫食い虫食い・復習 3 公開する追加操作

外からは add を使って図形を追加します.内部リストへ直接追加せず,メソッドを呼ぶ空欄を補ってください.

fill_review_classes2_3.py
class Drawing:
    def __init__(self):
        self._shapes = []
    def add(self, shape):
        self._shapes.append(shape)
    def count(self):
        return len(self._shapes)
work = Drawing()
work.____("square")
assert work.count() == 1
print(work.count())
虫食い虫食い・復習 4 空の作品を扱う

最後の図形を消す前に空かどうか確認します.空の作品で呼んでもエラーにならない条件を補ってください.

fill_review_classes2_4.py
class Drawing:
    def __init__(self):
        self.shapes = []
    def undo(self):
        if ____:
            self.shapes.pop()
work = Drawing()
work.undo()
assert work.shapes == []
print("空でも安全です")
虫食い虫食い・復習 5 データへ変換する責任

図形自身が辞書を返します.二つの図形のデータ一覧を作るメソッド名を補ってください.

fill_review_classes2_5.py
class Shape:
    def __init__(self, size):
        self.size = size
    def to_data(self):
        return {"size": self.size}
shapes = [Shape(20), Shape(40)]
data = [s.____() for s in shapes]
assert data == [{"size": 20}, {"size": 40}]
print(data)
虫食い虫食い・復習 6 構成を独立して検証する

二作品にそれぞれ独立した図形を追加します.一方を変更しても他方が10のままになる生成式を補ってください.

fill_review_classes2_6.py
class Shape:
    def __init__(self, size):
        self.size = size
class Drawing:
    def __init__(self):
        self.shapes = []
    def add(self, shape):
        self.shapes.append(shape)
a, b = Drawing(), Drawing()
a.add(Shape(10))
b.add(____)
a.shapes[0].size = 30
assert b.shapes[0].size == 10
print(a.shapes[0].size, b.shapes[0].size)