Back
Featured image of post Haskell 学习笔记03 - 基础语法

Haskell 学习笔记03 - 基础语法

导航页

REPL

之前我们已经介绍过,可以通过 ghci 命令进入 REPL。这里介绍一些基础 GHCi 命令:

  • :quit :q 用于退出
  • :info :i 用于查询类型信息,比如 :i Int
  • :load 用于加载文件。Haskell 后缀为 .hs。可以创建一个 test.hs 文件:
  • :reload :r 用于重新加载相同的文件
sayHello :: String -> IO () 
sayHello x =
  putStrLn ("Hello, " ++ x ++ "!")

使用 :load test.hs 加载后交互:

Prelude> sayHello "Haskell"
Hello, Haskell!

表达式

在 Haskell 中的一切,都是表达式或声明。表达式可以是值、值的组合、已应用的函数。表达式会求出一个结果。若表达式是字面量,求值微不足道,因为结果就是其自身。在涉及计算时,求值过程是计算运算符和其参数的过程。Haskell 表达式以可预测的、透明的方式求值。整个程序也是由较小表达式构成的一个大表达式。

而声明允许我们命名表达式,并使用它们,并多次引用,而无需复制。

以下这些都是表达式:

1
1 + 1
"Hello"
((1 + 2) * 3) + 100

当没有更多求值步骤可以执行时,亦即,达到不可归约形式时,我们说表达式是标准形式的。如 \(1 + 1\) 的不可归约形式是 \(2\).

函数

表达式是 Haskell 程序最基本的单元,而函数是其中的一种。

之前已经说过,函数只能有一个实参,并且返回一个结果。当想要有多个形参时,需要柯里化对应函数。

函数可以像这样定义:

triple x = x * 3

并使用:

triple 114514

请注意,这里虽然没有写类型。但并不代表 Haskell 类似 Python 甚至 JavaScript 是动态类型甚至弱类型。这是 Haskell 强大的类型推断所做的,甚至可以推断函数的形参类型和返回值。

不妨使用 :info triple 查看:

Prelude> :info triple
triple :: Num a => a -> a

求值

对表达式求值,就是减少项直到最简形式的过程。一旦项到达最简形式,就可称其为不可归约(irreducible)或完成求值。

Haskell 使用非严格求值(non-strict evaluation)策略,有时候也称惰性求值(lazy evaluation)策略,该策略推迟项的求值,直到它切实所需。

值是不可约的,但函数对参数的应用是可约的。减少一个表达式意味着求值项。与 lambda 演算一样,应用就是求值:将函数应用于参数允许求值或归约。

值是无法再被归约的表达式,也是归约的终点。

Haskell 默认不会求值,而是尝试求出标准形式的宽松版本,即 WHNF(weak head normal form)。

比如,这个表达式:

(\f -> (1, 2 + f)) 2

归约为 WHNF:

(1, 2 + 2)

这种表达是一种近似,但关键点在于,2 + 2 没有被求为 4.

中缀操作符

Haskell 的函数默认为前缀语法,也就是说,函数位于表达式的开头,而不是中间:

Prelude> id 1
1

id 函数就是 \(\lambda x.x\), 亦即 identity(恒等式)。

这也是函数的默认语法,但不是所有函数都是前缀的。有一些函数默认为中缀,比如算术操作符。

操作符(operators)是可以被中缀形式调用的函数。所有操作符都是函数;但不是所有函数都是操作符。tripleid 是前缀的函数(不是操作符),而 + 函数之类的是中缀操作符:

Prelude> 1 + 1
2
Prelude> 100 + 100
200
Prelude> 768395 * 21356345
16410108716275
Prelude> 123123 / 123
1001.0
Prelude> 476 - 36
440
Prelude> 10 / 4
2.5

为函数套上反引号,就可以用中缀形式使用它们:

Prelude> div 10 4
2
Prelude> 10 `div` 4
2

为中缀操作符套上括号,就可以用前缀形式使用它们:

Prelude> (+) 100 100
200
Prelude> (*) 768395 21356345
16410108716275
Prelude> (/) 123123 123
1001.0

若函数是字母和数字组成的,那么默认为前缀,并且不是所有函数,都可以套一层反引号变为中缀形式(显然的,实参数被限定为 2);若名称是单个符号,那么默认为中缀,并且肯定可以套一层括号变为前缀。

结合性和优先级

对于中缀表达式,结合性(associativity)和优先级(precedence)是重要的。

我们可以通过 :i 操作符 查看相关信息:

:info (*) 
infixl 7   * 
-- [1] [2] [3]
:info (+) (-) 
infixl 6 +

