Haskell语言学习笔记

本文是对Haskell语言学习的一个简单总结,包括如下章节的内容:

  • 概述
  • 编写第一个程序
  • 函数定义
  • IO操作
  • Haskell的函数式编程
  • 集合数据
  • 数据类型
  • 模块
  • ghci命令行程序
  • 小结

一、概述

(一)Haskell历史简介

20世纪50年代,编程语言开始兴起,最早的高级编程语言之一是LISP语言,LISP依然存在,最近的方言包括Scheme和Clojure(实时计算框架Storm就是用Clojure语言开发的)。

到了20世纪80年代,许多研究人员正在发明和扩展各种函数式编程语言,示例语言包括ML,Hope和Miranda。1985年,Miranda发行后,惰性函数式语言的关注度增长。到1987年前,出现了十多种非限定性、纯函数式语言。 其中,Miranda使用的最为广泛,但还没有在公共领域出现。在美国波特兰州俄勒冈的函数式编程语言与计算机结构大会(FPCA '87)上,与会者一致同意组成一个委员会,为这样的语言定义一种开放性标准。该委员会旨在整合已有的函数式语言,作为将来的函数式语言设计研究工作奠定基础。

经过几年的工作和争论,委员会于1990年发布了第一份Haskell语言报告(作为Haskell 1.0版本),这是一个重要的里程碑:最后有一种共同的功能语言,研究界可以围绕这种语言联合起来。

1999年2月,Haskell 98语言标准公布,名为《The Haskell 98 Report》。2010年7月发布了Haskell 2010。

Haskell现在广泛用于教学,研究和工业。

(二)Haskell开发环境

Haskell 是函数式(一切通过函数调用来完成)、静态、隐式类型(类型由编译器检测,类型声明不是必需的)、惰性(除非必要,否则什么也不做)的语言。

最流行(common)的 Haskell 编译器是 GHC。安装 GHC,即获得 ghc 和 ghci。前者用于将 Haskell 程序库或应用程序编译成二进制码。后者为命令行解释器,可在编写 Haskell 代码后立即得到反馈。

可以在 https://www.haskell.org 上下载相应的GHC安装程序,本文所用的示例下载的是windows平台下的 HaskellPlatform-8.6.3-core-x86_64-setup.exe 安装程序,版本是8.6.3。

二、编写第一个程序

一个haskell可执行程序需要包含一个main函数(类似c语言中的main函数入口),作为程序的入口。下面我们看一个简单例子。

1、新建一个文本文件,如test.hs,文件中内容如下(就一行代码)

main = print "hello,world"

上面代码定义了一个main函数,main函数没有输入参数,函数体是一个print语句。

2、编译源码文件

控制台进入test.hs所在的目录,运行ghc test.hs ,会发现当前目录下多出了一个test.exe文件和几个中间文件。其中test.exe就是源码编译后生成的可执行程序。下面可以直接执行。

整个过程如下:

D:\haskell>ghc test.hs

[1 of 1] Compiling Main             ( test.hs, test.o )

Linking test.exe ...

D:\haskell>test.exe

"hello,world"

三、函数定义

haskell是一种纯函数式编程语言,所以其最重要的概念就是函数。haskell程序也是由一个个的函数组成。本章节将对haskell中的函数进行介绍。

(一)概念

Haskell函数与我们在c/c++,java等编程语言中的函数(或方法)有较大差异。Haskell中的函数的的基本思想是:高等抽象,不依赖于计算机模型。语法尽量接近数学上的表达式。例如:

在数学上一元函数表达式为f(x)=x+x,在haskell中对应的函数就是f x = x + x,除了将括号换成空格,其余都一样。

二元函数也类似,数学函数g( x, y) = x3 + y5对应的Haskell函数也就表示为 g x y = x3 + y5。

我们也可以定义数学函数h (x, y) = f( x) + g( x, y),在Haskell中对应的也就是:h x y = f x + g x y,很明显具有形式上的一致性。

(二)最简单的函数

编写如下代码,保存在test.hs源文件中,内容如下:

main = print hello
hello =12

可以编译和运行上述程序。上面代码有两个函数,一个是可执行程序的入口函数main函数,另外一个是hello函数。其中main函数的函数体中调用了haskell的内置函数(或叫系统函数)print,print函数的作用是输出信息到控制台上。

hello函数有点奇怪,看上去更像在其它编程语言中的变量定义。但它在这里得确是一个函数,只是没有参数,函数体就是一个表达式,只不过是一个最简单那的值表达式。

haskell的函数,有如下语法特点:

1、函数名首字母必须小写。

2、声明函数时,如果函数没有参数,不能加()

3、函数声明与函数实现之间用 =分隔,=后就是一个表达式,表达式的返回这就是函数的返回值,haskell的函数一定有返回值。

4、调用函数,不要加()

(三)带参数的函数

更多的函数,是带参数。我们先看一个带一个参数的简单例子,代码保存在test.hs文件中,文件中内容如下:

main = print (plus 2)
plus a  = a*2

编译和运行上面代码,可以看到结果。

上面代码除main函数外,定义了一个plus函数,该函数有一个参数(参数名为a),函数实现是一个表达式。

调用该函数如plus 2 这样的方式,参数值不用放在()中,但如果参数值是一个负数,则需要放到括号中,如plus (-2)。在main函数中调用print函数,因为plus函数有参数,所以需要把对plus函数的调用放到()中,如果 print plus 2 这样写的话,编译器就会把 plus和2当作两个参数传递给print函数,就会报错。

我们再看一个带多个参数的函数例子,代码如下:

main = print (plus 2 8)
plus a b = a+b

注意对个参数名之间不用逗号分隔,也不用()括起来。

(四)if条件函数

上面例子中的,函数实现都非常简单,就是一个简单的表达式。在数学中,还一种条件函数,即满足不同的条件函数返回不同的值,如:


条件数学函数在haskell中用if语句来表示,表示的方式是

get x  = if x>0 then x else -x

完整的一个程序代码如:

main = print (get(-1))
get x  = if x>0 then x else -x

在haskell中,if语句也是一个表达式,另外if语句必须是完备的,即必须有else语句。

为了代码更加易读,对于一个if语句我们可以换行来写,如上面的代码可以采用如下方式来写:

main = print (get(-1))
get x  = if x>0
             then x
             else -x

我们习惯换行后的多行语句对齐,但需要注意的是,haskell源码中不能用tab键来分隔,需要用1个或多个空格来分隔。

(五)case条件

类似其它语言的swicth...case语句,haskell提供的case语句来进行多值匹配,如下面例子代码:

main = do
    print (test 2)
    print (test 0)
test x = case x of
           1->"hello1"
           2->"hello2"
           otherwise -> "otherwise"

上面代码定义的test函数使用了case语句进行多值匹配,这比使用if语句代码会更加简洁。

(六)模式匹配函数

模式匹配的本质就是一大串swith…case,或者说if…else if…这些,遇到第一个匹配值后就会返回。这里举著名的斐波那契数作为例子,其数学定义为:


在haskell中,如果我们用if语句表示如下:

fib x = if x==0 then 0 else if x==1 then 1 else fib(x-1)+fib(x-2)

用比较长的If语句去写代码可读性不强,haskell提供了一种新的写法(分段定义),如下面格式:

fib 0=0
fib 1=1
fib x = fib(x-1)+fib(x-2)

可以看出,haskell的这种语法与其它编程语言的函数定义有较大差别。在haskell函数中,模式匹配是个非常常用和重要的概念,在定义函数时,我们可以为不同的模式分别定义函数本身,这就让代码更加简洁易读,如上面的例子。这种方式比用if语句编写代码更加整洁,可读性更强。

实际上上述函数匹配还不是完备的,因为没有对参数值小于0的处理,如果我们调用上述定义的函数,传入负数,则会报错。完整的定义方式为:

fib 0=0
fib 1=1
fib x = if x<0 then -1 else fib(x-1)+fib(x-2)

完整的代码例子如下:

main = print (fib 6)
fib 0=0
fib 1=1
fib x = if x<0
        then -1
        else fib(x-1)+fib(x-2)

可以看出,fib函数的定义与我们在c/c++,java等语言中的函数定义在语法上有较大的区别。其核心是haskell的函数本质上数学函数的抽象,即一个输入集合到输出集合的映射关系的描述。

(七)Guard条件

上面我们介绍了模式匹配的函数用法,这里再举一个例子,函数定义如下:

sayMe 1 = "One"   
sayMe 2 = "Two"  
sayMe 3 = "Three"   
sayMe 4 = "Four"   
sayMe x = "Others"

上面代码定义了sayMe函数。匹配的是一个对象(值),很多时候,我们希望匹配的是一个条件(布尔表达式),对于条件当然我们可以用if表达式来实现,只不过如果条件多了if表达式就比较复杂。haskell提供了一种Guard(门卫)的机制来帮助我们简化函数的定义。比如我们看一个例子:

sayMe x       
   | x < 3 = "less than 3"       
   | x == 3 = "equals with 3"   
   | otherwise = "larger than 3"

利用 | 符号来实现Guard的机制,可以看出,这样代码更加清晰。上面例子中的斐波那契数函数我们也可用Guard的方式简化下函数定义,如下面方式:

fib 0=0
fib 1=1
fib x  
   | x<0 = -1
   | otherwise =fib(x-1)+fib(x-2)

注意,如果一个函数名后面有多行代码,后面行代码要与第1行代码有个缩进。一般情况下,最后的那个 guard 往往都是 otherwise,捕获一切,确保匹配是完整的。

(八)关键字where

我们继续看一个模式匹配的函数定义例子,如下:

health weight height   
    | weight / height ^ 2 <= 18.5 = "You're underweight!"   
    | weight / height ^ 2 <= 25.0 = "You're supposedly normal!"   
    | weight / height ^ 2 <= 30.0 = "You're fat! "
    | otherwise                   = "You're a whale!"

上面代码中,我们看到 weight / height ^ 2 这个表达式重复出现了3次,程序员的字典里不应该有"重复"这个词。既然发现有重复,那么给它一个名字来代替这三个表达式会更好些。我们可以这样修改:

health weight height   
    | bmi <= 18.5 = "You're underweight!"   
    | bmi <= 25.0 = "You're supposedly normal!"   
    | bmi <= 30.0 = "You're fat! "   
    | otherwise   = "You're a whale!"
    where bmi = weight / height ^ 2

我们的 where 关键字跟在 guard 后面(最好是与竖线缩进一致),可以定义多个名字。这些名字对每个 guard 都是可见的,这一来就避免了重复。如果我们打算换种方式计算 bmi,只需进行一次修改就行了。通过命名,我们提升了代码的可读性,并且由于 bmi 只计算了一次,函数的执行效率也有所提升。我们可以再做下修改:

health weight height   
    | bmi <= skinny = "You're underweight!"   
    | bmi <= normal = "You're supposedly normal!"   
    | bmi <= fat = "You're fat! "   
    | otherwise  = "You're a whale!"
    where bmi = weight / height ^ 2
          skinny = 18.5   
          normal = 25.0   
          fat = 30.0

函数在 where 绑定中定义的名字只对本函数可见,因此我们不必担心它会污染其他函数的命名空间。注意,其中的名字都是一列垂直排开,如果不这样规范,Haskell 就搞不清楚它们在哪个地方了。

where绑定还有更多灵活的用法,本文中不再一一介绍。

(九)do语句块

我们上面举例的函数定义,其函数实现只有一条语句,但在某些场景下,可能需要有多条语句,尤其是在涉及IO操作时,关于haskell的IO操作,后面会详细介绍。如果要用多条语句,需要用到do关键字。

我们先看一个例子:

main = print (hello)
hello= print "hello,"
       print "world"

上面代码,我们定义了一个hello函数,该函数的设想是调用两次print函数,把上面代码保存到文件中,编译。但我们会发现编译报错,错误信息如下:

D:\haskell>ghc test.hs

[1 of 1] Compiling Main             ( test.hs, test.o )

test.hs:2:8: error:
    ? Couldn't match expected type ‘(a0 -> IO ()) -> [Char] -> t’
                  with actual type ‘IO ()’

? **The function ‘print’ is applied to three arguments,**

**      but its type ‘[Char] -> IO ()’ has only one**

**In the expression:** **print "hello," print "world"**
      In an equation for ‘hello’: hello = print "hello," print "world"
    ? Relevant bindings include hello :: t (bound at test.hs:2:1)
  |

2 | hello= print "hello,"

  |        ^^^^^^^^^^^^^^^...

错误信息比较多,但看其中粗体部分的错误信息,可以看出,haskell编译器把hello函数的两个print函数调用当作一个函数调用语句了。

为了解决这个问题,我们需要使用do关键字。 do关键字的作用可以将一系列顺序操作的语句组成一起作为一个语句块,需要特别注意的是,do语句块的最后一个语句必须是一个表达式。

上面代码正确的写法是:

main = hello
hello= do
         print "hello,"
         print "world"

需要注意的是,do语句块中各行语句需要左对齐。第一条语句也可以直接放在关键字do的后面,但后面语句要跟第一条语句左对齐(注意不是跟do对齐),下面的写法也是正确的。

main = hello
hello= do print "hello,"
          print "world"

需要注意的是,haskell代码中不能以tab键进行分隔,要用空格分隔。这尤其适用do语句块对齐时,更需要注意这点。

(十)函数中缀化

对于有参数的函数,我们调用时一般使用“函数名 参数1 参数2 ....”这种方式调用,这种函数称为普通函数。haskell中还有一种函数称为中缀函数,如运算符(在haskell中运算符也是一种函数)。

比如

 3+2 

其实调用的是+函数,3和2是+的两个参数,这时因为函数名放在参数的中间,称为中缀函数。

比如

max 2 5 

这里的max函数也有两个参数,调用时max放在参数的前面,这时我们调用函数时最常用的方式,称为前缀方式。

如果我们希望普通函数改为中缀调用方式,只需用两个倒引号括起来即可,如

 2 `max` 5 

这个效果和

max 2 5

是一样的。

反过来,如果中缀函数想采用普通函数前缀调用的方式,则将函数名用()括起来即可。 如

3+2

可以写成

 (+) 3 2 

这样的方式。

有时采用中缀的方式,会提高代码的可读性。

(十一)变量

haskell同其它语言,也支持变量。haskell 中 变量 赋值后就是不可变的,该 变量 就等于被赋予的值,类似其它编程语言中的常量。haskell中的变量和无参数的函数是没有区别的,它们都是表达式。如下面代码:

main =  print y
y="hello"

这里的y本质上是一个函数,但你也可以理解为一个全局变量。

需要注意的是,如果在函数内部定义变量,如下面代码:

main = test
test =  do
  y="hello"
  print y

上述代码中定义了一个函数test,代码保存到源文件中,但编译就会报错,错误信息如下:

D:\haskell>ghc test.hs

[1 of 1] Compiling Main             ( test.hs, test.o )

test.hs:3:4: error:
    parse error on input ‘=’
    Perhaps you need a 'let' in a 'do' block?
    e.g. 'let x = 5' instead of 'x = 5'

  |

3 |   y="hello"

  |    ^

解决此问题,需要用到let关键字,如下面代码:

main = test
test =  do
  let y="hello"
  print y

这样上面代码就可以成功编译和运行了。也就是说在do语句块中声明一个变量或函数,需要加上let关键字。我们再看一个例子:

main = test
test =  do
  let y=succ 2
  print y

上面代码中,do语句块中调用succ系统函数,赋值给一个变量,这时变量前也需要加let关键字。还看一个例子:

main = test
test =  do
  let f x y = x+y
  print (f 2 3)

上面代码中在do语句块中定义了f函数,这时f函数前面也必须要加let关键字。通过上面的一些例子可以看出,在haskell中,变量定义与无参函数其实没啥差别。

(十二)递归函数

函数递归基本上所有的编程语言都支持,但在非函数式编程语言中,这个特性并不是特别重要,应用场景并不是很多。但但在纯函数式编程语言中,递归是个不或缺的非常重要的功能。因为在纯函数编程语言中,没有循环操作(如while循环),循环操作都是靠递归来完成的。
我们看一个传统的循环例子:

int test(int num){
  int re=0;
  for(int i=0;i<=num;i++)
    re=re+i;
  return re;
}

上面代码很简单,就是一个简单的循环。下面我们在haskell中实现,代码如下:

main = print (test 10)
test 0 =  0
test num = num + test(num-1)

可以看出,没有用到循环,用递归函数很容易实现,而且代码看上去更符合数学函数的特点,也更清晰,没有用到中间变量,没有变量的计算。

四、IO操作

Haskell中的函数可以分为两类,一类是无副作用的函数(也称为纯函数),所谓无副作用的函数,是指函数即不依赖外部的状态也不修改外部的状态,函数调用的结果不依赖调用的时间和位置,只依赖函数调用时输入的参数。这样的纯函数会带来很多好处,如代码容易进行推理,不容易出错,这使得单元测试和调试都更容易;还可以将函数调用的输入和输出进行缓存,如果再次调用传入相同的参数,就可以直接使用前面缓存的结果,而不需再次执行函数。

但在实际的应用中,还存在一些操作,会与环境打交道,典型的如IO操作,这类操作的函数就不是无副作用函数。

在本小结中,我们将介绍haskell的IO操作。

(一)输入输出

haskell提供了多个系统函数,来输出信息到控制台,以及从控制台获取用户输入的信息。

1、输出信息到控制台

如下面例子:

main = do
   putStrLn "hello"
   putStr "world"
   print 12
   print "hello"

把上面代码保存到源文件中,编译并运行,可以观察到控制台上的输出信息如下:

hello
world12
"hello"

上面例子使用了putStrLn, putStr, print三个函数,它们的作用都是输出信息到控制台上,它们的区别是:

1)putStrLn 是输出字符串,会换行

