Python for C++ Programner

Last Updated on 2020年7月20日

这里谈一下个人的学习建议. 首先阅读官方的入门教程, 看完这一部分,对于一个熟练的C++程序员,应该可以凑合写出可堪一用的代码了. 然后是Python in a Nutshell 3rd第七章之前的内容(不含第七章),这一部分看完, 你就能基本了解Python的运行机制, 写出质量合格的代码了(但可能不够Pythonic).

上面两步完成后, 对一个熟悉C++的程序员而言, 基本任何Level的Python资料都可以看了. 如果偏应用,你可以继续看Python in a nutshell中感兴趣的部分(或者任何一本偏应用的书); 如果偏理论,你可以选择Fluent Python.

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

书评

Python的受众非常广泛, 导致有非常多的Python书籍是完全面向初级读者,甚至无计算机背景的”程序盲”而写的, 尽管这些书一般风评很好, 但是看起来真的可能让你着急. 我简单说说我看过的书(都没有仔细研读过).

  • < Python In a Nutshell>: 面向有其他语言经验的读者, 既言简意赅的把所有特性陈列出来, 也详细的说明了重要的特性, 详略得当. 特别是这本书介绍了近来Python的业界生态,如重要的第三方库,IDE等(从2019年看仍不过时), 非常有助于你迅速找到状态.
  • < 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提供了一个相当开放的运行框架,你可以拦截很多操作,实现各种骚trick, 最好不要干这种事.
  • 与C++不同a,a.m,a[key]使用的方法有区别. 了解这些不同有助于你理解一些看起来奇怪的机制.
    • 例如a[0:2]=[1,2,3,4,5]这样的语法,在C++的惯例中意为a.operator[](0:2).operator=([1,2,3,4,5]),我们操作的是a.operator[](0:2)返回的某个对象,且这个对象通常不是a,在Python中,其实它的意思是a.set(range(0,2),[1,2,3,4,5]),我们是在直接操作a.
    • a=expr这样的语句称为”plain assignment”,只有引用绑定的语义.其中a必须是一个标识符.
    • a[key]a.m相关的操作总是会触发函数调用,例如:
      • 作为右值时,a[key]相当于a.get(key),一般会返回某个对象的引用
      • 作为左值时,a[key]=expr相当与a.set(key,expr)
  • 一般而言,Python称呼一个对象为”不可变”时,意思是:尝试修改该对象时,不会inplace原地操作,而是产生一个新的对象.由于Python的限定很弱,当我们使用一些习惯上称为”不可变”的类型时,不要通过任何trick来修改其内部的值.
  • 逻辑上,所有的内置容器类型中存储的都是引用,而不是具体的对象.
    • 在C++看起来, 相当于存储的是指针.
    • 对容器有浅拷贝和深拷贝的概念,浅拷贝只拷贝一层, 深拷贝则保证创建完全独立的两个容器.
  • Python的惯例中,a.foo()从不返回self
    • 如果返回了同类型的对象,一定是一个新实例, 且此时a绑定的对象没有任何变化.
    • 如果返回了None,则说明a绑定的对象被修改了.
  • del a的效果:解除名字与对象的绑定, 也就是是删除名字并将对象的引用计数减1.
    • del a.mdel a[key]将触发函数调用.
  • 对于各种运算符,解释器可能会解释为不同的函数调用. 如 x+=y可能是x.__iadd__(y),或者x=x.__add__(y).
  • 由于Python的语法非常多变,所以用()辅助解释器解析代码非常常见.
    • 如果不用(),for num in list默认是被解析为一个rangefor语法,这里缺少冒号:,肯定报错. 而(for num in list)则将被解析为一个列表生成器对象.
    • ()被解释为空的tuple,(obj)被解释为obj,(obj,)被解释为仅包含objtuple
  • 逗号表达式做右值时, 整体是tuple (一般用()包住来强调这一点)
  • 逗号表达式做左值时,将对应拆包赋值语法,即逗号表达式 = iterable
  • 列表推导可以展开成for循环,对于复杂的情况,只需要从左到右不断缩进展开即可.本质上列表推导语句创造了一个生成器,并按照list(iterable)的形式创建对象..
