[Effective Python笔记]二、函数

本文介绍了如何在Python中更好地使用函数,包括避免使用None来表示特殊情况,而是通过异常来处理错误;理解闭包中的变量使用,包括Python3中的nonlocal关键字以及Python2的实现;利用生成器减少内存消耗;注意在参数上迭代的陷阱;使用关键字参数提高代码可读性;以及如何处理动态默认值的参数。文章还给出了多个示例来解释这些概念。

尽量用异常来表示特殊情况,而不要返回None

有时候我们编写函数时,会喜欢给None这个返回值特殊意义。这种做法在某些情况下是合理的,例如,计算两数相除的商,在除数为0的时候,计算结果是没有明确含义的(undefined),所以似乎应该返回None。

def divide(a, b):
    try:
        return a/b
    except ZeroDivisionError:
        return None

但是我们在拿到这个返回值做判断时,往往不会专门去判断函数的返回值是否为None,而是会假定:只要返回了与False等效的运算结果,就说明函数出错了。

x, y = 0, 5
result = divide(x, y)
if not result:
    print("Invalid inputs") # This is wrong!

如果None这个返回值具备特殊意义,可能会使调用它的人写出错误的代码。有两种办法可以减少这个错误。
第一种办法,把返回值拆成两部分放到二元组(two-tuple)里。二元组的首个元素,表示操作是否成功,第二个元素,才是真正的运算结果。

def divide(a, b):
    try:
        return True, a/b
    except ZeroDivisionError:
        return False, None

这样做的问题在于,调用者可以用下划线为名称的变量,跳过元组的第一部分。这样写没错,但是却和返回None有着相似的错误。
第二种办法好一些,那就根本不返回None,把错误抛给上一级,让调用者必须处理它。

def divide(a, b):
    try:
        return a/b
    except ZeroDivisionError as e:
        raise ValueError("Invalid inputs") from e

这样写出来的代码,会比较清晰。

了解如何在闭包里使用外围作用域的变量

假如有一份列表,其中的元素全是数字,现在我们要对其排序,排序时要把出现在某个群组内的数字,放在群组外数字之前。
实现这个功能的常见用法是使用sort排序,把辅助函数传给key参数。

def sort_priority(values, group):
    def helper(x):
        if x in group:
            return (0, x)
        return (1, x)
    values.sort(key=helper)

用这个函数应对一些简单的输入值。

numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}
sort_priority(numbers, group)
print(numbers)
>>>
[2, 3, 5, 7, 1, 4, 6, 8]

这个函数能够运行的原因基于下列三个原因:

  • Python支持闭包(closure):闭包是一种定义在某个作用域中的函数,函数引用了那个作用域中的变量。
  • Python的函数是一级对象,我们可以直接引用函数、把函数赋给变量、把函数当成参数传给其它函数,并通过表达式及if语句对其进行比较和判断。
  • Python使用特殊的规则来判断两个元组。它首先比较各元组中下标为0的元素,如果相等,再比较下标为1的元素,依次类推。

现在我们增加一个小需求,如果列表中出现了优先级更高的元素,我们希望sort_priority函数返回一个值,该函数的调用者,可以根据这个值作出相应地功能。
我们先试试下面这种简单的写法:

def sort_priority2(numbers, group):
    found = False
    def helper(x):
        if x in group:
            found = True
            return (0, x)
        return (1, x)
    values.sort(key=helper)
    return found

用刚才输入的值,来运行这个函数。

numbers = [8, 3, 1, 2, 5, 4, 7, 6]
group = {2, 3, 5, 7}
found = sort_priority2(numbers, group)
print("Found:", found)
print(numbers)
>>>
Found: False
[2, 3, 5, 7, 1, 4, 6, 8]

可以发现排序结果是对的,但是found值不对,这是为什么呢?
在表达式中引用变量时,Python解释器将按如下顺序遍历各作用域,以解析该引用:

  1. 当前函数的作用域
  2. 任何外围的作用域(例如,包含当前函数的其它函数)
  3. 包含当前代码的那个模块的作用域(也叫全局作用域, global scope)
  4. 内置作用域(也就是包含len及str等函数的那个作用域)

如果上面这些地方都没有定义过名称相符的变量,那就抛出NameError异常。

给变量赋值时,规则又不一样。如果当前作用域内已经定义了这个变量,那么该变量就会具备新值。若当前作用域内没有这个变量,Python则会把这次赋值视为对该变量的定义。而新定义的这个值,其作用域就是包含赋值操作的这个函数。
而上面sort_priority2函数中,将found变量赋值这一操作,就是在helper闭包里进行的,于是,这相当于在helper函数中新定义了一个名为found的新变量。

1.获取闭包内的数据

