[译]PEP 342--增强型生成器:协程

adorable-animal-animal-photography-978555.jpg

PEP原文 : https://www.python.org/dev/peps/pep-0342/

PEP标题: Coroutines via Enhanced Generators

PEP作者: Guido van Rossum, Phillip J. Eby

创建日期: 2005-05-10

合入版本: 2.5

译者豌豆花下猫Python猫 公众号作者)

目录

  • 简介
  • 动机
  • 规格摘要
  • 规格:将值发送到生成器
    • 新的生成器方法:send(value)
    • 新的语法:yield 表达式
  • 规格:异常和清理
    • 新语法:yield 允许在try-finally
    • 新的生成器方法:throw(type,value = None,traceback = None)
    • 新的标准异常:GeneratorExit
    • 新的生成器方法:close()
    • 新的生成器方法:__del__()
  • 可选的扩展
    • 扩展的 continue 表达式
  • 未决问题
  • 示例
  • 参考实现
  • 致谢
  • 参考文献
  • 版权

简介

这个 PEP 在生成器的 API 和语法方面,提出了一些增强功能,使得它们可以作为简单的协程使用。这基本上是将下述两个 PEP 的想法结合起来,如果它被采纳,那它们就是多余的了:

  • PEP-288,关于生成器的属性特征与异常(Attributes and Exceptions)。当前 PEP 沿用了它的下半部分,即生成器的异常(事实上,throw() 的方法名就取自 PEP-288)。PEP-342 用 yield 表达式(这个概念来自 PEP-288 的早期版本)来替换了生成器的属性特征。
  • PEP-325,生成器支持释放资源。PEP-342 收紧了 PEP-325 中的一些松散的规范,使其更适用于实际的实现。

(译注:PEP-288 和 PEP-325 都没有被采纳通过,它们的核心内容被集成到了 PEP-342里。)

动机

协程是表达许多算法的自然方式,例如模拟/仿真、游戏、异步 I/O、以及其它事件驱动编程或协同的多任务处理。Python 的生成器函数几乎就是协程——但不完全是——因为它们允许暂停来生成值,但又不允许在程序恢复时传入值或异常。它们也不允许在 try-finally 结构的 try 部分作暂停,因此很难令一个异常退出的(aborted)协程来清理自己。

同样地,当其它函数在执行时,生成器不能提供控制,除非这些函数本身是生成器,并且外部生成器之所以写了去 yield,是要为了响应内部生成器所 yield 的值。这使得即使是相对简单的实现(如异步通信)也变得复杂,因为调用任意函数,要么需要生成器变堵塞(block,即无法提供控制),要么必须在每个要调用的函数的周围,添加一大堆引用循环代码(a lot of boilerplate looping code)。

但是,如果有可能在生成器挂起的点上传递进来值或者异常,那么,一个简单的协程调度器或蹦床函数(trampoline function)就能使协程相互调用且不用阻塞——对异步应用程序有巨大好处。这些应用程序可以编写协程来运行非阻塞的 socket I/O,通过给 I/O 调度器提供控制,直到数据被发送或变为可用。同时,执行 I/O 的代码只需像如下方式操作,就能暂停执行,直到 nonblocking_read() 继续产生一个值:

data = (yield nonblocking_read(my_socket, nbytes))

换句话说, 通过给语言和生成器类型增加一些相对较小的增强,Python 不需要为整个程序编写一系列回调,就能支持异步操作,并且对于本该需要数百上千个协作式的多任务伪线程的(co-operatively multitasking pseudothreads)程序,也可以不需要使用资源密集型线程。因此,这些增强功能将给标准 Python 带来 Stackless Python 的许多优点,又无需对 CPython 核心及其 API 进行任何重大的修改。此外,这些增强在任何已经支持生成器的 Python 实现(例如 Jython)上都是可落实的。

规格摘要

