【Python】FizzBuzzで理解するジェネレータの特長（主に消費メモリ）

# この記事でやってること

FizzBuzzを関数、ジェネレータでそれぞれ実装し、メモリ消費量と処理順序を比較する（ことでジェネレータの特長を理解する）。

# 環境

```sh
$ python3 --version
Python 3.8.5

uname -o
GNU/Linux

$ uname -p
x86_64
```

# 基本のソースコード

まずは通常の関数とジェネレータでそれぞれ FizzBuzz を実装。

## 関数

```python
def fizzbuzz(nums):
    result = []
    for num in nums:
        if num % 15 == 0:
            result.append('Fizz Buzz')
        elif num % 5 == 0:
            result.append('Buzz')
        elif num % 3 == 0:
            result.append('Fizz')
        else:
            result.append(str(num))
    return result


def main():
    numbers = [i+1 for i in range(15)]  # [1, 2, 3, ..., 15]
    for i in fizzbuzz(numbers):
        print(i)


if __name__ == '__main__':
    main()
```

## ジェネレータ

```python
def fizzbuzz(nums):
    for num in nums:
        if num % 15 == 0:
            yield 'Fizz Buzz'
        elif num % 5 == 0:
            yield 'Buzz'
        elif num % 3 == 0:
            yield 'Fizz'
        else:
            yield str(num)


def main():
    numbers = [i+1 for i in range(15)]  # [1, 2, 3, ..., 15]
    for i in fizzbuzz(numbers):
        print(i)


if __name__ == '__main__':
    main()
```

# メモリの使用状況を見てみる

ジェネレータの特長としてメモリ消費を抑えられるというのをよく目にするので実際に確認してみる。
なおメモリ使用量の計測には memory_profiler を使用。

```sh
$ pip3 --version
pip 20.0.2 from /usr/lib/python3/dist-packages/pip (python 3.8)

$ pip3 show memory_profiler
Name: memory-profiler
Version: 0.58.0
Summary: A module for monitoring memory usage of a python program
Home-page: https://github.com/pythonprofilers/memory_profiler
Author: Fabian Pedregosa
Author-email: f@bianp.net
License: BSD
Location: /home/h-okabe/.local/lib/python3.8/site-packages
Requires: psutil
Required-by:
```

- https://pypi.org/project/memory-profiler/
- https://github.com/pythonprofilers/memory_profiler

## 関数

基本のソースコードに下記の変更を実施。

- `fizzbuzz()`に食わすデータ数を10^7個に設定（マシンスペックに合わせて要調整、桁一つ間違えるとメモリが枯渇する）
- `main()`のループ内処理で`print`とかすると邪魔なので`pass`だけ実行しておく
- `main()`を呼び出した際のメモリ使用量を0.1秒間隔で計測し、計測回数と最大値を出力

```python
from memory_profiler import memory_usage


def fizzbuzz(nums):
    result = []
    for num in nums:
        if num % 15 == 0:
            result.append('Fizz Buzz')
        elif num % 5 == 0:
            result.append('Buzz')
        elif num % 3 == 0:
            result.append('Fizz')
        else:
            result.append(str(num))
    return result


def main():
    numbers = [i+1 for i in range(10**7)]  # [1, 2, 3, ..., 10000000]
    for i in fizzbuzz(numbers):
        pass


if __name__ == '__main__':
    mem_usage = memory_usage((main), interval=.1, timeout=None)
    print('monitoring count: {}'.format(len(mem_usage)))
    print('max usage: {}'.format(max(mem_usage)))
```

このプログラムをコマンドラインから実行する際に`time`で呼び出して実行時間も計測する。

5回実行した結果、メモリの使用量はいずれも 800MB ちょっとで収束。

```sh
$ time python3 fizzbuzz_func.py
monitoring count: 30
max usage: 812.96484375

real    0m2.806s
user    0m2.469s
sys     0m0.345s

$ time python3 fizzbuzz_func.py
monitoring count: 30
max usage: 811.05859375

real    0m2.880s
user    0m2.607s
sys     0m0.281s

$ time python3 fizzbuzz_func.py
monitoring count: 31
max usage: 812.88671875

real    0m2.953s
user    0m2.672s
sys     0m0.290s


$ time python3 fizzbuzz_func.py
monitoring count: 31
max usage: 812.31640625

real    0m2.941s
user    0m2.540s
sys     0m0.410s

$ time python3 fizzbuzz_func.py
monitoring count: 31
max usage: 811.23828125

real    0m2.975s
user    0m2.642s
sys     0m0.341s
```

## ジェネレータ

こちらも上記の関数と同様に下記の変更を実施。

- `fizzbuzz()`に食わすデータ数を10^7個に設定（マシンスペックに合わせて要調整、桁一つ間違えるとメモリが枯渇する）
- `main()`のループ内処理で`print`とかすると邪魔なので`pass`だけ実行しておく
- `main()`を呼び出した際のメモリ使用量を0.1秒間隔で計測し、計測回数と最大値を出力

```python
from memory_profiler import memory_usage


def fizzbuzz(nums):
    for num in nums:
        if num % 15 == 0:
            yield 'Fizz Buzz'
        elif num % 5 == 0:
            yield 'Buzz'
        elif num % 3 == 0:
            yield 'Fizz'
        else:
            yield str(num)


def main():
    numbers = [i+1 for i in range(10**7)]  # [1, 2, 3, ..., 10000000]
    for i in fizzbuzz(numbers):
        pass


if __name__ == '__main__':
    mem_usage = memory_usage((main), interval=.1, timeout=None)
    print('monitoring count: {}'.format(len(mem_usage)))
    print('max usage: {}'.format(max(mem_usage)))
```

