第2章 Python对象

第2章 Python对象

通过完成本章内容,我们将会学到:

  • 在Python中如何创建类并实例化对象。
  • 如何给Python对象添加属性和行为。
  • 如何把类组织成包和模块。
  • 如何建议别人不要错误使用我们的数据。

创建Python类

  我们不需要写过多代码就可以意识到Python是一门非常“简洁”的语言。当我们想做一件事情的时候,直接做就可以,不必要预先做很多的设置。一个普通的例子,就是用Python实现“hello world”,你会看到,只需要一行代码。
  同样,在Python3中一个最简单的类是这样实现的:

# OOP_c2_1.py
class MyFirstClass:
	pass

  这是我们第一个面向对象的程序!类的定义以关键字class开头。之后跟着一个名字(用户定义)用来标识这个类,并且以冒号结尾。

类的命名必须符合标准的Python变量命名规则(必须以字母或者下画线开头,名字中只能包含字母、下画线或者数字)。同时,Python代码风格指南(在网页搜索“PEP8”)建议类的名字应该使用驼峰式记法(以大写字母开头,并且随后紧跟的任意一个单词都要以大写字母开头)。

  因为我们第一个类并没有实际做什么事情,所以在第2行,我们简单地使用了pass这个关键词来表示不需要采取进一步的行动。
  我们可能会想,对于这么一个最基本的类,我们可能不能对它做什么,但是它确实允许我们实例化这个类的对象。我们可以在Python3的解释器里加载这个类,然后就可以交互式地操作它了。为了实现交互,我们把前面提到的这个类定义保存到一个叫first class.py的文件中,然后运行命令 python-i first_class.py。参数-i告诉Python“运行这段代码然后抛向交互式解释器”。下面这个解释器会话演示了与这个类的基本交互。

(root) >python -i OOP_c2_1.py
>>> a = MyFirstClass()
>>> b = MyFirstClass()
>>> a
<__main__.MyFirstClass object at 0x00000000026A8438>
>>> b
<__main__.MyFirstClass object at 0x00000000026A84A8>
>>>

  这段代码从这个新类里实例化了两个对象,名字为a和b。通过键入类的名字并紧跟一对小括号这种简单方式,就可以创建一个类的实例。这看起来像一个普通的函数调用,但是Python知道我们是在“调用”一个类而不是函数,所以它理解它的任务是创建一个新对象。当打印的时候,这两个对象会告诉我们它们是哪个类,以及在内存中的存放位置。在Python代码中不常使用内存地址,但是在这里,它证实了包含两个明显不同的对象。

添加属性

现在我们有了一个基本的类,但是它毫无用处。它不包含任何数据,并且也不做任何事情。我们如何做才能为一个给定对象赋予一个属性呢?
原来,在类的定义中,我们不必要做任何特殊的操作。我们可以通过点记法给一个实例化的对象赋予任意属性:

class Point:
	pass

p1 = Point()
p2 = Point()

p1.x = 5
p1.y = 4

p2.x = 3
p2.y = 6

print(p1.x, p1.y) # 5 4
print(p2.x, p2.y) # 3 6

  这段代码创建了一个没有任何数据和行为的空的Point类。然后它创建了这个point类的两个实例,并且给每个实例赋予一个x坐标和y坐标来标识一个二维空间的点。我们需要做的只是通过.=这个语法来为属性赋值。这种方式有时被称为点记法。这个值可以是任意的:一个Python原始的内置数据类型、其他的对象,甚至可以是一个函数或者另外一个类!

让类实际做一些事情

​ 现在,有一个带属性的对象确实是很棒的,但是面向对象编程真正的是对象之间的交互。我们感兴趣的是通过激发一些行为来引起这些属性的改变。是时候给我们的类加入一些行为了。
让我们来模拟这个Point类的一些行为。我们可以从一个叫reset的方法开始,这个方法用来把点移到原点(原点就是x坐标和y坐标都是0的点)。这是一个非常好的可以用来介绍的行为,因为它不需要任何参数:

class Point:
	def reset(self):
		self.x = 0
		self.y = 0

p = Point()
p.reset()
print(p.x, p.y) # 0 0

--->传参

class Point:
	def reset(self, x, y):
		self.x = x
		self.y = y

p = Point()
p.reset(4, 5)
print(p.x, p.y) # 4 5

  Python中的方法 (method) 和定义一个函数 (function) 相同,以关键字def开头,紧跟一个空格和方法名,方法名后紧跟一对小括号,括号内包含参数列表 (我们会在稍后讨论se1f这个参数) ,然后以冒号结尾。下面的行通过缩进方式包含了这个方法内部的语句,这些语句可以是任意的Python代码,它可以操作对象本身和任意传人的参数,当然只要这个方法认为这个参数合法。
  方法和普通函数有一点不同,就是所有方法都有一个必需的参数,这个参数通常被称为se1f,从来没有程序员采用其他名字来称呼这个变量 (习惯的力量很强大) 。即使如此,如果你想称它为this或者Martha,也不会有人阻止你。
  一个方法中的self参数,是对调用这个方法的对象的一个引用。我们可以和其他对象一样访问这个对象的属性和方法。当要改变self对象的x和y属性值时,正是我们通过调用内部 reset方法实现的。
  你会注意到,当我们调用p.reset () 方法时,并没有给它传入se1f参数。Python自动帮我们做了,它知道我们正在调用p对象的一个方法,所以它自动地把这个对象传给了这个方法。
  然而,方法真正只是一个函数而已,只不过它恰巧出现在类中。除了可以直接调用一个具体对象的方法以外,我们也可以在类中调用这个函数,并且明确地把这个对象作为se1f参数传给对象:

