铁定不纯的IO

写在前面

一直有个疑惑,Haskell号称纯函数式语言,那么铁定不纯的场景(肯定有副作用,或者操作本身就是副作用)如何解决?

比如(伪)随机数、I/O等,一个纯函数的随机数发生器肯定是不存在的,那要如何处理这种场景呢?

Haskell的做法其实类似于React的等组件生命周期函数,React建议(道德约束)保持是纯函数,带有副作用的操作挪到等生命周期中。也就是通过生命周期钩子,把纯的和不纯的区分开。Haskell提供了语句块,也是用来隔离不纯的部分的

一.I/O action

先看个函数类型:

函数接受一个类参数,返回一个,称之为I/O Action,也是一种类型,如下:

从类型上看,与类似,都是接受一个具体类型参数,返回具体类型(比如)

P.S.其中,newtype与类型声明类似,语法和用法也都基本相同,是更严格的类型声明(直接换成也能正常用,换就不一定了),具体区别是:

data can only be replaced with newtype if the type has exactly one constructor with exactly one field inside it.

二.用户输入

可以通过I/O Action获取用户输入,例如:

上面示例是个简单的程序,取一行输入,返回,并通过运算符把取出来,赋值给变量,为空则什么都不做(返回,结束),否则把该行内容通过输出到标准输出并换行,并递归执行

其中,表示入口函数(与C语言类似),用来把多个I/O Action合并成一个,返回被合并的最后一个I/O Action。另外,语句块里的I/O Action会执行,所以语句块有2个作用:

可以有多条语句,但最后要返回I/O Action

圈定不纯的环境,I/O Action能够在这个环境执行

类比JS,组合多条语句的功能类似于逗号运算符,返回最后一个表达式的值。圈定不纯环境类似于,I/O Action只能出现在语句块中,这一点类似于

P.S.实际上,执行I/O Action有3种方式:

绑定给时,作为入口函数

放到语句块里

在GHCi环境输入I/O Action再回车,如

执行

可以把当做普通函数在GHCi环境下执行,例如:

输入空行会退出,输入其它内容会按行原样输出

也可以编译得到可执行文件:

三.Control.Monad

模块还提供了一些适用于I/O场景函数,封装了一些固定的模式,比如、等,能够简化一些场景

return

用来把包成I/O Action,而不是从函数跳出。与作用相反(装箱/拆箱的感觉):

两个用途:

用来制造什么都不做的I/O Action,比如示例里的部分

自定义语句块的返回值,比如不想把I/O Action直接作为语句块的返回值,想要二次加工的场景

when

也是一个函数:

可以接受一个布尔值和一个I/O Action(属于类),作用是布尔值为时值为I/O Action,否则值为,所以相当于:

这个东西的类型是:

所以如果用于I/O的话,第二个参数的返回类型只能是,看起来不很方便,但很适合条件输出的场景,毕竟等一系列输出函数都满足该类型

sequence

这个类型声明看起来比较复杂:

在I/O List的场景(把换成,换成),参数的类型约束是,返回值的类型约束是,所以相当于:

作用是把I/O List中所有I/O结果收集起来,形成List,再包进

P.S.有点的感觉,接受一组,返回一个新携带这组结果

mapM与mapM_

在I/O List的场景,第一个参数是输入输出的函数,第二个参数是,返回,返回值类型与一致。作用相当于先对做映射,得到I/O List,再来一发,例如:

与之类似,但丢弃结果,返回,很适合等不关心I/O Action结果的场景:

forM

与参数顺序相反,作用相同:

只是形式上的区别,如果第二个参数传入的函数比较复杂,看起来更清楚一些,例如:

P.S.最后用(交换参数顺序)也可以,但出于语义习惯,常用于定义I/O Action的场景(如根据生成)

forever

在I/O的场景,接受一个I/O Action,返回一个永远重复该Action的I/O Action。所以的示例可以近似地改写成:

在的场景体现不出来什么优势(甚至还跳不出去了,除非强制中断),但有一种场景很适合:

即文本处理(转换)的场景,输入文本结束时也结束,例如:

通过把文件内容逐渐行处理成大写形式,更进一步的:

把处理结果写入文件,符合预期

四.System.IO

之前使用的、都是模块里的函数,常用的还有:

其中用来输出值,相当于,用来输出字符串,末尾不带换行,二者的区别是:

P.S.IO模块的详细信息见System.IO

getContents

能够把所有用户输入作为字符串返回,所以可以这样改写:

不再一行一行处理,而是取出所有内容,一次全转换完。但如果编译执行该函数,会发现是逐行处理的:

这与输入缓冲区有关,具体见Haskell: How getContents works?

惰性I/O

字符串本身是一个惰性List,也是惰性I/O,不会一次性读入内容放到内存中

的示例中会一行一行读入再输出大写版本,因为只在输出的时候才真正需要这些输入数据。在这之前的操作都只是一种承诺,在不得不做的时候才要求兑现承诺,类似于JS的Promise:

非常形象,,等操作都只是造了一系列的,直到遇到需要输出结果才真正去做I/O再进行等运算

interact

接受一个字符串处理函数作为参数,返回空的I/O Action。非常适合文本处理的场景,例如:

等价于:

看起来麻烦了不少,函数名就叫交互,作用就是简化这种最常见的交互模式:输入字符串,处理完毕再把结果输出出来

五.文件读写

读个文件,原样显示出来:

形式类似于C语言读写文件,相当于文件指针,以只读模式打开文件得到文件指针,再通过指针读取其内容,最后释放掉文件指针。直觉的,我们试着这样做:

一切正常,读取文件的前两行,再输出出来,这个指针果然是能移动的

P.S.类似的含有很多,比如等等,与不带的版本类似,只是多个参数,例如:

回头看看这几个函数的类型:

接受一个和参数,返回,拿着这个就可以找或要文件内容了,最后通过释放文件指针相关的资源。其中就是(给定义的别名),是个枚举值(只读,只写,追加,读写4种模式):

P.S.可以把文件指针当做书签来理解,书指的是整个文件系统,这个比喻非常形象

withFile

看起来又是一种模式的封装,那么,用它来简化上面读文件的示例:

看起来更清爽了一些,越来越多的函数式常见套路,做的事情无非两种:

抽象出通用模式,包括等类型抽象,等常用模式抽象

简化关键逻辑之外的部分,比如,等工具函数能够帮助剥离样板代码(等一板一眼的操作),更专注于关键逻辑

所以,所作的事情就是按照传入的文件路径和读取模式,打开文件,把得到的注入给文件处理函数(第3个参数),最后再把关掉:

注意,这里体现了的重要作用,我们需要在返回结果之前,所以必须要有返回自定义值的机制

readFile

输入文件路径,输出,Open/Close的环节都省掉了,能让读文件变的非常简单:

writeFile

输入文件路径,和待写入的字符串,返回个空的I/O Action,同样省去了与打交道的环节:

文件不存在会自动创建,覆盖式写入,用起来非常方便。等价于手动控件的麻烦方式:

appendFile

类型与一样,只是内部用了,把内容追加到文件末尾

其它文件操作函数

注意,其中和都是模块定义的(而不是中的),文件增删改查,权限管理等函数都在模块,例如等等

P.S.更多文件操作函数,见System.Directory

参考资料

Haskell default io buffering

Buffering operations

联系ayqy

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180520G1C9O300?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券