2)putStr 也是输出字符串,但不会换行

3)print 可输出任何类型的数据,如果是字符串,会把双引号也输出

2、从控制台读取用户输入的信息

如果我们要从控制台读取数据,可以使用getLine系统函数,如下面例子:

main = do
  a<-getLine
  print a

利用<-符号,调用getLine函数,从控制台输入信息,回车,输入的信息会当作字符串赋值给<-前的变量。注意,即使输入的是数值,也会当作字符串赋值给变量。

(二)读取文本文件

下面代码是从一个文本文件中读取数据的例子:

import System.IO
fileName = "test.hs"
main = do
  handle <- openFile fileName ReadMode
  contents <- hGetContents handle
  putStr contents
  hClose handle

上面例子代码是从一个文本文件中读取所有的内容,用到haskell的System.IO模块中的函数。上面代码的含义如下:

1、先调用openFile函数获取一个文件句柄,它有两个参数,第一个参数是文件路径,第二个参数是打开文件的模式(这里是只读模式),非常类似c语言中的fopen函数。

2、然后调用hGetContents 函数一次读取文件所有内容,返回一个集合对象。再调用putStr函数将集合中的内容输出到控制台上。

(三)写入文本文件

下面代码是将信息写入到一个文本文件中,代码如下:

import System.IO
fileName = "test.txt"
main = do
  handle <- openFile fileName WriteMode
  hPutStrLn  handle "hello1"
  hPutStrLn  handle "hello2"
  hPutStrLn  handle "hello3"
  hClose handle

