Daydreaming in Brookline, MA

Effective Python一人輪読会(Item 19 to 36)

Table of Contents

1 Chapter 3: 関数

1.1 Item 19: 関数が複数の値を返すとき、4つ以上の変数にunpackするな

うっかり順番を間違えたりするので。catch-allの星付きexpression(eg, *others)を使うか、namedtupleを使う。

namedtupleについてはここの説明がわかりやすかったです。(逆にIntroducing Pythonの説明はさっぱり。。。) 以下のように使います。

from collections import namedtuple
Car = namedtuple('Car', 'color milage')
または
Car = namedtuple('Car', ['color', 'milage'])

前者の文字列部分('color milage')は内部で split() されてリストになるため、後者と等価だそうです。

メソッドの無いimmutableなクラスのように使えます。

my_car = Car('red', 3812.4)

namedtupleは内部ではクラスとして表現されていますが、メモリ効率は良いそうです。

複数の値を返す関数は、それらをタプルにして返すことは知りませんでした。関数の呼び出し元が複数の変数にunpackする時にタプルから取り出します。

もう一つ。日本語ではあまり馴染みがありませんが、英語ではよく使うmaiden(中央値)。要素が偶数の場合にどうするのか知りませんでした。真ん中の二つの平均を取るのですか。

if count % 2 == 0:
    lower = sorted_numbers[middle - 1]
    upper = sorted_numbers[middle]
    median = (lower + upper) / 2

1.2 Item 20: Noneを返すくらいなら例外を上げよ

None や0, 空の文字列, etc.は全て False と解釈されるので、if文などでの判定でうっかり間違いやすい。

適切な例外(そのままでなく)を上げて、それをドキュメントに書く。更にtype annotationsする。

def careful_divide(a: float, b: float) -> float:
    """Divides a by b.

    Raises:
	ValueError: When the inputs cannot be divided.
    """"
    try:
	return a / b
    except ZeroDivisionError as e:
	raise ValueError('Invalid inputs')

1.3 Item 21: Closuresが変数のスコープにどう影響するかを知っておけ

えーっと、closuresって何でしたっけ? Introducing Pythonを読んだときに消化不良のまま終わったような気が。。

30分ほど調べてみました。ここの定義が一番しっくり来ました。

  • 関数と、そのインナー関数がある
  • インナー関数は、外側の関数で定義された変数を参照する
  • 外側の関数は、インナー関数を戻り値として返す
def print_msg(msg):
    def printer():    # inner function
        print(msg)    # msg was defined in the enclosing function
    return printer    # return the inner function

これを使うには、

another = print_msg("Hello")
another()
>>>
Hello

メリットの説明はここがわかりやすかったです。

  • コールバックとして定義されるため、ある意味データ隠蔽(hiding)に使える
  • ハードコードされた定数や文字列の代わりに使える
  • コードに関数が1, 2個しかないときに有効

この節では、やたらと小難しい具体例が続きますが、言っていることは単純です: インナー関数でアサインしている変数のスコープは、インナー関数で閉じる(ie, 外側の関数に届かない)。そして、もし外側の関数にスコープを広げたい場合は nonlocal を付けます。

この nonlocal の使い方にも注意が必要で、長い関数だと nonlocal であることがわかりづらくなります。単純な関数以外ではヘルパークラスにするのがよい、とのこと。

class SomeClass:
    def __call__(self, x, y):
        <snip>

sc = SomeClass()
sc(5, 7)  # __call__が使われる

__call__() スペシャル関数は、クラスを関数であるかのように振る舞わせる関数。なお、この大事なところでKindle版は最後の return (1, x) のインデントがずれています。しかも、Click here to view code imageがここには何故か無い。どうしろと。。。

1.4 Item 22: 可変数のpositional argumentsを使って見やすくしろ

  • 可変長の引数(0個含む)は関数の定義def文において *args のようにアスタリスク付きで書くことで実現できる
  • 関数の呼び出し側で、 *vars としてアスタリスクを付けて引数を指定することで、リスト等のシーケンスの中身を(展開して)渡せる

