Python for C++ Programner

Last Updated on 2024年2月18日

这里谈一下个人的学习建议.

首先可以阅读官方的入门教程, 看完这一部分, 对于一个熟练的C++程序员, 应该可以凑合写出可堪一用的代码了. 对于大部分不以Python为主要工作语言的开发者,到此基本就足够了.

如果有时间,我建议直接阅读Python in a Nutshell第七章之前的内容(不含第七章),并不用完全看懂,大部分细节也不用去记忆,只需要看完即可,至此,你就能基本了解Python的运行机制, 写出质量稳定可控(不会存在低级错误)的代码了.

上面两步完成后, 对一个熟悉C++的程序员而言, 基本任何Level的Python资料都可以看了. 你可以继续看Python in a nutshell中感兴趣的部分,也可以选择Fluent Python. 在有相当多的开发经验之后,可以阅读Python Cookbook. 如果仍然想进一步深入, 那么你应该直接学习CPython解释器的实现,Python Developer’s Guide可以作为一个入口.

最后,本文仅仅简单扼要的说明了Python和C++不同的一些特性, 可以作为补充阅读.

书评

Python的受众非常广泛, 导致有非常多的Python书籍是完全面向初级读者写的, 尽管这些书一般风评很好, 但是看起来真的可能让你着急. 我简单说说我看过的书(大部分都是快速阅读,没有深入总结).

  • < Python In a Nutshell>: 面向有其他语言经验的读者, 既言简意赅的把所有特性陈列出来, 也详细的说明了重要的特性, 详略得当.
  • < Fluent Python>: 总的来说,把Python中重要的特性/组件都深入探索了一遍,是一本不错的进阶读物.书的叙事风格非常详细,但是需要自己动脑子去总结一些关键结论.这本书的 Further reading 写的都很好,类似于优秀论文的Reference,可以作为继续学习的一个优秀向导.
  • < Learning Python>: 面向首次学习编程的人. 上册主要在说语言特性,下册偏应用. 由于过于啰嗦,没看完就停了
  • < Beginning Python: From Novice to Professional>: 面向初级读者, 足够简单,总体内容比较浅,不如官方文档好.
  • < Python Cookbook> : 面向进阶Python用户, 大致相当于C++的< Effective C++ > ,条目之间比较独立, 工程价值比较高
  • < Core PYTHON Programming (2e)> : 面向进阶Python用户, 但内容太过老旧, 现在(2019)已经没有必要去了解Python2的特性了. 注意,中文的"Python核心编程(第三版)"实际对应的是"Core Python Application Programing",这是一本完全不同的书, 主要专注于应用层.

