This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<audio id="audio" title="01 | 如何逐步突破成为Python高手" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/38/6f/385f2b2f8203ac28dd888d02e668f56f.mp3"></audio>
你好,我是景霄。
工作中我总听到很多程序员抱怨说现在的计算机编程语言太多了学不过来了。一些人Java用了很多年但是最近的项目突然需要用Python就会不知所措压力很大。
众所周知Facebook的主流语言是HackPHP的进化版本。不过我敢拍着胸脯说就刚入职的工程师而言100个里至少有95个以前都从未用过Hack或者PHP。但是这些人上手都特别快基本上一两周后日常编程便毫无压力了。
他们是怎么做到的呢?
事实上他们遵循的正是我在开篇词中提到的方法也是本专栏学习的中心观点“从工程的角度去学习Python”。那么具体来说到底要怎么学学习的过程中又要特别注意哪些地方呢
## 不同语言,需融会贯通
其实,如果你在学一门语言的时候多阅读、多练习、多思考,你就会发现,不同语言都是类似的。编程语言本就是人类控制计算机的指令,语法规则等方面自然大同小异。
而在原有基础上学习一门新的编程语言其实也没有那么难你首先要做到的是明确区分。比如在学习Python的条件与循环语句时多回忆一下其他语言的语法是怎样的。再如遇到Python中的字符串相加时你能分析出它的复杂度吗再联想到其他语言比如Java中字符串相加的复杂度它们之间有什么相同点、又有什么区别呢
除了能够明确区分语言的不同点,我们还要能联系起来灵活运用。比如,最典型的“编程语言两问”:
<li>
你了解你学过的每种编程语言的特点吗?
</li>
<li>
你能根据不同的产品需求,选用合适的编程语言吗?
</li>
举个例子Python的优点之一是特别擅长数据分析所以广泛应用于人工智能、机器学习等领域如机器学习中TensorFlow的框架就是用Python写的。但是涉及到底层的矩阵运算等等还是要依赖于C++完成因为C++的速度快,运行效率更高。
事实上很多公司都是这样服务器端开发基于Python但底层的基础架构依赖于C++。这就是典型的“不同需求选用不同语言”。毕竟,你要明白,哪怕只是几十到几百毫秒的速度差距,对于公司、对于用户体验来说都是决定性的。
## 唯一语言,可循序渐进
当然如果Python是你学的第一门编程语言那也不必担心。我们知道虽然同为人机交互的桥梁Python语言比起C++、Java等主流语言语法更简洁也更接近英语对编程世界的新人还是很友好的这也是其显著优点。这种情况下你要做的就是专注于Python这一门语言明确学习的重点把握好节奏循序渐进地学习。
根据我多年的学习工作经验,我把编程语言的学习重点,总结成了下面这三步,无论你是否有其他语言的基础,都可以对照来做,稳步进阶。
## 第一步:大厦之基,勤加练习
任何一门编程语言其覆盖范围都是相当广泛的从基本的变量赋值、条件循环到并发编程、Web开发等等我想市面上几乎没有任何一本书能够罗列完全。
所以,我建议你,在掌握必要的基础时,就得多上手操作了。千万不要等到把教材上所有东西都学完了才开始,因为到那时候你会发现,前面好不容易记住的一堆东西似乎又忘记了。计算机科学是一门十分讲究实战的学科,因此越早上手练习,练得越多越勤,就越好。
不过到底什么叫做必要的基础呢以Python为例如果你能够理解变量间的赋值、基本的数据类型、条件与循环语句、函数的用法那么你就达到了第一步的底线标准应该开始在课下多多练习了。
比方说,你可以自己动手编程做一个简易的计算器,这应该也是大多数程序员实操的第一个小项目。用户输入数字和运算符后,你的程序能够检查输入是否合法并且返回正确的结果吗?
在做这个小项目的过程中,你可能会遇到不少问题。我的建议是,遇到不懂的问题时,多去[Stack Overflow](https://stackoverflow.com/)上查询,这样你还能阅读别人优秀的代码,借鉴别人的思路,对于你的学习肯定大有帮助。当然,实在解决不了的问题,也可以写在留言区,我们一起来解决。
## 第二步:代码规范,必不可少
诚然,学习编程讲究快和高效。但是,与此同时,请一定不要忽略每一种语言必要的编程规范。在你自己刚开始写代码练习时,你可以不写单元测试,但总不能几百行的代码却没有一个函数,而是从头顺序写到尾吧?你可以省略一些可有可无的注释,但总不能把很多行代码全部并到一行吧?
比如,我们来看下面这行代码:
```
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最高频的核心知识大部分内容我在专栏中都会讲到。你可以保存或者打印出来作为学习参考。
<img src="https://static001.geekbang.org/resource/image/2c/1d/2cfc18adf51b61ca8140561071d20c1d.png" alt="">
今天我跟你分享了Python的学习方法和注意事项其实这些观点不只适用于Python也能帮助你学习任何一门其他计算机编程语言希望你能牢记在心。在接下来的课程里我会带你逐步突破最终成为一名Python高手。
那么对于学习Python或者是其他编程语言你有什么困扰或是心得吗欢迎在留言区与我交流

View File

@@ -0,0 +1,106 @@
<audio id="audio" title="02 | Jupyter Notebook为什么是现代Python的必学技术" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/b9/7f8481cb86c376b17e93ca8a01b74fb9.mp3"></audio>
你好,我是景霄。
Stack Overflow 曾在2017年底发布了在该站上各种语言的提问流量。其中Python已经超过了JavaScript成为了流量最高的语言预测在2020年前会远远甩开JavaScript。
<img src="https://static001.geekbang.org/resource/image/5c/2d/5c3daf49453370c3aa7ddf3bb36cab2d.png" alt="">
可能你已经知道Python在14年后的“崛起”得益于机器学习和数学统计应用的兴起。那为什么Python如此适合数学统计和机器学习呢作为“老司机”的我可以肯定地告诉你Jupyter Notebook [https://jupyter.org/](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了。
<img src="https://static001.geekbang.org/resource/image/de/71/dee40d0f591d3f5e2f43839dccc24471.png" alt="">
你在一个框框中直接输入代码运行它立马就在下面给你输出。怎么样是不是很酷你可能会纳闷儿这样一个看起来“华而不实”的玩意儿真的就成了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](https://mybinder.readthedocs.io/en/latest/index.html)和Google提供的 Google Colab环境介绍[https://colab.research.google.com/notebooks/welcome.ipynb](https://colab.research.google.com/notebooks/welcome.ipynb)。它们让Jupyter Notebook变得和石墨文档、Google Doc在线文档一样在浏览器点开链接就能运行。
所以现在当你用Binder打开一份GitHub上的Jupyter Notebook时你不需要安装任何软件直接在浏览器打开一份代码就能在云端运行。
## Jupyter Notebook 初体验
学习技术的最好方法就是用技术。不过在今天的篇幅里我不可能带你完全学会Jupyter Notebook的所有技巧。我想先带你直接感受一下使用Jupyter Notebook的工作体验。
比如这样一个[GitHub文件](https://github.com/binder-examples/python2_with_3/blob/master/index3.ipynb)。在[Binder](https://mybinder.org/)中你只要输入其对应的GitHub Repository的名字或者URL就能在云端打开整个Repository选择你需要的[notebook](https://mybinder.org/v2/gh/binder-examples/python2_with_3/master?filepath=index3.ipynb),你就能看到下图这个界面。
<img src="https://static001.geekbang.org/resource/image/f8/c9/f81efe2538074a3385b9ba70aced2cc9.png" alt="">
每一个Jupyter的运行单元都包含了In、Out的Cell。如图所示你可以使用Run按钮运行单独的一个Cell。当然你也可以在此基础上加以修改或者新建一个notebook写成自己想要的程序。赶紧打开链接试一试吧
另外我还推荐下面这些Jupyter Notebook作为你实践的第一站。
<li>
第一个是Jupyter官方[https://mybinder.org/v2/gh/binder-examples/matplotlib-versions/mpl-v2.0/?filepath=matplotlib_versions_demo.ipynb](https://mybinder.org/v2/gh/binder-examples/matplotlib-versions/mpl-v2.0/?filepath=matplotlib_versions_demo.ipynb)
</li>
<li>
第二个是Google Research提供的Colab环境尤其适合机器学习的实践应用[https://colab.research.google.com/notebooks/basic_features_overview.ipynb](https://colab.research.google.com/notebooks/basic_features_overview.ipynb)
</li>
>
如果你想在本地或者远程的机器上安装Jupyter Notebook可以参考下面的两个文档。
>
安装:[https://jupyter.org/install.html](https://jupyter.org/install.html)
>
运行:[https://jupyter.readthedocs.io/en/latest/running.html#running](https://jupyter.readthedocs.io/en/latest/running.html#running)
## 总结
这节课我为你介绍了Jupyter Notebook并告诉你它为什么日趋成为Python社区的必学技术。这主要是因为它的三大特点**整合所有的资源、交互性编程体验和零成本重现结果**。但还是那句话学习技术必须动手实操。这节课后希望你能自己动手试一试Jupyter Notebook后面我们的一些课程代码我也会用Jupyter Notebook的形式分享给你。
## 思考题
你尝试Jupyter Notebook了吗欢迎在留言区和我分享你的使用体验。

View File

@@ -0,0 +1,289 @@
<audio id="audio" title="03 | 列表和元组,到底用哪一个?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c4/24/c4d394762be3dc5bf584e1d7bfb7fc24.mp3"></audio>
你好,我是景霄。
前面的课程我们讲解了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)
```
其次,我们必须掌握它们的区别。
<li>
**列表是动态的**长度大小不固定可以随意地增加、删减或者改变元素mutable
</li>
<li>
**而元组是静态的**长度大小固定无法增加删减或者改变immutable
</li>
下面的例子中我们分别创建了一个列表与元组。你可以看到对于列表我们可以很轻松地让其最后一个元素由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 &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
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]
```
这里我简单解释一下这几个函数的含义。
<li>
count(item)表示统计列表/元组中item出现的次数。
</li>
<li>
index(item)表示返回列表/元组中item第一次出现的索引。
</li>
<li>
list.reverse()和list.sort()分别表示原地倒转列表和排序(注意,元组没有内置的这两个函数)。
</li>
<li>
reversed()和sorted()同样表示对列表/元组进行倒转和排序reversed()返回一个倒转后的迭代器上文例子使用list()函数再将其转换为列表sorted()返回排好序的新列表。
</li>
## 列表和元组存储方式的差异
前面说了,列表和元组最重要的区别就是,列表是动态的、可变的,而元组是静态的、不可变的。这样的差异,势必会影响两者存储方式。我们可以来看下面的例子:
```
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)
```
## 总结
关于列表和元组,我们今天聊了很多,最后一起总结一下你必须掌握的内容。
总的来说,列表和元组都是有序的,可以存储任意数据类型的集合,区别主要在于下面这两点。
<li>
列表是动态的,长度可变,可以随意的增加、删减或改变元素。列表的存储空间略大于元组,性能略逊于元组。
</li>
<li>
元组是静态的,长度大小固定,不可以对元素进行增加、删减或者改变操作。元组相对于列表更加轻量级,性能稍优。
</li>
## 思考题
**1. **想创建一个空的列表我们可以用下面的A、B两种方式请问它们在效率上有什么区别吗我们应该优先考虑使用哪种呢可以说说你的理由。
```
# 创建空列表
# option A
empty_list = list()
# option B
empty_list = []
```
**2. **你在平时的学习工作中,是在什么场景下使用列表或者元组呢?欢迎留言和我分享。

View File

@@ -0,0 +1,414 @@
<audio id="audio" title="04 | 字典、集合,你真的了解吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/ae/964aea2b7b66e549d6093a741634b9ae.mp3"></audio>
你好,我是景霄。
前面的课程我们学习了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 &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
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 &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
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(&quot;time elapse using list: {}&quot;.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(&quot;time elapse using set: {}&quot;.format(end_using_set - start_using_set))
# 输出
time elapse using set: 0.008238077163696289
```
你可以看到,仅仅十万的数据量,两者的速度差异就如此之大。事实上,大型企业的后台数据往往有上亿乃至十亿数量级,如果使用了不合适的数据结构,就很容易造成服务器的崩溃,不但影响用户体验,并且会给公司带来巨大的财产损失。
## 字典和集合的工作原理
我们通过举例以及与列表的对比,看到了字典和集合操作的高效性。不过,字典和集合为什么能够如此高效,特别是查找、插入和删除操作?
这当然和字典、集合内部的数据结构密不可分。不同于其他数据结构,字典和集合的内部结构都是一张哈希表。
<li>
对于字典而言这张表存储了哈希值hash、键和值这3个元素。
</li>
<li>
而对集合来说,区别就是哈希表内没有键和值的配对,只有单一的元素了。
</li>
我们来看老版本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) &amp; mask。如果哈希表中此位置是空的那么这个元素就会被插入其中。
而如果此位置已被占用Python便会比较两个元素的哈希值和键是否相等。
<li>
若两者都相等,则表明这个元素已经存在,如果值不同,则更新值。
</li>
<li>
若两者中有一个不相等这种情况我们通常称为哈希冲突hash collision意思是两个元素的键不相等但是哈希值相等。这种情况下Python便会继续寻找表中空余的位置直到找到位置为止。
</li>
值得一提的是通常来说遇到这种情况最简单的方式是线性寻找即从这个位置开始挨个往后寻找空位。当然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']}
```
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="05 | 深入浅出字符串" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/45/0ba5a185cab408cefd816258bab70045.mp3"></audio>
你好,我是景霄。
Python的程序中充满了字符串string在平常阅读代码时也屡见不鲜。字符串同样是Python中很常见的一种数据类型比如日志的打印、程序中函数的注释、数据库的访问、变量的基本操作等等都用到了字符串。
当然我相信你本身对字符串已经有所了解。今天这节课我主要带你回顾一下字符串的常用操作并对其中的一些小tricks详细地加以解释。
## 字符串基础
什么是字符串呢?字符串是由独立字符组成的一个序列,通常包含在单引号(`''`)双引号(`""`)或者三引号之中(`''' '''``""" """`,两者一样),比如下面几种写法。
```
name = 'jason'
city = 'beijing'
text = &quot;welcome to jike shijian&quot;
```
这里定义了name、city和text三个变量都是字符串类型。我们知道Python中单引号、双引号和三引号的字符串是一模一样的没有区别比如下面这个例子中的s1、s2、s3完全一样。
```
s1 = 'hello'
s2 = &quot;hello&quot;
s3 = &quot;&quot;&quot;hello&quot;&quot;&quot;
s1 == s2 == s3
True
```
Python同时支持这三种表达方式很重要的一个原因就是这样方便你在字符串中内嵌带引号的字符串。比如
```
&quot;I'm a student&quot;
```
Python的三引号字符串则主要应用于多行字符串的情境比如函数的注释等等。
```
def calculate_similarity(item1, item2):
&quot;&quot;&quot;
Calculate similarity between two items
Args:
item1: 1st item
item2: 2nd item
Returns:
similarity score between item1 and item2
&quot;&quot;&quot;
```
同时Python也支持转义字符。所谓的转义字符就是用反斜杠开头的字符串来表示一些特定意义的字符。我把常见的的转义字符总结成了下面这张表格。
<img src="https://static001.geekbang.org/resource/image/b7/52/b7a296ab8d26664e03a076fa50d5b152.png" alt="">
为了方便你理解,我举一个例子来说明。
```
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 &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
TypeError: 'str' object does not support item assignment
```
Python中字符串的改变通常只能通过创建新的字符串来完成。比如上述例子中想把`'hello'`的第一个字符`'h'`,改为大写的`'H'`,我们可以采用下面的做法:
```
s = 'H' + s[1:]
s = s.replace('h', 'H')
```
<li>
第一种方法,是直接用大写的`'H'`,通过加号`'+'`操作符,与原字符串切片操作的子字符串拼接而成新的字符串。
</li>
<li>
第二种方法,是直接扫描原字符串,把小写的`'h'`替换成大写的`'H'`,得到新的字符串。
</li>
你可能了解到在其他语言中如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 += str2Python首先会检测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):
&quot;&quot;&quot;
given namespace and table, query database to get corresponding
data
&quot;&quot;&quot;
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)
```
此外,常见的函数还有:
<li>
string.strip(str)表示去掉首尾的str字符串
</li>
<li>
string.lstrip(str)表示只去掉开头的str字符串
</li>
<li>
string.rstrip(str)表示只去掉尾部的str字符串。
</li>
这些在数据的解析处理中同样很常见。比如很多时候从文件读进来的字符串中开头和结尾都含有空字符我们需要去掉它们就可以用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字符串的一些基本知识和常用操作并且结合具体的例子与场景加以说明特别需要注意下面几点。
<li>
Python中字符串使用单引号、双引号或三引号表示三者意义相同并没有什么区别。其中三引号的字符串通常用在多行字符串的场景。
</li>
<li>
Python中字符串是不可变的前面所讲的新版本Python中拼接操作+='是个例外)。因此,随意改变字符串中字符的值,是不被允许的。
</li>
<li>
Python新版本2.5+)中,字符串的拼接变得比以前高效了许多,你可以放心使用。
</li>
<li>
Python中字符串的格式化string.format常常用在输出、日志的记录等场景。
</li>
## 思考题
最后给你留一道思考题。在新版本的Python2.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)
```

View File

@@ -0,0 +1,334 @@
<audio id="audio" title="06 | Python “黑箱”:输入与输出" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7e/54/7e2630056ca4ded65195cecfd7287954.mp3"></audio>
你好,我是景霄。
世纪之交的论坛上曾有一句流行语:在互联网上,没人知道你是一条狗。互联网刚刚兴起时,一根网线链接到你家,信息通过这条高速线缆直达你的屏幕,你通过键盘飞速回应朋友的消息,信息再次通过网线飞入错综复杂的虚拟世界,再进入朋友家。抽象来看,一台台的电脑就是一个个黑箱,黑箱有了输入和输出,就拥有了图灵机运作的必要条件。
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 &lt;class 'str'&gt;, type of b is &lt;class 'str'&gt;
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: &quot;Free at last! Free at last! Thank God Almighty, we are free at last!&quot;
```
好,让我们来做一个简单的 NLP自然语言处理任务。如果你对此不太了解也没有影响我会带你一步步完成这个任务。
首先我们要清楚NLP任务的基本步骤也就是下面的四步
<li>
读取文件;
</li>
<li>
去除所有标点符号和换行符,并把所有大写变成小写;
</li>
<li>
合并相同的词,统计每个词出现的频率,并按照词频从大到小排序;
</li>
<li>
将结果按行输出到文件 out.txt。
</li>
你可以自己先思考一下用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。这么做自然也是有利有弊
<li>
优点是方便,接下来我们可以很方便地调用 parse 函数进行分析;
</li>
<li>
缺点是如果文件过大,一次性读取可能造成内存崩溃。
</li>
这时,我们可以给 read 指定参数 size ,用来表示读取的最大长度。还可以通过 readline() 函数每次读取一行这种做法常用于数据挖掘Data Mining中的数据清洗在写一些小的程序时非常轻便。如果每行之间没有关联这种做法也可以降低内存的压力。而write() 函数,可以把参数中的字符串输出到文件中,也很容易理解。
这里我需要简单提一下 with 语句后文会详细讲到。open() 函数对应于 close() 函数,也就是说,如果你打开了文件,在完成读取任务后,就应该立刻关掉它。而如果你使用了 with 语句,就不需要显式调用 close()。在 with 的语境下任务执行完毕后close() 函数会被自动调用,代码也简洁很多。
最后需要注意的是,所有 I/O 都应该进行错误处理。因为 I/O 操作可能会有各种各样的情况出现而一个健壮robust的程序需要能应对各种情况的发生而不应该崩溃故意设计的情况除外
## JSON 序列化与实战
最后,我来讲一个和实际应用很贴近的知识点。
JSONJavaScript Object Notation是一种轻量级的数据交换格式它的设计意图是把所有事情都用设计的字符串来表示这样既方便在互联网上传递信息也方便人进行阅读相比一些 binary 的协议。JSON 在当今互联网中应用非常广泛,也是每一个用 Python程序员应当熟练掌握的技能点。
设想一个情景,你要向交易所购买一定数额的股票。那么,你需要提交股票代码、方向(买入/卖出)、订单类型(市价/限价)、价格(如果是限价单)、数量等一系列参数,而这些数据里,有字符串,有整数,有浮点数,甚至还有布尔型变量,全部混在一起并不方便交易所解包。
那该怎么办呢?
其实我们要讲的JSON ,正能解决这个场景。你可以把它简单地理解为两种黑箱:
<li>
第一种输入这些杂七杂八的信息比如Python 字典,输出一个字符串;
</li>
<li>
第二种,输入这个字符串,可以输出包含原始信息的 Python 字典。
</li>
具体代码如下:
```
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 = &lt;class 'str'&gt;, params_str = {'symbol': '123456', 'type': 'limit', 'price': 123.4, 'amount': 23}
after json deserialization
type of original_params = &lt;class 'dict'&gt;, original_params = {'symbol': '123456', 'type': 'limit', 'price': 123.4, 'amount': 23}
```
其中,
<li>
json.dumps() 这个函数,接受 Python 的基本数据类型,然后将其序列化为 string
</li>
<li>
而json.loads() 这个函数,接受一个合法字符串,然后将其反序列化为 Python 的基本数据类型。
</li>
是不是很简单呢?
不过还是那句话,请记得加上错误处理。不然,哪怕只是给 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 = &lt;class 'dict'&gt;, 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 序列化的基本知识,并通过具体的例子进一步掌握。再次强调一下需要注意的几点:
<li>
I/O 操作需谨慎,一定要进行充分的错误处理,并细心编码,防止出现编码漏洞;
</li>
<li>
编码时,对内存占用和磁盘占用要有充分的估计,这样在出错时可以更容易找到原因;
</li>
<li>
JSON序列化是很方便的工具要结合实战多多练习
</li>
<li>
代码尽量简洁、清晰,哪怕是初学阶段,也要有一颗当元帅的心。
</li>
## 思考题
最后,我给你留了两道思考题。
第一问你能否把NLP例子中的 word count 实现一遍不过这次in.txt 可能非常非常大(意味着你不能一次读取到内存中),而 output.txt 不会很大(意味着重复的单词数量很多)。
提示:你可能需要每次读取一定长度的字符串,进行处理,然后再读取下一次的。但是如果单纯按照长度划分,你可能会把一个单词隔断开,所以需要细心处理这种边界情况。
第二问你应该使用过类似百度网盘、Dropbox等网盘但是它们可能空间有限比如 5GB。如果有一天你计划把家里的 100GB 数据传送到公司,可惜你没带 U 盘,于是你想了一个主意:
每次从家里向 Dropbox 网盘写入不超过 5GB 的数据,而公司电脑一旦侦测到新数据,就立即拷贝到本地,然后删除网盘上的数据。等家里电脑侦测到本次数据全部传入公司电脑后,再进行下一次写入,直到所有数据都传输过去。
根据这个想法,你计划在家写一个 server.py在公司写一个 client.py 来实现这个需求。
提示:我们假设每个文件都不超过 5GB。
<li>
你可以通过写入一个控制文件config.json来同步状态。不过要小心设计状态这里有可能产生 race condition。
</li>
<li>
你也可以通过直接侦测文件是否产生,或者是否被删除来同步状态,这是最简单的做法。
</li>
不要担心难度问题,尽情写下你的思考,最终代码我也会为你准备好。
欢迎在留言区写下你的答案,也欢迎你把这篇文章转给你的同事、朋友,一起在思考中学习。

