• 鹅厂地道循环学习


    1、enumerate内置函数

    如果你去问一位刚学习 Python 一个月的人:“如何在遍历一个列表的同时获取当前下标?”。他可能会交出这样的代码:

    index = 0
    for name in names:
        print(index, name)
        index += 1
    

    上面的循环虽然没错,但它确一点都不“地道”。一个拥有三年 Python 开发经验的人会说,代码应该这么写:

    for i, name in enumerate(names):
        print(i, name)
    
    • enumerate() 是Python的一个内置函数,它接收一个“可迭代”对象作为参数,然后返回一个不断生成(当前下标,当前元素)的新可迭代对象。这个场景使用它最适合不过。
    • enumerate() 所代表的编程思路
      • 如你所见,Python的for循环只有for<item>in<iterable> 这一种结构,而结构里的前半部分 - 赋值给 item-没有太多花样可玩。所以后半部分的可迭代对象是我们唯一能够大做文章的东西。而以 enumerate()函数为代表的“修饰函数”,刚好提供了一种思路:通过修饰可迭代对象来优化循环本身。
    • 建议1:使用函数修饰被迭代对象来优化循环

    2、itertools模块

    • 使用修饰函数处理可迭代对象,可以在各种方面影响循环代码。而要找到合适的例子来演示这个方法,并不用去太远,内置模块 itertools 就是一个绝佳的例子。
    • 简单来说,itertools是一个包含很多面向可迭代对象的工具函数集。
    1. 使用 product 扁平化多层嵌套循环

    虽然我们都知道“扁平的代码比嵌套的好”。但有时针对某类需求,似乎一定得写多层嵌套循环才行。比如下面这段:

    def find_twelve(num_list1, num_list2, num_list3):
        """从 3 个数字列表中,寻找是否存在和为 12 的 3 个数
        """
        for num1 in num_list1:
            for num2 in num_list2:
                for num3 in num_list3:
                    if num1 + num2 + num3 == 12:
                        return num1, num2, num3
    

    对于这种需要嵌套遍历多个对象的多层循环代码,我们可以使用 product() 函数来优化它。product() 可以接收多个可迭代对象,然后根据它们的笛卡尔积不断生成结果。

    from itertools import product
    
    def find_twelve_v2(num_list1, num_list2, num_list3):
        for num1, num2, num3 in product(num_list1, num_list2, num_list3):
            if num1 + num2 + num3 == 12:
                return num1, num2, num3
    

    相比之前的代码,使用 product() 的函数只用了一层 for 循环就完成了任务,代码变得更精炼了。

    2. 使用 islice 实现循环内隔行处理

    有一份包含 Reddit 帖子标题的外部数据文件,里面的内容格式是这样的:

    python-guide: Python best practices guidebook, written for humans.
    ---
    Python 2 Death Clock
    ---
    Run any Python Script with an Alexa Voice Command
    ---
    <... ...>
    
    • 可能是为了美观,在这份文件里的每两个标题之间,都有一个 "---"分隔符。现在,我们需要获取文件里所有的标题列表,所以在遍历文件内容的过程中,必须跳过这些无意义的分隔符。
    • 参考之前对 enumerate()函数的了解,我们可以通过在循环内加一段基于当前循环序号的 if 判断来做到这一点:
    def parse_titles(filename):
        """从隔行数据文件中读取 reddit 主题名称
        """
        with open(filename, 'r') as fp:
            for i, line in enumerate(fp):
                # 跳过无意义的 '---' 分隔符
                if i % 2 == 0:
                    yield line.strip()
    
    • 但对于这类在循环内进行隔行处理的需求来说,如果使用 itertools里的islice()函数修饰被循环对象,可以让循环体代码变得更简单直接。
    • islice(seq,start,end,step) 函数和数组切片操作( list[start:stop:step])有着几乎一模一样的参数。如果需要在循环内部进行隔行处理的话,只要设置第三个递进步长参数 step 值为 2 即可(默认为 1)。
    from itertools import islice
    
    def parse_titles_v2(filename):
        with open(filename, 'r') as fp:
            # 设置 step=2,跳过无意义的 '---' 分隔符
            for line in islice(fp, 0, None, 2):
                yield line.strip()
    
    3. 使用 takewhile 替代 break 语句

    有时,我们需要在每次循环开始时,判断循环是否需要提前结束。比如下面这样:

    for user in users:
        # 当第一个不合格的用户出现后,不再进行后面的处理
        if not is_qualified(user):
            break
    
        # 进行处理 ... ...
    

    对于这类需要提前中断的循环,我们可以使用 takewhile() 函数来简化它。takewhile(predicate,iterable)会在迭代 iterable 的过程中不断使用当前对象作为参数调用 predicate 函数并测试返回结果,如果函数返回值为真,则生成当前对象,循环继续。否则立即中断当前循环。

    from itertools import takewhile
    
    for user in takewhile(is_qualified, users):
        # 进行处理 ... ...
    
    • itertools 里面还有一些其他有意思的工具函数,他们都可以用来和循环搭配使用,比如使用chain函数扁平化双层嵌套循环、使用zip_longest函数一次同时循环多个对象等等。
    • 篇幅有限,我在这里不再一一介绍。如果有兴趣,可以自行去官方文档详细了解。
    4. 使用生成器编写自己的修饰函数

    除了 itertools 提供的那些函数外,我们还可以非常方便的使用生成器来定义自己的循环修饰函数。

    def sum_even_only(numbers):
        """对 numbers 里面所有的偶数求和"""
        result = 0
        for num in numbers:
            if num % 2 == 0:
                result += num
        return result
    

    在上面的函数里,循环体内为了过滤掉所有奇数,引入了一条额外的 if判断语句。如果要简化循环体内容,我们可以定义一个生成器函数来专门进行偶数过滤:

    def even_only(numbers):
        for num in numbers:
            if num % 2 == 0:
                yield num
    
    def sum_even_only_v2(numbers):
        """对 numbers 里面所有的偶数求和"""
        result = 0
        for num in even_only(numbers):
            result += num
        return result
    
    • 将 numbers 变量使用 even_only 函数装饰后, sum_even_only_v2函数内部便不用继续关注“偶数过滤”逻辑了,只需要简单完成求和即可。

    • 暗示:当然,上面的这个函数其实并不实用。在现实世界里,这种简单需求最适合直接用生成器或列表表达式搞定:sum(num for numinnumbers if num%2==0)

    • 建议2:按职责拆解循环体内复杂代码块

    让我们来看看下面这个业务场景。在网站中,有一个每 30 天执行一次的周期脚本,它的任务是是查询过去 30 天内,在每周末特定时间段登录过的用户,然后为其发送奖励积分。

    import time
    import datetime
    
    def award_active_users_in_last_30days():
        """获取所有在过去 30 天周末晚上 8 点到 10 点登录过的用户,为其发送奖励积分
        """
        days = 30
        for days_delta in range(days):
            dt = datetime.date.today() - datetime.timedelta(days=days_delta)
            # 5: Saturday, 6: Sunday
            if dt.weekday() not in (5, 6):
                continue
    
            time_start = datetime.datetime(dt.year, dt.month, dt.day, 20, 0)
            time_end = datetime.datetime(dt.year, dt.month, dt.day, 23, 0)
    
            # 转换为 unix 时间戳,之后的 ORM 查询需要
            ts_start = time.mktime(time_start.timetuple())
            ts_end = time.mktime(time_end.timetuple())
    
            # 查询用户并挨个发送 1000 奖励积分
            for record in LoginRecord.filter_by_range(ts_start, ts_end):
                # 这里可以添加复杂逻辑
                send_awarding_points(record.user_id, 1000)
    
    • 上面这个函数主要由两层循环构成。外层循环的职责,主要是获取过去30天内符合要求的时间,并将其转换为 UNIX 时间戳。之后由内层循环使用这两个时间戳进行积分发送。
    • 通过观察后,我们可以发现整个循环体其实是由两个完全无关的任务构成的:“挑选日期与准备时间戳” 以及 “发送奖励积分”。
    5.复杂循环体如何应对新需求
    • 这样的代码有什么坏处呢?让我来告诉你。
    • 某日,产品找过来说,有一些用户周末半夜不睡觉,还在刷我们的网站,我们得给他们发通知让他们以后早点睡觉。于是新需求出现了:“给过去30天内在周末凌晨 3 点到 5 点登录过的用户发送一条通知”。
    • 新问题也随之而来。敏锐如你,肯定一眼可以发现,这个新需求在用户筛选部分的要求,和之前的需求非常非常相似。但是,如果你再打开之前那团循环体看看,你会发现代码根本没法复用,因为在循环内部,不同的逻辑完全被耦合在一起了。☹️
    • 在计算机的世界里,我们经常用“耦合”这个词来表示事物之间的关联关系。上面的例子中,“挑选时间”和“发送积分”这两件事情身处同一个循环体内,建立了非常强的耦合关系。
    • 为了更好的进行代码复用,我们需要把函数里的“挑选时间”部分从循环体中解耦出来。而我们的老朋友,“生成器函数”是进行这项工作的不二之选。
    6.使用生成器函数解耦循环体
    • 要把 “挑选时间”部分从循环内解耦出来,我们需要定义新的生成器函数gen_weekend_ts_ranges(),专门用来生成需要的 UNIX 时间戳:
    def gen_weekend_ts_ranges(days_ago, hour_start, hour_end):
        """生成过去一段时间内周六日特定时间段范围,并以 UNIX 时间戳返回
        """
        for days_delta in range(days_ago):
            dt = datetime.date.today() - datetime.timedelta(days=days_delta)
            # 5: Saturday, 6: Sunday
            if dt.weekday() not in (5, 6):
                continue
    
            time_start = datetime.datetime(dt.year, dt.month, dt.day, hour_start, 0)
            time_end = datetime.datetime(dt.year, dt.month, dt.day, hour_end, 0)
    
            # 转换为 unix 时间戳,之后的 ORM 查询需要
            ts_start = time.mktime(time_start.timetuple())
            ts_end = time.mktime(time_end.timetuple())
            yield ts_start, ts_end
    

    有了这个生成器函数后,旧需求“发送奖励积分”和新需求“发送通知”,就都可以在循环体内复用它来完成任务了:

    def award_active_users_in_last_30days_v2():
        """发送奖励积分"""
        for ts_start, ts_end in gen_weekend_ts_ranges(30, hour_start=20, hour_end=23):
            for record in LoginRecord.filter_by_range(ts_start, ts_end):
                send_awarding_points(record.user_id, 1000)
    
    def notify_nonsleep_users_in_last_30days():
        """发送通知"""
        for ts_start, ts_end in gen_weekend_ts_range(30, hour_start=3, hour_end=6):
            for record in LoginRecord.filter_by_range(ts_start, ts_end):
                notify_user(record.user_id, 'You should sleep more')
    

    总结

    • 第一个建议:使用修饰函数来改善循环。之后我虚拟了一个业务场景,描述了按职责拆解循环内代码的重要性。

    一些要点总结:

    • 使用函数修饰被循环对象本身,可以改善循环体内的代码
    • itertools 里面有很多工具函数都可以用来改善循环
    • 使用生成器函数可以轻松定义自己的修饰函数
    • 循环内部,是一个极易发生“代码膨胀”的场地
    • 请使用生成器函数将循环内不同职责的代码块解耦出来,获得更好的灵活性

    转自:微信订阅号--Python大本营

  • 相关阅读:
    Python之初识模块之序列化模块
    Python之初识模块二
    Python之初识模块
    Python之re模块
    python随笔来源
    Python初识模块之正则表达式
    Python之初识递归
    0.U-boot的简介
    2.11.移植uboot
    2.18.7.VFS简介
  • 原文地址:https://www.cnblogs.com/jiangmingbai/p/10997357.html
Copyright © 2020-2023  润新知