当我第一次听到iteratee这个词时,我以为是个玩笑。 事实证明,这不是在开玩笑,实际上也有枚举数(没关系)和枚举数(您正在杀死我)。 如果您是命令式程序员,或者更喜欢编写命令式代码而不是函数式代码的程序员,那么您可能会对所有在那里进行迭代的介绍不满意,因为它们都假定您从功能的角度考虑。 好吧,我刚刚学习了迭代,尽管我每天对函数式编程越来越感到自在,但我仍然觉得自己像一个刻不容缓的程序员。 这使得学习迭代对我来说非常困难。 因此,尽管我仍处于命令式思维模式,但我认为这是一个很好的机会,可以从命令式程序员的角度解释迭代,而无需理会任何功能知识。 如果您是想学习迭代的命令式程序员,那么这是适合您的博客文章。 我将专门研究Play的Iteratee API,但此处学习的概念通常适用于所有Iteratees。
因此,让我们从解释迭代及其同行尝试实现的目标开始。 迭代是一种非常容易组合的,反应性地处理数据流的方法。 反应式是指非阻塞,即您对可读取的数据做出反应,并对写入数据的机会做出反应。 所谓可组合,是指您编写完成一件小事情的简单迭代,然后将其用作构建更大功能的迭代的基础,然后将它们用作构建更大功能的迭代的基础,以此类推。 在每个阶段,所有事情都是简单易懂的。
反应式流处理
如果您正在寻找有关迭代对象的信息,那么我想您已经对什么是反应式流处理有所了解。 让我们将其与同步IO代码进行对比:
trait InputStream {
def read(): Byte
}
因此,这应该非常熟悉,如果您想读取一个字节,可以调用read
。 如果当前没有字节可读取,则该调用将阻塞,您的线程将等待直到一个字节可用。 对于反应式流,显然是另一回事,您将回调传递到要从中接收数据的流,并且在准备向您提供数据时将调用该回调。 因此,通常您可以实现如下所示的特征:
trait InputStreamHandler {
def onByte(byte: Byte)
}
因此,在继续之前,让我们看一下在纯功能世界中将如何实现相同的目标。 在这一点上,我不希望您问为什么我们要用这种方式做事,您稍后会看到,但是如果您对函数式编程一无所知,那么您就会知道一切都趋于不可变,并且函数没有一方。效果。 上面的特征必须具有副作用,因为除非您忽略传递给onByte
的字节, onByte
您必须在该函数中以某种方式更改状态(或其他状态)。 那么,如何在不更改状态的情况下处理数据? 答案与其他不可变数据结构的工作方式相同,我们返回自己的副本,并用新状态更新。 因此,如果要使用InputStreamHandler
,则可能看起来像这样:
trait InputStreamHandler {
def onByte(byte: Byte): InputStreamHandler
}
一个将输入读入seq的示例实现可能如下所示:
class Consume(data: Seq[Byte]) extends InputStreamHandler {
def onByte(byte: Byte) = new Consume(data :+ byte)
}
因此,我们现在具有对输入流做出反应的命令性和功能性特征,您可能会认为这就是对反应流的全部处理。 如果是这样,那您就错了。 如果在调用onByte
方法时还不准备处理数据怎么办? 如果我们要在内存中构建结构,则永远不会这样,但是例如,如果我们在接收数据时将它们存储到文件或数据库中,则很可能会这样。 因此,反应性流是两种方式,不仅是您,对输入做出反应的流使用者,流生产者还必须对您准备好输入进行反应。
尽管事情确实开始看起来功能更多,但现在在命令环境中可以实现这一点。 我们只是开始使用期货:
trait InputStreamHandler {
def onByte(byte: Byte): Future[Unit]
}
因此,当我们正在使用的流有一个字节供我们使用时,它将调用onByte
,然后将回调附加到我们返回的将来,以便在准备好时传递下一个字节。 如果您看一下Netty的异步通道API,就会发现它完全使用了这种模式。 我们还可以为不可变的功能性API实现类似的功能:
trait InputStreamHandler {
def onByte(byte: Byte): Future[InputStreamHandler]
}
因此,这里我们提供了用于反应流处理的功能解决方案。 但这不是一个很好的方法,首先,处理程序无法与使用它们的代码进行通信,即他们不想接收更多输入,或者遇到错误(皱眉的例外)在函数式编程中)。 我们可以添加一些东西来解决这个问题,但是很快我们的界面就会变得非常复杂,很难分解成可以组成的小片段,等等。我现在不打算为此辩护,我想您稍后会看到当我向您展示迭代的组成过程很简单时。
因此,我希望到现在为止您已经了解了两个要点。 首先,反应式流处理意味着双重反应,您的代码必须对准备就绪的流做出反应,而数据流也必须对准备好的流做出反应。 其次,当我说我们想要一个功能性的解决方案时,我的意思是一种解决方案,其中的所有内容都是不可变的,这是由流处理程序在每次接收/发送数据时生成自己的副本来实现的。 如果您已经了解了这两点,那么现在我们可以继续介绍迭代对象。
迭代
我们的界面尚未解决一些问题。 第一个是,流如何向我们传达已完成的信息,即没有更多数据供我们使用? 为此,我们将字节抽象为Input[Byte]
类型,而不是直接传入Input[Byte]
,该类型可以具有三种可能的实现:EOF,元素或空。 让我们不必担心为什么我们现在还需要清空,但是假设出于某些原因,我们可能想要清空。 这就是Input
样子:
sealed trait Input[+E]
object Input {
case object EOF extends Input[Nothing]
case object Empty extends Input[Nothing]
case class El[+E](e: E) extends Input[E]
}
更新InputStreamHandler
,我们现在得到如下所示的内容:
trait InputStreamHandler[E] {
def onInput(in: Input[E]): Future[InputStreamHandler[E]]
}
现在从以前更新我们的Consumer
以处理此问题,它可能看起来像这样:
class Consume(data: IndexedSeq[Byte]) extends InputStreamHandler[Byte] {
def onInput(in: Input[Byte]) = in match {
case El(byte) => Future.successful(new Consume(data :+ byte))
case _ => Future.successful(this)
}
}
您会看到,当我们收到EOF
或Empty
,没有任何事情可以改变状态,因此我们只是再次返回自己。 如果我们正在写另一个流,则当我们收到EOF
时,可能会关闭该流(或者将其发送给EOF
)。
我们要做的下一件事是使我们的处理程序更容易立即使用输入而不必创建将来。 为此,我们将传递一个函数,该函数将一个函数作为参数,而该函数将字节作为参数,而不是直接传递字节。 因此,我们的处理程序准备就绪后,将创建一个处理字节的函数,然后使用该函数调用传递给该字节的函数。 我们将第一个函数称为cont
函数,它是continue的缩写,它意味着当您准备好继续接收输入时调用我。 功能太多? 让我们看一下代码:
trait InputStreamHandler[E] {
def onByte[B](cont: (Input[E] => InputStreamHandler[E]) => Future[B]): Future[B]
}
现在,这个Future[B]
来自哪里? B
只是流将状态传递回自身的机制。 作为处理程序,我们不必担心它是什么,我们只需要确保我们最终调用cont
函数,并最终确保它返回的B
将其返回给调用方即可。 在我们的Consume
清单中,这看起来像什么? 我们来看一下:
class Consume(data: IndexedSeq[Byte]) extends InputStreamHandler {
def onByte(cont: (Input[Byte] => InputStreamHandler) => Future[B]) = cont {
case Input.El(byte) => new Consume(data :+ byte)
case _ => this
}
}
您可以看到在我们准备立即处理输入的简单情况下,我们立即调用了cont
,我们不再需要担心创建期货。 如果我们要异步处理输入,则要复杂一些,但是稍后我们将对其进行研究。
现在,我们在生产iteratee API的最后一步。 处理程序如何与已完成接收数据的流通讯回去? 可能有两个原因,一个是它已经完成接收数据。 例如,如果我们的处理程序是JSON解析器,则它可能已经到达要解析的对象的末尾,因此不再希望接收它。 另一个原因是它遇到错误,对于JSON解析器,这可能是语法错误,或者如果它正在将数据发送到另一个流,则可能是该流上的IO错误。
为了使iteratee与流进行通信,我们将创建一个代表其状态的特征。 我们将此特征称为Step
,迭代器可以处于的三个状态为Cont
, Done
和Error
。 我们的Cont
状态将包含Input[Byte] => InputStreamHandler
函数,以便流可以调用它。 Done
状态将包含结果(在Consume
的情况下为Seq[Byte]
), Error
状态将包含错误消息。
除此之外, Done
和“ Error
状态都需要包含未消耗的剩余输入。 这对于我们将迭代器组成一起时非常重要,这样一旦一个迭代器完成了对流的输入的消费后,下一个就可以在第一个中断的地方进行提取。 这就是为什么我们需要Input.Empty
原因之一,因为如果我们确实消耗了所有输入,则需要某种方式来表明这一点。
因此,这是我们的Step
特征:
sealed trait Step[E, +A]
object Step {
case class Done[+A, E](a: A, remaining: Input[E]) extends Step[E, A]
case class Cont[E, +A](k: Input[E] => InputStreamHandler[E, A]) extends Step[E, A]
case class Error[E](msg: String, input: Input[E]) extends Step[E, Nothing]
}
类型参数E
是我们的迭代器想要接受的输入类型,而A
是它所产生的。 因此,我们的处理程序特征现在看起来像这样:
trait InputStreamHandler[E, A] {
def onInput[B](step: Step[E, A] => Future[B]): Future[B]
}
我们的消费者是这样实现的:
class Consume(data: Seq[Byte]) extends InputStreamHandler[Byte, Seq[Byte]] {
def onInput(step: Step[Byte, Seq[Byte]] => Future[B]) = step(Step.Cont({
case Input.El(byte) => new Consume(data :+ byte)
case Input.EOF => new InputStreamHandler[Byte, Seq[Byte]] {
def onInput(cont: Step[Byte, Seq[Byte]] => Future[B]) = step(Step.Done(data, Input.Empty))
}
case Input.Empty => this
}))
}
您现在注意到的一个很大的不同是,当我们收到EOF
,我们实际上将Done
传递给step函数,也就是说我们已经完成了对输入的使用。
因此,现在我们建立了iteratee接口。 不过,我们的命名不太正确,因此我们将特征显然重命名为Iteratee
,并且将onInput
重命名为fold
,因为我们将状态折叠为一个结果。 现在,我们得到了界面:
trait Iteratee[E, +A] {
def fold[B](folder: Step[E, A] => Future[B]): Future[B]
}
在实践中进行迭代
到目前为止,我们从传统命令式输入流的需求开始,并描述了迭代器对此的反对。 但是看看上面的代码,您可能会认为使用它们确实很困难。 它们似乎比实现概念性流要复杂得多,至少在概念上要复杂得多。 好吧,事实证明,尽管到目前为止,我们已经展示了iteratee接口的基础知识,但是完整的iteratee API必须提供更多的功能,一旦我们开始理解并使用它,您将开始看到迭代有多么强大,简单和有用。
那么还记得迭代是如何不可变的吗? 并记住迭代如何处于cont,done和error这三种状态之一,并且取决于它处于哪种状态,它将把其相应的步骤类传递给folder函数? 好吧,如果iteratee是不可变的,并且可以处于三种状态之一,则它只能处于其处于该状态,因此,它只会将该对应的步骤传递给Folder函数。 如果iteratee完成了,就完成了,调用fold
函数的次数无关紧要,它永远不会成为续值或错误,并且它的完成值也不会改变,它只会将“ Done
步骤传递到文件夹A
值相同且剩余输入相同A
函数。 因此,我们只需要一个完成的iteratee的实现,它看起来像这样:
case class Done[E, A](a: A, e: Input[E] = Input.Empty) extends Iteratee[E, A] {
def fold[B](folder: Step[E, A] => Future[B]): Future[B] = folder(Step.Done(a, e))
}
这是您将需要完成的唯一迭代操作。 在上面的Consume
iteratee中,当我们到达EOF
,我们使用匿名内部类创建了完成的iteratee,我们不需要这样做,我们可以使用上面的Done
iteratee。 重复错误的原因完全相同:
case class Error[E](msg: String, e: Input[E]) extends Iteratee[E, Nothing] {
def fold[B](folder: Step[E, Nothing] => Future[B]): Future[B] = folder(Step.Error(msg, e))
}
您可能会惊讶地发现,同样的事情也适用于竞争对象–竞争iteratee只是传递了一个函数文件夹,而该函数由于iteratee是不可变的,因此永远不会改变。 因此,以下迭代通常足以满足您的要求:
case class Cont[E, A](k: Input[E] => Iteratee[E, A]) extends Iteratee[E, A] {
def fold[B](folder: Step[E, A] => Future[B]): Future[B] = folder(Step.Cont(k))
}
因此,让我们重写我们的消耗迭代器以使用这些帮助器类:
def consume(data: Array[Byte]): Iteratee[Byte, Array[Byte]] = Cont {
case Input.El(byte) => consume(data :+ byte)
case Input.EOF => Done(data)
case Input.Empty => consume(data)
}
CSV解析器
现在,我们看起来更加简单了,我们的代码专注于处理我们可以接收的不同类型的输入,并返回正确的结果。 因此,让我们开始编写一些不同的迭代器。 实际上,让我们编写一个iteratee来从字符流中解析CSV文件。 我们的CSV解析器将支持可选的引号字段,并使用双引号将引号转义。
我们的第一步将是编写解析器的构建块。 首先,我们要写一些跳过某些空白的东西。 因此,让我们在iteratee期间编写一个通用的drop:
def dropWhile(p: Char => Boolean): Iteratee[Char, Unit] = Cont {
case in @ Input.El(char) if !p(char) => Done(Unit, in)
case in @ Input.EOF => Done(Unit, in)
case _ => dropWhile(p)
}
由于我们只是删除输入,因此我们的结果实际上是Unit
。 如果谓词与当前char
不匹配,或者达到EOF,则返回Done
;否则,我们再次返回自己。 请注意,完成后,我们会将传递给我们的输入作为剩余数据包括进来,因为下一个迭代器将需要使用此输入。 使用此iteratee,我们现在可以编写一个删除空白的iteratee:
def dropSpaces = dropWhile(c => c == ' ' || c == '\t' || c == '\r')
接下来,我们将在iteratee期间编写代码,它将是我们之前的消耗iteratee,每次调用之间的携带状态与iteratee期间的丢弃之间的混合:
def takeWhile(p: Char => Boolean, data: Seq[Char] = IndexedSeq[Char]()): Iteratee[Char, Seq[Char]] = Cont {
case in @ Input.El(char) => if (p(char)) {
takeWhile(p, data :+ char)
} else {
Done(data, in)
}
case in @ Input.EOF => Done(data, in)
case _ => takeWhile(p, data)
}
我们还想编写一个窥视迭代器,查看下一个输入是什么,而无需实际使用它:
def peek: Iteratee[Char, Option[Char]] = Cont {
case in @ Input.El(char) => Done(Some(char), in)
case in @ Input.EOF => Done(None, in)
case Input.Empty => peek
}
请注意,我们的peek iteratee必须返回一个选项,因为如果遇到EOF,它将无法返回任何内容。
最后,我们要进行一次迭代:
def takeOne: Iteratee[Char, Option[Char]] = Cont {
case in @ Input.El(char) => Done(Some(char))
case in @ Input.EOF => Done(None, in)
case Input.Empty => takeOne
}
通过使用一个iteratee,我们将构建一个期望的iteratee,它要求在下一个字符必须出现,否则会引发错误:
def expect(char: Char): Iteratee[Char, Unit] = takeOne.flatMap {
case Some(c) if c == char => Done(Unit)
case Some(c) => Error('Expected ' + char + ' but got ' + c, Input.El(c))
case None => Error('Premature end of input, expected: ' + char, Input.EOF)
}
注意这里的flatMap
的使用。 如果您以前flatMap
它,那么在异步世界中, flatMap
基本上意味着“然后”。 它将一个函数应用于iteratee的结果,并返回一个新的iteratee。 在我们的案例中,我们使用它将结果转换为完成的iteratee或错误的iteratee,具体取决于结果是否符合我们的预期。 flatMap
是我们将用于组合迭代项的基本机制之一。
现在,有了我们的构建块,我们就可以开始构建CSV解析器了。 我们将编写的第一部分是未报价的值解析器。 这很简单,我们只想抓住所有不是逗号或换行符的字符。 我们希望结果是一个字符串,而不是像takeWhile
产生的Seq[Char]
。 让我们看看我们如何做到这一点:
def unquoted = takeWhile(c => c != ',' && c != '\n').map(v => v.mkString.trim)
如您所见,我们已使用map
函数将最终结果从字符序列转换为String。 这是迭代的另一个关键方法,您会发现它很有用。
我们的下一个任务是解析报价。 让我们从一个不考虑转义引号的实现开始。 要解析带引号的值,我们需要一个引号,然后我们需要取一个非引号的值,然后我们需要一个引号。 请注意,在那句话中,我说了“然后”两次。 我们可以使用哪种方法进行“然后”操作? 是的,我之前讨论过的flatMap
方法。 让我们看看我们的报价值解析器是什么样的:
def quoted = expect(''')
.flatMap(_ => takeWhile(_ != '''))
.flatMap(value => expect(''')
.map(_ => value.mkString))
所以现在您可能可以开始看到flatMap
的用处了。 实际上,它是如此有用,不仅对于迭代,而且在许多其他方面,Scala都有一种特殊的语法,称为理解。 让我们使用以下代码重写上述iteratee:
def quoted = for {
_ <- expect(''')
value <- takeWhile(_ != ''')
_ <- expect(''')
} yield value.mkString
现在,我希望您对此感到兴奋。 上面的代码是什么样的? 它看起来像普通的命令式同步代码。 读取此值,然后读取此值,然后读取此值。 除了它不是同步的,而且不是必须的。 它是功能性和异步的。 我们已经采用了构建基块,并将它们组合成一段非常易读的代码,从而可以完全清楚地知道我们在做什么。
现在,如果您不确定上述语法是否100%, <-
符号左侧的值就是右侧迭代的结果。 这些可以在任何后续行中的任何地方使用,包括在end yield
语句中。 下划线通常表示我们对值不感兴趣,我们将其用于expect
iteratee,因为无论如何它只会返回Unit。 yield
后面的语句是一个map函数,这使我们有机会采用所有中间值并将它们转换为单个结果。
现在,我们了解了这一点,让我们重写quoted
iteratee以支持转义的引号。 阅读我们的报价后,我们想窥视下一个字符。 如果是引号,则我们要附加刚刚读取的值,并在累加值后加上引号,然后再次递归调用引用的iteratee。 否则,我们已到达值的结尾。
def quoted(value: Seq[Char] = IndexedSeq[Char]()): Iteratee[Char, String] = for {
_ <- expect(''')
maybeValue <- takeWhile(_ != ''')
_ <- expect(''')
nextChar <- peek
value <- nextChar match {
case Some(''') => quoted(value ++ maybeValue :+ ''')
case _ => Done[Char, String]((value ++ maybeValue).mkString)
}
} yield value
现在,我们需要编写一个可以解析带引号或不带引号的值的iteratee。 我们通过偷看第一个字符来选择哪个,然后相应地返回正确的iteratee。
def value = for {
char <- peek
value <- char match {
case Some(''') => quoted()
case None => Error[Char]('Premature end of input, expected a value', Input.EOF)
case _ => unquoted
}
} yield value
现在,让我们分析整行,读取直到行尾的字符。
def values(state: Seq[String] = IndexedSeq[String]()): Iteratee[Char, Seq[String]] = for {
_ <- dropSpaces
value <- value
_ <- dropSpaces
nextChar <- takeOne
values <- nextChar match {
case Some('\n') | None => Done[Char, Seq[String]](state :+ value)
case Some(',') => values(state :+ value)
case Some(other) => Error('Expected comma, newline or EOF, but found ' + other, Input.El(other))
}
} yield values
枚举
现在,以类似于解析值的方式,我们也可以解析CSV文件的每一行,直到达到EOF。 但是这次我们将做一些不同的事情。 我们已经看到了如何使用flatMap
来对迭代对象进行排序,但是还有更多的可能性可以组成迭代对象。 迭代中的另一个概念是枚举。 枚举使流被迭代器消耗。 最简单的枚举只是将流的输入值映射为其他值。 因此,例如,这是一个将字符串流转换为整数流的枚举数:
def toInt: Enumeratee[String,Int] = Enumeratee.map[String](_.toInt)
Enumeratee
一种方法是transform
。 我们可以使用此方法将枚举数应用于iteratee:
val someIteratee: Iteratee[Int, X] = ...
val adaptedIteratee: Iteratee[String, X] = toInt.transform(someIteratee)
此方法也别名为运算符&>>
,因此下面的代码与上面的代码等效:
val adaptedIteratee: Iteratee[String, X] = toInt &>> someIteratee
我们还可以从另一个iteratee进行枚举,而这正是我们将要使用的values
iteratee所做的事情。 Enumeratee.grouped
方法采用一个iteratee,并将其反复应用到流中,每个应用程序的结果都是输入,以馈送到要转换的iteratee中。 我们来看一下:
def csv = Enumeratee.grouped(values())
现在让我们对枚举有更多的创意。 假设我们的CSV文件很大,因此我们不想将其加载到内存中。 每行是3个整数列的序列,我们要对每列求和。 因此,让我们定义一个将每个值集转换为整数的枚举:
def toInts = Enumeratee.map[Seq[String]](_.map(_.toInt))
另一个枚举将序列转换为3元组:
def toThreeTuple = Enumeratee.map[Seq[Int]](s => (s(0), s(1), s(2)))
最后是一个迭代总结:
def sumThreeTuple(a: Int = 0, b: Int = 0, c: Int = 0): Iteratee[(Int, Int, Int), (Int, Int, Int)] = Cont {
case Input.El((x, y, z)) => sumThreeTuple(a + x, b + y, c + z)
case Input.Empty => sumThreeTuple(a, b, c)
case in @ Input.EOF => Done((a, b, c), in)
}
现在将它们放在一起。 枚举上还有另一种方法称为compose
,您猜对了,让我们编写枚举。 它有一个别名运算符><>
。 让我们使用它:
val processCsvFile = csv ><> toInts ><> toThreeTuple &>> sumThreeTuple()
枚举器
最后,如果一个迭代器消耗了一个流,那么会产生一个流吗? 答案是一个枚举器。 枚举数可以使用其apply
方法应用于iteratee,该方法也别名为>>>
。 这将使iteratee处于续态,准备接收更多输入。 但是,如果枚举器包含整个流,则可以使用run
方法代替,该方法将在完成后向Iteratee发送EOF。 这是|>>>
别名。
通过将一系列输入传递给Enumerator
随播对象apply
方法,Play枚举器API可以轻松创建枚举器。 因此,我们可以使用以下代码创建字符枚举器:
val csvFile = Enumerator(
'''1,2,3
|4,5,6'''.stripMargin.toCharArray:_*)
我们可以像这样将其输入到我们的iteratee中:
val result = csvFile |>>> processCsvFile
在这种情况下,我们的结果将是最终用(5,7,9)赎回的未来。
结论
好吧,这是一段漫长的旅程,但是希望您如果是当务之急的程序员,您不仅可以理解迭代,还可以理解其设计背后的原因,以及它们组成的难易程度。 我也希望您总体上对功能和异步编程有更好的了解。 功能性思维方式与命令式思维方式完全不同,但我仍会坚持不懈,但尤其是在看到可以使用多好的迭代方式(一旦您了解它们)之后,我就深信功能性编程是必经之路。
如果您有兴趣从此博客文章中下载代码,或者想查看更复杂的JSON解析iteratee / enumeratee,请查看此GitHub项目 ,其中有一些示例,包括以数组块形式解析字节/字符流,而不是一次超过一个。
参考: James and Beth Roper博客博客中的JCG合作伙伴 James Roper的命令式程序员的迭代程序 。
翻译自: https://www.javacodegeeks.com/2012/11/iteratees-for-imperative-programmers.html