我们将一个类型及其关联的一组操作组成的整体叫做类,并称这些操作为类的属性。如果类的属性是函数的话,我们也称它为类的方法。当我们用类创建了一个对象时,称这个对象为类的实例。
类在Python中通过class
关键字定义,最简单的类就是空类:
class Firstclass:
pass
上述代码创建了一个类,类名叫做Firstclass
(在Python中,模块名一般小写字母开头,类名一般大写字母开头,请尽量遵循这一习惯),类的内部什么也没有,当我们在调试面板上调试该脚本时,会产生以下结果:
>>> x=Firstclass
>>> x
<class '__main__.Firstclass'>
>>> y=Firstclass()
>>> y
<__main__.Firstclass object at 0x000001E4350DAA58>
可以看到,Firstclass
是class
对象,而Firstclass()
才是object
对象。也就是说,Firstclass
是类,Firstclass()
是类的实例。
Python规定,只要是在类内部的顶层赋值的任何变量,都会成为类的属性(需要提醒的是,def
也是一种赋值运算),比如以下脚本:
class Firstclass:
version='1.0.0'
def setdata(self,value):
self.data=value
def display(self):
print(self.data)
当我们在调试面板上调试该脚本时,会产生以下结果:
>>> x=Firstclass()
>>> x.version
'1.0.0'
>>> x.setdata(2.71828)
>>> x.display()
2.71828
>>> y=Firstclass()
>>> y.version
'1.0.0'
>>> y.setdata('python')
>>> y.display()
python
我们给Firstclass
类写了三个属性,version
,setdata
和display
,其中,version
是字符串对象,剩下两个都是函数对象。而在setdata
方法内,我们又通过传入的self
参数给Firstclass
的实例(即self
代表的对象)增加了一个data
属性,data
对象的类型由传入的value
参数决定。
值得一提的是,这两个函数的self
参数都不是必须叫self
的,只不过按照惯例这样取而已。Python规定,在类方法函数内,第一个参数会引用正处理的实例对象,对self
的属性做赋值运算会创建或修改实例内的数据。所以类的方法在定义的时候至少要有一个参数,且最好写为self
。
从刚才的调试代码中可以看出,Python中使用点号来访问类的属性。当解释器遇到object.attribute
句式时,就会在类object
中寻找是否存在attribute
属性。如果存在,修改它,否则创建它。因此,在刚才的调试代码之后还可以追加:
>>> x.new_attri=3
>>> x.new_attri
3
这样做是不会报错的。
除了通过刚才的方式为类编写属性之外,类也可以引入其他类来进行定义。我们称被引用的类为父类,引用父类的类为父类的子类。由子类产生的实例会继承父类的属性,我们称这一行为为类的继承。在Python当中,实例继承自类,而类继承自超类。类在继承其他类以后还可以自行添加新的属性,或者修改父类的属性,我们称这样的修改为重载。请注意,重载不会修改父类的内容。
Python规定,要继承另一个类的属性,需要把该类列在class语句开头的括号中。如以下代码:
class Firstclass:
version='1.0.0'
def setdata(self,value):
self.data=value
def display(self):
print(self.data)
class Secondclass(Firstclass):
new_version='2.0.0'
调试代码如下:
>>> x=Secondclass()
>>> x.version
'1.0.0'
>>> x.new_version
'2.0.0'
>>> x.setdata(3.14)
>>> x.display()
3.14
我们可以看到,创建的Secondclass()
实例拥有Firstclass()
的所有属性,还可以添加自己新的属性new_version
。
上文提到,在Python中访问类的属性是通过查找的方式,而查找会优先查找当前类,当前类没有才会到父类里面找。因此,如果我们要在子类中重载父类的属性,只要赋一个与该属性相同的变量名即可。如以下代码:
class Firstclass:
version='1.0.0'
def setdata(self,value):
self.data=value
def display(self):
print(self.data)
class Secondclass(Firstclass):
new_version='2.0.0'
def display(self):
print('data='+self.data)
调试代码如下:
>>> x=Secondclass()
>>> x.setdata('6.62')
>>> x.display()
data=6.62
通过在Secondclass
中也定义一个display()
的方式,即可对Firstclass
的display
属性进行重载。
除了可以重载父类的属性,我们还可以重载内置类型的运算,如加法、切片、打印等。通过重载运算符,我们可以使得自己编写的类拥有类似于内置类型那样的行为。
内置类型的运算也是通过其类的方法来实现的,在Python中,这些方法会以双下划线命名(即__X__
的形式)以示区分。当实例对象继承了这些方法时,内置运算就会调用这些方法。比如,如果一个实例继承了__add__
方法,则当对象出现在+
表达式时,__add__
方法就会被调用。如果一个实例继承了__init__
方法,则当新的实例对象被构造时,__init__
方法就会被调用。
比如以下代码:
class Firstclass:
version='1.0.0'
def setdata(self,value):
self.data=value
def display(self):
print(self.data)
class Secondclass(Firstclass):
new_version='2.0.0'
def display(self):
print('data='+self.data)
class Thirdclass(Secondclass):
def __init__(self,value):
self.data=value
调试代码如下:
>>> x=Thirdclass(6.67)
>>> x.data
6.67
可以看到,Thirdclass
在生成实例时会传递一个参数(例如6.67
),这是传给__init__
构造函数内的参数value
的,而__init__
会将其赋值给self.data
。直接效果是,Thirdclass
在构建时自动设置data
属性,而不需要构建之后请求setdata
调用。
目前为止,我们已经基本写出了一个“像模像样”的类了,因为一般情况下,由于我们需要让类立即在其新建的实例内添加属性,几乎每个实际的类都会出现一个__init__
方法的重载。此外,我们也需要注意,尽量不要给自己的类起双下划线命名的属性,这可能造成隐藏的错误。
再补充几点,在Python中,__X__
定义的是特殊方法,一般是系统定义的变量 ;_X
表示的是保护类型的变量,只能允许其本身与子类进行访问,不能用于 from module import *
;__X
表示的是私有类型变量, 只允许这个类本身进行访问。
方法 | 重载 | 调用 |
---|---|---|
__init__ |
构造函数 | X=Class(args) |
__add__ |
运算符+ |
X+Y,X+=Y |
__or__ |
运算符| |
X|Y,X|=Y |
__str__ |
打印 | print(X) |
__getitem__ |
索引运算 | X[key],X[i:j] |
__setitem__ |
索引赋值 | X[key]=value,X[i:j]=sequence |
如果在类中定义了(或继承了)__getitem__
和__setitem__
方法的话,解释器就可以在类的实例进行索引和索引赋值时拦截其操作,并调用用户自己写的方法。例如,以下的代码定义了一个类,它的索引将返回索引值的平方。
class Squares:
def __getitem__(self,idx):
return idx**2
调试代码如下:
>>> x=Squares()
>>> x[2]
4
不仅如此,我们还可以用同样的操作拦截切片。为了理解这一点,首先我们需要了解到,切片操作[i:j:k]
本质上是一种语法糖,它真正传入__getitem__
的参数是一个分片对象slice(i,j,k)
。因此我们可以编写以下类,它将返回data
列表中的对应索引或切片:
class Indexer:
data=[1,2,3,4,5,6]
def __getitem__(self,idxs):
return self.data[idxs]
调试代码如下:
>>> x=Indexer()
>>> x[2]
3
>>> x[1:5:2]
[2, 4]
其实,__getitem__
也是Python中一种重载迭代的方式,如果定义了这个方法,那么当for
循环语句调用到这个类的实例的时候,for
循环内的每次循环都会调用类的__getitem__
,并持续传递更高的偏移值。对于刚才的Indexer
类,如果我们将其生成的实例放在for
循环中,像这样:
x=Indexer()
for i in x:
print(i,end=' ')
会产生如下输出:
1 2 3 4 5 6
在Python中,任何支持for
循环的类也会自动支持Python所有的迭代环境,例如成员关系测试in
、列表解析、内置函数map
等等。
尽管__getitem__
技术很有效,但在Python中,所有的迭代环境都会优先尝试__iter__
方法,再尝试__getitem__
。所以,一般来说,我们应该优先使用__iter__
。
从技术角度来讲,迭代环境是通过调用内置函数iter
去尝试寻找__iter__
方法来实现的,这个方法返回的内容是一个迭代器。如果找到了,Python就会重复调用这个迭代器的next
方法,直到遇到StopIteration
异常。而如果没有找到,才会改用__getitem__
机制,直到遇到IndexError
异常。
下面,我们将使用__iter__
机制来完成刚才生成平方值的操作。
class Squares:
def __init__(self,start,stop):
self.value=start-1
self.stop=stop
def __iter__(self):
return self
def __next__(self):
if self.value==self.stop:
raise StopIteration
self.value+=1
return self.value**2
x=Squares(1,5)
for i in x:
print(i,end=' ')
输出:
1 4 9 16 25
此外,由于__iter__
对象能够在调用的过程中保留状态信息(如果我们不用for
循环而手动不断__next__
也是可以实现相同的功能的,但这样的方法却不能用在__getitem__
上),所以__iter__
比__getitem__
有更好的通用性。
但是,在有些时候,__iter__
也有缺点。首先来说,__iter__
只用于迭代,而不重载索引表达式,因而我们无法使用Squares(1,5)[0]
的方式来调用它。其次,__iter__
只会迭代一次,之后返回的都是空。比如以下的测试代码:
>>> x=Squares(1,5)
>>> [n for n in x]
[1, 4, 9, 16, 25]
>>> [n for n in x]
[]
下面,我们将用以上知识编写一个程序。该程序包含一个数列类基类以及它的三个子类,等差数列类,等比数列类和斐波那契数列类。代码如下:
class Array:
def __init__(self,start=0):
self.start=start
def _advance(self):
return self.cur+1
def __iter__(self):
return self
def __next__(self):
if self.len>0:
self.len-=1
buf=self.cur
self.cur=self._advance()
return buf
else:
raise StopIteration
def show(self,num):
self.cur=self.start
self.next=None
self.len=num
print(list(self))
class Arithmetic(Array):
def __init__(self,start,increment):
Array.__init__(self,start)
self.increment=increment
def _advance(self):
return self.cur+self.increment
class Geometric(Array):
def __init__(self,start,base):
Array.__init__(self,start)
self.base=base
def _advance(self):
return self.cur*self.base
class Fibonacci(Array):
def __init__(self,first,second):
Array.__init__(self,first)
self.second=second
def _advance(self):
if self.next==None:
self.next=self.second
self.cur,self.next=self.next,self.cur+self.next
return self.cur
a=Array(4)
a.show(9)
b=Arithmetic(3,2)
b.show(10)
c=Geometric(1,2)
c.show(3)
d=Fibonacci(2,1)
d.show(7)
d.show(5)
##输出如下:
##[4, 5, 6, 7, 8, 9, 10, 11, 12]
##[3, 5, 7, 9, 11, 13, 15, 17, 19, 21]
##[1, 2, 4]
##[2, 1, 3, 4, 7, 11, 18]
##[2, 1, 3, 4, 7]
下面,我们一个类一个类的分析一下这个程序:
首先先看基类:
class Array:
def __init__(self,start=0):
self.start=start
def _advance(self):
return self.cur+1
def __iter__(self):
return self
def __next__(self):
if self.len>0:
self.len-=1
buf=self.cur
self.cur=self._advance()
return buf
else:
raise StopIteration
def show(self,num):
self.cur=self.start
self.next=None
self.len=num
print(list(self))
在这个类的构造函数中,我们初始化了类属性start
,用来存储数列的首项。中间三个方法暂时不看,当我们调用了这个类的show
方法时,我们会给这个类的实例先增加三个属性:cur
,next
和len
,分别用来保存数列的当前值(用于输出和向后迭代)、后继值(为了妥协斐波那契数列)以及数列长度(用于控制什么时候迭代结束)。然后,我们就要调用list(self)
。那么这个self
是什么呢?由于我们重载了__iter__
方法,因此这里的self
是一个迭代器对象,而当我们使用到这个迭代器对象时,我们知道,它会不断调用__next__
方法。那么再来看__next__
,我们会发现,它会首先检查len
数据是否合法,如果不合法,则抛出StopIteration
异常停止迭代;否则,它会先将len
,即数组的长度减一,再用buf
缓冲变量保存当前值cur
,然后用_advance
方法更新cur
,最后返回buf
的值,也就是原本的cur
值。至于为什么要如此大费周章而不是直接返回self._advance()
,是因为我们无法先return
掉cur
的值再去更新cur
(这里的实现确实有不够优美的地方,暂时还没想到解决方法)。
看完了最长的基类,下面几个类就很简单了。可以看到,子类都只是重载了__init__
方法和_advance
方法,用于它们各自需要的更新操作。等差数列的更新方式就是当前值cur
加上公差increment
;等比数列就是cur
乘以公比base
;斐波那契数列就是后继项next
,并更新当前项cur
为next
,next
为cur
加next
。
从这个程序中我们也可以看出,面向对象的继承和多态思想的恰当使用能够有效的降低代码的耦合度,提高代码的复用率,这一点确实解决了面向过程编程中可能或业已出现的许多问题。