最近,我花了一些时间来深入了解CPython,并且我想在此分享我的冒险经历。Allison Kaptur的Python内部入门指南有一点啰嗦——我认为展示我一步一步进行研究的步骤可能是简洁的,也许其他好奇的Python程序员也可以跟着一起做。
1.注意到了一些奇怪的事情
最初,我只是设置Nose来测试我写的一些Python3代码。当我运行测试时,得到一个奇怪的消息“TypeError: bad argument type for built-in operation”,这个消息在这个程序里我之前从来没有见过。
最终造成这个错误的原因较明显——在程序中,我错误地留了一个PDB断点(`import pdb; pdb.set_trace()`)。当我移除它后,测试正常运行。
但是,我之前在Python 2 repo上使用Nose运行测试,并且在那种情况下,错误地留下断点并不会导致Nose崩溃。而是程序会出现"挂起”。该程序不是真的挂起——它只是不显示stdout(标准输出)。Nose有意这样,并且这是有道理的——如果我运行一个测试套件,可能只想看测试的结果,而不是程序本身的一大堆输出语句。如果在这个场景下你敲击“c",那么Nose像往常一样经过该断点继续运行程序。
通常情况下,我可能耸耸肩,移除这个断点,然后继续做我正在做的工作。但是!既然我在Python部落而且有时间钻研抓住我猜想的任何东西,那么我决定用这个作为一个理由来查看Python内部。
2、做尽可能简单的测试用例。
事实证明,这个问题有点棘手。——我不确定问题是在Nose,还是PDB,还是CPython源码本身。当然,我不能使用任何断点,因为它们会导致程序崩溃。
最终,在测试了一些假设后,似乎PDB调用`input()`是问题所在。因此:input本身的实现在Python 2和Python 3有什么变化,或者还有其它的某些不同?
我和Jesse进行结对调试,最后我们终于注意到Nose以一种有趣的方式处理标准输出:
结果表明`sys.stdout`表示Python中所有的标准输出——也就是说,所有输出到终端的内容都会发送到这里。但是!既然我们可以像访问任何其它Python变量那样访问sys.stdout,那么我们可以改变它。在这里,Nose将sys.stdout设置为StringIO(),它只是一个随机字符串。
如果你这么做,print函数就不再工作了!
我们想知道这行是否是问题所在,所以我们建了一个简单的测试用例:
在Python 3中运行此操作会看到"bad argument for built-in operation"。现在我们知道该看什么地方了!当你试图改变sys.stdout时,内置函数`input()`以一种奇怪的方式中断。
3、了解一些CPython知识!
所以,我们想看看"input"是如何实现的。Python有一个很酷的模块叫做"inspect",该模块能让你检查源码,像这样:
然而,如果你在"input"调用"`inspect.getsource",结果是“TypeError: is not a module, class, method, function, traceback, frame, or code object.”。这意味着该函数不是用Python实现的——它是用C实现的,因此‘inspect’模块不能为我们显示它的源码。
……但是,使用模块cinspect ,我们可以看C源码!
棒极了。现在我们知道我们想使用的函数叫‘builtin_input’。此时,我们将开始查看C代码,而不仅是Python代码,而且我们将在终端而不是Python解释器上进行调试。你不必是一个C专家就能大致了解发生了什么事——我主要是根据函数名进行有根据的推测。:)
那么,我们查找CPython源码,会发现"builtin_input"是‘builtin_input_impl’的封装,而‘builtin_input_impl’是bltinmodule.c中的一个函数。让我们尝试加载Python到lldb C调试器并且在那个函数的开头设置一个断点:
在单步执行源码的同时(该过程与你在PDB中的操作类似——一直敲“n”继续下一行),我们发现问题首次出现的代码位:
第三行绊住了我。“如果编码字符串是null或错误字符串是null,则会出现错误。“但是,请等一下,null错误字符串是否意味着没有发现错误?
为此,我钻研_PyUnicode_AsString(另外一个C函数)的定义:
那仅仅是一个宏,意思是“当调用_PyUnicode_AsString时,调用PyUnicode_AsUTF8作为替代”。所以,我们真正想要的是PyUnicode_AsUTF8的定义。
……看上去这个函数所做的所有事情是调用PyUnicode_AsUTF8,而这才是我们真正想读的。
在PyUnicode_AsUTF8AndSize函数中有几种错误情况,每种错误都是返回NULL。在错误的情况下返回NULL,而不是像-1这样的错误码,这让我感觉很奇怪。也许这有我不熟悉的一些约定?
无论如何,为了找出我遇到的是哪种错误情况,我进行“打印调试”——我在每个可能的错误情况之前添加一个printf语句,并且运行程序——这样就能发现我们错在调用PyUnicode_Check的地方。
那么,是不是Python 2中没有进行检查而Python 3中有?我们可以比较这两个版本的源码来找出答案。结果表明,Python 2的源码不进行这种编码检查,而Python 3的源码则进行——所以,如果sys.stdout被一些错误的编码所取代,在3中会失败而2中却不会。哟!
4. 收获!
可能看起来做了很多工作只是为了找出很小的可修改的bug的原因。也许是,但是!我们在这个过程中学到了一些很酷的东西。在测试假设时,我发现了很多关于Python如何处理标准输入和输出。我学到了更多如何阅读大型的、有很多宏的C项目。我了解到GOTO仍然在使用,这让我吃惊,但在语境中却有意义的——在C中没有GOTO看起来处理一些事情如异常是很棘手的。而且,阅读bltinmodule.c的input函数在Python 2和3之间的变化真的很酷——严格地说,是检查,它们重构和清理东西看起来很简洁。
我还偶然发现了一些关于Python中引用计数的超级有趣的小细节,我正在另一个帖子中讲。:)
(同时,非常感谢Leta帮我校订本文的草稿!)
领取专属 10元无门槛券
私享最新 技术干货