6. プログラムの修正と拡張

前章に引き続き,質点の運動学シミュレーションを拡張していきます.具体的には,画面の端で消える代わりに跳ね返る質点を追加します.

前章の particle.py のコードをちゃちゃっとと書き換えてそのような改変をすること自体は簡単です.数行追加する程度で実現できますので,腕に覚えのある人ならわずか数分でできることです.

しかし,一般にプログラムの規模が大きく複雑になってくると,修正や拡張はどんどん難しくなっていきます.よく考えて行わないとあっという間に手に負えないグチャグチャなプログラムに育ちます.

この章では,プログラムを継続的にメンテナンスし,拡張しやすくするための技術のうち代表的なもの,具体的には,クラスの継承,オブジェクト合成の例を見ていきます.プログラムの修正作業を助ける単体テストについても紹介します.

一時的に使うだけの使い捨てのプログラムなら,そんな難しいこと考えずにちゃちゃっと作っちゃってもよいですよね?
本当に使い捨てならそれでよいのですが,そういうつもりで作ったプログラムに対して,忘れた頃に突然修正や機能追加が必要になるというのは実際よくある話です.普段からある程度心がけておいて損はないはずです.

6.1. モジュール

プログラムがだんだん大きくなってきたので,機能追加を始める前に複数のファイルに分けておくことにします.

新たに projectile_main.py を作成し,クラス AppMain の定義をこちらに移動してください.WorldParticle はそのまま particle.py に残します.

particle.py (ver 12.0)
 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)
particle_main.py (ver 12.0)
 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行目
WorldParticle は particle モジュールに属するものになりました.それぞれ particle.Worldparticle.Particle に書き換える必要があります.(もしくは import 行を,from particle import World, Particle とする必要があります)

これにより,自作モジュール particle が出来上がり,それを利用するプログラム projectile_main.py と分離されました.

実行してみて,以前と同様に動くことを確認してから,VSCode の Source Control サイドバーでファイルを 2 つともステージに上げ,コミットしてください.

Run ‣ Run Without Debugging をしても何も表示されず,エラーも起きずに終わってしまいます.
VSCode のエディタ領域で particle_main.py を表示した状態になっていますか?

配布環境の初期設定では,Run ‣ Run Without Debugging した際に,エディタ領域でアクティブになっているファイルを実行します.particle.py がアクティブな状態で実行すると,クラスを定義するだけで終わってしまいます.

毎回 particle_main.py をアクティブにしてから実行するのが面倒であれば,以下のようにして常にこのファイルを実行するような設定 (Configuration) を追加することもできます.

  • Run ‣ Add 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 を作り,以下の内容を保存してください.

test_particle.py (ver 13.0)
 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 の位置 (属性 xy) が想定通りの値と等しいか,さらにもう 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 をクリックしてください.各テスト項目の前の円形マークが緑色になれば成功です.

_images/vscode_test_annot.png

失敗したものは赤色になります.テスト関数か,あるいは Particle クラスの update の内容をちょっと書き換えて,結果が一致しないようにしてみてください.サイドバーの Show Test Output をクリックするとパネル領域で出力を見ることができ,どのテストがどのように失敗したかがわかります. (なお,テストを書いたら一度は失敗させてみることは重要です.そもそもテストとして機能していない誤ったテストを放置してしまうリスクが減ります)

失敗するのを確認したら,ちゃんと修正してグリーンシグナルが灯るのを確認してください.確認できたら,test_particle.py も Git でコミットしておきます.

6.2.3. 複数条件の自動展開

初期条件が 1 つだけではちょっと不安です.いろいろな初期値を用意して for 文で回すことも可能ですが,そうすると,一部が失敗したときにどの条件が失敗したのかわかりにくいという問題があります.また,テスト自体にバグがあると元も子もないので,テスト関数はあまり複雑にしないのが鉄則です.

pytest には,同じテスト関数を複数の入力の組について実行する機能があります.

test_particle.py (ver 14.0)
 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 として,…というように,初期値をいろいろと変えた条件でテストを実行できます.


ほかのテストもしてみましょう.単純な放物運動であれば理論的な解がわかっているので,それと計算結果を比較してみようというのは自然な発想です.

test_particle.py (ver 15.0)
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_discreteParticleupdate に用いている計算式を漸化式とみなして一般項を求めた, \(t = N \Delta t\) における解です.一方 py_continous は,連続時間での解です.高校物理では後者がお馴染みだと思います.

