見出し画像

Python実践入門 ──言語の力を引き出し、開発効率を高める(第9章Pythonの特有のさまざまな機能)

■今回扱う概念
 ジェネレータ
 デコレータ
 コンテキストマネージャー
 デスクリプタ


■今回勉強に利用した本

9.1ジェネレータ - メモリ効率のよいイテラブルなオブジェクト

yield式がジェネレータの目印

■ジェネレータ作成方法
 1.ジェネレータ関数を使う方法
    2.ジェネレータ式を使う方法

■ジェネレータ関数 - 関数のように作成する

関数内部でyield式を使っている関数のこと(ジェネレータとも呼ばれる)。ジェネレータ関数の戻り値は、ジェネレータイテレータと呼ばれるイテレータです。

>>> def gen_function(n):
...   print('start')
...   while n:
...     print(f'yield: {n}')
...     yield n  # ここで一時中断される
...     n -= 1
...

# 戻り値はジェネレータイテレータ
>>> gen = gen_function(2)
>>> gen
<generator object gen_function at 0x10439b9a8>

# 組み込み関数next()に渡すと
# __next__()が呼ばれる
>>> next(gen)
start
yield: 2
2  # これがnext(gen)の戻り値
>>> next(gen)
yield: 1
1
>>> next(gen)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

ジェネレータ関数は戻り値がイテレータであるため、for文や内包表記、引数にイテラブルをとる関数などで利用できます。

>>> def gen_function(n):
...   while n:
...     yield n
...     n -= 1
...
# for文での利用
>>> for i in gen_function(2):
...   print(i)
...
2
1
# 内包表記での利用
>>> [i for i in gen_function(5)]
[5, 4, 3, 2, 1]
# イテラブルを受け取る関数に渡す
>>> max(gen_function(5))
5

■ジェネレータ式 - 内包表記を利用して作成する。

リストやタプルなどのイテラブルがあるときは、内包表記を使ってイテラブルからジェネレータを作成できます。これはジェネレータ式と呼ばれ、リスト内包表記と同じ構文で[]の代わりに()を使います。

>>> x = [1, 2, 3, 4, 5]
# これはリスト内包表記
>>> listcomp = [i**2 for i in x]
>>> listcomp  # すべての要素がメモリ上にすぐ展開される
[1, 4, 9, 16, 25]

# これはジェネレータ式
>>> gen = (i**2 for i in x)
>>> gen  # 各要素は必要になるまで計算されない
<generator object <genexpr> at 0x10bc10408>
# リストにすると最後の要素まで計算される
>>> list(gen)
[1, 4, 9, 16, 25]

■yield from式 - サブジェネレータへ処理を委譲する

ジェネレータの内部でされにジェネレータを作成できる場合、yield from式を使うと簡潔に書き直せる。

>>> def chain(iterables):
...   for iterable in iterables:
...     for v in iterable:
...       yield v
...
>>> iterables = ('python', 'book')
>>> list(chain(iterables))
['p', 'y', 't', 'h', 'o', 'n', 'b', 'o', 'o', 'k']

# -------------------------------------------------------------------
# 書き直した結果(yield fromに)
>>> def chain(iterables):
...   for iterable in iterables:
...     yield from (v for v in iterable)
...
>>> list(chain(iterables))
['p', 'y', 't', 'h', 'o', 'n', 'b', 'o', 'o', 'k']


リストやタプルでよく利用される組み込み関数len()は、ジェネレータでは利用できません。利用する場合は、ジェネレータをリストやタプルに変換して利用します。

ジェネレータの実例
外部ファイルを読み込み -> 変換-> 出力の一連の流れを1行ずつ行うため
*ファイルのサイズが大きくてもメモリを圧迫することがなく動く

# ファイルの中身を1行ずつ読み込む
>>> def reader(src):
...   with open(src) as f:
...     for line in f:
...       yield line
...
# 行単位で実行する変換処理
>>> def convert(line):
...   return line.upper()
...
# 読み込み→変換→書き込みを1行ずつ行う
>>> def writer(dest, reader):
...   with open(dest, 'w') as f:
...     for line in reader:
...       f.write(convert(line))
...

# reader()には存在するファイルのパスを渡す
>>> writer('output.txt', reader('input.txt'))
値を無限に返したいときや大きなデータを扱いたいときに特に効果を発揮します。行単位やファイル単位で逐次処理をするとパフォーマンスが向上します。
また、実装中のコードでリストを返している箇所があったときには、積極的にジェネレータに書き換えましょう。もしリストやタプルとして使いたい場合であっても、呼び出し元で変換すれば問題ありません。


