• 函数高级用法


    函数高级用法

    1.概述

    这篇文章介绍python中函数的高级用法,函数在python中的地位是一等对象,他可以把一段逻辑封装成可复用的小单位,消除项目里重复代码。函数设计的质量在项目中起到了非常重要的作用,因此要多多研究如何设计巧妙好用的函数。

    1.1.把函数视作对象

    Python函数是对象,这里我们创建了一个函数,然后调用它,读取它的__doc__属性,并且确定函数对象本身是function类的实例。

    >>> def factorial(n):
    ...     '''returns n!'''
    ...     return 1 if n < 2 else n * factorial(n-1)
    ...
    
    //factorial是function类的实例
    >>> type(factorial)
    <class 'function'>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    2.函数基础

    2.1.函数参数技巧

    参数是函数的重要组成部分,它是函数最主要的输入源,决定了调用方使用函数时的体验。下面介绍几个有关函数参数的几个常用技巧。

    1.别将可变类型作为参数默认值

    在编写函数时,我们经常将参数设置为默认值,这些默认值可以为任何类型,比如字符串、数值、列表等等。而当它是可变类型时,怪事就会发生。

    例如下面的例子

    def append_value(value, items = []):
        items.append(value)
        return items
    
    print(append_value('foo'))
    print(append_value('bar'))
    
    # 输出结果
    ['foo']
    ['foo', 'bar']
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这样的函数看上去没有什么问题,但当你多次调用以后,就会发现函数的行为和预想的不太一样。第二次调用时,函数并没有返回正确结果[‘bar’] ,而是返回了[‘foo’, ‘bar’],这意味着参数items的值不在是函数定义的空列表[],而是第一次执行后的结果[‘foo’]

    之所以出现这个结果是因为函数的参数默认值对象只会在函数定义阶段被创建一次,如果是可变类型那么对象不会多次创建只有一个因此该对象会有多个值,如果是不可变对象例如字符串类型多次赋值都会创建多个对象,因此每次调用都是默认值。

    如果通过函数对象的保留属性__defaults__直接读取默认值。

    # 通过__defaults__属性可以直接过去该函数的参数默认值
    print(append_value.__defaults__[0])
    # 输出结果
    ['foo', 'bar']
    
    • 1
    • 2
    • 3
    • 4

    因此熟悉python的程序员通常不会将可变类型作为默认值,这是因为一旦函数在执行时修改了这个默认值,就会对之后多有函数调用产生影响。
    为了规避这个问题,使用None来替代可变类型默认值是比较常见的做法。这样修改后,假如调用方没有提供items参数,函数每次都会构建一个新列表,不会出现之前的问题。

    def append_value_v2(value, items = None):
        if items is None:
            items = []
            items.append(value)
            return items
    
    • 1
    • 2
    • 3
    • 4
    • 5
    2.定义特殊对象来区分是否提供了默认参数

    当我们为函数参数设置了默认值,不强制要求调用方提供这些参数以后,会引入另一件麻烦事,无法严格区分调用方是不是的提供了这个默认参数。

    def dump_value(value, extra=None):
        if extra is None:
            # 无法区分是否提供None 是不是主动传入。
    
    # 两种调用方式
    dump_value(value)
    dump_value(value, extra=None)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    对于dump_value() 函数来说,当调用方使用上面两种方式来调用它时,它其实无法分辨。因为在这两种情况下,函数内拿到的extra参数的值都是None

    # 定义标记变量
    # object 通常不会单独使用,但是拿它来做标记变量刚刚好
    _not_set = object()
    def dump_value(value, extra=_not_set):
        if extra is _not_set:
            # 调用方没有传递 extra 参数
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    相比None,_not_set是一个独一无二、无法随意获取的标记值。假如函数在执行时判断extra的值等于_not_set,那我们基本可以认定,调用方没有提供extra参数。

    3.定义仅限关键字参数

    函数的参数不要太多,因为参数越多,函数的调用方式就会变得越复杂,代码也会变得更难懂。

    # 定义函数参数过多
    def query_users(limit, offset, min_followers_count, include_profile):
    
    # 完全使用位置参数来调用它,会写出非常令人糊涂的代码
    query_users(20, 0, 100, True)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    如果使用关键字参数,代码就会易读许多,当你要调用参数较多的函数时,使用关键字参数模式可以大大提高代码的可读性。

    # 定义关键字参数
    query_users(limit=20, offset=0, min_followers_count=100, include_profile=True)
    
    • 1
    • 2

    虽然关键字参数调用模式很有用,但有一个 美中不足之处:他只是调用函数的一种可选方式,无法称为强制要求。不过我们可以用一种特殊的参数定义语法来弥补这个不足:

    def query_users(limt, offset, *, min_followers_count, include_profile):
    
    • 1

    通过在参数列表中插入*符号,该符号后的所有参数都变成了”仅限关键字参数(keyword-only argument)“
    如果调用方仍然想用位置参数来提供这些参数值,程序就会抛出错误。

    query_users(20, 0, 100, True)
    # 报错信息
    TypeError:query_users() takes 2 positional arguments but 4 were given
    
    • 1
    • 2
    • 3

    正确的调用方式

    query_users(20, 0, min_followers_count=100, include_profile=True)
    
    • 1

    当函数参数较多时,通过这种方式把部分参数变为仅限关键字参数,可以强制调用方提供参数名,提升代码可读性。

    2.2.函数返回的常见模式

    除了参数以外,函数还有另一个重要的组成部分:返回值。

    1.尽量只返回一种类型

    当使用方调用这个函数,如果提供了user_id,函数就会返回单个用户对象,否则函数会返回所有活跃的用户列表。虽然这个函数搞定了两种需求,但是这样的函数智慧让调用方困惑——“明明get_users()函数名字里写的是users,为什么有时候返回单个用户那”

    def get_users(user_id=None):
        if user_id is not None:
            return User.get(user_id)
        else:
            return User.filter(is_active=True)
    # 返回单个用户
    user = get_users(user_id=1)
    
    # 返回多个用户
    users = get_users()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    好的函数设计一定是简单的,这种简单体现在各个方面。返回多种类型明显违反了简单原则。像上面这样的例子,更好的做法是将它拆分为两个独立的函数。

    • get_user_by_id(user_id):返回单个用户
    • get_active_users():返回活跃的用户列表
    2.谨慎返回None值

    在编程语言的时间里,“空值” 随处可见 ,它通常用来表示某个应该存在但是缺失的东西。“空值” 在不同编程语言里有不同的名字,比如java叫做null,python称他为None。
    在python里,None是独一无二的存在,因为它有着一种独特的“虚无” 含义,所以会经常用作函数返回值。

    当我们需要让函数返回None时,主要是下面三种情况。

    • 操作类函数的默认返回值
      当操作类函数不需要任何返回值时,会返回None,None也是不带return语句函数的默认返回值
      在这种场景下返回None没有任何问题,标准库里有许多这类函数,比如os.chdir()、列表的append()方法等。
    def close_ignore_error(fp):
        try:
            # 改函数没有return语句,默认返回None
            fp.close()
        except IOError as e:
            print(e)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 意料之中的缺失值
      还有一类函数,他们天生就是做各种尝试,比如从数据库里查找一个用户,在目录中查找一个文件。条件不同,查询结果可能有结果,也可能没有结果。重点在于,对于函数的调用方来说,没有结果是意料中的事情。
      针对这类函数,使用None作为 没有结果 时返回值通常也是合理的。
      正则表达式模块re下的re.search()、re.match()函数均属于此类。

    • 在执行失败时代表错误
      有时候,None也会用作执行失败时的默认返回值。

    def create_user_from_name(username):
        # 通过用户名创建一个User实例
        if validate_username(username):
            return User.from_username(username)
        else:
            return None
        
    user = create_user_from_name(username=)
    if user is not None:
        user.do_something()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    当username通过校验时,函数会返回正常的用户对象,否则返回None
    这种做法看上去合情合理,甚至觉得和上面“意料之中的缺失值” 是一回事,但他们之间其实有着微妙的区别。区别如下

    • re.search():函数名search,代表从目标字符串里搜索匹配结果,而搜索行为一项是可能有结果,可能没有结果,当没有结果时,函数也不需要向调用方说明原因,所以它适合返回None。
    • create_user_from_name():函数名的含义是“通过名字构建用户”里面并没有一种可能没有结果的含义。而且如果创建失败,调用方大概会想知道失败原因,而不仅仅拿到一个None

    从上面的分析来看,适合返回None的函数需要满足一下两个特点:

    • 函数的名称和参数必须表达“结果可能缺失”的意思
    • 如果函数执行无法产生结果,调用方也不关系具体原因。

    所以除了 “搜索” “查询” 几个场景以外,对绝大部分函数而言,返回None并不是一个好的做法。

    对这些函数来说,用抛出异常来代替返回None会更为合理。当函数被调用时,如果无法返回正常结果,就代表出现了意料以外的状况,而意料之外 正是异常所掌管的领域。

    # 创建异常类
    class UnableToCreateUser(Exception):
        ''' 当无法创建用户时抛出'''
        
    def create_user_from_name(username):
        '''通过用户名创建一个User示例
        :raise: 当无法创建用户时抛出 UnableToCreateUser
        '''
        if validate_username(username):
            return User.from_username(username)
        else:
            raise UnableToCreateUser(f'unable to create user from {username}')
    
    try:
        user = create_user_from_name(username)
    except UnableToCreateUser:
        # 此处编写异常处理逻辑
    else:
        user.do_something()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    与返回None相比,这种方式要求调用方使用try语句来捕获可能出现的异常。虽然代码多了几行,但有一个明显优势:调用方可以从异常对象里获取错误原因——只返回一个None是做不到的。

    3.早返回,多返回

    在这段代码里,user_get_tweets函数统一在尾部用一条return语句返回,符合 单一出口 原则。这种风格的代码可读性不是很好,主要原因在于,读者在阅读函数的过程中,必须先把所有逻辑一个不落的装进脑子里,只有等到最后的return出现时,才能搞清楚所有事情。

    def user_get_tweets(user):
        '''获取用户已发布状态
        - 如果配置 展示随机状态, 获取随机状态
        - 如果配置 不展示任何状态, 返回空的占位符状态
        - 默认返回最新状态
        '''
        tweets = []
        if user.profile.show_random_tweets:
            tweets.extend(get_random_tweets(user))
        elif user.profile.hide_tweets:
            tweets.append(NULL_TWEET_PLACEHOLDER)
        else:
            tweets.extend(get_refree_tweets())
        return tweets
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    假如我在读user_get_tweets函数时,只想弄明白 “展示随机状态” 这个分支会返回什么,那当我读完第二段代码后,仍然需要继续看完剩下的代码,才能确认函数最终返回什么。
    如果我们稍微调整一下思路: 一旦函数在执行过程中满足返回结果的要求,就直接返回,代码会变成下面这样。

    def user_get_tweets(user):
        if user.profile.show_random_tweets:
            return get_random_tweets(user)
        
        if user.profile.hide_tweets:
            return [NULL_TWEET_PLACEHOLDER]
        
        return get_refree_tweets()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    在这段代码里,函数的return数量从1个变为3个,试读上面的代码是不是会发现函数的逻辑变得更容易理解了。
    产生这种变化主要原因是,对于读代码的人来说,return是一种有效的思维减负工具。当我们自上而下阅读代码时,假如遇到了return,就会清楚知道:“这条执行路线已经结束了”。这部分逻辑在大脑里占用的空间就会立刻得到释放,让我们可以专注于下一段逻辑。

    因此在编写函数时,请不要纠结函数是不是应该只有一个return,只要尽早返回结果可以提升代码可读性,那就多多返回吧。

    3.高阶函数

    Python 的 functools 模块提供了一些常用的高阶函数,也就是用于处理其它函数的特殊函数。所谓高阶函数,就是基于已有的函数定义新的函数,以函数作为输入参数,返回也是函数。
    functools是一个专门用来处理函数的内置模块,其中有十几个和函数相关的有用工具,挑选常用的两个高阶函数作为抛砖引玉,后面在实际开发中可以先到functools文档中查看可用的函数解决特殊需求。
    functools 官方文档:https://docs.python.org/zh-cn/3/library/functools.html

    3.1.partial()

    partial高阶函数作用是简化调用方调用函数时传参个数,下面通过一个例子介绍它的如何使用。

    在项目中定义了一个负责进行乘法运算的函数multiply(),调用方在使用这个函数时需要传入两个参数。

    def multiply(x, y):
        return x * y
    
    • 1
    • 2

    假如调用方每次传参第一个参数是2的次数很频繁,就像下面这样。

    result = multiply(2, value)
    val = multiply(2, number)
    
    • 1
    • 2

    那么为了简化函数调用,让代码更简洁,我们其实可以定义一个接收单个参数的double()函数,让它通过multiply()完成计算:

    def double(value):
        # 返回 multiply 函数调用结果
        return multiply(2, value)
    # 调用代码变得更简单
    result = double(value)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这是一个很常见的函数使用场景:首先有一个接收许多参数的函数a,然后额外定义一个接收更少参数的函数b,通过在b内部补充一些预设参数,最后返回调用a函数的结果。

    针对这类场景,我们其实不需要像前面一样,用def去完全定义一个新函数——直接使用functools模块提供的高阶函数partial()就行。
    partial的调用方式为partial(func, *arg, **kwargs),其中:

    • func是完成具体功能的原函数;
    • *args/**kwargs是可选位置与关键字参数,必须是原函数func所接收的合法参数。
    import functools
    # 将固定参数设置为关键字参数
    def multiply(x, y=2):
        return x * y
    
    # 只传入一个参数时,第二个参数使用multiply函数定义的y参数默认值
    double = functools.partial(multiply, 3)
    '''
    :param multiply被调用函数
    :param 3 被调函数参数
    :return 返回一个新函数
    '''
    print(f'functools.partialZ高阶函数传入一个参数输出结果:', double())
    
    # 输出结果
    functools.partialZ高阶函数传入一个参数输出结果: 6
    
    # 传入两个参数时第二个参数覆盖multiply函数定义的y参数默认值
    double = functools.partial(multiply, 1, 3)
    '''
    :param multiply被调用函数
    :param 1,3 被调函数参数
    :return 返回一个新函数
    '''
    print(f'functools.partialZ高阶函数传入两个参数输出结果:', double())
    # 输出结果
    functools.partialZ高阶函数传入两个参数输出结果: 3
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    3.2.lru_cache()

    我们的函数常常需要做一些耗时较长的操作,比如调用第三方API、进行复杂运算等。这些操作会导致函数执行速度慢,无法满足要求。为了提高效率,给这类慢函数加上缓存是比较常见的做法。
    在缓存方面,functools模块为我们提供一个开箱即用的工具:lru_cache()。使用它,你可以方便地给函数加上缓存功能,同时不用修改任何函数内部代码。假设我有一个分数统计函数caculate_score(),每次执行都要耗费一分钟以上:

    import time
    def calculate_score(class_id):
        print(f'Calculating score for class: {class_id}...')
        # 模拟此处存在一些速度很慢的统计代码……
        time.sleep(60)
        return 'success'
    
    print(calculate_score(1))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    因为caculate_score()函数执行耗时较长,而且每个class_id的统计结果都是稳定的,所以我可以直接使用lru_cache()为它加上缓存:

    import time
    from functools import lru_cache
    @lru_cache(maxsize=None)
    def calculate_score(class_id):
        print(f'Calculating score for class: {class_id}...')
        # 模拟此处存在一些速度很慢的统计代码……
        time.sleep(60)
        return 'success'
    
    print(calculate_score(1))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    加上lru_cache()后的效果如下:

    # 第一次调用,没有缓存数据需要等待60秒
    print(calculate_score(100))
    # 第二次调用,传入相同的参数,就不会触发函数内部计算逻辑,直接返回缓存结果。
    print(calculate_score(100))
    
    # 输出结果
    Calculating score for class: 100 sleep 60s...
    success
    success
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在使用lru_cache()装饰器时,可以传入一个可选的maxsize参数,该参数代表当前函数最多可以保存多少个缓存结果。当缓存的结果数量超过maxsize以后,程序就会基于“最近最少使用”(least recently used,LRU)算法丢掉旧缓存,释放内存。默认情况下,maxsize的值为128。如果你把maxsize设置为None,函数就会保存每一个执行结果,不再剔除任何旧缓存。这时如果被缓存的内容太多,就会有占用过多内存的风险。

    4.函数高级用法

    在函数式编程(functional programming)领域,可以将函数归类为两种状态,无状态函数和有状态函数。
    有一个术语纯函数(pure function)。它最大的特点是,假如输入参数相同,输出结果也一定相同,不受任何其他因素影响。换句话说,纯函数是一种无状态的函数。
    让函数保持无状态有不少好处。相比有状态函数,无状态函数的逻辑通常更容易理解。在进行并发编程时,无状态函数也有着无须处理状态相关问题的天然优势。

    def mosaic(s):
        """把输入字符串替换为等长的星号字符"""
        return '*' * len(s)
    # 调用结果如下:
    >>> mosaic('input')
    '*****'
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    但即便如此,我们的日常工作还是免不了要和“状态”打交道,下面通过几个例子介绍如何设计有状态函数。

    4.1.闭包设计状态函数

    什么是闭包
    在一个内部函数中,对外部作用域的变量进行引用,(并且一般外部函数的返回值为内部函数),那么内部函数就被认为是闭包。
    闭包作用
    简单来说,闭包是一种允许函数访问已执行完成的其他函数里的私有变量的技术,是为函数增加状态的另一种方式。闭包是一种延伸了变量作用域的函数。

    闭包使用例子
    正常情况下,当Python完成一次函数执行后,本次使用的局部变量都会在调用结束后被回收,无法继续访问。但是,如果你使用下面这种“函数套函数”的方式,在外层函数执行结束后,返回内嵌函数,后者就可以继续访问前者的局部变量,形成了一个“闭包”结构

    def counter():
        value = 0
        def _counter():
            # nonlocal 用来标注变量来自上层作用域,如不标明,内层函数将无法直接修改外层函数变量
            nonlocal value
            value += 1
            return value
        return _counter
    
    c1 = counter()
    print(c1())
    print(c1())
    
    # 调用counter返回的结果函数,可以继续访问本该被释放的value变量的值:
    # 输出结果
    1
    2
    c2 = counter()
    print(c2())
    1
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    使用闭包完成一个有状态需求
    使用星号和xx号替换文本中的数字,要求两个符号轮训替换。
    举个例子,“商店共100个苹果,小明以12元每斤的价格买走了8个”被替换后应该变成“商店共***个苹果,小明以xx元每斤的价格买走了*个”。

    def make_cyclic_mosaic():
        """
        将匹配到的模式替换为其他字符,使用闭包实现轮换字符效果
        """
        char_index = 0
        mosaic_chars = ['*', 'x']
        def _mosaic(matchobj):
            nonlocal char_index
            char = mosaic_chars[char_index]
            char_index = (char_index + 1) % len(mosaic_chars)
            length = len(matchobj.group())
            return char * length
        return _mosaic
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    >>> re.sub(r'\d+', make_cyclic_mosaic(), '商店共 100个苹果,小明以12 元每斤的价格买走了
    8 个')
    '商店共 *** 个苹果,小明以xx 元每斤的价格买走了* 个'
    >>> re.sub(r'\d+', make_cyclic_mosaic(), '商店共 100 个苹果,小明以12 元每斤的价格买走了
    8 个')
    '商店共 *** 个苹果,小明以xx 元每斤的价格买走了* 个'
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    4.2.类设计状态函数

    类(class)是面向对象编程里最基本的概念之一。在一个类中,状态和行为可以被很好地封装在一起,因此它天生适合用来实现有状态对象。通过类,我们可以生成一个个类实例,而这些实例对象的方法,可以像普通函数一样被调用。

    class CyclicMosaic:
        """使用会轮换的屏蔽字符,基于类实现"""
        _chars = ['*', 'x']
        def __init__(self):
            self._char_index = 0
        def generate(self, matchobj):
            char = self._chars[self._char_index]
            self._char_index = (self._char_index + 1) % len(self._chars)
            length = len(matchobj.group())
            return char * length
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    在调用时,需要先初始化一个CycleMosaic实例,然后使用它的generate方法:

    >>> re.sub(r'\d+', CycleMosaic().generate, '商店共 100个苹果,小明以12 元每斤的价格买走了8个')
    '商店共 *** 个苹果,小明以xx 元每斤的价格买走了* 个'
    
    • 1
    • 2

    5.函数编程建议

    5.1.别写太复杂的函数

    复杂函数的缺点
    你有没有在项目中见过那种长达几百行、逻辑错综复杂的“巨无霸”函数?那样的函数不光难读,改起来同样困难重重,人人唯恐避之不及。

    衡量函数复杂度指标
    什么样的函数才能算是过于复杂?我一般会通过两个标准来判断。

    • 长度
    • 圈复杂度

    长度
    第一个标准是长度,也就是函数有多少行代码。
    对于Python这种强表现力的语言来说,65行已经非常值得警惕了。假如你的函数超过65行,很大概率代表函数已经过于复杂,承担了太多职责,请考虑将它拆分为多个小而简单的子函数(类)吧。

    圈复杂度
    第二个标准是“圈复杂度”(cyclomatic complexity)。“圈复杂度”是由Thomas J. McCabe在1976年提出的用于评估函数复杂度的指标。它的值是一个正整数,代表程序内线性独立路径的数量。圈复杂度的值越大,表示程序可能的执行路径就越多,逻辑就越复杂。

    如果某个函数的圈复杂度超过10,就代表它已经太复杂了,代码编写者应该想办法简化。优化写法或者拆分成子函数都是不错的选择。接下来,我们通过实际代码来体验一下圈复杂度的计算过程。

    def rank(self):
        rating_num = float(self.rating)
        if rating_num >= 8.5:
            return 'S'
        elif rating_num >= 8:
            return 'A'
        elif rating_num >= 7:
            return 'B'
        elif rating_num >= 6:
            return 'C'
        else:
            return 'D'
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    可以看到,有着大段if/elif的rank()函数的圈复杂度为5,虽然这个值没有达到危险线10,但考虑到函数只有短短10行,5已经足够引起重视了。

    重构后函数的圈复杂度如下:

    def rank(self):
        breakpoints = (6, 7, 8, 8.5)
        grades = ('D', 'C', 'B', 'A', 'S')
        index = bisect.bisect(breakpoints, float(self.rating))
        return grades[index]
    
    • 1
    • 2
    • 3
    • 4
    • 5

    可以看到,新函数的圈复杂度从5降至1。1是一个非常理想化的值,如果一个函数的圈复杂度为1,就代表这个函数只有一条主路径,没有任何其他执行路径,这样的函数通常来说都十分简单、容易维护。

    5.2.一个函数只包含一层抽象

    在讨论函数设计只包含一层抽象之前,先介绍下什么是抽象与抽象级别。

    抽象
    举个例子,我吃完饭在大街上散步,走得有点儿累了,于是对自己说:“腿真疼啊,找把椅子坐吧。”此时此刻,“椅子”在我脑中就是一个抽象的概念。
    我脑中的椅子:· 有一个平坦的表面可以把屁股放上去;· 离地20到50厘米,能支撑60千克以上的重量。
    对这个抽象概念来说,路边的金属黑色长椅是我需要的椅子,饭店门口的塑料扶手椅同样也是我需要的椅子,甚至某个一尘不染的台阶也可以成为我要的“椅子”。
    所以简单来说,抽象就是将具有相同特征事物做了一个分类归纳,简化认知的手段。比如水果就是一个抽象,具体的对象可以是苹果、香蕉、桃子等等。接下来,我们看看抽象与软件开发的关系。

    抽象与软件开发
    在计算机科学领域里,人们广泛使用了抽象能力,并围绕抽象发明了许多概念和理论,而分层(分类)思想就是其中最重要的概念之一。

    什么是分层?分层就在设计一个复杂系统时,按照问题抽象程度的高低,将系统划分为不同的抽象层(abstraction layer)。低级的抽象层里包含较多的实现细节。随着层级变高,细节越来越少,越接近我们想要解决的实际问题。

    举个例子,计算机网络体系里的7层OSI模型(如图7-1所示),就应用了这种分层思想。
    在OSI模型的第一层物理层,主要关注原始字节流如何通过物理媒介传输,牵涉针脚、集线器等各种细节。而第七层应用层则更贴近终端用户,这层包含的都是我们日常用到的东西,比如浏览网页的HTTP协议、发送邮件的SMTP协议,等等。

    在这种分层结构下,每一层抽象都只依赖比它抽象级别更低的层,同时对比它抽象级别更高的层一无所知。因此,每层都可以脱离更高级别的层独立工作。比如活跃在传输层的TCP协议,可以对应用层的HTTP、HTTPS等应用协议毫无感知,独立工作。

    分层是一种特别有用的设计理念。基于分层,我们可以把复杂系统的诸多细节封装到各个独立的抽象层中,每一层只关注特定内容,复杂度得到大大降低,系统也变得更容易理解。

    因此,即便是在非常微观的层面上,比如编写一个函数时,我们同样需要考虑函数内代码与抽象级别的关系。假如一个函数内同时包含了多个抽象级别的内容,就会引发一系列的问题。

    1.如何设计函数抽象级别

    下面通过一个例子介绍如何确定函数抽象级别
    iTunes是苹果公司提供的内容商店服务,在里面可以购买世界各地的电影、音乐等数字内容。同时,iTunes还提供了一个公开的可免费调用的内容查询API。
    下面这个脚本就通过调用该API实现了查找歌手的第一张专辑的功能。

    """通过 iTunes API 搜索歌手发布的第一张专辑"""
    import sys
    from json.decoder import JSONDecodeError
    import requests
    from requests.exceptions import HTTPError
    ITUNES_API_ENDPOINT = 'https://itunes.apple.com/search'
     
     
    def command_first_album():
        """通过脚本输入查找并打印歌手的第一张专辑信息"""
        if not len(sys.argv) == 2:
            print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
            sys.exit(1)
        term = sys.argv[1]
        resp = requests.get(
            ITUNES_API_ENDPOINT,
            {
                'term': term,
                'media': 'music',
                'entity': 'album',
                'attribute': 'artistTerm',
                'limit': 200,
            },
        )
        try:
            resp.raise_for_status()
        except HTTPError as e:
            print(f'Error: failed to call iTunes API, {e}')
            # 当脚本执行异常时,应该使用非0返回码,这是编写脚本的规范之一
            sys.exit(2try:
            albums = resp.json()['results']
        except JSONDecodeError:
            print(f'Error: response is not valid JSON format')
            sys.exit(2if not albums:
            print(f'Error: no albums found for artist "{term}"')
            sys.exit(1)
        sorted_albums = sorted(albums, key=lambda item: item['releaseDate'])
        first_album = sorted_albums[0]
        # 去除发布日期里的小时与分钟信息
        release_date = first_album['releaseDate'].split('T')[0]
        # 打印结果
        print(f"{term}'s first album: ")
        print(f" * Name: {first_album['collectionName']}")
        print(f" * Genre: {first_album['primaryGenreName']}")
        print(f" * Released at: {release_date}")
     
     
    if __name__== '__main__':
        command_first_album()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    测试脚本执行结果

    # 没有提供参数时,打印错误信息并返回
    > python first_album.py
    usage: python first_album.py {SEARCH_TERM}
    # 执行正常,打印专辑信息(《Hybrid Theory》超好听!)
    > python first_album.py "linkin park"
    linkin park's first album:
      * Name: Hybrid Theory
      * Genre: Hard Rock
      * Released at: 2000-10-24
    # 输入参数没有匹配到任何专辑,打印错误信息
    > python first_album.py "calfoewf#@#FE"
    Error: no albums found for artist "calfoewf#@#FE"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    脚本抽象级别分析

    这个脚本实现了我们想要的效果,那么它的代码质量怎么样呢?我们从长度、圈复杂度、嵌套层级几个维度来看看:
    (1)主函数command_first_album()共40行代码;
    (2)函数圈复杂度为5;
    (3)函数内最大嵌套层级为1。
    看上去每个维度都在合理范围内,没有什么问题。但是,除了上面这些维度外,评价函数好坏还有一个重要标准:函数内的代码是否在同一个抽象层内。

    上面脚本的主函数command_first_album()显然不符合这个标准。在函数内部,不同抽象级别的代码随意混合在了一起。比如,当请求API失败时(数据层),函数直接调用sys.exit()中断了程序执行(用户界面层)。

    这种抽象级别上的混乱,最终导致了下面两个问题。

    • 函数代码的说明性不够:如果只是简单读一遍command_first_album(),很难搞清楚它的主流程是什么,因为里面的代码五花八门,什么层次的信息都有。
    • 函数的可复用性差:假如现在要开发新需求——查询歌手的所有专辑,你无法复用已有函数的任何代码。

    所以,如果缺乏设计,哪怕是一个只有40行代码的简单函数,内部也很容易产生抽象混乱问题。要优化这个函数,我们需要重新梳理程序的抽象级别。在我看来,这个程序至少可以分为以下三层。
    (1)用户界面层:处理用户输入、输出结果。
    (2) “第一张专辑”层:找到第一张专辑。
    (3)专辑数据层:调用API获取专辑信息。

    基于这样的层级设计,我们可以对原始函数进行拆分。
    基于抽象层重构代码

    """ 通过 iTunes API 搜索歌手发布的第一张专辑"""
    import sys
    from json.decoder import JSONDecodeError
    import requests
    from requests.exceptions import HTTPError
    ITUNES_API_ENDPOINT = 'https://itunes.apple.com/search'
     
     
    class GetFirstAlbumError(Exception):
        """ 获取第一张专辑失败"""
    class QueryAlbumsError(Exception):
        """获取专辑列表失败"""
     
     
    def command_first_album():
        """通过输入参数查找并打印歌手的第一张专辑信息"""
        if not len(sys.argv) == 2:
            print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
            sys.exit(1)
        artist = sys.argv[1]
        try:
            album = get_first_album(artist)
        except GetFirstAlbumError as e:
            print(f"error: {e}", file=sys.stderr)
            sys.exit(2print(f"{artist}'s first album: ")
        print(f" * Name: {album['name']}")
        print(f" * Genre: {album['genre_name']}")
        print(f" * Released at: {album['release_date']}")
     
     
    def get_first_album(artist):
        """根据专辑列表获取第一张专辑
        :param artist: 歌手名字
        :return: 第一张专辑
        :raises: 获取失败时抛出 GetFirstAlbumError
        """
        try:
            albums = query_all_albums(artist)
        except QueryAlbumsError as e:
            raise GetFirstAlbumError(str(e))
        sorted_albums = sorted(albums, key=lambda item: item['releaseDate'])
        first_album = sorted_albums[0]
        # 去除发布日期里的小时与分钟信息
        release_date = first_album['releaseDate'].split('T')[0]
        return {
            'name': first_album['collectionName'],
            'genre_name': first_album['primaryGenreName'],
            'release_date': release_date,
        }
     
     
    def query_all_albums(artist):
        """根据歌手名字搜索所有专辑列表
        :param artist: 歌手名字
        :return: 专辑列表,List[Dict]
        :raises: 获取专辑失败时抛出 GetAlbumsError
        """
        resp = requests.get(
            ITUNES_API_ENDPOINT,
            {
                'term': artist,
                'media': 'music',
                'entity': 'album',
                'attribute': 'artistTerm',
                'limit': 200,
            },
        )
        try:
            resp.raise_for_status()
        except HTTPError as e:
            raise QueryAlbumsError(f'failed to call iTunes API, {e}')
        try:
            albums = resp.json()['results']
        except JSONDecodeError:
            raise QueryAlbumsError('response is not valid JSON format')
        if not albums:
            raise QueryAlbumsError(f'no albums found for artist "{artist}"')
        return albums
    if __name__== '__main__':
        command_first_album()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81

    在新代码中,旧的主函数被拆分成了三个不同的函数。

    • command_first_album():程序主入口,对应用户界面层。
    • get_first_album():获取第一张专辑,对应“第一张专辑”层。
    • query_all_albums():调用API获取数据,对应专辑数据层。

    经过调整后,脚本里每个函数内的所有代码都只属于同一个抽象层。这大大提升了函数代码的说明性。现在,当你在阅读每个函数时,可以很清晰地知道它在做什么事情。

    在设计函数时,请时常记得检查函数内代码是否在同一个抽象级别,如果不是,那就需要把函数拆成更多小函数。只有保证抽象级别一致,函数的职责才更简单,代码才更易读、更易维护。

    5.3.优先使用列表推导式

    在大多数情况下,相比函数式编程,使用列表推导式的代码通常更短,而且描述性更强。所以,当列表推导式可以满足需求时,请优先使用它吧。

    举个例子,假如你想获取所有处于活跃状态的用户积分,代码可以这么写:

    points = [query_points(user) for user in users if user.is_active()]
    
    • 1

    5.4.你没有那么需要lambda

    Python中有一类特殊的函数:匿名函数。你可以用lambda关键字来快速定义一个匿名函数,比如lambda x, y: x + y。匿名函数最常见的用途就是作为sorted()函数的排序参数使用。

    >>> l = ['87', '3', '10']
    
    # 转换为整数后排序
    >>> sorted(l, key=lambda n: int(n))
    ['3', '10', '87']
    
    • 1
    • 2
    • 3
    • 4
    • 5

    仔细观察上面的代码,你能发现问题在哪吗?在这段代码里,为了排序,我们定义了一个lambda函数,但这个函数其实什么都没干,只是把调用透传给int()而已。所以,上面代码里的匿名函数完全是多余的,可以直接去掉:

    >>> sorted(l, key=int)
    ['3', '10', '87']
    
    • 1
    • 2

    5.5.了解递归的局限性

    递归(recursion)是指函数在执行时依赖调用自身来完成工作,是一种非常有用的编程技巧。在实现一些特定算法时,使用递归的代码更符合人们的思维习惯,有着天然的优势。

    比如,下面计算斐波那契数列(Fibonacci sequence)的函数就非常容易理解:

    def fib(n):
        if n < 2:
            return n
        return fib(n - 1) + fib(n - 2)
    
    • 1
    • 2
    • 3
    • 4

    虽然上面的函数代码很直观,但用起来有一些限制。比如当需要计算的数字很大时,上面的fib(n)函数在执行时会形成一个非常深的嵌套调用栈,当它的深度超过一定限制后,函数就会抛出RecursionError异常:

    >>> fib(1000)
    Traceback (most recent call last):
      ...
      [Previous line repeated 995 more times]
      File "fib.py", line 2, in fib
        if n < 2:
    RecursionError: maximum recursion depth exceeded in comparison
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这个最大递归深度限制由Python在语言层面上设置,你可以通过下面的命令查看和修改这个限制

    >>> import sys
    >>> sys.getrecursionlimit()
    1000
    # 你也可以手动把限制修改成10 000层,但我们一般不这么做
    >>> sys.setrecursionlimit(10000)
    
    • 1
    • 2
    • 3
    • 4
    • 5

    假如递归确实能带来许多方便,当你决意要使用它时,请务必注意不要超过最大递归深度限制。

  • 相关阅读:
    【Kafka】MirrorMaker 一次错误的配置引发的血案
    VI 使用技巧
    深入解析MySQL死锁:原因、检测与解决方案
    c++新特性 智能指针和内存管理
    跨域访问错误的这一种解决方法
    使用layui框架实战之栅格系统和菜单评分组件运用心得
    【Java探索之旅】运算符解析 算术运算符,关系运算符
    npm——整理前端包管理工具(cnpm、yarn、pnpm)
    致1024程序员节--多年前,我用代码赚到的第一桶金
    两万字带你认识黑客在kali中使用的工具
  • 原文地址:https://blog.csdn.net/m0_38039437/article/details/126896487