泛函编程(38)-泛函Stream IO:IO Process in action

在前面的几节讨论里我们终于得出了一个概括又通用的IO Process类型Process[F[_],O]。这个类型同时可以代表数据源(Source)和数据终端(Sink)。在这节讨论里我们将针对Process[F,O]的特性通过一些应用实例来示范它的组合性(composibility)和由数据源到接收终端IO全过程的功能完整性。

我们已经在前面的讨论中对IO Process的各种函数组合进行了调研和尝试,现在我们先探讨一下数据源设计方案:为了实现资源使用的安全性和IO程序的可组合性,我们必须保证无论在完成资源使用或出现异常时数据源都能得到释放,同时所有副作用的产生都必须延后直至Interpreter开始运算IO程序时。

我们先试试一个读取文件字符内容的组件:

  import java.io.{BufferedReader, FileReader}
  def readFile(fileName: String): Process[IO,String] =
    await[IO,BufferedReader,String](IO{new BufferedReader(new FileReader(fileName))}){
    	case Left(err) => Halt(err)
    	case Right(r) => {
    		lazy val next: Process[IO,String] = await(IO{r.readLine}) {
    			case Left(err2) => await(IO{r.close}){_ => Halt[IO,String](err2)}()
    			case Right(line) => emit(line, next)
    		}()
    		next
    	}
    }()

注意以下几个问题:首先是所有的IO动作都是通过await函数来实现的,再就是所有产生副作用的语句都被包嵌在IO{}里,这是典型的延迟运算。我们先来看看这个await函数:

  def await[F[_],A,O](req: F[A])(
     rcvfn: Either[Throwable,A] => Process[F,O] = (a: Either[Throwable,A]) => Halt[F,O](End))
     (fallback: Process[F,O] = Halt[F,O](End),
     onError: Process[F,O] = Halt[F,O](End)): Process[F,O] = Await(req,rcvfn,fallback,onError)

req是个F[A],在例子里就是IO[A]。把这个函数精简表述就是: await(IO(iorequest))(ioresult => Process)。从这个函数的款式可以看出:在调用await函数的时候并不会产生副作用,因为产生副作用的语句是包嵌在IO{}里面的。我们需要一个interpreter来运算这个IO{...}产生副作用。await的另一个输入参数是 iorequest => Process:iorequest是运算iorequest返回的结果:可以是A即String或者是个异常Exception。所以我们可以用PartialFunction来表达:

{ case Left(err) => Process[IO,???]; case Right(a) => Process[IO,???] }

我们用PartialFunction来描述运算iorequest后返回正常结果或者异常所对应的处理办法。

上面的例子readFile就是用这个await函数来打开文件: IO {new BufferedReader(new FileReader(fileName))}

读取一行:IO {r.readLine},如果读取成功则发送emit出去: case Right(line) => emit(line,next),

如果出现异常则关闭文件:case Left(err) => IO {r.close},注意我们会使用异常End来代表正常完成读取:

  case object End extends Exception
  case object Kill extends Exception

以上的Kill是强行终止信号。刚才说过,我们需要用一个interpreter来运算readFile才能正真产生期望的副作用如读写文件。现在我们就来了解一个interpreter:

  def collect[O](src: Process[IO,O]): IndexedSeq[O] =  {
     val E = java.util.concurrent.Executors.newFixedThreadPool(4)
     def go(cur: Process[IO,O], acc: IndexedSeq[O]): IndexedSeq[O] = cur match {
        case Halt(e) => acc
        case Emit(os,ns) => go(ns, acc ++ os)
        case Halt(err) => throw err
        case Await(rq,rf,fb,fl) =>
            val next =
              try rf(Right(unsafePerformIO(rq)(E)))
              catch { case err: Throwable => rf(Left(err)) }
            go(next, acc)

     }
     try go(src, IndexedSeq())
     finally E.shutdown
  }

首先要注意的是这句:unsafePerformIO(rq)(E)。它会真正产生副作用。当Process[IO,O] src当前状态是Await的时候就会进行IO运算。运算IO产生的结果作为Await的rf函数输入参数,正如我们上面描述的一样。所以,运算IO{iorequest}就是构建一个Await结构把iorequest和转换状态函数rf放进去就像这样:

