Python实践提升-数值与字符串

Python实践提升-数值与字符串
  现代人的生活离不开各种数字。人的身高是数字,年龄是数字,银行卡里的余额也是数字。大家同样离不开的还有文字。网络上的文章、路边的指示牌,以及你正在阅读的这本书,都是由文字构成的。

我们离不开数字和文字,正如同编程语言离不开“数值”与“字符串”。两者几乎是所有编程语言里最基本的数据类型,也是我们通过代码连接现实世界的基础。

对于这两种基础类型,Python 展现了它一贯的简单易用的特点。拿整型(integer)来说,在 Python 里使用整型,你不需要了解“有符号”“无符号”“32 位”“64 位”这些令人头疼的概念。不论多大的数字都能直接用,不必担心任何溢出问题:

#无符号 64 位整型的最大值(unsigned int64)
>>> 2 ** 64 - 1
18446744073709551615

#直接乘上 10000 也没问题,永不溢出!
>>> 18446744073709551615 * 10000
184467440737095516150000

和数字一样,Python 里的字符串(string)也很容易上手 1。它直接兼容所有的 Unicode 字符,处理起中文来非常方便:

1准确来说,是 Python 3 版本后的字符串容易上手。要处理好 Python 2 及之前版本中的字符串还是有些难度的。

>>> s = 'Hello, 中文'
>>> type(s)
<class 'str'>

#打印中文
>>> print(s)
Hello, 中文

除了上面的字符串类型(str),有时我们还需要同字节串类型(bytes)打交道。在本章的基础知识板块,我会简单介绍二者的区别,以及如何在它们之间做转换。

接下来,我们就从这两种最基础的数据类型开始,踏上探索 Python 对象世界的旅程吧!

2.1 基础知识
  本节将介绍与数值和字符串有关的基础知识,内容涵盖浮点数的精度问题、字符串与字节串的区别,等等。

2.1.1 数值基础
  在 Python 中,一共存在三种内置数值类型:整型(int)、浮点型(float)和复数类型(complex)。创建这三类数值很简单,代码如下所示:

#定义一个整型
>>> score = 100
#定义一个浮点型
>>> temp = 37.2
#定义一个复数
>>> com = 1+2j

在大多数情况下,我们只需要用到前两种类型:int 与 float。二者之间可以通过各自的内置方法进行转换:

#将浮点数转换为整型
>>> int(temp)
37

#将整型转换为浮点型
>>> float(score)
100.0

在定义数值字面量时,如果数字特别长,可以通过插入 _ 分隔符来让它变得更易读:

#以"千"为单位分隔数字
>>> i = 1_000_000
>>> i + 10
1000010

正如本章开篇所说,Python 里的数值类型十分让人省心,你大可随心所欲地使用,一般不会碰到什么奇怪的问题。不过,浮点数精度问题是个例外。

浮点数精度问题
  如果你在 Python 命令行里输入 0.1 + 0.2,你会看到这样的“奇景”:

>>> 0.1 + 0.2
0.30000000000000004

一个简单的小数计算,为何会产生这么奇怪的结果?这其实是一个由浮点数精度导致的经典问题。

计算机是一个二进制的世界,它能表示的所有数字,都是通过 0 和 1 两个数模拟而来的(比如二进制的 110 代表十进制的 6)。这套模拟机制在表示整数时,尚能勉强应对,一旦我们需要小于 1 的浮点数时,计算机就做不到绝对的精准了。

但是,不提供浮点数肯定是不行的。为此,计算机只好“尽力而为”:取一个固定精度来近似表示小数——Python 使用的是“双精度”(double precision)2。这个精度限制就是 0.1 + 0.2 的最终结果多出来 0.000…4 的原因。

2具体来说,是符合 IEEE-754 规范的双精度,它使用 53 个比特的精度来表达十进制浮点数。

为了解决这个问题,Python 提供了一个内置模块:decimal。假如你的程序需要精确的浮点数计算,请考虑使用 decimal.Decimal 对象来替代普通浮点数,它在做四则运算时不会损失任何精度:

>>> from decimal import Decimal
#注意:这里的 '0.1' 和 '0.2' 必须是字符串
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

在使用 Decimal 的过程中,大家需要注意:必须使用字符串来表示数字。如果你提供的是普通浮点数而非字符串,在转换为 Decimal 对象前就会损失精度,掉进所谓的“浮点数陷阱”:

>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

如果你想了解更多浮点数相关的内容,可查看 Python 官方文档中的“15. Floating Point Arithmetic: Issues and Limitations”,其中的介绍非常详细。
2.1.2 布尔值其实也是数字
  布尔(bool)类型是 Python 里用来表示“真假”的数据类型。你肯定知道它只有两个可选值:True 和 False。不过,你可能不知道的是:布尔类型其实是整型的子类型,在绝大多数情况下,True 和 False 这两个布尔值可以直接当作 1 和 0 来使用。

就像这样:

