| Day 18 | 迭代器、生成器与装饰器:惰性求值与 @ 语法糖 |

1 迭代器协议 —— for 循环的幕后真相

是什么?为什么学?

迭代器(iterator)是「一次取一个」的取值工具。你天天用的 for x in 列表,底层就是在和迭代器打交道:iter() 拿迭代器,next() 逐个取值,取完抛 StopIteration 结束。理解这套协议,你就能让任何自定义类for 遍历。

底层原理

「可迭代」(iterable)与「迭代器」(iterator)是两个概念:可迭代对象(如列表、字符串)实现了 __iter__,返回一个迭代器;迭代器实现了 __next__,每次返回下一个值,取完抛 StopIterationfor 循环的完整展开是:it = iter(对象) → 循环里 x = next(it) → 捕获 StopIteration 就 break。迭代器像「一次性吸管」——取过的值不保留,所以迭代器只能用一遍list(it) 之后再 next(it) 就报错)。

生活类比

迭代器像排队取餐的窗口:你每次去问「下一个好了吗」(next),窗口给你一份餐,发完了就告诉你「没了」(StopIteration)——队伍还在原地,但你的取餐过程走完就结束了,想再取得重新排队。

注意事项总结

1 iter 拿迭代器,next 逐个取;用 try/except 接住结束信号

nums = [1, 2, 3]
it = iter(nums)
while True:
    try:
        value = next(it)
    except StopIteration:
        print("取完了,循环结束")
        break
    print("取到:", value)

运行结果:

取到: 1
取到: 2
取到: 3
取完了,循环结束

iter(nums) 调用列表的 __iter__ 拿到迭代器;next(it) 调用迭代器的 __next__ 取值。这段代码和 for x in nums: 做的事一模一样——for 的完整展开就是:调 iter、循环 next、捕获 StopIteration、break、处理元素。理解这个展开,你就看穿了所有循环

2 自己写一个「能被 for 循环遍历」的类(迭代器协议)

class CountDown:
    """从 start 倒数到 1 的迭代器"""

    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self          # 返回自己即可(自己就是迭代器)

    def __next__(self):
        if self.current < 1:
            raise StopIteration   # 没有元素了,抛异常告诉 for 循环"结束"
        value = self.current
        self.current -= 1
        return value

for n in CountDown(3):
    print(n)

运行结果:

3
2
1

易错点

  • 错误写法:对迭代器重复使用:list(it) 后还想再遍历 it → 问题:第二次遍历结果为空(迭代器是一次性的) → 正确写法:需要重复遍历就重新 iter(原始对象)
  • 错误写法:只实现了 __next__ 没实现 __iter__,然后 for 遍历 → 问题:报 TypeError: 'X' object is not iterable → 正确写法:两个方法都实现(可迭代 + 迭代器)。

记忆口诀:iter 拿工具,next 取一个,StopIteration 是句号;迭代器一次性,用完得重来。

2 惰性求值 —— 生成器为什么省内存

是什么?为什么学?

惰性求值(lazy evaluation)的意思是「用到才算,不提前全造出来」。列表推导式 [i for i in range(百万)] 一次性把百万个数全放内存;生成器表达式 (i for i in range(百万)) 只占一小块内存,需要哪个现算哪个——处理大文件、大日志、无限序列时,这是内存的「救命稻草」。

底层原理

列表推导式的结果是一个列表:创建时就遍历完整个序列,把所有值存进连续内存。生成器表达式的结果是一个生成器对象:它只记住「规则」(怎么算下一个),内存里没有任何值;每次 next() 才现场算一个出来,算完即弃。时间上生成器并不更快(甚至略慢,省的是内存),它的价值是让「无限」和「超大」成为可能

生活类比

列表推导式像超市提前把一年的大米全搬进仓库:取用方便但仓库得够大;生成器像大米随吃随买:家里只存一小袋,需要时去米店现买一袋。

注意事项总结