可以看出,打开文件获取句柄时同样调用openFile函数,但传入的第二个参数是WriteMode,然后调用hPutStrLn函数往文件中写入信息,最后关闭文件句柄。上述操作,如果指定的文件不存在,则会新建文件;如果文件已经存在,则文件中原有内容会被覆盖。

注意,调用openFile函数打开文件时,除了只读和只写两种模式外,还可以有readwriteMode(读写模式), appendMode(追加写模式)这两种模式。

五、haskell的函数式编程

本章节介绍函数式编程范式中的几个重要概念,这些概念有些在一些支持部分函数式编程语言特性的语言中也有,学习时可以相互借鉴下。

(一)高阶函数

在函数式编程范式中,函数是一等公民,可以作为一个参数值传递给另一个函数,可以作为另一个函数的额返回值。所谓的高阶函数是,指函数参数或函数的返回值就是一个函数。haskell语言作为一个纯函数式编程语言,显然会支持高阶函数的特性。

我们先看一个例子,代码如下:

main = do
  print (callf test1 10)
  print (callf test2 10)
  print (callf test3 "dd")
callf f x  = f x
test1 x = x*2
test2 x = x+2
test3 x= x++x

上面代码中,callf函数就是一个高阶函数,其第一个参数是一个函数(带一个参数)。我们调用了三次callf函数,分别传入了3个不同的具体函数。

