当你的函数接受相同的参数时,考虑使用一个类:反例

之前的文章中,我谈到了在 Python 中使用类的启发式方法。

如果你有接受相同参数的函数,考虑使用一个类。

问题是,启发式方法并不总是有效

为了最大限度地利用它们,知道什么是例外情况是有帮助的。

所以,让我们看看几个现实世界的例子,在这些例子中,取相同参数的函数不一定能组成一个类

反例:两组参数 #

考虑一下下面的情况。

我们有一个Feed阅读器的网络应用,它显示了一个Feed列表和一个条目(文章)列表,以各种方式进行过滤。

因为我们想在命令行中做同样的事情,所以我们把数据库特定的逻辑拉到一个单独的模块中的函数中。 这些函数接受一个数据库连接和其他参数,查询数据库,并返回结果。

def get_entries(db, feed=None, read=None, important=None): ... def get_entry_counts(db, feed=None, read=None, important=None): ... def search_entries(db, query, feed=None, read=None, important=None): ... def get_feeds(db): ...

主要的使用模式是:在程序开始时,连接到数据库;根据用户的输入,用相同的连接,但不同的选项重复调用函数。


把启发式的做法发挥到极致,我们最终会得到这样的结果。

`class Storage:

def __init__(self, db, feed=None, read=None, important=None):
    self._db = db
    self._feed = feed
    self._read = read
    self._important = important

def get_entries(self): ...
def get_entry_counts(self): ...
def search_entries(self, query): ...
def get_feeds(self): ...` 
复制代码

这不是很有用:每次我们改变选项,我们都需要创建一个新的Storage 对象(或者更糟糕的是,有一个单一的对象并改变它的属性)。另外,get_feeds() 甚至不使用它们–但不知为何,不使用它似乎同样糟糕。

缺少的是一点细微的差别:并不是_只有_一组参数,而是有_两组_,而且其中一组比另一组更经常变化。

让我们先处理一下明显的那个问题。

数据库连接变化的频率最低,所以把它保留在存储上是有意义的,并在周围传递一个存储对象。

`class Storage:

def __init__(self, db):
    self._db = db

def get_entries(self, feed=None, read=None, important=None): ...
def get_entry_counts(self, feed=None, read=None, important=None): ...
def search_entries(self, query, feed=None, read=None, important=None): ...
def get_feeds(self): ...` 
复制代码

这样做最重要的好处是,它把数据库从使用它的代码中抽象出来,允许你有不止一种存储。

想把条目作为文件存储在磁盘上吗? 编写一个FileStorage类,从那里读取它们。 想用各种组合的条目来测试你的应用程序吗? 编写一个MockStorage类,把条目放在一个列表中,放在内存中。无论谁调用get_entries()search_entries(),都不必知道_或关心_条目来自哪里或如何实现搜索的。

这就是数据访问对象的设计模式。 在面向对象的编程术语中,DAO提供了一个抽象的接口,封装_了_一个持久性机制。


好了,上面的内容在我看来是差不多的–我不会真的改变其他东西。

有些参数仍然是重复的,但这是_有用的重复:_一旦用户学会了用一种方法来过滤条目,他们就可以用任何一种方法来做。 而且,人们在不同的时间使用不同的参数;从他们的角度来看,这并不是真正的重复。

而且,无论如何,我们已经在使用一个类…

反例:数据类 #

让我们增加更多的要求。

除了存储东西之外,还有更多的功能,而且我们也有多个用户来做这些事情(网络应用,CLI,有人把我们的代码作为一个库来使用)。所以我们让Storage ,_只_做存储,并把它包装在一个_有_存储功能的Reader 对象里。

`class Reader:

def __init__(self, storage):
    self._storage = storage

def get_entries(self, feed=None, read=None, important=None):
    return self._storage.get_entries(feed=feed, read=read, important=important)

...

def update_feeds(self):
    # calls various storage methods multiple times:
    # get feeds to be retrieved from storage,
    # store new/modified entries
    ...` 