Python3中有一种特殊的写法,我们可以使用nonlocal取得闭包内的数据。它的意图是:给相关变量赋值的时候,应该在上层作用域中查找该变量。nonlocal的唯一限制在于,它不能延伸到模块级别,这是为了防止它污染全局作用域。
下面用nonlocal实现这个函数。

def sort_priority3(numbers, group):
    found = False
    def helper(x):
    nonlocal found
        if x in group:
            found = True
            return (0, x)
        return (1, x)
    values.sort(key=helper)
    return

nonlocal语句表明,如果在闭包内给变量赋值,那么修改的其实是闭包外的那个作用域的变量。这与global语句互为补充。
如果使用nonlocal的代码已经很复杂,那就应该将相关的状态封装成辅助类(helper class)。下面定义的这个类,与nonlocal所达成的功能相同。

class Sorter(object):
    def __init__(self, group):
        self.found = False
        self.group = group

    def __call__(self, x):
        if x in self.group:
            self.found = True
            return (0, x)
        return (1, x)
sorter = Sorter(group)
numbers.sort(key=sorter)
2.Python2的值

由于Python2中不支持nonlocal,为了实现类似的功能,我们需要利用Python的作用域规则来解决。

def sort_priority2(numbers, group):
    found = [False]
    def helper(x):
        if x in group:
            found[0] = True
            return (0, x)
        return (1, x)
    values.sort(key=helper)
    return found[0]

上级作用域的变量是字典、集或某个类的实例时,这个技巧也同样适用。
要点

  • 对于定义在作用域内的闭包来说,它可以引用这些作用域内的变量。
  • 使用默认方式对闭包内的变量赋值,不会影响到外围作用域的同名变量。
  • Python3中,我们可以在闭包内使用nonlocal语句来修饰某个名称,使该闭包可以修改外围作用域中的变量。

笔记中的语句,应该是简短而又不失原意的。我在后续的笔记中会适当缩短用例描述,只记载关键内容。

考虑用生成器来改写直接返回列表的函数

假如我们要查出字符串中每个词的首字母,在整个字符串里的位置。
我们可以考虑用生成器(generator)函数来写。生成器是使用yield表达式的函数。调用生成器函数时,它并不是真的运行,而是会返回迭代器。每次在这个迭代器上面调用内置的next函数时,迭代器会把生成器推进到下一个yield表达式那里。生成器传给yield的每一个值,都会由迭代器返回给调用者。

def index_words_iter(text):
    if text:
        yield 0
    for index, letter in enumerater(text):
        if letter == ' ':
            yield index + 1

迭代器可以传给内置的list函数,以将其转换为列表。

address = "Four score and seven years ago..."
result = list(index_words_iter(address))
print(result[0:3])
>>>
[0, 5, 11]

想比如果使用返回值为列表的方法实现,这个函数可以解决两个问题。第一,函数需要额外创建新列表和在操作完成后返回列表。第二,函数在返回前,要先把所有结果都放在列表里面,如果内存非常大那么程序可能耗尽内存并崩溃。
定义这种生成器,唯一需要注意的就是:函数返回的那个迭代器是有状态的,调用者不应该反复使用它。
要点

  • 由生成器所返回的那个迭代器,可以把生成器函数体中,传给yield表达式的值,逐次产生出来
  • 无论输入量有多大,都不会影响生成器在执行时所耗的内存。

在参数上面迭代时,要多加小心

我们之前有提到过,一个迭代器只能产生一轮结果。在抛出过StopIteration异常的迭代器或生成器上面继续迭代第二轮,是不会有结果的。
例如。

def gen_num(numbers):
    for number in numbers:
        yield number
def pprint(gener):
    print(sum(gener))
    print(list(gener))
gener = gen_num([1, 2, 3, 4, 5, 6])
pprint(gener)
>>>
21
[]

通过这段代码,我们还可以看出一个奇怪的结果,那就是:在已经用完的迭代器上面继续迭代时,居然不会报错。for循环、list构造器和Python标准库里其它许多函数,都认为在正常操作过程中出现StopIteration异常是完全有可能的,这些函数没办法区分这个迭代器是本来就没有输出值,还是本来有输出值,但现在用完了。
一种解决办法就是新编一种实现迭代器协议(iterator protocal)的容器类。
迭代器协议是指:在执行类似for x in foo这样的语句时,Python实际上会调用iter(foo)。内置的iter函数又会调用foo.__iter__这个特殊的方法。该方法必定会返回迭代器对象,而迭代器本身,则实现了名为__next__的特殊方法。此后,for循环会在迭代器上面反复调用内置的next函数,直至其耗尽并产生StopIteration异常。
只需要让自己的类把__iter__方法实现为生成器即可。

class GenNum():
    def __init__(self, numbers):
        self.numbers = numbers
    def __iter__(self):
        for number in self.numbers:
            yield number