class Point:
	def reset(self):
		self.x = 0
		self.y = 0

p = Point()
Point.reset(p)
print(p.x, p.y) # 0 0

输出和前面的例子一样,因为在内部做了同样的处理。
如果我们在类定义中忘记包含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 0 positional arguments but 1 was given
>>>

  这个错误消息并不是那么清晰(“你这个傻瓜!你忘记self参数了”会得到更多的教训)。只要记住当你看到一条错误信息提示你缺少参数时,第一件事情就是去检查在方法定义时你是否忘记了带se1f参数。
  那么我们如何给方法传递多个参数呢?让我们添加一个新的方法,这个方法允许我们把点移动到任意位置,而不只是移动到原点。我们也可以添加另外一个方法,它接收另一个Point对象作为输入,然后返回这两个对象之间的距离:

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
			)
# 如何使用它
p1 = Point()
p2 = Point()

p1.reset()
p2.move(5, 0)
print(p2.calculate_distance(p1)) # 5.0

assert (p2.calculate_distance(p1) == 
	p1.calculate_distance(p2))

p1.move(3, 4)
print(p1.calculate_distance(p2)) # 4.47213595499958
print(p1.calculate_distance(p1)) # 0.0

  上面这个例子做了很多事情。

  • 这个类现在拥有3个方法。
  • move方法接收x和y两个参数,并且给self对象赋值,这和前面例子中的reset方法很像。之前的reset方法现在叫作move,因为reset只不过是一个移动到特定位置的move。
  • calculate_distance方法使用了不是很复杂的勾股定理来计算两个点的距离。我希望你能懂点数学(**是平方的意思,math.sqrt是计算平方根),但是现在我们的关注点是学习如何写一个方法,所以数学我们不做要求。
  • 最后几行代码展示了如何调用带参数的方法,简单地把参数包含在小括号里,用同样的点记法调用这个方法。我这里只是选了一些随机的位置来测试这些方法。这些测试代码调用了每一个方法并把结果打印在了控制台上。
    • assert函数是一个简单的测试工具,如果assert 后面的语句是False(0、空或者None)的话,这个程序就会异常退出。
    • 这里,我们用它来确保不管哪个point类调用另一个point类的calculate_distance方法,得到的距离是一样的。

对象的初始化

如果不清晰明确地给Point对象赋予x坐标和y坐标,也不使用move方法或者直接访问它们,那么你将得到一个没有实际位置的无意义的点。在这种情况下,我们试着访问这个对象会发生什么呢?
好的,我们不妨尝试做一下,看会如何。对于学习Python来讲,“尝试做一下,看会如何”是一个非常有用的学习工具。打开你的交互式解释器,键入要试的代码。下面这个交互式会话展示了如果你尝试访问一个不存在的属性会发生什么。如果你把之前的例子存成一个文件或者使用随书携带的代码文件例子,那么你可以通过命令行python-i filename.py 把代码加载到Python解释器。

p = Point()
p.x = 5
print(p.x) # 5
print(p.y) # 报错,AttributeError: 'Point' object has no attribute 'y'

  好处是,运行这段代码会至少抛出一个有用的异常。我们会在第4章详细讲解异常。在这之前你也可能会看到过它们(特别是普遍存在的语法错误异常SyntaxError,出现这个异常意味你键入了一些错误的代码!)。但此时,你只需要简单地意识到发生了错误即可。
  这些错误输出对于调试是有帮助的。在上面的交互式解释器中,错误输出告诉我们错误发生在第1行,这不完全正确(因为在一个交互式的会话中,一次只执行一行代码,所以它总会提示错误发生在第1行)。如果我们运行一个写在文件中的脚本,错误输出会告诉我们精确的行数,这样找到出错代码会变得容易。此外,它告诉我们这个错误是一个AttributeError,并且通过一个有用的消息告诉我们这个错误是什么意思。
  我们可以捕捉错误信息并修复错误,但是现在这个情况,好像我们应该指定一些默认值。可能是每一个新的对象都应该使用默认值调用reset()方法,或者当用户创建新对象时候,如果能强制用户告诉我们这些Point对象的位置,这样会比较好。
  大部分面向对象编程语言都有一个叫构造函数的特殊方法,当它被创建的时候会创建和初始化对象。这点,Python和它们有一点点不同,Python有一个构造函数和一个初始化函数。正常情况下,构造函数很少能用得到,除非你想做一些特别另类的操作。

​ 所以我们下面开始讨论初始化方法。
  除了有一个特殊的名字__init__以外,Python的初始化方法和其他方法没什么不同。开始和结尾的双下画线的意思是:“这是一个特殊的方法,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
p = Point(3, 5)
print(p.x, p.y) # 3 5

  现在,我们的点再也不会没有y坐标了!如果我们构造一个point对象时,没有包含合适的初始化参数,程序会报一个“没有足够参数”的错误,与之前当我们忘记 self参数时收到的那个错误类似。
  如果不想让这两个参数是必需的,我们该怎么办?那么我们可以通过不改变Python函数的语法,而通过提供参数默认值来实现,语法就是在每一个变量名后通过等号赋予参数默认值。如果调用对象时没有提供那个参数,那么就会使用默认参数值;此时对于这个函数来讲,这个变量仍然是可用的,但是这些变量将会使用参数列表里的默认值。这里有一个例子:

