Python函数的参数是如何工作的?

我们先探讨在Python中如何将参数传递给函数的相关细节,然后回顾与这些概念相关的良好软件工程实践的一般理论。

通过了解Python提供的处理参数的多种方式,我们能够更轻松地掌握通用规则,进而可以轻松地得出结论,即什么是好的模式或习惯用法。然后,我们可以确定在哪些情况下Python方法是正确的,以及在哪些情况下可能滥用了该语言的特性。

1.如何将参数复制到函数中

Python中的第一条规则是所有参数都由一个值传递——总是这样。这意味着,当把值传递给函数时,它们被分配给稍后将在其上使用的函数签名定义上的变量。你将注意到,函数更改参数可能依赖于类型参数——如果我们传递可变对象,而函数体修改了这一点,那么,这当然是有副作用的,当函数返回时,它们已经更改了。

通过如下示例,我们可以看到其中的区别:

>>> def function(argument):
...     argument += " in function"
...     print(argument)
...
>>> immutable = "hello"
>>> function(immutable)
hello in function
>>> mutable = list("hello")
>>> immutable
'hello'
>>> function(mutable)
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i',
'o', 'n']
>>> mutable
['h', 'e', 'l', 'l', 'o', ' ', 'i', 'n', ' ', 'f', 'u', 'n', 'c', 't', 'i',
'o', 'n']
>>>

这看起来可能不一致,但事实并非如此。当我们传递第一个参数(一个字符串)时,它被分配给函数上的参数。由于string对象是不可变的,因此像“argument += ”这样的语句实际上会创建新对象“argument + ”,并将其赋值给参数。此时,argument只是函数范围内的一个局部变量,与调用方中的原始变量无关。

此外,当我们传递list时,它是一个可变对象,那么这个语句就有了不同的含义(它实际上等价于在那个list上调用.extend())。该操作符的作用是对一个包含原始list对象引用的变量就地修改list,从而修改它。

在处理这些类型的参数时,我们必须小心,因为它们可能导致意想不到的副作用。除非你绝对确定以这种方式操纵可变参数是正确的,否则应避免使用它,并寻找没有这些问题的替代方法。

不要改变函数参数。一般来说,应尽量避免函数中的副作用。

与许多其他编程语言一样,Python中的参数可以通过位置传递,也可以通过关键字传递。这意味着我们可以明确地告诉函数我们想要为它的哪个参数设置哪个值。唯一需要注意的是,在通过关键字传递参数之后,后面的其他参数也必须以这种方式传递,否则会引发SyntaxError异常。

2.参数的变量数

Python和其他语言一样,具有内置的函数和结构,这些函数和结构可以接收可变数量的参数。考虑这样一种假设,遵循类似C语言中printf函数结构的字符串插值函数(无论是通过使用%运算符还是字符串的格式化方法),第一个位置放置字符串类型参数,紧随其后的是任意数量的参数,这些参数将被放置在标记了的格式化字符串中。

除了使用Python中提供的函数外,我们还可以自己创建函数,这两种函数的使用方式类似。在本节中,我们将介绍可变参数函数的基本原理,同时给出一些建议。在下一节中,我们将探讨当函数参数过多时如何利用这些特征来处理常见的问题和约束。

对于位置参数的可变数量,在包装这些参数的变量名之前,使用星号(*)。这是通过Python的打包机制实现的。

假设有一个函数有3个位置参数。在某段代码中,我们恰好可以很方便地将传递给函数的参数存储到一个列表中,列表的元素和函数的参数顺序一致。我们可以使用打包机制,通过一条指令的方式一起传递这些参数,而不是一个一个传递它们(就是说,将列表索引0中的元素传递给第一个参数,将列表索引1中的元素传递给第二个参数,并以此类推),一个一个传递参数的方式非常不符合Python的风格。

>>> def f(first, second, third):
...     print(first)
...     print(second)
...     print(third)
...
>>> l = [1, 2, 3]
>>> f(*l)
1
2
3

打包机制的好处是它也可以反过来工作。如果我们想将一个列表的值按其各自的位置提取到变量中,就可以这样分配它们:

>>> a, b, c = [1, 2, 3]
>>> a
1
>>> b
2
>>> c
3

部分解包也是可能的。假设我们只对序列的第一个值感兴趣(可以是列表、元组或其他东西),在某个点之后,我们只希望其余的值放在一起。我们可以分配所需要的变量,把其余的放在一个打包列表中。解包的顺序是不受限制的。如果在解包的部分没有任何内容可以放置,那么结果是一个空列表。我们鼓励你在Python终端上尝试一些示例,如下面的清单所示,并探索解包与生成器的关系:

