Python3中的数据类(dataclass)如何使用**kwargs(星号)?

22

目前我使用的 DTO(数据传输对象)如下所示。

class Test1:
    def __init__(self, 
        user_id: int = None,
        body: str = None):
        self.user_id = user_id
        self.body = body

示例代码非常简单,但当对象规模变大时,我必须定义每个变量。

在深入研究时,发现Python 3.7支持dataclass

以下代码是使用dataclass的DTO。

from dataclasses import dataclass


@dataclass
class Test2:
    user_id: int
    body: str
在这种情况下,我如何允许传递更多未定义的参数到Test2中?
如果我使用Test1,那很容易。只需在__init__中添加**kwargs(星号)即可。
class Test1:
    def __init__(self, 
        user_id: int = None,
        body: str = None,
        **kwargs):
        self.user_id = user_id
        self.body = body

使用 dataclass,但找不到任何实现它的方法。

这里有什么解决方案吗?

谢谢。


编辑

class Test1:
    def __init__(self,
        user_id: str = None, 
        body: str = None):
        self.user_id = user_id
        self.body = body

if __name__ == '__main__':
    temp = {'user_id': 'hide', 'body': 'body test'}
    t1 = Test1(**temp)
    print(t1.__dict__)

结果: {'user_id': 'hide', 'body': 'body test'}

正如您所知,我想使用字典类型**temp来插入数据。

在数据类中使用星号的原因相同。

我必须将字典类型传递给类 init。

有什么想法吗?


1
init=False 传递给数据类装饰器,并手动实现 __init__。这有点违背了初衷,如果不显式定义属性,您的数据类就不会使用这些变量。本质上,您不需要一个数据类。 - juanpa.arrivillaga
1
@juanpa.arrivillaga 如果我手动实现__init__,那么使用dataclass就没有优势了吗?这是唯一的方法吗? - Hide
4
如果您不明确列举变量,那么您希望数据类如何知道要使用哪些变量?如果您想要动态属性(通常是个糟糕的设计选择,在我看来),那么您实际上并不需要一个数据类。 - juanpa.arrivillaga
@juanpa.arrivillaga 我添加了更多关于我的问题的信息,请参考。 - Hide
1
是的,这并不会改变任何事情,尽管请注意,您不需要使用**kwargs来实现这一点,即传递一个字典。 - juanpa.arrivillaga
请按照 dataclass 文档 中所述使用 default_factory - Chris
8个回答

24
数据类的基本用途是提供一个容器,将参数映射到属性。如果你有未知参数,在类创建期间就不能知道相应的属性。
如果在初始化期间知道哪些参数是未知的,可以通过手动将它们发送到 catch-all 属性来解决这个问题:
from dataclasses import dataclass, field


@dataclass
class Container:
    user_id: int
    body: str
    meta: field(default_factory=dict)


# usage:
obligatory_args = {'user_id': 1, 'body': 'foo'}
other_args = {'bar': 'baz', 'amount': 10}
c = Container(**obligatory_args, meta=other_args)
print(c.meta['bar'])  # prints: 'baz'

但在这种情况下,您仍然需要查看字典,并且无法通过名称访问参数,即c.bar不起作用。
如果您关心通过名称访问属性,或者在初始化期间无法区分已知和未知的参数,则除非重写__init__(这几乎会取消使用dataclasses的目的),否则您的最后一招是编写一个@classmethod:
from dataclasses import dataclass
from inspect import signature


@dataclass
class Container:
    user_id: int
    body: str

    @classmethod
    def from_kwargs(cls, **kwargs):
        # fetch the constructor's signature
        cls_fields = {field for field in signature(cls).parameters}

        # split the kwargs into native ones and new ones
        native_args, new_args = {}, {}
        for name, val in kwargs.items():
            if name in cls_fields:
                native_args[name] = val
            else:
                new_args[name] = val

        # use the native ones to create the class ...
        ret = cls(**native_args)

        # ... and add the new ones by hand
        for new_name, new_val in new_args.items():
            setattr(ret, new_name, new_val)
        return ret

使用方法:

params = {'user_id': 1, 'body': 'foo', 'bar': 'baz', 'amount': 10}
Container(**params)  # still doesn't work, raises a TypeError 
c = Container.from_kwargs(**params)
print(c.bar)  # prints: 'baz'