list_new=[expr for num in list_num if num >8]
# 等价于
num_gen = (expr for num in list_num if num>8)
list_new=list(num_gen)
# 等价于
def num_gen2():
    for num in list_num:
        if num>8:
            yield expr
list_new=list(num_gen2())
  • python3的字符串str逻辑上是一个UTF-32数组,直接存储原始的代码点.这种设计导致了一些基本的交互问题
    • 输出至其他环境时,需要指定编码,将其转为字节流.即bytedata=str.encode()
    • 输入字节流时,需要指定解码,将其转换为UTF-32数组.即str=bytedata.decode()

函数

  • 函数定义语句只会执行def foo那一行,它将创建一个函数对象,并绑定到foo上.
    • 函数体是在调用时才执行的,只要函数体内没有语法错误就OK,其他错误将推迟到被调用时
    • 默认形参是在执行def时创建的, 它对应了某个对象,在没有传入实参时,就会传入它.如果这个对象是可变的,就可能引起一些问题.
  • 在函数内可以嵌套的定义函数,称为闭包,闭包的def是在函数执行时才动态进行的.
  • 如果函数foo的输入只有一个, 那么foo就可以被当做装饰器使用,支持装饰器语法,装饰器语法常常用于wrap输入函数,并生成一个新的函数
@foo
def bar(...)
    pass
#等价于
def bar(...)
    pass
bar=foo(bar)

生成器

Python的生成器最初的设计目的很简单: 节约内存的可迭代对象, 但经过python3的拓展后,生成器还可以用作实现协程coroutine的工具.
* 从语法上说,生成器是带有yield语句的函数.
* 这种函数的调用语句不会返回值,而是返回一个新的生成器对象.
* 生成器对象会记录自己的函数栈及pc等, 每次外部重新触发生成器对象时,会从上一次yield的地方开始执行.
* 生成器对象执行return时,会触发generator.close()

def foo(x,end):
    while x<end
        x*=2
        x+=1
        yield x
    return
generator=foo(0,10)
next(generator)#吐出1
next(generator)#吐出3
  • python3扩充了yield的功能,以支持”coroutine”
    • 添加了yield from语法,它可以转发yield
      • 和”rangefor”类似, yield from foo(...)只会执行foo(...)一次, 之后会直接转发值.
      • yield from可以用于实现递归, 其开销和递归函数调用是一致的.
    • yiled可以返回值,它返回的值是外部通过generator.send(value)发送的,外部发送值后,控制流将转给generator, yield语句处将返回value,并继续执行.
      • 如果外部通过next(generator)来切换控制流,那么相当于generator.send(None)
  • generator.throw(obj)可以发送异常,throw过去的异常在generator看来仿佛是被yield语句抛出的,所以yield语句也可以放在try-except
  • del generator会触发generator.close(),close()会调用generator.throw(GeneratorExit())
    • Python规定,在generator.close()后,generator必须rasie一个GeneratorExit型的对象,用于通知解释器自己已经正常结束了. 如果你不去catch generator.close()发送过去的异常,那么这个异常就一定会被yield语句抛出, 也就默认满足了这个条件.

作用域级别的名字搜索

  • 如果域X不含名字,那么依照:Local-Enclosing->Global->Builtin逐层查找
    • Local:当前所在作用域就是Local.
    • Enclosing: 仅对闭包有这一步, 闭包可以引用外层函数体定义的名字,直到外层不是函数体为止.
    • Global:.py文件的顶层是该模块的global
    • Builtin:内置名字
  • 没有特殊处理时, a=expr这样的”plain assignment”语句一定会创建一个新名字, hide外部作用域的名字.通过”global”和”nonlocal”可以使得当前作用域的”plain assignment”能重新绑定位于外部的名字,而不是hide.
    • “nonlocal x”: 按闭包的规则向外查找x,不会到达global
    • “global x”: 在”global”内查找x

Python OOP