一般性主题

  • Python 的 Data Model 用Object Model称呼更合适,它实际是在描述Python中对象的基本特性,具体而言,就是所有Object都支持的一系列 magic method. 这些Magic Method 是所有类型都(可以)支持的, 是自定义类型接入Python 对象体系的主要手段. Reference/DataModel是权威参考来源.利用Python灵活的对象模型,我们可以在必要时实现出非常灵活/Magic的功能.
  • "Everything is Python Object", 所有对象都是封装过的对象,即便是int这种"内置类型"也不例外.
    • 在C层面,每个Python Object对象都包含了一个header,里面存储至少两个指针refcnttype,所以一般说Python对象的内存效率是比较差的.
    • type.__class__type, type.__bases__(object,),object.__class__type,object.__bases__(,),这四个属性是为了避免无限递归而设置的固定值,不必探究其含义.
  • "Every Expr’s value is Reference"
    • 从原理上说,所有expression返回的都是一个reference to some object. 从直观上说, 你在代码里写的一段代码,如果对应了某个对象,那么它一定是这个对象的引用.
  • Python的各类运算符解读起来都和C++不同,一定要注意以下几点.
    • Python的等号运算符总是对应Bind,而不是拷贝/移动.
    • 把所有的 "plain assign" 都读作 "bind", 在过渡阶段能更容易帮你理清思路.
    • 参数传参也是按绑定的语义实现的.
  • a.attr = fooa[item] = foo将对应函数调用setattr(a,"attr",foo)a.__setitem__("item",foo),也和拷贝/移动无关,*=,+=这样的augmented assign也总是对应函数调用.
  • Python 的运算符大多有形式多样的fallback,这和C++非常不同,例如 a+=b可能调用a.__iadd__(b),也可能调用a = a.__add__(b)
    • 重载运算符失败时,应当优先返回NotImplemented(注意,不是NotImplementedError),这样python可能可以进一步进行fallback.例如 对于中缀加法, a + b ,python会优先尝试a.__add__(b),如果它不存在或者return了NotImplemented,则解释器会fallback到b.__radd__(a)
  • __future__并不是一个module,而是一个针对解释器的特殊mark. 很多Python特性在没有正式发布之前也可能试验性的先被实现出来, 或者从新版backport到旧版本. 例如某个特性预计3.11发布,可能在3.7就已经有初步实现了,只需要从 __future__ import 这些特性即可,from __future__ import foo读作“启用foo特性”更加合适。
  • 为了提升效率,大部分内置类型的接口都有C层面的短路实现,这些短路实现会绕开bytecode,例如,内置类型cast到bool时可能不会调用__bool__,如果我们继承内置类型并定制了__bool__,这个定制版本可能有时候就不会被使用到,所以一般不建议直接继承内置类型进行定制.
  • python类型向bool转换的规则也比较复杂,在不明确隐式转换的具体含义时,最好还是用显式的判定.
  • Python的dot运算符涉及了setattr和getattr,是一个巨坑,一定要注意,后面会单列.
  • del a的效果:解除名字与对象的绑定, 也就是是删除名字并将对象的引用计数减1.
    • del a.mdel a[key]将触发函数调用.
  • 引用计数到0时,Cpython会立即回收对象.
  • GC在解释器退出时的顺序是比较混乱的. 例如,module可能会先于module中的class销毁,导致class的__del__无法访问module内的全局变量
    • 事实上,python标准对GC的约束是很弱的,它只要求"仍然reachable的对象不可被销毁".
    • 换个角度说,如果希望控制析构的顺序, 那么必须通过成员手动构造一个依赖链条
  • __del__只会在对象被销毁时调用, 而Python解释器并不保证进程退出时会销毁所有对象
  • 在涉及自定义operator[]时,最好能支持,int,Tuple[Any],slice这三种类型作为key.
  • Python的"不可变类型"是非常弱的限定,尤其是对用户定义的类型. 一般来说,你总能找到一些hack来修改"不可变类型"的实例.
  • python内置的weakref在实现复杂数据结构时非常有用, 可以有效避免循环引用.
    • 循环引用可以被GC处理,但这一般意味着你的代码逻辑很混乱.
  • python的int和string并不总是intern的,也就是说, 对于相等的数值(字符串),他们的id()不一定总是相同.
  • module 的 import 会做两件事:
    • bytecode compilation: 这包括了传统意义上的lexing,parsing,code gen等。
    • top level code execution: module 被 import的时候, 位于这个module内的global域内代码都会被执行一遍。
  • tar = deco(tar)可以用装饰器语法糖描述
@deco
tar
  • 格式化:
    • __repr__返回的字符串应该能直接被解释器执行,例如f"Vector({self.x!r},{self.y!r})"就是不错的例子.其中!r用于控制格式化的格式,保证得到Vector(1,2)这样的结果,而不是Vector("1","2")
    • __str__返回的字符串则应该更容易由用户阅读.
    • 对Python而言,优先实现__repr__更好,因为在未定义__str__时,会自动fallback到__repr__
  • 如果域X不含名字,那么依照:LEGB的规则查找名字,注意,是查找identifier, 查找attr不使用这个规则.
  • Builtin:内置名字, 每个模块逻辑上都有自己私有的一份bultins,可以安全的修改其中的bultins.attr,而不影响其他模块
  • 通过"global"和"nonlocal"可以使得当前作用域的"plain assignment"能重新绑定位于外部的名字,而不是定义新的local.
  • import阶段时,class body 是会顺序执行的,function body 则不会
    • class body 确实是一个新的scope,但是这个scope 仅在import阶段时有local的意义,在其他时候,这个scope都不参与名字查找,必须通过C.attr或者self.attr这种语法,使用getattr来查找其中的名字。
  • 可以用@classmethod,@staticmethod来修饰类内的函数,这将动态修改函数的__get__,使其不再返回bound method
  • 实例化的过程总是先newinit,init可能会因new返回的类型不同而被跳过。
    • 这个过程对于metaclass也不例外,metaclass还额外有一个prepare的步骤.
  • __slots__在现代系统中应该没有使用的需要,与dict相比,它仅仅是空间开销更小。
  • Iterable: 需要支持__iter__,该函数返回一个Iterator,且每次调用都应该返回一个新的独立的迭代器.
  • Iterator: 需要支持__next____iter__,Iterator的__iter__惯例上必须返回self,__next__则需要在终止时抛出StopIteration异常.
  • for v in expr:的语法糖为
_iterator = iter(expr)
try:
    while  True:
        v = next(_iterator)
        ...
except  StopIteration:
    ...
  • with expr as foo的语法糖近似为,通过该语法糖可以看出, 一般__enter__也应当返回self
ctx = expr
foo = ctx.__enter__()
try:
    # sth
except:
    pass
ctx.__exit__(...) # 只有在 sth 抛出异常时exit才会传入三个与异常相关的信息。
  • python的GIL并不影响多线程的并发性,因为解释器会每5ms定期中断一次,强制释放GIL并切换"线程"。

