Python迭代器与生成器全解析:协议、惰性求值与避坑实战

Python迭代器与生成器全解析:协议、惰性求值与避坑实战 后台问迭代器的人一直不少隔三差五就冒出来一句python的迭代器到底是个啥。我理解这种困惑因为你在写业务代码的时候几乎不会主动去写__iter__和__next__但for循环天天在用StopIteration报错偶尔蹦出来吓人一跳面试官又特别爱拿它开场。我自己带过几届新人也踩过不少和迭代器相关的坑最典型的一次是拿一个迭代器去跑两轮统计第二轮直接算出来全是空结果排查了半天才反应过来是它已经被抽干了。这篇就把迭代器从概念到实操完整过一遍。你会看到它本质上是一套约定只要对象实现了特定的两个方法python 就认它是迭代器也会看到它真正的好处集中在惰性求值和统一接口这两点上前者让你处理几十 G 的日志文件时内存不爆后者让你写的自定义数据结构能无缝接入for、sum、max、解包、in判断这些内置能力。刚入门的朋友可以把这篇当概念补课写过一阵子的可以重点看第 5 节的坑和排查表那些是文档里基本不会写的部分。1. 把迭代器的概念拆开看它到底规定了什么很多人学迭代器卡在第一步就是被可迭代对象迭代器生成器这三个词绕晕了。它们确实相关但不是一回事。我习惯先不讲定义而是从一个最普通的for循环倒着推推着推着定义自己就浮出来了这比死记硬背管用得多。1.1 从一行最普通的 for 循环倒推你写for item in [1, 2, 3]:的时候python 内部干了三件事而且是有严格顺序的。第一步它对[1, 2, 3]调用内置函数iter()拿到一个迭代器对象第二步反复对这个迭代器调用next()第三步当迭代器抛StopIteration异常时循环安静地结束不往外冒错。把这个过程手写出来就是下面这样nums [1, 2, 3] it iter(nums) # 第一步拿到迭代器 while True: try: value next(it) # 第二步不断取下一个 except StopIteration: # 第三步取完了就退出 break print(value)你可以把这段代码跑一遍输出和for x in nums完全一样。这就是for的真身——它是一个语法糖底层就是iter()加next()加异常捕获。理解这一点非常关键因为后面所有的好处和坑都是从这个机制里长出来的。那为什么 python 要用异常来表示结束而不是返回一个None之类的特殊值这是新手最容易觉得别扭的地方。原因是None本身可以是序列里的合法元素如果你的数据里真有一个None用返回值判断就会产生歧义。异常不会——StopIteration是专属信号不占用任何数据值的语义空间。这个设计决定你要习惯用try/except来终止循环而不是判断返回值。提示手动调用next(it)时你可以给它第二个参数做兜底写成next(it, 默认值)。这样取完时不会抛异常而是返回你指定的默认值写一些边界逻辑时会方便很多。1.2 迭代器协议只要两个方法就够推导到这里迭代器的定义就自然出来了一个对象如果它实现了__iter__()和__next__()这两个方法python 就认为它是一个迭代器。就这两个没有第三个也没有继承要求——这是所谓的鸭子类型长得像就行不看你爹是谁。两个方法各自负责什么我用一句话区分__iter__()负责交出自己__next__()负责交出下一个值。非常反直觉的一点是迭代器的__iter__()通常就写return self也就是把自己返回出去。为什么这么设计因为 python 要求for循环能同时对可迭代对象和迭代器统一处理for永远先调iter()所以迭代器必须也能被iter()处理返回自己是最省事的做法。而__next__()的行为约定是每次被调用返回一个值值取完之后必须抛StopIteration。注意必须这两个字如果你只是return None或者return空for循环根本不知道结束了会永远转下去变成死循环。我见过有人写自定义迭代器忘了抛异常跑起来卡死还以为是电脑性能问题。class Range3: def __init__(self): self.n 0 def __iter__(self): return self def __next__(self): if self.n 3: raise StopIteration self.n 1 return self.n - 1这个类模拟了一个只有 0、1、2 的序列。你可以用list(Range3())得到[0, 1, 2]也可以直接for x in Range3()。它没有任何继承没有注册就因为实现了那两个方法就被 python 认了。1.3 可迭代对象和迭代器别再混为一谈这是最容易混的一对概念我用一句话给你钉死可迭代对象是能被遍历的迭代器是正在被遍历的那个游标。列表、元组、字符串、字典、集合、文件对象都是可迭代对象但它们本身不是迭代器。区别在哪迭代器有状态它记录我走到哪了可迭代对象通常没有这个游标状态你每次iter()它都能拿到一个全新的、从头开始的迭代器。判断依据也很清晰只有__iter__的是可迭代对象同时有__iter__和__next__的才是迭代器。用标准库里的工具一测便知from collections.abc import Iterable, Iterator print(isinstance([1, 2, 3], Iterable)) # True列表可迭代 print(isinstance([1, 2, 3], Iterator)) # False列表不是迭代器 it iter([1, 2, 3]) print(isinstance(it, Iterator)) # Trueiter() 之后才是迭代器这个区分在实际开发里价值很大。比如你写了一个函数参数收一个可迭代对象那你要清楚如果用户传进来的是列表你可以安全地遍历两遍但如果用户传进来的是一个迭代器或生成器你遍历第二遍时它已经空了。这个认知直接决定你要不要在函数开头先list()兜一下——代价是失去惰性优势得看场景权衡。注意iter()这个内置函数很统一它对可迭代对象返回一个迭代器对迭代器则原样返回自己。所以你在写代码时永远可以放心地对任意输入先iter()一下不用判断对方究竟是哪一类。2. 迭代器的好处到底体现在哪光讲定义不够解渴真正让人服气的是它解决了什么问题。我总结下来迭代器的好处集中在四个方向省内存、统一接口、表达无限序列、解耦状态管理。前两个是日常用得最多的后两个属于用上了才知道香的类型。这一节我把这四个都拆开讲透配着实际的数据量级来算你就能感受到差距。2.1 惰性求值内存不炸的真正底气这是迭代器最核心、最值钱的好处。所谓惰性求值就是用到才算而不是先全算出来再一个个用。举个最直观的对比你要处理一个包含一亿个整数的序列如果用列表[i for i in range(100_000_000)]光是每个 int 对象加上列表指针内存占用就直奔好几 G 去很多机器直接 MemoryError。但如果用range(100_000_000)内存占用几乎是常数级别。为什么因为range对象本身不是迭代器但它返回的迭代器每次只持有当前值这一个状态算一个给你一个用完就扔。整个过程内存里始终只有那么几个变量。你可以自己实测import sys lst list(range(1000)) rng range(1000) print(sys.getsizeof(lst)) # 列表本身随元素增长很大 print(sys.getsizeof(rng)) # range 对象大小固定与 1000 几乎无关sys.getsizeof这个函数在评估数据结构开销时特别好用建议你手边常备。列表的大小会随元素个数线性增长而range无论写 1000 还是 100000000它对象本身的大小基本不变——因为它根本不存这些数它是按需计算出来的。这个特性在处理文件时价值更大。读取一个大文件常见有两种写法# 写法一一次性全读进内存 with open(big.log, r, encodingutf-8) as f: lines f.readlines() # 整个文件都在内存里了 # 写法二逐行惰性读取 with open(big.log, r, encodingutf-8) as f: for line in f: # 内存里同时只有一行 process(line)文件对象本身就是一个迭代器。写法一遇到几 G 的日志文件基本就废了写法二无论文件多大内存占用都是稳定的。我在做日志分析时一开始也图省事用readlines()文件小的时候没感觉直到碰上一个 6G 的访问日志进程直接被系统杀掉。改成逐行之后同样的机器跑得稳稳当当。这就是惰性求值最实在的价值。实操心得惰性不是没有代价的它是用时间换空间。逐行读比一次性读慢一些因为多了很多次 I/O 和函数调用开销。所以小文件比如几 M 以内完全没必要纠结直接读进来处理反而更快只有数据量级真的上去了惰性读取的优势才压过开销。2.2 统一遍历接口一套协议通吃所有内置能力迭代器的第二个大好处是它把遍历这件事抽象成了一套标准接口于是 python 里所有需要遍历的功能都能无缝对接你的自定义对象。这包括for循环、sum、max、min、sorted、list/tuple/set构造、in成员判断、多变量解包a, b, c obj还有zip、enumerate、map、filter等等。这意味着什么意味着你只要给自己的数据结构实现好迭代器协议上面这一长串能力就自动获得了不需要为每一个单独写适配代码。我举个实际例子假设你在做一个订单系统写了一个OrderBook类你希望它能直接被for遍历、能sum求总金额、能判断某个订单在不在里面class OrderBook: def __init__(self): self.orders [] def add(self, amount): self.orders.append(amount) def __iter__(self): return iter(self.orders) # 委托给列表的迭代器注意__iter__里我写的是return iter(self.orders)而不是return self。这就是上一节说的可迭代对象写法——每次被遍历都创建一个新迭代器这样可以反复遍历也不会把类的内部状态和遍历状态耦合在一起。加完之后下面这些全都直接能用book OrderBook() book.add(100) book.add(200) for amount in book: # for 循环通吃 print(amount) print(sum(book)) # 求和通吃 print(max(book)) # 最大值通吃 print(300 in book) # 成员判断通吃 a, b book # 解包通吃一套协议换来一整套语言级能力这就是统一接口的威力。如果换一门没有这套机制的语言上面每一个功能你都得手动实现一遍代码量翻好几倍。这也是为什么我在设计类的时候只要这个类内部确实装着一批东西我就会顺手给它加上__iter__——性价比极高。2.3 无限序列与流式管道第三个好处是它天然适合表达无限这个概念。列表不可能无限长因为它要分配内存但迭代器可以因为它按需产生值只要你不主动停它可以一直产下去。标准库里的itertools.count()就是个典型from itertools import count for i in count(start0, step2): # 0, 2, 4, 6, ... 无限 if i 10: break print(i)这个无限序列配合islice就能截取前 N 个配合zip就能给有限数据编号非常灵活from itertools import count, islice names [张三, 李四, 王五] numbered zip(count(1), names) # (1, 张三), (2, 李四), ... print(list(numbered))比手动维护一个idx变量清爽多了。另一个我常用的场景是流式管道——把多个处理步骤串成一条链数据像水流一样一段段经过中间不落地、不存中间结果。比如从一个大文件里筛选、转换、再汇总def read_lines(path): with open(path, r, encodingutf-8) as f: for line in f: yield line.strip() def to_number(lines): for line in lines: if line: yield float(line) total sum(to_number(read_lines(numbers.txt)))注意这里read_lines是个生成器它从头到尾没有把文件内容存进任何列表。数据是被抽着往前流的sum要一个链路就往回要一个文件就读一行、转一下、加进总和。整个流程内存占用恒定即使文件有上百 G 也能跑。这种管道式写法的可读性也高每一段职责单一加一个新环节就是加一个生成器函数不用改动上下游。2.4 状态管理解耦不用手写游标变量最后一个好处相对隐蔽但写多了能体会到。手写遍历逻辑时你总要维护一堆游标变量、边界判断代码容易出错而迭代器把我走到哪了这个状态封装在对象内部调用方只管next()完全不用关心位置。这带来的直接好处是——可以让多个遍历同时进行而互不干扰。用列表做例子如果你手动用下标遍历同时起两个循环很容易互相踩到data [1, 2, 3, 4] it1 iter(data) it2 iter(data) print(next(it1)) # 1 print(next(it1)) # 2 print(next(it2)) # 1it2 有自己的状态不受 it1 影响两个独立迭代器各走各的这是用下标手动管理很难干净做到的。再比如你写一个解析器需要看一眼下一个 token 再决定怎么处理当前 token迭代器配合next的默认值参数就能优雅实现向前探视tokens iter([a, , b]) for tok in tokens: if tok : right next(tokens, None) # 向前借一个 print(加法右操作数, right)这种前瞻一个的模式在词法分析、协议解析里非常常见用迭代器写出来的代码天然比用下标清晰。状态被封装起来之后调用方的代码就只剩业务逻辑边界和游标这些都交给迭代器内部处理出错概率明显下降。3. 手写迭代器从类实现到生成器理论讲完动手部分才是真本事。这一节我从最原始的类实现开始再过渡到生成器这个更省事的写法最后把两者关系理清楚。我强烈建议你跟着敲一遍尤其是自己写一个类迭代器能写出来你对协议的理解就到位了。3.1 用类实现一个可复用的迭代器写一个倒计时迭代器从指定数字数到 1。这类顺序有规律、但不想提前建列表的需求用迭代器最合适class CountDown: def __init__(self, start): self.start start self.current start def __iter__(self): return self def __next__(self): if self.current 0: raise StopIteration value self.current self.current - 1 return value用法for n in CountDown(5): print(n) # 5 4 3 2 1这里有几个细节值得说。第一我把初始值start和运行时的游标current分成两个属性__iter__返回self。这种写法的代价是同一个CountDown实例只能被完整遍历一次因为遍历完current已经是 0 了。如果你想要每次都从头开始可以把__iter__改成一个生成器或者返回一个新对象class CountDown2: def __init__(self, start): self.start start def __iter__(self): current self.start while current 0: yield current current - 1注意这里__iter__里面用了yield那它就不再是个普通方法而是一个生成器函数每次被调用都返回一个全新的迭代器。这样一来CountDown2(5)就可以被反复遍历每次都是 5 到 1。上面两种写法没有绝对好坏取决于你的类想表达一次性消耗品还是可重复数据源。我自己在做数据源类反复读时用第二种做任务队列类消费一次时用第一种。注意__next__里做边界判断时抛StopIteration的位置一定要在返回值之前。如果你先返回了值再判断序列会多出一个不该出现的元素。这个顺序错误新手很常见写完最好用list()打印一遍看看长度对不对。3.2 生成器写迭代器的最省事方式每次都用类写一遍__iter__和__next__说实话挺啰嗦的。python 给了一个语法糖——生成器函数。任何函数体里出现了yield调用它时就不再执行函数体而是返回一个生成器对象这个对象天然就是迭代器__iter__和__next__自动都有了。把上面的倒计时改写成生成器就是几行的事def count_down(start): current start while current 0: yield current current - 1 for n in count_down(5): print(n) # 5 4 3 2 1对比一下类实现代码量差不多砍掉一半而且没有手工抛异常的负担——函数返回生成器自动触发StopIteration。yield的工作方式我习惯这样理解它像一个暂停键每次被next()调用函数从上次暂停的地方继续往下跑跑到下一个yield就把值交出去然后再次暂停。所有局部变量比如current都被完整保留在暂停现场这就是它的状态。生成器还能把查字典读文件远程分页拉取这类有副作用或 I/O 的操作写得很优雅。比如一个分页接口页数不确定可以边拉边处理def fetch_all_pages(fetch_page): page 1 while True: data fetch_page(page) if not data: return for item in data: yield item page 1调用方完全感知不到分页的存在for item in fetch_all_pages(api.get)就像遍历一个普通列表一样。这种把复杂的数据来源包装成一条平顺序列的能力是生成器最大的工程价值。3.3 生成器和迭代器的关系一句话讲清现在可以回答那个高频问题了生成器是迭代器吗是。生成器是迭代器的一种特殊实现由 python 自动帮你生成协议方法你不用手写。但反过来说不成立——迭代器不一定是生成器你自己用类写的、iter()返回的那些都是迭代器但不是生成器。我用一张表把这三者的关系捋直角色是否可迭代是否是迭代器典型例子关键特征可迭代对象是否list、tuple、str、dict每次 iter() 得新游标迭代器是是iter([1,2])、文件对象有状态一次性消耗生成器是是含 yield 的函数调用自动实现协议写法最简判断一个东西是哪一类最准的方法就是看它有没有__next__。有就是迭代器没有只有__iter__的就是单纯的可迭代对象。我在 review 代码时只要看到生成器被当成列表反复使用就会提醒作者注意它的一次性特性这里出错往往很隐蔽。4. 迭代器在真实项目里的几个用法概念和写法都齐了这一节我挑几个我自己项目里真用过的迭代器场景讲清楚选它的理由和具体实现。你会发现凡是数据量大、数据源不确定、处理链条长的地方迭代器几乎都是第一选择。4.1 大文件分块读取与处理逐行读适合按行组织的数据但如果文件是按块处理的比如定长记录或者二进制数据逐行就不合适了。这时候可以写一个分块迭代器每次吐一块出来块大小自己定def read_in_chunks(path, chunk_size8192): with open(path, rb) as f: while True: block f.read(chunk_size) if not block: break yield block for chunk in read_in_chunks(data.bin, chunk_size65536): process(chunk)chunk_size怎么定没有万能值我在实际调优时是这样试的从 4KB 开始往上翻倍8KB、64KB、256KB、1MB 各跑一遍记耗时。通常来说块太小会导致 read 系统调用次数过多开销上去了块太大则内存占用上升、缓存命中率下降。多数场景在 64KB 到 256KB 之间能拿到比较好的平衡但这个和磁盘类型、文件系统都有关系最好拿真实数据实测别照搬。实操心得用with包住文件的迭代器写法里有一个容易忽略的点——如果调用方没有把迭代器跑完就提前退出比如break了with块的关闭逻辑并不会立即执行文件句柄会一直到迭代器被垃圾回收才释放。极端情况下大量这样的半途迭代器会导致文件描述符耗尽。稳妥做法是让生成器自己在finally里清理或者调用方用contextlib.closing。4.2 用 itertools 把迭代器玩出花标准库的itertools是一个被严重低估的模块它把常见的迭代器操作都封装好了写着省事、跑着高效内部用 C 实现。我把最常用的几个列出来你记住这几个基本就够覆盖日常八成场景了。函数作用一句话示例chain串联多个可迭代对象chain([1,2], [3,4]) → 1,2,3,4islice切片迭代器islice(count(), 5) → 前 5 个takewhile条件为真时持续取takewhile(lambda x: x5, nums)dropwhile条件为真时持续丢dropwhile(lambda x: x5, nums)groupby按 key 分组groupby(sorted_data, keyfunc)accumulate累积计算跑和accumulate([1,2,3]) → 1,3,6tee把一个迭代器分成两个a, b tee(it, 2)举个实际例子日志里的请求耗时我想看累计耗时的走势用accumulate一行搞定from itertools import accumulate costs [12, 30, 8, 45, 20] print(list(accumulate(costs))) # [12, 42, 50, 95, 115]再比如想把多个来源的数据拼成一条流处理chain最合适from itertools import chain sales_2023 [100, 120] sales_2024 [150, 90] for amount in chain(sales_2023, sales_2024): print(amount)不用建中间列表也不用写两个循环。groupby有个大坑我必须提醒它只对相邻的相同 key 分组也就是说用之前一定要先按 key 排序否则同一 key 会被拆成多组。这个坑我至少见过三个人踩包括我自己第一次用的时候。4.3 自己封装一个惰性数据管道类最后一个场景是我比较喜欢的——把一条迭代器管道封装成一个类对外暴露清晰的接口内部用生成器实现惰性处理。比如做一个 CSV 数据的筛选器class CsvFilter: def __init__(self, path, min_value): self.path path self.min_value min_value def __iter__(self): with open(self.path, r, encodingutf-8) as f: header next(f) # 跳过表头 for line in f: name, value line.strip().split(,) if float(value) self.min_value: yield name, float(value)调用方写起来干净而且可以在上面继续套一层管道records CsvFilter(data.csv, 50) names (name for name, _ in records) print(list(names))这里__iter__是生成器所以CsvFilter对象既是可迭代对象能反复遍历每次遍历又都只读一遍文件、只处理符合条件的行内存始终很小。这种类的接口 生成器的实现是我在数据处理代码里最常用的一种组合它能同时兼顾对外好看和对内省资源。5. 迭代器常见问题与排查实录再好的机制也有坑迭代器的坑集中在一次性和状态上。这一节我把自己和朋友踩过的典型问题整理出来配上排查思路做成了速查表你遇到报错时可以直接对照。5.1 迭代器只能用一次这是最容易翻车的地方最常见的翻车场景是这样的你用一个生成器或map对象去做两件事第一件做完之后第二件拿到的是空的而且不报错静悄悄地算错。nums map(int, [1, 2, 3]) total sum(nums) # 第一次拿到 6 count len(list(nums)) # 第二次拿到 0 average total / count # ZeroDivisionError 或者算错问题就出在map返回的是一个迭代器第一次sum已经把它抽干了。修法有两种要么在最开始就nums list(map(int, ...))物化成一个列表牺牲惰性换可重复要么每次用之前重新创建迭代器保持惰性改调用方式。选哪个取决于数据量小数据直接物化最省心大数据就得想清楚架构把多次消费的需求合并成一次遍历。判断一个对象是不是一次性我有条土办法看它是map、filter、zip、生成器、iter(x)的结果还是列表、元组、字典这类容器。前者基本都只能用一次后者可以反复遍历。这个判断在 code review 时特别有用。注意itertools.tee()可以把一个迭代器分裂成多个看起来解决了重复消费的问题但它内部会缓存已经走过的元素如果多个分支走得快慢差距很大缓存会不断累积内存就上去了。所以tee适合分支消费速度接近的场景差距悬殊时它并不比直接list()省多少。5.2 常见报错与速查表下面这个表是我整理的高频问题清单覆盖了报错信息、可能原因和排查方向。遇到问题时先对着表找比搜索引擎翻半天快。报错 / 现象可能原因排查与解决TypeError: list object is not an iterator拿可迭代对象当迭代器用直接调了 next()先 iter(lst) 再 next或用 for 遍历TypeError: object is not iterable对象既没有iter也没getitem给类补iter确认没有拼错方法名StopIteration 冒到顶层手动 next() 没做兜底用 next(it, default) 或 try/except 包住循环变成死循环自定义next忘了抛 StopIteration在正确的边界处 raise StopIteration第二次遍历结果为空用了迭代器/生成器去做多轮遍历改成 list() 物化或每轮重新创建遍历中修改容器数据错乱迭代时增删了被遍历的列表遍历副本或先收集要改的项再统一处理groupby 分组结果碎片化没有先用同一个 key 排序先 sorted(data, keyfunc) 再 groupby其中遍历中修改容器这个坑值得单独说一句。你在for x in lst里做lst.remove(x)遍历顺序会错乱甚至跳过元素。原因是迭代器内部维护着索引你一删元素后面的整体前移索引就对不上了。安全做法是遍历一个副本或者用列表推导式重建# 危险写法 for x in lst: if x 0: lst.remove(x) # 安全写法一遍历副本 for x in list(lst): if x 0: lst.remove(x) # 安全写法二重建 lst [x for x in lst if x 0]我一般直接用第二种简洁又快。字典同理遍历时改字典会直接报RuntimeError: dictionary changed size during iteration比列表还严厉所以一定要先list(d.keys())再动。5.3 几个我总结的判断经验写多了之后慢慢会形成一些直觉。我分享几条自己一直在用的判断经验都是踩过坑之后沉淀下来的。第一条在设计阶段就问自己这个数据会被消费几次。只消费一次果断用生成器或迭代器省内存要消费多次老老实实物化成列表。很多性能问题不是出在写法上而是出在最开始没想清楚这个数据的使用模式。第二条公开接口的返回值尽量返回列表内部处理才用迭代器。原因是调用方不知道你返回的是不是一次性的他可能会不小心遍历两遍。如果你确实要返回惰性对象至少在文档里写清楚这是一个生成器只能遍历一次。我在自己的项目里就吃过这个亏一个辅助函数返回了生成器结果三个调用点里有两个是遍历两次的全算错了。第三条调试迭代器时先list()看一眼。迭代器本身打印出来只是个对象地址看不出内容短序列先转成列表打印能省掉很多猜谜时间长序列用islice(it, 10)取前十个看既能看到内容又不至于把内存撑爆。第四条用了itertools之后要留意上游是否也用了惰性。itertools的函数几乎都是惰性的好处是不占内存坏处是如果上游是一个已经耗尽的迭代器你在这里再优雅也是空转。调试时从链路末端往回查先确认数据源有没有正确产出再看每一段变换有没有过滤掉东西。关于性能这块再补一个小测法。如果你不确定某段迭代器管道到底省不省内存可以用tracemalloc实测峰值import tracemalloc tracemalloc.start() result sum(x for x in range(1_000_000)) current, peak tracemalloc.get_traced_memory() tracemalloc.stop() print(f当前 {current / 1024:.1f} KB峰值 {peak / 1024:.1f} KB)拿这个和sum([x for x in range(1_000_000)])对比你会直观看到惰性写法峰值内存低好几个数量级。这种自己动手量一遍的习惯比记住任何结论都可靠因为数据量级、python 版本、机器环境都会影响结果。最后再分享一个小技巧。如果你写了一个生成器想调试它内部每一步的值又不想破坏惰性可以在关键位置临时插一个yield或者用print跑一小段确认逻辑对了再删掉。别一上来就整条链路跑链路一长哪一段出错很难定位。我当时写多级管道过滤器时就是一段段单独测每加一段验证一次输出比事后调试省了不止一倍时间。