\[\begin{split}y_\text{discrete} &= y(0) + \dot{y}(0) N \Delta t + \frac{1}{2} g N (N + 1) (\Delta t)^2\\ y_\text{continuous} &= y(0) + \dot{y}(0) N \Delta t + \frac{1}{2} g (N \Delta t)^2\end{split}\]

もちろん後者は実際の計算結果と厳密一致しません.しかし 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 が必要になる可能性があることを覚えておいてください.

36行目の for _ in range(N):_ はどういう意味ですか?
Python の慣習で「変数として定義するが使わない」という意思表示です.

ここでは,N 回の繰り返しを行いたいだけです. range(N) から取り出された 0, 1, 2, ... といった数は変数 _ に代入されますが,その値はループ内で使用しません.

ここで何か下手に名前のついた変数を使ってしまうと,後から読んだときに「あれ,この変数は使ってないけど大丈夫か?」と不安になることがあります.未使用の変数というのはバグの兆候だからです.実際,配布環境の VSCode は未使用の変数に対して警告を出すように設定されているので,アンダーラインが表示されるはずです.

変数名を _ にすることで,後にコードを読む人に,あるいは VSCode に「わざと未使用にしています」と知らせることができます.


最後に,is_alive 属性のテストをしましょう.この場合,画面外に出るかどうかの判定を数式で書くよりも,初期条件とともに与えてしまう方が簡単で確実です.expected という引数を加えています.

test_particle.py (ver 16.0)
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 には,ここで紹介する以外にも多数の機能があっります.使いこなすと多少は楽になるので,興味がある人は調べてみてください.

単体テストが有用なのはわかりましたが,例えばキーボード入力に依存する処理とか,乱数を使う処理とか,テストしたいけどテストが書けそうにないものがいろいろあると思うのですが.
その辺をうまくやる方法はいろいろあります.

例えばこの章の後の方では,処理の一部をオブジェクトとして独立させて,必要に応じて入れ替える技法を学びます.同じ要領で,テストしたい処理のうちキーボード入力を読み取る部分や乱数を生成する部分を別のオブジェクトとして独立させ,テストのときだけは既知の値を返すものに入れ変えたりすると,テストが書けるようになります.このようなテスト用に使うオブジェクトをモックオブジェクトなどと呼びます.

この考え方は単体テストに限らずいろいろなところに使えます.例えばあるハードウェアと通信するプログラムを作っているとして,通信処理を置き換えるモックを用意しておくと,実際のハードウェアが手元にない環境でも開発を進めることができます.

dtg も複数の値をテストしなくてよいですか?
する方がよいのですが,長くなるので省略しています.

実際にやるなら,pytest.mark.parametrize を複数重ねて使うのがよいと思います.具体例は test_particle.py (ver 24.0) などを参照してください.

Run All Tests アイコンを毎回クリックするのが面倒です.
ファイルに変更が生じるたびに自動でテストを実行してくれるツールなどもあります.

残念ながら VSCode から利用可能なものは見当たらないのですが,コマンドプロンプトから利用可能なものとして pytest-watch を紹介しておきます.

使い方は簡単です.新しいコマンドプロンプトを開いて作業中のフォルダまで移動し,そこで ptw コマンドを実行します.

C:\cs1\projects>cd particle
C:\cs1\projects\particle>ptw

ファイルの変更が検出される度にテストが自動で走り,表示が更新されます.停止したい時は Ctrl + c を入力してください.

6.3. 機能変更を伴わない修正

単体テストを書いたので,プログラムを修正していきましょう.まずは機能変更を伴わない修正の例を見てみます.

6.3.1. メソッドの切り出し

そろそろお馴染みのメソッド切り出しです.Particleupdate のうち,画面境界に関する処理を別のメソッドに分けます.もちろんこれは,後の機能追加 (跳ね返り処理への入れ替え) への布石です.

修正を始める前に,テストが通ることを確認して,Git でコミット済みであることを確認してください.

particle.py (ver 17.0)
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

修正自体は簡単です.修正後,テストが通ることを確認して,Run ‣ Run Without Debugging でも動かしてみて,問題なければ Git でコミットします.

簡単な作業ですが,テストがあると安心感が違います.また,修正直前の状態をコミットしているので,いざというときは確実に元に戻せるという安心感も重要です.

コードをちょっと直して,テストを走らせて,シグナルがオールグリーンになるのを見てからコミットする.この繰り返しで脳内麻薬が出るようになったらシメたものです.

6.3.2. 抽象度の高いオブジェクトに置き換える

