【问题标题】:Which Algebraic Pattern fits this type of tree?哪种代数模式适合这种类型的树?
【发布时间】:2017-08-10 23:50:08
【问题描述】:

我为你准备了一个谜题,

我设法编写了一些代码来使用递归方案来完成这些事情,但它非常混乱 这通常意味着我在某处遗漏了一个有用的抽象。

我正在为我的文本编辑器设计一个布局系统 Rasa;它以非常相似的方式使用拆分 方式如 Vim。我决定用树来描述分裂;你可以想象 它作为垂直或水平分割的二叉树,在 叶节点。 This picture 可能会有所帮助。

这是我的初始数据结构:

data Direction = Hor | Vert
data Tree a = 
  Branch Direction (Tree a) (Tree a)
  | Leaf a
  deriving (Functor)

我需要的一些操作是:

  • split :: (View -> Tree View) -> Tree View -> Tree View 它将节点(或不)拆分为水平或垂直的两个节点(同时保持它们在 树)
  • close :: (View -> Bool) -> Tree View -> Tree View 通过删除“关闭”任何与谓词匹配的视图 将它们从树中提取出来并正确地重新组织相邻的视图。
  • fmap;我希望树成为函子,这样我就可以改变视图。

一些不错的功能: - focusRight :: Tree View -> Tree View,当且仅当最近的水平连接时才将视图设置为活动状态 向左查看已激活

我正在寻找一个抽象或一组抽象来提供这个 以干净的方式实现功能。到目前为止,这是我的思考过程:

起初我以为我有一个 Monoid,身份是一棵空树,并且 mappend 只会将另一个分支附加到树上,但这不起作用 因为我有两个操作:垂直追加和水平追加以及操作 当它们混合在一起时不具有关联性。

接下来我想“我的一些操作取决于他们的上下文”,所以我可能有一个 Comonad。树的版本 我没有作为共同单子工作,因为我在分支上没有 extract 的价值,所以我重组了我的树 像这样:

data Tree a = Node Direction [Tree a] a [Tree a]
    deriving (Functor)

但这仍然 没有根据里面的内容处理“拆分”节点的情况,这与签名(View -> Tree View) -> Tree View -> Tree View 相匹配,它与来自 Monad 的bind 统一,所以也许我有一个 monad?我可以为 原始树定义,但无法为我的 Comonad 树版本弄清楚。

有没有办法让我在这里两全其美?我用 Comonad/Monad 挖错树了吗? 基本上,我正在寻找一种优雅的方法来对我的数据结构上的这些函数进行建模。谢谢!

如果要查看完整代码,函数为here,当前树为here

【问题讨论】:

  • @pigworker 有一个完整的 talk 和一大块 paper 都是关于使用 monads(超过索引集)来划分 2D 空间
  • @benjamin-hodgson 哦,太棒了,谢谢! TBH 乍一看,这篇论文似乎有点不合我意。我只是触及了 Type Families 的皮毛;所以那篇论文中的类型级编程对我来说有点触手可及。也就是说,我一定会观看视频并尝试阅读论文!更简单的解决方案也值得赞赏:)
  • 切向建议:考虑在您的 recursion-schemes 代码中使用LambdaCase。 YMMV,但我发现它使(共)代数的定义更容易理解。
  • @ChrisPenner 明白了!总结:您的第一个Tree(其Monad 将树移植在一起)是正确的,但它不起作用,因为保持组成框的大小排列是很棘手的。 (如果您使用fmapas 替换为bs,您最好确保bs 不会比as 占用更多的2D 空间!)Lindley 和McBride 的想法是在类型级别将框组合在一起并公开索引的 monad 接口,该接口禁止用户弯曲时空。 FWIW 我认为 Hasochism 论文是对一般花式类型的出色介绍,而不仅仅是这个问题

标签: haskell monads comonad


【解决方案1】:

我放弃了将其塞进评论的尝试。 Conor McBride 有一个完整的 talk 和 Sam Lindley 的一大块 paper,都是关于使用 monads 来划分 2D 空间。既然您要求一个优雅的解决方案,我觉得有必要给您一个他们工作的总结,尽管我不一定建议将其构建到您的代码库中 - 我怀疑使用像 boxes 这样的库和手动操作可能更简单- 使用手动错误处理来启动剪切和调整大小的逻辑。


您的第一个Tree 是朝着正确方向迈出的一步。我们可以编写一个Monad 实例来将树嫁接在一起:

