3.1 类、对象、封装与继承


3.1 类、对象、封装与继承

本节摘要:面向对象编程把"数据"和"操作数据的方法"绑在一起,形成可复用的类。本节讲清楚类与对象的关系、属性与方法的定义、封装的目的与实现、继承的复用价值。通过图书管理和银行账户两道练习,把"用类建模现实问题、用继承减少重复"的能力练出来。

学习目标

阅读完本节,你应当能够:

  1. 定义类、创建实例、编写 __init__ 初始化方法
  2. 区分类属性与实例属性,说明各自的用途
  3. 用命名约定实现基本的封装,保护内部数据
  4. 用继承复用父类代码,用方法重写实现多态
  5. 独立完成一道用面向对象建模的练习题

问题与直觉

假设你要写一个图书管理系统。每本书有书名、作者、是否借出这几个数据,还要有"借出"、"归还"这几个操作。如果用第 2 章的函数风格,你大概会用一堆字典存书,再写几个函数去操作这些字典:

book1 = {"title": "Python入门", "author": "张三", "borrowed": False} def borrow(book): if book["borrowed"]: print("已被借出") else: book["borrowed"] = True

这种写法小规模能用,但问题很快暴露:数据和操作它的函数是分离的,谁都能随手改 book["borrowed"],绕过 borrow 函数里的校验逻辑。一旦书的结构变了(比如加个借阅者字段),所有相关函数都得跟着改。

面向对象的思路是:把书的数据和操作书的方法绑在一起,形成一个"书"类。数据被封装在对象内部,外部只能通过对象提供的方法来操作它,这样就保护了数据不被随意篡改,也让"书"相关的逻辑集中在一处。

继承解决的是另一个问题——重复。如果除了书还有杂志、DVD,它们都有"借出/归还"的逻辑,没必要每个都重写一遍。定义一个通用的"可借阅资源"父类,让书、杂志、DVD 继承它,公共逻辑写一次就够了。

核心原理

2.1 类与对象的关系

类是蓝图,对象是按蓝图造出来的具体实例。就像"汽车设计图"是类,而你停在车库里的那辆具体的车是对象。

class Dog: def __init__(self, name, age): self.name = name # 实例属性 self.age = age def bark(self): print(f"{self.name} 汪汪叫") d1 = Dog("旺财", 3) # 创建实例(对象) d2 = Dog("小白", 5) d1.bark() # 旺财 汪汪叫 print(d2.age) # 5

__init__ 是初始化方法,创建对象时自动调用。self 指向当前对象本身,通过它访问和设置对象的属性。每个实例有自己独立的一套实例属性。

2.2 类属性与实例属性

class Dog: species = "犬科" # 类属性,所有实例共享 def __init__(self, name): self.name = name # 实例属性,每个实例独有

💡 关键直觉:所有实例都一样、不会变的值用类属性(如物种、默认配置);每个实例各自不同的值用实例属性(如名字、年龄)。混用会导致难以追踪的 bug。

2.3 封装:保护内部数据

Python 没有严格的访问控制,但有命名约定:名字前加单下划线 _ 表示"内部使用,别从外部碰";加双下划线 __ 会触发名称改写,更难从外部访问。

class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.__balance = balance # 私有属性 def deposit(self, amount): if amount <= 0: raise ValueError("存款必须为正") self.__balance += amount def get_balance(self): return self.__balance

现在你不能直接 account.__balance = -1000 来篡改余额了(严格说还能通过 _BankAccount__balance 访问,但这是约定上的禁忌)。所有修改都必须走 deposit 方法,校验逻辑就有了保障。

封装的核心价值不是"绝对禁止访问",而是把修改的入口收拢到受控的方法里,让对象始终处于合法状态。

2.4 继承与方法重写

class Animal: def __init__(self, name): self.name = name def speak(self): raise NotImplementedError("子类必须实现") # 模板方法 class Dog(Animal): # 继承 Animal def speak(self): # 重写父类方法 return f"{self.name}: 汪汪" class Cat(Animal): def speak(self): return f"{self.name}: 喵喵" animals = [Dog("旺财"), Cat("小花")] for a in animals: print(a.speak()) # 多态:同一调用,不同行为

这就是多态:同一个 speak() 调用,根据对象的实际类型表现出不同行为。它让你能写"针对父类"的通用代码,自动适配所有子类。