もう少し実質的な内容のある修正をしてみようと思います.これまでは位置を xy,速度を vyvy というスカラ値 2 つずつで表してきましたが,pygame にはベクトルを表現するクラス pygame.math.Vector2 (以下 Vector2) があります.これを使って書き換えてみましょう.

particle.py (ver 18.0)
 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 の属性 xy を使っているからです.ついでにいうと,Particle のメソッド draw の中でも属性 xy を使っているので,普通に particle_main.py を実行してもエラーになるはずです.

つまり,今我々は機能変更をせずにプログラムを修正したかったはずなのに,実は機能が変わってしまっていたわけです.

選択肢は 2 つで,これを機に従来の機能 (属性 xy による位置の取得) を廃止すると決めること,または機能が変わらないように修正することです.ここでは後者を取ることにします.後方互換性はできるだけ保てる方がよいからです.

一つの方法は,xy を属性として残して,pos が更新されるたびにそれらも書き換えておくことです.今のプログラムならこれはまだ簡単ですが,pos が複数個所で更新されるようなプログラムだとすると,ひと仕事です.また,今後 pos を別の個所で更新するような修正を加えるときに,このことを忘れるとえらい目にあいます.

そこで Python に標準で用意されているデコレータ @property を使います.デコレータというのは関数定義の前に置いて,その内容を変化させるものでした.@property は,メソッドに適用すると,その名前をメソッドではない読み取り専用の属性に置き換えて,その属性が読み出されたときにメソッドが呼び出されるようにしてくれます.わかりにくいですね.実際の例で説明しましょう.

particle.py (ver 18.0)
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 型のオブジェクトは,属性 xy で第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 つで挟まれた名前のメソッドは特殊メソッドと呼ばれて,特定の条件で自動的に呼び出されるのでした.これと同様に,例えば velaccVector2 型のオブジェクトで dt が実数のとき:

vel + acc * dt

は:

vec.__add__(acc.__mul__(dt))

の呼び出しによって処理されます.

このように演算子の動作を定義する仕組みを一般に演算子オーバーロード (operator overloading) と呼びます. C++ では operator+operator* というメソッドを定義することで実現します. Java のように演算子オーバーロード機能をもたない言語もあります.

Python の @property のような機能がない言語で,この例のように属性をメソッドに置き換えたくなったらどうすればよいですか?
どうしようもありません.そういう言語では,メソッド以外の属性変数をクラス外から直接読み書きするのをそもそも避けるべきです.

例えば C++ や Java などが「そういう言語」に該当します.これらの言語では,p.xp.y のようにオブジェクトの属性変数に外部から直接アクセスすることがそもそも推奨されません.冗長に感じても,必ず p.x()p.y() のようにメソッドとしてアクセスするようにしておけば,後にメソッドに変えたくなったときに困らずに済みます.

(5章の「通りすがりの Java プログラマ」さんからの質問も参照してください)

ここで使われる property という言葉の意味がよくわかりません. attribute とは何が違うんですか? どっちも日本語なら「属性」ですよね?
歴史的事情で使われるようになった言葉なので,あまり気にしない方がよいと思います.

そもそも,Python でいう「属性」に相当するものの名前はプログラミング言語によってさまざまです.attribute, property, field, member variable, instance variable, …などなど.

そのうち一部の言語が,property という名前で「ただの属性変数のように見えるけど実は裏でメソッドが呼ばれている」という仕組みを使い始め,徐々に他の言語にも採用されるようになりました.

6.4. クラスの継承

6.4.1. 跳ね返り処理の仮実装

さて,いよいよ画面の左右と下の境界でバウンドするような変更を考えます.上の境界を考えてもよいですが,これまで同様に上空は開放されていることにしましょう (特に理由はないですが,上に飛んで行ったのがまた戻ってくるの楽しいですよね?).

内容としては,さっき切り出したメソッド update_after_move の処理内容を変えてやればよさそうです.範囲外に出たときに,is_aliveFalse にする代わりに vel.xvel.y の符号を逆にすれば完全弾性衝突になります.不完全弾性衝突にしたいなら,さらに弾性係数 \(e\) をかけてやればよいです.

そのような質点を表すクラス ConfinedParticle を作ることを考えます.安直な方法は,Particle のコードをコピーペーストして,名前を ConfinedParticle に変え,必要な修正を加えるといったやり方でしょう.しかし,update_after_move メソッド以外は全く変化がないのにすべてをコピペするのは無駄が多すぎます.後に,両方のクラスに共通する修正をしたくなったとき (例えば draw メソッドを修正してもっと見た目を格好よくしたくなったとか,共通部分にバグが見つかったとか) に,両方に同じ修正をしなくてはならなくなります.

