演習形式で学ぶPythonプログラミング vol.9 ~ while によるループ処理~

# まえがき
Python3.13環境を想定した演習問題を扱っています。変数からオブジェクト指向まできちんと扱います。続編として、データサイエンティストのためのPython(NumPyやpandasのような定番ライブラリ編)や統計学・統計解析・機械学習・深層学習・SQLをはじめCS全般の内容も扱う予定です。

対象者は、[1]を一通り読み終えた人、またそれに準ずる知識を持つ人とします。ただし初心者の方も、文法学習と同時並行で進めることで、文法やPythonそれ自体への理解が深まるような問題を選びました。前者については、答えを見るために考えてみることをお勧めしますが、後者については、自身と手元にある文法書を読みながら答えを考えるというスタイルも良いと思います。章分けについては[1]および[2]を参考にしました。

ストックしている問題のみすべて公開するのもありかなと考えたのですが、あくまで記事として読んでもらうことを主眼に置き、1記事1トピック上限5問に解答解説を付与するという方針で進めます。すべての問題を提供しない分、自分のストックの中から、特に読むに値するものを選んだつもりです。
また内容は、ただ文法をそのままコーディング問題にするのではなく、文法を俯瞰してなおかつ実務でも応用できるものを目指します。前者については世の中にあふれていますし、それらはすでに完成されたものです(例えばPython Vtuberサプーさんの動画[3]など)。これらを解くことによって得られるものも多いですし、そのような学習もとても有効ですが、私が選んだものからも得られるものは多いと考えます。
私自身がPython初心者ですので、誤りや改善点などがあればご指摘いただけると幸いです。

今回から「`for` / `while` によるループ処理」について扱います。それでは始めましょう！

# Q.9-1
##### 条件が関数呼び出しに依存しており、返値が都度変化する場合の `while` ループ設計上の注意点を述べよ。

### 問題背景
#### 【1】 `while` ループと関数呼び出し
Pythonの `while` ループでは、条件が `True` の間、繰り返し処理を行う。
```python
while condition():
    処理
```
この `condition()` が関数呼び出しであり、毎回実行されるたびに値が変わる場合、設計上の注意が必要になる。

#### 【2】 関数の副作用と非設計性
関数が毎回異なる値を返す原因として、
・乱数
・ユーザー入力
・センサー値/APIなどの外部情報
・内部状態の変化
などが挙げられる。これにより、ループの実行回数や修了条件が予測しづらくなる。

#### 【3】 設計上の注意点
| 注意点                        | 説明                                                     |
|---------------------------------|----------------------------------------------------------|
| 関数の返り値を一時変数に保持     | 同じ関数を複数回呼ぶと値が変わる場合、1回だけ呼び、一時変数に代入する |
| 不変条件のキャッシュ           | 状態が変わらない条件はループ外で計算する                     |
| ループの終了保証               | 無限ループにならないよう、別途カウンタやタイムアウトを設ける     |
| 副作用のない関数を使う         | 可能なら純粋関数（同じ入力 → 同じ出力）にする                |

### 解答例とコードによる実践
例えば以下のようなコードは設計上の問題がある。

```python
import random

# 【問題点】この関数は毎回異なる結果を返す
def random_condition():
    return random.randint(0, 1) == 0

while random_condition():
    print("ループ中...")
```

この場合、`random_condition()` が毎回実行されるたびに返値が変わるため、意図せずすぐ終わる/永遠に続く可能性がある。
この場合、以下のようなコードに書き換えることが出来る。
```python
# 【1】乱数を使って終了条件を判定する関数を定義
import random

def random_condition():
    # 【2】0 または 1 をランダムに返す（0 のとき終了する）
    return random.randint(0, 1) == 0

# 【3】ループ回数制限を設ける（安全設計）
MAX_TRIALS = 10

# 【4】ループカウンタを初期化
count = 0

# 【5】無限ループを開始
while True:
    # 【6】ループ回数制限に達した場合は終了
    if count >= MAX_TRIALS:
        print("最大試行回数に達したため終了します。")
        break  # 【7】無限ループを防ぐ

    # 【8】関数を1回だけ呼び出して結果を保持する（都度変化するため）
    should_continue = random_condition()

    # 【9】関数の返り値に応じて終了判定
    if not should_continue:
        print("条件が False になったため終了します。")
        break  # 【10】適切に break できる

    # 【11】継続の場合の処理
    print(f"試行 {count + 1}: 条件を満たしたので続行します。")

    # 【12】カウンタを増やす
    count += 1
```
```python
# 実行結果
試行 1: 条件を満たしたので続行します。
試行 2: 条件を満たしたので続行します。
条件が False になったため終了します。
```
または
```python
# 実行結果
試行 1: 条件を満たしたので続行します。
試行 2: 条件を満たしたので続行します。
試行 3: 条件を満たしたので続行します。
...
最大試行回数に達したため終了します。
```