1 列表 vs 生成器,内存占用对比

import sys
big_list = [i for i in range(1000000)]
big_gen = (i for i in range(1000000))
print(sys.getsizeof(big_list))   # 列表(示例输出:8448728)
print(sys.getsizeof(big_gen))    # 生成器(示例输出:200)

运行结果(示例输出,不同机器数值不同):

8448728
200

两个对象「包含」的数据一样多(都是 100 万个数),但内存差了 4 万倍:列表把 100 万个整数全存下(约 8MB),生成器只存规则和状态(约 200 字节)。

2 生成器「现算现给」——数据是现场造出来的;还能处理「无限序列」

gen = (x * x for x in range(5))
print(next(gen))     # 现场算出 0
print(next(gen))     # 现场算出 1
print(list(gen))     # 把剩下的 4、9、16 全部取完

def all_evens():
    n = 0
    while True:
        yield n
        n += 2

gen2 = all_evens()
for _ in range(3):
    print(next(gen2))        # 0、2、4

运行结果:

0
1
[4, 9, 16]
0
2
4

all_evens 里有 while True(无限循环),调用它不会卡死——每次 next 才执行到 yield n 暂停,所以「无限序列」完全合法:要多少给多少,不要就不算;若写列表推导式 [n for n in ...无限...],内存会直接爆炸。

易错点

  • 错误写法:把生成器当列表用:gen[0]len(gen) → 问题:报 TypeError: 'generator' object is not subscriptable(生成器不支持下标) → 正确写法:需要下标先 list(gen)(但会失去省内存优势)。
  • 错误写法:sum(gen) 之后再用 gen → 问题:结果是 0——生成器已被取空 → 正确写法:需要多次使用就存成列表,或用 itertools.tee

记忆口诀:惰性求值现算现卖,内存只存规则;无限序列也不怕,要多少给多少。

3 yield 的暂停与恢复 —— 生成器的执行状态机

是什么?为什么学?

yield 是生成器函数里的「暂停键」:函数执行到 yield 值 时,把值交给调用方,然后原地冻结(所有局部变量、执行位置都保留);下次 next() 时从暂停处继续执行

底层原理

普通函数调用时:进栈、执行、返回、出栈,局部变量全部销毁。生成器函数不同:它被编译成一个状态机(Python 内部用一个对象记录执行到哪一行、各局部变量的值)。next(gen) 触发状态机「从上次位置运行到下一个 yield」,返回值并暂停。所以「函数中间能停下、还能从原地接着走」——这是普通函数做不到的。

生活类比

yield连续剧的「本集完」:每集(每次 next)演到关键处就停,但剧情(状态)全部保留;下集从上次停的地方接着演——普通函数则是「一场电影」:一次放完,散场清场。

注意事项总结

1 在 yield 前后放 print,观察「暂停-恢复」的精确顺序

def counter():
    print("函数开始执行")
    yield 1
    print("第一次恢复,继续往后")
    yield 2
    print("第二次恢复,继续往后")
    yield 3

gen = counter()            # 注意:此时函数体一行都没执行!
print("拿到生成器对象")
print(next(gen))           # 执行到第一个 yield,暂停
print(next(gen))           # 从上次暂停处继续,跑到第二个 yield
print(next(gen))           # 再继续,跑到第三个 yield

运行结果:

拿到生成器对象
函数开始执行
1
第一次恢复,继续往后
2
第二次恢复,继续往后
3

关键看打印顺序——counter() 不执行函数体,所以「拿到生成器对象」先输出;第一次 next 才从函数开头执行到第一个 yield 1 并暂停;第二次 next 从暂停处继续……三次 next 三次「跑-停」,状态不丢。对比普通函数:def f(): return 42 一调用立刻执行,而 def g(): yield 42 调用只返回生成器对象、函数体一行没跑。

2 生成器简写:生成器表达式——把列表推导式的 [] 换成 () 就是惰性的:gen = (x * x for x in range(5)),用 sum(gen) 求和不占额外内存。

