1. 大括号在Python里的“身份识别”
先问一个问题:你第一次写Python的时候,看到{},脑子里第一反应是什么?
如果是从C、Java或者JavaScript转过来的,十有八九会觉得这是代码块——就是包住函数体、循环体、条件分支的那层皮。写Python爬虫时我在某公司带过几个新人,几乎每个从Java转过来的小伙伴第一天都会在编辑器里敲出类似的代码:
if x > 0 { print("positive") }然后运行,报SyntaxError,一脸懵。
这就是{}在Python里面最让人混淆的地方:它在Python里根本不作代码块标记。Python用缩进表达代码层次,用冒号:开启一个块。而{}只有一个身份——构造数据结构的字面量语法。说得再直白一点,看到{},你应该条件反射地想到两种东西:字典(dict)和集合(set)。
1.1 它不是代码块,而是数据结构
我整理了一张对照表,方便你从其他语言切过来时快速建立心智模型:
| 语言 | {}的含义 | 在Python中对应的替代方案 |
|---|---|---|
| C/Java | 代码块 | 冒号:+ 缩进 |
| C/Java | 数组初始化(一部分场景) | 列表[]或list() |
| JavaScript | 对象字面量 | 字典{}(近似对应) |
| JavaScript | 代码块 | 冒号:+ 缩进 |
| Python | 字典字面量 | 就是它自己 |
| Python | 集合字面量 | 就是它自己 |
换句话说,在Python里,你见到{}应该立刻想到“这里正在创造一只容器”,而不是“这里开始了一段逻辑”。
举例来说:
d = {"name": "张三", "age": 30} # 这是一个字典 s = {"苹果", "香蕉", "橙子"} # 这是一个集合 empty = {} # 这是一个空字典,注意这一点empty = {}是无数人踩过的一个坑,因为从字面上你很难分辨它到底想表达“空字典”还是“空集合”。答案固定:{}创建的是空字典,空集合你必须老老实实写set()。后面我单独有一节专门讲这个,这里先埋个伏笔。
1.2 为什么Python偏偏不拿大括号当代码块
要理解这个问题,得知道Python在设计哲学上的一个坚持:可读性优先。有其他语言的开发者吐槽过Python缩进敏感很烦,但反过来想,缩进是唯一的层次标识,这意味着所有Python代码长出来都带着统一的“骨架”,不管是谁写的,一眼扫过去就能看懂嵌套深度。
如果{}承担了代码块的职责,它就得兼职做数据结构的字面量,那{"a": 1}这种代码就会产生语法上的歧义——你到底是创建字典,还是开一个命名不存在的代码块?Python选择彻底切断这条路,把{}只留给数据结构。所以,你如果有“大括号包代码”的习惯,到了Python这里,先改掉,用缩进和你自己的审美来组织逻辑。
1.3 从C系语言转过来最容易踩的误区
第一个误区,就是刚才说的拿{}包逻辑块,这个报错还算显眼,SyntaxError一出来你基本能定位到。真正隐蔽的是第二个误区:写多行字典时,最后一个元素后面加了逗号,这个在Python里完全合法:
config = { "host": "127.0.0.1", "port": 8080, }这个尾逗号在Python里是鼓励的,方便以后加字段不产生diff噪音。但是在JavaScript或者C里,有些编译器会警告。所以你如果带着旧习惯来看这段代码,可能觉得“这是不是写错了”,其实这是Python的风格偏好。
第三个误区,嵌套数据结构的读写习惯。在Java里取一个嵌套Map的值,得一层层get(),在Python里你直接用[]套[]就行:
data = {"user": {"address": {"city": "上海"}}} city = data["user"]["address"]["city"]简单粗暴。如果中间某个键不存在,会直接抛KeyError,不像Java返回null。这个差异也经常让转语言的人蒙圈。怎么优雅地处理这种问题,我在字典那节详细说。
2. 字典:大括号最常见的舞台
Python里{}出现频率最高的场景,就是字典。字典的基础概念很好理解:一种“键-值”映射结构。你要是用过Java的HashMap,或者JavaScript的对象,Python的字典对他们来说就是换了身衣服。
2.1 字典的创建和基本读写
创建字典,一般有三种方式:
# 方式一:字面量(最常用) d = {"name": "李四", "age": 25} # 方式二:从键值参数创建 d = dict(name="李四", age=25) # 方式三:从可迭代对象创建 pairs = [("name", "李四"), ("age", 25)] d = dict(pairs)日常开发里,方式一用得最多,因为它直观、可读性最好。方式二适合临时组装一小撮数据,但键必须是合法的标识符,不能带空格或特殊字符。方式三你大概率用不太到,但面试里可能会被问到。
读操作:
d["name"] # '李四',键不存在会抛KeyError d.get("name") # '李四',键不存在返回None,不报错 d.get("email", "未填写") # 可以指定默认值我个人在写业务代码时,get()用得比[]多,因为很多场景是“这个字段可能有也可能没有”,用get()加默认值能省掉一堆try/except。
写操作和删除:
d["age"] = 26 # 更新已有键 d["city"] = "北京" # 新增键 d.pop("age") # 删除并返回该键的值 del d["city"] # 删除但不返回 d.update({"hobby": "coding"}) # 批量更新或新增2.2 常用方法和几个容易踩的坑
字典的方法不少,但实际高频用到的主要是keys()、values()、items()。遍历的时候:
for key in d: print(key) # 只取键 for value in d.values(): print(value) # 只取值 for k, v in d.items(): print(k, v) # 同时取键和值,注意items()返回的是视图对象踩过的坑里,我想重点说三个:
第一个坑是在遍历字典的同时修改字典。比如你想把字典里所有值大于10的键删掉,新手最容易写成这样:
d = {"a": 5, "b": 15, "c": 20} for k in d: if d[k] > 10: del d[k]这段代码运行时大概率会抛RuntimeError: dictionary changed size during iteration。因为遍历过程中字典的长度变化了,Python为了保护迭代器的一致性直接罢工。正确做法是把要删的键先收集到一个列表里,遍历完之后再删:
to_delete = [] for k in d: if d[k] > 10: to_delete.append(k) for k in to_delete: del d[k]或者用字典推导式一行搞定(下一节详细讲)。
第二个坑是键的类型必须是可哈希的。说白了就是字典要根据键计算哈希值来定位存储位置,所以键只能是不可变类型:字符串、数字、元组都可以;列表、字典这些可变类型不能当键。有人觉得“我把列表转成字符串存进去不就行了吗”,可以,但那不是同一个问题了。记住一条经验法则:你在位运算层面都不能修改的东西,才适合当键。元组里如果嵌了列表,那这个元组也不能当键。
第三个坑是嵌套字典的默认值问题。比如统计一段文本里每个单词在每句话里出现的次数,你需要{word: {sentence_id: count}}这种结构。新手容易这么写:
if word not in stats: stats[word] = {} stats[word][sent_id] = stats[word].get(sent_id, 0) + 1这个写法能用,但啰嗦。更Pythonic的方式是用collections.defaultdict:
from collections import defaultdict stats = defaultdict(lambda: defaultdict(int)) stats[word][sent_id] += 1你看,代码量直接砍半,而且逻辑更清楚了。defaultdict的好处是访问不存在的键时,会自动用你给的工厂函数创建默认值并写进字典,不会抛KeyError。
2.3 字典推导式的威力
列表推导式大家见得多了,字典推导式同样好用。基本语法是{键表达式: 值表达式 for 元素 in 可迭代对象}。
前面的“删除值大于10的键”用推导式一行搞定:
d = {"a": 5, "b": 15, "c": 20} d = {k: v for k, v in d.items() if v <= 10}再举一个实际场景:把两个列表配对成字典。
names = ["张伟", "李娜", "王芳"] ages = [28, 32, 25] people = {name: age for name, age in zip(names, ages)}还有更骚的操作,比如用字典推导式翻转键值:
d = {"a": 1, "b": 2, "c": 3} reversed_d = {v: k for k, v in d.items()}这个看起来很爽,但要小心:如果原字典有两个相同的值,翻转之后后面的键会覆盖前面的键。比如{"a": 1, "b": 1}翻转后得到{1: "b"},"a"就丢了。所以翻转前最好确认值唯一。
字典推导式的本质是构造一个全新的字典,所以旧字典不会被修改。如果你需要原地修改,还是老老实实用循环加删除,或者干脆重新赋值。
3. 集合:另一个被忽略的大括号使用者
如果说字典是大伙都知道的“正宫”,集合就是大括号语法里最容易被忽略的第二主角。集合的概念简单到一句话就能讲完:一大堆互不相同的元素,塞进同一个容器。元素之间没有顺序,也没有键值对,就是干巴巴的一堆值。
3.1 集合的创建与去重
创建集合用大括号,元素用逗号隔开:
s = {1, 2, 3, 4}最常见的实战场景是去重。我在做数据清洗的时候,经常面对一堆可能有重复的ID,直接转成集合再转回列表,重复项就没了:
ids = [101, 102, 103, 101, 104, 102] unique_ids = list(set(ids))这个过程可以理解为:集合天生不允许元素重复,往里塞元素的时候,如果有重复的,会被自动忽略。你没有写任何判断逻辑,重复数据就被过滤掉了。这个代码跑起来很快,因为集合底层是哈希表,判断重复的成本极低。
你可能会问,那我把数据从字典的keys()转成集合行不行?完全行,而且这也是一种常见的去重思路。比如你需要对比两个字典里有哪些相同的键:
dict_a = {"x": 1, "y": 2, "z": 3} dict_b = {"y": 4, "z": 5, "w": 6} same_keys = set(dict_a.keys()) & set(dict_b.keys())这一段就自然引出了集合的另一个优势——集合运算。
3.2 集合运算:交集、并集、差集
集合的运算逻辑可以直接对应到集合论的概念,语法也很直观:
| 运算 | 符号 | 方法 | 说明 |
|---|---|---|---|
| 并集 | | | union() | 出现在任一集合中的元素 |
| 交集 | & | intersection() | 同时出现在两个集合中的元素 |
| 差集 | - | difference() | 属于A但不属于B的元素 |
| 对称差 | ^ | symmetric_difference() | 只出现在其中一个集合的元素 |
实战中我经常用差集来对比配置。比如线上环境的白名单IP和测试环境的白名单IP,想知道哪些IP只在测试环境里出现过:
online = {"192.168.1.1", "192.168.1.2", "10.0.0.1"} test = {"192.168.1.2", "10.0.0.1", "172.16.0.8"} only_test = test - online print(only_test) # {'172.16.0.8'}还有一个场景:判断一个列表里有没有重复元素?不用写for循环,比较集合和原列表的长度就行:
has_duplicates = len(items) != len(set(items))这个判断看着简单,但特别实用。我写过很多数据校验的脚本,这句代码是常客。
3.3 空集合的黄金陷阱
这节我必须单独拿出来讲,因为我见过太多人在这里翻车。
在Python里,你想创建一个“空集合”,直觉会写:
s = {}运行没问题,但s是空字典,不是空集合。检查类型的话:
print(type(s)) # <class 'dict'>这就是我前面说的“{}创建的是空字典”的真正含义。创建一个真正的空集合,只有一条路:
s = set()这个坑藏得非常深,因为代码不报错,类型也不会在你眼前直接暴露,只有当你后续用s.add(1)往里塞元素时,字典和集合的行为看起来还挺像。但如果你做了{1, 2} - s这种运算,或者试图把s当成集合传给别人,问题才会浮出水面。
还有一种更隐蔽的情况:如果你想判断一个集合是不是空集:
if not s: # 无论s是空字典还是空集合,都会走到这里,但之后行为不同 pass所以,如果你在写一个函数,参数可以是字典也可以是集合,一定要在入口处明确类型。我的习惯是:凡是涉及“空”的初始化,一律问自己一句,“我到底要的是哪个容器”。
4. f-string中的大括号:格式化输出的秘密
到了Python 3.6版本以后,大括号在字符串格式化里又有了一个新角色——f-string占位符。很多人学到这里时有个疑惑:“我字符串里写个{},为什么有时候能显示数字,有时候就报错?”这一节把这个问题彻底讲透。
4.1 基础用法:占位符和表达式求值
f-string用f前缀开头,普通文本里的{}会被解释成“在这里插入一个变量的值”:
name = "赵敏" age = 28 print(f"姓名:{name},年龄:{age}") # 输出:姓名:赵敏,年龄:28关键在于,{}里面不只是放变量名,它可以把任何合法的Python表达式丢进去直接求值。这就比老式的%格式化和format()方法灵活得多:
price = 19.99 quantity = 3 print(f"总价:{price * quantity:.2f} 元") # 输出:总价:59.97 元甚至可以在里面调用函数:
def double(x): return x * 2 print(f"翻倍结果:{double(5)}")我当初从format()切到f-string之后,最直观的感受是写日志的代码变短了。以前得先算好值再拼接,现在直接把表达式甩进去就行。调试时打印变量也很方便,可以这样:
print(f"{key = }") # 输出:key = 'name'这个语法是Python 3.8之后支持的,调试时特别爽,不用自己写前面那串变量名=。
4.2 嵌套大括号与大括号转义:怎么输出“{}”字面量
有个场景你写多了就会发现:想在字符串里输出一个真的大括号,比如你想打印JSON样例{"status": 0},或者生成一些模板字符串。
如果你直接写:
print(f"结果:{"status": 0}")一定会被语法错误打断。因为{}在f-string里是保留符号,它默认你要插值。想输出字面量的大括号,必须用双大括号转义:
print(f"结果:{{'status': 0}}") # 输出:结果:{'status': 0}注意两点:第一,里面的引号要用单引号,否则会和f-string外层的引号冲突;第二,转义是{{和}},不是反斜杠。有些语言用反斜杠转义,Python的f-string里反斜杠在很多版本里有限制,双大括号是唯一正道。
还有一种嵌套场景——f-string里套字典,同时要对字典内容做格式化。比如:
user_info = {"name": "钱七", "score": 99.5} # 你想输出:钱七得了99.5分 print(f"用户{user_info['name']}得了{user_info['score']}分")这种写法里{}内部又出现了一对引号,很容易写错。我的习惯是:如果嵌套超过两层,先把要用的值取出来赋给一个临时变量,再在f-string里引用。这样代码好读,也不容易在引号上翻车:
name = user_info["name"] score = user_info["score"] print(f"用户{name}得了{score}分")4.3 格式化控制符:对齐、填充、精度
f-string最常见的格式化是数字和字符串的展示控制。这里列几个我真心觉得用得上的:
| 场景 | 写法 | 示例 |
|---|---|---|
| 保留两位小数 | {value:.2f} | {3.14159:.2f}→'3.14' |
| 百分数 | {value:.1%} | {0.856:.1%}→'85.6%' |
| 左对齐占10位 | {value:<10} | {"abc":<10}→'abc ' |
| 右对齐占10位 | {value:>10} | {"abc":>10}→' abc' |
| 居中占10位 | {value:^10} | {"abc":^10}→' abc ' |
| 千位分隔 | {value:,} | {1234567:,}→'1,234,567' |
组合使用也可以,比如对齐加宽度加千位分隔:
print(f"{1234567:>15,.2f}") # 输出: 1,234,567.00有人会问:这些格式控制在面试里真的考吗?会考,但更实际的价值在于生成报表、日志对齐、输出表格这种日常活。你能想象打印一个带表头的统计报告,如果没有对齐控制,所有数字挤在一起有多难看吗?f-string的格式控制是最轻量级的排版工具,不需要引入任何第三方库。
5. 大括号在代码技巧里的几个野路子
除了字典、集合和f-string,大括号还有几种存在感比较低的玩法。这些玩法不一定要求你天天用,但关键时刻能让你少写很多行代码。
5.1 解包与合并:{}在函数传参中的特技
你可能见过这么一种写法:
def connect(host, port, timeout=30): print(host, port, timeout) params = {"host": "127.0.0.1", "port": 5432, "timeout": 60} connect(**params)这里的**params就是把字典解包成关键字参数传给函数。字典里每个键对应参数名,值对应参数值。这个技巧在调用那些“参数特别多”的函数时特别好用,尤其是当你有一堆配置项需要动态传给某个接口时。
反过来,函数定义的时候也可以用**kwargs收集多余的关键字参数:
def log(level, **kwargs): print(level, kwargs)调用log("ERROR", msg="出错了", code=500)时,kwargs会得到一个字典{"msg": "出错了", "code": 500}。
那“合并两个字典”又是怎么回事?你可能有这三个选择:
a = {"x": 1} b = {"y": 2} # 方式一:update merged = a.copy() merged.update(b) # 方式二:双星号解包(Python 3.5+) merged = {**a, **b} # 方式三:| 运算符(Python 3.9+) merged = a | b{**a, **b}是我写脚本时最喜欢的写法,因为它表达式化——它可以出现在任何需要字典的地方,而不只是一条独立的语句。比如你要给函数传一个合并后的字典参数,方式一得多写三行,方式二直接写进参数位置:
func({**defaults, **user_input})而且注意,这种合并如果需要后者覆盖前者,顺序就是{**defaults, **user_input},后面的键值覆盖前面同名的。
5.2 JSON与Python字典的互转
现在做前后端接口对接,字典和JSON基本是无缝衔接的。字典的写法{"name": "张三"}和JSON的写法{"name": "张三"}几乎是同一套语法,唯一的差别是JSON里键必须是字符串,Python字典的键可以是数字、元组等。
我用json模块做转换的次数多了之后,总结出三个高频场景:
import json # 1. 从JSON字符串解析成Python字典 data = json.loads('{"name": "张三", "age": 30}') # 2. 从Python字典序列化成JSON字符串 text = json.dumps({"name": "张三", "age": 30}, ensure_ascii=False) # 3. 从文件读JSON / 写JSON with open("config.json", "r", encoding="utf-8") as f: config = json.load(f) with open("output.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)这里有个非常容易踩的坑:如果不加ensure_ascii=False,中文会被转存成\uXXXX的转义序列。比如"张三"会变成"\u5f20\u4e09",文件里看起来非常吓人,而且别人打开后没法直接读。第一次遇到这事的人基本都会怀疑自己是不是哪里编码写错了。解决办法就是我上面写的,把ensure_ascii显式设成False,这样中文就会以原文写进文件。
另一个跟大括号直接相关的问题是,JSON里的大括号一定要严格配对。有些配置文件是手写的,多了一个逗号或者漏了一个},json.load()会直接抛JSONDecodeError。排查这种错误有个土办法:把你的JSON字符串复制到编辑器的括号匹配功能里,一看就定位到哪一层没配对。
5.3 函数默认参数里的“空字典陷阱”
这节聊一个面试中老爱问、实际中也老坑人的问题:函数的默认参数不能直接用可变对象(比如字典或列表)。
新手写代码时,经常想实现一个“累积记录”的效果:
def add_record(new_item, storage={}): storage[new_item] = True return storage第一次调用add_record("apple"),返回{"apple": True}。第二次调用add_record("banana"),你可能以为会得到{"banana": True},但实际返回{"apple": True, "banana": True}。
原因在于:函数定义时{}只被创建了一次,所有对这个函数的调用共享同一个默认字典。这不是你想要的“每次调用都初始化一个空字典”。正确的写法是:
def add_record(new_item, storage=None): if storage is None: storage = {} storage[new_item] = True return storage有人觉得“我按这个写法要多写两行,很麻烦”,但这就是Python中引以为戒的典型陷阱之一。以后凡是默认参数涉及到字典、列表、集合、自定义类对象,一律用None做默认值,在函数体内再初始化为具体容器。这个习惯能帮你避免大量诡异的“数据串台”问题。
6. 常见问题速查与排查心得
把我在各种项目里实际碰到过的问题汇总一下,给出一张速查表。这份表算是我这么久“趟雷”之后攒下来的一个索引型笔记,你可以直接把它贴在键盘边上,遇到对应报错先来查一遍。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
SyntaxError: invalid syntax且代码块用了{} | 把{}当代码块用了 | 改成冒号+缩进 |
KeyError: 'xxx' | 访问字典不存在的键 | 用get()或检查in |
RuntimeError: dictionary changed size during iteration | 遍历字典时增删元素 | 先收集要改的键,再统一修改 |
TypeError: unhashable type: 'list' | 用列表做字典的键 | 改成元组等不可变类型 |
中文在JSON里变\uXXXX | 没设置ensure_ascii=False | 设置ensure_ascii=False |
| 空集合操作总感觉不对 | {}创建的是空字典 | 用set()创建空集合 |
| 函数默认字典被多次调用共享 | 默认参数用了{} | 改成None,函数体内初始化 |
f-string无法输出{} | 没转义 | 用{{和}} |
JSONDecodeError | JSON文本格式不合法 | 用编辑器括号匹配定位问题 |
6.1 排查思路分享:报错信息不会骗人
很多初学者看到报错第一反应是“出问题了,好慌”,我的建议恰恰相反——报错信息是Python在帮你指路。先读最关键的一行:File "xxx.py", line N,它会告诉你出错的精确行号和第几个符号。比如SyntaxError: invalid syntax配一个指向第八行末尾的箭头,那你第一件事应该是去看那行代码里有没有不匹配的大括号。
如果你用了IDE或者编辑器,大括号的高亮和括号匹配功能一定要打开。写字典嵌套多的时候,我习惯先在{}配对完整的情况下先写外层骨架,再往里填内容:
config = { "database": { "host": "...", "port": 3306, "options": { "charset": "utf8mb4" } } }这样一层一层对着括号缩进写,基本不会写错。
6.2 一段兜底技巧:用真实数据验证容器类型
有时候你拿到外部传入的数据,不确定它到底是字典还是集合还是一些奇怪类型,与其靠猜,不如直接打印类型。调试代码时我常临时加一行:
print(type(data), data)看到<class 'dict'>就知道后面可以安心用data["key"],看到<class 'set'>就知道不能用[]取值。
如果你是在写一个对其他人开放的接口,担心别人传错类型,可以加一层类型判断:
if not isinstance(data, dict): raise TypeError(f"期望dict,实际收到{type(data)}")这个写法看着啰嗦,但在数据处理脚本里能省掉很多“跑一半才发现类型不对”的尴尬。尤其当一段脚本要处理几十个字段时,前期一个类型校验能让你少调试半小时。
6.3 我自己实战里的三条心得
第一,能用字典推导式尽量用推导式,但别强行用。推导式的核心价值是“把一个容器变换成另一个容器”,适合过滤、映射、翻转这种逻辑清晰的操作。如果你的变换逻辑超过三步,或者中间需要复杂的条件分支,那拆成普通的for循环反而更好读,不用为了“秀操作”把代码写成谜。
第二,f-string虽然好用,但别在里面写太长的表达式。我见过有人在一个{}里写了40多字符的嵌套调用加三目运算,可读性差到极点。遇到这种情况,先在外面把结果算好,再放进f-string里格式化。调试着也方便,你能单独打印每一步的中间值。
第三,最容易在大括号上栽跟头的,其实是“手写结构化文本”的瞬间——不管是JSON、配置文件模板,还是SQL里的IN子句生成。因为你对数据结构的直觉是“大括号就是容器”,但输出目标要求的是“字符必须合法”。这种情况下,我的建议是:拿不准时,把生成结果打出来或者写到临时文件里,再让程序去解析它。格式化输出配合解析验证,是一条能兜住你的安全线。
说到底,Python的大括号{}本身不复杂,复杂的是它被塞进了四种完全不同的语境:字典、集合、f-string插值和函数传参解包。你只要心里有这张“身份地址”,看到{}先问一句“我现在的场景是哪个”,接下来的一切都好办了。