Python数据模型与内置函数之间的关系是什么?

36

在 Stack Overflow 上阅读 Python 的答案时,我经常看到一些人建议用户直接使用数据模型的特殊方法属性

然后我看到有相反的建议(有时是来自我自己),说不要那样做,而是要直接使用内置函数和运算符。

这是为什么呢? Python 数据模型的特殊“dunder”方法和属性与内置函数之间的关系是什么?

我应该在什么情况下使用这些特殊名称?

2个回答

45

Python数据模型和内置函数之间有什么关系?

  • 内置函数和运算符使用底层数据模型的方法或属性。
  • 内置函数和运算符具有更优雅的行为并且通常是向前兼容的。
  • 数据模型的特殊方法是语义上的非公共接口。
  • 内置函数和语言运算符旨在成为特殊方法实现行为的用户界面。

因此,在可能的情况下,你应该优先使用内置函数和运算符而不是数据模型的特殊方法和属性。

语义上的内部API比公共接口更容易发生变化。虽然Python实际上不认为任何东西是“私有的”并暴露其内部,但这并不意味着滥用访问权限是一个好主意。这样做会带来以下风险:

  • 当升级你的Python可执行文件或切换到其他Python实现(如PyPy、IronPython或Jython,或其他未预见的实现)时,你可能会发现更多的破坏性变化。
  • 你的同事很可能会对你的语言技能和责任心持负面看法,并认为这是一个代码气味,将你和你的代码引入更大的审查。
  • 内置函数易于拦截行为。直接使用特殊方法限制了你的Python进行内省和调试的能力。

深入探讨

内置函数和运算符会调用Python数据模型中的特殊方法并使用特殊属性。它们是可读性强且易于维护的外观,隐藏了对象的内部实现。一般来说,用户应该使用语言中提供的内置函数和运算符,而不是直接调用特殊方法或使用特殊属性。

内置函数和运算符也可以具有后备或更优雅的行为,而不是更原始的数据模型特殊方法。例如:

  • next(obj, default) 允许你提供一个默认值,而不是在迭代器用尽时引发 StopIteration 异常,而 obj.__next__() 不允许这样做。
  • str(obj)obj.__str__() 不可用时回退到 obj.__repr__() - 直接调用 obj.__str__() 会引发属性错误。
  • obj != other 在Python 3中当没有 __ne__ 时回退到 not obj == other - 直接调用 obj.__ne__(other) 将无法利用此功能。

(如果必要或合适,内置函数也可以在模块的全局范围或 builtins 模块上进行自定义以进一步定制行为。)

将内置函数和运算符映射到数据模型

下面是内置函数和运算符与使用或返回的相应特殊方法和属性的映射及其注意事项 - 请注意,通常规则是内置函数通常映射到同名的特殊方法,但这不足以保证给出以下映射:

builtins/     special methods/
operators  -> datamodel               NOTES (fb == fallback)

repr(obj)     obj.__repr__()          provides fb behavior for str
str(obj)      obj.__str__()           fb to __repr__ if no __str__
bytes(obj)    obj.__bytes__()         Python 3 only
unicode(obj)  obj.__unicode__()       Python 2 only
format(obj)   obj.__format__()        format spec optional.
hash(obj)     obj.__hash__()
bool(obj)     obj.__bool__()          Python 3, fb to __len__
bool(obj)     obj.__nonzero__()       Python 2, fb to __len__
dir(obj)      obj.__dir__()
vars(obj)     obj.__dict__            does not include __slots__
type(obj)     obj.__class__           type actually bypasses __class__ -
                                      overriding __class__ will not affect type
help(obj)     obj.__doc__             help uses more than just __doc__
len(obj)      obj.__len__()           provides fb behavior for bool
iter(obj)     obj.__iter__()          fb to __getitem__ w/ indexes from 0 on
next(obj)     obj.__next__()          Python 3
next(obj)     obj.next()              Python 2
reversed(obj) obj.__reversed__()      fb to __len__ and __getitem__
other in obj  obj.__contains__(other) fb to __iter__ then __getitem__
obj == other  obj.__eq__(other)
obj != other  obj.__ne__(other)       fb to not obj.__eq__(other) in Python 3
obj < other   obj.__lt__(other)       get >, >=, <= with @functools.total_ordering
complex(obj)  obj.__complex__()
int(obj)      obj.__int__()
float(obj)    obj.__float__()
round(obj)    obj.__round__()
abs(obj)      obj.__abs__()