(二)Lambda表达式

在其它编程语言中,匿名函数也称为Lambda表达式,haskell语言也支持Lambda表达式。使用Lambda表达式的好处是可以简化代码的编写,不需要事先定义好有名的函数。如上面小节的例子,我们调用了高阶函数callf三次,却需要提前定义好三个函数,而实际这个三个函数的实现都很简单。我们来看看如何使用Lambda表达式实现同样的功能。代码如下:

main = do
  print (callf (\x->x*2) 10)
  print (callf (\x->x+2) 10)
  print (callf (\x->x++x) "dd")
callf f x  = f x

可以看出,callf的第一个参数不再是一个具体的函数名,而是一个以\打头的表达式,这就是haskell中的Lambda表达式。表达式中的->左边是函数的参数列表,右边是函数的实现,通常就是一个表达式。

我们再看一个返回函数的例子,如下面代码:

main = do
  let f = callf 2
  print (f 10)
callf a = \x->x*a

上面代码定义的callf函数,有一个参数,函数的返回值是一个函数(返回的是一个Lambda表达式)。我们调用callf函数得到的是一个函数,可以再次调用得到的函数。上面main函数中代码也可以用一行语句完成,如下:

main = print ((callf 2) 10)
callf a = \x->x*a

第一种写法更像传统的命令式编程的写法,先把结果放到一个变量中,再使用该变量。第二种写法更符合函数式编程的特点,函数之间通过相互调用连接起来完成想要的功能。

