学生学完input、print、if之后,最想写的程序大概就是计算器。但很多第一版代码是这样的:
a=int(input("第一个数:"))op=input("运算符:")b=int(input("第二个数:"))ifop=='+':print(a+b)elifop=='/':print(a/b)``` 用户输入 `abc`,程序抛 `ValueError`;输入 `5/0`,程序抛 `ZeroDivisionError`。程序"崩了"这件事,正好是讲异常处理的入口。计算器这个题目小,却能自然覆盖 `ValueError`、`ZeroDivisionError`、`raise`、`try/except/else` 这几件事,比单纯背语法有用得多。## 完整代码```pythondefcalculate(a,op,b):"""根据运算符计算。遇到非法情况主动抛异常。"""ifop=='+':returna+bifop=='-':returna-bifop=='*':returna*bifop=='/':ifb==0:raiseZeroDivisionError("除数不能为 0")returna/braiseValueError(f"不支持的运算符:{op}")defparse_number(text):"""把字符串转成 float,失败时抛出统一的提示信息。"""try:returnfloat(text)exceptValueError:raiseValueError(f"“{text}”不是一个有效数字")fromNonedefformat_number(x):"""让 7.0 显示成 7,小数照常显示。"""ifisinstance(x,float)andx.is_integer():returnstr(int(x))returnstr(x)defmain():print("简易计算器,按“数字 运算符 数字”输入,例如 3 + 4;输入 q 退出")whileTrue:try:line=input("算式:").strip()ifline.lower()=='q':breakparts=line.split()iflen(parts)!=3:raiseValueError("请按“数字 运算符 数字”的格式输入,中间用空格隔开")a_text,op,b_text=parts a=parse_number(a_text)b=parse_number(b_text)result=calculate(a,op,b)exceptValueErroraserr:print(f"输入有误:{err}")exceptZeroDivisionErroraserr:print(f"计算错误:{err}")else:print(f"结果:{format_number(result)}")if__name__=="__main__":main()```## 代码要点拆解**1.`input()` 拿到的永远是字符串。**想把 `"3"` 变成数字,只有两条路:判断能不能转,或者直接转、转不动就接异常。用 `str.isdigit()` 判断是常见误区——它认不出 `-3`、`3.5`,也认不出 `3e-2`。用 `float()` 加 `exceptValueError` 才是通用做法。**2.`raise` 是"主动制造异常"。**除数为0时 Python 自己也会抛 `ZeroDivisionError`,这里的 `ifb==0` 是为了给出中文提示。真正体现设计意图的是 `else` 分支的那句 `raiseValueError(f"不支持的运算符:{op}")`:与其返回一个 `None` 让调用方去猜,不如当场把问题喊出来。函数的调用者只需要处理异常,不需要检查返回值是否合法。**3.`try` 里放的代码要尽量少。**主循环里 `try` 只包住"取输入 → 解析 → 计算"这三步,`print` 结果放到 `else` 里。如果顺手把 `print` 也塞进 `try`,一旦输出环节出错,报错信息会指向"输入有误",误导排查方向。`else` 子句的意义就在这:只在没出异常时执行,逻辑上比写在 `try` 末尾更清楚。**4.两个 `except` 的顺序无所谓,但类型要分对。**`ZeroDivisionError` 继承自 `ArithmeticError`,不是 `ValueError` 的子类,所以不会被第一个 `except` 抢走。分开写是为了打印不同的提示语——错误来源不同,给用户的反馈也该不同。**5.`fromNone` 的作用。**`parse_number` 里 `raise...fromNone` 会切断异常链,用户只看到"不是一个有效数字",而不是一长串英文的 `couldnotconvert string tofloat`。## 学生常见错误-**裸 `except:`。**它的范围大到能吞掉 `Ctrl+C` 产生的 `KeyboardInterrupt`,程序会变得按键也退不出。写 `exceptException:` 都比它好,最好精确到具体类型。--**异常捕获了,变量却没赋值。**在 `try` 中 `result=calculate(...)`,异常被捕获后跳过赋值,接着在循环里直接用 `result`,抛出的是 `NameError`。把输出放进 `else` 就绕开了这个坑。--**用 `except` 后继续循环,却忘了给用户重试的机会。**计算器这里直接回到 `while` 顶部重新读输入,是自然的做法;如果是 `parse_number` 这种独立函数,就需要自己写 `whileTrue` 重试。--**`try` 范围过大。**把整个 `main` 函数包进一个 `try`,任何一处出错都显示"输入有误",调试时很难定位。--**浮点比较的直觉错误。**`b==0` 对用户输入的 `0`、`0.0`、`-0.0` 都能判到,但如果是连续运算之后得到的近似0(比如 `0.1+0.2-0.3`),它就不等于0。这个知识点可以后面单独展开。## 可以留的练习1.增加 `**`(幂)运算,并处理"0 的负数次幂"这种非法情况,抛出你自己的提示。2.2.加一个 `%` 求余运算,先想清楚:除数为0时 Python 抛的是什么异常?3.3.把现在的"两数一算"改造成能连续输入 `2+3*4` 的形式,运算符优先级怎么处理?4.4.定义一个 `CalculatorError` 作为自定义异常,让所有错误都继承它,主循环只捕获这一个类型,错误信息由子类区分。5.5.为 `calculate` 和 `parse_number` 补几个 `assert` 或 `unittest` 用例,覆盖 `3/0`、`3%abc`、空输入三种情况。## 小结异常处理不是"给程序加一层保险",而是把"可能失败的地方"显式地写进代码结构里。一个函数用 `raise` 报告问题,调用方用 `try/except/else` 决定怎么应对,职责在这两端就分清了。计算器只有四个运算符、二十几行代码,却把这条链路完整地走了一遍。下次遇到读文件、解析 JSON、连数据库,思路是一样的:先想清楚哪里会失败,再想清楚失败了给谁看、看得懂吗。