9.2デコレータ - 関数やクラスの前後に処理を追加する

関数やクラスの前後に処理を追加できる機能

■利用用途
 関数の引数チェック
 関数の呼び出し結果のキャッシュ
 関数の実行時間の計測
 Web APIでのハンドラの登録、ログイン状態による制限

functools.lru_cache() - 関数の結果をキャッシュする関数デコレータ

>>> from functools import lru_cache
>>> from time import sleep

# 最近の呼び出し最大32回分までキャッシュ
>>> @lru_cache(maxsize=32)
... def heavy_funcion(n):
...   sleep(3)  # 重い処理をシミュレート
...   return n + 1
...

# 初回は時間がかかる
>>> heavy_funcion(2)
3

# キャッシュにヒットするのですぐに結果を得られる
>>> heavy_funcion(2)
3

dataclassess.dataclass() - よくある処理を自動追加するクラスデコレータ

■dataclassess.dataclass()
対象クラスに__init__()などの特殊メソッドを自動で追加してくれる。
>>> from dataclasses import dataclass
>>> @dataclass(frozen=True)
... class Fruit:
...   name: str  # 型ヒントを付けて属性を定義
...   price: int = 0  # 初期値も指定
...

# __init__()や__repr__()が自動で追加されている
>>> apple = Fruit(name='apple', price=128)
>>> apple
Fruit(name='apple', price=128)

# frozen=Trueとしたので読み取り専用
>>> apple.price = 256
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "<string>", line 3, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'price


デコレータの実装

シンプルなデコレータ

# デコレートしたい関数を受け取る
>>> def deco1(f):
...   print('deco1 called')
...   def wrapper():
...     print('before exec')
...     v = f()  # もとの関数を実行
...     print('after exec')
...     return v
...   return wrapper

# デコレータは関数定義時に実行される
>>> @deco1
... def func():
...   print('exec')
...   return 1
...
deco1 called  # デコレータが呼び出されている

# deco1(func)の結果に置き換わっている
>>> func.__name__
'wrapper'

# func()の呼び出しはwrapper()の呼び出しになる
>>> func()
before exec # デコレータ
exec
after exec # デコレータ
1  # wrapper()の戻り値

引数を受け取る関数のデコレータ

>>> def deco2(f):
...   # 新しい関数が引数を受け取る
...   def wrapper(*args, **kwargs):
...     print('before exec')
...     # 引数を渡してもとの関数を実行
...     v = f(*args, **kwargs)
...     print('after exec')
...     return v
...   return wrapper

>>> @deco2
... def func(x, y):
...   print('exec')
...   return x, y
...
>>> func(1, 2)
before exec # デコレータ
exec
after exec # デコレータ
(1, 2)

デコレータ自身が引数を受け取るデコレータ

# 引数zを受け取る
>>> def deco3(z):
...   def _deco3(f):
...     def wrapper(*args, **kwargs):
...       # ここでzを参照できる
...       print('before exec', z)
...       v = f(*args, **kwargs)
...       print('after exec', z)
...       return v
...     return wrapper
...   return _deco3  # デコレータを返す

# deco3(z=3)の戻り値がデコレータの実体
# つまりfunc = deco3(z=3)(func)と同等
>>> @deco3(z=3)
... def func(x, y):
...   print('exec')
...   return x, y
...

# zに渡した値は保持されている
>>> func(1, 2)
before exec 3
exec
after exec 3
(1, 2)

複数のデコレータを同時に利用

# 引数zを受け取る
>>> def deco3(z):
...   def _deco3(f):
...     def wrapper(*args, **kwargs):
...       # ここでzを参照できる
...       print('before exec', z)
...       v = f(*args, **kwargs)
...       print('after exec', z)
...       return v
...     return wrapper
...   return _deco3  # デコレータを返す

# 複数のデコレータを利用
>>> @deco3(z=3)
... @deco3(z=4)
... def func(x, y):
...   print('exec')
...   return x, y
...

# @deco3(z=4)が適用された結果に
# @deco3(z=3)が適用される
>>> func(1, 2)
before exec 3
before exec 4
exec
after exec 4
after exec 3
(1, 2)

functools.wraps()でデコレータの欠点を解消する

