Предположим, у меня есть интерфейс Base
с множеством реализаций.
from abc import ABC
class Base(ABC): ...
class A(Base): ...
class B(Base): ...
class C(Base): ...
# ...
class Z(Base): ...
Теперь я хочу определить составной класс, содержащий замороженный набор таких объектов. Существует общий интерфейс Product
и две реализации, которые принимают либо гетерогенный замороженный набор (MixedProduct
), либо однородный замороженный набор Z
(ZProduct
).
from abc import ABC, abstractmethod
from dataclasses import dataclass
class Product(ABC):
@property
@abstractmethod
def items(self) -> frozenset[Base]: ...
@dataclass(frozen=True)
class MixedProduct(Product):
items: frozenset[Base]
@dataclass(frozen=True)
class ZProduct(Product):
items: frozenset[Z]
существует фабричная функция, которая принимает произвольное количество объектов Base
и возвращает правильный объект Product
from collections.abc import Iterable
from typing_extensions import TypeGuard
def check_all_z(items: tuple[Base, ...]) -> TypeGuard[tuple[Z, ...]]:
return all([isinstance(item, Z) for item in items])
def make_product(*items: Base) -> MixedProduct | ZProduct:
# `items` is a tuple[Base, ...]
if check_all_z(items): # the TypeGuard tells MyPy that items: tuple[Z, ...] in this clause
return ZProduct(frozenset(items))
return MixedProduct(frozenset(items))
поэтому эта функция возвращает ZProduct
, только если все входные элементы равны Z
и MixedProduct
в противном случае. Теперь я хотел бы сузить тип возвращаемого значения make_product
, поскольку объединение не фиксирует возможные отношения ввода и типа возвращаемого значения. Я хочу что-то вроде этого
reveal_type(make_product(Z())) # note: Revealed type is "ZProduct"
reveal_type(make_product(A())) # note: Revealed type is "MixedProduct"
reveal_type(make_product(Z(), Z())) # note: Revealed type is "ZProduct"
reveal_type(make_product(B(), A())) # note: Revealed type is "MixedProduct"
reveal_type(make_product(B(), Z())) # note: Revealed type is "MixedProduct" # also contains one Z!!
Я продолжу и определяю две перегрузки
from typing import overload
@overload
def make_product(*items: Base) -> MixedProduct: ...
@overload
def make_product(*items: Z) -> ZProduct: ...
def make_product(*items):
if check_all_z(
items
): # the TypeGuard tells MyPy that items: tuple[Z, ...] in this clause
return ZProduct(frozenset(items))
return MixedProduct(frozenset(items))
поэтому первая перегрузка — это «перехват всего», а вторая — это специализация для единственного случая, когда вы получите ZProduct
. Но теперь MyPy жалуется на
error: Overloaded function signature 2 will never be matched: signature 1's parameter type(s) are the same or broader [misc]
Итак, мой вопрос: есть ли способ просто специализировать аннотации для make_product
для этого конкретного случая, которые бы возвращали ZProduct
любым другим способом? С overload
кажется, что это возможно только в том случае, если все задействованные типы вообще не пересекаются. Это означало бы, что мне придется определить объединение всех других реализаций Base
, кроме Z
, и использовать его в качестве входных данных для варианта MixedProduct
. Но это тоже не работает, потому что вы можете иметь Z
во входных элементах для варианта MixedProduct
, но не во всех из них (см. последний пример show_type выше). FWIW при использовании объединения всех реализаций Base
(включая Z
) для варианта MixedProduct
выдает ту же ошибку MyPy.
Как еще я мог бы различать однородные и гетерогенные кортежи с аннотациями типов, чтобы в моем случае определить правильные отношения типов ввода-возврата?
Чтобы внести ясность: фактический код времени выполнения делает то, что я хочу, я просто не могу правильно определить аннотации типов.
ах да, спасибо за быстрый ответ! Действительно, у меня было так раньше, и я получил другую ошибку. Как мне устранить эту фундаментальную небезопасность типа? Кстати, если вы напишете ответ, я его приму :-)
Статического вывода ZProduct
из make_product
можно добиться, поменяв местами @overload
, например:
@overload
def make_product(item: Z, /, *items: Z) -> ZProduct: ... # type: ignore[overload-overlap]
@overload
def make_product(item: Base, /, *items: Base) -> MixedProduct: ...
def make_product(*items: Base) -> MixedProduct | ZProduct:
if check_all_z(items):
return ZProduct(frozenset(items))
return MixedProduct(frozenset(items))
# type: ignore[overload-overlap]
подавляет ошибку mypy, которая предупреждает вас о небезопасности типа:
>>> z1: Base = Z()
>>> z2: Base = Z()
>>> reveal_type(make_product(z1, z2)) # revealed type is "MixedProduct"
Эта небезопасность очень плоха, поскольку MixedProduct
не является суперклассом ZProduct
, поэтому статические ошибки могут легко распространиться по всему коду, если вы не будете осторожны.
Есть несколько способов уменьшить эту опасность, например:
Base
(используйте конкретные типы A
, B
, ... везде, где это возможно) и избегайте союзов A
, B
, ... с Z
. Таким образом, когда элементы передаются в make_product
, средства проверки типов будут статически знать, когда вы передали чисто Z
объекты, и правильно сообщат вам, что результирующий тип — ZProduct
.MixedProduct
родительским классом ZProduct
. Таким образом, даже если ZProduct
не будет обнаружен из-за того, что экземпляры Z
замаскированы под экземпляры Base
, вывод MixedProduct
будет простой потерей точности типа, а не совершенно неправильным типом.большое спасибо! Интересно, я никогда не думал о возможности явно аннотировать Z
как Base
. Я не думаю, что это когда-нибудь произойдет в моем коде, но полезно об этом знать! Спасибо за ваш совет!
@ Darkdragon84 Это был урезанный пример того, что может пойти не так. Если у вас был list[Base]
, который вы накопили с помощью другой операции, и после этой операции список оказался заполнен Z
, если вы распакуете список в make_product
, результат также будет статически выведен как MixedProduct
- что неверно.
Я вижу, действительно. Я буду следить за таким случаем. Интересно, что такое, казалось бы, простое определение может иметь такие сложные последствия...
Поменяйте местами
@overload
. Если у вас возникнут какие-либо дальнейшие ошибки mypy, связанные сoverload-overlap
,# type: ignore
ими — в том, что вы пытаетесь сделать здесь, есть фундаментальная небезопасность типа, но это вряд ли существенно повлияет на ваш код, если только у вас не будет много ситуаций, похожих наa: Base = Z()
(преобразование экземпляровZ
вBase
).