
程序设计入门:从需求分析到代码实现的完整流程
编写程序并不只是把想法翻译成代码。一个看起来简单的功能,往往还涉及输入格式、异常情况、结果精度、使用方式等问题。若这些问题没有提前想清楚,程序可能“能运行”,却在真实输入下给出错误结果,或让使用者无法判断该怎么操作。
本文以一个命令行费用分摊工具为例,演示从需求分析到代码实现、测试和改进的基本过程。程序接收消费金额、小费比例和人数,计算含小费的总金额,并将金额按分分配给每个人。示例使用 Python 3,不依赖第三方库,适合初学者练习。它用于说明程序设计方法,不是税务、会计或支付系统。
一、先确认问题,而不是马上写代码
需求通常以自然语言出现,例如:“聚餐结束后,算一下每个人要付多少钱。”这句话表达了目标,却没有说明金额是否含小费、人数如何输入、遇到无法整除的情况怎么办。直接动手编码,往往会把这些未说明的假设偷偷写进程序。
开始设计前,可以先回答四个问题:
- 谁会使用它? 本例面向需要快速分摊账单的个人用户,运行环境是终端。
- 输入是什么? 消费金额、小费比例、分摊人数。
- 输出是什么? 含小费的总金额,以及每个人应承担的金额。
- 什么情况不接受? 金额不是有效数字、金额为负数、人数不是正整数、小费比例超出约定范围。
这里的“约定范围”必须由需求定义,而不是从技术上凭空得出。本例规定小费比例为 0% 到 100%,包括两端;金额不能为负,允许为 0;人数必须至少为 1。这些限制适用于本文示例,并不是所有地区、行业或费用分摊场景的通用规则。
二、把需求写成可验证的规则
一条好的需求能够通过输入和输出验证。我们将本例整理成下面的规则:
- 程序依次读取金额、小费比例和人数。
- 金额必须是非负的十进制数,最多保留两位小数;不接受科学计数法、逗号分隔符或货币符号。
- 小费比例必须是 0 到 100 之间的十进制数,最多保留两位小数。
- 人数必须是大于或等于 1 的整数,不接受小数。
- 先对“消费金额加小费”进行四舍五入,保留到分。
- 总金额换算成整数分,再尽量平均分配。无法平均分配时,余下的分依次分给前面的人,确保各人金额之和等于总金额。
- 无效输入要给出说明,不应打印错误的计算结果。
“先算总额再分配”是一个重要的业务选择。比如总额为 10.00 元、分给 3 人时,程序会分配为 3.34 元、3.33 元、3.33 元,总和仍是 10.00 元。若改成先计算每人份额、各自四舍五入,有时各人金额之和会与总额相差一两分钱。
规则写清楚后,也要明确不做什么。本例不处理不同货币的汇率与最小单位,不支持多人承担比例不同,不支持读取账单文件,也不涉及收款、转账或身份验证。范围越清楚,越容易判断当前实现是否足够。
三、设计处理流程
把程序想成一条从输入到输出的流水线,通常更容易实现,也更便于检查:
- 读取用户输入。
- 检查输入格式与范围。
- 把金额和比例转换为可精确处理的数值。
- 计算含小费总额,并按分进行舍入。
- 将总额换算为整数分,平均分配余数。
- 格式化并显示结果。
可以先用伪代码表达核心计算:
输入 amount、tip_percent、people
验证 amount、tip_percent、people
total = 按规则舍入(amount × (1 + tip_percent / 100))
total_cents = total 换算为分
每人基础分 = total_cents 整除 people
剩余分 = total_cents 除以 people 的余数
按顺序为前 remaining 人多分 1 分
输出总额和每个人的金额
伪代码不关心 Python 的语法细节,重点是让逻辑在编码之前变得可见。如果此时发现规则有歧义,修改伪代码通常比修改一段已经交织在一起的程序容易。
四、选择适合问题的数据类型
处理金额时,初学者常用浮点数,也就是 Python 的 float。浮点数适合很多近似计算,但十进制小数通常不能用二进制浮点数精确表示。例如某些计算可能得到接近 0.3、但并不严格等于 0.3 的结果。若金额经过多步计算,直接依赖浮点数可能导致意外的尾数或舍入差异。
本例使用标准库 decimal.Decimal 进行十进制运算,并使用 ROUND_HALF_UP 明确规定舍入方式。需要注意,任何舍入规则都是业务规则的一部分;若系统必须遵循特定地区的法律、合同或会计政策,应按相应要求确认舍入方法,而不能照搬示例。
另一个选择是用整数保存“分”。这是许多金额系统的常见思路:将 12.34 元保存为 1234 分,以整数进行加减和分配。但涉及比例计算时,仍须规定如何舍入。因此本文先用 Decimal 计算总额,再转成整数分进行分配。
五、实现可运行的 Python 程序
将下面代码保存为 split_bill.py,使用 Python 3 运行。程序只使用 Python 标准库,不需要安装额外依赖。
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
CENT = Decimal("0.01")
MAX_INPUT_DECIMAL_PLACES = 2
def parse_decimal(text: str, field_name: str) -> Decimal:
"""解析最多两位小数的十进制输入。"""
value_text = text.strip()
if not value_text:
raise ValueError(f"{field_name}不能为空。")
try:
value = Decimal(value_text)
except InvalidOperation as exc:
raise ValueError(f"{field_name}必须是有效数字。") from exc
if not value.is_finite():
raise ValueError(f"{field_name}必须是有限数值。")
decimal_places = max(0, -value.as_tuple().exponent)
if decimal_places > MAX_INPUT_DECIMAL_PLACES:
raise ValueError(
f"{field_name}最多允许 {MAX_INPUT_DECIMAL_PLACES} 位小数。"
)
return value
def parse_people(text: str) -> int:
"""解析正整数人数。"""
value_text = text.strip()
if not value_text:
raise ValueError("人数不能为空。")
try:
people = int(value_text)
except ValueError as exc:
raise ValueError("人数必须是整数。") from exc
if people < 1:
raise ValueError("人数必须大于或等于 1。")
return people
def calculate_shares(
amount: Decimal,
tip_percent: Decimal,
people: int,
) -> tuple[Decimal, list[Decimal]]:
"""计算含小费总额,并将总额按分平均分配。"""
if not amount.is_finite() or amount < 0:
raise ValueError("金额必须是非负的有限数值。")
if not tip_percent.is_finite() or not (
Decimal("0") <= tip_percent <= Decimal("100")
):
raise ValueError("小费比例必须在 0 到 100 之间。")
if people < 1:
raise ValueError("人数必须大于或等于 1。")
total = (amount * (Decimal("1") + tip_percent / Decimal("100"))).quantize(
CENT,
rounding=ROUND_HALF_UP,
)
total_cents = int(total * 100)
base_cents, remainder = divmod(total_cents, people)
shares = [
Decimal(base_cents + (1 if index < remainder else 0)) / Decimal("100")
for index in range(people)
]
return total, shares
def main() -> None:
try:
amount = parse_decimal(input("消费金额:"), "消费金额")
tip_percent = parse_decimal(input("小费比例(0-100):"), "小费比例")
people = parse_people(input("分摊人数:"))
total, shares = calculate_shares(amount, tip_percent, people)
except ValueError as exc:
print(f"输入错误:{exc}")
return
print(f"含小费总额:{total:.2f}")
print("每人应付:")
for index, share in enumerate(shares, start=1):
print(f" 第 {index} 人:{share:.2f}")
if __name__ == "__main__":
main()
代码里的 tuple[Decimal, list[Decimal]] 是 Python 3.9 及更高版本支持的类型注解写法。若运行环境较旧,可移除该返回类型注解,或使用 typing.Tuple 和 typing.List。程序本身不需要类型注解才能执行。
六、理解代码的几个关键点
1. 把输入解析和业务计算分开
parse_decimal 和 parse_people 负责把文本转换为程序需要的数据,并检查格式;calculate_shares 负责业务计算;main 负责终端交互和展示。这样的分工让计算函数可以独立测试,也避免把输入提示、校验、计算和输出全部塞进一个长函数。
函数不仅是为了让代码看起来整齐,更是为了明确责任。例如需要改成人数比例分摊时,可以重点修改计算逻辑;需要换成网页表单时,则可以复用计算函数,而替换终端输入输出部分。
2. 显式检查非有限数字
Decimal 可以表示 NaN 和无穷大等特殊值,不能只用“能成功转换”来判断输入有效。is_finite() 会排除这些值。对于金额和比例,程序还进一步检查业务范围,格式正确不代表业务上允许。
本例对金额允许零,但不允许负数;对小费比例允许 0 和 100。这些检查分别出现在解析流程和计算函数中。计算函数保留检查,是为了避免它被其他代码调用时绕过输入校验。真实项目中,应根据实际调用边界决定如何组织校验,但不能假设所有调用者都会先经过命令行解析。
3. 限制输入的小数位数
代码只允许输入最多两位小数。因此 12、12.3 和 12.34 可接受,12.345 会被拒绝。该限制是示例的输入政策,不是 Decimal 的技术上限。若业务需要允许更精细的金额,应该同时调整输入限制、单位换算、舍入规则和测试,不能只放开一处。
示例不接受 1,000.00、¥10、空格分隔的格式,也不支持科学计数法。这是为了让输入规则简单且明确。若要支持这些格式,应先定义地区格式、货币符号和逗号的含义,再实现可靠的解析,避免把格式错误的文本误读成另一个数值。
4. 用余数保证分配结果相加正确
divmod(total_cents, people) 同时得到每人基础分和余数。例如 1000 分分给 3 人,结果是基础 333 分、余数 1 分。程序给第一人 334 分,其他人各 333 分。这个规则具有确定性,但也意味着列表顺序中的前几人可能多付一分。若产品要求随机分配、轮换分配,或由用户选择承担余数的人,应另行设计并明确规则。
代码先用 int(total * 100) 将已经量化到两位小数的总额转换为分,再对整数分进行分配。不要把“按每人金额单独四舍五入”误认为必然能保持总和一致。
七、运行程序并观察结果
在终端中进入保存文件的目录,运行:
python split_bill.py
某些系统中命令名称是 python3,可以相应运行:
python3 split_bill.py
输入消费金额 10.00、小费比例 0、人数 3,预期输出如下:
消费金额:10.00
小费比例(0-100):0
分摊人数:3
含小费总额:10.00
每人应付:
第 1 人:3.34
第 2 人:3.33
第 3 人:3.33
再试试以下情况,确认程序行为符合需求:
- 金额输入
-1:提示金额不能为负数。 - 比例输入
100.01:提示比例超出范围。 - 人数输入
2.5:提示人数必须是整数。 - 金额输入
abc:提示金额必须是有效数字。 - 金额输入
0、比例输入0、人数输入3:总额和各人金额均为零。
八、测试不仅是“跑一下看看”
手动试运行有价值,但不够全面。测试应覆盖正常情况、边界情况和错误输入,尤其是会改变程序分支的情况。对于本例,至少要测试:刚好整除、不能整除、零金额、比例范围的上下界、非法小数位、非正人数,以及分配结果总和是否等于总额。
下面是一个可独立运行的测试文件。保存为 test_split_bill.py,并确保它与 split_bill.py 在同一目录:
import unittest
from decimal import Decimal
from split_bill import calculate_shares, parse_decimal, parse_people
class SplitBillTests(unittest.TestCase):
def test_remainder_is_distributed_and_sum_is_preserved(self):
total, shares = calculate_shares(
Decimal("10.00"),
Decimal("0"),
3,
)
self.assertEqual(total, Decimal("10.00"))
self.assertEqual(
shares,
[Decimal("3.34"), Decimal("3.33"), Decimal("3.33")],
)
self.assertEqual(sum(shares), total)
def test_tip_is_included_before_splitting(self):
total, shares = calculate_shares(
Decimal("100.00"),
Decimal("10"),
2,
)
self.assertEqual(total, Decimal("110.00"))
self.assertEqual(shares, [Decimal("55"), Decimal("55")])
def test_zero_amount_is_allowed(self):
total, shares = calculate_shares(
Decimal("0"),
Decimal("0"),
2,
)
self.assertEqual(total, Decimal("0.00"))
self.assertEqual(shares, [Decimal("0"), Decimal("0")])
def test_negative_amount_is_rejected(self):
with self.assertRaises(ValueError):
calculate_shares(Decimal("-1"), Decimal("0"), 1)
def test_tip_above_limit_is_rejected(self):
with self.assertRaises(ValueError):
calculate_shares(Decimal("10"), Decimal("100.01"), 1)
def test_too_many_decimal_places_are_rejected(self):
with self.assertRaises(ValueError):
parse_decimal("1.234", "金额")
def test_non_integer_people_are_rejected(self):
with self.assertRaises(ValueError):
parse_people("2.5")
if __name__ == "__main__":
unittest.main()
运行测试:
python -m unittest -v
测试使用的 assertEqual 不只检查程序有没有抛出异常,也检查了结果的具体内容,以及分配后的金额之和是否等于总额。对金额分配程序来说,“总额守恒”是比单独检查某个人应付多少更关键的性质之一。
九、常见错误与修正思路
错误一:未经确认就把输入当作正确
如果直接调用 float(input(...)),用户输入空字符串、字母或不支持的格式时,程序会抛出异常。捕获并处理预期内的输入错误,让用户得到明确反馈;不要用宽泛的 except Exception 把所有问题都吞掉,否则代码缺陷也可能被伪装成普通输入错误。
错误二:金额使用浮点数并随意格式化
round()、格式化小数位数和业务舍入并不是同一件事。格式化只能改变显示形式,不能自动证明计算过程符合目标规则。涉及金额时,应明确采用的数值表示、计算顺序和舍入方式,再针对规则编写测试。
错误三:只检查界面输入,不检查核心函数
如果计算函数被其他程序导入调用,命令行解析处的限制可能完全不起作用。核心业务函数保留关键参数检查,能够减少调用者误用时产生的问题。不过,检查不能替代接口设计,也不能保证函数在所有环境中都安全。
错误四:把实现细节当成需求
例如“余下的一分给第一个人”是一个具体实现规则,不一定符合所有使用者的预期。在需求阶段应明确谁承担余数、能否选择人员、是否需要公平轮换。没有说明时,即使程序算术正确,也可能在业务上不合适。
错误五:只测典型输入
只测试 100 元、两个人这样的整除情况,无法发现余数分配错误。测试至少要包括边界值和异常值。对输入范围为 0 到 100 的比例,应测试 0、100、低于 0 和高于 100;对人数,应测试 1、0、负数和非整数。
错误六:过早追求抽象
初学者有时会在一个小程序里加入配置框架、插件、复杂类结构和多层继承。抽象应该回应已知变化,而不是为了显得“工程化”。本例用几个职责清晰的函数即可;当程序增加货币、多人不同权重或文件输入时,再根据实际需要提取新的模块。
十、参数限制与安全边界
本例的参数限制包括:金额非负且最多两位小数,小费比例在 0 到 100 之间且最多两位小数,人数为正整数。代码没有规定金额或人数的业务上限。因此,极端大的输入仍可能产生不符合实际需要的结果或消耗过多资源。若程序面向真实用户,应根据业务容量设置合理上限,并验证输入长度,尤其是处理外部传入数据时。
输入校验是数据质量控制,不等于安全认证,也不等于支付安全。示例只读取终端文本,不执行用户输入,也不访问文件、网络或数据库;它没有用户账户、权限控制、加密、审计日志或防欺诈机制。因此不能把它用于存储敏感支付信息、发起扣款或作为生产结算系统。
即使未来把输入改为网页或 API,客户端限制也不能代替服务端校验。服务端应重新验证类型、范围和业务约束;如需保存或处理敏感数据,还必须按具体产品和适用规则设计权限、加密、留存和审计措施。不能仅因某段输入“看起来是数字”,就认为它适合进入财务流程。
此外,人数没有业务上限,意味着用户输入很大的数量时程序会创建相应数量的结果元素,占用内存并生成大量输出。生产系统应依据使用场景设置人数上限,并测试上限处的行为。这个限制不是 Python 自动替开发者做出的,而是需求和运行环境共同决定的。
十一、从示例迈向更完整的程序
当需求扩大时,可以按变化逐步改进,而不是一次性增加所有功能:
- 支持多种货币:明确币种、最小单位、显示符号和舍入规则。不能默认所有货币都以两位小数表示。
- 支持不同比例分摊:定义比例输入方式、总和是否必须为 100%,以及无法分到整分时如何处理。
- 支持历史记录:先说明保存内容、保存位置、保留时间和访问权限,再决定文件或数据库格式。
- 提供图形界面或网页界面:将计算逻辑与界面分离,复用业务函数,并在服务端重复验证输入。
- 适配正式结算:确认实际业务规则、审计要求和适用政策,通过领域专家审查,并为并发、权限和数据完整性设计专门机制。
每次扩展都可能改变边界条件。例如支持其他货币后,输入小数位数就不能继续固定为两位;支持比例分摊后,余数分配规则也会不同。因此要同步更新需求、代码和测试,而不是只补一个新按钮或新参数。
十二、可复用的程序设计流程
费用分摊只是练习案例,背后的方法可以用于许多小型程序。遇到一个新问题时,可以按以下顺序推进:
- 描述目标:程序要为谁解决什么问题?使用环境是什么?
- 列出输入和输出:输入类型、单位、格式和预期结果是什么?
- 定义边界:哪些值允许?空值、极端值、非法格式如何处理?
- 选定数据表示:根据问题选择整数、十进制数、日期或其他类型,避免未经考虑地使用不合适的数据类型。
- 拆解步骤:先用列表或伪代码表达处理流程,再写具体语法。
- 划分职责:把解析、验证、计算和输出分开,让重要逻辑可以独立复用和测试。
- 实现最小版本:先满足已确认的核心需求,不要提前加入与目标无关的复杂功能。
- 测试正常、边界和异常情况:验证结果,也验证程序拒绝不合法输入时的行为。
- 复查安全与运行限制:确认程序是否接触敏感数据、外部输入、网络、文件或资源上限。
- 根据反馈迭代:将新发现的规则写回需求和测试,避免让关键约定只存在于代码作者的记忆里。
一个可靠的小程序,不是代码行数越多越好,而是目标明确、边界说得清楚、重要规则能够被验证。对于初学者而言,先学会把需求拆成可检查的输入、处理规则和输出,再练习用小函数实现并测试,通常比一开始追求复杂架构更有效。掌握这条流程后,无论编写费用工具、文本处理脚本还是简单的数据转换程序,都能少一些猜测,多一些可解释、可复现的结果。









