Python自学之路八:对象和类

“No object is mysterious. The mystery is your eye.”
—— Elizabeth Bowen

面向对象编程(OOP)是一种程序设计范型,同时也是一种程序开发方法,程序中包含各种独立而又能互相调用的对象,每个对象都能接受、处理数据并将数据传递给其他对象;而传统程序设计只是将程序看作一系列函数或指令的集合。

1.什么是对象

Python 里的所有数据都是以对象形式存在的,无论是简单的数字类型还是复杂的代码模块。对象(object)既表示客观世界问题空间中的某个具体事物,又表示软件系统解空间中的基本元素;既包含数据(属性),也包含代码(方法),它是某一类具体事物的特殊实例。

文章封面上的整数1,用type方法可以查看它属于整数类,既然它是对象,那么可以用dir方法可以查看它有哪些属性和方法,例如abs方法、pos方法;字符串'a',用type可以知道他属于字符串类,也能用dir知道它包含capitalize()和replace()之类的字符串方法;甚至在Python语言中,函数也是对象,比方说abs函数,可以看到type到它是一个属于Python内置的函数或方法的类,是函数类,它也包含了一系列的属性和方法。通过这些例子,我们可以发现在Python中所有东西都是对象,所以说,Python语言可以被称之为是一个纯面向对象或完全面向对象的程序设计语言

创建对象

对象是类的实例,是程序的基本单元。要创建一个新的对象,首先必须定义一个类,用以指明该类型的对象所包含的内容(属性和方法),同一类(class)的对象具有相同的属性和方法,但属性值和id不同。赋值语句给予对象以名称,对象可以有多个名称(变量引用),但只有一个id。对象实现了属性和方法的封装,是一种数据抽象机制,提高了软件的重用性、灵活性、扩展性。引用形式我们应该很熟悉了,是<对象名>.<属性名>,可以跟一般的变量一样用在赋值语句和表达式中。同时Python语言动态的特征,使得对象可以随时增加或者删除属性或者方法。

2.类的定义与调用

①什么是类

类(class)是对象的模版,封装了对应现实实体的性质和行为.实例对象(Instance Objects)是类的具体化,把类比作模具,对象则是用模具制造出来的零件。例如,Python的内置类String可以创建像'Kim'和'grade'这样的字符串对象。Python中还有许多用来创建其他标准数据类型的类,包括列表、字典等。类的出现,为面向对象编程的三个最重要的特性提供了实现的手段:封装性、继承性、多态性;和函数相似,类是一系列代码的封装。Python中约定,类名用大写字母开头,函数用小写字母开头,以便区分。

②定义类和调用类

假设我们要定义一些对象来说明一个学生的某些爱好,一个对象就对应一个爱好。首先创建一个没有任何内容的空类:

和函数一样,用pass表示这个类是一个空类,这种定义类的方法就是最简形式。现在我们将Python中特殊的对象初始化方法__init__放入其中:

虽然__init__()和self看起来很奇怪,但这就是实际的Python类的定义形式。__init__()是Python中一个特殊的函数名,用于根据类的定义创建实例对象,self参数指向了这个正在被创建的对象本身。要注意的是,在类声明里定义__init__()方法时,第一个参数必须为self。切记!!!init前后是双下划线!!!而且在类的定义中,__init__ 并不是必需的,只有当需要区分由该类创建的不同对象时,才需要指定 __init__ 方法。同时self并不是一个Python保留字,但它很常用。

到这里Student类创建的对象依然什么也做不了,现在我们在初始化方法中添加food参数:

然后用Student类创建一个对象,为hobby特性传递一个字符串参数:

就上面这一行代码,实际上做了很多工作:

  • 查看Student类的定义;
  • 在内存中创建一个新的对象;
  • 调用对象的__init__方法,将这个新创建的对象作为self传入,并将另一个参数('chicken')作为hobby传入;
  • 将hobby的值存入对象,并且返回这个新的对象;
  • 将food与这个对象关联。

这个新对象与任何其他的Python对象一样。你可以把它当作列表、元组、字典或集合中的元素,也可以把它当作参数传递给函数,或者把它做为函数的返回结果。

接着我们可以直接对刚刚传入的hobby参数进行读写操作,在这个过程中它作为对象的特性存储在了对象里:

③小例子:向量的合成

3.继承

①类的继承机制

