在前面的章节我们已经了解了面向对象的入门知识,知道了如何定义类,如何创建对象以及如何给对象发消息。为了能够更好的使用面向对象编程思想进行程序开发,我们还需要对Python中的面向对象编程进行更为深入的了解。
可见性
在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息,也是对象暴露给外界的调用接口,这就是所谓的访问可见性。在Python中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性,例如,可以用__name表示一个私有属性,_name表示一个受保护属性,代码如下所示。
class Student:def __init__(self, name, age):self.__name = nameself.__age = agedef study(self, course_name):print(f'{self.__name}正在学习{course_name}.')stu = Student('程不懂', 20)stu.study('Python程序设计')print(stu.__name)
上面代码的最后一行会引发AttributeError(属性错误)异常,异常消息为:'Student' object has no attribute '__name'。由此可见,以__开头的属性__name是私有的,在类的外面无法直接访问,但是类里面的study方法中可以通过self.__name访问该属性。
需要提醒大家的是,Python并没有从语法上严格保证私有属性的私密性,它只是给私有的属性和方法换了一个名字来阻挠对它们的访问,事实上如果你知道更换名字的规则仍然可以访问到它们,我们可以对上面的代码稍作修改就可以访问到私有的。
class Student:def __init__(self, name, age):self.__name = nameself.__age = agedef study(self, course_name):print(f'{self.__name}正在学习{course_name}.')stu = Student('程不懂', 20)stu.study('Python程序设计')print(stu._Student__name, stu._Student__age)
Python中做出这样的设定是基于一句名言:“We are all consenting adults here”(大家都是成年人)。Python语言的设计者认为程序员要为自己的行为负责,而不是由Python语言本身来严格限制访问可见性,而大多数的程序员都认为开放比封闭要好,把对象的属性私有化并不是必须的东西。
属性装饰器
Python中可以通过property装饰器为“私有”属性提供读取和修改的方法,装饰器通常会放在类、函数或方法的声明之前,通过一个@符号表示将装饰器应用于类、函数或方法。装饰器的概念我们会在稍后的课程中以专题的形式为大家讲解,这里我们只需要了解property装饰器的用法就可以了。
class Student:def __init__(self, name, age):self.__name = nameself.__age = age# 属性访问器(getter方法) - 获取__name属性@propertydef name(self):return self.__name# 属性修改器(setter方法) - 修改__name属性@name.setterdef name(self, name):# 如果name参数不为空就赋值给对象的__name属性# 否则将__name属性赋值为'无名氏',有两种写法# self.__name = name if name else '无名氏'self.__name = name or '无名氏'@propertydef age(self):return self.__age# 属性修改器(setter方法) - 修改__age属性@age.setterdef age(self, age):# 如果name参数不为空就赋值给对象的__age属性# 否则将__age属性赋值为'unknown',有两种写法# self.__age = age if age else 'unknown'self.__age = age or 'unknown'stu = Student('王大锤', 20)print(stu.name, stu.age) # 王大锤 20stu.name = ''stu.age = 30print(stu.name, stu.age) # 无名氏 30
在实际项目开发中,我们并不经常使用私有属性,属性装饰器的使用也比较少,所以上面的知识点大家简单了解一下就可以了。
动态属性
Python是一门动态语言,维基百科对动态语言的解释是:“在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除或是其他结构上的变化。动态语言非常灵活,目前流行的Python和JavaScript都是动态语言,除此之外如PHP、Ruby等也都属于动态语言,而C、C++等语言则不属于动态语言”。
在Python中,我们可以动态为对象添加属性,这是Python作为动态类型语言的一项特权,代码如下所示。需要提醒大家的是,对象的方法其实本质上也是对象的属性,如果给对象发送一个无法接收的消息,引发的异常仍然是AttributeError。
class Student:def __init__(self, name, age):self.name = nameself.age = agestu = Student('王大锤', 20)# 为Student对象动态添加sex属性stu.sex = '男'print(stu.name, stu.age, stu.sex)
如果不希望在使用对象时动态的为对象添加属性,可以使用Python的__slots__魔法。对于Student类来说,可以在类中指定__slots__ = ('name', 'age'),这样Student类的对象只能有name和age属性,如果想动态添加其他属性将会引发异常,代码如下所示。
class Student:__slots__ = ('name', 'age')def __init__(self, name, age):self.name = nameself.age = agestu = Student('王大锤', 20)# 为Student对象动态添加sex属性stu.sex = '男'
静态方法和类方法
之前我们在类中定义的方法都是对象方法,换句话说这些方法都是对象可以接收的消息。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息,二者并没有实质性的区别。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象,而静态方法和类方法就是发送给类对象的消息。那么,什么样的消息会直接发送给类对象呢?
举一个例子,定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。计算周长和面积肯定是三角形对象的方法,这一点毫无疑问。但是在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形,这种方法很显然就不是对象方法,因为在调用这个方法时三角形对象还没有创建出来。我们可以把这类方法设计为静态方法或类方法,也就是说这类方法不是发送给三角形对象的消息,而是发送给三角形类的消息,代码如下所示。
class Triangle:"""三角形类"""def __init__(self, a, b, c):"""初始化三条边"""self.a, self.b, self.c = a, b, c@staticmethoddef is_valid(a, b, c):"""判断三条边长能否构成三角形(静态方法)"""return a + b > c and b + c > a and a + c > b# @classmethod# def is_valid(cls, a, b, c):# """判断三条边长能否构成三角形(类方法)"""# return a + b > c and b + c > a and a + c > bdef perimeter(self):"""计算周长"""return self.a + self.b + self.cdef area(self):"""计算面积"""p = self.perimeter() / 2return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5def main():a, b, c = 3, 4, 5# 静态方法和类方法都是通过给类发消息来调用的if Triangle.is_valid(a, b, c):t = Triangle(a, b, c)print(t.perimeter())# 也可以通过给类发消息来调用对象方法但是要传入接收消息的对象作为参数# print(Triangle.perimeter(t))print(t.area())# print(Triangle.area(t))else:print('无法构成三角形.')if __name__ == '__main__':main()
上面的代码使用staticmethod装饰器声明了is_valid方法是Triangle类的静态方法,如果要声明类方法,可以使用classmethod装饰器。可以直接使用类名.方法名的方式来调用静态方法和类方法,二者的区别在于,类方法的第一个参数是类对象本身,而静态方法则没有这个参数。简单的总结一下,对象方法、类方法、静态方法都可以通过类名.方法名的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接受消息的对象。静态方法通常也可以直接写成一个独立的函数,因为它并没有跟特定的对象绑定。
类之间的关系
简单的说,类和类之间的关系有三种:is-a、has-a和use-a关系。
- is-a关系也叫继承或泛化,比如学生和人的关系、手机和电子产品的关系都属于继承关系。
- has-a关系通常称之为关联,比如部门和员工的关系,汽车和引擎的关系都属于关联关系;关联关系如果是整体和部分的关联,那么我们称之为聚合关系;如果整体进一步负责了部分的生命周期(整体和部分是不可分割的,同时同在也同时消亡),那么这种就是最强的关联关系,我们称之为合成关系。
- use-a关系通常称之为依赖,比如司机有一个驾驶的行为(方法),其中(的参数)使用到了汽车,那么司机和汽车的关系就是依赖关系。
我们可以使用一种叫做UML(统一建模语言)的东西来进行面向对象建模,其中一项重要的工作就是把类和类之间的关系用标准化的图形符号描述出来。关于UML我们在这里不做详细的介绍,有兴趣的读者可以自行阅读《UML面向对象设计基础》一书。