(三)柯里化

柯里化是函数式编程范式中的一个重要概念。什么是柯里化,简单的理解是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

我们先看一个例子,代码如下:

main = print (fun 6 2)
fun a b = a/b

上面代码定义了一个函数fun,它有两个参数。现在我们把它进行柯里化,转换后的代码如下:

main = print ((fun 6) 2)
fun a= \b->a/b

可以看出新的fun函数,只有一个参数(即原来fun函数的第1个参数),它的返回值是一个函数(是一个Lambda表达式,带1个参数,即原来fun函数的第2个参数)。这样fun函数就变成了只接受单一参数的函数。 调用时采用 (fun 6) 2这样连续调用的方式。

需要说明的是,在haskell中,函数柯里化可以自动完成的,也就是说,即使我们定义了2个参数的函数,haskell会自动进行柯里化处理。对于上面两种fun函数的定义,我们调用时无论采用 fun 6 2 或 (fun 6) 2 都是可以的。

柯里化之后,函数最多只有一个参数,这样会让代码更加简洁,也不易出错,也便于进行单元测试等。

(四)闭包

闭包在很多编程语言中都支持,如python,Javascript等。简单的理解,就是在一个函数内部的函数(如Lambda表达式)可以访问包围它的函数中的局部变量(包括函数参数)。

上面函数柯里化的例子就说明了这一点,我们再来解释下,如下面代码:

main = print ((fun 6) 2)
fun a= \b->a/b

函数fun有一个参数a(函数的参数是一个局部变量),该函数返回了一个函数(这里是Lambda表达式,即匿名函数),在该Labmda表达式中使用了fun函数的参数a。在调用fun函数时,按道理返回时,局部变量a就该失效了,但因为a被其内部的Lambda表达式引用了,则不会失效。

六、集合数据

基本数据类型可以很容易的通过两种方式组合在一起:通过 [方括号] 组合的列表(lists),和通过 (圆括号) 组合的元组(tuples)。

(一)列表

列表可以用来储存多个相同类型的值,其字面量值的格式如 [1,4,10]。

1、添加新元素

可以利用:运算符可以将一个值加入到列表的前部,如下面例子代码:

main = do
    let list = [2,10,20]
    print list
    let newlist = (30:list)
    print list
    print newlist

编译和运行上面代码,观察输出,可以看出,利用:运算符往列表中插入一个值,不会影响原来的列表,而会返回一个新的列表。

2、获取指定序号的元素

可以使用 !! 使用索引(从0开始)访问 List 中的元素,如下面例子代码:

main = do
 let list = [2,3,5]
 print (list!!0)
 print (list!!2)

上面代码中,利用 list!!0获取到列表中的第一个元素。

haskell也提供了很多系统函数来对列表进行各种操作,下面介绍几个常见的操作。

3、map函数

例子代码如下:

main = do
 let list = [2,3,5]
 let newlist = map(+10)list
 print (list)
 print (newlist)

map函数可以返回一个新的列表,元素个数和原列表中元素个数一样,只不过原列表中的每个元素经过指定的操作(上面例子是每个元素加10)返回一个新的值。

再看一个例子:

main = do
 let list = ["hello","tom","good"]
 let newlist = map(length)list
 print (list)
 print (newlist)

上面例子,我们对字符串列表中每个元素调用length函数返回字符串的长度,这样map操作后返回一个新的整数列表。

4、filter函数

filter函数的作用是对列表的元素按照指定条件进行过滤,返回一个新的列表,新列表中只有满足条件的元素。如下面例子:

main = do
 let list =  [1,2,3,4,5]
 let newlist = filter(>3) list
 print (list)
 print (newlist)

上面代码过滤出值大于3的的元素,返回一个新的列表。

5、length函数

length函数用于返回列表中元素的个数。如下面例子:

main = print (length [1,2,3,4,5])

6、sum函数

sum函数用于求和,注意列表中元素要求是数组。如下面例子:

main = print (sum [1,2,3,4,5])

还有很多的操作列表的系统函数,这里不再一一介绍。

