@delight
2017-12-05T12:27:35.000000Z
字数 11377
阅读 5577
Haskell FP
我打算学习但是最终没能完全理解的语言大概有三门:因为复杂性而放弃的Scala和Rust,因为难以理解而放弃的Haskell. 学习过C++以后,我对太过复杂的语言一项是敬而远之的,这一点暂时不会有变化,以后在工作中如果遇到必须使用Scala的时候我还是会认真学习。但是对于Haskell还是有些不甘心。虽然这是一门号称不看论文很难理解的语言,我还是想尽力学习一下。
Haskell相对于其他语言,变化的速度非常快,现在最新的平台是stack,连Haskell Platform也过时了(有种web前端的感觉),不过其核心理念还是比较稳定的,以前从Haskell趣学指南中学习的东西,现在也大多还能用。
apt-get install haskell-stack以后,使用stack upgrade升个级,stack setup安装环境,使用stack ghci打开交互式环境,然后就可以愉悦的学习了。目前最的版本是ghc-8.0.1;
默认的stack使用起来不是很方便,有些地方可以优化一二。
首先编辑~/.stack/config.yaml,删掉空白的{},加入以下内容:
templates:params:author-email: yaotairan@gmail.comauthor-name: tryaocategory: Personalcopyright: 'Copyright (c) TairanYao'github-username: YiuTerransetup-info: "http://mirrors.tuna.tsinghua.edu.cn/stackage/stack-setup.yaml"urls:latest-snapshot: http://mirrors.tuna.tsinghua.edu.cn/stackage/snapshots.jsonlts-build-plans: http://mirrors.tuna.tsinghua.edu.cn/stackage/lts-haskell/nightly-build-plans: http://mirrors.tuna.tsinghua.edu.cn/stackage/stackage-nightly/package-indices:- name: Tsinghuadownload-prefix: http://mirrors.tuna.tsinghua.edu.cn/hackage/package/http: http://mirrors.tuna.tsinghua.edu.cn/hackage/00-index.tar.gz
这是stackage的修改
上面的email和名字之类的需要换成自己的,后面加了清华的源,加快cabal的下载速度。
编辑~/.ghci加入以下内容:
import qualified IPPrintimport qualified Language.Haskell.HsColour as HsColourimport qualified Language.Haskell.HsColour.Colourise as HsColourimport qualified Language.Haskell.HsColour.Output as HsColourlet myColourPrefs = HsColour.defaultColourPrefs { HsColour.conid = [HsColour.Foreground HsColour.Yellow, HsColour.Bold], HsColour.conop = [HsColour.Foreground HsColour.Yellow], HsColour.string = [HsColour.Foreground HsColour.Green], HsColour.char = [HsColour.Foreground HsColour.Cyan], HsColour.number = [HsColour.Foreground HsColour.Red, HsColour.Bold], HsColour.layout = [HsColour.Foreground HsColour.White], HsColour.keyglyph = [HsColour.Foreground HsColour.White] }let myPrint = putStrLn . HsColour.hscolour (HsColour.TTYg HsColour.XTerm256Compatible) myColourPrefs False False "" False . IPPrint.pshow:set -interactive-print=myPrint:set -XNoMonomorphismRestriction:set prompt "λ "
然后在shell里运行:
sudo apt-get install libbz2-devstack install ipprint hscolour
执行stack安装依赖时,可能会报错,根据提示修正即可.
这是给ghci添加语法高亮.
最后,如果用zsh的话, 可以打开stack插件,方便自动完成. 也可以alias ghci='stack ghci'. 另外,最好把~/.local/bin加到PATH里面去.
仍然从《Haskell趣学指南指南》这本书开始是比较合适的,虽然内容有些过时,但是基础部分变动应该不大。上次看这本书的时候我还不会python呢,如今js我也驾轻就熟了,看起来应该简单很多了。完成后开始看《Real World Haskell》,后者相关习题的个人解答:https://github.com/YiuTerran/rwh-exercise
基本语法大体和普通语言(C系)相似,除了:
/=,和数学中的长的很像not和python里面一样,但是&&, ||和C语言一样if..then..else句式中,else是不可省略的,类似python中的单句if..elseData.Ratio里面有一堆分数相关的操作,Data.Bits里面则定义了很多位操作let定义变量,在脚本中直接赋值即可,变量可以看作是没有参数的函数(名字)。list和python中的形式大体一致,除了一点:所有元素的类型必须相同。++(效率较低),使用:将元素插入队首,本质上[1, 2, 3]就是1: 2: 3: []!!取下标,越界会报错head返回头部(car),tail返回尾部(cdr),last返回最后一个元素,init返回除了最后一个元素的列表[Char]length函数返回list的长度null函数检测是否为空reverse函数进行反转take函数取出队首任意多个元素,超过长度也不会报错,只是返回所有drop删除队首任意多个元素maximum和minimum求出队列中的极值sum和product求出队列的和,积elem判断是否是元素,一般用中缀形式表达(类似python中的in)..,例如[1..20], step可以在第二个元素中指定,如[10,8..0];字母也可以用;但是不要用浮点数;由于惰性的原因,可以是无限长的range;*[k^2 for k in range(1, 10)],在hs中则是[k^2 | k <- [1..10]],感觉有点像高中数学吧,哈。后面的条件用逗号隔开表示&&fst, snd分别返回tuple的第一项和第二项,显然仅对pair(二元组)有效;zip用于使list组成tuple对,类似pythonmap也类似python::,如a :: Char:t判断类型,使用:info查看具体相关Int是有限的,Integer是无限的[a]表示任意元素组成的listEq的一个实现=>,类似于接口中的where,可以是任意类型,但必须满足。例如 (Eq a)=> a -> a对于Haskell的纯函数而言,其主要书写格式就是大名鼎鼎的模式匹配,Rust也学习了这套语法。模式匹配其实是将数学按照构造函数进行拆分,能够这么干的主要原因是Haskell是纯函数式语言,没有副作用,变量被赋值后不能够再次修改。
- 最好自己写上函数的签名,方便在编译期查错
- 对于函数来说,从上到下匹配,允许递归
- 对于switch...case类型的句子,使用Guard(|),如果条件需要计算,在后面使用where(允许多个变量,但是要垂直隔开)
- 也可以使用let..in表示中某个作用域中的变量声明
- let是一个表达式,而where是一个语法结构,表达式(类似if..then..else)可以放在各种位置
- 除了这些以外,case表达式本身也存在,其语法结构是case xx of x -> ...,模式匹配本质上都是这个表达式的语法糖
- 使用_可以匹配任意情况
- 除了纯函数外,Haskell也有有副作用的函数比如IO,这种函数的书写格式更类似普通命令式语言。
- 函数的基本形式是a->b->c,箭头连接各个参数,由于函数是柯里化的,所以也可以看做a->(b->c),每个部分是一个偏函数,整个被称为全函数。如果函数不返回任何东西(在某些语言中被称为过程),这个函数肯定是非纯函数,一般返回()【读作unit】,如IO ()
lambda表达式本来就源自FP(lisp),所以很自然的:\xs -> length xs,不过haskell中用lambda比其他语言应该少很多。
常用的一些函数:
fold, fold1, foldr, scan家族类似
$ 符号同样也可以调用函数,但是与空格不同,他具有最低优先级,主要用来减少括号的数量;
.符号表示右结合的函数,即先算最右边的,然后依次应用左边的函数,右边函数的返回值是左边函数的参数;
上面两个符号主要用来做函数组合,简化书写。。
熟悉的import语句,不过和python的语法不太像,倒是有点像java.
import Data.List 会导入该模块下的所有函数;import Data.List (nub, sort)仅导入两个函数import Data.list hiding (nub) 导入除了nub之外的所有函数import qualified Data.Map 导入所有函数,但是如果和已加载模块冲突的话,必须使用全引用as x做别名关键字: data
和其他语言不一样,标准格式很奇怪
data BookInfo = Book Int String [String]deriving (Show)
BookInfo就是类型的名字(类型构造器),Book是值构造器的名字,后面是成员;两者名字可以一致;
这样访问属性还要写专门的函数(模式匹配),很蛋疼,所以有个惯用法(标准称呼是记录):
data BookInfo = BookInfo {price :: Int,author :: String,buyer :: [String]} deriving (show)
这些成员当然也可以是函数。
递归定义也是很常见的,比如一个二叉树:
data Tree a = Node a (Node a) (Node a)
这里a是类型参数。
关键字type类似C++中的typedef,用于定义类型的别名。
此外newtype也可以自定结构,不过有很多约束。newtype关键字给现有类型一个不同的身份,因此只能有一个值构造器,并且这个构造器只能有一个字段。
由data关键字创建的类型在运行时有一个记录开销,如记录某个值是用哪个构造器创建的。而newtype只有一个构造器,所以不需要这个额外开销。这使得它在运行时更省时间和空间。由newtype的构造器只在编译时使用,运行时甚至不存在。
就是其他语言中的类型类/接口/模板,语法是
class BasicEq a whereisEqual:: a -> a -> Bool
实例是
instance BasicEq bool whereisEqual True True = TrueisEqual False False = TrueisEqual _ _ = False
Eq, Ord(可比较), Show(可以转换为字符串), Read可以被String转换(可以用::显式指定类型),Enum(可以被迭代),Bounded(有上下限),Num(有数字特征,必须实现Eq和Show),Integral, FloatingfromIntegral可以将整数转换成浮点数(根据后续操作转换)FlexibleInstances取消这个限制。OverlappingInstances可以允许重叠实例。{-# LANGUAGE TypeSynonymInstances, OverlappingInstances #-}即可打开编译器扩展;RWH上面提到了单一同态错误问题,经过我的测试,在最新的(8.0.1)的GHC中这个问题已经不存在了;使用 error 函数输出错误
避免抛出错误,使用Maybe, Just和Nothing
Haskell 依据缩进来解析代码块。这种用排版来表达逻辑结构的方式通常被称作缩进规则。在源码文件开始的那一行,首个顶级声明或者定义可以从该行的任意一列开始,Haskell 编译器或解释器将记住这个缩进级别,并且随后出现的所有顶级声明也必须使用相同的缩进。
let 表达式和 where 从句的规则与此类似。一旦 Haskell 编译器或解释器遇到一个 let 或 where 关键字,就会记住接下来第一个标记(token)的缩进位置。然后如果紧跟着的行是空白行或向右缩进更深,则被视作是前一行的延续。而如果其缩进和前一行相同,则被视作是同一区块内的新的一行。
也可以使用显式语法结构(使用花括弧代替缩进),不过一般不这么做
节其实是高阶函数的一种形式(语法糖),如
test = (+2)// test 3 = 5
模式
xs@(_:xs')被称为 as-模式,它的意思是:如果输入值能匹配 @ 符号右边的模式(这里是(_:xs')),那么就将这个值绑定到 @ 符号左边的变量中(这里是 xs )。
除了增强可读性外,可以简化代码,减少内存分配
foldl,接受一个初始值,一个步进函数,对一个列表进行迭代求职,显然sum = foldl (+) 0foldr,格式同上,从右侧开始折叠。一种思考 foldr 的方式是,将它看成是对输入列表的一种转换(transform):它的第一个参数决定了该怎么处理列表的 head 和 tail 部分;而它的第二个参数则决定了,当遇到空列表时,该用什么值来代替这个空列表。
foldl',foldr' (Data.List)foldl的步进函数格式是 step acc x, foldr的则是step x acc,对于list (x:xs)而言,从左折叠第一个元素是x,从右折叠第一个元素是[];module的规定类似java,名字必须和文件名一致,首字母必须大写;
module SimpleJSON(Xxxx, //导出部分,如果省略则全部导出) where
显然入口模块应该是Main.hs
单文件可以用runghc命令运行,相当于脚本;也可以在ghci里面:l外部文件进行测试;
编译则需要使用stack ghc命令;
<-运算符从I/O中抽出结果并保存到一个变量中do引入代码块do中,使用命令式语言的语法完成一系列操作(赋值用let)do 代码块中的每一个声明,除了 let ,都要产生一个I/O操作,这个操作在将来被执行(惰性)System.IO中return的作用和<-相反,将一个纯值包装进IO,可以把IO想象成一个流。return入流后,将来可以通过->取出来赋值;hGetContents就是惰性的openFile和hClose外,使用readFile也可以,同时可以避免忘记关掉hClose的问题interactMonad实现,或者说IO是一种MonadmapM返回一个IO动作,mapM_完成IO,但是不返回任何值,M的后缀表示Monadmap不能执行操作(纯函数),这就是mapM存在的意义forM意思与mapM相反,第一个参数是列表,第二个是动作,存在的意义是更干净的代码do代码块实际上是把操作连接在一起的快捷记号,可以用>>和>>=代替>> 运算符把两个操作串联在一起:第一个操作先运行,然后是第二个,整个计算的结果是最后运算的结果>>= 运算符运行一个操作,然后把它的结果传递给一个返回操作的函数。类似linux 管道操作=<<运算符,将右边的动作传递给左边do块的最后一个操作的值就是整个do的值。如果以return结尾,返回一个Monad,比如整个函数的返回值是IO(),最后多半就是以return结尾。这种函数的返回值只能在另一个Monad函数中重新读出,不能直接用于任何操作。bytestring库代替。Data.ByteString和Data.ByteString.Lazy分别代表了惰性和普通模式的情况;除了List和Tuple, Haskell自带了其他的一些常见的数据结构;
lookup可以在关联列表中查询数据;Data.Map,不同于大部分语言,这个Map是用平衡二叉树实现的,而不是哈希表。这和haskell的不可变性有关;fromList(通过关联列表转换),insert(插入新值返回新的Map)Map仍然是无序的(有点奇怪)Data.Sequence提供了比默认list更好的效率
- 使用fromList创建,或者用empty和singleton函数创建
- 使用|>, <|和>< 添加元素,箭头指向被添加的元素
- 使用Foldable.toList将Sequence转回list
对于一个递归的数据结构,对于应用于其上的函数也很可能是同构递归的。书中的例子是将一棵字符串树转变为包含字符串长度的树,也就是说,树的结构不变,但是元素变成长度:
treeLengths:: Tree -> TreetreeLengths (Leaf s) = Leaf (treeLengths s)treeLengths (Node l r) = Node (treeLengths l) (treeLengths r)
这种能够同构映射的,满足类型类Functor,映射函数即fmap:
class Functor f wherefmap :: (a -> b) -> f a -> f b
Functor是非常重要的一类class,有几个基本原则进行约束。首先,fmap id必须返回相同的值,其次,Functor必须是可组合的,换句话说,fmap a . fmap b == fmap (a . b)应该成立。
编译器不会检查这些规则,程序员需要自己保证这些规则成立。这些规则是函子在范畴论中的数学约束。
在范畴论中,有一类简单的抽象结构被称为幺半群。许多数学结构都是幺半群,因为成为幺半群的要求非常低。 一个结构只要满足两个性质便可称为幺半群:
(*):表达式 a * (b * c) 和 (a * b) * c 结果必须相同。e,它必须遵守两条法则:a * e == a 和 e * a == a。即:
class Monoid a wheremempty :: a -- the identitymappend :: a -> a -> a -- associative binary operator
如果我们真的需要在同一个类型上实现多个 Monoid 实例,我们可以用 newtype 创建不同的类型来达到目的。
Monad(单子)是Haskell最难理解的东西之一,这个概念和上文中的幺半群有关,这里有一些辅助理解的漫画。
一个Monad由以下几个构造元素:
m a -> (a -> m b) -> m ba -> m a 的函数,它把普通值注入到调用链里面,也就是说,它把类型 a 用类型构造器 m 包装起来。标准的monad定义为:
class Monad m where-- chain(>>=) :: m a -> (a -> m b) -> m b-- injectreturn :: a -> m a
显然>>=就是串联函数,return是注入函数(IO monad就是把普通值放入IO),类型构造器就是m本身。除此之外,还有>>函数用户忽略返回值的过程(即按步骤一步步来),以及fail函数接受一个错误消息让整个调用链失败(默认使用error函数)。
下面是具体的解析:
fmap函数,可以让让普通函数与该类型类的实例进行运算,最简单的来讲fmap (+3) (Just 5)应该是Just 8;函子的中缀形式是<$>;fmap仅能将函数应用于被包装的数据。对于Applicative这种,则可以将函数应用于被包装的函数。import Control.Applicative以后,可以使用(Just (+3)) <*> (Just (+5)),这将会生成一个Just (+8)的函数;这是因为函数也可以是函子的实例。因为函子的约束只有可以应用fmap这一个而已.>>=(bind)和return这两个函数,前者用于连接包装值和接受普通值作为参数的函数,该函数返回一个包装值。换句话说,Monad定义了一种行为,如何将包装值分解为普通值行为,最后再返回包装值。也就是M a -> (a -> M b) -> M b.return其实就是将任意普通值包装起来;Monad class里面没有提供任何函数可以使一个monadic的值还原成一个普通值,这需要写代码的人自己定义。
我们经常需要将数据从Monad中取出来,然后使用纯函数进行计算,最后再用原来的类型构造器重新包围这个计算的结果,这种需求被称为lifting.对于Monad而言,已经定义了>>=和return,所以很容易得出:
liftM :: (Monad m) => (a -> b) -> m a -> m bliftM f m = m >>= \i ->return (f i)
该函数被定义在Control.Monad中。比如我们要计算Just (1 + 3),就可以用
-- Just 4( 1 + ) `liftM` (Just 3)
这与 Just 3 >>= \x -> Just (x+1)等价。
另外,从函子的角度,这个也可以写作(1+) <$> (Just 3)
如果函数f有多余一个参数,Control.Monad中也有对应的liftM2~liftM5
Control.Monad中定义ap函数,其签名为: ap :: Monad m => m (a -> b) -> m a -> m b liftM,如果我们需要大量链式调用,除了<*>外,还可以组合liftM和ap. 举个例子,定义
fee :: Int -> Int -> Intfee a b = a + b
那么 liftM fee的类型为:
Monad m => m Int -> m (Int -> Int)
显然这个函数的返回值满足ap的参数需求, 所以以下两个表达式相等:
fee `liftM` (Just 1) `ap` (Just 1)fee `liftM` (Just 1) <*> (Just 1)liftM2 fee (Just 1) (Just 1)
join函数也很常用:
join :: Monad m => m (m a) -> m ajoin x = x >>= id
用来移除一层Monad,join (Just (Just a))就是Just a
return x >>= f == f xm >>= return == mm >>= (\x -> f x >>= g) === (m >>= f) >>= g第三条是结合律,如果我们把一个大的action分解成一系列的子action,只要我们保证子action的顺序,把哪些子action提取出来组合成一个新action对求值结果是没有影响的。
Control.Monad里面定义了MonadPlus,这其实是短路求值。
class Monad m => MonadPlus m wheremzero :: m amplus :: m a -> m a -> m ainstance MonadPlus [] wheremzero = []mplus = (++)instance MonadPlus Maybe wheremzero = NothingNothing `mplus` ys = ysxs `mplus` _ = xs
Control.Monad中定义了标准函数guard:
guard :: (MonadPlus m) => Bool -> m()guard True = return ()guard False = mzero
Control.Arrow中定义了函数first和second用于将普通函数应用到Pair中去Monad变换器主要用来修改已经存在的Monad,以T结尾。大量Monad变换器进行叠加,就可以得到拥有多种功能的Monad.
讨论一些常用的haskell处理方式:Maybe, Either和Exception,一般都使用Monad配合。
再往后就是一些实际使用的例子了。