第22章-封装与继承


昨天写的类,有个隐患你注意到没有:s.score = 60 这种写法,外面的代码想怎么改就怎么改,改成负数、改成字符串都没人拦。对象是你造出来的,数据却像摆在路边没人看管。今天解决这件事:封装,把数据锁进对象里,只留几个"窗口"让人操作。

顺便再学第二个设计:继承——先画一张通用的图纸,再画新的类时直接说"它也是这种",旧图上的东西就全有了,不用重画。两个概念都从生活例子讲起,不绕。

昨天留了个隐患:数据随便被人改

昨天的代码里,s.score 想改就改:

class Student:
    def __init__(self, name, score):
        self.name = name
        self.score = score

s = Student("小明", 90)
s.score = -100    # 外面直接改,没人拦

能跑,但很危险。想想银行账户:如果外面的人能随手 acc.balance = -100,余额变负数都没人拦。但如果只能通过 deposit()(存钱)、withdraw()(取钱)这两个"窗口"操作,窗口里就可以设规矩——金额必须大于 0、余额不足不能取。数据就安全了。

这就是封装(encapsulation):把"数据"和"操作这些数据的方法"打包在同一个类里,外部只能通过方法间接操作数据,不能直接乱动。

封装:给对象装上玻璃窗

封装的比喻,最好用的是银行柜台的玻璃窗:你的钱(数据)放在里面,外面的人不能伸手乱拿,只能通过柜台窗口(方法)办业务,取多少、存多少都要过一遍规矩。

Python 里的"锁"分三层:

写法 含义 能访问吗
self.balance 公开属性,外面随便改 能,但不建议
self._balance 约定"内部属性,别乱动"(下划线是提醒) 能,但约定不允许
self.__balance 真正的私有(Python 会做名字改写,外面访问会报错) 不能直接访问

本周重点用第二种_ 开头的属性是"君子协定",告诉读代码的人"这是我的内部数据,请走方法操作"。听懂会用即可,第三种先知道有这回事。

看一个带"窗口规矩"的账户类:

class Account:
    def __init__(self):
        self._balance = 0        # _ 开头:内部属性,别乱动

    def deposit(self, money):    # 存钱窗口
        if money > 0:
            self._balance += money
        else:
            print("金额必须大于 0")

    def show(self):              # 查看余额窗口
        print(f"余额:{self._balance}")

acc = Account()
acc.deposit(100)    # 存 100
acc.deposit(-5)     # 金额必须大于 0   ← 窗口拦住了非法操作
acc.show()          # 余额:100

输出长这样:

金额必须大于 0
余额:100

注意 deposit(-5) 被拦下来了,报了个"金额必须大于 0"。判断条件写在方法里,而不是靠外面的人自觉——这就是封装起作用的地方。

三个容易误解的点:

  • _balance 只是"约定",Python 并不真正禁止 acc._balance = -100。但你要养成习惯:内部数据一律加 _,并且只在方法里改
  • 封装不是"给每个属性都写个方法"。只给需要规则的属性开窗口,name 这种随便读的属性没必要包一层。
  • 方法名用动词(deposit 存钱、withdraw 取钱、show 显示),读代码的人不用看实现就知道能干什么——封装顺便带来了"接口清晰"。

为什么要费这个事

封装不是让代码变复杂,它换回的是三件实在的东西:

  1. 安全:数据不能随便被改成非法值(余额不为负、年龄不为负数)。
  2. 好维护:改数据规则时只改方法里那一处,不用到处找"谁在改这个属性"。以后想加"存钱要手续费",只改 deposit 一处就完事。
  3. 好分工:使用者只关心"窗口"(方法)怎么用,不关心内部实现。就像你用遥控器,不用懂电视机内部电路。

没封装有多乱,对比一下就知道了:

# 没有封装:谁都能乱改,规则散落各处
class Account:
    def __init__(self):
        self.balance = 0

acc = Account()
acc.balance = -100        # 直接改成负数,没人拦
acc.balance = "一百块"    # 甚至改成字符串,后面一加钱直接崩溃

# 有封装:规则只在 deposit / withdraw 里
class Account:
    def __init__(self):
        self._balance = 0

    def deposit(self, money):
        if money > 0:
            self._balance += money
        else:
            print("金额必须大于 0")

