- 参考书《Python基础教程(第三版)》—— Magnus Lie Hetland
- 廖雪峰的python教程:面向对象编程
文章目录
- 一、面向对象编程OOP
- 1. 基本概念
- 2. 例子:鸟和云雀
- 二、类和对象
- 1. 基本概念
- 2. 创建一个自定义类
- 3. 属性、函数和方法
- 4. 类的命名空间 & 对象的命名空间
- 5. __init__方法
- 三、数据封装
- 1. 读取器和设置器方法
- 2. “私有属性”
- 四、继承
- 1. 普通继承
- 2. 多继承
- 3. 深入探讨继承
- (1)内置方法
- (2)super方法
- (3)接口
- 4. 抽象基类
- (1)基本概念
- (2)注册为抽象基类的子类
- 五、多态
- 1. 为什么需要多态
- 2. 多态的威力
- 3. 开闭原则
- 4. 鸭子类型
- 六、获取对象信息
一、面向对象编程OOP
1. 基本概念
- OOP是一种程序设计思想。这是一种源自自然界的思想,我们在生活中会把自然地把各种具体事物归类到某种抽象概念。比如我们把“小轿车”、“卡车”、“面包车”等等统称为“车”,一个班级里的“Mike”和“Alice”都是“student”。基于这种思想,OOP中我们先要抽象出Class,再根据Class创建Instance,最后用Instance组成整个程序
- 类(Class):一种抽象概念,比如我们定义的Class——Student,是指学生这个概念
- 对象/实例(object/instance):是类的具现,比如一个个具体的Student
- OOP把对象作为程序的基本单元,一个对象包含了
- 属性:数据
- 方法:操作数据的函数
- 计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。
- 在Python中,所有数据类型都可以视为对象,当然也可以自定义对象。
- 数据封装、继承和多态是面向对象的三大特点
2. 例子:鸟和云雀
- 给出一个实例来帮助理解面向对象编程中类的概念及编程方法。这个例子来自《Python基础教程(第三版)》第七章
- 例如,我们在窗外看到一只鸟,这只鸟就是 “鸟类” 这个非常抽象(通用)的类的一个实例
- 可以把 “鸟类” 视为由所有鸟组成的集合,这个集合有多个子集。 比如我们看到的那只鸟可能属于子集 “云雀”,那么这只鸟不但是 “鸟类” 这个类的一个实例,还是 “云雀” 这个类的一个实例。当一个类的对象为另一个类的子集时,前者就是后置的子类,后者为前者的超类/父类。这个例子中, “云雀” 是 “鸟类” 的子类;“鸟类” 是 “云雀” 的超类。
- 在面向对象编程中,子类关系意味深长,因为类是由其支持的方法定义的,类的所有实例都有该类的所有方法,因此子类的所有实例都有超类的所有方法。有鉴于此,在定义子类时,只需定义多出来的方法(可能还要修改超类中一些既有的方法)
- 例如,
Bird
类可能提供方法fly
,而Bird
的一个子类Penguin
类可能新增方法eat_fish
。由于企鹅不会飞,因此在Penguin
类的实例中,fly
方法应该什么都不做或引发异常,这时就要在子类Penguin
定义时修改父类Bird
中提供的fly
方法。
二、类和对象
1. 基本概念
- 类是抽象的模板,实例是根据类创建出来的一个个具体的 “对象”,每个对象都拥有相同的方法,但各自的数据可能不同。
- 在较旧的python版本中,"类型"和"类"是泾渭分明的,内置对象是基于类型的,自定义对象是基于类的,可以创建类但不能创建类型。但在python3中,已经没有这种区别
2. 创建一个自定义类
- class创建独立的命名空间,用于在其中定义函数
- 实例化
foo = person()
的时候,实际上是在内存某个位置创建了一个person
对象,再把foo
变量指向它。 - 通过上面的示例,可以看出self的作用:代指对象本身。如
foo.set_name("A")
时,其实传递了foo本身
和'A'
两个参数 - self参数也可以显示给出,如果
foo
是一个person
实例,可将foo.greet()
视为person.greet(foo)
的简写,但后者的多态性更低
3. 属性、函数和方法
- 和普通的函数相比,在类中定义的函数只有一点不同,就是第一个参数永远是实例变量self,并且,调用时,不用传递该参数。除此之外,类的方法和普通函数没有什么区别,所以,你仍然可以用默认参数、可变参数、关键字参数和命名关键字参数。
- 可以将方法重新关联一个普通函数
- 相应的,也可以让普通变量指向类的成员方法,这样如果成员方法有self,它们也可以访问到self
4. 类的命名空间 & 对象的命名空间
- 在class语句中定义的代码都是在一个特殊的命名空间(类的命名空间)内执行的,而类的所有成员都可以访问这个命名空间
- 实例化类的对象时,也会给每个对象一个它自己的对象的命名空间
- 当我们定义了一个类属性后,这个属性虽然归类所有,但类的所有实例都可以访问到
- 看这个示例
- 分析:
- 定义类时,属性n会定义在类的命名空间中
- 对此类实例化为对象时,对象的命名空间中可以访问到类命名空间的属性n,在“遮盖”发生之前,修改类命名空间的n会改变所有对象的属性n的值
- 一旦对某个对象的属性n被修改(通过self方法,或者直接进行t1.n=100之类的赋值),这个对象命名空间中,对象的属性n就会“遮盖”类属性n
- 这类似函数形参“遮盖”全局变量
- 注意,直到“覆盖”发生前,对象的属性值一直和类的属性值相同
- 再看几个对比示例
- python不会在执行前对你编写的类检查属性是否存在,python是一个解释型语言,只有解释到调用属性的时候才会去查找对象命名空间或类命名空间中的这个属性,并进行可能的报错。在解释到调用这个属性前,在任意位置定义这个属性都可以避免解释器报错
5. __init__方法
- 从上面的可以看出类属性的添加很自由,但因此也有点混乱。我们可以最好定义一个特殊的
__init__
方法来规范类的属性,这个特殊的方法被称为构造方法
- 由于类可以起到模板的作用,因此,可以在创建实例的时候,把一些我们认为必须绑定的属性强制填写进去。
- 有了
__init__
方法,在创建实例的时候,就不能传入空的参数了,必须传入与__init__
方法匹配的参数。self
不需要传,Python解释器自己会把实例变量传进去
- 在继承子类中,构造函数的处理:参考Python 子类继承父类构造函数说明
三、数据封装
1. 读取器和设置器方法
- 封装:向外部隐藏不必要的细节,这样无需知道对象的构造就能使用它
- 一个类的成员变量(属性)应当对类外隐藏,以降低类聚性。如果想要在类外读写类的属性,应该通过读取器和设置器方法。
- 此类有一个属性I,并对I设置了存取器方法,但由于I没有对类外隐藏,可以从外部直接修改I的值,这样的封装不好,应当将I设置为私有,存取器设置为公共,提升封装性
2. “私有属性”
- 在python中没有为私有属性提供直接的支持,我们只能用一些手段来近似做到这一点:在私有成员名称前加两个下划线。这样处理的方法或属性,在类外不能直接访问,而在类内可以
- 事实上,加两个下划线意义是:对成员进行名称转换,在类内没有变化,在类外必须加"_类名"前缀才能访问,因此
- 这种方法虽然不能彻底禁止类外访问,但是它发出了强烈的信号,不要这样做!
四、继承
1. 普通继承
- 继承:如果已经写了一个类,现在又要写一个很相似的类,可以通过继承继承原有类的方法和属性,并在此基础上修改。
- 继承得到的新的class称为子类(Subclass),而被继承的class称为基类、父类或超类(Base class、Super class)
- 继承最大的好处是子类获得了父类的全部方法
- 可以对子类增加一些方法,也可以修改从父类得到的方法
- 当子类和父类都存在同名run()方法时,我们说,子类的run()覆盖了父类的run(),在代码运行的时候,总是会调用子类的run()。这样,我们就获得了继承的另一个好处:多态
2. 多继承
- 多重继承:一个子类可能有多个超类/父类
- 使用多重继承,要注意多个父类的方法不应出现同名,否则继承时排在最前的类的方法会“遮住”其他类的同名方法
- 假设有父类
A
、B
、C
且它们都有方法talk()
-
class sonClass(A,B,C)
这样A.talk()
会遮住其他俩个 -
class sonClass(B,A,C)
这样B.talk()
会遮住其他俩个
3. 深入探讨继承
(1)内置方法
- 有以下类
- 要确定一个类是否是另一个类的子类:内置方法
issubclass
- 有一个类,想知道它的父类:访问类的特殊属性
__bases__
- 有一个实列,要确定它是否是某个类的实例:内置方法
isinstance
- 有一个对象,想知道它的类:访问对象的特殊属性
__class__
- 小结:
- 子类对象是父类对象,也就是说在继承关系中,如果一个实例的数据类型是某个子类,那它的数据类型也可以被看做是父类。
- 父类对象不是子类对象
- 使用
isinstance
通常不是良好的做法,依赖多态往往是更好的选择。一个重要的例外情况是使用抽象基类和模块abc
时
(2)super方法
- super方法允许在子类中调用父类的方法,详见:python super详解
(3)接口
- 接口即一个类对外暴露的方法和属性,java中常使用接口作为类的蓝图,指明一个类必须要做什么和不能做什么。python中没有接口,人们不会编写显式接口,而是使用 “鸭子类型”,即假定对象能够完成期望的任务,如果不能完成,程序奖失败。这要求对象遵循特定的接口(即实现特定的方法),但如果有需要也可灵活处理,在调用对象方法或属性前检查其是否存在。
- 相关内置方法说明
- 内置方法
hasattr
:检查对象的属性或方法是否存在 - 内置方法
callable
:检查对象的某个属性是否是可调用的(是否是一个方法) - 内置方法
getattr
:获取对象的某个属性或方法,可以设置缺省值 - 内置方法
setattr
:设置对象的某个属性或方法
4. 抽象基类
(1)基本概念
- 历史上大多数时候,python几乎都只依赖鸭子类型,偶尔使用
hasattr
检查属性是否存在。与之相比,很多其他语言(比如Java和Go)都使用显式指定接口的理念。很多第三方库提供这种理念的各种实现,最终python通过引入abc
库提供了官方的解决方案,这个模块为所谓的抽象基类提供了支持。 - 抽象基类是不能被实例化的类,其职责是定义子类应该实现的一组抽象方法,也可在其中定义普通方法
- 使用abc模块定义抽象方法
- 抽象类的重要特点是不能被实例化
- 从抽象类派生的子类,如果没有重写所有抽象方法,就仍是一个抽象类,不能实例化
- 抽象类中可以同时定义抽象方法和普通方法
(2)注册为抽象基类的子类
-
isinstance
在处理抽象基类时不符合python通常依赖的 “鸭子类型” 规则,若不加处理,将导致其多态性下降。 - 以下实例中,
Duck
类实现了AbstractBird
类的全部方法,根据 “鸭子类型” 规则,Duck
类的实例应当也是AbstractBird
类的实例,但是使用isinstance
检查时却不是如此
- 当然,可以从
AbstractBird
类派生出Duck
类,但Duck
可能是从其他模块引入的,这时就无法使用这种做法。为了解决这个问题,可以将Duck
类注册为AbstractBird
,这样所有Duck
类实例都将被视为AbstractBird
对象。
- 这种方法存在一个缺点,
register
方法允许用户注册任意类,因此直接从抽象类派生提供的保障没有了,注册的类可能并没有实现被注册类的全部方法。从这个角度看,应当把isinstance
返回True视为一种意图表达,注册的类有成为被注册类的意图,但可能会失败。
五、多态
1. 为什么需要多态
- 假设我们现在要编写一个方法,用于获取商品的价格。我们起初定义使用元组表示商品,结构为
(商品名,价格)
,那么代码可能会写成
- 后来我们引入了一个新模块,这个模块给出的商品使用字典表示,结构为
{'price':价格,'name':商品名}
。为了兼容这个模块,不得不对以前的方法进行修改,根据上文,可以用isinstance
方法来检查传入参数的类型。
- 随着我们引入更多的模块,传入参数的类型或格式不断变化,我们不得不不断修改
get_price
方法,这种方法非常不灵活。 - 如何解决这个问题呢,我们可以让传入对象自己去做获取价格的操作,而在
get_price
方法中只需要向传入对象询问价格即可。这正是多态的用武之地,利用多态,上述程序可以简化为
- 术语多态源自希腊语,意为“有多种形态”,这大致意味着即使你不知道变量指向的是哪种对象,也能对其进行操作,而且操作的行为随着对象所属的类而异
2. 多态的威力
- 以下示例来自廖雪峰的教程
- 新增一个
Animal
的子类,不必对run_twice()
做任何修改,实际上,任何依赖Animal
作为参数的函数或者方法都可以不加修改地正常运行,原因就在于多态。 - 由于
Animal
类型有run()
方法,因此,传入的任意类型,只要是Animal
类或者子类,就会自动调用实际类型的run()
方法,这就是多态的意思: - 对于一个变量,我们只需要知道它是
Animal
类型,无需确切地知道它的子类型,就可以放心地调用run()
方法 - 调用方只管调用,不管细节,而当我们新增一种
Animal
的子类时,只要确保run()
方法编写正确,不用管原来的代码是如何调用的。
3. 开闭原则
- 对扩展开放:允许新增
Animal
子类; - 对修改封闭:不需要修改依赖
Animal
类型的run_twice()
等函数。
4. 鸭子类型
- 对于静态语言(例如Java)来说,如果需要传入Animal类型,则传入的对象必须是Animal类型或者它的子类,否则,将无法调用run()方法。
- 对于Python这样的动态语言来说,则不一定需要传入Animal类型。我们只需要保证传入的对象有一个
run()
方法就可以了 - python语言设计类型时遵循了“鸭子原则”:一个对象只要“看起来像鸭子,走起路来像鸭子”,那它就可以被看做是鸭子。因此一个有了
run()
方法的类会被认为是Animal
类型的。即使它并不符合继承体系 - 由于这一点,我们可以像下面这样绕过参数必为
Animal
类型的限制
六、获取对象信息
- 参考:获取对象信息