易错点

  • 错误写法:函数里写了 yield 却当普通函数用:counter() 想直接拿结果 → 问题:拿到的是生成器对象,不是第一个值 → 正确写法:用 next(gen)for x in gen 取值。
  • 错误写法:在循环外写 yield 后不再继续 → 问题:生成器只产出一个值,第二次 next 抛 StopIteration → 正确写法:想产多个值,yield 写在循环里。

记忆口诀:yield 是暂停键,状态全保留;next 一按接着演,演完抛句号。

4 @staticmethod 与 @classmethod —— 三种方法的区别

是什么?为什么学?

普通方法接收 self(实例);类方法接收 cls(类本身);静态方法两个都不接收。区别决定了「谁有资格调用、能访问什么」:普通方法操作实例数据,类方法适合「工厂方法」(换一种方式创建对象)和访问类属性,静态方法适合「逻辑上属于这个类、但不需要任何对象数据」的工具函数。

底层原理

三种方法的本质差异在绑定(binding):普通方法绑定到实例——obj.method() 自动把 obj 作为第一个参数;类方法绑定到类——cls.method() 自动把类作为第一个参数(且通过实例调用时,传的也是「实例的类」,不是实例本身);静态方法不绑定——就是放在类里的普通函数,怎么调用都不注入参数。@classmethod / @staticmethod 干的事,就是改变这种绑定方式。

生活类比

三种方法像员工办业务 vs 公司总机 vs 前台张贴的服务说明:员工要带工牌(self)操作自己的业务数据;打总机(cls)能办「公司级」的事;服务说明不需要身份,谁来了都能照着做。

注意事项总结

1 三种方法的签名与调用方式对比

class Tool:
    def normal(self):
        return "普通方法:我是", self

    @classmethod
    def create(cls):
        return cls()          # cls 就是 Tool 类本身,调用它会创建实例

    @staticmethod
    def add(a, b):
        return a + b

t = Tool()
print(t.normal())             # 普通方法:必须通过实例调用
print(Tool.create())          # 类方法:类和实例都能调用
print(Tool.add(1, 2))         # 静态方法:不接收 self/cls

运行结果:

普通方法:我是 <__main__.Tool object at 0x...>(示例输出)
<__main__.Tool object at 0x...>(示例输出)
3

t.normal() 里 self 自动绑定为 t;Tool.create() 里 cls 自动绑定为 Tool,cls() 就是在「用类本身创建实例」;Tool.add(1, 2) 直接调用,不注入任何参数。

2 classmethod 工厂方法——最经典的用途

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    @classmethod
    def from_string(cls, s):
        name, salary = s.split(",")
        return cls(name, int(salary))

    def __str__(self):
        return f"{self.name}: {self.salary}元"

e = Employee.from_string("张三,8000")
print(e)

运行结果:

张三: 8000元

from_string 是「工厂方法」——输入字符串格式,输出 Employee 实例。关键在 return cls(...)cls 是类本身,将来有人继承 Employee 写子类,子类.from_string(...) 返回的也是子类实例,所以用 cls 而不是写死 Employee。

易错点

  • 错误写法:静态方法里写 self.xxx → 问题:报 TypeError(静态方法不接收 self,self 未定义) → 正确写法:静态方法只用参数,不碰实例属性。
  • 错误写法:类方法里写 self → 问题:报 NameError 或参数错(第一个参数约定叫 cls) → 正确写法:类方法第一个参数写 cls
  • 错误写法:工厂方法里写死类名:return Employee(...) → 问题:子类继承后返回的仍是父类实例,绑定错误 → 正确写法:用 return cls(...)

记忆口诀:普通带 self 绑实例,类方法带 cls 绑类,静态方法不绑定;工厂方法用 cls,继承不翻车。

5 装饰器原理 —— 函数是一等公民,包装即增强

是什么?为什么学?

