29 - 异常处理-模块化
1 异常
在程序运行过程中,总会遇到各种各样的错误。有的错误是程序编写有问题造成的,比如本来应该输出整数结果输出了字符串,有的错误是用户输入造成的,比如让用户输入email地址,结果得到一个空字符串,这种错误可以通过检查用户输入来做相应的处理。还有一类错误是完全无法在程序运行过程中预测的,比如写入文件的时候,磁盘满了,写不进去了,或者从网络抓取数据,网络突然断掉了。这类错误也称为异常,在程序中通常是必须处理的,否则,程序会因为各种问题终止并退出。
1.1 产生异常
在Python中产生异常主要有两种方式:解释器出触发和手动触发
当异常被触发后,触发异常的代码段的后续代码将不会被继续执行。如果存在于顶层命名空间中,可能还会导致整个程序退出。
1.1.1 解释器触发异常
什么情况下解释器会触发异常?举个例子:
print('hello world)
print(1/0)
当解释器一行一行解释时,由于print函数少些一个引号,不符合Python语法规范,所以会提示SyntaxError,严格来说的话,语法错误不算一种异常,在解释阶段就无法通过,所以没办法进行捕获,其他的还比如IndentationError,因为是SyntaxError的子类, 而print(1/0)的除数不能为零,符合Python语法规范,只是在代码运行时触发了ZeroDivisionError异常,这种在运行时触发的异常,我们都是可以捕获的。
1.1.2 手动触发异常
在代码中,使用raise
关键字,可以手动触发异常,当检测某些数据类型,不符合预期时,就可以手动的触发,它的格式如下:
raise 异常类型
raise 异常类型()
raise 自定义异常
raise 自定义异常()
手动触发异常:
def add(x,y):
if isinstance(x, int) or isinstance(y, int):
raise TypeError
return x + y
函数add期待两个类型为int的参数,如果我们传递'a','1'时,由于Python语言的特性,预期的结果就可能会不同,如果后续代码依赖该函数的返回值,那么就可能会引发后续代码的异常报错,不利于排查。
raise关键字可以触发异常类,也可以触发异常类实例,其实当我们触发异常类时,实际上raise会帮我们实例化,然后触发。异常类也可以传参,其本质上,可以理解为:
raise Exception == raise Exception()
class Exception:
def __init__(self, message='Exception', code=200):
self.message = message
self.code = code
......
不传递参数时,使用默认属性初始化,传递参数时,则参数进行实例初始化赋值。
1.2 异常类型
Python内置了大量的异常类,用于标识不同的异常以及分类。如下
BaseException
+-- SystemExit
+-- KeyboardInterrupt
+-- GeneratorExit
+-- Exception
+-- StopIteration
+-- StopAsyncIteration
+-- ArithmeticError
| +-- FloatingPointError
| +-- OverflowError
| +-- ZeroDivisionError
+-- AssertionError
+-- AttributeError
+-- BufferError
+-- EOFError
+-- ImportError
| +-- ModuleNotFoundError
+-- LookupError
| +-- IndexError
| +-- KeyError
+-- MemoryError
+-- NameError
| +-- UnboundLocalError
+-- OSError
| +-- BlockingIOError
| +-- ChildProcessError
| +-- ConnectionError
| | +-- BrokenPipeError
| | +-- ConnectionAbortedError
| | +-- ConnectionRefusedError
| | +-- ConnectionResetError
| +-- FileExistsError
| +-- FileNotFoundError
| +-- InterruptedError
| +-- IsADirectoryError
| +-- NotADirectoryError
| +-- PermissionError
| +-- ProcessLookupError
| +-- TimeoutError
+-- ReferenceError
+-- RuntimeError
| +-- NotImplementedError
| +-- RecursionError
+-- SyntaxError
| +-- IndentationError
| +-- TabError
+-- SystemError
+-- TypeError
+-- ValueError
| +-- UnicodeError
| +-- UnicodeDecodeError
| +-- UnicodeEncodeError
| +-- UnicodeTranslateError
+-- Warning
+-- DeprecationWarning
+-- PendingDeprecationWarning
+-- RuntimeWarning
+-- SyntaxWarning
+-- UserWarning
+-- FutureWarning
+-- ImportWarning
+-- UnicodeWarning
+-- BytesWarning
+-- ResourceWarning
不同的层级表示继承关系,常用的错误及其含义如下:
AttributeError # 试图访问一个对象没有的属性时
IOError # 输入/输出异常;基本上是无法打开文件
ImportError # 无法引入模块或包;基本上是路径问题或名称错误
SyntaxError # 语法错误
IndentationError # 代码没有正确对齐(SyntaxError的子类)
IndexError # 下标索引超出序列边界,比如当x只有三个元素,却试图访问x[5]
KeyError # 试图访问字典里不存在的键
KeyboardInterrupt # Ctrl+C被按下
NameError # 使用一个还未被赋予对象的变量
TypeError # 传入对象类型与要求的不符合
UnboundLocalError # 试图访问一个还未被设置的局部变量,基本上是由于另有一个同名的全局变量,导致你以为正在访问它
ValueError # 传入一个调用者不期望的值,即使值的类型是正确的,比如列表a=[1, 2, 3],而你执行了a.remove(4)
SystemExit # sys.exit()函数触发的异常,异常被解释器发现时,会退出执行
ArithmetieError # 所有算数引发的异常
LookupError # 使用映射的键或序列的索引无效的异常
Python提供的大部分运行时的错误异常,都继承自Exception类,所以我们自定义的异常类,也要从Exception类继承(当然也可以从BaseException继承,但不建议。)
1.2.1 自定义异常
自定义的异常类需要从Exception进行继承
class MyException(Exception):
def __init__(self,message='Not Exception',code=200):
super().__init__(message,code)
# 就等于
# class MyException(Exception):
# pass
try:
raise MyException('from MyException')
raise MyException
except:
print('没有异常')
由于异常有默认值,所以传递不传递参数都可
1.3 异常处理(捕获)
所谓异常处理就是:出现异常,捕获并处理异常。一个健壮的程序应该尽可能的的避免错误,尽可能的捕捉错误,处理各种异常。Python内置了一套异常处理机制,来帮助我们进行错误处理。其完整格式如下:
try:
代码段1 # 检测执行的代码段
except:
代码段2 # try代码段产生异常后,被捕捉到后执行的代码段
else:
代码段3 # try代码段中没有异常要执行的代码段(非必须)
finally:
代码段4 # try代码段中不管有没有异常,都要执行的代码段(非必须)
其中except语句可以有多个,捕捉不同类型的异常并进行相应的处理,并且还可利用as语句存储异常,所以except的扩展格式:
except: 表示遇到异常就捕捉。
except Exception: 表示只捕捉Exception类的异常,而Exception类包含了Python内置的所有异常,所以功能类似于捕捉所有。
except IndexError as e: 捕捉IndexError异常,并交给变量e存储。
需要注意的是: except子句必须和try在同一层,搭配使用,不能单独使用except
。下面来看一个小例子:
try:
a=[1,2,3]
a[4]
except:
print('一切正常',e)
else:
print('真的是一切正常')
finally:
print('我是 finally')
分析:
由于列表a只有3个元素,取列表第四个元素,会报异常(IndexError: list index out of range,从pycharm的console窗口就能看到),异常被except捕捉,所以会打印,一切正常,并打印错误信息。由于产生了异常不会执行else语句,最后执行finally语句。
1.3.1 多种捕获
实际上我们一段代码可能会有不同类型的错误,而我们需要针对不同类型的错误,做不同的操作,所以这个时候就会用到多except语句。
try:
a = [1,2,3,4]
a[7]
except IndexError as e:
print('索引错误',e)
except KeyError as e:
print('key错误',e )
except:
print('发生异常了')
分析:
异常被IndexError捕获,所以会打印索引错误。
多条except语句,优先级从上到下,和if条件的优先级是相同的,即如果捕获到一个,就不继续执行except语句了。利用多except语句,来对不同的异常进行处理,可以使我们的程序更健壮。
1.3.2 finally子句引发的问题
先来看下面的例子:
def test(x, y):
try:
print('from Test')
return x / y
finally:
return 200
a = test(1,0)
print(a)
# from Test
# 200
分析:
在test语句中由于除数为0,所以会触发ZeroDivisionError异常,由于finally是最后被执行的,还没有上报呢,就return了,这个时候异常就被压制了。
函数的返回值取决于最后一个执行的return语句,finally是最后被执行的语句。所以在finally语句中return是一个不明智的决定,除非你真的需要它。
1.3.3 异常的传递
当异常没有被处理时,就会向上层逐级上报。
def foo1():
foo2()
def foo2():
foo3()
def foo3():
raise Exception
foo1()
# Traceback (most recent call last):
# File "E:/Python - base - code/chapter08_OOP/module_and_package.py", line 15, in <module>
# foo1()
# File "E:/Python - base - code/chapter08_OOP/module_and_package.py", line 7, in foo1
# foo2()
# File "E:/Python - base - code/chapter08_OOP/module_and_package.py", line 10, in foo2
# foo3()
# File "E:/Python - base - code/chapter08_OOP/module_and_package.py", line 13, in foo3
# raise Exception
# Exception
异常在foo3中产生,上报到foo2中,foo2没有处理,上报到foo1中,foo1没有处理,继续上报,如果到了外层还没有被处理,就会中断异常所在的线程的执行。
try ... except 可以被嵌套使用
1.3.4 异常的捕捉时机
我们可以在可能产生异常地方立即捕获,或者在边界捕获。立即捕获很好理解,那什么是边界捕获呢?
- 例如:写了一个模块,用户调用这个模块的时候捕获异常,模块内部需要捕获、处理异常,一旦内部处理了,外部调用者就无法感知了。
- 例如:open函数,出现的异常交给调用者处理,如果打开的文件不存在,可以创建,文件存在了,就不再创建了,继续看是否修改又或者删除,又或者其他业务逻辑。
- 例如:自己写一个类,使用了open函数,但是出现了异常不知道如何处理,就继续向外层抛出,一般来说最外城也是边界,必须处理这个异常了,否则线程退出。
1.3.5 小结
try的工作原理:
- 如果try中语句执行时发生异常,搜索except子句,并执行第一个匹配该异常的except子句
- 如果没有匹配的except子句,异常将被传递到外层的try(如果有),如果外层不处理这个异常,将继续向外抛出,到达最外层,还没有被处理,就终止异常所在的线程
- 如果在try执行时没有发生异常,将执行else子句中的代码段
- 无论try中是否发生异常,finally子句都将会被执行
2 模块化
一般来说,编程语言中,库,包,模块是同一种概念、是代码的组织方式。Python中只有一种模块对象类型,但是为了模块化组织模块的便利,提供了'包'的概念。
- 模块module:指的是一个个Python的源代码文件
- 包package:指的是模块组织在一起的包名同名的目录及其相关文件
2.1 导入语句
Pythong主要提供了两类导入模块的方法。它们是import和from ... import。
2.1.1 import导入
语句 | 含义 |
---|---|
import n1,n2 | 完全导入 |
import x as x | 模块别名完全导入 |
导入过程:
- 找到指定的模块,加载和初始化它,生成模块对象。找不到,抛出异常
- 在import所在的作用域的局部命名空间中,增加名称与上一步创建的对象关联。
import os
print(list(filter(lambda name: not name.startswith('_'),dir()))) # 去掉_开头的方法
# ['os']
dir()返回当前名称空间内的名称。
(1)
import functools as ft
print(list(filter(lambda name:not name.startswith('_'),dir()))) # ['ft']
(2)
import os
print(os) # <module 'os' from 'D:\\Python3.6.6\\lib\\os.py'>
print(os.path) # <module 'ntpath' from 'D:\\Python3.6.6\\lib\\ntpath.py'>
print(list(filter(lambda name: not name.startswith('_'),dir()))) # ['os']
当我们使用as语句时,那么当前名称空间会仅存在于一个as后的别名,来指向模块了。os.path在打印时也是一个模块,那么我们能否只导入os模块下的path模块呢?
import os.path
print(list(filter(lambda name: not name.startswith('_'),dir()))) # ['os']
并没有导入os.path模块,只有os模块,这是为什么呢?
- 导入顶级模块,其名称(无别名时)会加入到本地命名空间中,并绑定到其模块对象上去。
- 导入非顶级模块,只将其顶级模块名称加入到本地名称空间中。
- 如果使用了as,as后的名称直接绑定到导入的模块对象上,并将该名称加入到本地命名空间中。
import 后面只能是包名,或者模块名
2.1.2 from导入
语句 | 含义 |
---|---|
from xx import xx | 部分导入 |
from xx import xx as xx | 别名部分导入 |
导入过程:
- 找到from子句中指定的模块,加载并初始化它们(不是导入)
- 对于import 子句后的名称
- 先查from子句加载的模块是否具有该名称的属性
- 如果不是,则尝试导入该名称的子模块
- 还没有找到,则抛出ImportError异常
- 这个名称保存到本地名称空间中,如果有sa子句,则使用as子句后的名称
from os import path
print(list(filter(lambda name: not name.startswith('_'),dir()))) # ['path']
from os import path as osp
print(list(filter(lambda name: not name.startswith('_'),dir()))) # ['osp', 'path']
from 后面只能是模块或者包名,import后面可以是包、模块、类、函数、甚至是某个变量
2.2 自定义模块
一个py文件就是一个模块。先不考虑模块查找顺序,看下面例子:
# 目录结构
# Learn_Python_fromn_Zero
# |--my_module.py
# |--my_script.py
(1) my_module.py
class MyClass:
def __init__(self, name):
self.name = name
def print(self):
print('{} say hello'.format(self.name))
def my_func(context):
print(context)
(2) my_script.py
import my_module
daxin = my_module.MyClass('daxin')
daxin.print() # daxin say hello
当然也可以通过from方式只导入某个类
from my_module import MyClass
daxin = MyClass('daxin')
daxin.print() # daxin say hello
需要注意的是,自定义模块的名称要遵循一定的规范:
- 模块名就是文件名。
- 模块名必须符合标识符命名要求,是非数字开头的字母数字下划线的组合。
- 不要使用那个系统模块名,否则会出现冲突覆盖的问题。
- 模块名通常全部为小写,可以添加_下划线。
2.3 模块搜索顺序
sys.path是一个列表,里面存放的就是模块搜索的路径
import sys
print(*sys.path,sep='\n')
# E:\Learn_Python_from_Zero\chapter08\_Package_and_module
# E:\Learn_Python_from_Zero
# D:\Python3.6.6\python36.zip
# D:\Python3.6.6\DLLs
# D:\Python3.6.6\lib
# D:\Python3.6.6
# D:\Python3.6.6\lib\site-packages
上面的结果就是我当前环境下Python模块的路径搜索顺序,当加载一个模块的时候,需要从这些路径中从前到后依次查找,但并不搜索这些目录的子目录,搜索到模块就加载,搜索不到就抛异常。
大致的路径顺序为:
程序主目录
,运行程序的主程序所在的目录PYTHONPATH目录
,环境变量PYTHONPATH设置的目录,也是搜索模块哦路径标准库目录
,Python自带的库模块所在的目录
sys.path是一个列表,那么就意味着可以进行增删改查,不过一般我们不会操作这个路径,除非你真的需要。
2.4 模块的重复导入
我们说模块的导入就是一个加载以及关联到标识符存入本地命名空间的过程,如果我要多次导入相同的模块,是不是要多次加载,多次重新存入本地命名空间呢,写个代码测试一下
import os
print(id(os))
import os as daxin
print(id(daxin))
import os
print(id(os))
# 2300567923272
# 2300567923272
# 2300567923272
三次导入的模块的内存地址是相同的,说明只会导入一次,这是为什么呢?这是因为在Python中,所有的加载的模块都会记录在sys.modules中,它里面存放的是已经加载过的所有模块的字典,导入模块就和它有关:
- import/from 触发导入操作
- 解释器在sys.modules中查找是否已经加载过,如果已经加载则直接把加载后的对象,及名称加入到导入操作所在的命名空间中。
- 如果使用了as语句,则将对象关联到新的标识符后,加入到导入操作所在的命名空间中。
- 如果sys.modules中不存在,则在sys.path中寻找该模块,然后加载并写入到sys.modules中,然后重复2.3这个过程。
- 如果没找到,那么就会报ImportError异常。
2.5 模块的运行
每个模块都会定义一个__name__特殊变量来存储当前模块的名称,如果不指定,默认为源码文件名,如果是包则有限定名(包名.文件名)。
解释器初始化的时候,会初始化sys.modules字典,用来保存已加载的模块,加载builtins(全局函数、常量)模块,__main__模块,sys模块,以及初始化模块搜索路径sys.path
当从标准输入(命令行方式敲代码)、脚本(在命令行运行)或者交互式读取的时候,会将模块的__name__设置为__main__,模块的顶层代码就在__main__这个作用域中执行。如果是import导入的,其__name__默认就是模块的名称。
(1) mymodule.py
print(__name__)
(2) myscript.py
import mymodulel.py
有如上两个py文件存在于同一层级下:
- 直接运行mymodule.py,会打印__main__
- 在myscript.py中导入时,会打印 mymodule
很多时候,我们写的函数,类需要在其他地方调用,导入时会自动执行模块,如果存在很多print语句时,就会直接在导入的地方进行输出,不便于调用。所以一般我们都使用下面结构:
if __name__ == '__main__':
pass
他的好处是:
- 直接运行当前Python文件,才会__name__属性为__main__,会执行后续代码
- 使用import导入时,文件的__main__属性被重置为文件名,后续代码不会被执行,便于调用。
属性 | 含义 |
---|---|
__file__ | 当前文件的绝对路径(字符串格式) |
__cached__ | 编译后的字节码文件路径(字符串格式) |
__sepc__ | 显示模块的规范 |
__name__ | 模块名 |
__package__ | 当模块是包时,同__name__,否则可以设置为顶级模块的空字符串。 |
2.6 包
Python中把包理解为一个特殊的模块,在文件系统上的体现就是一个目录。用于组织多个模块或者其他更多的包。主要有两种格式:
- 目录
- 目录下包含一个__init__.py文件
新版本的Python直接认为一个目录就是一个包,但老版本的包,下面需要存在一个__init__.py文件,所以规范化起见,建议创建这个文件。
__init__.py文件的作用:
在linux下,一切皆文件,那么一个目录也是一个文件,Python认为包文件本身也可以有一定的属性或者其他信息,所以在包下用__init__.py标识这个包自身的属性信息或者其他代码。包在导入时,它下面的__init__.py 就会被执行
包目录下的其他py文件、子目录都被称为它的子模块。
# 目录结构:
---- mypackage
|--- __init__.py
|--- hello
|--- __init__.py
|--- mymodule.py
---- myscript.py
# myscript.py
import mypackage
print(mypackage.hello.mymodule) # ? 可以执行吗
不可以执行,我们说能否执行主要看sys.modules中是否加载,以及是否关联到本地命名空间中去
import mypackage
print(dir()) # ['__annotations__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'mypackage']
print(dir(mypackage)) # ['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']
从命名空间中,我们可以看到,mypackage包下并没有hello这个包(都没有加载到sys.modules中去),所以说直接通过import mypackage是无法导入它下面的子包以及子模块的。
import mypackage.hello.mymodule
import sys
print(sys.modules.keys()) # [ ..., 'mypackage', 'mypackage.hello', 'mypackage.hello.mymodule']
print(dir()) # [ ... 'mypackage', 'sys']
mypackage.hello.mymodule.echo('hello world')
从命名空间中,我们看到只有顶级包名加入了本地命名空间,但是我们导入的hello,mymodule已经加载到了sys.modules中,这样我们就可以使用mypackage调用了。
2.6.1 模块和包的区别
- 包能更好的组织模块,尤其是大量的模块,其代码行数很多,可以把它按照功能拆分成多个字模块,使用哪个功能时,加载对应的子模块即可。
- 包目录中的__init__.py是在包第一次导入的时候就会执行,内容可以为空,也可以是用于该包初始个跨工作的代码,最好不要删除它。
- 包目录之间只能使用点好作为分隔符,表示模块的层级关系
- 模块就是名称空间,其内部的顶层标识符,都是它的属性,可以通过__dict__或者dir(module)查看。
- 包是一个特殊的模块,但模块不一定是包,是一种组织模块的方式,它包含__path__属性
from json import encoder
json.dump可以使用吗? # 无法使用,因为虽然加载了json模块,但是仅仅把encoder导入了当前名称空间
import json.encoder
json.dump可以使用吗? # 可以使用,因为eocoder是一个模块,这种导入当时,只会将顶层json模块加入到本地名称空间中,所以可以通过json执行dump函数。
2.7 相对导入与绝对导入
绝对导入:在import语句或者from语句中,导入模块。模块名称最前面不是以.点开头的。绝对导入总是去模块搜索路径中寻找(也会查看是否已经加载)
相对导入:只能在包内使用,且只能用在from语句中,使用.点号,表示当前目录内。使用..表示上一级目录。
不要在顶层模块中使用相对导入。
--- package1
| --- m1.py
| --- m2.py
--- m3.py
# m2.py
from . import m1
# m3.py
from package1 import m2
直接运行m2.py时,是无法导入m1的。而,通过m3,导入m2,是正常的。这是因为:一旦一个模块中使用了相对导入,就不可以作为主模块运行了
。
使用相对导入的模块就是为了内部相互引用资源的,不是为了直接运行的,对于包来说,正确的使用方式还是在顶级模块使用这些包。
2.8 访问控制
模块中的变量、类、函数等等都可以称作模块的属性,在导入的时候,都可以被直接导入到本地命名,只要它们符合一个合法的标识符即可(包名,也同样适用)
# mymodule.py
_a = 123
__x = 456
y = 789
class A: pass
def hello():pass
# my_script.py
import mymodule
print(dir(mymodule)) # ['A','__x', '_a', 'hello', 'y']
也就是说模块内没有私有变量,在模块定义中不做特殊处理(类的私有变量会被改名)
2.8.1 from xxx import *
使用from xxx import * 导入时,xxx模块中的私有属性以及保护属性将不会被导入。
# mymodule.py
_a = 123
__x = 456
y = 789
class A: pass
def hello():pass
# my_script.py
from mymodule import *
print(dir()) # ['A','__x', '_a', 'hello', 'y']
2.8.2 __all__
一般写在文件的前几行,用于表示当from xxx import * 导入本模块时,哪些属性可以被别人导入,它的值是一个列表.
# mymodule.py
__all__ = ['_a','y]
_a = 123
__x = 456
y = 789
class A: pass
def hello():pass
# my_script.py
from mymodule import *
print(dir()) # ['_a', 'y']
所以使用from xxx import * 导入时:
- 如果模块没有__all__属性,那么只会导入非下划线开头的变量,如果是包,子模块也不会导入,除非在__all__中设置,或者__init__.py中导入它们
- 如果模块有__all__,from xxx import * ,只导入__all__类表中指定的名称,哪怕这个名称是下划线开头的,或者是子模块
- 使用起来虽然很简单,但是导入了大量不需要使用的变量,__all__就是控制被导入的变量的,为了避免冲突以及污染名称空间,建议添加__all__属性