确保干净的代码:看一下 Python,参数化

已发表: 2022-03-11

在这篇文章中,我将讨论我认为在生成干净的 Pythonic 代码时最重要的技术或模式——即参数化。 这篇文章适合你,如果:

  • 您对整个设计模式的东西相对较新,可能对一长串模式名称和类图感到困惑。 好消息是,对于 Python,您绝对必须了解的设计模式只有一种。 更好的是,您可能已经知道它,但也许不是所有可以应用它的方式。
  • 您从另一种 OOP 语言(如 Java 或 C#)来到 Python,并想知道如何将您对该语言的设计模式知识转化为 Python。 正如作者 Peter Norvig 所说,在 Python 和其他动态类型语言中,静态类型 OOP 语言中常见的许多模式是“不可见的或更简单的”。

在本文中,我们将探讨“参数化”的应用,以及它如何与称为依赖注入策略模板方法抽象工厂工厂方法装饰器的主流设计模式相关联。 在 Python 中,由于 Python 中的参数可以是可调用的对象或类,因此其中许多变得简单或不必要。

参数化是获取在函数或方法中定义的值或对象,并将它们作为该函数或方法的参数的过程,以便泛化代码。 此过程也称为“提取参数”重构。 在某种程度上,这篇文章是关于设计模式和重构的。

Python参数化的最简单案例

对于我们的大多数示例,我们将使用教学标准库 turtle 模块来制作一些图形。

下面是一些使用turtle绘制 100x100 正方形的代码:

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

假设我们现在要绘制一个不同大小的正方形。 此时,非常初级的程序员会很想复制粘贴此块并进行修改。 显然,更好的方法是首先将正方形绘制代码提取到一个函数中,然后将正方形的大小作为该函数的参数:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

所以我们现在可以使用draw_square绘制任意大小的正方形。 这就是参数化基本技术的全部内容,我们刚刚看到了第一个主要用法——消除复制粘贴编程。

上面代码的一个直接问题是draw_square依赖于一个全局变量。 这有很多不好的后果,有两种简单的方法可以解决它。 第一个是让draw_square创建Turtle实例本身(稍后我将讨论)。 如果我们想为所有绘图使用单个Turtle ,这可能是不可取的。 所以现在,我们将再次简单地使用参数化来使turtle成为draw_square的参数:

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

这有一个花哨的名字——依赖注入。 它只是意味着如果一个函数需要某种对象来完成它的工作,比如draw_square需要一个Turtle ,调用者负责将该对象作为参数传入。 不,真的,如果你曾经对 Python 依赖注入感到好奇,就是这样。

到目前为止,我们已经处理了两个非常基本的用法。 本文其余部分的关键观察是,在 Python 中,有很多东西可以成为参数——比在其他一些语言中更多——这使得它成为一种非常强大的技术。

任何物体

在 Python 中,您可以使用这种技术来参数化任何对象,而在 Python 中,您遇到的大多数事物实际上都是对象。 这包括:

  • 内置类型的实例,如字符串"I'm a string"和整数42或字典
  • 其他类型和类的实例,例如datetime.datetime对象
  • 函数和方法
  • 内置类型和自定义类

最后两个是最令人惊讶的,特别是如果你来自其他语言,他们需要更多的讨论。

作为参数的函数

Python 中的函数语句做了两件事:

  1. 它创建一个函数对象。
  2. 它在指向该对象的本地范围内创建一个名称。

我们可以在 REPL 中使用这些对象:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

就像所有对象一样,我们可以将函数分配给其他变量:

 > >> bar = foo > >> bar() 'Hello from foo'

请注意, bar是同一对象的另一个名称,因此它具有与以前相同的内部__name__属性:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

但关键是,因为函数只是对象,所以在任何你看到函数被使用的地方,它都可能是一个参数。

因此,假设我们扩展了上面的正方形绘制函数,现在有时当我们绘制正方形时,我们希望在每个角处暂停——调用time.sleep()

但是假设有时我们不想暂停。 实现这一点的最简单方法是添加一个pause参数,可能默认为零,这样默认情况下我们不会暂停。

然而,我们后来发现,有时我们实际上想要在角落做一些完全不同的事情。 也许我们想在每个角落绘制另一个形状,改变笔的颜色,等等。我们可能会想添加更多的参数,一个用于我们需要做的每一件事。 但是,更好的解决方案是允许传入任何函数作为要采取的操作。 默认情况下,我们将创建一个什么都不做的函数。 如果需要,我们还将使此函数接受本地turtlesize参数:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

或者,我们可以做一些更酷的事情,比如在每个角落递归绘制更小的正方形:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

如上面的 python 参数化代码所示,递归绘制的小方块的插图

当然,这有一些变化。 在许多示例中,将使用函数的返回值。 在这里,我们有一种更加命令式的编程风格,调用函数只是为了它的副作用。

在其他语言中……

在 Python 中拥有一流的函数使这变得非常容易。 在缺少它们的语言中,或者某些需要参数类型签名的静态类型语言中,这可能会更难。 如果我们没有一流的功能,我们将如何做到这一点?

一种解决方案是将draw_square变成一个类SquareDrawer

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

现在我们可以SquareDrawer并添加一个at_corner方法来满足我们的需要。 这种python模式被称为模板方法模式——一个基类定义了整个操作或算法的形状,操作的变体部分被放入需要由子类实现的方法中。

虽然这有时在 Python 中可能会有所帮助,但将变体代码提取到一个简单地作为参数传递的函数中通常会简单得多。

在没有第一类函数的语言中,我们可以解决这个问题的第二种方法是将我们的函数包装为类中的方法,如下所示:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

这被称为策略模式。 同样,这肯定是在 Python 中使用的有效模式,特别是如果策略类实际上包含一组相关函数,而不仅仅是一个。 然而,通常我们真正需要的只是一个函数,我们可以停止编写类。

其他可调用对象

在上面的例子中,我谈到了将函数作为参数传递给其他函数。 然而,事实上,我写的所有东西都适用于任何可调用对象。 函数是最简单的例子,但我们也可以考虑方法。

假设我们有一个列表foo

 foo = [1, 2, 3]

foo现在附加了一大堆方法,例如.append().count() 。 这些“绑定方法”可以像函数一样传递和使用:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

除了这些实例方法之外,还有其他类型的可调用对象—— staticmethodsclassmethods ,实现__call__的类的实例,以及类/类型本身。

类作为参数

在 Python 中,类是“第一类”——它们是运行时对象,就像字典、字符串等一样。这可能看起来比作为对象的函数更奇怪,但幸运的是,证明这一事实实际上比函数更容易。

您熟悉的 class 语句是创建类的好方法,但它不是唯一的方法——我们还可以使用类型的三参数版本。 以下两个语句的作用完全相同:

 class Foo: pass Foo = type('Foo', (), {})

在第二个版本中,注意我们刚刚做的两件事(使用 class 语句更方便地完成):

  1. 在等号的右侧,我们创建了一个内部名称为Foo的新类。 如果您执行Foo.__name__ ,这是您将获得的名称。
  2. 通过赋值,我们在当前范围内创建了一个名称,Foo,它引用了我们刚刚创建的那个类对象。

我们对函数语句的作用做了同样的观察。

这里的关键见解是类是可以指定名称的对象(即,可以放入变量中)。 在任何地方你看到一个正在使用的类,你实际上只是看到一个正在使用的变量。 如果它是一个变量,它可以是一个参数。

我们可以将其分解为多种用法:

作为工厂的类

类是创建自身实例的可调用对象:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

并且作为一个对象,它可以分配给其他变量:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

回到我们上面的海龟示例,使用海龟进行绘图的一个问题是绘图的位置和方向取决于海龟的当前位置和方向,它也可以让它处于不同的状态,这可能对你没有帮助呼叫者,召集者。 为了解决这个问题,我们的draw_square函数可以创建自己的海龟,将其移动到所需的位置,然后绘制一个正方形:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

但是,我们现在有一个自定义问题。 假设调用者想要设置乌龟的一些属性或使用具有相同界面但具有一些特殊行为的不同种类的乌龟?

我们可以像以前一样通过依赖注入来解决这个问题——调用者将负责设置Turtle对象。 但是如果我们的函数有时需要为不同的绘图目的制作许多海龟,或者如果它想启动四个线程,每个线程都有自己的海龟来绘制正方形的一侧? 答案很简单,就是让 Turtle 类成为函数的参数。 我们可以使用带有默认值的关键字参数,以使不关心的调用者保持简单:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

要使用它,我们可以编写一个make_turtle函数来创建一个海龟并对其进行修改。 假设我们想在绘制正方形时隐藏海龟:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

或者我们可以继承Turtle以内置该行为并将子类作为参数传递:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

在其他语言中……

其他几种 OOP 语言,如 Java 和 C#,缺少一流的类。 要实例化一个类,您必须使用new关键字后跟一个实际的类名。

这种限制是抽象工厂(需要创建一组类,其唯一工作是实例化其他类)和工厂方法模式等模式的原因。 如您所见,在 Python 中,只需将类作为参数提取出来,因为类是它自己的工厂。

作为基类的类

假设我们发现自己创建子类以将相同的功能添加到不同的类。 例如,我们想要一个Turtle子类,它会在创建时写入日志:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

但随后,我们发现自己对另一个类做了完全相同的事情:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

这两者之间唯一不同的是:

  1. 基类
  2. 子类的名称——但我们并不真正关心它,可以从基类的__name__属性中自动生成它。
  3. debug调用中使用的名称——但同样,我们可以从基类名称生成它。

面对只有一个变体的两个非常相似的代码,我们能做什么? 就像在我们的第一个示例中一样,我们创建一个函数并将变量部分作为参数提取出来:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

在这里,我们有一个头等舱的演示:

  • 我们将一个类传递给一个函数,给参数一个常规名称cls以避免与关键字class冲突(您还将看到用于此目的的class_klass )。
  • 在函数内部,我们创建了一个类——注意每次调用这个函数都会创建一个类。
  • 我们将该类作为函数的返回值返回。

我们还设置LoggingThing.__name__这完全是可选的,但可以帮助调试。

这种技术的另一个应用是当我们有时想要添加到一个类中的一大堆特性时,我们可能想要添加这些特性的各种组合。 手动创建我们需要的所有不同组合可能会变得非常笨拙。

在编译时而不是运行时创建类的语言中,这是不可能的。 相反,您必须使用装饰器模式。 这种模式有时在 Python 中可能很有用,但大多数情况下你可以使用上面的技术。

通常,我实际上避免创建大量用于自定义的子类。 通常,有更简单、更 Pythonic 的方法,它们根本不涉及类。 但是,如果您需要,可以使用此技术。 另请参阅 Brandon Rhodes 对 Python 中装饰器模式的完整处理。

作为例外的类

您看到正在使用的类的另一个地方是在 try/except/finally 语句的except子句中。 猜测我们也可以参数化这些类也就不足为奇了。

例如,以下代码实现了一个非常通用的策略,即尝试可能失败的操作并使用指数退避重试,直到达到最大尝试次数:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

我们已经提取了要采取的操作和要捕获的异常作为参数。 参数exceptions_to_catch可以是单个类,例如IOErrorhttplib.client.HTTPConnectionError ,也可以是此类类的元组。 (我们要避免使用“bare except”子句,甚至是except Exception ,因为众所周知这会隐藏其他编程错误)。

警告和结论

参数化是重用代码和减少代码重复的强大技术。 它并非没有一些缺点。 在追求代码复用的过程中,经常会出现几个问题:

  • 变得非常难以理解的过于通用或抽象的代码。
  • 带有大量参数的代码会掩盖全局或引入错误,因为实际上,只有某些参数组合得到了正确测试。
  • 代码库不同部分的无用耦合,因为它们的“公共代码”已被分解到一个地方。 有时两个地方的代码只是偶然相似,两个地方应该相互独立,因为它们可能需要独立更改。

有时,一些“重复”的代码比这些问题要好得多,所以要小心使用这种技术。

在这篇文章中,我们介绍了称为依赖注入策略模板方法抽象工厂工厂方法装饰器的设计模式。 在 Python 中,其中许多确实是参数化的简单应用,或者由于 Python 中的参数可以是可调用的对象或类这一事实而变得不必要。 希望这有助于减轻“作为真正的 Python 开发人员应该知道的事情”的概念负担,并使您能够编写简洁的 Python 代码!

进一步阅读:

  • Python 设计模式:用于时尚时尚的代码
  • Python 模式:对于 Python 设计模式
  • Python 日志记录:深度教程