序言

这是 “Python 工匠”系列的第 3 篇文章。

数字是几乎所有编程语言里最基本的数据类型,它是我们通过代码连接现实世界的基础。在 Python 里有三种数值类型:整型(int)、浮点型(float)和复数(complex)。绝大多数情况下,我们只需要和前两种打交道。

整型在 Python 中比较让人省心,因为它不区分有无符号并且永不溢出。但浮点型仍和绝大多数其他编程语言一样,依然有着精度问题,经常让很多刚进入编程世界大门的新人们感到困惑:"Why Are Floating Point Numbers Inaccurate?"。

相比数字,Python 里的字符串要复杂的多。要掌握它,你得先弄清楚 bytes 和 str 的区别。如果更不巧,你还是位 Python2 用户的话,就够你喝上好几壶了光 unicode 和字符编码问题(赶快迁移到 Python3 吧,就在今天!)

不过,上面提到的这些都不是这篇文章的主题,如果感兴趣,你可以在网上找到成堆的相关资料。在这篇文章里,我们将讨论一些 更细微、更不常见 的编程实践。来帮助你写出更好的 Python 代码。


内容目录

  • 最佳实践

    1 少写数字字面量,使用 enum 枚举类型改善代码

    2 别在裸字符串处理上走太远

    3 不必预计算字面量表达式
  • 实用技巧

    1 当多级缩进里出现多行字符串时

    2 布尔值其实也是“数字”

    3 改善超长字符串的可读性

    4 别忘了那些 “r” 开头的内建字符串函数

    5 使用“无穷大” float("inf")
  • 常见误区

    1 “value = 1” 并非线程安全

    2 字符串拼接并不慢

最佳实践

1. 少写数字字面量

“数字字面量(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)用在这里最合适不过了。

enum 是 Python 自 3.4 版本引入的内置模块,如果你使用的是更早的版本,可以通过 pip install enum34 来安装它。下面是使用 enum 的样例代码:

# -*- coding: utf-8 -*-
from enum import IntEnum class TripSource(IntEnum):
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 会怎么样?我们时常会犯这种错,而这类错误在早期特别难被发现。将这些数字字面量全部放入枚举类型中可以比较好的规避这类问题。类似的,将字符串字面量改写成枚举也可以获得同样的好处。

使用枚举类型代替字面量的好处:

  • 提升代码可读性:所有人都不需要记忆某个神奇的数字代表什么
  • 提升代码正确性:减少打错数字或字母产生 bug 的可能性

当然,你完全没有必要把代码里的所有字面量都改成枚举类型。 代码里出现的字面量,只要在它所处的上下文里面容易理解,就可以使用它。 比如那些经常作为数字下标出现的 0 和 -1 就完全没有问题,因为所有人都知道它们的意思。

2. 别在裸字符串处理上走太远

什么是“裸字符串处理”?在这篇文章里,它指只使用基本的加减乘除和循环、配合内置函数/方法来操作字符串,获得我们需要的结果。

所有人都写过这样的代码。有时候我们需要拼接一大段发给用户的告警信息,有时我们需要构造一大段发送给数据库的 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 is not None:
query = query.where(users.c.level >= min_level)
if gender is not 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: 目标/源字符串是结构化的,遵循某种格式吗?

是:找找是否已经有开源的对象化模块操作它们,或是自己写一个

SQL:SQLAlchemy

XML:lxml

JSON、YAML ...

否:尝试使用模板引擎而不是复杂字符串处理逻辑来达到目的

Jinja2

mako

Mustache

3. 不必预计算字面量表达式

我们的代码里偶尔会出现一些比较复杂的数字,就像下面这样

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_VALU

看见上面的 2 LOAD_CONST 1 (950400) 了吗?这表示 Python 解释器在将源码编译成成字节码时,会计算 11 * 24 * 3600 这段整表达式,并用 950400 替换它。

所以,当我们的代码中需要出现复杂计算的字面量时,请保留整个算式吧。它对性能没有任何影响,而且会增加代码的可读性。