Python3的OOP模型和Python2很不一样, 和Cpp也很不一样. 由于我不了解Python2,所以至多仅和Cpp对比

  • Python中,class C这样的语句实际创建了一个”type object”, 然后把C绑定到这个对象上. 本节中, class用于指代一个自定义类型, 用C举例, instance指代一个class的实例, 用x举例.Cx是相当独立的两个对象
  • Python没有静态绑定,所有??.attr型语句都是通过运行期的名字查找来动态确定的. 这是实现”鸭子类型”和”多态”的基础,一般情况下:
    • 绑定时??.attr=expr:总是执行??.__dict__["attr"]=expr
    • 引用时??.attr:从??.__dict__["expr"]开始,顺着继承关系逐步查找所有__dict__,直到找到为止.
    • 如果C的继承体系中存在名为attrproperty,那么除C.attr=expr外, 对C.attrx.attr的访问/绑定/del都将被拦截为propertyfget/fset/fdel
  • 从Cpp的角度看,C的attr相当于是static成员,主要通过class body定义, 而xattr是对象的普通成员,只能在__init__时创建
    • 在通常情况下,C.foo(args)的调用是简单直白的,x.foo(args)则会被解释器替换为C.foo(x,args)

Atrribute机制

  • Python OOP中的诸多特性都是通过其神奇的”attribute”使用机制实现的.主要包括:多态, property, bound method.
  • 除了少数内置的特殊attribute可以直接被解释器使用,其余attribute都是通过__dict__间接实现的
    • 每个class和instance都有其独立的的__dict__
      • class的__dict__是不能直接写入的,本文中的写入操作仅仅是表达这个意思.
    • 在类定义阶段创建的attr=expr,相当于是直接操作C.__dict__["attr"]=expr
  • 绑定C.attr=expr
    • 总是执行C.__dict__["attr"]=expr
  • 引用C.attr
    • 按继承关系逐层向上确定v=??.__dict__["attr"]是否存在,如果找到的对象v__get__,则会触发v.__get__(None,C),否则就是v
  • 绑定x.attr=expr, 直接触发x.__setattr__("attr",expr), 这个函数的默认实现为:
    • C.__dict__开始,按继承关系逐层向上确定v=??.__dict__["attr"], 若v存在,且v__set__,则触发v.__set__(x,expr)
    • 否则, 执行self.__dict__["attr"]=expr
  • 引用x.attr,直接触发x.__getattribute__("attr"),该函数的默认实现为:
    • C.__dict__开始,按继承关系逐层向上确定v=??.__dict__["attr"],若v存在,且v同时有__get____set__,则触发v.__get__(x,C)
    • 否则,如果x.__dict__["attr"]存在, 则就是它.
    • 否则,再次从C.__dict__开始按继承查找,如果最终得到的v__get__,那么触发v.__get__(x,C)
    • 如果最终还是找不到,就会触发x.__getattr__("attr"),这个函数的默认实现就是报错.

property

  • property就是同时定义了__set__,__get__,__del__的特殊类,当class的某个attr是property时,因为attribute的使用规则,仅C.attr=expr仍是常规语义, C.attr,x.attr=expr,x.attr都会被拦截为预先设好的函数调用.

bound method

  • 首先,所有def创建的函数对象都定义了__get__而没定义__set__. 本节只讨论def型函数
  • 对于C.foo(args)这样的调用,找到foo后一定会触发某个可调用对象vf=v.__get__(None,C), 返回一个普通的function对象f,最终调用f(args),再转发为v(args).
  • 对于x.foo(args)这样的调用,查找foo的过程中可能性就多一些.
    • 如果x通过x.foo=callable绑定了自定义的可调用对象callable, 语义上就只能是callable(args)
    • 如果x没有绑定自己的foo,那么x.foo最终就肯定会触发某个函数vbm=v.__get__(x,C),返回一个 bound methodbm,bm内部会持有xv,在触发调用bm(args)时,实际会转发给v(x,args)

