Haskell学习笔记<四>

《learn you a Haskell》这书的结构与常见的语言入门教材完全不一样。事实上,即使学到第八章,你还是写不出正常的程序…因为到现在为止还没告诉你入口点模块怎么写,IO部分也留在了最后几章才介绍。最重要的是,没有系统的总结数据类型、操作符、语句,这些知识被零散的介绍在1-8章的例子中,换句话来说,这书其实不算是很合格的教材(代码大全那种结构才更适合),不过它重点强调了FP与其他语言的思想差异,对于已经有其他语言基础的人来说,读这本书能够很快领悟到FP的妙处,对于那些并不用它来做项目(估计也很难有公司用这个做项目,太难了)的人来说,这本书很适合作为拓展阅读的小册子。另外一本《Real World Haskell》也相当有名,我还没有看,这本看完后再着手阅读吧。

第八章 自定义类型和类型类

关键字data用来构建类型,格式为

data classname = definition

左端是类型的名字,右端是该类型的取值,又称为构造子。例如:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)

这里表示类型Shape可以取值为Circle或Rectangle,这两个类型的构造子分别是3个、4个Float。构造子本质是一种函数,取几个参数返回一种类型。比如这里的Circle :: Float->Float->Float->Shape。关键字deriving表示派生于某类型类,即具有该类型类的性质。这里面有一些类似于面向对象中的封装与继承的概念,比如可以认为Shape是基类,Circle和Rectangle是派生类。如果某个函数对Shape通用,那么在模式匹配中就将两种派生类的实现都写出来,否则只匹配部分类即可。注意Circle和Rectangle并不是自定义类型,并不能直接使用,只有在需要Shape时,用构造子进行匹配来使用。如果构造子只有一个,可以让data类型与构造子同名,这样更加清晰。

如果说第七章的module相当于C++中的namespace,那么本章的data就相当于class,不同的是module封装的是function,而Haskell中class就是function,所以module、data和function就构成了Haskell中的用户层次。

导出格式:

module Shape

(Shape(…)

,fun1

,fun2

)

其中Shape(…)表示导出全部构造子。

Record Syntax

我们知道C++中提倡软件工程中的封装概念,尽量不让内置数据类型暴露给使用者,一般要写一大堆setxxx,const getxxx函数,虽然这些小函数往往只有一句话,但是写多了也很烦。Record Syntax是Haskell中一种用来简化此类函数书写的语法结构。在声明data时,直接标明其参数名称和对应的数据类型:

data Shape=Circle { x::Float,y::Float,radix::Float} | Rectangle { x1::Float,y1::Float,x2::Float,y2::Float} deriving (Show)

这里的语法有点像C中的位域,但是它实际的意思其实仍然是前面学过的类型解释符。Haskell会自动生成已经注明名称的参数的函数。另外在调用构造子时,也要遵从这种结构,不过x::Float换成x=1.0(即Circle {x=1.0,y=2.2,radix=3.1})。

类型参数

前面学习了值构造子,这里介绍类型构造子。值构造子显然需要明确值的类型,而类型构造子则宽松的多,比如向量,我们只需要向量的参数类型一致即可,不必明确具体的类型。换句话说,类型参数是一种"泛型"语法支持(类似C++的模板)。

比如 data Vector a = Vector a a a deriving (Show),这就是一个三维向量。类型参数a对后面的值构造子产生约束。也就是C++中的:

template <class a>

class Vector

{

    Vector(a first,a second, a last);

}

显然我们在写函数的时候需要对类型参数进行实例化,换句话说,需要对函数进行类型约束。记住,不要在data声明中添加类型约束,而是在函数中添加,因为具体执行操作的是函数,而数据类型需要比函数更加抽象。这里按着C++中写模板的思路来就很容易理解什么时候用类型构造子。

派生实例

本节介绍了从类型类从派生类型的方法。前面已经介绍过类型类本质上是一种接口要求,它描述了类型的行为一致性。在我们创建类型时,可以在值构造子后面加上deriving (Ord,Show,Read,…),来给创建的类添加接口。一旦加上了指定的类型类接口,就给予所创建的类对应的行为特性。如果加了Ord,就可以直接比较两个类(根据值构造子和参数),如果加了Show,那么就可以显示该类的参数(如果使用了syntax record,就显示出"名称=值"的格式。)