1
值得注意的是,我们必须排除带有ClassVar注释的属性,因为它们不会被包含在生成的__init__中。可以使用类似于if getattr(cls.__annotations__.get(name, None), "__origin__", None) is not ClassVar:这样的语句来实现。 - Anakhand
@Anakhand,好发现,谢谢。我添加了类似的检查。 - Arne
@Arne 我编辑了答案,使用 dataclasses.fields 代替笨拙的推导式,从而简化了代码。它还解决了 ClassVarInitVar 的情况。这个修改没有经过审核,如果你觉得不合适,请回滚。非常感谢您的回答! - Felix
@Felix要求属性字段时存在错误,如果使用了InitVar,那么这些是数据类构造函数签名的一部分,但不是属性。正确的方式(也避免了笨拙的推理)是使用inspect.signature,我会测试并修复这个问题,或者如果您更快的话,您也可以自行解决。 - Arne
1
@Felix,现在已经修复了,感谢你的提醒。 - Arne
显示剩余3条评论

12

Dataclass只依赖于__init__方法,因此您可以在__new__方法中自由地修改您的类。


from dataclasses import dataclass


@dataclass
class Container:
    user_id: int
    body: str

    def __new__(cls, *args, **kwargs):
        try:
            initializer = cls.__initializer
        except AttributeError:
            # Store the original init on the class in a different place
            cls.__initializer = initializer = cls.__init__
            # replace init with something harmless
            cls.__init__ = lambda *a, **k: None

        # code from adapted from Arne
        added_args = {}
        for name in list(kwargs.keys()):
            if name not in cls.__annotations__:
                added_args[name] = kwargs.pop(name)

        ret = object.__new__(cls)
        initializer(ret, **kwargs)
        # ... and add the new ones by hand
        for new_name, new_val in added_args.items():
            setattr(ret, new_name, new_val)

        return ret


if __name__ == "__main__":
    params = {'user_id': 1, 'body': 'foo', 'bar': 'baz', 'amount': 10}
    c = Container(**params)
    print(c.bar)  # prints: 'baz'
    print(c.body)  # prints: 'baz'`

3
但是,initializer(ret, *args, **kwargs) - Sole Sensei
2
如果你正在使用继承,那么这个解决方案会遇到一个问题。你还需要保存类名,以便子类知道不要重用 cls.__initializer。cls.__class_name = cls.__name__ - pdpAxis

7
这是我使用过的一个不错的变体。
from dataclasses import dataclass, field
from typing import Optional, Dict


@dataclass
class MyDataclass:
    data1: Optional[str] = None
    data2: Optional[Dict] = None
    data3: Optional[Dict] = None

    kwargs: field(default_factory=dict) = None

    def __post_init__(self):
        [setattr(self, k, v) for k, v in self.kwargs.items()]

它的工作方式如下:

>>> data = MyDataclass(data1="data1", kwargs={"test": 1, "test2": 2})
>>> data.test
1
>>> data.test2
2

然而需要注意的是,数据类似乎并不知道它有这些新属性:
>>> from dataclasses import asdict
>>> asdict(data)
{'data1': 'data1', 'data2': None, 'data3': None, 'kwargs': {'test': 1, 'test2': 2}}

这意味着必须知道密钥。这适用于我的使用情况,也可能适用于其他人。


不错的想法。请注意:1.如果您想访问kwargs字段,可以使用data.__dict__。2.将“field”作为类型提示有点奇怪:最好用InitVardataclasses的一部分)替换。我尝试提交了一个基于您的替代方案!^_^ - Jean-Francois T.
1
此外,最好使用列表推导式来存储处理后的列表,并在需要处理元素时避免使用它(此时,标准循环更清晰)。 - Jean-Francois T.
2
不要使用列表推导式来进行副作用操作:[setattr(self, k, v) for k, v in self.kwargs.items()],请使用for循环。 - juanpa.arrivillaga
我认为你的意思是 kwargs: dict = field(default_factory=dict) - Remi Cuingnet

2
from dataclasses import make_dataclass
Clas = make_dataclass('A', 
                      ['d'], 
                      namespace={
                                 '__post_init__': lambda self: self.__dict__.update(self.d)
                      })
d = {'a':1, 'b': 2}
instance = Clas(d)
instance.a

2
所有这些更改都是出于善意,但很明显违背了数据类的精神,即避免编写大量样板代码来设置类。
Python 3.10引入了匹配语句,并且在其中数据类在构造函数(即装饰器)中获得了一个match_args=True的默认参数。
这意味着您可以获得一个dunder属性__match_args__,它存储了一个init(kw)args元组,重要的是没有运行时检查。
因此,您只需创建一个classmethod。
from dataclasses import dataclass

@dataclass
class A:
    a: int
    b: int = 0
    
    def from_kwargs(cls, **kwargs: dict) -> A:
        return cls(**{k: kwargs[k] for k in kwargs if k in cls.__match_args__})

它有效:

