typing.NamedTuple 和可变默认参数

9

假设我想正确地使用typing模块的命名元组类型注释:

from typing import NamedTuple, List

class Foo(NamedTuple):
    my_list: List[int] = []

foo1 = Foo()
foo1.my_list.append(42)

foo2 = Foo()
print(foo2.my_list)  # prints [42]

什么是Python中避免可变默认值错误的最佳或最干净的方法?我有一些想法,但似乎没有什么好的解决方案。
  1. Using None as default

    class Foo(NamedTuple):
        my_list: Optional[List[int]] = None
    
    foo1 = Foo()
    if foo1.my_list is None
      foo1 = foo1._replace(my_list=[])  # super ugly
    foo1.my_list.append(42)
    
  2. Overwriting __new__ or __init__ won't work:

    AttributeError: Cannot overwrite NamedTuple attribute __init__
    AttributeError: Cannot overwrite NamedTuple attribute __new__
    
  3. Special @classmethod

    class Foo(NamedTuple):
        my_list: List[int] = []
    
        @classmethod
        def use_me_instead(cls, my_list=None):
           if not my_list:
               my_list = []
           return cls(my_list)
    
    foo1 = Foo.use_me_instead()
    foo1.my_list.append(42)  # works!
    
  4. Maybe using frozenset and avoid mutable attributes altogether? But that won't work with Dicts as there are no frozendicts.

有没有人有一个好的答案?


5
你考虑过使用数据类吗?这可以让你为一个字段指定默认工厂而不是默认值。 - chepner
1
@chepner,我认为你可以把这个变成一个合适的答案。看起来你真的不想让可变容器作为typing.NamedTuple的属性。 - Sebastian Wagner
2
我有一种难以表达的松散感觉,即命名元组可能不应该为其元素设置默认值。 - chepner
1
就我所知,我并不认为你在问题中提出的classmethod解决方案有任何问题,这也是我可能会选择的方法 - 尽管我会在类的主体中去掉默认值,以便默认值只能通过classmethod来实现。 - Alex Waygood
2
@AlexWaygood 我认为 classmethod 的方法是危险的,因为我们仍然让实例化 Foo 的标准方式存在缺陷。但我能理解你的观点。 - Sebastian Wagner
3个回答

7

使用数据类而不是命名元组。数据类允许字段指定默认的工厂而不仅仅是单个默认值。

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Foo:
    my_list: List[int] = field(default_factory=list)

3

编辑:更新为使用Alex的方法,因为这比我之前的想法要好得多。

这里是将Alex的Foo类放入装饰器中:

from typing import NamedTuple, List, Callable, TypeVar, cast, Type
T = TypeVar('T')

def default_factory(**factory_kw: Callable) -> Callable[[Type[T]], Type[T]]:
    def wrapper(wcls:  Type[T]) -> Type[T]:
        def du_new(cls: Type[T], **kwargs) -> T:
            for key, factory in factory_kw.items():
                if key not in kwargs:
                    kwargs[key] = factory()
            return super(cls, cls).__new__(cls, **kwargs)  # type: ignore[misc]
        return type(f'{wcls.__name__}_', (wcls, ), {'__new__': du_new})
    return wrapper

@default_factory(my_list=list)
class Foo(NamedTuple):
    my_list: List[int] = []  # you still need to define the default argument

foo1 = Foo()
foo1.my_list.append(42)

foo2 = Foo()
print(foo2.my_list)  # prints []
#reveal_type(foo2) # prints Tuple[builtins.list[builtins.int], fallback=foo.Foo]