>>> def show(e, rest):
...     print("Element: {0} - Rest: {1}".format(e, rest))
...
>>> first, *rest = [1, 2, 3, 4, 5]
>>> show(first, rest)
Element: 1 - Rest: [2, 3, 4, 5]
>>> *rest, last = range(6)
>>> show(last, rest)
Element: 5 - Rest: [0, 1, 2, 3, 4]
>>> first, *middle, last = range(6)
>>> first
0
>>> middle
[1, 2, 3, 4]
>>> last
5
>>> first, last, *empty = (1, 2)
>>> first
1
>>> last
2
>>> empty
[]

在迭代中可以找到解包变量的最佳用途之一。当我们必须遍历一组元素,而每个元素又依次是一个序列时,最好是在遍历每个元素的同时解包。为了实际查看这样的示例,我们将假设有一个函数用来接收数据库行的列表,并负责从该数据创建用户。第一个要实现的是,从行中每一列的位置获取要构造用户的值,这根本不是习惯用法。第二个要实现的是,在迭代时进行解包:

USERS = [(i, f"first_name_{i}", "last_name_{i}") for i in range(1_000)]

class User:
    def __init__(self, user_id, first_name, last_name):
        self.user_id = user_id
        self.first_name = first_name
        self.last_name = last_name

def bad_users_from_rows(dbrows) -> list:
    """A bad case (non-pythonic) of creating ``User``s from DB rows."""
    return [User(row[0], row[1], row[2]) for row in dbrows]

def users_from_rows(dbrows) -> list:
    """Create ``User``s from DB rows."""
    return [
        User(user_id, first_name, last_name)
        for (user_id, first_name, last_name) in dbrows
    ]

可以注意到,第二个版本更容易阅读。在第一个版本的函数(bad_users_from_rows)中,有以row[0]、row[1]和row[2]的形式表示的数据,这些数据并没有说明它们是什么。换句话说,像user_id、first_name和last_name这样的变量代表了它们自己。

在设计函数时,我们可以利用这种功能。

我们在标准库中可以找到的一个示例是max函数,它的定义如下:

max(...)
    max(iterable, *[, default=obj, key=func]) -> value
    max(arg1, arg2, *args, *[, key=func]) -> value
    With a single iterable argument, return its biggest item. The
    default keyword-only argument specifies an object to return if
    the provided iterable is empty.
    With two or more arguments, return the largest argument.

其中有一个类似的表示法,关键字参数用两个星号(**)表示。如果有一个字典,我们用两个星号把它传递给一个函数,那么该函数会选择键作为参数的名称,然后把键的值作为函数中那个参数的值进行传递。

例如,下面这行代码:

function(**{"key": "value"})

等同于:

function(key="value")

相反,如果我们定义一个参数以两个星号开头的函数,则将发生相反的情况——关键字提供的参数将被打包到字典中:

>>> def function(**kwargs):
...     print(kwargs)
...
>>> function(key="value")
{'key': 'value'}

函数中参数的数量

我们认为,如果函数或方法中的参数太多,就意味着代码设计很糟糕(“代码异味”)。鉴于此,我们将给出这个问题的解决方案。

一种解决方案是软件设计的一个更通用的原则——具体化(为传递的所有参数创建一个新对象,这可能是我们缺少的抽象)。将多个参数压缩到一个新对象中并不是Python特有的解决方案,而是可以应用到任何编程语言中。

另一种解决方案是使用我们在上一节中看到的特定于Python的特性,利用变量位置参数和关键字参数创建具有动态签名的函数。虽然这可能是一种Python式的处理方式,但我们必须小心,不要滥用该特性,因为可能创建了一些太过于动态的东西,以至于难以维护。在这种情况下,我们应该看一下函数的主体。不管签名或者参数是否似乎是正确的,如果函数使用参数的值做了太多不同的事情,那么这是一个信号——它必须被分解成多个更小的函数。(记住,函数应该做一件事,而且仅做一件事!)

1.函数参数和耦合

函数签名的参数越多,这个参数就越有可能与调用方函数紧密耦合。

假设有两个函数f1和f2,函数f2有5个参数。f2接收的参数越多,对于任何试图调用该函数的人来说,收集所有信息并将其传递下去以便使其正常工作的难度就越大。