デコレータを使う際は、標準ライブラリのデコレータfunctools.wraps()を使い、実際に実行される関数の名前やDocstringをもとの関数のものに置き換えることが一般的です。
>>> from functools import wraps
>>> def deco4(f):
...   @wraps(f)  # もとの関数を引数に取るデコレータ
...   def wrapper(*args, **kwargs):
...     print('before exec')
...     v = f(*args, **kwargs)
...     print('after exec')
...     return v
...   return wrapper
...
>>> @deco4
... def func():
...   """funcです"""
...   print('exec')
...
>>> func.__name__
'func'
>>> func.__doc__
'funcです

デコレータの実例 - 処理時間の計測

割愛


9.3コンテキストマネージャー -  with文の前後で処理を実行するオブジェクト

with文に対応したオブジェクトをコンテキストマネージャーと呼びます。with文の本質はある処理の前後の処理をまとめて再利用可能にしてくれる点です。
# 第二引数で書き込みモードを指定
>>> with open('some.txt', 'w') as f:
...   f.write('python')
...
6  # 書き込まれたバイト数
>>> f.closed
True


コンテキストマネージャーの実装

■コンテキストマネージャーの実体
 __enter__() -> withブロックに入る際に呼ばれる前処理
   __exit__() -> withブロックを抜ける際に呼ばれる後処理
# このクラスのインスタンスがコンテキストマネージャー
>>> class ContextManager:
...   # 前処理を実装
...   def __enter__(self):
...     print('__enter__ was called')
...   # 後処理を実装
...   def __exit__(self, exc_type, exc_value, traceback):
...     print('__exit__ was called')
...     print(f'{exc_type=}')
...     print(f'{exc_value=}')
...     print(f'{traceback=}')
...

# withブロックが正常終了の場合は
# __exit__()の引数はすべてNone
>>> with ContextManager():
...   print('inside the block')
...
__enter__ was called
inside the block
__exit__ was called
exc_type=None
exc_value=None
traceback=None

with文と例外処理

# このクラスのインスタンスがコンテキストマネージャー
>>> class ContextManager:
...   # 前処理を実装
...   def __enter__(self):
...     print('__enter__ was called')
...   # 後処理を実装
...   def __exit__(self, exc_type, exc_value, traceback):
...     print('__exit__ was called')
...     print(f'{exc_type=}')
...     print(f'{exc_value=}')
...     print(f'{traceback=}')
...

# withブロック内で例外が発生した場合は
# その情報が__exit__()に渡される
>>> with ContextManager():
...   1 / 0
...
__enter__ was called
__exit__ was called
exc_type=<class 'ZeroDivisionError'>
exc_value=ZeroDivisionError('division by zero')
traceback=<traceback object at 0x109322400>
Traceback (most recent call last):
 File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero


asキーワード - __enter__()の戻り値を利用する

>>> class ContextManager:
...   # 戻り値がasキーワードに渡される
...   def __enter__(self):
...     return 1
...   def __exit__(self, exc_type, exc_value, traceback):
...     pass
...
>>> with ContextManager() as f:
...   print(f)
...
1

# asキーワードの省略
>>> with ContextManager():
...   pass
...
>>>

--------------------------------
# __exit__に直接渡す方法がないので、頑張る必要がある。
# オブジェクト内にdictを保持する方法

>>> class Point:
...   def __init__(self, **kwargs):
...     self.value = kwargs
...   def __enter__(self):
...     print('__enter__ was called')
...     return self.value  # as節で渡される
...   def __exit__(self, exc_type, exc_value, traceback):
...     print('__exit__ was called')
...     print(self.value)
...
>>> with Point(x=1, y=2) as p:
...   print(p)
...   p['z'] = 3
...
__enter__ was called
{'x': 1, 'y': 2}
__exit__ was called
{'x': 1, 'y': 2, 'z': 3}

contextlib.contextmanagerでシンプルに実装する。

>>> from contextlib import contextmanager
>>> @contextmanager
... def point(**kwargs):
...   print('__enter__ was called')
...   value = kwargs
...   try:
...     # yield式より上が前処理
...     # valueがasキーワードに渡される
...     yield value
...     # yield式より下が後処理
...   except Exception as e:
...     # エラー時はこちらも呼ばれる
...     print(e)
...     raise
...   finally:
...     print('__exit__ was called')
...     print(value)
...

>>> with point(x=1, y=2) as p:
...   print(p)
...   p['z'] = 3
...
__enter__ was called
{'x': 1, 'y': 2}
__exit__ was called
{'x': 1, 'y': 2, 'z': 3}


コンテキストマネージャーの実例 - 一時的なログレベルの変更

# debug_context.py

