跳到主要内容

11. 标准库速览(二)

对应官方文档:11. Brief Tour of the Standard Library — Part II 一句话导读:这一章接着逛工具箱的另一半——怎么把输出弄好看、怎么用模板拼文本、怎么存二进制、怎么开线程打日志,最后补上「钱不能算错」的 decimal,学完写小工具会明显顺手。

上一章偏「跟外界打交道」,这一章偏「把程序本身写讲究」:输出、模板、二进制、并发、日志、内存、列表算法、精确小数。官方 11.1 到 11.8 每节都不长,村长照样给每节配一段能直接跑的短代码,看到输出就懂它是干嘛的。

11.1 输出格式化​

大对象直接 print 会刷屏,reprlib 负责截断长长长长的内容,pprint 负责漂亮地折行缩进嵌套结构,textwrap 负责按宽度折中文/英文段落,locale 负责按本地习惯格式化数字和货币。四个模块管的是四种不同的「不好看」。

import reprlib, pprint, textwrap, locale

print('reprlib 截断:', reprlib.repr(set('supercalifragilisticexpialidocious')))

nested = [[[['black', 'cyan'], 'white', ['green', 'red']],
[['magenta', 'yellow'], 'blue']]]
print('pprint 折行:')
pprint.pprint(nested, width=30)

text = '不好奇编程的 Python 教程:把一段很长的文字按指定宽度折行,段落读起来更舒服。'
print('textwrap:')
print(textwrap.fill(text, width=22))
print('本地首选编码:', locale.getpreferredencoding())
print('本地货币格式字段存在吗:', 'currency_symbol' in locale.localeconv())

注意 locale 的 setlocale 依赖系统装了对应的语言包,跨平台很容易抛错,所以这里只演示不改全局状态的安全读法。真要按「千分位、货币符号」格式化,生产环境更常用 babel 这类第三方库。

11.2 模板​

string.Template 是「给不懂 Python 的人也能填」的轻量模板:用 $名字 或 ${名字} 占位,substitute() 严格替换,safe_substitute() 缺变量时原样留着不报错。它比 f-string 弱,但正因如此更适合放在配置文件里让用户自己改。

from string import Template

t = Template('${name} 在 $town 教 Python,已经有 ${num} 位学员了,价格是 $$5。')
print(t.substitute(name='村长', town='不好奇编程', num=100))
# 村长 在 不好奇编程 教 Python,已经有 100 位学员了,价格是 $5。
print(t.safe_substitute(name='村长'))
# 村长 在 $town 教 Python,已经有 ${num} 位学员了,价格是 $5。

$$ 是转义,输出一个真的美元符号;${name} 这种带花括号的写法用在占位符后面紧挨着别的字符时(比如 ${num}位),免得被误读成变量名。

村长划重点:substitute() 少传一个变量就抛 KeyError,safe_substitute() 不抛——模板来自用户配置时用后者更稳,但也要留意「悄悄没替换」可能留下脏数据。另外 Template 只做字符串替换,不执行任何表达式,这正是它比 eval、比 f-string 安全的地方。

11.3 二进制数据记录布局​

struct 负责「Python 值和 C 结构体那种定长字节串」互转,读写二进制文件、解析网络包头时必备。格式串里 > 表示大端(网络字节序),I 是 4 字节无符号整数,10s 是 10 字节字符串,h 是 2 字节短整数。

import struct

fmt = '>I10sh'
print('这条记录占几字节:', struct.calcsize(fmt)) # 16
packed = struct.pack(fmt, 1001, b'buhaoqi', 7)
print('打包后的字节:', packed)
print('解包还原:', struct.unpack(fmt, packed))
# (1001, b'buhaoqi\x00\x00\x00', 7) —— 不足 10 字节自动补 \x00

字节序符号别省:不写 > 时用本机字节序 + 默认对齐,同一份代码换台机器可能解出不同结果。字符串字段解出来是 bytes,记得按需要 .decode() 并去掉尾部填充。

11.4 多线程​

threading 用来并发跑任务:把函数交给 Thread(target=..., args=...),start() 开工,join() 等它收工。多个程序「同时等网络、同时读文件」这类 I/O 密集活,线程很划算。

import threading, time

def worker(n):
print(f' 线程 {n} 开工')
time.sleep(0.01) # 模拟 I/O 等待
print(f' 线程 {n} 收工')

threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join() # 主线程在这里等,别抢先退出
print('主线程:都收工了')

村长划重点:CPython 有 GIL,同一时刻只有一个线程执行字节码,所以多线程救不了纯 CPU 计算(那要用 multiprocessing 或 concurrent.futures.ProcessPoolExecutor);它救的是「等待」——等网络、等磁盘、等用户输入。另外共享数据要加锁(threading.Lock),print 看着乱序也是并发的正常现象。

11.5 日志​

别再用 print 当调试日志了。logging 提供分级(DEBUG < INFO < WARNING < ERROR < CRITICAL)、格式、输出目的地,basicConfig 一行就能配好,%(levelname)s 这类占位符自动填。

import logging, sys

