"深入了解面向对象编程。"
01 面临问题
继续跟着官网学Python,第9章类。
类(Class)是很多面向对象编程语言中的一个关键概念,实现信息封装的基础,类是一种用户定义的引用数据类型,每个类包含数据说明和一组操作数据或传递消息的函数,类的实例称为对象。
上篇我们学习了命名空间和作用域,了解名称和对象,初探类学习了定义,非常简单,当定义了一个类对象后,我们能干嘛?
下面继续。
02 怎么办
初探类
2) 类对象
类对象支持两种操作:属性引用和实例化。
属性引用 使用Python中所有属性引用所使用的标准语法: obj.name
。
有效的属性名称是类对象被创建时存在于类命名空间中的所有名称,也包括函数名称。
其实Python万物皆对象,都可以合法的使用obj.name
,只要name
是对象obj
命名空间的合法名称,比如上面scope_test
函数也可以.name
。
那么如何看一个对象(包括类)的命名空间中的所有名称呢?试试dir(obj)
。
如下定义测试类:
class MyClass:
"""A simple example class"""
i = 12345
def f(self):
return 'hello world'
MyClass.i
和 MyClass.f
就是有效的属性引用,将分别返回一个整数和一个函数对象。
类属性也可以被赋值,因此可以通过赋值来更改 MyClass.i
的值。 __doc__
也是一个有效的属性,将返回所属类的文档字符串: "A simple example class"。
好像其他语言的类只有被实例化后才能操作?
实例化使用函数表示法,可以把类对象视为是返回该类的一个新实例的不带参数的函数。 如上述类x = MyClass()
会创建类的新实例并将此对象分配给局部变量x
。
实例化操作(“调用”类对象)会创建一个空对象。 许多类喜欢创建带有特定初始状态的自定义实例。
为此类定义可能包含一个名为 __init__()
的特殊方法(函数),就像这样:
def __init__(self):
self.data = []
当一个类定义了 __init__()
方法时,类的实例化操作会自动为新创建的类实例发起调用 __init__()
。
当然,__init__()
方法还可以有额外参数以实现更高灵活性。 在这种情况下,提供给类实例化运算符的参数将被传递给 __init__()
。 如:
>>>
>>> class Complex:
... def __init__(self, realpart, imagpart):
... self.r = realpart
... self.i = imagpart
...
>>> x = Complex(3.0, -4.5)
>>> x.r, x.i
(3.0, -4.5)
注意类内部方法第一个参数都是self
,代表这个类自身,然后下面的赋值就显而易见。
上面定义了一个复数类,__init()__
初始化函数接受两个参数实部和虚部,然后分别赋值为类的r
和i
属性(发现不用提前定义)。
然后初始化一个复数类实例Complex(3.0, -4.5)
赋值给x
,发现就是我们想要的复数,当然你可以继续添加其他方法完善这个复数类。
3) 实例对象
实例对象做什么? 实例对象理解的唯一操作是属性引用。 有两种有效的属性名称:数据属性和方法。
数据属性不需要声明;像局部变量一样,它们将在第一次被赋值时产生。 例如,如果 x
是上面创建的 MyClass
的实例,则以下代码段将打印数值 16
,且不保留任何追踪信息:
x.counter = 1
while x.counter < 10:
x.counter = x.counter * 2
print(x.counter)
del x.counter
就是类的数据属性可以被修改和删除。
另一类实例属性引用称为方法。 方法是“从属于”对象(类实例)的函数。
在 Python 中,方法这个术语并不是类实例所特有的:其他对象也可以有方法。 例如,列表对象具有 append, insert, remove, sort 等方法。 然而,在以下讨论中,我们使用方法一词将专指类实例对象的方法,除非另外显式地说明。
实例对象的有效方法名称依赖于其所属的类。 根据定义,一个类中所有是函数对象的属性都是定义了其实例的相应方法。 因此在我们的示例中,x.f
是有效的方法引用,因为 MyClass.f
是一个函数,而 x.i
不是方法,因为 MyClass.i
不是一个函数。 但是 x.f
与 MyClass.f
并不是一回事 --- 它是一个方法对象,不是函数对象。
有点绕,我不觉得这个概念暂时不理解不影响,反之知道实例.函数可以正确工作就好。
4) 方法对象
通常,方法在绑定后立即被调用x.f()
,在 MyClass
示例中,这将返回字符串 'hello world'
。 但是,立即调用一个方法并不是必须的: x.f
是一个方法对象,它可以被保存起来以后再调用。 例如:
xf = x.f
while True:
print(xf())
将一直打印Hello world
。
当一个方法被调用时到底发生了什么?
你可能已经注意到上面调用 x.f()
时并没有带参数,虽然 f()
的函数定义指定了一个参数self
。
这个参数发生了什么事? 当不带参数地调用一个需要参数的函数时 Python 肯定会引发异常即使参数实际未被使用。
方法的特殊之处就在于实例对象会作为函数的第一个参数被传入,也就是self
,这就是方法和函数的区别?应该是了。
在我们的示例中,调用 x.f()
其实就相当于 MyClass.f(x)
。
总之,调用一个具有 n
个参数的方法就相当于调用再多一个参数的对应函数,这个参数值为方法所属实例对象,位置在其他参数之前。
当然方法对象简单直接很多,关系需要理解函数的self参数代表自身即可。
如果你仍然无法理解方法的运作原理,那么查看实现细节可能会澄清问题。 当一个实例的非数据属性被引用时,将搜索实例所属的类。
如果名称表示一个属于函数对象的有效类属性,会通过合并打包(指向)实例对象和函数对象到一个抽象对象中的方式来创建一个方法对象:这个抽象对象就是方法对象。
当附带参数列表调用方法对象时,将基于实例对象和参数列表构建一个新的参数列表,并使用这个新参数列表调用相应的函数对象。
觉得有点绕,反正记住类实例.方法少传一个参数就可。
5) 类变量和实例变量
实例变量用于每个实例的唯一数据,而类变量用于类的所有实例共享的属性和方法,比如下面的狗类定义和测试:
class Dog:
kind = 'canine' #类变量,所有实例共享
def __init__(self, name):
self.name = name # 归属实例的唯一变量,或者说每个实例不一样
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.kind # shared by all dogs
'canine'
>>> e.kind # shared by all dogs
'canine'
>>> d.name # unique to d
'Fido'
>>> e.name # unique to e
'Buddy'
然后创建两个狗实例d和e,他们是相同的品种,但是有不同的名字。
前面说过,共享数据可能在涉及 可变(mutable) 对象例如列表和字典的时候导致令人惊讶的结果。
如以下代码中的 tricks
列表不应该被用作类变量,因为所有的 Dog 实例将只共享一个单独的列表:
class Dog:
tricks = [] # mistaken use of a class variable
def __init__(self, name):
self.name = name
def add_trick(self, trick):
self.tricks.append(trick)
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks # unexpectedly shared by all dogs
['roll over', 'play dead']
由于错误使用了类变量,导致实例e修改tricks
,另一个实例d也被修改,这是不符合预期的。
正确的类设计应该使用实例变量:
class Dog:
def __init__(self, name):
self.name = name
self.tricks = [] # creates a new empty list for each dog
def add_trick(self, trick):
self.tricks.append(trick)
>>> d = Dog('Fido')
>>> e = Dog('Buddy')
>>> d.add_trick('roll over')
>>> e.add_trick('play dead')
>>> d.tricks
['roll over']
>>> e.tricks
['play dead']
补充说明
数据属性会覆盖掉具有相同名称的方法属性。
为了避免会在大型程序中导致难以发现的错误的意外名称冲突,明智的做法是使用某种约定来最小化冲突的发生几率。其实也算编码规范的一种。
可能的约定包括方法名称使用大写字母,属性名称加上独特的短字符串前缀(或许只加一个下划线),或者是用动词来命名方法,而用名词来命名数据属性。
数据属性可以被方法以及一个对象的普通用户(“客户端”)所引用。
换句话说,类不能用于实现纯抽象数据类型。
实际上,在 Python 中没有任何东西能强制隐藏数据 --- 它是完全基于约定的。
客户端应当谨慎地使用数据属性 --- 客户端可能通过直接操作数据属性的方式破坏由方法所维护的固定变量。
请注意客户端可以向一个实例对象添加他们自己的数据属性而不会影响方法的可用性,只要保证避免名称冲突。
再次提醒,在此使用命名约定可以省去许多令人头痛的麻烦。
在方法内部引用数据属性(或其他方法!)并没有简便方式(就是需要用self),实际上提升了方法的可读性:当浏览一个方法代码时,不会存在混淆局部变量和实例变量的机会。
方法的第一个参数常常被命名为self
,这也不过就是一个约定: self 这一名称在Python 中绝对没有特殊含义。 但是要注意,不遵循此约定会使得你的代码对其他 Python 程序员来说缺乏可读性。
任何一个作为类属性的函数都为该类的实例定义了一个相应方法。
我理解就算只要为了class缩进关键字内的函数,类实例都可以作为方法属性调用,不存在什么私有函数啥的。
函数定义的文本并非必须包含于类定义之内:将一个函数对象赋值给一个局部变量也是可以的,如:
# Function defined outside the class
def f1(self, x, y):
return min(x, x+y)
class C:
f = f1
def g(self):
return 'hello world'
h = g
现在 f
, g
和 h
都是 C
类的引用函数对象的属性,因而它们就都是 C
的实例的方法 --- 其中 h
完全等同于 g
。
这只是示例,实际不建议使用,老老实实的按照标准语法吧。
方法(就是def语句那个,就是函数,只是有self参数,在class内部,故称之为方法)可以通过使用 self
参数的方法属性调用其他方法:
class Bag:
def __init__(self):
self.data = []
def add(self, x):
self.data.append(x)
def addtwice(self, x):
self.add(x)
self.add(x)
方法可以通过与普通函数相同的方式引用全局名称。 与方法相关联的全局作用域就是包含其定义的模块。 (类永远不会被作为全局作用域。)
虽然我们很少会有充分的理由在方法中使用全局作用域,但全局作用域存在许多合法的使用场景:举个例子,导入到全局作用域的函数和模块可以被方法所使用,在其中定义的函数和类也一样。
通常,包含该方法的类本身是在全局作用域中定义的,而在下一节中我们将会发现为何方法需要引用其所属类的很好的理由。
每个值都是一个对象,因此具有 类 (也称为 类型),并存储为 object.__class__
。
__class__属性测试
难道1不是作为内置的int
类型,有啥特别的?
继承
如果不支持继承,语言特性就不值得称为“类”。派生类(继承)定义的语法如下所示:
class DerivedClassName(BaseClassName):
<statement-1>
……
<statement-N>
和之前不一样的地方,类定义()中多了一个父类名称,名称 BaseClassName
必须定义于包含派生类定义的作用域中,也允许用其他任意表达式代替基类名称所在的位置,如其他模块中的基类:class DerivedClassName(modname.BaseClassName):
派生类定义的执行过程与基类相同。 当构造类对象时,基类会被记住。 此信息将被用来解析属性引用:如果请求的属性在类中找不到,搜索将转往基类中进行查找。 如果基类本身也派生自其他某个类,则此规则将被递归地应用。
也就是先找自身,再找父类,这就是覆盖的基础。
派生类的实例化没有任何特殊之处: DerivedClassName()
会创建该类的一个新实例。
方法引用将按以下方式解析:搜索相应的类属性,如有必要将按基类继承链逐步向下查找,如果产生了一个函数对象则方法引用就生效。
其实就是上面说的。
派生类可能会重载其基类的方法。 因为方法在调用同一对象的其他方法时没有特殊权限,调用同一基类中定义的另一方法的基类方法最终可能会调用覆盖它的派生类的方法。
这句话暂时理解不深,也许以后遇到问题才能知道,猜测派生类的同名方法使用要小心。
在派生类中的重载方法实际上可能想要扩展而非简单地替换同名的基类方法。
有一种方式可以简单地直接调用基类方法:即调用 BaseClassName.methodname(self, arguments)
。 有时这对客户端来说也是有用的。 (请注意仅当此基类可在全局作用域中以 BaseClassName 的名称被访问时方可使用此方式。)
Python有两个内置函数可被用于继承机制:
-
使用
isinstance()
来检查一个实例的类型:isinstance(obj, int)
仅会在obj.__class__
为int
或某个派生自int
的类时为True
。 -
使用
issubclass()
来检查类的继承关系:issubclass(bool, int)
为True
,因为bool
是int
的子类。 但是,issubclass(float, int)
为False
,因为float
不是int
的子类。
多重继承
Python支持多重继承,如:
class DerivedClassName(Base1, Base2, Base3):
<statement-1>
……
<statement-N>
对于多数应用来说,在最简单的情况下,你可以认为搜索从父类所继承属性的操作是深度优先、从左至右的,当层次结构中存在重叠时不会在同一个类中搜索两次。 因此,如果某一属性在 DerivedClassName
中未找到,则会到 Base1
中搜索它,然后(递归地)到 Base1
的基类中搜索,如果在那里未找到,再到 Base2
中搜索,依此类推。
真实情况比这个更复杂一些;方法解析顺序会动态改变以支持对 super()
的协同调用。
动态改变顺序是有必要的,因为所有多重继承的情况都会显示出一个或更多的菱形关联(即至少有一个父类可通过多条路径被最底层类所访问)。
例如,所有类都是继承自 object
,因此任何多重继承的情况都提供了一条以上的路径可以通向 object
。
为了确保基类不会被访问一次以上,动态算法会用一种特殊方式将搜索顺序线性化, 保留每个类所指定的从左至右的顺序,只调用每个父类一次,并且保持单调(即一个类可以被子类化而不影响其父类的优先顺序)。 总而言之,这些特性使得设计具有多重继承的可靠且可扩展的类成为可能。 要了解更多细节,请参阅 https://www.python.org/download/releases/2.3/mro/。(留待以后再看)
私有变量
那种仅限从一个对象内部访问的“私有”实例变量在 Python 中并不存在。
但是,大多数 Python 代码都遵循这样一个约定:带有一个下划线的名称 (例如 _spam) 应该被当作是 API 的非公有部分 (无论它是函数、方法或是数据成员)。
这应当被视为一个实现细节,可能不经通知即加以改变。
由于存在对于类私有成员的有效使用场景(例如避免名称与子类所定义的名称相冲突),因此存在对此种机制的有限支持,称为 名称改写。
任何形式为 __spam
的标识符(至少带有两个前缀下划线,至多一个后缀下划线)的文本将被替换为 _classname__spam
,其中 classname
是去除了前缀下划线的当前类名称。
这种改写不考虑标识符的句法位置,只要它出现在类定义内部就会进行。
名称改写有助于让子类重载方法而不破坏类内方法调用。例如:
class Mapping:
def __init__(self, iterable):
self.items_list = []
self.__update(iterable)
def update(self, iterable):
for item in iterable:
self.items_list.append(item)
__update = update # private copy of original update() method
class MappingSubclass(Mapping):
def update(self, keys, values):
# provides new signature for update()
# but does not break __init__()
for item in zip(keys, values):
self.items_list.append(item)
上面的示例即使在 MappingSubclass
引入了一个 __update
标识符的情况下也不会出错,因为它会在 Mapping
类中被替换为 _Mapping__update
而在 MappingSubclass
类中被替换为 _MappingSubclass__update
。
请注意,改写规则的设计主要是为了避免意外冲突;访问或修改被视为私有的变量仍然是可能的。
这在特殊情况下甚至会很有用,例如在调试器中。
请注意传递给 exec()
或 eval()
的代码不会将发起调用类的类名视作当前类;这类似于 global
语句的效果,因此这种效果仅限于同时经过字节码编译的代码。 同样的限制也适用于 getattr()
, setattr()
和 delattr()
,以及对于 __dict__
的直接引用。
说实话,以前没怎么实现过类,对上面很多概念理解不深,只是记录再记录,希望以后遇到问题能够快速解决。
杂项说明
隐约记得C语言中有stuct
这样的数据类型,将一些命名数据项捆绑在一起,适合定义一个空类:
class Employee:
pass
john = Employee() # Create an empty employee record
# Fill the fields of the record
john.name = 'John Doe'
john.dept = 'computer lab'
john.salary = 1000
感觉好方便,想要什么属性随便加。
一段需要特定抽象数据类型的 Python 代码往往可以被传入一个模拟了该数据类型的方法的类作为替代。
例如,如果你有一个基于文件对象来格式化某些数据的函数,你可以定义一个带有 read()
和 readline()
方法从字符串缓存获取数据的类,并将其作为参数传入。
实例方法对象也具有属性: m.__self__
就是带有 m()
方法的实例对象,而 m.__func__
则是该方法所对应的函数对象。
说实话,还是没用过,不知道干嘛的。
说实话虽然Python编程两年,但是我居然没怎么用过类,正好这次从0开始看一次,后续有机会把部分代码封装成Class。
迭代器
还记得for
语句吗,配合in
关键字非常方便,很多Python容器(可以保持一组对象的空间,如list等)都支持for
语句。
for element in [1, 2, 3]:
print(element)
for element in (1, 2, 3):
print(element)
for key in {'one':1, 'two':2}:
print(key)
for char in "123":
print(char)
for line in open("myfile.txt"):
print(line, end='')
这种访问风格清晰、简洁又方便。
迭代器的使用非常普遍并使得 Python 成为一个统一的整体。
在幕后,for
语句会在容器对象上调用 iter()
。 该函数返回一个定义了 __next__()
方法的迭代器对象,此方法将逐一访问容器中的元素。
当元素用尽时,__next__()
将引发 StopIteration
异常来通知终止 for
循环。 你可以使用 next()
内置函数来调用 __next__()
方法;这个例子显示了它的运作方式:
>>> s = 'abc'
>>> it = iter(s)
>>> it
<iterator object at 0x00A1DB50>
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
next(it)
StopIteration
看过迭代器协议的幕后机制,给你的类添加迭代器行为就很容易了。 定义一个 __iter__()
方法来返回一个带有 __next__()
方法的对象。 如果类已定义了 __next__()
,则 __iter__()
可以简单地返回 self
:
class Reverse:
"""Iterator for looping over a sequence backwards."""
def __init__(self, data):
self.data = data
self.index = len(data)
def __iter__(self):
return self
def __next__(self):
if self.index == 0:
raise StopIteration
self.index = self.index - 1
return self.data[self.index]
实现了一个反转类,如果可以尝试传入一个字符串或者列表:
>>> rev = Reverse('spam')
>>> iter(rev)
<__main__.Reverse object at 0x00A1DB50>
>>> for char in rev:
... print(char)
...
m
a
p
s
生成器
Generator
是一个用于创建迭代器的简单而强大的工具。 它们的写法类似标准的函数,但当它们要返回数据时会使用 yield
语句。 每次对生成器调用 next()
时,它会从上次离开位置恢复执行(它会记住上次执行语句时的所有数据值)。 显示如何非常容易地创建生成器的示例如下:
def reverse(data):
for index in range(len(data)-1, -1, -1):
yield data[index]
>>> for char in reverse('golf'):
... print(char)
...
f
l
o
g
可以用生成器来完成的操作同样可以用前一节所描述的基于类的迭代器来完成。 但生成器的写法更为紧凑,因为它会自动创建 __iter__()
和 __next__()
方法。
另一个关键特性在于局部变量和执行状态会在每次调用之间自动保存。 这使得该函数相比使用 self.index
和 self.data
这种实例变量的方式更易编写且更为清晰。
除了会自动创建方法和保存程序状态,当生成器终结时,它们还会自动引发 StopIteration
。
这些特性结合在一起,使得创建迭代器能与编写常规函数一样容易。
生成器表达式
某些简单的生成器可以写成简洁的表达式代码,所用语法类似列表推导式,将外层为圆括号而非方括号。
这种表达式被设计用于生成器将立即被外层函数所使用的情况。
生成器表达式相比完整的生成器更紧凑但较不灵活,相比等效的列表推导式则更为节省内存。示例:
>>> sum(i*i for i in range(10)) # 平方和
285
>>> xvec = [10, 20, 30]
>>> yvec = [7, 5, 3]
>>> sum(x*y for x,y in zip(xvec, yvec)) # 向量点积
260
>>> from math import pi, sin
>>> sine_table = {x: sin(x*pi/180) for x in range(0, 91)}
>>> unique_words = set(word for line in page for word in line.split())
>>> valedictorian = max((student.gpa, student.name) for student in graduates)
>>> data = 'golf'
>>> list(data[i] for i in range(len(data)-1, -1, -1))
['f', 'l', 'o', 'g']
03 为什么
为什么要这么做?
首先任何东西的官方文档都是最全面最权威的教程。
以前只是受限于英语水平,
对官方网站敬而远之,
遇到问题都百度,
很多答案讲的都不到位,
没有说明为什么?
越到后面,收获越大。
类、面向对象很重要,当然今天重点在类的关键知识,类对象、实例对象、方法对象、类变量和实例变量,补充说明的属性覆盖、方法self
参数,继承的相关知识与私有变量,迭代器和生成器,只要实现__iter__()
和__next__()
方法即可。
收获满满。
04 更好的选择
有没有更好的选择
还是那句话,多敲代码,结合案例,偶尔看看官方源代码,加深理解。
好像我们接触的python对象,如int
、str
、list
内部都是类实现的,有能力可以看看源码,非常锻炼人。
我之前数据分析对象告警信息,有属性(字段,如告警时间等),还有告警的一些操作,如标准化,其实可以改成类写法,定义一个告警类Class Alarm
,然后设置一些属性和方法。
一句话
面向对象是语言的灵魂,python万物皆对象。类就是用户自定义的一种对象,实现数据和功能的封装,Python类对象、实例对象、方法对象需要了解,类变量和实例变量以及某种意义上的私有变量,继承的方法,如所有的类都派生自object
类。迭代器和生成器语法可以让类在操作上更为便利。