派生实例是很有用的特性,在我们设计类的时候需要明确该类支持的行为特性。这看起来有些像C++中的重载操作符,但是不需要我们自己去实现。Haskell会自动推断应该怎样实现声明的行为。

注意Haskell中True/False与C中意义完全不同【C语言中没有bool,只是单纯认为0为false,非0为true】,可以认为 data Bool = False | True deriving (Ord),所以True > False是成立的。同理Nothing总是小于Just a。

类型别名

类似typedef的语法,在Haskell中格式为

type Newtype=OldType,与typedef相似的是一般用来简化书写或者明晰概念。与typedef不同的是,type也支持Haskell的不全调用。

这里举了Either a b作为例子,Either a b是用来代替Maybe a的,当函数的返回类型可能有多种,或者函数需要根据情况返回不同的信息时,经常使用Either a b作为返回类型。

data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)

Either a b有两个值构造子,如果用了Left构造子,其返回类型就是a,否则是b,换句话说,Haskell可以返回多种类型的结果,而不像C++那样只能用结构来封装。

递归数据结构

【我本以为第8章到这就完了呢…结果后面又发现是翻译的大哥翻到一半就终止了,第八章后面还有不少,这部分是后来添补的】

考虑list,如果我们需要自己定义list类型,应该如何声明?应该这样:

data List a = Empty | Cons a (List a) deriving (Show,Read,Eq,Ord)

这里Cons相当于运算符 " : ",显然这里的List定义是递归的——等号的左右两端都存在List。这样,我们就可以像使用:一样使用Cons来构造List,如 3 `Cons` 4 `Cons` Empty。

也可以自定义操作符,使用infixr来确定操作符的优先级和左、右结合性,注意这里可以定义任意操作符,这一点和C++中的重载操作符有本质的不同。书中以操作符 :-: 为例介绍了使用方法。

下面介绍了一个二叉搜索树的生成作为例子:

data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show, Read, Eq)

一个二叉树是一个空树,或者一个由根节点和其左子树与右子树构成的树。下面是一些常用操作函数的实现,这里从略。

TypeClass102

本节介绍如何自定义类型类,类型类与常见的过程式或者面向对象模型的编程语言并无相似之处,不过可以和C++中的重载操作符对比参考。

我们使用关键字class来定义类型类,使用关键字instance来定义类型类的实例。

class Eq a where

(==) :: a->a->Bool

(/=) :: a->a->Bool

x == y = not x /= y

x /= y = not x==y

instance Eq TrafficLight where

instance下面就是详细解释该接口的实现。在不想使用默认的从类型类派生得到的行为时,必须使用instance来自定义数据对于该接口的行为方式。对于Maybe类型的实例,格式是

instance (Eq m) => Eq (Maybe m) where

注意这里需要对m添加类型约束。

可以在GHCI中使用:info 得到类型类、类型和类型构造子的详细信息。

A yes-no typeclass

本节讲了一个类型类的实例,它用来完成各种类型向Bool的缺省转换(如C的非零为True,0为False)。

class YesNo a where

    yesno :: a -> Bool

实现:

instance YesNo Int where

    yesno 0 = False

    yesno _ = True

instance YesNo [a] where

    yesno [] = False

    yesno _ = True

instance YesNo Bool where

    yesno = id

…其他略

注意id是标准库函数。

函数子类型类

函数子类型类(Functor class)指的是可以被被映射的函数满足的接口。

class Functor f where

    fmap :: (a->b) -> f a -> f b

注意定义中的f并非类型,而是类型构造子。换言之,函数子类型类的实例参数必须是类型类构造子而不能是具体的类型,那么可见这个类型类是容器的接口(如Maybe或[]等拥有1个以上值构造子的类型)。如果f有两个以上的参数,那么只能用函数的不完全调用格式,仅保留一个参数进行处理(如Either a)

种类和一些类型相关的东西

本节对类型(type)的知识做了扩展,这里将类型本身分为具体类型和不完全类型。