在编写代码解决实际问题时,我们经常能找到一些已有的类,它们能够实现你所需的大部分功能。但如果有它们实现不了的功能该怎么办?诚然,我们可以对这个已有的类进行修改,但这么做很容易让代码变得更加复杂,甚至能会破坏原来可以正常工作的功能。当然,我们也可以重新编写一个类:复制粘贴原来的代码再融入自己的新代码。但这意味着你需要维护更多的代码。同时,新类和旧类中实现同样功能的代码被分隔在了不同的地方,日后修改时需要改动多处。

更好的解决方法是利用类的继承:从已有类中衍生出新的类,添加或修改部分功能。利用继承可以从已有类中衍生出新的类,添加或修改部分功能,新类具有旧类中的各种属性和方法,而不需要进行任何复制。我们只需要在新类里面定义自己额外需要的方法,或者按照需求对继承的方法进行修改即可,修改得到的新方法会覆盖原有的方法。如果一个类别A继承自另一个类别B,就把继承者A称为子类,被继承的类B称为父类、基类或超类,将新的类称作孩子类、子类或衍生类

比方说下面这个例子:

在这个例子里,我们有一个汽车的类,汽车的类有一个构造方法name,它有两个属性:名字name和续航里程remain_mile,它有两个方法:第一个是加燃料也就是延长续航里程,参数是miles,直接把miles赋值给remain_mile;第二个是开车跑miles这么多英里,它会把这个名称打印出来,并且判断续航里程是否大于等于要跑的miles,来输出不同的语句。接下来就是说汽油车GasCar和电动车ElecCar是车的两个子类,它们的class旁边有了一个括号,括号里面填的是父类,表示它从Car这个父类中继承了所有的属性和方法,不需要再复制上面的代码,就拥有了init和run这两个方法,同时还可以对fill_fuel这个方法进行修改。

②覆盖(Override)和添加方法

在说这两个方法之前,我们再巩固一下子类和父类。如果两个类具有“一般-特殊”的逻辑关系,那么特殊类就可以作为一般类的“子类”来定义,从“父类”继承属性和方法。

覆盖(Override):

子类对象可以调用父类方法,除非这个方法在子类中重新定义了;如果子类同名方法覆盖了父类的方法,仍然还可以调用父类的方法。

在上面的例子中,我们覆盖了父类的evaluate()方法。在子类中,可以覆盖任何父类的方法,包括__init__()。
添加新方法:

我们还是使用上面的例子。我们会发现,Beer类的对象可以相应color()方法,但比它广义Wine无法响应该方法。

③关于self

在类定义中,所有方法的首个参数一般都是self,它的作用是在类内部,实例化过程中传入的所有数据都赋给这个变量。self实际上代表对象实例,<对象>.<方法>(<参数>)等价于:<类>.<方法>(<对象>, <参数>),这里的对象就是self,Python 使用self参数来找到正确的对象所包含的特性和方法。比方说上面的Wine类,再次调用evaluate()方法:

在这个过程中,Python做了以下两件事情:

  • 查找对象wine所属的类(Wine);
  • 把wine对象作为self参数传给Wine类所包含的evaluate()方法。

4.属性(property)

①使用属性对特性进行访问和设置

有一些面向对象的语言支持私有特性,这些特性无法从对象外部直接访问,我们需要编写getter和setter方法对这些私有特性进行读写操作。Python 不需要 getter 和 setter 方法,因为 Python 里所有特性都是公开的,使用时全凭自觉。如果你不放心直接访问对象的特性,可以为对象编写setter和getter方法。但更具Python风格的解决方案是使用属性(property)。与直接访问特性相比,使用property还有一个巨大的优势:如果你改变了某个特性的定义,只需要在类定义里修改相关代码即可,不需要在每一处调用修改。

下面的例子中,首先定义一个Student类,它仅包含一个excellent_name特性。我们不希望别人能够直接访问这个特性,因此需要定义两个方法:getter方法(get_name())和setter方法(set_name())。我们在每个方法中都添加一个print()函数,这样就能方便地知道它们何时被调用。最后,把这些方法设置为name属性:

这两个新方法在最后一行之前都与普通的getter和setter方法没有任何区别,而最后一行则把这两个方法定义为了name属性。property()的第一个参数是getter方法,第二个参数是setter方法。现在,当我们尝试访问Student类对象的name特性时,get_name()会被自动调用:

当对name特性执行赋值操作时,set_name()方法会被调用:

