个性化阅读
专注于IT技术分析

确保干净的代码:Python参数化

本文概述

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

  • 你对整个设计模式事物还比较陌生, 一长串的模式名称和类图可能会让你感到困惑。好消息是, 对于Python来说, 实际上只有一种你绝对必须知道的设计模式。甚至更好的是, 你可能已经知道了, 但可能不是所有可以应用的方式。
  • 你是从另一种OOP语言(例如Java或C#)学习Python的, 并且想知道如何将你对设计模式的知识从该语言转换为Python。正如作者Peter Norvig所说, 在Python和其他动态类型的语言中, 静态类型的OOP语言中常见的许多模式是”不可见的或更简单”。

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

参数化是获取函数或方法中定义的值或对象, 并使其成为该函数或方法的参数, 以使代码通用化的过程。此过程也称为”提取参数”重构。从某种意义上说, 本文是关于设计模式和重构的。

Python参数化的最简单情况

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

以下是一些使用乌龟绘制100×100正方形的代码:

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中的function语句有两件事:

  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()。

但是假设有时候我们不想暂停。实现此目的的最简单方法是添加一个暂停参数, 可能默认值为零, 以便默认情况下我们不暂停。

但是, 我们后来发现有时实际上我们确实想在角落做一些完全不同的事情。也许我们想在每个角上绘制另一种形状, 改变笔的颜色, 等等。我们可能会想添加更多的参数, 每一项需要做的事情。但是, 一个更好的解决方案是允许将任何函数作为采取的措施传递进来。默认情况下, 我们将创建一个不执行任何操作的函数。如果需要, 我们还将使该函数接受本地的turtle和size参数:

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]

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

类作为参数

在Python中, 类是”第一类”, 它们是运行时对象, 如字典, 字符串等。这似乎比作为对象的函数更奇怪, 但值得庆幸的是, 实际上比对函数更容易证明这一事实。

你熟悉的class语句是创建类的一种不错的方法, 但这不是唯一的方法-我们还可以使用type的三个参数版本。以下两个语句执行的操作完全相同:

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中可能有用, 但是大多数情况下, 你可以使用上述技术。

通常, 我实际上避免创建许多子类来进行自定义。通常, 有一些更简单, 更多的Python方法完全不涉及类。但是, 如果需要, 可以使用此技术。另请参见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可以是单个类, 例如IOError或httplib.client.HTTPConnectionError, 也可以是此类的元组。 (我们要避免” bare除了”子句甚至是Exception除外, 因为众所周知这会隐藏其他编程错误)。

警告和结论

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

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

有时, 一些”重复的”代码比这些问题要好得多, 因此请谨慎使用此技术。

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

进一步阅读:

  • Python设计模式:适用于时尚代码
  • Python模式:适用于Python设计模式
  • Python日志记录:深入的教程
赞(0)
未经允许不得转载:srcmini » 确保干净的代码:Python参数化

评论 抢沙发

评论前必须登录!