そこで,クラスの継承 (inheritance) と呼ばれる仕組みを使います.以下を particle.py に追記してください.

particle.py (ver 19.0)
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) と書かれている点です.これにより ConfinedParticleParticle のすべての定義をいったん引き継ぎます.その上で,再定義されている update_after_move メソッドだけを上書きします.新しい定義では,弾性係数を \(e\) として,左右の壁にぶつかったら vel.x\(-e\) 倍,床にぶつかったら vel.y\(-e\) 倍しています.これ以外のメソッドは Particle のものを全く同じように使えます.

このとき ConfinedParticleParticle を継承しているといいます. ConfinedParticleParticleサブクラス (sub class),子クラス (child clas),派生クラス (derived class) などと呼ばれます.逆に, ParticleConfinedParticleスーパークラス (super class),親クラス (parent class),基底クラス (base class) などと呼ばれます.

particle モジュールを使う側,つまり particle_main.pyParticle 型オブジェクトを生成しているところも ConfinedParticle に書き換えると,とりあえず何かしら動く様子が見えるはずです.

particle_main.py (ver 19.0)
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 の大きさを考慮していないのでちょっと違和感があります.大きさを考慮するとこんな感じになります.

particle.py (ver 20.0)
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 *= -eself.vel.y *= -e の行 (46行目と48行目) にブレイクポイントを仕掛けてデバッガで追ってみるとよいです. 1回の衝突でこれらが複数回実行されていることがわかるはずです.

このシミュレーションは離散時間で行っているため,衝突判定は,一般に壁の中に少し潜り込んだところで起きます.衝突が起きると速度が反転しますが,次の時刻にまだ壁の中に残っている可能性があり,その場合はもう一度反転してしまいます.するとまた壁に向かって進んでしまい,また反転するがやはり壁から出られない…をひたすら繰り返します.

原因がわかれば対策は簡単で,衝突条件として速度も考慮すればよいです.例えば右側の壁なら,属性 vel.x が正である場合に限ることにします.

2つ目の問題は少しわかりにくいのですが,床上に静止した状態で力が釣り合うような機構が根本的に存在していないことに起因します.質点には常に重力がかかっており,本来はそれと釣り合う抗力が床から与えられなければいけませんが,このシミュレータにそんなものはありません.

この問題に関しては,対症療法ですが,y 方向の衝突が発生したときだけ位置を強制的に床面に移動させておくことで回避することにします.

これら 2 つの対処を盛り込むと,以下のようになります.

particle.py (ver 21.0)
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行目で,条件が長くなりすぎて \ で折り返していることに注意してください.

長いだけでなく,ドット演算子が続きすぎて読みづらいと感じます.こういう場合は,いったん簡潔な名前のローカル変数で受けるという手があります.

particle.py (ver 22.0)
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 に修正するのが望ましい手順です.

test_particle.py (ver 21.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 で,ParticleConfinedParticle をマウスボタンによって打ち分けられるようにしてみます.左ボタンで従来通りの Particle を,右ボタン (ボタン番号 3) では ConfinedParticle を打ち上げられるようにします.見た目で区別がつくように後者は青色に変更します.

particle_main.py (ver 23.0)
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) といいます.ポリモーフィズムは,オブジェクト指向プログラミングにおける「責務をオブジェクトに移す」ことを可能とする重要な性質であり,継承はその一手段といえます.

これって,Python のリストが異なる型を混在させてもよいから可能なことですよね? そうでない言語ではどうなるんですか?
リストの型として基底クラスを指定しておけば大丈夫です.

オブジェクト指向プログラミングをサポートする言語であれば,あるクラス型のオブジェクトを割り当てられるように宣言された変数に,そのクラスの派生クラス型のオブジェクトを割り当てることができます.

このことを利用すると,変数に型の宣言が必要な言語でも同様のことが可能です.「利用すると」というよりも,そういうことができるようにクラスの継承機構が作られているという方が適切です.

(なお,言語によっては上記の「オブジェクト」を「オブジェクトへの参照またはポインタ」に読み替える必要があります.例えば 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行目).

結果として,Particletest メソッドが 2 回, ConfinedParticletest メソッドが 1 回呼ばれて,以下のように出力されます:

10
10
20

6.5. オブジェクト合成

6.5.1. Strategy パターン

継承によって2種類の質点を作りましたが,同様のことは継承を使わなくても可能です.上書きする新しい処理を外部の関数として定義しておき,Particle を初期化するときに (あるいは初期化後に) オブジェクトにその関数を保持させることで動作を変化させる方法です.