通过给生成器类型增加一些简单的方法,以及两个微小的语法调整,Python 开发者就能够使用生成器函数来实现协程与其它的协作式多任务。这些方法和调整是:

  1. 重定义 yield 为表达式(expression),而不是语句(statement)。当前的 yield 语句将变成一个 yield 表达式,其值将被丢弃。每当通过正常的 next() 调用来恢复生成器时,yield 表达式的返回值是 None。
  2. 为生成器(generator-iterator)添加一个新的 send() 方法,它会恢复生成器,并且 send 一个值作为当前表达式的结果。send() 方法返回的是生成器产生的 next 值,若生成器没有产生值就退出的话,则抛出 StopIteration
  3. 为生成器(generator-iterator)添加一个新的 throw() 方法,它在生成器暂停处抛出异常,并返回生成器产生的下一个值,若生成器没有产生值就退出的话,则抛出 StopIteration (如果生成器没有捕获传入的异常,或者它引发了其它异常,则该异常会传递给调用者。)
  4. 为生成器(generator-iterator)添加一个新的 close() 方法,它在生成器暂停处引发 GeneratorExit 。如果生成器在之后引发 StopIteration (通过正常退出,或者已经被关闭)或 GeneratorExit (通过不捕获异常),则 close() 返回给其调用者。如果生成器产生一个值,则抛出 RuntimeError。如果生成器引发任何其它异常,也会传递给调用者。如果生成器已经退出(异常退出或正常退出),则 close() 不执行任何操作。
  5. 增加了支持,确保即使在生成器被垃圾回收时,也会调用 close()。
  6. 允许 yield 在 try-finally 块中使用,因为现在允许在 finally 语句中执行垃圾回收或显式地调用 close() 。

实现了所有这些变更的原型补丁已经可用了,可作为当前 Python CVS HEAD 的 SourceForge 补丁。# 1223381

设计规格:将值发送进生成器

新的生成器方法:send(value)

为生成器提出了一种新的方法,即 send() 。它只接收一个参数,并将它发送给生成器。调用 send(None) 完全等同于调用生成器的 next() 方法。使用其它参数调用 send() 也有同样的效果,不同的是,当前生成器表达式产生的值会不一样。

因为生成器在生成器函数体的头部执行,所以在刚刚创建生成器时不会有 yield 表达式来接收值,因此,当生成器刚启动时,禁止使用非 None 参数来调用 send() ,如果调用了,就会抛出 TypeError (可能是由于某种逻辑错误)。所以,在与协程通信前,必须先调用 next() 或 send(None) ,来将程序推进到第一个 yield 表达式。

与 next() 方法一样,send() 方法也返回生成器产生的下一个值,或者抛出 StopIteration 异常(当生成器正常退出,或早已退出时)。如果生成器出现未捕获的异常,则它会传给调用者。

新语法:yield 表达式

yield 语句(yield-statement)可以被用在赋值表达式的右侧;在这种情况下,它就是 yield 表达式(yield-expression)。除非使用非 None 参数调用 send() ,否则 yield 表达式的值就是 None。见下文。

yield 表达式必须始终用括号括起来,除非它是作为顶级表达式而出现在赋值表达式的右侧。所以,下面例子都是合法的:

x = yield 42
x = yield
x = 12 + (yield 42)
x = 12 + (yield)
foo(yield 42)
foo(yield)

而下面的例子则是非法的(举了一些特例的原因是,当前的 yield 12,42 是合法的):

x = 12 + yield 42
x = 12 + yield
foo(yield 42, 12)
foo(yield, 12)

请注意,如今没有表达式的 yield-语句 和 yield-表达式是合法的。这意味着:当 next() 调用中的信息流被反转时,应该可以在不传递显式的值的情况下 yield (yield 当然就等同于 yield None)。

当调用 send(value) 时,它恢复的 yield 表达式将返回传入的值。当调用 next() 时,它恢复的 yield 表达式将返回 None。如果 yield-表达式(yield-expression)是一个 yield-语句(yield-statement),其返回值会被忽略,就类似于忽略用作语句的函数的返回值。

实际上,yield 表达式就像一个反函数调用(inverted function);它所 yield 的值实际上是当前函数返回(生成)的,而它 return 的值则是通过 send() 传入的参数。

提示:这样的拓展语法,使得它非常地接近于 Ruby。这是故意的。请注意,Python 在阻塞时,通过使用 send(EXPR) 而不是 return EXPR 来传值给生成器,并且在生成器与阻塞之间传递控制权的底层机制完全不同。Python 中的阻塞不会被编译成 thunk,相反,yield 暂停生成器的执行进度。有一些不是这样的特例,在 Python 中,你不能保存阻塞以供后续调用,并且你无法测试是否存在着阻塞。(XXX - 关于阻塞的这些东西似乎不合适,或许 Guido 会编辑下,做澄清。)