现在,f1似乎有所有这些信息,因为这些信息能正确调用f1,由此我们可以得出两个结论。首先,f2可能是一个有漏洞的抽象概念,这意味着,当f1知道f2所需要的所有东西时,它几乎可以知道自己在做什么,并且能够自行完成。总而言之,f2抽象得没那么多。其次,f2看起来只对f1有用,很难想象在不同的上下文中使用这个函数,这使得重用变得更加困难。

当函数具有更通用的接口并且能够处理更高级别的抽象时,它们就变得更加可重用。

这适用于所有类型的函数和对象方法,包括类的__init__方法。这种方法的出现通常(但并不总是)意味着应该传递一个新的更高层级的抽象,或者存在一个缺失的对象。

如果一个函数需要太多的参数才能正常工作,就可以将其看作“代码异味”。

事实上,这是一个设计问题——静态分析工具,如Pylint(见第1章),在遇到这种情况时,默认会发出警告。如果发生这种情况,不要抑制警告,而应该重构它。

2.使用太多参数的简洁函数签名

假设我们找到一个需要太多参数的函数,并且知道不能就这样把它放置在代码库中,必须重构它。但是,用什么方法呢?

根据具体情况,我们可以应用以下一些规则。这些规则虽然不是广泛适用的,但可以为我们解决那些常见问题提供思路。

有时,如果看到大多数参数属于一个公共对象,就可以用一种简单的方法更改参数。例如,考虑这样一个函数调用:

track_request(request.headers, request.ip_addr, request.request_id)

现在,函数可能接收或不接收其他参数,但这里有一点非常明显:所有参数都依赖于request,那么为什么不直接传递request对象呢?这是一个简单的更改,但是它显著地改进了代码。正确的函数调用应该是track_request(request)方法。进一步来说,从语义上讲,调用track_request(request)方法也更有意义。

虽然鼓励传递这样的参数,但是在所有将可变对象传递给函数的情况下,我们必须非常小心副作用。我们调用的函数不应该对传递的对象做任何修改,因为这会使对象发生变化,产生不希望出现的副作用。除非这实际上是想要的效果(在这种情况下,必须明确说明),否则不鼓励这种行为。即使当我们实际上想要更改正在处理的对象上的某些内容时,更好的替代方法是复制它并返回(新的)修改后的版本。

处理不可变对象,并尽可能避免副作用。

这给我们带来了一个类似的主题:分组参数。在前面的示例中,我们已经对参数进行了分组,但是没有使用组(在本示例中是请求对象)。但是其他情况没有这种情况那么明显,我们可能希望将参数中的所有数据分组到能充当容器的单个对象中。不用说,这种分组必须有意义。这里的想法是具体化:创建设计中缺少的抽象。

如果前面的策略不起作用,作为最后的手段,我们可以更改函数的签名,以接收可变数量的参数。如果参数的数量太多,使用*args或**kwargs会使事情更加难以理解,所以我们必须确保接口被正确地记录和使用,但在某些情况下,这是值得做的。

的确,用*args和**kwargs定义的函数非常灵活且适应性强,但缺点是失去了它的签名,以及它的部分含义和几乎所有易读性。我们已经看到了变量名(包括函数参数)如何使代码更容易阅读的示例。如果一个函数将获取任意数量的参数(位置或关键字),当我们想要看看这个函数在未来可以做什么时,我们可能无法通过这些参数了解到这一点,除非有一个非常好的文档说明。

本文摘自《编写整洁的Python代码》

Python函数的参数是如何工作的?_第1张图片

[西] 马里亚诺·阿那亚(Mariano Anaya) 著,包永帅,周立 译

  • Python语言程序设计代码整洁之道,
  • 全面介绍实现Python代码整洁应遵循的基本原则,
  • 自学编程软件开发设计原则,并提供源代码下载。

本书介绍Python软件工程的主要实践和原则,旨在帮助读者编写更易于维护和更整洁的代码。全书共10章:第1章介绍Python语言的基础知识和搭建Python开发环境所需的主要工具;第2章描述Python风格代码,介绍Python中的第一个习惯用法;第3章总结好代码的一般特征,回顾软件工程中的一般原则;第4章介绍一套面向对象软件设计的原则,即SOLID原则;第5章介绍装饰器,它是Python的**特性之一;第6章探讨描述符,介绍如何通过描述符从对象中获取更多的信息;第7章和第8章介绍生成器以及单元测试和重构的相关内容;第9章回顾Python中最常见的设计模式;第10章再次强调代码整洁是实现良好架构的基础。

本书适合所有Python编程爱好者、对程序设计感兴趣的人,以及其他想学习更多Python知识的软件工程的从业人员。

你可能感兴趣的:(Python,编程语言,代码规范,整洁代码,Python软件工程)