やはり具体例で見ていきます.

particle.py (ver 24.0)
 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行目

ConfinedParticleupdate_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) を渡します.
particle_main.py (ver 24.0)
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_strategybounce_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 は,ConfinedParticleupdate_after_move と中身がほとんど同じなのでコピーペーストで作ろうと思ったのですが,多数の selfp に書き換えて回るのが面倒です.何とかなりませんか.
VSCode の rename symbol 機能を使うと便利です.

まずいったん引数も self という名前にしたままでコードをコピーしてから,self を右クリックして Rename Symbol を選んでください.リネーム後の名前として p を指定すると,自動で置き換えてくれます.

エディタの置換機能で単純に文字列置換をしてもよいのですが,気をつけないと他のメソッドの self まで置き換えてしまったり,また,例えばどこかに myself などという別の変数があったりするとそちらまで書き換わってしまったりします.開発環境の機能を使うと,文法を理解してリネームしてくれるためより安全です.

bounce_on_boundaryParticle クラスの外部にある関数ですよね.クラス外から Particle の内部属性である vel.xvel.y などを更新してしまうのは「できるだけ避ける」方針だったのではないでしょうか?
はい,継承と対比したときのわかりやすさのため,承知の上でその方針に違反しています.

この方針を堅持するならば,Particleset_velset_pos といったメソッドを用意して,bounce_on_boundary からはそれらを利用して状態変更するのが正しいです.

余談ですが,継承を使った場合もこれと同じ問題が発生していたことを指摘しておきます.多くの主要なプログラミング言語では,派生クラスは基底クラスの属性にアクセスできないのがデフォルトの動作です.つまり派生クラスは「外部」とみなします.これと同じ考え方に立つなら, ConfinedParticle の中から vel.xvel.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.parametrizescheme を変化させています.

test_particle.py (ver 24.0)
 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) と呼びます.

particle.py (ver 25.0)
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 に反発係数属性の値を受けている点だけ注意してください.これがやりたかったことでした.

particle_main.py (ver 25.0)
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 に関数を渡す場合,呼び出し可能オブジェクトを渡す場合のそれぞれを同じコードでテストできます.

このようにオブジェクトを生成する部分を関数やメソッドにして,生成方法を簡単に切り替えられるようにしておくと便利なことが多いです.このような関数をファクトリと呼びます.テスト以外でもよく使われます.次の章でいくつか具体例を見ます.

test_particle.py (ver 25.0)
 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 パターンによってクラスが持つ機能の一部を置き換える例を見てきました.見方を変えると,これらは,定義済みのクラス (あるいは関数) で実装済みの機能を他のクラスからうまく利用するための技術であると見ることができます.

  1. 継承で作った ConfinedParticleParticleupdatedraw などを再利用しています.
  2. Strategy パターンで作った跳ね返る Particle は, BounceOnBoundaryStrategy 型のオブジェクトを属性として保持して,その __call__ を利用しています.
  3. ここでは紹介しませんでしたが,ConfinedParticle クラスを Particle を継承せずに作り,代わりに Particle 型オブジェクトを属性として保持して,updatedraw の処理はそちらに任せてしまうというやり方も可能です.

この 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 と呼び,文法を別にしている言語もあります.

「Strategy パターン」という言葉に違和感があります.パターンとはどういうことですか? ストラテジ (戦略) って何だか大げさすぎませんか?
Strategy はデザインパターン (design pattern) と呼ばれるものの一種です.

オブジェクト指向プログラミングという考え方が形成され普及していく過程で,ベストプラクティスだと考えられる設計やその実装技法が「パターン」として整理され,デザインパターンと呼ばれるようになりました.

有名なのは 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] に得られます.
  • 外側のループでは kn-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引数 reverseTrue のときは降順でソートします.

このような指定方法になっている背景として,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)]
lambda ってギリシア文字の \(\lambda\) ですよね? なぜ関数を表す意味になるんですか?
ラムダ計算という数理論理学の記法が由来ですが,なぜラムダなのかはよくわかっていません.

ラムダ計算では,例えば \(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_ratiosurface_area メソッドと volume メソッドを使用しますが,これらは定義されていないため, Solid 自体をインスタンス化して使うことはできません.実際に使うときには,Solid を継承してこれら 2 つのメソッドを持つクラスを作成します.この Solid のようなクラスを抽象基底クラスと呼びます.

以下の pass の部分を適切なコードで置き換えて,CubeSphere を完成させてください:

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)