工程实践要点

3.1 什么时候该用类

不是所有代码都该塞进类。判断标准是:有没有"需要把数据和操作绑在一起"的实体

场景 推荐做法
一组纯函数式的数据处理 用函数,别硬造类
一个有状态、有行为的实体(用户、订单、购物车) 用类
多个实体共享公共逻辑、各有差异 用类 + 继承
一次性脚本、逻辑简单 用函数足够

⚠️ 常见坑:新手容易"为了面向对象而面向对象",把几个不相关的函数硬塞进一个类里。类应该围绕一个内聚的概念组织,不是为了装代码而存在。

3.2 super() 调用父类

子类重写 __init__ 时,别忘了调用父类的初始化,否则父类的属性不会被设置:

class GuideDog(Dog): def __init__(self, name, handler): super().__init__(name) # 调用父类 __init__,设置 name self.handler = handler # 再设置子类特有属性

漏写 super().__init__() 是继承里最常见的 bug——子类对象看起来"缺了"父类该有的属性。

3.3 继承层次别太深

继承超过两三层就会变得难维护:改一个顶层类,所有后代都受影响,而且追踪某个方法到底来自哪一层很费劲。优先用组合(在一个类里持有另一个类的实例)而非继承来复用代码,这是更灵活的设计。

3.4 @property 让方法像属性

有时你想在读取或设置属性时加逻辑,又不想让调用方写成方法调用的形式,用 @property

class Circle: def __init__(self, radius): self.radius = radius @property def area(self): return 3.14 * self.radius ** 2 c = Circle(5) print(c.area) # 78.5,像属性一样访问,但背后是计算

分层练习

基础题:图书类

题面:定义一个 Book 类,有书名、作者、是否借出三个属性。提供 borrow()return_book() 两个方法,借出时若已借出要提示"已被借出",归还时若未借出要提示"未被借出"。

思路点拨__init__ 里初始化三个属性,borrowed 默认 False。两个方法里先判断当前状态再修改。

参考骨架

class Book: def __init__(self, title, author): self.title = title self.author = author self.borrowed = False def borrow(self): if self.borrowed: print(f"《{self.title}》已被借出") else: self.borrowed = True print(f"成功借出《{self.title}》") def return_book(self): # TODO: 实现归还逻辑 if not self.borrowed: print(f"《{self.title}》未被借出") else: self.borrowed = False print(f"已归还《{self.title}》") b = Book("Python入门", "张三") b.borrow() # 成功借出 b.borrow() # 已被借出 b.return_book() # 已归还

进阶题:银行账户类

题面:定义 BankAccount 类,有户主、余额(私有)两个属性。提供 deposit(存款,金额必须为正)、withdraw(取款,金额为正且不超过余额)、get_balance(查询余额)三个方法。非法操作要抛出异常或给出提示。

思路点拨:余额用 __balance 私有化,强制所有修改走方法。取款要同时校验"金额为正"和"余额足够"。

参考骨架

class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.__balance = balance def withdraw(self, amount): # TODO: 校验金额并取款 if amount <= 0: raise ValueError("取款必须为正") if amount > self.__balance: raise ValueError("余额不足") self.__balance -= amount def get_balance(self): return self.__balance acc = BankAccount("小明", 100) acc.deposit(50) acc.withdraw(30) print(acc.get_balance()) # 120

💡 思考延伸:如果想给账户加"透支额度"功能(允许余额为负到某个限度),怎么改?这就要扩展 withdraw 的校验逻辑——因为修改入口收拢在方法里,加功能只改一处,这就是封装的红利。

挑战题:形状继承体系

题面:定义一个 Shape 基类,有 area() 方法(在基类里抛出 NotImplementedError)。再定义 Rectangle(长方形)和 Circle(圆形)两个子类,重写 area() 计算各自面积。最后写一段代码,把几个不同形状放进列表,循环调用 area() 并输出——体会多态。

思路点拨:基类的 area 是模板方法,子类必须重写。这题练的是继承 + 多态的组合。

参考骨架

import math class Shape: def area(self): raise NotImplementedError class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 shapes = [Rectangle(3, 4), Circle(5), Rectangle(2, 2)] for s in shapes: print(f"面积:{s.area():.2f}")

⚠️ 常见坑:基类方法直接 pass 而不抛异常,会让"忘记在子类重写"的问题延迟到运行时才暴露,而且静默返回 None。用 raise NotImplementedError 能尽早报警。

