🐍 第7章 保存文件:纯文本、CSV 与 Google 试算表
📄 一句话概括

列表按下标取,第三位是啥得自己记。字典按键取,rec['品名'] 一眼就知道拿的是什么。 方括号取不到会抛 KeyError,get 取不到给 None 或你给的默认值。 字段可能缺的时候一律用 get。 真实数据几乎都是「字典装字段,里面再套字典或列表」。JSON、接口返回、配置文件,全是这个形状。

📝 本章和前面几章的关系

前六章抓到的数据都散在变量里,程序一关就没了。这一章开始存。 教材第 7 章开头两节是【使用字典保存结构化数据】和【在本机电脑保存数据】。 顺序是:先定「一条记录长什么样」(字典),再定「一批记录怎么落盘」(JSON、CSV)。 本篇末尾那个 8500 对 4542 是实测的,它说明一件事:同一样数据,写法不同体积差近一倍。

一、列表的麻烦:第三位是啥

用列表存一条货:['六角螺栓 M8', 120, 1.5]。 取第三个写 x[2]——一个月后你自己都不记得 2 是单价还是数量。 字典解决的就是这件事:{'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}, 取的时候写 rec['单价']。 这一行代码自己说明了意图。 读代码的人不用回头翻「当初第几位放的是什么」。

字典的键必须是不可变类型(第 4.3 篇那张表),所以字符串、数字、元组能当键,列表不行。 实际用起来几乎全是字符串。

二、按键取,还是用 get

rec['规格'] 取一个不存在的键,直接抛 KeyError: '规格',退出码非 0。 rec.get('规格') 取不到给 None;rec.get('规格', '未填') 取不到给你指定的默认值。 字段可能缺的时候一律用 get,别赌它一定在。 尤其是数据来自别人、来自网页、 来自用户输入的时候。

什么时候可以用方括号?你自己刚写进去的键,确定在。 那种情况用方括号更直接,而且真缺了就该炸——说明你的逻辑错了。 这两种行为的差别,跟第 3.1 篇列表「取值越界报错、切片越界不报错」是一个道理: 「我就要这个」和「有就给我」是两种语义,得用两种写法。

三、加、改、删

给一个不存在的键赋值就是加,给已有的键赋值就是改。字典不区分这两种动作,看键在不在。 演示里加「规格」和改「数量」用的是同一句 rec['规格'] = ...。 所以「我不小心把已有字段覆盖了」这种 bug 不会报错,只会悄悄发生。 删除用 pop(键),它把值还给你;也有 del rec[键],那个不给返回值。 判断某栏在不在,用 '单价' in rec,别用 try。

in 判断的是键,不是值。'六角螺栓 M8' in rec 是 False——哪怕它确实是某个键的值。 要查值得写 '六角螺栓 M8' in rec.values()。

四、走一遍字典

for k in rec 拿到的是键,不是键值对。这一点跟列表不一样,第一次用容易愣住。

写法 给你什么
for k in rec 键(不是键值对)
for k, v in rec.items() 键和值
rec.keys() 所有键
rec.values() 所有值

顺序是插入的顺序。从 Python 3.7 起这一点是有保证的,可以依赖。 要按键排序走一遍,先 sorted(rec)。 要按值排,sorted(rec.items(), key=lambda kv: kv[1])——就是第 5.3 篇那个 key 的用法。

五、一层套一层:一条订单

一条真订单长这样:单号、客户(名称、联系人)、明细(好几行,每行是品名、数量、单价)。 它不是「一条记录」,是「一条记录里套着别的记录」。 字典装字段,字段的值可以是另一个字典, 也可以是列表。 演示第四段打出来:单号 PO-20261102-001、客户名、第一条明细、明细条数 2、合计数量 200。

真实数据几乎都是这个形状。 JSON、HTTP 接口的返回、配置文件、第 8 章要讲的 XML,全是「字典套列表套字典」。 理解了这个形状,后面很多事就顺了:「取深层的一个值」就是把下标和键一层层串起来, order['客户']['名称']、order['明细'][0]['品名']。

六、存成文件再读回来

json.dumps(对象) 把字典变成字符串,json.loads(字符串) 变回来。 带 s 的两个处理字符串,不带 s 的 dump/load 直接处理文件。 演示里写出去 266 字节,读回来单号对得上、明细条数还是 2。这一步是「程序关掉数据还在」的开始。 有两个参数必须知道。 ensure_ascii=False——不加它,中文会变成 \u4e2d 这样的转义,文件能读但人看不懂。

indent=2 是给人看的排版,一行一层缩进,文件会大一些。 给机器读的接口数据不用 indent,省下来的都是带宽。 还有一个坑:json.dumps 碰到 datetime 对象会抛 TypeError,因为 JSON 标准里没有日期类型。 要么先 str() 转成字符串,要么传 default=str 让它自己转。第 8 章会细讲。

七、一百条记录,键名重复了几遍

存法 字节数 每条多少
每行一个字典 8500 85.0
表头 + 一行一个数组 4542 45.4

差 3958 字节,全是重复的键名。 一百条记录,同一批键名被写了 100 遍。 这就是列式存储的思路:同一列的键只存一次。 数据量小的时候不用管,上万条之后这个差别很可观。 代价是可读性。表头加数组那种存法,少一行就整列错位,人也看不出第三列是数量还是单价。 自己程序内部传可以这么压,给别人看别这么干。

顺带一提:这种「表头只写一次」的形状,就是下一篇 CSV 的形状。CSV 之所以比 JSON 小,原因就在这里。

八、动手 Demo:字典:一条记录该长什么样

先看按键取和 get 的差别,再走一遍增删改。 然后套一层,做一条带明细的真订单。 最后把同一批数据用两种写法存一遍,比字节数。

import json

print('① 键值对,按键取')
rec = {'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}
print('  rec =', rec)
print("  rec['品名']            =", rec['品名'])
print("  rec.get('规格')        =", rec.get('规格'))
print("  rec.get('规格', '未填')  =", rec.get('规格', '未填'))
try:
    rec['规格']
except KeyError as e:
    print('  rec[\'规格\']            → KeyError:', e)

print('② 加、改、删')
rec['规格'] = 'M8×40'
print('  加一栏    ', rec)
rec['数量'] = 100
print('  改数量    ', rec)
got = rec.pop('规格')
print('  pop 掉规格   拿走 %s,剩 %s' % (got, rec))
print("  in 判断     '单价' in r → %s" % ('单价' in rec))
print("  查值要写 values():'六角螺栓 M8' in rec.values() → %s"
      % ('六角螺栓 M8' in rec.values()))

print('③ 走一遍字典')
print('  for k in rec        →', ' '.join(rec))
print('  for k, v in .items() →', ';'.join('%s=%s' % kv for kv in rec.items()))
print('  .keys()  ', list(rec.keys()))
print('  .values()', list(rec.values()))

print('④ 一层套一层:一条订单')
order = {'单号': 'PO-20261102-001',
         '客户': {'名称': '某装饰公司', '联系人': '王工'},
         '明细': [{'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5},
                  {'品名': '膨胀螺栓 10mm', '数量': 80, '单价': 2.3}]}
print('  单号    ', order['单号'])
print('  客户名  ', order['客户']['名称'])
print('  第一条  ', '%s × %d' % (order['明细'][0]['品名'], order['明细'][0]['数量']))
print('  明细条数', len(order['明细']))
print('  合计数量', sum(d['数量'] for d in order['明细']))

print('⑤ 存成文件再读回来')
s = json.dumps(order, ensure_ascii=False, indent=2)
open('order.json', 'w', encoding='utf-8').write(s)
print('  写出去 %d 字节' % len(s.encode('utf-8')))
for ln in s.splitlines()[:6]:
    print('    ' + ln)
back = json.loads(open('order.json', encoding='utf-8').read())
print('  读回来单号对得上?', back['单号'] == order['单号'])
print('  明细条数', len(back['明细']))
print('  不加 ensure_ascii 会变成:', json.dumps({'品名': '螺栓'})[:40])

print('⑥ 一百条记录,键名重复了几遍')
rows = [{'日期': '2026-11-%02d' % (i % 3 + 1), '品名': '品名%d' % (i % 7),
         '数量': i % 50 + 1, '单价': 1.5 + i % 9} for i in range(100)]
a = json.dumps(rows, ensure_ascii=False).encode('utf-8')
head = ['日期', '品名', '数量', '单价']
b = (json.dumps(head, ensure_ascii=False)[:-1] + ',"rows":'
     + json.dumps([[r[h] for h in head] for r in rows], ensure_ascii=False) + '}')
b = b.encode('utf-8')
print('| 存法 | 字节数 | 每条多少 |')
print('|---|---:|---:|')
print('| 每行一个字典 | %d | %.1f |' % (len(a), len(a) / 100.0))
print('| 表头 + 一行一个数组 | %d | %.1f |' % (len(b), len(b) / 100.0))
print('  100 条记录,同一批键名被写了两遍以上,多出来的 %d 字节全是它们。' % (len(a) - len(b)))

实跑输出:

--------------------------------------------------------------------
字典:一条记录该长什么样
--------------------------------------------------------------------

  ① 键值对,按键取
  --------------------------------------------------------------------
    rec = {'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}
    rec['品名']            = 六角螺栓 M8
    rec.get('规格')        = None
    rec.get('规格', '未填')  = 未填
    rec['规格']            → KeyError: '规格'
  方括号取不到会抛 KeyError,get 取不到给 None 或你指定的默认值。
  字段可能缺的时候一律用 get,别赌它一定在。

  ② 加、改、删
  --------------------------------------------------------------------
    加一栏      {'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5, '规格': 'M8×40'}
    改数量      {'品名': '六角螺栓 M8', '数量': 100, '单价': 1.5, '规格': 'M8×40'}
    pop 掉规格   拿走 M8×40,剩 {'品名': '六角螺栓 M8', '数量': 100, '单价': 1.5}
    in 判断     '单价' in r → True
  字典的键不能重复。给已有的键赋值就是改,不是加。要判断某栏在不在,用 in,别用 try。

  ③ 走一遍字典
  --------------------------------------------------------------------
    for k in rec        → 品名 数量 单价
    for k, v in .items() → 品名=六角螺栓 M8;数量=120;单价=1.5
    .keys()  ['品名', '数量', '单价']
    .values() ['六角螺栓 M8', 120, 1.5]
  直接 for 拿到的是键。
  要键值一起拿写 .items(),顺序是插入的顺序,从 Python 3.7 起这一点是有保证的。

  ④ 一层套一层:一条订单
  --------------------------------------------------------------------
    单号     PO-20261102-001
    客户名   某装饰公司
    第一条   六角螺栓 M8 × 120
    明细条数 2
    合计数量 200
  真实数据几乎都是这个形状:字典装一层的字段,里面再套字典或列表。
  JSON、接口返回、配置文件,全是这个结构。

  ⑤ 存成文件再读回来
  --------------------------------------------------------------------
    写出去 266 字节
    {
      "单号": "PO-20261102-001",
      "客户": {
        "名称": "某装饰公司",
        "联系人": "王工"
      },
      "明细": [
        {
    读回来单号对得上?True
    明细条数 2
  json.dumps 把字典变成字符串,json.loads 变回来。
  ensure_ascii=False 才能让中文原样写出来,不然会变成一堆 \u4e2d 这样的转义。
  indent=2 是给人看的排版,文件会大一些。给机器读的接口数据不用 indent,省下来的都是带宽。

  ⑥ 一百条记录,键名重复了几遍
  --------------------------------------------------------------------

| 存法 | 字节数 | 每条多少 |
|---|---:|---:|
| 每行一个字典 | 8500 | 85.0 |
| 表头 + 一行一个数组 | 4542 | 45.4 |

  100 条记录,同一批键名被写了两遍以上,多出来的 3958 字节全是它们。
  这就是列式存储的思路:同一列的键只存一次。数据量小的时候不用管,上万条之后这个差别就很可观了。
  代价是可读性:表头 + 数组那种存法,少一行就错位,人也看不出第三列是数量还是单价。
  自己程序内部传可以这么压,给别人看别这么干。

第一段那个 KeyError 和 get 的对照是本篇最实用的一处。字段可能缺的时候一律用 get,别赌它一定在。 第二段有个容易忽略的地方:给已有键赋值就是改,不报错。 所以「不小心覆盖了已有字段」这种 bug 是静默的。 第三段那句 直接 for 拿到的是键 要记牢。要键值对写 .items(),顺序就是插入顺序,3.7 以后有保证。 第四段那个 order 三层嵌套:字典里套字典、字典里套列表、列表里再套字典。真实数据几乎都是这个形状。 第五段末尾那行对照很直观:不加 ensure_ascii,中文全变成转义序列,文件照样能读,只是人看不懂。 第六段那一千多字节的差距全是重复的键名。这就是 CSV 比 JSON 小的原因:表头只写一次。

30 秒实验

  1. 把演示第一段的 rec.get('规格', '未填') 改成 rec.get('规格', 0),看返回什么类型。
  2. 把演示第四段的 sum(d['数量'] for d in order['明细']) 改成算总金额(数量乘单价再求和), 看结果是 364.0 还是别的。
  3. 把演示第六段的 100 改成 1000,重跑,看两种存法的倍数关系变没变。

常见误区

  1. 「字典取值都用方括号。」取不到的键会抛 KeyError。 数据来自别人时一律用 get,别赌它一定在。
  2. 「'值' in 字典 能查值。」它查的是键。要查值写 '值' in d.values()。 这个错不报错,只是永远返回 False。
  3. 「json.dumps 直接就能存中文。」不加 ensure_ascii=False,中文会变成 \u4e2d 这样的转义。 文件能读,但人看不懂,也没法直接搜。
自测三问(点开对答案) 1. `rec['规格']` 和 `rec.get('规格')` 有什么区别?什么时候该用哪个? —— 前者取不到抛 `KeyError`,后者取不到给 `None`(或你指定的默认值)。 数据来源不确定、字段可能缺的时候用 `get`; 自己刚写进去、确定在的键可以用方括号——真缺了就该炸,说明逻辑错了。 2. `for k in rec` 拿到的是什么?要键值一起拿怎么写? —— 拿到的是键,不是键值对。要一起拿写 `for k, v in rec.items()`。 这个默认值跟列表不一样,第一次用字典很容易愣住。 3. 同样一百条记录,「每行一个字典」8500 字节,「表头加数组」4542 字节。差的那四千字节是什么? —— 是重复的键名。 一百条记录把「日期」「品名」「数量」「单价」各写了 100 遍,而表头那种存法只写一遍。 这就是列式存储的思路,也是 CSV 比 JSON 小的原因。

下一篇把这种「表头只写一次」的形状落到文件里:CSV。顺便说说 Excel 打开乱码是怎么回事。

相关笔记