>>> A.from_kwargs(a=1, b=2, c=3)
A(a=1, b=2)
>>> A.from_kwargs(a=1)
A(a=1, b=0)

然而,我们在Python 3.9中也可以通过__dataclass_fields__访问这些相同的键,如果您不能依赖于3.10运行时,则这是下一个最佳选项。

    def from_kwargs(cls, **kwargs: dict) -> A:
        return cls(**{k: kwargs[k] for k in kwargs if k in cls.__dataclass_fields__})

这将得到相同的结果。

对于问题中(不寻常但合理的!)用例,您只需将类方法更改为 pop,而不是在构建 init_kw 字典时访问 kwargs 字典,这样剩余的键将保留在 kwargs 中,并可以作为它们自己的关键字参数 rest 传递。

from dataclasses import dataclass

@dataclass
class A:
    a: int
    b: int = 0
    rest: dict = {}
    
    def from_kwargs(cls, **kwargs: dict) -> A:
        init_kw = {k: kwargs.pop(k) for k in dict(kwargs) if k in cls.__match_args__}
        return cls(**init_kw, rest=kwargs)

请注意,您必须将kwargs包装在调用dict(创建副本)的函数中,以避免“迭代过程中字典大小发生改变”的错误。

1

Trian Svinit的答案的变体:

您可以使用以下方法:

  1. 通过kwargs参数添加额外属性,例如:MyDataclass(xx, yy, kwargs={...})
  2. kwargs是一个dataclasses.InitVar,然后在数据类的__post_init__中进行处理
  3. 您可以使用instance.__dict__访问所有值(因为asdict无法检测通过kwargs=...添加的属性)

这将仅使用数据类的本机功能,并且继承此类仍将起作用。

from dataclasses import InitVar, asdict, dataclass
from typing import Dict, Optional


@dataclass
class MyDataclass:
    data1: Optional[str] = None
    data2: Optional[Dict] = None
    data3: Optional[Dict] = None

    kwargs: InitVar[Optional[Dict[str, Any]]] = None

    def __post_init__(self, kwargs: Optional[Dict[str, Any]]) -> None:
        if kwargs:
            for k, v in kwargs.items():
                setattr(self, k, v)


data = MyDataclass(data1="data_nb_1", kwargs={"test1": 1, "test2": 2})
print(data, "-", data.data1, "-", data.test1)
# MyDataclass(data1='data_nb_1', data2=None, data3=None) - data1 - 1
print(asdict(data))
# {'data1': 'data_nb_1', 'data2': None, 'data3': None}
print(data.__dict__)
# {'data1': 'data_nb_1', 'data2': None, 'data3': None, 'test1': 1, 'test2': 2}

如果你真的需要使用asdict来获取作为kwargs传递的属性,你可以开始使用dataclasses中的私有属性来hack asdict:
from dataclasses import _FIELD, _FIELDSInitVar, asdict, dataclass, field
from typing import Dict, Optional


@dataclass
class MyDataclass:
    data1: Optional[str] = None
    data2: Optional[Dict] = None
    data3: Optional[Dict] = None

    kwargs: InitVar[Optional[Dict[str, Any]]] = None

    def __post_init__(self, kwargs: Optional[Dict[str, Any]]) -> None:
        if kwargs:
            for k, v in kwargs.items():
                setattr(self, k, v)
                self._add_to_asdict(k)

    def _add_to_asdict(self, attr:str) -> None:
        """Add an attribute to the list of keys returned by asdict"""
        f = field(repr=True)
        f.name = attr
        f._field_type = _FIELD
        getattr(self, _FIELDS)[attr] = f

data = MyDataclass(data1="data_nb_1", kwargs={"test1": 1, "test2": 2})
print(asdict(data))
# {'data1': 'data_nb_1', 'data2': None, 'data3': None, 'test1': 1, 'test2': 2}

0

基于Arnes Answer,我创建了一个类装饰器,它通过from_kwargs方法扩展了dataclass装饰器。

from dataclasses import dataclass
from inspect import signature


def dataclass_init_kwargs(cls, *args, **kwargs):
    cls = dataclass(cls, *args, **kwargs)

    def from_kwargs(**kwargs):
        cls_fields = {field for field in signature(cls).parameters}
        native_arg_keys = cls_fields & set(kwargs.keys())
        native_args = {k: kwargs[k] for k in native_arg_keys}
        ret = cls(**native_args)
        return ret

    setattr(cls, 'from_kwargs', from_kwargs)
    return cls


-1
对于这个问题,你应该使用default_factory,就像dataclass documentation中所述的那样。
@dataclass
class Foo:
    a: Dict = field(default_factory=dict)

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