Pythonで始めるREPL駆動開発
(IDLE Shell/Editor)

  • PythonのIDLEを使い、EditorとShellの2画面でコードを書きながら即座に動作確認できるREPL駆動開発を紹介しています。
  • 関数を小さな単位で作ってShellで呼び出す手順を繰り返すことで、実装の勘違いを早い段階で発見できます。
  • io.StringIOでsys.stdinを差し替えることで、テスト用入力を埋め込んだままF5で実行でき、提出時はそのブロックを削除するだけです。

関連記事

1. 対話環境でコードを作る

プログラムを書いていて、「この関数、本当に正しく動いているか」と気になったとき、どうしますか。
print デバッグを仕込んでファイルを実行して、ログを見て……という手順は、小さな勘違いを確認するにも時間がかかります。

対話環境でコードを作る 従来の手順 printデバッグを仕込む ファイルを実行 ログを追う 時間がかかる REPL駆動開発 書く→試す 即座に確認できる

Common Lispの開発環境SLIMEには、関数を書いたらその場でコンパイルして呼び出し、結果をすぐ見られる対話的な流れがあります1
これまで、REPLはコード学習用のツールだと思っていましたが、LispでのREPL駆動開発の良さに驚きました。
REPL駆動開発は Lisp 以外のプログラミング言語でも使える考え方だと思います。

1. 対話環境でコードを作る

そこで、Pythonにも IDLE という対話環境が同梱されていることを思い出しました。
IDLEはIntegrated Development and Learning Environmentの略で、インストールすれば追加設定なしで使えます2
かんたんな問題を解きながら、IDLEで「関数を書いてすぐ試す」開発の流れを紹介します。

1.1. 題材の問題

次の問題を解きながら進めます。

整数 N と、N 個の整数からなる数列 A が与えられる。
数列の中で、隣り合う2要素の差の絶対値が K 以下であるような連続部分列のうち、最長のものの長さを求めよ。

入力形式:

N K
A_1 A_2 ... A_N

たとえば入力が

7 3
1 2 4 7 5 6 9Code language: Python (python)

であれば、1 2 4 7 は差が最大3なので条件を満たします。
7 5 6 975 の差が2、56 が1、69 が3で条件を満たします。
答えは4です。

2. IDLEの2枚構成

REPL駆動開発では、、最初からいきなり問題を解こうとするのではなく、部品ごとに作ってIDLEで確かめながら進めます。

IDLEの2枚構成 Editor def solve(N, K, A): lengths = run_lengths return max(lengths) def run_lengths(A, K): current = 1 for i in range(…): 関数を書く Shell (REPL) >>> solve(7, 2, A) 3 >>> run_lengths(A, 2) [3, 1, 2, 1] >>> is_adjacent_ok(7,5,3) True >>> 即座に呼ぶ F5 修正

PythonのIDLEを起動すると、Shellウィンドウが開きます。
ここがREPL、つまり入力した式をその場で評価して結果を返す対話環境です。
さらに、File → New File でEditorウィンドウを開いて、.py ファイルを編集できます3

ShellとEdirotrの2つの画面を使って開発をしていきます。

  1. Editorで関数を書く
  2. F5(Run Module)で実行する
  3. Shellに定義が読み込まれる
  4. Shellで関数を手で呼び出して結果を確かめる
  5. Editorで修正して、F5 でまた読み込む

F5 を押すたびにShellの環境がリセットされて最新の定義が入ります。
「書いて試す」の循環を短く回せます。

2.1. ステップ1:隣接要素の差を確認する関数を作る

2要素間の差がK以下かどうかを判定する関数を作ります。

def is_adjacent_ok(a, b, K):
    return abs(a - b) <= KCode language: Python (python)

Shellで確かめます。

>>> is_adjacent_ok(7, 5, 3)
True
>>> is_adjacent_ok(6, 9, 3)
True
>>> is_adjacent_ok(7, 3, 3)
FalseCode language: Python (python)

73 の差は4なのでFalseになるはずで、合っています。
この小さな確認をしておくと、後で結果がおかしくなったときに「判定関数の問題ではない」と切り分けられます。

2.2. ステップ2:連続部分列の長さを数える関数を作る

条件を満たす連続部分列の長さをすべて列挙する関数を作ります。

def run_lengths(A, K):
    if not A:
        return []
    lengths = []
    current = 1
    for i in range(1, len(A)):
        if is_adjacent_ok(A[i-1], A[i], K):
            current += 1
        else:
            lengths.append(current)
            current = 1
    lengths.append(current)
    return lengthsCode language: Python (python)

Shellで呼び出します。

>>> run_lengths([1, 2, 4, 7, 5, 6, 9], 3)
[4, 3]Code language: Python (python)

1 2 4 7 が長さ4、 5 6 9 が長さ3という結果です。
ところで 75 の差は2なので、7 5 6 9 はひとつながりになるはずです。
is_adjacent_ok で確かめます。

>>> is_adjacent_ok(7, 5, 3)
TrueCode language: Python (python)

やはりTrue。
run_lengths の実装を見直してもインデックスの境界処理は問題なさそうなので、実際に手で差を追ってみます。

>>> A = [1, 2, 4, 7, 5, 6, 9]
>>> [(A[i-1], A[i], abs(A[i-1]-A[i])) for i in range(1, len(A))]
[(1, 2, 1), (2, 4, 2), (4, 7, 3), (7, 5, 2), (5, 6, 1), (6, 9, 3)]

[(A[i-1], A[i], abs(A[i-1]-A[i])) for i in range(1, len(A))] はリスト内包表記と呼ばれる書き方で、ループを1行で書いてリストを返します4

