Scala编程与实践

Monads are Elephants(4) 翻译

2015-07-26  本文已影响225人  阿能是一只猫

前言

要问我为什么不从头开始翻译而偏偏从第四部分开始的话,是因为前三部分已经有大神翻过了。而原文我也是从那里提供的链接跳过去的,所以就接着开始读第四部分了。原文和前三部分链接在这里。

原文  
http://james-iry.blogspot.com/search/label/monads
前面部分的翻译
http://hongjiang.info/monads-are-elephants-part1-chinese
http://hongjiang.info/monads-are-elephants-part2-chinese
http://hongjiang.info/monads-are-elephants-part3-chinese

渣翻,轻喷。

Monads are Elephants Part 4

在你第一次摸到成年大象之前,你可不知道大象到底能有多大。我在这系列文章中把Monads比作大象,可是从开始直到现在我仅仅展示了一些诸如List, Option之类的婴儿级别的大象呢。不过现在是时候了,让我们来一起看下到底这头成年巨兽是什么样子的。有趣的是,他甚至会表演一点杂技给你看。

Functional Programming and IO

函数式编程中有一个常见的概念,叫做引用透明。引用透明意味着不论你什么时候在哪里调用一个特定的函数,只要调用的参数一致,返回的结果就一定会一样。显然的,引用透明的程序相比拥有一大堆状态的代码来说更加容易使用和调试。

但是有一件事情貌似对引用透明来说是不可能的:IO。想象下,当用户把自己吃的早餐作为字符串输入到命令行的时候,每个readLine函数显然会返回不同的字符串。类似的道理,发送网络包的函数也同时有成功和失败的可能。

但我们并不能为了程序的引用透明性而放弃使用IO。一个没有IO交互的程序充其量也只是别出心裁的消耗CPU的计算资源而已。

你可能猜到了,既然这个系列文章都在谈论Monads,那他应该能够提供一个解决方案。的确是这样,我接下来会从一些基本的概念开始介绍。虽然我只会用命令行读取和打印的例子来演示如何解决这个问题。但你显然可以举一反三的把这个方法用于任何其他的例如网络通信和文件读写的IO。

当然啦,你可能不觉得引用透明的IO在Scala中是不可缺少的。我也并不是在这里试图鼓吹任何纯净函数式引用透明的真理。我在这里仅仅是希望讨论Monads而事实上恰巧IO Monads能够非常形象深刻的帮助你理解其他Monads是怎么工作的。

The World In a Cup

从命令行读取字符串之所以不是引用透明的,是因为readLine函数的返回值是由用户的状态决定的,而用户却并不是调用readLine函数的参数。同样一个读取文件的函数读到的内容取决于文件系统的状态。读取指定web页面的函数则会收到目标web服务器,因特网,甚至本地网络的状态。同样,输出型的IO函数也会有类似的依赖关系。

所有这些都可以被归囊括在一个我们创建的叫做WorldState的类然后把它作为所有IO函数的输入和返回参数。不幸的是,这个世界貌似太大啦。我的第一次尝试最终以因内存耗尽而导致的编译器崩溃而失败告终。因此我决定选择一个和模拟整个世界相比不那么庞大的方法。在这里我们会需要引入一点马戏团魔术。

这个技巧就是仅仅用我们需要的那部分构造出整个世界,就是然后假装世界会对其他部分了若指掌。以下有一些我们需要考虑到的。

第三点有一点微妙所以我们现在考虑前两点来看看。

针对第一点,这里有一个粗糙的版本。

//file RTConsole.scala  
object RTConsole_v1 {  
  def getString(state: WorldState) =   
    (state.nextState, Console.readLine)  
  def putString(state: WorldState, s: String) =   
    (state.nextState, Console.print(s) )  
}  

getString和putString函数直接使用了在scala.Console中定义的原生函数。并且他们将世界的状态传入,然后将一个包含了世界状态和原生IO函数返回结果的元组传出。

接着我们来看第二点怎么实现。

//file RTIO.scala  
sealed trait WorldState{def nextState:WorldState}  
  
abstract class IOApplication_v1 {  
  private class WorldStateImpl(id:BigInt)   
      extends WorldState {  
    def nextState = new WorldStateImpl(id + 1)  
  }  
  final def main(args:Array[String]):Unit = {  
    iomain(args, new WorldStateImpl(0))  
  }  
  def iomain(  
      args:Array[String],   
      startState:WorldState):(WorldState, _)  
}  

