笔记 / L12
L12 封装与继承
L12上一讲已经会写类了,这一讲解决两个工程问题:怎么封装,怎么复用。
本讲速览
- 访问数据属性走 getter / setter,类外直接
a.age是坏风格 - Python 不阻止你从类外读写属性,但「能写」不等于「该写」
- 子类
class Cat(Animal)继承父类全部数据和行为,可加新方法、覆盖旧方法 - 找方法:先在当前类找,再沿继承链往上,用第一个找到的
super().__init__先初始化父类那部分,super().method()调被覆盖的父类版本- 类变量(如
Rabbit.tag)属于类,所有实例共享
考试与作业安排
- HW3 截止 11 月 5 日 21:00,在 OJ 提交
- 期中 11/11(三)3–4 节、11/13(五)3–4 节与 11–12 节,占总评 41%
- 范围是课上讲过的 Python,不含 OOP 与继承
- 地点、形式、机考环境、考场规则见《考试与作业信息》
1. 类的回顾:getter/setter 与信息隐藏
- 定义类 = 类名 +
__init__+ 数据属性 + 方法,这套上一讲已经写过 - 用点号能直接拿属性
a.age,但更好的做法是用 getter / setter
class Animal(object):
def __init__(self, age):
self.age = age
def get_age(self):
return self.age
def set_age(self, age):
self.age = age
a = Animal(3)
print(a.get_age()) # 3
a.set_age(4)
print(a.get_age()) # 4
- 为什么要绕一层?类的作者可能改内部表示(比如把
age换成出生年份),只要方法签名不变,外部代码不用改 Animal(3)造出来的对象里,age存着 3;get_age()读它,set_age()改它- 换内部表示时重写
get_age()的内部逻辑就行,外部拿到的还是年龄 - Python 在信息隐藏上很弱,下面三种「越界」写法都能跑:
a = Animal(3)
print(a.age) # 从类外读
a.age = 'infinite' # 从类外写
a.size = 'tiny' # 从类外凭空造一个新属性
- 都能跑,但都不是好风格;和对象打交道只该通过方法
- 记住:方法才是接口,getter / setter 就是这个接口的入口
2. 继承与层级
- 层级里上面的叫父类(superclass),下面的叫子类(subclass)
- 子类继承父类的全部数据和行为,然后做三件事:加数据、加行为、覆盖行为
class Cat(Animal):
def speak(self):
print("meow")
c = Cat(3)
c.speak() # meow
print(c.get_age()) # 3
Cat里没写__init__,用的是Animal.__init__,所以Cat(3)直接可用Cat的实例也有age,因为初始化走的就是父类那份构造Cat的实例能调新方法speak();Animal的实例调speak()会报AttributeError
方法重名时按「就近」原则查找:
- 先在当前类的定义里找方法名
- 找不到就沿层级往上找:父类、祖父类,一直往上
- 用第一个找到的那个,后面的不再看
讲义接着用 Person → Student 走了同一个套路:共同属性留在父类,子类只加自己那一份。
3. 子类、类变量与自定义运算
Rabbit 是 Animal 的子类,多了一个类变量 tag,用来给每只新兔子发唯一编号:
class Rabbit(Animal):
tag = 1
def __init__(self, age, parent1=None, parent2=None):
Animal.__init__(self, age)
self.parent1 = parent1
self.parent2 = parent2
self.rid = Rabbit.tag
Rabbit.tag += 1
def get_rid(self):
return str(self.rid).zfill(3)
def get_parent1(self):
return self.parent1
def __add__(self, other):
return Rabbit(0, self, other)
r1 = Rabbit(1)
r2 = Rabbit(2)
print(r1.get_rid()) # 001
print(r2.get_rid()) # 002
print(Rabbit.tag) # 3
tag写在类里、方法外,属于类,被所有实例共享- 每只新兔子先拿走当前
tag当编号,再把tag加一,编号就不重复 r1.tag和r2.tag读到的都是类上那个tag,当前值是 3self.rid是实例变量,每只兔子各一份,所以能当唯一编号- 类变量放全类共享的东西(计数器、配置),实例变量放各对象自己的状态
- 类变量活在类的状态字典里,实例变量活在实例的状态字典里,两本字典分开
给兔子定义 +,让 r1 + r2 生出一只新兔子:
r3 = r1 + r2
print(r3.get_rid()) # 003
print(r3.age) # 0
print(r3.get_parent1() is r1) # True
r3 = r1 + r2的语义是:新兔子年龄 0,父母分别是r1和r2- 这正是第 11 讲
__add__的用法,只是返回的是同类的新实例
为什么要继承:
- 更清楚:共同点在父类,差异在子类,一眼看出谁和谁不同
- 复用代码:父类写一次,子类不必重写
- 更模块化:每层只关心自己新增的东西
4. 健身追踪器:Workout 类
场景:给智能手表写程序记录不同种类的锻炼。先把共同属性和专属属性分开:
| 共同属性 | 游泳特有 | 跑步特有 |
|---|---|---|
| icon、日期、起止时间 | swimming pace | cadence |
| 心率、kind、卡路里 | stroke type | running pace |
| distance | 100 yd splits | mile splits、elevation |
最朴素的版本:
class Workout(object):
def __init__(self, start, end, calories):
self.start = start
self.end = end
self.calories = calories
self.icon = ' '
self.kind = 'Workout'
w = Workout('9/30/2021 1:35 PM', '9/30/2021 1:57 PM', 200)
print(w.kind) # Workout
- 讲义给这个类补了 6 个方法:
get_calories()、get_start()、get_end()、set_calories()、set_start()、set_end() - 类状态字典里存的是方法和类变量,实例状态字典里存数据属性,实例那本通过
self访问 - 朴素版把起止时间当字符串存,字符串没法相减,所以真实版换成
datetime对象
真实版本要改两处:时间用 datetime 对象存,卡路里允许不填、按时长算:
from dateutil import parser
class Workout(object):
cal_per_hr = 200
def __init__(self, start, end, calories=None):
self.start = parser.parse(start)
self.end = parser.parse(end)
self.calories = calories # 可能是 None
self.icon = ' '
self.kind = 'Workout'
def get_duration(self):
return self.end - self.start
def get_calories(self):
if self.calories is None:
hours = self.get_duration().total_seconds() / 3600
return Workout.cal_per_hr * hours
return self.calories
def __str__(self):
mins = int(self.get_duration().total_seconds() // 60)
return f"[{self.icon}] {self.kind} {mins} min {self.get_calories():.0f} Cal"
w = Workout('9/30/2021 1:35 PM', '9/30/2021 1:57 PM')
print(w.get_calories()) # 73.33…,200 卡/小时 × 22 分钟
print(w) # [ ] Workout 22 min 73 Cal
dateutil 不是标准库,要额外装;它把字符串解析成标准库的 datetime 对象,之后日期运算就不用自己算:
from dateutil import parser
start = parser.parse('9/30/2021 1:35 PM')
end = parser.parse('9/30/2021 1:45 PM')
print(type(start)) # <class 'datetime.datetime'>
print((end - start).total_seconds()) # 600.0
- 两个
datetime相减得到timedelta,total_seconds()再换成秒 - 天数、闰年、时区这些麻烦事都由对象自己处理,不用手写
类变量 cal_per_hr 属于类,改它要用类名:
print(Workout.cal_per_hr) # 200
Workout.cal_per_hr = 250
print(w.cal_per_hr) # 250,所有实例一起变
- 讲义的警告:类变量由所有实例共享,改它对每个实例都生效
- 精确一点:
Workout.cal_per_hr = 250才是改共享的那个值;w.cal_per_hr = 250只是给w单独加了个同名属性
5. 用继承组织 workout 层级
层级长这样:Workout 下分 Indoor Workout 与 Outdoor Workout,前者再管 Treadmill、Weights,后者再管 Running、Swimming。
跑步课是 Workout 的子类,只写差异:
class RunWorkout(Workout):
def __init__(self, start, end, elev=0, calories=None):
super().__init__(start, end, calories)
self.icon = 'R'
self.kind = 'Running'
self.elev = elev
def get_elev(self):
return self.elev
def set_elev(self, e):
self.elev = e
rw = RunWorkout('9/30/2021 1:35 PM', '9/30/2021 3:35 PM', elev=100, calories=800)
print(rw.kind, rw.elev) # Running 100
print(rw.get_calories()) # 800
print(rw) # [R] Running 120 min 800 Cal
super().__init__(start, end, calories)先把父类那部分数据属性初始化好,再设自己的- 子类只写「多出来的那部分」,父类那 6 个 getter / setter 一个都不用重抄
- 内存里:
RunWorkout的类字典有自己的方法加一个指向父类的引用;实例字典既有父类的start、end、calories、icon、kind,也有子类的elev
注意最后一行 print(rw):子类一个字都没写 __str__,父类那段照样能用。
- 讲义原版用 f-string 拼了一个 16 字符宽、带边框的卡片,还会画上图标、时长、卡路里
- 关键在父类
__str__里写的是self.kind、self.get_calories():子类覆盖后自动用新版 Workout、RunWorkout、SwimWorkout共用同一段__str__,print都能打
覆盖父类方法:跑步用距离算卡路里,算不了就退回父类:
class RunWorkout(Workout):
cals_per_km = 100
def __init__(self, start, end, elev=0, calories=None, distance_km=None):
super().__init__(start, end, calories)
self.kind = 'Running'
self.elev = elev
self.distance_km = distance_km
def get_elev(self):
return self.elev
def get_calories(self):
if self.distance_km is not None:
return self.distance_km * RunWorkout.cals_per_km
return super().get_calories()
a = RunWorkout('9/30/2021 1:35 PM', '9/30/2021 3:35 PM', elev=100, distance_km=5)
b = RunWorkout('9/30/2021 1:35 PM', '9/30/2021 3:35 PM', elev=100, calories=700)
print(a.get_calories()) # 500,按 100 卡/公里
print(b.get_calories()) # 700,退回父类实现
- 讲义里用的是 GPS 轨迹点列表,逐段累加距离,这里用一维的
distance_km演示同一个结构 super().get_calories()是兜底:子类没新数据时不要重写父类的逻辑,直接借
方法查找的规则在这里再出现一次:父类的 __str__ 里调 self.get_calories(),如果对象是 RunWorkout,先看 RunWorkout 有没有定义,有就用它的,没有才往上找。
RunWorkout 的实例能用在任何需要 Workout 的地方,反过来不行:
def total_calories(workouts):
cals = 0
for w in workouts:
cals += w.get_calories()
return cals
def total_elevation(run_workouts):
elev = 0
for w in run_workouts:
elev += w.get_elev()
return elev
w1 = Workout('9/30/2021 1:35 PM', '9/30/2021 2:05 PM')
w2 = Workout('9/30/2021 4:35 PM', '9/30/2021 5:05 PM')
print(total_calories([w1, w2, a, b])) # 混合列表也能算
print(total_elevation([a, b])) # 200
total_calories只要求对象有get_calories(),所以父类子类混着传都行total_elevation要求get_elev(),传普通Workout进去就AttributeError
子类比较相等时,先比父类那部分,再比自己的新字段。为了只看清相等判断,这里把两个类压到最短:
class Workout(object):
def __init__(self, start, end, kind, calories):
self.start = start
self.end = end
self.kind = kind
self.calories = calories
def get_calories(self):
return self.calories
def __eq__(self, other):
return (type(self) == type(other)
and self.start == other.start
and self.end == other.end
and self.kind == other.kind
and self.get_calories() == other.get_calories())
class RunWorkout(Workout):
def __init__(self, start, end, kind, calories, elev):
super().__init__(start, end, kind, calories)
self.elev = elev
def __eq__(self, other):
return super().__eq__(other) and self.elev == other.elev
print(Workout('1:00', '2:00', 'Run', 200)
== RunWorkout('1:00', '2:00', 'Run', 200, 0)) # False,类型不同
print(RunWorkout('1:00', '2:00', 'Run', 200, 0)
== RunWorkout('1:00', '2:00', 'Run', 200, 50)) # False,海拔不同
print(RunWorkout('1:00', '2:00', 'Run', 200, 0)
== RunWorkout('1:00', '2:00', 'Run', 200, 0)) # True
type(self) == type(other)先卡同类型,避免Workout和RunWorkout被误判相等- 子类的
__eq__用super().__eq__(other)复用父类那串判断,再补self.elev
6. 面向对象设计:艺术多于科学
- OOP 是给代码分模块、把状态和函数打包的强工具
- 但它也很容易用过头:新手爱搭又深又复杂的类层级,不一定好
- 设计时想想你代码的用户:这样的分解对他们说得通吗
- 先想清楚「对象是什么、谁会用它」,再决定要不要拆成子类
- 网上关于 OOP 和「好设计」的意见一大堆,品味只能靠实践慢慢养
常见错误
| 症状 | 原因 | 修法 |
|---|---|---|
AttributeError: 'Animal' object has no attribute 'speak' |
speak 只定义在子类 |
调用前确认对象类型,或把方法提到父类 |
| 子类实例少了父类的属性 | 覆盖 __init__ 后没调父类构造 |
第一行写 super().__init__(start, end, calories) |
| 改了类变量,别的实例没跟着变 | 写成了 w.cal_per_hr = 250 |
用类名 Workout.cal_per_hr = 250 |
total_elevation([w1, rw]) 报错 |
w1 是父类实例,没有 get_elev |
只传子类实例,或用 isinstance 过滤 |
| 两个属性相同的对象却判不相等 | 子类 __eq__ 漏了父类那部分 |
super().__eq__(other) and self.elev == other.elev |
从类外 a.age = 'infinite' 后程序乱掉 |
绕过接口破坏了内部状态 | 用 set_age() 改 |
讲义原文
- 本讲课件(52 页):Lecture 12 · 继承与面向对象实践
Hyacine Studio