复制代码

现在,Storage.get_entries() 的主要调用者是Reader.get_entries() 。此外,过滤器的参数很少被存储方法直接使用,大多数时候它们被传递给辅助函数。

`class Storage:

def get_entries(self, feed=None, read=None, important=None):
    query = make_get_entries_query(feed=feed, read=read, important=important)
    ...` 
复制代码

问题:当我们增加一个新的入口过滤器选项时,我们必须改变阅读器方法、存储方法_和_帮助器。 而且很可能我们在未来还会这样做。

解决方案。将参数分组在一个只包含数据的类中。

`from typing import NamedTuple, Optional

class EntryFilterOptions(NamedTuple):
feed: Optional[str] = None
read: Optional[bool] = None
important: Optional[bool] = None

class Storage:

...

def get_entries(self, filter_options):
    query = make_get_entries_query(filter_options)
    ...

def get_entry_counts(self, filter_options): ...
def search_entries(self, query, filter_options): ...
def get_feeds(self): ...` 
复制代码

现在,不管它们被传了多少遍,只有两个地方的选项是重要的。

  • 在读者方法中,它建立了EntryFilterOptions对象
  • 它们被使用的地方,要么是一个帮助器,要么是一个存储方法

注意,虽然我们使用的是 Python 类的_语法_,但 EntryFilterOptions_并不是_传统的面向对象编程意义上的_类_,因为它没有行为。1有时,这些被称为 “被动数据结构 ” 或” 普通数据 ” 。

一个普通的类或数据类也会是一个不错的选择。 为什么我选择了一个命名的元组,这是另一篇文章的讨论。

我使用了类型提示,因为这是一种记录选项的廉价方式,但你不一定要这样做,甚至对数据类也是如此。

上面的例子是我的feed阅读器库中代码的简化版。 在现实世界中,EntryFilterOptions分组了6个选项,还有更多的选项在路上,而ReaderStorageget_entries()则有点复杂。

为什么不是一个dict呢? #

与其定义一个全新的类,我们还不如直接使用一个dict,比如。

{'feed': ..., 'read': ..., 'important': ...}

但是这有很多缺点。

  • Dict是没有类型检查的,TypedDict有帮助,但仍然不能防止_在运行时_使用错误的键。
  • Dicts不能很好地完成代码,同样,TypedDict可以帮助像PyCharm这样的智能工具,但在交互模式或IPython中却不能。
  • Dicts是可变的,对于我们的使用情况来说,不可变性是一个优点:选项没有太多的理由去改变,而且这将是非常意外的,所以不允许它发生是非常有用的。

为什么不采取**kwargs? #

既然我们谈论的是dicts,为什么不让Reader.get_entries()等直接接受和传递**kwargs 到EntryFilterOptions?

这虽然更短,但也破坏了完成度。

此外,这使得代码的自文档化程度降低:即使你看了Reader.get_entries()的源代码,你仍然不能立即知道它需要哪些参数。 这对于内部代码来说并不重要,但对于API中面向用户的部分,如果能使代码更容易使用,我们不介意让它变得更加冗长。

另外,如果我们以后引入另一个数据对象(比如,挂起分页选项),我们仍然要写代码在两者之间分割kwargs。

为什么不采取EntryFilterOptions呢? #

那为什么不让reader.get_entries()接受一个EntryFilterOptions呢?

因为这对用户来说_太_繁琐了:他们必须导入EntryFilterOptions,构建它,_然后_把它传给get_entries()。 坦率地说,这不是很成文。

读者方法和存储方法签名之间的这种差异存在,是因为它们的使用方式不同。

  • 读者方法主要是由外部用户以不同方式调用的
  • 存储方法主要由内部用户(Reader)以几种方式调用。

现在就这些了。

今天学到了一些新东西?与他人分享这篇文章吧,它真的很有帮助!:)


  1. Ted Kaminski在《数据、对象以及我们是如何被引入不良设计的》中更详细地讨论了这种区别。 [返回]
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享