import logging
from contextlib import contextmanager

logger = logging.getLogger(__name__)
logger.addHandler(logging.StreamHandler())

# デフォルトをINFOレベルとし、DEBUGレベルのログは無視する
logger.setLevel(logging.INFO)

@contextmanager
def debug_context():
   level = logger.level
   try:
       # ログレベルを変更する
       logger.setLevel(logging.DEBUG)
       yield
   finally:
       # もとのログレベルに戻す
       logger.setLevel(level)

def main():
   logger.info('before: info log')
   logger.debug('before: debug log')

   # DEBUGログを見たい処理をwithブロック内で実行する
   with debug_context():
       logger.info('inside the block: info log')
       logger.debug('inside the block: debug log')

   logger.info('after: info log')
   logger.debug('after: debug log')

if __name__ == '__main__':
   main()
$ python3 debug_context.py
before: info log
inside the block: info log
inside the block: debug log
after: info log

そのほかには、ある処理の前後の処理をまとめて、再利用可能にしてくれるため下記もあります。

・開始/終了のステータス変更や通知
・ネットワークやDBの接続/切断処理


9.4デスクリプタ - 属性処理をクラスに委譲する

プロパティを作成する際に利用する@propertyは、デスクリプタとして実装されています。特殊メソッド__get__(), __set__(), __delete__()のうちいずれか 1つ以上でも持っていれば、そのオブジェクトはデスクリプタと呼ばれます。
# デスクリプタが持つメソッドが定義されている
>>> dir(property())
[... '__delete__', ... '__get__', ... '__set__', ...]

# propertyの実体はクラスとして定義されている
>>> type(property())
<class 'property'>
■ディスクリプタの種類
 データディスクリプタ(オーバーライドディスクリプタ)
   _set__() or(and) __delete__()
 非データディスクリプタ
   _get__()


# __set__()を持つクラスはデータデスクリプタ
>>> class TextField:
...   def __set_name__(self, owner, name):
...     print(f'__set_name__ was called')
...     print(f'{owner=}, {name=}')
...     self.name = name
...   def __set__(self, instance, value):
...     print('__set__ was called')
...     if not isinstance(value, str):
...       raise AttributeError('must be str')
...     # ドット記法ではなく属性辞書を使って格納
...     instance.__dict__[self.name] = value
...   def __get__(self, instance, owner):
...     print('__get__ was called')
...     return instance.__dict__[self.name]

>>> class Book:
...   title = TextField()
...
__set_name__ was called
owner=<class '__main__.Book'>, name='title


>>> book = Book()
# 代入時にはTextFieldの__set__()が呼ばれる
>>> book.title = 'Python Practice Book'
__set__ was called

# 取得時には__get__()が呼ばれる
>>> book.title
__get__ was called
'Python Practice Book'

# 別のインスタンスを作成して代入
>>> notebook = Book()
>>> notebook.title = 'Notebook'
__set__ was called

# それぞれデータを保持している
>>> book.title
__get__ was called
'Python Practice Book'

>>> notebook.title
__get__ was called
'Notebook

デスクリプタの実例(非データディスクリプタ) - プロパティのキャッシュ

>>> class LazyProperty:
...   def __init__(self, func):
...     self.func = func
...     self.name = func.__name__
...   def __get__(self, instance, owner):
...     if not instance:
...       # クラス変数としてアクセスされたときの処理
...       return self
...     # self.funcは関数なので明示的にインスタンスを渡す
...     v = self.func(instance)
...     instance.__dict__[self.name] = v
...     return v

>>> TAX_RATE = 1.10
>>> class Book:
...   def __init__(self, raw_price):
...     self.raw_price = raw_price
...   @LazyProperty
...   def price(self):
...     print('calculate the price')
...     return int(self.raw_price * TAX_RATE)
...
>>> book = Book(1980)
>>> book.price
calculate the price
2178

>>> book.price
2178

その他の実例

・汎用のO/RマッパライブラリであるSQLAlchemyのsqlalchemy.Column()


まとめ

ジェネレータ、デコレータ、コンテキストマネージャー、デスクリプタ日頃から別言語でも同じような概念の処理はやっていたけど、いまいちその実装までは理解できなかったので、非常に勉強になりました。

もちろん、上記だけで完全に理解した(自由につかにこなせる)わけではないので、まずは「ジェネレータ、デコレータ、コンテキストマネージャー、デスクリプタ」を意識して、他人のコードでもすんなり理解でき、コードを書く際に思い出せるようしようと思います。

この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?