写简单案例其实比写复杂项目更难因为一个“小”案例要把核心知识点串起来还不能显得枯燥。打印购物小票这个案例我用了很久教过不少朋友可以说它麻雀虽小五脏俱全变量、数据类型、循环、格式化输出、函数封装全都能带出来甚至还能延伸到文件操作和真实打印机对接。这个案例适合刚学完Python基础语法的人也适合想找一个完整小项目练手的学生以及对“写出来的程序到底能干啥”有好奇心的初学者。我之前在几个技术社区分享过这个案例的代码收到最多的反馈是原来Python可以做这么贴近生活的东西而且代码量真的不大理解了之后还能自己改着玩。这篇文章就把完整的拆解思路、核心代码、实操过程、问题排查全都写出来你照着敲一遍再自己改几个功能基本上就把Python的基础用法吃透了一大半。1. 案例拆解与整体设计思路1.1 购物小票打印的“需求本质”是什么购物小票表面上就是几行商品名称、单价、数量、金额再加一个合计但实际上它包含了一个完整程序必须具备的三个环节数据的组织、业务的计算、结果的呈现。数据组织是指怎么把商品信息存下来。最简单的方式是列表里装元组每个元组表示一条商品记录比如(“苹果”, 8.5, 2)分别是名称、单价、数量。稍微复杂一点可以用字典把名称、单价、数量、折扣都放进去。这一步对应真实项目里的“数据结构设计”决定后续代码好不好写。业务计算是指金额怎么算。单件商品的小计是单价乘数量多个商品要累加总价如果设置了折扣或满减还要在适当位置扣减。真实收银系统还会涉及抹零、四舍五入到分、找零计算这一步对应的是“业务逻辑”也是面试和考试爱考的部分。结果呈现是指怎么把数据变成人能看懂的内容。对这个小案例来说就是控制台里输出一张对齐的小票。更进阶一点是写入文本文件、生成PDF甚至直接发送给热敏打印机。在真实项目中这一步关系到用户最终看到的东西是什么样是体验的最后一公里也最容易出细节问题。1.2 为什么选择“购物小票”作为练手项目选这个题目有几个很现实的原因。首先它足够小核心代码去掉注释和空行不到一百行就能写出干净整洁的版本不会让人觉得“学了一堆语法却无处施展”。其次它足够完整从输入数据到计算再到输出是一条完整的链路学完以后能清楚知道一个程序是怎么从零到一跑起来的。还有一个重要原因是这个场景大家都见过。超市小票、奶茶店小票、外卖单几乎每天都会接触所以程序输出的内容跟生活中真实的东西对上号时理解成本直接降为零。学编程最怕的就是“代码和现实世界没有联系”这个案例恰好把联系拉得很近。1.3 方案选型基础版到进阶版的阶梯式写法这个案例我建议分三个层次来做不同基础的人可以停在适合自己的位置。第一层控制台纯文本输出。只用Python内置的print和字符串格式化做一张包含店名、商品明细、合计金额的小票。这一层覆盖的知识点包括变量、for循环、f-string、数字格式化没有任何第三方库是标准的基础练手方式。第二层函数封装 文件保存。把打印小票的逻辑封装成函数传入商品列表就能输出。同时在控制台输出的基础上把文本内容写入一个txt文件这样程序运行的“结果”就留下来了。这一层开始有一点真实项目的味道因为真实系统不太可能只用控制台展示一定有持久化。一般写到这一层基础语法已经算掌握得比较扎实了。第三层对齐真实打印场景。用文本文件配合操作系统的打印命令或者用第三方库生成PDF再交给打印机。这一层涉及编码、宽度对齐、打印驱动这些真实世界里才有的问题属于把“玩具案例”变成“工具”的关键一步。在后面的实操章节里我会把第一层和第三层的关键代码都写出来第二层的函数封装就穿插在其中一起说。2. 核心细节解析与关键代码实现2.1 数据结构设计列表、元组还是字典购物小票的核心数据是商品列表这个列表怎么设计直接影响后续代码好不好写。最基础的方式列表套元组。goods [ (苹果, 8.5, 2), (牛奶, 5.0, 1), (面包, 12.0, 3), ]元组里的三个元素分别是商品名称、单价、数量。这种方式写起来最短位置固定不会搞混适合新手。缺点是如果以后要加“折扣”“会员价”“商品编码”这些字段元组就得跟着改长度代码里所有取数据的地方也得一起改扩展性差。推荐的进阶方式列表套字典。goods [ {name: 苹果, price: 8.5, count: 2}, {name: 牛奶, price: 5.0, count: 1}, {name: 面包, price: 12.0, count: 3}, ]字典的好处是每个字段有名字读代码的人一眼就明白price是单价、count是数量不需要靠记忆。以后加字段也不需要动已有代码的结构只在字典里多写一个键就行。真实项目里数据大概率会以类似JSON的格式存在字典的思维方式也更贴近实际。我个人建议你在自己练习时两种方式都写一遍。先写元组版体会“简洁”再改成字典版体会“可扩展”。这两个版本写完之后你对数据结构设计的理解会比只看别人的代码深刻很多。2.2 金额计算的边界处理金额计算是整个案例里最容易“差不多就行”的地方但恰恰是真实收银系统里最重要的环节。Python的浮点数有一个经典问题——0.1 0.2并不精确等于0.3在控制台里试一下就知道了。直接拿浮点数计算金额攒多了就会差几分钱。处理这个问题有三种常见做法方案一用Decimal定点数。Python自带的decimal模块可以精确表示小数适合金额计算。from decimal import Decimal price Decimal(8.5) count Decimal(2) subtotal price * count # Decimal精确结果是17.0注意这里传的是字符串不是浮点数。如果传Decimal(8.5)还是会有浮点数的精度问题残留。方案二统一以“分”为单位用整数计算。这就是真实收银系统的常用做法。把钱都换算成分8.5元就存850计算全部用整数最后展示时再除以100。整数计算不会有任何精度问题性能还好。方案三最后用round统一舍入。这种方法适合练习不适合正式场景。round带有银行家舍入的规则而且舍入时机不同结果可能不同非常容易踩坑。对于这个案例我建议你用整数分这个方案体会一下真实系统的做法。它会让你的代码在计算这一环就比大多数人写的严谨。2.3 对齐输出的核心算法中文宽度处理购物小票输出的最大难点不是计算而是对齐。英文和数字在等宽字体下占1个字符宽度中文占2个Python的字符串长度函数len只会按字符数统计不会区分中英文所以直接用format冒号宽度对齐中文一多就歪了。举个例子你写f{name:10}想把商品名放到宽度为10的区域里如果name是“苹果”len(苹果)等于2但屏幕上实际占了4个格子后面内容就会错位。解决方式是写一个能计算“显示宽度”的工具函数def display_width(text): 计算字符串在终端中的显示宽度中文按2个宽度算 width 0 for char in text: if \u4e00 char \u9fff: # 常用汉字范围 width 2 else: width 1 return width然后做填充的时候不能直接用ljust因为ljust也是按字符数量计算必须自己写一个按显示宽度左对齐的函数。这个函数是整个案例最值钱的代码因为它是“中文场景独有的问题”。你在网上看到很多Python格式化教程几乎都不会讲这一层但我可以说几乎所有做中文小票输出的人都被这个问题折腾过。做进销存系统的朋友应该会深有体会商品名称有中文有英文有数字长度参差不齐不做宽度处理打印出来就是一团糟。2.4 小票模板设计分块区域规划一张购物小票从视觉上可以分成四块区域我们写代码时也按这四块来组织逻辑。区块一店名与抬头。一般居中显示上面还可以加一行日期时间。真实的超市小票会在这部分打出“欢迎光临”之类的文案门店编号、收银员编号也在这里。我们案例里放店名、日期、单号就够了。区块二表头与商品明细。表头是“商品 单价 数量 小计”这样的列标题下面是逐行商品数据。这一块是重点每一列的对齐都要精确控制。实际超市小票的商品列表还会在单价后面标注是否参与促销我们基础版先不做。区块三金额信息。包括商品总数、合计、实收、找零。真实场景还会打印“含税金额”“优惠金额”等字段我们案例里先放基础字段。这里有一个生活化的小技巧真实小票里这三行经常用分隔线和左右对齐的方式排布右边是数字左边是标签中间用空格填充。区块四尾部信息。可以是“谢谢惠顾欢迎再次光临”的温馨提示也可以是会员积分、退货政策说明。这部分在真实场景里承担营销和告知功能我们做案例时写一行感谢语就够了。把输出内容拆成四块之后代码结构就非常清晰每个区块写一个函数最后在主流程里依次调用来组装整张小票。这也是真实项目“模板化”“组件化”思路的雏形。3. 实操过程与核心代码实现全流程3.1 环境准备Python安装与编辑器选择运行这个案例需要一个Python环境。如果你还没装Python直接去官网下载安装包安装时记得勾选“Add Python to PATH”这样在命令行里输入python才能识别。装好之后打开命令行输入python --version能显示版本号就说明环境没问题。编辑器方面新手我推荐直接用VS Code装一个Python扩展就够了。VS Code的好处是集成终端写代码和跑程序都在一个窗口里省去来回切换的麻烦。你不需要一开始就折腾复杂的IDE配置等代码量变大、需要调试功能时再换更专业的工具也完全来得及。在这个案例中用到的全部是Python标准库没有第三方依赖。也就是说只要你装好了Python本身就能跑通下面所有代码不需要额外装任何包。3.2 完整代码最基础的购物小票先给你一个最直观的版本。这个版本不处理中文宽度对齐用最简单的f-string让你先看到程序的整体骨架。import datetime goods [ {name: 苹果, price: 8.5, count: 2}, {name: 牛奶, price: 5.0, count: 1}, {name: 面包, price: 12.0, count: 3}, ] total 0 print( * 30) print( ××超市购物小票) print( * 30) print(f时间: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}) print(- * 30) for item in goods: subtotal item[price] * item[count] total subtotal print(f{item[name]} {item[price]} x{item[count]} {subtotal:.2f}) print(- * 30) print(f合计: {total:.2f} 元) print( * 30) print( 谢谢惠顾欢迎再次光临)这段代码跑起来就能看到一张基本的小票。它用的知识点有字典取值、for循环遍历列表、累加求和、格式化保留两位小数、datetime获取当前时间。这里面{total:.2f}的意思是格式化浮点数并保留两位小数是金额展示的常用写法。你可以先跑通这段代码理解整体逻辑然后再进入下面的升级版。3.3 升级版中文宽度对齐 函数封装这一版是很多人真正需要的一版。它引入了显示宽度计算函数输出内容能严丝合缝地对齐再把各部分封装成函数方便复用。import datetime def display_width(text): 计算字符串在终端中的显示宽度中文按2个宽度计算 width 0 for char in text: if \u4e00 char \u9fff: width 2 else: width 1 return width def pad(text, total_width): 按显示宽度左对齐填充空格 total_width是期望占用的显示宽度 return text * max(0, total_width - display_width(text)) def print_receipt(goods, store_name××超市): 根据商品列表打印购物小票 print( * 36) print(pad(store_name, 36)) now datetime.datetime.now() print(f时间: {now:%Y-%m-%d %H:%M:%S}) print(- * 36) # 表头 print(pad(商品, 16) pad(单价, 6) pad(数量, 6) pad(小计, 8)) print(- * 36) total 0 for item in goods: name item[name] price item[price] count item[count] subtotal price * count total subtotal print(pad(name, 16) pad(f{price:.2f}, 6) pad(str(count), 6) pad(f{subtotal:.2f}, 8)) print(- * 36) print(pad(合计, 28) pad(f{total:.2f}, 8)) print( * 36) print(pad(谢谢惠顾欢迎再次光临, 36)) if __name__ __main__: goods [ {name: 苹果, price: 8.5, count: 2}, {name: 牛奶, price: 5.0, count: 1}, {name: 面包, price: 12.0, count: 3}, {name: 进口提拉米苏蛋糕, price: 39.9, count: 1}, ] print_receipt(goods)这段代码里最关键的就是display_width和pad这两个函数。你注意第6行pad(name, 16)它的意思是这个区域总共要占16个显示宽度如果name是“苹果”实际显示宽度是4函数会在后面补12个空格。而“进口提拉米苏蛋糕”显示宽度是18已经超过16此时pad函数会返回原字符串不会截断也不会补空格后面内容会挤在一起这其实是另一个需要注意的问题列宽和超长商品名之间的取舍后面会在问题排查讨论。3.4 金额优化用整数“分”避免浮点误差上面这个版本直接用浮点计算金额看结果可能没问题但我建议你养成用整数分的习惯。把单价改成以分为单位的整数计算时全部用整数只在最终展示时才转成带两位小数的字符串。def format_money(cents): 把以分为单位的整数转成保留两位小数字符串如850 - 8.50 yuan cents // 100 # 整数部分 fen cents % 100 # 小数部分 return f{yuan}.{fen:02d}对应的商品数据就要改造goods [ {name: 苹果, price_cents: 850, count: 2}, {name: 牛奶, price_cents: 500, count: 1}, ]计算小计用subtotal_cents item[price_cents] * item[count]合计也全部以分累加。展示时调用format_money(total_cents)。这个写法可能一开始让你觉得麻烦但它的价值在于计算结果是完全精确的。比如三件单价0.1元的商品浮点算出来可能是0.30000000000000004整数分算出来就是30分“0.30”干净利落。实际开发中金额系统绝大多数都是这么做的。3.5 保存小票文本 利用系统命令实现真实打印控制台输出只是第一步真实场景需要把小票内容保存成文件这样既可以存档也能交给打印机。实现起来很简单把print的内容换成写入文件即可更通用的做法是重构一下让函数既能打印到控制台也能写入文件。一个比较实际的方案是把小票文本先写入txt文件然后调用系统打印功能。Windows系统下可以用os.startfile(path, print)把文件交给默认打印程序或者用subprocess.run([notepad, /p, path])调用记事本的打印命令。这些思路比直接控制打印机简单得多适合个人项目和轻量场景。更进一步的方案是用reportlab或者其他PDF库先组合生成PDF小票。PDF的好处是排版可靠字体不乱无论哪台机器打开效果都一样。真实商业场景中大多数小票打印是走热敏打印机厂商的SDK或指令集这个案例先不展开那么深但你可以从“控制台 → 文件 → 打印”这条路径体会到开发流程的升级。3.6 完整实操流程从代码到打印我按真实操作顺序给你梳理一遍完整流程。假设你用的是VS Code文件名叫receipt.py。第一步创建项目文件夹把上面的代码完整粘贴到receipt.py里保存。第二步在终端运行python receipt.py如果没有报错控制台会显示出对齐整齐的小票内容这就是程序的“控制台模式”输出。第三步验证对齐效果把“进口提拉米苏蛋糕”这种长名称加进去观察列是否错位顺便理解为什么需要宽度处理函数。第四步把print部分的输出同时写入文件再在资源管理器里双击打开生成的txt查看视觉效果。第五步连接一台打印机测试系统打印命令确认打印输出与屏幕显示一致。我自己第一次做完这个流程时最大的感受是控制台里看着没问题实际拿打印机打出来以后才发现中文等宽字体和理想效果差距不小。所以如果你手边有打印机一定要走一遍真实打印流程很多问题只有到这一步才暴露。4. 常见问题与排查技巧实录4.1 中文对齐错乱、列不对齐这是出现频率最高的问题原因就是前面说的Python字符串宽度和显示宽度不一致。如果你遇到小票内容歪歪扭扭第一件事不是怀疑代码逻辑而是检查有没有用到按显示宽度填充的函数。系统自带的format、ljust、rjust都不区分中英文宽度中文一多就会错位。解决方案是写一个显示宽度计算函数然后自己实现填充逻辑替换掉原生的ljust。再补充一点数字里英文标点和汉字等不同字符宽度也要按实际显示效果处理不能想当然。如果你发现某个字符比如“·”或者“”显示宽度跟预期不符可以逐步把特殊字符加进宽度函数里兜底。4.2 中文乱码问题在Windows控制台里运行Python打印中文偶尔会出现乱码。这通常跟控制台代码页有关。Python 3在Windows上默认输出编码是UTF-8但老版本的Windows控制台默认可能是GBK两边不匹配就乱码。解决办法是写文件时显式指定编码open(receipt.txt, w, encodingutf-8)这样文件本身是UTF-8用支持UTF-8的编辑器打开就不会乱。如果控制台乱码可以在代码开头加上# -*- coding: utf-8 -*-或者通过设置环境变量PYTHONIOENCODING为utf-8来约束输出编码。写文件时还有个细节Windows自带记事本对UTF-8文件会读得很好但如果你用系统打印命令打印txt编码也可能造成乱码。最稳妥的方式是生成PDF而不是txtPDF内部有规定的字体编码天然规避乱码问题。注意不是所有环境都默认UTF-8如果项目中涉及其他人读代码或跨平台运行最好在文件头部统一声明编码策略。4.3 金额显示多位小数或精度错误如果直接用浮点数累加金额常见问题是输出出现1.2000000000000002这种长尾巴。这是因为浮点数二进制存储无法精确表示某些十进制小数。解决办法是前文说的用整数分或者Decimal。如果你只是做练习round也能凑合但你要知道round不是万能的它可能因为舍入规则和时机不同产生偏差。展开来说round(2.675, 2)在Python里结果是2.67而不是2.68很多人栽在这里。所以涉及金额我建议从书写代码的第一刻起就用分做整数后面就不会返工。4.4 打印出来的小票与屏幕不一致这种情况多半不是代码问题而是打印机、驱动、纸宽等多方面因素。热敏打印机小票纸一般是58mm或80mm宽每行能容纳的字符数是有限的超出部分会被裁掉或折行。你打印前需要根据纸宽调整每行总宽度比如58mm纸常见一行能打印32个英文字符也就是16个汉字。案例里的36宽度更适合80mm纸或普通A4打印如果你用58mm小票机要重新度量。其次字体不同也会导致实际宽度不同。等宽字体和比例字体下同样字符数排出来的宽度可能差很多。解决方式是在打印之前先用一款目标环境的字体做测试以真实输出的行宽为准。4.5 文件打印命令没有反应调用系统打印命令时没有反应最常见原因是系统里没有设置默认打印机或者打印服务被关闭。排查顺序是先确认能打印测试页再确认默认打印机选对再检查打印队列是否堵塞。如果你是在办公环境有时候网络打印机不在线也会导致命令无反应。个人练习环境建议先用PDF输出验证排版再考虑真实打印这样可以隔离软件和硬件的问题。4.6 遇到问题时的通用排查思路小票程序出问题不要盯着代码一行行咬文嚼字按这个顺序来找第一看报错位置。Python报错会给出文件名、行号和错误类型先看是不是语法错误、缩进错误这类占了初学者的七成问题。第二打印中间变量。在关键计算处打印出来比如每种商品的subtotal、每次累加后的total看看数据流在哪里断掉。第三简化数据。把你的商品列表砍到只剩一条跑通之后再加第二条定位是通用逻辑问题还是特定数据问题。第四重读需求。你这一步是想输出什么、对齐成什么样、计算什么金额先想清楚再改代码很多时候改着改着忘了本来要干什么越改越乱。5. 进阶扩展让小票真正“落地”到现实工具5.1 增加会员折扣和满减规则真实购物小票必然有优惠逻辑。你可以给商品字典加一个“discount”字段按折扣计算小计或者在计算合计之后根据满减规则调整总额。比如满100减10满200减30用if判断就能实现。这个扩展非常适合练习条件分支学起来也不复杂。做完这个扩展之后你就能理解为什么真实收银系统里优惠计算和商品列表是分开存储、分开展示的。具体实现思路商品明细打印原价小计合计行之前增加一个“优惠金额”行最后再打印“应收金额”。这比直接把折扣写进每行商品里更接近真实场景。5.2 对接真实打印机的两种路径如果你的目标是把小票真正打出来目前有两类做法。一类是走通用打印方案就是前面提的生成txt或PDF再交给系统打印优点是代码量小缺点是对纸张宽度、打印指令的控制能力弱。另一类是直接用厂商SDK开发套件比如常见的58mm热敏打印机厂家都会提供Windows下的动态库或指令文档用Python的ctype直接调用动态库或者通过串口/USB发送打印指令。这个路子能做得很底层控制精细但代码量和工作量都比较大。我建议个人学习阶段走第一类先把业务逻辑和排版做扎实等到确实需要生产级别的打印时再研究指令协议也不迟。5.3 让程序接收真实输入现在的代码是写死商品数据的接下来把它变成一个“能收数据”的工具就更有意思了。最简单的方式是用内置的input函数交互式录入复杂一点可以读取csv文件批量生成小票再复杂一点可以做个简单的Web页面前端填写商品后端生成小票。这些方向都能锻炼不同层面的能力。我个人觉得读取csv这个方向性价比最高因为熟练之后你手上有一份商品清单随时运行脚本生成小票很实用。import csv def load_goods_from_csv(file_path): goods [] with open(file_path, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: goods.append({ name: row[name], price_cents: int(float(row[price]) * 100), count: int(row[count]), }) return goods这个函数会读一个包含name、price、count三列的csv文件把每行转换成字典供打印函数使用。csv表格可以用Excel编辑对非程序员也友好整个工具链瞬间就完整了。5.4 从案例到系统的思路迁移把这个小票案例做熟之后你可以往两个方向迁移。一个方向是往“进销存”靠小票只是销售环节的末端往前还有一个商品库存、订单管理、客户信息等一整套东西你可以逐步加加一个库存字典卖出就减库存加一个订单类把小票数据对象化。另一个方向是往“数据可视化”靠把小票里的销售记录汇总成日报表用matplotlib画一个每天销售额的折线图。不管哪个方向这个案例都会是你的起点。我个人在实际操作中的体会是小票案例最妙的地方在于它像一块积木单独看很小但拼到任何系统里都能用。做完这个案例后再去看开源的后台管理系统你会发现很多模块本质上就是“取数据、算数据、呈现数据”三个环节小票只是呈现环节的一个特例而你已经把这个特例完整掌握了一遍。最后再分享一个小技巧如果你打算长期写Python做数据处理尽量把“数据存储”和“展示排版”分开。这个小票案例里商品数据是数据层print和pad函数是展示层。数据层保持纯数据展示层只管排版以后你想换Web展示、PDF展示、还是打印机展示数据层都不用动。很多人在小项目里养成混写的习惯一到大型项目就吃亏。这个案例麻雀虽小如果能一开始就养成分层的习惯后面会受益很久。做完了这些你可以把代码保存下来放一个备注下次帮朋友写个简单的销售记录工具直接改改就能用。这就是从“练手案例”到“趁手工具”的转变。