面向对象编程
基本概念
面向对象编程 —— Object Oriented Programming,简称OOP,是一种编程思想。OOP把对象作为程序的基本单元,一个对象不仅包含数据还包含操作数据的函数。 ### 对比面向过程与面向对象
面向过程编程(Procedural programming):把计算机程序视为一系列子程序的集合。为了简化程序设计,面向过程把子程序继续切分为更小的子程序,也即把大的功能分为若干小的功能进行实现,从而降低系统的复杂度,这种做法也称为模块化(Modularity)。
面向对象编程(Object-oriented programming):把计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。
下面以保存和打印学生成绩表为例,分别展示面向过程编程和面向对象编程的不同:
面向过程编程:
1 | def save_score(name, score): |
面向过程编程其实就是细分功能并逐步实现,这里分出了保存成绩和打印成绩两个细的功能,并分别封装成子程序(或者说函数),然后通过调用各个子程序来实现程序的最终目标(保存并打印成绩)。
面向对象编程:
1 | class Student(object): |
面向对象编程强调程序的主体是对象,这里我们把学生抽象成一个类(class),这个类的对象拥有 name
和 score
这两个属性(Property),那么保存成绩就是把学生类实例化为对象(object),打印成绩就是给每个学生对象发送一个 print_score
的消息(message),让对象自己打印自己的属性。这个发送消息的过程又称为调用对象的方法(method),注意区分函数和方法。
五种编程范式的区分
这一小节是额外加上的,因为之前第4章函数式编程也讲了一种编程范式,这章又引入了面向过程编程和面向对象编程的概念,所以就在这里整理一下。也可以直接查看维基词条:Wikipedia - Procedural programming 以及引用的文章,讲得比较细致和清晰。更详细的可以查找专门讲编程范式的书籍来浏览。
面向过程编程(Procedural programming)
面向过程就是拆分和逐步实现,前一小节已经说过了,这里不再累述。
命令式编程(Imperative programming)
有时候面向过程编程又称为命令式编程,两者经常混用,但它们之间还是有一点差别的。面向过程编程依赖于块和域,比方说有 while
,for
等保留字;而命令式编程则没有这样的特征,一般采用 goto
或者分支来实现。
面向对象编程(Object-oriented programming)
面向对象也在上一小节简单介绍过了,它比面向过程编程抽象程度更高,面向过程将一个编程任务划分为若干变量、数据结构和子程序的组合,而面向对象则是划分为对象,对象的行为(方法)、使用对象的数据(成员/属性)的接口。面向过程编程使用子程序去操作数据,而面向对象则把这两者结合为对象,每一个对象的方法作用在自身上。
函数式编程(Functional programming)
在模块化和代码复用上,函数式编程和面向过程编程是很像的。但函数式编程中不再强调指令(赋值语句)。面向过程编写出的程序是一组指令的集合,这些指令可能会隐式地修改了一些公用的状态,而函数式编程则规避了这一点,每一个语句都是一个表达式,只依赖于自己而不依赖外部状态,因为我们现在所用的计算机都是基于指令运作的,所以函数式编程的效率会稍低一些,但是函数式编程一个很大的好处就是,既然每个语句都是独立的,那么就很容易实现并行化了。
逻辑编程(Logic programming)
逻辑编程是一种我们现在比较少接触的变成范式,它关注于表达问题是什么是不是怎样解决问题。感兴趣的话可以了解一下Prolog语言。
面向对象编程的三大特点
- 数据封装
- 继承
- 多态
类和实例
在Python中定义类是通过 class
关键字完成的,像前面例子一样:
1 | class Student(object): |
定义类的格式是 class 类名(继承的类名)
。 类名一般用大写字母开头,关于继承的知识会留在后续的章节里详述,object类是所有类的父类,在Python3中不写也行(既可以写作 class Student:
或 class Student():
),会自动继承,详情可以看python class inherits object。
定义类以后,即使没有定义构造函数和属性,我们也可以实例化对象:
1 | bart = Student() |
可以看到变量bart指向的就是一个Student类的实例,每个实例的地址是不一样的。
与静态语言不同,Python作为动态语言,我们可以自由地给一个实例变量绑定属性:
1 | 'Bart Simpson' bart.name = |
绑定的属性在定义类时无须给出,随时都可以给一个实例绑定新属性。 但是!这样绑定的新属性仅仅绑定在这个实例上,别的实例是没有的! 也就是说,同一个类的多个实例例拥有的属性可能不同!
对于我们认为必须绑定的属性,可以通过定义特殊的 __init__
方法进行初始化,在创建实例时就进行绑定:
1 | class Student(object): |
使用 __init__
方法要注意:
- 第一个参数永远是
self
,指向创建出的实例本身。 - 有了
__init__
方法,创建实例时就不能传入空的参数,必须传入与__init__
方法匹配的参数,参数self不用传,Python解释器会自动传入。
1 | 'Bart Simpson', 59) bart = Student( |
数据封装
数据封装是面向对象编程特点之一,对于类的每个实例而言,属性访问可以通过函数来实现。 既然实例本身拥有属性数据,那么访问实例的数据就不需要通过外面的函数实现,可以直接在类的内部定义访问数据的函数。
利用内部定义的函数,就把数据封装起来了,这些函数和类本身是关联的,称为类的方法。
1 | class Student(object): |
依然是前面的例子,可以看到在类中定义方法和外面定义的函数唯一区别就是方法的第一个参数永远是实例变量 self
。其他一致,仍然可以用默认参数,可变参数,关键字参数和命名关键字参数等参数形式。和创建实例一样,调用方法时不需传入self。
对于外部,类的方法实现细节不用了解,只需要知道怎样调用,能返回什么就可以了。
访问限制
尽管前一节中我们把数据用方法进行了封装,但事实上,实例化对象后,我们依然可以直接通过属性名来访问一个实例的属性值,并且自由地修改属性值。要让实例的内部属性不被外部访问,只需要在属性的名称前加上两个下划线 __
就可以了,此时属性就转换成了私有属性。
1 | class Student(): |
这是怎么实现的呢?其实呀,不能访问的实质是Python解释器对外给私有变量添加了前缀 _类名
,比如把 __name
会被改成 _Student__name
。所以我们在外部(即不是通过类的方法)访问时,__name
属性是不存在的,但访问 _Student__name
属性就可以了:
1 | bart._Student__name |
所以说,即使有访问限制,外部依然可以访问和修改内部属性,Python没有任何机制预防这一点,只有靠使用者自己注意了。
还有一点必须明白。使用访问限制跟绑定属性是不冲突的,所以虽然对内而言存在 __name
属性,但对外而言这个属性不存在,我们依然可以给实例绑定一个 __name
属性:
1 | 'Alice' # 外部绑定__name属性 bart.__name = |
Python同样没有任何机制防止我们给实例绑定一个和私有属性同名的属性,从外部是可以访问这样绑定的属性的,但对内部方法而言,这种绑定的赋值不会覆盖私有属性原来的值,所以极容易出错,只有靠使用者自己注意了。
通过 dir(bart)
可以查看实例包含的所有变量(属性和方法):
1 | dir(bart) |
在Python中,变量名类似 __xxx__
的,也就是以双下划线开头,并且以双下划线结尾的,是特殊变量,特殊变量允许直接访问,注意私有变量不要这样取名。有时会看到以一个下划线开头的变量名,比如 _name
,这样的变量外部是可以访问的,不属于访问限制,但是按照约定,这样的变量我们应视为私有变量,不应在外部直接访问。
获取和修改限制访问的属性
对于限制访问的属性,外部代码还是需要进行访问和修改的,我们可以在类中定义对应的get方法和set方法:
1 | class Student(): |
通过类的set方法修改属性值,而不直接在外部修改有一个明显的好处,我们可以在类的方法中对参数做检查,避免传入无效的参数。 比如这里可以限制修改成绩时成绩的范围必须是0~100,超出就报错。
继承和多态
基本概念
在OOP程序设计中,当我们定义一个类的时候,可以从某个现有的类继承,新的类称为子类(Subclass),被继承的类则称为基类/父类/超类(Base class/Super class)。比方说我们创建一个Animal类,该类有一个run方法:
1 | class Animal(object): |
定义一个Dog类继承Animal类,尽管我们没有为它编写任何方法,但它却可以获得父类的全部功能:
1 | class Dog(Animal): |
我们也可以为子类增加新的方法:
1 | class Dog(Animal): |
这里我们除了定义一个新的 eat
方法之外,还定义了一个和父类方法同名的 run
方法,它会覆盖父类的方法,当调用子类实例的 run
方法时调用的就会是子类定义的 run
方法而不是父类的 run
方法了,这个特点称作多态。总结一下,如果子类也定义一个和父类相同的方法,则执行时总是调用子类的方法。
实例的数据类型
我们定义一个类,实际就是定义了一种数据类型,和 list
,str
等没有什么区别,要判断一个变量是否属于某种数据类型可以使用 isinstance()
方法:
1 | isinstance(dog,Animal) |
实例化子类的对象既属于子类数据类型也属于父类数据类型!但是反过来就不可以,实例化父类的对象不属于子类数据类型。
多态的好处
比方说在外部编写一个函数,接收含有 run
方法的变量作参数:
1 | def run_twice(animal): |
当传入Animal类的实例时就执行Animal类的 run
方法,当传入Dog类的实例时也能执行Dog类的 run
方法,非常方便:
1 | run_twice(Animal()) |
有了多态的特性:
- 实现同样的功能时,不需要为每个子类都在外部重写一个函数
- 只要一个外部函数能接收父类实例作参数,则任何子类实例都能直接使用这个外部函数
- 传入的任意子类实例在调用方法时,调用的都是子类中定义的方法(如果子类没有定义的话就调用父类的)
开闭原则
所谓开闭原则,指的是对扩展开放:允许新增 Animal
类的子类;对修改封闭:父类 Animal
可以调用的外部函数其子类也能直接调用,不需要对外部函数进行修改。
多态真正的威力:调用方只需要关注调用函数的对象,不需要关注所调用的函数内部的细节。当我们新增一种Animal的子类时,只要确保子类的 run()
方法编写正确即可,无须修改要调用的函数。对任意一个对象,我们只需要知道它属于父类类型,无需确切知道它的子类类型具体是什么,就可以放心地调用外部函数,而函数内部调用的方法是属于Animal类、Dog类、还是Cat类,由运行时该对象的确切类型决定。
静态语言 VS 动态语言
对于静态语言(如:Java),如果函数需要传入 Animal
类型,则传入的参数必须是 Animal
类型或者它的子类类型,否则无法调用 run()
方法。
对于动态语言而言,则不一定要传入Animal类型。只要保证传入的对象有 run()
方法就可以了。 这个特性又称"鸭子类型",即一个对象只需要 "看起来像鸭子,能像鸭子那样走" 就可以了。
1 | class Timer(object): |
比方说这里的Timer类既不属于Animal类型也不继承自Animal类,但它的实例依然可以传入 run_twice()
函数并且执行自己的 run()
方法。
Python的 file-like object
就是一种鸭子类型。真正的文件对象有一个 read()
方法,能返回其内容。 但是只要一个类中定义有 read()
方法,它的实例就可以被视为 file-like object
。许多函数接收的参数都是 file-like object
,不一定要传入真正的文件对象,传入任何实现了 read()
方法的对象都可以。
小结
继承可以把父类的所有功能都赋予子类,这样编写子类就不必从零做起,子类只需要新增自己特有的方法,把父类不合适的方法覆盖重写就可以了。
动态语言的鸭子类型特点决定了继承不像静态语言那样是必须的。
获取对象信息
这一小节主要介绍给定一个对象,如何了解对象的类型以及有哪些方法。
type函数
使用type函数获得各种变量的类型:
1 | type('str') |
type()
函数返回值是参数所属的类型,而这个返回值本身则属于type类型:
1 | type(type('123')) |
可以用来进行类型判断:
1 | type('123')==str |
导入内建的 types
模块还可以做更多更强大的类型判断:
1 | import types |
isinstance函数
判断类型除了使用 type()
函数之外,使用 isinstance()
函数也能达到一样的效果:
1 | isinstance('a', str) |
并且 isinstance()
函数的参数二还可以是一个tuple,此时 isinstance()
函数将判断参数一是否属于参数二tuple中所有类型中的其中一种,只要符合其中一种则返回 True
。
对于类的继承关系来说,type()
函数不太合适,因为我们没办法知道一个子类是否属于继承自某个类,使用 isinstance()
函数就可以解决这个问题了。子类的实例也是父类的实例。
1 | animal = Animal() |
dir函数
dir()
函数返回一个对象的所有属性和方法:
1 | dir('ABC') |
形如 __xxx__
的属性和方法都是有特殊用途的,比如 __len__
方法会返回对象长度,不过我们一般直接调用内建函数 len()
获取一个对象的长度。而事实上,len()
函数内部就是通过调用对象的 __len__()
方法来获取长度的。两种写法都可以:
1 | len('ABC') |
如果自己写的类希望能用 len()
函数获取对象的长度,可以在定义类时实现一个 __len__()
方法,例如:
1 | class MyDog(object): |
hasattr函数、setattr函数、getattr函数
首先定义一个类,并创建一个该类的实例 obj
:
1 | class MyObject(object): |
利用 hasattr()
函数、setattr()
函数、getattr()
函数可以分别实现判断属性/方法是否存在,绑定/赋值属性/方法,以及获取属性值/方法的功能:
1 | hasattr(obj, 'x') # 有属性x吗? |
如果试图获取不存在的属性,会抛出 AttributeError
的错误:
1 | # 直接访问属性z obj.z |
特别地,我们可以为 getattr()
函数传入一个额外参数表示默认值,这样当属性不存在时就会返回默认值,而不是抛出错误了,但要注意,getattr()` 并不会把这个默认值绑定到对象:
1 | getattr(obj, 'z', 404) # 获取属性z,如果不存在,返回默认值404 |
getattr()
函数除了可以获取属性之外,也可以用来获取方法并且赋值到变量,然后再通过变量使用:
1 | getattr(obj, 'power') # 获取方法 power() 并赋值给变量fn fn = |
看到这里也许有一些疑问,为什么我们明明可以通过 obj.x = 10
的方式设置属性值,还要用 setattr(obj, 'x', 10)
呢? 为什么我们明明可以通过 obj.y
直接访问属性值,还要用 getattr(obj, 'x')
呢?显然后者的写法要繁琐得多。确实,前面举得例子中,我们都没有任何必要这样写,也不应该这样写。setattr()
函数和 getattr()
函数是为了一些更特别的情况而创造的,例如:
1 | 'x' attrname = |
当属性名绑定在一个变量上时,显然直接访问就没有办法使用了,但 getattr()
函数则不存在这方面的问题。并且前面也提到了 getattr()
函数允许我们设置默认返回值,这时直接访问无法做到的。又例如:
1 | 'x':9, 'y':19, 'z':29} attr = { |
我们可以非常方便地把属性名和属性值存储在一个dict里面,然后利用循环进行赋值,而无需显式地写出 obj.x = 9
,obj.y = 19
,和 obj.z = 29
,当我们需要批量赋值大量属性时,好处就体现出来了。同样地,我们也可以利用循环来读取需要的每个属性的值,而无需显式地逐个属性进行访问。
当然,如果可以直接写 sum = obj.x + obj.y
就不要写: sum = getattr(obj, 'x') + getattr(obj, 'y')
,属性少的时候完全没有必要给自己添麻烦,对编写和阅读代码都不友好。
最后举个使用 hasattr()
函数的例子,比方说读取对象fp,我们可以首先判断fp是否有 read()
方法,有则进行读取,无则直接返回空值:
1 | def readSomething(fp): |
实例属性和类属性
由于Python是动态语言,根据类创建的实例可以任意绑定属性。
要给实例绑定属性除了通过实例变量之外,也可以通过self变量来完成绑定:
1 | class Student(object): |
但是,如果Student类本身需要绑定一个属性呢?可以直接在类中定义属性,这种属性是类属性,归Student类所有:
1 | class Student(object): |
当我们定义了一个类属性后,这个类属性虽然归类所有,但类的所有实例都可以访问到:
1 | class Student(object): |
从上面的例子可以看出,在编写程序的时候,千万不要给实例属性和类属性设置相同的名字,因为相同名称的实例属性将屏蔽掉类属性。但是删除实例属性后,再使用相同的名称进行访问,返回的就会变回类属性了。