别把封装理解成"物理锁死"。本周的 _ 版本任何人都能访问,封装的意义在于引导大家走方法。练习里会让你故意直接改 acc._balance = -100 看看——目的是让你直观感受"能改 vs 应该改"的区别,那只是演示,不是推荐写法。

继承:子类白捡父类的能力

继承(inheritance) 用一句大白话说:新写一个类时,让它"拥有"另一个类的全部属性和方法,再补充自己的新东西。

  • 父类(parent class):被继承的类,也叫基类。
  • 子类(child class):继承别人的类,也叫派生类。子类是"父类的升级版",父类有的它全有,还额外多出自己的。
  • 写法:class 子类(父类):——括号里写父类名,就完成了"认亲"。

生活里的例子:Animal(动物)是所有动物的"通用图纸",会吃、会跑。Cat(猫)是"动物 + 会喵喵叫"。写 Cat 的时候,不用把 eat 重新抄一遍,直接继承 Animal 就有了:

class Animal:                          # 父类:通用图纸
    def __init__(self, name):
        self.name = name

    def eat(self):
        print(f"{self.name} 在吃东西")

class Cat(Animal):                     # 子类:继承 Animal
    def meow(self):                    # 子类自己的新方法
        print(f"{self.name}:喵~")

c = Cat("咪咪")       # 用父类的 __init__ 装名字
c.eat()              # 咪咪 在吃东西   ← 继承来的
c.meow()             # 咪咪:喵~      ← 自己新增的

输出长这样:

咪咪 在吃东西
咪咪:喵~

注意一个细节:Cat("咪咪") 创建对象时,Cat 没写自己的 __init__,会自动使用父类 Animal 的 __init__——名字照样装得上去。这是继承的默认行为:子类没定义的方法,去父类找

继承的方向最容易搞反:

  • 子类对象调用父类的方法(c.eat() 没问题),但父类对象不能调用子类的方法(Animal("x").meow() 会报 AttributeError)。能力是往下传的,不是往上冒的。
  • 子类不写 __init__,自动用父类的;子类自己写了 __init__ 又想保留父类的属性,就得用 super()(下一节),把父类的初始化也跑一遍。
  • 本周只学单继承(一个子类一个父类);继承还能嵌套:class 金毛(狗)class 狗(动物),一层层往上找方法。

super():叫一声"父类,来帮忙"

super() 是用来"调用父类方法"的钥匙。最常见的场景:子类重写了 __init__,但不想把父类那套初始化(比如装 name)重抄一遍,就调 super().__init__(...) 让父类先干它那份活。

什么时候必须用它?子类有自己的 __init__,并且希望保留父类装好的属性时。例子:

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, age):
        super().__init__(name)     # 让父类装 name(不用自己再写一遍 self.name = name)
        self.age = age             # 子类新增的属性自己装

d = Dog("旺财", 3)
print(d.name)    # 旺财  ← 父类装的
print(d.age)     # 3    ← 子类装的

输出长这样:

旺财
3

这里 super().__init__(name) 等价于把父类 __init__ 里的 self.name = name 执行了一遍。今天的要求:看得懂、会照着写就行,原理不用深究。

对比一下就明白它为什么必要:如果 Dog 的 __init__不写 super().__init__(name),只写 self.age = age,那 d.name 会报 AttributeError: 'Dog' object has no attribute 'name'——父类初始化被你的 __init__"覆盖"掉了,没机会执行。这是继承里最容易踩的坑。

写的时候注意三点:

  • 子类写了 __init__ 就会覆盖父类的 __init__,父类不再自动执行。需要父类属性时,第一行就写 super().__init__(...)
  • super().__init__(...) 传的参数要跟父类 __init__ 对齐(Animal.__init__(self, name) → 只传 name,self 不用传)。
  • super() 不只用在 __init__ 里,任何方法里都能用它调父类的同名方法。本周用到最多的还是 __init__

方法重写:同名的,各干各的

方法重写(override):子类定义了和父类同名的方法,调用时子类对象用的就是子类自己的版本,父类那版被"换掉"了。

动物都会"叫",但狗叫"汪汪"、猫叫"喵"。于是父类写一个通用的 speak,子类各自重写:

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        print(f"{self.name} 发出声音")

