AI 技术博客
返回首页
Python 基础 29 分钟阅读

阶段测验三:函数、OOP 与命令行(第 8-9 章)

Python基础阶段测验面向对象命令行

引言

阶段测验一检验了 1-3 章的基础语句,阶段测验二检验了 4-7 章的字符串、容器与文件,本篇是阶段测验三,聚焦第 8 章(面向对象入门)与第 9 章(综合项目:命令行工具)。第 8 章的关键词是「类、实例、继承、多态、封装、魔法方法」,第 9 章的关键词是「argparse、logging、退出码、测试」。需要说明一处衔接:装饰器是第四章首次正式讲解的语法,而 @property、@staticmethod 等 OOP 装饰器是第八章的日常武器,所以本测验保留一道「装饰器叠加顺序」题,它同时检验第四章的基础与第八章的运用。所有代码均在本机 CPython 3.12.10(Windows,Git Bash)实测,代码块全部自包含可运行。

两章的共同底层是「协议与约定」:类之间靠协议协作(可迭代协议、上下文协议、运算符协议),命令与被调方之间靠退出码与参数约定协作。看输出前先想协议,是本篇想训练的核心直觉。

测验说明:覆盖范围与难度分布

十道选择题按「记忆 → 理解 → 应用」梯度排列,每题标注出处:

  • 第 8 章(面向对象):7 题——装饰器叠加顺序(衔接第四章)、property 的 setter 校验、__mro__ 线性化、super().__init__ 的调用链、__len__/__getitem__ 序列协议、__add__ 运算符重载、未定义 __str__ 时 str 与 repr 的关系。
  • 第 9 章(命令行工具):3 题——logging 级别过滤、argparse 的 choices 与 default、RotatingFileHandler 的轮转行为。

难度分布:记忆 1 题、理解 5 题、应用 4 题。与第 8 章章末测验(综合测验:面向对象)刻意错开:那里考的是属性查找、self、名称改写等基础辨析,这里考的是「协议行为」——同一章知识的不同截面,避免重复刷题。建议每题限时两分半,错题务必回到出处篇目重读,再进入编程题。

选择题(共 10 题,每题只有一个正确答案)

第 1 题:装饰器的叠加顺序【理解】(出处:第 4 章「装饰器」衔接第 8 章「封装与 property」)

def double(func):
    def wrapper(word):
        return func(word) * 2
    return wrapper


def exclaim(func):
    def wrapper(word):
        return func(word) + '!'
    return wrapper


@double
@exclaim
def echo(word):
    return word


print(echo('ha'))

输出是什么?

A. haha! B. ha!ha! C. ha!! D. 抛 TypeError

第 2 题:property 的 setter 校验【应用】(出处:第 8 章「封装与 property」)

class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError('余额不能为负')
        self._balance = value


acc = Account(100)
acc.balance = 250
print(acc.balance)

输出是什么?

A. 100 B. 250 C. 抛 ValueError D. 抛 AttributeError(balance 只读)

第 3 题:MRO 线性化顺序【理解】(出处:第 8 章「继承」)

class A:
    pass


class B(A):
    pass


class C(A):
    pass


class D(B, C):
    pass


print([cls.__name__ for cls in D.__mro__])

输出是什么?

A. ['D', 'B', 'C', 'A', 'object'] B. ['D', 'C', 'B', 'A', 'object'] C. ['A', 'B', 'C', 'D', 'object'] D. ['D', 'B', 'A', 'C', 'object']

第 4 题:super().__init__ 的调用链【理解】(出处:第 8 章「继承」「init 与实例属性」)

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


class Dog(Animal):
    def __init__(self, name, age):
        super().__init__(name)
        self.age = age
        print('Dog.__init__')


d = Dog('旺财', 3)
print(d.name, d.age)

输出是什么?

A. 只打印 旺财 3 B. 依次打印 Dog.__init__、Animal.__init__、旺财 3 C. 依次打印 Animal.__init__、Dog.__init__、旺财 3 D. 抛 AttributeError:name 未定义