class Point:
	def __init__(self, x = 0, y = 0):
		self.move(x, y)

  大多数情况下,我们会把初始化的语句放到__init__函数里。但是,就像之前提到过的一样,除了初始化函数,Python还有一个构造函数,你可能永远不会用到这个函数,但是下面的讲解会帮助我们认识到它的存在,所以这里我们稍微提一下。

  和__init__不同,构造函数名叫__new__,并且它只接收一个参数,就是这个将要被构造的类本身(它会在对象被构造之前调用,所以这里也就没有self参数),同时它会返回刚被创建的对象。当提到复杂的元编程技术时,这个可能会比较有趣,但是在日常编程工作中,它不是很有用。在练习时,你几乎不需要去使用__new__,因为__init__的功能已经足够了。

解释你自己

  Python是一门非常简单易读的编程语言,有些人可能认为它是文档型语言(self-documenting)。然而,当我们进行面向对象编程时,清晰地总结每一个对象是什么,每一个方法是做什么的,并把这些内容写成APl文档是很重要的。做到文档的实时更新很困难,最好的方式就是把这些文档写到代码里。
  Python的docstring提供了对这种文档方式的支持。在每一个类、函数、方法的开头,紧接着它们的定义(以冒号结尾那行)可以有一行Python的标准字符串。这行字符串也需要和下面的代码一样有缩进。