operator 模块具有 length_hint 方法,如果没有实现 __len__ 方法,则有相应的特殊方法作为后备实现:

length_hint(obj)  obj.__length_hint__() 

点号查找

点号查找是有上下文关系的。如果没有特殊方法的实现,首先在类层次结构中查找数据描述符(例如属性和slots),然后在实例的__dict__中查找(实例变量),最后在类层次结构中查找非数据描述符(例如方法)。特殊方法实现以下行为:

obj.attr      obj.__getattr__('attr')       provides fb if dotted lookup fails
obj.attr      obj.__getattribute__('attr')  preempts dotted lookup
obj.attr = _  obj.__setattr__('attr', _)    preempts dotted lookup
del obj.attr  obj.__delattr__('attr')       preempts dotted lookup

描述符

描述符有点高级,如果您感到困难,请跳过这些内容并稍后再回来。请记住,描述符实例位于类层次结构中(例如方法、槽和属性)。数据描述符实现__set____delete__

obj.attr        descriptor.__get__(obj, type(obj)) 
obj.attr = val  descriptor.__set__(obj, val)
del obj.attr    descriptor.__delete__(obj)

当类被实例化(定义)时,如果任何描述符具有以下描述符方法__set_name__,则会调用该方法以通知描述符其属性名称。(这是Python 3.6中的新功能。)cls与上面的type(obj)相同,'attr'代表属性名称:

class cls:
    @descriptor_type
    def attr(self): pass # -> descriptor.__set_name__(cls, 'attr') 

项(下标符号)

下标符号也是上下文相关的:

obj[name]         -> obj.__getitem__(name)
obj[name] = item  -> obj.__setitem__(name, item)
del obj[name]     -> obj.__delitem__(name)

对于字典的子类,如果__getitem__方法没有找到键,则会调用__missing__方法:

obj[name]         -> obj.__missing__(name)  

运算符

此外,对于+, -, *, @, /, //, %, divmod(), pow(), **, <<, >>, &, ^, |等运算符也有特殊的方法,例如:

obj + other   ->  obj.__add__(other), fallback to other.__radd__(obj)
obj | other   ->  obj.__or__(other), fallback to other.__ror__(obj)

支持原地操作符进行增量赋值,+=, -=, *=, @=, /=, //=, %=, **=, <<=, >>=, &=, ^=, |=。例如:

obj += other  ->  obj.__iadd__(other)
obj |= other  ->  obj.__ior__(other)

如果未定义这些原地操作符,Python会退化为例如obj += other变成obj = obj + other)的形式。

以及一元操作符:

+obj          ->  obj.__pos__()
-obj          ->  obj.__neg__()
~obj          ->  obj.__invert__()

上下文管理器

上下文管理器定义了__enter__,在进入代码块时调用(其返回值通常是self,并别名为as),并定义了__exit__,保证在离开代码块时被调用,并提供异常信息。

with obj as enters_return_value: #->  enters_return_value = obj.__enter__()
    raise Exception('message')
                                 #->  obj.__exit__(Exception, 
                                 #->               Exception('message'), 
                                 #->               traceback_object)

如果 __exit__ 函数抛出异常并返回 false 值,那么在退出该方法时会重新引发该异常。

如果没有异常,则 __exit__ 函数将分别得到这三个参数的值为 None,而返回值则是无意义的:

with obj:           #->  obj.__enter__()
    pass
                    #->  obj.__exit__(None, None, None)

一些元类特殊方法

同样地,类可以有特殊方法(来自它们的元类)来支持抽象基类:

isinstance(obj, cls) -> cls.__instancecheck__(obj)
issubclass(sub, cls) -> cls.__subclasscheck__(sub)

重要的一点是,虽然像 nextbool 这样的内置函数在 Python 2 和 3 之间不会改变,但底层实现名称确实正在发生变化。

因此,使用内置函数也提供了更多的前向兼容性。

什么时候应该使用特殊名称?

在 Python 中,以下划线开头的名称在语义上是用户不应直接使用的非公共名称。下划线是创建者表达“不能触碰”的一种方式。

这不仅是文化差异,而且还涉及到 Python 对 API 的处理。当一个包的 __init__.py 使用 import * 来从子包中提供 API 时,如果子包没有提供 __all__,它会排除以下划线开头的名称。子包的 __name__ 也会被排除。