instance Monad Tree where
    return = Leaf
    Leaf x >>= f = f x
    Branch d l r >>= f = Branch d (l >>= f) (r >>= f)

Tree's join 取一棵树,叶子上长着树,让你一直走到底部,而不用停下来喘口气。将Tree 视为free monad 可能会有所帮助,正如@danidiaz 在an answer 中所展示的那样。或者Kmett might say,您有一个非常简单的语法允许术语替换,其Var 称为Leaf

无论如何,关键是您可以使用>>= 通过逐步砍伐树木的叶子来种植树木。在这里,我有一个一维 UI(让我们暂时忘记 Direction),其中包含一个包含 String 的窗口,通过反复将其切成两半,我最终得到了八个较小的窗口。

halve :: [a] -> Tree [a]
halve xs = let (l, r) = splitAt (length xs `div` 2) xs
         in Node (Leaf l) (Leaf r)

ghci> let myT = Leaf "completeshambles"
-- |completeshambles|
ghci> myT >>= halve
Node (Leaf "complete") (Leaf "shambles")
-- |complete|shambles|
ghci> myT >>= halve >>= halve
Node (Node (Leaf "comp") (Leaf "lete")) (Node (Leaf "sham") (Leaf "bles"))
-- |comp|lete|sham|bles|
ghci> myT >>= halve >>= halve >>= halve
Node (Node (Node (Leaf "co") (Leaf "mp")) (Node (Leaf "le") (Leaf "te"))) (Node (Node (Leaf "sh") (Leaf "am")) (Node (Leaf "bl") (Leaf "es")))
-- |co|mp|le|te|sh|am|bl|es|

(在现实生活中,您可能一次只剪切一个窗口,方法是在绑定函数中检查其 ID,如果不是您要查找的窗口,则将其原封不动地返回。)

问题是,Tree 不了解物理空间是有限且宝贵的资源这一事实。 fmap 允许您将as 替换为bs,但如果bs 占用的空间比as 占用的空间多,则生成的结构将不适合屏幕!

ghci> fmap ("in" ++) myT
Leaf "incompleteshambles"

这在二维方面变得更加严重,因为盒子可以相互推动并撕裂。如果中间的窗口被意外调整大小,我要么得到一个畸形的盒子,要么中间有一个洞(取决于它在树上的位置)。

+-+-+-+         +-+-+-+            +-+-+  +-+
| | | |         | | | |            | | |  | |
+-+-+-+         +-+-+-++-+   or,   +-+-+--+-+
| | | |  ---->  | |    | | perhaps | |    | |
+-+-+-+         +-+-+-++-+         +-+-+--+-+
| | | |         | | | |            | | |  | |
+-+-+-+         +-+-+-+            +-+-+  +-+

扩展一个窗口是一件非常合理的事情,但在现实世界中,它扩展的空间必须来自某个地方。你不能在不缩小另一个窗口的情况下增长一个窗口,反之亦然。这不是>>= 可以完成的那种操作,它在单个叶节点上执行局部替换;您需要查看一个窗口的兄弟姐妹,才能知道谁占用了它附近的空间。


因此,您不应该被允许使用>>= 来调整内容的大小。 Lindley 和 McBride 的想法是教类型检查器如何将盒子拼接在一起。使用类型级自然数和加法,

data Nat = Z | S Nat
type family n :+ m where
    Z :+ m = m
    S n :+ m = S (n :+ m)

它们处理按宽度和高度索引的内容。 (在论文中,他们使用表示为向量的向量的 2D 矩阵,但为了提高效率,您可能希望使用具有幻像类型的数组来测量其大小。)

a, Box a :: (Nat, Nat) -> *
-- so Box :: ((Nat, Nat) -> *) -> (Nat, Nat) -> *

使用Hor 将两个盒子并排放置要求它们具有相同的高度,而使用Ver 将它们放在彼此上方则要求它们具有相同的宽度。

data Box a wh where
    Content :: a '(w, h) -> Box a '(w, h)
    Hor :: Box a '(w1, h) -> Box a '(w2, h) -> Box a '(w1 :+ w2, h)
    Ver :: Box a '(w, h1) -> Box a '(w, h2) -> Box a '(w, h1 :+ h2)

现在我们准备构建一个单子来将这些树嫁接在一起。 return 的语义没有改变 - 它自己将一个 2D 对象放在 Box 中。

return :: a wh -> Box a wh
return = Content