gener = GenNum([1, 2, 3, 4, 5, 6])
pprint(gener)
>>>
21
[1, 2, 3, 4, 5, 6]

pprint函数中sum方法会调用GenNum中的__iter方法,list方法中也同样会调用,两个迭代器会各自走完一整轮。
这种方式唯一的缺点在于,需要多次读取输入数据。
迭代器协议有这样的约定:如果把迭代器对象传给内置的iter函数,那么此函数会把该迭代器对象返回,反之,如果传给iter函数的是个容器类型的对象,那么iter函数每次都会返回新的迭代器对象。于是,我们可以根据iter函数的行为来判断输入值是不是迭代器对象。如果是,就抛出TypeError错误。

def pprint(gener):
    if iter(gener) is iter(gener):    # An Iterator -- bad
        raise TypeError("Must supply a container")
    print(sum(gener))
    print(list(gener))

如果我们希望多次迭代这些数据,那上面这种写法就比较理想。这样能够处理list和GenNum这样的输入参数,因为他们都是容器。凡是遵从迭代器协议的容器类型,都可以兼容输入。

visits = [1, 2, 3, 4, 5]
pprint(visits)
visits = GenNum([1, 2, 3, 4, 5, 6])
pprint(visits)

要点

  • 函数在输入参数上迭代多次时要注意:如果参数是迭代器,那么可能会导致奇怪的值并错失某些值。
  • Python的迭代器协议,描述了容器和迭代器应该如何与iter和next内置函数、for循环及相关表达式相互配合。
  • __iter__方法实现为生成器,即可定义自己的容器类型。
  • 想判断某个值是迭代器还是容器,可以拿该值为参数,两次调用iter函数,若结果相同,则是迭代器。调用内置的next函数,即可令该迭代器前进一步。

用数量可变的位置参数减少视觉杂讯

令函数接受可选的位置参数(由于这种参数习惯上写为*args,所以又称为starargs,星号参数),能够使代码更加清晰。
例如,定义一个log函数,接受一段信息和可选的其它信息。

def log(message, *args):
    if not args:
        print(message)
    else:
        args_str = ', '.join(str(x) for x in args)
        print('%s: %s', (message, args_str))
log('My Number are', 1, 2)
log('Hi there')
text = 'Hello'
log('Some letter are', *text)
>>>
My Number are 1, 2
Hi there
Some letter are H, e, l, l, o

接受数量可变的位置参数会带来两个问题。
第一个问题是,变长参数在传给函数时,总是要先转化成元组。这就意味着,如果用带有*操作符的生成器为参数,那么Python就会把该生成器完整地迭代一轮,并把生成器的每一个值都放入元组中。这可能会消耗大量内存,并导致程序崩溃。

def my_generator():
    for i in range(10):
        yield i

def my_func(*args):
    print(args)

it = my_generator()
my_func(*it)
>>>
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

只有当我们能够确定输入的参数个数比较少时,才应该令函数接受*args式的变长参数。
第二个问题是,如果以后要给函数添加新的位置参数,那就必须修改原来调用该函数的旧代码。为了彻底避免此类情况,我们应该使用只能以关键字形式指定的参数(keyword-only argument),来扩展这种接受*args的函数。
要点

  • 在def语句中使用*args,即可令函数接受数量可变的位置参数。
  • 调用函数时,可以采用*操作符,把序列中的元素当成位置参数,传给该函数
  • 对生成器使用*操作符并作为参数传入函数中,可能会到导致程序耗尽内存。

用关键字参数来表达可选的行为

采用关键字形式来指定参数值时,我们会在表示函数操作的那一对圆括号内,以赋值的格式,把参数名称和参数值分别放在等号左右两侧。关键字参数的顺序不限,只要把函数所要求的的全部位置参数指定号即可。

def remainder(number, divisor):
    return number % divisor

assert remainder(20, 7) == 6
remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)

这些调用方式都是等效的。

remainder(20, 7)
remainder(20, divisor=7)
remainder(number=20, divisor=7)
remainder(divisor=7, number=20)

使用关键字参数,能带来三个好处。
首先,以关键字参数来调用函数,能使读这行代码的人更容易理解其含义。
第二个,它可以在函数定义中提供默认值,这样可以消除重复代码,并使代码变得更整洁。例如对上一个代码进行一些修改。

def remainder2(number=0, divisor):
    return number % divisor
remainder(4)
>>>
0

第三个好处,是可以提供一种扩充函数参数的有效方式,使得扩充后的函数依然能与原有的那些调用代码相兼容。
这种写法只有一个缺陷,那就是这些可选的关键字参数,仍然可以通过位置参数的形式来指定.

def remainder3(number, divisor, add=1):
    return (number % divisor) + add
number, divisor = 10, 3
remainder3(number, divisor, 10)