Hint:Python 解释器除了会预计算数值字面量表达式以外,还会对字符串、列表做类似的操作。一切都是为了性能。谁让你们老吐槽 Python 慢呢?


实用技巧

1. 布尔值其实也是“数字”

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'

2. 改善超长字符串的可读性

单行代码的长度不宜太长。比如 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)""")

3. 别忘了那些 “r” 开头的内建字符串函数

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']

4. 使用“无穷大” float("inf")

如果有人问你:“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

常见误区

1. “value += 1” 并非线程安全

当我们编写多线程程序时,经常需要处理复杂的共享变量和竞态等问题。

“线程安全”,通常被用来形容 某个行为或者某类数据结构,可以在多线程环境下被共享使用并产生预期内的结果。一个典型的满足“线程安全”的模块就是 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 时,你将为自己的直觉付出惨痛的代价。

2. 字符串拼接并不慢

我刚接触 Python 不久时,在某个网站看到这样一个说法: “Python 里的字符串是不可变的,所以每一次对字符串进行拼接都会生成一个新对象,导致新的内存分配,效率非常低”。 我对此深信不疑。

所以,一直以来,我尽量都在避免使用 += 的方式去拼接字符串,而是用 "".join(str_list) 之类的方式来替代。

但是,在某个偶然的机会下,我对 Python 的字符串拼接做了一次简单的性能测试后发现: Python 的字符串拼接根本就不慢! 在查阅了一些资料后,最终发现了真相。

Python 的字符串拼接在 2.2 以及之前的版本确实很慢,和我最早看到的说法行为一致。但是因为这个操作太常用了,所以之后的版本里专门针对它做了性能优化。大大提升了执行效率。

如今使用 += 的方式来拼接字符串,效率已经非常接近 "".join(str_list) 了。所以,该拼接时就拼接吧,不必担心任何性能问题。

Hint: 如果你想了解更详细的相关内容,可以读一下这篇文章:Python - Efficient String Concatenation in Python (2016 edition) - smcl


结语

以上就是『Python 工匠』系列文章的第三篇,内容比较零碎。由于篇幅原因,一些常用的操作比如字符串格式化等,文章里并没有涵盖到。以后有机会再写吧。

让我们最后再总结一下要点:

  • 编写代码时,请考虑阅读者的感受,不要出现太多神奇的字面量
  • 当操作结构化字符串时,使用对象化模块比直接处理更有优势
  • dis 模块非常有用,请多多使用它验证你的猜测
  • 多线程环境下的编码非常复杂,要足够谨慎,不要相信自己的直觉
  • Python 语言的更新非常快,不要被别人的经验所左右

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


往期推荐

Python 工匠:善用变量来改善代码质量

Python 工匠:编写条件分支代码的技巧


蓝鲸智云简介

腾讯蓝鲸智云(简称蓝鲸)软件体系是一套基于PaaS的技术解决方案,致力于打造行业领先的一站式自动化运维平台。目前已经推出社区版、企业版、公有云版,欢迎体验。

请点击访问蓝鲸官网:http://bk.tencent.com

Python 工匠:使用数字与字符串的技巧的更多相关文章

  1. Python 工匠:使用数字与字符串的技巧学习笔记

    #Python 工匠:使用数字与字符串的技巧学习笔记#https://github.com/piglei/one-python-craftsman/blob/master/zh_CN/3-tips-o ...

  2. python入门:数字型和字符串换行要同类型 注意连接符

    #!/usr/bin/env python # -*- coding: utf-8 -*- #数字型和字符串换行要同类型 注意连接符 a = 1 b = 2 print(str(a) + " ...

  3. Python 编程语言要掌握的技能之一:使用数字与字符串的技巧

    最佳实践 1. 少写数字字面量 “数字字面量(integer literal)” 是指那些直接出现在代码里的数字.它们分布在代码里的各个角落,比如代码 del users[0] 里的 0 就是一个数字 ...

  4. Python使用数字与字符串的技巧

    1.少写数字字面量 "数字字面量(integer literal)" 是指那些直接出现在代码里的数字.它们分布在代码里的各个角落,比如代码 del users[0] 里的 0 就是 ...

  5. python基础之数字、字符串、列表、元组、字典

    Python基础二: 1.运算符: 判断某个东西是否在某个东西里面包含: in  为真 not in  为假 (1).算术运算符: 运算符 描述 实例 + 加  表示两个对象相加 a + b输出结果3 ...

  6. Python数据类型(数字和字符串)

    1.1 Number(数字) Python可以处理任意大的整数,包括负整数. 浮点数也就是小数,之所以称为浮点数,是因为按照科学记数法表示时,一个浮点数的小数点位置是可变的.,比如,\(1.23\ti ...

  7. python基础(数字、字符串、布尔值、字典数据类型简介)

    一 执行第一个python程序 1.下载安装python2.7和python3.6的版本及pycharm,我们可以再解释器中输入这样一行代码: 则相应的就打出了一句话.这里的print是打印的意思.你 ...

  8. 4. Python数据类型之数字、字符串、列表

    开发过程中,我们需要处理文本.图形.音频.视频.网页等各种各样的数据,不同的数据,需要定义不同的数据类型.在Python中,能够直接处理的数据类型主要有以下几种:数字.字符串.列表.元组.字典.集合等 ...

  9. 第二章 Python基本元素:数字、字符串和变量

    Python有哪些内置的数据类型: True False #布尔型 42 100000000 #整型 3.14159 1.0e8 #浮点型 abcdes #字符串 2.1 变量.名字和对象 pytho ...

随机推荐

  1. 论文翻译:2018_Source localization using deep neural networks in a shallow water environment

    论文地址:https://asa.scitation.org/doi/abs/10.1121/1.5036725 深度神经网络在浅水环境中的源定位 摘要: 深度神经网络(DNNs)在表征复杂的非线性关 ...

  2. C++11移动语义之一(基本概念)

    摘要 移动语义是C++11的新特性之一,利用移动语义可以实现对象的移动而非拷贝.在某些情况下,可以大幅度的提升性能.本文将介绍C++11移动语义中的一些基本概念. 表达式 表达式是由一个或者多个运算对 ...

  3. java高级用法之:调用本地方法的利器JNA

    目录 简介 JNA初探 JNA加载native lib的流程 本地方法中的结构体参数 总结 简介 JAVA是可以调用本地方法的,官方提供的调用方式叫做JNI,全称叫做java native inter ...

  4. Golang之框架篇-Windows环境bee工具运行beego

    bee工具简介及好处     bee 工具是一个为了协助快速开发 beego 项目而创建的项目,通过 bee 你可以很容易的进行 beego 项目的创建.热编译.开发.测试.和部署. 强烈推荐新手或J ...

  5. Java并发机制(2)--synchronized与Lock

    本内容整理自:博客园-海 子-java并发编程系列-http://www.cnblogs.com/dolphin0520/category/602384.html 1.基础: 1.什么时候出现线程安全 ...

  6. 什么是 Spring 引导的执行器?

    Spring Boot 执行程序提供了 restful Web 服务,以访问生产环境中运行应用程序 的当前状态.在执行器的帮助下,您可以检查各种指标并监控您的应用程序.

  7. 为什么 Java 中的 String 是不可变的(Immutable)?

    Java 中的 String 不可变是因为 Java 的设计者认为字符串使用非常频繁,将字 符串设置为不可变可以允许多个客户端之间共享相同的字符串.

  8. springboot使用策略模式实现一个基本的促销

    策略模式 定义了算法族,分别封装起来,让它们之间可以互相替换, 此模式让算法的变化独立于使用算法的客户 源码:https://github.com/youxiu326/sb_promotion.git ...

  9. Redis++:Redis做分布式锁真的靠谱吗

    Redis做分布式锁真的靠谱吗 Redis的分布式锁可以通过Lua进行实现,通过setnx和expire命令连用的方式 || 也可以使用高版本的方法同时设置失效时间,但是假如在以下情况下,就会造成无锁 ...

  10. 剑指Offer30——包含min函数的栈

    剑指Offer30--包含min函数的栈 1. 题目简述 定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的min函数在该栈中,调用min.push及pop的时间复杂度是O(1). 2. 题 ...