1
1
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

クラスメソッドでcacheを使うときの注意点

Last updated at Posted at 2024-06-11

まえがき

クラスのメソッドをmemoizeしたいとき、安易にlru_cacheを使ってはいけない

参考

引用元

実例

元のコードはこのような形

import time

class C:
    def __init__(self, y: int) -> None:
        self.y = y

    def compute(self, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return self.y * x

    def __del__(self) -> None:
        print(f'delete {self}')

compute が呼び出されるたびに計算が走り 0.5 秒かかる

>>> c = C(4)
>>> c.compute(1)
computing...
4
>>> c.compute(1)
computing...
4

ダメな例

class C_bad:
    def __init__(self, y: int) -> None:
        self.y = y

    @functools.lru_cache(maxsize=128)
    def compute(self, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return self.y * x

    def __del__(self) -> None:
        print(f'delete {self}')

このように書くことで compute の結果がキャッシュ化され 0.5 秒の待機は不要となる

>>> c = C(4)
>>> c.compute(1)
computing...
4
>>> c.compute(1)
computing...
4

しかし、このときインスタンスを消去してもメモリを開放することができなくなる

>>> c = None
delete <__main__.C object at 0x000001C7D7FDB790>
>>> c_bad = C_bad(4)
>>> c_bad.compute(1)
computing...
4
>>> c_bad.compute(1)
4
>>> c_bad = None

良い例(動画で紹介)

このような書き方によりメモリリークを回避できる

class C_1:
    def __init__(self, y: int) -> None:
        self.y = y
        self.compute = functools.lru_cache(maxsize=128)(self._compute)

    def _compute(self, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return self.y * x

    def __del__(self) -> None:
        print(f'delete {self}')


>>> s = C_1(4)
>>> s.compute(4)   
computing...
16
>>> s.compute(4)
16
>>> del s
>>> import gc
>>> gc.collect()
delete <__main__.C_1 object at 0x0000023202ADB550>
36

良い例(今回提案する方法)

class C_2:
    def __init__(self, y: int) -> None:
        self.y = y

    def compute(self, x: int) -> int:
        return self._compute(self.y, x)

    @staticmethod
    @functools.lru_cache(maxsize=128)
    def _compute(y: int, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return y * x

    def __del__(self) -> None:
        print(f'delete {self}')

>>> g = C_2(4)
>>> g.compute(1) 
computing...
4
>>> g.compute(1)
4
>>> del g
delete <__main__.C_2 object at 0x0000023202DF96D0>

こちらの書き方でもメモリリークを回避できる

メリット

  1. docstring やIDEの補完を有効にすることができる
  2. 即座にメモリ解放が行われる

気をつけるべきこと

次に示す例では compute関数が list のインスタンス変数 self.yl を利用する
このとき C_1 は動作するが、C_2 は動作しない
なぜなら C_2 では list をハッシュ化しようとするからだ

class C_1:
    def __init__(self, yl: list) -> None:
        self.yl = yl
        self.compute = functools.lru_cache(maxsize=128)(self._compute)

    def _compute(self, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return sum(self.yl) * x

    def __del__(self) -> None:
        print(f'delete {self}')

class C_2:
    def __init__(self, yl: list) -> None:
        self.yl = yl

    def compute(self, x: int) -> int:
        return self._compute(self.yl, x)

    @staticmethod
    @functools.lru_cache(maxsize=128)
    def _compute(yl: list, x: int) -> int:
        print('computing...')
        time.sleep(.5)
        return yl * x

    def __del__(self) -> None:
        print(f'delete {self}')


>>> c = C_1([2,1]) 
>>> c.compute(1)
computing...
3
>>> c.compute(1)
3
>>> c.compute(1)
3
>>> import gc 
>>> gc.collect()
delete <__main__.C_1 object at 0x000001BB7894B550>
37
>>> c = C_2([2,1])
>>> c.compute(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "xxxxxx\method_cache2.py", line 34, in compute
    return self._compute(self.yl, x)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'list'

下の例にあるように、list はハッシュ化できないが、インスタンスはハッシュ化できる。

>>> c = C_1([2,1])
>>> hash(c)
119043543925
>>> hash(c.yl)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

一方で、インスタンスのハッシュ値は変数を変更しても変わらないため
そのことに注意して実装する必要がある

下の例では途中で self.yl の値を変更したが、それ以前に compute のキャッシュがあるとそれが出力され、ただしい結果が得られない

キャッシュをクリアすると正しい結果となる
(最初の例である self.y が int のときも同様の注意が必要)

>>> c.compute(2)
computing...
6
>>> c.yl = [1, 2, 3]
>>> c.compute(2)     
6
>>> hash(c)
119043543925
>>> c.compute.cache_clear()
>>> c.compute(2) 
computing...
12
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1