V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐学习书目
Learn Python the Hard Way
Python Sites
PyPI - Python Package Index
http://diveintopython.org/toc/index.html
Pocoo
值得关注的项目
PyPy
Celery
Jinja2
Read the Docs
gevent
pyenv
virtualenv
Stackless Python
Beautiful Soup
结巴中文分词
Green Unicorn
Sentry
Shovel
Pyflakes
pytest
Python 编程
pep8 Checker
Styles
PEP 8
Google Python Style Guide
Code Style from The Hitchhiker's Guide
piglei
V2EX  ›  Python

Python 工匠:编写地道循环的两个建议

  •  
  •   piglei ·
    piglei · 2019-04-27 18:30:36 +08:00 · 4674 次点击
    这是一个创建于 2039 天前的主题,其中的信息可能已经有所发展或是发生改变。

    不知不觉,已经把自己开的坑《 Python 工匠系列》更新到第 7 篇了。回想在 V2EX 上面发第 1 篇文章,已经是快三年以前的事情了。再次感谢当时在第 1 篇文章下面给过意见和鼓励的朋友们。

    系列会继续更新,欢迎通过下面的索引地址关注我或者订阅我的博客。

    谢谢。


    所有文章链接:


    前言

    这是 “ Python 工匠”系列的第 7 篇文章。[查看系列所有文章]

    循环是一种常用的程序控制结构。我们常说,机器相比人类的最大优点之一,就是机器可以不眠不休的重复做某件事情,但人却不行。而**“循环”**,则是实现让机器不断重复工作的关键概念。

    在循环语法方面,Python 表现的即传统又不传统。它虽然抛弃了常见的 for (init; condition; incrment) 三段式结构,但还是选择了 forwhile 这两个经典的关键字来表达循环。绝大多数情况下,我们的循环需求都可以用 for <item> in <iterable> 来满足,while <condition> 相比之下用的则更少些。

    虽然循环的语法很简单,但是要写好它确并不容易。在这篇文章里,我们将探讨什么是“地道”的循环代码,以及如何编写它们。

    什么是“地道”的循环?

    “地道”这个词,通常被用来形容某人做某件事情时,非常符合当地传统,做的非常好。打个比方,你去参加一个朋友聚会,同桌的有一位广东人,对方一开口,句句都是标准京腔、完美儿化音。那你可以对她说:“您的北京话说的真地道”。

    既然“地道”这个词形容的经常是口音、做菜的口味这类实实在在的东西,那“地道”的循环代码又是什么意思呢?让我拿一个经典的例子来解释一下。

    如果你去问一位刚学习 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:使用函数修饰被迭代对象来优化循环

    使用修饰函数处理可迭代对象,可以在各种方面影响循环代码。而要找到合适的例子来演示这个方法,并不用去太远,内置模块 itertools 就是一个绝佳的例子。

    简单来说,itertools 是一个包含很多面向可迭代对象的工具函数集。我在之前的系列文章《容器的门道》里提到过它。

    如果要学习 itertools,那么 Python 官方文档 是你的首选,里面有非常详细的模块相关资料。但在这篇文章里,侧重点将和官方文档稍有不同。我会通过一些常见的代码场景,来详细解释它是如何改善循环代码的。

    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 函数并测试返回结果,如果函数返回值为真,则生成当前对象,循环继续。否则立即中断当前循环。

    使用 takewhile 的代码样例:

    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 函数内部便不用继续关注“偶数过滤”逻辑了,只需要简单完成求和即可。

    Hint:当然,上面的这个函数其实并不实用。在现实世界里,这种简单需求最适合直接用生成器 /列表表达式搞定:sum(num for num in numbers 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 时间戳。之后由内层循环使用这两个时间戳进行积分发送。

    如之前所说,外层循环所开辟的黑魔法阵内被塞的满满当当。但通过观察后,我们可以发现 整个循环体其实是由两个完全无关的任务构成的:“挑选日期与准备时间戳” 以及 “发送奖励积分”

    复杂循环体如何应对新需求

    这样的代码有什么坏处呢?让我来告诉你。

    某日,产品找过来说,有一些用户周末半夜不睡觉,还在刷我们的网站,我们得给他们发通知让他们以后早点睡觉。于是新需求出现了:“给过去 30 天内在周末凌晨 3 点到 5 点登录过的用户发送一条通知”

    新问题也随之而来。敏锐如你,肯定一眼可以发现,这个新需求在用户筛选部分的要求,和之前的需求非常非常相似。但是,如果你再打开之前那团循环体看看,你会发现代码根本没法复用,因为在循环内部,不同的逻辑完全被 耦合 在一起了。☹️

    在计算机的世界里,我们经常用**“耦合”**这个词来表示事物之间的关联关系。上面的例子中,*“挑选时间”“发送积分”*这两件事情身处同一个循环体内,建立了非常强的耦合关系。

    为了更好的进行代码复用,我们需要把函数里的*“挑选时间”*部分从循环体中解耦出来。而我们的老朋友,**“生成器函数”**是进行这项工作的不二之选。

    使用生成器函数解耦循环体

    要把 “挑选时间” 部分从循环内解耦出来,我们需要定义新的生成器函数 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 里面有很多工具函数都可以用来改善循环
    • 使用生成器函数可以轻松定义自己的修饰函数
    • 循环内部,是一个极易发生“代码膨胀”的场地
    • 请使用生成器函数将循环内不同职责的代码块解耦出来,获得更好的灵活性

    看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。

    附录

    29 条回复    2019-04-30 16:10:35 +08:00
    ksedz
        1
    ksedz  
       2019-04-27 20:15:43 +08:00
    支持一下
    yushi17
        2
    yushi17  
       2019-04-27 20:45:16 +08:00 via Android
    一直想找一个类似 product 的东西 终于看到了!!
    whoisghost
        3
    whoisghost  
       2019-04-27 20:47:55 +08:00   ❤️ 1
    以后谁再说 Python 代码清晰易读好学,我会一巴掌抽过去,全都是黑魔法。
    Vegetable
        4
    Vegetable  
       2019-04-27 20:54:27 +08:00
    资瓷
    congeec
        5
    congeec  
       2019-04-27 21:17:39 +08:00 via iPhone
    我又要来吹一波 Haskell 了。python itertools 模块里的东西不少是从 Haskell 抄的。入门 Haskell 后再用 python itertools 爽的一笔。

    不过话说回来 python 的 generator comprehension 抄的相当有水平
    Hopetree
        6
    Hopetree  
       2019-04-27 21:35:15 +08:00   ❤️ 1
    我知道 itertools 很强,有很多函数可以减少日常的一些操作,特别是各种迭代,但是我总是记不住这些函数,真的烦,看的时候总是觉得好有用,但是实际上自己总是忘记用了,所以久而久之,就忘记怎么用了。。。。。。。。。。。
    Northxw
        7
    Northxw  
       2019-04-27 21:46:07 +08:00
    马克
    piglei
        8
    piglei  
    OP
       2019-04-27 22:00:14 +08:00
    @whoisghost 淡定,都没出现什么双下划线方法和描述符之类的,还远远算不上什么黑魔法。😂

    @congeec 是的,itertools 官方文档第一句话就是:

    > This module implements a number of iterator building blocks inspired by constructs from APL, Haskell, and SML. Each has been recast in a form suitable for Python.

    函数式编程在一些特定场景下用起来确实爽,一点都不拖泥带水。

    @Hopetree 建议你详细的理解每一个函数的名字,itertools 下的每个函数名字含义非常清楚的,比如 dropwhile、takewhile。这样可以加强记忆。
    chinesehuazhou
        9
    chinesehuazhou  
       2019-04-27 22:01:47 +08:00 via Android
    前排撒花
    WhoMercy
        10
    WhoMercy  
       2019-04-27 22:09:43 +08:00 via Android
    很赞,py 新手要向编写出更加 pythonic 的代码方向努力👍
    piglei
        11
    piglei  
    OP
       2019-04-27 22:17:42 +08:00
    @chinesehuazhou 感谢 🍺

    @WhoMercy 谢谢支持,加油。
    infun
        12
    infun  
       2019-04-27 22:23:04 +08:00 via iPhone
    @Hopetree 我也是,还以为是因为我笨才这样
    onlyice
        13
    onlyice  
       2019-04-27 22:57:08 +08:00 via Android
    支持,讲得好懂,例子也很赞
    chengxiao
        14
    chengxiao  
       2019-04-28 03:23:50 +08:00 via iPhone
    干货支持
    hp66722667
        15
    hp66722667  
       2019-04-28 09:03:52 +08:00   ❤️ 1
    python 看似简单,其实反倒比静态语言要更复杂,自己写着 high 了,其他人看起来可就头疼了,如果单纯把语言作为一种生产工具,功能越单一才更适合当今的发展。换句话说,中国这么大各地都有地方口音,如果没有普通话真心不敢想象
    Eds1995
        16
    Eds1995  
       2019-04-28 09:16:43 +08:00
    @congeec 不怕被人砍?推荐人学 Haskell🌺🐔
    vipppppp
        17
    vipppppp  
       2019-04-28 09:20:42 +08:00
    作为一个 pythoner,真的想说,python 很多库的源码真的很难阅读,因为黑魔法真的太多。。。
    zeromake
        18
    zeromake  
       2019-04-28 09:24:30 +08:00
    go 才是真的什么魔法都木有
    claymore94
        19
    claymore94  
       2019-04-28 09:56:36 +08:00
    get ,谢谢分享
    piglei
        20
    piglei  
    OP
       2019-04-28 10:29:15 +08:00
    @hp66722667 是的,go 比较符合你说的简单哲学。

    但编程语言这东西,究其本质还是体现语言创始人个人经历和语言审美的“作品”。所以它必定是百花齐放、各有千秋的。但如你所说,Python 语言现存的问题之一,确实就是太复杂了。各种可能不该暴露出来的语言内部细节、不断增加的新语法,搞得全世界没有任何一个人可以说自己了解“全部”的 Python。

    但我觉得,如果要用 Python 开发一些比较严肃的项目,一些核心的语言特性和思想还是必须要了解的,比如生成器、描述符等等。不然的话,就像我在文章里举的那个例子一样,程序员只是以套用一种编程语言的经验在写 Python。而非如《代码大全》里所说的真正 **深入一门语言编程** 。

    @vipppppp 虽然不知道你说的是什么库,但是我觉得 Python 里面真正称得上“黑魔法”的特性其实还是偏少的。当然,如果以其他语言,比如 Go 来作为标准的话,那 Python 确实遍地都是黑魔法了。😂
    JStatham
        21
    JStatham  
       2019-04-28 11:53:41 +08:00
    牛逼, 后悔没有早点看到这么优秀的文章
    nuance2ex
        22
    nuance2ex  
       2019-04-28 17:08:18 +08:00 via iPhone
    精彩,精彩。开阔思路,通俗易懂。
    bwangel
        23
    bwangel  
       2019-04-28 17:31:12 +08:00
    感觉 Python 和 Go 就是两个极端。

    Python 是什么操作都能自定义,运算符重载,__geattr__, __getitem__ property.setter, property.getter, __iter__ 等,更别说还可以用 type 动态创建类,用 Metaclass 修改类的创建过程

    而 Go 是啥语法都不让自定义,连个最基本的运算符重载都没有,Add 方法还要写上好几个 IntAdd, FloatAdd。
    piglei
        24
    piglei  
    OP
       2019-04-28 17:46:05 +08:00
    @bwangel 是的,所以你可以说 Python “优雅”,也可以说 Python “乱七八糟”。同样也可以说 Go “务实稳重”,或者可以说它 “死板不懂得变通”。
    bwangel
        25
    bwangel  
       2019-04-28 17:54:53 +08:00
    @ #22 举一个标准库的例子

    https://golang.org/src/sync/atomic/doc.go

    因为没有函数重载,出现了这样的写法。

    @piglei 现在越来越喜欢 Go 了。

    曾经遇到过一种写法,类的定义是空的,然后 setattr 往里面塞。然后光看代码很难知道一个对象中有哪些属性,必须要跑起来,通过 vars 才能看出到底有哪些属性。

    感觉宁愿多写一些,也不愿意搞这些黑魔法了。
    piglei
        26
    piglei  
    OP
       2019-04-28 18:03:46 +08:00
    @bwangel 你提的这个 setattr 例子可以算是极大的反模式。是的,我的体会是越灵活和复杂的语言,对团队在该语言上的整体经验要求会比其他死板一点的语言更严格,也对团队配套行为,比如 Code Review、知识共享等提出了更高的要求。

    "Write Less, Do More" 不是没有代价的,一切早已在暗中标好了价格。😂
    xpresslink
        27
    xpresslink  
       2019-04-28 22:48:44 +08:00
    [enumerate() 是 Python 的一个内置函数] 这句话是错误的。在 python 里面千万不能见到用括号调用的东西就认为是函数。还有一种是对象实例化。
    piglei
        28
    piglei  
    OP
       2019-04-29 10:30:07 +08:00
    @xpresslink 特意查了一下 enumerate 的实现,发现简单称之为“函数”确实不严谨,它更像是一个“工厂类”。

    但我觉得这属于语言内部的细节,不应该作为额外阅读成本引入到文章中。所以,我不准备把它作为一个“错误”修复,仍然保持“内置函数”这个比较容易理解的表达。感谢提醒。
    bwangel
        29
    bwangel  
       2019-04-30 16:10:35 +08:00
    啊,刚刚又遇到一个坑,忍不住过来吐槽一下。

    https://gist.github.com/bwangelme/4002260ea023736eb7e51bb0a53a23e0

    上面的代码是大概的伪代码。`update_id_time ` 函数正常是能够成功更新 ID 的 time 属性的,但是如果把 `update_id_time` 变成一个异步任务,程序就失败了。

    因为在执行 `self.props['time'] = datetime.datetime.now().strftime('%Y/%m/%d')` 我们相当于创建了一个临时变量,props,然后对这个临时变量做了一些修改。

    但是在整个代码放到队列中后,队列消费者 unpickle 代码的时候,`self.props`不会使用我们这个临时变量,而是直接从`props_getter`中获取。这样我们的修改就丢失了。

    感觉打造一支高水平的 Python 开发团队难度太大了,比 Java 要难很多。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2834 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 30ms · UTC 15:06 · PVG 23:06 · LAX 07:06 · JFK 10:06
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.