在Haskell中打结的教程

160 阅读3分钟

这篇文章与婚姻没有关系。至少在我看来,打结是一种相对晦涩的技术,你可以在Haskell中用来解决某些角落的情况。我自己只用过几次,其中一次我将在下面提到。我在前面这样说,希望能说明:打结在某些情况下是一种很好的技术,但不要认为它是你应该经常需要的一般技术。它还不如像软件事务性内存那样普遍有用。

也就是说,你仍然对这种技术感兴趣,并且仍然在阅读这篇文章。很好!让我们从所有糟糕的Haskell代码开始吧。

双链表

通常我会在Rust中演示指令性代码,但在这种情况下,这不是一个好主意。所以我们将从C++中一个非常简单的双链表实现开始。说到 "非常简单",我也许应该说 "写得非常差",因为我已经没有实践经验了。

Rusty C++

不管怎么说,阅读整个代码并不是必要的,因为它能让我们明白这个道理。让我们来看看一些相关的部分。我们这样定义一个列表的节点,包括一个指向列表中上一个和下一个节点的可空指针。

template <typename T> class Node {
public:
  Node(T value) : value(value), prev(NULL), next(NULL) {}
  Node *prev;
  T value;
  Node *next;
};

当你向列表添加第一个节点时,你将新节点的上一个和下一个值设置为NULL ,并将列表的第一个和最后一个值设置为新节点。更有趣的情况是当你在列表中已经有了一些东西。要在列表的后面添加一个新的节点,你需要一些代码,看起来像下面这样。

node->prev = this->last;
this->last->next = node;
this->last = node;

对于那些(像我一样)不精通C++的人来说,我在做三个突变:

  1. 变异新节点的prev 成员,使其指向当前列表的最后一个节点。
  2. 突变当前最后一个节点的next 成员,使其指向新节点。
  3. 突变列表本身,使其last 成员指向新节点。

所有这些的重点是:为了创建一个双链表,有很多的变异在进行。与Haskell中的单链表相比,后者是不可变的数据结构,完全不需要变异。

总之,在这一点上,我已经写完了我每年的C++配额,是时候回到Haskell了。

RIIH (Rewrite it in Haskell)

使用IORefs和大量的IO 调用,就可以在Haskell中重现C++的可变双链表的概念。完整的代码可以在Gist中找到,但让我们先看一下重要的部分。我们的核心数据类型看起来很像C++版本,但为了稳妥起见,还加入了IORefMaybe

data Node a = Node
    { prev  :: IORef (Maybe (Node a))
    , value :: a
    , next  :: IORef (Maybe (Node a))
    }

data List a = List
    { first :: IORef (Maybe (Node a))
    , last :: IORef (Maybe (Node a))
    }

向非空列表添加一个新的值看起来像这样:

node <- Node <$> newIORef (Just last') <*> pure value <*> newIORef Nothing
writeIORef (next last') (Just node)
writeIORef (last list) (Just node)

注意,像C++代码一样,我们需要对现有节点和列表中的last 成员进行突变。

这当然是可行的,但对于一个哈斯克人来说,它可能感觉不那么令人满意:

  • 我不喜欢到处都是突变的想法。
  • 这段代码看起来和感觉都很难看。
  • 我不能从纯代码中访问列表的值。

所以挑战是:我们能用纯代码在Haskell中写一个双链表吗?

定义我们的数据

我要提前警告你。每一次我在Haskell中写 "打结 "的代码时,都至少经历了两个阶段:

  1. 这没有任何意义,这不可能成功,我到底在做什么?
  2. 哦,已经完成了,到底是怎么做到的?

这是在写下面的代码时发生的。你在阅读时很可能会有同样的感觉:"等等,什么?我不明白,嗯?"

无论如何,让我们从定义我们的数据类型开始。我们不喜欢到处都是IORef 的事实。所以,让我们把它去掉吧!

data Node a = Node
    { prev  :: Maybe (Node a)
    , value :: a
    , next  :: Maybe (Node a)
    }

data List a = List
    { first :: Maybe (Node a)
    , last :: Maybe (Node a)
    }

我们仍然有Maybe ,以表示在我们自己的节点之前或之后有无节点。这种翻译是很容易的。当我们试图建立这样一个结构时,问题就会出现,因为我们已经看到我们需要突变来实现它。我们需要重新考虑我们的API来进行。

不可变异的API

我们需要考虑的第一个变化是摆脱API中的变异概念。以前,我们有像pushBackpopBack 这样的函数,它们本身就是突变的。相反,我们应该从不可变的数据结构和API的角度来考虑。

我们已经知道了所有关于单链表的信息,即古老的[] 数据类型。让我们看看我们是否可以建立一个函数,让我们从单链表中构造一个双链表。换句话说:

buildList :: [a] -> List a

让我们先解决两个简单的情况。一个空的列表最后应该是没有任何节点的。这个条款将是:

buildList [] = List Nothing Nothing

下一个简单的情况是列表中的一个值。这最终会产生一个没有指向其他节点的单一节点,以及一个firstlast 字段,都指向这一个节点。同样,相当简单,不需要打结:

buildList [x] =
    let node = Node Nothing x Nothing
     in List (Just node) (Just node)

好吧,这太简单了。让我们把它提高一个档次。

双元素列表

为了更深入地了解情况,我们接下来处理两个元素的情况,而不是一般的 "2或更多 "的情况,后者要复杂一些。我们需要

  1. 构建一个指向最后一个节点的第一个节点
  2. 构建一个指向第一个节点的最后一个节点
  3. 构建一个同时指向第一个和最后一个节点的列表

步骤(3)并不难。第(2)步听起来也不坏,因为第一节点可能已经存在于该点。问题似乎在于步骤(1)。当我们还没有构建第二个节点时,我们怎么能构建一个指向第二个节点的第一个节点呢?让我告诉你怎么做。

buildList [x, y] =
    let firstNode = Node Nothing x (Just lastNode)
        lastNode = Node (Just firstNode) y Nothing
     in List (Just firstNode) (Just lastNode)

如果这段代码没有让你感到困惑或困扰,你可能已经了解了打结的知识。这似乎是没有意义的。我在构造firstNode 的同时参考了lastNode ,在构造lastNode 的同时参考了firstNode 。这有点让我想到了大蛇丸,或者说是蛇吃自己的尾巴。

Ouroboros

在一个正常的编程语言中,这个概念是没有意义的。我们需要先定义firstNode ,并为next 提供一个空指针。然后我们可以定义lastNode 。然后我们可以将firstNode'snext 改为指向最后一个节点。但在Haskell中不是这样的!为什么?因为懒惰。由于懒惰,firstNodelastNode 最初被创建为thunks。它们的内容还不需要存在。但值得庆幸的是,我们仍然可以创建指向这些未完全评估值的指针。

有了这些指针,我们就可以为其中的每一个定义一个表达式,利用另一个的指针。现在,我们已经成功地打了一个结。

扩展到两个以上

超过两个元素的扩展遵循完全相同的模式,但(至少在我看来)明显要复杂得多。我通过编写一个辅助函数buildNodes 来实现它,该函数(有点诡异)以列表中的前一个节点为参数,并返回列表中的下一个节点和最后一个节点。让我们看看这一切的动作。

buildList (x:y:ys) =
    let firstNode = Node Nothing x (Just secondNode)
        (secondNode, lastNode) = buildNodes firstNode y ys
     in List (Just firstNode) (Just lastNode)

-- | Takes the previous node in the list, the current value, and all following
-- values. Returns the current node as well as the final node constructed in
-- this list.
buildNodes :: Node a -> a -> [a] -> (Node a, Node a)
buildNodes prevNode value [] =
    let node = Node (Just prevNode) value Nothing
     in (node, node)
buildNodes prevNode value (x:xs) =
    let node = Node (Just prevNode) value (Just nextNode)
        (nextNode, lastNode) = buildNodes node x xs
     in (node, lastNode)

请注意,在buildList 中,我们使用了同样的技巧,用secondNode 来构造firstNode ,而firstNode 是传递给buildNodes 的一个参数,用来构造secondNode

buildNodes ,我们有两个子句。第一个子句是那种比较简单的情况:我们只剩下一个值了,所以我们创建一个终端节点,指向上一个。不需要打结。然而,第二个子句再次使用了打结技术,加上对buildNodes 的递归调用来建立列表中的其他节点。

完整的代码可以在Gist中找到。我建议多读几遍代码,直到你觉得舒服为止。当你对发生的事情有了很好的把握后,可以尝试自己从头开始实现它。

限制条件

重要的是要理解这种方法与可变双链表和单链表相比的一个局限。对于单链表,我可以很容易地通过cons一个新的值到前面来构造一个新的单链表。或者我可以从前面删除一些值,然后在新的尾巴前面加上一些新的值。换句话说,我可以随心所欲地在旧值的基础上构造新值。

同样地,对于可变双链表,我可以自由地进行变异,改变我现有的数据结构。这与构造新的单链表的行为略有不同,属于哈斯克人所熟悉和喜爱的可变-不可变数据结构的范畴。如果你想复习一下,请查看:

这些都不适用于数据结构的打结方法。一旦你构建了这个双链表,它就被锁定在那里。如果你试图在这个列表的前面添加一个新的节点,你会发现你无法更新旧的第一个节点中的prev 指针。

有一个变通的办法。你可以用原来的值构建一个全新的双链表。一个常见的方法是提供一个从你的List a[a] 的转换函数。然后你可以用一些类似的代码将一个值追加到双链表中。

let oldList = buildList [2..10]
    newList = buildList $ 1 : toSinglyLinkedList oldList

然而,与单链表不同的是,我们失去了数据共享的所有可能性,至少在结构层面上是这样(值本身仍然可以共享)。

为什么要打结呢?

这是个很酷的技巧,但它真的有用吗?在某些情况下,绝对有用我所做的一个例子是在xml-conduit包中。有些人可能对XPath很熟悉,这是一个相当不错的XML遍历标准。它允许你这样说:"找到文档中的第一个ul 标签,然后找到之前的p 标签,并告诉我其id 属性"。

一个XML数据类型在Haskell中的简单实现可能是这样的:

data Element = Element Name (Map Name AttributeValue) [Node]
data Node
    = NodeElement Element
    | NodeContent Text

使用这种数据结构,要实现我刚才描述的遍历是相当困难的。你需要编写逻辑来跟踪你在文档中的位置,然后实现逻辑说:"好吧,鉴于我在第六个孩子的第二个孩子的第三个孩子中,在我之前的所有节点是什么?"

相反,在xml-conduit ,我们用打结的方式来创建一个数据结构,称为a Cursor.一个Cursor ,不仅可以跟踪它自己的内容,而且还包含一个指向它的父游标、它的前游标、它的后游标和它的子游标的指针。这样你就可以轻松地遍历树了。上面的遍历将被实现为:

#!/usr/bin/env stack
-- stack --resolver lts-17.12 script
{-# LANGUAGE OverloadedStrings #-}
import qualified Text.XML as X
import Text.XML.Cursor

main :: IO ()
main = do
    doc <- X.readFile X.def "input.xml"
    let cursor = fromDocument doc
    print $ cursor $// element "ul" >=> precedingSibling >=> element "p" >=> attribute "id"

你可以用这个输入文件样本自己测试一下:

<foo>
    <bar>
        <baz>
            <p id="hello">Something</p>
            <ul>
                <li>Bye!</li>
            </ul>
        </baz>
    </bar>
</foo>

我应该打结吗?

就像大多数编程技术一样,特别是Haskell,我们很想去寻找一个用例来使用这个技术。用例肯定是存在的。我认为xml-conduit 就是其中之一。但让我指出,这是我作为一个Haskeller的职业生涯中唯一能想到的例子,打结是解决问题的好办法。还有一些类似的案例,我也会包括在内(比如JSON文档的遍历)。

这个技术值得学习吗?是的,绝对值得。这是一个拓展思维的举措。它可以帮助你更好地内化懒惰的概念。这真的很有趣,也很震撼人心。但不要急于重写你的代码来使用一个相对小众的技术。