我们将WorldState定义成一个密封的特质,因此它只能够在同一个源代码文件中被继承。然后我们在IOApplication中,以private的方式,定义了WorldState得唯一实现所以现在没有其他人可以继承它了。IOApplication还定义了一个不可被覆盖的main方法并且在里面调用了另一个需要在子类中实现的抽象方法iomain。注意我们在这里做的一切都是为了阻止那些使用IO库的程序员们直接访问到WorldState。

有了上面的铺垫,我们来一个HelloWorld的例子。

// file HelloWorld.scala  
class HelloWorld_v1 extends IOApplication_v1 {  
  import RTConsole_v1._  
  def iomain(  
        args:Array[String],   
        startState:WorldState) =   
    putString(startState, "Hello world")  
}  

That Darn Property 3

之前我们曾经提出过要求,整个世界在任何一个时刻都只会处在一个特定的状态。可惜,这点至今我尚未能解决,我们来看看这是为什么呢。

class Evil_v1 extends IOApplication_v1 {  
  import RTConsole_v1._  
  def iomain(  
      args:Array[String],   
      startState:WorldState) = {  
    val (stateA, a) = getString(startState)  
    val (stateB, b) = getString(startState)  
    assert(a == b)  
    (startState, b)  
  }  
}  

在这里,我连续两次调用getString函数并且他们的输入参数一致。如果我们的代码是引用透明的话,那么两次函数的返回值a和b,肯定是一致的。然后这显然并不可能,因为这两个返回值完全取决于用户分别两次输入命令行的内容。这里的问题是,在程序的执行的过程中的任一时刻,startState和其他的状态stateA,stateB一样,对程序员来说都是可见的。

Inside Out

作为解决这个问题的第一步,我决定把整个解决方案彻底推翻。与之前通过iomain函数来将WorldState进行转化的方式不同,这次的iomain将会直接返回这样一个供main方法调用的函数。代码是这样的。

//file RTConsole.scala  
object RTConsole_v2 {  
  def getString = {state:WorldState =>   
    (state.nextState, Console.readLine)}  
  def putString(s: String) = {state: WorldState =>   
    (state.nextState, Console.print(s))}  
}  

getString和putString函数不再直接对参数进行操作 - 这次他们将会返回一个新的等待WorldState传入然后才被执行的函数。

//file RTIO.scala  
sealed trait WorldState{def nextState:WorldState}  
  
abstract class IOApplication_v2 {  
  private class WorldStateImpl(id:BigInt)   
      extends WorldState {  
    def nextState = new WorldStateImpl(id + 1)  
  }  
  final def main(args:Array[String]):Unit = {  
    val ioAction = iomain(args)  
    ioAction(new WorldStateImpl(0));  
  }  
  def iomain(args:Array[String]):  
    WorldState => (WorldState, _)  
}  

现在IOApplication的main方法会调用iomain函数来获得它接下来将会执行的函数,接下来它将一个最初的世界状态传递给这个函数从执行。我们的HelloWorld看上去并没什么改变,除了他不再需要WorldState对象了。

//file HelloWorld.scala  
class HelloWorld_v2 extends IOApplication_v2 {  
  import RTConsole_v2._  
  def iomain(args:Array[String]) =   
    putString("Hello world")  
}  

起初看来,我们好像已经解决这个问题了,因为WorldState不在出现在我们的程序中了。但事实是怎样的呢,让我们接着看下去。

Oh That Darn Property 3

class Evil_v2 extends IOApplication_v2 {  
  import RTConsole_v2._  
  def iomain(args:Array[String]) = {          
    {startState:WorldState =>  
      val (statea, a) = getString(startState)  
      val (stateb, b) = getString(startState)  
      assert(a == b)  
      (startState, b)  
    }  
  }  
}  

但是只要我们如法炮制,就能通过一个貌似完全符合规范的函数让我们之前建立的规则再度悲剧了。看来只要程序员不受到限制而能够随意创建IO函数,那么他就能够看穿这个WorldState把戏。

Property 3 Squashed For Good

看上去我们需要防止程序员随意的创建签名符合要求的函数。恩。。。那现在该怎么做呢?