第 5 题:__len__ 与 __getitem__ 序列协议【应用】(出处:第 8 章「魔法方法」)

class Grades:
    def __init__(self, scores):
        self._scores = scores

    def __len__(self):
        return len(self._scores)

    def __getitem__(self, index):
        return self._scores[index]


g = Grades([88, 92, 76])
print(len(g))
print([x + 10 for x in g])

输出是什么?

A. 3 和 [98, 102, 86] B. 3 和 [88, 92, 76] C. 抛 TypeError:Grades 不可迭代 D. 3 和 [88, 92, 76, 10]

第 6 题:logging 的级别过滤【记忆】(出处:第 9 章「logging 模块」)

import logging

logging.basicConfig(level=logging.INFO)
logging.debug('调试信息')
logging.info('进度:3/10')
logging.warning('磁盘空间不足')

输出是什么?

A. 三行都会输出 B. 只输出 INFO:root:进度:3/10 C. 输出 INFO:root:进度:3/10 和 WARNING:root:磁盘空间不足 D. 什么都不输出

第 7 题:argparse 的 choices 与 default【理解】(出处:第 9 章「argparse 进阶」)

import argparse

parser = argparse.ArgumentParser()
parser.add_argument('--mode', choices=['fast', 'safe'], default='safe')
print(parser.parse_args(['--mode', 'fast']).mode)
print(parser.parse_args([]).mode)

运行 python q7.py,输出是什么?

A. fast 和 safe B. fast 和 fast C. safe 和 safe D. 抛 SystemExit(2):—mode 必填

第 8 题:RotatingFileHandler 的轮转结果【应用】(出处:第 9 章「logging 进阶」)

import logging
import os
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler('app.log', maxBytes=100, backupCount=2, encoding='utf-8')
logger = logging.getLogger('rotation')
logger.setLevel(logging.INFO)
logger.addHandler(handler)
for i in range(50):
    logger.info(f'条目 {i}:这是一条演示日志,用于触发轮转')

files = sorted(f for f in os.listdir('.') if f.startswith('app.log'))
print(files)

首次运行(当前目录没有 app.log 相关文件)后,输出是什么?

A. ['app.log'] B. ['app.log', 'app.log.1'] C. ['app.log', 'app.log.1', 'app.log.2'] D. ['app.log.1', 'app.log.2']

第 9 题:__add__ 运算符重载【应用】(出处:第 8 章「魔法方法」)

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __repr__(self):
        return f'¥{self.amount}'


print(Money(10) + Money(5))

输出是什么?

A. ¥10¥5 B. 15 C. ¥15 D. 抛 TypeError:Money 不支持 +

第 10 题:未定义 __str__ 时的 str 与 repr【理解】(出处:第 8 章「魔法方法」)

class Task:
    def __init__(self, title):
        self.title = title


t = Task('写周报')
print(str(t) == repr(t))

输出是什么?

A. True B. False C. 写周报 D. 抛 AttributeError

点击查看答案

答案:B B A C A C A C C A

第 1 题(B):装饰器从下往上叠加:@double @exclaim def echo 等价于 echo = double(exclaim(echo)),外层的 double 作用在已经加过叹号的 exclaim(echo) 结果上:echo('ha') 先被 exclaim 处理成 'ha!',再被 double 翻倍成 'ha!ha!'。顺序颠倒(@exclaim @double)则会得到 'haha!'——两个结果只差一个叹号,但对「先处理再放大」还是「先放大再处理」的语义完全不同。@property 之所以能工作,底层也是装饰器:balance = property(balance)。

第 2 题(B):@property 把 balance 变成「可读属性」,@balance.setter 注册写入时的守卫逻辑。acc.balance = 250 触发 setter,250 非负,被写入 _balance,随后读出 250。若改成 acc.balance = -5,setter 里的 raise ValueError('余额不能为负') 会如实抛出(本机实测报错文案为 ValueError: 余额不能为负)。注意「属性 vs 下划线私有」的分工:外部统一走 acc.balance,内部存 _balance,这就是封装的度——不放裸数据、不挡合法操作。