二つ目において、呼び出し側がgeneratorにアスタリスクを付けると、呼び出しのたびにgeneratorを全て展開してしまうため、メモリ圧迫&クラッシュに注意、とのことです。

1.5 Item 23: オプションとなる挙動はキーワード引数で与えよ

ここは特にコメントなしです。

1.6 Item 24: 動的なデフォルト引数を指定するときはNoneとDocstringsを使え

例:

def log(message, when=None):
    “““Log a message with a timestamp.
    Args:
        message: Message to print.
        when: datetime of when the message occurred.
            Defaults to the present time.
    ”””
    if when is None:
        when = datetime.now()
    print(f'{when}: {message}')

上の関数において、whenのデフォルトはNoneとしておき、関数の中で現在時刻を設定しています。def文で when=datetime.now() を書いても機能しないのは、 datetime.now() が評価されるのが関数のロード時のみだからです。同様に、 {} , [] のような動的な値を使うときにも None をデフォルト値として置きます。

例えば、以下のケースでは {} の同一オブジェクトが各呼び出し間で共有されてしまいます。

def decode(data, dafault={})
    try:
        return json.loads(data)
    except ValueError:
        return default

1.7 Item 25: キーワードオンリー引数、位置オンリー引数を使って、明確さを強制せよ

float('inf') は無限大を意味するそうです。

引数リストにあるアスタリスク * は、位置引数の終わりと、キーワードオンリー引数の始まりを強制します。また、Python 3.8より、引数リストにある / は、これよりも前の引数は位置オンリー引数であることを強制します。例えば、

def func(num, div, /, *, ov_flag=False, zd_flag=True):

とあったときに、

func(10, 2, True, False)

func(num=10, div=2)

などと呼び出すとTypeErrorとなります。

