first commit
This commit is contained in:
75
专栏/Python核心技术与实战/00开篇词从工程的角度深入理解Python.md
Normal file
75
专栏/Python核心技术与实战/00开篇词从工程的角度深入理解Python.md
Normal file
@ -0,0 +1,75 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 从工程的角度深入理解Python
|
||||
你好,我是景霄。
|
||||
|
||||
我是Facebook的一名全栈工程师,目前从事机器学习的相关工作,主要工作领域是人工智能的推荐排序系统与算法。工作期间,我曾领导多个上亿用户级产品的开发与落地,有丰富的工程与实战经验。
|
||||
|
||||
一听机器学习,很多人第一反应可能是“好难呀、厉害呀”。可事实上,我的编程之路并非一路高光。
|
||||
|
||||
不同于大城市长大或竞赛出身、十一二岁接触编程的人,在刚上大学时,我的编程基础几乎为零。大一上的C语言,便是我出生起学到的第一门编程语言。初识计算机语言的世界,很有趣也很吸引我,这也是我成为程序员的最初动力。
|
||||
|
||||
和很多对编程感兴趣的人一样,哪怕老师只是在讲台上,照本宣科地读着N年前的课件,我也会竖起耳朵认真听讲、认真做笔记。并且,私下里我还买了不少厚重的大块头书,在网上查了不少博客、帖子,照着上面的例子一行行地敲代码。很多内容我并不理解,比如指针、递归这类抽象的概念,查了一堆资料也没看明白。但靠着死记硬背,考试基本可以过关,虽然这个过程比较痛苦,也比较累。
|
||||
|
||||
后来,为了更深入了解计算机,我去了哥伦比亚大学攻读计算机硕士学位,又陆续学到不少新的编程语言,比如Node.js、Python、PHP、Scala等等。这个阶段,我边学习,边做项目,却发现轻松了很多。
|
||||
|
||||
这两个学习阶段,收获和感受天差地别,难道仅仅是因为“万事入门难”吗?我不止一次反思过这个问题,终于发现,问题出在了资料本身上。
|
||||
|
||||
为什么这么说呢?一是因为书上或网上的很多东西,非常理论化,实例少之又少,单凭死记硬背很难真正掌握;二是这些内容中,原创的观点和经验更少,大多互相抄袭,内容雷同且不实用,远离实际工程,毫无借鉴价值。
|
||||
|
||||
但显然,市面上的资料问题,我们个人是很难解决的。我们能做的,便是克服常见资料的弊端,另辟蹊径来学习。这其中,最重要的一点就是,从工程的角度思考学习,以实用为出发点,多练习、多阅读、多做项目,这样才能有质的提高。
|
||||
|
||||
在Facebook工作的这么多年,也验证了我的观点。我身边的新手,他们学习新的语言总是只会啃书练习,还难以上手;而有经验的同事则不同,他们能花很短的时间看完基础语法,然后找行家去了解一些重难点、易错点,最后亲自动手完成一个项目,达到融会贯通的效果。这样下来,可能几周时间就掌握得差不多了。
|
||||
|
||||
这样的差距,确实让人心塞,而这也是我开这个专栏的最初动力——帮助更多入门级程序员迅速成长。至于专栏主题,我选择了Python这门编程语言,原因也很明了。
|
||||
|
||||
这首先来自于我个人的重要感悟。经过多年学习工作的积累,我深刻认识到,牢牢掌握一门编程语言及其学习方法,是日后在所有领域深造的根基。而在实际工作和生活中,我更是见过不少反例,比如搞机器学习的工程师,算法、理论等极强,但是编程水平或是工程水平很一般,于是涉及到偏工程的工作或合作时,就显得力不从心,这样就非常可惜了。
|
||||
|
||||
另外,不可否认,Python确实是这个时代最流行、也必须要掌握的编程语言。Python可以运用在数据处理、Web开发、人工智能等多个领域,它的语言简洁、开发效率高、可移植性强,并且可以和其他编程语言(比如C++)轻松无缝衔接。现如今,不少学校的文科生甚至中学生也开设了此课程,可见其重要程度。
|
||||
|
||||
因此,我决定开设这么一个专栏,从工程的角度去讲解Python这门编程语言。我不是语言学专家,不会死抠一些很偏的知识点;相反,作为一名工程师,我会从实际出发,以工作中遇到的实例为主线,去讲解Python的核心技术和应用。
|
||||
|
||||
专栏的所有内容都基于Python最新的3.7版本,其中有大量独家解读、案例,以及不少我阅读源码后的发现和体会。同时,在层次划分上,我希望能难易兼顾,循序渐进。专栏中既有核心的基础知识,也有高级的进阶操作,尽量做到“老少皆宜”。
|
||||
|
||||
从内容上来说,专栏主要分为四大版块。
|
||||
|
||||
1. Python基础篇
|
||||
|
||||
第一部分主要讲解Python的基础知识。当然,不同于其他基础教材,专栏的基础版块并不只有基础概念、操作,我同时加入了很多进阶难度的知识,或是一些重难点、易错点等需要注意的地方。如果你觉得自己基础的东西都会了,这部分不用学了,那你就大错特错了。比如,
|
||||
|
||||
|
||||
列表和元组存储结构的差异是怎样的?它们性能的详细比较又如何?
|
||||
|
||||
字符串相加的时间复杂度,你真的清楚吗?
|
||||
|
||||
|
||||
基础不牢,地动山摇。更深刻、实质的基础理解,才是更牢固的知识大厦的根基。我希望这一版块,不仅可以让入门级的程序员查漏补缺、打牢基础,也能让有经验的程序员,重新从工程角度认识基础、升华理解。
|
||||
|
||||
2. Python进阶篇
|
||||
|
||||
这部分讲的是 Python的一些进阶知识,比如装饰器、并发编程等等。如果你的工作只是写100行以下的脚本程序,可能不怎么会用得到。但如果你做的是大型程序的开发,则非常有必要。我希望通过这一版块,让你熟悉各种高级用法,真正理解Python,理解这门编程语言的特点。
|
||||
|
||||
3. Python规范篇
|
||||
|
||||
这部分着重于教你把程序写得更加规范、更加稳定。我在实际工作中见过不少程序员,会写程序,但写得实在有点“惨不忍睹”,导致最后调试起来错误不断,修改非常费劲儿。因此,我觉得用单独一个版块讲解这个问题非常有必要。
|
||||
|
||||
当然,我不会用一些似是而非的规范来说教,而是会用具体的编程操作和技巧,教你提高代码质量。比如,如何合理地分解代码、运用assert,如何写单元测试等等。
|
||||
|
||||
4. Python实战篇
|
||||
|
||||
没上过战场开过枪的人,不可能做主官;没有实战经验的语言学习者,不可能成为高手。这部分,我会通过量化交易系统这个具体的实战案例,带你综合运用前面所学的Python知识。
|
||||
|
||||
真正要掌握一门编程语言,仅仅学会分散的知识点是不够的,还必须要把知识点串联起来,做一些中型的项目才能有更深的领悟与提高。
|
||||
|
||||
专栏篇幅只有40多篇,但是每篇绝对都是干货满满。我希望这个专栏,能帮助更多入门级和有一定项目基础的程序员,真正掌握Python,并且给你一些学习上的启发。
|
||||
|
||||
100天后,晋级为Python高手,让我们一起加油吧!
|
||||
|
||||
课程的练习代码:https://github.com/zwdnet/PythonPractice
|
||||
|
||||
|
||||
|
||||
|
92
专栏/Python核心技术与实战/01如何逐步突破,成为Python高手?.md
Normal file
92
专栏/Python核心技术与实战/01如何逐步突破,成为Python高手?.md
Normal file
@ -0,0 +1,92 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 如何逐步突破,成为Python高手?
|
||||
你好,我是景霄。
|
||||
|
||||
工作中,我总听到很多程序员抱怨,说现在的计算机编程语言太多了,学不过来了。一些人Java用了很多年,但是最近的项目突然需要用Python,就会不知所措,压力很大。
|
||||
|
||||
众所周知,Facebook的主流语言是Hack(PHP的进化版本)。不过,我敢拍着胸脯说,就刚入职的工程师而言,100个里至少有95个,以前都从未用过Hack或者PHP。但是,这些人上手都特别快,基本上一两周后,日常编程便毫无压力了。
|
||||
|
||||
他们是怎么做到的呢?
|
||||
|
||||
事实上,他们遵循的,正是我在开篇词中提到的方法,也是本专栏学习的中心观点:“从工程的角度去学习Python”。那么具体来说,到底要怎么学,学习的过程中又要特别注意哪些地方呢?
|
||||
|
||||
不同语言,需融会贯通
|
||||
|
||||
其实,如果你在学一门语言的时候多阅读、多练习、多思考,你就会发现,不同语言都是类似的。编程语言本就是人类控制计算机的指令,语法规则等方面自然大同小异。
|
||||
|
||||
而在原有基础上,学习一门新的编程语言,其实也没有那么难,你首先要做到的是明确区分。比如,在学习Python的条件与循环语句时,多回忆一下其他语言的语法是怎样的。再如,遇到Python中的字符串相加时,你能分析出它的复杂度吗?再联想到其他语言,比如Java中字符串相加的复杂度,它们之间有什么相同点、又有什么区别呢?
|
||||
|
||||
除了能够明确区分语言的不同点,我们还要能联系起来灵活运用。比如,最典型的“编程语言两问”:
|
||||
|
||||
|
||||
你了解你学过的每种编程语言的特点吗?
|
||||
|
||||
你能根据不同的产品需求,选用合适的编程语言吗?
|
||||
|
||||
|
||||
举个例子,Python的优点之一是特别擅长数据分析,所以广泛应用于人工智能、机器学习等领域,如机器学习中TensorFlow的框架,就是用Python写的。但是涉及到底层的矩阵运算等等,还是要依赖于C++完成,因为C++的速度快,运行效率更高。
|
||||
|
||||
事实上,很多公司都是这样,服务器端开发基于Python,但底层的基础架构依赖于C++。这就是典型的“不同需求选用不同语言”。毕竟,你要明白,哪怕只是几十到几百毫秒的速度差距,对于公司、对于用户体验来说都是决定性的。
|
||||
|
||||
唯一语言,可循序渐进
|
||||
|
||||
当然,如果Python是你学的第一门编程语言,那也不必担心。我们知道,虽然同为人机交互的桥梁,Python语言比起C++、Java等主流语言,语法更简洁,也更接近英语,对编程世界的新人还是很友好的,这也是其显著优点。这种情况下,你要做的就是专注于Python这一门语言,明确学习的重点,把握好节奏循序渐进地学习。
|
||||
|
||||
根据我多年的学习工作经验,我把编程语言的学习重点,总结成了下面这三步,无论你是否有其他语言的基础,都可以对照来做,稳步进阶。
|
||||
|
||||
第一步:大厦之基,勤加练习
|
||||
|
||||
任何一门编程语言,其覆盖范围都是相当广泛的,从基本的变量赋值、条件循环,到并发编程、Web开发等等,我想市面上几乎没有任何一本书能够罗列完全。
|
||||
|
||||
所以,我建议你,在掌握必要的基础时,就得多上手操作了。千万不要等到把教材上所有东西都学完了才开始,因为到那时候你会发现,前面好不容易记住的一堆东西似乎又忘记了。计算机科学是一门十分讲究实战的学科,因此越早上手练习,练得越多越勤,就越好。
|
||||
|
||||
不过,到底什么叫做必要的基础呢?以Python为例,如果你能够理解变量间的赋值、基本的数据类型、条件与循环语句、函数的用法,那么你就达到了第一步的底线标准,应该开始在课下多多练习了。
|
||||
|
||||
比方说,你可以自己动手编程做一个简易的计算器,这应该也是大多数程序员实操的第一个小项目。用户输入数字和运算符后,你的程序能够检查输入是否合法并且返回正确的结果吗?
|
||||
|
||||
在做这个小项目的过程中,你可能会遇到不少问题。我的建议是,遇到不懂的问题时,多去Stack Overflow上查询,这样你还能阅读别人优秀的代码,借鉴别人的思路,对于你的学习肯定大有帮助。当然,实在解决不了的问题,也可以写在留言区,我们一起来解决。
|
||||
|
||||
第二步:代码规范,必不可少
|
||||
|
||||
诚然,学习编程讲究快和高效。但是,与此同时,请一定不要忽略每一种语言必要的编程规范。在你自己刚开始写代码练习时,你可以不写单元测试,但总不能几百行的代码却没有一个函数,而是从头顺序写到尾吧?你可以省略一些可有可无的注释,但总不能把很多行代码全部并到一行吧?
|
||||
|
||||
比如,我们来看下面这行代码:
|
||||
|
||||
v.A(param1, param2, param3).B(param4, param5).C(param6, param7).D()
|
||||
|
||||
|
||||
显然,这样写十分不科学,应该把它拆分成多行:
|
||||
|
||||
v.A(param1, param2, param3) \ # 字符'\'表示换行
|
||||
.B(param4, param5) \
|
||||
.C(param6, param7) \
|
||||
.D()
|
||||
|
||||
|
||||
再比如,变量和函数的命名虽有一定的随意性,但一定要有意义。如果你图省事,直接把变量依次命名为v1、v2、v3等,把函数依次命名为func1、func2、func3等等,不仅让其他人难理解,就算是你自己,日后维护起来都费劲儿。
|
||||
|
||||
一名优秀的程序员,一定遵守编程语言的代码规范。像Facebook的工程师,每次写完代码都必须经过别人的review才能提交。如果有不遵守代码规范的例子,哪怕只是一个函数或是一个变量的命名,我们都会要求原作者加以修改,严格规范才能保证代码库的代码质量。
|
||||
|
||||
第三步:开发经验,质的突破
|
||||
|
||||
想要真正熟练地掌握Python或者是任何一门其他的编程语言,拥有大中型产品的开发经验是必不可少的。因为实战经验才能让你站得更高,望得更远。
|
||||
|
||||
比如我们每天都在用搜索引擎,但你了解一个搜索引擎的服务器端实现吗?这是一个典型的面向对象设计,你需要定义一系列相关的类和函数,需要从产品需求、代码复杂度、效率以及可读性等多个方面考虑,同时,上线后还要进行各种优化等等。
|
||||
|
||||
当然,在专栏里我没办法让你完成一个上亿用户级的实践产品,但是我会把自己这些年的开发经验倾囊相授,并通过量化交易这个实战案例,带你踏入“高级战场”,帮你掌握必要的开发知识。
|
||||
|
||||
最后,我专门为你绘制了一张Python学习的知识图谱,里面涵盖了Python最高频的核心知识,大部分内容我在专栏中都会讲到。你可以保存或者打印出来,作为学习参考。
|
||||
|
||||
|
||||
|
||||
今天,我跟你分享了Python的学习方法和注意事项,其实这些观点不只适用于Python,也能帮助你学习任何一门其他计算机编程语言,希望你能牢记在心。在接下来的课程里,我会带你逐步突破,最终成为一名Python高手。
|
||||
|
||||
那么,对于学习Python或者是其他编程语言,你有什么困扰或是心得吗?欢迎在留言区与我交流!
|
||||
|
||||
|
||||
|
||||
|
107
专栏/Python核心技术与实战/02JupyterNotebook为什么是现代Python的必学技术?.md
Normal file
107
专栏/Python核心技术与实战/02JupyterNotebook为什么是现代Python的必学技术?.md
Normal file
@ -0,0 +1,107 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 Jupyter Notebook为什么是现代Python的必学技术?
|
||||
你好,我是景霄。
|
||||
|
||||
Stack Overflow 曾在2017年底,发布了在该站上各种语言的提问流量。其中,Python已经超过了JavaScript成为了流量最高的语言,预测在2020年前会远远甩开JavaScript。
|
||||
|
||||
|
||||
|
||||
可能你已经知道,Python在14年后的“崛起”,得益于机器学习和数学统计应用的兴起。那为什么Python如此适合数学统计和机器学习呢?作为“老司机”的我可以肯定地告诉你,Jupyter Notebook (https://jupyter.org/)功不可没。
|
||||
|
||||
毫不夸张地说,根据我对Facebook等硅谷一线大厂的了解,一个Python工程师如果现在还不会使用Jupyter Notebook的话,可能就真的太落伍了。
|
||||
|
||||
磨刀不误砍柴工,高效的工具让我们的编程事半功倍。这一节课,我就来带你学习一下Jupyter Notebook,为后面的Python学习打下必备基础。
|
||||
|
||||
什么是Jupyter Notebook?
|
||||
|
||||
说了这么多,到底什么是Jupyter Notebook?按照Jupyter 创始人 Fernando Pérez的说法,他最初的梦想是做一个综合 Ju (Julia)、Py (Python)和 R 三种科学运算语言的计算工具平台,所以将其命名为Ju-Py-te-R。发展到现在,Jupyter 已经成为一个几乎支持所有语言,能够把软件代码、计算输出、解释文档、多媒体资源整合在一起的多功能科学运算平台。
|
||||
|
||||
英文里说一图胜千言(A picture is worth a thousand words)。看下面这个图片,你就明白什么是Jupyter Notebook了。
|
||||
|
||||
|
||||
|
||||
你在一个框框中直接输入代码,运行,它立马就在下面给你输出。怎么样,是不是很酷?你可能会纳闷儿,这样一个看起来“华而不实”的玩意儿,真的就成了Python社区的颠覆者吗?说实话放在几年前我也是不信的。所以 Jupyter Notebook 的影响究竟有多大呢?
|
||||
|
||||
Jupyter Notebook 的影响力
|
||||
|
||||
我们衡量一个技术的影响力,或者说要用自己的技术去影响世界时,必定绕不开这个技术对教育界的影响力。
|
||||
|
||||
就拿微软的Word文本处理系统来说吧。从纯技术角度来讲,Word的单机设计理念早已落后时代20年。但以Google Doc为代表的在线文档系统,却并没有像想象中那样,实现对Word的降维打击。
|
||||
|
||||
直观的原因是用户习惯,使用Word修改文档,那就来回发几十遍呗,用着也还可以。但更深刻来想,之所以养成这样的用户习惯,是因为我们的教育根源。教育系统从娃娃抓起,用小学中学大学十几年的时间,训练了用户Word的使用习惯。到工作中,老员工又会带着新员工继续使用Word,如此行程技术影响力生生不息的正向反馈。
|
||||
|
||||
回到我们今天的主题,我们来看Jupyter Notebook。从2017年开始,已有大量的北美顶尖计算机课程,开始完全使用Jupyter Notebook作为工具。比如李飞飞的CS231N《计算机视觉与神经网络》课程,在16年时作业还是命令行Python的形式,但是17年的作业就全部在Jupyter Notebook上完成了。再如UC Berkeley的《数据科学基础》课程,从17年起,所有作业也全部用Jupyter Notebook完成。
|
||||
|
||||
而Jupyter Notebook 在工业界的影响力更甚。在Facebook,虽然大规模的后台开发仍然借助于功能齐全的IDE,但是几乎所有的中小型程序,比如内部的一些线下分析软件,机器学习模块的训练都是借助于Jupyter Notebook完成的。据我了解,在别的硅谷一线大厂,例如Google的AI Research部门Google Brain,也是清一色地全部使用Jupyter Notebook,虽然用的是他们自己的改进定制版,叫 Google Colab。
|
||||
|
||||
看到这里,相信你已经认可了Jupter Notebook现如今的江湖地位。不过,说到技术的选择,有些人会说,这个技术流行,我们应该用;有些人认为,阿里已经在用这个技术了,这就是未来,我们也要用等等。不得不说,这些都是片面的认知。不管是阿里还是Facebook用的技术,其实不一定适用你的应用场景。
|
||||
|
||||
我经常会鼓励技术同行,对于技术选择要有独立的思考,不要人云亦云。最起码你要去思考,Facebook为什么选择这个技术?这个技术解决了哪些问题?Facebook为什么不选择别的技术?有哪些局限?单从选择结果而言,Facebook选择的技术很可能是因为它有几百个产品线,几万个工程师。而同样的技术,在一个十人的团队里,反而成了拖累。
|
||||
|
||||
在这里,我不想忽悠你任何技术,我想教会你的是辩证分析技术的思考方法。接下来,我们就来看看,Jupyter究竟解决了哪些别人没有解决的问题。
|
||||
|
||||
Jupyter的优点
|
||||
|
||||
整合所有的资源
|
||||
|
||||
在真正的软件开发中,上下文切换占用了大量的时间。什么意思呢?举个例子你就很好理解了,比如你需要切换窗口去看一些文档,再切换窗口去用另一个工具画图等等。这些都是影响生产效率的因素。
|
||||
|
||||
正如我前面提到的,Jupyter通过把所有和软件编写有关的资源全部放在一个地方,解决了这个问题。当你打开一个Jupyter Notebook时,就已经可以看到相应的文档、图表、视频和相应的代码。这样,你就不需要切换窗口去找资料,只要看一个文件,就可以获得项目的所有信息。
|
||||
|
||||
交互性编程体验
|
||||
|
||||
在机器学习和数学统计领域,Python编程的实验性特别强,经常出现的情况是,一小块代码需要重写100遍,比如为了尝试100种不同的方法,但别的代码都不想动。这一点和传统的Python开发有很大不同。如果是在传统的Python开发流程中,每一次实验都要把所有代码重新跑一遍,会花费开发者很多时间。特别是在像Facebook这样千万行级别的代码库里,即使整个公司的底层架构已经足够优化,真要重新跑一遍,也需要几分钟的时间。
|
||||
|
||||
而Jupyter Notebook 引进了Cell的概念,每次实验可以只跑一小个Cell里的代码;并且,所见即所得,在代码下面立刻就可以看到结果。这样强的互动性,让Python研究员可以专注于问题本身,不被繁杂的工具链所累,不用在命令行直接切换,所有科研工作都能在Jupyter上完成。
|
||||
|
||||
零成本重现结果
|
||||
|
||||
同样在机器学习和数学统计领域,Python的使用是非常短平快的。常见的场景是,我在论文里看到别人的方法效果很好,可是当我去重现时,却发现需要pip重新安装一堆依赖软件。这些准备工作可能会消耗你80%的时间,却并不是真正的生产力。
|
||||
|
||||
Jupyter Notebook如何解决这个问题呢?
|
||||
|
||||
其实最初的Jupyter Notebook也是挺麻烦的,需要你先在本机上安装IPython引擎及其各种依赖软件。不过现在的技术趋势,则是彻底云端化了,例如Jupyter官方的Binder平台(介绍文档:https://mybinder.readthedocs.io/en/latest/index.html)和Google提供的 Google Colab环境(介绍:https://colab.research.google.com/notebooks/welcome.ipynb)。它们让Jupyter Notebook变得和石墨文档、Google Doc在线文档一样,在浏览器点开链接就能运行。
|
||||
|
||||
所以,现在当你用Binder打开一份GitHub上的Jupyter Notebook时,你不需要安装任何软件,直接在浏览器打开一份代码,就能在云端运行。
|
||||
|
||||
Jupyter Notebook 初体验
|
||||
|
||||
学习技术的最好方法就是用技术。不过,在今天的篇幅里,我不可能带你完全学会Jupyter Notebook的所有技巧。我想先带你直接感受一下,使用Jupyter Notebook的工作体验。
|
||||
|
||||
比如这样一个GitHub文件。在Binder中,你只要输入其对应的GitHub Repository的名字或者URL,就能在云端打开整个Repository,选择你需要的notebook,你就能看到下图这个界面。
|
||||
|
||||
|
||||
|
||||
每一个Jupyter的运行单元都包含了In、Out的Cell。如图所示,你可以使用Run按钮,运行单独的一个Cell。当然,你也可以在此基础上加以修改,或者新建一个notebook,写成自己想要的程序。赶紧打开链接试一试吧!
|
||||
|
||||
另外,我还推荐下面这些Jupyter Notebook,作为你实践的第一站。
|
||||
|
||||
|
||||
第一个是Jupyter官方:https://mybinder.org/v2/gh/binder-examples/matplotlib-versions/mpl-v2.0/?filepath=matplotlib_versions_demo.ipynb
|
||||
|
||||
第二个是Google Research提供的Colab环境,尤其适合机器学习的实践应用:https://colab.research.google.com/notebooks/basic_features_overview.ipynb
|
||||
|
||||
|
||||
|
||||
如果你想在本地或者远程的机器上安装Jupyter Notebook,可以参考下面的两个文档。
|
||||
|
||||
安装:https://jupyter.org/install.html
|
||||
|
||||
运行:https://jupyter.readthedocs.io/en/latest/running.html#running
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课,我为你介绍了Jupyter Notebook,并告诉你它为什么日趋成为Python社区的必学技术。这主要是因为它的三大特点:整合所有的资源、交互性编程体验和零成本重现结果。但还是那句话,学习技术必须动手实操。这节课后,希望你能自己动手试一试Jupyter Notebook,后面我们的一些课程代码,我也会用Jupyter Notebook的形式分享给你。
|
||||
|
||||
思考题
|
||||
|
||||
你尝试Jupyter Notebook了吗?欢迎在留言区和我分享你的使用体验。
|
||||
|
||||
|
||||
|
||||
|
262
专栏/Python核心技术与实战/03列表和元组,到底用哪一个?.md
Normal file
262
专栏/Python核心技术与实战/03列表和元组,到底用哪一个?.md
Normal file
@ -0,0 +1,262 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 列表和元组,到底用哪一个?
|
||||
你好,我是景霄。
|
||||
|
||||
前面的课程,我们讲解了Python语言的学习方法,并且带你了解了Python必知的常用工具——Jupyter。那么从这节课开始,我们将正式学习Python的具体知识。
|
||||
|
||||
对于每一门编程语言来说,数据结构都是其根基。了解掌握Python的基本数据结构,对于学好这门语言至关重要。今天我们就一起来学习,Python中最常见的两种数据结构:列表(list)和元组(tuple)。
|
||||
|
||||
列表和元组基础
|
||||
|
||||
首先,我们需要弄清楚最基本的概念,什么是列表和元组呢?
|
||||
|
||||
实际上,列表和元组,都是一个可以放置任意数据类型的有序集合。
|
||||
|
||||
在绝大多数编程语言中,集合的数据类型必须一致。不过,对于Python的列表和元组来说,并无此要求:
|
||||
|
||||
l = [1, 2, 'hello', 'world'] # 列表中同时含有int和string类型的元素
|
||||
l
|
||||
[1, 2, 'hello', 'world']
|
||||
|
||||
tup = ('jason', 22) # 元组中同时含有int和string类型的元素
|
||||
tup
|
||||
('jason', 22)
|
||||
|
||||
|
||||
其次,我们必须掌握它们的区别。
|
||||
|
||||
|
||||
列表是动态的,长度大小不固定,可以随意地增加、删减或者改变元素(mutable)。
|
||||
|
||||
而元组是静态的,长度大小固定,无法增加删减或者改变(immutable)。
|
||||
|
||||
|
||||
下面的例子中,我们分别创建了一个列表与元组。你可以看到,对于列表,我们可以很轻松地让其最后一个元素,由4变为40;但是,如果你对元组采取相同的操作,Python 就会报错,原因就是元组是不可变的。
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
l[3] = 40 # 和很多语言类似,python中索引同样从0开始,l[3]表示访问列表的第四个元素
|
||||
l
|
||||
[1, 2, 3, 40]
|
||||
|
||||
tup = (1, 2, 3, 4)
|
||||
tup[3] = 40
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: 'tuple' object does not support item assignment
|
||||
|
||||
|
||||
可是,如果你想对已有的元组做任何”改变”,该怎么办呢?那就只能重新开辟一块内存,创建新的元组了。
|
||||
|
||||
比如下面的例子,我们想增加一个元素5给元组,实际上就是创建了一个新的元组,然后把原来两个元组的值依次填充进去。
|
||||
|
||||
而对于列表来说,由于其是动态的,我们只需简单地在列表末尾,加入对应元素就可以了。如下操作后,会修改原来列表中的元素,而不会创建新的列表。
|
||||
|
||||
tup = (1, 2, 3, 4)
|
||||
new_tup = tup + (5, ) # 创建新的元组new_tup,并依次填充原元组的值
|
||||
new _tup
|
||||
(1, 2, 3, 4, 5)
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
l.append(5) # 添加元素5到原列表的末尾
|
||||
l
|
||||
[1, 2, 3, 4, 5]
|
||||
|
||||
|
||||
通过上面的例子,相信你肯定掌握了列表和元组的基本概念。接下来我们来看一些列表和元组的基本操作和注意事项。
|
||||
|
||||
首先,和其他语言不同,Python中的列表和元组都支持负数索引,-1表示最后一个元素,-2表示倒数第二个元素,以此类推。
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
l[-1]
|
||||
4
|
||||
|
||||
tup = (1, 2, 3, 4)
|
||||
tup[-1]
|
||||
4
|
||||
|
||||
|
||||
除了基本的初始化,索引外,列表和元组都支持切片操作:
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
l[1:3] # 返回列表中索引从1到2的子列表
|
||||
[2, 3]
|
||||
|
||||
tup = (1, 2, 3, 4)
|
||||
tup[1:3] # 返回元组中索引从1到2的子元组
|
||||
(2, 3)
|
||||
|
||||
|
||||
另外,列表和元组都可以随意嵌套:
|
||||
|
||||
l = [[1, 2, 3], [4, 5]] # 列表的每一个元素也是一个列表
|
||||
|
||||
tup = ((1, 2, 3), (4, 5, 6)) # 元组的每一个元素也是一个元组
|
||||
|
||||
|
||||
当然,两者也可以通过list()和tuple()函数相互转换:
|
||||
|
||||
list((1, 2, 3))
|
||||
[1, 2, 3]
|
||||
|
||||
tuple([1, 2, 3])
|
||||
(1, 2, 3)
|
||||
|
||||
|
||||
最后,我们来看一些列表和元组常用的内置函数:
|
||||
|
||||
l = [3, 2, 3, 7, 8, 1]
|
||||
l.count(3)
|
||||
2
|
||||
l.index(7)
|
||||
3
|
||||
l.reverse()
|
||||
l
|
||||
[1, 8, 7, 3, 2, 3]
|
||||
l.sort()
|
||||
l
|
||||
[1, 2, 3, 3, 7, 8]
|
||||
|
||||
tup = (3, 2, 3, 7, 8, 1)
|
||||
tup.count(3)
|
||||
2
|
||||
tup.index(7)
|
||||
3
|
||||
list(reversed(tup))
|
||||
[1, 8, 7, 3, 2, 3]
|
||||
sorted(tup)
|
||||
[1, 2, 3, 3, 7, 8]
|
||||
|
||||
|
||||
这里我简单解释一下这几个函数的含义。
|
||||
|
||||
|
||||
count(item)表示统计列表/元组中item出现的次数。
|
||||
|
||||
index(item)表示返回列表/元组中item第一次出现的索引。
|
||||
|
||||
list.reverse()和list.sort()分别表示原地倒转列表和排序(注意,元组没有内置的这两个函数)。
|
||||
|
||||
reversed()和sorted()同样表示对列表/元组进行倒转和排序,reversed()返回一个倒转后的迭代器(上文例子使用list()函数再将其转换为列表);sorted()返回排好序的新列表。
|
||||
|
||||
|
||||
列表和元组存储方式的差异
|
||||
|
||||
前面说了,列表和元组最重要的区别就是,列表是动态的、可变的,而元组是静态的、不可变的。这样的差异,势必会影响两者存储方式。我们可以来看下面的例子:
|
||||
|
||||
l = [1, 2, 3]
|
||||
l.__sizeof__()
|
||||
64
|
||||
tup = (1, 2, 3)
|
||||
tup.__sizeof__()
|
||||
48
|
||||
|
||||
|
||||
|
||||
你可以看到,对列表和元组,我们放置了相同的元素,但是元组的存储空间,却比列表要少16字节。这是为什么呢?
|
||||
|
||||
事实上,由于列表是动态的,所以它需要存储指针,来指向对应的元素(上述例子中,对于int型,8字节)。另外,由于列表可变,所以需要额外存储已经分配的长度大小(8字节),这样才可以实时追踪列表空间的使用情况,当空间不足时,及时分配额外空间。
|
||||
|
||||
l = []
|
||||
l.__sizeof__() // 空列表的存储空间为40字节
|
||||
40
|
||||
l.append(1)
|
||||
l.__sizeof__()
|
||||
72 // 加入了元素1之后,列表为其分配了可以存储4个元素的空间 (72 - 40)/8 = 4
|
||||
l.append(2)
|
||||
l.__sizeof__()
|
||||
72 // 由于之前分配了空间,所以加入元素2,列表空间不变
|
||||
l.append(3)
|
||||
l.__sizeof__()
|
||||
72 // 同上
|
||||
l.append(4)
|
||||
l.__sizeof__()
|
||||
72 // 同上
|
||||
l.append(5)
|
||||
l.__sizeof__()
|
||||
104 // 加入元素5之后,列表的空间不足,所以又额外分配了可以存储4个元素的空间
|
||||
|
||||
|
||||
上面的例子,大概描述了列表空间分配的过程。我们可以看到,为了减小每次增加/删减操作时空间分配的开销,Python每次分配空间时都会额外多分配一些,这样的机制(over-allocating)保证了其操作的高效性:增加/删除的时间复杂度均为O(1)。
|
||||
|
||||
但是对于元组,情况就不同了。元组长度大小固定,元素不可变,所以存储空间固定。
|
||||
|
||||
看了前面的分析,你也许会觉得,这样的差异可以忽略不计。但是想象一下,如果列表和元组存储元素的个数是一亿,十亿甚至更大数量级时,你还能忽略这样的差异吗?
|
||||
|
||||
列表和元组的性能
|
||||
|
||||
通过学习列表和元组存储方式的差异,我们可以得出结论:元组要比列表更加轻量级一些,所以总体上来说,元组的性能速度要略优于列表。
|
||||
|
||||
另外,Python会在后台,对静态数据做一些资源缓存(resource caching)。通常来说,因为垃圾回收机制的存在,如果一些变量不被使用了,Python就会回收它们所占用的内存,返还给操作系统,以便其他变量或其他应用使用。
|
||||
|
||||
但是对于一些静态变量,比如元组,如果它不被使用并且占用空间不大时,Python会暂时缓存这部分内存。这样,下次我们再创建同样大小的元组时,Python就可以不用再向操作系统发出请求,去寻找内存,而是可以直接分配之前缓存的内存空间,这样就能大大加快程序的运行速度。
|
||||
|
||||
下面的例子,是计算初始化一个相同元素的列表和元组分别所需的时间。我们可以看到,元组的初始化速度,要比列表快5倍。
|
||||
|
||||
python3 -m timeit 'x=(1,2,3,4,5,6)'
|
||||
20000000 loops, best of 5: 9.97 nsec per loop
|
||||
python3 -m timeit 'x=[1,2,3,4,5,6]'
|
||||
5000000 loops, best of 5: 50.1 nsec per loop
|
||||
|
||||
|
||||
但如果是索引操作的话,两者的速度差别非常小,几乎可以忽略不计。
|
||||
|
||||
python3 -m timeit -s 'x=[1,2,3,4,5,6]' 'y=x[3]'
|
||||
10000000 loops, best of 5: 22.2 nsec per loop
|
||||
python3 -m timeit -s 'x=(1,2,3,4,5,6)' 'y=x[3]'
|
||||
10000000 loops, best of 5: 21.9 nsec per loop
|
||||
|
||||
|
||||
当然,如果你想要增加、删减或者改变元素,那么列表显然更优。原因你现在肯定知道了,那就是对于元组,你必须得通过新建一个元组来完成。
|
||||
|
||||
列表和元组的使用场景
|
||||
|
||||
那么列表和元组到底用哪一个呢?根据上面所说的特性,我们具体情况具体分析。
|
||||
|
||||
1. 如果存储的数据和数量不变,比如你有一个函数,需要返回的是一个地点的经纬度,然后直接传给前端渲染,那么肯定选用元组更合适。
|
||||
|
||||
def get_location():
|
||||
.....
|
||||
return (longitude, latitude)
|
||||
|
||||
|
||||
2. 如果存储的数据或数量是可变的,比如社交平台上的一个日志功能,是统计一个用户在一周之内看了哪些用户的帖子,那么则用列表更合适。
|
||||
|
||||
viewer_owner_id_list = [] # 里面的每个元素记录了这个viewer一周内看过的所有owner的id
|
||||
records = queryDB(viewer_id) # 索引数据库,拿到某个viewer一周内的日志
|
||||
for record in records:
|
||||
viewer_owner_id_list.append(record.id)
|
||||
|
||||
|
||||
总结
|
||||
|
||||
关于列表和元组,我们今天聊了很多,最后一起总结一下你必须掌握的内容。
|
||||
|
||||
总的来说,列表和元组都是有序的,可以存储任意数据类型的集合,区别主要在于下面这两点。
|
||||
|
||||
|
||||
列表是动态的,长度可变,可以随意的增加、删减或改变元素。列表的存储空间略大于元组,性能略逊于元组。
|
||||
|
||||
元组是静态的,长度大小固定,不可以对元素进行增加、删减或者改变操作。元组相对于列表更加轻量级,性能稍优。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
1. 想创建一个空的列表,我们可以用下面的A、B两种方式,请问它们在效率上有什么区别吗?我们应该优先考虑使用哪种呢?可以说说你的理由。
|
||||
|
||||
# 创建空列表
|
||||
# option A
|
||||
empty_list = list()
|
||||
|
||||
# option B
|
||||
empty_list = []
|
||||
|
||||
|
||||
2. 你在平时的学习工作中,是在什么场景下使用列表或者元组呢?欢迎留言和我分享。
|
||||
|
||||
|
||||
|
||||
|
376
专栏/Python核心技术与实战/04字典、集合,你真的了解吗?.md
Normal file
376
专栏/Python核心技术与实战/04字典、集合,你真的了解吗?.md
Normal file
@ -0,0 +1,376 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 字典、集合,你真的了解吗?
|
||||
你好,我是景霄。
|
||||
|
||||
前面的课程,我们学习了Python中的列表和元组,了解了他们的基本操作和性能比较。这节课,我们再来学习两个同样很常见并且很有用的数据结构:字典(dict)和集合(set)。字典和集合在Python被广泛使用,并且性能进行了高度优化,其重要性不言而喻。
|
||||
|
||||
字典和集合基础
|
||||
|
||||
那究竟什么是字典,什么是集合呢?字典是一系列由键(key)和值(value)配对组成的元素的集合,在Python3.7+,字典被确定为有序(注意:在3.6中,字典有序是一个implementation detail,在3.7才正式成为语言特性,因此3.6中无法100%确保其有序性),而3.6之前是无序的,其长度大小可变,元素可以任意地删减和改变。
|
||||
|
||||
相比于列表和元组,字典的性能更优,特别是对于查找、添加和删除操作,字典都能在常数时间复杂度内完成。
|
||||
|
||||
而集合和字典基本相同,唯一的区别,就是集合没有键和值的配对,是一系列无序的、唯一的元素组合。
|
||||
|
||||
首先我们来看字典和集合的创建,通常有下面这几种方式:
|
||||
|
||||
d1 = {'name': 'jason', 'age': 20, 'gender': 'male'}
|
||||
d2 = dict({'name': 'jason', 'age': 20, 'gender': 'male'})
|
||||
d3 = dict([('name', 'jason'), ('age', 20), ('gender', 'male')])
|
||||
d4 = dict(name='jason', age=20, gender='male')
|
||||
d1 == d2 == d3 ==d4
|
||||
True
|
||||
|
||||
s1 = {1, 2, 3}
|
||||
s2 = set([1, 2, 3])
|
||||
s1 == s2
|
||||
True
|
||||
|
||||
|
||||
这里注意,Python中字典和集合,无论是键还是值,都可以是混合类型。比如下面这个例子,我创建了一个元素为1,'hello',5.0的集合:
|
||||
|
||||
s = {1, 'hello', 5.0}
|
||||
|
||||
|
||||
再来看元素访问的问题。字典访问可以直接索引键,如果不存在,就会抛出异常:
|
||||
|
||||
d = {'name': 'jason', 'age': 20}
|
||||
d['name']
|
||||
'jason'
|
||||
d['location']
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
KeyError: 'location'
|
||||
|
||||
|
||||
也可以使用get(key, default)函数来进行索引。如果键不存在,调用get()函数可以返回一个默认值。比如下面这个示例,返回了'null'。
|
||||
|
||||
d = {'name': 'jason', 'age': 20}
|
||||
d.get('name')
|
||||
'jason'
|
||||
d.get('location', 'null')
|
||||
'null'
|
||||
|
||||
|
||||
说完了字典的访问,我们再来看集合。
|
||||
|
||||
首先我要强调的是,集合并不支持索引操作,因为集合本质上是一个哈希表,和列表不一样。所以,下面这样的操作是错误的,Python会抛出异常:
|
||||
|
||||
s = {1, 2, 3}
|
||||
s[0]
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: 'set' object does not support indexing
|
||||
|
||||
|
||||
想要判断一个元素在不在字典或集合内,我们可以用value in dict/set 来判断。
|
||||
|
||||
s = {1, 2, 3}
|
||||
1 in s
|
||||
True
|
||||
10 in s
|
||||
False
|
||||
|
||||
d = {'name': 'jason', 'age': 20}
|
||||
'name' in d
|
||||
True
|
||||
'location' in d
|
||||
False
|
||||
|
||||
|
||||
当然,除了创建和访问,字典和集合也同样支持增加、删除、更新等操作。
|
||||
|
||||
d = {'name': 'jason', 'age': 20}
|
||||
d['gender'] = 'male' # 增加元素对'gender': 'male'
|
||||
d['dob'] = '1999-02-01' # 增加元素对'dob': '1999-02-01'
|
||||
d
|
||||
{'name': 'jason', 'age': 20, 'gender': 'male', 'dob': '1999-02-01'}
|
||||
d['dob'] = '1998-01-01' # 更新键'dob'对应的值
|
||||
d.pop('dob') # 删除键为'dob'的元素对
|
||||
'1998-01-01'
|
||||
d
|
||||
{'name': 'jason', 'age': 20, 'gender': 'male'}
|
||||
|
||||
s = {1, 2, 3}
|
||||
s.add(4) # 增加元素4到集合
|
||||
s
|
||||
{1, 2, 3, 4}
|
||||
s.remove(4) # 从集合中删除元素4
|
||||
s
|
||||
{1, 2, 3}
|
||||
|
||||
|
||||
不过要注意,集合的pop()操作是删除集合中最后一个元素,可是集合本身是无序的,你无法知道会删除哪个元素,因此这个操作得谨慎使用。
|
||||
|
||||
实际应用中,很多情况下,我们需要对字典或集合进行排序,比如,取出值最大的50对。
|
||||
|
||||
对于字典,我们通常会根据键或值,进行升序或降序排序:
|
||||
|
||||
d = {'b': 1, 'a': 2, 'c': 10}
|
||||
d_sorted_by_key = sorted(d.items(), key=lambda x: x[0]) # 根据字典键的升序排序
|
||||
d_sorted_by_value = sorted(d.items(), key=lambda x: x[1]) # 根据字典值的升序排序
|
||||
d_sorted_by_key
|
||||
[('a', 2), ('b', 1), ('c', 10)]
|
||||
d_sorted_by_value
|
||||
[('b', 1), ('a', 2), ('c', 10)]
|
||||
|
||||
|
||||
这里返回了一个列表。列表中的每个元素,是由原字典的键和值组成的元组。
|
||||
|
||||
而对于集合,其排序和前面讲过的列表、元组很类似,直接调用sorted(set)即可,结果会返回一个排好序的列表。
|
||||
|
||||
s = {3, 4, 2, 1}
|
||||
sorted(s) # 对集合的元素进行升序排序
|
||||
[1, 2, 3, 4]
|
||||
|
||||
|
||||
字典和集合性能
|
||||
|
||||
文章开头我就说到了,字典和集合是进行过性能高度优化的数据结构,特别是对于查找、添加和删除操作。那接下来,我们就来看看,它们在具体场景下的性能表现,以及与列表等其他数据结构的对比。
|
||||
|
||||
比如电商企业的后台,存储了每件产品的ID、名称和价格。现在的需求是,给定某件商品的ID,我们要找出其价格。
|
||||
|
||||
如果我们用列表来存储这些数据结构,并进行查找,相应的代码如下:
|
||||
|
||||
def find_product_price(products, product_id):
|
||||
for id, price in products:
|
||||
if id == product_id:
|
||||
return price
|
||||
return None
|
||||
|
||||
products = [
|
||||
(143121312, 100),
|
||||
(432314553, 30),
|
||||
(32421912367, 150)
|
||||
]
|
||||
|
||||
print('The price of product 432314553 is {}'.format(find_product_price(products, 432314553)))
|
||||
|
||||
# 输出
|
||||
The price of product 432314553 is 30
|
||||
|
||||
|
||||
假设列表有n个元素,而查找的过程要遍历列表,那么时间复杂度就为O(n)。即使我们先对列表进行排序,然后使用二分查找,也会需要O(logn)的时间复杂度,更何况,列表的排序还需要O(nlogn)的时间。
|
||||
|
||||
但如果我们用字典来存储这些数据,那么查找就会非常便捷高效,只需O(1)的时间复杂度就可以完成。原因也很简单,刚刚提到过的,字典的内部组成是一张哈希表,你可以直接通过键的哈希值,找到其对应的值。
|
||||
|
||||
products = {
|
||||
143121312: 100,
|
||||
432314553: 30,
|
||||
32421912367: 150
|
||||
}
|
||||
print('The price of product 432314553 is {}'.format(products[432314553]))
|
||||
|
||||
# 输出
|
||||
The price of product 432314553 is 30
|
||||
|
||||
|
||||
类似的,现在需求变成,要找出这些商品有多少种不同的价格。我们还用同样的方法来比较一下。
|
||||
|
||||
如果还是选择使用列表,对应的代码如下,其中,A和B是两层循环。同样假设原始列表有n个元素,那么,在最差情况下,需要O(n^2)的时间复杂度。
|
||||
|
||||
# list version
|
||||
def find_unique_price_using_list(products):
|
||||
unique_price_list = []
|
||||
for _, price in products: # A
|
||||
if price not in unique_price_list: #B
|
||||
unique_price_list.append(price)
|
||||
return len(unique_price_list)
|
||||
|
||||
products = [
|
||||
(143121312, 100),
|
||||
(432314553, 30),
|
||||
(32421912367, 150),
|
||||
(937153201, 30)
|
||||
]
|
||||
print('number of unique price is: {}'.format(find_unique_price_using_list(products)))
|
||||
|
||||
# 输出
|
||||
number of unique price is: 3
|
||||
|
||||
|
||||
但如果我们选择使用集合这个数据结构,由于集合是高度优化的哈希表,里面元素不能重复,并且其添加和查找操作只需O(1)的复杂度,那么,总的时间复杂度就只有O(n)。
|
||||
|
||||
# set version
|
||||
def find_unique_price_using_set(products):
|
||||
unique_price_set = set()
|
||||
for _, price in products:
|
||||
unique_price_set.add(price)
|
||||
return len(unique_price_set)
|
||||
|
||||
products = [
|
||||
(143121312, 100),
|
||||
(432314553, 30),
|
||||
(32421912367, 150),
|
||||
(937153201, 30)
|
||||
]
|
||||
print('number of unique price is: {}'.format(find_unique_price_using_set(products)))
|
||||
|
||||
# 输出
|
||||
number of unique price is: 3
|
||||
|
||||
|
||||
可能你对这些时间复杂度没有直观的认识,我可以举一个实际工作场景中的例子,让你来感受一下。
|
||||
|
||||
下面的代码,初始化了含有100,000个元素的产品,并分别计算了使用列表和集合来统计产品价格数量的运行时间:
|
||||
|
||||
import time
|
||||
id = [x for x in range(0, 100000)]
|
||||
price = [x for x in range(200000, 300000)]
|
||||
products = list(zip(id, price))
|
||||
|
||||
# 计算列表版本的时间
|
||||
start_using_list = time.perf_counter()
|
||||
find_unique_price_using_list(products)
|
||||
end_using_list = time.perf_counter()
|
||||
print("time elapse using list: {}".format(end_using_list - start_using_list))
|
||||
## 输出
|
||||
time elapse using list: 41.61519479751587
|
||||
|
||||
# 计算集合版本的时间
|
||||
start_using_set = time.perf_counter()
|
||||
find_unique_price_using_set(products)
|
||||
end_using_set = time.perf_counter()
|
||||
print("time elapse using set: {}".format(end_using_set - start_using_set))
|
||||
# 输出
|
||||
time elapse using set: 0.008238077163696289
|
||||
|
||||
|
||||
你可以看到,仅仅十万的数据量,两者的速度差异就如此之大。事实上,大型企业的后台数据往往有上亿乃至十亿数量级,如果使用了不合适的数据结构,就很容易造成服务器的崩溃,不但影响用户体验,并且会给公司带来巨大的财产损失。
|
||||
|
||||
字典和集合的工作原理
|
||||
|
||||
我们通过举例以及与列表的对比,看到了字典和集合操作的高效性。不过,字典和集合为什么能够如此高效,特别是查找、插入和删除操作?
|
||||
|
||||
这当然和字典、集合内部的数据结构密不可分。不同于其他数据结构,字典和集合的内部结构都是一张哈希表。
|
||||
|
||||
|
||||
对于字典而言,这张表存储了哈希值(hash)、键和值这3个元素。
|
||||
|
||||
而对集合来说,区别就是哈希表内没有键和值的配对,只有单一的元素了。
|
||||
|
||||
|
||||
我们来看,老版本Python的哈希表结构如下所示:
|
||||
|
||||
--+-------------------------------+
|
||||
| 哈希值(hash) 键(key) 值(value)
|
||||
--+-------------------------------+
|
||||
0 | hash0 key0 value0
|
||||
--+-------------------------------+
|
||||
1 | hash1 key1 value1
|
||||
--+-------------------------------+
|
||||
2 | hash2 key2 value2
|
||||
--+-------------------------------+
|
||||
. | ...
|
||||
__+_______________________________+
|
||||
|
||||
|
||||
|
||||
不难想象,随着哈希表的扩张,它会变得越来越稀疏。举个例子,比如我有这样一个字典:
|
||||
|
||||
{'name': 'mike', 'dob': '1999-01-01', 'gender': 'male'}
|
||||
|
||||
|
||||
那么它会存储为类似下面的形式:
|
||||
|
||||
entries = [
|
||||
['--', '--', '--']
|
||||
[-230273521, 'dob', '1999-01-01'],
|
||||
['--', '--', '--'],
|
||||
['--', '--', '--'],
|
||||
[1231236123, 'name', 'mike'],
|
||||
['--', '--', '--'],
|
||||
[9371539127, 'gender', 'male']
|
||||
]
|
||||
|
||||
|
||||
这样的设计结构显然非常浪费存储空间。为了提高存储空间的利用率,现在的哈希表除了字典本身的结构,会把索引和哈希值、键、值单独分开,也就是下面这样新的结构:
|
||||
|
||||
Indices
|
||||
----------------------------------------------------
|
||||
None | index | None | None | index | None | index ...
|
||||
----------------------------------------------------
|
||||
|
||||
Entries
|
||||
--------------------
|
||||
hash0 key0 value0
|
||||
---------------------
|
||||
hash1 key1 value1
|
||||
---------------------
|
||||
hash2 key2 value2
|
||||
---------------------
|
||||
...
|
||||
---------------------
|
||||
|
||||
|
||||
那么,刚刚的这个例子,在新的哈希表结构下的存储形式,就会变成下面这样:
|
||||
|
||||
indices = [None, 1, None, None, 0, None, 2]
|
||||
entries = [
|
||||
[1231236123, 'name', 'mike'],
|
||||
[-230273521, 'dob', '1999-01-01'],
|
||||
[9371539127, 'gender', 'male']
|
||||
]
|
||||
|
||||
|
||||
我们可以很清晰地看到,空间利用率得到很大的提高。
|
||||
|
||||
清楚了具体的设计结构,我们接着来看这几个操作的工作原理。
|
||||
|
||||
插入操作
|
||||
|
||||
每次向字典或集合插入一个元素时,Python会首先计算键的哈希值(hash(key)),再和 mask = PyDicMinSize - 1做与操作,计算这个元素应该插入哈希表的位置index = hash(key) & mask。如果哈希表中此位置是空的,那么这个元素就会被插入其中。
|
||||
|
||||
而如果此位置已被占用,Python便会比较两个元素的哈希值和键是否相等。
|
||||
|
||||
|
||||
若两者都相等,则表明这个元素已经存在,如果值不同,则更新值。
|
||||
|
||||
若两者中有一个不相等,这种情况我们通常称为哈希冲突(hash collision),意思是两个元素的键不相等,但是哈希值相等。这种情况下,Python便会继续寻找表中空余的位置,直到找到位置为止。
|
||||
|
||||
|
||||
值得一提的是,通常来说,遇到这种情况,最简单的方式是线性寻找,即从这个位置开始,挨个往后寻找空位。当然,Python内部对此进行了优化(这一点无需深入了解,你有兴趣可以查看源码,我就不再赘述),让这个步骤更加高效。
|
||||
|
||||
查找操作
|
||||
|
||||
和前面的插入操作类似,Python会根据哈希值,找到其应该处于的位置;然后,比较哈希表这个位置中元素的哈希值和键,与需要查找的元素是否相等。如果相等,则直接返回;如果不等,则继续查找,直到找到空位或者抛出异常为止。
|
||||
|
||||
删除操作
|
||||
|
||||
对于删除操作,Python会暂时对这个位置的元素,赋于一个特殊的值,等到重新调整哈希表的大小时,再将其删除。
|
||||
|
||||
不难理解,哈希冲突的发生,往往会降低字典和集合操作的速度。因此,为了保证其高效性,字典和集合内的哈希表,通常会保证其至少留有1/3的剩余空间。随着元素的不停插入,当剩余空间小于1/3时,Python会重新获取更大的内存空间,扩充哈希表。不过,这种情况下,表内所有的元素位置都会被重新排放。
|
||||
|
||||
虽然哈希冲突和哈希表大小的调整,都会导致速度减缓,但是这种情况发生的次数极少。所以,平均情况下,这仍能保证插入、查找和删除的时间复杂度为O(1)。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起学习了字典和集合的基本操作,并对它们的高性能和内部存储结构进行了讲解。
|
||||
|
||||
字典在Python3.7+是有序的数据结构,而集合是无序的,其内部的哈希表存储结构,保证了其查找、插入、删除操作的高效性。所以,字典和集合通常运用在对元素的高效查找、去重等场景。
|
||||
|
||||
思考题
|
||||
|
||||
1. 下面初始化字典的方式,哪一种更高效?
|
||||
|
||||
# Option A
|
||||
d = {'name': 'jason', 'age': 20, 'gender': 'male'}
|
||||
|
||||
# Option B
|
||||
d = dict({'name': 'jason', 'age': 20, 'gender': 'male'})
|
||||
|
||||
|
||||
2. 字典的键可以是一个列表吗?下面这段代码中,字典的初始化是否正确呢?如果不正确,可以说出你的原因吗?
|
||||
|
||||
d = {'name': 'jason', ['education']: ['Tsinghua University', 'Stanford University']}
|
||||
|
||||
|
||||
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
248
专栏/Python核心技术与实战/05深入浅出字符串.md
Normal file
248
专栏/Python核心技术与实战/05深入浅出字符串.md
Normal file
@ -0,0 +1,248 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 深入浅出字符串
|
||||
你好,我是景霄。
|
||||
|
||||
Python的程序中充满了字符串(string),在平常阅读代码时也屡见不鲜。字符串同样是Python中很常见的一种数据类型,比如日志的打印、程序中函数的注释、数据库的访问、变量的基本操作等等,都用到了字符串。
|
||||
|
||||
当然,我相信你本身对字符串已经有所了解。今天这节课,我主要带你回顾一下字符串的常用操作,并对其中的一些小tricks详细地加以解释。
|
||||
|
||||
字符串基础
|
||||
|
||||
什么是字符串呢?字符串是由独立字符组成的一个序列,通常包含在单引号('')双引号("")或者三引号之中(''' '''或""" """,两者一样),比如下面几种写法。
|
||||
|
||||
name = 'jason'
|
||||
city = 'beijing'
|
||||
text = "welcome to jike shijian"
|
||||
|
||||
|
||||
这里定义了name、city和text三个变量,都是字符串类型。我们知道,Python中单引号、双引号和三引号的字符串是一模一样的,没有区别,比如下面这个例子中的s1、s2、s3完全一样。
|
||||
|
||||
s1 = 'hello'
|
||||
s2 = "hello"
|
||||
s3 = """hello"""
|
||||
s1 == s2 == s3
|
||||
True
|
||||
|
||||
|
||||
Python同时支持这三种表达方式,很重要的一个原因就是,这样方便你在字符串中,内嵌带引号的字符串。比如:
|
||||
|
||||
"I'm a student"
|
||||
|
||||
|
||||
Python的三引号字符串,则主要应用于多行字符串的情境,比如函数的注释等等。
|
||||
|
||||
def calculate_similarity(item1, item2):
|
||||
"""
|
||||
Calculate similarity between two items
|
||||
Args:
|
||||
item1: 1st item
|
||||
item2: 2nd item
|
||||
Returns:
|
||||
similarity score between item1 and item2
|
||||
"""
|
||||
|
||||
|
||||
同时,Python也支持转义字符。所谓的转义字符,就是用反斜杠开头的字符串,来表示一些特定意义的字符。我把常见的的转义字符,总结成了下面这张表格。
|
||||
|
||||
|
||||
|
||||
为了方便你理解,我举一个例子来说明。
|
||||
|
||||
s = 'a\nb\tc'
|
||||
print(s)
|
||||
a
|
||||
b c
|
||||
|
||||
|
||||
这段代码中的'\n',表示一个字符——换行符;'\t'也表示一个字符——横向制表符。所以,最后打印出来的输出,就是字符a,换行,字符b,然后制表符,最后打印字符c。不过要注意,虽然最后打印的输出横跨了两行,但是整个字符串s仍然只有5个元素。
|
||||
|
||||
len(s)
|
||||
5
|
||||
|
||||
|
||||
在转义字符的应用中,最常见的就是换行符'\n'的使用。比如文件读取,如果我们一行行地读取,那么每一行字符串的末尾,都会包含换行符'\n'。而最后做数据处理时,我们往往会丢掉每一行的换行符。
|
||||
|
||||
字符串的常用操作
|
||||
|
||||
讲完了字符串的基本原理,下面我们一起来看看字符串的常用操作。你可以把字符串想象成一个由单个字符组成的数组,所以,Python的字符串同样支持索引,切片和遍历等等操作。
|
||||
|
||||
name = 'jason'
|
||||
name[0]
|
||||
'j'
|
||||
name[1:3]
|
||||
'as'
|
||||
|
||||
|
||||
和其他数据结构,如列表、元组一样,字符串的索引同样从0开始,index=0表示第一个元素(字符),[index:index+2]则表示第index个元素到index+1个元素组成的子字符串。
|
||||
|
||||
遍历字符串同样很简单,相当于遍历字符串中的每个字符。
|
||||
|
||||
for char in name:
|
||||
print(char)
|
||||
j
|
||||
a
|
||||
s
|
||||
o
|
||||
n
|
||||
|
||||
|
||||
特别要注意,Python的字符串是不可变的(immutable)。因此,用下面的操作,来改变一个字符串内部的字符是错误的,不允许的。
|
||||
|
||||
s = 'hello'
|
||||
s[0] = 'H'
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: 'str' object does not support item assignment
|
||||
|
||||
|
||||
Python中字符串的改变,通常只能通过创建新的字符串来完成。比如上述例子中,想把'hello'的第一个字符'h',改为大写的'H',我们可以采用下面的做法:
|
||||
|
||||
s = 'H' + s[1:]
|
||||
s = s.replace('h', 'H')
|
||||
|
||||
|
||||
|
||||
第一种方法,是直接用大写的'H',通过加号'+'操作符,与原字符串切片操作的子字符串拼接而成新的字符串。
|
||||
|
||||
第二种方法,是直接扫描原字符串,把小写的'h'替换成大写的'H',得到新的字符串。
|
||||
|
||||
|
||||
你可能了解到,在其他语言中,如Java,有可变的字符串类型,比如StringBuilder,每次添加、改变或删除字符(串),无需创建新的字符串,时间复杂度仅为O(1)。这样就大大提高了程序的运行效率。
|
||||
|
||||
但可惜的是,Python中并没有相关的数据类型,我们还是得老老实实创建新的字符串。因此,每次想要改变字符串,往往需要O(n)的时间复杂度,其中,n为新字符串的长度。
|
||||
|
||||
你可能注意到了,上述例子的说明中,我用的是“往往”、“通常”这样的字眼,并没有说“一定”。这是为什么呢?显然,随着版本的更新,Python也越来越聪明,性能优化得越来越好了。
|
||||
|
||||
这里,我着重讲解一下,使用加法操作符'+='的字符串拼接方法。因为它是一个例外,打破了字符串不可变的特性。
|
||||
|
||||
操作方法如下所示:
|
||||
|
||||
str1 += str2 # 表示str1 = str1 + str2
|
||||
|
||||
|
||||
我们来看下面这个例子:
|
||||
|
||||
s = ''
|
||||
for n in range(0, 100000):
|
||||
s += str(n)
|
||||
|
||||
|
||||
你觉得这个例子的时间复杂度是多少呢?
|
||||
|
||||
每次循环,似乎都得创建一个新的字符串;而每次创建一个新的字符串,都需要O(n)的时间复杂度。因此,总的时间复杂度就为O(1) + O(2) + … + O(n) = O(n^2)。这样到底对不对呢?
|
||||
|
||||
乍一看,这样分析确实很有道理,但是必须说明,这个结论只适用于老版本的Python了。自从Python2.5开始,每次处理字符串的拼接操作时(str1 += str2),Python首先会检测str1还有没有其他的引用。如果没有的话,就会尝试原地扩充字符串buffer的大小,而不是重新分配一块内存来创建新的字符串并拷贝。这样的话,上述例子中的时间复杂度就仅为O(n)了。
|
||||
|
||||
因此,以后你在写程序遇到字符串拼接时,如果使用’+=‘更方便,就放心地去用吧,不用过分担心效率问题了。
|
||||
|
||||
另外,对于字符串拼接问题,除了使用加法操作符,我们还可以使用字符串内置的join函数。string.join(iterable),表示把每个元素都按照指定的格式连接起来。
|
||||
|
||||
l = []
|
||||
for n in range(0, 100000):
|
||||
l.append(str(n))
|
||||
l = ' '.join(l)
|
||||
|
||||
|
||||
由于列表的append操作是O(1)复杂度,字符串同理。因此,这个含有for循环例子的时间复杂度为n*O(1)=O(n)。
|
||||
|
||||
接下来,我们看一下字符串的分割函数split()。string.split(separator),表示把字符串按照separator分割成子字符串,并返回一个分割后子字符串组合的列表。它常常应用于对数据的解析处理,比如我们读取了某个文件的路径,想要调用数据库的API,去读取对应的数据,我们通常会写成下面这样:
|
||||
|
||||
def query_data(namespace, table):
|
||||
"""
|
||||
given namespace and table, query database to get corresponding
|
||||
data
|
||||
"""
|
||||
|
||||
path = 'hive://ads/training_table'
|
||||
namespace = path.split('//')[1].split('/')[0] # 返回'ads'
|
||||
table = path.split('//')[1].split('/')[1] # 返回 'training_table'
|
||||
data = query_data(namespace, table)
|
||||
|
||||
|
||||
此外,常见的函数还有:
|
||||
|
||||
|
||||
string.strip(str),表示去掉首尾的str字符串;
|
||||
|
||||
string.lstrip(str),表示只去掉开头的str字符串;
|
||||
|
||||
string.rstrip(str),表示只去掉尾部的str字符串。
|
||||
|
||||
|
||||
这些在数据的解析处理中同样很常见。比如很多时候,从文件读进来的字符串中,开头和结尾都含有空字符,我们需要去掉它们,就可以用strip()函数:
|
||||
|
||||
s = ' my name is jason '
|
||||
s.strip()
|
||||
'my name is jason'
|
||||
|
||||
|
||||
当然,Python中字符串还有很多常用操作,比如,string.find(sub, start, end),表示从start到end查找字符串中子字符串sub的位置等等。这里,我只强调了最常用并且容易出错的几个函数,其他内容你可以自行查找相应的文档、范例加以了解,我就不一一赘述了。
|
||||
|
||||
字符串的格式化
|
||||
|
||||
最后,我们一起来看看字符串的格式化。什么是字符串的格式化呢?
|
||||
|
||||
通常,我们使用一个字符串作为模板,模板中会有格式符。这些格式符为后续真实值预留位置,以呈现出真实值应该呈现的格式。字符串的格式化,通常会用在程序的输出、logging等场景。
|
||||
|
||||
举一个常见的例子。比如我们有一个任务,给定一个用户的userid,要去数据库中查询该用户的一些信息,并返回。而如果数据库中没有此人的信息,我们通常会记录下来,这样有利于往后的日志分析,或者是线上bug的调试等等。
|
||||
|
||||
我们通常会用下面的方法来表示:
|
||||
|
||||
print('no data available for person with id: {}, name: {}'.format(id, name))
|
||||
|
||||
|
||||
其中的string.format(),就是所谓的格式化函数;而大括号{}就是所谓的格式符,用来为后面的真实值——变量name预留位置。如果id = '123'、name='jason',那么输出便是:
|
||||
|
||||
'no data available for person with id: 123, name: jason'
|
||||
|
||||
|
||||
这样看来,是不是非常简单呢?
|
||||
|
||||
不过要注意,string.format()是最新的字符串格式函数与规范。自然,我们还有其他的表示方法,比如在Python之前版本中,字符串格式化通常用%来表示,那么上述的例子,就可以写成下面这样:
|
||||
|
||||
print('no data available for person with id: %s, name: %s' % (id, name))
|
||||
|
||||
|
||||
其中%s表示字符串型,%d表示整型等等,这些属于常识,你应该都了解。
|
||||
|
||||
当然,现在你写程序时,我还是推荐使用format函数,毕竟这是最新规范,也是官方文档推荐的规范。
|
||||
|
||||
也许有人会问,为什么非要使用格式化函数,上述例子用字符串的拼接不也能完成吗?没错,在很多情况下,字符串拼接确实能满足格式化函数的需求。但是使用格式化函数,更加清晰、易读,并且更加规范,不易出错。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们主要学习了Python字符串的一些基本知识和常用操作,并且结合具体的例子与场景加以说明,特别需要注意下面几点。
|
||||
|
||||
|
||||
Python中字符串使用单引号、双引号或三引号表示,三者意义相同,并没有什么区别。其中,三引号的字符串通常用在多行字符串的场景。
|
||||
|
||||
Python中字符串是不可变的(前面所讲的新版本Python中拼接操作’+=‘是个例外)。因此,随意改变字符串中字符的值,是不被允许的。
|
||||
|
||||
Python新版本(2.5+)中,字符串的拼接变得比以前高效了许多,你可以放心使用。
|
||||
|
||||
Python中字符串的格式化(string.format)常常用在输出、日志的记录等场景。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,给你留一道思考题。在新版本的Python(2.5+)中,下面的两个字符串拼接操作,你觉得哪个更优呢?欢迎留言和我分享你的观点,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
s = ''
|
||||
for n in range(0, 100000):
|
||||
s += str(n)
|
||||
|
||||
|
||||
l = []
|
||||
for n in range(0, 100000):
|
||||
l.append(str(n))
|
||||
|
||||
s = ' '.join(l)
|
||||
|
||||
|
||||
|
||||
|
||||
|
318
专栏/Python核心技术与实战/06Python“黑箱”:输入与输出.md
Normal file
318
专栏/Python核心技术与实战/06Python“黑箱”:输入与输出.md
Normal file
@ -0,0 +1,318 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 Python “黑箱”:输入与输出
|
||||
你好,我是景霄。
|
||||
|
||||
世纪之交的论坛上曾有一句流行语:在互联网上,没人知道你是一条狗。互联网刚刚兴起时,一根网线链接到你家,信息通过这条高速线缆直达你的屏幕,你通过键盘飞速回应朋友的消息,信息再次通过网线飞入错综复杂的虚拟世界,再进入朋友家。抽象来看,一台台的电脑就是一个个黑箱,黑箱有了输入和输出,就拥有了图灵机运作的必要条件。
|
||||
|
||||
Python 程序也是一个黑箱:通过输入流将数据送达,通过输出流将处理后的数据送出,可能 Python 解释器后面藏了一个人,还是一个史莱哲林?No one cares。
|
||||
|
||||
好了废话不多说,今天我们就由浅及深讲讲 Python 的输入和输出。
|
||||
|
||||
输入输出基础
|
||||
|
||||
最简单直接的输入来自键盘操作,比如下面这个例子。
|
||||
|
||||
name = input('your name:')
|
||||
gender = input('you are a boy?(y/n)')
|
||||
|
||||
###### 输入 ######
|
||||
your name:Jack
|
||||
you are a boy?
|
||||
|
||||
welcome_str = 'Welcome to the matrix {prefix} {name}.'
|
||||
welcome_dic = {
|
||||
'prefix': 'Mr.' if gender == 'y' else 'Mrs',
|
||||
'name': name
|
||||
}
|
||||
|
||||
print('authorizing...')
|
||||
print(welcome_str.format(**welcome_dic))
|
||||
|
||||
########## 输出 ##########
|
||||
authorizing...
|
||||
Welcome to the matrix Mr. Jack.
|
||||
|
||||
|
||||
input() 函数暂停程序运行,同时等待键盘输入;直到回车被按下,函数的参数即为提示语,输入的类型永远是字符串型(str)。注意,初学者在这里很容易犯错,下面的例子我会讲到。print() 函数则接受字符串、数字、字典、列表甚至一些自定义类的输出。
|
||||
|
||||
我们再来看下面这个例子。
|
||||
|
||||
a = input()
|
||||
1
|
||||
b = input()
|
||||
2
|
||||
|
||||
print('a + b = {}'.format(a + b))
|
||||
########## 输出 ##############
|
||||
a + b = 12
|
||||
print('type of a is {}, type of b is {}'.format(type(a), type(b)))
|
||||
########## 输出 ##############
|
||||
type of a is <class 'str'>, type of b is <class 'str'>
|
||||
print('a + b = {}'.format(int(a) + int(b)))
|
||||
########## 输出 ##############
|
||||
a + b = 3
|
||||
|
||||
|
||||
这里注意,把 str 强制转换为 int 请用 int(),转为浮点数请用 float()。而在生产环境中使用强制转换时,请记得加上 try except(即错误和异常处理,专栏后面文章会讲到)。
|
||||
|
||||
Python 对 int 类型没有最大限制(相比之下, C++ 的 int 最大为 2147483647,超过这个数字会产生溢出),但是对 float 类型依然有精度限制。这些特点,除了在一些算法竞赛中要注意,在生产环境中也要时刻提防,避免因为对边界条件判断不清而造成 bug 甚至 0day(危重安全漏洞)。
|
||||
|
||||
我们回望一下币圈。2018年4月23日中午11点30分左右,BEC 代币智能合约被黑客攻击。黑客利用数据溢出的漏洞,攻击与美图合作的公司美链 BEC 的智能合约,成功地向两个地址转出了天量级别的 BEC 代币,导致市场上的海量 BEC 被抛售,该数字货币的价值也几近归零,给 BEC 市场交易带来了毁灭性的打击。
|
||||
|
||||
由此可见,虽然输入输出和类型处理事情简单,但我们一定要慎之又慎。毕竟相当比例的安全漏洞,都来自随意的 I/O 处理。
|
||||
|
||||
文件输入输出
|
||||
|
||||
命令行的输入输出,只是 Python 交互的最基本方式,适用一些简单小程序的交互。而生产级别的 Python 代码,大部分 I/O 则来自于文件、网络、其他进程的消息等等。
|
||||
|
||||
接下来,我们来详细分析一个文本文件读写。假设我们有一个文本文件in.txt,内容如下:
|
||||
|
||||
I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character. I have a dream today.
|
||||
|
||||
I have a dream that one day down in Alabama, with its vicious racists, . . . one day right there in Alabama little black boys and black girls will be able to join hands with little white boys and white girls as sisters and brothers. I have a dream today.
|
||||
|
||||
I have a dream that one day every valley shall be exalted, every hill and mountain shall be made low, the rough places will be made plain, and the crooked places will be made straight, and the glory of the Lord shall be revealed, and all flesh shall see it together.
|
||||
|
||||
This is our hope. . . With this faith we will be able to hew out of the mountain of despair a stone of hope. With this faith we will be able to transform the jangling discords of our nation into a beautiful symphony of brotherhood. With this faith we will be able to work together, to pray together, to struggle together, to go to jail together, to stand up for freedom together, knowing that we will be free one day. . . .
|
||||
|
||||
And when this happens, and when we allow freedom ring, when we let it ring from every village and every hamlet, from every state and every city, we will be able to speed up that day when all of God's children, black men and white men, Jews and Gentiles, Protestants and Catholics, will be able to join hands and sing in the words of the old Negro spiritual: "Free at last! Free at last! Thank God Almighty, we are free at last!"
|
||||
|
||||
|
||||
好,让我们来做一个简单的 NLP(自然语言处理)任务。如果你对此不太了解也没有影响,我会带你一步步完成这个任务。
|
||||
|
||||
首先,我们要清楚NLP任务的基本步骤,也就是下面的四步:
|
||||
|
||||
|
||||
读取文件;
|
||||
|
||||
去除所有标点符号和换行符,并把所有大写变成小写;
|
||||
|
||||
合并相同的词,统计每个词出现的频率,并按照词频从大到小排序;
|
||||
|
||||
将结果按行输出到文件 out.txt。
|
||||
|
||||
|
||||
你可以自己先思考一下,用Python如何解决这个问题。这里,我也给出了我的代码,并附有详细的注释。我们一起来看下这段代码。
|
||||
|
||||
import re
|
||||
|
||||
# 你不用太关心这个函数
|
||||
def parse(text):
|
||||
# 使用正则表达式去除标点符号和换行符
|
||||
text = re.sub(r'[^\w ]', ' ', text)
|
||||
|
||||
# 转为小写
|
||||
text = text.lower()
|
||||
|
||||
# 生成所有单词的列表
|
||||
word_list = text.split(' ')
|
||||
|
||||
# 去除空白单词
|
||||
word_list = filter(None, word_list)
|
||||
|
||||
# 生成单词和词频的字典
|
||||
word_cnt = {}
|
||||
for word in word_list:
|
||||
if word not in word_cnt:
|
||||
word_cnt[word] = 0
|
||||
word_cnt[word] += 1
|
||||
|
||||
# 按照词频排序
|
||||
sorted_word_cnt = sorted(word_cnt.items(), key=lambda kv: kv[1], reverse=True)
|
||||
|
||||
return sorted_word_cnt
|
||||
|
||||
with open('in.txt', 'r') as fin:
|
||||
text = fin.read()
|
||||
|
||||
word_and_freq = parse(text)
|
||||
|
||||
with open('out.txt', 'w') as fout:
|
||||
for word, freq in word_and_freq:
|
||||
fout.write('{} {}\n'.format(word, freq))
|
||||
|
||||
########## 输出(省略较长的中间结果) ##########
|
||||
|
||||
and 15
|
||||
be 13
|
||||
will 11
|
||||
to 11
|
||||
the 10
|
||||
of 10
|
||||
a 8
|
||||
we 8
|
||||
day 6
|
||||
|
||||
...
|
||||
|
||||
old 1
|
||||
negro 1
|
||||
spiritual 1
|
||||
thank 1
|
||||
god 1
|
||||
almighty 1
|
||||
are 1
|
||||
|
||||
|
||||
你不用太关心 parse() 函数的具体实现,你只需要知道,它做的事情是把输入的 text 字符串,转化为我们需要的排序后的词频统计。而 sorted_word_cnt 则是一个二元组的列表(list of tuples)。
|
||||
|
||||
首先我们需要先了解一下,计算机中文件访问的基础知识。事实上,计算机内核(kernel)对文件的处理相对比较复杂,涉及到内核模式、虚拟文件系统、锁和指针等一系列概念,这些内容我不会深入讲解,我只说一些基础但足够使用的知识。
|
||||
|
||||
我们先要用open() 函数拿到文件的指针。其中,第一个参数指定文件位置(相对位置或者绝对位置);第二个参数,如果是 'r' 表示读取,如果是'w' 则表示写入,当然也可以用 'rw' ,表示读写都要。a 则是一个不太常用(但也很有用)的参数,表示追加(append),这样打开的文件,如果需要写入,会从原始文件的最末尾开始写入。
|
||||
|
||||
这里我插一句,在 Facebook 的工作中,代码权限管理非常重要。如果你只需要读取文件,就不要请求写入权限。这样在某种程度上可以降低 bug 对整个系统带来的风险。
|
||||
|
||||
好,回到我们的话题。在拿到指针后,我们可以通过 read() 函数,来读取文件的全部内容。代码 text = fin.read() ,即表示把文件所有内容读取到内存中,并赋值给变量 text。这么做自然也是有利有弊:
|
||||
|
||||
|
||||
优点是方便,接下来我们可以很方便地调用 parse 函数进行分析;
|
||||
|
||||
缺点是如果文件过大,一次性读取可能造成内存崩溃。
|
||||
|
||||
|
||||
这时,我们可以给 read 指定参数 size ,用来表示读取的最大长度。还可以通过 readline() 函数,每次读取一行,这种做法常用于数据挖掘(Data Mining)中的数据清洗,在写一些小的程序时非常轻便。如果每行之间没有关联,这种做法也可以降低内存的压力。而write() 函数,可以把参数中的字符串输出到文件中,也很容易理解。
|
||||
|
||||
这里我需要简单提一下 with 语句(后文会详细讲到)。open() 函数对应于 close() 函数,也就是说,如果你打开了文件,在完成读取任务后,就应该立刻关掉它。而如果你使用了 with 语句,就不需要显式调用 close()。在 with 的语境下任务执行完毕后,close() 函数会被自动调用,代码也简洁很多。
|
||||
|
||||
最后需要注意的是,所有 I/O 都应该进行错误处理。因为 I/O 操作可能会有各种各样的情况出现,而一个健壮(robust)的程序,需要能应对各种情况的发生,而不应该崩溃(故意设计的情况除外)。
|
||||
|
||||
JSON 序列化与实战
|
||||
|
||||
最后,我来讲一个和实际应用很贴近的知识点。
|
||||
|
||||
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,它的设计意图是把所有事情都用设计的字符串来表示,这样既方便在互联网上传递信息,也方便人进行阅读(相比一些 binary 的协议)。JSON 在当今互联网中应用非常广泛,也是每一个用 Python程序员应当熟练掌握的技能点。
|
||||
|
||||
设想一个情景,你要向交易所购买一定数额的股票。那么,你需要提交股票代码、方向(买入/卖出)、订单类型(市价/限价)、价格(如果是限价单)、数量等一系列参数,而这些数据里,有字符串,有整数,有浮点数,甚至还有布尔型变量,全部混在一起并不方便交易所解包。
|
||||
|
||||
那该怎么办呢?
|
||||
|
||||
其实,我们要讲的JSON ,正能解决这个场景。你可以把它简单地理解为两种黑箱:
|
||||
|
||||
|
||||
第一种,输入这些杂七杂八的信息,比如Python 字典,输出一个字符串;
|
||||
|
||||
第二种,输入这个字符串,可以输出包含原始信息的 Python 字典。
|
||||
|
||||
|
||||
具体代码如下:
|
||||
|
||||
import json
|
||||
|
||||
params = {
|
||||
'symbol': '123456',
|
||||
'type': 'limit',
|
||||
'price': 123.4,
|
||||
'amount': 23
|
||||
}
|
||||
|
||||
params_str = json.dumps(params)
|
||||
|
||||
print('after json serialization')
|
||||
print('type of params_str = {}, params_str = {}'.format(type(params_str), params))
|
||||
|
||||
original_params = json.loads(params_str)
|
||||
|
||||
print('after json deserialization')
|
||||
print('type of original_params = {}, original_params = {}'.format(type(original_params), original_params))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
after json serialization
|
||||
type of params_str = <class 'str'>, params_str = {'symbol': '123456', 'type': 'limit', 'price': 123.4, 'amount': 23}
|
||||
after json deserialization
|
||||
type of original_params = <class 'dict'>, original_params = {'symbol': '123456', 'type': 'limit', 'price': 123.4, 'amount': 23}
|
||||
|
||||
|
||||
其中,
|
||||
|
||||
|
||||
json.dumps() 这个函数,接受 Python 的基本数据类型,然后将其序列化为 string;
|
||||
|
||||
而json.loads() 这个函数,接受一个合法字符串,然后将其反序列化为 Python 的基本数据类型。
|
||||
|
||||
|
||||
是不是很简单呢?
|
||||
|
||||
不过还是那句话,请记得加上错误处理。不然,哪怕只是给 json.loads() 发送了一个非法字符串,而你没有 catch 到,程序就会崩溃了。
|
||||
|
||||
到这一步,你可能会想,如果我要输出字符串到文件,或者从文件中读取JSON字符串,又该怎么办呢?
|
||||
|
||||
是的,你仍然可以使用上面提到的 open() 和 read()/write() ,先将字符串读取/输出到内存,再进行JSON编码/解码,当然这有点麻烦。
|
||||
|
||||
import json
|
||||
|
||||
params = {
|
||||
'symbol': '123456',
|
||||
'type': 'limit',
|
||||
'price': 123.4,
|
||||
'amount': 23
|
||||
}
|
||||
|
||||
with open('params.json', 'w') as fout:
|
||||
params_str = json.dump(params, fout)
|
||||
|
||||
with open('params.json', 'r') as fin:
|
||||
original_params = json.load(fin)
|
||||
|
||||
print('after json deserialization')
|
||||
print('type of original_params = {}, original_params = {}'.format(type(original_params), original_params))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
after json deserialization
|
||||
type of original_params = <class 'dict'>, original_params = {'symbol': '123456', 'type': 'limit', 'price': 123.4, 'amount': 23}
|
||||
|
||||
|
||||
这样,我们就简单清晰地实现了读写 JSON 字符串的过程。当开发一个第三方应用程序时,你可以通过 JSON 将用户的个人配置输出到文件,方便下次程序启动时自动读取。这也是现在普遍运用的成熟做法。
|
||||
|
||||
那么 JSON 是唯一的选择吗?显然不是,它只是轻量级应用中最方便的选择之一。据我所知,在 Google,有类似的工具叫做Protocol Buffer,当然,Google 已经完全开源了这个工具,你可以自己了解一下使用方法。
|
||||
|
||||
相比于 JSON,它的优点是生成优化后的二进制文件,因此性能更好。但与此同时,生成的二进制序列,是不能直接阅读的。它在 TensorFlow 等很多对性能有要求的系统中都有广泛的应用。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们主要学习了 Python 的普通 I/O和文件 I/O,同时了解了 JSON 序列化的基本知识,并通过具体的例子进一步掌握。再次强调一下需要注意的几点:
|
||||
|
||||
|
||||
I/O 操作需谨慎,一定要进行充分的错误处理,并细心编码,防止出现编码漏洞;
|
||||
|
||||
编码时,对内存占用和磁盘占用要有充分的估计,这样在出错时可以更容易找到原因;
|
||||
|
||||
JSON序列化是很方便的工具,要结合实战多多练习;
|
||||
|
||||
代码尽量简洁、清晰,哪怕是初学阶段,也要有一颗当元帅的心。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,我给你留了两道思考题。
|
||||
|
||||
第一问:你能否把NLP例子中的 word count 实现一遍?不过这次,in.txt 可能非常非常大(意味着你不能一次读取到内存中),而 output.txt 不会很大(意味着重复的单词数量很多)。
|
||||
|
||||
提示:你可能需要每次读取一定长度的字符串,进行处理,然后再读取下一次的。但是如果单纯按照长度划分,你可能会把一个单词隔断开,所以需要细心处理这种边界情况。
|
||||
|
||||
第二问:你应该使用过类似百度网盘、Dropbox等网盘,但是它们可能空间有限(比如 5GB)。如果有一天,你计划把家里的 100GB 数据传送到公司,可惜你没带 U 盘,于是你想了一个主意:
|
||||
|
||||
每次从家里向 Dropbox 网盘写入不超过 5GB 的数据,而公司电脑一旦侦测到新数据,就立即拷贝到本地,然后删除网盘上的数据。等家里电脑侦测到本次数据全部传入公司电脑后,再进行下一次写入,直到所有数据都传输过去。
|
||||
|
||||
根据这个想法,你计划在家写一个 server.py,在公司写一个 client.py 来实现这个需求。
|
||||
|
||||
提示:我们假设每个文件都不超过 5GB。
|
||||
|
||||
|
||||
你可以通过写入一个控制文件(config.json)来同步状态。不过,要小心设计状态,这里有可能产生 race condition。
|
||||
|
||||
你也可以通过直接侦测文件是否产生,或者是否被删除来同步状态,这是最简单的做法。
|
||||
|
||||
|
||||
不要担心难度问题,尽情写下你的思考,最终代码我也会为你准备好。
|
||||
|
||||
欢迎在留言区写下你的答案,也欢迎你把这篇文章转给你的同事、朋友,一起在思考中学习。
|
||||
|
||||
|
||||
|
||||
|
340
专栏/Python核心技术与实战/07修炼基本功:条件与循环.md
Normal file
340
专栏/Python核心技术与实战/07修炼基本功:条件与循环.md
Normal file
@ -0,0 +1,340 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 修炼基本功:条件与循环
|
||||
你好,我是景霄。
|
||||
|
||||
前面几节,我们一起学习了列表、元组、字典、集合和字符串等一系列Python的基本数据类型。但是,如何把这一个个基本的数据结构类型串接起来,组成一手漂亮的代码呢?这就是我们今天所要讨论的“条件与循环”。
|
||||
|
||||
我习惯把“条件与循环”,叫做编程中的基本功。为什么称它为基本功呢?因为它控制着代码的逻辑,可以说是程序的中枢系统。如果把写程序比作盖楼房,那么条件与循环就是楼房的根基,其他所有东西都是在此基础上构建而成。
|
||||
|
||||
毫不夸张地说,写一手简洁易读的条件与循环代码,对提高程序整体的质量至关重要。
|
||||
|
||||
条件语句
|
||||
|
||||
首先,我们一起来看一下Python的条件语句,用法很简单。比如,我想要表示y=|x|这个函数,那么相应的代码便是:
|
||||
|
||||
# y = |x|
|
||||
if x < 0:
|
||||
y = -x
|
||||
else:
|
||||
y = x
|
||||
|
||||
|
||||
和其他语言不一样,我们不能在条件语句中加括号,写成下面这样的格式。
|
||||
|
||||
if (x < 0)
|
||||
|
||||
|
||||
但需要注意的是,在条件语句的末尾必须加上冒号(:),这是Python特定的语法规范。
|
||||
|
||||
由于Python不支持switch语句,因此,当存在多个条件判断时,我们需要用else if来实现,这在Python中的表达是elif。语法如下:
|
||||
|
||||
if condition_1:
|
||||
statement_1
|
||||
elif condition_2:
|
||||
statement_2
|
||||
...
|
||||
elif condition_i:
|
||||
statement_i
|
||||
else:
|
||||
statement_n
|
||||
|
||||
|
||||
整个条件语句是顺序执行的,如果遇到一个条件满足,比如condition_i满足时,在执行完statement_i后,便会退出整个if、elif、else条件语句,而不会继续向下执行。这个语句在工作中很常用,比如下面的这个例子。
|
||||
|
||||
实际工作中,我们经常用ID表示一个事物的属性,然后进行条件判断并且输出。比如,在integrity的工作中,通常用0、1、2分别表示一部电影的色情暴力程度。其中,0的程度最高,是red级别;1其次,是yellow级别;2代表没有质量问题,属于green。
|
||||
|
||||
如果给定一个ID,要求输出某部电影的质量评级,则代码如下:
|
||||
|
||||
if id == 0:
|
||||
print('red')
|
||||
elif id == 1:
|
||||
print('yellow')
|
||||
else:
|
||||
print('green')
|
||||
|
||||
|
||||
不过要注意,if语句是可以单独使用的,但elif、else都必须和if成对使用。
|
||||
|
||||
另外,在我们进行条件判断时, 不少人喜欢省略判断的条件,比如写成下面这样:
|
||||
|
||||
if s: # s is a string
|
||||
...
|
||||
if l: # l is a list
|
||||
...
|
||||
if i: # i is an int
|
||||
...
|
||||
...
|
||||
|
||||
|
||||
关于省略判断条件的常见用法,我大概总结了一下:
|
||||
|
||||
|
||||
|
||||
不过,切记,在实际写代码时,我们鼓励,除了boolean类型的数据,条件判断最好是显性的。比如,在判断一个整型数是否为0时,我们最好写出判断的条件:
|
||||
|
||||
if i != 0:
|
||||
...
|
||||
|
||||
|
||||
而不是只写出变量名:
|
||||
|
||||
if i:
|
||||
...
|
||||
|
||||
|
||||
循环语句
|
||||
|
||||
讲完了条件语句,我们接着来看循环语句。所谓循环,顾名思义,本质上就是遍历集合中的元素。和其他语言一样,Python中的循环一般通过for循环和while循环实现。
|
||||
|
||||
比如,我们有一个列表,需要遍历列表中的所有元素并打印输出,代码如下:
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
for item in l:
|
||||
print(item)
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
|
||||
|
||||
你看,是不是很简单呢?
|
||||
|
||||
其实,Python中的数据结构只要是可迭代的(iterable),比如列表、集合等等,那么都可以通过下面这种方式遍历:
|
||||
|
||||
for item in <iterable>:
|
||||
...
|
||||
|
||||
|
||||
这里需要单独强调一下字典。字典本身只有键是可迭代的,如果我们要遍历它的值或者是键值对,就需要通过其内置的函数values()或者items()实现。其中,values()返回字典的值的集合,items()返回键值对的集合。
|
||||
|
||||
d = {'name': 'jason', 'dob': '2000-01-01', 'gender': 'male'}
|
||||
for k in d: # 遍历字典的键
|
||||
print(k)
|
||||
name
|
||||
dob
|
||||
gender
|
||||
|
||||
for v in d.values(): # 遍历字典的值
|
||||
print(v)
|
||||
jason
|
||||
2000-01-01
|
||||
male
|
||||
|
||||
for k, v in d.items(): # 遍历字典的键值对
|
||||
print('key: {}, value: {}'.format(k, v))
|
||||
key: name, value: jason
|
||||
key: dob, value: 2000-01-01
|
||||
key: gender, value: male
|
||||
|
||||
|
||||
看到这里你也许会问,有没有办法通过集合中的索引来遍历元素呢?当然可以,其实这种情况在实际工作中还是很常见的,甚至很多时候,我们还得根据索引来做一些条件判断。
|
||||
|
||||
我们通常通过range()这个函数,拿到索引,再去遍历访问集合中的元素。比如下面的代码,遍历一个列表中的元素,当索引小于5时,打印输出:
|
||||
|
||||
l = [1, 2, 3, 4, 5, 6, 7]
|
||||
for index in range(0, len(l)):
|
||||
if index < 5:
|
||||
print(l[index])
|
||||
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
|
||||
|
||||
当我们同时需要索引和元素时,还有一种更简洁的方式,那就是通过Python内置的函数enumerate()。用它来遍历集合,不仅返回每个元素,并且还返回其对应的索引,这样一来,上面的例子就可以写成:
|
||||
|
||||
l = [1, 2, 3, 4, 5, 6, 7]
|
||||
for index, item in enumerate(l):
|
||||
if index < 5:
|
||||
print(item)
|
||||
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
|
||||
|
||||
在循环语句中,我们还常常搭配continue和break一起使用。所谓continue,就是让程序跳过当前这层循环,继续执行下面的循环;而break则是指完全跳出所在的整个循环体。在循环中适当加入continue和break,往往能使程序更加简洁、易读。
|
||||
|
||||
比如,给定两个字典,分别是产品名称到价格的映射,和产品名称到颜色列表的映射。我们要找出价格小于1000,并且颜色不是红色的所有产品名称和颜色的组合。如果不用continue,代码应该是下面这样的:
|
||||
|
||||
# name_price: 产品名称(str)到价格(int)的映射字典
|
||||
# name_color: 产品名字(str)到颜色(list of str)的映射字典
|
||||
for name, price in name_price.items():
|
||||
if price < 1000:
|
||||
if name in name_color:
|
||||
for color in name_color[name]:
|
||||
if color != 'red':
|
||||
print('name: {}, color: {}'.format(name, color))
|
||||
else:
|
||||
print('name: {}, color: {}'.format(name, 'None'))
|
||||
|
||||
|
||||
而加入continue后,代码显然清晰了很多:
|
||||
|
||||
# name_price: 产品名称(str)到价格(int)的映射字典
|
||||
# name_color: 产品名字(str)到颜色(list of str)的映射字典
|
||||
for name, price in name_price.items():
|
||||
if price >= 1000:
|
||||
continue
|
||||
if name not in name_color:
|
||||
print('name: {}, color: {}'.format(name, 'None'))
|
||||
continue
|
||||
for color in name_color[name]:
|
||||
if color == 'red':
|
||||
continue
|
||||
print('name: {}, color: {}'.format(name, color))
|
||||
|
||||
|
||||
我们可以看到,按照第一个版本的写法,从开始一直到打印输出符合条件的产品名称和颜色,共有5层for或者if的嵌套;但第二个版本加入了continue后,只有3层嵌套。
|
||||
|
||||
显然,如果代码中出现嵌套里还有嵌套的情况,代码便会变得非常冗余、难读,也不利于后续的调试、修改。因此,我们要尽量避免这种多层嵌套的情况。
|
||||
|
||||
前面讲了for循环,对于while循环,原理也是一样的。它表示当condition满足时,一直重复循环内部的操作,直到condition不再满足,就跳出循环体。
|
||||
|
||||
while condition:
|
||||
....
|
||||
|
||||
|
||||
很多时候,for循环和while循环可以互相转换,比如要遍历一个列表,我们用while循环同样可以完成:
|
||||
|
||||
l = [1, 2, 3, 4]
|
||||
index = 0
|
||||
while index < len(l):
|
||||
print(l[index])
|
||||
index += 1
|
||||
|
||||
|
||||
那么,两者的使用场景又有什么区别呢?
|
||||
|
||||
通常来说,如果你只是遍历一个已知的集合,找出满足条件的元素,并进行相应的操作,那么使用for循环更加简洁。但如果你需要在满足某个条件前,不停地重复某些操作,并且没有特定的集合需要去遍历,那么一般则会使用while循环。
|
||||
|
||||
比如,某个交互式问答系统,用户输入文字,系统会根据内容做出相应的回答。为了实现这个功能,我们一般会使用while循环,大致代码如下:
|
||||
|
||||
while True:
|
||||
try:
|
||||
text = input('Please enter your questions, enter "q" to exit')
|
||||
if text == 'q':
|
||||
print('Exit system')
|
||||
break
|
||||
...
|
||||
...
|
||||
print(response)
|
||||
except Exception as err:
|
||||
print('Encountered error: {}'.format(err))
|
||||
break
|
||||
|
||||
|
||||
同时需要注意的是,for循环和while循环的效率问题。比如下面的while循环:
|
||||
|
||||
i = 0
|
||||
while i < 1000000:
|
||||
i += 1
|
||||
|
||||
|
||||
和等价的for循环:
|
||||
|
||||
for i in range(0, 1000000):
|
||||
pass
|
||||
|
||||
|
||||
究竟哪个效率高呢?
|
||||
|
||||
要知道,range()函数是直接由C语言写的,调用它速度非常快。而while循环中的“i += 1”这个操作,得通过Python的解释器间接调用底层的C语言;并且这个简单的操作,又涉及到了对象的创建和删除(因为i是整型,是immutable,i += 1相当于i = new int(i + 1))。所以,显然,for循环的效率更胜一筹。
|
||||
|
||||
条件与循环的复用
|
||||
|
||||
前面两部分讲了条件与循环的一些基本操作,接下来,我们重点来看它们的进阶操作,让程序变得更简洁高效。
|
||||
|
||||
在阅读代码的时候,你应该常常会发现,有很多将条件与循环并做一行的操作,例如:
|
||||
|
||||
expression1 if condition else expression2 for item in iterable
|
||||
|
||||
|
||||
将这个表达式分解开来,其实就等同于下面这样的嵌套结构:
|
||||
|
||||
for item in iterable:
|
||||
if condition:
|
||||
expression1
|
||||
else:
|
||||
expression2
|
||||
|
||||
|
||||
而如果没有else语句,则需要写成:
|
||||
|
||||
expression for item in iterable if condition
|
||||
|
||||
|
||||
举个例子,比如我们要绘制y = 2*|x| + 5 的函数图像,给定集合x的数据点,需要计算出y的数据集合,那么只用一行代码,就可以很轻松地解决问题了:
|
||||
|
||||
y = [value * 2 + 5 if value > 0 else -value * 2 + 5 for value in x]
|
||||
|
||||
|
||||
再比如我们在处理文件中的字符串时,常常遇到的一个场景:将文件中逐行读取的一个完整语句,按逗号分割单词,去掉首位的空字符,并过滤掉长度小于等于3的单词,最后返回由单词组成的列表。这同样可以简洁地表达成一行:
|
||||
|
||||
text = ' Today, is, Sunday'
|
||||
text_list = [s.strip() for s in text.split(',') if len(s.strip()) > 3]
|
||||
print(text_list)
|
||||
['Today', 'Sunday']
|
||||
|
||||
|
||||
当然,这样的复用并不仅仅局限于一个循环。比如,给定两个列表x、y,要求返回x、y中所有元素对组成的元组,相等情况除外。那么,你也可以很容易表示出来:
|
||||
|
||||
[(xx, yy) for xx in x for yy in y if xx != yy]
|
||||
|
||||
|
||||
这样的写法就等价于:
|
||||
|
||||
l = []
|
||||
for xx in x:
|
||||
for yy in y:
|
||||
if xx != yy:
|
||||
l.append((xx, yy))
|
||||
|
||||
|
||||
熟练之后,你会发现这种写法非常方便。当然,如果遇到逻辑很复杂的复用,你可能会觉得写成一行难以理解、容易出错。那种情况下,用正常的形式表达,也不失为一种好的规范和选择。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们一起学习了条件与循环的基本概念、进阶用法以及相应的应用。这里,我重点强调几个易错的地方。
|
||||
|
||||
|
||||
在条件语句中,if可以单独使用,但是elif和else必须和if同时搭配使用;而If条件语句的判断,除了boolean类型外,其他的最好显示出来。
|
||||
|
||||
在for循环中,如果需要同时访问索引和元素,你可以使用enumerate()函数来简化代码。
|
||||
|
||||
写条件与循环时,合理利用continue或者break来避免复杂的嵌套,是十分重要的。
|
||||
|
||||
要注意条件与循环的复用,简单功能往往可以用一行直接完成,极大地提高代码质量与效率。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一个思考题。给定下面两个列表attributes和values,要求针对values中每一组子列表value,输出其和attributes中的键对应后的字典,最后返回字典组成的列表。
|
||||
|
||||
attributes = ['name', 'dob', 'gender']
|
||||
values = [['jason', '2000-01-01', 'male'],
|
||||
['mike', '1999-01-01', 'male'],
|
||||
['nancy', '2001-02-01', 'female']
|
||||
]
|
||||
|
||||
# expected output:
|
||||
[{'name': 'jason', 'dob': '2000-01-01', 'gender': 'male'},
|
||||
{'name': 'mike', 'dob': '1999-01-01', 'gender': 'male'},
|
||||
{'name': 'nancy', 'dob': '2001-02-01', 'gender': 'female'}]
|
||||
|
||||
|
||||
你能分别用一行和多行条件循环语句,来实现这个功能吗?
|
||||
|
||||
欢迎在留言区写下你的答案,还有你今天学习的心得和疑惑,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
293
专栏/Python核心技术与实战/08异常处理:如何提高程序的稳定性?.md
Normal file
293
专栏/Python核心技术与实战/08异常处理:如何提高程序的稳定性?.md
Normal file
@ -0,0 +1,293 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 异常处理:如何提高程序的稳定性?
|
||||
你好,我是景霄。
|
||||
|
||||
今天这节课,我想和你聊聊Python的异常处理。和其他语言一样,异常处理是Python中一种很常见,并且很重要的机制与代码规范。
|
||||
|
||||
我在实际工作中,见过很多次这样的情况:一位工程师提交了代码,不过代码某处忘记了异常处理。碰巧这种异常发生的频率不低,所以在代码push到线上后没多久,就会收到紧急通知——服务器崩溃了。
|
||||
|
||||
如果事情严重,对用户的影响也很大,这位工程师还得去专门的会议上做自我检讨,可以说是很惨了。这类事件层出不穷,也告诉我们,正确理解和处理程序中的异常尤为关键。
|
||||
|
||||
错误与异常
|
||||
|
||||
首先要了解,Python中的错误和异常是什么?两者之间又有什么联系和区别呢?
|
||||
|
||||
通常来说,程序中的错误至少包括两种,一种是语法错误,另一种则是异常。
|
||||
|
||||
所谓语法错误,你应该很清楚,也就是你写的代码不符合编程规范,无法被识别与执行,比如下面这个例子:
|
||||
|
||||
if name is not None
|
||||
print(name)
|
||||
|
||||
|
||||
If语句漏掉了冒号,不符合Python的语法规范,所以程序就会报错invalid syntax。
|
||||
|
||||
而异常则是指程序的语法正确,也可以被执行,但在执行过程中遇到了错误,抛出了异常,比如下面的3个例子:
|
||||
|
||||
10 / 0
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
ZeroDivisionError: integer division or modulo by zero
|
||||
|
||||
order * 2
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
NameError: name 'order' is not defined
|
||||
|
||||
1 + [1, 2]
|
||||
Traceback (most recent call last):
|
||||
File "<stdin>", line 1, in <module>
|
||||
TypeError: unsupported operand type(s) for +: 'int' and 'list'
|
||||
|
||||
|
||||
它们语法完全正确,但显然,我们不能做除法时让分母为0;也不能使用未定义的变量做运算;而让一个整型和一个列表相加也是不可取的。
|
||||
|
||||
于是,当程序运行到这些地方时,就抛出了异常,并且终止运行。例子中的ZeroDivisionError NameError和TypeError,就是三种常见的异常类型。
|
||||
|
||||
当然,Python中还有很多其他异常类型,比如KeyError是指字典中的键找不到;FileNotFoundError是指发送了读取文件的请求,但相应的文件不存在等等,我在此不一一赘述,你可以自行参考相应文档。
|
||||
|
||||
如何处理异常
|
||||
|
||||
刚刚讲到,如果执行到程序中某处抛出了异常,程序就会被终止并退出。你可能会问,那有没有什么办法可以不终止程序,让其照样运行下去呢?答案当然是肯定的,这也就是我们所说的异常处理,通常使用try和except来解决,比如:
|
||||
|
||||
try:
|
||||
s = input('please enter two numbers separated by comma: ')
|
||||
num1 = int(s.split(',')[0].strip())
|
||||
num2 = int(s.split(',')[1].strip())
|
||||
...
|
||||
except ValueError as err:
|
||||
print('Value Error: {}'.format(err))
|
||||
|
||||
print('continue')
|
||||
...
|
||||
|
||||
|
||||
这里默认用户输入以逗号相隔的两个整形数字,将其提取后,做后续的操作(注意input函数会将输入转换为字符串类型)。如果我们输入a,b,程序便会抛出异常invalid literal for int() with base 10: 'a',然后跳出try这个block。
|
||||
|
||||
由于程序抛出的异常类型是ValueError,和except block所catch的异常类型相匹配,所以except block便会被执行,最终输出Value Error: invalid literal for int() with base 10: 'a',并打印出continue。
|
||||
|
||||
please enter two numbers separated by comma: a,b
|
||||
Value Error: invalid literal for int() with base 10: 'a'
|
||||
continue
|
||||
|
||||
|
||||
我们知道,except block只接受与它相匹配的异常类型并执行,如果程序抛出的异常并不匹配,那么程序照样会终止并退出。
|
||||
|
||||
所以,还是刚刚这个例子,如果我们只输入1,程序抛出的异常就是IndexError: list index out of range,与ValueError不匹配,那么except block就不会被执行,程序便会终止并退出(continue不会被打印)。
|
||||
|
||||
please enter two numbers separated by comma: 1
|
||||
IndexError Traceback (most recent call last)
|
||||
IndexError: list index out of range
|
||||
|
||||
|
||||
不过,很显然,这样强调一种类型的写法有很大的局限性。那么,该怎么解决这个问题呢?
|
||||
|
||||
其中一种解决方案,是在except block中加入多种异常的类型,比如下面这样的写法:
|
||||
|
||||
try:
|
||||
s = input('please enter two numbers separated by comma: ')
|
||||
num1 = int(s.split(',')[0].strip())
|
||||
num2 = int(s.split(',')[1].strip())
|
||||
...
|
||||
except (ValueError, IndexError) as err:
|
||||
print('Error: {}'.format(err))
|
||||
|
||||
print('continue')
|
||||
...
|
||||
|
||||
|
||||
或者第二种写法:
|
||||
|
||||
try:
|
||||
s = input('please enter two numbers separated by comma: ')
|
||||
num1 = int(s.split(',')[0].strip())
|
||||
num2 = int(s.split(',')[1].strip())
|
||||
...
|
||||
except ValueError as err:
|
||||
print('Value Error: {}'.format(err))
|
||||
except IndexError as err:
|
||||
print('Index Error: {}'.format(err))
|
||||
|
||||
print('continue')
|
||||
...
|
||||
|
||||
|
||||
这样,每次程序执行时,except block中只要有一个exception类型与实际匹配即可。
|
||||
|
||||
不过,很多时候,我们很难保证程序覆盖所有的异常类型,所以,更通常的做法,是在最后一个except block,声明其处理的异常类型是Exception。Exception是其他所有非系统异常的基类,能够匹配任意非系统异常。那么这段代码就可以写成下面这样:
|
||||
|
||||
try:
|
||||
s = input('please enter two numbers separated by comma: ')
|
||||
num1 = int(s.split(',')[0].strip())
|
||||
num2 = int(s.split(',')[1].strip())
|
||||
...
|
||||
except ValueError as err:
|
||||
print('Value Error: {}'.format(err))
|
||||
except IndexError as err:
|
||||
print('Index Error: {}'.format(err))
|
||||
except Exception as err:
|
||||
print('Other error: {}'.format(err))
|
||||
|
||||
print('continue')
|
||||
...
|
||||
|
||||
|
||||
或者,你也可以在except后面省略异常类型,这表示与任意异常相匹配(包括系统异常等):
|
||||
|
||||
try:
|
||||
s = input('please enter two numbers separated by comma: ')
|
||||
num1 = int(s.split(',')[0].strip())
|
||||
num2 = int(s.split(',')[1].strip())
|
||||
...
|
||||
except ValueError as err:
|
||||
print('Value Error: {}'.format(err))
|
||||
except IndexError as err:
|
||||
print('Index Error: {}'.format(err))
|
||||
except:
|
||||
print('Other error')
|
||||
|
||||
print('continue')
|
||||
...
|
||||
|
||||
|
||||
需要注意,当程序中存在多个except block时,最多只有一个except block会被执行。换句话说,如果多个except声明的异常类型都与实际相匹配,那么只有最前面的except block会被执行,其他则被忽略。
|
||||
|
||||
异常处理中,还有一个很常见的用法是finally,经常和try、except放在一起来用。无论发生什么情况,finally block中的语句都会被执行,哪怕前面的try和excep block中使用了return语句。
|
||||
|
||||
一个常见的应用场景,便是文件的读取:
|
||||
|
||||
import sys
|
||||
try:
|
||||
f = open('file.txt', 'r')
|
||||
.... # some data processing
|
||||
except OSError as err:
|
||||
print('OS error: {}'.format(err))
|
||||
except:
|
||||
print('Unexpected error:', sys.exc_info()[0])
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
这段代码中,try block尝试读取file.txt这个文件,并对其中的数据进行一系列的处理,到最后,无论是读取成功还是读取失败,程序都会执行finally中的语句——关闭这个文件流,确保文件的完整性。因此,在finally中,我们通常会放一些无论如何都要执行的语句。
|
||||
|
||||
值得一提的是,对于文件的读取,我们也常常使用with open,你也许在前面的例子中已经看到过,with open会在最后自动关闭文件,让语句更加简洁。
|
||||
|
||||
用户自定义异常
|
||||
|
||||
前面的例子里充斥了很多Python内置的异常类型,你可能会问,我可以创建自己的异常类型吗?
|
||||
|
||||
答案是肯定是,Python当然允许我们这么做。下面这个例子,我们创建了自定义的异常类型MyInputError,定义并实现了初始化函数和str函数(直接print时调用):
|
||||
|
||||
class MyInputError(Exception):
|
||||
"""Exception raised when there're errors in input"""
|
||||
def __init__(self, value): # 自定义异常类型的初始化
|
||||
self.value = value
|
||||
def __str__(self): # 自定义异常类型的string表达形式
|
||||
return ("{} is invalid input".format(repr(self.value)))
|
||||
|
||||
try:
|
||||
raise MyInputError(1) # 抛出MyInputError这个异常
|
||||
except MyInputError as err:
|
||||
print('error: {}'.format(err))
|
||||
|
||||
|
||||
如果你执行上述代码块并输出,便会得到下面的结果:
|
||||
|
||||
error: 1 is invalid input
|
||||
|
||||
|
||||
实际工作中,如果内置的异常类型无法满足我们的需求,或者为了让异常更加详细、可读,想增加一些异常类型的其他功能,我们可以自定义所需异常类型。不过,大多数情况下,Python内置的异常类型就足够好了。
|
||||
|
||||
异常的使用场景与注意点
|
||||
|
||||
学完了前面的基础知识,接下来我们着重谈一下,异常的使用场景与注意点。
|
||||
|
||||
通常来说,在程序中,如果我们不确定某段代码能否成功执行,往往这个地方就需要使用异常处理。除了上述文件读取的例子,我可以再举一个例子来说明。
|
||||
|
||||
大型社交网站的后台,需要针对用户发送的请求返回相应记录。用户记录往往储存在key-value结构的数据库中,每次有请求过来后,我们拿到用户的ID,并用ID查询数据库中此人的记录,就能返回相应的结果。
|
||||
|
||||
而数据库返回的原始数据,往往是json string的形式,这就需要我们首先对json string进行decode(解码),你可能很容易想到下面的方法:
|
||||
|
||||
import json
|
||||
raw_data = queryDB(uid) # 根据用户的id,返回相应的信息
|
||||
data = json.loads(raw_data)
|
||||
|
||||
|
||||
这样的代码是不是就足够了呢?
|
||||
|
||||
要知道,在json.loads()函数中,输入的字符串如果不符合其规范,那么便无法解码,就会抛出异常,因此加上异常处理十分必要。
|
||||
|
||||
try:
|
||||
data = json.loads(raw_data)
|
||||
....
|
||||
except JSONDecodeError as err:
|
||||
print('JSONDecodeError: {}'.format(err))
|
||||
|
||||
|
||||
不过,有一点切记,我们不能走向另一个极端——滥用异常处理。
|
||||
|
||||
比如,当你想要查找字典中某个键对应的值时,绝不能写成下面这种形式:
|
||||
|
||||
d = {'name': 'jason', 'age': 20}
|
||||
try:
|
||||
value = d['dob']
|
||||
...
|
||||
except KeyError as err:
|
||||
print('KeyError: {}'.format(err))
|
||||
|
||||
|
||||
诚然,这样的代码并没有bug,但是让人看了摸不着头脑,也显得很冗余。如果你的代码中充斥着这种写法,无疑对阅读、协作来说都是障碍。因此,对于flow-control(流程控制)的代码逻辑,我们一般不用异常处理。
|
||||
|
||||
字典这个例子,写成下面这样就很好。
|
||||
|
||||
if 'dob' in d:
|
||||
value = d['dob']
|
||||
...
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课, 我们一起学习了Python的异常处理及其使用场景,你需要重点掌握下面几点。
|
||||
|
||||
|
||||
异常,通常是指程序运行的过程中遇到了错误,终止并退出。我们通常使用try except语句去处理异常,这样程序就不会被终止,仍能继续执行。
|
||||
|
||||
处理异常时,如果有必须执行的语句,比如文件打开后必须关闭等等,则可以放在finally block中。
|
||||
|
||||
异常处理,通常用在你不确定某段代码能否成功执行,也无法轻易判断的情况下,比如数据库的连接、读取等等。正常的flow-control逻辑,不要使用异常处理,直接用条件语句解决就可以了。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,给你留一个思考题。在异常处理时,如果try block中有多处抛出异常,需要我们使用多个try except block吗?以数据库的连接、读取为例,下面两种写法,你觉得哪种更好呢?
|
||||
|
||||
第一种:
|
||||
|
||||
try:
|
||||
db = DB.connect('<db path>') # 可能会抛出异常
|
||||
raw_data = DB.queryData('<viewer_id>') # 可能会抛出异常
|
||||
except (DBConnectionError, DBQueryDataError) err:
|
||||
print('Error: {}'.format(err))
|
||||
|
||||
|
||||
第二种:
|
||||
|
||||
try:
|
||||
db = DB.connect('<db path>') # 可能会抛出异常
|
||||
try:
|
||||
raw_data = DB.queryData('<viewer_id>')
|
||||
except DBQueryDataError as err:
|
||||
print('DB query data error: {}'.format(err))
|
||||
except DBConnectionError as err:
|
||||
print('DB connection error: {}'.format(err))
|
||||
|
||||
|
||||
欢迎在留言区写下你的答案,还有你今天学习的心得和疑惑,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
381
专栏/Python核心技术与实战/09不可或缺的自定义函数.md
Normal file
381
专栏/Python核心技术与实战/09不可或缺的自定义函数.md
Normal file
@ -0,0 +1,381 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 不可或缺的自定义函数
|
||||
你好,我是景霄。
|
||||
|
||||
实际工作生活中,我曾见到不少初学者编写的Python程序,他们长达几百行的代码中,却没有一个函数,通通按顺序堆到一块儿,不仅让人读起来费时费力,往往也是错误连连。
|
||||
|
||||
一个规范的值得借鉴的Python程序,除非代码量很少(比如10行、20行以下),基本都应该由多个函数组成,这样的代码才更加模块化、规范化。
|
||||
|
||||
函数是Python程序中不可或缺的一部分。事实上,在前面的学习中,我们已经用到了很多Python的内置函数,比如sorted()表示对一个集合序列排序,len()表示返回一个集合序列的长度大小等等。这节课,我们主要来学习Python的自定义函数。
|
||||
|
||||
函数基础
|
||||
|
||||
那么,到底什么是函数,如何在Python程序中定义函数呢?
|
||||
|
||||
说白了,函数就是为了实现某一功能的代码段,只要写好以后,就可以重复利用。我们先来看下面一个简单的例子:
|
||||
|
||||
def my_func(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
|
||||
# 调用函数 my_func()
|
||||
my_func('Hello World')
|
||||
# 输出
|
||||
Got a message: Hello World
|
||||
|
||||
|
||||
其中:
|
||||
|
||||
|
||||
def是函数的声明;
|
||||
|
||||
my_func是函数的名称;
|
||||
|
||||
括号里面的message则是函数的参数;
|
||||
|
||||
而print那行则是函数的主体部分,可以执行相应的语句;
|
||||
|
||||
在函数最后,你可以返回调用结果(return或yield),也可以不返回。
|
||||
|
||||
|
||||
总结一下,大概是下面的这种形式:
|
||||
|
||||
def name(param1, param2, ..., paramN):
|
||||
statements
|
||||
return/yield value # optional
|
||||
|
||||
|
||||
和其他需要编译的语言(比如C语言)不一样的是,def是可执行语句,这意味着函数直到被调用前,都是不存在的。当程序调用函数时,def语句才会创建一个新的函数对象,并赋予其名字。
|
||||
|
||||
我们一起来看几个例子,加深你对函数的印象:
|
||||
|
||||
def my_sum(a, b):
|
||||
return a + b
|
||||
|
||||
result = my_sum(3, 5)
|
||||
print(result)
|
||||
|
||||
# 输出
|
||||
8
|
||||
|
||||
|
||||
这里,我们定义了my_sum()这个函数,它有两个参数a和b,作用是相加;随后,调用my_sum()函数,分别把3和5赋于a和b;最后,返回其相加的值,赋于变量result,并输出得到8。
|
||||
|
||||
再来看一个例子:
|
||||
|
||||
def find_largest_element(l):
|
||||
if not isinstance(l, list):
|
||||
print('input is not type of list')
|
||||
return
|
||||
if len(l) == 0:
|
||||
print('empty input')
|
||||
return
|
||||
largest_element = l[0]
|
||||
for item in l:
|
||||
if item > largest_element:
|
||||
largest_element = item
|
||||
print('largest element is: {}'.format(largest_element))
|
||||
|
||||
find_largest_element([8, 1,-3, 2, 0])
|
||||
|
||||
# 输出
|
||||
largest element is: 8
|
||||
|
||||
|
||||
这个例子中,我们定义了函数find_largest_element,作用是遍历输入的列表,找出最大的值并打印。因此,当我们调用它,并传递列表 [8, 1, -3, 2, 0] 作为参数时,程序就会输出 largest element is: 8。
|
||||
|
||||
需要注意,主程序调用函数时,必须保证这个函数此前已经定义过,不然就会报错,比如:
|
||||
|
||||
my_func('hello world')
|
||||
def my_func(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
|
||||
# 输出
|
||||
NameError: name 'my_func' is not defined
|
||||
|
||||
|
||||
但是,如果我们在函数内部调用其他函数,函数间哪个声明在前、哪个在后就无所谓,因为def是可执行语句,函数在调用之前都不存在,我们只需保证调用时,所需的函数都已经声明定义:
|
||||
|
||||
def my_func(message):
|
||||
my_sub_func(message) # 调用my_sub_func()在其声明之前不影响程序执行
|
||||
|
||||
def my_sub_func(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
|
||||
my_func('hello world')
|
||||
|
||||
# 输出
|
||||
Got a message: hello world
|
||||
|
||||
|
||||
另外,Python函数的参数可以设定默认值,比如下面这样的写法:
|
||||
|
||||
def func(param = 0):
|
||||
...
|
||||
|
||||
|
||||
这样,在调用函数func()时,如果参数param没有传入,则参数默认为0;而如果传入了参数param,其就会覆盖默认值。
|
||||
|
||||
前面说过,Python和其他语言相比的一大特点是,Python是dynamically typed的,可以接受任何数据类型(整型,浮点,字符串等等)。对函数参数来说,这一点同样适用。比如还是刚刚的my_sum函数,我们也可以把列表作为参数来传递,表示将两个列表相连接:
|
||||
|
||||
print(my_sum([1, 2], [3, 4]))
|
||||
|
||||
# 输出
|
||||
[1, 2, 3, 4]
|
||||
|
||||
|
||||
同样,也可以把字符串作为参数传递,表示字符串的合并拼接:
|
||||
|
||||
print(my_sum('hello ', 'world'))
|
||||
|
||||
# 输出
|
||||
hello world
|
||||
|
||||
|
||||
当然,如果两个参数的数据类型不同,比如一个是列表、一个是字符串,两者无法相加,那就会报错:
|
||||
|
||||
print(my_sum([1, 2], 'hello'))
|
||||
TypeError: can only concatenate list (not "str") to list
|
||||
|
||||
|
||||
我们可以看到,Python不用考虑输入的数据类型,而是将其交给具体的代码去判断执行,同样的一个函数(比如这边的相加函数my_sum()),可以同时应用在整型、列表、字符串等等的操作中。
|
||||
|
||||
在编程语言中,我们把这种行为称为多态。这也是Python和其他语言,比如Java、C等很大的一个不同点。当然,Python这种方便的特性,在实际使用中也会带来诸多问题。因此,必要时请你在开头加上数据的类型检查。
|
||||
|
||||
Python函数的另一大特性,是Python支持函数的嵌套。所谓的函数嵌套,就是指函数里面又有函数,比如:
|
||||
|
||||
def f1():
|
||||
print('hello')
|
||||
def f2():
|
||||
print('world')
|
||||
f2()
|
||||
f1()
|
||||
|
||||
# 输出
|
||||
hello
|
||||
world
|
||||
|
||||
|
||||
这里函数f1()的内部,又定义了函数f2()。在调用函数f1()时,会先打印字符串'hello',然后f1()内部再调用f2(),打印字符串'world'。你也许会问,为什么需要函数嵌套?这样做有什么好处呢?
|
||||
|
||||
其实,函数的嵌套,主要有下面两个方面的作用。
|
||||
|
||||
第一,函数的嵌套能够保证内部函数的隐私。内部函数只能被外部函数所调用和访问,不会暴露在全局作用域,因此,如果你的函数内部有一些隐私数据(比如数据库的用户、密码等),不想暴露在外,那你就可以使用函数的的嵌套,将其封装在内部函数中,只通过外部函数来访问。比如:
|
||||
|
||||
def connect_DB():
|
||||
def get_DB_configuration():
|
||||
...
|
||||
return host, username, password
|
||||
conn = connector.connect(get_DB_configuration())
|
||||
return conn
|
||||
|
||||
|
||||
这里的函数get_DB_configuration,便是内部函数,它无法在connect_DB()函数以外被单独调用。也就是说,下面这样的外部直接调用是错误的:
|
||||
|
||||
get_DB_configuration()
|
||||
|
||||
# 输出
|
||||
NameError: name 'get_DB_configuration' is not defined
|
||||
|
||||
|
||||
我们只能通过调用外部函数connect_DB()来访问它,这样一来,程序的安全性便有了很大的提高。
|
||||
|
||||
第二,合理的使用函数嵌套,能够提高程序的运行效率。我们来看下面这个例子:
|
||||
|
||||
def factorial(input):
|
||||
# validation check
|
||||
if not isinstance(input, int):
|
||||
raise Exception('input must be an integer.')
|
||||
if input < 0:
|
||||
raise Exception('input must be greater or equal to 0' )
|
||||
...
|
||||
|
||||
def inner_factorial(input):
|
||||
if input <= 1:
|
||||
return 1
|
||||
return input * inner_factorial(input-1)
|
||||
return inner_factorial(input)
|
||||
|
||||
|
||||
print(factorial(5))
|
||||
|
||||
|
||||
这里,我们使用递归的方式计算一个数的阶乘。因为在计算之前,需要检查输入是否合法,所以我写成了函数嵌套的形式,这样一来,输入是否合法就只用检查一次。而如果我们不使用函数嵌套,那么每调用一次递归便会检查一次,这是没有必要的,也会降低程序的运行效率。
|
||||
|
||||
实际工作中,如果你遇到相似的情况,输入检查不是很快,还会耗费一定的资源,那么运用函数的嵌套就十分必要了。
|
||||
|
||||
函数变量作用域
|
||||
|
||||
Python函数中变量的作用域和其他语言类似。如果变量是在函数内部定义的,就称为局部变量,只在函数内部有效。一旦函数执行完毕,局部变量就会被回收,无法访问,比如下面的例子:
|
||||
|
||||
def read_text_from_file(file_path):
|
||||
with open(file_path) as file:
|
||||
...
|
||||
|
||||
|
||||
我们在函数内部定义了file这个变量,这个变量只在read_text_from_file这个函数里有效,在函数外部则无法访问。
|
||||
|
||||
相对应的,全局变量则是定义在整个文件层次上的,比如下面这段代码:
|
||||
|
||||
MIN_VALUE = 1
|
||||
MAX_VALUE = 10
|
||||
def validation_check(value):
|
||||
if value < MIN_VALUE or value > MAX_VALUE:
|
||||
raise Exception('validation check fails')
|
||||
|
||||
|
||||
这里的MIN_VALUE和MAX_VALUE就是全局变量,可以在文件内的任何地方被访问,当然在函数内部也是可以的。不过,我们不能在函数内部随意改变全局变量的值。比如,下面的写法就是错误的:
|
||||
|
||||
MIN_VALUE = 1
|
||||
MAX_VALUE = 10
|
||||
def validation_check(value):
|
||||
...
|
||||
MIN_VALUE += 1
|
||||
...
|
||||
validation_check(5)
|
||||
|
||||
|
||||
如果运行这段代码,程序便会报错:
|
||||
|
||||
UnboundLocalError: local variable 'MIN_VALUE' referenced before assignment
|
||||
|
||||
|
||||
这是因为,Python的解释器会默认函数内部的变量为局部变量,但是又发现局部变量MIN_VALUE并没有声明,因此就无法执行相关操作。所以,如果我们一定要在函数内部改变全局变量的值,就必须加上global这个声明:
|
||||
|
||||
MIN_VALUE = 1
|
||||
MAX_VALUE = 10
|
||||
def validation_check(value):
|
||||
global MIN_VALUE
|
||||
...
|
||||
MIN_VALUE += 1
|
||||
...
|
||||
validation_check(5)
|
||||
|
||||
|
||||
这里的global关键字,并不表示重新创建了一个全局变量MIN_VALUE,而是告诉Python解释器,函数内部的变量MIN_VALUE,就是之前定义的全局变量,并不是新的全局变量,也不是局部变量。这样,程序就可以在函数内部访问全局变量,并修改它的值了。
|
||||
|
||||
另外,如果遇到函数内部局部变量和全局变量同名的情况,那么在函数内部,局部变量会覆盖全局变量,比如下面这种:
|
||||
|
||||
MIN_VALUE = 1
|
||||
MAX_VALUE = 10
|
||||
def validation_check(value):
|
||||
MIN_VALUE = 3
|
||||
...
|
||||
|
||||
|
||||
在函数validation_check()内部,我们定义了和全局变量同名的局部变量MIN_VALUE,那么,MIN_VALUE在函数内部的值,就应该是3而不是1了。
|
||||
|
||||
类似的,对于嵌套函数来说,内部函数可以访问外部函数定义的变量,但是无法修改,若要修改,必须加上nonlocal这个关键字:
|
||||
|
||||
def outer():
|
||||
x = "local"
|
||||
def inner():
|
||||
nonlocal x # nonlocal关键字表示这里的x就是外部函数outer定义的变量x
|
||||
x = 'nonlocal'
|
||||
print("inner:", x)
|
||||
inner()
|
||||
print("outer:", x)
|
||||
outer()
|
||||
# 输出
|
||||
inner: nonlocal
|
||||
outer: nonlocal
|
||||
|
||||
|
||||
如果不加上nonlocal这个关键字,而内部函数的变量又和外部函数变量同名,那么同样的,内部函数变量会覆盖外部函数的变量。
|
||||
|
||||
def outer():
|
||||
x = "local"
|
||||
def inner():
|
||||
x = 'nonlocal' # 这里的x是inner这个函数的局部变量
|
||||
print("inner:", x)
|
||||
inner()
|
||||
print("outer:", x)
|
||||
outer()
|
||||
# 输出
|
||||
inner: nonlocal
|
||||
outer: local
|
||||
|
||||
|
||||
闭包
|
||||
|
||||
这节课的第三个重点,我想再来介绍一下闭包(closure)。闭包其实和刚刚讲的嵌套函数类似,不同的是,这里外部函数返回的是一个函数,而不是一个具体的值。返回的函数通常赋于一个变量,这个变量可以在后面被继续执行调用。
|
||||
|
||||
举个例子你就更容易理解了。比如,我们想计算一个数的n次幂,用闭包可以写成下面的代码:
|
||||
|
||||
def nth_power(exponent):
|
||||
def exponent_of(base):
|
||||
return base ** exponent
|
||||
return exponent_of # 返回值是exponent_of函数
|
||||
|
||||
square = nth_power(2) # 计算一个数的平方
|
||||
cube = nth_power(3) # 计算一个数的立方
|
||||
square
|
||||
# 输出
|
||||
<function __main__.nth_power.<locals>.exponent(base)>
|
||||
|
||||
cube
|
||||
# 输出
|
||||
<function __main__.nth_power.<locals>.exponent(base)>
|
||||
|
||||
print(square(2)) # 计算2的平方
|
||||
print(cube(2)) # 计算2的立方
|
||||
# 输出
|
||||
4 # 2^2
|
||||
8 # 2^3
|
||||
|
||||
|
||||
这里外部函数nth_power()返回值,是函数exponent_of(),而不是一个具体的数值。需要注意的是,在执行完square = nth_power(2)和cube = nth_power(3)后,外部函数nth_power()的参数exponent,仍然会被内部函数exponent_of()记住。这样,之后我们调用square(2)或者cube(2)时,程序就能顺利地输出结果,而不会报错说参数exponent没有定义了。
|
||||
|
||||
看到这里,你也许会思考,为什么要闭包呢?上面的程序,我也可以写成下面的形式啊!
|
||||
|
||||
def nth_power_rewrite(base, exponent):
|
||||
return base ** exponent
|
||||
|
||||
|
||||
|
||||
确实可以,不过,要知道,使用闭包的一个原因,是让程序变得更简洁易读。设想一下,比如你需要计算很多个数的平方,那么你觉得写成下面哪一种形式更好呢?
|
||||
|
||||
# 不适用闭包
|
||||
res1 = nth_power_rewrite(base1, 2)
|
||||
res2 = nth_power_rewrite(base2, 2)
|
||||
res3 = nth_power_rewrite(base3, 2)
|
||||
...
|
||||
|
||||
# 使用闭包
|
||||
square = nth_power(2)
|
||||
res1 = square(base1)
|
||||
res2 = square(base2)
|
||||
res3 = square(base3)
|
||||
...
|
||||
|
||||
|
||||
显然是第二种,是不是?首先直观来看,第二种形式,让你每次调用函数都可以少输入一个参数,表达更为简洁。
|
||||
|
||||
其次,和上面讲到的嵌套函数优点类似,函数开头需要做一些额外工作,而你又需要多次调用这个函数时,将那些额外工作的代码放在外部函数,就可以减少多次调用导致的不必要的开销,提高程序的运行效率。
|
||||
|
||||
另外还有一点,我们后面会讲到,闭包常常和装饰器(decorator)一起使用。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起学习了Python函数的概念及其应用,有这么几点你需要注意:
|
||||
|
||||
|
||||
Python中函数的参数可以接受任意的数据类型,使用起来需要注意,必要时请在函数开头加入数据类型的检查;
|
||||
|
||||
和其他语言不同,Python中函数的参数可以设定默认值;
|
||||
|
||||
嵌套函数的使用,能保证数据的隐私性,提高程序运行效率;
|
||||
|
||||
合理地使用闭包,则可以简化程序的复杂度,提高可读性。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。在实际的学习工作中,你遇到过哪些使用嵌套函数或者是闭包的例子呢?欢迎在下方留言,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
228
专栏/Python核心技术与实战/10简约不简单的匿名函数.md
Normal file
228
专栏/Python核心技术与实战/10简约不简单的匿名函数.md
Normal file
@ -0,0 +1,228 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 简约不简单的匿名函数
|
||||
你好,我是景霄。
|
||||
|
||||
上一节,我们一起学习了Python中的“常规”函数,用途十分广泛。不过,除了常规函数,你应该也会在代码中见到一些“非常规”函数,它们往往很简短,就一行,并且有个很酷炫的名字——lambda,没错,这就是匿名函数。
|
||||
|
||||
匿名函数在实际工作中同样举足轻重,正确地运用匿名函数,能让我们的代码更简洁、易读。这节课,我们继续Python的函数之旅,一起来学习这个简约而不简单的匿名函数。
|
||||
|
||||
匿名函数基础
|
||||
|
||||
首先,什么是匿名函数呢?以下是匿名函数的格式:
|
||||
|
||||
lambda argument1, argument2,... argumentN : expression
|
||||
|
||||
|
||||
我们可以看到,匿名函数的关键字是lambda,之后是一系列的参数,然后用冒号隔开,最后则是由这些参数组成的表达式。我们通过几个例子看一下它的用法:
|
||||
|
||||
square = lambda x: x**2
|
||||
square(3)
|
||||
|
||||
9
|
||||
|
||||
|
||||
这里的匿名函数只输入一个参数x,输出则是输入x的平方。因此当输入是3时,输出便是9。如果把这个匿名函数写成常规函数的形式,则是下面这样:
|
||||
|
||||
def square(x):
|
||||
return x**2
|
||||
square(3)
|
||||
|
||||
9
|
||||
|
||||
|
||||
可以看到,匿名函数lambda和常规函数一样,返回的都是一个函数对象(function object),它们的用法也极其相似,不过还是有下面几点区别。
|
||||
|
||||
第一,lambda是一个表达式(expression),并不是一个语句(statement)。
|
||||
|
||||
|
||||
所谓的表达式,就是用一系列“公式”去表达一个东西,比如x + 2、 x**2等等;
|
||||
|
||||
而所谓的语句,则一定是完成了某些功能,比如赋值语句x = 1完成了赋值,print语句print(x)完成了打印,条件语句 if x < 0:完成了选择功能等等。
|
||||
|
||||
|
||||
因此,lambda可以用在一些常规函数def不能用的地方,比如,lambda可以用在列表内部,而常规函数却不能:
|
||||
|
||||
[(lambda x: x*x)(x) for x in range(10)]
|
||||
# 输出
|
||||
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
|
||||
|
||||
|
||||
再比如,lambda可以被用作某些函数的参数,而常规函数def也不能:
|
||||
|
||||
l = [(1, 20), (3, 0), (9, 10), (2, -1)]
|
||||
l.sort(key=lambda x: x[1]) # 按列表中元组的第二个元素排序
|
||||
print(l)
|
||||
# 输出
|
||||
[(2, -1), (3, 0), (9, 10), (1, 20)]
|
||||
|
||||
|
||||
常规函数def必须通过其函数名被调用,因此必须首先被定义。但是作为一个表达式的lambda,返回的函数对象就不需要名字了。
|
||||
|
||||
第二,lambda的主体是只有一行的简单表达式,并不能扩展成一个多行的代码块。
|
||||
|
||||
这其实是出于设计的考虑。Python之所以发明lambda,就是为了让它和常规函数各司其职:lambda专注于简单的任务,而常规函数则负责更复杂的多行逻辑。关于这点,Python之父Guido van Rossum曾发了一篇文章解释,你有兴趣的话可以自己阅读。
|
||||
|
||||
为什么要使用匿名函数?
|
||||
|
||||
理论上来说,Python中有匿名函数的地方,都可以被替换成等价的其他表达形式。一个Python程序是可以不用任何匿名函数的。不过,在一些情况下,使用匿名函数lambda,可以帮助我们大大简化代码的复杂度,提高代码的可读性。
|
||||
|
||||
通常,我们用函数的目的无非是这么几点:
|
||||
|
||||
|
||||
减少代码的重复性;
|
||||
|
||||
模块化代码。
|
||||
|
||||
|
||||
对于第一点,如果你的程序在不同地方包含了相同的代码,那么我们就会把这部分相同的代码写成一个函数,并为它取一个名字,方便在相对应的不同地方调用。
|
||||
|
||||
对于第二点,如果你的一块儿代码是为了实现一个功能,但内容非常多,写在一起降低了代码的可读性,那么通常我们也会把这部分代码单独写成一个函数,然后加以调用。
|
||||
|
||||
不过,再试想一下这样的情况。你需要一个函数,但它非常简短,只需要一行就能完成;同时它在程序中只被调用一次而已。那么请问,你还需要像常规函数一样,给它一个定义和名字吗?
|
||||
|
||||
答案当然是否定的。这种情况下,函数就可以是匿名的,你只需要在适当的地方定义并使用,就能让匿名函数发挥作用了。
|
||||
|
||||
举个例子,如果你想对一个列表中的所有元素做平方操作,而这个操作在你的程序中只需要进行一次,用lambda函数可以表示成下面这样:
|
||||
|
||||
squared = map(lambda x: x**2, [1, 2, 3, 4, 5])
|
||||
|
||||
|
||||
如果用常规函数,则表示为这几行代码:
|
||||
|
||||
def square(x):
|
||||
return x**2
|
||||
|
||||
squared = map(square, [1, 2, 3, 4, 5])
|
||||
|
||||
|
||||
这里我简单解释一下。函数map(function, iterable)的第一个参数是函数对象,第二个参数是一个可以遍历的集合,它表示对iterable的每一个元素,都运用function这个函数。两者一对比,我们很明显地发现,lambda函数让代码更加简洁明了。
|
||||
|
||||
再举一个例子,在Python的Tkinter GUI应用中,我们想实现这样一个简单的功能:创建显示一个按钮,每当用户点击时,就打印出一段文字。如果使用lambda函数可以表示成下面这样:
|
||||
|
||||
from tkinter import Button, mainloop
|
||||
button = Button(
|
||||
text='This is a button',
|
||||
command=lambda: print('being pressed')) # 点击时调用lambda函数
|
||||
button.pack()
|
||||
mainloop()
|
||||
|
||||
|
||||
而如果我们用常规函数def,那么需要写更多的代码:
|
||||
|
||||
from tkinter import Button, mainloop
|
||||
|
||||
def print_message():
|
||||
print('being pressed')
|
||||
|
||||
button = Button(
|
||||
text='This is a button',
|
||||
command=print_message) # 点击时调用lambda函数
|
||||
button.pack()
|
||||
mainloop()
|
||||
|
||||
|
||||
显然,运用匿名函数的代码简洁很多,也更加符合Python的编程习惯。
|
||||
|
||||
Python函数式编程
|
||||
|
||||
最后,我们一起来看一下,Python的函数式编程特性,这与我们今天所讲的匿名函数lambda,有着密切的联系。
|
||||
|
||||
所谓函数式编程,是指代码中每一块都是不可变的(immutable),都由纯函数(pure function)的形式组成。这里的纯函数,是指函数本身相互独立、互不影响,对于相同的输入,总会有相同的输出,没有任何副作用。
|
||||
|
||||
举个很简单的例子,比如对于一个列表,我想让列表中的元素值都变为原来的两倍,我们可以写成下面的形式:
|
||||
|
||||
def multiply_2(l):
|
||||
for index in range(0, len(l)):
|
||||
l[index] *= 2
|
||||
return l
|
||||
|
||||
|
||||
这段代码就不是一个纯函数的形式,因为列表中元素的值被改变了,如果我多次调用multiply_2()这个函数,那么每次得到的结果都不一样。要想让它成为一个纯函数的形式,就得写成下面这种形式,重新创建一个新的列表并返回。
|
||||
|
||||
def multiply_2_pure(l):
|
||||
new_list = []
|
||||
for item in l:
|
||||
new_list.append(item * 2)
|
||||
return new_list
|
||||
|
||||
|
||||
函数式编程的优点,主要在于其纯函数和不可变的特性使程序更加健壮,易于调试(debug)和测试;缺点主要在于限制多,难写。当然,Python不同于一些语言(比如Scala),它并不是一门函数式编程语言,不过,Python也提供了一些函数式编程的特性,值得我们了解和学习。
|
||||
|
||||
Python主要提供了这么几个函数:map()、filter()和reduce(),通常结合匿名函数lambda一起使用。这些都是你需要掌握的东西,接下来我逐一介绍。
|
||||
|
||||
首先是map(function, iterable)函数,前面的例子提到过,它表示,对iterable中的每个元素,都运用function这个函数,最后返回一个新的可遍历的集合。比如刚才列表的例子,要对列表中的每个元素乘以2,那么用map就可以表示为下面这样:
|
||||
|
||||
l = [1, 2, 3, 4, 5]
|
||||
new_list = map(lambda x: x * 2, l) # [2, 4, 6, 8, 10]
|
||||
|
||||
|
||||
我们可以以map()函数为例,看一下Python提供的函数式编程接口的性能。还是同样的列表例子,它还可以用for循环和list comprehension(目前没有统一中文叫法,你也可以直译为列表理解等)实现,我们来比较一下它们的速度:
|
||||
|
||||
python3 -mtimeit -s'xs=range(1000000)' 'map(lambda x: x*2, xs)'
|
||||
2000000 loops, best of 5: 171 nsec per loop
|
||||
|
||||
python3 -mtimeit -s'xs=range(1000000)' '[x * 2 for x in xs]'
|
||||
5 loops, best of 5: 62.9 msec per loop
|
||||
|
||||
python3 -mtimeit -s'xs=range(1000000)' 'l = []' 'for i in xs: l.append(i * 2)'
|
||||
5 loops, best of 5: 92.7 msec per loop
|
||||
|
||||
|
||||
你可以看到,map()是最快的。因为map()函数直接由C语言写的,运行时不需要通过Python解释器间接调用,并且内部做了诸多优化,所以运行速度最快。
|
||||
|
||||
接下来来看filter(function, iterable)函数,它和map函数类似,function同样表示一个函数对象。filter()函数表示对iterable中的每个元素,都使用function判断,并返回True或者False,最后将返回True的元素组成一个新的可遍历的集合。
|
||||
|
||||
举个例子,比如我要返回一个列表中的所有偶数,可以写成下面这样:
|
||||
|
||||
l = [1, 2, 3, 4, 5]
|
||||
new_list = filter(lambda x: x % 2 == 0, l) # [2, 4]
|
||||
|
||||
|
||||
最后我们来看reduce(function, iterable)函数,它通常用来对一个集合做一些累积操作。
|
||||
|
||||
function同样是一个函数对象,规定它有两个参数,表示对iterable中的每个元素以及上一次调用后的结果,运用function进行计算,所以最后返回的是一个单独的数值。
|
||||
|
||||
举个例子,我想要计算某个列表元素的乘积,就可以用reduce()函数来表示:
|
||||
|
||||
l = [1, 2, 3, 4, 5]
|
||||
product = reduce(lambda x, y: x * y, l) # 1*2*3*4*5 = 120
|
||||
|
||||
|
||||
当然,类似的,filter()和reduce()的功能,也可以用for循环或者list comprehension来实现。
|
||||
|
||||
通常来说,在我们想对集合中的元素进行一些操作时,如果操作非常简单,比如相加、累积这种,那么我们优先考虑map()、filter()、reduce()这类或者list comprehension的形式。至于这两种方式的选择:
|
||||
|
||||
|
||||
在数据量非常多的情况下,比如机器学习的应用,那我们一般更倾向于函数式编程的表示,因为效率更高;
|
||||
|
||||
在数据量不多的情况下,并且你想要程序更加Pythonic的话,那么list comprehension也不失为一个好选择。
|
||||
|
||||
|
||||
不过,如果你要对集合中的元素,做一些比较复杂的操作,那么,考虑到代码的可读性,我们通常会使用for循环,这样更加清晰明了。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起学习了Python中的匿名函数lambda,它的主要用途是减少代码的复杂度。需要注意的是lambda是一个表达式,并不是一个语句;它只能写成一行的表达形式,语法上并不支持多行。匿名函数通常的使用场景是:程序中需要使用一个函数完成一个简单的功能,并且该函数只调用一次。
|
||||
|
||||
其次,我们也入门了Python的函数式编程,主要了解了常见的map(),fiilter()和reduce()三个函数,并比较了它们与其他形式(for循环,comprehension)的性能,显然,它们的性能效率是最优的。
|
||||
|
||||
思考题
|
||||
|
||||
最后,我想给你留下两道思考题。
|
||||
|
||||
第一问:如果让你对一个字典,根据值进行由高到底的排序,该怎么做呢?以下面这段代码为例,你可以思考一下。
|
||||
|
||||
d = {'mike': 10, 'lucy': 2, 'ben': 30}
|
||||
|
||||
|
||||
第二问:在实际工作学习中,你遇到过哪些使用匿名函数的场景呢?
|
||||
|
||||
欢迎在留言区写下你的答案想法,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
379
专栏/Python核心技术与实战/11面向对象(上):从生活中的类比说起.md
Normal file
379
专栏/Python核心技术与实战/11面向对象(上):从生活中的类比说起.md
Normal file
@ -0,0 +1,379 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 面向对象(上):从生活中的类比说起
|
||||
你好,我是景霄。
|
||||
|
||||
很多朋友最开始学编程的时候,是从 C++ 或者 JAVA 语言入手的。他们好不容易磕磕绊绊地搞懂了最基本的数据类型、赋值判断和循环,却又迎面撞上了 OOP (object oriented programming) 的大墙,一头扎进公有私有保护、多重继承、多态派生、纯函数、抽象类、友元函数等一堆专有名词的汪洋大海中找不到彼岸,于是就放弃了进阶之路。
|
||||
|
||||
相比之下,Python 是一门相对友好的语言,它在创立之初就鼓励命令交互式的轻量级编程。理论上,Python 的命令式语言是图灵完备的, 也就是说命令式语言,理论上可以做到其他任何语言能够做到的所有的事情,甚至进一步,仅仅依靠汇编语言的 MOV 指令,就能实现图灵完备编程。
|
||||
|
||||
那么为什么不这样做呢?其实,“上古时代”的程序员就是这么做的,可是随着程序功能复杂性的逐步提升,以及需求的不断迭代,很多老旧的代码修改起来麻烦无比,牵一发而动全身,根本无法迭代和维护,甚至只能推倒重来,这也是很多古老的代码被称为“屎山”的原因。
|
||||
|
||||
传统的命令式语言有无数重复性代码,虽然函数的诞生减缓了许多重复性,但随着计算机的发展,只有函数依然不够,需要把更加抽象的概念引入计算机才能缓解(而不是解决)这个问题,于是 OOP 应运而生。
|
||||
|
||||
Python 在 1989 年被一位程序员打发时间创立之后,一步步攻城掠地飞速发展,从最基础的脚本程序,到后来可以编写系统程序、大型工程、数据科学运算、人工智能,早已脱离了当初的设计,因此一些其他语言的优秀设计之处依然需要引入。我们必须花费一定的代价掌握面向对象编程,才能跨越学习道路中的瓶颈期,走向下一步。
|
||||
|
||||
接下来,我将用两节课来讲解面向对象编程,从基础到实战。第一讲,我将带你快速但清晰地疏通最基础的知识,确保你能够迅速领略面向对象的基本思想;第二讲,我们从零开始写一个搜索引擎,将前面所学知识融会贯通。
|
||||
|
||||
这些内容可能和你以往看到的所有教程都不太一样,我会尽可能从一个初学者的角度来审视这些难点。同时我们面向实战、面向工程,不求大而全,但是对最核心的思想会有足够的勾勒。我可以保证内容清晰易懂,但想要真正掌握,仍要求你能用心去阅读和思考。真正的提高,永远要靠自己才能做到。
|
||||
|
||||
对象,你找到了吗?
|
||||
|
||||
我们先来学习,面向对象编程中最基本的概念。
|
||||
|
||||
为了方便你理解其中的抽象概念,我先打个比方带你感受一下。生物课上,我们学过“界门纲目科属种”的概念,核心思想是科学家们根据各种动植物、微生物的相似之处,将其分化为不同的类型方便研究。生活中我们也是如此,习惯对身边的事物进行分类:
|
||||
|
||||
|
||||
猫和狗都是动物;
|
||||
直线和圆都是平面几何的图形;
|
||||
《哈利波特》和《冰与火之歌》(即《权力的游戏》)都是小说。
|
||||
|
||||
|
||||
自然,同一类事物便会有着相似的特性:
|
||||
|
||||
|
||||
动物会动;
|
||||
平面图形有面积和周长;
|
||||
小说也都有相应的作者和大致情节等各种元素。
|
||||
|
||||
|
||||
那回到我们的Python上,又对应哪些内容呢?这里,我们先来看一段最基本的 Python 面向对象的应用代码,不要被它的长度吓到,你无需立刻看懂所有代码,跟着节奏来,我会一点点为你剖析。
|
||||
|
||||
class Document():
|
||||
def __init__(self, title, author, context):
|
||||
print('init function called')
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.__context = context # __开头的属性是私有属性
|
||||
|
||||
def get_context_length(self):
|
||||
return len(self.__context)
|
||||
|
||||
def intercept_context(self, length):
|
||||
self.__context = self.__context[:length]
|
||||
|
||||
harry_potter_book = Document('Harry Potter', 'J. K. Rowling', '... Forever Do not believe any thing is capable of thinking independently ...')
|
||||
|
||||
print(harry_potter_book.title)
|
||||
print(harry_potter_book.author)
|
||||
print(harry_potter_book.get_context_length())
|
||||
|
||||
harry_potter_book.intercept_context(10)
|
||||
|
||||
print(harry_potter_book.get_context_length())
|
||||
|
||||
print(harry_potter_book.__context)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
init function called
|
||||
Harry Potter
|
||||
J. K. Rowling
|
||||
77
|
||||
10
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
AttributeError Traceback (most recent call last)
|
||||
<ipython-input-5-b4d048d75003> in <module>()
|
||||
22 print(harry_potter_book.get_context_length())
|
||||
23
|
||||
---> 24 print(harry_potter_book.__context)
|
||||
|
||||
AttributeError: 'Document' object has no attribute '__context'
|
||||
|
||||
|
||||
参照着这段代码,我先来简单解释几个概念。
|
||||
|
||||
|
||||
类:一群有着相似性的事物的集合,这里对应 Python 的 class。
|
||||
对象:集合中的一个事物,这里对应由 class 生成的某一个 object,比如代码中的 harry_potter_book。
|
||||
属性:对象的某个静态特征,比如上述代码中的 title、author 和 __context。
|
||||
函数:对象的某个动态能力,比如上述代码中的 intercept_context ()函数。
|
||||
|
||||
|
||||
当然,这样的说法既不严谨,也不充分,但如果你对面向对象编程完全不了解,它们可以让你迅速有一个直观的了解。
|
||||
|
||||
这里我想多说两句。回想起当年参加数学竞赛时,我曾和一个大佬交流数学的学习,我清楚记得我们对数学有着相似的观点:很多数学概念非常抽象,如果纯粹从数理逻辑而不是更高的角度去解题,很容易陷入僵局;而具体、直观的想象和类比,才是迅速打开数学大门的钥匙。虽然这些想象和类比不严谨也不充分,很多时候甚至是错误或者异想天开的,但它们确实能帮我们快速找到正确的大门。
|
||||
|
||||
就像很多人都有过的一个疑惑,“学霸是怎样想到这个答案的?”。德国数学家克莱因曾说过,“推进数学的,主要是那些有卓越直觉的人,而不是以严格的证明方法见长的人。”编程世界同样如此,如果你不满足于只做一个CRUD“码农”,而是想成为一个优秀的工程师,那就一定要积极锻炼直觉思考和快速类比的能力,尤其是在找不到 bug 的时候。这才是编程学习中能给人最快进步的方法和路径。
|
||||
|
||||
言归正传,继续回到我们的主题,还是通过刚刚那段代码,我想再给类下一个更为严谨的定义。
|
||||
|
||||
类,一群有着相同属性和函数的对象的集合。
|
||||
|
||||
虽然有循环论证之嫌(lol),但是反复强调,还是希望你能对面向对象的最基础的思想,有更真实的了解。清楚记住这一点后,接下来,我们来具体解读刚刚这段代码。为了方便你的阅读学习,我把它重新放在了这段文字下方。
|
||||
|
||||
class Document():
|
||||
def __init__(self, title, author, context):
|
||||
print('init function called')
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.__context = context # __开头的属性是私有属性
|
||||
|
||||
def get_context_length(self):
|
||||
return len(self.__context)
|
||||
|
||||
def intercept_context(self, length):
|
||||
self.__context = self.__context[:length]
|
||||
|
||||
harry_potter_book = Document('Harry Potter', 'J. K. Rowling', '... Forever Do not believe any thing is capable of thinking independently ...')
|
||||
|
||||
print(harry_potter_book.title)
|
||||
print(harry_potter_book.author)
|
||||
print(harry_potter_book.get_context_length())
|
||||
|
||||
harry_potter_book.intercept_context(10)
|
||||
|
||||
print(harry_potter_book.get_context_length())
|
||||
|
||||
print(harry_potter_book.__context)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
init function called
|
||||
Harry Potter
|
||||
J. K. Rowling
|
||||
77
|
||||
10
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
AttributeError Traceback (most recent call last)
|
||||
<ipython-input-5-b4d048d75003> in <module>()
|
||||
22 print(harry_potter_book.get_context_length())
|
||||
23
|
||||
---> 24 print(harry_potter_book.__context)
|
||||
|
||||
AttributeError: 'Document' object has no attribute '__context'
|
||||
|
||||
|
||||
可以看到,class Document 定义了 Document 类,再往下能看到它有三个函数,这三个函数即为 Document 类的三个函数。
|
||||
|
||||
其中,init 表示构造函数,意即一个对象生成时会被自动调用的函数。我们能看到, harry_potter_book = Document(...)这一行代码被执行的时候,'init function called'字符串会被打印出来。而 get_context_length()和 intercept_context()则为类的普通函数,我们调用它们来对对象的属性做一些事情。
|
||||
|
||||
class Document 还有三个属性,title、author和 __context 分别表示标题、作者和内容,通过构造函数传入。这里代码很直观,我们可以看到, intercept_context 能修改对象 harry_potter_book 的 __context 属性。
|
||||
|
||||
这里唯一需要强调的一点是,如果一个属性以 __ (注意,此处有两个_) 开头,我们就默认这个属性是私有属性。私有属性,是指不希望在类的函数之外的地方被访问和修改的属性。所以,你可以看到,title 和 author 能够很自由地被打印出来,但是 print(harry_potter_book.__context)就会报错。
|
||||
|
||||
老师,能不能再给力点?
|
||||
|
||||
掌握了最基础的概念,其实我们已经能做很多很多的事情了。不过,在工程实践中,随着复杂度继续提升,你可能会想到一些问题:
|
||||
|
||||
|
||||
如何在一个类中定义一些常量,每个对象都可以方便访问这些常量而不用重新构造?
|
||||
如果一个函数不涉及到访问修改这个类的属性,而放到类外面有点不恰当,怎么做才能更优雅呢?
|
||||
既然类是一群相似的对象的集合,那么可不可以是一群相似的类的集合呢?
|
||||
|
||||
|
||||
前两个问题很好解决,不过,它们涉及到一些常用的代码规范,这里我放了一段代码示例。同样的,你无需一口气读完这段代码,跟着我的节奏慢慢学习即可。
|
||||
|
||||
class Document():
|
||||
|
||||
WELCOME_STR = 'Welcome! The context for this book is {}.'
|
||||
|
||||
def __init__(self, title, author, context):
|
||||
print('init function called')
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.__context = context
|
||||
|
||||
# 类函数
|
||||
@classmethod
|
||||
def create_empty_book(cls, title, author):
|
||||
return cls(title=title, author=author, context='nothing')
|
||||
|
||||
# 成员函数
|
||||
def get_context_length(self):
|
||||
return len(self.__context)
|
||||
|
||||
# 静态函数
|
||||
@staticmethod
|
||||
def get_welcome(context):
|
||||
return Document.WELCOME_STR.format(context)
|
||||
|
||||
|
||||
empty_book = Document.create_empty_book('What Every Man Thinks About Apart from Sex', 'Professor Sheridan Simove')
|
||||
|
||||
|
||||
print(empty_book.get_context_length())
|
||||
print(empty_book.get_welcome('indeed nothing'))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
init function called
|
||||
7
|
||||
Welcome! The context for this book is indeed nothing.
|
||||
|
||||
|
||||
第一个问题,在 Python 的类里,你只需要和函数并列地声明并赋值,就可以实现这一点,例如这段代码中的 WELCOME_STR。一种很常规的做法,是用全大写来表示常量,因此我们可以在类中使用 self.WELCOME_STR ,或者在类外使用 Entity.WELCOME_STR ,来表达这个字符串。
|
||||
|
||||
而针对第二个问题,我们提出了类函数、成员函数和静态函数三个概念。它们其实很好理解,前两者产生的影响是动态的,能够访问或者修改对象的属性;而静态函数则与类没有什么关联,最明显的特征便是,静态函数的第一个参数没有任何特殊性。
|
||||
|
||||
具体来看这几种函数。一般而言,静态函数可以用来做一些简单独立的任务,既方便测试,也能优化代码结构。静态函数还可以通过在函数前一行加上 @staticmethod 来表示,代码中也有相应的示例。这其实使用了装饰器的概念,我们会在后面的章节中详细讲解。
|
||||
|
||||
而类函数的第一个参数一般为 cls,表示必须传一个类进来。类函数最常用的功能是实现不同的 init 构造函数,比如上文代码中,我们使用 create_empty_book 类函数,来创造新的书籍对象,其 context 一定为 'nothing'。这样的代码,就比你直接构造要清晰一些。类似的,类函数需要装饰器 @classmethod 来声明。
|
||||
|
||||
成员函数则是我们最正常的类的函数,它不需要任何装饰器声明,第一个参数 self 代表当前对象的引用,可以通过此函数,来实现想要的查询/修改类的属性等功能。
|
||||
|
||||
继承,是每个富二代的梦想
|
||||
|
||||
接下来,我们来看第三个问题,既然类是一群相似的对象的集合,那么可不可以是一群相似的类的集合呢?
|
||||
|
||||
答案是,当然可以。只要抽象得好,类可以描述成任何事物的集合。当然你要小心、严谨地去定义它,不然一不小心就会引起第三次数学危机 XD。
|
||||
|
||||
类的继承,顾名思义,指的是一个类既拥有另一个类的特征,也拥有不同于另一个类的独特特征。在这里的第一个类叫做子类,另一个叫做父类,特征其实就是类的属性和函数。
|
||||
|
||||
class Entity():
|
||||
def __init__(self, object_type):
|
||||
print('parent class init called')
|
||||
self.object_type = object_type
|
||||
|
||||
def get_context_length(self):
|
||||
raise Exception('get_context_length not implemented')
|
||||
|
||||
def print_title(self):
|
||||
print(self.title)
|
||||
|
||||
class Document(Entity):
|
||||
def __init__(self, title, author, context):
|
||||
print('Document class init called')
|
||||
Entity.__init__(self, 'document')
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.__context = context
|
||||
|
||||
def get_context_length(self):
|
||||
return len(self.__context)
|
||||
|
||||
class Video(Entity):
|
||||
def __init__(self, title, author, video_length):
|
||||
print('Video class init called')
|
||||
Entity.__init__(self, 'video')
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.__video_length = video_length
|
||||
|
||||
def get_context_length(self):
|
||||
return self.__video_length
|
||||
|
||||
harry_potter_book = Document('Harry Potter(Book)', 'J. K. Rowling', '... Forever Do not believe any thing is capable of thinking independently ...')
|
||||
harry_potter_movie = Video('Harry Potter(Movie)', 'J. K. Rowling', 120)
|
||||
|
||||
print(harry_potter_book.object_type)
|
||||
print(harry_potter_movie.object_type)
|
||||
|
||||
harry_potter_book.print_title()
|
||||
harry_potter_movie.print_title()
|
||||
|
||||
print(harry_potter_book.get_context_length())
|
||||
print(harry_potter_movie.get_context_length())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
Document class init called
|
||||
parent class init called
|
||||
Video class init called
|
||||
parent class init called
|
||||
document
|
||||
video
|
||||
Harry Potter(Book)
|
||||
Harry Potter(Movie)
|
||||
77
|
||||
120
|
||||
|
||||
|
||||
我们同样结合代码来学习这些概念。在这段代码中,Document 和 Video 它们有相似的地方,都有相应的标题、作者和内容等属性。我们可以从中抽象出一个叫做 Entity 的类,来作为它俩的父类。
|
||||
|
||||
首先需要注意的是构造函数。每个类都有构造函数,继承类在生成对象的时候,是不会自动调用父类的构造函数的,因此你必须在 init()函数中显式调用父类的构造函数。它们的执行顺序是 子类的构造函数 -> 父类的构造函数。
|
||||
|
||||
其次需要注意父类 get_context_length()函数。如果使用 Entity 直接生成对象,调用 get_context_length()函数,就会 raise error 中断程序的执行。这其实是一种很好的写法,叫做函数重写,可以使子类必须重新写一遍 get_context_length()函数,来覆盖掉原有函数。
|
||||
|
||||
最后需要注意到 print_title()函数,这个函数定义在父类中,但是子类的对象可以毫无阻力地使用它来打印 title,这也就体现了继承的优势:减少重复的代码,降低系统的熵值(即复杂度)。
|
||||
|
||||
到这里,你对继承就有了比较详细的了解了,面向对象编程也可以说已经入门了。当然,如果你想达到更高的层次,大量练习编程,学习更多的细节知识,都是必不可少的。
|
||||
|
||||
最后,我想再为你扩展一下抽象函数和抽象类,我同样会用一段代码来辅助讲解。
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
class Entity(metaclass=ABCMeta):
|
||||
@abstractmethod
|
||||
def get_title(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_title(self, title):
|
||||
pass
|
||||
|
||||
class Document(Entity):
|
||||
def get_title(self):
|
||||
return self.title
|
||||
|
||||
def set_title(self, title):
|
||||
self.title = title
|
||||
|
||||
document = Document()
|
||||
document.set_title('Harry Potter')
|
||||
print(document.get_title())
|
||||
|
||||
entity = Entity()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
Harry Potter
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
TypeError Traceback (most recent call last)
|
||||
<ipython-input-7-266b2aa47bad> in <module>()
|
||||
21 print(document.get_title())
|
||||
22
|
||||
---> 23 entity = Entity()
|
||||
24 entity.set_title('Test')
|
||||
|
||||
TypeError: Can't instantiate abstract class Entity with abstract methods get_title, set_title
|
||||
|
||||
|
||||
你应该发现了,Entity 本身是没有什么用的,只需拿来定义 Document 和 Video 的一些基本元素就够了。不过,万一你不小心生成 Entity 的对象该怎么办呢?为了防止这样的手误,必须要介绍一下抽象类。
|
||||
|
||||
抽象类是一种特殊的类,它生下来就是作为父类存在的,一旦对象化就会报错。同样,抽象函数定义在抽象类之中,子类必须重写该函数才能使用。相应的抽象函数,则是使用装饰器 @abstractmethod 来表示。
|
||||
|
||||
我们可以看到,代码中entity = Entity()直接报错,只有通过 Document 继承 Entity 才能正常使用。
|
||||
|
||||
这其实正是软件工程中一个很重要的概念,定义接口。大型工程往往需要很多人合作开发,比如在 Facebook 中,在 idea 提出之后,开发组和产品组首先会召开产品设计会,PM(Product Manager,产品经理) 写出产品需求文档,然后迭代;TL(Team Leader,项目经理)编写开发文档,开发文档中会定义不同模块的大致功能和接口、每个模块之间如何协作、单元测试和集成测试、线上灰度测试、监测和日志等等一系列开发流程。
|
||||
|
||||
抽象类就是这么一种存在,它是一种自上而下的设计风范,你只需要用少量的代码描述清楚要做的事情,定义好接口,然后就可以交给不同开发人员去开发和对接。
|
||||
|
||||
总结
|
||||
|
||||
到目前为止,我们一直在强调一件事情:面向对象编程是软件工程中重要的思想。正如动态规划是算法中的重要思想一样,它不是某一种非常具体的技术,而是一种综合能力的体现,是将大型工程解耦化、模块化的重要方法。在实践中要多想,尤其是抽象地想,才能更快掌握这个技巧。
|
||||
|
||||
回顾一下今天的内容,我希望你能自己回答下面两个问题,作为今天内容的总结,写在留言区里。
|
||||
|
||||
第一个问题,面向对象编程四要素是什么?它们的关系又是什么?
|
||||
|
||||
第二个问题,讲了这么久的继承,继承究竟是什么呢?你能用三个字表达出来吗?
|
||||
|
||||
|
||||
这里不开玩笑,Facebook 很多 Launch Doc (上线文档)中要求用五个单词总结你的文档,因为你的文档不仅仅是你的团队要看,往上走甚至会到 VP 或者 CTO 那里,你需要言简意赅,让他们快速理解你想要表达的意思。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,再给你留一道思考题。既然你能通过继承一个类,来获得父类的函数和属性,那么你能继承两个吗?答案自是能的,这就叫做多重继承。那么问题来了。
|
||||
|
||||
我们使用单一继承的时候,构造函数的执行顺序很好确定,即子类->父类->爷类->… 的链式关系。不过,多重继承的时候呢?比如下面这个例子。
|
||||
|
||||
--->B---
|
||||
A- -->D
|
||||
--->C---
|
||||
|
||||
|
||||
这种继承方式,叫做菱形继承,BC 继承了 A,然后 D 继承了 BC,创造一个 D 的对象。那么,构造函数调用顺序又是怎样的呢?
|
||||
|
||||
欢迎在留言区写下你的答案想法,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
434
专栏/Python核心技术与实战/12面向对象(下):如何实现一个搜索引擎?.md
Normal file
434
专栏/Python核心技术与实战/12面向对象(下):如何实现一个搜索引擎?.md
Normal file
@ -0,0 +1,434 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 面向对象(下):如何实现一个搜索引擎?
|
||||
你好,我是景霄。这节课,我们来实现一个 Python 的搜索引擎(search engine)。
|
||||
|
||||
承接上文,今天这节课的主要目的是,带你模拟敏捷开发过程中的迭代开发流程,巩固面向对象的程序设计思想。
|
||||
|
||||
我们将从最简单最直接的搜索做起,一步步优化,这其中,我不会涉及到过多的超纲算法,但不可避免会介绍一些现代搜索引擎中的基础概念,例如语料(corpus)、倒序索引(inverted index)等。
|
||||
|
||||
如果你对这方面本身有些了解,自然可以轻松理解;即使你之前完全没接触过搜索引擎,也不用过分担心,我会力求简洁清晰,降低学习难度。同时,我希望你把更多的精力放在面向对象的建模思路上。
|
||||
|
||||
“高大上”的搜索引擎
|
||||
|
||||
引擎一词尤如其名,听起来非常酷炫。搜索引擎,则是新世纪初期互联网发展最重要的入口之一,依托搜索引擎,中国和美国分别诞生了百度、谷歌等巨型公司。
|
||||
|
||||
搜索引擎极大地方便了互联网生活,也成为上网必不可少的刚需工具。依托搜索引擎发展起来的互联网广告,则成了硅谷和中国巨头的核心商业模式;而搜索本身,也在持续进步着, Facebook 和微信也一直有意向在自家社交产品架设搜索平台。
|
||||
|
||||
关于搜索引擎的价值我不必多说了,今天我们主要来看一下搜索引擎的核心构成。
|
||||
|
||||
听Google的朋友说,他们入职培训的时候,有一门课程叫做 The life of a query,内容是讲用户在浏览器中键入一串文字,按下回车后发生了什么。今天我也按照这个思路,来简单介绍下。
|
||||
|
||||
我们知道,一个搜索引擎由搜索器、索引器、检索器和用户接口四个部分组成。
|
||||
|
||||
搜索器,通俗来讲就是我们常提到的爬虫(scrawler),它能在互联网上大量爬取各类网站的内容,送给索引器。索引器拿到网页和内容后,会对内容进行处理,形成索引(index),存储于内部的数据库等待检索。
|
||||
|
||||
最后的用户接口很好理解,是指网页和 App 前端界面,例如百度和谷歌的搜索页面。用户通过用户接口,向搜索引擎发出询问(query),询问解析后送达检索器;检索器高效检索后,再将结果返回给用户。
|
||||
|
||||
爬虫知识不是我们今天学习的重点,这里我就不做深入介绍了。我们假设搜索样本存在于本地磁盘上。
|
||||
|
||||
为了方便,我们只提供五个文件的检索,内容我放在了下面这段代码中:
|
||||
|
||||
# 1.txt
|
||||
I have a dream that my four little children will one day live in a nation where they will not be judged by the color of their skin but by the content of their character. I have a dream today.
|
||||
|
||||
# 2.txt
|
||||
I have a dream that one day down in Alabama, with its vicious racists, . . . one day right there in Alabama little black boys and black girls will be able to join hands with little white boys and white girls as sisters and brothers. I have a dream today.
|
||||
|
||||
# 3.txt
|
||||
I have a dream that one day every valley shall be exalted, every hill and mountain shall be made low, the rough places will be made plain, and the crooked places will be made straight, and the glory of the Lord shall be revealed, and all flesh shall see it together.
|
||||
|
||||
# 4.txt
|
||||
This is our hope. . . With this faith we will be able to hew out of the mountain of despair a stone of hope. With this faith we will be able to transform the jangling discords of our nation into a beautiful symphony of brotherhood. With this faith we will be able to work together, to pray together, to struggle together, to go to jail together, to stand up for freedom together, knowing that we will be free one day. . . .
|
||||
|
||||
# 5.txt
|
||||
And when this happens, and when we allow freedom ring, when we let it ring from every village and every hamlet, from every state and every city, we will be able to speed up that day when all of God's children, black men and white men, Jews and Gentiles, Protestants and Catholics, will be able to join hands and sing in the words of the old Negro spiritual: "Free at last! Free at last! Thank God Almighty, we are free at last!"
|
||||
|
||||
|
||||
我们先来定义 SearchEngineBase 基类。这里我先给出了具体的代码,你不必着急操作,还是那句话,跟着节奏慢慢学,再难的东西也可以啃得下来。
|
||||
|
||||
class SearchEngineBase(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def add_corpus(self, file_path):
|
||||
with open(file_path, 'r') as fin:
|
||||
text = fin.read()
|
||||
self.process_corpus(file_path, text)
|
||||
|
||||
def process_corpus(self, id, text):
|
||||
raise Exception('process_corpus not implemented.')
|
||||
|
||||
def search(self, query):
|
||||
raise Exception('search not implemented.')
|
||||
|
||||
def main(search_engine):
|
||||
for file_path in ['1.txt', '2.txt', '3.txt', '4.txt', '5.txt']:
|
||||
search_engine.add_corpus(file_path)
|
||||
|
||||
while True:
|
||||
query = input()
|
||||
results = search_engine.search(query)
|
||||
print('found {} result(s):'.format(len(results)))
|
||||
for result in results:
|
||||
print(result)
|
||||
|
||||
|
||||
SearchEngineBase 可以被继承,继承的类分别代表不同的算法引擎。每一个引擎都应该实现 process_corpus()和search()两个函数,对应我们刚刚提到的索引器和检索器。main()函数提供搜索器和用户接口,于是一个简单的包装界面就有了。
|
||||
|
||||
具体来看这段代码,其中,
|
||||
|
||||
|
||||
add_corpus() 函数负责读取文件内容,将文件路径作为 ID,连同内容一起送到 process_corpus 中。
|
||||
process_corpus 需要对内容进行处理,然后文件路径为 ID ,将处理后的内容存下来。处理后的内容,就叫做索引(index)。
|
||||
search 则给定一个询问,处理询问,再通过索引检索,然后返回。
|
||||
|
||||
|
||||
好,理解这些概念后,接下来,我们实现一个最基本的可以工作的搜索引擎,代码如下:
|
||||
|
||||
class SimpleEngine(SearchEngineBase):
|
||||
def __init__(self):
|
||||
super(SimpleEngine, self).__init__()
|
||||
self.__id_to_texts = {}
|
||||
|
||||
def process_corpus(self, id, text):
|
||||
self.__id_to_texts[id] = text
|
||||
|
||||
def search(self, query):
|
||||
results = []
|
||||
for id, text in self.__id_to_texts.items():
|
||||
if query in text:
|
||||
results.append(id)
|
||||
return results
|
||||
|
||||
search_engine = SimpleEngine()
|
||||
main(search_engine)
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
simple
|
||||
found 0 result(s):
|
||||
little
|
||||
found 2 result(s):
|
||||
1.txt
|
||||
2.txt
|
||||
|
||||
|
||||
你可能很惊讶,只需要短短十来行代码居然就可以了吗?
|
||||
|
||||
没错,正是如此,这段代码我们拆开来看一下:
|
||||
|
||||
SimpleEngine 实现了一个继承 SearchEngineBase 的子类,继承并实现了 process_corpus 和 search 接口,同时,也顺手继承了 add_corpus 函数(当然你想重写也是可行的),因此我们可以在 main() 函数中直接调取。
|
||||
|
||||
在我们新的构造函数中,self.__id_to_texts = {} 初始化了自己的私有变量,也就是这个用来存储文件名到文件内容的字典。
|
||||
|
||||
process_corpus() 函数则非常直白地将文件内容插入到字典中。这里注意,ID 需要是唯一的,不然相同ID的新内容会覆盖掉旧的内容。
|
||||
|
||||
search 直接枚举字典,从中找到要搜索的字符串。如果能够找到,则将 ID 放到结果列表中,最后返回。
|
||||
|
||||
你看,是不是非常简单呢?这个过程始终贯穿着面向对象的思想,这里我为你梳理成了几个问题,你可以自己思考一下,当成是一个小复习。
|
||||
|
||||
|
||||
现在你对父类子类的构造函数调用顺序和方法应该更清楚了吧?
|
||||
继承的时候,函数是如何重写的?
|
||||
基类是如何充当接口作用的(你可以自行删掉子类中的重写函数,抑或是修改一下函数的参数,看一下会报什么错)?
|
||||
方法和变量之间又如何衔接起来的呢?
|
||||
|
||||
|
||||
好的,我们重新回到搜索引擎这个话题。
|
||||
|
||||
相信你也能看得出来,这种实现方式简单,但显然是一种很低效的方式:每次索引后需要占用大量空间,因为索引函数并没有做任何事情;每次检索需要占用大量时间,因为所有索引库的文件都要被重新搜索一遍。如果把语料的信息量视为 n,那么这里的时间复杂度和空间复杂度都应该是 O(n) 级别的。
|
||||
|
||||
而且,还有一个问题:这里的 query 只能是一个词,或者是连起来的几个词。如果你想要搜索多个词,它们又分散在文章的不同位置,我们的简单引擎就无能为力了。
|
||||
|
||||
这时应该怎么优化呢?
|
||||
|
||||
最直接的一个想法,就是把语料分词,看成一个个的词汇,这样就只需要对每篇文章存储它所有词汇的 set 即可。根据齐夫定律(Zipf’s law,https://en.wikipedia.org/wiki/Zipf%27s_law),在自然语言的语料库里,一个单词出现的频率与它在频率表里的排名成反比,呈现幂律分布。因此,语料分词的做法可以大大提升我们的存储和搜索效率。
|
||||
|
||||
那具体该如何实现呢?
|
||||
|
||||
Bag of Words 和 Inverted Index
|
||||
|
||||
我们先来实现一个名叫 Bag of Words 的搜索模型。请看下面的代码:
|
||||
|
||||
import re
|
||||
|
||||
class BOWEngine(SearchEngineBase):
|
||||
def __init__(self):
|
||||
super(BOWEngine, self).__init__()
|
||||
self.__id_to_words = {}
|
||||
|
||||
def process_corpus(self, id, text):
|
||||
self.__id_to_words[id] = self.parse_text_to_words(text)
|
||||
|
||||
def search(self, query):
|
||||
query_words = self.parse_text_to_words(query)
|
||||
results = []
|
||||
for id, words in self.__id_to_words.items():
|
||||
if self.query_match(query_words, words):
|
||||
results.append(id)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def query_match(query_words, words):
|
||||
for query_word in query_words:
|
||||
if query_word not in words:
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def parse_text_to_words(text):
|
||||
# 使用正则表达式去除标点符号和换行符
|
||||
text = re.sub(r'[^\w ]', ' ', text)
|
||||
# 转为小写
|
||||
text = text.lower()
|
||||
# 生成所有单词的列表
|
||||
word_list = text.split(' ')
|
||||
# 去除空白单词
|
||||
word_list = filter(None, word_list)
|
||||
# 返回单词的 set
|
||||
return set(word_list)
|
||||
|
||||
search_engine = BOWEngine()
|
||||
main(search_engine)
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
i have a dream
|
||||
found 3 result(s):
|
||||
1.txt
|
||||
2.txt
|
||||
3.txt
|
||||
freedom children
|
||||
found 1 result(s):
|
||||
5.txt
|
||||
|
||||
|
||||
你应该发现,代码开始变得稍微复杂些了。
|
||||
|
||||
这里我们先来理解一个概念,BOW Model,即 Bag of Words Model,中文叫做词袋模型。这是 NLP 领域最常见最简单的模型之一。
|
||||
|
||||
假设一个文本,不考虑语法、句法、段落,也不考虑词汇出现的顺序,只将这个文本看成这些词汇的集合。于是相应的,我们把 id_to_texts 替换成 id_to_words,这样就只需要存这些单词,而不是全部文章,也不需要考虑顺序。
|
||||
|
||||
其中,process_corpus() 函数调用类静态函数 parse_text_to_words,将文章打碎形成词袋,放入 set 之后再放到字典中。
|
||||
|
||||
search() 函数则稍微复杂一些。这里我们假设,想得到的结果,是所有的搜索关键词都要出现在同一篇文章中。那么,我们需要同样打碎 query 得到一个 set,然后把 set 中的每一个词,和我们的索引中每一篇文章进行核对,看一下要找的词是否在其中。而这个过程由静态函数 query_match 负责。
|
||||
|
||||
你可以回顾一下上节课学到的静态函数,我们看到,这两个函数都是没有状态的,它们不涉及对象的私有变量(没有 self 作为参数),相同的输入能够得到完全相同的输出结果。因此设置为静态,可以方便其他的类来使用。
|
||||
|
||||
可是,即使这样做,每次查询时依然需要遍历所有ID,虽然比起 Simple 模型已经节约了大量时间,但是互联网上有上亿个页面,每次都全部遍历的代价还是太大了。到这时,又该如何优化呢?
|
||||
|
||||
你可能想到了,我们每次查询的 query 的单词量不会很多,一般也就几个、最多十几个的样子。那可不可以从这里下手呢?
|
||||
|
||||
再有,词袋模型并不考虑单词间的顺序,但有些人希望单词按顺序出现,或者希望搜索的单词在文中离得近一些,这种情况下词袋模型现任就无能为力了。
|
||||
|
||||
针对这两点,我们还能做得更好吗?显然是可以的,请看接下来的这段代码。
|
||||
|
||||
import re
|
||||
|
||||
class BOWInvertedIndexEngine(SearchEngineBase):
|
||||
def __init__(self):
|
||||
super(BOWInvertedIndexEngine, self).__init__()
|
||||
self.inverted_index = {}
|
||||
|
||||
def process_corpus(self, id, text):
|
||||
words = self.parse_text_to_words(text)
|
||||
for word in words:
|
||||
if word not in self.inverted_index:
|
||||
self.inverted_index[word] = []
|
||||
self.inverted_index[word].append(id)
|
||||
|
||||
def search(self, query):
|
||||
query_words = list(self.parse_text_to_words(query))
|
||||
query_words_index = list()
|
||||
for query_word in query_words:
|
||||
query_words_index.append(0)
|
||||
|
||||
# 如果某一个查询单词的倒序索引为空,我们就立刻返回
|
||||
for query_word in query_words:
|
||||
if query_word not in self.inverted_index:
|
||||
return []
|
||||
|
||||
result = []
|
||||
while True:
|
||||
|
||||
# 首先,获得当前状态下所有倒序索引的 index
|
||||
current_ids = []
|
||||
|
||||
for idx, query_word in enumerate(query_words):
|
||||
current_index = query_words_index[idx]
|
||||
current_inverted_list = self.inverted_index[query_word]
|
||||
|
||||
# 已经遍历到了某一个倒序索引的末尾,结束 search
|
||||
if current_index >= len(current_inverted_list):
|
||||
return result
|
||||
|
||||
current_ids.append(current_inverted_list[current_index])
|
||||
|
||||
# 然后,如果 current_ids 的所有元素都一样,那么表明这个单词在这个元素对应的文档中都出现了
|
||||
if all(x == current_ids[0] for x in current_ids):
|
||||
result.append(current_ids[0])
|
||||
query_words_index = [x + 1 for x in query_words_index]
|
||||
continue
|
||||
|
||||
# 如果不是,我们就把最小的元素加一
|
||||
min_val = min(current_ids)
|
||||
min_val_pos = current_ids.index(min_val)
|
||||
query_words_index[min_val_pos] += 1
|
||||
|
||||
@staticmethod
|
||||
def parse_text_to_words(text):
|
||||
# 使用正则表达式去除标点符号和换行符
|
||||
text = re.sub(r'[^\w ]', ' ', text)
|
||||
# 转为小写
|
||||
text = text.lower()
|
||||
# 生成所有单词的列表
|
||||
word_list = text.split(' ')
|
||||
# 去除空白单词
|
||||
word_list = filter(None, word_list)
|
||||
# 返回单词的 set
|
||||
return set(word_list)
|
||||
|
||||
search_engine = BOWInvertedIndexEngine()
|
||||
main(search_engine)
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
little
|
||||
found 2 result(s):
|
||||
1.txt
|
||||
2.txt
|
||||
little vicious
|
||||
found 1 result(s):
|
||||
2.txt
|
||||
|
||||
|
||||
首先我要强调一下,这次的算法并不需要你完全理解,这里的实现有一些超出了本章知识点。但希望你不要因此退缩,这个例子会告诉你,面向对象编程是如何把算法复杂性隔离开来,而保留接口和其他的代码不变。
|
||||
|
||||
我们接着来看这段代码。你可以看到,新模型继续使用之前的接口,仍然只在 __init__()、process_corpus()和search()三个函数进行修改。
|
||||
|
||||
这其实也是大公司里团队协作的一种方式,在合理的分层设计后,每一层的逻辑只需要处理好分内的事情即可。在迭代升级我们的搜索引擎内核时, main 函数、用户接口没有任何改变。当然,如果公司招了新的前端工程师,要对用户接口部分进行修改,新人也不需要过分担心后台的事情,只要做好数据交互就可以了。
|
||||
|
||||
继续看代码,你可能注意到了开头的Inverted Index。Inverted Index Model,即倒序索引,是非常有名的搜索引擎方法,接下来我简单介绍一下。
|
||||
|
||||
倒序索引,一如其名,也就是说这次反过来,我们保留的是 word -> id 的字典。于是情况就豁然开朗了,在 search 时,我们只需要把想要的 query_word 的几个倒序索引单独拎出来,然后从这几个列表中找共有的元素,那些共有的元素,即 ID,就是我们想要的查询结果。这样,我们就避免了将所有的 index 过一遍的尴尬。
|
||||
|
||||
process_corpus 建立倒序索引。注意,这里的代码都是非常精简的。在工业界领域,需要一个 unique ID 生成器,来对每一篇文章标记上不同的 ID,倒序索引也应该按照这个 unique_id 来进行排序。
|
||||
|
||||
至于search() 函数,你大概了解它做的事情即可。它会根据 query_words 拿到所有的倒序索引,如果拿不到,就表示有的 query word 不存在于任何文章中,直接返回空;拿到之后,运行一个“合并K个有序数组”的算法,从中拿到我们想要的 ID,并返回。
|
||||
|
||||
|
||||
注意,这里用到的算法并不是最优的,最优的写法需要用最小堆来存储 index。这是一道有名的 leetcode hard 题,有兴趣请参考:https://blog.csdn.net/qqxx6661/article/details/77814794)
|
||||
|
||||
|
||||
遍历的问题解决了,那第二个问题,如果我们想要实现搜索单词按顺序出现,或者希望搜索的单词在文中离得近一些呢?
|
||||
|
||||
我们需要在 Inverted Index 上,对于每篇文章也保留单词的位置信息,这样一来,在合并操作的时候处理一下就可以了。
|
||||
|
||||
倒序索引我就介绍到这里了,如果你感兴趣可以自行查阅资料。还是那句话,我们的重点是面向对象的抽象,别忘了体会这一思想。
|
||||
|
||||
LRU 和多重继承
|
||||
|
||||
到这一步,终于,你的搜索引擎上线了,有了越来越多的访问量(QPS)。欣喜骄傲的同时,你却发现服务器有些“不堪重负”了。经过一段时间的调研,你发现大量重复性搜索占据了 90% 以上的流量,于是,你想到了一个大杀器——给搜索引擎加一个缓存。
|
||||
|
||||
所以,最后这部分,我就来讲讲缓存和多重继承的内容。
|
||||
|
||||
import pylru
|
||||
|
||||
class LRUCache(object):
|
||||
def __init__(self, size=32):
|
||||
self.cache = pylru.lrucache(size)
|
||||
|
||||
def has(self, key):
|
||||
return key in self.cache
|
||||
|
||||
def get(self, key):
|
||||
return self.cache[key]
|
||||
|
||||
def set(self, key, value):
|
||||
self.cache[key] = value
|
||||
|
||||
class BOWInvertedIndexEngineWithCache(BOWInvertedIndexEngine, LRUCache):
|
||||
def __init__(self):
|
||||
super(BOWInvertedIndexEngineWithCache, self).__init__()
|
||||
LRUCache.__init__(self)
|
||||
|
||||
def search(self, query):
|
||||
if self.has(query):
|
||||
print('cache hit!')
|
||||
return self.get(query)
|
||||
|
||||
result = super(BOWInvertedIndexEngineWithCache, self).search(query)
|
||||
self.set(query, result)
|
||||
|
||||
return result
|
||||
|
||||
search_engine = BOWInvertedIndexEngineWithCache()
|
||||
main(search_engine)
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
little
|
||||
found 2 result(s):
|
||||
1.txt
|
||||
2.txt
|
||||
little
|
||||
cache hit!
|
||||
found 2 result(s):
|
||||
1.txt
|
||||
2.txt
|
||||
|
||||
|
||||
它的代码很简单,LRUCache 定义了一个缓存类,你可以通过继承这个类来调用其方法。LRU 缓存是一种很经典的缓存(同时,LRU的实现也是硅谷大厂常考的算法面试题,这里为了简单,我直接使用 pylru 这个包),它符合自然界的局部性原理,可以保留最近使用过的对象,而逐渐淘汰掉很久没有被用过的对象。
|
||||
|
||||
因此,这里的缓存使用起来也很简单,调用 has() 函数判断是否在缓存中,如果在,调用 get 函数直接返回结果;如果不在,送入后台计算结果,然后再塞入缓存。
|
||||
|
||||
我们可以看到,BOWInvertedIndexEngineWithCache 类,多重继承了两个类。首先,你需要注意的是构造函数(上节课的思考题,你思考了吗?)。多重继承有两种初始化方法,我们分别来看一下。
|
||||
|
||||
第一种方法,用下面这行代码,直接初始化该类的第一个父类:
|
||||
|
||||
super(BOWInvertedIndexEngineWithCache, self).__init__()
|
||||
|
||||
|
||||
不过使用这种方法时,要求继承链的最顶层父类必须要继承 object。
|
||||
|
||||
第二种方法,对于多重继承,如果有多个构造函数需要调用, 我们必须用传统的方法LRUCache.__init__(self) 。
|
||||
|
||||
其次,你应该注意,search() 函数被子类 BOWInvertedIndexEngineWithCache 再次重载,但是我还需要调用 BOWInvertedIndexEngine 的 search() 函数,这时该怎么办呢?请看下面这行代码:
|
||||
|
||||
super(BOWInvertedIndexEngineWithCache, self).search(query)
|
||||
|
||||
|
||||
我们可以强行调用被覆盖的父类的函数。
|
||||
|
||||
这样一来,我们就简洁地实现了缓存,而且还是在不影响 BOWInvertedIndexEngine 代码的情况下。这部分内容希望你多读几遍,自己揣摩清楚,通过这个例子多多体会继承的优势。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课是面向对象的实战应用,相比起前面的理论知识,内容其实不那么友好。不过,若你能静下心来,仔细学习,理清楚整个过程的要点,对你理解面向对象必将有所裨益。比如,你可以根据下面两个问题,来检验今天这节课的收获。
|
||||
|
||||
|
||||
你能把这节课所有的类的属性和函数抽取出来,自己在纸上画一遍继承关系吗?
|
||||
迭代开发流程是怎样的?
|
||||
|
||||
|
||||
其实于我而言,通过构造搜索引擎这么一个例子来讲面向对象,也是颇费了一番功夫。这其中虽然涉及一些搜索引擎的专业知识和算法,但篇幅有限,也只能算是抛砖引玉,你若有所收获,我便欣然满足。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。私有变量能被继承吗?如果不能,你想继承应该怎么去做呢?欢迎留言与我分享、讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起交流与进步。
|
||||
|
||||
|
||||
|
||||
|
335
专栏/Python核心技术与实战/13搭建积木:Python模块化.md
Normal file
335
专栏/Python核心技术与实战/13搭建积木:Python模块化.md
Normal file
@ -0,0 +1,335 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 搭建积木:Python 模块化
|
||||
你好,我是景霄。
|
||||
|
||||
这是基础版块的最后一节。到目前为止,你已经掌握了 Python 这一门当代武功的基本招式和套路,走出了新手村,看到了更远的世界,有了和这个世界过过招的冲动。
|
||||
|
||||
于是,你可能开始尝试写一些不那么简单的系统性工程,或者代码量较大的应用程序。这时候,简单的一个 py 文件已经过于臃肿,无法承担一个重量级软件开发的重任。
|
||||
|
||||
今天这节课的主要目的,就是化繁为简,将功能模块化、文件化,从而可以像搭积木一样,将不同的功能,组件在大型工程中搭建起来。
|
||||
|
||||
简单模块化
|
||||
|
||||
说到最简单的模块化方式,你可以把函数、类、常量拆分到不同的文件,把它们放在同一个文件夹,然后使用 from your_file import function_name, class_name 的方式调用。之后,这些函数和类就可以在文件内直接使用了。
|
||||
|
||||
# utils.py
|
||||
|
||||
def get_sum(a, b):
|
||||
return a + b
|
||||
|
||||
|
||||
# class_utils.py
|
||||
|
||||
class Encoder(object):
|
||||
def encode(self, s):
|
||||
return s[::-1]
|
||||
|
||||
class Decoder(object):
|
||||
def decode(self, s):
|
||||
return ''.join(reversed(list(s)))
|
||||
|
||||
|
||||
# main.py
|
||||
|
||||
from utils import get_sum
|
||||
from class_utils import *
|
||||
|
||||
print(get_sum(1, 2))
|
||||
|
||||
encoder = Encoder()
|
||||
decoder = Decoder()
|
||||
|
||||
print(encoder.encode('abcde'))
|
||||
print(decoder.decode('edcba'))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
3
|
||||
edcba
|
||||
abcde
|
||||
|
||||
|
||||
我们来看这种方式的代码:get_sum() 函数定义在 utils.py,Encoder 和 Decoder 类则在 class_utils.py,我们在 main 函数直接调用 from import ,就可以将我们需要的东西 import 过来。
|
||||
|
||||
非常简单。
|
||||
|
||||
但是这就足够了吗?当然不,慢慢地,你会发现,所有文件都堆在一个文件夹下也并不是办法。
|
||||
|
||||
于是,我们试着建一些子文件夹:
|
||||
|
||||
# utils/utils.py
|
||||
|
||||
def get_sum(a, b):
|
||||
return a + b
|
||||
|
||||
|
||||
# utils/class_utils.py
|
||||
|
||||
class Encoder(object):
|
||||
def encode(self, s):
|
||||
return s[::-1]
|
||||
|
||||
class Decoder(object):
|
||||
def decode(self, s):
|
||||
return ''.join(reversed(list(s)))
|
||||
|
||||
|
||||
# src/sub_main.py
|
||||
|
||||
import sys
|
||||
sys.path.append("..")
|
||||
|
||||
from utils.class_utils import *
|
||||
|
||||
encoder = Encoder()
|
||||
decoder = Decoder()
|
||||
|
||||
print(encoder.encode('abcde'))
|
||||
print(decoder.decode('edcba'))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
edcba
|
||||
abcde
|
||||
|
||||
|
||||
而这一次,我们的文件结构是下面这样的:
|
||||
|
||||
.
|
||||
├── utils
|
||||
│ ├── utils.py
|
||||
│ └── class_utils.py
|
||||
├── src
|
||||
│ └── sub_main.py
|
||||
└── main.py
|
||||
|
||||
|
||||
很容易看出,main.py 调用子目录的模块时,只需要使用 . 代替 / 来表示子目录,utils.utils 表示 utils 子文件夹下的 utils.py 模块就行。
|
||||
|
||||
那如果我们想调用上层目录呢?注意,sys.path.append("..") 表示将当前程序所在位置向上提了一级,之后就能调用 utils 的模块了。
|
||||
|
||||
同时要注意一点,import 同一个模块只会被执行一次,这样就可以防止重复导入模块出现问题。当然,良好的编程习惯应该杜绝代码多次导入的情况。在Facebook 的编程规范中,除了一些极其特殊的情况,import 必须位于程序的最前端。
|
||||
|
||||
最后我想再提一下版本区别。你可能在许多教程中看到过这样的要求:我们还需要在模块所在的文件夹新建一个 __init__.py,内容可以为空,也可以用来表述包对外暴露的模块接口。不过,事实上,这是 Python 2 的规范。在 Python 3 规范中,__init__.py 并不是必须的,很多教程里没提过这一点,或者没讲明白,我希望你还是能注意到这个地方。
|
||||
|
||||
整体而言,这就是最简单的模块调用方式了。在我初用 Python 时,这种方式已经足够我完成大学期间的项目了,毕竟,很多学校项目的文件数只有个位数,每个文件代码也只有几百行,这种组织方式能帮我顺利完成任务。
|
||||
|
||||
但是在我来到 Facebook后,我发现,一个项目组的 workspace 可能有上千个文件,有几十万到几百万行代码。这种调用方式已经完全不够用了,学会新的组织方式迫在眉睫。
|
||||
|
||||
接下来,我们就系统学习下,模块化的科学组织方式。
|
||||
|
||||
项目模块化
|
||||
|
||||
我们先来回顾下相对路径和绝对路径的概念。
|
||||
|
||||
在 Linux 系统中,每个文件都有一个绝对路径,以 / 开头,来表示从根目录到叶子节点的路径,例如 /home/ubuntu/Desktop/my_project/test.py,这种表示方法叫作绝对路径。
|
||||
|
||||
另外,对于任意两个文件,我们都有一条通路可以从一个文件走到另一个文件,例如 /home/ubuntu/Downloads/example.json。再如,我们从 test.py 访问到 example.json,需要写成 '../../Downloads/example.json',其中 .. 表示上一层目录。这种表示方法,叫作相对路径。
|
||||
|
||||
通常,一个 Python 文件在运行的时候,都会有一个运行时位置,最开始时即为这个文件所在的文件夹。当然,这个运行路径以后可以被改变。运行 sys.path.append("..") ,则可以改变当前 Python 解释器的位置。不过,一般而言我并不推荐,固定一个确定路径对大型工程来说是非常必要的。
|
||||
|
||||
理清楚这些概念后,我们就很容易搞懂,项目中如何设置模块的路径。
|
||||
|
||||
首先,你会发现,相对位置是一种很不好的选择。因为代码可能会迁移,相对位置会使得重构既不雅观,也易出错。因此,在大型工程中尽可能使用绝对位置是第一要义。对于一个独立的项目,所有的模块的追寻方式,最好从项目的根目录开始追溯,这叫做相对的绝对路径。
|
||||
|
||||
事实上,在 Facebook 和 Google,整个公司都只有一个代码仓库,全公司的代码都放在这个库里。我刚加入 Facebook 时对此感到很困惑,也很新奇,难免会有些担心:
|
||||
|
||||
|
||||
这样做似乎会增大项目管理的复杂度吧?
|
||||
是不是也会有不同组代码隐私泄露的风险呢?
|
||||
|
||||
|
||||
后来,随着工作的深入,我才发现了这种代码仓库独有的几个优点。
|
||||
|
||||
第一个优点,简化依赖管理。整个公司的代码模块,都可以被你写的任何程序所调用,而你写的库和模块也会被其他人调用。调用的方式,都是从代码的根目录开始索引,也就是前面提到过的相对的绝对路径。这样极大地提高了代码的分享共用能力,你不需要重复造轮子,只需要在写之前,去搜一下有没有已经实现好的包或者框架就可以了。
|
||||
|
||||
第二个优点,版本统一。不存在使用了一个新模块,却导致一系列函数崩溃的情况;并且所有的升级都需要通过单元测试才可以继续。
|
||||
|
||||
第三个优点,代码追溯。你可以很容易追溯,一个 API 是从哪里被调用的,它的历史版本是怎样迭代开发,产生变化的。
|
||||
|
||||
|
||||
如果你有兴趣,可以参考这篇论文:https://cacm.acm.org/magazines/2016/7/204032-why-google-stores-billions-of-lines-of-code-in-a-single-repository/fulltext
|
||||
|
||||
|
||||
在做项目的时候,虽然你不可能把全世界的代码都放到一个文件夹下,但是类似模块化的思想还是要有的——那就是以项目的根目录作为最基本的目录,所有的模块调用,都要通过根目录一层层向下索引的方式来 import。
|
||||
|
||||
明白了这一点后,这次我们使用 PyCharm 来创建一个项目。这个项目结构如下所示:
|
||||
|
||||
.
|
||||
├── proto
|
||||
│ ├── mat.py
|
||||
├── utils
|
||||
│ └── mat_mul.py
|
||||
└── src
|
||||
└── main.py
|
||||
|
||||
|
||||
# proto/mat.py
|
||||
|
||||
class Matrix(object):
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
self.n = len(data)
|
||||
self.m = len(data[0])
|
||||
|
||||
|
||||
# utils/mat_mul.py
|
||||
|
||||
from proto.mat import Matrix
|
||||
|
||||
def mat_mul(matrix_1: Matrix, matrix_2: Matrix):
|
||||
assert matrix_1.m == matrix_2.n
|
||||
n, m, s = matrix_1.n, matrix_1.m, matrix_2.m
|
||||
result = [[0 for _ in range(n)] for _ in range(s)]
|
||||
for i in range(n):
|
||||
for j in range(s):
|
||||
for k in range(m):
|
||||
result[i][k] += matrix_1.data[i][j] * matrix_2.data[j][k]
|
||||
|
||||
return Matrix(result)
|
||||
|
||||
|
||||
# src/main.py
|
||||
|
||||
from proto.mat import Matrix
|
||||
from utils.mat_mul import mat_mul
|
||||
|
||||
|
||||
a = Matrix([[1, 2], [3, 4]])
|
||||
b = Matrix([[5, 6], [7, 8]])
|
||||
|
||||
print(mat_mul(a, b).data)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
[[19, 22], [43, 50]]
|
||||
|
||||
|
||||
这个例子和前面的例子长得很像,但请注意 utils/mat_mul.py,你会发现,它 import Matrix 的方式是from proto.mat。这种做法,直接从项目根目录中导入,并依次向下导入模块 mat.py 中的 Matrix,而不是使用 .. 导入上一级文件夹。
|
||||
|
||||
是不是很简单呢?对于接下来的所有项目,你都能直接使用 Pycharm 来构建。把不同模块放在不同子文件夹里,跨模块调用则是从顶层直接索引,一步到位,非常方便。
|
||||
|
||||
我猜,这时你的好奇心来了。你尝试使用命令行进入 src 文件夹,直接输入 Python main.py,报错,找不到 proto。你不甘心,退回到上一级目录,输入Python src/main.py,继续报错,找不到 proto。
|
||||
|
||||
Pycharm 用了什么黑魔法呢?
|
||||
|
||||
实际上,Python 解释器在遇到 import 的时候,它会在一个特定的列表中寻找模块。这个特定的列表,可以用下面的方式拿到:
|
||||
|
||||
import sys
|
||||
|
||||
print(sys.path)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
['', '/usr/lib/python36.zip', '/usr/lib/python3.6', '/usr/lib/python3.6/lib-dynload', '/usr/local/lib/python3.6/dist-packages', '/usr/lib/python3/dist-packages']
|
||||
|
||||
|
||||
请注意,它的第一项为空。其实,Pycharm 做的一件事,就是将第一项设置为项目根目录的绝对地址。这样,每次你无论怎么运行 main.py,import 函数在执行的时候,都会去项目根目录中找相应的包。
|
||||
|
||||
你说,你想修改下,使得普通的 Python 运行环境也能做到?这里有两种方法可以做到:
|
||||
|
||||
import sys
|
||||
|
||||
sys.path[0] = '/home/ubuntu/workspace/your_projects'
|
||||
|
||||
|
||||
第一种方法,“大力出奇迹”,我们可以强行修改这个位置,这样,你的 import 接下来肯定就畅通无阻了。但这显然不是最佳解决方案,把绝对路径写到代码里,是我非常不推荐的方式(你可以写到配置文件中,但找配置文件也需要路径寻找,于是就会进入无解的死循环)。
|
||||
|
||||
第二种方法,是修改 PYTHONHOME。这里我稍微提一下 Python 的 Virtual Environment(虚拟运行环境)。Python 可以通过 Virtualenv 工具,非常方便地创建一个全新的 Python 运行环境。
|
||||
|
||||
事实上,我们提倡,对于每一个项目来说,最好要有一个独立的运行环境来保持包和模块的纯净性。更深的内容超出了今天的范围,你可以自己查资料了解。
|
||||
|
||||
回到第二种修改方法上。在一个 Virtual Environment 里,你能找到一个文件叫 activate,在这个文件的末尾,填上下面的内容:
|
||||
|
||||
export PYTHONPATH="/home/ubuntu/workspace/your_projects"
|
||||
|
||||
|
||||
这样,每次你通过 activate 激活这个运行时环境的时候,它就会自动将项目的根目录添加到搜索路径中去。
|
||||
|
||||
神奇的 if __name__ == '__main__'
|
||||
|
||||
最后一部分,我们再来讲讲 if __name__ == '__main__' ,这个我们经常看到的写法。
|
||||
|
||||
Python 是脚本语言,和 C++、Java 最大的不同在于,不需要显式提供 main() 函数入口。如果你有 C++、Java 等语言经验,应该对 main() {} 这样的结构很熟悉吧?
|
||||
|
||||
不过,既然 Python 可以直接写代码,if __name__ == '__main__' 这样的写法,除了能让 Python 代码更好看(更像 C++ )外,还有什么好处吗?
|
||||
|
||||
项目结构如下:
|
||||
|
||||
.
|
||||
├── utils.py
|
||||
├── utils_with_main.py
|
||||
├── main.py
|
||||
└── main_2.py
|
||||
|
||||
|
||||
# utils.py
|
||||
|
||||
def get_sum(a, b):
|
||||
return a + b
|
||||
|
||||
print('testing')
|
||||
print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
|
||||
|
||||
|
||||
# utils_with_main.py
|
||||
|
||||
def get_sum(a, b):
|
||||
return a + b
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('testing')
|
||||
print('{} + {} = {}'.format(1, 2, get_sum(1, 2)))
|
||||
|
||||
|
||||
# main.py
|
||||
|
||||
from utils import get_sum
|
||||
|
||||
print('get_sum: ', get_sum(1, 2))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
testing
|
||||
1 + 2 = 3
|
||||
get_sum: 3
|
||||
|
||||
|
||||
# main_2.py
|
||||
|
||||
from utils_with_main import get_sum
|
||||
|
||||
print('get_sum: ', get_sum(1, 2))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
get_sum_2: 3
|
||||
|
||||
|
||||
看到这个项目结构,你就很清晰了吧。
|
||||
|
||||
import 在导入文件的时候,会自动把所有暴露在外面的代码全都执行一遍。因此,如果你要把一个东西封装成模块,又想让它可以执行的话,你必须将要执行的代码放在 if __name__ == '__main__'下面。
|
||||
|
||||
为什么呢?其实,__name__ 作为 Python 的魔术内置参数,本质上是模块对象的一个属性。我们使用 import 语句时,__name__ 就会被赋值为该模块的名字,自然就不等于 __main__了。更深的原理我就不做过多介绍了,你只需要明白这个知识点即可。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我为你讲述了如何使用 Python 来构建模块化和大型工程。这里需要强调几点:
|
||||
|
||||
|
||||
通过绝对路径和相对路径,我们可以 import 模块;
|
||||
在大型工程中模块化非常重要,模块的索引要通过绝对路径来做,而绝对路径从程序的根目录开始;
|
||||
记着巧用if __name__ == '__main__'来避开 import 时执行。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,我想为你留一道思考题。from module_name import *和import module_name有什么区别呢?欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
208
专栏/Python核心技术与实战/14答疑(一):列表和元组的内部实现是怎样的?.md
Normal file
208
专栏/Python核心技术与实战/14答疑(一):列表和元组的内部实现是怎样的?.md
Normal file
@ -0,0 +1,208 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 答疑(一):列表和元组的内部实现是怎样的?
|
||||
你好,我是景霄。
|
||||
|
||||
转眼间,专栏上线已经一个月了,而我们也在不知不觉中完成了第一大章基础篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
|
||||
|
||||
大部分留言,我都在相对应的文章中回复过了。而一些手机上不方便回复,或是很有价值很典型的问题,我专门摘录了出来,作为今天的答疑内容,集中回复。
|
||||
|
||||
问题一:列表和元组的内部实现
|
||||
|
||||
第一个问题,是胡峣同学提出的,有关列表(list)和元组(tuple)的内部实现,想知道里边是linked list 或array,还是把array linked一下这样的方式?
|
||||
|
||||
|
||||
|
||||
关于这个问题,我们可以分别从源码来看。
|
||||
|
||||
先来看 Python 3.7 的list源码。你可以先自己阅读下面两个链接里的内容。
|
||||
|
||||
listobject.h:https://github.com/python/cpython/blob/949fe976d5c62ae63ed505ecf729f815d0baccfc/Include/listobject.h#L23
|
||||
|
||||
listobject.c: https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/listobject.c#L33
|
||||
|
||||
我把 list的具体结构放在了下面:
|
||||
|
||||
|
||||
|
||||
可以看到,list本质上是一个over-allocate的array。其中,ob_item是一个指针列表,里面的每一个指针都指向列表的元素。而 allocated则存储了这个列表已经被分配的空间大小。
|
||||
|
||||
需要注意的是,allocated 与列表实际空间大小的区别。列表实际空间大小,是指len(list)返回的结果,即上述代码注释中的ob_size,表示这个列表总共存储了多少个元素。实际情况下,为了优化存储结构,避免每次增加元素都要重新分配内存,列表预分配的空间allocated往往会大于ob_size(详见正文中的例子)。
|
||||
|
||||
所以,它们的关系为:allocated >= len(list) = ob_size。
|
||||
|
||||
如果当前列表分配的空间已满(即allocated == len(list)),则会向系统请求更大的内存空间,并把原来的元素全部拷贝过去。列表每次分配空间的大小,遵循下面的模式:
|
||||
|
||||
0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
|
||||
|
||||
|
||||
我们再来分析元组。下面是Python 3.7 的tuple源码,同样的,你可以先自己阅读一下。
|
||||
|
||||
tupleobject.h: https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Include/tupleobject.h#L25
|
||||
|
||||
tupleobject.c:https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/tupleobject.c#L16
|
||||
|
||||
同样的,下面为tuple的具体结构:
|
||||
|
||||
|
||||
|
||||
你可以看到,它和list相似,本质也是一个array,但是空间大小固定。不同于一般array,Python的tuple做了许多优化,来提升在程序中的效率。
|
||||
|
||||
举个例子,当tuple的大小不超过20时,Python就会把它缓存在内部的一个free list中。这样,如果你以后需要再去创建同样的tuple,Python就可以直接从缓存中载入,提高了程序运行效率。
|
||||
|
||||
问题二:为什么在旧哈希表中,元素会越来越稀疏?
|
||||
|
||||
第二个问题,是Hoo同学提出的,为什么在旧哈希表中,元素会越来越稀疏?
|
||||
|
||||
|
||||
|
||||
我们可以先来看旧哈希表的示意图:
|
||||
|
||||
--+-------------------------------+
|
||||
| 哈希值 (hash) 键 (key) 值 (value)
|
||||
--+-------------------------------+
|
||||
0 | hash0 key0 value0
|
||||
--+-------------------------------+
|
||||
1 | hash1 key1 value1
|
||||
--+-------------------------------+
|
||||
2 | hash2 key2 value2
|
||||
--+-------------------------------+
|
||||
. | ...
|
||||
__+_______________________________+
|
||||
|
||||
|
||||
你会发现,它是一个over-allocate的array,根据元素键(key)的哈希值,来计算其应该被插入位置的索引。
|
||||
|
||||
因此,假设我有下面这样一个字典:
|
||||
|
||||
{'name': 'mike', 'dob': '1999-01-01', 'gender': 'male'}
|
||||
|
||||
|
||||
那么这个字典便会存储为类似下面的形式:
|
||||
|
||||
entries = [
|
||||
['--', '--', '--']
|
||||
[-230273521, 'dob', '1999-01-01'],
|
||||
['--', '--', '--'],
|
||||
['--', '--', '--'],
|
||||
[1231236123, 'name', 'mike'],
|
||||
['--', '--', '--'],
|
||||
[9371539127, 'gender', 'male']
|
||||
]
|
||||
|
||||
|
||||
这里的’---‘,表示这个位置没有元素,但是已经分配了内存。
|
||||
|
||||
我们知道,当哈希表剩余空间小于1/3时,为了保证相关操作的高效性并避免哈希冲突,就会重新分配更大的内存。所以,当哈希表中的元素越来越多时,分配了内存但里面没有元素的位置,也会变得越来越多。这样一来,哈希表便会越来越稀疏。
|
||||
|
||||
而新哈希表的结构,改变了这一点,也大大提高了空间的利用率。新哈希表的结构如下所示:
|
||||
|
||||
Indices
|
||||
----------------------------------------------------
|
||||
None | index | None | None | index | None | index ...
|
||||
----------------------------------------------------
|
||||
|
||||
|
||||
Entries
|
||||
--------------------
|
||||
hash0 key0 value0
|
||||
---------------------
|
||||
hash1 key1 value1
|
||||
---------------------
|
||||
hash2 key2 value2
|
||||
---------------------
|
||||
...
|
||||
---------------------
|
||||
|
||||
|
||||
你可以看到,它把存储结构分成了Indices和Entries这两个array,而’None‘代表这个位置分配了内存但没有元素。
|
||||
|
||||
我们同样还用上面这个例子,它在新哈希表中的存储模式,就会变为下面这样:
|
||||
|
||||
indices = [None, 1, None, None, 0, None, 2]
|
||||
entries = [
|
||||
[1231236123, 'name', 'mike'],
|
||||
[-230273521, 'dob', '1999-01-01'],
|
||||
[9371539127, 'gender', 'male']
|
||||
]
|
||||
|
||||
|
||||
其中,Indices中元素的值,对应entries中相应的索引。比如indices中的1,就对应着entries[1],即’'dob': '1999-01-01'‘。
|
||||
|
||||
对比之下,我们会清晰感受到,新哈希表中的空间利用率,相比于旧哈希表有大大的提升。
|
||||
|
||||
问题三:有关异常的困扰
|
||||
|
||||
第三个问题,是“不瘦到140不改名”同学提出的,对“NameError”异常的困惑。这是很常见的一个错误,我在这里也解释一下。
|
||||
|
||||
|
||||
|
||||
这个问题其实有点tricky,如果你查阅官方文档,会看到这么一句话”When an exception has been assigned using as target, it is cleared at the end of the except clause. ”
|
||||
|
||||
这句话意思是,如果你在异常处理的except block中,把异常赋予了一个变量,那么这个变量会在except block执行结束时被删除,相当于下面这样的表示:
|
||||
|
||||
e = 1
|
||||
try:
|
||||
1 / 0
|
||||
except ZeroDivisionError as e:
|
||||
try:
|
||||
pass
|
||||
finally:
|
||||
del e
|
||||
|
||||
|
||||
这里的e一开始指向整数1,但是在except block结束时被删除了(del e),所以程序执行就会抛出“NameError”的异常。
|
||||
|
||||
因此,这里提醒我们,在平时写代码时,一定要保证except中异常赋予的变量,在之后的语句中不再被用到。
|
||||
|
||||
问题四:关于多态和全局变量的修改
|
||||
|
||||
最后的问题来自于farFlight同学,他提了两个问题:
|
||||
|
||||
|
||||
Python自己判断类型的多态和子类继承的多态Polymorphism是否相同?
|
||||
函数内部不能直接用+=等修改全局变量,但是对于list全局变量,却可以使用append、extend之类修改,这是为什么呢?
|
||||
|
||||
|
||||
|
||||
|
||||
我们分别来看这两个问题。对于第一个问题,要搞清楚多态的概念,多态是指有多种不同的形式。因此,判断类型的多态和子类继承的多态,在本质上都是一样的,只不过你可以把它们理解为多态的两种不同表现。
|
||||
|
||||
再来看第二个问题。当全局变量指向的对象不可变时,比如是整型、字符串等等,如果你尝试在函数内部改变它的值,却不加关键字global,就会抛出异常:
|
||||
|
||||
x = 1
|
||||
|
||||
def func():
|
||||
x += 1
|
||||
func()
|
||||
x
|
||||
|
||||
## 输出
|
||||
UnboundLocalError: local variable 'x' referenced before assignment
|
||||
|
||||
|
||||
这是因为,程序默认函数内部的x是局部变量,而你没有为其赋值就直接引用,显然是不可行。
|
||||
|
||||
不过,如果全局变量指向的对象是可变的,比如是列表、字典等等,你就可以在函数内部修改它了:
|
||||
|
||||
x = [1]
|
||||
|
||||
def func():
|
||||
x.append(2)
|
||||
func()
|
||||
x
|
||||
|
||||
## 输出
|
||||
[1, 2]
|
||||
|
||||
|
||||
当然,需要注意的是,这里的x.append(2),并没有改变变量x,x依然指向原来的列表。事实上,这句话的意思是,访问x指向的列表,并在这个列表的末尾增加2。
|
||||
|
||||
今天主要回答这些问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望每一次的留言和答疑,都能给你带来新的收获和价值。
|
||||
|
||||
|
||||
|
||||
|
302
专栏/Python核心技术与实战/15Python对象的比较、拷贝.md
Normal file
302
专栏/Python核心技术与实战/15Python对象的比较、拷贝.md
Normal file
@ -0,0 +1,302 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 Python对象的比较、拷贝
|
||||
你好,我是景霄。
|
||||
|
||||
在前面的学习中,我们其实已经接触到了很多 Python对象比较和复制的例子,比如下面这个,判断a和b是否相等的if语句:
|
||||
|
||||
if a == b:
|
||||
...
|
||||
|
||||
|
||||
再比如第二个例子,这里l2就是l1的拷贝。
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l2 = list(l1)
|
||||
|
||||
|
||||
但你可能并不清楚,这些语句的背后发生了什么。比如,
|
||||
|
||||
|
||||
l2是l1的浅拷贝(shallow copy)还是深度拷贝(deep copy)呢?
|
||||
a == b是比较两个对象的值相等,还是两个对象完全相等呢?
|
||||
|
||||
|
||||
关于这些的种种知识,我希望通过这节课的学习,让你有个全面的了解。
|
||||
|
||||
'==' VS 'is'
|
||||
|
||||
等于(==)和is是Python中对象比较常用的两种方式。简单来说,'=='操作符比较对象之间的值是否相等,比如下面的例子,表示比较变量a和b所指向的值是否相等。
|
||||
|
||||
a == b
|
||||
|
||||
|
||||
而'is'操作符比较的是对象的身份标识是否相等,即它们是否是同一个对象,是否指向同一个内存地址。
|
||||
|
||||
在Python中,每个对象的身份标识,都能通过函数id(object)获得。因此,'is'操作符,相当于比较对象之间的ID是否相等,我们来看下面的例子:
|
||||
|
||||
a = 10
|
||||
b = 10
|
||||
|
||||
a == b
|
||||
True
|
||||
|
||||
id(a)
|
||||
4427562448
|
||||
|
||||
id(b)
|
||||
4427562448
|
||||
|
||||
a is b
|
||||
True
|
||||
|
||||
|
||||
这里,首先Python会为10这个值开辟一块内存,然后变量a和b同时指向这块内存区域,即a和b都是指向10这个变量,因此a和b的值相等,id也相等,a == b和a is b都返回True。
|
||||
|
||||
不过,需要注意,对于整型数字来说,以上a is b为True的结论,只适用于-5到256范围内的数字。比如下面这个例子:
|
||||
|
||||
a = 257
|
||||
b = 257
|
||||
|
||||
a == b
|
||||
True
|
||||
|
||||
id(a)
|
||||
4473417552
|
||||
|
||||
id(b)
|
||||
4473417584
|
||||
|
||||
a is b
|
||||
False
|
||||
|
||||
|
||||
这里我们把257同时赋值给了a和b,可以看到a == b仍然返回True,因为a和b指向的值相等。但奇怪的是,a is b返回了false,并且我们发现,a和b的ID不一样了,这是为什么呢?
|
||||
|
||||
事实上,出于对性能优化的考虑,Python内部会对-5到256的整型维持一个数组,起到一个缓存的作用。这样,每次你试图创建一个-5到256范围内的整型数字时,Python都会从这个数组中返回相对应的引用,而不是重新开辟一块新的内存空间。
|
||||
|
||||
但是,如果整型数字超过了这个范围,比如上述例子中的257,Python则会为两个257开辟两块内存区域,因此a和b的ID不一样,a is b就会返回False了。
|
||||
|
||||
通常来说,在实际工作中,当我们比较变量时,使用'=='的次数会比'is'多得多,因为我们一般更关心两个变量的值,而不是它们内部的存储地址。但是,当我们比较一个变量与一个单例(singleton)时,通常会使用'is'。一个典型的例子,就是检查一个变量是否为None:
|
||||
|
||||
if a is None:
|
||||
...
|
||||
|
||||
if a is not None:
|
||||
...
|
||||
|
||||
|
||||
这里注意,比较操作符'is'的速度效率,通常要优于'=='。因为'is'操作符不能被重载,这样,Python就不需要去寻找,程序中是否有其他地方重载了比较操作符,并去调用。执行比较操作符'is',就仅仅是比较两个变量的ID而已。
|
||||
|
||||
但是'=='操作符却不同,执行a == b相当于是去执行a.__eq__(b),而Python大部分的数据类型都会去重载__eq__这个函数,其内部的处理通常会复杂一些。比如,对于列表,__eq__函数会去遍历列表中的元素,比较它们的顺序和值是否相等。
|
||||
|
||||
不过,对于不可变(immutable)的变量,如果我们之前用'=='或者'is'比较过,结果是不是就一直不变了呢?
|
||||
|
||||
答案自然是否定的。我们来看下面一个例子:
|
||||
|
||||
t1 = (1, 2, [3, 4])
|
||||
t2 = (1, 2, [3, 4])
|
||||
t1 == t2
|
||||
True
|
||||
|
||||
t1[-1].append(5)
|
||||
t1 == t2
|
||||
False
|
||||
|
||||
|
||||
我们知道元组是不可变的,但元组可以嵌套,它里面的元素可以是列表类型,列表是可变的,所以如果我们修改了元组中的某个可变元素,那么元组本身也就改变了,之前用'is'或者'=='操作符取得的结果,可能就不适用了。
|
||||
|
||||
这一点,你在日常写程序时一定要注意,在必要的地方请不要省略条件检查。
|
||||
|
||||
浅拷贝和深度拷贝
|
||||
|
||||
接下来,我们一起来看看Python中的浅拷贝(shallow copy)和深度拷贝(deep copy)。
|
||||
|
||||
对于这两个熟悉的操作,我并不想一上来先抛概念让你死记硬背来区分,我们不妨先从它们的操作方法说起,通过代码来理解两者的不同。
|
||||
|
||||
先来看浅拷贝。常见的浅拷贝的方法,是使用数据类型本身的构造器,比如下面两个例子:
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l2 = list(l1)
|
||||
|
||||
l2
|
||||
[1, 2, 3]
|
||||
|
||||
l1 == l2
|
||||
True
|
||||
|
||||
l1 is l2
|
||||
False
|
||||
|
||||
s1 = set([1, 2, 3])
|
||||
s2 = set(s1)
|
||||
|
||||
s2
|
||||
{1, 2, 3}
|
||||
|
||||
s1 == s2
|
||||
True
|
||||
|
||||
s1 is s2
|
||||
False
|
||||
|
||||
|
||||
这里,l2就是l1的浅拷贝,s2是s1的浅拷贝。当然,对于可变的序列,我们还可以通过切片操作符':'完成浅拷贝,比如下面这个列表的例子:
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l2 = l1[:]
|
||||
|
||||
l1 == l2
|
||||
True
|
||||
|
||||
l1 is l2
|
||||
False
|
||||
|
||||
|
||||
当然,Python中也提供了相对应的函数copy.copy(),适用于任何数据类型:
|
||||
|
||||
import copy
|
||||
l1 = [1, 2, 3]
|
||||
l2 = copy.copy(l1)
|
||||
|
||||
|
||||
不过,需要注意的是,对于元组,使用tuple()或者切片操作符':'不会创建一份浅拷贝,相反,它会返回一个指向相同元组的引用:
|
||||
|
||||
t1 = (1, 2, 3)
|
||||
t2 = tuple(t1)
|
||||
|
||||
t1 == t2
|
||||
True
|
||||
|
||||
t1 is t2
|
||||
True
|
||||
|
||||
|
||||
这里,元组(1, 2, 3)只被创建一次,t1和t2同时指向这个元组。
|
||||
|
||||
到这里,对于浅拷贝你应该很清楚了。浅拷贝,是指重新分配一块内存,创建一个新的对象,里面的元素是原对象中子对象的引用。因此,如果原对象中的元素不可变,那倒无所谓;但如果元素可变,浅拷贝通常会带来一些副作用,尤其需要注意。我们来看下面的例子:
|
||||
|
||||
l1 = [[1, 2], (30, 40)]
|
||||
l2 = list(l1)
|
||||
l1.append(100)
|
||||
l1[0].append(3)
|
||||
|
||||
l1
|
||||
[[1, 2, 3], (30, 40), 100]
|
||||
|
||||
l2
|
||||
[[1, 2, 3], (30, 40)]
|
||||
|
||||
l1[1] += (50, 60)
|
||||
l1
|
||||
[[1, 2, 3], (30, 40, 50, 60), 100]
|
||||
|
||||
l2
|
||||
[[1, 2, 3], (30, 40)]
|
||||
|
||||
|
||||
这个例子中,我们首先初始化了一个列表l1,里面的元素是一个列表和一个元组;然后对l1执行浅拷贝,赋予l2。因为浅拷贝里的元素是对原对象元素的引用,因此l2中的元素和l1指向同一个列表和元组对象。
|
||||
|
||||
接着往下看。l1.append(100),表示对l1的列表新增元素100。这个操作不会对l2产生任何影响,因为l2和l1作为整体是两个不同的对象,并不共享内存地址。操作过后l2不变,l1会发生改变:
|
||||
|
||||
[[1, 2, 3], (30, 40), 100]
|
||||
|
||||
|
||||
再来看,l1[0].append(3),这里表示对l1中的第一个列表新增元素3。因为l2是l1的浅拷贝,l2中的第一个元素和l1中的第一个元素,共同指向同一个列表,因此l2中的第一个列表也会相对应的新增元素3。操作后l1和l2都会改变:
|
||||
|
||||
l1: [[1, 2, 3], (30, 40), 100]
|
||||
l2: [[1, 2, 3], (30, 40)]
|
||||
|
||||
|
||||
最后是l1[1] += (50, 60),因为元组是不可变的,这里表示对l1中的第二个元组拼接,然后重新创建了一个新元组作为l1中的第二个元素,而l2中没有引用新元组,因此l2并不受影响。操作后l2不变,l1发生改变:
|
||||
|
||||
l1: [[1, 2, 3], (30, 40, 50, 60), 100]
|
||||
|
||||
|
||||
通过这个例子,你可以很清楚地看到使用浅拷贝可能带来的副作用。因此,如果我们想避免这种副作用,完整地拷贝一个对象,你就得使用深度拷贝。
|
||||
|
||||
所谓深度拷贝,是指重新分配一块内存,创建一个新的对象,并且将原对象中的元素,以递归的方式,通过创建新的子对象拷贝到新对象中。因此,新对象和原对象没有任何关联。
|
||||
|
||||
Python中以copy.deepcopy()来实现对象的深度拷贝。比如上述例子写成下面的形式,就是深度拷贝:
|
||||
|
||||
import copy
|
||||
l1 = [[1, 2], (30, 40)]
|
||||
l2 = copy.deepcopy(l1)
|
||||
l1.append(100)
|
||||
l1[0].append(3)
|
||||
|
||||
l1
|
||||
[[1, 2, 3], (30, 40), 100]
|
||||
|
||||
l2
|
||||
[[1, 2], (30, 40)]
|
||||
|
||||
|
||||
我们可以看到,无论l1如何变化,l2都不变。因为此时的l1和l2完全独立,没有任何联系。
|
||||
|
||||
不过,深度拷贝也不是完美的,往往也会带来一系列问题。如果被拷贝对象中存在指向自身的引用,那么程序很容易陷入无限循环:
|
||||
|
||||
import copy
|
||||
x = [1]
|
||||
x.append(x)
|
||||
|
||||
x
|
||||
[1, [...]]
|
||||
|
||||
y = copy.deepcopy(x)
|
||||
y
|
||||
[1, [...]]
|
||||
|
||||
|
||||
上面这个例子,列表x中有指向自身的引用,因此x是一个无限嵌套的列表。但是我们发现深度拷贝x到y后,程序并没有出现stack overflow的现象。这是为什么呢?
|
||||
|
||||
其实,这是因为深度拷贝函数deepcopy中会维护一个字典,记录已经拷贝的对象与其ID。拷贝过程中,如果字典里已经存储了将要拷贝的对象,则会从字典直接返回,我们来看相对应的源码就能明白:
|
||||
|
||||
def deepcopy(x, memo=None, _nil=[]):
|
||||
"""Deep copy operation on arbitrary Python objects.
|
||||
|
||||
See the module's __doc__ string for more info.
|
||||
"""
|
||||
|
||||
if memo is None:
|
||||
memo = {}
|
||||
d = id(x) # 查询被拷贝对象x的id
|
||||
y = memo.get(d, _nil) # 查询字典里是否已经存储了该对象
|
||||
if y is not _nil:
|
||||
return y # 如果字典里已经存储了将要拷贝的对象,则直接返回
|
||||
...
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们一起学习了Python中对象的比较和拷贝,主要有下面几个重点内容。
|
||||
|
||||
|
||||
比较操作符'=='表示比较对象间的值是否相等,而'is'表示比较对象的标识是否相等,即它们是否指向同一个内存地址。
|
||||
比较操作符'is'效率优于'==',因为'is'操作符无法被重载,执行'is'操作只是简单的获取对象的ID,并进行比较;而'=='操作符则会递归地遍历对象的所有值,并逐一比较。
|
||||
浅拷贝中的元素,是原对象中子对象的引用,因此,如果原对象中的元素是可变的,改变其也会影响拷贝后的对象,存在一定的副作用。
|
||||
深度拷贝则会递归地拷贝原对象中的每一个子对象,因此拷贝后的对象和原对象互不相关。另外,深度拷贝中会维护一个字典,记录已经拷贝的对象及其ID,来提高效率并防止无限递归的发生。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后,我为你留下一道思考题。这节课我曾用深度拷贝,拷贝过一个无限嵌套的列表。那么。当我们用等于操作符'=='进行比较时,输出会是什么呢?是True或者False还是其他?为什么呢?建议你先自己动脑想一想,然后再实际跑一下代码,来检验你的猜想。
|
||||
|
||||
import copy
|
||||
x = [1]
|
||||
x.append(x)
|
||||
|
||||
y = copy.deepcopy(x)
|
||||
|
||||
# 以下命令的输出是?
|
||||
x == y
|
||||
|
||||
|
||||
欢迎在留言区写下你的答案和学习感想,也欢迎你把这篇文章分享给你的同事、朋友。我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
267
专栏/Python核心技术与实战/16值传递,引用传递or其他,Python里参数是如何传递的?.md
Normal file
267
专栏/Python核心技术与实战/16值传递,引用传递or其他,Python里参数是如何传递的?.md
Normal file
@ -0,0 +1,267 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 值传递,引用传递or其他,Python里参数是如何传递的?
|
||||
你好,我是景霄。
|
||||
|
||||
在前面的第一大章节中,我们一起学习了Python的函数基础及其应用。我们大致明白了,所谓的传参,就是把一些参数从一个函数传递到另一个函数,从而使其执行相应的任务。但是你有没有想过,参数传递的底层是如何工作的,原理又是怎样的呢?
|
||||
|
||||
实际工作中,很多人会遇到这样的场景:写完了代码,一测试,发现结果和自己期望的不一样,于是开始一层层地debug。花了很多时间,可到最后才发现,是传参过程中数据结构的改变,导致了程序的“出错”。
|
||||
|
||||
比如,我将一个列表作为参数传入另一个函数,期望列表在函数运行结束后不变,但是往往“事与愿违”,由于某些操作,它的值改变了,那就很有可能带来后续程序一系列的错误。
|
||||
|
||||
因此,了解Python中参数的传递机制,具有十分重要的意义,这往往能让我们写代码时少犯错误,提高效率。今天我们就一起来学习一下,Python中参数是如何传递的。
|
||||
|
||||
什么是值传递和引用传递
|
||||
|
||||
如果你接触过其他的编程语言,比如C/C++,很容易想到,常见的参数传递有2种:值传递和引用传递。所谓值传递,通常就是拷贝参数的值,然后传递给函数里的新变量。这样,原变量和新变量之间互相独立,互不影响。
|
||||
|
||||
比如,我们来看下面的一段C++代码:
|
||||
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
// 交换2个变量的值
|
||||
void swap(int x, int y) {
|
||||
int temp;
|
||||
temp = x; // 交换x和y的值
|
||||
x = y;
|
||||
y = temp;
|
||||
return;
|
||||
}
|
||||
int main () {
|
||||
int a = 1;
|
||||
int b = 2;
|
||||
cout << "Before swap, value of a :" << a << endl;
|
||||
cout << "Before swap, value of b :" << b << endl;
|
||||
swap(a, b);
|
||||
cout << "After swap, value of a :" << a << endl;
|
||||
cout << "After swap, value of b :" << b << endl;
|
||||
return 0;
|
||||
}
|
||||
Before swap, value of a :1
|
||||
Before swap, value of b :2
|
||||
After swap, value of a :1
|
||||
After swap, value of b :2
|
||||
|
||||
|
||||
这里的swap()函数,把a和b的值拷贝给了x和y,然后再交换x和y的值。这样一来,x和y的值发生了改变,但是a和b不受其影响,所以值不变。这种方式,就是我们所说的值传递。
|
||||
|
||||
所谓引用传递,通常是指把参数的引用传给新的变量,这样,原变量和新变量就会指向同一块内存地址。如果改变了其中任何一个变量的值,那么另外一个变量也会相应地随之改变。
|
||||
|
||||
还是拿我们刚刚讲到的C++代码为例,上述例子中的swap()函数,如果改成下面的形式,声明引用类型的参数变量:
|
||||
|
||||
void swap(int& x, int& y) {
|
||||
int temp;
|
||||
temp = x; // 交换x和y的值
|
||||
x = y;
|
||||
y = temp;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
那么输出的便是另一个结果:
|
||||
|
||||
Before swap, value of a :1
|
||||
Before swap, value of b :2
|
||||
After swap, value of a :2
|
||||
After swap, value of b :1
|
||||
|
||||
|
||||
原变量a和b的值被交换了,因为引用传递使得a和x,b和y一模一样,对x和y的任何改变必然导致了a和b的相应改变。
|
||||
|
||||
不过,这是C/C++语言中的特点。那么Python中,参数传递到底是如何进行的呢?它们到底属于值传递、引用传递,还是其他呢?
|
||||
|
||||
在回答这个问题之前,让我们先来了解一下,Python变量和赋值的基本原理。
|
||||
|
||||
Python变量及其赋值
|
||||
|
||||
我们首先来看,下面的Python代码示例:
|
||||
|
||||
a = 1
|
||||
b = a
|
||||
a = a + 1
|
||||
|
||||
|
||||
这里首先将1赋值于a,即a指向了1这个对象,如下面的流程图所示:
|
||||
|
||||
|
||||
|
||||
接着b = a则表示,让变量b也同时指向1这个对象。这里要注意,Python里的对象可以被多个变量所指向或引用。
|
||||
|
||||
|
||||
|
||||
最后执行a = a + 1。需要注意的是,Python的数据类型,例如整型(int)、字符串(string)等等,是不可变的。所以,a = a + 1,并不是让a的值增加1,而是表示重新创建了一个新的值为2的对象,并让a指向它。但是b仍然不变,仍然指向1这个对象。
|
||||
|
||||
因此,最后的结果是,a的值变成了2,而b的值不变仍然是1。
|
||||
|
||||
|
||||
|
||||
通过这个例子你可以看到,这里的a和b,开始只是两个指向同一个对象的变量而已,或者你也可以把它们想象成同一个对象的两个名字。简单的赋值b = a,并不表示重新创建了新对象,只是让同一个对象被多个变量指向或引用。
|
||||
|
||||
同时,指向同一个对象,也并不意味着两个变量就被绑定到了一起。如果你给其中一个变量重新赋值,并不会影响其他变量的值。
|
||||
|
||||
明白了这个基本的变量赋值例子,我们再来看一个列表的例子:
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l2 = l1
|
||||
l1.append(4)
|
||||
l1
|
||||
[1, 2, 3, 4]
|
||||
l2
|
||||
[1, 2, 3, 4]
|
||||
|
||||
|
||||
同样的,我们首先让列表l1和l2同时指向了[1, 2, 3]这个对象。
|
||||
|
||||
|
||||
|
||||
由于列表是可变的,所以l1.append(4)不会创建新的列表,只是在原列表的末尾插入了元素4,变成[1, 2, 3, 4]。由于l1和l2同时指向这个列表,所以列表的变化会同时反映在l1和l2这两个变量上,那么,l1和l2的值就同时变为了[1, 2, 3, 4]。
|
||||
|
||||
|
||||
|
||||
另外,需要注意的是,Python里的变量可以被删除,但是对象无法被删除。比如下面的代码:
|
||||
|
||||
l = [1, 2, 3]
|
||||
del l
|
||||
|
||||
|
||||
del l 删除了l这个变量,从此以后你无法访问l,但是对象[1, 2, 3]仍然存在。Python程序运行时,其自带的垃圾回收系统会跟踪每个对象的引用。如果[1, 2, 3]除了l外,还在其他地方被引用,那就不会被回收,反之则会被回收。
|
||||
|
||||
由此可见,在Python中:
|
||||
|
||||
|
||||
变量的赋值,只是表示让变量指向了某个对象,并不表示拷贝对象给变量;而一个对象,可以被多个变量所指向。
|
||||
可变对象(列表,字典,集合等等)的改变,会影响所有指向该对象的变量。
|
||||
对于不可变对象(字符串、整型、元组等等),所有指向该对象的变量的值总是一样的,也不会改变。但是通过某些操作(+=等等)更新不可变对象的值时,会返回一个新的对象。
|
||||
变量可以被删除,但是对象无法被删除。
|
||||
|
||||
|
||||
Python函数的参数传递
|
||||
|
||||
从上述Python变量的命名与赋值的原理讲解中,相信你能举一反三,大概猜出Python函数中参数是如何传递了吧?
|
||||
|
||||
这里首先引用Python官方文档中的一段说明:
|
||||
|
||||
|
||||
“Remember that arguments are passed by assignment in Python. Since assignment just creates references to objects, there’s no alias between an argument name in the caller and callee, and so no call-by-reference per Se.”
|
||||
|
||||
|
||||
准确地说,Python的参数传递是赋值传递 (pass by assignment),或者叫作对象的引用传递(pass by object reference)。Python里所有的数据类型都是对象,所以参数传递时,只是让新变量与原变量指向相同的对象而已,并不存在值传递或是引用传递一说。
|
||||
|
||||
比如,我们来看下面这个例子:
|
||||
|
||||
def my_func1(b):
|
||||
b = 2
|
||||
|
||||
a = 1
|
||||
my_func1(a)
|
||||
a
|
||||
1
|
||||
|
||||
|
||||
这里的参数传递,使变量a和b同时指向了1这个对象。但当我们执行到b = 2时,系统会重新创建一个值为2的新对象,并让b指向它;而a仍然指向1这个对象。所以,a的值不变,仍然为1。
|
||||
|
||||
那么对于上述例子的情况,是不是就没有办法改变a的值了呢?
|
||||
|
||||
答案当然是否定的,我们只需稍作改变,让函数返回新变量,赋给a。这样,a就指向了一个新的值为2的对象,a的值也因此变为2。
|
||||
|
||||
def my_func2(b):
|
||||
b = 2
|
||||
return b
|
||||
|
||||
a = 1
|
||||
a = my_func2(a)
|
||||
a
|
||||
2
|
||||
|
||||
|
||||
不过,当可变对象当作参数传入函数里的时候,改变可变对象的值,就会影响所有指向它的变量。比如下面的例子:
|
||||
|
||||
def my_func3(l2):
|
||||
l2.append(4)
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
my_func3(l1)
|
||||
l1
|
||||
[1, 2, 3, 4]
|
||||
|
||||
|
||||
这里l1和l2先是同时指向值为[1, 2, 3]的列表。不过,由于列表可变,执行append()函数,对其末尾加入新元素4时,变量l1和l2的值也都随之改变了。
|
||||
|
||||
但是,下面这个例子,看似都是给列表增加了一个新元素,却得到了明显不同的结果。
|
||||
|
||||
def my_func4(l2):
|
||||
l2 = l2 + [4]
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
my_func4(l1)
|
||||
l1
|
||||
[1, 2, 3]
|
||||
|
||||
|
||||
为什么l1仍然是[1, 2, 3],而不是[1, 2, 3, 4]呢?
|
||||
|
||||
要注意,这里l2 = l2 + [4],表示创建了一个“末尾加入元素4“的新列表,并让l2指向这个新的对象。这个过程与l1无关,因此l1的值不变。当然,同样的,如果要改变l1的值,我们就得让上述函数返回一个新列表,再赋予l1即可:
|
||||
|
||||
def my_func5(l2):
|
||||
l2 = l2 + [4]
|
||||
return l2
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l1 = my_func5(l1)
|
||||
l1
|
||||
[1, 2, 3, 4]
|
||||
|
||||
|
||||
这里你尤其要记住的是,改变变量和重新赋值的区别:
|
||||
|
||||
|
||||
my_func3()中单纯地改变了对象的值,因此函数返回后,所有指向该对象的变量都会被改变;
|
||||
但my_func4()中则创建了新的对象,并赋值给一个本地变量,因此原变量仍然不变。
|
||||
|
||||
|
||||
至于my_func3()和my_func5()的用法,两者虽然写法不同,但实现的功能一致。不过,在实际工作应用中,我们往往倾向于类似my_func5()的写法,添加返回语句。这样更简洁明了,不易出错。
|
||||
|
||||
总结
|
||||
|
||||
今天,我们一起学习了Python的变量及其赋值的基本原理,并且解释了Python中参数是如何传递的。和其他语言不同的是,Python中参数的传递既不是值传递,也不是引用传递,而是赋值传递,或者是叫对象的引用传递。
|
||||
|
||||
需要注意的是,这里的赋值或对象的引用传递,不是指向一个具体的内存地址,而是指向一个具体的对象。
|
||||
|
||||
|
||||
如果对象是可变的,当其改变时,所有指向这个对象的变量都会改变。
|
||||
如果对象不可变,简单的赋值只能改变其中一个变量的值,其余变量则不受影响。
|
||||
|
||||
|
||||
清楚了这一点,如果你想通过一个函数来改变某个变量的值,通常有两种方法。一种是直接将可变数据类型(比如列表,字典,集合)当作参数传入,直接在其上修改;第二种则是创建一个新变量,来保存修改后的值,然后将其返回给原变量。在实际工作中,我们更倾向于使用后者,因为其表达清晰明了,不易出错。
|
||||
|
||||
思考题
|
||||
|
||||
最后,我为你留下了两道思考题。
|
||||
|
||||
第一个问题,下面的代码中, l1、l2和l3都指向同一个对象吗?
|
||||
|
||||
l1 = [1, 2, 3]
|
||||
l2 = [1, 2, 3]
|
||||
l3 = l2
|
||||
|
||||
|
||||
第二个问题,下面的代码中,打印d最后的输出是什么呢?
|
||||
|
||||
def func(d):
|
||||
d['a'] = 10
|
||||
d['b'] = 20
|
||||
|
||||
d = {'a': 1, 'b': 2}
|
||||
func(d)
|
||||
print(d)
|
||||
|
||||
|
||||
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。
|
||||
|
||||
|
||||
|
||||
|
439
专栏/Python核心技术与实战/17强大的装饰器.md
Normal file
439
专栏/Python核心技术与实战/17强大的装饰器.md
Normal file
@ -0,0 +1,439 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 强大的装饰器
|
||||
你好,我是景霄。这节课,我们一起来学习装饰器。
|
||||
|
||||
装饰器一直以来都是Python中很有用、很经典的一个feature,在工程中的应用也十分广泛,比如日志、缓存等等的任务都会用到。然而,在平常工作生活中,我发现不少人,尤其是初学者,常常因为其相对复杂的表示,对装饰器望而生畏,认为它“too fancy to learn”,实际并不如此。
|
||||
|
||||
今天这节课,我会以前面所讲的函数、闭包为切入点,引出装饰器的概念、表达和基本用法,最后,再通过实际工程中的例子,让你再次加深理解。
|
||||
|
||||
接下来,让我们进入正文一起学习吧!
|
||||
|
||||
函数->装饰器
|
||||
|
||||
函数核心回顾
|
||||
|
||||
引入装饰器之前,我们首先一起来复习一下,必须掌握的函数的几个核心概念。
|
||||
|
||||
第一点,我们要知道,在Python中,函数是一等公民(first-class citizen),函数也是对象。我们可以把函数赋予变量,比如下面这段代码:
|
||||
|
||||
def func(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
|
||||
send_message = func
|
||||
send_message('hello world')
|
||||
|
||||
# 输出
|
||||
Got a message: hello world
|
||||
|
||||
|
||||
这个例子中,我们把函数func赋予了变量send_message,这样之后你调用send_message,就相当于是调用函数func()。
|
||||
|
||||
第二点,我们可以把函数当作参数,传入另一个函数中,比如下面这段代码:
|
||||
|
||||
def get_message(message):
|
||||
return 'Got a message: ' + message
|
||||
|
||||
|
||||
def root_call(func, message):
|
||||
print(func(message))
|
||||
|
||||
root_call(get_message, 'hello world')
|
||||
|
||||
# 输出
|
||||
Got a message: hello world
|
||||
|
||||
|
||||
这个例子中,我们就把函数get_message以参数的形式,传入了函数root_call()中然后调用它。
|
||||
|
||||
第三点,我们可以在函数里定义函数,也就是函数的嵌套。这里我同样举了一个例子:
|
||||
|
||||
def func(message):
|
||||
def get_message(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
return get_message(message)
|
||||
|
||||
func('hello world')
|
||||
|
||||
# 输出
|
||||
Got a message: hello world
|
||||
|
||||
|
||||
这段代码中,我们在函数func()里又定义了新的函数get_message(),调用后作为func()的返回值返回。
|
||||
|
||||
第四点,要知道,函数的返回值也可以是函数对象(闭包),比如下面这个例子:
|
||||
|
||||
def func_closure():
|
||||
def get_message(message):
|
||||
print('Got a message: {}'.format(message))
|
||||
return get_message
|
||||
|
||||
send_message = func_closure()
|
||||
send_message('hello world')
|
||||
|
||||
# 输出
|
||||
Got a message: hello world
|
||||
|
||||
|
||||
这里,函数func_closure()的返回值是函数对象get_message本身,之后,我们将其赋予变量send_message,再调用send_message(‘hello world’),最后输出了'Got a message: hello world'。
|
||||
|
||||
简单的装饰器
|
||||
|
||||
简单的复习之后,我们接下来学习今天的新知识——装饰器。按照习惯,我们可以先来看一个装饰器的简单例子:
|
||||
|
||||
def my_decorator(func):
|
||||
def wrapper():
|
||||
print('wrapper of decorator')
|
||||
func()
|
||||
return wrapper
|
||||
|
||||
def greet():
|
||||
print('hello world')
|
||||
|
||||
greet = my_decorator(greet)
|
||||
greet()
|
||||
|
||||
# 输出
|
||||
wrapper of decorator
|
||||
hello world
|
||||
|
||||
|
||||
这段代码中,变量greet指向了内部函数wrapper(),而内部函数wrapper()中又会调用原函数greet(),因此,最后调用greet()时,就会先打印'wrapper of decorator',然后输出'hello world'。
|
||||
|
||||
这里的函数my_decorator()就是一个装饰器,它把真正需要执行的函数greet()包裹在其中,并且改变了它的行为,但是原函数greet()不变。
|
||||
|
||||
事实上,上述代码在Python中有更简单、更优雅的表示:
|
||||
|
||||
def my_decorator(func):
|
||||
def wrapper():
|
||||
print('wrapper of decorator')
|
||||
func()
|
||||
return wrapper
|
||||
|
||||
@my_decorator
|
||||
def greet():
|
||||
print('hello world')
|
||||
|
||||
greet()
|
||||
|
||||
|
||||
这里的@,我们称之为语法糖,@my_decorator就相当于前面的greet=my_decorator(greet)语句,只不过更加简洁。因此,如果你的程序中有其它函数需要做类似的装饰,你只需在它们的上方加上@decorator就可以了,这样就大大提高了函数的重复利用和程序的可读性。
|
||||
|
||||
带有参数的装饰器
|
||||
|
||||
你或许会想到,如果原函数greet()中,有参数需要传递给装饰器怎么办?
|
||||
|
||||
一个简单的办法,是可以在对应的装饰器函数wrapper()上,加上相应的参数,比如:
|
||||
|
||||
def my_decorator(func):
|
||||
def wrapper(message):
|
||||
print('wrapper of decorator')
|
||||
func(message)
|
||||
return wrapper
|
||||
|
||||
|
||||
@my_decorator
|
||||
def greet(message):
|
||||
print(message)
|
||||
|
||||
|
||||
greet('hello world')
|
||||
|
||||
# 输出
|
||||
wrapper of decorator
|
||||
hello world
|
||||
|
||||
|
||||
不过,新的问题来了。如果我另外还有一个函数,也需要使用my_decorator()装饰器,但是这个新的函数有两个参数,又该怎么办呢?比如:
|
||||
|
||||
@my_decorator
|
||||
def celebrate(name, message):
|
||||
...
|
||||
|
||||
|
||||
事实上,通常情况下,我们会把*args和**kwargs,作为装饰器内部函数wrapper()的参数。*args和**kwargs,表示接受任意数量和类型的参数,因此装饰器就可以写成下面的形式:
|
||||
|
||||
def my_decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
print('wrapper of decorator')
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
带有自定义参数的装饰器
|
||||
|
||||
其实,装饰器还有更大程度的灵活性。刚刚说了,装饰器可以接受原函数任意类型和数量的参数,除此之外,它还可以接受自己定义的参数。
|
||||
|
||||
举个例子,比如我想要定义一个参数,来表示装饰器内部函数被执行的次数,那么就可以写成下面这种形式:
|
||||
|
||||
def repeat(num):
|
||||
def my_decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
for i in range(num):
|
||||
print('wrapper of decorator')
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
return my_decorator
|
||||
|
||||
|
||||
@repeat(4)
|
||||
def greet(message):
|
||||
print(message)
|
||||
|
||||
greet('hello world')
|
||||
|
||||
# 输出:
|
||||
wrapper of decorator
|
||||
hello world
|
||||
wrapper of decorator
|
||||
hello world
|
||||
wrapper of decorator
|
||||
hello world
|
||||
wrapper of decorator
|
||||
hello world
|
||||
|
||||
|
||||
原函数还是原函数吗?
|
||||
|
||||
现在,我们再来看个有趣的现象。还是之前的例子,我们试着打印出greet()函数的一些元信息:
|
||||
|
||||
greet.__name__
|
||||
## 输出
|
||||
'wrapper'
|
||||
|
||||
help(greet)
|
||||
# 输出
|
||||
Help on function wrapper in module __main__:
|
||||
|
||||
wrapper(*args, **kwargs)
|
||||
|
||||
|
||||
你会发现,greet()函数被装饰以后,它的元信息变了。元信息告诉我们“它不再是以前的那个greet()函数,而是被wrapper()函数取代了”。
|
||||
|
||||
为了解决这个问题,我们通常使用内置的装饰器@functools.wrap,它会帮助保留原函数的元信息(也就是将原函数的元信息,拷贝到对应的装饰器函数里)。
|
||||
|
||||
import functools
|
||||
|
||||
def my_decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
print('wrapper of decorator')
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@my_decorator
|
||||
def greet(message):
|
||||
print(message)
|
||||
|
||||
greet.__name__
|
||||
|
||||
# 输出
|
||||
'greet'
|
||||
|
||||
|
||||
类装饰器
|
||||
|
||||
前面我们主要讲了函数作为装饰器的用法,实际上,类也可以作为装饰器。类装饰器主要依赖于函数__call__(),每当你调用一个类的示例时,函数__call__()就会被执行一次。
|
||||
|
||||
我们来看下面这段代码:
|
||||
|
||||
class Count:
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
self.num_calls = 0
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.num_calls += 1
|
||||
print('num of calls is: {}'.format(self.num_calls))
|
||||
return self.func(*args, **kwargs)
|
||||
|
||||
@Count
|
||||
def example():
|
||||
print("hello world")
|
||||
|
||||
example()
|
||||
|
||||
# 输出
|
||||
num of calls is: 1
|
||||
hello world
|
||||
|
||||
example()
|
||||
|
||||
# 输出
|
||||
num of calls is: 2
|
||||
hello world
|
||||
|
||||
...
|
||||
|
||||
|
||||
这里,我们定义了类Count,初始化时传入原函数func(),而__call__()函数表示让变量num_calls自增1,然后打印,并且调用原函数。因此,在我们第一次调用函数example()时,num_calls的值是1,而在第二次调用时,它的值变成了2。
|
||||
|
||||
装饰器的嵌套
|
||||
|
||||
回顾刚刚讲的例子,基本都是一个装饰器的情况,但实际上,Python也支持多个装饰器,比如写成下面这样的形式:
|
||||
|
||||
@decorator1
|
||||
@decorator2
|
||||
@decorator3
|
||||
def func():
|
||||
...
|
||||
|
||||
|
||||
它的执行顺序从里到外,所以上面的语句也等效于下面这行代码:
|
||||
|
||||
decorator1(decorator2(decorator3(func)))
|
||||
|
||||
|
||||
这样,'hello world'这个例子,就可以改写成下面这样:
|
||||
|
||||
import functools
|
||||
|
||||
def my_decorator1(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
print('execute decorator1')
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def my_decorator2(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
print('execute decorator2')
|
||||
func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@my_decorator1
|
||||
@my_decorator2
|
||||
def greet(message):
|
||||
print(message)
|
||||
|
||||
|
||||
greet('hello world')
|
||||
|
||||
# 输出
|
||||
execute decorator1
|
||||
execute decorator2
|
||||
hello world
|
||||
|
||||
|
||||
装饰器用法实例
|
||||
|
||||
到此,装饰器的基本概念及用法我就讲完了,接下来,我将结合实际工作中的几个例子,带你加深对它的理解。
|
||||
|
||||
身份认证
|
||||
|
||||
首先是最常见的身份认证的应用。这个很容易理解,举个最常见的例子,你登录微信,需要输入用户名密码,然后点击确认,这样,服务器端便会查询你的用户名是否存在、是否和密码匹配等等。如果认证通过,你就可以顺利登录;如果不通过,就抛出异常并提示你登录失败。
|
||||
|
||||
再比如一些网站,你不登录也可以浏览内容,但如果你想要发布文章或留言,在点击发布时,服务器端便会查询你是否登录。如果没有登录,就不允许这项操作等等。
|
||||
|
||||
我们来看一个大概的代码示例:
|
||||
|
||||
import functools
|
||||
|
||||
def authenticate(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
request = args[0]
|
||||
if check_user_logged_in(request): # 如果用户处于登录状态
|
||||
return func(*args, **kwargs) # 执行函数post_comment()
|
||||
else:
|
||||
raise Exception('Authentication failed')
|
||||
return wrapper
|
||||
|
||||
@authenticate
|
||||
def post_comment(request, ...)
|
||||
...
|
||||
|
||||
|
||||
|
||||
这段代码中,我们定义了装饰器authenticate;而函数post_comment(),则表示发表用户对某篇文章的评论。每次调用这个函数前,都会先检查用户是否处于登录状态,如果是登录状态,则允许这项操作;如果没有登录,则不允许。
|
||||
|
||||
日志记录
|
||||
|
||||
日志记录同样是很常见的一个案例。在实际工作中,如果你怀疑某些函数的耗时过长,导致整个系统的latency(延迟)增加,所以想在线上测试某些函数的执行时间,那么,装饰器就是一种很常用的手段。
|
||||
|
||||
我们通常用下面的方法来表示:
|
||||
|
||||
import time
|
||||
import functools
|
||||
|
||||
def log_execution_time(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start = time.perf_counter()
|
||||
res = func(*args, **kwargs)
|
||||
end = time.perf_counter()
|
||||
print('{} took {} ms'.format(func.__name__, (end - start) * 1000))
|
||||
return res
|
||||
return wrapper
|
||||
|
||||
@log_execution_time
|
||||
def calculate_similarity(items):
|
||||
...
|
||||
|
||||
|
||||
这里,装饰器log_execution_time记录某个函数的运行时间,并返回其执行结果。如果你想计算任何函数的执行时间,在这个函数上方加上@log_execution_time即可。
|
||||
|
||||
输入合理性检查
|
||||
|
||||
再来看今天要讲的第三个应用,输入合理性检查。
|
||||
|
||||
在大型公司的机器学习框架中,我们调用机器集群进行模型训练前,往往会用装饰器对其输入(往往是很长的JSON文件)进行合理性检查。这样就可以大大避免,输入不正确对机器造成的巨大开销。
|
||||
|
||||
它的写法往往是下面的格式:
|
||||
|
||||
import functools
|
||||
|
||||
def validation_check(input):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
... # 检查输入是否合法
|
||||
|
||||
@validation_check
|
||||
def neural_network_training(param1, param2, ...):
|
||||
...
|
||||
|
||||
|
||||
其实在工作中,很多情况下都会出现输入不合理的现象。因为我们调用的训练模型往往很复杂,输入的文件有成千上万行,很多时候确实也很难发现。
|
||||
|
||||
试想一下,如果没有输入的合理性检查,很容易出现“模型训练了好几个小时后,系统却报错说输入的一个参数不对,成果付之一炬”的现象。这样的“惨案”,大大减缓了开发效率,也对机器资源造成了巨大浪费。
|
||||
|
||||
缓存
|
||||
|
||||
最后,我们来看缓存方面的应用。关于缓存装饰器的用法,其实十分常见,这里我以Python内置的LRU cache为例来说明(如果你不了解 LRU cache,可以点击链接自行查阅)。
|
||||
|
||||
LRU cache,在Python中的表示形式是@lru_cache。@lru_cache会缓存进程中的函数参数和结果,当缓存满了以后,会删除least recenly used 的数据。
|
||||
|
||||
正确使用缓存装饰器,往往能极大地提高程序运行效率。为什么呢?我举一个常见的例子来说明。
|
||||
|
||||
大型公司服务器端的代码中往往存在很多关于设备的检查,比如你使用的设备是安卓还是iPhone,版本号是多少。这其中的一个原因,就是一些新的feature,往往只在某些特定的手机系统或版本上才有(比如Android v200+)。
|
||||
|
||||
这样一来,我们通常使用缓存装饰器,来包裹这些检查函数,避免其被反复调用,进而提高程序运行效率,比如写成下面这样:
|
||||
|
||||
@lru_cache
|
||||
def check(param1, param2, ...) # 检查用户设备类型,版本号等等
|
||||
...
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起学习了装饰器的概念及用法。所谓的装饰器,其实就是通过装饰器函数,来修改原函数的一些功能,使得原函数不需要修改。
|
||||
|
||||
|
||||
Decorators is to modify the behavior of the function through a wrapper so we don’t have to actually modify the function.
|
||||
|
||||
|
||||
而实际工作中,装饰器通常运用在身份认证、日志记录、输入合理性检查以及缓存等多个领域中。合理使用装饰器,往往能极大地提高程序的可读性以及运行效率。
|
||||
|
||||
思考题
|
||||
|
||||
那么,你平时工作中,通常会在哪些情况下使用装饰器呢?欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。
|
||||
|
||||
|
||||
|
||||
|
244
专栏/Python核心技术与实战/18metaclass,是潘多拉魔盒还是阿拉丁神灯?.md
Normal file
244
专栏/Python核心技术与实战/18metaclass,是潘多拉魔盒还是阿拉丁神灯?.md
Normal file
@ -0,0 +1,244 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 metaclass,是潘多拉魔盒还是阿拉丁神灯?
|
||||
你好,我是蔡元楠,极客时间《大规模数据处理实战》专栏的作者。今天我想和你分享的主题是:metaclass,是潘多拉魔盒还是阿拉丁神灯?
|
||||
|
||||
Python中有很多黑魔法,比如今天我将分享的metaclass。我认识许多人,对于这些语言特性有两种极端的观点。
|
||||
|
||||
|
||||
一种人觉得这些语言特性太牛逼了,简直是无所不能的阿拉丁神灯,必须找机会用上才能显示自己的Python实力。
|
||||
另一种观点则是认为这些语言特性太危险了,会蛊惑人心去滥用,一旦打开就会释放“恶魔”,让整个代码库变得难以维护。
|
||||
|
||||
|
||||
其实这两种看法都有道理,却又都浅尝辄止。今天,我就带你来看看,metaclass到底是潘多拉魔盒还是阿拉丁神灯?
|
||||
|
||||
市面上的很多中文书,都把metaclass译为“元类”。我一直认为这个翻译很糟糕,所以也不想在这里称metaclass为元类。因为如果仅从字面理解,“元”是“本源”“基本”的意思,“元类”会让人以为是“基本类”。难道Python的metaclass,指的是Python 2的Object吗?这就让人一头雾水了。
|
||||
|
||||
事实上,meta-class的meta这个词根,起源于希腊语词汇meta,包含下面两种意思:
|
||||
|
||||
|
||||
“Beyond”,例如技术词汇metadata,意思是描述数据的超越数据;
|
||||
“Change”,例如技术词汇metamorphosis,意思是改变的形态。
|
||||
|
||||
|
||||
metaclass,一如其名,实际上同时包含了“超越类”和“变形类”的含义,完全不是“基本类”的意思。所以,要深入理解metaclass,我们就要围绕它的超越变形特性。接下来,我将为你展开metaclass的超越变形能力,讲清楚metaclass究竟有什么用?怎么应用?Python语言设计层面是如何实现metaclass的 ?以及使用metaclass的风险。
|
||||
|
||||
metaclass的超越变形特性有什么用?
|
||||
|
||||
YAML是一个家喻户晓的Python工具,可以方便地序列化/逆序列化结构数据。YAMLObject的一个超越变形能力,就是它的任意子类支持序列化和反序列化(serialization & deserialization)。比如说下面这段代码:
|
||||
|
||||
class Monster(yaml.YAMLObject):
|
||||
yaml_tag = u'!Monster'
|
||||
def __init__(self, name, hp, ac, attacks):
|
||||
self.name = name
|
||||
self.hp = hp
|
||||
self.ac = ac
|
||||
self.attacks = attacks
|
||||
def __repr__(self):
|
||||
return "%s(name=%r, hp=%r, ac=%r, attacks=%r)" % (
|
||||
self.__class__.__name__, self.name, self.hp, self.ac,
|
||||
self.attacks)
|
||||
|
||||
yaml.load("""
|
||||
--- !Monster
|
||||
name: Cave spider
|
||||
hp: [2,6] # 2d6
|
||||
ac: 16
|
||||
attacks: [BITE, HURT]
|
||||
""")
|
||||
|
||||
Monster(name='Cave spider', hp=[2, 6], ac=16, attacks=['BITE', 'HURT'])
|
||||
|
||||
print yaml.dump(Monster(
|
||||
name='Cave lizard', hp=[3,6], ac=16, attacks=['BITE','HURT']))
|
||||
|
||||
# 输出
|
||||
!Monster
|
||||
ac: 16
|
||||
attacks: [BITE, HURT]
|
||||
hp: [3, 6]
|
||||
name: Cave lizard
|
||||
|
||||
|
||||
这里YAMLObject的特异功能体现在哪里呢?
|
||||
|
||||
你看,调用统一的yaml.load(),就能把任意一个yaml序列载入成一个Python Object;而调用统一的yaml.dump(),就能把一个YAMLObject子类序列化。对于load()和dump()的使用者来说,他们完全不需要提前知道任何类型信息,这让超动态配置编程成了可能。在我的实战经验中,许多大型项目都需要应用这种超动态配置的理念。
|
||||
|
||||
比方说,在一个智能语音助手的大型项目中,我们有1万个语音对话场景,每一个场景都是不同团队开发的。作为智能语音助手的核心团队成员,我不可能去了解每个子场景的实现细节。
|
||||
|
||||
在动态配置实验不同场景时,经常是今天我要实验场景A和B的配置,明天实验B和C的配置,光配置文件就有几万行量级,工作量真是不小。而应用这样的动态配置理念,我就可以让引擎根据我的文本配置文件,动态加载所需要的Python类。
|
||||
|
||||
对于YAML的使用者,这一点也很方便,你只要简单地继承yaml.YAMLObject,就能让你的Python Object具有序列化和逆序列化能力。是不是相比普通Python类,有一点“变态”,有一点“超越”?
|
||||
|
||||
事实上,我在Google见过很多Python开发者,发现能深入解释YAML这种设计模式优点的人,大概只有10%。而能知道类似YAML的这种动态序列化/逆序列化功能正是用metaclass实现的人,更是凤毛麟角,可能只有1%了。
|
||||
|
||||
metaclass的超越变形特性怎么用?
|
||||
|
||||
刚刚提到,估计只有1%的Python开发者,知道YAML的动态序列化/逆序列化是由metaclass实现的。如果你追问,YAML怎样用metaclass实现动态序列化/逆序列化功能,可能只有0.1%的人能说得出一二了。
|
||||
|
||||
因为篇幅原因,我们这里只看YAMLObject的load()功能。简单来说,我们需要一个全局的注册器,让YAML知道,序列化文本中的 !Monster 需要载入成 Monster这个Python类型。
|
||||
|
||||
一个很自然的想法就是,那我们建立一个全局变量叫 registry,把所有需要逆序列化的YAMLObject,都注册进去。比如下面这样:
|
||||
|
||||
registry = {}
|
||||
|
||||
def add_constructor(target_class):
|
||||
registry[target_class.yaml_tag] = target_class
|
||||
|
||||
|
||||
然后,在Monster 类定义后面加上下面这行代码:
|
||||
|
||||
add_constructor(Monster)
|
||||
|
||||
|
||||
但这样的缺点也很明显,对于YAML的使用者来说,每一个YAML的可逆序列化的类Foo定义后,都需要加上一句话,add_constructor(Foo)。这无疑给开发者增加了麻烦,也更容易出错,毕竟开发者很容易忘了这一点。
|
||||
|
||||
那么,更优的实现方式是什么样呢?如果你看过YAML的源码,就会发现,正是metaclass解决了这个问题。
|
||||
|
||||
# Python 2/3 相同部分
|
||||
class YAMLObjectMetaclass(type):
|
||||
def __init__(cls, name, bases, kwds):
|
||||
super(YAMLObjectMetaclass, cls).__init__(name, bases, kwds)
|
||||
if 'yaml_tag' in kwds and kwds['yaml_tag'] is not None:
|
||||
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
|
||||
# 省略其余定义
|
||||
|
||||
# Python 3
|
||||
class YAMLObject(metaclass=YAMLObjectMetaclass):
|
||||
yaml_loader = Loader
|
||||
# 省略其余定义
|
||||
|
||||
# Python 2
|
||||
class YAMLObject(object):
|
||||
__metaclass__ = YAMLObjectMetaclass
|
||||
yaml_loader = Loader
|
||||
# 省略其余定义
|
||||
|
||||
|
||||
你可以发现,YAMLObject把metaclass都声明成了YAMLObjectMetaclass,尽管声明方式在Python 2 和3中略有不同。在YAMLObjectMetaclass中, 下面这行代码就是魔法发生的地方:
|
||||
|
||||
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
|
||||
|
||||
|
||||
YAML应用metaclass,拦截了所有YAMLObject子类的定义。也就说说,在你定义任何YAMLObject子类时,Python会强行插入运行下面这段代码,把我们之前想要的add_constructor(Foo)给自动加上。
|
||||
|
||||
cls.yaml_loader.add_constructor(cls.yaml_tag, cls.from_yaml)
|
||||
|
||||
|
||||
所以YAML的使用者,无需自己去手写add_constructor(Foo) 。怎么样,是不是其实并不复杂?
|
||||
|
||||
看到这里,我们已经掌握了metaclass的使用方法,超越了世界上99.9%的Python开发者。更进一步,如果你能够深入理解,Python的语言设计层面是怎样实现metaclass的,你就是世间罕见的“Python大师”了。
|
||||
|
||||
Python底层语言设计层面是如何实现metaclass的?
|
||||
|
||||
刚才我们提到,metaclass能够拦截Python类的定义。它是怎么做到的?
|
||||
|
||||
要理解metaclass的底层原理,你需要深入理解Python类型模型。下面,我将分三点来说明。
|
||||
|
||||
第一,所有的Python的用户定义类,都是type这个类的实例。
|
||||
|
||||
可能会让你惊讶,事实上,类本身不过是一个名为 type 类的实例。在Python的类型世界里,type这个类就是造物的上帝。这可以在代码中验证:
|
||||
|
||||
# Python 3和Python 2类似
|
||||
class MyClass:
|
||||
pass
|
||||
|
||||
instance = MyClass()
|
||||
|
||||
type(instance)
|
||||
# 输出
|
||||
<class '__main__.C'>
|
||||
|
||||
type(MyClass)
|
||||
# 输出
|
||||
<class 'type'>
|
||||
|
||||
|
||||
你可以看到,instance是MyClass的实例,而MyClass不过是“上帝”type的实例。
|
||||
|
||||
第二,用户自定义类,只不过是type类的__call__运算符重载。
|
||||
|
||||
当我们定义一个类的语句结束时,真正发生的情况,是Python调用type的__call__运算符。简单来说,当你定义一个类时,写成下面这样时:
|
||||
|
||||
class MyClass:
|
||||
data = 1
|
||||
|
||||
|
||||
Python真正执行的是下面这段代码:
|
||||
|
||||
class = type(classname, superclasses, attributedict)
|
||||
|
||||
|
||||
这里等号右边的type(classname, superclasses, attributedict),就是type的__call__运算符重载,它会进一步调用:
|
||||
|
||||
type.__new__(typeclass, classname, superclasses, attributedict)
|
||||
type.__init__(class, classname, superclasses, attributedict)
|
||||
|
||||
|
||||
当然,这一切都可以通过代码验证,比如下面这段代码示例:
|
||||
|
||||
class MyClass:
|
||||
data = 1
|
||||
|
||||
instance = MyClass()
|
||||
MyClass, instance
|
||||
# 输出
|
||||
(__main__.MyClass, <__main__.MyClass instance at 0x7fe4f0b00ab8>)
|
||||
instance.data
|
||||
# 输出
|
||||
1
|
||||
|
||||
MyClass = type('MyClass', (), {'data': 1})
|
||||
instance = MyClass()
|
||||
MyClass, instance
|
||||
# 输出
|
||||
(__main__.MyClass, <__main__.MyClass at 0x7fe4f0aea5d0>)
|
||||
|
||||
instance.data
|
||||
# 输出
|
||||
1
|
||||
|
||||
|
||||
由此可见,正常的MyClass定义,和你手工去调用type运算符的结果是完全一样的。
|
||||
|
||||
第三,metaclass是type的子类,通过替换type的__call__运算符重载机制,“超越变形”正常的类。
|
||||
|
||||
其实,理解了以上几点,我们就会明白,正是Python的类创建机制,给了metaclass大展身手的机会。
|
||||
|
||||
一旦你把一个类型MyClass的metaclass设置成MyMeta,MyClass就不再由原生的type创建,而是会调用MyMeta的__call__运算符重载。
|
||||
|
||||
class = type(classname, superclasses, attributedict)
|
||||
# 变为了
|
||||
class = MyMeta(classname, superclasses, attributedict)
|
||||
|
||||
|
||||
所以,我们才能在上面YAML的例子中,利用YAMLObjectMetaclass的__init__方法,为所有YAMLObject子类偷偷执行add_constructor()。
|
||||
|
||||
使用metaclass的风险
|
||||
|
||||
前面的篇幅,我都是在讲metaclass的原理和优点。的的确确,只有深入理解metaclass的本质,你才能用好metaclass。而不幸的是,正如我开头所说,深入理解metaclass的Python开发者,只占了0.1%不到。
|
||||
|
||||
不过,凡事有利必有弊,尤其是metaclass这样“逆天”的存在。正如你所看到的那样,metaclass会”扭曲变形”正常的Python类型模型。所以,如果使用不慎,对于整个代码库造成的风险是不可估量的。
|
||||
|
||||
换句话说,metaclass仅仅是给小部分Python开发者,在开发框架层面的Python库时使用的。而在应用层,metaclass往往不是很好的选择。
|
||||
|
||||
也正因为这样,据我所知,在很多硅谷一线大厂,使用Python metaclass需要特例特批。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们通过解读YAML的源码,围绕metaclass的设计本意“超越变形”,解析了metaclass的使用场景和使用方法。接着,我们又进一步深入到Python语言设计层面,搞明白了metaclass的实现机制。
|
||||
|
||||
正如我取的标题那样,metaclass是Python黑魔法级别的语言特性。天堂和地狱只有一步之遥,你使用好metaclass,可以实现像YAML那样神奇的特性;而使用不好,可能就会打开潘多拉魔盒了。
|
||||
|
||||
所以,今天的内容,一方面是帮助有需要的同学,深入理解metaclass,更好地掌握和应用;另一方面,也是对初学者的科普和警告:不要轻易尝试metaclass。
|
||||
|
||||
思考题
|
||||
|
||||
学完了上节课的Python装饰器和这节课的metaclass,你知道了,它们都能干预正常的Python类型机制。那么,你觉得装饰器和metaclass有什么区别呢?欢迎留言和我讨论。
|
||||
|
||||
|
||||
|
||||
|
333
专栏/Python核心技术与实战/19深入理解迭代器和生成器.md
Normal file
333
专栏/Python核心技术与实战/19深入理解迭代器和生成器.md
Normal file
@ -0,0 +1,333 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 深入理解迭代器和生成器
|
||||
你好,我是景霄。
|
||||
|
||||
在第一次接触 Python 的时候,你可能写过类似 for i in [2, 3, 5, 7, 11, 13]: print(i) 这样的语句。for in 语句理解起来很直观形象,比起 C++ 和 java 早期的 for (int i = 0; i < n; i ++) printf("%d\n", a[i]) 这样的语句,不知道简洁清晰到哪里去了。
|
||||
|
||||
但是,你想过 Python 在处理 for in 语句的时候,具体发生了什么吗?什么样的对象可以被 for in 来枚举呢?
|
||||
|
||||
这一节课,我们深入到 Python 的容器类型实现底层去走走,了解一种叫做迭代器和生成器的东西。
|
||||
|
||||
你肯定用过的容器、可迭代对象和迭代器
|
||||
|
||||
容器这个概念非常好理解。我们说过,在Python 中一切皆对象,对象的抽象就是类,而对象的集合就是容器。
|
||||
|
||||
列表(list: [0, 1, 2]),元组(tuple: (0, 1, 2)),字典(dict: {0:0, 1:1, 2:2}),集合(set: set([0, 1, 2]))都是容器。对于容器,你可以很直观地想象成多个元素在一起的单元;而不同容器的区别,正是在于内部数据结构的实现方法。然后,你就可以针对不同场景,选择不同时间和空间复杂度的容器。
|
||||
|
||||
所有的容器都是可迭代的(iterable)。这里的迭代,和枚举不完全一样。迭代可以想象成是你去买苹果,卖家并不告诉你他有多少库存。这样,每次你都需要告诉卖家,你要一个苹果,然后卖家采取行为:要么给你拿一个苹果;要么告诉你,苹果已经卖完了。你并不需要知道,卖家在仓库是怎么摆放苹果的。
|
||||
|
||||
严谨地说,迭代器(iterator)提供了一个 next 的方法。调用这个方法后,你要么得到这个容器的下一个对象,要么得到一个 StopIteration 的错误(苹果卖完了)。你不需要像列表一样指定元素的索引,因为字典和集合这样的容器并没有索引一说。比如,字典采用哈希表实现,那么你就只需要知道,next 函数可以不重复不遗漏地一个一个拿到所有元素即可。
|
||||
|
||||
而可迭代对象,通过 iter() 函数返回一个迭代器,再通过 next() 函数就可以实现遍历。for in 语句将这个过程隐式化,所以,你只需要知道它大概做了什么就行了。
|
||||
|
||||
我们来看下面这段代码,主要向你展示怎么判断一个对象是否可迭代。当然,这还有另一种做法,是 isinstance(obj, Iterable)。
|
||||
|
||||
def is_iterable(param):
|
||||
try:
|
||||
iter(param)
|
||||
return True
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
params = [
|
||||
1234,
|
||||
'1234',
|
||||
[1, 2, 3, 4],
|
||||
set([1, 2, 3, 4]),
|
||||
{1:1, 2:2, 3:3, 4:4},
|
||||
(1, 2, 3, 4)
|
||||
]
|
||||
|
||||
for param in params:
|
||||
print('{} is iterable? {}'.format(param, is_iterable(param)))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
1234 is iterable? False
|
||||
1234 is iterable? True
|
||||
[1, 2, 3, 4] is iterable? True
|
||||
{1, 2, 3, 4} is iterable? True
|
||||
{1: 1, 2: 2, 3: 3, 4: 4} is iterable? True
|
||||
(1, 2, 3, 4) is iterable? True
|
||||
|
||||
|
||||
通过这段代码,你就可以知道,给出的类型中,除了数字 1234 之外,其它的数据类型都是可迭代的。
|
||||
|
||||
生成器,又是什么?
|
||||
|
||||
据我所知,很多人对生成器这个概念会比较陌生,因为生成器在很多常用语言中,并没有相对应的模型。
|
||||
|
||||
这里,你只需要记着一点:生成器是懒人版本的迭代器。
|
||||
|
||||
我们知道,在迭代器中,如果我们想要枚举它的元素,这些元素需要事先生成。这里,我们先来看下面这个简单的样例。
|
||||
|
||||
import os
|
||||
import psutil
|
||||
|
||||
# 显示当前 python 程序占用的内存大小
|
||||
def show_memory_info(hint):
|
||||
pid = os.getpid()
|
||||
p = psutil.Process(pid)
|
||||
|
||||
info = p.memory_full_info()
|
||||
memory = info.uss / 1024. / 1024
|
||||
print('{} memory used: {} MB'.format(hint, memory))
|
||||
|
||||
|
||||
def test_iterator():
|
||||
show_memory_info('initing iterator')
|
||||
list_1 = [i for i in range(100000000)]
|
||||
show_memory_info('after iterator initiated')
|
||||
print(sum(list_1))
|
||||
show_memory_info('after sum called')
|
||||
|
||||
def test_generator():
|
||||
show_memory_info('initing generator')
|
||||
list_2 = (i for i in range(100000000))
|
||||
show_memory_info('after generator initiated')
|
||||
print(sum(list_2))
|
||||
show_memory_info('after sum called')
|
||||
|
||||
%time test_iterator()
|
||||
%time test_generator()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initing iterator memory used: 48.9765625 MB
|
||||
after iterator initiated memory used: 3920.30078125 MB
|
||||
4999999950000000
|
||||
after sum called memory used: 3920.3046875 MB
|
||||
Wall time: 17 s
|
||||
initing generator memory used: 50.359375 MB
|
||||
after generator initiated memory used: 50.359375 MB
|
||||
4999999950000000
|
||||
after sum called memory used: 50.109375 MB
|
||||
Wall time: 12.5 s
|
||||
|
||||
|
||||
声明一个迭代器很简单,[i for i in range(100000000)]就可以生成一个包含一亿元素的列表。每个元素在生成后都会保存到内存中,你通过代码可以看到,它们占用了巨量的内存,内存不够的话就会出现 OOM 错误。
|
||||
|
||||
不过,我们并不需要在内存中同时保存这么多东西,比如对元素求和,我们只需要知道每个元素在相加的那一刻是多少就行了,用完就可以扔掉了。
|
||||
|
||||
于是,生成器的概念应运而生,在你调用 next() 函数的时候,才会生成下一个变量。生成器在 Python 的写法是用小括号括起来,(i for i in range(100000000)),即初始化了一个生成器。
|
||||
|
||||
这样一来,你可以清晰地看到,生成器并不会像迭代器一样占用大量内存,只有在被使用的时候才会调用。而且生成器在初始化的时候,并不需要运行一次生成操作,相比于 test_iterator() ,test_generator() 函数节省了一次生成一亿个元素的过程,因此耗时明显比迭代器短。
|
||||
|
||||
到这里,你可能说,生成器不过如此嘛,我有的是钱,不就是多占一些内存和计算资源嘛,我多出点钱就是了呗。
|
||||
|
||||
哪怕你是土豪,请坐下先喝点茶,再听我继续讲完,这次,我们来实现一个自定义的生成器。
|
||||
|
||||
生成器,还能玩什么花样?
|
||||
|
||||
数学中有一个恒等式,(1 + 2 + 3 + ... + n)^2 = 1^3 + 2^3 + 3^3 + ... + n^3,想必你高中就应该学过它。现在,我们来验证一下这个公式的正确性。老规矩,先放代码,你先自己阅读一下,看不懂的也不要紧,接下来我再来详细讲解。
|
||||
|
||||
def generator(k):
|
||||
i = 1
|
||||
while True:
|
||||
yield i ** k
|
||||
i += 1
|
||||
|
||||
gen_1 = generator(1)
|
||||
gen_3 = generator(3)
|
||||
print(gen_1)
|
||||
print(gen_3)
|
||||
|
||||
def get_sum(n):
|
||||
sum_1, sum_3 = 0, 0
|
||||
for i in range(n):
|
||||
next_1 = next(gen_1)
|
||||
next_3 = next(gen_3)
|
||||
print('next_1 = {}, next_3 = {}'.format(next_1, next_3))
|
||||
sum_1 += next_1
|
||||
sum_3 += next_3
|
||||
print(sum_1 * sum_1, sum_3)
|
||||
|
||||
get_sum(8)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
<generator object generator at 0x000001E70651C4F8>
|
||||
<generator object generator at 0x000001E70651C390>
|
||||
next_1 = 1, next_3 = 1
|
||||
next_1 = 2, next_3 = 8
|
||||
next_1 = 3, next_3 = 27
|
||||
next_1 = 4, next_3 = 64
|
||||
next_1 = 5, next_3 = 125
|
||||
next_1 = 6, next_3 = 216
|
||||
next_1 = 7, next_3 = 343
|
||||
next_1 = 8, next_3 = 512
|
||||
1296 1296
|
||||
|
||||
|
||||
这段代码中,你首先注意一下 generator() 这个函数,它返回了一个生成器。
|
||||
|
||||
接下来的yield 是魔术的关键。对于初学者来说,你可以理解为,函数运行到这一行的时候,程序会从这里暂停,然后跳出,不过跳到哪里呢?答案是 next() 函数。那么 i ** k 是干什么的呢?它其实成了 next() 函数的返回值。
|
||||
|
||||
这样,每次 next(gen) 函数被调用的时候,暂停的程序就又复活了,从 yield 这里向下继续执行;同时注意,局部变量 i 并没有被清除掉,而是会继续累加。我们可以看到 next_1 从 1 变到 8,next_3 从 1 变到 512。
|
||||
|
||||
聪明的你应该注意到了,这个生成器居然可以一直进行下去!没错,事实上,迭代器是一个有限集合,生成器则可以成为一个无限集。我只管调用 next(),生成器根据运算会自动生成新的元素,然后返回给你,非常便捷。
|
||||
|
||||
到这里,土豪同志应该也坐不住了吧,那么,还能再给力一点吗?
|
||||
|
||||
别急,我们再来看一个问题:给定一个 list 和一个指定数字,求这个数字在 list 中的位置。
|
||||
|
||||
下面这段代码你应该不陌生,也就是常规做法,枚举每个元素和它的 index,判断后加入 result,最后返回。
|
||||
|
||||
def index_normal(L, target):
|
||||
result = []
|
||||
for i, num in enumerate(L):
|
||||
if num == target:
|
||||
result.append(i)
|
||||
return result
|
||||
|
||||
print(index_normal([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
[2, 5, 9]
|
||||
|
||||
|
||||
那么使用迭代器可以怎么做呢?二话不说,先看代码。
|
||||
|
||||
def index_generator(L, target):
|
||||
for i, num in enumerate(L):
|
||||
if num == target:
|
||||
yield i
|
||||
|
||||
print(list(index_generator([1, 6, 2, 4, 5, 2, 8, 6, 3, 2], 2)))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
[2, 5, 9]
|
||||
|
||||
|
||||
聪明的你应该看到了明显的区别,我就不做过多解释了。唯一需要强调的是, index_generator 会返回一个 Generator 对象,需要使用 list 转换为列表后,才能用 print 输出。
|
||||
|
||||
这里我再多说两句。在Python 语言规范中,用更少、更清晰的代码实现相同功能,一直是被推崇的做法,因为这样能够很有效提高代码的可读性,减少出错概率,也方便别人快速准确理解你的意图。当然,要注意,这里“更少”的前提是清晰,而不是使用更多的魔术操作,虽说减少了代码却反而增加了阅读的难度。
|
||||
|
||||
回归正题。接下来我们再来看一个问题:给定两个序列,判定第一个是不是第二个的子序列。(LeetCode 链接如下:https://leetcode.com/problems/is-subsequence/ )
|
||||
|
||||
先来解读一下这个问题本身。序列就是列表,子序列则指的是,一个列表的元素在第二个列表中都按顺序出现,但是并不必挨在一起。举个例子,[1, 3, 5] 是 [1, 2, 3, 4, 5] 的子序列,[1, 4, 3] 则不是。
|
||||
|
||||
要解决这个问题,常规算法是贪心算法。我们维护两个指针指向两个列表的最开始,然后对第二个序列一路扫过去,如果某个数字和第一个指针指的一样,那么就把第一个指针前进一步。第一个指针移出第一个序列最后一个元素的时候,返回 True,否则返回 False。
|
||||
|
||||
不过,这个算法正常写的话,写下来怎么也得十行左右。
|
||||
|
||||
那么如果我们用迭代器和生成器呢?
|
||||
|
||||
def is_subsequence(a, b):
|
||||
b = iter(b)
|
||||
return all(i in b for i in a)
|
||||
|
||||
print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
|
||||
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
True
|
||||
False
|
||||
|
||||
|
||||
这简短的几行代码,你是不是看得一头雾水,不知道发生了什么?
|
||||
|
||||
来,我们先把这段代码复杂化,然后一步步看。
|
||||
|
||||
def is_subsequence(a, b):
|
||||
b = iter(b)
|
||||
print(b)
|
||||
|
||||
gen = (i for i in a)
|
||||
print(gen)
|
||||
|
||||
for i in gen:
|
||||
print(i)
|
||||
|
||||
gen = ((i in b) for i in a)
|
||||
print(gen)
|
||||
|
||||
for i in gen:
|
||||
print(i)
|
||||
|
||||
return all(((i in b) for i in a))
|
||||
|
||||
print(is_subsequence([1, 3, 5], [1, 2, 3, 4, 5]))
|
||||
print(is_subsequence([1, 4, 3], [1, 2, 3, 4, 5]))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
<list_iterator object at 0x000001E7063D0E80>
|
||||
<generator object is_subsequence.<locals>.<genexpr> at 0x000001E70651C570>
|
||||
1
|
||||
3
|
||||
5
|
||||
<generator object is_subsequence.<locals>.<genexpr> at 0x000001E70651C5E8>
|
||||
True
|
||||
True
|
||||
True
|
||||
False
|
||||
<list_iterator object at 0x000001E7063D0D30>
|
||||
<generator object is_subsequence.<locals>.<genexpr> at 0x000001E70651C5E8>
|
||||
1
|
||||
4
|
||||
3
|
||||
<generator object is_subsequence.<locals>.<genexpr> at 0x000001E70651C570>
|
||||
True
|
||||
True
|
||||
False
|
||||
False
|
||||
|
||||
|
||||
首先,第二行的b = iter(b),把列表 b 转化成了一个迭代器,这里我先不解释为什么要这么做。
|
||||
|
||||
接下来的gen = (i for i in a)语句很好理解,产生一个生成器,这个生成器可以遍历对象 a,因此能够输出 1, 3, 5。而 (i in b)需要好好揣摩,这里你是不是能联想到 for in 语句?
|
||||
|
||||
没错,这里的(i in b),大致等价于下面这段代码:
|
||||
|
||||
while True:
|
||||
val = next(b)
|
||||
if val == i:
|
||||
yield True
|
||||
|
||||
|
||||
这里非常巧妙地利用生成器的特性,next() 函数运行的时候,保存了当前的指针。比如再看下面这个示例:
|
||||
|
||||
b = (i for i in range(5))
|
||||
|
||||
print(2 in b)
|
||||
print(4 in b)
|
||||
print(3 in b)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
True
|
||||
True
|
||||
False
|
||||
|
||||
|
||||
至于最后的 all() 函数,就很简单了。它用来判断一个迭代器的元素是否全部为 True,如果是则返回 True,否则就返回 False.
|
||||
|
||||
于是到此,我们就很优雅地解决了这道面试题。不过你一定注意,面试的时候尽量不要用这种技巧,因为你的面试官有可能并不知道生成器的用法,他们也没有看过我的极客时间专栏。不过,在这个技术知识点上,在实际工作的应用上,你已经比很多人更加熟练了。继续加油!
|
||||
|
||||
总结
|
||||
|
||||
总结一下,今天我们讲了四种不同的对象,分别是容器、可迭代对象、迭代器和生成器。
|
||||
|
||||
|
||||
容器是可迭代对象,可迭代对象调用 iter() 函数,可以得到一个迭代器。迭代器可以通过 next() 函数来得到下一个元素,从而支持遍历。
|
||||
生成器是一种特殊的迭代器(注意这个逻辑关系反之不成立)。使用生成器,你可以写出来更加清晰的代码;合理使用生成器,可以降低内存占用、优化程序结构、提高程序速度。
|
||||
生成器在 Python 2 的版本上,是协程的一种重要实现方式;而 Python 3.5 引入 async await 语法糖后,生成器实现协程的方式就已经落后了。我们会在下节课,继续深入讲解 Python 协程。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一个思考题。对于一个有限元素的生成器,如果迭代完成后,继续调用 next() ,会发生什么呢?生成器可以遍历多次吗?
|
||||
|
||||
欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起在交流中进步。
|
||||
|
||||
|
||||
|
||||
|
482
专栏/Python核心技术与实战/20揭秘Python协程.md
Normal file
482
专栏/Python核心技术与实战/20揭秘Python协程.md
Normal file
@ -0,0 +1,482 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 揭秘 Python 协程
|
||||
你好,我是景霄。
|
||||
|
||||
上一节课的最后,我们留下一个小小的悬念:生成器在 Python 2 中还扮演了一个重要角色,就是用来实现 Python 协程。
|
||||
|
||||
那么首先你要明白,什么是协程?
|
||||
|
||||
协程是实现并发编程的一种方式。一说并发,你肯定想到了多线程/多进程模型,没错,多线程/多进程,正是解决并发问题的经典模型之一。最初的互联网世界,多线程/多进程在服务器并发中,起到举足轻重的作用。
|
||||
|
||||
随着互联网的快速发展,你逐渐遇到了 C10K 瓶颈,也就是同时连接到服务器的客户达到了一万个。于是很多代码跑崩了,进程上下文切换占用了大量的资源,线程也顶不住如此巨大的压力,这时, NGINX 带着事件循环出来拯救世界了。
|
||||
|
||||
如果将多进程/多线程类比为起源于唐朝的藩镇割据,那么事件循环,就是宋朝加强的中央集权制。事件循环启动一个统一的调度器,让调度器来决定一个时刻去运行哪个任务,于是省却了多线程中启动线程、管理线程、同步锁等各种开销。同一时期的 NGINX,在高并发下能保持低资源低消耗高性能,相比 Apache 也支持更多的并发连接。
|
||||
|
||||
再到后来,出现了一个很有名的名词,叫做回调地狱(callback hell),手撸过 JavaScript 的朋友肯定知道我在说什么。我们大家惊喜地发现,这种工具完美地继承了事件循环的优越性,同时还能提供 async / await 语法糖,解决了执行性和可读性共存的难题。于是,协程逐渐被更多人发现并看好,也有越来越多的人尝试用 Node.js 做起了后端开发。(讲个笑话,JavaScript 是一门编程语言。)
|
||||
|
||||
回到我们的 Python。使用生成器,是 Python 2 开头的时代实现协程的老方法了,Python 3.7 提供了新的基于 asyncio 和 async / await 的方法。我们这节课,同样的,跟随时代,抛弃掉不容易理解、也不容易写的旧的基于生成器的方法,直接来讲新方法。
|
||||
|
||||
我们先从一个爬虫实例出发,用清晰的讲解思路,带你结合实战来搞懂这个不算特别容易理解的概念。之后,我们再由浅入深,直击协程的核心。
|
||||
|
||||
从一个爬虫说起
|
||||
|
||||
爬虫,就是互联网的蜘蛛,在搜索引擎诞生之时,与其一同来到世上。爬虫每秒钟都会爬取大量的网页,提取关键信息后存储在数据库中,以便日后分析。爬虫有非常简单的 Python 十行代码实现,也有 Google 那样的全球分布式爬虫的上百万行代码,分布在内部上万台服务器上,对全世界的信息进行嗅探。
|
||||
|
||||
话不多说,我们先看一个简单的爬虫例子:
|
||||
|
||||
import time
|
||||
|
||||
def crawl_page(url):
|
||||
print('crawling {}'.format(url))
|
||||
sleep_time = int(url.split('_')[-1])
|
||||
time.sleep(sleep_time)
|
||||
print('OK {}'.format(url))
|
||||
|
||||
def main(urls):
|
||||
for url in urls:
|
||||
crawl_page(url)
|
||||
|
||||
%time main(['url_1', 'url_2', 'url_3', 'url_4'])
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
crawling url_1
|
||||
OK url_1
|
||||
crawling url_2
|
||||
OK url_2
|
||||
crawling url_3
|
||||
OK url_3
|
||||
crawling url_4
|
||||
OK url_4
|
||||
Wall time: 10 s
|
||||
|
||||
|
||||
(注意:本节的主要目的是协程的基础概念,因此我们简化爬虫的 scrawl_page 函数为休眠数秒,休眠时间取决于 url 最后的那个数字。)
|
||||
|
||||
这是一个很简单的爬虫,main() 函数执行时,调取 crawl_page() 函数进行网络通信,经过若干秒等待后收到结果,然后执行下一个。
|
||||
|
||||
看起来很简单,但你仔细一算,它也占用了不少时间,五个页面分别用了 1 秒到 4 秒的时间,加起来一共用了 10 秒。这显然效率低下,该怎么优化呢?
|
||||
|
||||
于是,一个很简单的思路出现了——我们这种爬取操作,完全可以并发化。我们就来看看使用协程怎么写。
|
||||
|
||||
import asyncio
|
||||
|
||||
async def crawl_page(url):
|
||||
print('crawling {}'.format(url))
|
||||
sleep_time = int(url.split('_')[-1])
|
||||
await asyncio.sleep(sleep_time)
|
||||
print('OK {}'.format(url))
|
||||
|
||||
async def main(urls):
|
||||
for url in urls:
|
||||
await crawl_page(url)
|
||||
|
||||
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
crawling url_1
|
||||
OK url_1
|
||||
crawling url_2
|
||||
OK url_2
|
||||
crawling url_3
|
||||
OK url_3
|
||||
crawling url_4
|
||||
OK url_4
|
||||
Wall time: 10 s
|
||||
|
||||
|
||||
看到这段代码,你应该发现了,在 Python 3.7 以上版本中,使用协程写异步程序非常简单。
|
||||
|
||||
首先来看 import asyncio,这个库包含了大部分我们实现协程所需的魔法工具。
|
||||
|
||||
async 修饰词声明异步函数,于是,这里的 crawl_page 和 main 都变成了异步函数。而调用异步函数,我们便可得到一个协程对象(coroutine object)。
|
||||
|
||||
举个例子,如果你 print(crawl_page('')),便会输出<coroutine object crawl_page at 0x000002BEDF141148>,提示你这是一个 Python 的协程对象,而并不会真正执行这个函数。
|
||||
|
||||
再来说说协程的执行。执行协程有多种方法,这里我介绍一下常用的三种。
|
||||
|
||||
首先,我们可以通过 await 来调用。await 执行的效果,和 Python 正常执行是一样的,也就是说程序会阻塞在这里,进入被调用的协程函数,执行完毕返回后再继续,而这也是 await 的字面意思。代码中 await asyncio.sleep(sleep_time) 会在这里休息若干秒,await crawl_page(url) 则会执行 crawl_page() 函数。
|
||||
|
||||
其次,我们可以通过 asyncio.create_task() 来创建任务,这个我们下节课会详细讲一下,你先简单知道即可。
|
||||
|
||||
最后,我们需要 asyncio.run 来触发运行。asyncio.run 这个函数是 Python 3.7 之后才有的特性,可以让 Python 的协程接口变得非常简单,你不用去理会事件循环怎么定义和怎么使用的问题(我们会在下面讲)。一个非常好的编程规范是,asyncio.run(main()) 作为主程序的入口函数,在程序运行周期内,只调用一次 asyncio.run。
|
||||
|
||||
这样,你就大概看懂了协程是怎么用的吧。不妨试着跑一下代码,欸,怎么还是 10 秒?
|
||||
|
||||
10 秒就对了,还记得上面所说的,await 是同步调用,因此, crawl_page(url) 在当前的调用结束之前,是不会触发下一次调用的。于是,这个代码效果就和上面完全一样了,相当于我们用异步接口写了个同步代码。
|
||||
|
||||
现在又该怎么办呢?
|
||||
|
||||
其实很简单,也正是我接下来要讲的协程中的一个重要概念,任务(Task)。老规矩,先看代码。
|
||||
|
||||
import asyncio
|
||||
|
||||
async def crawl_page(url):
|
||||
print('crawling {}'.format(url))
|
||||
sleep_time = int(url.split('_')[-1])
|
||||
await asyncio.sleep(sleep_time)
|
||||
print('OK {}'.format(url))
|
||||
|
||||
async def main(urls):
|
||||
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
|
||||
for task in tasks:
|
||||
await task
|
||||
|
||||
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
crawling url_1
|
||||
crawling url_2
|
||||
crawling url_3
|
||||
crawling url_4
|
||||
OK url_1
|
||||
OK url_2
|
||||
OK url_3
|
||||
OK url_4
|
||||
Wall time: 3.99 s
|
||||
|
||||
|
||||
你可以看到,我们有了协程对象后,便可以通过 asyncio.create_task 来创建任务。任务创建后很快就会被调度执行,这样,我们的代码也不会阻塞在任务这里。所以,我们要等所有任务都结束才行,用for task in tasks: await task 即可。
|
||||
|
||||
这次,你就看到效果了吧,结果显示,运行总时长等于运行时间最长的爬虫。
|
||||
|
||||
当然,你也可以想一想,这里用多线程应该怎么写?而如果需要爬取的页面有上万个又该怎么办呢?再对比下协程的写法,谁更清晰自是一目了然。
|
||||
|
||||
其实,对于执行 tasks,还有另一种做法:
|
||||
|
||||
import asyncio
|
||||
|
||||
async def crawl_page(url):
|
||||
print('crawling {}'.format(url))
|
||||
sleep_time = int(url.split('_')[-1])
|
||||
await asyncio.sleep(sleep_time)
|
||||
print('OK {}'.format(url))
|
||||
|
||||
async def main(urls):
|
||||
tasks = [asyncio.create_task(crawl_page(url)) for url in urls]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
%time asyncio.run(main(['url_1', 'url_2', 'url_3', 'url_4']))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
crawling url_1
|
||||
crawling url_2
|
||||
crawling url_3
|
||||
crawling url_4
|
||||
OK url_1
|
||||
OK url_2
|
||||
OK url_3
|
||||
OK url_4
|
||||
Wall time: 4.01 s
|
||||
|
||||
|
||||
这里的代码也很好理解。唯一要注意的是,*tasks 解包列表,将列表变成了函数的参数;与之对应的是, ** dict 将字典变成了函数的参数。
|
||||
|
||||
另外,asyncio.create_task,asyncio.run 这些函数都是 Python 3.7 以上的版本才提供的,自然,相比于旧接口它们也更容易理解和阅读。
|
||||
|
||||
解密协程运行时
|
||||
|
||||
说了这么多,现在,我们不妨来深入代码底层看看。有了前面的知识做基础,你应该很容易理解这两段代码。
|
||||
|
||||
import asyncio
|
||||
|
||||
async def worker_1():
|
||||
print('worker_1 start')
|
||||
await asyncio.sleep(1)
|
||||
print('worker_1 done')
|
||||
|
||||
async def worker_2():
|
||||
print('worker_2 start')
|
||||
await asyncio.sleep(2)
|
||||
print('worker_2 done')
|
||||
|
||||
async def main():
|
||||
print('before await')
|
||||
await worker_1()
|
||||
print('awaited worker_1')
|
||||
await worker_2()
|
||||
print('awaited worker_2')
|
||||
|
||||
%time asyncio.run(main())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
before await
|
||||
worker_1 start
|
||||
worker_1 done
|
||||
awaited worker_1
|
||||
worker_2 start
|
||||
worker_2 done
|
||||
awaited worker_2
|
||||
Wall time: 3 s
|
||||
|
||||
|
||||
import asyncio
|
||||
|
||||
async def worker_1():
|
||||
print('worker_1 start')
|
||||
await asyncio.sleep(1)
|
||||
print('worker_1 done')
|
||||
|
||||
async def worker_2():
|
||||
print('worker_2 start')
|
||||
await asyncio.sleep(2)
|
||||
print('worker_2 done')
|
||||
|
||||
async def main():
|
||||
task1 = asyncio.create_task(worker_1())
|
||||
task2 = asyncio.create_task(worker_2())
|
||||
print('before await')
|
||||
await task1
|
||||
print('awaited worker_1')
|
||||
await task2
|
||||
print('awaited worker_2')
|
||||
|
||||
%time asyncio.run(main())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
before await
|
||||
worker_1 start
|
||||
worker_2 start
|
||||
worker_1 done
|
||||
awaited worker_1
|
||||
worker_2 done
|
||||
awaited worker_2
|
||||
Wall time: 2.01 s
|
||||
|
||||
|
||||
不过,第二个代码,到底发生了什么呢?为了让你更详细了解到协程和线程的具体区别,这里我详细地分析了整个过程。步骤有点多,别着急,我们慢慢来看。
|
||||
|
||||
|
||||
asyncio.run(main()),程序进入 main() 函数,事件循环开启;
|
||||
task1 和 task2 任务被创建,并进入事件循环等待运行;运行到 print,输出 'before await';
|
||||
await task1 执行,用户选择从当前的主任务中切出,事件调度器开始调度 worker_1;
|
||||
worker_1 开始运行,运行 print 输出'worker_1 start',然后运行到 await asyncio.sleep(1), 从当前任务切出,事件调度器开始调度 worker_2;
|
||||
worker_2 开始运行,运行 print 输出 'worker_2 start',然后运行 await asyncio.sleep(2) 从当前任务切出;
|
||||
以上所有事件的运行时间,都应该在 1ms 到 10ms 之间,甚至可能更短,事件调度器从这个时候开始暂停调度;
|
||||
一秒钟后,worker_1 的 sleep 完成,事件调度器将控制权重新传给 task_1,输出 'worker_1 done',task_1 完成任务,从事件循环中退出;
|
||||
await task1 完成,事件调度器将控制器传给主任务,输出 'awaited worker_1',·然后在 await task2 处继续等待;
|
||||
两秒钟后,worker_2 的 sleep 完成,事件调度器将控制权重新传给 task_2,输出 'worker_2 done',task_2 完成任务,从事件循环中退出;
|
||||
主任务输出 'awaited worker_2',协程全任务结束,事件循环结束。
|
||||
|
||||
|
||||
接下来,我们进阶一下。如果我们想给某些协程任务限定运行时间,一旦超时就取消,又该怎么做呢?再进一步,如果某些协程运行时出现错误,又该怎么处理呢?同样的,来看代码。
|
||||
|
||||
import asyncio
|
||||
|
||||
async def worker_1():
|
||||
await asyncio.sleep(1)
|
||||
return 1
|
||||
|
||||
async def worker_2():
|
||||
await asyncio.sleep(2)
|
||||
return 2 / 0
|
||||
|
||||
async def worker_3():
|
||||
await asyncio.sleep(3)
|
||||
return 3
|
||||
|
||||
async def main():
|
||||
task_1 = asyncio.create_task(worker_1())
|
||||
task_2 = asyncio.create_task(worker_2())
|
||||
task_3 = asyncio.create_task(worker_3())
|
||||
|
||||
await asyncio.sleep(2)
|
||||
task_3.cancel()
|
||||
|
||||
res = await asyncio.gather(task_1, task_2, task_3, return_exceptions=True)
|
||||
print(res)
|
||||
|
||||
%time asyncio.run(main())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
[1, ZeroDivisionError('division by zero'), CancelledError()]
|
||||
Wall time: 2 s
|
||||
|
||||
|
||||
你可以看到,worker_1 正常运行,worker_2 运行中出现错误,worker_3 执行时间过长被我们 cancel 掉了,这些信息会全部体现在最终的返回结果 res 中。
|
||||
|
||||
不过要注意return_exceptions=True这行代码。如果不设置这个参数,错误就会完整地 throw 到我们这个执行层,从而需要 try except 来捕捉,这也就意味着其他还没被执行的任务会被全部取消掉。为了避免这个局面,我们将 return_exceptions 设置为 True 即可。
|
||||
|
||||
到这里,发现了没,线程能实现的,协程都能做到。那就让我们温习一下这些知识点,用协程来实现一个经典的生产者消费者模型吧。
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
async def consumer(queue, id):
|
||||
while True:
|
||||
val = await queue.get()
|
||||
print('{} get a val: {}'.format(id, val))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def producer(queue, id):
|
||||
for i in range(5):
|
||||
val = random.randint(1, 10)
|
||||
await queue.put(val)
|
||||
print('{} put a val: {}'.format(id, val))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def main():
|
||||
queue = asyncio.Queue()
|
||||
|
||||
consumer_1 = asyncio.create_task(consumer(queue, 'consumer_1'))
|
||||
consumer_2 = asyncio.create_task(consumer(queue, 'consumer_2'))
|
||||
|
||||
producer_1 = asyncio.create_task(producer(queue, 'producer_1'))
|
||||
producer_2 = asyncio.create_task(producer(queue, 'producer_2'))
|
||||
|
||||
await asyncio.sleep(10)
|
||||
consumer_1.cancel()
|
||||
consumer_2.cancel()
|
||||
|
||||
await asyncio.gather(consumer_1, consumer_2, producer_1, producer_2, return_exceptions=True)
|
||||
|
||||
%time asyncio.run(main())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
producer_1 put a val: 5
|
||||
producer_2 put a val: 3
|
||||
consumer_1 get a val: 5
|
||||
consumer_2 get a val: 3
|
||||
producer_1 put a val: 1
|
||||
producer_2 put a val: 3
|
||||
consumer_2 get a val: 1
|
||||
consumer_1 get a val: 3
|
||||
producer_1 put a val: 6
|
||||
producer_2 put a val: 10
|
||||
consumer_1 get a val: 6
|
||||
consumer_2 get a val: 10
|
||||
producer_1 put a val: 4
|
||||
producer_2 put a val: 5
|
||||
consumer_2 get a val: 4
|
||||
consumer_1 get a val: 5
|
||||
producer_1 put a val: 2
|
||||
producer_2 put a val: 8
|
||||
consumer_1 get a val: 2
|
||||
consumer_2 get a val: 8
|
||||
Wall time: 10 s
|
||||
|
||||
|
||||
实战:豆瓣近日推荐电影爬虫
|
||||
|
||||
最后,进入今天的实战环节——实现一个完整的协程爬虫。
|
||||
|
||||
任务描述:https://movie.douban.com/cinema/later/beijing/ 这个页面描述了北京最近上映的电影,你能否通过 Python 得到这些电影的名称、上映时间和海报呢?这个页面的海报是缩小版的,我希望你能从具体的电影描述页面中抓取到海报。
|
||||
|
||||
听起来难度不是很大吧?我在下面给出了同步版本的代码和协程版本的代码,通过运行时间和代码写法的对比,希望你能对协程有更深的了解。(注意:为了突出重点、简化代码,这里我省略了异常处理。)
|
||||
|
||||
不过,在参考我给出的代码之前,你是不是可以自己先动手写一下、跑一下呢?
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
def main():
|
||||
url = "https://movie.douban.com/cinema/later/beijing/"
|
||||
init_page = requests.get(url).content
|
||||
init_soup = BeautifulSoup(init_page, 'lxml')
|
||||
|
||||
all_movies = init_soup.find('div', id="showing-soon")
|
||||
for each_movie in all_movies.find_all('div', class_="item"):
|
||||
all_a_tag = each_movie.find_all('a')
|
||||
all_li_tag = each_movie.find_all('li')
|
||||
|
||||
movie_name = all_a_tag[1].text
|
||||
url_to_fetch = all_a_tag[1]['href']
|
||||
movie_date = all_li_tag[0].text
|
||||
|
||||
response_item = requests.get(url_to_fetch).content
|
||||
soup_item = BeautifulSoup(response_item, 'lxml')
|
||||
img_tag = soup_item.find('img')
|
||||
|
||||
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
|
||||
|
||||
%time main()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
阿拉丁 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2553992741.jpg
|
||||
龙珠超:布罗利 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2557371503.jpg
|
||||
五月天人生无限公司 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2554324453.jpg
|
||||
... ...
|
||||
直播攻略 06月04日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555957974.jpg
|
||||
Wall time: 56.6 s
|
||||
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
async def fetch_content(url):
|
||||
async with aiohttp.ClientSession(
|
||||
headers=header, connector=aiohttp.TCPConnector(ssl=False)
|
||||
) as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.text()
|
||||
|
||||
async def main():
|
||||
url = "https://movie.douban.com/cinema/later/beijing/"
|
||||
init_page = await fetch_content(url)
|
||||
init_soup = BeautifulSoup(init_page, 'lxml')
|
||||
|
||||
movie_names, urls_to_fetch, movie_dates = [], [], []
|
||||
|
||||
all_movies = init_soup.find('div', id="showing-soon")
|
||||
for each_movie in all_movies.find_all('div', class_="item"):
|
||||
all_a_tag = each_movie.find_all('a')
|
||||
all_li_tag = each_movie.find_all('li')
|
||||
|
||||
movie_names.append(all_a_tag[1].text)
|
||||
urls_to_fetch.append(all_a_tag[1]['href'])
|
||||
movie_dates.append(all_li_tag[0].text)
|
||||
|
||||
tasks = [fetch_content(url) for url in urls_to_fetch]
|
||||
pages = await asyncio.gather(*tasks)
|
||||
|
||||
for movie_name, movie_date, page in zip(movie_names, movie_dates, pages):
|
||||
soup_item = BeautifulSoup(page, 'lxml')
|
||||
img_tag = soup_item.find('img')
|
||||
|
||||
print('{} {} {}'.format(movie_name, movie_date, img_tag['src']))
|
||||
|
||||
%time asyncio.run(main())
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
阿拉丁 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2553992741.jpg
|
||||
龙珠超:布罗利 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2557371503.jpg
|
||||
五月天人生无限公司 05月24日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2554324453.jpg
|
||||
... ...
|
||||
直播攻略 06月04日 https://img3.doubanio.com/view/photo/s_ratio_poster/public/p2555957974.jpg
|
||||
Wall time: 4.98 s
|
||||
|
||||
|
||||
总结
|
||||
|
||||
到这里,今天的主要内容就讲完了。今天我用了较长的篇幅,从一个简单的爬虫开始,到一个真正的爬虫结束,在中间穿插讲解了 Python 协程最新的基本概念和用法。这里带你简单复习一下。
|
||||
|
||||
|
||||
协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
|
||||
协程的写法更加简洁清晰,把async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
|
||||
写协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。
|
||||
|
||||
|
||||
最后的最后,请一定不要轻易炫技。多线程模型也一定有其优点,一个真正牛逼的程序员,应该懂得,在什么时候用什么模型能达到工程上的最优,而不是自觉某个技术非常牛逼,所有项目创造条件也要上。技术是工程,而工程则是时间、资源、人力等纷繁复杂的事情的折衷。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一个思考题。协程怎么实现回调函数呢?欢迎留言和我讨论,也欢迎你把这篇文章分享给你的同事朋友,我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
309
专栏/Python核心技术与实战/21Python并发编程之Futures.md
Normal file
309
专栏/Python核心技术与实战/21Python并发编程之Futures.md
Normal file
@ -0,0 +1,309 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 Python并发编程之Futures
|
||||
你好,我是景霄。
|
||||
|
||||
无论对于哪门语言,并发编程都是一项很常用很重要的技巧。比如我们上节课所讲的很常见的爬虫,就被广泛应用在工业界的各个领域。我们每天在各个网站、各个App上获取的新闻信息,很大一部分便是通过并发编程版的爬虫获得。
|
||||
|
||||
正确合理地使用并发编程,无疑会给我们的程序带来极大的性能提升。今天这节课,我就带你一起来学习理解、运用Python中的并发编程——Futures。
|
||||
|
||||
区分并发和并行
|
||||
|
||||
在我们学习并发编程时,常常同时听到并发(Concurrency)和并行(Parallelism)这两个术语,这两者经常一起使用,导致很多人以为它们是一个意思,其实不然。
|
||||
|
||||
首先你要辨别一个误区,在Python中,并发并不是指同一时刻有多个操作(thread、task)同时进行。相反,某个特定的时刻,它只允许有一个操作发生,只不过线程/任务之间会互相切换,直到完成。我们来看下面这张图:
|
||||
|
||||
|
||||
|
||||
图中出现了thread和task两种切换顺序的不同方式,分别对应Python中并发的两种形式——threading和asyncio。
|
||||
|
||||
对于threading,操作系统知道每个线程的所有信息,因此它会做主在适当的时候做线程切换。很显然,这样的好处是代码容易书写,因为程序员不需要做任何切换操作的处理;但是切换线程的操作,也有可能出现在一个语句执行的过程中(比如 x += 1),这样就容易出现race condition的情况。
|
||||
|
||||
而对于asyncio,主程序想要切换任务时,必须得到此任务可以被切换的通知,这样一来也就可以避免刚刚提到的 race condition的情况。
|
||||
|
||||
至于所谓的并行,指的才是同一时刻、同时发生。Python中的multi-processing便是这个意思,对于multi-processing,你可以简单地这么理解:比如你的电脑是6核处理器,那么在运行程序时,就可以强制Python开6个进程,同时执行,以加快运行速度,它的原理示意图如下:
|
||||
|
||||
|
||||
|
||||
对比来看,
|
||||
|
||||
|
||||
并发通常应用于I/O操作频繁的场景,比如你要从网站上下载多个文件,I/O操作的时间可能会比CPU运行处理的时间长得多。
|
||||
而并行则更多应用于CPU heavy的场景,比如MapReduce中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。
|
||||
|
||||
|
||||
并发编程之Futures
|
||||
|
||||
单线程与多线程性能比较
|
||||
|
||||
接下来,我们一起通过具体的实例,从代码的角度来理解并发编程中的Futures,并进一步来比较其与单线程的性能区别。
|
||||
|
||||
假设我们有一个任务,是下载一些网站的内容并打印。如果用单线程的方式,它的代码实现如下所示(为了简化代码,突出主题,此处我忽略了异常处理):
|
||||
|
||||
import requests
|
||||
import time
|
||||
|
||||
def download_one(url):
|
||||
resp = requests.get(url)
|
||||
print('Read {} from {}'.format(len(resp.content), url))
|
||||
|
||||
def download_all(sites):
|
||||
for site in sites:
|
||||
download_one(site)
|
||||
|
||||
def main():
|
||||
sites = [
|
||||
'https://en.wikipedia.org/wiki/Portal:Arts',
|
||||
'https://en.wikipedia.org/wiki/Portal:History',
|
||||
'https://en.wikipedia.org/wiki/Portal:Society',
|
||||
'https://en.wikipedia.org/wiki/Portal:Biography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Mathematics',
|
||||
'https://en.wikipedia.org/wiki/Portal:Technology',
|
||||
'https://en.wikipedia.org/wiki/Portal:Geography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Science',
|
||||
'https://en.wikipedia.org/wiki/Computer_science',
|
||||
'https://en.wikipedia.org/wiki/Python_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/Java_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/PHP',
|
||||
'https://en.wikipedia.org/wiki/Node.js',
|
||||
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
|
||||
'https://en.wikipedia.org/wiki/Go_(programming_language)'
|
||||
]
|
||||
start_time = time.perf_counter()
|
||||
download_all(sites)
|
||||
end_time = time.perf_counter()
|
||||
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
# 输出
|
||||
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
|
||||
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
|
||||
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
|
||||
Read 107637 from https://en.wikipedia.org/wiki/Portal:Biography
|
||||
Read 151021 from https://en.wikipedia.org/wiki/Portal:Mathematics
|
||||
Read 157811 from https://en.wikipedia.org/wiki/Portal:Technology
|
||||
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
|
||||
Read 93347 from https://en.wikipedia.org/wiki/Portal:Science
|
||||
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
|
||||
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
|
||||
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
|
||||
Read 468461 from https://en.wikipedia.org/wiki/PHP
|
||||
Read 180298 from https://en.wikipedia.org/wiki/Node.js
|
||||
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
|
||||
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
|
||||
Download 15 sites in 2.464231112999869 seconds
|
||||
|
||||
|
||||
这种方式应该是最直接也最简单的:
|
||||
|
||||
|
||||
先是遍历存储网站的列表;
|
||||
然后对当前网站执行下载操作;
|
||||
等到当前操作完成后,再对下一个网站进行同样的操作,一直到结束。
|
||||
|
||||
|
||||
我们可以看到总共耗时约2.4s。单线程的优点是简单明了,但是明显效率低下,因为上述程序的绝大多数时间,都浪费在了I/O等待上。程序每次对一个网站执行下载操作,都必须等到前一个网站下载完成后才能开始。如果放在实际生产环境中,我们需要下载的网站数量至少是以万为单位的,不难想象,这种方案根本行不通。
|
||||
|
||||
接着我们再来看,多线程版本的代码实现:
|
||||
|
||||
import concurrent.futures
|
||||
import requests
|
||||
import threading
|
||||
import time
|
||||
|
||||
def download_one(url):
|
||||
resp = requests.get(url)
|
||||
print('Read {} from {}'.format(len(resp.content), url))
|
||||
|
||||
|
||||
def download_all(sites):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
executor.map(download_one, sites)
|
||||
|
||||
def main():
|
||||
sites = [
|
||||
'https://en.wikipedia.org/wiki/Portal:Arts',
|
||||
'https://en.wikipedia.org/wiki/Portal:History',
|
||||
'https://en.wikipedia.org/wiki/Portal:Society',
|
||||
'https://en.wikipedia.org/wiki/Portal:Biography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Mathematics',
|
||||
'https://en.wikipedia.org/wiki/Portal:Technology',
|
||||
'https://en.wikipedia.org/wiki/Portal:Geography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Science',
|
||||
'https://en.wikipedia.org/wiki/Computer_science',
|
||||
'https://en.wikipedia.org/wiki/Python_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/Java_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/PHP',
|
||||
'https://en.wikipedia.org/wiki/Node.js',
|
||||
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
|
||||
'https://en.wikipedia.org/wiki/Go_(programming_language)'
|
||||
]
|
||||
start_time = time.perf_counter()
|
||||
download_all(sites)
|
||||
end_time = time.perf_counter()
|
||||
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
## 输出
|
||||
Read 151021 from https://en.wikipedia.org/wiki/Portal:Mathematics
|
||||
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
|
||||
Read 107637 from https://en.wikipedia.org/wiki/Portal:Biography
|
||||
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
|
||||
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
|
||||
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
|
||||
Read 157811 from https://en.wikipedia.org/wiki/Portal:Technology
|
||||
Read 91533 from https://en.wikipedia.org/wiki/Portal:Science
|
||||
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
|
||||
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
|
||||
Read 180298 from https://en.wikipedia.org/wiki/Node.js
|
||||
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
|
||||
Read 468461 from https://en.wikipedia.org/wiki/PHP
|
||||
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
|
||||
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
|
||||
Download 15 sites in 0.19936635800002023 seconds
|
||||
|
||||
|
||||
非常明显,总耗时是0.2s左右,效率一下子提升了10倍多。
|
||||
|
||||
我们具体来看这段代码,它是多线程版本和单线程版的主要区别所在:
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
executor.map(download_one, sites)
|
||||
|
||||
|
||||
这里我们创建了一个线程池,总共有5个线程可以分配使用。executer.map()与前面所讲的Python内置的map()函数类似,表示对sites中的每一个元素,并发地调用函数download_one()。
|
||||
|
||||
顺便提一下,在download_one()函数中,我们使用的requests.get()方法是线程安全的(thread-safe),因此在多线程的环境下,它也可以安全使用,并不会出现race condition的情况。
|
||||
|
||||
另外,虽然线程的数量可以自己定义,但是线程数并不是越多越好,因为线程的创建、维护和删除也会有一定的开销。所以如果你设置的很大,反而可能会导致速度变慢。我们往往需要根据实际的需求做一些测试,来寻找最优的线程数量。
|
||||
|
||||
当然,我们也可以用并行的方式去提高程序运行效率。你只需要在download_all()函数中,做出下面的变化即可:
|
||||
|
||||
with futures.ThreadPoolExecutor(workers) as executor
|
||||
=>
|
||||
with futures.ProcessPoolExecutor() as executor:
|
||||
|
||||
|
||||
在需要修改的这部分代码中,函数ProcessPoolExecutor()表示创建进程池,使用多个进程并行的执行程序。不过,这里我们通常省略参数workers,因为系统会自动返回CPU的数量作为可以调用的进程数。
|
||||
|
||||
我刚刚提到过,并行的方式一般用在CPU heavy的场景中,因为对于I/O heavy的操作,多数时间都会用于等待,相比于多线程,使用多进程并不会提升效率。反而很多时候,因为CPU数量的限制,会导致其执行效率不如多线程版本。
|
||||
|
||||
到底什么是 Futures ?
|
||||
|
||||
Python中的Futures模块,位于concurrent.futures和asyncio中,它们都表示带有延迟的操作。Futures会将处于等待状态的操作包裹起来放到队列中,这些操作的状态随时可以查询,当然,它们的结果或是异常,也能够在操作完成后被获取。
|
||||
|
||||
通常来说,作为用户,我们不用考虑如何去创建Futures,这些Futures底层都会帮我们处理好。我们要做的,实际上是去schedule这些Futures的执行。
|
||||
|
||||
比如,Futures中的Executor类,当我们执行executor.submit(func)时,它便会安排里面的func()函数执行,并返回创建好的future实例,以便你之后查询调用。
|
||||
|
||||
这里再介绍一些常用的函数。Futures中的方法done(),表示相对应的操作是否完成——True表示完成,False表示没有完成。不过,要注意,done()是non-blocking的,会立即返回结果。相对应的add_done_callback(fn),则表示Futures完成后,相对应的参数函数fn,会被通知并执行调用。
|
||||
|
||||
Futures中还有一个重要的函数result(),它表示当future完成后,返回其对应的结果或异常。而as_completed(fs),则是针对给定的future迭代器fs,在其完成后,返回完成后的迭代器。
|
||||
|
||||
所以,上述例子也可以写成下面的形式:
|
||||
|
||||
import concurrent.futures
|
||||
import requests
|
||||
import time
|
||||
|
||||
def download_one(url):
|
||||
resp = requests.get(url)
|
||||
print('Read {} from {}'.format(len(resp.content), url))
|
||||
|
||||
def download_all(sites):
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
to_do = []
|
||||
for site in sites:
|
||||
future = executor.submit(download_one, site)
|
||||
to_do.append(future)
|
||||
|
||||
for future in concurrent.futures.as_completed(to_do):
|
||||
future.result()
|
||||
def main():
|
||||
sites = [
|
||||
'https://en.wikipedia.org/wiki/Portal:Arts',
|
||||
'https://en.wikipedia.org/wiki/Portal:History',
|
||||
'https://en.wikipedia.org/wiki/Portal:Society',
|
||||
'https://en.wikipedia.org/wiki/Portal:Biography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Mathematics',
|
||||
'https://en.wikipedia.org/wiki/Portal:Technology',
|
||||
'https://en.wikipedia.org/wiki/Portal:Geography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Science',
|
||||
'https://en.wikipedia.org/wiki/Computer_science',
|
||||
'https://en.wikipedia.org/wiki/Python_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/Java_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/PHP',
|
||||
'https://en.wikipedia.org/wiki/Node.js',
|
||||
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
|
||||
'https://en.wikipedia.org/wiki/Go_(programming_language)'
|
||||
]
|
||||
start_time = time.perf_counter()
|
||||
download_all(sites)
|
||||
end_time = time.perf_counter()
|
||||
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
# 输出
|
||||
Read 129886 from https://en.wikipedia.org/wiki/Portal:Arts
|
||||
Read 107634 from https://en.wikipedia.org/wiki/Portal:Biography
|
||||
Read 224118 from https://en.wikipedia.org/wiki/Portal:Society
|
||||
Read 158984 from https://en.wikipedia.org/wiki/Portal:Mathematics
|
||||
Read 184343 from https://en.wikipedia.org/wiki/Portal:History
|
||||
Read 157949 from https://en.wikipedia.org/wiki/Portal:Technology
|
||||
Read 167923 from https://en.wikipedia.org/wiki/Portal:Geography
|
||||
Read 94228 from https://en.wikipedia.org/wiki/Portal:Science
|
||||
Read 391905 from https://en.wikipedia.org/wiki/Python_(programming_language)
|
||||
Read 321352 from https://en.wikipedia.org/wiki/Computer_science
|
||||
Read 180298 from https://en.wikipedia.org/wiki/Node.js
|
||||
Read 321417 from https://en.wikipedia.org/wiki/Java_(programming_language)
|
||||
Read 468421 from https://en.wikipedia.org/wiki/PHP
|
||||
Read 56765 from https://en.wikipedia.org/wiki/The_C_Programming_Language
|
||||
Read 324039 from https://en.wikipedia.org/wiki/Go_(programming_language)
|
||||
Download 15 sites in 0.21698231499976828 seconds
|
||||
|
||||
|
||||
这里,我们首先调用executor.submit(),将下载每一个网站的内容都放进future队列to_do,等待执行。然后是as_completed()函数,在future完成后,便输出结果。
|
||||
|
||||
不过,这里要注意,future列表中每个future完成的顺序,和它在列表中的顺序并不一定完全一致。到底哪个先完成、哪个后完成,取决于系统的调度和每个future的执行时间。
|
||||
|
||||
为什么多线程每次只能有一个线程执行?
|
||||
|
||||
前面我说过,同一时刻,Python主程序只允许有一个线程执行,所以Python的并发,是通过多线程的切换完成的。你可能会疑惑这到底是为什么呢?
|
||||
|
||||
这里我简单提一下全局解释器锁的概念,具体内容后面会讲到。
|
||||
|
||||
事实上,Python的解释器并不是线程安全的,为了解决由此带来的race condition等问题,Python便引入了全局解释器锁,也就是同一时刻,只允许一个线程执行。当然,在执行I/O操作时,如果一个线程被block了,全局解释器锁便会被释放,从而让另一个线程能够继续执行。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们首先学习了Python中并发和并行的概念与区别。
|
||||
|
||||
|
||||
并发,通过线程和任务之间互相切换的方式实现,但同一时刻,只允许有一个线程或任务执行。
|
||||
而并行,则是指多个进程同时执行。
|
||||
|
||||
|
||||
并发通常用于I/O操作频繁的场景,而并行则适用于CPU heavy的场景。
|
||||
|
||||
随后,我们通过下载网站内容的例子,比较了单线程和运用Futures的多线程版本的性能差异。显而易见,合理地运用多线程,能够极大地提高程序运行效率。
|
||||
|
||||
我们还一起学习了Futures的具体原理,介绍了一些常用函数比如done()、result()、as_completed()等的用法,并辅以实例加以理解。
|
||||
|
||||
要注意,Python中之所以同一时刻只允许一个线程运行,其实是由于全局解释器锁的存在。但是对I/O操作而言,当其被block的时候,全局解释器锁便会被释放,使其他线程继续执行。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。你能否通过查阅相关文档,为今天所讲的这个下载网站内容的例子,加上合理的异常处理,让程序更加稳定健壮呢?欢迎在留言区写下你的思考和答案,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。
|
||||
|
||||
|
||||
|
||||
|
222
专栏/Python核心技术与实战/22并发编程之Asyncio.md
Normal file
222
专栏/Python核心技术与实战/22并发编程之Asyncio.md
Normal file
@ -0,0 +1,222 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 并发编程之Asyncio
|
||||
你好,我是景霄。
|
||||
|
||||
上节课,我们一起学习了Python并发编程的一种实现——多线程。今天这节课,我们继续学习Python并发编程的另一种实现方式——Asyncio。不同于协程那章,这节课我们更注重原理的理解。
|
||||
|
||||
通过上节课的学习,我们知道,在处理I/O操作时,使用多线程与普通的单线程相比,效率得到了极大的提高。你可能会想,既然这样,为什么还需要Asyncio?
|
||||
|
||||
诚然,多线程有诸多优点且应用广泛,但也存在一定的局限性:
|
||||
|
||||
|
||||
比如,多线程运行过程容易被打断,因此有可能出现race condition的情况;
|
||||
再如,线程切换本身存在一定的损耗,线程数不能无限增加,因此,如果你的 I/O操作非常heavy,多线程很有可能满足不了高效率、高质量的需求。
|
||||
|
||||
|
||||
正是为了解决这些问题,Asyncio应运而生。
|
||||
|
||||
什么是Asyncio
|
||||
|
||||
Sync VS Async
|
||||
|
||||
我们首先来区分一下Sync(同步)和Async(异步)的概念。
|
||||
|
||||
|
||||
所谓Sync,是指操作一个接一个地执行,下一个操作必须等上一个操作完成后才能执行。
|
||||
而Async是指不同操作间可以相互交替执行,如果其中的某个操作被block了,程序并不会等待,而是会找出可执行的操作继续执行。
|
||||
|
||||
|
||||
举个简单的例子,你的老板让你做一份这个季度的报表,并且邮件发给他。
|
||||
|
||||
|
||||
如果按照Sync的方式,你会先向软件输入这个季度的各项数据,接下来等待5min,等报表明细生成后,再写邮件发给他。
|
||||
但如果按照Async的方式,再你输完这个季度的各项数据后,便会开始写邮件。等报表明细生成后,你会暂停邮件,先去查看报表,确认后继续写邮件直到发送完毕。
|
||||
|
||||
|
||||
Asyncio工作原理
|
||||
|
||||
明白了Sync 和Async,回到我们今天的主题,到底什么是Asyncio呢?
|
||||
|
||||
事实上,Asyncio和其他Python程序一样,是单线程的,它只有一个主线程,但是可以进行多个不同的任务(task),这里的任务,就是特殊的future对象。这些不同的任务,被一个叫做event loop的对象所控制。你可以把这里的任务,类比成多线程版本里的多个线程。
|
||||
|
||||
为了简化讲解这个问题,我们可以假设任务只有两个状态:一是预备状态;二是等待状态。所谓的预备状态,是指任务目前空闲,但随时待命准备运行。而等待状态,是指任务已经运行,但正在等待外部的操作完成,比如I/O操作。
|
||||
|
||||
在这种情况下,event loop会维护两个任务列表,分别对应这两种状态;并且选取预备状态的一个任务(具体选取哪个任务,和其等待的时间长短、占用的资源等等相关),使其运行,一直到这个任务把控制权交还给event loop为止。
|
||||
|
||||
当任务把控制权交还给event loop时,event loop会根据其是否完成,把任务放到预备或等待状态的列表,然后遍历等待状态列表的任务,查看他们是否完成。
|
||||
|
||||
|
||||
如果完成,则将其放到预备状态的列表;
|
||||
如果未完成,则继续放在等待状态的列表。
|
||||
|
||||
|
||||
而原先在预备状态列表的任务位置仍旧不变,因为它们还未运行。
|
||||
|
||||
这样,当所有任务被重新放置在合适的列表后,新一轮的循环又开始了:event loop继续从预备状态的列表中选取一个任务使其执行…如此周而复始,直到所有任务完成。
|
||||
|
||||
值得一提的是,对于Asyncio来说,它的任务在运行时不会被外部的一些因素打断,因此Asyncio内的操作不会出现race condition的情况,这样你就不需要担心线程安全的问题了。
|
||||
|
||||
Asyncio用法
|
||||
|
||||
讲完了Asyncio的原理,我们结合具体的代码来看一下它的用法。还是以上节课下载网站内容为例,用Asyncio的写法我放在了下面代码中(省略了异常处理的一些操作),接下来我们一起来看:
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import time
|
||||
|
||||
async def download_one(url):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as resp:
|
||||
print('Read {} from {}'.format(resp.content_length, url))
|
||||
|
||||
async def download_all(sites):
|
||||
tasks = [asyncio.create_task(download_one(site)) for site in sites]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def main():
|
||||
sites = [
|
||||
'https://en.wikipedia.org/wiki/Portal:Arts',
|
||||
'https://en.wikipedia.org/wiki/Portal:History',
|
||||
'https://en.wikipedia.org/wiki/Portal:Society',
|
||||
'https://en.wikipedia.org/wiki/Portal:Biography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Mathematics',
|
||||
'https://en.wikipedia.org/wiki/Portal:Technology',
|
||||
'https://en.wikipedia.org/wiki/Portal:Geography',
|
||||
'https://en.wikipedia.org/wiki/Portal:Science',
|
||||
'https://en.wikipedia.org/wiki/Computer_science',
|
||||
'https://en.wikipedia.org/wiki/Python_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/Java_(programming_language)',
|
||||
'https://en.wikipedia.org/wiki/PHP',
|
||||
'https://en.wikipedia.org/wiki/Node.js',
|
||||
'https://en.wikipedia.org/wiki/The_C_Programming_Language',
|
||||
'https://en.wikipedia.org/wiki/Go_(programming_language)'
|
||||
]
|
||||
start_time = time.perf_counter()
|
||||
asyncio.run(download_all(sites))
|
||||
end_time = time.perf_counter()
|
||||
print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
## 输出
|
||||
Read 63153 from https://en.wikipedia.org/wiki/Java_(programming_language)
|
||||
Read 31461 from https://en.wikipedia.org/wiki/Portal:Society
|
||||
Read 23965 from https://en.wikipedia.org/wiki/Portal:Biography
|
||||
Read 36312 from https://en.wikipedia.org/wiki/Portal:History
|
||||
Read 25203 from https://en.wikipedia.org/wiki/Portal:Arts
|
||||
Read 15160 from https://en.wikipedia.org/wiki/The_C_Programming_Language
|
||||
Read 28749 from https://en.wikipedia.org/wiki/Portal:Mathematics
|
||||
Read 29587 from https://en.wikipedia.org/wiki/Portal:Technology
|
||||
Read 79318 from https://en.wikipedia.org/wiki/PHP
|
||||
Read 30298 from https://en.wikipedia.org/wiki/Portal:Geography
|
||||
Read 73914 from https://en.wikipedia.org/wiki/Python_(programming_language)
|
||||
Read 62218 from https://en.wikipedia.org/wiki/Go_(programming_language)
|
||||
Read 22318 from https://en.wikipedia.org/wiki/Portal:Science
|
||||
Read 36800 from https://en.wikipedia.org/wiki/Node.js
|
||||
Read 67028 from https://en.wikipedia.org/wiki/Computer_science
|
||||
Download 15 sites in 0.062144195078872144 seconds
|
||||
|
||||
|
||||
这里的Async和await关键字是Asyncio的最新写法,表示这个语句/函数是non-block的,正好对应前面所讲的event loop的概念。如果任务执行的过程需要等待,则将其放入等待状态的列表中,然后继续执行预备状态列表里的任务。
|
||||
|
||||
主函数里的asyncio.run(coro)是Asyncio的root call,表示拿到event loop,运行输入的coro,直到它结束,最后关闭这个event loop。事实上,asyncio.run()是Python3.7+才引入的,相当于老版本的以下语句:
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
至于Asyncio版本的函数download_all(),和之前多线程版本有很大的区别:
|
||||
|
||||
tasks = [asyncio.create_task(download_one(site)) for site in sites]
|
||||
await asyncio.gather(*task)
|
||||
|
||||
|
||||
这里的asyncio.create_task(coro),表示对输入的协程coro创建一个任务,安排它的执行,并返回此任务对象。这个函数也是Python 3.7+新增的,如果是之前的版本,你可以用asyncio.ensure_future(coro)等效替代。可以看到,这里我们对每一个网站的下载,都创建了一个对应的任务。
|
||||
|
||||
再往下看,asyncio.gather(*aws, loop=None, return_exception=False),则表示在event loop中运行aws序列的所有任务。当然,除了例子中用到的这几个函数,Asyncio还提供了很多其他的用法,你可以查看 相应文档 进行了解。
|
||||
|
||||
最后,我们再来看一下最后的输出结果——用时只有0.06s,效率比起之前的多线程版本,可以说是更上一层楼,充分体现其优势。
|
||||
|
||||
Asyncio有缺陷吗?
|
||||
|
||||
学了这么多内容,我们认识到了Asyncio的强大,但你要清楚,任何一种方案都不是完美的,都存在一定的局限性,Asyncio同样如此。
|
||||
|
||||
实际工作中,想用好Asyncio,特别是发挥其强大的功能,很多情况下必须得有相应的Python库支持。你可能注意到了,上节课的多线程编程中,我们使用的是requests库,但今天我们并没有使用,而是用了aiohttp库,原因就是requests库并不兼容Asyncio,但是aiohttp库兼容。
|
||||
|
||||
Asyncio软件库的兼容性问题,在Python3的早期一直是个大问题,但是随着技术的发展,这个问题正逐步得到解决。
|
||||
|
||||
另外,使用Asyncio时,因为你在任务的调度方面有了更大的自主权,写代码时就得更加注意,不然很容易出错。
|
||||
|
||||
举个例子,如果你需要await一系列的操作,就得使用asyncio.gather();如果只是单个的future,或许只用asyncio.wait()就可以了。那么,对于你的future,你是想要让它run_until_complete()还是run_forever()呢?诸如此类,都是你在面对具体问题时需要考虑的。
|
||||
|
||||
多线程还是Asyncio
|
||||
|
||||
不知不觉,我们已经把并发编程的两种方式都给学习完了。不过,遇到实际问题时,多线程和Asyncio到底如何选择呢?
|
||||
|
||||
总的来说,你可以遵循以下伪代码的规范:
|
||||
|
||||
if io_bound:
|
||||
if io_slow:
|
||||
print('Use Asyncio')
|
||||
else:
|
||||
print('Use multi-threading')
|
||||
else if cpu_bound:
|
||||
print('Use multi-processing')
|
||||
|
||||
|
||||
|
||||
如果是I/O bound,并且I/O操作很慢,需要很多任务/线程协同实现,那么使用Asyncio更合适。
|
||||
如果是I/O bound,但是I/O操作很快,只需要有限数量的任务/线程,那么使用多线程就可以了。
|
||||
如果是CPU bound,则需要使用多进程来提高程序运行效率。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们一起学习了Asyncio的原理和用法,并比较了Asyncio和多线程各自的优缺点。
|
||||
|
||||
不同于多线程,Asyncio是单线程的,但其内部event loop的机制,可以让它并发地运行多个不同的任务,并且比多线程享有更大的自主控制权。
|
||||
|
||||
Asyncio中的任务,在运行过程中不会被打断,因此不会出现race condition的情况。尤其是在I/O操作heavy的场景下,Asyncio比多线程的运行效率更高。因为Asyncio内部任务切换的损耗,远比线程切换的损耗要小;并且Asyncio可以开启的任务数量,也比多线程中的线程数量多得多。
|
||||
|
||||
但需要注意的是,很多情况下,使用Asyncio需要特定第三方库的支持,比如前面示例中的aiohttp。而如果I/O操作很快,并不heavy,那么运用多线程,也能很有效地解决问题。
|
||||
|
||||
思考题
|
||||
|
||||
这两节课,我们学习了并发编程的两种实现方式,也多次提到了并行编程(multi-processing),其适用于CPU heavy的场景。
|
||||
|
||||
现在有这么一个需求:输入一个列表,对于列表中的每个元素,我想计算0到这个元素的所有整数的平方和。
|
||||
|
||||
我把常规版本的写法放在了下面,你能通过查阅资料,写出它的多进程版本,并且比较程序的耗时吗?
|
||||
|
||||
import time
|
||||
def cpu_bound(number):
|
||||
print(sum(i * i for i in range(number)))
|
||||
|
||||
def calculate_sums(numbers):
|
||||
for number in numbers:
|
||||
cpu_bound(number)
|
||||
|
||||
def main():
|
||||
start_time = time.perf_counter()
|
||||
numbers = [10000000 + x for x in range(20)]
|
||||
calculate_sums(numbers)
|
||||
end_time = time.perf_counter()
|
||||
print('Calculation takes {} seconds'.format(end_time - start_time))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
欢迎在留言区写下你的思考和答案,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。
|
||||
|
||||
|
||||
|
||||
|
213
专栏/Python核心技术与实战/23你真的懂PythonGIL(全局解释器锁)吗?.md
Normal file
213
专栏/Python核心技术与实战/23你真的懂PythonGIL(全局解释器锁)吗?.md
Normal file
@ -0,0 +1,213 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 你真的懂Python GIL(全局解释器锁)吗?
|
||||
你好,我是景霄。
|
||||
|
||||
前面几节课,我们学习了Python的并发编程特性,也了解了多线程编程。事实上,Python多线程另一个很重要的话题——GIL(Global Interpreter Lock,即全局解释器锁)却鲜有人知,甚至连很多Python“老司机”都觉得GIL就是一个谜。今天我就来为你解谜,带你一起来看GIL。
|
||||
|
||||
一个不解之谜
|
||||
|
||||
耳听为虚,眼见为实。我们不妨先来看一个例子,让你感受下GIL为什么会让人不明所以。
|
||||
|
||||
比如下面这段很简单的cpu-bound代码:
|
||||
|
||||
def CountDown(n):
|
||||
while n > 0:
|
||||
n -= 1
|
||||
|
||||
|
||||
现在,假设一个很大的数字n = 100000000,我们先来试试单线程的情况下执行CountDown(n)。在我手上这台号称8核的MacBook上执行后,我发现它的耗时为5.4s。
|
||||
|
||||
这时,我们想要用多线程来加速,比如下面这几行操作:
|
||||
|
||||
from threading import Thread
|
||||
|
||||
n = 100000000
|
||||
|
||||
t1 = Thread(target=CountDown, args=[n // 2])
|
||||
t2 = Thread(target=CountDown, args=[n // 2])
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
|
||||
我又在同一台机器上跑了一下,结果发现,这不仅没有得到速度的提升,反而让运行变慢,总共花了9.6s。
|
||||
|
||||
我还是不死心,决定使用四个线程再试一次,结果发现运行时间还是9.8s,和2个线程的结果几乎一样。
|
||||
|
||||
这是怎么回事呢?难道是我买了假的MacBook吗?你可以先自己思考一下这个问题,也可以在自己电脑上测试一下。我当然也要自我反思一下,并且提出了下面两个猜想。
|
||||
|
||||
第一个怀疑:我的机器出问题了吗?
|
||||
|
||||
这不得不说也是一个合理的猜想。因此我又找了一个单核CPU的台式机,跑了一下上面的实验。这次我发现,在单核CPU电脑上,单线程运行需要11s时间,2个线程运行也是11s时间。虽然不像第一台机器那样,多线程反而比单线程更慢,但是这两次整体效果几乎一样呀!
|
||||
|
||||
看起来,这不像是电脑的问题,而是Python的线程失效了,没有起到并行计算的作用。
|
||||
|
||||
顺理成章,我又有了第二个怀疑:Python的线程是不是假的线程?
|
||||
|
||||
Python的线程,的的确确封装了底层的操作系统线程,在Linux系统里是Pthread(全称为POSIX Thread),而在Windows系统里是Windows Thread。另外,Python的线程,也完全受操作系统管理,比如协调何时执行、管理内存资源、管理中断等等。
|
||||
|
||||
所以,虽然Python的线程和C++的线程本质上是不同的抽象,但它们的底层并没有什么不同。
|
||||
|
||||
为什么有GIL?
|
||||
|
||||
看来我的两个猜想,都不能解释开头的这个未解之谜。那究竟谁才是“罪魁祸首”呢?事实上,正是我们今天的主角,也就是GIL,导致了Python线程的性能并不像我们期望的那样。
|
||||
|
||||
GIL,是最流行的Python解释器CPython中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的Mutex。每一个Python线程,在CPython解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。
|
||||
|
||||
当然,CPython会做一些小把戏,轮流执行Python线程。这样一来,用户看到的就是“伪并行”——Python线程在交错执行,来模拟真正并行的线程。
|
||||
|
||||
那么,为什么CPython需要GIL呢?这其实和CPython的实现有关。下一节我们会讲Python的内存管理机制,今天先稍微提一下。
|
||||
|
||||
CPython使用引用计数来管理内存,所有Python脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有0时,则会自动释放内存。
|
||||
|
||||
什么意思呢?我们来看下面这个例子:
|
||||
|
||||
>>> import sys
|
||||
>>> a = []
|
||||
>>> b = a
|
||||
>>> sys.getrefcount(a)
|
||||
3
|
||||
|
||||
|
||||
这个例子中,a的引用计数是3,因为有a、b和作为参数传递的getrefcount这三个地方,都引用了一个空列表。
|
||||
|
||||
这样一来,如果有两个Python线程同时引用了a,就会造成引用计数的race condition,引用计数可能最终只增加1,这样就会造成内存被污染。因为第一个线程结束时,会把引用计数减少1,这时可能达到条件释放内存,当第二个线程再试图访问a时,就找不到有效的内存了。
|
||||
|
||||
所以说,CPython 引进 GIL 其实主要就是这么两个原因:
|
||||
|
||||
|
||||
一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
|
||||
二是因为CPython大量使用C语言库,但大部分C语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
|
||||
|
||||
|
||||
GIL是如何工作的?
|
||||
|
||||
下面这张图,就是一个GIL在Python程序的工作示例。其中,Thread 1、2、3轮流执行,每一个线程在开始执行时,都会锁住GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放GIL,以允许别的线程开始利用资源。
|
||||
|
||||
|
||||
|
||||
细心的你可能会发现一个问题:为什么Python线程会去主动释放GIL呢?毕竟,如果仅仅是要求Python线程在开始执行时锁住GIL,而永远不去释放GIL,那别的线程就都没有了运行的机会。
|
||||
|
||||
没错,CPython中还有另一个机制,叫做check_interval,意思是CPython解释器会去轮询检查线程GIL的锁住情况。每隔一段时间,Python解释器就会强制当前线程去释放GIL,这样别的线程才能有执行的机会。
|
||||
|
||||
不同版本的Python中,check interval的实现方式并不一样。早期的Python是100个ticks,大致对应了1000个bytecodes;而 Python 3以后,interval是15毫秒。当然,我们不必细究具体多久会强制释放GIL,这不应该成为我们程序设计的依赖条件,我们只需明白,CPython解释器会在一个“合理”的时间范围内释放GIL就可以了。
|
||||
|
||||
|
||||
|
||||
整体来说,每一个Python线程都是类似这样循环的封装,我们来看下面这段代码:
|
||||
|
||||
for (;;) {
|
||||
if (--ticker < 0) {
|
||||
ticker = check_interval;
|
||||
|
||||
/* Give another thread a chance */
|
||||
PyThread_release_lock(interpreter_lock);
|
||||
|
||||
/* Other threads may run now */
|
||||
|
||||
PyThread_acquire_lock(interpreter_lock, 1);
|
||||
}
|
||||
|
||||
bytecode = *next_instr++;
|
||||
switch (bytecode) {
|
||||
/* execute the next instruction ... */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
从这段代码中,我们可以看到,每个Python线程都会先检查ticker计数。只有在ticker大于0的情况下,线程才会去执行自己的bytecode。
|
||||
|
||||
Python的线程安全
|
||||
|
||||
不过,有了GIL,并不意味着我们Python编程者就不用去考虑线程安全了。即使我们知道,GIL仅允许一个Python线程执行,但前面我也讲到了,Python还有check interval这样的抢占机制。我们来考虑这样一段代码:
|
||||
|
||||
import threading
|
||||
|
||||
n = 0
|
||||
|
||||
def foo():
|
||||
global n
|
||||
n += 1
|
||||
|
||||
threads = []
|
||||
for i in range(100):
|
||||
t = threading.Thread(target=foo)
|
||||
threads.append(t)
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
print(n)
|
||||
|
||||
|
||||
如果你执行的话,就会发现,尽管大部分时候它能够打印100,但有时侯也会打印99或者98。
|
||||
|
||||
这其实就是因为,n+=1这一句代码让线程并不安全。如果你去翻译foo这个函数的bytecode,就会发现,它实际上由下面四行bytecode组成:
|
||||
|
||||
>>> import dis
|
||||
>>> dis.dis(foo)
|
||||
LOAD_GLOBAL 0 (n)
|
||||
LOAD_CONST 1 (1)
|
||||
INPLACE_ADD
|
||||
STORE_GLOBAL 0 (n)
|
||||
|
||||
|
||||
而这四行bytecode中间都是有可能被打断的!
|
||||
|
||||
所以,千万别想着,有了GIL你的程序就可以高枕无忧了,我们仍然需要去注意线程安全。正如我开头所说,GIL的设计,主要是为了方便CPython解释器层面的编写者,而不是Python应用层面的程序员。作为Python的使用者,我们还是需要lock等工具,来确保线程安全。比如我下面的这个例子:
|
||||
|
||||
n = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def foo():
|
||||
global n
|
||||
with lock:
|
||||
n += 1
|
||||
|
||||
|
||||
如何绕过GIL?
|
||||
|
||||
学到这里,估计有的Python使用者感觉自己像被废了武功一样,觉得降龙十八掌只剩下了一掌。其实大可不必,你并不需要太沮丧。Python的GIL,是通过CPython的解释器加的限制。如果你的代码并不需要CPython解释器来执行,就不再受GIL的限制。
|
||||
|
||||
事实上,很多高性能应用场景都已经有大量的C实现的Python库,例如NumPy的矩阵运算,就都是通过C来实现的,并不受GIL影响。
|
||||
|
||||
所以,大部分应用情况下,你并不需要过多考虑GIL。因为如果多线程计算成为性能瓶颈,往往已经有Python库来解决这个问题了。
|
||||
|
||||
换句话说,如果你的应用真的对性能有超级严格的要求,比如100us就对你的应用有很大影响,那我必须要说,Python可能不是你的最优选择。
|
||||
|
||||
当然,可以理解的是,我们难以避免的有时候就是想临时给自己松松绑,摆脱GIL,比如在深度学习应用里,大部分代码就都是Python的。在实际工作中,如果我们想实现一个自定义的微分算子,或者是一个特定硬件的加速器,那我们就不得不把这些关键性能(performance-critical)代码在C++中实现(不再受GIL所限),然后再提供Python的调用接口。
|
||||
|
||||
总的来说,你只需要重点记住,绕过GIL的大致思路有这么两种就够了:
|
||||
|
||||
|
||||
绕过CPython,使用JPython(Java实现的Python解释器)等别的实现;
|
||||
把关键性能代码,放到别的语言(一般是C++)中实现。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们先通过一个实际的例子,了解了GIL对于应用的影响;之后我们适度剖析了GIL的实现原理,你不必深究一些原理的细节,明白其主要机制和存在的隐患即可。
|
||||
|
||||
自然,我也为你提供了绕过GIL的两种思路。不过还是那句话,很多时候,我们并不需要过多纠结GIL的影响。
|
||||
|
||||
思考题
|
||||
|
||||
最后,我给你留下两道思考题。
|
||||
|
||||
第一问,在我们处理cpu-bound的任务(文中第一个例子)时,为什么有时候使用多线程会比单线程还要慢些?
|
||||
|
||||
第二问,你觉得GIL是一个好的设计吗?事实上,在Python 3之后,确实有很多关于GIL改进甚至是取消的讨论,你的看法是什么呢?你在平常工作中有被GIL困扰过的场景吗?
|
||||
|
||||
欢迎在留言区写下你的想法,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。
|
||||
|
||||
|
||||
|
||||
|
332
专栏/Python核心技术与实战/24带你解析Python垃圾回收机制.md
Normal file
332
专栏/Python核心技术与实战/24带你解析Python垃圾回收机制.md
Normal file
@ -0,0 +1,332 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 带你解析 Python 垃圾回收机制
|
||||
你好,我是景霄。
|
||||
|
||||
众所周知,我们当代的计算机都是图灵机架构。图灵机架构的本质,就是一条无限长的纸带,对应着我们今天的存储器。在工程学的演化中,逐渐出现了寄存器、易失性存储器(内存)和永久性存储器(硬盘)等产品。其实,这本身来自一个矛盾:速度越快的存储器,单位价格也越昂贵。因此,妥善利用好每一寸高速存储器的空间,永远是系统设计的一个核心。
|
||||
|
||||
回到 Python 应用层。
|
||||
|
||||
我们知道,Python 程序在运行的时候,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量;计算完成后,再将结果输出到永久性存储器中。如果数据量过大,内存空间管理不善就很容易出现 OOM(out of memory),俗称爆内存,程序可能被操作系统中止。
|
||||
|
||||
而对于服务器,这种设计为永不中断的系统来说,内存管理则显得更为重要,不然很容易引发内存泄漏。什么是内存泄漏呢?
|
||||
|
||||
|
||||
这里的泄漏,并不是说你的内存出现了信息安全问题,被恶意程序利用了,而是指程序本身没有设计好,导致程序未能释放已不再使用的内存。
|
||||
内存泄漏也不是指你的内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计错误,失去了对这段内存的控制,从而造成了内存的浪费。
|
||||
|
||||
|
||||
那么,Python 又是怎么解决这些问题的?换句话说,对于不会再用到的内存空间,Python 是通过什么机制来回收这些空间的呢?
|
||||
|
||||
计数引用
|
||||
|
||||
我们反复提过好几次, Python 中一切皆对象。因此,你所看到的一切变量,本质上都是对象的一个指针。
|
||||
|
||||
那么,怎么知道一个对象,是否永远都不能被调用了呢?
|
||||
|
||||
我们上节课提到过的,也是非常直观的一个想法,就是当这个对象的引用计数(指针数)为 0 的时候,说明这个对象永不可达,自然它也就成为了垃圾,需要被回收。
|
||||
|
||||
我们来看一个例子:
|
||||
|
||||
import os
|
||||
import psutil
|
||||
|
||||
# 显示当前 python 程序占用的内存大小
|
||||
def show_memory_info(hint):
|
||||
pid = os.getpid()
|
||||
p = psutil.Process(pid)
|
||||
|
||||
info = p.memory_full_info()
|
||||
memory = info.uss / 1024. / 1024
|
||||
print('{} memory used: {} MB'.format(hint, memory))
|
||||
|
||||
|
||||
def func():
|
||||
show_memory_info('initial')
|
||||
a = [i for i in range(10000000)]
|
||||
show_memory_info('after a created')
|
||||
|
||||
func()
|
||||
show_memory_info('finished')
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 47.19140625 MB
|
||||
after a created memory used: 433.91015625 MB
|
||||
finished memory used: 48.109375 MB
|
||||
|
||||
|
||||
通过这个示例,你可以看到,调用函数 func(),在列表 a 被创建之后,内存占用迅速增加到了 433 MB:而在函数调用结束后,内存则返回正常。
|
||||
|
||||
这是因为,函数内部声明的列表 a 是局部变量,在函数返回后,局部变量的引用会注销掉;此时,列表 a 所指代对象的引用数为 0,Python 便会执行垃圾回收,因此之前占用的大量内存就又回来了。
|
||||
|
||||
明白了这个原理后,我们稍微修改一下代码:
|
||||
|
||||
def func():
|
||||
show_memory_info('initial')
|
||||
global a
|
||||
a = [i for i in range(10000000)]
|
||||
show_memory_info('after a created')
|
||||
|
||||
func()
|
||||
show_memory_info('finished')
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 48.88671875 MB
|
||||
after a created memory used: 433.94921875 MB
|
||||
finished memory used: 433.94921875 MB
|
||||
|
||||
|
||||
新的这段代码中,global a 表示将 a 声明为全局变量。那么,即使函数返回后,列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存。
|
||||
|
||||
同样,如果我们把生成的列表返回,然后在主程序中接收,那么引用依然存在,垃圾回收就不会被触发,大量内存仍然被占用着:
|
||||
|
||||
def func():
|
||||
show_memory_info('initial')
|
||||
a = [i for i in derange(10000000)]
|
||||
show_memory_info('after a created')
|
||||
return a
|
||||
|
||||
a = func()
|
||||
show_memory_info('finished')
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 47.96484375 MB
|
||||
after a created memory used: 434.515625 MB
|
||||
finished memory used: 434.515625 MB
|
||||
|
||||
|
||||
这是最常见的几种情况。由表及里,下面,我们深入看一下 Python 内部的引用计数机制。老规矩,先来看代码:
|
||||
|
||||
import sys
|
||||
|
||||
a = []
|
||||
|
||||
# 两次引用,一次来自 a,一次来自 getrefcount
|
||||
print(sys.getrefcount(a))
|
||||
|
||||
def func(a):
|
||||
# 四次引用,a,python 的函数调用栈,函数参数,和 getrefcount
|
||||
print(sys.getrefcount(a))
|
||||
|
||||
func(a)
|
||||
|
||||
# 两次引用,一次来自 a,一次来自 getrefcount,函数 func 调用已经不存在
|
||||
print(sys.getrefcount(a))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
2
|
||||
4
|
||||
2
|
||||
|
||||
|
||||
简单介绍一下,sys.getrefcount() 这个函数,可以查看一个变量的引用次数。这段代码本身应该很好理解,不过别忘了,getrefcount 本身也会引入一次计数。
|
||||
|
||||
另一个要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数。
|
||||
|
||||
import sys
|
||||
|
||||
a = []
|
||||
|
||||
print(sys.getrefcount(a)) # 两次
|
||||
|
||||
b = a
|
||||
|
||||
print(sys.getrefcount(a)) # 三次
|
||||
|
||||
c = b
|
||||
d = b
|
||||
e = c
|
||||
f = e
|
||||
g = d
|
||||
|
||||
print(sys.getrefcount(a)) # 八次
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
2
|
||||
3
|
||||
8
|
||||
|
||||
|
||||
看到这段代码,需要你稍微注意一下,a、b、c、d、e、f、g 这些变量全部指代的是同一个对象,而sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有八次引用。
|
||||
|
||||
理解引用这个概念后,引用释放是一种非常自然和清晰的思想。相比 C 语言里,你需要使用 free 去手动释放内存,Python 的垃圾回收在这里可以说是省心省力了。
|
||||
|
||||
不过,我想还是会有人问,如果我偏偏想手动释放内存,应该怎么做呢?
|
||||
|
||||
方法同样很简单。你只需要先调用 del a 来删除对象的引用;然后强制调用 gc.collect(),清除没有引用的对象,即可手动启动垃圾回收。
|
||||
|
||||
import gc
|
||||
|
||||
show_memory_info('initial')
|
||||
|
||||
a = [i for i in range(10000000)]
|
||||
|
||||
show_memory_info('after a created')
|
||||
|
||||
del a
|
||||
gc.collect()
|
||||
|
||||
show_memory_info('finish')
|
||||
print(a)
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 48.1015625 MB
|
||||
after a created memory used: 434.3828125 MB
|
||||
finish memory used: 48.33203125 MB
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
NameError Traceback (most recent call last)
|
||||
<ipython-input-12-153e15063d8a> in <module>
|
||||
11
|
||||
12 show_memory_info('finish')
|
||||
---> 13 print(a)
|
||||
|
||||
NameError: name 'a' is not defined
|
||||
|
||||
|
||||
到这里,是不是觉得垃圾回收非常简单呀?
|
||||
|
||||
我想,肯定有人觉得自己都懂了,那么,如果此时有面试官问:引用次数为 0 是垃圾回收启动的充要条件吗?还有没有其他可能性呢?
|
||||
|
||||
这个问题,你能回答的上来吗?
|
||||
|
||||
循环引用
|
||||
|
||||
如果你也被困住了,别急。我们不妨小步设问,先来思考这么一个问题:如果有两个对象,它们互相引用,并且不再被别的对象所引用,那么它们应该被垃圾回收吗?
|
||||
|
||||
请仔细观察下面这段代码:
|
||||
|
||||
def func():
|
||||
show_memory_info('initial')
|
||||
a = [i for i in range(10000000)]
|
||||
b = [i for i in range(10000000)]
|
||||
show_memory_info('after a, b created')
|
||||
a.append(b)
|
||||
b.append(a)
|
||||
|
||||
func()
|
||||
show_memory_info('finished')
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 47.984375 MB
|
||||
after a, b created memory used: 822.73828125 MB
|
||||
finished memory used: 821.73046875 MB
|
||||
|
||||
|
||||
这里,a 和 b 互相引用,并且,作为局部变量,在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在了。但是,很明显,依然有内存占用!为什么呢?因为互相引用,导致它们的引用数都不为 0。
|
||||
|
||||
试想一下,如果这段代码出现在生产环境中,哪怕 a 和 b 一开始占用的空间不是很大,但经过长时间运行后,Python 所占用的内存一定会变得越来越大,最终撑爆服务器,后果不堪设想。
|
||||
|
||||
当然,有人可能会说,互相引用还是很容易被发现的呀,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环还真不一定能被轻易发现。
|
||||
|
||||
那么,我们应该怎么做呢?
|
||||
|
||||
事实上,Python 本身能够处理这种情况,我们刚刚讲过的,可以显式调用 gc.collect() ,来启动垃圾回收。
|
||||
|
||||
import gc
|
||||
|
||||
def func():
|
||||
show_memory_info('initial')
|
||||
a = [i for i in range(10000000)]
|
||||
b = [i for i in range(10000000)]
|
||||
show_memory_info('after a, b created')
|
||||
a.append(b)
|
||||
b.append(a)
|
||||
|
||||
func()
|
||||
gc.collect()
|
||||
show_memory_info('finished')
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
initial memory used: 49.51171875 MB
|
||||
after a, b created memory used: 824.1328125 MB
|
||||
finished memory used: 49.98046875 MB
|
||||
|
||||
|
||||
所以你看,Python 的垃圾回收机制并没有那么弱。
|
||||
|
||||
Python 使用标记清除(mark-sweep)算法和分代收集(generational),来启用针对循环引用的自动垃圾回收。你可能不太熟悉这两个词,这里我简单介绍一下。
|
||||
|
||||
先来看标记清除算法。我们先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们就称之为不可达节点。显而易见,这些节点的存在是没有任何意义的,自然的,我们就需要对它们进行垃圾回收。
|
||||
|
||||
当然,每次都遍历全图,对于 Python 而言是一种巨大的性能浪费。所以,在 Python 的垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。具体算法这里我就不再多讲了,毕竟我们的重点是关注应用。
|
||||
|
||||
而分代收集算法,则是另一个优化手段。
|
||||
|
||||
Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
|
||||
|
||||
事实上,分代收集基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高 Python 的性能。
|
||||
|
||||
学了这么多,刚刚面试官的问题,你应该能回答得上来了吧!没错,引用计数是其中最简单的实现,不过切记,引用计数并非充要条件,它只能算作充分非必要条件;至于其他的可能性,我们所讲的循环引用正是其中一种。
|
||||
|
||||
调试内存泄漏
|
||||
|
||||
不过,虽然有了自动回收机制,但这也不是万能的,难免还是会有漏网之鱼。内存泄漏是我们不想见到的,而且还会严重影响性能。有没有什么好的调试手段呢?
|
||||
|
||||
答案当然是肯定的,接下来我就为你介绍一个“得力助手”。
|
||||
|
||||
它就是objgraph,一个非常好用的可视化引用关系的包。在这个包中,我主要推荐两个函数,第一个是show_refs(),它可以生成清晰的引用关系图。
|
||||
|
||||
通过下面这段代码和生成的引用调用图,你能非常直观地发现,有两个 list 互相引用,说明这里极有可能引起内存泄露。这样一来,再去代码层排查就容易多了。
|
||||
|
||||
import objgraph
|
||||
|
||||
a = [1, 2, 3]
|
||||
b = [4, 5, 6]
|
||||
|
||||
a.append(b)
|
||||
b.append(a)
|
||||
|
||||
objgraph.show_refs([a])
|
||||
|
||||
|
||||
|
||||
|
||||
而另一个非常有用的函数,是 show_backrefs()。下面同样为示例代码和生成图,你可以自己先阅读一下:
|
||||
|
||||
import objgraph
|
||||
|
||||
a = [1, 2, 3]
|
||||
b = [4, 5, 6]
|
||||
|
||||
a.append(b)
|
||||
b.append(a)
|
||||
|
||||
objgraph.show_backrefs([a])
|
||||
|
||||
|
||||
|
||||
|
||||
相比刚才的引用调用图,这张图显得稍微复杂一些。不过,我仍旧推荐你掌握它,因为这个 API 有很多有用的参数,比如层数限制(max_depth)、宽度限制(too_many)、输出格式控制(filename output)、节点过滤(filter, extra_ignore)等。所以,建议你使用之前,先认真看一下文档。
|
||||
|
||||
总结
|
||||
|
||||
最后,带你来总结一下。今天这节课,我们深入了解了Python 的垃圾回收机制,我主要强调下面这几点:
|
||||
|
||||
|
||||
垃圾回收是 Python 自带的机制,用于自动释放不会再用到的内存空间;
|
||||
引用计数是其中最简单的实现,不过切记,这只是充分非必要条件,因为循环引用需要通过不可达判定,来确定是否可以回收;
|
||||
Python 的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集;
|
||||
调试内存泄漏方面, objgraph 是很好的可视化分析工具。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。你能否自己实现一个垃圾回收判定算法呢?我的要求很简单,输入是一个有向图,给定起点,表示程序入口点;给定有向边,输出不可达节点。
|
||||
|
||||
希望你可以认真思考这个问题,并且在留言区写下你的答案与我讨论。也欢迎你把这篇文章分享出去,我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
116
专栏/Python核心技术与实战/25答疑(二):GIL与多线程是什么关系呢?.md
Normal file
116
专栏/Python核心技术与实战/25答疑(二):GIL与多线程是什么关系呢?.md
Normal file
@ -0,0 +1,116 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 答疑(二):GIL与多线程是什么关系呢?
|
||||
你好,我是景霄。
|
||||
|
||||
不知不觉中,我们又一起完成了第二大章进阶篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
|
||||
|
||||
大部分留言,我都在相对应的文章中回复过了。而一些手机上不方便回复,或是很有价值很典型的问题,我专门摘录了出来,作为今天的答疑内容,集中回复。
|
||||
|
||||
问题一:列表self append无限嵌套的原理
|
||||
|
||||
|
||||
|
||||
先来回答第一个问题,两个同学都问到了,下面这段代码中的x,为什么是无限嵌套的列表?
|
||||
|
||||
x = [1]
|
||||
x.append(x)
|
||||
x
|
||||
[1, [...]]
|
||||
|
||||
|
||||
我们可以将上述操作画一个图,便于你更直观地理解:
|
||||
|
||||
|
||||
|
||||
这里,x指向一个列表,列表的第一个元素为1;执行了append操作后,第二个元素又反过来指向x,即指向了x所指向的列表,因此形成了一个无限嵌套的循环:[1, [1, [1, [1, …]]]]。
|
||||
|
||||
不过,虽然x是无限嵌套的列表,但x.append(x)的操作,并不会递归遍历其中的每一个元素。它只是扩充了原列表的第二个元素,并将其指向x,因此不会出现stack overflow的问题,自然不会报错。
|
||||
|
||||
至于第二点,为什么len(x)返回的是2?我们还是来看x,虽然它是无限嵌套的列表,但x的top level只有2个元素组成,第一个元素为1,第二个元素为指向自身的列表,因此len(x)返回2。
|
||||
|
||||
问题二:装饰器的宏观理解
|
||||
|
||||
|
||||
|
||||
再来看第二个问题,胡峣同学对装饰器的疑问。事实上,装饰器的作用与意义,在于其可以通过自定义的函数或类,在不改变原函数的基础上,改变原函数的一些功能。
|
||||
|
||||
Decorators is to modify the behavior of the function through a wrapper so we don't have to actually modify the function.
|
||||
|
||||
|
||||
装饰器将额外增加的功能,封装在自己的装饰器函数或类中;如果你想要调用它,只需要在原函数的顶部,加上@decorator即可。显然,这样做可以让你的代码得到高度的抽象、分离与简化。
|
||||
|
||||
光说概念可能还是有点抽象,我们可以想象下面这样一个场景,从真实例子来感受装饰器的魅力。在一些社交网站的后台,有无数的操作在调用之前,都需要先检查用户是否登录,比如在一些帖子里发表评论、发表状态等等。
|
||||
|
||||
如果你不知道装饰器,用常规的方法来编程,写出来的代码大概是下面这样的:
|
||||
|
||||
# 发表评论
|
||||
def post_comment(request, ...):
|
||||
if not authenticate(request):
|
||||
raise Exception('U must log in first')
|
||||
...
|
||||
|
||||
# 发表状态
|
||||
def post_moment(request, ...):
|
||||
if not authenticate(request):
|
||||
raise Exception('U must log in first')
|
||||
...
|
||||
|
||||
|
||||
显然,这样重复调用认证函数authenticate()的步骤,就显得非常冗余了。更好的解决办法,就是将认证函数authenticate()单独分离出来,写成一个装饰器,就像我们下面这样的写法。这样一来,代码便得到了高度的优化:
|
||||
|
||||
# 发表评论
|
||||
@authenticate
|
||||
def post_comment(request, ...):
|
||||
|
||||
# 发表状态
|
||||
@authenticate
|
||||
def post_moment(request, ...):
|
||||
|
||||
|
||||
不过也要注意,很多情况下,装饰器并不是唯一的方法。而我这里强调的,主要是使用装饰器带来的好处:
|
||||
|
||||
|
||||
代码更加简洁;
|
||||
逻辑更加清晰;
|
||||
程序的层次化、分离化更加明显。
|
||||
|
||||
|
||||
而这也是我们应该遵循和优先选择的开发模式。
|
||||
|
||||
问题三:GIL与多线程的关系
|
||||
|
||||
|
||||
|
||||
第三个问题,new同学疑惑的是,GIL只支持单线程,而Python支持多线程,这两者之间究竟是什么关系呢?
|
||||
|
||||
其实,GIL的存在与Python支持多线程并不矛盾。前面我们讲过,GIL是指同一时刻,程序只能有一个线程运行;而Python中的多线程,是指多个线程交替执行,造成一个“伪并行”的结果,但是具体到某一时刻,仍然只有1个线程在运行,并不是真正的多线程并行。这个机制,我画了下面这张图来表示:
|
||||
|
||||
|
||||
|
||||
举个例子来理解。比如,我用10个线程来爬取50个网站的内容。线程1在爬取第1个网站时,被I/O block住了,处于等待状态;这时,GIL就会释放,而线程2就会开始执行,去爬取第2个网站,依次类推。等到线程1的I/O操作完成时,主程序便又会切回线程1,让其完成剩下的操作。这样一来,从用户角度看到的,便是我们所说的多线程。
|
||||
|
||||
问题四:多进程与多线程的应用场景
|
||||
|
||||
|
||||
|
||||
第四个问题,这个在文章中多次提到,不过,我还是想在这里再次强调一下。
|
||||
|
||||
如果你想对CPU密集型任务加速,使用多线程是无效的,请使用多进程。这里所谓的CPU密集型任务,是指会消耗大量CPU资源的任务,比如求1到100000000的乘积,或者是把一段很长的文字编码后又解码等等。
|
||||
|
||||
使用多线程之所以无效,原因正是我们前面刚讲过的,Python多线程的本质是多个线程互相切换,但同一时刻仍然只允许一个线程运行。因此,你使用多线程,和使用一个主线程,本质上来说并没有什么差别;反而在很多情况下,因为线程切换带来额外损耗,还会降低程序的效率。
|
||||
|
||||
而如果使用多进程,就可以允许多个进程之间in parallel地执行任务,所以能够有效提高程序的运行效率。
|
||||
|
||||
至于 I/O密集型任务,如果想要加速,请优先使用多线程或Asyncio。当然,使用多进程也可以达到目的,但是完全没有这个必要。因为对I/O密集型任务来说,大多数时间都浪费在了I/O等待上。因此,在一个线程/任务等待I/O时,我们只需要切换线程/任务去执行其他 I/O操作就可以了。
|
||||
|
||||
不过,如果I/O操作非常多、非常heavy,需要建立的连接也比较多时,我们一般会选择Asyncio。因为Asyncio的任务切换更加轻量化,并且它能启动的任务数也远比多线程启动的线程数要多。当然,如果I/O的操作不是那么的heavy,那么使用多线程也就足够了。
|
||||
|
||||
今天主要回答这几个问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望每一次的留言和答疑,都能给你带来新的收获和价值。
|
||||
|
||||
|
||||
|
||||
|
235
专栏/Python核心技术与实战/26活都来不及干了,还有空注意代码风格?!.md
Normal file
235
专栏/Python核心技术与实战/26活都来不及干了,还有空注意代码风格?!.md
Normal file
@ -0,0 +1,235 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 活都来不及干了,还有空注意代码风格?!
|
||||
你好,我是蔡元楠,是极客时间《大规模数据处理实战》的作者。今天是我第二次受邀来我们专栏分享了,很高兴再次见到你。今天我分享的主题是:活都来不及干了,还有空注意代码风格吗?!
|
||||
|
||||
许多来Google参观的人,用完洗手间后,都会惊奇而略带羞涩地问:“你们马桶前面的门上,贴着的Python编程规范,是用来搞笑的吗?”
|
||||
|
||||
这事儿还真不是搞笑,Google对编码规范的要求极其严格。今天,我们就来聊聊编程规范这件事儿。
|
||||
|
||||
对于编程规范(style guide) 的认知,很多人可能只停留在第一阶段:知道编程规范有用,整个公司都要求使用驼峰式命名。而后面的阶段,比如为什么和怎么做,就并不了解了。
|
||||
|
||||
但在Google,对于编程规范的信仰,可能超出很多人的想象,我给你简单介绍几点。
|
||||
|
||||
|
||||
每一个语言都有专门的委员会(Style Committee)制定全公司强制的编程规范,和负责在编程风格争议时的仲裁人(Style Arbiters)。
|
||||
在每个语言相应的编程规范群里,每天都有大量的讨论和辩论。新达成的共识会被写出“大字报”张贴在厕所里,以至于每个人甚至来访者都能用坐着的时候那零碎的5分钟阅读。
|
||||
每一个代码提交,类似于Git里diff的概念,都需要至少两次代码评审(code review),一次针对业务逻辑,一次针对可读性(readability review)。所谓的可读性评审,着重在代码风格规范上。只有通过考核的人,才能够成为可读性评审人(readability reviewer)。
|
||||
有大量的开发自动化工具,确保以上的准则得到强制实施。例如,代码提交前会有linter做静态规则检查,不通过是无法提交代码的。
|
||||
|
||||
|
||||
看到这里,不知道你有怎样的感受?我自己十分认同这样的工程师文化,所以今天,我会给你介绍清楚两点:
|
||||
|
||||
|
||||
Python的编程规范为什么重要,这对于业务开发来说,究竟有没有帮助?
|
||||
有哪些流程和工具,可以整合到已有的开发流程中,让你的编程规范强制自动执行呢?
|
||||
|
||||
|
||||
在讲解过程中,我会适时引用两个条例来举例,分别是:
|
||||
|
||||
|
||||
《8号Python增强规范》(Python Enhacement Proposal #8),以下简称PEP8;
|
||||
《Google Python 风格规范》(Google Python Style Guide),以下简称Google Style,这是源自Google内部的风格规范。公开发布的社区版本,是为了让Google旗下所有Python开源项目的编程风格统一。(http://google.github.io/styleguide/pyguide.html)
|
||||
|
||||
|
||||
相对来说,Google Style是比PEP8更严格的一个编程规范。因为PEP8的受众是个人和小团队开发者,而Google Style能够胜任大团队,企业级,百万行级别代码库。他们的内容,后面我也会简单说明。
|
||||
|
||||
统一的编程规范为什么重要?
|
||||
|
||||
用一句话来概括,统一的编程规范能提高开发效率。而开发效率,关乎三类对象,也就是阅读者、编程者和机器。他们的优先级是阅读者的体验 >> 编程者的体验 >> 机器的体验。
|
||||
|
||||
阅读者的体验>>编程者的体验
|
||||
|
||||
写过代码的人可能都有体会,在我们的实际工作中,真正在打字的时间,远比阅读或者debug的时间要少。事实正是如此,研究表明,软件工程中80%的时间都在阅读代码。所以,为了提高开发效率,我们要优化的,不是你的打字时间,而是团队阅读的体验。
|
||||
|
||||
其实,不少的编程规范,本来就是为了优化读者体验而存在的。举个例子,对于命名原则,我想很多人应该都有所理解,PEP8第38条规定命名必须有意义,不能是无意义的单字母。
|
||||
|
||||
有些人可能会说,啊,编程规范好烦哟,变量名一定要我写完整,打起来好累。但是当你作为阅读者时,一定能分辨下面两种代码的可读性不同:
|
||||
|
||||
# 错误示例
|
||||
if (a <= 0):
|
||||
return
|
||||
elif (a > b):
|
||||
return
|
||||
else:
|
||||
b -= a
|
||||
|
||||
# 正确示例
|
||||
if (transfer_amount <= 0):
|
||||
raise Exception('...')
|
||||
elif (transfer_amount > balance):
|
||||
raise Exception('...')
|
||||
else:
|
||||
balance -= transfer_amount
|
||||
|
||||
|
||||
再举一个例子,Google Style 2.2条规定,Python代码中的import对象,只能是package或者module。
|
||||
|
||||
# 错误示例
|
||||
from mypkg import Obj
|
||||
from mypkg import my_func
|
||||
|
||||
my_func([1, 2, 3])
|
||||
|
||||
# 正确示例
|
||||
import numpy as np
|
||||
import mypkg
|
||||
|
||||
np.array([6, 7, 8])
|
||||
|
||||
|
||||
以上错误示例在语法上完全合法(因为没有符号冲突name collisions),但是对于读者来讲,它们的可读性太差了。因为my_func这样的名字,如果没有一个package name提供上下文语境,读者很难单独通过my_func这个名字来推测它的可能功能,也很难在debug时根据package name找到可能的问题。
|
||||
|
||||
反观正确示例,虽然array是如此大众脸的名字,但因为有了numpy这个package的暗示,读者可以一下子反应过来,哦,这是一个numpy array。不过这里要注意区别,这个例子和符号冲突(name collisions)是正交(orthogonal)的两个概念,即使没有符号冲突,我们也要遵循这样的import规范。
|
||||
|
||||
编程者的体验 >> 机器的体验
|
||||
|
||||
说完了阅读者的体验,再来聊聊编程者的体验。我常常见到的一个错误倾向,是过度简化自己的代码,包括我自己也有这样的问题。一个典型的例子,就是盲目地使用Python的list comprehension。
|
||||
|
||||
# 错误示例
|
||||
result = [(x, y) for x in range(10) for y in range(5) if x * y > 10]
|
||||
|
||||
|
||||
我敢打赌,一定很少有人能一口气写出来这么复杂的list comprehension。这不仅容易累着自己,也让阅读者看得很累。其实,如果你用一个简单的for loop,会让这段代码更加简洁明了,自己也更为轻松。
|
||||
|
||||
# 正确示例
|
||||
result = []
|
||||
for x in range(10):
|
||||
for y in range(5):
|
||||
if x * y > 10:
|
||||
result.append((x, y))
|
||||
|
||||
|
||||
机器的体验也很重要
|
||||
|
||||
讲完了编程者和阅读者的重要性,我们不能忽视了机器的体验。我们最终希望代码能正确、高效地在电脑上执行。但是,一些危险的编程风格,不仅会影响程序正确性,也容易成为代码效率的瓶颈。
|
||||
|
||||
我们先来看看 is 和 == 的使用区别。你能看出下面的代码的运行结果吗?
|
||||
|
||||
# 错误示例
|
||||
x = 27
|
||||
y = 27
|
||||
print(x is y)
|
||||
|
||||
x = 721
|
||||
y = 721
|
||||
print(x is y)
|
||||
|
||||
|
||||
看起来is是比较内存地址,那么两个结果应该都是一样的,可是实际上打印出来的,却分别是True和False!
|
||||
|
||||
原因是在CPython(Python的C实现)的实现中,把-5到256的整数做成了singleton,也就是说,这个区间里的数字都会引用同一块内存区域,所以上面的27和下面的27会指向同一个地址,运行结果为True。
|
||||
|
||||
但是-5到256之外的数字,会因为你的重新定义而被重新分配内存,所以两个721会指向不同的内存地址,结果也就是False了。
|
||||
|
||||
所以,即使你已经清楚,is比较对象的内存地址,你也应该在代码风格中,避免去用is比较两个Python整数的地址。
|
||||
|
||||
# 正确示例
|
||||
x = 27
|
||||
y = 27
|
||||
print(x == y)
|
||||
|
||||
x = 721
|
||||
y = 721
|
||||
print(x == y)
|
||||
|
||||
|
||||
看完这个例子,我们再看==在比较值的时候,是否总能如你所愿呢?同样的,你可以自己先判断一下运行结果。
|
||||
|
||||
# 错误示例
|
||||
x = MyObject()
|
||||
print(x == None)
|
||||
|
||||
|
||||
打印结果是False吗?不一定。因为对于类来说,==的结果,取决于它的__eq__()方法的具体实现。MyObject的作者完全可能这样实现:
|
||||
|
||||
class MyObject(object):
|
||||
def __eq__(self, other):
|
||||
if other:
|
||||
return self.field == other.field
|
||||
return True
|
||||
|
||||
|
||||
正确的是在代码风格中,当你和None比较时候永远使用 is:
|
||||
|
||||
# 正确示例
|
||||
x = MyObject()
|
||||
print(x is None)
|
||||
|
||||
|
||||
上面两个例子,我简单介绍了通过编程风格的限制,让is 和 == 的使用更安全。不过,光注意这两点就可以了吗?不要忘记,Python中还有隐式布尔转换。比如:
|
||||
|
||||
# 错误示例
|
||||
def pay(name, salary=None):
|
||||
if not salary:
|
||||
salary = 11
|
||||
print(name, "is compensated", salary, "dollars")
|
||||
|
||||
|
||||
如果有人调用 pay(“Andrew”, 0) ,会打印什么呢?“Andrew is compensated 11 dollars”。当你明确想要比较对象是否是None时,一定要显式地用 is None。
|
||||
|
||||
# 正确示例
|
||||
def pay(name, salary=None):
|
||||
if salary is not None:
|
||||
salary = 11
|
||||
print(name, "is compensated", salary, "dollars")
|
||||
|
||||
|
||||
这就是为什么,PEP8和Google Style都特别强调了,何时使用is, 何时使用 ==,何时使用隐式布尔转换。
|
||||
|
||||
不规范的编程习惯也会导致程序效率问题,我们看下面的代码有什么问题:
|
||||
|
||||
# 错误示例
|
||||
adict = {i: i * 2 for i in xrange(10000000)}
|
||||
|
||||
for key in adict.keys():
|
||||
print("{0} = {1}".format(key, adict[key]))
|
||||
|
||||
|
||||
keys()方法会在遍历前生成一个临时的列表,导致上面的代码消耗大量内存并且运行缓慢。正确的方式,是使用默认的iterator。默认的iterator不会分配新内存,也就不会造成上面的性能问题:
|
||||
|
||||
# 正确示例
|
||||
for key in adict:
|
||||
|
||||
|
||||
这也就是为什么Google Style 2.8对于遍历方式的选择作出了限制。
|
||||
|
||||
相信读到这里,对于代码风格规范的重要性,你已经有了进一步的理解。如果能够做到下一步,会让你和你的团队脱胎换骨,那就是和开发流程的完全整合。
|
||||
|
||||
整合进开发流程的自动化工具
|
||||
|
||||
前面我们已经提到了,编程规范的终极目标是提高开发效率。显然,如果每次写代码,都需要你在代码规范上额外花很多时间的话,就达不到我们的初衷了。
|
||||
|
||||
首先,你需要根据你的具体工作环境,选择或者制定适合自己公司/团队的规范。市面上可以参考的规范,也就是我在开头提到的那两个,PEP8和Google Style。
|
||||
|
||||
没有放之四海而皆准的规范,你需要因地制宜。例如在Google,因为历史原因C++不使用异常,引入异常对整个代码库带来的风险已经远大于它的益处,所以在它的C++代码规范中,禁止使用异常。
|
||||
|
||||
其次,一旦确定了整个团队同意的代码规范,就一定要强制执行。停留在口头和大脑的共识,只是水中月镜中花。如何执行呢?靠强制代码评审和强制静态或者动态linter。
|
||||
|
||||
当然,需要注意的是,我这里“强制”的意思,不是说如果不做就罚款。那就太low了,完全没有极客精神。我指的“强制”,是把共识写进代码里,让机器来自动化这些流程。比如:
|
||||
|
||||
|
||||
在代码评审工具里,添加必须的编程规范环节;
|
||||
把团队确定的代码规范写进Pylint里(https://www.pylint.org/),能够在每份代码提交前自动检查,不通过的代码无法提交。
|
||||
|
||||
|
||||
整合之后,你的团队工作流程就会变成这样:
|
||||
|
||||
|
||||
|
||||
总结
|
||||
|
||||
学到这里,相信你对代码风格的重要性有了全新的认识。代码风格之所以重要,是因为它关乎阅读者的体验、编程者的体验和执行代码的机器体验。
|
||||
|
||||
当然,仅仅意识到代码风格重要,是远远不够的。我还具体分享了一些自动化代码风格检查的切实方法,比如强制代码评审和强制静态或者动态linter。总之还是那句话,我们强调编程规范,最终一定是为了提高开发效率,而不是做额外功。
|
||||
|
||||
思考题
|
||||
|
||||
在你个人或者团队的项目经验中,是否也因为编程规范的问题,踩过坑或者吵过架呢?欢迎留言和我分享,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
431
专栏/Python核心技术与实战/27学会合理分解代码,提高代码可读性.md
Normal file
431
专栏/Python核心技术与实战/27学会合理分解代码,提高代码可读性.md
Normal file
@ -0,0 +1,431 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 学会合理分解代码,提高代码可读性
|
||||
你好,我是景霄。今天我们不讲任何技术知识点,继续来一起聊聊代码哲学。
|
||||
|
||||
有句话说得好,好的代码本身就是一份文档。同样功能的一份程序,一个组件,一套系统,让不同的人来写,写出来的代码却是千差万别。
|
||||
|
||||
有些人的设计风格和代码风格犹如热刀切黄油,从顶层到底层的代码看下来酣畅淋漓,注释详尽而又精简;深入到细节代码,无需注释也能理解清清楚楚。
|
||||
|
||||
而有些人,代码勉勉强强能跑起来,遇到稍微复杂的情况可能就会出 bug;深入到代码中 debug,则发现处处都是魔术数、函数堆在一起。一个文件上千行,设计模式又是混淆不堪,让人实在很难阅读,更别提修改和迭代开发。
|
||||
|
||||
Guido van Rossum(吉多·范罗苏姆,Python创始人 )说过,代码的阅读频率远高于编写代码的频率。毕竟,即使是在编写代码的时候,你也需要对代码进行反复阅读和调试,来确认代码能够按照期望运行。
|
||||
|
||||
话不多说,进入正题。
|
||||
|
||||
PEP 8 规范
|
||||
|
||||
上节课我们简单提起过 PEP 8 ,今天我们继续来详细解读。
|
||||
|
||||
PEP是 Python Enhancement Proposal 的缩写,翻译过来叫“Python 增强规范”。正如我们写文章,会有句式、标点、段落格式、开头缩进等标准的规范一样,Python 书写自然也有一套较为官方的规范。PEP 8 就是这样一种规范,它存在的意义,就是让 Python 更易阅读,换句话,增强代码可读性。
|
||||
|
||||
事实上,Pycharm 已经内置了 PEP 8 规范检测器,它会自动对编码不规范的地方进行检查,然后指出错误,并推荐修改方式。下面这张图就是其界面。
|
||||
|
||||
|
||||
|
||||
因此,在学习今天的内容时,我推荐你使用 Pycharm IDE 进行代码检查,看一下自己的代码格式哪里有问题。尤其对于初学者,从某些程度来说,代码规范甚至是比代码准确更重要的事情,因为实际工作中,代码可读性的重要性一定比你想象的多得多。
|
||||
|
||||
缩进规范
|
||||
|
||||
首先,我们来看代码块内的缩进。
|
||||
|
||||
Python 和 C++ / Java 最大的不同在于,后者完全使用大括号来区分代码块,而前者依靠不同行和不同的缩进来进行分块。有一个很有名的比赛,叫作 C 语言混乱代码大赛,其中有很多非常精彩的作品,你能看到书写的代码排成各种形状,有的是一幅画,或者一个卡通头像,但是能执行出惊人的结果。
|
||||
|
||||
而放到 Python ,显然就不能实现同样的技巧了。不过,以小换大,我们有了“像阅读英语”一样清晰的 Python 代码,也还是可以接受的。
|
||||
|
||||
话说回来,Python 的缩进其实可以写成很多种,Tab、双空格、四空格、空格和 Tab 混合等。而PEP 8 规范告诉我们,请选择四个空格的缩进,不要使用 Tab,更不要 Tab 和空格混着用。
|
||||
|
||||
第二个要注意的是,每行最大长度请限制在 79 个字符。
|
||||
|
||||
这个原则主要有两个优点,第一个优点比较好理解。很多工程师在编程的时候,习惯一个屏幕并列竖排展示多个源代码。如果某个源代码的某些行过长,你就需要拖动横向滚动条来阅读,或者需要软回车将本行内容放入下一行,这就极大地影响了编码和阅读效率。
|
||||
|
||||
至于第二个优点,需要有一定经验的编程经验后更容易理解:因为当代码的嵌套层数过高,比如超过三层之后,一行的内容就很容易超过 79 个字符了。所以,这条规定另一方面也在制约着程序员,不要写迭代过深的代码,而是要思考继续把代码分解成其他函数或逻辑块,来优化自己的代码结构。
|
||||
|
||||
空行规范
|
||||
|
||||
接着我们来看代码块之间的空行。
|
||||
|
||||
我们知道,Python 中的空行对 Python 解释器的执行没有影响,但对阅读体验有很深刻的影响。
|
||||
|
||||
PEP 8 规定,全局的类和函数的上方需要空两个空行,而类的函数之间需要空一个空行。当然,函数内部也可以使用空行,和英语的段落一样,用来区分不同意群之间的代码块。但是记住最多空一行,千万不要滥用。
|
||||
|
||||
另外,Python 本身允许把多行合并为一行,使用分号隔开,但这是 PEP 8 不推荐的做法。所以,即使是使用控制语句 if / while / for,你的执行语句哪怕只有一行命令,也请另起一行,这样可以更大程度提升阅读效率。
|
||||
|
||||
至于代码的尾部,每个代码文件的最后一行为空行,并且只有这一个空行。
|
||||
|
||||
空格规范
|
||||
|
||||
我们再来看一下,代码块中,每行语句中空格的使用。
|
||||
|
||||
函数的参数列表中,调用函数的参数列表中会出现逗号,请注意逗号后要跟一个空格,这是英语的使用习惯,也能让每个参数独立阅读,更清晰。
|
||||
|
||||
同理,冒号经常被用来初始化字典,冒号后面也要跟一个空格。
|
||||
|
||||
另外,Python 中我们可以使用#进行单独注释,请记得要在#后、注释前加一个空格。
|
||||
|
||||
对于操作符,例如+,-,*,/,&,|,=,==,!=,请在两边都保留空格。不过与此对应,括号内的两端并不需要空格。
|
||||
|
||||
换行规范
|
||||
|
||||
现在再回到缩进规范,注意我们提到的第二点,控制每行的最大长度不超过 79 个字符,但是有时候,函数调用逻辑过长而不得不超过这个数字时,该怎么办呢?
|
||||
|
||||
请看下面这段代码,建议你先自己阅读并总结其特点:
|
||||
|
||||
def solve1(this_is_the_first_parameter, this_is_the_second_parameter, this_is_the_third_parameter,
|
||||
this_is_the_forth_parameter, this_is_the_fifth_parameter, this_is_the_sixth_parameter):
|
||||
return (this_is_the_first_parameter + this_is_the_second_parameter + this_is_the_third_parameter +
|
||||
this_is_the_forth_parameter + this_is_the_fifth_parameter + this_is_the_sixth_parameter)
|
||||
|
||||
|
||||
def solve2(this_is_the_first_parameter, this_is_the_second_parameter, this_is_the_third_parameter,
|
||||
this_is_the_forth_parameter, this_is_the_fifth_parameter, this_is_the_sixth_parameter):
|
||||
return this_is_the_first_parameter + this_is_the_second_parameter + this_is_the_third_parameter + \
|
||||
this_is_the_forth_parameter + this_is_the_fifth_parameter + this_is_the_sixth_parameter
|
||||
|
||||
|
||||
(top_secret_func(param1=12345678, param2=12345678, param3=12345678, param4=12345678, param5=12345678).check()
|
||||
.launch_nuclear_missile().wait())
|
||||
|
||||
|
||||
top_secret_func(param1=12345678, param2=12345678, param3=12345678, param4=12345678, param5=12345678).check() \
|
||||
.launch_nuclear_missile().wait()
|
||||
|
||||
|
||||
事实上,这里有两种经典做法。
|
||||
|
||||
第一种,通过括号来将过长的运算进行封装,此时虽然跨行,但是仍处于一个逻辑引用之下。solve1 函数的参数过多,直接换行,不过请注意,要考虑第二行参数和第一行第一个参数对齐,这样可以让函数变得非常美观的同时,更易于阅读。当然,函数调用也可以使用类似的方式,只需要用一对括号将其包裹起来。
|
||||
|
||||
第二种,则是通过换行符来实现。这个方法更为直接,你可以从 solve2 和第二个函数调用看出来。
|
||||
|
||||
关于代码细节方面的规范,我主要强调这四个方面。习惯不是一天养成的,但一定需要你特别留心和刻意练习。我能做的,便是告诉你这些需要留心的地方,并带你感受实际项目的代码风格。
|
||||
|
||||
下面的代码选自开源库 Google TensorFlow Keras,为了更加直观突出重点,我删去了注释和大部分代码,你意会即可。我希望,通过阅读这段代码,你能更真实地了解到,前沿的项目是怎么在增强阅读性上下功夫的。
|
||||
|
||||
class Model(network.Network):
|
||||
def fit(self,
|
||||
x=None,
|
||||
y=None,
|
||||
batch_size=None,
|
||||
epochs=1,
|
||||
verbose=1,
|
||||
callbacks=None,
|
||||
validation_split=0.,
|
||||
validation_data=None,
|
||||
shuffle=True,
|
||||
class_weight=None,
|
||||
sample_weight=None,
|
||||
initial_epoch=0,
|
||||
steps_per_epoch=None,
|
||||
validation_steps=None,
|
||||
validation_freq=1,
|
||||
max_queue_size=10,
|
||||
workers=1,
|
||||
use_multiprocessing=False,
|
||||
**kwargs):
|
||||
# Legacy support
|
||||
if 'nb_epoch' in kwargs:
|
||||
logging.warning(
|
||||
'The `nb_epoch` argument in `fit` has been renamed `epochs`.')
|
||||
epochs = kwargs.pop('nb_epoch')
|
||||
if kwargs:
|
||||
raise TypeError('Unrecognized keyword arguments: ' + str(kwargs))
|
||||
self._assert_compile_was_called()
|
||||
|
||||
func = self._select_training_loop(x)
|
||||
return func.fit(
|
||||
self,
|
||||
x=x,
|
||||
y=y,
|
||||
batch_size=batch_size,
|
||||
epochs=epochs,
|
||||
verbose=verbose,
|
||||
callbacks=callbacks,
|
||||
validation_split=validation_split,
|
||||
validation_data=validation_data,
|
||||
shuffle=shuffle,
|
||||
class_weight=class_weight,
|
||||
sample_weight=sample_weight,
|
||||
initial_epoch=initial_epoch,
|
||||
steps_per_epoch=steps_per_epoch,
|
||||
validation_steps=validation_steps,
|
||||
validation_freq=validation_freq,
|
||||
max_queue_size=max_queue_size,
|
||||
workers=workers,
|
||||
use_multiprocessing=use_multiprocessing)
|
||||
|
||||
|
||||
文档规范
|
||||
|
||||
接下来我们说说文档规范。先来看看最常用的 import 函数。
|
||||
|
||||
首先,所有 import 尽量放在开头,这个没什么说的,毕竟到处 import 会让人很难看清楚文件之间的依赖关系,运行时 import 也可能会导致潜在的效率问题和其他风险。
|
||||
|
||||
其次,不要使用 import 一次导入多个模块。虽然我们可以在一行中 import 多个模块,并用逗号分隔,但请不要这么做。import time, os 是 PEP 8 不推荐的做法。
|
||||
|
||||
如果你采用 from module import func 这样的语句,请确保 func 在本文件中不会出现命名冲突。不过,你其实可以通过 from module import func as new_func 来进行重命名,从而避免冲突。
|
||||
|
||||
注释规范
|
||||
|
||||
有句话这么说:错误的注释,不如没有注释。所以,当你改动代码的时候,一定要注意检查周围的注释是否需要更新。
|
||||
|
||||
对于大的逻辑块,我们可以在最开始相同的缩进处以 # 开始写注释。即使是注释,你也应该把它当成完整的文章来书写。如果英文注释,请注意开头大写及结尾标点,注意避免语法错误和逻辑错误,同时精简要表达的意思。中文注释也是同样的要求。一份优秀的代码,离不开优秀的注释。
|
||||
|
||||
至于行注释,如空格规范中所讲,我们可以在一行后面跟两个空格,然后以 # 开头加入注释。不过,请注意,行注释并不是很推荐的方式。
|
||||
|
||||
# This is an example to demonstrate how to comment.
|
||||
# Please note this function must be used carefully.
|
||||
def solve(x):
|
||||
if x == 1: # This is only one exception.
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
文档描述
|
||||
|
||||
再来说说文档描述,我们继续以 TensorFlow 的代码为例。
|
||||
|
||||
class SpatialDropout2D(Dropout):
|
||||
"""Spatial 2D version of Dropout.
|
||||
This version performs the same function as Dropout, however it drops
|
||||
entire 2D feature maps instead of individual elements. If adjacent pixels
|
||||
within feature maps are strongly correlated (as is normally the case in
|
||||
early convolution layers) then regular dropout will not regularize the
|
||||
activations and will otherwise just result in an effective learning rate
|
||||
decrease. In this case, SpatialDropout2D will help promote independence
|
||||
between feature maps and should be used instead.
|
||||
Arguments:
|
||||
rate: float between 0 and 1. Fraction of the input units to drop.
|
||||
data_format: 'channels_first' or 'channels_last'.
|
||||
In 'channels_first' mode, the channels dimension
|
||||
(the depth) is at index 1,
|
||||
in 'channels_last' mode is it at index 3.
|
||||
It defaults to the `image_data_format` value found in your
|
||||
Keras config file at `~/.keras/keras.json`.
|
||||
If you never set it, then it will be "channels_last".
|
||||
Input shape:
|
||||
4D tensor with shape:
|
||||
`(samples, channels, rows, cols)` if data_format='channels_first'
|
||||
or 4D tensor with shape:
|
||||
`(samples, rows, cols, channels)` if data_format='channels_last'.
|
||||
Output shape:
|
||||
Same as input
|
||||
References:
|
||||
- [Efficient Object Localization Using Convolutional
|
||||
Networks](https://arxiv.org/abs/1411.4280)
|
||||
"""
|
||||
def __init__(self, rate, data_format=None, **kwargs):
|
||||
super(SpatialDropout2D, self).__init__(rate, **kwargs)
|
||||
if data_format is None:
|
||||
data_format = K.image_data_format()
|
||||
if data_format not in {'channels_last', 'channels_first'}:
|
||||
raise ValueError('data_format must be in '
|
||||
'{"channels_last", "channels_first"}')
|
||||
self.data_format = data_format
|
||||
self.input_spec = InputSpec(ndim=4)
|
||||
|
||||
|
||||
你应该可以发现,类和函数的注释,为的是让读者快速理解这个函数做了什么,它输入的参数和格式,输出的返回值和格式,以及其他需要注意的地方。
|
||||
|
||||
至于docstring 的写法,它是用三个双引号开始、三个双引号结尾。我们首先用一句话简单说明这个函数做什么,然后跟一段话来详细解释;再往后是参数列表、参数格式、返回值格式。
|
||||
|
||||
命名规范
|
||||
|
||||
接下来,我来讲一讲命名。你应该听过这么一句话,“计算机科学的两件难事:缓存失效和命名。”命名对程序员来说,是一个不算省心的事。一个具有误导性的名字,极有可能在项目中埋下潜在的 bug。这里我就不从命名分类方法来给你划分了,我们只讲一些最实用的规范。
|
||||
|
||||
先来看变量命名。变量名请拒绝使用 a b c d 这样毫无意义的单字符,我们应该使用能够代表其意思的变量名。一般来说,变量使用小写,通过下划线串联起来,例如:data_format、input_spec、image_data_set。唯一可以使用单字符的地方是迭代,比如 for i in range(n) 这种,为了精简可以使用。如果是类的私有变量,请记得前面增加两个下划线。
|
||||
|
||||
对于常量,最好的做法是全部大写,并通过下划线连接,例如:WAIT_TIME、SERVER_ADDRESS、PORT_NUMBER。
|
||||
|
||||
对于函数名,同样也请使用小写的方式,通过下划线连接起来,例如:launch_nuclear_missile()、check_input_validation()。
|
||||
|
||||
对于类名,则应该首字母大写,然后合并起来,例如:class SpatialDropout2D()、class FeatureSet()。
|
||||
|
||||
总之,还是那句话,不要过于吝啬一个变量名的长度。当然,在合理描述这个变量背后代表的对象后,一定的精简能力也是必要的。
|
||||
|
||||
代码分解技巧
|
||||
|
||||
最后,我们再讲一些很实用的代码优化技巧。
|
||||
|
||||
编程中一个核心思想是,不写重复代码。重复代码大概率可以通过使用条件、循环、构造函数和类来解决。而另一个核心思想则是,减少迭代层数,尽可能让 Python 代码扁平化,毕竟,人的大脑无法处理过多的栈操作。
|
||||
|
||||
所以,在很多业务逻辑比较复杂的地方,就需要我们加入大量的判断和循环。不过,这些一旦没写好,程序看起来就是地狱了。
|
||||
|
||||
我们来看下面几个示例,来说说写好判断、循环的细节问题。先来看第一段代码:
|
||||
|
||||
if i_am_rich:
|
||||
money = 100
|
||||
send(money)
|
||||
else:
|
||||
money = 10
|
||||
send(money)
|
||||
|
||||
|
||||
这段代码中,同样的send语句出现了两次,所以我们完全可以合并一下,把代码改造成下面这样:
|
||||
|
||||
if i_am_rich:
|
||||
money = 100
|
||||
else:
|
||||
money = 10
|
||||
send(money)
|
||||
|
||||
|
||||
再来看一个例子:
|
||||
|
||||
def send(money):
|
||||
if is_server_dead:
|
||||
LOG('server dead')
|
||||
return
|
||||
else:
|
||||
if is_server_timed_out:
|
||||
LOG('server timed out')
|
||||
return
|
||||
else:
|
||||
result = get_result_from_server()
|
||||
if result == MONEY_IS_NOT_ENOUGH:
|
||||
LOG('you do not have enough money')
|
||||
return
|
||||
else:
|
||||
if result == TRANSACTION_SUCCEED:
|
||||
LOG('OK')
|
||||
return
|
||||
else:
|
||||
LOG('something wrong')
|
||||
return
|
||||
|
||||
|
||||
这段代码层层缩进,显而易见的难看。我们来改一下:
|
||||
|
||||
def send(money):
|
||||
if is_server_dead:
|
||||
LOG('server dead')
|
||||
return
|
||||
|
||||
if is_server_timed_out:
|
||||
LOG('server timed out')
|
||||
return
|
||||
|
||||
result = get_result_from_server()
|
||||
|
||||
if result == MONET_IS_NOT_ENOUGH:
|
||||
LOG('you do not have enough money')
|
||||
return
|
||||
|
||||
if result == TRANSACTION_SUCCEED:
|
||||
LOG('OK')
|
||||
return
|
||||
|
||||
LOG('something wrong')
|
||||
|
||||
|
||||
新的代码是不是就清晰多了?
|
||||
|
||||
另外,我们知道,一个函数的粒度应该尽可能细,不要让一个函数做太多的事情。所以,对待一个复杂的函数,我们需要尽可能地把它拆分成几个功能简单的函数,然后合并起来。那么,应该如何拆分函数呢?
|
||||
|
||||
这里,我以一个简单的二分搜索来举例说明。我给定一个非递减整数数组,和一个 target,要求你找到数组中最小的一个数 x,可以满足 x*x > target。一旦不存在,则返回 -1。
|
||||
|
||||
这个功能应该不难写吧。你不妨先自己写一下,写完后再对照着来看下面的代码,找出自己的问题。
|
||||
|
||||
def solve(arr, target):
|
||||
l, r = 0, len(arr) - 1
|
||||
ret = -1
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if arr[m] * arr[m] > target:
|
||||
ret = m
|
||||
r = m - 1
|
||||
else:
|
||||
l = m + 1
|
||||
if ret == -1:
|
||||
return -1
|
||||
else:
|
||||
return arr[ret]
|
||||
|
||||
|
||||
print(solve([1, 2, 3, 4, 5, 6], 8))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 9))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 0))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 40))
|
||||
|
||||
|
||||
我给出的第一段代码这样的写法,在算法比赛和面试中已经 OK 了。不过,从工程角度来说,我们还能继续优化一下:
|
||||
|
||||
def comp(x, target):
|
||||
return x * x > target
|
||||
|
||||
|
||||
def binary_search(arr, target):
|
||||
l, r = 0, len(arr) - 1
|
||||
ret = -1
|
||||
while l <= r:
|
||||
m = (l + r) // 2
|
||||
if comp(arr[m], target):
|
||||
ret = m
|
||||
r = m - 1
|
||||
else:
|
||||
l = m + 1
|
||||
return ret
|
||||
|
||||
|
||||
def solve(arr, target):
|
||||
id = binary_search(arr, target)
|
||||
|
||||
if id != -1:
|
||||
return arr[id]
|
||||
return -1
|
||||
|
||||
|
||||
print(solve([1, 2, 3, 4, 5, 6], 8))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 9))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 0))
|
||||
print(solve([1, 2, 3, 4, 5, 6], 40))
|
||||
|
||||
|
||||
你可以看出,第二段代码中,我把不同功能的代码拿了出来。其中,comp() 函数作为核心判断,拿出来后可以让整个程序更清晰;同时,我也把二分搜索的主程序拿了出来,只负责二分搜索;最后的 solve() 函数拿到结果,决定返回不存在,还是返回值。这样一来,每个函数各司其职,阅读性也能得到一定提高。
|
||||
|
||||
最后,我们再来看一下如何拆分类。老规矩,先看代码:
|
||||
|
||||
class Person:
|
||||
def __init__(self, name, sex, age, job_title, job_description, company_name):
|
||||
self.name = name
|
||||
self.sex = sex
|
||||
self.age = age
|
||||
self.job_title = job_title
|
||||
self.job_description = description
|
||||
self.company_name = company_name
|
||||
|
||||
|
||||
你应该能看得出来,job 在其中出现了很多次,而且它们表达的是一个意义实体,这种情况下,我们可以考虑将这部分分解出来,作为单独的类。
|
||||
|
||||
class Person:
|
||||
def __init__(self, name, sex, age, job_title, job_description, company_name):
|
||||
self.name = name
|
||||
self.sex = sex
|
||||
self.age = age
|
||||
self.job = Job(job_title, job_description, company_name)
|
||||
|
||||
class Job:
|
||||
def __init__(self, job_title, job_description, company_name):
|
||||
|
||||
self.job_title = job_title
|
||||
self.job_description = description
|
||||
self.company_name = company_name
|
||||
|
||||
|
||||
你看,改造后的代码,瞬间就清晰了很多。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们简单讲述了如何提高 Python 代码的可读性,主要介绍了PEP 8 规范,并通过实例的说明和改造,让你清楚如何对 Python 程序进行可读性优化。
|
||||
|
||||
思考题
|
||||
|
||||
最后,我想留一个思考题。这次的思考题开放一些,希望你在评论区讲一讲,你自己在初学编程时,不注意规范问题而犯下的错误,和这些错误会导致什么样的后果,比如对后来读代码的人有严重的误导,或是埋下了潜在的 bug 等等。
|
||||
|
||||
希望你在留言区分享你的经历,你也可以把这篇文章分享出去,让更多的人互相交流心得体会,留下真实的经历,并在经历中进步成长。
|
||||
|
||||
|
||||
|
||||
|
205
专栏/Python核心技术与实战/28如何合理利用assert?.md
Normal file
205
专栏/Python核心技术与实战/28如何合理利用assert?.md
Normal file
@ -0,0 +1,205 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 如何合理利用assert?
|
||||
你好,我是景霄。
|
||||
|
||||
相信你平时在写代码时,肯定或多或少看到过assert的存在。我也曾在日常的code review中,被一些同事要求增加assert语句,让代码更加健壮。
|
||||
|
||||
不过,尽管如此,我发现在很多情况下,assert还是很容易被忽略,人们似乎对这么一个“不起眼”的东西并不关心。但事实上,这个看似“不起眼”的东西,如果能用好,对我们的程序大有裨益。
|
||||
|
||||
说了这么多,那么究竟什么是assert,我们又该如何合理地使用assert呢?今天这节课,我就带你一起来学习它的用法。
|
||||
|
||||
什么是assert?
|
||||
|
||||
Python的assert语句,可以说是一个debug的好工具,主要用于测试一个条件是否满足。如果测试的条件满足,则什么也不做,相当于执行了pass语句;如果测试条件不满足,便会抛出异常AssertionError,并返回具体的错误信息(optional)。
|
||||
|
||||
它的具体语法是下面这样的:
|
||||
|
||||
assert_stmt ::= "assert" expression ["," expression]
|
||||
|
||||
|
||||
我们先来看一个简单形式的assert expression,比如下面这个例子:
|
||||
|
||||
assert 1 == 2
|
||||
|
||||
|
||||
它就相当于下面这两行代码:
|
||||
|
||||
if __debug__:
|
||||
if not expression: raise AssertionError
|
||||
|
||||
|
||||
再来看assert expression1, expression2的形式,比如下面这个例子:
|
||||
|
||||
assert 1 == 2, 'assertion is wrong'
|
||||
|
||||
|
||||
它就相当于下面这两行代码:
|
||||
|
||||
if __debug__:
|
||||
if not expression1: raise AssertionError(expression2)
|
||||
|
||||
|
||||
这里的__debug__是一个常数。如果Python程序执行时附带了-O这个选项,比如Python test.py -O,那么程序中所有的assert语句都会失效,常数__debug__便为False;反之__debug__则为True。
|
||||
|
||||
不过,需要注意的是,直接对常数__debug__赋值是非法的,因为它的值在解释器开始运行时就已经决定了,中途无法改变。
|
||||
|
||||
此外,一定记住,不要在使用assert时加入括号,比如下面这个例子:
|
||||
|
||||
assert(1 == 2, 'This should fail')
|
||||
# 输出
|
||||
<ipython-input-8-2c057bd7fe24>:1: SyntaxWarning: assertion is always true, perhaps remove parentheses?
|
||||
assert(1 == 2, 'This should fail')
|
||||
|
||||
|
||||
如果你按照这样来写,无论表达式对与错(比如这里的1 == 2显然是错误的),assert检查永远不会fail,程序只会给你SyntaxWarning。
|
||||
|
||||
正确的写法,应该是下面这种不带括号的写法:
|
||||
|
||||
assert 1 == 2, 'This should fail'
|
||||
# 输出
|
||||
AssertionError: This should fail
|
||||
|
||||
|
||||
总的来说,assert在程序中的作用,是对代码做一些internal的self-check。使用assert,就表示你很确定。这个条件一定会发生或者一定不会发生。
|
||||
|
||||
举个例子,比如你有一个函数,其中一个参数是人的性别,因为性别只有男女之分(这里只指生理性别),你便可以使用assert,以防止程序的非法输入。如果你的程序没有bug,那么assert永远不会抛出异常;而它一旦抛出了异常,你就知道程序存在问题了,并且可以根据错误信息,很容易定位出错误的源头。
|
||||
|
||||
assert 的用法
|
||||
|
||||
讲完了assert的基本语法与概念,我们接下来通过一些实际应用的例子,来看看assert在Python中的用法,并弄清楚 assert 的使用场景。
|
||||
|
||||
第一个例子,假设你现在使用的极客时间正在做专栏促销活动,准备对一些专栏进行打折,所以后台需要写一个apply_discount()函数,要求输入为原来的价格和折扣,输出是折后的价格。那么,我们可以大致写成下面这样:
|
||||
|
||||
def apply_discount(price, discount):
|
||||
updated_price = price * (1 - discount)
|
||||
assert 0 <= updated_price <= price, 'price should be greater or equal to 0 and less or equal to original price'
|
||||
return updated_price
|
||||
|
||||
|
||||
可以看到,在计算新价格的后面,我们还写了一个assert语句,用来检查折后价格,这个值必须大于等于0、小于等于原来的价格,否则就抛出异常。
|
||||
|
||||
我们可以试着输入几组数,来验证一下这个功能:
|
||||
|
||||
apply_discount(100, 0.2)
|
||||
80.0
|
||||
|
||||
apply_discount(100, 2)
|
||||
AssertionError: price should be greater or equal to 0 and less or equal to original price
|
||||
|
||||
|
||||
显然,当discount是0.2时,输出80,没有问题。但是当discount为2时,程序便抛出下面这个异常:
|
||||
|
||||
AssertionError:price should be greater or equal to 0 and less or equal to original price
|
||||
|
||||
|
||||
这样一来,如果开发人员修改相关的代码,或者是加入新的功能,导致discount数值的异常时,我们运行测试时就可以很容易发现问题。正如我开头所说,assert的加入,可以有效预防bug的发生,提高程序的健壮性。
|
||||
|
||||
再来看一个例子,最常见的除法操作,这在任何领域的计算中都经常会遇到。同样还是以极客时间为例,假如极客时间后台想知道每个专栏的平均销售价格,那么就需要给定销售总额和销售数目,这样平均销售价格便很容易计算出来:
|
||||
|
||||
def calculate_average_price(total_sales, num_sales):
|
||||
assert num_sales > 0, 'number of sales should be greater than 0'
|
||||
return total_sales / num_sales
|
||||
|
||||
|
||||
同样的,我们也加入了assert语句,规定销售数目必须大于0,这样就可以防止后台计算那些还未开卖的专栏的价格。
|
||||
|
||||
除了这两个例子,在实际工作中,assert还有一些很常见的用法,比如下面的场景:
|
||||
|
||||
def func(input):
|
||||
assert isinstance(input, list), 'input must be type of list'
|
||||
# 下面的操作都是基于前提:input必须是list
|
||||
if len(input) == 1:
|
||||
...
|
||||
elif len(input) == 2:
|
||||
...
|
||||
else:
|
||||
...
|
||||
|
||||
|
||||
这里函数func()里的所有操作,都是基于输入必须是list 这个前提。是不是很熟悉的需求呢?那我们就很有必要在开头加一句assert的检查,防止程序出错。
|
||||
|
||||
当然,我们也要根据具体情况具体分析。比如上面这个例子,之所以能加assert,是因为我们很确定输入必须是list,不能是其他数据类型。
|
||||
|
||||
如果你的程序中,允许input是其他数据类型,并且对不同的数据类型都有不同的处理方式,那你就应该写成if else的条件语句了:
|
||||
|
||||
def func(input):
|
||||
if isinstance(input, list):
|
||||
...
|
||||
else:
|
||||
...
|
||||
|
||||
|
||||
assert错误示例
|
||||
|
||||
前面我们讲了这么多 assert的使用场景,可能给你一种错觉,也可能会让你有些迷茫:很多地方都可以使用assert, 那么,很多if条件语句是不是都可以换成assert呢?这么想可就不准确了,接下来,我们就一起来看几个典型的错误用法,避免一些想当然的用法。
|
||||
|
||||
还是以极客时间为例,我们假设下面这样的场景:后台有时候需要删除一些上线时间较长的专栏,于是,相关的开发人员便设计出了下面这个专栏删除函数。
|
||||
|
||||
def delete_course(user, course_id):
|
||||
assert user_is_admin(user), 'user must be admin'
|
||||
assert course_exist(course_id), 'course id must exist'
|
||||
delete(course_id)
|
||||
|
||||
|
||||
极客时间规定,必须是admin才能删除专栏,并且这个专栏课程必须存在。有的同学一看,很熟悉的需求啊,所以在前面加了相应的assert检查。那么我想让你思考一下,这样写到底对不对呢?
|
||||
|
||||
答案显然是否定的。你可能觉得,从代码功能角度来说,这没错啊。但是在实际工程中,基本上没人会这么写。为什么呢?
|
||||
|
||||
要注意,前面我说过,assert的检查是可以被关闭的,比如在运行Python程序时,加入-O这个选项就会让assert失效。因此,一旦assert的检查被关闭,user_is_admin()和course_exist()这两个函数便不会被执行。这就会导致:
|
||||
|
||||
|
||||
任何用户都有权限删除专栏课程;
|
||||
并且,不管这个课程是否存在,他们都可以强行执行删除操作。
|
||||
|
||||
|
||||
这显然会给程序带来巨大的安全漏洞。所以,正确的做法,是使用条件语句进行相应的检查,并合理抛出异常:
|
||||
|
||||
def delete_course(user, course_id):
|
||||
if not user_is_admin(user):
|
||||
raise Exception('user must be admin')
|
||||
if not course_exist(course_id):
|
||||
raise Exception('coursde id must exist')
|
||||
delete(course_id)
|
||||
|
||||
|
||||
再来看一个例子,如果你想打开一个文件,进行数据读取、处理等一系列操作,那么下面这样的写法,显然也是不正确的:
|
||||
|
||||
def read_and_process(path):
|
||||
assert file_exist(path), 'file must exist'
|
||||
with open(path) as f:
|
||||
...
|
||||
|
||||
|
||||
因为assert的使用,表明你强行指定了文件必须存在,但事实上在很多情况下,这个假设并不成立。另外,打开文件操作,也有可能触发其他的异常。所以,正确的做法是进行异常处理,用try和except来解决:
|
||||
|
||||
def read_and_process(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
...
|
||||
except Exception as e:
|
||||
...
|
||||
|
||||
|
||||
总的来说,assert并不适用run-time error 的检查。比如你试图打开一个文件,但文件不存在;再或者是你试图从网上下载一个东西,但中途断网了了等等,这些情况下,还是应该参照我们前面所讲的错误与异常的内容,进行正确处理。
|
||||
|
||||
总结
|
||||
|
||||
今天这节课,我们一起学习了assert的用法。assert通常用来对代码进行必要的self check,表明你很确定这种情况一定发生,或者一定不会发生。需要注意的是,使用assert时,一定不要加上括号,否则无论表达式对与错,assert检查永远不会fail。另外,程序中的assert语句,可以通过-O等选项被全局disable。
|
||||
|
||||
通过这节课的几个使用场景,你能看到,assert的合理使用,可以增加代码的健壮度,同时也方便了程序出错时开发人员的定位排查。
|
||||
|
||||
不过,我们也不能滥用assert。很多情况下,程序中出现的不同情况都是意料之中的,需要我们用不同的方案去处理,这时候用条件语句进行判断更为合适。而对于程序中的一些run-time error,请记得使用异常处理。
|
||||
|
||||
思考题
|
||||
|
||||
最后,给你留一个思考题。在平时的工作学习中,你用过assert吗?如果用过的话,是在什么情况下使用的?有遇到过什么问题吗?
|
||||
|
||||
欢迎在留言区写下你的经历,还有今天学习的心得和疑惑,与我一起分享。也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
239
专栏/Python核心技术与实战/29巧用上下文管理器和With语句精简代码.md
Normal file
239
专栏/Python核心技术与实战/29巧用上下文管理器和With语句精简代码.md
Normal file
@ -0,0 +1,239 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 巧用上下文管理器和With语句精简代码
|
||||
你好,我是景霄。
|
||||
|
||||
我想你对Python中的with语句一定不陌生,在专栏里它也曾多次出现,尤其是在文件的输入输出操作中,不过我想,大部分人可能习惯了它的使用,却并不知道隐藏在其背后的“秘密”。
|
||||
|
||||
那么,究竟with语句要怎么用,与之相关的上下文管理器(context manager)是什么,它们之间又有着怎样的联系呢?这节课,我就带你一起揭开它们的神秘面纱。
|
||||
|
||||
什么是上下文管理器?
|
||||
|
||||
在任何一门编程语言中,文件的输入输出、数据库的连接断开等,都是很常见的资源管理操作。但资源都是有限的,在写程序时,我们必须保证这些资源在使用过后得到释放,不然就容易造成资源泄露,轻者使得系统处理缓慢,重则会使系统崩溃。
|
||||
|
||||
光说这些概念,你可能体会不到这一点,我们可以看看下面的例子:
|
||||
|
||||
for x in range(10000000):
|
||||
f = open('test.txt', 'w')
|
||||
f.write('hello')
|
||||
|
||||
|
||||
这里我们一共打开了10000000个文件,但是用完以后都没有关闭它们,如果你运行该段代码,便会报错:
|
||||
|
||||
OSError: [Errno 23] Too many open files in system: 'test.txt'
|
||||
|
||||
|
||||
这就是一个典型的资源泄露的例子。因为程序中同时打开了太多的文件,占据了太多的资源,造成系统崩溃。
|
||||
|
||||
为了解决这个问题,不同的编程语言都引入了不同的机制。而在Python中,对应的解决方式便是上下文管理器(context manager)。上下文管理器,能够帮助你自动分配并且释放资源,其中最典型的应用便是with语句。所以,上面代码的正确写法应该如下所示:
|
||||
|
||||
for x in range(10000000):
|
||||
with open('test.txt', 'w') as f:
|
||||
f.write('hello')
|
||||
|
||||
|
||||
这样,我们每次打开文件“test.txt”,并写入‘hello’之后,这个文件便会自动关闭,相应的资源也可以得到释放,防止资源泄露。当然,with语句的代码,也可以用下面的形式表示:
|
||||
|
||||
f = open('test.txt', 'w')
|
||||
try:
|
||||
f.write('hello')
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
要注意的是,最后的finally block尤其重要,哪怕在写入文件时发生错误异常,它也可以保证该文件最终被关闭。不过与with语句相比,这样的代码就显得冗余了,并且还容易漏写,因此我们一般更倾向于使用with语句。
|
||||
|
||||
另外一个典型的例子,是Python中的threading.lock类。举个例子,比如我想要获取一个锁,执行相应的操作,完成后再释放,那么代码就可以写成下面这样:
|
||||
|
||||
some_lock = threading.Lock()
|
||||
some_lock.acquire()
|
||||
try:
|
||||
...
|
||||
finally:
|
||||
some_lock.release()
|
||||
|
||||
|
||||
而对应的with语句,同样非常简洁:
|
||||
|
||||
some_lock = threading.Lock()
|
||||
with somelock:
|
||||
...
|
||||
|
||||
|
||||
我们可以从这两个例子中看到,with语句的使用,可以简化了代码,有效避免资源泄露的发生。
|
||||
|
||||
上下文管理器的实现
|
||||
|
||||
基于类的上下文管理器
|
||||
|
||||
了解了上下文管理的概念和优点后,下面我们就通过具体的例子,一起来看看上下文管理器的原理,搞清楚它的内部实现。这里,我自定义了一个上下文管理类FileManager,模拟Python的打开、关闭文件操作:
|
||||
|
||||
class FileManager:
|
||||
def __init__(self, name, mode):
|
||||
print('calling __init__ method')
|
||||
self.name = name
|
||||
self.mode = mode
|
||||
self.file = None
|
||||
|
||||
def __enter__(self):
|
||||
print('calling __enter__ method')
|
||||
self.file = open(self.name, self.mode)
|
||||
return self.file
|
||||
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
print('calling __exit__ method')
|
||||
if self.file:
|
||||
self.file.close()
|
||||
|
||||
with FileManager('test.txt', 'w') as f:
|
||||
print('ready to write to file')
|
||||
f.write('hello world')
|
||||
|
||||
## 输出
|
||||
calling __init__ method
|
||||
calling __enter__ method
|
||||
ready to write to file
|
||||
calling __exit__ method
|
||||
|
||||
|
||||
需要注意的是,当我们用类来创建上下文管理器时,必须保证这个类包括方法”__enter__()”和方法“__exit__()”。其中,方法“__enter__()”返回需要被管理的资源,方法“__exit__()”里通常会存在一些释放、清理资源的操作,比如这个例子中的关闭文件等等。
|
||||
|
||||
而当我们用with语句,执行这个上下文管理器时:
|
||||
|
||||
with FileManager('test.txt', 'w') as f:
|
||||
f.write('hello world')
|
||||
|
||||
|
||||
下面这四步操作会依次发生:
|
||||
|
||||
|
||||
方法“__init__()”被调用,程序初始化对象FileManager,使得文件名(name)是"test.txt",文件模式(mode)是'w';
|
||||
方法“__enter__()”被调用,文件“test.txt”以写入的模式被打开,并且返回FileManager对象赋予变量f;
|
||||
字符串“hello world”被写入文件“test.txt”;
|
||||
方法“__exit__()”被调用,负责关闭之前打开的文件流。
|
||||
|
||||
|
||||
因此,这个程序的输出是:
|
||||
|
||||
calling __init__ method
|
||||
calling __enter__ method
|
||||
ready to write to file
|
||||
calling __exit__ meth
|
||||
|
||||
|
||||
另外,值得一提的是,方法“__exit__()”中的参数“exc_type, exc_val, exc_tb”,分别表示exception_type、exception_value和traceback。当我们执行含有上下文管理器的with语句时,如果有异常抛出,异常的信息就会包含在这三个变量中,传入方法“__exit__()”。
|
||||
|
||||
因此,如果你需要处理可能发生的异常,可以在“__exit__()”添加相应的代码,比如下面这样来写:
|
||||
|
||||
class Foo:
|
||||
def __init__(self):
|
||||
print('__init__ called')
|
||||
|
||||
def __enter__(self):
|
||||
print('__enter__ called')
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_tb):
|
||||
print('__exit__ called')
|
||||
if exc_type:
|
||||
print(f'exc_type: {exc_type}')
|
||||
print(f'exc_value: {exc_value}')
|
||||
print(f'exc_traceback: {exc_tb}')
|
||||
print('exception handled')
|
||||
return True
|
||||
|
||||
with Foo() as obj:
|
||||
raise Exception('exception raised').with_traceback(None)
|
||||
|
||||
# 输出
|
||||
__init__ called
|
||||
__enter__ called
|
||||
__exit__ called
|
||||
exc_type: <class 'Exception'>
|
||||
exc_value: exception raised
|
||||
exc_traceback: <traceback object at 0x1046036c8>
|
||||
exception handled
|
||||
|
||||
|
||||
这里,我们在with语句中手动抛出了异常“exception raised”,你可以看到,“__exit__()”方法中异常,被顺利捕捉并进行了处理。不过需要注意的是,如果方法“__exit__()”没有返回True,异常仍然会被抛出。因此,如果你确定异常已经被处理了,请在“__exit__()”的最后,加上“return True”这条语句。
|
||||
|
||||
同样的,数据库的连接操作,也常常用上下文管理器来表示,这里我给出了比较简化的代码:
|
||||
|
||||
class DBConnectionManager:
|
||||
def __init__(self, hostname, port):
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.connection = None
|
||||
|
||||
def __enter__(self):
|
||||
self.connection = DBClient(self.hostname, self.port)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.connection.close()
|
||||
|
||||
with DBConnectionManager('localhost', '8080') as db_client:
|
||||
|
||||
|
||||
与前面FileManager的例子类似:
|
||||
|
||||
|
||||
方法“__init__()”负责对数据库进行初始化,也就是将主机名、接口(这里是localhost和8080)分别赋予变量hostname和port;
|
||||
方法“__enter__()”连接数据库,并且返回对象DBConnectionManager;
|
||||
方法“__exit__()”则负责关闭数据库的连接。
|
||||
|
||||
|
||||
这样一来,只要你写完了DBconnectionManager这个类,那么在程序每次连接数据库时,我们都只需要简单地调用with语句即可,并不需要关心数据库的关闭、异常等等,显然大大提高了开发的效率。
|
||||
|
||||
基于生成器的上下文管理器
|
||||
|
||||
诚然,基于类的上下文管理器,在Python中应用广泛,也是我们经常看到的形式,不过Python中的上下文管理器并不局限于此。除了基于类,它还可以基于生成器实现。接下来我们来看一个例子。
|
||||
|
||||
比如,你可以使用装饰器contextlib.contextmanager,来定义自己所需的基于生成器的上下文管理器,用以支持with语句。还是拿前面的类上下文管理器FileManager来说,我们也可以用下面形式来表示:
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def file_manager(name, mode):
|
||||
try:
|
||||
f = open(name, mode)
|
||||
yield f
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
with file_manager('test.txt', 'w') as f:
|
||||
f.write('hello world')
|
||||
|
||||
|
||||
这段代码中,函数file_manager()是一个生成器,当我们执行with语句时,便会打开文件,并返回文件对象f;当with语句执行完后,finally block中的关闭文件操作便会执行。
|
||||
|
||||
你可以看到,使用基于生成器的上下文管理器时,我们不再用定义“__enter__()”和“__exit__()”方法,但请务必加上装饰器@contextmanager,这一点新手很容易疏忽。
|
||||
|
||||
讲完这两种不同原理的上下文管理器后,还需要强调的是,基于类的上下文管理器和基于生成器的上下文管理器,这两者在功能上是一致的。只不过,
|
||||
|
||||
|
||||
基于类的上下文管理器更加flexible,适用于大型的系统开发;
|
||||
而基于生成器的上下文管理器更加方便、简洁,适用于中小型程序。
|
||||
|
||||
|
||||
无论你使用哪一种,请不用忘记在方法“__exit__()”或者是finally block中释放资源,这一点尤其重要。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们先通过一个简单的例子,了解了资源泄露的易发生性,和其带来的严重后果,从而引入了应对方案——即上下文管理器的概念。上下文管理器,通常应用在文件的打开关闭和数据库的连接关闭等场景中,可以确保用过的资源得到迅速释放,有效提高了程序的安全性,
|
||||
|
||||
接着,我们通过自定义上下文管理的实例,了解了上下文管理工作的原理,并一起学习了基于类的上下文管理器和基于生成器的上下文管理器,这两者的功能相同,具体用哪个,取决于你的具体使用场景。
|
||||
|
||||
另外,上下文管理器通常和with语句一起使用,大大提高了程序的简洁度。需要注意的是,当我们用with语句执行上下文管理器的操作时,一旦有异常抛出,异常的类型、值等具体信息,都会通过参数传入“__exit__()”函数中。你可以自行定义相关的操作对异常进行处理,而处理完异常后,也别忘了加上“return True”这条语句,否则仍然会抛出异常。
|
||||
|
||||
思考题
|
||||
|
||||
那么,在你日常的学习工作中,哪些场景使用过上下文管理器?使用过程中又遇到了哪些问题,或是有什么新的发现呢?欢迎在下方留言与我讨论,也欢迎你把这篇文章分享出去,我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
281
专栏/Python核心技术与实战/30真的有必要写单元测试吗?.md
Normal file
281
专栏/Python核心技术与实战/30真的有必要写单元测试吗?.md
Normal file
@ -0,0 +1,281 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 真的有必要写单元测试吗?
|
||||
你好,我是景霄。
|
||||
|
||||
说到unit test(即单元测试,下文统一用中文称呼),大部分人的反应估计有这么两种:要么就是,单元测试啊,挺简单的呀,做不做无所谓吧;要么就是,哎呀,项目进度太赶,单元测试拖一拖之后再来吧。
|
||||
|
||||
显然,这两种人,都没有正确认识到单元测试的价值,也没能掌握正确的单元测试方法。你是不是觉得自己只要了解Python的各个feature,能够编写出符合规定功能的程序就可以了呢?
|
||||
|
||||
其实不然,完成产品的功能需求只是很基础的一部分,如何保证所写代码的稳定、高效、无误,才是我们工作的关键。而学会合理地使用单元测试,正是帮助你实现这一目标的重要路径。
|
||||
|
||||
我们总说,测试驱动开发(TDD)。今天我就以Python为例,教你设计编写Python的单元测试代码,带你熟悉并掌握这一重要技能。
|
||||
|
||||
什么是单元测试?
|
||||
|
||||
单元测试,通俗易懂地讲,就是编写测试来验证某一个模块的功能正确性,一般会指定输入,验证输出是否符合预期。
|
||||
|
||||
实际生产环境中,我们会对每一个模块的所有可能输入值进行测试。这样虽然显得繁琐,增加了额外的工作量,但是能够大大提高代码质量,减小bug发生的可能性,也更方便系统的维护。
|
||||
|
||||
说起单元测试,就不得不提 Python unittest库,它提供了我们需要的大多数工具。我们来看下面这个简单的测试,从代码中了解其使用方法:
|
||||
|
||||
import unittest
|
||||
|
||||
# 将要被测试的排序函数
|
||||
def sort(arr):
|
||||
l = len(arr)
|
||||
for i in range(0, l):
|
||||
for j in range(i + 1, l):
|
||||
if arr[i] >= arr[j]:
|
||||
tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
|
||||
|
||||
# 编写子类继承unittest.TestCase
|
||||
class TestSort(unittest.TestCase):
|
||||
|
||||
# 以test开头的函数将会被测试
|
||||
def test_sort(self):
|
||||
arr = [3, 4, 1, 5, 6]
|
||||
sort(arr)
|
||||
# assert 结果跟我们期待的一样
|
||||
self.assertEqual(arr, [1, 3, 4, 5, 6])
|
||||
|
||||
if __name__ == '__main__':
|
||||
## 如果在Jupyter下,请用如下方式运行单元测试
|
||||
unittest.main(argv=['first-arg-is-ignored'], exit=False)
|
||||
|
||||
## 如果是命令行下运行,则:
|
||||
## unittest.main()
|
||||
|
||||
## 输出
|
||||
..
|
||||
----------------------------------------------------------------------
|
||||
Ran 2 tests in 0.002s
|
||||
|
||||
OK
|
||||
|
||||
|
||||
这里,我们创建了一个排序函数的单元测试,来验证排序函数的功能是否正确。代码里我做了非常详细的注释,相信你能够大致读懂,我再来介绍一些细节。
|
||||
|
||||
首先,我们需要创建一个类TestSort,继承类‘unittest.TestCase’;然后,在这个类中定义相应的测试函数test_sort(),进行测试。注意,测试函数要以‘test’开头,而测试函数的内部,通常使用assertEqual()、assertTrue()、assertFalse()和assertRaise()等assert语句对结果进行验证。
|
||||
|
||||
最后运行时,如果你是在IPython或者Jupyter环境下,请使用下面这行代码:
|
||||
|
||||
unittest.main(argv=['first-arg-is-ignored'], exit=False)
|
||||
|
||||
|
||||
而如果你用的是命令行,直接使用unittest.main()就可以了。你可以看到,运行结果输出’OK‘,这就表示我们的测试通过了。
|
||||
|
||||
当然,这个例子中的被测函数相对简单一些,所以写起对应的单元测试来也非常自然,并不需要很多单元测试的技巧。但实战中的函数往往还是比较复杂的,遇到复杂问题,高手和新手的最大差别,便是单元测试技巧的使用。
|
||||
|
||||
单元测试的几个技巧
|
||||
|
||||
接下来,我将会介绍Python单元测试的几个技巧,分别是mock、side_effect和patch。这三者用法不一样,但都是一个核心思想,即用虚假的实现,来替换掉被测试函数的一些依赖项,让我们能把更多的精力放在需要被测试的功能上。
|
||||
|
||||
mock
|
||||
|
||||
mock是单元测试中最核心重要的一环。mock的意思,便是通过一个虚假对象,来代替被测试函数或模块需要的对象。
|
||||
|
||||
举个例子,比如你要测一个后端API逻辑的功能性,但一般后端API都依赖于数据库、文件系统、网络等。这样,你就需要通过mock,来创建一些虚假的数据库层、文件系统层、网络层对象,以便可以简单地对核心后端逻辑单元进行测试。
|
||||
|
||||
Python mock则主要使用mock或者MagicMock对象,这里我也举了一个代码示例。这个例子看上去比较简单,但是里面的思想很重要。下面我们一起来看下:
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class A(unittest.TestCase):
|
||||
def m1(self):
|
||||
val = self.m2()
|
||||
self.m3(val)
|
||||
|
||||
def m2(self):
|
||||
pass
|
||||
|
||||
def m3(self, val):
|
||||
pass
|
||||
|
||||
def test_m1(self):
|
||||
a = A()
|
||||
a.m2 = MagicMock(return_value="custom_val")
|
||||
a.m3 = MagicMock()
|
||||
a.m1()
|
||||
self.assertTrue(a.m2.called) #验证m2被call过
|
||||
a.m3.assert_called_with("custom_val") #验证m3被指定参数call过
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(argv=['first-arg-is-ignored'], exit=False)
|
||||
|
||||
## 输出
|
||||
..
|
||||
----------------------------------------------------------------------
|
||||
Ran 2 tests in 0.002s
|
||||
|
||||
OK
|
||||
|
||||
|
||||
这段代码中,我们定义了一个类的三个方法m1()、m2()、m3()。我们需要对m1()进行单元测试,但是m1()取决于m2()和m3()。如果m2()和m3()的内部比较复杂, 你就不能只是简单地调用m1()函数来进行测试,可能需要解决很多依赖项的问题。
|
||||
|
||||
这一听就让人头大了吧?但是,有了mock其实就很好办了。我们可以把m2()替换为一个返回具体数值的value,把m3()替换为另一个mock(空函数)。这样,测试m1()就很容易了,我们可以测试m1()调用m2(),并且用m2()的返回值调用m3()。
|
||||
|
||||
可能你会疑惑,这样测试m1()不是基本上毫无意义吗?看起来只是象征性地测了一下逻辑呀?
|
||||
|
||||
其实不然,真正工业化的代码,都是很多层模块相互逻辑调用的一个树形结构。单元测试需要测的是某个节点的逻辑功能,mock掉相关的依赖项是非常重要的。这也是为什么会被叫做单元测试unit test,而不是其他的integration test、end to end test这类。
|
||||
|
||||
Mock Side Effect
|
||||
|
||||
第二个我们来看Mock Side Effect,这个概念很好理解,就是 mock的函数,属性是可以根据不同的输入,返回不同的数值,而不只是一个return_value。
|
||||
|
||||
比如下面这个示例,例子很简单,测试的是输入参数是否为负数,输入小于0则输出为1 ,否则输出为2。代码很简短,你一定可以看懂,这便是Mock Side Effect的用法。
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
def side_effect(arg):
|
||||
if arg < 0:
|
||||
return 1
|
||||
else:
|
||||
return 2
|
||||
mock = MagicMock()
|
||||
mock.side_effect = side_effect
|
||||
|
||||
mock(-1)
|
||||
1
|
||||
|
||||
mock(1)
|
||||
2
|
||||
|
||||
|
||||
patch
|
||||
|
||||
至于patch,给开发者提供了非常便利的函数mock方法。它可以应用Python的decoration模式或是context manager概念,快速自然地mock所需的函数。它的用法也不难,我们来看代码:
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
@patch('sort')
|
||||
def test_sort(self, mock_sort):
|
||||
...
|
||||
...
|
||||
|
||||
|
||||
在这个test里面,mock_sort 替代sort函数本身的存在,所以,我们可以像开始提到的mock object一样,设置return_value和side_effect。
|
||||
|
||||
另一种patch的常见用法,是mock类的成员函数,这个技巧我们在工作中也经常会用到,比如说一个类的构造函数非常复杂,而测试其中一个成员函数并不依赖所有初始化的object。它的用法如下:
|
||||
|
||||
with patch.object(A, '__init__', lambda x: None):
|
||||
…
|
||||
|
||||
|
||||
代码应该也比较好懂。在with语句里面,我们通过patch,将A类的构造函数mock为一个do nothing的函数,这样就可以很方便地避免一些复杂的初始化(initialization)。
|
||||
|
||||
其实,综合前面讲的这几点来看,你应该感受到了,单元测试的核心还是mock,mock掉依赖项,测试相应的逻辑或算法的准确性。在我看来,虽然Python unittest库还有很多层出不穷的方法,但只要你能掌握了MagicMock和patch,编写绝大部分工作场景的单元测试就不成问题了。
|
||||
|
||||
高质量单元测试的关键
|
||||
|
||||
这节课的最后,我想谈一谈高质量的单元测试。我很理解,单元测试这个东西,哪怕是正在使用的人也是“百般讨厌”的,不少人很多时候只是敷衍了事。我也嫌麻烦,但从来不敢松懈,因为在大公司里,如果你写一个很重要的模块功能,不写单元测试是无法通过code review的。
|
||||
|
||||
低质量的单元测试,可能真的就是摆设,根本不能帮我们验证代码的正确性,还浪费时间。那么,既然要做单元测试,与其浪费时间糊弄自己,不如追求高质量的单元测试,切实提高代码品质。
|
||||
|
||||
那该怎么做呢?结合工作经验,我认为一个高质量的单元测试,应该特别关注下面两点。
|
||||
|
||||
Test Coverage
|
||||
|
||||
首先我们要关注Test Coverage,它是衡量代码中语句被cover的百分比。可以说,提高代码模块的Test Coverage,基本等同于提高代码的正确性。
|
||||
|
||||
为什么呢?
|
||||
|
||||
要知道,大多数公司代码库的模块都非常复杂。尽管它们遵从模块化设计的理念,但因为有复杂的业务逻辑在,还是会产生逻辑越来越复杂的模块。所以,编写高质量的单元测试,需要我们cover模块的每条语句,提高Test Coverage。
|
||||
|
||||
我们可以用Python的coverage tool 来衡量Test Coverage,并且显示每个模块为被coverage的语句。如果你想了解更多更详细的使用,可以点击这个链接来学习:https://coverage.readthedocs.io/en/v4.5.x/ 。
|
||||
|
||||
模块化
|
||||
|
||||
高质量单元测试,不仅要求我们提高Test Coverage,尽量让所写的测试能够cover每个模块中的每条语句;还要求我们从测试的角度审视codebase,去思考怎么模块化代码,以便写出高质量的单元测试。
|
||||
|
||||
光讲这段话可能有些抽象,我们来看这样的场景。比如,我写了一个下面这个函数,对一个数组进行处理,并返回新的数组:
|
||||
|
||||
def work(arr):
|
||||
# pre process
|
||||
...
|
||||
...
|
||||
# sort
|
||||
l = len(arr)
|
||||
for i in range(0, l):
|
||||
for j in range(i + 1, j):
|
||||
if arr[i] >= arr[j]:
|
||||
tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
# post process
|
||||
...
|
||||
...
|
||||
Return arr
|
||||
|
||||
|
||||
这段代码的大概意思是,先有个预处理,再排序,最后再处理一下然后返回。如果现在要求你,给这个函数写个单元测试,你是不是会一筹莫展呢?
|
||||
|
||||
毕竟,这个函数确实有点儿复杂,以至于你都不知道应该是怎样的输入,并要期望怎样的输出。这种代码写单元测试是非常痛苦的,更别谈cover每条语句的要求了。
|
||||
|
||||
所以,正确的测试方法,应该是先模块化代码,写成下面的形式:
|
||||
|
||||
def preprocess(arr):
|
||||
...
|
||||
...
|
||||
return arr
|
||||
|
||||
def sort(arr):
|
||||
...
|
||||
...
|
||||
return arr
|
||||
|
||||
def postprocess(arr):
|
||||
...
|
||||
return arr
|
||||
|
||||
def work(self):
|
||||
arr = preprocess(arr)
|
||||
arr = sort(arr)
|
||||
arr = postprocess(arr)
|
||||
return arr
|
||||
|
||||
|
||||
接着再进行相应的测试,测试三个子函数的功能正确性;然后通过mock子函数,调用work()函数,来验证三个子函数被call过。
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
def test_preprocess(self):
|
||||
...
|
||||
|
||||
def test_sort(self):
|
||||
...
|
||||
|
||||
def test_postprocess(self):
|
||||
...
|
||||
|
||||
@patch('%s.preprocess')
|
||||
@patch('%s.sort')
|
||||
@patch('%s.postprocess')
|
||||
def test_work(self,mock_post_process, mock_sort, mock_preprocess):
|
||||
work()
|
||||
self.assertTrue(mock_post_process.called)
|
||||
self.assertTrue(mock_sort.called)
|
||||
self.assertTrue(mock_preprocess.called)
|
||||
|
||||
|
||||
你看,这样一来,通过重构代码就可以使单元测试更加全面、精确,并且让整体架构、函数设计都美观了不少。
|
||||
|
||||
总结
|
||||
|
||||
回顾下这节课,整体来看,单元测试的理念是先模块化代码设计,然后针对每个作用单元,编写单独的测试去验证其准确性。更好的模块化设计和更多的Test Coverage,是提高代码质量的核心。而单元测试的本质就是通过mock,去除掉不影响测试的依赖项,把重点放在需要测试的代码核心逻辑上。
|
||||
|
||||
讲了这么多,还是想告诉你,单元测试是个非常非常重要的技能,在实际工作中是保证代码质量和准确性必不可少的一环。同时,单元测试的设计技能,不只是适用于Python,而是适用于任何语言。所以,单元测试必不可少。
|
||||
|
||||
思考题
|
||||
|
||||
那么,你在平时的学习工作中,曾经写过单元测试吗?在编写单元测试时,用到过哪些技巧或者遇到过哪些问题吗?欢迎留言与我交流,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/Python核心技术与实战/31pdb&cProfile:调试和性能分析的法宝.md
Normal file
0
专栏/Python核心技术与实战/31pdb&cProfile:调试和性能分析的法宝.md
Normal file
92
专栏/Python核心技术与实战/32答疑(三):如何选择合适的异常处理方式?.md
Normal file
92
专栏/Python核心技术与实战/32答疑(三):如何选择合适的异常处理方式?.md
Normal file
@ -0,0 +1,92 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 答疑(三):如何选择合适的异常处理方式?
|
||||
你好,我是景霄。
|
||||
|
||||
不知不觉中,我们又一起完成了第三大章规范篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
|
||||
|
||||
大部分留言,我都在相对应的文章中回复过了。而一些手机上不方便回复,或是很有价值很典型的问题,我专门摘录了出来,作为今天的答疑内容,集中回复。
|
||||
|
||||
问题一:应该使用哪种异常处理方式?
|
||||
|
||||
|
||||
|
||||
第一个问题是code2同学的疑惑。下面这两种处理的风格,哪一种风格更有效、更优雅?
|
||||
|
||||
|
||||
第一种,在代码中对数据进行检测,并直接处理与抛出异常。
|
||||
第二种,在异常处理代码中进行处理。
|
||||
|
||||
|
||||
其实,第一种方法,可以翻译成下面的“if…elif…”语句:
|
||||
|
||||
if [condition1]:
|
||||
raise Exception1('exception 1')
|
||||
elif [condition2]:
|
||||
raise Exception2('exception 2')
|
||||
...
|
||||
|
||||
|
||||
而第二种方法,则对应着下面异常处理的代码:
|
||||
|
||||
try:
|
||||
...
|
||||
except Exception as e:
|
||||
...
|
||||
|
||||
|
||||
这两种方法很大的一个区别是,第一种方法一旦抛出异常,那么程序就会终止;而在第二种方法中,如果抛出异常,会被程序捕获(catch),程序还会继续运行。这也是我们选择这两种方法的重要依据。当然,在实际工作中,到底使用哪一种方法,还是取决于具体的场景。
|
||||
|
||||
比方说,一个模块的功能是对输入进行检测,如果输入不合法,则弹出对话框进行提示,并终止程序。那么,这种情况下,使用第一种方法更加合理。
|
||||
|
||||
但是,如果换成一个产品的服务器端,它需要应对各种可能发生的情况,以保证服务器不崩溃。比如在连接数据库时,如果网络异常,无法连接,那就需要捕获(catch)这个异常(exception),进行记录,并同时保证其他功能不受影响。这种情况下,我们通常会选择第二种方式。
|
||||
|
||||
问题二:先写出能跑起来的代码,后期再优化可以吗?
|
||||
|
||||
|
||||
|
||||
第二个问题,夜路破晓同学提到了很多程序员传授的“经验之谈”,即先写出能跑起来的代码,后期再优化。很明显,这种认知是错误的。我们从一开始写代码时,就必须对功能和规范这两者双管齐下。
|
||||
|
||||
代码功能完整和规范完整的优先级是不分先后的,应该是同时进行的。如果你一开始只注重代码的功能完整,而不关注其质量、规范,那么规范问题很容易越积越多。这样就会导致产品的bug越来越多,相应的代码库越发难以维护,到最后不得已只能推倒重来。
|
||||
|
||||
我在Facebook工作时就遇到过这样的情况,参与过类似的项目。当时,某些功能模块因为赶时间,code review很宽松,代码写得很不规范,留下了隐患。时间一长,bug越来越多,legacy越来越多。到最后,万分无奈的情况下,我们几个工程师专门立项,花了三个多月时间,重写了这一模块的代码,才解决了这个问题。
|
||||
|
||||
问题三:代码中写多少注释才合适?
|
||||
|
||||
|
||||
|
||||
第三个问题,小侠龙旋风同学留言说,自己的同事要求代码中有70%的注释,这显然有点过了。但是反过来说,如果你的代码中没有注释或者注释很少,仅凭规范的变量名肯定是远远不够的。
|
||||
|
||||
通常来说,我们会在类的开头、函数的开头或者是某一个功能块的开头加上一段描述性的注释,来说明这段代码的功能,并指明所有的输入和输出。除此之外,我们也要求在一些比较tricky的代码上方加上注释,帮助阅读者理解代码的含义。
|
||||
|
||||
总的来说,代码中到底需要有多少注释,其实并没有一个统一的要求,还是要根据代码量和代码的复杂度来决定。不过,我们平常书写时,只要满足这样的规范就可以了。
|
||||
|
||||
另外,必须提醒一点,如果在写好之后修改了代码,那么代码对应的注释一定也要做出相应的修改,不然很容易造成“文不对题”的现象,给别人也给你自己带来困扰。
|
||||
|
||||
问题四:项目的API文档重要吗?
|
||||
|
||||
|
||||
|
||||
第四个问题,是未来已来同学的留言。他提到了项目的API文档的问题,这一点说得非常好,在这里我也简单介绍一下。
|
||||
|
||||
我在专栏中主要讲的是代码的规范问题,但很多情况下,光有规范的代码还是远远不够的。因为一个系统,一个产品,甚至一个功能模块的代码,都有可能非常复杂。少则几千行,动辄几十万行,尤其是对于刚加入的新人来说,在ramp up阶段光看代码可能就是一个噩梦了。
|
||||
|
||||
因此,在这方面做得比较规范的公司,通常也会要求书写文档。项目的文档,主要是对相应的系统、产品或是功能模块做一个概述,有助于后人理解。以一个service为例,其对应的文档通常会包括下面几部分:
|
||||
|
||||
|
||||
第一点,系统的概述,包括各个组成部分以及工作流程的介绍;
|
||||
第二点,每个组成部分的具体介绍,包括必要性、设计原理等等;
|
||||
第三点,系统的performance,包括latency等等参数;
|
||||
第四点主要说明如何对系统的各个部分进行修改,主要给出相应的code pointer及对应的测试方案。
|
||||
|
||||
|
||||
这些内容,也希望屏幕前的你能够牢记。
|
||||
|
||||
今天我主要回答这些问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望每一次的留言和答疑,都能给你带来新的收获和价值。
|
||||
|
||||
|
||||
|
||||
|
191
专栏/Python核心技术与实战/33带你初探量化世界.md
Normal file
191
专栏/Python核心技术与实战/33带你初探量化世界.md
Normal file
@ -0,0 +1,191 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
33 带你初探量化世界
|
||||
你好,我是景霄。
|
||||
|
||||
在2000 年顶峰时期,高盛雇佣了 600 名交易员为机构客户买卖现金股票。可如今,这个数字只有 2 名(Ref. 经济学人)。到了2009 年,金融危机余音未散,专家面对股票和证券交易中越来越多的机器参与提出了警告,因为机器的崛起,逐渐导致了手操交易工作的消失。
|
||||
|
||||
很久之前,瑞银集团(UBS)的交易大厅是下面这样的:
|
||||
|
||||
|
||||
|
||||
8 年之后,交易大厅就已经只有这些人了:
|
||||
|
||||
|
||||
|
||||
事实上,随着数据处理技术的飞速发展,和量化交易模型研究理论的逐渐成熟,现金股票交易、债券市场、期货市场以及投行的相当一部分业务,都在朝着自动化的方向迈进。
|
||||
|
||||
而发展到2017 年,WannyCry 席卷全球,随之而来的比特币,在短短几个月内从小众玩家走入了公众视野,币价也是一飞冲天,很多炒币的人赚得盆满钵满。更有一部分人,将金融业的量化策略应用其中,无论是搬砖(套利),还是波段,在不成熟的市场初期都赚了个爽快。
|
||||
|
||||
这节课开始,我们就来探索一下量化的世界。作为我们 Python 专栏的综合实践模块,希望你能在这一部分内容中,收获自己独特的东西。
|
||||
|
||||
交易是什么?
|
||||
|
||||
市场,是人类有史以来最伟大的发明之一。亚当·斯密在国富论中,用“看不见的手”这个概念,生动形象地阐释了市场和理性人之间是如何交互,最终让整个社会受益的。
|
||||
|
||||
而市场的核心,是交换。人类发展最开始是物物交换,原始的“以物易物”的方式产生于货币诞生之前。不过,这种方式非常低效,不便于流通交换,经常会出现的情况是,要走很长的交换链条才能拿到自己想要的物品。于是,一般等价物出现了,社会分工也逐渐出现了。人们把自己生产的商品换成一般等价物,然后再换成自己需要的其他商品。
|
||||
|
||||
而交换的核心,就是买和卖。当买卖双方对价格预期相等的时候,交易达成。随着金融和技术的发展,逐渐出现了股票、债券、期权、期货等越来越多的金融工具,金融衍生品也朝着复杂的方向发展。
|
||||
|
||||
在我们经常听到的投资银行中,量化基金交易员这种角色,所做的事情,就是在这些复杂的衍生品基础上,分析投资标的的价值,然后以某种策略来管理持有仓位,进行买进和卖出。
|
||||
|
||||
为什么交易能赚钱,是很多人疑惑不解的地方。市场究竟有没有规律可循呢?可以肯定是有的,但虽有迹可循却无法可依。交易的多样性和人性的复杂性,使得金融数据的噪音极大,我们无法简单地从某一两个因子来确定地推导行情变化。
|
||||
|
||||
所以交易员这个行业本身,对自身素质要求是极高的。除了要具备扎实的专业素养(包括金融功底、数理逻辑、分析能力、决策能力),对心理素质的要求也非常高。这种直接和钱打交道、并直面人性深处欲望的行业,也因此吸引了无数高手的参与,很多人因此暴富,也有不少人破产,一无所有。
|
||||
|
||||
那么,有什么办法可以规避这种,因为心理素质原因带来的风险呢?
|
||||
|
||||
量化交易
|
||||
|
||||
回答这个问题之前,我先插一句题外话。刚接触量化交易的朋友,都很容易被这几个词绕晕:量化交易(Quantitative Trading)、程序化交易(Program Trading)、算法交易(Algo-Trading)、高频交易(High Frequency Trading)和自动化交易平台(Automated Trading System)。
|
||||
|
||||
虽然我遇到过不少行业内的人也混用这词,但是作为初学者来说,厘清这些术语还是很有帮助的。至少,在别人说出这些高大上的词时,我们心里不用犯怵了。
|
||||
|
||||
先来看程序化交易,它通常用计算机程序代替交易员,来具体执行金融产品的买卖。比如,一个基金经理需要卖出大量股票。如果直接挂一个大的卖单,可能会影响市场,那就用计算机程序拆分成小单慢慢执行。所以,量化交易的下层通常是程序交易。
|
||||
|
||||
而算法交易通常用于高频交易中。它指的是,通过算法快速判定买卖的时间点,快速买卖多个产品。
|
||||
|
||||
量化交易则通常是指使用数学、统计甚至机器学习的方法,去找寻合适的买卖时机。所以,在这个维度的定义之下,算法交易、高频交易还有统计套利(Statistical Arbitrage)都可以算作量化交易。
|
||||
|
||||
简单而言,我们可以认为量化交易的涵盖范围最大。因此,当你不确定用哪个词的时候,用量化交易就行了。
|
||||
|
||||
回到我们刚刚的问题,规避心理素质原因带来的风险的方法,自然就是量化交易了。量化交易的好处显而易见。最直观来看,计算机不眠不休,不需要交易员实时操盘,满足了人们“躺着挣钱”的愿景。当然,这只是美好的想象,真要这么做的话,不久之后就要回工地搬砖了。现实场景中,成熟的量化交易也需要有人蹲守,适时干预,防止算法突然失效造成巨额的交易亏损。
|
||||
|
||||
在数字货币领域的交易,这一点更加显著。数字货币的交易在全球许多交易所进行,和股票不同,一支股票可能只在少数几个交易所交易,而一种数字货币可以在所有的交易所同时进行交易。同时,因为没有股市的开盘、收盘限制,数字货币的交易通常是 7 x 24 小时不眠不休,比前世的 “996 福报”凶残多了。要是真有交易员能在这个市场活下来,我们尊称一声“神仙”也不为过了。
|
||||
|
||||
多交易所交易,也意味着全球数字货币市场每时每刻都是紧密相连的。一个市场、一个局部的巨大变动,都会影响所有的市场。比如,2017年朝鲜氢弹炸了的当天,新闻还没出来,隔壁韩国、日本的比特币价格马上拉升了一波;再比如,当比特币的负面消息半夜里传出来的时候,其价格也马上跟着暴跌一波。
|
||||
|
||||
|
||||
|
||||
2019年6月比特币在全球头部交易所的价格
|
||||
|
||||
因此,我们经常看到比特币的价格波动巨大。很有可能今天还是财富自由状态,明天的财富就没那么自由了。显然,在这种市场中交易,人力很难持久支撑,而量化交易就很合适了。
|
||||
|
||||
通常的电子盘交易(比如股票,数字货币),是通过券商或者软件,直接把买卖请求发送给交易所;而算法交易的底层,就是让程序来自动实现这类操作。券商或者交易所,通常也会提供API接口给投资者。比如,盈透证券(Interactive Broker)的接口,就可以支持股票、期权的行情数据获取和交易;而 Gemini、OKCoin等交易所,也提供了对应的接口进行数字货币行情获取和交易。
|
||||
|
||||
Gemini交易所的公开行情API,就可以通过下面这种简单的HTTP GET请求,来获取最近的比特币(BTC)对美元(USD)的价格和最近的成交量。
|
||||
|
||||
########## GEMINI行情接口 ##########
|
||||
## https://api.gemini.com/v1/pubticker/:symbol
|
||||
|
||||
import json
|
||||
import requests
|
||||
|
||||
gemini_ticker = 'https://api.gemini.com/v1/pubticker/{}'
|
||||
symbol = 'btcusd'
|
||||
btc_data = requests.get(gemini_ticker.format(symbol)).json()
|
||||
print(json.dumps(btc_data, indent=4))
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
{
|
||||
"bid": "8825.88",
|
||||
"ask": "8827.52",
|
||||
"volume": {
|
||||
"BTC": "910.0838782726",
|
||||
"USD": "7972904.560901317851",
|
||||
"timestamp": 1560643800000
|
||||
},
|
||||
"last": "8838.45"
|
||||
}
|
||||
|
||||
|
||||
对算法交易系统来说,API只是最下层的结构。通常而言,一个基本的交易系统应该包括:行情模块、策略模块和执行模块。为了辅助策略的开发,通常还有回测系统辅助。它们的分工示意图大致如下:
|
||||
|
||||
|
||||
|
||||
其中,
|
||||
|
||||
|
||||
行情模块的主要功能是,尝试获取市场的行情数据,通常也负责获取交易账户的状态。
|
||||
策略模块的主要功能是,订阅市场的数据,根据设定的算法发出买、卖指令给执行模块。
|
||||
执行模块的主要功能是,接受并把策略模块发过来的买、卖指令封装并转发到交易所;同时,监督并确保策略买卖的完整执行。
|
||||
|
||||
|
||||
Python算法交易
|
||||
|
||||
了解了这么多关于量化交易的知识,接下来我们就来说说Python算法交易。Python 在金融行业的许多方面都有用到,在算法交易领域,更是发挥了日益重要的作用。 Python 之所以能在这个行业这么流行,主要是因为下面四个原因。
|
||||
|
||||
数据分析能力
|
||||
|
||||
第一个原因,是Python的数据分析能力。算法交易领域的一个基本需求,就是高效数据处理能力,而数据处理则是Python的强项。特别是NumPy+Pandas的组合,简直让算法交易开发者的生活质量直线上升。
|
||||
|
||||
我们可以用一个简单的例子来展示一下,如何抓取、格式化和绘制,比特币过去一个小时在Gemini交易所的价格曲线。相关的代码我都附了详细注释,这里就不再多讲,你阅读了解一下即可。
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
# 选择要获取的数据时间段
|
||||
periods = '3600'
|
||||
|
||||
# 通过Http抓取btc历史价格数据
|
||||
resp = requests.get('https://api.cryptowat.ch/markets/gemini/btcusd/ohlc',
|
||||
params={
|
||||
'periods': periods
|
||||
})
|
||||
data = resp.json()
|
||||
|
||||
# 转换成pandas data frame
|
||||
df = pd.DataFrame(
|
||||
data['result'][periods],
|
||||
columns=[
|
||||
'CloseTime',
|
||||
'OpenPrice',
|
||||
'HighPrice',
|
||||
'LowPrice',
|
||||
'ClosePrice',
|
||||
'Volume',
|
||||
'NA'])
|
||||
|
||||
# 输出DataFrame的头部几行
|
||||
print(df.head())
|
||||
|
||||
# 绘制btc价格曲线
|
||||
df['ClosePrice'].plot(figsize=(14, 7))
|
||||
plt.show()
|
||||
|
||||
|
||||
########### 输出 ###############
|
||||
CloseTime OpenPrice HighPrice ... ClosePrice Volume NA
|
||||
0 1558843200 8030.55 8046.30 ... 8011.20 11.642968 93432.459964
|
||||
1 1558846800 8002.76 8050.33 ... 8034.48 8.575682 68870.145895
|
||||
2 1558850400 8031.61 8036.14 ... 8000.00 15.659680 125384.519063
|
||||
3 1558854000 8000.00 8016.29 ... 8001.46 38.171420 304342.048892
|
||||
4 1558857600 8002.69 8023.11 ... 8009.24 3.582830 28716.385009
|
||||
|
||||
|
||||
通过执行这样的一段代码,我们便可以得到下面这张图所示的价格曲线。
|
||||
|
||||
|
||||
|
||||
大量专有库
|
||||
|
||||
除了强大的数据处理能力之外,Python 还有许许多多已经开发成熟的算法交易库可供使用。比如,你可以使用Zipline进行策略回测,或者用Pyfolio进行投资组合分析。而许多交易所也都提供了基于Python的API客户端。
|
||||
|
||||
便利的交易平台
|
||||
|
||||
第三个原因,是因为便利的交易平台。有一些算法交易平台可以执行自定义 Python 策略,无需搭建量化交易框架。算法交易平台,实际上等效于帮用户完成了行情模块和执行模块。用户只需要在其中定义策略模块,即可进行算法交易和回测。
|
||||
|
||||
比如,Quantopian,就提供了基于Zipline的标准回测环境。用户可以选择Python作为开发语言,并且和社区的网友分享自己的策略。此外,国内也有诸如BigQuant、果仁网等类似平台,提供不同市场和金融产品的交易。
|
||||
|
||||
广泛的行业应用
|
||||
|
||||
最后一个原因,则是Python本身广泛的行业应用了。目前,越来越多投资机构的交易部门,都开始使用Python,因此也对优秀的Python开发者产生了更多的需求。自然,这也让学习Python,成为了更有意义的“投资”。
|
||||
|
||||
总结
|
||||
|
||||
这一节课,我们介绍了交易,以及算法交易中的基本概念,也简单介绍了为什么要学习 Python 来搭建量化交易系统。量化交易是交易行业的大趋势;同时, Python 作为最适合量化从业者的语言之一,对于初学者而言也有着非常重要的地位。
|
||||
|
||||
接下来的几节课,我们将从细节深入量化交易的每一个模块,由浅入深地为你揭开量化交易神秘的面纱。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。高频交易和中低频交易,哪个更适合使用 Python?为什么?欢迎在留言区写下你的想法,也欢迎你把这篇文章分享给更多对量化交易感兴趣的人,我们一起交流和探讨。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/Python核心技术与实战/34RESTful&Socket:搭建交易执行层核心.md
Normal file
0
专栏/Python核心技术与实战/34RESTful&Socket:搭建交易执行层核心.md
Normal file
0
专栏/Python核心技术与实战/35RESTful&Socket:行情数据对接和抓取.md
Normal file
0
专栏/Python核心技术与实战/35RESTful&Socket:行情数据对接和抓取.md
Normal file
0
专栏/Python核心技术与实战/36Pandas&Numpy:策略与回测系统.md
Normal file
0
专栏/Python核心技术与实战/36Pandas&Numpy:策略与回测系统.md
Normal file
0
专栏/Python核心技术与实战/37Kafka&ZMQ:自动化交易流水线.md
Normal file
0
专栏/Python核心技术与实战/37Kafka&ZMQ:自动化交易流水线.md
Normal file
243
专栏/Python核心技术与实战/38MySQL:日志和数据存储系统.md
Normal file
243
专栏/Python核心技术与实战/38MySQL:日志和数据存储系统.md
Normal file
@ -0,0 +1,243 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
38 MySQL:日志和数据存储系统
|
||||
你好,我是景霄。今天这节课,我们来聊聊日志和存储系统。
|
||||
|
||||
在互联网公司中,日志系统是一个非常重要的技术底层。在每一次重要的交互行为中,关键信息都会被记录下来存档,以供日后线下分析,或者线上实时分析。这些数据,甚至可以说是硅谷互联网大公司的命脉所在。
|
||||
|
||||
有了它们,你才能建立机器学习模型来预测用户的行为,从而可以精确描绘用户画像,然后针对性地使用推荐系统、分类器,将用户进一步留下,并精准推送广告来盈利。
|
||||
|
||||
在量化交易中,日志同样有着非常重要的作用。一如前面所讲,我们重要的数据有:行情数据、策略信号、执行情况、仓位信息等等非常多的信息。
|
||||
|
||||
对于简单的、小规模的数据,例如 orderbook 信息,我们完全可以把数据存在 txt、csv 文件中,这样做简单高效。不过,缺点是,随着数据量上升,一个文件将会变得非常大,检索起来也不容易。这时,一个很直观的方式出现了,我们可以把每天的数据存在一个文件中,这样就暂时缓解了尴尬。
|
||||
|
||||
但是,随着数据量的上升,或者是你的算法逐渐来到高频交易领域时,简单地把数据存在文件上,已经不足以满足新的需求,更无法应对分布式量化交易系统的需求。于是,一个显而易见的想法就是,我们可以把日志存在数据库系统中。
|
||||
|
||||
这节课,我们就以 MySQL 这种传统型关系数据库为例,讲解一下数据库在日志中的运用。
|
||||
|
||||
快速理解MySQL
|
||||
|
||||
担心一些同学没有数据库的基础,我先来简单介绍一下 MySQL 数据库。
|
||||
|
||||
MySQL 属于典型的关系型数据库(RDBMS),所谓的关系型数据库,就是指建立在关系模型基础上的数据库,借助于集合代数等数学概念和方法,来处理数据库中的数据。基本上任何学习资料都会告诉你,它有着下面这几个特征:
|
||||
|
||||
|
||||
数据是以表格的形式出现的;
|
||||
每一行是各种记录名称;
|
||||
每一列是记录名称所对应的数据域;
|
||||
许多的行和列,组成一张表单;
|
||||
若干的表单,组成数据库(database)这个整体。
|
||||
|
||||
|
||||
不过,抛开这些抽象的特征不谈,你首先需要掌握的,是下面这些术语的概念。
|
||||
|
||||
|
||||
数据库,是一些关联表的集合;而数据表则是数据的矩阵。在一个数据库中,数据表看起来就像是一个简单的电子表格。
|
||||
在数据表中,每一列包含的是相同类型的数据;每一行则是一组相关的数据。
|
||||
主键也是数据表中的一个列,只不过,这一列的每行元素都是唯一的,且一个数据表中只能包含一个主键;而外键则用于关联两个表。
|
||||
|
||||
|
||||
除此之外,你还需要了解索引。索引是对数据库表中一列或多列的值进行排序的一种结构。使用索引,我们可以快速访问数据库表中的特定信息。一般来说,你可以对很多列设置索引,这样在检索指定列的时候,就大大加快了速度,当然,代价是插入数据会变得更慢。
|
||||
|
||||
至于操作 MySQL,一般用的是结构化查询语言SQL。SQL是一种典型的领域专用语言(domain-specific language,简称DSL),这里我就不做过多介绍了,如果你感兴趣,可以学习极客时间平台上的“SQL必知必会”专栏。
|
||||
|
||||
接下来,我们就来简单看一下,如何使用 Python 来操作 MySQL 数据库。
|
||||
|
||||
Python 连接数据库的方式有好多种,这里我简单介绍其中两种。我们以 Ubuntu 为例,假设你的系统中已经安装过 MySQL Server。(安装 MySQL可以参考这篇文章 https://www.jianshu.com/p/3111290b87f4,或者你可以自行搜索解决)
|
||||
|
||||
mysqlclient
|
||||
|
||||
事实上, Python 连接 MySQL 最流行的一个驱动是 MySQL-python,又叫 MySQLdb,很多框架都也是基于此库进行开发。不过,遗憾的是,它只支持 Python2.x,而且安装的时候有很多前置条件。因为它是基于C开发的库,在 Windows 平台安装非常不友好,经常出现失败的情况。所以,现在我们基本不再推荐使用,取代者是它的衍生版本——mysqlclient。
|
||||
|
||||
mysqlclient 完全兼容 MySQLdb,同时支持 Python3.x,是 Django ORM的依赖工具。如果你想使用原生 SQL 来操作数据库,那么我优先推荐使用这个框架。
|
||||
|
||||
它的安装方式很简单:
|
||||
|
||||
sudo apt-get install python3-dev
|
||||
pip install mysqlclient
|
||||
|
||||
|
||||
我们来看一个样例代码:
|
||||
|
||||
import MySQLdb
|
||||
|
||||
|
||||
def test_pymysql():
|
||||
conn = MySQLdb.connect(
|
||||
host='localhost',
|
||||
port=3306,
|
||||
user='your_username',
|
||||
passwd=your_password’,
|
||||
db='mysql'
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
CREATE TABLE price (
|
||||
timestamp TIMESTAMP NOT NULL,
|
||||
BTCUSD FLOAT(8,2),
|
||||
PRIMARY KEY (timestamp)
|
||||
);
|
||||
''')
|
||||
cur.execute('''
|
||||
INSERT INTO price VALUES(
|
||||
"2019-07-14 14:12:17",
|
||||
11234.56
|
||||
);
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
test_pymy
|
||||
|
||||
|
||||
代码的思路很清晰明了,首先是通过 connect 命令连接数据库,来创建一个连接;之后,通过 conn.cursor() 函数创建一个游标。这里你可能会问,为什么要使用游标呢?
|
||||
|
||||
一个主要的原因就是,这样可以把集合操作转换成单个记录处理的方式。如果用 SQL 语言从数据库中检索数据,结果会放在内存的一块区域中,并且这个结果往往是一个含有多个记录的集合。而游标机制,则允许用户在 MySQL 内逐行地访问这些记录,这样你就可以按照自己的意愿,来显示和处理这些记录。
|
||||
|
||||
继续回到代码中,再往下走,我们创建了一个 price table,同时向里面插入一条 orderbook 数据。这里为了简化代码突出重点,我只保留了 timestamp 和 price。
|
||||
|
||||
最后,我们使用 conn.commit() 来提交更改,然后 close() 掉连接就可以了。
|
||||
|
||||
peewee
|
||||
|
||||
不过,大家逐渐发现,写原生的 SQL 命令很麻烦。因为你需要根据特定的业务逻辑,来构造特定的插入和查询语句,这样可以说就完全抛弃了面向对象的思维。因此,又诞生了很多封装 wrapper 包和 ORM 框架。
|
||||
|
||||
这里所说的ORM(Object Relational Mapping,简称ORM) ,是 Python 对象与数据库关系表的一种映射关系,有了 ORM 后,我们就不再需要写 SQL 语句,而可以直接使用 Python 的数据结构了。
|
||||
|
||||
ORM 框架的优点,是提高了写代码的速度,同时兼容多种数据库系统,如SQLite、MySQL、PostgreSQL等这些数据库;而付出的代价,可能就是性能上的一些损失。
|
||||
|
||||
接下来要讲的peewee,正是其中一种基于 Python 的 ORM 框架,它的学习成本非常低,可以说是 Python 中最流行的 ORM 框架。
|
||||
|
||||
它的安装方式也很简单:
|
||||
|
||||
pip install peewee
|
||||
|
||||
|
||||
我们来看一个样例代码:
|
||||
|
||||
import peewee
|
||||
from peewee import *
|
||||
|
||||
db = MySQLDatabase('mysql', user='your_username', passwd=your_password’)
|
||||
|
||||
|
||||
class Price(peewee.Model):
|
||||
timestamp = peewee.DateTimeField(primary_key=True)
|
||||
BTCUSD = peewee.FloatField()
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
|
||||
|
||||
def test_peewee():
|
||||
Price.create_table()
|
||||
price = Price(timestamp='2019-06-07 13:17:18', BTCUSD='12345.67')
|
||||
price.save()
|
||||
|
||||
|
||||
test_p
|
||||
|
||||
|
||||
如果你写过 Django,你会发现,这个写法和 Django 简直一模一样。我们通过一个 Python class ,映射了 MySQL 中的一张数据表;只要对其中每一列数据格式进行定义,便可按照 Python 的方式进行操作。
|
||||
|
||||
显而易见,peewee的最大优点,就是让 SQL 语言瞬间变成强类型语言,这样不仅极大地增强了可读性,也能有效减少出 bug 的概率。
|
||||
|
||||
不过,事实上,作为一名数据科学家,或者作为一名量化从业者(quant ),你要处理的数据远比这些复杂很多。互联网工业界有大量的脏数据,金融行业的信噪比更是非常之低,数据处理只能算是基本功。
|
||||
|
||||
如果你对数据分析有兴趣和志向,在学生时期就应该先打牢数学和统计的基础,之后在实习和工作中快速掌握数据处理的方法。当然,如果你已经错过学生时期的话,现在开始也是个不错的选择,毕竟,逐渐形成自己的核心竞争力,才是我们每个人的正道。
|
||||
|
||||
量化数据分析系统
|
||||
|
||||
数据库有了量化数据存入后,接下来,我们便可以开始进行一些量化分析了。这一块儿也是一个很大的学术领域,叫做时间序列分析,不过就今天这节课的主题来说,我们仅做抛砖引玉,列举一个非常简单的例子,即求过去一个小时 BTC/USD 的最高价和最低价。
|
||||
|
||||
我们来看下面这段代码:
|
||||
|
||||
import MySQLdb
|
||||
import numpy as np
|
||||
|
||||
|
||||
def test_pymysql():
|
||||
conn = MySQLdb.connect(
|
||||
host='localhost',
|
||||
port=3306,
|
||||
user='your_username',
|
||||
passwd='your_password',
|
||||
db='mysql'
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute('''
|
||||
SELECT
|
||||
BTCUSD
|
||||
FROM
|
||||
price
|
||||
WHERE
|
||||
timestamp > now() - interval 60 minute
|
||||
''')
|
||||
|
||||
BTCUSD = np.array(cur.fetchall())
|
||||
print(BTCUSD.max(), BTCUSD.min())
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
test_pym
|
||||
|
||||
|
||||
代码看起来很简单吧!显然,通过 SQL 语句,我们可以抓取到过去一小时的时间序列片段,拿到我们想要的 BTC/USD 价格向量,然后通过 numpy 处理一下即可。不过这里需要注意一点,我们并不需要调用 conn.commit(),因为我们的操作是只读的,对数据库没有任何影响。
|
||||
|
||||
分布式日志系统
|
||||
|
||||
明白了上面的内容后,我们现在来看一下分布式日志系统。
|
||||
|
||||
对量化交易而言,我们需要的模块主要有数据系统、策略系统、交易执行系统、线下模型训练、线上风控系统以及实时监控系统。它们之间的对应关系,我画了一张图,你可以参考来理解。
|
||||
|
||||
|
||||
|
||||
这里的每个子系统都是独立运行的,并且还有许多模块需要迭代更新,所以我们简单保存本地日志显然不是一个明智之举。于是,我们可以专门开一台服务器来运行 MySQL server,并且开放指定端口和其他系统进行交互。
|
||||
|
||||
另外,图中的收集系统,其实类似于上一节我们所讲的消息队列体系,在各个上游系统中运行代理工具,负责将各个模块的 log 收集起来,然后发送到收集系统中。收集系统整理过后,再将信息存到日志系统。当然,除了简单的消息队列,我们还能用很多工具,比如阿里云的Logtail、 Apache 的 Flume Agent等等。
|
||||
|
||||
而到了后期,对于日志系统来说,越来越需要注意的就是存储效率和分析效率。随着使用的增加,数据会越来越多,因此我们可以考虑对一些数据进行压缩和保存。而越是久远的数据,越是粗粒度的数据,被调用的概率也就越低,所以它们也就首当其冲,成了我们压缩、保存的目标。
|
||||
|
||||
日志分析
|
||||
|
||||
最后,我再来补充讲一讲日志的分析。前面提到过,分析一般分为两种,离线分析和在线分析。
|
||||
|
||||
在离线分析中,比较常见的是生成报告。
|
||||
|
||||
比如,总结某天某月或某季度内的,收益亏损情况(PnL)、最大回撤、夏普比率等数据。这种基于时间窗口的统计,在关系型数据库中也能得到很方便的支持。
|
||||
|
||||
而另一类常见的离线使用方式,则是回测系统。在一个新策略研发的周期中,我们需要对历史数据进行回测,这样就可以得到历史数据中交易的收益率等数据。回测系统对于评估一个新的策略非常重要,然而,回测往往需要大量的资源,所以选取好数据库、数据存储方式,优化数据连接和计算,就显得至关重要。
|
||||
|
||||
在线分析,则更多应用于风控和警报系统。这种方式,对数据的实时性要求更高一些,于是,一种方法就是,从消息队列中直接拿最快的数据进行操作。当然,这个前提是时间窗口较小,这样你就不需要风控系统来维护大量的本地数据。
|
||||
|
||||
至于实时警报,最关键的依然是数据。
|
||||
|
||||
|
||||
比如,数据系统异常停止,被监视的表没有更新;
|
||||
或者,交易系统的连接出了故障,委托订单的某些状态超过了一定的阈值;
|
||||
再或者,仓位信息出现了较大的、预计之外的变动。
|
||||
|
||||
|
||||
这些情况都需要进行报警,也就是硅谷大公司所说的“oncall”。一旦发生意外,负责人会迅速收到电话、短信和邮件,然后通过监控平台来确认,是真的出了事故还是监控误报。
|
||||
|
||||
当然,现在已经有了不少开源的工具可以在云端使用,其中 AWS 属于全球领先的云计算平台。如果你的服务器架设在美国,那就可以考虑选择它家的各种各样的云服务。这样做的好处是,对于小型量化交易团队而言,避免自己搭建复杂的日志系统,而是把主要精力放在策略的开发迭代之上,提高了不少效率。
|
||||
|
||||
总结
|
||||
|
||||
这一节课,我从工程的角度,为你介绍了量化系统中的存储系统。我们从基础的 MySQL 的使用方法讲起,再讲到后面的量化系统框架。数据库和数据在绝大部分互联网行业都是核心,对量化从业者来说也是重要的生产资料。而搭建一套负载合理、数据可靠的数据系统,也需要一个量化团队长期打磨,并根据需求进行迭代。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。量化交易需要的数据量不是很大,但是有可能出现调用频率极高的情况,例如回测系统。那么,你能想到哪些优化手段,来降低调用代价吗?欢迎留言和我讨论,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
350
专栏/Python核心技术与实战/39Django:搭建监控平台.md
Normal file
350
专栏/Python核心技术与实战/39Django:搭建监控平台.md
Normal file
@ -0,0 +1,350 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
39 Django:搭建监控平台
|
||||
你好,我是景霄。
|
||||
|
||||
通过前几节课的学习,相信你对量化交易系统已经有了一个最基本的认知,也能通过自己的代码,搭建一个简单的量化交易系统来进行盈利。
|
||||
|
||||
前面几节课,我们的重点在后台代码、中间件、分布式系统和设计模式上。这节课,我们重点来看前端交互。
|
||||
|
||||
监控和运维,是互联网工业链上非常重要的一环。监控的目的就是防患于未然。通过监控,我们能够及时了解到企业网络的运行状态。一旦出现安全隐患,你就可以及时预警,或者是以其他方式通知运维人员,让运维监控人员有时间处理和解决隐患,避免影响业务系统的正常使用,将一切问题的根源扼杀在摇篮当中。
|
||||
|
||||
在硅谷互联网大公司中,监控和运维被称为 SRE,是公司正常运行中非常重要的一环。作为 billion 级别的 Facebook,内部自然也有着大大小小、各种各样的监控系统和运维工具,有的对标业务数据,有的对标服务器的健康状态,有的则是面向数据库和微服务的控制信息。
|
||||
|
||||
不过,万变不离其宗,运维工作最重要的就是维护系统的稳定性。除了熟悉运用各种提高运维效率的工具来辅助工作外,云资源费用管理、安全管理、监控等,都需要耗费不少精力和时间。运维监控不是一朝一夕得来的,而是随着业务发展的过程中同步和发展的。
|
||||
|
||||
作为量化实践内容的最后一节,今天我们就使用 Django 这个 Web 框架,来搭建一个简单的量化监控平台。
|
||||
|
||||
Django 简介和安装
|
||||
|
||||
Django 是用 Python 开发的一个免费开源的 Web 框架,可以用来快速搭建优雅的高性能网站。它采用的是“MVC”的框架模式,即模型 M、视图 V 和控制器 C。
|
||||
|
||||
Django 最大的特色,在于将网页和数据库中复杂的关系,转化为 Python 中对应的简单关系。它的设计目的,是使常见的Web开发任务变得快速而简单。Django是开源的,不是商业项目或者科研项目,并且集中力量解决Web开发中遇到的一系列问题。所以,Django 每天都会在现有的基础上进步,以适应不断更迭的开发需求。这样既节省了开发时间,也提高了后期维护的效率。
|
||||
|
||||
说了这么多,接下来,我们通过上手使用进一步来了解。先来看一下,如何安装和使用 Django。你可以先按照下面代码块的内容来操作,安装Django :
|
||||
|
||||
pip3 install Django
|
||||
django-admin --version
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
2.2.3
|
||||
|
||||
|
||||
接着,我们来创建一个新的 Django 项目:
|
||||
|
||||
django-admin startproject TradingMonitor
|
||||
cd TradingMonitor/
|
||||
python3 manage.py migrate
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
Applying contenttypes.0001_initial... OK
|
||||
Applying auth.0001_initial... OK
|
||||
Applying admin.0001_initial... OK
|
||||
Applying admin.0002_logentry_remove_auto_add... OK
|
||||
Applying admin.0003_logentry_add_action_flag_choices... OK
|
||||
Applying contenttypes.0002_remove_content_type_name... OK
|
||||
Applying auth.0002_alter_permission_name_max_length... OK
|
||||
Applying auth.0003_alter_user_email_max_length... OK
|
||||
Applying auth.0004_alter_user_username_opts... OK
|
||||
Applying auth.0005_alter_user_last_login_null... OK
|
||||
Applying auth.0006_require_contenttypes_0002... OK
|
||||
Applying auth.0007_alter_validators_add_error_messages... OK
|
||||
Applying auth.0008_alter_user_username_max_length... OK
|
||||
Applying auth.0009_alter_user_last_name_max_length... OK
|
||||
Applying auth.0010_alter_group_name_max_length... OK
|
||||
Applying auth.0011_update_proxy_permissions... OK
|
||||
Applying sessions.0001_initial... OK
|
||||
|
||||
|
||||
这时,你能看到文件系统大概是下面这样的:
|
||||
|
||||
TradingMonitor/
|
||||
├── TradingMonitor
|
||||
│ ├── __init__.py
|
||||
│ ├── settings.py
|
||||
│ ├── urls.py
|
||||
│ └── wsgi.py
|
||||
├── db.sqlite3
|
||||
└── manage.py
|
||||
|
||||
|
||||
我简单解释一下它的意思:
|
||||
|
||||
|
||||
TradingMonitor/TradingMonitor,表示项目最初的 Python 包;
|
||||
TradingMonitor/init.py,表示一个空文件,声明所在目录的包为一个 Python 包;
|
||||
TradingMonitor/settings.py,管理项目的配置信息;
|
||||
TradingMonitor/urls.py,声明请求 URL 的映射关系;
|
||||
TradingMonitor/wsgi.py,表示Python 程序和 Web 服务器的通信协议;
|
||||
manage.py,表示一个命令行工具,用来和 Django 项目进行交互;
|
||||
Db.sqlite3,表示默认的数据库,可以在设置中替换成其他数据库。
|
||||
|
||||
|
||||
另外,你可能注意到了上述命令中的python3 manage.py migrate,这个命令表示创建或更新数据库模式。每当 model 源代码被改变后,如果我们要将其应用到数据库上,就需要执行一次这个命令。
|
||||
|
||||
接下来,我们为这个系统添加管理员账户:
|
||||
|
||||
python3 manage.py createsuperuser
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
Username (leave blank to use 'ubuntu'): admin
|
||||
Email address:
|
||||
Password:
|
||||
Password (again):
|
||||
Superuser created successfully.
|
||||
|
||||
|
||||
然后,我们来启动 Django 的 debugging 模式:
|
||||
|
||||
python3 manage.py runserver
|
||||
|
||||
|
||||
最后,打开浏览器输入:http://127.0.0.1:8000。如果你能看到下面这个画面,就说明 Django 已经部署成功了。
|
||||
|
||||
|
||||
|
||||
Django 的安装是不是非常简单呢?这其实也是 Python 一贯的理念,简洁,并简化入门的门槛。
|
||||
|
||||
OK,现在我们再定位到 http://127.0.0.1:8000/admin,你会看到 Django 的后台管理网页,这里我就不过多介绍了。
|
||||
|
||||
-
|
||||
|
||||
|
||||
到此,Django 就已经成功安装,并且正常启动啦。
|
||||
|
||||
MVC 架构
|
||||
|
||||
刚刚我说过,MVC 架构是 Django 设计模式的精髓。接下来,我们就来具体看一下这个架构,并通过 Django 动手搭建一个服务端。
|
||||
|
||||
设计模型 Model
|
||||
|
||||
在之前的日志和存储系统这节课中,我介绍过 peewee 这个库,它能避开通过繁琐的 SQL 语句来操作 MySQL,直接使用 Python 的 class 来进行转换。事实上,这也是 Django 采取的方式。
|
||||
|
||||
Django 无需数据库就可以使用,它通过对象关系映射器(object-relational mapping),仅使用Python代码就可以描述数据结构。
|
||||
|
||||
我们先来看下面这段 Model 代码:
|
||||
|
||||
# TradingMonitor/models.py
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Position(models.Model):
|
||||
asset = models.CharField(max_length=10)
|
||||
timestamp = models.DateTimeField()
|
||||
amount = models.DecimalField(max_digits=10, decimal_places=3)
|
||||
|
||||
|
||||
models.py 文件主要用一个 Python 类来描述数据表,称为模型 。运用这个类,你可以通过简单的 Python 代码来创建、检索、更新、删除数据库中的记录,而不用写一条又一条的SQL语句,这也是我们之前所说的避免通过 SQL 操作数据库。
|
||||
|
||||
在这里,我们创建了一个 Position 模型,用来表示我们的交易仓位信息。其中,
|
||||
|
||||
|
||||
asset 表示当前持有资产的代码,例如 btc;
|
||||
timestamp 表示时间戳;
|
||||
amount 则表示时间戳时刻的持仓信息。
|
||||
|
||||
|
||||
设计视图 Views
|
||||
|
||||
在模型被定义之后,我们便可以在视图中引用模型了。通常,视图会根据参数检索数据,加载一个模板,并使用检索到的数据呈现模板。
|
||||
|
||||
设计视图,则是我们用来实现业务逻辑的地方。我们来看 render_positions 这个代码,它接受 request 和 asset 两个参数,我们先不用管 request。这里的 asset 表示指定一个资产名称,例如 btc,然后这个函数返回一个渲染页面。
|
||||
|
||||
# TradingMonitor/views.py
|
||||
|
||||
from django.shortcuts import render
|
||||
from .models import Position
|
||||
|
||||
def render_positions(request, asset):
|
||||
positions = Position.objects.filter(asset = asset)
|
||||
context = {'asset': asset, 'positions': positions}
|
||||
return render(request, 'positions.html', context)
|
||||
|
||||
|
||||
不过,这个函数具体是怎么工作的呢?我们一行行来看。
|
||||
|
||||
positions = Position.objects.filter(asset = asset),这行代码向数据库中执行一个查询操作,其中, filter 表示筛选,意思是从数据库中选出所有我们需要的 asset 的信息。不过,这里我只是为你举例做示范;真正做监控的时候,我们一般会更有针对性地从数据库中筛选读取信息,而不是一口气读取出所有的信息。
|
||||
|
||||
context = {'asset': asset, 'positions': positions},这行代码没什么好说的,封装一个字典。至于这个字典的用处,下面的内容中可以体现。
|
||||
|
||||
return render(request, 'positions.html', context),最后这行代码返回一个页面。这里我们采用的模板设计,这也是 Django 非常推荐的开发方式,也就是让模板和数据分离,这样,数据只需要向其中填充即可。
|
||||
|
||||
最后的模板文件是 position.html,你应该注意到了, context 作为变量传给了模板,下面我们就来看一下设计模板的内容。
|
||||
|
||||
设计模板Templates
|
||||
|
||||
模板文件,其实就是 HTML 文件和部分代码的综合。你可以想象成,这个HTML 在最终送给用户之前,需要被我们预先处理一下,而预先处理的方式就是找到对应的地方进行替换。
|
||||
|
||||
我们来看下面这段示例代码:
|
||||
|
||||
# TradingMonitor/templates/positions.html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Positions for {{asset}}</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Positions for {{asset}}</h1>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
{% for position in positions %}
|
||||
<tr>
|
||||
<th>{{position.timestamp}}</th>
|
||||
<th>{{position.amount}}</th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
|
||||
|
||||
我重点说一下几个地方。首先是<title>Positions for {{asset}}</title>,这里双大括号括住 asset 这个变量,这个变量对应的正是前面 context 字典中的 asset key。Django 的渲染引擎会将 asset ,替换成 context 中 asset 对应的内容,此处是替换成了 btc。
|
||||
|
||||
再来看{% for position in positions %},这是个很关键的地方。我们需要处理一个列表的情况,用 for 对 positions 进行迭代就行了。这里的 positions ,同样对应的是 context 中的 positions。
|
||||
|
||||
末尾的{% endfor %},自然就表示结束了。这样,我们就将数据封装到了一个列表之中。
|
||||
|
||||
设计链接 Urls
|
||||
|
||||
最后,我们需要为我们的操作提供 URL 接口,具体操作我放在了下面的代码中,内容比较简单,我就不详细展开讲解了。
|
||||
|
||||
# TradingMonitor/urls.py
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('positions/<str:asset>', views.render_positions),
|
||||
]
|
||||
|
||||
|
||||
到这里,我们就可以通过 http://127.0.0.1:8000/positions/btc 来访问啦!
|
||||
|
||||
测试
|
||||
|
||||
当然,除了主要流程外,我还需要强调几个很简单但非常关键的细节,不然,我们这些改变就不能被真正地应用。
|
||||
|
||||
第一步,在 TradingMonitor/TradingMonitor 下,新建一个文件夹 migrations;并在这个文件夹中,新建一个空文件 __init__.py。
|
||||
|
||||
mkdir TradingMonitor/migrations
|
||||
touch TradingMonitor/migrations/__init__.py
|
||||
|
||||
|
||||
此时,你的目录结构应该长成下面这样:
|
||||
|
||||
TradingMonitor/
|
||||
├── TradingMonitor
|
||||
│ ├── migrations
|
||||
│ └── __init__.py
|
||||
│ ├── templates
|
||||
│ └── positions.html
|
||||
│ ├── __init__.py
|
||||
│ ├── settings.py
|
||||
│ ├── urls.py
|
||||
│ ├── models.py
|
||||
│ ├── views.py
|
||||
│ └── wsgi.py
|
||||
├── db.sqlite3
|
||||
└── manage.py
|
||||
|
||||
|
||||
第二步,修改 TradingMonitor/settings.py:
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'TradingMonitor', # 这里把我们的 app 加上
|
||||
]
|
||||
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [os.path.join(BASE_DIR, 'TradingMonitor/templates')], # 这里把 templates 的目录加上
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
第三步,运行 python manage.py makemigrations:
|
||||
|
||||
python manage.py makemigrations
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
Migrations for 'TradingMonitor':
|
||||
TradingMonitor/migrations/0001_initial.py
|
||||
- Create model Position
|
||||
|
||||
|
||||
第四步,运行 python manage.py migrate:
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
|
||||
Operations to perform:
|
||||
Apply all migrations: TradingMonitor, admin, auth, contenttypes, sessions
|
||||
Running migrations:
|
||||
Applying TradingMonitor.0001_initial... OK
|
||||
|
||||
|
||||
这几步的具体操作,我都用代码和注释表示了出来,你完全可以同步进行操作。操作完成后,现在,我们的数据结构就已经被成功同步到数据库中了。
|
||||
|
||||
最后,输入 python manage.py runserver,然后打开浏览器输入http://127.0.0.1:8000/positions/btc,你就能看到效果啦。-
|
||||
|
||||
|
||||
现在,我们再回过头来看一下 MVC 模式,通过我画的这张图,你可以看到,M、V、C这三者,以一种插件似的、松耦合的方式连接在一起:
|
||||
|
||||
|
||||
|
||||
当然,我带你写的只是一个简单的 Django 应用程序,对于真正的量化平台监控系统而言,这还只是一个简单的开始。
|
||||
|
||||
除此之外,对于监控系统来说,其实还有着非常多的开源插件可以使用。有一些界面非常酷炫,有一些可以做到很高的稳定性和易用性,它们很多都可以结合 Django 做出很好的效果来。比较典型的有:
|
||||
|
||||
|
||||
Graphite 是一款存储时间序列数据,并通过 Django Web 应用程序在图形中显示的插件;
|
||||
Vimeo 则是一个基于 Graphite 的仪表板,具有附加功能和平滑的设计;
|
||||
Scout 监控 Django和Flask应用程序的性能,提供自动检测视图、SQL查询、模板等。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
这一节课的内容更靠近上游应用层,我们以 Django 这个 Python 后端为例,讲解了搭建一个服务端的过程。你应该发现了,使用 RESTful Framework 搭建服务器,是一个如此简单的过程,你可以去开一个自己的交易所了(笑)。相比起具体的技术,今天我所讲的 MVC 框架和 Django 的思想,更值得你去深入学习和领会。
|
||||
|
||||
思考题
|
||||
|
||||
今天我想给你留一个难度比较高的作业。RESTful API 在 Django 中是如何实现安全认证的?你能通过搜索和自学掌握这个知识点吗?希望可以在留言区看到你的认真学习记录和总结,我会一一给出建议。也欢迎你把这篇文章分享给你的朋友、同事,一起交流、一起进步。
|
||||
|
||||
|
||||
|
||||
|
99
专栏/Python核心技术与实战/40总结:Python中的数据结构与算法全景.md
Normal file
99
专栏/Python核心技术与实战/40总结:Python中的数据结构与算法全景.md
Normal file
@ -0,0 +1,99 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
40 总结:Python中的数据结构与算法全景
|
||||
你好,我是景霄。
|
||||
|
||||
不知不觉中,我们又一起完成了量化交易实战篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
|
||||
|
||||
实战篇的主要用意,是通过一个完整的技术领域,讲明白 Python 在这个领域中如何发挥作用。所以,我们在每节课都会梳理一个小知识点;同时,也在第 36 讲中,我用大量篇幅讲解了策略和回测系统,作为量化交易中最重要内容的解释。
|
||||
|
||||
对于本章答疑,因为不断有同学留言询问Python中数据结构和算法相关的问题,我在这里也简单说一下。
|
||||
|
||||
|
||||
|
||||
首先,希望你明白,我们Python 专栏的定位是有一定计算机知识基础的进阶课程,重点在 Python 的核心知识点上,默认你对基础的算法和数据结构有一定的了解。因此,在语法和技术知识点的讲解过程中,我会综合性地穿插不少数据结构的基本知识,但并不会进行深入地讲解。涉及到数据结构中的关键名词和难点,自然都会有所提及,但还是希望你有一定的自学能力来掌握。
|
||||
|
||||
不过,为了进一步方便你理解Python的数据结构和算法,加深对 Python 基础内容的掌握,我在这里总结了一个综合性的提纲。如果你在这方面有所欠缺,可以参考性地借鉴学习一下。当然,有时间和精力的话,我最鼓励的是你可以通过 Python 把所有数据结构和算法实现一下。
|
||||
|
||||
基础数据结构:数组,堆,栈,队列,链表
|
||||
|
||||
数组自不必多说,Python 中的基础数组,满足 O(1) 的随机查找,和 O(n) 的随机插入。
|
||||
|
||||
堆,严格来讲,是一种特殊的二叉树,满足 O(nlogn) 的随机插入和删除,以及 O(1) 时间复杂度拿到最大值或者最小值。堆可以用来实现优先队列,还可以在项目中实现多任务调度,有着非常广泛的应用。
|
||||
|
||||
栈,是一种先进后出的数据结构,入栈和出栈操作都是 O(1) 时间复杂度。
|
||||
|
||||
队列和栈对应,不过功能刚好相反,它是一种先进先出的数据结构,一如其名,先排队者先服务。入队和出队也是 O(1) 的时间复杂度。栈和队列都能用数组来实现,但是对空间的规划需要注意,特别是用数组实现的队列,我们通常用的是循环队列。
|
||||
|
||||
链表则是另一种线性表,和数组的不同是,它不支持随机访问,你不能通过下标来获取链表的元素。链表的元素通过指针相连,单链表中元素可以指向后者,双链表则是让相邻的元素互相连接。
|
||||
|
||||
这些基础数据结构,在 Python 中都有很好的库和包支持,从使用上来说都非常方便,但我仍然希望你对原理能有一定的了解,这样,处理起复杂问题也能得心应手不胆怯。
|
||||
|
||||
进阶数据结构:无向图,有向图,树,DAG 图,字典树,哈希表
|
||||
|
||||
无向图,是由顶点和边组成的数据结构,一条边连接两个顶点(如果两个顶点是一个,这条边称为自环)。一如其名,“无向”,所以它的边没有指向性。
|
||||
|
||||
有向图,和无向图一样都是“图”这种数据结构,不同的是有向图的边有指向性,方向为一个顶点指向另一个顶点。
|
||||
|
||||
树这种数据结构,则可以分为有根树和无根树。前者中,最常见的就是我们的二叉树,从顶点开始一级级向下,每个父结点最多有两个子结点。至于无根树,则是一种特殊的无向图,无环连通的无向图被称为无根树,它有很多特别的性质和优点,在离散数学中应用广泛。
|
||||
|
||||
DAG 图,也叫做有向无环图,是一种特殊应用的数据结构,在图的动态规划问题中出现甚多。遍历 DAG 图的方式,也就是我们常说的拓扑排序,是一种图算法。DAG 可以认为是链表的图版本,如果说区块链是链表,那么区块链 3.0 时代可能就是 DAG 图。
|
||||
|
||||
字典树,又被称为 Trie 树,是一种边为字符的有向图,它在字符串处理中有着非常强大的应用。广为人知的 AC 自动机,就是用 Trie 树来解决多模式字符串匹配问题。Trie 树在工业界也常被拿来做搜索提示,例如你在百度中搜索 “极客时”,就会自动跳出 “极客时间”。
|
||||
|
||||
哈希表,这一定是程序员应用最广、自觉最简单的一个数据结构,比如 Python 的 dict() 就可以拿来即用,简单而自然。不过,哈希表其实有着非常深刻的内涵,冲突算法、哈希算法、扩容算法,都很值得我们去深究一下。
|
||||
|
||||
算法:排序
|
||||
|
||||
从排序开始入门算法有一定的难度,因为这需要你理解时间复杂度的概念,开始接触到基本的二分思想以及严谨的数学证明过程。不过,不管难度如何,我想强调的是,在学习的过程中一定不要跳过这些必需的科学训练。如果你忽略基础,只会调用 list.sort(),未来遇到稍复杂的问题基本懵圈,需要花费更多的时间来重走基础路,得不偿失。
|
||||
|
||||
我们可以从基础的冒泡排序开始理解排序,这是一个很好理解正确性和代码的算法;然后是选择排序和插入排序,它们和冒泡排序一样,都是 O(n^2) 时间复杂度的算法。
|
||||
|
||||
从归并排序开始,算法复杂度骤降到 O(nlogn) 的理论下界,这里也开始涉及到算法中的一个经典思想——分治(Divide and Conquer)。然后就是快速排序、堆排序这些算法,他们和快速排序一样都是 O(nlogn) 级别。
|
||||
|
||||
除此之外,还有一些针对性的优化排序,比如计数排序、桶排序、基数排序等,在特定条件下可以做到 O(n) 的时间复杂度。
|
||||
|
||||
关于各种算法,我推荐你可以查看这个B站的视频:https://www.bilibili.com/video/av685670
|
||||
|
||||
算法:二分搜索
|
||||
|
||||
二分搜索也是一种思想,甚至在生活中都有很广泛的应用(笑),比如书本的翻页设计是一种二分,你不需要查找很多次,就能找到自己想要的那一页。再比如就是很有名的,就是女生通过图书馆的笑话了。
|
||||
|
||||
|
||||
图书馆自习的时候,一女生背着一堆书进阅览室,结果警报响了,大妈让女生看是哪本书把警报弄响了,女生把书倒出来,一本一本地测。大妈见状急了,把书分成两份,第一份过了一下,响了。又把这一份分成两份接着测,三回就找到了,大妈用鄙视的眼神看着女生,仿佛在说O(n)和O(log2n)都分不清。
|
||||
|
||||
|
||||
对于二分搜索算法,你千万不要只是套用 API 和简单的代码,一定要从本质上理解二分思想,做到活学活用。
|
||||
|
||||
算法:深度优先搜索(DFS)和广度优先搜索(BFS)
|
||||
|
||||
DFS 和 BFS是图论算法中的基础。你需要先把这两个基础知识点掌握下来,然后学习几个经典算法,比如最短路算法、并查集、记忆化深度优先搜索、拓扑排序、DAG 图上的 DP 等等。
|
||||
|
||||
这里要注意,我们的重点还是学习思想。对于业务逻辑而言,图算法的重要性可能并没有那么大,但是当你开始接触技术栈深层,接触大数据(Hadoop, Spark),接触神经网络和人工智能时,你会发现,图的基本思想早已渗透到了设计模式中,而 DFS 和 BFS 正是操作图的最基础的两把钥匙。
|
||||
|
||||
算法:贪心和动态规划
|
||||
|
||||
这两个算法依然是两种重要的思维。虽然在绝大部分程序员的工作中,这两个算法可能一年都不会被用到过几次,但同样的,这些都是向更高技术能力升级必备的基本功。你不需要掌握到能够参加 ACM 世界总决赛的级别,但是,我们哪怕是对基本的方法论能有所了解,都将受益匪浅。
|
||||
|
||||
|
||||
曾有参加过 ACM 竞赛的朋友和我讲过,说他学懂动态规划后,感觉整个人生观和方法论都有了变化。在那之后,他自己去思考一些现实生活中的决策时,就会明白哪些是短视的贪心,哪些才是长远考虑的动态规划(笑)。
|
||||
|
||||
|
||||
总结
|
||||
|
||||
作为Python语言专栏,我确实不可能给你把每一种数据结构和算法都详细讲解一遍,但是,还是那句话,基础的数据结构和算法,一定是每个程序员的基本功。
|
||||
|
||||
这里,我推荐你可以学习极客时间上王争老师的《数据结构与算法之美》专栏,以及覃超老师的《算法面试通关40讲》视频课程。这两位在 Google和 Facebook 工作过的老师,同样底子扎实、实战经验丰富,将会给你带来不同角度的更翔实的算法精讲。
|
||||
|
||||
在数据爆炸的互联网的今天,学习资料触手可及,时间就显得更加宝贵。我在这里列出这些纲要的目的,也是希望能够帮你节省时间,为你整理出适合入门学习、掌握的基础知识点,让你可以带着全局观更有针对性地去学习。
|
||||
|
||||
当然,一切可以取得成果的学习,都离不开我们自己付出的努力。也只有这样,掌握了数据结构和算法的你,才能在数学基础上对 Python 的理解更进一步。同时,在未来的项目设计中,这些思维亦会在无形之中,帮你设计出更高质量的系统和架构,可以说是终生受益的学习投资了。
|
||||
|
||||
希望你可以学会并且切实有所收获,如果在哪个地方有所困惑,也欢迎在留言区和我交流讨论,我们一起精进和提高!
|
||||
|
||||
|
||||
|
||||
|
77
专栏/Python核心技术与实战/41硅谷一线互联网公司的工作体验.md
Normal file
77
专栏/Python核心技术与实战/41硅谷一线互联网公司的工作体验.md
Normal file
@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
41 硅谷一线互联网公司的工作体验
|
||||
你好, 我是景霄。
|
||||
|
||||
前面四个版块,我们一起由浅入深地学习了Python这门语言,而最后一个版块,我想与你分享一些我的技术与工作见闻,谈谈我的领悟与理解。
|
||||
|
||||
首先,我想带你去了解一下,硅谷Top互联网公司的工作体验与文化,这里就以我工作的Facebook为例。
|
||||
|
||||
扁平化的管理制度
|
||||
|
||||
硅谷的一线互联网公司,都会实行扁平化的管理制度,当然FB也不例外。在这里,虽然也有上下级之分,比如 Software Engineer -> Engineering Manager -> Director -> VP,但是我们大家的思想中,并没有严格上下级这样的概念。
|
||||
|
||||
公司鼓励每个人积极发表自己的观点。比如,一个应届毕业生,因为一个问题和自己的老板,乃至老板的老板据理力争,这样的场景也是很常见的。
|
||||
|
||||
另外,公司每隔一段时间便会组织一次Q&A,我们大家可以向CEO、CTO等提问。比如你想了解某个产品的发展方向,公司目前的侧重点,甚至是一些敏感的问题,都可以提问。
|
||||
|
||||
同时,公司的领导,哪怕是上到CEO、CTO、COO这样的高层,都没有自己的单独办公室,都是和我们一起坐在开放的区域内办公,这样即拉近了距离,也是为了方便交流和讨论。
|
||||
|
||||
开放式的讨论平台
|
||||
|
||||
第二点是开放式的讨论平台。我一直觉得这个方式非常好,也很喜欢。FB用的是自己开发的workplace,相当于一个开放的社区,里面会有不同的群组,无论你有什么问题,都可以去相应的群组提问,那里会有各个领域的高手来帮你解答。
|
||||
|
||||
举个例子,如果你有Python相关的问题,便可以去Python的群组问;你如果有Spark的问题,就去Spark 群组问。
|
||||
|
||||
很多时候,各个组开发的产品,都会涉及很多的跨组合作,要用到其他组开发的一些API、算法、框架等等。这样,在使用的时候就难免会遇到一些问题,这个时候我们大家通常便会在对应的群组中提问。问题解决后也保存了下来,之后再有人遇到相同的问题时,便能直接搜索到对应的帖子及答案,大大提高了办公的效率。
|
||||
|
||||
除了上述Q&A形式的群组外,我们也会有很多其他形式的群组。比如,自己工作组内的群组,用于发布一些重要消息及技术交流;A/B测试的群组,用于大家讨论某个实验的结果等等。当然,还有很多非技术的群组,比如足球俱乐部、篮球俱乐部等用于休闲娱乐的平台。
|
||||
|
||||
在有了这么一个生态系统后,员工可以很方便地获取到自己想要的信息,也大大方便了公司内部员工的交流,可以算是一举多得的事情了。
|
||||
|
||||
数据驱动为中心
|
||||
|
||||
FB是一个典型的数据驱动型的公司,一切都以数据为依据,这样实际上极大地提高了工程师的地位。比如,在决定一个实验要不要最终发起时,我们都会首先关注各项指标,是不是能带来正向影响,是不是提高了用户的体验等等。
|
||||
|
||||
再比如,每次提出一个新项目时,我们都需要做大量的数据分析与调研,然后与组内的同事及上级领导 review 后再做决定。这样,每次绩效考核时,证明自己最好的依据,便是自己发起的实验对指标的提升等等。这样的一种策略,对于公司及个人的发展都更为有利。
|
||||
|
||||
举一个反例,之前的Snapchat,就是一个典型的不以数据驱动为中心的公司。他们产品的发布、改变,大多依赖一些产品经理和设计师的主观臆断,这样实际上是很偏颇的。后来的结局我们也都知道了,产品变得越来越不受用户喜欢,股价大跌,而我大部分在那里工作的同学,也都纷纷离职了。
|
||||
|
||||
Bootcamp
|
||||
|
||||
Bootcamp是FB中很著名的一个项目,所有入职FB的员工,在正式进入具体的工作组之前,都会参加4-10周的Bootcamp;而每个员工也会分配一个导师,帮助其了解FB的技术栈、文化以及吃喝玩乐等等。
|
||||
|
||||
Bootcamp的前两周,通常会安排不少的课程,帮助新员工了解FB的内部工具。之后就会进入选组阶段,组和员工之间进行双向选择,形式通常是“聊天+做组内的项目”,这样双方都能对彼此有更深入的了解。
|
||||
|
||||
在Bootcamp期间,特别是对于应届毕业生来说,你可以尝试各种不同的方向,这对于未来的职业发展是非常有裨益的。公司也鼓励Bootcamp的员工参加各种娱乐活动,增进交流,而且这期间的吃喝玩乐都可以报销。我身边的每个同事都会有这样的感受:Bootcamp真是在公司最舒服的日子了。
|
||||
|
||||
鼓励工程师更换工作方向
|
||||
|
||||
在FB,无论是内部换组,还是更换工作方向都是非常普遍的现象。很多工程师在一个组做的时间久了,就会想尝试一些新的方向,这在公司是非常鼓励的。
|
||||
|
||||
方法也很简单,一般来说让你去新组做几个任务,或者花一个月的时间做一个Hackamonth就可以了。这种形式是对双方的考量,新组会对工程师的能力有一个大概的了解;而工程师也会对新组的工作、技术有所掌握,并进一步判断自己是否感兴趣。
|
||||
|
||||
因此,在FB,你会看到很多全栈工程师,比如我就是其中一个,对移动端、服务器端以及机器学习都有所涉猎。显然,这样的制度,非常有利于工程师的全面发展。
|
||||
|
||||
福利政策
|
||||
|
||||
FB的福利,应该可以算是全球互联网公司中最好之一了。公司为了留住人才,提供了很多外人看来非比寻常的福利。
|
||||
|
||||
首先从工位说起,其装备都是业内顶级标准。电脑是可以自己随意选配的,比如你可以随意选配7000多美金的iMac Pro,显示器也可以随意选配价值1000多美金的4K屏幕。至于可升降桌子和椅子,都是Herman Miller 标配,桌椅总价在2000美金以上。
|
||||
|
||||
在技术交流方面,除了正常的学习培训外,公司还鼓励员工每年外出参加一次会议,比如机器学习方向的ICML、KDD等等,给予全程报销。
|
||||
|
||||
另外,公司包一日三餐,包括内部的零食、甜品等全部免费。我们拥有一年21天带薪休假,女性还拥有6个月的带薪产假,同时提供免费的健身房、游泳池等,每年还会提供 720美金的健身私教报销等等。
|
||||
|
||||
写在最后
|
||||
|
||||
以上就是我在FB工作的主要工作体验。其实,在硅谷工作,不仅仅有技术上的收获,比如你可以直接接触到业内顶级大牛,了解到最新最前沿的技术;还有很多认知和思维方式上的影响,比如对于流程、合作、开源等的思考。
|
||||
|
||||
接下来的几篇文章,我会继续讲述,关于技术研发我这些年的工作经验和总结,以及对于职业方向的认识和思考。欢迎你在留言区和我一起讨论交流这些问题,经验分享和交流,是每个技术人成长必不可少的环节。
|
||||
|
||||
|
||||
|
||||
|
84
专栏/Python核心技术与实战/42细数技术研发的注意事项.md
Normal file
84
专栏/Python核心技术与实战/42细数技术研发的注意事项.md
Normal file
@ -0,0 +1,84 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
42 细数技术研发的注意事项
|
||||
你好,我是景霄。
|
||||
|
||||
技术研发一直以来都是各大公司的核心部分之一,其质量的好坏直接影响到了产品的质量以及用户对产品的体验。如何建立一套规范、健全的开发体系,就显得尤为重要。今天我就和你聊聊技术研发的注意事项。
|
||||
|
||||
选择合适的编程语言
|
||||
|
||||
比如我们正在开发一个系统,首先,根据具体的需求,我们需要对系统的各个部分选择合适的编程语言。一般来说,infra这层我们更偏向于使用C++,而纯的服务器端则是以Python、Java、PHP等等为主。以搜索引擎为例,下面我画了一个它的简略架构图:
|
||||
|
||||
|
||||
|
||||
你可以看到,大概的工作流程是:用户在客户端(client)输入一个查询(query),发送请求(request)到达服务器端(server-side);服务器端首先向NLP service发请求,并对请求进行分析,等到拿到各项信号(signal)后,再向后端(backend)发送请求;后端会做特征抽取(feature extraction),利用ML 模型进行结果的检索(candidate retrieval)、排序,最后再把结果返回给服务器端和客户端。
|
||||
|
||||
这里的NLP Service和后端,我们都会使用C++。因为这部分的处理最为复杂和耗时,都涉及到了特征抽取和model serving,对延迟(latency)的要求极高,只有C/C++这类语言才能满足需求。
|
||||
|
||||
而服务器端或者叫中间层(middle tier),我们则会使用Python、Java、PHP等语言。因为这部分并没有特别复杂的处理和对延迟的高需求,主要是以一些业务逻辑为主;并且,对程序员来说,使用这种高级语言也更容易上手和调试。
|
||||
|
||||
合理使用缓存
|
||||
|
||||
缓存(cache)在实际工程中十分重要,可以想像,如果没了缓存,我们今天所用的绝大多数产品估计都会崩溃。缓存为我们节约了大量的CPU 容量(capacity)和延迟。
|
||||
|
||||
还是以刚刚的搜索引擎系统为例,我们在客户端、服务器端和后端都会设置缓存。在客户端,我们一般会缓存用户的搜索记录,比如当你点击搜索框时,自动弹出的建议关键词的前几个,就可以是缓存的结果。这不需要向服务器端发请求,可以直接从客户端下载。
|
||||
|
||||
而在服务器端,我们也会设置缓存来存储一些搜索结果。这样,如果同一个用户多次发送相同的请求,就不需要再向后端请求,直接从缓存里面拿结果就可以了,快速又高效。
|
||||
|
||||
同样的,后端也需要缓存。比如在model serving这块儿,我们通常会有几个小时的缓存,不然每次提供实时的在线服务时,对CPU的负荷很大,延迟也会很高。
|
||||
|
||||
总而言之,如果没了缓存,容易造成很多问题。
|
||||
|
||||
|
||||
服务器负荷迅速飙升,崩溃的几率大大增加。
|
||||
端对端的延迟迅速飙升,请求超时的概率大大增加。
|
||||
|
||||
|
||||
但是不是缓存越多就越好呢?显然也不是。
|
||||
|
||||
第一,通常来说,缓存比较昂贵,所以在使用上,我们都会有一个限度,不能无限制索取。
|
||||
|
||||
第二,缓存不是万能的,过度增加缓存,也会损害用户的产品体验。比如搜索结果的retrieval和排序这两块,理想状况下,肯定是做实时的model serving最好,因为这样对用户的个性化推荐更准确和实时。之所以会对model有几个小时的缓存,更多的是出于性能的考虑,但如果把缓存从几小时改为几天,显然不合适,无疑会对用户的产品体验造成极大的负面影响。
|
||||
|
||||
因此,缓存到底取多久、取多少,往往是用户对产品参与度和性能的一个权衡,需要根据一些具体的分析以及A/B测试做出决定。
|
||||
|
||||
健全的日志记录系统
|
||||
|
||||
健全的日志记录系统也是尤其关键的一点。大型公司的系统,往往由成千十万个小系统组合而来,如果发生故障,比如Google、Facebook的某项服务突然宕机了,我们就需要以最快的速度找出原因并做出修复。这靠的是什么呢?靠的正是健全的日志记录系统,使得我们能够方便地分解错误原因,一层一层追溯,直到找到根源。
|
||||
|
||||
一般来说,在线上环境中,我们需要两种类型的日志记录模式。
|
||||
|
||||
一种是实时logging,考虑到服务器的压力,通常会做降采样(downsampling),比如log实际流量的1%。这样的好处是,可以及时跟踪各项指标,如果有情况,立即触发警报(alert)。
|
||||
|
||||
比如,某天的中午12点,一位工程师push了一段会造成服务器奔溃的代码进入产品,实时logging检测到异常,发出警报,这时有关人员便会进行排查。如果发现这个代码的push时间和警报触发时间一致,就能够最快地恢复(revert),最小化其带来的负面影响。
|
||||
|
||||
同时,实时logging也有利于我们进行各种线上实验。比如,ML组的A/B测试常常需要调参,我们的通常做法,就是每隔几小时查看实时 logging的table,根据各项指标,适度调整参数。
|
||||
|
||||
第二种是每天更新一次也就是daily的 full logging,有助于我们统计一些信息,进行分析,比如做成仪表板(dashboard),方便查看每天的各项指标,来跟踪进度。此外,full logging的table,也常常用于ML组的训练数据(training data)。
|
||||
|
||||
Profiling必不可少
|
||||
|
||||
关于profile,之前我们也提到过,在实际开发中是非常重要的一项功能,能够帮助开发人员详细了解系统每个部分的效率,并加以提高。
|
||||
|
||||
在线上环境中,我们通常会在许多必要的地方加上profile的代码,这样我们就能够知道这段代码的延迟是多少,哪个部分的延迟特别严重等等,然后对症下药。
|
||||
|
||||
如果没有profile,很容易导致开发人员随意增加功能而不进行优化,这样以来,随着时间的推移,系统越来越冗余,延迟也会越来越高。因此,一个成熟的系统,一定会有profile的代码,帮助开发人员随时监控内部的各项指标变化。
|
||||
|
||||
test、test、test
|
||||
|
||||
这一点,我也已经在前面的文章中强调过了,测试(test)一定不能少。无论是单元测试(unit test)、集成测试(integration test)还是其他,都是保证代码质量、减小bug发生概率的一个有效手段。
|
||||
|
||||
在真正规范的公司或是小组里,开发人员如果新增或改变了一个功能而不写测试,是过不了代码评审的。因此,测试一定要写,尤其是系统复杂了以后,很多工程师都要在上面开发各种不同的新功能,很难保证各个部分不受影响,测试便是一种很好的解决方法。
|
||||
|
||||
除了日常开发中所写的测试外,在代码push到线上之前,最好还要加一层测试。还是以刚刚的搜索引擎系统为例,我所知道的,Google或者Facebook的代码在push的过程中,都会有专门的service,去模拟不同的用户发送请求,然后看返回的响应是不是符合要求。如果出错,就会阻止代码的push,这也就告诉了开发人员,他们所写的代码可能存在问题,需要再次检查。
|
||||
|
||||
写在最后
|
||||
|
||||
关于技术研发的注意事项,我主要强调这些内容。事实上,日常开发工作中,很多的细节都值得特别关注,而对于易错的地方,用系统化的流程解决不失为一个高效的方案。那么,在你的日常工作中,有哪些特别留心的地方值得分享,或者有哪些疑惑的地方想要交流吗?欢迎在留言区写下你的想法。
|
||||
|
||||
|
||||
|
||||
|
0
专栏/Python核心技术与实战/43Q&A:聊一聊职业发展和选择.md
Normal file
0
专栏/Python核心技术与实战/43Q&A:聊一聊职业发展和选择.md
Normal file
941
专栏/Python核心技术与实战/加餐带你上手SWIG:一份清晰好用的SWIG编程实践指南.md
Normal file
941
专栏/Python核心技术与实战/加餐带你上手SWIG:一份清晰好用的SWIG编程实践指南.md
Normal file
@ -0,0 +1,941 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐 带你上手SWIG:一份清晰好用的SWIG编程实践指南
|
||||
你好,我是卢誉声,Autodesk 数据平台和计算平台资深软件工程师,也是《移动平台深度神经网络实战》和《分布式实时处理系统:原理架构与实现》的作者,主要从事C/C++、JavaScript开发工作和平台架构方面的研发工作,对SWIG也有比较深的研究。很高兴受极客时间邀请来做本次分享,今天,我们就来聊一聊SWIG这个话题。
|
||||
|
||||
我们都知道,Python 是一门易于上手并实验友好的胶水语言。现在有很多机器学习开发或研究人员,都选择Python作为主力编程语言;流行的机器学习框架也都会提供Python语言的支持作为调用接口和工具。因此,相较于学习成本更高的C++来说,把Python作为进入机器学习世界的首选编程语言,就再合适不过了。
|
||||
|
||||
不过,像TensorFlow或PyTorch这样的机器学习框架的核心,是使用Python编写的吗?
|
||||
|
||||
显然不是。这里面的原因比较多,但最为显著的一个原因就是“性能”。通过C++编写的机器学习框架内核,加上编译器的优化能力,为系统提供了接近于机器码执行的效率。这种得天独厚的优势,让C++在机器学习的核心领域站稳了脚跟。我们前面所说的TensorFlow和PyTorch的核心,便都是使用C/C++开发的。其中,TensorFlow的内核,就是由高度优化的C++代码和CUDA编写而成。
|
||||
|
||||
因此,我们可以理解为,TensorFlow通过Python来描述模型,而实际的运算则是由高性能C++代码执行的。而且,在绝大多数情况下,不同操作之间传递的数据,并不会拷贝回Python代码的执行空间。机器学习框架,正是通过这样的方式确保了计算性能,同时兼顾了对框架易用性方面的考虑。
|
||||
|
||||
因此,当Python和C++结合使用的时候,Python本身的性能瓶颈就不那么重要了。它足够胜任我们给它的任务就可以了,至于对计算有更高要求的任务,就交给C++来做吧!
|
||||
|
||||
今天,我们就来讨论下,如何通过SWIG对C++程序进行Python封装。我会先带你编写一段Python脚本,来执行一个简单的机器学习任务;接着,尝试将计算密集的部分改写成C++程序,再通过SWIG对其进行封装。最后的结果就是,Python把计算密集的任务委托给C++执行。
|
||||
|
||||
我们会对性能做一个简单比较,并在这个过程中,讲解使用SWIG的方法。同时,在今天这节课的最后,我会为你提供一个学习路径,作为日后提高的参考。
|
||||
|
||||
明确了今天的学习目的,也就是使用SWIG来实现Python对C++代码的调用,那么,我们今天的内容,其实可以看成一份关于SWIG的编程实践指南。学习这份指南之前,我们先来简单了解一下SWIG。
|
||||
|
||||
SWIG 是什么?
|
||||
|
||||
SWIG,是一款能够连接C/C++与多种高级编程语言(我们在这里特别强调Python)的软件开发工具。SWIG支持多种不同类型的目标语言,这其中,支持的常见脚本语言包括JavaScript、Perl、PHP、Tcl、Ruby和Python等,支持的高级编程语言则包括C#、D、Go语言、Java(包括对Android的支持)、Lua、OCaml、Octave、Scilab和R。
|
||||
|
||||
我们通常使用SWIG来创建高级解释或编译型的编程环境和接口,它也常被用来当作C/C++编写原型的测试工具。一个典型的应用场景,便是解析和创建C/C++接口,生成胶水代码供像Python这样的高级编程语言调用。近期发布的4.0.0版本,更是带来了对C++的显著改进和支持,这其中包括(不局限于)下面几点。
|
||||
|
||||
|
||||
针对C#、Java和Ruby而改进的STL包装器。
|
||||
针对Java、Python和Ruby,增加C++11标准下的STL容器的支持。
|
||||
改进了对C++11和C++14代码的支持。
|
||||
修正了C++中对智能指针shared_ptr的一系列bug修复。
|
||||
一系列针对C预处理器的极端case修复。
|
||||
一系列针对成员函数指针问题的修复。
|
||||
低支持的Python版本为2.7、3.2-3.7。
|
||||
|
||||
|
||||
使用Python实现PCA算法
|
||||
|
||||
借助于SWIG,我们可以简单地实现用Python调用C/C++库,甚至可以用Python继承和使用C++类。接下来,我们先来看一个你十分熟悉的使用Python编写的PCA(Principal Component Analysis,主成分分析)算法。
|
||||
|
||||
因为我们今天的目标不是讲解PCA算法,所以如果你对这个算法还不是很熟悉,也没有关系,我会直接给出具体的代码,我们把焦点放在如何使用SWIG上就可以了。下面,我先给出代码清单1。
|
||||
|
||||
代码清单1,基于Python编写的PCA算法 testPCAPurePython.py :
|
||||
|
||||
import numpy as np
|
||||
|
||||
def compute_pca(data):
|
||||
m = np.mean(data, axis=0)
|
||||
datac = np.array([obs - m for obs in data])
|
||||
T = np.dot(datac, datac.T)
|
||||
[u,s,v] = np.linalg.svd(T)
|
||||
|
||||
pcs = [np.dot(datac.T, item) for item in u.T ]
|
||||
|
||||
pcs = np.array([d / np.linalg.norm(d) for d in pcs])
|
||||
|
||||
return pcs, m, s, T, u
|
||||
|
||||
def compute_projections(I,pcs,m):
|
||||
projections = []
|
||||
for i in I:
|
||||
w = []
|
||||
for p in pcs:
|
||||
w.append(np.dot(i - m, p))
|
||||
projections.append(w)
|
||||
return projections
|
||||
|
||||
def reconstruct(w, X, m,dim = 5):
|
||||
return np.dot(w[:dim],X[:dim,:]) + m
|
||||
|
||||
def normalize(samples, maxs = None):
|
||||
if not maxs:
|
||||
maxs = np.max(samples)
|
||||
return np.array([np.ravel(s) / maxs for s in samples])
|
||||
|
||||
|
||||
现在,我们保存这段编写好的代码,并通过下面的命令来执行:
|
||||
|
||||
python3 testPCAPurePython.py
|
||||
|
||||
|
||||
准备SWIG
|
||||
|
||||
这样,我们已经获得了一些进展——使用Python编写了一个PCA算法,并得到了一些结果。接下来,我们看一下如何开始SWIG的开发工作。我会先从编译相关组件开始,再介绍一个简单使用的例子,为后续内容做准备。
|
||||
|
||||
首先,我们从SWIG的网站(http://swig.org/download.html)下载源代码包,并开始构建:
|
||||
|
||||
$ wget https://newcontinuum.dl.sourceforge.net/project/swig/swig/swig-4.0.0/swig-4.0.0.tar.gz # 下载路径可能会有所变化
|
||||
$ tar -xvf swig-4.0.0.tar.gz
|
||||
$ cd swig-4.0.0
|
||||
$ wget https://ftp.pcre.org/pub/pcre/pcre-8.43.tar.gz # SWIG需要依赖pcre工作
|
||||
$ sh ./Tools/pcre-build.sh # 该脚本会将pcre自动构建成SWIG使用的静态库
|
||||
$ ./configure # 注意需要安装bison,如果没有安装需要读者手动安装
|
||||
$ make
|
||||
$ sudo make install
|
||||
|
||||
|
||||
一切就绪后,我们就来编写一个简单的例子吧。这个例子同样来源于SWIG网站(http://swig.org/tutorial.html)。我们先来创建一个简单的c文件,你可以通过你习惯使用的文本编辑器(比如vi),创建一个名为example.c的文件,并编写代码。代码内容我放在了代码清单2中。
|
||||
|
||||
代码清单2,example.c:
|
||||
|
||||
#include <time.h>
|
||||
double My_variable = 3.0;
|
||||
|
||||
int fact(int n) {
|
||||
if (n <= 1) return 1;
|
||||
else return n*fact(n-1);
|
||||
}
|
||||
|
||||
int my_mod(int x, int y) {
|
||||
return (x%y);
|
||||
}
|
||||
|
||||
char *get_time()
|
||||
{
|
||||
time_t ltime;
|
||||
time(<ime);
|
||||
return ctime(<ime);
|
||||
}
|
||||
|
||||
|
||||
接下来,我们编写一个名为example.i的接口定义文件,和稍后用作测试的Python脚本,内容如代码清单3和代码清单4所示。
|
||||
|
||||
代码清单3,example.i:
|
||||
|
||||
%module example
|
||||
%{
|
||||
/* Put header files here or function declarations like below */
|
||||
extern double My_variable;
|
||||
extern int fact(int n);
|
||||
extern int my_mod(int x, int y);
|
||||
extern char *get_time();
|
||||
%}
|
||||
|
||||
extern double My_variable;
|
||||
extern int fact(int n);
|
||||
extern int my_mod(int x, int y);
|
||||
extern char *get_time();
|
||||
|
||||
|
||||
我来解释下清单3这段代码。第1行,我们定义了模块的名称为example。第2-8行,我们直接指定了example.c中的函数定义,也可以定义一个example.h头文件,并将这些定义加入其中;然后,在 %{ … %}结构体中包含example.h,来实现相同的功能。第10-13行,则是定义了导出的接口,以便你在Python中直接调用这些接口。
|
||||
|
||||
代码清单4,testExample.py:
|
||||
|
||||
import example
|
||||
print(example.fact(5))
|
||||
print(example.my_mod(7,3))
|
||||
print(example.get_time())
|
||||
|
||||
|
||||
好了, 到现在为止,我们已经准备就绪了。现在,我们来执行下面的代码,创建目标文件和最后链接的文件吧:
|
||||
|
||||
swig -python example.i
|
||||
gcc -c -fPIC example.c example_wrap.c -I/usr/include/python3.6
|
||||
gcc -shared example.o example_wrap.o -o _example.so
|
||||
python3 testExample.py # 测试调用
|
||||
|
||||
|
||||
其实,从代码清单4中你也能够看到,通过导入example,我们可以直接在Python脚本中,调用使用C实现的函数接口,并获得返回值。
|
||||
|
||||
通过SWIG封装基于C++编写的Python模块
|
||||
|
||||
到这一步,我们已经准备好了一份使用C++编写的PCA算法,接下来,我们就要对其进行一个简单的封装。由于C++缺少线性代数的官方支持,因此,为了简化线性代数运算,我这里用了一个第三方库Armadillo。在Ubuntu下,它可以使用apt-get install libarmadillo-dev安装支持。
|
||||
|
||||
另外,还是要再三说明一下,我们今天这节课的重点并不是讲解PCA算法本身,所以希望你不要困于此处,而错过了真正的使用方法。当然,为了完整性考虑,我还是会对代码做出最基本的解释。
|
||||
|
||||
封装正式开始。我们先来编写一个名为pca.h的头文件定义,内容我放在了代码清单5中。
|
||||
|
||||
代码清单5,pca.h:
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <armadillo>
|
||||
|
||||
class pca {
|
||||
public:
|
||||
pca();
|
||||
explicit pca(long num_vars);
|
||||
virtual ~pca();
|
||||
|
||||
bool operator==(const pca& other);
|
||||
|
||||
void set_num_variables(long num_vars);
|
||||
long get_num_variables() const;
|
||||
void add_record(const std::vector<double>& record);
|
||||
std::vector<double> get_record(long record_index) const;
|
||||
long get_num_records() const;
|
||||
void set_do_normalize(bool do_normalize);
|
||||
bool get_do_normalize() const;
|
||||
void set_solver(const std::string& solver);
|
||||
std::string get_solver() const;
|
||||
|
||||
void solve();
|
||||
|
||||
double check_eigenvectors_orthogonal() const;
|
||||
double check_projection_accurate() const;
|
||||
|
||||
void save(const std::string& basename) const;
|
||||
void load(const std::string& basename);
|
||||
|
||||
void set_num_retained(long num_retained);
|
||||
long get_num_retained() const;
|
||||
std::vector<double> to_principal_space(const std::vector<double>& record) const;
|
||||
std::vector<double> to_variable_space(const std::vector<double>& data) const;
|
||||
double get_energy() const;
|
||||
double get_eigenvalue(long eigen_index) const;
|
||||
std::vector<double> get_eigenvalues() const;
|
||||
std::vector<double> get_eigenvector(long eigen_index) const;
|
||||
std::vector<double> get_principal(long eigen_index) const;
|
||||
std::vector<double> get_mean_values() const;
|
||||
std::vector<double> get_sigma_values() const;
|
||||
|
||||
protected:
|
||||
long num_vars_;
|
||||
long num_records_;
|
||||
long record_buffer_;
|
||||
std::string solver_;
|
||||
bool do_normalize_;
|
||||
long num_retained_;
|
||||
arma::Mat<double> data_;
|
||||
arma::Col<double> energy_;
|
||||
arma::Col<double> eigval_;
|
||||
arma::Mat<double> eigvec_;
|
||||
arma::Mat<double> proj_eigvec_;
|
||||
arma::Mat<double> princomp_;
|
||||
arma::Col<double> mean_;
|
||||
arma::Col<double> sigma_;
|
||||
void initialize_();
|
||||
void assert_num_vars_();
|
||||
void resize_data_if_needed_();
|
||||
};
|
||||
|
||||
|
||||
接着,我们再来编写具体实现pca.cpp,也就是代码清单6的内容。
|
||||
|
||||
代码清单6,pca.cpp:
|
||||
|
||||
#include "pca.h"
|
||||
#include "utils.h"
|
||||
#include <stdexcept>
|
||||
#include <random>
|
||||
|
||||
pca::pca()
|
||||
: num_vars_(0),
|
||||
num_records_(0),
|
||||
record_buffer_(1000),
|
||||
solver_("dc"),
|
||||
do_normalize_(false),
|
||||
num_retained_(1),
|
||||
energy_(1)
|
||||
{}
|
||||
|
||||
pca::pca(long num_vars)
|
||||
: num_vars_(num_vars),
|
||||
num_records_(0),
|
||||
record_buffer_(1000),
|
||||
solver_("dc"),
|
||||
do_normalize_(false),
|
||||
num_retained_(num_vars_),
|
||||
data_(record_buffer_, num_vars_),
|
||||
energy_(1),
|
||||
eigval_(num_vars_),
|
||||
eigvec_(num_vars_, num_vars_),
|
||||
proj_eigvec_(num_vars_, num_vars_),
|
||||
princomp_(record_buffer_, num_vars_),
|
||||
mean_(num_vars_),
|
||||
sigma_(num_vars_)
|
||||
{
|
||||
assert_num_vars_();
|
||||
initialize_();
|
||||
}
|
||||
|
||||
pca::~pca()
|
||||
{}
|
||||
|
||||
bool pca::operator==(const pca& other) {
|
||||
const double eps = 1e-5;
|
||||
if (num_vars_ == other.num_vars_ &&
|
||||
num_records_ == other.num_records_ &&
|
||||
record_buffer_ == other.record_buffer_ &&
|
||||
solver_ == other.solver_ &&
|
||||
do_normalize_ == other.do_normalize_ &&
|
||||
num_retained_ == other.num_retained_ &&
|
||||
utils::is_approx_equal_container(eigval_, other.eigval_, eps) &&
|
||||
utils::is_approx_equal_container(eigvec_, other.eigvec_, eps) &&
|
||||
utils::is_approx_equal_container(princomp_, other.princomp_, eps) &&
|
||||
utils::is_approx_equal_container(energy_, other.energy_, eps) &&
|
||||
utils::is_approx_equal_container(mean_, other.mean_, eps) &&
|
||||
utils::is_approx_equal_container(sigma_, other.sigma_, eps) &&
|
||||
utils::is_approx_equal_container(proj_eigvec_, other.proj_eigvec_, eps))
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
void pca::resize_data_if_needed_() {
|
||||
if (num_records_ == record_buffer_) {
|
||||
record_buffer_ += record_buffer_;
|
||||
data_.resize(record_buffer_, num_vars_);
|
||||
}
|
||||
}
|
||||
|
||||
void pca::assert_num_vars_() {
|
||||
if (num_vars_ < 2)
|
||||
throw std::invalid_argument("Number of variables smaller than two.");
|
||||
}
|
||||
|
||||
void pca::initialize_() {
|
||||
data_.zeros();
|
||||
eigval_.zeros();
|
||||
eigvec_.zeros();
|
||||
princomp_.zeros();
|
||||
mean_.zeros();
|
||||
sigma_.zeros();
|
||||
energy_.zeros();
|
||||
}
|
||||
|
||||
void pca::set_num_variables(long num_vars) {
|
||||
num_vars_ = num_vars;
|
||||
assert_num_vars_();
|
||||
num_retained_ = num_vars_;
|
||||
data_.resize(record_buffer_, num_vars_);
|
||||
eigval_.resize(num_vars_);
|
||||
eigvec_.resize(num_vars_, num_vars_);
|
||||
mean_.resize(num_vars_);
|
||||
sigma_.resize(num_vars_);
|
||||
initialize_();
|
||||
}
|
||||
|
||||
void pca::add_record(const std::vector<double>& record) {
|
||||
assert_num_vars_();
|
||||
|
||||
if (num_vars_ != long(record.size()))
|
||||
throw std::domain_error(utils::join("Record has the wrong size: ", record.size()));
|
||||
|
||||
resize_data_if_needed_();
|
||||
arma::Row<double> row(&record.front(), record.size());
|
||||
data_.row(num_records_) = std::move(row);
|
||||
++num_records_;
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_record(long record_index) const {
|
||||
return std::move(utils::extract_row_vector(data_, record_index));
|
||||
}
|
||||
|
||||
void pca::set_do_normalize(bool do_normalize) {
|
||||
do_normalize_ = do_normalize;
|
||||
}
|
||||
|
||||
void pca::set_solver(const std::string& solver) {
|
||||
if (solver!="standard" && solver!="dc")
|
||||
throw std::invalid_argument(utils::join("No such solver available: ", solver));
|
||||
solver_ = solver;
|
||||
}
|
||||
|
||||
void pca::solve() {
|
||||
assert_num_vars_();
|
||||
|
||||
if (num_records_ < 2)
|
||||
throw std::logic_error("Number of records smaller than two.");
|
||||
|
||||
data_.resize(num_records_, num_vars_);
|
||||
|
||||
mean_ = utils::compute_column_means(data_);
|
||||
utils::remove_column_means(data_, mean_);
|
||||
|
||||
sigma_ = utils::compute_column_rms(data_);
|
||||
if (do_normalize_) utils::normalize_by_column(data_, sigma_);
|
||||
|
||||
arma::Col<double> eigval(num_vars_);
|
||||
arma::Mat<double> eigvec(num_vars_, num_vars_);
|
||||
|
||||
arma::Mat<double> cov_mat = utils::make_covariance_matrix(data_);
|
||||
arma::eig_sym(eigval, eigvec, cov_mat, solver_.c_str());
|
||||
arma::uvec indices = arma::sort_index(eigval, 1);
|
||||
|
||||
for (long i=0; i<num_vars_; ++i) {
|
||||
eigval_(i) = eigval(indices(i));
|
||||
eigvec_.col(i) = eigvec.col(indices(i));
|
||||
}
|
||||
|
||||
utils::enforce_positive_sign_by_column(eigvec_);
|
||||
proj_eigvec_ = eigvec_;
|
||||
|
||||
princomp_ = data_ * eigvec_;
|
||||
|
||||
energy_(0) = arma::sum(eigval_);
|
||||
eigval_ *= 1./energy_(0);
|
||||
}
|
||||
|
||||
void pca::set_num_retained(long num_retained) {
|
||||
if (num_retained<=0 || num_retained>num_vars_)
|
||||
throw std::range_error(utils::join("Value out of range: ", num_retained));
|
||||
|
||||
num_retained_ = num_retained;
|
||||
proj_eigvec_ = eigvec_.submat(0, 0, eigvec_.n_rows-1, num_retained_-1);
|
||||
}
|
||||
|
||||
std::vector<double> pca::to_principal_space(const std::vector<double>& data) const {
|
||||
arma::Col<double> column(&data.front(), data.size());
|
||||
column -= mean_;
|
||||
if (do_normalize_) column /= sigma_;
|
||||
const arma::Row<double> row(column.t() * proj_eigvec_);
|
||||
return std::move(utils::extract_row_vector(row, 0));
|
||||
}
|
||||
|
||||
std::vector<double> pca::to_variable_space(const std::vector<double>& data) const {
|
||||
const arma::Row<double> row(&data.front(), data.size());
|
||||
arma::Col<double> column(arma::trans(row * proj_eigvec_.t()));
|
||||
if (do_normalize_) column %= sigma_;
|
||||
column += mean_;
|
||||
return std::move(utils::extract_column_vector(column, 0));
|
||||
}
|
||||
|
||||
double pca::get_energy() const {
|
||||
return energy_(0);
|
||||
}
|
||||
|
||||
double pca::get_eigenvalue(long eigen_index) const {
|
||||
if (eigen_index >= num_vars_)
|
||||
throw std::range_error(utils::join("Index out of range: ", eigen_index));
|
||||
return eigval_(eigen_index);
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_eigenvalues() const {
|
||||
return std::move(utils::extract_column_vector(eigval_, 0));
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_eigenvector(long eigen_index) const {
|
||||
return std::move(utils::extract_column_vector(eigvec_, eigen_index));
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_principal(long eigen_index) const {
|
||||
return std::move(utils::extract_column_vector(princomp_, eigen_index));
|
||||
}
|
||||
|
||||
double pca::check_eigenvectors_orthogonal() const {
|
||||
return std::abs(arma::det(eigvec_));
|
||||
}
|
||||
|
||||
double pca::check_projection_accurate() const {
|
||||
if (data_.n_cols!=eigvec_.n_cols || data_.n_rows!=princomp_.n_rows)
|
||||
throw std::runtime_error("No proper data matrix present that the projection could be compared with.");
|
||||
const arma::Mat<double> diff = (princomp_ * arma::trans(eigvec_)) - data_;
|
||||
return 1 - arma::sum(arma::sum( arma::abs(diff) )) / diff.n_elem;
|
||||
}
|
||||
|
||||
bool pca::get_do_normalize() const {
|
||||
return do_normalize_;
|
||||
}
|
||||
|
||||
std::string pca::get_solver() const {
|
||||
return solver_;
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_mean_values() const {
|
||||
return std::move(utils::extract_column_vector(mean_, 0));
|
||||
}
|
||||
|
||||
std::vector<double> pca::get_sigma_values() const {
|
||||
return std::move(utils::extract_column_vector(sigma_, 0));
|
||||
}
|
||||
|
||||
long pca::get_num_variables() const {
|
||||
return num_vars_;
|
||||
}
|
||||
|
||||
long pca::get_num_records() const {
|
||||
return num_records_;
|
||||
}
|
||||
|
||||
long pca::get_num_retained() const {
|
||||
return num_retained_;
|
||||
}
|
||||
|
||||
void pca::save(const std::string& basename) const {
|
||||
const std::string filename = basename + ".pca";
|
||||
std::ofstream file(filename.c_str());
|
||||
utils::assert_file_good(file.good(), filename);
|
||||
utils::write_property(file, "num_variables", num_vars_);
|
||||
utils::write_property(file, "num_records", num_records_);
|
||||
utils::write_property(file, "solver", solver_);
|
||||
utils::write_property(file, "num_retained", num_retained_);
|
||||
utils::write_property(file, "do_normalize", do_normalize_);
|
||||
file.close();
|
||||
|
||||
utils::write_matrix_object(basename + ".eigval", eigval_);
|
||||
utils::write_matrix_object(basename + ".eigvec", eigvec_);
|
||||
utils::write_matrix_object(basename + ".princomp", princomp_);
|
||||
utils::write_matrix_object(basename + ".energy", energy_);
|
||||
utils::write_matrix_object(basename + ".mean", mean_);
|
||||
utils::write_matrix_object(basename + ".sigma", sigma_);
|
||||
}
|
||||
|
||||
void pca::load(const std::string& basename) {
|
||||
const std::string filename = basename + ".pca";
|
||||
std::ifstream file(filename.c_str());
|
||||
utils::assert_file_good(file.good(), filename);
|
||||
utils::read_property(file, "num_variables", num_vars_);
|
||||
utils::read_property(file, "num_records", num_records_);
|
||||
utils::read_property(file, "solver", solver_);
|
||||
utils::read_property(file, "num_retained", num_retained_);
|
||||
utils::read_property(file, "do_normalize", do_normalize_);
|
||||
file.close();
|
||||
|
||||
utils::read_matrix_object(basename + ".eigval", eigval_);
|
||||
utils::read_matrix_object(basename + ".eigvec", eigvec_);
|
||||
utils::read_matrix_object(basename + ".princomp", princomp_);
|
||||
utils::read_matrix_object(basename + ".energy", energy_);
|
||||
utils::read_matrix_object(basename + ".mean", mean_);
|
||||
utils::read_matrix_object(basename + ".sigma", sigma_);
|
||||
|
||||
set_num_retained(num_retained_);
|
||||
}
|
||||
|
||||
|
||||
这里要注意了,代码清单6中用到了utils.h这个文件,它是对部分矩阵和数学计算的封装,内容我放在了代码清单7中。
|
||||
|
||||
代码清单7,utils.h:
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <armadillo>
|
||||
#include <sstream>
|
||||
|
||||
namespace utils {
|
||||
arma::Mat<double> make_covariance_matrix(const arma::Mat<double>& data);
|
||||
arma::Mat<double> make_shuffled_matrix(const arma::Mat<double>& data);
|
||||
arma::Col<double> compute_column_means(const arma::Mat<double>& data);
|
||||
void remove_column_means(arma::Mat<double>& data, const arma::Col<double>& means);
|
||||
arma::Col<double> compute_column_rms(const arma::Mat<double>& data);
|
||||
void normalize_by_column(arma::Mat<double>& data, const arma::Col<double>& rms);
|
||||
void enforce_positive_sign_by_column(arma::Mat<double>& data);
|
||||
std::vector<double> extract_column_vector(const arma::Mat<double>& data, long index);
|
||||
std::vector<double> extract_row_vector(const arma::Mat<double>& data, long index);
|
||||
void assert_file_good(const bool& is_file_good, const std::string& filename);
|
||||
template<typename T>
|
||||
void write_matrix_object(const std::string& filename, const T& matrix) {
|
||||
assert_file_good(matrix.quiet_save(filename, arma::arma_ascii), filename);
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void read_matrix_object(const std::string& filename, T& matrix) {
|
||||
assert_file_good(matrix.quiet_load(filename), filename);
|
||||
}
|
||||
template<typename T, typename U, typename V>
|
||||
bool is_approx_equal(const T& value1, const U& value2, const V& eps) {
|
||||
return std::abs(value1-value2)<eps ? true : false;
|
||||
}
|
||||
template<typename T, typename U, typename V>
|
||||
bool is_approx_equal_container(const T& container1, const U& container2, const V& eps) {
|
||||
if (container1.size()==container2.size()) {
|
||||
bool equal = true;
|
||||
for (size_t i=0; i<container1.size(); ++i) {
|
||||
equal = is_approx_equal(container1[i], container2[i], eps);
|
||||
if (!equal) break;
|
||||
}
|
||||
return equal;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
double get_mean(const std::vector<double>& iter);
|
||||
double get_sigma(const std::vector<double>& iter);
|
||||
|
||||
struct join_helper {
|
||||
static void add_to_stream(std::ostream& stream) {}
|
||||
|
||||
template<typename T, typename... Args>
|
||||
static void add_to_stream(std::ostream& stream, const T& arg, const Args&... args) {
|
||||
stream << arg;
|
||||
add_to_stream(stream, args...);
|
||||
}
|
||||
};
|
||||
|
||||
template<typename T, typename... Args>
|
||||
std::string join(const T& arg, const Args&... args) {
|
||||
std::ostringstream stream;
|
||||
stream << arg;
|
||||
join_helper::add_to_stream(stream, args...);
|
||||
return stream.str();
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void write_property(std::ostream& file, const std::string& key, const T& value) {
|
||||
file << key << "\t" << value << std::endl;
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
void read_property(std::istream& file, const std::string& key, T& value) {
|
||||
std::string tmp;
|
||||
bool found = false;
|
||||
while (file.good()) {
|
||||
file >> tmp;
|
||||
if (tmp==key) {
|
||||
file >> value;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
throw std::domain_error(join("No such key available: ", key));
|
||||
file.seekg(0);
|
||||
}
|
||||
|
||||
} //utils
|
||||
|
||||
|
||||
至于具体的实现代码,我放在了在代码清单8utils.cpp中。
|
||||
|
||||
代码清单8,utils.cpp:
|
||||
|
||||
#include "utils.h"
|
||||
#include <stdexcept>
|
||||
#include <sstream>
|
||||
#include <numeric>
|
||||
|
||||
namespace utils {
|
||||
|
||||
arma::Mat<double> make_covariance_matrix(const arma::Mat<double>& data) {
|
||||
return std::move( (data.t()*data) * (1./(data.n_rows-1)) );
|
||||
}
|
||||
|
||||
arma::Mat<double> make_shuffled_matrix(const arma::Mat<double>& data) {
|
||||
const long n_rows = data.n_rows;
|
||||
const long n_cols = data.n_cols;
|
||||
arma::Mat<double> shuffle(n_rows, n_cols);
|
||||
for (long j=0; j<n_cols; ++j) {
|
||||
for (long i=0; i<n_rows; ++i) {
|
||||
shuffle(i, j) = data(std::rand()%n_rows, j);
|
||||
}
|
||||
}
|
||||
return std::move(shuffle);
|
||||
}
|
||||
|
||||
arma::Col<double> compute_column_means(const arma::Mat<double>& data) {
|
||||
const long n_cols = data.n_cols;
|
||||
arma::Col<double> means(n_cols);
|
||||
for (long i=0; i<n_cols; ++i)
|
||||
means(i) = arma::mean(data.col(i));
|
||||
return std::move(means);
|
||||
}
|
||||
|
||||
void remove_column_means(arma::Mat<double>& data, const arma::Col<double>& means) {
|
||||
if (data.n_cols != means.n_elem)
|
||||
throw std::range_error("Number of elements of means is not equal to the number of columns of data");
|
||||
for (long i=0; i<long(data.n_cols); ++i)
|
||||
data.col(i) -= means(i);
|
||||
}
|
||||
|
||||
arma::Col<double> compute_column_rms(const arma::Mat<double>& data) {
|
||||
const long n_cols = data.n_cols;
|
||||
arma::Col<double> rms(n_cols);
|
||||
for (long i=0; i<n_cols; ++i) {
|
||||
const double dot = arma::dot(data.col(i), data.col(i));
|
||||
rms(i) = std::sqrt(dot / (data.col(i).n_rows-1));
|
||||
}
|
||||
return std::move(rms);
|
||||
}
|
||||
|
||||
void normalize_by_column(arma::Mat<double>& data, const arma::Col<double>& rms) {
|
||||
if (data.n_cols != rms.n_elem)
|
||||
throw std::range_error("Number of elements of rms is not equal to the number of columns of data");
|
||||
for (long i=0; i<long(data.n_cols); ++i) {
|
||||
if (rms(i)==0)
|
||||
throw std::runtime_error("At least one of the entries of rms equals to zero");
|
||||
data.col(i) *= 1./rms(i);
|
||||
}
|
||||
}
|
||||
|
||||
void enforce_positive_sign_by_column(arma::Mat<double>& data) {
|
||||
for (long i=0; i<long(data.n_cols); ++i) {
|
||||
const double max = arma::max(data.col(i));
|
||||
const double min = arma::min(data.col(i));
|
||||
bool change_sign = false;
|
||||
if (std::abs(max)>=std::abs(min)) {
|
||||
if (max<0) change_sign = true;
|
||||
} else {
|
||||
if (min<0) change_sign = true;
|
||||
}
|
||||
if (change_sign) data.col(i) *= -1;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<double> extract_column_vector(const arma::Mat<double>& data, long index) {
|
||||
if (index<0 || index >= long(data.n_cols))
|
||||
throw std::range_error(join("Index out of range: ", index));
|
||||
const long n_rows = data.n_rows;
|
||||
const double* memptr = data.colptr(index);
|
||||
std::vector<double> result(memptr, memptr + n_rows);
|
||||
return std::move(result);
|
||||
}
|
||||
|
||||
std::vector<double> extract_row_vector(const arma::Mat<double>& data, long index) {
|
||||
if (index<0 || index >= long(data.n_rows))
|
||||
throw std::range_error(join("Index out of range: ", index));
|
||||
const arma::Row<double> row(data.row(index));
|
||||
const double* memptr = row.memptr();
|
||||
std::vector<double> result(memptr, memptr + row.n_elem);
|
||||
return std::move(result);
|
||||
}
|
||||
|
||||
void assert_file_good(const bool& is_file_good, const std::string& filename) {
|
||||
if (!is_file_good)
|
||||
throw std::ios_base::failure(join("Cannot open file: ", filename));
|
||||
}
|
||||
|
||||
double get_mean(const std::vector<double>& iter) {
|
||||
const double init = 0;
|
||||
return std::accumulate(iter.begin(), iter.end(), init) / iter.size();
|
||||
}
|
||||
|
||||
double get_sigma(const std::vector<double>& iter) {
|
||||
const double mean = get_mean(iter);
|
||||
double sum = 0;
|
||||
for (auto v=iter.begin(); v!=iter.end(); ++v)
|
||||
sum += std::pow(*v - mean, 2.);
|
||||
return std::sqrt(sum/(iter.size()-1));
|
||||
}
|
||||
|
||||
} //utils
|
||||
|
||||
|
||||
最后,我们来编写pca.i接口文件,也就是代码清单9的内容。
|
||||
|
||||
代码清单9,pca.i:
|
||||
|
||||
%module pca
|
||||
|
||||
%include "std_string.i"
|
||||
%include "std_vector.i"
|
||||
|
||||
namespace std {
|
||||
%template(DoubleVector) vector<double>;
|
||||
}
|
||||
|
||||
%{
|
||||
#include "pca.h"
|
||||
#include "utils.h"
|
||||
%}
|
||||
|
||||
%include "pca.h"
|
||||
%include "utils.h"
|
||||
|
||||
|
||||
这里需要注意的是,我们在C++代码中使用了熟悉的顺序容器std::vector,但由于模板类比较特殊,我们需要用%template声明一下。
|
||||
|
||||
一切就绪后,我们执行下面的命令行,生成_pca.so库供Python使用:
|
||||
|
||||
$ swig -c++ -python pca.i # 解释接口定义生成包SWIG装器代码
|
||||
$ g++ -fPIC -c pca.h pca.cpp utils.h utils.cpp pca_wrap.cxx -I/usr/include/python3.7 # 编译源代码
|
||||
$ g++ -shared pca.o pca_wrap.o utils.o -o _pca.so -O2 -Wall -std=c++11 -pthread -shared -fPIC -larmadillo # 链接
|
||||
|
||||
|
||||
接着,我们使用Python脚本,导入我们创建好的so动态库;然后,调用相应的类的函数。这部分内容,我写在了代码清单10中。
|
||||
|
||||
代码清单10,testPCA.py:
|
||||
|
||||
import pca
|
||||
|
||||
pca_inst = pca.pca(2)
|
||||
pca_inst.add_record([1.0, 1.0])
|
||||
pca_inst.add_record([2.0, 2.0])
|
||||
pca_inst.add_record([4.0, 1.0])
|
||||
|
||||
pca_inst.solve()
|
||||
|
||||
energy = pca_inst.get_energy()
|
||||
eigenvalues = pca_inst.get_eigenvalues()
|
||||
|
||||
print(energy)
|
||||
print(eigenvalues)
|
||||
|
||||
|
||||
最后,我们分别对纯Python实现的代码,和使用SWIG封装的版本来进行测试,各自都执行1,000,000次,然后对比执行时间。我用一张图表示了我的机器上得到的结果,你可以对比看看。
|
||||
|
||||
|
||||
|
||||
虽然这样粗略的比较并不够严谨,比如我们没有认真考虑SWIG接口类型转换的耗时,也没有考虑在不同编程语言下实现算法的逻辑等等。但是,通过这个粗略的结果,你仍然可以看出执行类似运算时,两者性能的巨大差异。
|
||||
|
||||
SWIG C++常用工具
|
||||
|
||||
到这里,你应该已经可以开始动手操作了,把上面的代码清单当作你的工具进行实践。不过,SWIG本身非常丰富,所以这里我也再给你总结介绍几个常用的工具。
|
||||
|
||||
1.全局变量
|
||||
|
||||
在Python 中,我们可以通过cvar,来访问C++代码中定义的全局变量。
|
||||
|
||||
比如说,我们在头文件 sample.h中定义了一个全局变量,并在sample.i中对其进行引用,也就是代码清单 11和12的内容。
|
||||
|
||||
代码清单11,sample.h:
|
||||
|
||||
#include <cstdint>
|
||||
int32_t score = 100;
|
||||
|
||||
|
||||
代码清单12,sample.i:
|
||||
|
||||
%module sample
|
||||
%{
|
||||
#include "sample.h"
|
||||
%}
|
||||
|
||||
%include "sample.h"
|
||||
|
||||
|
||||
这样,我们就可以直接在Python脚本中,通过cvar来访问对应的全局变量,如代码清单13所示,输出结果为100。
|
||||
|
||||
代码清单13,sample.py:
|
||||
|
||||
import sample
|
||||
print sample.cvar.score
|
||||
|
||||
|
||||
2.常量
|
||||
|
||||
我们可以在接口定义文件中,使用 %constant来设定常量,如代码清单14所示。
|
||||
|
||||
代码清单14,sample.i:
|
||||
|
||||
%constant int foo = 100;
|
||||
%constant const char* bar = "foobar2000";
|
||||
|
||||
|
||||
3.Enumeration
|
||||
|
||||
我们可以在接口文件中,使用enum关键字来定义enum。
|
||||
|
||||
4.指针和引用
|
||||
|
||||
在C++世界中,指针是永远也绕不开的一个概念。它无处不在,我们也无时无刻不需要使用它。因此,在这里,我认为很有必要介绍一下,如何对C++中的指针和引用进行操作。
|
||||
|
||||
SWIG对指针有着较为不错的支持,对智能指针也有一定的支持,而且在近期的更新日志中,我发现它对智能指针的支持一直在更新。下面的代码清单15和16,就展示了针对指针和引用的使用方法。
|
||||
|
||||
代码清单15,sample.h:
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
void passPointer(ClassA* ptr) {
|
||||
printf("result= %d", ptr->result);
|
||||
}
|
||||
|
||||
void passReference(const ClassA& ref) {
|
||||
printf("result= %d", ref.result);
|
||||
}
|
||||
|
||||
void passValue(ClassA obj) {
|
||||
printf("result= %d", obj.result);
|
||||
}
|
||||
|
||||
|
||||
代码清单16,sample.py:
|
||||
|
||||
import sample
|
||||
|
||||
a = ClassA() # 创建 ClassA实例
|
||||
passPointer(a)
|
||||
passReference(a)
|
||||
passValue(a)
|
||||
|
||||
|
||||
5.字符串
|
||||
|
||||
我们在工业级代码中,时常使用std::string。而在SWIG的环境下,使用标准库中的字符串,需要你在接口文件中声明%include “std_stirng.i”,来确保实现C++ std::string到Python str的自动转换。具体内容我放在了代码清单17中。
|
||||
|
||||
代码清单17,sample.i:
|
||||
|
||||
%module sample
|
||||
|
||||
%include "std_string.i"
|
||||
|
||||
|
||||
|
||||
6.向量
|
||||
|
||||
std::vector是STL中最常见也是使用最频繁的顺序容器,模板类比较特殊,因此,它的使用也比字符串稍微复杂一些,需要使用%template进行声明。详细内容我放在了代码清单18中。
|
||||
|
||||
代码清单18,sample.i:
|
||||
|
||||
%module sample
|
||||
|
||||
%include "std_string.i"
|
||||
%include "std_vector.i"
|
||||
|
||||
namespace std {
|
||||
%template(DoubleVector) vector<double>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
7. 映射
|
||||
|
||||
std::map 同样是STL中最常见也是使用最频繁的容器。同样的,它的模板类也比较特殊,需要使用%template进行声明,详细内容可见代码清单19。
|
||||
|
||||
代码清单19,sample.i:
|
||||
|
||||
%module sample
|
||||
|
||||
%include "std_string.i"
|
||||
%include "std_map.i"
|
||||
|
||||
namespace std {
|
||||
%template(Int2strMap) map<int, string>;
|
||||
%template(Str2intMap) map<string, int>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
学习路径
|
||||
|
||||
到此,SWIG入门这个小目标,我们就已经实现了。今天内容可以当作一份SWIG的编程实践指南,我给你提供了19个代码清单,利用它们,你就可以上手操作了。当然,如果在这方面你还想继续精进,该怎么办呢?别着急,今天这节课的最后,我再和你分享下,我觉得比较高效的一条SWIG学习路径。
|
||||
|
||||
首先,任何技术的学习不要脱离官方文档。SWIG网站上提供了难以置信的详尽文档,通过文档掌握SWIG的用法,显然是最好的一个途径。
|
||||
|
||||
其次,要深入SWIG,对C++有一个较为全面的掌握,就显得至关重要了。对于高性能计算来说,C++总是绕不开的一个主题,特别是对内存管理、指针和虚函数的应用,需要你实际上手编写C++代码后,才能逐渐掌握。退一步讲,即便你只是为了封装其他C++库供Python调用,也需要对C++有一个基本了解,以便未来遇到编译或链接错误时,可以找到方向来解决问题。
|
||||
|
||||
最后,我再罗列一些学习素材,供你进一步学习参考。
|
||||
|
||||
第一便是SWIG文档。
|
||||
|
||||
|
||||
a. http://www.swig.org/doc.html
|
||||
b. http://www.swig.org/Doc4.0/SWIGPlus.html
|
||||
c. PDF版本:http://www.swig.org/Doc4.0/SWIGDocumentation.pdf
|
||||
|
||||
|
||||
第二是《C++ Primer》这本书。作为C++领域的经典书籍,这本书对你全面了解C++有极大帮助。
|
||||
|
||||
第三则是《高级C/C++编译技术》这本书。这本书的内容更为进阶,你可以把它作为学习C++的提高和了解。
|
||||
|
||||
好了,今天的内容就到此结束了。关于SWIG,你有哪些收获,或者还有哪些问题,都欢迎你留言和我分享讨论。也欢迎你把这篇文章分享给你的同事、朋友,我们一起学习和进步。
|
||||
|
||||
|
||||
|
||||
|
39
专栏/Python核心技术与实战/结束语技术之外的几点成长建议.md
Normal file
39
专栏/Python核心技术与实战/结束语技术之外的几点成长建议.md
Normal file
@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 技术之外的几点成长建议
|
||||
你好,我是景霄。
|
||||
|
||||
不知不觉,专栏上线已经4个月了,终于到了和你们说再见的时候,心中既有兴奋,又有不舍。说兴奋,是因为自己坚持完成了40多篇文章的写作,这对我而言是一项体力与脑力的“马拉松”,颇有成就感;说不舍,是因为你们的热情远超乎我当时的想象,和你们一起交流学习真的很高兴,收获也很大。
|
||||
|
||||
这里也非常感谢极客时间能提供这个平台,让我得以和一万多的学员分享经验。当然,虽然课程结束了,但是技术人的学习并未终止,最后我还想再着重强调这么几点。
|
||||
|
||||
计算机科学是一门需要实践的学科
|
||||
|
||||
无论是对于Python这门课程,还是其他语言,或是计算机的其他领域,我认为实践永远是至关重要的。计算机科学是一门偏向工程的学科,所以一定要多实践,多写代码,多交流,多思考。实际生活中,我见过不少同学虽然看了很多的书籍,但是代码功底非常差,这是一个很严重的问题。
|
||||
|
||||
那么,怎么提高呢?答案就是,请尽可能地参与更多的项目。俗话说“实践出真知”,说的正是这个道理。当你做的东西多了以后,你就会发现,很多的知识都会自然而然地串接起来,感觉自己的任督二脉似乎被打通了一般。
|
||||
|
||||
选择适合自己的职业方向
|
||||
|
||||
关于职业方向的问题,我还是那句话,对于尚不清晰自己兴趣所在的同学,最好的办法就是多尝试,因为在尝试的过程中,你就会发现自己的特长,发现自己的喜好,从而坚定不移、一步一步地走下去。
|
||||
|
||||
当然,我了解到很多同学都是转行做程序员,以前都不是学计算机的,所以,很多人首先都会遇到“找工作”或者“跳槽”的难题。这种情况下,请不要气馁或是放弃。我也不完全算是科班出身的,我身边好多同事都不是科班出身,但是几年以后,你就会发现,好多当年非科班出身的同学,干得甚至比科班出身的都要好。因此,只要你坚定信念,不要放弃,风雨之后必有彩虹。
|
||||
|
||||
能用代码解决的问题都不是问题
|
||||
|
||||
这句话,主要送给有一定工作经验的朋友。其实,有了一定的积累后,你就会发现,能用代码解决的问题都不是问题。职场上,要想出类拔萃,除了能在技术上独当一面,如何与他人进行沟通交流,如何正确地处理同事间的关系,都是你额外需要学习的东西。实际工作中,我也见过不少人,代码能力天赋异禀,但是沟通交流不行,这同样也会阻碍你向上晋升。
|
||||
|
||||
就拿一个很简单的例子来说。如果你和周围的同事合作一个项目,你觉得他有一样东西做得不好,你会直接说诸如“你很傻”,“你这样做大错特错”的话吗?你如果这样说了,那可真的是“大错特错了”。事实上,我们通常的一个措辞是,“我觉得你这里做得很好,不过,如果你可以……的话,那就更好了”。
|
||||
|
||||
说了这么多,最后,还是要感谢你的订阅,感谢你的一路陪伴,祝你的生活学习一帆风顺,永远幸福!课程虽然已经结束,但是你仍然可以留言,我会尽可能多地一一回复,让你的学习没有遗憾。
|
||||
|
||||
结束不是终点,而是更高阶旅程的重新启航。最后的最后,我们专栏的编辑同学,特意为你准备了一份毕业调查问卷,希望你能抽出两三分钟时间,写下你的学习经历和感受。这既是你课程学习的仪式感,作为你这几个月学习的记录和总结;也是我后续为你优化课程的重要参考资料,很有价值。感谢你的反馈,学习不停,精进不止!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user