4. 基本的なプログラミング (データ構造編)

前の章の続きで,主にリスト等のデータ構造を見ていきます.

既習の人はやはり実践編と演習を中心に見ていくのでもよいかも知れません.ただし,「Python のデータモデル」の節で紹介する Online Python Tutor をもし使ったことがなければぜひ試すようにしてください.Python に慣れている人にとっても大変有用です.

4.1. リスト (list型)

4.1.1. シーケンス

何らかのデータが直列に並んでいて,インデックス番号によって要素にアクセスできるデータ構造をシーケンスと呼びます.そのうち最も重要なものがリストです:

x = [10, 20, 30, 40, 50]      # リストの定義
len(x)                        # 5 (リスト長)
x[0]                          # 10 (最初の要素のインデックスは 0)
x[-1]                         # [50]  (最後の要素)
x[-3]                         # [30]
20 in x                       # True (要素として含むとき True)
99 not in x                   # True
[10, 20, 30] == [10, 20, 30]  # True (すべての要素が等いとき True)

連続するインデックスによって部分列にアクセスできます.スライスと呼びます:

x[2:4]                        # [30, 40] (注: x[4] は含まれない)
x[2:]                         # [30, 40, 50]
x[:2]                         # [10, 20]

連結演算子と繰り返し演算子が使えます:

[10, 20, 30] + [40, 50]  # [10, 20, 30, 40, 50]
[10, 20] * 3             # [10, 20, 10, 20, 10, 20]
どうしてスライスは x[2:4] と書いたとき x[4] を含まないんですか? わかりにくくないですか? そもそもどうしてインデックスは 0 から始めるんですか? 数学のベクトルや行列みたいに 1 から始めればいいのに.
プログラミング言語によっていろいろな慣習があるので,Python はそういうものだと思って慣れるしかありません.

数学のベクトルや行列にならうなら 1 から始めて最後のインデックスを含む方がよいというのはその通りだと思います.Fortran や MATLAB などは数学記法との類似を重視するのでこの流儀です.

一方で,コンピュータのメモリの中のデータの場所をリストの先頭位置 + インデックスで表そうと考えると,先頭要素のインデックスは 0 にする方が自然です.Python だけでなく,C や C++,Java などこちらの流儀を採用する言語も多いです.

最後のインデックスに扱いについても考え方はいろいろです.「含めない派」がよく理由として挙げるのは以下のようなものです.

  • x[n:n+k] と書いたときに要素数が k 個だとわかりやすい

  • N 番目より前と N 番目以降を分けるときに x[:N]x[N:] と書けて便利

  • 例えば C 言語の文字列のように,データの個数が未知で,目印となるような特別な要素 (番兵, sentinel) が現れたら終わりとみなす方式との整合性がよい (番兵はデータに含めない):

    for (i = 0; str[i] != '\0'; i++) {
        putchar(str[i]);
    }
    

4.1.2. リストのいろいろな作成方法

range 関数により連番を作ることができます:

range(5)              # range型オブジェクト (これ自体はリストではない)
list(range(5))        # [0, 1, 2, 3, 4]  (0 から 5 未満)
list(range(2, 6))     # [2, 3, 4, 5]     (2 から 6 未満)
list(range(2, 10, 3)) # [2, 5, 8]        (2 から 10 未満,ステップ 3)

既存のリストの各要素を加工したリストを作るには,リスト内包記法が便利です:

x = [1, 2, 15, 30]
[2 * a for a in x]                # [2, 4, 30, 60] (x の各要素 a について 2 * a を取る)
[2 * a for a in x if a % 2 == 0]  # [4, 60]
[2 ** n for n in range(5)]        # [1, 2, 4, 8, 16]
range に引数を3つ渡すと,どれが最初でどれが最後でどれが増分だか一見わかりにくくないですか?
同感です.こういうときこそ説明的な変数の出番かも知れません.
step = 3
for x in range(2, 10, step):
    print(x)

4.1.3. リストの変更

リストは変更可能 (mutable) です:

x[2] = 12345             # 要素 30 を 12345 に変更する
x[2:4] = [-20, -30]      # スライスの変更

リストを伸縮させるときの基本操作は,末尾への要素追加と,末尾からの要素削除です:

x = []
x.append(10)             # x: [10]
x.append(20)             # x: [10, 20]
item = x.pop()           # x: [10], item: 20

任意位置への要素挿入,任意位置の要素削除もできますが,末尾の追加・削除より効率が悪いです:

x = [0, 10, 20, 30]
x.insert(2, 12345)     # x: [0, 10, 12345, 20, 30]
item = x.pop(1)        # x: [0, 12345, 20, 30], item: 10
del x[1]               # x: [0, 12345, 30]  (値を返す必要がない場合)
del x[:]               # x: [] (スライスの削除)
x.clear()              # x: [] (del x[:] と同じ意味.少し速い)
x.remove(12345)        # x: [0, 30] (値を指定して削除)
Python の変数は,あらかじめ定義しておく必要はないと思っていましたが, append メソッドを呼ぶ前に x = [] と定義しておく必要があるのはなぜですか?
x の内容がリスト型のオブジェクトにならないと append メソッドが使えないからです.

なので,例えば以下のように書くならもちろん x = [] は不要です:

x = [10]
x.append(20)
item = x.pop()

4.1.4. 実践編

さて,ようやく hello_pygame.py に戻りましょう.これまではプレイヤキャラクターは静止画のまま上下左右に動くだけでしたが,歩行をしているかのようなアニメーションをつけてみます.

アニメーションというと難しく聞こえるかも知れませんが,これからやることは簡単で,ただのパラパラマンガです.

これまで表示してきた p1_walk01.png と同じところに,ちょっとずつ姿勢が違うプレイヤーの画像が置いてあります.p1_walk04.png から p1_walk07.png までを繰り返せば何となく歩いているように見えそうです.

