基础篇 / 第 7 章
第 7 章 · 面向对象编程入门
__init__、实例属性与类属性、封装、继承、多态三大特性,再讲清魔术方法(magic method / dunder method)、类方法与静态方法的真实用途,最后说明 Python 的动态特性(鸭子类型)为何让「继承」变得可选。面向对象进阶与设计模式见第 12 章。从过程到对象:为什么需要 OOP
第 6 章的函数式写法在中小规模程序里很好用:函数接收数据、返回数据,数据在函数间流动。但随着程序变大,会遇到三个经典痛点:
- 数据散落:同一个「用户」的信息被拆成
name、age、email三个独立变量,在十几个函数里传来传去。 - 数据与操作分离:
print_user()、send_mail()、to_dict()三个函数都在处理同一份用户数据,谁也离不开谁。 - 无法扩展:要新增「管理员」类型,只能复制粘贴整个函数组,把
if role == ...塞得到处都是。
类(class)的思路是:把这批数据和「只服务于这批数据」的函数绑在一起,形成一个自洽的整体。
| 维度 | 过程式 | 面向对象 |
|---|---|---|
| 组织单位 | 函数 + 独立数据 | 类 → 对象 |
| 数据归属 | 散落在参数里 | 绑定到对象自身 |
| 扩展方式 | 加分支 if role == | 加子类 |
| 状态管理 | 靠外部变量 | 对象自己持有 |
| 复用单元 | 函数 | 类(含数据与行为) |
pathlib.Path、第 17 章的数据库游标、第 18 章的 Flask 请求对象,全都建立在这个观念上。
类的定义与对象的诞生
用 class 关键字定义类,类体同样靠缩进:
# class_basic.py —— 最小的类
class Dog:
"""狗类。这是一个最简单的类。"""
species = "犬科" # 类属性(class attribute):所有实例共享
def __init__(self, name, age):
"""构造方法,创建实例时自动调用。"""
self.name = name # 实例属性(instance attribute):每个实例独有
self.age = age
def bark(self):
"""实例方法:第一个参数必须叫 self。"""
return f"{self.name}({self.age}岁):汪汪!"
def birthday(self):
self.age += 1
return self
# 1) 创建对象:调用类本身,返回一个实例
d1 = Dog("旺财", 3)
d2 = Dog("小黑", 1)
# 2) 访问实例属性与方法
print(d1.name, d1.age) # 旺财 3
print(d1.bark()) # 旺财(3岁):汪汪!
# 3) 类属性被所有实例共享
print(d1.species, d2.species) # 犬科 犬科
Dog.species = "犬类"
print(d1.species, d2.species) # 犬类 犬类(两个实例都变了)
# 4) 实例属性互相独立
d1.age = 10
print(d1.age, d2.age) # 10 1
print("\n--- 实例的通用属性 ---")
print(type(d1)) # <class '__main__.Dog'>
print(d1.__dict__) # {'name': '旺财', 'age': 10} 实例自己的属性字典
print(Dog.__dict__.keys()) # 类属性与方法所在的字典(dict_keys 视图)
print(d1.birthday().age) # 11
| 对比项 | 类属性 | 实例属性 |
|---|---|---|
| 定义位置 | 类体内、__init__ 之外 | 方法内通过 self.x = 赋值 |
| 存储位置 | 类对象的 __dict__ | 实例对象的 __dict__ |
| 共享性 | 所有实例共享同一份 | 每个实例各有一份 |
| 访问方式 | Dog.species 或 d1.species | 只能 d1.name |
| 典型用途 | 常量、计数器、配置、共享缓存 | 每个对象自己的业务数据 |
list,一个实例 append,所有实例都被污染:
class BadList:
items = [] # 错误:共享!
def add(self, x):
self.items.append(x) # 改的是那个共享列表
class GoodList:
def __init__(self):
self.items = [] # 正确:每个实例独立
def add(self, x):
self.items.append(x)
需要共享且可控时,应在 __init__ 里写 self.shared = [](每实例一份)或明确使用 cls.shared(真共享,要小心)。
__init__ 与实例属性
__init__(前后双下划线)叫作初始化方法或构造器:它在实例创建后自动被调用,用来设定实例属性。
__init__ 的执行时机
# init_flow.py —— 实例创建的完整流程
print("步骤 1:定义类")
class Book:
print("步骤 2:执行类体(类属性、函数对象被创建)")
def __init__(self, title, price):
print(f"步骤 3:__init__ 被调用({title})")
self.title = title
self.price = price
def show(self):
print(f"步骤 4:调用普通方法 -> {self.title}")
print("开始创建实例")
b = Book("Python 入门", 59.9)
print("实例创建完成")
b.show()
# 实际输出顺序:
# 步骤 1:定义类
# 步骤 2:执行类体(类属性、函数对象被创建)
# 开始创建实例
# 步骤 3:__init__ 被调用(Python 入门)
# 实例创建完成
# 步骤 4:调用普通方法 -> Python 入门
class 语句执行时就跑一遍(此时还没有实例);__init__ 则每创建一个实例跑一遍。理解这个时序,能解释「为什么类体里不能直接引用实例变量」。
带默认值的 __init__ 与属性校验
# init_validate.py —— 在构造时守住不变量
class Product:
"""商品类:保证 name 非空、price 为正、stock 非负。"""
def __init__(self, sku, name, price, stock=0, tags=None):
if not sku or not isinstance(sku, str):
raise ValueError("sku 必须是非空字符串")
if not name.strip():
raise ValueError("name 不能为空")
if price <= 0:
raise ValueError("price 必须大于 0")
if stock < 0:
raise ValueError("stock 不能为负")
self.sku = sku
self.name = name.strip()
self.price = float(price) # 顺手做类型规范化
self.stock = int(stock)
self.tags = list(tags) if tags else [] # 用 list() 拷贝,避免外部修改
self.active = True
@property
def value(self):
"""只读派生属性:总价 = 单价 × 库存。"""
return round(self.price * self.stock, 2)
def restock(self, qty):
self.stock += qty
return self
def __repr__(self):
return f"Product({self.sku!r}, {self.name!r}, price={self.price})"
p = Product("SKU-001", " 机械键盘 ", 399, 12, ["外设", "热插拔"])
print(p) # Product('SKU-001', '机械键盘', price=399.0)
print("库存总价:", p.value)
print("补货后:", p.restock(8).stock, "总价", p.value)
print("标签:", p.tags)
try:
Product("SKU-002", "坏数据", -1)
except ValueError as e:
print("校验拦截:", e)
# 预期输出:
# Product('SKU-001', '机械键盘', price=399.0)
# 库存总价:4788.0
# 补货后:20 总价 7980.0
# 标签: ['外设', '热插拔']
# 校验拦截: price 必须大于 0
__init__ 必须返回 None(或什么都不写)。写 return self 会抛 TypeError: __init__() should return None。要返回对象请改用工厂类方法(见 s5)。
__init__ 里不要写 self.__init__(...) 去「复用」父类构造器,应该写 super().__init__(...)(见 s4)。
封装:属性保护与 property
封装(encapsulation)指「把内部状态藏起来,只暴露受控的读写入口」,目的是防止外部把对象改坏。
下划线约定与真私有
| 写法 | 含义 | 外部可否访问 |
|---|---|---|
self.name | 公开属性 | 可以,鼓励直接访问 |
self._name | 「约定私有」(protected-like),提示不要外部用 | 可以但不推荐(纯约定,不是强制) |
self.__name | 真私有(name mangling),编译为 _类名__name | 不能直接访问,继承也拿不到 |
# encapsulation.py —— 三种可见性
class Account:
owner = "公共类属性"
def __init__(self, balance, password):
self.balance = balance # 公开:简单数据直接开放
self._internal_id = id(self) # 约定私有:内部使用
self.__password = password # 真私有:外部与子类都访问不到
def check(self, pwd):
return pwd == self.__password
acc = Account(100, "s3cret")
print(acc.balance) # 100
print(acc._internal_id is not None) # True
print(acc.check("s3cret")) # True
print(acc.check("wrong")) # False
# print(acc.__password) # AttributeError
print(acc._Account__password) # s3cret <-- 名字改写后可以,但属于「钻空子」
# print(acc.password) # AttributeError
__name 的「私有」靠名字改写(name mangling)实现,编译器把它改成 _Account__password。它防的是「误用」而非「恶意」——知道改写规则的人仍可访问。真正的封装依赖 @property 提供的受控接口。
property:把方法伪装成属性
Python 的 property 内置函数让「取值」和「赋值」可以用 obj.score 这种属性语法,同时内部仍是方法逻辑,并可在赋值时自动校验:
# property_demo.py —— getter / setter / deleter
class Temperature:
"""温度类:内部统一用摄氏度,外部可读华氏/开尔文。"""
def __init__(self, celsius):
self._c = 0.0
self.celsius = celsius # 走 setter,顺带校验
@property
def celsius(self):
"""读取时执行的方法。"""
return round(self._c, 2)
@celsius.setter
def celsius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("温度必须是数字")
if value < -273.15:
raise ValueError("低于绝对零度")
self._c = float(value)
@property
def fahrenheit(self):
"""只读派生属性:没有对应 setter,赋值会报错。"""
return round(self._c * 9 / 5 + 32, 2)
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9 # 反算回摄氏度
@property
def kelvin(self):
return round(self._c + 273.15, 2)
def __del__(self):
"""对象被回收时的清理钩子(较少使用)。"""
pass
t = Temperature(25)
print(t.celsius, t.fahrenheit, t.kelvin) # 25.0 77.0 298.15
t.celsius = 30
print(t.celsius, t.fahrenheit) # 30.0 86.0
t.fahrenheit = 212
print(t.celsius) # 100.0
try:
t.celsius = 500
except ValueError as e:
print("拦截:", e)
# 拦截: 低于绝对零度
try:
t.kelvin = 0
except AttributeError as e:
print("只读属性不可写:", type(e).__name__)
# 只读属性不可写: AttributeError
上面还展示了「把无关的单位换算藏进类里」的价值:调用方写 t.fahrenheit = 212 就完成了单位转换,不必关心内部存的是摄氏度。
@property 暴露只读视图,需要额外校验或派生时再补 setter;不要为了「显得严谨」给每个属性都写 property,会让代码膨胀。
类方法、静态方法与工厂构造
| 方法 | 装饰器 | 第一个参数 | 何时调用 | 典型用途 |
|---|---|---|---|---|
| 实例方法 | 无 | self(实例) | obj.m() | 操作实例状态 |
| 类方法 | @classmethod | cls(类本身) | Cls.m() 或 obj.m() | 工厂、类级注册表、继承友好 |
| 静态方法 | @staticmethod | 无 | Cls.m() 或 obj.m() | 工具函数,与类逻辑相关但不需要类/实例 |
# class_static.py —— 类方法与静态方法
import math
class Circle:
"""圆。演示类方法(工厂)与静态方法(工具)。"""
registry = {} # 类属性:形状注册表
def __init__(self, radius):
if radius <= 0:
raise ValueError("半径必须为正")
self.radius = radius
# ---------- 类方法:工厂构造器 ----------
@classmethod
def from_diameter(cls, d):
"""由直径创建。cls 是当前类,子类调用时自动变成子类。"""
return cls(d / 2)
@classmethod
def from_square_area(cls, area):
if area <= 0:
raise ValueError("面积必须为正")
return cls(math.sqrt(area / math.pi))
@classmethod
def register(cls, shape):
"""把类登记到全局表里(插件注册模式的简化版)。"""
cls.registry[cls.__name__] = cls
return cls
# ---------- 静态方法:纯工具 ----------
@staticmethod
def is_valid_radius(r):
"""不依赖实例也不依赖类,做参数校验。"""
return isinstance(r, (int, float)) and r > 0
@staticmethod
def area_of(radius):
"""静态方法也能被实例调用,但不建议。"""
return math.pi * radius ** 2
# ---------- 实例属性接口 ----------
@property
def area(self):
return round(self.area_of(self.radius), 4)
@property
def perimeter(self):
return round(2 * math.pi * self.radius, 4)
Circle.register(Circle) # 登记
print("注册表:", list(Circle.registry)) # ['Circle']
c1 = Circle(1)
c2 = Circle.from_diameter(4)
c3 = Circle.from_square_area(25)
print(c1.radius, c2.radius, c3.radius) # 1 2.0 2.8209...
print("面积:", c1.area, c2.area) # 3.1416 12.5664
print("校验:", Circle.is_valid_radius(3), Circle.is_valid_radius(-1)) # True False
print("静态方法也可用实例调:", round(c1.area_of(2), 4)) # 12.5664
print("类方法可直接用类名调:", Circle.from_diameter(10).radius) # 5.0
| 你的需求 | 选择 | 示例 |
|---|---|---|
| 读写实例数据 | 实例方法 + property | def restock(self, n) |
| 提供另一条构造路径 | 类方法(cls) | @classmethod def from_json(cls, s) |
| 子类需要固定行为 | 类方法 | 注册表、元类替代品 |
| 只与本类概念相关的纯计算 | 静态方法 | @staticmethod def validate(s) |
| 逻辑其实不属于这个类 | 普通函数/模块函数 | 别硬塞进类里 |
self 也不接 cls,它和「类的实例」没有任何关系,只是恰好住在类里便于命名空间归类。如果一个静态方法完全不需要类上下文,把它写成模块级函数更诚实。
@classmethod 是「不继承就无法重载」的方法:cls.from_json() 在子类上调用时 cls 指向子类,从而实现「用父类的通用逻辑造出子类实例」。这比在类里写 if self.__class__.__name__ == ... 干净得多。
继承:类的派生与 super()
继承(inheritance)让子类复用父类的属性与方法,并可扩展或覆盖。Python 支持多继承,方法解析遵循 MRO(Method Resolution Order,方法解析顺序) 与 C3 线性化。
# inheritance.py —— 继承与 super()
class Shape:
"""基类:所有形状的公共部分。"""
sides = 0 # 类属性
def __init__(self, name, color):
self.name = name
self.color = color
self.history = [] # 每个实例一份
def describe(self):
return f"{self.color}的{self.name}"
def area(self):
raise NotImplementedError("子类必须实现 area()")
def scale(self, k):
"""通用逻辑:子类覆盖同名方法即可自动调用新逻辑(多态)。"""
self.name = f"{self.name}x{k}"
return self.history.append(f"缩放 {k} 倍")
def __repr__(self):
return f"{type(self).__name__}({self.name!r})"
class Circle(Shape):
def __init__(self, radius, color="蓝色"):
super().__init__("圆", color) # 调用父类 __init__,初始化公共属性
self.radius = radius
def area(self): # 覆盖父类的抽象方法
return round(3.14159 * self.radius ** 2, 2)
def describe(self): # 覆盖并扩展:父类逻辑 + 子类信息
base = super().describe()
return f"{base}(半径={self.radius})"
class Square(Shape):
sides = 4 # 覆盖类属性
def __init__(self, side, color="绿色"):
super().__init__("正方形", color)
self.side = side
def area(self):
return self.side ** 2
c = Circle(2, "红色")
s = Square(3)
print(c.describe()) # 红色的圆(半径=2)
print(c.area()) # 12.57
print(s.describe()) # 绿色的正方形
print(s.area()) # 9
print(c.history) # [] 子类实例也有父类初始化的属性
c.scale(2)
print(c.history, c.describe()) # ['缩放 2 倍'] 红色的圆(半径=2)
print("\n--- isinstance 与 issubclass ---")
print(isinstance(c, Shape), isinstance(c, Circle), isinstance(c, Square)) # True True False
print(issubclass(Circle, Shape), issubclass(Square, Circle)) # True False
print("\n--- 父类也能调用子类的多态方法 ---")
for sh in [c, s]:
print(sh.describe(), "面积=", sh.area())
super() 的正确用法
# super_usage.py —— super() 解决继承顺序问题
class Base:
def __init__(self, level):
self.level = level
print(f"Base.__init__(level={level})")
def ping(self):
print("Base.ping")
return "base"
class Mixin:
def ping(self):
print("Mixin.ping")
return "mixin"
class Child(Base, Mixin): # 多继承:MRO = Child -> Base -> Mixin -> object
def __init__(self, level, extra):
super().__init__(level) # 沿 MRO 调下一个,不要写 Base.__init__
self.extra = extra
def ping(self):
parent = super().ping() # 调 Base.ping(按 MRO)
return f"child -> {parent}"
ch = Child(3, "extra")
print(ch.ping())
print("MRO:", [k.__name__ for k in Child.__mro__])
# 预期输出:
# Base.__init__(level=3)
# Base.ping
# child -> base
# MRO: ['Child', 'Base', 'Mixin', 'object']
| 写法 | 可读性 | 多继承安全性 | 建议 |
|---|---|---|---|
Base.__init__(self, ...) | 清楚 | 差,绕过中间类 | 只在极简单场景且确认单一继承时使用 |
super().__init__(...) | 清楚 | 好,自动沿 MRO | 推荐 |
super(Child, self).__init__(...) | 啰嗦 | 好 | 只在需要跳过某一层时使用 |
super().__init__() 且参数个数保持一致。若 Base.__init__ 要 3 个参数而调用方只传 2 个,运行到那一行才报 TypeError,且报错位置离出错原因很远。用协作式多继承写法(统一 *args, **kwargs)可避免。
super().__init__() 会导致继承自 Shape 的 self.name、self.history 不存在,调用 describe() 时抛 AttributeError。子类务必显式调用父类构造器。
多态:让同一接口服务不同类型
多态(polymorphism)指「同一条调用语句,作用于不同对象时执行各自的方法」。它是继承体系最有价值的部分。
# polymorphism.py —— 多态实战
class Notification:
"""抽象基类:定义接口,不实现。"""
def send(self, message):
raise NotImplementedError
def summary(self):
# 模板方法:公共流程固定,步骤由子类实现
return f"[{type(self).__name__}] {self.channel} 通道发送"
class EmailNotification(Notification):
channel = "邮件"
def __init__(self, address):
self.address = address
def send(self, message):
print(f" 📧 发往 {self.address}:{message}")
class SMSNotification(Notification):
channel = "短信"
def __init__(self, phone):
self.phone = phone
def send(self, message):
print(f" 📱 发往 {self.phone}:{message}")
class WebhookNotification(Notification):
channel = "Webhook"
def __init__(self, url, secret):
self.url = url
self.secret = secret
def send(self, message):
print(f" 🔗 POST {self.url}(签名 {self.secret[:4]}***):{message}")
# 关键:broadcast 只关心「有 send 方法」,不关心具体类型
def broadcast(channels, message):
print(f"群发:{message}")
for ch in channels:
ch.summary()
ch.send(message)
subscribers = [
EmailNotification("tom@example.com"),
SMSNotification("13800000000"),
WebhookNotification("https://api.example.com/hook", "sk_live_abc123"),
]
broadcast(subscribers, "系统将于今晚 22:00 维护")
# 预期输出:
# 群发:系统将于今晚 22:00 维护
# [EmailNotification] 邮件 通道发送
# 📧 发往 tom@example.com:系统将于今晚 22:00 维护
# [SMSNotification] 短信 通道发送
# 📱 发往 13800000000:系统将于今晚 22:00 维护
# [WebhookNotification] Webhook 通道发送
# 🔗 POST https://api.example.com/hook(签名 sk_l***):系统将于今晚 22:00 维护
broadcast 没有写任何 if isinstance(...)。这就是多态的价值:新增一种通知渠道时,只新增一个类,broadcast 一行都不用改。这就是开闭原则(对扩展开放,对修改关闭)。
abc 模块可以声明「抽象基类」,禁止实例化未实现接口的类,并把接口意图写进代码:
from abc import ABC, abstractmethod
class Storage(ABC): # ABC = Abstract Base Class
@abstractmethod # 装饰器:子类必须实现
def save(self, data): ...
@abstractmethod
def load(self, key): ...
class FileStorage(Storage):
def save(self, data): return f"写入 {len(data)} 字节"
def load(self, key): return f"读取 {key}"
s = FileStorage() # 合法
# Storage() # TypeError: Can't instantiate abstract class
魔术方法与运算符重载
魔术方法(magic method,又称双下划线方法 / dunder method)是 Python 在特定时机自动调用的特殊方法。它让自定义类能接入语言的内建语法:+、len()、for、in、[]、==、with、print()……
| 分类 | 方法 | 触发方式 | 作用 |
|---|---|---|---|
| 创建 | __new__ / __init__ | Cls(...) | 分配内存 / 初始化属性 |
| 表示 | __repr__ | repr(obj)、交互式回显 | 面向开发者的精确表示 |
| 表示 | __str__ | str(obj)、print(obj) | 面向用户的友好文本 |
| 容器 | __len__ | len(obj) | 长度 |
| 容器 | __getitem__ | obj[k] | 下标访问 |
| 容器 | __setitem__ | obj[k] = v | 下标赋值 |
| 容器 | __contains__ | x in obj | 成员判断 |
| 容器 | __iter__ | for x in obj | 迭代(见第 10 章) |
| 运算 | __add__ | a + b | 加法 |
| 运算 | __eq__ | a == b | 相等判断 |
| 运算 | __lt__ | a < b | 小于(配合排序) |
| 上下文 | __enter__ / __exit__ | with obj: | 资源管理(第 8 章详述) |
| 属性 | __getattr__ / __setattr__ | 属性访问/赋值 | 动态属性、代理 |
| 属性 | __call__ | obj(...) | 让实例像函数一样可调用 |
| 比较 | __bool__ | if obj: | 真值判断 |
# dunder.py —— 常见魔术方法实现
class Money:
"""金额类:重载加法、比较、字符串化与布尔判断。"""
def __init__(self, amount, currency="CNY"):
if not isinstance(amount, (int, float)):
raise TypeError("金额必须是数字")
self.amount = round(float(amount), 2)
self.currency = currency
# ---- 表示 ----
def __repr__(self): # 面向开发者:可用于 repr() 复现对象
return f"Money({self.amount}, {self.currency!r})"
def __str__(self): # 面向用户:print() 时使用
symbol = {"CNY": "¥", "USD": "$"}.get(self.currency, "")
return f"{symbol}{self.amount:.2f}"
# ---- 运算重载 ----
def __add__(self, other):
if isinstance(other, Money):
self._check_currency(other)
return Money(self.amount + other.amount, self.currency)
if isinstance(other, (int, float)): # 支持 money + 10
return Money(self.amount + other, self.currency)
return NotImplemented # 交回 Python 处理
__radd__ = __add__ # 10 + money 也走同一逻辑
def __sub__(self, other):
other_amt = other.amount if isinstance(other, Money) else other
return Money(self.amount - other_amt, self.currency)
def __mul__(self, k):
return Money(self.amount * k, self.currency)
__rmul__ = __mul__
def __neg__(self):
return Money(-self.amount, self.currency)
def _check_currency(self, other):
if self.currency != other.currency:
raise ValueError(f"币种不一致:{self.currency} vs {other.currency}")
# ---- 比较 ----
def __eq__(self, other):
return (isinstance(other, Money)
and self.amount == other.amount
and self.currency == other.currency)
def __lt__(self, other):
self._check_currency(other)
return self.amount < other.amount
def __hash__(self): # 定义 __eq__ 后必须重新定义,否则对象不可哈希
return hash((self.amount, self.currency))
# ---- 容器 / 真值 ----
def __bool__(self):
return self.amount != 0
def __iter__(self): # 让 for 能遍历金额拆分成的元与角
yield int(self.amount)
yield int(round((self.amount % 1) * 100))
# ---- 可调用 ----
def __call__(self, times):
"""实例像函数一样用:money(3) 表示同一金额 3 笔。"""
return [Money(self.amount, self.currency) for _ in range(times)]
m1 = Money(19.9)
m2 = Money(5.1)
print(m1) # ¥19.90 -> __str__
print(repr(m1)) # Money(19.9, 'CNY') -> __repr__
print(m1 + m2) # ¥25.00
print(m1 + 10) # ¥29.90
print(10 + m1) # ¥29.90 -> __radd__
print(m2 * 3) # ¥15.30
print(3 * m2) # ¥15.30 -> __rmul__
print(-m1) # ¥-19.90
print(m1 > m2, m1 == m2) # True False
print(bool(m1), bool(Money(0))) # True False
print(list(m1)) # [19, 90]
print(len(m1(3))) # 3 -> m1(3) 调用 __call__
__eq__ 却不定义 __hash__,Python 会把实例设为不可哈希,于是不能放进 set 或作 dict 的键。要么补上 __hash__,要么明确写 __hash__ = None 表示「故意不可哈希」。
__add__ 时应返回 NotImplemented 而不是抛异常或返回 None,这样 Python 才会尝试 __radd__ 或给出合理的 TypeError。
__add__ 让金额相加很自然,但不要定义 __delete__ 来「删除订单」——那会让代码意图变得不可读。
鸭子类型:Python 的替代方案
Python 的多态并不依赖继承。只要对象「长什么样」(duck typing,鸭子类型),就能参与多态:如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。
# duck_typing.py —— 无需继承也能多态
class Duck:
def speak(self):
return "嘎嘎"
def fly(self):
return "扑棱"
class Dog:
def speak(self):
return "汪汪"
class Robot:
def speak(self):
return "哔哔"
class Fish: # 没有 speak,但有 swim
def swim(self):
return "摆尾"
def make_it_speak(creatures):
"""只调用 speak(),不关心类型。"""
for c in creatures:
# 用 hasattr 做防御,或直接信任调用者
if hasattr(c, "speak"):
print(f" {type(c).__name__}: {c.speak()}")
else:
print(f" {type(c).__name__}: (不会说话)")
make_it_speak([Duck(), Dog(), Robot(), Fish()])
# 预期输出:
# Duck: 嘎嘎
# Dog: 汪汪
# Robot: 哔哔
# Fish: (不会说话)
# ---- 结构化子类型(Protocol):想要「类型检查器友好」的鸭子类型 ----
from typing import Protocol, runtime_checkable
@runtime_checkable
class Speaker(Protocol):
"""只要有 speak() 方法就算 Speaker。"""
def speak(self) -> str: ...
print("\n--- Protocol 检查 ---")
for obj in (Duck(), Dog(), Robot(), Fish()):
print(f" {type(obj).__name__:6s} 是 Speaker? {isinstance(obj, Speaker)}")
# 预期输出:
# Duck 是 Speaker? True
# Dog 是 Speaker? True
# Robot 是 Speaker? True
# Fish 是 Speaker? False
| 方式 | 耦合度 | 灵活性 | 何时选 |
|---|---|---|---|
| 继承(is-a) | 高 | 低 | 确实需要「替换」语义、有共享实现时 |
| 组合(has-a) | 低 | 高 | 把行为作为参数传入,最推荐 |
| 鸭子类型 | 极低 | 最高 | 小型工具、临时协议、性能敏感处 |
| Protocol | 低(编译期) | 高 | 既要鸭子类型又要类型检查器(Pyright/mypy) |
class B(A)。这能彻底避免多继承带来的菱形继承与 MRO 冲突问题。
实战:图书管理系统(综合演练)
把类、封装、继承、多态、类方法、魔术方法全部用一遍:
library.py —— 图书管理系统
"""图书管理系统:演示 OOP 三大特性与魔术方法。"""
from datetime import date, timedelta
class Item:
"""抽象基类:馆藏物品。"""
registry = {} # 类属性:类级注册表
def __init_subclass__(cls, category=None, **kwargs):
"""魔术方法:子类定义时自动调用,用于登记。"""
super().__init_subclass__(**kwargs)
if category:
cls.category = category
Item.registry[category] = cls
@classmethod
def create(cls, **kwargs):
"""统一工厂:按类别创建正确的子类实例。"""
target = Item.registry.get(kwargs.get("category"))
return (target or cls)(**kwargs)
def __init__(self, item_id, title):
self.item_id = item_id
self.title = title
self.borrowed_by = None # None 表示在架
self.borrowed_at = None
@property
def available(self):
return self.borrowed_by is None
def __repr__(self):
return f"{type(self).__name__}({self.item_id!r}, {self.title!r})"
class Book(Item, category="book"):
"""图书:可借阅天数更长。"""
LOAN_DAYS = 30
def __init__(self, item_id, title, author, isbn, pages=0):
super().__init__(item_id, title)
self.author = author
self.isbn = isbn
self.pages = pages
@property
def due_date(self):
if not self.borrowed_at:
return None
return self.borrowed_at + timedelta(days=self.LOAN_DAYS)
def summary(self):
state = "在架" if self.available else f"借给{self.borrowed_by}"
return f"《{self.title}》/{self.author} — {state}"
class DVD(Item, category="dvd"):
"""光盘:借阅天数更短,无作者概念。"""
LOAN_DAYS = 7
def __init__(self, item_id, title, minutes):
super().__init__(item_id, title)
self.minutes = minutes
@property
def hours(self):
return f"{self.minutes // 60} 小时 {self.minutes % 60} 分"
def summary(self):
state = "在架" if self.available else f"借给{self.borrowed_by}"
return f"《{self.title}》({self.hours}) — {state}"
class Library:
"""图书馆:聚合馆藏与读者,实现借用/归还。"""
def __init__(self, name):
self.name = name
self._items = {} # 约定私有
self._readers = set()
def __len__(self):
return len(self._items)
def __contains__(self, item_id):
return item_id in self._items
def __iter__(self):
return iter(self._items.values())
def add(self, item):
if item.item_id in self._items:
raise ValueError(f"编号重复:{item.item_id}")
self._items[item.item_id] = item
return self
def register(self, reader):
self._readers.add(reader)
return self
def borrow(self, item_id, reader, today=None):
item = self._items.get(item_id)
if item is None:
raise KeyError(f"不存在:{item_id}")
if reader not in self._readers:
raise PermissionError(f"{reader} 未注册")
if not item.available:
raise RuntimeError(f"《{item.title}》已借出")
item.borrowed_by = reader
item.borrowed_at = today or date.today()
return item.due_date
def return_item(self, item_id):
item = self._items[item_id]
item.borrowed_by = None
item.borrowed_at = None
return item
def available_items(self):
return [i for i in self._items.values() if i.available]
if __name__ == "__main__":
lib = Library("市图书馆")
print("注册表:", Item.registry)
b1 = Book.create(category="book", item_id="B001", title="Python 完全教程",
author="张三", isbn="978-7-000-00001-1", pages=520)
b2 = Book("B002", "流畅的 Python", "Luciano Ramalho", "978-7-000-00002-8", 480)
d1 = DVD("D001", "Python 大型教程(DVD)", 385)
for it in (b1, b2, d1):
lib.add(it)
print(f"\n馆藏 {len(lib)} 件;'B001' 在库中? {'B001' in lib}")
print("在架:", [i.title for i in lib.available_items()])
lib.register("王小明")
due = lib.borrow("B001", "王小明", today=date(2026, 1, 1))
print("借出成功,应还日期:", due)
try:
lib.borrow("B001", "李小红")
except PermissionError as e:
print("权限拦截:", e)
try:
lib.borrow("B001", "王小明")
except RuntimeError as e:
print("状态拦截:", e)
print("\n--- 各馆藏自述(多态)---")
for it in lib:
print(" ", it.summary())
lib.return_item("B001")
print("\n归还后在架:", [i.title for i in lib.available_items()])
print("借期:Book", Book.LOAN_DAYS, "天 / DVD", DVD.LOAN_DAYS, "天")
预期输出:
注册表: {'book': <class '__main__.Book'>, 'dvd': <class '__main__.DVD'>}
馆藏 3 件;'B001' 在库中? True
在架: ['Python 完全教程', '流畅的 Python', 'Python 大型教程(DVD)']
借出成功,应还日期: 2026-01-31
权限拦截: 李小红 未注册
状态拦截: 《Python 完全教程》已借出
--- 各馆藏自述(多态)---
《Python 完全教程》/张三 — 借给王小明
《流畅的 Python》/Luciano Ramalho — 在架
《Python 大型教程(DVD)》(6 小时 25 分) — 在架
归还后在架: ['Python 完全教程', '流畅的 Python', 'Python 大型教程(DVD)']
借期:Book 30 天 / DVD 7 天
__init_subclass__ 是「类定义时自动执行」的魔术方法,用来维护注册表;Item.create 用类方法实现了「按类别自动选类」的工厂;__len__ / __contains__ / __iter__ 让 Library 表现得像内置容器;borrow 抛出不同异常表达不同失败原因(第 8 章会讲异常的层级设计)。
常见反例对照
| 场景 | ❌ 反面写法 | ✅ 推荐写法 |
|---|---|---|
| 类属性可变对象 | items = [] | 在 __init__ 中 self.items = [] |
| 调用父类构造器 | Base.__init__(self, x) | super().__init__(x) |
| 父类字段未初始化 | 子类忘了 super() | 强制 super().__init__(),加测试 |
| 用 if 判类型 | if type(o) == Dog: | isinstance(o, Dog)(会匹配子类)或直接多态 |
| 大量实例方法当工具 | obj.validate() 却不用 self | @staticmethod 或模块函数 |
滥用 __getattr__ | 任意拼错属性都返回 None | 只在明确做代理时使用,并对少数名字放行 |
| 类里塞太多无关工具 | 500 行的「万能类」 | 按职责拆成多个小类 + 模块函数 |
| 为「显得严谨」加 setter | 每个属性都 @x.setter | 只在需要校验/派生时加 |
type(o) == Dog 是精确类型比较,Dog 的子类实例返回 False,在多态场景几乎总是错的;应使用 isinstance(o, Dog)。
obj.__dict__(实例属性)、Cls.__dict__(类成员)、[c.__name__ for c in Cls.__mro__](继承链)、help(obj)(完整文档)。第 22 章会介绍用 IDE 断点查看这些。
本章小结
- 类与对象:
class定义类,调用类创建实例。Python 中一切皆对象,包括类与函数。 - 类属性 vs 实例属性:类属性写在类体里被所有实例共享;实例属性在
__init__中通过self.x =赋值,每实例独有。可变对象绝不能作类属性。 __init__在实例创建后自动调用,必须返回None;适合在此做参数校验与类型规范化。- 封装三层:
self.x公开、self._x约定私有、self.__x名字改写真私有。真正的封装靠@property提供受控读写与派生属性。 - 三种方法:实例方法接
self;@classmethod接cls,适合工厂构造与注册表;@staticmethod不接收任何参数,只是住在类里的工具函数。 - 继承:子类通过
super().__init__()复用父类;方法可覆盖;多继承按 C3 线性化的 MRO 查找super()。 - 多态:同一条调用语句作用于不同对象执行各自实现,让调用方不需要
if isinstance,即开闭原则。抽象接口可用abc.ABC+@abstractmethod声明。 - 魔术方法把自定义对象接入语言语法:
__str__/__repr__、__len__、__getitem__、__iter__、__add__、__eq__、__enter__/__exit__、__call__。定义了__eq__就要补__hash__。 - 鸭子类型:只要「长得像」就能参与多态,不一定需要继承;
typing.Protocol让鸭子类型获得静态类型检查。优先组合,而非继承。
练习题
- 基础类设计。定义
Rectangle类:__init__(self, width, height)校验二者为正数;area、perimeter为只读@property;实现__repr__;额外写scale(k)返回新对象(不修改自身)。测试Rectangle(3, 4)。 - 等价性测试。给上一题补充
__eq__(面积相同即相等)、__lt__(按面积)、__hash__,验证Rectangle(3,4) == Rectangle(4,3)为 True 且能放进set;再尝试用__slots__精简类,找出它与__dict__的取舍。 - 类方法工厂。为
DateRange类提供三个类方法工厂:from_today(days)、from_string("2026-01-01,2026-02-01")、from_days_ago(n),并提供__contains__(d)判断日期是否落在区间内、__len__返回天数、__iter__逐日产出。 - 继承与多态。设计三层继承:
Shape(抽象,area()抛NotImplementedError、__str__)→Polygon(抽象,加perimeter())→Rectangle/Triangle(具体)。写一个函数接收任意list[Shape],输出「名称 + 面积」,其中三角形面积由area用海伦公式计算,验证未实现的抽象类无法实例化。 - 多继承与 MRO。定义
A(__init__打印 A、show()返回 "A")、B、C(A, B)与D(B, A),分别打印C.__mro__与D.__mro__,解释为什么两者顺序不同,并检查C()、D()是否都能正常初始化。 - 封装与校验。写
BankAccount:私有__balance(真私有)、公开只读balance属性;deposit拒绝非正数并限制单笔上限 10000;withdraw余额不足时抛InsufficientFunds(自定义异常,继承Exception);__str__输出脱敏账号;__eq__按账号比较。列出演示全部行为。 - 运算符重载。实现
Vector2:__add__、__sub__、__mul__(支持标量右乘)、__truediv__(除零返回NotImplemented)、__eq__、__abs__(模长)、__repr__、__len__(维度,固定 2)、__bool__(零向量为 False)、__iter__(可解包成 x, y)。测试向量相加、除零报错与x, y = v。 - 鸭子类型 vs 继承。同一功能写两版:一个用继承(
Shape抽象基类 + 三个子类),一个用typing.Protocol定义Drawable协议。要求:向render(shapes)传入的对象在两个版本里都能工作(继承版把 Protocol 类直接传进去,协议版把子类传进去),验证鸭子类型确实不需要继承关系。 - 类方法注册表。实现一个迷你「插件系统」:基类
Codec用__init_subclass__自动把子类登记到Codec.registry(键为name类属性);子类实现encode(text) -> bytes与decode(data) -> str;写@classmethod Codec.get(name)查表并实例化。再实现Base64Codec、ReverseCodec、ROT13Codec三个子类,验证注册、查表与编解码往返一致。 - 综合实战:图形编辑器。实现
Canvas类,内部用list保存图形,实现add(shape)、remove(shape_id)、total_area()、group_by_type();图形基类Shape带shape_id、__eq__、__hash__(便于 remove 与 set 去重);子类Circle、Rect、Text(Text无面积,area()返回 0,用于演示「多态下不同类型同一接口」)。最后实现Canvas.render()返回可读字符串,并用__str__输出统计摘要。