另一种定义属性的方式是使用修饰符(decorator)。下面会定义两个不同的方法,它们都叫name(),但包含不同的修饰符:@property用于指示getter方法,@name.setter用于指示setter方法,我们仍然可以像之前访问特性一样访问name,但不能直接调用get_name()和set_name()方法:

在前面几个例子中,我们都使用name属性指向类中存储的某一特性,除此之外,属性还可以指向一个计算结果值。而且在下面这个例子里我们可以随时改变radius特性的值,计算属性diameter会自动根据新的值更新自己:

②使用名称重整保护私有特性

在上面的例子中,我们将隐藏内部特性命名为excellent_name。其实,Python对那些需要刻意隐藏在类内部的特性有自己的命名规范:由连续的两个下划线开头(__):

代码依然可以正常工作,但我们无法在外部访问__name特性了

这种命名规范本质上并没有把特性变成私有,但Python确实将它的名字重整了,让外部的代码无法使用。名称重整其实是这样实现的,我们并没有得到inside the getter,成功绕过了getter方法。虽然这种保护特性的方式并不完美,但它确实能在一定程度上避免我们无意或有意地对特性进行直接访问:

5.类定义中的特殊方法

当我们输入像a = 3 + 8这样的式子时,整数3和8是怎么知道如何实现"+"的?同样,a又是怎么知道如何使用"="来获取计算结果的?我们可以使用Python的特殊方法(special method),也被称作魔术方法(magic method),来实现这些操作符的功能。在类定义中实现一些特殊方法,可以方便地使用python中一些内置操作,所有特殊方法的名称以两个下划线(__)开始和结束。

看下面这个例子,我们有一个Word类,创建一个方法equals来比较两个词是否一致,忽略大小写:

我们成功定义了equals()方法来进行小写转换并比较。但试想一下,如果能通过if first == second进行比较,这样类会更自然,表现得更像一个Python内置的类。

我们把上面例子中的equals()方法的名称改为__eq__():

我们会发现程序依然可以正常运行

①和比较相关的魔术方法

方法名 使用
__eq__(self,other) self == other
__ne__(self,other) self != other
__lt__(self,other) self < other
__gt__(self,other) self > other
__le__(self,other) self <= other
__ge__(self,other) self >= other

②和数学相关的魔术方法

方法名 使用
__add__(self, other) self + other
__sub__(self,other) self - other
__mul__(self,other) self * other
__floordiv__(self,other) self // other
__truediv__(self,other) self / other
__mod__(self,other) self % other
__pow__(self,other) self ** other

当左操作数不支持相应的操作时被调用,我们需要使用反运算,例如:__radd__(self,other),__rsub__(self,other),__rmul__(self,other),__rdiv__(self,other)。比方说radd就变成了other+self,而不是self+other。

③其他比较常用的魔术方法

方法名 使用
__str__(self) 自动转换为字符串
__repr__(self) 返回一个用来表示对象的字符串
__len__(self) 返回元素个数

除了__init__()外,我们在编写类方法时最常用到的是__str__(),它用于定义如何打印对象信息。print()方法,str()方法以及的关于字符串格式化的相关方法都会用到__str__(),交互式解释器则用__repr__()方法输出变量。还是继续使用上面的例子,我们直接输入first和print(first),看看会输出什么:

现在我们将__str__()和__repr__()方法都添加到Word类里:

6.何时使用类和对象而不是模块

不要过度构建数据结构。尽量使用元组(以及命名元组)而不是对象。尽量使用简单的属性域而不是getter/setter函数……内置数据类型是你最好的朋友。尽可能多地使用数字、字符串、元组、列表、集合以及字典。多看看容器库提供的类型,尤其是双端队列。

——创始人Guido van Rossum

有一些方法可以帮助我们决定是把我们的代码封装到类里还是模块里:

  • 当你需要许多具有相似行为(方法)但不同状态(特性)的实例时,使用对象是最好的选择。
  • 类支持继承,但模块不支持。
  • 如果你想要保证实例的唯一性,使用模块是最好的选择。不管模块在程序中被引用多少次,始终只有一个实例被加载。
  • 如果你有一系列包含多个值的变量,并且它们能作为参数传入不同的函数,那么最好将它们封装到类里面。
  • 用最简单的方式解决问题。使用字典、列表和元组往往要比使用模块更加简单、简洁且快速。而使用类则更为复杂。
——Bill Lubanovic
点赞

发表评论

电子邮件地址不会被公开。必填项已用 * 标注