命名元组和可选关键字参数的默认值

394

我正在尝试将一个比较冗长的“数据”类转换为一个命名元组。我的类目前看起来像这样:

class Node(object):
    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

转换为 namedtuple 后,它的样子如下:

from collections import namedtuple
Node = namedtuple('Node', 'val left right')

但是这里存在一个问题。我的原始类允许我仅传递一个值,并通过使用命名/关键字参数的默认值来处理默认值。就像这样:

class BinaryTree(object):
    def __init__(self, val):
        self.root = Node(val)

但是对于我重构后的命名元组,这种方法不起作用,因为它期望我传递所有字段。当然,我可以将 Node(val) 的所有出现替换为 Node(val, None, None),但我并不喜欢这种方式。

那么,是否存在一种好的技巧,可以让我成功地重写代码,而不会增加太多的代码复杂性(元编程),或者我只能咽下这颗苦果,继续使用“搜索和替换”方法?:)


3
你为什么想要进行这个转换?我喜欢你的原始“Node”类,就像它现在的样子。为什么要转换成命名元组? - steveha
44
我希望进行这种转换,因为当前的“Node”和其他类都是简单的数据持有者值对象,具有许多不同的字段(“Node”只是其中之一)。在我看来,这些类声明不过是无用的代码,所以我希望将它们删减掉。为什么要维护不必要的东西呢? :) - sasuke
你的类中完全没有任何方法函数吗?例如,你没有一个.debug_print()方法来遍历树并打印它吗? - steveha
4
当然可以,但是这是针对BinaryTree类而言的。而Node和其他的数据容器并不需要这样特殊的方法,尤其是因为命名元组已经有了相当不错的__str____repr__表示方式。 :) - sasuke
好的,看起来很合理。我认为Ignacio Vazquez-Abrams已经给出了答案:使用一个函数为您的节点设置默认值。 - steveha
类定义清晰,完全符合读者的预期。这里的许多答案都很复杂,并且具有令人惊讶的副作用。是的,Node是一个数据持有者类,但在幕后,namedtuple也是如此! - Lorenz Forvang
23个回答

713

Python 3.7

使用defaults参数。

>>> from collections import namedtuple
>>> fields = ('val', 'left', 'right')
>>> Node = namedtuple('Node', fields, defaults=(None,) * len(fields))
>>> Node()
Node(val=None, left=None, right=None)

或者更好的方法是使用新的dataclasses库,它比namedtuple更好用。

>>> from dataclasses import dataclass
>>> from typing import Any
>>> @dataclass
... class Node:
...     val: Any = None
...     left: 'Node' = None
...     right: 'Node' = None
>>> Node()
Node(val=None, left=None, right=None)

Python 3.7之前

Node.__new__.__defaults__设置为默认值。

>>> from collections import namedtuple
>>> Node = namedtuple('Node', 'val left right')
>>> Node.__new__.__defaults__ = (None,) * len(Node._fields)
>>> Node()
Node(val=None, left=None, right=None)

Python 2.6之前

Node.__new__.func_defaults设置为默认值。

>>> from collections import namedtuple
>>> Node = namedtuple('Node', 'val left right')
>>> Node.__new__.func_defaults = (None,) * len(Node._fields)
>>> Node()
Node(val=None, left=None, right=None)

订单

在所有版本的Python中,如果您设置的默认值少于namedtuple中存在的值,则默认值将应用于最右侧的参数。这样可以使某些参数保持为必需参数。

>>> Node.__new__.__defaults__ = (1,2)
>>> Node()
Traceback (most recent call last):
  ...
TypeError: __new__() missing 1 required positional argument: 'val'
>>> Node(3)
Node(val=3, left=1, right=2)

Python 2.6 到 3.6 的包装器

这里有一个包装器,甚至让您可以(可选地)将默认值设置为除 None 之外的其他内容。该包装器不支持必需参数。

import collections
def namedtuple_with_defaults(typename, field_names, default_values=()):
    T = collections.namedtuple(typename, field_names)
    T.__new__.__defaults__ = (None,) * len(T._fields)
    if isinstance(default_values, collections.Mapping):
        prototype = T(**default_values)
    else:
        prototype = T(*default_values)
    T.__new__.__defaults__ = tuple(prototype)
    return T

例子:

>>> Node = namedtuple_with_defaults('Node', 'val left right')
>>> Node()
Node(val=None, left=None, right=None)
>>> Node = namedtuple_with_defaults('Node', 'val left right', [1, 2, 3])
>>> Node()
Node(val=1, left=2, right=3)
>>> Node = namedtuple_with_defaults('Node', 'val left right', {'right':7})
>>> Node()
Node(val=None, left=None, right=7)
>>> Node(4)
Node(val=4, left=None, right=7)