Attr Resolve Rule

  • obj.my_attr -> getattr(obj,"my_attr") -> obj.__getattribute__("my_attr")
__getattribute__ :
    attr_in_mro = get_mro_attr(obj,"my_attr")
    if attr_in_mro has __get__  and  __set__, call __get__  else
    obj.__dict__["my_attr"] else
    if attr_in_mro has __get__, call __get__  else
    attr_in_mro else
    raise  AttributeError to let the VM fall back to obj.__getattr__("my_attr")
  • obj.my_attr = expr -> setattr(obj,"my_attr",expr)-> obj.__setattr__("my_attr",expr):
__setattr__:
    if get_mro_attr(obj,"my_attr") has __set__, call __set__  else
    obj.__dict__["my_attr"] = expr

注意,get_mro_attr(obj,"my_attr")用于在obj的MRO中查找第一个出现的同名attr, 大致为

for cls in obj.__class__.__mro__:
    if hasattr(cls,"my_attr"):
        return getattr(cls,"my_attr") # 这里将开始递归

这一套Resolve系统中,涉及了descriptor的概念, 实现了getset的类型就称为descriptor.

property 和 bound method 都是依赖descriptor来正常工作的

bound method: 定义在class内的函数会有一个特别的__get__,这个__get__会直接动态创建一个lambda,把instance和function直接绑定起来.

Python OOP

Python的多态是通过鸭子类型实现的,且在若干PEP讨论之后,鸭子类型这种特性将永远作为Python的核心特性。

继承体系并不是Python实现多态的唯一途径,使用继承主要优点为更加符合传统OOP的惯例,便于代码的阅读。
在Python的继承体系中,实际不存在狭义的override概念,因为所有obj的真实类型obj.__class__在运行期都是已知的,所有函数调用都是动态dispatch.

Python 中约束鸭子类型的四类Pattern

实践中,可以通过MyPy这样的静态分析工具实施静态检查,或者通过abc进行运行期检查.(静态检查必须依赖外部工具,运行期检查则是有原生的支持)

  • 静态OOP:type hint 默认按此规则进行约束,要求类型必须符合继承关系才能保证compatible
  • 静态鸭子类型:使用protocol作为type hint时,按照此规则进行约束,要求类型的"接口"满足约束即可(不限制类型)
  • 动态OOP: 使用abc作为基类时
    • 在运行期会多额外的安全检查,abstractmethod必须在派生类被override,否则类型实例化时就会有runtime error。
    • 可以通过isinstanceissubclass对类型进行检查,判断类型的接口是否满足要求。
  • (动态)鸭子类型:假装类型完全符合要求,由runtime负责进行检查,例如名字不存在或参数错误等都会直接抛出异常。
    • 例如,当我们需要类型支持__len__时,我们不是使用if hasattr(obj,"__len__"): len(obj),而是直接len(obj),任由异常抛出。
    • 通常我们应该在尽可能早的位置对类型进行试探,这样可以在尽可能早的位置抛出异常。

注意,Type hint 是确确实实会转换为annotation存储在对象中的,这可能会引入额外的import开销,所以一般建议将type hint 作为stub存放在单独的项目中,从而能灵活的排除这种开销。

注意,isinstanceissubclass的检查是非常容易定制的,不要认为这个检查严格按类型或继承关系进行检查,也就是说 isinstance(obj,cls)并不等价于obj.__class__ is cls

  • abc 典型用法举例
import abc
class  Foo(abc.ABC):
    @abc.abstractmethod
    def  bar(self):
        pass

@Foo.register
class  ConcreateFoo:
    def  bar(self):
        pass

class  ConcreateFoo2(Foo):
    def  bar(self):
        pass

class  ConcreateFoo3:
    def  bar(self):
        pass
print(isinstance(ConcreateFoo(), Foo)) # True, register is fine
print(isinstance(ConcreateFoo2(), Foo)) # True, inheritage is fine
print(isinstance(ConcreateFoo3(), Foo)) # False, duck typing is not fine

Generic