装饰器(decorator)是「接收一个函数、返回一个新函数的函数」。@log 写在函数上方,等价于 add = log(add)——把原函数「包装」进一个增强版。日志、计时、权限、缓存这类「横切关注点」(与业务无关、却到处都要)用装饰器写一次、到处复用,原函数一行不改。

底层原理

先决条件是「函数是一等公民」:函数可以像变量一样被传递、被返回。@decorator 只是语法糖:Python 在定义完函数后,自动执行 func = decorator(func)用返回值替换原名字。所以装饰器必须返回一个「可调用的东西」(通常是内部定义的 wrapper 函数),wrapper 里 *args, **kwargs 是为了把原函数的任意参数原样转发。wrapper 必须 return 原函数的结果,否则被装饰函数调用后的返回值变成 None(最常见的坑)。

生活类比

装饰器像手机壳:手机(原函数)功能不变,套上壳就多了防摔(日志)、支架(计时)等能力;壳不改变手机内部电路(不改原函数代码),想换壳随时换。

注意事项总结

1 函数是一等公民——函数能作为返回值(「加法器工厂」)

def make_adder(n):
    def adder(x):            # 在函数内部再定义函数
        return x + n
    return adder             # 函数作为返回值

add_10 = make_adder(10)
print(add_10(5))

运行结果:

15

函数还能当参数传——函数能像数据一样流转,这是装饰器能成立的地基。

2 @ 语法糖的真相——装饰器就是「手动包装」的简写

def shout(func):
    def wrapper():
        print("!!!")
        func()
        print("!!!")
    return wrapper

@shout                   # 语法糖:等价于 bye = shout(bye)
def bye():
    print("再见")

bye()

运行结果:

!!!
再见
!!!

@shout 写在 def 上面 = 定义完立刻执行 bye = shout(bye)——bye 这个名字从此指向 wrapper(里面「包裹」着原函数)。

3 wrapper 忘记 return 的坑——返回 None 的函数

def my_log(func):
    def wrapper(*args, **kwargs):
        print(f"调用 {func.__name__}")
        func(*args, **kwargs)        # 忘了 return!
    return wrapper

@my_log
def add(a, b):
    return a + b

print(add(1, 2))         # 结果是 None 而不是 3!

运行结果:

调用 add
None

add(1, 2) 实际执行的是 wrapper:打印日志、调用原函数算出了 3,但 wrapper 没有把 3 传出去(没有 return),所以返回值是 None——函数「白算了」,而且不报错、只是结果不对。修复:return func(*args, **kwargs)

4 装饰器带参数(外层再套一层函数,用来传配置)