というわけで,これらを読み込んでリストを作り,順番に繰り返し表示してみます.これまでは整数のリストの例ばかり扱ってきましたが,リストに入れられるものは整数型に限りません.ここでは Surface 型のリストを作ります.

hello_pygame.py (ver 15.0)
 1import pygame
 2
 3
 4def init_screen():
 5    pygame.init()
 6    width, height = 600, 400
 7    screen = pygame.display.set_mode((width, height))
 8    return screen
 9
10
11def create_text():
12    font_size = 50
13    font_file = None
14    antialias = True
15    font = pygame.font.Font(font_file, font_size)
16    text_image = font.render("hello, pygame", antialias, pygame.Color("green"))
17    return text_image
18
19
20def create_player():
21    player_images = [
22        pygame.image.load("../../assets/player/p1_walk04.png").convert(),
23        pygame.image.load("../../assets/player/p1_walk05.png").convert(),
24        pygame.image.load("../../assets/player/p1_walk06.png").convert(),
25        pygame.image.load("../../assets/player/p1_walk07.png").convert()
26    ]
27    return player_images
28
29
30def draw(screen, player_image, text_image, mouse_pos):
31    screen.fill(pygame.Color("black"))
32    screen.blit(player_image, mouse_pos)
33    mouse_x, mouse_y = mouse_pos
34    text_offset_x = 100
35    screen.blit(text_image, (mouse_x + text_offset_x, mouse_y))
36    pygame.display.update()
37
38
39def main():
40    screen = init_screen()
41    text_image = create_text()
42    player_images = create_player()
43    frame_index = 0
44
45    while True:
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.key == pygame.K_b:
54                    pass
55        if should_quit:
56            break
57        mouse_pos = pygame.mouse.get_pos()
58
59        frame_index += 1
60        animation_index = frame_index % len(player_images)
61        draw(screen, player_images[animation_index], text_image, mouse_pos)
62
63    pygame.quit()
64
65
66if __name__ == "__main__":
67    main()
20-27行目

プレイヤ画像を読み込んだ Surface のリストを作って返す関数 create_player を定義します.

リストの作り方が力技すぎて格好悪いですね.後でなんとかします.

42行目

関数 create_player を呼び出し,返ってきたリストを変数 player_images に代入しておきます.

関数 create_player と関数 main の中の変数 player_images はそれぞれの関数のローカル変数なので,返り値を通して受け渡してやる必要があるわけです.

43行目

時間の進行とともに表示するプレイヤ画像を変えるので,現在時間を保持しておく変数を用意します.

ディスプレイに表示される映像やビデオカメラで得られる動画などの各時刻の画像をフレーム (frame) と呼びます.ここではディスプレイに表示するフレームの番号 frame_index をカウントしておいて,それを時刻として扱うことにします.

59行目
フレームごとに frame_index に 1 を加え,カウントアップしていきます.
60-61行目

len(player_images)player_images の要素数が得られます.剰余演算子 % を使って,フレーム番号を要素数で割った余りを求めます.今は要素数が 4 なので,結果は 0, 1, 2, 3 のいずれかです.

これを player_images の要素を指定するインデックスにすることで,このリストの中の Surface を順に繰り返し表示されることになります.


さて,動かしてみると,足のバタバタが速すぎてどうも歩いているようには見えないのではないかと思います.ちょっと調節してみることにします.

現状のプログラムの問題は,そもそもどのくらいの速さでパラパラマンガをめくっているかわからないことです.使っているコンピュータが速ければフレームは速く進みますし,遅ければその分ゆっくりになります.まずこのフレームの更新速度 (フレームレート) を一定にしておきましょう.

