@delight
2017-12-05T20:27:35.000000Z
字数 11377
阅读 5255
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.com
author-name: tryao
category: Personal
copyright: 'Copyright (c) TairanYao'
github-username: YiuTerran
setup-info: "http://mirrors.tuna.tsinghua.edu.cn/stackage/stack-setup.yaml"
urls:
latest-snapshot: http://mirrors.tuna.tsinghua.edu.cn/stackage/snapshots.json
lts-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: Tsinghua
download-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 IPPrint
import qualified Language.Haskell.HsColour as HsColour
import qualified Language.Haskell.HsColour.Colourise as HsColour
import qualified Language.Haskell.HsColour.Output as HsColour
let 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-dev
stack 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..else
Data.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 where
isEqual:: a -> a -> Bool
实例是
instance BasicEq bool where
isEqual True True = True
isEqual False False = True
isEqual _ _ = False
Eq
, Ord
(可比较), Show
(可以转换为字符串), Read
可以被String
转换(可以用::
显式指定类型),Enum
(可以被迭代),Bounded
(有上下限),Num
(有数字特征,必须实现Eq
和Show
),Integral
, Floating
fromIntegral
可以将整数转换成浮点数(根据后续操作转换)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 (+) 0
foldr
,格式同上,从右侧开始折叠。一种思考 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
的问题interact
Monad
实现,或者说IO是一种MonadmapM
返回一个IO动作,mapM_
完成IO,但是不返回任何值,M
的后缀表示Monad
map
不能执行操作(纯函数),这就是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 -> Tree
treeLengths (Leaf s) = Leaf (treeLengths s)
treeLengths (Node l r) = Node (treeLengths l) (treeLengths r)
这种能够同构映射的,满足类型类Functor
,映射函数即fmap
:
class Functor f where
fmap :: (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 where
mempty :: a -- the identity
mappend :: a -> a -> a -- associative binary operator
如果我们真的需要在同一个类型上实现多个 Monoid 实例,我们可以用 newtype 创建不同的类型来达到目的。
Monad
(单子)是Haskell最难理解的东西之一,这个概念和上文中的幺半群有关,这里有一些辅助理解的漫画。
一个Monad由以下几个构造元素:
m a -> (a -> m b) -> m b
a -> m a
的函数,它把普通值注入到调用链里面,也就是说,它把类型 a 用类型构造器 m 包装起来。标准的monad定义为:
class Monad m where
-- chain
(>>=) :: m a -> (a -> m b) -> m b
-- inject
return :: 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 b
liftM 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 -> Int
fee 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 a
join x = x >>= id
用来移除一层Monad,join (Just (Just a))
就是Just a
return x >>= f
== f x
m >>= return
== m
m >>= (\x -> f x >>= g)
=== (m >>= f) >>= g
第三条是结合律,如果我们把一个大的action分解成一系列的子action,只要我们保证子action的顺序,把哪些子action提取出来组合成一个新action对求值结果是没有影响的。
Control.Monad
里面定义了MonadPlus
,这其实是短路求值。
class Monad m => MonadPlus m where
mzero :: m a
mplus :: m a -> m a -> m a
instance MonadPlus [] where
mzero = []
mplus = (++)
instance MonadPlus Maybe where
mzero = Nothing
Nothing `mplus` ys = ys
xs `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配合。
再往后就是一些实际使用的例子了。