Haskell 期中复习

Haskell 复习

Functional Thinking

讲了加法乘法 exp 阶乘 fib 用 succ 定义;指出这些函数都可以用 foldn(\(n\) 次作用一个函数)定义,不过阶乘和 fib 需要返回一个二元组。

fold 可以拓展到 list 上,这就引出了许多其它函数。这里有一个 rev 函数,需要借助 revm:

revm (xs) (y:ys) = revm (y:xs) (ys)
revm (xs) [] = xs
rev = revm []

接下来就是 foldlr,foldll:

foldlr :: (A -> B -> B) -> B -> [A] -> B
foldlr h c [] = c
foldlr h c (x:xs) = h x (foldlr h c xs)
foldll :: (A -> B -> B) -> B -> [A] -> B
foldll h c [] = c
foldll h c (x:xs) = foldll h (h x c) xs

这里还很容易搞错,其实是看你希望实现什么功能。比如 foldll 就是 c,x,x,x,...。

这几个函数结合一些简单函数已经可以实现 rev、filter、快排等了。

Meeting Haskell

not'' :: Bool -> Bool
not'' x = if x == True then False else True
-- conditional expression
not''' :: Bool -> Bool
not''' x | x == True = False
         | x == False = True
-- guarded equation
not'''' :: Bool -> Bool
not'''' x | x = False
          | otherwise = True
-- guarded equation
and'' :: Bool -> Bool
and'' True True = True
and'' _ _ = False
-- pattern matching

(x +), (+ y) 都是合法表达式。div 是函数,而 div 可以是运算符。/ 是小数除法,div 是整除。

Numeric.Natural 中,Natural:任意精度自然数。

foldn :: (a -> a) -> a -> Natural -> a
foldn h c 0 = c
foldn h c n = h (foldn h c (n - 1))

首字母小写 (a) 表示类型变量,首字母大写表示类型。

函数调用优先级最高;函数调用是左结合的。

$ 运算符可以调整优先级,原理如下:首先,($) :: (a -> b) -> a -> b,而且其为运算符,这就意味着 f $ x = f x。而它的优先级最低,所以 f $ g x = (f) $ (g x) = f (g x)(两边都在其之前计算)。而且其右结合,故 f $ g $ h x = (f) $ (g) $ (h x) = f (g (h x))

. 运算符:f $ g x = (f.g) x。注意函数调用优先级高,所以 f.g x = f.(g x)

在实际定义函数时,if then else 和模式匹配都用得很多,这里再展示一例 guarded equations:

filter' :: (a -> Bool) -> [a] -> [a]
filter' p [] = []
filter' p (n:ns) | p(n) = n : filter' p ns
                 | otherwise = filter' p ns

注意条件方程组可以在任何时候跳出来,可以嵌套,还要注意 otherwise 咋用。

let A in BA 是一些等于组成的语句,A 的量只能在 B 中访问。B where A 通常和上述等价。但注意条件方程组:

f x y | ... = g z
      | ... = h z
      | otherwise = k z
      where z = p x y

这时不能轻易改写为 let in。

交互式程序里有一些常用系统函数,如

  • show :: a -> String:把一个值变成字符串。
  • read :: String -> a:与 show 相反,但是为了让程序知道 a 的类型,常常需要写成 read str :: Integer
  • getLine, putStrLn:输入输出一行。

Identifiers:首字母为字母,后面数字、下划线、单引号。函数、变量、类型变量必须小写开头,类型必须大写开头。

下面的 IO 内容先看完后面再说,

关于缩进:layout-sensitive(缩进表示层次)和 layout-insensitive(括号表示层次)。layout-sensitive => 相同缩进,开始新语句;更多缩进,继续上一条语句;更少缩进,代码块结束。

还有一种奇怪的写法,注释直接写,但是程序内容要用 >。

Type and Type Class

e :: T 表示 eT 类型的,Haskell 可以 type inference。

常见 Type:

type String = [Char]
Int -- [-2^63, 2^63-1]
Integer -- arbitary precision
Word -- [0, 2^64-1]
Natural -- 见上
Float -- single precision
Double -- double precision
[T] -- list T

接下来还有 function type。curried function 就是 ((a, b) -> c) -> (a -> b -> c)

