Python 3.12 核心语法实战:从数据类型到流程控制
Python 3.12 核心语法实战:从数据类型到流程控制
《Python 实战》基础篇第二篇。承接上篇搭好的虚拟环境,这一篇我把 Python 3.12 的核心语法一个个敲一遍,所有输出都是服务器真实回显。语法书能讲清楚规则,但"坑"只有真跑过才知道长在哪儿。
环境说明
本篇实验全部在上一篇建好的虚拟环境之外,用系统python3(3.12.3)运行,因为要演示的语法特性 3.9/3.10/3.12 都已支持。先确认版本:
$ python3-VPython3.12.3下面每个示例都是一个独立.py文件,我贴出源码,再贴服务器跑出来的真实结果。
一、变量、字符串与 f-string
Python 里变量不用声明类型,赋值即用。字符串的常用操作很多,f-string 是最舒服的格式化方式:
name="Python"version=3.12pi=3.14159# f-string 基本用法print(f"语言:{name}, 版本:{version}")# 3.8+ 调试写法 f"{x=}"x=42print(f"{x=},{x*2=}")# 字符串常用操作s=" Hello, Python "print(s.strip())print(s.strip().upper())print(s.strip().replace("Python","World"))print("".join(reversed("abc")))# 反转print("py"in"python")# 子串判断print(len(s))真实回显:
语言: Python, 版本: 3.12 x=42, x*2=84 Hello, Python HELLO, PYTHON Hello, World cba True 17重点说两个常被忽视的点。f"{x=}"是 Python 3.8 引入的调试写法,它会同时打印变量名和值,调试时比手写print("x=", x)省事太多,我几乎在每段调试代码里都用它。另一个是print("".join(reversed("abc")))得到cba——字符串反转不用自己写循环,reversed配合join一行搞定。
二、数字类型与浮点精度坑
整数、浮点、布尔、复数都是数字类型,但浮点运算有一处经典陷阱:
print("0.1 + 0.2 =",0.1+0.2)# 浮点误差print("0.1 + 0.2 == 0.3 ?",0.1+0.2==0.3)importmathprint("math.isclose:",math.isclose(0.1+0.2,0.3))fromdecimalimportDecimalprint("Decimal:",Decimal("0.1")+Decimal("0.2"))# 精确print("整除与取余:",7//2,7%2,2**10)# 地板除 vs 真除print("5/2 =",5/2," | -5//2 =",-5//2)真实回显:
0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 ? False math.isclose: True Decimal: 0.3 整除与取余: 3 1 1024 5/2 = 2.5 | -5//2 = -30.1 + 0.2不等于0.3,而是0.30000000000000004,这是二进制浮点的固有问题,不是 Python 的 bug。所以涉及金额、精度比较时,绝不能直接用==,要么用math.isclose允许误差,要么用decimal.Decimal(注意要用字符串构造"0.1",否则一进来就带误差)。7 // 2是整除得3,7 % 2是取余得1,2 ** 10是幂运算得1024。最后那个-5 // 2 = -3也值得记:Python 的地板除是向下取整,-2.5向下就是-3,和很多语言不一样。
真实踩坑:脚本名千万别叫 numbers.py
这一段我自己的操作翻过车,原样记下来。我最初把上面这个文件命名为numbers.py,一运行就炸了,而且报错信息非常迷惑:
0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 ? False math.isclose: True 0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 ? False math.isclose: True 0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 ? False math.isclose: True Traceback (most recent call last): File "/usr/lib/python3.12/decimal.py", line 3, in <module> from _decimal import * File "/root/lab-a/numbers.py", line 5, in <module> from decimal import Decimal ImportError: cannot import name 'Decimal' from partially initialized module 'decimal' (most likely due to a circular import) (/usr/lib/python3.12/decimal.py) During handling of the above exception, another exception occurred: Traceback (most recent call last): File "/root/lab-a/numbers.py", line 5, in <module> from decimal import Decimal File "/usr/lib/python3.12/decimal.py", line 8, in <module> from _pydecimal import * File "/usr/lib/python3.12/_pydecimal.py", line 157, in <module> import numbers as _numbers File "/root/lab-a/numbers.py", line 5, in <module> from decimal import Decimal ImportError: cannot import name 'Decimal' from partially initialized module 'decimal' (most likely due to a circular import) (/usr/lib/python3.12/decimal.py)根因是:我的脚本叫numbers.py,恰好和标准库里的numbers模块同名。当decimal内部执行import numbers时,Python 因为当前目录里有numbers.py,就把标准库的numbers给遮蔽了,进而触发循环导入。上面那三遍重复的0.1+0.2打印,就是numbers.py被当成标准库反复"导入"自己导致的。把文件改名为nums.py后,一切正常。
经验法则:永远不要用标准库已有的名字(如numbers.py、json.py、email.py、random.py)给自己的脚本命名,这是新手最高频也最隐蔽的坑之一。
三、列表、元组、集合、字典全套
四种内置容器各有所长,下面一次性过完:
# 列表lst=[3,1,2]lst.append(4)lst.sort()print("list:",lst)# 元组t=(1,2,3)print("tuple:",t,"不可变")# 集合a={1,2,3};b={3,4,5}print("set 交集:",a&b,"并集:",a|b)# 字典d1={"a":1,"b":2}d2={"b":3,"c":4}d1.update(d2)print("dict update:",d1)# 3.9+ 合并运算符 |=d3={"a":1,"b":2}|{"b":99,"c":3}print("dict |:",d3)# 字典推导sq={k:k*kforkinrange(1,5)}print("dict comp:",sq)真实回显:
list: [1, 2, 3, 4] tuple: (1, 2, 3) 不可变 set 交集: {3} 并集: {1, 2, 3, 4, 5} dict update: {'a': 1, 'b': 3, 'c': 4} dict |: {'a': 1, 'b': 99, 'c': 3} dict comp: {1: 1, 2: 4, 3: 9, 4: 16}要点:列表可变、有序;元组不可变,适合当常量或函数返回多值;集合自动去重、支持交集&并集|;字典里要特别注意|=合并运算符(Python 3.9 引入),它返回新字典且后者覆盖前者同键,上面d3里"b"从2变成了99。推导式(列表、字典、集合都支持)是 Python 很爽的语法糖,{k: k*k for k in range(1, 5)}一行就生成了平方表。
四、流程控制:if / match / while / for
条件、循环、模式匹配,逐个演示。先if和 Python 3.10 的match:
score=85ifscore>=90:g="A"elifscore>=60:g="B"else:g="C"print("grade:",g)defhandle(cmd):matchcmd.split():case["quit"]:return"退出"case["get",name]:returnf"获取{name}"case["set",name,val]:returnf"设置{name}={val}"case_:return"未知命令"print(handle("quit"))print(handle("get user"))print(handle("set user 100"))print(handle("foo bar baz"))i=0whilei<3:forjinrange(2):print(f"while i={i}for j={j}")i+=1defkind(v):matchv:caseint()ifv>0:return"正整型"caseint():return"非正整型"casestr():return"字符串"caselist()asxsiflen(xs)>2:return"长列表"case_:return"其他"print(kind(5),kind(-1),kind("hi"),kind([1,2,3,4]))真实回显:
grade: B 退出 获取 user 设置 user=100 未知命令 while i=0 for j=0 while i=0 for j=1 while i=1 for j=0 while i=1 for j=1 while i=2 for j=0 while i=2 for j=1 正整型 非正整型 字符串 长列表match是 Python 3.10 才有的结构模式匹配,比一长串if/elif清爽太多。它能解构序列:case ["get", name]表示"列表第一个元素是字符串get,剩下的绑定到name";case _是通配兜底。kind函数里还演示了带守卫的模式:case int() if v > 0先匹配整型,再用if加条件,以及case list() as xs把匹配到的列表绑定到变量xs继续判断长度。结构化匹配在处理命令解析、协议字段时特别好用。
五、实战一:用 match 写四则运算计算器
把上面的match用起来,做个能识别"3 + 4"这种表达式的小计算器:
defcalc(expr):importre m=re.match(r"\s*([0-9.]+)\s*([+\-*/])\s*([0-9.]+)\s*",expr)ifnotm:return"格式错误"a,op,b=float(m.group(1)),m.group(2),float(m.group(3))matchop:case"+":returna+bcase"-":returna-bcase"*":returna*bcase"/":returna/bifb!=0else"除数不能为0"case_:return"未知运算符"forein["3 + 4","10 - 2","6 * 7","8 / 2","5 / 0","bad"]:print(f"{e!r:>10}=>{calc(e)}")真实回显:
'3 + 4' => 7.0 '10 - 2' => 8.0 '6 * 7' => 42.0 '8 / 2' => 4.0 '5 / 0' => 除数不能为0 'bad' => 格式错误这里用正则抽出数字和运算符,再用match分发。注意f"{e!r:>10}":!r把字符串按repr形式(带引号)输出,>10右对齐占 10 列,排版更整齐。5 / 0没有让程序崩溃,因为我提前用if b != 0拦住了除零,这是写计算器必须考虑的边界。
六、实战二:手机通讯录(dict + json 持久化)
再做一个带增删查和文件持久化的通讯录,顺便演示int()类型转换和json落盘:
importjson,os FILE="/root/lab-a/contacts.json"defload():ifos.path.exists(FILE):withopen(FILE,"r",encoding="utf-8")asf:returnjson.load(f)return{}defsave(book):withopen(FILE,"w",encoding="utf-8")asf:json.dump(book,f,ensure_ascii=False,indent=2)book=load()print("初始通讯录:",book)# 增book["张三"]={"phone":"13800000001","age":int(28)}book["李四"]={"phone":"13900000002","age":int(32)}save(book)print("新增后:",book)# 查print("查张三:",book.get("张三"))print("查王五(不存在):",book.get("王五","无此联系人"))# 改book["张三"]["phone"]="13811111111"save(book)# 删removed=book.pop("李四",None)print("删除李四:",removed)save(book)print("最终文件内容:")print(open(FILE,encoding="utf-8").read())真实回显:
初始通讯录: {} 新增后: {'张三': {'phone': '13800000001', 'age': 28}, '李四': {'phone': '13900000002', 'age': 32}} 查张三: {'phone': '13800000001', 'age': 28} 查王五(不存在): 无此联系人 删除李四: {'phone': '13900000002', 'age': 32} 最终文件内容: { "张三": { "phone": "13811111111", "age": 28 } }book.get(key, 默认值)是安全取值的写法——键不存在时返回你给的默认值而不是抛KeyError,查王五时返回了"无此联系人"就是它发挥作用。json.dump(..., ensure_ascii=False, indent=2)让中文原样保存、带缩进更可读。文件里"age": 28是数字而非字符串,因为我在存的时候用int(28)做了转换——从外部读进来的数据往往都是字符串,记得按需转成正确的类型。
真实踩坑:路径里的 %s 忘了格式化
这个通讯录脚本我第一版也写错了,原样贴出当时的报错:
初始通讯录: {} Traceback (most recent call last): File "/root/lab-a/contacts.py", line 21, in <module> save(book) File "/root/lab-a/contacts.py", line 12, in save with open(FILE, "w", encoding="utf-8") as f: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FileNotFoundError: [Errno 2] No such file or directory: '%s/contacts.json'原因很直白:我起初写的是FILE = "%s/contacts.json",却忘了在后面用% BASE把%s替换成真实目录,于是 Python 真的去打开一个名字叫%s/contacts.json的文件,自然不存在。这种"格式化占位符忘了填"的笔误,在拼接路径、日志、SQL 时极常见。修法是直接用拼接好的绝对路径,或者老老实实FILE = "%s/contacts.json" % BASE。报错信息把出错的行和具体的文件名都打印出来了,照着看一眼就能定位。
七、避坑:可变默认参数
函数默认参数如果是可变对象(列表、字典等),会在多次调用间共享,酿成诡异 bug:
defadd_item_bad(item,lst=[]):lst.append(item)returnlstprint("第一次:",add_item_bad(1))print("第二次:",add_item_bad(2))# 期望 [2],实际累积了 [1,2]defadd_item_good(item,lst=None):iflstisNone:lst=[]lst.append(item)returnlstprint("good1:",add_item_good(1))print("good2:",add_item_good(2))真实回显:
第一次: [1] 第二次: [1, 2] good1: [1] good2: [2]第二次调用只传了2,却返回了[1, 2]——因为默认列表[]在函数定义时只创建一次,之后每次调用都在往同一个列表里追加。正确写法是把默认值设为None,在函数体内再新建列表。这条坑我见过不止一个人栽,尤其是写递归或累积逻辑时。
八、避坑:浅拷贝
copy()和切片得到的是浅拷贝,嵌套的可变对象仍指向同一份:
importcopy a={"scores":[80,90]}b=a.copy()# 浅拷贝b["scores"].append(100)# 影响到了 a!print("a:",a)print("b:",b)print("是同一列表对象?",a["scores"]isb["scores"])c=copy.deepcopy(a)# 深拷贝c["scores"].append(999)print("深拷贝后 a:",a)print("深拷贝后 c:",c)真实回显:
a: {'scores': [80, 90, 100]} b: {'scores': [80, 90, 100]} 是同一列表对象? True 深拷贝后 a: {'scores': [80, 90, 100]} 深拷贝后 c: {'scores': [80, 90, 100, 999]}a.copy()之后,改b["scores"]竟然连a也变了,因为b["scores"]和a["scores"]是同一个列表对象(输出True已证实)。只要涉及嵌套可变结构,要用copy.deepcopy做深拷贝才能彻底隔离。
九、附带几个高频小技巧
最后补几个我写业务代码天天在用的小语法,顺便巩固上面的容器和循环。
names=["张三","李四","王五"]scores=[90,85,88]fori,ninenumerate(names):print(i,n)print("---- zip ----")forn,sinzip(names,scores):print(f"{n}:{s}")# 元组解包a,b,*rest=[1,2,3,4,5]print("a,b,rest =",a,b,rest)# 交换变量x,y=10,20x,y=y,xprint("swap:",x,y)# is vs ==m=[1,2];n=[1,2]print("==:",m==n,"is:",misn)真实回显:
0 张三 1 李四 2 王五 ---- zip ---- 张三: 90 李四: 85 王五: 88 a,b,rest = 1 2 [3, 4, 5] swap: 20 10 ==: True is: Falseenumerate在遍历时顺带给出下标,比自己维护一个计数器清爽;zip把多个可迭代对象按位置"拉链"式配对,处理"名字+分数"这类平行序列时离不开它。a, b, *rest = [1,2,3,4,5]这个写法我很喜欢——*rest把剩下的元素一股脑收进列表,得到rest = [3,4,5],拆包时特别灵活。变量交换x, y = y, x在 Python 里一行就成,不需要中间变量,这是元组解包的语法糖。
最后那个==与is的区别值得单独划重点:m == n是True,因为两个列表的内容相同;m is n是False,因为is比的是对象身份(内存地址),这是两个不同的列表对象。is只对小整数缓存、单例None这类场景可靠,比较两个值永远用==,不要用is,这是新手写条件判断时很容易写错的一处。
踩坑清单
| 现象 | 根因 | 正确做法 |
|---|---|---|
0.1 + 0.2 == 0.3是False | 二进制浮点固有误差 | 用math.isclose或Decimal |
文件命名为numbers.py导致循环导入 | 同名遮蔽标准库模块 | 脚本名避开标准库保留名 |
FileNotFoundError: '%s/...' | 路径字符串%s忘了格式化 | 直接拼绝对路径或补% BASE |
| 可变默认参数跨调用串数据 | 默认对象只创建一次 | 默认设None,体内新建 |
| 浅拷贝后改一个影响另一个 | 嵌套对象仍共享引用 | 用copy.deepcopy |
match匹配不到 | 缺少case _兜底 | 总加一个通配兜底分支 |
总结
这一篇把 Python 3.12 的骨架过了一遍:变量与 f-string 让表达简洁,Decimal和math.isclose兜住浮点精度,四种容器加|=合并运算符覆盖日常数据结构,match模式匹配比if/elif更优雅也更强,两个小实战把语法落到了能跑的东西上。而那几个坑——标准库同名遮蔽、路径占位符漏填、可变默认参数、浅拷贝共享引用——都是我亲手敲错、看着报错改过来的,比任何记忆都牢。
语法只是工具,踩坑才是学费。写这篇时我刻意在每个示例后面都贴了真实回显,而不是只给结论——因为语法背得再熟,不亲手敲一遍、不亲眼看看报错长什么样,遇到真问题还是两眼一抹黑。下一篇可以聊函数、类与模块化,把这套基础真正用起来。
本文实验均在华为云 Flexus X 实例(Ubuntu 24.04, Python 3.12.3)上真实执行。
