問題提起
Python 3.7 で新たに登場したdataclass
デコレータは、クラスに様々なメソッドを自動的に付加してくれる。
例えば、次のようにすることで、__init__()
と__hash__()
と__eq__()
を適切に実装したnamedtuple
のようなものを作ってくれる。
from dataclasses import dataclass
@dataclass(eq=True, frozen=True)
class Point3D:
"""Immutable struct for 3-D points"""
x: float
y: float
z: float = 0.0 # default value
if __name__ == '__main__':
p = Point3D(x=3.14, y=2.72)
print(p) # -> Point3D(x=3.14, y=2.72, z=0.0)
q = Point3D(4.6, 9.3, 0.0)
print(p == q) # -> False
another_p = Point3D(3.14, 2.72)
print(p == another_p) # -> True
d = dict(p='Point3D can be', q='a key of a dictionary')
このインスタンス、p
とq
は、当然hashable
である。
from dataclass_example import Point3D
from typing import Hashable
p = Point3D(x=3.14, y=2.72)
print(hash(p)) # -> -7688538011378258102
print(isinstance(p, Hashable)) # -> True
ところが、このクラスはHashable
を継承できない。
以下のコードはエラーになるのである。
from dataclasses import dataclass
from typing import Hashable
@dataclass(eq=True, frozen=True)
class HashablePoint3D(Hashable): # Explicitly inherit from Hashable (actually it's a protocol)
x: float
y: float
z: float = 0.0
if __name__ == '__main__':
p = HashablePoint3D(x=3.14, y=2.72)
# -> TypeError: Can't instantiate abstract class HashablePoint3D with abstract methods __hash__
Point3D
はインスタンスが__hash__()
メソッドを持つにもかかわらず、Hashable
を明示的に継承することができない。
Hashable
に限らず、dataclass
によって付加されるメソッドを要求するほとんどの Abstract Base Class が、継承できずエラーになるはずだ。
理由
デコレータはただのラッパーで、__new__()
を上書きするだけなのだが、その前にABCMeta
の監査が入ってしまうため、__hash__()
が実装される前にHashable
かどうかの判定が行われてしまい、エラーが出てしまう。
dataclass
はクラスの生成時ではなく、値の生成時に作用しているということだ。
以下では、このことをもう少し詳しく説明する。
復習: デコレータの中身
デコレータとは、メソッドやクラスに作用するラッパーである。
from typing import Callable, Any
def sample_decorator(target_function: Callable[[int], str]) -> Callable[[int], str]:
# decorated_function = ( lambda x: decoration(target_function(x)) )
def decorated_function(x: int) -> str:
original_return_value = target_function(x)
return f'Decorated! Original return value is: { original_return_value }'
return decorated_function
@sample_decorator
def sample(x: int) -> str:
return f'x is {x}'
if __name__ == '__main__':
print(sample(1)) # -> Decorated! Original return value is: x is 1
sample_decorator
は、対象であるsample
メソッドを受け取って、それをラップした新たなメソッドを返す。
@sample_decorator
を付けることで、元のsample
メソッドにそのラッパーを作用させたメソッドをsample
として改めて定義することができる。
復習: Abstract Base Class
Abstract Base Classとは、型チェックのないPythonにおいて、その型が特定のメソッドをサポートしていることを保証する構文である。
from abc import ABC, abstractmethod
class Button:
def push(self) -> str:
raise NotImplementedError()
class UnsafeButton(Button):
"""Ouch you unfortunately forget to implement push()"""
pass
class ABCButton(ABC): # Inheritance from ABC is the syntax sugar of metaclass=abc.ABCMeta
@abstractmethod # Declare that this method needs implementation
def push(self) -> str:
raise NotImplementedError()
class SafeButton(ABCButton)
"""Again you forget to implement push()"""
pass
def do_stuff_and_push_the_button(button):
do_your_complicated_stuff(button)
button.push()
if __name__ == '__main__':
unsafe_button = UnsafeButton() # -> (No error)
do_stuff_and_push_the_button(unsafe_button) # -> NotImplementedError
safe_button = SafeButton() # -> TypeError: Can't instantiate abstract class SafeButton with abstract methods push
インスタンス生成時に怒ってくれるので、そのクラスのインスタンスが作れている段階で全てのabstractmethod
が実装されていることを保証できる。
復習: Pythonの__new__()
と__init__()
Pythonにおけるコンストラクタは2種類ある。__new__(cls)
はcls
のインスタンスを生成し、__init__(self, ...)
は生成したクラスのインスタンスself
に対して初期化処理を行う。
class Test:
def __new__(cls):
instance = super().__new__(cls)
print('new')
return instance
def __init__(self):
super().__init__()
print('init')
if __name__ == '__main__':
t = Test() # -> new, init
大事なのは、必ず__new__()
を呼び出した後に__init__()
が呼ばれること。
__new__()
より先に、生成されるインスタンスに対して処理を加えることはできない。
本題
対象となるクラスを再掲する。
from dataclasses import dataclass
from typing import Hashable
@dataclass(eq=True, frozen=True)
class HashablePoint3D(Hashable):
...
この@dataclass
はデコレータであるから、クラスを受け取ってクラスを返すメソッドである。
その中身は、たぶん「受け取ったクラスの__new__()
を、以下のように書き換える」という内容なのだろう。
def __new__(new_cls):
instance = old_cls.__new___()
instance.__hash__ = ...
instance.__eq__ = ...
instance.__awesome_methods__ = ...
return instance
すなわち、__new__()
メソッドでインスタンスを生成する際に、種々のメソッドを付加するというものである。
ところが、このデコレータの作用対象であるold_cls
は、Hashable
を継承している。
from abc import ABC, abstractmethod
class Hashable(ABC):
@abstractmethod
def __hash__(self) -> int:
raise NotImplementedError()
したがって、
instance = old_cls.__new___()
の中身は、次のようになっている。
def __new__(old_cls):
instance = object.__new__(old_cls)
ABC_abstract_implementaion_check(instance) # -> Here we have NOT implemented the __hash__() yet!
この段階ではまだ__hash__()
は実装されていないため、abc.ABC
によってTypeError
が送出されてしまう。
すなわち、
- 対象となるクラスの
__new__()
、すなわちデコレータの__new__()
が呼び出される。 - デコレータの
__new__()
内でsuper().__new__()
、すなわちABC
の__new__()
が呼び出される。 -
ABC
の__new__()
内でsuper().__new__()
、すなわちobject
の__new__()
が呼び出される。 -
object
の__new__()
によってインスタンスが生成される。 -
ABC
の__new__()
による型検査が入り、この時点で全てのabstractmethod
を実装していないとTypeError
が送出される。 - デコレータの
__new__()
によって種々のメソッドが付与される。
という順番で処理が行われ、5.の段階でエラーになってしまう。
これが事件の真相である。
対策
対策と言っても、Hashable
を明示的に継承するシンプルな方法は、たぶん存在しない。
__new__
の代わりにdataclass
に直接メソッドを付与するようなメタクラス操作をするデコレータを自分で書くとか、dataclass
でラップされたクラスをHashable
とは別に継承するとか、とにかくダサい方法しかないと思う。
だが、実際問題、Hashable
を明示的に継承する必要はない。
我々にはProtocol
という強力な武器があるからである。
from dataclasses import dataclass
from typing import Hashable
from abc import abstractmethod
from typing_extensions import Protocol
@dataclass(eq=True, frozen=True)
class Point3D:
x: float
y: float
z: float = 0.0
class MyHashable(Protocol):
@abstractmethod
def __hash__(self) -> int:
raise NotImplementedError()
def get_hashable_and_return_none(x: Hashable) -> None: pass
def get_myhashable_and_return_none(x: MyHashable) -> None: pass
if __name__ == '__main__':
p = Point3D(4.6, 9.3)
get_hashable_and_return_none(p) # Mypy (static type checker) finds no error
get_myhashable_and_return_none(p) # Also no error
Mypyは賢いので、デコレータが付与するメソッドもきちんと理解してくれる。
明示的に継承できないのは若干読みづらいかもしれないが、静的型チェック時にはProtocol
を利用するようにすることで一応解決する。
(どうもmypyのHashable
の確認はかなり怪しい挙動をするようなのだが、そこには目を瞑るほかないようだ。)
この記事が皆さんのPython生活の一助となれば幸いである。