予測・検証・修正でプログラムを育てる
エラーが出ないことと,仕様を満たすことを分けて考える.
- 症状を小さい入力で再現する
- 原因を説明してから修正する
- 修正前に失敗し,修正後に成功するテストを残す
22.1 調べ方を決める
- 期待する結果を先に書く(予測欄)
- 実行し,予測と違う値・行を絞る(試したこと欄)
- 小さい入力と print・assert で原因を確認する
- 1つの原因を直し,ほかの入力でも確かめる
- 原因と修正理由を振り返り欄に残す
22.2〜22.4 は小さな故障でこの手順を試す準備問題です.以下のひな形は意図的に壊れています.AssertionError は教材の故障ではなく,仕様に反したことを知らせるテストの結果です.assert の行を消して成功にしてはいけません.
22.2 準備:共有してしまった行
2行目は変更しないつもりです.実行前に出力を予測し,2つの行が同じリストを参照していないか is で調べてから直してください.
必須(すべて満たして正しく動けば 8)
- 行ごとに別のリストを作る方法へ直し,rows[1] が [0, 0] のままになる
- 修正後は2つの行が別のリストであることを is not などの assert で示している
- 原因(* 2 が同じ行への参照を並べること)をコメントで説明している
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 3行3列でも同じ方法で独立した行になることを確かめている
- 修正前に rows[0] is rows[1] が True だったことを予測欄・振り返り欄に記録している
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
22.3 準備:消し残る図形
大きさが0以下の図形をすべて除く仕様です.0が連続するとどうなりますか.走査中のリスト変更が次の添字に与える影響を説明してから直してください.
必須(すべて満たして正しく動けば 8)
- 走査中のリストから remove せず,残す条件で新しいリスト visible を作っている
- 0が連続する入力で visible == [30] になり,元の sizes が変わらないことを assert で確かめている
- 空のリストと,すべて0以下のリストでも結果が空になることを確かめている
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 原因(要素が前へ詰まって次の要素を飛ばすこと)を,各回の添字と値の表でコメントに残している
- 条件を関数にして,境界の0と1でも確かめている
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
22.4 準備:辺の上だけ選択できない
四角形の輪郭を含めて選択する仕様です.左下(0,0),辺40の図形について,x と y の両方の境界(0 と 40)を含めて直し,内側1点・4辺上の4点・外側3点の8点をテストしてください.
必須(すべて満たして正しく動けば 8)
- x と y の両方で境界の 0 と 40 を含む条件に直している
- 内側1点・4辺上の4点・外側3点の計8点を assert で確かめている
- 修正前に失敗した点 (40, 20) をテストに残している
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 左下と辺の長さを引数にして,別の四角形でも同じ関数を使えるようにしている
- 角の4点((0, 0) や (40, 40) など)も確かめている
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
22.5 図で追う:正方形が閉じない
仕様は辺60の正方形です。次のプログラムは止まらず動きますが、正方形にはなりません。まず絵を見て、回数・長さ・角度のどれを調べるか予測します。
from turtle import *
for side in range(4):
forward(60)
left(80)
done()
| 観察した事実 | まだ確定していない原因 | 確かめる方法 |
|---|---|---|
| 線は4本ある | 繰り返し回数が原因? | range(4)が0,1,2,3の4回か表示する |
| 各辺の長さは60 | 長さが原因? | forwardへ渡す値を確認する |
| 角と最後の位置がずれる | 回転角が原因? | 各辺の後のheading()とposition()を表示する |
ここでは変える前に観察することが大切です。線を長くして無理につなぐと、正方形という仕様を壊します。次のコードは、元の角度80度を残したまま状態を表示します。
from turtle import *
for side in range(4):
print("辺", side + 1, "進む前の向き", heading())
forward(60)
print("進んだ後の位置", position())
left(80)
print("最後の向き", heading())
done()
| 辺 | 進む前の向き:80度の場合 | 正方形で必要な向き |
|---|---|---|
| 1 | 0° | 0° |
| 2 | 80° | 90° |
| 3 | 160° | 180° |
| 4 | 240° | 270° |
| 4回回転後 | 320° | 0°(360°回転) |
回転角だけを修正してください。4本の線、辺60、出発点への復帰を確かめます。最後にprint(position(), heading())を加え、出発点 (0, 0) と最初の向き 0 度に戻ったことを表示して確かめます。座標のごく小さな小数誤差は、後半の調査で扱います。
必須(すべて満たして正しく動けば 8)
- left の角度だけを直し、forward の長さ60と回数4は変えていない
- 最後に position() と heading() を表示し、出発点と最初の向きへ戻ったことを確かめている
- 原因(80度では4回で320度しか回らないこと)をコメントで説明している
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 5角形・6角形でも「曲がる角度=360÷辺の数」で閉じることを確かめている
- 修正前の各辺の向きを表にした結果を振り返り欄に残している
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
from turtle import *
for side in range(4):
forward(60)
left(90)
done()
22.6 状態で追う:図形の間に余計な線が出る
仕様は離れた2個の正方形だけを描くことです。次の関数では、最初の図形へ向かう線と、図形を結ぶ線が描かれます。座標の計算は正しくても、ペンの状態が違えば絵は変わります。
from turtle import *
def square(x, y):
goto(x, y)
for _ in range(4):
forward(40)
left(90)
square(-100, 0)
left(45)
square(60, 0)
done()
| 場面 | ペンの状態 | 起きること |
|---|---|---|
| 初期状態 | 下がっている | gotoでも線を描く |
| 1個目を描き終わった直後 | 下がったまま | 次のgotoが連結線になる |
| 正しい移動中 | 上げておく | 座標だけが変わる |
| 四角形を描き始める直前 | 下げる | 4辺を描ける |
gotoの直前にprint(isdown())を置き、移動時の状態を観察する。penup()→goto(...)→pendown()の順に直す。- 1個だけでなく2個、3個を続けて描く。図形間に線がないことを確認する。
- 呼び出す前に
left(45)を入れて試す。毎回同じ向きに描く仕様なら、関数内でsetheading(0)も行う。
ひな形では2個目の呼び出しの前にleft(45)があります。移動中は線を描かず、呼び出す直前の向きによらず、水平・垂直の辺を持つ正方形を描く関数へ直してください。3個目の正方形を(-20, -120)に加えて確かめます。
必須(すべて満たして正しく動けば 8)
- 関数の中で penup → goto → setheading(0) → pendown の順に描画の前提をそろえている
- 直前に left(45) があっても、2個目の正方形が水平・垂直の辺で描かれる
- 3個目の square 呼び出しを加え、図形の間に移動の線が出ないことを確かめている
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 線の色も関数の引数にして、呼び出し前の色に依存しないようにしている
- 関数の最後でペンの状態をどうするか決め、コメントで理由を説明している
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
from turtle import *
def square(x, y):
penup()
goto(x, y)
setheading(0)
pendown()
for _ in range(4):
forward(40)
left(90)
square(-100, 0)
left(45)
square(60, 0)
done()
22.7 処理を分ける:片方の図形しか移動しない
- ① データ図形の座標
- ② 更新xへ20を加える
- ③ 描画現在の座標で描く
- ④ 確認期待値と比較
仕様はすべての図形を右へ20移動することです。片方だけ動かないとき、すぐに描画命令を変えず、描く前のデータを確認します。座標がすでに違っていれば、描画より前に原因があります。
| 確認する値 | 移動前 | 期待する移動後 | ひな形の実測値 |
|---|---|---|---|
| 1個目のx | -100 | -80 | -80 |
| 2個目のx | 40 | 60 | 40 |
実行すると図形を描いた後にAssertionErrorで止まります。move_allのどの行で処理が終わるかを確認し、全件を更新してください。assertや期待値は変更しません。直した後、下の回帰テストの表から3件以上をassertで加えてください。
必須(すべて満たして正しく動けば 8)
- move_all のループ内の return を取り除き、元の assert と期待値を変えずに通している
- 回帰テストの表から3件以上(空・1個・dx=0・負の移動量など)を assert で加えている
- 原因(return が関数全体を終えること)をコメントで説明している
発展(必須に加えて 1 つ以上で 9,学んだ範囲を大きく超える作り込みで 10)
- 表にない入力(3個以上、y 座標を持つ図形など)でも確かめている
- 報告の見本の6項目に沿って、自分の調査を振り返り欄にまとめている
必須が 1〜2 つ足りない → 7 / 動かない・未着手が複数 → 6
ループ内のreturnは「次の図形へ進む」命令ではなく、関数全体を終了する命令です。先頭の更新が終わった直後に戻るため、2個目へ到達しません。修正後は、1個だけで偶然成功する例に頼らず、次の入力でも試します。
| 回帰テスト | 期待する結果 | 確かめたいこと |
|---|---|---|
| 図形0個 | 空のまま | 空でも止まらない |
| 図形1個:x=0、dx=20 | x=20 | 基本の更新 |
| 図形2個:x=-100,40、dx=20 | x=-80,60 | 途中で終了しない |
| dx=0 | 座標が変わらない | 不要な変更がない |
| dx=-20 | 全図形が左へ20移動 | 負の移動量も扱える |
from turtle import *
shapes = [{"x": -100, "size": 40}, {"x": 40, "size": 60}]
def move_all(items, dx):
for shape in items:
shape["x"] += dx
def draw(items):
for shape in items:
penup()
goto(shape["x"], 0)
pendown()
for _ in range(4):
forward(shape["size"])
left(90)
move_all(shapes, 20)
draw(shapes)
print([s["x"] for s in shapes])
assert [s["x"] for s in shapes] == [-80, 60]
done()
報告の見本:観察と判断を分けて書く
| 項目 | この不具合の報告例 |
|---|---|
| 期待 | x=-100,40の2個を右へ20動かすと、-80,60になる |
| 実測 | 描く前のxは-80,40だった |
| 切り分け | 描画前に違っているため、更新関数を調べた |
| 原因 | ループ内のreturnで先頭の更新後に終了した |
| 修正 | 不要なreturnを除き、全要素を更新した |
| 再確認 | 同じ2個の入力、空、1個、移動量0、負の移動量で確認した |
ここまでが、絵の違いから原因を絞る一連の流れです。続く小さな調査では、入力・条件・リスト・小数など、原因の種類ごとに練習します。
22.8 原因別の調査実習
各課題のひな形には意図的な不具合があります.期待値を変更してテストを通すのではなく,仕様を保って処理を直してください.例題は,調べ方を小さく実演するプログラムです.
調査22-1 症状と原因を分けて記録する
「動かない」という報告だけでは,どこから調べればよいか分かりません.
実行ボタンを押しても始まらないのか,エラーが表示されるのか,絵は出るが形が違うのかを分けます.さらに,期待した結果と実際の結果を具体的に書きます.
例えば「辺40の正方形になるはずが,最後の辺が描かれない」と書けば,長さより繰り返し回数を調べる手がかりになります.
症状は観察した事実,原因はその事実を説明する仮説です.
最後の辺がない原因として,繰り返しが3回しかないことも,途中でペンを上げたことも考えられます.最初に思いついた原因を決め付けず,確かめるための小さな変更を選びます.
同時に回数も角度も長さも変えると,どの変更が効いたか分からなくなります.
調査中は,入力,期待値,実測値,仮説,試した変更,変更後の結果を残します.
長い文章である必要はありませんが,後から同じ状況を再現できる具体性が必要です.「直った」だけで終えると,同じ不具合が戻ったときに役立ちません.
演習の予測・実験・振り返りノートは,この記録に使えます.
以下の例では,4辺の長さから周の長さを計算しています.
元の問題を数値の一覧へ小さくすると,図形の描画速度や向きに影響されず,回数と合計に注目できます.大きな作品で不具合が出たときも,問題の部分だけを短い入力で再現できないか考えてください.
関係のない処理を減らすほど,仮説を確かめやすくなります.
lengths = [40,40,40,40]
expected = 160
actual = sum(lengths)
print("期待",expected,"実測",actual)
assert actual == expected
4辺それぞれ40の周を求める仕様です.ひな形の実測値を記録し,原因を1つに絞って直してください.最後のassertを消したり期待値を120へ変えたりせず,仕様を満たすように修正します.
調査22-2 例外の種類と,止まった行を読む
エラー表示には,例外の種類,説明,発生した位置が含まれます.
まず末尾の例外名を読み,次に自分のコードのどの行で起きたかを確認します.NameErrorなら名前,TypeErrorなら操作対象の型,IndexErrorなら添字と長さ,KeyErrorなら辞書のキーを調べる手がかりになります.
例外名は原因そのものではありませんが,調査範囲を絞れます.
止まった行が,間違いを作った行とは限りません.
例えば,計算結果へNoneを代入してしまい,後の行で数値を足そうとしたときにTypeErrorになることがあります.エラー行で使う変数が,どこでどの値になったかを遡ります.
変数の値とtypeを表示すると,想定との違いを具体的に確認できます.
調査のために例外を受け止める場合も,種類を限定します.
何でもexceptで無視すると,問題が隠れて後の処理が不正な状態で進むかもしれません.ここでは小さな実験として,予想した例外だけを捕まえ,名前と説明を表示します.
完成する作品では,入力のやり直しなど利用者に必要な対応を行い,原因を隠さないようにします.
以下では文字列と数値を直接足せないことを観察します.
表示用に文字列へ変えるのか,計算用に数値へ変えるのかは,目的によって違います.エラーを消すだけならstrでもintでも書けますが,得られる意味は異なります.
何を計算したいのかという仕様へ戻って,適切な変換を選んでください.
value = "20"
print(value,type(value).__name__)
try:
result = value + 5
except TypeError as error:
print(type(error).__name__, str(error))
print(int(value) + 5)
入力された文字列20を数値として扱い,5を加えて25を求めてください.文字列205を作る修正は仕様に合いません.変更前後のtypeを表示して確認します.
調査22-3 空の入力で,隠れていた前提を見つける
リストの先頭や末尾を使う処理は,要素が1個以上あることを前提にしています.
図形が1個もない作品,検索で一致がなかった結果,入力をすべて削除した状態では,空のリストが正常に現れることがあります.その状態を試さずに完成とすると,授業中の操作で突然IndexErrorになるかもしれません.
空の場合に何を返すかは,処理の意味から決めます.
合計なら0が自然ですが,最大値や最後の図形には通常の値がありません.Noneを返すか,呼び出し側が対処すべき例外を出すかを決めます.
単に0を返してエラーを消すと,値0の図形が存在する場合と区別できなくなることがあります.
条件を追加するときは,通常の入力が引き続き正しいことも確かめます.
空のときだけを直すつもりで,1個のときまでNoneを返すようにしてしまう可能性があります.空・1個・複数という3つの規模でテストすると,前提の境界を確認しやすくなります.
大きなデータをたくさん流す前に,小さい例で意味を確かめましょう.
以下では,空のリストの末尾を取り出したときの例外と,空のリスト・[0]・0の真偽を観察します.
空のリストは偽ですが,数値の0も偽です.戻り値を受け取る側でif not valueとすると,最後の値が0の場合も空と混同してしまうので,is Noneで区別します.
空への対応を加える際には,戻り値を受け取る側の判定も合わせて点検します.
values = []
print("長さ", len(values))
print("[]は真か", bool([]), "[0]は真か", bool([0]), "0は真か", bool(0))
try:
print(values[-1])
except IndexError as error:
print("IndexError", error)
ひな形を,空ならNone,空でなければ最後の値を返す仕様へ直してください.0を含む入力も確認し,空と数値0を混同しないようにします.
調査22-4 繰り返しの端を,一覧で追う
rangeの終端は含まれません.
0からn-1までを使うにはrange(n),1からnまでならrange(1,n+1)です.この規則を知っていても,図形の個数,添字,表示する番号が混ざると,1個多い・1個少ない不具合が起きます.
どの値が個数で,どの値が位置を表すかを,名前と具体例で区別します.
調査するときは,ループ変数をprintし,実際に何回処理されたかを一覧にします.
「n回のはず」と考えるだけでなく,nが0,1,3の場合に得られる値を紙へ書いてください.最小の入力で規則を確認すると,端の間違いが見えやすくなります.
大きいnの画面だけを見ても,1個の違いを見落とすことがあります.
添字と表示番号を分けるには,enumerateを使う方法もあります.
内部では0からの添字,利用者へは1からの番号を見せたい場合,どこで1を足すかを統一します.すべての変数を最初から1始まりに変えると,リストを参照する箇所で新たなずれが起こるかもしれません.
変換する境界を決めてください.
以下では0からの添字と1からの番号を並べます.
出力を予測した後,空のリストでも同じコードが使えることを確認します.次の課題は1からnまでの合計です.
ループの端を修正したら,n=1とn=3を確かめ,終端を含む仕様になったかを確認します.
names = ["青","赤","緑"]
for index,name in enumerate(names):
print("添字",index,"番号",index+1,name)
print(list(range(1,4)))
1からnまでの整数を足す仕様に直してください.nは0以上とし,0のときは0です.rangeの引数だけでなく,実際に処理する値の一覧をノートへ書いて確認します.
調査22-5 returnの位置が,処理を途中で終わらせる
関数の中でreturnを実行すると,その関数の処理はそこで終わります.
ループの中へ誤ってreturnを置くと,先頭の要素だけを処理して結果を返してしまいます.文法としては正しいため,SyntaxErrorにはなりません.
要素が1個の入力だけで試すと,偶然正しい結果になって不具合を見落とします.
Pythonでは字下げが構造を表します.
returnがforと同じ深さならループの後,ループ本体と同じ深さなら各回の中です.画面の見た目だけで判断しにくい場合は,どの文が繰り返され,どの文が1回だけ実行されるかを言葉で書きます.
「全部の値を足してから返す」という仕様と,コードの位置が一致しているか確認します.
途中で返すこと自体が常に間違いなのではありません.
最初に条件へ一致した図形を探す関数なら,一致した時点でreturnするのが自然です.合計では全件,最初の一致では必要なところまで,という違いがあります.
returnを機械的にループの外へ移すのではなく,その関数がいつ答えを確定できるかを考えます.
以下では,各回の途中経過を表示し,最後に合計を返します.
表示が3回あることと,戻り値が1個であることを分けて観察してください.次の課題は,最初の一致を探す関数で,見つからなかったときのreturnの字下げだけが違う故障です.
修正後に空・1個・複数を確認すると,ループを1回も通らない場合にも値が返るかを確かめられます.
def total(values):
result = 0
for value in values:
result += value
print("途中",result)
return result
print("答え",total([10,20,30]))
limit以上の最初の値を返し,なければNoneを返す仕様です.ひな形は先頭が条件に合わないとすぐNoneを返します.return Noneの位置を直し,2個目で見つかる場合,1個目で見つかる場合,該当なし,空の4つで確認してください.関数がいつ答えを確定できるかも説明します.
調査22-6 条件式は,日本語の省略をそのまま書けない
「色が赤または青なら」という日本語を,if color == 'red' or 'blue'と書く間違いがあります.
Pythonは,右側のblueを色との比較として補ってくれません.左の比較結果,または空でない文字列という式を評価するため,意図と違って常に真のように働きます.
日本語では省略できる主語や比較対象も,コードでは必要です.
比較を2つ書くならcolor == 'red' or color == 'blue'です.
候補が複数あるならcolor in ('red','blue')と書くと,所属を調べる意図が読みやすくなります.短い書き方へ変えることが目的ではなく,条件の意味が明確に表現されているかが大切です.
複数の条件が混ざる場合には括弧でまとまりを示します.
条件分岐のテストでは,受け付けたい値だけでなく,受け付けたくない値も試します.
redとblueの2例で成功しても,greenを拒否できるかは分かりません.許可する集合の内側と外側の両方を確かめます.
この考え方は,図形の選択範囲,入力の形式,ファイルの種類の検査などにも使えます.
例題では,元の式を部分ごとに分けて評価し,greenでも真のように働く理由を観察します.
実際の図形を描く前に条件だけを切り出すと,誤った分岐を調べやすくなります.次の課題では許可しない色を必ずテストへ入れます.
条件を修正した後,どの値で修正前と結果が変わったかを記録してください.それが不具合を再現する入力になります.
color = "green"
print("左の比較", color == "red")
print("右の文字列だけの真偽", bool("blue"))
print("元の式の値", color == "red" or "blue")
if color == "red" or "blue":
print("greenでもifの中へ進みました")
redとblueだけを受け付ける関数に直してください.greenと空文字列を拒否することも確認します.元の式でorの左右がそれぞれ何を表していたかを説明してください.
調査22-7 小数の比較では,誤差の範囲を考える
小数を使った計算では,見た目には同じ値でも,内部の近似によって小さな差が出ることがあります.
0.1を3回足した結果と0.3の比較は,その代表例です.これはPythonが気まぐれに計算しているのではなく,多くの小数を有限の2進数で正確に表せないことによるものです.
座標や角度を何度も更新する作品でも関係します.
小数の計算結果を検証する場合,必要な精度に応じて許容差を決めます.
math.iscloseは相対的な差と絶対的な差を使って近さを調べられます.特に0付近を比較するときは,abs_tolを指定することが役立ちます.
何でも大きな許容差を与えると本当の不具合を隠すので,作品の単位と必要な精度から考えます.
一方,個数やIDのように正確な整数で表す値まで,近さで比較する必要はありません.
3個の図形と4個の図形は,少し違うだけだから同じとはできません.どの値が計測・近似の対象で,どの値が離散的な数なのかを分けます.
問題の性質に合った比較方法を選ぶことが,適切なテストにつながります.
以下では,計算結果そのものとiscloseの結果を並べて表示します.
丸めて表示した文字列が同じでも,内部の値が完全に同じとは限りません.表示の書式を変えることと,計算の判定を変えることを混同しないでください.
次の課題では,誤差を許す比較へ直し,明らかに違う値までは受け付けないことも確認します.
import math value = 0.1 + 0.1 + 0.1 print(repr(value),repr(0.3)) print(value == 0.3) print(math.isclose(value,0.3,abs_tol=1e-9))
0.1を3回足した結果を,許容差1e-9で0.3と比較してください.0.31とは一致しないことも確認します.図形数の比較なら==を使う理由も説明してください.
調査22-8 乱数を含む問題を,同じ条件で再現する
ランダムな配置でだけ不具合が起こる場合,毎回違う配置になると調査が難しくなります.
乱数の種を固定すると,同じ環境で同じ呼び出し順なら,同じ乱数列を得られます.まず再現できる状態を作り,原因を調べてから,別の種でも確認する流れにします.
種を固定することは,作品を永久に同じにするためではなく,調査条件をそろえるための道具です.
乱数の種だけでなく,呼び出し回数と順番も結果へ影響します.
描画のために乱数を余分に1回呼んだだけで,後の配置がずれることがあります.計算する処理と見た目を作る処理が同じ乱数器を使っていると,説明しにくい変化が起こる場合があります.
必要ならRandomの個体を分け,どの乱数列を使うかを明示します.
乱数を含むテストでは,具体的な値の一致だけでなく,常に守るべき条件も確かめます.
座標が画面内にある,個数が指定どおり,大きさが正,といった条件です.1つの種で成功しただけでは,すべての配置が正しいとは言えません.
再現用の種と,複数の種で条件を調べるテストを組み合わせます.
以下では,同じ種のRandomを2個作り,同じ列になることを確認します.
全体のrandom.seedを何度も変える方法より,他の処理への影響を分けやすくなります.次の課題では,指定した個数と範囲を守る関数を作ります.
不具合を報告するときは,種と個数も入力条件の一部として記録してください.
import random a = random.Random(7) b = random.Random(7) first = [a.randint(-10,10) for _ in range(5)] second = [b.randint(-10,10) for _ in range(5)] assert first == second print(first)
positionsが指定した個数だけ値を返すように直します.同じ種なら同じ列,10種類の種でも各値が範囲内,個数0なら空になることを確認してください.
調査22-9 辞書の欠落と,既定値の使い過ぎ
辞書のキーがないとき,角括弧で取り出すとKeyErrorになります.
この表示を見て,すべてgetへ置き換えるだけでは,問題を解決したことにならない場合があります.sizeが必須の図形で,欠けている値を0へ置き換えると,エラーは消えても描画できない図形が残ります.
そのキーが必須か省略可能かを,仕様から判断してください.
nameのように,省略したら空文字列でよい情報もあります.
この場合はgetが適しています.同じ辞書に含まれるキーでも,欠けたときの意味は違います.
入力の入口で必須キーを検査し,省略可能な値だけ既定値で補うと,後の計算を分かりやすくできます.欠落をどの段階で知らせるかも設計の一部です.
テストには,完全な辞書,省略可能なキーだけ欠ける辞書,必須キーが欠ける辞書を用意します.
さらに,キーはあるが値がNoneという場合も,欠落とは別です.キーの存在と値の妥当性を分けて調べる必要があります.
第25章のJSON読み込みでは,外から来るデータを扱うため,この区別が特に重要になります.
以下では,sizeのない辞書から,角括弧とgetでそれぞれ取り出した結果を観察します.
getの既定値0はエラーを消しますが,sizeがないことを隠してしまいます.nameは省略可能,sizeは必須という仕様に照らして,どちらの書き方が合うかを考えます.
次の課題では,欠けている図形を静かに面積0へ変える不具合を直します.「エラーをなくす」より,「不正な入力を正しく拒否する」ことが完成条件になる例です.
shape = {"color":"blue"}
print("sizeはあるか", "size" in shape)
print("getで取り出すと", shape.get("size",0))
print("nameの既定値", shape.get("name","無名"))
try:
print(shape["size"])
except KeyError as error:
print("KeyError", error)
sizeが必須の仕様に直します.sizeが20なら400,sizeがなければValueErrorです.見えない図形へ変換して処理を続けず,入力の問題を知らせるようにしてください.
調査22-10 その場で変更する操作の戻り値
リストのsortは,リスト自体を並べ替え,戻り値はNoneです.
ordered = values.sort()と書くと,valuesは並び替わっていても,orderedには一覧が入りません.この不具合は,関数を呼べば必ず結果の値が返ると思い込んだときに起こります.
第14章の戻り値と,変更可能なデータの操作を結び付けて理解します.
調査するときは,操作の前後のvaluesと,戻り値を別々に表示してください.
処理が何を変更し,何を返すかを分けると,現象を説明できます.appendやreverseにも同じ種類の注意が必要です.
ただし,popのように変更しながら値を返す操作もあるので,すべてのリスト操作を一括りにしないでください.
元の一覧を保って並べ替えた結果がほしい場合はsortedを使います.
元の順序を変更することが仕様なら,sortを呼んでから元の変数を使います.どちらでも出力だけは同じにできることがありますが,後の処理が元の順番を必要としているかで選択が変わります.
表示結果だけでなく,入力が維持されるかも確認します.
例題は,sortedとsortを並べて観察します.
次の課題は,入力を変更しない関数を作る仕様です.sortの後にvaluesを返すだけでは,戻り値の問題は解消しても,入力を保つ条件を満たしません.
複数の条件を持つ仕様では,1つの症状だけ直して終えないようにします.
values = [30,10,20]
ordered = sorted(values)
print("元",values,"新",ordered)
result = values.sort()
print("変更後",values,"戻り値",result)
並べ替えた新しいリストを返し,入力は変更しない関数に直します.結果が正しいことと,元の一覧がそのままであることを,別々のassertで確認してください.
調査22-11 呼び出しのたびに初期化する値を見分ける
関数の外へ集計結果のリストを置くと,何度も呼び出すうちに前回の結果が残ることがあります.
1回目の実行だけでは正しく見えるため,テストを一度で終えると見落とします.同じ入力で続けて呼び出す,違う入力を順に渡す,空の入力を間に挟む,といった操作列で確認します.
状態を持ち続けること自体が間違いなのではありません.
履歴やゲームのスコアなら,前回の状態を保持するのが目的です.一方,受け取った値から新しい集計結果を作る関数では,前回の呼び出しへ依存しないほうが使いやすいことがあります.
関数が単発の計算か,状態を更新する操作かを,仕様として区別します.
初期化する場所は,値を使いたい期間に対応します.
各呼び出しで新しくしたい一覧なら関数の内側,作品全体で共有する履歴なら管理する対象の状態へ置きます.とりあえずすべて大域変数へ置く方法は,値がいつ変わるかを追いにくくします.
変数を宣言した場所だけでなく,誰が変更するかを確認してください.
以下では,関数の外にリストを置いた形のまま2回呼び,戻り値と1回目の変数を表示します.
isで調べると,2回とも同じリストを返していることが分かります.次の課題では,この結果の積み重なりを直します.
空の入力の結果も空になることを確認すると,前回の状態が残っていないかを調べられます.
result = []
def squares(values):
for value in values:
result.append(value**2)
return result
first = squares([2])
print("1回目", first)
second = squares([3])
print("2回目", second, "1回目の変数", first)
print("同じリストか", first is second)
各呼び出しで新しいリストを返す仕様へ直してください.[2],[3],空の入力を順に渡しても結果が混ざらず,最初の戻り値も変わらないことを確認します.
調査22-12 変更可能な既定値が共有される
関数の引数にvalues=[]と書くと,そのリストは呼び出しのたびに新しく作られるわけではありません.
関数が定義されたときの既定値が使われるため,引数を省略した呼び出しで同じリストが共有されます.前の節の大域変数と違い,関数の引数に書かれているので,見落としやすい問題です.
毎回新しいリストを用意したい場合は,既定値をNoneにし,関数の中でNoneなら空リストを作ります.
明示的に渡されたリストを変更してよいかは,別に決める必要があります.この例では渡されたリストへ追加し,省略時だけ独立したリストを作る仕様です.
入力を常に保ちたい関数なら,さらにコピーが必要になります.
テストでは,省略した呼び出しを2回行うことが重要です.
1回だけなら共有の影響が見えません.また,引数を明示して渡した場合の振る舞いも確認します.
1つの修正で両方の使い方を保てるかを確かめてください.既定値を使う関数は,省略時と指定時の2つの入口を持つと考えると,テストを整理しやすくなります.
以下では,values=[]と書いた関数を省略して2回呼び,aとbが同じ入れ物かをisで調べます.
直し方は2段落目の説明を読んで自分で書いてください.この問題は後のクラスの__init__でも同じように起こります.
関数の基本として理解しておけば,クラス特有の不思議な現象として覚える必要はありません.
def add(value,values=[]):
values.append(value)
return values
a = add(1)
print("1回目の後", a)
b = add(2)
print("2回目の後", a, b)
print("同じ入れ物か", a is b)
引数省略時は毎回新しいリストを作り,明示されたリストにはそのまま追加する仕様へ修正します.2つの使い方を両方テストしてください.
調査22-13 座標の意味と,数値の単位を確かめる
数式として実行できても,値の意味が違えば正しい結果にはなりません.
例えば,図形の左下座標を中心座標として扱うと,形は正しくても位置がずれます.角度が度なのかラジアンなのか,長さが半径なのか直径なのかも同じ問題です.
変数の型だけでなく,その値が何を表すかを確認してください.
座標の不具合を調べるときは,まず原点,軸上,負の座標を含む簡単な例を使います.
複雑な絵の中だけで試すと,ずれがどこから来たか分かりにくくなります.四角形の左下が(10,20),辺が40なら,中心は(30,40)です.
このような手計算できる値を用意して,期待値を先に決めます.
変換する処理を関数へまとめると,複数の場所で異なる式を使うことを防げます.
left_to_centerのように変換の向きを名前で示すと,使う側が判断しやすくなります.すべてをcenterという曖昧な名前にすると,元の値なのか変換後なのかが分からなくなります.
名前が長ければよいわけではありませんが,異なる意味の値は区別します.
以下では左下と右上の座標を表示します.
中心は2点のちょうど中間なので,紙で(30,40)になることを確かめてから式を考えます.次の課題は,sizeを丸ごと足して右上を返してしまう故障です.
正常な例だけでなく,負の座標から始める場合も確かめてください.画面に収まるかどうかと計算が正しいかどうかは別なので,まず数値で変換の約束を検証します.
x,y,size = 10,20,40
print("左下", (x,y))
print("右上", (x+size,y+size))
print("中心は左下と右上の中間にある")
positionは左下,sizeは辺の長さという仕様で,中心座標を返すように修正します.同じ式を使う前に,紙で3例の中心を求めてからテストへ書いてください.
調査22-14 途中の値を,必要な場所だけ観察する
最終結果が違う場合,すべての行へprintを入れると,出力が多すぎてかえって読みにくくなります.
処理をいくつかの段階へ分け,まず各段階の入口と出口を確認します.選び出し,並べ替え,移動,集計という流れなら,どの段階から期待と違うかを探します.
違いが現れた段階へ調査を絞る方法です.
例えば,面積の合計が小さい場合,面積の式が間違っているのか,図形を選ぶ条件で取りこぼしたのかを分けます.
選ばれたIDが正しければ,次に個々の面積を調べます.先に式をいろいろ変えるのではなく,仮説を区別できる観察を選んでください.
調べたい疑問を1つ書いてからprintを置くと,目的のある調査になります.
観察用の出力にはラベルを付けます.
20,30,50だけが並んでいると,どの段階の値か分かりません.選択後,移動後,合計のような名前を一緒に表示すると,記録を後から読めます.
ただし,最終提出の出力仕様に余分な文字を含めたくない場合は,調査用の出力を整理してから完成とします.調査中と完成時の出力を区別します.
以下は偶数だけを選び,2乗して合計する処理です.
3段階の中間値を表示します.課題では選ぶ条件が間違っています.
合計式を変更する前に,最初の選択結果が仕様どおりかを確認してください.原因が別の場所にあるのに,最後の式へ補正値を足して合わせる修正を避けます.
values = [1,2,3,4]
selected = [x for x in values if x%2 == 0]
squared = [x*x for x in selected]
print("選択後",selected)
print("2乗後",squared)
print("合計",sum(squared))
偶数だけを選んで2乗和を求める仕様です.選択結果,2乗した値,合計を順に確認し,最初に違う段階だけを修正します.結果へ定数を足して合わせる方法は使いません.
調査22-15 具体例に加えて,前後の関係をテストする
すべての入力について期待値を手で書くことはできません.
そこで,具体的な入力と期待値に加え,操作の前後で成り立つ関係も調べます.平行移動なら図形の大きさと色は変わらず,ある移動の後に逆向きの移動をすれば元の位置へ戻ります.
拡大率2なら辺は2倍,面積は4倍です.仕様から分かる関係をテストへ使います.
この方法だけで正しさが証明されるわけではありません.
例えば,何も移動しない関数も,逆向きに動かして元へ戻るという確認だけなら通るかもしれません.そのため,(10,20)から(3,4)動かしたら(13,24)になる,という具体的な期待値も必要です.
異なる観点のテストを組み合わせて,不具合を見落としにくくします.
テストの期待値を,実装とまったく同じ式で求めることにも注意します.
移動の実装がx-dxと誤っていて,期待値もx-dxで計算したら,一致してしまいます.小さい例は手計算し,別の原理で確認できる関係を選ぶと,実装を写しただけのテストから離れられます.
テストが何を根拠に正しさを判断するかを説明してください.
以下では,dyの符号を間違えた関数でも,移動と逆移動を続けると元へ戻ってしまうことを観察します.
逆移動だけのテストでは符号の間違いを見逃すため,課題では具体的な結果も一緒に確認します.テストが成功したという事実だけでなく,どの不具合を検出できるテストかを考える練習です.
def move(position,dx,dy):
x,y = position
return x+dx,y-dy
start = (10,20)
result = move(start,3,4)
print("移動後", result, "期待", (13,24))
print("逆移動で戻るか", move(result,-3,-4) == start)
移動の符号を修正し,具体的な期待値,逆移動,移動量0の3つの観点で確認します.何もしない関数がどのテストを通ってしまうかも考えてください.
調査22-16 修正した不具合を,再発させない記録
不具合が直ったら,原因を再現した入力をテストとして残します.
後で別の機能を追加した際,同じ問題が戻っていないか確認するためです.以前成功していた振る舞いが壊れることを退行と呼び,それを調べるテストは回帰テストと呼ばれます.
難しい仕組みを導入しなくても,最初は小さなassertの集まりで始められます.
テストには,何を守るためのものか分かる名前や説明を付けます.
単にassertが20行あるより,空の入力,境界の内側,境界の外側,連続した対象,というように観点が分かるほうが,失敗した理由を調べやすくなります.条件を追加したときは,既存のテストを消して合わせる前に,仕様が変わったのか実装が壊れたのかを判断します.
調査記録は,次の人が再現できる短い報告にまとめます.
使用したコードの項目,入力,期待値,実測値,原因,修正,残したテストを記します.スクリーンショットだけでは入力や値が分からない場合があるため,具体的な数値も残します.
ただし,提出する文章を長くすること自体が目的ではありません.再現と判断に必要な情報がそろっているかが基準です.
例題は,ひな形と同じ判定で中央・境界・外側の結果を一覧表示します.
最後の課題では,図形の表示範囲を判定する関数を使います.境界を含む仕様を保ち,右端や上端だけでなく,すべての辺の外側も確認します.
修正前に失敗する入力を1つ挙げ,修正後にほかの入力も成功したことを報告してください.この流れを,自分の作品で起こった不具合にも適用できれば,今回の学習は完成です.
def inside(x,y):
return -100 < x < 100 and -80 < y < 80
for x,y in [(0,0),(100,0),(0,80),(101,0)]:
print((x,y), inside(x,y))
境界を含む範囲判定へ直し,中央,4辺上,4辺の外側の9点をテストしてください.最初に失敗した点,原因,修正,ほかに確かめた点をノートへ短くまとめます.
22.9 空・1個・複数を確かめる
def total_area(sizes):
return sum(size * size for size in sizes if size > 0)
assert total_area([]) == 0
assert total_area([10]) == 100
assert total_area([10, 20]) == 500
assert total_area([0, -10, 20]) == 400
print("面積のテスト4件成功")
| 症状 | 最初に調べること |
|---|---|
| IndexError | 添字と長さ.空の入力を想定したか |
| KeyError | 辞書に必要なキーがあるか |
| 絵が違うがエラーはない | 計算値・描画順・座標・角度を別々に確かめる |
| 別の図形まで変わる | 同じオブジェクトを共有していないか |
提出時は,失敗を再現する入力・期待値・実測値・原因・修正・再テストの6点を説明します.操作回数や実行回数の多さだけで理解の深さを判断しないようにしましょう.次章では独立した状態を持つクラスを作ります.
提出前には,直したコードを最初から実行し直します.調査中に作った変数や,手作業で変更したデータが残っていると,途中からの実行だけが成功することがあります.入力と初期状態をそろえ,必要な手順をコードに含めた状態で確認してください.
振り返りでは,試行回数の多さを成果にするのではなく,どの観察で候補を絞れたかを説明します.たとえば『選択後のIDがすでに違っていたので,面積の式を変えずに条件を調べた』と書けば,判断の根拠が伝わります.次に同じ種類の問題が起きたとき,最初に何を確かめるかも1行残しましょう.
修正後の再実行には,調査中に失敗した入力を必ず含めてください.別の入力で動いただけでは,同じ不具合が直ったとは確認できません.
復習:虫食いで確かめる
コードの ____ を埋めてください.実行前に結果を予測し,実行後に問題文の条件と照合しましょう.assert は条件が成り立つかを調べ,成り立たないとエラーになります.
x が100以上なら折り返します.境界ちょうどの100でも dx が -5 になる条件を補ってください.
x = 100
dx = 5
if x ____ 100:
dx = -dx
assert dx == -5
print(dx)
一つの行を変更しても別の行へ影響しない二次元リストを作ります.二行目が [0, 0] のままか確認してください.
grid = [[0, 0] for i in ____] grid[0][0] = 1 assert grid[1] == [0, 0] print(grid)
反復中に元のリストから削除せず,正の値だけの新しいリストを作ります.[2, 3] になる条件を補ってください.
values = [0, -1, 2, -2, 3] remaining = [v for v in values if ____] assert remaining == [2, 3] print(remaining)
二乗を返す関数について,負の入力を検証します.square(-3) の期待値を補い,assert を実行してください.
def square(n):
return n * n
assert square(-3) == ____
print("負の入力も確認しました")
位置がリストの図形を deepcopy でコピーします.移動後も元の位置が [0, 0] のままになる命令を補ってください.
from copy import ____
original = {"position": [0, 0]}
moved = deepcopy(original)
moved["position"][0] = 50
assert original["position"] == [0, 0]
print(original, moved)
大きさ0を拒否する関数を検証します.検証成功時に「拒否できました」と表示し,拒否されなければ assert で失敗するようにしてください.
def check_size(size):
if size <= 0:
raise ValueError("大きさは正")
try:
check_size(0)
except ____:
print("拒否できました")
else:
assert ____, "0を拒否していません"