Hyacine Studio
笔记 / L12

L12 封装与继承

L12
10301 字 约 26 分钟 提纲,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,当前值是 3
  • self.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() 改

讲义原文