以位置参数的形式来指定可选参数,是容易令人困惑的。最好的办法,就是一直以关键字的形式来指定这些参数。决不采用位置参数来指定它们。
要点

  • 函数参数可以按位置或关键字来指定。
  • 给函数添加新的行为时,可以使用带默认值的关键字参数,以便于原有的函数调用代码保持兼容。

用None和文档字符串来描述具有动态默认值的参数

有时我们想采用一种非静态的类型,来做关键字参数的默认值。

def log(message, when=datetime.now()):
    print('%s: %s' % (when, message)
log('Hi there!')
Sleep(0.1)
log('Hi again!')
>>>
2019-2-19 9:52:10.371432:Hi there!
2019-2-19 9:52:10.371432:Hi again!

两条消息的时间戳是一样的,这是因为datetime.now只运行了一次,也就是它只在函数定义时执行了一次。参数的默认值,会在每个模块加载进来的时候求出,而很多模块都是在程序启动时加载的。包含这段代码的模块一旦加载进来,参数的默认值就固定不变了。
在Python中正确实现动态默认值,习惯上是把默认值设为None,如果发现该参数的值是None,那就将其设为实际的默认值。

def log(message, when=None):
"""Log a message with timestamp.
Args:
    message:Message to print
    when:datetime of when the message occurred.Defaults to the present time.
"""
    when = datetime.now() if when is None else when
    print('%s: %s' % (when, message)

现在,两条消息的时间戳就不同了。
如果参数的实际默认值是可变类型(mutable),那就一定要使用None作为形式上的默认值。
要点

  • 参数的默认值,只会在程序加载模块并读到函数的定义时评估一次。对于{}或[]等动态的值,可能会产生奇怪的结果
  • 对于以动态值作为实际默认值的关键字参数来说,应该把形式上的默认值设为None。

用只能以关键字形式指定的参数来确保代码明晰

由于关键字参数很灵活,所以在编写代码时,可以把函数的用法表达得更加明确。
例如,要计算两数相除的结果,同时要对计算时的特殊情况进行小心的处理。有时我们想忽略ZeroDivisionError异常并返回无穷。有时又想忽略OverflowError异常并返回0。

def safe_division(number, divisor,
                    ignore_overflow=False,
                    ignore_zero_division=False):
    try:
        return number / divisor
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise

下面这种调用方式,可以忽略float溢出,并返回0

result = safe_division(1.0, 10**500, ignore_overflow=True)
print(result)
>>>
0.0

下面这种调用方式,可以忽略拿0作为除数的错误,并返回无穷

result = safe_division(1.0, 0, ignore_zero_division=True)
print(result)
>>>
inf

上面的写法还是有缺陷。由于关键字参数都是可选的,所以没办法确保函数的调用者一定会使用关键字来明确指出这些参数的值。

safe_division(1.0, 0, True, False)

再Python3中,可以定义一种只能以关键字形式来指定的参数,从而确保调用该函数的代码读起来会比较明确。
下面这个函数,参数列表里的*号,标志着位置参数就此终结,之后的那些参数,都只能以关键字形式来指定。

def safe_division2(number, divisor, *,
                    ignore_overflow=False,
                    ignore_zero_division=False):
    # ...

现在我们就不能用位置参数的形式来指定关键字参数了。

safe_division2(1.0, 0, True, False)
TypeError: safe_division2() take 2 positional argument but 4 were given
在Python2中实现只能以关键字来指定的参数

Python2中并没有明确的语法来定义这种只能以关键字形式指定的参数。我们可以在参数列表中使用**操作符,并且令函数在遇到无效的调用时抛出TypeError。**操作符是用来接受任意数量的关键字参数的。即便某些关键字参数没有定义在函数中,它也依然能接受。

# Python 2
def safe_division3(number, divisor, **kwargs):
    ignore_overflow = kwargs.pop('ignore_overflow', False)
    ignore_zero_div = kwargs.pop('ignore_zero_div', False)
    if kwargs:
        raise TypeError('Unexpected **kwargs: %r' % kwargs)
    # ...

字典pop()方法用于删除字典给定键key及对应的返回值,返回值为被删除的值,如果没有key,返回默认值。
现在,既可以用不带关键字参数的方式来调用safe_division3函数,也可以用有效的关键字参数来调用它。与Python3版本的函数一致。也不能用位置参数的形式来指定关键字参数的值。
此外,调用者还不能传入不符合预期的关键字参数。

safe_division3(0, 0, unexcepted=True)
>>>
TypeError: Unexcepted **kwargs: {'unexcepted': True}

要点

  • 在编写函数时,Python3有明确的语法来定义这种只能以关键字形式指定的参数。
  • Python2的函数可以接受**kwargs参数,并手工抛出TypeError异常,以便模拟只能以关键字形式指定的参数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值