class Dog(Animal):
    def speak(self):                    # 重写父类的方法
        print(f"{self.name}:汪汪!")

class Cat(Animal):
    def speak(self):                    # 也重写
        print(f"{self.name}:喵~")

d = Dog("旺财")
c = Cat("咪咪")
d.speak()    # 旺财:汪汪!   ← 用 Dog 自己的版本
c.speak()    # 咪咪:喵~     ← 用 Cat 自己的版本

输出长这样:

旺财:汪汪!
咪咪:喵~

同一个方法名,不同对象干不同的事。三个要点:

  • 重写只影响"调用时用哪份代码",父类自己的方法不受影响——Animal("x").speak() 还是打印「发出声音」。
  • 重写后还想用父类版本的逻辑,方法里可以 super().speak() 先调父类的,再补自己的内容。
  • 重写方法的参数最好和父类保持一致(都叫 speak,参数个数一样),否则调用时容易传错参数。

随堂练习

练习一:体会封装(先做这个,感受最深)

  1. 抄写并运行上面的 Account 类,确认 deposit(-5) 被拦住。
  2. 然后故意在文件末尾加一行 acc._balance = -100,再运行 acc.show(),观察输出——思考:代码能跑吗?这说明了 Python 封装的什么特点?

练习二:给 Account 加"取钱"窗口

给 Account 加一个 withdraw(money) 取钱方法,规则:

  • 金额必须大于 0,否则打印「金额必须大于 0」
  • 余额不足时打印「余额不足」
  • 都通过才扣钱

提示:withdraw 的结构和 deposit 对称,但要多一个 if money > self._balance: 的判断。写完后测试:存 100 → 取 30(成功)→ 取 200(余额不足)→ 取 -5(被拦)。

进阶题 ★:继承练习——会飞的鸟

  1. 建一个 Bird(Animal) 子类(父类用上面的 Animal),加一个 fly() 方法打印「XX 在飞」。
  2. 创建鸟对象,分别调用 eat()(继承来的)和 fly()(自己的),确认都能用。
  3. 再挑战:给 Bird 写自己的 __init__(加一个 wings 翅膀数属性),用 super().__init__(name) 保留父类的名字属性,最后打印「XX 有 X 只翅膀」。

让 AI 给你讲封装和继承

两个概念要是还糊着,或者想让 AI 检查你写的类,用这段:

我是 Python 零基础初学者。请用大白话和生活中的比喻,给我讲清楚:
1. 什么是封装?为什么要用 _ 开头表示内部属性?
2. 什么是继承?super() 是干什么的?
每个概念给我一个不超过 15 行的完整代码例子,并逐行注释。最后告诉我新手最容易犯的 3 个错误。

报错速查

报错信息 什么意思 怎么办
AttributeError: 'Dog' object has no attribute 'name' 子类写了 __init__ 但没调 super().__init__(),父类没来得及装属性 子类 __init__ 第一行写上 super().__init__(父类需要的参数)
TypeError: __init__() missing 1 required positional argument: 'name' 调用 super().__init__(...) 时少传了父类需要的参数 数一下父类 __init__(self, ...) 的参数,逐个传齐
AttributeError: 'Animal' object has no attribute 'meow' 用父类对象调用子类独有的方法 记住继承方向:父类没有子类的新方法,只能用子类对象调
TypeError: speak() takes 1 positional argument but 2 were given 某个方法定义时漏了 self(常见于重写时照抄漏掉) 所有方法第一个参数永远是 self
没有报错,但 acc._balance 变成了 -100 你直接改内部属性,Python 默认不拦 记住约定:_ 开头的属性只在方法里通过 self 修改

明后两天的主角变了

今天两件事:封装把数据锁进对象,只留方法当窗口,_ 开头是"内部数据"的约定;继承让新类白捡旧类的能力,class Cat(Animal): 一写,认亲完成,子类没定义的方法自动去父类找,想保留父类的初始化就用 super().__init__(...)

这两天学的类,明天就要派上用场了——不过明天的主角不是代码,而是怎么把需求说给 AI 听。明后天学"给 AI 提需求"的两板斧:四要素和报错三步法,这是比任何语法都值钱的技能,也是周五图书管理项目的吃饭手艺。今天要是还有哪段代码没敲熟,今晚补一补,明天用得上。