docstring是用单引号(')或者双引号(")标注的Python字符串。通常,docstring比较长并且跨越多行时(PEP8风格指南建议行的长度不应该超过80个字符),就可以格式化成多行string,用(''' ''')或者(""" """)标注起来。
docstring应该能清晰准确地总结出它所描述的类或者对象的用途,应该能解释任何用法不是那么明显的参数,并且也包含如何使用这些API的简单例子。任何一个API使用者应该知道的注意事项或者问题,都应该标注在这里。

​ 在这一节的结尾,我们将通过一个完整的具有文档的Point 类来展示 docstring的用法:

import math
class Point: 
	'''Represents a point in two-dimensional geometric 
	coordinates'''
	def __init__(self, x=0, y=0):
		'''Initialize the position of a new point. 
		The x and y coordinates can be specified. 
		If they are not, the point defaults to the origin.'''
		self.move(x, y)

	def move(self, x, y):
		'''Move the point to a new location in two-dimensional space.'''
		self.x=x
		self.y=y
	def reset(self):
		'''Reset the point back to the geometric origin:0, 0'''
		self. move(0,0)
	def calculate_distance(self, other_point):
		'''Calculate the distance from this point to a second point 
		passed as a parameter.

		This function uses the Pythagorean Theorem to calculate the
		distance between the two points. The distance is returned 
		as a float.'''
		return math.sqrt(
			(self.x - other_point.x)**2 + 
			(self.y - other_point.y)**2)

print(help(Point))

输出:

Help on class Point in module __main__:

class Point(builtins.object)
 |  Represents a point in two-dimensional geometric 
 |  coordinates
 |  
 |  Methods defined here:
 |  
 |  __init__(self, x=0, y=0)
 |      Initialize the position of a new point. 
 |      The x and y coordinates can be specified. 
 |      If they are not, the point defaults to the origin.
 |  
 |  calculate_distance(self, other_point)
 |      Calculate the distance from this point to a second point 
 |      passed as a parameter.
 |      
 |      This function uses the Pythagorean Theorem to calculate the
 |      distance between the two points. The distance is returned 
 |      as a float.
 |  
 |  move(self, x, y)
 |      Move the point to a new location in two-dimensional space.
 |  
 |  reset(self)
 |      Reset the point back to the geometric origin:0, 0
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)

None

模块和包

  现在我们知道了如何创建类和实例化对象,是时候想一下该如何组织它们了。对于小程序来讲,我们可以把所有类都放到一个文件里,并且在文件最后通过一些代码来使它们相互调用。然而,随着我们项目的发展,在我们定义的众多类中找到想要编辑的那个类变得很困难。模块Module就这么产生了。模块是非常简单的Python文件,仅此而已。在小程序里,单个Python文件就是一个模块,两个文件就是两个模块。如果你在同一个文件夹里有两个文件,我们可以通过从这个模块加载一个类的方式来使用其他模块。
  比如,如果我们正在构建一个电子商务系统(e-commerce system),我们可能会在数据库里存储大量数据。我们可以把所有关于数据库访问的类和函数放到一个单一文件里(给它起一个有意义的名字:database.py)。然后其他的模块(如客户模型、产品信息和财产清单)为了访问数据库,可以从这个模块里导入那些关于数据库访问的类。
  import 语句是用来导入模块或者从模块里导入特定的类或函数的。在上一节的Point类中,我们已经通过一个例子看到了import的用法。我们通过import 语句导入了一个Python的内置math模块,从而可以使用sqrt函数来计算距离。
  这里有一个具体的例子:

​ 假如我们有一个叫database.py的模块,它包含一个叫Database的类,第2个模块叫products.py,它负责产品相关的查询。此时,关于这两个文件的具体内容,我们不需要想太多。只需要知道,products.py需要从database.py里实例化一个Database类,然后就可以在数据库产品表里执行查询了。
  这里有一些导入语句语法的变量可以用来访问这个类。

import database
db = database.Database()
# 数据库搜索操作

​ 上面这个版本,把database模块导入到了products命名空间(在一个模块或者函数内部可以访问到的名称列表),这时候任何在database这个模块里的类或者函数,都可以通过database.<something>这种记法来访问。或者,我们可以用from.…import语法来导入一个类:

from database import Database
db = Database()
# 数据库搜索操作

​ 假如出于某种原因,products自己已经有了一个叫Database的类,并且我们不想让这两个名字冲突,那么在products模块里使用这个类的时候,我们可以重新命名它:

from database import Database as DB
db = DB()
# 数据库搜索操作

​ 我们也可以在同一行语句里导入多项。如果database模块同时包含一个叫Query的类,可以通过下面的语句导入这两个类:

from database import Database, Query

​ 有一些教程说,我们甚至可以用下面的语法从database模块里导入它所有的类和函数:

from database import *

千万不要这么做!任何一个有经验的Python程序员都会告诉你绝对不要用这种语法,他们会用“这会使命名空间混乱”这种模糊不清的理由,对于初学者来说,这个理由并没有多大意义。有一种方式可以学习到为啥要避免这种语法,那就是两年以后,你再尝试使用这种语法并理解你的代码。但是在这里,我们会快速地解释一下原因,以便节省时间和两年糟糕的代码!
当我们在文件开头用from database import Database明确地导入Database类的时候,可以清楚地看到database类是从哪里来的。我们可以在文件400行以后使用db=Database(),并且快速地看一下导入语句就可以知道Database类是从哪里来的。
然后如果需要阐明如何使用Database类的时候,你可以去查看源文件(或者在交互式解释器里导入这个模块,并且使用help(database.Database)命令)。然而,如果我们使用from database import 这个语法,寻找这个类的位置将会花费长一点的时间。代码维护会变成噩梦。
同时,很多编辑器能提供一些额外的功能,像可靠的代码补全,或者如果使用正常的导入语句,跳到类定义地方的功能。import
语法通常会完全破坏它们可靠地做这些事情的能力。

​ 最后一点,使用import*语法会把一些意想不到的对象导入到我们本地的命名空间。肯定的一点是,它会把这个模块里定义的所有类和函数导入进来,但是,这样也会把这个模块自己导入的任意类和模块导入到当前文件中。
尽管有这些警告,你可能会想,“如果我只是为一个模块使用了from X import * 语法,我可以假设任何未知的导入都来源于这个模块”。从技术层面来讲这是对的,但在实际中它会发生问题。我敢保证,如果你使用这种语法,你(或者其他那些试图理解你的代码的人)将会遇到极其让人沮丧的时刻,“这个类到底是从哪里来的?”一个模块中所有用到的名字,都应该来自一个特定的空间,不论它是在这个模块中定义的,还是从其他模块明确导入的。不应该有奇怪的变量凭空出来。我们应该总是能够立即就确定,在当前的命名空间里,这个名字来自哪里。

组织模块

​ 随着项目发展成越来越多模块的集合,我们可能会发现,需要增加另外一层抽象,基于模块水平的某种层次模型。但是我们不能把模块放到模块里面,一个文件只能持有一个文件,毕竟,模块也只不过是Python文件而已。
然而,文件可以放到文件夹里,模块也是可以的。一个包(package)就是放到一个文件夹里的模块集合。包的名字就是文件夹的名字。我们需要做的只是告诉Python这个文件夹是一个包,并且把一个名为__init.py__的文件(通常是空的)放到这个文件夹里。如果我们忘记创建这个文件,就没法从这个文件夹里导入那些模块。
在我们的工作目录里,把我们的模块放到了一个叫ecommerce(电子商务)的包里,这个目录同样包含一个main.py文件用来启动程序。此外,在ecommerce包里再增加一个叫payments的包用来管理不同的付款方式。文件夹的层次结构看起来像这样:

➜  parent_directory tree
.
├── ecommerce
│   ├── database.py
│   ├── __init__.py
│   ├── payments
│   │   ├── authorizenet.py
│   │   ├── __init__.py
│   │   └── paypal.py
│   └── products.py
└── main.py

2 directories, 7 files

​ 当在包之间导入模块或类的时候,我们要注意语法。在Python3中,导入模块有两种方式:绝对导入和相对导入

绝对导入

绝对导入需要指明这个模块、函数的完整路径,或我们希望导入的路径。如果我们需要访问 products模块里的Product类,我们可以使用下面这些语法做绝对导入:

import ecommerce.products
product = ecommerce.products.Product()

或者

from ecommerce.products import Product
product = Product()

或者

from ecommerce import products
product = products.Product()

​ import 语句使用点号作为分隔符来分隔包或者模块。
对于任何模块,这些语句都可以运行。我们可以在main.py里、在database模块里,或者任意一个payment 模块里使用这样的语法实例化一个product对象。确实,只要这些包在Python里是可用的,就可以导入它们。比如,这些包还可以安装到Python的site packages文件夹里,或者可以通过自定义PYTHONPATH环境变量来动态地告诉Python,该如何搜索它即将要导入的包或者模块。
有这么多方式,我们该选择哪种语法呢?它取决于个人口味及你手头的应用程序。如果我想用的这个products模块里有几十个类和函数,我通常会使用from ecommerce import products 语法来导入模块名字,然后通过products.Product来访问单一的类。如果我只需要products模块里的一两个类,我会用from ecommerce.products import Product语法来直接导入它们。我个人不太常使用第一种语法,除非有某种命名冲突(比如,我需要访问两个名字都叫products但完全不同的模块,需要把它们分开)。
你可以做任何能让你的代码看起来更优雅的事情。

相对导入

​ 当处理一个包里的相关模块时,详细指明完整路径看起来有点蠢,因为我们知道父模块的名称。相对导入就这么产生了。相对导入基本上就是这么一个意思,“找出一个类、函数或者模块,它的定位要相对于当前模块”。比如,如果当前我们在products模块下工作,想从“隔壁”的database模块里导入Database类,我们可以使用相对导入:

from .database import Database

​ database前面这个点号说明,“使用当前包里的database模块”。在这种情况下,当前的包指的是包含目前我们正在编辑的product.py文件的这个包,这个包就是ecommerce包。
如果我们正在编辑ecommerce.payments包里的paypal模块,我们可能会说,“使用父包里的database包”,改成两个点号就可以轻松做到这点:

from ..database import Database

​ 我们可以通过使用更多的点号来访问层级的更上层。当然,一方面可以往下层访问,另一方面可以往上层访问。我们没有一个层级足够深的例子来恰当地展示,但是如果我们有一个ecommerce.contact包,这个包里有一个emai1模块,我们要把这个模块中的send_mai1函数导人到我们的paypa1模块中,下面的导入语句是有效的:

from ..contact.email import send_email

​ 这个导入语句使用了两个点来告诉我们,“payments包的父包”,然后使用普通的package.module语法,访问上层的contact包。
在任何一个模块里,我们可以指定要访问的变量、类或者函数。这是一个很方便的方法,可以用来存储没有命名空间冲突的全局状态。比如,我们已经把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变量。如果我们没有指明这个变量是全局的(global),当initialize_database方法执行完返回的时候,Python在这个方法内部新创建的这个database变量会被抛弃,剩下那个模块级别的database变量,值不会变(None)。
就像在这两个例子中展示的一样,当导入模块的时候,模块里的所有代码都会被立即执行。但是如果模块里的是一个方法或者函数,会创建这个函数,但函数里的代码直到函数被调用的时候才会执行。这对执行脚本来说比较狡猾(就像在e-commerce例子里的main脚本)。通常,我们会写一个程序让它来做一些有用的事情,过后发现,我们想从另一个程序里的一个模块导入一个函数或者类。但是只要我们导入了它,这个模块里的所有代码都会被立即执行。当我们只是想访问那个模块里的一些函数的时候,如果不小心,可能会把当前正在运行的程序终止掉。

​ 为了解决这个问题,我们应该总是把启动代码放到一个函数里(通常叫作main函数),并且只有当我们知道这是在执行脚本的时候,才去执行这个函数,而不是在其他脚本导入我们的代码的时候。但是我们如何知道呢?

class UsefulClass:
	'''This class might be useful to other modules.'''
	pass

def main():
	'''Create a useful class and does something with it for our module.'''
	useful = UsefulClass()
	print(useful)

if __name__ == '__main__':
	main()
# <__main__.UsefulClass object at 0x00000000004AB160>

​ 每一个模块都有一个特殊的变量__name__(记住,Python使用双下画线命名一些特殊变量,像一个类里的__init__方法),当导入这个模块的时候,这个变量指明了模块的名字。但是当这个模块直接通过python module.py执行的时候,就不会导入这个变量,而这时__name__变量就赋值给一个字符串”__main__",而不再是模块名了。把你所有的代码都包在if __name__ == '__main__':里面便成了一个策略,你就会发现它是非常有用的,可以防止万一有一天你写了一个函数,代码里导入了其他的代码。方法出现在类里,类出现在模块里,模块出现在包里,所有的都会这样吗?
事实不是这样的,当然这确实是Python程序里的典型顺序,但并不是唯一可能的布局方式。类可以定义在任何地方。它们通常是在模块级别定义的,但是它们也可以在一个函数或者方法内部定义,像:

def format_string(string, formatter = None):
	'''Format a string using the formatter object, 
	whichis expected to have a format() method that accepts a string.'''
	class DefaultFormatter:
		'''Format a string in title case.'''
		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:\t", hello_string)
print("output:\t", format_string(hello_string))

# input:	 Hello, world, how are you today?
# output:	 Hello, World, How Are You Today?

​ format_string函数接收一个字符串和一个可选的格式化对象作为参数,然后执行格式化字符串操作。如果没有提供格式化方法,函数会自己创建一个格式化方法,作为一个本地的类并实例化它。既然是在函数内部创建的,这个类就不能访问这个函数外面的任何地方。类似地,函数也可以定义在另一个函数里面;总之,任何时候都可以执行任何Python语句。这种“内部”类或者函数是有用的,特别对于那些在模块级别不需要或值得保留自己作用范围的“一次性”项目,或者只有在一个单一方法里有意义的项目。

谁可以访问我的数据

​ 大多数面向对象编程语言会有一个“访问控制”的概念。这个和抽象有关。对象里的某些属性和方法会被标记为“私有的(private)”,意思是只有这个对象可以访问它们。另外一些会被标记为“受保护的(protected)”,意思是只有这个类和它的子类可以访问。剩下的会被标记为“公共的(public)”,意思是允许任何其他对象访问它们。
Python不会这样做。Python不相信强制制定规则这种方式会让人严格遵守。相反,它提供了一个不强制的指南和最佳实践。在技术层面上,一个类里的所有方法和属性都是公共可访问的。如果我们想建议某个方法不应该能被公共访问,我们可以通过在docstring里放一个提示来表明是否这个方法只是内部使用的(解释面向公共的API如何工作会更好!)。
按照惯例,我们也可以给一个属性或者方法加一个下画线的前缀,大部分Python程序员会把这个解释为,“这是一个内部变量,在直接访问它之前请三思”。但是如果别人认为访问这个变量能给他们的程序带来最大的帮助,那么什么也阻止不了他们去访问。是的,如果他们这么想,为什么我们要阻止呢?我们不会想到将来这个类会如何使用。
你可以用另外一种方式,强烈建议外部对象不能访问某个属性或者方法,就是给它添加一个双下画线的前缀。这就是所谓的对问题中的属性做“名称改编(name mangling)”。
基本的意思就是,外部对象如果真的想访问的话,还是仍然可以调用这个方法的,但是你需要做额外的工作,并且它是一个很强的指示器,指示你想到你的属性应该保持私有性。
例如:

class SecretString:
	'''A not-at-all secure way to store a secret string.'''

	def __init__(self, plain_string, pass_phrase):
		self.__plain_string = plain_string
		self.__pass_phrase = pass_phrase
	def decrypt(self, pass_phrase):
		'''Only show the string if the pass_phrase is correct.'''
		if pass_phrase == self.__pass_phrase:
			return self.__plain_string
		else:
			return ''

secret_string = SecretString("ACEM: Top Secret", "Antwerp")
print(secret_string.decrypt("Antwerp")) # ACEM: Top Secret
print(secret_string.__plain_string) # AttributeError: 'SecretString' object has no attribute '__plain_string'

​ 看起来它生效了。没有密码的话没人可以访问我们的plaintext属性,所以它一定是安全的。不过,在我们太过兴奋之前,让我们看看想破解我们的密码是多么容易:

print(secret_string._SecretString__plain_string) # ACEM: Top Secret

​ 不要啊!有人已经破解了我们的密码字符串。好处是我们查到了,这就是Python的“名称改编(name mangling)”起作用了。当我们使用双下画线开头定义一个属性时,这个属性会自动加上一个_<classname>的前缀。这时候这个类的方法在内部访问变量,它们没有被自动转换。当外部的类想要访问它时,它们必须要自己做名称改编。所以名称改编不能保证隐私,它只会强烈建议要隐私。大部分Python程序员不会在其他对象中碰触双下画线开头的变量,除非他们有极其强制性性的理由这么做。
然而,如果没有强制性的原因,大部分Python程序员也不会使用单下画线开头的变量。大部分情况下,在Python代码中没有什么好的理由说一定要用“名称改编”的变量,这样做会导致不幸。比如,一个“名称改编”的变量可能对于子类有用,并且它需要自己做改编。其他的对象如果想访问你的隐藏信息,只要让它们知道,你认为使用单下画线前缀或者一些清晰的docstring,都不是好主意。
最后,我们可以直接从包里导入变量,这和从包里导入模块截然不同。在我们之前的例子中,我们有一个ecommerce包,它包含两个模块,一个叫database.py,一个叫products.py。database模块里有一个db变量,这个变量在很多地方都会被访问。如果我们用import ecommerce.db 取代import ecommerce.database.db,这样会不会方便一些?
还记得__init.py__文件定义目录为包吗?只要我们愿意,这个文件里可以包含任意变量或者类的声明,而且它们会作为这个包的一部分被我们使用。在我们的例子中,如果ecommerce/__init__.py文件里包含这么一行:

from .database import db

​ 这样我们就可以用下面的导入语句,在main.py或者其他文件中访问db这个属性了:

from ecommerce import db

​ 如果你能记起导致ecommerce.py这个文件是一个模块而不是包的原因就是__init__.py文件,会很有帮助。如果你把所有代码都放到了一个单独的模块里,过后又决定把它拆分成一个包里的多个包,__init__.py文件同样会对你有帮助。其他模块想要访问这个新包,__init__.py文件仍然是主要的切入点,但是在内部,代码仍然可以被组织成许多不同的模块或者子包。

案例学习

​ 把前面讲的所有都结合在一起,让我们构建一个简单的命令行笔记本应用(command-line notebook application)。这是一个非常简单的任务,所以我们不会用到多个包。然而在这个例子中,我们会看到类、函数、方法和docstring的基本使用方法。
让我们从一个简单的分析开始:备注(notes)是存在笔记本(Notebook)里的短的备忘录(memos)。每一个备注(note)都应该记录下它被创建的时间,并且为了查询方便,可以添加标签(tag)。备注(note)应该可以修改。我们也需要能够搜索备注。所有的这些功能都应该通过命令行实现。
很明显的对象就是Note。不太明显的就是要有一个容器对象Notebook。标签和日期看起来也是对象,但是我们可以使用Python标准库里的日期对象,以及逗号分隔的字符串作为标签。这时候为了避免复杂,对于这些对象我们不会去定义单独的类。
Note对象有memo本身、tags和creation_date这几个属性。每一个备注也需要一个唯一的整数id,这样用户就可以通过菜单接口选择它们。备注有一个方法可以用来修改它的内容,另外一个方法来修改标签,或者我们可以直接让笔记本访问这些属性。为了让搜索更简单,Note对象需要一个match方法。这个方法以一个字符串作为输入参数,不需要直接访问Note的属性,就能告诉我们是否有一条备注与输入的字符串匹配。这样的话,如果我们想修改搜索参数(比如,搜索标签而不是搜索备注的内容,或者让搜索结果大小写敏感),只需要在一个地方修改即可。
很明显,Notebook对象会有一个notes列表作为它的属性,也需要一个能返回过滤后的notes列表的search方法。
但是,我们如何和这些对象交互呢?我们已经指明了一个命令行的应用,当要运行这个程序的时候,这个应用可以让我们用不同的选项添加修改命令,或者我们会有各种菜单,这些菜单允许我们对这个笔记本对象做各种不同的操作。如果我们这样设计,那么上面提到的这些接口都是允许用的,或者在将来,我们可以添加基于Web页面的接口或者图形开发工具(GUItoolkit)的接口。
作为一个设计决定,我们现在就要实现菜单接口,为了做到我们设计的Notebook类是可扩展的,基于命令行选项的接口设计我们也要记在脑子里。

​ 如果我们有两个命令行接口,每一个都和Notebook做交互,那么Notebook为了能和它们交互,就需要一些方法。我们需要可以add新备注的方法,基于id modify已存在的备注的方法,同时还有我们已经讨论过的search方法。这个接口同样需要可以列出所有的备注,但是它们也可以通过直接访问notes列表属性获取。
我们可能会丢失一些细节,但是给了我们需要写的代码的一个很好的概述。我们可以在一个简单的类图里总结一下:

​ 在你写任何代码之前,让我们先为这个项目定义一下文件夹的结构。菜单接口应该清晰地存在于它自己的模块里,因为它将是一个可执行的脚本,并且将来我们可能有其他可执行脚本来访问笔记本。Notebook和Note对象可以在一个模块里。这些模块可以放到同一个顶级目录里而无须把它们放到一个包里。一个空的command_option.py模块在将来可以帮助提醒我们计划添加的新的用户接口。

➜  NoteBook tree
.
├── command_option.py
├── munu.py
└── notebook.py

0 directories, 3 files
➜  NoteBook 

​ 现在,回到代码。让我们开始定义Note类,因为它看起来比较简单。下面的例子展示了整个Note类。例子里的docstrings解释了它们之间是如何适用的。

import datetime

# 为所有新的备注存储下一个可用的id
last_id = 0

class Note:
    '''Represent a note in the notebook. 
    Match against a string in searches and 
    store tags for each note.'''

    def __init__(self, memo, tags=''):
        '''initialize a note with memo and 
        optional space-separated tags. 
        Automatically set the note's creation 
        date and a unique 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):
        '''Determine if this note matches the filter text. 
        Return True if it matches, False otherwise.
        
        Search is case sensitive and matches both text and tags.'''
        
        return filter in self.memo or filter in self.tags