await(iorequest)(rf)(fb,fl) = Await(ioreques,rf,fb,fl),

然后返回到collect,collect看到src状态是Await就会运算iorequest然后再运行rf。

我们的下一个问题是如何把文件里的内容一行一行读入而不是一次性预先全部搬进内存,这样我们可以读一行,处理一行,占用最少内存。我们再仔细看看readFile的这个部分:

  def readFile(fileName: String): Process[IO,String] =
    await[IO,BufferedReader,String](IO{new BufferedReader(new FileReader(fileName))}){
    	case Left(err) => Halt(err)
    	case Right(r) => {
    		lazy val next: Process[IO,String] = await(IO{r.readLine}) {
    			case Left(err2) => Halt[IO,String](err2) //await(IO{r.close}){_ => Halt[IO,String](err2)}()
    			case Right(line) => emit(line, next)
    		}()
    		next
    	}
    }()

如果成功创建BufferedReader,运算IO产生Right(r)结果;运算IO{r.readLine}后返回最终结果next。next可能是Halt(err)或者Emit(line,next)。如果这样分析那么整个readFile函数也就会读入文件的第一行然后emit输出。记着泛函编程特点除了递归算法之外还有状态机器(state machine)方式的程序运算。在以上例子里的运算结果除输出值line外还有下一个状态next。再看看以下这个组件:

  //状态进位,输出Process[F,O2]
  final def drain[O2]: Process[F,O2] = this match {
  	case Halt(e) => Halt(e)      //终止
  	case Emit(os,ns) => ns.drain  //运算下一状态ns,输出
  	case Await(rq,rf,fb,cl) => Await(rq, rf andThen (_.drain)) //仍旧输出Await
  }

这个drain组件实际上起到了一个移动状态的作用。如果我们这样写:readFile("myfile.txt").drain 那么在我们上面的例子里readFile返回Emit(line,next);drain接着readFile输出状态就会运算next,这样程序又回到readFile next的IO{r.readline}运算中了。如果我们在drain组件前再增加一些组件:

