Effective Python -- 第 1 章 用 Pythonic 方式来思考(上)

第 1 章 用 Pythonic 方式来思考(上)

该系列文章为图书 《Effective Python:编写高质量 Python 代码的 59 个有效方法》(英文名称为 《Effective
Python: 59 Specific Ways to Write Better Python》)的内容总结,侵删。

第 1 条:确认自己所用的 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:Python 2 与 Python 3,从 2020 年 1 月 1 日 起 Python 2 停止更新和维护。
  • 有很多种流行的 Python 运行时环境,例如,CPython、Jython、IronPython 以及 PyPy 等。
  • 在操作系统的命令行中运行 Python 时,请确保该 Python 的版本与你想使用的 Python 版本相符。

第 2 条:遵循 PEP 8 风格指南

《Python Enhancement Proposal #8》(8 号 Python 增强提案)又叫 PEP 8,它是针对 Python 代码格式而编订的风格指南。

PEP 8 描述如何撰写清晰的 Python 代码,官方指南:http://www.python.org/dev/peps/pep-0008。

以下列出几条绝对遵守的规则:

  • 空白:Python 中的空白(whitespace)会影响代码的含义。Python 程序员使用空白的时候尤其在意。空白同时会影响代码的清晰度。
    • 使用 space(空格)来表示缩进,而不要使用 tab(制表符)。
    • 和语法相关的每一层缩进都用 4 个空格表示。
    • 每行字符数不应超过 79。
    • 对于占据多行的长表达式来说,除了首行之外的其余各行都应该在通常的缩进级别之上再加 4 个空格。
    • 文件中的函数与类之间应该用两个空行隔开。
    • 在同一个类中,各方法之间应该用一个空行隔开。
    • 在使用下表来获取列表元素、调用函数或给关键字参数赋值的时候,不要在两旁添加空格。
    • 为变量赋值的时候,赋值符号的左侧和右侧应该各自写上一个空格,而且只写一个就好。
  • 命令:PEP 8 提倡采用不同的命名风格来编写 Python 代码中的各个部分,以便在阅读代码时可以根据这些名称看出它们在 Python 语音中的角色。
    • 函数、变量及属性应该用小写字母来拼写,各单词之间以下划线相连,例如,lowercase_underscore。
    • 受保护的实例属性,应该以单个下划线开头,例如,_leading_underscore。
    • 私有的实例属性,应该以两个下划线开头,例如,__double_leading_underscore。
    • 类与异常,应该以每个单词首字母均大写的形式来命名,例如,CapitalizedWord。
    • 模块级别的变量,应该全部采用大写字母来拼写,各单词之间以下划线相连,例如,ALL_CAPS。
    • 类中的实例方法(instance method),应该把首个参数命名为 self,以表示该对象自身。
    • 类方法(class method)的首个参数,应该命名为 cls,以表示该类自身。
  • 表达式和语句:《The Zen of Python》(Python 之禅)中说:“每件事都应该有直白的做法,而且最好只有一种。” PEP 8 在制定表达式和语句的风格时,就试着体现了这种思想。
    • 采用内联形式的否定词,而不要把否定词放在整个表达式的前面,例如,应该写 if a is not b 而不是 if not a is b
    • 不要通过检测长度的办法(如 if len(somelist) == 0)来判断 somelist 是否为 [] 或 ‘’ 等空值,而是应该采用 if not somelist 这种写法来判断,它会假定:空值将自动评估为 False。
    • 检测 somelist 是否为 [1] 或 ‘hi’ 等非空值时,也应如此,if somelist 语句默认会把非空的值判断为 True。
    • 不要编写单行的 if 语句、for 循环、while 循环及 except 复合语句,而是应该把这些语句分成多行来书写,以示清晰。
    • import 语句应该总是放在文件开头。
    • 引入模块的时候,总是应该使用绝对名称,而不应该根据当前模块的路径来使用相对名称。例如,引入 bar 包中的 foo 模块时,应该完整的写出 from bar import foo,而不应该简写为 import foo
    • 如果一定要以相对名称来编写 import 语句,那就采用明确的写法:from . import foo
    • 文件中的那些 import 语句应该按顺序分成三个部分,分别表示标准库模块、第三方模块以及自用模块。在每一部分之中,各 import 语句应该按模块的字母顺序来排列。

总结

  • 当编写 Python 代码时,总是应该遵循 PEP 8 风格指南。
  • 当扩大 Python 开发者采用同一套代码风格,可以使项目更利于多人协作。
  • 采用一致的风格来编写代码,可以令后续的修改工作变得更为容易。

第 3 条:了解 bytes、str 与 unicode 的区别

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 代码中经常出现两种常见的使用情景:

  • 开发者需要原始 8 位值,这些 8 位值表示以 UTF-8 格式(或其他编码形式)来编码的字符。
  • 开发者需要操作没有特定编码形式的 Unicode 字符。

所以,需要编写两个辅助(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 实例似乎就成了同一种类型。

  • 可以用 + 操作符把这种 str 与 unicode 连接起来。
  • 可以用等价与不等价操作符,在这种 str 实例与 unicode 实例之间进行比较。
  • 在格式字符串中,可以用 ‘%s’ 等形式来代表 unicode 实例。

这些行为意味着,在只处理 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 3 中,bytes 是一种包含 8 位值序列,str 是一种包含 Unicode 字符的序列。开发者不能以 > 或 + 等操作符来混同操作 bytes 和 str 实例。
  • 在 Python 2 中,str 是一种包含 8 位值的序列,unicode 是一种包含 Unicode 字符的序列。如果 str 只含有 7 位 ASCII 字符,那么可以通过相关的操作符来同时使用 str 与 unicode。
  • 在对输入的数据进行操作之前,使用辅助函数来保证字符序列的类型与开发者的期望相符。
  • 从文件中读取二进制数据,或向其中写入二进制数据时,总应该以 ‘rb’ 或 ‘wb’ 等二进制模式来开启文件。

第 4 条:用辅助函数来去嗲复杂的表达式

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 的语法特性,从而写出那种特别复杂并且难以理解的单行表达式。
  • 请把复杂的表达式移入辅助函数之中,如果要反复使用相同的逻辑,那就更应该这么做。
  • 使用 if/else 表达式,要比用 or 或 and 这样的 Boolean 操作符写成的表达式更加清晰。

第 5 条:了解切割序列的办法

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]

总结

  • 不要写多余的代码:当 start 索引为 0,或 end 索引为序列长度时,应该将其省略。
  • 切片操作不会计较 start 与 end 索引是否越界,这使得我们很容易就能从序列的前端或后端开始,对其进行范围固定的切片操作。
  • 对 list 赋值的时候,如果使用切片操作,就会把原列表中处在相关范围内的值替换成新值,即便它们的长度不同也依然可以替换。

第 6 条:在单次切片操作内,不要同时指定 start、end 和 stride

除了基本的切片操作之外,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 指定负值。

总结

  • 既有 start 和 end,又有 stride 的切割操作,可能会令人费解。
  • 尽量使用 stride 为正数,且不带 start 或 end 索引的切割操作。尽量避免使用负数做 stride。
  • 在同一个切片操作内,不要同时使用 start、end 和 stride。如果确实需要执行这种操作,那就考虑将其拆解为两条赋值语句,其中一条做范围切割,另一条做步进切割,或考虑使用内置 itertools 模块中的 islide。

你可能感兴趣的:(Python,python)