​ 在继续之前,我们应该快速地启动交互式解释器并且测试我们目前的代码。经常频繁测试,因为事情总不会以我们期待的方式去工作。事实上,当我测试这个例子的第一个版本时,我发现在match函数里我忘记了se1f参数!我们会在第10章讨论自动化测试;现在,用解释器来检查一些东西就足够了:

➜  ~ cd /home/user/Desktop/NoteBook
➜  NoteBook python3 -i notebook.py
>>> from notebook import Note
>>> n1 = Note("Hello first")
>>> n2 = Note("hello again")
>>> n1.id
1
>>> n2.id
2
>>> n1.match('hello')
False
>>> n1.match('Hello')
True
>>> n2.match('second')
False
>>> n2.match('again')
True
>>>

​ 看起来一切都正常。接下来让我们创建我们的笔记本:

class Notebook:
    '''Represent a collection of notes that can be tagged, 
    modified, and searched.'''

    def __init__(self):
        '''Initialize a notebook with an empty list.'''
        
        self.notes = []
    
    def new_note(self, memo, tags=''):
        '''Create a new note and add it to the list.'''
        
        self.notes.append(Note(memo, tags))

    def modify_memo(self, note_id, memo):
        '''Find the note with the given id and change 
        its memo to the given value.'''

        for note in self.notes:
            if note.id == note_id:
                note.memo = memo
                break

    def modify_tags(self, note_id, tags):
        '''Find the note with the given id and change
        its tags to the given value.'''

        for note in self.notes:
            if note.id == note_id:
                note.tags = tags
                break
    
    def search(self, filter):
        ''' Find all notes that match the given filter string.'''

        return [note for notes in self.notes if note.match(filter)]

