Python进阶
前面的Python基础文章中简单介绍了Python的基础语法和一些常见的操作
这篇文章则会更加深入Python的各项机制
1. 对象与内存
Python中几乎一切都是对象
比如:
1 | x = 10 |
这里的10、"hello"、[1, 2, 3]、字典对象本身,都是对象
这里的
1 | x = 10 |
相当于创建一个整数对象10,然后让名字x指向这个对象
所以在python中,变量更像是标签
更具体的例子:
1 | x = 10 |
这里并不是复制一个新的10,而是让y也指向同一个对象10
1 | x ───┐ |
1.1 可变对象和不可变对象
1.1.1. 不可变对象
对象创建后,内容不能被原地修改
常见类型有:int、float、bool、str、tuple、forzenset
不可变对象最重要的特点是不能被原地修改:
1 | a = 10 |
很多人会以为a = a + 1是把原来的10改成11,时则不然
因为int是不可变对象,所以10这个对象本身不会变成11
实际过程:
1 | a -> 10 |
a只是从指向10,变成了指向新的对象11。
所以这里并不是原来的10被修改了,而是生成了一个新的int:11,然后让a指向新对象。
所以:
1 | a = 10 |
输出的两次id不一样
1.1.2. 可变对象
对象创建后,内容可以被原地修改
常见类型:list、dict、set、大多数自定义对象
与不可变对象相反,可变对象是可以被原地修改的
例子:
1 | lst = [1, 2, 3] |
这里输出的两个id是一样的
所以修改可变对象会修改原来的对象,而不是创建一个全新的对象。
1.1.3. 重新赋值与原地修改的区别
重新赋值:
1 | a = [1, 2, 3] |
输出:
1 | [1, 2, 3] |
关系变化:
1 | 一开始: |
原地修改:
1 | a = [1, 2, 3] |
输出:
1 | [1, 2, 3, 4] |
关系:
1 | a ───┐ |
1.2 浅拷贝和深拷贝
拷贝问题主要出现在容器对象上
1.2.1. 浅拷贝
浅拷贝会创建一个新的外层容器,但里面的元素仍然与原来共享
例子:
1 | a = [1, 2, 3] |
输出:
1 | [1, 2, 3] |
说明a和b已经不是同一个列表了
但是注意,浅拷贝只拷贝最外层
例子:
1 | a = [[1, 2], [3, 4]] |
输出:
1 | [[1, 2, 99], [3, 4]] |
因为浅拷贝之后外面的列表是新的,但是里面的子列表还是同一批对象
1.2.2. 深拷贝
深拷贝会递归地拷贝对象内部的对象
使用copy.deepcopy():
1 | import copy |
结果:
1 | [[1, 2], [3, 4]] |
1.3. is
1.3.1. is 和 ==
== 比较值
1 | a = [1, 2, 3] |
输出:
1 | True |
is 比较身份
1 | a = [1, 2, 3] |
输出:
1 | False |
1.3.2. 小整数和字符串驻留问题
1 | a = 10 |
结果可能是:
1 | True |
但是在Python中,一些小整数,短字符串可能会被缓存或复用。
所以
1 | a = 10 |
在不同环境下结果可能是不一样的,所以判断数值是否相等就用==
1.3.3. is常见使用场景
最常见的场景:判断是否为None
1 | if x is None: |
因为None是单例对象,整个python程序里通常只有一个None对象。
1.4. 函数传参机制
python的函数传参既不是传统意义上的值传递,也不是传统意义上的引用传递,而是对象引用的传递
这个引用本身是按赋值方式绑定给形参的。
1.4.1. 不可变对象传参
1 | def change(x): |
输出:
1 | 10 |
调用过程可以这样理解:
进入函数时:
1 | a ───┐ |
函数内部执行:
1 | x = 20 |
结果:
1 | a -> 10 |
1.4.2. 可变对象传参
1 | def change(lst): |
输出:
1 | [1, 2, 3, 4] |
调用过程:
进入函数时:
1 | a ───┐ |
函数内部执行:
1 | lst.append(4) |
这是对列表函数进行原地修改
所以函数外面也能看到变化
1.4.3. 形参重新赋值,不影响实参
1 | def change(lst): |
输出:
1 | [1, 2, 3] |
这次没有变,因为:
1 | lst = [4, 5, 6] |
是让形参lst指向一个新列表,而不是修改原来的列表,所以外面的a没有变化
1.4.4. +=的特殊情况
+=对可变对象和不可变对象的表现不一样
对不可变对象:
1 | def change(x): |
输出:
1 | 10 |
因为整数不可变,x += 1 等价于创建新整数,然后让 x 指向它。
对可变对象:
1 | def change(lst): |
输出:
1 | [1, 2, 3, 4] |
对列表来说:
1 | lst += [4] |
通常是原地扩展列表,所以会影响外部列表
1.5. 函数默认参数
1 | def add_item(item, lst=[]): |
会输出:
1 | [1] |
会有人认为每次调用函数时都会创建一个新的空列表
但是实际上,默认参数只会在函数定义时创建一次
正确写法:
1 | def add_item(item, lst=None): |
这样每次没有传入 lst 时,都会在函数内部新建一个列表。
2. 作用域与命名空间
Python里“作用域”和“命名空间”是理解变量查找、函数嵌套、闭包、global、nonlocal的基础
2.1. 命名空间
在Python中,变量名本质上是一个“名字”,它会绑定到某个对象
1 | x = 10 |
可以理解成:
1 | 名字 x -> 整数对象 10 |
这些名字需要被存放在某个地方,这个“存放名字的地方”叫做命名空间
常见命名空间:
1 | 内置命名空间:print、len、range、int、str ... |
例如:
1 | x = 10 |
命名空间:
1 | 全局命名空间: |
2.2. 作用域
作用域指的是一个名字在代码中可以被访问的范围
1 | def func(): |
运行结果:
1 | NameError: name 'x' is not defined |
因为x定义在func()函数内部,所以x是局部变量,只能在函数内部访问
也就是说x的作用域是func函数的内部
2.2.1. 局部作用域
函数内部定义的变量,默认属于局部作用域
例如:
1 | def func(): |
执行流程:
1 | 调用 func() |
函数执行完毕后,局部命名空间通常会被销毁
2.2.2. 全局作用域
在函数外部定义的变量,属于全局作用域
1 | x = 10 |
输出:
1 | 10 |
这里x定义在函数外部,所以它是全局变量
执行流程:
1 | print(x) |
所以函数内部可以读取全局变量
2.3. 函数内部修改外部变量
2.3.1. 函数内部修改全局变量
先看一个例子:
1 | x = 10 |
输出:
1 | 20 |
这里可以看到,函数里面的x = 20并没有修改外面的x = 10
原因是在函数内部写赋值语句时,Python默认认为这个x是新的局部变量,所以这里实际上是两个不同的x
如果真的想在函数内部修改全局变量,就需要使用global
1 | x = 10 |
输出:
1 | 20 |
这里:
1 | global x |
表示:函数内部的x不再是局部变量,而是全局变量
global执行过程:
1 | 全局命名空间: |
其实并不推荐在函数内大量使用global,这会让函数更加依赖外部环境,使代码更难维护
global的常见用途:统计函数被调用了多少次
1 | count = 0 |
输出:
1 | 函数被调用了 1 次 |
2.3.2. 函数内部修改外层函数变量
nonlocal用于嵌套函数中
它表示:当前函数中的变量不是局部变量,而是外层函数作用域中的变量
例如:
1 | def outer(): |
输出:
1 | 20 |
2.4. LEGB规则
Python查找名字时遵循LEGB规则
LEGB是四个单词的缩写:
1 | L:Local,本地作用域,也就是当前函数内部 |
查找顺序:
1 | Local -> Enclosing -> Global -> Built-in |
也就是:
1 | 当前函数内部 |
如果内层作用域和外层作用域有同名变量,内层名字会遮蔽外层名字
3. 迭代器与生成器
前面的文章有提到过可迭代对象,比如range:
1 | for x in range(3): |
只要一个对象可以被iter()处理,并能不断通过next()取出下一个值,它就可以参与迭代
3.1. 可迭代对象
可迭代对象,英文叫iterable
简单来说:可以被for循环遍历的对象,就是可迭代对象
常见的可迭代对象有:
1 | list |
一个对象只要实现了__iter__()方法,它就是可迭代对象
例如:
1 | nums = [10, 20, 30] |
这里的:
1 | iter(nums) |
本质上会调用:
1 | nums.__iter__() |
它会返回一个迭代器对象
可迭代对象本身不一定能直接一个一个取值,它通常需要先通过iter()转成迭代器
判断一个对象是否可以迭代:
1 | from collections.abc import Iterable |
3.2. 迭代器
迭代器,英文叫iterable
迭代器是一个可以不断取出下一个值的对象
它满足两个条件:
- 有__iter__()方法
- 有__next__()方法
也就是说:
1 | from collections.abc import Iterator |
这里要注意nums是可迭代对象,但它不是迭代器,但它不是迭代器
用next()手动取值
1 | nums = [10, 20, 30] |
迭代器是一次性的,迭代器会记录自己当前的位置
例如:
1 | nums = [10, 20, 30] |
输出:
1 | 10 |
这里会发现,for循环只打印了30,因为前面两次next(it)已经把10和20取走了
3.3. for循环的本质
我们平时写的for循环:
1 | nums = [10, 20, 30] |
python内部大概会把它理解成下面这样:
1 | nums = [10, 20, 30] |
也就是说,for循环的本质:
- 先调用iter()得到迭代器
- 不断调用next()取值
- 遇到StopIteration就结束循环
这里用try except是因为当迭代器没有元素时不会返回None,而是会抛出StopIteration,所以需要这样写
这个try except是异常处理的内容,后面会详细介绍
3.4. 生成器和yield
生成器,英文叫generator本质上是一种特殊的迭代器
生成器不一次性把所有数据都生成出来,而是用到一个,生成一个
yield是生成器的核心
如果一个函数里面输出了yield,它就不再是普通函数,而是生成器函数
例如:
1 | def my_gen(): |
它不会直接输出10、20、30,而是得到一个生成器对象:
1 | <generator object my_gen at ...> |
完整例子:
1 | def my_gen(): |
输出:
1 | 开始 |
执行流程:
1 | g = my_gen() |
| 对比 | return |
yield |
|---|---|---|
| 作用 | 返回结果并结束函数 | 返回一个值并暂停函数 |
| 是否保存状态 | 不保存 | 保存 |
| 是否能多次返回 | 通常一次 | 可以多次 |
| 返回对象 | 普通值 | 生成器对象 |
3.5. 生成器表达式
生成器表达式和列表推导式很像
生成器推导式:
1 | g = (x * x for x in range(5)) |
输出:
1 | <generator object <genexpr> at ...> |
它可以这样取值:
1 | print(next(g)) # 0 |
也可以用for循环:
1 | g = (x * x for x in range(5)) |
输出:
1 | 0 |
生成器是一次性的,所以不可以重复遍历
1 | g = (x * x for x in range(5)) |
第二个for循环不会输出任何内容
因为生成器已经被第一个for消耗完了
生成器表达式经常和这些函数一起用:
1 | sum() |
例如:
1 | result = sum(x * x for x in range(5)) |
输出:
1 | 30 |
4. 函数进阶
4.1. 函数对象
在Python里,函数不仅能被调用,它本身也是一个对象
比如:
1 | def say_hello(): |
这里的say_hello不是函数本体的全部含义,而是一个变量名,它指向了一个函数对象
1 | say_hello -> 函数对象 |
所以也可以这样做:
1 | def say_hello(): |
输出:
1 | hello |
这里不是复制了一份函数,而是让f也指向同一个函数对象
1 | say_hello ───┐ |
4.1.1. 函数名加括号和不加括号的区别
函数名不加括号:
1 | say_hello |
表示函数对象本身
函数名加括号:
1 | say_hello() |
表示调用这个函数
例子:
1 | def add(a, b): |
输出:
1 | <function add at 0x...> |
4.1.2. 函数可以放进列表、字典
因为函数是对象,所以可以放到数据结构里
1 | def add(a, b): |
也可以放进字典里:
1 | def add(a, b): |
4.2. 高阶函数
高阶函数定义:一个函数A接收另一个函数B做为参数,或者返回一个函数C,A就是高阶函数
4.2.1. 函数作为参数
例如:
1 | def apply(func, x): |
输出:
1 | 25 |
4.2.2. 函数作为返回值
函数可以返回另一个函数
1 | def get_func(): |
输出:
1 | inner function |
4.2.3. 常见高阶函数:map
把一个函数依次作用到一个可迭代对象的每个元素上
1 | nums = [1, 2, 3, 4] |
输出:
1 | [1, 4, 9, 16] |
4.2.4. 常见高阶函数:filter
根据函数返回的真假值,筛选函数
1 | nums = [1, 2, 3, 4, 5, 6] |
输出:
1 | [2, 4, 6] |
4.2.5. 常见高阶函数:sorted
也可以经常配合函数使用
比如按字符串长度排序:
1 | words = ["python", "c", "java", "go"] |
输出:
1 | ['c', 'go', 'java', 'python'] |
也可以自定义函数:
1 | students = [ |
4.3. lambda 表达式
lambda 用来创建一个简单的匿名函数
基本格式:
1 | lambda 参数: 表达式 |
例如:
1 | f = lambda x: x * x |
输出:
1 | 25 |
也可以多参数:
1 | add = lambda a,b: a + b |
4.3.1. lambda的特点
没有函数名,所以叫匿名函数
只能写一个表达式
表达式的结果会自动作为返回值
适合写短小的临时函数
lambda表达式常用于高阶函数
4.4. 闭包
内部函数引用了外部函数的变量,并且外部函数返回了内部函数
例子:
1 | def outer(): |
输出:
1 | 10 |
执行流程:
1 | 调用 outer() |
按照普通理解,outer()执行完后,它的局部变量x应该销毁。
但是因为inner还在使用x,所以Python会把x保存起来
闭包的条件:
- 外部函数内部定义了内部函数
- 内部函数引用了外部函数的变量
- 外部函数返回了内部函数
4.5. 装饰器
在不修改原函数代码的情况下,给函数增加额外功能
装饰器其实就是闭包的应用
例子:
1 | def decorator(func): |
输出:
1 | 开始执行 |
执行流程:
1 | decorator(say_hello) |
这里的decorator就是一个装饰器
4.5.1. @语法
关于装饰器,Python提供了更简洁的写法:
1 | def decorator(func): |
输出:
1 | 开始执行 |
这段:
1 |
|
等价于:
1 | def say_hello(): |
装饰器语法@decorator本质上就是:原函数=decorator(原函数)
4.5.2. 有参数的情况
如果原函数有参数,装饰器就要接收参数
例子:
1 | def add(a, b): |
输出:
1 | 开始执行 |
4.5.3. *args和**kwargs
这样无论原函数参数是什么,都可以处理
例子:
1 | def decorator(func): |
输出:
1 | 开始执行 |
4.5.4. 保留原函数信息:functools.warps
普通装饰器无法保留原函数信息
1 | def decorator(func): |
输出:
1 | wrapper |
可以看到say_hello被替换成了wrapper
保留函数的信息可以使用:
1 | from functools import wraps |
完整写法:
1 | from functools import wraps |
输出:
1 | say_hello |
5. 面向对象编程
面向对象编程,英文叫Object-Oriented Programming,简称 OOP。
核心思想是:把现实世界中的事物抽象成程序中的对象,对象内部保存数据,并提供操作这些数据的方法。
Python是一门支持面向对象编程的语言。在Python中,Python中几乎一切都是对象
每个对象通常都有三个核心特征:
1 | id(x) # 对象的身份 |
5.1. 类和对象
类:对象的模板
对象:根据类创建出来的具体实例
例子:
1 | class Student: |
这里的student就是一个类
创建对象:
1 | stu1 = Student() |
5.1.1. 属性
对象中的数据叫做属性
例如:
1 | class Student: |
输出:
1 | Tom |
这里的name和age就是对象stu的属性
5.1.2. __init__()初始化方法
__init__()是Python类中的一个特殊方法
它会在创建对象时自动调用
1 | class Student: |
输出:
1 | Tom |
执行这句代码时:
1 | stu = Student("Tom", 18) |
python大致会做两件事:
创建一个Student对象
自动调用__init__(),给对象初始化属性
5.1.3. self
self表示当前正在操作的那个对象本身,这里与c++中的this指针类似
1 | class Student: |
把传进来的 name 保存到当前对象自己的 name 属性中
self一定要作为__init__()的第一个参数,python会自动把新建的实例对象作为第一个实参传给__init__,这个接收实例的形参,约定俗成的名字为self(其实把self换成别的名字也行,只是可读性会大大下降)
5.2. 方法和属性
5.2.1. 实例属性和类属性
实例属性:属于某个具体对象
1 | class Student: |
这里的self.name是实例属性,每个对象都有自己的一份
类属性:属于整个类,所有对象共享
1 | class Student: |
输出:
1 | XJTLU |
school是类属性,所有对象共享
5.2.2. 实例方法
定义在类中,并且第一个参数是self的方法,叫做实例方法
1 | class Student: |
输出:
1 | 大家好,我叫 Tom,今年 18 岁。 |
调用时:
1 | stu.introduce() |
看起来没有传参数,但实际上Python会自动把stu传给self。
所以方法定义时必须要写:
1 | def introduce(self): |
5.2.3. 类方法
类方法使用@classmethod修饰,第一个参数通常叫cls
1 | class Student: |
输出:
1 | 2 |
cls表示当前类本身
类方法常用于操作类属性,或者创建对象的其他构造方法
1 | class Date: |
输出:
1 | 2026 06 30 |
5.2.4. 静态方法
静态方法使用@staticmethod修饰
1 | class MathTool: |
输出:
1 | 8 |
静态方法适合放一些和这个类相关,但不依赖对象状态的函数
5.2.5. 私有属性
Python没有c++那种严格的private,但是可以通过命名约定(这里并不是真正的禁止外部访问,而是一种约定)
单下划线开头:_name
1 | class Student: |
单下划线表示:这是内部属性,不建议外部直接访问
但它实际上仍然可以被访问
双下划线开头:__name
1 | class Student: |
双下划线会触发名字改写,外部不能直接访问:
1 | stu.__name # 会报错 |
这里的名字改写是Python会把__name改成_Student__name
所以实际上:
1 | stu._Student__name |
是可以访问的
5.2.6. @property
@property是python类中的一个装饰器,作用是:把一个方法伪装成属性来访问
最简单的例子:
1 | class Student: |
输出:
1 | Tom |
这里是s.name而不是s.name(),因为name已经被@property包装成了一个属性
1 |
|
大致等价于:
1 | def age(self): |
完整写法一般有三个部分:@property、@xxx.setter、@xxx.deleter
1 | class Student: |
@property这个的函数名就是外部访问的属性接口
1 |
|
定义了一个叫做age的属性接口
1 | self.age = age |
这里的self.age会调用age的setter
1 | s.age # 这里是外部访问 |
这里的s.age会调用age的getter,也就是@property下面的部分
1 | del s.age # 这里是删除 |
这里的del s.age会调用age的deleter
age是对外暴露的属性名,_age是真正保存数据的变量名
注意,__init__ 接收到的 age,会作为 @age.setter 方法的第二个形参传进去。
上面代码的对应关系相当于:
1 | Student(18) |
如果不写setter则无法在外部更改
如果不写deleter,就代表这个property不支持用del删除
5.3. 继承
继承表示:一个类可以复用另一个类的属性和方法
被继承的类叫父类,也叫基类
继承别人的类叫子类,也叫派生类
例子:
1 | class Animal: |
输出:
1 | 动物在吃东西 |
在类名后面的**()**中写父类的名字,就可以继承父类
子类可以复用父类的属性和方法
子类可以重写父类的方法
1 | class Animal: |
输出:
1 | 狗在叫 |
这就叫方法重写
5.3.1. super()
如果子类重写了父类的方法,但又想复用父类的逻辑,可以用super()
或许有的人会认为:既然想复用父类的逻辑那就不应该重写
这里super()实际的应用场景是在父类原有的逻辑基础上,再加一点自己的逻辑
__init__是最常见的例子:
1 | class Animal: |
输出:
1 | 旺财 |
这里:
1 | super().__init__(name) |
表示调用父类的__init__(),如果不super()则:
1 | self.name = name |
这一句就不会自动执行
5.4. 多态
不同的对象调用同一个方法,可以表现出不同的行为
例如:
1 | class Dog: |
输出:
1 | 汪汪汪 |
这里 make_sound() 不关心传进来的到底是 Dog 还是 Cat。
它只关心这个对象有没有 speak() 方法。
这就是python典型的动态多态思想:鸭子类型(Duck Typing)
看起来像鸭子、走起来像鸭子、叫起来像鸭子,那它就是鸭子
5.5. 魔术方法
Python中有很多双下划线开头和结尾的方法,例如:
1 | __init__ |
等
它们叫做魔术方法,也可以叫特殊方法
这些方法可以让自定义对象支持Python内置语法
这里主要介绍几个常用的
2.5.1. __str__
控制对象被print()时的显示效果
1 | class Student: |
输出:
1 | Student(name=Tom, age=18) |
5.5.2. __len__
让对象可以使用len()
1 | class Team: |
输出:
1 | 3 |
5.5.3. __eq__
控制两个对象使用==比较时的行为
1 | class Student: |
输出:
1 | True |
6. 异常处理
先看一个例子:
1 | print(10 / 0) |
这里会报错:
1 | ZeroDivisionError: division by zero |
这些运行时出现的问题,就叫做异常。
异常处理的作用是在程序出错时,不让程序直接崩溃,而是让我们有机会处理错误
6.1. try/except
最基本的异常处理结构是:
1 | try: |
例子:
1 | try: |
执行流程:
1 | 进入 try |
捕获异常信息
有时候我们不仅想知道发生了什么类型的异常,还想拿到异常的具体信息。
1 | try: |
输出:
1 | 发生 ValueError |
这里的e就是异常对象
捕获多个异常
1 | try: |
这表示:如果出现 ValueError 或 ZeroDivisionError,都执行同一段处理逻辑。
捕获所有异常
1 | try: |
Exception是大多数常见异常的父类
比如:
1 | ValueError |
但不推荐一上来就写Exception
可以最后用Exception兜底
1 | try: |
裸except
Python允许裸except:
1 | try: |
但是它会捕获几乎所有异常,甚至包括一些不应该被普通程序捕获的异常,比如用户按下Ctrl + c产生的KeyboardInterrupt
所以一般不推荐这样写
6.2. else/finally
完整的异常处理结构应该是:
1 | try: |
else 只有在 try 里面没有发生异常时才会执行
finally 无论有没有异常,都会执行
6.3. raise
raise是用来主动抛出异常的
前面的哪些异常都是由Python自动产生的,而raise是程序员主动抛出异常
例如:
1 | age = -1 |
输出:
1 | ValueError: 年龄不能是负数 |
raise的作用是当程序发现某个情况不合法时,主动终止当前流程,并告诉调用者哪里出了问题。
例子:
1 | def set_age(age): |
报错:
1 | set_age(-1) |
raise抛出异常后,调用者可以用前面所介绍的try/except捕获它
1 | def divide(a, b): |
输出:
1 | 调用失败:除数不能为0 |
重新抛出异常
有时候我们想先记录一下错误,然后继续把异常抛出去。
可以这样写:
1 | try: |
最后会把捕获到的异常重新抛出去
6.4. 异常链
raise from是Python中异常链的语法
当你捕获一个异常后,又抛出另一个异常时,用
1 | raise 新异常 from 原异常 |
表示:新异常是原异常导致的
例子:
1 | try: |
输出:
1 | ValueError: invalid literal for int() with base 10: 'abc' |
如果这里用普通的raise则不会明确表示RuntimeError 是 ValueError 直接导致的,raise from则会更加清楚
常见用途是隐藏底层错误,抛出更高级的错误
还有一种特殊写法:
1 | raise 新异常 from None |
抛出新异常,但隐藏原来的异常链。
这样可以不暴露底层细节
6.5. 自定义异常
自定义异常本质上就是定义一个类,并继承Exception
比如我们定义一个异常来表示用户余额不足:
1 | class BalanceNotEnoughError(Exception): |
输出:
1 | 取款失败: 余额不足 |
自定义异常可以带属性
简单的自定义异常可以只写pass,如果想保存更多信息也可以加入属性
1 | class BalanceNotEnoughError(Exception): |
7. 模块和包
用Python写大工程时,不可能把所有代码都放在一个.py文件里,所以Python中有两个组织代码的概念:
模块(module): 一个.py文件
包(package): 一个包含多个模块的文件
7.1. import的本质
搜索到一个模块,把它执行一遍,然后把此模块对象绑定到当前命名空间中
假设一个模块为math_utils.py:
1 | # math_utils.py |
主文件:
1 | # main.py |
输出:
1 | math_utils已执行 |
import math_utils会执行math_utils里的顶层代码
顶层代码:模块文件被Python读取时,会直接从上到下执行的代码,不缩进在函数、类、if、for 等结构里面的代码
简单来说,所谓顶层代码,就是不在函数、不在类里面的代码。
顶层代码案例:
1 | print("这是顶层代码") # 顶层代码 |
最常见的写法:
1 | import math_utils |
这种方式会把模块名math_utils放进当前命名空间,所以访问里面的内容时,需要加模块名前缀
7.1.1. from import
也可以把模块里面的某些名字直接导入到当前命名空间:
1 | from math_utils import PI, add |
但是要注意,这种方式容易造成名字冲突。
7.1.2. import as
可以给模块起别名:
1 | import math_utils as mu |
7.1.3. from module import *
也可以把模块中的很多名字都导入命名空间
1 | from math_utils import * |
这表示把 math_utils 中的很多名字都导入到当前命名空间,比如PI、add
但是一般不推荐这样写,因为很难看出当前文件的名字到底来自哪里
7.1.4. import的执行次数
1 | import math_utils |
执行结果通常是:
1 | math_utils已执行 |
虽然写了三次import math_utils,但是模块顶层代码只执行了一次
原因是Python会把已经导入过的模块缓存到sys.module里
可以验证一下:
1 | import sys |
输出:
1 | True |
sys.modules 就像 Python 的模块缓存表。
顺便说一下,sys.modules本质是一个字典
7.2. 模块搜索路径
当写下:
1 | import math_utils |
后,python会从哪里找math_utils呢?
答案是,它会从一个叫sys.path的列表里查询
这个列表的顺序是:
当前脚本所在目录
环境变量 PYTHONPATH 指定的目录
Python 标准库目录
第三方库目录 site-packages
可以通过__file__来查看一个模块来自哪里
例子:
1 | import random |
输出:
1 | /usr/lib/python3.12/random.py |
有时候可以看到有人这样写:
1 | import sys |
这样可以临时把某个目录加入模块搜索路径,然后就可以导入这个目录下的模块。
7.3. __name__ == "__main__"
每个Python文件在运行时。都有一个特殊变量:
1 | __name__ |
它的值取决于这个文件是怎么被使用的
7.3.1. 文件被直接执行时
文件test.py:
1 | # test.py |
直接运行:
1 | python3 test.py |
输出:
1 | __main__ |
也就是说当一个 Python 文件被直接运行时,它的 __name__ 会被设置为 "__main__"
7.3.2. 文件被导入
文件test.py:
1 | # test.py |
文件main.py:
1 | # main.py |
输出:
1 | test |
当一个 Python 文件被作为模块导入时,它的 __name__ 是模块名
所以:
1 | if __name__ == "__main__": |
的作用是:
如果当前文件是直接运行的,就执行 main()
如果当前文件是被别人导入的,就不要执行 main()
所以一般组织一个可运行文件的结构是:
1 | def main(): |
7.4. 包结构
当项目变大后,只有几个.py文件就不够了
这时就可以使用包结构组织代码
这是一个包结构的模板:
1 | project/ |
当包里的模块需要相互导入时,分为相对导入和绝对导入
绝对导入:
1 | from package.a import func |
相对导入:
1 | from .a import func |
绝对导入:从项目根包开始写,清晰稳定
相对导入:在包内部比较方便,但不能随便直接运行模块文件
一般推荐用绝对导入,相对导入更适合在包内部使用
7.5. __init__.py
__init__.py是包里面的一个特殊文件
例如:
1 | mypackage/ |
传统上,只要一个文件夹里有 __init__.py,Python 就会把这个文件夹当成一个普通包。
现代 Python 中,即使没有 __init__.py,有些情况下也可以形成命名空间包,但是对于初学者和大多数普通项目来说,建议还是写上 __init__.py。
当导入包时,这个包的__init__.py就会执行
例如:
1 | mypackage/ |
__init__.py:
1 | # mypackage/__init__.py |
main.py:
1 | # main.py |
运行结果:
1 | mypackage 被初始化了 |
所以import mypackage本质上会执行__init__.py
__init__.py中可用来控制包对外暴露的接口
假设结构:
1 | mypackage/ |
calculator.py:
1 | # calculator.py |
如果在__init__.py中写:
1 | from .calculator import add, sub |
外部就可以这样用:
1 | from mypackage import add, sub |
而不需要这样:
1 | from mypackage.calculator import add, sub |
__init__.py可以写一些包初始化的代码
例如:
1 | # mypackage/__init__.py |
不过注意:__init__.py中不要写过于复杂的逻辑
8. 文件与上下文管理器
8.1. open()
open()是Python中用于打开文件的内置函数,open()的作用是打开文件,并进行读写
完整函数签名比较长,这里主要关注几个常见的参数:
1 | open(file, mode = 'r', encoding = None) |
例子:
1 | f = open("text.txt", "r", encoding = "utf-8") |
打开test.txt文件
使用r,也就是只读模式
使用utf-8编码解析文本
返回一个文件对象f
8.2. 文件对象
open()返回的是文件对象
比如:
1 | f = open("test.txt", "r", encoding="utf-8") |
输出:
1 | <_io.TextIOWrapper name='test.txt' mode='r' encoding='utf-8'> |
通过这个对象,可以调用很多方法来操作文件:
1 | f.read() |
8.3. 文件打开模式
8.3.1. "r":只读模式
例子:
1 | f = open("test.txt", "r", encoding="utf-8") |
如果test.txt不存在则会报错:
1 | FileNotFoundError |
"r"模式要求文件必须已经存在
8.3.2. "w":写入模式
如果文件不存在,则创建新文件
如果文件已经存在,先清空原来的内容,再写入新内容
例子:
1 | f = open("test.txt", "w", encoding="utf-8") |
test.txt:
1 | new content |
8.3.3. "a":追加模式
"a"是append的意思
保留原文件中存在的内容,把新内容写到文件末尾
例子:
1 | f = open("test.txt", "a", encoding="utf-8") |
修改前:
text.txt:
1 | old line |
修改后:
text.txt:
1 | old line |
8.3.4. "b":二进制模式
处理图片,音频,视频等文件时不能用文本方式读,而是用二进制模式读
1 | with open("image.jpg", "rb") as f: |
"rb"表示:
1 | r:读取 |
同样的,二进制写入:
1 | with open("copy.jpg", "wb") as f: |
二进制读写不需要写encoding,因为二进制数据不需要用文本编码
8.4. 读取文件:
8.4.1. read()
read():从当前文件指针位置开始,读取字符/字节
函数签名:
1 | file.read(size=-1, /) |
假设test.txt:
1 | hello |
例子:
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出:
1 | hello |
read() 会把文件中的内容一次性读出来,返回一个字符串(二进制模式下返回字节序列),适合小文件
8.4.2. readline()
readline():从当前文件指针位置开始,读取一行内容
函数签名:
1 | file.readline(size=-1, /) |
假设test.txt:
1 | hello |
例子:
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出:
1 | hello |
注意中间会多空行,因为readline()读出来的内容通常包含行尾的换行符\n
当文件已经读完,再调用readline(),会返回空字符串
8.4.3. readlines()
readlines():从当前文件指针位置开始,读取剩余所有行,并把每一行作为列表中的一个元素
常见函数签名是:
1 | file.readlines(hint=-1, /) |
假设test.txt:
1 | hello |
例子:
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出类似:
1 | ['hello\n', 'python\n', 'world\n'] |
8.5. 写入文件
8.5.1. write()
write():把内容写入文件
函数签名:
1 | file.write(s, /) -> int |
返回值是实际写入的数量
例如:
1 | with open("test.txt", "w", encoding="utf-8") as f: |
test.txt:
1 | hello |
如果想换行,需要自己写入
8.5.2. writelines()
writelines():写入多行
函数签名:
1 | file.writelines(lines, /) |
例子:
1 | lines = ["hello\n", "python\n", "world\n"] |
注意,writelines()也不会自动添加换行符,也需要自己手动添加
8.6. 文件指针
文件对象内部有一个表示"当前位置"的对象,可以理解为文件指针
假设test.txt:
1 | abcdef |
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出:
1 | ab |
第二次不是从头开始读的原因是,第一次f.read(2)读取了ab,所以文件指针移动到了c的位置,所以第二次就开始从c开始读
8.6.1. tell()
tell():用于获取文件指针现在停在什么位置
函数签名:
1 | file.tell() -> int |
返回一个整数,表示当前文件位置
假设test.txt:
1 | abcdef |
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出:
1 | 0 |
8.6.2. seek()
seek():移动文件指针
函数签名:
1 | file.seek(offset, whence=0, /) -> int |
返回一个整数,表示移动后的文件位置
假设test.txt:
1 | abcdef |
1 | with open("test.txt", "r", encoding="utf-8") as f: |
输出:
1 | ab |
8.7. close()
close():用于关闭文件
打开文件后,程序会占用操作系统资源
如果不关闭文件,可能会导致文件资源被占用;写入的内容可能还停留在缓冲区,没有直接写入磁盘;打开文件太多会报错等问题
函数签名:
1 | file.close() |
例子:
1 | f = open("test.txt", "w", encoding="utf-8") |
当然,这也存在一些问题,比如中途报错会导致close()不执行,使得文件没有被正常关闭
这个问题可以使用之前讲过的try...finally解决,这样即使中间报错,文件也会被正常关闭
但是这样写比较麻烦,于是python提供了更简洁和优雅的写法:with
8.8 with
with是Python中专门用来管理资源的语法
with在前面的例子中已经有出现过了,这里先展示一个最常见的例子:
1 | with open("test.txt", "r", encoding="utf-8") as f: |
进入with时,打开文件;在with代码块中,使用文件;离开with时,自动关闭文件
这里不管是正常离开,还是因为异常离开,都会自动关闭
很多人会误以为with是专门用来打开文件的,其实不是
with是用来管理上下文管理器对象的,文件刚好是上下文管理器,所以可以配合with使用
除了文件,很多别的东西也可以用with使用:
1 | 数据库连接 |
8.9. 上下文管理器
上下文管理器(Context Manager)
一个对象只要实现了__enter__和__exit__方法,就可以被with语句管理
简单的上下文管理器例子:
1 | class MyContext: |
输出:
1 | 进入with |
执行流程:
1 | MyContext() |
8.9.1. __enter__
__enter__()在进入with代码块之前自动执行
它的返回值会赋值给as后面的变量:
1 | class MyContext: |
输出:
1 | 调用 __enter__ |
也就是说,as value中的value不是一定等于MyContext()创建出来的对象,而是等于__enter__()的返回值
但常见写法就是:
1 | class MyContext: |
这样__enter__就返回了上下文管理器对象本身
8.9.2. __exit__
__exit__()在离开with代码块时自动执行
函数形式:
1 | def __exit__(self, exc_type, exc_value, traceback): |
如果with代码块正常执行,没有报错,那么三个参数都是None
正常执行时:
1 | class MyContext: |
输出:
1 | 进入 |
出现异常时:
1 | class MyContext: |
输出:
1 | 进入 |
__exit__()的返回值决定异常是否继续向外传播,默认返回None
返回None
如果with代码块里报错,异常会继续向外抛出
例如:
1 | class MyContext: |
输出:
1 | 进入 |
虽然__exit__()执行了,但异常没有被处理掉
返回True
如果__exit__()返回True,则异常不会继续抛出
例如:
1 | class MyContext: |
输出:
1 | 进入 |
8.9.3. with open(...)的底层理解
这段代码:
1 | with open("test.txt", "r", encoding="utf-8") as f: |
可以近似理解为:
1 | manager = open("test.txt", "r", encoding="utf-8") |
真实实现细节肯定更加严谨,但这个足以理解其原理