现在让我们想想>>=。一般来说,一个盒子由许多大小不一的Content 组成,它们以某种方式组合成一个更大的盒子。下面我有三个大小为 2x1、2x2 和 1x3 的内容组成一个 3x3 的盒子。这个框看起来像Hor (Ver (Content 2x1) (Content 2x2)) Content 1x3

 2x1
+--+-+
|  | |
+--+ |1x3
|  | |
|  | |
+--+-+
 2x2

虽然您,>>= 的调用者知道您的盒子的外部尺寸,但您不知道组成它的各个内容的尺寸。当您使用>>= 剪切内容时,如何期望您保留内容的大小?您必须编写一个函数来保留大小,而 先验知道大小是多少。

所以>>= 需要一个已知大小的Box wh,将其拆开以查找内容,使用保留您提供的内容的(未知)大小的函数对其进行处理*,然后放入它重新组合在一起以产生一个具有相同尺寸wh 的新盒子。请注意 rank-2 类型,这反映了 >>= 的调用者无法控制将调用延续的内容的维度。

(>>=) :: Box a wh -> (forall wh2. a wh2 -> Box b wh2) -> Box b wh
Content x >>= f = f x
Hor l r >>= f = Hor (l >>= f) (r >>= f)
Ver t b >>= f = Ver (t >>= f) (b >>= f)

如果您将类型同义词~> 用于保留索引的函数并翻转参数,您会得到类似于常规Monads 的=<< 的东西,但带有不同类型的箭头。 Kleisli 的作品看起来也很漂亮。

type a ~> b = forall x. a x -> b x

return :: a ~> Box a
(=<<) :: (a ~> Box b) -> (Box a ~> Box b)
(>=>) :: (a ~> Box b) -> (b ~> Box c) -> (a ~> Box c)

这就是索引集上的单子。 (更多内容请参见Kleisli Arrows of Outrageous Fortune。)在the paper 中,他们构建了更多基础架构来支持裁剪和重新排列框,这可能对您构建 UI 很有用。为了提高效率,您可能还决定使用zipper 跟踪当前聚焦的窗口,这是一个有趣的练习。顺便说一句,我认为 Hasochism 是对一般花哨类型的一个很好的介绍,而不仅仅是作为这个特定问题的解决方案。

*假设a的索引确实是对其物理尺寸的准确衡量

【讨论】:

  • 谢谢!我会尽快读完这篇文章,只是想让你知道分配给每个盒子的跟踪空间实际上对我来说并不是超级重要;哪个盒子的结构附加到哪个方向是最重要的部分:)
  • @ChrisPenner 好吧,在这种情况下,原始的 Tree 及其 Monad 实例应该足够了(希望我的答案的前半部分足以很好地解释它是如何工作的) - 但是您建议如何避免意外调整内容大小?
  • 它用于文本编辑器,“框”是缓冲区中的视口,我认为视口在其中一个被拆分或关闭时自行调整大小是合理的。无论如何,我们可以保证我们最多调整 1 个窗口的大小。我们如何使用 monad 来实现 close?
  • 非常感谢您不厌其烦地深入解释这一点!我很高兴明天能深入研究它:)我一直希望能更深入地研究类型编程,这应该有助于我开始!
  • @ChrisPenner IDK 您的确切用例,但我正在考虑fmap 在您的一个叶节点中意外调整a 的大小并推动其他窗口的情况 - 参见图表大约一半的帖子。 close 应该很容易 - 如果您关闭上面示例中的 1x3 框,其他两个框应该展开以取代它。我希望只有一种方法可以对其进行类型检查:在将兄弟姐妹最近的孩子(ren)扩大相同数量之后,用它的兄弟姐妹替换关闭的窗口的父母。现在是凌晨 3 点,所以我很高兴明天再回答任何问题 :)
【解决方案2】:

我会将您的类型表示为 monad 并使用 &gt;&gt;= 来处理 split

{-# LANGUAGE DeriveFunctor #-}
import Control.Monad.Free

data Direction = Hor | Vert

data TreeF a = TreeF Direction a a deriving Functor

type Tree a = Free TreeF a

至于 close,我可能会使用 recursion-schemes 中的 catapara,因为 close 似乎是自下而上的,并且最多需要节点父节点的知识和兄弟姐妹。您也可以转至Control.Lens.Plated

顺便说一句,Free 已经有一个 Recursive 实例。 FreeF TreeF a 将是相应的代数。但是你提到它的效果并不好。

直接使用 FreeFreeT 构造函数可能会很麻烦。也许一些模式同义词可以提供帮助。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2023-03-10
    • 2012-10-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多