第 3 题(A):__mro__(Method Resolution Order,方法解析顺序)由 C3 线性化算法算出:子类在前,父类按声明顺序(class D(B, C) 中 B 在 C 前),全部父类完成后公共祖先 object 收尾,即 ['D', 'B', 'C', 'A', 'object']。菱形继承里 B、C 共享祖先 A,A 只出现一次且在 B、C 之后——重复祖先被合并到末尾,这是 C3 保证「每个类只出现一次、顺序单调」的结果。日常写代码不必背算法,但要知道属性查找沿这条链自上而下找第一个命中。

第 4 题(C):super().__init__(name) 显式调用父类构造,把 name 交给 Animal 初始化、age 由 Dog 自己初始化,执行顺序是先父后子:先打印 Animal.__init__,再打印 Dog.__init__,最后 d.name 为 旺财、d.age 为 3。对比第 8 章章末测验「忘记 super」的版本——那里子类不调用 super().__init__(),hasattr(b, 'x') 为 False;本题是「记得 super 且参数正确」时的正确形态。继承的初始化永远问一句:父类需要什么参数、我是否传了。

第 5 题(A):定义 __len__ 让 len(g) 工作,定义 __getitem__ 让下标访问工作;更重要的是,迭代(for、列表推导式)在缺少 __iter__ 时会退化为「反复用 __getitem__ 从 0 开始取,取到 IndexError 为止」——这是迭代协议的内置回退机制。所以 [x + 10 for x in g] 逐元素取出 88、92、76 并各自加 10。你自己写的类只要实现这两个方法,就免费获得 len()、下标、切片、迭代、成员测试全套序列能力,这就是第 8 章魔法方法篇「协议即接口」的实证。

第 6 题(C):basicConfig(level=logging.INFO) 把 root logger 的放行门槛设为 INFO:DEBUG(10)低于门槛被过滤,INFO(20)与 WARNING(30)通过。注意输出格式是默认的 级别:日志器名:消息,即 INFO:root:进度:3/10 与 WARNING:root:磁盘空间不足 两行。五个级别从小到大是 DEBUG < INFO < WARNING < ERROR < CRITICAL,门槛表示「低于它的全部丢弃」——想临时查看 DEBUG 细节,把 level 改成 DEBUG 即可,一行改动,不用删语句。

第 7 题(A):choices=['fast', 'safe'] 限定可选值,default='safe' 给出省略时的默认值,两者独立不冲突:显式传 --mode fast 得到 fast,什么都不传得到默认 safe。若传入 --mode turbo,argparse 会向 stderr 打印错误并以退出码 2 结束(参数错误约定 2)——choices 把「非法值」挡在程序逻辑之外,是声明式校验,比在代码里手写 if 判断更早生效。

第 8 题(C):RotatingFileHandler('app.log', maxBytes=100, backupCount=2) 规定:文件写满 100 字节时轮转——app.log 改名 app.log.1,原 app.log.1 改名 app.log.2,更旧的 app.log.2 被丢弃,然后新日志写进新的 app.log。50 条日志远超 100 字节,轮转多次后进入稳态:正好三个文件 ['app.log', 'app.log.1', 'app.log.2'],backupCount=2 意味着「永远只保留当前文件加 2 份历史」。本机连续运行两次,输出完全一致(轮转是幂等的),这也说明轮转方案适合长期运行的常驻进程——日志总量被锁死在 maxBytes × (backupCount+1),不会无限膨胀。

第 9 题(C):+ 运算符会被解释为调用 __add__:Money(10) + Money(5) 等价于 Money(10).__add__(Money(5)),返回一个新的 Money(15);打印该对象时没有 __str__,print 回退用 __repr__,恰好这里定义了 __repr__ 返回 ¥15。两个细节:__add__ 返回新对象而不是修改 self(不可变风格),以及「print 优先 __str__、缺省回退 __repr__」的优先级规则——这是 str 与 repr 分工的完整闭环。

