如果你觉得你已经有了Python的入门水平,那么面向对象编程一定是你Python进阶之路上的必修课;如果你想深入理解从而充分利用Python的各种功能强大的拓展库(比如数据分析中的numpy
和pandas
),那么你也应该首先理解Python的面向对象编程。
不过这并不困难。这篇文章会用尽可能简单的方式介绍Python的类型系统,让你对它有最基本的理解。
用字典表示对象
为了方便理解,假如我们有三只动物,每只动物有一个名字,属于某一个物种:
名字 | 物种 |
---|---|
Jessie | dog |
Tom | cat |
Billy | sheep |
我们想在Python中把上面这段信息存起来,最简单的方法是每个动物用一个字典:
1 | animal1 = {"name": "Jessie", "species": "dog"} |
这时的每只动物就可以理解为一个“对象”,即具有某些属性(名字、物种)的实例。 假如现在我们希望输出每只动物的信息,我们可以定义一个函数,接受一个表示动物的字典,读取相应的字段,然后进行输出:
1 | def print_info(animal): |
比如想输出第一只动物的信息,我们可以:
1 | print_info(animal1) |
请注意我们的print_info
函数对所有的动物,不分物种,都进行同一种操作。 假如我们想让动物们给我们打个招呼。因为物种不同,它们打招呼的方式会有较大的差别,所以我们需要定义这么一个函数:
1 | def greet(animal): |
这个函数接受一只用字典存储的动物,读取它的物种信息,然后根据不同的物种输出不同的叫声。当然,我们没有办法枚举所有的动物,所以只要如果不是我们已知的三个物种,我们就输出'Unknown animal!'
。(如果你想,也可以让程序报错,像代码里的注释那样,但那不是我们的重点。) 目前为止,似乎一切都很正常。但这主要是因为我们现在只有三只动物,动物的信息并不复杂,动物的各种行为也比较简单。如果事情变得复杂起来,一些问题就会暴露出来:
- 我们在用字典存储信息,这导致新增一只动物比较麻烦。我们必须把它的每个属性都作为字符串完整无误地敲一遍才行。如果像这样不小心打错了什么,尤其是如果每只动物拥有更多的属性,就会出现一些难以发现的错误:
1 | animal4 = {"name": "Dannie", "speceis": "sheep"} #typo! |
- 我们针对动物定义的函数
print_info
和greet
是全局函数。我们其实只希望把一只动物传入这些函数,不希望其他传入其他的什么东西。但是我们现在不能阻止这样的事情:
1 | person = 'David' |
- 我们很难把有关动物的各种操作作为一个模块封装起来,给其他程序使用。
类型
为了解决这些问题,我们就要引入“类型”这个概念了。什么是类型呢?一个类型可以理解为用来生成若干具有一定“属性”和“方法”的实例的模板。可以认为“属性”是表示类型实例的状态的值,而“方法”是修改或利用这些状态的函数。Python中的列表就可以看成是一种类型:每个列表实例储存有若干个值对作为它的内容(属性),每个列表实例又可以进行查找、修改、添加等等操作(方法)。 我们也可以把动物看成一种类型。对于一只动物来说,它有两个属性:名字和物种。因此在生成一只新的动物的时候,我们希望输入它的两个属性,然后让它自己记住这两个属性。我们可以这么定义一个Animal
类型:
1 | class Animal: |
解释一下: self
是什么意思呢?在类型定义之中,当每个实例想使用它自身的某个属性,或者调用自身的某个方法的时候,我们就用self
来指代它自身。请记住self
本质上是一个实例。 注意在这里我们是如何新建一个实例的属性的。我们就直接self.name = name
的时候,就直接给self
这一实例新建了一个name
属性并赋值。这和Python中的变量声明和赋值机制非常相似,在其他地方我们也是通过直接赋值的方法新建变量,比如直接写一句x = 1
就声明了x
变量并将它赋值为1
。我们也可以用同样的语法修改一个实例的属性。 注意区分self.name
和name
。前者是self
的一个属性,而后者是函数中的一个变量。两者并不冲突,因为其实它们是在不同的命名空间下的。 __init__
又是什么意思?它是一个特殊的方法,赋予一个类型的属性以具体的值来生成一个实例。我们不应该在这个函数中定义返回值,但是可以理解为它默认的返回值是self
。在上面的代码中,Animal
类型定义了__init__
方法,那么在类型定义的外面,我们就可以用调用函数的方法调用Animal
,其实是调用了Animal
类型中定义的__init__
方法。像这样(在类型定义后面):
1 | animal1 = Animal("Jessie", "dog") |
下面我们可以给Animal
类型添加一些方法:
1 | class Animal: |
注意到所有的方法定义都必须以self作为第一个函数自变量。但在调用方法的时候,就要省略这一变量。你可以理解为,在调用animal1.print_info()
的时候,函数定义中self
的值被代入了animal1
这个实例。事实上,你也可以显性地感受这一过程,因为animal1.print_info()
完全等价于:
1 | Animal.print_info(animal1) |
这种写法也不常用,但同样可以帮助你理解。 我们还定义了一个change_name
方法,用来给动物改名字。这就展示了如何使用方法修改实例的属性。从效果上,根据我们的方法的定义,下面两行代码等价:
1 | animal1.change_name("Alice") |
然而给类型的使用者一个方法作为接口,而不是让类型的使用者去直接修改实例的属性,通常是更加合理的做法;即,如果给动物改名是一个常用的需求,我们则更倾向于第一行的写法。 到目前为止,我们基本上解决了之前提出的三个问题。
- 新建动物的操作规范化、方便了许多。
- 因为
greet
和print_info
都定义在Animal
类型中,我们只能对动物实例使用这两个方法(函数)。 - 我们可以把
Animal
类型的定义放在一个文件中,在另一个文件中import
这一类型,就能非常方便地使用它。(当然,这并不是本文的重点。)
继承
一切都在正确的轨道上行进。 然而,现在还有一个问题:如果我们的动物物种增加呢?假如现在来了一只鸭子,我们想要让它正确地打招呼。这似乎非常容易,只需要给Animal
类型的greet
方法增加一个elif
分支即可。然而,修改一个已经定义的类型中的代码往往是不好的操作。假如有人一直都在import
这一类型,而你修改了它,就有可能导致其他人的代码出现错误。在这种情况下,我们会发现greet方法是有问题的,因为它难以进行拓展。 这时候,我们需要引入“继承”的概念。何为继承?我们意识到,既然Animal
是一个类型,那么Dog
,Cat
和Sheep
也可以是一个类型。这三个类型沿用了Animal
类型的许多内容,包括name
和species
的属性,__init__
、 change_name
和print_info
的方法。然而,这三个类型在处理greet这一方法的时候,有不同的实现方式。 请看代码:
1 | class Animal: |
可以认为继承就是对原有类型的修修补补。比如新建一个Dog
类型的实例,将保留所有Animal
类型的方法和属性,又在Animal
类型的基础上新增了一个方法greet
。 这时,如果再试图这么做,会得到错误:
1 | animal0 = Animal("Jessie", "dog") |
原因是Animal
类型并没有定义greet
方法。(你可能注意到这里Python的报错信息显示“属性错误”,这是因为其实方法可以看成是属性,只是其值为函数。) 你还可以覆盖原有的类型的方法。比如,你可以让狗们有特殊的输出信息的方式:
1 | class Dog(Animal): |
这时,Dog
类型中print_info
方法的定义就覆盖了Animal
类型中同名方法的定义。
1 | animal1 = Dog("Jessie", "dog") |
你还可以调用原有类型中的方法:
1 | class Dog(Animal): |
其中super()
表示的是superclass,即原有的(被继承的)类型。
1 | animal1 = Dog("Jessie", "dog") |
super().print_info()
也可以写成:
1 | Animal.print_info(self) |
同样,这种写法并不常见。
结语
如果你看到这里还跟得上,那要恭喜你基本上理解了Python的类型系统,也就是所谓的面向对象编程——所谓“对象”,就是我们这里所说的“类型实例”。如果你感觉有点困难,可以自己用新的语法写写程序,加深理解。如果你寻求更加精准和专业的解释,请移步Python文档,或搜索相关资料。