>>> int(True), int(False)
(1, 0)
>>> True + 1
2

#把 False 当除数的效果和 0 一样
>>> 1 / False
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: division by zero

布尔值的这个特点,最常用来简化统计总数操作。

假设有一个包含整数的列表,我需要计算列表里一共有多少个偶数。正常来说,我得写一个循环加分支结构才能完成统计:

numbers = [1, 2, 4, 5, 7]

count = 0
for i in numbers:
    if i % 2 == 0:
        count += 1

print(count)
#输出:2

但假如利用“布尔值可作为整型使用”的特性,一个简单的表达式就能完成同样的事情:

count = sum(i % 2 == 0 for i in numbers)

❶ 此处的表达式 i % 2 == 0 会返回一个布尔值结果,该结果随后会被当成数字 0 或 1 由 sum() 函数累加求和

2.1.3 字符串常用操作
  本节介绍一些与字符串有关的常用操作。

把字符串当序列来操作

字符串是一种序列类型,这意味着你可以对它进行遍历、切片等操作,就像访问一个列表对象一样:

>>> s = 'Hello, world!'
>>> for c in s:...     print(c)
...
H
...
d
!
>>> s[1:3]'el'

❶ 遍历一个字符串,将会逐个返回每个字符

❷ 对字符串进行切片

假如你想反转一个字符串,可以使用切片操作或者 reversed 内置方法:

>>> s[::-1]'!dlrow ,olleH'
>>> ''.join(reversed(s))'!dlrow ,olleH'

❶ 切片最后一个字段使用 -1,表示从后往前反序

❷ reversed 会返回一个可迭代对象,通过字符串的 .join 方法可以将它转换为字符串

字符串格式化

Python 语言有一个设计理念:“任何问题应有一种且最好只有一种显而易见的解决方法。”3如果把这句话放到字符串格式化领域,似乎就有点儿难以自圆其说了。

在当前的主流 Python 版本中,至少有三种主要的字符串格式化方式。

(1) C 语言风格的基于百分号 % 的格式化语句:‘Hello, %s’ % ‘World’。

(2) 新式字符串格式化(str.format)方式(Python 2.6 新增):“Hello, {}”.format (‘World’)。

(3) f-string 字符串字面量格式化表达式(Python 3.6 新增):name = ‘World’; f’Hello, {name}'。

第一种字符串格式化方式历史最为悠久,但现在已经很少使用。相比之下,后两种方式正变得越来越流行。从个人体验来说,f-string 格式化方式用起来最方便,是我的首选。和其他两种方式比起来,使用 f-string 的代码多数情况下更简洁、更直观。

举个例子:

username, score = 'andy', 100
#1. C 语言风格格式化
print('Welcome %s, your score is %d' % (username, score))
#2. str.format
print('Welcome {}, your score is {:d}'.format(username, score))

#3. f-string,最短最直观
print(f'Welcome {username}, your score is {score:d}')
#输出:
#Welcome andy, your score is 100

str.format 与 f-string 共享了同一种复杂的“字符串格式化微语言”。通过这种微语言,我们可以方便地对字符串进行二次加工,然后输出。比如:

#将 username 靠右对齐,左侧补空格到一共 20 个字符
#以下两种方式将输出同样的内容
print('{:>20}'.format(username))
print(f'{username:>20}')
#输出:
#andy

对于用户自定义类型来说,可以通过定义魔法方法,来修改对象被渲染成字符串的值。我在 12.2.1 节会介绍这部分内容。
虽然年轻的 f-string 抢走了 str.format 的大部分风头,但后者仍有着自己的独到之处。比如 str.format 支持用位置参数来格式化字符串,实现对参数的重复使用:

print('{0}: name={0} score={1}'.format(username, score))
#输出:
#andy: name=piglei score=100

综上所述,日常编码中推荐优先使用 f-string,搭配 str.format 作为补充,想必能满足大家绝大多数的字符串格式化需求。

查看 Python 官方文档中的“Format Specification Mini-Language”一节,了解字符串格式化微语言更多的相关信息。

拼接多个字符串

假如要拼接多个字符串,比较常见的 Python 式做法是:首先创建一个空列表,然后把需要拼接的字符串都放进列表,最后调用 str.join 来获得大字符串。示例如下:

>>> words = ['Numbers(1-10):']
>>> for i in range(10):
...     words.append(f'Value: {i + 1}')
...
>>> print('\n'.join(words))
Numbers(1-10):
Value: 1
...
Value: 10

除了使用 join,也可以直接用 words_str += f’Value: {i + 1}’ 这种方式来拼接字符串。但也许有人告诫过你:“千万别这么干!这样操作字符串很慢很不专业!”这个说法也许曾经正确,但现在看其实有些危言耸听。我在 2.3.5 节会向你证明:在拼接字符串时,+= 和 join 同样好用。

3原文来自“Python 之禅”:There should be one-- and preferably only one --obvious way to do it。翻译来自维基百科。

