1.4 了解Python
Python和社会语言一样,有着自己的表达方式和表达逻辑,只不过它是“说”给计算机听的,我们在阅读和编写Python代码之前需要先了解它的思考和表达方式。
之前我们简单介绍了Python的基本用法并搭建了Python的开发环境。在本节,我们将介绍Python的一些基础语法和运行机制,为今后编写Python代码建立底层认知。
本节可能稍显晦涩难懂,初学时可以略读或者跳过,待有一定代码经验后再来精读,相信会给你豁然开朗的感觉。
1.4.1 代码行
我们编写的未被Python解释器解析执行的代码叫源代码。我们在编写代码时是一行一行编写的,几乎所有的IDE左边都会有行号(JupyterLab可从View菜单中开启行号显示),行号对应一个物理代码行或物理行,但Python解释器在分析源代码时,会将多个物理行解析成一个代码行,我们称之为逻辑代码行或逻辑行。Python按逻辑行执行代码。
新增一个物理行只要按回车键即可,通过拼接可以将多个物理行转换为一个逻辑行,此时Python会认为它们是一个完整的逻辑。
物理行拼接有显式拼接和隐式拼接两种方式。显式拼接指在物理行尾用反斜杠(\)拼接,如以下判断一个日期是否有效的代码,由于表达式过长,可以通过反斜杠将多个物理行转换为一个逻辑行。
反斜杠只能用在代码物理行尾和字符串字面值中,在字符串字面值中可以将两个物理行的字符串拼接在一起,下文会讲到这个规则。除此之外,反斜杠在其他地方都是非法的。
另一种拼接方式是隐式拼接。圆括号(元组、表达式和调用传参等)、方括号(列表)、花括号(字典和集合)中的代码可以分成多个物理行,而不必使用反斜杠,这属于隐式拼接。这样做的好处是代码更加易读,且能在物理行尾增加注释(反斜杠后不能加注释)。以下代码演示了这些情况。
物理行拼接可以帮助我们避免编写超宽的物理行。在Python的PEP8规范(一个Python官方倡议的代码编写风格)中,一个物理行一般不能超过79个字符。如果你所在团队有相应的约束,也应当遵守。
我们的代码是写给计算机看的,而注释是写给人看的。注释的读者是阅读你代码的其他人和你自己。物理行中井号(#)之后的所有内容都是注释,Python不会解析。井号可以在物理行的开头也可以在行后,即支持单独一行注释和一行后半部分注释(参见上文代码)。再次强调,Python只有单行注释,没有多行注释,多行注释需要用三引号包裹多行字符串间接实现。
和注释一样,空白行(仅包含空格符、制表符、换页符)的逻辑行也会被Python忽略。在标准的交互模式下,完全空白的逻辑行(即连空格或注释都没有)将结束多行复合语句。
有时你在阅读代码时可能会看到Python文件第一、二行有类似以下的注释:
这是在声明Python的解释器和代码文件编码,不过这是Python的历史遗留问题,在Python 3中默认的编码已经是utf-8编码了,因此我们不必增加这两行内容。
1.4.2 缩进
缩进是Python的特色,当其他大部分语言用花括号({})组织逻辑层次时,Python为了让代码更加简洁而设计了缩进方式。PEP8建议在上一个逻辑行后缩4个空格,但所有的Python编辑器已经支持自动缩进,当语法需要缩进时,回车换行会帮助我们自动缩进,如果需要手动缩进可以按Ta b键,按一次缩进一层。
常用的定义函数、for循环等语句都需要缩进,例如:
函数say()中的print()相对于def关键字缩进一层,for语句中的if等相对于for关键字缩进一层,print()相对于if等又缩进一层,从而表达代码的逻辑关系。哪些语句需要缩进,本书后续会一一介绍。
对于初学者来说缩进是比较难掌握的知识点,学会正确处理缩进意味着你已经迈入Python的门槛。在代码编写中,我们要结合代码编辑器的自动格式化功能严格布局缩进和上下行空格,让代码更加美观易读。
1.4.3 标识符
我们说过,在Python中,一切皆是对象,而对象存在某个内存地址里,在使用对象进行操作时,为了方便起见我们给这个内存地址起一个名,这个名的符号表达就是标识符,因此标识符又称名称。
标识符是内存地址的名称,当然也可以把标识符换为别的内存地址,标识符与内存地址绑定的过程就是赋值操作。以下代码演示了一个名称的使用过程:
在赋值表达式中,对象10先绑定了名称a,用Python内置函数id()获取其内存地址,然后将a换绑(再次赋值)到对象20,再看它的内存地址已经发生了变化。这里的10和20是整型,它们都是对象,占用不同的内存空间。我们定义的函数say()的名称是say,它所指向的函数对象也占有一定的内存空间。甚至内置函数id()也是一个对象,我们可以使用id(id)传入它的名称id,也能返回它的内存地址。
Python标识符和其他语言中的变量是不同的,比如C语言中的变量表示一段固定的内存地址,而Python的标识符更像一个标签,可以贴在任何对象上。尽管这样,很多人还是习惯将Python的标识符称为变量,今后当你听到Python的变量时,应该知道它指的就是标识符或者名称。
在不同的场景下,标识符、名称、名字、变量名等其实是一回事。
1.4.4 标识符命名
标识符用于给对象起名,即所谓的变量、函数、类等的名字都是标识符。标识符起名必须符合一定的规范。有效标识符可以包含大小写字母、下划线(_)、数字0~9,但不能以数字开头,也不能包含其他特殊符号(如.、!、@、#、$、%等)。Python 3.0引入了ASCII(American Standard Code for Information Interchange,美国信息交换标准代码,一套电脑编码系统,主要用于显示现代英语)之外的更多字符,如汉字、其他语种的字符,不过还是建议用英文作为标识符。
标识符是区分大小写的,如age、Age、agE是不同的标识符。另外,Python的关键字如if、is、import等作为保留标识符号,不能用于我们自定义的标识符。可以导入内置模块keyword查看Python的关键字:
另外还有一些被称为软关键字的关键字,它们只在特定上下文中作为关键字,如上述列表中没有的match,只有在模式匹配操作中连同case以及下划线一起它才具有关键字的语义。
以下列出了一些合法和不合法的标识符,合法的标识符并不一定是符合规范的。
除了关键字,内置函数名、内置库或者知名库的名称,以及这些库的常量、方法名、内置异常对象等,无论大小写,也不要作为标识符,如果一定要用的话,应该在后面加一个下划线,如len_、bool_。还可以使用与关键字相似的单词(或者是读音、其他语言派生等)代替,如class用klass代替。也可以用约定俗成的foo、bar、baz、qux等为不特定对象命名。
判断字符串是不是合法的标识符,可以用字符串的isidentifier()方法:
虽然关键字是合法的标识符,但我们不能自己使用。可以用keyword.iskeyword()来判断一个标识符是不是关键字。
在实践中,标识符的起名应该遵照以下约定。
❑ 多个单词组合时用下划线连接,每个单词都用小写,如user_name。
❑ 特别地,类名以大写字母开头,不用下划线连接,如CamelCase。
❑ 使用下划线作为第一个字符来声明私有标识符。
❑ 不要在标识符中使用下划线同时作为前导和尾随字符(如_name_),因为Python内置类型已经使用了这种表示法。
❑ 正式代码中避免使用只有一个字符的名称,而应使用有意义的名字。
❑ 常量(执行过程中永远不会变化的量)的所有字母都大写。
❑ 不使用内置命名空间的名称,可导入builtins模块(import builtins)并用dir(builtins)查看。
❑ 学习PEP8规范,里面有更为详细的建议。
另外,还有一些特殊场景下的标识符命名规则和注意事项。
❑ 脚本中名称以下划线开头的全局对象(_*)用from module import *导入时,不会被导入。
❑ 开头和结尾双下划线的名称(__*__)是Python中对象的特殊方法,用于定义内置函数和操作所执行的方法,如len(x),就是执行对象x的__len__()方法。
❑ 类中的方法如果是双下划线名称(__*),会被自动转换为_类名__标识符,以免与有继承关系的私有属性发生冲突。
以上这些我们现在可能不是太理解,后文在介绍面向对象编程时还会详细讲解。在命名时,需要特别注意单下划线开头和两端双下划线的名称。
1.4.5 名称的使用
Python标识符包含很多用户定义的名称,这些名称通过绑定来表示变量、函数、类、模块或任何其他对象。如果在Python中为一个对象实体指定了某个名称,那么就说它是一个标识符,在后续的代码中可以通过名称来使用它。在Python中,标识符的命名规则都是一样的。
准确地说,这些名称在以下场景会进行绑定,大家可以先大概了解一下,后续会详细介绍这些操作。
❑ 赋值语句,定义一个对象,如a=10中的a。
❑ 定义函数,函数的名称,如def func()中的func。
❑ 函数的形式参数,如def func(name, age)中的name和age。
❑ 定义类,类和名称,如class Person()中的Person。
❑ import语句中的as别名,如import numpy as np中的np。
❑ for循环的循环头,如for item in items中的item。
❑ 赋值表达式(海象运算符操作),如if(n:=len(a))>10中的n。
❑ with、except语句as后面的名称。
❑ match case模式匹配语句中的模式原型。
名称通过绑定来指代一个对象,这个名称可以应用在任何使用这个对象的地方。之前我们也讲过名称可以换绑成其他对象,原对象会处于无名称的境地。del语句可以解除对象与名称的绑定关系,也会让对象没有名称。对象没有了名称,我们就再也无法使用它,对象将等待垃圾回收机制进行回收,对象最后会消亡,释放相应内存。
下面是一个简单的示例,我们先绑定了名称,最后解除绑定,程序无法再引用此名称,抛出一个NameError名称错误。
最后一个比较特殊的名称是单下划线(_)。对于Python而言它并没有什么特别的,也是合法的标识符,但是交互式解释器会将最后一次求值的结果放到这个变量(_)中,这个标识符存储在内置模块builtins中。
以下为打开终端,执行python命令进入纯净的Python交互式环境中的测试代码:
用dir()获取builtins的所有属性,可以看到有一个单下划线名称。这个机制让我们在交互模式下查看最后一次求值结果时,不用写其名称,直接输入一个下划线即可。在JupyterLab上,由于其解释器内核是IPython,它虽然不将下划线指定在builtins中,但它对下划线做了增强,同时支持两个和三个下划线,分别是倒数第二、倒数第三个求值结果。
下划线的另一个常见用途是伪装名称,表示不会使用的变量(有人喜欢用useless)。有些场景下,我们不需要接纳相应的对象或者只是临时接纳一个对象,可以用下划线来代替。参考以下代码:
在以上场景下,下划线是一个非常好用的名称。
1.4.6 常量和字面量
在编程中,需要区分字面量、变量、常量这三个概念,其中常量是我们构建基础数据的来源,是对生命周期全局进行控制的量,变量(标识符)是方便我们操作各种对象的引用。
字面量(literal),或者叫作字面值,就是表示它本来的意思,也就是字面意思。比如字符串'hello'、数字888。Python中的字面量有字符串、字节串、数值(整数、浮点数、虚数)、省略号(...)这几种,字面量是内置类型常量值的表示法。
定义一个字面量就是创建一个对应类型的对象,这个对象可以再去参与构造元组、列表、字典等类型的数据对象,成为这些容器中的元素。字符串、字节串可以拼接,数值可以计算,从而形成新的字面量。我们在后面讲这些数据类型时会详细介绍字面量的写法。
列表、集合、字典以及相应的推导式(一种在它们内部写for循环的方法而生成对应类型的写法)所使用的以方括号([])和花括号({})来表示容器直接构造数据的方式,Python官方文档称为显示(display),这是一种特殊的句法。
变量(variable)在Python语境下表达会变化的量,给人一种感觉是用来保存字面量、对象等内容,比如age=18中的age经常被称为变量,但在Python中它是标识符,并不存储内容,而用于指示对象的内存地址。当新的对象指向age时,如age='20岁',age的输出值换成了一个字符串,给人感觉age的存储内容是变化的,但其实只是它的指向发生了变化。
常量(constant)是指不会变化的量,在程序执行过程中值始终保持不变。程序级别的常量在程序执行的周期内不会发生改变,比如在一个爬虫程序中,变量URL设置为要抓取的网址,在运行过程中URL的值保持不变,但在下次执行时URL可以更换为其他网址。语言级别的常量是语言自身定义的,编写代码的人不能修改,无论何种情况它都取这个值。
Python的内置常量见表1-5。
表1-5 Python的内置常量
这些常量在内置的命名空间中,不能给它们赋值,否则会引发SyntaxError(语法错误)。它们中有些还是关键字,因此我们无须定义,可以直接使用。
1.4.7 表达式
Python代码由各种表达式(expression)和语句(statement)构成。表达式是可以求出某个值的语法单元,一般包含字面值、名称、属性访问、运算符或函数调用等,它们最终都会返回一个值。
语句是一个代码块,可以由表达式或关键字(如if、while或for)构成。因此,并非所有的语言构件都是表达式,还存在不能被用作表达式的语句,如while。赋值属于语句而非表达式。
Python的主要表达式见表1-6。
表1-6 Python的主要表达式
在交互模式下,如果结果值不为None,它会通过内置的repr()函数转换为一个字符串,以单独一行的形式输出,如果结果为None,则不产生任何输出。
表达式从左到右求值,要注意的是赋值操作求值时,右侧会先于左侧被求值。如下示例中,expr后边的数字是它们的求值顺序:
各个表达式如果同时存在的话,会存在优先级的问题。表1-7按从高到低、从上到下和从左到右的顺序列出了Python运算符的优先级。
表1-7 Python运算符的优先级
注意,-1其实是一个位运算,数字没有负数的字面量。幂运算是从右至左分组,如2**-1值为0.5,先计算一元位运算-1得到负值,再与2进行幂运算。%运算符也被用于字符串格式化,此时它们的优先级相同。
如果自定义数据类型要支持这些运算符,可以在类中实现对应的特殊方法(对象方法标识符以两端下划线命名),比如,对于加号运算符,需要实现obj.__add__(self, other)特殊方法。
关于各个表达式的编程意义和详细语法,后文会详细介绍。了解了表达式,我们再来看看Python的语句。
1.4.8 语句
表达式专指能够计算出值的语句,它可以用等号赋值给一个变量,但我们日常说的语句侧重于做某件事,逻辑更加复杂。Python的语句分为两大类:简单语句和复合语句。
简单语句由一个单独的逻辑行构成,表达式就属于简单语句。Python的简单语句见表1-8。
表1-8 Python的简单语句
多条简单语句可以存在于同一行内并以分号分隔,这样我们写简单语句时无须进行物理行换行,比如a=1; print(a)或者import math; math.pi。
赋值语句支持增强赋值,如a+=1表示a加1再赋值给a,在等号前增加了运算符。支持的运算符有+=、-=、*=、/=、//=、%=、@=、&=、|=、^=、>>=、<<=、**=。
赋值语句还支持带标注赋值,如a:int=9,在标识符后增加了冒号和类型名称,表示引用的值的类型,这将使得我们在阅读代码时知道变量的类型,现代IDE也会更好地为我们提示代码。
以下是赋值语句的一些示例:
复合语句一般包含多个语句,是指包含、影响或控制一组语句的代码。通常复合语句(如if、try和class语句等)会跨多行,虽然在某些简单形式下整个复合语句也可能只占一行。
if、while和for语句用来实现一般的流程控制;try语句用来指定异常处理的机制和清理代码;with语句代码块在开始前和结束后两处执行初始化与终结化代码;我们经常使用的函数和类定义在语法上也属于复合语句。
Python的复合语句见表1-9。
表1-9 Python的复合语句
一条复合语句由一个或多个子句组成,子句包含句头和句体,句头处于相同的缩进层级。每个子句头以一个作为唯一标识的关键字开始并以一个冒号结束。子句体是由一个子句控制的一组语句,可以是在子句头的冒号之后与其同处一行的一条语句或由分号分隔的多条简单语句,也可以是在其之后缩进的一行或多行语句。
关于各个语句的具体语法,我们会在后文中详细介绍。
1.4.9 命令行执行
之前我们介绍过在交互模式下运行Python程序,你可以在终端中执行python或ipython(需要用pip安装)命令进入交互模式,还可以在JupyterLab提供的浏览器界面中完成交互式代码的执行。如果你使用的是VS Code或PyCharm这样的IDE,会让你创建一个扩展名为py的Python脚本文件。它们提供了运行(一般叫run)按钮,单击该按钮它们会自动在终端中用命令执行脚本文件。
如果想自己在终端中执行脚本,实现一些功能,就有必要了解如何用命令行执行脚本。关于如何进入终端可以参考1.3.3节。
启动执行脚本文件的最常见的命令是:
执行命令后,就能看到执行结果。如果脚本不在当前位置或者不想使用相对定位,可以先用cd命令并配合ls命令定位查看,确定脚本所在的目录。假设我们编写了一个名为hello.py的脚本:
保存后,在终端执行(输入命令后按回车键),就会输出结果:
脚本执行完成后会自动退出执行,如果执行过程中想退出,在macOS系统中按Ctrl+D组合键,在Windows系统中先按Ctrl+Z组合键,再按回车键。有时,需要保存脚本的输出供以后分析用,可以执行以下命令中的一个:
如果output.txt不存在,则会自动创建;如果已经存在,第一行中,内容将被每次新的输出替换,第二行将输出添加到output.txt的末尾。
之前讲过的内置模块builtins中,有一个__name__名称。如果模块被用import语句等方式导入,那么它的值是模块名(文件名);如果直接执行这个模块(此文件),那么它的值就是__main__。这也是我们经常在Python脚本文件中看到以下代码的原因。
如果是单一的脚本文件,我们也可以不判断它的__name__,不增加这段代码。不过,有了这段代码,我们可以在命令行执行时不写文件扩展名,用python-m hello来运行这个代码中的逻辑。这就允许我们用命令行来使用一些内置的以及第三方库的功能,比如:
以下是一些通用的功能:
我们在编写脚本时还可以支持在命令行中传入一些参数作为控制变量,这些内容将在后文中专门介绍。
1.4.10 执行模型
接下来探究一下Python是怎么执行的,虽然我们不需要知道太多细节,但是理解Python的执行机制能让我们在写代码时做到心中有数。
简单来说,Python解释器会将源代码分块,并将这些代码块放在一个叫帧(frame)的容器里,帧包含它的前续帧、后继帧以及调试信息,这些帧按照调用次序叠放在一个栈(stack)的结构里。栈的特点是后进先出(Last In First Out, LIFO),就如同一堆盘子一样,使用时先取顶部的,而顶部的总是后堆放的。
代码块是被作为一个单元来执行的一段Python程序文本。被认为是代码块的内容大致如下:
❑ 交互模式下执行的每条命令都是一个代码块;
❑ 模块、函数体和类定义分别是一个代码块;
❑ 脚本文件整体是一个代码块;
❑ 内置函数eval()和exec()被传入的字符串与代码对象是一个代码块。
Python中的对象内容存放在一个叫堆(heap)的地方,堆是没有顺序的,系统会通过复杂的算法,根据程序的需要合理分配对象存放的地方,让内存使用更高效。栈和堆都是内存中开辟出的一块区域。访问堆里的对象只能通过与之绑定的名称。Python有着良好的垃圾回收机制,对对象数据进行有效的生命周期管理,及时释放内存空间。如图1-6所示,名称和对象分别在栈和堆里,其中名称a和b都指向同一个对象。
图1-6 堆、栈区分及功能图
Python解释代码时会从代码的逻辑入口开始,根据调用关系从上到下分析代码,最终形成调用栈,再从顶部开始执行这个调用栈。我们来看以下代码及其执行过程:
Python Tutor(http://www.pythontutor.com)是一个对Python运行原理进行可视化分析的工具,我们将以上代码放到Python Tutor上测试它的执行步骤。图1-7是代码执行步骤的拆解。
图1-7 代码执行步骤拆解
首先将所有的名称按顺序载入,然后根据代码中逻辑调用关系形成的调用栈main()→fun_a()→fun_b()来执行,右边为栈的顶端,main()在栈底,最后由main()返回数据。
正是Python做了相关的优化、适配、调试等工作,我们才能无须关心一些计算机的底层细节,将精力放在逻辑代码的编写上。如果想更加详细地了解相关内容,可以访问https://www.gairuo.com/p/python-program-work-principle。
1.4.11 小结
本节中我们了解了Python语言最基础的语法——代码行、缩进和标识符,这些内容是Python在语法上最大的特色,也是我们学习Python首先要掌握的。关于PEP8规范可以参阅https://www.gairuo.com/p/python-pep8。
虽然对于初学者来说本节中有很多地方难以理解,但可以按照本节中的大纲在后边的内容中深入学习,筑牢Python基础,为今后的进阶学习、编程应用提供强有力的保障。