IDE 自动完成工具对以下划线开头的名称是否为非公共名称有所分歧。但当我输入对象名和句点时,我非常感谢看不到 __init____new____repr____str____eq__ 等名称(也不会看到任何用户创建的非公共接口)。

因此,我断言:

特殊的“双下划线”方法不是公共接口的一部分。避免直接使用它们。

那么什么时候使用它们呢?

主要用例是实现自己的自定义对象或内置对象的子类。

尽量仅在绝对必要时使用它们。以下是一些示例:

在函数或类上使用 __name__ 特殊属性

当我们装饰函数时,通常会得到一个包装函数作为返回值,该函数隐藏了有关该函数的有用信息。我们将使用 @wraps(fn) 装饰器来确保我们不会失去该信息,但如果我们需要函数的名称,则需要直接使用 __name__ 属性:

from functools import wraps

def decorate(fn): 
    @wraps(fn)
    def decorated(*args, **kwargs):
        print('calling fn,', fn.__name__) # exception to the rule
        return fn(*args, **kwargs)
    return decorated

同样地,当我需要在一个方法中获取对象的类名(例如在__repr__中使用)时,我会按照以下步骤进行:

def get_class_name(self):
    return type(self).__name__
          # ^          # ^- must use __name__, no builtin e.g. name()
          # use type, not .__class__

使用特殊属性编写自定义类或子类内置类

当我们想定义自定义行为时,必须使用数据模型名称。

这是有道理的,因为我们是实现者,这些属性不是私有的。

class Foo(object):
    # required to here to implement == for instances:
    def __eq__(self, other):      
        # but we still use == for the values:
        return self.value == other.value
    # required to here to implement != for instances:
    def __ne__(self, other): # docs recommend for Python 2.
        # use the higher level of abstraction here:
        return not self == other  

然而,在这种情况下,我们不使用 self.value.__eq__(other.value)not self.__eq__(other)(参见我在这里的回答以了解后者可能导致意外行为的证明)。相反,我们应该使用更高层次的抽象。

另一个需要使用特殊方法名称的情况是当我们处于子类实现中,并且想要委托给父级。例如:

class NoisyFoo(Foo):
    def __eq__(self, other):
        print('checking for equality')
        # required here to call the parent's method
        return super(NoisyFoo, self).__eq__(other) 

结论

特殊方法允许用户为对象内部实现接口。

尽可能使用内置函数和运算符。仅在没有公共API文件的情况下使用特殊方法。


1
我认为里程略有不同,这取决于您使用哪个dunder属性/方法。例如,self.__dict__可能几乎与vars(self)一样常见,self.__class__也几乎与type(self)一样受欢迎。稍微概括一下,我认为不使用查找属性的方法比调用钩子的方法更容易被社区认为是“可接受的”。话虽如此,我完全赞同您的结论:“在任何可以使用内置函数/运算符的地方,请使用它们”。 - mgilson
1
有趣的是,特殊方法是在类上调用而不是实例上调用。例如,next(obj)并不完全等同于obj.__next__(),而更像是type(obj).__next__(obj) - jme

11
我将展示一些你显然没有考虑过的用法,评论你展示的例子,并反驳你在自己的回答中提出的隐私声明。
我同意你的答案,例如应该使用len(a)而不是a.__len__()。我的表述方式是:len存在是为了我们可以使用它,而__len__存在是为了len可以使用它。或者说内部实际工作方式如何,因为len(a)实际上可以更快速,至少对于列表和字符串来说是这样的:
>>> timeit('len(a)', 'a = [1,2,3]', number=10**8)
4.22549770486512
>>> timeit('a.__len__()', 'a = [1,2,3]', number=10**8)
7.957335462257106

>>> timeit('len(s)', 's = "abc"', number=10**8)
4.1480574509332655
>>> timeit('s.__len__()', 's = "abc"', number=10**8)
8.01780160432645

除了在自己的类中定义这些方法以供内置函数和运算符使用外,我偶尔也会像下面这样使用它们:

假设我需要给某个函数提供一个过滤器函数,并且我想使用一个集合s作为过滤器。我不会创建一个额外的函数lambda x: x in sdef f(x): return x in s。不,我已经有一个完美的可用函数:集合的__contains__方法。它更简单、更直接,甚至更快,如下所示(请忽略我在此将其保存为f,那只是为了进行时间演示):