本問題の注意点をまとめると以下。
| 注意点                        | 説明                                                     |
|---------------------------------|----------------------------------------------------------|
| 関数の返り値を一時変数に保持     | 同じ関数を複数回呼ぶと値が変わる場合、1回だけ呼び、一時変数に代入する |
| 不変条件のキャッシュ           | 状態が変わらない条件はループ外で計算する                     |
| ループの終了保証               | 無限ループにならないよう、別途カウンタやタイムアウトを設ける     |
| 副作用のない関数を使う         | 可能なら純粋関数（同じ入力 → 同じ出力）にする                |

# Q.9-2
##### `while` 内部で `break` されることを前提とした `else` 節の使い方と、その誤解による挙動のずれを例示せよ。

### 問題背景
#### 【1】`while-else` 構文の基本
Pythonの `while` には、ループが `break` されずに正常終了した場合にのみ実行される `else` 節が存在する
```python
while 条件:
    処理
    if 条件2:
        break
else:
    break が一度も実行されなかった場合にここが実行される
```

#### 【2】 誤解されがちなポイント
【1】の挙動は、`for` ループにも共通だが、他言語にはあまりないため誤解されやすい。具体的には、
・「`else` はループ条件が `False` になったときにだけ動く」
というのは誤り。「`break` が発動しなかったときに動作する」が正しく、`break` があると、例えループ条件が残っていても `else` はスキップされる。

以下の例であれば、
```python
x = 0
while x < 3:
    user_input = input("数字を入力してください (5 で終了): ")
    if user_input == '5':
        print("5 が入力されたので終了します。")
        break
    x += 1
else:
    print("3回試行したが 5 は入力されなかった。")
```
「3回ループし終わったら、必ず `else` が動く」と思い込むと間違い。

正しい動作パターンは以下
| 状況                               | else は実行されるか | 理由                        |
|------------------------------------|--------------------|----------------------------|
| 途中で `break` された               | されない           | `break` が発動したため      |
| `break` されずに条件が `False` になった | 実行される         | 正常終了（`break` なし）     |

### 解答例とコードによる実践
```python
# 【1】カウンタ変数を初期化
x = 0

# 【2】3回まで繰り返すループ
while x < 3:
    # 【3】ユーザーから数字の入力を受け取る
    user_input = input("数字を入力してください (5 で終了): ")
    
    # 【4】入力が '5' なら終了する
    if user_input == '5':
        print("5 が入力されたので終了します。")  # 【5】break による終了
        break

    # 【6】カウンタを増やす
    x += 1

# 【7】else 節（break されなかった場合のみ実行される）
else:
    print("3回試行したが 5 は入力されなかった。")  # 【8】条件が False で正常終了
```
```python
# 【実行結果①】5 を入力した場合
数字を入力してください (5 で終了): 3
数字を入力してください (5 で終了): 4
数字を入力してください (5 で終了): 5
5 が入力されたので終了します。

# 【実行結果②】5 を入力しなかった場合：
数字を入力してください (5 で終了): 1
数字を入力してください (5 で終了): 2
数字を入力してください (5 で終了): 3
3回試行したが 5 は入力されなかった。
```

# Q.9-3
##### `while` 条件式に `any()` や `all()` を用いて複雑な終了条件を指定した際、評価の短絡(ショートサーキット)が安全性に影響する例を示せ。

### 問題背景
#### 【1】 `any()` と `all()` の基本動作
`any()` と `all()` は、短絡評価を行う関数