(二)元组

元组则和列表不同,它用来储存固定个数,但类型不同的值。如:

Prelude> ("tom",21,"man")
("tom",21,"man")

元组的值用()括起,里面是各个元素的值,可以是不同数据类型的数据。

haskell也提供了很多针对元组操作的系统函数,如 fst 函数返回元组中的第一个元素,snd返回元组中的第2个元素,但需要注意的是,fst和snd函数只适合元素个数为2个的元组(即二元组),如果元组中元素数量不是2,这使用fst和snd函数都会报错。

注意,元组中的元素数量不能为1,比如 (2)表示的不是元组,而就是数值2。

注意,()代表的是空元组,这也是为什么无参函数的调用后面不加()了,因为如果加了(),会当作是参数值是空元组,而不是没有参数。

(三)元组和列表的区别

  • Tuple用圆括号表示,而List用方括号表示。

  • Tuple中的元素不必是相同类型,而List中的元素必须是相同类型。

  • Tuple不可追加元素,而List可以在原有的基础上追加元素。

  • Tuple只有在长度,内部元素类型依次相对应,才属于同种类型的元组。List类型相同,仅需内部元素类型相同。例,[1,2]与['a','b']的类型不同,所以[[1,2],['a','b']]是错误的表达式。

  • Tuple不能只包含一个元素,没有实际意义。(1)表示的就是数字1。可以用[(1),2,3]的合法性,证明其与数字1相同。或者,用":t"命令直接检测其数据类型。

  • [[1,2],[1,1],[1,2,3]]的写法是正确的。[(1,2),(1,1),(1,2,3)]的写法是错误的。因为List中的元素必须是相同类型的,三元组与二元组不是相同类型,则非法。

七、数据类型

到目前为止,我们还没提到数据类型,实际上haskell语言是一种强类型的语言,任何值都是有数据类型的。我们前面没用到,是因为haskell会做类型的自动推断。比如声明一个变量时,会自动根据给变量赋值的字面量推断出数据类型,这样变量就不用显示的定义数据类型。比如定义函数时,没有指明参数类型和返回值类型,而是由haskell自动推断出来。

(一)类型是干什么用的

Haskell 中的每个函数和表达式都带有各自的类型,通常称一个表达式拥有类型 T ,或者说这个表达式的类型为 T 。举个例子,布尔值 True 的类型为 Bool ,而字符串 "foo" 的类型为 String 。一个值的类型标识了它和该类型的其他值所共有的一簇属性(property),比如我们可以对数字进行相加,对列表进行拼接,诸如此类。

在对 Haskell 的类型系统进行更深入的探讨之前,不妨先来了解下,我们为什么要关心类型 —— 也即是,它们是干什么用的?

在计算机的最底层,处理的都是没有任何附加结构的字节(byte)。而类型系统在这个基础上提供了抽象:它为那些单纯的字节加上了意义,使得我们可以说“这些字节是文本”,“那些字节是机票预约数据”,等等。

通常情况下,类型系统还会在标识类型的基础上更进一步:它会阻止我们混合使用不同的类型,避免程序错误。比如说,类型系统通常不会允许将一个酒店预约数据当作汽车租凭数据来使用。

引入抽象的使得我们可以忽略底层细节。举个例子,如果程序中的某个值是一个字符串,那么我不必考虑这个字符串在内部是如何实现的,只要像操作其他字符串一样,操作这个字符串就可以了。

类型系统的一个有趣的地方是,不同的类型系统的表现并不完全相同。实际上,不同类型系统有时候处理的还是不同种类的问题。

除此之外,一门语言的类型系统,还会深切地影响这门语言的使用者思考和编写程序的方式。而 Haskell 的类型系统则允许程序员以非常抽象的层次思考,并写出简洁、高效、健壮的代码。

(二)Haskell 的类型系统

Haskell 中的类型有三个特征:

  • 首先,它们是强(strong)类型的;

  • 其次,它们是静态(static)的;

  • 最后,它们可以通过自动推导(automatically inferred)得出。

下面分别来介绍这三个特征。

1、强类型

Haskell 的强类型系统会拒绝执行任何无意义的表达式,保证程序不会因为这些表达式而引起错误:比如将整数当作函数来使用,或者将一个字符串传给一个只接受整数参数的函数,等等。

遵守类型规则的表达式被称为是“类型正确的”(well typed),而不遵守类型规则、会引起类型错误的表达式被称为是“类型不正确的”(ill typed)。

Haskell 强类型系统的另一个作用是,它不会自动地将值从一个类型转换到另一个类型(转换有时又称为强制或变换)。举个例子,如果将一个整数值作为参数传给了一个接受浮点数的函数,C 编译器会自动且静默(silently)地将参数从整数类型转换为浮点类型,而 Haskell 编译器则会引发一个编译错误。

要在 Haskell 中进行类型转换,必须显式地使用类型转换函数。

有些时候,强类型会让某种类型代码的编写变得困难。比如说,一种编写底层 C 代码的典型方式就是将一系列字节数组当作复杂的数据结构来操作。这种做法的效率非常高,因为它避免了对字节的复制操作。因为 Haskell 不允许这种形式的转换,所以要获得同等结构形式的数据,可能需要进行一些复制操作,这可能会对性能造成细微影响。

