6. プログラムの修正と拡張¶
前章に引き続き,質点の運動学シミュレーションを拡張していきます.具体的には,画面の端で消える代わりに跳ね返る質点を追加します.
前章の particle.py
のコードをちゃちゃっとと書き換えてそのような改変をすること自体は簡単です.数行追加する程度で実現できますので,腕に覚えのある人ならわずか数分でできることです.
しかし,一般にプログラムの規模が大きく複雑になってくると,修正や拡張はどんどん難しくなっていきます.よく考えて行わないとあっという間に手に負えないグチャグチャなプログラムに育ちます.
この章では,プログラムを継続的にメンテナンスし,拡張しやすくするための技術のうち代表的なもの,具体的には,クラスの継承,オブジェクト合成の例を見ていきます.プログラムの修正作業を助ける単体テストについても紹介します.
6.1. モジュール¶
プログラムがだんだん大きくなってきたので,機能追加を始める前に複数のファイルに分けておくことにします.
新たに projectile_main.py
を作成し,クラス AppMain
の定義をこちらに移動してください.World
と Particle
はそのまま
particle.py
に残します.
1import pygame
2
3
4class World:
5 def __init__(self, width, height, dt, gy):
6 self.width = width
7 self.height = height
8 self.dt = dt
9 self.gy = gy
10
11
12class Particle:
13 def __init__(self, pos, vel, world, radius=10.0, color="green"):
14 self.is_alive = True
15 self.x, self.y = pos
16 self.vx, self.vy = vel
17 self.world = world
18 self.radius = radius
19 self.color = pygame.Color(color)
20
21 def update(self):
22 self.vy += self.world.gy * self.world.dt
23 self.x += self.vx * self.world.dt
24 self.y += self.vy * self.world.dt
25 if self.x < 0 or self.x > self.world.width or self.y > self.world.height:
26 self.is_alive = False
27
28 def draw(self, screen):
29 pygame.draw.circle(screen, self.color, (self.x, self.y), self.radius)
1import random
2import pygame
3import particle
4
5
6class AppMain:
7 def __init__(self):
8 pygame.init()
9 width, height = 600, 400
10 self.screen = pygame.display.set_mode((width, height))
11 self.world = particle.World(width, height, dt=1.0, gy=0.5)
12 self.particle_list = []
13
14 def update(self):
15 for p in self.particle_list:
16 p.update()
17 self.particle_list[:] = [p for p in self.particle_list if p.is_alive]
18
19 def draw(self):
20 self.screen.fill(pygame.Color("black"))
21 for p in self.particle_list:
22 p.draw(self.screen)
23 pygame.display.update()
24
25 def add_particle(self, pos, button):
26 if button == 1:
27 vx = random.uniform(-10, 10)
28 vy = random.uniform(-10, 0)
29 p = particle.Particle(pos, (vx, vy), self.world)
30 self.particle_list.append(p)
31
32 def run(self):
33 clock = pygame.time.Clock()
34
35 while True:
36 frames_per_second = 60
37 clock.tick(frames_per_second)
38
39 should_quit = False
40 for event in pygame.event.get():
41 if event.type == pygame.QUIT:
42 should_quit = True
43 elif event.type == pygame.KEYDOWN:
44 if event.key == pygame.K_ESCAPE:
45 should_quit = True
46 elif event.type == pygame.MOUSEBUTTONDOWN:
47 self.add_particle(event.pos, event.button)
48 if should_quit:
49 break
50
51 self.update()
52 self.draw()
53
54 pygame.quit()
55
56
57if __name__ == "__main__":
58 app = AppMain()
59 app.run()
- 2-3行目
particle.py
内の定義を使うため,projectile_main.py
の先頭にimport particle
を入れます.なお,
random.uniform
を使っているのも AppMain だけなので,import random
もこちらに移します.- 11行目
World
とParticle
は particle モジュールに属するものになりました.それぞれparticle.World
とparticle.Particle
に書き換える必要があります.(もしくは import 行を,from particle import World, Particle
とする必要があります)
これにより,自作モジュール particle が出来上がり,それを利用するプログラム projectile_main.py
と分離されました.
実行してみて,以前と同様に動くことを確認してから,VSCode の Source Control サイドバーでファイルを 2 つともステージに上げ,コミットしてください.
particle_main.py
を表示した状態になっていますか?配布環境の初期設定では,particle.py
がアクティブな状態で実行すると,クラスを定義するだけで終わってしまいます.
毎回 particle_main.py
をアクティブにしてから実行するのが面倒であれば,以下のようにして常にこのファイルを実行するような設定 (Configuration) を追加することもできます.
- を選び,右下のボタンで Add Configuration を選択
"program": "${file}"
のところにparticle_main.py
を指定する
作成した Configuration は,Run サイドバーの上部で選ぶことができます.
particle_main.py
は新しいファイルなのになぜ ver 12.0 なんですか?プログラムが複数のソースファイルに分かれると,それらのバージョンを一括して管理することが重要になります.そうしないと「このバージョンの particle_main.py
を動かせる particle.py
は一体どのバージョンだっけ?」という残念な状況になります.
実際,Git のコミットには複数のファイルを含むことができ,それらは一括して管理されます.
このテキストでも,同じバージョン番号がついた2つ (または3つ以上) のファイルは,同じ時点のファイルであると理解してください.
6.2. 単体テスト¶
6.2.1. 単体テストの心理的効果¶
既にあるプログラムに機能を追加したり,あるいは機能を変えずに修正する (リファクタする) 際に,元の機能を壊さないようにする必要があります.そして,大規模なプログラムではこれは思いのほか難しいことです.
新しい機能を作って「よし,動いた」と思っていたのに,しばらくしてから既存機能にバグが生じていたことに気づいた (そしてその頃にはどこを変えたかもう覚えてない) なんて話はよくあることで,経験のある人も多いのではないかと思います.
このような経験を繰り返すと「今うまく動いているコードはできれば変更したくない」という心理がはたらくようになります.もう少し綺麗に書き直せるはずだとわかっていても,修正を先送りしたくなります.結果として,いざ機能変更の必要が生じたときに綺麗に直せなくなり,場当たり的な修正が繰り返されて,どんどん手に負えなくなっていく負のスパイラルに陥ります.
このような事態に陥りにくくする方法の一つが,テストを書くことです.プログラムの一部 (例えば関数やメソッド) が,正しい結果を返すかどうかを確認する小さなプログラム群を用意しておいて,それらをこまめに (何なら修正が生じるたびに自動的に) 実行するようにしておきます.こうすると,少なくともテストがカバーする範囲では元の機能が壊れていないことを確信しながらプログラムを整理してくことができるので,心理的な障壁が格段に低くなります.
この実習の開発環境には,pytest と呼ぶ Python プログラムのテスト用ツールをインストール済みです.ちょっと試してみましょう.
新しいファイル test_particle.py
を作り,以下の内容を保存してください.
1import particle
2
3
4def test_particle_move():
5 width, height = 600, 400
6 dt, g = 2, 4
7 w = particle.World(width, height, dt, g)
8 x0, y0, vx0, vy0 = 0, 0, 5, 10
9 p = particle.Particle((x0, y0), (vx0, vy0), w)
10 p.update()
11 assert p.x == x0 + vx0 * dt
12 assert p.y == y0 + (vy0 + g * dt) * dt
13 p.update()
14 assert p.x == x0 + 2 * vx0 * dt
15 assert p.y == y0 + (vy0 + g * dt) * dt + (vy0 + 2 * g * dt) * dt
コマンドプロンプト (あるいは VSCode 内のターミナル) で
C:\cs1\projects\particle>pytest
と実行すると,test
という文字列から始まる名前のファイルの中にある
test
という文字列から始まる名前の関数が自動で検出され,実行されます.
関数の内容は,ほぼ見ての通りです.重要なのは,これまで演習問題などでときどき使ってきた assert
文です.これにより,ある初期状態から 1 時刻
update
された Particle
の位置 (属性 x
と y
) が想定通りの値と等しいか,さらにもう 1 時刻 update
しても正しいかを確認しています.齟齬があればエラーが発生します.
このような,単一の関数やメソッド,あるいはせいぜいクラスの単位の動作確認テストを単体テスト (unit testing) と呼びます.複数の構成要素の組合せをテストする結合テストや,より大規模なシステムレベルのテストとは根本的に別物で,プログラムの品質保証や評価をするためのものではありません.開発者の日々のプログラミングを助けるためのツールです.
そもそも,テストに通ったからといってプログラムが絶対正しいという保証はないのです.誤解を恐れず敢えて言い切るならば,単体テストで期待できるのは正当性の保証ではなく,「動いているコードを変更したくない」という気持ちを打破する心理的効果です.
だから,完全である必要はありません.すべての条件を網羅しようとしなくて構いません (できません).プログラムのあらゆる場所をテストしようとしなくて構いません (できません).できるところから,できる範囲でやりましょう.それでも十分な助けになります.
6.2.2. VSCode からのテスト実行¶
pytest の実行機能は VSCode に組み込まれていますが,初期状態では無効になっています.アクティビティバーから Explorer サイドバーを開き,先ほど作った test_particle.py
を右クリックして Run All Tests
を実行してください.右下に No test framework configured というポップアップが出るはずなので,Enable and Configure a Test Framework をクリックしてください.Select a test framework/tool と問われるので,pytest を選び,
Select the directory に対して「. Root directory」を選んでください.
アクティビティバーの一番下に Testing (フラスコアイコン) が出るはずです.クリックして Testing サイドバーを開き,Run All Tests をクリックして実行します.テストが見つかっていなければ先に Discover Tests をクリックしてください.各テスト項目の前の円形マークが緑色になれば成功です.