| 関数        | 動作                               | 短絡評価の動作                                 |
|-------------|------------------------------------|------------------------------------------------|
| `any()`     | 1つでも `True` なら `True`（OR 的動作） | 先頭が `True` なら以降は評価されない             |
| `all()`     | 全て `True` なら `True`（AND 的動作）  | 先頭が `False` なら以降は評価されない            |

これにより副作用のある関数が評価されないことがあり、設計によっては安全性に影響する。

#### 【2】 具体的な危険例(副作用を伴う関数)
たとえば「リソースを確保する処理」が含まれていて、
その関数が評価されないと「確保すべきだったリソースが確保されない」などの問題が起きる。

### 解答例とコードによる実践
#### 【A】 良くないコード(短絡評価による副作用のスキップ)
```python
import time

# 【1】副作用を持つ関数（ログを記録する）
def log_check():
    # 【2】この関数は必ずログ出力したい
    print("log_check が呼び出されました")
    return False

# 【3】普通の条件関数
def always_true():
    print("always_true が呼び出されました")
    return True

# 【4】ループカウンタ
count = 0

# 【5】無限ループ（終了条件に any を使用）
while any([always_true(), log_check()]):  # 【6】1つ目が True なので log_check() が呼ばれない！
    print(f"ループ {count + 1} 回目")
    time.sleep(1)
    count += 1
    if count >= 2:
        break
```
```python
always_true が呼び出されました
ループ 1 回目
always_true が呼び出されました
ループ 2 回目
```
上記のコードでは、`log_check が呼び出されました` が出ない(評価されていない)。
具体的には、
 `any()` に複数の関数を入れており、そのうち1つ目で `True` になると2つ目の関数が評価されない。
このとき、もし2つ目の関数に「副作用（ログ記録や状態変更など）」があると、その副作用が実行されず、安全性を損なう。

#### 【B】修正コード(副作用関数をループ外で必ず実行)
```python
import time

def log_check():
    print("log_check が呼び出されました")
    return False

def always_true():
    print("always_true が呼び出されました")
    return True

count = 0

while True:
    # 【1】先に副作用のある処理を実行しておく
    log_result = log_check()

    # 【2】必要な条件を any で判定
    if not any([always_true(), log_result]):
        break

    # 【3】ループ処理
    print(f"ループ {count + 1} 回目")
    time.sleep(1)
    count += 1
    if count >= 2:
        break
```
```python
# 実行結果
log_check が呼び出されました
always_true が呼び出されました
ループ 1 回目
log_check が呼び出されました
always_true が呼び出されました
ループ 2 回目
```
→ `log_check()` も正しく毎回実行される。

# Q.9-4
##### `while True:` 内部に複数の `break` 条件がある場合、`logging` を使ってそれぞれの脱出理由を明示する実装を記述せよ。

### 問題背景
#### `logging` モジュール
Pythonの標準ライブラリで、実行中の情報を記録できる。`print()` よりも柔軟で、ログレベル(`INFO`, `WARNING`, `ERROR` など)を設定できる。ログをファイルに出力する機能も実装可能。
```python
import logging
logging.info("メッセージ")
```

`break` が複数ある場合、「どの条件でループを抜けたか」を把握することは重要で、`logging` によってその情報を記録できる。

### 解答例とコードによる実践
```python
count = 0
while True:
    user_input = input("何か入力してください (exit で終了): ")
    count += 1
    if user_input == "exit":
        break
    if count >= 5:
        break
```
上記のコードでは、
・ `exit` が入力されたら終了
・ 5回ループしたら終了
の2種類の `break` 条件がある。しかしこの場合、なぜ終了したのかが分かりづらい。

