STATION II · 干流CH 02里程 02/20

GHCi 与函数:一切皆表达式

进入干流的第一课不讲语法大全,讲两件塑造手感的事:其一,Haskell 没有「语句」,只有「表达式」——没有 return,没有「先做这个再做那个」,一切都是算出一个值;其二,所有函数天生只吃一个参数——「多参数函数」是个善意的幻觉,而这个幻觉一旦戳破,部分应用、管道、高阶函数会突然全部连成一片。本章配了一台真求值引擎驱动的浏览器 GHCi,请边读边敲。

GHCi表达式柯里化$ 与 .

GHCi:比测试快一个数量级的反馈环

Haskell 工程师的日常不是「写完编译跑一下」,而是把 GHCi 常开在旁边,函数写一个喂一个。它能求值、能查类型、能加载整个项目。几个高频命令先混个脸熟:

λ> 2 + 3 * 4
14
λ> :t True
True :: Bool
λ> :t "上游"
"上游" :: String
λ> :l Main.hs        -- 加载文件
λ> :r                -- 改完代码后重新加载
λ> :q                -- 退出

:t(type)是你未来最好的朋友:任何表达式,问它要类型,GHCi 秒回。第 3 章整章教你读懂它的回答。现在,本章的泅渡点到了——这台浏览器 GHCi 由本书内置的惰性求值引擎驱动,支持后面几章的大部分语法,请随手验证正文里的每一个例子:

没有语句,只有表达式

在 Java/Kotlin 里,你的函数体是一串语句:先声明变量、再循环、中途 return。语句的本质是「命令机器做事」,它们依赖执行顺序。Haskell 里没有这个东西——函数体是一个表达式,函数的返回值就是这个表达式的值。没有 return 关键字,因为不需要:

price :: Int -> Int
price age = if age >= 60 then 0 else if age >= 18 then 30 else 15

注意 if:它是表达式,有值,所以 else 不能省——「不然的话值是什么?」这个问题必须有答案。你其实见过这个设计:

▸ 下游对照 · 你的语言正在「表达式化」

Kotlin 的 if/when/try 都是表达式(val x = if (c) a else b);Java 14 的 switch 表达式(-> 箭头形式 + yield)也是。语句改造成表达式,是过去十年主流语言最一致的演化方向之一——方向的尽头就是 Haskell:干脆没有语句。表达式的好处很实际:能被赋值、能被组合、能被传递,而语句只能被「执行」。

分支多了,嵌套 if 会难看。Haskell 的惯用解法是守卫(guard)——读作「哪个条件先为真,就用哪行」:

price :: Int -> Int
price age
  | age >= 60 = 0
  | age >= 18 = 30
  | otherwise = 15

局部定义用 where 挂在函数尾部,或者 let ... in 内嵌在表达式里。两者语义相同,风格之争而已:

bmi :: Double -> Double -> String
bmi weight height
  | v < 18.5  = "偏轻"
  | v < 24    = "正常"
  | otherwise = "偏重"
  where v = weight / (height * height)

操作符与函数:同一种东西的两副面孔

在讲柯里化之前,先拆掉一堵心理墙。多数语言里「运算符」和「函数」是两个物种:运算符是语法,函数是代码。Haskell 里它们是同一种东西,只有书写位置的差别,而且可以互换:

λ> 7 + 3            -- 符号名,默认写中间(中缀)
10
λ> (+) 7 3          -- 加括号,就变成普通前缀函数
10
λ> div 7 3          -- 字母名,默认写前面
2
λ> 7 `div` 3        -- 加反引号,就能写中间
2

规则总共两条:符号名默认中缀,括号变前缀;字母名默认前缀,反引号变中缀。这不是语法糖收藏癖——它意味着你可以定义自己的操作符(它只是名字长得像符号的函数),也意味着任何二元函数都能享受中缀的可读性:x `elem` list 读起来就是「x 属于 list」。第 5 章的 `f` 写法、第 10 章的 >>=,都建立在这个统一之上。

另一个日常小工具:GHCi 用 it 记住上一个结果,用 :i(info)查看任何名字的完整档案——定义位置、类型、优先级:

λ> 6 * 7
42
λ> it + 1
43
λ> :i (+)
class Num a where
  (+) :: a -> a -> a
  ...
infixl 6 +        -- 左结合,优先级 6

柯里化:多参数是幻觉

现在戳破那个幻觉。写一个「两数相加」:

λ> let add x y = x + y
λ> add 3 4
7
λ> :t add
add :: Int -> Int -> Int

看类型:Int -> Int -> Int。这个签名里的 ->右结合的,补上括号是 Int -> (Int -> Int)。逐字翻译:add 接收一个 Int,返回另一个函数,那个函数再接收一个 Int,才返回 Int。也就是说 add 从来只吃一个参数;add 3 4 其实是 (add 3) 4——先造出「加 3」这个函数,再把 4 喂给它。这就是柯里化(currying),纪念第 1 章提过的 Haskell Curry。

幻觉戳破后,部分应用(partial application)就不是什么特性,而是天然会发生的事:

λ> let addThree = add 3      -- 少喂一个参数,得到的就是函数
λ> addThree 10
13
λ> map (add 3) [1,2,3]
[4,5,6]
λ> map (*2) [1,2,3]          -- 操作符也能部分应用,叫「截断」(section)
[2,4,6]
λ> filter (> 10) [4,25,9,17]
[25,17]

(*2)(> 10) 这种写法叫操作符截断:把二元操作符缺一边,得到一个单参函数。(2^) 是「2 的多少次方」,(^2) 是「多少的平方」——缺哪边,哪边就是将来的参数。配合 map/filter,大量一次性 lambda 直接蒸发。