2.1.4 不常用但特别好用的字符串方法
  为了方便,Python 为字符串类型实现了非常多内置方法。在对字符串执行某种操作前,请一定先查查某个内置方法是不是已经实现了该操作,否则一不留神就会重复发明轮子。

比如我以前就写过一个函数,它专门用正则表达式来判断某个字符串是否只包含数字。写完后我才发现,这个功能其实根本不用自己实现,直接调用字符串的 s.isdigit() 方法就能完成任务:

>>> '123'.isdigit(), 'foo'.isdigit()
(True, False)

日常编程中,我们最常用到的字符串方法有 .join()、.split()、.startswith(),等等。虽然这些常用方法能满足大部分的字符串处理需求,但要成为真正的字符串高手,除了掌握常用方法,了解一些不那么常用的方法也很重要。在这方面,.partition() 和 .translate() 方法就是两个很好的例子。

str.partition(sep) 的功能是按照分隔符 sep 切分字符串,返回一个包含三个成员的元组:(part_before, sep, part_after),它们分别代表分隔符前的内容、分隔符以及分隔符后的内容。

第一眼看上去,partition 的功能和 split 的功能似乎是重复的——两个方法都是分割字符串,只是结果稍有不同。但在某些场景下,使用 partition 可以写出比用 split 更优雅的代码。

举个例子,我有一个字符串 s,它的值可能会是以下两种格式。

(1)‘{key}:{value}’,键值对标准格式,此时我需要拿到 value 部分。

(2)‘{key}’,只有 key,没有冒号 : 分隔符,此时我需要拿到空字符串 ‘’。

如果用 split 方法来实现需求,我需要写出下面这样的代码:

def extract_value(s):
    items = s.split(':')
    # 因为 s 不一定会包含 ':',所以需要对结果长度进行判断
    if len(items) == 2:
        return items[1]
    else:
        return ''

执行效果如下:

extract_value(‘name:andy’)
‘andy’
extract_value(‘name’)
‘’
  这个函数的逻辑虽算不上复杂,但由于 split 的特点,函数内的分支判断基本无法避免。这时,如果使用 partition 函数来替代 split,原本的分支判断逻辑就可以消失——一行代码就能完成任务:

def extract_value_v2(s):
    # 当 s 包含分隔符 : 时,元组最后一个成员刚好是 value
    # 若是没有分隔符,最后一个成员默认是空字符串 ''
    return s.partition(':')[-1]

除了 partition 方法,str.translate(table) 方法有时也非常有用。它可以按规则一次性替换多个字符,使用它比调用多次 replace 方法更快也更简单:

>>> s = '明明是中文,却使用了英文标点.'

#创建替换规则表:',' -> ',', '.' -> '。'
>>> table = s.maketrans(',.', ',。')
>>> s.translate(table)
'明明是中文,却使用了英文标点。'

除了上面这两个方法,在 2.3.4 节中,我们还会分享一个用较少露面的内置方法解决真实问题的例子。

2.1.5 字符串与字节串
  按照受众的不同,广义上的“字符串”概念可分为两类。

(1) 字符串:我们最常挂在嘴边的“普通字符串”,有时也被称为文本(text),是给人看的,对应 Python 中的字符串(str)类型。str 使用 Unicode 标准,可通过 .encode() 方法编码为字节串。

(2) 字节串:有时也称“二进制字符串”(binary string),是给计算机看的,对应 Python 中的字节串(bytes)类型。bytes 一定包含某种真正的字符串编码格式(默认为 UTF-8),可通过 .decode() 解码为字符串。

下面是简单的字符串操作示例:

>>> str_obj = 'Hello, 世界'
>>> type(str_obj)
<class 'str'>

>>> bin_str = str_obj.encode('UTF-8')>>> type(bin_str)
<class 'bytes'>
>>> bin_str
b'Hello, \xe4\xb8\x96\xe7\x95\x8c'

>>> str_obj.encode('UTF-8') == str_obj.encode()True

>>> str_obj.encode('gbk')b'Hello, \xca\xc0\xbd\xe7'

❶ 通过 .encode() 方法将字符串编码为字节串,此时使用的编码格式为 UTF-8

❷ 假如不指定任何编码格式,Python 也会使用默认值:UTF-8

❸ 也可以使用其他编码格式,比如另一种中文编码格式:gbk

要创建一个字节串字面量,可以在字符串前加一个字母 b 作为前缀:

>>> bin_obj = b'Hello'
>>> type(bin_obj)
<class 'bytes'>
>>> bin_obj.decode()'Hello'

❶ 字节串可通过调用 .decode() 解码为字符串

bytes 和 str 是两种数据类型,即便有时看上去“一样”,但做比较时永不相等:

>>> 'Hello' == b'Hello'
False

它们也不能混用:

>>> 'Hello'.split('e')
['H', 'llo']

#str 不能使用 bytes 来调用任何内置方法,反之亦然
>>> 'Hello'.split(b'e')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: must be str or None, not bytes