#### 模範解答コードは以下の通り
```python
# 【1】logging モジュールを読み込む
import logging

# 【2】ログの出力設定を行う（INFO レベル以上を表示）
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")

# 【3】ループカウンタを初期化
count = 0

# 【4】無限ループを開始
while True:
    # 【5】ユーザー入力を受け取る
    user_input = input("何か入力してください (exit で終了): ")
    # 【6】カウンタを1増やす
    count += 1

    # 【7】脱出条件1：exit と入力された場合
    if user_input == "exit":
        # 【8】この理由で break することをログに記録
        logging.info("ユーザーが 'exit' を入力したためループを終了します。")
        break  # 【9】ループ終了

    # 【10】脱出条件2：5回繰り返した場合
    if count >= 5:
        # 【11】この理由で break することをログに記録
        logging.info("入力回数が5回に達したためループを終了します。")
        break  # 【12】ループ終了

    # 【13】入力値を表示（ここは確認用）
    print(f"入力内容: {user_input}")

# 【14】ループ終了後の処理
print("プログラムを終了しました。")
```
```python
# 実行結果
何か入力してください (exit で終了): hello
入力内容: hello
何か入力してください (exit で終了): world
入力内容: world
何か入力してください (exit で終了): exit
2024-05-01 12:34:56,789 - INFO - ユーザーが 'exit' を入力したためループを終了します。
プログラムを終了しました。
```
```python
# 実行結果(5回入力した場合)
何か入力してください (exit で終了): a
入力内容: a
何か入力してください (exit で終了): b
入力内容: b
何か入力してください (exit で終了): c
入力内容: c
何か入力してください (exit で終了): d
入力内容: d
何か入力してください (exit で終了): e
2024-05-01 12:34:56,789 - INFO - 入力回数が5回に達したためループを終了します。
プログラムを終了しました。
```

## 【補足】
### 【1】 ログファイル出力
`logging` モジュールでは、標準出力(コンソール)のみでなく、ファイルに記録する設定が可能。

```python
import logging

# 【1】ログファイルに出力する設定
logging.basicConfig(
    filename="loop.log",                # 【2】出力ファイル名
    level=logging.INFO,                 # 【3】ログレベル
    format="%(asctime)s - %(levelname)s - %(message)s",  # 【4】ログフォーマット
    filemode='w'                        # 【5】'w' で上書き、'a' で追記
)

# 【6】ログ出力テスト
logging.info("プログラムが開始されました。")
```
```
# 実行結果
2024-05-01 15:00:12,345 - INFO - プログラムが開始されました。
```
下に注意するとよい。
| 設定項目               | 説明                                  |
|------------------------|---------------------------------------|
| `filename="loop.log"`   | 出力先ファイルを指定                 |
| `filemode='w'`          | 毎回上書き（`'a'` にすると追記）     |
| `level=logging.INFO`    | 出力する最低レベルを指定              |

### 【2】 複雑な複合条件でのログ設計
複数の条件が組み合わさって `break` する場合、
「どの条件が成立したか」「何番目の条件で止まったか」を記録すると、デバッグや保守が容易。

```python
import logging

# 【1】ログファイル出力の設定
logging.basicConfig(
    filename="complex_loop.log",
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    filemode='w'
)

# 【2】終了条件に使う関数
def is_user_exit(user_input):
    return user_input == "exit"

def is_too_many_trials(count):
    return count >= 5

def is_forbidden_word(user_input):
    return user_input in ["forbidden", "banned"]

# 【3】ループカウンタ
count = 0

# 【4】無限ループ
while True:
    user_input = input("何か入力してください (exit で終了): ")
    count += 1

    # 【5】複雑な条件ごとにログを記録
    if is_user_exit(user_input):
        logging.info("終了条件1: 'exit' が入力されたため終了。")
        break
    elif is_forbidden_word(user_input):
        logging.warning(f"終了条件2: 禁止ワード '{user_input}' が入力されたため強制終了。")
        break
    elif is_too_many_trials(count):
        logging.info("終了条件3: 入力回数が5回に達したため終了。")
        break
    else:
        logging.debug(f"試行 {count} 回目: 入力 '{user_input}'")

print("プログラムを終了しました。")
```
```python
# 実行結果
2024-05-01 15:05:12,789 - INFO - 終了条件1: 'exit' が入力されたため終了。
```
```python
# 実行結果
2024-05-01 15:07:34,123 - WARNING - 終了条件2: 禁止ワード 'forbidden' が入力されたため強制終了。
```

設計上のポイントは以下
| ポイント                       | 内容                                             |
|----------------------------------|--------------------------------------------------|
| 条件ごとにメッセージを変える     | どの条件が成立したかを明示する                   |
| ログレベルを使い分ける           | 正常系は `INFO`、異常系は `WARNING` や `ERROR`   |
| 必要なら入力内容もログに残す    | 後から原因調査ができるようにする                 |


