【python】读书笔记之python之禅(六)
第 6 章 编写Python风格的代码
对于编程语言来说,“强大”是一个毫无意义的形容词。每一种编程语言都称自己是强大的。官方的Python教程一开始就说“Python是一种易于学习、功能强大的编程语言”,但没有哪种算法只能用某种特定的语言编写,也没有哪种衡量单位来量化某种编程语言的“厉害程度”(尽管可以衡量程序员为他们最喜欢的编程语言争取地位的声音大小)。
每种语言都有自己的设计模式和缺陷,它们构成了语言的优势和劣势。要想像一个真正的Python大师一样编写代码,你需要懂的不仅仅是语法和标准库,还要学习它的习惯用法,或Python特定的编程方法。Python语言的某些特性有助于编写Python风格的代码。
在本章中,我将提供几种编写Python风格代码的方法,以及与之对应的非Python风格的写法。对Python风格的理解可能因人而异,但通常包括本章所呈现的示例和实践。有经验的程序员都会使用这些技术,熟悉这些技术可以让你一眼看懂实际的工程代码。
6.1 Python之禅
Tim Peters的“Python之禅”汇集了Python语言设计和Python编程的20条准则。你的Python代码不一定必须遵循这些准则,但它们不无裨益。“Python之禅”也是一个复活节彩蛋,或者说是隐藏的笑话,当运行import this
时就会出现。
1 |
|
注意 神奇的是,实际上只有19条准则写了出来。据Python之父Guido van Rossum所说,缺失的第20条箴言是Tim Peters的搞怪的行内笑话,Tim留出地方让Guido来写,但看起来他一直没做到。
总体来说,这些准则是程序员可以支持或者反对的观点。就像一些优秀的道德准则一样,它们在一定程度上有些自相矛盾,但能提供更大的灵活性。以下是我对这些箴言的解释。
美丽胜于丑陋。美丽的代码指易于阅读和理解的代码。程序员经常快速编写代码,不考虑可读性。虽然计算机会运行可读性不强的代码,但这样的代码对于程序员而言不容易维护和调试。虽然美是主观的,但不考虑可读性的代码在别人看来往往是丑陋的。Python之所以受欢迎,是因为它的语法不像其他语言那样充斥着神秘的标点符号,Python很容易编写。
明确胜于隐含。如果我给这条箴言的解释是“这是不言而喻的”,那它就是一个糟糕的解释。代码应该是详细明确的,应避免把代码的功能隐匿在晦涩的、且需要对语言非常熟悉才能理解的语言特性中。
简单胜于复杂。复杂胜于更复杂。这两句箴言告诉我们,任何东西都既可以用简单的技术建造,也可以用复杂的技术建造。如果你有一个小问题用铲子就可以解决,使用50吨级的液压推土机就有些大材小用。但如果是个大工程,那么操作一台推土机比协调100名铲运工要简单得多。所以,要选择简单而非复杂,但也要知道简单方案的局限性。
扁平胜于嵌套。程序员喜欢将代码按照类别进行组织,特别是类别又包含子类别,而子类别又包含更细的子类别。这些层级结构往往并不会增强代码的组织性,而是增强了官僚性。只在一个顶层模块或者数据结构中编写代码并无不妥。如果你的代码看起来像
spam.eggs.bacon.ham()
或spam['eggs']['bacon']['ham']
,那它就太过复杂了。稀疏胜于密集。程序员经常喜欢把尽可能多的功能塞进尽可能少的代码中,就像下面这一行:
print('\n'.join("%i bytes = %i bits which has %i possiblevalues." % (j, j*8, 256**j-1) for j in (1 << i for i in range(8))))
。尽管这样的代码可以给朋友留下深刻印象,但会惹怒同事,因为他们得费尽心思理解这段代码。不要让代码一次做太多的事情。分散在多行的代码往往比密集的单行代码更容易阅读。这句箴言与“简单胜于复杂”类似。 可读性很重要。尽管对于那些从1970年就开始用C语言编程的人而言,
strcmp()
显而易见指的是“string compare”(字符串比较)函数,但现代计算机的内存足以让你编写完整的函数名称。不要从完整的名称中删除某些字母或者写过分简洁的代码。花点时间为变量和函数想出具有描述性且具体的名称。代码各个部分之间的空行与书中起分隔作用的段落一样,可以让读者知道哪些部分应该放在一起阅读。这句箴言与“美丽胜于丑陋”类似。特殊情况并没有特殊到打破规则的地步。不过,实用性胜于纯粹性。这两句箴言看起来相互矛盾。在编程过程中,有很多“最佳实践”值得程序员努力践行。一方面,绕过这些实践,快速实现需求的想法也许很诱人,但可能会导致一堆不一致、不可读的代码烂摊子。另一方面,妥协遵守一些规则可能会导致高度抽象、不可读的代码。比如,Java试图让所有代码都符合面向对象的范式,这往往会导致即使很小的程序也有很多模板代码。随着经验不断积累,你在这两条箴言之间取舍将会变得越来越容易。时间长了,你不仅可以学会遵循规则,而且将学会何时打破规则。
除非必要,否则错误不该被悄无声息地忽略。程序员经常忽略错误信息,但这并不意味着程序也是这样。当函数返回错误代码或
None
而不是提示异常时,“无声的错误”就会发生。这条箴言的意思是,程序快速失败和崩溃要比不提示错误并继续运行好。后来发生的不可避免的错误将更加难以调试,因为它们是在出现源头问题之后很久才被发现的。尽管你随时可以忽略程序引起的错误,但要确保这样做有充足的理由。面对模棱两可的问题,不要猜测。计算机使人类变得迷信,有句话是“重启计算机,包治百病”。但计算机没有神奇的魔法。代码未能正常执行是有明确原因的,只有通过仔细、批判性的思考才能解决问题。拒绝通过盲目尝试来解决问题,这样做往往只会掩盖问题,而不能真正解决问题。
应该有一个,最好只有一个明显的方法能使用。这是对Perl编程语言的座右铭“有不止一种方法能用”的抨击。事实证明,有三四种方法完成同样的任务是一把双刃剑:坏处是为了能读懂其他人写的代码,你不得不学会所有可能的写法;好处是在编写代码时,你可以灵活地使用多种写法。不过,这种灵活性是得不偿失的,最好只有一个明显的方法能使用。
除非你是荷兰人,否则这种方法可能并不那么显而易见。这是一句玩笑话。Python的创造者Guido van Rossum是荷兰人。1
有总比没有好。然而不经思考就做还不如不做。****2这两句箴言是说,运行速度慢的代码显然比不上运行速度快的代码。但是,多等待程序运行一会儿总比程序尽快运行完却发现结果是错的要好。
如果实现很难解释,它就是个坏主意;如果实现很容易解释,这可能是一个好主意。许多事情随着时间的推移变得越来越复杂,比如税法、恋爱关系、Python编程书。软件也不例外。这句箴言提醒我们,如果代码复杂到让专业人员无法理解和调试的程度,那就是坏代码。但是,很容易被解释的代码也不一定就是好代码。遗憾的是,写出尽可能简单的代码并非易事。
命名空间是一个很棒的主意,可以多用。命名空间是标识符的独立容器,用来防止命名冲突。例如,内置函数
open()
和webbrowser.open()
函数有相同的名字,但对应不同的函数。导入webbrowser
不会覆盖内置的open()
函数,因为这两个open()
函数存在于不同的命名空间,分别是内置命名空间和webbrowser
模块的命名空间。但要记住,扁平胜于嵌套。尽管命名空间确实很好,但你应该只为防止命名冲突而使用命名空间,而不是添加不必要的分
6.2 学着喜欢强制缩进
6.3 使用timeit
模块衡量性能
6.4 常被误用的语法
如果Python不是你的第一门编程语言,那么你可能会用其他编程语言的代码编写策略来写Python代码。或者因为不知道有更多既定的最佳实践,你学了一种并不常见的Python编写方式。这种不优雅的代码也能用,但你可以学习更多编写Python代码的标准方法以节省时间和精力。本节讲述了程序员常见的错误,以及该如何编写代码。
6.4.1 使用enumerate()
而不是range()
当在一个列表或者其他序列上循环时,一些程序员使用range()
函数和len()
函数生成从0到序列长度−1的索引整数。在这些for
循环中通常使用变量i
(代表index)。例如在交互式shell中输入下面这个不符合Python风格的示例:
1 |
|
range(len())
的传统写法比较直接,但不够理想,因为它的可读性不好。更好的做法是将列表或者序列传递给内置的enumerate()
函数,它将返回索引的整数值和当前索引对应的项。比如,可以编写下面这种Python风格的代码:
1 |
|
使用enumerate()
替代range(len())
可以让你的代码整洁一点。如果你只需要列表中的项而不需要索引,可以用下面这种Python风格的方式迭代列表:
1 |
|
调用enumerate()
并直接在一个序列上进行迭代要比使用传统的range(len())
方式好。
6.4.2 使用with
语句代替open()
和close()
open()
函数将返回一个文件对象,该对象包含读取和写入文件的方法。当操作完成后需要调用close()
方法释放文件,以便其他程序读取和写入。你可以单独使用这些函数,但这样做不符合Python风格。比如,将文本“Hello, world!”写入一个名为spam.txt的文件中:
1 |
|
这样编写代码可能会导致文件未被关闭,比如下面这个示例,如果try
块中出现了异常,程序就会跳过close()
调用:
1 |
|
在遇到以0为除数的错误时,程序会转移到except
块执行,跳过了close()
调用,且文件一直保持打开状态。这可能会导致文件出现损坏,而这个错误很难被追溯到try
块上。更好的做法是使用with
语句,它可以在执行顺序离开with
语句块时自动调用close()
。下面的Python风格的示例和本节第一个示例有相同的作用:
1 |
|
尽管没有明确地调用close()
,但当执行顺序离开这个块的时候,with
语句会自动调用它。
6.4.3 用is
跟None
做比较而不用==
==
相等运算符是比较两个对象的值,而is
身份运算符是比较两个对象的身份。第7章将解释值和身份的区别。两个对象可以存储相同的值,但它们是两个独立的对象,拥有不同的身份。将某个值跟None
比较时,绝大多数情况下应使用is
,而非==
。
在特殊情况下,如果使用了运算符重载,即使spam
指向None
,表达式spam == None
也会等于True
。spam is None
将检查spam
变量中的值是否真的是None
,由于None
是NoneType
数据类型唯一的值,因此在任何Python程序中只有一个None
对象。当变量指向None
时,is None
比较表达式总是为True
。第17章将描述==
运算符重载的具体细节,可先看看这个示例:
1 |
|
很少会以这种方式重载==
运算符,但为了以防万一,推荐一直使用is None
而非== None
,这是Python的惯用写法。
而且,不应该在值为True
和False
的情况下使用is
运算符。可以使用==
相等运算符将值与True
或者False
比较,比如spam == True
或者spam == False
。更常见的是根本不使用运算符和布尔值,把代码写成if spam:
或者if not spam:
,而不是if spam == True
或if spam == False
。
6.5 格式化字符串
几乎每个使用不同编程语言编写的计算机程序中都有字符串。这种数据类型很常见,所以Python中有许多操作和格式化字符串的方法。本节将重点介绍一些最佳实践。
6.5.1 如果字符串有很多反斜杠,请使用原始字符串
转义字符允许你在字符串字面量中插入原本不能包含的文本。例如在'Zophie\'s chair'
中,需要反斜杠\
,这样会使第二个单引号成为字符串的一部分,而不是表示字符串到此结束。因为反斜杠具有这种特殊的转义作用,所以如果真的想在字符串中放入一个反斜杠字符,那么必须以\\
的形式输入。
原始字符串是具有r
前缀的字符串字面量,它们不把反斜杠视为转义字符,而是作为普通字符。比如下面这个Windows文件路径的字符串需要多个转义的反斜杠,这和Python风格不同:
1 |
|
而下面这个原始字符串(注意带有r
前缀)提供相同的字符串值,它的可读性更好:
1 |
|
原始字符串并不是一种不同的字符串数据类型,它只是用来输入包含多个反斜杠字符的字符串字面量的便捷方式。它常用来输入正则表达式或者Windows文件路径的字符串。这些字符串中经常有多个反斜杠字符,如果逐个使用\\
转义会费时费力。
6.5.2 使用f-string格式化字符串
字符串格式化,也被称为字符串插值,用来创建嵌套其他字符串的字符串,此方法的发展历史很长。3最初是使用+
运算符将字符串连接在一起,但这导致代码中出现很多引号和加号,比如'Hello, ' + name + '. Today is ' + day + ' and it is ' + weather + '.'
,而%s
转换格式符的出现则简化了语法:'Hello, %s. Today is %s and it is %s.' % (name, day, weather)
。这两种方法都将name
、day
和weather
变量中的字符串插入到字符串字面量中以得到一个新的字符串值,比如'Hello, Al. Today is Sunday and it is sunny.'
。
3伴随着Python的发展,Python提出了多种字符串格式化方法。——译者注
format()
字符串方法添加了格式规范迷你语言,它使用{}
括号对,与%s
转换格式符使用方式类似。不过这个方法有些复杂,可能会产生不可读的代码,所以我不推荐使用它。
从Python 3.6开始,f-string(format string的缩写)提供了一种更方便的方法来创建嵌套其他字符串的字符串。类似于原始字符串会在第一个引号前使用前缀r
,f-string使用前缀f
。可以在f-string的大括号中加入变量名称,以插入存储在这些变量中的字符串:
1 |
|
大括号中也可以包含完整的表达式:
1 |
|
如果要在f-string中包含大括号字符,可以使用额外的括号来转义它:
1 |
|
由于可以把变量名和表达式直接写在字符串内,因此代码的可读性比旧的字符串格式化方法强。
格式化字符串有这么多方法,这似乎违背了“Python之禅”的箴言:“应该有一个,最好只有一个明显的方法能使用。”但在我看来,f-string是对语言的一种改进,而且正如另一条准则所说,“实用性胜于纯粹性”,所以它无可厚非。如果你只用Python 3.6或者更高版本编写代码,请使用f-string。如果你写的代码是由早期的Python版本运行,那就继续用format()
方法或者%s
转换格式符。
6.6 制作列表的浅副本
使用slice
语法可以很容易地基于现有的字符串或者列表创建新的字符串或列表。在交互式shell中输入以下内容看一下:
1 |
|
要使用冒号对开始索引位置和结束索引位置进行分隔,以使内容从旧列表复制到新列表中。当省略冒号前的起始索引时,比如'Hello, world!'[:5]
,起始索引默认为0。同理,当省略冒号后的结束索引时,比如['cat','dog','rat','eel'][2:]
,结束索引默认为列表结尾。
如果两个索引都被省略,起始索引是0(列表开头),结束索引是列表结尾,这实际上会创建一个列表的副本。
1 |
|
注意,spam
和eggs
指向的列表对象的身份是不同的。eggs = spam[:]
创建了spam
列表的浅副本。而eggs = spam
只复制了列表的引用。[:]
这种写法看起来有些奇怪,而使用copy
模块的copy()
函数创建列表的浅副本会有更好的可读性:
1 |
|
你应该了解这个奇怪的语法,以避免在读到这样的Python代码时感到费解,但我不建议在代码中使用它,因为[:]
和copy.copy()
都能创建浅副本。
6.7 以Python风格使用字典
字典的键−值对(第7章将进一步讨论)可以维护一份数据到另一份数据的映射,这种灵活性使其成为很多Python程序的常用数据类型。因此,了解Python代码中常用的字典用法大有益处。
如果想进一步了解字典,可以参考Brandon Rhodes的关于什么是字典,以及它如何工作的演讲。他在PyCon大会上所做的“The Mighty Dictionary”(强大的字典)和“The Dictionary Even Mightier”(更强大的字典)演讲应该对你有所帮助。
6.7.1 在字典中使用get()
和setdefault()
试图访问一个不存在的字典键会导致KeyError
。为了避免它,程序员经常会写出一些不符合Python风格的代码,比如这样:
1 |
|
这段代码检查numberOfPets
字典中是否存在一个为字符串“cats”的键。如果存在,print()
调用会获取numberOfPets['cats']
的值作为信息的一部分展示给用户。如果不存在,另一个print()
调用不会访问numberOfPets['cats']
,而是展示其他字符串,这样就不会显示KeyError
。
由于这种代码模式很常见,因此字典提供了get()
方法,允许当键不存在的时候返回指定的默认值。下面这段Python风格的代码跟前面的代码功效相同:
1 |
|
numberOfPets.get('cats', 0)
这个调用会检查numberOfPets
字典是否存在'cats'
键。如果存在,该方法返回'cats'
键对应的值,如果不存在则返回第二个参数,也就是0
。使用get()
方法指定键不存在时返回的默认值,要比使用if-else
语句简单明了。
另一种情况是,当键不存在时,为其设置默认值。如果字典numberOfPets
没有'cats'
键,numberOfPets['cats'] += 10
会导致KeyError
。你可能想预先检查键是否缺失,如果缺失则为其设置默认值:
1 |
|
这种模式也很常见,字典提供了一个符合Python风格的setdefault()
方法。下面这段代码与前一段等效:
1 |
|
如果你还在用if
语句检查字典中是否存在某个键,在键不存在时设置默认值,请使用setdefault()
代替。
6.7.2 使用collections.defaultdict()
设置默认值
使用collections.defaultdict()
可以彻底避免KeyError
。导入collections
模块并调用collections.defaultdict()
,传递数据类型作为默认值,就可以创建一个默认的字典。比如,通过向collections.defaultdict()
传递int
可以创建一个类似字典的对象,当键不存在时,使用0作为默认值。在交互式shell中输入以下内容:
1 |
|
注意,你是在传递int()
函数,而不是调用它,所以要省略int()
中的括号。正确的写法是collections.defaultdict(int)
。也可以传递list
,使用空列表作为默认值。在交互式shell中输入以下内容:
1 |
|
如果需要对任意一个键设置默认值,使用collections.defaultdict()
要比使用常规字典再反复调用setdefault()
方便得多。
6.7.3 使用字典代替switch
语句
Java之类的语言有switch
语句,与if-elif-else
类似,用来根据变量是多个可能值中的哪一个来执行不同的代码。Python没有switch
语句,所以Python程序员有时会写出下面这个示例中的代码。这段代码根据season
变量的不同值来执行不同的赋值语句。
1 |
|
这段代码符合Python风格,但是有点啰唆。Java的switch
语句默认会有fall-through特性,如果块不用break
语句结束,就会继续执行下一个块。忘记添加break
语句是个常见的错误来源。这个Python示例中所有if-elif
语句的功能都比较类似,对于这种情况,一些Python程序员更喜欢通过字典来做这种工作。下面这段简洁的代码与上一个示例功效一致:
1 |
|
这段代码只是一个简单的赋值语句。holiday
中存储的值是get()
方法调用的结果。如果字典中存在season
的值对应的键,则返回对应的值,不存在时则返回'Personal day off'
。使用字典会让代码更加简洁,但也可能降低可读性。所以,是否使用这种习惯用法取决于你。
6.8 条件表达式:Python“丑陋”的三元运算符
三元运算符(在Python中的正式说法是条件表达式,有时也被称为三元选择表达式)是根据条件将某个表达式推导为两个值中的某一个值。通常,它是使用Python风格的if-else
语句实现的:
1 |
|
“三元”的本意是运算符有3个输入,但它在编程中的含义类似于条件表达式。条件表达式也能为这种模式提供一个更简洁的一行代码版本。在Python中,它们是通过关键字if
和else
的奇特组合实现的:
1 |
|
valueIfTrue if condition else valueIfFalse
❶这段表达式在condition
值为True
时结果为valueIfTrue
,在condition
值为False
时值为valueIfFalse
。Guido van Rossum开玩笑地将这种语法设计称作“故意的丑陋”。大多数语言的三元运算符是先列出条件,之后再是条件为真时的值和条件为假时的值。任何使用表达式或者值的地方都可以使用条件表达式,包括作为函数调用的参数❷。
为什么Python会在2.5版本中引入这种语法,即使违背了“美丽胜于丑陋”这条准则?因为虽然这种写法可读性不强,但很多程序员使用三元运算符,并希望Python也支持这种语法。巧用布尔运算符的短路运算可以创建一种三元运算符。表达式condition and valueIfTrue or valueIfFalse
,在condition
为True
时结果为valueIfTrue
,反之为valueIfFalse
(实际上有一重要的例外,下面会讲到)。在交互式shell中输入以下内容:
1 |
|
这种condition and valueIfTrue or valueIfFalse
风格的伪三元运算符有一个不易察觉的错误:如果valueIfTrue
是一个假值(如0
、False
、None
或空白字符串),即使条件为True
,表达式的结果还是会为valueIfFalse
。但这种伪三元运算符还在被程序员使用。“为什么Python没有三元运算符?”这是Python核心开发者经常被问到的问题。条件表达式的出现就是为了响应程序员想要三元运算符的呼声,避免程序员继续使用容易出错的伪三元运算符。但程序员也不愿意使用“丑陋”的条件运算符。尽管“美丽胜于丑陋”,但“实用性胜于纯粹性”,这就是Python“丑陋的三元运算符”体现的价值。