View File

@@ -0,0 +1,389 @@
<audio id="audio" title="07 | 修炼基本功:条件与循环" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/6d/f04354e70d7dff4762981b6d95b57b6d.mp3"></audio>
你好,我是景霄。
前面几节我们一起学习了列表、元组、字典、集合和字符串等一系列Python的基本数据类型。但是如何把这一个个基本的数据结构类型串接起来组成一手漂亮的代码呢这就是我们今天所要讨论的“条件与循环”。
我习惯把“条件与循环”,叫做编程中的基本功。为什么称它为基本功呢?因为它控制着代码的逻辑,可以说是程序的中枢系统。如果把写程序比作盖楼房,那么条件与循环就是楼房的根基,其他所有东西都是在此基础上构建而成。
毫不夸张地说,写一手简洁易读的条件与循环代码,对提高程序整体的质量至关重要。
## 条件语句
首先我们一起来看一下Python的条件语句用法很简单。比如我想要表示y=|x|这个函数,那么相应的代码便是:
```
# y = |x|
if x &lt; 0:
y = -x
else:
y = x
```
和其他语言不一样,我们不能在条件语句中加括号,写成下面这样的格式。
```
if (x &lt; 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
...
...
```
关于省略判断条件的常见用法,我大概总结了一下:
<img src="https://static001.geekbang.org/resource/image/94/45/949742df36600c086c31e399ce515f45.png" alt="">
不过切记在实际写代码时我们鼓励除了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 &lt;iterable&gt;:
...
```
这里需要单独强调一下字典。字典本身只有键是可迭代的如果我们要遍历它的值或者是键值对就需要通过其内置的函数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 &lt; 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 &lt; 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 &lt; 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 &gt;= 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 &lt; len(l):
print(l[index])
index += 1
```
那么,两者的使用场景又有什么区别呢?
通常来说如果你只是遍历一个已知的集合找出满足条件的元素并进行相应的操作那么使用for循环更加简洁。但如果你需要在满足某个条件前不停地重复某些操作并且没有特定的集合需要去遍历那么一般则会使用while循环。
比如某个交互式问答系统用户输入文字系统会根据内容做出相应的回答。为了实现这个功能我们一般会使用while循环大致代码如下
```
while True:
try:
text = input('Please enter your questions, enter &quot;q&quot; 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 &lt; 1000000:
i += 1
```
和等价的for循环
```
for i in range(0, 1000000):
pass
```
究竟哪个效率高呢?
要知道range()函数是直接由C语言写的调用它速度非常快。而while循环中的“i += 1”这个操作得通过Python的解释器间接调用底层的C语言并且这个简单的操作又涉及到了对象的创建和删除因为i是整型是immutablei += 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 &gt; 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()) &gt; 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))
```
熟练之后,你会发现这种写法非常方便。当然,如果遇到逻辑很复杂的复用,你可能会觉得写成一行难以理解、容易出错。那种情况下,用正常的形式表达,也不失为一种好的规范和选择。
## 总结
今天这节课,我们一起学习了条件与循环的基本概念、进阶用法以及相应的应用。这里,我重点强调几个易错的地方。
<li>
在条件语句中if可以单独使用但是elif和else必须和if同时搭配使用而If条件语句的判断除了boolean类型外其他的最好显示出来。
</li>
<li>
在for循环中如果需要同时访问索引和元素你可以使用enumerate()函数来简化代码。
</li>
<li>
写条件与循环时合理利用continue或者break来避免复杂的嵌套是十分重要的。
</li>
<li>
要注意条件与循环的复用,简单功能往往可以用一行直接完成,极大地提高代码质量与效率。
</li>
## 思考题
最后给你留一个思考题。给定下面两个列表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'}]
```
你能分别用一行和多行条件循环语句,来实现这个功能吗?
欢迎在留言区写下你的答案,还有你今天学习的心得和疑惑,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,325 @@
<audio id="audio" title="08 | 异常处理:如何提高程序的稳定性?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/f7/0c5b7acad86cebfb547c88b1cd22cef7.mp3"></audio>
你好,我是景霄。
今天这节课我想和你聊聊Python的异常处理。和其他语言一样异常处理是Python中一种很常见并且很重要的机制与代码规范。
我在实际工作中见过很多次这样的情况一位工程师提交了代码不过代码某处忘记了异常处理。碰巧这种异常发生的频率不低所以在代码push到线上后没多久就会收到紧急通知——服务器崩溃了。
如果事情严重,对用户的影响也很大,这位工程师还得去专门的会议上做自我检讨,可以说是很惨了。这类事件层出不穷,也告诉我们,正确理解和处理程序中的异常尤为关键。
## **错误与异常**
首先要了解Python中的错误和异常是什么两者之间又有什么联系和区别呢
通常来说,程序中的错误至少包括两种,一种是语法错误,另一种则是异常。
所谓语法错误,你应该很清楚,也就是你写的代码不符合编程规范,无法被识别与执行,比如下面这个例子:
```
if name is not None
print(name)
```
If语句漏掉了冒号不符合Python的语法规范所以程序就会报错`invalid syntax`
而异常则是指程序的语法正确也可以被执行但在执行过程中遇到了错误抛出了异常比如下面的3个例子
```
10 / 0
Traceback (most recent call last):
File &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
ZeroDivisionError: integer division or modulo by zero
order * 2
Traceback (most recent call last):
File &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
NameError: name 'order' is not defined
1 + [1, 2]
Traceback (most recent call last):
File &quot;&lt;stdin&gt;&quot;, line 1, in &lt;module&gt;
TypeError: unsupported operand type(s) for +: 'int' and 'list'
```
它们语法完全正确但显然我们不能做除法时让分母为0也不能使用未定义的变量做运算而让一个整型和一个列表相加也是不可取的。
于是,当程序运行到这些地方时,就抛出了异常,并且终止运行。例子中的`ZeroDivisionError` `NameError``TypeError`,就是三种常见的异常类型。
当然Python中还有很多其他异常类型比如`KeyError`是指字典中的键找不到;`FileNotFoundError`是指发送了读取文件的请求,但相应的文件不存在等等,我在此不一一赘述,你可以自行参考[相应文档](https://docs.python.org/3/library/exceptions.html#bltin-exceptions)。
## **如何处理异常**
刚刚讲到如果执行到程序中某处抛出了异常程序就会被终止并退出。你可能会问那有没有什么办法可以不终止程序让其照样运行下去呢答案当然是肯定的这也就是我们所说的异常处理通常使用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):
&quot;&quot;&quot;Exception raised when there're errors in input&quot;&quot;&quot;
def __init__(self, value): # 自定义异常类型的初始化
self.value = value
def __str__(self): # 自定义异常类型的string表达形式
return (&quot;{} is invalid input&quot;.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的异常处理及其使用场景你需要重点掌握下面几点。
<li>
异常通常是指程序运行的过程中遇到了错误终止并退出。我们通常使用try except语句去处理异常这样程序就不会被终止仍能继续执行。
</li>
<li>
处理异常时如果有必须执行的语句比如文件打开后必须关闭等等则可以放在finally block中。
</li>
<li>
异常处理通常用在你不确定某段代码能否成功执行也无法轻易判断的情况下比如数据库的连接、读取等等。正常的flow-control逻辑不要使用异常处理直接用条件语句解决就可以了。
</li>
## **思考题**
最后给你留一个思考题。在异常处理时如果try block中有多处抛出异常需要我们使用多个try except block吗以数据库的连接、读取为例下面两种写法你觉得哪种更好呢
第一种:
```
try:
db = DB.connect('&lt;db path&gt;') # 可能会抛出异常
raw_data = DB.queryData('&lt;viewer_id&gt;') # 可能会抛出异常
except (DBConnectionError, DBQueryDataError) err:
print('Error: {}'.format(err))
```
第二种:
```
try:
db = DB.connect('&lt;db path&gt;') # 可能会抛出异常
try:
raw_data = DB.queryData('&lt;viewer_id&gt;')
except DBQueryDataError as err:
print('DB query data error: {}'.format(err))
except DBConnectionError as err:
print('DB connection error: {}'.format(err))
```
欢迎在留言区写下你的答案,还有你今天学习的心得和疑惑,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,432 @@
<audio id="audio" title="09 | 不可或缺的自定义函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/8f/faa0b56c240a8f7b87424aab7a8f618f.mp3"></audio>
你好,我是景霄。
实际工作生活中我曾见到不少初学者编写的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
```
其中:
<li>
def是函数的声明
</li>
<li>
my_func是函数的名称
</li>
<li>
括号里面的message则是函数的参数
</li>
<li>
而print那行则是函数的主体部分可以执行相应的语句
</li>
<li>
在函数最后你可以返回调用结果return或yield也可以不返回。
</li>
总结一下,大概是下面的这种形式:
```
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 &gt; 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 &quot;str&quot;) 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 &lt; 0:
raise Exception('input must be greater or equal to 0' )
...
def inner_factorial(input):
if input &lt;= 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 &lt; MIN_VALUE or value &gt; 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 = &quot;local&quot;
def inner():
nonlocal x # nonlocal关键字表示这里的x就是外部函数outer定义的变量x
x = 'nonlocal'
print(&quot;inner:&quot;, x)
inner()
print(&quot;outer:&quot;, x)
outer()
# 输出
inner: nonlocal
outer: nonlocal
```
如果不加上nonlocal这个关键字而内部函数的变量又和外部函数变量同名那么同样的内部函数变量会覆盖外部函数的变量。
```
def outer():
x = &quot;local&quot;
def inner():
x = 'nonlocal' # 这里的x是inner这个函数的局部变量
print(&quot;inner:&quot;, x)
inner()
print(&quot;outer:&quot;, 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
# 输出
&lt;function __main__.nth_power.&lt;locals&gt;.exponent(base)&gt;
cube
# 输出
&lt;function __main__.nth_power.&lt;locals&gt;.exponent(base)&gt;
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函数的概念及其应用有这么几点你需要注意:
<li>
Python中函数的参数可以接受任意的数据类型使用起来需要注意必要时请在函数开头加入数据类型的检查
</li>
<li>
和其他语言不同Python中函数的参数可以设定默认值
</li>
<li>
嵌套函数的使用,能保证数据的隐私性,提高程序运行效率;
</li>
<li>
合理地使用闭包,则可以简化程序的复杂度,提高可读性。
</li>
## 思考题
最后给你留一道思考题。在实际的学习工作中,你遇到过哪些使用嵌套函数或者是闭包的例子呢?欢迎在下方留言,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,255 @@
<audio id="audio" title="10 | 简约不简单的匿名函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/c8/f8af5d5d9dd7b1a5199356821b258dc8.mp3"></audio>
你好,我是景霄。
上一节我们一起学习了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**
<li>
所谓的表达式,就是用一系列“公式”去表达一个东西,比如`x + 2``x**2`等等;
</li>
<li>
而所谓的语句,则一定是完成了某些功能,比如赋值语句`x = 1`完成了赋值print语句`print(x)`完成了打印,条件语句 `if x &lt; 0:`完成了选择功能等等。
</li>
因此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曾发了一篇[文章](https://www.artima.com/weblogs/viewpost.jsp?thread=147358)解释,你有兴趣的话可以自己阅读。
## 为什么要使用匿名函数?
理论上来说Python中有匿名函数的地方都可以被替换成等价的其他表达形式。一个Python程序是可以不用任何匿名函数的。不过在一些情况下使用匿名函数lambda可以帮助我们大大简化代码的复杂度提高代码的可读性。
通常,我们用函数的目的无非是这么几点:
<li>
减少代码的重复性;
</li>
<li>
模块化代码。
</li>
对于第一点,如果你的程序在不同地方包含了相同的代码,那么我们就会把这部分相同的代码写成一个函数,并为它取一个名字,方便在相对应的不同地方调用。
对于第二点,如果你的一块儿代码是为了实现一个功能,但内容非常多,写在一起降低了代码的可读性,那么通常我们也会把这部分代码单独写成一个函数,然后加以调用。
不过,再试想一下这样的情况。你需要一个函数,但它非常简短,只需要一行就能完成;同时它在程序中只被调用一次而已。那么请问,你还需要像常规函数一样,给它一个定义和名字吗?
答案当然是否定的。这种情况下,函数就可以是匿名的,你只需要在适当的地方定义并使用,就能让匿名函数发挥作用了。
举个例子如果你想对一个列表中的所有元素做平方操作而这个操作在你的程序中只需要进行一次用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的形式。至于这两种方式的选择
<li>
在数据量非常多的情况下,比如机器学习的应用,那我们一般更倾向于函数式编程的表示,因为效率更高;
</li>
<li>
在数据量不多的情况下并且你想要程序更加Pythonic的话那么list comprehension也不失为一个好选择。
</li>
不过如果你要对集合中的元素做一些比较复杂的操作那么考虑到代码的可读性我们通常会使用for循环这样更加清晰明了。
## 总结
这节课我们一起学习了Python中的匿名函数lambda它的主要用途是减少代码的复杂度。需要注意的是lambda是一个表达式并不是一个语句它只能写成一行的表达形式语法上并不支持多行。匿名函数通常的使用场景是程序中需要使用一个函数完成一个简单的功能并且该函数只调用一次。
其次我们也入门了Python的函数式编程主要了解了常见的map()fiilter()和reduce()三个函数并比较了它们与其他形式for循环comprehension的性能显然它们的性能效率是最优的。
## 思考题
最后,我想给你留下两道思考题。
第一问:如果让你对一个字典,根据值进行由高到底的排序,该怎么做呢?以下面这段代码为例,你可以思考一下。
```
d = {'mike': 10, 'lucy': 2, 'ben': 30}
```
第二问:在实际工作学习中,你遇到过哪些使用匿名函数的场景呢?
欢迎在留言区写下你的答案想法,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,377 @@
<audio id="audio" title="11 | 面向对象(上):从生活中的类比说起" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/47/c5183e54c60a16fdfb286100c0aae547.mp3"></audio>
你好,我是景霄。
很多朋友最开始学编程的时候,是从 C++ 或者 JAVA 语言入手的。他们好不容易磕磕绊绊地搞懂了最基本的数据类型、赋值判断和循环,却又迎面撞上了 OOP (object oriented programming) 的大墙,一头扎进公有私有保护、多重继承、多态派生、纯函数、抽象类、友元函数等一堆专有名词的汪洋大海中找不到彼岸,于是就放弃了进阶之路。
相比之下Python 是一门相对友好的语言它在创立之初就鼓励命令交互式的轻量级编程。理论上Python 的命令式语言是[图灵完备](https://zh.wikipedia.org/wiki/%E5%9C%96%E9%9D%88%E5%AE%8C%E5%82%99%E6%80%A7)的, 也就是说命令式语言,理论上可以做到其他任何语言能够做到的所有的事情,甚至进一步,仅仅依靠汇编语言的 MOV 指令,就能实现[图灵完备编程](http://stedolan.net/research/mov.pdf)。
那么为什么不这样做呢?其实,“上古时代”的程序员就是这么做的,可是随着程序功能复杂性的逐步提升,以及需求的不断迭代,很多老旧的代码修改起来麻烦无比,牵一发而动全身,根本无法迭代和维护,甚至只能推倒重来,这也是很多古老的代码被称为“屎山”的原因。
传统的命令式语言有无数重复性代码,虽然函数的诞生减缓了许多重复性,但随着计算机的发展,只有函数依然不够,需要把更加抽象的概念引入计算机才能缓解(而不是解决)这个问题,于是 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)
&lt;ipython-input-5-b4d048d75003&gt; in &lt;module&gt;()
22 print(harry_potter_book.get_context_length())
23
---&gt; 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)
&lt;ipython-input-5-b4d048d75003&gt; in &lt;module&gt;()
22 print(harry_potter_book.get_context_length())
23
---&gt; 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 代表当前对象的引用,可以通过此函数,来实现想要的查询/修改类的属性等功能。
## 继承,是每个富二代的梦想
接下来,我们来看第三个问题,既然类是一群相似的对象的集合,那么可不可以是一群相似的类的集合呢?
答案是,当然可以。只要抽象得好,类可以描述成任何事物的集合。当然你要小心、严谨地去定义它,不然一不小心就会引起[第三次数学危机](https://en.wikipedia.org/wiki/Russell%27s_paradox) 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**()函数中显式调用父类的构造函数。它们的执行顺序是 子类的构造函数 -&gt; 父类的构造函数。
其次需要注意父类 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)
&lt;ipython-input-7-266b2aa47bad&gt; in &lt;module&gt;()
21 print(document.get_title())
22
---&gt; 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 提出之后开发组和产品组首先会召开产品设计会PMProduct Manager产品经理 写出产品需求文档然后迭代TLTeam Leader项目经理编写开发文档开发文档中会定义不同模块的大致功能和接口、每个模块之间如何协作、单元测试和集成测试、线上灰度测试、监测和日志等等一系列开发流程。
抽象类就是这么一种存在,它是一种自上而下的设计风范,你只需要用少量的代码描述清楚要做的事情,定义好接口,然后就可以交给不同开发人员去开发和对接。
## 总结
到目前为止,我们一直在强调一件事情:面向对象编程是软件工程中重要的思想。正如动态规划是算法中的重要思想一样,它不是某一种非常具体的技术,而是一种综合能力的体现,是将大型工程解耦化、模块化的重要方法。在实践中要多想,尤其是抽象地想,才能更快掌握这个技巧。
回顾一下今天的内容,我希望你能自己回答下面两个问题,作为今天内容的总结,写在留言区里。
第一个问题,面向对象编程四要素是什么?它们的关系又是什么?
第二个问题,讲了这么久的继承,继承究竟是什么呢?你能用三个字表达出来吗?
>
这里不开玩笑Facebook 很多 Launch Doc (上线文档)中要求用五个单词总结你的文档,因为你的文档不仅仅是你的团队要看,往上走甚至会到 VP 或者 CTO 那里,你需要言简意赅,让他们快速理解你想要表达的意思。
## 思考题
最后,再给你留一道思考题。既然你能通过继承一个类,来获得父类的函数和属性,那么你能继承两个吗?答案自是能的,这就叫做多重继承。那么问题来了。
我们使用单一继承的时候,构造函数的执行顺序很好确定,即子类-&gt;父类-&gt;爷类-&gt;… 的链式关系。不过,多重继承的时候呢?比如下面这个例子。
```
---&gt;B---
A- --&gt;D
---&gt;C---
```
这种继承方式叫做菱形继承BC 继承了 A然后 D 继承了 BC创造一个 D 的对象。那么,构造函数调用顺序又是怎样的呢?
欢迎在留言区写下你的答案想法,与我讨论,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,438 @@
<audio id="audio" title="12 | 面向对象(下):如何实现一个搜索引擎?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/49/d7fd64a6d48eb01d0fc41bf94a582449.mp3"></audio>
你好,我是景霄。这节课,我们来实现一个 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: &quot;Free at last! Free at last! Thank God Almighty, we are free at last!&quot;
```
我们先来定义 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 即可。根据齐夫定律Zipfs law[https://en.wikipedia.org/wiki/Zipf%27s_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](https://en.wikipedia.org/wiki/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 &gt;= 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 -&gt; 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](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 代码的情况下。这部分内容希望你多读几遍,自己揣摩清楚,通过这个例子多多体会继承的优势。
## 总结
今天这节课是面向对象的实战应用,相比起前面的理论知识,内容其实不那么友好。不过,若你能静下心来,仔细学习,理清楚整个过程的要点,对你理解面向对象必将有所裨益。比如,你可以根据下面两个问题,来检验今天这节课的收获。
- 你能把这节课所有的类的属性和函数抽取出来,自己在纸上画一遍继承关系吗?
- 迭代开发流程是怎样的?
其实于我而言,通过构造搜索引擎这么一个例子来讲面向对象,也是颇费了一番功夫。这其中虽然涉及一些搜索引擎的专业知识和算法,但篇幅有限,也只能算是抛砖引玉,你若有所收获,我便欣然满足。
## 思考题
最后给你留一道思考题。私有变量能被继承吗?如果不能,你想继承应该怎么去做呢?欢迎留言与我分享、讨论,也欢迎你把这篇文章分享给你的同事、朋友,一起交流与进步。

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="13 | 搭建积木Python 模块化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/0a/3d96f70bcd4066d9bcf5ff4fd47f9c0a.mp3"></audio>
你好,我是景霄。
这是基础版块的最后一节。到目前为止,你已经掌握了 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.pyEncoder 和 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(&quot;..&quot;)
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](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.pyimport 函数在执行的时候,都会去项目根目录中找相应的包。
你说,你想修改下,使得普通的 Python 运行环境也能做到?这里有两种方法可以做到:
```
import sys
sys.path[0] = '/home/ubuntu/workspace/your_projects'
```
第一种方法,“大力出奇迹”,我们可以强行修改这个位置,这样,你的 import 接下来肯定就畅通无阻了。但这显然不是最佳解决方案,把绝对路径写到代码里,是我非常不推荐的方式(你可以写到配置文件中,但找配置文件也需要路径寻找,于是就会进入无解的死循环)。
第二种方法,是修改 PYTHONHOME。这里我稍微提一下 Python 的 Virtual Environment虚拟运行环境。Python 可以通过 Virtualenv 工具,非常方便地创建一个全新的 Python 运行环境。
事实上,我们提倡,对于每一个项目来说,最好要有一个独立的运行环境来保持包和模块的纯净性。更深的内容超出了今天的范围,你可以自己查资料了解。
回到第二种修改方法上。在一个 Virtual Environment 里,你能找到一个文件叫 activate在这个文件的末尾填上下面的内容
```
export PYTHONPATH=&quot;/home/ubuntu/workspace/your_projects&quot;
```
这样,每次你通过 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 来构建模块化和大型工程。这里需要强调几点:
1. 通过绝对路径和相对路径,我们可以 import 模块;
1. 在大型工程中模块化非常重要,模块的索引要通过绝对路径来做,而绝对路径从程序的根目录开始;
1. 记着巧用`if __name__ == '__main__'`来避开 import 时执行。
## 思考题
最后,我想为你留一道思考题。`from module_name import *``import module_name`有什么区别呢?欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友。

View File

@@ -0,0 +1,218 @@
<audio id="audio" title="14 | 答疑(一):列表和元组的内部实现是怎样的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/02/4ebb5414c2c5e6a15be30d87509e2602.mp3"></audio>
你好,我是景霄。
转眼间,专栏上线已经一个月了,而我们也在不知不觉中完成了第一大章基础篇的学习。我非常高兴看到很多同学一直在坚持积极地学习,并且留下了很多高质量的留言,值得我们互相思考交流。也有一些同学反复推敲,指出了文章中一些表达不严谨或是不当的地方,我也表示十分感谢。
大部分留言,我都在相对应的文章中回复过了。而一些手机上不方便回复,或是很有价值很典型的问题,我专门摘录了出来,作为今天的答疑内容,集中回复。
## 问题一:列表和元组的内部实现
第一个问题是胡峣同学提出的有关列表list和元组tuple的内部实现想知道里边是linked list 或array还是把array linked一下这样的方式
<img src="https://static001.geekbang.org/resource/image/8f/a2/8fb9cf6bf14357104c88454eefaaeca2.png" alt="">
关于这个问题,我们可以分别从源码来看。
先来看 Python 3.7 的list源码。你可以先自己阅读下面两个链接里的内容。
listobject.h[https://github.com/python/cpython/blob/949fe976d5c62ae63ed505ecf729f815d0baccfc/Include/listobject.h#L23](https://github.com/python/cpython/blob/949fe976d5c62ae63ed505ecf729f815d0baccfc/Include/listobject.h#L23)
listobject.c: [https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/listobject.c#L33](https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/listobject.c#L33)
我把 list的具体结构放在了下面
<img src="https://static001.geekbang.org/resource/image/99/e0/99e356ee9b00e645004879b9837c3ee0.png" alt="">
可以看到list本质上是一个over-allocate的array。其中ob_item是一个指针列表里面的每一个指针都指向列表的元素。而 allocated则存储了这个列表已经被分配的空间大小。
需要注意的是allocated 与列表实际空间大小的区别。列表实际空间大小是指len(list)返回的结果即上述代码注释中的ob_size表示这个列表总共存储了多少个元素。实际情况下为了优化存储结构避免每次增加元素都要重新分配内存列表预分配的空间allocated往往会大于ob_size详见正文中的例子
所以,它们的关系为:`allocated &gt;= 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](https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Include/tupleobject.h#L25)
tupleobject.c[https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/tupleobject.c#L16](https://github.com/python/cpython/blob/3d75bd15ac82575967db367c517d7e6e703a6de3/Objects/tupleobject.c#L16)
同样的下面为tuple的具体结构
<img src="https://static001.geekbang.org/resource/image/5b/d2/5b038b1819ee122b6309b5c5bae456d2.png" alt="">
你可以看到它和list相似本质也是一个array但是空间大小固定。不同于一般arrayPython的tuple做了许多优化来提升在程序中的效率。
举个例子当tuple的大小不超过20时Python就会把它缓存在内部的一个free list中。这样如果你以后需要再去创建同样的tuplePython就可以直接从缓存中载入提高了程序运行效率。
## 问题二:为什么在旧哈希表中,元素会越来越稀疏?
第二个问题是Hoo同学提出的为什么在旧哈希表中元素会越来越稀疏
<img src="https://static001.geekbang.org/resource/image/cf/6b/cf241621f373b0e3712f3e0fcc71896b.png" alt="">
我们可以先来看旧哈希表的示意图:
```
--+-------------------------------+
| 哈希值 (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”异常的困惑。这是很常见的一个错误我在这里也解释一下。
<img src="https://static001.geekbang.org/resource/image/48/6e/48c46d4a66e5c002ce392d79deee436e.png" alt="">
这个问题其实有点tricky如果你查阅[官方文档](https://docs.python.org/3/reference/compound_stmts.html#the-try-statement)会看到这么一句话”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同学他提了两个问题
1. Python自己判断类型的多态和子类继承的多态Polymorphism是否相同
1. 函数内部不能直接用+=等修改全局变量但是对于list全局变量却可以使用append、extend之类修改这是为什么呢?
<img src="https://static001.geekbang.org/resource/image/aa/0c/aa20a535ce703ef0fe0f1291877f960c.png" alt="">
我们分别来看这两个问题。对于第一个问题,要搞清楚多态的概念,多态是指有多种不同的形式。因此,判断类型的多态和子类继承的多态,在本质上都是一样的,只不过你可以把它们理解为多态的两种不同表现。
再来看第二个问题。当全局变量指向的对象不可变时比如是整型、字符串等等如果你尝试在函数内部改变它的值却不加关键字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)`并没有改变变量xx依然指向原来的列表。事实上这句话的意思是访问x指向的列表并在这个列表的末尾增加2。
今天主要回答这些问题,同时也欢迎你继续在留言区写下疑问和感想,我会持续不断地解答。希望每一次的留言和答疑,都能给你带来新的收获和价值。