Python3基础语法这种事看起来简单到可以写进三天入门的教程里但我在实际带人和改代码过程中发现很多同学不是不会语法而是带着别的语言的惯性在写Python。同样一段逻辑有人写得干净利落有人写得像穿了棉袄洗澡处处别扭。这篇文章不是要把所有语法点罗列一遍而是挑出Python3基础语法里最影响代码质量、最容易被低估的几条主线讲清楚背后的原理和实际选择。适合刚接触Python3的初学者、有其它语言经验想切过来的开发者也适合已经写了一阵子但总觉得代码不够Pythonic的人。1. Python3基础语法真正的门槛不是规则记忆而是思维切换1.1 缩进不只是排版而是语法块的承重墙很多从C、Java转过来的同学第一反应是Python居然用缩进表示代码块这也太随意了。事实上缩进在Python里不是风格指南而是强制语法。别的语言里缩进写乱了顶多被同事吐槽Python里缩进乱了直接跑不起来要么报IndentationError要么更隐蔽——逻辑分组变了程序不报错但行为完全不对。Python的缩进规则其实很朴素同一个代码块内部所有语句的缩进必须完全一致代码块之间用缩进层级区分。实际开发中几乎所有人都遵守PEP 8推荐的4个空格并且禁止把Tab和空格混用。我的建议是从第一天就用编辑器自带的tab转空格功能IDE配置好之后基本不会踩TabError。Python之父当年选择缩进作为语法块边界核心理由是让代码结构从视觉上一眼看穿。这个设计在团队协作时确实有用代码长得什么样逻辑就长什么样。那为什么很多初学者觉得它难因为你在粘贴代码、或者从网页复制示例时缩进会被打乱于是出现大量莫名其妙的报错。遇到这种情况别急着骂编辑器先在视图里打开显示空白字符把缩进统一成4空格再说。1.2 动态类型与运行时绑定为什么报错总是迟到另一个让新手困惑的点是Python变量的类型不固定。在C里你写int a 1这个变量这辈子都是int在Python里写a 1之后再写a hello完全合法。这里其实把一个关键概念讲清楚就理解了Python的变量不是一个装值的盒子而是一个贴在对象上的标签。赋值语句a 1做的事是创建整数对象1然后把名字a绑定到它身上下次a hello是创建字符串对象再把a重新绑定过去。对象有类型名字没有类型。这个机制带来了极大的灵活性但也意味着很多类型错误要等程序运行到那一行才会爆炸。C和Java在编译期就能拦住把字符串加到整数上这种问题Python只能运行到1 a时扔出TypeError。所以动态类型不是没有类型而是类型检查发生在运行时。这解释了一个常见现象很多同学觉得我的代码编译解释没问题怎么一运行就各种报错。没错Python解释器在把源码转成字节码的阶段只检查语法和缩进等结构问题变量的类型正确性一概不管。理解了这一点你才会明白为什么Python社区格外强调单元测试和类型注解——动态类型给了你自由但自由需要靠习惯来约束。Python3从3.5开始支持类型注解语法写完def add(a: int, b: int) - int:再加mypy静态检查能在不牺牲灵活性的前提下找回大部分静态类型的安全网我个人很推荐在项目里逐步引入。1.3 先建立这三个习惯再谈后面的套路基础语法那么多哪些值得优先养成肌肉记忆我根据自己的使用频率和对代码质量的影响总结出三条第一变量名一定要能朗读。代码是给人看的Python3语法本身不管你的变量叫a还是叫account_balance但三个月后回来看代码的人很可能就是你自己会感谢前者还是后者不言而喻。第二优先使用标准库提供的数据结构和工具函数而不是什么都自己造。后面讲到解包、enumerate、zip时你会发现Python已经把高频操作封装得很干净。第三每次写循环前先问自己一句这个循环真的需要吗列表推导式、字典推导式、生成器表达式往往更短更清晰但要注意不能走极端嵌套三层以上的推导式可读性反而灾难这时候老老实实写for循环。有了这三个习惯打底下面进入最容易出问题、也最值得花精力看透的区域变量与对象。2. 变量与对象先从内存视角把赋值这件事看透2.1 a b 到底复制了什么从名字绑定的角度看引用我在很多场合问过一个问题a [1, 2, 3]; b a; b.append(4)此时a里有什么每次都会有相当一部分人犹豫b改了a应该没变吧答案恰恰相反a也变成了[1, 2, 3, 4]。原因就是前面说的标签模型。a [1, 2, 3]创建列表对象a指向它b a不是复制列表而是给同一个列表对象贴了一张新标签b。你通过b去append修改的是那个唯一的列表对象a当然看得到变化。这跟Java里的引用传递、跟C语言里的指针概念是一脉相承的只不过Python把语法包装得更友善让很多人误以为赋值就是复制。理解这个模型后很多困惑立刻瓦解为什么函数里修改传入的列表外部变量也跟着变为什么用判断两个字典相等有时候True有时候False这里需要单独强调一个极易混淆的知识点—— 与 is 的区别。比较的是值是否相等is比较的是两个名字是否绑定到同一个对象即内存地址是否相同。由于解释器会对小整数做缓存-5到256之间的整数无论你怎么赋值指向的都是同一个缓存对象所以a 257; b 257; a is b是False而a 1; b 1; a is b是True。很多人从这里得出is就是值比较的错误结论其实只是被小整数缓存误导了。我的建议是业务代码里判断值一律用is只用来和None比较比如if x is None这是Python社区的共识也最安全。2.2 可变对象与不可变对象你们家的家具和房子逻辑Python的对象分成两大阵营可变对象和不可变对象。列表、字典、集合是可变阵营的它们的内部内容可以被增删改整数、浮点数、字符串、元组、布尔值是不可变阵营的一旦创建内部内容就不可能再变。不可变对象这个不可变不是说变量不能重新赋值而是说对象本身的内部状态不会改变。拿字符串举例s a; s b看上去像把a改成了ab实际是创建了一个全新的字符串对象ab再让s指向它原来的a对象原地不动等垃圾回收。这也是为什么在循环里大量拼接字符串效率极差的根因每次拼接都会创建新对象。更合适的做法是用列表收集片段再用.join。整数和浮点数同理每做一次运算几乎都会产生新对象。可变对象则不同list.append是在原地修改不需要创建新对象。这个区分在函数传参时极其重要。如果你写def process(data): ...然后传入一个列表函数内对列表做append、remove、排序等操作这些修改会直接作用到调用方的列表上。如何避免函数意外修改外部数据最常见的做法是函数开头先拷贝一份比如data list(data)或data data.copy()把处理放在副本上进行如果只是想遍历读取就明确不调用任何修改方法。反之如果传入的是字符串或元组无论函数内怎么折腾外部的数据都不会变天然安全。理解不可变对象更安全这一点后你就会明白为什么Python的字典键要求必须用不可变对象——如果键能在哈希表里变来变去存储位置就没法维护了。2.3 拷贝的三种姿势切片、浅拷贝与深拷贝的分界线既然直接赋值只是多贴一张标签那真想复制一份独立的列表该怎么做这里有一整套容易记混的姿势。最简单的是切片new_list old_list[:]它产生一个新的列表对象。对于一维列表这么做通常就够用了新列表里的元素和旧列表的元素是同一个对象——如果元素是整数、字符串这种不可变对象改new_list的元素不会影响old_list因为那是重新绑定标签但如果你把new_list[0]里存着一个子列表然后去修改new_list[0][0]问题就来了你改的是内部那个子列表对象的内容而这个子列表对象同时在old_list[0]的引用链上。因为切片只拷贝了外层列表内层列表仍然是共享的所以它本质上是浅拷贝。需要完全独立的副本时就要用copy模块。copy.copy()做浅拷贝copy.deepcopy()做深拷贝深拷贝会递归复制所有层级。听到递归复制所有层级你可能觉得那用deepcopy不就好了其实deepcopy有代价一是慢二是对某些包含文件句柄、锁、网络连接等资源的对象深拷贝根本没有意义甚至直接报错。所以正确的思路是能浅拷贝就不深拷贝只有确认对象内部还嵌套着可变对象、且这些嵌套内容你需要独立修改时才动用deepcopy。这里有一个经典面试陷阱也是我见过无数人踩过的坑用乘号复制列表。matrix [[0] * 3] * 3你本意是创建一个3行3列的零矩阵结果matrix[0][0] 1会把三行第0列全变成1。原因很简单[ [0, 0, 0] ] * 3并不是复制出三个新列表而是把同一个内层列表对象重复绑定了三次。三个外层槽位指向同一个对象修改其中一个等于修改全部。正确写法是列表推导式matrix [[0] * 3 for _ in range(3)]每一轮循环都执行一次[0] * 3产生一个新的内层列表。这个坑特别值得记住因为平时写代码时乘号语法看着太自然了几乎没有人会第一时间怀疑它。3. 容器与迭代最容易写出别的语言味道的地方3.1 四类内置容器列表、元组、字典、集合的选型逻辑Python3基础语法里内置容器就四类列表、元组、字典、集合。选型对了代码自然简洁选型错了后面全是补丁代码。容器是否可变是否有序能否重复典型用途list可变有序允许需要动态增删改的序列tuple不可变有序允许定长记录、字典键、函数多返回值dict可变有序3.7起键不重复键值映射、结构数据set可变无序元素不重复去重、成员判断、集合运算很多同学把列表当成万能容器什么数据都往里塞。实际上如果数据是一条记录的多个字段用元组更合适因为元组的不可变性保证了这条记录不会被意外篡改如果数据是按某种条件快速查找用字典远胜于在列表里线性扫描如果只是关心某个元素在不在里面集合的成员判断时间复杂度是O(1)列表是O(n)数据量一大差距极其明显。还有一个高频疑问字典在Python3.7之后保证插入顺序这是语言规范不是实现细节。这意味着你可以放心地用for key in d:遍历得到的键顺序和插入顺序一致。如果你需要按键排序可以直接for key in sorted(d):或者sorted(d.items())按键值对排序。如果你需要同时遍历键和值直接for key, value in d.items():不要再用先遍历键、再通过键取值的绕路写法。集合的成员判断写法也很直接if admin in user_set:比写成一个列表再调用count不知道清爽多少。3.2 for循环的隐藏逻辑与迭代协议很多从C语言转过来的同学写Python循环时会习惯性地找下标的活儿for i in range(len(items)): print(items[i])。其实这种写法虽然能跑但完全没有发挥出Python迭代协议的力量。Python里for x in iterable:这个语句背后的机制是解释器先调用iter(iterable)拿到一个迭代器对象然后不断调用next()获取下一个值直到遇到StopIteration异常就干净退出循环。循环并不关心你是什么类型只要你是可迭代对象就行。这正是for循环如此统一的原因字符串可以迭代出字符文件对象可以迭代出每一行字典可以迭代出键集合可以迭代出元素range对象可以迭代出一系列整数。你完全不需要像C语言那样关心这个容器支持随机访问吗、下标越界了怎么办迭代协议把这些全都抽象掉了。一旦理解了这一点你会发现自己的代码可以写得很抽象写一个函数只要参数是可迭代对象就都能处理不绑定在具体类型上。顺带说一个容易被忽略的细节for循环也能和else搭配for ... else的else分支在循环正常结束没有被break打断时执行。这个语法一开始看着别扭但在某些场景非常省事比如判断一个序列里是否存在满足条件的元素找到就break循环走完没找到就进入else分支处理。写起来比设置一个flag变量再在循环后检查要直白很多。不太建议初学者上来就用知道有这个东西即可因为可读性争议不小但一旦用顺手了你会发现省掉的变量是实打实的。3.3 解包、enumerate与zip让代码少三行、可读性翻倍我反复对身边人说Python3基础语法里性价比最高的一组语法一定是解包unpacking。它能让你写出既是个人风格又异常清爽的代码。最基础的是序列解包a, b 1, 2a, b b, a这一行就完成了两个变量值的交换不需要第三个临时变量。进阶一点的用法是带星号的解包head, *rest [1, 2, 3, 4]head拿到1rest拿到[2, 3, 4]a, *middle, z [1, 2, 3, 4]middle拿到[2, 3]。这个语法在拆分第一个元素和剩余部分时非常顺手。函数层面def fn(*args,kwargs)分别收集位置参数和关键字参数调用时,*args和kwargs又负责把序列和字典展开传入函数。一旦掌握写装饰器、封装第三方接口、处理变长参数时就会感觉Python的设计是真的很统一。enumerate和zip是我眼中基础函数里的两个宝藏。遍历列表同时需要下标时enumerate帮你省掉手动维护索引变量的麻烦for index, item in enumerate(items, start1):。第二个参数start从1开始计数的写法在处理展示第几条数据这种场景里特别贴心不用再在循环里自己加1。zip则能把多个可迭代对象按位置对齐组合成元组流for name, score in zip(names, scores):。当两个列表长度不一致时zip默认按最短的截断如果你希望按最长的来并且缺少的位置用指定值填充就要用itertools.zip_longest。这些都是标准库自带的能力用熟了之后写出来的代码基本不会再有C语言那种我遍历的时候还得维护三个数组的下标的痛苦。4. 函数与作用域def里藏着的基础语法陷阱4.1 可变默认参数魔鬼藏在def定义那一刻如果让我只推荐一个初学者必须提前知道的坑我会选可变默认参数。这个坑特别阴险因为语法本身完全合法结果却完全不符合直觉。看这段代码def add_item(item, lst[]): lst.append(item) return lst print(add_item(1)) # [1] print(add_item(2)) # [1, 2]而不是预想的[2]为什么第二次调用拿到的不是[2]因为默认参数lst[]在函数定义执行的那一刻只被求值一次生成的这个列表对象被永久绑定在函数上。之后每次调用不传lst参数用的都是同一个列表。第二次调用时这个列表里还留着第一次的1于是追加2之后变成了[1, 2]。这是Python与很多语言不同的地方默认值不是每次调用时重新生成而是定义时固化。要修复也很简单惯用方案是用None占位函数内部再创建新列表def add_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst同样的问题也出现在默认参数用字典、集合时。凡是在函数定义里写可变对象作默认值的几乎都是隐患。我自己的规则是默认参数一律用None或者不可变对象函数内部再根据None执行初始化逻辑。这个习惯换来的安稳远大于多写一行if的代价。4.2 LEGB作用域与global/nonlocal的边界Python里的变量作用域可以用一个口诀记LEGB从里到外分别是Local当前函数局部、Enclosing外层嵌套函数的局部、Global模块全局、Built-in内建名字空间。查找变量时按这个顺序从内往外找找到就用。这个规则本身不难难的是理解赋值这个动作会改变变量的归类。下面这个代码是个典型陷阱x 10 def func(): print(x) x 5 func()运行会抛UnboundLocalError而不是打印10。原因是Python编译函数体时发现x出现在赋值语句的左边于是把x标记为func的局部变量。既然它是局部变量print(x)时它还没有被赋值就直接报使用未绑定的局部变量。这个报错很反直觉因为它和你在函数里读外部全局变量的直觉冲突。唯一的修复方式是明确告诉解释器x是全局的在函数里加一句global x。同理嵌套函数里想修改外层函数的变量需要在内存函数里声明nonlocal。global处理模块级变量nonlocal处理外层函数变量两者边界分明用混了就会报SyntaxError或作用域错乱。我建议的实践是尽量少靠global变量传数据。函数应该是输入参数、输出返回值全局变量是必要的恶不是常规工具。如果某个值确实需要在多个函数间共享可以考虑把它作为参数显式传递或者打包成一个类、一个数据对象来管理都比默默依赖全局量好排查得多。4.3 闭包与lambda的延迟绑定你以为捕获的是值其实是变量闭包是函数式编程的基础概念Python里也非常常见。简单说闭包就是内层函数引用了外层函数的变量。这个引用是有生命力的外层函数执行结束后被引用的变量并不会消失而是被内层函数记住了。但这个记住的机制藏着一个老牌陷阱。functions [] for i in range(3): def f(): return i functions.append(f) for f in functions: print(f()) # 输出 2 2 2而不是 0 1 2很多同学预期输出0 1 2因为看起来每次循环都在f里捕获了当前i的值。但实际上闭包捕获的是变量i本身不是i那一刻的值。整个循环结束后i的最终值是2三个f去读取i时读到的自然都是2。lambda的捕获规则也完全一样所以lambda x: x * i里的i同样有这个坑。解决方案有三条路。第一利用默认参数在定义时求值的特性把当前值冻结进去def f(ii): return i。第二使用函数工厂在中间层用不可变参数把值传进去。第三用functools.partial绑定参数。如果你只想在循环里立刻调用函数而不是推迟到循环结束后那么老老实实用普通函数调用就行没必要制造闭包。这个坑的核心教训其实还是那句Python中很多结合都是基于变量名而不是基于变量值快照理解这一点闭包和lambda才敢放心用。5. 异常与上下文管理让程序体面地失败5.1 try/except/else/finally的执行顺序与取舍很多初学者写异常处理只会写try和except甚至习惯用一大段try包住所有代码。这样做不是不行但很容易掩盖错误、丢失线索。一个完整的异常处理块Python给了四个关键字顺序是try、except、else、finally。它们的执行逻辑是先跑try里的语句如果抛出异常跳到对应的except处理如果没有异常try跑完后执行else无论有没有异常finally在最后一定执行。else的用处是让正常流程和异常流程彻底分开。比如你读取并解析配置文件读文件、解析那些关键语句放在try里解析成功后的业务逻辑放else里异常处理放在except里。这样可以避免在try里写完正常逻辑后因为异常被捕获而分不清到底哪一步成功了。finally则适合做清理工作不管是成功还是失败比如关闭数据库连接、释放锁、删除临时文件。注意finally里的代码即使遇到return也会先执行这是边界情况我建议不要在finally里写return容易让函数返回值变得晦涩。异常捕获的范围也要控制。我见过有人写except Exception: pass把错误静默吞掉这比直接崩溃更糟糕——程序看起来正常数据可能已经是错的。更不推荐写裸的except:因为它会把KeyboardInterrupt、SystemExit这类本该让程序退出的异常也一起吞掉。正规做法是用except SomeSpecificError:精确捕获你知道怎么处理的异常或者用except Exception:记录下来再重新抛出或在日志里留痕。判断要不要捕获的依据很简单你能从这个异常里恢复吗不能就让它向上抛让上层决定能就只捕获这一种别殃及池鱼。5.2 自定义异常与异常链别让错误信息断掉线索业务代码里把所有错误都抛成ValueError或RuntimeError调用方很难区分参数不合法和状态已损坏。Python3允许你定义自己的异常类方式就是继承Exceptionclass ConfigError(Exception): 配置相关错误 class UserNotExistError(Exception): 用户不存在自定义异常的价值在于把错误语义变成类型调用方可以针对特定类型做不同处理。你可能会问自定义一个空的异常类能有什么用类型本身就是信息。在判断异常时isinstance可以同时匹配自定义异常和它的父类方便你做层级化设计。再进一步异常链是Python3里一个被低估的语法。在except块里抛出新的异常时原始异常如果就这么丢了上游只看到新异常排查时线索就断了。正确写法是raise NewError(...) from original_exception或者raise NewError(...) from None显式表示不需要保留原来的上下文。如果你不加from解释器也会在异常上下文里带上__context__但用from明确写出来后维护者一眼就能明白因果关系。我自己的习惯是在底层捕获到具体异常后往上抛一个语义更丰富的自定义异常并用from cause把原始异常带上。这样错误信息既有业务含义又不丢失技术细节日志里看起来一目了然。5.3 with语句与上下文管理器资源管理的优雅写法资源管理是新手最容易忽视、老手最看重的部分。如果不用任何语法操作完文件你得记得f.close()忘了就泄漏文件句柄更麻烦的是处理过程中一旦抛出异常close这行代码根本没机会执行。传统做法是try/finally包一层虽然能保证关闭但写法笨重。Python3提供的with语句把这件事变成了规范动作with open(data.txt, r, encodingutf-8) as f: content f.read() # 不管with块里是否抛出异常文件都会自动关闭with后面跟的对象必须是一个上下文管理器也就是实现了__enter__和__exit__两个方法的对象。进入with时调用__enter__把返回值绑定到as后面的变量无论正常结束还是异常结束都会调用__exit__。如果你想自定义一个上下文管理器最常见的快捷方式是标准库contextlib里的contextmanager装饰器from contextlib import contextmanager contextmanager def timer(): start time.time() try: yield finally: print(f耗时: {time.time() - start:.4f}s) with timer(): # 这里做一些耗时操作 time.sleep(0.5)装饰器版本的好处是把__enter__/exit__样板逻辑压缩成一个生成器yield前面的语句相当于__enteryield后面的相当于__exit__非常符合直觉。with的典型应用场景包括文件、数据库连接、线程锁、临时目录、计时、事务边界等。记住一个原则所有用完必须释放的资源都应该通过with管理不要指望程序员记得写close。这就是Python3基础语法里看着最不起眼、实则最能降低事故率的一条。6. 个人固定动作几个我一直在用的Python3写法与习惯6.1 三个可以无脑复制的常见代码模板回到实践层面分享三个我平时写Python3几乎天天在用的模板都是踩过坑之后固定下来的。第一个是安全读文件。文件路径、编码都显式写出来配合with自动关闭再针对不存在的文件做异常处理import json from pathlib import Path def load_json(path: Path): try: with open(path, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: # 场景不同处理方式不同这里先返回空字典 return {} except json.JSONDecodeError as e: raise ValueError(f文件 {path} 不是合法JSON) from e第二个是处理可能不存在的字典键。直接d[key]遇到缺失会抛KeyError写try/except又嫌啰嗦。我一般用d.get(key)拿默认值或者setdefault做没有就初始化的原子操作counter {} counter[count] counter.get(count, 0) 1 # 或者若键不存在先设置为[]再append groups {} groups.setdefault(users, []).append(某开发者)第三个是遍历文件目录。用pathlib.Path替代自己拼接字符串路径代码干净很多from pathlib import Path for p in Path(src).rglob(*.py): if p.name.startswith(test): continue print(p.relative_to(src))pathlib是Python3很值得投入力量学习的标准库它的Path对象几乎把所有路径操作都变成了方法比如读取文本用p.read_text()写回用p.write_text()。这些方法自动处理编码和关闭省心程度很高。6.2 平时写Python3我坚持的几个习惯最后讲几个我自己的习惯不一定适合所有人但对保持代码健康很有帮助。一是善用类型注解。虽然Python解释器不强制但类型注解本身就是文档。团队里配上mypy做静态检查之后很多低级错误在提交前就被拦住了排查成本远低于运行时才暴露的TypeError。_二是保持函数短小。如果一个函数超过十行我总会回头审视能否拆开。Python的缩进规则天然鼓励你写短函数长函数嵌套层数一多缩进直接把你压垮。三是警惕隐藏的共享状态包括可变默认参数、模块级列表、闭包捕获的循环变量只要看到多个调用会修改同一个对象的迹象就停下来想清楚归属问题。四是代码写完之后回过头来删掉所有不必要的else。很多分支逻辑用提前return改写后会更平直比如先检查异常情况直接return后面放正常逻辑。这样缩进少一层阅读负担轻不少。Python3基础语法最大的福利是它把你需要管理的细节降到了一个舒服的层级。代价是你得理解它背后的对象模型和作用域规则否则很多简洁的写法反而会变成陷阱。把本文涉及的这些点真正弄明白再去读别人那些漂亮的开源项目代码你会发现每个简洁写法背后都是同一套原理。