​ 我们快速清理一下。首先让我们测试并确保它能工作:

➜  NoteBook python3 -i notebook.py
>>> from notebook import Note, Notebook
>>> n = Notebook()
>>> n.new_note("Hello world")
>>> n.new_note("Hello again")
>>> n.notes
[<notebook.Note object at 0x7f0c27010470>, <notebook.Note object at 0x7f0c269595f8>]
>>> n.notes[0].id
1
>>> n.notes[1].id
2
>>> n.notes[0].memo
'Hello world'
>>> n.search("Hello")
[<notebook.Note object at 0x7f0c27010470>, <notebook.Note object at 0x7f0c269595f8>]
>>> n.search("world")
[<notebook.Note object at 0x7f0c27010470>]
>>> n.modify_memo(1, "Hi world")
>>> n.notes[0].memo
'Hi world'
>>>

​ 它确实可以工作。虽然代码有点混乱;我们的modify_tags和modify_memo方法几乎是相同的。这不是良好的编程实践。让我们看看是否可以修复这个问题。
在对一个备注做一些事情之前,两个方法都试图通过给定的ID来区分不同的备注。因此让我们添加一个通过给定ID来定位备注的方法。我们让这个方法的名字以下画线开头,这样这个方法就仅供内部使用,当然,如果我们想,我们的菜单接口也是可以访问这个方法的。

    # 2
    def _find_note(self, note_id):
        '''Locate the note with the given id.'''
        for note in self.notes:
            if note.id == note_id:
                return note 
        return None 

    # 2
    def modify_memo(self, note_id, memo):
        '''Find the note with the given id and change 
        its memo to the given value.'''
        self._find_note(note_id).memo = memo