# Q.9-5
##### `while True:` の中で `yield` を用いたジェネレータを構築し、反復処理として再利用可能な設計を提示せよ。

### 問題背景
#### 【1】 ジェネレーター(generator)とは
`yield` を用いて一時停止できる関数を作ることのできる仕組み。呼び出すたびに次の値を返す「イテレータ(反復可能オブジェクト)」を生成する。

```python
def count_up():
    n = 0
    while True:
        yield n
        n += 1
```

特に、ジェネレータを関数として定義しておくと、
・毎回新しいイテレータを生成できる
・必要な場面で繰り返し使える
というメリットがある。

#### 【2】 `while True:` の意味
無限ループだが、`yield` によって1回ごとに処理が中断されるため、必要なタイミングで1つずつ値を取り出せる。

### 問題解説
#### ユーザー入力を無限に受け取るジェネレータ
```python
# 【1】ジェネレータ関数を定義
def user_input_generator():
    # 【2】無限ループで毎回 yield する
    while True:
        # 【3】ユーザー入力を受け取る
        user_input = input("何か入力してください ('exit' で終了): ")

        # 【4】終了条件（入力が 'exit' の場合）
        if user_input == 'exit':
            print("終了します。")
            return  # 【5】ジェネレータ終了（StopIteration 例外が内部的に発生）

        # 【6】入力された内容を yield で返す
        yield user_input

# 【7】ジェネレータを作成
gen = user_input_generator()

# 【8】反復処理（1つずつ next() で取り出す）
for value in gen:
    # 【9】取得した値を表示
    print(f"入力内容: {value}")
```
```python
# 実行結果
何か入力してください ('exit' で終了): hello
入力内容: hello
何か入力してください ('exit' で終了): world
入力内容: world
何か入力してください ('exit' で終了): exit
終了します。
```

#### Pythonicな設計ポイント
| 工夫点                     | 内容                                      |
|----------------------------|-------------------------------------------|
| `yield` で一時停止可能      | 毎回一つずつ値を返す                     |
| ジェネレータ関数として設計   | 再利用可能・メモリ効率が良い              |
| `return` で終了時明示       | `StopIteration` によって終了が伝わる      |

#### ジェネレータの使いどころ
| 適したケース                | 理由                                     |
|----------------------------|------------------------------------------|
| ユーザー入力の逐次処理      | 必要な分だけ取り出せる                   |
| 無限列（自然数列など）      | メモリを消費しない                      |
| 大規模ファイルの逐行読み込み| メモリ負荷を抑えつつ処理できる           |

## 【補足】
### 【1】 ジェネレータとイテレータの違い

| 項目           | イテレータ                                      | ジェネレータ                                           |
|----------------|-------------------------------------------------|--------------------------------------------------------|
| 何か           | `__iter__()` と `__next__()` を持つオブジェクト   | `yield` を使って一時停止する関数が返すオブジェクト      |
| 作り方         | クラスで `__iter__()` / `__next__()` を定義       | `yield` を使った関数 (`def` で作る)                     |
| 実装の手間     | やや多い（メソッドをすべて定義する必要）          | 簡単（`yield` で中断と再開ができる）                    |
| メモリ効率     | 要素を一つずつ返すので効率的                     | 同じく効率的                                           |
| 再利用性       | イテレータは1回使い切り                          | ジェネレータも基本は1回使い切り（再生成可）             |

#### イテレーターの例(クラス実装)
```python
# 【1】カスタムイテレータを作成するクラス
class CounterIterator:
    def __init__(self, limit):
        self.current = 0
        self.limit = limit

    def __iter__(self):
        return self  # 自身がイテレータ

    def __next__(self):
        if self.current < self.limit:
            value = self.current
            self.current += 1
            return value
        else:
            raise StopIteration  # 終了

# 【2】使用例
counter = CounterIterator(3)
for num in counter:
    print(num)
```
```python
# 実行結果
0
1
2
```

これをジェネレーターで書くと
```python
# 【1】ジェネレータ関数でカウンタを作成
def counter_generator(limit):
    n = 0
    while n < limit:
        yield n  # 【2】yield で一時停止
        n += 1

# 【3】使用例
for num in counter_generator(3):
    print(num)
```
```python
# 実行結果
0
1
2
```