最佳实践
  正因为字符串面向的是人,而二进制的字节串面向的是计算机,因此,在使用体验方面,前者要好得多。在我们的程序中,应该尽量保证总是操作普通字符串,而非字节串。必须操作处理字节串的场景,一般来说只有两种:

(1) 程序从文件或其他外部存储读取字节串内容,将其解码为字符串,然后再在内部使用;

(2) 程序完成处理,要把字符串写入文件或其他外部存储,将其编码为字节串,然后继续执行其他操作。

举个例子,假如你写了一个简单的字符串函数:

def upper_s(s):
    """把输入字符串里的所有“s”都转为大写"""
    return s.replace('s', 'S')

当接收的输入是字节串时,你需要先将其转换为普通字符串,再调用函数:

#从外部系统拿到的字节串对象
>>> bin_obj
b'super sunflowers\xef\xbc\x88\xe5\x90\x91\xe6\x97\xa5\xe8\x91\xb5\xef\xbc\x89'

#将其转换为字符串后,再继续执行后面的操作
>>> str_obj = bin_obj.decode('UTF-8')>>> str_obj
'super sunflowers(向日葵)'
>>> upper_s(str_obj)
'Super SunflowerS.(向日葵)'

❶ 此处的 UTF-8 也可能是 gbk 或其他任何一种编码格式,一切取决于输入字节串的实际编码格式

反之,当你要把字符串写入文件(进入计算机的领域)时,请谨记:普通字符串采用的是文本格式,没法直接存放于外部存储,一定要将其编码为字节串——也就是“二进制字符串”——才行。

这个编码工作有时需要显式去做,有时则隐式发生在程序内部。比如在写入文件时,只要通过 encoding 参数指定字符串编码格式,Python 就会自动将写入的字符串编码为字节串:

#通过 encoding 指定字符串编码格式为 UTF-8
with open('output.txt', 'w', encoding='UTF-8') as fp:
    str_obj = upper_s('super sunflowers(向日葵)')
    fp.write(str_obj)
    # 最后 output.txt 中存储的将是 UTF-8 编码的文本

删掉 open(…) 里的 encoding 参数

假如删掉上面 open(…) 调用里的 encoding 参数,将其改成 open(‘output.txt’, ‘w’),也就是不指定任何编码格式,你会发现代码也能正常运行。

这并不代表字符串编码过程消失了,只是变得更加隐蔽而已。如果不指定 encoding 参数,Python 会尝试自动获取当前环境下偏好的编码格式。

比如在 Linux 操作系统下,这个编码格式通常是 UTF-8。

#如果不指定 encoding,Python 将通过 locale 模块获取系统偏好的编码格式
>>> import locale
>>> locale.getpreferredencoding()
'UTF-8'

一旦弄清楚“字符串”和“字节串”的区别,你会发现 Python 里的字符串处理其实很简单。关键在于:用一个“边缘转换层”把人类和计算机的世界隔开,如图 2-1 所示。

在这里插入图片描述

图 2-1 字符串类型转换图

有关数字与字符串的基础知识就先讲到这里。下面我们进入故事时间。

2.2 案例故事
  在本章中,我准备了两个案例故事。

2.2.1 代码里的“密码”
  又是一年求职季,小 R 成功入职了一家心仪已久的大公司,负责公司的核心系统开发。入职的第一天,小 R 从茶水间接了一杯咖啡,坐在电脑前打开 IDE,准备好好地熟悉一下项目代码。

但刚点开第一个文件,小 R 就愣住了,他端着咖啡的手悬在空中许久,似乎没想到自己的读代码计划这么快就卡壳了。此时,屏幕上展示的是这么一段代码:

def add_daily_points(user):
    """用户每天完成第一次登录后,为其增加积分"""
    if user.type == 13:
        return
    if user.type == 3:
        user.points += 120
        return
    user.points += 100
    return

“这个函数是啥意思?”小 R 在心里问自己。“首先,从函数名和文档来看,它在给用户发送每日积分,但它的内部逻辑呢?第一行的 user.type == 13 是什么,之后的 user.type == 3 又是什么?其次,为啥有时增加 100 积分,有时增加 120 积分?”

这几行代码看似简单,没有用到任何魔法特性,但代码里的那些数字字面量(13、3、120、100)就像几个无法破解的密码一样,让读代码的小 R 脑子里一团糨糊。

“密码”的含义

幸运的是,就在小 R 一筹莫展之际,公司的资深程序员小 Q 从他身边走过。小 R 赶紧叫住了小 Q,向他咨询这段积分代码。在后者的一番解释后,小 R 终于搞明白了那些“密码”的含义。

13:用户 type 是 13,代表用户处于被封禁状态,不能增加积分。
3:用户 type 为 3,代表用户充值了 VIP。
100:普通用户每天登录增加 100 积分。
120:VIP 用户在普通用户基础上,每天登录多得 20 积分。
弄明白这些数字的含义后,小 R 觉得自己必须把这段代码改写一遍。我们来帮他看看有哪些办法。