对于MyGeneric[T], 本文按照C++的惯例, 称MyGeneric[Cat]这样的操作为"实例化".

  • 对于TypeVar, 可以通过bound来约束实例化时允许传入的类型,当其bound是protocol时,实例化时传入的类型必须能够进行鸭子型的替换,当bound时concreate类型时,则只能按继承关系实例化.
  • 泛型实例之间没有继承关系,如SomeGeneric[Cat]SomeGeneric[Lion],两个实例是彼此独立的, 泛型实例之间是使用Invariant,Covariant,ContraVariant来描述接口的兼容方式:
    • def foo(v:SomeGeneric[Cat])为例
    • Invariant(默认): 绑定的实际类型必须和实例化时的类型一致,例如,上面的函数中,如果SomeGeneric[T]关于T是Invariant的, 那么就不能将SomeGeneric[Animal],SomeGeneric[Lion]的实例传入函数foo
    • Covariant: 可以绑定子类,例如,上面的函数,如果SomeGeneric[T]关于T是Covariant,则可以绑定SomeGeneric[Lion],但是不能绑定SomeGeneric[Animal]
    • Contravariant: 可以绑定基类,例如,上面的函数,如果SomeGeneric[T]关于T是Contravariant,可以绑定SomeGeneric[Animal],但是不能绑定SomeGeneric[Lion]

Invariant 一般意味着严格匹配, Covariant意味着可以向派生类放松, Contravariant意味着可以向基类放松.

  • 典型的例子是: Callable[[ArgT,...],RetT],我们一般说 Callable is covariant on RetT, is contravariant on ArgT.
  • 例如, 对于一个函数def foo(call_back:Callable[[Cat],Cat]),我们可以按照下面的方式调用它,我们希望call_back的输入可以接收Cat, 输出是一个Cat
def  foo(call_back:Callable[[Cat],Cat]):
    small_cat = SmallCat()
    new_cat = call_back(small_cat)
    # do things with new cat

如果callback的签名是def cb(v:Animal) -> Lion ,那他是可以的,但是def cb2(v:Lion) -> Animal则是不行的.对于foo而言,当它调用回调函数时,会传入一个small_cat的实例, 即call_back(small_cat),那么显然,对cb而言,small_cat可以被当成animal来处理,对cb2而言,把small_cat当 Lion显然是有问题的. 从返回值上说,cb(small_cat)返回了一个Lion,foo把它当cat来用是没问题的,但是cb2(small_cat)则返回了一个Animal,显然foo拿他当cat来用是不太合理的

MRO

在涉及继承时,Python 的 MRO 和 super() 是相辅相成的,二者搭配起来看才有实际意义。

super()指代的总是final MRO中与当前类型相邻的下一个类型,这一点一定要注意, 例如,对于没有基类的class X,其内部定义的method也可以使用super(),但super()具体对应的是哪个类型,只有在最终的MRO确定后才能知道。(这种没有基类且使用super()的类型常常用于实现Mixin)

class X:
    def foo(self):
        super().foo()
        print("X")
class Y:
    def foo(self):
        print("Y")
class Z(X, Y):
    def foo(self):
        super().foo()
        print("Z")
z = Z()
z.foo()
print(Z.__mro__)

import系统

基本特性:

  • "Module": Module是位于文件系统中的可供导入的"文件",常见的有.py,.pyc,.so,"Frozen module"或"目录"
    • __path__属性的Module就被称为pacakge.
    • 目录作为模块时,默认是namespace package, 可以通过添加一个__init__.py变成普通pacakge.
  • 当import一个名字为import a.b.c.d的模块时,加载逻辑近似为
    • 尝试读取cache中名为a.b.c.d的module, 如果不在cache中,则
    • 尝试在a.b.c.__path__查找名为d的模块,例如d.py,d.so, 如果a.b.c也不在cache中,则开始递归, 如果a.b.c在cache中,但是a.b.c.__path__下没有对应模块,则加载失败
  • m.__name__: 唯一决定一个模块,是以dot分割的一系列名字, 作为sys.modules内的key参与缓存
  • m.__dict__: 用于提供模块内的"全局变量"
>>> import sys
>>> current_module = sys.modules[__name__] # sys.modules stores imported modules
>>> current_module.__dict__  is  globals()
True
>>> globals()["__name__"] is  __name__
True
  • import 语句只允许加载模块, from xxx import y则额外允许加载模块内的attr
    • import默认不会为模块起别名,必须通过as x后缀起别名.
  • from x.y.z import v 实际是一个语法糖:
    • 先尝试 import x.y.z as _tmp; v = _tmp.v(优先使用attr)
    • 再尝试 import s.y.z.v as v
  • 相对引用也是一个语法糖,相对路径实际是对__package__进行strip
    • 例如,假如当前模块内,package值为"a.b.c",那么from ..d import e等价于from a.d import e
  • 星号import时,from <> import *时,只能import attr

Packging system

标准的build过程

  1. 创建sdist目录: 这是原始项目的一份独立拷贝,它内部仅包含了会参与packging过程的文件
    1. 拷贝所有MANIFSET描述的文件到sdisit目录
    2. 拷贝所有pyproject侦测到的py文件到sdisit目录
    3. 拷贝setup.py到sdist目录.
  2. 在sdist目录中开始执行构建, 注意, 在使用python -m build时, 这个sdist目录是一个临时目录, 在setup.py中将无法稳定的获得原始的代码目录