>>> timeit('f(2); f(4)', 's = {1, 2, 3}; f = s.__contains__', number=10**8)
6.473739433621368
>>> timeit('f(2); f(4)', 's = {1, 2, 3}; f = lambda x: x in s', number=10**8)
19.940786514456924
>>> timeit('f(2); f(4)', 's = {1, 2, 3}\ndef f(x): return x in s', number=10**8)
20.445680107760325

因此,虽然我不会直接调用像s.__contains__(x)这样的魔术方法,但我偶尔会将它们传递到某个地方,比如像some_function_needing_a_filter(s.__contains__)。我认为这是完全可以的,而且比lambda/def替代方案更好。

你展示的例子,我的想法:

  • 示例1:当被问及如何获取列表的大小时,他回答说items.__len__(),甚至没有任何理由。我的结论是:这是错误的。应该是len(items)
  • 示例2:首先提到了d[key] = value!然后加上了d.__setitem__(key, value),并解释道"如果你的键盘缺少方括号键",这很少适用,我怀疑这不是认真的。我认为这只是为了最后一点打开大门,提到这是我们如何支持自己类中的方括号语法的方法。这将它转回建议使用方括号。
  • 示例3:建议使用obj.__dict__。就像__len__示例一样糟糕。但我怀疑他只是不知道vars(obj),我可以理解,因为vars不太常见/知名,而且名称与__dict__中的"dict"不同。
  • 示例4:建议使用__class__。应该是type(obj)。我怀疑这与__dict__的故事类似,尽管我认为type更为人所知。

关于隐私:在您自己的回答中,您说这些方法是“语义上私有的”。我强烈反对。单个和双重前导下划线用于此,但不适用于数据模型的特殊“dunder / magic”方法,该方法具有双重前导+尾随下划线。
  • 你提到的两个参数是导入行为和IDE的自动完成。但是导入和这些特殊方法是不同的领域,而我尝试过的一个IDE(流行的PyCharm)与你的观点不同。我创建了一个带有_foo__bar__方法的类/对象,然后自动完成没有提供_foo,但确实提供了__bar__。当我仍然使用这两种方法时,PyCharm只警告了我关于_foo(称其为“受保护成员”),而不是关于__bar__
  • PEP 8明确指出单个下划线的弱“内部使用”指示符,对于双下划线它提到名称混淆,并在稍后解释说它是用于“您不希望子类使用的属性”。但是关于双重前导+后缀下划线的注释并没有像那样说任何话。
  • 你自己链接的data model page页面说这些special method names“Python的运算符重载方法”。那里没有任何关于隐私的内容。该页面中甚至没有出现过私有/隐私/受保护的单词。

    我还建议阅读Andrew Montalenti的这篇文章,强调“dunder惯例是一个核心Python团队保留的命名空间”“永远不要发明自己的dunders”,因为“核心Python团队为自己保留了一个相当丑陋的命名空间”。这与PEP 8的指令“永远不要发明[dunder/magic]名称;只按照文档使用它们”相符。我认为Andrew说得很对——这只是核心团队的一个丑陋的命名空间。它的目的是运算符重载,而不是关于隐私(这不是Andrew的观点,而是我的和数据模型页面的观点)。
除了安德鲁的文章外,我还查阅了几篇关于这些“magic”/“dunder”方法的文章,但没有一个谈到隐私问题。这不是它所涉及的内容。
再次强调,我们应该使用len(a),而不是a.__len__()。但并不是因为隐私问题。

我认为 type(obj)obj.__class__ 以及 len(a)a.__len__() 之间的另一个(关键)区别是内置函数 type 被重载了。type(obj) 的意义与 type('Foo', (object,), {}) 完全不同。vars()vars(obj) 也是如此。人们不必记住 type 做了多种事情,而是将其视为创建新类型的工厂,因此他们会退回到老式的 obj.__class__ 来获取对象的类型。我并不是说他们应该这样做,只是试图解释为什么这可能比误用 __len__ 更常见。 - mgilson
@mgilson 很好的观点。或者他们甚至只知道另一件事情。比如,他们在某个地方看到了使用 type 创建类型,然后因为认为自己已经知道了 type 的作用,就从未学习过其他内容。另一个可能是 IDE 的自动完成功能。人们可能通过查看自动完成提供的对象来了解它们的用途。然后他们看到了 __class__,但没有看到 type - Stefan Pochmann

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