Polymorphic function 就是存在类型变量的函数,例如 length :: [a] -> Int。Overloaded function 就是有 type class constraints 的 function。例如

(+) :: Num a => a -> a -> a
-- => 表示“推出”

Eq Ord 和 Num 是常见 type class。

  • Eq 需要定义 (==)(/=)
  • Ord 需要定义 compare 或者 (<=)。这里,compare 返回 LT, GT, EQ 中的一个。
  • Num 需要定义加,乘,abs,signum(sign of a number),fromInteger,和 negate(或者减)。

Function Definition

还是先讲 conditional expressions; guarded equations; pattern matching。注意 patterns are matched in order.

list pattern 用 (:) 来描述,head (x:_) = x。tuple pattern 也可以任意换为 _,例如 snd (_, y) = y。注意名字分别是 fst, snd。

Lambda functions:不用给出名称,例如 \x -> x + x。同时,还支持 currying:add = \x -> (\y -> x + y)

List Comprehension

格式为 [x^2 | x <- [1..5]],例如这等价于 [1, 4, 9, 16, 25]。若有 multiple generator,可以看成先遍历前面的。原因在后面的 list monad 会提。

还可以 dependent generator:[(x, y) | x <- [1..3], y <- [x..3]]concat :: [[a]] -> [a]concat xss = [x | xs <- xss, x <- xs]

还可以 guards。[x | x <- [1..10], even x]

zip 很有用,例如可以获取下标:

positions :: Eq a => a -> [a] -> [Int]
positions x xs = [i | (x', i) <- zip xs [0..], x == x']

String comprehension 和 list 没有区别,注意三个函数(定义于 Data.Char):ord, chr, isLowerord :: Char -> Int 为编码值,chr 为其反函数。

Recursive Function

讲到 Haskell 支持多种多样的递归:多参数的,list 上的,别的结构上的,互相递归(even 和 odd)等。没有新东西。

Higher-order Function

Higher-order Function 就是接受函数作为参数或者返回函数的 function。例如 (.) :: (b -> c) -> (a -> b) -> (a -> c)。这里就讲到 map 和 filter,又重提 foldl 和 foldr。再比如,all 表示是不是全都满足:all p xs = and [p x | x <- xs]。还有 takeWhiledropWhile

Type and Type Class

type 可以给已有的类型重命名,例如 type String = [Char] 或者 type Nat = Inttype 还可以有 parameter,丽日 type Pair a = (a, a)

注意,type 定义的类型终究是已有的;因此,type declaration can be nested but not recursive。

data 可以定义全新的类型,例如 data Bool = False | TrueBool 叫做 type constructor,False, True 叫做 data constructor,都必须大写字母开头)。它与 context free grammar 有深刻联系,在 parser 一节可以看到这一点。data 也能有 parameter,data Shape = Circle Float | Rect Float Float。注意这里的 type constructor 可以看成函数:Circle :: Float -> Shape

一个例子:

data Maybe a = Nothing | Just a
data Nat = Zero | Succ Nat
nat2int :: Nat -> Int
nat2int Zero = 0
nat2int (Succ n) = 1 + nat2int n

data Expr = Val Int | Add Expr Expr | Mul Expr Expr
-- 例如,Mul :: Expr -> Expr -> Expr

注意优先级:函数调用 (i.e Just a, Succ Nat) 最先。

newtype 定义:若一个 type 只有一个 constructor 和一个 argument,就可以写 newtype。例如

newtype Nat = M Int -- correct
newtype Nat' a = N a -- correct
(N "a") :: Nat' String -- correct
newtype Na' a = N Int a -- wrong, has 2 arguments

type class 的实现方式如下:

class Eq a where
      (==), (/=) :: a -> a -> Bool
      -- Eq 具有的符号
      x /= y = not (x == y)
      x == y = not (x /= y)
      -- Eq 符号的性质及如果未定义时补全方法
      {-# MINIMAL (==) | (/=) #-}
      -- 至少要哪些

instance Eq Bool where
      False == False = True
      True == True = True
      _ == _ = False
      -- instance 的声明:类簇 + 类型 + where

Type class 之间也有 (=>) 关系,例如 class (Eq a) => Ord a where ..。许多内置类簇可以 derive,如 data Bool = False | True deriving (Eq, Ord, Show, Read)

An Example: The Countdown Problem

用暴力穷举找到 countdown problem 的解。最有趣的是其中的 combinatorial functions,例如

subs :: [a] -> [[a]] -- 所有子序列
subs [] = [[]]
subs (x:xs) = let yss = subs xs in yss ++ map (x:) yss

还有 interleave(所有插入方法),perms(全排列),choices(部分排列) 等。

用这些实现穷举表达式:先 choices 得到顺序,然后穷举表达式树:找一个地方分开,再枚举操作符拼起来。还讲了常数优化。

Interactive Programming

Haskell 可以容易地实现 Batch Program:给你 input(函数的)然后得到 output。但是,我们希望再从 keyboard 得到 input,然后一部分 output 输出到 screen。大区别在于,这强制 side effects 出现。

所以,为了把这部分也归到 pure function 上来,我们认为:

type IO = World -> World
type IO a = World -> (a, World)

这里,IO a 是 type of actions,返回值是 a。没有返回值,就是 IO ()() 就是零元组,也即一种没有值的类型。

由此可以知道有的函数的类型:

getChar :: IO Char
-- 从和 World 的交互中,返回一个 Char
putChar :: Char -> IO ()
-- 没有返回值
return :: a -> IO a
-- 交互就是什么都不做,但返回一个 a

do 的使用例子:

act :: IO (Char, Char)
act = do x <- getChar
         getChar
         y <- getChar
         return (x, y)

getLine :: IO String
getLine = do x <- getChar
             if x == '\n' then
                  return []
             else
                  do xs <- getLine
                  return (x:xs)

注意这里 (<-) 的作用,可以看成把 IO aa 取出来。

Monads and More

首先回顾一下两种提升代码抽象层次的方式:之前已经提到了 polymorphic functions,其是 over types。现在,我们在 type constructor 层面更进一级。

Functor 是一类(class)type constructor。其定义如下:

class Functor f where
      fmap :: (a -> b) -> (f a) -> (f b)
      -- <$> 就是 fmap 的运算符
      (<$) :: a -> f b -> f a
      (<$) = fmap . const

functor laws 为

  • fmap id = id
  • fmap (f.g) = fmap f . fmap g

fmap 所做的可以简单地看成“替换包装的内容”,也没有特别深刻的理解。

Applicative 是更进一步的 functor,课件上用如下方式引入(其实已经挺自然了):

fmap0 :: a -> f a
fmap1 :: (a -> b) -> f a -> f b
fmap2 :: (a -> b -> c) -> f a -> f b -> f c
...

我们希望能把这些全看成一种东西,自然就会想,因为 fmap 一层就会把 (curried) 函数包进 f,那能不能直接 f (a -> b) -> f a -> f b 呢?

这就引出了

class Functor f => Applicative f where
      pure :: a -> f a
      (<*>) :: f (a -> b) -> f a -> f b

这样,fmap3 g x y z = g <$> x <*> y <*> z,非常简洁。

例如 maybe applicative 就是直接把里面的结合起来,list applicative 可以定义为笛卡尔积,IO applicative 可以看成顺序做两个操作再结合返回值:

instance Applicative Maybe where
      pure = Just
      Nothing <*> _ = Nothing
      (Just g) <*> mx = g <$> mx

instance Applicative [] where
      pure x = [x]
      gs <*> xs = [g x | g <- gs, x <- xs]

instance Applicative IO where
      pure = return
      mg <*> mx = do {g <- mg; x <- mx; return (g x)}

为了提取一个 list 的信息,如下定义 sequenceA

sequenceA :: Applicative f => [f a] -> f [a]
sequenceA [] = pure []
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs
-- sequenceA [Just 1, Just 2, Just 3] = Just [1, 2, 3]
-- sequenceA [Just 1, Nothing, Just 3] = Nothing
-- sequenceA [一堆序列] = 它们的笛卡尔积

那如果序列的 <*> 不是笛卡尔积(虽然细想肯定是满足要求的,但我觉得不符合直觉)行不行呢?这就引出 applicative laws:

  • pure id <*> x = x 其实就是 fmap id = id
  • pure (g x) = pure g <*> pure x。前两条其实都是对较简单的情况做出的规定。
  • x <*> pure y = pure (\g -> g y) <*> x。这一条很有趣,很多不对的 applicative definition 都栽在这里,因为其要求交换函数和值(其中有一个是 pure)的顺序,不改变最终得到的东西的结构,换句话说(其中有一个是 pure 时)结构的交换律(例如后面的 Expr applicative),类似于这样:pure 返回的是“结构单位元”。
  • x <*> (y <*> z) = (.) <$> x <*> y <*> z。这其实就是结合律。

可以验证,很多“非笛卡尔积”的 <*> 都满足不了第三条。第三条也可以这么理解:打包的函数和打包的值地位是对等的。

接下来,monad 又是什么呢?使用运算符的异常处理这个例子来引入。

一个比较简单的想法是

safediv :: Int -> Int -> Maybe Int
safediv _ 0 = Nothing
safediv n m = Just (div n m)

但如果要对它 evaluate 呢?看看这个程序:

eval :: Expr -> Maybe Int
eval (Div x y) = safediv <$> eval x <*> eval y

这个程序有问题,因为其最终结果的类型变成 Maybe Maybe Int 了!safediv 套了一层 Maybe,最终结果又套了一层 Maybe。当然可以写一个函数 f :: Maybe a -> a,但是不够简洁。

这时候就引入了 monad 的 bind 操作符:

(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b
mx >>= f = case mx of
           Nothing -> Nothing
           Just x -> f x

eval :: Expr -> Maybe Int
eval (Div x y) = eval x >>= (\n -> (eval y >>= (\m -> safediv n m)))
-- 所有 lambda 函数的参数全是 int,增加了可读性和简洁性!

注意这里 case of 语句的用法。

接下来来点语法糖,定义 a <- b 表示 b >>= \a ->,则上面的东西可以写成

eval :: Expr -> Maybe Int
eval (Div x y) = do n <- eval x
                    m <- eval y
                    safediv n m

这个定义的理解是很容易的:b >>= \a -> 就是对 b 里面的东西做一个操作,在后面的操作里令这东西叫 a,相当于把 b 里面的东西提取出来叫做 a。形式化地说:

  • a >>= f 等价于 do v <- af v
  • a >> b 等价于 do ab
    这里,
    (>>) :: m a -> m b -> m b
    m >> k = m >>= const k
    

例如,list monad:

instance Monad [] where
      xs >>= f = [y | x <- xs, y <- f x]

Haskell 中不能把 type 定义成 monad,只能把 data 或者 newtype 定义成 monad。考虑下面的 ST(状态变换器),返回 a 类型信息和一个新状态:

newtype ST a = S (State -> (a, State))
app :: ST a -> State -> (a, State)
app (S f) s = f s

现在我们希望把它变成 monad,以支持“顺序操作”。直观感受一下以下几个函数的意义:

  • fmap :: (a -> b) -> ST a -> ST b:新的状态变换器就是对原来的结果再施加一个函数。
  • pure :: a -> ST a:不改变状态,返回这个 a
  • (<*>) :: ST (a -> b) -> ST a -> ST b:先把状态过第一个 ST,再过第二个,结果就是第一个的结果 ($) 上第二个。
  • (>>=) :: ST a -> (a -> ST b) -> ST b:先把状态过第一个 ST,把这个的状态过这个 ST 的结果得到的 ST。

问题:(<*>) 为什么一定要左结合,能否右结合?其实也可以,Applicative 本来就不唯一。

这个 State Monad 有啥用?下面给出例子:树的标注。考虑这样定义的树:

data Tree a = Leaf a | Node (Tree a) (Tree a) deriving Show

希望将其每个叶子标注一个从 0 开始不重复的编号,也即实现

relabel :: Tree a -> Tree Int

注意我们递归过程中要维护当前标到了几号,“几号”就是 State,“标好号的树”就是返回值,故可以用 Applicative 如下实现:

type State = Int

fresh :: ST Int
fresh = S $ \n -> (n, n + 1)
-- 返回状态并将状态加一

alabel :: Tree a -> (ST Tree Int)
alabel (Leaf _) = Leaf <$> fresh
-- 在叶子处调用上述变换,并将结果 fmap 成树
alabel (Node l r) = Node <$> alabel l <*> alabel r
-- 这个写法上面 safediv 已经见过了

relabel t = fst $ app (alabel t) 0

当然也可以写成 Monad:

mlabel :: Tree a -> (ST Tree Int)
mlabel (Leaf _) = do n <- fresh
                  return (Leaf n)
-- 在叶子把上述变换里面的状态取出来变成树
mlabel (Node l r) = do l' <- mlabel l
                       r' <- mlabel r
                       return $ (Node l' r')
-- 这个写法上面 safediv 已经见过了

relabel t = fst $ app (mlabel t) 0

这样至少让代码清晰很多。

下面是 Monad laws:

  • Left identity:return a >>= h = h a
  • Right identity:mx >>= return = mx
  • Associativity:(mx >>= g) >>= h = mx >>= (\x -> g x >>= h)

下面引入一个新的、让事情更简洁的操作符,(>=>):the monad-composition operator。

(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g = \x -> f x >>= g

那此时 Monad laws 可以写成:

  • return >=> h = h
  • f >=> return = f
  • (f >=> g) >=> h = f >=> (g >=> h)

非常简洁。这其实是很自然的,因为控制流程本来就应该有这些性质。换句话说,f 构成幺半群,return 是单位元!

根据这些 law,我们能推出

  • do {x' <- return x; f x'} 等价于 do {f x},也就是 <- return 是没意义的。
  • do {x' <- x; return x'} 等价于 do {x},也就是 u <- ...; return u 是没意义的。
  • do {y <- do {x <- mx; f x}; g y} 等价于 do {x <- mx; y <- f x; g y}

就算不变换也很好理解。但注意,其实程序某种意义上是“从下往上”展开的!

结合上 let,我们有 do {let A; B} 等价于 let A in do B

上面我们用 Applicative 实现了 sequenceA,用 Monad 来写就是

sequence :: (Monad m) => [m a] -> m [a]
-- 把一系列计算一起做了,返回结果的 list
sequence [] = return []
sequence (x:xs) = do v <- x
                     vs <- sequence xs
                     return (v:vs)

这里头脑中想着 State Monad,就会很清晰(不需要把自己绕到具体实现上)。IO Monad 也就是一种特殊的 State Monad 罢了。

到这里内容就完了。作业中有一个语法问题:(->) a 是个 Type constructor,它接受一个 b,返回 a -> b 这个 type。

Monadic Parser

希望写一个解析器,给一个字符串,从前往后解析表达式。本题中,希望 Parser 类型是 newtype Parser a = P (String -> [(a, String)])a 是返回值,String 是剩下的字符串。之所以是数组是因为若计算失败了返回空数组就行了(注意数组在 Applicative, Monad 意义上类似于 Maybe 的严格超集)。

由此就能实现很多简单的函数了,例如

parse :: Parser a -> String -> [(a, String)]
parse (P f) program = f program

item :: Parser Char
item = P (\program -> case program of
                           [] -> []
                           (x:xs) -> [(x, xs)])
-- parse item "abc" = [('a', "bc")]

我们发现 parser 的结构天然就很适合 do:

three = do {x <- item; item; z <- item; return (x,z)}
-- parse three "abcdef" = [(('a', 'c'), "def")]

sat :: (Char -> Bool) -> Parser Char
sat p = do x <- item
           if p x then return x else empty

但为了处理不定长的整数等,我们需要 Alternative 这种 making choices 的 type class。其实就是一个幺半群,定义如下:

class Applicative f => Alternative f where
      (<|>) :: f a -> f a -> f a
      -- 有结合律
      empty :: f a
      -- 幺元
      many :: f a -> f [a]
      many v = some v <|> pure []
      -- 0 or more:先把这里面的计算尝试 1 or more 次,不行就返回空
      some :: f a -> f [a]
      some v = (:) <$> v <*> many v
      -- 1 or more:无论如何都要先尝试一次计算,再接上 0 or more

注意这里 many 和 sum 定义相互递归,但在之后用的时候就能看出它总会结束。

那在 parser 里面这有什么用呢?可以用 empty 表示不成功,而 (<|>) 运算符就用来判断表达式的这一项是数字、乘积、子表达式中的哪一种。所以作出如下定义:

instance Alternative Parser where
      empty = P (const [])
      p <|> q = P $ \program -> case parse p program of
                                     [] -> parse q program
                                     res -> res
-- 取两者中先有结果的一个

结合 Alternative 里的 some, many 和 sat(item)就可以写出很多 parser 了:读整数,读字母,读 token,读空格……例如读自然数:

digit :: Parser Char
digit = sat isDigit

nat :: Parser Int
nat = do xs <- some digit -- xs 是 Parser [Char],返回的就是 String
         return (read xs) -- 库函数 read 把 String 里的东西读出来

下面我们看表达式的语法,例如其中一项是

expr ::= term '+' expr | term

这个语法直接就可以翻译成 Haskell!

expr :: Parser Int
expr = do t <- term
          do  symbol "+"
              e <- expr
              return (t + e)
          <|> return t
-- 如果前者可行(还能读到 + 和 expr)就读,否则就读一 term

最后的 eval 函数也相当简单:

eval :: String -> Int
eval xs = fst $ head $ parse expr xs

Foldables and Friends

Haskell 中定义了半群和幺半群:

class Semigroup a where
      (<>) :: a -> a -> a
      mappend = <> -- 别名
      -- 需要有结合律

class Semigroup a => Monoid a where
      mempty :: a
      -- 幺元

例如 Maybe Monoid

instance Semigroup a => Semigroup (Maybe a) where
      Nothing <> b = b
      a <> Nothing = a
      Just a <> Just b = Just (a <> b)

instance Semigroup a => Monoid (Maybe a) where
      mempty = Nothing

如果一个东西有结合律,那就可以对他在序列上 fold 了!例如如下函数:

fold :: Monoid a => [a] -> a
fold [] = mempty
fold (x:xs) = x <> fold xs

将其拓展,例如树上也可以按照先序遍历 fold,我们如下定义 Foldable:

class Foldable t where
      fold :: Monoid a => t a -> a
      foldMap :: Monoid b => (a -> b) -> t a -> b
      foldr :: (a -> b -> b) -> b -> t a -> b
      foldl :: (b -> a -> b) -> b -> t a -> b

那这样对于一棵树,也可以把它变得 foldable:

data Tree a = Leaf a | Node (Tree a) (Tree a) deriving Show

instance Foldable Tree where
      fold (Leaf x) = x
      fold (Node l r) = fold l <> fold r
      foldMap f (Leaf x) = f x
      foldMap (Node l r) = foldMap l <> foldMap r
      foldr f v (Leaf x) = f x v
      foldr f v (Node l r) = foldr f (foldr f v r) l
      -- foldr 是从右往左
      foldl f v (Leaf x) = f v x
      foldl f v (Node l r) = foldl f (foldl f v l) r
      -- foldl 是从左往右
-- foldl foldr 要小心,两个函数稍不注意就会定义成一样的

foldable 至少需要定义 foldMap 或者 foldr,例如

  • fold = foldMap id
  • foldMap f = foldr (mappend . f) memptymappend 就是 <>

如果我们想把一个 Foldable 东西里面的信息提出来,例如

traverse :: (a -> Maybe b) -> [a] -> Maybe [b]
traverse g [] = pure []
traverse g (x:xs) = (:) <$> g x <*> traverse g xs
-- 只要有一个变成 Nothing 就是 Nothing

为了把这个定义泛化(例如 Tree 里的信息也能提取),定义 Traversable 为

class (Functor t, Foldable t) => Traversable t where
      traverse :: Applicative f => (a -> f b) -> t a -> f (t b)

这里可以想着 list 和 Maybe 的例子理解。

traverse 也可以用之前的 sequenceA 定义,两者是互推的:

sequenceA = traverse id
-- 式子是显然的,但注意这要求传进去的东西本身就是 t (f a),回忆 list 和 Maybe
traverse g = sequenceA . fmap g
-- 先把 g fmap 上去,再 sequenceA

Lazy Evaluation

计算策略有两种:call-by-value(自内而外)和 call-by-name(自外而内)。Haskell 的惰性求值是自外而内加上 sharing(记忆化),如

square (1 + 2)
= (1 + 2) * (1 + 2)
= 3 * 3 -- 这里只算了一次 1+2
= 9

但用 $!,可以强制自内而外,有时可以减少空间:

square $! (1 + 2)
= square 3
= 3 * 3
= 9

$! 也可以嵌套,如

  • (f $! x) y 先算 x
  • (f x) $! y 先算 y
  • (f $! x) $! y 会把 x 和 y 都算出来
posted @ 2023-11-09 16:34  tianbu  阅读(326)  评论(0)    收藏  举报