其他

  • class C定义时,”class body”是完整执行的,只有在class body执行完之后,才绑定到C.
    • 在class body执行完成前,是不能使用C这个名字的.
    • 在 class body 中, 按照LGB的顺序检索名字, 如果嵌套的定义class, 要注意名字检索问题.
  • 所有自定义class都隐式的继承自object,从而获得默认__init__,__new__等dunder函数
  • 在class body内定义函数时,函数体内的名字查找遵循LEGB的规则, 所以class body的作用域并不在其中, 因此,函数不能直接使用类的attribute,只能通过C.attrself.attr这样的形式使用
    • 函数定义时,函数体不会执行,只有在调用时才会执行, 所以C.attrself.attr是没问题的
  • 可以用@classmethod,@staticmethod来修饰类内的函数,经过修饰后,其调用形式将不按默认的C.foo(args)x.foo(args)流程处理.
    • 这种带修饰的函数与一般的函数相比,仅仅是__get__的行为不一样,名字检索的流程是一致的
  • instance构造的流程大致相当于C++的”malloc + ctor”
x=C(args)
#等价于
x = C.__new__(C, args) # `__new__`是一个classmethod, 返回的空对象x连__dict__都没有
if isinstance(x, C): x.__init__(args)
  • 可以通过拦截__new__来管理对象的创建, 下面就是一个典型的单例模式
    • 拦截__new__,仅在需要的时候创建对象.
    • 拦截__init__ ,以避免重复的初始化同一个对象.如果派生类要添加自己的新成员, 那么就必须写这样逻辑的__init__
class MyClass(BaseClass):
    _instance = None

    def __new__(cls, name):
        if not cls._instance :
            cls._instance  = super().__new__(cls)
        return cls._instance 

    def __init__(self, name):
        if hasattr(self, "_inited") and self._inited:
            return
        super().__init__()
        self.name = name
        self._inited = True


foo = MyClass("1")
print(foo.name, id(foo))
foo = MyClass("2")
print(foo.name, id(foo))
  • 如果在class body中定义了成员__slots__=("name1","name2"...),那么这个class的所有instance都只能绑定__slots__提供的name作为attribute
    • __slots__将抑制解释器创建__dict__,以节省空间
    • 继承类不受影响, 继承类必须在其class body中定义自己的__slots__
  • super(cls,obj):语义比较复杂,在仅使用单继承的场合中,super()就是指父类的class,可以使用super().foo(self,args)这样的语法

导入系统

  • Python的导入系统是基于Module-模块实现的.
    • 模块对象如果有了__path__属性,就会被作为Package-包来处理,也就是说,”包”是特殊的”模块”.后文用P特指包对象,用M特指一般的模块对象.
    • 传统上,包特指包含了__init__.py文件的目录(__init__.py内容可以为空,但必须存在),目录名就是包的名字.
    • 内置模块bultin modules不对应某个独立的文件,其余的模块都对应了一个python模块文件.
    • 例如,名为XMod的模块一般对应了一个XMod.py,或者名为XMod的文件夹(该文件内必须包含__init__.py)

简单导入

  • import Xfrom X import item时,对X的检索.
    • 1.优先在builtin module中查找名为X的模块
    • 2.在启动脚本main.py所在的目录查找名为X的模块(注意,如果main.py是符号链接,那么实际是从原始文件位置查找的)
    • 3.在sys.path中的指定目录下查找
    • 4.在PYTHONHOME内的.pth指定目录下查找

注意,在Python查找模块时,总是不会自动进入子目录.

NOTE: python的实际实现中: 解释器启动后,sys.path的初始值是环境变量PYTHONPATH,会自动把作为main.py文件的所在目录插入到sys.path的头部,把PYTHONHOME*.pth文件的内容加载到sys.path尾部. 所以对python解释器而言,它只需要在sys.path中查找即可.

  • import X后,X的可用attr是由模块开发者通过__all__设定的,__all__中存储的名字称为模块的公开属性(public attr).
    • 没有在M.py手动定义__all__时, M.py内的所有非_开头的全局名字都会被添加到__all__
    • 没有在__init__.py中定义__all__时,__init__.py内的所有非_开头的全局名字都会被添加到__all__中,这个__all__将作为Package的可用attr列表.
    • 换言之,对于一个包,空的__init__.py将使这个包对象没有任何attr可用
  • from X import item的机制随着item不同,意义不同.
    • 优先在X的模块属性中查找名为item的属性(不必是公开属性),如果找不到名为item的属性,将尝试查找X.__path__下名为item的模块(即item.py或名为item的文件夹)
    • from X import *相当于import X,然后再把X的所有attr alias 到当前空间中. 也就是说,只有模块的公开attr会被引入当前空间,不能引入私有的attr.
  • 通过from X import item进行导入时,模块虽然完整载入了,但是并不会把模块对象绑定到名字X上,所以当前空间无法使用X

