💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
[TOC] 我们现在手上有一个设计,并准备把这个设计变成一个可用的程序!当然,通常这并不容易做到。我们将在整本书中看到一些例子和关于良好软件设计的提示,但我们的重点是面向对象编程。那么,让我们来看一下python语法,它允许我们创建面向对象软件。 </b> 完成本章后,我们将理解: * 如何在Python中创建类和实例化对象 * 如何向Python对象添加属性和行为 * 如何将类组织成包和模块 * 如何建议人们不要破坏我们的数据 ## 创建python类 我们不必编写太多的python代码,就可以感受到python是一个非常“干净”的语言。当我们想做某件事的时候,我们就做,而不必通过很多设置去做。正如你所看到的,在python中无处不在的“hello world”,通常只有一行。 </b> python 3中最简单的类如下所示: ``` class MyFirstClass: pass ``` 这是我们的第一个面向对象程序!类定义从关键字`class`开始。后面跟着一个(我们选择的)名称来标识类,并且以冒号结尾。 > 类名必须遵循标准的python变量命名规则(它必须以字母或下划线开头,并且只能由字母、下划线或数字)。此外,python样式指南(在网上搜索“PEP 8”)建议将类命名为使用驼峰形式(以大写字母开头;任何后续字母单词也应该以大写字母开头)。 在类定义行后是缩进的类内容。和其他python结构一样,缩进用于分隔类,而不是像其他语言一样使用大括号或括号。一般使用四个空格作为缩进,除非有一个令人信服的理由不这样做(例如加入别人使用制表符进行缩进的代码)。任何像样的编程编辑器都可以进行二次配置,每次按Tab键时可以插入四个空格。 </b> 因为我们第一个类实际上不做任何事情,所以我们在第二行使用pass关键字,表示无需采取进一步行动。 </b> 我们可能认为用这个最基本的类我们做不了什么,但实际上我们可以实例化该类的对象。我们可以将这个类加载到python 3解释器中,这样我们就可以交互地使用它。为此,请在前面提到的类定义保存到名为`first_class.py`的文件中,然后运行命令`python -i first_class.py`。-i参数告诉python“运行代码然后转到交互式解释器。以下解释器会话演示了与此类的基本交互过程: ``` >>> a = MyFirstClass() >>> b = MyFirstClass() >>> print(a) <__main__.MyFirstClass object at 0xb7b7faec> >>> print(b) <__main__.MyFirstClass object at 0xb7b7fbac> >>> ``` 这段代码实例化了新类的两个对象,a和b。创建类的实例是一件简单的事情,即键入类名后,再添加一对括号。它看起来很像一个普通的函数调用,但python知道我们正在“调用”一个类而不是一个函数,因此它理解它的工作是创建一个新的对象。打印后,这两个对象告诉我们它们是哪个类以及它们在内存中的地址。在python代码中,内存地址的使用并不多,但是这里,它们表明有两个不同的对象。 > **下载示例代码** 如果你已从 http://www.packtpub.com 中购买了这本书,你可以下载这本书的示例代码文件。如果你在别处购买了这本书,你可以访问 http://www. packtpub.com/support 并注册,出版社会通过电子邮件发送文件给你。 ### 添加属性 现在,我们有一个基本类,但它相当无用。它不包含任何数据,而且什么都不能做。我们应该怎样给对象分配一个属性呢? </b> 我们仍然可以不用在定义类环节做任何特别的事情。我们可以使用点表示法在已经实例化的对象上设置任意属性: ``` class Point: pass p1 = Point() p2 = Point() p1.x = 5 p1.y = 4 p2.x = 3 p2.y = 6 print(p1.x, p1.y) print(p2.x, p2.y) ``` 如果我们运行此段代码,结尾的两个`print`语句将告诉我们两个对象上的新属性值: ``` 5 4 3 6 ``` 此代码创建一个没有数据或行为的空的`Point`类。然后它创建了该类的两个实例,并分配给每个实例x和y坐标去识别一个二维空间中的点。我们要做的是,使用`<object>.<attribute>=<value>`语法,为对象的属性进行赋值。这有时被称为**点号表示**。值可以是任何东西:python主类型、内置数据类型,或者其他对象。它甚至可以是一个函数或另一个类! ### 让它做点什么 到目前为止,我们已经拥有具有属性的对象,这看起来很不错,但面向对象编程的核心是有关对象之间的相互作用的。我们感兴趣的是,调用方法(行为),使得对象属性发生变化。是时候向我们的类中添加行为了。 </b> 让我们在`Point`类上构建几个方法。我们先构建一个重置`reset`方法,将点移动到原点(原点是x和Y都是零)。这是一个很好的入门方法,因为它不需要任何参数: ``` class Point: def reset(self): self.x = 0 self.y = 0 p = Point() p.reset() print(p.x, p.y) ``` 这个`print`语句向我们展示了两个属性都为零: `0 0` python中的方法的格式与函数的格式相同。它以关键字`def`开头,后跟空格和方法名。接下来是一组包含参数列表的括号(我们随后再讨论`self`参数的意义),以冒号结束。下一行缩进,以包含方法内部的语句。这些语句可以是任意的python代码,包括对对象本身的操作,以及对方法所看到的传入的任何参数进行操作。 #### 扪心自问 方法和正常函数的一个区别是所有方法都有一个必需的参数。这个参数按惯例被称为`self`,我从未见过程序员对这个变量使用任何其他名称(约定是非常强大的事情)。但是,没有什么能阻止你称它为`this`,甚至是`Martha`。 </b> 方法的`self`变量仅仅意味着该方法所指向的对象。我们可以访问对象的属性或方法,就好像这个对象是其他任何对象。这正是我们在重置`reset`方法中所做的,我们设置了`self`对象的x和y属性。 </b> 注意,当我们调用`p.reset()`方法时,不需要传递`self`参数。python会自动为我们处理这个问题。它知道我们在调用p对象上的一个方法,因此它自动将该对象传递给该方法。 </b> 然而,方法实际上只是一个恰好位于类上的函数。除了对对象调用方法外,我们还可以调用类上的函数,将我们的对象作为`self`参数显式传递: ``` p = Point() Point.reset(p) print(p.x, p.y) ``` 输出结果与前一个示例相同,因为内部发生了同样的过程。 </b> 如果我们忘记把`self`包括在我们的类定义中,那会发生什么呢?python将返回一条错误消息: ``` >>> class Point: ... def reset(): ... pass ... >>> p = Point() >>> p.reset() Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: reset() takes no arguments (1 given) ``` 错误信息并不像我们希望的那样清晰(例如,“你这个愚蠢的傻瓜,你忘了`self`变量”,将更清楚一些)。记住,当你看到显示缺少参数的错误消息,首先要检查的是,看看方法定义中是不是忘记了`self`。 #### 更多参数 那么,我们如何将多个参数传递给一个方法呢?让我们添加一个新方法,允许我们将一个点移动到任意位置,而不仅仅是移动到原点。我们可以还包括一个点接受另一个点对象作为输入并返回它们之间的距离: ``` import math class Point: def move(self, x, y): self.x = x self.y = y def reset(self): self.move(0, 0) def calculate_distance(self, other_point): return math.sqrt((self.x - other_point.x)**2 + (self.y - other_point.y)**2) # 如何使用: point1 = Point() point2 = Point() point1.reset() point2.move(5,0) print(point2.calculate_distance(point1)) assert (point2.calculate_distance(point1) == point1.calculate_distance(point2)) point1.move(3,4) print(point1.calculate_distance(point2)) print(point1.calculate_distance(point1)) ``` 最后的print语句给出了以下输出: ``` 5.0 4.472135955 0.0 ``` 这里发生了很多事。`Point`类现在有三个方法。`move`方法接受x和y这两个参数,并用于设置`self`对象的值,就像上一个示例中的重置`reset`方法。旧的重置`reset`方法现在调用`move`方法,因为重置`reset`只是移动到特定的已知位置。 </b> `calculate_distance`方法使用不太复杂的勾股定理计算两点之间的距离。我希望你理解数学(**表示平方,math.sqrt计算平方根),但这不是必须要掌握的,我们目前的焦点是学习如何写方法。 </b> 前面示例末尾显示了如何调用带参数的方法:只需在括号内包含参数,并使用使用相同的点标记来访问该方法。我只是随便选了几个位置来测试方法。测试代码调用每个方法并在控制台上打印结果。断言函数是一个简单的测试工具;如果断言为假(或零、空或无),程序将终止。在这种情况下,我们使用它来确保用`calculate_distance`方法计算A点到B点或B点到A点之间的距离,都是相同的。 ### 初始化对象 如果我们没有在`Point`对象上显式设置x和y坐标,无论是使用`move`或直接访问它们,我们将获得一个没有实际位置的`broken`点。当我们试图访问它时会发生什么? </b> 好吧,我们试试看。“试试看”对于学习Python来说是一个非常有用的工具。打开交互式解释器并键入程序。以下互动会话显示如果我们尝试访问缺失的属性时会发生什么。如果你将上一个示例保存作为一个文件,或者正在使用随书分发的示例,可以使用命令`python -i filename.py`将其加载到python解释器中: ``` >>> point = Point() >>> point.x = 5 >>> print(point.x) 5 >>> print(point.y) Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'Point' object has no attribute 'y' ``` 好吧,至少它抛出了一个有用的异常信息。我们将在第4章“异常处理”里详细介绍异常。你可能以前见过它们(尤其是无处不在的**SyntaxError**,意味着你键入的内容不正确!)。目前为止,我们只要简单地记住这意味着程序出了问题。 </b> 输出对于调试很有用。在交互式解释器中,它告诉我们**第1行**出错,该错误仅部分为真(在交互式会话中,一次仅执行一行)。如果我们运行一个文件脚本,它会告诉我们准确的行号,便于查找有问题的代码。此外,它还告诉我们错误是一个属性错误`AttributeError`,并提供了一个有用的消息告诉我们错误意味着什么。 </b> 我们可以捕获这个错误,并从中恢复。但在这个例子中,似乎我们应该指定某种默认值。也许每个新对象都应该默认执行`reset()`方法,或者要求用户在创建对象时告诉我们对象的位置应该在哪里。 </b> 大多数面向对象编程语言都有**构造函数**的概念,一种创建并初始化对象的特殊方法。python有点不同;它有一个构造函数*和*一个初始化函数。一般很少使用构造函数,除非你在做异国情调的事情。所以,我们讨论初始化方法。 </b> python初始化方法与其他方法相同,只是它有一个特殊名称,`__init__`。前缀和尾随双下划线表示一种特殊的方法,Python解释器将其视为特殊情况。 > 永远不要用前缀和尾随双下划线来命名自己的函数。对python来说可能没有什么影响,但python的设计者可能在将来添加一个和你的函数同名的,有特殊目的函数。当他们这样做的时候,你的代码就崩溃了。 让我们从`Point`类上的初始化函数开始,该函数要求用户在实例化`Point`对象时提供x和y坐标: ``` class Point: def __init__(self, x, y): self.move(x, y) def move(self, x, y): self.x = x self.y = y def reset(self): self.move(0, 0) # 创建一个 Point 对象 point = Point(3, 5) print(point.x, point.y) ``` 现在,没有y坐标,我们的点对象是走不了的!如果我们试图创建一个不包含正确初始化参数的对象,我们将得到`not enough arguments`的错误信息,类似于早先我们忘记添加`self`参数的例子。 </b> 如果我们不想每次一定要输入这两个参数呢?好吧,我们可以使用与python函数相同的语法来提供默认参数。这个键参数语法在每个变量名后附加一个等号(原文拗口,不如直接说:变量名后附加一个等号和默认变量值)。如果调用对象时不提供参数,则使用默认参数值替代。变量仍然对函数可用,只是它们将具有参数列表中指定的值。下面是一个例子: ``` class Point: def __init__(self, x=0, y=0): self.move(x, y) ``` 大多数时候,我们把初始化语句放在一个`__init__`函数中。但前面提到过,除了初始化函数之外,python还有一个构造函数。你可能永远不需要使用python构造函数,但了解它会有所帮助。所以我们大致说一下它。 </b> 这个构造函数被称为`__new__`,与`__init__`差不多,构造函数只接受一个参数,即正在构造的类(因为是在对象之前调用`__new__`函数,所以没有`self`参数)。它还必须返回新创建的对象。当谈到复杂的元编程,或许有些益处,但在日常编程中不是很实用。实践中,我们很少需要,如果一定要用的话,使用`__new__`和 `__init__`就够用了。 ### 自我解释 python是一种非常容易阅读的编程语言;有些人可能会说它是自动文档化的。然而,在进行面向对象编程时,编写API文档是非常重要的,目的是清楚地描述每个对象和方法。使文档保持最新是困难的,最好的方法是把文档写进我们的代码中。 </b> python通过使用**文档字符串**来支持这一点。每个类、函数或方法头部可以有一个标准的python字符串作为第一行,随后接着是定义行(以冒号结尾的那一行)。文档字符串行应该和随后的代码有相同的缩进。 </b> 文档字符串只是用单引号(')或双引号(")括起来的python字符串。 通常,文档字符串相当长,跨越多行(样式指南建议行长度不超过80个字符),这可以多行字符串格式来处理,用匹配的三个单引号(''')或三引号(""")字符括起来。 </b> 文档字符串应该清楚、简洁地概括类的目的,或者它所描述的方法。它应该解释那些用法不是很清晰的参数,而且适时包括一些关于如何使用API的简短的例子。任何一个毫无戒心的API用户应该意识到的警告或问题应该被备注。 </b> 为了说明文档字符串的用法,我们给`Point`类编写一段完整的文档,作为本节的结束: ``` import math class Point: '代表一个拥有二维几何坐标的点' def __init__(self, x=0, y=0): '''初始化一个新点的位置。 x和y坐标应该被提供, 如果不提供,默认位置为原点''' self.move(x, y) def move(self, x, y): "在2D空间,将点移动到一个新位置" self.x = x self.y = y def reset(self): '将点位置重置到原点: 0, 0' self.move(0, 0) def calculate_distance(self, other_point): """计算从这一点到作为参数传递的第二点之间的距离。 这个函数使用毕达哥拉斯定理来计算两点之间的距离。 返回的距离是一个浮点型数值。""" return math.sqrt( (self.x - other_point.x)**2 + (self.y - other_point.y)**2) ``` 尝试键入或加载这个脚本文件到交互式解释器(记住,命令是`python -i filename.py`)。然后,在python提示下输入`help(Point)<enter>`。 您应该看到类的格式良好的文档,如以下屏幕截图: ![](https://box.kancloud.cn/d306b939c1e51c420bc7c9d76394becf_668x513.png) ## 模块和包 现在,我们知道了如何创建类和实例化对象,但是我们如何组织它们呢?对于小程序,我们可以把所有的类放到一个文件中,并在文件末尾添加一段让它们交互的脚本。然而,随着我们项目的发展,寻找一个需要被很多类进行编辑的类,变得越来越困难。这时候可以借助模块来帮助我们。模块仍是一些简单的python文件。我们小程序中的单个文件是一个模块。两个python文件是两个模块。如果在同一文件夹中有两个文件,则可以从一个模块加载一个类,用在另外一个模块。 </b> 例如,如果我们正在构建一个电子商务系统,我们可能正在数据库中存储大量数据。我们可以将所有与访问数据库相关的类和函数放在一个单独的文件(并给它起个有意义的名字:`database.py`)。然后,我们其他模块(例如,客户模型、产品信息和库存)可以从该模块导入类以访问数据库。 </b> `import`语句用于从模块导入模块、特定类或函数。我们已经看到在`Point`类上的例子。我们使用`import`语句来获取python的内置模块`math`并用其`sqrt`函数计算距离。下面是一个具体的例子。假设我们有一个名为`database.py`的数据库模块,包含一个名为`database`的类,我们还有另一个名为`products.py`的产品模块,负责与产品相关的查询。我们暂时不需要思考关于这些模块的内容。我们只要知道`products.py`需要从`database.py`实例化数据库类,以便它可以对数据库中的`product`表执行查询事务。`import`语句语法有几种用于访问类的变体: ``` import database db = database.Database() # 在db上执行查询 ``` 此版本`import`语句将数据库模块导入到产品模块的命名空间(当前可访问模块或函数的名称列表),因此可以使用`database.<something>`的标记方法访问数据库模块中的类和方法。或者,我们可以使用`from…import`语法,只导入需要的那个类: ``` from database import Database db = Database() # 在db上执行查询 ``` 如果出于某种原因,产品模块已经有一个名为`Database`的类,为了不混淆这两个类,我们可以在产品模块中重命名这个类: ``` from database import Database as DB db = DB() # 在db上执行查询 ``` 我们还可以在一个语句中导入多个项。如果我们的数据库模块也 包含一个查询类,我们可以使用以下方法导入这两个类: ``` from database import Database, Query ``` 有些资料说,我们可以从数据库模块中导入所有类和函数: ``` from database import * ``` 但是千万不要这样做!每个有经验的Python程序员都会告诉你不要使用这种语法。这会破坏一些你的编程环境,比如“它把名称空间搞得一团糟“,这对初学者来说没什么好处。如果你想了解为什么要避免这种语法,可以使用它并在两年后尝试理解代码(然后你就会有多么痛的领悟了)。当然我们可以现在就解释,这样就不用浪费两年时间了。 </b> 当我们在文件顶部使用`from database import Database`显式导入`Database`类时,我们可以很容易地看到`Database`类的来源。我们可能稍后在文件中第400行使用`db=Database()`,然后快速查看`from database import Database`行以查看该`Database`类的来源。如果我们需要了解如何使用`Database`类,我们就可以访问原始文件(或在交互式解释器中导入`database`模块,然后使用`help(database.database)`命令)。但如果使用`from database import *`语法,我们如何知道`Database`类是来自`database`模块呢?这导致代码维护变成了一场噩梦。 </b> 此外,大多数编辑器都能够提供额外的功能,例如可靠的代码补全,跳转到类的定义或内联文档的能力,而`import *`语法通常会完全破坏编辑器的这些能力。 </b> 最后,使用`import *`语法可能将一些不需要的对象导入本地命名空间。当然,它将导入被导入模块中所有类和函数,但它还将导入被导入模块自身导入的任意类和函数! </b> 模块中使用的每个名称都应该来自一个特定的地方,不管它是不是在该模块中定义的,或从另一个模块显式导入。不应该有仿佛来自稀薄空气中的神奇变量。我们应该总是能够立即确定在当前命名空间中的这些变量的来源。我保证如果你使用`import *`这种邪恶的语法,总有一天你会非常沮丧“这个类到底从哪里来的?” ### 组织模块 随着项目发展成为包含越来越多模块的集合,我们可能发现我们想在模块级别上在抽象出网状的组织结构。然而,我们不能把模块放在模块里面;一个文件只能容纳一个文件,模块只不过是Python文件。 </b> 然而,文件可以放在文件夹中,模块也可以。一个包就是放在一个文件夹中的一些模块的集合。包的名称就是文件夹的名称。对于一个最简单的包,我们只需要告诉Python有个文件夹是一个包,请在这个文件夹中放置一个(通常是空的)命名为`__init__.py`文件。如果我们忘记了加这个文件,我们将无法从这个文件夹导入模块。 </b> 让我们把我们的模块放在工作文件夹的`ecommerce`包中,`ecommerce`包还包含一个启动程序的`main.py`文件。让我们在`ecommerce`包中再添加一个用于各种支付选项的包。文件夹层次结构将如下所示: ``` parent_directory/ main.py ecommerce/ __init__.py database.py products.py payments/ __init__.py square.py stripe.py ``` 当在包之间导入模块或类时,我们必须小心语法。在Python 3中,有两种方式导入模块:绝对导入和相对导入。 #### 绝对导入 **绝对导入**,如果我们想导入模块、函数或路径,我们得给出完整路径。如果我们需要访问`products`模块中的`Product`类,我们可以使用下面语法中的任何一个来进行绝对导入: ``` import ecommerce.products product = ecommerce.products.Product() ``` 或 ``` from ecommerce.products import Product product = Product() ``` 或 ``` from ecommerce import products product = products.Product() ``` `import`语句使用句点运算符来分隔包或模块。 </b> 这些语句适用于任何模块。我们可以在`main.py`、`database`模块或任意一个支付模块,实例化一个`Product`类。事实上,假设这些包对Python是可用的,我们就能够导入它们。例如,很多包安装在Python官网的包文件夹中,我们也可以自定义`PYTHONPATH`环境变量,动态告诉Python它应该从什么文件夹来搜索要导入的包和模块。 </b> 那么,有了这些选择,我们选择哪种语法?这取决于你的个人品味和手头上的应用程序。如果我想使用的`products`模块中有几十个类或函数,我通常使用`from ecommerce import products`语法,然后使用`products.Product`访问`Product`类。如果我们只是需要`products`模块的一两个类,我可以直接使用`from ecommerce.proucts import Product`导入它们。除非存在某种命名冲突(例如,我需要访问两个完全不同的都叫`products`的模块,我需要将它们分开),否则我个人不会经常使用第一种语法。做你想做的,让你的代码看起来更优雅。 #### 相对导入 当使用包中的相关模块时,指定完整路径似乎有点傻。我们已经知道我们的父模块叫什么,所以可以使用相对导入。相对导入基本上是一种通过相对当前模块的位置寻找类、函数或模块的方法。例如,如果我们当前在`products`模块中工作,我们希望从它旁边的`database`模块导入`Database`类,我们可以使用相对导入: ``` from .database import Database ``` `database`前面的句点表示“使用当前包中的`database`模块”。在这种情况下,当前包是我们正在编辑的`products.py`文件所在的包(译注:文件夹),即`ecommerce`包。 </b> 我们正在编辑`ecommerce.payments`包中的`paypal`模块,如果我们想“使用父包中的`database`模块”,这很容易通过两个点号来完成,如下所示: ``` from ..database import Database ``` 我们可以用更多的点号来进一步提升层次。当然,我们也可以从一边下去,从另一边后退。我们没有足够深刻的层次结构例子来正确地说明这一点,但是下面的导入是有效的。如果我们有一个包含`email`模块的`ecommerce.contact`包,我们可以把`email`模块中的`send_mail`函数导入到我们的`paypal`模块: ``` from ..contact.email import send_mail ``` 其中,两个点号表示`payments`包的父包,然后使用标准的`package.module`语法升到的`contact`包。 </b> 最后,我们可以直接从包中导入代码,而不仅仅是里面的模块。在这个例子中,我们有一个包含两个模块(`database`模块和`products`模块)的`ecommerce`包,`database`模块包含一个`db`变量,这个变量可以从很多地方进入。如果能使用`import ecommerce.db `导入岂不比` import ecommerce.database.db`更加方便? </b> 还记着`__init__.py`将目录定义为包这件事吗?这个文件可以包含我们喜欢的任何变量或类声明,它们将作为包的一部分。在我们的示例中,如果`ecommerce/__init__.py`文件包含以下行: ``` from .database import db ``` 那么,我们可以使用下面一行命令从`main.py`或任何其他文件访问`db`属性: ``` from ecommerce import db ``` `__init__`可能会有所帮助。这里,它就好像它是`ecommerce.py`文件,这时候它是模块而不是包。如果你所有的代码都在单个模块中,然后你决定将其分解,构建一个模块包。新包的`__init__.py`文件仍然可以作为与它对话的其他模块的主要联系人,而代码可以在内部被组织成几个不同的模块或子包。 </b> 我建议不要将所有代码放在`__init__.py`文件里。尽管如此。程序员们不期望在这个文件中出现实际的逻辑,这有点儿像`from x import *`,如果他们正在寻找某个特定的代码,他们可能会遇到麻烦,直到他们检查`__init__.py`后才能找到它们。 ## 组织模块内容 在任何一个模块中,我们都可以定义变量、类或函数。用它们存储全局状态是一种方便的方法,且不会出现命名空间冲突。例如,我们一直在将`Database`类导入各种模块,然后实例化它,然而,从`database`模块创建一个全局可用的`database`对象可能更有意义。`database`模块可能如下所示: ``` class Database: # 数据库的实现 pass database = Database() ``` 然后,我们可以使用我们讨论过的任何一种导入方法来访问这个`database`对象,例如: ``` from ecommerce.database import database ``` 这个刚刚定义的类存在一个问题。当第一次导入模块时,通常也是程序开始的时候,`database`对象会被立刻被创建。然而这并不总是理想的,因为连接到数据库可能需要一段时间,减慢启动速度,或者甚至可能还没有可用的数据库连接信息。我们可以延迟创建`database`对象,直到通过调用用于创建模块级变量的`initialize_database`函数: ``` class Database: # 数据库的实现 pass database = None def initialize_database(): global database database = Database() ``` `global`全局关键字告诉Python我们在`initialize_database`内部定义了一个模块级的`database`变量。如果我们没有指定`database`变量是全局变量的话,Python会创建一个新的局部变量,当方法退出时,这个局部变量被丢弃,模块级变量值保持不变。 </b> 如这两个示例所示,所有模块级代码都在导入时被执行。但是,如果模块级代码在方法或函数内部,虽然函数被创建了,但在调用函数之前,它的内部代码不会被执行。这对于即将执行的脚本来说可能是一件棘手的事情(比如我们电子商务示例中的主脚本)。通常,我们会编写一个程序(A)来做一些有用的事情,然后发现我们想在另外一个程序B从这个程序A中导入一个函数或类。然而,一旦我们导入它,模块级的任何代码都是立即执行。如果我们不小心,我们可能就运行了第一个程序A,而实际上我们只是想访问程序A内部的几个函数。 </b> 为了解决这个问题,我们应该总是把我们的启动代码放在一个函数中(按照惯例,调用`main`),且只有当我们知道我们正在运行脚本上的模块时,才真正运行这个函数,现在,当我们的代码是从不同的脚本导入时,我们该如何知道应该运行哪个脚本呢? ``` class UsefulClass: '''这个类可能对其他模块有用''' pass def main(): ''' 创建一个有用的类,用它为我们的模块做一些事情 ''' useful = UsefulClass() print(useful) if __name__ == "__main__": main() ``` 每个模块都有一个`__name__`特殊变量(记住,Python使用双下划线表示特殊变量,例如类的`__init__`方法),指定了模块导入时的名称。当使用`python module.py`直接执行模块时,这个模块将不被导入,因此`__name__`被设置为字符串`__main__`。用这个策略将你所有的脚本包裹起来,并置于`if __name__ ==" __main__":`的检测中,总有一天你会发现,当你写了一个可能会被其它代码导入的函数时,这将是很有用处的策略。 </b> 所以,方法放在类中,类放在模块中,模块放在包中。就这些吗? </b> 事实上,这只是Python程序中的典型顺序,但不是所有可能的布局。类可以在任何地方定义。它们通常定义在模块级里,但也可以在函数或方法中定义,如下所示: ``` def format_string(string, formatter=None): '''使用格式化对象格式化一个字符串,格式化对象 应具有接受字符串的format()方法。''' class DefaultFormatter: '''将字符串的首字母大写''' def format(self, string): return str(string).title() if not formatter: formatter = DefaultFormatter() return formatter.format(string) hello_string = "hello world, how are you today?" print(" input: " + hello_string) print("output: " + format_string(hello_string)) ``` 输出如下: ``` input: hello world, how are you today? output: Hello World, How Are You Today? ``` `format_string`函数接受一个字符串和可选的格式化对象,然后对该字符串进行格式化。如果没有提供格式化对象,则会创建一个格式化对象。因为它是在函数内部创建的,该类不能被该函数之外的任何地方访问。同样,函数也可以在其他函数内部中定义;一般来说,任何Python语句都可以随时被执行。 </b> 这些内部类和函数,偶尔对那些一次性项目有用,这些内部类或函数不需要或不值得在模块级别拥有自己范围,或者它们只在单一方法有意义。然而,这项技术在Python代码中已经不常见了。 ## 谁可以访问我的数据 大多数面向对象编程语言都有访问控制的概念。这与抽象有关。对象上的一些属性和方法被标记为私有,意味着只有对象本身可以访问它们。一些被标记为受保护,意味着只有对象所属的类和任何子类可以访问。其余的是公开的,意思是允许任何其他对象访问它们。 </b> python不会这么做。python并不真的相信必须要遵守的强制性规则。相反,它提供了非强制的指导和最佳实践。从技术上讲,类的所有方法和属性都是公开可用的。如果我们想建议不要公开使用某一种方法,我们可以在`docstrings`上注明,表示该方法仅用于内部(如果可以的话,最好添加API如何工作的解释!)。 </b> 按照惯例,我们还可以在属性或方法前加下划线前缀,`_`。Python程序员会将其解释为“这是一个内部变量,在直接访问它之前,请三思而后行”。但在解释器里面没有什么可以阻止访问它们,只要程序员们认为这样做符合他们的最大利益。况且如果他们想这样做,我们为什么要阻止他们?我们可能并不知道我们将如何使我们的类。 </b> 还有一件事你可以做,就是强烈建议外部对象不要访问有双下划线`__`前缀的属性或方法。这将在有问题的属性上执行命名矫正。这基本上意味着外部对象仍然可以调用这个方法,只要我们想这样做,但需要一些额外的工作才能实现。而且这将暗示你的属性将保持私有。例如: ``` class SecretString: '''一种不怎么安全的存储秘密字符串的方法''' def __init__(self, plain_string, pass_phrase): self.__plain_string = plain_string self.__pass_phrase = pass_phrase def decrypt(self, pass_phrase): '''仅仅当pass_phrase是正确的,才显示字符串''' if pass_phrase == self.__pass_phrase: return self.__plain_string else: return '' ``` 如果我们加载这个类并在交互式解释器中测试它,我们可以看到它对外部世界隐藏了纯文本字符串: ``` >>> secret_string = SecretString("ACME: Top Secret", "antwerp") >>> print(secret_string.decrypt("antwerp")) ACME: Top Secret >>> print(secret_string.__plain_text) Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'SecretString' object has no attribute '__plain_text' ``` 看起来很有效;没有密码,没有人能访问`plain_text`属性,所以它是安全的。不过,在我们过于兴奋之前,让我们看看如何很容易地侵入我们的安全系统: ``` >>> print(secret_string._SecretString__plain_string) ACME: Top Secret ``` 哦不!有人黑了我们的秘密字符串。幸好我们检查过了!这是python的命名矫正在起作用。当我们在属性前使用双下划线时,属性的前缀将变成`_<classname>'(单下划线+类名)。类中方法内部访问这种双下划线变量,它们不会被自动矫正。当外部类希望访问时,它们不得不自己动手进行命名矫正。所以,命名矫正并没有保证隐私,它只是推荐做法。大多数Python程序员不会在另一个对象上接触双下划线变量,除非它们有非常有说服力的理由这样做。 </b> 然而,大多数Python程序员在没有令人信服的理由下,也不会接触单个下划线变量。因此,没有什么好的理由在Python中使用命名矫正后的变量,这样做只会导致悲伤。例如,命名矫正后的变量可能对子类有用,但我们必须得自己动手矫正。如果其他对象想访问你的隐藏信息,就让他们访问好了,使用单下划线做前缀,或一些清晰的文档字符串,只是提醒他们,你并不认为这是个好主意。 ## 第三方工具库 Python附带了一个可爱的标准库,这是许多包和模块的集合,这个标准库对于运行Python的每台机器都是可用的。然而,你很快就会发现它并没有包含你需要的一切。当这种情况发生时,你有两个选择: * 自己写一个支持包 * 使用别人的代码 我们不会涉及将包转化为库的细节,但是如果你有一个需要解决的问题,你不想编码(最好的程序员非常懒惰,他们更喜欢重用现有的、经过验证的代码,而不是写他们自己的),你可以在**Python Package Index (PyPI)** [地址](http://pypi.python.org)中找到你想要的库。一旦你确定了你想要安装的库,可以使用名为`pip`的工具来安装它。然而,`pip`并不是Python自带的,但是Python 3.4包含一个名为`ensurepip`的有用工具,它将安装`pip`: ``` python -m ensurepip ``` 在Linux、苹果操作系统或其他Unix系统上,这可能会失败,在这种情况下,你需要成为根用户才能让它起作用。在大多数现代Unix系统上,这可以用`sudo python -m ensurepip`完成。 > 如果你使用的是比Python 3.4更旧的版本,因为不存在ensurepip,你得自己下载并安装`pip`。你可以按照[http://pip.readthedocs.org](http://pip.readthedocs.org)上的说明进行操作。 一旦`pip`安装完毕,并且你知道要安装的软件包的名称,你可以使用以下语法安装它: ``` pip install requests ``` 但是,如果你这样做,要么第三方库被直接安装在你的系统Python目录,或者更有可能的是,得到一个不允许这样做的错误。你可以作为管理员强制安装,但是Python社区的普遍共识是,你应该只使用系统安装程序将第三方库安装到你的系统Python目录中。 </b> 相反,Python 3.4提供了`venv`工具。这个实用程序在你的工作目录中,给了你一个基本的、迷你的、被称为*虚拟环境* 的Python安装环境。当你激活这个迷你Python,与Python相关的命令将在工作目录下而不是系统目录下运行。所以当你运行`pip`或`python`时,将不会碰到系统Python。以下是如何使用它: ``` cd project_directory python -m venv env source env/bin/activate # Linux 或 MacOS env/bin/activate.bat # Windows ``` 通常,你可以为你的每个Python项目创建不同的虚拟环境。你可以把你的虚拟环境存储在任何地方,但是记住虚拟环境与项目文件应该放在相同的目录里(这点在版本控制中被忽略),所以,我们先`cd`进入那个目录。然后我们运行`venv`来创建一个名为env的虚拟环境。最后,我们使用最后两行中的一行(取决于操作系统,如注释所示)来激活环境。当我们每次我们想使用这个特定的虚拟环境时,都需要执行这一行。然后,当我们想退出这个项目时,使用`deactivate`命令。 </b> 虚拟环境是一种很好的方法,可以建立不同的库依赖环境。如果你有很多项目,每个项目依赖不同版本的库,使用虚拟环境是很普遍的(例如,一个老网站可能在Django 1.5上运行,而较新的版本在Django 1.8上运行)。将每个项目放在不同的虚拟环境中,使得在Django的任何版本中工作都变得容易。此外,它还能防止系统安装包和`pip`安装包之间的冲突,如果尝试使用不同工具安装同一个包的话。 ## 案例研究 为了将它们联系在一起,让我们构建一个简单的命令行笔记本应用程序。这是一个相当简单的任务,所以我们不会尝试多个包。然而,我们会看到类、函数、方法和文档字符串的常见用法。 </b> 让我们快速分析一下:笔记是存储在笔记本中的简短备忘录。每个笔记应该记录它写的日期,并且可以添加标签以便于查询。修改笔记是有可能的。我们还需要能够搜索笔记的功能。所有这些都应该通过命令行完成。 </b> 很明显我们需要一个笔记对象`Note`;另外一个不太明显的是笔记本容器对象`Notebook`。标签和日期似乎也是对象,但是我们可以使用Python标准库中的日期,并用逗号分隔字符串作为标签。为了避免复杂性,在原型中,我们不为这些对象定义单独的类。 </b> `Note`对象具有备忘`memo`、标签`tags`和创建日期`creation_date`的属性。每个`note`对象还需要一个唯一的整数`id`,以便用户可以在菜单界面中选择它们。`Note`对象可能还需要方法,一种用于修改笔记内容,另一种方法修改标签,或者我们可以让笔记本直接访问这些属性。为了使搜索更容易,我们应该在`Note`对象添加匹配方法`match()`。这个方法将接受一个字符串,在不直接访问属性的情况下,告诉我们是否有`Note`与字符串匹配。这样,如果我们希望修改搜索参数(例如,搜索标签,而不是`Note`内容,或者为了使搜索不区分大小写),我们只需要在一个地方进行。 </b> 笔记本对象`Notebook`显然有个笔记列表的属性。它还需要一个搜索方法,用于返回过滤后的笔记列表。 </b> 但是我们如何与这些对象互动呢?我们已经指定了这是一个命令行应用程序,这意味着我们通过不同的添加或编辑命令运行程序,或者我们有某种菜单,允许我们选择对笔记本做不同的事情。我们应该尝试设计它,以便支持任何一个现在或未来的接口,如图形用户界面工具包或基于网络的接口,这些都可以在未来添加。 </b> 作为设计决策,我们先实现菜单接口,但保留命令行选项版本,以确保我们在设计`Notebook`类时仍然具有可扩展性。 </b> 如果我们有两个命令行接口,每个都与`Notebook`对象交互,那么`Notebook`需要一些方法来与这些接口进行交互。除了我们已经讨论过的搜索方法,我们还需要新建笔记的`add`方法、根据`id`修改笔记的`modify`方法。接口还需要能够列出所有笔记,但是我们可以通过直接访问`notes`列表属性来完成。 </b> 我们可能遗漏了一些细节,但我们对我们需要写的代码有一个大概的认识。我们可以用一个简单的类图来总结这一切: ![](https://box.kancloud.cn/6b4cb8f4fc32424ff7a0f3ca420ad329_476x391.png) 在编写任何代码之前,让我们先定义这个项目的文件夹结构。菜单接口显然应该在自己的模块中,因为它将是一个可执行脚本,将来我们可能会有其他可执行脚本访问笔记本。笔记本对象`Notebook`和笔记对象`notes`可以一起放在同一个模块中。这些模块都可以放在同一级目录中,而不必将它们放入包中。一个空的`command_option.py`提醒我们未来将添加新的用户界面。 ``` parent_directory/ notebook.py menu.py command_option.py ``` 现在让我们看看一些代码。我们从定义`notes`类开始,因为它看起来最简单。以下示例完整展现了定义`notes`的过程。示例中的文档字符串解释这一切是如何结合在一起的。 ``` import datetime # 为所有新的笔记存储下一个可用的id last_id = 0 class Note: '''代表笔记本中的一个笔记。在搜索中匹配一个字符串,为每个笔记存储标签''' def __init__(self, memo, tags=''): '''用memo和可选空格分离标签初始化一个笔记,自动设置笔记的创建日期和唯一的id''' self.memo = memo self.tags = tags self.creation_date = datetime.date.today() global last_id last_id += 1 self.id = last_id def match(self, filter): '''判定这个笔记是否匹配fliter文本。如果匹配返回True,否则False。 搜索是大小写敏感的,同时匹配文本和标签''' return filter in self.memo or filter in self.tags ``` 在继续之前,我们应该快速启动交互式解释器并测试我们刚刚写下的代码。测试要经常做,因为事情从来不会按照你期望的方式进行下去。事实上,当我测试这个例子的第一个版本时,我发现我忘记了`match`函数中的`self`参数!我们将在第10章*Python设计模式I*中讨论自动化测试。目前,使用解释器检查就足够了: ``` >>> from notebook import Note >>> n1 = Note("hello first") >>> n2 = Note("hello again") >>> n1.id 1 >>> n2.id 2 >>> n1.match('hello') True >>> n2.match('second') False ``` 看起来一切都像预期的那样。接下来让我们创建我们的笔记本: ``` class Notebook: '''代表可被标签、被修改和被搜索的笔记的集合''' def __init__(self): '''用一个空列表初始化一个笔记本''' self.notes = [] def new_note(self, memo, tags=''): '''创建一个新笔记,并把它加入列表中''' self.notes.append(Note(memo, tags)) def modify_memo(self, note_id, memo): '''找到给定id的笔记,并将memo改为给定的值''' for note in self.notes: if note.id == note_id: note.memo = memo break def modify_tags(self, note_id, tags): '''找到给定id的笔记,并将标签改为给定的值''' for note in self.notes: if note.id == note_id: note.tags = tags break def search(self, filter): '''发现匹配给定字符串的笔记''' return [note for note in self.notes if note.match(filter)] ``` 我们停一下。首先,让我们测试它,以确保它工作正常: ``` >>> from notebook import Note, Notebook >>> n = Notebook() >>> n.new_note("hello world") >>> n.new_note("hello again") >>> n.notes [<notebook.Note object at 0xb730a78c>, <notebook.Note object at 0xb73103ac>] >>> n.notes[0].id 1 >>> n.notes[1].id 2 >>> n.notes[0].memo 'hello world' >>> n.search("hello") [<notebook.Note object at 0xb730a78c>, <notebook.Note object at 0xb73103ac>] >>> n.search("world") [<notebook.Note object at 0xb730a78c>] >>> n.modify_memo(1, "hi world") >>> n.notes[0].memo 'hi world' ``` 确实有用。尽管代码有点混乱;我们的修改标签`modify_tags`和修改备忘录`modify_memo`的方法几乎相同。这不是好的编码实践。让我们看看我们如何改进它。 </b> 两种方法都试图在做某事之前通过给定的ID来识别某个笔记。认识到这一点,让我们添加一个方法来定位带有特定标ID的笔记。我们给这个方法名称加一个单下划线的前缀,表示该方法只能供内部使用,当然,我们的菜单接口仍然可以访问这个方法,如果它想访问的话: ``` def _find_note(self, note_id): '''定位给定id的笔记''' for note in self.notes: if note.id == note_id: return note return None def modify_memo(self, note_id, memo): '''找到给定id的笔记,并将memo改为给定的值''' self._find_note(note_id).memo = memo ``` 现在应该可以了。让我们看看菜单接口。接口只需要显示一个菜单,让用户输入选择。这是我们的第一次尝试: ``` import sys from notebook import Notebook, Note class Menu: '''显示一个菜单,当运行时,对选择作出反应''' def __init__(self): self.notebook = Notebook() self.choices = { "1": self.show_notes, "2": self.search_notes, "3": self.add_note, "4": self.modify_note, "5": self.quit } def display_menu(self): print(""" Notebook Menu 1. Show all Notes 2. Search Notes 3. Add Note 4. Modify Note 5. Quit """) def run(self): '''显示一个菜单,对选择作出反应''' while True: self.display_menu() choice = input("Enter an option: ") action = self.choices.get(choice) if action: action() else: print("{0} is not a valid choice".format(choice)) def show_notes(self, notes=None): if not notes: notes = self.notebook.notes for note in notes: print("{0}: {1}\n{2}".format( note.id, note.tags, note.memo)) def search_notes(self): filter = input("Search for: ") notes = self.notebook.search(filter) self.show_notes(notes) def add_note(self): memo = input("Enter a memo: ") self.notebook.new_note(memo) print("Your note has been added.") def modify_note(self): id = input("Enter a note id: ") memo = input("Enter a memo: ") tags = input("Enter tags: ") if memo: self.notebook.modify_memo(id, memo) if tags: self.notebook.modify_tags(id, tags) def quit(self): print("Thank you for using your notebook today.") sys.exit(0) if __name__ == "__main__": Menu().run() ``` 代码首先绝对导入notebook模块中的对象。相对导入将不起作用,因为我们没有将代码放入包中。菜单`Menu`类的运行`run`方法反复显示菜单,并通过调用notebook模块上的函数返回待选择项。对于Python来讲,这是相当奇特的习惯作法;这是我们将在(第10章Python设计模式I)讨论命令模式的轻量级版本。用户输入的选项是字符串。在菜单的`__init__`方法,我们创建了一个将字符串映射到菜单对象本身上的函数的字典。然后,当用户做出选择时,我们将从字典里检索对象。动作`action`变量实际上是指一种特定的方法,通过给变量附加空括号调用这个方法(这种方式产生的方法都没有参数)。当然,用户可能输入了不适当的选择,所以在调用它之前,我们检查这个变量是否真的存在。 </b> 各种方法都要求用户提供输入,并调用与输入关联的`Notebook`对象中适当的方法。对于搜索`search`实现,我们注意到,当我们过滤完笔记后,我们需要向用户展示结果,所以我们提供具有双重功能的`show_notes`函数;它接受一个可选的`notes`参数。如果提供了这个参数,它将显示过滤后的笔记清单,但如果没有,它将显示所有的笔记。由于`notes`参数是可选的,`show_notes`函数仍然可以作为空菜单项,在没有参数的情况下被调用。 </b> 如果我们测试这段代码,我们会发现修改笔记不起作用。这里有两个` bug`,即: * 当我们输入不存在的笔记ID时,笔记本崩溃。我们永远不应该相信我们的用户输入了正确的数据! * 即使我们输入了正确的ID,它也会崩溃,因为笔记ID是整数,但是我们的菜单传递了一个字符串。 后一个错误可以通过修改笔记本`Notebook`类的`_find_note`方法来解决。将输入参数和存储在整数ID转换为字符串后进行比较,如下所示: ``` def _find_note(self, note_id): '''定位给定id的笔记''' for note in self.notes: if str(note.id) == str(note_id): return note return None ``` 我们只需将输入(`note_id`)和笔记ID转换为字符串后,然后再比较它们。我们也可以将输入转换成整数,但是如果用户输入的是字母`a`而不是数字`1`,就会有问题。 </b> 用户输入不存在的笔记ID的问题可以通过更改两个`modify`方法来解决。在方法中,通过`_find_note`检查是否真的返回了一个笔记,像这样: ``` def modify_memo(self, note_id, memo): '''找到给定id的笔记,并将memo改为给定的值''' note = self._find_note(note_id) if note: note.memo = memo return True return False ``` 此方法已更新为返回真或假,具体取决于能否找到某个笔记。如果用户输入了无效笔记,菜单将使用这个返回值来显示一条错误信息。这个代码有点笨拙,如果能返回一个异常就好了,我们将在第4章“异常处理”中讲述这些。 ## 总结 在本章中,我们学习了在python中创建类和分配属性和方法是多么简单的事情。与许多语言不同,Python区分了构造函数和初始化函数。它对访问控制的态度很灵活。作用域有许多不同的级别,包括包、模块、类和函数。我们理解了相对导入和绝对导入之间的区别,以及如何管理Python的第三方包。 </b> 在下一章中,我们将学习如何使用继承来共享实现。