python开发的坑
28 Oct 2014 by LelouchHe
吐槽时间
以前我写代码也不喜欢写文档,设计文档可能还有,但最后的manual之类的东西,是从来没写过的.现在终于承受了报应了,我只想大声说
python的文档也太烂了
如果光看官网的话,感觉文档非常充分.但一旦开发开始,你就发现,python的各种坑,都是隐藏在代码中的,看手册压根不解决问题,甚至还有类似”我不期望你理解,但事情就是这样”的API注释
我上一次看到这样的话,还是linux内核的代码注释,对于此,我就不想说啥了
所以,如果要开发python扩展,最好把源码放在手边,因为你时刻需要进去看下对应API真正有什么作用,甚至是查找一些压根没有写的数据和接口
以下只针对python2.7系列
引用计数
时刻要记住的是,如果你在操作C API,你就没有GC的辅助了,所以你需要手动的操作python内部的引用计数(Py_INCREF/Py_DECREF系列).当确定要保存一个python对象(通常是PyObject或指针),就要增加计数,一旦使用完毕,就要减少计数
python对象在内存中的最开始部分,总是PyObject,所以你总是可以完成类似操作的(这一点,在自定义对象时也必须满足)
计数一旦清零,内存就会被释放,所以一定要注意+/-的顺序,搞不好就直接销毁
程序结束时,最好保证计数上是0,你可以跟踪计数来查看这一点.计数为0的话,这个对象操作流程上应该就没什么大问题了(剩下的可能是内存布局之类,后叙)
继承
如果你想完成一个继承关系,最直接的方法是创建类型(PyTypeObject)时,设置好tp_bases为基类类型,这样使用PyType_Ready注册之后,python就自动把2个类关联起来,就能完成一般的OO操作了
但有几个地方需要注意:
- tp_bases需要时PyTuple类型,而且这个PyTuple最后是交由python管理的,所以PyTuple_New一个出来之后,不需要计数减一(啊啊啊,这种规则你得看多少遍API和代码啊).另,虽然python源码里看上去普通PyObject也行,但我这里是从来无法编译通过,即使强制转型,最后也要core掉
- 自定义类型的开始,必须是所继承类要求的相同布局.因为底层的C API,是直接强制转型的,如果内存布局不一致,任何诡异问题都可能发生.这个布局不限于PyObject,比如,继承自Exception类(如果想成为异常可抛出的话,这是必须),开头必须是BaseExceptionObject.所以想继承么?先把基类的对象写到头上
- 基类可能有些操作,必须有某些值,这个也得保证.当然,这个一般是看不出来的,我的方法是程序跑一遍,把有core的地方全部修正.像继承Exception,头部的args就不能为空.这个方法可能不太健壮,最好能全部自定义的,就全部自定义(各种操作都自己来,就没有意想不到的使用了),否则,就得看代码,看看python内部或其他Module是怎么继承的(比如我手边常备xxsubtype.cpp和exceptions.cpp)
- PyTypeObject中有无数的项要填,有的会有默认实现,继承时基类有时也有自己的默认实现,有的时候也必须自己人工实现.所以最好弄清楚主要项的含义.和内存相关的有tp_new(新建对象时,分配内存),tp_init(内存分配之后,初始化),dealloc(析构),我一般都要自定义,因为有很多额外的内存项.一旦人工操作内存,就要时刻牢记计数问题
异常
上面为什么一直提异常呢?因为最近困扰我很久的问题就是自定义异常类型.简单的方法,PyErr_NewException系列就可以,但这是纯python的自定义异常,同其他异常,比如在框架内我们自己的异常体系是无法融合的,我们只能走更通用的自定义类型
而困扰我的,就是根本没有对此的介绍,网上都是类似”我有个绝妙的方法,可惜空白太小我写不下”的解决方案
经过一番摸索,继承异常应该只要以下几个步骤:
- 自定义类型头部需要包含对应异常类型.比如要继承Exception(一般都要继承这个),头部就是上面提过的BaseExceptionObject.
- 构建PyTypeObject时,tp_flags要加上Py_TPFLAGS_BASE_EXC_SUBCLASS.在raise时,python会检查类型是否匹配,就是检查这个标识
- 初始化时,BaseExceptionObject中的三项都需要初始化,dict可以置NULL,args/message最好初始化为空.因为Exception某些方法会用到这些(好像是在输出异常信息时),而且万恶的居然不检查是否为NULL.当然,如果你全部自定义方法的话,就无所谓了
- 管理好异常的计数.自定义异常往往和其他框架的异常体系挂钩,不要让python的异常析构,直接把其他体系的异常清零.所以要提前分析清楚异常对象在整个系统的流向,并保证流向畅通时,计数不会到0
- 外部要raise给python异常,只要使用PyErr_SetObject系列函数设置异常,并返回错误值即可(-1/NULL之类的,不同接口有不同值);python要raise给外部异常,一般先检查API返回值,是上述错误值时才有异常情况,然后使用PyErr_Occurred获取异常类型,或者直接PyErr_Fetch/PyErr_Restore操作异常类型/异常值/调用栈信息,后者更通用,但需要更谨慎的处理计数问题.这几个接口都是borrow型的引用,调用就会转移对象所有权,就得操心异常的释放了
异常是比较复杂的,因为它牵扯到的周期比较长,有时甚至涉及到rpc的传递,所以要谨慎(嗯,怪不得没有文档)
内存
python的内存管理感觉甚至混乱.有人说python在Finalize之后,居然还有内存不会被收回,让人感到十分诧异.可以在valgrind下简单的运行一个python脚本,就能看到有多少的error和warning
这也是python内存问题难调的原因,因为有的时候不知道是哪方面造成的.不过一般来说,python自己的问题是不会crash的(很好奇是怎么做到的),只是会给我们调试带来一些麻烦
比如,很难再使用valgrind来调试内存bug了,因为输出太多,而且都混在一起,很难分清楚彼此
一个常见的内存问题,就是老生常谈的指针.虽然在C++中使用了智能指针来封装,但一旦和python接合,又需要用到裸指针,而且,python内部也有自己的类似智能指针的引用计数,使得对象的生命期比较难分析
我的建议是强依赖于一方,即只使用一方作为基准来掌控对象.假如外部框架很大了,python只是作为一个小的扩展,那么所有对象生命期的管理应该让外部的C++代码控制,交给python只有计数功能;假如外部很小,只是作为python的简单扩展,那么还是要使用python自己的计数来控制生命期
对于前者,一个比较好的做法是使用带有可操控计数的对象.std::shared_ptr虽然很好用,但没有办法直接操作计数,也很难直接嵌入到python对象中去(毕竟有时还要裸指针),所以最好是将裸指针计数化,比如所有的可操作对象都内嵌计数,智能指针和python都使用这个来操作对象,这样,生命期就有C++的外部代码掌控,而双方都比较舒适.另,即使在python里头,能自动处理计数的,还是要自动处理,比如对外部对象计数的增减,就可以放在PyTypeObject的几个内存管理函数上,让python自己来调用,保证不会有太大问题
对于后者,个人感觉只要扩展规模不是很大,应该就没有太大问题,因为此时外部代码可能不是很复杂,直接利用py系列接口来操作对象即可
关于计数的调试,目前感觉最合适的方法,还是printf打印,包括对象地址和计数.计数最关键,不要只打印inc/dec操作,而要把真正的ref打印出来.在开发的过程中,printf可能由于一系列的原因(比如缓冲,优化之类的),根本就没有输出,只看操作,就会误认为内存上有bug(嗯,我就在这个问题上折腾了好久)
valgrind虽然好用,但有时过于底层了,高层的一些分配释放,很难通过这些操作来体现的,所以参考即可(当然,太大的bug肯定也能暴露的)
结语
以上就是开发python遇到的一些坑,希望以后再次遇到时,不要还摔的那么痛苦