原做者:pigleihtml
原文连接:www.zlovezl.cn/articles/ti…python
我一直以为编程某种意义上是一门『手艺』,由于优雅而高效的代码,就如同完美的手工艺品同样让人赏心悦目。git
在雕琢代码的过程当中,有大工程:好比应该用什么架构、哪一种设计模式。也有更多的小细节,好比什么时候使用异常(Exceptions)、或怎么给变量起名。那些真正优秀的代码,正是由无数优秀的细节造就的。程序员
『Python 工匠』这个系列文章,是个人一次小小尝试。它专一于分享 Python 编程中的一些偏 『小』 的东西。但愿可以帮到每一位编程路上的匠人。github
系列其余文章:sql
数字是几乎全部编程语言里最基本的数据类型,它是咱们经过代码链接现实世界的基础。在 Python 里有三种数值类型:整型(int)、浮点型(float)和复数(complex)。绝大多数状况下,咱们只须要和前两种打交道。数据库
整型在 Python 中比较让人省心,由于它不区分有无符号而且永不溢出。但浮点型仍和绝大多数其余编程语言同样,依然有着精度问题,常常让不少刚进入编程世界大门的新人们感到困惑:"Why Are Floating Point Numbers Inaccurate?"。编程
相比数字,Python 里的字符串要复杂的多。要掌握它,你得先弄清楚 bytes 和 str 的区别。若是更不巧,你仍是位 Python2 用户的话,光 unicode 和字符编码问题就够你喝上好几壶了*(赶快迁移到 Python3 吧,就在今天!)*。设计模式
不过,上面提到的这些都不是这篇文章的主题,若是感兴趣,你能够在网上找到成堆的相关资料。在这篇文章里,咱们将讨论一些 更细微、更不常见 的编程实践。来帮助你写出更好的 Python 代码。安全
“数字字面量(integer literal)” 是指那些直接出如今代码里的数字。它们分布在代码里的各个角落,好比代码 del users[0]
里的 0
就是一个数字字面量。它们简单、实用,每一个人天天都在写。可是,当你的代码里不断重复出现一些特定字面量时,你的“代码质量告警灯”就应该亮起黄灯 🚥 了。
举个例子,假如你刚加入一家心仪已久的新公司,同事转交给你的项目里有这么一个函数:
def mark_trip_as_featured(trip):
"""将某个旅程添加到推荐栏目 """
if trip.source == 11:
do_some_thing(trip)
elif trip.source == 12:
do_some_other_thing(trip)
... ...
return
复制代码
这个函数作了什么事?你努力想搞懂它的意思,不过 trip.source == 11
是什么状况?那 == 12
呢?这两行代码很简单,没有用到任何魔法特性。但初次接触代码的你可能须要花费一整个下午,才能弄懂它们的含义。
问题就出在那几个数字字面量上。 最初写下这个函数的人,多是在公司成立之初加入的那位元老程序员。而他对那几个数字的含义很是清楚。但若是你是一位刚接触这段代码的新人,就彻底是另一码事了。
那么,怎么改善这段代码?最直接的方式,就是为这两个条件分支添加注释。不过在这里,“添加注释”显然不是提高代码可读性的最佳办法*(其实在绝大多数其余状况下都不是)*。咱们须要用有意义的名称来代替这些字面量,而枚举类型(enum)
用在这里最合适不过了。
enum
是 Python 自 3.4 版本引入的内置模块,若是你使用的是更早的版本,能够经过 pip install enum34
来安装它。下面是使用 enum 的样例代码:
# -*- coding: utf-8 -*-
from enum import IntEnum
class TripSource(IntEum):
FROM_WEBSITE = 11
FROM_IOS_CLIENT = 12
def mark_trip_as_featured(trip):
if trip.source == TripSource.FROM_WEBSITE:
do_some_thing(trip)
elif trip.source == TripSource.FROM_IOS_CLIENT:
do_some_other_thing(trip)
... ...
return
复制代码
将重复出现的数字字面量定义成枚举类型,不光能够改善代码的可读性,代码出现 Bug 的概率也会下降。
试想一下,若是你在某个分支判断时将 11
错打成了 111
会怎么样?咱们时常会犯这种错,而这类错误在早期特别难被发现。将这些数字字面量所有放入枚举类型中能够比较好的规避这类问题。相似的,将字符串字面量改写成枚举也能够得到一样的好处。
使用枚举类型代替字面量的好处:
固然,你彻底没有必要把代码里的全部字面量都改为枚举类型。 代码里出现的字面量,只要在它所处的上下文里面容易理解,就可使用它。 好比那些常常做为数字下标出现的 0
和 -1
就彻底没有问题,由于全部人都知道它们的意思。
什么是“裸字符串处理”?在这篇文章里,它指只使用基本的加减乘除和循环、配合内置函数/方法来操做字符串,得到咱们须要的结果。
全部人都写过这样的代码。有时候咱们须要拼接一大段发给用户的告警信息,有时咱们须要构造一大段发送给数据库的 SQL 查询语句,就像下面这样:
def fetch_users(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""获取用户列表 :param int min_level: 要求的最低用户级别,默认为全部级别 :param int gender: 筛选用户性别,默认为全部性别 :param int has_membership: 筛选全部会员/非会员用户,默认非会员 :param str sort_field: 排序字段,默认为按 created "用户建立日期" :returns: 列表:[(User ID, User Name), ...] """
# 一种古老的 SQL 拼接技巧,使用 "WHERE 1=1" 来简化字符串拼接操做
# 区分查询 params 来避免 SQL 注入问题
statement = "SELECT id, name FROM users WHERE 1=1"
params = []
if min_level is not None:
statement += " AND level >= ?"
params.append(min_level)
if gender is not None:
statement += " AND gender >= ?"
params.append(gender)
if has_membership:
statement += " AND has_membership == true"
else:
statement += " AND has_membership == false"
statement += " ORDER BY ?"
params.append(sort_field)
return list(conn.execute(statement, params))
复制代码
咱们之因此用这种方式拼接出须要的字符串 - 在这里是 SQL 语句 - 是由于这样作简单、直接,符合直觉。可是这样作最大的问题在于:随着函数逻辑变得更复杂,这段拼接代码会变得容易出错、难以扩展。事实上,上面这段 Demo 代码也只是仅仅作到看上去没有明显的 bug 而已 (谁知道有没有其余隐藏问题)。
其实,对于 SQL 语句这种结构化、有规则的字符串,用对象化的方式构建和编辑它才是更好的作法。下面这段代码用 SQLAlchemy 模块完成了一样的功能:
def fetch_users_v2(conn, min_level=None, gender=None, has_membership=False, sort_field="created"):
"""获取用户列表 """
query = select([users.c.id, users.c.name])
if min_level != None:
query = query.where(users.c.level >= min_level)
if gender != None:
query = query.where(users.c.gender == gender)
query = query.where(users.c.has_membership == has_membership).order_by(users.c[sort_field])
return list(conn.execute(query))
复制代码
上面的 fetch_users_v2
函数更短也更好维护,并且根本不须要担忧 SQL 注入问题。因此,当你的代码中出现复杂的裸字符串处理逻辑时,请试着用下面的方式替代它:
Q: 目标/源字符串是结构化的,遵循某种格式吗?
咱们的代码里偶尔会出现一些比较复杂的数字,就像下面这样:
def f1(delta_seconds):
# 若是时间已通过去了超过 11 天,不作任何事
if delta_seconds > 950400:
return
...
复制代码
话说在前头,上面的代码没有任何毛病。
首先,咱们在小本子(固然,和我同样的聪明人会用 IPython)上算了算:11天一共包含多少秒?
。而后再把结果 950400
这个神奇的数字填进咱们的代码里,最后心满意足的在上面补上一行注释:告诉全部人这个神奇的数字是怎么来的。
我想问的是:“为何咱们不直接把代码写成 if delta_seconds < 11 * 24 * 3600:
呢?”
“性能”,答案必定会是“性能”。咱们都知道 Python 是一门~~(速度欠佳的)~~解释型语言,因此预先计算出 950400
正是由于咱们不想让每次对函数 f1
的调用都带上这部分的计算开销。不过事实是:即便咱们把代码改为 if delta_seconds < 11 * 24 * 3600:
,函数也不会多出任何额外的开销。
Python 代码在执行时会被解释器编译成字节码,而真相就藏在字节码里。让咱们用 dis 模块看看:
def f1(delta_seconds):
if delta_seconds < 11 * 24 * 3600:
return
import dis
dis.dis(f1)
# dis 执行结果
5 0 LOAD_FAST 0 (delta_seconds)
2 LOAD_CONST 1 (950400)
4 COMPARE_OP 0 (<)
6 POP_JUMP_IF_FALSE 12
6 8 LOAD_CONST 0 (None)
10 RETURN_VALUE
>> 12 LOAD_CONST 0 (None)
14 RETURN_VALUE
复制代码
看见上面的 2 LOAD_CONST 1 (950400)
了吗?这表示 Python 解释器在将源码编译成成字节码时,会计算 11 * 24 * 3600
这段整表达式,并用 950400
替换它。
因此,当咱们的代码中须要出现复杂计算的字面量时,请保留整个算式吧。它对性能没有任何影响,并且会增长代码的可读性。
Hint:Python 解释器除了会预计算数值字面量表达式之外,还会对字符串、列表作相似的操做。一切都是为了性能。谁让大家老吐槽 Python 慢呢?
Python 里的两个布尔值 True
和 False
在绝大多数状况下均可以直接等价于 1
和 0
两个整数来使用,就像这样:
>>> True + 1
2
>>> 1 / False
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero
复制代码
那么记住这点有什么用呢?首先,它们能够配合 sum
函数在须要计算总数时简化操做:
>>> l = [1, 2, 4, 5, 7]
>>> sum(i % 2 == 0 for i in l)
2
复制代码
此外,若是将某个布尔值表达式做为列表的下标使用,能够实现相似三元表达式的目的:
# 相似的三元表达式:"Javascript" if 2 > 1 else "Python"
>>> ["Python", "Javascript"][2 > 1]
'Javascript'
复制代码
单行代码的长度不宜太长。好比 PEP8 里就建议每行字符数不得超过 79。现实世界里,大部分人遵循的单行最大字符数在 79 到 119 之间。若是只是代码,这样的要求是比较容易达到的,但假设代码里须要出现一段超长的字符串呢?
这时,除了使用斜杠 \
和加号 +
将长字符串拆分为好几段之外,还有一种更简单的办法:使用括号将长字符串包起来,而后就能够随意折行了:
def main():
logger.info(("There is something really bad happened during the process. "
"Please contact your administrator."))
复制代码
平常编码时,还有一种比较麻烦的状况。就是须要在已经有缩进层级的代码里,插入多行字符串字面量。由于多行字符串不能包含当前的缩进空格,因此,咱们须要把代码写成这样:
def main():
if user.is_active:
message = """Welcome, today's movie list: - Jaw (1975) - The Shining (1980) - Saw (2004)"""
复制代码
可是这样写会破坏整段代码的缩进视觉效果,显得很是突兀。要改善它有不少种办法,好比咱们能够把这段多行字符串做为变量提取到模块的最外层。不过,若是在你的代码逻辑里更适合用字面量的话,你也能够用标准库 textwrap
来解决这个问题:
from textwrap import dedent
def main():
if user.is_active:
# dedent 将会缩进掉整段文字最左边的空字符串
message = dedent("""\ Welcome, today's movie list: - Jaw (1975) - The Shining (1980) - Saw (2004)""")
复制代码
Python 的字符串有着很是多实用的内建方法,最经常使用的有 .strip()
、.split()
等。这些内建方法里的大多数,处理起来的顺序都是从左往右。可是其中也包含了部分以 r
打头的从右至左处理的镜像方法。在处理特定逻辑时,使用它们可让你事半功倍。
假设咱们须要解析一些访问日志,日志格式为:"{user_agent}" {content_length}:
>>> log_line = '"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36" 47632'
复制代码
若是使用 .split()
将日志拆分为 (user_agent, content_length)
,咱们须要这么写:
>>> l = log_line.split()
>>> " ".join(l[:-1]), l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')
复制代码
可是若是使用 .rsplit()
的话,处理逻辑就更直接了:
>>> log_line.rsplit(None, 1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']
复制代码
若是有人问你:“Python 里什么数字最大/最小?”。你应该怎么回答?有这样的东西存在吗?
答案是:“有的,它们就是:float("inf")
和 float("-inf")
”。它们俩分别对应着数学世界里的真负无穷大。当它们和任意数值进行比较时,知足这样的规律:float("-inf") < 任意数值 < float("inf")
。
由于它们有着这样的特色,咱们能够在某些场景用上它们:
# A. 根据年龄升序排序,没有提供年龄放在最后边
>>> users = {"tom": 19, "jenny": 13, "jack": None, "andrew": 43}
>>> sorted(users.keys(), key=lambda user: users.get(user) or float('inf'))
['jenny', 'tom', 'andrew', 'jack']
# B. 做为循环初始值,简化第一次判断逻辑
>>> max_num = float('-inf')
>>> # 找到列表中最大的数字
>>> for i in [23, 71, 3, 21, 8]:
...: if i > max_num:
...: max_num = i
...:
>>> max_num
71
复制代码
当咱们编写多线程程序时,常常须要处理复杂的共享变量和竞态等问题。
“线程安全”,一般被用来形容 **某个行为或者某类数据结构,能够在多线程环境下被共享使用并产生预期内的结果。**一个典型的知足“线程安全”的模块就是 queue 队列模块。
而咱们常作的 value += 1
操做,很容易被想固然的认为是“线程安全”的。由于它看上去就是一个原子操做 (指一个最小的操做单位,执行途中不会插入任何其余操做)。然而真相并不是如此,虽然从 Python 代码上来看,value += 1
这个操做像是原子的。但它最终被 Python 解释器执行的时候,早就再也不 “原子” 了。
咱们能够用前面提到的 dis
模块来验证一下:
def incr(value):
value += 1
# 使用 dis 模块查看字节码
import dis
dis.dis(incr)
0 LOAD_FAST 0 (value)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_FAST 0 (value)
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
复制代码
在上面输出结果中,能够看到这个简单的累加语句,会被编译成包括取值和保存在内的好几个不一样步骤,而在多线程环境下,任意一个其余线程都有可能在其中某个步骤切入进来,阻碍你得到正确的结果。
所以,请不要凭借本身的直觉来判断某个行为是否“线程安全”,否则等程序在高并发环境下出现奇怪的 bug 时,你将为本身的直觉付出惨痛的代价。
我刚接触 Python 不久时,在某个网站看到这样一个说法: “Python 里的字符串是不可变的,因此每一次对字符串进行拼接都会生成一个新对象,致使新的内存分配,效率很是低”。 我对此深信不疑。
因此,一直以来,我尽可能都在避免使用 +=
的方式去拼接字符串,而是用 "".join(str_list)
之类的方式来替代。
可是,在某个偶然的机会下,我对 Python 的字符串拼接作了一次简单的性能测试后发现: Python 的字符串拼接根本就不慢! 查阅了一些资料后,最后发现真相。
Python 的字符串拼接在 2.2 以及以前的版本确实很慢,和我最先看到的说法行为一致。可是由于这个操做太经常使用了,因此以后的版本里专门针对它作了性能优化。大大提高了执行效率。
现在使用 +=
的方式来拼接字符串,效率已经很是接近 "".join(str_list)
了。因此,该拼接时就拼接吧,没必要担忧任何性能问题。
Hint: 若是你想了解更详细的相关内容,能够读一下这篇文章:Python - Efficient String Concatenation in Python (2016 edition) - smcl
以上就是『Python 工匠』系列文章的第三篇,内容比较零碎。因为篇幅缘由,一些经常使用的操做好比字符串格式化等,文章里并无涵盖到。之后有机会再写吧。
让咱们最后再总结一下要点:
看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。