$和()之间的区别

19

我开始学习 Haskell,但遇到了一个我无法理解的问题。我有一个用于从一组键值对列表中查找值的方法(来自这个页面):

let findKey key xs = snd . head . filter (\(k,v) -> key == k) $ xs  
我尝试了一下,最终决定以以下方式去掉美元符号:
let findKey key xs = snd . head . filter (\(k,v) -> key == k) ( xs )

然而,它甚至无法解析(应用于过多参数错误的过滤器)。我已经阅读过了,$符号用于简单地替换括号,但我无法弄清楚为什么这种简单的代码更改是有问题的。有人能给我解释一下吗?


3
可能是关于 Haskell 中 .(点)和 $(美元符号)的区别的重复问题。 - cdk
5个回答

31

中缀运算符 ($) 就是“函数应用”。换句话说

 f   x     -- and
 f $ x

都是一样的。 在 Haskell 中,圆括号仅用于消除优先级歧义(以及元组表示法和其他少量情况,请参见注释),因此我们还可以用几种其他方式编写上述内容。

 f     x
 f  $  x
(f)    x
 f    (x)
(f)   (x)    -- and even
(f) $ (x)
在每种情况下,上述表达式表示相同的意思:“将函数f应用于参数x”。
那么为什么要有这些语法?($)有两个用途:
  1. 它具有非常低的优先级,因此有时可以代替很多括号。
  2. 拥有一个明确的名称来表示函数应用的操作是很好的。

在第一种情况下,请考虑以下深度嵌套的函数应用。
f (g (h (i (j x))))

阅读此内容可能有些困难,而且很难知道你是否拥有正确数量的括号。不过,这只是一堆应用程序,因此应该可以使用($)表示此短语的表达方式。实际上确实如此。

 f $ g $ h $ i $ j $ x

有些人发现这样更易于阅读。更现代的风格还使用(.)来强调这个短语的整个左侧只是一个由函数组成的管道。

 f . g . h . i . j $ x

正如我们之前所看到的,这个短语与

(f . g . h . i . j)  x

有时阅读起来更加舒适。


此外,有时我们希望能够传递函数应用的想法。例如,如果我们有一组函数:

lof :: [Int -> Int]
lof = [ (+1), (subtract 1), (*2) ]

我们可能希望通过某个值对应用程序进行映射,例如将数字4应用于每个函数。

> map (\fun -> fun 4) lof
[ 5, 3, 8 ]

但由于这只是函数应用,我们也可以使用 ($) 的“截断”语法来更加明确

> map ($ 4) lof
[ 5, 3, 8 ]

你如何用括号替换点呢?这个 (f . g . h . i( j )) x 似乎不起作用... - TheMP
2
为了完整起见,括号也被运算符部分和单位类型使用。 - nponeccop
3
“@Niemand (f . g) x = f (g x)是你想要使用的方程式。特别地,除非你有参数x在某个地方,否则你不能用“括号表示法”替换(.)。” - J. Abrahamson
1
非常感谢你的帮助,你把一切都解释得很清楚,即使对于像我这样的面向对象编程小白来说也能理解。:D - TheMP
@dfeuer 谢谢!已修复。 - J. Abrahamson
显示剩余3条评论

17
运算符$的优先级最低,因此
snd . head . filter (\(k,v) -> key == k) $ xs

被读作

(snd . head . filter (\(k,v) -> key == k)) xs

虽然你的第二个表达式相当

snd . head . ( filter (\(k,v) -> key == k) xs )

7
$符号并不是用于替换括号的魔法语法。它是一个普通的中缀运算符,就像+运算符一样。将括号放在单个名称周围,例如(xs),永远等同于只使用xs1。因此,如果$这样做,那么你无论哪种方式都会得到相同的错误。试想一下,如果你在那里使用了其他熟悉的运算符,比如+,会发生什么:
let findKey key xs = snd . head . filter (\(k,v) -> key == k) + xs

忽略+在数字上的作用,这没有意义,只需考虑表达式的结构; 哪些术语被识别为函数,哪些术语被作为参数传递给它们。

