我们不需要写太多的代码就会发现Python是一门非常“简单”的语言。当我们想要做什么的时候,直接做就可以,不需要进行很多的设置。你可能已经看到,著名的“ hello world ”例子在Python中只需要一行代码即可完成。
类似地,Python 3最简单的类就像这样:
这就是我们的第一个面向对象程序!类定义以class关键字开始,接着是类名(由我们自己确定),最后以冒号结尾。
类名必须遵循标准的Python变量名原则(必须以字母或下画线开头,并只能由字母、下画线或数字组成)。除此之外,Python风格指南(可以在网上搜“PEP 8”)建议类名应该用 驼峰命名法 ( CapWords或CamelCase )(以大写字母开头,后续任意单词都以大写字母开头)。
类的定义行后面是类的内容块。与其他的Python代码结构一样,类也使用缩进,而非其他语言中常用的大括号关键字或方括号来分隔。除非你有足够充分的理由(比如配合其他人的代码用制表符作为缩进),否则尽量用4个空格作为缩进。任何好用的代码编辑器都支持将输入的Ta b键替换为4个空格。
由于我们的第一个类实际上并不添加任何数据和行为,因此我们简单地在第2行用pass关键字作为占位符来表示下面没有进一步的动作了。
我们可能觉得这个最基本的类什么都不能做,但是它允许我们创建这个类的实例对象。我们可以将这个类加载到Python 3解释器中,这样就能在交互式解释器中使用它。为了做到这一点,将上面这个类定义的代码保存到名为first_class.py的文件中,执行命令python-i first_class.py。-i参数告诉Python启动交互式解释器并运行那个文件中的代码。下面这个解释器的会话可以说明这个类的一些基本交互操作:
这段代码从这个新类中实例化了两个对象,将对象变量分别命名为a和b。创建类实例很简单,只需要输入类的名字和一对括号。看起来就像一个普通的函数调用,但是Python知道我们 调用 的是类而不是函数,因此它知道它的任务是创建一个新对象。打印这两个变量,会输出对象的类名及其在内存中的地址。内存地址在Python代码中不常用到,但是在这里,可以证明这是两个不同的对象(因为地址不同)。
使用is运算符,我们可以看到它们是不同的对象:
当我们创建了很多对象并为对象分配了不同的变量名时,这有助于减少混淆。
现在我们有了一个基本的类,但它没什么实际用处。它没有包含任何数据,也不能做任何事。我们如何给指定的对象添加属性?
实际上,不需要改动类定义,我们可以通过点标记法为实例对象设定任意属性。下面是一个示例:
如果我们执行这段代码,最后的两个print语句将告诉我们两个对象新属性的值:
这段代码先创建了一个空的Point类,没有任何数据和行为。然后创建了这个类的两个实例,并分别赋予它们在二维坐标系中定位一个点的 x 和 y 坐标值。为对象属性赋值的语法是<object>.<attribute>=<value>。这种方法被称为 点标记法 ( dot notation )。这里的值可以是任何类型:Python基本类型、内置数据类型或其他的对象,甚至可以是一个函数或另一个类!
像这样创建属性会使 mypy 工具感到困惑。我们没办法直接在Point类定义上添加类型提示。类型提示通常被添加到方法或属性定义中。我们可以在赋值语句中使用类型提示,像这样:p1.x:float=5。在2.2.3节中会讲到一个更好的添加类型提示和属性的方法。不过,我们先在类定义中添加一些行为。
让对象拥有属性已经很棒了,但是面向对象编程的重点在于不同对象之间的交互。我们感兴趣的是,触发某些行为可以使属性发生变化。现在是时候为我们的类添加一些行为了。
让我们模拟Point类的一些动作。我们可以从一个让它回到原点的reset() 方法 开始(其中原点是指x和y坐标值都是0的点)。这个动作很适合初学者学习,因为它不需要任何参数:
print语句的执行结果显示两个属性值都变成了0:
Python中的 方法 在格式上和 函数 完全相同。它以def关键字开始,紧接着的是空格和方法名,然后是一对括号括起来的参数列表(我们稍后马上会讨论self参数,有时其也被称为实例变量),最终以冒号结尾。下一行开始是方法内部的代码块。这些语句可以是任意的Python代码,可以操作对象自身属性和传递给方法的参数。
我们忽略了reset()方法的类型提示,因为这里不是很适合使用类型提示。我们会在2.2.3节看到使用类型提示最好的地方。下面我们先深入看一下实例变量,以及self关键字的工作原理。
类方法和普通函数的区别之一是,所有的类方法都有一个必要的参数。按照惯例,这个参数通常被命名为self;我从没见过哪个程序员使用其他名称(习惯是很有力量的)。当然,你可以用this,甚至Maishu等名称,但你最好遵循PEP 8中的编码规范,使用self。
简单来说,self参数就代表被调用的对象本身。这个对象是类的实例,有时候也被称为实例变量。
我们可以通过self访问对象的属性和方法。这也是为什么我们可以在reset()方法中通过self对象设置对象的x和y属性。
在这个讨论中,注意 类 和 对象 的区别。我们可以将方法视为附加到类上的函数。self参数代表类的当前实例。当在两个不同的对象上调用该方法时,调用两次相同的方法,但传递两个不同的 对象 作为self参数。
注意,当我们调用p.reset()方法时,我们不需要传递self参数给它。Python自动帮我们做了这件事。Python知道我们正在调用p对象的方法,所以它自动将p对象传递给Point类的这个方法。
方法实际上就是一个放在类中的函数。除了调用对象的方法,我们也可以直接通过类名调用函数,同时将对象名作为self参数传递给函数:
输出结果与上一个示例完全一样,因为内部发生的过程是完全一样的。这实在不是好的编程实践,但它能加深我们对self参数的理解。
如果我们忘了在定义方法时添加self参数,会发生什么?Python会抛出一个错误:
这个错误的消息看起来不是那么清楚(如果是“你这个傻瓜,你忘记了self参数”,则可能看起来更直接一些)。只要记住,如果看到一条错误消息说缺少参数,第一件事就应该检查你是否忘记了方法定义中的self参数。
我们如何向方法传递多个参数呢?让我们添加一个新的方法,用来将Point对象移动到任意指定的位置,而不仅仅是原点。我们也可以接收另一个Point对象作为输入,并返回它们之间的距离:
我们定义的这个类有两个属性x和y,以及3个方法:move()、reset()及calculate_distance()。
move()方法接收两个参数x和y,并用它们的值设置self对象的坐标。既然reset操作就是把对象移动到特定的已知点,那么用reset()方法直接调用move()方法就可以了。
calculate_distance()方法用于计算两点之间的欧几里得距离(计算距离还有一些其他方法,我们会在第3章的案例学习中讨论其他方法)。我希望你可以理解其中的数学计算。数学公式 可以用math.hypot()来实现。在Python中,我们使用self.x,但数学家通常使用 x s 。
下面是一个使用这个类定义的示例。它显示了如何调用一个有参数的方法:使用同样的点号标记法调用实例的方法,并把参数包在括号内。我们只是挑选了几个随机点来测试这些方法。测试代码调用了每个方法并把结果打印到控制台上:
assert(断言)语句是一个神奇的测试工具。如果assert后面的语句的执行结果为False(或者是零、空值、None),那么程序将会报错并退出。在这个示例中,我们用它来确保不管是哪个对象调用另一个对象的calculate_distance()方法,距离应该是相等的。我们会在第13章中更多地使用assert语句,那时我们会写更严格的测试代码。
如果我们不明确设定Point对象的位置(使用move()方法,或直接设置属性x和y的值),我们将会得到一个没有真实位置的Point对象。当我们尝试访问它时会发生什么?
让我们试试看,试试看是学习Python非常有用的途径。打开交互式解释器,大胆输入(使用交互式解释器是我们写作这本书的工具之一)。
看看如果我们尝试访问一个不存在的属性,会发生什么。如果你将前面的示例保存为文件或下载随书发布的代码,你可以用python-i filename.py命令把它们载入Python解释器:
好吧,至少这次抛出了一个有用的异常信息。我们将会在第4章中详细介绍关于异常的内容。你可能之前已经见过(尤其是无处不在的SyntaxError,它意味着你的语法出错)。现在来说,你只要知道这意味着什么东西出错了即可。
程序打印的输出对调试错误非常有用。在上面的示例中,交互式解释器告诉我们错误出现在第1行,这个行数并不是很有用,因为交互式解释器一次只执行一行代码。但如果我们执行的是一个Python脚本文件,它就能明确地告诉我们行数,让我们更容易找到出错的代码。除此之外,它也告诉我们错误是AttributeError,并且给出一条有用的消息告诉我们这个错误的含义。
我们可以捕获并修复这一错误,但是在这个示例中,看起来我们需要指定一个默认值。也许每个新对象都应该默认通过执行reset()被重置,或者在创建新对象时,要求用户告诉我们Point所在的位置。
有意思的是, mypy 无法确定x和y是否应该是Point对象的属性,因为Python的属性定义是动态的,类定义中并没有一个确定的属性列表。但是Python有一些常用的规范,可以帮助确定对象属性的集合。
大多数面向对象的编程语言都有 构造方法 ( constructor )的概念,它是创建对象时进行创建和初始化的特定方法。Python有一点不一样,它同时拥有构造方法和初始化方法。除非你需要做一些异乎寻常的事,否则将很少用到构造方法__new__()。我们的讨论主要集中在初始化方法__init__()上。
Python初始化方法和其他方法一样,除了它有特定的名称__init__,开头和结尾的双下画线意味着,这是一个特殊方法(魔术方法),Python解释器会将其当作特例对待。
永远不要用以双下画线开头和结尾的名称定义你自己的方法。也许这个方法名现在对Python没什么特殊含义,但是有可能Python设计者会在未来将其作为特殊用途,一旦他们这么做了,你的代码就会崩溃。
让我们给Point类添加初始化方法,它要求用户在实例化Point对象时必须提供x和y的坐标值:
现在创建一个Point实例的代码是这样的:
现在,我们的点永远不会没有x或y坐标值了!如果我们初始化对象时没有传入必要的参数,将会得到一个和前面在方法定义中忘记self参数类似的参数不足(not enough argument)错误。
在大多数情况下,我们将初始化语句放在__init__()函数中。在初始化方法中给所有属性做初始化是非常重要的。这样可以在一个明显的地方告诉 mypy 所有的属性。同时会让你的代码易于理解,他人不用通读所有代码来查找类定义之外创建的神秘属性。
虽然这是可选的,但给参数和返回值添加类型提示是很有帮助的。在上面的示例中,我们在每个参数名称之后都指定了参数类型。在方法定义的最后,我们使用->指定了方法返回值的类型。
我们已经多次注意到:类型提示不是必需的。它们在程序运行的时候是不起任何作用的。但是,有些工具可以检查代码中的类型是否一致。 mypy 是一种广泛使用的类型检查工具。
如果我们不想让这两个参数作为必填参数,我们同样可以使用Python函数的默认值语法。使用等号可以给关键字参数设置默认值。如果调用者没有提供这些参数值,那么这些默认值将被使用。参数变量仍然可以在函数中使用,只不过它们的值是参数列表中的默认值。下面是一个示例:
这样写可能会让函数的每个参数定义都变长,从而可能会让一行代码变得很长。为了解决这个问题,在某些示例中,你会看到逻辑上是一行的代码但被写在了多行里。这样做没问题,因为Python会通过括号匹配来识别它们属于同一行代码。我们可以在一行变长时这样写:
这种写法并不是很常见,但是这种写法是有效的,可以让每行代码很短,因此易于理解。
类型提示和默认值很方便,但我们还可以做更多的事情在新的需求出现时让类易于理解和扩展。我们可以通过文档字符串的方式给类添加文档。
Python是一门非常易读的编程语言,有些人可能说Python的代码就是文档。但在面向对象编程中,编写能清晰总结每个对象和方法的API文档是非常重要的。代码可能会时常变动,保持文档的同步更新是很难的,所以最好的方式是直接把文档写进代码里。
Python通过 文档字符串 ( docstring )来支持在代码中直接嵌入文档。我们可以在每个类、函数或方法的定义语句(冒号结尾那一行)之后的第1行添加标准Python字符串作为说明文档。这一行的缩进应该与后面的代码相同。
文档字符串就是用单引号(')或双引号(")包围的Python字符串。通常文档字符串都相当长而且跨越多行(风格指南建议每行的长度不应超过80个字符),因此可以用多行字符串的形式,用3个单引号(''')或3个双引号(""")包围。
文档字符串应该简明扼要地描述类或方法的目的,应该解释所有用途不够明显的参数,同时可以放一些简短的使用API的示例。对用户容易出错或误解API的一些地方最好也加以说明。
文档字符串最好的用法之一是在其中添加一些测试用例。像 doctest 这样的工具可以找到这些用例并测试它们能否正确执行。本书中所有的示例都通过了doctest的验证。
为了说明文档字符串的用途,本节以完整注释版的Point类作为结束:
试着输入或加载(使用python-i filename.py命令)这个文件到交互式解释器中。然后,在Python提示符处输入help(Point)并按下回车键。你将会看到这个类的完善的文档,如下所示:
我们的文档不仅看起来像内置函数一样专业,而且可以运行python-m doctest point_2.py来确认文档中的示例是否可以正确运行。
而且,我们可以运行 mypy 做类型检查。使用mypy--strict src/*.py可以检查src文件夹下的所有文件。如果没有问题, mypy 将不会产生任何输出。(记住, mypy 不是内置库,你需要自己安装它。查看本书前言可以获取需要安装的额外软件包的有关信息。)