logging.basicConfig(level=logging.DEBUG,
format='%(levelname)-8s %(name)s: %(message)s',
stream=sys.stdout)
logging.debug('调试细节,平时关掉')
logging.info('一般信息:任务开始')
logging.warning('有点不对劲,但还能跑')
logging.error('出错了:拿不到数据')

log = logging.getLogger('myapp')
log.info('用带名字的 logger 更好归类')

日志级别是过滤用的:设成 WARNING 后,debug 和 info 直接不输出,代码一行都不用改。真项目里再配 FileHandler 写文件、RotatingFileHandler 按大小切分。

村长划重点:logging.basicConfig() 只在第一次调用时生效,之后再调(比如被别的库先配过了)会静默失效——所以在入口文件最开头配一次就好。想给某个模块单独调级别,用 logging.getLogger('名字').setLevel(...),别反复 basicConfig。

11.6 弱引用​

普通赋值会让对象「引用计数 +1」,只要有人指着它就不会被回收。weakref.ref(obj) 生成一个不增加引用计数的引用:对象还活着时 ref() 返回它,对象被回收后返回 None。做缓存、Observer、父子双向引用时特别有用。

import weakref

class Node:
pass

n = Node()
r = weakref.ref(n)
print('对象还在:', r() is n) # True
print('弱引用不阻止回收,引用数没加:', r)
del n # 最后一个强引用没了
print('对象已回收:', r() is None) # True

d = weakref.WeakValueDictionary()
obj = Node()
d['a'] = obj # 值是弱引用
print('在字典里:', 'a' in d)
del obj
print('自动从字典消失:', 'a' not in d)

弱引用不是「通用替代品」——int、str、tuple、list、dict 这些内置类型不支持弱引用,只有自定义类实例(及少数类型)才行。另外 r() 每次都要判一下是不是 None,别拿到就直接用,对象可能刚好在上一行被回收了。

11.7 处理列表的工具​

官方一口气点了三个「别自己造」的模块:array 存同种数字、比 list 省内存;heapq 维护堆,取最小/前 N 小极快;bisect 用二分在已排序列表里找插入位置,插完仍有序。

from array import array
a = array('H', [4000, 10, 700, 22222]) # 'H' = 2 字节无符号短整数
print('array:', a[1], sum(a), a[1:3])

import heapq
data = [1, 3, 5, 7, 9, 2, 4, 6, 8, 0]
heapq.heapify(data) # 原地变成小顶堆
print('弹出最小:', heapq.heappop(data))
print('剩余里前 3 小:', heapq.nsmallest(3, data))
print('最大的 2 个:', heapq.nlargest(2, data))

import bisect
scores = [100, 200, 300, 400]
i = bisect.bisect(scores, 250) # 只算位置,不改列表
print('250 该插在下标:', i)
bisect.insort(scores, 250) # 直接插进去并保持有序
print('插完:', scores)

bisect 的前提是列表本来就有序,喂一个乱序列表结果是未定义的,别指望它帮你排序。heapq 只保证堆顶是最小,不保证整个列表有序。

11.8 十进制浮点运算​

decimal 提供按十进制算的 Decimal,专门对付「钱不能算错」:0.1 + 0.2 在二进制浮点里是 0.30000000000000004,用 Decimal 就规规矩矩等于 0.3。精度由 getcontext().prec 控制,默认 28 位有效数字。

from decimal import Decimal, getcontext

print('二进制浮点:', 0.1 + 0.1 + 0.1, '| 等于 0.3 吗:', 0.1 + 0.1 + 0.1 == 0.3)
print('Decimal:', Decimal('0.1') + Decimal('0.1') + Decimal('0.1'),
'| 等于 0.3 吗:',
Decimal('0.1') + Decimal('0.1') + Decimal('0.1') == Decimal('0.3'))

print('当前精度:', getcontext().prec)
print('1 / 7 =', Decimal(1) / Decimal(7))
print('四舍五入到 2 位:', round(Decimal('1.2345'), 2))
print('金额求和:', sum([Decimal('19.99'), Decimal('5.50')], Decimal('0')))

村长划重点:构造 Decimal 一定用字符串 '0.1',不要写 Decimal(0.1)——后者先把二进制浮点的误差带进来了,等于白用。Decimal 也不能和 float 直接混着运算(会抛 TypeError),整数可以。它比 float 慢,只在需要精确十进制的场景用,科学计算还是 float / numpy。

本章小结​

  • 输出四件套各管一摊:reprlib 截断、pprint 漂亮打印、textwrap 折行、locale 本地化格式(别乱改全局 setlocale)。
  • string.Template 用 $name 占位,substitute 严格 / safe_substitute 宽松,$$ 转义,适合放在用户可改的配置里。
  • struct 用格式串打包定长二进制(记得写 > 字节序);threading 适合 I/O 并发,CPU 密集另寻他法。
  • logging 用分级日志替代 print,basicConfig 只首次生效;weakref 不增加引用计数,做缓存能自动释放。
  • array 省内存、heapq 找前 N、bisect 维护有序插入;decimal.Decimal 要用字符串构造,专门解决金额精度问题。