hello_pygame.py (ver 16.0)
39def main():
40    screen = init_screen()
41    text_image = create_text()
42    player_images = create_player()
43    clock = pygame.time.Clock()
44    frame_index = 0
45
46    while True:
47        frames_per_second = 60
48        clock.tick(frames_per_second)
49
50        should_quit = False
51        for event in pygame.event.get():
52            if event.type == pygame.QUIT:
53                should_quit = True
54            elif event.type == pygame.KEYDOWN:
55                if event.key == pygame.K_ESCAPE:
56                    should_quit = True
57                elif event.key == pygame.K_b:
58                    pass
59        if should_quit:
60            break
61        mouse_pos = pygame.mouse.get_pos()
62
63        frame_index += 1
64        animation_period = 6
65        animation_index = (frame_index // animation_period) % len(player_images)
66        draw(screen, player_images[animation_index], text_image, mouse_pos)
67
68    pygame.quit()
69
70
71if __name__ == "__main__":
72    main()
43行目
pygame.time.Clock 関数が返す Clock 型のオブジェクトを変数 clock に入れておきます.これを時間管理に使います.
47-48行目

while ループの先頭で (実はどこでもよいのですが,ループ内の決まった 1箇所で),clock に属する tick メソッドを呼び出します.その際引数としてフレームレートを指定します.単位は frames/s (fps),つまり 1 秒間に何フレーム表示するかです.ここでは 60 frames/s を指定しています.現代のコンピュータ用ディスプレイの画面が更新される標準的なレートです.

Clock 型のオブジェクトは,前回 tick メソッドが呼ばれた時刻を覚える機能を持っています.ループの中で呼ばれると,前回の呼び出しからの経過時間を計算し,適切な待ち時間を入れます.今は 60 frames/s を指定しているので,前回からの経過時間が 16.66 ms 以上になるように調整します.

ここまでの変更で一度動かしてみると,やっぱりバタバタが速すぎるのではないかと思います.今どうなっているかというと,足の動きが 1 サイクルする 4 コマのアニメーションを 60 frames/s で再生しているので,15 cycles/s です.そりゃまだ速いわけですよね.

64-65行目
というわけでもう少し遅くします.整数除算演算子 // を使って frame_index を適当な数で割ってから使うことにします.ここでは経験的に 6 で割ることにしました.別の値に変えてみても構いません.
clock.tick の引数に 60 を指定すると,フレームレートはぴったり 60 frames/s になるのですか?
なりません.60 frames/s より速くはならないだけです.
clock.tick(frames_per_second) を入れる前より,入れた後の方が足のバタバタが速くなったように見えるのですが,何か間違っているのでしょうか.
その現象は起こり得ます.エイリアシングと呼ばれます.

ちょっと思考実験してみてください.1.0 秒間で 1 回転する円運動を, 0.9 秒ごとにシャッターを切るカメラで撮影します.撮影と同じ速さで動画として再生すると,逆向きのゆっくりな回転に見えるはずです.もっと極端な状況として,1.0 秒ごとにシャッターを切れば,静止して見えます.

同様に,動画内で自動車のタイヤホイールやヘリコプターのローターがゆっくり回転して見えるなんてことは多くの人が経験があるのではないかと思います.

速い動きを,その動きを十分捉えらないくらい遅い周期で離散時間化すると,本来の運動とは異なる周波数成分が現れます.より正確な説明が欲しい人は,サンプリング定理,エイリアシングなどのキーワードで調べてください.

4.2. リストと類似したデータ構造

4.2.1. タプル (tuple型)

タプルは変更不能 (immutable) なシーケンスです.長さを変えたり要素を変更することはできません.変更を伴わない操作はリストと同様にできます:

>>> tup = (10, 20, 30)
>>> tup[1] = 123
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
「変更不能なリスト」などというリストの下位互換なものに何の存在意義があるのでしょうか.全部リストにすればいいんじゃないですか?
もっともな疑問ですが,タプルには多くの存在意義があります.

一番わかりやすいけど一番つまらない理由としては,変更に対応する必要がない分だけ内部構造が最適化されているため,動作が速くメモリ使用量も少ないことが挙げられます.

それ以上に重要なのは,プログラムを読んでいるときに「これはタプルだから絶対に変更されない」と確信できることです.例えばある関数 f に引数としてリスト x が渡されているのを見かけたとしましょう:

x = [10, 20, 30]
f(x)

この呼び出しの結果,x の内容が変化するかどうかは,関数 f の動作を詳細に理解していないとわかりません.一方,もし x がタプルだったら,たとえ f が何をする関数なのかを知らなかったとしても,少なくとも x が変化することはないとして以降のコードを読み進めることができます.これにより,プログラムの読解が格段にしやすくなります.このあたりは,C 言語や C++ で「変更しないものには可能な限り const をつけなさい」と教わるのと同じ事情です.

別の意義として,この後で出てくる「辞書」のキーになれるという利点もあります.

あれ? 以下のようにするとタプルを「変更」できてしまったのですがどうしてですか?:

>>> tup = (10, 20, 30)
>>> tup = (40, 50, 60)
>>> tup
(40, 50, 60)
それは変更ではないんです.

それは,新しいタプル (40, 50, 60) を作って,変数 tup に割り当てただけです.元の (10, 20, 30) というタプルが変わったわけではありません.

この辺の事情は,4.4 節「Python のデータモデル」まで進んでもらうともう少し理解しやすくなります.

4.2.2. 文字列 (str型)

文字列も変更不能なシーケンスです.表記方法はいろいろあります:

"hello, world"
'hello, world'              # シングルクォートでもよい
"what's new"
'1" is equal to 25.4 mm'
'1h 23\' 45"'               # \ を使って文字の役割をエスケープできる
"hello\nworld"

文字列型には特有のメソッドが多数あります.以下はごく一部の例です:

"Computer Seminar I".split()                 # ['Computer', 'Seminar', 'I']
", ".join(["abc", "def", "ghi"])             # 'abc, def, ghi'
"2021-10-01".replace("-", "/")               # '2021/10/01'
"number {} and string {}".format(10, "abc")  # 'number 10 and string abc'

文字列型の format メソッドでは各種書式を指定できます.以下はごく一部の例です:

>>> "{1} {2}, {0}".format(2021, "October", 1)  # 引数指定
>>> "{:5}".format(123)                         # 5文字に満たなければ空白づめ
>>> "{:05}".format(123)                        # 5文字に満たなければ0づめ
>>> "{:.4f}".format(3.141592)                  # 小数点以下4桁に丸め
>>> "{:10.4f}".format(3.141592)                # 丸め + 空白づめ
>>> "{0} {0:5} {0:05}".format(123)             # 引数指定 + 書式
こういう膨大な数のメソッドをみんな覚えておかないといけないんですか?
いいえ,文字列型のメソッドに限らず,全部覚えているなんて人のほうが珍しいと思います.

それよりも,使いたいと思ったときにすぐにマニュアル・書籍その他で調べられるようにしておくこと (どこを調べればよいのか,どう読めばよいのかをわかっていること) が重要です.

文字列型のメソッドの公式ドキュメントは以下のページにあります.

「読みづらいなー」と思う人は自分に合うドキュメントを探してみてください.「チートシート」という名前で公開されているものには読みやすいものが多いです.

format メソッドの書式は C言語の printf で使う書式に似てますが,微妙に違うのがイラッとします.
C 言語に慣れている人は format メソッドより % 演算子の方がとっつきやすいかも知れません.

4.2.3. 辞書 (dict型)

辞書はシーケンスとは似て非なるデータ構造で,整数以外のものをインデックスの代わりに使えます:

score = {"Alice": 80, "Bob": 80, "Charlie": 80}  # 辞書の定義
score["Alice"]       # 80  (キー "Alice" に関連付けられた値)
score["Alice"] = 90  # 辞書は変更可能
score["Dave"] = 95   # 追加
del score["Charlie"] # 削除
"Alice" in score     # True (キーが存在するかどうか)
list(score)          # ['Alice', 'Bob', 'Dave'] (型変換でキーのリストが得られる)

4.2.4. 実践編

文字列処理を使って,プレイヤ画像のリストを作るところをもう少しスマートにしてみます.4 つのファイル名は,連番の数字のところが違うだけなので,文字列の format メソッドを使って生成することができます.

hello_pygame.py (ver 17.0)
20def create_player():
21    file_path = "../../assets/player/p1_walk{:02}.png"
22    player_images = []
23    for k in range(4, 8):
24        player_images.append(pygame.image.load(file_path.format(k)).convert())
25    return player_images

一見ややこしそうですが,k を 4, 5, 6, 7 と変えながら, file_path の中の {:02} の部分に 2 文字ゼロ詰め表記で入れることでファイル名文字列を生成しています.あとは 1 つずつ append していくだけです.

同じことを,リスト内包記法を使うと少しだけ簡潔に書けます.

hello_pygame.py (ver 18.0)
20def create_player():
21    file_path = "../../assets/player/p1_walk{:02}.png"
22    player_images = [pygame.image.load(file_path.format(k)).convert()
23                     for k in range(4, 8)]
24    return player_images
hello_pygame.py の ver 5.0 あたりから気になっていたのですが,C言語では,ファイルをオープンするときは必ずエラーチェックをするように習いました.pygame.image.load を使うときはエラーチェックは必要ないのですか?
エラー時に異常終了させたいだけなら何もしなくてよいです.自動的にそうなるからです.そうではなく,エラー発生時に他の処理に移りたい等の場合は,例外処理という仕組みを使います.

基本的なことだけ示します.まず,どのようなエラーが出るかを確認します.以前の Q&A で示しましたが,例えばファイルがなかった場合 pygame.image.load は:

FileNotFoundError: No such file or directory.

というエラーメッセージを出力します.この最初の FileNotFoundError は発生したエラーの種類を表すもので,これを覚えておきます.

FileNotFoundError が起きたときに他の処理をしたい場合は,以下のような書き方をします:

def create_player():
    try:
        file_path = "../../assets/player/p1_walk{:02}.png"
        player_images = [pygame.image.load(file_path.format(k)).convert()
                         for k in range(4, 8)]
        return player_images
    except FileNotFoundError:
        player = pygame.Surface((64, 64))
        player.fill(pygame.Color("red"))
        return [player]

ファイルがある限りは今まで通りの動作をするはずです.ファイルがなかった場合 (例えば,上のコードのファイル名のうち png の部分をわざと間違えて jpg と書いてみてください),プレイヤ画像の代わりに 64×64 画素の赤い四角形が表示されると思います.

try 節には通常の処理を普通に書きます.エラーが起きなければ except 節はスキップされます.try 節の実行中にエラーが発生した場合,直ちに except の処理に移ります,except の後ろで指定されたエラー (この場合 FileNotFoundError) だった場合は,except 節の内容が実行されます.指定されていないエラーの場合は異常終了します.

FileNotFoundError のようなエラーの種類を表すオブジェクトを Python では例外 (exception) と呼びます.try 文により例外を捕捉し,異なる扱いをさせることを例外処理 (exeption handling) といいます.

この方式の便利なところは,関数呼び出しをまたいで例外を処理できる点です.例えば今の例では,FileNotFoundError 例外が発生したのは pygame.image.load 関数の内部です.それを,特に pygame.image.load の返り値をチェックしたりすることなく,呼び出し元の create_player 側で捕捉できたことになります.何なら create_player は元のままとした上で,もう 1 レベル上の main 関数で捕捉させることもできます:

def main():
    screen = init_screen()
    text_image = create_text()
    try:
        player_images = create_player()
    except FileNotFoundError:
        player = pygame.Surface((64, 64))
        player.fill(pygame.Color("red"))
        player_images = [player]
    clock = pygame.time.Clock()
    frame_index = 0

例外を発生させたいときは raise 文を使います.詳細はドキュメントを参照してください.

4.3. 演習

例題4-1

対話的実行で以下のようなリストの操作を行ってください.実行前に結果 (「???」のところ) を予想して,実際の結果と一致するか確認してください.予想が間違っていたら理由を考えて,正しい理解をするようにしてください.

>>> x = [10, 20, 30, 40, 50]
>>> x[3] = 99
>>> x
???
>>> x = [10, 20, 30, 40, 50]
>>> x[1:4]
???
>>> x = [10, 20, 30, 40, 50]
>>> x[:] = []
>>> x
???
>>> x = [10, 20, 30, 40, 50]
>>> x.append(99)
???
>>> list(range(2, 7))
???
>>> list(range(7, 2, -1))
???
>>> "/".join([c for c in ("red", "green", "blue") if len(c) >= 4])
???

4.4. Python のデータモデル

4.4.1. 変数は箱ではない

リストのように変更可能なデータ構造を扱い始めると,一見不可解な現象に出会うことがあります.例えば:

>>> x = [10, 20, 30]
>>> y = x
>>> y[0] = 123
>>> y
[123, 20, 30]
>>> x
[123, 20, 30]

リスト y を変更したら,x も変わってしまいました.何が起きたか想像できるでしょうか.

鍵になるのは2行目の y = x です.ここでリスト x の内容である [10, 20, 30] がそっくりコピーされて y に代入される様子をイメージした人も多いのではないかと思います.しかし実際に起きることは違います.

この点をしっかり理解するためには,これまで「変数」と呼んできたもののイメージを刷新する必要があります.「変数 x 」というと,何やら x という名前の書かれた箱があり,その中にデータが保存される様子を多くの人が想像するのではないかと思います.しかし Python の変数はそういうものではありません.データには名前はついておらず.名前の書かれた荷札が別の場所にあります.その荷札からデータまで紐が延びています.

_images/box_or_tag.png

変数とはこの荷札のことであり,代入とは荷札とデータを紐で結ぶことを意味します.

このように考えると,先ほどの例の動作は正しく理解できます. x = [10, 20, 30] により,[10, 20, 30] というデータが作成され, x と書かれた荷札が作られて,両者は紐で結ばれます.次に y = x が実行されると,y と書かれた新しい荷札が作られ,x からの紐が延びている先の [10, 20, 30] と紐で結ばれます.同じデータに 2 つの荷札がついた状態になります.y[0] = 123 を実行すると,荷札 y から紐をたどってリストのデータにたどり着き,その最初の要素を 123 に変更します.

4.4.2. Online Python Tutor

このような正しいイメージを掴むために有用なウェブサイトを紹介します.

「Start visualizing your code now」をクリックすると,コードを入力できるページに進みます.入力欄の下にあるドロップダウンリストにてすべて [default] と書かれたものが選択されていることを確認してください.さっきのコード例を入力して,「Visualize Execution」をクリックしてください:

x = [10, 20, 30]
y = x
y[0] = 123

以下のような表示が得られると思います.「Next」ボタンをクリックすると,プログラムが1行ずつ実行され,各時点での「荷札とデータ」の様子が右側に図示されます. (以下の図も動きます)

4.4.3. リスト以外も同じこと

今見てもらったような変数とデータの関係はリストに限りません.整数も実数も,あるいは pygame の SurfaceColor も,すべて同じ「荷札と紐のモデル」で動作します.

「えっ? そんな馬鹿な.さすがに整数や実数は違うでしょ?」と思った人も多いかと思います.試しにさっきと同じようなものをリストではなく整数で考えてみましょう:

>>> x = 10
>>> y = x
>>> y = 123
>>> y
123
>>> x
10

今度は y を書き換えても x は変わりません.「やっぱり違うじゃないか.この場合は,x という箱と y という箱があって,その中に 10 とか 123 とかのデータが入っているんでしょ?」と思うかも知れません.

しかしそうではないのです.実際に起きていることは以下のように説明できます.1行目の x = 10 で整数データ 10 が作成され,荷札 x が作られて,両者が紐で結ばれます.2行目の y = x で荷札 y が作られて, x の繋がり先である 10 と紐で結ばれます.この時点で xy は同じデータを共有しています.

重要なのは次です.y = 123 を実行すると,整数 123 が作られ,荷札 y から延びている紐は 10 から切り離され,123 と結ばれます.決して 10 が 123 で上書きされるわけではありません.

_images/box_or_tag_reassign.png

このような仕組みのため,整数の場合は「箱のモデル」で考えた場合と結果的に同じにことになります.そのため,これまで「荷札のモデル」を意識せずに話を進めてこれたのでした.

Online Python Tutor でも,デフォルトの設定では,整数や実数などについては「荷札のモデル」で可視化するのを省略しています.省略せずに可視化するには,コード入力欄の下のドロップダウンリストで「inline primitives」の代わりに「render all objects on the heap」を選択してください:

x = 10
y = x
y = 123

を入力して Visualize Code すると,先ほどの説明のように「紐が切り離される」のがわかると思います.一つ前のリストの場合のコード例も試してみてください.リストの要素として直列に並んでいるのも「荷札」であり,整数データへの紐が延びているのを確認できます.

結局,リストと整数の挙動の違いをもたらしたのは,リストは要素を変更可能なのに対して,整数は変更不能である (というか,変更できる「要素」に相当するものが存在しない) という点に尽きます.実際にはすべてが「荷札モデル」で動きますが,変更不能なものは「箱モデル」で考えても矛盾しません.

「A から延びた紐が B に繋がっている」というのはもちろん正確な用語ではありません.より正しい表現は「A は B への参照 (reference) を保持している」「A は B を参照している (A references B, A refers to B)」「A が B を指している (A points to B)」などです.

「Python の変数には型がない」の意味がようやくわかった気がします.荷札だからどんな型のデータにでも繋げるし,同じ荷札を別の型のデータに繋ぎ直してもよい.そういう理解で合ってますか?
はい,でも混乱するので,同じ変数を違う型のデータに使いまわすのは避ける方がよいです.
その「参照」というのは具体的にはどういう仕組みなのですか? 実際にコンピュータの中で紐みたいなものが延びているわけじゃないですよね.
参照先のデータが置かれた場所を表す値 (メモリアドレス) を保持しています.

コンピュータの内部では,メモリ内の特定の場所にデータを書き込んだりそこから読み出したりする場合に,アドレスと呼ばれる数値を使って場所を指定します.x = 10 を実行すると,数値 10 がメモリのどこかに書き込まれ,変数 x はそのアドレスを保持します.

ある変数が保持しているアドレスを知るには id という関数を使うことができます:

>>> x = 10
>>> id(x)
2528242330192
>>> y = x
>>> id(y)
2528242330192
>>> y = 123
>>> id(y)
2528242522288

id が返す具体的な数値にはあまり意味がありません.ここでは,1 回目の id(y)id(x) と同じ値であること,2回目の id(y) は値が変わっていることにだけ注意してください.

参照ってのは,C 言語でいうポインタのことですよね? Python にはポインタは無いから簡単だと聞いていたのですが,話が違うじゃないですか.
はい,ポインタのことだと理解して差し支えありません.Python にはポインタが無いのではありません.すべての変数がポインタだから,わざわざ「これはポインタだ」という必要がないだけです.

もう一つポインタと大きく違うのは,実行できる操作が厳しく制限されていることです.例えば C ではポインタに整数を加減算したりしてアドレス計算をすることができますが Python ではできません.このように制限することで,C のポインタより安全に扱えるようになっています.

4.4.4. is と == (is not と !=)

以上のように理解すると,2 つのデータが互いに「等しい」というときに,異なる 2 種類の意味があることがわかります.

ほとんどの場合に重要なのは,それらが「同じ値を持っているかどうか」 (等価性, equality) です.演算子 ==!= で判定するのはこれです.例えば:

>>> x = [10, 20, 30]
>>> y = [10, 20, 30]
>>> x == y
True

となります.しかしこのとき xy が指しているデータの実体は別物です.例えばこの直後に y[0] = 99 としても x[0] は変わりません.別物だからです.

この「実体が同じものであるかどうか」(同一性, identity) を判定するときには,演算子 isis not を使います.先ほどの xy は等価ですが同一ではありません:

>>> x is y
False
>>> x is not y
True

となります.一方,以下のように yx と同じものを指すようにすると同一になります:

>>> y = x
>>> x is y
True

C言語に慣れている人は,ポインタの比較に相当するのが isis not だと考えるのがわかりやすいかもしれません.

4.4.5. 実践編

「hello, world」の文字列を,マウスの左ボタンが押されている間だけ表示するようにします.

hello_pygame.py (ver 19.0)
27def draw(screen, player_image, text_image, mouse_pos):
28    screen.fill(pygame.Color("black"))
29    screen.blit(player_image, mouse_pos)
30    if text_image is not None:
31        mouse_x, mouse_y = mouse_pos
32        text_offset_x = 100
33        screen.blit(text_image, (mouse_x + text_offset_x, mouse_y))
34    pygame.display.update()
44    while True:
45        frames_per_second = 60
46        clock.tick(frames_per_second)
47
48        should_quit = False
49        for event in pygame.event.get():
50            if event.type == pygame.QUIT:
51                should_quit = True
52            elif event.type == pygame.KEYDOWN:
53                if event.key == pygame.K_ESCAPE:
54                    should_quit = True
55                elif event.key == pygame.K_b:
56                    pass
57        if should_quit:
58            break
59        mouse_pos = pygame.mouse.get_pos()
60        buttons_pressed = pygame.mouse.get_pressed()
61        if buttons_pressed[0]:
62            text_image_shown = text_image
63        else:
64            text_image_shown = None
65
66        frame_index += 1
67        animation_period = 6
68        animation_index = (frame_index // animation_period) % len(player_images)
69        draw(screen, player_images[animation_index], text_image_shown, mouse_pos)
27-34行目

draw 関数を修正して,引数 text_imageNone だったら表示しないようにします.

None との比較には == ではなく is を使います.None は,存在し得る他のデータとは絶対に同一にならないと定められているオブジェクトです.同一かどうかの判定なので is を使います.

60-64行目,69行目

マウス左ボタンが押されているなら,変数 text_image_showntext_image が指す Surface 型オブジェクトを,押されていないなら None を割り当てます.draw の呼び出し時には第3引数としてこれを渡します.

関数 pygame.mouse.get_pressed は,pygame.key.get_pressed と同じような要領で,各マウスボタンが押されているかどうかの真偽値をシーケンスとして得ます.左ボタンのインデックスは 0 です.

4.4.6. 関数呼び出しと変数の関係

関数を呼び出す場合の引数,返り値,ローカル変数の挙動を正しく理解しておくことは重要です.以下のコードを Online Python Tutor で「render all objects」を指定して実行してみてください:

def func(x, a):
    x[0] = 123
    a = 456
    y = [99, 88, 77]
    return y

def main():
    xm = [10, 20, 30]
    am = 1
    ym = func(xm, am)
    return

main()

いくつか重要なことが理解できると思います.

  • 関数が呼び出されると,それぞれの関数用にフレーム (frame) (映像のフレームとは別の用語) と呼ばれるものが作られて,引数やローカル変数はその中に置かれます.フレーム内に置かれるのは「荷札」だけです.実際のデータは決して置かれません.
  • 引数や返り値の受け渡しは,代入文と同様に行われます.つまり,送り元の変数が指しているデータを,送り先の変数からも指して共有します.
  • 関数の実行中,その関数のフレームは保持されます.そこから他の関数を呼び出している間も保持されます (例えば main から func を呼んでいる間も main フレームは存在しています).しかし,関数からリターンするとその関数のフレームは削除され,その中にあった変数も削除されます.
  • 変数が削除されても,それが指しているデータが削除されるわけではありません.

最後の項目は注意が必要です.例えば上の例で func からリターンする前後の挙動に注目してください.変数 y が指しているリスト [99, 88, 77] は関数 func の中で作られたものです.func からリターンすると変数 y という「荷札」は消えますが,それが指している [99, 88, 77] まで消えるわけではありません.このリストは変数 ym が指すようになり, main 関数の中で利用できます (この例では何の利用もせずに終わりますが).

では,一度作成されたデータはずっと残るのかというとそうではなく,現在残っているどの変数からも参照されなくなったものは,自動的に削除されます.例えば a が指していた整数 456 は, func からリターンすると削除されます.

データの削除が起きるのは関数からのリターン時に限りません.例えば:

x = 100
x = 200

のような2行を実行すると,1行目で作られた整数データ 100 は,2行目を実行した時点でどこからも参照されなくなるので削除されます.

何だか C 言語とはだいぶ違うような気がするのですが.
だいぶ違います.C 言語の知識がある人は比較してみると理解が深まります.

Online Python Tutor のトップページ https://pythontutor.com/ で, Related services のところの C Tutor を開いてください.C のコードの実行状況を同様に可視化できます.

さっきの Python コードに相当するものを C で書くと以下のようになると思います.これを C Tutor で可視化してみてください:

int *func(int *x, int a) {
    x[0] = 123;
    a = 456;
    int y[3] = {99, 88, 77};
    return y;
}

int main() {
    int xm[3] = {10, 20, 30};
    int am = 1;
    int *ym = func(xm, am);
    return 0;
}

すぐに気づくのは,整数も配列も,可視化画面の左側の「フレーム」内に置かれるということです.右側の領域に矢印が延びることはありません.値を再代入すると,フレーム内に置かれた変数の中身が直接書き換わります.つまり,C の場合これらの変数は「箱モデル」で動いているわけです.

関数 func の実行中は,func のフレーム内に 3 要素の配列 y が保持されます.return y; によってその先頭アドレスが main に返されるのですが,その時点で func のフレームは破棄されるので,main 内の ym は何も指しません (選択したコンパイラのバージョンによって,NULL だったりゴミだったりするはずです).もし main 側でこの後 ym[0] = 999; などとしようものなら,実行時エラーが発生します.

これが「C 言語では関数内で定義したローカル配列をリターンするな」と教わる理由です.

では,Python ではなぜリストをリターンしても大丈夫なのかというと,リスト (に限らずあらゆるデータ) は「右側の領域」に確保されており,関数のフレームとともに破棄されるのはデータを指している「荷札」だけだからです.

Python の動作に近いことを C で実現するなら以下のようになります:

#include <stdlib.h>

int *func(int *x, int a) {
    x[0] = 123;
    a = 456;
    int *y = (int *)malloc(sizeof(int) * 3);
    y[0] = 99;
    y[1] = 88;
    y[2] = 77;
    return y;
}

int main() {
    int *xm = (int *)malloc(sizeof(int) * 3);
    xm[0] = 10;
    xm[1] = 20;
    xm[2] = 30;
    int am = 1;
    int *ym = func(xm, am);
    return 0;
}

malloc (Memory ALLOCation) 関数は初めて見た人もいるかも知れません.細かい話を抜きにすると,この例では,整数 3 つ分の配列を「右側の領域」に確保する仕事をします.このようにすると,func のフレームが破棄された後も y が指していた配列データは生き残り,main 関数内の変数 ym を通して読み書きできます.

問題は,(この例では main もこの後すぐ終了するので問題ありませんが,) 仮にこの後プログラムの実行がさらに継続するとして,これらの配列データがどこからも参照されなくなっても,Python のように自動で解放してはくれないことです.C で malloc 関数を使ってメモリを確保したときは,プログラマが自分の責任で free 関数を呼び出して解放する必要があります.これは煩雑で,注意して書かないとバグの温床になります.

Python と C の動作を比べると,Python のようにデータを自動で確保・解放してくれる方が便利で理に適っているように思います.C 言語はなぜこんな面倒な動作なんですか? 他の言語はどうですか?
C がこのような動作なのは速度重視のためです.

メモリ領域の確保というのは時間のかかる重い処理です.参照されなくなったデータを検出して解放する (ゴミ集め,garbage collection と呼ばれます) のはさらに重い処理です.すべてのデータについてこれらをやっているとプログラムの実行が遅くなります.そのため C ではすべてプログラマに任せる方針を取っています.

このあたりのバランスの取り方はプログラミング言語によってさまざまです.C++ は基本的には C と同じですが,スマートポインタという仕組みを使うことで必要に応じて Python と同様の自動メモリ管理を行えます.

Java では,intfloat といった基本型のデータは C と同様ですが,配列やユーザ定義型などは Python と同様の方式で自動管理されます.「じゃあ intfloat を自動管理することはできないの?」というと,そのために IntegerFloat という型が別途用意されていて,そちらを使うと Python 方式になるというアクロバティックな仕組みが採用されています.

Online Python Tutor の右側のデータが並んでいるところに Objects と書いてあります.ここに現れるものは必ずオブジェクトだということですか?
はい,そうです.

実際のところ,Python ではすべてのデータが (それどころか関数やモジュールすらも) オブジェクトとして扱われます.なので「これはオブジェクトですか? オブジェクトではないですか?」と問うこと自体にあまり意味がありません.ここで重要なのは,オブジェクトと,それを指す変数 (= 荷札) がしっかり区別できることだけです.

4.4.7. リストを使いこなす

リストの要素には異なる型のものが混在して構いません.「荷札」が並んでいるだけなので,それらが指す先はどんな型のものでもよいのです:

>>> x = [10, 20, 3.14, "abc"]
>>> x
[10, 20, 3.14, 'abc']

したがって,リストもリストの要素になれます.これを利用するとリストだけでかなり複雑なデータ構造を構築できます:

>>> x = [10, 20, [99, 88, ["a", "b", "c"]], 40, 50]
>>> x
[10, 20, [99, 88, ['a', 'b', 'c']], 40, 50]
>>> x[2]
[99, 88, ['a', 'b', 'c']]
>>> x[2][2]
['a', 'b', 'c']
>>> x[2][2][2]
'c'

どういうデータ構造になっているか想像できるでしょうか.ぜひ Online Python Tutor で可視化してみてください.

代入文 y = x では,yx と同じものを指すようになるだけで,データのコピーは全く発生しないのでした.ではコピーをするにはどうしたらよいでしょうか.一つの方法は,list 型のメソッド copy を使用することです.Online Python Tutor で以下を可視化してみてください:

x = [10, 20, [99, 88, ["a", "b", "c"]], 40, 50]
y1 = x
y2 = x.copy()

x.copy() と全く同じことが,「最初の要素から最後の要素までのスライス」 x[:] を指定することでも行えます.以下の行を追加して確認してください:

y3 = x[:]

このどちらの方法でも,コピーされるのはあくまでリストの「1段目」だけであることに注意してください.つまり,x が指している先で直列に並んだ荷札たちをコピーしているだけです.荷札のさらにその先まではコピーしないので,「2段目」以降は共有されます.これを浅いコピー (shallow copy) と呼びます.

2段目以降もすべてコピーして完全に独立したデータを作り直したい場合もあるかも知れません.深いコピー (deep copy) と呼ばれます.以下のようにコツコツとやればよいのですがちょっと面倒です:

x = [10, 20, [99, 88, ["a", "b", "c"]], 40, 50]
y4 = x.copy()
y4[2] = x[2].copy()
y4[2][2] = x[2][2].copy()

Python 標準の copy モジュールに deepcopy という関数が用意されており,これを使うと一発でやってくれます.コツコツ作ったのと同じ結果になることを Online Python Tutor で確認してください:

import copy
x = [10, 20, [99, 88, ["a", "b", "c"]], 40, 50]
y5 = copy.deepcopy(x)

4.5. 演習

例題4-2

対話的実行で以下のようなリストの操作を行ってください.実行前に結果 (「???」のところ) を予想して,実際の結果と一致するか確認してください.予想と違ったら Online Python Tutor で確認し,正しい理解をするようにしてください.

>>> x = [10, 20, 30]
>>> y = x
>>> x.append(40)
>>> y
???
>>> x = [10, 20, 30]
>>> y = x
>>> x = [10, 20, 30, 40]
>>> y
???
>>> x = [10, 20, 30]
>>> y = x
>>> x[:] = [10, 20, 30, 40]
>>> y
???
>>> x = [10, 20, 30]
>>> x[1] = [99, 98, 97]
>>> x
???
>>> def func(x):
...     x[0] = 99
...
>>> x = [10, 20, 30]
>>> func(x)
>>> x
???
>>> def func(x):
...     x = [99, 98, 97]
...
>>> x = [10, 20, 30]
>>> func(x)
>>> x
???

4.6. docstring を書く

hello_pygame.py の開発を例とした Python の基本事項の確認は以上で終わりです.最後に,docstring について紹介します.

docstring は,ファイルの先頭,関数の先頭などに記載する説明用コメントです.VSCode で関数名の上でマウスホバーしたとき,説明がポップアップしてくることに気づいた人もいると思います.自分で作った関数でもこれが出てくると便利ですし,より一般に,後からコードを読む人 (それには数か月後の自分も含まれます) の助けになります.

関数 init_screendraw の場合の簡単な記載例を示します.

def init_screen():
    """Initialize pygame screen.

    Library pygame is initialized and screen size is set to 600x400.

    Returns
    -------
    Surface
        Screen surface
    """
    pygame.init()
    width, height = 600, 400
    screen = pygame.display.set_mode((width, height))
    return screen
def draw(screen, player_image, text_image, mouse_pos):
    """Draw images onto screen.

    player_image is drawn at mouse_pos on screen, and text_image,
    unless it is None, is drawn to the right of player_image.

    Parameters
    ----------
    screen : Surface
        Screen onto which images are drawn.
    player_image : Surface
        Player image to be blit'ed.
    text_image : Surface
        Text image to be blit'ed.  If None is passed, nothing is drawn.
    mouse_pos : tuple[int, int]
        Position where player_image is blit'ed.
    """
    screen.fill(pygame.Color("black"))
    screen.blit(player_image, mouse_pos)
    if text_image is not None:
        mouse_x, mouse_y = mouse_pos
        text_offset_x = 100
        screen.blit(text_image, (mouse_x + text_offset_x, mouse_y))
    pygame.display.update()

関数定義の場合,def 行の直後に二つの """ で挟んで記載します.正確には """...""" はコメントではなく,改行を含むことのできる文字列です.文字列だけの文は実行に何も影響しないので,事実上コメントとして扱えます.しかしインデントレベルは守る必要があることに注意してください.

docstring の書き方には,Google スタイル,numpy スタイルなどいくつかの流儀がありますが,共通するのは,最初に1行で簡潔な要約を書き,1行空けてから,詳しい説明を書くことです.詳しい説明のところには,動作,引数の型と説明,返り値の型と説明などをそれぞれのスタイルで記載します.上の例は numpy スタイルです.

このテキストのサンプルコードには docstring は書かないんですか?
サンプルが長くなる上に同じ docstring が何度も現れることになるため省略しています.関数等の説明は本文から読み取ってください.
私は,ファイルの先頭や関数の先頭だけではなく,コードの 1 行 1 行にコメントを入れるように心がけているのですが?
せっかくの心がけですが,あまりお勧めできません.

それよりも,各行が何をしているのか,コメントが無くても自然に読めるように変数名や関数名を工夫する方が後々有益です.

その上で,それでもわかりづらいところ,なぜそのように書く必要があったのか説明が必要なところ,どういう意図でそのような処理をしているのか説明が必要なところなどに限定してコメントを入れていく方が読みやすいプログラムになります.

4.7. 演習

例題4-3

前の章の演習で,キーが押されている間文字が変わるような修正をしました.この章ではマウスボタンが押されたときだけ表示するようになったので,マウス左ボタンが押下されるたびに文字の内容や色が変わるようにしてください.

(ヒント: Surface のリスト text_images を作成し, player_images を参考にして表示することを考えるとよいです.ボタン押下を検出するには MOUSEBUTTONDOWN イベントを使います)

例題4-4

プレイヤが動いていないときは足踏みを止めるようにしてください.

(ヒント: pygame.mouse.get_rel 関数を調べてください.マウスの相対移動量が得られます.その量に応じて player_images に与えるインデックスの計算を考えるとよいでしょう)

例題4-5

プレイヤの動きの速さによって足踏みの速さを変えるようにしてください.

(ヒント: pygame.mouse.get_rel から得た移動距離を積分すれば累積移動距離が得られます.距離の算出には math.sqrt が使えます)

例題4-6

assert 文は,assert と書くことで,True なら何もせず,False ならエラーを発生させます.プログラムが想定通りの挙動をしているかを確認する際に便利です.以下の assert が通るように関数を修正してください:

def set_to_zero_vector(vec, n_elements):
    vec = [0] * n_elements

x = []
set_to_zero_vector(x, 3)
assert x == [0, 0, 0]

y = [10, 20]
set_to_zero_vector(y, 5)
assert y == [0, 0, 0, 0, 0]