5. オブジェクト指向への第一歩¶
この章と次の章で,以下のように放物運動をする質点の運動学シミュレーションを実装します.この例を通して,この章ではクラスの定義とオブジェクト指向プログラミングの基本的な考え方を学びます.
- 左クリック: 画面境界で消える質点を投てき
- 右クリック: 画面境界で跳ね返る質点を投てき
- ESCキー: 終了
5.1. 枠組みだけを作る¶
まずは,プログラム全体の枠組みを作っておきます.真っ黒のウィンドウを開いて,60 frames/s でループを回し, pygame.QUIT
イベントもしくは
Esc キーで止められるようにしておきます.
projects
フォルダの中に particle
フォルダを作成し,VSCode でそのフォルダを開いた状態にしてください.そこに particle.py
というファイルを作成し開いてください.VSCode のタイトルバーは particle.py - particle
- VSCodium (Computer Seminar I)
になるはずです.
注意
そろそろ忘れているかもしれないので再度の注意です.
(ファイルを開く) や (ワークスペースを開く) ではなく,必ず (フォルダを開く) を使ってください.また,開くフォルダは C:/cs1/projects/particle
です.
C:/cs1/projects
ではありません.
particle.py
に以下の内容を入力してください.
1import pygame
2
3
4def main():
5 pygame.init()
6 width, height = 600, 400
7 screen = pygame.display.set_mode((width, height))
8 clock = pygame.time.Clock()
9
10 while True:
11 frames_per_second = 60
12 clock.tick(frames_per_second)
13
14 should_quit = False
15 for event in pygame.event.get():
16 if event.type == pygame.QUIT:
17 should_quit = True
18 elif event.type == pygame.KEYDOWN:
19 if event.key == pygame.K_ESCAPE:
20 should_quit = True
21 if should_quit:
22 break
23
24 screen.fill(pygame.Color("black"))
25 pygame.display.update()
26
27 pygame.quit()
28
29
30if __name__ == "__main__":
31 main()
前章までの内容を理解していれば,特に説明の必要はないと思います.この章では関数を切り出すのとは別のアプローチで抽象化するため,まずは
main
関数1個にすべてを入れておきました.
Source Control サイドバーを開いて Git リポジトリを初期化して,ファイルの内容をコミットしておいてください.以降,これまで通り,キリのいいところで適宜コミットしておくようにしてください.
5.2. 放物運動¶
加速度 \(\boldsymbol{a}(t)\) を受ける質点の速度 \(\boldsymbol{v}(t)\) と位置 \(\boldsymbol{x}(t)\) は以下のように書けます.
コンピュータでシミュレートするため,時間を \(\Delta t\) ごとに離散化して以下のように近似します.
マウスの左ボタンクリック位置を初期位置とし,初期速度を乱数で発生させることにして, y 軸方向を重力方向と見立てて,一定の加速度がかかっている場合の計算をしてみます.
乱数の生成には Python 標準の random.uniform
関数を使うことにします.
random.uniform(a, b)
で a
から b
の範囲の一様乱数を生成します.
1import random
2import pygame
3
4
5def main():
6 pygame.init()
7 width, height = 600, 400
8 screen = pygame.display.set_mode((width, height))
9 clock = pygame.time.Clock()
10
11 dt = 1.0
12 gy = 0.5
13 particle_is_alive = False
14 x, y = 0, 0
15 vx, vy = 0, 0
16
17 while True:
18 frames_per_second = 60
19 clock.tick(frames_per_second)
20
21 should_quit = False
22 for event in pygame.event.get():
23 if event.type == pygame.QUIT:
24 should_quit = True
25 elif event.type == pygame.KEYDOWN:
26 if event.key == pygame.K_ESCAPE:
27 should_quit = True
28 elif event.type == pygame.MOUSEBUTTONDOWN:
29 if event.button == 1:
30 particle_is_alive = True
31 x, y = event.pos
32 vx = random.uniform(-10, 10)
33 vy = random.uniform(-10, 0)
34 if should_quit:
35 break
36
37 if particle_is_alive:
38 vy += gy * dt
39 x += vx * dt
40 y += vy * dt
41
42 screen.fill(pygame.Color("black"))
43 if particle_is_alive:
44 radius = 10
45 pygame.draw.circle(screen, pygame.Color("green"), (x, y), radius)
46 pygame.display.update()
47
48 pygame.quit()
49
50
51if __name__ == "__main__":
52 main()
- 1行目
- random モジュールをインポートします.
- 13-15行目
- 最初に左クリックするまでは質点は存在しないことにします.
particle_is_alive
という変数を用意し,これがFalse
のときは運動計算も描画もしないことにします.位置座標x
,y
, 速度vx
,vy
も,適当な値で初期化しておきます. - 28-33行目
type
属性がpygame.MOUSEBUTTONDOWN
であるようなEvent
型オブジェクトを検出します.このとき,Event
型オブジェクトのbutton
属性にマウスボタン番号が入っています.左ボタンは番号 1 です.- 37-40行目,43-45行目
- 運動計算と描画処理を
particle_is_alive
がTrue
のときだけ行います.
前回 0 だったのは pygame.mouse.get_pressed
が返すシーケンスへのインデックスです.Python のシーケンスのインデックスだから 0
から始まって,左ボタンが 0,(もしあれば) 中ボタンが 1,右ボタンが 2 です.
一方,今回のはインデックスではないので 0 から始まるという縛りがない…からなのかどうかはわかりませんが,左ボタンが 1,中ボタンが 2,右ボタンが 3 です.迷惑.
運動方程式の積分を離散時間で行う方法にはいろいろあり,それぞれ誤差の生じ方が違います.長時間の計算を行う際は,解が時間ともに発散してしまわないことは特に重要です.Symplectic 積分法は,簡単な割にエネルギーが増大しにくい方法として知られています.
Symplectic 法の要点は,先に速度を更新し,その更新された速度を用いて位置の更新をすることです.同じことですが,先に位置を更新してからその位置で力の発生を評価し,その力によって生じる加速度で速度を更新していると解釈することもできます.(なお,今の例では力は重力加速度のみで常に一定なので,あまりご利益はありません.今後の拡張で効いてきます)
左ボタンクリックを短時間で繰り返してもらうとわかりますが,左クリックをすると現在の質点が消えて,新しいのが生まれます.ちょっと不自然ですね.現在のものが画面外に出るまでは新しいものを作れないようにします.
21 should_quit = False
22 for event in pygame.event.get():
23 if event.type == pygame.QUIT:
24 should_quit = True
25 elif event.type == pygame.KEYDOWN:
26 if event.key == pygame.K_ESCAPE:
27 should_quit = True
28 elif event.type == pygame.MOUSEBUTTONDOWN:
29 if event.button == 1 and not particle_is_alive:
30 particle_is_alive = True
31 x, y = event.pos
32 vx = random.uniform(-10, 10)
33 vy = random.uniform(-10, 0)
34 if should_quit:
35 break
36
37 if particle_is_alive:
38 vy += gy * dt
39 x += vx * dt
40 y += vy * dt
41 if x < 0 or x > width or y > height:
42 particle_is_alive = False
43
44 screen.fill(pygame.Color("black"))
if 文の条件に使われる真偽値の論理演算には,演算子 and
, or
,
not
を使うことだけ思い出せば,特に難しいことはないはずです.
これをさらに発展させて,複数の質点を同時に動かすことを考えます.
すぐに思いつくのは x
, y
, vx
, vy
,
particle_is_alive
をそれぞれリストにする方法です.例えば k 番目の質点の運動計算は以下のようになるでしょう:
vy[k] += gy * dt
x[k] += vx[k] * dt
y[k] += vy[k] * dt
これはこれで可能なのですが,質点が頻繁に増えたり減ったりする場合には,すべてのリストの要素を append
したり del
したりする必要があって管理が煩雑です.うっかり一つ忘れたりすると (例えば particle_is_alive
だけ append
し忘れたりすると),変数間でインデックスがずれてしまい,原因追求が難しいバグにつながります.
それよりも,質点 1 個をひとまとまりのデータとして扱って,x
や
vx
などは「そこに所属するもの」とするのが合理的です.以降ではそのような方針を取ります.
5.3. データをまとめる¶
要するにこれからやるのは x
や vx
といった属性を持つオリジナルの型のオブジェクトを作ることです.オブジェクトの型,つまりそのオブジェクトがどんな属性を持ちどんな操作が可能なのかを定義する仕組みをクラス
(class) と呼びます.
質点を表す Particle
クラスを実際に定義してみましょう.まずは質点 1
個だけのままです.
1import random
2import pygame
3
4
5class Particle:
6 pass
7
8
9def main():
10 pygame.init()
11 width, height = 600, 400
12 screen = pygame.display.set_mode((width, height))
13 clock = pygame.time.Clock()
14
15 dt = 1.0
16 gy = 0.5
17 p = Particle()
18 p.is_alive = False
19 p.x, p.y = 0, 0
20 p.vx, p.vy = 0, 0
21
22 while True:
23 frames_per_second = 60
24 clock.tick(frames_per_second)
25
26 should_quit = False
27 for event in pygame.event.get():
28 if event.type == pygame.QUIT:
29 should_quit = True
30 elif event.type == pygame.KEYDOWN:
31 if event.key == pygame.K_ESCAPE:
32 should_quit = True
33 elif event.type == pygame.MOUSEBUTTONDOWN:
34 if event.button == 1 and not p.is_alive:
35 p.is_alive = True
36 p.x, p.y = event.pos
37 p.vx = random.uniform(-10, 10)
38 p.vy = random.uniform(-10, 0)
39 if should_quit:
40 break
41
42 if p.is_alive:
43 p.vy += gy * dt
44 p.x += p.vx * dt
45 p.y += p.vy * dt
46 if p.x < 0 or p.x > width or p.y > height:
47 p.is_alive = False
48
49 screen.fill(pygame.Color("black"))
50 if p.is_alive:
51 radius = 10
52 pygame.draw.circle(screen, pygame.Color("green"), (p.x, p.y), radius)
53 pygame.display.update()
54
55 pygame.quit()
56
57
58if __name__ == "__main__":
59 main()
- 5-6行目
Particle
という名前のクラスを定義しています.クラス名の後のコロン:
を忘れないようにしてください.Python にも慣れてきたと思いますので,コロンが出てきたら,次の行からは 1 レベルインデントして内容を書くんだな,と予測できると思います.その通りです.…その通りなのですが,実はこの例ではここに書くべき「内容」がありません.Python では,このように文法上何かを書かなくてはならないのに書くべきものがない場合,
pass
文を置きます.何もしない文でした.内容が何もないというのは奇妙に思えるかも知れません.しかしここでは
Particle
という名前のクラスを作ったという事実だけで十分です.- 17-20行目
p = Particle()
と書くことで,Particle
型のオブジェクトを生成し,変数p
に割り当てます.一般に,クラス名の後ろに()
をつけて関数として呼び出すと,その型のオブジェクトを生成できます.続いて,
p
が指すオブジェクトの属性x
,y
,vx
,vy
,is_alive
を定義します.ここで定義するから,6 行目はpass
だけでよかったわけです.Particle
型のオブジェクトのことを,Particle
型のインスタンス (instance),クラスParticle
のインスタンスなどとも呼びます.オブジェクトを生成することをインスタンス化する (instantiate) ともいいます.- 34-38, 42-47行目
- 変数
p
の属性を使って初期位置・速度設定,運動計算,描画の処理を行います.
pygame.Color
関数とか
pygame.time.Clock
関数とかってもしかして…?()
をつけて,オブジェクトを生成しているのでした.Color の方は ()
の中に引数を取りましたが,その仕組みの説明もこの後すぐに出てきます.x
とか vx
とかのメンバ変数を列挙しますよね.Python のクラスではどうして pass
で済んじゃうんですか?C言語では
struct Particle {
float x;
float y;
float vx;
float vy;
int is_alive;
};
と書くと struct Particle
という名前の新しい型を作ることができ,その型のデータはメンバ変数 x
, y
, vx
, yv
,
is_alive
を持つようになります.クラスというのは,メンバとして変数のほかに関数 (メソッド) を持てるように構造体を拡張したもので,C++ や Java など他の言語にも用意されています.
C, C++, Java 等では,変数はあらかじめ宣言する必要がありますので,構造体やクラスの定義時にメンバ変数を宣言しなくてはなりません.これに対して Python では変数の宣言は必要ないので,クラスを定義する時点で変数を列挙しておく必要はありません.
もう少し後で,クラス内に関数 (メソッド) を定義します.関数については Python でも使用前に定義しておかないといけないので,
pass
するわけにはいきません.
Particle
のように語頭を大文字にするんですね?UpperCamelCase
のように書きます.このような書き方を UpperCamelCase または PascalCase などと呼びます.なお,先頭だけを小文字にするものは lowerCamelCase と呼ばれます.(単に camel case といったときどちらを指すかは人によるようです)
そもそもインスタンスとは,実例,具体例の意味です.なので「x はインスタンスです」という文は意味をなしません (「何の実例だよ!」という反応を招きます).「x はクラス A のインスタンスです」などとして初めて意味をなします.
一方,「x はオブジェクトです」という文はそれ自体で意味をなし得ます.Python の場合は,「ああ,x には属性とかメソッドとかがあるんですね」とか「Online Python Tutor で可視化したときに右側に出てくるんですね」という理解ができます.
(以下はややこしい話なので,慣れないうちは気にしない方がよいです)
「x はクラス A のオブジェクトです」という文は間違いではないのですが,「x は A 型のオブジェクトです」「x はクラス A のインスタンスであるオブジェクトです」という方が誤解のおそれがありません. (なぜかというと,実は Python ではクラスもオブジェクトの一種だからです.ややこしいですね)
それでは満を持して複数の質点を同時にシミュレートするように拡張します.
Particle
型オブジェクトのリスト particle_list
を作って複数の質点を管理します.
1import random
2import pygame
3
4
5class Particle:
6 pass
7
8
9def main():
10 pygame.init()
11 width, height = 600, 400
12 screen = pygame.display.set_mode((width, height))
13 clock = pygame.time.Clock()
14
15 dt = 1.0
16 gy = 0.5
17 particle_list = []
18
19 while True:
20 frames_per_second = 60
21 clock.tick(frames_per_second)
22
23 should_quit = False
24 for event in pygame.event.get():
25 if event.type == pygame.QUIT:
26 should_quit = True
27 elif event.type == pygame.KEYDOWN:
28 if event.key == pygame.K_ESCAPE:
29 should_quit = True
30 elif event.type == pygame.MOUSEBUTTONDOWN:
31 if event.button == 1:
32 p = Particle()
33 p.is_alive = True
34 p.x, p.y = event.pos
35 p.vx = random.uniform(-10, 10)
36 p.vy = random.uniform(-10, 0)
37 particle_list.append(p)
38 if should_quit:
39 break
40
41 for p in particle_list:
42 p.vy += gy * dt
43 p.x += p.vx * dt
44 p.y += p.vy * dt
45 if p.x < 0 or p.x > width or p.y > height:
46 p.is_alive = False
47
48 particle_list[:] = [p for p in particle_list if p.is_alive]
49
50 screen.fill(pygame.Color("black"))
51 for p in particle_list:
52 radius = 10
53 pygame.draw.circle(screen, pygame.Color("green"), (p.x, p.y), radius)
54 pygame.display.update()
55
56 pygame.quit()
57
58
59if __name__ == "__main__":
60 main()
- 17行目
particle_list
変数を用意します.空のリストで初期化しておきます.- 31-37行目
Particle
型オブジェクトは,マウスボタンが押されたときに生成して属性を初期化し,particle_list
にappend
します.複数の質点の存在を許すため,
if
文の条件の「is_alive
でないならば」は外します.- 41行目
particle_list
の各要素について運動計算と範囲外に出たかどうかのチェックをします.- 48行目
is_alive
属性がFalse
な質点を削除します.リスト内包記法を使って,is_alive
属性がTrue
であるものだけを残したリストを作り,particle_list
の全要素を置き換えます.- 51行目
particle_list
の各要素を描画します.
以下,他の手段について検討していきますが,たぶん途中で「あー,もういい,わかった,リスト内包でいいよ」ってなると思います.そこで読むのをやめて結構です.
まず安直に考えると,以下のような方法が思いつくと思います:
for p in particle_list:
if not p.is_alive:
particle_list.remove(p)
しかしこれはエラーになります.リストの要素を for
文で回している最中にそのリストを変更することは許されていません.
これを回避するためによく使われるのは以下のようなテクニックです:
for p in particle_list[:]:
if not p.is_alive:
particle_list.remove(p)
何が変わったかわかるでしょうか.for
文で要素を回すリストを
particle_list[:]
としています.つまりいったん
particle_list
のコピーを作ることで,上の制限を回避しています.言われないと気付かないですよね.
さらに細かいことをいうと,上の方法は性能面でもちょっと微妙です.
for
文でリストの各要素についてループしながら別のリストの要素を
remove
しているわけですが,particle_list.remove(p)
はリストの先頭から p
を探す処理を含みます.ループの内側にループが入っているわけですが,削除すべき要素は特定できているのだから,こんな二重ループは不要なはずです.
これを避けるには,インデックスを使って:
for i in range(len(particle_list)):
if not particle_list[i].is_alive:
del particle_list[i]
と書けばよい…と思いましたか? これは期待通りに動きません.
particle_list
の要素を前から削除していくと,削除された要素以降のインデックスが変わってしまいます.よって正しくは range
の結果を逆順にして:
for i in reverse(range(len(particle_list))):
if not particle_list[i].is_alive:
del particle_list[i]
としなくてはなりません.
…ほら,リスト内包の方がわかりやすいですよね.
particle_list = ...
としてはダメですか?particle_list = ...
とした場合,右辺で作られた新しいリストが変数 particle_list
に新たに割り当てられます.もともと
particle_list
に割り当てられていたリストは破棄されます.
一方 particle_list[:] = ...
と書いた場合,もともと
particle_list
に割り当てられていたリストの内容が右辺のリストの内容で置き換えられます.
5.4. データの操作を整理する¶
質点 1 個に関するデータをひとまとまりとして,複数の質点を動作させることはできました.そうこうしているうちに main
関数が長くなってきました.データをまとめるだけではコードは簡潔にはならないのです.これまで同様に関数に切り分けていきたいと思います.
しかしデータをまとめたことが無駄なわけではありません.単に計算手順のまとまりに着目するだけでなく,データのまとまりにも着目することですっきりさせることができます.
具体的には以下の方針で考えます:
main
側からはParticle
をできるだけひとまとまりのものとして扱い,個々の属性を直接読み書きするコードは関数を作って閉じ込める- 完全に閉じ込めるのが難しい場合は,
main
側から個々の属性の読み取りだけは許す (書き込むのは可能な限り避ける)
1import random
2import pygame
3
4
5class Particle:
6 pass
7
8
9def init_particle(p, pos, vel):
10 p.is_alive = True
11 p.x, p.y = pos
12 p.vx, p.vy = vel
13
14
15def update_particle(p, width, height, dt, gy):
16 p.vy += gy * dt
17 p.x += p.vx * dt
18 p.y += p.vy * dt
19 if p.x < 0 or p.x > width or p.y > height:
20 p.is_alive = False
21
22
23def draw_particle(p, screen):
24 radius = 10
25 pygame.draw.circle(screen, pygame.Color("green"), (p.x, p.y), radius)
26
27
28def main():
29 pygame.init()
30 width, height = 600, 400
31 screen = pygame.display.set_mode((width, height))
32 clock = pygame.time.Clock()
33
34 dt = 1.0
35 gy = 0.5
36 particle_list = []
37
38 while True:
39 frames_per_second = 60
40 clock.tick(frames_per_second)
41
42 should_quit = False
43 for event in pygame.event.get():
44 if event.type == pygame.QUIT:
45 should_quit = True
46 elif event.type == pygame.KEYDOWN:
47 if event.key == pygame.K_ESCAPE:
48 should_quit = True
49 elif event.type == pygame.MOUSEBUTTONDOWN:
50 if event.button == 1:
51 p = Particle()
52 vx = random.uniform(-10, 10)
53 vy = random.uniform(-10, 0)
54 init_particle(p, event.pos, (vx, vy))
55 particle_list.append(p)
56 if should_quit:
57 break
58
59 for p in particle_list:
60 update_particle(p, width, height, dt, gy)
61
62 particle_list[:] = [p for p in particle_list if p.is_alive]
63
64 screen.fill(pygame.Color("black"))
65 for p in particle_list:
66 draw_particle(p, screen)
67 pygame.display.update()
68
69 pygame.quit()
70
71
72if __name__ == "__main__":
73 main()
- 9-12行目と 52-54行目
Particle
型オブジェクトp
の初期化を行うinit_particle
という関数を作り,main
からはそれを呼びます.main
側 (52-54行目) に現れるのはp
だけで,p.x
やp.vx
などに直接触れなくなったことに着目してください.init_particle
に第1引数としてp
を渡し,その属性の操作を任せています.- 15-20行目と60行目, 23-25行目と66行目
- 同様に,状態更新を行う関数と描画を行う関数を切り出します.
- 62行目
- ここは変更がありません.ここでは
p
の属性is_alive
に直接触っています.ここで関数を切り出そうとしても単にis_alive
返すだけの関数を作るだけで,別に読みやすくなりません.素直にmain
側から属性を読み出すことにします.
Particle
単体についての処理を切り出すことを考えています.「Particle
のリスト」についての処理を整理するのは別の話です.あとで AppMain
というクラスを作るときにこの辺の整理に踏み込みます.
読み書き両方を避けられるに越したことはないですが,それが難しい場合,せめて外部から属性が書き換えられるのだけでも防げれば,混乱を最小限にすることができます.
5.5. データとその操作をまとめる¶
今作った init_particle
, move_particle
, draw_particle
は,クラス Particle
の定義の外側で定義されています.しかし,これらは
Particle
型オブジェクトの属性を扱う関数なので,Particle
クラスの「内部のもの」であることが明確にわかるよう書けるのが自然です.
Python では,
class ClassABC:
の内側で def method_xyz():
と書いて関数定義をすることでこれを実現します.その結果
ClassABC.method_xyz
という名前の「関数」が作られます.これを
ClassABC
のメソッド method_xyz
と呼びます.
そして:
object_abc.method_xyz(引数1, 引数2, 引数3)
と書くことで:
ClassABC.method_xyz(object_abc, 引数1, 引数2, 引数3)
と書いたのと同じ効果を得ることができるようになっています.ただし
object_abc
は ClassABC
のインスタンスであるとします.
つまり,クラス名を直接指定してメソッドを呼び出すのではなく,そのオブジェクトがどのクラスのインスタンスかによって呼ばれるメソッドが決まります.そしてそのメソッドには,最初の引数としてオブジェクト自身が自動で渡されます.
これがこれまで何度となく目にしてきたオブジェクト.メソッド()
という形式の呼び出し方の正体です.
1import random
2import pygame
3
4
5class Particle:
6 def __init__(self, pos, vel):
7 self.is_alive = True
8 self.x, self.y = pos
9 self.vx, self.vy = vel
10
11 def update(self, width, height, dt, gy):
12 self.vy += gy * dt
13 self.x += self.vx * dt
14 self.y += self.vy * dt
15 if self.x < 0 or self.x > width or self.y > height:
16 self.is_alive = False
17
18 def draw(self, screen):
19 radius = 10
20 pygame.draw.circle(screen, pygame.Color("green"), (self.x, self.y), radius)
21
22
23def main():
24 pygame.init()
25 width, height = 600, 400
26 screen = pygame.display.set_mode((width, height))
27 clock = pygame.time.Clock()
28
29 dt = 1.0
30 gy = 0.5
31 particle_list = []
32
33 while True:
34 frames_per_second = 60
35 clock.tick(frames_per_second)
36
37 should_quit = False
38 for event in pygame.event.get():
39 if event.type == pygame.QUIT:
40 should_quit = True
41 elif event.type == pygame.KEYDOWN:
42 if event.key == pygame.K_ESCAPE:
43 should_quit = True
44 elif event.type == pygame.MOUSEBUTTONDOWN:
45 if event.button == 1:
46
47 vx = random.uniform(-10, 10)
48 vy = random.uniform(-10, 0)
49 p = Particle(event.pos, (vx, vy))
50 particle_list.append(p)
51 if should_quit:
52 break
53
54 for p in particle_list:
55 p.update(width, height, dt, gy)
56
57 particle_list[:] = [p for p in particle_list if p.is_alive]
58
59 screen.fill(pygame.Color("black"))
60 for p in particle_list:
61 p.draw(screen)
62 pygame.display.update()
63
64 pygame.quit()
65
66
67if __name__ == "__main__":
68 main()
- 6-20行目
先ほど切り出した 3 つの関数をクラスの定義内に移して
Particle
型に属するメソッドにします.ようやくpass
ではなく意味のあることを書けるようになりました.Particle
型に属していることはわかるので,関数名の後半の _particle の部分はもはや不要です.また,初期化用のメソッドは必ず__init__
という名前にするのが Python の約束です.3 つのメソッドとも,第1引数が
self
という名前に変わったことに注意してください.ここには,呼び出し側の.
の前にあるオブジェクト自身が渡されてきます.self
という名前は実は単なる慣習ですが,必ずこの名前を使います.- 46-49行目
これまで
p = Particle()
としてオブジェクトを作り,init_particle(p, arg1, arg2)
という形式で初期化していましたが,このオブジェクト生成と初期化をまとめてp = Particle(arg1, arg2)
と書けるようになりました.なぜならば,この書き方により,オブジェクトの生成と
Particle.__init__(p, arg1, arg2)
の呼び出しが行われる約束になっているからです.Python ではこのような
__xyz__
という形の名前を持つメソッドは特殊メソッド (special method) と呼ばれます.プログラム中で直接呼び出すためのものではなく,何らかの条件で自動的に呼び出されるものにこういう名前がついています.- 55行目,61行目
move
とdraw
の呼び出しも「メソッド呼び出し形式」に書き換えました.
クラスと,そのインスタンスであるオブジェクトの関係は,「整数」と,そのインスタンスである「1」とか「2」の関係と同じです.「猫」と,そのインスタンスである「磯野さんちのタマ」とか「西根さんちのミケ」の関係と同じです.
クラスを定義することで,そのインスタンスであるオブジェクトがどのように振舞うかが定まります.その意味で,クラスを定義する工程はオブジェクトを定義する工程であるともいえます.
一方,クラスを使わずに書かれた既存のコードをクラスを使って整理することもよくありますが,そういう場合もここまで回りくどいことはせず,すぐ後で見る AppMain
を作る例のようになるのが普通です.
is_alive
属性を外から読めるようにする設計には違和感があるのですが.質問で述べられているような設計を Python で書いてみると以下のようになります (関係する箇所以外は省略しています):
class Particle:
def __init__(self):
self._is_alive = True
def is_alive(self):
return self._is_alive
元のコードの属性 is_alive
を _is_alive
という名前に変えました.Python の慣習で,属性の名前が _
から始まるときは「クラス外からは読み書きしてはいけない」約束になっています.(Java や
C++ の用語でいうならば private 指定をしていることに相当します)
この属性の値をクラス外から読み出すときは,別途用意したメソッド
is_alive
を使います.例えば元のコードの 57 行目は以下のように書くことになります:
particle_list[:] = [p for p in particle_list if p.is_alive()]
このように属性を読み取るだけのメソッドをゲッター (getter) と呼びます.同様に,属性に値をセットするメソッドを設ける場合,それをセッター (setter) と呼びます.総称してアクセッサ (accessor) と呼ぶ場合があります.
Java や C++ ではこのような書き方が必要となるのは,6 章で述べるようなプロパティ (property) 機構がこれらの言語に用意されていないからです.詳しくは 6 章まで待ってください.
とはいえ,Python でもこのようにゲッター・セッターを用意するのを好む人もいます.他の言語と設計を共通にしたいなど,理由はいろいろあり得ると思います.
5.6. 結局何をしたのか¶
関係するデータをひとまとまりにして,それらを操作するメソッドを用意することで,プログラムを整理する例を見てきました.何となく読みやすくなったかなと思ってもらえるとよいのですが,まだピンと来ない人も多いかも知れません.
このように整理することで何が変わったのかを標語的にいうと,「運動法則に従って動く」という責務 (responsibility) を,メインプログラムからオブジェクトに移したといえます.当初のコードでは,メインプログラムが質点の動きを事細かに指示していました.これに対して整理後のプログラムでは,メインプログラムはオブジェクトに「自分の状態を更新してください」「自分を描画してください」と依頼するだけで,その詳細はオブジェクト自身に任せるスタイルになっています.
このようにプログラムが果たすべき責務を分割してオブジェクトに任せていくスタイルをオブジェクト指向プログラミング (object-oriented programming) と呼びます.
Particle
の中身を作っているときはそれを使う側の詳細に注意を払うことなく,使う側のコードを書いているときは Particle
の中身を気にしなくて済むようにすることができます (理想的には).
この特徴が,複数人で分業するときに有益なのは言うまでもありません.
誰かが何らかの文書で厳密に定義したというようなものではなく,長い年月をかけて醸成され,計算機科学者やプログラマのコミュニティの中で緩やかに合意形成されてきたものです.
なので,少し古い本を読めばちょっと違った力点の置き方で説明されていることもあると思います.現代においても,人によってちょっとずつ言うことが違いますが,「責務を分割してオブジェクトに任せる」という理念が要点であることには多くの人が同意するものと思います.
オブジェクト指向型と呼ばれるプログラミング言語に用意されているいろいろな機能は,その理念を実現しやすくするための手段で,具体的にどんな機能を使うかは言語ごとに違います.そのため,機能の有無によってオブジェクト指向を定義するのは難しいです.
クラスを使えばオブジェクト指向とか,オブジェクトを使えばオブジェクト指向といったふうに簡単に述べられればよいのですが,そんなに単純ではありません.クラスを持たないオブジェクト指向プログラミング言語も存在しますし,言語仕様にオブジェクトという概念があるのにオブジェクト指向型とは言えない言語もあります (実は C 言語がそうです.C の言語仕様に出てくる「オブジェクト」はオブジェクト指向とは何の関係もありません).
C言語であれば,構造体と関数を使って particle.py
ver 6.0
のような形にコードを整理することはで可能ですし,実際,大規模なプログラムはこのようなスタイルで書く人が多いです.
関数型プログラミング (functional programming) はおおむね以下の点を特徴とするものです.
- 関数は数学的な意味での純粋な関数とする.つまり,引数が定まれば返り値が一意に定まるような関数だけを考える.
- 変数への再代入は許さない.
言い換えれば,関数も変数も,いわゆる普通の数学と同じ扱いをするということです (x = x + 1
などという代入は,もう慣れてしまって驚かないかも知れませんが,数学的には奇妙なものです).
非常に制限が強いように感じるかも知れませんが,さまざまなテクニックを駆使することで,他のプログラミング言語と同等の計算を実現できます.
関数型プログラミングが「同時に考える必要のある要素を減らす」のに効果的であるのは上記の特徴からよくわかると思います.関数の動作は引数以外の影響を受けることがありませんし,「この変数ってどこかで値が変わっちゃってないかな?」などと考える必要がありません.数学との親和性が高いことから,理論的な解析や検証にも向いています.
ソフトウェア全体を関数型プログラミングで構築するなら,関数型プログラミングをサポートする言語を使うべきですが,他のプログラミング言語を使う場合でも部分的に関数型の考え方を取り入れることは可能であり有益です (ちょうど,C 言語でもオブジェクト指向の考え方を部分的に取り入れられるように).例えば Python でも,リスト内包表記などは「関数型プログラミングっぽい」特徴を持つ機能です.
5.7. もう少し整理する¶
ここまでは,質点を表す Particle
クラスだけを作りました.同じように他のデータもまとめていくことを考えます.
5.7.1. World¶
現状のプログラムでは Particle
の move
メソッドに引数として
width
, height
, dt
, gy
を渡しています.言わば,メインプログラムが質点に「動いてください」と依頼する際に,「ただし世界の大きさは width
× height
で,時間ステップは
dt
で,重力加速度は gy
だとします」と毎回伝えていることになります.
一方,運動法則に従う責務を任せるのであれば,質点オブジェクトがこの世界に生成された時点でこれらの情報を教えてしまい,あとはそれぞれの質点の自己責任でこれらの情報を参照するべきだと考えることもできます.
ちょっと大仰な名前ですが,World
というクラスを作り,これら 4 つの変数を World
型オブジェクトの属性であると考えてみることにします.
1import random
2import pygame
3
4
5class World:
6 def __init__(self, width, height, dt, gy):
7 self.width = width
8 self.height = height
9 self.dt = dt
10 self.gy = gy
11
12
13class Particle:
14 def __init__(self, pos, vel, world):
15 self.is_alive = True
16 self.x, self.y = pos
17 self.vx, self.vy = vel
18 self.world = world
19
20 def update(self):
21 self.vy += self.world.gy * self.world.dt
22 self.x += self.vx * self.world.dt
23 self.y += self.vy * self.world.dt
24 if self.x < 0 or self.x > self.world.width or self.y > self.world.height:
25 self.is_alive = False
26
27 def draw(self, screen):
28 radius = 10
29 pygame.draw.circle(screen, pygame.Color("green"), (self.x, self.y), radius)
30
31
32def main():
33 pygame.init()
34 width, height = 600, 400
35 screen = pygame.display.set_mode((width, height))
36 clock = pygame.time.Clock()
37
38 world = World(width, height, dt=1.0, gy=0.5)
39 particle_list = []
40
41 while True:
42 frames_per_second = 60
43 clock.tick(frames_per_second)
44
45 should_quit = False
46 for event in pygame.event.get():
47 if event.type == pygame.QUIT:
48 should_quit = True
49 elif event.type == pygame.KEYDOWN:
50 if event.key == pygame.K_ESCAPE:
51 should_quit = True
52 elif event.type == pygame.MOUSEBUTTONDOWN:
53 if event.button == 1:
54 vx = random.uniform(-10, 10)
55 vy = random.uniform(-10, 0)
56 p = Particle(event.pos, (vx, vy), world)
57 particle_list.append(p)
58 if should_quit:
59 break
60
61 for p in particle_list:
62 p.update()
63
64 particle_list[:] = [p for p in particle_list if p.is_alive]
65
66 screen.fill(pygame.Color("black"))
67 for p in particle_list:
68 p.draw(screen)
69 pygame.display.update()
70
71 pygame.quit()
72
73
74if __name__ == "__main__":
75 main()
- 5-10行目
クラス
World
を定義します.メソッドは__init__
だけです.self
のほかに 4 つの引数を受け取って自身 (self
) の属性としてセットします.Particle
クラスでもそうでしたが,一般に__init__
の主な仕事は属性の値を初期化することです.- 38行目
関数
main
の最初の方でWorld
型のオブジェクトを生成して,変数world
に入れておきます.Python では大文字・小文字は区別されます.先頭が大文字の
World
をクラス名,全部小文字のworld
を変数名に使っています.- 14-18行目と 56行目
main
関数内でParticle
型オブジェクトを生成するときに引数としてworld
を渡しています.Particle
の__init__
の定義も修正していて,渡されたworld
を自身の属性として保持しています.ここで保持されているのが
World
型オブジェクトへの参照だという点に注意してください.World
型オブジェクトはこのシミュレーション世界にただひとつ存在し,それぞれの質点が持つself.world
という荷札からの紐がつながっている状態になります.それぞれの質点がコピーを持っているわけではありません.- 20-24行目と 62行目
Particle
のupdate
メソッドにはもはやself
以外の引数は不要です.計算に必要な値はself.world
の属性から読み出して使います.
残念ながら Online Python Tutor では pygame は使えませんが,簡略化したものを作ってみればよいです.構造を理解するには十分です:
class World:
def __init__(self, width, height, dt, gy):
self.width = width
self.height = height
self.dt = dt
self.gy = gy
class Particle:
def __init__(self, pos, vel, world):
self.is_alive = True
self.x, self.y = pos
self.vx, self.vy = vel
self.world = world
def main():
w = World(600, 400, 1, 2)
p1 = Particle((100, 120), (0, 0), w)
p2 = Particle((200, 220), (0, 0), w)
main()
Python では,データだけでなく関数もモジュールもオブジェクトとして扱われるという話を (以前の Q&A で) したことがあります.同様に,クラスもオブジェクトとして扱われます.
この「オブジェクトとして扱われる」ということの意味がピンとこないかもしれませんが,例えば,変数に代入したり,関数やメソッドに引数として渡したりできるというくらいの意味で理解しておいてもらえればよいです.これは便利な点で,後の章でも利用することがあります.
そんなわけで,Online Python Tutor ではクラスも関数もモジュールもすべて Objects のところに並ぶことになるのですが,あまり気にせずに,各種インスタンスやデータの配置だけに注目する方がわかりやすいと思います.
World
型オブジェクトみたいなのを作ってすべての
Particle
型オブジェクトから共有したら,グローバル変数を作ってしまうのとほとんど同じじゃないですか?例えば,複数の世界 (重力加速度の違う2つの環境とか) をシミュレートして比べたい場合に,World
のインスタンスを複数作って,それぞれの世界の計算を並行して進めながら可視化したりすることができます.もし height
, width
, dt
, gy
がグローバル変数だったなら,そういうことはできません.
一方,World
のインスタンスが他のほとんどすべてのオブジェクトから共有されていることで,グローバル変数と同様のデメリットがあるのも確かです.なので,World
に持たせる属性は最小限にするべきで,かつ読み取り専用とするのが望ましいといえます.実際,そのような設計にしています.
5.7.2. AppMain¶
メインプログラム側もクラスにしてしまう例を示します.ここでは
AppMain
という名前にしましょう (Application Main のつもり).この運動学シミュレーションアプリケーション自身を表すオブジェクトを作ることにして,その属性と操作を定めようということです.
32class AppMain:
33 def run(self):
34 pygame.init()
35 width, height = 600, 400
36 screen = pygame.display.set_mode((width, height))
37 clock = pygame.time.Clock()
38
39 world = World(width, height, dt=1.0, gy=0.5)
40 particle_list = []
41
42 while True:
43 frames_per_second = 60
44 clock.tick(frames_per_second)
45
46 should_quit = False
47 for event in pygame.event.get():
48 if event.type == pygame.QUIT:
49 should_quit = True
50 elif event.type == pygame.KEYDOWN:
51 if event.key == pygame.K_ESCAPE:
52 should_quit = True
53 elif event.type == pygame.MOUSEBUTTONDOWN:
54 if event.button == 1:
55 vx = random.uniform(-10, 10)
56 vy = random.uniform(-10, 0)
57 p = Particle(event.pos, (vx, vy), world)
58 particle_list.append(p)
59 if should_quit:
60 break
61
62 for p in particle_list:
63 p.update()
64
65 particle_list[:] = [p for p in particle_list if p.is_alive]
66
67 screen.fill(pygame.Color("black"))
68 for p in particle_list:
69 p.draw(screen)
70 pygame.display.update()
71
72 pygame.quit()
73
74
75if __name__ == "__main__":
76 app = AppMain()
77 app.run()
- 32-72行目
クラス
AppMain
を定義し,これまでmain
関数だったものをそっくりrun
メソッドにします.内容はコピーしただけです.こうやって普通の関数をメソッドに変える場合,メソッドは普通の関数とは違って第1引数に必ず
self
を入れなくてはならない点に注意してください.(慣れないうちは忘れやすいです.また,他の言語に慣れている人も忘れやすいです).- 76-77行目
- 関数
main
を呼んでいたところで,AppMain
型オブジェクトを生成して変数app
に割り当て,そのメソッドrun
を呼ぶことにします.
ここまで書いて動作確認できたら,引き続いて run
の中の処理の一部を,初期化,状態更新,描画などなどのメソッドに切り分けていきます.
普通の関数の場合,ローカル変数の値を受け渡すために,引数や返り値を設ける必要がありました (hello_pygame.py
で関数に切り分けたときのことを思い出してください).一方,メソッドに切り分ける場合は, self
の,つまりオブジェクト自身の属性にしてしまってメソッド間で共有するという手もあります.
update
, draw
, add_particle
をメソッドとして切り出すことを考えると,これらと run
の間で, particle_list
, screen
, world
,
pos
, button
をどうやって受け渡すかを考える必要があります.
ここでは以下のように考えます.これは一つの設計例に過ぎません.(唯一の正解などというものはないのです)
particle_list
,screen
,world
はこのシミュレーションアプリケーションが動いている間保持され続けるべきものなのでself
の属性にするpos
,button
はマウスボタンが押されたときに発生する一時的な値なので,引数として受け渡すrun
メソッドの中だけで使うものはローカル変数として残す
self
の属性の初期化はすべて __init__
にまとめておくことにすると,結局以下のように整理されます.
32class AppMain:
33 def __init__(self):
34 pygame.init()
35 width, height = 600, 400
36 self.screen = pygame.display.set_mode((width, height))
37 self.world = World(width, height, dt=1.0, gy=0.5)
38 self.particle_list = []
39
40 def update(self):
41 for p in self.particle_list:
42 p.update()
43 self.particle_list[:] = [p for p in self.particle_list if p.is_alive]
44
45 def draw(self):
46 self.screen.fill(pygame.Color("black"))
47 for p in self.particle_list:
48 p.draw(self.screen)
49 pygame.display.update()
50
51 def add_particle(self, pos, button):
52 if button == 1:
53 vx = random.uniform(-10, 10)
54 vy = random.uniform(-10, 0)
55 p = Particle(pos, (vx, vy), self.world)
56 self.particle_list.append(p)
57
58 def run(self):
59 clock = pygame.time.Clock()
60
61 while True:
62 frames_per_second = 60
63 clock.tick(frames_per_second)
64
65 should_quit = False
66 for event in pygame.event.get():
67 if event.type == pygame.QUIT:
68 should_quit = True
69 elif event.type == pygame.KEYDOWN:
70 if event.key == pygame.K_ESCAPE:
71 should_quit = True
72 elif event.type == pygame.MOUSEBUTTONDOWN:
73 self.add_particle(event.pos, event.button)
74 if should_quit:
75 break
76
77 self.update()
78 self.draw()
79
80 pygame.quit()
81
82
83if __name__ == "__main__":
84 app = AppMain()
85 app.run()
ここでは (テキストがあまり長くなるのを避けるため) 4 つのメソッドを一気に切り出しましたが,普通は 1 つずつ考えます.その都度,関連する変数をどう扱うかを考えます.そうやって順次やっていくうちに「ああ,さっき引数にしたアレは,やっぱり self
の属性にするべきだったな」と考え直して修正する場合などももちろんあります.
こういう作業は本質的に「設計」なので,唯一の正解があるわけではありません.particle.py
の ver 9.0 のように大きな
run
メソッド 1 個だけのままでも,別に間違いというわけではないです.逆に,ver 10.0 が理想形というわけでもありません.実際,例えばイベント処理の部分は別のメソッドか,さらには別のクラスに分けるのが普通の設計だと思います.できる範囲で考えてみるのがいいのではないでしょうか.
AppMain
の中の変数を,run
のローカル変数とするのと,
AppMain
型オブジェクトの属性にするのとで,具体的にどういうメリットとデメリットがあるのですか?属性変数としてメソッド間で共有すると,
- 引数として渡さなくてよいので呼び出し側のコードが簡潔
- 受け渡すべきものが増えたり減ったりしたときの対応が楽
というメリットがあります.一方で,
- 同時に把握しておかなくてはならない要素が増える
というグローバル変数と同様のデメリットが生じます.
AppMain
みたいなクラスは必ず作る方がよいですか?
main
関数といくつかの補助関数のままでもよいように思うのですが.動詞を使わない場合の例として,返り値を表す名詞をそのまま使うことも多いです.Python の標準関数でいうと,len
(length を返す) や
math.sqrt
(square root を返す) などがそうです.
返り値が真偽値型の場合は,動詞の三人称単数現在形から始める命名もよく使われます.例えば文字列型のメソッド isalpha
(その文字列のすべての文字がアルファベットなら True
を返す),
startswith
(その文字列が引数として渡された文字列で始まるなら
True
を返す) などです.
コツとしては,その関数やメソッドを呼び出すコードが英語っぽく読めるにはどういう名前にするとよいだろうか,と考えるとうまい命名につながることが多いです.例えば三単現を使うのは,if 文の条件などで使うときに読みやすくなるからだと考えるとよくわかると思います:
if password.isalpha():
print("Password should include non-alphabet letters.")
5.8. 演習¶
以下のように Particle の色や半径も属性として持たせるように修正します.
__init__
の引数 radius
と color
はデフォルト値つきとして,これまで通りに使うこともできるようにしています.
13class Particle:
14 def __init__(self, pos, vel, world, radius=10.0, color="green"):
15 self.is_alive = True
16 self.x, self.y = pos
17 self.vx, self.vy = vel
18 self.world = world
19 self.radius = radius
20 self.color = pygame.Color(color)
21
22 def update(self):
23 self.vy += self.world.gy * self.world.dt
24 self.x += self.vx * self.world.dt
25 self.y += self.vy * self.world.dt
26 if self.x < 0 or self.x > self.world.width or self.y > self.world.height:
27 self.is_alive = False
28
29 def draw(self, screen):
30 pygame.draw.circle(screen, self.color, (self.x, self.y), self.radius)
31
ここまで修正したら,いったんコミットしておいてください.次の章では,この時点から再開します.
例題5-1
質点の色や半径も乱数で生成するように AppMain
側を修正してください.
(色の一覧は適当なリストとしてあらかじめ用意しておいて結構です)
例題5-2
現在位置から複数個の ((異なる初速度の) 質点を投てきするメソッド
add_multiple_particles
を AppMain
に追加し,マウスの右ボタンのクリック1回で複数の質点を投てきできるようにしてください.
例題5-3
質点の初速度がランダムではなくユーザの操作によって決まるようにすることを考えます.
AppMain
を修正し,マウス左ボタンを押下したときにその位置が記憶され,マウス左ボタンを離したとき (MOUSEBUTTONUP
イベント) に質点が投てきされるようにしてください.その際,押下時に記憶した位置と現在位置との相対関係から,初速度が直感的に与えられるようにしてください.
(操作性を上げるために表示方法を工夫するのも面白いです)
例題5-4
直方体を表すクラスを定義し,体積,総表面積,対角長を計算するメソッド
volume
, surface_area
, diagonal
を設けることを考えます.
以下のプログラムの pass
の部分を書き換えて,プログラムの実行が成功するようにしてください:
import math
class Cuboid:
pass
shape1 = Cuboid(width=6, height=4, depth=5)
assert shape1.volume() == 120
assert shape1.surface_area() == 148
assert shape1.diagonal() == math.sqrt(77)
shape2 = Cuboid(width=3, height=3, depth=2)
assert shape2.volume() == 18
assert shape2.surface_area() == 42
assert shape2.diagonal() == math.sqrt(22)