関数の計測時と同様に`time`で呼び出して5回計測した結果、メモリの使用量はいずれも 400MB ちょっとで収束。
今回のように要素数が多いケースではリスト一つの差が顕著にあらわれた。

```sh
$ time python3 fizzbuzz_gen.py
monitoring count: 26
max usage: 404.37109375

real    0m2.416s
user    0m2.286s
sys     0m0.138s

$ time python3 fizzbuzz_gen.py
monitoring count: 26
max usage: 404.8828125

real    0m2.412s
user    0m2.299s
sys     0m0.120s

$ time python3 fizzbuzz_gen.py
monitoring count: 26
max usage: 405.10546875

real    0m2.440s
user    0m2.247s
sys     0m0.200s

$ time python3 fizzbuzz_gen.py
monitoring count: 26
max usage: 405.44140625

real    0m2.418s
user    0m2.215s
sys     0m0.210s

$ time python3 fizzbuzz_gen.py
monitoring count: 26
max usage: 403.8828125

real    0m2.454s
user    0m2.261s
sys     0m0.200s
```

# 処理の流れを確認

関数とジェネレータそれぞれの実装において処理が行き来するタイミングを把握する。

## 関数

- `fizzbuzz()` に食わすデータ数を15個に設定
- `main()` のループ内処理のタイミングと `fizzbuzz()` の `return` のタイミングを `print` で出力

```sh
def fizzbuzz(nums):
    result = []
    for num in nums:
        if num % 15 == 0:
            result.append('Fizz Buzz')
        elif num % 5 == 0:
            result.append('Buzz')
        elif num % 3 == 0:
            result.append('Fizz')
        else:
            result.append(str(num))
    print('return from fizzbuzz')
    return result


def main():
    numbers = [i+1 for i in range(15)]  # [1, 2, 3, ..., 15]
    for i in fizzbuzz(numbers):
        print(i)


if __name__ == '__main__':
    main()
```

これを実行すると `fizzbuzz()` の実行が終わりリストが返ってきた後に `main()` のループ処理が行われていることが読み取れる。

```sh
$ python3 fizzbuzz_func.py
return from fizzbuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
Fizz Buzz
```

## ジェネレータ

- `fizzbuzz()` に食わすデータ数を 15個に設定
- `main()` のループ内処理のタイミングと `fizzbuzz()` の `yield` のタイミングを `print` で出力

```python
def fizzbuzz(nums):
    for num in nums:
        if num % 15 == 0:
            print('yield from fizzbuzz')
            yield 'Fizz Buzz'
        elif num % 5 == 0:
            print('yield from fizzbuzz')
            yield 'Buzz'
        elif num % 3 == 0:
            print('yield from fizzbuzz')
            yield 'Fizz'
        else:
            print('yield from fizzbuzz')
            yield str(num)


def main():
    numbers = [i+1 for i in range(15)]  # [1, 2, 3, ..., 15]
    for i in fizzbuzz(numbers):
        print(i)


if __name__ == '__main__':
    main()
```

ジェネレータの場合には `fizzbuzz()` と `main()` のループ処理を都度行き来していることが読み取れる。

※ `fizzbuzz()` から `main()` に戻る契機は `yield` だが、再度 `fizzbuzz()` に戻る契機となる処理は謎。
（ループ内の処理が全て終わり次のループが開始するタイミングであるとは想像できるがソースなし）

```sh
$ python3 fizzbuzz_gen.py
yield from fizzbuzz
1
yield from fizzbuzz
2
yield from fizzbuzz
Fizz
yield from fizzbuzz
4
yield from fizzbuzz
Buzz
yield from fizzbuzz
Fizz
yield from fizzbuzz
7
yield from fizzbuzz
8
yield from fizzbuzz
Fizz
yield from fizzbuzz
Buzz
yield from fizzbuzz
11
yield from fizzbuzz
Fizz
yield from fizzbuzz
13
yield from fizzbuzz
14
yield from fizzbuzz
Fizz Buzz
```

# おまけ

上記のジェネレータを使用したプログラムにおいては、以下のようにリストを使わない形するとメモリ消費を更に抑えられる。

```python
from memory_profiler import memory_usage


def fizzbuzz(nums):
    for num in nums:
        if num % 15 == 0:
            yield 'Fizz Buzz'
        elif num % 5 == 0:
            yield 'Buzz'
        elif num % 3 == 0:
            yield 'Fizz'
        else:
            yield str(num)


def main():
    for i in fizzbuzz(range(10**7)):  # [1, 2, 3, ..., 10000000]
        pass


if __name__ == '__main__':
    mem_usage = memory_usage((main), interval=.1, timeout=None)
    print('monitoring count: {}'.format(len(mem_usage)))
    print('max usage: {}'.format(max(mem_usage)))
```

実行してみると使用メモリはおよそ 17MB、書き方ひとつの工夫でこうまで結果が違うのはなんともおもしろい。

```sh
$ time python3 fizzbuzz_gen.py
monitoring count: 21
max usage: 17.5

real    0m1.886s
user    0m1.875s
sys     0m0.017s

$ time python3 fizzbuzz_gen.py
monitoring count: 21
max usage: 17.46875

real    0m1.925s
user    0m1.913s
sys     0m0.018s

$ time python3 fizzbuzz_gen.py
monitoring count: 21
max usage: 17.6484375

real    0m1.898s
user    0m1.885s
sys     0m0.020s

$ time python3 fizzbuzz_gen.py
monitoring count: 21
max usage: 17.6015625

real    0m1.960s
user    0m1.966s
sys     0m0.000s

$ time python3 fizzbuzz_gen.py
monitoring count: 21
max usage: 17.4296875

real    0m1.909s
user    0m1.916s
sys     0m0.000s
```