显而易见,在处理WorldState时我们已经能够做到防止程序员来实现它的子类了。所以接下来让我们把我们的整个函数也变成特质的形式。

sealed trait IOAction[+A] extends   
  Function1[WorldState, (WorldState, A)]   
  
private class SimpleAction[+A](  
   expression: => A) extends IOAction[A]...  

与WorldState不同,我们是需要创建IOAction的实例的。举例来说,我们接下来在另一个文件中将会定义的getString和putString函数就需要创建新的IOAction。我们这样做仅仅是让他们更加安全。这会让你有点摸不着头脑,直到意识到我们现在的getString和putString函数都具有两个不相干的部分组成:一部分调用原生IO,另一部分则将世界转化到下一个状态。让我们借助一点工厂方法来让代码变得更清楚。

//file RTIO.scala  
sealed trait IOAction_v3[+A] extends   
  Function1[WorldState, (WorldState, A)]   
  
object IOAction_v3 {  
  def apply[A](expression: => A):IOAction_v3[A] =   
    new SimpleAction(expression)  
  
  private class SimpleAction [+A](  
      expression: => A) extends IOAction_v3[A] {  
    def apply(state:WorldState) =   
      (state.nextState, expression)  
  }  
}  
  
sealed trait WorldState{def nextState:WorldState}  
  
abstract class IOApplication_v3 {  
  private class WorldStateImpl(id:BigInt)   
      extends WorldState {  
    def nextState = new WorldStateImpl(id + 1)  
  }  
  final def main(args:Array[String]):Unit = {  
    val ioAction = iomain(args)  
    ioAction(new WorldStateImpl(0));  
  }  
  def iomain(args:Array[String]):IOAction_v3[_]  
}  

IOAction对象是一个创建SimpleAction的工厂而已,通过使用“=> A”的注解,我们让它的构造函数需要传入一个会被延迟计算的表达式作为参数。这个表达式不会被计算直到SimpleAction的apply方法被调用。而为了调用这个apply方法,一个WorldState需要被传入。而返回的结果将是一个包含了新的WorldState和表达式结果的元组。

现在我们的IO方法看起来是这样子的。

//file RTConsole.scala  
object RTConsole_v3 {  
  def getString = IOAction_v3(Console.readLine)  
  def putString(s: String) =   
    IOAction_v3(Console.print(s))  
}  

最终我们的HelloWorld还和之前保持一样。

class HelloWorld_v3 extends IOApplication_v3 {  
  import RTConsole_v3._  
  def iomain(args:Array[String]) =   
    putString("Hello world")  
}  

现在我们似乎可以保证悲剧不再发生了。程序员不再能够接触到WorldState了。它被完全的密封起来了,main方法现在仅仅是传第一个WorldState给IOAction的apply方法,而我们没法通过定制IOAction子类的apply方法的方式来进行随意的IO操作了。

不幸的是,我们遇到了一点组合的问题。我们没法将多个IO操作进行组合了。因此我们不再能够简单的进行这样的对话了。“你叫什么名字?”,Bob, “你好Bob。”

额。IO操作是一个表达式的容器而Monads是容器。IO操作需要被组合而Monads是能够被组合的。也许。。。让我们来看下。

Ladies and Gentleman I Present the Mighty IO Monad

我们给IOAction的apply方法传入一个类型为A的参数,然后返回一个IOAction[A]的对象。这看起来很像“unit”。虽然它不是,但是对于现在来说,它已经足够像了。接下来只要我们知道对于这样的Monad我们的flatMap是什么,Monad法则就能告诉我们它对应的map函数应该是怎么样的。但我们需要怎样的flatMap呢。它的函数签名应该是这样的

def flatMap[B](f: A=>IOAction[B]):IOAction[B]。

所以这有什么用?

在这里我们希望它能够将一个操作链接到另一个返回IOAction的函数,并且在这个函数激活的时候能够顺序的调用这两个操作。换句话说,getString.flatMap{y => putString(y)}应该会返回一个IOAction的Monad,在执行的时候能够首先调用getString操作然后执行putString函数所返回的操作。试试看。