第 10 题(A):Task 既没定义 __str__ 也没定义 __repr__,两者都回退到 object 的默认实现(<__main__.Task object at 0x...> 形式的地址字符串),因此 str(t) == repr(t) 为 True。这条「默认一致」是理解 str/repr 分工的起点:给类定义 __repr__(给人看,信息尽量完整、可重建对象)后,str 会继续回退到它;再定义 __str__ 则两者分道扬镳(print 用 __str__、容器显示用 __repr__)。判断一个类「有没有自定义人设」,先看 str(x) is not repr(x) 是否成立。

编程题(共 2 题,附可运行参考解答)

编程题 1:带 property 校验的银行账户类 + 单元测试

题目:实现 BankAccount 类:__init__(owner, balance=0.0);balance 用 property 管理,setter 拒绝负数(抛 ValueError);deposit 与 withdraw 校验金额必须为正;取款超过余额抛自定义异常 InsufficientFundsError 且余额不变;__str__ 返回「某人 的账户余额:¥xx.xx」。再用 unittest 写四个用例:初始余额、负数拒绝、存取款流程、超额取款后余额不变。要求测试文件独立运行并全部通过。

参考解答(bank_account.py):

"""带 property 校验的银行账户类。"""


class InsufficientFundsError(Exception):
    """余额不足时抛出。"""


class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance        # 赋值走 setter 校验

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError('余额不能为负')
        self._balance = value

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError('存款金额必须为正数')
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError('取款金额必须为正数')
        if amount > self.balance:
            raise InsufficientFundsError(
                f'余额不足:可用 {self.balance},想取 {amount}')
        self.balance -= amount

    def __str__(self):
        return f'{self.owner} 的账户余额:¥{self.balance:.2f}'


if __name__ == '__main__':
    acc = BankAccount('小明', 100)
    acc.deposit(50)
    acc.withdraw(30)
    print(acc)

关键设计:self.balance = balance 写在 __init__ 里,赋值走的正是 setter,所以负数在构造阶段就被拦下——校验只写一处,所有入口共享,这就是 property 相对普通方法的收益。测试文件 test_bank_account.py:

"""BankAccount 的单元测试。"""
import unittest

from bank_account import BankAccount, InsufficientFundsError


class TestBankAccount(unittest.TestCase):
    def test_initial_balance(self):
        acc = BankAccount('小明', 100)
        self.assertEqual(acc.balance, 100)

    def test_property_rejects_negative(self):
        with self.assertRaises(ValueError):
            BankAccount('小明', -5)

    def test_deposit_and_withdraw(self):
        acc = BankAccount('小明', 100)
        acc.deposit(50)
        acc.withdraw(30)
        self.assertEqual(acc.balance, 120)

    def test_withdraw_insufficient_keeps_balance(self):
        acc = BankAccount('小明', 10)
        with self.assertRaises(InsufficientFundsError):
            acc.withdraw(20)
        self.assertEqual(acc.balance, 10)


if __name__ == '__main__':
    unittest.main(verbosity=2)

注意最后一个用例的写法:先用 assertRaises 捕获异常,再断言「余额未被改动」——失败路径的副作用为零,比只看「抛没抛」更严格。本机实测:

$ python bank_account.py
小明 的账户余额:¥120.00
$ python -m unittest test_bank_account -v
test_deposit_and_withdraw (test_bank_account.TestBankAccount.test_deposit_and_withdraw) ... ok
test_initial_balance (test_bank_account.TestBankAccount.test_initial_balance) ... ok
test_property_rejects_negative (test_bank_account.TestBankAccount.test_property_rejects_negative) ... ok
test_withdraw_insufficient_keeps_balance (test_bank_account.TestBankAccount.test_withdraw_insufficient_keeps_balance) ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK

编程题 2:calc 命令行计算器

题目:用 argparse 的子命令做一个整数计算器 calc.py:add、sub、mul 直接计算,div 在除数为 0 时向 stderr 打印错误并以退出码 1 结束;其余错误(缺子命令、缺参数、非法整数)交给 argparse 以退出码 2 结束;成功时以「a 命令 b = 结果」打印并返回 0。要求用 main(argv=None) 骨架与 sys.exit(main()) 收尾。