​ 现在应该可以工作了;让我们看看菜单接口。接口只需要简单地提供一个菜单并且允许用户输入他们的选择。下面是第一次尝试:

#coding=utf-8
import sys
from notebook import Notebook, Note

class Menu:
    '''Display a menu and respond to choices when run.'''

    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("""
\t ---Welcom to Notebook Menu---

\t\t1.Show all Notes
\t\t2.Search Notes
\t\t3.Add Note
\t\t4.Modify Note
\t\t5.Quit

\t---Please Enter  a number to apply.---
""")
    def run(self):
        '''Display the menu and respond to choices.'''
        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_memo(id, tags)
        
    def quit(self):
        print("Thank you for using your notebook today.")
        sys.exit(0)

if __name__ == "__main__":
    Menu().run()

​ 这段代码首先通过一个绝对导人语句导入了笔记本对象。相对导入无法工作,因为我们还没有把我们的代码放到一个包里面。Menu类的run方法会重复地显示一个菜单并且通过笔记本里的函数对输入做出响应。这是通过使用一个Python中特有的习惯用法来实现的。用户输入的选择是字符串。在菜单的__init__方法里我们创建一个字典来把字符串映射到菜单对象本身的函数。然后,当用户做出一个选择,我们从这个字典里检索这个对象。变量action实际上指向一个特定的方法,并且通过添加空括号(因为这些方法都不需要参数)给变量的方式调用这个方法。当然,用户可能做了一个不恰当的选择,所以在调用方法之前,我们会检查这个行为是否真的存在。
各种方法都要求用户输入与调用和Notebook对象相关联的方法。对于search的实现,我们注意到,在我们过滤完备注之后,我们需要显示它们。所以我们让show_notes函数执行双重任务;它接收一个可选的notes参数。如果提供了这个参数,它就只显示过滤后的备注,但是如果没有提供,它会显示所有备注。因为notes参数是可选的,所以仍然可以像一个空菜单项那样,不带任何参数地调用show_notes。
如果我们测试这个代码,我们会发现修改备注是无法工作的。这里有两个错误,即:

  • 当我们输入一个不存在的备注ID的时候,备注本会崩溃。我们永远不能相信我们的用户会输入正确的数据!
  • 如果我们输入一个正确的ID,因为ID是整型数字,但是我们菜单传人的是一个字符串,所以笔记本还是会崩溃。
    后一个错误可以通过修改Notebook类的_find_note方法来解决,这个方法会用字符串来比较而不是存于备注中的整数来比较,如下:
    def _find_note(self, note_id):
        '''Locate the note with the given id.'''
        for note in self.notes:
            # if note.id == note_id:
			if str(note.id) == str(note_id): # 3
                return note 
        return None 