//file RTIO.scala  
sealed abstract class IOAction_v4[+A] extends   
    Function1[WorldState, (WorldState, A)] {  
  def map[B](f:A => B):IOAction_v4[B] =   
    flatMap {x => IOAction_v4(f(x))}    
  def flatMap[B](f:A => IOAction_v4[B]):IOAction_v4[B]=   
    new ChainedAction(this, f)  
    
  private class ChainedAction[+A, B](  
      action1: IOAction_v4[B],   
      f: B => IOAction_v4[A]) extends IOAction_v4[A] {  
    def apply(state1:WorldState) = {  
      val (state2, intermediateResult) =   
        action1(state1);  
      val action2 = f(intermediateResult)  
      action2(state2)  
    }  
  }    
}  
  
object IOAction_v4 {  
  def apply[A](expression: => A):IOAction_v4[A] =   
    new SimpleAction(expression)  
  
  private class SimpleAction[+A](expression: => A)   
      extends IOAction_v4[A] {  
    def apply(state:WorldState) =   
      (state.nextState, expression)  
  }      
}  
  
// the rest remains the same  
sealed trait WorldState{def nextState:WorldState}  
  
abstract class IOApplication_v4 {  
  private class WorldStateImpl(id:BigInt) ...  

IOAction的工厂和SimpleAction依然保持不变。IOAction类现在拥有了我们的Monad方法。按照Monad法则,map的行为由flatMap和我们选取的unit决定。看样子,对于我们的新IO行为ChainedAction来说,flatMap的实现是我们新方案的关键。

我们来看下ChainedAction的apply方法。首先它将最初的世界状态传递给action1来执行,得到了第二个世界状态以及一个中间的执行结果。它链接到的函数会需要这个执行结果并且由这个函数产生另一个操作:action2。action2通过第二个世界状态调用并且最终讲包含了所有结果的元组返回出来。记住,所有这一切都是延迟执行的,直到我们的main方法将最初的世界状态传递进来才会执行。

A Test Drive

你也许会有疑问,getString和putString函数既然仅仅是返回对应的IO动作而不是真正执行他们的话,为什么不干脆就直接叫做createGetStringAction和createPutStringAction呢。想要知道原因的话,我们来接着看看当把它们和我们的老朋友“for”糅杂之后会发生些什么呢。

object HelloWorld_v4 extends IOApplication_v4 {  
  import RTConsole_v4._  
  def iomain(args:Array[String]) = {  
    for{  
        _ <- putString(  
            "This is an example of the IO monad.");  
        _ <- putString("What's your name?");  
        name <- getString;  
        _ <- putString("Hello " + name)  
    } yield ()  
  }  
}  

看上去好像“for”和getString/putString一起组成了一种新的表达复杂IO行为的迷你语言。

Take a Deep Breath

现在让我们来总结一下。IOApplication建立了一个纯粹的体系。用户创建它的子类并且实现一个会被main方法调用的叫做iomain的方法。而这个方法会返回一个IOAction,它可能是一个单独的IO操作,也可能是一组链接起来的操作。这个操作不会立刻执行直到有人传递给它一个WorldState对象。这里ChainedAction类保证了WorldState在每个动作之间的变化和传递。

getString和putString并不像它们名字宣称的那样能够传入或输出字符串。事实上,它们返回IOActions。但是作为Monad,IOAction能够被嵌入for表达式,从而让它们看起来像是真的做了它们号称做的事情。

这是一个好的开始,我们已经几乎完成了一个完美的Monad。但这里还有两个问题。首先,因为unit会改变整个世界的状态,所以我们似乎稍微打破了一点Monad法则(e.g. m flatMap unit === m)。不过这点问题不大,因为在这里这个变化是不可见的。不过我们最好还是想办法修复它。

第二个问题则是。众所周知,IO是有失败的可能的,而我们目前还没有考虑怎么样去处理它们。

IO Errors

对Monad来说,失败是用Zero来表示的。因此我们只要将这里的失败的定义(异常)对应到我们Monad的概念中就可以了。这里我要换一个套路:我会给出一个最终版的程序,我会在期间对它们一一说明来帮助你理解。

IOAction对象保持了带有若干个工厂方法和私有实现类的方便的模块的形式(也许把它们写成匿名类更好,不过带有名字的话比较便于我进行说明)。SimpleAction保持原样而IOAction的apply方法是它的工厂方法。

//file RTIO.scala  
object IOAction {  
  private class SimpleAction[+A](expression: => A)   
      extends IOAction[A] {  
    def apply(state:WorldState) =   
      (state.nextState, expression)  
  }  
  
