1. 编程范式与面向对象概述
面向对象编程是一种非常流行的编程范式(programming paradigm),所谓编程范式就是程序设计的方法论,简单的说就是程序员对程序的认知和理解以及他们编写代码的方式。
在前面的课程中,我们说过"程序是指令的集合",运行程序时,程序中的语句会变成一条或多条指令,然后由 CPU 去执行。为了简化程序的设计,我们又讲到了函数,把相对独立且经常重复使用的代码放置到函数中,在需要使用这些代码的时候调用函数即可。
编程其实是写程序的人按照计算机的工作方式通过代码控制机器完成任务。但是,当我们需要开发一个复杂的系统时,这种方式会让代码过于复杂,从而导致开发和维护工作都变得举步维艰。
诞生于上世纪70年代的 Smalltalk 语言引入了面向对象编程。在面向对象编程的世界里,程序中的数据和操作数据的函数是一个逻辑上的整体,我们称之为对象,对象可以接收消息,解决问题的方法就是创建对象并向对象发出各种各样的消息;通过消息传递,程序中的多个对象可以协同工作,这样就能构造出复杂的系统并解决现实中的问题。
可以用一句话来概括面向对象编程:把一组数据和处理数据的方法组成对象,把行为相同的对象归纳为类,通过封装隐藏对象的内部细节,通过继承实现类的特化和泛化,通过多态实现基于对象类型的动态分派。
2. 类与对象
在面向对象编程中,类是一个抽象的概念,对象是一个具体的概念。我们把同一类对象的共同特征抽取出来就是一个类,比如我们经常说的人类,这是一个抽象概念,而我们每个人就是人类的这个抽象概念下的实实在在的存在,也就是一个对象。简而言之,类是对象的蓝图和模板,对象是类的实例,是可以接受消息的实体。
在面向对象编程的世界中,有几个核心观点:
- 一切皆为对象
- 对象都有属性和行为 —— 属性是静态特征,行为是动态特征
- 每个对象都是独一无二的
- 对象一定属于某个类
3. 定义类
在 Python 语言中,我们可以使用 class 关键字加上类名来定义类,通过缩进确定类的代码块。在类的代码块中,我们需要写一些函数,这些函数就是我们对一类对象共同的动态特征的提取。写在类里面的函数我们通常称之为方法,方法就是对象的行为,也就是对象可以接收的消息。方法的第一个参数通常都是 self,它代表了接收这个消息的对象本身。
class Student:
def study(self, course_name):
print(f'学生正在学习{course_name}.')
def play(self):
print(f'学生正在玩游戏.')
4. 创建和使用对象
在我们定义好一个类之后,可以使用构造器语法来创建对象。在类的名字后跟上圆括号就是所谓的构造器语法。
stu1 = Student()
stu2 = Student()
print(stu1) # <__main__.Student object at 0x10ad5ac50>
print(stu2) # <__main__.Student object at 0x10ad5acd0>
print(hex(id(stu1)), hex(id(stu2))) # 0x10ad5ac50 0x10ad5acd0
用 print 函数打印 stu1 和 stu2 时,会输出对象在内存中的地址(十六进制形式),跟我们用 id 函数查看对象标识获得的值是相同的。stu3 = stu2 这样的赋值语句并没有创建新的对象,只是用一个新的变量保存了已有对象的地址。
给对象发消息(调用方法)
Python 中,给对象发消息有两种方式:
# 方式1:通过"类.方法"调用
# 第一个参数是接收消息的对象,第二个参数是学习的课程名称
Student.study(stu1, 'Python程序设计') # 学生正在学习Python程序设计.
# 方式2:通过"对象.方法"调用
# 点前面的对象就是接收消息的对象,只需要传入第二个参数
stu1.study('Python程序设计') # 学生正在学习Python程序设计.
Student.play(stu2) # 学生正在玩游戏.
stu2.play() # 学生正在玩游戏.
5. 初始化方法 __init__
如果要给学生对象定义属性,我们可以修改 Student 类,为其添加一个名为 __init__ 的方法。在我们调用 Student 类的构造器创建对象时,首先会在内存中获得保存学生对象所需的内存空间,然后通过自动执行 __init__ 方法,完成对内存的初始化操作,也就是把数据放到内存空间中。所以 __init__ 方法通常也被称为初始化方法。
class Student:
"""学生"""
def __init__(self, name, age):
"""初始化方法"""
self.name = name
self.age = age
def study(self, course_name):
"""学习"""
print(f'{self.name}正在学习{course_name}.')
def play(self):
"""玩耍"""
print(f'{self.name}正在玩游戏.')
# 调用Student类的构造器创建对象并传入初始化参数
stu1 = Student('骆昊', 44)
stu2 = Student('王大锤', 25)
stu1.study('Python程序设计') # 骆昊正在学习Python程序设计.
stu2.play() # 王大锤正在玩游戏.
6. 面向对象的三大支柱
面向对象编程有三大支柱:封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。
封装
封装就是:隐藏一切可以隐藏的实现细节,只向外界暴露简单的调用接口。我们在类中定义的对象方法其实就是一种封装,这种封装可以让我们在创建对象之后,只需要给对象发送一个消息就可以执行方法中的代码,也就是说我们在只知道方法的名字和参数(方法的外部视图),不知道方法内部实现细节(方法的内部视图)的情况下就完成了对方法的使用。
在很多场景下,面向对象编程其实就是一个三步走的问题:第一步定义类,第二步创建对象,第三步给对象发消息。有时候我们是不需要第一步的,因为我们想用的类可能已经存在了(如 Python 内置的list、set、dict都是类)。
7. 案例:Clock 时钟类
定义一个类描述数字时钟,提供走字和显示时间的功能。
import time
# 定义时钟类
class Clock:
"""数字时钟"""
def __init__(self, hour=0, minute=0, second=0):
"""初始化方法
:param hour: 时
:param minute: 分
:param second: 秒
"""
self.hour = hour
self.min = minute
self.sec = second
def run(self):
"""走字"""
self.sec += 1
if self.sec == 60:
self.sec = 0
self.min += 1
if self.min == 60:
self.min = 0
self.hour += 1
if self.hour == 24:
self.hour = 0
def show(self):
"""显示时间"""
return f'{self.hour:0>2d}:{self.min:0>2d}:{self.sec:0>2d}'
# 创建时钟对象
clock = Clock(23, 59, 58)
while True:
# 给时钟对象发消息读取时间
print(clock.show())
# 休眠1秒钟
time.sleep(1)
# 给时钟对象发消息使其走字
clock.run()
8. 案例:Point 点类
定义一个类描述平面上的点,提供计算到另一个点距离的方法,以及 __str__ 魔法方法用于对象的字符串表示。
class Point:
"""平面上的点"""
def __init__(self, x=0, y=0):
"""初始化方法
:param x: 横坐标
:param y: 纵坐标
"""
self.x, self.y = x, y
def distance_to(self, other):
"""计算与另一个点的距离
:param other: 另一个点
"""
dx = self.x - other.x
dy = self.y - other.y
return (dx * dx + dy * dy) ** 0.5
def __str__(self):
return f'({self.x}, {self.y})'
p1 = Point(3, 5)
p2 = Point(6, 9)
print(p1) # 调用对象的__str__魔法方法 → (3, 5)
print(p2) # (6, 9)
print(p1.distance_to(p2)) # 5.0
9. 可见性与属性装饰器
在很多面向对象编程语言中,对象的属性通常会被设置为私有(private)或受保护(protected)的成员,简单的说就是不允许直接访问这些属性;对象的方法通常都是公开的(public),因为公开的方法是对象能够接受的消息。这就是所谓的访问可见性。
在 Python 中,可以通过给对象属性名添加前缀下划线的方式来说明属性的访问可见性:
__name表示一个私有属性_name表示一个受保护属性
class Student:
def __init__(self, name, age):
self.__name = name
self.__age = age
def 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 相当于是私有的,在类的外面无法直接访问,但是类里面的方法中可以通过 self.__name 访问该属性。
大多数程序员都认为开放比封闭要好,把对象的属性私有化并非必不可少的东西。所以 Python 语言并没有从语义上做出最严格的限定,如果你愿意,用stu._Student__name的方式仍然可以访问到私有属性__name。
10. 动态属性与 __slots__
Python 语言属于动态语言:在运行时可以改变其结构的语言,例如新的函数、对象、甚至代码可以被引进,已有的函数可以被删除。
在 Python 中,我们可以动态为对象添加属性,这是 Python 作为动态类型语言的一项特权。
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
stu = Student('王大锤', 20)
stu.sex = '男' # 给学生对象动态添加sex属性
如果不希望在使用对象时动态的为对象添加属性,可以使用 __slots__ 魔法:
class Student:
__slots__ = ('name', 'age')
def __init__(self, name, age):
self.name = name
self.age = age
stu = Student('王大锤', 20)
# AttributeError: 'Student' object has no attribute 'sex'
stu.sex = '男'
11. 静态方法和类方法
之前我们在类中定义的方法都是对象方法。除了对象方法之外,类中还可以有静态方法和类方法,这两类方法是发给类的消息。在面向对象的世界里,一切皆为对象,我们定义的每一个类其实也是一个对象。
举个例子:定义一个三角形类,通过传入三条边的长度来构造三角形,并提供计算周长和面积的方法。在创建三角形对象时,传入的三条边长未必能构造出三角形,为此我们可以先写一个方法来验证给定的三条边长是否可以构成三角形——这种方法显然是发送给三角形类的消息,因为调用时三角形对象还没有创建出来。
class Triangle(object):
"""三角形"""
def __init__(self, a, b, c):
"""初始化方法"""
self.a = a
self.b = b
self.c = c
@staticmethod
def 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 > b
def perimeter(self):
"""计算周长"""
return self.a + self.b + self.c
def area(self):
"""计算面积"""
p = self.perimeter() / 2
return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5
直接使用 类名.方法名 的方式来调用静态方法和类方法。二者的区别在于:类方法的第一个参数是类对象本身(cls),而静态方法则没有这个参数。
总结:对象方法、类方法、静态方法都可以通过"类名.方法名"的方式来调用,区别在于方法的第一个参数到底是普通对象还是类对象,还是没有接收消息的对象。静态方法通常也可以直接写成一个独立的函数。
12. @property 装饰器
我们可以给计算三角形周长和面积的方法添加 @property 装饰器(Python 内置类型),这样 perimeter 和 area 就变成了两个属性,不再通过调用方法的方式来访问,而是用对象访问属性的方式直接获得。
class Triangle(object):
"""三角形"""
def __init__(self, a, b, c):
"""初始化方法"""
self.a = a
self.b = b
self.c = c
@staticmethod
def is_valid(a, b, c):
"""判断三条边长能否构成三角形(静态方法)"""
return a + b > c and b + c > a and a + c > b
@property
def perimeter(self):
"""计算周长"""
return self.a + self.b + self.c
@property
def area(self):
"""计算面积"""
p = self.perimeter / 2
return (p * (p - self.a) * (p - self.b) * (p - self.c)) ** 0.5
t = Triangle(3, 4, 5)
print(f'周长: {t.perimeter}')
print(f'面积: {t.area}')
13. 继承
面向对象的编程语言支持在已有类的基础上创建新类,从而减少重复代码的编写。提供继承信息的类叫做父类(超类、基类),得到继承信息的类叫做子类(派生类、衍生类)。
例如,学生和老师都有姓名、年龄,都会吃饭、睡觉——这些是作为"人"的公共属性和行为。所以我们应该先定义人类,再通过继承,从人类派生出老师类和学生类。
class Person:
"""人"""
def __init__(self, name, age):
self.name = name
self.age = age
def eat(self):
print(f'{self.name}正在吃饭.')
def sleep(self):
print(f'{self.name}正在睡觉.')
class Student(Person):
"""学生"""
def __init__(self, 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().__init__(name, age)
self.title = title
def teach(self, course_name):
print(f'{self.name}{self.title}正在讲授{course_name}.')
stu1 = Student('白元芳', 21)
stu2 = Student('狄仁杰', 22)
tea1 = Teacher('武则天', 35, '副教授')
stu1.eat()
stu2.sleep()
tea1.eat()
stu1.study('Python程序设计')
tea1.teach('Python程序设计')
stu2.study('数据科学导论')
继承的语法是在定义类的时候,在类名后的圆括号中指定当前类的父类。如果定义一个类的时候没有指定它的父类是谁,那么默认的父类是 object 类。object 类是 Python 中的顶级类,所有的类都是它的子类。
在子类的初始化方法中,我们通过 super().__init__() 来调用父类初始化方法,super 函数是 Python 内置函数中专门为获取当前对象的父类对象而设计的。子类除了可以通过继承得到父类提供的属性和方法外,还可以定义自己特有的属性和方法,所以子类比父类拥有的更多的能力。
14. 多态
子类继承父类的方法后,还可以对方法进行重写(重新实现该方法)。不同的子类可以对父类的同一个方法给出不同的实现版本,这样的方法在程序运行时就会表现出多态行为:调用相同的方法,不同的子类对象做不同的事情。
多态是面向对象编程中最精髓的部分,也是初学者最难以理解和灵活运用的部分。我们通过下面的两个经典案例来深入理解。
15. 应用案例:扑克游戏
简单起见,我们的扑克只有52张牌(没有大小王),游戏需要将 52 张牌发到 4 个玩家的手上,每个玩家手上有 13 张牌,按照黑桃、红心、草花、方块的顺序和点数从小到大排列。
步骤1:定义花色枚举
如果一个变量的取值只有有限多个选项,我们可以使用枚举。Python 中没有声明枚举类型的关键字,但是可以通过继承 enum 模块的 Enum 类来创建枚举类型。
from enum import Enum
class Suite(Enum):
"""花色(枚举)"""
SPADE, HEART, CLUB, DIAMOND = range(4)
定义枚举类型其实就是定义符号常量,如 SPADE、HEART 等。使用符号常量优于使用字面常量,因为能够读懂英文就能理解符号常量的含义,代码的可读性会提升很多。
步骤2:定义牌类 Card
class Card:
"""牌"""
def __init__(self, suite, face):
self.suite = suite
self.face = face
def __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]}'
步骤3:定义扑克类 Poker
import random
class Poker:
"""扑克"""
def __init__(self):
self.cards = [Card(suite, face)
for suite in Suite
for face in range(1, 14)] # 52张牌构成的列表
self.current = 0 # 记录发牌位置的属性
def shuffle(self):
"""洗牌"""
self.current = 0
random.shuffle(self.cards) # 通过random模块的shuffle函数实现随机乱序
def deal(self):
"""发牌"""
card = self.cards[self.current]
self.current += 1
return card
@property
def has_next(self):
"""还有没有牌可以发"""
return self.current < len(self.cards)
步骤4:定义玩家类 Player
class Player:
"""玩家"""
def __init__(self, name):
self.name = name
self.cards = [] # 玩家手上的牌
def get_one(self, card):
"""摸牌"""
self.cards.append(card)
def arrange(self):
"""整理手上的牌"""
self.cards.sort()
步骤5:运算符重载 __lt__
执行 player.arrange() 时,排序需要比较两个 Card 对象的大小,而 < 运算符不能直接作用于 Card 类型。为解决这个问题,需要对 Card 类进行运算符重载,添加 __lt__ 魔法方法:
class Card:
"""牌"""
def __init__(self, suite, face):
self.suite = suite
self.face = face
def __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 # 花色不同比较花色对应的值
魔术方法 __lt__ 中的 lt 是 "less than" 的缩写。同理:__gt__ 对应 >,__le__ 对应 <=,__ge__ 对应 >=,__eq__ 对应 ==,__ne__ 对应 !=。
步骤6:发牌与输出
poker = Poker()
poker.shuffle()
players = [Player('东邪'), Player('西毒'), Player('南帝'), Player('北丐')]
# 将牌轮流发到每个玩家手上每人13张牌
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)
16. 应用案例:工资结算系统
某公司有三种类型的员工:部门经理月薪固定 15000 元;程序员按工作时间支付月薪,每小时 200 元;销售员月薪由 1800 元底薪加上销售额 5% 的提成构成。设计一个工资结算系统。
定义抽象类 Employee
部门经理、程序员、销售员都是员工,有相同的属性和行为。我们先设计一个名为 Employee 的父类,再通过继承派生子类。由于不会直接创建 Employee 类的对象,我们可以将其设计成专门用于继承的抽象类。通过 abc 模块中名为 ABCMeta 的元类来定义抽象类。
from abc import ABCMeta, abstractmethod
class Employee(metaclass=ABCMeta):
"""员工"""
def __init__(self, name):
self.name = name
@abstractmethod
def get_salary(self):
"""结算月薪"""
pass
使用 @abstractmethod 装饰器将 get_salary 声明为抽象方法:抽象方法就是只有声明没有实现的方法,声明这个方法是为了让子类去重写这个方法。
派生三个子类
class Manager(Employee):
"""部门经理"""
def get_salary(self):
return 15000.0
class Programmer(Employee):
"""程序员"""
def __init__(self, name, working_hour=0):
super().__init__(name)
self.working_hour = working_hour
def get_salary(self):
return 200 * self.working_hour
class Salesman(Employee):
"""销售员"""
def __init__(self, name, sales=0):
super().__init__(name)
self.sales = sales
def get_salary(self):
return 1800 + self.sales * 0.05
三个类分别重写了 get_salary 方法,各实现不同——这就是多态:调用相同的方法,不同的子类对象做不同的事情。
使用 isinstance 判断类型并结算
emps = [Manager('刘备'), Programmer('诸葛亮'), 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 = float(input(f'请输入{emp.name}本月销售额: '))
print(f'{emp.name}本月工资为: ¥{emp.get_salary():.2f}元')
isinstance 函数可以判断出一个对象是不是某个继承结构下的子类型,比 type 函数更强大(type 是精准匹配,isinstance 是模糊匹配)。
本章小结
面向对象编程是一种非常流行的编程范式,除此之外还有指令式编程、函数式编程等。由于现实世界是由对象构成的,而对象是可以接收消息的实体,所以面向对象编程更符合人类正常的思维习惯。
类是抽象的,对象是具体的,有了类就能创建对象,有了对象就可以接收消息,这就是面向对象编程的基础。定义类的过程是一个抽象的过程:找到对象公共的属性属于数据抽象,找到对象公共的方法属于行为抽象。
面向对象的四大核心概念:抽象、封装、继承、多态。要想灵活运用需要长时间的积累和沉淀,大量的编程练习和阅读优质的代码是最好的方法。