def repeat(times):
    """装饰器工厂:让函数执行 times 次"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def hello():
    print("你好")

hello()

运行结果:

你好
你好
你好

易错点

  • 错误写法:wrapper 里忘了 return func(...) → 问题:被装饰函数返回 None,逻辑「静默出错」 → 正确写法:wrapper 里 return func(*args, **kwargs)
  • 错误写法:wrapper 参数写死 def wrapper(a, b) → 问题:装饰不同参数的函数就报错 → 正确写法:用 *args, **kwargs 通配转发。
  • 错误写法:装饰器直接返回原函数:return func → 问题:装饰器形同虚设,没有任何增强 → 正确写法:返回内部 wrapper(新函数)。

记忆口诀:一等公民可传递,包装替换是语法糖;wrapper 必须 return,否则结果变 None。

6 @property —— 把方法变成属性

是什么?为什么学?

@property 可以让 obj.属性 这种写法背后执行一段方法逻辑,最常见两个场景:计算属性(不存值、现算)和赋值校验(不能随便乱存)。

注意事项总结

1 计算属性 + 赋值校验

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14 * self._radius ** 2

c = Circle(2)
print(c.area)        # 不加括号,像属性一样访问

class Student:
    def __init__(self, score=0):
        self._score = 0
        self.score = score      # 赋值会走下面的 setter

    @property
    def score(self):
        return self._score

    @score.setter
    def score(self, value):
        if not 0 <= value <= 100:
            raise ValueError("分数必须在 0~100 之间")
        self._score = value

s = Student(85)
print(s.score)
s.score = 60         # 写:走 setter,带校验
print(s.score)
# s.score = 999      # 取消注释会抛 ValueError

运行结果:

12.56
85
60

为什么用 @property:外部代码写 obj.area 很自然,以后内部实现想改(比如换更精确的圆周率)也不影响外部调用方。

易错点

  • 错误写法:@property 方法名和内部属性同名(self.radius 又定义 def radius) → 问题:无限递归,报 RecursionError → 正确写法:内部存 self._radius,外部属性名用 radius

记忆口诀:property 变属性,点号就能取;setter 加校验,内部存下划线。

7 动手实践:数据流水线工具

设计一条「数据流水线」:源头生成器产生数字,经过「放大」、「过滤」两个加工环节,最后收集结果——把今天学的生成器真正串联起来:

# pipeline.py —— 数据流水线工具
def numbers(limit):
    """源头:逐个产出 1~limit"""
    for n in range(1, limit + 1):
        yield n


def multiply(factor):
    """加工环节工厂:把上游每个数乘以 factor"""
    def transform(source):
        for item in source:
            yield item * factor
    return transform


def filter_big(threshold):
    """加工环节工厂:只保留大于 threshold 的数"""
    def transform(source):
        for item in source:
            if item > threshold:
                yield item
    return transform


def collect(source):
    """末端:把数据收进列表"""
    return list(source)


def run_pipeline(limit, factor, threshold):
    source = numbers(limit)
    source = multiply(factor)(source)     # 环节1:放大
    source = filter_big(threshold)(source)  # 环节2:过滤
    result = collect(source)
    print("最终结果:", result)


run_pipeline(10, 3, 20)

运行结果:

最终结果: [21, 24, 27, 30]

1~10 乘 3 得到 3,6,...,30,再筛掉 <=20 的,就剩下 21,24,27,30。整条流水线每个环节都是生成器,数据一路「边算边流」,不会一次性造一个大列表。

升级挑战:再写一个「累加求和」环节(比如把过滤后的数全部加起来);试试把流水线环节用类组织,让每个环节是一个对象;或按知识点 5 的写法给 run_pipeline 包一个计时装饰器。

今日总结

迭代器协议:iter() 拿迭代器、next() 逐个取值、StopIteration 表示结束;for 循环就是这三步的语法糖;迭代器是一次性的(取完即空),可迭代对象 ≠ 迭代器 惰性求值:生成器「用到才算」,内存占用与数据总量无关,能处理大文件与无限序列 yield 是暂停键:状态全保留,next() 从上次暂停处继续执行;区分「调用生成器函数」与「执行生成器函数体」(前者只返回对象) 三种方法:普通方法(self,绑实例)、类方法(cls,绑类,适合工厂方法)、静态方法(不绑定) @property 把方法变成属性:计算属性 + setter 赋值校验 装饰器 = 接收函数返回函数的函数,@x 等价于 f = x(f);wrapper 必须 return func(...) 数据流水线工具能正常跑通全部环节(源头/加工/收集都是惰性生成器)

报错原因修复
StopIteration迭代器已经取完,还继续 next()for 循环自动处理结束,或自己捕获 StopIteration
TypeError: 'CountDown' object is not iterable类没实现 __iter__ 就想 for 遍历实现 __iter__(返回迭代器)和 __next__
TypeError: 'generator' object is not subscriptable生成器不支持 gen[0] 这种下标取值list(gen) 转成列表,或改用迭代
被装饰函数返回 Nonewrapper 里忘了 return func(*args, **kwargs)wrapper 里把结果 return 出去
AttributeError: 'function' object has no attribute 'xxx'装饰器把原函数换成了 wrapper,丢失了原函数信息wrapper 上再加 @functools.wraps(func)
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