个人Python核心笔记
个人学习记录文档,欢迎补充
Python的运行:Python转换成字节码byte code,再用c语言做的虚拟机运行该字节码。
所以看python代码差别的时候,最简单最直接的是使用import dis
中dis.dis()
,将他们转化成字节码,查看它们之间有什么不一样。
迭代器
迭代:是访问序列类型元素的一种方式
可迭代对象Iterable:可以迭代的数据类型有列表list
、元组tuple
、字典dict
、字符串str
迭代器Iterator:可以记住遍历的位置的对象。将迭代时的状态保存在对象中。
- 对可迭代对象调用
iter()
可获取可迭代对象的迭代器 - 对迭代器调用
next()
可获取迭代器中的数据
for...in...
循环的本质
- 先调用
iter()
函数,它会自动调用可迭代对象中的__iter__
方法,此方法返回这个可迭代对象的 迭代器对象 - 对获取到的迭代器不断调用
next()
函数,它会自动调用迭代器中的__next__
方法来获取下一个值 - 当遇到
StopIteration
异常后循环结束
自定义迭代器
定义了__iter__
的类是可迭代对象
定义了__next__
的类是迭代器
1 |
|
**python官方规定,迭代器__next__
本身必须要是可迭代对象__iter__
**。不过人家cpython似乎也没太遵守。
注意定义迭代器的__next__
时候需要定义完善
- 无法再给出数据时,raise一个
StopIteration
- 还有数据时,return
实际案例:
1 |
|
生成器
生成器 ( generator ) 是一种特殊的迭代器,用法几乎一样。但是它还有send()
和close()
的用法。
生成器函数:定义了yield
语句的function
生成器对象:通过生成器函数产生的对象,会保存迭代时的状态,记录函数运行到哪一步了。生成器对象本身就是一个迭代器
生成器中的yield
:一次next()
调用返回一个结果。一个语法糖,内部实现支持了迭代器协议,它内部是一个状态机,维护着挂起和继续的状态。
先看看下面的结果
1 |
|
<小明 : gen(5)不应该直接return一个None
给g吗?>
python在编译时发现一个函数定义里有yield关键词时,它不会把这个函数当成普通函数来处理,python会给这个函数打上一个标签 说明这是一个生成器函数。
调用一个生成器函数会生成一个生成器对象,不会执行。所以g = gen(5)
的时候它不会返回值,而是返回一个生成器对象保存到g
。
里面的yield
和return
都不是它的返回值,只用对生成器对象使用next()
的时候,它才会开始运行函数本体。
在生成器函数里面,return
等价于raise StopIteration
,不管有没有返回值,在调用next
的时候都不会返回。
send和close
send
:可以将参数传递到生成器函数中。对于生成器来说,next()
和.send(None)
一样
1 |
|
close()
:这个方法用于关闭生成器,对关闭的生成器后再次调用next
或send
将抛出StopIteration
异常。
asyncio
本次学习的是python3.7以上的用法
在python的asyncio中同时执行的任务只能有一个,不存在系统级的上下文切换,一个线程里只能有一个事件循环
事件循环(event loop):
它是一个运行在单线程中的调度器,负责管理和调度所有注册的异步任务。
这些任务可以是协程、回调函数或者 Future 对象。
asyncio 的事件循环在单个线程中运行,它利用 Python 的协程特性(通过
async
和await
关键字)来实现并发执行。- async:通过
async
定义的函数,是协程函数。在直接调用协程函数时,它返回的是协程对象,它不会运行其中函数的代码 - await:await关键字表示该位置阻塞时可让出cpu执行,即切换到下一协程运行
- async:通过
事件循环会自动调度任务的执行,由程序员显示的在代码中控制任务切换,从而实现异步执行的效果
协程的运行:
- 通过
asyncio.run
或run_until_complete, run_forever
等方法,运行一个event loop死循环。使python的同步模式变成异步模式。 - 使用下面列举的方法,会把你传入的携程方法等封装成task并注册到loop中,并启动这个loop
- 事件循环建立后,寻找可执行task
python的协程也并不是真正意义上只有一条线程,asyncio库内是有一个方法叫做 run_in_executor,他会将你传入的普通函数或者方法扔到子线程中运行,许多python的携程库都是基于这个方法实现的。协程之所以没办法用time.sleep来写,就是因为time.sleep是python解析器底层的一个c实现的方法,他会直接卡住整条线程的运行,而asyncio底层就是一条死循环,不停的在一个一个的执行各个task,一旦线程卡住了,循环也就执行不下去了。
await coroutine:像调用生成器一样调用coroutine,等待直到拿到返回值。不交还控制权给loop,导致不能实现异步。
await task:阻塞程序,并将控制权交还给loop。
create_task( coroutine ):将单个coroutine包装成task的方法,注册到event_loop,不交还控制权给loop,会返回一个任务对象
gather( coroutine, coroutine… ):将多个coroutine包装成task,注册到event_loop,交还控制权给loop
gather( task, task… ):将多个task进行await,注册到event_loop并等待,阻塞直到所有task完成,交还控制权给loop
class的定义
指路:【python】你知道定义class背后的机制和原理嘛?当你定义class的时候,python实际运行了什么呢?
定义一个class的时候相当于:
运行了所有在这个class中的代码
(用一个命名空间ns)把产生的所有局部变量(variable、function)的名字 和 对应的值,保存到
__dict__
中__dict__:在 Python 中,每个类都有一个
__dict__
属性,它是一个字典对象,用于存储类或实例的属性。如类变量class_var
、实例方法instance_method
、静态方法static_method
、类方法class_method
,以及一些特殊的属性__module__
、__init__
、__dict__
等。如无继承任何类,默认会给你继承python object,然后建立一个type,赋值给我们定义的class的名字作为变量
图例:拓展知识—平常定义类为静态建立一个类,动态建立类方法如下。下面与上面等价。
私有属性
是只有在类自己的方法里才能使用/读取变量/方法。
在python中通过添加下划线__var
构成一个私有变量/方法。
python实现私有变量的方式,本质上是把self.__var
在程序在编译期(源代码变字节码)变为了self._类名_var
。
所以python的私有属性可以理解为是一种伪私有,只是一种“约定‘,外部照样能访问和修改。
但是它不支持骚操作,例如
1 |
|
上面的方式通过实例化的o.__v
调用能正常输出
class中的self、function、method
指路:【python】class里定义的函数是怎么变成方法的?函数里的self有什么特殊意义么?
self:
self就是obj实例对象的内存地址。
在用类定义obj实例对象的时候,实例对象已经把自己这个object作为第一个argument变量传进去了。
function:
类中定义的function与平常定义的function无异;self魔法方法在object对象上调用才会出现(实例化后)。
所以实际上类的方法和对象的方法拥有同一个attribute,但是得到了不同的结果,该机制核心见章节你知道描述器是多么重要的东西嘛?你写的所有程序都用到了!
图例:类与实例的function的打印结果。类中方法为普通方法,实例中方法 ( bound method ) 为绑定了实例对象的方法。
这个method object当被调用的时候会记住created的时候绑定的是哪个object
当我们在python中定义函数,其实是建立了function object,他有一个descripter get函数。如上图,我们通过实例o调用f方法,实际上是调用A类中f方法的descript get,而这个描述器返回了实例绑定的method,其中已对第一个argument自动处理绑定了。
实验代码:分清楚function object还是method
1 |
|
衍生知识 (chatGPT)
在Python中,“函数”(function)和“方法”(method)有一些关键的区别,尽管它们在很多方面是相似的。要更好地理解它们的差异,可以从CPython(即Python官方实现)的角度进行分析。
函数(Function)
定义: 一个函数是通过
def
关键字定义的独立的代码块,它接收输入参数并返回输出结果。调用方式: 函数可以独立于类或对象使用。
语法:
1
2
3def my_function(param1, param2):
# Function body
return result
方法(Method)
定义: 一个方法是定义在类内部的函数。方法依附于特定的对象实例,并且通常需要通过对象调用。
调用方式: 方法必须通过对象实例或者类本身调用。
类型:
实例方法: 绑定到对象实例的方法,调用时第一个参数是
self
,代表调用方法的实例。1
2
3
4class MyClass:
def my_method(self, param1, param2):
# Method body
return result类方法: 绑定到类的方法,调用时第一个参数是
cls
,代表调用方法的类。本质上是用@classmethod
装饰器标记的函数。1
2
3
4
5class MyClass:
@classmethod
def my_class_method(cls, param1, param2):
# Method body
return result静态方法: 不绑定到实例或类,调用时没有默认的第一个参数。本质上是用
@staticmethod
装饰器标记的函数。1
2
3
4
5class MyClass:
@staticmethod
def my_static_method(param1, param2):
# Method body
return result
CPython 实现中的区别
函数对象
在CPython中,函数是一种特定类型的对象。每个函数对象包含一个或多个字节码指令和其他元数据,例如函数名、文档字符串、默认参数等。函数对象的类型是 types.FunctionType
。
方法对象
方法在CPython中实现为不同类型的对象,根据它们是实例方法、类方法还是静态方法而有所不同:
- 实例方法: 当一个函数在类的上下文中被访问时,Python会自动将其转换为一个绑定方法对象 (
types.MethodType
)。 - 类方法: 通过
@classmethod
装饰器定义的函数,在类或实例上访问时会返回一个classmethod
对象。 - 静态方法: 通过
@staticmethod
装饰器定义的函数,在类或实例上访问时会返回一个staticmethod
对象。
绑定与非绑定
- 绑定方法: 绑定方法是已经将实例(对于实例方法)或类(对于类方法)绑定到自身的方法对象。调用绑定方法时,不需要显式传递实例或类,会自动作为第一个参数传入。
- 非绑定方法: 非绑定方法是未绑定到任何实例或类的函数。在类定义体中直接引用的方法是非绑定的,需要手动绑定后才可调用。
例子
以下是一个具体例子展示函数和方法的区别:
1 |
|
总结来说,函数和方法在Python中虽然相似,但它们的定义位置和调用方式有所不同,而在CPython实现中,它们也有不同的对象类型和绑定机制。
super()
super是一个type/类。
在子类中调用super().__init__(*args)
会调用父类的init初始化函数,它等价于super(子类, self).__init__(*args)
在它的两个参数中,第一个决定了在mro链上从哪开始找(找如__init__
方法),第二个决定了这个函数的对象和mro
super().__init__
中动态的继承方法过程:
- 从self中拿到mro:子类→父类→object实例
- 找到第一个argument、略过,向后面的class找。
- 如有
__init__
函数,bind其到self上,也就是父类.__init__(self, *args)
所以 super当然可以在class外使用,直接覆盖继承
一、如果只传递一个参数(class)的话,它会返回unbound object,需要bind一个object上才能使用,代码如下。这里不深入讨论,略过了
1 |
|
二、我们平时常用的super()
用法,必须要在class中进行。
- 它会寻找自己在哪个class被定义,作为第一个参数;
- 然后寻找自己在哪个函数中被定义,拿这个函数的第一个argument(通常是self),作为第二个参数
注意:想想看下面的运行结果是什么?
1 |
|
上述的运行结果均为C
,但是B.say(self)
也是这个结果!
似乎有点反直觉,为什么?明明他跟C一点关系都没有
答案是:在class M的super()
中,我们第二个参数self决定了它的mro,而我们传递的self是M类的实例
元类 metaclass
主要了解概念和应用
回顾class的定义章节,列出了动态和静态建立一个class的过程,并且提到是使用type建立一个class。如下:
1 |
|
元类的建立:
1 |
|
元类会隐式的重写class,当在被继承的时候,就会调用父元类的new、init方法。
所以元类可以实现普通继承无法很好实现的方法。推荐用于单例。
单例模式:
单例模式会阻止其他对象实例化其自己的单例对象的副本,从而确保所有对象都访问唯一实例。
在某些场景下,并不需要多个实例对象。而是重用第一次创建的实例对象。
由于
__new__
负责分配内存,__init__
负责初始化内存,如果只想让元类成为单例就可以在__new__
里面限制,避免不必要的内存分配__new__
方法是一个静态方法,没有默认的实现,需要显示地重写。它的作用是创建并返回一个新的实例对象。
__new__
方法在对象创建之前调用,它返回一个实例对象,并且这个对象会传递给__init__
方法的第一个参数(即self
)。__init__
方法是一个实例方法,不需要返回值。它在对象创建后被调用,接收实例对象作为第一个参数(即
self
),并可以进行实例属性的初始化、设置默认值和执行其他操作。
案例一:
1 |
|
案例二:限制coder在定义类的时候不允许定义开头为test_的函数
1 |
|
如果要使用原来的class用法来实现,则实例化后才会抛出异常
1 |
|
案例三:在一个类中创建了多个关于云服务器的连接信息,这些信息是不变的。那么只是需要一个实例对象来访问这些信息,而不是创建多个来占用内存。
1 |
|
描述器 descripter
指路:【python】你知道描述器是多么重要的东西嘛?你写的所有程序都用到了!
定义了__get__
、__set__
、__delete__
的类为描述器
使用 什么.什么 都会用到这个描述器机制
print(o.name)
和o.name='Bob'
分别为 LOAD_ATTR
和STORE_ATTR
LOAD_ATTR
return的顺序问题:
- 优先在带有
__get__
和__set__
的描述器对象中查找 - 在类对象中
__dict__
定义的attribute中查找 - 在带有
__get__
中的描述器对象中查找 - 都没有,就返回descriptor本身。返回他的type ( 类/class ) 中甚至父类中的
__dict__
查找
图例:运行结果示例
实验代码:STORE_ATTR
是否能设置成功,输出结果是什么?
1 |
|
实验代码2:LOAD_ATTR
return的实验思考,猜猜运行结果
1 |
|
关于import
先了解两个概念
module
- 是运行时的概念,保存在内存中,独立构成命名空间。它是一个python object(可包含其他python object),是一个组织单位
- 而文件是操作系统级的概念,我们需要通过import导入这个过程,从文件中生成module
实际应用中,一个module常常对应一个.py文件
package
- 特殊的module,几乎有着一模一样的功能,只多了一个
__path__
- package往往对应文件夹,它可包含
subpackage
或module
。其中module
在组织结构中最末端
从Python3.3开始:无论有没有__init__.py
文件,一个文件夹都可以作为package
被使用
import的时候发生了什么?
其中module和package大致相同,在最后一步需要区分
1 |
|
拿到test字符串,作为名字查找module
- 检查缓存,有没有读取记录
- 叫test的module已经被读取进来了,直接把它赋值给test
- 没有则寻找 ↓
- 检查缓存,有没有读取记录
首先看这个名字是不是python自带的module(builtin module),如sys、os
不是builtin module,则寻找可以被加载为test的文件,它会在sys.path提供的几个路径中按顺序寻找。sys.path类似输出如下
1
2['','/usr/lib/python311.zip','/usr/lib/python3.11','/usr/lib/python3.11/lib-dynload','/home/gaogaotiantian/programs/bilibili video/venv3.11/lib/python3.11/site-packages']
# site-packages是当前环境pip install的位置如是module:找到这个文件后,它会在单独的命名空间中运行这个文件(建立一个module)
完成后变成module object,更新缓存,以后有其他代码import这个名字的时候就不用再加载/寻找一次
- module object赋值给变量
test
——import test
- module object赋值给变量
t
——import test as t
- module object中找到名字为A的变量,将其保存的object赋值给变量
A
——from test import A
- module object赋值给变量
如是package:查看这个package文件夹下是否存在
__init__.py
文件没有,不会运行任何额外的代码
有,在单独命名空间中运行
__init__.py
文件,构成一个特殊的module即package。而package import分为两种absolute import:绝对引入。如果通过
import mypackage
引入,则其他文件如mymodule.py
并不会在dir(mypackage)
中。如果想import一个package下的module,则需要
import mypackage.mymodule
,而此时我们会有一个mypackage
的全局变量并且中dir(mypackage)
有了mymodule.py
。如果你此时使用as
语句将module赋值给了一个变量,它会把最后面的module赋值到对应变量,同时mypackage
全局变量也不存在relative import:相对引入。根据包的相对路径来引入一个module或package。但重点是,绝对引入的写法帮助module确认了它所属的package,相对引入没有!所以使用
python module.py
运行含有相对引入的代码的脚本时,会抛出ImportError: attempted relative import with no known parent package
或者类似的异常。**所以在python中,相对导入的设计更适合用于模块化的包结构中,而不是直接运行的顶层脚本中**。如果需要直接运行带有相对引入的module脚本,推荐使用
python -m package.module
来执行模块。
一个案例分享:
1 |
|
1 |
|
1 |
|
1 |
|
1 |
|
运行结果如下:
1 |
|
内存管理结构
简单入门:详细请看Python内存分配器
主要讨论Cpython的内存管理机制,CPython是最广泛使用的Python实现。
Python会在程序启动时从操作系统申请一块较大的内存作为内存池,然后将这块内存分割成固定大小的小块(通常是8字节的倍数)。
Python程序请求分配一个小于512字节的对象时,Python会直接从内存池中分配一个空闲的小块,而不是每次都向操作系统请求新的内存空间。
当对象不再需要时,Python将其关联的内存块标记为空闲,而非立即将内存返回给操作系统,从而避免了频繁的系统调用开销。
若大于512字节,则调用c的malloc申请。
操作系统对于Python内存池中的具体对象分配和释放过程并不感知。从操作系统的角度看,它只知道Python一开始申请了一块较大的内存,但具体内部的小块分配和释放过程并不影响操作系统级别的内存管理。
Python中并不是在生成所有对象时都去调用malloc(),而是根据要分配的内存大小来改变分配方法,判断申请的内存大小是否大于512字节
- 是,调用c的malloc申请
- 不是,使用内存分配器做内存对齐。如下说明
内存分配器:负责管理对象的分配和释放,会根据请求的内存大小 以及Python数据对象类型、内存大小,进行内存的分配和释放,与垃圾回收机制紧密集成。
内存对齐:指在内存中按照特定的边界(4字节或8字节)对数据进行排列,以确保数据的起始地址是该边界的整数倍,使得数据在内存中的布局更加紧凑和高效。
内存管理系统中的内存分类:分为三个层级,Block < Pool < Arena。
最小的单位为block。 最终给申请者的就是block的地址
比他大的是pool。 用于管理相同大小的Block,以便快速分配和释放。
其中使用used_pools数组来快速查找和分配内存。
最大的是arena。 作为顶层的内存管理单元,负责从操作系统申请一大块内存,并将其分割成多个Pool
当申请一个小于512字节的内存空间时:
- 确定Pool:根据申请的内存大小,CPython确定需要哪个大小的Pool。
- 查找used_pools:在used_pools列表中查找具有足够大小且有空闲Block的Pool。
- 分配Block:如果找到了合适的Pool,CPython从该Pool中分配一个Block给请求的对象。
- 更新Pool状态:更新Pool的状态,标记分配的Block为已使用。
- 内存对齐:确保分配的Block是按照内存对齐规则对齐的。
- 返回内存地址:返回Block的地址给请求者,作为新分配内存的地址。
- 处理Pool耗尽:如果当前Arena中的Pool没有足够的空闲Block,CPython可能会从操作系统申请新的内存来创建新的Pool或Arena。
- 垃圾收集:当对象被销毁,其占用的Block会被返回到Pool中,Pool的状态会更新以反映Block现在是空闲的。
- 内存整理:在垃圾收集过程中,CPython可能会进行内存整理,以减少内存碎片。
涉及组件详细介绍
Arena:为避免频繁的调用malloc和free,内存分配器会以最大的单位arena来保留内存,所以内存的free和malloc都是以Arena层级进行的,即:如果在一个Arena中,一个Pool中的一个Block还正在被使用,这个Arena就无法被free。
- Python在运行的时候会建立一堆Arena(一般是16个),不够再增加。
- Arena只记录还没有被使用的pool
Pool:用于有效管理空的block,申请Pool时,优先用满所有的Arenas。
- 有一个freeblock链表,其中记录着空闲链表。
- 各个pool的大小固定为4k字节(计算机内存中的页就是这么大,设置为4k字节)
Block:每个block的大小定为8的倍数,例如16字节、24字节等。按照此区间预申请多余的内存,避免内存碎片化。
block划分的大小在pool初始化的时候就决定了。一旦确定了块(block)的大小,Python会从操作系统申请一块大的内存,这块内存会被分割成多个固定大小的块,而这些块构成了内存池。
如下,申请一定区间大小内的空间会返回8的倍数的block:
usedpools:是一个数组,保存了很多Pool指针,按Pool的大小排序,用于跟踪当前已经分配了对象的Pool,负责从众多Pool里返回适合申请的Pool,即快速查找和分配内存。
这个数组中的index对应了pool中block的大小,例如index0每一个block为8字节,index1每一个block为16字节,index2每一个block为24字节……
如果申请了大小为20字节,则根据 usedpools 索引出index为2的元素中的pool(该pool拥有24字节block)
所以你可以发现python中object的byte数永远可以被8整除。整存整取。
垃圾回收机制
GC是Garbage Collection的简称,中文称为“垃圾回收”,是指程序把不用的内存空间视为垃圾并回收掉的整套动作。
GC 要做的有两件事:
- 找到内存空间里的垃圾;
- 回收垃圾,让程序能再次利用这部分空间。
在CPython中,对象可以分为容器对象和非容器对象两种,并且各自有不同的具体method来实现,也分别引申涉及到了不同的GC算法。
非容器对象,通常使用引用计数就可以解决
容器对象,分不同的情况,有两种主要GC算法
引用计数
Python里,在对象的建立中,python不仅要保存对象的值,还要去保存该对象的头信息(被引用了多少次)
所以每个对象都有一个引用计数器,用于记录有多少个变量引用了该对象。
当引用计数器归0时,表示没有变量引用该对象,该对象就成为垃圾对象,会被垃圾回收机制自动删除。
1 |
|
循环引用情况:标记清除
由于两个对象互相引用,所以各对象的计数器的值都是1。
但是这些对象组并没有被其他任何对象引用。因此想一并回收这两个对象都不行,只要它们的计数器值都是1,就无法回收。
由此引入了循环垃圾收集器,它会定期扫描内存中的对象,检测循环引用并清理掉这些无法访问的对象。
容器对象:有元组,字典,列表。保留了指向其他对象的引用的对象
非容器对象:有字符串和数值等。这些对象不能保留指向其他对象的引用,那么这些对象就不存在循环引用问题。
两种类型在CPython中建立的方式各不相同
容器对象类型在CPython中使用
PyObject_GC_New
建立,其中有PyObject_GC_Link
方法^也就是说每建立一个容器,Cpython就把这个容器放到垃圾回收专门的链表里
1 |
|
小总结
CPython的垃圾回收算法结合了多种技术来有效管理内存,有:
引用计数器(Reference Counting):
- 每个对象都有一个引用计数器,用于跟踪引用该对象的活跃对象数量。
- 当对象的引用计数降到零时,意味着没有任何活跃的引用指向该对象,它将被立即回收。
标记-清除(Mark-and-Sweep):
用于解决引用计数无法处理的循环引用问题。
当两个或多个对象相互引用时,即使它们不再被使用,引用计数器也不会降到零。
垃圾收集器通过“标记”阶段识别所有可达对象,然后在“清除”阶段清除未被标记的对象。
分代回收(Generational Collection):
- 根据对象的存活时间将对象分为不同的代:年轻代、中年代和老年代。
- 不同代的对象以不同的频率进行垃圾回收,年轻代更频繁,老年代较少。
CPython的垃圾回收机制通过这些方法的结合,实现了有效的内存管理和回收,同时最小化了程序运行时的性能影响。引用计数器提供了快速的内存回收,而标记-清除算法解决了循环引用问题。分代回收策略进一步优化了垃圾收集过程,通过区分对象的生命周期来减少不必要的垃圾收集工作。