readFile("farenheit.txt").filter(line => !line.startsWith("#").map(line => line.toUpperCase).drain

那么我们就会得到读取一行字符;过滤起始为#的行;转成大写字符;返回再读一行交替循环这样的效果了。

很明显readFile实在太有针对性了。函数类型款式变的复杂可读性低。我们需要一种更概括的形式来实现泛函编程语言的简练而流畅表达形式。

我们首先应该把IO运算方式重新定义一下。用await函数显得太复杂:

  //await 的精简表达形式
  def eval[F[_],A](fa: F[A]): Process[F,A] = //运算F[A]
    await[F,A,A](fa){
    case Left(err) => Halt(err)
    case Right(a) => emit(a, Halt(End))
  }()
  def evalIO[A](ioa: IO[A]) = eval[IO,A](ioa)   //运算IO[A]
  //确定终结的运算
  def eval_[F[_],A,B](fa: F[A]): Process[F,B] = eval[F,A](fa).drain[B] //运算F[A]直到终止

如此运算IO只需要这样写:eval(iorequest),是不是精简多了。

再来一个通用安全的IO资源使用组件函数:

 def resource[R,O](   //通用IO程序运算函数
      acquire: IO[R])(  //获取IO资源。open file
      use: R => Process[IO,O])(  //IO运算函数  readLine
      release: R => Process[IO,O]): Process[IO,O] = //释放资源函数 close file
    eval(acquire) flatMap { r => use(r).onComplete(release(r)) }

  def resource_[R,O](   //与resource一样,只是运算realease直至终止
      acquire: IO[R])(  //获取IO资源。open file
      use: R => Process[IO,O])(  //IO运算函数  readLine
      release: R => IO[Unit]): Process[IO,O] = //释放资源函数 close file
    resource(acquire)(use)(release andThen (eval_[IO,Unit,O]))

以下是个套用resource组件的例子:从一个文件里逐行读出,在完成读取或出现异常时主动释放资源:

  def lines(fileName: String): Process[IO,String] = //从fileName里读取
     resource
     {IO {io.Source.fromFile(fileName)}}  //占用资源
     {src =>  //使用资源。逐行读取
       lazy val iter = src.getLines
       def nextLine = if (iter.hasNext) Some(iter.next) else None //下一行
       lazy val getLines: Process[IO,String] =  //读取
         eval(IO{nextLine}) flatMap {  //运算IO
         	case None => Halt(End)   //无法继续读取:完成或者异常
         	case Some(line) => emit(line, getLines) //读取然后发送
         }
       getLines
     }
     {src => eval_ (IO{src.close})} //释放资源

这样易读易解多了。

现在我们应该可以很简练但又不失清楚详尽地描述一段IO程序:

打开文件fahrenheit.txt

读取一行字符

过滤空行或者以#开始的字行,可通过的字行代表亨氏温度数

把亨氏温度转换成摄氏温度数

这里面的温度转换函数如下:

  def fahrenheitToCelsius(f: Double): Double =
    (f - 32) * 5.0/9.0

那么整个程序就可以这样写了:

      lines("fahrenheit.txt").
      filter(line => !line.startsWith("#") && !line.trim.isEmpty).
      map(line => fahrenheitToCelsius(line.toDouble).toString).
      drain

这段代码是不是很清晰的描述了其所代表的功能,不错!

现在到了了解IO过程的另一端:Sink的时候了。我们如果需要通过Process来实现输出功能的话,也就是把Source[O]的这个O发送输出到一个Sink。实际上我们也可以用Process来表达Sink,先看一个简单版本的Sink如下:

  type SimpleSink[F[_],O] = Process[F,O => F[Unit]]

SimpleSink就是一个IO Process,它的输出是一个 O => F[Unit]函数,用一个例子来解释:

  def simpleWriteFile(fileName: String, append: Boolean = false) : SimpleSink[IO, String] =
    resource[FileWriter, String => IO[Unit]]
    {IO {new FileWriter(fileName,append)}}   //acquire
    {w => IO{(s:String) => IO{w.write(s)}}}  //use
    {w => IO{w.close}}  //release

下面是个可使用的Sink:

    type Sink[F[_],O] = Process[F, O => Process[F,Unit]]

    import java.io.FileWriter

    def fileW(file: String, append: Boolean = false): Sink[IO,String] =
      resource[FileWriter, String => Process[IO,Unit]]
        { IO { new FileWriter(file, append) }}
        { w => stepWrite { (s: String) => eval[IO,Unit](IO(w.write(s))) }} //重复循环逐行写
        { w => eval_(IO(w.close)) }

    /* 一个无穷循环恒量stream. */
    def stepWrite[A](a: A): Process[IO,A] =
      eval(IO(a)).flatMap { a => Emit(a, stepWrite(a)) } 通过Emit的下一状态重复运算IO(a)

我们需要实现逐行输出,所以用这个stepWrite来运算IO。stepWrite是通过返回Emit来实现无穷循环的。

下一步是把Sink和Process对接起来。我们可以用以下的to组件来连接:

    def to[O2](sink: Sink[F,O]): Process[F,Unit] =
      join { (this zipWith sink)((o,f) => f(o)) }

join组件就是标准的monadic组件,因为我们需要把 Process[F,Process[F,Unit]]打平为Process[F,Unit]:

    def join[F[_],A](p: Process[F,Process[F,A]]): Process[F,A] =
      p.flatMap(pa => pa)

现在我们可以在前面例子里的Process过程中再增加一个写入celsius.txt的组件:

    val converter: Process[IO,Unit] =
      lines("fahrenheit.txt"). //读取
      filter(line => !line.startsWith("#") && !line.trim.isEmpty). //过滤
      map(line => fahrenheitToCelsius(line.toDouble).toString).  //温度转换
      pipe(intersperse("\n")).  //加end of line
      to(fileW("celsius.txt")).  //写入
      drain     //继续循环

上面的Sink类型运算IO后不返回任何结果(Unit)。但有时我们希望IO运算能返回一些东西,如运算数据库query之后返回结果集,那我们需要一个新的类型:

    type Channel[F[_],I,O] = Process[F, I => Process[F,O]]

Channel和Sink非常相似,差别只在Process[F,O]和Process[F,Unit]。

我们用Channel来描述一个数据库查询:

    import java.sql.{Connection, PreparedStatement, ResultSet}

    def query(conn: IO[Connection]):
        Channel[IO, Connection => PreparedStatement, Map[String,Any]] = //Map === Row
      resource_     //I >>> Connection => PreparedStatement
        { conn }  //打开connection
        { conn => constant { (q: Connection => PreparedStatement) => //循环查询
          resource_
            { IO {    //运行query
                val rs = q(conn).executeQuery
                val ncols = rs.getMetaData.getColumnCount
                val cols = (1 to ncols).map(rs.getMetaData.getColumnName)
                (rs, cols)
            }}
            { case (rs, cols) =>   //读取纪录Row
                def step =
                  if (!rs.next) None
                  else Some(cols.map(c => (c, rs.getObject(c): Any)).toMap)
                lazy val rows: Process[IO,Map[String,Any]] = //循环读取
                  eval(IO(step)).flatMap {
                    case None => Halt(End)
                    case Some(row) => Emit(row, rows)  //循环运算rows函数
                  }
                rows
            }
            { p => IO { p._1.close } } // close the ResultSet
        }}
        { c => IO(c.close) }

以下提供更多的应用示范:

从一个文件里读取存放亨氏温度的文件名后进行温度转换并存放到celsius.txt中

    val convertAll: Process[IO,Unit] = (for {
      out <- fileW("celsius.txt").once  // out的类型是String => Process[IO,Unit]
      file <- lines("fahrenheits.txt") //fahrenheits.txt里保存了一串文件名
      _ <- lines(file).  //动态打开文件读取温度记录
           map(line => fahrenheitToCelsius(line.toDouble)). //温度系统转换
           flatMap(celsius => out(celsius.toString)) //输出
    } yield ()) drain  //继续循环

输出到多个.celsius文件:

    val convertMultisink: Process[IO,Unit] = (for {
      file <- lines("fahrenheits.txt") //读取文件名称
      _ <- lines(file). //打开文件读取温度数据
           map(line => fahrenheitToCelsius(line.toDouble)). //温度系统转换
           map(_ toString).
           to(fileW(file + ".celsius")) //写入文件
    } yield ()) drain

我们可以按需要在处理过程中增加处理组件:

    val convertMultisink2: Process[IO,Unit] = (for {
      file <- lines("fahrenheits.txt")
      _ <- lines(file).
           filter(!_.startsWith("#")). //过滤#开始字串
           map(line => fahrenheitToCelsius(line.toDouble)).
           filter(_ > 0). // 过滤0度以下温度
           map(_ toString).
           to(fileW(file + ".celsius"))
    } yield ()) drain
 

版权声明:本文为博主原创文章,未经博主允许不得转载。

时间: 2024-08-26 08:46:12

泛函编程(38)-泛函Stream IO:IO Process in action的相关文章

泛函编程(32)-泛函IO:IO Monad

由于泛函编程非常重视函数组合(function composition),任何带有副作用(side effect)的函数都无法实现函数组合,所以必须把包含外界影响(effectful)副作用不纯代码(impure code)函数中的纯代码部分(pure code)抽离出来形成独立的另一个纯函数.我们通过代码抽离把不纯代码逐步抽离向外推并在程序里形成一个纯代码核心(pure core).这样我们就可以顺利地在这个纯代码核心中实现函数组合.IO Monad就是泛函编程处理副作用代码的一种手段.我们先

泛函编程(5)-数据结构(Functional Data Structures)

编程即是编制对数据进行运算的过程.特殊的运算必须用特定的数据结构来支持有效运算.如果没有数据结构的支持,我们就只能为每条数据申明一个内存地址了,然后使用这些地址来操作这些数据,也就是我们熟悉的申明变量再对变量进行读写这个过程了.试想想如果没有数据结构,那我们要申明多少个变量呢.所以说,数据结构是任何编程不可缺少的元素. 泛函编程使用泛函数据结构(Functional Data Structure)来支持泛函程序.泛函数据结构的特点是”不可变特性“(Immutability), 是泛函编程中函数组

泛函编程(35)-泛函Stream IO:IO处理过程-IO Process

IO处理可以说是计算机技术的核心.不是吗?使用计算机的目的就是希望它对输入数据进行运算后向我们输出计算结果.所谓Stream IO简单来说就是对一串按序相同类型的输入数据进行处理后输出计算结果.输入数据源可能是一串键盘字符.鼠标位置坐标.文件字符行.数据库纪录等.如何实现泛函模式的Stream IO处理则是泛函编程不可或缺的技术. 首先,我们先看一段较熟悉的IO程序: 1 import java.io._ 2 def linesGt4k(fileName: String): IO[Boolean

泛函编程(37)-泛函Stream IO:通用的IO处理过程-Free Process

在上两篇讨论中我们介绍了IO Process:Process[I,O],它的工作原理.函数组合等.很容易想象,一个完整的IO程序是由 数据源+处理过程+数据终点: Source->Process->Sink所组成的.我们发现:Process[I,O]本身是无法兼顾Source和Sink的功能.而独立附加的Source和Sink又无法有效地与Process[I,O]进行函数组合(functional composition). 实际上Process[I,O]是一种固定单一输入类型(single

泛函编程(36)-泛函Stream IO:IO数据源-IO Source &amp; Sink

上期我们讨论了IO处理过程:Process[I,O].我们说Process就像电视信号盒子一样有输入端和输出端两头.Process之间可以用一个Process的输出端与另一个Process的输入端连接起来形成一串具备多项数据处理功能的完整IO过程.但合成的IO过程两头输入端则需要接到一个数据源,而另外一端则可能会接到一个数据接收设备如文件.显示屏等.我们在这篇简单地先介绍一下IO数据源Source和IO数据接收端Sink. 我们先用一个独立的数据类型来代表数据源Source进行简单的示范说明,这

泛函编程(30)-泛函IO:Free Monad-Monad生产线

在上节我们介绍了Trampoline.它主要是为了解决堆栈溢出(StackOverflow)错误而设计的.Trampoline类型是一种数据结构,它的设计思路是以heap换stack:对应传统递归算法运行时在堆栈上寄存程序状态,用Trampoline进行递归算法时程序状态是保存在Trampoline的数据结构里的.数据结构是在heap上的,所以可以实现以heap换stack的效果.这种以数据结构代替函数调用来解决问题的方式又为泛函编程提供了更广阔的发展空间. 我们知道,任何涉及IO的运算都会面临

泛函编程(14)-try to map them all

虽然明白泛函编程风格中最重要的就是对一个管子里的元素进行操作.这个管子就是这么一个东西:F[A],我们说F是一个针对元素A的高阶类型,其实F就是一个装载A类型元素的管子,A类型是相对低阶,或者说是基础的类型.泛函编程风格就是在F内部用对付A类的函数对里面的元素进行操作.但在之前现实编程中确总是没能真正体会这种编程模式畅顺的用法:到底应该在哪里用?怎么用?可能内心里还是没能摆脱OOP的思维方式吧.在前面Stream设计章节里,我们采用了封装形式的数据结构设计,把数据结构uncons放进了特质申明里

泛函编程(27)-泛函编程模式-Monad Transformer

经过了一段时间的学习,我们了解了一系列泛函数据类型.我们知道,在所有编程语言中,数据类型是支持软件编程的基础.同样,泛函数据类型Foldable,Monoid,Functor,Applicative,Traversable,Monad也是我们将来进入实际泛函编程的必需.在前面对这些数据类型的探讨中我们发现: 1.Monoid的主要用途是在进行折叠(Foldable)算法时对可折叠结构内元素进行函数施用(function application). 2.Functor可以对任何高阶数据类型F[_]

泛函编程(6)-数据结构-List基础

List是一种最普通的泛函数据结构,比较直观,有良好的示范基础.List就像一个管子,里面可以装载一长条任何类型的东西.如需要对管子里的东西进行处理,则必须在管子内按直线顺序一个一个的来,这符合泛函编程的风格.与其它的泛函数据结构设计思路一样,设计List时先考虑List的两种状态:空或不为空两种类型.这两种类型可以用case class 来表现: 1 trait List[+A] {} 2 case class Cons[+A](head: A, tail: List[A]) extends