infixl 6 -
  1. infixl 表示,这是一个中缀操作符;其中 l 表示 left,即左结合性。
    • 比如 2 * 3 * 4 ,因为 * 是左结合的,所以会解析为 (2 * 3) * 4
  2. 7 是优先级:越大优先级越高,越先应用,范围为 0-9
  3. 中缀函数名:这个例子中是乘法。
Prelude> :info (^)
infixr  8   ^
  1. infixr 表示,这是一个中缀操作符;其中 r 表示 right,即右结合性。
  2. 8 是优先级:幂运算比加减(6)、乘除(7)都高。

因为 ^ 是右结合的,所以它的表现类似这样:

Prelude> 2 ^ 3 ^ 4
2417851639229258349412352
Prelude> 2 ^ (3 ^ 4)
2417851639229258349412352
Prelude> (2 ^ 3) ^ 4
4096

通常而言,数学中的优先级对 Haskell 也适用:

2 + 3 * 4
(2 + 3) * 4

声明值

可以用 = 声明值:

y = 10
x = 10 * 5 + y
myResult = x * 5

值得注意的是,在文件中,声明的顺序不重要,因为它们被同时读取。但在 REPL 中,需要按照先后顺序声明。

-- learn.hs

module Learn where 

x = 10 * 5 + y

myResult = x * 5

y = 10

模块名使用 CamelCase,变量名使用 camelCase。

缩进

Haskell 不使用大括号或分号来维护代码结构,而是使用缩进。另外,Haskell 建议使用空格(通常每一级两个空格)而不是 Tab。

以下代码是合法的:

foo x =
  let y = x * 2
      z = x ^ 2 
  in 2 * y * z

或这样:

foo x =
  let 
    y = x * 2
    z = x ^ 2 
  in 2 * y * z

但这样就会报错:

foo x =
  let 
    y = x * 2
     z = x ^ 2 
  in 2 * y * z

这样也会:

foo x =
  let  y = x * 2
      z = x ^ 2
   in 2 * y * z

这样会报错:

y = 4
x = 10 
* 5 + y

但这样就不会:

y = 4
x = 10 
  * 5 + y

这样也是不行的:

 y = 4
x = 10 * 5 + y

总之,同一级的代码,应该使用同样的缩进。而若要换行书写表达式,则需要另加一层缩进。

算术函数

操作符名称目的
+plus
-minus
*asterisk
/slash小数除法
divdivide整数除法,向下取整
modmodulo类似 rem,但是经过模数除法
quotquotient整数除法,向零取整
remremainder除数的余数

通常而言,整数除法使用 div,而非 quot,因为它们的取整方式不同:

-- 向下取整
div 20 (-6) -- Result: -4
-- 向 0 取整
quot 20 (-6) -- Result: -3

另外,remmod 也有微小的不同……

商余法则

商余法则即:

(quot x y) * y + (rem x y) == x
(div  x y) * y + (mod x y) == x

这里就不必证明了,它们切实为真:

quotRem x y = (quot x y) * y + (rem x y) == x
divMod  x y = (div  x y) * y + (mod x y) == x

quotRem (-10) (-9)   -- True
quotRem 10    (-9)   -- True
quotRem 10    123    -- True

divMod  10    13     -- True
divMod  (-3)  12     -- True
divMod  (-3)  (-12)  -- True

使用 mod

之前提到,mod 返回模数除法(modular division)的余数。若不熟悉模数除法,那么大概率是不知道 remmod 有何区别的。

模数除法是关于整数的算术系统,数字在达到某个值时「回绕」,该值亦称模数(modulus)。常见的生活对应物是时钟:

若使用 12 小时制,则需要每十二个数回绕一次。例如,现在是 8:00,你想知道 8 个小时后的时间,你并不能简单地将 8 和 8 加和,得出 16:00 的结果。

需要先 +4 然后回绕为 0。再加剩余的 4 小时。得出 4:00 的结果。

此处的算术模(arithmetic modulo)为 12. 因此,实质上在该算术系统中,\(0 = 12\)

mod 21 12 -- 9
rem 21 12 -- 9
mod  3 12 -- 3
rem  3 12 -- 3

假设我们要编写一个判断某一星期过了多少天,是星期几的函数:

mod (1 + 23) 7 -- 3
mod (6 +  5) 7 -- 4
rem (1 + 23) 7 -- 3

到目前为止,似乎 mod rem 区别不大。但不妨看看,被余数是负数的情况:

mod (3 - 12) 7 --  5
rem (3 - 12) 7 -- -2