  def apply[A](expression: => A):IOAction[A] =   
    new SimpleAction(expression)  

UnitAction是“unit”的操作 - 一个返回特定值但不改变世界状态的操作。unit方法是它的一个工厂方法。把它从SimpleAction那里分离开来看上去有点奇怪,不过我们最好还是养成良好的习惯,按照Monad的规则来处理它们。

private class UnitAction[+A](value: A)   
    extends IOAction[A] {  
  def apply(state:WorldState) =   
    (state, value)  
}  
  
def unit[A](value:A):IOAction[A] =   
  new UnitAction(value)  

FailureAction是我们用来表示Zero的类。这是一个总是会抛出异常的IO操作。我们自定义的UserException是从其中抛出的异常之一。这里的fail和ioError方法都是我们用来创建Zero的工厂方法。fail方法读入一个字符串然后返回一个会抛出UserException的action,ioError则将一个任意异常作为参数,返回一个会抛出该异常的action。

  private class FailureAction(e:Exception)   
      extends IOAction[Nothing] {  
    def apply(state:WorldState) = throw e  
  }  
    
  private class UserException(msg:String)   
    extends Exception(msg)  
  
  def fail(msg:String) =   
    ioError(new UserException(msg))      
  def ioError[A](e:Exception):IOAction[A] =   
    new FailureAction(e)  
}  

IOAction的flatMap方法和ChainedAction保持原样。map函数现在会直接调用unit方法所以它也是符合Monad法则的。除此之外,我还另外添加了两个操作符>>和<<。如同flatMap会将一个返回另一个action的函数拼接到这个action之后,>>和<<将另一个action直接拼接到这个action之后。这仅仅是一个返回值的问题,>>读作“then”,有了它我们就可以做到创建一个返回第二个操作结果的动作。因此putString "What's your name" >> getString 就能创建一个首先输出提示符然后读入用户输入的操作。而相反,<<读作“before”,则会创建一个会返回第一个操作结果的动作。

sealed abstract class IOAction[+A]   
    extends Function1[WorldState, (WorldState, A)] {  
  def map[B](f:A => B):IOAction[B] =   
    flatMap {x => IOAction.unit(f(x))}    
  def flatMap[B](f:A => IOAction[B]):IOAction[B]=  
    new ChainedAction(this, f)  
  
  private class ChainedAction[+A, B](  
      action1: IOAction[B],   
      f: B => IOAction[A]) extends IOAction[A] {  
    def apply(state1:WorldState) = {  
      val (state2, intermediateResult) =   
        action1(state1);  
      val action2 = f(intermediateResult)  
      action2(state2)  
    }  
  }    
  
  def >>[B](next: => IOAction[B]):IOAction[B] =  
    for {  
      _ <- this;  
      second <- next  
    } yield second  
      
  def <<[B](next: => IOAction[B]):IOAction[A] =  
    for {  
      first <- this;  
      _ <- next  
    } yield first  

由于我们现在定义好Zero了,我们很容易就能够遵循Monad法则来添加一个filter方法。这里我创建了两种形式的filter方法。第一种允许传入一个用户自定义的消息来提示为什么filter不兼容而第二种则延续Scala的标准规范,使用通用的错误消息。

def filter(  
    p: A => Boolean,   
    msg:String):IOAction[A] =  
  flatMap{x =>   
    if (p(x)) IOAction.unit(x)   
    else IOAction.fail(msg)}  
def filter(p: A => Boolean):IOAction[A] =  
  filter(p, "Filter mismatch")  

Zero还意味着我们能够实现Monad的加法了。为了实现他,我们需要做一点准备工作。HandlingAction能够包裹住另一个action类,并且在那个action抛出异常的时候将异常传递给一个特定的处理函数。onError则是一个专门创建HandlingAction的工厂方法。最后,“or”是我们的Monad加法。简单说,它会在一个action失败的时候尝试运行另外那个可选的action。

private class HandlingAction[+A](  
    action:IOAction[A],  
    handler: Exception => IOAction[A])   
    extends IOAction[A] {  
  def apply(state:WorldState) = {  
    try {  
      action(state)  
    } catch {  
      case e:Exception => handler(e)(state)  
    }  
  }      
}  
  
def onError[B >: A](  
    handler: Exception => IOAction[B]):  
    IOAction[B] =   
  new HandlingAction(this, handler)        
  
def or[B >: A](  
    alternative:IOAction[B]):IOAction[B] =  
  this onError {ex => alternative}  

最终版本的IOApplication保持原样

sealed trait WorldState{def nextState:WorldState}  
  
abstract class IOApplication {  
  private class WorldStateImpl(id:BigInt)   
      extends WorldState {  
    def nextState = new WorldStateImpl(id + 1)  
  }  
  final def main(args:Array[String]):Unit = {  
    val ioaction = iomain(args)  
    ioaction(new WorldStateImpl(0));  
  }  
  def iomain(args:Array[String]):IOAction[_]  
}  

RTConsole同样也几乎没变,不过我给它添加了一个类似于println的putLine�方法。我还把getString方法设置为了val变量。为什么不呢?它并不会改变。

//file RTConsole.scala  
object RTConsole {  
  val getString = IOAction(Console.readLine)  
  def putString(s: String) =   
    IOAction(Console.print(s))  
  def putLine(s: String) =   
    IOAction(Console.println(s))  
}    

现在是时候给我们的HelloWorld程序添加点新功能了。sayHello方法通过一个字符串返回了一个action,如果它认得出这个名字那么就会给他打个招呼,不然就会返回一个会导致失败的action。

Ask是一个方便的创建提示符然后读入字符串的方法,我们的>>操作符保证了这个action的结果会是getString返回的字符串。

processsString方法读入一个任意字符串,如果读到的是“quit”的话,它就会返回一个会向你告别的action然后退出。其他情况下,它会再次调用sayHello方法并将你输入的字符串传入,然后为了防止失败还将sayHello返回的action和另一action用or进行了组合。最后它会再次调用一个叫做loop的action。

Loop是很有趣的方法。它被定义成了一个val,当然用def也没什么不好。它其实是一个递归的函数而并不是一个普通的循环。它的定义中用到processString方法而恰巧processString方法也是基于loop来定义的。

最后是iomain方法,它仅仅是创建了一个会打印自我介绍语句然后会再次调用loop的action。

警告:由于这个库中loop方法的实现问题,最终这些代码可能会导致栈溢出。不要在任何生产环境中用这段代码,你能从上面的注解里看到原因。

object HelloWorld extends IOApplication {  
  import IOAction._  
  import RTConsole._  
    
  def sayHello(n:String) = n match {  
    case "Bob" => putLine("Hello, Bob")  
    case "Chuck" => putLine("Hey, Chuck")  
    case "Sarah" => putLine("Helloooo, Sarah")  
    case _ => fail("match exception")  
  }  
    
  def ask(q:String) =  
    putString(q) >> getString  
  
  def processString(s:String) = s match {  
    case "quit" => putLine("Catch ya later")  
    case _ => (sayHello(s) or           
        putLine(s + ", I don't know you.")) >>  
  
        loop   
  }  
      
  val loop:IOAction[Unit] =   
    for {  
      name <- ask("What's your name? ");  
      _ <- processString(name)  
    } yield ()  
    
  def iomain(args:Array[String]) = {  
    putLine(  
        "This is an example of the IO monad.") >>  
    putLine("Enter a name or 'quit'") >>  
    loop  
  }  
}  

结论

在这篇文章中,我把IO Monad称为IO Action来让大家更好的理解它们是等待被执行的动作。也许有的人会认为在Scala中,IO Monad并没有太大的实际意义。没关系,我的本意也并不是在这里鼓吹函数引用透明性。IO Monad只是作为我们目前遇到的Monad中唯一从各种意义上来说都不是容器类型的简单例子来介绍。

事实上,IO Monad依然可以被看做是容器,只是与普通包含值的容器不同,它包含的是表达式。它通过flatMap和map函数依次将嵌套的表达式来组成更加复杂的表达式。

也许从更深层次的角度可以把IO Monad看做一个函数或是抽象的计算。而flatMap的作用可以看做是把函数应用于计算而创建更加复杂计算的。

在这个系列的最后部分,我会介绍一种将容器和计算模型统一的抽象方法。但首先我要通过展示一个稍微复杂一点的运用了大量Monad的应用来向你们展示一下到底它有多重要。

上一篇下一篇

猜你喜欢

热点阅读