强类型的最大好处是可以让 bug 在代码实际运行之前浮现出来。比如说,在强类型的语言中,“不小心将整数当成了字符串来使用”这样的情况不可能出现。

2、静态

静态类型系统指的是,编译器可以在编译期(而不是执行期)知道每个值和表达式的类型。Haskell 编译器或解释器会察觉出类型不正确的表达式,并拒绝这些表达式的执行。如下面例子:

Prelude> True && "False"

:2:9:
    Couldn't match expected type `Bool' with actual type `[Char]'
    In the second argument of `(&&)', namely `"False"'
    In the expression: True && "False"
In an equation for `it': it = True && "False"

上面例子中这个错误的原因是:编译器发现值 "False" 的类型为 [Char] ,而 (&&) 操作符要求两个操作对象的类型都为 Bool ,虽然左边的操作对象 True 满足类型要求,但右边的操作对象 "False" 却不能匹配指定的类型,因此编译器以“类型不正确”为由,拒绝执行这个表达式。

静态类型有时候会让某种有用代码的编写变得困难。在 Python 这类语言里, duck typing 非常流行, 只要两个对象的行为足够相似,那么就可以在它们之间进行互换。 幸运的是, Haskell 提供的 typeclass 机制以一种安全、方便、实用的方式提供了大部分动态类型的优点。Haskell 也提供了一部分对全动态类型(truly dynamic types)编程的支持,尽管用起来没有专门支持这种功能的语言那么方便。

Haskell 对强类型和静态类型的双重支持使得程序不可能发生运行时类型错误,这也有助于捕捉那些轻微但难以发现的小错误,作为代价,在编程的时候就要付出更多的努力[译注:比如纠正类型错误和编写类型签名]。Haskell 社区有一种说法,一旦程序编译通过,那么这个程序的正确性就会比用其他语言来写要好得多。(一种更现实的说法是,Haskell 程序的小错误一般都很少。)

使用动态类型语言编写的程序,常常需要通过大量的测试来预防类型错误的发生,然而,测试通常很难做到巨细无遗:一些常见的任务,比如重构,非常容易引入一些测试没覆盖到的新类型错误。

另一方面,在 Haskell 里,编译器负责检查类型错误:编译通过的 Haskell 程序是不可能带有类型错误的。而重构 Haskell 程序通常只是移动一些代码块,编译,修复编译错误,并重复以上步骤直到编译无错为止。

要理解静态类型的好处,可以用玩拼图的例子来打比方:在 Haskell 里,如果一块拼图的形状不正确,那么它就不能被使用。另一方面,动态类型的拼图全部都是 1 x 1 大小的正方形,这些拼图无论放在那里都可以匹配,为了验证这些拼图被放到了正确的地方,必须使用测试来进行检查。

3、自动推导

Haskell 编译器可以自动推断出程序中几乎所有表达式的类型[注:有时候要提供一些信息,帮助编译器理解程序代码]。这个过程被称为类型推导(type inference)。所以我们上面所有的操作,没有看到任何的显示类型声明。

虽然 Haskell 允许我们显式地为任何值指定类型,但类型推导使得这种工作通常是可选的,而不是非做不可的事。

(三)一些常用的基本类型

下表是 Haskell 里最常用的一些基本类型。需要注意的是,haskell的类型名必须大写字母开头,而变量、函数名必须小些字母开头,这不是建议,而是语法规定。

类型名 含义
Char 单个 Unicode 字符。
Bool 表示一个布尔逻辑值。这个类型只有两个值: True 和 False 。
Int 带符号的定长(fixed-width)整数。这个值的准确范围由机器决定:在 32 位机器里, Int 为 32 位宽,在 64 位机器里, Int 为 64 位宽。
Integer 不限长度的带符号整数。 Integer 并不像 Int 那么常用,因为它们需要更多的内存和更大的计算量。另一方面,对 Integer 的计算不会造成溢出,因此使用 Integer 的计算结果更可靠。
Double 用于表示浮点数。长度由机器决定,通常是 64 位。(Haskell 也有 Float 类型,但是并不推荐使用,因为编译器都是针对 Double 来进行优化的,而 Float 类型值的计算要慢得多。)
List 列表(List)中所有的项都必须是同一类型
Tuple 必须长度一样,每个对应的元素类型一样才是同一个元组(Tuple)类型

注意,我们常用的字符串,对应的数据类型实际是 元素类型为Char的List。

在ghci命令行程序中,使用 :type 命令(或简写的:t)可以查看值或函数的类型,如:

Prelude> :t 12

12 :: Num p => p

Prelude> :t "hello"

"hello" :: [Char]

Prelude> :t [1,2,3]

[1,2,3] :: Num a => [a]

Prelude> :t (1,2)

(1,2) :: (Num a, Num b) => (a, b)

Prelude> :t (1,"a")

(1,"a") :: Num a => (a, [Char])

关于ghci命令行程序的使用,在后面的章节会介绍。

(四)使用类型

我们在定义函数时,可以显示的声明类型,如下面例子:

main = print (plus 2)
plus :: Int -> Int
plus a = a + 1

上面代码中的 plus :: Int -> Int 就是函数类型的声明,告诉编译器,定义了一个名叫 plus 的函数,这个函数接受一个 Int 参数,并返回一个 Int。->符号的前面的Int表示是输入参数的类型,->符号后面的Int表示是函数返回值的类型。

再看一个例子:

main = print (plus 2 3)
plus :: Int -> Int-> Int
plus a b = a+b

上面代码中的 plus :: Int -> Int-> Int 是定义了两个输入参数(都是Int类型),函数返回值也是Int的函数。大家可能觉得这种 Int -> Int-> Int 表达方式很奇怪。但回忆下前面我们讲的函数柯里化,Haskell语言的函数本质上是单参数的,多个参数的函数其内部实际是被自动柯里化为多个单参数函数。这样就比较好理解了。

八、模块

在实际的程序开发中,不可能把所有的代码都写在一个源码文件中,而且也可能用到第三方提供的一些函数等。同其它编程语言类似,haskell也使用模块来组织自己的代码。

Haskell中的模块是由一组相关的函数,类型和类型类组成的。程序的架构就是由主模块调用其他模块(类似C)。

比如我们前面用到的haskell内置的一些函数,如succ,round等都是定义在prelude模块中。正常要使用一个模块中的内容,需要导入该模块,只是prelude模块比较特殊,不需要我们显示的导入,haskell会自动帮导入。

下面我们来举例自定义一个模块,以及如何使用定义的模块。

1、新建文件 plus.hs,文件中代码如下:

module Mymodule where
f x = x + x
g x y = x*3 + y*5

通过关键字module和where指定模块,这里的模块名是Mymodule,需要注意的是,模块名首字母必须大写。该模块中定义了两个函数f和g。

2、新建文件test.hs,与plus.hs保存在同一个目录下。test.hs是一个可执行程序的主模块入口,需要使用Mymodule模块中定义的函数。test.hs文件中的代码如下:

import Mymodule
main =   print(f 10)

上面代码中通过import关键字引入前面定义的Mymodule模块。

3、编译程序

进入上面源代码文件所在的目录,执行ghc命令,生成test.exe可执行程序。如下面显示的过程:

D:\haskell>ghc plus.hs test.hs

[1 of 2] Compiling Mymodule         ( plus.hs, plus.o )

[2 of 2] Compiling Main             ( test.hs, test.o )

Linking test.exe ...

可以看出,上述编译生成可执行程序的过程类似c语言的编译过程。

九、ghci命令行程序

安装好ghc工具后,在命令行下输入ghci,就会出现一个交互式命令行界面,可以执行相关的haskell代码。如:

C:\Windows\System32>ghci

GHCi, version 8.6.3: http://www.haskell.org/ghc/  :? for help

Prelude>

出现Prelude>提示符,我们可以在该提示符下输入haskell代码,回车执行。

(一)执行表达式

使用ghci命令行程序,最常见的是进行表达式的计算,包括调用函数,可用于快速的熟悉haskell提供的各种内置函数。如下面操作示例:

Prelude> 3

3

Prelude> 5+6

11

Prelude> "hello"

"hello"

Prelude> "hello"++",world"

"hello,world"

Prelude> succ 12

13

Prelude> round 6.7

7

Prelude> round 6.3

6

Prelude> :quit

Leaving GHCi.

C:\Windows\System32>

在haskell中,任何语句都是一个表达式,单个值也是个表达式,任何表达式都有一个返回值,如上面例子中的 3 ,字符串用双引号括起,字符串相连用++。调用函数,不需要加(),直接函数名后跟参数值,上面例子中的succ, round是haskell本身提供的库函数。函数调用也是个表达式。

退出命令行程序用:quit,注意quit前加冒号。

(二)使用函数

在命令行下定义单行的函数比较方便,直接输入即可。

Prelude> plus a = a+1

Prelude> plus 2

3

Prelude> plus (-2)

-1

但直接在ghci命令行下定义多行的函数不方便,我们可以把函数定义到haskell源码文件中,然后利用:load命令加载到命令行中,这样就可以调用在文件中定义的函数了。具体的操作步骤如下:

1、新建一个文本文件,扩展名为hs,这是haskell源码文件的扩展名,文件名如plus.hs。文件中内容如下:

plus a = a + 1

上面语句定义了一个函数,函数名为plus,可以看出haskell函数的语法形式与其它编程语言差异挺大,详细的语法规则后面再介绍。

2、控制台的当前目录设置为plus.hs所在的目录,运行ghci,出来交互式命令行界面。

3、输入:load plus.hs

4、调用函数,如plus(10),就可以看到在plus.hs文件中定义的函数被调用执行了。

上述的整个过程在控制台上显示的信息如下:

Prelude> :load plus.hs

[1 of 1] Compiling Main             ( plus.hs, interpreted )

Ok, one module loaded.

*Main> plus 10

11

*Main>

可以看出,执行:load plus.hs 命令行,交互式提示符由Prelude>变成*Main>了。 需要说明的是,调用haskell函数,不需要加(),如上面的plus 10,如果参数是个负数,则该负数需要用括号括起来,如 plus (-2)。

十、小结

通过上面的介绍,我们可以发现,无论是从语法规范上,还是编程方式上,haskell语言作为一门纯函数式编程语言,与我们熟悉的c,c++,java等语言有较大差别。对于初学者,理解起来也有些困难。本文也只是对haskell语言的特点做了初步的介绍,起到一个入门作用,还有很多高级的特性并没涉及到。

你可能感兴趣的:(Haskell语言学习笔记)