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,里面存储至少两个指针
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
则额外允许加载模块内的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
- 例如,假如当前模块内,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中将无法稳定的获得原始的代码目录