本系列旨在通过梳理和总结Python中常见的八股内容,希望与读者共同提高在讨论相关话题时的知识储备,做到“胸中有粮心不慌”。
与此同时,在AI时代我们可以通过Vibe Coding来编程,但知道原理可以让我们更加踏实和安心,本文重点关注原理层面的内容。
历史文章列表:
如何让人觉得你熟练Python(1)
如何让人觉得你熟练Python(2)
之前提到过前作内容更像是笔记大纲,因此本篇我会重新整合之前两篇的内容,尽量做到通俗易懂。对于之前理解错误的地方予以纠正,同时补充和优化一些表达。大家在阅读过程中,有任何问题或发现错误,欢迎评论区留言。
一、Python的内存管理机制
1、概述
首先,Python会在内存中开辟一块独立空间来存储所有对象,这块独立空间叫内存池其次,对象分为容器对象(列表、元组、字典、自定义对象)和非容器对象(数值、布尔、字符串)它们的区别是非容器对象存储值,容器对象存储引用,被引用的可以是容器对象或非容器对象然后,在Python中,内存泄漏指不被程序使用的对象没有被回收,导致内存占用越来越多由于容器对象可以存储容器对象的引用,这会导致两个容器对象互相引用,即循环引用,导致内存泄露除了循环引用外,内存泄漏还包括全局变量或容器对象的无限增加、监听器/回调函数的未注销、C扩展库的泄漏等最后,Python主要通过引用计数、标记清除和隔代回收来管理内存2、引用计数
Python会记录所有对象的被引用次数当对象被引用时,例如a=1;b=a;c=f(a),a的计数加1当对象减少引用时,例如del a;b=3,a的计数减1当对象的引用次数为0时,说明程序不会再使用a了,触发回收,释放a对象占用的内存资源3、标记清除
(这里需纠正前作的错误认识)标记清除主要用来处理循环引用的容器对象,即a[0]=b;b[0]=a首先从根节点出发,通过引用链,遍历并标记所有可以触达的容器对象然后清除所有没有被标记过的容器对象(1)Python的垃圾回收,以引用计数为主,标记清除为辅(2)标记清除只针对容器对象(3)Python会维护一个双向链表记录所有对象引用,从中找到未标记对象4、隔代回收
每次从根节点扫描所有容器对象,对性能损耗较大,因此Python秉承如下概念“被检测次数越多的对象,触发垃圾回收的概率越低,命越大越长寿”因此Python会将容器对象分为三组,新创建、经历过一次标记清除后幸存和经历过2次及以上扫描后仍幸存对于新创建的容器对象,只要Python分配内存的对象数量达到阈值就会扫描,幸存的容器对象移动至第二组对于经历过一次标记清除后幸存的容器对象,当新创建容器对象被扫描过N(比如N=10)次后触发扫描,幸存者移到第三组对于经历过2次及以上扫描仍幸存的容器对象,当第二组被扫描过N(比如N=10)次时触发扫描由此可见,第三组被触发扫描的概率为第一组的1%5、Python中对象的生命周期管理
主要通过维护一些特殊方法,由Python解释器自动调用__new__():创建对象时执行__init__():实例化对象时执行__del__():释放对象时执行当你运行obj=myClass()时,Python会先执行__new__(),然后执行__init__()。创建对象和实例化对象的区别在于,前者会在内存中寻找一块空间分配给该对象,后者会往这个对象中填入数据。而从代码层面看,__new__()要返回一个实例,而实例的内容由__init__()来设定二、迭代器Iterator和生成器Generator的区别
1、迭代器和生成器的存在都是为了惰性加载数据,节省内存比如在内存有限的前提下,读取一个非常巨大的文件,如果不用惰性加载,会造成Out Of Memory报错2、迭代器和生成器都是通过编写__iter__()和__next__()两个函数来实现惰性加载数据。在代码层面,__iter__()要返回一个迭代器,__next__()规定如何返回下一个值3、生成器是特殊的迭代器,生成器通过隐式实现__iter__()和__next__()来达到目的隐式实现方法一:用yield来代替函数的return,当解释器执行到yield时暂停,下次从这里继续隐式实现方法二:用列表推导式来实现隐式实现三、With的底层实现
1、主要依赖上下文管理协议,通过实现__enter__()和__exit__()两个函数,分别用于初始化资源和清理资源2、__enter__()和__exit__()成对出现,当使用with时,开始时执行__enter__()使用资源,结束时执行exit()释放资源3、__exit__()和__del__()均与资源清理有关,但执行时机上有差异,前者当with语句结束时,无论是否有异常都会立即执行,而后者只有被销毁前才执行;同样的,__enter__()和__init__(),前者是在创建对象时执行,而后者在with语句开始时执行4、因此,__enter__()和__exit__()偏向于使用层面,与with搭配;而__init__()和__del__()偏向于定义层面四、Python元类
1、元类指类的类,主要作用是在创建时修改类的结构,提高代码复用性2、一般情况下,我们通过class obj()来定义一个普通的类,此时它默认继承object(基类),参数metaclass=type(元类)3、对于普通类来说,__new__()方法中只有self一个参数,用来控制类的创建,__init__()方法控制类的初始化我们会通过class base_obj(type)来定义一个元类,再通过class new_obj(metaclass=base_obj)来定义该元类的类4、对于元类来说,__new__()方法中有cls(类自身)、name(名称)、bases(基类列表,默认时object)和attrs(属性,例如init)5、无论是基类还是元类,都可以通过super()来调用父类的方法,例如super().__new__()6、Python元类应用场景主要包括实现ORM、自动建数据表、做数据有效性验证等,通过重写__new__()来控制类的生成来实现五、装饰器
1、装饰器是一个接收函数并返回新函数的高阶函数,它本质也是提高代码复用性2、相比于Python元类,它们都是在不改变原有代码的前提下修改类/函数,但元类是在创建时修改,而装饰器是在运行时改变3、使用装饰器只需在函数前,添加一行@装饰器名称,因此它是Python的语法糖4、装饰器的应用场景包括:日志、鉴权、缓存、重试六、Mixin混入类
1、Mixin混入类是通过多重继承来灵活组合扩展功能,提高代码复用2、一般的类,遵从层级继承,通过继承来获得父类的属性和方法例如先定义一个“人”类,包含姓名、年龄等属性,还包含行走、吃饭等方法然后再定义一个“男人”类继承“人”类,这时候,“男人”类获得了“人”类中已经拥有的属性和方法3、而混入类指先定义“打电话”、“发短信”、“看视频”三个混入类,然后再定义“手机”类继承前面三个类4、容易发现,一般的类的层级继承的特征是is-a,即男人是人、女人是人;而混入类的特点为has-a或can-do,例如手机可以打电话、发短信5、考虑到多重继承,容易产生菱形继承的问题,此时MRO(方法解析顺序)会遵循C3算法举个例子:A类是一个父类,它有B、C两个子类,而D类继承B、C,这就是菱形继承其中,A类中func()方法被B、C同时重写,此时对于D类来说,它的func()继承了哪个类?6、C3算法指子类优于父类、每个类出现一次、继承顺序决定调用顺序对于本案例来说,首先看D类本身有没有重写func(),有的话就按D类中重写的为准(子类优于父类)如果没有重写,则根据D类继承时的顺序,如果是class D(B,C),则它继承B类的func()(基于继承顺序)对于D类来说,它的完整继承顺序是D-B-C-A-Object(基类)七、Python的进程、线程、协程及性能调优
1、进程是计算机资源分配的最小单位,一个进程里包含内存(存储)和至少一个线程(计算),进程内所有线程共享内存资源。2、线程是计算机CPU调度的最小单位,一般对应一个CPU核,但是由于Python的GIL(全局解释锁的存在,同一个进程内只有一个线程参与运算。(新版Python已经改进了这个设定,暂不讨论)3、协程的作用是为了应对IO密集型任务时(后续会详细讲),通过自身的事件循环,来提高执行效率,协程的本质还是线程。4、性能问题本身是个比较宏大的命题,从架构层面上说,需要全面评估研发链路上的每个环节,包括硬件配置、网络传输、软件架构等。聚焦到Python本身,重点关注程序处理的目标是CPU密集型任务还是IO密集型任务,针对不同的运行瓶颈设计优化策略。5、CPU密集型任务,即主要工作量在计算上,例如在单台机器上跑大数据量级的计算任务,应当充分利用系统的计算资源,由于全局解释锁的存在,一个进程里只能有一个线程运行,因此考虑使用多进程的解决方案,例如调用multiprocessing包。值得注意的是,进程相对较重,新建、启动和切换的时间相对较长,因此如遇到相关场景,更建议使用集群解决方案,例如spark、flink框架,再退一步可以用C语言方式优化,或cProfile,尽量避免用Python多进程去应对。6、IO密集型任务,指像数据库增删改查,网络请求等任务,特点是每个任务从发起到完成,期间有等待时间。这段时间里,程序可以考虑做其他的事情。考虑到全局锁和资源消耗,相比于多进程方案,不妨考虑单进程多线程来解决,例如调用threading包,简单说就是同时开启多个线程,每个线程发起一个请求,然后切换到另一个线程来发起第二个请求,以此类推……当第一个请求收到返回信息时,切换回对应的线程,处理后续的逻辑。但是线程之间的切换同样有时间开销,因此协程应运而生,它的特点就是避免了线程间的切换,用同一个线程切换上下文的方式实现了多个线程的效果。协程对应的包名为asyncio,值得注意的是,当我们采用协程处理IO密集型任务时,必须要使用支持协程的包,例如aiohttp、aioredis7、在任务等待期间,如果线程被挂起,不能再做其他事情,即阻塞状态;如果线程不被挂起,可以执行其他任务,即非阻塞状态,因此阻塞和非阻塞是任务等待状态;而同步和异步是消息通信机制,异步指发起请求后立刻得到响应信息,但不是最终结果,可能是一个Promise对象,可以通俗类比于“你的请求已收到,正在处理中”,而最终结果只有当被调用房处理完后通过回调函数、或通知的方式来传达;同步则是让你等待直到返回结果。容易发现,“阻塞|非阻塞”“同步|异步”是两个互相独立的概念,前者针对线程是否挂起,后者关注返回方式。八、TDD(测试驱动开发)
1、TDD是一种研发方式,核心是在完整全面的测试用例的保驾护航下,完成对程序的研发和重构,保证项目交付质量2、TDD的核心循环是“红-绿-重构”,第一步就是构建测试用例,从需求和产品出发设计输入和期望输出;第二步才开始正式开发,程序从无到有的过程,也是测试用例逐步通过的过程,即从红(失败)到绿(成功)。在研发过程中,研发目标可以从“快速编写可以让测试通过的最少代码”到“维持测试全部通过下最有的代码结构”3、测试驱动开发,最显著的价值在于,重构不会引入新的错误,并节省了回归测试的时间和成本。此外,由测试主导意味着在项目初始就有较高的质量标准阈值。与此相对,不一定所有测试用例真的会在真实环境里遇到,边缘或极端少数的场景应对反而会虚耗研发资源,这就导致了全绿的边界非常难以界定。在AI Coding时代到来之前,TDD的受众明显偏少九、Type Hints
1、Python是动态类型语言,它会在我们给变量赋值时自动推断它的类型,而不用想其他语言例如Java那样提前声明。2、但随着机器学习、AI时代到来,Python的定位从脚本语言变成主力后端语言,如果无法第一时间知道变量类型,对代码维护和错误排查会造成障碍,因此新版本Python引入了Type Hint,即通过静态类型检查来提高代码的可维护性例如过去我们定义方法:def func(a):;现在采用def func(a:List[Str]) -> Dict[Str,int]:3、Type Hints和TDD都在代码及程度交付质量上做出贡献,区别在于,前者在代码编译阶段保证了代码结构的正确,而后者则在程序正式运行确保逻辑的正确