Python基础教程——再谈抽象
再谈抽象
对象魔法
在面向对象编程中,术语对象大致意味着一系列数据(属性)以及一套访问和操作这些数据的方法。
使用对象而非全局变量和函数的原因有多个,下面列出了使用对象的最重要的好处:
- 多态:
可对不同类型的对象执行相同的操作,而这些操作就像“被施了魔法”一样能够正常运行。 - 封装:
对外部隐藏有关对象工作原理的细节。 - 继承:
可基于通用类创建出专用类。
多态
术语多态(polymorphism)源自希腊语,意思是“有多种形态”。
意味着:即便你不知道变量指向的是哪种对象,也能够对其执行操作,且操作的行为将随对象所属类型(类)而异。
例如,假设你要为一个销售食品的电子商务网站创建在线支付系统,
程序将接收来自系统另一部分的购物车。因此你只需计算总价并从信用卡扣除费用即可。
首先想到的可能是,指定程序收到商品时必须如何表示。
例如,你可能要求用元组表示收到的商品,如下所示:
('SPAM', 2.50)
但这种方式不太灵活。
假设该网站新增了拍卖服务,即不断降低商品的价格,直到有人购买为止。
使用简单的元组表示商品无法做到这一点。要做到这样,表示商品的对象必须在编写代码询问价格时通过网络检查其当前价格,也就是说不能像在元组中那样固定价格。
要解决这个问题,可创建一个函数。
# 不要像下面这样做:
def get_price(object):
if isinstance(object, tuple)
return object[1]
else:
return magic_network_method(object)
注意,使用isinstance来执行类型/类检查旨在说明:使用类型检查通常是馊主意,应尽可能避免。
但这种解决方案还是不太灵活。
可更新相应的函数,用十六进制的字符串表示价格,并将其存储在字典的‘price’键下:
# 不要像下面这样做:
def get_price(object):
if isinstance(object, tuple)
return object[1]
elif isinstance(object, dict):
return int(object['price'])
else:
return magic_network_method(object)
现在你确定考虑到所有的可能性了吗?
假设有人决定添加一种新字典,并在其中将价格存储在另一个键下,该如何办呢?
可以让对象自己去处理这种操作。
这样事情将简单得多:每种新对象能够获取或计算其价格并返回结果,而你只需向它们询问价格即可。
这正是多态(从某种程度上说还有封装)的用武之地。
多态和方法
当你收到一个对象,却根本不知道它是如何实现——它可能是众多“形态”中的任何一种。
这样对象和属性相关联的函数称为方法。
如下:
>>> 'abc'.count('a')
1
>>> [1,2,'a'].count('a')
1
如果有一个变量x,无需知道它是字符串还是列表就能调用方法count:只要你向这个方法提供了一个字符作为参数,它就能正常运行。
标准库模块random包含一个名为choice的函数,它从序列中随机选择一个元素。
下面使用这个函数给变量提供一个值。
>>> from random import choice
>>> x=choice(['Hello, world!',[1,2,'e','e',4]])
可调用count。
>>> x.count('e')
2
在这里无需执行相关的检查,只有x有一个名为count的方法,它将单个字符作为参数并返回一个整数就行。
如果有人创建了包含这个方法的对象, 也可以像使用字符串和列表一样使用这种对象。
多态形式多样
每当无需知道对象是什么样的就能对其执行操作时,都是多态在起作用。
这不仅仅适用于方法,我们还通过内置运算符和函数大量使用了多态。
如下代码:
>>> 1 + 2
3
>>> 'Fish' + 'license'
'Fishlicense'
上述代码表明,加法运算符(+)既可用于数(这里是整数),也可用于字符串(以及其他类型的序列)。
假设要创建一个将两个对象相加的add函数,可像下面这样定义它(这与模块operator中的函数add等价,但效率更低):
def add(x, y):
return x + y
可使用众多不同类型的参数来调用这个函数。
>>> add(1,2)
3
>>> add('Fish','license')
'Fishlicense'
这种方式也许有点傻,但重点在于参数可以是任何支持加法的对象。
编写一个函数,通过打印一条消息来指出对象的长度,可以像下面这样做(它对参数的唯一要求是有长度,可对其执行函数len)。
def length_message(x):
print("The length of", repr(x), "is", len(x))
这个函数还使用了repr。
repr是多态的集大成者之一,可用于任何对象,如下:
>>> length_message('Fnord')
The length of 'Fnord' is 5
>>> length_message([1,2,3])
The length of [1, 2, 3] is 3
事实上,要破坏多态,唯一的办法是使用诸如type、issubclass 等函数显式地执行类型检查,但应尽可能避免以这种方式破坏多态。
重要的是,对象按你希望的那样行事,而非它是否是正确的类型(类)。
封装
封装(encapsulation)指的是向外部隐藏不必要的细节。
跟多态的概念很像,因为它们都是抽象的原则。它们都像函数一样,可帮助你处理程序的组成部分,让你无需关心不必要的细节。
但封装不同于多态。
多态让你无需知道对象所属的类(对象的类型)就能调用其方法,而封装让你无需知道对象的构造就能使用它。
示例:使用了多态但没有使用封装。
假设你有一个名为 OpenObject 的类。
>>> o = OpenObject() # 对象就是这样创建的
>>> o.set_name('Sir Lancelot')
>>> o.get_name()
'Sir Lancelot'
(通过像调用函数一样调用类)创建一个对象,将其关联到变量 o ,然后就可以使用方法 set_name 和 get_name 了。
如果o将其名称存储在全局变量global_name中,意味着使用OpenObject类的实例(对象)时,需要考虑global_name的内容。
必须确保无人能修改它。
不能创建多个OpenObject对象,因为它们共用同一个变量。
当设置一个对象的名称时,将自动设置另一个对象的名称。
对象是抽象的:当调用方法时,无需操心其他的事情,如避免干扰全局变量。
将名称“封装”在对象中,可将其作为一个属性即可。
属性是归属于对象的变量,就像方法一样。
使用属性而非全局变量重新编写前面的类,并将其重命名为CloseObject,就可像下面这样使用它:
>>> c = CloseObject()
>>> c.set_name('Sir Lancelot')
>>> c.get_name()
'Sir Lancelot'
对象的状态由其属性(如名称)描述。
对象的方法可能修改这些属性,因此对象将一系列函数(方法)组合起来,并赋予它们访问一些变量(属性)的权限,而属性可用于在两次函数调用之间存储值。
继承
继承是另一种偷懒的方式(这里是褒义)。
程序员总是想避免多次输入同样的代码。
如果已经有了一个类,要创建一个与之很像的类(可能只是新增了几个方法)。
例如,已经有了一个名为Shape的类,它知道如何将自己绘制到屏幕上。现在想创建一个名为Rectangle的类,但它不知道如何将自己绘制到屏幕上,而且还知道如何计算其面积。
Shape已经有方法draw,且效果很好。
不想重新编写方法draw时,可以让Rectangle继承Shape的方法,使得对Rectangle对象调用方法draw时,将自动调用Shape类的这个方法。
类
类到底是什么
类的定义——一种对象。
每个对象都属于特定的类,并被加粗样式称为该类的实例。
例如,看到一只鸟,这只鸟就是“鸟类”的一个实例。
鸟类是一个非常通用(抽象)的类,它有多个子类:如你看到的那只鸟可能属于子类“云雀”。
可将“鸟类”视为由所有鸟组成的集合,而“云雀”是其中一个子集。
一个类的对象为另一个类的对象的子集时,前者就是后者的子类。
因此“云雀”为“鸟类”的子类,而“鸟类”为“云雀”的超类。
注意:
在英语日常交谈中,使用复数来表示类,如birds(鸟类)和 larks(云雀)。
在Python中,约定使用单数并将首字母大写,如 Birds 和 Larks。
在面向对象编程中,子类关系意味深长,因为类是由其支持的方法定义的。
类的所有实例都有该类的所有方法,因此子类的所有实例都有超类的所有方法。
要定义子类,只需定义多出来的方法(还可能重写一些既有的方法)。
创建自定义类
要创建自定义类。
下面是一个简单的示例:
_metaclass_ = type # 如果你使用的是Python 2,请包含这行代码
class Person:
def set_name(self, name):
self.name = name
def get_name(self)
return self.name
def greet(self):
print("Hello,world! I'm {}.".format(self.name))
这个示例包含三个方法定义,它们类似于函数定义,但位于class语句内。
Person当然是类的名称。class语句创建独立的命名空间,用于在其中定义函数。
参数self是什么,它指向对象本身,是哪个对象呢?通过创建如下两个实例来说明这一点:
>>> foo = Person()
>>> bar = Person()
>>> foo.set_name('Luke Skywalker')
>>> bar.set_name('Anakin Skywalker')
>>> foo.greet()
Hello, world! I'm Luke Skywalker.
>>> bar.greet()
Hello, world! I'm Anakin Skywalker.
对 foo 调用 set_name 和 greet 时, foo 都会作为
第一个参数自动传递给它们。
实际上,可以随便给这个参数命名,但鉴于它总是指向对象本身,因此习惯上将其命名为 self 。
self 很有用,甚至必不可少。如果没有它,所有的方法都无法访问对象本身——要操作的属性所属的对象。与以前一样,也可以从外部访问这些属性。
>>> foo.name
'Luke Skywalker'
>>> bar.name = 'Yoda'
>>> bar.greet()
Hello, world! I'm Yoda.
属性、函数和方法
方法(更准确地说是关联的方法)将其第一个参数关联到它所属的实例,因此无需提供这个参数。
可以将属性关联到一个普通函数,但这样就没有特殊的 self 参数了。
>>> class Class:
... def method(self):
... print('I have a self!')
...
>>> def function():
... print("I don't...")
...
>>> instance = Class()
>>> instance.method() I have a self!
>>> instance.method = function
>>> instance.method() I don't...
注意,有没有参数 self 并不取决于是否以刚才使用的方式(如 instance.method )调用方法。
可以让另一个变量指向同一个方法。
>>> class Bird:
... song = 'Squaawk!'
... def sing(self):
... print(self.song)
...
>>> bird = Bird()
>>> bird.sing()
Squaawk!
>>> birdsong = bird.sing
>>> birdsong()
Squaawk!
变量 birdsong 指向的是关联的方法bird.sing ,这意味着它也能够访问参数 self (即它也被关联到类的实例)。
再谈隐藏
默认情况下,可从外部访问对象的属性。
封装时使用的示例。
>>> c.name
'Sir Lancelot'
>>> c.name = 'Sir Gumby'
>>> c.get_name()
'Sir Gumby'
可将属性定义为私有。
私有属性不能从对象外部访问,而只能通过存取器方法(如 get_name 和set_name )来访问。
要让方法或属性成为私有的(不能从外部访问),只需让其名称以两个下划线打头即可。
class Secretive:
def __inaccessible(self):
print("Bet you can't see me ...")
def accessible(self):
print("The secret message is:")
self.__inaccessible()
现在从外部不能访问 __inaccessible ,但在类中(如 accessible 中)依然可以使用它。
>>> s = Secretive()
>>> s.__inaccessible()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: Secretive instance has no attribute '__inaccessible'
>>> s.accessible()
The secret message is:
Bet you can't see me ...
在类定义中,对所有以两个下划线打头的名称都进行转换,即在开头加上一个下划线和类名。
>>> Secretive._Secretive__inaccessible
<unbound method Secretive.__inaccessible>
只要知道这种幕后处理手法,就能从类外访问私有方法,然而不应这样做。
>>> s._Secretive__inaccessible()
Bet you can't see me ...
如果你不希望名称被修改,又想发出不要从外部修改属性或方法的信号,可用一个下划线打头。
这虽然只是一种约定,但也有些作用。
例如, from module import * 不会导入以一个下划线
打头的名称 。
类的命名空间
def foo(x): return x * x
大致等价于
foo = lambda x: x * x
它们都创建一个返回参数平方的函数,并将这个函数关联到变量 foo 。
可以在全局(模块)作用域内定义名称 foo ,也可以在函数或方法内定义。
在 class 语句中定义的代码都是在一个特殊的命名空间(类的命名空间)内执行的,而类的所有成员都可访问这个命名空间。
类定义其实就是要执行的代码段。
例如,在类定义中,并非只能包含 def 语句。
>>> class C:
... print('Class C being defined...')
...
Class C being defined...
>>>
下面的代码:
class MemberCounter:
members = 0
def init(self):
MemberCounter.members += 1
>>> m1 = MemberCounter()
>>> m1.init()
>>> MemberCounter.members
1
>>> m2 = MemberCounter()
>>> m2.init()
>>> MemberCounter.members
2
上述代码在类作用域内定义了一个变量,所有的成员(实例)都可访问它,这里使用它来计算类实例的数量。
每个实例都可访问这个类作用域内的变量,就像方法一样。
>>> m1.members
2
>>> m2.members
2
在一个实例中给属性 members 赋值:
>>> m1.members = 'Two'
>>> m1.members
'Two'
>>> m2.members
2
新值被写入 m1 的一个属性中,这个属性遮住了类级变量。
指定超类
要指定超类,可在 class 语句中的类名后加上超类名,并将其用圆括号括起。
class Filter:
def init(self):
self.blocked = []
def filter(self, sequence):
return [x for x in sequence if x not in self.blocked]
class SPAMFilter(Filter): # SPAMFilter是Filter的子类
def init(self): # 重写超类Filter的方法init
self.blocked = ['SPAM']
Filter 是一个过滤序列的通用类。
它不会过滤掉任何东西。
>>> f = Filter()
>>> f.init()
>>> f.filter([1, 2, 3])
[1, 2, 3]
Filter 类的用途在于可用作其他类(如将 ‘SPAM’ 从序列中过滤掉的 SPAMFilter 类)的基类(超类)。
>>> s = SPAMFilter()
>>> s.init()
>>> s.filter(['SPAM', 'SPAM', 'SPAM', 'SPAM', 'eggs', 'bacon', 'SPAM'])
['eggs', 'bacon']
注意 SPAMFilter 类的定义中有两个要点:
- 以提供新定义的方式重写了 Filter 类中方法 init 的定义。
- 直接从 Filter 类继承了方法 filter 的定义,因此无需重新编写其定义。
第二点说明了继承很有用的原因:可以创建大量不同的过滤器类,它们都从 Filter 类派生而来,并且都使用已编写好的方法 filter 。
深入深讨继承
要确定一个类是否是另一个类的子类,可使用内置方法 issubclass 。
>>> issubclass(SPAMFilter, Filter)
True
>>> issubclass(Filter, SPAMFilter)
False
可访问其特殊属性 __bases__知道一个类的基类 。
>>> SPAMFilter.__bases__
(<class __main__.Filter at 0x171e40>,)
>>> Filter.__bases__
(<class 'object'>,)
要确定对象是否是特定类的实例,可使用 isinstance 。
>>> s = SPAMFilter()
>>> isinstance(s, SPAMFilter)
True
>>> isinstance(s, Filter)
True
>>> isinstance(s, str)
False
s 是 SPAMFilter 类的(直接)实例,但它也是 Filter 类的间接实例,因为 SPAMFilter是 Filter 的子类。
即所有 SPAMFilter 对象都是 Filter 对象。
要获悉对象属于哪个类,可使用属性 _class_ 。
>>> s.__class__
<class __main__.SPAMFilter at 0x1707c0>
多个超类
复数形式的 _bases_。
可使用它来获悉类的基类,而基类可能有多个。
说明如何继承多个类,下面来创建几个类。
class Calculator:
def calculate(self, expression):
self.value = eval(expression)
class Talker:
def talk(self):
print('Hi, my value is', self.value)
class TalkingCalculator(Calculator, Talker):
pass
子类 TalkingCalculator 本身无所作为,其所有的行为都是从超类那里继承的。
从Calculator 那里继承 calculate ,并从 Talker 那里继承 talk。
>>> tc = TalkingCalculator()
>>> tc.calculate('1 + 2 * 3')
>>> tc.talk()
Hi, my value is 7
这被称为多重继承,是一个功能强大的工具。
注意,除非万不得已,否则应避免使用多重继承,因为在有些情况下,它可能带来意外的“并发症”。
使用多重继承时,有一点务必注意:如果多个超类以不同的方式实现了同一个方法(即有多个同名方法),必须在 class 语句中小心排列这些超类,因为位于前面的类的方法将覆盖位于后面的类的方法。
像下面这样反转超类的排列顺序:
class TalkingCalculator(Talker, Calculator): pass
将导致 Talker 的方法 talk 是可以访问的。
多个超类的超类相同时,查找特定方法或属性时访问超类的顺序称为方法解析顺序(MRO)。
接口和内省
接口的概念与多态相关。
处理多态对象时,你只关心其接口(协议)——对外暴露的方法和属性。
在Python中,不显式地指定对象必须包含哪些方法才能用作参数。
通常,要求对象遵循特定的接口(即实现特定的法),但也可提出要求:不是直接调用方法并期待一切顺利,而是检查所需的方法是否存在;如果不存在,就改弦易辙。
要查看对象中存储的所有值,可检查其__dict__ 属性。
如果要确定对象是由什么组成的,应研究模块 inspect 。
这个模块主要供高级用户创建对象浏览器(让用户能够以图形方式浏览Python对象的程序)以及其他需要这种功能的类似程序。
抽象基类
鸭子类型,即假设所有对象都能完成其工作,同时偶尔使用 hasattr 来检查所需的方法是否存
在。
模块 abc,这个模块为所谓的抽象基类提供了支持。
一般而言,抽象类是不能(至少是不应该)实例化的类,其职责是定义子类应实现的一组抽象方法。
一个简单的示例:
from abc import ABC, abstractmethod
class Talker(ABC):
@abstractmethod
def talk(self):
pass
形如 @this 的东西被称为装饰器。
使用@abstractmethod 来将方法标记为抽象的——在子类中必须实现的方法。
抽象类(即包含抽象方法的类)最重要的特征是不能实例化。
>>> Talker()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Talker with abstract methods talk
假设像下面这样从它派生出一个子类:
class Knigget(Talker):
pass
由于没有重写方法 talk ,因此这个类也是抽象的,不能实例化。
可重新编写这个类,使其实现要求的方法。
class Knigget(Talker):
def talk(self):
print("Ni!")
这是抽象基类的主要用途,而且只有在这种情形下使用
isinstance 才是妥当的:如果先检查给定的实例确实是 Talker 对象,就能相信这个实例在需要的情况下有方法 talk 。
>>> k = Knigget()
>>> isinstance(k, Talker)
True
>>> k.talk()
Ni!
还缺少一个重要的部分——让 isinstance 的多态程度更高的部分。
只要实现了方法 talk ,即便不是 Talker 的子类,依然能
够通过类型检查。
创建另一个类。
class Herring:
def talk(self):
print("Blub.")
这个类的实例能够通过是否为 Talker 对象的检查,可它并不是 Talker 对象。
>>> h = Herring()
>>> isinstance(h, Talker)
False
可从 Talker 派生出 Herring ,但 Herring 可能是从他人的模块中导入的。
可将 Herring 注册为 Talker(而不从 Herring 和 Talker 派生出子类),这样所有的 Herring 对象都将被视为 Talker 对象。
>>> Talker.register(Herring)
<class '__main__.Herring'>
>>> isinstance(h, Talker)
True
>>> issubclass(Herring, Talker)
True
这种做法存在一个缺点,就是直接从抽象类派生提供的保障没有了。
>>> class Clam:
... pass
...
>>> Talker.register(Clam)
<class '__main__.Clam'>
>>> issubclass(Clam, Talker)
True
>>> c = Clam()
>>> isinstance(c, Talker)
True
>>> c.talk()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Clam' object has no attribute 'talk'
应将 isinstance 返回 True 视为一种意图表达。
在这里, Clam 有成为 Talker 的意图。
关于面向对象设计的一些思考
- 将相关的东西放在一起。如果一个函数操作一个全局变量,最好将它们作为一个类的属性和方法。
- 不要让对象之间过于亲密。方法应只关心其所属实例的属性,对于其他实例的状态,让它们自己去管理就好了。
- 慎用继承,尤其是多重继承。继承有时很有用,但在有些情况下可能带来不必要的复杂性。要正确地使用多重继承很难,要排除其中的bug更难。
- 保持简单。让方法短小紧凑。一般而言,应确保大多数方法都能在30秒内读完并理解。对于其余的方法,尽可能将其篇幅控制在一页或一屏内。
确定需要哪些类以及这些类应包含哪些方法时,尝试像下面这样做:
(1) 将有关问题的描述(程序需要做什么)记录下来,并给所有的名词、动词和形容词加上标记。
(2) 在名词中找出可能的类。
(3) 在动词中找出可能的方法。
(4) 在形容词中找出可能的属性。
(5) 将找出的方法和属性分配给各个类。
进一步改进模型,可像下面这样做:
(1) 记录(或设想)一系列用例,即使用程序的场景,并尽力确保这些用例涵盖了所有的功能。
(2) 透彻而仔细地考虑每个场景,确保模型包含了所需的一切。如果有遗漏,就加上;
如果有不太对的地方,就修改。不断地重复这个过程,直到对模型满意为止。
小结
本章介绍的新函数
学习参考资料:
《Python基础教程》 第3版