参考解答:

"""calc:带子命令的整数计算器。"""
import argparse
import sys


def add(a, b):
    return a + b


def sub(a, b):
    return a - b


def mul(a, b):
    return a * b


def div(a, b):
    if b == 0:
        raise ZeroDivisionError('除数不能为 0')
    return a / b


OPS = {'add': add, 'sub': sub, 'mul': mul, 'div': div}


def main(argv=None):
    parser = argparse.ArgumentParser(prog='calc', description='整数四则运算计算器')
    sub_parsers = parser.add_subparsers(dest='command', required=True)
    for name in OPS:
        p = sub_parsers.add_parser(name)
        p.add_argument('a', type=int)
        p.add_argument('b', type=int)
    args = parser.parse_args(argv)
    try:
        result = OPS[args.command](args.a, args.b)
    except ZeroDivisionError as exc:
        print(f'错误:{exc}', file=sys.stderr)
        return 1
    print(f'{args.a} {args.command} {args.b} = {result}')
    return 0


if __name__ == '__main__':
    sys.exit(main())

本机实测(exit=0 表示命令后的退出码):

$ python calc.py add 5 3
5 add 3 = 8
exit=0
$ python calc.py sub 7 9
7 sub 9 = -2
exit=0
$ python calc.py div 10 4
10 div 4 = 2.5
exit=0
$ python calc.py div 10 0
错误:除数不能为 0
exit=1
$ python calc.py
usage: calc [-h] {add,sub,mul,div} ...
calc: error: the following arguments are required: command
exit=2

关键点:子命令表用字典 OPS 驱动循环注册,新增一种运算只改字典不改流程(第 8 章「接口统一」思想在 CLI 层的体现);业务错误(除零)自己捕获并以退出码 1 表达,参数错误由 argparse 统一处理为退出码 2——两类错误码分工清楚,脚本可以据此写自动化调度;div 返回浮点(10 / 4 恒为 float),要整数结果可改用 //。把 0、1、2 三档退出码的含义背下来,这是 Unix 工具约定的最小核心。

小结

本篇检验了第 8-9 章:装饰器从下往上叠加、property 把校验收进赋值通道、__mro__ 与 super() 决定初始化与查找顺序、序列协议与运算符重载让类获得「内建行为」、logging 用门槛过滤信息、argparse 用声明式规则拦截非法输入、RotatingFileHandler 锁死日志体积。两个编程题把「校验」与「错误表达」付之实践:类的边界用 property 与异常管住,命令的边界用退出码管住。前九章至此收官,从下一篇开始进入第十章的后半程:常见坑总述、代码风格审查、性能初体验、调试训练场与里程碑项目。

练习与自检清单

  1. 改造练习:给 BankAccount 增加 __sub__(acc - 20 等价取款 20)并写一条用例,注意 __sub__ 同样要抛 InsufficientFundsError——答案导向:__sub__ 内部直接调用 self.withdraw(other) 复用校验,不必重复写逻辑。
  2. 反向练习:把 calc 的 div 改成「除零时返回 None 而不是抛异常」,运行 python calc.py div 10 0 观察输出与退出码变化,解释为什么「异常方案」比「返回 None 方案」更容易发现调用方漏判(答案导向:None 会静默打印错误结果并以 0 结束,脚本无法察觉)。
  3. 协议练习:给 Grades 加 __iter__,对比「有 __iter__」与「只有 __getitem__」时 iter(g) 返回的对象类型差异(答案导向:前者返回 __iter__ 的返回值,后者返回一个自动生成的序列迭代器)。
  4. 自查清单:能默写装饰器叠加的展开式;能解释 property setter 在 __init__ 赋值时同样生效;能背出 D(B, C) 的 MRO 前缀并解释 object 为何只出现一次;能说出 str/repr 的回退链;能区分 argparse 退出码 2 与业务退出码 1;能说明 RotatingFileHandler 的 maxBytes 与 backupCount 如何共同决定磁盘占用的上界。全部打勾后,进入第十章第 4 篇「常见坑总结:全书九章的 25 个经典陷阱」。