/* に挟まれた引数は、位置引数としてもキーワード引数としてもどちらでもよいそうです。これがPythonのデフォルトです。

1.8 Item 26: 関数のデコレーターをfunctools.wrapsを使って定義せよ

コードスニップを見ていると、見慣れない表現が。。。

{args!r}

この !r は、 __repr__() で解釈せよ、という意味だそうです。 f'This string {name!r} is a good one' のように使います。関係ありませんが、 !r はgoogle等で検索しづらいですね。。

デコレーターは難しいですね。 ここ の説明がわかりやすかったです。なるほど、 __name____doc__ を表示したり help() を使うと、インナー関数でなくwrapper関数自体の情報が表示されてしまうようです。

その解決策がfunctools.wrapsを使うことで、

from functools import wraps

def mydeco(func):
    @wraps(func)
    def wrapper(*args, *kwargs):
        return f'{func(args, **kwargs)}!!!'
    return wrapper

wraps デコレーターはインナー関数のdoc stringやその他の情報を外側のwrapper関数にコピーしてくれます。

2 Chapter 4: Comprehensionsとジェネレーター

ここを参考にして、ジェネレーターについて整理します。(このリンクはかなり詳細に説明しています)

  • ジェネレーター関数は、 return の代わりに yield を持つ関数で、ジェネレーター(=ジェネレーターiterator)を作る。
  • ジェネレーターは特定のタイプのiterator。iteratorとして機能するために、ジェネレーターは __next__() メソッドを持つ。
  • ジェネレーターから次の値を得るには、iterators: next() を使う。( next()__next__() メソッドを呼ぶ)
  • ジェネレーターは next() の呼び主に対して、 yield 文で値を返す。

ジェネレーターの使い方

  • 一通りたどるとジェネレーターは StopIteration 例外を上げる。これは普通のiteratorと同じ挙動で、for文などでは問題視せずにサイレントに抜ける。
  • その後、もう一度ジェネレーターを呼ぶと StopIteration 例外を上げる。
  • ジェネレーター関数をもう一度呼んで、新たなジェネレーターを作り直すことができる。

2.1 Item 27: mapとfilterの代わりにcomprehensionsを使え

map() ビルトイン関数は知りませんでした。ここによると、第二引数のiterable(例: リスト)の各アイテムに、第一引数の関数を適用して、iterableなmapオブジェクトを返す、とのこと。

mapobj = map(lambda x: x ** 2, somelist)

filter ビルトイン関数も知りませんでしたが、名前から明らかです。

filter(lambda x: x % 2 == 0, somelist)

filter もフィルターされたiteratorを返します。

両方とも返すのはiteratorですが、リストcomprehensionsと似てますね。

mapfilter を組み合わせると読みづらいので、スッキリとかけるcomprehensionsを使え、ということでした。 somelist の中から2で割りきれる要素のみ2乗したいとき、

map(lambda x: x**2, filter(lambda x: x % 2 == 0, somelist))

これが、

[x**2 for x in somelist if x % 2 == 0]

。。。確かに。

2.2 Item 28: 3つ以上のコントロールsubexpressionsをcomprehensionsで使うな

初級者的にキツい表現が出ました。やりたいことは、二次元にネストされたリストであるmatrixの中身をflattenする(一次元にする)、です。えーと、ネストされた for は左から解釈するらしいので、、

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]

最初の formatrix の各アイテム= row を取り出して、次の forrow の中の x を取り出し、これを使ってリストを作る、ということでした。

print(flat)
>>>
[1, 2, 3, 4, 5, 6, 7, 8, 9]

これは便利そうです。

次は、先ほど matrix の要素を二乗する(flattenしない)場合。

squared = [[x ** 2 for x in row] for row in matrix]
print(squared)
>>>
[[1, 4, 9], [16, 25, 36], [49, 64, 81]]

このリストcomprehensionのリストが二重になっているので、flattenしないのですね。

お題のコントロールsubexpressionsは、 for によるループだけでなく、 for に付けられる if も指していました。下の例は for が二つと if があるのでアウト。

[x for wor in matrix for x in row if x % 2 == 0]

2.3 Item 29: Assignment表現を使って、comprehensions内での繰り返しを避けよ

Assignment expressions - Walrusのやつです(:=)。これは便利なので、是非とも使っていきたいと思います。

次のようなストックがあって、

stock = {
    'nails': 125,
    'screws': 35,
    'wingnuts': 8,
    'washers': 24,
}

次のようなオーダー(8個単位とする)が来た場合について考えます。

order = ['screws', 'wingnuts', 'clips']

ディクショナリcomprehensionを使って、次のように書くと良いです。

def get_batches(count, size):
    return count // size

found = {name: batches for name in order
    if (batches := get_batches(stock.get(name, 0), 8))}

get_batches を使って、8個のセットが何セットあるかを求めています。 ポイントWalrusを使って get_batches の実行をif節の中で1回だけ使っているところ。

print(found)
>>>
{'screws': 4, 'wingnuts': 1}

知りませんでした、generator expressions。ここの説明がわかりやすかったです。まずは似たようなリストcomprehension:

listcomp = ['Hello' for i in range(3)]

これがジェネレーターexpression:

genexpr = ('Hello' for i in range(3))

後者が返すのはジェネレーターです。

>>> next(genexpr)
'Hello'
>>> next(genexpr)
'Hello'
>>> next(genexpr)
'Hello'
>>> next(genexpr)
StopIteration

先ほどの例はジェネレーターexpressionを使っても書けます。

found = ((name, batches) for name in order 
    if (batches := get_batches(stock.get(name, 0), 8)))

もちろん、返るのはジェネレーター。

print(next(found))
print(next(found))
>>>
('screws', 4)
('wingnuts', 1)

2.4 Item 30: リストを返すくらいならジェネレーターを考慮せよ

お、なんだか今更ジェネレーターの説明がありました。どうしてジェネレーターexpressionsを説明なしで使った後にこれが来るかな。。。

この本は、どこから読んでも良いと謳っていますが、逆に、最初から読もうが途中から読もうが読みづらさは変わらない、という残念なことになっていると思います。

  • リストにまとめて返すより、generatorsを使ったほうがよりクリア
  • generatorsはメモリ使用量が少ない

2.5 Item 31: 引数をたどるときには保守的になれ

ジェネレーター関数の read_visits() は、1行に一つの数字(入場者数)が書いてあるファイルを読んで yield します。

def read_visits(data_path):
    with open(data_path) as f:
        for line in f:
            yield int(line)

normalize_func() は入場者数をyieldするiterator(正確にはiteratorを返すジェネレーター関数)を引数に取り、毎日の入場者数をパーセントに変換したリストを返します。

def normalize_func(get_iter):
    total = sum(get_iter())   # 最初にiteratorを使いきる
    result = []
    for value in get_iter():  # 更にiteratorをループで回す
        percent = 100 * value / total
        result.append(percent)
    return result

この時、以下がどうなるかぱっと見、わかりずらいです。特にlambdaが何をしているのか。

percentages = normalize_func(lambda: read_visits(path))

もしlambdaが無ければ、normalize_funcの引数read_visits(path)はiteratorをそのまま返します。normalize_funcに渡したiteratorは最初にsumを取るところでexhaustされ、次のfor文では既にexhaustしたままのため、このfor文のブロックは一度も実行されません。

これに lambda が付くと、normalize_func()に渡されるlambdaの無名関数は、iteratorそのものでなく、iteratorを(呼び出しの度に)返す関数です。

sum関数でiteratorをいったんexhaustさせますが、次のfor文ではまた新たな(ie, exhaustしていない)iteratorが返されるので、for文ブロックが期待通り実行されます。

ただ、お勧めのやり方はこれではなく、自分で __iter__() メソッドを書いたクラスを用意することでした。 __iter__() メソッドはジェネレーター関数でなくてはならず、iteratorを返します。

class ReadVisits:
    def __init__(self, data_path):
        self.data_path = data_path

    def __iter__(self):
        with open(self.data_path) as f:
            for line in f:
                yeild int(line)

iteratorを返す __iter__() メソッドを持つクラスを用意することで、このクラスのオブジェクトをfor文などでiterateすることが可能です。

また、normalize_funcにおいて、引数がiteratorそのものならばexceptionを上げるようにチェックを追加できます。

def normalize_defensive(numbers):
    if iter(numbers) is numbers:  # An iterator -- bad!

または、

from collections.abc import Iterator

def normalize_defensive(numbers):
    if ininstance(numbers, Iterator): 

2.6 Item 32: 大きなリストcomprehensionsの代わりにジェネレーターexpressionsを考えよ

(今更ですが)ジェネレーターexpressionsの説明でした。その戻り値であるiteratorを入力とした、ジェネレーターexpressionsのチェイン実行も可能で効率的。

it = (len(x) for x in open('my_file.txt'))
roots = ((x, x**0.5) for x in it)

listcompsは大量のインプットがある時にメモリを使いすぎてしまいますが、genexpsはメモリ効率がとても良いです。

2.7 Item 33: yield fromを使って複数のジェネレーターを組み合わせよ

yield from です。for文で yield を回すところを yeild from とシンプルに書け、リーダビリティーを向上させるだけでなく、ループで yield を回すよりも高速です。 yield を組み合わせるケースでは、可能な限り yeild from を使うべきです。

def slow():
    for i in child():
        yield i

は、以下のようによりシンプルに書けます。

def fast():
    yield from child()

2.8 Item 34: ジェネレーターにsendを使ってデータを送ってはいけない

えー、前の項目で何とか理解したばかりだというのに、 send() を使うなと言っています。初心者にわかりづらいのと、 yield from を使って初回に None が返るケースでびっくりすることがあるため。この節はかなり難しいです。

def my_generator():
    received = yield 1

it = iter(my_genarator())
output = it.send(None)

このコードにおいて、最後の行でitに対して(初めて)sendが呼ばれる時、 yield 1 部分のみが実行され、 received = 部分はまだ実行されません。つまり、sendからは何も受け取りません。receivedにsendから値が渡されるのはit.sendが再度呼ばれてジェネレーターが再開するタイミングです。確かに、慣れていないとわかりずらいです。

def wave_modulating(steps):
    step_size = 2 * math.pi / steps
    amplitude = yield    # Receive initial amplitude
    for step in range(steps):
        radians = step * step_size
        fraction = math.sin(radians)
        output = amplitude * fraction
        amplitude = yield output  # Receive next amplitude

上記で、最初にamplitudeの初期値を受け取るところが、yield fromするとNoneが返る箇所です。

def complex_wave_modulating():
    yield from wave_modulating(3)
    yield from wave_modulating(4)
    yield from wave_modulating(5)
def run_modulating(it):
    amplitudes = [
        None, 7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10]
    for amplitude in amplitudes:
       output = it.send(amplitude)
       transmit(output)  # outputをprintする

complex_wave_modulatingの yield from と、run_modulatingの send を以下のように組み合わせます。複雑でだいぶ頭が混乱しますが、、、

 run_modulating(complex_wave_omdulating())

>>>
Output is None
Output:   0.0
Output:   6.1
Output:  -6.1
Output is None
Output:   0.0
Output:   2.0
Output:   0.0
Output: -10.0
Output is None
Output:   0.0
Output:   9.5
Output:   5.9

。。。うまくありません。yield fromで引数を渡すところがNoneを返すため、出力にNoneが入り込んでしまいます。yield fromとsendの組み合わせはいまいちです。

これを、 send() を使わずに、iteratorを引数で渡しておくように修正します。

def wave_cascading(amplitude_it, steps):
    step_size = 2 * math.pi / steps
    for step in range(steps):
        radians = step * step_size
        fraction = math.sin(radians)
        amplitude = next(amplitude_it)  # Get next input
        output = amplitude * fraction
        yield output

最初の呼び出しでNoneをyieldすることが無くなりました。

そして、次のcomplex_cascadingから、このwave_cascadingをyield fromで組み合わせてジェネレーター関数のカスケードをします。

def complex_cascading(amplitude_it):
    yield from wave_cascading(amplitude_it, 3)
    yield from wave_cascading(amplitude_it, 4)
    yield from wave_cascading(amplitude_it, 5)

そして更に、

def run_cascading():
    amplitudes = [7, 7, 7, 2, 2, 2, 2, 10, 10, 10, 10, 10]
    it = complex_cascading(iter(amplitudes))
    for amplitude in amplitudes:
        output = next(it)
        transmit(output)

amplitudesから抽出したiteratorをcomplex_cascadingに渡してiterator it を作ります。そして、amplitudesをループで回し、 it からyieldした値をtransmitします。

なんだかやたらと複雑ですが、transmitでNoneが返らずに正しく動くようになりました。

まとめると、カスケードさせたジェネレーターとsendを組み合わせるのは筋が悪く、iteratorを引数として渡した方がよい、ということでした。

2.9 Item 35: throwで例外を投げてジェネレータの状態遷移を起こすな

throw を使う駄目な例:

def run():
    it = timer(4)
    while True:
        try:
            if check_for_reset():
                current = it.throw(Reset())  # 自分で例外を投げている
            else:
                current = next(it)  # ここで例外が発生する可能性あり
        except StopIteration:
            break
        else:  # 例外が発生しなかったら
            announce(current)

何だか、何がしたいのかよくわかりません。。。。ので調べます。ここによると、 it.throw() はit内部で例外を発生させるようです。 itReset() 例外ハンドラーでタイマーをリセットしているのかな。確かに、少し読みづらいですかね。

ジェネレーターの __iter__() を持つクラスを用意して run() を書き換えます。

class Timer:
    def __init__(self, period):
        self.current = period
        self.period = period

    def reset(self):
        self.current = self.period

    def __iter__(self):
        while self.current:
            self.current -= 1
            yield self.current

run() 関数はずっとシンプルになります。

def run():
    timer = Timer(4)
    for current in timer:
        if check_for_reset():
            timer.reset()
        announce(current)

2.10 Item 36: Iteratorやジェネレーターを使うときにはitertoolsの使用を検討せよ

itertoolsには便利な関数がたくさんあるので使うとよい、という節でした。

2.10.1 Iteratorsをつなぐ

  1. chain
    it = itertools.chain([1, 2, 3], [4, 5, 6])
    print(list(it))
    >>>
    [1, 2, 3, 4, 5, 6]
    
  2. repeat
    it = itertools.repeat('hello', 3)
    print(list(it))
    >>>
    ['hello', 'hello', 'hello']
    

    もっと簡単な方法はないのでしょうか。実験してみます。

    >>> 'hello' * 3
    'hellohellohello'
    >>> ['hello' * 3]
    ['hellohellohello']
    >>> list('hello' * 3)
    ['h', 'e', 'l', 'l', 'o', 'h', 'e', 'l', 'l', 'o', 'h', 'e', 'l', 'l', 'o']
    
  3. cycle
    it = itertools.cycle([1, 2])
    result = [next(it) for _ in range(10)]
    print(result)
    >>>
    [1, 2, 1, 2, 1, 2, 1, 2, 1, 2]
    
  4. tee - iteratorを指定個数に分割して並列実行
    it1, it2, it3 = itertools.tee(['first', 'second'], 3)
    print(list(it1))
    print(list(it2))
    print(list(it3))
    >>>
    ['first', 'second']
    ['first', 'second']
    ['first', 'second']
    
  5. zip_longest - zipと似ているが、一番長いiteratorに合わせる。

2.10.2 Iteratorからアイテムをフィルタする

  1. islice - スライスする。ステップも指定可
    first_five = itertools.islice(values, 5)  # 最初の5個
    mkddle_odds = itertools.islice(values, 2, 8, 2)  # 3番目からステップ2で7まで
    
  2. takewhile - 指定関数がFalseを返すまでtakeする
    values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    less_than_seven = lambda x: x < 7
    it = itertools.takewhile(less_than_seven, values)
    print(list(it))
    >>>
    [1, 2, 3, 4, 5, 6]
    
  3. dropwhile - takewhileの逆。指定関数がTrueを返すまでドロップする
  4. filterfalse - filterの逆

2.10.3 複数iteratorsのアイテムを結びつける

  1. accumulate - 積算する

    二つ目の引数に関数を指定して、加工が可能。

    values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    sum_reduce = itertools.accumulate(values)
    print('Sum: ', list(sum_reduce))
    >>>
    Sum: [1, 3, 6, 10, 15, 21, 28, 36, 45, 55]
    def sum_modulo_20(first, second):
        return (first + second) % 20
    modulo_reduce = itertools.accumulate(values, sum_modulo_20)
    print('Modulo: ', list(modulo_reduce))
    >>>
    Modulo: [1, 3, 6, 10, 15, 1, 8, 16, 5, 15]
    

    後者のユースケースは、行列計算かな。

  2. product - 直積(cartesian product)

    直積、デカルト積、cartesian積と呼ばれます。AとBの要素からそれぞれ一つずつ取ってきた作ったペアの集合です。学校で習った記憶があまりありません。。。

    single = itertools.product([1, 2], repeat=2)
    print('Single: ', list(single)
    >>>
    Single: [(1, 2), (1, 2), (2, 1), (2, 2)]
    multiple = itertools.product([1, 2], ['a', 'b'])
    print('Multi: ', list(multiple))
    >>>
    Multi: [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')
    

    上の例でのrepeatは、自身で積を計算するときの個数を指定します。product(A, repeat=4)はproduct(A, A, A, A)と等価。

  3. permutations - 順列
    it = itertools.permutations([1, 2, 3, 4], 2)
    print(list(it))
    >>>
    [(1, 2), (1, 3), (1, 4), (2, 1), (2, 3), (2, 4), 
     (3, 1), (3, 2), (3, 4), (4, 1), (4, 2), (4, 3)]
    
  4. combinations - 組み合わせ
    it = itertools.combinations([1, 2, 3, 4], 2)
    print(list(it))
    >>>
    [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]
    
  5. combinations_with_replacement
    it = itertools.combinations_with_replacement([1, 2, 3, 4], 2)
    print(list(it))
    >>>
    [(1, 1), (1, 2), (1, 3), (1, 4), (2, 2), (2, 3), (2, 4), 
     (3, 3), (3, 4), (4, 4)]