这种类型的类型被称为种类(kinds),可以在GHCI中使用:k来对类型进行种类的分析。

GHCI使用 " * "表示具体的类型,如果不是具体类型,就是可以通过一个或多个参数得到具体类型的不完全类型。

如果理解了类型构造子本身也是函数这一点,本节的内容还是比较容易理解的。

第九章 输入与输出

第九章开始就没有中文的翻译了,只能看英文资料,英文9-14章戳我

到了第九章,我们终于可以写Hello World了!一本教材讲到大半才讲输入输出的,也算是比较罕见了,呵呵。

在Haskell中,我们并不能像在命令式编程中一样随意改变非const变量的值。Haskell保持着这样一个特性:对于一个函数,只要它的调用参数不变,那么它返回的值总是不变。Haskell不会试图改变已经确定的变量,而是试图返回新的变量。那么这里出现一个问题:如果Haskell并不改变任何变量,那么它就无法输出——因为输出会改变屏幕。为此,Haskell设计了一种机制将非纯函数式编程(即与输入输出打交道的部分)与函数式编程(即前面八章介绍的内容)隔离开来,这里将这种机制称为side-effects,直译为边界效应。

Hello World!

Hello World总是每种语言必须提到的东西。对于Haskell,输出Helloword只需要一行代码,嗯,不愧是优雅与简洁的典范。

main = putStrLn "Hello world!"

main表示主函数,所有涉及IO的函数都在main中执行。所以main函数经常被写作main:: IO (),当然()也可以换成其他的返回类型。

putStrLn函数的解释是

putStrLn :: String->IO ()

也就是输入一个字符串,执行一个IO action,返回一个空的tuple。

因为IO行为是非纯函数行为,所以Haskell设计了do块来将所有的非纯函数进行封装,最后通过<-操作符将IO取得的值绑定到一个变量上。do块中,除了最后一个IO action 其他的均可绑定到一个变量上,不过如putStrLn这种函数的返回值肯定是(),所以绑定没什么意义。最后一个IO action会将返回值绑定给main本身。do块这种行为方式类似于verilog中的begin…end语句块。

如果不涉及IO,仍然使用前面学过的let … in…来直接绑定变量,不过这里in可以省略,缺省成为整个do块中有效。

return语句:在haskell中return语句只能在IO块中使用,它表示一个IO行为,输出一个可以通过变量绑定的值。return语句并不能从该段程序中返回。我们只有在需要执行一次什么都不做的IO或者不希望返回最后一个IO action取得的值时使用return语句。

main:main本身即是一个IO函数,所以可以通过在main函数结尾调用它来递归该函数。

其他IO函数:

函数原型

解释

putStr

类似putStrLn,但尾部不输出换行符

putChar/getChar

输出/入字符

print:: Show a => a -> IO ()

输出一切属于show类型类的数据

sequence:: Monad m => [m a] -> m [a]

执行参数1中的I/O动作,返回动作的结果

mapM :: Monad m => (a -> m b) -> [a] -> m [b]

map的I/O版,相当于sequence . map

mapM_ :: Monad m => (a -> m b) -> [a] -> m ()

同上,只是不再返回I/O动作的执行结果

注意map一个I/O动作到一个list中,并不会真正执行这个list中的动作。想要真正执行,必须使用sequence函数,当然,方便起见,可以使用mapM或mapM_。

这一块介绍了Control.Monad内的几个函数,when函数取一个布尔值和一个I/O动作作为参数,如果bool值为真,执行该动作,否则返回一个什么都不执行的I/O动作;forever永久执行参数中的I/O动作;forM类似于mapM,只是参数的顺序颠倒。

文件与流

其实I/O这一块Haskell与命令式语言并无不同,要注意的只是do块的位置和I/O函数结果的绑定。对于文件I/O,haskell与C基本一致,常用函数如下:

函数原型

解释

getContents :: IO String

从标准流读入数据直到EOF(Ctrl+D)

interact :: (String -> String) -> IO ()

对输入执行参数1的函数,输出结果

openFile :: FilePath -> IOMode -> IO Handle

