【问题标题】:What constitutes codata in the context of programming?在编程的上下文中什么构成了 codata?
【发布时间】:2020-08-26 06:59:02
【问题描述】:

这是一种核心递归算法,因为每次迭代它都会调用比之前更大的数据:

iterate f x =  x : iterate f (f x)

它类似于尾递归累加器样式,但它的累加器是隐式的,而不是作为参数传递。如果不是因为懒惰,它将是无限的。那么 codata 是否只是 WHNF 中的值构造函数的结果,有点像 (a, thunk)?还是 codata 是范畴论中的一个数学术语,在编程领域没有有用的表示?

后续问题:值递归只是核心递归的同义词吗?

【问题讨论】:

  • Codata 是来自理论en.wikipedia.org/wiki/Coinduction#Codata 的术语,专门用于wrt 'Total Functional Programming en.wikipedia.org/wiki/Total_functional_programming。请参阅从该页面链接的 Turner 2004 年的论文。
  • 不是真正的答案,而是我有时觉得很有帮助的直觉:数据是由有限数量的构造函数应用程序生成的那些内存结构,而 codata 是那些内存结构它们被有限数量的模式匹配所消耗。
  • @DanielWagner 这个其实挺有用的。根据应用上下文来区分,即数据是引入还是淘汰。所以 codata 根本与 corecursion 无关。
  • @bob 不一定。可以区分递归函数可以在数据上调用,而协递归函数可以在余数据上调用。 Haskell 不区分递归和核心递归,但某些语言(Idris?)可以。
  • 这里是@DanielWagner 评论的一个更具体的例子,这可能会有所帮助: List 的引入规则是构造函数应用程序,而消除规则是递归(或折叠)。 Stream 的引入规则是核心递归,排除规则是模式匹配。 List 编码数据,而 Stream 编码 codata,但 Haskell 并没有在类型级别进行这种区分。

标签: haskell recursion functional-programming corecursion codata


【解决方案1】:

我认为回答你的问题需要很多解释,所以这里有一个很长的答案,最后是对你问题的具体答案。

Data 和 codata 在范畴论方面有正式的数学定义,所以这只是它们在程序中的使用方式(即,不仅仅是你在cmets)。在 Haskell 中可能看起来是这样,因为语言的特性(特别是非终止和惰性)最终会模糊区分,所以 在 Haskell 中,所有数据也是 codata,反之亦然,但它没有不一定要这样,而且有些语言可以更清楚地区分。

data 和 codata 确实在编程领域都有有用的表示,这些表示产生了与递归和核心递归的自然关系。

如果不快速掌握技术知识,很难解释这些正式的定义和表示,但粗略地说,一个数据类型,比如整数列表,是一个类型 L 和一个构造函数:

makeL :: Either () (Int, L) -> L

这在某种程度上是“普遍的”,因为它可以完全代表任何这样的结构。 (在这里,您想将 LHS 类型 Either () (Int, L) 解释为表示列表 L 是空列表 Left () 或由头部元素 h :: Int 和尾部列表 t :: L 组成的一对 Right (h, t) .)

举个反例,L = Bool 不是我们正在寻找的数据类型,因为即使你可以这样写:

foo :: Either () (Int, Bool) -> Bool
foo (Left ()) = False
foo (Right (h, t)) = True

要“构造”Bool,这不能完全代表任何这样的构造。例如,这两种结构:

foo (Right (1, foo (Left ()))) = True
foo (Right (2, foo (Left ()))) = True

给出相同的Bool 值,即使它们使用不同的整数,所以这个Bool 值不足以完全表示构造。

相比之下,[Int] 类型 是一种合适的数据类型,因为(几乎是微不足道的)构造函数:

makeL :: Either () (Int, [Int]) -> [Int]
makeL (Left ()) = []
makeL (Right (h, t)) = h : t

完全代表任何可能的构造,为每个构造创造独特的价值。因此,它在某种程度上是类型签名 Either () (Int, L) -> L 的“自然”构造。

类似地,整数列表的 codata 类型 将是一个类型 L 以及一个析构函数:

eatL :: L -> Either () (Int, L)

这在某种意义上是“普遍的”,因为它可以代表任何可能的破坏。

再次,从一个反例开始,一对(Int, Int) 不是我们正在寻找的余数据类型。例如,使用析构函数:

eatL :: (Int, Int) -> Either () (Int, (Int, Int))
eatL (a, b) = Right (a, (b, a))

我们可以代表破坏:

let p0 = (1, 2)
    Right (1, p1) = eatL p0
    Right (2, p2) = eatL p1
    Right (1, p3) = eatL p2
    Right (2, p4) = eatL p3
...continue indefinitely or stop whenever you want...

但我们不能代表破坏:

let p0 = (?, ?)
    Right (1, p1) = eatL p0
    Right (2, p2) = eatL p1
    Right (3, p3) = eatL p2
    Left () = eatL p3

另一方面,在 Haskell 中,列表类型 [Int] 是整数列表的合适余数据类型,因为析构函数:

eatL :: [Int] -> Either () (Int, [Int])
eatL (x:xs) = Right (x, xs)
eatL [] = Left ()

可以表示任何可能的破坏(包括有限或无限破坏,这要归功于 Haskell 的惰性列表)。