​ 我们只是在比较它们之前,简单地把输入(input)和备注的ID转换成了字符串。我们也可以把输入转换成整型数据,但是那样的话,当用户输入一个字母“a”而不是数字“1”的时候我们会遇到麻烦。
用户输入的备注ID不存在的这个问题可以通过改变两个在笔记本里的modify方法来完成,这个方法会检查是否_find_note会返回一个备注,像这样:

    def modify_memo(self, note_id, memo):
        '''Find the note with the given id and change 
        its memo to the given value.'''
        # self._find_note(note_id).memo = memo
		note = self._find_note(note_id)
		if note:
			note.memo = memo
			return True
		return False

​ 这个方法已经更新,取决于一个备注是否能找到,这个方法会返回True或者False。
如果用户输入一个无效的备注,菜单可以使用这个返回值来显示一条错误信息。这个代码看起来有点笨拙,相反如果它能抛出一个异常的话,看起来会更好一些。我们将在第4章讲解这些。

练习

​ 编写一些面向对象的代码。目标是使用你在这一章学到的规则和语法,来保证你不只是阅读了它,更是使用了它。如果你已经进入一个Python项目里了,回去看看是否有些地方你可以创建对象并且给它添加属性和方法。如果项目非常大,试着使用这些语法把它分成一些模块或者包。
如果你没有这么一个项目,尝试开始一个新的。它不一定是一个你想要完成的事情,只需要一些基本的设计部分。你不需要完全实现一切,往往在整个设计阶段你所需要的只是一个Print("this method wi1l do somethivg")方法。当你要实现不同的交互以及在真正实现它们要做的事情之前描述一下它们该如何工作时,这就是所谓的自上而下设计。相反的叫自下而上设计,先实现细节,然后把它们所有的连接在一起。两种模式在不同的时间都是有用的,但是为了理解面向对象的原则,一个自上而下的工作流更合适一些。
如果你提出新想法有些困难,试着写一个TO DO的应用。(提示:和设计笔记本应用类似,但是有额外的数据管理方法。)它可以记录每天你想做的事情,并且允许你把它们标记为完成状态。
现在,尝试设计一个更大的项目;它不需要实际做任何事,但是确保你实验了包和模块的导入语法。给不同的模块添加函数并且尝试从其他模块和包导入函数。使用相对导入和绝对导入。看看差异之处,并且尝试想象一些场景,哪一个场景需要哪一种导人方式。

总结

在这一章,我们学习了在Python中创建类并且给类分配属性和方法是多么简单的事。我们同时还介绍了访问控制以及不同级别的范围(包、模块、类以及函数)。特别是,我们讲解了:

  • 类的语法。
  • 属性和方法。
  • 初始化函数和构造函数。
  • 模块和包。
  • 相对导入和绝对导入。
  • 访问控制以及它的局限性。

在下一章,我们将学习如何使用继承来分享实现。

摘自Python3面向对象编程

posted @ 2019-04-15 23:51  linus_gau  阅读(223)  评论(0)    收藏  举报
TOP