System.IO,打开文件,选择方式,返回句柄

hGetContents :: Handle -> IO String

根据句柄返回文件内容

hClose :: Handle -> IO ()

根据句柄关闭文件

withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r

整合的一个函数,打开文件并使用参数3处理文件,完成后关闭文件

hGetLine/hPutStr/hputStrLn/hGetChar

大体同标准流,只是参数中多了文件句柄

readFile :: FilePath -> IO String

读取文件

writeFile :: FilePath -> String -> IO ()

写入文件,模式为截断

appendFile :: FilePath -> String -> IO ()

写入文件,模式为追加

getTemporaryDirectory :: IO FilePath

System.Directory,读取临时文件夹路径

openTempFile :: FilePath -> String -> IO (FilePath, Handle)

System.IO,打开临时文件,返回一个tuple

removeFile/renameFile

System.Directory,删除、重命名文件,参数是路径不是句柄

注意这里的读取文件相关函数都是惰性的。以上青色字体来自System.IO库,没有注明的来自Precluded库。使用句柄做参数的函数均以h开头。

缓冲控制:如果需要修改编译器默认的缓冲机制,可以使用函数hSetBuffering来修改,使用hFlush来强制刷新缓冲区

这里介绍了Unix下管道操作符 | 的使用。简单来说,可以通过管道操作符将上一个动作的输出作为下一个动作的输入。

命令行参数

同C语言程序一样,Haskell也是可以接受命令行参数的。C语言将命令行参数作为main函数的参数传递,而Haskell主要使用两个函数来取得用户输入的参数,import System.Environment

getArgs :: IO [String]

取出所有参数

getProgName :: IO String

取得程序名称

另外后面给了错误退出的函数(类似<stdlib>中的exit函数):errorExit。

随机数发生器

随机数发生器在任何语言中都是标准库自带的函数/类。虽然Haskell要求纯函数的输入一定时,输出固定,但是实际上几乎所有的语言中随机数发生器生成的都是伪随机数,所以Haskell这个特性并不意味着其实现比一般的语言困难。相关函数如下(import System.Random):

random :: (RandomGen g, Random a) => g -> (a, g)

参数给出一个随机数种子,返回一个随机数和一个新的种子

mkStdGen :: Int -> StdGen

以一个整数为参数,生成一个标准的种子

randoms :: (RandomGen g, Random a) => g -> [a]

根据种子生成一个无限长的随机序列

randomR :: (RandomGen g, Random a) => (a, a) -> g -> (a, g)

参数1的pair限制了最后取得随机数的范围

randomRs :: (RandomGen g, Random a) => (a, a) -> g -> [a]

根据参数1生成规定范围的无限长list

getStdGen :: IO StdGen

取得一个全局的随机数发生器种子

newStdGen :: IO StdGen

刷新全局随机数发生器

  

注意random函数的返回类型可以是任何类型,所以在使用的时候必须在后面加上类型约束::(type1,type2)作为随机数和种子的类型,如果使用StdGen的种子,则一般返回类型为(Int,StdGen)。

如果不执行newStdGen,那么getStdGen总是返回同样的种子。

另外这里还介绍了read加入了错误处理的版本reads,后者再不能读取参数时将返回空list。

二进制字符串

不清楚这个翻译是否合适,总之Bytestrings主要介绍二进制读写文件的方法。不同于C语言,这里有两个版本的二进制读取,一个是严格的非惰性版本,另外一个是惰性版本,分别来自Data.ByteString和Data.ByteString.Lazy. 对于lazy版本,这里和前面介绍的文件IO函数的实现也有所不同——它每次最少读取64K字节的东西。大体来讲ByteString的相关函数与Data.List中的函数接口一致,不过在类型约束中用ByteString代替了[a],用Word8代替了a。函数pack将Word8打包成ByteString,unpack用于解包;fromChunks将严格版(非惰性)转换为惰性版,toChunks则相反;cons和cons'用来取代list的 :操作符,注意后者适用于非惰性版;还有其他一些函数,对应于list中的某些函数,这里就不列举了。

posted @ 2012-08-02 22:06 生无所息 阅读(...) 评论(...) 编辑 收藏