在这里,mod 是正确的。至少在 Haskell 中,modrem 具有区别:

  • mod 结果的符号跟随除数(divisor)
  • rem 结果的符号跟随被除数(dividend)
(-5) `mod`   2  --  1
  5  `mod` (-2) -- -1
(-5) `mod` (-2) -- -1

(-5) `rem`   2  -- -1
  5  `rem` (-2) --  1
(-5) `rem` (-2) -- -1

负数

为了更好地和括号、柯里化、中缀语法交互,负数在 Haskell 被特殊对待。

单独使用负号没有问题:

Prelude> -1000
-1000

然而组合使用就会出问题:

Prelude> 1000 + -9
<interactive>:3:1:
    Precedence parsing error
        cannot mix ‘+’ [infixl 6] and
        prefix `-` [infixl 6]
           in the same infix expression

我们需要使用括号包裹:

Prelude> 1000 + (-9)
991

在 Haskell 中,将 - 作为一元(unary)操作符是一种语法糖(syntactic sugar)。- 在 Haskell 中有两种意义:1. 减法操作符 2. negate 的别名。以下代码的语义是完全相等的:

Prelude> 2000 + (-1234)
766

Prelude> 2000 + (negate 1234)
766

- 用作减法:

Prelude> 2000 - 1234
766

括号

本节将解释 $

ghci> :info $
($) :: (a -> b) -> a -> b   -- Defined in ‘GHC.Base’
infixr 0 $

可以看到,($) 的优先级是最低的 0。

并且定义也很简单:

f $ a = f a

乍一看,这似乎没有什么意义,但别忘了,以符号为名的函数,默认为中缀操作符。我们可以使用 ($) 操作符减少括号:

Prelude> (2^) (2 + 2)
16
Prelude> (2^) $ 2 + 2
16
Prelude> (2^) 2 + 2
6

该操作符允许它右边的所有东西先被求值,这对延迟函数应用很有用。

在同一个表达式中,可以使用多个 ($)

Prelude> (2^) $ (+2) $ 3 * 2
256

但这样不行:

Prelude> (2^) $ 2 + 2 $ (*30)

因为 ($) 的右结合性,我们必须从最右侧开始归约:

(2^) $ (*30) $ 2 + 2
-- 首先求值最右侧
(2^) $ (*30) $ 2 + 2
-- 要想将函数 (*30) 应用于表达式 (2 + 2)
-- 必须先求出表达式的值
(2^) $ (*30) 4
-- 然后再归约 (*30) 4
(2^) $ 120
-- 归约 ($)
(2^) 120
-- 归约 (2^)
1329227995784915872903807060280344576

此外,(*30) 这种写法被称为分片(sectioning)。

括号化中缀操作符

有些时候,你想引用中缀函数而不应用任何实参;也有些时候,你想将它们当作前缀运算符使用。这两种情况下,都必须用圆括号把运算符包起来:

1 + 2   -- 3
(+) 1 2 -- 3
(+1) 2  -- 3

最后一种写法即为分片。

对于满足交换律(commutative)的函数,例如 (+)(+1)(1+) 没有区别。但若函数不满足交换律,则需要小心:

(1/) 2 -- 0.5
(/1) 2 -- 2.0

减法,(-) 作为特例,这是正常的:

2 - 1   -- 1
(-) 2 1 -- 1

但这不行:

(-2) 1 -- error

- 包裹在括号中,让 GHCi 认为这是函数的参数。因为 - 意味着相反数 negate,而不是减法。所以 GHCi 不知道下一步要做什么,因此返回了错误信息。

你可以对于减法使用分片,但它必须是第一个实参:

x = 5
y = (1 -)
y x -- -4

或者,你可以这样,使用 subtract 而非 -

(subtract 2) 3 -- 1

letwhere

letwhere 经常用于引入表达式的组成部分。不同的是,let 引入了一个表达式,所以它可以在任何可以有表达式的地方使用;而 where 是一个声明,并受限于上下文。

一个简单的 where 例子:

-- FunctionWithWhere.hs
module FunctionWithWhere where

printInc n = print plusTwo
  where
    plusTwo = n + 2

在 REPL 中使用:

Prelude> :l FunctionWithWhere.hs
Prelude> printInc 1
3

同样函数的 let 写法:

-- FunctionWithLet.hs
module FunctionWithLet where

printInc2 n =
  let plusTwo = n + 2
   in print plusTwo

let 后面跟着 in,那么该 let 就用作表达式。在 REPL 中使用:

Prelude> :load FunctionWithLet.hs
Prelude> printInc2 3
5

以上就是基本的 Haskell 语法。

相关链接

Let vs. Where - Haskell Wiki

comments powered by Disqus