差がすべて3以下なので、7要素すべてがひとつながりになります。
答えは7です。
問題の例として用意した入力が間違っていました。
正しい例に直します。

7 2
1 2 4 7 5 6 9Code language: Python (python)

K=2 にすると 47 の差が3なので切れます。

>>> run_lengths([1, 2, 4, 7, 5, 6, 9], 2)
[3, 1, 3]Code language: Python (python)

1 2 4 が長さ3、7 が長さ1、5 6 9 ——ただし 69 の差は3なのでK=2では切れて、5 6 が長さ2、9 が長さ1になるはずです。
F5 で再実行してから確かめます。

>>> run_lengths([1, 2, 4, 7, 5, 6, 9], 2)
[3, 1, 2, 1]Code language: Python (python)

K=2での正しい動作になりました。
Shellで細かく試しながら進めると、問題設定の勘違いも含めて早い段階で気づけます。

2.3. ステップ3:最長を返す関数を作る

def solve(N, K, A):
    lengths = run_lengths(A, K)
    return max(lengths)Code language: Python (python)

Shellで確かめます。

>>> solve(7, 2, [1, 2, 4, 7, 5, 6, 9])
3Code language: Python (python)

K=2での最長は3です。

3. テストと提出を切り替える

部品となる関数ができたら、最後に main() を書いて全体をつなぎます。

テストと提出を切り替える テスト時 import sys, io # stdin を差し替え sys.stdin = io.StringIO( “7 2\n1 2 4 7 5 6 9” ) main() F5で即確認 提出時 # このブロックを削除 sys.stdin = … main() → 標準入力から動作 削除のみで完了
def main():
    N, K = map(int, input().split())
    A = list(map(int, input().split()))
    print(solve(N, K, A))Code language: Python (python)

テスト時はファイルの末尾に sys.stdin の差し替えと main() の呼び出しを書いておきます。
io.StringIO は文字列をファイルのように読む仕組みで、input() が内部で参照する sys.stdin をここで差し替えると、複数行の入力を丸ごと流し込めます5

import sys
import io

sys.stdin = io.StringIO("""\
7 2
1 2 4 7 5 6 9
""")
main()Code language: PHP (php)

これをF5で実行すると、Shellに結果が表示されます。

3Code language: Python (python)

別の入力例でも確かめたいときは、io.StringIO の中身を書き換えてF5を押し直します。
提出時はこのブロックを削除するだけで、main() はそのまま標準入力から読む動作になります。

3.1. この流れで得られるもの

「関数を書いてすぐ呼ぶ」という循環を短く回せると、勘違いに気づくタイミングが早くなります。
今回の例でも、問題設定のKの値を間違えたことをShellでの確認中に発見しました。
ファイルを実行してログを追っていたら、もう少し遅れて気づいたはずです。

SLIMEほどシームレスではありませんが、IDLEのEditorとShellを使い分けるだけで、「書いて試す」の手触りはかなり変わります。
Python環境が入っていればすぐ使えます。

3.2. 【補足】モジュールを修正して再読み込みする(importlib.reload())

F5 を押すたびにShellがリセットされます。
も、リセットせずに部分的に再読み込みしたい場合は importlib.reload() を使います6
関数を少しずつ直しながら試す場合に便利です7

別ファイル solver.py として保存し、Shellから次のように操作できます。

>>> import solver
>>> solver.solve(7, 2, [1, 2, 4, 7, 5, 6, 9])
3Code language: Python (python)

solver.py を編集して保存したあと、

>>> import importlib
>>> importlib.reload(solver)
>>> solver.solve(...)Code language: CSS (css)

とすれば、Shellをリセットせずに最新の定義に切り替えられます。

  1. SLIMEはSuperior Lisp Interaction Mode for Emacsの略で、EmacsとCommon Lisp処理系をSwankというサーバーを介して接続し、エディタから関数単位でコンパイル・評価できる開発環境です。2003年から開発が続いており、SBCLをはじめ主要なCommon Lisp実装に対応しています。 – SLIME: The Superior Lisp Interaction Mode for Emacs
  2. IDLEという名称は略語としての意味に加え、Pythonという言語名がコメディグループMonty Pythonに由来するのと同様に、メンバーのEric Idleへの言及を含んでいるとされています。 – IDLE – Wikipedia
  3. IDLEはPython標準ライブラリのtkinterを使って実装されており、追加インストールなしでWindows・macOS・Linuxで動作します。ただしLinuxでは別途パッケージのインストールが必要な場合があります(例:sudo apt install idle-python3.x)。 – IDLE — Python 3 documentation
  4. リスト内包表記(list comprehension)はPythonの構文で、[式 for 変数 in イテラブル] の形で書きます。for ループで書けば複数行になる処理を1行で表現できます。 – List Comprehensions — Python 3 documentation
  5. io.StringIO はPython標準ライブラリ io モジュールに含まれており、追加インストールは不要です。文字列をファイルオブジェクトと同じインターフェースで扱えるため、input()sys.stdin.readline() がそのまま使えます。 – io — Core tools for working with streams
  6. Pythonの import 文は、同じモジュールを2回以上 import しても初回しか実行されません。これはパフォーマンス上の設計で、sys.modules にキャッシュされた結果が返されます。変更を反映するには importlib.reload() が必要です。 – The import system — Python 3 documentation
  7. importlib.reload() はサブモジュールを自動的には再読み込みしません。また from solver import solve のように名前を直接インポートしていた場合、reload 後も古い関数が参照されたままになります。reload後は solver.solve の形で参照するか、改めて from solver import solve を実行する必要があります。 – importlib — The implementation of import