### 【2】`send()` を用いた値の送り込み
`send(value)` で次の `yield` に値を渡せる。通常の `next()` は `send(None)` と等価。

#### `send()` の使用例
```python
# 【1】ジェネレータ関数で送られた値を受け取る
def echo():
    while True:
        # 【2】yield の左側で受け取る
        received = yield
        print(f"受け取った値: {received}")

# 【3】ジェネレータ生成
gen = echo()

# 【4】最初は next() または send(None) で起動する必要がある
next(gen)  # または gen.send(None)

# 【5】send() で値を送り込む
gen.send("hello")
gen.send(42)
```
```python
# 実行結果
受け取った値: hello
受け取った値: 42
```

#### `send()` の実用的な使い方(状態更新)
```python
def accumulator():
    total = 0
    while True:
        value = yield total  # 【1】前回までの total を返しつつ、次の値を受け取る
        if value is not None:
            total += value  # 【2】受け取った値を加算する

# 【3】ジェネレータ作成と起動
gen = accumulator()
next(gen)  # 最初に起動

# 【4】値を送って加算
print(gen.send(10))  # 10
print(gen.send(5))   # 15
print(gen.send(20))  # 35
```
```python
10
15
35
```
→ 状態(合計値)を維持しながら外から値を渡せる

# Q.9-6
##### `while True:` の中で、条件判定を伴う if による break 条件が設計的に分かりづらくなっているコードを改善せよ。

### 問題背景
#### 【1】 `while True:` と `break` の関係
`while True:` は「無限ループ」を意味するが、`break` によって明示的にループを終了できる。
```python
while True:
    if 条件:
        break
```
しかし、`break` 条件がループ内部の深いところに埋もれると可読性が低くなり、また複雑な条件式が `if` に直接書かれていると理解が難しい、という問題がある。
この場合、
・ 終了条件を先に明示する(ガード節)
・「続ける」場合の処理を奥に押し込む
・ ループ条件自体に終了条件を組み込められればベスト(可能なら `while` の条件式にする)

以下は悪い設計例である。
```python
# 【1】無限ループ
while True:
    user_input = input("数値を入力してください (0 で終了): ")
    if user_input == "":
        continue  # 空入力はスキップ
    if int(user_input) == 0:  # 【2】終了条件が途中にある
        break
    print(f"入力された数値: {user_input}")
```
これでは、「何を終了するのか」が直ぐには分からない。

### 解答例とコードによる実践
```python
# 【1】終了条件判定用の関数を用意（意味が明確になる）
def is_exit_condition(user_input):
    # 【2】0 が入力されたとき終了する
    return user_input != "" and int(user_input) == 0

# 【3】無限ループを開始
while True:
    # 【4】ユーザーに入力を求める
    user_input = input("数値を入力してください (0 で終了): ")

    # 【5】空入力の場合は再入力を促す
    if user_input == "":
        print("空入力です。もう一度入力してください。")
        continue  # 【6】空入力時は次のループへ

    # 【7】終了条件を関数で判定し、明示的に終了処理を書く
    if is_exit_condition(user_input):
        print("0 が入力されたため、終了します。")
        break  # 【8】ここでループを終了

    # 【9】通常の処理（入力を表示）
    print(f"入力された数値: {user_input}")
```
```python
数値を入力してください (0 で終了): 
空入力です。もう一度入力してください。
数値を入力してください (0 で終了): 5
入力された数値: 5
数値を入力してください (0 で終了): 3
入力された数値: 3
数値を入力してください (0 で終了): 0
0 が入力されたため、終了します。
```

# あとがき
今回は「 `while` によるループ処理」について扱いました。個人的にはもっと扱いたかった問題も多いのですが、盲点となりがちな話を中心に選んでみました。次回からは、「`for` によるループ処理」を扱います。

# 参考文献
[1] 独習Python (2020, 山田祥寛, 翔泳社)
[2] Pythonクイックリファレンス 第4版（2024, Alex, O’Reilly Japan）
[3] 【Python 猛特訓】100本ノックで基礎力を向上させよう！プログラミング初心者向けの厳選100問を出題！(Youtube, https://youtu.be/v5lpFzSwKbc?si=PEtaPNdD1TNHhnAG)