失敗したものは赤色になります.テスト関数か,あるいは Particle
クラスの update
の内容をちょっと書き換えて,結果が一致しないようにしてみてください.サイドバーの Show Test Output をクリックするとパネル領域で出力を見ることができ,どのテストがどのように失敗したかがわかります.
(なお,テストを書いたら一度は失敗させてみることは重要です.そもそもテストとして機能していない誤ったテストを放置してしまうリスクが減ります)
失敗するのを確認したら,ちゃんと修正してグリーンシグナルが灯るのを確認してください.確認できたら,test_particle.py
も Git でコミットしておきます.
6.2.3. 複数条件の自動展開¶
初期条件が 1 つだけではちょっと不安です.いろいろな初期値を用意して
for
文で回すことも可能ですが,そうすると,一部が失敗したときにどの条件が失敗したのかわかりにくいという問題があります.また,テスト自体にバグがあると元も子もないので,テスト関数はあまり複雑にしないのが鉄則です.
pytest には,同じテスト関数を複数の入力の組について実行する機能があります.
1import pytest
2import particle
3
4
5@pytest.mark.parametrize("x0, y0, vx0, vy0", [
6 (0, 0, 5, 10),
7 (300, 200, -7, 12),
8 (500, 300, -4, -10),
9])
10def test_particle_move(x0, y0, vx0, vy0):
11 width, height = 600, 400
12 dt, g = 2, 4
13 w = particle.World(width, height, dt, g)
14
15 p = particle.Particle((x0, y0), (vx0, vy0), w)
16 p.update()
17 assert p.x == x0 + vx0 * dt
18 assert p.y == y0 + (vy0 + g * dt) * dt
19 p.update()
20 assert p.x == x0 + 2 * vx0 * dt
21 assert p.y == y0 + (vy0 + g * dt) * dt + (vy0 + 2 * g * dt) * dt
1行目で pytest モジュールをインポートしています.14行目で定義していた初期値の変数 x0
, y0
, vx0
, vy0
は関数の引数としました
(10行目).そして関数の前に,@
で始まる謎の呪文を書いています (5-9
行目).
Python では関数定義の直前に置くことでその関数の内容に変更を加える
** デコレータ** (decorator) というものを定義することができます.
@
はデコレータを呼び出す記法です.
文法的なことはさておき,pytest が用意している
pytest.mark.parametrize
デコレータを用いると,テスト関数に与える引数を,タプルのリストとして与えることができます.この場合は,まず
x0, y0, vx0, vy0 = 0, 0, 5, 0
として,次に
x0, y0, vx0, vy0 = 300, 200, -7, 12
として,…というように,初期値をいろいろと変えた条件でテストを実行できます.
ほかのテストもしてみましょう.単純な放物運動であれば理論的な解がわかっているので,それと計算結果を比較してみようというのは自然な発想です.
24@pytest.mark.parametrize("x0, y0, vx0, vy0", [
25 (0, 0, 5, 10),
26 (300, 200, -7, 12),
27 (500, 300, -4, -10),
28])
29def test_particle_move_analytically(x0, y0, vx0, vy0):
30 width, height = 600, 400
31 dt, g = 1, 0.5
32 w = particle.World(width, height, dt, g)
33
34 p = particle.Particle((x0, y0), (vx0, vy0), w)
35 N = 2000
36 for _ in range(N):
37 p.update()
38 assert p.x == x0 + vx0 * (N * dt)
39 py_discrete = y0 + vy0 * (N * dt) + 1/2 * g * N * (N + 1) * dt ** 2
40 assert p.y == py_discrete
41 py_continuous = y0 + vy0 * (N * dt) + 1/2 * g * N ** 2 * dt ** 2
42 assert p.y == pytest.approx(py_continuous, 1e-3)
x 座標については等速運動なので難しいことはありません.y 座標については,この例では 2 つの解との比較をしています.py_discrete
は Particle
の update
に用いている計算式を漸化式とみなして一般項を求めた,
\(t = N \Delta t\) における解です.一方 py_continous
は,連続時間での解です.高校物理では後者がお馴染みだと思います.
もちろん後者は実際の計算結果と厳密一致しません.しかし N
が十分大きければ近似的に等しいことが期待できます.42行目のように pytest.approx
関数を使うと,近似的な比較ができます.この場合,正解の絶対値の \(10^{-3}\) 倍までの誤差を許容します.
pytest.approx
は近似式のときだけ使えばいいですか?例えば以下の等式は数学的には成立するはずですが,数値計算結果は一致しません:
>>> (0.1 + 0.5) + 0.2 == 0.1 + (0.5 + 0.2)
False
上の test_particle_move_analytically
でも,例えばパラメータとして (0, 0, 5, 10.1),
を指定すると失敗します.
そのため,数値誤差も含めて一致することを確認したい場合を除けば,常に pytest.approx
を使う方が安全です.
この章のテストコードは pytest.approx
を使わなくても一致する例がほとんどですが,たまたま一致するだけだと理解する方がよいです.別のパラメータを試したり,計算手順を変更したりする場合は
pytest.approx
が必要になる可能性があることを覚えておいてください.
for _ in range(N):
の _
はどういう意味ですか?ここでは,N
回の繰り返しを行いたいだけです.
range(N)
から取り出された 0, 1, 2, ... といった数は変数 _
に代入されますが,その値はループ内で使用しません.
ここで何か下手に名前のついた変数を使ってしまうと,後から読んだときに「あれ,この変数は使ってないけど大丈夫か?」と不安になることがあります.未使用の変数というのはバグの兆候だからです.実際,配布環境の VSCode は未使用の変数に対して警告を出すように設定されているので,アンダーラインが表示されるはずです.
変数名を _
にすることで,後にコードを読む人に,あるいは
VSCode に「わざと未使用にしています」と知らせることができます.
最後に,is_alive
属性のテストをしましょう.この場合,画面外に出るかどうかの判定を数式で書くよりも,初期条件とともに与えてしまう方が簡単で確実です.expected
という引数を加えています.
45@pytest.mark.parametrize("x0, y0, vx0, vy0, expected", [
46 (300, 200, 5, 10, True),
47 (300, 200, -5, -10, True),
48 (599, 200, 5, 10, False),
49 (300, 399, 5, 10, False),
50 (-1, 399, 5, 10, False),
51 (0, 0, 5, 10, True),
52 (0, 0, -5, 10, False),
53])
54def test_particle_liveness_after_move(x0, y0, vx0, vy0, expected):
55 width, height = 600, 400
56 dt, g = 1, 0.5
57 w = particle.World(width, height, dt, g)
58 p = particle.Particle((x0, y0), (vx0, vy0), w)
59 p.update()
60 assert p.is_alive == expected
実際,真面目にテストを書こうと思うと,プログラム本体のコードを書くよりも手間がかかることも多々あります.
だから,皆さんが自分のプログラムを書くときに必ずテストを書けとは言いません.しかし,モダンなソフトウェア開発を理解する上でテストの話を抜きにするわけにもいきません.まずは,こういう概念や技法があるんだなということだけ知っておいてください.
その上で,例えば「この関数が正しく書けているか不安だ」とか「このクラスを修正したいけどどうも心理的な障壁を感じる」と思うことがあれば,部分的でよいので単体テストを試してみてください.
テストを書く面倒くささを低減するため,pytest には,ここで紹介する以外にも多数の機能があっります.使いこなすと多少は楽になるので,興味がある人は調べてみてください.
例えばこの章の後の方では,処理の一部をオブジェクトとして独立させて,必要に応じて入れ替える技法を学びます.同じ要領で,テストしたい処理のうちキーボード入力を読み取る部分や乱数を生成する部分を別のオブジェクトとして独立させ,テストのときだけは既知の値を返すものに入れ変えたりすると,テストが書けるようになります.このようなテスト用に使うオブジェクトをモックオブジェクトなどと呼びます.
この考え方は単体テストに限らずいろいろなところに使えます.例えばあるハードウェアと通信するプログラムを作っているとして,通信処理を置き換えるモックを用意しておくと,実際のハードウェアが手元にない環境でも開発を進めることができます.
dt
や g
も複数の値をテストしなくてよいですか?実際にやるなら,pytest.mark.parametrize
を複数重ねて使うのがよいと思います.具体例は test_particle.py
(ver 24.0) などを参照してください.
残念ながら VSCode から利用可能なものは見当たらないのですが,コマンドプロンプトから利用可能なものとして pytest-watch を紹介しておきます.
使い方は簡単です.新しいコマンドプロンプトを開いて作業中のフォルダまで移動し,そこで ptw
コマンドを実行します.
C:\cs1\projects>cd particle
C:\cs1\projects\particle>ptw
ファイルの変更が検出される度にテストが自動で走り,表示が更新されます.停止したい時は Ctrl + c を入力してください.
6.3. 機能変更を伴わない修正¶
単体テストを書いたので,プログラムを修正していきましょう.まずは機能変更を伴わない修正の例を見てみます.
6.3.1. メソッドの切り出し¶
そろそろお馴染みのメソッド切り出しです.Particle
の update
のうち,画面境界に関する処理を別のメソッドに分けます.もちろんこれは,後の機能追加 (跳ね返り処理への入れ替え) への布石です.
修正を始める前に,テストが通ることを確認して,Git でコミット済みであることを確認してください.
21 def update(self):
22 self.vy += self.world.gy * self.world.dt
23 self.x += self.vx * self.world.dt
24 self.y += self.vy * self.world.dt
25 self.update_after_move()
26
27 def update_after_move(self):
28 if self.x < 0 or self.x > self.world.width or self.y > self.world.height:
29 self.is_alive = False
修正自体は簡単です.修正後,テストが通ることを確認して,
でも動かしてみて,問題なければ Git でコミットします.簡単な作業ですが,テストがあると安心感が違います.また,修正直前の状態をコミットしているので,いざというときは確実に元に戻せるという安心感も重要です.
コードをちょっと直して,テストを走らせて,シグナルがオールグリーンになるのを見てからコミットする.この繰り返しで脳内麻薬が出るようになったらシメたものです.
6.3.2. 抽象度の高いオブジェクトに置き換える¶
もう少し実質的な内容のある修正をしてみようと思います.これまでは位置を
x
と y
,速度を vy
と vy
というスカラ値 2 つずつで表してきましたが,pygame にはベクトルを表現するクラス
pygame.math.Vector2
(以下 Vector2
) があります.これを使って書き換えてみましょう.
1import pygame
2
3
4class World:
5 def __init__(self, width, height, dt, gy):
6 self.width = width
7 self.height = height
8 self.dt = dt
9 self.gravity_acc = pygame.math.Vector2(0, gy)
10
11
12class Particle:
13 def __init__(self, pos, vel, world, radius=10.0, color="green"):
14 self.is_alive = True
15 self.pos = pygame.math.Vector2(pos)
16 self.vel = pygame.math.Vector2(vel)
17 self.world = world
18 self.radius = radius
19 self.color = pygame.Color(color)
20
21 def update(self):
22 self.vel += self.world.gravity_acc * self.world.dt
23 self.pos += self.vel * self.world.dt
24 self.update_after_move()
25
World
の重力加速度と,Particle
の位置,速度を Vector2
で表し,それぞれ gravity_acc
, pos
, vel
という属性にします.
Vector2
は,2 つの引数か,2 要素のシーケンス (タプルやリストなど) を渡すことで生成できます.
Vector2
は要素ごとの加算やスカラ倍の計算を演算子 +
や *
で表すことができます.おかげで 22-23行目のように運動計算がちょっとだけ簡潔になります.今後いろいろ拡張するときも書きやすくなりそうです.
よし,これで大丈夫そうだな,と思ってテストを実行すると,残念ながら通りません.テスト関数で Particle
の属性 x
と y
を使っているからです.ついでにいうと,Particle
のメソッド draw
の中でも属性 x
と
y
を使っているので,普通に particle_main.py
を実行してもエラーになるはずです.
つまり,今我々は機能変更をせずにプログラムを修正したかったはずなのに,実は機能が変わってしまっていたわけです.
選択肢は 2 つで,これを機に従来の機能 (属性 x
や y
による位置の取得) を廃止すると決めること,または機能が変わらないように修正することです.ここでは後者を取ることにします.後方互換性はできるだけ保てる方がよいからです.
一つの方法は,x
と y
を属性として残して,pos
が更新されるたびにそれらも書き換えておくことです.今のプログラムならこれはまだ簡単ですが,pos
が複数個所で更新されるようなプログラムだとすると,ひと仕事です.また,今後 pos
を別の個所で更新するような修正を加えるときに,このことを忘れるとえらい目にあいます.
そこで Python に標準で用意されているデコレータ @property
を使います.デコレータというのは関数定義の前に置いて,その内容を変化させるものでした.@property
は,メソッドに適用すると,その名前をメソッドではない読み取り専用の属性に置き換えて,その属性が読み出されたときにメソッドが呼び出されるようにしてくれます.わかりにくいですね.実際の例で説明しましょう.
30 def draw(self, screen):
31 pygame.draw.circle(screen, self.color, (self.x, self.y), self.radius)
32
33 @property
34 def x(self):
35 return self.pos.x
36
37 @property
38 def y(self):
39 return self.pos.y
Vector2
型のオブジェクトは,属性 x
と y
で第1要素と第2要素を読み書きできます(あるいは後ろに [0]
と [1]
をつけてもよいです).もし def x(self):
の行の前の @property
がなかったとすると,メソッド
x
が定義され,例えば Particle
型オブジェクト p
の x 座標を
p.x()
として得られるようになります.しかし,従来は p.x
と書いてきたのだから,やはり後方互換性はありません.@property
を置くことで,属性 x
を読み取ろうとすると自動的にメソッド x()
が呼ばれ,その返り値が得られるようになります.このような仕組みをプロパティ
(property) と呼びます.
この修正を加えることで今まで通りテストが通り,particle_main.py
が実行できることを確認してから変更内容をコミットしてください.これで修正の一連の作業が完了です.
Vector2
というのは pygame の中で定義されたクラスなんですよね?
どのような仕組みで +
とか *
とかの演算子が使えるんですか?Vector2
に __add__
や __mul__
などのメソッドが定義されていて,それらが呼ばれます.__init__
のように,アンダースコア 2 つで挟まれた名前のメソッドは特殊メソッドと呼ばれて,特定の条件で自動的に呼び出されるのでした.これと同様に,例えば vel
と acc
が Vector2
型のオブジェクトで dt
が実数のとき:
vel + acc * dt
は:
vec.__add__(acc.__mul__(dt))
の呼び出しによって処理されます.
このように演算子の動作を定義する仕組みを一般に演算子オーバーロード (operator overloading) と呼びます.
C++ では operator+
や operator*
というメソッドを定義することで実現します.
Java のように演算子オーバーロード機能をもたない言語もあります.
@property
のような機能がない言語で,この例のように属性をメソッドに置き換えたくなったらどうすればよいですか?例えば C++ や Java などが「そういう言語」に該当します.これらの言語では,p.x
や p.y
のようにオブジェクトの属性変数に外部から直接アクセスすることがそもそも推奨されません.冗長に感じても,必ず p.x()
や p.y()
のようにメソッドとしてアクセスするようにしておけば,後にメソッドに変えたくなったときに困らずに済みます.
(5章の「通りすがりの Java プログラマ」さんからの質問も参照してください)
そもそも,Python でいう「属性」に相当するものの名前はプログラミング言語によってさまざまです.attribute, property, field, member variable, instance variable, …などなど.
そのうち一部の言語が,property という名前で「ただの属性変数のように見えるけど実は裏でメソッドが呼ばれている」という仕組みを使い始め,徐々に他の言語にも採用されるようになりました.
6.4. クラスの継承¶
6.4.1. 跳ね返り処理の仮実装¶
さて,いよいよ画面の左右と下の境界でバウンドするような変更を考えます.上の境界を考えてもよいですが,これまで同様に上空は開放されていることにしましょう (特に理由はないですが,上に飛んで行ったのがまた戻ってくるの楽しいですよね?).
内容としては,さっき切り出したメソッド update_after_move
の処理内容を変えてやればよさそうです.範囲外に出たときに,is_alive
を False
にする代わりに vel.x
や vel.y
の符号を逆にすれば完全弾性衝突になります.不完全弾性衝突にしたいなら,さらに弾性係数 \(e\) をかけてやればよいです.
そのような質点を表すクラス ConfinedParticle
を作ることを考えます.安直な方法は,Particle
のコードをコピーペーストして,名前を
ConfinedParticle
に変え,必要な修正を加えるといったやり方でしょう.しかし,update_after_move
メソッド以外は全く変化がないのにすべてをコピペするのは無駄が多すぎます.後に,両方のクラスに共通する修正をしたくなったとき (例えば draw
メソッドを修正してもっと見た目を格好よくしたくなったとか,共通部分にバグが見つかったとか) に,両方に同じ修正をしなくてはならなくなります.
そこで,クラスの継承 (inheritance) と呼ばれる仕組みを使います.以下を particle.py
に追記してください.
41
42class ConfinedParticle(Particle):
43 def update_after_move(self):
44 e = 0.95
45 if self.pos.x < 0 or self.pos.x > self.world.width:
46 self.vel.x *= -e
47 if self.pos.y > self.world.height:
48 self.vel.y *= -e
これにより ConfinedParticle
が定義されます.重要なのは 42 行目の
ConfinedParticle
の後ろに (Particle)
と書かれている点です.これにより ConfinedParticle
は Particle
のすべての定義をいったん引き継ぎます.その上で,再定義されている update_after_move
メソッドだけを上書きします.新しい定義では,弾性係数を \(e\) として,左右の壁にぶつかったら vel.x
を \(-e\) 倍,床にぶつかったら
vel.y
を \(-e\) 倍しています.これ以外のメソッドは Particle
のものを全く同じように使えます.
このとき ConfinedParticle
は Particle
を継承しているといいます.
ConfinedParticle
は Particle
のサブクラス (sub class),子クラス (child
clas),派生クラス (derived class) などと呼ばれます.逆に,
Particle
は ConfinedParticle
のスーパークラス (super
class),親クラス
(parent class),基底クラス (base class) などと呼ばれます.
particle モジュールを使う側,つまり particle_main.py
で
Particle
型オブジェクトを生成しているところも ConfinedParticle
に書き換えると,とりあえず何かしら動く様子が見えるはずです.
25 def add_particle(self, pos, button):
26 if button == 1:
27 vx = random.uniform(-10, 10)
28 vy = random.uniform(-10, 0)
29 p = particle.ConfinedParticle(pos, (vx, vy), self.world)
30 self.particle_list.append(p)
6.4.2. 跳ね返り処理を真面目に考える¶
修正後のプログラムをしばらく動かしてみると,いくつかおかしな点があるのに気づくと思います.まず,Particle
の大きさを考慮していないのでちょっと違和感があります.大きさを考慮するとこんな感じになります.
42class ConfinedParticle(Particle):
43 def update_after_move(self):
44 e = 0.95
45 if self.x < 0 + self.radius or self.x > self.world.width - self.radius:
46 self.vel.x *= -e
47 if self.y > self.world.height - self.radius:
48 self.vel.y *= -e
要するに,半径の分だけ壁が内側に移ったと思えばよいです.大きさを考慮し始めると厳密には「質点」とはいえないのですが,気にしないことにします.
まだ違和感があることに気づいた人もいると思います.気づかない人は弾性係数を小さめに,例えば e = 0.5
などにしてみてください.以下の 2 つの問題が見えてきます.
- \(-e\) 倍されるはずの速度成分が急に 0 になることがある
- 反発を繰り返してだんだん遅くなって床面に落ち着いた後,床の中に沈んでいく
1つ目の問題の原因を理解するには,self.vel.x *= -e
や
self.vel.y *= -e
の行 (46行目と48行目) にブレイクポイントを仕掛けてデバッガで追ってみるとよいです.
1回の衝突でこれらが複数回実行されていることがわかるはずです.
このシミュレーションは離散時間で行っているため,衝突判定は,一般に壁の中に少し潜り込んだところで起きます.衝突が起きると速度が反転しますが,次の時刻にまだ壁の中に残っている可能性があり,その場合はもう一度反転してしまいます.するとまた壁に向かって進んでしまい,また反転するがやはり壁から出られない…をひたすら繰り返します.
原因がわかれば対策は簡単で,衝突条件として速度も考慮すればよいです.例えば右側の壁なら,属性 vel.x
が正である場合に限ることにします.
2つ目の問題は少しわかりにくいのですが,床上に静止した状態で力が釣り合うような機構が根本的に存在していないことに起因します.質点には常に重力がかかっており,本来はそれと釣り合う抗力が床から与えられなければいけませんが,このシミュレータにそんなものはありません.
この問題に関しては,対症療法ですが,y 方向の衝突が発生したときだけ位置を強制的に床面に移動させておくことで回避することにします.
これら 2 つの対処を盛り込むと,以下のようになります.
42class ConfinedParticle(Particle):
43 def update_after_move(self):
44 e = 0.95
45 if (self.x < 0 + self.radius and self.vel.x < 0) or \
46 (self.x > self.world.width - self.radius and self.vel.x > 0):
47 self.vel.x *= -e
48 if self.y > self.world.height - self.radius and self.vel.y > 0:
49 self.vel.y *= -e
50 # constrain particle on or above the floor
51 self.pos.y = self.world.height - self.radius
45-46行目で,条件が長くなりすぎて \
で折り返していることに注意してください.
長いだけでなく,ドット演算子が続きすぎて読みづらいと感じます.こういう場合は,いったん簡潔な名前のローカル変数で受けるという手があります.
42class ConfinedParticle(Particle):
43 def update_after_move(self):
44 x, y = self.x, self.y
45 vx, vy = self.vel.x, self.vel.y
46 width, height = self.world.width, self.world.height
47 radius = self.radius
48 e = 0.95
49 if (x < 0 + radius and vx < 0) or (x > width - radius and vx > 0):
50 self.vel.x *= -e
51 if y > height - radius and vy > 0:
52 self.vel.y *= -e
53 # constrain particle on or above the floor
54 self.pos.y = height - radius
例えば self.vel.x *= -e
の部分を vx *= -e
と書いた場合にどうなるか,「荷札と紐」のモデルを思い出しながら考えてみてください.
vx
という「荷札」は,self.vel.x
を指すのではなく,
self.vel.x
が指している整数データを共有して指しています.vx *= -e
によって vx
は新しい整数データを指すことになりますが,self.vel.x
が指しているのは元の整数データのままです.
test_particle.py
ver 21.0).以下のように,壁で跳ね返る,床で跳ね返る,跳ね返られないに対応する3つのテスト関数を作ります.ここまでの検討で,跳ね返った直後にもう一度跳ね返ってしまわないことを確認するのが大事だとわかったので,2時刻分テストするようにしています.
ver 21.0 の particle.py
でこのテストが通ることを確認してから,テストに異常がないことを確認しつつ ver 22.0 に修正するのが望ましい手順です.
63@pytest.mark.parametrize("x0, y0, vx0, vy0", [
64 (600, 200, 5, 0),
65 (0, 200, -7, 0),
66])
67def test_confined_particle_on_wall(x0, y0, vx0, vy0):
68 dt, g = 1, 0.5
69 w = particle.World(600, 400, dt, g)
70 e = 0.95
71 p = particle.ConfinedParticle((x0, y0), (vx0, vy0), w, radius=10)
72 p.update()
73 assert p.x == x0 + (vx0 * dt)
74 assert p.vel.x == - e * vx0
75 p.update()
76 assert p.x == x0 + (vx0 * dt) - (e * vx0 * dt)
77 assert p.vel.x == - e * vx0
78
79
80@pytest.mark.parametrize("x0, y0, vx0, vy0", [
81 (300, 402, 3, 5),
82 (200, 395, -4, 7),
83])
84def test_confined_particle_on_floor(x0, y0, vx0, vy0):
85 dt, g = 1, 0.5
86 width, height = 600, 400
87 w = particle.World(width, height, dt, g)
88 e, radius = 0.95, 10
89 p = particle.ConfinedParticle((x0, y0), (vx0, vy0), w, radius=radius)
90 p.update()
91 assert p.vel.y == - e * (vy0 + g * dt)
92 assert p.y == height - radius
93 p.update()
94 assert p.vel.y == - e * (vy0 + g * dt) + g * dt
95 assert p.y == (height - radius) + (- e * (vy0 + g * dt) + g * dt) * dt
96
97
98@pytest.mark.parametrize("x0, y0, vx0, vy0", [
99 (500, 150, 5, 0),
100 (300, 200, -6, 0),
101 (200, 250, 0, 7),
102 (100, 300, 0, -8),
103])
104def test_confined_particle_inside(x0, y0, vx0, vy0):
105 dt, g = 1, 0.5
106 w = particle.World(600, 400, dt, g)
107 p = particle.ConfinedParticle((x0, y0), (vx0, vy0), w, radius=10)
108 p.update()
109 assert p.vel.x == vx0
110 assert p.vel.y == vy0 + g * dt
111 p.update()
112 assert p.vel.x == vx0
113 assert p.vel.y == vy0 + 2 * g * dt
6.4.3. ポリモーフィズム¶
particle_main.py
で,Particle
と ConfinedParticle
をマウスボタンによって打ち分けられるようにしてみます.左ボタンで従来通りの Particle
を,右ボタン (ボタン番号 3) では ConfinedParticle
を打ち上げられるようにします.見た目で区別がつくように後者は青色に変更します.
14 def update(self):
15 for p in self.particle_list:
16 p.update()
17 self.particle_list[:] = [p for p in self.particle_list if p.is_alive]
18
19 def draw(self):
20 self.screen.fill(pygame.Color("black"))
21 for p in self.particle_list:
22 p.draw(self.screen)
23 pygame.display.update()
24
25 def add_particle(self, pos, button):
26 vx = random.uniform(-10, 10)
27 vy = random.uniform(-10, 0)
28 if button == 1:
29 p = particle.Particle(pos, (vx, vy), self.world, color="green")
30 elif button == 3:
31 p = particle.ConfinedParticle(pos, (vx, vy), self.world, color="blue")
32 else:
33 return
34 self.particle_list.append(p)
変更箇所自体は難しいところはないと思います.ボタン番号が 1 でも 3 でもないときは何もせずに return
している点 (32-33行目) にだけ注意してください.
むしろここでもっと注目すべきなのは,16 行目を変更していないことです.
self.particle_list
の中には,今や Particle
型と ConfinedParticle
型のオブジェクトが (正確にはこれらのオブジェクトへの参照が) 混在しています.しかし,今 for
文で取り出した p
がどちらなのかここでは気にしません.メインプログラム側は,各オブジェクトに「自分の状態を更新してください」と一様に依頼するだけで,実際にどう動くかは各オブジェクトの責務です.
このように,プログラム上のある要素 (この場合は 15-16行目の p
など)
が実行時に複数の異なる型のものになる可能性があり,それぞれの型に応じて異なる動作をし得る性質をポリモーフィズム (多相性, 多態性, polymorphism) といいます.ポリモーフィズムは,オブジェクト指向プログラミングにおける「責務をオブジェクトに移す」ことを可能とする重要な性質であり,継承はその一手段といえます.
オブジェクト指向プログラミングをサポートする言語であれば,あるクラス型のオブジェクトを割り当てられるように宣言された変数に,そのクラスの派生クラス型のオブジェクトを割り当てることができます.
このことを利用すると,変数に型の宣言が必要な言語でも同様のことが可能です.「利用すると」というよりも,そういうことができるようにクラスの継承機構が作られているという方が適切です.
(なお,言語によっては上記の「オブジェクト」を「オブジェクトへの参照またはポインタ」に読み替える必要があります.例えば C++ など)
この実習の範囲を外れますが,参考までに Java の例を示しておきます:
1public class AppMain {
2 public static void main(String[] args) {
3 class Particle {
4 public int test() { return 10; }
5 }
6 class ConfinedParticle extends Particle {
7 public int test() { return 20; }
8 }
9
10 Particle[] particle_list = new Particle[3];
11 particle_list[0] = new Particle();
12 particle_list[1] = new Particle();
13 particle_list[2] = new ConfinedParticle();
14 for (Particle p : particle_list) {
15 System.out.println(p.test());
16 }
17 }
18}
Python からの類推でなんとなく読んでもらえるとよいのですが,
Particle
型の 3 要素の配列を用意して (10行目),最初の 2 要素に Particle
型のオブジェクトを,
3 要素めに派生クラスである ConfinedParticle
型のオブジェクトを代入しています (11-13行目).for
文でこの配列の要素を順に取り出して,test
メソッドを呼んでいます (14-16行目).
結果として,Particle
の test
メソッドが 2 回,
ConfinedParticle
の test
メソッドが 1 回呼ばれて,以下のように出力されます:
10
10
20
6.5. オブジェクト合成¶
6.5.1. Strategy パターン¶
継承によって2種類の質点を作りましたが,同様のことは継承を使わなくても可能です.上書きする新しい処理を外部の関数として定義しておき,Particle
を初期化するときに (あるいは初期化後に) オブジェクトにその関数を保持させることで動作を変化させる方法です.
やはり具体例で見ていきます.
1import pygame
2
3
4def bounce_on_boundary(p):
5 x, y = p.x, p.y
6 vx, vy = p.vel.x, p.vel.y
7 width, height = p.world.width, p.world.height
8 radius = p.radius
9 e = 0.95
10 if (x < 0 + radius and vx < 0) or (x > width - radius and vx > 0):
11 p.vel.x *= -e
12 if y > height - radius and vy > 0:
13 p.vel.y *= -e
14 # constrain particle on or above the floor
15 p.pos.y = height - radius
16
17
18class World:
19 def __init__(self, width, height, dt, gy):
20 self.width = width
21 self.height = height
22 self.dt = dt
23 self.gravity_acc = pygame.math.Vector2(0, gy)
24
25
26class Particle:
27 def __init__(self, pos, vel, world, radius=10.0, color="green", postmove_strategy=None):
28 self.is_alive = True
29 self.pos = pygame.math.Vector2(pos)
30 self.vel = pygame.math.Vector2(vel)
31 self.world = world
32 self.radius = radius
33 self.color = pygame.Color(color)
34 self.postmove_strategy = postmove_strategy
35
36 def update(self):
37 self.vel += self.world.gravity_acc * self.world.dt
38 self.pos += self.vel * self.world.dt
39 if self.postmove_strategy is not None:
40 self.postmove_strategy(self)
41 else:
42 self.update_after_move()
43
44 def update_after_move(self):
- 4-15行目
ConfinedParticle
のupdate_after_move
メソッドと同じ処理をするbounce_on_boundary
という外部関数を作ります.こちらはメソッドではないので,第1引数の
self
は別の名前にします.ここではp
としました.自分自身ではなく,与えられたParticle
型オブジェクトp
を操作する関数だということです.ConfinedParticle
はもう消してしまってもよいですし,同じコードが2箇所にあるのはよくないので本来消すべきですが,勉強用なので,見比べたり,同じ動きをすることを確認したりするために残しておきます.- 26, 34行目
__init__
メソッドで引数postmove_strategy
を受け取るようにし,self.postmove_strategy
として保持しておきます (41-44行目).- 39-42行目
self.postmove_strategy
が None でなければ,update_after_move
の代わりにself.postmove_strategy
を呼び出すようにします.これにより,初期化時に渡されていた関数が呼び出されるようになります.引数として,Particle
型オブジェクトである自分自身 (self
) を渡します.
25 def add_particle(self, pos, button):
26 vx = random.uniform(-10, 10)
27 vy = random.uniform(-10, 0)
28 if button == 1:
29 p = particle.Particle(pos, (vx, vy), self.world, color="green")
30 elif button == 3:
31 p = particle.Particle(pos, (vx, vy), self.world, color="blue",
32 postmove_strategy=particle.bounce_on_boundary)
33 else:
34 return
35 self.particle_list.append(p)
particle_main.py
でも,ConfinedParticle
型オブジェクトを生成していたところを Particle
型に戻し,代わりに引数 postmove_strategy
で bounce_on_boundary
を指定します.
以上の変更で,継承したときと同様に2種類の質点の打ち分けができるようになったはずです.
この方法の特徴は,Particle
が担うべき処理の一部をそのクラスのメソッドとしてではなく,クラス外で実装し (この場合は外部関数として定義して),それをオブジェクトの属性として保持させることで,処理内容を入れ替え可能としている点です.
このような技法を Strategy パターン (Strategy pattern) と呼びます (本来はもう少し狭い定義でしたが,最近はこのように広く捉えることが多いようです).クラス定義時点で処理内容を定める継承とは異なり,オブジェクト生成時に処理内容が定まります.生成後でも属性を変更すれば動作を変えられるので,より柔軟であるといえます.
self.postmove_strategy(self)
がよくわからないです.なぜ
self
が 2 回出てくるんですか? self.update_after_move()
では引数がいらないのに.bounce_on_boundary
が要求している引数を渡してやっているだけです.先頭の self
は,自身の属性である postmove_strategy
にアクセスするために使われます.引数として渡している self
は外部関数 bounce_on_boundary
が第1引数として受け取り,呼び出し元の Particle
の属性にアクセスするのに使います.たまたま両方が self
なので混乱するかも知れませんが,特に不思議なところはありません.
self.update_after_move()
に引数を渡す必要がないのは,メソッド呼び出しの場合は暗黙的に self
が渡されるルールだったからです.ここで混乱する人は 5.5 節を復習してください.
bounce_on_boundary
は,ConfinedParticle
の update_after_move
と中身がほとんど同じなのでコピーペーストで作ろうと思ったのですが,多数の self
を p
に書き換えて回るのが面倒です.何とかなりませんか.まずいったん引数も self
という名前にしたままでコードをコピーしてから,self
を右クリックして
を選んでください.リネーム後の名前として p
を指定すると,自動で置き換えてくれます.
エディタの置換機能で単純に文字列置換をしてもよいのですが,気をつけないと他のメソッドの self
まで置き換えてしまったり,また,例えばどこかに myself
などという別の変数があったりするとそちらまで書き換わってしまったりします.開発環境の機能を使うと,文法を理解してリネームしてくれるためより安全です.
bounce_on_boundary
は Particle
クラスの外部にある関数ですよね.クラス外から Particle
の内部属性である vel.x
や
vel.y
などを更新してしまうのは「できるだけ避ける」方針だったのではないでしょうか?この方針を堅持するならば,Particle
に set_vel
や
set_pos
といったメソッドを用意して,bounce_on_boundary
からはそれらを利用して状態変更するのが正しいです.
余談ですが,継承を使った場合もこれと同じ問題が発生していたことを指摘しておきます.多くの主要なプログラミング言語では,派生クラスは基底クラスの属性にアクセスできないのがデフォルトの動作です.つまり派生クラスは「外部」とみなします.これと同じ考え方に立つなら,
ConfinedParticle
の中から vel.x
や vel.y
を更新するのも同様に方針違反であって,やはり本来はメソッドを経由して状態変更するべきです.
派生クラスやストラテジオブジェクトを「外部」とみなすのが妥当かどうかは状況によります.もしあなたが作っているのが多数のユーザを持つライブラリで,ユーザ自身に自由に派生クラスやストラテジオブジェクトを作らせる方針なのであれば,それらは「外部」とみなす方が安全です.そうではなくてあなた自身による拡張のためだけに派生やストラテジを使っていて,それらの拡張の挙動をすべて把握していられるのなら「内部」とみなしてもよいでしょう.
test_particle.py
ver 24.0).テスト関数のうち,これまで ConfinedParticle
を使っていたところを,postmove_strategy
を渡された Particle
に変えればひとまず OK です.
ただ,せっかく ConfinedParticle
も削除せず残しておくことにしたので,両方のテストをする方法を考えます.両者のテストの内容自体は全く同じなので,@pytest.mark.parametrize
で繰り返せるようにするのが良さそうです.
以下の修正では,125-230行目で ConfinedParticle
または
postmove_strategy
つきの Particle
を,その第1引数
scheme
に渡す文字列によって選んで生成する関数を用意しています.テスト関数の中で質点を作るところではこれを呼び出すことにして,
@pytest.mark.parametrize
で scheme
を変化させています.
63@pytest.mark.parametrize("scheme", [
64 "inheritance", "strategy_func"
65])
66@pytest.mark.parametrize("x0, y0, vx0, vy0", [
67 (600, 200, 5, 0),
68 (0, 200, -7, 0),
69])
70def test_confined_particle_on_wall(scheme, x0, y0, vx0, vy0):
71 dt, g = 1, 0.5
72 w = particle.World(600, 400, dt, g)
73 e = 0.95
74 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=10)
75 p.update()
76 assert p.x == x0 + (vx0 * dt)
77 assert p.vel.x == - e * vx0
78 p.update()
79 assert p.x == x0 + (vx0 * dt) - (e * vx0 * dt)
80 assert p.vel.x == - e * vx0
81
82
83@pytest.mark.parametrize("scheme", [
84 "inheritance", "strategy_func"
85])
86@pytest.mark.parametrize("x0, y0, vx0, vy0", [
87 (300, 402, 3, 5),
88 (200, 395, -4, 7),
89])
90def test_confined_particle_on_floor(scheme, x0, y0, vx0, vy0):
91 dt, g = 1, 0.5
92 width, height = 600, 400
93 w = particle.World(width, height, dt, g)
94 e, radius = 0.95, 10
95 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=radius)
96 p.update()
97 assert p.vel.y == - e * (vy0 + g * dt)
98 assert p.y == height - radius
99 p.update()
100 assert p.vel.y == - e * (vy0 + g * dt) + g * dt
101 assert p.y == (height - radius) + (- e * (vy0 + g * dt) + g * dt) * dt
102
103
104@pytest.mark.parametrize("scheme", [
105 "inheritance", "strategy_func"
106])
107@pytest.mark.parametrize("x0, y0, vx0, vy0", [
108 (500, 150, 5, 0),
109 (300, 200, -6, 0),
110 (200, 250, 0, 7),
111 (100, 300, 0, -8),
112])
113def test_confined_particle_inside(scheme, x0, y0, vx0, vy0):
114 dt, g = 1, 0.5
115 w = particle.World(600, 400, dt, g)
116 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=10)
117 p.update()
118 assert p.vel.x == vx0
119 assert p.vel.y == vy0 + g * dt
120 p.update()
121 assert p.vel.x == vx0
122 assert p.vel.y == vy0 + 2 * g * dt
123
124
125def create_confined_particle(scheme, pos, vel, world, radius):
126 if scheme == "inheritance":
127 return particle.ConfinedParticle(pos, vel, world, radius)
128 else: # scheme == "strategy_func"
129 return particle.Particle(pos, vel, world, radius,
130 postmove_strategy=particle.bounce_on_boundary)
6.5.2. 状態を持つストラテジオブジェクトを作る¶
先ほど作った bounce_on_boundary
関数では,反発係数を e = 0.95
に決め打ちにしていました.オブジェクト生成時にこの値を指定したくなるケースは容易に想定されますが,今のままではできません.
bounce_on_boundary
関数の引数に e
を追加すればいいのでは?bounce_on_boundary
を呼び出すコードは
self.postmove_strategy(self)
と決まっています.引数を追加したところで,誰も e
なんて渡してはくれません.
対処方法はいくつかありますが,オブジェクト指向プログラミングらしい方法を紹介します.それは反発係数を属性として持ち,跳ね返り処理をメソッドとして持つオブジェクトを作って,それを関数の代わりに渡すという方法です.
その際,メソッド名は __call__
にしておくと便利です.なぜならば,
Python では __call__
という名前の特殊メソッドを持つオブジェクトの後ろに (引数1, 引数2, ...)
をつければ,自動的にこのメソッドが呼び出されるからです.そうすると,普通の関数と全く同様に使えます.このようなオブジェクトを呼び出し可能オブジェクト
(callable object) と呼びます.
18class BounceOnBoundaryStrategy:
19 def __init__(self, restitution=0.95):
20 self.restitution = restitution
21
22 def __call__(self, p):
23 x, y = p.x, p.y
24 vx, vy = p.vel.x, p.vel.y
25 width, height = p.world.width, p.world.height
26 radius = p.radius
27 e = self.restitution
28 if (x < 0 + radius and vx < 0) or (x > width - radius and vx > 0):
29 p.vel.x *= -e
30 if y > height - radius and vy > 0:
31 p.vel.y *= -e
32 # constrain particle on or above the floor
33 p.pos.y = height - radius
34
35
36class World:
- 18-33行目
クラス
BounceOnBoundaryStrategy
を定義しています.反発係数restitution
を属性として持ちます.23-33行目の中身は
bounce_on_boundary
とほぼ同じ (インデントレベルは違う) ですが,27行目でローカル変数e
に反発係数属性の値を受けている点だけ注意してください.これがやりたかったことでした.
25 def add_particle(self, pos, button):
26 vx = random.uniform(-10, 10)
27 vy = random.uniform(-10, 0)
28 if button == 1:
29 p = particle.Particle(pos, (vx, vy), self.world, color="green")
30 elif button == 3:
31 p = particle.Particle(pos, (vx, vy), self.world, color="blue",
32 postmove_strategy=particle.BounceOnBoundaryStrategy())
33 else:
34 return
35 self.particle_list.append(p)
particle_main.py
でも,引数 postmove_strategy
に渡すものを
BounceOnBoundaryStrategy
型オブジェクトにしておきます.
この時点で bounce_on_boundary
関数は用なしですが,勉強用なのでやはり残しておきます.引数 postmove_strategy
にどちらを渡しても大丈夫な点を確認したりしてみてください.
test_particle.py
ver 25.0).create_confined_particle
で質点を生成するところに,もう一種類追加して,@pytest.mark.parametrize
で回せるようにします.これで,継承の場合,postmove_strategy
に関数を渡す場合,呼び出し可能オブジェクトを渡す場合のそれぞれを同じコードでテストできます.
このようにオブジェクトを生成する部分を関数やメソッドにして,生成方法を簡単に切り替えられるようにしておくと便利なことが多いです.このような関数をファクトリと呼びます.テスト以外でもよく使われます.次の章でいくつか具体例を見ます.
63@pytest.mark.parametrize("scheme", [
64 "inheritance", "strategy_func", "strategy_obj"
65])
66@pytest.mark.parametrize("x0, y0, vx0, vy0", [
67 (600, 200, 5, 0),
68 (0, 200, -7, 0),
69])
70def test_confined_particle_on_wall(scheme, x0, y0, vx0, vy0):
71 dt, g = 1, 0.5
72 w = particle.World(600, 400, dt, g)
73 e = 0.95
74 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=10, e=e)
75 p.update()
76 assert p.x == x0 + (vx0 * dt)
77 assert p.vel.x == - e * vx0
78 p.update()
79 assert p.x == x0 + (vx0 * dt) - (e * vx0 * dt)
80 assert p.vel.x == - e * vx0
81
82
83@pytest.mark.parametrize("scheme", [
84 "inheritance", "strategy_func", "strategy_obj"
85])
86@pytest.mark.parametrize("x0, y0, vx0, vy0", [
87 (300, 402, 3, 5),
88 (200, 395, -4, 7),
89])
90def test_confined_particle_on_floor(scheme, x0, y0, vx0, vy0):
91 dt, g = 1, 0.5
92 width, height = 600, 400
93 w = particle.World(width, height, dt, g)
94 e, radius = 0.95, 10
95 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=radius, e=e)
96 p.update()
97 assert p.vel.y == - e * (vy0 + g * dt)
98 assert p.y == height - radius
99 p.update()
100 assert p.vel.y == - e * (vy0 + g * dt) + g * dt
101 assert p.y == (height - radius) + (- e * (vy0 + g * dt) + g * dt) * dt
102
103
104@pytest.mark.parametrize("scheme", [
105 "inheritance", "strategy_func", "strategy_obj"
106])
107@pytest.mark.parametrize("x0, y0, vx0, vy0", [
108 (500, 150, 5, 0),
109 (300, 200, -6, 0),
110 (200, 250, 0, 7),
111 (100, 300, 0, -8),
112])
113def test_confined_particle_inside(scheme, x0, y0, vx0, vy0):
114 dt, g = 1, 0.5
115 w = particle.World(600, 400, dt, g)
116 p = create_confined_particle(scheme, (x0, y0), (vx0, vy0), w, radius=10)
117 p.update()
118 assert p.vel.x == vx0
119 assert p.vel.y == vy0 + g * dt
120 p.update()
121 assert p.vel.x == vx0
122 assert p.vel.y == vy0 + 2 * g * dt
123
124
125def create_confined_particle(scheme, pos, vel, world, radius, e=0.95):
126 if scheme == "inheritance":
127 return particle.ConfinedParticle(pos, vel, world, radius)
128 elif scheme == "strategy_func":
129 return particle.Particle(pos, vel, world, radius,
130 postmove_strategy=particle.bounce_on_boundary)
131 else: # scheme == "strategy_obj"
132 return particle.Particle(pos, vel, world, radius,
133 postmove_strategy=particle.BounceOnBoundaryStrategy(e))
6.5.3. 継承とオブジェクト合成¶
この章では,継承や Strategy パターンによってクラスが持つ機能の一部を置き換える例を見てきました.見方を変えると,これらは,定義済みのクラス (あるいは関数) で実装済みの機能を他のクラスからうまく利用するための技術であると見ることができます.
- 継承で作った
ConfinedParticle
はParticle
のupdate
やdraw
などを再利用しています. - Strategy パターンで作った跳ね返る
Particle
は,BounceOnBoundaryStrategy
型のオブジェクトを属性として保持して,その__call__
を利用しています. - ここでは紹介しませんでしたが,
ConfinedParticle
クラスをParticle
を継承せずに作り,代わりにParticle
型オブジェクトを属性として保持して,update
やdraw
の処理はそちらに任せてしまうというやり方も可能です.
この 2. や 3. のように,属性として他のオブジェクトを保持し,そのメソッドを利用する方法をオブジェクト合成 (object composition, あるいは単に composition) と総称します.
継承を使うかオブジェクト合成を使うかは,ときに重大な設計判断になります.継承の方が一見便利そうに思えるかもしれませんが,無計画に継承を多用すると収拾がつかなくなることが多々あります.「迷ったら継承より合成を使え」(prefer composition over inheritance) とよく言われます.
特に,クラス X を継承してクラス X' を作るのは,X' が X の一種であるという関係 (X' is a X の関係,略して is-a 関係と呼びます) が成り立っている場合に限定するべきです.例えば自動車シミュレータを開発しているとして,Car クラスを継承して SedanCar クラスを作るのは OK ですが,Engine クラスの機能を利用したいからといって Engine クラスを継承して Car クラスを作ってはいけません.
実際,うまく設計されたライブラリは継承を効果的に活用しており,その利用のためには継承概念の理解が必要です.また,ライブラリが用意する基底クラスをユーザが継承して使うことを前提とするライブラリも多いです.
ともかく,初学者のうちは継承・合成含めていろいろ試してみるのがいいんじゃないかと思います.実感を伴わないまま「継承は使わない方がいい」と信じ込んでいる人よりも,継承で痛い目にあう経験をしている人の方が強いプログラマになれます.
なお,ここで議論している「継承は悪かどうか」は,継承によって実装済みの機能を再利用することの是非について話です.6.4.3 節の Q&A で説明したようなポリモーフィズムの実現のための継承は,静的型付け言語では必要なものです.Java のように,実装を再利用するものを継承,実装を再利用しないものを Interface と呼び,文法を別にしている言語もあります.
オブジェクト指向プログラミングという考え方が形成され普及していく過程で,ベストプラクティスだと考えられる設計やその実装技法が「パターン」として整理され,デザインパターンと呼ばれるようになりました.
有名なのは Erich Gamma らがまとめた 23 個のパターンで,Strategy パターンはその 1 つです.名前が大げさだという意見には同意しますが,浸透している用語です.
6.6. 汎用性の高い関数¶
Strategy パターンと同様の考え方,つまり,処理の一部を目的に応じて入れ替えられるようにしておくという考え方は,クラス以外にも適用できます.
例としてソート処理を考えます.シーケンスの要素を決まった順序で並び変えるものです.Python の場合,sorted
関数と,リストのメソッド
sort
の2種類が標準で用意されています:
>>> x = [3, 2, 1, 5, 4]
>>> sorted(x)
[1, 2, 3, 4, 5]
>>> x
[3, 2, 1, 5, 4]
>>> x.sort()
>>> x
[1, 2, 3, 4, 5]
sorted
関数は元のリストを変更せずに新しいソート済みリストを生成するのに対し,sort
メソッドは元のリスト自体を変更することに注意してください.
6.6.1. ソートアルゴリズムの例¶
ソートのアルゴリズムにはさまざまなものがあるのを習ったことがあるかも知れません.これから話すことは特定のアルゴリズムに関する話ではないのですが,何か具体的なものがないと話がしにくいので,バブルソート
(bubble sort) と呼ばれるものを見てみましょう.Python ではこんな感じの関数になります.これは引数として渡されたリスト x
を昇順に並べるものです:
1def bubble_sort(x):
2 n = len(x)
3 for k in range(n-1, 0, -1):
4 for i in range(k):
5 if x[i] > x[i+1]:
6 x[i], x[i+1] = x[i+1], x[i]
バブルソートは,アルゴリズムの教科書の最初の方に必ず出てくるもので,ソートの中では最も簡単なものの一つです (ただし遅いので実用性はありません).ここではアルゴリズムは理解しなくてよいのですが,勘所だけ説明しておきます.
昇順に並んだ状態というのは,あらゆる隣り合う要素が必ず x[i] <= x[i+1]
を満たしている状態です.
5-6行目で,もしこれに違反していたら入れ替える操作をしています.この操作を,3-4行目の二重の for
文によって所定の順番で繰り返すのがバブルソートです.以降の話を理解するにはこれだけわかっておけば OK です.
- 内側のループでは,
i
を 0 からk-1
までの範囲 (k-1
を含む) で動かしながら,隣り合う要素x[i]
とx[i+1]
を比較していき,大小が逆転していたら入れ替えます.結果としてx[0]
からx[k]
の範囲の最大値が末尾x[k]
に得られます. - 外側のループでは
k
がn-1
から始まり,1 ずつ減っていきます.つまり,k = n-1
のときに内側のループでx[k]
(=x[n-1]
) に得られた最大値は,それ以降触れらませんので確定です.以降,2番目に大きな値がx[n-2]
に,3番目がx[n-3]
に,と確定していき,ソートが完了します.
x[0]
から x[k]
の範囲の最大値が末尾
x[k]
に得られます」のところをもうちょっと詳しく!x[0]
から x[i]
の範囲の最大値が x[i]
にあったと仮定すると,「x[i] > x[i+1]
なら x[i]
と x[i+1]
を入れ替える」操作によって,x[0]
から x[i+1]
の範囲の最大値が x[i+1]
に来ます.
i
= 0 のとき仮定は当然成り立つので,i
が 0 から
k-1
までの範囲を動けば,帰納法によって主張が示されます.6.6.2. いろいろなソート順序¶
複合的なデータを扱うとき,どのような順序でソートしたいかは場合によって異なります.例えば,(名前,数学の成績,英語の成績) のタプルのリストをソートしたいとします:
x = []
x.append(("Alice", 90, 95))
x.append(("Bob", 80, 75))
x.append(("Charlie", 0, 100))
Charlie が数学の試験で何をやらかしたのかはさておき,このリスト x
を「ソートする」と一口にいっても,その順序はいろいろあり得るわけです.
- 名前でソートする
- 数学の点数でソートする
- 英語の点数でソートする
- 数学と英語の平均でソートする
- 数学と英語のうち良い方の点数でソートする
- 数学の点数順でソートするが,同点の場合は英語の点数順とする
- …
実用的なソート関数は,こういういろいろな場合に対応できる必要があります.
6.6.3. ソート関数の内容を直接書き換える (非推奨)¶
一番安直な手段は,ソート対象のデータ表現とソート目的に合わせてソート関数の中身を改変してしまうことです.以下は,数学の点数の昇順でソートするように改変する例です.
1def bubble_sort_by_math(x):
2 n = len(x)
3 for k in range(n-1, 0, -1):
4 for i in range(k):
5 if x[i][1] > x[i+1][1]:
6 x[i], x[i+1] = x[i+1], x[i]
しかし,無数にあるデータ表現に対して,無数にあるソートアルゴリズムを,無数にあるソート目的に応じて毎回書き換えるなんてのはやってられませんし,ミスも入り込みます.そもそも,よく理解できているアルゴリズム,よく理解できているコードに対してしかこれは実行できません.バブルソートはたまたま単純だったから改変も簡単でしたが,いつもできるとは限りません.
まともなプログラミング言語やライブラリのソート関数は,こういうことをしなくて済むように作られています.つまり,いま直接書き換えた5行目の比較処理を,外から入れ替えられるようになっています.
6.6.4. ソート関数に比較関数を渡せるようにする¶
多くの言語やライブラリで採用されているのは,2要素の比較をする関数を外から渡す方法です.演算子 >
による比較をこれで置き換えることで,好きな順序関係でのソートを行えます.
1def bubble_sort(x, compare_func=None):
2 if compare_func is None:
3 def compare_func(a, b):
4 return a > b
5
6 n = len(x)
7 for k in range(n-1, 0, -1):
8 for i in range(k):
9 if compare_func(x[i], x[i+1]):
10 x[i], x[i+1] = x[i+1], x[i]
ちょっと驚くかもしれないのは,関数の中で関数を定義しているところです
(3-4行目).Python ではこれは完全に合法です.この関数の中だけで使えるローカル関数を定義できます.ここでは,引数として渡された比較関数がデフォルト値 None
だったら,元通り「大なり」の比較をするようにしています.
6.6.5. ソート関数に比較キー関数を渡す¶
Python のソートも同様に外から比較処理を指定できますが,指定方法がちょっと珍しいです.
1def bubble_sort(x, key=None, reverse=False):
2 if key is None:
3 def key(x):
4 return x
5
6 if reverse:
7 def compare_func(a, b):
8 return a < b
9 else:
10 def compare_func(a, b):
11 return a > b
12
13 n = len(x)
14 for k in range(n-1, 0, -1):
15 for i in range(k):
16 if compare_func(key(x[i]), key(x[i+1])):
17 x[i], x[i+1] = x[i+1], x[i]
第2引数 key
には,ソート対象のリストの要素を受け取って,比較計算に使う値を返す関数を指定します.第3引数 reverse
が True
のときは降順でソートします.
このような指定方法になっている背景として,Python では文字列やタプルの大小が最初から定義されていることが挙げられます.文字列の大小は辞書順で,タプルの大小は,先頭の要素から順に比較していって最初に確定した大小で与えられます:
>>> "Alice" > "Bob"
False
>>> "abc" > "abb"
True
>>> (10, 20, 99, 40) <= (10, 20, 5, 40)
False
これを利用すると複合的な比較条件を簡潔に表せることが多いです.
動作例を見てみます.ここではさっき定義した bubble_sort
関数を使いますが,bs.bubble_sort(x, key=..., reverse=...)
の部分を
x.sort(key=..., reverse=...)
のように置き換えても全く同様に動作します:
>>> import bubble_sort as bs
>>> x = [3, 2, 1, 5, 4]
>>> bs.bubble_sort(x)
>>> x
[1, 2, 3, 4, 5]
>>> x = [("Alice", 80, 75), ("Bob", 90, 95), ("Charlie", 0, 100)]
>>> bs.bubble_sort(x)
>>> x
[('Alice', 80, 75), ('Bob', 90, 95), ('Charlie', 0, 100)]
key
を指定しないと,タプルに定義された順序でソートされます.この場合,最初の要素 (名前) が最初から辞書順なのでそのままです.
例えば数学と英語の合計点でソートしたいなら,以下のような比較関数を定義して引数で key=sum_of_math_and_english
と指定します:
def sum_of_math_and_english(tup):
return tup[1] + tup[2]
このようなちょっとした関数をいちいち定義したくない場合に便利なのがラムダ式 (lambda expression) です.上の関数と同じものを:
lambda tup: tup[1] + tup[2]
という式で表せます.lambda 引数リスト: 返り値
という形式です.
return 式
の 1 行だけで定義できるような関数の場合にしか使えませんが,この key
引数のような場合に使うととても便利です.
>>> x = [("Alice", 80, 75), ("Bob", 90, 95), ("Charlie", 0, 100)]
>>> bs.bubble_sort(x, key=lambda tup: tup[1] + tup[2], reverse=True)
>>> x
('Bob', 90, 95), ('Alice', 80, 75), ('Charlie', 0, 100)]
ラムダ計算では,例えば \(f(x) = 3 x + 2\) という関数を \(\lambda x . 3 x + 2\) と表し,この引数 \(x\) に値 10 を渡して計算した結果を \((\lambda x . 3 x + 2) 10\) と表します.このように書くことで,関数そのものと,関数が返す値の表記を明確に区別しています (\(f(x)\) と書く場合,関数自体を指すのか,この関数の \(x\) における値なのか曖昧です).
ここで使われる \(\lambda\) がラムダ計算の名前の由来なのですが,なぜ \(\lambda\) なのかは創始者である Alonzo Church も明らかにしませんでした.
6.7. 演習¶
例題6-1
壁や床とぶつかると跳ね返るが,一定の回数以上跳ね返ったら消滅するような Particle
の postmove ストラテジを作成してください.
例題6-2
壁や床とぶつかると跳ね返るが,跳ね返る度に半径と色がランダムに変わるような Particle
の postmove ストラテジを作成してください.
例題6-3
6.6 節で考えた (名前,数学の成績,英語の成績) のタプルのリストをソートする問題において,以下のルールでソートするような key
引数と
reverse
引数を考えてください.
- 数学と英語の平均点が高い順とする
- 同点の場合は,数学と英語のうち低い方の点が高い順とする
- これも同点の場合は,数学の点が高い順とする
例えば入力リストが:
x = []
x.append(("Alice", 90, 95))
x.append(("Bob", 80, 75))
x.append(("Charlie", 0, 100))
x.append(("Dave", 75, 80))
x.append(("Ellen", 77, 78))
のように与えられた場合には,結果は Alice, Ellen, Bob, Dave, Charlie の順になるようにしてください.
例題6-4
立体図形の表面積と体積の比を計算するメソッド
surface_to_volume_ratio
を持つクラス Solid
を以下のように定義します.
surface_to_volume_ratio
は surface_area
メソッドと
volume
メソッドを使用しますが,これらは定義されていないため,
Solid
自体をインスタンス化して使うことはできません.実際に使うときには,Solid
を継承してこれら 2 つのメソッドを持つクラスを作成します.この Solid
のようなクラスを抽象基底クラスと呼びます.
以下の pass
の部分を適切なコードで置き換えて,Cube
と Sphere
を完成させてください:
import math
import pytest
class Solid:
def surface_to_volume_ratio(self):
return self.surface_area() / self.volume()
class Cube(Solid):
def __init__(self, edge_length):
self.edge_length = edge_length
def volume(self):
pass
def surface_area(self):
pass
class Sphere(Solid):
def __init__(self, radius):
self.radius = radius
def volume(self):
pass
def surface_area(self):
pass
def test_solids():
for a in range(1, 10):
shape1 = Cube(a)
assert shape1.surface_to_volume_ratio() == pytest.approx(6 / a)
shape2 = Sphere(a)
assert shape2.surface_to_volume_ratio() == pytest.approx(3 / a)