该系列文章为图书 《Effective Python:编写高质量 Python 代码的 59 个有效方法》(英文名称为 《Effective
Python: 59 Specific Ways to Write Better Python》)的内容总结,侵删。
很多电脑都预装了多个版本的标准 CPython 运行时环境(似乎并不是,Windows 不预装,Ubuntu 16 预装 Python 2,Ubuntu 18 预装 Python 3)。
在命令行中输入默认的 python 命令之后,会执行哪个版本无法确定。python 通常是 python 2.7 的别名,请使用 --version 标志来运行 python 命令。通常可以用 python3 命令来运行 Python 3。不过在 Windows 中,没有预装 Python,下载安装 Python3 后,命令行输入 python,会运行 python3。在 Linux 系列下,如 Ubuntu 18.04 和 20.04 中,预装了 Python 3,输入命令 python3 即可运行 Python 3。
运行程序的时候,可以在内置的 sys 模块里查询相关的值,以确定当前使用的 Python 版本。
import sys
print(sys.version)
print(sys.version_info)
>>>
3.4.2 (default, Oct 19 2014, 17:52:17)
由于 2020/1/1 开始 Python 2 已停止和维护,所以建议使用 Python 3 来开发项目。
《Python Enhancement Proposal #8》(8 号 Python 增强提案)又叫 PEP 8,它是针对 Python 代码格式而编订的风格指南。
PEP 8 描述如何撰写清晰的 Python 代码,官方指南:http://www.python.org/dev/peps/pep-0008。
以下列出几条绝对遵守的规则:
if a is not b
而不是 if not a is b
。if len(somelist) == 0
)来判断 somelist 是否为 [] 或 ‘’ 等空值,而是应该采用 if not somelist
这种写法来判断,它会假定:空值将自动评估为 False。if somelist
语句默认会把非空的值判断为 True。from bar import foo
,而不应该简写为 import foo
。from . import foo
。Python 3 有两种表示字符序列的类型:bytes 和 str。前者的实例包含原始的 8 位值(字节);后者的实例包含 Unicode 字符。
Python 2 也有两种表示字符序列的类型,分别是 str 和 unicode。与 Python 3 不同,str 的实例包含原始 8 位值;而 unicode 的实例,则包含 Unicode 字符。
把 Unicode 字符表示为二进制数据最常见的编码方式是 UTF-8。要想把 Unicode 字符转换成二进制数据,必须使用 encode 方法。要想把二进制数据转换成 Unicode 字符,则必须使用 decode 方法。
编写 Python 程序的时候,一定要把编码类型和解码操作放在界面最外围来做。程序的核心部分应该使用 Unicode 字符类型(也就是 Python 3 中的 str、Python 2 中的 unicode),不要对字符编码做任何假设。
由于字符类型有别,Python 代码中经常出现两种常见的使用情景:
所以,需要编写两个辅助(helper)函数,以便在这两种情况之间转换,使得转换后的输入数据能够符合开发者的预期。
在 Python 3 中,我们需要编写接受 str 或 bytes,并总是返回 str 的方法:
def to_str(bytes_or_str):
if isinstance(bytes_or_str, bytes):
value = bytes_or_str.decode('utf-8')
else:
value = bytes_or_str
return value
另外,还需要编写接受 str 或 bytes,并总是返回 bytes 的方法:
def to_bytes(bytes_or_str):
if isinstance(bytes_or_str, str):
value = bytes_or_str.encode('utf-8')
else:
value = bytes_or_str
return value
在 Python 2 中,需要编写接受 str 或 unicode,并总是返回 unicode 的方法:
# Python 2
def to_unicode(unicode_or_str):
if isinstance(unicode_or_str, str):
value = unicode_or_str.decode('utf-8)
else:
value = unicode_or_str
return value
另外,还需要编写接受 str 或 unicode,并总是返回 str 的方法:
# Python 2
def to_str(unicode_or_str):
if isinstance(unicode_or_str, unicode):
value = unicode_or_str.encode('utf-8')
else:
value = unicode_or_str
return value
在 Python 中使用原始 8 位值与 Unicode 字符时,有两个问题需要注意。
第一个问你题,可能会出现在 Python 2 里面。如果 str 只包含 7 位 ASCII 字符,那么 unicode 和 str 实例似乎就成了同一种类型。
这些行为意味着,在只处理 7 为 ASCII 的情境下,如果某函数接受 str,那么可以给它传入 unicode;如果某函数接受 unicode,那么也可以给它传入 str。而在 Python 3 中,bytes 与 str 实例则绝对不会等价,即使是空字符串也不行。所以,在传入字符序列的时候必须留意其类型。
第二个问题可能会出现在 Python 3 里面,如果通过内置的 open 函数获取了文件句柄,那么请注意,该句柄默认会采用 UTF-8 编码格式来操作文件。在 Python 2 中则是二进制形式。这可能会导致程序出现奇怪的错误。
例如,现在要向文件中随机写入一些二进制数据。下面这种用法在 Python 2 中可以正常运作,但在 Python 3 中不行。
with open('/tmp/random.bin', 'w') as f:
f.write(os.urandom(10))
>>>
TypeError: must be str, not bytes
发生上述异常的原因在于,Python 3 给 open 函数添加了名为 encoding 的新参数,而这个新参数的默认值却是 ‘utf-8’。这样在文件句柄上进行 read 和 write 操作时,系统就要求开发者必须传入包含 Unicode 字符的 str 实例,而不接受包含二进制数据的 bytes 实例。
为了解决这个问题,必须用二进制写入模式(‘wb’)来开启代操作的文件,而不能像原来那样,采用字符写入模式(‘w’)。安装下面这种方式来使用 open 函数,即可同时适配 Python 2 与 Python 3:
with open('/tmp/random.bin', 'wb') as f:
f.write(os.urandom(10))
从文件中读取数据的时候也有这种问题。解决办法与写入时相似:用 ‘rb’ 模式(也就是二进制模式)打开文件,而不要使用 ‘r’ 模式。
Python 的语法非常精炼,很容易就能用一行表达式来实现许多逻辑。例如,要从 URL 中解码查询字符串。在下例所举的查询字符串中,每个参数都可以表示一个整数值:
from urllib.parse import parse_qs
my_values = parse_qs('red=5&blue=0&green=', keep_blank_values=True)
print(repr(my_values))
>>>
{
'red': [5], 'green': [''], 'blue': ['0']}
查询字符串中的某些参数可能有多个值,某些参数可能只有一个值,某些参数可能是空白(blank)值,还有些参数则没有出现在查询字符串中。用 get 方法在 my_values 字典中查询不同的参数时,就有可能获得不同的返回值。
print('Red: ', my_values.get('red'))
print('Green: ', my_values.get('green'))
print('Opacity: ', my_values.get('opacity'))
>>>
Red: ['5']
Green: ['']
Opacity: None
如果待查询的参数没有出现在字符串中,或当该参数的值为空白时能够返回默认值 0,那就更好了。这个逻辑看上去似乎并不值得用完整的 if 语句或辅助函数来实现,于是,你可能会考虑用 Boolean 表达式。
由于 Python 的语法非常精炼, 所以很容易想到了这种做法。空字符串、空列表及零值,都会评估为 False。因此,在下面这个例子中,如果 or 操作符左侧的子表达式估值为 False,那么整个表达式的值就将是 or 操作符右侧那个子表达式的值。
# For query string 'red=5&blue=0&green='
red = my_values.get('red', [''])[0] or 0
green = my_values.get('green', [''])[0] or 0
opacity = my_values.get('opacity', [''])[0] or 0
print('Red: %r' % red)
print('Green: %r' % green)
print('Opacity: %r' % opacity)
>>>
Red: '5'
Green: 0
Opacity: 0
red 那一行代码是正确的,因为 my_values 字典里确实有 ‘red’ 这个键。该键所对应的值是个列表,列表中只有一个元素,也就是字符串 ‘5’。这个字符串会自动估值为 True,所以,or 表达式第一部分的值就会赋给 red。
green 那一行代码也是正确的,因为 get 方法从 my_values 字典中获取的值是个列表,该列表只有一个元素,这个元素是个空字符串。由于空字符串会自动估值为 False,所以整个 or 表达式的值就成了 0。
opacity 那一行代码也没有错。my_values 字典里面没有名为 ‘opacity’ 的键,而当字典中没有待查询的键时,get 方法会返回第二个参数的值,所以,在本例中,get 方法就会返回仅包含一个元素的列表,那个元素是个空字符串。当字典里没有待查询的 ‘opacity’ 键时,这行代码的执行效果与 green 那行代码相同。
这样的长表达式虽然语法正确,但却很难阅读,而且有时也未必完全符合要求。由于想在数学表达式中使用这些参数,所以还要确保每个参数的值都是整数。为了实现这一点,需要把每个长表达式都包裹在内置的 int 函数中,以便把字符串解析为整数。
red = int(my_values.get('red', [''])[0] or 0)
这种写法读起来很困难,而且看上去很乱。即使想把代码写得简省一些,也没必要将全部内容都挤在一行里面。
Python 2.5 添加了 if/else 条件表达式(又称三元操作符),是我们可以把上述逻辑写得清晰一些,同时还能保持代码简洁。
red = my_values.get('red', [''])
red = int(red[0]) if red[0] else 0
这种写法比原来好了一些。对于不太复杂的情况来说,if/else 条件表达式可以令代码变得清晰。但对于上面这个例子来说,它的清晰程度还是比不上跨多行的完整 if/else 语句。如果把上述逻辑全都改成下面这种形式,那我们就能感觉到:刚才那种紧缩的写法其实挺复杂。
green = my_values.get('green', [''])
if green[0]:
greem = int(green[0])
else:
green = 0
现在应该把它总结成辅助函数了,如果需要频繁使用这种逻辑,那就更应该这样做。
def get_first_int(values, key, default=0):
found = values.get(key, [''])
if found[0]:
found = int(found[0])
else:
found = default
return found
调用这个辅助函数时所使用的代码,要比使用 or 操作符的长表达式版本,以及使用 if/else 表达式的两行版本更加清晰。
green = get_first_int(my_values, 'green')
表达式如果变得比较复杂,那就应该考虑将其拆解成小块,并把这些逻辑移入辅助函数中。这会令代码更加易读,它比原来那种密集的写法更好。
Python 提供了一种把序列切成小块的写法。这种切片(slice)操作,使得开发者能够轻易地访问由序列中的某些元素所构成的子集。最简单的用法,就是对内置的 list、str 和 bytes 进行切割。切割操作还可以延伸到实现了 __getitem__
和 __setitem__
这两个特殊方法的 Python 类上。
切割操作的基本写法是 somelist[start:end]
,其中 start(其实索引)所指的元素涵盖在切割后的范围内,而 end(结束索引)所指的元素则不包括在切割结果之中。
a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
print('First four:', a[:4])
print('Last four: ', a[-4:])
print('Middle two: ', a[3:-3])
>>>
First four: ['a', 'b', 'c', 'd']
Last four: ['e', 'f', 'g', 'h']
Middle two: ['d', 'e']
如果从列表开头获取切片,那就不要在 start 那里写上 0,而是应该把它留空,这样代码看起来会清爽一些。
assert a[:5] == a[0:5]
如果切片一直要取到列表末尾,那就应该把 end 留空,因为即便写了,也是多余。
assert a[5:] == a[5:len(a)]
在指定切片起止索引时,若要从列表尾部向前算,则可使用负值来表示相关偏移量。这些写法很正常,在代码中可以放心地使用。
a[:] # ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
a[:5] # ['a', 'b', 'c', 'd', 'e']
a[:-1] # ['a', 'b', 'c', 'd', 'e', 'f', 'g']
a[4:] # ['e', 'f', 'g', 'h']
a[-3:] # ['f', 'g', 'h']
a[2:5] # ['c', 'd', 'e']
a[2:-1] # ['c', 'd', 'e', 'f', 'g']
a[-3:-1] # ['f', 'g']
切割列表时,即便 start 或 end 索引超越边界也不会出问题。利用这一特性,可以限定输入序列的最大长度。
first_twenty_items = a[:20]
last_twenty_items = a[-20:]
反之,访问列表中的单个元素时,下表不能越界,否则会导致异常。
a[20]
>>>
IndexError: list index out of range
对原列表进行切割之后,会尝试另外一份全新的列表。系统依然维护着指向原列表中各个对象的引用。在切割后得到的新列表上进行修改,不会影响原列表。
b = a[4:]
print('Before:', b)
b[1] = 99
print('After:', b)
print('No change:', a)
>>>
Before: ['e', 'f', 'g', 'h']
After: ['e', 99, 'g', 'h']
No change: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
在赋值时对左侧列表使用切割操作,会把该列表中处在指定范围内的对象替换为新值。与元组(tuple)的赋值(如 a,b=c[:2]
)不同,此切片的长度无需新值的个数相等。位于切片范围之前及之后的那些值都保留不变。列表会根据新值的个数相应地扩张或收缩。
print('Before ', a)
a[2:7] = [99, 22, 14]
print('After ', a)
>>>
Before ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
After ['a', 'b', 99, 22, 14, h]
如果对赋值操作右侧的列表使用切片,二把切片的起止索引都留空,那就会产生一份原列表的拷贝。
b = a[:]
assert b == a and b is not a
如果对赋值操作左侧的列表使用切片,而又没有指定起止索引,那么系统会把右侧的新值复制一份,并用这份拷贝来替换左侧列表的全部内容,而不会重新分配新的列表。
b = a
print('Before ', a)
a[:] = [101, 102, 103]
assert a is b # Still the same list object
print('After ', a) # Now has different contents
>>>
Before ['a', 'b', 99, 22, 14, 'h']
After [101, 102, 103]
除了基本的切片操作之外,Python 还提供了 somelist[start:end:stride]
形式的写法,以实现步进式切割,也就是从每 n 个元素里面取 1 个出来。例如:可以指定步进值(stride),把列表中位于偶数索引处和奇数索引处的元素分成两组:
a = ['red', 'orange', 'yellow', 'green', 'blue', 'purple']
odds = a[::2]
evens = a[1::2]
print(odds)
print(evens)
>>>
['red', 'yellow', 'blue']
['orange', 'green', 'purple']
问题在于,采用 stride 方式进行切片时,经常会出现不符合预期的结果。例如,Python 中有一种常见的技巧,能够把以字节形式存储的字符串反转过来,这个技巧就是采用 -1 做步进值。
x = b'mongoose'
y = x[::-1]
print(y)
>>>
b'esoognom'
这种技巧对字节串和 ASCII 字符有用,但是对已经编码成 UTF-8 字节串的 Unicode 字符来说,则无法奏效。
w = '谢谢'
x = w.encode('utf-8')
y = x[::-1]
z = y.decode('utf-8')
>>>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x9d in position 0: invalid start byte
除了 -1 之外,其他的负步进值有没有意义呢?请看例子:
a = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
a[::2] # ['a', 'c', 'e', 'g']
a[::-2] # ['h', 'f', 'd', 'b']
上例中,::2 表示从头部开始,每两个元素选取一个。::-2 则表示从尾部开始,向前选取,每两个元素里选一个。
2::2 是什么意思?-2::-2、-2:2:-2 和 2:2:-2 又是什么意思?请看例子:
a[2::2] # ['c', 'e', 'g']
a[-2::-2] # ['g', 'e', 'c', 'a']
a[-2:2:-2] # ['g', 'e']
a[2:2:-2] # []
通过上面几个例子可以看出:切割列表时,如果指定了 stride,那么代码可能会变得相当费解。在一对中括号里写上 3 个数字显得太过拥挤,从而导致代码难以阅读。这种写法使得 start 和 end 索引的含义变得模糊,当 stride 为负值时,尤其如此。
为了解决这种问题,我们不应该把 stride 与 start 和 end 写在一起。如果非要用 stride,那就尽量采用正值,同时省略 start 和 end 索引。如果一定要配合 start 和 end 索引来使用 stride,那么请考虑先做步进式切片,把切割结果赋给某个变量,然后在那个变量上面做第二次切割。
b = a[::2] # ['a', 'c', 'e', 'g']
c = b[1:-1] # ['c', 'e']
上面这种先做步进式切割,再做范围切割的办法,会多产生一份原数据的浅拷贝。执行第一次切割操作时,应该尽量缩减切割后的列表尺寸。如果你所开发的程序对执行时间或内存用量的要求非常严格,以致不能采用两阶段切割法,那就请考虑 Python 内置的 itertools 模块。该模块中有个 islide 方法,这个方法不允许为 start、end 或 stride 指定负值。