1. Python中变量的保存和访问
Python中的变量实际上是一个指向对象的引用,每个对象都有一个唯一的标识符(即内存地址)。对于一些不可变对象,如字符串和整数,因为它们的值不可更改,所以当多个变量引用相同的值时,实际上都是在指向同一个对象。这样可以节省内存空间,避免重复创建相同的对象。对于可变对象,如列表和字典,将会为每个变量分配独立的内存空间。
同时要记住python的用‘=’符号对变量进行赋值的时候总是在创建新的对象并将变量名与新对象进行绑定,而不是修改原有对象,所以在下面的代码中,尽管list是可变数据类型,但是还是开辟了新的内存空间
2. 对于字符串的添加,如何更有效率的完成代码
对于下图中的代码,在Python中,字符串是不可变的数据类型。每次执行s += 's'
时,实际上会创建一个新的字符串对象,其中包含原始字符串 s
的内容以及要追加的字符 's'
。这意味着每次迭代都会创建一个新的字符串对象,并将其赋值给变量 s
。由于每次迭代都会创建一个新的字符串对象,并且这些对象的大小会逐渐增长,因此会产生大量的临时对象,这会导致效率很低
s = 's'
for i in range(1000):s += 's'print(s)
解决的方法1,就是用列表来构建字符串
chars = ['s']
for i in range(1000):chars.append('s')s = ''.join(chars)
print(s)
另一个解决方案是使用字符串的 str.join()
方法以及列表推导式。这种方法利用了列表推导式生成一个包含要添加的字符的列表,然后使用 str.join()
方法将这些字符连接成一个字符串。这种方法避免了在循环中不断地进行字符串连接,因此效率更高
s = 's'
s += ''.join('s' for _ in range(1000))
print(s)
3. 函数的默认参数机制
Python解释器对于函数的默认参数,在第一次调用时,会计算一次,即在内存中创建一个对象保存默认参数,而后如果继续调用,且对于默认参数没有显性传参,那么后续调用的同一函数的同一默认参数共享内存
如上图中所示,第一次调用会在内存中新建一个对象,而后的调用会共享该对象,所以foo(2)打印出来的就会保存着ls之前的值
我们应该避免使用可变对象作为默认参数,这是因为假如使用可变对象作为默认参数,且没有进行显性传参,那么该对象会被多次默认调用的函数参数共享,如果函数内部又存在原地修改对象的操作,便会影响到其他调用的函数,导致不同调用的内部默认参数不再独立,会互相影响
4. Python中变量的作用域
Python中有四种作用域,分别是局部作用域(Local)、嵌套作用域(Embedded)、全局作用域(Global)、内置作用域(Built-in),搜索一个标识符时,会按照LEGB的顺序进行搜索,如果所有的作用域中都没有找到这个标识符,就会引发NameError
异常。
你会发现在上面的代码中,尽管y和z分别在全局作用域和嵌套作用域中赋值了,但是打印出来的结果是按照搜索顺序的,然后呢对于print变量,其实就是一个内置函数的名字,所以是可以找到的,不会报错,唯一报错的就是从来没有命名过的w变量
5. MRO(Method Resolution Order)方法解析顺序在继承中的应用
MRO 的计算遵循以下规则:
- 首先考虑当前类(即子类)。
- 然后按照从左到右的顺序,递归地考虑每个父类,直到所有父类都被考虑。
- 在这个过程中,如果有多个父类,且它们有相同的祖先,则按照其在类定义中的顺序确定 MRO
也就是说,每一个类创建了之后都会有一个MRO的顺序,就像图中的M类一样,然后我们在调用函数或者获取变量的时候,就会遵守这个MRO的顺序一个一个的在每一个类中去找,优先使用或返回在MRO中位置靠前的,所以上图中m.say()的调用会打印B
这里对于mro顺序的底层实现逻辑用到了C3算法,简单的来说大致分两种,第一种就是菱形查找,也就是上图中的情况,
在菱形查找中,我们如果有类会最终汇聚到同一个父类,那么这个父类一定是最后一个被搜索的,在那之前,我们进行广度优先搜索,一层一层的找,
在非菱形搜索中,我们搜索的时候,当然同一层也就是当前类如果有多个父类,那肯定还是从左往右进行搜索,但遇到一个分支之后,会继续往上寻找他的父类,直到直到头之后,再对之前同一层的下一个类的分支进行搜索
6. 迭代器和生成器
迭代器
迭代器类需要满足有两个函数,
__iter__()
方法:返回迭代器自身,这使得迭代器对象本身也是可迭代的。通常该方法直接返回对象本身。
__next__()
方法:返回容器中的下一个元素。当没有元素可以返回时,抛出 StopIteration
异常,这标志着迭代结束。如果不想抛出异常,也可以在 __next__()
方法中返回一个特殊值,如 None
。
同时我们还要记住,通过迭代器类实例化的一个迭代器对象,和可迭代对象之间的关系
1. 可迭代对象包含迭代器,也就是说我们python中自带的数据类型如列表,元组,字典其实都是可迭代对象,类中只要包含__iter__ 函数,就是一个可迭代对象,代表可以被for循环,因为我们可以通过__iter__会返回一个迭代器,意味着我们可以像for i in list 一样通过for循环语句对生成的迭代器对象进行遍历,也就是下图中我们第一个对象even_iter的变量方法,
2. 定义可迭代对象,必须实现__iter__方法;定义迭代器,必须实现__iter__和next方法
通过for循环对迭代器进行遍历的过程其实就是迭代器先通过 __iter__() 函数获取一个迭代器对象然后再不断通过 next( ) 取值,也就是说,在进行for循环的时候,迭代器对象的__iter__ 返回他自身,也就是一个迭代器然后被for循环,而可迭代对象也通过调用__iter__函数来返回一个迭代器,被for循环,总而言之,只有迭代器对象才能被for循环,Python 中,确实是先将可迭代对象转换为迭代器对象,然后才能进行遍历。这是因为 for
循环实际上是在处理迭代器对象,而不是可迭代对象 我们的 range方法其实返回的就是一个可迭代对象,obj = range(10000), 这里如果用dir(obj)来查看obj的信息,就会发现其中包含__iter__函数,但是没有__next__ 函数,只有我们在for i in range(10000) 的时候,在for循环中,才会调用range( ) 返回的可迭代对象的__iter__函数来得到一个迭代器供for循环来遍历
然后除了使用for循环,还可以直接调用 __next__( ) 函数来获取迭代器中的值,写成next( ) 也可以,注意这种方法如果我们手动调用的时候,next会记录上一次遍历到的位置因为同一个对象中的类属性被更改之后是保留着一直更新的,那么当遇到stop iteration的条件时,就会报错
生成器
然后呢我们来了解一下生成器,记住:生成器是特殊的迭代器,它可以动态生成值而不需要预先分配内存空间来存储所有的值。生成器通过使用 yield
关键字来定义,在每次调用时生成(yield)一个值,而不是将所有的值一次性生成并存储在内存中
他有两种方式,第一种方式就是通过生成器函数,任何函数中带有yield语句的,就是一个生成器函数,然后当我们调用生成器函数的时候,就会返回一个生成器对象,而不会执行函数体内的代码。每次调用生成器对象的 __next__()
方法时,生成器函数会从上一次 yield
语句的位置开始执行,直到遇到下一个 yield
语句为止,并将 yield
后面的表达式的值返回给调用者
当我们调用生成器函数的时候,其实python底层会根据生成器类 generator来创建这个生成器对象,这个类中也有__iter__ 和 __next__ 函数
生成器还有一种方法就是生成器表达式,类似于列表表达式,只是把[ ] 换成 ()
并且这里你可以看到,正因为我们说了,生成器是特殊的迭代器,所以他也能够被for循环遍历, 但是这里要注意,生成器只能被遍历一次,就如上图我们的打印gen值发生了一次
接下来是迭代器和生成器两者的区别,重点记忆
7. 以下代码会返回什么值
def multiply():return [lambda x: i * x for i in range(4)]print([m(100) for m in multiply()])
正确答案是[300, 300, 300, 300], 很多同学可能会误认为是[0, 100, 200, 300] 因为认为我们的multiply函数返回了四个匿名函数分别是lambda x: 0 * x, lambda x: 1 * x, lambda x: 2 * x 还有 lambda x: 3 * x,但其实并不是这样,这里需要注意的就是一个闭包的现象
当你调用 `multiply()` 函数时,它返回了一个包含四个 lambda 函数的列表,每个 lambda 函数都是一个闭包,其中 `i` 是一个自由变量。在这个列表推导式中,`i` 是在循环结束时保存的最后一个值,因为 lambda 函数在定义时会捕获当前的环境变量,而不是在调用时。因此,无论何时调用 lambda 函数,它都会使用最后一个值 `i`。
当你调用 `[m(100) for m in multiply()]` 时,会遍历这个列表并对其中的每个 lambda 函数调用,`x` 的值为 100。但是由于每个 lambda 函数都捕获了同一个自由变量 `i`,而 `i` 在循环结束时的值是 3,所以每个 lambda 函数都会返回 `3 * 100`,即 300。因此,结果列表中的所有元素都是 300。
这里不用是不用列表推导式的写法,会更好理解一点
def multiply():result = []for i in range(4):def inner_func(x):return i * x result.append(inner_func)return resultfuncs = multiply()
results = []
for func in funcs:results.append(func(100))
print(results)
你这里就会明白因为变量 i 的生成是在外部函数 multiply 中的,当我们在内部函数 inner_func 使用 i 的时候,就形成了一个闭包,局部变量i
的生命周期被延展了,在下面我们调用func(100) 的时候,还是会需要获取 i 的值,这时其实 i 已经结束了for 循环了,并且值被留在了最后一层循环的3,所以会全都返回300
这里如果我们的确想要得到[0, 100, 200, 300]的话,可以用下面的方法,把列表推导式换成生成器,生成器表达式会在每次迭代时动态地生成新的作用域,而列表推导式则会在定义时捕获当前作用域的变量
def multiply():return (lambda x: i * x for i in range(4))print([m(100) for m in multiply()])
这里为了能更好的理解这里出现的问题的原因,我们就要聊到闭包
8. 闭包
闭包是指在一个函数内部定义的函数,并且内部函数可以访问外部函数的变量。当外部函数调用结束后,内部函数仍然可以访问并操作外部函数中的变量,这种行为称为闭包
闭包是函数,是一种可以访问其他函数作用域里面东西的函数
def outer_func():x = 10 # 外部函数的局部变量def inner_func():print("x 的值为:", x) # 内部函数引用了外部函数的变量return inner_func # 外部函数返回内部函数的引用# 调用外部函数,得到内部函数的引用
my_func = outer_func()# 调用内部函数,内部函数仍然可以访问外部函数的变量
my_func()
我们可以这样简单的理解一下闭包的使用,这里我有一个函数,我想要在每次调用的时候,都往列表中添加一个值,然后打印出来当前列表中的所有值
你会发现上面的写法很明显不是我们想要的,我们不能每次都在调用这个函数的时候重新生成一个num列表,然后其实解决方法也很简单,我们可以把num[ ] 列表在函数外面生成为全局变量,但是这种方法在实际开发中是不好用的,我们没办法在其他的文件中对函数进行重复利用,因为我们还得把全局变量都一起进行迁移,所以更好的方法就是进行闭包
使用闭包的时候需要注意,闭包会使得函数中创建的对象不会被垃圾回收,可能会导致很大的内存开销,所以闭包一定不能滥用
闭包的使用场景除了上面这种对函数进行了封装以外,还有就是用来实现装饰器
9. 装饰器
装饰器本质上是一个高阶函数,它接受一个函数作为参数,并返回一个新的函数。这个新函数通常会在原始函数执行之前或之后,执行额外的逻辑,他的使用方法也就是 @decorator 这个写法本身就是一个语法糖,没有什么特别的,每一个函数被装饰器装饰的时候都可以理解成一下的写法
上图的f1和f2中,f1被装饰器装饰了,然后f2是被赋值成了把自己传入装饰器后的结果,这两者其实是一样的,装饰器做的就是对f2的操作
装饰器是一个参数是函数,返回值也是函数的函数
同时我们的decorator为了满足能够修饰不同的任意函数,会把wrapper函数的参数设定为可变长度的参数,下面是一个装饰器的例子
def my_decorator(func):def wrapper(*args, **kwargs):print("Something is happening before the function is called.")result = func(*args, **kwargs)print("Something is happening after the function is called.")return resultreturn wrapper@my_decorator
def say_hello(name):return f"Hello, {name}!"# 调用被装饰后的函数
message = say_hello("Alice")
print(message)
10. Python标准库中的模块
11. Python的解释器
我们都知道当我们写完一个python代码然后保存成.py文件之后,如果没有解释器来运行这个文件,那这个文件和普通的txt file没任何区别,python这个语言有不只一个的解释器,而python官方提供使用的解释器叫CPython,他的底层是由C语言编写的,其他解释器还有例如PyPy,是一个用 Python 编写的、自举的、高度优化的 Python 解释器。PyPy 使用即时编译(JIT)技术,还有Jython,将 Python 代码编译成 Java 字节码,然后在 Java 虚拟机上执行
12. Python的内存管理和垃圾回收
1. 引用计数器
我们需要先明白在python中,有一个双向环形列表,用于管理内存中的对象。CPython解释器来说,Python中的每一个对象其实就是PyObject
结构体,这个结构体就被储存在这个双向环形链表中,每个结构体会保存的数据有:两个指针一个指向上一个对象结构体一个指向下一个对象结构体,然后和当前对象的类型,还有一个很重要的就是名为ob_refcnt
的引用计数器成员变量
这里其实就是当我们创建了一个float对象之后,在底层发生的对其数据的创建 ,这里的计数器会被默认为1,然后在那之后,每次有其他变量引用这个对象的时候,refcnt就会+1
同样能加1就能减一,如下图,然后当一个对象的引用计数变为0了之后,就会被回收,也就是从我们的双向环形链表内存池的refchain中移除 ,并且销毁对象,内存归还
2. 标记清除
但是这种引用计数器的处理机制其实也有一个bug,那就是循环引用的问题,在下面的代码中,当我们执行完del v1 和 del v2后,与上面的int对象99999不同的是,我们的v1和v2的引用计数器此时并不会变成0而是都为1,但是按理来说我们del之后已经没办法再对其进行修改了,所以如果我们只依靠引用计数器来决定一个内存空间是否为垃圾需要回收的话,此时的v1和v2就不会被清理,如果这种循环引用次数很多,那就会造成内存泄漏,也就是程序中的内存分配操作并未被正确释放,导致程序在运行过程中不断占用内存而不释放,最终导致可用内存耗尽
于是python就又多了一个内存管理机制就是标记清除,首先我们要知道可能会出现循环引用的对象一般都是可以储存多个元素的对象例如list/set/tuple/dict,那标记清除的机制其实就是当我们创建了这类对象的时候,除了会将其加入到我们的refchain中,还会再使用和维护一个新的双向环形链表来储存这个对象
就如上图所示,普通的int对象19只存在于refchain中,但是列表对象在两个链表中都有储存,然后呢python内部中某种情况时,他会去扫描我们的这个记录潜在循环引用对象的链表,去一个一个遍历其中的元素以及每个元素引用的元素,去找是否存在两个对象之间含有你引用我,我又引用你的循环引用现象,如果又,就把这两个对象的引用计数都一起 -1,然后去看是否变成了0,如果是,就认为是垃圾,然后进行回收
3. 分代回收
但是呢,光有上面的其实还是不够,我并没有说明到底如何来决定什么时候去扫描我们的循环引用链表,并且要知道,为了查找是否存在循环引用,对于每一个对象中存放的各个元素我们还要去找对应的子元素,其实是一个比较花费时间代价比较大的操作,那么为了解决这两个问题,就继续引出分代回收的概念
思路就是把我们上面说的存放可能存在循环引用对象的链表拆分成三个链表,也就是三代,分别为0代,1代,和2代
上图就是对于每一代的链表进行扫描的机制,用一个具体例子来说明就是一开始三个链表都是空的,然后呢我们开始创建很多有可能发生循环引用的数据例如list,每一个list对象一开始都会被存入到0代中,当我们存放了很多很多对象达到700的时候,我们对0代进行一次扫描,来找任何发生了循环引用的对象,并将其的引用计数-1,接着就对那些引用计数经过-1变成0的对象进行垃圾回收,接下来这700个对象中那些没有变成垃圾的对象,会被移动到1代链表中,0代链表被清空,当我们有经过一段时间的添加后,0代再次达到700个时,重复上面的操作,进行扫描清除然后移动到1代,1代会记录0代进行扫描的次数,达到10次时,1代就也进行一次扫描也清理,并将剩余的对象移动到2代链表中,就根据这个机制一直循环下去,也就是说2代链表中存放的对象是存活时间最长的对象
到此为止我们可以先对python的内存管理和垃圾回收的面试做一个回答的答案就是
4. 缓存机制
除了上面的回答以外,还可以给面试官聊一下缓存机制,cpython在进行上述的进程的时候还提出了一个优化机制
也就是说Python为了避免多次重复的去新建和销毁一些很常用的值,他选择维护一个池,里面已经为[-5, 256] 的整数创建好了空间当我们去创建这些池内的值的时候, 就直接去池中获取就行了,就如下图所示,因为v2和v3需要的数值都是9,所以他们会指向我们池中的同一个内存,而不是分别各自去开辟新内存新建9
这里有个小地方需要注意,当我们在使用一些文本编辑器然后运行python或者ide进行集成式开发的时候,程序的运行还会发生一些别的优化或调整,你会遇到这种情况,即使v2和v3的值已经超出了我们整数缓存池的范围,但是他们依然指向了同一个地址,不用太在意这种情况,你如果在终端命令行中执行Python的话又会看到不一样的结果
然后我们需要明白的是,我们存放在池中的这些对象,他们的引用计数永远不会变为0,意味着永远不会被垃圾回收,因为在程序开始的时候他们就已经被赋予了默认值为1的引用计数,后续我们在添加或删除只能在1的基础上进行
在这个基础上,还有一个缓存机制,在 Python 内部,一种特定类型的对象池被称为 "freelist"(空闲列表),他的机制是当我们的一个对象的引用计数变为0时,不会直接对其进行销毁而是先放入我们的free_list列表中,当我们下一次又创建了新的对象的时候并且对象类型和free_list有的对象一样,我们不会开辟新的内存空间而且取到free_list中的这个对象然后将其的值改成我们新对象的值
上图中你可以清楚的看到,v2所使用的内存地址和v1在被删除之前是一样的
free_list的空间不是无限的,例如在源码中,说明了free_list中最多可以储存80个list对象,如果超出了限制之后,我们再发现一个list对象的引用计数已经变为了0,那就只能直接销毁了
python中使用free_list机制的数据类型有float, list, tuple, dict
然后对于字符串来说,首先我们也知道字符串都是由字符组成的,python会想整数一样,在内存中维护一个链表叫做ascii_latin然后把所有的ascii code中的字符都先储存起来,需要的之后直接去拿,然后对于字符串来说,还有一个驻留机制,如果你是只包含字母数字下划线长度小于20,那么你在创建之后,如果又需要创建一个和你一模一样的字符串,那么我们不会再开辟新的内存空间而是让其直接指向你
13. 并发编程,Python的线程与进程
在编程中引入并发最直接的原因就是提高程序的运行速度
1. cpu密集型计算和io密集型计算
2. 进程,线程,协程的对比
也就是说当我们需要选择使用哪种技术的时候,首先就是看的是不是CPU密集型计算,如果是,直接选择进程,其次再看IO密集型计算中我们的任务多不多,现成库支不支持,并且能不能接受协程的复杂度,如果可以那就可以选择协程
3. GIL
在 Python 中,GIL(全局解释器锁)是一种机制,用于确保在同一时间只有一个线程可以执行 Python 字节码,也就是说由于 GIL 的存在,Python 中的多线程并不能真正意义上实现并行执行,即使在多核系统中。当一个线程获得 GIL 后,其他线程会被阻塞,直到该线程释放 GIL,这点不同于Java和C++,那为什么Python要设计这个看起来很笨的东西呢?其实这又要牵扯到Python的内存管理机制,也就是引用计数器的使用,导致了Python需要解决多线程之间的数据完整性和状态同步问题
我们为了避免GIL的问题,就只能确保我们在使用threading多线程计算的时候,是IO密集型运算,因为GIL锁在遇到IO操作的时候,是会释放锁然后允许其他线程运行的,这样以来,依然会帮助我们提升运行速度,然后呢就是Python也提供了multiprocessing 的多进程机制来让我们能真正利用多核CPU的好处
4. 多线程的实现和注意事项
下面的代码是一个非常简单的线程展示,每个线程就是一个Thread对象,然后对象的生成需要传入一个线程对应的函数以及用元组的形式传入那个函数的参数
import threading
import time# 定义一个函数作为线程的执行体
def thread_function(name):print("Thread", name, "is starting...")# 模拟线程执行一些任务time.sleep(2)print("Thread", name, "is ending.")# 创建并启动多个线程
threads = []
for i in range(5):thread = threading.Thread(target=thread_function, args=(i,))thread.start()threads.append(thread)# 等待所有线程执行完成
for thread in threads:thread.join()print("All threads have finished.")
在 Python 中,queue.Queue
是一个线程安全的队列实现,它可以用于多个线程之间的安全通信。Queue
提供了多个方法来实现线程之间的数据传递和同步
除了线程之间的数据传递以外,我们还可能遇到当在多线程并发执行的情况下,由于多个线程同时访问共享资源或共享数据而可能导致的数据不一致或不正确的问题,这就是线程安全问题,最简单的就是两个取钱的线程会判断余额是否充足,但是有可能线程2的判断发生在了线程1取钱之前,那么就会错误判断,我们有两种方法利用锁Lock来解决线程安全问题
5. 多进程的实现
我们要明白,因为Python有GIL锁的机制,代表每个线程在遇到IO操作之前不会释放GIL,那当然如果我们要运行的程序本身就是IO密集,那代表每个线程之间可以频繁的进行切换从而提高代码的运行速度,但是在CPU密集的操作中,同样也是同一时间只有一个线程在执行,缺少了IO操作就会让其他线程大部分时间都处在堵塞的状态,并且切换的时候也没有带来更好的效益,反而变成了负担,减慢了运行速度
多进程的multiprocessing模块的用法几乎和threading语法一模一样