▸ 下游对照 · 你手动做过一万次的事

Kotlin 里你写 { x -> add(3, x) } 或用 ::add 加适配;Java 里写 x -> add(3, x);JS 里 add.bind(null, 3) 或者干脆手写箭头函数。这些都是「手动柯里化」。Haskell 把它做成默认物理规律的代价是:定义函数时不用做任何选择,任何函数天生支持部分应用。Kotlin 的 fun add(x: Int): (Int) -> Int 能模拟,但你得提前决定「这个函数要不要可部分应用」——上游不需要决定,这就是「默认值的力量」,后面还会反复见到。

λ 源头碑 · lambda 与函数字面量

匿名函数写作 \x -> x * 2,反斜杠是 λ 的 ASCII 化身。多参数照旧是幻觉:\x y -> x + y 等于 \x -> (\y -> x + y)。语言名字里的那个 λ 就是它——在 Haskell 里,函数不是「代码块」,是和 42、True 完全平权的:能存进列表、能当参数、能当返回值。

$ 与 . :括号的两把剪刀

读 Haskell 代码,你会高频遇到两个小符号。先看 $,它的全部作用是「把右边整个算完,再喂给左边」——本质是个优先级最低的函数应用,专治括号堆积:

-- 这两行完全等价:
print (sum (filter even (take 100 [1..])))
print $ sum $ filter even $ take 100 [1..]

再看 .,函数组合:(f . g) x = f (g x),数学里的 f∘g。它让你把「管道」先组装好、再通水:

λ> let process = sum . filter even . take 100
λ> :t process
process :: [Int] -> Int
λ> process [1..]
2550

注意 process 的定义里根本没出现参数。这种风格叫 point-free(point 指「值的名字」):不描述数据怎么流,只描述加工站怎么串。读的方向和数据流相反(右边先执行),习惯之后非常顺眼——但也别上瘾,三站以上的组合链,拆开命名更善良。

▸ 下游对照 · 管道的方向之争

Kotlin 的 list.filter{...}.map{...}.sum()、Elixir 的 |>、Bash 的管道都是「从左往右」;Haskell 的 . 是「从右往左」(跟随数学传统)。方向不同,思想相同:用小函数拼大函数,而不是用大函数包小逻辑。顺带一提,Kotlin 的 let/also 链式调用,本质就是穷人版 $

真实代码:把本章零件拼一次

用一个五脏俱全的小函数收拢本章所有零件——FizzBuzz 的表兄弟,按成绩分档并给出评语:

grade :: Int -> String
grade score
  | score >= excellent = badge ++ "优秀"
  | score >= pass      = badge ++ "及格"
  | otherwise          = badge ++ "再来一次"
  where
    excellent = 85
    pass      = 60
    badge     = "[" ++ show score ++ "分] "

-- 批量使用时,部分应用 + 组合自然出现:
report :: [Int] -> [String]
report = map grade . filter (>= 0)

逐行认领:守卫(表达式分支)、where(局部绑定,三个绑定行首对齐)、++ 拼接、show 把值转字符串;最后一行是 point-free 管道——先滤掉非法分数,再逐个评级。注意 report 全程没提到参数,也没写一个 lambda:map 吃的是 grade 这个「值」。当函数是一等公民,「批量化」永远只是一个 map 的距离,这正是高阶函数改变代码组织方式的最小样本:逻辑(grade)与调度(report)天然分层,单测只需要打 grade,不需要构造列表。

再看一眼它在 GHCi 里的生活——这就是「GHCi 驱动开发」的节奏,写一行验一行:

λ> grade 92
"[92分] 优秀"
λ> report [92, -3, 60, 41]
["[92分] 优秀","[60分] 及格","[41分] 再来一次"]
λ> :t map grade
map grade :: [Int] -> [String]

命名、缩进与两个小地雷

收尾前扫两个新手雷。第一,Haskell 用缩进表达代码块(layout rule),规则只有一句话值得背:同一代码块的行首要对齐,续行要缩得更深。用空格别用 Tab,对齐 where/let 下面的绑定,基本就不会中招。

第二,函数应用是空格,优先级最高。f x + 1(f x) + 1 而不是 f (x + 1);negate 3 + 4(negate 3) + 4 = 1。所有从 C 系语言来的人都会在这里摔一跤,摔一次就记住了:

λ> negate 3 + 4
1
λ> negate (3 + 4)
-7
⚠ 暗礁 · 「变量」不可变,但可以「遮蔽」

GHCi 里连续两次 let x = 1let x = 2 看起来像重新赋值,其实是新建了一个同名绑定把旧的遮住(shadowing),旧值本身从未改变。Haskell 没有赋值语句——= 读作「定义为」,不是「装进去」。这个区别在第 7 章(惰性)和第 16 章(性能)会产生真实后果,现在先把心智模型摆正:名字绑定到值,像数学里的「设 x = 1」,而不是内存里的一个格子

✚ 动手泅渡 · 在上面的终端里完成

1. 用截断改写:把 map (\x -> x * x) [1..10] 写成不含 lambda 的版本(提示:平方是「多少的二次方」)。

2. 定义 applyTwice f x = f (f x),然后算 applyTwice (+3) 10applyTwice (++ "!") "哇"。体会「函数是值」。

3. 用 . 把「取前 5 个正偶数的平方和」写成一条 point-free 管道,然后喂给它 [1..]

4. 预测再验证:foldr (-) 0 [1,2,3]foldl (-) 0 [1,2,3] 各是多少?(答案第 5 章揭晓,先记下你的猜测。)