引言
第八章(面向对象入门)到此收官。本篇综合测验严格以第 1 至 8 篇为范围:第 1 篇讲类与实例、属性查找的层次(类属性与实例属性);第 2 篇讲 __init__、实例属性与名称改写(name mangling);第 3 篇讲实例方法、类方法(classmethod)、静态方法(staticmethod)与 self;第 4 篇讲封装与 property 的读写守卫;第 5 篇讲继承、覆盖与 MRO;第 6 篇讲多态与鸭子类型(duck typing);第 7 篇讲 __str__/__repr__/__eq__ 等魔法方法及其协议;第 8 篇用图书管理系统做了建模实战。第九篇订单系统是这八篇的深化应用,本篇不考察它,也不引入第九章及以后的任何知识(argparse、logging、单元测试都不出现)。
全篇分四个部分:10 道选择题按「记忆 → 理解 → 应用 → 分析」的难度梯度排列,每题标注难度并附运行代码,逐题解析放在答案折叠区;两道编程题给出可独立运行的参考解答与实测输出;一个综合任务——用面向对象实现小型账户系统,考察你把异常、property、魔法方法与流程编排串起来的能力;最后是学习自检清单。建议先独立完成全部题目再对照解析,做错的题回到对应篇章重读原理。文中所有运行结果均基于本机 CPython 3.12.10(Windows)实测,未做任何修饰。
选择题(共 10 题,每题只有一个正确答案)
第 1 题:实例属性与类属性【记忆】
class A:
x = 1
a = A()
a.x = 2
b = A()
print(a.x, b.x, A.x)
输出是什么?
A. 2 1 1
B. 1 1 1
C. 2 2 1
D. 抛 AttributeError(a 没有 x)
第 2 题:self 的本质【理解】
class Dog:
def __init__(self, name):
self.name = name
def greet(self):
return self.name + ' 在叫'
d = Dog('旺财')
print(Dog.greet(d))
输出是什么?
A. 旺财 在叫
B. 抛 TypeError:greet() 缺少必填参数 self
C. 抛 NameError:找不到 name
D. 输出 在叫(self 被忽略)
第 3 题:类方法与静态方法【理解】
class Tool:
count = 0
def __init__(self):
Tool.count += 1
@classmethod
def how_many(cls):
return cls.count
@staticmethod
def version():
return '1.0'
a = Tool()
b = Tool()
print(Tool.how_many(), b.how_many(), Tool().how_many(), a.version())
输出是什么?
A. 2 2 3 1.0
B. 2 2 2 1.0
C. 2 3 3 1.0
D. 抛 TypeError:how_many 不能通过实例调用
第 4 题:名称改写【记忆】
class A:
def __init__(self):
self.__secret = 1
a = A()
print(a.__secret)
输出是什么?
A. 1
B. 抛 AttributeError:__secret 已被改写成 _A__secret
C. 打印字符串 __secret
D. 抛 NameError
第 5 题:只读 property【理解】
class Box:
def __init__(self):
self._value = 10
@property
def value(self):
return self._value
b = Box()
print(b.value)
b.value = 20
输出是什么?
A. 打印 10 后正常结束(赋值被静默忽略)
B. 打印 10 后抛 AttributeError(property 没有 setter)
C. 第一行就抛 AttributeError
D. 打印 20
第 6 题:继承与 MRO 查找顺序【应用】
class A:
def who(self):
return 'A'
class B(A):
pass
class C(A):
def who(self):
return 'C'
class D(B, C):
pass
print(D().who())
输出是什么?
A. A
B. B
C. C
D. 抛 TypeError(D 没有 who)
第 7 题:覆盖 __init__ 与 super【应用】
class A:
def __init__(self):
self.x = 1
class B(A):
def __init__(self):
self.y = 2
b = B()
print(hasattr(b, 'x'), b.y)
输出是什么?
A. True 2
B. False 2
C. True 1
D. 抛 AttributeError
第 8 题:鸭子类型【理解】
def speak(animal):
return animal.voice()
class Duck:
def voice(self):
return '嘎嘎'
class Robot:
def voice(self):
return '哔哔'
print(speak(Duck()), speak(Robot()))
输出是什么?
A. 嘎嘎 哔哔
B. 哔哔 嘎嘎
C. 抛 TypeError:Robot 不是 Duck 的子类
D. 抛 AttributeError:speak 不接收参数
第 9 题:__str__ 与 __repr__ 的分工【应用】
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f'Point({self.x}, {self.y})'
def __str__(self):
return f'({self.x}, {self.y})'
p = Point(1, 2)
print(p)
print([p])
输出是什么?
A. 第一行 (1, 2);第二行 [Point(1, 2)]
B. 两行都是 (1, 2)
C. 第一行 Point(1, 2);第二行 [Point(1, 2)]
D. 第一行 (1, 2);第二行抛 TypeError
第 10 题:定义 __eq__ 之后的 hash 陷阱【分析】
class Item:
def __init__(self, name):
self.name = name
def __eq__(self, other):
return self.name == other.name
a = Item('苹果')
b = Item('苹果')
print(a == b)
print(set([a, b]))
输出是什么?
A. True,然后正常打印集合(两个对象都在里面)
B. True,然后抛 TypeError:unhashable type
C. False,然后正常打印集合
D. 第一行就抛 TypeError
点击查看答案
答案:A A A B B C B A A B
第 1 题(A):a.x = 2 只会在实例 a 上新建实例属性,类属性 x 依然是 1(第 1 篇的属性查找:先实例、后类)。所以 a 读到自己的 2,b 与 A 读到类的 1。选 C 的同学把实例属性和类属性当成共享的;选 D 忘了类属性对所有实例可见。
第 2 题(A):d.greet() 与 Dog.greet(d) 完全等价——实例方法就是”第一个参数绑定到实例的函数”。通过类名调用时 self 必须手动传入(第 3 篇)。所以 Dog.greet(d) 输出 旺财 在叫,不会报”缺参”。
第 3 题(A):Tool.how_many() 与 b.how_many() 都返回类属性 count。执行顺序:a、b 两次构造后 count=2,打印前两个 2;Tool() 又构造一次使 count=3,第三个 2 之前输出 3;version 是静态方法,不接收 self/cls,返回 '1.0'(第 3 篇)。三个 how_many() 分别是 2、2、3。
第 4 题(B):双下划线开头(且不以双下划线结尾)的属性名会被改写(name mangling)为 _A__secret,类外按原名访问必然 AttributeError(第 2 篇)。本机实测(直接运行该段代码)的错误信息是 AttributeError: 'A' object has no attribute '__secret'. Did you mean: '_A__secret'?——Python 3.12 连”改写后的名字”都提示给你了。注意这不是真正的私有:用 a._A__secret 仍可读写——改写是为了防止不小心撞名,不是安全机制。
第 5 题(B):只定义了 getter 的 property 是只读的。实测先打印 10,随后赋值抛 AttributeError: property 'value' of 'Box' object has no setter(第 4 篇)。选 A 的同学以为赋值会被吞掉——恰恰相反,Python 会明确报错,这正是”显式优于隐式”的体现。
第 6 题(C):D(B, C) 的 MRO(方法解析顺序)为 [D, B, C, A, object]:C3 线性化按”深度优先、从左到右”排列,B 里没有 who,接着找 C 找到 'C'(第 5 篇)。选 A 的同学跳过了中间子类;也可以在交互环境用 D.__mro__ 亲手查看顺序。
第 7 题(B):B 覆盖了 __init__ 却没有调用 super().__init__(),父类的初始化体根本不执行,self.x 从未创建,所以 hasattr(b, 'x') 是 False(第 5 篇)。覆盖构造方法时,“要不要调 super”取决于父类初始化是否必要——绝大多数业务场景都需要,忘调是经典 bug。
第 8 题(A):speak 只看对象有没有 voice() 方法,不检查继承关系——Duck 和 Robot 毫无血缘,都能通过接口协作,这就是鸭子类型(第 6 篇)。选 C 的同学把多态理解成了”必须继承”;Python 的接口是约定而非类型约束。
第 9 题(A):print(p) 走 __str__(面向人),输出 (1, 2);容器打印元素时走 __repr__(面向调试,要求尽可能无歧义、可还原对象),输出 [Point(1, 2)](第 7 篇)。不定义 __str__ 时它默认回落到 __repr__,两个方法最好都实现。
第 10 题(B):定义 __eq__ 后,Python 会把 __hash__ 置为 None(相等对象的哈希必须一致,而默认哈希按 id 给出,两者冲突)。实测 a == b 输出 True,随后 set([a, b]) 抛 TypeError: unhashable type: 'Item'(第 7 篇)。修复三选一:不定义 __eq__;或者同时定义 __hash__(必须保证相等对象哈希相同);或者用 __lt__ 等排序协议加 functools.total_ordering。凡是”要装进集合/字典键”的类,都绕不开这一个坑。
编程题(共 2 题,附可运行参考解答)
编程题 1:带 property 校验的体温计
题目:实现 Thermometer 类:摄氏温度 celsius 用 property 校验在人体合理范围 34.0–42.0 之间(含边界),超范围抛 ValueError;提供只读的华氏温度 fahrenheit(公式为摄氏度 × 9 / 5 + 32);实现 __repr__ 让调试输出可读。最后演示默认值、正常复测与两次非法赋值被拦截。
参考解答:
class Thermometer:
"""体温计:摄氏温度,property 校验人体合理范围 34.0–42.0。"""
def __init__(self, celsius=36.5):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if not 34.0 <= value <= 42.0:
raise ValueError(f'体温 {value} 超出人体合理范围(34.0–42.0)')
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
def __repr__(self):
return f'Thermometer({self._celsius})'
t = Thermometer()
print(f'默认体温:{t.celsius}°C(华氏 {t.fahrenheit:.1f}°F)')
t.celsius = 38.5
print(f'复测:{t.celsius}°C → 华氏 {t.fahrenheit:.1f}°F')
for bad in (33.9, 42.1):
try:
t.celsius = bad
except ValueError as e:
print('拦截:', e)
print('放进列表再打印:', [t])
运行结果:
默认体温:36.5°C(华氏 97.7°F)
复测:38.5°C → 华氏 101.3°F
拦截: 体温 33.9 超出人体合理范围(34.0–42.0)
拦截: 体温 42.1 超出人体合理范围(34.0–42.0)
放进列表再打印: [Thermometer(38.5)]
关键点:setter 用链式比较 34.0 <= value <= 42.0 一次完成双侧边界判断(第 4 篇);fahrenheit 只提供 getter,是”派生只读属性”的典型用法;__repr__ 返回可还原的表达式形式,所以 [t] 的打印结果是 Thermometer(38.5)(第 7 篇)。注意 __init__ 里 self.celsius = celsius 走的是 setter——构造阶段的非法值同样被拦截。
编程题 2:蔬菜与水果的多态价格表
题目:实现 Produce 基类(名称、单价分/斤、重量斤),total_price_fen() 计算小计(重量可为小数,四舍五入到分);Vegetable 继承但不打折;Fruit 继承并全场八折、展示时标注。写一个 price_table 函数逐行打印每个商品的小计并汇总——函数只调用 total_price_fen() 与 label() 两个接口,不得用 isinstance 分支。
参考解答:
class Produce:
"""农产品基类:单价单位是分/斤,重量单位是斤(可为小数)。"""
def __init__(self, name, price_fen, weight):
self.name = name
self.price_fen = price_fen
self.weight = weight
def total_price_fen(self):
return round(self.price_fen * self.weight)
def label(self):
return self.name
class Vegetable(Produce):
"""蔬菜:不参与折扣。"""
class Fruit(Produce):
"""水果:全场八折,重写计价与展示。"""
def total_price_fen(self):
return super().total_price_fen() * 8 // 10
def label(self):
return f'{self.name}(八折)'
def price_table(produce_list):
total = 0
for p in produce_list:
fen = p.total_price_fen()
total += fen
print(f'{p.label()}:{p.weight:g} 斤 × {p.price_fen / 100:.2f} 元/斤 = {fen / 100:.2f} 元')
print(f'合计:{total / 100:.2f} 元')
basket = [
Vegetable('大白菜', 180, 3.0),
Vegetable('西红柿', 450, 1.5),
Fruit('苹果', 700, 5.0),
Fruit('草莓', 2400, 0.6),
]
price_table(basket)
运行结果:
大白菜:3 斤 × 1.80 元/斤 = 5.40 元
西红柿:1.5 斤 × 4.50 元/斤 = 6.75 元
苹果(八折):5 斤 × 7.00 元/斤 = 28.00 元
草莓(八折):0.6 斤 × 24.00 元/斤 = 11.52 元
合计:51.67 元
关键点:price_table 不知道对象是蔬菜还是水果,只按接口调用——多态让”新增一种分类”不需要改函数(第 6 篇);Fruit 里 super().total_price_fen() 复用父类计价,再乘 0.8 并用整数运算 * 8 // 10 取整(第 5 篇的 super 调用;金额用分避免浮点尾差);round() 处理小数斤与整数分之间的换算。苹果原价 35.00 元打八折 28.00 元、草莓原价 14.40 元打八折 11.52 元,合计 51.67 元,全部来自实测。
综合任务:小型账户系统
任务描述:实现一个银行账户类 Account(金额单位:分):①走 property 语义的余额——余额只由存款、取款、转账三个方法改变,取款超余额抛自定义异常 InsufficientBalanceError(携带信息可读);②deposit/withdraw 对非正金额抛 ValueError;③transfer(other, amount) 由本账户扣款、对方入账,语义复用已有方法;④每笔操作(开户、存入、取出、转出、转入)追加到流水 _ledger;⑤statement() 打印流水报表。最后演示:开户、存款、转账、非法取款被拦截、双方报表。
考察点:__init__ 与类属性(第 1、2 篇)、property 守卫(第 4 篇)、自定义异常与精确捕获(第四章 + 第 4 篇)、方法重用于复用(第 3 篇)、报表循环(第六章知识联动)。
参考实现:
class InsufficientBalanceError(Exception):
"""余额不足时抛出,携带需要的信息由上层展示。"""
class Account:
"""小型账户:存款、取款、转账,每笔操作记录流水。金额单位:分。"""
def __init__(self, owner, initial=0):
if initial < 0:
raise ValueError('开户金额不能为负')
self.owner = owner
self._balance = initial
self._ledger = [('开户', initial, initial)]
@property
def balance(self):
return self._balance
def _record(self, action, amount, balance):
self._ledger.append((action, amount, balance))
def deposit(self, amount, note='存入'):
if amount <= 0:
raise ValueError('存款金额必须为正')
self._balance += amount
self._record(note, amount, self._balance)
def withdraw(self, amount, note='取出'):
if amount <= 0:
raise ValueError('取款金额必须为正')
if amount > self._balance:
raise InsufficientBalanceError(
f'{self.owner} 余额不足:需 {amount} 分,仅剩 {self._balance} 分')
self._balance -= amount
self._record(note, amount, self._balance)
def transfer(self, other, amount):
"""转账 = 先在自己账户扣款,再给对方入账。"""
self.withdraw(amount, f'转给 {other.owner}')
other.deposit(amount, f'来自 {self.owner}')
def statement(self):
"""打印账户流水报表。"""
print(f'账户「{self.owner}」余额:{self._balance / 100:.2f} 元')
print('动作 金额(元) 余额(元)')
for action, amount, balance in self._ledger:
print(f'{action:<10}{amount / 100:>11.2f}{balance / 100:>10.2f}')
alice = Account('林晓', 10000) # 开户 100 元
bob = Account('王远')
alice.deposit(2500) # 存 25 元
bob.deposit(5000) # 存 50 元
alice.transfer(bob, 3000) # 转 30 元给王远
try:
bob.withdraw(99999) # 余额不足,应该被拦截
except InsufficientBalanceError as e:
print('取款失败:', e)
print()
alice.statement()
print()
bob.statement()
运行结果:
取款失败: 王远 余额不足:需 99999 分,仅剩 8000 分
账户「林晓」余额:95.00 元
动作 金额(元) 余额(元)
开户 100.00 100.00
存入 25.00 125.00
转给 王远 30.00 95.00
账户「王远」余额:80.00 元
动作 金额(元) 余额(元)
开户 0.00 0.00
存入 50.00 50.00
来自 林晓 30.00 80.00
逐项核对:transfer 复用 withdraw 与 deposit,转账等于”扣我的 + 存你的”,双方流水各记一笔(转给/来自),金额与余额全部对账一致——林晓 10000 + 2500 − 3000 = 9500 分,王远 0 + 5000 + 3000 = 8000 分;InsufficientBalanceError 携带”需多少、剩多少”的信息,上层一条 except 精确捕获,不会吞掉 ValueError 之类的编程错误;balance 只读 property 防止外部直接改余额——账户余额只能经由业务方法变化,这正是本篇建模思想的缩影。整条实现没有引入任何新知识,全部是本章 1–8 篇的构件。
扩展思考:如果要在「转给」中同时记录对方的账户对象(而不仅是名字字符串),三元组流水需要变成什么结构?如果把账户余额改为支持”流水重放”(从开户逐笔重算余额),需要哪些方法配合?试试把 Withdrawal 之类的方法扩展为”取款后余额不得低于 0 且单笔上限 50000 分”,看哪些行会报错、为什么。
高频易错点速查
- 属性查找先实例后类:给实例赋同名属性会遮蔽类属性(第 1 题),类常量要通过类名访问。
- self 是绑定机制不是语法糖:类上调用方法必须手动传实例(第 2 题);忘写 self 的参数列表,调用时必报缺参 TypeError。
- 类方法要 cls、静态方法什么都不要:以类为参数的操作用
@classmethod,工具函数用@staticmethod(第 3 题)。 - 双下划线 = 名称改写,不是私有:
_A__secret依然可访问(第 4、10 题)。 - property 无 setter 就是只读:赋值会抛 AttributeError 而非静默忽略(第 5 题)。
- 覆盖
__init__记得 super:父类初始化不会自动执行(第 7 题)。 - 定义
__eq__必失__hash__:类装不进集合/字典键(第 10 题),三选一修复。 - 金额别用 float:用整数分或 Decimal(第 9 篇与第二章)。
- 多态靠接口不靠类型检查:
isinstance分支通常是设计信号(第 8 题)。
学习自检清单
完成本章学习后,请逐项确认你能独立做到:
- 说出类属性与实例属性的查找顺序,并解释”实例赋值遮蔽类属性”的行为(第 1 篇)。
- 解释
self的绑定机制,知道Dog.greet(d)与d.greet()等价、类调用必须显式传实例(第 3 篇)。 - 区分实例方法、
@classmethod、@staticmethod三种形态并说明各自适用场景(第 3 篇)。 - 说出名称改写的规则(
__x→_类名__x),并知道它不构成真正的私有保护(第 2 篇)。 - 用 property 实现读取拦截、写入校验与只读派生属性,解释”没有 setter 的赋值抛 AttributeError”(第 4 篇)。
- 画出多重继承的 MRO 并解释 C3 的”深度优先、从左到右”直觉,阐述覆盖
__init__时 super 的必要性(第 5 篇)。 - 用自然语言讲清鸭子类型与多态的关系,写出不依赖 isinstance 的通用接口函数(第 6 篇)。
- 实现
__str__、__repr__、__eq__并说明它们各自被谁调用;知道__eq__与__hash__的连带关系(第 7 篇)。 - 独立完成第 8 篇图书管理系统与第 9 篇订单系统的建模,说清每个类的职责边界(数据/计算/流程/策略)。
- 独立完成本篇账户系统综合任务,能讲清”余额只能经业务方法改变、失败必须抛可恢复信息、流水必须对账一致”这三条设计的理由。
如果某一项无法打勾,请回到对应篇章重新练习。第八章的核心思想是:用类把数据与规则绑在一起,用接口代替类型判断,用异常承载可恢复的失败——带着这份清单进入第九章命令行工具,你会发现 argparse 的参数校验、logging 的日志对象,处处都是”对象接管责任”的延续。