利用类之间的这些关系,我们可以在已有类的基础上来完成某些操作,也可以在已有类的基础上创建新的类,这些都是实现代码复用的重要手段。复用现有的代码不仅可以减少开发的工作量,也有利于代码的管理和维护,这是我们在日常工作中都会使用到的技术手段。
继承和多态
面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。例如,我们定义一个学生类和一个老师类,我们会发现他们有大量的重复代码,而这些重复代码都是老师和学生作为人的公共属性和行为,所以在这种情况下,我们应该先定义人类,再通过继承,从人类派生出老师类和学生类,代码如下所示。
class Person:"""人类"""def __init__(self, name, age):self.name = nameself.age = agedef eat(self):print(f'{self.name}正在吃饭.')def sleep(self):print(f'{self.name}正在睡觉.')class Student(Person):"""学生类"""def __init__(self, name, age):# super(Student, self).__init__(name, age)super().__init__(name, age)def study(self, course_name):print(f'{self.name}正在学习{course_name}.')class Teacher(Person):"""老师类"""def __init__(self, name, age, title):# super(Teacher, self).__init__(name, age)super().__init__(name, age)self.title = titledef teach(self, course_name):print(f'{self.name}{self.title}正在讲授{course_name}.')stu1 = Student('白元芳', 21)stu2 = Student('狄仁杰', 22)teacher = Teacher('武则天', 35, '副教授')stu1.eat()stu2.sleep()teacher.teach('Python程序设计')stu1.study('Python程序设计')
继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。Python语言允许多重继承,也就是说一个类可以有一个或多个父类,关于多重继承的问题我们在后面会有更为详细的讨论。在子类的初始化方法中,我们可以通过super().__init__()来调用父类初始化方法,super函数是Python内置函数中专门为获取当前对象的父类对象而设计的。从上面的代码可以看出,子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。在实际开发中,我们经常会用子类对象去替换掉一个父类对象,这是面向对象编程中一个常见的行为,也叫做“里氏替换原则”(Liskov Substitution Principle)。
子类继承父类的方法后,还可以对方法进行重写(重新实现该方法),不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为(调用相同的方法,做了不同的事情)。多态是面向对象编程中最精髓的部分,当然也是对初学者来说最难以理解和灵活运用的部分,我们会在下一节课中用专门的例子来讲解多态这个知识点。
面向对象编程应用
案例1:扑克游戏。
说明:简单起见,我们的扑克只有52张牌(没有大小王),游戏需要将52张牌发到4个玩家的手上,每个玩家手上有13张牌,按照黑桃、红心、草花、方块的顺序和点数从小到大排列,暂时不实现其他的功能。 使用面向对象编程方法,首先需要从问题的需求中找到对象并抽象出对应的类,此外还要找到对象的属性和行为。当然,这件事情并不是特别困难,我们可以从需求的描述中找出名词和动词,名词通常就是对象或者是对象的属性,而动词通常是对象的行为。扑克游戏中至少应该有三类对象,分别是牌、扑克和玩家,牌、扑克、玩家三个类也并不是孤立的。类和类之间的关系可以粗略的分为is-a关系(继承)、has-a关系(关联)和use-a关系(依赖)。很显然扑克和牌是has-a关系,因为一副扑克有(has-a)52张牌;玩家和牌之间不仅有关联关系还有依赖关系,因为玩家手上有(has-a)牌而且玩家使用了(use-a)牌。
牌的属性显而易见,有花色和点数。我们可以用0到3的四个数字来代表四种不同的花色,但是这样的代码可读性会非常糟糕,因为我们并不知道黑桃、红心、草花、方块跟0到3的数字的对应关系。如果一个变量的取值只有有限多个选项,我们可以使用枚举。与C、Java等语言不同的是,Python中没有声明枚举类型的关键字,但是可以通过继承enum模块的Enum类来创建枚举类型,代码如下所示。
from enum import Enumclass Suite(Enum):"""花色(枚举)"""SPADE, HEART, CLUB, DIAMOND = range(4)
通过上面的代码可以看出,定义枚举类型其实就是定义符号常量,如SPADE、HEART等。每个符号常量都有与之对应的值,这样表示黑桃就可以不用数字0,而是用Suite.SPADE;同理,表示方块可以不用数字3, 而是用Suite.DIAMOND。注意,使用符号常量肯定是优于使用字面常量的,因为能够读懂英文就能理解符号常量的含义,代码的可读性会提升很多。Python中的枚举类型是可迭代类型,简单的说就是可以将枚举类型放到for-in循环中,依次取出每一个符号常量及其对应的值,如下所示。
for suite in Suite:print(f'{suite}: {suite.value}')
接下来我们可以定义牌类。
class Card:"""牌"""def __init__(self, suite, face):self.suite = suiteself.face = facedef __repr__(self):suites = '♠♥♣♦'faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']# 根据牌的花色和点数取到对应的字符return f'{suites[self.suite.value]}{faces[self.face]}'
可以通过下面的代码来测试下Card类。
card1 = Card(Suite.SPADE, 5)card2 = Card(Suite.HEART, 13)print(card1, card2) # ♠5 ♥K
接下来我们定义扑克类。
class Poker(object):"""扑克"""def __init__(self):# 通过列表的生成式语法创建一个装52张牌的列表self.cards = [Card(suite, face) for suite in Suitefor face in range(1, 14)]# current属性表示发牌的位置self.current = 0def shuffle(self):"""洗牌"""self.current = 0# 通过random模块的shuffle函数实现列表的随机乱序random.shuffle(self.cards)def deal(self):"""发牌"""card = self.cards[self.current]self.current += 1return card@propertydef has_next(self):"""还有没有牌可以发"""return self.current < len(self.cards)
可以通过下面的代码来测试下Poker类。
poker = Poker()poker.shuffle()print(poker.cards)
定义玩家类。
class Player(object):"""玩家"""def __init__(self, name):self.name = nameself.cards = []def get_one(self, card):"""摸牌"""self.cards.append(card)def arrange(self):self.cards.sort()
创建四个玩家并将牌发到玩家的手上。
poker = Poker()poker.shuffle()players = [Player('刘备'), Player('关羽'), Player('张飞'), Player('赵云')]for _ in range(13):for player in players:player.get_one(poker.deal())for player in players:player.arrange()print(f'{player.name}:', end='')print(player.cards)
执行上面的代码会在player.arrange()那里出现异常,因为Player的arrange方法使用了列表的sort对玩家手上的牌进行排序,排序需要比较两个Card对象的大小,而<运算符又不能直接作用于Card类型,所以就出现了TypeError异常,异常消息为:'<' not supported between instances of 'Card' and 'Card'。
为了解决这个问题,我们可以对Card类的代码稍作修改,使得两个Card对象可以直接用<进行大小的比较。这里用到技术叫运算符重载,Python中要实现对<运算符的重载,需要在类中添加一个名为__lt__的魔术方法。很显然,魔术方法__lt__中的lt是英文单词“less than”的缩写,以此类推,魔术方法__gt__对应>运算符,魔术方法__le__对应<=运算符,__ge__对应>=运算符,__eq__对应==运算符,__ne__对应!=运算符。
修改后的Card类代码如下所示。
class Card:"""牌"""def __init__(self, suite, face):self.suite = suiteself.face = facedef __repr__(self):suites = '♠♥♣♦'faces = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']# 根据牌的花色和点数取到对应的字符return f'{suites[self.suite.value]}{faces[self.face]}'def __lt__(self, other):# 花色相同比较点数的大小if self.suite == other.suite:return self.face < other.face# 花色不同比较花色对应的值return self.suite.value < other.suite.value
案例2:工资结算系统。
要求:某公司有三种类型的员工,分别是部门经理、程序员和销售员。需要设计一个工资结算系统,根据提供的员工信息来计算员工的月薪。其中,部门经理的月薪是固定15000元;程序员按工作时间(以小时为单位)支付月薪,每小时200元;销售员的月薪由1800元底薪加上销售额5%的提成两部分构成。 通过对上述需求的分析,可以看出部门经理、程序员、销售员都是员工,有相同的属性和行为,那么我们可以先设计一个名为
Employee的父类,再通过继承的方式从这个父类派生出部门经理、程序员和销售员三个子类。很显然,后续的代码不会创建Employee类的对象,因为我们需要的是具体的员工对象,所以这个类可以设计成专门用于继承的抽象类。Python中没有定义抽象类的关键字,但是可以通过abc模块中名为ABCMeta的元类来定义抽象类。关于元类的知识,后面的课程中会有专门的讲解,这里不用太纠结这个概念,记住用法即可。 ```python from abc import ABCMeta, abstractmethod
class Employee(metaclass=ABCMeta): “””员工抽象类””” def init(self, name): self.name = name
@abstractmethoddef get_salary(self):"""结算月薪"""pass
在上面的员工类中,有一个名为`get_salary`的方法用于结算月薪,但是由于还没有确定是哪一类员工,所以结算月薪虽然是员工的公共行为但这里却没有办法实现。对于暂时无法实现的方法,我们可以使用`abstractmethod`装饰器将其声明为抽象方法,所谓**抽象方法就是只有声明没有实现的方法**,**声明这个方法是为了让子类去重写这个方法**。接下来的代码展示了如何从员工类派生出部门经理、程序员、销售员这三个子类以及子类如何重写父类的抽象方法。```pythonfrom abc import ABCMeta, abstractmethodclass Employee(metaclass=ABCMeta):"""员工抽象类"""def __init__(self, name):self.name = name@abstractmethoddef get_salary(self):"""结算月薪"""passclass Manager(Employee):"""经理"""def get_salary(self):return 15000.0class Programmer(Employee):"""程序员"""def __init__(self, name, working_hour=0):super().__init__(name)self.working_hour = working_hourdef get_salary(self):return 200 * self.working_hourclass Salesman(Employee):"""销售"""def __init__(self, name, sales=0):super(Salesman, self).__init__()self.sales = salesdef get_salary(self):return 1800 + self.sales * 0.05
上面的Manager、Programmer、Salesman三个类都继承自Employee,三个类都分别重写了get_salary方法。重写就是子类对父类已有的方法重新做出实现。相信大家已经注意到了,三个子类中的get_salary各不相同,所以这个方法在程序运行时会产生多态行为,多态简单的说就是调用相同的方法,不同的子类对象做不同的事情。
我们通过下面的代码来完成这个工资结算系统,由于程序员和销售员需要分别录入本月的工作时间和销售额,所以在下面的代码中我们使用了Python内置的isinstance函数来判断员工对象的类型。我们之前讲过的type函数也能识别对象的类型,但是isinstance函数更加强大,因为它可以判断出一个对象是不是某个继承结构下的子类型,你可以简答的理解为type函数是对对象类型的精准匹配,而isinstance函数是对对象类型的模糊匹配。
emps = [Manager('刘备'), Programmer('赵云'), Salesman('诸葛亮'),Manager('曹操'), Programmer('夏侯惇'), Salesman('司马懿')]for emp in emps:if isinstance(emp, Programmer):emp.working_hour = int(input(f'请输入{emp.name}本月的工作时间(小时):'))elif isinstance(emp, Salesman):emp.sales = int(input(f'请输入{emp.name}本月销售额(元): '))print(f'{emp.name}本月工资为: ¥{emp.get_salary():.2f}元')