绝对路径导入

  • import A.B...Q.Xfrom A.B...Q.X import item时,对A.B...Q.X的导入
    • 限定: 最后一项X必须是一个模块(可以是包,也可以是普通模块)
    • Python先按简单导入的规则查找名字为A的模块(如A.py或名为A的目录),载入A模块,并绑定到名字A
    • 然后查找A.B
    • 如果A不是包,就会直接报错
    • 如果A是包,就在A.__path__目录下查找名为B的模块,载入该模块,并绑定到A.B
      • 显然,把模块对象绑定到A.B上的操作是在A载入后执行的,如果A的__init__.py定义了名为B的公开属性,则该属性会被覆盖掉.
    • 如此循环下去,直到X被载入.
  • 从上面可以看出,A.B....Q.X
    • AQ必须是包
    • 中间过程的所有包都被完整载入了,在当前空间中A,A.B,A.B.C等等都是可以完整使用的名字.
  • from A.B...Q.X import item和简单情况一致

相对路径导入

  • 只有from支持相对路径导入, 规则和from的绝对路径导入一致,但要求当前目录必须是一个包.

其他

  • 优秀的实践风格: import导入的总应该是一个模块对象.
    • import X已经满足
    • from A.B.C... import X: X总应该是一个模块
  • 每个模块都有它自己的私有符号表,该表用作模块中定义的所有函数的全局符号表
    • 每个模块逻辑上都有自己私有的一份bultins,可以安全的修改其中的bultins.attr,而不影响其他模块
    • 如果要修改自己模块的builtins,需要先import builtins,再直接修改builtins.attr即可
  • 模块载入时,会把载入的模块对象额外添加到sys.modules列表中
    • sys.modules相当于cache,只有未import过的module才会触发module对象的创建, 否则import会直接从sys.modules绑定.
    • importlib.reload(X)可以用于刷新sys.modules中已经加载的模块.
    • 注意,reload只是创建了一个新的module对象,并绑定到sys.modules中旧对象的位置,如果之前通过foo=M.attr这样的语句绑定过,那么foo使用的仍然是旧模块对象的attr
    • importlib.import_module("modname")可以手动创建一个模块对象,从而让我们在不刷新sys.modules的同时使用新的module

Pipenv

  • pipenv 是一个相对独立的工具,可以通过pip/apt/brew 安装.
    • 官方的venv最初用于替代virtualenv, 而现在的pipenv似乎是要把它们都替代掉.
    • pyenv仍然是独立的组件.
  • virtualenv的风格是创造一个”虚拟环境”,仿佛你在一个新的shell里执行操作. pipenv的风格则将更像是remote controller, 你通过指令与虚拟环境进行交互,仅在必要的时候才进入虚拟环境的shell.

异常

  • Python的异常机制是围绕raise,try展开的
    • 如果后跟了finally,那么无论try-except语句块如何结束,都将执行finlaly(即使在try中return也会触发finally)
try:
    expr
except TypeName [as err]:
    pass
finally:
    pass
  • 尽管try-except支持 else语法,但是同样的,这个else引入的复杂性要大于它的便利性.
  • 最后一个excpet一般不带有任何TypeName,作为default来接受任何异常.
  • 由于Python的设计中静态检查很弱, 所以很多检查工作需要放到运行期执行,常常就会涉及异常.
    • 例如, 我们希望foo(obj)仅处理某一个类型的对象,那么,检查isinstance(T,obj)失败时,一般就会抛出一个异常.
    • Python自身大量的使用这种策略, 当检查到不符合预期的情况,就直接抛出异常
  • RAII异常安全的Python实现(基于with)
    • expr需要返回一个对象tmp,该对象支持__enter____exit__,可选的as可以用于为tmp命名.
    • block执行前,会自动执行tmp.__enter__
    • 无论block是否被异常中断,都可以保证block结束时,tmp.__exit__被执行
with expr [as varname]:
    block