学习目标:
了解面向对象思想,掌握面向对象基础使用方法。
不知大家是否发现,我们所谓的编程其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,计算机的工作方式与人类正常的思维模式是不同的,如果编程就必须抛弃人类正常的思维方式去迎合计算机,编程的乐趣就少了很多,而“每个人都应该学习编程”这样的豪言壮语也就只能喊喊口号而已。不是说我们不能按照计算机的工作方式去编写代码,但是当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰,这也就是上世纪60年代末,出现了“软件危机”、“软件工程”这些概念的原因。
随着软件复杂性的增加,解决“软件危机”就成了软件开发者必须直面的问题。诞生于上世纪70年代的Smalltalk
语言让软件开发者看到了希望,因为它引入了一种新的编程范式叫面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象,对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。本节内容的目标就是让大家了解面向对象编程的思想以及基础使用方法。
一、编程范式
编程范式(programming paradigm
),即编程风格,是指计算机中编程的典范模式或方法。常见的编程范式有:面向过程编程、函数式编程、面向对象编程等。不同编程范式之间的根本区别之一,就是对状态(state
)的处理。状态,是程序运行时其内部变量的值。全局状态(global state)是程序运行时其内部全局变量的值。
1. 过程式编程
面向过程其实是最为实际的一种思考方式,可以说面向过程是一种基础的方法,这种编程风格要求你编写一系列步骤来解决问题,每步都会改变程序的状态。它考虑的是实际地实现。一般的面向过程是从上往下步步求精,所以面向过程最重要的是模块化的思想方法。当程序规模不是很大时,面向过程的方法还会体现出一种优势。因为程序的流程很清楚,按着模块与函数的方法可以很好的组织。示例如下:
1 | x = 2 |
上例中每行代码都改变了程序的状态。在过程式编程时,我们将数据存储在全局变量中,并通过函数进行处理。示例如下:
1 | rock = [] |
编写类似的简短程序时,使用过程式编程是没有什么问题的,但是由于我们将程序的状态都保存在全局变量中,如果程序慢慢变大就会碰到问题。因为随着程序规模扩大,可能会在多个函数中使用全局变量,我们很难记录都有哪些地方对一个全局变量进行了修改。例如,某个函数可能改变了一个全局变量的值,在后面的程序中又有一个函数改变了相同的变量,因为写第二个函数时程序员忘记了已经在第一个函数中做了修改。这种情况经常会发生,会严重破坏程序的数据准确性。
随着程序越来越复杂,全局变量的数据量也随着增加。再加上程序需要不断添加新的功能,也需要修改全局变量,这样程序很快就会变得无法维护。而且,这种编程方式也会有副作用,其中之一就是会改变全局变量的状态。使用过程式编程时,经常会碰到意料之外的副作用,比如意外递增某个变量两次。
这些问题促使了函数式编程和面向对象编程的出现,二者采取了不同的方法来解决上述问题。
2. 函数式编程
函数式编程( functional programming
)源自拉姆达运算( lambda calculus
):世界上最小的通用编程语言(由数学家阿隆佐·邱奇发明)。函数式编程通过消除全局状态,解决了过程式编程中出现的问题。函数式程序员依靠的是不使用或不改变全局状态的函数,他们唯一使用的状态就是传给函数的参数。一个函数的结果通常被继续传给另一个函数。因此,这些程序员通过函数之间传递状态,避免了全局状态的问题,也因此消除了由此带来的副作用和其他问题。函数式编程的专业术语很多,有人下过一个还算精简的定义:“函数式代码有一个特征:没有副作用。它不依赖当前函数之外的数据,也不改变当前函数之外的数据。”并给出了一个带副作用的函数。示例如下:
1 | a = 0 |
还给出了一个不带副作用的函数。示例如下:
1 | def increment(a): |
第一个函数有副作用,因为它依赖函数之外的数据,并改变了当前函数之外的数据——递增了全局变量的值。第二个函数没有副作用,因为它没有依赖或修改自身之外的数据。
函数式编程的一个优点,在于它消除了所有由全局状态引发的错误(函数式编程中不存在全局状态)。但是也有缺点,即部分问题更容易通过状态进行概念化。例如,设计一个包含全局状态的人机界面,比设计没有全局状态的人机界面要更简单。如果你要写一个程序,通过按钮改变用户看到画面的可见状态,用全局状态来编写该按钮会更简单。你可以创建一个全局变量,值为True时画面可见,值为Fae时则不可见。如果不使用全局状态,设计起来就比较困难。
3. 面向对象编程
面向对象(object-oriented
)编程是一种非常流行的编程范式,也是通过消除全局状态来解决过程式编程引发的问题,但并不是用函数,而是用对象来保存状态。在面向对象编程中,类(class
)定义了一系列相互之间可进行交互的对象。类是程序员对类似对象进行分类分组的一种手段。用开公司举个例子。如果公司就只有几个人,那么大家总是一起干活,工作可以通过“上帝视角“完全搞清楚每一个细节,于是可以制定非常清晰的、明确的流程来完成这个任务。这个思想接近于传统的面向过程编程。而如果公司人数变多,达到几百上千,这种“上帝视角”是完全不可行的。在这样复杂的公司里,没有一个人能搞清楚一个工作的所有细节。为此,公司要分很多个部门,每个部门相对的独立,有自己的章程,办事方法和规则等。独立性就意味着“隐藏内部状态”。比如你只能说申请让某部门按照章程办一件事,却不能说命令部门里的谁谁谁,在什么时候之前一定要办成。这些内部的细节你管不着。类似的,更高一层,公司之间也存在大量的协作关系。一个汽车供应链可能包括几千个企业,组成了一个商业网络。通过这种松散的协作关系维系的系统可以无限扩展下去,形成庞大的,复杂的系统。这就是OOP想表达的思想。
二、类和对象
如果要用一句话来概括面向对象编程,我认为下面的说法是相当精准的。
面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为类,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。
这句话对初学者来说可能难以理解,但是我们先为大家圈出几个关键词:对象(object)、类(class)、封装(encapsulation)、继承(inheritance)、多态(polymorphism)。
我们先说说类和对象这两个词。在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。俗话说,人以类聚,物以群分,类是具有相似内部状态和运动规律的实体的集合(或统称为抽象),也是具有相同属性和行为事物的统称。类是抽象的,在使用的时候通常会找到这个类的一个具体的存在,使用这个具体的存在。一个类可以找到多个对象我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的具体的实实在在的存在,也就是一个对象。对象是某一个具体事物的存在,在现实世界中可以是看得见摸得着的,可以是直接使用的。类就是一个模板,模板里可以包含多个函数,函数里实现一些功能。对象则是根据模板创建的实例,通过实例对象可以执行类中的函数。简而言之,类是对象的蓝图和模板,对象是类的实例。
在面向对象编程的世界中,一切皆为对象,对象都有属性和行为,每个对象都是独一无二的,而且对象一定属于某个类。对象的属性是对象的静态特征,对象的行为是对象的动态特征。按照上面的说法,如果我们把拥有共同特征的对象的属性和行为都抽取出来,就可以定义出一个类。
三、类的构成
类(Class) 由三个部分构成:类的名称(类名)、类的属性(一组数据)和类的方法(允许对进行操作的方法,也称作行为)。举一个例子:比如说把学生看做一个类,我们只关心3样东西:事物名称(类名):学生(Student),属性:姓名(name)、年龄(age)等,方法(行为/功能):学习(study)、玩(play)。
1 | class 类名: |
四、类的定义
在Python中,可以使用class
关键字加上类名来定义类,通过缩进我们可以确定类的代码块,就如同定义函数那样。那么,我们如何给学生定义属性呢?如果要给学生类定义属性,我们可以为其添加一个名为__init__
的方法。
1 | class Student: |
如上所示,我们为学生类定义了一个__init__
的初始化方法,包含name和age两个属性。有了属性之后,如何为学生定义行为呢?我们说过类是一个抽象概念,那么这些行为就是我们对一类对象共同的动态特征的提取。写在类里面的函数我们通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是self
,它代表了接收这个消息的对象本身。
1 | class Student: |
那么,目前为止我们就完成了一个拥有name
和age
两个属性,study
和play
两个行为的类的定义。
五、创建和使用对象
在我们定义好一个类之后,可以使用构造器语法来创建对象,代码如下所示。
1 | # 由于初始化方法除了self之外还有两个参数 |
在类的名字后跟上圆括号就是所谓的构造器语法,上面的代码创建了两个学生对象,在我们调用Student
类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行__init__
方法,完成对内存的初始化操作,也就是把数据放到内存空间中。所以我们可以通过给Student
类添加__init__
方法的方式为学生对象指定属性,同时完成对属性赋初始值的操作,正因如此,__init__
方法通常也被称为初始化方法。一个赋值给变量stu1
,一个赋值给变量stu2
。当我们用print
函数打印stu1
和stu2
两个变量时,我们会看到输出了对象在内存中的地址(十六进制形式),跟我们用id
函数查看对象标识获得的值是相同的。现在可以告诉大家,我们定义的变量其实保存的是一个对象在内存中的逻辑地址(位置),通过这个逻辑地址,我们就可以在内存中找到这个对象。所以stu3 = stu2
这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。
接下来,我们尝试给对象发消息,即调用对象的方法。刚才的Student
类中我们定义了study
和play
两个方法,两个方法的第一个参数self
代表了接收消息的学生对象,study
方法的第二个参数是学习的课程名称。Python中,给对象发消息有两种方式,请看下面的代码。
1 | # 通过“类.方法”调用方法,第一个参数是接收消息的对象,第二个参数是学习的课程名称 |
六、打印对象
上面我们通过__init__
方法在创建对象时为对象绑定了属性并赋予了初始值。在Python中,以两个下划线__
开头和结尾的方法通常都是有特殊用途和意义的方法,我们一般称之为魔术方法或魔法方法。如果我们在打印对象的时候不希望看到对象的地址而是看到我们自定义的信息,可以通过在类中放置__str__
或__repr__
魔术方法来做到,该方法返回的字符串就是用print
函数打印对象的时候会显示的内容,代码如下所示。
1 | class Student: |
1 | class Student: |
1 | class Student: |
__str__
和 __repr__
的差别究竟在哪里,它们的功能都是实现类到字符串的转化,它们的特定并没有体现出用途上的差异。
1 | In[1]: import datetime |
另外,列表以及字典等容器总是会使用 __repr__
方法。即使你显式的调用str
方法,也是如此。
1 | students = [stu1, Student('小明', 16), Student('李云龙', 30)] |
__str__
和__repr__
的区别:
- 内置类型
object
所定义的默认实现会调用object
.__repr__()
。 print
打印功能和str
函数会优先执行__str__
方法,如果没有定义该方法则会执行__repr__
方法。- 交互模式下提示以及
repr
函数会执行__repr__
方法。 __str__
方法用于通常应该返回一个友好的显示,返回结果可读性强。也就是说,__str__
的意义是得到便于人们阅读的信息。
由以上内容可知,当我们自己定义的类要想要返回一个友好的提示的话,推荐用__repr__
,这能保证类到字符串始终有一个有效的自定义转换方式。
七、面向对象的支柱
面向对象编程有四大概念:封装、抽象、继承和多态。他们共同构成了面向对象编程的四大支柱。编程语言必须同时支持这四个概念,才能被认为是一门面向对象编程的语言。
本节术语表
- 继承:在基因继承中,子女会从父母那继承眼睛、颜色等特征。类似地,在创建物时,该类也可以从另一个类那里继
承方法和变量。 - 父类:被继承的类。
- 子类:继承父类的类。
- 方法覆盖:子类改变从父类中继承方法的实现能力。
- 多态:多志指的是为不同的基础形态(数据类型)提供相关接口的能力。
- 抽象:抽象指的是剥离事物的诸多特征,使其只保留最基本的特质的过程。
- 客户端代码:使用对象的类之外的代码。
- 封装:封装包含两个概念。第一个概念是在面向对象编程中对象将变量(状态)和方法(用来改变状态或执行涉及状态的计算)集中在一个地方,即对象本身。 第二个概念指的是隐藏类的内部数据,以避免客户端代码( 即类外部的代码)直接进行访问。
- 组合:通过组合技巧,将一个对象作为变量保存在另一个对象中, 可以模拟“拥有”关系
1. 封装
这里我们先说一下什么是封装:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。举一个例子,假如要控制一个机器人帮我倒杯水,如果不使用面向对象编程,不做任何的封装,那么就需要向这个机器人发出一系列的指令,如站起来、向左转、向前走5步、拿起面前的水杯、向后转、向前走10步、弯腰、放下水杯、按下出水按钮、等待10秒、松开出水按钮、拿起水杯、向右转、向前走5步、放下水杯等,才能完成这个简单的操作,想想都觉得麻烦。按照面向对象编程的思想,我们可以将倒水的操作封装到机器人的一个方法中,当需要机器人帮我们倒水的时候,只需要向机器人对象发出倒水的消息就可以了,这样做不是更好吗?
封装包含两个概念。第一个概念是在面向对象编程中,对象将变量(状态)和方法(用来概念状态或执行涉及状态的计算)集中在一个地方——即对象本身。示例如下:
1 | class Rectangle(): |
上例中,实例变量height
和width
保存的是对象的状态,并在area
方法内集中在相同的地方(对象本身)。该方法使用对象的状态来返回长方形的面积。
封装包含第二个概念,指的是隐藏类的内部数据,以避免客户端(client)代码(即类外部的代码)直接进行访问。示例如下:
1 | class Data: |
Data类有一个叫num
的实例变量,包含一个整型数列表。创建一个Data对象后,有两种方法可以改变nums
中的元素:使用change_data方法,或者直接使用Data对象访问其nums
实例变量。示例如下:
1 | class Data: |
那如果我们不希望之后调用这个类的用户直接访问或修改变量,只能通过方法访问或修改,那应该怎么办呢?私有变量和私有方法可以解决这个问题,其应用场景为:有一个类内部使用的方法或变量,并且希望后续调整代码实现(或保留选项的灵活),但不想让任何使用该类的人依赖这些方法或变量,因为后续代码可能会调整(到时会导致客户端代码无法执行)。私有变量是封装包含的第二个概念的一种范例:私有变量隐藏了类的内部数据,避免客户端代码直接访问。公有变量(publ ariable
)则相反,它是客户端代码可以直接访问的变量。
Python中没有私有变量,所有的变量都是可以公开访问的。 Python通过另一种方式解决了私有变量应对的问题:使用命名约定。在 Python中,如果有调用者不应该访问的变量或方法,则应在名称前加下划线,Python程序员看见某个方法或变量以下划线开头时,就会知道它们不应该被使用(不过实际仍然是可以使用的)。示例如下:
1 | class PublicPrivateExample: |
编写客户端代码的程序员看到上述代码后,会知道变量se1f.public
是可以安全使用的,但是不应该使用变量se1f._unsafe
,因为其以下划线开头。如果非要使用后续可能会有风险。维护上述代码的程序员,没有义务一直保留se1f._ unsafe
,因调用者本不应该访问该变量。客户端程序员也能确认public method
是可以放心使用的_safe method
则不然,因为其名称同样以下划线开头。
2. 抽象
抽象(abstraction
)指的是剥离事物的诸多特征,使其只保留最基本的特质的过程。在面向对象编程中,使用类进行对象建模时就会用到抽象的技巧。
假设要对人进行建模。人的特征很复杂,头发和眼睛颜色不同,还有身高、体重、种族、性别等诸多特征。如要创建一个类代表人,有一些细节可能与要解决的问题并不相关。举个例子,我们创建一个Person
类,但是忽略其眼睛颜色和身高等特征,这最就是在进行抽象。Person
对象是对人的抽象,代表的是只具备解决当前问题所需的基本特征的人。
3. 多态
多态(polymorphism
)指的是为不同的基础形态(数据类型)提供相关接口能力。接口,指的是函数或方法。下面就是一个多态的示例:
1 | print('hello world') |
print
函数为字符串、整数和浮点数这3种不同的数据类型提供了相同的接口,我们不必定义并调用3个不同的函数(如调用print string
打印字符, print int
打印整数, print float
打印浮点数),只需要调用print
函数即可支持所有数据类型。
假设我们要编写一个程序,创建3个对象,用对象分别画出三角形、正方形和圆形。可以定义3个不同的类Triangle
、 Square
和Circle
,并各自定义draw
方法来实现。Triangle.draw()
用来画三角形, Sqaure.draw()
用来画正方形 circle.draw()
则用来画圆形。这样设计的话,每个对象都有一个draw
接口,支持画出自身类所对应的图形。这样就为3个不同的数据类型提供了相同的接口。
如果Python不支持多态,每个图形就都需要创建一个方法:draw_triangle
画 Triangle
对象, draw_square
画 Sqaure
对象, draw_cirlce
画Circle
对象。
另外,如果有一个包含这些对象的列表,且要将每个对象画出来,就必须要检查每个对象的数据类型,然后调用正确的方法。这会让程序规模变大,更难阅读,更难编写, 也更加脆弱。这还会使得程序更难以优化,因为每添加一个新图形,必须要找到代码中 所有要画出图形的地方,并为新图形添加检查代码(以便确定使用哪个方法),而且还需要再调用新的画图函数。下面分别是未使用多态和使用了多态的画图代码示例:
1 | # 未使用多态的代码 |
如果在没有使用多态的代码中添加新图形,则必须修改for循环中的代码,shape的类型并调用其画图方法。通过统一多态的接口,可以随意向shapes
列表 添加新图形,不需要再添加额外的代码即可画出对应图形。
4. 继承
编程语境中的继承(inheritance
),与基因继承类似。在基因继承中,子女会从父母那继承眼睛颜色等特征。类似地,在创建类时,该类也可以从另一个类那里继承方法变量,被继承的类,称为父类(parent class
);继承的类则被称为子类( child class) 。本节将使用继承对图形进行建模。示例如下:
1 | class Shape(): |
通过该类,我们可以创建拥有width
和height
属性的Shape
对象。 Shape
对象有 个方法 print size
,可打印其 width
和height
值。 接下来,定义一个子类。在创建子类时,将父类的变量名传入子类,即可继承父类 的属性。下例中Square
类的继承来自Shape
类:
1 | class Shape(): |
因为我们将Shape
类作为参数传给了square
类,后者就继承了 Shape类的变量和方法。 Sqaure
类中定义的代码只有关键字keyword
,表示不执行任何操作。由于继承了父类,我们可以创建Square
对象,传入宽度和长度参数,并在其上 用 print size
方法,而不需要再写任何代码(除pass
外)。由此带来的代码量缩 很重要,因为避免代码重复可以让程序更精简、更可控。 子类与其他类没有区别,它可以定义新的方法和变量,不会影响父类。
1 | class Shape(): |
当子类继承父类的方法时,我们可以定义一个与继承的方法名称相同的新方法,从而覆盖父类中的方法。子类改变从父类中继承方法的实现能力,被称为方法覆盖(method overriding),示例如下:
1 | class Shape(): |
上例中,由于定义了一个叫print_size
的方法,新定义的方法覆盖了父类中同名的方法,在调用时会打印不同的信息。
5. 组合
介绍完面向对象编程的4个支柱之后,这里再介绍一个更重要的概念:组合(composition)。通过组合技巧,将一个对象作为变量保存在另一个对象中,可以模拟“拥有”关系。例如,可使用组合来表达狗和其主人之间的关系(狗有主人)。为此,我们首先定义表示狗和人的类:
1 | class Dog(): |
然后,在创建Dog对象时将Person对象作为owner参数传入:
1 | mick = Person("Mick Jagger") |
这样,stan
对象“Stanley`就有了一位主人,即名叫”Mick Jagger“的Person对象,保存在其实例变量owner中。
八、经典案例
例子1:
定义一个类描述数字时钟。
1 | import time |
例子2:
定义一个类描述平面上的点,要求提供计算到另一个点距离的方法。
1 | class Point(object): |
九、最佳练习
练习1
创建Rectangle和Square类,使它们均有 个叫calculate perimeter周长计算方法。计算其所表示图形的周长。创建Rectangle和Square对象,并满用二者的周长计算方法。
练习2
在Square类中,定义一个叫change size的方法,支持传入一个数字,增加或减少(数字为负时)Square对象的边长。
练习3
创建一个叫Shape的类。在其中定义一叫what_am_i的方法,被调用时打印”I am a shape”。调整上个练习中的Square和Rectangle类,使其继承Shape类,重写what_am_i方法,然后创建Square和Rectangle对象,并在二者上调用新方法。
练习4
创建一个叫Horse的类,以及一个叫Rider的类。使用组合,表示一匹有骑手的马。
十、简单小结
面向对象编程是一种非常流行的编程范式,除此之外还有过程式编程、函数式编程等编程范式。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程,找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。抽象的过程是一个仁者见仁智者见智的过程,对同一类对象进行抽象可能会得到不同的结果,如下图所示。
在很多场景下,面向对象编程其实就是一个三步走的问题。第一步定义类,第二步创建对象,第三步给对象发消息。当然,有的时候我们是不需要第一步的,因为我们想用的类可能已经存在了。之前我们说过,Python内置的list
、set
、dict
其实都不是函数而是类,如果要创建列表、集合、字典对象,我们就不用自定义类了。当然,有的类并不是Python标准库中直接提供的,它可能来自于第三方的代码,如何安装和使用三方代码在后续课程中会进行讨论。在某些特殊的场景中,我们会用到名为“内置对象”的对象,所谓“内置对象”就是说上面三步走的第一步和第二步都不需要了,因为类已经存在而且对象已然创建过了,直接向对象发消息就可以了,这也就是我们常说的“开箱即用”。
面向对象编程有多个优点,鼓励代码重用,从而减少开发和维护的时间;还鼓励拆解问题,使代码更容易维护。但又一个缺点便是编写程序时要多下些功夫,因为要做恩多的事前规划和设计。