28
看起来...你的一句话回答:a)是最短/最简单的答案,b)保持了空间效率,c)不会破坏isinstance ...全部都是优点,没有缺点...可惜你来得有点晚。这是最好的答案。 - Gerrat
3
我已经给这个答案点赞了,因为我更喜欢它。但是很遗憾,我的答案仍然在不断地被点赞 :| - Justin Fay
1
@MarkLodato 我想插一句话,我认为这是对OP问题的最佳解决方案。然而,仅将最后一个参数设置为可选似乎不起作用,即如果您设置 Node.__new__.__defaults__ = (None),则似乎必须始终指定所有三个参数,它似乎只在有两个或更多可选参数时起作用。 - ishaaq
4
@ishaaq,问题在于(None)不是元组,而是None。如果您使用(None,)代替,那么它应该能正常工作。 - Mark Lodato
4
太好了!你可以用以下方式泛化“设置默认值”的设置:Node.__new__.__defaults__= (None,) * len(Node._fields) - ankostis
显示剩余15条评论

156

我子类化了namedtuple并覆盖了__new__方法:

from collections import namedtuple

class Node(namedtuple('Node', ['value', 'left', 'right'])):
    __slots__ = ()
    def __new__(cls, value, left=None, right=None):
        return super(Node, cls).__new__(cls, value, left, right)

这样做保留了一种直观的类型层次结构,而将一个工厂函数伪装成类则不具备这种保留。


9
为了保持命名元组的空间效率,可能需要使用插槽和字段属性。 - Pepijn
由于某些原因,在使用_replace时,__new__方法没有被调用。 - user3892448
1
请看下面@marc-lodato的答案,我认为这是比这个更好的解决方案。 - Justin Fay
2
但是 @marc-lodato 的答案没有提供子类具有不同默认值的能力。 - Jason S
1
@JasonS,我怀疑如果一个子类有不同的默认值可能会违反LSP原则。然而,子类很可能希望拥有更多默认。无论如何,应由子类使用justinfay的方法,而基类可以使用Marc的方法 - Alexey
显示剩余2条评论

111

使用Python 3.6.1+中的typing.NamedTuple,您可以为NamedTuple字段提供默认值和类型注释。如果您只需要前者,请使用typing.Any

from typing import Any, NamedTuple


class Node(NamedTuple):
    val: Any
    left: 'Node' = None
    right: 'Node' = None

使用方法:

>>> Node(1)
Node(val=1, left=None, right=None)
>>> n = Node(1)
>>> Node(2, left=n)
Node(val=2, left=Node(val=1, left=None, right=None), right=None)

此外,如果您需要默认值和可选的可变性,Python 3.7将具有 数据类(PEP 557),在某些(许多?)情况下可以替代命名元组。


顺便说一句,Python中当前规范的一个怪癖是注释(参数和变量后面的之后以及函数后面的->之后的表达式)会在定义时进行评估*。因此,由于“类名在整个类体执行完毕后才被定义”,上面类字段中“Node”的注释必须是字符串,以避免NameError。

这种类型提示称为“前向引用”([1], [2]),通过PEP 563,Python 3.7+将拥有一个__future__导入(默认情况下在4.0中启用),允许使用前向引用而不需要引号,推迟它们的评估。