1
我喜欢这个想法,但我认为它存在一些小问题。如果您将其输入交互式REPL中,然后键入>>>Foo,输出结果是<class'__main__.Foo_'>。同样,如果您输入Foo.__mro__,输出结果是(<class'__main__.Foo_'>,<class'__main__.Foo'>,<class'tuple'>,<class'object'>),如果您输入Foo.__new__.__name__,输出结果是'du_new'。我已经编辑了我的答案,结合我们的方法并提供了一个解决方案来解决这些内省问题! - Alex Waygood
我最喜欢的是,我们基本上创建了一个“隐形”类_Foo,你只能在Foo__mro__中找到对它的引用。它不存在于全局命名空间中。在REPL中键入_Foo会出现NameError。深奥的魔法。 - Alex Waygood
这种魔法只是必需的,因为我们不能直接在NamedTuple中覆盖__new__。 :-( - Sebastian Wagner
1
没错,这只是因为它使用了元类,对吧?而且 NamedTuple 元类还为我们做了许多其他很棒的事情,所以我认为在这个方案中忍受这点小困难是值得的。 - Alex Waygood

2

编辑:

将我的方法与Sebastian Wagner的使用装饰器的想法相结合,我们可以实现以下效果:

from typing import NamedTuple, List, Callable, TypeVar, Type, Any, cast
from functools import wraps

T = TypeVar('T')

def default_factory(**factory_kw: Callable[[], Any]) -> Callable[[Type[T]], Type[T]]:
    def wrapper(wcls: Type[T], /) -> Type[T]:
        @wraps(wcls.__new__)
        def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T:
            for key, factory in factory_kw.items():
                kwargs.setdefault(key, factory())
            new = super(cls, cls).__new__(cls, *args, **kwargs) # type: ignore[misc]
            # This call to cast() is necessary if you run MyPy with the --strict argument
            return cast(T, new)
        cls_name = wcls.__name__
        wcls.__name__ = wcls.__qualname__ = f'_{cls_name}'
        return type(cls_name, (wcls, ), {'__new__': __new__, '__slots__': ()})
    return wrapper

@default_factory(my_list=list)
class Foo(NamedTuple):
    # You do not *need* to have the default value in the class body,
    # but it makes MyPy a lot happier
    my_list: List[int] = [] 
    
foo1 = Foo()
foo1.my_list.append(42)

foo2 = Foo()
print(f'foo1 list: {foo1.my_list}')     # prints [42]
print(f'foo2 list: {foo2.my_list}')     # prints []
print(Foo)                              # prints <class '__main__.Foo'>
print(Foo.__mro__)                      # prints (<class '__main__.Foo'>, <class '__main__._Foo'>, <class 'tuple'>, <class 'object'>)
from inspect import signature
print(signature(Foo.__new__))           # prints (_cls, my_list: List[int] = [])

将其通过MyPy运行,MyPy告知我们foo1foo2的类型仍为"Tuple[builtins.list[builtins.int], fallback=__main__.Foo]"

以下是原始答案。


这个怎么样?(灵感来源于这个答案):

from typing import NamedTuple, List, Optional, TypeVar, Type

class _Foo(NamedTuple):
    my_list: List[int]


T = TypeVar('T', bound="Foo")


class Foo(_Foo):
    "A namedtuple defined as `_Foo(mylist)`, with a default value of `[]`"
    __slots__ = ()

    def __new__(cls: Type[T], mylist: Optional[List[int]] = None) -> T:
        mylist = [] if mylist is None else mylist
        return super().__new__(cls, mylist)  # type: ignore


f, g = Foo(), Foo()
print(isinstance(f, Foo))  # prints "True"
print(isinstance(f, _Foo))  # prints "True"
print(f.mylist is g.mylist)  # prints "False"

将其通过MyPy运行,fg的类型将显示为:"Tuple[builtins.list[builtins.int], fallback=__main__.Foo]"

我不确定为什么我必须添加# type: ignore才能让MyPy停止抱怨 - 如果有人可以启示我,我会很感兴趣。在运行时似乎运行良好。


太好了,谢谢!你可以使用cls._field_defaults.items()isinstance(value, Field)以及value.default_factory()来结合NamedTuplefrom dataclasses import field - rysson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接