改善代码的可读性

要改善这段代码的可读性,最直接的做法就是给每一行有数字的代码加上注释。但在这种情况下,加注释显然不是首选。我们在第 1 章中讲过,注释应该用来描述那些代码所不能表达的信息;而在这里,小 R 的首要问题是让代码变得可以“自说明”。

他需要用有意义的名称来代替这些数字字面量。

说到有意义的数字,大家最先想到的一般是“常量”(constant)。但 Python 里没有真正的常量类型,人们一般会把大写字母全局变量当“常量”来用。

比如把积分数量定义为常量:

#用户每日奖励积分数量
DAILY_POINTS_REWARDS = 100
#VIP 用户每日额外奖励积分数量
VIP_EXTRA_POINTS = 20

除了常量以外,我们还可以使用枚举类型(enum.Enum)。

enum 是 Python 3.4 引入的专门用于表示枚举类型的内置模块。使用它,小 R 可以定义出这样的枚举类型:

from enum import Enum

#在定义枚举类型时,如果同时继承一些基础类型,比如 int、str,
#枚举类型就能同时充当该基础类型使用。比如在这里,UserType 就可以当作int 使用
class UserType(int, Enum):
    # VIP 用户
    VIP = 3
    # 小黑屋用户
    BANNED = 13

有了这些常量和枚举类型后,一开始那段满是“密码”的代码就可以重写成这样:

def add_daily_points(user):
    """用户每天完成第一次登录后,为其增加积分"""
    if user.type == UserType.BANNED:
        return
    if user.type == UserType.VIP:
        user.points += DAILY_POINTS_REWARDS + VIP_EXTRA_POINTS
        return
    user.points += DAILY_POINTS_REWARDS
    return

把那些神奇的数字定义成常量和枚举类型后,代码的可读性得到了可观的提升。不仅如此,代码出现 bug 的概率其实也降低了。

试想一下,如果某位同事在编写分支判断时,把 13 错打成了 3 会怎么样?那样 VIP 用户和小黑屋用户的权益就会对调,势必会引发一大批用户投诉。这种因为输入错误导致的 bug 并不少见,而且隐蔽性特别强。

把数字字面量改成常量和枚举类型后,我们就能很好地规避输入错误问题。同样,把字符串字面量改写成枚举类型,也可以获得这种好处:

#如果 'vip' 字符串打错了,不会有任何提示
#正确写法:
#if user.type == 'vip':
#错误写法:
if user.type == 'vlp':

#正确写法:
#if user.type == UserType.VIP:
#错误写法:
if user.type == UserType.VLP:
#更健壮:如果 VIP 打错了,会报错 AttributeError: VLP

最后,总结一下用常量和枚举类型来代替字面量的好处。

更易读:所有人都不需要记忆某个数字代表什么。
更健壮:降低输错数字或字母产生 bug 的可能性。
2.2.2 别轻易成为 SQL 语句“大师”
  一个月后,小 R 慢慢习惯了新工作。他学会了用常量和枚举类型替换那些难懂的字面量,逐步改善项目的代码质量。不过,在他所负责的项目里,还有一样东西一直让他觉得很难受

——数据库操作模块。

在这个大公司的核心项目里,所有的数据库操作代码,都是用下面这样的“裸字符串处理”逻辑拼接 SQL 语句而成的,比如一个根据条件查询用户列表的函数如下所示:

def fetch_users(
    conn,
    min_level=None,
    gender=None,
    has_membership=False,
    sort_field="created",
):
    """获取用户列表

    :param min_level: 要求的最低用户级别,默认为所有级别
    :type min_level: int, optional
    :param gender: 筛选用户性别,默认为所有性别
    :type gender: int, optional
    :param has_membership: 筛选会员或非会员用户,默认为 False,代表非会员
    :type has_membership: bool, optional
    :param sort_field: 排序字段,默认为 "created",代表按用户创建日期排序
    :type sort_field: str, optional
    :return: 一个包含用户信息的列表:[(User ID, User Name), ...]
    """
    # 一种古老的 SQL 拼接技巧,使用“WHERE 1=1”来简化字符串拼接操作
    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)
    # 将查询参数 params 作为位置参数传递,避免 SQL 注入问题
    return list(conn.execute(statement, params))

这类代码历史悠久,最初写下它的人甚至早已不知所踪。不过,小 R 大约能猜到,代码的作者当年这么写的原因肯定是:“这种拼接字符串的方式简单直接、符合直觉。”

但令人遗憾的是,这样的代码只是看上去简单,实际上有一个非常大的问题:无法有效表达更复杂的业务逻辑。假如未来查询逻辑要增加一些复合条件、连表查询,人们很难在现有代码的基础上扩展,修改也容易出错。

我们来看看有什么办法能帮助小 R 优化这段代码。

使用 SQLAlchemy 模块改写代码

