本章

基础篇 / 第 7 章

第 7 章 · 面向对象编程入门

本章导读:面向对象编程(Object-Oriented Programming,OOP)把「数据 + 操作数据的行为」打包成一个对象,让大型程序更容易维护和扩展。本章从类与对象的诞生讲起,逐一拆解 __init__、实例属性与类属性、封装、继承、多态三大特性,再讲清魔术方法(magic method / dunder method)、类方法与静态方法的真实用途,最后说明 Python 的动态特性(鸭子类型)为何让「继承」变得可选。面向对象进阶与设计模式见第 12 章。

从过程到对象:为什么需要 OOP

第 6 章的函数式写法在中小规模程序里很好用:函数接收数据、返回数据,数据在函数间流动。但随着程序变大,会遇到三个经典痛点:

  1. 数据散落:同一个「用户」的信息被拆成 name、age、email 三个独立变量,在十几个函数里传来传去。
  2. 数据与操作分离:print_user()、send_mail()、to_dict() 三个函数都在处理同一份用户数据,谁也离不开谁。
  3. 无法扩展:要新增「管理员」类型,只能复制粘贴整个函数组,把 if role == ... 塞得到处都是。

类(class)的思路是:把这批数据和「只服务于这批数据」的函数绑在一起,形成一个自洽的整体。

表 7-1 过程式 vs 面向对象
维度过程式面向对象
组织单位函数 + 独立数据类 → 对象
数据归属散落在参数里绑定到对象自身
扩展方式加分支 if role ==加子类
状态管理靠外部变量对象自己持有
复用单元函数类(含数据与行为)
要点Python 中几乎所有东西都是对象:数字、字符串、列表,甚至类本身、函数本身。理解「一切皆对象」是学完本章的钥匙——第 14 章的 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
表 7-2 类属性 vs 实例属性
对比项类属性实例属性
定义位置类体内、__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)指「把内部状态藏起来,只暴露受控的读写入口」,目的是防止外部把对象改坏。

下划线约定与真私有

表 7-3 属性可见性约定
写法含义外部可否访问
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,会让代码膨胀。

类方法、静态方法与工厂构造

表 7-4 三种方法的本质区别
方法装饰器第一个参数何时调用典型用途
实例方法无self(实例)obj.m()操作实例状态
类方法@classmethodcls(类本身)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
表 7-5 该用哪个:三种方法决策表
你的需求选择示例
读写实例数据实例方法 + propertydef 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']
表 7-6 继承写法对比
写法可读性多继承安全性建议
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()……

表 7-7 最常用的魔术方法
分类方法触发方式作用
创建__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
表 7-8 继承 vs 组合 vs 鸭子类型
方式耦合度灵活性何时选
继承(is-a)高低确实需要「替换」语义、有共享实现时
组合(has-a)低高把行为作为参数传入,最推荐
鸭子类型极低最高小型工具、临时协议、性能敏感处
Protocol低(编译期)高既要鸭子类型又要类型检查器(Pyright/mypy)
要点经验法则:「优先组合,而不是继承」(composition over inheritance)。当你只是想「用」某个类的功能,把对象作为属性存起来传入即可,不必写 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 章会讲异常的层级设计)。

常见反例对照

表 7-9 反面写法 vs 推荐写法
场景❌ 反面写法✅ 推荐写法
类属性可变对象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 让鸭子类型获得静态类型检查。优先组合,而非继承。

练习题

  1. 基础类设计。定义 Rectangle 类:__init__(self, width, height) 校验二者为正数;area、perimeter 为只读 @property;实现 __repr__;额外写 scale(k) 返回新对象(不修改自身)。测试 Rectangle(3, 4)。
  2. 等价性测试。给上一题补充 __eq__(面积相同即相等)、__lt__(按面积)、__hash__,验证 Rectangle(3,4) == Rectangle(4,3) 为 True 且能放进 set;再尝试用 __slots__ 精简类,找出它与 __dict__ 的取舍。
  3. 类方法工厂。为 DateRange 类提供三个类方法工厂:from_today(days)、from_string("2026-01-01,2026-02-01")、from_days_ago(n),并提供 __contains__(d) 判断日期是否落在区间内、__len__ 返回天数、__iter__ 逐日产出。
  4. 继承与多态。设计三层继承:Shape(抽象,area() 抛 NotImplementedError、__str__)→ Polygon(抽象,加 perimeter())→ Rectangle / Triangle(具体)。写一个函数接收任意 list[Shape],输出「名称 + 面积」,其中三角形面积由 area 用海伦公式计算,验证未实现的抽象类无法实例化。
  5. 多继承与 MRO。定义 A(__init__ 打印 A、show() 返回 "A")、B、C(A, B) 与 D(B, A),分别打印 C.__mro__ 与 D.__mro__,解释为什么两者顺序不同,并检查 C()、D() 是否都能正常初始化。
  6. 封装与校验。写 BankAccount:私有 __balance(真私有)、公开只读 balance 属性;deposit 拒绝非正数并限制单笔上限 10000;withdraw 余额不足时抛 InsufficientFunds(自定义异常,继承 Exception);__str__ 输出脱敏账号;__eq__ 按账号比较。列出演示全部行为。
  7. 运算符重载。实现 Vector2:__add__、__sub__、__mul__(支持标量右乘)、__truediv__(除零返回 NotImplemented)、__eq__、__abs__(模长)、__repr__、__len__(维度,固定 2)、__bool__(零向量为 False)、__iter__(可解包成 x, y)。测试向量相加、除零报错与 x, y = v。
  8. 鸭子类型 vs 继承。同一功能写两版:一个用继承(Shape 抽象基类 + 三个子类),一个用 typing.Protocol 定义 Drawable 协议。要求:向 render(shapes) 传入的对象在两个版本里都能工作(继承版把 Protocol 类直接传进去,协议版把子类传进去),验证鸭子类型确实不需要继承关系。
  9. 类方法注册表。实现一个迷你「插件系统」:基类 Codec 用 __init_subclass__ 自动把子类登记到 Codec.registry(键为 name 类属性);子类实现 encode(text) -> bytes 与 decode(data) -> str;写 @classmethod Codec.get(name) 查表并实例化。再实现 Base64Codec、ReverseCodec、ROT13Codec 三个子类,验证注册、查表与编解码往返一致。
  10. 综合实战:图形编辑器。实现 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__ 输出统计摘要。
Python 完全教程 · 第 7 章