(作为证据表明这并不全是挥手,如果你想把它与形式数学联系起来,在技术范畴理论术语中,上面相当于说类似列表的 endofunctor:

F(A) = 1 + Int*A   -- RHS equivalent to "Either () (Int,A)"

产生一个类别,其对象是构造函数(AKA F 代数)1 + Int*A -> A。与 F 关联的 data 类型是该类别中的初始 F 代数。 F 还产生了另一个类别,其对象是析构函数(AKA F-coalgebras)A -> 1 + Int*A。与 F 相关的 codata 类型是该类别中的最终 F-余代数。)

直观地说,正如@DanielWagner 所建议的那样,数据类型是表示类列表对象的任何构造的一种方式,而余数据类型是表示类列表对象的任何破坏的一种方式。在 data 和 codata 不同的语言中,有一个基本的不对称性——终止程序只能构造一个有限列表,但它可以破坏(第一部分)一个无限列表,因此 data 必须是有限的,但 codata 可以是有限的或无限。

这会导致另一个复杂情况。在 Haskell 中,我们可以使用makeL 来构造一个无限列表,如下所示:

myInfiniteList = let t = makeL (Right (1, t)) in t

请注意,如果 Haskell 不允许对非终止程序进行惰性求值,则这是不可能的。因为我们可以做到这一点,根据“数据”的正式定义,Haskell 整数列表数据类型还必须包含无限列表!也就是说,Haskell“数据”可以是无限的。

这可能与您可能在其他地方读到的内容相冲突(甚至与@DanielWagner 提供的直觉相冲突),其中“数据”仅用于指代有限的数据结构。好吧,因为 Haskell 有点奇怪,并且因为在数据和余数据不同的其他语言中不允许无限数据,所以当人们谈论“数据”和“余数据”(即使在 Haskell 中)并且有兴趣区分时,他们可能只使用“数据”来指代有限结构。

递归和核心递归适用于此的方式是,普遍性属性自然地给了我们“递归”来消费数据和“核心递归”来产生余数据。如果L 是具有构造函数的整数列表数据类型:

makeL :: Either () (Int, L) -> L

那么使用列表L 生成Result 的一种方法是定义一个(非递归)函数:

makeResult :: Either () (Int, Result) -> Result

这里,makeResult (Left ()) 给出了空列表的预期结果,而makeResult (Right (h, t_result)) 给出了头元素为 h :: Int 且尾部将给出结果 t_result :: Result 的列表的预期结果。

通过普遍性(即,makeL 是初始 F 代数的事实),存在一个独特的函数 process :: L -> Result,它“实现”makeResult。在实践中,它会递归实现:

process :: [Int] -> Result
process [] = makeResult (Left ())
process (h:t) = makeResult (Right (h, process t))

相反,如果L 是具有析构函数的整数列表余数据类型:

eatL :: L -> Either () (Int, L)

那么从Seed 生成列表L 的一种方法是定义一个(非递归)函数:

unfoldSeed :: Seed -> Either () (Int, Seed)

这里,unfoldSeed 应该为每个所需的整数生成 Right (x, nextSeed),并生成 Left () 以终止列表。

通过普遍性(即,eatL 是最终的 F-coalebra 的事实),存在一个独特的函数 generate :: Seed -> L,它“实现”unfoldSeed。在实践中,它将以核心递归方式实现:

generate :: Seed -> [Int]
generate s = case unfoldSeed s of
  Left () -> []
  Right (x, s') -> x : generate s'

综上所述,以下是您最初问题的答案:

  • 从技术上讲,iterate f 是核心递归的,因为它是独特的生成代码数据的函数 Int -> [Int],它实现了:

    unfoldSeed :: Seed -> Either () (Int, Seed)
    unfoldSeed x = Right (x, f x)
    

    通过上面定义的generate

  • 在 Haskell 中,产生 [a] 类型的 codata 的 corecursion 依赖于惰性。然而,严格的尾数据表示是可能的。例如,以下 codata 表示在 Strict Haskell 中运行良好,可以安全地进行全面评估。

    data CoList = End | CoList Int (() -> CoList)
    

    以下 corecursive 函数产生一个 CoList 值(我将其设为有限只是为了好玩——它也很容易产生无限余数据值):

    countDown :: Int -> CoList
    countDown n | n > 0 = CoList n (\() -> countDown (n-1))
                | otherwise = End
    
  • 所以,不,codata 不只是 WHNF 中具有 (a, thunk) 或类似形式的值的结果,并且 corecursion 不是值递归的同义词。但是,WHNF 和 thunk 提供了一种可能的实现,并且是“标准”Haskell 列表数据类型也是余数据类型的实现级别的原因。

【讨论】:

  • 这样的知识是无价的。感谢您的分享和抽出时间。我希望其他人也能培养一种直觉。
  • 我终于有时间重新阅读您的答案。我试图用一种没有这种分隔的语言在数据/余数据(和递归/核心递归)之间构建一个分隔。由于我对 CT 的数学理解还不够,所以我迷路了。非常感谢。
猜你喜欢
  • 2011-03-19
  • 1970-01-01
  • 2014-09-22
  • 1970-01-01
  • 1970-01-01
  • 2013-04-07
  • 2011-04-27
  • 1970-01-01
  • 2022-01-08
相关资源
最近更新 更多