上述函数所做的事情,我习惯称之为“裸字符串处理”。这种处理一般只使用基本的加减乘除和循环,配合 .split() 等内置方法来不断操作字符串,获得想要的结果。

它的优点显而易见:一开始业务逻辑比较简单,操作字符串代码符合思维习惯,写起来容易。但随着业务逻辑逐渐变得复杂,这类裸处理就会显得越来越力不从心。

其实,对于 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 注入问题。它最大的缺点在于引入了一个额外依赖:sqlalchemy,但同 sqlalchemy 带来的种种好处相比,这点复杂度成本微不足道。

使用 Jinja2 模板处理字符串

除了 SQL 语句,我们日常接触最多的还是一些普通字符串拼接任务。比如,有一份电影评分数据列表,我需要把它渲染成一段文字并输出。

代码如下:

def render_movies(username, movies):
    """
    以文本方式展示电影列表信息
    """
    welcome_text = 'Welcome, {}.\n'.format(username)
    text_parts = [welcome_text]
    for name, rating in movies:
        # 没有提供评分的电影,以 [NOT RATED] 代替
        rating_text = rating if rating else '[NOT RATED]'
        movie_item = '* {}, Rating: {}'.format(name, rating_text)
        text_parts.append(movie_item)
    return '\n'.join(text_parts)

movies = [
    ('The Shawshank Redemption', '9.3'),
    ('The Prestige', '8.5'),
    ('Mulan', None),
]

print(render_movies('andy', movies))

运行上面的代码会输出:

Welcome, andy.

  • The Shawshank Redemption, Rating: 9.3
  • The Prestige, Rating: 8.5
  • Mulan, Rating: [NOT RATED]
    或许你觉得,这样的字符串拼接代码没什么问题。但如果使用 Jinja2 模板引擎处理,代码可以变得更简单:
from jinja2 import Template

_MOVIES_TMPL = '''\
Welcome, {{ username }}.
{% for name, rating in movies %}
* {{ name }}, Rating: {{ rating|default("[NOT RATED]", true) }}
{%- endfor %}
'''

def render_movies_j2(username, movies):
    tmpl = Template(_MOVIES_TMPL)
    return tmpl.render(username=username, movies=movies)

movies = [
    ('The Shawshank Redemption', '9.3'),
    ('The Prestige', '8.5'),
    ('Mulan', None),
]

print(render_movies_j2('andy', movies))

和之前的代码相比,新代码少了列表拼接、默认值处理,所有的逻辑都通过模板语言来表达。假如我们的渲染逻辑以后变得更复杂,第二份代码也能更好地随之进化。

总结一下,当你的代码里出现复杂的裸字符串处理逻辑时,请试着问自己一个问题:“目标/源字符串是结构化的且遵循某种格式吗?”如果答案是肯定的,那么请先寻找是否有对应的开源专有模块,比如处理 SQL 语句的 SQLAlchemy、处理 XML 的 lxml 模块等。

如果你要拼接非结构化字符串,也请先考虑使用 Jinja2 等模板引擎,而不是手动拼接,因为用模板引擎处理字符串之后,代码写起来更高效,也更容易维护。

2.3 编程建议
2.3.1 不必预计算字面量表达式
  在写代码的过程中,我们偶尔会用到一些比较复杂的数字,举个例子:

def do_something(delta_seconds):
    # 如果时间已经过去11 天(或者更久),不做任何事
    if delta_seconds > 950400:
        return
    ...

我们在写这个函数时的“心路历程”大概是下面这样的。

首先,拿起办公桌上的小本子,在上面写上问题:11 天一共包含多少秒?经过一番计算后,得到结果:950 400。然后,我们把这个数字填进代码里,心满意足地在上面补上一行注释——告诉所有人这个数字是怎么来的。

这样的代码看似没有任何毛病,但我想问一个问题:为什么不直接把代码写成 if delta_seconds < 11 * 24 * 3600: 呢?

我猜你给的答案一定是“性能”。

我们都知道,和 C、Go 这种编译型语言相比,Python 是一门执行效率欠佳的解释型语言。出于性能考虑,我们预先算出算式的结果 950400 并写入代码,这样每次调用函数就不会有额外的计算开销了,积水成渊嘛。

但事实是,即便我们把代码改写成 if delta_seconds < 11 * 24 * 3600:,函数也不会多出任何额外开销。为了展示这一点,我们需要用到两个知识点:字节码与 dis 模块。

使用 dis 模块反编译字节码
  虽然 Python 是一门解释型语言,但在解释器真正运行 Python 代码前,其实仍然有一个类似“编译”的加速过程:将代码编译为二进制的字节码。我们没法直接读取字节码,但利用内置的 dis 模块 4,可以将它们反汇编成人类可读的内容——类似一行行的汇编代码。

4dis 的全称是 disassembler for Python bytecode,翻译过来就是 Python 字节码的反汇编器。