实际上,在那里使用+确实可以成功解析和类型检查!(它会给你一个带有无意义类型类约束的函数,但是如果您满足它们,它确实有意义)。 让我们一起走过中缀运算符的解析过程:

let findKey key xs = snd . head . filter (\(k,v) -> key == k) + xs

最高优先级的操作始终是普通函数应用(仅在彼此之间编写术语,不涉及中缀运算符)。这里只有一个例子,即 filter 应用于 lambda 定义。它被"解析"后就成为一个单独的子表达式,从而在解析其余运算符时得到考虑:

let findKey key xs
      = let filterExp = filter (\(k,v) -> key == k)
        in  snd . head . fileterExp + xs

下一个最高优先级的运算符是“.”操作符。我们有几个选择,在这里它们的优先级是相同的。“.”是右结合的,所以我们首先取最右边的一个(但无论我们选哪一个,实际上都不会改变结果,因为“.”的意义是关联操作,但解析器无法知道这一点)。
let findKey key xs
      = let filterExp = filter (\(k,v) -> key == k)
            dotExp1 = head . filterExp
        in  snd . dotExp1 + xs

请注意, . 会立即抓取其左右的术语。这就是为什么优先级如此重要的原因。还剩下一个 . ,它的优先级仍高于 + ,所以接下来处理它:
let findKey key xs
      = let filterExp = filter (\(k,v) -> key == k)
            dotExp1 = head . filterExp
            dotExp2 = snd . dotExp1
        in  dotExp2 + xs

我们完成了!在这里,+的优先级最低,因此它最后获得其参数,并成为整个表达式中最高层的调用。请注意,+的低优先级防止了xs被左侧任何更高优先级的应用程序声明为参数。如果它们中的任何一个优先级较低,它们将以整个表达式dotExp2 + xs作为参数,因此它们仍然无法到达xs;在xs之前放置中缀运算符(任何中缀运算符)可防止其被左侧任何东西声明为参数。
实际上,这正是在该表达式中解析$的方式,因为.$恰好具有与.+相同的相对优先级;$的设计具有极低的优先级,因此它将以几乎任何其他涉及左右的操作符的方式工作。
如果我们在filter调用和xs之间放置中缀运算符,则会发生以下情况:
let findKey key xs = snd . head . filter (\(k,v) -> key == k) xs

首先进行普通函数应用。这里我们有三个术语简单地并列在一起:filter(\(k,v) -> key == k)xs。函数应用是左结合的,因此我们首先取最左侧的一对:

let findKey key xs
      = let filterExp1 = filter (\(k,v) -> key == k)
        in  snd . head . filterExp1 xs

还有另一个常见的应用程序,它的优先级仍然高于.,所以我们执行它:

let findKey key xs
      = let filterExp1 = filter (\(k,v) -> key == k)
            filterExp2 = filterExp1 xs
        in  snd . head . filterExp2

现在是第一个点:
let findKey key xs
      = let filterExp1 = filter (\(k,v) -> key == k)
            filterExp2 = filterExp1 xs
            dotExp = head . filterExp2
        in  snd . dotExp

我们完成了,这次整个表达式中最顶部的调用是最左边的.运算符。这次xs作为第二个参数被吸入filter。这有点像我们想要的,因为filter确实需要两个参数,但它将filter的结果留在函数组合链中,而对两个参数应用的filter不能返回一个函数。我们想要的是将其应用于一个参数,生成一个函数,使该函数成为函数组合链的一部分,然后将整个函数应用于xs
有了$,最终形式与我们使用+时相同:
let findKey key xs
      = let filterExp = filter (\(k,v) -> key == k)
            dotExp1 = head . filterExp
            dotExp2 = snd . dotExp1
        in  dotExp2 $ xs