* 据我所知,只有局部变量注释不会在运行时进行评估。(来源:PEP 526


4
对于3.6.1及以上版本用户来说,这似乎是最简洁的解决方案。请注意,此示例有点令人困惑,因为字段leftright的类型提示(即Node)与正在定义的类相同,因此必须编写为字符串格式。 - 101
1
@101,谢谢,我已经在答案中添加了一条注释。 - monk-time
2
my_list: List[T] = None的类比是什么?self.my_list = my_list if my_list is not None else [] 可以使用默认参数来代替吗? - weberc2
@weberc2 很好的问题!我不确定在 typing.NamedTuple 中是否可以使用可变默认值的解决方法。但是,对于数据类,您可以使用带有 default_factory 属性的 Field 对象来实现此目的,用 my_list: List[T] = field(default_factory=list) 替换您的习惯用法。 - monk-time

104

将其包装在一个函数中。

NodeT = namedtuple('Node', 'val left right')

def Node(val, left=None, right=None):
  return NodeT(val, left, right)

20
这很聪明,可以是一个不错的选择,但也可能会导致问题,因为它破坏了"isinstance(Node('val'), Node)"的结果:现在它将引发一个异常,而不是返回True。虽然稍微啰嗦一些,但@justinfay的回答(下面的链接)正确地保留了类型层次信息,所以如果其他人要与Node实例交互,则可能是更好的方法。 - Gabriel Grant
6
我喜欢这个回答的简洁性。也许可以通过将函数命名为 def make_node(...): 而不是假装它是一个类定义来解决上面评论中的顾虑。这样用户就不会被诱惑检查函数的类型多态性,而是使用元组定义本身。 - user1556435
请查看我的答案,其中有一种变体,不会误导人们错误地使用isinstance - Elliot Cameron

22

这是文档中的一个示例:

可以使用_replace()方法来自定义原型实例以实现默认值:

>>> Account = namedtuple('Account', 'owner balance transaction_count')
>>> default_account = Account('<owner name>', 0.0, 0)
>>> johns_account = default_account._replace(owner='John')
>>> janes_account = default_account._replace(owner='Jane')
所以,楼主的例子是:

from collections import namedtuple
Node = namedtuple('Node', 'val left right')
default_node = Node(None, None, None)
example = default_node._replace(val="whut")

然而,我更喜欢这里给出的其他答案。我只是想为了完整性而添加这个。


2
很奇怪他们决定在像replace这样似乎非常有用的东西上使用_方法(基本上意味着私有方法)。 - sasuke
@sasuke - 我也在想这个问题。用空格分隔的字符串定义元素已经有点奇怪了,而不是使用 *args。可能只是因为在很多这些事情被标准化之前,它就被添加到语言中了。 - Tim Tisdall
12
下划线前缀的作用是避免与用户定义的元组字段名称冲突(相关文档引用:“任何有效的Python标识符都可以用作字段名称,但不能以下划线开头。”)。至于空格分隔的字符串,我认为这只是为了节省一些按键次数(如果您喜欢,您也可以传递字符串序列)。 - Søren Løvborg
1
啊,是的,我忘记了你作为属性访问命名元组的元素,所以下划线 _ 现在有很多意义了。 - Tim Tisdall
2
你的解决方案很简单,也是最好的。其他的我认为相当丑陋。我只会做一个小改变。 我更喜欢 node_default 而不是 default_node,因为它在 IntelliSense 中有更好的体验。如果你开始输入 node,你会得到你需要的一切 :) - Pavel Hanpari

20

我不确定是否有内置的namedtuple方法可以轻松实现这一点。但有一个很好的名为recordtype的模块具备此功能:

>>> from recordtype import recordtype
>>> Node = recordtype('Node', [('val', None), ('left', None), ('right', None)])
>>> Node(3)
Node(val=3, left=None, right=None)
>>> Node(3, 'L')
Node(val=3, left=L, right=None)

2
啊,虽然recordtype看起来很有趣,但不可能使用第三方包。+1 - sasuke
这个模块非常小,只有一个单文件,所以你可以将它添加到你的项目中。 - jterrace
好的,不过在我确认是否有纯命名元组解决方案之前,我会再等一段时间,然后再标记为已接受! :) - sasuke
同意使用纯Python会很好,但我认为目前没有这样的库。 - jterrace
3
请注意,“recordtype”可变,而“namedtuple”不可变。 如果您希望对象是可哈希的(我猜您不需要,因为它最初是一个类),这可能很重要。 - bavaza

17

在Python3.7+中,新增了一个名为defaults=的关键字参数。

defaults可以是None或者默认值的可迭代对象。由于带有默认值的字段必须出现在没有默认值的字段之后,所以defaults会应用于最右边的参数。例如,如果字段名为['x', 'y', 'z'],而默认值为(1, 2),那么x将是必需的参数,y将默认为1z将默认为2

示例用法:

$ ./python
Python 3.7.0b1+ (heads/3.7:4d65430, Feb  1 2018, 09:28:35) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import namedtuple
>>> nt = namedtuple('nt', ('a', 'b', 'c'), defaults=(1, 2))
>>> nt(0)
nt(a=0, b=1, c=2)
>>> nt(0, 3)  
nt(a=0, b=3, c=2)
>>> nt(0, c=3)
nt(a=0, b=1, c=3)

15

以下是受justinfay答案启发的更紧凑版本:

from collections import namedtuple
from functools import partial

Node = namedtuple('Node', ('val left right'))
Node.__new__ = partial(Node.__new__, left=None, right=None)

7
请注意,这个配方不适用于 Node(1, 2),但在 @justinfay 的答案中可以使用。除此之外,它相当巧妙(+1)。 - jorgeca

6

Python 3.7: 引入了 defaults 参数来定义命名元组。

文档中的示例:

>>> Account = namedtuple('Account', ['type', 'balance'], defaults=[0])
>>> Account._fields_defaults
{'balance': 0}
>>> Account('premium')
Account(type='premium', balance=0)

点击此处了解更多信息。


6

简短、简单,不会误导人们错误地使用 isinstance

class Node(namedtuple('Node', ('val', 'left', 'right'))):
    @classmethod
    def make(cls, val, left=None, right=None):
        return cls(val, left, right)

# Example
x = Node.make(3)
x._replace(right=Node.make(4))

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