我正在构建一个处理序列化数据的API,我更愿意添加对尽可能多的静态分析的支持。我受到 Django 在类上声明元数据的Meta
模式的启发,并使用类似于pydantic
的内部库来检查序列化的类型注释。
我希望 API 的工作方式如下:
class Base:
Data: type
def __init__(self):
self.data = self.Data()
class Derived(Base):
class Data:
# Actually, more like a pydantic model.
# Used for serialization.
attr: str = None
obj = Derived()
type(obj.data) # Derived.Data
obj.data.attr = 'content'
这很好用并且可读,但是它似乎根本不支持静态分析。如何在Base
中注释self.data
,以便我在obj
上获得正确的类型信息?
reveal_type(obj.data) # Derived.Data
reveal_type(obj.data.attr) # str
obj.data.attr = 7 # Should be static error
obj.data.other = 7 # Should be static error
我可能会写self.data: typing.Self.Data
但这显然不起作用。
我能够通过typing.Generic
和转发引用获得接近的东西:
import typing
T = typing.TypeVar('T')
class Base(typing.Generic[T]):
Data: type[T]
def __init__(self):
self.data: T = self.Data()
class Derived(Base['Derived.Data']):
class Data:
attr: str = None
但它不是 DRY,它不会强制注释和运行时类型实际匹配。例如:
class Derived(Base[SomeOtherType]):
class Data: # Should be static error
attr: str = None
type(obj.data) # Derived.Data
reveal_type(obj.data) # SomeOtherType
我也可以要求派生类为data
提供注释,但这会遇到与typing.Generic
类似的问题。
class Derived(Base):
data: SomeOtherClass # should be 'Data'
class Data: # should be a static error
attr: str = None
为了尝试解决此问题,我尝试在__init_subclass__
中编写一些验证逻辑,以确保T
匹配cls.data
; 但是这很脆弱,并非在所有情况下都有效。它还禁止创建任何不定义Data
的抽象派生类。
这实际上并不平凡,因为您遇到了想要动态创建类型的经典问题,同时让静态类型检查器理解它们。一个明显的矛盾。
<小时 />快速皮丹特语题外话
既然你提到了Pydantic,我就接手了。他们解决它的方法,大大简化,是从来没有实际实例化内部Config
类。相反,__config__
属性是在类上设置的,只要你子类BaseModel
并且此属性本身是一个类(意味着type
的实例)。
__config__
引用的该类继承自BaseConfig
,并由ModelMetaclass
构造函数动态创建。在此过程中,它继承了模型基类设置的所有属性,并用您在内部Config
中设置的任何属性覆盖它们。
您可以在此示例中看到结果:
from pydantic import BaseConfig, BaseModel
class Model(BaseModel):
class Config:
frozen = True
a = BaseModel()
b = Model()
a_conf = a.__config__
b_conf = b.__config__
assert isinstance(a_conf, type) and issubclass(a_conf, BaseConfig)
assert isinstance(b_conf, type) and issubclass(b_conf, BaseConfig)
assert not a_conf.frozen
assert b_conf.frozen
顺便说一下,这就是为什么你不应该在代码中直接引用内部Config
。它只会具有您在该类上显式设置的属性,并且不会继承任何内容,甚至不会继承BaseConfig
的默认值。访问完整模型配置的记录方法是通过__config__
。
这也是为什么没有模型实例配置这样的东西。更改__config__
的属性,您将为整个类/模型更改它:
from pydantic import BaseModel
foo = BaseModel()
bar = BaseModel()
assert not foo.__config__.frozen
bar.__config__.frozen = True
assert foo.__config__.frozen
<小时 />可能的解决方案
此方法的一个重要约束是,只有当您拥有所有这些动态创建的类都可以从中继承的固定类型时,它才真正有意义。在 Pydantic 的情况下,它是BaseConfig
的,并且相应地注释了__config__
属性,即使用type[BaseConfig]
,这允许静态类型检查器推断该__config__
类的接口。
当然,你可以走相反的道路,允许为类Data
定义任何内部类,但这可能违背了设计的目的。不过它可以正常工作,您可以通过元类挂钩到类创建中,以强制设置Data
和类。您甚至可以强制设置该内部类上的特定属性,但此时您不妨为此提供一个公共基类。
如果你想复制Pydantic方法,我可以给你一个非常粗略的例子,说明如何做到这一点,基本思想无耻地从Pydantic代码中窃取(或受到启发)。
您可以设置一个BaseData
类,并完全定义其注释和类型推断的属性。然后,设置自定义元类。在其__new__
方法中,执行继承循环以动态生成新的BaseData
子类,并将结果分配给新外部类的__data__
属性:
from __future__ import annotations
from typing import ClassVar, cast
class BaseData:
foo: str = "abc"
bar: int = 1
class CustomMeta(type):
def __new__(
mcs,
name: str,
bases: tuple[type],
namespace: dict[str, object],
**kwargs: object,
) -> CustomMeta:
data = BaseData
for base in reversed(bases):
if issubclass(base, Base):
data = inherit_data(base.__data__, data)
own_data = cast(type[BaseData], namespace.get('Data'))
data = inherit_data(own_data, data)
namespace["__data__"] = data
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
return cls
def inherit_data(
own_data: type[BaseData] | None,
parent_data: type[BaseData],
) -> type[BaseData]:
if own_data is None:
base_classes: tuple[type[BaseData], ...] = (parent_data,)
elif own_data == parent_data:
base_classes = (own_data,)
else:
base_classes = own_data, parent_data
return type('Data', base_classes, {})
... # more code below...
有了这个,您现在可以定义Base
类,使用type[BaseData]
注释其命名空间中的__data__
,并将BaseData
分配给其Data
属性。所有派生类上的内部Data
类现在可以只定义那些与其父类Data
不同的属性。要证明这有效,请尝试以下操作:
... # Code from above
class Base(metaclass=CustomMeta):
__data__: ClassVar[type[BaseData]]
Data = BaseData
class Derived1(Base):
class Data:
foo = "xyz"
class Derived2(Derived1):
class Data:
bar = 42
if __name__ == "__main__":
obj0 = Base()
obj1 = Derived1()
obj2 = Derived2()
print(obj0.__data__.foo, obj0.__data__.bar) # abc 1
print(obj1.__data__.foo, obj1.__data__.bar) # xyz 1
print(obj2.__data__.foo, obj2.__data__.bar) # xyz 42
静态类型检查器当然也知道从__data__
属性中得到什么,IDE应该给出适当的自动建议。如果在底部添加reveal_type(obj2.__data__.foo)
和reveal_type(obj2.__data__.bar)
并对代码运行mypy
,它将输出显示的类型分别str
和int
。
注意事项
这种方法的一个重要缺点是继承被抽象出来,静态类型检查器将内部Data
类视为与BaseData
无关的自己的类,这是有道理的,因为它就是这样;它只是从object
继承。
因此,您将不会得到有关 IDE 可以覆盖Data
的属性的任何建议。这与Pydantic的交易相同,这也是他们为mypy
和PyCharm推出自己的自定义插件的原因之一。后者允许 PyCharm 在你在任何派生类上编写内部Data
类时向你建议BaseConfig
属性。
我知道我已经提供了一个答案,但是在反复之后,我想到了另一种可能的解决方案,涉及与我之前提出的完全不同的设计。我认为这提高了可读性,如果我将其作为第二个答案发布。
没有内部类;只有一个类型参数
有关如何访问子类化期间提供的 type 参数的详细信息,请参阅此处。
from typing import Generic, TypeVar, get_args, get_origin
D = TypeVar("D", bound="BaseData")
class BaseData:
foo: str = "abc"
bar: int = 1
class Base(Generic[D]):
__data__: type[D]
@classmethod
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__: # type: ignore[attr-defined]
origin = get_origin(base)
if origin is None or not issubclass(origin, Base):
continue
type_arg = get_args(base)[0]
# Do not set the attribute for GENERIC subclasses!
if not isinstance(type_arg, TypeVar):
cls.__data__ = type_arg
return
用法:
class Derived1Data(BaseData):
foo = "xyz"
class Derived1(Base[Derived1Data]):
pass
class Derived2Data(Derived1Data):
bar = 42
baz = True
class Derived2(Base[Derived2Data]):
pass
if __name__ == "__main__":
obj1 = Derived1()
obj2 = Derived2()
assert "xyz" == obj1.__data__.foo == obj2.__data__.foo
assert 42 == obj2.__data__.bar
assert not hasattr(obj1.__data__, "baz")
assert obj2.__data__.baz
为mypy
添加reveal_type(obj1.__data__)
和reveal_type(obj2.__data__)
将分别显示type[Derived1Data]
和type[Derived2Data]
。
缺点是显而易见的:它不是你心目中的"内部类"设计。
好处是完全类型安全,同时需要最少的代码。用户只需要提供自己的BaseData
子类作为类型参数,当子类化时Base
。
添加实例(可选)
如果您希望__data__
成为指定BaseData
子类的实例属性和实际实例,这也很容易实现。这是一个粗略但有效的例子:
from typing import Generic, TypeVar, get_args, get_origin
D = TypeVar("D", bound="BaseData")
class BaseData:
foo: str = "abc"
bar: int = 1
def __init__(self, **kwargs: object) -> None:
self.__dict__.update(kwargs)
class Base(Generic[D]):
__data_cls__: type[D]
__data__: D
@classmethod
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__: # type: ignore[attr-defined]
origin = get_origin(base)
if origin is None or not issubclass(origin, Base):
continue
type_arg = get_args(base)[0]
# Do not set the attribute for GENERIC subclasses!
if not isinstance(type_arg, TypeVar):
cls.__data_cls__ = type_arg
return
def __init__(self, **data_kwargs: object) -> None:
self.__data__ = self.__data_cls__(**data_kwargs)
用法:
class DerivedData(BaseData):
foo = "xyz"
baz = True
class Derived(Base[DerivedData]):
pass
if __name__ == "__main__":
obj = Derived(baz=False)
print(obj.__data__.foo) # xyz
print(obj.__data__.bar) # 1
print(obj.__data__.baz) # False
同样,静态类型检查器将知道__data__
属于DerivedData
类型。
不过,我想在这一点上,您不妨让用户在初始化Derived
期间提供他自己的BaseData
子类实例。无论如何,也许这是一个更干净,更直观的设计。
我认为您最初的想法只有在您为静态类型检查器推出自己的插件时才有效。
它不是完全干燥的,但鉴于 @daniil-fajnberg 的建议,我认为这可能是可取的。显式比隐式更好,对吧?
这个想法是要求派生类为数据指定类型注释;类型检查器会很高兴,因为派生类都使用正确的类型进行注释,而基类只需要检查该单个注释即可确定运行时类型。
from typing import ClassVar, TypeVar, get_type_hints
class Base:
__data_cls__: ClassVar[type]
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
hints = get_type_hints(cls)
if 'data' in hints:
if isinstance(hints['data'], TypeVar):
raise TypeError('Cannot infer __data_cls__ from TypeVar.')
cls.__data_cls__ = hints['data']
def __init__(self):
self.data = self.__data_cls__()
用法如下所示。请注意,数据类型的名称和数据属性不再耦合。
class Derived1(Base):
class TheDataType:
foo: str = ''
bar: int = 77
data: TheDataType
print('Derived1:')
obj1 = Derived1()
reveal_type(obj1.data) # Derived1.TheDataType
reveal_type(obj1.data.foo) # str
reveal_type(obj1.data.bar) # int
这种解耦意味着您不需要使用内部类型。
class Derived2(Base):
data: Derived1.TheDataType
print('Derived3:')
obj2 = Derived2()
reveal_type(obj2.data) # Derived1.TheDataType
reveal_type(obj2.data.foo) # str
reveal_type(obj2.data.bar) # int
我认为在此解决方案中不可能支持泛型子类。在某些情况下,可以调整 https://stackoverflow.com/a/74788026/4672189 中的代码以获取运行时类型。