Class Python 中的不变量

Class invariants in Python

Class invariants 在编码中绝对有用,因为它们可以在检测到明显的编程错误时提供即时反馈,而且它们还提高了代码的可读性,因为它们明确了哪些参数和 return 值可以是。我相信这也适用于 Python。

但是,通常在 Python 中,参数测试似乎不是 "pythonic" 做事的方式,因为它有悖于鸭子打字的习惯用法。

我的问题是:

  1. 在代码中使用断言的 Pythonic 方法是什么?

    例如,如果我有以下功能:

    def do_something(name, path, client):
        assert isinstance(name, str)
        assert path.endswith('/')
        assert hasattr(client, "connect")
    
  2. 更一般地说,什么时候断言太多了?

很高兴听到您对此的意见!

简答:

Are assertions Pythonic?

取决于您如何使用它们。一般来说,没有。制作通用的、灵活的代码是最 Pythonic 的事情,但是当你需要检查不变量时:

  1. 使用类型提示来帮助您的IDE执行类型推断,这样您就可以避免潜在的陷阱。

  2. 进行健壮的单元测试

  3. 首选 try/except 子句 引发更具体的异常。

  4. 把属性变成属性这样你就可以控制它们的getters和setters.

  5. 仅将 assert 语句用于调试目的。

有关最佳实践的更多信息,请参阅 this Stack Overflow discussion

长答案

你是对的。具有严格的 class 不变量不被认为是 Pythonic,但是有一种内置的方法来指定参数的首选类型和 returns 称为 类型提示 ,如在 PEP 484:

中定义

[Type hinting] aims to provide a standard syntax for type annotations, opening up Python code to easier static analysis and refactoring, potential runtime type checking, and (perhaps, in some contexts) code generation utilizing type information.

格式是这样的:

def greeting(name: str) -> str:
    return 'Hello ' + name 

typing 库提供了更进一步的功能。但是,有一个巨大的警告...

While these annotations are available at runtime through the usual __annotations__ attribute, no type checking happens at runtime . Instead, the proposal assumes the existence of a separate off-line type checker which users can run over their source code voluntarily. Essentially, such a type checker acts as a very powerful linter.

哎呀。嗯,您 可以 在测试时使用外部工具来检查不变性何时被破坏,但这并不能真正回答您的问题。


属性和try/except

处理错误的最佳方法是确保它从一开始就不会发生。第二个最好的方法是在它发生时制定一个计划。以这样的 class 为例:

 class Dog(object):
     """Canis lupus familiaris."""

     self.name = str()
     """The name you call it."""


     def __init__(self, name: str):
         """What're you gonna name him?"""

         self.name = name


     def speak(self, repeat=0):
         """Make dog bark. Can optionally be repeated."""

         print("{dog} stares at you blankly.".format(dog=self.name))

         for i in range(repeat):
             print("{dog} says: 'Woof!'".format(dog=self.name)

如果你想让你的狗的名字不变,这实际上不会阻止 self.name 被覆盖。它也不会阻止可能崩溃的参数 speak()。但是,如果您将 self.name 设为 property...

 class Dog(object):
     """Canis lupus familiaris."""
     
     self._name = str()
     """The name on the microchip."""

     self.name = property()
     """The name on the collar."""


     def __init__(self, name: str):
         """What're you gonna name him?"""

         if not name and not name.isalpha():
             raise ValueError("Name must exist and be pronouncable.")

         self._name = name


     def speak(self, repeat=0):
         """Make dog bark. Can optionally be repeated."""

         try:
             print("{dog} stares at you blankly".format(dog=self.name))
             
             if repeat < 0:
                 raise ValueError("Cannot negatively bark.")

             for i in range(repeat):
                 print("{dog} says: 'Woof!'".format(dog=self.name))

         except (ValueError, TypeError) as e:
             raise RuntimeError("Dog unable to speak.") from e


     @property
     def name(self):
         """Gets name."""

         return self._name

由于我们的 属性 没有 setter,self.name 本质上是不变的;除非有人知道 self._x,否则该值无法更改。此外,由于我们添加了 try/except 子句来处理我们预期的特定错误,因此我们为我们的程序提供了更简洁的控制流程。


那么什么时候使用断言?

可能没有 100% 的“Pythonic”方式来执行断言,因为您应该在单元测试中执行这些操作。但是,如果数据在运行时保持不变至关重要,则可以使用 assert 语句查明可能的问题点,如 Python wiki:

中所述

Assertions are particularly useful in Python because of Python's powerful and flexible dynamic typing system. In the same example, we might want to make sure that ids are always numeric: this will protect against internal bugs, and also against the likely case of somebody getting confused and calling by_name when they meant by_id.

For example:

from types import *
  class MyDB:
  ...
  def add(self, id, name):
    assert type(id) is IntType, "id is not an integer: %r" % id
    assert type(name) is StringType, "name is not a string: %r" % name

Note that the "types" module is explicitly "safe for import *"; everything it exports ends in "Type".

它负责数据类型检查。对于 classes,您使用 isinstance(),就像您在示例中所做的那样:

You can also do this for classes, but the syntax is a little different:

class PrintQueueList:
  ...
  def add(self, new_queue):
   assert new_queue not in self._list, \
     "%r is already in %r" % (self, new_queue)
   assert isinstance(new_queue, PrintQueue), \
     "%r is not a print queue" % new_queue

I realize that's not the exact way our function works but you get the idea: we want to protect against being called incorrectly. You can also see how printing the string representation of the objects involved in the error will help with debugging.

为了获得正确的形式,请像上面的示例一样在您的断言中附加一条消息
(例如:assert <statement>, "<message>")会自动将信息附加到生成的 AssertionError 中以帮助您进行调试。它还可以深入了解消费者错误报告,了解程序崩溃的原因。

Checking isinstance() should not be overused: if it quacks like a duck, there's perhaps no need to enquire too deeply into whether it really is. Sometimes it can be useful to pass values that were not anticipated by the original programmer.

Places to consider putting assertions:

  • checking parameter types, classes, or values
  • checking data structure invariants
  • checking "can't happen" situations (duplicates in a list, contradictory state variables.)
  • after calling a function, to make sure that its return is reasonable

断言如果使用得当会很有用,但对于不需要显式不变的数据,您不应该依赖它们。如果您希望它更像 Pythonic,您可能需要重构您的代码。

请查看 icontract 图书馆。我们开发它是为了将合同设计引入 Python,并提供信息性错误消息。这里作为 class 不变量的示例:

>>> @icontract.inv(lambda self: self.x > 0)
... class SomeClass:
...     def __init__(self) -> None:
...         self.x = 100
...
...     def some_method(self) -> None:
...         self.x = -1
...
...     def __repr__(self) -> str:
...         return "some instance"
...
>>> some_instance = SomeClass()
>>> some_instance.some_method()
Traceback (most recent call last):
 ...
icontract.ViolationError: self.x > 0:
self was some instance
self.x was -1