Python中静态类型的一些陷阱


我认为我们正在慢慢适应Python具有类型注释的事实:它们在功能和方法的注释( PEP 484 )中带回了两个版本(3.5),而在变量( PEP 526 )的最新版本(3.6)中又带了两个版本。


由于这两个PEP 均受MyPy的启发, 因此我将告诉您使用此静态分析器以及整个打字系统时,世俗的喜悦和认知失调在等待着我什么。


Disclamer:我没有提出在Python中使用静态类型的必要性或有害性的问题。 我只是在谈论我在静态类型的上下文中工作时遇到的陷阱。

泛型(打字通用)


在注解中使用类似List[int]Callable[[int, str], None]东西很好。
分析器突出显示以下代码非常好:


 T = ty.TypeVar('T') class A(ty.Generic[T]): value: T A[int]().value = 'str' # error: Incompatible types in assignment # (expression has type "str", variable has type "int") 

但是,如果我们编写一个库,而使用它的程序员不会使用静态分析器,该怎么办?
强制用户使用值初始化类,然后存储其类型?


 T = ty.TypeVar('T') class Gen(Generic[T]): value: T ref: Type[T] def __init__(self, value: T) -> None: self.value = value self.ref = type(value) 

不知何故不友好。
但是,如果您想这样做怎么办?


 b = Gen[A](B()) 

在寻找这个问题的答案时,我经过一番typing ,然后跳入工厂的世界。

事实是,在初始化Generic类的实例之后,它将获得__origin_class__属性,该属性具有__args__属性,该属性是类型元组。 但是,不是从__init__以及从__new__ 。 而且它也不在__call__元类中。 诀窍在于,在初始化Generic的子类时Generic它将变成另一个元类_GenericAlias ,它会在对象初始化后(包括其元类的所有方法)或在__getithem__时设置最终类型。 因此,在构造对象时无法获得泛型类型。


我们抛出了这个垃圾,答应了更通用的解决方案。

因此,我为自己写了一个解决这个问题的小描述符:


 def _init_obj_ref(obj: 'Gen[T]') -> None: """Set object ref attribute if not one to initialized arg.""" if not hasattr(obj, 'ref'): obj.ref = obj.__orig_class__.__args__[0] # type: ignore class ValueHandler(Generic[T]): """Handle object _value attribute, asserting it's type.""" def __get__(self, obj: 'Gen[T]', cls: Type['Gen[T]'] ) -> Union[T, 'ValueHandler[T]']: if not obj: return self _init_obj_ref(obj) if not obj._value: obj._value = obj.ref() return obj._value def __set__(self, obj: 'Gen[T]', val: T) -> None: _init_obj_ref(obj) if not isinstance(val, obj.ref): raise TypeError(f'has to be of type {obj.ref}, pasted {val}') obj._value = val class Gen(Generic[T]): _value: T ref: Type[T] value = ValueHandler[T]() def __init__(self, value: T) -> None: self._value = value class A: pass class B(A): pass b = Gen[A](B()) b.value = A() b.value = int() # TypeError: has to be of type <class '__main__.A'>, pasted 0 

当然,因此,有必要重写以更广泛地使用它,但是本质很清楚。


[UPD]:早上,我决定尝试执行与typing模块本身相同的操作,但更简单:


 import typing as ty T = ty.TypeVar('T') class A(ty.Generic[T]): # __args are unique every instantiation __args: ty.Optional[ty.Tuple[ty.Type[T]]] = None value: T def __init__(self, value: ty.Optional[T]=None) -> None: """Get actual type of generic and initizalize it's value.""" cls = ty.cast(A, self.__class__) if cls.__args: self.ref = cls.__args[0] else: self.ref = type(value) if value: self.value = value else: self.value = self.ref() cls.__args = None def __class_getitem__(cls, *args: ty.Union[ty.Type[int], ty.Type[str]] ) -> ty.Type['A']: """Recive type args, if passed any before initialization.""" cls.__args = ty.cast(ty.Tuple[ty.Type[T]], args) return super().__class_getitem__(*args, **kwargs) # type: ignore a = A[int]() b = A(int()) c = A[str]() print([a.value, b.value, c.value]) # [0, 0, ''] 