追问与答疑

双下划线私有和单下划线到底该用哪个? 团队协作的默认选择是单下划线:它是"君子协定",够用且不制造障碍。双下划线的名称改写主要防一种事故——子类不小心覆盖了父类的内部属性,比如父类和子类都定义了 __temp,改写后它们实际是两个不同名字,互不干扰。所以决策可以这样记:内部实现细节用单下划线;担心继承体系里撞名才上双下划线。把双下划线当"安全锁"是误解,它挡不住存心要访问的人。

@property 用多了有什么坏处? 它把"计算"伪装成"存储",调用方以为读属性是免费操作,实际可能触发昂贵计算。一个 area 每次访问都重算还好,若某属性背后是数据库查询,藏在属性语法后面就是性能陷阱。原则:轻量计算用 property 没问题;开销大的操作老老实实做成方法,名字本身就是在告诉调用者"这里有代价"。

继承和组合怎么选,有没有更具体的判据? 问一句:"B 是一种 A 吗?"成立才用继承。汽车"是一种"交通工具,可继承;引擎不是一种汽车,汽车"有一个"引擎,用组合。用错方向的代价在需求变更时显现:继承让子类绑死父类实现,父类内部一改,子类莫名其妙坏掉;组合只是持有一个引用,两边可以独立演化。练习里可以故意把"图书管理系统"改成用组合实现(管理器持有书对象列表),对比两种结构的改动成本。

类属性被实例赋值覆盖时发生什么? 实例上赋值会"遮蔽"类属性:d1.species = "猫科" 只在 d1 自己身上生效,其他实例仍读到"犬科"。这是因为属性查找先看实例再回类。想让"改一个、全体变"时直接改类属性 Dog.species = ...。初学者常在这里被吓到——同一行赋值,写在实例前和写在类前,影响范围完全不同。拿 Dog 类试两分钟,这个机制就刻进记忆了。

补充练习:倒计时类与重写练习

题面:定义一个 Countdown 类,初始化接收秒数;提供 tick() 方法每次调用减一并返回剩余秒数,减到零后再调用返回零不变;再重写 __str__,让打印形如"剩余 3 秒"。

思路点拨:剩余秒数是内部状态,用单下划线命名。tick 的边界在"已经是零"时要稳住不变成负数。__str__ 用 f-string 直接拼。写完后挑战延伸:给类加一个 reset(n) 方法重置秒数,体会"状态被方法管理"的封装感。

参考骨架

class Countdown: def __init__(self, seconds: int): self._left = seconds def tick(self) -> int: if self._left > 0: self._left -= 1 return self._left def __str__(self): return f"剩余 {self._left} 秒" c = Countdown(3) print(c) # 剩余 3 秒 print(c.tick()) # 2 print(c.tick(), c.tick(), c.tick()) # 1 0 0

最后那个"连 tick 三次输出 1 0 0"是刻意设计的验证点:第三次调用不该把状态破坏成 -1。状态机类的正确性,就看边界处守不守得住。

顺带一个建模观察:为什么倒计时不直接用一个整数变量加一个函数?因为一旦系统里同时存在多个倒计时(比如三盏灯各自的延时),散落的变量就要复制三份逻辑;收进类之后,每个对象自带状态和行为,互不干扰。类在多实例场景下的优势最明显——单实例时函数和类的差别确实不大,这回答了"什么时候值得上类"的另一个判据:预期这个概念会不会出现多份。

本节自查清单

  • 类是蓝图,对象是实例__init__ 初始化,self 指向当前对象。
  • 类属性共享、实例属性独有:所有实例一样的值用类属性,各自不同的用实例属性。
  • 封装收拢修改入口:用 _/__ 约定和受控方法保护数据,让对象始终合法。
  • 继承复用、多态适配:子类继承父类公共逻辑,重写方法实现差异化行为;针对父类写通用代码可自动适配子类。
  • 重写 __init__ 要调 super():否则父类属性不会被初始化。
  • 继承层次别太深:优先组合而非继承,两三层内最好。
  • 别为了 OOP 而 OOP:类应围绕内聚概念组织,无状态纯逻辑用函数即可。

下一节我们让自定义类支持 Python 的内置操作——加法、长度、迭代,靠的是魔术方法。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U