先举一个简单的例子。比如,一个简单的加法函数的反汇编结果是这样的:

>>> def add(x, y):
...     return x + y
...

#导入 dis 模块,使用它打印 add() 函数的字节码,也就是解释器如何理解 add() 函数
>>> import dis
>>> dis.dis(add)
  2           0 LOAD_FAST                     0 (x)
              2 LOAD_FAST                     1 (y)
              4 BINARY_ADD
              6 RETURN_VALUE

在上面的输出中,add() 函数的反汇编结果主要展示了下面几种操作。

(1) 两次 LOAD_FAST:分别把局部变量 x 和 y 的值放入栈顶。

(2) BINARY_ADD:从栈顶取出两个值(也就是 x 和 y 的值),执行加法操作,将结果放回栈顶。

(3) RETURN_VALUE:返回栈顶的结果。

如果想了解字节码相关的更多知识,建议阅读 dis 模块的官方文档:“dis — Disassembler for Python bytecode”。
  现在,我们再回头用 dis 模块看看 do_something 函数的字节码:

def do_something(delta_seconds):
    if delta_seconds < 11 * 24 * 3600:
        return

import dis
dis.dis(do_something)

#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 替换它。也就是说,无论你调用 do_something 多少次,其中的算式 11 * 24 * 3600 都只会在编译期被执行 1 次。

因此,当我们需要用到复杂计算的数字字面量时,请保留整个算式吧。这样做对性能没有任何影响,而且会让代码更容易阅读。

解释器除了会预计算数值表达式以外,还会对字符串、列表执行类似的操作——一切为了性能。
2.3.2 使用特殊数字:“无穷大”
  如果有人问你:Python 里哪个数字最大/最小?你该怎么回答?存在这样的数字吗?

答案是“有的”,它们就是 float(“inf”) 和 float(“-inf”)。这两个值分别对应数学世界里的正负无穷大。当它们和任意数值做比较时,满足这样的规律:float(“-inf”) < 任意数值 < float(“inf”)。

正因为有着这样的特点,它们很适合“扮演”一些特殊的边界值,从而简化代码逻辑。

比如有一个包含用户名和年龄的字典,我需要把里面的用户名按照年龄升序排序,没有提供年龄的放在最后。使用 float(‘inf’),代码可以这么写:

def sort_users_inf(users):

    def key_func(username):
        age = users[username]
        # 当年龄为空时,返回正无穷大作为 key,因此就会被排到最后
        return age if age is not None else float('inf')

    return sorted(users.keys(), key=key_func)

users = {"tom": 19, "jenny": 13, "jack": None, "andrew": 43}
print(sort_users_inf(users))
#输出:
 ['jenny', 'tom', 'andrew', 'jack']

2.3.3 改善超长字符串的可读性
  为了保证可读性,单行代码的长度不宜太长。比如 PEP 8 规范就建议每行字符数不超过 79。在现实世界里,大部分人遵循的单行最大字符数通常会比 79 稍大一点儿,但一般不会超过 119 个字符。

假如只考虑普通代码,满足这个长度要求并不算太难。但是,当代码里需要用到一段超长的、没有换行的字符串时,怎么办?

这时,除了用斜杠 \ 和加号 + 将长字符串拆分为几段,还有一种更简单的办法,那就是拿括号将长字符串包起来,之后就可以随意折行了:

s = ("This is the first line of a long string, "
     "this is the second line")

#如果字符串出现在函数参数等位置,可以省略一层括号
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:
        message = dedent("""\
            Welcome, today's movie list:
            - Jaw (1975)
            - The Shining (1980)
            - Saw (2004)""")

dedent 方法会删除整段字符串左侧的空白缩进。使用它来处理多行字符串以后,整段代码的缩进视觉效果就能保持正常了。

2.3.4 别忘了以 r 开头的字符串内置方法
  当人们阅读文字时,通常是从左往右,这或许影响了我们处理字符串的顺序——也是从左往右。Python 的绝大多数字符串方法遵循从左往右的执行顺序,比如最常用的 .split() 就是:

>>> s = 'hello, string world!'

#从左边开始切割字符串,限制 maxsplit=1 只切割一次
>>> s.split(' ', maxsplit=1)
['hello,', 'string world!']

但除了这些“正序”方法,字符串其实还有一些执行从右往左处理的“逆序”方法。这些方法都以字符 r 开头,比如 rsplit() 就是 split() 的镜像“逆序”方法。在处理某些特定任务时,使用“逆序”方法也许能事半功倍。