它的解析方式与我们使用 '+' 时完全相同,所以唯一的区别在于,当 '+' 表示“将我的左参数添加到我的右参数”时,“$” 表示“将我的左参数作为函数应用于我的右参数”。这正是我们想要的!太好了!
简而言之:坏消息是 '$' 并不能仅仅通过括号包裹来工作;它比那更复杂。好消息是,如果你理解 Haskell 解析涉及中缀运算符的表达式的方式,那么你就理解了 '$' 的工作原理。对于语言本身来说,它并没有什么特殊性;它就是一个普通的操作符,如果它不存在,你也可以自己定义它。
1. 像 '+' 这样的操作符加上括号只是给你同样由 '+' 表示的函数,但现在它不再有特殊的中缀语法,因此在这种情况下会影响到如何解析东西。但像 '(xs)' 这样的名称内部则不是这样。

4
"$符号用于简单替换括号"这基本上是正确的 - 但它实际上把两侧的所有内容都括起来了!因此,"
snd . head . filter (\(k,v) -> key == k) $ xs

是有效的

( snd . head . filter (\(k,v) -> key == k) ) ( xs )

当然,在这里xs周围的圆括号是不必要的(那本来就是一个“原子”),因此在这种情况下,相关的圆括号在左边。实际上,在Haskell中经常发生这种情况,因为一般的哲学是尽可能地将函数视为抽象实体,而不是当将该函数应用于某个参数时涉及到的特定值。您的定义也可以写成:
let findKey key xs' = let filter (\(k,v) -> key == k) $ xs
                          x0 = head xs'
                          v0 = snd x0
                      in v0

这将非常明确,但所有这些中间值并不是真正有趣的。因此,我们更喜欢将函数“点无”地链接在一起,使用.。这通常可以让我们摆脱很多样板文件,事实上,对于您的定义,可以执行以下操作:

  1. η-reduction of the xs. That argument just passed on to the chain of functions, so we might as well say "findKey key" is that chain, with whatever argument you supply"!

        findKey key = snd . head . filter (\(k,v) -> key == k)
    
  2. Next, we can avoid this explicit lambda: \(k,v) -> k is simply the fst function. You then need to postcompose comparison with key.

        findKey key = snd . head . filter ((key==) . fst)
    
  3. I'd stop here, since too much point-free is pointless and unreadable. But you could go on: there's new parens now around the argument to key, we can again get rid of those with $. But careful:

        "findKey key = snd . head . filter $ (key==) . fst"
    

    is not right, because the $ would again parenthesise both sides, yet (snd . head . filter) is not well-typed. Actually snd . head should only come after both arguments to filter. A possible way to do such a post-post-composition is using the function functor:

        findKey key = fmap (snd . head) . filter $ (key==) . fst
    

    ...we could go on even further and get rid also of the key variable, but it wouldn't look nice. I think you've got the point...


4

其他答案详细讨论了如何用 ($) 替换括号,因为 ($) 定义为具有正确优先级的应用运算符。

我想补充的是,为了使 GHC 能够用 ($) 替换括号,在定义 ($) 时使用了更多的魔法。当涉及到高阶函数时,类型系统可以通过标准应用(如 f x)处理高阶参数,但是不可以通过应用操作符(如 f $ x)处理。为了解决这个问题,GHC 在类型系统中特殊处理 ($)。事实上,以下代码显示,如果我们定义并使用自己的应用操作符 ($$),类型系统不会应用相同的魔法处理。

{-# LANGUAGE RankNTypes #-}

-- A higher rank function
higherRank :: (forall a. a -> a -> a) -> (Int, Char)
higherRank f =  (f 3 4, f 'a' 'b')

-- Standard application
test0 :: (Int, Char)
test0 = higherRank const     -- evaluates to (3,'a')

-- Application via the ($) operator
test1 :: (Int, Char)
test1 = higherRank $ const   -- again (3, 'a')

-- A redefinition of ($)
infixr 0 $$
($$) :: (a -> b) -> a -> b
($$) = ($)

test2 :: (Int, Char)
test2 = higherRank $$ const  -- Type error (!)
-- Couldn't match expected type `forall a. a -> a -> a'
--             with actual type `a0 -> b0 -> a0'

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接