Python for C++ Programner
这里谈一下个人的学习建议.
首先可以阅读官方的入门教程, 看完这一部分, 对于一个熟练的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,里面存储至少两个指针
refcnt和type,所以一般说Python对象的内存效率是比较差的. type.__class__是type,type.__bases__是(object,),object.__class__是type,object.__bases__是(,),这四个属性是为了避免无限递归而设置的固定值,不必探究其含义.
- 在C层面,每个Python Object对象都包含了一个header,里面存储至少两个指针
- "Every Expr’s value is Reference"
- 从原理上说,所有expression返回的都是一个
reference to some object. 从直观上说, 你在代码里写的一段代码,如果对应了某个对象,那么它一定是这个对象的引用.
- 从原理上说,所有expression返回的都是一个
- Python的各类运算符解读起来都和C++不同,一定要注意以下几点.
- Python的等号运算符总是对应
Bind,而不是拷贝/移动. - 把所有的 "plain assign" 都读作 "bind", 在过渡阶段能更容易帮你理清思路.
- 参数传参也是按绑定的语义实现的.
- Python的等号运算符总是对应
a.attr = foo及a[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)
- 重载运算符失败时,应当优先返回NotImplemented(注意,不是NotImplementedError),这样python可能可以进一步进行fallback.例如 对于中缀加法, a + b ,python会优先尝试
__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.m或del 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来查找其中的名字。
- class body 确实是一个新的scope,但是这个scope 仅在import阶段时有local的意义,在其他时候,这个scope都不参与名字查找,必须通过
- 可以用
@classmethod,@staticmethod来修饰类内的函数,这将动态修改函数的__get__,使其不再返回bound method - 实例化的过程总是先new再init,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的概念, 实现了get或set的类型就称为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。 - 可以通过
isinstance或issubclass对类型进行检查,判断类型的接口是否满足要求。
- 在运行期会多额外的安全检查,
- (动态)鸭子类型:假装类型完全符合要求,由runtime负责进行检查,例如名字不存在或参数错误等都会直接抛出异常。
- 例如,当我们需要类型支持
__len__时,我们不是使用if hasattr(obj,"__len__"): len(obj),而是直接len(obj),任由异常抛出。 - 通常我们应该在尽可能早的位置对类型进行试探,这样可以在尽可能早的位置抛出异常。
- 例如,当我们需要类型支持
注意,Type hint 是确确实实会转换为annotation存储在对象中的,这可能会引入额外的import开销,所以一般建议将type hint 作为stub存放在单独的项目中,从而能灵活的排除这种开销。
注意,
isinstance和issubclass的检查是非常容易定制的,不要认为这个检查严格按类型或继承关系进行检查,也就是说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__下没有对应模块,则加载失败
- 尝试读取cache中名为
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则额外允许加载模块内的attrimport默认不会为模块起别名,必须通过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
- 例如,假如当前模块内,package值为"a.b.c",那么
- 星号import时,
from <> import *时,只能import attr
Packging system
标准的build过程
- 创建sdist目录: 这是原始项目的一份独立拷贝,它内部仅包含了会参与packging过程的文件
- 拷贝所有MANIFSET描述的文件到sdisit目录
- 拷贝所有pyproject侦测到的py文件到sdisit目录
- 拷贝setup.py到sdist目录.
- 在sdist目录中开始执行构建, 注意, 在使用python -m build时, 这个sdist目录是一个临时目录, 在setup.py中将无法稳定的获得原始的代码目录