举个例子,假设我需要解析一些访问日志,日志格式为 ‘“{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()

#因为 UserAgent 里面有空格,所以切完后得把它们再连接起来
>>> " ".join(l[:-1]), l[-1]
('"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632')

但假如利用 .rsplit(),处理逻辑就可以变得更直接:

#从右往左切割,None 表示以所有的空白字符串切割
>>> log_line.rsplit(None, maxsplit=1)
['"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"', '47632']

2.3.5 不要害怕字符串拼接
  很多年以前刚接触 Python 时,我在某个网站上看到这样一个说法:

Python 里的字符串是不可变对象,因此每拼接一次字符串都会生成一个新对象,触发新的内存分配,效率非常低。

有段时间我对此深信不疑。

因此,一直以来,我在任何场合都避免使用 += 拼接字符串,总是用 “”.join(str_list) 之类的方式来替代。

但有一次,在开发一个文本处理工具时,我偶然对字符串拼接操作做了一次性能测试,然后发现——“Python 的字符串拼接根本就不慢!”下面我简单重现一下当时的性能测试。

Python 有一个内置模块 timeit,利用它,我们可以非常方便地测试代码的执行效率。首先,定义需要测试的两个函数:

#定义一个长度为 100 的词汇列表
WORDS = ['Hello', 'string', 'performance', 'test'] * 25

def str_cat():
    """使用字符串拼接"""
    s = ''
    for word in WORDS:
        s += word
    return s


def str_join():
"""使用列表配合 join 产生字符串"""
    l = []
    for word in WORDS:
        l.append(word)
    return ''.join(l)

然后,导入 timeit 模块,定义性能测试:

import timeit

 # 默认执行 100 万次
cat_spent = timeit.timeit(setup='from __main__ import str_cat', stmt='str_cat()')
print("cat_spent:", cat_spent)

join_spent = timeit.timeit(setup='from __main__ import str_join', stmt='str_join()')
print("join_spent", join_spent)

在我的笔记本电脑上,上面的测试会输出以下结果:

cat_spent: 7.844882188
join_spent 7.310863505

发现了吗?基于字符串拼接的 str_cat() 函数只比 str_join() 慢 0.5 秒,按比例来说不到 7%。所以,这两种字符串拼接方式在效率上根本没什么区别。

当时的我在做完性能测试后,又查阅了一些资料,最终才弄明白这是怎么一回事。

在 Python 2.2 及之前的版本里,字符串拼接操作确实很慢,这正是由“不可变对象”和“内存分配”导致的,跟我最早看到的说法一致。但重点是,由于字符串拼接操作实在太常用,2.2 版本之后的 Python 专门针对它做了性能优化,大大提升了其执行效率。

如今,使用 += 拼接字符串基本已经和 “”.join(str_list) 一样快了。所以,该拼接时就拼接吧,少量的字符串拼接根本不会带来任何性能问题,反而会让代码更直观。

2.4 总结
  本章我们学习了在 Python 中使用数值与字符串的经验和技巧。

Python 中的数值非常让人省心,使用它的过程中只要注意不要掉入浮点数精度陷阱就行。而 Python 中的字符串也特别好用,它具有大量内置方法,甚至一些不那么常用的字符串方法有时也能发挥奇效。

正因为字符串简单易用,有时也会被过度使用。比如在代码中直接拼接字符串生成 SQL 语句、组装复杂文本,等等。在这些场景下,使用专业模块和模板引擎才是更好的选择。

在看到一些写代码的“经验之谈”时,你最好抱着怀疑精神,因为 Python 语言进化得特别快,稍不留神,以往的经验就会过时。如果需要验证某个“经验之谈”,dis 和 timeit 两个优秀的工具可以帮到你:前者能让你直接查看编译后的字节码,后者则能让你方便地做性能测试。保持怀疑、多多实验,有助于你成长为更优秀的程序员。

以下是本章要点知识总结。

(1) 数值基础知识

Python 的浮点数有精度问题,请使用 Decimal 对象做精确的小数运算
布尔类型是整型的子类型,布尔值可以当作 0 和 1 来使用
使用 float(‘inf’) 无穷大可以简化边界处理逻辑
  (2) 字符串基础知识

字符串分为两类:str(给人阅读的文本类型)和 bytes(给计算机阅读的二进制类型)
通过 .encode() 与 .decode() 可以在两种字符串之间做转换
优先推荐的字符串格式化方式(从前往后):f-string、str.format()、C 语言风格格式化
使用以 r 开头的字符串内置方法可以从右往左处理字符串,特定场景下可以派上用场
字符串拼接并不慢,不要因为性能原因害怕使用它
  (3) 代码可读性技巧

在定义数值字面量时,可以通过插入 _ 字符来提升可读性
不要出现“神奇”的字面量,使用常量或者枚举类型替换它们
保留数学算式表达式不会影响性能,并且可以提升可读性
使用 textwrap.dedent() 可以让多行字符串更好地融入代码
  (4) 代码可维护性技巧

当操作 SQL 语句等结构化字符串时,使用专有模块比裸处理的代码更易于维护
使用 Jinja2 模板来替代字符串拼接操作
  (5) 语言内部知识

使用 dis 模块可以查看 Python 字节码,帮助我们理解内部原理
使用 timeit 模块可以对 Python 代码方便地进行性能测试
Python 语言进化得很快,不要轻易被旧版本的“经验”所左右

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值