设计规格:异常和清理

让生成器对象成为通过调用生成器函数而生成的迭代器。本节中的 g 指的都是生成器对象。

新语法:yield 允许在 try-finally 里

生成器函数的语法被拓展了,允许在 try-finally 语句中使用 yield 语句。

新的生成器方法:throw(type,value = None,traceback = None)

g.throw(type, value, traceback) 会使生成器在挂起的点处抛出指定的异常(即在 yield 语句中,或在其函数体的头部、且还未调用 next() 时)。如果生成器捕获了异常,并生成了新的值,则它就是 g.throw() 的返回值。如果生成器没有捕获异常,那 throw() 也会抛出同样的异常(它溜走了)。如果生成器抛出其它异常(包括返回时产生的 StopIteration),那该异常会被 throw() 抛出。总之,throw() 的行为类似于 next() 或 send(),除了它是在挂起点处抛出异常。如果生成器已经处于关闭状态,throw() 只会抛出经过它的异常,而不去执行生成器的任何代码。

抛出异常的效果完全像它所声明的那样:

raise type, value, traceback

会在暂停点执行。type 参数不能是 None,且 type 与 value 的类型必须得兼容。如果 value 不是 type 的实例(instance),则按照 raise 语句创建异常实例的规则,用 value 来生成新的异常实例。如果提供了 traceback 参数,则它必须是有效的 Python 堆栈(traceback)对象,否则会抛出 TypeError 。

注释:选择 throw() 这个名称,有几个原因。Raise 是一个关键字,因此不能作为方法的名称。与 raise 不同(它在执行点处即时地抛出异常),throw() 首先恢复生成器,然后才抛出异常。单词 throw 意味着将异常抛在别处,并且跟其它语言相关联。

考虑了几个替代的方法名:resolve(), signal(), genraise(), raiseinto()flush() 。没有一个像 throw() 那般合适。

新的标准异常:GeneratorExit

定义了一个新的标准异常 GeneratorExit,继承自 Exception。生成器应该继续抛出它(或者就不捕获它),或者通过抛出 StopIteration 来处理这个问题。

新的生成器方法:close()

g.close() 由以下伪代码定义:

def close(self):
    try:
        self.throw(GeneratorExit)
    except (GeneratorExit, StopIteration):
        pass
    else:
        raise RuntimeError("generator ignored GeneratorExit")
    # Other exceptions are not caught

新的生成器方法:__del__()

g.__ del __() 是 g.close() 的装饰器。当生成器对象被作垃圾回收时,会调用它(在 CPython 中,则是它的引用计数变为零时)。如果 close() 引发异常, 异常的堆栈信息(traceback)会被打印到 sys.stderr 并被忽略掉;它不会退回到触发垃圾回收的地方。这与类实例在处理 __del__()的异常时的方法一样。

如果生成器对象被循环引用,则可能不会调用 g.__del__() 。这是当前 CPython 的垃圾收集器的表现。做此限制的原因是,GC 代码需要在一个任意点打破循环,以便回收它,在此之后,不允许 Python 代码“看到”形成循环的对象,因为它们可能处于无效的状态。被用于解开(hanging off)循环的对象不受此限制。

尽管实际上不太可能看到生成器被循环引用。但是,若将生成器对象存储在全局变量中,则会通过生成器框架的 f_globals 指针创建一个循环。另外,若在数据结构中存储对生成器对象的引用,且该数据结构被作为参数传递给生成器,这也会创造一个循环引用(例如,如果一个对象具有一个作为生成器的方法,并持有由该方法创建的运行中的迭代器的引用)。鉴于生成器的典型用法,这些情况都不太可能。

此外,CPython 在实现当前 PEP 时,每当由于错误或正常退出而终止执行时,会释放被生成器使用的框架对象(frame object)。这保证了那些无法被恢复的生成器不会成为无法回收的循环引用的部分。这就允许了其它代码在 try-finally 或 with 语句中使用 close() (参考 PEP-343),确保了给定的生成器会正确地完结。

可选扩展

扩展的 continue 语句

本 PEP 的早期草案提出了一种新的 continue EXPR 语法,用于 for 循环(继承自 PEP-340),将 EXPR 的值传给被遍历的迭代器。此功能暂时被撤销了,因为本 PEP 的范围已经缩小,只关注将值传给生成器迭代器(generator-iterator),而非其它类型的迭代器。Python-Dev 邮件列表中的一些人也觉得为这个特定功能添加新语法是为时过早(would be premature at best)。