[UPD]: typing开发人员Ivan Levinsky表示,这两种选择都可能无法预测。


无论如何,您可以使用任何方式。 也许__class_getitem__甚至更好一些,至少__class_getitem__是一个已记录的特殊方法(尽管它对泛型的行为不是)。

功能和别名


是的,泛型根本不容易:
例如,如果我们在某个地方接受一个函数作为参数,那么它的注解会自动从协变变为反变:


 class A: pass class B(A): pass def foo(arg: 'A') -> None: #   A  B ... def bar(f: Callable[['A'], None]): #       A ... 

原则上,我对逻辑没有任何抱怨,只需通过通用别名即可解决:


 TA = TypeVar('TA', bound='A') def foo(arg: 'B') -> None: #   B   ... def bar(f: Callable[['TA'], None]): #     A  B ... 

通常,必须仔细阅读有关类型变性的部分,并且应多次阅读。


向后兼容


并不是很热门:从3.7版起, GenericABCMeta的子类,非常方便且不错。 如果它在3.6上运行,则会破坏代码,这很不好。


结构继承(结构化原型)


刚开始我很高兴:接口已经交付了! 接口的作用是由类型类的Protocol来执行的,该模块与@runtime装饰器结合使用,可让您检查该类是否在没有直接继承的情况下实现了接口。 MyPy也将在更深层次上突出显示。


但是,与多重继承相比,我在运行时没有发现任何实际好处。
似乎装饰器仅检查具有所需名称的方法的存在,甚至不检查参数的数量,更不用说键入:


 import typing as ty import typing_extensions as te @te.runtime class IntStackP(te.Protocol): _list: ty.List[int] def push(self, val: int) -> None: ... class IntStack: def __init__(self) -> None: self._list: ty.List[int] = list() def push(self, val: int) -> None: if not isinstance(val, int): raise TypeError('wrong pushued val type') self._list.append(val) class StrStack: def __init__(self) -> None: self._list: ty.List[str] = list() def push(self, val: str, weather: ty.Any=None) -> None: if not isinstance(val, str): raise TypeError('wrong pushued val type') self._list.append(val) def push_func(stack: IntStackP, value: int): if not isinstance(stack, IntStackP): raise TypeError('is not IntStackP') stack.push(value) a = IntStack() b = StrStack() c: ty.List[int] = list() push_func(a, 1) push_func(b, 1) # TypeError: wrong pushued val type push_func(c, 1) # TypeError: is not IntStackP 

另一方面,MyPy则表现得更聪明,并突出了类型的不兼容性:


 push_func(a, 1) push_func(b, 1) # Argument 1 to "push_func" has incompatible type "StrStack"; # expected "IntStackP" # Following member(s) of "StrStack" have conflicts: # _list: expected "List[int]", got "List[str]" # Expected: # def push(self, val: int) -> None # Got: # def push(self, val: str, weather: Optional[Any] = ...) -> None 

操作员超载


一个非常新鲜的话题,因为 当给操作员提供全面的安全保护时,所有的乐趣就会消失。 这个问题在MyPy Bug跟踪器中屡次浮出水面,但在某些地方它仍然发誓,您可以放心地将其关闭。
我解释一下这种情况:


 class A: def __add__(self, other) -> int: return 3 def __iadd__(self, other) -> 'A': if isinstance(other, int): return NotImplemented return A() var = A() var += 3 # Inferred type is 'A', but runtime type is 'int'? 

如果复合赋值方法返回NotImplemented ,Python首先搜索__radd__ ,然后使用__add____add__


重载任何形式的子类方法也是如此:


 class A: def __add__(self, x : 'A') -> 'A': ... class B(A): @overload def __add__(self, x : 'A') -> 'A': ... @overload def __add__(self, x : 'B') -> 'B' : ... 

在某些地方,警告已经转移到文档中,而在某些地方,警告仍然适用于产品。 但是,贡献者的一般结论是让这种超载可以接受。

Source: https://habr.com/ru/post/zh-CN437018/


All Articles