未决问题

Python-Dev 邮件的讨论提出了一些未决的问题。我罗列于此,附上我推荐的解决方案与它的动机。目前编写的 PEP 也反映了这种喜好的解决方案。

  1. 当生成器产生另一个值作为对“GeneratorExit”异常的响应时,close()应该引发什么异常?

    我最初选择了 TypeError ,因为它表示生成器函数发生了严重的错误行为,应该通过修改代码来修复。但是 PEP-343 中的 with_template 装饰器类使用了 RuntimeError 来进行类似处理。可以说它们都应该使用相同的异常。我宁愿不为此目的引入新的异常类,因为它不是我希望人们捕获的异常:我希望它变成一个 traceback 给程序员看到,然后进行修复。所以我觉得它们都应该抛出 RuntimeError 。有一些先例:在检测到无限递归的情况下,或者检测到未初始化的对象(由于各种各样的原因),核心 Python 代码会抛出该异常。

  2. Oren Tirosh 建议将 send() 方法重命名为 feed() ,以便能跟 consumer 接口兼容(规范参见:http://effbot.org/zone/consumer.htm)。

然而,仔细观察 consumer 接口,似乎 feed() 所需的语义与 send() 不同,因为后者不能在刚启动的生成器上作有意义的调用。此外,当前定义的 consumer 接口不包含对 StopIteration 的处理。

因此,创建一个贴合 consumer 接口的简单的装饰器,来装饰生成器函数,似乎会更有用。举个例子,它可以用初始的 next() 调用给生成器预热(warm up),追踪 StopIteration,甚至可以通过重新调用生成器来提供 reset() 用途。

示例

  1. 一个简单的 consumer 装饰器,它使生成器函数在最初调用时,就自动地前进到第一个 yield 点:
def consumer(func):
    def wrapper(*args,**kw):
        gen = func(*args, **kw)
        gen.next()
        return gen
    wrapper.__name__ = func.__name__
    wrapper.__dict__ = func.__dict__
    wrapper.__doc__  = func.__doc__
    return wrapper
  1. 一个使用 consumer 装饰器创建反向生成器(reverse generator)的示例,该生成器接收图像并创建缩略图,再发送给其它 consumer。像这样的函数可以链接在一起,形成 consumer 间的高效处理流水线,且每个流水线都可以具有复杂的内部状态:
@consumer
def thumbnail_pager(pagesize, thumbsize, destination):
    while True:
        page = new_image(pagesize)
        rows, columns = pagesize / thumbsize
        pending = False
        try:
            for row in xrange(rows):
                for column in xrange(columns):
                    thumb = create_thumbnail((yield), thumbsize)
                    page.write(
                        thumb, col*thumbsize.x, row*thumbsize.y )
                    pending = True
        except GeneratorExit:
            # close() was called, so flush any pending output
            if pending:
                destination.send(page)

            # then close the downstream consumer, and exit
            destination.close()
            return
        else:
            # we finished a page full of thumbnails, so send it
            # downstream and keep on looping
            destination.send(page)

@consumer
def jpeg_writer(dirname):
    fileno = 1
    while True:
        filename = os.path.join(dirname,"page%04d.jpg" % fileno)
        write_jpeg((yield), filename)
        fileno += 1


# Put them together to make a function that makes thumbnail
# pages from a list of images and other parameters.
#
def write_thumbnails(pagesize, thumbsize, images, output_dir):
    pipeline = thumbnail_pager(
        pagesize, thumbsize, jpeg_writer(output_dir)
    )

    for image in images:
        pipeline.send(image)

    pipeline.close()
  1. 一个简单的协程调度器或蹦床(trampoline),它允许协程通过 yield 其它协程,来调用后者。被调用的协程所产生的非生成器的值,会被返回给调用方的协程。类似地,如果被调用的协程抛出异常,该异常也会传导给调用者。实际上,只要你用 yield 表达式来调用协程(否则会阻塞),这个例子就模拟了 Stackless Python 中使用的简单的子任务(tasklet)。这只是一个非常简单的例子,但也可以使用更复杂的调度程序。(例如,现有的 GTasklet 框架 (http://www.gnome.org/~gjc/gtasklet/gtasklets.html) 和 peak.events 框架 (http://peak.telecommunity.com/) 已经实现类似的调度功能,但大多数因为无法将值或异常传给生成器,而必须使用很尴尬的解决方法。)
import collections

class Trampoline:
    """Manage communications between coroutines"""

    running = False

    def __init__(self):
        self.queue = collections.deque()

    def add(self, coroutine):
        """Request that a coroutine be executed"""
        self.schedule(coroutine)

    def run(self):
        result = None
        self.running = True
        try:
            while self.running and self.queue:
               func = self.queue.popleft()
               result = func()
            return result
        finally:
            self.running = False

    def stop(self):
        self.running = False

    def schedule(self, coroutine, stack=(), val=None, *exc):
        def resume():
            value = val
            try:
                if exc:
                    value = coroutine.throw(value,*exc)
                else:
                    value = coroutine.send(value)
            except:
                if stack:
                    # send the error back to the "caller"
                    self.schedule(
                        stack[0], stack[1], *sys.exc_info()
                    )
                else:
                    # Nothing left in this pseudothread to
                    # handle it, let it propagate to the
                    # run loop
                    raise

            if isinstance(value, types.GeneratorType):
                # Yielded to a specific coroutine, push the
                # current one on the stack, and call the new
                # one with no args
                self.schedule(value, (coroutine,stack))

            elif stack:
                # Yielded a result, pop the stack and send the
                # value to the caller
                self.schedule(stack[0], stack[1], value)

            # else: this pseudothread has ended

        self.queue.append(resume)
  1. 一个简单的 echo 服务器以及用蹦床原理实现的运行代码(假设存在 nonblocking_readnonblocking_write 和其它 I/O 协程,该例子在连接关闭时抛出 ConnectionLost ):
# coroutine function that echos data back on a connected
# socket
#
def echo_handler(sock):
    while True:
        try:
            data = yield nonblocking_read(sock)
            yield nonblocking_write(sock, data)
        except ConnectionLost:
            pass  # exit normally if connection lost

# coroutine function that listens for connections on a
# socket, and then launches a service "handler" coroutine
# to service the connection
#
def listen_on(trampoline, sock, handler):
    while True:
        # get the next incoming connection
        connected_socket = yield nonblocking_accept(sock)

        # start another coroutine to handle the connection
        trampoline.add( handler(connected_socket) )

# Create a scheduler to manage all our coroutines
t = Trampoline()

# Create a coroutine instance to run the echo_handler on
# incoming connections
#
server = listen_on(
    t, listening_socket("localhost","echo"), echo_handler
)

# Add the coroutine to the scheduler
t.add(server)

# loop forever, accepting connections and servicing them
# "in parallel"
#
t.run()

参考实现

实现了本 PEP 中描述的所有功能的原型补丁已经可用,参见 SourceForge 补丁 1223381 (https://bugs.python.org/issue1223381)。

该补丁已提交到 CVS,2005年8月 01-02。

致谢

Raymond Hettinger (PEP 288) 与 Samuele Pedroni (PEP 325) 第一个正式地提出将值或异常传递给生成器的想法,以及关闭生成器的能力。Timothy Delaney 建议了本 PEP 的标题,还有 Steven Bethard 帮忙编辑了早期的版本。另见 PEP-340 的致谢部分。

参考文献

TBD.

版权

本文档已经放置在公共领域。

源文档:https://github.com/python/peps/blob/master/pep-0342.txt

----------------(译文完)--------------------

相关链接:

PEP背景知识学习Python,怎能不懂点PEP呢?

PEP翻译计划https://github.com/chinesehuazhou/peps-cn

[译] PEP 255--简单的生成器

[译]PEP 525--异步生成器

花下猫语: 唠叨几句吧,年前这几周事情太多了,挤着时间好歹是又翻译出一篇 PEP。与生成器密切相关的 PEP 已经完成 3/4,年后再译最后一篇(PEP-380)。当初翻译第一篇,完全是一时兴起,直觉这是一件有意义的事,现在呢,这个念头开始有点膨胀——我竟然在 Github 上建了个翻译项目。我深知,自己水平实在有限,因此不求得到多少认同吧。但行好事,莫问前程。不过,若有人帮着吆喝一声,也是极好的。

-----------------

本文原创并首发于微信公众号【Python猫】,后台回复“爱学习”,免费获得20+本精选电子书。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容