EZYang-博客翻译-一-
EZYang 博客翻译(一)
ω:I’m lubbin’ it:ezyang's blog
来源:
blog.ezyang.com/2010/12/omega-i-m-lubbin-it/
新来这个系列?从开始吧!
今天我们将更详细地了解一种有些不寻常的数据类型,Omega。在此过程中,我们将讨论lub库的工作原理以及如何使用它。这对于懒惰的程序员很有实际意义,因为在 Conal 的话中,lub 是模块化懒惰的一个好方法。
Omega 很像自然数,但是没有显式的Z
(零)构造器,而是使用 bottom 代替。毫不奇怪,这使得理论更容易,但实践更困难(但多亏了 Conal 的 lub 库,并没有太多困难)。我们将展示如何在这种数据类型上实现加法、乘法和阶乘,还将展示如何证明减法和等式(甚至对垂直布尔值)是不可计算的。
这是一个文学化的 Haskell 文章。因为我们要实现的类型类的并非所有方法都是可计算的,我们关闭了丢失方法警告:
> {-# OPTIONS -fno-warn-missing-methods #-}
一些初步工作:
> module Omega where
>
> import Data.Lub (HasLub(lub), flatLub)
> import Data.Glb (HasGlb(glb), flatGlb)
这里再次是 Omega 的定义,以及它的两个显著元素,zero 和 omega(无穷)。Zero 是 bottom;我们也可以写作undefined
或fix id
。Omega 是 Omega 的最小上界,是一个无穷的 W 的堆栈。
> data Omega = W Omega deriving (Show)
>
> zero, w :: Omega
> zero = zero
> w = W w -- the first ordinal, aka Infinity
这里有两个w
的备选定义:
w = fix W
w = lubs (iterate W zero)
第一个备选定义使用显式的固定点递归,正如我们在图表中看到的那样。第二个备选定义直接计算ω作为链的最小上界[⊥, W ⊥, W (W ⊥) ...] = iterate W ⊥
。
Data.Lub
中的 lub 运算符做什么?到目前为止,我们只看到 lub 运算符用于定义链的最小上界:我们能有用地谈论两个值的 lub 吗?是的:最小上界简单地是两个值都“在顶部”的值。
如果顶部没有值,则 lub 是未定义的,lub 运算符可能会给出虚假的结果。
如果一个值比另一个值更严格定义,它可能只是 lub 的结果。
一种直观的理解 lub 运算符的方式是它结合了两个表达式的信息内容。因此,(1, ⊥)
知道对偶的第一个元素,(⊥, 2)
知道第二个元素,所以 lub 将这些信息结合起来得到(1, 2)
。
我们如何计算最小上界?要意识到的一件事是,在 Omega 的情况下,最小上界实际上是两个数字的最大值,因为该域是完全有序的。
> instance Ord Omega where
> max = lub
> min = glb
相应地,两个数字的最小值是最大下界:一个比两个值的信息内容少的值。
如果我们考虑实现case lub x y of W a -> case a of W _ -> True
的对话,它可能会像这样进行:
Me
Lub,请给我你的值。
Lub
稍等片刻。X 和 Y,请给我你们的值。
X
我的值是 W 和另一个值。
Lub
好的 Edward,我的值是 W 和另一个值。
Me
谢谢!Lub,你的下一个值是什么?
Lub
稍等片刻。X,请给我你的下一个值。
Y
(过了一会儿。)我的值是 W 和另一个值。
Lub
好的。Y,请给我你的下一个值。
Y
我的下一个值是 W 和另一个值。
Lub
好的 Edward,我的值是 W 和另一个值。
Me
谢谢!
X
我的值是 W 和另一个值。
Lub
好的。
这是这次对话的时间线:
这个对话有几个有趣的特点。第一个是 lub 本身是惰性的:它将开始返回答案而不知道完整的答案。第二个是 X 和 Y“竞赛”返回特定的 W,而 lub 不会对第二个返回结果进行操作。然而,顺序并不重要,因为最终结果总是相同的(当最小上界未定义时,情况将不相同!)
驱动lub
的unamb
库为我们处理了所有这些混乱的并发业务,通过flatLub
操作符将其公开,用于计算平面数据类型的最小上界。我们需要为非平面数据类型稍加帮助来计算它(尽管人们不禁想知道这是否可以自动推导出来)。
> instance Enum Omega where
> succ = W
> pred (W x) = x -- pred 0 = 0
> toEnum n = iterate W zero !! n
>
> instance HasLub Omega where
> lub x y = flatLub x y `seq` W (pred x `lub` pred y)
等价的、更冗长但更明显正确的定义是:
isZero (W _) = False -- returns ⊥ if zero (why can’t it return True?)
lub x y = (isZero x `lub` isZero y) `seq` W (pred x `lub` pred y)
将此定义与自然数的普通最大值进行比较可能也很有用:
data Nat = Z | S Nat
predNat (S x) = x
predNat Z = Z
maxNat Z Z = Z
maxNat x y = S (maxNat (predNat x) (predNat y))
我们可以将lub
的定义分为两部分:零-零情况和否则情况。在maxNat
中,我们对两个参数进行模式匹配,然后返回 Z。我们不能直接对底部进行模式匹配,但如果承诺在模式匹配成功时返回底部(这里就是这种情况),我们可以使用seq
来进行模式匹配。我们使用flatLub
和lub
来进行多个模式匹配:如果任一值不是底部,则 lub 的结果为非底部,并继续执行seq
的右侧。
在备选定义中,我们将Omega
展平为Bool
,然后在其上使用先前定义的lub
实例(我们也可以使用flatLub
,因为Bool
是一个平坦的域)。为什么我们可以在Omega
上使用flatLub
,而Omega
并不是一个平坦的域?原因有两个:第一个是seq
只关心其第一个参数是否为底或非底:它隐式地将所有域展平为“底或非底”。第二个原因是flatLub = unamb
,虽然unamb
要求其两边的值相等(以便它可以在两者之间做一个无歧义的选择),但是对于Omega
来说,无法证明其不等性:Omega
的等号和不等号都是不可计算的。
> instance Eq Omega where -- needed for Num
glb
实例相当简单,我们将不再深入讨论。建议读者为此实例绘制对话图。
> instance HasGlb Omega where
> glb (W x') (W y') = W (x' `glb` y')
现在是思考的好时机,为什么加法、乘法和阶乘在 Omega 上是可计算的,而减法和等式则不是。如果你选择使用游戏语义的方法,你可能会相当自信地认为对于后者的任何情况都没有合理的对话可以完成工作。我们来做一些更有说服力的事情:画一些图片。我们将拆分二元运算符以帮助绘制图表。
这里是加法的图示:
Omega 的成对形成一个矩阵(通常,向上和向右在偏序上更高),而蓝线将输入集分隔为它们的输出。乘法类似,虽然稍微不那么漂亮(有更多的切片)。
我们可以看到这个函数是单调的:一旦我们跟随偏序进入下一个“步骤”,通过蓝线,我们就再也不能回去了。
现在考虑减法:
在这里,函数不是单调的:如果我在偏序上向右移动并进入下一个步骤,我可以通过向上移动“后退”(红线)。因此,它必须是不可计算的。
这是等式的图片。我们立即注意到,将(⊥,⊥)映射到 True 将意味着每个值都必须映射到 True,所以我们不能使用普通的布尔值。但是,我们也不能使用垂直布尔值(其中⊥表示 False,()表示 True):
再次可以清楚地看到这个函数不是单调的。
现在是实际实现加法和乘法的时候了:
> instance Num Omega where
> x + y = y `lub` add x y
> where add (W x') y = succ (x' + y)
> (W x') * y = (x' * y) + y
> fromInteger n = toEnum (fromIntegral n)
这些函数看起来与定义在 Peano 自然数上的加法和乘法非常相似:
natPlus Z y = y
natPlus (S x') y = S (natPlus x' y)
natMul Z y = Z
natMul (S x') y = natPlus y (natMul x' y)
这里是以前对第一个零进行模式匹配的模式。但是natPlus
有点令人烦恼:我们模式匹配到零,但返回y
:我们的seq
技巧在这里不起作用!然而,我们可以观察到,如果第一个参数是底部,add
将会是底部,因此如果 x 为零,返回值将为 y。如果 x 不是零怎么办?我们知道add x y
必须大于或等于y
,所以这也符合预期。
对于乘法,我们不需要这种技巧,因为零乘以任何数都是零,模式匹配将自动执行此操作。
最后,壮举——阶乘:
factorial n = W zero `lub` (n * factorial (pred n))
我们使用了与加法相同的技巧,注意到 0! = 1。对于阶乘 1,lub 的两边实际上是相等的,而对于任何更大的值,右侧占优势。
总结一下将模式匹配对零的规则转换为 lub(假设函数是可计算的):
f ⊥ = ⊥
f (C x') = ...
变成:
f (C x') = ...
(正如你可能已经注意到的,这只是通常的严格计算)。更有趣的情况:
g ⊥ = c
g (C x') = ...
变成:
g x = c `lub` g' x
where g' (C x') = ...
假设原始函数g
是可计算的(特别是单调的)。当 x 为⊥时,情况显而易见;而由于⊥处于偏序的底部,对于任何 x 不为底部的情况,g x 的任何可能值必须大于或等于底部,从而满足第二种情况。
一个轻松的片段。 量子 bogosort 是一种排序算法,涉及创建列表所有可能的排列的宇宙,然后摧毁所有列表未排序的宇宙。
事实证明,使用lub
时,很容易在你的算法中意外地实现量子 bogosort 的等效物。我将使用我的加法算法的早期版本来演示:
x + y = add x y `lub` add y x
where add (W x') y = succ (x' + y)
或者,(+) = parCommute add
,其中:
parCommute f x y = f x y `lub` f y x
这个定义可以得到正确答案,但需要指数级的线程才能弄清楚。以下是正在发生的情况的示意图:
关键在于我们在每次递归时重复交换加法的参数,并且非确定性路径之一导致了 x 和 y 都为零的结果。树中的任何其他“早期”终止的分支都会小于真实结果,因此lub
不会选择它。正如你可能猜到的那样,探索所有这些分支是低效的。
下一次,我们将探讨 Scott 归纳作为一种关于像这样的不动点的推理方法,将其与自然数的归纳和广义归纳联系起来。如果我在下一篇文章中设法理解共归纳,可能也会有一点内容。
(同伦)类型论:第一章:ezyang’s 博客
来源:
blog.ezyang.com/2013/06/homotopy-type-theory-chapter-one/
现在看来已经是老生常谈的事情了,智库的人们发布了《同伦类型论:数学的同构基础》。有一些(元)评论(丹·皮波尼,鲍勃·哈珀,安德烈·鲍尔,弗朗索瓦·G·多雷,史蒂夫·奥威迪,卡尔洛·安朱利,迈克·舒尔曼,约翰·贝兹),尽管在互联网上进行数学教科书的阅读需要时间,所以不要指望非作者能够提供详细的技术评论一段时间。
当然,作为一个渺小的研究生,我当然对书中“再次马丁·洛夫直觉性类型论介绍”贡献最感兴趣,例如第一章。经典的介绍当然是马丁·洛夫写的论文(请注意:这篇论文有许多版本,所以很难找到合适的版本,尽管看起来乔瓦尼·桑宾的笔记最容易找到),但是为了“同伦类型论”的类型论介绍必须做出某些调整,这导致了一些新颖的呈现。特别是,章节讨论的“同一性类型”比我在其他地方看到的要详细得多(这并不奇怪,因为同一性对同伦类型论至关重要)。在讨论构成该理论的类型时,也有相当多的学术细节/结构,让人想起了《PFPL》(尽管我相信这一特定章节大部分是由其他人撰写的)。当然,在理论如何实际组合和详细解释方面也有许多小的变化,在章节注释中也进行了详细阐述。
更详细地说:
定义性和命题性相等性。 章节花了一点时间仔细区分了定义性相等性(纯语法概念,通过计算)和命题性相等性(涉及证据),这一点我很赞赏。当我最初学习逻辑时,内部和外部推理系统中出现的连接词的差异是我困惑的主要点。
引入新类型的一般模式。 引入逻辑连接词的现代风格是将规则分类为各种类型,例如引入规则和消除规则,然后在展示中坚持这种规律性。通常,读者被期望“看到它”,但这本书通过一个有用的备注阐述了这种风格。我发现一个有用的练习是将规则重新组织,例如将所有的消除规则放在一起并进行比较。
递归和归纳。 我之前写过关于这个主题的文章,主张递归和归纳并不是同一回事,因为归纳需要针对索引类型进行操作。这是正确的,但我没有提到一个重要的观点:归纳是广义的递归。这是因为当你将类型族 P 指定为常数类型族,它忽略其索引时,依赖关系被抹去,你得到了一个普通的递归器。事实上,这是一个CPDT 练习;我认为在 Coq 和非正式数学中看到这一点可以澄清事情的广义维度。
恒同类型。 我不会撒谎:我在这一部分遇到了一些困难,即使在这一部分的最后有一个非常长的注释,我仍然不完全理解路径归纳为什么起作用。(此外,尽管注释指向一些关于该主题的文献,我看了这些论文,但没有看到任何类似于他们对路径归纳的呈现。)默认情况下,Coq 认为等同类型的归纳原则应该是这本书所说的相同者的不可辨认性:
> Check eq_rect.
eq_rect
: forall (A : Type) (x : A) (P : A -> Type),
P x -> forall y : A, x = y -> P y
(顺带一提,家族 C 的使用有些混乱;当讨论前面原则的泛化时,读者需要想象 C(x) -> C(y) === C(x, y)
——这些 C 显然是不同的。)路径归纳要求更多:
eq_ind
: forall (A : Type), forall (C : forall (x y : A), x = y -> Type),
(forall (x : A), C x x (eq_refl x)) -> forall (x y : A), forall (p : x = y), C x y p
这也许并不太令人惊讶,因为这些机制主要受同伦类型理论的驱动。此外,归纳原则遵循与为其他类型定义的其他归纳原则相同的模式。问题在于对为什么这个归纳原则有效的沮丧讨论,即使在 HoTT 设置中,你可能期望,并非所有的相等性都是通过自反性证明的。我对此的理解是与forall (x : A)
量化器的放置有关。允许将其中一个 x 移动到顶层(基于路径归纳),但不能两者都移动。(这在变量名称的重用中有些模糊。)还有一种几何直觉,即当路径的两个端点都是自由的(内部量化)时,我可以将路径收缩为空。但我很难将这一点映射到任何严格的论证上。也许你能帮助我搞清楚。
作为旁注,我对从函数式编程背景学习类型理论有一些一般性的评论。我注意到,即使不了解太多类型理论,使用 Coq 也不是太难,而且更容易错过类型理论可能有帮助的点。但最终,理解发生的事情真的很有用,因此研究为什么依赖积和和广义的依赖和一般化方式的原因是非常值得的。看起来人们对 pi 和 sigma 符号感到困惑:如果意识到它们是代数双关语,会有所帮助。不要跳过归纳原则的定义。
如果本帖有任何不准确或误导性的倾向,我深感抱歉。我总体上的印象是,这一章对类型理论是一个非常清晰的介绍,但是关于同一类型的部分可能有点难以理解。现在,继续看第二章吧!
“这真的是结束了。”:ezyang 的博客
完成。 这个形容词很少用来描述任何种类的软件项目——总会有更多的错误需要修复,更多的功能需要添加。但在法国的一个早上,9 月 20 日,乔治·戈西埃确实做到了:他完成了一个为期六年的项目的最后一个组成部分,带着简洁的提交消息,“这真的是结束了。”
它已经完成:费特-汤普森定理的形式化。
如果你没有跟上互动定理证明或数学形式化的发展,这一成就可能会让你有些摸不着头脑。费特-汤普森定理是什么?它被形式化意味着什么?这项工作的意义何在?不像 2005 年戈西埃和他的团队先前攻克的四色定理,费特-汤普森定理(也称为奇次阶定理),对非数学背景的人来说不容易理解,特别是没有群论背景的人(我不打算解释它)。也没有许多工作中的数学家的生活会因为这个特定定理的形式化而受到实质影响。但这其中确实有一些意义;这一点可以在计算机辅助定理证明背后的广泛动机和费特-汤普森定理周围引人入胜的社会背景之间找到。
虽然由大卫·希尔伯特提出的自动定理证明的原始愿景是完全由计算机代替数学家完成证明定理的工作(数学家将被限制在想出有趣的定理声明来证明),但现代数学形式化研究人员考虑的更为谨慎的问题是一个证明是否真实。也就是说,人类是有过失的,他们在审查同事的证明时可能会犯错误,而计算机是一个白痴-天才,一旦被喂养了一个证明的内容,它在判断正确性方面基本上是不可辜负的。大学数学课程的证明实际上并不需要这种处理(毕竟,它们在数个世纪以来一直在影响着无数的数学专业人员),但确实有一些领域,这种额外的信心增强是非常受欢迎的:
-
在传统软件系统中发现的定理中,由于大量的细枝末节使得证明表面而大,
-
在极其棘手且一贯不直观的数学领域,包括可证性和合理性问题,
-
在极其冗长和复杂的证明中,传统的人类数学家验证是一项艰巨的任务。
正是在这最后的领域,我们可以真正地将四色定理和 Feit-Thompson 定理放置在一起。四色定理的原始证明因其依赖计算机解决了 1936 个特例而备受争议。Feit-Thompson 定理对许多数学家来说有些讨厌,因为其长度为 255 页,其全局结构已经抵抗了半个多世纪的简化尝试。它本身仅仅是通向有限简单群分类定理的一座小山(包括数以万页计的证明)。
正式化整个 Feit-Thompson 定理是一个技术成就,它将计算机辅助定理证明应用于一个极为非平凡的数学证明,并在能够陈述“Feit-Thompson 真实”具有非零信息内容的情况下进行。这表明 Coq 中的定理证明确实可扩展(至少在足够完成这一规模证明的程度上),并为处理实际数学特征提供了一个大型工具箱,包括使用频繁的符号重载和处理许多不同理论的组合(这些证明都依赖于这些理论)。
当然,还有一个微小的担忧:如果存在漏洞,Gonthier 和他的团队所证明的并不实际上是 Feit-Thompson 定理呢?我曾在 Galois 时听过一个故事,一个研究实习生被委以证明一些定理的任务。起初他遇到了一些困难,但最终完成了第一个定理,并转向下一个,逐渐加快了速度。最终,他的导师审视了这些证明,并意识到他一直在利用证明检查器中的完整性问题来解决这些证明。我想知道这是否是有经验的博士生向新生讲的一个让他们做噩梦的故事。
但我认为这种风险非常低。机械化证明与原始证明非常接近,因此错误的定义或完整性错误可能只会对证明造成局部问题,可以轻松解决。确实,如果证明存在如此严重的问题,以至于必须丢弃并重新做,那将是非常有趣的事情——这将对原始证明也有所启示。这将是令人惊讶的,值得关注的,因为这可能意味着我们所知的 Feit-Thompson 定理的终结。但我不认为我们生活在这样的世界中。
为 Gonthier 及其团队祝贺,让我们开香槟庆祝!
2010 年:一条路线图:ezyang 的博客
2010 年:一条路线图
我在 2008 年做了一个这样的计划,看到我列出的一些目标,在我大二的时候实际上我一点也不在乎,这真的很有趣。(更加技术导向的一个,参见“让 PHP 与 VS2008 编译”... 唉。)它们不完全是决议,因为我足够了解到要真正做成事情,我应该设定时间表。这些是倾向;新年的指导原则。让习惯化的事情。这些是困难的事情。
-
更多关注与我共同生活和关心的人,这样我就能知道他们何时感到痛苦,并知道我能做些什么来帮助他们。
-
更多关注人们的感受。培养移情能力。
-
进行有趣的研究。撰写学术论文。提交至主要期刊。
-
参与无线电爱好活动。获得认证。
-
浪漫一些。
-
继续扩展我的烹饪技能,特别是对于绿色食品。
-
继续学习数学。
-
增强身体灵活性,找出如何把锻炼纳入我的日程安排,而不仅仅是我决定需要锻炼来集中注意力时才做的事情。
-
学习 C++元编程。找一个好的借口在这种语言中进行编程。
-
超越自己的极限,因为你不知道它们在你超过之前是什么。
-
偶尔给老朋友打个电话。
-
讲故事。
8 种在 Haskell 中报告错误的方式再访:ezyang 的博客
来源:
blog.ezyang.com/2011/08/8-ways-to-report-errors-in-haskell-revisited/
2007 年,Eric Kidd 写了一篇相当流行的文章,名为8 ways to report errors in Haskell。然而,自原文发表以来已经过去四年了。这是否会影响原文章的真实性?一些名称已经更改,原来给出的建议可能有些...靠不住。我们将审视原始文章中的每一个建议,并提出一种新的概念来理解 Haskell 的所有错误报告机制。
我建议你将这篇文章与旧文章并列阅读。
1. 使用 error
没有改变。我个人的建议是,只有在涉及程序员错误的情况下才使用error
;也就是说,你有一些不变量只有程序员(而不是最终用户)可能会违反。不要忘记,你可能应该看看能否在类型系统中强制执行这个不变量,而不是在运行时。在与error
相关联的函数名称中包含函数的名称也是良好的风格,这样你可以说“myDiv:除以零”而不仅仅是“除以零”。
另一个重要的事情要注意的是,error e
实际上是throw (ErrorCall e)
的缩写,所以你可以显式地模式匹配这类错误,例如:
import qualified Control.Exception as E
example1 :: Float -> Float -> IO ()
example1 x y =
E.catch (putStrLn (show (myDiv1 x y)))
(\(ErrorCall e) -> putStrLn e)
然而,测试错误消息的字符串相等性是不好的做法,所以如果你确实需要区分特定的error
调用,你可能需要更好的东西。
2. 使用 Maybe a
没有改变。Maybe 是一个方便的、通用的机制,用于在只有一种可能的失败模式和用户可能会想要在纯代码中处理的情况下报告失败。你可以很容易地将返回的 Maybe 转换成一个错误,使用fromMaybe (error "bang") m
。Maybe 不提供错误的指示,所以对于像head
或tail
这样的函数是个好主意,但对于doSomeComplicatedWidgetThing
这样的函数就不是那么好了。
3. 使用 Either String a
在任何情况下,我实际上不能推荐使用这种方法。如果你不需要区分错误,你应该使用Maybe
。如果你不需要在纯代码中处理错误,使用异常。如果你需要在纯代码中区分错误,请不要使用字符串,而是制作一个可枚举类型!
然而,在 base 4.3 或更高版本(GHC 7)中,这个 monad 实例在Control.Monad.Instances
中是免费的;你不再需要做丑陋的Control.Monad.Error
导入。但是改变这一点也有一些成本:请参见下文。
4. 使用 Monad 和 fail 来泛化 1-3
如果你是一个理论家,你会拒绝将fail
作为不应该属于Monad
的憎恶,并拒绝使用它。
如果您比这更加实际,那么情况就更加复杂。我已经提到,在纯代码中捕获字符串异常不是一个特别好的主意,如果您在Maybe
monad 中,fail
会简单地吞噬您精心编写的异常。如果您运行的是 base 4.3,Either
也不会对fail
特别处理:
-- Prior to base-4.3
Prelude Control.Monad.Error> fail "foo" :: Either String a
Loading package mtl-1.1.0.2 ... linking ... done.
Left "foo"
-- After base-4.3
Prelude Control.Monad.Instances> fail "foo" :: Either String a
*** Exception: foo
所以,您有这种不真正做您大部分时间所需的奇怪泛化。它可能会(即使如此,仅仅是勉强)在您有一个自定义错误处理应用 monad 时有点用处,但仅此而已。
值得注意的是,Data.Map
不再使用这种机制。
5. 使用 MonadError 和自定义错误类型
在新的世界秩序中,MonadError
变得更加合理了,如果您正在构建自己的应用 monad,这是一个相当合理的选择,无论是作为堆栈中的转换器还是要实现的实例。
与旧建议相反,您可以在IO
的顶部使用MonadError
:您只需转换IO
monad 并提升所有 IO 操作。尽管如此,我不太确定为什么您会想要这样做,因为 IO 有自己的良好、高效且可扩展的错误抛出和捕获机制(见下文)。
我还要注意,规范化与您正在交互的库中的错误是一件好事:它让您考虑您关心的信息以及您希望向用户展示信息的方式。您总是可以创建一个MyParsecError
构造器,它直接使用 parsec 错误,但是对于真正良好的用户体验,您应该考虑每种情况。
6. 在 IO monad 中使用 throw
现在不再称为throwDyn
和catchDyn
(除非您导入Control.OldException
),只是throw
和catch
。您甚至不需要一个 Typeable 实例;只需要一个微不足道的 Exception 实例。我强烈推荐在 IO 中用这种方法处理未经检查的异常:尽管随着时间的推移这些库的变化,Haskell 和 GHC 的维护者们对这些异常应该如何工作已经进行了深思熟虑,并且它们具有广泛的适用性,从正常的同步异常处理到异步异常处理,这非常巧妙。有很多 bracketing、masking 和其他函数,如果您在传递 Eithers 的话,您根本做不到这些。
确保您在 IO monad 中使用throwIO
而不是throw
,因为前者保证了顺序;后者则不一定。
7. 使用 ioError 和 catch
没有理由使用这个,它存在是因为有些历史原因。
8. 在 monad transformers 中大肆发挥
在所有情况下,这与第 5 种情况是相同的;只是在一个情况中,您自己编写,而在这种情况下,您使用 transformers 组合它。同样的注意事项适用。Eric 在这里给出了一个很好的建议,劝阻您不要在 IO 中使用这个。
这里有一些自原始文章发布以来涌现的新机制。
9. 已检查的异常
Pepe Iborra 写了一个非常巧妙的 checked exceptions library,它允许你明确指出一段代码可能抛出哪些 Control.Exception
风格的异常。我以前从未使用过它,但知道 Haskell 的类型系统可以(被滥用)这样使用是令人满意的。如果你不喜欢很难确定是否捕获了所有你关心的异常,可以去看看它。
10. 失败
Failure typeclass 是一个非常简单的库,试图通过简化包装和解包第三方错误来解决互操作性问题。我用过一点点,但不足以对此事发表权威意见。还值得看一看 Haskellwiki 页面。
结论
你需要考虑两个错误处理领域:纯错误 和 IO 错误。对于 IO 错误,有一个非常明确的选择:Control.Exception
中指定的机制。如果错误明显是由于外部环境的不完善造成的,请使用它。对于纯错误,需要稍微有些品味。如果只有一个失败的情况(也许甚至不是什么大不了的事),应该使用 Maybe
;如果编码了一个不可能的条件,可以使用 error
;在不需要对错误做出反应的小应用程序中,字符串错误可能是可以接受的;而在需要对其做出反应的应用程序中,则可以使用自定义错误类型。对于互操作性问题,你可以很容易地通过自定义错误类型来解决它们,或者尝试使用一些其他人正在构建的框架:也许某一天会达到关键质量。
应该清楚地指出,Haskell 的错误报告有很多选择。但是,我认为这种选择并非没有道理:每种工具都有适合其使用的情况,而在高级语言中工作的一大乐趣就是错误转换并不是那么难。
PyTorch 操作符的简要分类:ezyang’s 博客
来源:
blog.ezyang.com/2020/05/a-brief-taxonomy-of-pytorch-operators-by-shape-behavior/
最近,我一直在重新设计如何在 PyTorch 中指定张量形状公式的方法。作为这个过程的一部分,我按其形状行为对每一个 PyTorch 操作符进行了分类;是的,总共有 1364 个(这包括每个操作符的变体,例如原地操作和out=
关键字的变体)。在这个过程中,我试图提出一些类别来帮助分类操作符的功能。过程中的一个意外是发现我之前认为不常见的形状行为,实际上出现的频率比预期要高一些。
这些类别本身非常有趣,并且可以帮助理解 PyTorch API 的各个部分是如何结合在一起的。以下是我设计的所有类别。
TensorIterator(505,例如 add、sum)操作符是 PyTorch 的核心操作;这些操作符执行逐点操作和减少,并支持 broadcasting 和 type promotion。名称 TensorIterator 指的是 PyTorch 中用于实现这些操作的内部抽象;您可以在 wiki 和 这篇博客文章 上了解更多信息。TensorIterator 在 PyTorch 中是一个真正的工作马:大部分(虽然不是大多数)操作符都是以这种方式实现的!请注意,此类别包括一些使用等效的传统功能的函数(但并非完全使用 TensorIterator)。
Fixed(273,例如卷积、addbmm)操作符是仅适用于固定数量维度的操作符。这一假设使得编写高效的内核变得更加容易,因为固定维度的索引计算非常简单。(例如,TensorAccessor 是一个内部类,允许您在编译时已知的固定维度上查看张量)。有时,第一个维度被视为批处理维度,但并非总是如此(不幸的是,我在数据集中没有区分这些情况)。有些固定操作符实际上支持多个维度,但只支持固定数量的维度;例如,因为我们只支持 1-3 维卷积,这被视为固定。(与下面的 FeatureBatched 进行比较!)
N-Dimensional(107,例如,squeeze,index_add,tensordot)运算符是通用于任意维度张量的运算符。这些是很难以符号形式编写通用形状规则的操作,因为你需要一个能够处理列表操作的语言。N 维运算符的一个重要子类是Identity(42,例如,clone,contiguous;不包括上述计数)运算符,可以在任意维度上工作,但它们始终返回与其输入大小相同的张量。另一个子类是Flatten(11,例如,take,bucketize)运算符,可以接受任意维度的张量,但在内部始终将它们视为 1D 张量。
Composite(95,例如,kl_div,isfinite)运算符是在其他运算符中实现的,它们本身不进行形状检查(而是依赖于它们调用的操作来检查形状)。请注意,这一类别可能有些被低估,因为在某些情况下,当运算符的基本行为显而易见时,我将其分类为该类别,而不是复合类别。
Batched(94,例如,nll_loss,adaptive_avg_pool2d)运算符类似于固定维度运算符,但在其开始处接受任意数量的批处理维度。许多固定运算符应该是批处理运算符;其他则不能转换为批处理运算符,否则会引入关于批处理维度结束位置的歧义。与之相比,FeatureBatched(19,例如,batch_norm,embedding)运算符类似于批处理运算符,但不是在开始处接受批处理维度,而是在结束处接受任意数量的特征维度。
Factory(90,例如,empty)运算符在没有任何张量输入的情况下生成新的张量。
Trivial(59,例如,size,is_floating_point)运算符并非实际的张量操作,而是返回非张量信息或访问内部数据结构的方式。
Sparse(40)运算符很特殊,因为它们的大小计算考虑了密集和稀疏维度。
Dynamic(15,例如,unique)运算符产生的输出形状取决于其输入张量的数据。
Variadic(14,例如,cat)运算符接受多个输入张量;与 n 维操作类似,它们难以以符号形式捕捉。
一个古典逻辑童话故事:ezyang's 博客
一个古典逻辑童话故事
(Selinger) 这是一个童话故事:邪恶的国王召见可怜的牧羊人,并下令他:“你必须给我带来哲学石,或者找到将哲学石变成黄金的方法。如果你做不到,你明天就要被砍头!”可怜的牧羊人该怎么办才能挽救自己的生命?
致谢克里斯,最初告诉我这个故事的不同版本。不幸的是,这段引用来自柯瑞-霍华德同构的讲座,这是我能找到的唯一参考资料。牧羊人应该怎么做?这个故事有点奇怪吗?
一个帮助您编写张量形状检查的编译时调试器:ezyang's blog
来源:
blog.ezyang.com/2018/04/a-compile-time-debugger-that-helps-you-write-tensor-shape-checks/
运行时调试器允许您查看程序中的具体值,对其进行更改,并继续运行程序。一个编译时调试器允许您查看程序中的符号值,对其进行推理,并编写程序的其余部分,例如填写缺失的张量大小检查。
这是编译时调试器实际操作的一个例子。
假设您正在编写一个简单的程序,从两个文件中读取一对张量并对它们进行矩阵乘法。“简单”,您想,然后编写以下程序:
main() {
x = load("tensor1.t")
y = load("tensor2.t")
return matmul(x, y)
}
然而,有一个曲折:这个矩阵乘法是一个未经检查的矩阵乘法。如果传递的张量无法有效地相乘,这是未定义的行为。您的编译器已经意识到了这一点,并拒绝编译您的程序。您启动编译时调试器,它将您放到程序中出现错误的地方:
# Ahoy there Edward! I stopped your program, because I could not
# prove that execution of this line was definitely safe:
main() {
x = load("tensor1.t")
y = load("tensor2.t")
-> return matmul(x, y)
}
# Here's what's in scope:
_x_size : List(Nat) # erases to x.size()
_y_size : List(Nat) # erases to y.size()
x : Tensor(_x_size)
y : Tensor(_y_size)
# I don't know anything else about these values
让我们仔细看一下作用域中的变量。我们的编译时调试器通过写x : t
来告诉我们变量 x 的类型。我们有各种普通类型,如自然数(Nat)和自然数列表(List(Nat))。更有趣的是,张量是由自然数列表参数化的,这些列表指定它们在每个维度上的大小。(为简单起见,假设张量的底层字段是固定的。)
我们的调试器有一个命令行,因此我们可以询问它关于程序中事物类型的问题(:t
用于类型):
> :t 1
# Here's the type of 1, which you requested:
Nat
> :t [1, 2, 0]
# Here's the type of [1, 2, 0], which you requested:
List(Nat)
> :t matmul
# Here's the type of matmul, which you requested:
forall (a, b, c : Nat). (Tensor([a, b]), Tensor([b, c])) -> Tensor([a, c])
矩阵乘法的类型应该是合理的。我们说矩阵乘法接受两个大小为 AxB 和 BxC 的二维张量,并生成大小为 AxC 的张量。如上所述,另一种表达方式是说,“对于任何自然数 A、B 和 C,矩阵乘法将接受大小为 AxB 和 BxC 的张量,并给出大小为 AxC 的张量”。
查看load
的类型也是有教育意义的:
> :t load
# Here's the type of load, which you requested:
String ~> exists (size : List(Nat)). Tensor(size)
我们不知道从文件加载的张量的维度是多少;我们只能说存在一些大小(自然数列表),描述了所讨论的张量。我们的编译时调试器友好地为我们提供了作用域内张量大小的名称 _x_size
和 _y_size
,并告诉我们如何在运行时计算它们(x.size()
和 y.size()
)。
Enough of this. Let's remind ourselves why our program has failed to typecheck:
> matmul(x, y)
# I'm sorry! I was trying to find values of a, b and c which
# would make the following equations true:
#
# [a, b] = _x_size
# [b, c] = _y_size
#
# But I don't know anything about _x_size or _y_size (they are skolem
# variables), so I couldn't do it. Cowardly bailing out!
编译器是完全正确的。我们对 x 或 y 的大小一无所知;它们可能是 2D,也可能是 100D,或者根本不具有匹配的维度。
作为一种附加说明:有时候,不了解大小的任何信息也是可以的。考虑将张量加到自身的情况:
> add
# Here's the type of add, which you requested!
add : forall (size : List(Nat)). Tensor(size) -> Tensor(size) -> Tensor(size)
> add(x, x)
Tensor(_x_size)
# This type-checked OK! I set size = _x_size and all of the arguments
# checked out. You're good to go.
我们对_x_size
一无所知,但add
并不在乎;它会接受任何List(Nat)
,而_x_size
肯定是其中之一。
回到正题。我们将插入动态检查,以完善我们对 x 和 y 的知识,直到显然矩阵乘法将成功。
什么是动态检查?操作上,动态检查测试某个条件是否为真,并在条件不满足时中止。如果我们成功运行动态检查,我们现在对我们作用域中的符号类型有了新的信息。例如,添加一个运行时测试,检查两个数字是否相等后,我们随后可以在编译时假设这些数字是相等的:
> :t assert_eq_nat!
(x : Nat) -> (y : Nat) ~> x = y
首先,我们要确定我们的张量是 2D 张量:
> assert_eq_nat!(len(_x_size), 2)
# OK! I added assert_eq_nat!(len(x.size()), 2) to your program, and
# here's what I know now:
_x_size : List(Nat)
_y_size : List(Nat)
x : Tensor(_x_size)
y : Tensor(_y_size)
len(_x_size) = 2
# By the way, I can profitably destruct _x_size into its constituent
# parts; would you like to do this? (Y/n)
> Y
# OK, your new context is this:
_x0, _x1 : Nat
_y_size : List(Nat)
x : Tensor([_x0, _x1])
y : Tensor(_y_size)
# I don't know anything about the new variables _x0 and _x1, but I
# learned enough about _x_size that I was able to eliminate it from
# the context (_x_size = [_x0, _x1])
列表长度是一个非常有用的属性,用于对比测试,因为它极大地指定了所讨论列表的形状。我们也可以对_y_size
做同样的事情:
> assert_eq_nat!(len(_y_size), 2)
# OK! I added assert_eq_nat!(len(y.size()), 2) to your program, and
# here's what I know now:
_x0, _x1 : Nat # erases to x.size(0), x.size(1)
_y_size : List(Nat)
x : Tensor([_x0, _x1])
y : Tensor(_y_size)
len(_y_size) = 2
# By the way, I can profitably destruct _y_size into its constituent
# parts; would you like to do this? (Y/n)
> Y
# OK, your new context is this:
_x0, _x1 : Nat # erases to x.size(0), x.size(1)
_y0, _y1 : Nat # erases to y.size(0), y.size(1)
x : Tensor([_x0, _x1])
y : Tensor([_y0, _y1])
# I don't know anything about the new variables _y0 and _y1, but I
# learned enough about _y_size that I was able to eliminate it from
# the context (_y_size = [_y0, _y1])
我们现在非常接近了。我们所需要做的就是断言内部维度是相等的:
> assert_eq_nat!(_x1, _y0)
# OK! I added assert_eq_nat!(x.size(1), y.size(0)) to your program.
# After doing this, I learned _x1 = _y0, so I replaced all occurrences
# of _y0 with _x1\. Now the context looks like this.
_x0, _x1 : Nat # erases to x.size(0), x.size(1)
_y1 : Nat # erases to y1.size(1)
x : Tensor([_x0, _x1])
y : Tensor([_x1, _y1])
胜利!
> matmul(x, y)
# This type-checked OK! I set a = _x0, b = _x1, c = _y1 and all of the
# arguments checked out. You're good to go.
将此会话内容提取回我们的代码,我们现在有:
main() {
x = load("tensor1.t")
y = load("tensor2.t")
assert_eq_nat!(x.size(), 2)
assert_eq_nat!(y.size(), 2)
assert_eq_nat!(x.size(1), y.size(0))
matmul(x, y)
}
此时,我必须坦白:我上面描述的编译时调试器实际上并不存在。但它与交互式证明助理的证明模式并没有太大不同,这是自动定理证明社区今天使用的。但与定理证明不同的是,我们有一个秘密武器:在困难时刻,强者会变成运行时检查。传统智慧认为自动定理证明需要过于理想化的设置才能在今天的软件编写中有用。传统智慧错了。
数据库索引的机器学习方法(Alex Beutel):ezyang’s 博客
来源:
blog.ezyang.com/2017/12/a-machine-learning-approach-to-database-indexes-alex-beutel/
以下是对 Alex Beutel 在 machine learning database indexes 上的讲话的转录,于 ML Systems Workshop 在 NIPS'17 上进行。
数据库研究人员以不同的方式思考他们的研究。你有一个需要适用于所有情况的系统。而在机器学习中,我们有一个独特的情况,我会建立一个效果良好的模型。在数据库中,你必须适应所有情况。
举个例子,这是一个 B-树。B-树适用于范围查询。我们有记录,关键字,我们想要找到所有关键字范围内的记录。0-1000,你在排序数组上构建树。为了快速查找范围内的起始点。如果我的所有数据,所有的关键字,从零到百万……变得清晰,你不需要整个树顶部。你可以将关键字本身用作数组中的偏移量。你的查找是 O(1),O(1) 内存,不需要额外的数据结构。
现在,我们不能为每个应用程序都进行定制实现以利用某种模式。数据库可以扩展到任何应用程序,我们不希望每次都重新构建它。
但是在这种情况下,机器学习表现出色。它对各种分布都能很好地工作,学习并有效地利用它们。
这是我们得出的关键见解。传统的数据结构不对数据做任何假设。它们适用于任何分布,并且通常在 O(n) 的规模上扩展。有趣的是,学习这些数据分布可以带来巨大的收益。我们尝试的是,不再按数据大小扩展,而是按其复杂性扩展。对于线性数据,复杂度是 O(1)。对于其他分布,我们能否利用这一点?
数据库的底层有三种数据结构。有 B-树;范围查询,相似性搜索。主索引。哈希映射用于点查找;单个记录。这在计算机科学中更为常见。而布隆过滤器,在集合包含查询中非常常见。我有一个关键。如果你的记录存储在磁盘上,首先检查是否有这个键的记录是值得的。我们将完全专注于 B-树。
B-树采用高分支因子的树形结构。其真正有效之处在于它的高缓存效率。你可以将顶层节点存储在缓存中,快速查找,可能将其他节点存储在主内存中,实际内存在磁盘上。通过适当缓存层次结构,使其高效。在高层次上,B-树将一个关键字映射到一个页,内存中的某个给定位置。一旦找到该页,它将进行一些局部搜索,以找到该关键字的特定范围。这可以是扫描或二分搜索;我们知道范围将是从页面起始到页面大小的位置。
在抽象级别上,B 树只是一个模型。它采用关键点的位置,并尝试估计位置。在这种情况下,我们希望在此误差范围内搜索以找到最终记录。在高层次上,这意味着我们无法使用任何模型。我们需要 err_min 和 err_max。但我们拥有所有数据。如果在索引构建时,您已经知道了要执行的所有数据,并且可以计算出模型的最小和最大误差。
有趣的是,这只是一个回归问题。你真正建模的是 CDF。在这个图的 X 轴上,X 轴是您的键,Y 轴是您的位置。这在哪里建模您的概率质量的位置;您的数据在键空间中的位置。CDF 在文献中有些研究,但不多。这是研究的一个新的有趣的含义。
我们想,好吧,让我们立即尝试一下。训练一个模型,看看它有多快。我们查看了 2 亿个服务器日志,时间戳键,2 层 NN,32 宽度,相对较小的机器学习模型。我们训练以预测位置,平方误差。一个 B 树执行需要 300 纳秒。不幸的是,用模型来执行需要 80000 纳秒。按大多数机器学习模型的速度来看,这很好。如果您打算在服务器上执行,那很好。但这对数据库不适用。
在这里有一堆问题。TF 确实是为大型模型设计的。想想翻译或超分辨率图像;这些任务都很重。我们需要将其速度提高到数据库级别的速度。其次,B 树在过拟合方面表现出色。在这种情况下不存在过拟合的风险。它们还具有高效的缓存效率;这在机器学习中并没有得到重视。最后是最后的局部搜索。这真的是最终找到关键点的最有效方法吗?我跳过了那部分,因为它相当详细,我将专注于前三个问题。
第一部分只是机器学习模型执行的原始速度。这确实是由 Tim 构建的 Learning Index Framework 程序。它可以让您在不同配置下创建不同的索引。首先,它可以为 TF 进行代码编译,借鉴了 Tupleware 的思想,您可以快速执行线性模型。我们还可以训练简单的模型。使用 TF 进行更复杂的基于梯度下降的学习;提取权重,并且推理图被代码生成。我们还可以进行大量的自动调整,以找到最佳的模型架构。我们提前知道什么是最佳的训练。我们可以做出关于哪种方法最有效的相当明智的决策。
下一个问题是准确性和速度。如果我有 100M 记录,我从 1.5M 快速缩小到 24K,每一步都在这棵树上。这些步骤中的每一个都是查找那一页,找到正确分支的 50-60 个周期。因此,我们必须在缓存中达到 12000 的精度,在 500 乘加内,才能超过这些层次的水平。这是一个艰巨的任务。问题是什么是正确的模型?一个非常宽的网络?单隐藏层?这样的规模很好,我们可以合理地放入 256 层。我们可以更深入......挑战是我们有宽度²,需要以某种方式并行化。挑战是,我们如何有效地扩展这个。我们希望向模型增加容量,使其更加精确,增加大小,而不至于变得。
我们采取了一种基于混合专家的不同方法。我们会有一个关键点,有一个非常简单的分类器。我们得到一个估计值。然后我们可以使用那个估计值在下一个阶段找到它。缩小 CDF 范围,并在空间子集中尝试更精确。它仍然会以关键点作为输入;给定关键点,给出位置,但关键点的空间更窄。我们构建这一点,并且我们将沿着这个层次结构向下走。这解耦了模型大小和复杂性。我们有一个庞大的模型,过度拟合,但我们不必执行所有从纯 ML 视图中必须执行的稀疏化。我们可以有用地解耦它。我们可以做的好事是为在模型中难以学习的子集退回到 B 树。LIF 框架让我们能够轻松替代它。在最坏的情况下,是 B 树。最好的情况下,更高效。
这里的快速结果版本是,我们发现我们有四种不同的数据集。大多数是整数数据集;最后一个是字符串数据集。我们试图节省内存和速度;我们极大地节省内存;这些都是非常简单的模型。线性与简单层,可能有两个阶段。在这些情况下,我们能够获得显著的加速。服务器日志是有趣的。从高层看似乎是非常线性的,但实际上这些数据访问有每日模式。地图更线性;是空间的经度。我们创建了对数正态的合成数据,在这里我们看到我们可以有效地建模。字符串是一个有趣的挑战;您的数据更大更复杂,构建在长字符串上高效的模型是不同的;整体模式更难以直觉理解。这里真正值得注意的一点是,它不使用 GPU 或 TPU;这纯粹是 CPU 对比。一比一。
这主要涉及到 B 树部分。这是一个回归模型,看数据的 CDF。我们可以用这些完全相同的模型用于哈希映射。用布隆过滤器,你可以使用二进制分类器。我在后面的海报上有一堆结果。
几分钟来谈谈改进的空间。有很多我们很兴奋要探索的方向。显而易见的一个是 GPU/TPU。它是 CPU,因为在 B 树最有效的时候;但是扩展性全都是关于机器学习的。改进 GPU 模型的吞吐量和延迟,前景充满挑战。建模本身;没有理由相信模型层次结构是正确或最佳选择;构建与硬件匹配的模型结构是很有趣的。内存高效,GPU 的底层架构。在数据库所需的 ns 级别的规模上。多维索引;机器学习在高维数上表现出色;大多数事物不是在单个整数特征上进行观察。如何将难以扩展的多维索引映射到的有趣问题。如果有一个 CDF,你可以在那里近似地对其进行排序。和插入和更新,假设只读数据库。大类系统,但我们获取更多的数据。如何在过拟合与准确性之间取得平衡;我们能够添加一些额外的辅助数据结构来平衡这一点吗?
Q: 有一件事是,当... 这个问题时,我们在没有机器学习的情况下解决得非常好。当我们引入机器学习时,我们应该引入新的度量标准。我们不应该让我们的系统更加脆弱,因为分布会改变。当分布改变时,最坏的情况会是什么?
A: 随着数据的更新... 在推断和更新的情况下,有一个关于泛化的问题。我认为你可以从机器学习的角度来看待它:统计上,今天测试模型,明天的插入数据。 (这是一种方法。如果我使用这种方法,然后用我还没有的数据来训练它... 并且...)典型的推广到未来机器学习的泛化。保证是困难的。会有一个最糟糕的情况是糟糕的... 但另一面,那是机器学习的一面... 泛化。还有一个观点,我将这与经典数据结构相结合。我们将建模与经典数据结构相结合:搜索、布隆过滤器案例,所以你实际上没有这项工作。你抓住了最坏的情况。
让我补充一下。如果你假设插入数据遵循与训练模型相同的分布,那么插入数据就成为一个操作。它们甚至更好。假设它们不遵循相同的分布?你仍然可以做增量索引。大多数系统确实做增量索引。所以插入数据不是一个大问题。
Q: (罗伯特)大部分输入是一个或两个实数,输出是一个单一的实数。如果你在不同的数字上使用低次多项式,或者分段线性分类器,它是如何工作的?
A: 对于字符串来说,它不是单一的输入。(把它当作整数处理?)嗯,它可能长达一千个字符。这不是最好的表示方式。不同的表示方式确实效果很好。我想说的最后一件事是,分段线性可能有效,但当你运行 10k、100k 个子模型时,速度会很慢。层次结构有助于。多项式很有趣,取决于数据来源。
Q: 你能评论一下你们的最坏情况有多糟吗?平均数是多少?
A: 我们总是会有溢出的情况。最坏的情况是默认到典型的数据库。我们还没有遇到更糟的情况,因为我们会默认使用 B 树。(确定性执行?)不是推断时间。
Mendeley 的新视角:ezyang 的博客
Mendeley 的新视角
我使用 Mendeley因为它让我能轻松搜索我关心的论文。不幸的是,这似乎是 Mendeley 对我做的全部贡献......这真是个耻辱。也许这是因为我还是一个大学生,仍在浅尝辄止的学术研究的海洋中摸索。Mendeley 是针对从业研究人员设计的,而不是像我这样,还在追求广度而非深度的人。我真正深入研究过的技术论文数目可以用两只手数出来——我正试图弄清楚我究竟想要专攻什么。
从这个角度来看,Mendeley 实际上可以为我做很多事情,但现在却没有做到。这不仅仅是一种自私的观点:学术界是一个相对小众的领域,我希望我的偏好能够吸引更多潜在的用户和客户。我的请求/恳求/愿景可以总结如下:Mendeley 需要更好的元数据。
Conclusion
在许多方面,这就像是为学术论文引入“语义网”。总体上来说,我们仍在努力实现互联网的这一愿景。但有充分的理由相信,学术论文的世界可能是不同的。首先,学术论文的网络甚至还没有达到传统互联网的水平:它没有超链接!此外,学术论文的流动速度比传统网络内容慢得多,也更为持久。我希望我的行业朋友们没有理由抱怨他们更希望研究人员发表博客文章而不是论文。我知道这很雄心勃勃。但这就是我对 Mendeley 的愿景。
增加共享的模式:ezyang 的博客
来源:
blog.ezyang.com/2011/06/a-pattern-for-increasing-sharing/
我最近在编写一些 Haskell 代码时遇到了以下模式,并惊讶地发现标准库中实际上没有为其提供支持。我不知道它叫什么(当我向 Simon Peyton-Jones 提到它时,他也不知道),所以如果有人知道,请告诉我。这个模式是这样的:很多时候,一个自同态映射(map
函数是a -> a
)对底层数据结构不会做出太多改变。如果我们直接实现map
,我们将不得不重建递归数据结构的整个脊柱。然而,如果我们使用a -> Maybe a
的函数,如果没有更改,我们可以重用旧的映射部分。(我博客的常读者可能会从this post.中认出这种情况。)那么这样的替代map
函数(a -> Maybe a) -> f a -> Maybe (f a)
叫什么?
有一个猜测它可能是Data.Traversable
中的traverse
函数:它的类型签名确实非常相似:Applicative f => (a -> f b) -> t a -> f (t b)
。然而,语义上有微妙的不同,你可以从这个例子中看出来:
Data.Traversable> traverse (\x -> if x == 2 then Just 3 else Nothing) [1,2,3]
Nothing
请记住,我们的函数只在没有变化时返回Nothing
。因此,我们应该得到结果Just [1,3,3]
:列表的第一和第三个元素不变,而列表的第二个元素有新值。
我们如何为列表实现这样的函数?这里是一个简单的实现:
nonSharingMap :: (a -> Maybe a) -> [a] -> Maybe [a]
nonSharingMap f xs = let (b, r) = foldr g (False, []) (zip xs (map f xs))
in if b then Just r else Nothing
where g (y, Nothing) (b, ys) = (b, y:ys)
g (_, Just y) (_, ys) = (True, y:ys)
但是我们可以做得更好。考虑一种情况,列表中除头部外所有元素保持不变:
我们希望在旧版本和新版本之间共享列表的尾部。稍加思索后,意识到tails
可以实现共享,我们可以写出这个版本:
sharingMap :: (a -> Maybe a) -> [a] -> Maybe [a]
sharingMap f xs = let (b, r) = foldr g (False, []) (zip3 (tails xs) xs (map f xs))
in if b then Just r else Nothing
where g (_, y, Nothing) (True, ys) = (True, y:ys)
g (_, _, Just y) (True, ys) = (True, y:ys)
g (ys', _, Nothing) (False, _) = (False, ys')
g (_, _, Just y) (False, ys) = (True, y:ys)
未解决的问题:这种模式叫什么?为什么它不遵循通常的应用结构?它是否满足某种高阶模式?此外,这种方案并非完全组合:如果我传递给你一个Nothing
,你就无法访问原始版本,以防数据结构的其他地方发生变化:(Bool, a)
可能更具有组合性。这是否意味着这是状态单子的一个示例?分享又如何呢?
更新. Anders Kaseorg 提供了一个更直接递归版本的函数:
sharingMap f [] = Nothing
sharingMap f (x : xs) = case (f x, sharingMap f xs) of
(Nothing, Nothing) -> Nothing
(y, ys) -> Just (fromMaybe x y : fromMaybe xs ys)
我还没有检查过,但是用foldr
和zip3
来表达这个函数的一个希望是能够进行融合。当然,对于实际的递归脊柱严格的数据类型,通常无法融合,因此更直接的展示方式更为正常。
一个激进的 Hackage 社会实验:ezyang 的博客
来源:
blog.ezyang.com/2010/08/the-radical-hackage-social-experiment/
序言。这篇文章试图明确一些关于即将到来的 Hackage 2.0 的想法,这些想法在 Galois 的午餐桌周围已经讨论过。请注意,我从未见证过一门语言进入主流,所以请对我说的话持保留态度。论点是,如果 Hackage 结合了大教堂(Python)、集市(Perl/CPAN)和社会协作的轮子(Wikipedia、StackOverflow、Github),它可以彻底改变在 Haskell 中编程的含义。
新的编程语言层出不穷:只需漫步在OSCON Emerging Languages track,就能看到原因所在。作为程序员,我们的自然好奇心集中在语言本身:“它解决了什么问题?它是什么样子?”作为工程师,我们可能会问:“它的运行时系统是什么?”作为计算机科学家,我们可能会问:“这门语言融入了哪些新颖的研究?”当一门语言解决了我们能够理解的问题或展示了时髦的新技术时,我们的兴趣被激发了,我们会更仔细地看待它。
但随着语言的发展和知名度的提升,随着它从“新兴”阶段进入“新生”阶段,在某些时候,语言本身变得不再重要。取而代之的是围绕语言的社区:无论是社会上还是技术上。一个由人和代码组成的社区——库、框架、平台。一个工程师会问:“好的。我需要做 X。有没有填补这个需求的库?”
成功的语言能够毫无疑问地回答:“是的。”这是一个显而易见的陈述,因为受欢迎的语言吸引了编写更多库的开发者,这又吸引了更多开发者:一个正反馈循环。但对于试图打入主流的语言来说,这并不有利。
降低流行水平一点,然后你可以看到由开发者获取所需功能的机制定义的语言。两个即时的例子是 Python 和 Perl。
Python 的口号是:“一切都包括在内”,将一个没有库的语言比作一台没有电池的花哨技术:看起来漂亮,但目前来说相当无用。Python 文档 自豪地宣称,在原生 Python 安装中,任何基本功能都只需一行导入操作。Python 标准库本身遵循大教堂模型:提交受限于 python-dev 成员,大约 120 名信任的人员列表。对于标准库的主要增加,包括 新增模块的添加 需要经历 严格的提案过程,在这个过程中,他们必须证明你的模块被接受、广泛使用并将得到积极维护。如果维护者消失,python-dev 将接管模块的管理,直到找到新的维护者,否则将弃用该模块。这种模型已经导致标准库中有 三百多个相对高质量的模块。
另一方面,Perl 采用了与 CPAN 相关的集市模型,以至于核心 Perl 的缓慢发布周期意味着一些核心模块已经双重存在:即它们既存在于核心中又存在于 CPAN 中。任何人都可以上传到 CPAN:结果是超过 20,000 个模块,被许多 Perl 开发人员视为必不可少的资源。除了它朴素的主页界面外,还有 大规模的测试基础设施 适用于 CPAN 中的所有内容,以及一个 评级系统(或许效用存疑)。CPAN 已经启发了许多编程语言中类似的集市风格的仓库(有趣的是,一些最流行的语言,如 C 和 Java,大部分都抵制了这一趋势)。
对于任何语言来说,要建立超过一百名可信任的提交者或规模庞大的社区都是一个很高的要求。但如果没有这个机制,该语言就无法生存。一般工程师必须重写太多的功能才能使其成为一种有用的通用语言。
这将我们带回到最初的问题:Hackage 站在哪里?
最近的结果来自 Haskell 2010 调查报告,反映了任何尝试使用 Hackage 的 Haskell 程序员的感受。有太多质量不足的库。
我们如何解决这个问题?毕竟,这是由志愿者制作的开源软件:你不能到处告诉人们要改进他们的库。是否增加核心模块集(即 Haskell 平台)和核心贡献者的数量,需要严格的质量审查(Python 模型)?还是让自然演变发生,并添加测量流行度的机制(Perl 模型)?
要取得成功,我认为 Hackage 需要做到两者兼顾。如果成功了,我相信它可能会成为标准库增长的模式。
大教堂模型是快速提高少量包质量的明显解决方案。Don Stewart 以前已经成功地使用了这种方法:bytestring最初是一个业余项目,然后 Haskell 社区意识到高效打包字符串有多重要。一支由经验丰富的 Haskeller 组成的“突击队”被组建起来,代码得到了大幅改进、充实和文档化,并在此过程中产生了多篇论文。现在 bytestring 是一个非常调优的库,是 Haskell 中高效输入输出的基础。Don 建议我们为真正重要的功能块采用类似的突击队。我们可以通过将被认为重要的库放入一个共享的存储库中来促进这一过程,以便不是主要维护者的人们仍然可以帮助进行基本维护和错误修复。
但是这个过程并不可扩展。首先,培养一组可信任的维护者是困难的。当前的基础库只由非常少的人维护:人们不得不思考一下 Simons 在维护base
时可能会花费多少时间,而他们本可以在 GHC 上工作。人们只能说服大多数人在智慧之前维护X
个包。即使是单个包的积极维护也可能非常耗时。
Hackage 2.0致力于促进 Bazaar 模型。包的流行度和反向依赖性可以帮助开发人员判断是否值得使用。
但是,如果我们同时考虑开发人员和包维护人员,我们正在解决一个复杂的社会技术问题,我们不知道什么能彻底改变集市。像 StackOverflow 风格的声誉系统会鼓励维护者完善他们的文档吗?像维基百科那样奖励贡献者增加特权的文化是否有助于选择一组可信的管理者?即使在一个编程语言的结构中没有尝试过这些想法,我们也无法判断它们是否有效!
我谨慎乐观地认为我们正处在 Hackage 对 Haskell 社区所代表的重大转变的边缘。但要实现这一目标,我们需要您的帮助。Vive la révolution!
致谢. 大多数想法不是我自己的。我只是把它们记录下来了。尤其是 Don Stewart,他对这个问题思考很多。
有关功能线性映射的简短笔记:ezyang 的博客
来源:
blog.ezyang.com/2019/05/a-short-note-about-functional-linear-maps/
从对 Conal Elliot 的编译为范畴和自动微分的简单本质的仔细阅读中收集的一些笔记。
有位同事试图定义张量的“树结构”,希望从而将该概念推广到具有“不规则维度”的张量上。让我们来看看:
假设我们有一个(2,3)矩阵:
tensor([[1, 2, 3],
[4, 5, 6]])
想一种方式来思考这个问题,我们有一种某种类型的“树”,其中树的根分支到两个子节点,然后每个子节点再分支到三个节点:
/- ROOT -\
ROW 1 ROW 2
/ | \ / | \
1 2 3 4 5 6
假设您想在 Haskell 中定义此数据结构。一个显而易见的方法是说矩阵只是一堆嵌套的列表,[[Float]]
。这确实有效,但并不是很详细,并且肯定不是类型安全的。使用大小向量可以实现类型安全,但我们仍然想知道,“这意味着什么?”
常常,归纳定义源于我们如何组合事物,就像编程语言的归纳数据结构告诉我们如何将较小的程序组合起来形成更大的程序一样。对于矩阵,我们可以考虑一种图解方式将它们组合起来,无论是垂直附加还是水平附加。这为我们提供了将矩阵组合起来的词汇表,这使我们能够(非唯一地)表示每个矩阵(编译为范畴,第八部分):
data Matrix
= Scalar Float
| Horizontal Matrix Matrix
| Vertical Matrix Matrix
但是这意味着什么呢?好吧,每个矩阵表示一个线性映射(如果A:(n,m)
是您的矩阵,则线性映射是函数R^m -> R^n
,定义为f(x)= A x
。我们将从 a 到 b 的线性映射称为Linear a b
)。所以我们现在要问的问题是,将两个矩阵“粘”在一起意味着什么?这是将两个线性映射组合成一个新线性映射的一种方法:
-- A function definition does not a category make! You have to
-- prove that the resulting functions are linear.
horizontal :: Linear a c -> Linear b c -> Linear (a, b) c
horizontal f g = \(a, b) -> f a + g b
-- In matrix form:
--
-- [ a ]
-- [ F | G ] [ - ] = [ F a + G b ]
-- [ b ]
vertical :: Linear a c -> Linear a d -> Linear a (c, d)
vertical f g = \a -> (f a, g a)
-- In matrix form:
--
-- [ F ] [ F a ]
-- [ - ] [ a ] = [ - ]
-- [ G ] [ G a ]
现在我们开始了!请注意,粘贴在线性映射的类型中显示出来:如果我们水平粘贴,那只意味着这个线性映射接收的向量必须被粘在一起(使用元组构造函数);同样地,如果我们垂直粘贴,我们将产生输出向量,这些向量是粘贴结果。
很棒,我们可以添加一些类型索引,并将 Linear 写成一个 GADT,以在应用构造函数时精细化索引:
data Linear a b where
Scalar :: Float -> Linear Float Float
Horizontal :: Linear a c -> Linear b c -> Linear (a, b) c
Vertical :: Linear a c -> Linear a d -> Linear a (c, d)
这就是故事的结局吗?还没有。有很多方法可以组合线性映射;例如,你可以(字面上)将两个线性映射组合在一起(与函数组合的意义相同)。确实,你可以用上述数据类型粘贴任何你喜欢的矩阵;我们如何决定什么应该放入我们的线性映射语言中,什么不应该?
为此,Conal Elliot 借助范畴论的语言来裁决。一个范畴应该定义身份和函数组合:
identity :: Linear a a
identity a = a
-- In matrix form: the identity matrix
compose :: Linear b c -> Linear a b -> Linear a c
compose g f = \a -> g (f a)
-- In matrix form: matrix multiply
我们发现水平和垂直是余笛卡尔和笛卡尔范畴的消除和引入操作(分别)。
但我们应该只是在我们的数据类型中添加Identity和Compose构造函数吗?线性映射组合是一个计算上有趣的操作:如果我们只是保留它作为语法(而不是像道义上的矩阵乘法那样做),那么在最终线性映射上进行操作将会非常昂贵。可表示函子又在哪里?我不太确定如何解释这一点,而且我在这篇文章中已经没有时间了;请继续关注后续。
建议 indent/php.vim : ezyang's 博客
建议 indent/php.vim
收件人:John Wellesz
首先,我要感谢您编写了 php.vim 缩进插件。最近使用了一些其他缩进插件的经历使我意识到没有好的缩进插件编辑会很烦人,多年来 php.vim 大部分时间都为我服务良好。
但是,我对PHP_autoformatcomment
的默认行为有一个建议。当此选项启用(默认情况下启用),它设置了'w'格式选项,根据尾随换行符进行段落格式化。不幸的是,此选项可能会产生许多不利影响,除非您特别留意尾随换行符的情况,否则可能并不明显:
-
当您输入注释并自动换行时,Vim 会留下单个尾随空格,以示“这不是段落的结尾!”
-
如果您选择几个相邻的注释,例如:
// Do this, but if you do that then // be sure to frob the wibble
然后输入 'gq',期望重新换行,但什么也不会发生。这是因为这些行缺少尾随空格,因此 Vim 认为它们是单独的句子。
我还认为缩进插件应该无条件地设置 'comments' 选项,因为您加载了 'html' 插件,这会覆盖任何预先存在的值(例如由 .vim/indent/php.vim 文件指定的值)。
请告诉我您对这些更改的看法。我还查看了 Vim 默认提供的所有其他缩进脚本,并注意到它们都不编辑 formatoptions。
AST 中的向后兼容故事:ezyang 的博客
来源:
blog.ezyang.com/2016/12/a-tale-of-backwards-compatibility-in-asts/
那些推崇向后兼容价值的人常常声称,向后兼容仅仅是永远不 移除 事物的问题。但是,任何发布涉及数据结构的 API 的人都知道,事实并非如此简单。我想描述一下我在 Cabal 文件格式上最近正在处理的一个向后兼容问题的思考过程。像往常一样,我对您可能有的任何见解和评论都很感兴趣。
现状。 在 Cabal 文件中,build-depends
字段用于声明对其他包的依赖关系。格式是一个逗号分隔的包名称和版本约束的列表,例如 base >= 4.2 && < 4.3
。抽象地说,我们将其表示为 Dependency
的列表:
data Dependency = Dependency PackageName VersionRange
在 build-depends
中的一项的效果是双重的:首先,它指定了一个版本约束,依赖解决器在选择包的版本时会考虑这一约束;其次,它将该包的模块引入作用域,以便可以使用这些模块。
扩展。 我们在 Cabal 中添加了对 "内部库" 的支持,允许你在单个包中指定多个库。例如,假设你正在编写一个库,但是有一些内部函数你想暴露给测试套件而不是一般公众。你可以将这些函数放在一个内部库中,该库被公共库和测试套件依赖,但不对外部包可用。
想了解更多动机,请参阅原始的 功能请求,但出于本博客文章的目的,我们关注如何指定对其中一个内部库的依赖问题。
尝试 #1:保留旧语法。 我对内部库的新语法的第一个想法是保留 build-depends
的语法 不变。要引用名为 foo
的内部库,你只需写 build-depends: foo
;一个内部库会遮蔽同名的任何外部包。
向后兼容?绝对不是。请记住,build-depends
中条目的最初解释是 包 名称和版本范围。因此,如果你的代码假设 build-depends
中的每个条目实际上是一个外部包,当指定一个依赖于内部库时,它会以意想不到的方式中断。这正是 cabal-install 的依赖解决器所发生的,需要更新以过滤掉对应于内部库的依赖关系。
有人可能会认为,如果使用了新功能,旧代码破坏是可以接受的。但是,对以这种方式重载包名称存在更大的哲学上的反对意见:如果... 实际上并不是一个包名称,那就不要称之为包名称!
尝试#2:一种新的语法。 受到这种哲学关注的启发,以及您无法同时引用名为foo
的内部库和名为foo
的外部包的问题,我们引入了一种新的句法形式:要引用包pkg
中的内部库foo
,我们写build-depends: pkg:foo
。
由于有一个新的句法形式,我们的内部 AST 也必须更改以处理这种新形式。显而易见的做法是引入一种新类型的依赖:
data BuildDependency =
BuildDependency PackageName
(Maybe UnqualComponentName)
VersionRange
并声明build-depends
的内容是BuildDependency
的列表。
当涉及数据表示的更改时,这是一个“最佳情况”,因为我们可以轻松编写一个函数BuildDependency -> Dependency
。因此,假设我们用于描述库构建信息的数据结构看起来像这样:
data BuildInfo = BuildInfo {
targetBuildDepends :: [Dependency],
-- other fields
}
我们可以通过将targetBuildDepends
转换为一个函数来保持向后兼容性,该函数读取新的扩展字段并将其转换为旧形式:
data BuildInfo = BuildInfo {
targetBuildDepends2 :: [BuildDependency],
-- other fields
}
targetBuildDepends :: BuildInfo -> [Dependency]
targetBuildDepends = map buildDependencyToDependency
. targetBuildDepends2
关键是,这利用了 Haskell 中记录选择器看起来像函数的事实,因此我们可以用函数替换选择器而不影响下游代码。
不幸的是,这实际上并不是真的。Haskell 还支持记录更新,让用户可以按照以下方式覆盖字段:bi { targetBuildDepends = new_deps }
。如果我们查看 Hackage,实际上有大约十几个使用targetBuildDepends
的方式。因此,如果我们想维持向后兼容性,就不能删除这个字段。不幸的是,Haskell 不支持重载记录更新的含义(也许这里要学到的教训是你永远不应该导出记录选择器:而是导出一些镜头)。
可能,在平衡中,破坏十几个软件包是一个公平的代价来支付这样的变化。但让我们假设我们坚决要保持 BC。
尝试#3:保留两个字段。 保持旧代码正常工作的一种简单方法是保留两个字段:
data BuildInfo = BuildInfo {
targetBuildDepends :: [Dependency],
targetBuildDepends2 :: [BuildDependency],
-- other fields
}
我们引入了一个新的不变量,即targetBuildDepends bi == map buildDependencyToDependency (targetBuildDepends2 bi)
。看到问题了吗?任何更新targetBuildDepends
的旧代码可能不知道要更新targetBuildDepends2
,破坏不变量,可能导致一些非常令人困惑的错误。呃。
尝试#4:做一些数学。 上面表示的问题是冗余的,这意味着我们必须添加不变量来“减少”类型下可接受值的空间。通常,我们喜欢“紧密”的类型,因此,正如 Yaron Minsky 所说,我们“使非法状态不可表示”。
为了更仔细地思考这个问题,让我们将其转化为数学形式。我们有一个Old
类型(同构于[(PN, VR)]
)和一个New
类型(同构于[(PN, Maybe CN, VR)]
)。Old
是New
的子空间,因此我们有一个众所周知的注入inj :: Old -> New
。
当用户更新targetBuildDepends
时,他们会应用一个函数f :: Old -> Old
。在使我们的系统向后兼容时,我们隐式地定义了一个新函数g :: New -> New
,它是f
的扩展(即inj . f == g . inj
):这个函数告诉我们在新系统中对旧更新的语义是什么。一旦我们有了这个函数,我们就试图将New
分解为(Old, T)
,使得将f
应用于(Old, T)
的第一个分量会给你一个新值,这个新值等同于将g
应用于New
的结果。
因为在 Haskell 中,f
是一个不透明的函数,我们实际上无法实现许多“常识性”的扩展。例如,我们可能希望f
更新所有parsec
出现为parsec-new
,相应的g
也做同样的更新。但是我们无法区分一个更新parsec
的f
和一个删除parsec
依赖,然后添加parsec-new
依赖的f
。在双向编程世界中,这就是基于状态和基于操作的方法之间的区别。
如果f
只能添加依赖项,我们真的只能做一些合理的事情,比如这样写:
data BuildInfo = BuildInfo {
targetBuildDepends :: [Dependency],
targetSubLibDepends :: [(PackageName, UnqualComponentName)],
targetExcludeLibDepends :: [PackageName],
-- other fields
}
从这里到BuildDependency
的转换大致如下:
-
对于
targetBuildDepends
中的每个Dependency pn vr
,如果包名称在targetExcludeLibDepends
中未提及,我们有BuildDependency pn Nothing vr
。 -
对于
targetSubLibDepends
中的每个(pn, cn)
,如果存在一个匹配的Dependency pn vr
(即包名称匹配),我们有BuildDependency pn (Just cn) vr
。
暂时退一步,这真的是我们想写的代码吗?如果修改不是单调的,我们将陷入麻烦;如果有人读取targetBuildDepends
然后将其写入一个全新的BuildInfo
,我们将陷入麻烦。真的值得为了实现如此小的、容易出错的向后兼容性而费这么大的劲吗?
结论。 我仍然不确定我要采取什么样的方法来处理这个特定的扩展,但似乎有几个教训:
-
记录对于向后兼容性来说是不好的,因为没有办法重载记录更新与自定义的新更新。更新的镜头会更好。
-
记录更新对于向后兼容性来说是不好的,因为它将我们置于双向编程的领域,要求我们将旧世界的更新反映到新世界中。如果我们的记录是只读的,生活会轻松得多。另一方面,如果有人设计了一种明确考虑向后兼容性的编程语言,双向编程最好能够出现在你的工具箱中。
-
向后兼容性可能在治愈中更糟。你是希望你的软件在编译时出问题,因为确实需要考虑这个新情况,还是希望所有东西都继续编译,但如果新功能被使用,会以微妙的方式破坏?
你怎么看?我不会自称是向后兼容性问题的专家,非常希望看到你的参与,无论是关于我应该采取哪种方法,还是关于编程语言与向后兼容性交互的一般想法。
一个 Cabal 化 Backpack 的体验:ezyang 的博客
更新。 想了解更多关于 Backpack 的信息?阅读规范
所以也许你已经接受了模块和模块化,并希望立即开始使用 Backpack。你如何做到?在这篇博文中,我想给出一个教程风格的 Cabal 编程示例。这些示例是可执行的,但你需要构建自定义版本的GHC和Cabal来构建它们。非常感谢您的评论和建议;尽管这里的设计在理论上是基础良好的,但由于显而易见的原因,我们还没有太多实际的程序员反馈。
在今天的 Cabal 中,有一个简单的包
首先,让我们简要回顾一下 Haskell 模块和 Cabal 包如何工作。我们的运行示例将是bytestring
包,尽管我会内联、简化和省略定义以增强清晰度。
假设你正在编写一个库,你想为一些二进制处理使用高效的紧凑字符串。幸运的是,著名的 Don Stewart 已经为你编写了一个bytestring
包,为你实现了这个功能。这个包包含几个模块:一个严格ByteStrings
的实现...
module Data.ByteString(ByteString, empty, singleton, ...) where
data ByteString = PS !(ForeignPtr Word8) !Int !Int
empty :: ByteString
empty = PS nullForeignPtr 0 0
-- ...
...和一个惰性ByteStrings
的实现:
module Data.ByteString.Lazy(ByteString, empty, singleton, ...) where
data ByteString = Empty | Chunk !S.ByteString ByteString
empty :: ByteString
empty = Empty
-- ...
这些模块被打包成一个包,并使用 Cabal 文件指定:
name: bytestring
version: 0.10.4.0
library
build-depends: base >= 4.2 && < 5, ghc-prim, deepseq
exposed-modules: Data.ByteString, Data.ByteString.Lazy, ...
other-modules: ...
接着我们可以创建一个简单的模块和依赖于bytestring
包的包:
module Utils where
import Data.ByteString.Lazy as B
blank :: IO ()
blank = B.putStr B.empty
name: utilities
version: 0.1
library
build-depends: base, bytestring >= 0.10
exposed-modules: Utils
关于这个完全标准的模块设置,值得注意的几点:
-
不能简单地将
Utils
从使用惰性ByteStrings
切换到严格ByteStrings
,除非直接编辑Utils
模块。即使如此,也不能使Utils
依赖于严格ByteString
和惰性ByteString
的同一程序,而不复制整个模块文本。(这并不令人太惊讶,因为代码确实不同。) -
尽管在这里有一些间接性:虽然
Utils
包括一个特定的ByteString
模块,但未指定会使用哪个版本的ByteString
。假如(假设性地),bytestring
库发布了一个新版本,其中惰性字节字符串实际上是严格的,那么当用户重新运行依赖解析时,Utils
的功能将相应改变。 -
我使用了限定导入来引用
Data.ByteString.Lazy
中的标识符。这在开发 Haskell 代码时是一种相当常见的模式:我们将B
视为实际模型的别名。从文本上讲,这也是有帮助的,因为它意味着我只需要编辑导入语句即可更改我引用的ByteString
。
通过签名泛化 Utils
要通过一些 Backpack 魔法泛化Utils
,我们需要为ByteString
创建一个签名,指定提供ByteStrings
模块的接口。这里是一个这样的签名,它放置在utilities
包内的文件Data/ByteString.hsig
中:
signature Data.ByteString where
import Data.Word
data ByteString
instance Eq ByteString
empty :: ByteString
singleton :: Word8 -> ByteString
putStr :: ByteString -> IO ()
签名的格式本质上与hs-boot
文件相同:我们有普通的 Haskell 声明,但省略了值的实际实现。
utilities
包现在需要一个新字段来记录签名:
name: utilities
library
build-depends: base
exposed-modules: Utils
signatures: Data.ByteString
注意,这里发生了三个变化:(1) 我们移除了对bytestring
包的直接依赖,和 (2) 我们新增了一个名为签名的字段,其中简单列出了我们需要填充的签名文件(也称为空洞)的名称。
那么我们实际上如何使用 utilities 包呢?假设我们的目标是生成一个新模块Utils.Strict
,它是Utils
,但使用严格的ByteStrings
(这是由 bytestring 包在模块名Data.ByteString
下导出的)。为此,我们需要创建一个新的包:
name: strict-utilities
library
build-depends: utilities, bytestring
reexported-modules: Utils as Utils.Strict
就是这样!strict-utilities
导出了一个单一模块Utils.Strict
,它使用了来自bytestring
的Data.ByteString
(这是其严格实现)。这被称为混合:在相同的依赖列表中,我们简单地混合在一起:
-
utilities
,它要求一个名为Data.ByteString
的模块,并且 -
bytestring
提供了一个名为Data.ByteString
的模块。
Cabal 会自动找出如何通过匹配模块名称来实例化 utilities 包。具体而言,上述两个包通过模块名Data.ByteString
连接在一起。这使得包实例化变得非常方便(事实证明,也很表达)。顺便说一句,reexported-modules是一个新的(正交的)特性,它允许我们重新导出一个模块,从当前包或依赖关系到外部世界,使用不同的名称。导出的模块和重新导出的模块区分开来是为了明确哪些模块在包中有源代码(exposed-modules)。
不寻常的是,strict-utilities
是一个不包含任何代码的包!它的唯一目的是混合现有的包。
现在,你可能会想:我们如何使用懒惰的ByteString
实现来实例化 utilities 呢?该实现放在了Data.ByteString.Lazy
中,因此名称不匹配。在这种情况下,我们可以使用另一个新特性,即模块瘦身和重命名:
name: lazy-utilities
library
build-depends: utilities, bytestring
backpack-includes:
bytestring (Data.ByteString.Lazy as Data.ByteString)
reexported-modules: Utils as Utils.Lazy
新的backpack-includes
字段表示只应该将Data.ByteString.Lazy
模块引入到范围内,使用名称Data.ByteString
。这足以将utilities
与ByteString
的延迟实现混合链接。
有趣的二元性在于,你可以以另一种方式进行重命名:
name: lazy-utilities
library
build-depends:
utilities (Utils, Data.ByteString as Data.ByteString.Lazy),
bytestring
我没有重命名实现,而是重命名了洞!这是等效的:重要的是,签名和实现需要在同一名称下混合,以便进行链接(签名与实现的实例化)。
有几点关于签名的使用需要注意:
-
如果你正在使用一个签名,那么在导入它时指定显式导入列表没有太大意义:你保证只能看到签名中的类型和定义(除了类型类... 这是另一个话题)。签名文件就像一个类型安全的导入列表,你可以跨模块共享它。
-
一个签名可以(而且通常必须)导入其他模块。在
Data/ByteString.hsig
中singleton
的类型签名中,我们需要引用Word8
类型,因此必须通过导入Data.Word
来将其引入范围内。现在,当我们编译
utilities
包中的签名时,我们需要知道Data.Word
来自哪里。它可能来自另一个签名,但在这种情况下,它由明确的包基础提供:它是一个具有实现的适当的具体模块!签名可以依赖于实现:由于我们只能引用那些模块中的类型,实际上我们在说:singleton
函数的任何实现和ByteString
类型的任何表示都是可以接受的,但是关于Word8
,你必须使用prelude
中来自Data.Word
的特定类型。 -
如果,独立于我的
strict-utilities
包,其他人也用Data.ByteString
实例化了utilities
,会发生什么?背包足够聪明,可以重复使用utilities
的实例化:这个属性称为模块系统的适用性。我们用来决定实例化是否相同的具体规则是看所有包所需的所有洞是如何实例化的,如果它们用完全相同的模块实例化,那么实例化的包被认为是类型相等的。因此,实际上不需要创建strict-utilities
或lazy-utilities
:你可以随时在现场实例化utilities
。
迷你测验: 这个软件包做什么?
name: quiz-utilities
library
build-depends:
utilities (Utils, Data.ByteString as B),
bytestring (Data.ByteString.Lazy as B)
共享签名
能够为Data.ByteString
显式编写签名非常好,但是如果我必须为每个我依赖的软件包都这样做,那会很烦人。如果我能够将所有签名放在一个包中,并在需要时包含它,那会更好。我希望所有 Hackage 机制都适用于我的签名以及我的普通软件包(例如版本控制)。好吧,你可以!
bytestring
的作者可以编写一个 bytestring-sig
包,其中只包含签名:
name: bytestring-sig
version: 1.0
library
build-depends: base
signatures: Data.ByteString
现在,utilities
可以包含这个包来指示它对签名的依赖:
name: utilities
library
build-depends: base, bytestring-sig-1.0
exposed-modules: Utils
与普通依赖不同,签名依赖应该是精确的:毕竟,虽然你可能想要一个升级的实现,但你不希望签名随意更改!
我们可以总结所有字段如下:
- exposed-modules 表示在本包中定义了一个公共模块。
系统消息:警告/2 (<stdin>
, 第 189 行)
枚举列表在没有空行的情况下结束;意外的缩进错误。
2. other-modules 表示在本包中定义了一个私有模块 4. signatures 表示在本包中定义了一个公共签名(没有私有签名;它们总是公共的,因为签名总是必须被实现) 5. reexported-modules 表示在依赖中定义了一个公共模块或签名。
在这个列表中,公共意味着它对客户端是可用的。注意前四个字段列出了本包中的所有源代码。以下是客户端的一个简单示例:
name: utilities-extras
library
build-depends: utilities
exposed-modules: Utils.Extra
总结
我们已经涵盖了很多内容,但归根结底,Backpack 真正出色是因为一组正交特性的良好互动:
-
模块签名:模块系统的核心,使我们能够编写不定的包并混合实现,
-
模块重新导出:能够将本地可用的模块重新导出为不同的名称,并
-
模块精简和重命名:有选择地从依赖中公开模块的能力。
要编译一个 Backpack 包,我们首先运行传统的版本依赖解决,获取所有涉及包的精确版本,然后计算如何将这些包链接在一起。就是这样!在未来的博客文章中,我计划更全面地描述这些新特性的语义,特别是有时可能会有微妙的模块签名。
互联网社交分类法:ezyang 的博客
来源:
blog.ezyang.com/2011/06/taxonomy-of-socialization-on-the-internet/
互联网社交分类法
有网络,也有社交。网络是建立联系,知道如何找到并与人交流的过程。社交是为了交流而交流;它加强网络并保持人们联系。在很多方面,这是社交网络网站提供的效用。问题是,如今在互联网上有很多不同的交流方式。在这篇博文中,我尝试解释互联网社交的基本差异。我认为所谓的一对多社交是最新社交模式的基本特征(由 Facebook 和 Twitter 提供支持),并描述了设计消费和聚合这些信息方法中的一些挑战。
有几种明显的方式来切分通信方法。以下是一些:
-
媒体. 大多数当前应用主要是基于文本的,但人们也尝试过基于音频、图片和视频的模式,成功的程度不一。
-
长度. 在这个媒介中,平均消息有多长?
-
持久性. 如果我忽略所有一小时之前的活动,我会错过多少?消息是否预期是短暂的还是持久的?是否总是可以随时发送消息?
-
分发. 是一对一、一对多还是多对多的通讯方式?(在本讨论中,我们将在信息流不对称时定义为一对多:因此“评论”系统不会将对话转化为多对多……除非它设法捕捉到自己的对话。)当有许多听众时,我们如何选择谁在听?
-
消费. 我们如何接收关于通信的更新?
我们可以看一些现有的网络,并看看它们如何回答这些问题:
-
即时消息(IM)。文本,长度短,持久但不总是可用,一对一,通过聊天客户端消费。请注意,随着多协议聊天客户端的存在,导航世界各地人们采用的多种协议并不是件太困难的事。
-
Internet Relay Chat (IRC) 和 Jabber。文本,长度短,短暂,多对多,通过 IRC/Jabber 客户端使用。这些协议与即时通讯工具有一定的整合。任何人都可以参与,但也可能会被踢出或禁止在频道内。
-
个人电子邮件. 文本,中等长度,持久,一对一,通过网络邮件界面或邮件客户端消费。
-
邮件列表. 文本,中等长度,持久,多对多,通过网络邮件、邮件客户端或新闻阅读器消费。对于公共邮件列表,任何人都可以参与。
-
论坛. 中等长度的文本,持久,多对多,通过网络浏览器消费。
-
博客. HTML,中到长篇幅,持久,一对多,通过网络浏览器消费,可能使用 RSS 阅读器。任何人都可以订阅他们感兴趣的公共博客。
-
Twitter. 短文本,短暂或持久,一对多(尽管可以通过回复 "@" 实现一对一通信,在更稀有的情况下也可以进行多对多通信),通过网络浏览器或 Twitter 客户端消费。任何人都可以关注公共 Twitter 用户。
-
Facebook 动态. 文本和图像,短到中等长度,短暂或持久,一对多(但通过评论可以进行多对多),通过网络浏览器消费。只有朋友可以接收更新。
-
Zephyr.(抱歉,不得不插入 MIT 的例子,因为我经常使用这个协议。)短到中等长度的文本,短暂或持久,多对多(但通过个人类别,可以让人们以一对多的方式发起对话),通过 Zephyr 客户端消费。实际上仅限于大学成员。
-
社交问答网站.(例如 Ask Reddit,Ask Hacker News,Stack Overflow。)中长文本,持久,一对多(讨论本身可以从一对一到多对多),通过网络浏览器消费。更新由特定主题的订阅者看到。
-
Skype 语音/视频. 音频/视频,短暂,一对一,通过 Skype 客户端进行。
当我们将这些协议简化到这些层次后,我可以观察到当前景观的一些有趣特征。
-
新兴公司试图在几个维度上创造下一个大型社交网络。Google Wave 尝试通过引入非常丰富的通信方式在媒介上进行创新。Color 试图在媒介和分发上进行创新。
-
属于相同分类法的通信协议(例如即时消息客户端)已经看到了能够在所有这些客户端之间进行互操作的大量客户端的激增。然而,跨分类法跃迁的情况通常并非如此:你见过一个可以将论坛、电子邮件、Facebook 和即时消息参与集成在一起的单一客户端吗?如果有,它是否运作良好?一种社交信息类型的消费方法未必适用于其他类型。
-
同样,对其他通信协议进行持久通信的引导并不太困难。例如,对论坛帖子的回复相对持久,如果你发现新回复时发送电子邮件更新,且不能依赖用户定期检查网站更新,这可能至关重要。然而,将即时消息发送到电子邮件帐户毫无意义!这里有一些重要的事情,即混合短暂和持久通信方式是没有意义的。
-
早期的社交网络工具专注于一对一和多对多的互动,因为这些方式与我们在现实生活中社交的方式非常相似。然而,Twitter 和 Facebook 的出现表明,普通人不仅可以用一对多的媒介来发布信息(就像博客的情况),而且可以用来社交。人们不是在布告栏上张贴流言纸片,而是通过一对一的对话来传播。但这正是新互联网所关注的。新的社交互联网涉及在出版和私人通信之间探索。理解这种变化,在很多方面是正确使用新社交媒体的关键。
作为终端用户,我对统一所有具有相同消费模式的社交互动方法的机制很感兴趣——主要是因为我没有足够长的注意力来处理那么多我必须检查的不同网站。并且,这并不意味着只提供一个选项卡界面,让你可以在不同的网络之间切换。对于持久方法,电子邮件和 RSS 对我来说效果还不错,但是当涉及新的一对多的瞬时(但持久)通信方式时,我总觉得有一个很大的空白。部分原因是,即使只涉及一个通信协议,我们仍在努力找出最佳的解决方法。(请记住,Facebook 的新闻订阅在最初发布时曾广受诟病——但这正是将 Facebook 从社交网络网站转变为社交通信网站的关键功能。)当多种微妙不兼容的通信模式混合在一起时,情况变得更加复杂。
Kyle Cordes 从权力用户的角度写道这个主题。我同意他的观点,即在权力用户之外是否实际存在这样一个统一的社交媒体客户端的市场尚不清楚。但我不确定开源社区是否会迈出这一步并创建这样的客户端。我希望自己是错的。
一年后的 Backpack:ezyang 的博客
一年后的 Backpack
距我获得我的斗篷和长袍并加入 Facebook(我一直在那里工作 PyTorch)已经一年了,但是在 Facebook,Backpack 并没有停滞不前;事实上,活动比我希望的要多得多。我希望在这篇博文中总结一些近况。
使用 Backpack 的库
在使用 Backpack 空间的库中,一直在进行一些非常有趣的工作。以下是我在过去几个月中看到的两个最大的项目:
unpacked-containers. 多产的 Edward Kmett 编写了unpacked-containers包,利用了通过 Backpack 签名进行展开的事实,为你提供了比通常的装箱表示性能提高(15-45%)的通用容器实现。在这篇 Reddit 主题中进行了大量讨论。
hasktorch. hasktorch,由 Austin Huang 和 Sam Stites 开发,是 Haskell 的张量和神经网络库。它绑定到 TH 库(也支持 PyTorch),但使用了 Backpack,使得 Kaixi Ruan 的文章深度学习的 Backpack焕然一新。这可能是我迄今为止见过的最大的 Backpack 实例之一。
生态系统中的 Backpack
Eta 支持 Backpack。 Eta 是 GHC 的 JVM 分支,将 Backpack 支持移植到他们的分支中,这意味着你现在可以在你的 Eta 项目中使用 Backpack。它在这篇 Twitter 帖子中宣布,并且在这篇帖子中有更多讨论。
GSOC 关于多个公共库。 作为 Google Summer of Code 的一部分,Francesco Gazzetta 正在添加对 Cabal 中多个公共库的支持。多个公共库将使许多 Backpack 用例更容易编写,因为您不再需要将 Backpack 单元拆分为单独的包,为每个包编写不同的 Cabal 文件。
GHC 和 Cabal 中的 Backpack
总体而言,自初始发布以来,我们没有改变 Backpack 的用户界面语法或语义。然而,已经有一些显著的错误修复(也许比预期的少),这些错误修复已经合并并即将到来:
-
#13955:Backpack 现在支持非*种类,因此您可以使用 Backpack 进行活力多态。
-
#14525:Backpack 现在支持 CPP 扩展
-
#15138:Backpack 将很快支持数据 T : Nat 签名,可以用类型 T = 5 来实例化。感谢 Piyush Kurur 发现问题并编写补丁来修复此问题。
-
修复了 Cabal 问题 #4754:现在 Backpack 与性能分析兼容。
需要帮助的事情
Stack 对 Backpack 的支持。 在 Stack issue #2540 中,我自愿为 Stack 实现 Backpack 的支持。然而,在过去的一年中,显而易见的是我实际上没有足够的空闲时间来亲自实现这个功能。现在正在寻找勇敢的人来深入研究这个问题;我很乐意就 Backpack 方面提供咨询。
为 Backpack 添加模式同义词支持。 你应该能够用一个合适的双向类型同义词填充签名数据 T = MkT Int,反之亦然!这是 GHC 问题 #14478。我们认为这并不应该太难;我们必须获取由构造函数诱导的匹配项,并检查它们是否匹配,但确切地如何做还需要一些时间。
一年的博客:ezyang 的博客
博客的一年
在这里庆祝一年的博客。感谢大家的阅读。仅仅是一年前,我第一次在 Iron Blogger 的翅膀下开设了这个博客。Iron Blogger 在此时大部分已经解散,但我很自豪地说这个博客没有停止发布,每周三次(除了那次我错过了一篇文章,后来进行了补偿性的额外发布),这是我与自己打赌并且很高兴赢得的赌注。
这个博客在过去的一年里走过了哪些地方?根据 Google Analytics 的数据,这里是十篇最受欢迎的文章:
可能还有一些我个人最喜欢的不那么显眼的文章,但到这个点我写了这么多,有点难以数清:包括这篇文章,我将已发布 159 篇文章,总字数约为 120,000 字。 (此数据包含标记,但用于比较,一本书大约有 80,000 字。哇哦,我写了一本半的内容。不过我不觉得自己是个更好的作家——这可能是因为我在“修订”这部分的过程中偷懒了。)
这个博客将在一月份进行短暂的休息。不是因为我在假期期间不能发布文章(如果有机会,我可能会... 实际上,这是一个有点难做出的决定),而是因为我应该花一个月的大部分空闲时间集中在除了博客之外的事情上。祝大家新年快乐,我们二月见!
附言. 这是我用来计数的 SQL 查询:
select
sum( length(replace(post_content, ' ', '')) - length(replace(post_content, ' ', ''))+1)
from wp_posts
where post_status = 'publish';
或许有更精确的方法,但我懒得写脚本。
A Year of Notebooking (Part 1) : ezyang’s blog
今年,我已经积累了三本笔记本,记录了各种各样的笔记和思考。由于这些笔记本已经破烂不堪,我决定把它们的内容转移到这里。警告:它们可能有些不连贯!这是三本笔记本中的第一本。我建议你浏览一下各个章节的标题,看看有没有什么特别吸引你的内容。
Tony Hoare:抽象分离代数
Tony Hoare 希望利用已经解决的“艰难工作”,将分离逻辑(例如 Hoare 三元组)的形式化放置到一个抽象代数中。其想法是通过将事物编码为一对,而不是三元组,我们可以利用代数中的众多结果。基本思想是我们取一个传统的三元组 {p} q {r}
,并将其转换为一个有序半群关系 p; q <= r
,其中 ;
是一个单调运算。最终,我们得到了一个分离代数,这是一个带有额外星号操作符的单调格。公理的选择很重要:“这是抽象代数,所以你应该愿意接受这些公理,而无需考虑任何模型。”(在这里涂鸦写着:“梦中套梦作为数学多层次思维的隐喻。”)我们有一个同态(而不是同构)在实现和规范之间(右方向是简化,左方向是伽罗华连接)。实际上,正如观众中的一位评论者指出的那样,这被称为斯通对偶性——有点像两点决定一条线——带有逆变点和属性。我相信 Tony 在今年年初我去听这场讲座时已经对这个主题进行了一些思考,所以这些内容可能已经被后来的发现所取代。C'est la vie!
Satnam Singh:多目标并行处理
我们能编写可以在多种类型硬件上执行的并行代码吗:例如,在传统 CPU 上进行矢量化操作,GPU 或 FPGA?他提出了一种可以嵌入到任何语言中的 EDSL(对于这种特定表示,是 C#),具有诸如newFloatParallelArray
、dx9Target.toArray1D(z)
和重载运算符的构造。在我的笔记中,我备注道:这种表示是否可以无标签地实现,或者我们总是需要在执行之前构建系统描述的成本?在异构处理器面前,将软件推向硬件尤为重要(例如 Metropolis)。萨特南姆是一个非常引人入胜的演讲者,这里的引用很多都是归功于他——尽管我确实有一个引用是“希望这不会被引用”(别担心,我没有引用那句话)。跳舞是并行处理的一个比喻(尽管我不记得那个比喻是什么)。自修改硬件怎么样:我们将电路描述映射到内存,并让硬件重新编程 FPGA!
高层信息对优化至关重要:因此,我们可能希望有一个符号评估器,具有即时编译(除了在 FPGA 上我们无法做到)。内存访问融合很重要:我们想要摆脱意外的分号。Array -> Stream / Shift = Delay
。研究想法:常见并发问题的几何编码。矩阵求逆是一个问题(所以不要反转矩阵,傻瓜),本地内存限制 GPU 与 FPGA,以及能量调度问题。
Streambase
Streambase 是一家实现视觉事件流处理语言的公司。我曾经参与他们的编译器面试;虽然最终我拒绝了他们,但这是一个非常有趣的面试,我认为如果在那里工作会很有趣(尽管工作语言是 Java,我就不是很喜欢!)面试非常有趣:其中一个问题是,“向我解释单子。”天啊,我仍然不知道如何恰当地解释这个概念。(附注:人们确实非常喜欢副作用。围绕编写能在持久数据上运行的高性能程序的编程传说非常新颖,或许我可以说比围绕惰性求值的传说还要新颖。)
怀疑论
亚历山大·伯德的书《科学哲学》教会了我如何识别无效的怀疑,即使在不明显的情况下,比如休谟关于归纳问题。我的可靠主义论文相当不错;这是我在哲学辅导课中唯一一篇拿到一级的论文。像类型理论一样,理由的确证是分层的,有层层叠加的。
西蒙·佩顿·琼斯:不应该概括“Let”
Hindley-Milner 类型系统的程序员长期以来一直享受着实用类型推断的好处:我们通常期望推断出最一般的类型,并且在它们的使用位置进行表达式的语法替换总是可以类型检查的。当然,类型推断算法通常是 EXPTIME-complete 的,但类型理论家们不会因此而抱怨太多,因为对于更强大的逻辑,推断通常是不可判定的。(顺便说一句:字典构成了运行时证据。这是一个很好的思路。)有趣的是,在更高级别的类型特性存在的情况下,编写类型签名实际上可能会使类型检查器的工作变得更加困难,但它们会添加需要由约束求解器处理的局部相等性假设。广义的 let 意味着所有这些约束直到达到调用点才能解决。我们能否通过在解决相等约束时进行即时解决来解决这个问题?法国人有一篇关于这个问题的论文,但 Peyton Jones 建议如果你决定阅读这篇论文,最好随身带上一罐阿司匹林。在他的演讲后,一位研究生评论说 Hindley-Milner 在许多方面都是一个异常现象:Agda 的用户期望需要指定所有类型签名,除非在一些特殊情况下可以消除它们,而 Haskell 的用户则期望在特殊情况下不需要指定任何类型签名。
树的流处理
Document Object Model(DOM)的一个长期问题是它要求将整个文档加载到内存中。在像 PHP 文档手册这样的情况下,内存使用量可能超过一千兆字节。不幸的是,操作 DOM 的心理模型比操作 XML 标签事件流更自然。有没有办法自动将 DOM 的更改映射到流的更改上?我们希望构建两者之间的同构。我正在寻找 DOM 的函数表示,用于操作(对于 DOM 样式的事件编程模型,你仍然需要可变性)。"左边是小丑,右边是小丑"强调了局部和全局分析之间的差异。你可能会认为遍历令牌流的方法只是简单地遍历,使用拉链来跟踪你的位置。当然,这个拉链会占用内存(实际上,它形成了类似堆栈的东西,这正是你将令牌流转换为树的方式)。因此,我们可以高效地构建树表示而无需突变,但最终我们仍然得到了树表示。此时,我已经写下了“别再打自己了。”确实如此。我们能否利用领域特定知识,一个我承诺不再超出这一点的声明?将 DOM 操作投射到 XML 流操作中,并将其用作衡量某些事物成本的方法可能会很有利可图。当然,现在我应该做一次文献检索。
正则表达式编辑距离
给定一个正则表达式和一个不匹配的字符串,需要多少次编辑才能使字符串匹配?可能会有多个答案,算法应允许对不同的修改进行加权。
Frank Tip:为 Web 应用生成测试和故障定位
Apollo 采用了测试 Web 应用程序的混合方法,结合了具体执行和符号执行。其理念是,大多数 Web 应用程序具有模糊的、早期的条件化,没有复杂的状态转换或循环。因此,我们在控制器上生成路径约束并解决它们,然后生成输入,使我们能够执行所有控制路径。数据即代码:我们想描述数据。我可能没有很仔细地听演讲,因为我写下了各种其他事情:“堆栈不是 STG 的正确调试机制”(嗯,是的,因为我们想知道我们来自哪里。不幸的是,知道我们要去哪里也不是很有用)和“我们可以使用执行跟踪自动生成 QuickCheck 缩减实现吗?”(一种自动化的测试用例最小化)以及最后的思考,“Haskell 不是一个适合运行时检查或故障定位的好语言。”
Benjamin Pierce:类型与编程语言
如果有人制作出一个交互式可视化,展示在向类型系统添加新功能时类型系统如何生长和扩展,一种类型规则和操作语义的视觉差异,那将非常酷。
平滑趋势
作为一名博客作者,我的页面浏览量往往会非常波动,当我的文章被流行新闻网站推广时,访问量就会飙升(迄今为止,我的比特币文章已经有 22k 次浏览。不错!)但这并不能帮助我了解网站的长期趋势。有没有办法使这些波动趋势平滑,使得高峰仅仅成为更长期趋势上的“热门点”?
用户界面
我想要一个最小技术工作量最佳实践用户界面的圣经,实现起来简单且不会让用户太困惑的模式。对我来说,UI 设计有点太琐碎了。在智能界面的情况下,我们如何不让用户生气(例如 Google Instant)?我们有一个用户期望,即计算机不会猜测我想要什么。那太奇怪了。
第 32 页
我用大字写着:“证明局部定理。没有远程作用。”诺曼·拉姆齐在我和 Hoopl 一起工作时,几乎用了同样的话告诉我。我认为这是一个非常有力的想法。
分离逻辑与图形模型
我记录了一些数学定义,但它们并不完整。我不认为我写了什么特别有洞察力的东西。这反映了笔记的目的:你应该记录那些以后可能无法获取的东西,但你也应该确保跟进所有你说过会查找的完整信息。
Jane Street
我有两页关于通过电话面试解决问题的涂鸦。我非常喜欢它们。一个是动态规划问题(一开始我对递归关系不太理解,但最终搞定了),第二个是在 OCaml 中实现函数式编程特性。实际上,我想写一篇关于后者的博客文章,但到目前为止,它一直留存在我的草稿箱中,等待重生的一天。在我的笔记中(第 74 页),我记录了现场面试的问题,不幸的是,我不能与你分享它们。
Quote
“这就像雇用律师开车带你穿越城市。”我不记得具体的语境是什么了。
Mohan Ganesalingam:数学语言
我真的很喜欢这个演讲。Mohan 研究将自然语言处理应用于一个比无限制的人类语料库更易处理的领域:数学语言的领域。为什么这个领域易于处理?数学在文本中定义了其词汇(数学术语必须明确定义),我们混合符号和自然语言,并且语法是受限的。Montague 语法与表义语义相对应。当然,像普通语言一样,数学语言也存在严重的歧义。我们有词汇歧义(“质数”可以描述数字、理想等),结构歧义(如果 p 生成 某些多项式的分裂域 在 F_0 上 —— F_0 是指生成还是多项式?),符号歧义(d(x + y)
,这不仅仅是操作符重载,因为解析树可以改变:例如取(A+B)=C
与λ+(M=N)
作比较),以及符号和文本结合的歧义。事实证明,数学的语言类型系统,这是正确获取解析树所必需的,根本不是数学性的:整数、实数及其伴侣都归为一个大类别的数字,类型不是外延的(对象根据内容具有不同类型)。我们需要一个动态类型系统,而不是结构或名义类型系统,并且我们需要在解析过程中推断类型。
写笔记
从 12 月 1 日开始,我似乎需要写更具总结性的结尾段落,使用更短的句子。总结我的论点部分,详细描述实验内容,并不要忘记,历史数学的大部分是几何学。旨在用更少的句子表达更多的内容。阿门!
另一组笔记:所有问题都是陷阱:考官希望你思考被问的内容。思考事件周围的更广泛背景。你可能没有足够的时间与当代观点比较。在你的文章中放置路标。 小心不要发生非因果关系。冒号很好:它们增加了强调(但要小心使用)。短句子!
Principia Mathematica
多么美妙的会议啊!有很多演讲,我本应该多记一些笔记,但这是我有的一些,一些引语和素描。
代数学家与分析学家。 “四个人骑自行车进来,然后再骑出去。” 数字作为时刻,而不是对象(尽管它不失一般性)。 “康托尔对此完全无望。”(关于零)。“数字是否从 0 或 1 开始?是和是。” 弗雷格和罗素最终给了零适当的地位。计数的误读:算术是否从计数开始?数字序列已经就位,相反,我们构造了同构。有一个错误的信念,我们从一开始数数。同构避免计数,给予零适当的地位,并且避开计数实际如何工作的问题(一个及物动词:预计数,我们必须决定计算什么)。与《逻辑漫游》中的普遍描述相反,哥德尔和罗素确实见过面。奎因逻辑和教会逻辑。“平方根二不是无理数”要求每个数字都是有理数或无理数。
我们为什么关心老年人?我们如何在哲学中取得进展?秩序是句法而不是语义:克里普克和塔尔斯基发展了一个真理的层次结构。自由变量推理有助于解决名词和典型模糊:对哲学问题的科学方法。“现在什么构成研究——也就是说,谷歌它。”名词模糊:断言“x 是偶数”,实际上是“对所有 x,x 是偶数。”引用:“从信中清楚地表明他没有看过《原理》的第五页。”单词“变量”是非常误导的,它不是变量名(进步!)“没有不确定的人。” 同指代代词。我们不能用这种方式表达推理规则。
类型:变量必须具有范围。几乎没有定理(所有的陈述都是模式):我们想证明关于所有类型的事情,但不能因为矛盾而这样做。所以所有的变量通常是类型不明确的。第 2 卷中有关于无穷的论证,但是小世界给了你错误的数学(实证主义)。但是有一个聪明的想法:即使世界上的东西不够多,如果有 k 个东西,就有 2^k 类的东西等等。上升到层次结构。这就是典型模糊的解释。怀特海德认为理论是理论土地上的无意义字符串(一种宏)。斯特劳斯基在语言/元语言区分方面有所贡献!!“看”是确定类型的方式。逻各斯中心主义困境是你应该使用推理,但这种推理是在形式系统之外的。更高类型的操作符双关语,所有操作符都带有类型标签。类型的分层。
自由变量推理对典型模糊推理是相同的。量化推理的缩写(需要内部量化器的混乱规则),不定名(不能是变量名,不能导致不定事物),示意名(λ:正确的变量,现代的类型)。但是如果不让某人相信它(怀疑主义),看起来:如果正确的逻辑是类型理论和外部的,那么我们没有超出推理的立场。(这是一个单向方向。)我认为有一种从内部谈论系统的方法。我们有一种削弱的真理感:如果你已经相信它,那就没问题,但没有说服力。
下一堂课来自计算机科学家的世界。“可以说,编程语言越多地借鉴形式逻辑,它就越好。” 否则,这是“电工的临时创建”。计算机允许进行简单的形式化操作和正确性检查。但对于数学来说呢?并不是很多。可以通过算法检查证明(使用形式推理规则)。“因为这里有很多哲学家,我希望我能以适当模糊的方式回答问题。” 符号化允许我们机械地做“容易”的事情(怀特黑德的引用)。我们需要形式方法吗?在 1994 年,发现奔腾处理器在浮点除法中有错误。罗宾的猜想被错误地证明了。不同的证明系统:德布鲁因生成的证明由单独的检查器检查,LCF 将所有规则化简为由逻辑核心检查的原始推理。毕竟,为什么我们不证明我们的证明助手有效?HOL Light(Principia)只有 430 行代码。谢弗的笑话:拉姆赛化的类型。现在,形式逻辑正处于 20 世纪研究数学的边缘,证明只需要“1 万行代码”。形式证明的维护是一个大问题:我们需要中间的声明性抽象模型。看看 Flyspeck。
我在页边有些涂鸦:“逻辑中的引用?”(我认为这是线性逻辑),性能证明如何(保证在某个时间内运行,实时证明),或概率可检查的证明。也许复杂性理论在这里有所发言。
图灵机
他们有效访问的方法是……拉链。哦,天哪!
GHC
我在这里的涂鸦大部分都看不清楚,但最初有些概念让我困扰:
-
栈布局,保持上下直线,信息表,以及栈指针的运动。现在我对这一切是如何工作有了相当清楚的理解,但开始时它确实相当神秘。
-
CmmNode
构造器有很多字段,与打印的 C-- 构成对应关系是非平凡的。 -
变量的大小。
-
标题,负载和代码。
-
指针标记,特别是关于存储在寄存器中、堆栈上的值,以及标签位在上下文中的含义(函数或数据)。我从未弄清楚压缩 GC 是如何工作的。
这结束了第一本笔记本。
一年的笔记本(第二部分):ezyang 的博客
这是笔记本的第二部分。
Max Schäfer:重构 Java
大多数内置于诸如 Eclipse 之类的 IDE 中的 Java 重构工具只不过是经过美化的文本操作宏。不能保证重构的结果与原始行为相同:甚至可以重构不编译的代码!为了防止这种情况发生,大多数重构都需要复杂且难以理解的前提条件。Max 提出了两个想法:
-
不要试图编写一个复杂的前提条件,可能不能准确反映安全问题,我们相反地进行转换,然后验证重构没有破坏任何东西。我们可以用程序行为的依赖描述来做这件事,它过度规范了原始的语义(否则,这样的分析将是不可判定的)。
-
不要试图编写一个试图处理所有可能情况的庞大重构,我们将重构分解为源语言的简化版本上的微重构。例如,将一块代码移动到一个方法中将涉及到闭包转换(控制)、lambda 提升(数据),然后是实际的外部移动,在这一点上已经是微不足道的。然后我们可以重新将其转换为原始的源语言。这使我们可以抽象处理边界情况。
模块化是一种非常强大的理念,这也是 Hoopl 所采用的。 (有人可能会想知道 Hoopl 是否对重构有用,我观察到的一个大问题是 Hoopl 的表示太低级了,而一个高级语言的要点实际上是你不需要复杂的数据流分析。)
然而,对此有一些错误的假设。它假设我们知道整个程序,它只用一种语言编写(没有 XML 清单文件),并且是静态类型和基于类的。当然,所有真实的程序都违反了这些假设,因此如果我们真的希望人们采用这种工作流程,我们需要一个适合他们的故事。重构是从松散到结构化代码的过渡。(我在页面底部涂鸦了责任计算,但现在我不知道这是什么意思。)
语义过度规范让我想起了 SLAM 对程序行为迭代逼近的方法。
Mike Dodds:确定性并行性的模块化推理
分离逻辑的一个常见问题是,你需要为可能想要建模的每种并发构造引入一个新的原语,因此你最终会得到无数不同的逻辑,每个适合自己的并发模型。迈克提出了一种逐步构建任何你可能想要的语义的方法,使用“并发抽象谓词”。你可以使用谓词生成满足各种其他函数规范的变量。
这次特别讲座的大部分时间都花在了一个名为wait/grant
的奇怪并发构造上,描述在《安全未来的准静态调度》中。这是一个保持“必要”顺序依赖性的屏障。一些时间实际上用来澄清这个构造的作用,以及它与缓冲通道的区别。托尼·霍尔评论说,这种构造类似于现实生活中的“生产线”,虽然在编程中并不常见,但对于原始论文作者所处理的问题(并行化顺序程序)来说却是相当自然的。
我的笔记鼓励我“在 Haskell 中实现这一点”,并且还有关于“树形位向量传播”的备注,显然这是优化过的版本。还有一堆代码片段,但我肯定可以在幻灯片中找到这些内容。
托马斯·佩特里切克:Joinads
F#的 Joinads 是一种用于对计算进行模式匹配的系统(与仅对值进行模式匹配不同)。这与单子有何不同?Joinads 支持额外的操作:merge :: m a -> m b -> m (a, b)
和choose :: [m (Maybe (m a))] -> m a
,可以实现特殊的调度属性。这对于未来(Manticore)、事件(FRP)或联接演算(当通道包含值时执行联接:它是廉价而快乐的复用)可能很有用。事实证明,你可以从交换单子中免费获得一个 Joinad,这可能说明了对这些单子有用的语法扩展类型。
无论出于何种原因,我对这次讲座并不特别感兴趣。我想我的理由是我并没有感受到为什么 Joinads 可能是一个特别有趣的理论构造要研究,也没有看到它们在任何意义上是最小的。此外,在 Haskell 中,我们通过 fork 额外的绿色线程来实现复用,这在我看来是一个更好的模型。
计算实数
丹·皮波尼之前已经写过这个主题,但这次讲座确实帮助我从更广阔的视角看待了这些问题。
我们可以轻松地处理许多类型的数字:二元域(布尔值)、整数(当然,不考虑溢出),以及有理数(整数对)。但是实数带来了一些困难。它们是有理数集合 Q 的度量闭包,即所有可以作为 Q 的柯西序列极限的东西。这让人想起了十进制展开。
现在我们要考虑实数上的函数。我们如何知道某物是可计算的?我们可能会尝试说它是从数字串到数字串的关系,其中F(p)
的任何有限前缀可以从p
的足够长度的有限前缀统一计算得出。但对于像 0.3333 这样的情况,这显然是不够的,因为我们需要读取无限多的数字来判断这是否真的是三分之一。(我要注意,无穷大有点像图灵机领域的预言机。)
相反地,我们说一个有理数序列q_i
(其中i
是自然数)代表了一个实数x
,如果对于所有i
,|x - q_i| < 2^-i
。(基数并不重要)。让p(q) = x
,那么如果存在F
使得p(F(prefix)) = f(p(prefix)
,则f
是可计算的。根据这个定义,加法、乘法、除法、三角函数、指数函数和对数函数都是可计算的。
一个有趣的函数限制是连续性,从任何微积分教科书中都很熟悉:如果对于f: R -> R
的所有x
,对于所有ε > 0
,存在一个δ > 0
,使得对于f
的所有定义域中的y
,当|x - y| < δ
时,|f(x) - f(y)| < ε
。有人提出每个可计算函数都是连续的:因此,可计算性源于我们能够逼近事物,这对于不连续的函数来说是做不到的(我们处于不连续处的哪一边?)我们可以进一步限制函数到C(R,R)
,这是连续函数集合,也可以通过无限序列来逼近(例如多项式)。
考虑单调中间值定理,该定理指出如果f
是单调的,f(0) < 0``and ``f(1) > 0
,那么存在某个 x 使得f(x) = 0
。我们能计算出这个数吗?二分法不起作用,因为确定一个数是否大于或小于另一个数通常是不可计算的。(一般的中间值定理也不可计算,因为我们可以无限接近原点线。)我们可以使用三分法。同时计算f(0.3)
和f(0.7)
,并执行以下比较:
-
如果
f(0.3) < 0
,新的区间是[0.3, 1]
-
如果
f(0.3) > 0
,新的区间是[0, 0.3]
-
如果
f(0.7) < 0
,新的区间是[0.7, 1]
-
如果
f(0.7) > 0
,新的区间是[0, 0.7]
你需要做一些工作来得到一个正确性的正式证明,但很容易看出为什么这在直观上可能有效:比较只有在数接近比较点时才会花费无限的时间,但那么我们的另一个比较就一定会成功,因为它与比较点有非无限小的距离。(我有一个小边注:我们可以用这个来做密码学吗?)
结果证明实数的可计算性有各种学派。我刚才描述的观点是波兰学派。然而,俄罗斯学派认为不可计算的点不存在:代码片段就是实数。因此,有效分析不是经典分析。构造数学与经典数学之间存在对应关系。关于巴拿赫空间(线性算子必须是有界的),因此一般情况下微分不可计算,尽管积分是可计算的!(这与符号评估世界非常不同。)欲了解更多,请参阅《可计算分析》,Springer,2000。
马丁·埃斯卡尔多:处处选择函数
我没听懂这个讲座。实际上,我对马丁的大部分工作都不太理解。但我有一堆引用:
-
“我不知道为什么它被称为延续单子;没有控制,这个讲座完全失控了。”
-
“我不打算解释这意味着什么,但我将解释这是如何运作的。”
-
“[无法理解的陈述]。这并不太深奥。”
-
“原因是,嗯,可能在下一张幻灯片里。”
-
“每场比赛都是长度为 2 的:你有第一步,然后所有其他的。”
-
“你将可以使用单调单子结构来计算这两个人。”
选择函数选择具有“最高”真值的个体。最大值定理、最小值定理和全局值定理(饮酒者悖论)都是不可计算的。选择函数是一个两阶段过程。K A -> A
是双重否定消除,J A -> A
是皮尔斯定律。贝基奇引理给出一个不动点。条形递归和对p
(连续函数)的归纳(以树形式)进行递归。我们可以计算最优策略。T 系统加上 J-shift 方程是强正则化的。我们可以编码可数选择公理(但这不再是选择了!)。依赖选择:(AC 经典选择,Tychonoff 理论等。参见那篇文章。)为什么 J 是一个单子?
罗杰·彭罗斯爵士:纺锤体理论
彭罗斯……的讲话并不是很易懂。嗯,他解释了黎曼球面(一种立体投影)得相当不错,但接下来的事情变得非常模糊。一些引用:“这就是数学中你所必须做的:去想象它。”“小不适用于距离。”“所以我最好的方法是把它画成香肠。”“我不认为我应该解释这幅图。”“首先我会让你更加困惑。”“这可以在板球中完成,我们希望在纺锤体理论中也能做到。”关于余同调有一个有趣的旁白:一个关于不可能性程度的精确的非局部度量;我看到艾舍尔的图片时我觉得很振奋。甚至物理学家们都认为纺锤体理论并不反映现实。这只是一个数学玩物。
没有关联的话题,那晚上 RAG 的相亲活动正在进行。
康纳·麦克布赖德:命运的 Kleisli 箭头
“考虑以下程序,哎呀,可怜!”代码幻灯片包含变量 b
和 c
:“有 b 还是没有 b,这是一个问题!...或者为了抵抗一堆麻烦。”“世界上最成功的依赖类型语言:Haskell。”向房间里的 Simon Peyton-Jones 挥手。
我一直想写一篇关于这次演讲的博客文章,或许还会写,尽管这次演讲在我脑海中已不再那么新鲜。Conor 描述了模拟依赖类型所需的技术机制,即使不能显然地将值推入类型中。程序应该是策略树,涵盖所有可能的响应(尽管现实会选择其中一种)。我们不需要依赖类型:我们可以使用参数化的单子来编码 Hoare 逻辑的前后条件。(导致一个可预测的世界。)这些被称为“向上流动的大括号”(向上流动指的是值变成类型)。这次演讲让我想知道 Strathclyde Haskell 扩展是否是会话类型的良好平台,这些类型因缺乏真正的依赖类型而受到极大的影响。(它还让我想到了高效的类型级计算。)
魔鬼是 ∀(看看那对角)例如,一个错误的全称量词(我以为你可以将它们视为斯克勒姆变量)。术语在类型上持有证据。(典型的 Conor 会开个关于这个的玩笑。)Kleisli 箭头是 Hoare 三元组(考虑可编程的分号),并且用 bind 不会让您选择结束状态(那里有个存在量)。然而,我们可以使用强制魔鬼给我们值的替代 bind:Atkey 参数化单子。我们还需要确保我们天真的自由单子不会在运行时出问题。这并不是很一致,整理这个故事是我在发布真正的文章之前需要做的事情。
Conor 的主要信息是数据是证明的见证!“我在见证保护计划中。”如果我们有一些数据,我们就履行了一些证明义务。(但我想知道,多态性呢?)
并发的 Petri 网
我们想要性能的非确定性,所以我们使用 Petri 网来编码非确定性契约,然后通过图表来推动流量,以确定我们需要什么样的性能。调度和放置很容易处理,因为它们只是 Petri 网上的修改。但我不确定这种方法是否有效,因为 Petri 网可能会变得相当复杂,而且您可能无法获得所需的性能以进行有趣的分析。
Grant Passmore:计算代数中的策略
自动推理问题是不可判定的,并且是用户引导的。计算代数问题是不可行的,但算法往往是黑盒子、秘密酱的解决方案(例如 Mathematica),具有各种像 Groebner 基、量词消除、非线性复杂算术的 SAT(希尔伯特的弱零点定理)和简化到重写的技巧。
格兰特希望将选择权交还给计算机代数。有许多选择点:二次预处理,成对选择,基础增长,前向简化。他对 ATP 和 GB 进行了比较。在 LCF 中,功能参数是关键。套路可以,但证明过程是一种策略。
-
算法中有什么可以改变而保持正确性?
-
我的算法正在做出什么任意选择?
(页边注:我们是否可以通过理解它创建的可变状态来理解融合?)
(未命名的软件工程讲座)
形式方法的一个持久问题是它们在假设和要求的领域中运作,而无法确定这些假设或要求是否正确!这是一个相当哲学的问题:我们是否可以有“已知的认证缺陷”:一个已知的未知?这些是潜在的反证据来源。不知道有什么最坏的影响?你提出了什么主张?(看看哲学的哪些元素在这里是相关的会很有趣。)有时安全论据只是断言“因为我这么说”:这是认识论不确定性。演讲者认为我们应该用定性的信心论据来交换概率完整性论据:这是安全与信心的问题。我们应该明确承认认证缺陷,并停止太过信任。
我提出了这样一个问题:是否有动机进行这场辩论?(工程师们想要表现得很好。)把它交给评估者显然更糟,因为他们没有适当的位置来评估系统。他没有回答,但说道,“不这样做是不能接受的。”
我有点惊讶地注意到,在接近结束时,他推动了一些处理这个问题的技术软件,显然是他自己研究计划的产物。我对解决这个特定问题的技术解决方案仍然不确定。
近似马尔可夫链
标记马尔可夫过程编码连续状态空间,长期以来,我们注意到双仿真适用于离散状态空间,但不适用于连续状态空间。我们将最终状态视为概率分布,并在我们的机器上按下按钮(标签)以更改分布:每个标签是一个随机核(广义二元关系)。当然,推理关于连续状态空间是重要的:它们涵盖像布朗运动、性能、带递归的概率过程代数、混合控制系统(飞行管理系统)等复杂的离散现象,例如人口增长和股票价格。
我并没有理解大部分技术细节,但主要观点是共仿真是正确的方法:这只是巧合,双仿真适用于离散系统。投影极限恰好是最小的双仿真过程。还有一些关于度量空间的材料。这显然是一个我们仅仅从数学的小众领域导入众所周知结果的领域。
金融密码学
美国人不知情,他们的金融系统仍然停留在磁条阅读器上,而欧洲的银行卡已经迁移到 EMV,其中在芯片中嵌入了一个微处理器,可以进行真正的挑战-响应认证。本讲座探讨了我们如何通过现有的 EMV 硬件引导一个绕过银行的 P2P 交易系统。
我们该如何做到这一点?我们可以使用称为 CAP 的设备,它有一个小显示屏,当给定一个 PIN 时会生成认证码(双因素认证)。我们将交易与 CAP 码关联起来,这样商家同意接收款项。但是你仍然需要从银行那里获得合作。(这是 SDA 方法。)所以我们完全摆脱了银行,使用 Diffie-Hellman(DDA)。卡只是简单便利的现有基础设施,用于获取名称认证。由于我们一次只能签署 32 位,通常需要更多。 (边注:“威胁模型是隐私倡导者。”)
讲话很短,所以之后我们讨论了为什么这个方案实际上不会起作用。我的反对意见是银行可能会简单地禁止以这种方式使用他们的银行卡。讨论是他们是否技术上能够强制执行这一点:马库斯·库恩用护照作为例子,如果您没有互联网访问权限,您就无法读取护照,因为护照本身有一个嵌入的单调时钟,如果扫描仪软件不是最新版本,则会拒绝向扫描仪提供信息。护照如何知道最新版本是什么?它的时钟在看到新扫描仪时得到更新。)护照安全技术非常有趣!他们为此发明了一个字母表上的块密码。
验证 QBF 的有效性。
SAT 是指数级的,当你将量化器加入其中时,得到的是“另一个”指数级,但这次是在证书中。如何验证一个全称量化公式实际上是真的?在这种情况下,证书是扩展变量和见证:我们为所有存在量化的变量提供具体实现,然后可以替换成一个传统的 SAT 问题。因此,一旦我们有了证书,一个 PSPACE 问题现在“只是”一个 NP 问题。
(技术注释:我们希望按拓扑顺序消除假设(使用 Refl、Gen 和 Exists)。正确排列量化器,见证取决于存在变量,扩展变量取决于此。)
讲话描述了他如何钩入 Squolem 的内部,以实际获得这些证书。结果表明,德布鲁因比携带名称更快(这与典型的 QBF 无效性检查不同)。他甚至发现了非 LCF 样式验证器中的一个 bug(由于缺乏循环检查)。
应用:模型检验(有界和无界),PSPACE 问题。(边注:“比较 BDD 和 QBF?”)
这篇笔记本二到此结束。
一个 Zerocoin 的谜题:ezyang 的博客
一个 Zerocoin 的谜题
我很少发布链接垃圾信息,但考虑到我过去曾写过关于比特币匿名化的主题,这个链接似乎很相关:Zerocoin:使比特币匿名化。他们的核心创新是在区块链本身中建立一个持续运行的混合池;他们通过使用零知识证明来实现这一点。神奇!
这里有一个给本博客读者的难题。假设我是一个想要匿名化一些比特币的用户,并且愿意在兑换我的 Zerocoins 之前等待期望时间N。那么,我从哪个正确的概率分布中选择等待时间呢?此外,假设一个 Zerocoin 参与者的群体,他们都使用这个概率分布。进一步假设,每个参与者都有一些效用函数,权衡匿名性和预期等待时间(请随意做出使分析变得容易的假设)。这个群体处于纳什均衡状态吗?
abcBridge:AIGs 和 SAT 求解的功能接口:ezyang 的博客
来源:
blog.ezyang.com/2010/08/galois-tech-talk-abcbridge-functional-interfaces-for-aigs-and-sat-solving/
abcBridge:AIGs 和 SAT 求解的功能接口
昨天我进行了一场Galois Tech Talk,主题是 abcBridge,这是我暑期实习期间在 Haskell 中为 ABC 构建的一组绑定。
很快会有视频,但在那之前,你可以下载我带有注释的幻灯片。软件目前还不公开,但希望很快会公开。
Abstraction without a concrete concept : ezyang’s blog
霍尔逻辑,尽管听起来很数学,实际上是程序员推理的一种非常实用的方式,大多数软件工程师在使用前置条件和后置条件的形式时会下意识地应用它。它明确地公理化了对程序员来说是常识的事情:例如,NOP 不应改变任何条件,或者如果一行代码具有另一行代码作为其前置条件的后置条件,那么这些代码行可以依次执行,而内部的前置条件-后置条件对可以被忽略。即使你从未真正写出推导链,当你试图审查使用前置条件和后置条件的代码时,你也在非正式地应用霍尔逻辑。霍尔逻辑是一种抽象,让我们可以严谨地讨论任何带有相同规则的命令式语言。
在我的第一个语义午餐上,我有幸听了托尼·霍尔的一场名为抽象分离代数的演讲。托尼·霍尔并不满意于需要同时讨论三个方面——前置条件、实际代码和后置条件——这一事实,于是他将此方案重新编码为普通的抽象代数结构。尽管他的幻灯片看起来都很合理,但我的抽象代数知识不足以验证他所有的结果,因为这些结果仅在瞬息之间闪过。因此,我不打算过多讨论实际的重新编码(等我有空时再自行研究吧,哈!)。然而,我捕捉到了驱动这种重新编码的基础思维过程的一丝气息,我觉得这是一种迷人的思维方式:从你想要成立的抽象的公理 X 出发,使用更基本的公理池(在这种情况下,是抽象代数的通常公理)来找出使公理 X 在逻辑上成立的必要公理。这在多个方面都是一种令人费解的思维,尤其是你最终得到了一组原语,而你甚至可能连一个具体的模型都没有!
我承认,我有点害怕听众中的数学家会认为,“当然这就是你选择公理集合的方式!对于强壮的人来说,具体的模型就是多余的。”(毕竟,在演讲中曾说,“这是抽象代数,所以你应该愿意接受这些公理,而不需要任何具体模型。”)但考虑到演讲中提出的问题,其中包括“那么直觉上★代表什么?”(对此并没有答案),我觉得我有理由声称,对于我们这些凡人来说,这确实是一种相当奇怪的思考方式。
例如,考虑霍尔给出的第一个例子:让 (C, ;, ⊑) 是一个有序半群(即,一个带有关于部分序关系 ⊑ 单调的载体集合 C 和一个关联的可交换二元操作 ;)。那么,我们可以定义霍尔三元组 {p} q {r}
作为关系 p ; q ⊑ r。我立即想知道:C 代表什么(它似乎包含后置条件、代码行和前置条件:正如 Simon Peyton Jones 指出的那样,它似乎没有区分这两种代码类型),以及 ⊑ 关系代表什么?但这些都是不太重要的问题,因为如果我们通过单调性摇动这棵代数树,顺序组合的果实会掉下来:
遗憾的是,谦卑的有序群组并不足以编码我们需要的所有东西,所以霍尔最终带来了幺半格和遵守 abides law 的特殊运算符,以获得“分离代数”:
分离代数是元组 (C, ⊑, ;, ★, ε, ⊔),其中 (C, ⊑, ;, ε, ⊔) 形成一个幺半格,并且满足律 (p ★ q) ; (p' ★ q') ⊑ (p ; p') ★ (q ; q')。
正如他承认的那样,他稍微作弊了:他正是引入了他所需的代数性质,以便得到他想要的性质。但是,使结果显著的是他所需的只是常见的抽象代数结构。因此,当我们操作我们新编码的霍尔三元组时,我们可以依赖于传统的抽象代数技术(对于我们这些从未学过抽象代数的人来说,这可能并不是“好”的或“老”的技术。)
那么 ⊑, ★, ε 和 ⊔ 是什么意思呢?由于我们构造了我们的代数结构的方式,我们并不一定知道!(尽管我相信某个具体的模型已经在那里等待被发现了。也许它已经被发现了。)也许这只是范畴论的一个例证:“自我无关紧要:只有它与他人的关系才重要。”如果你问我的话,这有点禅意。
后记。 有一个关于实现和规范代数之间的对偶性类似于 Stern 对偶性的好评论:粗略地说,两点确定一条线,但一条线确定无穷多点。我去搜索了这个术语,但找不到任何相关文献,所以也许会有一些指引将不胜感激。
加速持久性神经网络(Daniel Lo):ezyang 的博客
来源:
blog.ezyang.com/2017/12/accelerating-persistent-neural-networks-at-datacenter-scale-daniel-lo/
下面是Daniel Lo在NIPS'17的ML 系统研讨会的讲话记录。
部署和提供云规模的加速深度神经网络(DNN)。正如我们所见,DNN 已经实现了惊人的应用。架构在计算机视觉、语言翻译和语音识别方面实现了最先进技术。但是在大规模交互服务中存在延迟、成本和功耗限制,这是一个挑战。此外,DNN 的大小和复杂性正在增加。
我们看到了解决这一问题的初创企业的堆积如山。研究团队开发了 DNN 处理单元(DPUs),定制硬件解决方案来实现高吞吐量的有效服务 DNN。我们将它们分类为两类:快速 DPUs,其中算法和应用程序必须在设计时固定,因为它们在制造 ASIC,或者软 DPUs,FPGA。但是对于软 DPUs,我们尚未看到它们大规模部署。
为了解决这个问题,我们一直在进行 Project BrainWave 的工作。这是一个解决大规模 DNN 部署的解决方案,利用 FPGA 加速。我们设计它以实现快速、灵活和友好。使用 FPGA 的高吞吐量、低延迟加速。使用可适应的数值精度,更新最新的 AI 算法与可重配置的 FPGA。而且它用户友好,因为我们有一个全栈解决方案,编译 CNTK/Caffe/TF 并将其编译下来。这部署在我们可配置的云端,一个外层的 CPU 层,一个将所有东西整合在一起的数据中心,以及一个可重构的 FPGA 层。
我们已经部署了 DNN 模型。LSTM 模型在 CPU 上需要几十到几百毫秒的时间。我们看到的是 99th 百分位数的延迟;即使在 99th 百分位数,我们也能达到亚毫秒级别的延迟。当你达到这种加速水平时,在端到端流程中是可以忽略不计的。
接下来我将深入细节。这是一个全栈解决方案。从编译器和运行时开始,将高级框架中的模型编译到我们的体系结构中。一个灵活的 ISA 用于服务 DNN。我们有一个高吞吐量、低延迟的服务。我们在大规模持久性的情况下做到这一点,以保持模型固定在 FPGA 内存中。部署在我们广泛部署的 Intel FPGA 上,使用硬件微服务。
首先,让我们谈谈硬件微服务。这是我们在 Micro 上展示的内容。可重配置云的架构是,FPGAs 位于 CPU 和网络之间。CPU 可以在本地使用 FPGA 进行加速,但由于 FPGAs 通过网络连接,它们可以在它们之间进行分布。我们有专有的网络协议用于低延迟计算。
我们已将 FPGA 计算平面与 CPU 分开。因此,我们可以将多个 FPGAs 聚合在一起形成更大的加速器,而不必将 FPGAs 与 CPUs 的速率匹配。您可以使用少量的 FPGA 集群为大量的 CPU 提供服务,反之亦然。
接下来我将谈论编译器和运行时环境。目标是使 ML 专家能够轻松处理这些问题。典型的 ML 专家不知道如何编程这些东西。在高级框架中开发的模型,将它们编译成我们的架构。如果您首先将它们编译成中间图形表示。我们将它们分割成在 FPGA 上部分,和在 CPU 上的部分。当我们执行时,我们还有运行时环境来处理编排和调度。
我们需要为两种主要类型的 DNN 进行优化。具有非常高计算数据比的 DNNs,如卷积神经网络,这些已经研究得很好。我将重点放在另一类 DNN 上,即计算数据比较低的 DNN,例如密集层和循环神经网络。
在 FPGAs 上加速 DNNs 的常规方法是,将所有模型参数存储在 DRAM 中。当有请求进来时,你会从 DRAM 中流式传输模型参数,并返回请求。这种方法的问题在于,当你有内存带宽受限的 DNN 层时,你受限于内存带宽的速度;你无法充分发挥 FPGA 的计算能力。通常解决这个问题的方法是使用批处理;你发送一些请求,并对所有请求使用相同的模型参数。虽然你可能会获得良好的吞吐量,但延迟会增加。对于实时服务来说,这违反了你的 SLA。我们想要做的是在低或无批处理的情况下提供高性能。
我们解决这个问题的方法是使用持久化的 Dnets。FPGAs 在芯片上有大量的内存:10MB 内存。由于它们在芯片上,具有高带宽。因此,我们将保持模型参数在芯片上,这样当我们收到一个请求时,我们可以将其分布到整个 FPGA 芯片上。
显而易见的问题是,如果您的模型不能放在芯片上会发生什么?我们利用硬件微中心。我们将单个模型分布在数据中心的多个 FPGAs 上。
让我们看看我们开发的处理单元的架构和微架构。BrainWave DPU 是一种软件可编程处理器,用单线程 C 编程,但我们增加了一些用于服务 DNNs 的指令,例如矩阵乘法,卷积,非线性激活,嵌入。该处理器设计用于使用窄精度格式(float16),并且易于扩展到新的算法。
处理器的微架构,主要部分专用于矩阵向量单元;矩阵向量乘法,由大矩阵上的若干个核心组成。瓦片化使我们在保持性能的同时具备了灵活性。其他计算单元是多功能单元;向量-向量操作,如逐元素乘法、加法和激活函数。将所有这些元素连接在一起的是芯片上的网络,让我们能够同时保持所有计算的进行。
大多数芯片专用于矩阵向量单元。它由数百个多车道点积单元组成。每个点积单元由数十个加法和乘法单元组成。为了确保它们能够及时处理数据,每个点积单元都由一组专用的块 RAM 提供数据。
接下来,我想展示这种架构的性能结果。两年前,我们部署了 Stratix V FPGA。它展示了这种格式的有效 Tflops。16 位整数……我们一直在使用我们自己的 Microsoft 浮点格式。在 MSFP5.8 上的 4.5Tflops。这些 Stratix 已经相当老了。
(展示最新一代 FPGA 的演示)
查看面向吞吐量的 DPU,延迟为 65.81 毫秒。使用 Brainwave,延迟为 0.98 毫秒。低于 1 毫秒。
这是在初始工程硅上完成的。对于生产硅,我们预计以 16 位整数获得 12TOps。对于 MSFP8,则为 90TOps。一个问题是数值输出如何影响输出。这里是三个内部文本模型的归一化精度,使用 GRU 和 LSTM。橙色条显示了当您转向 MSFP9 时会发生什么,但我们已经开发出了一种调整网络以适应此精度的方法,您可以看到我们恢复了准确性。我们正在使用 MSFP8 并且看到类似的结果。
Project BrainWave 是我们用于在云端扩展 DNN(深度神经网络)的项目。我们希望它能够快速、友好并且具备云规模,扩展 AI 在云中的能力,为运行高维度 RNN 网络用于 NLP 和其他应用提供一种方式。我们计划向第三方发布,敬请关注。
Q: 当您减少批处理大小时,您正在评估哪种硬件?随着减少,硬件利用率如何?
A: 即使在减少批处理大小时,我们仍然保持高度利用;即使在高批处理大小时,我们仍然一次发送一个请求。(只有一个步骤会被处理?)对。
Q: 关于 FP9 和 FP8,九和八是否是使用的位数?(是的)它在某种程度上与 Intel 的 Flexpoint 有关吗?
A: 我们独立于 flexpoint 开发了这个项目,我无法谈论我们的数值格式。
Q: 在微软,您是否真的为您的 FPGA 编写 Verilog,还是使用高级综合工具?
A: 对于这个项目,我们正在编写 System Verilog。
Q: 需要批处理计算的批处理归一化层如何放入 FPGA 中?
A: 编译器的工作之一是在 CPU 和 FPGA 之间进行分割。因此,那些不适合在 FPGA 上运行的内容,包括批处理归一化,我们仍然在 CPU 上运行它们。
从 C 中访问惰性结构:ezyang 的博客
从 C 中访问惰性结构
最近有人在 haskell-beginners 上询问,如何在 C 中访问一个惰性(可能是无限的)数据结构。我未能找到一些关于如何做到这一点的示例代码,因此我自己写了一些。希望这能帮助你在你的 C 调用 Haskell 的努力中!
主文件 Main.hs
:
{-# LANGUAGE ForeignFunctionInterface #-}
import Foreign.C.Types
import Foreign.StablePtr
import Control.Monad
lazy :: [CInt]
lazy = [1..]
main = do
pLazy <- newStablePtr lazy
test pLazy -- we let C deallocate the stable pointer with cfree
chead = liftM head . deRefStablePtr
ctail = newStablePtr . tail <=< deRefStablePtr
cfree = freeStablePtr
foreign import ccall test :: StablePtr [CInt] -> IO ()
foreign export ccall chead :: StablePtr [CInt] -> IO CInt
foreign export ccall ctail :: StablePtr [CInt] -> IO (StablePtr [CInt])
foreign export ccall cfree :: StablePtr a -> IO ()
C 文件 export.c
:
#include <HsFFI.h>
#include <stdio.h>
#include "Main_stub.h"
void test(HsStablePtr l1) {
int x = chead(l1);
printf("first = %d\n", x);
HsStablePtr l2 = ctail(l1);
int y = chead(l2);
printf("second = %d\n", y);
cfree(l2);
cfree(l1);
}
以及一个简单的 Cabal 文件来构建它全部:
Name: export
Version: 0.1
Cabal-version: >=1.2
Build-type: Simple
Executable export
Main-is: Main.hs
Build-depends: base
C-sources: export.c
祝愉快编程!
ACM XRDS:Jeff Dean 个人资料:ezyang 的博客
我正漫步在盖茨大楼时,看到了最新一期的 ACM XRDS,这是一本由学生撰写的杂志。
“哦,我难道没为这期写篇文章吗?” 是的,我写了!
在线版本在这里,尽管我听说它在付费墙后,所以我复制粘贴了下面的草稿版本。有趣的事实:这篇文章的第一个版本有关 Jeff Dean 的内容,但我们删除了,因为我们不确定每个人是否知道Jeff Dean facts是什么......
真实事实:作为一名高中学生,Jeff Dean 写了一个统计软件包,在某些功能上比商业软件包快了二十六倍。如今,Jeff Dean 在 Google 工作,帮助设计和优化一些日常使用的大型数据处理系统。这些系统包括众所周知的 MapReduce(一种用于并行化大规模计算的编程模型)和 BigTable(存储几乎所有 Google 数据的系统)。Jeff 目前的项目是基于神经网络的深度学习基础设施,这种系统应用于语音/图像识别和自然语言处理。
尽管 Jeff 已经成为与 Google 内部基础设施项目密切相关的公众面孔,但 Jeff 强调这些项目需要来自不同领域专家的混合技能。任何一个项目可能会有网络、机器学习和分布式系统背景的人员。总体而言,一个项目可以比个人单打独斗取得更多成就。不足之处?因为背景各异,你真的需要知道何时说:“等一下,我不明白这个机器学习术语。” 然而,Jeff 补充说,在这些团队工作非常有趣:你可以了解到自己可能之前不太了解的子领域。
除了解决问题的不同风格之外,Google 还有不同于学术界的研究目标。Jeff 举了一个特定的例子:当学术界研究一个系统时,他们不必担心如果发生一些非常罕见的硬件故障会发生什么:他们只需演示这个想法。但 Google 必须担心这些边缘情况;这只是当你的优先事项之一是构建生产系统时会发生的情况。发布结果到普通大众也存在一定紧张关系。在发布 MapReduce 论文之前,内部讨论是否发布过。一些人担心该论文可能会使 Google 的竞争对手受益。最终,Google 决定发布该论文,现在你可以获取任意数量的开源实现 MapReduce。
尽管 Jeff 在 Google 工作了十多年,他职业生涯的开始看起来大不相同。他回忆起如何得到他的第一份工作。“我小时候搬家很多:在世界各地的不同地方上了十一所学校,十二年... 我们在高中二年级后搬到亚特兰大,这所学校要求我们在毕业前做实习... 我知道我对开发软件很感兴趣。所以学校的指导顾问说,'哦,太好了,我会安排一些事情',她安排了这个听起来很无聊的实习。我在开始之前去见他们,他们基本上希望我为这家保险公司把磁带装入磁带驱动器。我想,'这听起来不太像开发软件。' 所以,我四处寻找,最终在疾控中心找到了一份实习。”
这次“混搭”实习标志着 Jeff 在疾控中心和世界卫生组织工作多年的开始。一开始在亚特兰大工作,然后在日内瓦,Jeff 花了大量时间开发逐渐扩展成为越来越大的系统,用于追踪传染病的传播。这些经历包括在本科毕业和研究生学习之间全职工作一年,帮助他最终选择论文课题:当 Jeff 参加了优化编译器课程时,他想知道是否可以教编译器做他在世卫组织所做的优化。最终,他与克雷格·查伯斯合作,一个新的教员,他们是同一年开始的研究生。"很棒,一个由三四名学生和他组成的小研究小组。我们从零开始编写了这个优化编译器,并进行了有趣且富有挑战性的优化工作。” 当他完成博士论文后,他加入了数字设备公司,并致力于低级别应用程序的性能分析工具。
Jeff 喜欢每隔几年尝试不同的事情。在某个领域工作一段时间后,他会选择一个相邻的领域继续学习。但 Jeff 强调,虽然这种策略对他有效,他也认为有不同类型的研究人员很重要,需要有愿意在同一个问题上工作数十年或整个职业生涯的人——这些人在这个领域有着深入的知识。"世界上有两种人都有其存在的空间。" 然而,当他从一个话题转移到另一个话题时,结果是 Jeff 再次回到了原点:他在 Google 的当前项目是关于神经网络并行训练的,而这也正是 Jeff 大学本科毕业论文的主题。“具有讽刺意味,” Jeff 说。
临时近似:ezyang's 博客
在他的书反方法中,保罗·费耶拉本德写道关于‘临时近似’的激进段落,这段对任何上过物理课并想过“他们从哪里得到这个近似值”的人都很熟悉。
水星的近日点每世纪大约移动 5600"。这个值中,5026"是几何学的,与参考系统的运动有关,而 531"是动力学的,由于太阳系中的摄动引起的。除了著名的 43",所有这些摄动都可以用经典力学解释。这通常是这种情况的解释方式。
这个解释表明,我们推导出 43"的前提不是广义相对论加上合适的初值条件。这个前提包含了经典物理,除外正在被做的任何相对论假设。此外,所谓的‘施瓦西尔德解’,并不处理行星系统在现实世界中存在的方式(即我们自己的非对称星系);它处理的是完全虚构的中心对称宇宙,其中包含一个中心奇点和其他什么都没有。为什么要采用这样奇怪的前提联结呢?
根据惯常的回答,原因在于我们正在处理近似值。经典物理的公式并未出现,因为相对论是不完整的。同样,中心对称的情况也没有使用,因为相对论没有提供更好的选择。这两种方案都源于特定情况下的广义理论,实现于我们的行星系统中前提是我们省略了太小以至于不予考虑的量级。因此,我们贯穿使用了相对论的理论,并且我们在适当的情况下使用它。
注意,这种近似的想法与合法的想法有所不同。通常情况下,人们有一个理论,能够计算感兴趣的特定情况,注意到这种计算导致的大小远低于实验精度,忽略这些大小,从而得到大大简化的形式主义。在当前情况下,进行所需的近似意味着相对论地计算完整的n-体问题(包括不同行星轨道之间的长期共振),忽略小于观测精度的大小,并展示因此修剪的理论与由施瓦西尔德修正的经典天体力学相符。这种程序还没有被任何人使用,仅仅是因为相对论的n-体问题至今还未得到解决。对于重要问题,例如稳定性问题(牛顿理论的第一个重大障碍之一),甚至没有近似解。因此,解释的经典部分[解释我们的观测的前提]不仅仅是为了方便,它是绝对必要的。所做的近似不是相对论计算的结果,而是为了使相对论适用于特定情况而引入的。可以恰当地称之为特设近似。
费耶尔本德的确是一个非常反传统的人,我邀请读者暂时搁置他们对这段文字的第一反应。对于那些认为,“当然物理学家就是这么做的,否则我们永远无法完成任何工作”的人,请考虑这样一个问题,我们有什么理由相信这些近似是合理的,它们不会影响我们计算的可观测结果,实际上它们反映了现实?人们可以采取这样的观点,认为这种怀疑是无益的,会妨碍科学研究,而我们知道,从先前的经验中,这种怀疑是行不通的。但我认为,这个论点对所有领域的规范主义者都有重要的启示作用——那些希望说事情应该如何做的人(软件领域确实看到了很多这样的事情;甚至在这个博客上也是如此)。因为,就像学生抱怨,“我根本想不到那种近似”,或者数学家畏缩并想,“我没有理由相信那个近似应该有效”,如果这些近似确实存在,并且科学的进程就是去发现它们,那么,你会怎么做呢?
似乎象牙塔也不免有现实生活的污点。
即时无线网络:ezyang 的博客
即时无线网络
从蒙特利尔问候!我现在是在 La Cité的三十九楼通过无线连接写下这篇文章。不幸的是,当我们读租约时,唯一检查的是是否有“互联网”……而没有“无线网络”。那么,一群 MIT 的学生,带着一堆笔记本电脑和没有无线路由器的情况下要做什么呢?搭建无线即时网络。
但事实上并没有奏效。大部分情况下是这样。我们在多台笔记本上进行了一些调试和尝试,最终找到了可行的配置。首先是那些不起作用的配置:
-
Windows, 如 Daniel Gray 所说,有两种标准方法用于创建即时网络:桥接两个网络或者……我们尝试了两种方法,其中一个……我们能够连接其他 Windows 笔记本和 Mac OS X 笔记本……但对于 Linux 笔记本却毫无进展。由于我们三个都是 Linux 用户,这种状态让我们感到非常不满。
-
Linux 理论上支持使用 dnsmasq 创建即时网络;然而,我们尝试了两台不同的笔记本,都未能建立任何其他笔记本能够使用的即时网络。我们发现了一些关于 ESSID 未初始化字段的滑稽错误。
-
Mac OS X. 此时,我们正在认真考虑出门,找到一个无线硬件商店,为公寓购买路由器。然而,有人意识到我们还有一个操作系统没有尝试过。经过几分钟的调试…… 是的!即时网络在所有三个操作系统上都能正常工作!
结果:苹果+1,微软 0,Linux -1。尽管如此,毫不奇怪没有人真正关注到无线驱动程序所需的细节。
三个单子的冒险:ezyang 的博客
三个单子的冒险
我在这个冬季休假期间忙于工作,为 Monad 读者 写一篇文章,名为 "Adventures in Three Monads"。这篇文章的内容将与我将在 SIPB IAP 主办的 IAP 讲座的第二部分 Haskell Typeclasses 重叠。
文章本身是一个文学化的 Haskell 文件,包含了我在与语言 flirtations 一年中编写的各种 Haskell 应用程序中抄袭的样本代码:包括我为了 brute-force 6.004 作业而构建的概率图灵机的代码和解释。(给课程工作人员:代码不完整到足以等同于发布所有解决方案;勇敢的读者仍然必须自己编写搜索函数。)
我将在我的 公共目录 中保留文章的预印本。欢迎提问、评论和建议!
关于 MVars 的全部内容:ezyang 的博客
我最近花了时间重新编写了MVar 文档,目前文档内容相对较少(简介部分非常简洁地说明了“同步变量”;尽管原始作者在数据类型和其基本操作的内联文档方面做得相当详尽。)我在这里复制了我的新简介。
在研究此文档时,我发现了有关 MVars 如何工作的新内容,这体现在这个程序中。它做什么?
import Control.Concurrent.MVar
import Control.Concurrent
main = do
x <- newMVar 0
forkIO $ do
putMVar x 1
putStrLn "child done"
threadDelay 100
readMVar x
putStrLn "parent done"
MVar t
是一个可变位置,可以是空的,也可以包含类型为t
的值。它有两个基本操作:putMVar
,如果 MVar 为空则填充并阻塞,否则阻塞;takeMVar
,如果 MVar 为满则清空并阻塞,否则阻塞。它们可以以多种不同的方式使用:
-
作为同步可变变量,
-
作为通道,使用
takeMVar
和putMVar
作为接收和发送, -
作为二进制信号量
MVar ()
,使用takeMVar
和putMVar
作为等待和信号。
它们是由 Simon Peyton Jones、Andrew Gordon 和 Sigbjorn Finne 在论文“Concurrent Haskell”中引入的,尽管其实现的一些细节已经发生了变化(特别是,对满 MVar 的放置曾经导致错误,但现在仅仅阻塞。)
适用性
MVars 比 IORefs 提供更多的灵活性,但比 STM 提供的灵活性更少。它们适用于构建同步原语和执行简单的线程间通信;然而,它们非常简单且容易受到竞争条件、死锁或未捕获的异常的影响。如果需要执行更大的原子操作(例如从多个变量读取),请使用“STM”。
特别是,在本模块中的“大”函数(readMVar
,swapMVar
,withMVar
,modifyMVar_
和modifyMVar
)只是一个takeMVar
后跟一个带有异常安全的putMVar
的组合。只有当所有其他线程在putMVar
之前执行takeMVar
时,它们才具有原子性保证;否则,它们可能会阻塞。
公平性
原始论文规定,除非另一个线程无限期地持有该 MVar,否则不能有任何线程在 MVar 上被阻塞。通过以先进先出的方式为阻塞在 MVar 上的线程提供服务,此实现维护了这一公平性质。
注意事项
与许多其他 Haskell 数据结构一样,MVars 是惰性的。这意味着如果您将一个昂贵的未求值的 thunk 放入 MVar 中,它将由消费它的线程求值,而不是产生它的线程。确保将要放入 MVar 中的值评估为适当的正常形式,或者利用由strict-concurrency 包提供的严格 MVar。
示例
考虑以下并发数据结构,跳过通道。这是用于间歇性高带宽信息源(例如,鼠标移动事件)的通道。写入通道永远不会阻塞,从通道读取仅返回最新值,或者如果没有新值则阻塞。支持多个读取器,有一个dupSkipChan
操作。
跳过通道是一对 MVars:第二个 MVar 是特定读取器的信号量:如果通道中有该读取器尚未读取的值,则为满,否则为空。
import Control.Concurrent.MVar
import Control.Concurrent
data SkipChan a = SkipChan (MVar (a, [MVar ()])) (MVar ())
newSkipChan :: IO (SkipChan a)
newSkipChan = do
sem <- newEmptyMVar
main <- newMVar (undefined, [sem])
return (SkipChan main sem)
putSkipChan :: SkipChan a -> a -> IO ()
putSkipChan (SkipChan main _) v = do
(_, sems) <- takeMVar main
putMVar main (v, [])
mapM_ (\sem -> putMVar sem ()) sems
getSkipChan :: SkipChan a -> IO a
getSkipChan (SkipChan main sem) = do
takeMVar sem
(v, sems) <- takeMVar main
putMVar main (v, sem:sems)
return v
dupSkipChan :: SkipChan a -> IO (SkipChan a)
dupSkipChan (SkipChan main _) = do
sem <- newEmptyMVar
(v, sems) <- takeMVar main
putMVar main (v, sem:sems)
return (SkipChan main sem)
该示例改编自原始的 Concurrent Haskell 论文。有关使用 MVars 构建更高级同步原语的更多示例,请参见Control.Concurrent.Chan和Control.Concurrent.QSem。
非德布鲁因术语的 Eq 实例:ezyang 的博客
来源:
blog.ezyang.com/2015/01/an-eq-instance-for-non-de-bruijn-terms/
简短总结 一个非无名术语配备一个指定德布鲁因编号的映射可以支持有效的相等性,而无需辅助函数。更抽象地说,商集不仅适用于证明:它们还可以提高程序的效率。
关键点。 你正在编写一个小编译器,定义表达式如下:
type Var = Int
data Expr = Var Var
| App Expr Expr
| Lam Var Expr
其中Var
来自某个全局唯一的提供。但是当你在共同子表达式消除器上工作时,你发现自己需要定义表达式的相等性。
默认实例不适用,因为它不会说Lam 0 (Var 0)
等于Lam 1 (Var 1)
。你的同事尼古拉斯取笑你说,如果你使用无名表示,默认实例就能起作用,但德布鲁因级数让你头痛,所以你决定尝试自己写一个正确的实例。然而,你遇到了一个困境:
instance Eq Expr where
Var v == Var v' = n == n'
App e1 e2 == App e1' e2' = e1 == e1' && e2 == e2'
Lam v e == Lam v' e' = _what_goes_here
如果v == v'
,事情就简单了:只需检查e == e'
。但如果它们不是... 需要做些什么。一种可能性是在继续之前重命名e'
,但这会导致一个需要二次时间的相等性。你打开了一个著名编译器的源代码,你发现事实上:(1)术语没有 Eq 实例,(2)已定义了一个具有此类型签名的相等函数:
eqTypeX :: RnEnv2 -> Type -> Type -> Bool
其中RnEnv2
是一个包含重命名信息的数据结构:编译器通过延迟任何重命名来避免二次增长。
“好吧,这很棒,”你想,“但我想要我的 Eq 实例,并且我不想转换为德布鲁因级别。”还有什么可以做吗?
或许需要换个角度看问题:
转折点。 尼古拉斯有正确的想法:无名术语表示具有非常自然的相等性,但你定义的类型太大了:它包含许多表达式,它们应该是相等的,但在结构上却不是。但从另一个角度来看,它也太小了。
这里有一个例子。考虑术语x
,它是λx. λy. x
的子术语。这个术语中的x
是自由的;只有通过上下文λx. λy. x
我们才知道它是绑定的。然而,在使用德布鲁因级别(而不是索引——事实证明,在这种情况下级别更方便)的类似情况中,我们有0
,它是λ λ 0
的子术语。我们不仅知道0
是一个自由变量,而且我们还知道它绑定到最外层的 lambda,不管上下文如何。只有x
,我们没有足够的信息!
如果你知道你不知道某事,你应该学习它。如果你的术语对它们的自由变量了解不足,你应该为它们提供必要的知识:
import qualified Data.Map as Map
import Data.Map (Map)
data DeBruijnExpr = D Expr NEnv
type Level = Int
data NEnv = N Level (Map Var Level)
lookupN :: Var -> NEnv -> Maybe Level
lookupN v (N _ m) = Map.lookup v m
extendN :: Var -> NEnv -> NEnv
extendN v (N i m) = N (i+1) (Map.insert v i m)
当你这样做时,事情可能会按你所希望的方式进行:
instance Eq DeBruijnExpr where
D (Var v) n == D (Var v') n' =
case (lookupN v n, lookupN v' n') of
(Just l, Just l') -> l == l'
(Nothing, Nothing) -> v == v'
_ -> False
D (App e1 e2) n == D (App e1' e2') n' =
D e1 n == D e1' n' && D e2 n == D e2' n'
D (Lam v e) n == D (Lam v' e') n' =
D e (extendN v n) == D e' (extendN v' n')
(尽管也许 Coq 在无外援的情况下可能无法判断这个函数是结构递归的。)
练习。 定义一个类型为
DeBruijnExpr -> DeBruijnExpr'
的函数及其逆函数,其中:data DeBruijnExpr' = Var' Var | Bound' Level | Lam' DeBruijnExpr' | App' DeBruijnExpr' DeBruijnExpr'
总结。 我们在这里做了什么?我们通过添加更多信息对一个类型进行了商集化,使其变得更小。通过这样做,我们恢复了一种简单的方式来定义类型上的相等性,而无需定义一个辅助函数、进行额外的转换或者承受二次复杂性能的损失。
有时候,增加信息是获得最小定义的唯一方法。这种情况发生在同伦类型论中,其中等价性必须装备有额外的信息,否则它不是一个单纯的命题(具有错误的同伦类型)。如果您,亲爱的读者,有更多例子,我很乐意在评论中听取。我们经常被告知“少即是多”,简约主义的路线在于去除事物:但有时,真正的路径在于增加约束。
后记. 在 Haskell 中,我们并没有真正地让类型更小:例如,我可以通过投影出底层的 Expr
区分出应该等价的两个表达式。一个合适的类型系统,支持商集,将迫使我证明,如果两个元素在商等价关系下是等价的,那么我的消除函数就不能观察到它。
后记 2. 这种技术有其局限性。以下是一种情况,我还没有找到正确的商集:假设我的表达式类型是这样的,所有自由变量都是隐式全称量化的。也就是说,存在某种量化顺序,对于 a
和 b
,a b
等价于 b a
。有没有办法在不要求在表达式上使用这种商集技术的预处理的情况下,实时地按顺序获取量词?我不知道!
一个不够懒惰的 map:ezyang 博客
另一个常见的 thunk 泄漏源于在容器上映射函数时,并没有严格执行它们的组合函数。通常的修复方法是改用函数的严格版本,比如 foldl'
或 insertWith'
,或者完全使用一个严格版本的结构。在今天的文章中,我们将更仔细地看待这种情况。特别是,我想回答以下几个问题:
示例
我们的例子是一个非常简单的数据结构,即 spine-strict 链表:
data SpineStrictList a = Nil | Cons a !(SpineStrictList a)
ssFromList [] l = l
ssFromList (x:xs) l = ssFromList xs (Cons x l)
ssMap _ Nil l = l
ssMap f (Cons x xs) l = ssMap f xs (Cons (f x) l)
main = do
let l = ssFromList ([1..1000000] :: [Int]) Nil
f x = ssMap permute x Nil
evaluate (f (f (f (f (f (f (f (f l))))))))
permute y = y * 2 + 1
我们首先使用 ssFromList
创建数据结构的一个实例,然后使用 ssMap
对其所有元素进行映射。我们假设列表的结构在语义上并不重要(毕竟,对于用户来说,不透明数据结构中树的分布可能仅仅出于性能原因是没有兴趣的。实际上,每当调用 ssFromList
和 ssMap
时,它们都会反转结构,以避免堆栈溢出)。这里的空间泄漏典型地展示了“非严格容器函数”问题,即像 map
这样的函数看起来无害,实际上会导致问题。
如果你仔细看这个实现,这并不奇怪,基于对 SpineStrictList
的粗略查看:当然会积累 thunk,因为它不严格于值,只对结构本身严格。让我们看看一些解决方法。
修复
Bang-pattern permute. 这个修复方法很诱人,特别是如果你在考虑我们上一个例子:
permute !y = y * 2 + 1
但这是错误的。为什么错呢?首先,我们实际上并没有改变这个函数的语义:y
已经是严格的了!导致的 seq
嵌入表达式太深;我们需要更早地调用 permute y
,而不是 y
。还要记住,上次修复组合函数仅起作用是因为我们成功启用了 GHC 优化,它使元组变成非分配,从而完全避免了它们的分配。然而,在这里行不通,因为我们有一个 GHC 不知道能否摆脱的严格数据结构,所以所有分配总是会发生。
在每次迭代中强制求值结构。 这种方法虽然有效,但相当不优雅且效率低下。实质上,你每次都要遍历一遍,导致最终的运行时间是二次的,仅仅是为了确保所有东西都被评估了。rnf
就像是一个重锤,通常最好避免使用它。
使用 ssMap 的严格版本。 这是一个相当普通的反应,任何改过 foo
函数为 foo'
版本的人都已经尝试过:
ssMap' _ Nil l = l
ssMap' f (Cons x xs) l = ssMap' f xs ((Cons $! f x) l)
剩余的空间使用仅仅是严格的数据结构在内存中的存在。为了修复这个问题,我们必须进入并调整我们SpineStrictList
的内部表示,以引入这种严格性。这是第一个问题的答案:我们无法通过修改组合函数来修复这个空间泄漏,因为我们需要的额外严格性需要“附加”(使用seq
)到数据结构本身的外部构造函数上:这是只有当你能够操作数据结构的内部结构时才能访问到的东西。
这样做的一个好处是,当你喜欢的容器库无法提供你需要的函数的严格版本时,这是相当令人恼火的。事实上,历史上容器包一直存在这个问题,尽管我最近已经提交了一个提案来帮助解决这个问题。
使结构体的值严格。这是将ssMap
转换为其严格版本的“更好”方法,因为惰性模式将为您完成所有的序列化工作:
data StrictList a = Nil | Cons !a !(SpineStrictList a)
当然,如果你真的想要一个脊柱严格但值惰性的列表,这并不是最好的选择。然而,从灵活性的角度来看,完全严格的数据结构确实更加灵活。这是因为你总是可以通过增加额外的间接性来模拟值惰性的版本:
data Lazy a = Lazy a
type SpineStrictList a = StrictList (Lazy a)
现在构造函数Lazy
被强制执行,但其内部未必会。你不能利用延迟数据结构来完成这一技巧,因为你需要所有函数的合作,以便在所有情况下评估容器的内部。然而,这种方法有一个缺点,即额外的包装器在内存和指针间接方面确实会造成成本。
使结构体变得懒惰。有趣的是,如果我们添加了惰性,空间泄漏就消失了:
data SpineStrictList a = Nil | Cons a (SpineStrictList a)
instance NFData a => NFData (SpineStrictList a) where
rnf Nil = ()
rnf (Cons x xs) = rnf x `seq` rnf xs
main = do
let l = ssFromListL ([1..1000000] :: [Int])
f x = ssMapL permute x
evaluate (rnf (f (f (f (f (f (f (f (f l)))))))))
ssFromListL [] = Nil
ssFromListL (x:xs) = Cons x (ssFromListL xs)
ssMapL _ Nil = Nil
ssMapL f (Cons x xs) = Cons (f x) (ssMapL f xs)
我们添加了一个rnf
来确保所有东西实际上都被评估了。事实上,空间使用显著改善了!
发生了什么?技巧在于,因为数据结构是惰性的,我们实际上并没有一次性创建 1000000 个 thunk;相反,我们只在任何给定时间创建表示列表头部和尾部的 thunk。两者远小于一百万,因此内存使用量相应减少。此外,因为在评估完元素后,rnf
不需要保留列表的元素,所以我们能够立即进行垃圾回收。
融合。 如果你移除我们类似列表的数据构造器包装器,并使用内置的列表数据类型,你会发现 GHC 能够将所有的映射合并为一个极快的非装箱操作:
main = do
let l = [1..1000000] :: [Int]
f x = map permute x
evaluate (rnf (f (f (f (f (f (f (f (f l)))))))))
这并不完全公平:我们可以用我们严格的代码做同样的技巧;然而,我们不能使用简单的 foldr/build 融合,因为它对于 foldl(带有累积参数的递归)是无效的。我们也不能将我们的函数转换为 foldr,否则在大输入时会有堆栈溢出的风险(尽管在树状数据结构中可以施加对其脊柱大小的对数界限,这可能是可以接受的)。对我来说,也不清楚脊柱严格性是否会为融合带来任何好处,尽管它在值严格性存在时肯定可以更好地运作。
Sequent Calculus 交互式教程:ezyang 的博客
来源:
blog.ezyang.com/2012/05/an-interactive-tutorial-of-the-sequent-calculus/
Sequent Calculus 交互式教程
你可以在这里查看:Sequent Calculus 交互式教程。这是我在这篇博客文章中提到的“三语系统”。你还可以从这个页面更加开放地使用这个系统。以下是简介:
这个交互式教程将教你如何使用序推理,这是一组简单的规则,你可以用来展示一阶逻辑中陈述的真实性。它面向那些具有一些计算机软件编写背景和基本布尔逻辑知识的人。
开发这个系统是一次非常迷人的探索,涉及用户界面和游戏设计。虽然过去已经构建了类似的系统,但我的目标有些不同:我希望它足够简单和易于访问,以至于任何对这个主题感兴趣的人都可以在一个小时内掌握它,并学到一些关于形式逻辑的有趣内容。我认为这个演示对于数学恐惧症来说可能不会特别成功,但也许对那些对这类事物有固有兴趣的人来说会更成功。我一定是在系统上烦了我的许多麻省理工学院的朋友,他们一直忍受着我对系统反复纠缠的评论。第一个版本看起来非常不同。我非常感谢我的测试用户们。
下一代在线教学系统(edX)引起了很多关注,而这个演示(因为实际上就是这样)旨在探索如何模糊教科书和视频游戏之间的界限。但它确实没有走得太远:它仍然太像一本教科书,早期练习的创造性空间不够。我觉得我没有把一个逐步层层叠加概念的视频游戏的正确感觉。另一方面,我确实做了一些让文本可以快速浏览的工作,并且有很多小细节我认为增强了体验。我很尴尬地承认,有一些功能由于技术上的麻烦而没有包含在内。
如果这个演示背后有一个重要的设计原则,那就是给一个人一张地图和让一个人在城市中闲逛几个小时之间是有区别的。但是,亲爱的读者,你可能没有几个小时,我可能也要求你太多的注意力。尽管如此,请原谅我的无礼,还是,请试一试吧。
《“你本可以发明…” 的解剖:ezyang 的博客》
来源:
blog.ezyang.com/2012/02/anatomy-of-you-could-have-invented/
“你本可以发明…” 的解剖
你本可以发明... 的文章遵循一种特定的方案:
-
简单介绍一个易于理解的问题,
-
尝试解决问题,但卡在显而易见的方式上,
-
简单介绍一个易于理解的见解,
-
方法 ically 解决其余的细节,最终得出最终结果。
为什么以这种方式构建问题有助于解决?
-
虽然步骤 4 中涉及的细节导致结构并不一定显而易见(因此给人一种概念难以理解的印象),但其见解非常易于理解,其余部分只是“苦工”。推导解决方案的方法比解决方案本身更易于压缩,因此更容易学习。
-
选择一个非常具体且易于理解的问题有助于我们在一个具体示例中打下基础,而结果结构可能过于一般化,难以获得良好的直觉。
很重要的是问题要容易理解,“解决细节”过程要简单。否则,展示会感觉刻意。当观众足够聪明,能够直接看到最终结果并在直觉层面理解时,这种方法也不合适。通常是因为他们已经看过示例。但对于我们其他人来说,这是一种非常有效的教学方法。
我将利用这个机会分析两个特定的例子。第一个是丹·皮波尼的经典文章,你本可以发明单子。以下是四个步骤:
-
假设我们想调试纯函数,类型签名为
f :: Float -> Float
,使它们也能返回一个发生了什么的字符串消息,f' :: Float -> (Float, String)
。 -
如果我们想组合这些函数,手动线程状态的显而易见解决方案确实很烦人。
-
我们可以将这种模式抽象为一个高阶函数。
-
我们可以在许多其他例子上做同样的配方,然后显示其泛化。泛化即是一个单子。
第二篇是我的文章,你本可以发明拉链:
-
我想做两件事:访问树中节点的父节点和子节点,并对树进行持久更新(例如,无需变异)。
-
如果我们做显而易见的事情,我们必须更新树中的所有节点。
-
我们只需要翻转一个指针,使其指向父节点。
-
我们可以创建一个新的数据结构来保存这些信息(说实话,有点丑陋),然后展示这个过程的泛化。泛化即是一个拉链。
所以下次当你试图解释看似复杂但实际简单的事物时,请试试这种方法!下次:你本可以发明分数级联。
附录. 这是一种常见的学术论文结构,尽管很少有论文以此为题。然而,一个显著的区别是,通常“细节工作”并不明显,或者需要一些新颖的技术方法。有时,研究人员会发现一种真正重要的技术方法,并且这种方法会在学术界广泛传播,以至于对于任何在该领域工作的人来说都是显而易见的。在某些方面,这正是一个真正成功的论文的特征之一。
Thunk 泄漏的解剖:ezyang 的博客
在本文中,我们讨论了 thunk 泄漏的特征,这种泄漏已经成为“推理空间使用难题”在 Haskell 中的象征。我将考虑几个此类泄漏的例子,并认为这些泄漏实际上是微不足道的修复。相反,困难在于当一个 thunk 泄漏被混淆与其他类型的泄漏时(我们将在后续文章中讨论)。
描述
我将以两种方式描述各种泄漏:首先我将使用我在 Haskell 堆系列中开发的隐喻给出一个非正式的具体描述,然后我将在最后给出更直接、临床的处理。如果你无法忍受一种形式的解释或另一种形式的解释,请随意跳过。
当太多包裹好的礼物(thunk)同时存在时,就会发生 thunk 泄漏。
创造 thunk 并不一定是一件坏事:事实上,大多数 Haskell 程序生成大量的 thunk。有时候堆上存在 thunk 是不可避免的。问题是当它们没有及时评估:就像懒惰的大学生房间里的袜子一样,它们开始堆积起来。
有一个明确的意义,即 thunk “堆积”起来,这可以通过观察幽灵关心的礼物来观察到。
每个幽灵都关心堆叠中的下一个礼物(这样格林奇就不能将它们带走),而我们(用户)关心的是堆叠最底部的礼物。因此,当我们打开那份礼物时,整个礼物链就会倾覆下来(假设没有其他引用指向堆积)。
Thunk 的链条可以是任何你想要的形状,虽然线性是通常情况。
解决问题的方式是什么?显然不是等到礼物堆积起来然后一次清理(就像我们的大学生可能会做的那样):伤害(大内存使用)已经造成了!
相反,我们应该更加渴望并在收到礼物时立即打开它们。
然而,这种策略可能会失败。如果打开礼物导致比起始状态更大的东西,或者如果我们可能不需要打开所有礼物,我们最好还是懒得去做。
此外,还有一个问题,即这些礼物最初是从哪里来的。也许我们最初对于得到这些礼物太急切了...
总之,Thunk 泄漏是指当一个 Haskell 程序积累大量的 thunk 时,如果评估的话,将会导致更小的内存使用。这要求这些 thunk 具有几个属性:
-
它们不得有外部引用(因为 thunk 被评估时,它们的结果可以被垃圾回收),
-
它们必须执行某种减少,而不是创建一个更大的数据结构,而
-
它们应该是必需的。
如果(1)失败,这些未求值表达式很可能是合法的,并且只会产生很小的开销(真正困难的是算法问题)。如果(2)失败,评估所有未求值表达式可能会加剧内存情况。如果(3)失败,您可能正在看到流失败,因为未求值表达式正在急切地创建但懒惰地评估(它们应该也是懒惰地创建)。
举例
我提炼了一些例子来帮助说明所讨论的现象,并直接提供源码级的所有可能修复泄漏的方法。我还将提供一些未泄漏但因为 GHC 足够聪明(为优化欢呼!)而没有泄漏的示例。可运行的代码可以在GitHub 仓库找到,我会尽量保持更新。
首先我们来看看来自简单迭代代码的经典空间泄漏:
main = evaluate (f [1..4000000] (0 :: Int))
f [] c = c
f (x:xs) c = f xs (c + 1)
显而易见的是谁在累积未求值表达式:是 c + 1
。不那么明显的是,当您使用优化编译 GHC 时,此代码实际上并不泄漏。为什么会这样?快速查看 Core 将告诉我们为什么:
Main.$wf =
\ (w_s1OX :: [GHC.Integer.Type.Integer])
(ww_s1P0 :: GHC.Prim.Int#) ->
case w_s1OX of _ {
[] -> ww_s1P0;
: _ xs_a1MR -> Main.$wf xs_a1MR (GHC.Prim.+# ww_s1P0 1)
}
请注意,c
的类型(重命名为 ww_s1P0
)是 GHC.Prim.Int#
,而不是 Int
。由于这是一个原始类型,它是 非懒惰的:无法创建这种类型的未求值表达式。因此,GHC 通过根本不创建它们来避免未求值表达式。修复未优化的情况就像使 c
严格化一样简单,因为整数的加法是一个严格函数。
GHC 通常无法执行此类拆箱优化,因为这可能违反代码的语义。我们的下一段代码正是在研究这样的情况:
main = do
evaluate (f [1..4000000] (0 :: Int, 1 :: Int))
f [] c = c
f (x:xs) c = f xs (tick x c)
tick x (c0, c1) | even x = (c0, c1 + 1)
| otherwise = (c0 + 1, c1)
这个空间泄漏在有优化和无优化的情况下都会发生。它也会导致栈溢出。
GHC 无法通过优化此代码以使得元组的元素被急切地求值,而不改变函数 f
的语义。为什么会这样?我们考虑对 f
的另一种调用:f [1..4000000] (0, undefined)
。函数当前的语义要求结果是 (2000000, undefined)
(因为对 undefined
添加任何东西仍然是 undefined
),这意味着在强制内部元组之前我们无法进行任何求值。如果我们只在需要的时候对元组进行弱标准形式的求值(如 evaluate
调用所做的),或者如果我们只使用第一个结果,那么不应抛出任何异常。如果我们用 undefined
替换 1 :: Int
并运行程序,这确实是情况。
好吧,这就够理论的了,我们如何修复这个错误呢?我可以直接给出一个答案,但如果我们考虑一系列可能的修复方法并分析它们对程序的影响,这可能会更有信息性。希望这将使空间泄漏不再像符文预测那样难以捉摸,而更加有方法论。
在 f
函数中为 c
添加一个严格模式。这个方法行不通:
f [] !c = c
f (x:xs) !c = f xs (tick x c)
我们 这个洞见在于我们并没有改变函数的语义:f l (undefined, undefined)
仍然应该返回 (undefined, undefined)
,因为 seq
并不会“查看元组内部”。然而,添加这个叹号模式可能有助于构建其他解决方案,如果评估元组本身有其他副作用(如我们可能会说,那只鬼会为我们打开一些礼物)。
使元组在 tick 中不可反驳。这只是混乱的:
tick x ~(c0, c1) | even x = (c0, c1 + 1)
| otherwise = (c0 + 1, c1)
不可反驳模式增加了 惰性,而不是严格性,因此问题变得更糟并不令人惊讶(注意内存使用量现在达到了 80M,而不是 40M)。
使 tick 严格。注意 x
已经通过 even x
立即被强制,所以无需在这里添加叹号模式。我们只是在 c0
和 c1
上添加叹号模式:
tick x (!c0, !c1) | even x = (c0, c1 + 1)
| otherwise = (c0 + 1, c1)
这些看起来像是一个糟糕的图,但看看比例。1.2 千字节。一般来说,如果在你修改 Haskell 程序后,开始再次看到很多带状数据,说明你已经修复了泄漏。所以我们已经修复了它!
好吧,不完全是。未经优化的代码仍然有内存泄漏:
通过启用一个 GHC 优化,我们修复了内存泄漏,类似于修复原始内存泄漏的方式。再一次,Core 让这一点变得清楚:
Main.$wf :: [GHC.Integer.Type.Integer]
-> GHC.Types.Int
-> GHC.Types.Int
-> (# GHC.Types.Int, GHC.Types.Int #)
GHC 已经将元组优化为一个无框返回,并内联了对 tick
的调用,因此我们没有任何元组惰性求值浮动在四周。我们本可以手动进行这个优化,但让编译器为我们做更好(并保持代码整洁)。
严格化 tick 和 f。与第一个例子类比,现在 tick
是严格的,如果我们将两个地方都严格化,未经优化的代码也会没问题。果然,没问题。
对于优化后的情况,这并没有太大帮助!(堆内存剖面基本上没有变化。)
使对偶严格。使用严格对偶而不是默认的惰性对偶,相当于在我们对元组进行模式匹配时插入叹号模式。因此,它相当于将 tick
严格化,如果你这样做,在未经优化的情况下你仍然需要一些额外的工作才能让它正常工作。当你控制进入循环的数据结构时,这通常更有效,因为你不需要更改所有的数据声明。
深度 seq c. 如果对 c 的简单叹号模式不起作用,深度叹号模式会起作用:
f [] c = c
f (x:xs) c@(!_,!_) = f xs (tick x c)
或者,你可以使用深度 seq 包中的 rnf
。虽然这确实有效,但我个人认为最好还是使用严格的数据类型,如果你要随便地使用 rnf
,那么最好保持所有东西始终被完全评估。
我还有另一个例子,但今天时间不够了!作为告别的话,注意到元组并不是唯一的提升类型:从记录到单数据构造器(data I a = I a
)再到可变引用,都具有额外的语义,可能带来额外的空间成本。但识别和修复这个特定问题非常容易:堆配置文件非常独特,修复方法简单且非侵入性,甚至可以使用指称语义来帮助分析代码!你所需的只是一点额外的知识。
附言. 对于图表轴线和颜色的变化很抱歉。尽量关注形状和标签。我仍在努力使用hp2pretty
生成正确类型的堆配置文件,并需要更一致的缩放机制和更一致的着色方案。这些实验是在 GHC 6.12.3 上进行的。
MVar
操作解剖:ezyang 的博客
Adam Belay(Dune 的知名人物)最近在思考为什么 Haskell 的 MVar
如此缓慢。“缓慢?”我想,“Haskell 的 MVar
不是应该很快吗?” 所以我研究了一下 MVar
的工作原理,看看能否解释清楚。
让我们考虑在 Control.Concurrent.MVar 中函数 takeMVar
的操作。此函数非常简单,它解包 MVar
以获取基础的 MVar#
原始值,然后调用 primop takeMVar#
:
takeMVar :: MVar a -> IO a
takeMVar (MVar mvar#) = IO $ \ s# -> takeMVar# mvar# s#
Primops 导致在 PrimOps.cmm
中调用 stg_takeMVarzh
,这是魔术发生的地方。为简单起见,我们只考虑多线程情况。
第一步是锁定闭包:
("ptr" info) = ccall lockClosure(mvar "ptr");
在 GHC 堆上的对象具有信息表头,指示它们是什么类型的对象,通过指向对象的相关信息表来实现。这些表头还用于同步:由于它们是字大小的,因此它们可以原子地与其他值交换。lockClosure
实际上是信息表头上的自旋锁:
EXTERN_INLINE StgInfoTable *lockClosure(StgClosure *p)
{
StgWord info;
do {
nat i = 0;
do {
info = xchg((P_)(void *)&p->header.info, (W_)&stg_WHITEHOLE_info);
if (info != (W_)&stg_WHITEHOLE_info) return (StgInfoTable *)info;
} while (++i < SPIN_COUNT);
yieldThread();
} while (1);
}
lockClosure
用于一些其他对象,即线程状态对象(stg_TSO_info
,通过 lockTSO
)和线程消息,即异常(stg_MSG_THROWTO_info
,stg_MSG_NULL_info
)。
下一步是在 MVar
上应用 GC 写屏障:
if (info == stg_MVAR_CLEAN_info) {
ccall dirty_MVAR(BaseReg "ptr", mvar "ptr");
}
正如我之前写过的,由于 MVar
是可变对象,可以变异以指向第 0 代中的对象;因此,当发生变异时,必须通过可变列表将其添加到根集中。由于每个能力都有一个可变对象,这归结为一堆指针修改,并不需要任何同步。请注意,即使我们最终阻塞在其上,我们也需要将 MVar
添加到可变列表中,因为 MVar
是阻塞在其上的 线程(TSO)的保留者!(然而,我怀疑在某些情况下,我们可以不这样做。)
接下来,我们根据 MVar
是否满或空进行分割。如果 MVar
为空,我们需要阻塞线程,直到 MVar
为满:
/* If the MVar is empty, put ourselves on its blocking queue,
* and wait until we're woken up.
*/
if (StgMVar_value(mvar) == stg_END_TSO_QUEUE_closure) {
// We want to put the heap check down here in the slow path,
// but be careful to unlock the closure before returning to
// the RTS if the check fails.
ALLOC_PRIM_WITH_CUSTOM_FAILURE
(SIZEOF_StgMVarTSOQueue,
unlockClosure(mvar, stg_MVAR_DIRTY_info);
GC_PRIM_P(stg_takeMVarzh, mvar));
q = Hp - SIZEOF_StgMVarTSOQueue + WDS(1);
SET_HDR(q, stg_MVAR_TSO_QUEUE_info, CCS_SYSTEM);
StgMVarTSOQueue_link(q) = END_TSO_QUEUE;
StgMVarTSOQueue_tso(q) = CurrentTSO;
if (StgMVar_head(mvar) == stg_END_TSO_QUEUE_closure) {
StgMVar_head(mvar) = q;
} else {
StgMVarTSOQueue_link(StgMVar_tail(mvar)) = q;
ccall recordClosureMutated(MyCapability() "ptr",
StgMVar_tail(mvar));
}
StgTSO__link(CurrentTSO) = q;
StgTSO_block_info(CurrentTSO) = mvar;
StgTSO_why_blocked(CurrentTSO) = BlockedOnMVar::I16;
StgMVar_tail(mvar) = q;
jump stg_block_takemvar(mvar);
}
解码 C-- primop 代码时的一个有用提示是 StgTSO_block_info(...)
及其关联部分是我们如何访问对象字段的。C-- 对 C 结构布局一无所知,因此这些“函数”实际上是由 utils/deriveConstants
生成的宏。阻塞线程包括三个步骤:
-
我们必须将线程添加到附加到 MVar 的阻塞队列中(这就是为什么在 MVar 上阻塞会改变 MVar 的原因!)这包括为链表节点进行堆分配以及变更旧链表尾部。
-
我们必须将线程标记为阻塞状态(
StgTSO
的修改)。 -
我们需要为线程设置一个栈帧,以便线程唤醒时执行正确的操作(即对
stg_block_takemvar
的调用)。这个调用还负责解锁闭包。虽然这里的机制非常复杂,但它并不是这篇博文的重点。
如果 MVar 是满的,则可以从 MVar 中取出值。
/* we got the value... */
val = StgMVar_value(mvar);
但这还不是全部。如果有其他阻塞的 putMVar
在 MVar 上(记住,当线程尝试放置一个已满的 MVar 时,它会阻塞直到 MVar 清空),那么我们应立即解除其中一个线程的阻塞状态,以便 MVar 始终保持满状态:
q = StgMVar_head(mvar);
loop:
if (q == stg_END_TSO_QUEUE_closure) {
/* No further putMVars, MVar is now empty */
StgMVar_value(mvar) = stg_END_TSO_QUEUE_closure;
unlockClosure(mvar, stg_MVAR_DIRTY_info);
return (val);
}
if (StgHeader_info(q) == stg_IND_info ||
StgHeader_info(q) == stg_MSG_NULL_info) {
q = StgInd_indirectee(q);
goto loop;
}
有一件有趣的事情与检查阻塞线程的代码有关,那就是对 indirectees(stg_IND_info
)的检查。在什么情况下,队列对象会被间接替换为间接引用呢?事实证明,当我们从链表中 删除 一个项时会发生这种情况。这非常好,因为在单链表中,除非我们也有指向前一项的指针,否则我们没有简单的方法来删除项。采用这种方案,我们只需用一个间接引用覆盖当前项,以便在下次垃圾回收时进行清理。(顺便说一句,这就是为什么我们不能仅仅直接链起 TSO,而不需要额外的链表节点。[1])
当我们找到其他线程时,立即运行它们,这样 MVar 就永远不会变为空:
// There are putMVar(s) waiting... wake up the first thread on the queue
tso = StgMVarTSOQueue_tso(q);
StgMVar_head(mvar) = StgMVarTSOQueue_link(q);
if (StgMVar_head(mvar) == stg_END_TSO_QUEUE_closure) {
StgMVar_tail(mvar) = stg_END_TSO_QUEUE_closure;
}
ASSERT(StgTSO_why_blocked(tso) == BlockedOnMVar::I16); // note: I16 means this is a 16-bit integer
ASSERT(StgTSO_block_info(tso) == mvar);
// actually perform the putMVar for the thread that we just woke up
W_ stack;
stack = StgTSO_stackobj(tso);
PerformPut(stack, StgMVar_value(mvar));
这里有一个细节:PerformPut
实际上并没有运行线程,它只是查看线程的堆栈以确定它打算 执行 什么。一旦 MVar 被放置,我们就唤醒线程,这样它就可以继续它的工作了。
// indicate that the MVar operation has now completed.
StgTSO__link(tso) = stg_END_TSO_QUEUE_closure;
// no need to mark the TSO dirty, we have only written END_TSO_QUEUE.
ccall tryWakeupThread(MyCapability() "ptr", tso);
unlockClosure(mvar, stg_MVAR_DIRTY_info);
return (val);
总结一下,当你执行 takeMVar
时,你需要付出以下成本:
-
一个自旋锁,
-
大约数十个内存操作(写障碍、队列操作),以及
-
当 MVar 为空时,进行(小)堆分配和栈写入。
亚当和我对此有些困惑,然后意识到循环次数之所以如此之多的原因:我们的数字是关于 往返 的,即使在如此轻量级的同步(和缺乏系统调用)中,当所有事情都说完时,你仍然需要经过调度器,这会增加循环次数。
[1] 曾经并非如此,请参见:
commit f4692220c7cbdadaa633f50eb2b30b59edb30183
Author: Simon Marlow <marlowsd@gmail.com>
Date: Thu Apr 1 09:16:05 2010 +0000
Change the representation of the MVar blocked queue
The list of threads blocked on an MVar is now represented as a list of
separately allocated objects rather than being linked through the TSOs
themselves. This lets us remove a TSO from the list in O(1) time
rather than O(n) time, by marking the list object. Removing this
linear component fixes some pathalogical performance cases where many
threads were blocked on an MVar and became unreachable simultaneously
(nofib/smp/threads007), or when sending an asynchronous exception to a
TSO in a long list of thread blocked on an MVar.
MVar performance has actually improved by a few percent as a result of
this change, slightly to my surprise.
This is the final cleanup in the sequence, which let me remove the old
way of waking up threads (unblockOne(), MSG_WAKEUP) in favour of the
new way (tryWakeupThread and MSG_TRY_WAKEUP, which is idempotent). It
is now the case that only the Capability that owns a TSO may modify
its state (well, almost), and this simplifies various things. More of
the RTS is based on message-passing between Capabilities now.
Android 2.x 传感器模拟器:ezyang 的博客
Android 2.x 传感器模拟器
OpenIntents 有一个很棒的应用程序叫做SensorSimulator,允许您向 Android 应用程序提供加速度计、方向和温度传感器数据。不幸的是,在较新的 Android 2.x 系列设备上表现不佳。特别是:
-
展示给用户的模拟 API 与真实 API 不同。部分原因是原始代码中复制了 Sensor、SensorEvent 和 SensorEventHandler,以解决 Android 没有这些类的公共构造函数的问题,
-
虽然文档声称“无论何时您未连接到模拟器,您将获得真实设备的传感器数据”,但事实并非如此:所有与真实传感器系统接口的代码都被注释掉了。因此,不仅 API 不兼容,而且在您希望变换测试时,必须从一种方式编辑代码到另一种方式。 (代码在处理实际未测试应用程序的边缘条件时表现也很糟糕。)
对于这种现状,我感到相当不满,决定进行修复。借助 Java 反射的力量(咳咳),我将表示方式切换为真实的 Android 对象(在模拟器未连接时有效地消除了所有开销)。幸运的是,Sensor 和 SensorEvent 是小巧的数据导向类,所以我认为我没有对内部表示造成太大影响,尽管随着未来版本的 Android SDK,代码可能会彻底崩溃。也许我应该建议上游开发人员将它们的构造函数设置为公共的。
您可以在这里获取代码:Github 上的 SensorSimulator。如果发现错误,请告知;我只在 Froyo(Android 2.2)上进行了测试。
注释幻灯片:ezyang 的博客
注释幻灯片
一个小技巧供你参考:在生成了幻灯片堆栈并将其打印为 PDF 后,你可能想要用评论来注释幻灯片。这是一个好主意,有几个原因:
-
如果你的幻灯片构造得内容较少,它们可能会被优化用于展示,但并不适合稍后阅读。(“嗯,这里是个图表,我真希望我知道演讲者在这一点上说了什么。”)
-
编写与幻灯片配套的对话是一种无声地练习你的演示的方式!
但是你如何将幻灯片页面与你的注释交叉排列?利用enscript
和pdftk
的强大功能,你可以完全自动地完成这一过程,甚至无需离开终端!以下是具体方法。
-
创建一个“annotations”文本文件(我们将其称为
annot.txt
)。其中包含了与幻灯片配套的文字评论。首先写出解释你的第一张幻灯片的文字,然后插入一个换页(^L
,你可以在 vim 中按C-l
(插入模式)或在 emacs 中按C-q C-l
来实现)。接着写出第二张幻灯片的文字。如此反复。 -
现在,我们希望将此渲染为一个 PDF 文件,并与幻灯片堆栈具有相同的尺寸。找出你的幻灯片尺寸为多少像素,然后编辑你的
~/.enscriptrc
文件,加入以下行:Media: Slide width height llx lly urx ury
其中 ll 表示左下,ur 表示右上:这四个数字表示文字的边界框。这些数字的一个可能组合是:
Media: Slide 576 432 18 17 558 415
现在我们可以调用 enscript 来生成我们注释的一个尺寸合适的 PostScript 文件,使用
enscript annot.txt -p annot.ps -M Slide -B -f Palatino-Roman14
(如果你愿意,可以选择不同的字体。) -
将生成的 PostScript 文件转换为 PDF,使用
ps2pdf annot.ps
。 -
现在,使用 pdftk,我们将分割我们的注释 PDF 和幻灯片 PDF 成为单独的页面,然后将它们合并成一个 PDF。我们可以使用
burst
来输出页面,并建议命名输出文件以便它们正确地交叉排列:mkdir stage pdftk slides.pdf burst output stage/%02da.pdf pdftk annot.pdf burst output stage/%02db.pdf
然后我们将它们合并回来:
pdftk stage/*.pdf cat output annotated-slides.pdf
这是完整的脚本:
#!/bin/sh
set -e
ANNOT="$1"
SLIDES="$2"
OUTPUT="$3"
if [ -z "$3" ]
then
echo "usage: $0 annot.txt slides.pdf output.pdf"
exit 1
fi
TMPDIR="$(mktemp -d)"
enscript "$ANNOT" -p "$ANNOT.ps" -M Slide -B -f Palatino-Roman14
ps2pdf "$ANNOT.ps" "$ANNOT.pdf"
pdftk "$SLIDES" burst output "$TMPDIR/%03da.pdf"
pdftk "$ANNOT.pdf" burst output "$TMPDIR/%03db.pdf"
pdftk "$TMPDIR"/*.pdf cat output "$OUTPUT"
rm -Rf "$TMPDIR"
不要忘记在你的.enscriptrc
文件中定义Slide
,并愉快地进行注释吧!
宣布 cabal new-build:Nix 风格的本地构建:ezyang 的博客
来源:
blog.ezyang.com/2016/05/announcing-cabal-new-build-nix-style-local-builds/
cabal new-build
,也称为“Nix 风格的本地构建”,是受 Nix 启发的一个新命令,随 cabal-install 1.24 一同发布。Nix 风格的本地构建结合了非沙盒化和沙盒化 Cabal 的优点:
-
像今天的沙盒化 Cabal 一样,我们以确定性和独立于任何全局状态的方式构建一组独立的本地包。
new-build
永远不会告诉你,它不能构建你的包,因为这将导致“危险的重新安装”。在特定状态的 Hackage 索引下,你的构建是完全可重复的。例如,你不再需要预先使用性能分析编译包;只需请求性能分析,new-build
将自动重新构建所有依赖项以进行性能分析。 -
像今天的非沙盒化 Cabal 一样,外部包的构建全局缓存,因此一个包只需构建一次,然后可以在任何其他使用它的地方重复使用。不需要在每次创建新沙盒时不断重新构建依赖项:可以共享的依赖项会被共享。
Nix 风格的本地构建与 cabal-install 1.24 支持的所有 GHC 版本兼容,目前包括 GHC 7.0 及更高版本。此外,cabal-install 的发布周期与 GHC 不同,因此我们计划以比 GHC 每年一次的发布周期更快的速度推送 bug 修复和更新。
尽管此功能目前仅处于测试版(存在一些 bug,请参见“已知问题”,并且文档有点稀少),但我一直成功地使用 Nix 风格的本地构建来进行我的 Haskell 开发。难以形容我对这一新功能的热情:它“只是有效”,而且你不需要假设有一个版本固定的、经过认可的包分发来构建(例如 Stackage)。最终,new-build
将简单地取代现有的 build
命令。
快速入门
Nix 风格的本地构建“只是有效”:几乎不需要任何配置即可开始使用它。
-
下载并安装 cabal-install 1.24:
cabal update cabal install cabal-install
确保新安装的
cabal
已添加到你的路径中。 -
要构建单个 Cabal 包,不需要运行
cabal configure; cabal build
,可以通过在这些命令前加上new-
来使用 Nix 风格的构建;例如cabal new-configure; cabal new-build
。cabal new-repl
也受支持。(不幸的是,其他命令尚未支持,例如new-clean
(#2957) 或new-freeze
(#2996)。) -
要构建多个 Cabal 包,需要首先在某个根目录下创建
cabal.project
文件。例如,在 Cabal 存储库中,有一个根目录,其中每个包都有一个文件夹,例如,文件夹Cabal
和cabal-install
。然后在cabal.project
中,指定每个文件夹:packages: Cabal/ cabal-install/
然后,在包的目录中,您可以使用
cabal new-build
来构建该包中的所有组件;或者,您可以指定要构建的目标列表,例如,package-tests cabal
要求构建package-tests
测试套件和cabal
可执行文件。组件可以从任何目录构建;您不必 cd 到包含要构建的包的目录中。此外,您可以按其来自的包合格目标,例如Cabal:package-tests
具体要求从 Cabal 获取package-tests
组件。无需手动配置沙盒:添加cabal.project
文件,它就可以工作了!
不像沙盒,无需 add-source
;只需将包目录添加到您的 cabal.project
中。而且与传统的 cabal install
不同,无需显式请求安装包;new-build
将自动获取并构建依赖项。
还有一个方便的脚本,您可以用它来连接 new-build
到您的Travis 构建。
工作原理
Nix 风格的本地构建采用了以下两个重要的思想:
-
对于外部包(来自 Hackage),在编译之前,我们将会影响包编译的所有输入(标志,依赖选择等)进行哈希处理,生成一个标识符。就像在 Nix 中一样,这些哈希唯一地标识构建的结果;如果我们计算出此标识符,并发现我们已经构建了这个 ID,我们可以直接使用已经构建好的版本。这些包存储在全局的
~/.cabal/store
中;您可以使用ghc-pkg list --package-db=$HOME/.cabal/store/ghc-VERSION/package.db
列出所有全局可用的 Nix 包。 -
对于本地包,我们使用
inplace
标识符,例如foo-0.1-inplace
,这是针对给定cabal.project
的本地包。这些包存储在本地的dist-newstyle/build
目录中;您可以使用ghc-pkg list --package-db=dist-newstyle/packagedb
列出所有按项目分组的包。这种处理方式适用于任何依赖于本地包的远程包(例如,如果您嵌入某些依赖项,而其他依赖项依赖于它们)。
此外,Nix 的本地构建采用确定性依赖解决策略,通过独立于本地安装包进行依赖解决。一旦我们解决了要使用的版本,并确定了编译过程中将使用的所有标志,我们生成标识符,然后检查是否可以改进我们需要构建的包,使其成为已经在数据库中的包。
命令
new-configure FLAGS
基于 FLAGS 覆盖 cabal.project.local
。
new-build [FLAGS] [COMPONENTS]
构建一个或多个组件,自动构建任何本地和非本地依赖项(本地依赖项是指我们在开发过程中可以修改的现有源代码目录)。不具有本地包的传递依赖关系的非本地依赖项安装到 ~/.cabal/store
,而所有其他依赖项安装到 dist-newstyle
。
从 cabal.project
中读取本地包的集合;如果不存在,则假定默认项目包括本地目录中的所有 Cabal 文件(即 packages: *.cabal
),以及每个子目录中的可选包(即 optional-packages: */*.cabal
)。
本地 包的构建配置是通过以下来源读取标志来计算的(后续来源具有优先级):
-
~/.cabal/config
-
cabal.project
-
cabal.project.local
(通常由new-configure
生成) -
命令行的
FLAGS
非本地包的配置只受这些来源中特定于包的标志的影响;全局选项不适用于构建。(例如,如果 --disable-optimization
,则仅适用于本地的 inplace 包,而不适用于它们的远程依赖项。)
new-build
不从 cabal.config
中读取配置。
短语手册
这里是一个便捷的短语手册,说明如何使用 Nix 本地构建来执行现有的 Cabal 命令:
old-style | new-style |
---|---|
cabal configure |
cabal new-configure |
cabal build |
cabal new-build |
cabal clean |
rm -rf dist-newstyle cabal.project.local |
cabal run EXECUTABLE |
cabal new-build; ./dist-newstyle/build/PACKAGE-VERSION/build/EXECUTABLE/EXECUTABLE |
cabal repl |
cabal new-repl |
cabal test TEST |
cabal new-build; ./dist-newstyle/build/PACKAGE-VERSION/build/TEST/TEST |
cabal benchmark BENCH |
cabal new-build; ./dist-newstyle/build/PACKAGE-VERSION/build/BENCH/BENCH |
cabal haddock |
目前不存在 |
cabal freeze |
目前不存在 |
cabal install --only-dependencies |
不必要的(由 new-build 处理) |
cabal install |
目前不存在(对于库,new-build 应该足够;对于可执行文件,它们可以在 ~/.cabal/store/ghc-GHCVER/PACKAGE-VERSION-HASH/bin 中找到) |
cabal.project 文件
cabal.project
文件实际上支持多种选项,用于配置构建的详细信息。以下是一个简单的示例文件,展示了一些可能性:
-- For every subdirectory, build all Cabal files
-- (project files support multiple Cabal files in a directory)
packages: */*.cabal
-- Use this compiler
with-compiler: /opt/ghc/8.0.1/bin/ghc
-- Constrain versions of dependencies in the following way
constraints: cryptohash < 0.11.8
-- Do not build benchmarks for any local packages
benchmarks: False
-- Build with profiling
profiling: true
-- Suppose that you are developing Cabal and cabal-install,
-- and your local copy of Cabal is newer than the
-- distributed hackage-security allows in its bounds: you
-- can selective relax hackage-security's version bound.
allow-newer: hackage-security:Cabal
-- Settings can be applied per-package
package cryptohash
-- For the build of cryptohash, instrument all functions
-- with a cost center (normally, you want this to be
-- applied on a per-package basis, as otherwise you would
-- get too much information.)
profiling-detail: all-functions
-- Disable optimization for this package
optimization: False
-- Pass these flags to GHC when building
ghc-options: -fno-state-hack
package bytestring
-- And bytestring will be built with the integer-simple
-- flag turned off.
flags: -integer-simple
运行 cabal new-configure
时,它会输出一个 cabal.project.local
文件,其中保存了从命令行输入的额外配置选项;如果想知道如何将命令行参数转换为 cabal.project
文件,请运行 new-configure
并检查输出。
已知问题
作为技术预览,这段代码仍然有些粗糙。以下是一些可能遇到的更重要的问题:
-
虽然依赖关系解析是确定性的,如果使用
cabal update
更新你的 Hackage 索引,依赖关系解析也会改变。没有cabal new-freeze
,所以你必须手动构建所需约束的集合。 -
new-build 的一个新功能是,当包没有变化时,它避免重新构建包,通过跟踪它们内容的哈希值。然而,这种依赖跟踪不是百分之百准确(具体来说,它依赖于你的 Cabal 文件准确地报告所有文件依赖项,就像
sdist
,并且不知道搜索路径)。目前没有 UI 强制重新编译一个包;不过你可以通过删除适当的缓存文件相对容易地诱发重新编译:特别是对于名为p-1.0
的包,删除文件dist-newstyle/build/p-1.0/cache/build
。 -
在 Mac OS X 上,Haskell 平台,你可能会收到“警告:'hackage.haskell.org' 的包列表不存在。运行 'cabal update' 下载它。”这是问题 #3392;查看链接的票证以获取解决方法。
如果你遇到其他 bug,请在Cabal 的问题跟踪器上告诉我们。
AP 物理:被困在具体中:ezyang's blog
来源:
blog.ezyang.com/2010/06/ap-physics-stuck-in-the-concrete/
注意保留通知。作者回忆在高中学习物理的经历,并声称教学往往过于专注于具体公式,而忽视其背后的统一理论。
在小学时,你可能学过 D=RT(发音为“dirt”),即距离等于速率乘以时间。这基本上是谎言,但没关系,因为无论这个方程式与现实世界的联系多么脆弱,老师们都可以用它来介绍代数操作的概念,即仅凭 D=RT,你也可以找出 R,如果你知道 D 和 T,也可以找出 T,如果你知道 D 和 R。除非你异常聪明,否则你不会知道自己被欺骗了;你只是学会了解决他们给你的应用题。
快进到高中物理。谎言仍在传播,尽管服饰略有不同:“位置等于速度乘以时间”,他们说。但然后会说到关键的限定语:“这个方程适用于匀速运动。”然后你会被介绍到你的朋友均匀加速度,还会有另一个方程供你使用,到你结束为期一个月的运动单元时,你会有一大堆方程和变量需要跟踪。
CollegeBoard 的 AP 物理继续这一良好传统,正如他们的方程式表所述:
隐含的预期是学生知道每个方程式的含义,并且还有意外的效果,即训练学生在何时使用方程式以及哪些值对应于哪些变量。
我更喜欢这种表述:
有了这两个方程式,我可以运用微积分,深入探讨位置、速度和加速度之间的核心关系:其中一个仅仅是前一个的导数。这些方程是完全通用的(不仅适用于非匀速运动,还适用于任意维度的向量),简洁而优雅。但从计算的角度来看,它们并不立即有用。
是否有一个比另一个更有价值?它们各有优点:第一组更有可能帮助你计算苹果从建筑物上落下需要多长时间,忽略空气阻力。但第二组更有可能帮助你真正理解运动,而不仅仅是一系列代数操作。
直到我在麻省理工学院上了高级古典力学课程才学到这些。由于某种原因,流行的是停留在具体公式中,而不是教授其背后的理论。即使是 AP 物理也不例外:即使声称更具分析性的 AP 物理 C,也会在其公式表上填满前一组方程。
大多数学生在中学时期大部分时间都在学习如何进行代数运算。在修完物理课程之后,他们可能会进入完全不涉及物理的职业,所有那些演习的训练都将被浪费。他们应该得到更好的待遇,而不是被灌输更多的代数运算;他们应该知道古典力学的优雅和简洁。
附言。 对于阅读本博客的程序员来说,可以随意将自己的类比画到你的工艺上;我相信科学的其他领域在抽象主题上有很多话要说。对于阅读本博客的准 MIT 学生来说,你可能听说过 8.012 和 8.022 很难。这是真的;但同样真实的是,这一对课程已经吸引了许多本科生进入物理系。我对这一对课程的推荐无比推崇。
应用函子:ezyang 的博客
关于主要来源的重要性。
(前言资料。)这篇博客的大多数读者应该至少对适用函子有所了解:
class Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
这个接口非常方便日常编程(特别是,它使得漂亮的f <$> a <*> b <*> c
习语变得容易),但它遵守的法律非常糟糕:
[identity] pure id <*> v = v
[composition] pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
[homomorphism] pure f <*> pure x = pure (f x)
[interchange] u <*> pure y = pure ($ y) <*> u
所以,如果你(像我在二十四小时前一样)还没有看过它,你应该展示这个接口等价于 Applicative:
class Functor f => Monoidal f where
unit :: f ()
(**) :: f a -> f b -> f (a,b)
(顺便说一句,如果你还没有证明对于单子join :: m (m a) -> m a
等价于bind :: m a -> (a -> m b) -> m b
,你也应该这样做。)这种表述的定律要好得多:
[naturality] fmap (f *** g) (u ** v) = fmap f u ** fmap g v
[left identity] unit ** v ≅ v
[right identity] u ** unit ≅ u
[associativity] u ** (v ** w) ≅ (u ** v) ** w
其中 f *** g = \(x,y) -> (f x, g y)
。我稍微美化了一下,通过使用“等同于”来抑制 ((), a)
和 a
之间的差异,以及 (a,(b,c))
和 ((a,b),c)
之间的差异,对于严格的相等性,你需要一些额外的函数来将结果转换为正确的类型。看起来有一个一般模式,即具有良好法律表述的 API 并不方便编程,而良好编程的表述则没有良好的法律。C’est la vie...但至少它们是等价的!
有了这种表述,可以轻松地陈述交换适用函子遵循的法律:
[commutativity] u ** v ≅ v ** u
原始论文使用效果的适用性编程非常值得一读。快去看看吧!这就是本次公共服务通知的结束。
Arcadia Rising 海报:ezyang 的博客
Arcadia Rising 海报
作为刺客公会的财务,我经常不得不向 GMs 乞求和恳求,以获取他们各自游戏的海报,因为 UA Finboard 规定,要获得活动资金,必须提供海报。
对于 Lauren Gust 在 Arcadia 海报上的工作,我感到非常惊讶、惊叹和印象深刻。构图简单而有效,绝对增添了游戏的氛围。点击查看更大版本,并可以获取Lauren Gust 的公开的海报的完整尺寸 PDF。要了解海报背后的更多背景信息,请查看《Arcadia Rising 情景》。
艺术。代码。数学。(以及 mit-scheme):ezyang 的博客
我今天在排练中,作为第二管簧管演奏圣桑的管风琴交响曲,这已经是第 n 次了,突然想到:我已经听过并演奏了这首音乐足够多的次数,以至于知道整体流程和大部分管弦乐部分,不仅仅是我的部分。因此,当圣歌的呼叫为最后一乐章的管风琴的胜利入场让路,或者当速度开始变化,同时加快和减慢,在曲末时,这并不令人惊讶;几乎是不可避免的。不能有其他方式。
但我们本来可以另有他法;圣桑本可以决定移动第二乐章或引入另一个主题或任何其他多种变化。但他创作了这首曲子,唯独这首曲子,这就是被奉为美的东西。
这让我想起了我计算可计算性问题集上的第一个问题,它要求我展示宇宙的一个基本真理(好吧,在数学哲学家的界限内);不可协商的、不动的、普遍的。或者我写的程序,当然是一个创造性的过程,但通过需求和规范牢固地锚定在具体的领域。那些数学家和程序员需要多么创造性才能设计出优雅的证明和程序,然而他们离艺术家还有多远。
不合逻辑的推论。MIT/GNU Scheme 在你运行它时喜欢冒出大量额外的横幅垃圾,即使你实际上并不想使用交互式 REPL,只是运行一些 mit-scheme 代码。事实证明,mit-scheme 的维护者做出了以下决定:
过去,我(CPH)对于稳定版本的政策是在发布之前必须更新文档。实际上,这意味着近年来没有稳定版本。从这个版本开始,我们将不再将更新后的文档视为稳定版本的先决条件。
哎,什么?
无论如何,这里有一个名为--batch-mode
的奇妙未记录选项,可以抑制入口消息。然而,在 7.7.90 版本(Ubuntu Karmic 的默认版本,别试图自己编译;你需要 mit-scheme 来编译 mit-scheme),它并不能抑制“Loading…”消息,所以你需要用以下小技巧来调用 load:
# run-scheme LOAD EVAL
# LOAD - Scheme file to load
# EVAL - Scheme expression to evaluate
run-scheme() {
# --batch-mode doesn't work completely on mit-scheme 7.7.90, in
# particular it fails to suppress Loading... messages. As a result,
# we require this elaborate work-around.
mit-scheme --batch-mode --eval \
"(begin (set! load/suppress-loading-message? #t) \
(load \"$1\") $2)" </dev/null
}
简而言之,有点令人失望。
Association maps in mit-scheme : ezyang’s blog
MIT Scheme 中的关联映射
我最近在 MIT Scheme 中对持久化数据结构进行了一些基准测试,作为我的 UROP 项目。我们对几个问题感兴趣:
-
对于什么样的关联大小,更高级的数据结构能够超越普通的关联列表?
-
持久性的代价是多少?也就是说,持久化数据结构与普通的哈希表相比慢了多少倍?
-
什么是最佳的持久化数据结构?
这些结果并非权威;我仍需仔细检查测试和代码的正确性。但它们已经具有一些有趣的含义,因此我想分享一下。
所有实现都使用eq?
进行键比较。
不出所料,assoc 击败了其他所有数据结构,因为它只需做一个简单的 cons 操作。然而,有些奇怪的高峰出现在固定的间隔,我不确定它们的起源,可能是垃圾收集器在起作用。
当然,你通过简单的关联操作支付廉价的更新,具有线性查找时间;这个故事同样适用于权衡树,它们具有快速插入但最慢的查找。
当键不存在时,Hamt 确实效率很高,甚至在 15 个元素左右也超过了哈希表。
可以在scheme-hamt repository找到运行基准测试的源代码、我们自己开发的实现以及图表。
音频连接器与无线电:ezyang 的博客
寒假期间,我购买了一台Yaesu VX-8R,这是 Yaesu 最新的型号,也是VX-7R的后继者,受到 MIT 社区许多人的青睐。决定购买这台特定的收音机并不容易:购买(更便宜的)VX-7R 意味着我可以利用已经围绕这一特定型号形成的庞大知识库。但是我父亲愿意多出 50 美元购买更新版本,因此我决定尝试一下。
然而,这并不是本文的重点(对 VX-8R 的评论将等到我真正亲自使用它才能发表):这项练习真正令人困惑的部分是弄清楚购买哪些配件以获得麦克风耳机。如果听起来有些模糊,那是因为确实如此。Yaesu 的官方配件——强调蓝牙的——缺乏标准的有线耳机。经过一番调查,并与 Kelsey 进行了非常信息丰富的交谈后,这是我找到的情况。
首先,一些词汇。如果你曾经将耳机插入计算机,你一定熟悉TRS 连接器,也被称为音频或耳机插孔。这是外露的锥形插孔,并包含多个导体(黑色环分隔它们)。你所拥有的立体声扬声器的连接器可能有三个导体:一个接地,一个左声道,一个右声道。TRS 连接器在尺寸和导体数量上有各种各样的类型。对于无线电(以及更普遍地可以使用按键式(PTT)耳机的设备),我们对直径为 3.5 毫米(1/8 英寸)且具有四个导体的 TRS 连接器感兴趣:一个接地,一个音频,一个麦克风和一个 PTT。
DIN 连接器有多个由金属屏蔽保护的引脚。DIN 连接器的引脚对应导体,并且引脚分配因应用而异常见有三到八个引脚(超过四个导体 TRS 连接器非常罕见)。DIN 连接器具有标准尺寸(13.2 毫米),但引脚分配因应用而异。
现在,说到实际的无线电设备。我们先从 VX-7R 开始,因为 VX-8R 的插孔比 7R 的更强大。VX-7R 采用四导体 3.5mm TRS 连接器,但有一个小变化:它特别设计为防水,因此为了更紧密地连接,你需要一个带有插孔后螺丝的特殊 TRS 连接器。CT-91 就是这样一款 TRS 连接器,它将连接分成一个三导体 3.5mm 耳机 TRS 插头和一个三导体 2.5mm 麦克风 TRS 插头;这些显然是标准插孔,因此你可以找到各种耳机以及单独的耳塞和 PTT 麦克风。(注:我家里没有任何闲置的,也没机会去无线电店,所以这些只是听说。)
另一方面,VX-8R 支持 GPS,因此不能仅仅使用四导体:相反,它采用 8 引脚 DNS 插头,这在实质上是专有的。你可以连接 GPS 单元(CT-136 和 FGPS-2),但通常需要$30 的连接器 CT-131,将其转换为四导体 3.5mm TRS 插孔。这与 VX-7R 上的 TRS 插头相同,但没有防水螺旋部分。要拆分它,你可以使用 CT-91,但螺丝部分会显示出来,为了更紧密的连接,你将不得不购买他们建议的 CT-44。
我们找到了一个四导体耳机插头,但它不起作用;就像我尝试的杂乱的三导体立体声耳机一样,插入后能听到声音信号,但会导致 PTT 持续激活。目前的理论是立体声可能干扰了一些东西。
我想了解以下几点:
-
VX-8R 有一个独立的立体声耳机插孔,所以我很好奇如果我将 PTT 麦克风插入四导体插孔会发生什么。如果两者奇迹般兼容,这意味着不需要额外的适配器。考虑到分线器建议使用 2.5mm 麦克风,而四导体插头是 3.5mm,这似乎不太可能。
-
CT-131 和 CT-91 组成了一种看起来有点不靠谱的连接,我不确定在实际操作中这是否会成为问题,或者我是否需要用电子胶带把它们粘在一起。这里需要一些现场测试,我也很好奇购买或制作四导体到 2.5mm PTT 麦克风适配器有多难。
-
我需要找到剑桥/波士顿附近的商店,可以测试各种按键对讲麦克风。任何建议将不胜感激!
Backpack 和独立编译:ezyang 的博客
来源:
blog.ezyang.com/2016/09/backpack-and-separate-compilation/
Backpack 和独立编译
在构建一个支持在多个实现之间参数化代码(即 functors)的模块系统时,你会遇到一个重要的实现问题:如何编译这些参数化的代码?在现有的语言实现中,有三种主要的思路:
-
独立编译学派认为,你应该独立编译你的 functors,而不管它们的具体实现。这个学派更重视编译时间而非性能:一旦一个 functor 被构建,你可以自由地替换其参数的实现,而无需重新构建 functor,从而实现快速的编译时间。Pre-Flambda 的 OCaml 就是这种方式。缺点是,无法基于实现的知识对 functor 主体进行优化(除非你可能有一个即时编译器可用)。
-
使用时专门化学派说,你可以通过在已知实现的使用点内联 functors 来获得性能提升。如果 functor 主体不太大,你可以在不需要大幅度改变编译器架构的情况下透明地获得良好的性能效益。Post-FLambda OCaml 和 C++ 模板在Borland 模型中都是这样工作的。缺点是,代码必须在每个使用点重新优化,并且可能会存在大量的代码重复(这可以在链接时减少)。
-
专门化仓库学派认为,不断重新编译实例化是愚蠢的:相反,每个实例化的编译代码应该被缓存到某个全局位置;下次需要相同实例时,应该重用它。C++ 中的模板在 Cfront 模型和 Backpack 中都是这样工作的。
仓库视角听起来不错,直到你意识到这需要对编译器的工作方式进行重大的架构更改:大多数编译器不尝试将中间结果写入某些共享缓存中,而添加对此的支持可能会非常复杂且容易出错。
Backpack 通过将实例化的缓存工作外包给包管理器来规避这个问题,后者确实知道如何缓存中间产品。这种折衷是,Backpack 并没有像某些人希望的那样完全整合到 Haskell 本身中(它极其不是第一类)。
背包和 PVP:ezyang 的博客
在 PVP 中,如果向一个模块添加函数,则增加次要版本号;如果从一个模块中移除函数,则增加主要版本号。直观地说,这是因为添加函数是向后兼容的更改,而删除函数是破坏性的更改;更正式地说,如果新接口是旧接口的子类型,则只需要增加次要版本号。
Backpack 给混合添加了一个新的复杂性:签名。向签名添加/删除函数的 PVP 政策应该是什么?如果我们将具有必需签名的包解释为一个函数,理论告诉我们答案:签名是逆变的,因此添加必需函数是破坏性的(增加主要版本号),而删除必需函数是向后兼容的(增加次要版本号)。
然而,故事并没有结束。签名可以重复使用,即一个包可以定义一个签名,然后另一个包可以重用该签名:
unit sigs where
signature A where
x :: Bool
unit p where
dependency sigs[A=<A>]
module B where
import A
z = x
在上面的例子中,我们将一个签名放在 sigs 单元中,p 通过对 sigs 声明依赖项来使用它。B 可以访问 sigs 中由 A 定义的所有声明。
但这里有一些非常奇怪的地方:如果 sigs 曾经删除了 x 的声明,p 将会中断(x 将不再在作用域内)。在这种情况下,上述的 PVP 规则是错误的:p 必须始终对 sigs 声明一个精确的版本边界,因为任何添加或删除都将是破坏性的更改。
所以我们处于这种奇怪的情况中:
-
如果我们包含一个依赖项和一个签名,但我们从未使用过该签名的任何声明,我们可以对依赖项指定一个宽松的版本边界,允许它从签名中删除声明(使签名更容易实现)。
-
然而,如果我们导入签名并使用其中的任何内容,我们必须指定一个精确的边界,因为现在删除操作将是破坏性的更改。
我认为不应该期望 Backpack 的最终用户能够自行正确地理解这一点,因此 GHC(在这个 提议的补丁集 中)试图通过向仅来自可能已被指定为宽松边界的包的声明附加此类警告来帮助用户。
foo.bkp:9:11: warning: [-Wdeprecations]
In the use of ‘x’ (imported from A):
"Inherited requirements from non-signature libraries
(libraries with modules) should not be used, as this
mode of use is not compatible with PVP-style version
bounds. Instead, copy the declaration to the local
hsig file or move the signature to a library of its
own and add that library as a dependency."
更新。 在发布这篇文章后,我们最终删除了这个错误,因为它在与 PVP 兼容的情况下触发了。(详细信息:如果一个模块重新导出了一个来自签名的实体,那么来自该模块的实体使用将会触发错误,这是由于过时通知的工作方式。)
当然,GHC 对边界一无所知,所以我们使用的启发式方法是,如果一个包不暴露任何模块,则认为它是一个签名包,具有精确的边界。像这样的包只通过导入其签名才有用,所以我们从不对这种情况发出警告。我们保守地假设暴露模块的包可能受到 PVP 风格的边界约束,因此在这种情况下会发出警告,例如:
unit q where
signature A where
x :: Bool
module M where -- Module!
unit p where
dependency q[A=<A>]
module B where
import A
z = x
正如警告所示,可以通过在 p 中明确指定x :: Bool
来修复这个错误,这样,即使 q 移除其要求,也不会导致代码破坏:
unit q where
signature A where
x :: Bool
module M where -- Module!
unit p where
dependency q[A=<A>]
signature A where
x :: Bool
module B where
import A
z = x
或者将签名放入自己的新库中(就像原始示例中的情况一样)。
这个解决方案并不完美,因为仍然有一些方法可以使你以 PVP 不兼容的方式依赖继承的签名。最明显的是与类型相关的情况。在下面的代码中,我们依赖于 q 的签名强制 T 类型等于 Bool 的事实:
unit q where
signature A where
type T = Bool
x :: T
module Q where
unit p where
dependency q[A=<A>]
signature A where
data T
x :: T
module P where
import A
y = x :: Bool
原则上,q 可以放宽对 T 的要求,允许其实现为任何形式(而不仅仅是 Bool 的同义词),但这一变更将破坏 P 中对 x 的使用。不幸的是,在这种情况下并没有简单的方法来发出警告。
也许一个更有原则的方法是禁止来自非签名包的签名导入。然而,在我看来,这样做会使 Backpack 模型变得更加复杂,而这并没有很好的理由(毕竟,总有一天我们会用签名增强版本号,那将是辉煌的,对吧?)
总结一下。 如果你想重用来自签名包的签名,请在该包上指定一个精确的版本边界。如果你使用的组件是参数化的签名,不要导入和使用这些签名的声明;如果你这样做,GHC 会警告你。
深度学习的背包:ezyang 的博客
这是由阮开曦撰写的客座文章。
背包 是 Haskell 的一个模块系统,最近在 GHC 8.2.1 中发布。作为一项新功能,我想知道人们如何使用它。因此,我每天在 Twitter 上搜索,前些天看到了 这条推文:
除了 String/Bytestring/Text 外,还有其他的示例吗?到目前为止我还没有看到任何其他示例;看起来背包只是用来赞美的字符串洞。
有一些 良好的回应,但我想给出另一个来自深度学习的用例。
在深度学习中,人们对在 张量 上进行计算感兴趣。张量可以具有不同的值类型:整数、浮点数、双精度等。此外,张量计算可以在 CPU 或 GPU 上进行。尽管有许多不同类型的张量,但每种类型的张量计算是相同的,即它们共享相同的接口。由于背包允许您针对可以有多个实现的单个接口进行编程,因此它是实现张量库的理想工具。
Torch 是一个广泛使用的用于深度学习的库,用 C 实现。亚当·帕什克有一篇关于 Torch 的好文章 文章。我们可以为 Torch 编写一些 Haskell 绑定,然后使用背包在浮点和整数张量之间切换实现。这是一个通过背包签名使用张量的程序:
unit torch-indef where
signature Tensor where
import Data.Int
data Tensor
data AccReal
instance Show AccReal
instance Num AccReal
read1dFile :: FilePath -> Int64 -> IO Tensor
dot :: Tensor -> Tensor -> IO AccReal
sumall :: Tensor -> IO AccReal
module App where
import Tensor
app = do
x <- read1dFile "x" 10
y <- read1dFile "y" 10
d <- dot x y
s <- sumall x
print (d + s)
return ()
我们有一个简单的主函数,从文件中读取两个一维张量,计算两者的点积,对第一个张量的所有条目求和,然后最后打印出这两个值的和。(这个程序是从亚当的文章中转录的,不同之处在于亚当的程序使用浮点张量,而我们保持张量类型抽象,因此使用背包可以同时处理浮点数和整数)。程序使用像点积这样的函数,在签名中定义。
这里是 dot
的实现以及浮点张量的类型。使用 Haskell 的 FFI 调用 C 函数:
import Foreign
import Foreign.C.Types
import Foreign.C.String
import Foreign.ForeignPtr
foreign import ccall "THTensorMath.h THFloatTensor_dot"
c_THFloatTensor_dot :: (Ptr CTHFloatTensor) -> (Ptr CTHFloatTensor) -> IO CDouble
type Tensor = FloatTensor
type AccReal = Double
dot :: Tensor -> Tensor -> IO AccReal
dot (FT f) (FT g) = withForeignPtr f $ \x ->
withForeignPtr g $ \y -> do
d <- c_THFloatTensor_dot x y
return (realToFrac d)
正如您所见,背包可以用于构建一个深度学习库,该库具有多个不同类型操作的实现。如果您为 Torch 中的所有函数编写绑定,您将拥有一个用于 Haskell 的深度学习库;通过背包,您可以轻松编写对张量类型和处理单元(CPU 或 GPU)不可知的模型。
你可以在 GitHub 上找到完整的示例代码。
香蕉、透镜、信封和铁丝网——翻译指南:ezyang's 博客
来源:
blog.ezyang.com/2010/05/bananas-lenses-envelopes-and-barbed-wire-a-translation-guide/
自从夏天开始以来,我一直在缓慢地重新阅读一篇论文,这篇论文是 Erik Meijer、Maarten Fokkinga 和 Ross Paterson 的《香蕉、透镜、信封和铁丝网的函数式编程》。如果你想知道 {cata,ana,hylo,para}morphisms 是什么,这篇论文是必读的:第二节为所爱的链表提供了一个非常易读的形式化定义。
然而上次,当他们开始讨论代数数据类型时,我的眼睛有点发直,尽管我在 Haskell 中已经使用和定义了它们;部分原因是我感到自己淹没在三角形、圆形和波浪线的海洋中,当他们讨论基本组合子的定律时,我甚至可能会说:“这全都是数学!”
更仔细地阅读揭示了实际情况,所有这些代数运算符都可以用简单的 Haskell 语言书写出来,对于那些在 Haskell 中已经有一些时间的人来说,这可以提供更流畅(尽管更冗长)的阅读体验。因此,我呈现这份翻译指南。
类型运算符。 按照惯例,类型用 表示在左边,而 a, b, c...
表示在右边。我们将其与函数运算符区分开来,尽管本文没有并且依赖于惯例来区分这两者。
Bifunctor t => t a b
Functor f => f a
[a]
(d, d')
Either d d'
Identity
Const d
(Functor f, Functor g) => g (f a)
(Bifunctor t, Functor f, Functor g) => Lift t f g a
()
(对于学究们来说,你需要在所有的 Bifunctors 后面加上 Hask Hask Hask
。)
函数运算符。 按照惯例,函数用 表示在左边,而 f :: a -> b, g :: a' -> b', h...
表示在右边(类型根据需要统一)。
bimap f g :: Bifunctor t => t a a' -> t b b'
fmap f :: Functor f => f a -> f b
f *** g :: (a, a') -> (b, b')
where f *** g = \(x, x') -> (f x, g x')
fst :: (a, b) -> a
snd :: (a, b) -> b
f &&& g :: a -> (b, b') -- a = a'
where f &&& g = \x -> (f x, g x)
double :: a -> (a, a)
where double x = (x, x)
asum f g :: Either a a' -> Either b b'
where asum f g (Left x) = Left (f x)
asum f g (Right y) = Right (g y)
Left :: a -> Either a b
Right :: b -> Either a b
either f g :: Either a a' -> b -- b = b'
extract x :: a
where extract (Left x) = x
extract (Right x) = x
(f --> g) h = g . h . f
(-->) :: (a' -> a) -> (b -> b') -> (a -> b) -> a' -> b'
(g <-- f) h = g . h . f
(<--) :: (b -> b') -> (a' -> a) -> (a -> b) -> a' -> b'
(g <-*- f) h = g . fmap h . f
(<-*-) :: Functor f => (f b -> b') -> (a' -> f a) -> (a -> b) -> a' -> b'
id f :: a -> b
const id f :: a -> a
(fmap . fmap) x
const ()
fix f
现在,让我们来看看 abides law:
被翻译成 Haskell 后,这一句是:
either (f &&& g) (h &&& j) = (either f h) &&& (either g j)
对我来说(至少是这样)更有意义:如果我想从 Either 中提取一个值,然后对其运行两个函数并返回结果的元组,我也可以立即将该值分成一个元组,并使用不同的函数从 either 中“两次”提取值。(尝试手动在 Left x
和 Right y
上运行该函数。)
成为专家被认为是有害的:ezyang 的博客
在你的高级符号编程课上是一个晴朗的一天。你的老师刚开始讲授单子——虽然是在 Scheme 语言中——而你坐在教室后排,嘲笑你从 Haskell 语言知道的一些小知识片段。突然间,老师很认真地说,“爱德华似乎对单子知识很多。为什么不让他上来教给大家呢?”突然间,你站起来向从未使用过 Haskell 语言的人们解释类型,却完全无法向他们解释延续单子的工作原理。经过数次尝试之后,你终于部分重写了演示文稿,不再假设人们已经精通 Haskell 语言。你已经陷入了专家陷阱。
你是一个专家。你拥有深入的知识,积累了智慧和直觉,在你的领域内比其他人能更有效地工作。你可能有点自负;你可能会和其他专家激烈辩论。或者你可能非常谦逊和深思熟虑;你的专业知识与你的自我无关。
但是,除非你注意到你假设的先决知识,否则你在教授你的专业领域时会表现糟糕。你的专业知识阻碍了有效教学,因为专家假设了太多的先决知识。
当我谈到先修知识时,我并不是指先决的“事实”——例如迭代算法解决线性方程,使用折叠函数反转列表,或者在我最喜欢的框架中如何操作 X。我指的是基础知识:抽象和高阶原语用于思考——比如线性代数,减少高阶运算符和框架的架构。一个回答“如何”,另一个回答“为什么”。
所有的工程和数学都在不断寻找正确的抽象来解决问题。当你将问题放在正确的表现形式中时,最显著的变化也许就是它变得更简洁、更易于在更高层次上操作。不足为奇,牛顿需要发明微积分来发展他的物理思想。今天我们构建的高级编程语言和系统,在纯汇编语言或硅中是无法想象的。
找到并理解正确的抽象概念就是启蒙:它使得困难变得简单,不可能的事情变得可能。曾经需要一页才能计算的内容现在可以简洁地用一句话描述。冗长系统的结构被编码成抽象,留下问题的关键部分。对程序也可以说类似的事情:在高级语言出现之前,汇编程序可以放在几页纸上,并且可以被单个程序员理解。必须如此。 现代软件发展已经远远超出这个阶段。
在这两种情况下,专家会看到这种新的表述,并立即理解。初学者,也许对这种编码有所了解但不熟练,现在必须重新探索基础(或者冒着使用简单但错误的前提的风险四处碰壁)。
你可能会说,“嗯,这不是专家的问题;他们只是没有先修条件!一旦他们掌握了基础,我将教他们这个主题。” 这是不能接受的。 确实,正规教育可以使他们熟悉抽象的基本原语和关系;它尤其有效地清除了错误的概念。但是,专家对于抽象的熟悉程度只有在你花时间“在战壕中”,使用和应用抽象到更大的问题时才会出现。
你可能会说,“我并不那么刻薄;我也会教授先修课程。” 你甚至可能期望能够向听众传授知识!不要自欺欺人。除了简单的主题(简单的解决方案已足以阐明的主题),如果你仅仅向他们讲授,他们不会理解。教学只是做事的路线图,是真正理解任何难题的唯一途径。
你应该说的是,“我只是初学者理解的工具之一。我寻求精确地照亮初学者没有想到的地方。” 实际上,有一个实现这一目标的简单方法:强迫初学者去教!他们将从一个非常有限和不清晰的概念模型开始:在理解的许多道路中,只有一条是他们知道的。他们将详细解释所有错误的细节以及你的隐含知识。他们会被问到问题,这些问题将迫使他们澄清对这条路的理解。最终,他们将对这条路的知识感到自信,如果他们继续学习,这条路将扩展到包括许多道路,不同的理解路径。初学者已经成为专家。但是,正如佛陀可能会说的那样,他们必须发现启蒙。老师只是向他们展示路径。
Haskell 堆上的绑定和 CAF:ezyang's 博客
来源:
blog.ezyang.com/2011/05/bindings-and-cafs-on-the-haskell-heap/
今天,我们讨论 Haskell 堆上的礼物如何命名,无论是通过顶层绑定、let 绑定还是参数。我们介绍了表达式现有等效交换,这突显了表达式在 Haskell 堆上也是延迟绑定。最后,我们解释了这种函数内的 let 绑定如何导致创建更多礼物,而不是常量应用形式(CAF),后者从执行的开始就存在于 Haskell 堆上。
当我们描绘 Haskell 堆上的礼物时,它们通常有名称。
我们一直对这些名称的来源有些保密。部分原因是因为大多数这些名称的来源很简单:它们只是 Haskell 程序中的顶层绑定:
y = 1
maxDiameter = 100
我们还有一些名称作为函数参数绑定的绑定名称。我们在讨论函数时也已经讨论过这些内容。您将一个标签插入到机器中,该标签是幽灵知道 x
的“真实”位置的方式:
f x = x + 3
pred = \x -> x == 2
所以如果我写 f maxDiameter
,幽灵就知道无论在哪里看到 x
,它都应该寻找 maxDiameter
。但这个解释还有些漏洞。如果我写 f (x + 2)
,x + 2
的标签是什么?
一种看待这个问题的方式是用不同的方式重写这个函数:let z = x + 2 in f z
,其中z
是一个新的变量:在表达式中没有其他地方出现过的变量。所以,只要我们理解了let
的作用,我们就理解了紧凑的f (x + 2)
的作用。我称之为表达式现有等效交换。
但 let
究竟是做什么呢?
有时,完全与顶层绑定的工作相同。这些是常量应用形式(CAF)。
因此,我们只需将变量提升到全局堆中,为其赋予一些唯一名称,然后它就像原始情况一样。我们甚至不需要在对函数的后续调用中重新评估它。再次强调,关键区别在于自由变量(请参见帖子底部的术语表):常量应用形式没有自由变量,而我们编写的大多数 let
绑定都有自由变量。
术语表。即使您从未学过λ演算,自由变量的定义也非常有用。一个表达式的自由变量是指通过查看表达式无法得知其值的变量。在表达式
x + y
中,x
和y
是自由变量。它们被称为自由变量,因为λ“捕获”它们:\x -> x
中的x
不是自由的,因为它是由λ\x ->
定义的。形式上:fv(x) = {x} fv(e1 e2) = fv(e1) `union` fv(e2) fv(\x -> e1) = fv(e1) - {x}
如果我们有自由变量,事情就会变得有些棘手。所以这里有一个扩展漫画,解释了当你强制执行一个延迟绑定时会发生什么。
注意幽灵如何传递自由变量。当一个 thunk 保留未评估时,看重的是它的自由变量,因为这些是其他也保留未评估的 thunk。还值得重申的是,函数始终使用礼物的标签,而不是实际未打开的礼物本身。
规则非常简单,但交互可能会很复杂!
技术注释。 在编写严格的迷你语言时,一个常见的技巧是,在实现let
时认识到它实际上是 lambda 应用的语法糖:let x = y in z
与(\x -> z) y
相同。但这在惰性语言中不起作用:如果y
引用x
怎么办?在这种情况下,我们有一个递归的 let 绑定,通常需要使用特殊的let-rec
构造,需要一些变异。但在惰性语言中很容易:进行绑定永远不会评估等式的右侧,所以我可以随意设置每个变量。我还选择以相反的方式进行演示,因为我希望人们始终考虑名称。CAFs 没有名称,但从所有目的来看,它们是会被共享的全局数据,因此给它们命名是有用的,特别是在尝试调试与 CAF 相关的空间泄漏时。
或许f (x + 2)
的更精确的翻译是f (let y = x + 2 in y)
,但我觉得那看起来有点奇怪。抱歉。
此作品根据知识共享署名-相同方式共享 3.0 未本地化许可协议许可。
比特币并非去中心化:ezyang 的博客
比特币由中本聪设计,主要客户端由一群来自bitcoin.org的人开发。你在乎这些人是谁吗?理论上来说,你不应该在乎:他们所做的一切只是为开源协议开发一个开源客户端。任何人都可以开发自己的客户端(有些人已经这样做了),除了比特币网络中每个人的一致同意,没有人能改变协议。这是因为比特币网络被设计成去中心化的。
如果你相信比特币的长期可行性,你应该关心这些人是谁。虽然比特币本身是去中心化的,但从比特币到新货币的过渡不能是去中心化的。这一过渡的发生是由所有密码系统最终变得过时这一事实所保证的。谁将决定如何构建这种新货币?很可能是比特币的原始创造者,如果你在比特币中拥有重要的持有,你应该关心这些人是谁。
以下的文章将更仔细地阐述这一论点,如下:
-
包括加密哈希在内的密码系统必须在最终被替换的前提下使用。有人可能会争辩说,“如果比特币的加密学被破解,金融行业的其余部分也会陷入麻烦” — 我们解释了为什么这对比特币是不相关的。我们还看到,如果比特币成为一个严肃的货币,合理地预期它将存在足够长的时间来发生这种过时。
-
比特币社区流传着几种粗糙的过渡计划。我们描述了最常见的分散化和最常见的集中化变体,并解释了为什么分散化变体不能以非破坏性方式运行,同时吸引了经济学和具有类似属性的现有市场。
-
我们更加仔细地审视这些分散化和集中化转变的影响,并评估与比特币作为新兴货币面临的其他风险相比,转变的风险。我们建议,虽然比特币的转变并不是一个核心关注点,但天真的去中心化的观念是一个需要打破的神话。
我已将这篇文章分成几个部分,以便那些对特定论点感兴趣的读者随意跳跃阅读。
密码系统的时限炸弹
“所有加密系统最终会变得过时。” 与货币相比,加密哈希是一个相对较新的发明,只能追溯到 20 世纪 70 年代。MD5 是 1991 年发明的,仅花了大约十五年的时间就彻底被攻破了。对于计算机程序员来说,加密技术的变化是不可避免的,并且系统设计时考虑到了这一点。例如,考虑一下用于安全互联网交易(包括金融交易)的 SSL 证书。这些证书需要定期更新,随着新证书的颁发,可以增加它们的保护级别,使用更新的密码或更长的密钥长度。大多数当前使用的加密技术遵循这种模式:密码和密钥可以相对容易地替换。
然而,比特币是特殊的。它实现去中心化的方式是将所有相关的技术细节嵌入到协议中。其中包括哈希算法 SHA-256。在比特币中“改变”哈希算法是完全不可能的;任何变动都会构成协议的变动,因此会导致一个全新的货币。不要相信任何告诉你相反的人。论点“如果比特币的加密被破解,其他金融行业也会陷入困境”是无关紧要的,因为其他金融机构可以控制它们使用的密码,并且可以轻松地更改它们:比特币却不能。由于 SHA-1 的弱点可能会影响 SHA-2 系列(其中 SHA-256 是一员),因此 SHA-3 的竞赛已经在进行中。
欺诈交易是否会持续足够长时间以变得实际?也许不会(毕竟,该货币可能在到达此阶段之前被许多其他潜在问题杀死)。然而,如果它确实变得建立起来,你可以期待它会是一个顽强的小家伙。货币会长期存在。
去中心化和中心化货币的过渡
比特币社区已经意识到转变将是必要的事实,尽管普遍的感觉是“我们到那时再想办法”,也有一些模糊的提议被浮出水面。冒着制造草人的风险,我现在想呈现我对两种最广泛提出的计划的看法。首先是去中心化计划:
由于加密系统不会一夜之间崩溃,一旦对 SHA-256 的关注达到足够高的程度,我们将创建一个使用更强加密哈希的新版本比特币。然后,我们将让市场决定这两种货币之间的汇率,并允许人们从一种货币转移到另一种货币。
这是分散的,因为任何人都可以提出一种新的货币:市场将决定最终哪一种会胜出。它也不可能以非破坏性的方式运作,因为任何想要将旧比特币兑换为新比特币的人都必须找到愿意购买的买家,而在某些时候,超级通胀将确保没有愿意购买的买家。所有现有的比特币将变得毫无价值。
此时,我们将稍作停留,进入中国的月饼黑市场,这是一个非常引人入胜的“货币”,与即将过时的比特币有很多相似之处。这个市场的前提是,虽然给予现金贿赂是非法的,但是赠送月饼券是合法的。因此,想要贿赂某人的人可以简单地“赠送”给他们一个月饼券,然后将其在黑市上出售,转换回现金。
参与月饼黑市的人必须小心,因为一旦中秋节到来,所有这些券必须兑换成月饼或变得毫无价值。随着日期的临近,你会看到对越来越贬值的券进行越来越疯狂的烫手山芋游戏。输家?他们最终会拥有大量的月饼。当然,有一个关键的区别,那就是比特币游戏的输家最终一无所有。
这是一个过渡吗?是的。它会造成混乱吗?绝对是。这当然不是你希望用于日常交易的货币做的事情。当然,对一些行业来说,这可能是可以接受的风险,我们将在最后一节中进一步分析这一点。
这是集中计划:
一旦对哈希算法的担忧达到足够高的程度,我们将创建一个新的比特币协议。这个协议不仅包括一个新的哈希算法,还基于某个日期的旧比特币经济价值:在那一点上,所有新的交易在新的比特币方案中都是无效的,并且使用该快照来确定每个人拥有的比特币数量。
还有一种变体,涉及到在他们设法切换之前对哈希算法进行主动攻击的情况,其中包括将特定的区块链标记为已知的良好区块链,并清除疑似的欺诈交易。
这个计划真的是集中的吗?是的:有人需要设计新的协议,说服所有客户接受它,并在新经济到来时统一切换到新的协议。比特币经济的分裂将会极大地破坏,对任何主要参与者都不利。比特币协议的任何其他更改(到那时可能会有很多提议)都可能对比特币经济产生重大影响。
影响和风险
在这里,我们评估了一个问题,“我真的在乎吗?” 短期内,不在乎。比特币有许多,许多弱点将被检验。虽然我个人希望它会成功(它无疑是一个从未进行过的伟大实验),但我的评估是它的机会并不乐观。过度担心过渡并不是明智的时间利用方式。
然而,这并不意味着这不是一个重要的事实需要记住。比特币的未来取决于那些将设计其继任者的人。如果您在比特币上投入了大量资金,至少应该考虑谁拥有下一个王国的钥匙。更为紧迫的问题是比特币客户端的单一文化的影响(某人可能会推出一个更新,调整协议以达到不良目的)。使用比特币的人应尽快多样化其客户端。您应极度怀疑那些使他人能够将您的客户端从协议的一个版本翻转到另一个版本的更新。尽可能保持协议的不可变性,因为没有它,比特币根本不是去中心化的。
感谢 Nelson Elhage,Kevin Riggle,Shae Erisson 和 Russell O’Connor 阅读并评论本文草稿。
更新. 与主题无关的评论将会被严格审查。你已经被警告了。
更新二. 在 Hacker News 和 Reddit 的讨论中出现了一个可能的第三继任计划,即分散化的自启动货币。基本上,多种货币竞争入驻和采纳,但与仅通过汇率分开的两种完全独立的货币不同,这些货币在某种程度上被固定在旧比特币货币上(也许它们拒绝在某个日期之后的所有比特币交易,或者它们要求进行某种破坏性操作才能将旧比特币转换为新比特币——后者可能存在安全漏洞)。我没有分析过这种情况下的经济情况,我鼓励其他人接手。我的直觉是,它仍然会带来破坏性影响;也许会更多,因为这些货币的人为固定。
责备树:ezyang 的博客
责备树
我刚在第 13 届算法与数据结构研讨会上介绍了责备树。责备树是一种功能性数据结构,通过融入关于结构任意部分“责备”的信息(类似于git blame
),支持高效的合并操作。这是一篇理论论文,因此常数因子并不理想,但渐近性能比现代版本控制系统中使用的传统合并算法要好得多。
这是与大卫·A·威尔逊、帕维尔·潘切哈和埃里克·D·德迈恩共同完成的工作。你可以查看论文,或查看幻灯片。 我还有一份稍早版本的演讲录像在YouTube (20 minutes),我用它来从外地合作者那里获取反馈,然后才真正做演讲。还要感谢大卫·马兹雷斯亲自对演示稿提出有用的评论。
BlockedIndefinitelyOnMVar:ezyang 的博客
本文摘自我发表在 glasgow-haskell-users 列表中的一篇帖子。
根据Control.Exception,BlockedIndefinitelyOnMVar
异常(以及相关的BlockedIndefinitelyOnSTM
异常)在“线程被阻塞在 MVar 上,但没有其他引用指向 MVar,因此它永远无法继续执行”时被抛出。描述实际上相当精确,但容易被误解。要完全理解此异常的工作原理,需要一些额外来自Control.Concurrent的文档,以及对 Haskell 的绿色线程与 GHC 垃圾收集机制的直觉感受。
这是一个试金石测试:您能预测这三个程序会做什么吗?
main1 = do
lock <- newMVar ()
forkIO $ takeMVar lock
forkIO $ takeMVar lock
threadDelay 1000 -- let threads run
performGC -- trigger exception
threadDelay 1000
main2 = do
lock <- newEmptyMVar
complete <- newEmptyMVar
forkIO $ takeMVar lock `finally` putMVar complete ()
takeMVar complete
main3 = do
lock <- newEmptyMVar
forkIO $ takeMVar lock `finally` putMVar lock ()
let loop = do
b <- isEmptyMVar lock
if b
then yield >> performGC >> loop
else return ()
loop
不要偷看。要获取提示,请查看forkIO的文档。
第一个程序没有输出,尽管threadDelay
表面上让两个分叉线程都得以调度、运行并发生死锁。实际上,会引发BlockedIndefinitelyOnMVar
,而您没有看到的原因是forkIO
安装了一个异常处理程序,使该异常静音,以及BlockedIndefinitelyOnSTM
和ThreadKilled
。您可以使用catch
及其相关方法安装自己的异常处理程序。
这个程序末尾有一组有趣的额外咒语,确保线程高概率调度,并且抛出BlockedIndefinitelyOnMVar
异常。请注意,只有在“没有其他引用指向 MVar”时才会抛出该异常。由于 Haskell 是一种垃圾收集语言,它仅在垃圾回收发生时才会发现引用已消失,因此在看到这些错误之前,您需要确保发生其中一个垃圾回收。
这意味着的一个推论是,GHC 不会神奇地知道要将异常抛给哪个线程来“解开”程序的死锁状态:相反,它会将BlockedIndefinitelyOnMVar
异常抛给所有死锁线程,包括(如果适用)主线程。这种行为在第二个程序中得到了展示,该程序由于主线程获取了异常的副本而以BlockedIndefinitelyOnMVar
终止,尽管子线程的finally
处理程序本来会解决死锁。尝试将最后一行替换为takeMVar complete `catch` \BlockedIndefinitelyOnMVar -> takeMVar complete >> putStrLn "done"
。这真的很滑稽。
最后一个程序考虑了 MVar
被“可达”是什么意思。由于它在死锁时是沉默的,这必须意味着 MVar
仍然可达;确实,我们的引用 isEmptyMVar
防止了 MVar
从未变为死的,并且因此我们无限循环,即使 MVar
没有可能被填充。GHC 只知道如果没有引用指向它,线程就可以被视为垃圾(这会导致异常被抛出)。谁持有线程的引用?MVar
,因为线程 阻塞 在这个数据结构上,并将自身添加到该阻塞列表中。谁保持 MVar
存活?嗯,我们的闭包中包含一个对 isEmptyMVar
的调用。所以线程保持存在。一般规则如下:如果一个线程被阻塞在一个可从非阻塞线程访问的 MVar
上,那么该线程就会一直存在。虽然有一些明显的情况(GHC 无法处理),其中 MVar
显然已经死了,即使还有引用指向它,但是在一般情况下找出这一点是不可判定的。(练习:编写一个程序来解决停机问题,如果 GHC 能够在一般情况下弄清楚这一点。)
总结起来,没有一点工作(顺便说一句,看起来会很有趣),BlockedIndefinitelyOnMVar
并不是一个显而易见的机制,用来给你的 Haskell 程序提供死锁保护。相反,你被邀请把它看作是一种垃圾收集那些本来会无休止地闲置下去的线程的方法:默认情况下,一个死锁的线程是沉默的(除了内存使用方面)。事实上,异常的出现是方便的,从操作的角度来看,但不应依赖这一点。
Blog name changed… : ezyang 的博客
博客名称已更改…
…因为我不再住在 245s 号的房间了。是的。 😃
这是一头牛。它们在卡姆河旁边啃草。
小测验。矩阵链乘法、最长公共子序列、最优二叉搜索树的构造、双峰欧几里德旅行推销员问题、编辑距离和维特比算法有什么共同之处?
缓冲流和迭代器:ezyang 的博客
在试图找出如何更深入地解释惰性与严格字节串而不会让我的读者半途而废时,我偶然发现了在命令式语言中缓冲流的标准实现与函数式语言中的迭代器之间存在的一个有趣的对比。
没有自重的输入/输出机制会缺少缓冲。通过将读取或写入操作分组以便作为单个单元执行,缓冲可以提高效率。在 C 中,一个简单的读取缓冲区可以像这样实现(当然,使用静态变量封装到数据结构中...并且对read
中的错误条件进行适当处理...):
static char buffer[512];
static int pos = 0;
static int end = 0;
static int fd = 0;
int readChar() {
if (pos >= end && feedBuf() == 0) {
return EOF;
}
return (int) buffer[pos++];
}
int feedBuf() {
pos = 0;
end = read(fd, buffer, sizeof(buffer));
assert(end != -1);
return end;
}
导出的接口是readChar
,每次用户调用时提供一个转换为int
的单个char
,但在幕后仅在缓冲区耗尽时实际从输入中读取(pos >= end
)。
对于大多数应用程序来说,这已经足够好了:底层行为的粗糙性被一个简单而明了的函数隐藏起来。此外,我们的函数并不过于简单:如果我们将所有标准输入读入一个巨大的缓冲区中,直到EOF
到来之前我们将无法做任何其他事情。在这里,我们可以在输入到来时做出反应。
在纯函数设置中,这样一组函数会是什么样子呢?一个明显的困难是buffer
被重复地突变,因为我们执行读取操作。在持久性的精神中,我们应该尽量避免在最初填充之后对缓冲区进行变异。使缓冲区持久化意味着我们在读取更多数据时也可以保存数据而不必复制它(你可以称之为零拷贝)。我们可以使用一些简单的方式将缓冲区链接在一起:比如说,一个链表。
链表?让我们查看惰性和严格字节字符串的定义(稍作编辑,以适应你,读者):
data Strict.ByteString = PS !(ForeignPtr Word8) !Int !Int
data Lazy.ByteString = Empty | Chunk !Strict.ByteString Lazy.ByteString
在 C 中,这些将是:
struct strict_bytestring {
char *pChar;
int offset;
int length;
}
struct lazy_bytestring {
struct strict_bytestring *cur;
int forced;
union {
struct lazy_bytestring *next;
void (*thunk)(struct lazy_bytestring*);
}
}
严格字节串只不过是一个精心设计的、内存管理的缓冲区:两个整数跟踪偏移量和长度。在持久性存在的情况下,选择偏移量是一个特别好的选择:从字符串中取子串不再需要复制:只需创建一个新的严格字节串,适当设置偏移量和长度,并使用相同的基指针。
那么什么是Lazy.ByteString
呢?嗯,它是一种显赫的懒惰严格字节串的惰性链表——只需将Chunk
理解为Cons
,将Empty
理解为Null
:惰性源自于对Chunk
的第二个参数的非严格性(注意没有感叹号,这是一种严格性注释)。这种惰性是我们在lazy_bytestring
结构中有thunk
联合和forced
布尔值的原因:当调用时,此 API 将新的lazy_bytestring
scribble 到函数指针上(这与 GHC 的工作方式非常相似;少了一层或更多的间接层)。如果忽略惰性,这听起来有点像我们之前描述的缓冲区链表。
然而,有一个重要的区别。Lazy.ByteString
是纯的:我们无法调用原始的read
函数(一个系统调用,这使得它几乎是最 IO 的操作)。因此,当我们有一些纯计算(比如马尔可夫过程)可以生成无限量的文本时,懒惰字节串是合适的选择,但在缓冲输入方面则显得不足。
“没问题!”你可能会说,“只需将数据类型更改为持有IO Lazy.ByteString
而不是Lazy.ByteString
即可:
data IO.ByteString = Empty | Chunk !Strict.ByteString (IO IO.ByteString)
但是这种数据类型有些问题:没有人阻止多次调用IO IO.ByteString
。事实上,将 IO 操作放在Chunk
值中没有任何意义:由于文件描述符的状态性质,每次都是相同的代码:hReadByteString handle
。我们又回到基于句柄的 IO。
IO.ByteString
作为列表的想法是一个重要的直觉。关键的洞察力在于:谁说我们必须将 IO 操作的列表提供给用户?相反,倒转控制,使得用户不再调用迭代器:迭代器调用用户并将 IO 的结果返回给用户。用户反过来可以启动其他 IO 操作,或将迭代器组合在一起(我们还没有讨论过),以从一个迭代器流向另一个。
此时,我推荐参考 Oleg 的优秀注释幻灯片(PDF)进一步解释迭代器(不是开玩笑,幻灯片写得非常好),以及多种迭代器 教程。我希望对由 IO 操作生成的“缓冲区链表”进行重视,引起对迭代器本质的注意:这是一个在 IO 操作列表之上的抽象。
总结一下:
-
使用严格的字节串作为构建更有趣的结构的原语,这些结构具有缓冲区(尽管避免重新实现惰性字节串或迭代器)。当数据量较小、全部可以一次性初始化或随机访问、切片和其他非线性访问模式很重要时,请使用它们。
-
使用lazy bytestrings作为表示纯计算生成的无限数据流的机制。考虑在执行主要适合于惰性列表的操作(
concat
、append
、reverse
等)时使用它们。尽管模块上写着,避免在惰性 IO 时使用它们。 -
使用iteratees表示可以逐步处理的来自 IO 源的数据:这通常意味着大型数据集。迭代器特别适合多层逐步处理:它们可以自动且安全地“融合”处理。
Bug boogie: Git 和符号链接 : ezyang’s blog
Git 对你的文件非常小心:除非你明确告诉它要进行破坏性操作,否则它将拒绝覆盖它不认识的文件,并显示如下错误:
未跟踪的工作树文件'foobar'将被合并覆盖。
在我的工作中,经常需要对Wizard上那些“维护不良”的工作副本执行合并操作,例如,在旧目录上解压了新版本的源代码树,却忘记添加新加入的文件。当 Wizard 尝试以正确方式自动升级它们到新版本时,这将导致各种未跟踪的工作树文件投诉,然后我们必须手动检查这些未跟踪的文件,并在它们正常后移除它们。
对此有一个简单的解决方法:虽然我们不想将所有未跟踪的文件添加到 Git 仓库中,但我们可以只添加那些可能会被覆盖的文件。Git 将停止对这些文件的投诉,并且我们仍然可以在历史记录中找到它们的记录:
def get_file_set(rev):
return set(shell.eval("git", "ls-tree", "-r", "--name-only", rev).split("\n"))
old_files = get_file_set("HEAD")
new_files = get_file_set(self.version)
added_files = new_files - old_files
for f in added_files:
if os.path.lexists(f): # *
shell.call("git", "add", f)
以前,代码中的星号行为 if os.path.exists(f)
。你能猜到这有什么错误吗?回想一下 exists
和 lexists
之间的区别;如果涉及的文件是符号链接,exists
会跟随它,而 lexists
则不会。因此,如果将要被覆盖的文件是一个损坏的符号链接,旧版本的代码将不会将其删除。在许多情况下,你无法区分这些情况:如果文件符号链接指向的父目录存在,我可以通过符号链接创建一个文件,以及其他正常的“文件操作”。
然而,Git 非常清楚符号链接和普通文件之间的区别,并且如果它将会覆盖一个符号链接,它会相应地投诉。保留了这些好的老信息!
附言。 昨天是我在 Galois 工作的第一天!如此令人兴奋,以至于我没能整理思绪写一篇博客。敬请期待更多。
Bugs and Battleships:ezyang 的博客
你还记得你的第一个计算机程序吗?当你完成编写它时,你做的第一件事是什么?你进行了最简单的可能测试:你运行了它。
随着程序规模的增加,可能测试的数量也在增加。值得考虑的是我们实际运行了哪些测试:想象一下儿童游戏“战舰”,其中海洋是所有可能程序执行的空间,战舰是你要寻找的 bug,每一颗发射的导弹就是你运行的一个测试(如果测试通过则为白色,失败则为红色)。你没有无限的导弹,所以你必须决定将它们发送到哪里。
关于“你的第一个计算机程序”的情况,答案似乎很明显:只有一种方式来运行程序,只有少数几种测试情况。
但这种幻想很快就会被真实软件的遭遇所打破。即使你的程序没有输入,硬件、操作系统、开发环境和其他环境因素也会立即增加测试空间。添加显式输入和不确定性到应用程序中,你会看到一个游泳池和海洋之间的差异。
我们如何决定测试什么?我们的策略是什么——我们在哪里发送更多导弹,哪里发送更少?不同的测试策略导致在所有可能执行的空间上的不同测试分布。即使我们在编写测试或在整个系统中运行集成测试时可能没有考虑测试用例的分布,不同的测试策略也会导致不同的覆盖范围。
例如,你可能决定不进行任何测试,并依赖用户向你报告 bug。结果是,你的应用程序在经常使用的区域具有较高的覆盖率,在很少使用的区域覆盖率较低。在某种意义上,当你有一个愿意容忍失败的大用户群体时,这是一种最优策略——尽管任何在不寻常情况下使用软件时遇到 bug 的人可能会持不同意见!
对于回归测试有不同的想法,即你为过去发生过的任何 bug 添加一个自动测试。与将覆盖面集中在经常使用的区域不同,回归测试套件最终会集中在应用程序的“棘手”区域,即过去发现大多数 bug 的区域。这种策略背后的假设是,历史上有 bug 的代码区域未来更可能有 bug。
你甚至可能对应用程序中的 bug 发生位置有一些先验假设;也许你认为应用程序中的边界情况最有可能出 bug。那么你可能会合理地把测试工作集中在这些区域上。
其他测试策略可能专注于测试的分布。当您关注最坏情况行为(例如安全漏洞)而不是平均情况行为(普通错误)时,这一点尤为重要。例如,模糊测试涉及随机在测试空间中泼洒,而不考虑使用频率等因素:结果是您在很少使用和没有发现许多错误的区域上获得了更多的分布。
然而,您可能会注意到,虽然模糊测试改变了测试的分布,但它并不提供任何保证。为了保证没有任何错误,您必须测试每一个输入,而在现代软件工程实践中,这是不可能的。实际上,有一种非常巧妙的技术叫做模型检查器,专门设计了各种技巧以加速进行这种详尽的测试。对于有限的状态空间来说,无论如何都是如此——还有更近期的研究项目(例如 Alloy),可以执行这种详尽的测试,但仅限于一定的深度。
模型检查器在某种意义上是“愚笨的”,因为它们并不真正理解程序试图做什么。我们可以采取的另一种方法是利用我们知道的程序工作方式的事实,以选择一些非常谨慎设计的测试输入,这些输入“泛化”以覆盖整个测试空间。(我们很快会更加精确地阐明这一点。)
然而,上面的图表有点误导:测试用例很少能够如此轻松地泛化。甚至可以说,将特定测试的行为泛化到程序行为的能力,正是区分好程序与坏程序的关键。坏程序充满了许多不同的情况,所有这些情况都必须单独测试才能确保。
说一个测试用例泛化是什么意思?我个人的信念是,被称为相互等效的测试输入空间块对应于程序的单个案例,是更大数学证明的一部分,可以独立进行论证。当您将一个复杂的程序分解为部分以解释其功能时,这些部分应该对应于程序的等效划分。
这种信念的推论是,好程序易于证明正确。
这比“运行程序看看是否正常”要复杂得多。但我认为,无论软件工程师是否使用像模型检查器和定理证明器这样的学术工具,对于想要制作正确可靠软件的人来说,这种过渡是必要的。无论如何,最终你仍然需要编写测试用例。但如果你理解构建测试用例背后的分布理论,你将会更加有效。
附言. 类型检查与测试之间的关系经常被误解。我认为这张图表很好地总结了它们之间的关系:
类型可以消除某些 bug 区域,但对其他区域无效。依赖类型的理念是增加这些边界,直至覆盖所有空间,即使你只能管理测试空间的子集,其好处也是非常明显的。
这项工作根据知识共享署名-相同方式共享 3.0 未本地化版本许可协议进行许可。
使用错误变量导致的错误:ezyang's 博客
来源:
blog.ezyang.com/2011/04/bugs-from-using-the-wrong-variable/
我原本应该在今天发布关于 Hoopl 的另一篇文章,但当我写的一个示例程序触发了我认为是 Hoopl 的一个 bug 时(如果这不是一个 bug,那么我的关于 Hoopl 内部工作方式的心理模型严重错误,我也不应该写这个),所以今天的文章将是关于所谓的 Hoopl 遇到的 bug:使用错误变量导致的 bug。
如果我没记错,使用了错误的变量就是缺少撇号:
; let (cha', fbase') = mapFoldWithKey
- (updateFact lat lbls)
+ (updateFact lat lbls')
(cha,fbase) out_facts
实际上,这种 bug 在函数式代码中经常发生。这里是最近我与 Simon Marlow 一起修复的 GHC 本地代码生成后端中的一个 bug:
- return (Fixed sz (getRegisterReg use_sse2 reg) nilOL)
+ return (Fixed size (getRegisterReg use_sse2 reg) nilOL)
几周前,当我在处理 abcBridge 时,由于类似以下原因导致无限循环:
cecManVerify :: Gia_Man_t -> Cec_ParCec_t_ -> IO Int
- cecManVerify a b = handleAbcError "Cec_ManVerify" $ cecManVerify a b
+ cecManVerify a b = handleAbcError "Cec_ManVerify" $ cecManVerify' a b
如何防范这些错误?有多种选择:
将旧变量变异/遮蔽掉
对于任何命令式程序员来说,这是经典的解决方案:如果某个值不再使用,用新值覆盖它。因此,您会得到这样的代码:
$string = trim($string);
$string = str_replace('/', '_', $string);
$string = ...
在函数式编程语言中,您可以通过重新使用名称来创建新的绑定,这将遮蔽旧的绑定。但是这种做法有些不鼓励,因为-Wall
可能会建议:
test.hs:1:24:
Warning: This binding for `a' shadows the existing binding
bound at test.hs:1:11
使用点无关风格消除变量
特定情况下,如果变量只在一个地方使用,在这种管道样式中通过一系列函数可以相对直接地消除它,将代码移至点无关风格(“点”在“点无关”中指的是变量名):
let z = clipJ a . clipI b . extendIJ $ getIJ (q ! (i-1) ! (j-1))
但是当中间值需要多次使用时,这种方式通常效果不佳。通常可以安排一种方法,但是“多次使用”通常是点无关风格变得难以理解的一个很好的指标。
视图模式
视图模式是一种相当巧妙的语言扩展,允许您避免编写类似这样的代码:
f x y = let x' = unpack x
in ... -- using only x'
使用 {-# LANGUAGE ViewPatterns #-}
,您可以改写为:
f (unpack -> x) y = ... -- use x as if it were x'
因此避免了创建可能会意外使用的临时名称的需要,同时允许您使用名称。
打开警告
只需要几秒钟的凝视就能看出这段代码有什么问题:
getRegister (CmmReg reg)
= do use_sse2 <- sse2Enabled
let
sz = cmmTypeSize (cmmRegType reg)
size | not use_sse2 && isFloatSize sz = FF80
| otherwise = sz
--
return (Fixed sz (getRegisterReg use_sse2 reg) nilOL)
是的,size
在函数体中从未被使用。 GHC 会提醒您这一点:
test.hs:1:24: Warning: Defined but not used: `b'
不幸的是,有人把它关闭了(眩光):
{-# OPTIONS -w #-}
-- The above warning supression flag is a temporary kludge.
-- While working on this module you are encouraged to remove it and fix
-- any warnings in the module. See
-- http://hackage.haskell.org/trac/ghc/wiki/Commentary/CodingStyle#Warnings
-- for details
使用描述性的变量名和类型
Haskell 程序员倾向于使用像f, g, h, x, y, z
这样的短、数学风格的名称,当变量的作用域不是很大时。命令式编程者倾向于觉得这有些奇怪和难以维护。在 Haskell 中,这种风格能够被维护的原因在于静态类型系统:如果我写的函数是compose f g
,其中f :: a -> b
,g :: b -> c
,我可以确定不会意外地在f
的位置使用g
:它会导致类型错误!如果所有关于变量内容的语义信息都包含在类型中,重复说明似乎没有多大意义。当然,不要在这个方向上走得太远是好的:当有两个变量都具有Int
类型时,类型检查器将无法帮助你很多。在这种情况下,最好稍微多加一点描述。相反,如果你调整类型使得这两个变量再次具有不同的类型,错误的可能性再次消失。
cabal new-build 是一个包管理器:ezyang 的博客
来源:
blog.ezyang.com/2016/08/cabal-new-build-is-a-package-manager/
今天偶尔会看到引用的一篇旧文章是重复一遍:"Cabal 不是一个包管理器"。很多批评不适用于 cabal-install 1.24 的新Nix 风格本地构建。让我们澄清一下。
事实:cabal new-build 不处理非 Haskell 依赖项
好的,这是自 Ivan 的文章以来没有改变的一点。与 Stack 不同,cabal new-build
不会帮助你下载和安装 GHC,并且像 Stack 一样,它也不会下载和安装系统库或编译器工具链:这些都需要你自己来做。在这种情况下,你应该依赖于系统包管理器来启动 Cabal 和 GHC 的工作安装。
事实:Cabal 文件格式可以记录非 Haskell 的 pkg-config 依赖项。
自 2007 年起,Cabal 文件格式有一个pkgconfig-depends
字段,可用于指定对由pkg-config工具理解的库的依赖关系。它不会为您安装非 Haskell 依赖项,但它可以让您提前知道某个库是否不可用。
实际上,cabal-install 的依赖解决器了解pkgconfig-depends
字段,并会选择版本并设置标志,以便我们不会得到一个具有不可满足的 pkg-config 依赖的包。
事实:cabal new-build 可以升级包而不会破坏你的数据库
假设你正在开发一个依赖于几个依赖项的项目。你决定通过放宽项目配置中的版本约束来升级其中一个依赖项。做出这些改变后,只需运行cabal new-build
重新构建相关的依赖项并开始使用它。就是这样!更好的是,如果你有一个使用旧依赖项的旧项目,它仍然能够正常工作,就像你希望的那样。
cabal new-build
实际上并不像传统的升级那样工作。安装到cabal new-build
全局存储的包是通过类似 Nix 的标识符唯一标识的,该标识符包含了影响构建的所有信息,包括构建所依赖的具体版本。因此,“升级”一个包实际上只是安装一个使用不同唯一标识符的包,这个包可以与旧版本共存。你永远不会因为输入new-build
而导致包数据库损坏。
目前没有删除软件包的机制,除非删除您的存储(.cabal/store
),但值得注意的是,删除您的存储是完全安全的操作:如果存储不存在,cabal new-build
不会决定以不同方式构建您的软件包;存储仅仅是一个缓存,不影响依赖解决过程。
事实:除了软件包作者外,Hackage 受托人还可以编辑已发布软件包的 Cabal 文件以修复错误。
如果上传的软件包带有错误的版本范围,并且随后的新版本破坏了这些范围,Hackage 受托人 可以介入,修改 Cabal 文件以根据新信息更新版本范围。这是一种比 Linux 发行版补丁更有限的干预形式,但其本质类似。
事实:如果可能的话,请使用您的系统包管理器。
cabal new-build
很棒,但并非人人都适用。如果您只需在系统上安装一个可工作的pandoc
二进制文件,并且不介意是否有最新的版本,您应该通过操作系统的包管理器下载和安装它。发行版软件包非常适合二进制文件;对于开发人员来说,它们的库通常太旧(尽管这通常是获得工作 OpenGL 安装的最简单方法)。cabal new-build
面向的是 Haskell 软件包的开发人员,他们需要构建并依赖于操作系统未分发的软件包。
我希望这篇文章能消除一些误解!
计算麻将的shanten :ezyang 的博客
离开一边,扑克牌!虽然各种扑克牌手的概率已经被广泛理解和列出,但是中国的游戏Mahjong [1]拥有更为复杂的预期价值和概率结构。[2]这主要是由于可用的瓷砖种类更多(136 张瓷砖,而不是标准扑克牌组的 52 张),以及逐轮游戏玩法,这意味着虽然本质上是一种游戏的机会,但涉及到了相当多的策略。事实上,这个主题如此复杂,以至于我决定写我的博士论文。本博客文章是我论文的一个章节的精简版本,考虑到shanten的计算,我们将在下面定义。我将使用日本术语,因为我最喜欢的麻将变体是日本麻将;您可以查阅维基百科文章来翻译。
计算shanten
麻将的基本玩法包括将一张牌抓入手中的十三张牌,并且再弃掉另一张牌。目标是形成一个十四张牌的手牌(也就是在抓牌后,但弃牌前)这是一个获胜的配置。有许多不同的获胜配置,但大多数获胜配置都有一个类似的模式:十四张牌必须分为四个三张牌和一个对子。三张牌可以是相同的三张牌,或者是一个顺子中的三张牌(有三种“花色”可以用来形成顺子);对子则是两张相同的牌。以下是一个例子:
从数字上看,这手牌包括三张牌和对子 123 55 234 789 456。
有一个在麻将手牌中计算非常有用的有趣量——shanten 数字,即离胜利还有多少张牌。这可以用来给你提供一个最基本的启发式玩法:弃掉能让你更接近听牌的牌。最广为人知的shanten 计算器是天凤网站上的这个 [3];不幸的是,这个计算器的源代码不可用。还有另一个关于 StackOverflow 的问题,但“最佳”答案只提供了一个启发式方法,没有证明其正确性!我们能做得更好吗?
初级的话,向听数是在手牌排列的广度优先搜索。当找到一个获胜的手牌时,算法终止并指示搜索已达到的深度。这样的算法显然是正确的;不幸的是,对于 136 张牌,你必须遍历 个手牌(新牌的选择乘以弃牌的选择),同时寻找一个相差 n 向听的获胜手牌。如果你差四张牌,你将不得不遍历超过六万亿个手牌。如果我们记忆化与手牌相关的向听数,可以减少这个数字;然而,所有可能手牌的总数大约是 ![136 \choose 13](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/choose 13") 或者 59 位。尽管我们可以将一手牌通过组合数系统嵌入到 64 位整数中,但结果表仍然太大,希望能够放入内存。
观察到每种花色的向听计算是对称的技巧;因此,我们可以在 1 到 9 号牌的更小空间上进行动态规划,然后在组装最终计算时重复使用这些结果。 ![9 \times 4 \choose 13](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/choose 13") 仍然相当大,因此我们可以利用每种牌有四张副本的事实,一个等价的表示是一个 9 维向量,其中的数字是从零到四,有一个约束条件是这些数字的和为 13。即使没有约束条件,计数 也只有两百万,非常可行。每个条目一个字节,这是 2MB 的内存;比你的浏览器用来查看此网页的还少。(实际上,我们希望约束条件是总和小于或等于 13,因为并非所有的手都是单一花色,所以手中的牌数会少一些。)
解决单一花色的广度优先搜索如下进行:
-
初始化一个由牌配置(一个 0 到 4 的 9 维向量)索引的表 A。
-
初始化一个待办队列 Q,其中包含牌配置。
-
将表 A 中所有获胜配置的向听数初始化为零(可以通过枚举完成),并将这些配置记录在 Q 中。
-
当待办队列 Q 不为空时,弹出队列的前端元素,标记所有相邻未初始化节点的向听数为比该节点多一,然后将这些节点推入待办队列。
拥有这些信息后,我们可以汇总手牌的总实时。只需尝试所有三张牌和四种类型的牌(包括空牌),并查看请求形状的实时,并返回所有这些配置中的最小值。根据星和条,有![4 \times {4 + 4 - 1 \choose 4}](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/choose 4}")个组合,总计 140 个配置。计算每个配置的实时是一个常量时间操作,进入由每套计算生成的查找表。一个真正的实时计算器也必须适应那些不遵循此配置的罕见其他手牌,但这些获胜配置通常非常受限制,并且很容易(单独)计算实时。
带有一种实盘计算器,可以计算出许多其他的数量。浮法 是指可以减少您手上的实时的可能抽取的数量:人们努力追求高浮法,因为这意味着您有可能抽到一张可以接近于获胜的牌。给定一手牌,计算其浮法非常简单:只需查看所有相邻的手牌,并计算具有较低实时的手牌的数量。
进一步的扩展
假设您正在尝试设计一个可以玩麻将的人工智能。上述实时计算器是否能为您的手提供良好的评估指标?实际上不行:它有一个主要缺点,即它并未考虑到某些牌根本不可用(它们已被丢弃)。例如,如果所有四张“九条”牌都在桌上可见,那么实际上不存在包含九条的手牌配置。调整这种情况实际上非常困难,有两个原因:首先,我们不能再预先计算实时表,因为我们需要在运行时调整可达性指标;其次,各种类型的牌不再对称,因此我们需要做三倍的工作。但我们可以避免指数级的膨胀,因为各套之间没有交互作用。
实时和浮法指标的另一个缺点是它们不是“牌效率”的直接度量:也就是说,它们并不直接指导您在获胜之前最小化预期时间的丢牌策略。例如,假设您有 233 这样的牌,并且只需要再凑成一个三张牌就可以赢了。您有两种可能的丢牌方式:您可以丢弃一个 2 或者一个 3。在两种情况下,您的实时都是零,但是如果丢弃一个 2,您只能通过抽到一个 3 来赢,而如果丢弃一个 3,您可以通过抽到一个 1 或者一个 4 来赢。最大化效率需要考虑您手牌的生命周期浮法。
即便如此,完美的牌效率并不足以取得胜利:每个获胜的手牌都与一定的得分关联,因此在许多情况下,选择一个低概率但预期价值更高的手牌可能更合理。在这里,我们的分解方法完全失效了,因为虽然可以将获胜配置的空间划分,但是计分具有非局部效应,因此整手牌必须作为一个整体来考虑。在这种情况下,可以尝试蒙特卡罗方法,因为直接表征概率空间太困难。然而,在日本麻将的计分系统中,这种方法还面临另一个困难:计分系统是指数级的。因此,我们面临这样一种情况:大多数样本的得分较低,但极少数样本具有指数级的回报。在这种情况下,很难说随机抽样是否会真正给出一个好结果,除非抽取指数多的样本。(另一方面,由于这些手牌如此罕见,一个 AI 可能简单地忽略它们而表现得相当好。)
总之,麻将是一款迷人的游戏,其庞大的状态空间使得准确表征涉及的概率变得困难。在我的论文中,我尝试解决其中的一些问题;如果你对此感兴趣,请查看。
[1] 不,我不是在谈论那些麻将接龙的荒谬事情。
[2] 明确一点,我并不是说扑克策略简单——下注策略可能是游戏中最有趣的部分之一——我只是说从概率角度来看,基本游戏相对简单。
[3] 天凤是一个流行的日本在线麻将客户端。天凤计算器的输入格式是123m123p123s123z
,其中m
之前的数字表示万子,p
表示筒子,s
表示索子,z
表示风牌(顺序依次为:东、南、西、北、白、发、中)。每个条目指示你可以丢弃哪张牌来接近听牌;下一个列表是连切牌的数目(及使手牌进一步完善的牌数)。
Call and fun: marshalling redux : ezyang’s blog
这是 c2hs 的六部分介绍之一。最终我们谈论的是 c2hs 的主要用途:从 Haskell 调用 C 函数。由于 c2hs 对 C 头文件有了解,因此可以自动生成 FFI 导入的工作。call
钩子只是告诉 c2hs 生成 FFI 导入,而 fun
钩子则生成另一个执行 marshalling 的 Haskell 函数。
Call. 调用的格式非常简单,因为像 get
和 set
一样,它意味着可以与其他 Haskell 代码交错使用。如果我想从 readline/readline.h
调用 readline
函数,只需 {#call readline #}
即可;c2hs 将会生成正确签名的 FFI 导入,并将调用指令转换为 FFI 导入的名称。
当然,readline
不会回调到 Haskell,所以我们可以添加 unsafe
:{#call unsafe readline #}
。如果你确信 C 函数没有副作用,可以添加 pure
:{#call pure unsafe sin #}
。如果多次调用同一个函数并使用相同的 FFI 声明,它们的标志需要保持一致。
默认情况下,cid
将精确用于确定 FFI 导入的名称;如果它不是函数的有效 Haskell 标识符(即大写),或者 C 函数名称会与其他名称冲突,则需要指定 FFI 将导入为什么。常见的约定包括给函数添加前缀 c_
,或者使用 ^
来进行 c2hs 的大写转换。{#call FooBar_Baz as ^ #}
将转换为 fooBarBaz
(并带有适当的 FFI 声明)。
Fun. 因为 FFI 声明的签名都是 C 类型,而 Haskell 程序往往不使用这些类型,并且因为频繁进行 C 类型的转换,有一点自动化来帮助你处理 fun
指令。与 call
不同,它是作为一个定义独立存在的,而不是嵌入在代码中的。请注意,不 必须使用 fun
;例如 gtk2hs 就没有使用它,但很多人发现它很有用。
fun
的开始与 call
类似:首先指定它是纯的和/或不安全的,指定 C 标识符,然后指定 Haskell 名称。由于大多数代码将引用 Haskell 名称,通常最好指定 ^
来保持一致的命名约定。
在这里,我们需要指定 所需 Haskell 函数的最终类型,以及 如何从这些类型转换为 C 类型(即 marshalling 函数)。c2hs 教程在这个主题上有一些内容,所以我们将采取更多的示例导向的方法。
基本 C 类型。 整数、浮点数和布尔(通常在幕后是整数)基本的 C 类型非常普遍,如果没有指定,c2hs 将自动使用cIntConv
、cFloatConv
和cFromBool
/cToBool
函数进行编组。这些函数可以双向工作。这个指令:
{#fun pure sinf as ^
{ `Float' } -> `Float' #}
生成:
sinf :: Float -> Float
sinf a1 =
let {a1' = cFloatConv a1} in
let {res = sinf'_ a1'} in
let {res' = cFloatConv res} in
(res')
您可以看到,会添加一堆(丑陋的)生成代码以在参数上运行编组函数,将其传递给 FFI,然后在结果上调用另一个编组函数。惯用的 Haskell 可能是这样的:
sinf = cFloatConv . sinf'_ . cFloatConv
如果您想要为编组函数使用不同的名称,可以在参数类型之前指定它(“in”编组器),或者在结果之后指定它(“out”编组器),如下所示:
{#fun pure sinf as ^
{ myFloatConv `Float` } -> `Float` myFloatConv
而您只需在生成的 Haskell 中替换相关函数调用。
String 参数。 字符串也在 c2hs 的心中占有特殊地位;处理以 null 结尾的字符串和需要显式长度信息的字符串都很容易。考虑这两个函数原型:
void print_null_str(char *str);
void print_explicit_str(char *str, int length);
我们可以编写以下 c2hs 指令:
{#fun print_null_str as ^ { `String' } -> `()' }
{#fun print_explicit_str as ^ { `String'& } -> `()' }
并且它们将自动使用withCString*
和withCStringLen*
进行编组。
这里发生了几件有趣的事情。我们使用()
(Haskell 中的空类型)来表示空返回类型。此外,print_explicit_str
中的 String 参数有一个附加的和号;这意味着编组器应该产生一个参数元组,这些参数将作为两个单独的参数传递给函数。确实,withCStringLen
的结果是(Ptr CChar, Int)
,而 c2hs 使用略有不同的变体withCStringLenIntConv
,它将Int
转换为CInt
。(请注意,如果您需要更复杂的多参数排序,fun
并不适合您。)
但也许最有趣的是附加到输入编组器上的*
,它有两个效果。首先,它表明输入编组函数是 IO 单子,例如,withCString
的类型是String -> (CString -> IO a) -> IO a
。但更重要的是,它指示了一个遵循括号资源模式“with”的函数。我们没有使用String -> CString
,因为如果我们不稍后释放CString
,这可能导致内存泄漏!然后生成的代码是:
printNullStr :: String -> IO ()
printNullStr a1 =
withCString a1 $ \a1' ->
printNullStr'_ a1' >>= \res ->
return ()
printExplicitStr :: String -> IO ()
printExplicitStr a1 =
withCStringLenIntConv a1 $ \(a1'1, a1'2) ->
printExplicitStr'_ a1'1 a1'2 >>= \res ->
return ()
使用悬挂 lambda 保持布局一致。
编组结构参数。 尽管 c2hs 文档声称如果您在 C 中有以下情况,那么会有一个默认的编组器:
struct my_struct { int b; int c; };
void frob_struct(struct my_struct *);
并在 Haskell 中:
data MyStruct = MyStruct Int Int
instance Storable MyStruct where ...
{#pointer *my_struct as MyStructPtr -> MyStruct #}
因此,您应该能够写出:
{#fun frob_struct as ^ { `MyStruct' } -> `()' #}
其中,输入编组器是with*
。不幸的是,我从未能让它起作用;此外,c2hs 认为with
是一个保留字,所以您需要重命名它才能使用它。
withT = with
{#fun copy_struct as ^ { withT* `MyStruct' } -> `()' #}
不透明指针参数。 当您不想在 Haskell 中对指针执行任何花哨的操作时,可以简单地指定指针是参数并使用id
作为编组器。在前面的例子中,copy_struct
也可以另外定义为:
{#fun copy_struct as ^ { id `MyStructPtr' } -> `()' #}
一个约定是,如果只处理不透明指针,可以省略指针类型的名称中的 Ptr
。
输出编组器的输入参数。 C 代码中的一个常见模式是使用指针参数允许函数返回多个结果。例如,strtol
的签名如下:
long int strtol(const char *nptr, char **endptr, int base);
endptr
指向一个指针,该指针将设置为我们解析的 nptr
字符串部分的结尾处的指针。如果我们不关心它,可以将 endptr = NULL
设置为 NULL
。
显然,我们不希望我们的 Haskell 函数这样做,并且我们有更简单的方法使用元组返回多个结果,所以 c2hs 有一个关于输入参数的输出编组器的概念。它还有一个“虚假”输入参数的概念,用户不必传递,以防我们的函数完全负责分配指向函数的指针的内存。
这是编写 strtol
的 fun
钩子的第一个尝试:
{#fun strtol as ^ {id `Ptr CChar', id `Ptr (Ptr CChar)', `Int'} -> `Int` #}
我们避开了默认的字符串编组,因为否则 endptr
不会给我们非常有趣的信息。这个版本是原始内容的简单复制。
为了改进这一点,我们认为 Ptr (Ptr CChar)
是返回 Ptr CChar
的一种方式。因此,在函数运行后,我们应该 peek
(解引用指针)并返回结果:
{#fun strtol as ^ {id `Ptr CChar', withT* `Ptr CChar' peek*, `Int'} -> `Int' #}
peek
在 IO 中,所以它需要星号,但对于我们的编组器来说,它并不会导致任何复杂的括号使用。现在,这个函数的 Haskell 返回类型不是 Int
;它是 (Int, Ptr CChar)
。
strtol :: Ptr CChar -> Ptr CChar -> Int -> IO (Int, Ptr CChar)
strtol a1 a2 a3 =
let {a1' = id a1} in
withT a2 $ \a2' ->
let {a3' = cIntConv a3} in
strtol'_ a1' a2' a3' >>= \res ->
peek a2'>>= \a2'' ->
let {res' = cIntConv res} in
return (res', a2'')
由于我们要覆盖指针的原始内容,强制用户向我们传递它并没有多大意义。我们可以在我们的输入编组器后缀上加-
,以表明它不是真正的 Haskell 参数,并改用alloca
代替:
{#fun strtol as ^ {id `Ptr CChar', alloca- `Ptr CChar' peek*, `Int'} -> `Int' #}
请注意,我们去掉了 *
;这是一种或另一种方式。现在我们有一个可用的函数:
strtol :: Ptr CChar -> Int -> IO (Int, Ptr CChar)
strtol a1 a3 =
let {a1' = id a1} in
alloca $ \a2' ->
let {a3' = cIntConv a3} in
strtol'_ a1' a2' a3' >>= \res ->
peek a2'>>= \a2'' ->
let {res' = cIntConv res} in
return (res', a2'')
或者,在习惯用法的 Haskell 中:
strtol nptr base = alloca $ \endptr -> do
result <- strtol'_ nptr endptr (cIntconv base)
end <- peek endptr
return (result, end)
错误处理。 还有一个功能片段我们尚未讨论,即一个输出编组器上的 -
标志,导致 Haskell 忽略结果。单独使用时通常没有用,但与 *
(表示操作在 IO 中)结合使用时,可用于附加检查错误条件并在情况成立时抛出异常的函数。请记住,()
的默认输出编组器是 void-
,忽略函数的输出结果。
- Calling all space leaks:ezyang's 博客
Calling all space leaks
I’m currently collecting non-stack-overflow space leaks, in preparation for a future post in the Haskell Heap series. If you have any interesting space leaks, especially if they’re due to laziness, send them my way.
Here’s what I have so far (unverified: some of these may not leak or may be stack overflows. I’ll be curating them soon).
import Control.Concurrent.MVar
-- http://groups.google.com/group/fa.haskell/msg/e6d1d5862ecb319b
main1 = do file <- getContents
putStrLn $ show (length $ lines file) ++ " " ++
show (length $ words file) ++ " " ++
show (length file)
-- http://www.haskell.org/haskellwiki/Memory_leak
main2 = let xs = [1..1000000::Integer]
in print (sum xs * product xs)
-- http://hackage.haskell.org/trac/ghc/ticket/4334
leaky_lines :: String -> [String]
leaky_lines "" = []
leaky_lines s = let (l, s') = break (== '\n') s
in l : case s' of
[] -> []
(_:s'') -> leaky_lines s''
-- http://stackoverflow.com/questions/5552433/how-to-reason-about-space-complexity-in-haskell
data MyTree = MyNode [MyTree] | MyLeaf [Int]
makeTree :: Int -> MyTree
makeTree 0 = MyLeaf [0..99]
makeTree n = MyNode [ makeTree (n - 1)
, makeTree (n - 1) ]
count2 :: MyTree -> MyTree -> Int
count2 r (MyNode xs) = 1 + sum (map (count2 r) xs)
count2 r (MyLeaf xs) = length xs
-- http://stackoverflow.com/questions/2777686/how-do-i-write-a-constant-space-length-function-in-haskell
leaky_length xs = length' xs 0
where length' [] n = n
length' (x:xs) n = length' xs (n + 1)
-- http://stackoverflow.com/questions/3190098/space-leak-in-list-program
leaky_sequence [] = [[]]
leaky_sequence (xs:xss) = [ y:ys | y <- xs, ys <- leaky_sequence xss ]
-- http://hackage.haskell.org/trac/ghc/ticket/917
initlast :: (()->[a]) -> ([a], a)
initlast xs = (init (xs ()), last (xs ()))
main8 = print $ case initlast (\()->[0..1000000000]) of
(init, last) -> (length init, last)
-- http://hackage.haskell.org/trac/ghc/ticket/3944
waitQSem :: MVar (Int,[MVar ()]) -> IO ()
waitQSem sem = do
(avail,blocked) <- takeMVar sem
if avail > 0 then
putMVar sem (avail-1,[])
else do
b <- newEmptyMVar
putMVar sem (0, blocked++[b])
takeMVar b
-- http://hackage.haskell.org/trac/ghc/ticket/2607
data Tree a = Tree a [Tree a] deriving Show
data TreeEvent = Start String
| Stop
| Leaf String
deriving Show
main10 = print . snd . build $ Start "top" : cycle [Leaf "sub"]
type UnconsumedEvent = TreeEvent -- Alias for program documentation
build :: [TreeEvent] -> ([UnconsumedEvent], [Tree String])
build (Start str : es) =
let (es', subnodes) = build es
(spill, siblings) = build es'
in (spill, (Tree str subnodes : siblings))
build (Leaf str : es) =
let (spill, siblings) = build es
in (spill, Tree str [] : siblings)
build (Stop : es) = (es, [])
build [] = ([], [])
剑桥百科混合物:ezyang’s 博客
Edward 在这里分享了一些关于剑桥的故事,并发布了许多照片。
显然,Alyssa B. Hacker (注)参加了剑桥麻省理工交换项目。
或许这是因为在 MIT 待得时间太长,这个校园以不太风景如画而闻名(我可能能用一只手数出真正漂亮的地方),但停下来欣赏这里的建筑和建筑物是真的很容易。看看!
确实,我并不住在这些地方;我只是在去剑桥市中心上课的路上经过它们。我的学院,费茨威廉,从外面看并不那么漂亮(省略图片),但内部绝对华丽:
啊,别介意等待收集的袋子里装满了碎纸。
一幅非常精美的费茨威廉学院地图(抱歉有些地方有反光):
如果你眯起眼睛,可以看到地图上标有The Grove的地方。关于学院的这部分有一个相当有趣的故事:它实际上曾属于达尔文家族(即查尔斯·达尔文),学院建造时围绕这片树丛而建,直到艾玛·达尔文去世,这所房子被合并为学院的一部分。
很容易走进一个房间看到一些名人。比如托尼·霍尔(Quicksort 和 Hoare logic 的发明者)。我告诉人们我来剑桥大学的一部分是为了品尝其理论风味,我并没有失望。我非常喜欢马塞洛·费奥雷关于指称语义的讲座(也许我会写几篇博客文章)。其他讲座有些是半斤八两的(但再怎么说,讲座什么时候不是呢?),但它们成功填补了我教育中我不知道我不知道的领域。来自我的逻辑和证明课程的斯科莱姆化部分让我想起了这篇博客文章,曾经抱怨没有人告诉你斯科莱姆常量是如何实现类型检查器的事实。嗯,显然,斯科莱姆化是逻辑世界中的一种经典技术,确实像你在类型系统中所期望的那样工作(毕竟,Curry-Howard 同构)。
此外,我们的逻辑和证明导师还给我们提供茶水。:-)
他在三一学院的房间(我们进行辅导的地方)就是著名数学家拉马努金在他是三一学院研究员期间住的同一间房间。说起来,现在是罗素和怀特黑德的《数学原理》百年纪念。
到目前为止,我在实际做研究方面彻底失败了,但我很享受理论讨论并吸收所有这些基础知识。
说到这一点,计算机实验室不是古典风格。我猜你很难说服现代建筑师建造老式的建筑了。你会在传统硬核学院内发现一些奇特的东西,比如高跷上的图书馆:
啊,现代化的进程。如果我没有选择历史和科学哲学,我可能会被困在西剑桥的现代建筑中,但幸运的是,在实验室和菲茨威廉学院之间有一条美妙的小自行车道(尼迪告诉过我,但我终于错过了,最后在丘吉尔学院的后面骑车下了草坪,然后沿着道路反方向行驶,因为有栅栏挡住了我的路):
在英国食物可能不怎么样,但你真的不能击败它的甜食:
唉,我已经禁止自己购买任何糖果,以免在回到学院之前神秘消失。早餐吃榛子酱也被证明是一个极其糟糕的主意,我已经放弃了这种做法,改为吃一个面包和两块水果。剑桥有一个每天开放的市场广场(不错!),但没有太多的本地产品(可以理解,但仍有点遗憾!)
实际上,我发现了位于高跷上的图书馆,因为我试图确定 ICUSU 活动的位置,而且与在麻省理工学院不同的是,我在剑桥时不随身携带我的笔记本电脑(哎呀!)最终我找到了位置:
如果你说你在剑桥是在踢足球,那意味着与在麻省理工学院有很大不同。尽管我参考了维基百科(consulted Wikipedia),它与剑桥对该术语的解释是一致的。哦,我提到了吗,狗也会踢足球!
踢球有点不同于帆船;特别是,你将踏板杆推向你想要去的方向(而舵柄则向你希望去的相反方向)。
这篇文章有点长了,也许我会留点到以后再说。
附言。 今天我注意到了柯里化和教堂编码之间(并不特别深刻的)关系:也就是说,如果你用数据柯里化销毁函数(either
,maybe
,foldr
等),你得到的函数与该数据类型的教堂编码具有相同的类型。
剑桥回顾:历史与科学哲学:ezyang 的博客
我最近完成了剑桥大学的一年留学项目。你可以在这里阅读我的原始动机和第一印象。
现在是考试前的星期日,天气格外好。大多数学生(也许除了纯历史学家外)都在忙于复习,没有时间利用这样的好天气。但我被推出了我的窝,这是由一堆笔记和过去的考试题组成的,为了与我们的历史导师 Mirjam 进行最后一次复习。我骑车沿着格兰治路,把我的自行车停在里德利大厅外,然后受到了一个惊喜的欢迎:Mirjam 选择在户外的一组公园长椅上进行复习,四周都是草地和树木,还带来了一个装有新鲜水果、小蛋糕和其他点心的柳条篮子,还有起泡酒和啤酒(或许对于一个本质上是复习的活动来说并不是最好的饮品,但我们还是享用了)。
历史与科学哲学是由两个部分组成的课程。它们最初相对独立:科学史始于伽利略和 16 世纪初期的黎明,而科学哲学则从分析因果关系的本质开始。但到了课程结束时,关于量化和量子力学的哲学问题已经深深嵌入这些科学的历史中,关于西方文明起源根源于希腊和古巴比伦数学的传统观念也在日益哲学化的分析中被探讨。这两个学科的混合效果非常好,修读这门课程是一次非常丰富的经历。(有时,我甚至希望我们被要求写更多的一篇文章一周——对于麻省理工的人文课程来说,这是相当罕见的。)
这也是一项艰苦的工作。特别是在历史课上,我的最初论文由于我对如何综合更广泛的材料而不仅仅是必读的调查文本的误解而起步艰难(我可能在完成所有必读内容时有些“疏忽”)。写 HPS(历史与科学哲学)论文要求我在周末抽出大段时间来完成;我很少因为分配的计算机科学作业而熬夜,但在 HPS 论文到期的那个晚上却不得不这样做。我总是尽力在讲座中集中注意力(有时比其他时候更成功——直到今天我仍然不太理解现代研究型大学系统的兴起)。
几位教授在我的记忆中格外突出。我永远不会忘记哈索克·张(上图)的入门讲座,在一个离题的插曲中,他解释说他最初在加州理工学院主修物理学,但在他的教授们因他问诸如“大爆炸之前发生了什么?”这样的问题而感到恼怒后,他转而学习哲学。这是他第一年教授物理科学哲学系列讲座(许多学生并没有过去的考试题来备考),但他继续进行了一系列极为扎实、引人入胜且信息丰富的讲座(我认为这些讲座也吸引了一些对因果关系和归纳的抽象问题感到厌倦的物理学家)。他甚至在最后一堂课后邀请我们到附近的酒吧喝一轮,我们在那里就最近的一个话题进行了热烈的辩论。
史蒂芬·约翰进行了一系列极富活力和引人深思的科学伦理学讲座。他的大部分讲座让我们更多地提出了问题而不是答案,但它们是一次引人入胜的旅程,涉及知情同意、预防原则和成本效益分析的领域。(知情同意是我新的个人最爱之一:它是一个很好的例子,显示了一个被奉为医疗实践的保守和开放同时存在的状态。)而且我将永远记得我们的导师理查德·詹宁斯(下图),他总是面带微笑,留着胡须,他的辅导并不真正是辅导,而更像是关于最近讲座中涉及的话题的交流。
在历史讲师中,我不得不向埃莉诺·罗宾致敬,她进行了最后四堂科学史讲座。我承认:当我第一次看到教学大纲时,我对学习古巴比伦不太感兴趣,但这些讲座最终成为了关于史学问题的讨论:历史学家们如何得知那些“枯燥的事实”,那些每个读过高中历史的人都非常熟悉的事实?在很多方面,我们知道的如此之多令人惊讶。这是你希望早些学到的教训之一,尽管你在脑海的后面知道,你早些时候可能并不完全欣赏它们。
西蒙·沙弗(上图)也是一个相当有个性的人物,他进行了我们的第一系列历史讲座。你可以从短暂的 BBC 系列 光芒四射 中体会到他的风格,尽管当你亲自听他讲课时,他可能会更加直率一些(“胖,易怒,聪明。绝对是一个榜样,”沙弗在提科·布拉赫身上说)。当然,还有我们的历史导师米尔雅姆,她一直鼓励我们改进我们的论文写作。
HPS 真的很神奇。(即使我也参加了第二部分的迎新活动,尽管我并不符合资格。)如果你是一个科学家,对哲学或历史有兴趣(我第一次涉足哲学是在 CTY 上修读“逻辑与推理”课程),我强烈推荐这个项目。读者可能已经注意到我在博客上尝试讨论这些问题的各种尝试——相比我讨论的更技术性的事物,这些问题更难写,但我认为它们也非常值得(有时,它们是我最受欢迎的作品之一——尽管回想起来,这篇文章现在看起来有些惠格历史主义)。这也很有趣:我设法写了一篇关于库恩式科学革命的论文,并将其放在 MIT Mystery Hunt 的背景下进行了构思。我不会否认:在短暂的一段时间里,我感觉自己像是一名文科学生。它使我在很多方面成为了一个更好的人。
Category theory for loop optimizations : ezyang’s blog
来源:
blog.ezyang.com/2013/05/category-theory-for-loop-optimizations/
克里斯托弗·德·萨(Christopher de Sa)和我一直在研究一种类别论方法来优化类似 MapReduce 的管道。实际上,我们开始并不涉及任何范畴论——最初我们只是试图在Delite 编译器执行的一些现有循环优化上施加一些结构,顺便发现了范畴论与循环优化之间丰富的关系。
一方面,我认为这种方法非常酷;但另一方面,该领域有许多先前的工作,很难弄清楚自己在研究景观中的位置。当我与约翰·米切尔讨论这个想法时,他对我说:“循环优化,难道你不能只用表查找来解决吗?”我们从现有工作中获得了很多灵感,特别是由伯德、梅尔滕斯、马尔科姆、迈耶等人在 90 年代初开创的程序计算文献。这篇博客文章的目的是讨论我们已经研究出的一些想法,并从你这位亲爱的读者那里获得一些反馈。
有几种思考我们试图做的事情的方式:
-
我们希望实现一个基于计算的优化器,针对一个真实的项目(Delite),其中循环优化的应用可以对任务的性能产生 drastc 的影响(其他具有类似目标的系统包括Yicho,HYLO)。
-
我们希望冒险涉足理论家通常不会涉足的领域。例如,有许多“无聊”的函子(如数组),它们具有重要的性能特性。虽然它们可能与适当定义的代数数据类型同构,但我们认为在计算优化器中,我们希望区分这些不同的表示方式。同样,许多不是自然变换本身的函数可以通过部分应用变成自然变换。例如,当
map p xs
被作为函数定义的一部分(结果函数可以应用于任何列表,而不仅限于原始的xs
)时,filter p xs
就是一个自然变换。这种结果的自然变换丑陋但有用。 -
对于股票优化器(例如 Haskell),一些计算优化可以通过使用 重写规则 支持。虽然重写规则是一个非常强大的机制,但它们只能描述“始终有效”的优化方式;例如对于森林化,我们总是希望尽可能消除中间数据结构。在我们希望优化的许多应用中,只有通过 增加 中间数据结构才能达到最佳性能:现在我们有一个可能的程序空间,而重写规则对于指定哪个程序最好是明显不足的。我们希望能够使用范畴论来解释带有结构的重写规则,并利用领域特定知识来选择最佳程序。
我想通过一个例子来阐述这些想法。这里有一些用 Delite 写的示例代码,用于计算(一维)k 均值聚类的一个迭代:
(0 :: numClusters, *) { j =>
val weightedPoints = sumRowsIf(0,m){i => c(i) == j}{i => x(i)};
val points = c.count(_ == j);
val d = if (points == 0) 1 else points
weightedPoints / d
}
可以这样理解它:我们正在计算一个结果数组,其中包含每个簇的位置,最外层的块正在通过索引变量 j
循环遍历簇。要计算簇的位置,我们必须获取分配给簇 j
的所有点 x
(即 c(i) == j
的条件),将它们加在一起,最后除以簇中点的数量来获取真实位置。
这段代码的主要问题在于,它在整个数据集上 numClusters 次进行迭代,而我们只想执行一次迭代。优化后的版本看起来是这样的:
val allWP = hashreduce(0,m)(i => c(i), i => x(i), _ + _)
val allP = hashreduce(0,m)(i => c(i), i => 1, _ + _)
(0::numClusters, *) { j =>
val weightedPoints = allWP(j);
val points = allP(j);
val d = if (points == 0) 1 else points
return weightedpoints / d
}
换句话说,我们必须预先计算加权点和点数(请注意两个 hashreduce 可以和应该融合在一起)之后,才能为每个簇生成新的坐标:在这种情况下,生成 更多 中间数据结构是有利的。
现在让我们计算优化程序的方式。但是首先,我们必须定义一些函子:
-
D_i[X]
是大小由i
指定的X
数组(具体来说,我们将使用D_i
来表示大小为numPoints
的数组,以及D_j
来表示大小为numClusters
的数组)。这组函子也被称为 对角函子,适用于任意大小的乘积。我们还将依赖于D
是可表示的事实,即D_i[X] = Loc_D_i -> X
对于某些类型Loc_D_i
(在这种情况下,它是索引集{0 .. i}
)。 -
List[X]
是X
的标准列表。它是函子F[R] = 1 + X * R
的初始代数。任何D_i
都可以嵌入到List
中;我们将隐式地进行这些转换(请注意反过来则不成立)。
还有一些函数,我们将在下面描述:
-
tabulate
见证了Loc_D_i -> X
和D_i[X]
之间同构的一个方向,因为D_i
是可表示的。另一个方向是index
,它接受D_i[X]
和一个Loc_D_i
,并返回一个X
。 -
fold
是在List
上的初始代数唯一确定的函数。此外,假设我们有一个函数*
,通过取它们的笛卡尔积来组合两个代数, -
bucket
是一个自然变换,它接受D_i[X]
并基于某些函数将其分桶到D_j[List[X]]
中,该函数将D_i
中的元素分配到D_j
的插槽中。这是一个自然变换的示例,在部分应用之前并不是自然变换:如果我们计算D_i[Loc_D_j]
,那么我们可以创建一个永远不会查看X
的自然变换;它只是“知道”每个D_i
插槽在结果结构中应该去的位置。
现在让我们用更功能化的术语重新写循环:
tabulate (\j ->
let weightedPoints = fold plus . filter (\i -> c[i] == j) $ x
points = fold inc . filter (\i -> c[i] == j) $ x
in divide (weightedPoints, points)
)
(其中 divide
只是一个函数,它除以它的参数,但检查除数不为零。)消除一些共同的子表达式并将两个折叠融合在一起,我们得到:
tabulate (\j -> divide . fold (plus * inc) . filter (\i -> c[i] == j) $ x)
在这一点上,仍然完全不清楚我们可以执行任何重写:filter
对我们造成了问题。然而,因为 filter
在进行相等性测试,我们可以以不同的方式重写它:
tabulate (\j -> divide . fold (plus * inc) . index j . bucket c $ x)
这里发生了什么?不直接筛选仅在群集 j
中的项目,我们可以将其视为在 c
上进行 分桶 x
,然后索引出我们关心的单个桶。这种视角的转变对整体优化至关重要。
现在我们可以应用自然变换的基本规则。设 phi = index j
和 f = divide . fold (plus * inc)
,那么我们可以将 f
推到 phi
的另一侧:
tabulate (\j -> index j . fmap (divide . fold (plus * inc)) . bucket c $ x)
现在我们可以消除 tabulate
和 index
:
fmap (divide . fold (plus * inc)) . bucket c $ x
最后,因为我们知道如何高效实现 fmap (fold f) . bucket c
(作为 hashreduce
),我们分解 fmap
并加入折叠和桶:
fmap divide . hashreduce (plus * inc) c $ x
我们已经实现了我们的完全优化程序。
所有这些都是正在进行的研究,有许多未解决的问题。尽管如此,我希望这篇文章能让你感受到我们推崇的方法。我对你的评论很感兴趣,无论是“太棒了!”还是“这在 20 年前就被 X 系统完成了。” 期待听到你的看法!
链式法则 + 动态规划 = 神经网络:ezyang 的博客
(猜猜 Edward 一周有什么事情:考试!这些帖子的主题可能与此有关...)
在我生命中的这一阶段,我已经两次上过介绍人工智能的课程。(不是我的错:在去剑桥之前,我碰巧已经上过 MIT 的版本,剑桥也将这些内容作为第二年课程的一部分进行教授。)我第一次学习 6.034 时,对算法的简单感到难以置信,对考试方法感到愤怒,并在最后模糊感到我真的应该更加关注。第二次学习时,我设法从课程中提炼出更多的算法内容,因为我不再过多担心细节。
今天的帖子主题是从神经网络学习过程中提炼出的算法内容。嗯,至少是对于多层感知器来说是这样——因为这通常是作为神经网络案例研究的一部分被研究的。值得注意的是,感知器实际上是一种非常简单的数学函数:它是一个多变量函数,它以一个权重向量和一个输入向量为参数,取它们的点积并通过激活函数(通常选择使其在微分时具有良好性质的函数)运行结果。“学习”在这种情况下是通过梯度下降进行的一阶优化,主要的计算内容涉及计算函数对权重向量的偏导数——这是任何学过多元微积分的人应该能够轻而易举做到的事情。
注意,我说的是应该。实际上,神经网络两次让我非常头痛,因为每次学习它时都很艰难。问题的一部分在于,一旦你计算出更新公式,你其实不需要理解推导过程:它们“就这样运行”。当然,任何值得尊敬的课程都不会只问你记忆相关方程的能力,所以它们通常会要求你写出推导过程。在这里,你会遇到第二个问题:大多数推导的呈现都相当冗长,不太容易“压缩”。
首次深入了解到的过程,这是我第一次学习这门课程时(最终)掌握的内容,是这些推导实际上只是重复应用链规则。因此,所有偏导数的繁琐分析可以用以下算法替代:“将感知机切割成较小的函数,计算每个函数的导数,然后将结果相乘。” 现在,这确实需要一点小心:人们通常将感知机网络视为输入值的函数,但导数是相对于权重的。此外,感知机网络是一个比通常在多变量微积分考试中找到的更为复杂的偏导数问题,因此,如果您的变量索引没有弄清楚,很容易感到困惑。(在这里,新名称和全局名称的概念非常有用,因为它为数学家自由且令人困惑的符号戏法设定了基本规则。)如果您掌握了链规则,您对输出感知机就有了一个相当令人信服的解释,并且再加上一点混乱,您可能也能应对内部感知机。
第二次深入了解到的过程直到第二次我才明白:反向传播与动态规划的相似性。这涉及到一种认识,即原则上,我可以通过跟踪“下游”节点并手动计算(更长的)导数链来计算函数对任何权重的偏导数。我可以为每个节点这样做,尽管这可能有点乏味:“反向传播”的关键思想是您可以重复使用结果以提高效率,就像动态规划一样。看到这一点也很令人满意,这解释了为什么我看过的神经网络两种处理都过于强调δ,一个看似无害的导数,实际上不应该有自己的符号。原因是这个值恰好存储在动态规划表中(在这种情况下,形状与输入神经网络相同);权重的实际偏导数实际上并不是我们所需要的。这在竞赛动态规划问题中是相当常见的一点——其中一部分技巧是找出还需要在表中存储的中间计算。然后,反向传播只是从输出节点向输入节点填写表格。
所以你明白了:链规则 + 动态规划 = 神经网络反向传播算法。当然,这种表述要求您知道如何执行链规则,以及如何进行动态规划,但我发现这些概念要更容易记住,它们的结合也非常平凡。
后记. 没有讲师能抵挡住阐述他们对“人工智能”看法的诱惑。我会趁此机会插话一句:我认为人工智能既是一个问题也是一种方法:
-
人工智能是一个问题,因为问“人类能做什么计算机做不了”这个问题是挖掘计算上有趣问题的一种重要方式,和
-
人工智能是一种方法,因为自然界的智能实例暗示了计算问题的可能解决方案。
我非常尊重人工智能的力量,它能够指导研究者提出应该问什么问题,如果我们说一种方法是人工智能,因为它在这个领域处理问题的能力相当不错,那么人工智能无处不在。(这也解释了为什么人工智能在麻省理工学院,一个非常工程导向的学校,如此蓬勃发展。)然而,我对“生物启发”仍然持怀疑态度,因为这些方法似乎并不那么有效(例如,“传统”人工智能的衰落和统计自然语言处理方法的崛起),而且由此产生的方法与其生物学对应物大相径庭,任何熟悉“神经”网络的神经科学家都会证明这一点。在某些情况下,生物类比可能会有积极有害作用,掩盖了核心的数学问题。
IntMap 的更改:ezyang's 博客
IntMap 的更改
目前,使用当前 containers API 无法对 IntMaps 定义某些严格值操作。例如,读者可以尝试高效地实现 map :: (a -> b) -> IntMap a -> IntMap b
,使得对于非底部和非空映射 m
,Data.IntMap.map (\_ -> undefined) m == undefined
。
现在,我们本可以简单地在现有的 API 上添加大量带有撇号后缀的操作,这样会大大增加其大小,但根据libraries@haskell.org 上的讨论,我们决定将模块拆分为两个模块:Data.IntMap.Strict
和 Data.IntMap.Lazy
。为了向后兼容,Data.IntMap
将成为模块的惰性版本,而当前存放在此模块中的值严格函数将被弃用。
发生的细节有点微妙。这是读者文摘版本:
-
Data.IntMap.Strict
中的IntMap
和Data.IntMap.Lazy
中的IntMap
是完全相同的映射;在两者之间没有运行时或类型级别的差异。用户可以通过导入一个模块或另一个模块来在“实现”之间切换,但我们不会阻止您在严格映射上使用惰性函数。您可以使用seqFoldable
将惰性映射转换为严格映射。 -
类似地,如果将具有惰性值的映射传递给严格函数,则该函数将在映射上执行最大惰性操作,以确保在严格情况下仍然正确操作。通常情况下,这意味着惰性值可能不会被评估……除非它是。
-
大多数类型类实例对严格和惰性映射都有效,但
Functor
和Traversable
没有遵守适当法律的有效“严格”版本,因此我们选择了它们的惰性实现。 -
惰性和严格折叠保持不变,因为折叠是否严格独立于数据结构是值严格还是脊椎严格。
我在 Hac Phi 上星期日为严格模块编写了第一个版本,您可以在此处查看。完整实现可以在此处找到
Class Reflections : ezyang’s blog
去年二月,我发表了有关我将要上的课程。现在期末项目和考试结束,我想做些反思。
6.005:软件构造。教授学生如何设计大型软件项目是你在学术生涯中可能遇到的最奇怪的悖论之一。学院当然能够教授概念、工具和方法论,但要真正从零开始构建系统?这并不是你可以学到的,而是你必须去做的。要想开始尝试真实代码的味道而不是学校代码(学期结束时会丢弃的代码),需要投入的工作量远超一个学期。我们开玩笑说,MIT 应该开设一个两部曲系列课程,第二部分要求你在面对不断变化的需求时修改一年前写的代码。(不幸的是,我怀疑很多人会重写这个东西:这就是实际上无法做到大规模系统的一个问题。)
一旦你解决了这个基本问题,这门课程就变得相关甚至有点愉快了。虽然我个人没从中得到太多收获,但看到课程涉及我刚开始学习编程时遇到的一些大难点,我还是感到很高兴:大致来说,课程可以分为状态机、函数式思想和关系建模。尽管其他人说的不一样,我觉得这些形式化方法很有用,并且是我帮助培养对传统命令式程序应该具备的直觉的关键途径。不幸的是,每个都是非常重要的思想,而课程并未能充分展现它们的重要性。
6.02:EECS II 导论。MIT 似乎喜欢三个:对于 6.02 而言,大三是信号、编码和网络。尽管课程声称是“入门级”的,但这些主题都不是我现在在计算机科学中真正感兴趣的内容。
在我上这门课时遇到的一个显著困难是在课程讲到频率分析时。我非常相信理解复杂系统背后的基本原理:这也是为什么基于微积分驱动的力学物理课对我有如此大帮助的原因之一。但在这里,这种偏好是适得其反的:正如罗伯特所说(我引用的),是的,你可以那样做,但这样做会很混乱,并且并不特别有见地。
6.045:自动机、计算和复杂性。非常有趣!Scott Aaronson 是一个迷人的讲师,处理完自动机和复杂性的基础知识后(课程教得很好;正如一位数学专业的学生所说,“我确实能理解这些讲座!”),课程开始涉及到密码学、可能近似正确的学习和量子计算的神奇世界(具体说来,综合期末考试中有十分之三的问题)。到最后,你将知道如何进行 Diffie-Helman 密钥交换,为什么婴儿可能能够破解 RSA,以及何时在量子比特上应用 Hadamard 门!不幸的是,评分者对于评分问题集并不“迅速”,但在我看来,6.045 的问题仅仅是行政上的。
6.945:大规模符号系统。课程内容非常值得任何有进取心的程序员掌握:组合子、模式匹配和通用分派都是强大的工具,在许多系统中都有广泛的应用。你还将学会如何使用延续(哇!)
Sussman 作为讲师是一个有趣的现象,特别是当你达到结尾的讲座时,他们基本上是在讲述他们昨晚刚想出来的想法。很少有电气工程和高度符号化的编程结合在一起,但这恰恰是 Sussman 最擅长的问题:他知道如何解决电路工程问题,并且他想弄清楚一个基本上是人工智能系统的实现细节,该系统也具备这方面的知识。不幸的是,如果你对这种分析不是完全精通,那么所作的类比可能很难理解,这在学期后期是一个主要的阻碍点。反馈时晚到几乎没有;如果你不需要太多动力来学习这些东西,可以选这门课程。
21M.283:舞台和屏幕上的音乐剧。我看了很多音乐剧。真是太棒了。
Classes begin:ezyang 的博客
Classes begin
因此,2010 年春季学期的课程开始了。我目前注册的课程包括:
-
6.005:软件构造
-
6.02: 电子工程与计算机科学导论 II
-
6.045:自动机、计算与复杂性
-
6.945:大规模符号系统
-
21M.283:舞台和银幕上的音乐剧
6.945 是本学期的“有趣”课程;我期望得花费很多时间并且从中获得很多收获。6.005 和 6.02 则是因为我的学位要求而必须选修的(我已经将 6.02 安排在冲突课程之上,所以可能需要更多的努力来确保我掌握了所有的课程内容)。6.045 是我本学期的数学课程;不幸的是,没有纯粹的课程 18 类别的课程!受 Robert Jacobs 的建议,21M.283 是我本学期的 HASS 类似的课程(我很高兴我已经完成了 HASS-D 的要求)。
在本学期我没有选修的课程中包括:6.006(天知道我确实需要算法知识),7.01X(踢踢踢)和 6.824(听起来很有趣,但会导致三重冲突,我不愿意这样做)。
Comonads and Convolutions : ezyang’s blog
> {-# LANGUAGE GeneralizedNewtypeDeriving #-}
> import Control.Comonad
> import Data.List
来自 category-extras
的那个可怕的 Control.Comonad
导入将成为今天文章的主题。我们将看看一个可能的非空列表的余单子实现,它模拟因果时不变系统,这些系统的输出仅依赖于过去的输入。我们将看到这些系统中的计算遵循余单子结构,并且该结构的一个实例强烈强制执行因果性和弱化时不变性。
我们的因果列表简单来说就是一个带有额外约束的 newtype
列表,即它们不为空;causal
是一个“智能构造器”,用来强制执行这个约束。我们使用 GeneralizedNewtypeDeriving
来自动获得 Functor
实例。
> newtype Causal a = Causal [a]
> deriving (Functor, Show)
>
> causal :: a -> [a] -> Causal a
> causal x xs = Causal (x:xs)
>
> unCausal :: Causal a -> [a]
> unCausal (Causal xs) = xs
>
> type Voltage = Float
背景.(如果您已经熟悉信号处理,请随意跳过此部分。)这样的系统模拟了跨不完美电线通道的电压样本的点对点通信。在理想世界中,我们非常希望能够假装我将任何电压输入到这个通道中,它将立即完美地将这个电压传输到通道的另一端。实际上,我们会看到各种不完美,包括上升和下降的时间,延迟,振铃和噪声。噪声是个扫兴的东西,所以在本文中我们将忽略它。
初步的近似条件可以对我们的系统施加以下重要的条件:
-
因果性. 我们的电线不能窥视未来并在甚至获得电压之前传输一些电压。
-
时不变性. 任何信号,无论现在发送还是延后发送,都会得到相同的响应。
-
线性. 对于电线来说是一个简单且有用的近似,它陈述了这个数学属性:如果输入
x1
得到输出y1
,输入x2
得到输出y2
,那么输入Ax1 + Bx2
将得到输出Ay1 + By2
。这也意味着我们得到了叠加,这是一个我们很快会使用的重要技术。
当你看到一个线性时不变系统时,这意味着我们可以使用一个喜欢的数学工具,即卷积。
离散卷积. 通道执行的离散化计算的总体结构是 [Voltage] -> [Voltage]
;也就是说,我们输入一系列输入电压样本,得到另一系列输出电压样本。另一方面,离散卷积是由以下函数计算的(变量名称具有启发性):
(u ∗ f)[n] = sum from m = -∞ to ∞ of f[m]u[n-m]
这里并不完全明显为什么卷积是我们在这里寻找的数学抽象,因此我们将简要推导一下。
我们计算的一个特殊情况是当输入对应于[1, 0, 0, 0 ...]
,称为单位样本。实际上,由于线性性和时不变性,当我们的系统给定单位样本时,单位样本响应精确地指定了系统对所有输入的行为:任何可能的输入序列都可以由延迟和缩放的单位样本组成,并且线性性质告诉我们可以将所有结果加在一起得到一个结果。
实际上,一个列表实际上是一个函数ℕ → a
,如果我们假设约定f[n] = 0
对于n < 0
。假设f[n]
代表我们随时间变化的输入样本,δ[n]
代表一个单位样本(δ[0] = 1
,对所有其他n
,δ[n] = 0
;你通常会看到δ[n-t]
,这是时间t
的单位样本),而u[n]
代表我们的单位样本响应。然后,我们将f[n]
分解为一系列单位样本:
f[n] = f[0]δ[n] + f[1]δ[n-1] + ...
然后使用线性性质来检索我们的响应g[n]
:
g[n] = f[0]u[n] + f[1]u[n-1] + ...
= sum from m = 0 to ∞ of f[m]u[n-m]
看起来就像离散卷积,只是没有-∞的边界。请记住,我们定义了对于m < 0
,f[m] = 0
,因此这两者实际上是等价的。
在写出等价的 Haskell 之前,我想再谈一下最后的数学定义。我们最初声明输入-响应计算的类型是[Voltage] -> [Voltage]
;然而,在我们的数学中,我们实际上定义了一个关系[Voltage] -> Voltage
,一个特定通道的函数,它接受直到时间n
的所有输入,即f[0]..f[n]
,并返回单个输出g[n]
。我用一种具有暗示性的柯里化形式写了以下定义,以反映这一点:
> ltiChannel :: [Voltage] -> Causal Voltage -> Voltage
> ltiChannel u = \(Causal f) -> sum $ zipWith (*) (reverse f) u
单位样本响应可以是有限或无限列表,出于效率考虑,建议使用有限列表:
> usr :: [Voltage]
> usr = [1,2,5,2,1]
共函子。现在,我们应该清楚我们一直在努力达成的目标:我们有ltiChannel usr :: Causal Voltage -> Voltage
,而我们想要:Causal Voltage -> Causal Voltage
。这正是共函子引起的计算形式!为了方便起见,这里是Copointed
和Comonad
类型类的定义:
class Functor f => Copointed f where
extract :: f a -> a
class Copointed w => Comonad w where
duplicate :: w a -> w (w a)
extend :: (w a -> b) -> w a -> w b
Copointed
实例非常直接,但说明了为什么Causal
必须包含非空列表:
> instance Copointed Causal where
> extract (Causal xs) = head xs
Comonad
实例可以使用duplicate
或extend
定义;两者在彼此的默认实现中已定义。推导这些默认实现留给读者作为练习;我们将在这里定义两者:
> instance Comonad Causal where
> extend f = Causal . map (f . Causal) . tail . inits . unCausal
> duplicate = Causal . map Causal . tail . inits . unCausal
代码的意图有些被Causal
的解包和封装所遮蔽;对于一个纯列表,实例看起来像这样:
instance Comonad [] where
extend f = map f . tail . inits
duplicate = tail . inits
函数 duplicate
真正深入了解到这个共单子实例所做的事情:我们将输入列表转换为历史记录列表,每一步都比上一步进一步。tail
跟随以丢弃 inits
的第一个值,这是一个空列表。duplicate
构建起 w (w a)
,然后用户提供的函数将其拆解为 w b
(如果你考虑到单子,提升的用户函数会构建起 m (m b)
,然后 join
将其拆解为 m b
。)
一个快速测试来确保它工作:
> unitStep :: Causal Voltage
> unitStep = Causal (repeat 1)
>
> result :: Causal Voltage
> result = unitStep =>> ltiChannel usr
而事实上,result
是:
Causal [1.0, 3.0, 8.0, 10.0, 11.0, 11.0, ...]
=>>
是一个翻转的 extend
,是单子 >>=
的共单子等效物。
强制不变性。 我们以这种形式结构化我们的计算(而不是明确地写出该死的卷积)在我们的代码中产生了一些有趣的强制不变性。我们的通道不必是线性的;我可以在与单位样本响应卷积之前对所有输入进行平方处理,这显然不是线性的。然而,我们写的任何通道 必须 是因果的,并且通常是时不变的:它必须是因果的,因为我们从未将任何未来的值传递给用户函数,并且它是弱时不变的,因为我们不显式地让用户知道输入流的进度。在我们的实现中,他们可以通过 length
推测这些信息;我们可以使用一个将列表反转并附加 repeat 0
的组合器获得更强的保证:
> tiChannel :: ([Voltage] -> Voltage) -> Causal Voltage -> Voltage
> tiChannel f (Causal xs) = f (reverse xs ++ repeat 0)
>
> ltiChannel' :: [Voltage] -> Causal Voltage -> Voltage
> ltiChannel' u = tiChannel (\xs -> sum $ zipWith (*) u xs)
在这种情况下,u
必须是有限的,如果它是无限的,可以在某个点截断它,以指定我们的计算应该多精确。
未解之谜。 单位样本响应在我们的示例代码中被表达为 [Voltage]
,但它实际上是 因果电压
。不幸的是,共单子似乎没有指定结合共单子值的机制,就像列表单子自动结合列表每个值的计算结果一样。我有点好奇类似于这样的东西可能如何工作。
计算函数组合:ezyang 的博客
这是我在Thunk 泄漏解剖中的第二个例子的附录,其中我想提出另一个解决空间泄漏的方案,涉及计算所有这些 thunk 的组合。这个解决方案特别显著,因为它保留了原始函数的指示,即 f l (undefined, undefined) = (undefined, undefined)
。这应该是令人惊讶的,因为我声称 GHC 不可能通过更急切地评估某些 thunk 来优化具有这种指示的函数以消除空间泄漏。这并不矛盾:我们想在这里应用的优化是部分评估的一种。不理解?别担心,一个具体的例子即将来临。
正如 Heinrich Apfelmus 指出的那样,空间泄漏可以被视为一个大的表达式图,尚未折叠为单个值:1 + (1 + (1 + (1 + (1 + (1 + ...)))))
。我们可以将这个图像化为函数连续迭代构建的过程:
引入严格性(而改变函数的指示)的要点在于我们持续地(评估)树的折叠。
但请注意红色高亮显示的值:在进行任何计算之前,我们必须知道这个值是什么。但是如果这个值是未知的(或者在我们的情况下,如果我们在形成这个图时不想评估它),我们的策略实际上不起作用。我们无法折叠整个树。然而,(这是关键),因为加法是结合的,我们可以旋转树,然后评估(现在是左侧的)子树。
实际上,所有的 thunk 都已经合并在一起:而不是 1 + 1 + 1 + X
,现在我们有了 3 + X
。简单!这里是实现:
f l (x0, x1) = go l (0, 0)
where go [] (!c0, !c1) = (c0 + x0, c1 + x1)
go (x:xs) !c = go xs (tick x c)
tick x (!c0, !c1) | even x = (c0, c1 + 1)
| otherwise = (c0 + 1, c1)
go
本质上是 f
的严格版本,但在迭代结束时返回一个带有两个 thunk 的对:c0 + x0
和 c1 + x1
,其中 c0
和 c1
都已完全评估。
这是我们正在做事情的另一种思考方式:
如果能够自动完成这个过程将会很酷,并且在其他领域也非常适用。结合结合性函数在并行化时是一种宝贵的资源。
现代 GHC 中的 STG 成本语义:ezyang 的博客
来源:
blog.ezyang.com/2013/09/cost-semantics-for-stg-in-modern-ghc/
现代 GHC 中的 STG 成本语义
学术出版的一个问题是难以使旧论文保持最新。这对于这篇1995 年 Sansom 关于非严格高阶函数式语言剖析的论文来说显然也是如此。尽管论文的基本思想仍然成立,但在 GHC 中成本中心的实际实现已经发生了相当大的变化,也许最显著的变化是成本中心栈的引入。因此,虽然旧论文很好地向你介绍了 GHC 中剖析的基本思想,但如果你真的想了解详情,这篇论文提供的指导有限。
当你的成本语义过时时,你会怎么做?当然是更新它们!我呈现了一份现代 GHC 中 STG 的更新成本语义(PDF)(GitHub)。最终,这些将会放入 GHC 代码库中,与core-spec类似,后者是 Core 语言的类似文档。然而,我还没有用这些语义做过任何证明,所以它们可能还有些 bug。
尽管没有证明,但形式化已经非常有帮助:我已经发现了当前实现中的一个 bug(在文档中有所记录)。我还根据当前规则的设置方式,确定了一次潜在的重构。请告诉我你发现的其他任何 bug!
创造性折叠映射:ezyang 的博客
过去 50 年来为我们服务良久的编程技巧包是前行的错误方式,必须被抛弃。
上周,Guy Steele 来了,为我高级符号课(6.945)做了一次客座讲座 "未来是并行的:程序员该怎么做?"。这真是一场非常精彩的演讲;如此出色的演讲,以至于在演讲之前我已经看过幻灯片。然而,听 Guy Steele 亲自讲述确实帮助我更好地理解了背景。
演讲的核心观点之一是呼吁更多创造性的折叠映射。那么,什么是创造性的折叠映射?要回答这个问题,我们首先要了解什么是折叠映射。函数式编程群体对折叠映射有一些相对平凡的例子非常熟悉,即左折叠和右折叠。理解折叠的一种方式只是比在命令式语言中编写的循环更高一层次的“抽象水平”。折叠的另一种方式是用另一个函数替换列表的类型构造器(cons 或 :
操作),如 Cale Gibbard 的出色图表所示:
折叠映射的要点在于,这不仅适用于列表;事实上,我们可以对任何递归数据结构运行折叠映射!只需为类型中的每个构造器编写一个函数,具有适当的元数(因此三元树将需要接受三个参数的函数,依此类推),然后让它发挥作用!这非常重要,因为老式的左折叠和右折叠是“错误的思维方式”;由于它们的结构特性,它们要求你按顺序评估。但是在二叉树中设置好结构,你可以在最后组合它们之前先评估所有子树。
什么是创造性的折叠映射(catamorphism)?这是当原始递归数据结构无法清晰地映射到计算需要处理的原子时。Guy Steele 在他的演讲中讨论的例子是传统任务,将字符串分解为单词。字符串仅仅是字符列表,这仅让我们逐个字符处理(传统顺序),或者是朴素地转换为二叉树,这只提供了高效的二分(可并行化)。朴素二分的问题在于可能在单词中间分割,因此我们的合并函数必须考虑这种情况。如何处理这个问题留给读者作为练习(或者你可以去看幻灯片)。
实际上,这是我理解 Edward Kmett 在他关于"并行解析三连击:迭代器、Parsec 和单子"的(在我看来相当疯狂)演讲背后的全局推理的关键时刻。这段代码的目标是通过将输入文档分割成块并使用解析函数重新组合它们来进行大规模并行解析。他不得不处理与 Steele 演讲中的玩具示例中出现的相同问题,并采用各种技巧来解决这些问题。
我承认,这项工作很复杂,有时感觉有点过火。但这是一个勇敢的新并行世界,现在是我们充分探索其设计和影响的时候了。带着一些运气,我们将能够像写顺序程序一样自然地编写并行程序,但这条路还很漫长。
更新(2013-05-21)。 Oleg 给我写信说,这些技巧实际上有一个名称:准同态。看到在Skepara 项目描述的工作与 Guy Steele 和 Fortress 项目合作,确实是值得一看,因为它提供了一种计算方法来推导这些 catamorphism。
Cup of FP with a Java twist : ezyang’s blog
zip: List<A>, List<B> -> List<(A, B)>
zip(Nil, Nil) = Nil
zip(_, Nil) = Nil
zip(Nil, _) = Nil
zip(Cons(a, as), Cons(b, bs)) = Cons((a, b), zip(as, bs))
fst: (A, B) -> A
fst((a, _)) = a
last: List<A> -> A
last(Cons(a, Nil)) = a
last(Cons(a, as)) = last(as)
foldl: (B, A -> B), B, List<A> -> B
foldl(_, z, Nil) = z
foldl(f, z, Cons(x, xs)) = foldl(f, f(z, x), xs)
天啊,爱德华,你那里有什么?简直像是 Haskell、Java 和 ML 的变种混合体。
它实际上是由 Daniel Jackson 发明的受 ML 启发的伪语言。它被 MIT course 6.005 用来教授其学生函数编程概念。它没有编译器或正式规范(尽管我听说助教们正在拼命地研究一种类型),但其语法的最显著点在 第 10 讲(PDF) 中介绍,当他们开始讨论如何构建 SAT 求解器时。
我们的第二份问题集要求我们在这种伪语言中编写一些代码。不幸的是,作为伪语言,您实际上无法运行它...而且我讨厌写我无法运行的代码。但它确实看起来很像 Haskell...只是更啰嗦了一点。我问课程工作人员是否可以用 Haskell 提交问题集,他们告诉我:“不行,因为课程工作人员不懂。但如果它确实与这种语言如您所说的那么接近,您完成后可以将其翻译成这种语言。”
我就是这样做的。
这个计划实际上是不可能的,没有一个现有的 Haskell 漂亮打印程序 来为我做大部分的脚手架工作。从那里开始,在适当的函数中混合使用 <>
、lparen
和 comma
等朋友来渲染数据类型。漂亮打印组合器太棒了!
内核中的巧妙宏技巧:ezyang 的博客
内核中的巧妙宏技巧
给 C 程序员的一个经典风格提示是,尽可能使用内联函数而不是宏。这个建议源于宏和内联函数可以达到相同的效果,但内联函数还可以进行类型检查。
结果表明,如果愿意采用 Linux 内核下面这个小巧的技巧,你确实可以通过宏实现静态类型检查:
#define module_param_named(name, value, type, perm) \
param_check_##type(name, &(value)); \
module_param_call(name, param_set_##type, param_get_##type, &value, perm); \
__MODULE_PARM_TYPE(name, #type)
嗯... 我想知道那个param_check_##type
调用是怎么回事。再深入挖掘几个宏定义,我们看到:
#define __param_check(name, p, type) \
static inline type *__check_##name(void) { return(p); }
就是这样。一个名为__check_##name
的一次性内联函数确保p
与type
是相同类型。还附有一条注释,解释了发生了什么:
/* The macros to do compile-time type checking stolen from Jakub
Jelinek, who IIRC came up with this idea for the 2.4 module init code. */
数据即代码:ezyang 的博客
昨天,我有幸参加了Chung-Chieh Shan的学术报告,主题是嵌入式概率语言。关于报告的完整内容可以在本文中找到,所以我想专注于一个特定的大观点:即数据即代码的理念。
Lisp 程序员熟悉的口头禅是,“代码即数据”,这个概念认为每个源代码清单背后都有一个由 cons 单元和标签组成的数据结构,表示可以构建、修改和评估的代码。在这个框架下,一小部分数据是代码:'(cons 1 (cons 2 ()))
是代码,但'((.5 ((.5 #t) (.5 #f))) (.5 ((.5 #t))))
则不是。
在什么情况下后者可以成为代码呢?考虑以下问题(一种希望明确表达的男孩或女孩悖论):
你闭上眼睛。我会递给你一个红球或一个蓝球。然后,我会再递给你一个红球或一个蓝球。然后你偷偷看了一眼,发现至少有一个球是红色的。第一个球是红色的概率是多少?
了解概率的你们可能会编写概率表格,并得出答案是2/3
,但对于那些不太确信的人来说,可能会去编写一些代码来模拟这种情况:
a <- dist [(.5, red), (.5, blue)]
b <- dist [(.5, red), (.5, blue)]
if a != red && b != red
then fail
else a == red
其中dist
是从分布中随机选择变量的某个函数,而fail
则报告矛盾并忽略生成的宇宙。这段代码是数据,但它比抽象语法树更深刻地是数据。特别是,它编码了推理树 '((.5 ((.5 #t) (.5 #f))) (.5 ((.5 #t))))
:
O O
/ \ / \
/ \ / \
R B .5 .5
/ \ / \ / \ / \
RR RB BR BB .25.25.25
#t #t #f
旁注. 对 Haskeller 来说,现在可以尝试去编写上述代码建议的概率单子的朴素和延续传递实现,这是一个返回所有可能结果概率列表的单子。这是一个有趣的技术细节,可能会成为未来博客文章的主题,但在上述链接的论文的 2.2、2.3 和 2.4 节中已经很好地讨论了这个问题,并且在延续使用社区中是相当标准的做法。
现在,我并没有真正向你展示数据如何成为代码;相反,我展示了代码如何映射到“抽象语法树”表示或“推理树”表示。然而,与 AST 不同的是,我们不应该简单地构建整个推理树:推理树的节点如果有许多子节点,会呈指数级分支,我们在尝试进行精确推理时会在内存耗尽之前做不了多少事情。
然而,如果我们遵循“数据即代码”的口头禅,并将我们的树表示为惰性数据结构,其中每个节点的子节点实际上是一个延续,表示“为我构建这个子树”,我们可以恢复一个高效的表示。这些延续本身可以包含更多的延续,这些延续要放置在子树的叶子节点上,并且可以用叶子的值应用。因此,我们的数据结构在很大程度上由代码表示。(事实上,所有惰性数据结构都是这样工作的,但在这种情况下尤为显著。)
更具有说服力的是,对于分界延续的一流支持意味着你可以将一个常规函数 () -> e
实体化为一个(部分)树结构,其中更多的延续作为子节点准备好自行实体化。当然,我们可以评估这个树结构,以将其转回成一个函数。(在 Haskell 中,Monad 通过在抽象接口中无处不在的 lambda 使这种表示免费获得了一些小技巧。)
我发现真正迷人的是,一整类用于高效概率推断的算法,在推理树顶端重新组合时变得显而易见。例如:
-
变量和桶消除对应于记忆化延续,
-
拒绝采样对应于随机地沿着我们的树遍历路径,丢弃导致矛盾 (
fail
) 的样本,并且 -
重要性采样对应于随机地遍历路径,但如果一个分支失败,则切换到另一个分支。
作为浅层嵌入,遗憾的是我们无法进行像比较两个延续是否相等或进行复杂的代码分析这样的事情。但是一些初步的实验结果显示,这种方法在与现有的专门构建的推理引擎竞争时具有竞争力。
这里有一个更大的故事待讲述,关于 DSL 编译器,我们给用户提供工具来轻松实现他们自己的语言,从而提高其表达能力和生产力,但也允许他们实现自己的优化,从而不会像通常情况下仅编写解释器那样损失速度。我们希望利用现有的编译器框架,但根据适当的情况增加我们自己问题领域的增强功能。我们希望为我们的问题域提供行为规范,并教导编译器如何解决细节问题。编写一个适合所有人的编译器是不可行的,但每个人都可以拥有编译器精神 —— 我认为这将对软件工程产生令人兴奋和解放的影响。
数据库即范畴:ezyang 的博客
更新 视频可以在这里找到:Galois 技术讲座 Vimeo: 类别即数据库。
上周四,博士大卫·斯皮瓦克在 Galois 的技术讲座上,演讲了《类别即数据库》。他的幻灯片在这里,比他的论文《单纯数据库》更加易于理解。这里简要介绍这个概念,适合那些对范畴论只有初步了解的人士。
在设计关系数据库时的一个重要练习是使用对象和关系的标记图进行对象建模。在视觉上,这涉及到绘制代表正在建模的对象的一堆框,并在对象之间画箭头显示它们可能具有的关系。然后,我们可以将这个对象模型作为关系数据库模式的基础。
软件工程课程中的一个例子模型如下:
当你脑中有一个对象模型的形象时,请考虑维基百科对范畴的定义:
在数学中,一个范畴是一个由一组“对象”组成的代数结构,它们通过一组“箭头”相互连接,具有两个基本属性:箭头的可结合性和每个对象存在一个身份箭头。
定义的其余部分可能看起来非常抽象,但希望粗体部分清晰地对应于我们之前绘制的框(对象)和箭头的图片。也许...
数据库模式 = 范畴。
不幸的是,一个有向图并不完全是一个范畴;使得范畴成为范畴的关键因素是箭头上的这两个属性,可结合性和身份性。如果我们真的想加强我们的论断,即模式是一个范畴,我们需要证明这些属性。
记住,我们的箭头是“关系”,即“X 占据 Y”或“X 是 Y 的关键”。我们的范畴必须有一个身份箭头,即某种关系“X 到 X”。那么,“X 就是 X”,一个几乎空洞的陈述,但绝对正确。身份箭头,检查。
我们还需要展示箭头的可结合性。两个箭头的组合很像他们在教你向量代数时所展示的:你拿一个箭头的头(从 X 到 Y),并将它与另一个箭头的尾(从 Y 到 Z)粘合在一起,你得到另一个箭头(从 X 到 Z)。如果“书有作者”和“作者有最喜欢的颜色”,我可以说“书的作者有最喜欢的颜色”。这个组合的陈述并不关心作者是谁... 只关心他最喜欢的颜色是什么。实际上,
箭头组合 = 连接
也就是说,范畴的一个基本特征,任何纯范畴论中的好结果都使用它,仿佛它是直观显而易见的特性,是那些在数据库教程的后半部分读者看起来并不显而易见的技术之一。
(旁注. 外键关系本质上是多对一的:外键字段只能指向另一个表中的一条记录,但许多行可以将该字段指向同一条记录。在关系建模时,我们经常使用多对多或一对多关系。然而,任何数据库管理员都知道,我们可以简单地将这些重新编写为多对一关系(在一对多情况下颠倒箭头,并引入新表以进行多对多关系)。)
当我们有一个模式时,我们也希望有数据来填充这个模式。事实证明,这也适合范畴论框架,尽管完整的解释不在本文范围内(建议查看幻灯片)。
函子(C -> S)= 数据
你为什么要关心这个?Spivak 提到了一些好的理由:
我会提到我自己的一个例子:SQL 虽然混乱,但是精确;它可以被输入计算机,并转化为可以进行实际工作的数据库。另一方面,关系模型是高层次的但有点模糊;开发者可能会抱怨,用箭头画图看起来并不是非常严格,形式主义并不真正帮助他们很多。
范畴论是精确的;它明确地赋予关系意义和结构,组合法则定义了哪些关系是可允许的,哪些是不允许的。范畴论不仅仅是关于箭头(如果只有箭头的话会相当无聊);相反,它拥有许多领域的丰富成果,用一种通用语言表达,可以“翻译”为数据库术语。在许多情况下,重要的范畴论概念是数据库管理员传说中棘手的技术。当你谈论箭头时,你谈论的远不止箭头。这是非常有力的!
一个 Galois 实习生的一天:ezyang 的博客
来源:
blog.ezyang.com/2010/08/day-in-the-life-of-a-galois-intern/
Vrrmm! Vrrmm! Vrrmm!
现在是上午 9 点,我枕边的手机正在预示性地震动。我起床并且在闹铃开始大响之前就把它停掉,然后探出房间的窗户看了看外面。
波特兰的夏天是个善变的季节:我实习的头一个月的天气充满了薄雾和雨水(唐告诉我,这在波特兰是非常不寻常的现象),而第二个月的天气早晨则是一片昏昏沉沉的灰色。“现在是夏天了吗?”成为了大部分七月里#galois
的讨论话题。但在八月的深处,夏天终于来了,阳光映入我的眼帘。穿短裤和 T 恤,不需要穿毛衣!我在心里默默地说“是的!”
我穿好衣服,跟坐在我办公桌椅子上的白猫 Pixie 道别,跳过早餐,拿起我的自行车,朝波特兰市中心的方向出发。(警告:本文其余部分同样缺乏任何技术细节(除了最后一段)!而且,我是一个技术含量极低的摄影师,不知道如何后期处理照片。)
通勤。 我每天骑自行车上班。骑行大约需要 30 分钟。
在路上,我穿过了威拉米特河:
这是一座双层桥,底层平台在船只需要通过时会抬起。不幸的是,在我早上上班的通勤途中偶尔会遇到这种情况,那时我不得不沿着滨河大道再骑一段路。
在旅程的尽头,我迎来了波特兰联邦大厦那熟悉的面孔。
就建筑而言,这是一座相当有名的建筑:它是第一批建成的玻璃摩天大楼之一。我听说来自城市各大学的建筑学生经常来看这座建筑。
Galois 在三楼。
我把自行车停在办公室里一个方便的自行车架上:
然后我朝我的办公桌走去。(包含疯狂的桌友。😃)
办公室。 现在我们在 Galois,也许是时候快速参观一下办公室了。Galois 办公室只有一个楼层,有几个值得注意的房间。其中非常重要的是厨房:
可以获得咖啡(波特兰人对咖啡非常认真!这让我几乎希望我也是个咖啡饮用者)的地方:
厨房是全员会议的场所(Galois 的规模小到可以把公司的所有员工都放进一个房间里——只有我实习过的初创公司 Ksplice 也获得了这一荣誉)。关于 Galois 文化的一个很好的反映是赞赏的实践,期间 Galois 员工互相赞赏本周发生的事情。
这里有一个小图书馆(一个安静的好地方,如果需要进行特别艰深的思考):
一个会议室:
甚至还有一个小房间,你可以在那里小憩一会儿!
十二点钟时,我们盖尔韦格人已经饿了,所以我们出去吃午饭。
在波特兰市中心有一个巨大的优势:食品车。我从未见过类似的场景:街区上竟然摆满了各种食品车,供应你喜欢的任何风格的食物。
波特兰也因其对素食友好而闻名。你可以品尝到素食培根芝士汉堡!(说实话,它们相当美味,就我这个食肉动物而言。)
或者喝果汁冰沙。
拿到食物后,我们回到办公室大快朵颐。
我们的首席科学家和坐在我对面的工程师在午餐后打乒乓球!(我也打了几局:他们相当不错——反旋转球、顶旋转球,这比我能跟上的多!)
提供建筑. 在周二,许多人不是聚集在厨房,而是聚集在会议室:这是 MOB 午餐!
MOB 代表“合并报价建设”,尽管这个名字本身也很有趣:“MOB 会给你一个你无法拒绝的报价。” 不同于传统产品公司,其中工程部门负责制造产品,然后销售部门负责找客户并说服他们购买你的产品,在 Galois,对于许多合同,工程师们就是销售人员:他们负责撰写我们提交的资金申请书。 MOB 会议协调各种报价建设工作——虽然它与我的实习没有直接关系,但参加 MOB 午餐是对 SBIR、采购、EC&A 等许多缩写词世界的迷人窥视。
简而言之,今年夏天在 Galois 实习非常精彩,非常遗憾只剩下一周了。我会想念大家的!♥
后记. 经过一个夏天的科技讲座撰写,我将在接下来的周二举办一场Galois 科技讲座!它将深入讲解 abcBridge,这是我在夏季期间建立的 Haskell 库。如果你在附近,欢迎过来看看!
Dead Edward Day : ezyang’s blog
Dead Edward Day
软件工程师在使用抽象之前是否应该要求实现这些抽象?(有点像数学教科书中使用定理前要先证明一样。)在教学目的上有点像重复造轮子。
(自从周六病倒以来,今天是一个 Dead Edward Day。希望我能在本周五之前清理 DP Zoo 文章,并附加更多注释。)
使用优化燃料调试编译器:ezyang 的博客
来源:
blog.ezyang.com/2011/06/debugging-compilers-with-optimization-fuel/
今天我想描述一下我如何精确定位编译器错误,具体来说,是被优化触发的错误,使用了一个叫做优化燃料的巧妙功能,这个功能是由 Hoopl 引入的。不幸的是,这不是一个特别容易在 Google 上找到的术语,所以希望这篇文章也能帮助到一些人。优化燃料最初是由 David Whalley 在 1994 年的一篇论文自动隔离编译器错误中提出的。基本思想是编译器执行的所有优化可以被限制(例如通过限制燃料),所以当我们怀疑优化器行为异常时,我们进行二分搜索,找到在引入错误之前能够给予编译器的最大燃料量。然后我们可以检查有问题的优化并修复错误。优化燃料是新代码生成器的一个特性,只有当你向 GHC 传递-fuse-new-codegen
参数时才可用。
缺陷
当我尝试使用新代码生成器构建 GHC 本身时,bug 就出现了。构建 GHC 是发现 bug 的一个好方法,因为它有这么多代码,它成功覆盖了很多情况:
"inplace/bin/ghc-stage1" (...) -o compiler/stage2/build/FastString.o
ghc-stage1: panic! (the 'impossible' happened)
(GHC version 7.1 for i386-unknown-linux):
RegAllocLinear.makeRegMovementGraph
Please report this as a GHC bug: http://www.haskell.org/ghc/reportabug
我们迅速地在代码库中使用 grep 命令来找到相关错误,它位于compiler/nativeGen/RegAlloc/Linear/JoinToTargets.hs
文件中:
-- | Construct a graph of register\/spill movements.
--
-- Cyclic components seem to occur only very rarely.
--
-- We cut some corners by not handling memory-to-memory moves.
-- This shouldn't happen because every temporary gets its own stack slot.
--
makeRegMovementGraph :: RegMap Loc -> RegMap Loc -> [(Unique, Loc, [Loc])]
makeRegMovementGraph adjusted_assig dest_assig
= let
mkNodes src vreg
= expandNode vreg src
$ lookupWithDefaultUFM_Directly
dest_assig
(panic "RegAllocLinear.makeRegMovementGraph")
vreg
in [ node | (vreg, src) <- ufmToList adjusted_assig
, node <- mkNodes src vreg ]
但是源代码并没有特别指出问题可能在哪里。现在是开始使用优化燃料的时候了!
二分搜索
我们可以通过改变-dopt-fuel
的值来修改 GHC 用于运行优化的优化燃料数量。如果我们发现 bug 在没有优化燃料的情况下出现,我们首先要做的是:
$ "inplace/bin/ghc-stage1" (...) -o compiler/stage2/build/FastString.o -dopt-fuel=0
太棒了,成功了!我们选择一个较大的数字作为我们二分搜索的起点(并传递-fforce-recomp
,这样 GHC 实际上会编译程序)。
$ "inplace/bin/ghc-stage1" (...) -o compiler/stage2/build/FastString.o -dopt-fuel=1000 -fforce-recomp
ghc-stage1: panic! (the 'impossible' happened)
(GHC version 7.1 for i386-unknown-linux):
RegAllocLinear.makeRegMovementGraph
Please report this as a GHC bug: http://www.haskell.org/ghc/reportabug
然后我进行二分搜索(测试 500,如果失败则测试 750 等),直到找到添加一个燃料单元导致失败的点。
$ "inplace/bin/ghc-stage1" (...) -o compiler/stage2/build/FastString.o -dopt-fuel=709 -fforce-recomp
$ "inplace/bin/ghc-stage1" (...) -o compiler/stage2/build/FastString.o -dopt-fuel=710 -fforce-recomp
ghc-stage1: panic! (the 'impossible' happened)
(GHC version 7.1 for i386-unknown-linux):
RegAllocLinear.makeRegMovementGraph
查看罪魁祸首
如何说服 GHC 告诉我们它在第 710 个燃料单位时做了什么优化呢?我最喜欢的方法是从两次运行中输出优化后的 C--代码,然后进行比较。我们可以使用-ddump-opt-cmm -ddump-to-file
将 C--代码输出到文件,然后进行比较:
@@ -10059,7 +10059,6 @@
}
c45T:
_s3es::I32 = I32[Sp + 4];
- _s3eu::I32 = I32[Sp + 0];
// deleted: if (0) goto c460;
// outOfLine should follow:
_s3er::I32 = 0;
@@ -10093,1354 +10092,3 @@
jump (I32[Sp + 0]) ();
}
优化正在删除一个赋值。这有效吗?这是完整的代码,带有一些注释:
FastString.$whashStr_entry()
{ [const 131081;, const 0;, const 15;]
}
c45T:
_s3es::I32 = I32[Sp + 4];
_s3eu::I32 = I32[Sp + 0]; // deleted assignment
_s3er::I32 = 0;
_s3ex::I32 = 0;
goto c463;
c460:
R1 = FastString.$whashStr_closure;
jump (I32[BaseReg - 4]) ();
c463:
if (I32[GHC.Types.Bool_closure_tbl + ((_s3er::I32 == _s3es::I32) << 2)] & 3 >= 2) goto c46d;
// uh oh, assignment used here
_s3IC::I32 = %MO_S_Rem_W32(%MO_UU_Conv_W8_W32(I8[_s3eu::I32 + (_s3er::I32 << 0)]) + _s3ex::I32 * 128,
4091);
_s3er::I32 = _s3er::I32 + 1;
_s3ex::I32 = _s3IC::I32;
goto c463;
c46d:
R1 = _s3ex::I32;
Sp = Sp + 8;
jump (I32[Sp + 0]) ();
}
似乎不是:变量在MO_S_Rem_W32
中被使用:这不好。我们得出结论,bug 在一个优化过程中,并且不是寄存器分配器未能处理我们的优化现在正在触发的情况。
修复 bug
有了这些信息,我们还可以提取导致此 bug 的程序片段:
hashStr :: Ptr Word8 -> Int -> Int
hashStr (Ptr a#) (I# len#) = loop 0# 0#
where
loop h n | n GHC.Exts.==# len# = I# h
| otherwise = loop h2 (n GHC.Exts.+# 1#)
where !c = ord# (indexCharOffAddr# a# n)
!h2 = (c GHC.Exts.+# (h GHC.Exts.*# 128#)) `remInt#` 4091#
我们还可以看到我们的流水线如何处理程序,并准确观察在过程中坏优化发生的确切位置:
==================== Post Proc Points Added ====================
{offset
c43r:
_s3es::I32 = I32[(old + 8)];
_s3eu::I32 = I32[(old + 12)];
if (Sp - <highSp> < SpLim) goto c43y; else goto c43u;
==================== Post spills and reloads ====================
{offset
c43r:
_s3es::I32 = I32[(old + 8)];
_s3eu::I32 = I32[(old + 12)];
if (Sp - <highSp> < SpLim) goto c43y; else goto c43u;
==================== Post rewrite assignments ====================
{offset
c43r:
_s3es::I32 = I32[(old + 8)];
if (Sp - <highSp> < SpLim) goto c43y; else goto c43u;
由于这是代码移除的一个虚假实例,我们在重写赋值优化步骤中寻找所有对emptyGraph
的提及:
usageRewrite :: BwdRewrite FuelUniqSM (WithRegUsage CmmNode) UsageMap
usageRewrite = mkBRewrite3 first middle last
where first _ _ = return Nothing
middle :: Monad m => WithRegUsage CmmNode O O -> UsageMap -> m (Maybe (Graph (WithRegUsage CmmNode) O O))
middle (Plain (CmmAssign (CmmLocal l) e)) f
= return . Just
$ case lookupUFM f l of
Nothing -> emptyGraph
Just usage -> mkMiddle (AssignLocal l e usage)
middle _ _ = return Nothing
last _ _ = return Nothing
看起来这应该是无可非议的死赋值消除案例,结合存活性分析,但出于某种原因,向后事实未能正确传播。事实上,问题在于我试图优化 Hoopl 数据流函数,结果搞错了。(不动点分析很棘手!)在恢复我的更改后,不合理的优化问题消失了。呼~
调试 GHC 中的 tcIfaceGlobal 错误:解读跟踪输出研究:ezyang 的博客
来源:
blog.ezyang.com/2016/05/debugging-tcifaceglobal-errors-in-ghc-a-study-in-interpreting-trace-output/
最近我解决了一个 bug,其中 GHC 表现得不够懒惰(是的,更多的懒惰是需要的!)我想这可能成为一个很好的博客文章,介绍我如何解决这类懒惰 bug,并可能引发关于如何使调试这类问题对人们更容易的有用讨论。
哎呀!一个 bug!
我们的故事始于一个待处理的补丁,涉及到我之前正在进行的一些相关更改。补丁的内容并不重要——它只是修复了一个 bug,即 ghc --make
在具有 hs-boot
文件的程序中与 ghc -c
没有相同的行为。
在验证对 GHC 测试套件的补丁时,我发现这导致 prog006
测试在 GHCi 上开始失败,并显示以下错误:
ghc-stage2: panic! (the 'impossible' happened)
(GHC version 8.1.20160512 for x86_64-unknown-linux):
tcIfaceGlobal (global): not found
You are in a maze of twisty little passages, all alike.
While forcing the thunk for TyThing Data
which was lazily initialized by initIfaceTcRn,
I tried to tie the knot, but I couldn't find Data
in the current type environment.
If you are developing GHC, please read Note [Tying the knot]
and Note [Type-checking inside the knot].
Consider rebuilding GHC with profiling for a better stack trace.
Contents of current type environment: []
tcIfaceGlobal
错误是 GHC 如何实现 hs-boot 文件的“黑暗”角落,但因为我过去一周一直在看这部分编译器,所以我决定大胆地前进。
如果你的测试案例放不进一张幻灯片,那还不够小
prog006
并不是一个简单的测试案例,因为它涉及在 GHCi 会话中运行以下命令:
:! cp Boot1.hs Boot.hs
:l Boot.hs
:! sleep 1
:! cp Boot2.hs Boot.hs
:r
虽然所涉及的源文件相对较短,但我第一个想法仍然是简化测试案例。我最初的想法是,这个 bug 可能涉及到 GHCi 如何重新加载模块的某些方面,因此我的第一个想法是尝试最小化涉及的源代码:
-- Boot.hs-boot
module Boot where
data Data
-- A.hs
module A where
import {-# SOURCE #-} Boot
class Class a where
method :: a -> Data -> a
-- Boot1.hs
module Boot where
data Data
-- Boot2.hs
{-# LANGUAGE ExistentialQuantification #-}
module Boot where
import A
data Data = forall n. Class n => D n
这个示例使用了一个花哨的语言特性 ExistentialQuantification
,如果这些使用与手头的问题无关,通常最好尝试消除它们。因此,我最初的想法是用更普通的东西替换模块 A 中的类型类,例如,一个类型同义词。(注意:为什么不试着消除 hs-boot
?在这种情况下,我碰巧知道,在编译 hs-boot
文件时,tcIfaceGlobal
错误只会发生。)
我进行了这个转换,得到了以下较小的程序:
-- Boot.hs-boot
module Boot
data Data
-- A.hs
module A
import {-# SOURCE #-} Boot
type S = Data
-- Boot.hs
module Boot
import A
x :: S
这个程序确实也产生了一个 tcIfaceGlobal
错误...但后来我意识到 Boot.hs
本身就不是良好类型化的:它缺少了 Data
的声明!事实上,当我插入了缺少的声明时,恐慌消失了。
在调试中的一个重要事项是要知道何时意外触发了不同的 bug。事实上,这确实是一个不同的 bug,我在这里报告了。
在减少这个测试用例的过程中,我发现这个 bug 与 GHCi 无关;例如,如果我只是运行ghc --make Boot2.hs
,就足以触发这个 bug。(或者,对于一个没有我的补丁的 GHC 版本,在构建其余部分后运行ghc -c Boot2.hs
,ghc --make
在引发问题的补丁之前具有不同的行为,这一切都掩盖了问题的本质。)因此,这是最终的测试用例(为了避免一些混乱的消息使用了一些更短的名称):
-- Boot.hs-boot
module Boot where
data D
-- A.hs
module A where
import {-# SOURCE #-} Boot
class K a where
method :: a -> D -> a
-- Boot.hs
{-# LANGUAGE ExistentialQuantification #-}
module Boot where
import A
data Data = forall n. K n => D n
当你知道问题所在时,调试就更容易
在调试这样的问题时,了解为什么 bug 会发生是有帮助的。而要有假设,我们必须首先问自己一个问题:tcIfaceGlobal
到底在做什么?
每当你遇到这样的恐慌时,你应该搜索错误消息并查看周围的源代码。这里是关于tcIfaceGlobal
的(在一个稍旧版本的 GHC 上,这也表现出了 bug):
; case if_rec_types env of { -- Note [Tying the knot]
Just (mod, get_type_env)
| nameIsLocalOrFrom mod name
-> do -- It's defined in the module being compiled
{ type_env <- setLclEnv () get_type_env -- yuk
; case lookupNameEnv type_env name of
Just thing -> return thing
Nothing -> pprPanic "tcIfaceGlobal (local): not found:"
(ppr name $$ ppr type_env) }
; _ -> do
如果你看到与代码相关联的注释,你绝对应该去找到它并阅读它:
-- Note [Tying the knot]
-- ~~~~~~~~~~~~~~~~~~~~~
-- The if_rec_types field is used in two situations:
--
-- a) Compiling M.hs, which indirectly imports Foo.hi, which mentions M.T
-- Then we look up M.T in M's type environment, which is splatted into if_rec_types
-- after we've built M's type envt.
--
-- b) In ghc --make, during the upsweep, we encounter M.hs, whose interface M.hi
-- is up to date. So we call typecheckIface on M.hi. This splats M.T into
-- if_rec_types so that the (lazily typechecked) decls see all the other decls
--
-- In case (b) it's important to do the if_rec_types check *before* looking in the HPT
-- Because if M.hs also has M.hs-boot, M.T will *already be* in the HPT, but in its
-- emasculated form (e.g. lacking data constructors).
所以情况(a)正是这里正在发生的事情:当我们正在对Boot.hs
进行类型检查并加载接口A.hi
时,当我们对D
的引用进行类型检查时,我们不会去对Boot.hi-boot
进行类型检查;相反,我们试图与模块中本地定义的Data
打成一片。如果类型环境中没有Data
,我们会看到我们之前遇到的恐慌。
使情况复杂的是,并没有显式调用“对D
的类型检查”;相反,这一堆工作被不安全地封装在表示D
的TyThing
的 thunk 中,而这个 thunk 嵌入在对K
描述中。当我们强制求值这个 thunk 时,GHC 将然后忙于尝试对与D
相关联的类型进行类型检查。
回到我们最初的问题:为什么本地类型环境中没有定义D
?一般来说,这是因为我们在实际将D
添加到类型环境之前就强制求值了K
的 thunk(因此导致调用tcIfaceGlobal D
)。但为什么会这样呢?有两种可能的解释:
-
第一个解释是,我们在强制求值 thunk 之前忘记更新类型环境。修复方法是在全局类型环境中添加一些额外的更新,这样当我们强制求值 thunk 时,就能看到缺失的类型。
-
第二个解释是,我们强制求值 thunk 的时间过早,有些代码需要变得更懒,这样我们才能在类型环境已经充分更新时才强制求值 thunk。
所以,问题究竟出在哪里?
读茶叶脉络
在这两种情况下,知道我们实际上在类型检查过程中什么时候强制求值 thunk 似乎是有用的。现在是时候重建带有分析工具的 GHC 并获得tcIfaceGlobal
的堆栈跟踪了,但我有点懒,所以我决定改用 GHC 的跟踪工具。
GHC 具有现有标志 -ddump-tc-trace
,-ddump-rn-trace
和 -ddump-if-trace
,它们分别倾倒了与类型检查、重命名和接口加载相关的大量调试跟踪信息。大多数这些消息非常简洁,不会详细说明消息应该如何解释;如果您想要解释这些消息,您将不得不搜索源代码,看看哪段代码输出了这些跟踪信息。
这是我们在编译 Boot.hs
时得到的跟踪的结尾:
Tc2 (src)
Tc3
txExtendKindEnv []
txExtendKindEnv []
tcTyAndCl start kind checking ()
kcTyClGroup
module Boot
data D = forall n_anU. K n_anU => D
<<some log elided here>>
tc_lhs_type:
K n_anU
Constraint
tc_infer_lhs_type: K
lk1 K
Starting fork { Declaration for K
Loading decl for K
updating EPS_
Considering whether to load GHC.Prim {- SYSTEM -}
Reading interface for GHC.Prim;
reason: Need home interface for wired-in thing TYPE
updating EPS_
tc-iface-class1 K
tc-iface-class2 K
tc-iface-class3 K
tc-iface-class4 K
buildClass
newGlobalBinder A C:K <no location info>
C:K
newGlobalBinder A $tcK <no location info>
$tcK
Starting fork { Class op method D -> a
ghc-stage2: panic! (the 'impossible' happened)
<<rest of the panic message>>
神奇的是,这个跟踪实际上告诉你确切地你需要知道什么来解决这个 bug……但我们得先知道如何解释这个跟踪。
每条跟踪消息,例如 Tc2 (src)
,Tc3
等,都带有一个唯一的字符串,您可以用它来找到跟踪的来源。例如,使用 Tc2
进行 grep 会导航到 TcRnDriver.hs
,就在我们即将开始对源文件中所有声明进行重命名和类型检查的地方。类似地,lk1
会导航到 TcHsType.hs
,在这里我们试图查找与 K
关联的 TyThing
。
Starting fork
消息特别值得关注:这是 -ddump-if-trace
的方式表达“我正在评估一个带有某些延迟工作类型检查接口的 thunk”。因此,我们可以看到,在跟踪 lk1
之后不久,我们强制执行了类型类声明 K
的 thunk;此外,在我们强制执行此 thunk 时,我们进一步强制执行了类操作 method :: D -> a
的 thunk,这实际上导致了 panic。
鲁布·戈尔德堡机器
我没有仔细阅读跟踪,因此在类型检查期间,我花了一些时间手动添加额外的跟踪声明和跟踪代码的流程。从 Tc2 (src)
开始,我们实际上可以使用跟踪来跟随类型检查的流程(这里使用 hasktags
是必不可少的!)
-
tcRnModuleTcRnM
是重命名和类型检查模块的主要入口点。处理导入后,它调用tcRnSrcDecls
对主体进行类型检查。 -
tcRnSrcDecls
调用tc_rn_src_decls
来对所有顶层声明进行类型检查;然后简化所有顶层约束并完成所有类型。 -
tc_rn_src_decls
是模板 Haskell / 类型检查/重命名舞蹈的主循环。我们首先通过rnTopSrcDecls
进行重命名,然后通过tcTopSrcDecls
进行类型检查,直到第一个 splice,然后运行 splice 并递归。 -
tcTopSrcDecls
输出Tc2 (src)
。它逐个检查所有不同类型的顶层声明。其中一个重要的是tcTyClsInstDecls
,它对类型和类声明进行类型检查,并处理推导子句。 -
tcTyClsInstDecls
调用tcTyAndClassDecls
对顶层类型和类声明进行类型检查,然后调用tcInstDeclsDeriv
处理推导。 -
tcTyAndClassDecls
处理每个互递类型/类声明组,并在它们上调用tcTyClGroup
。 -
tcTyClGroup
调用tcTyClDecls
来对组进行类型检查,然后检查一切是否良好形式。 -
tcTyClDecls
实际上是类型检查声明组。它首先用kcTyClGroup
对组进行种类检查,然后将所有组一起进行类型检查,绑定结节。 -
kcTyClGroup
输出(适当命名的)kcTyClGroup
追踪。在这一点上,我停止了追踪。
通过观察kcTyClGroup
的追踪,但没有终止的kcTyClGroup result
追踪(这在函数末尾),我们可以得知在我们进行种类检查时,坏的延迟计算被触发了。
知道恐慌发生在我们进行种类检查时实际上是非常有用的:种类检查发生在我们实际构造这些顶层声明的结节绑定TyThing
结构之前。所以我们知道,我们没有失败更新全局类型环境,因为它在这一点上肯定没有构建。必须是我们太早强制了一个延迟计算。
AAAAAAAA 是 GHC 消失在一个黑洞中的声音
此时,我非常确定lk1
,即tcTyVar
是导致最终引发恐慌的延迟计算的责任所在,但我并不确定。以下是该函数的代码:
tcTyVar :: TcTyMode -> Name -> TcM (TcType, TcKind)
-- See Note [Type checking recursive type and class declarations]
-- in TcTyClsDecls
tcTyVar mode name -- Could be a tyvar, a tycon, or a datacon
= do { traceTc "lk1" (ppr name)
; thing <- tcLookup name
; case thing of
ATyVar _ tv -> return (mkTyVarTy tv, tyVarKind tv)
ATcTyCon tc_tc -> do { check_tc tc_tc
; tc <- get_loopy_tc name tc_tc
; handle_tyfams tc tc_tc }
-- mkNakedTyConApp: see Note [Type-checking inside the knot]
-- NB: we really should check if we're at the kind level
-- and if the tycon is promotable if -XNoTypeInType is set.
-- But this is a terribly large amount of work! Not worth it.
AGlobal (ATyCon tc)
-> do { check_tc tc
; handle_tyfams tc tc }
对K
的tcTyVar
应该导致AGlobal (ATyCon tc)
,在该分支上添加一个追踪并没有额外的输出。但我通过添加thing `seq` traceTc "lk2" (ppr name)
并观察没有出现lk2
来确定了这件事。
显然在这一点上强制K
应该对我们来说没问题,因为它是一个外部声明。所以某些东西在延迟计算本身出错了。
回到茶叶上
让我们再次看一下追踪的结尾:
Starting fork { Declaration for K
Loading decl for K
updating EPS_
Considering whether to load GHC.Prim {- SYSTEM -}
Reading interface for GHC.Prim;
reason: Need home interface for wired-in thing TYPE
updating EPS_
tc-iface-class1 K
tc-iface-class2 K
tc-iface-class3 K
tc-iface-class4 K
buildClass
newGlobalBinder A C:K <no location info>
C:K
newGlobalBinder A $tcK <no location info>
$tcK
Starting fork { Class op method D -> a
ghc-stage2: panic! (the 'impossible' happened)
<<rest of the panic message>>
以人类可读的方式来说,这个追踪告诉了一个这样的故事:
-
有人强制了代表类型类
K
的TyThing
的延迟计算(Starting fork { Declaration for K
) -
我正在对
K
的IfaceDecl
的内容进行类型检查(tc-iface-class
等) -
我正在构建代表这个类型类的实际
Class
(buildClass
) -
我为所讨论的类分配了一些全局名称。(
newGlobalBinder
) -
糟糕!我强制了代表类操作
method
的延迟计算(其类型为D -> a
) -
不久之后,恐慌发生。
所以,去读TcIface
的代码。以下是类型检查IfaceDecl
的代码体:
= bindIfaceTyConBinders binders $ \ tyvars binders' -> do
{ tc_name <- lookupIfaceTop tc_occ
; traceIf (text "tc-iface-class1" <+> ppr tc_occ)
; ctxt <- mapM tc_sc rdr_ctxt
; traceIf (text "tc-iface-class2" <+> ppr tc_occ)
; sigs <- mapM tc_sig rdr_sigs
; fds <- mapM tc_fd rdr_fds
; traceIf (text "tc-iface-class3" <+> ppr tc_occ)
; mindef <- traverse (lookupIfaceTop . mkVarOccFS) mindef_occ
; cls <- fixM $ \ cls -> do
{ ats <- mapM (tc_at cls) rdr_ats
; traceIf (text "tc-iface-class4" <+> ppr tc_occ)
; buildClass tc_name tyvars roles ctxt binders' fds ats sigs mindef tc_isrec }
; return (ATyCon (classTyCon cls)) }
类型类的方法在sigs <- mapM tc_sig rdr_sigs
中处理。看一下这个辅助函数,我们可以看到:
tc_sig :: IfaceClassOp -> IfL TcMethInfo
tc_sig (IfaceClassOp occ rdr_ty dm)
= do { op_name <- lookupIfaceTop occ
; ~(op_ty, dm') <- forkM (mk_op_doc op_name rdr_ty) $
do { ty <- tcIfaceType rdr_ty
; dm' <- tc_dm dm
; return (ty, dm') }
-- Must be done lazily for just the same reason as the
-- type of a data con; to avoid sucking in types that
-- it mentions unless it's necessary to do so
; return (op_name, op_ty, dm') }
太好了!已经有一些代码提到了签名类型需要懒惰地完成。如果我们强制op_ty
或dm'
,我们将导致这里的类型被加载。现在我们只需要找到在buildClass
中它们被强制的地方。以下是buildClass
的头部:
buildClass tycon_name tvs roles sc_theta binders
fds at_items sig_stuff mindef tc_isrec
所以让我们来看看sig_stuff
的出现。它们第一次被使用的地方是:
; op_items <- mapM (mk_op_item rec_clas) sig_stuff
-- Build the selector id and default method id
让我们看看这个辅助函数:
mk_op_item :: Class -> TcMethInfo -> TcRnIf n m ClassOpItem
mk_op_item rec_clas (op_name, _, dm_spec)
= do { dm_info <- case dm_spec of
Nothing -> return Nothing
Just spec -> do { dm_name <- newImplicitBinder op_name mkDefaultMethodOcc
; return (Just (dm_name, spec)) }
; return (mkDictSelId op_name rec_clas, dm_info) }
在这里!dm_spec
上的这个案例将迫使dm'
,进而导致类型被强制,结果引发了恐慌。这肯定不对。
看起来mk_op_item
只关心dm_spec
上的顶层包装;dm_info
内部懒惰地使用spec
,并且似乎在mkClass
后期不会被强制执行。因此修复的方法将是使得我们可以在不强制dm
内容的情况下剥离外部的Maybe
:
--- a/compiler/iface/TcIface.hs
+++ b/compiler/iface/TcIface.hs
@@ -429,20 +429,23 @@ tc_iface_decl _parent ignore_prags
tc_sig :: IfaceClassOp -> IfL TcMethInfo
tc_sig (IfaceClassOp occ rdr_ty dm)
= do { op_name <- lookupIfaceTop occ
- ; ~(op_ty, dm') <- forkM (mk_op_doc op_name rdr_ty) $
- do { ty <- tcIfaceType rdr_ty
- ; dm' <- tc_dm dm
- ; return (ty, dm') }
+ ; let doc = mk_op_doc op_name rdr_ty
+ ; op_ty <- forkM (doc <+> text "ty") $ tcIfaceType rdr_ty
-- Must be done lazily for just the same reason as the
-- type of a data con; to avoid sucking in types that
-- it mentions unless it's necessary to do so
+ ; dm' <- tc_dm doc dm
; return (op_name, op_ty, dm') }
- tc_dm :: Maybe (DefMethSpec IfaceType) -> IfL (Maybe (DefMethSpec Type))
- tc_dm Nothing = return Nothing
- tc_dm (Just VanillaDM) = return (Just VanillaDM)
- tc_dm (Just (GenericDM ty)) = do { ty' <- tcIfaceType ty
- ; return (Just (GenericDM ty')) }
+ tc_dm :: SDoc
+ -> Maybe (DefMethSpec IfaceType)
+ -> IfL (Maybe (DefMethSpec Type))
+ tc_dm _ Nothing = return Nothing
+ tc_dm _ (Just VanillaDM) = return (Just VanillaDM)
+ tc_dm doc (Just (GenericDM ty))
+ = do { -- Must be done lazily to avoid sucking in types
+ ; ty' <- forkM (doc <+> text "dm") $ tcIfaceType ty
+ ; return (Just (GenericDM ty')) }
我们检查了修复,是的!它奏效了!
分手的酒杯
我不会声称我的调试过程是可能的最有效过程——在这篇博文中没有提到的是我花了一天时间阅读提交历史,试图说服自己我们并没有忘记更新全局类型环境中的错误。但是这里似乎有一些可推广的经验教训:
-
如果你看到一些跟踪输出,使跟踪对你最有用的方法是确定代码中跟踪消息来自何处,并且在那个时间点编译器正在做什么。通常,使用 grep 搜索跟踪消息就足以弄清楚这一点。
-
你的测试案例越小,你的跟踪就会越小,这样解释跟踪就更容易。当我运行我的测试案例时使用
ghc --make
而不是ghc -c
时,输出的日志要多得多。确实,结束的跟踪是一样的,但如果在早期跟踪中有重要内容,那么挖掘出来就更加困难。 -
如果你可以信任你的跟踪,调试就会更容易。如果我相信跟踪输出,我本可以更快地找到错误。但我没有,而是花了大量时间确保代码表现出我期望的行为。好的一面是,我现在对这里的代码路径了解得比以前深多了。
GHC 如何使调试这些类型的错误更容易?有自己的与惰性相关的调试故事吗?我很想知道你的想法。
定义“Haskelly”:ezyang 的博客
定义“Haskelly”
虽然可能听起来像是老生常谈,但这篇文章的主题也来源于abcBridge。John Launchbury 在我的演讲中提出了一个问题,让我开始思考 Haskell 中的 API 设计。(顺便说一句,演讲视频已经发布!不幸的是,由于技术问题,第二部分不得不被删减了,但你仍然可以查看幻灯片。)
他的问题是这样的:
你用一种非常命令式的风格呈现了这个 AIG 结构在 ABC 工具中的存在,而实际上你给了我一个很好的类型化的 Haskell 接口,允许你进入并放置一个新的门或者抓取一个结构,我不禁想知道,为什么需要与该空间中正在进行的事情紧密联系?这里有一个思想实验:我可以想象自己拥有一个描述数据结构的纯函数数据结构...然后你最终得到了一个描述你希望图形看起来如何的函数描述,并告诉 ABC 一次性构建图形。
我曾声称 abcBridge 是一个用于操作与反转器图相关的“功能性 API”;也许我在撒谎!abcBridge——与底层命令式代码密切对应——真的是“功能性”的吗?或者,即使它不是功能性的,它至少有一个“Haskelly”API 吗?(一个 API 要“Haskelly”意味着什么?)为什么纯函数接口似乎在道义上比命令式接口更好?这不仅是一个具有哲学意义的问题,也是一个具有实际意义的问题——为什么我们更喜欢可能需要更复杂的底层实现的纯函数接口?
我的推测是,API 对其主机语言的忠诚度基于它利用语言易于使用的特定功能的程度。 Haskell 经常被介绍为一种“纯函数的、惰性的、强静态类型的编程语言”。逐个查看这些术语(非正式)...
-
纯函数指的是那些避免破坏性更新的 API,而是选择不可变性和持久化数据结构。使得以这种风格编写更容易的语言特性包括
final
和const
关键字、代数数据类型、模式匹配和一组持久化数据结构的库。 -
惰性 指的是利用惰性来构建自定义控制结构和生成无限数据结构的 API。懒惰评估默认是惰性的语言特性,但在严格语言中,即使有显式的惰性注释或便捷的 lambda 抽象也鼓励懒惰风格。(Python 没有方便的 lambda,这就是为什么像 Twisted 这样的异步框架如此令人痛苦!)
-
强静态类型 指的是将各种形状的不变性编码到静态类型系统中,以便程序员的错误可以在编译时捕获,而不是在运行时。类型系统显然是这里的显著语言特性,其表达能力定义了你可以轻松添加到系统中的内容。
我们将利用这些语言特性的程序称为“Haskelly”,因为 Haskell 在语法和概念上都很容易使用它们!但与此同时,这些特性大多是正交的语言特性,对于任何给定的 API,你可能选择放弃一些特性以换取其他特性:也许这个特性在你的问题域中并不重要,也许这个特性会带来无法接受的性能损失或者不足以封闭的抽象。
以 abcBridge 作为具体示例,这里是你如何在实践中进行这种分类:
-
用于构建网络的单子接口距离纯函数的概念相当远,这是出于性能和控制的显式设计选择。(幸运的是,我们可以在其基础上构建一个更好的 API —— 实际上,我已经做了一个实验性实现。)然而,当你处理完全构建的网络时,API 采用纯函数风格,背后进行复制和
unsafePerformIO
以保持这种错觉。 -
abcBridge 并不直接使用惰性:特别是单子代码非常结构化,并且其中没有很多流程控制。
-
静态类型系统是 abcBridge 的重要组成部分,因为它与底层硬件操作如此紧密相关:它有两个单子,以及一组复杂的函数用于运行和转换这些单子,低级 FFI 绑定使得每一次尝试都能增强现有的基于 C 的类型系统。注意类型与函数接口之间有趣的互动:如果我们有一个纯函数接口,可能大部分这些复杂的类型都可以不用!(命令式代码似乎需要更强的类型系统技巧。)
就纯 Haskell 库而言,abcBridge 非常不“Haskelly”:我肯定会期待一个在纯 Haskell 中实现的等效库能提供更多。但它比起它衍生自的 C 库有了长足的进步。我们应该把握多大的发展空间?这完全取决于找到正确平衡——这就是 API 设计的艺术。
延迟隐式参数绑定:ezyang 的博客
来源:
blog.ezyang.com/2010/07/delaying-implicit-parameter-binding/
今天,我们将更详细地讨论丹·多尔在周一的文章评论中提到的动态绑定的一些要点。我们的第一步是巩固我们对延迟绑定的定义,如在惰性语言(使用 Reader 单子的 Haskell)和严格语言(使用有错误的元循环评估器的 Scheme)中所见。然后我们回到隐式参数,并问一个问题:隐式参数执行动态绑定吗?(忽略单型限制,奥列格说不,但在 GHC 可能存在 bug 的情况下,答案是肯定的。)最后,我们展示如何将隐式参数的方便性与 Reader 单子的显式性结合起来,使用奥列格在他的单子区域中使用的标准技巧。
旁注. 对于那些注意力不集中的人,要点是:使用隐式参数的表达式的类型确定了隐式参数绑定的时间。对于大多数项目,隐式参数往往会尽快解析,这并不是很动态;关闭单型限制将导致更加动态的行为。如果您只设置一次隐式参数并且不再更改它们,您将看不到太多差异。
冒着听起来像是一个破碎的记录的风险,我想复习有关 Reader 单子的一个重要区别。在 Reader 单子中,以下两行之间存在很大的区别:
do { x <- ask; ... }
let x = ask
如果我们在Reader r
单子中,第一个x
将具有类型r
,而第二个x
将具有类型Reader r r
;可以称第二个x
为“延迟”,因为我们尚未使用>>=
来查看谚语单子包装器并在其结果上采取行动。我们可以通过以下代码看到这意味着什么:
main = (`runReaderT` (2 :: Int)) $ do
x <- ask
let m = ask
liftIO $ print x
m3 <- local (const 3) $ do
liftIO $ print x
y <- m
liftIO $ print y
let m2 = ask
return m2
z <- m3
liftIO $ print z
which outputs:
2
2
3
2
虽然我们通过调用local
改变了底层环境,但原始的x
保持不变,而当我们将m
的值强制到y
时,我们发现了新的环境。m2
表现类似,但方向相反(在内部ReaderT
声明,但采用外部ReaderT
的值)。语义不同,因此语法也不同。
请记住这一点,因为我们即将离开(我敢说“熟悉的”?)单子的世界,转向 Lisp 的领域,那里大部分代码不是单子的,动态绑定是意外发明的。
在这里,我有在 SICP 中找到的精简版元循环评估器(去除了变异和顺序控制;如果添加这些内容,理论是可行的,但我们在此帖子中忽略它们):
(define (eval exp env)
(cond ((self-evaluating? exp) exp)
((variable? exp) (lookup-variable-value exp env))
((lambda? exp)
(make-procedure (lambda-parameters exp)
(lambda-body exp)))
((application? exp)
(apply (eval (operator exp) env)
(list-of-values (operands exp) env))
env)
))
(define (apply procedure arguments env)
(eval
(procedure-body procedure)
(extend-environment
(procedure-parameters procedure)
arguments
env)))
这里是另一个版本的评估器:
(define (eval exp env)
(cond ((self-evaluating? exp) exp)
((variable? exp) (lookup-variable-value exp env))
((lambda? exp)
(make-procedure (lambda-parameters exp)
(lambda-body exp)
env))
((application? exp)
(apply (eval (operator exp) env)
(list-of-values (operands exp) env)))
))
(define (apply procedure arguments)
(eval
(procedure-body procedure)
(extend-environment
(procedure-parameters procedure)
arguments
(procedure-environment procedure))))
如果你对 SICP 的知识有点生疏,在查阅源代码之前,试着弄清楚哪个版本实现了词法作用域,哪个版本实现了动态作用域。
这两个版本之间的主要区别在于 make-procedure
的定义。第一个版本本质上是 lambda 定义的直译,只接受参数和主体,而第二个版本添加了额外的信息,即 lambda 创建时的环境。相反,当 apply
解开过程以运行其内部时,第一个版本需要额外的信息——当前环境——作为我们将使用 eval
运行时的基础环境,而第二个版本则只使用过程中存储的环境。对于没有被“双泡泡” lambda 模型击败的学生来说,这两种选择都似乎是合理的,他们可能会简单地遵循 make-procedure
的定义(请注意:给学生一个不正确的 make-procedure
是非常邪恶的!)
第一个版本是动态作用域的:如果我尝试引用一个未在 lambda 参数中定义的变量,我会在调用 lambda 的环境中寻找它。第二个版本是词法作用域的:我会在创建 lambda 的环境中寻找缺失的变量,这恰好是 lambda 源代码所在的地方。
那么,“延迟”变量引用意味着什么?如果是词法作用域,意义不大:过程要使用的环境从创建时就已经确定,如果环境是不可变的(即我们不允许 set!
等操作),则在尝试解引用变量时根本不重要。
另一方面,如果变量是动态作用域的,则调用引用变量的函数的时间至关重要。由于 Lisp 是严格评估的,一个简单的 variable
表达式将立即导致在当前调用环境中查找,但是以 (lambda () variable)
形式的“惰性求值”将延迟查找变量,直到我们使用 (thunk)
强制求值 thunk
为止。variable
在 Haskell 中直接类比于类型为 r
的值,而 (lambda () variable)
类比于类型为 Reader r r
的值。
回到 Haskell,再谈隐式参数。百万美元问题是:我们能区分强制和延迟隐式参数吗?如果我们尝试直译原始代码,我们很快就会陷入困境:
main = do
let ?x = 2 :: Int
let x = ?x
m = ?x
...
隐式参数的语法似乎没有任何区分x
和m
的内置语法。因此,人们必须要问,什么是默认行为,另外一种方法可以实现吗?
对于 Haskell 来说,这是一种罕见的情况,类型实际上改变了表达式的语义。考虑这个带注释的版本:
main =
let ?x = 2 :: Int
in let x :: Int
x = ?x
m :: (?x :: Int) => Int
m = ?x
in let ?x = 3 :: Int
in print (x, m)
x
的类型是Int
。回顾一下,(?x :: t)
约束指示表达式使用该隐式变量。这怎么可能:当我们约定不使用隐式变量时,我们是否在非法地使用隐式变量?在这个困境中有一种解决办法:我们强制?x
的值,并将其赋给x
,这样我们就已经解析了?x
,不需要在使用x
的任何地方要求它。因此,从表达式的类型约束中移除隐式变量会强制该表达式中的隐式变量。
另一方面,m
不执行这种特化:它声明你需要?x
才能使用表达式m
。因此,推迟隐式变量的评估。在类型约束中保持隐式变量会延迟该变量。
因此,如果简单地写let mystery = ?x
,那么 mystery 的类型是什么?在这里,可怕的单型限制就出现了。你可能已经见过单型限制:在大多数情况下,它使得你的函数比你想要的更不通用。然而,这是非常明显的——你的程序无法通过类型检查。在这里,无论单型限制是否开启,都不会导致你的程序无法通过类型检查;它只会改变其行为。我建议不要猜测,在使用隐式参数时明确指定你的类型签名。这样可以清楚地显示出隐式参数是被强制还是推迟的视觉线索。
旁注. 对于那些好奇的人,如果单型限制被启用(默认情况下是启用的),并且你的表达式是合格的(如果它不带参数,它肯定是合格的,否则,请查阅你最近的 Haskell 报告),所有隐式参数将从你的类型中特化出来,所以
let mystery = ?x
将立即强制?x
。即使你已经为你的隐式参数精心编写了类型,单型 Lambda 或函数也可能导致你的表达式变为单型化。如果通过NoMonomorphismRestriction
禁用单型限制,推断算法将保留你的隐式参数,直到它们在一个特殊化的上下文中使用而没有隐式参数。 GHC 也试验性地使模式绑定单型化,可以通过NoMonoPatBinds
进行调整。
然而,这个故事并没有完全结束:我忽略了m2
和m3
!
main =
let ?x = (2 :: Int)
in do m3 <- let x :: Int
x = ?x
m :: (?x :: Int) => Int
m = ?x
in let ?x = 3
in let m2 :: (?x :: Int) => Int
m2 = ?x
in print (x, m) >> return m2
print m3
但是m3
打印的是3
而不是2
!我们已经指定了完整的签名,正如我们应该做的那样:出了什么问题?
麻烦的是,一旦我们试图使用 m2
将其从内部作用域传递回外部作用域,我们强制隐式参数,并且出现的 m3
只不过是一个 m3 :: Int
。即使我们尝试指定 m3
应该使用隐式参数 ?x
,该参数也会被忽略。你可以将其类比为以下链条:
f :: (?x :: Int) => Int
f = g
g :: Int
g = let ?x = 2 in h
h :: (?x :: Int) => Int
h = ?x
g
是单态的:再怎么劝说,?x
也不会再次未绑定。
我们在 Scheme 世界的简短旅行中,然而,暗示了一种防止 m2
过早使用的可能方法:将其放在一个 thunk 中。
main =
let ?x = (2 :: Int)
in let f2 :: (?x :: Int) => () -> Int
f2 = let ?x = 3
in let f1 :: (?x :: Int) => () -> Int
f1 = \() -> ?x
in f1
in print (f2 ())
但我们发现当我们运行 f2 ()
时,签名再次变成了单态,时间点太早了。虽然在 Scheme 中,创建一个 thunk 起作用是因为动态绑定与 执行模型 密切相关,但在 Haskell 中,隐式参数由类型控制,而类型却不对。
Dan Doel 发现 有一种方法使事情工作:将 ?x
约束移到签名的右侧:
main =
let ?x = (2 :: Int)
in let f2 :: () -> (?x :: Int) => Int
f2 = let ?x = (3 :: Int)
in let f1 :: () -> (?x :: Int) => Int
f1 = \() -> ?x
in f1
in print (f2 ())
以高阶等级的风格来说,这非常脆弱(最微小的触碰,比如一个 id
函数,可能使高阶特性消失)。Simon Peyton Jones 对此行为感到惊讶,所以不要对它太过依赖。
这里有另一种获得“真正”动态绑定的方法,以及一个在我看来使绑定时机更加清晰的单子接口。它的模式是基于 Oleg 的 单子区域。
{-# LANGUAGE ImplicitParams, NoMonomorphismRestriction,
MultiParamTypeClasses, FlexibleInstances #-}
import Control.Monad
import Control.Monad.Reader
-- How the API looks
f = (`runReaderT` (2 :: Int)) $ do
l1 <- label
let ?f = l1
r1 <- askl ?f
liftIO $ print r1
g
g = (`runReaderT` (3 :: Int)) $ do
l <- label
let ?g = l
r1 <- askl ?f
r2 <- askl ?g
liftIO $ print r1
liftIO $ print r2
delay <- h
-- change our environment before running request
local (const 8) $ do
r <- delay
liftIO $ print r
h = (`runReaderT` (4 :: Int)) $ do
l3 <- label
let ?h = l3
r1 <- askl ?f
r2 <- askl ?g
r3 <- askl ?h
-- save a delayed request to the environment of g
let delay = askl ?g
liftIO $ print r1
liftIO $ print r2
liftIO $ print r3
return delay
-- How the API is implemented
label :: Monad m => m (m ())
label = return (return ())
class (Monad m1, Monad m2) => LiftReader r1 m1 m2 where
askl :: ReaderT r1 m1 () -> m2 r1
instance (Monad m) => LiftReader r m (ReaderT r m) where
askl _ = ask
instance (Monad m) => LiftReader r m (ReaderT r1 (ReaderT r m)) where
askl = lift . askl
instance (Monad m) => LiftReader r m (ReaderT r2 (ReaderT r1 (ReaderT r m))) where
askl = lift . askl
这是一种混合方法:每次我们以 ReaderT
单子的形式添加新参数时,我们生成一个“标签”,这个标签允许我们回到那个单子(通过使用标签的类型来提升我们回到原始单子的方式)。然而,不是通过词法传递标签,而是将它们塞进隐式参数中。然后有一个定制的 askl
函数,它以标签作为参数,并返回对应于那个单子的环境。即使你用 local
改变环境,这个处理也能正常工作:
*Main> f
2
2
3
2
3
4
8
更详细地解释这个机制可能是另一篇文章的主题;它非常方便且非常轻量级。
结论. 如果你计划将隐式变量仅仅用作更接近程序顶部的可变静态变量,单态性限制是你的朋友。然而,为了安全起见,强制所有隐式参数。你不需要担心让隐式变量通过函数输出逃逸的困难。
如果你计划为更复杂的事情使用动态作用域,使用 Oleg 风格的动态绑定 并使用隐式参数作为传递标签的便捷方式可能更好。
后记. 或许解释单态性和隐式参数交互如此久,可能表明对两者的高级使用可能并非普通程序员的菜。
Haskell 中的设计模式:ezyang’s 博客
注意:保存注意事项。列出了如何在函数式编程语言中等效实现四人帮设计模式的清单。这是面向对象程序员处理函数式编程概念的短语手册。
在其对经典作品设计模式的介绍中,四人帮说:“编程语言的选择很重要,因为它影响一个人的视角。我们的模式假设具有 Smalltalk/C++级别的语言特性,而这种选择决定了什么可以轻松实现,什么不可以。如果我们假设过程式语言,我们可能会包括名为‘继承’、‘封装’和‘多态性’的设计模式。”
在函数式编程语言中,什么容易实现,什么难以实现?我决定重新审视所有 23 个原始的四人帮设计模式,从这个角度出发。我希望这些结果对希望学习函数式编程的面向对象程序员有所帮助。
策略。一级函数和 lambda。任何可能放置为类成员的额外数据通常使用闭包(将数据存储在 lambda 函数的环境中)或柯里化(为函数的参数创建隐式闭包)来实现。策略也非常强大,因为它们是多态的;函数类型的类型同义词可以发挥类似的作用。Java 已经认识到匿名函数是一个好主意,并且添加了匿名类的功能,这在这方面经常被使用。
工厂方法和模板方法。高阶函数。不要创建子类,只需传递想要变化行为的函数。
抽象工厂,建造者和桥接。类型类和智能构造函数。类型类能够定义创建其实例的函数;一个函数只需承诺返回某种类型的值 TypeClass a => a
,并且仅使用类型类公开的(构造函数等)函数,就能利用这一特性。如果你不仅仅是构造值,而是通过通用类型类接口操纵它们,你就拥有了一个桥接。智能构造函数是在基本数据构造函数之上构建的函数,可以做“更多”事情,无论是不变性检查、封装还是更简单的 API,这对应于工厂提供的更高级方法。
适配器模式,装饰者模式和责任链模式。组合和提升。函数组合可用于形成函数之间的数据管道;外部函数可以夹在期望的类型转换函数之间,或者一个函数可以与另一个函数组合以使其执行更多操作。如果签名保持不变,则一个或多个函数是端态的。如果函数具有副作用,则可能是 Kleisli 箭头组合(更通俗地称为单子函数组合)。多个函数可以使用 Reader 单子处理相同的输入。
访问者模式。等式函数。经常是可折叠的。许多函数式语言喜欢以数学等式风格将相同操作在不同数据构造器上分组,这意味着类似的行为被聚集在一起。传统的按“类”分组行为是通过类型类来实现的。访问者通常将它们操作的数据结构折叠成更小的值,这在折叠函数族中可以看到。
解释器模式。函数。经常通过嵌入式领域特定语言绕过。代数数据类型使得轻量级抽象语法树易于构造。正如访问者经常与解释器一起使用,你可能会用模式匹配编写你的解释函数。更好的做法是,不要再想出另一种数据类型;只需使用函数和中缀操作符来表达你的意图。与此密切相关的是...
命令模式。单子。另见代数数据类型,经常是广义代数数据类型(GADT)。纯语言不会运行你的IO
,直到main
触及它,因此你可以自由地传递类型为IO a
的值,而不必担心实际引起副作用,尽管这些函数难以序列化(命令背后的常见动机)。通过高阶函数再次实现对要执行的操作的参数化。GADT 有点臃肿,但可以在像Prompt monad (PDF)这样的地方看到,其中 GADT 用于表示另一个函数将其解释为IO
单子的操作;类型给出了静态强制执行的保证,这种数据类型中允许做什么操作。
组合模式。递归代数数据类型。特别突出,因为没有内置继承。
迭代器模式。惰性列表。 迭代器公开了对数据结构逐个元素访问的接口,而不暴露其外部结构;列表是这种访问的 API,惰性意味着在需要时我们不会计算整个流。涉及 IO 时,你可能会使用真正的迭代器。
原型模式。不可变性。 修改默认复制。
享元模式。记忆化 和 常量适用表达形式(CAF)。 而不是计算表达式的结果,创建一个包含所有可能输入值的结果的数据结构(或者,只是最大的记忆)。因为它是惰性的,结果在需要时才计算;因为它是一个合法的数据结构,所以在后续计算中返回相同的结果。CAF 描述的表达式可以提升到程序的顶层,并且其结果可以被所有引用它的其他代码共享。
状态模式 和 备忘录模式。不必要;状态具有显式表示,因此可以随意修改,它可以包括函数,可以更改以改变行为。状态作为函数(而不是对象或枚举),如果你愿意。备忘录提供的封装是通过隐藏适当的构造函数或析构函数实现的。你可以在适当的单子(例如 Undo 单子)中轻松自动管理过去和未来状态。
单例模式。不必要;除了在单子中的全局状态之外,还可以通过单子的类型来强制只存在一个记录实例;函数存在于全局命名空间并且总是可访问的。
外观模式。函数。 一般来说不太普遍,因为函数式编程侧重于输入输出,使得直接使用函数的版本非常简短。高泛化度可能需要更用户友好的接口,通常通过更多的函数实现。
观察者模式。诸如通道、异步异常和可变变量等许多并发机制之一。参见函数式反应式编程。
代理模式。包装数据类型, 惰性 和 垃圾回收器。 参见参考单子类型(IORef,STRef),它们提供更传统的指针语义。惰性意味着结构总是按需创建的,垃圾回收意味着智能引用是不必要的。你还可以包装一个数据类型,并且只发布强制执行额外限制的访问器。
中介者模式。 单子堆栈。虽然讨论对象之间的交互并不实用,因为更偏好无状态代码,但单子堆栈经常用于为在复杂环境中执行操作的代码提供统一接口。
欢迎评论和建议;我将会保持这篇文章的更新。
设计 Backpack 签名生态系统:ezyang 的博客
来源:
blog.ezyang.com/2017/03/designing-the-backpack-signature-ecosystem/
假设您是一个希望使用 Backpack 的库编写者。Backpack 表示您可以用一个或多个签名替换对函数、类型或包的直接依赖。您对签名进行类型检查,而最终用户选择他们希望最终如何实现该签名。
听起来不错对吧?但有一个小小的秘密:要享受所有这些好处,您必须编写一个签名——您知道的,一个用于每个要在您的库中使用的函数和类型的类型签名。我们都知道 Haskell 程序员有多么讨厌编写签名(https://ghc.haskell.org/trac/ghc/ticket/1409)。但是 Backpack 有一个解决方案:用户可以在一个包中放置一个签名,以便在其他包中重复使用。
最长时间以来,我以为这已经是“足够”了,只需坐下来写写一些有关如何编写签名包的教程就行了。但是当我坐下来亲自编写签名包时,我发现设置事物的方法不止一种。在本文中,我想讨论一下签名包集合的两种可能设计。这些设计基于以下考虑:
-
例如,对于
bytestring
,应该有多少个签名包?可能恰好是一个,或者可能是每个 API 修订版的一个单独包? -
是否应该发布签名包的新版本?在什么情况下应该允许这样做?
-
对于一个库的开发者来说,一个更大的签名更方便,因为它提供了更多功能供您使用。然而,对于客户来说,一个更小的签名更好,因为它减少了实现的负担。签名包应该默认鼓励大签名还是小签名呢?
每个发布版本的签名包
直觉上,每个包的发布版本也与指定该版本支持的函数的“签名”相关联。因此,可以得出结论,应该有一个签名包每个发布版本,每个包描述了相关问题版本的接口。(或者,可以合理地认为 GHC 应该能够自动从包中推断出签名。由于本文篇幅所限,这并不容易实现。)
然而,我们必须谨慎地进行每个签名的发布。一个明显但有问题的做法是这样的:给定 bytestring-0.10.8.1
,同时发布一个 bytestring-sig-0.10.8.1
。问题在于,在今天的 Haskell 生态系统中,强烈假设一个包的版本只能选择一个。因此,如果我有一个需要 bytestring-sig == 0.10.8.1
的包,另一个需要 bytestring-sig == 0.10.8.2
的包,在尝试同时解决这两个包的依赖时将会失败。我们可以通过教会 Cabal 和 Stack 如何链接多个版本的签名包来使这个方案可行,但目前还不实际。
解决“多版本”问题的简单方法是为每个 bytestring
版本创建一个全新的包。包名的语法有些烦人(只能使用字母数字字符和连字符,并且连字符之间不能直接是数字),但可以想象,会发布 bytestring-v1008
、bytestring-v1009
等,每个版本的 API 都会有一个新的发布。一旦发布了签名包,除了修复签名错误外,就不应再更新。
根据语义化版本控制,具有相同主版本的包应仅添加功能,而不是删除功能。因此,这些连续的签名包也可以相互构建:例如,bytestring-v1009
可以通过继承 bytestring-v1008
中的所有函数并仅添加在 0.10.9 版本中添加的新函数来实现。
每个主要发布系列一个签名包。
上述方案有一个非常可怕的问题:我们将会有大量签名包:每个包的每个版本都有一个!如果在 Hackage 索引中有 bytestring-v900
、bytestring-v901
、bytestring-v902
、bytestring-v1000
、bytestring-v1002
、bytestring-v1004
、bytestring-v1006
和 bytestring-v1008
作为包选项会有多么糟糕!(如果存在意外更改了 API 的补丁发布,可能会更多。)因此,极度希望找到减少需要发布的签名包数量的方法。
这是一种仅针对主要发布版本需要签名包的方案,例如对于 bytestring
,我们只会有 bytestring-v9
和 bytestring-v10
:
-
bytestring-v9
的最新版本应该对应于 0.9 系列支持的“最大” API。因此,对于bytestring
的每个次要版本发布,都会有一个新的bytestring-v9
的发布:例如,当发布bytestring-0.9.1.0
时,我们会发布bytestring-v9-1.0
。每个发布都会增加签名中记录的功能,但不允许做其他更改。 -
在依赖签名包时,我们将提供一个版本范围,指定构建我们包所需的签名的最小功能;例如,
bytestring-v9 >= 1.0
。(不需要上限,因为假设签名包永远不会破坏向后兼容性。)
存在一个主要困难:假设两个无关的软件包都对bytestring-v9
指定了版本范围。在这种情况下,我们选择的签名包的最终版本将是与两个范围都兼容的版本;实际上,是最新版本的签名包。这有两个坏处:首先,这意味着我们将始终要求客户端实现完整的bytestring-v9
,即使我们与发行系列中的早期版本兼容。其次,这意味着每当更新bytestring-v9
时,我们可能会引入更多实体:如果这引入了歧义,将导致以前编译的代码停止编译。
幸运的是,对于这个问题有一个解决方案:使用签名减少来减少所需实体,精确到你需要的实体集合。例如,假设bytestring-v9-0.0
具有以下签名:
signature Data.ByteString where
data ByteString
empty :: ByteString
null :: ByteString -> Bool
作为用户,我们只需要ByteString
和empty
。然后我们在我们的本地ByteString
签名中写入:
signature Data.ByteString (ByteString, empty) where
现在不管bytestring-v9-0.0
添加了什么新功能,这个签名将始终只需要ByteString
和empty
。(关于签名减少的另一种思考方式是,它是一种集中显式导入列表的方法。)请注意,如果没有每个主要发行系列单独的软件包,这种方案是不起作用的,因为签名减少无法防止您依赖的函数类型发生向后不兼容的更改。
这些签名减少的头部可以自动计算;我已经编写了一个工具(ghc-usage),可以精确执行此操作。实际上,即使在第一种设计中,签名减少也是有用的,因为它们可以用来减少包的要求;然而,对于每个主要发行版的签名包来说,它们是强制的;如果你不使用它们,你的代码可能会出现问题。
结论
那么,我们应该采用什么设计?我认为第一种方案(每个发行版一个签名包)在理论上更纯粹,但我非常担心“太多包”问题。此外,我确实认为尽可能减少签名是一个好主意(要求你不打算使用的东西并不好!),这意味着签名减少的要求可能并不那么糟糕。我与其他人讨论过,他们认为第一种方案显然是正确的做法。
你更喜欢哪种方案?你有自己的建议吗?我很想听听你的想法。(另外,如果你想要就签名包的命名惯例进行讨论,我也非常乐意听取意见。)
附录
在发布这篇文章后,一些人的评论让我意识到,我没有解释为什么你会想要讨论 bytestring-0.10.8 的 API;难道你只想要一个字符串的签名吗?因此,为了回应这个评论,我想描述一下引导我走上这条路的推理过程。
我从一个简单的目标开始:编写一个字符串签名,具有以下特性:
-
要足够完整;即,包含所有想要进行“字符串”操作的函数,但是
-
要足够通用;即,只支持所有主要字符串实现都支持的函数(例如,String、严格/惰性 Text、严格/惰性 Word8/Char8 ByteString 和 Foundation 字符串)。
结果表明,为了实现通用性,我需要放弃相当多的函数;例如,Foundation 中没有实现 transpose、foldl1、foldl1'、mapAccumL/R、scanl、replicate、unfoldr、group、groupBy、inits、tails;惰性类型中没有实现 foldr'、foldr1'、scanl1、scanr、scanr1、unfoldN、spanEnd、breakEnd、splitOn、isInfixOf。
这让我想到,如果我不要求签名支持所有可能的实现,我可以提供更大的签名;你可以有一个签名,允许你仅在严格变体之间切换字符串类型,甚至一个只允许在 Word8 和 Char8 ByteStrings 之间切换的签名。
当然,有组合方式组合签名,每种方式都需要编写(和命名)一个新的签名包,这样做是可怕的。那么,一个人可以编写的最小签名单位是什么?在这种情况下,有一个明显的答案:特定版本包中特定模块(比如,Data.ByteString
)的 API。参见上文讨论。
附录 2
上面,我写道:
当然,有组合方式组合签名,每种方式都需要编写(和命名)一个新的签名包,这样做是可怕的。那么,一个人可以编写的最小签名单位是什么?在这种情况下,有一个明显的答案:特定版本包中特定模块(比如,
Data.ByteString
)的 API。
我认为从这里可以得出另一个结论:有人应该编写一个包含所有模块选择可能支持的每一个函数的签名,然后让最终用户负责将这些签名缩减到他们实际使用的集合。因此,每个人都要负责编写大的导出列表,说明他们使用了什么,但你不必为不同方法组合发布新包。
我目前正在追求这种方法!
在 Xournal 和 Gimp 中绘图:ezyang’s 博客
两个人问我如何为我的上一篇文章You Could Have Invented Zippers绘制图表,我想我会稍微详细地分享一下,因为在我找到适合自己的方法之前,这显然是一些实验。
Linux 的绘图软件太糟糕了。Mac OS X 上的人可以使用OmniGraffle制作令人眼前一亮的美丽图表;我们能做的最好的是一些微不足道的 GraphViz 输出,或者也许如果我们有很多时间,从 Inkscape 中精心制作的 SVG 文件。这对我来说太费时间了。
因此,对我来说,手绘图表!我做的第一件事是打开我信赖的Xournal,这是由Denis Auroux(我的前多元微积分教授)编写的基于 GTK 的高质量笔记应用程序。然后我开始画图。
实际上,这并不完全正确;到这个时候,我已经花了一些时间用铅笔和纸笔画图,并想出了我想要的布局。因此,当我在平板电脑上时,我脑海中有一个清晰的图像,并仔细地用黑色画出图表。如果我需要图表的多个版本,我会复制粘贴并根据需要调整颜色(电子绘图的一个伟大之处!)。我还会使用荧光笔工具进行着色。完成后,我可能会有几页图表,可能会用到也可能不会。
从那里开始,“File > Export to PDF”,然后在 Gimp 中打开生成的 PDF 文件。有一段时间,我没有意识到可以这样做,而是使用scrot
来截取屏幕截图。Gimp 会询问你要导入哪些页面;我导入了所有页面。
每一页都放在单独的“层”中(这有点无用,但不会太有害)。然后我裁剪一个逻辑图表,另存为结果(请求 Gimp 合并可见层),然后撤销以返回到全屏状态(并裁剪另一选择)。当我完成一页后,我将其从可见层中删除,然后转到下一页。
当所有工作都完成时,我有一个标记图像的目录。我根据需要使用convert -resize XX% ORIG NEW
调整它们的大小,然后将它们放入公共文件夹中以供链接。
附言. Kevin Riggle 提醒我,在同一图中不要混合绿色和红色,除非我想要困扰我的色盲朋友。Xournal 有一个黑色、蓝色、红色、绿色、灰色、青色、石灰色、粉红色、橙色、黄色和白色的调色板,这有点限制。不过我敢打赌,你可以通过在src/xo-misc.c中混淆predef_colors_rgba
来切换它们的顺序
无盘 Paxos 崩溃恢复:ezyang 博客
这是我上周发送的电子邮件的编辑版本。不幸的是,这需要你熟悉原始 Paxos 正确性证明,所以我甚至没有试图将其扩展为适合普通读者的内容。这个算法可能太简单了,以至于不可能在文献中出现,除非可能是非正式提到的—然而,如果它是错误的,我很想知道,因为实际的代码依赖于它。
我想描述一个关于 Paxos 崩溃恢复的算法,该算法不需要持久存储,而是利用同步时钟和基于格子的时期编号。基本思想是将选票/提案号码增加到一个对于崩溃节点不可能做出任何承诺的值。正如在 Paxos made Live 中指出的那样,这种算法在磁盘损坏的情况下非常有用,其中持久存储丢失了。(不幸的是,他们在论文中描述的从此情况恢复的算法是错误的。原因留给读者作为一个练习。)它受到 Renesse 关于“基于时期的系统”的评论的启发,以及在 JPaxos:基于 Paxos 协议的状态机复制 中描述的基于时期的崩溃恢复算法。然而,与 Nuno 的通信中,我发现他们的算法正确性的证明尚未发表,因此我决定自己确信其正确性,并在此过程中发现了一个更简单的版本。也许这个算法已经在社区传闻中存在,如果是这样,那就更好,因为我的主要兴趣是实现。
首先,让我们将提案号码从单一的命名空间值 n
扩展为元组 (e, n)
,其中 n
是之前的命名空间提案号码,e
是一个时期向量,其长度等于 Paxos 集群中节点的数量,并在其上强制常规的笛卡尔乘积格子结构。
让我们确定在节点崩溃期间我们希望从节点中获得的行为:
已知未知。 一个接受者知道一个值 e*
,对于所有 e
满足 e* ≤ e
(使用格子排序),接受者知道是否已经响应了形式为 (e, n)
的准备请求(对所有 n
)。
也就是说,接受者知道一组提案号码的集合,他保证不会为这些号码做出任何承诺。
我们如何建立这个不变性?我们可以将一个值写入持久存储,并在崩溃后递增它;这种行为通过单调性来确立。事实证明,我们还有其他方便的单调数字来源:同步时钟(在 Paxos 的其他情况下也很有用)具有这种行为。因此,我们不再使用整数向量,而是使用时间戳向量。在崩溃时,进程将其时期设置为零向量,除了其自己的条目,该条目设置为当前时间戳。
在Paxos 简介中,Lamport 介绍了接受者操作的以下不变性:
P1a. 只有当接受者未对大于n
的准备请求作出响应时,接受者才能接受编号为n
的提议。
我们可以修改这个不变性为以下内容:
P1b. 只有当e* ≤ e
并且该接受者未对具有n' > n
的准备请求(_, n')
作出响应时,接受者才能接受编号为(e, n)
的提议。
注意这个不变性在“强化”了P1a,因为接受者在更少的情况下接受提议(即在e* ≰ e
时拒绝提议)。因此,安全性得到保证,但进展现在是可疑的。
在建立 Paxos 的进展时,我们要求存在一个稳定的领导者,并且该领导者最终选择一个足够“高”的提议号。因此问题是,领导者最终能够选择一个足够“高”的提议号吗?是的,定义这个数为(lub{e}, max{n} + 1)
。这个时期违反了已知未知吗?不是的,因为零向量与系统已经收敛的任何时期都是不可比的,这个节点有单个较晚的时间戳。
因此,对 Paxos 算法的修改如下:
-
扩展选票号以包括时期号;
-
在初始启动时,将
e*
设置为零向量,并在该节点条目中添加当前时间戳; -
另外,拒绝接受那些时期号不大于
e*
的接受请求; -
当选择新的提议号来提议时,取所有时期号的最小上界。
一个优化是在非崩溃启动时,将e*
初始化为零向量;这消除了在准备请求的第一轮中建立时期的需求。从快照克隆状态是一个正交问题,可以使用修复滞后副本的相同机制来解决。我们建议还实现一种优化,即领导者仅向已知良好的法定人数发送接受消息,因此恢复的节点不会立即强制视图更改。
我若不提及此领域的一些先前工作,就会显得不周到。特别是,在故障检测和一致性在崩溃恢复模型中的应用中,作者们提出了一种卓越的算法,即使在没有稳定存储的情况下,也能处理同时多于大多数节点崩溃的情况(在一些条件下,详见论文)。不幸的是,他们的解决方案比我上面描述的方案要复杂得多,而且我不知道有没有人实现它。此外,处理没有内存的崩溃节点的另一种机制是组成员机制。然而,组成员机制实现起来非常微妙,正确性难以保证。
“不要重复自己”是依赖上下文的:ezyang's 博客
我是一个名为刺客协会的组织成员。不,我们不杀人,也不玩刺客游戏。相反,我们编写和运行竞技实时角色扮演游戏:你会得到一些描述宇宙的游戏规则,一个包含目标、能力和限制的角色表,然后我们会放你在四小时到十天的时间内自由发挥。在这种情况下,我想描述一个应用“不要重复自己”规则可能有害的情况。
当游戏作者构建游戏规则时,“不要重复自己”的原则以非常有趣的方式出现。游戏规则相当复杂:我们希望玩家能够执行诸如进行近战攻击、背后刺杀、施展魔法、打破门等操作,而又不能实际上伤害任何人或破坏任何财产,因此,就像 MIT 的学生那样,我们有“机制”来执行这些游戏内的行动(例如,在一组规则中,近战攻击可以用“Wound 5”声明,其中 5 是你的战斗评级,如果另一个个体的 CR 大于或等于 5,他们可以声明“抵抗”;否则,他们必须扮演昏倒并流血。这非常有趣。)由于构建合理的基础宇宙需要这么多规则,大多数游戏作者都会采用一个普通的、九页的规则表。
当然,规则并不总是一样。一个 GM(即编写和运行游戏的人)可能会决定单独使用攻击和防御等级,而不只是一个 CR 评级。另一组 GM 可能会引入机器人角色,这些角色不能因流血而死亡。等等。
因此,当我们向玩家提供规则时,我们有两种可能性:我们可以重复自己,简单地给他们完整的、修改后的规则集。或者我们可以避免重复自己,给出标准规则和一份勘误表——我们宇宙中所做的具体更改。我们倾向于重复自己,因为这在我们的游戏制作工具中更容易。但一个明显的问题是,哪种方法更好?
答案当然是,这取决于情况。
-
熟悉标准规则的老玩家在玩游戏时不需要每次都给他们整套规则;相反,如果他们只是给一个勘误表,他们会更容易更高效地说:“哦,嗯,有所不同,好的”,然后为这个改变过的游戏宇宙制定策略。这对于十天游戏尤为重要,因为改变宇宙规则可以极大地影响情节和策略。
-
对于从未玩过游戏的新玩家来说,拿到一套规则,然后被告知,“哦,但忽略这个和那个,这里还有一个额外的条件”,会非常令人困惑!在他们玩游戏的最初几次中重复完整的规则是有帮助的。
我认为这个原则同样适用于软件开发中的不要重复自己。为任何特定的代码或数据采用简洁、独特的表示是好的和有用的,但不要忘记一点冗余会极大地帮助那些初次学习你系统的人!而且,为了两全其美,你甚至不应该重复自己:应该让计算机替你完成。
附言. 好奇的朋友们,这里有我们在与 Alex Gurany 和 Jonathan Chapman 合作编写的游戏《杰斐逊·道格拉斯的谋杀案》(工作名称《危险的游戏》)的游戏规则 PDF。
附言 II. 什么时候重复自己被认为是好设计呢?
-
Perl 希望程序员尽可能少地说话来完成工作,这给它赢得了“只写语言”的声誉。
-
并非所有看起来相同的代码都应该重构为一个函数;所抽象出来的东西应该有一定的逻辑统一性。
-
Java 涉及大量的代码编写:IDE 生成
hashCode
和equals
的代码,你可能事后会微调它们。喜欢 Java 的人大胆地声称这阻止了 Java 程序员造成过多的破坏(尽管也有人持不同意见)。 -
当你写论文时,即使你在五十页前已经定义了一个术语,给读者刷新记忆是很好的。这对数学教科书尤其如此。
-
Haskell 挑战你尽可能抽象出数学上合理的结构。因此,这会让人们的头痛,导致各种组合的动物园。但对于即使是中等程度的高级用户来说,它也非常有益。
鼓励读者提出更多例子。
DP Zoo : ezyang’s blog
DP Zoo Tour : ezyang’s blog
有人告诉我,这一切都发生在动物园里……
我一直认为动态规划是一种对将来使用的子计算进行存储的实践,这个名字相当糟糕。为什么不叫它填表算法呢?因为实际上,把动态规划算法看作是填写表格的算法,是一个相当不错的思路。
事实上,你几乎可以完全通过动态规划算法的表格形状及数据从一个单元格流向另一个单元格的方式来描述一个动态规划算法。如果你知道这个形状是什么样子的,通常你可以直接推断出复杂度,而不必知道问题的任何具体内容。
所以我做的事情是收集了一堆来自算法导论的动态规划问题,并画出了表格和数据流。这里有一个简单的例子,解决装配线问题:
蓝色表示我们可以免费填写的单元格,因为它们不依赖于其他单元格。红色表示我们想要找出的单元格,以便从中选择最优解决方案。灰色表示沿途的代表单元格及其数据依赖关系。在这种情况下,机器到给定单元格的最优路径仅取决于其前两个单元格的最优路径。(因为,如果有更优的路径,它早已显示在我的前两个单元格中!)我们还看到任何单元格都有恒定数量的箭头出口和O(n)个单元格在这个表中,因此该算法显然总共需要O(n)的时间。
这里是下一个介绍示例,矩阵乘法的最佳括号化。
每个单元格包含矩阵 i 到 j 的最佳括号化。为了计算单元格的值,我们必须考虑所有可能导致此结果的现有括号化组合(因此有多个箭头)。有O(n²)个框,和O(n)个箭头,总复杂度为O(n³)。
这里有一个很好的方框用于找到两个字符串的最长公共子序列。每个单元格表示第一个字符串到x和第二个字符串到y的最长公共子序列。我会让读者计算单元格和箭头,并验证复杂度是否正确。
构建最佳二叉搜索树的方式与最佳矩阵括号化非常相似。但是索引有些复杂。(哦,顺便说一句,算法导论是以 1 为基础索引的;我在这里切换到了 0 索引来进行我的示例。)
现在我们进入练习领域!欧几里德双调旅行推销员问题在网络上相当知名,其复杂的递归关系与底部边有关。每个单元格表示从 i 到 j 的最佳开放双调路径。
美丽的换行问题,其变种是 Knuth TeX 换行算法的核心,利用一些额外信息来限制必须查找回的单元格数目。(TeX 算法进行全局优化,因此复杂度会是 O(n²)。)每个单元格代表了到目前为止所有单词的最佳换行方式。
最后,编辑问题似乎就像作者决定尽可能增加的复杂性一样,当你意识到他们要求你设计的每个字符串操作对应于某个先前单元的单一箭头时,这个问题就会很好地解决。有用!每个单元都是从源的那个前缀到目的地的那个前缀的最优编辑链。
动物园管理员非常喜欢朗姆酒。
方块、三角形、矩形,这些是我通常找到的桌子。我很好奇 DP 算法是否填补了更多奇异的桌子。发送它们给我,我会画出来!
Haskeller 的对偶:ezyang 的博客
这篇文章是 在 coBurger King 中翻转汉堡 的精神前身。
什么是对偶?一个范畴论者会说,“它是相同的东西,只是所有箭头都反过来了。”这个答案似乎令人沮丧地模糊,但实际上它是相当精确的。唯一缺少的就是知道哪些箭头要反转!如果你知道这些箭头,那么你就知道如何对偶化。在这篇文章中,我想介绍一些对于 Haskeller 而言很好知道的结构,描述这些结构的箭头是什么样子的,然后展示当我们反转这些箭头时,我们得到了一个对偶的概念。
产品与总和
假设你有一些类型为 Either a b
的数据。对于所有的数据,我们希望能够执行两个基本操作:我们想能够构造它和解构它。Either 的构造函数是 Left :: a -> Either a b
和 Right :: b -> Either a b
,而一个合理的解构函数选择可能是 either :: (a -> r) -> (b -> r) -> Either a b -> r
(情况分析,其中第一个参数是左侧情况,第二个参数是右侧情况)。让我们画个图:
我添加了两个额外的箭头:它们表示 either f g . Left == f
和 either f g . Right == g
;这些方程在某种意义上表征了构造函数和解构函数之间的关系。
好的,那么当我们反转这些箭头时会发生什么?这一节的标题已经透露了答案,但让我们来看看:
这些箭头中的一些很容易解释。曾经是我们的构造函数(Left
和 Right
)现在是我们的解构函数(fst
和 snd
)。但是 f 和 g 以及我们的新构造函数呢?实际上,\x -> (f x, g x)
在某种意义上是对于成对值的广义构造函数,因为如果我们设置 f = const a
和 g = const b
,我们可以轻松地获得成对值的传统构造函数(其中成对值的规范本身是箭头——当你第一次看到它时会有点惊讶):
因此,总和和乘积在彼此之间是对偶的。因此,总和经常被称为余积。
(敏锐的读者可能已经注意到这个演示是颠倒的。这主要是为了避免引入似乎突如其来的 \x -> (f x, g x)
。)
顶部和底部
单元类型(称为顶)和底类型(没有元素的类型)在彼此之间表现出对偶关系。我们可以这样看待:对于任何 Haskell 类型,我都可以轻松地构造一个函数,它接受该类型的值并产生单元;它是 const ()
:
此外,忽略惰性计算,这是唯一可以完成这一技巧的函数:它是唯一的。让我们反转这些箭头:是否存在一种类型 A,对于任何类型 B,都存在一个函数 A -> B
?乍一看,这似乎是不可能的。B 可以是任何东西,包括一个不可居住的类型,在这种情况下,我们很难生成适当值。但等等:如果 A 是不可居住的,那么我什么也不用做:函数不可能被调用!
因此,上和下相互对偶。实际上,它们对应于类别Hask中的终端对象和初始对象的概念(分别)。
关于终端对象的一个重要说明:Int
是一个终端对象吗?可以肯定的是,有些函数的类型为 forall a. a -> Int
(例如 const 2
)。然而,这个函数并不唯一:还有 const 0
,const 1
等。因此,Int
不是终端对象。也有很好的理由:有一个易于证明的定理表明所有终端对象彼此同构(对偶地:所有初始对象彼此同构),而 Int
和 ()
显然不是同构的!
折叠和展开
函数式编程语言中最重要的组成部分之一是递归数据结构(也称为归纳数据结构)。有许多方法可以操作这些数据,但其中最简单且最广为人知的是折叠,可能是可以使用的最简单的递归形式之一。
折叠的图表稍微复杂一些,所以我们将从头开始推导,思考函数式程序员最常见的折叠,即列表的折叠:
data List a = Cons a (List a) | Nil
foldr :: (a -> r -> r) -> r -> List a -> r
前两个参数“定义”了折叠,而第三个参数只是提供了实际要折叠的列表。我们可以尝试立即绘制一个图表:
但是我们遇到了一点小麻烦:我们的图表有点无聊,主要是因为对偶 (a -> r -> r, r)
并没有一个好的解释作为箭头。那么我们该怎么办呢?我们真正想要的是一个单一的函数,它能编码我们最初编码的所有信息。
好吧,这里有一个例子:g :: Maybe (a, r) -> r
。假设我们最初有一对 (f, z)
,然后定义 g
如下:
g (Just (x, xs)) = f x xs
g Nothing = z
直观地说,我们通过用一个和类型替换输入参数将折叠函数和初始值合并为一个函数。为了运行 f
,我们传递一个 Just
;为了获取 z
,我们传递一个 Nothing
。稍微概括一下,任何折叠函数都可以通过一个函数 g :: F a r -> r
来指定,其中 F a
是适合问题中的数据类型的函子(在列表的情况下,type F a r = Maybe (a, r)
)。我们重复使用 Maybe
,这样我们就不必定义一个新的数据类型,但我们可以更有启发性地重命名 Just
和 Nothing
,作为 data ListF a r = ConsF a r | NilF
。与我们原始的 List
定义 (Cons a (List a) | Nil
) 相比,它是相同的,但所有递归出现的 List a
都用 r
替换了。
有了这个定义,我们可以更详细地构建我们的图表:
最后一步是以某种方式关联 List a
和 ListF a r
。记得 ListF
看起来很像 List
,只是用 r
替换了 List a
。所以如果我们有 ListF a (List a)
—— 简单地将 List a
替换回函子中。我们预计这与 List a
有关系,确实有一个简单而独特的函数可以将一个转换为另一个:
in :: ListF a (List a) -> List a
in (ConsF x xs) = Cons x xs
in NilF = Nil
谜题的最后一块是:我们如何从 ListF a (List a)
转换到 ListF a r
?嗯,我们已经有一个函数 fold g :: List a -> r
,所以我们需要做的就是用 fmap
将其提升起来。
我们有一个交换图表,并要求 g . fmap (fold g) = fold g . in
。
现在剩下的就是泛化了。一般来说,ListF
和 List
使用一个叫做 Mu
的小技巧相关联,定义为 data Mu f = Mu (f (Mu f))
。Mu (ListF a)
和 List a
是同构的;直观地说,它用所定义的数据结构替换了所有的 r
。所以一般来说,图表看起来像这样:
现在所有这些初步工作都已经完成,让我们来对偶化!
如果我们来看一下 Prelude 中 unfold 的定义:unfold :: (b -> Maybe (a, b)) -> b -> [a]
;那么 Maybe (a, b)
正好对应我们的 ListF
!
这里的情况与和积的故事非常相似:在递归世界中,我们主要关注如何 析构 数据。在核递归世界中,我们主要关注如何 构造 数据:g :: r -> F r
,现在告诉我们如何从 r
进入更大的 Mu F
。
结论
对偶化是一个优雅的数学概念,在你知道去哪里找到它后,它就随处可见!此外,从范畴论学家的角度来看,它非常好,因为当你知道两个概念是对偶的时候,你所拥有的所有定理都会自动翻转到另一侧!(这是因为范畴论中的所有基本概念都可以对偶化。)如果你有兴趣了解更多信息,我建议阅读 Dan Piponi 关于数据和余数据的文章。
杜达梅尔访问麻省理工学院:ezyang 的博客
杜达梅尔访问麻省理工学院
指挥家和小提琴家Gustavo Dudamel今天将访问麻省理工学院,接受尤金·麦克德莫特艺术奖。颁奖典礼的一部分将包括杜达梅尔指挥麻省理工交响乐团的演出;我将在舞台上演奏韦伯和莫扎特的双簧管和英国管。我们的常任指挥(亚当·博伊尔斯)通过在节奏和乐句方面采取,嗯,不寻常的自由来为此做准备。
我通常不太了解指挥家的名字,但偶尔在WQXR上听到过杜达梅尔的名字。今晚将会是令人兴奋的一晚。
前后不相干的事实。你可以使用pdftk input1.pdf input2.pdf input3.pdf cat output output.pdf
将 PDF 文件合并。你也可以使用 GhostScript,但这样会导致文件先转换为 PostScript,从而使质量下降。(这也许可以解释为什么我们总是发现服务器磁盘上充斥着 2.5GB 的 PostScript 文件。)
动态作用域是一种效果,隐式参数是一种共效:ezyang 的博客
来源:
blog.ezyang.com/2020/08/dynamic-scoping-is-an-effect-implicit-parameters-are-a-coeffect/
长久以来,我一直认为隐式参数和动态作用域基本上是相同的东西,因为它们都可以用来解决类似的问题(例如所谓的“配置问题”,其中你需要将某些配置深入到函数定义的嵌套体中而不显式地定义它们)。但隐式参数却有一个不被推荐使用的名声(使用反射代替),而通过读者单子进行动态作用域是一个有用且被充分理解的构造(除了你需要将一切变成单子的那一点)。为什么会有这样的差异?
Oleg 指出隐式参数并不真正是动态作用域,并且举了一个例子,展示了 Lisp 和 Haskell 在此问题上的分歧。而你甚至不希望在 Haskell 中出现 Lisp 的行为:如果你考虑动态作用域的操作概念(沿着堆栈向上走,直到找到动态变量的绑定位置),它与惰性计算并不兼容,因为一个 thunk(访问动态变量)将在程序执行的某个不可预测的点被强制执行。你确实不想要去思考 thunk 将在何处执行以确定它的动态变量将如何绑定,这样做只会导致疯狂。但在严格语言中,没人会觉得需要去理解动态作用域应该如何运行(好吧,大多数情况下--稍后会更详细说明)。
研究界已经发现,隐式参数与附效有所不同。我相信这最初是在Tomas Petricek的研究中首次观察到的(更现代的呈现在Tomas Petricek; 更 Haskelly 的呈现可以在Tomas Petricek找到)。然而,Tomas 在 2012 年在我的博客上发表评论,探讨了类似的想法,所以这可能已经在酝酿了一段时间了。关键点是,对于一些附效(即隐式参数),按名调用的减少保持类型和附效,因此隐式参数不会像动态作用域(一种效果)那样在使用时出现问题。这些肯定有不同的行为方式!类型类也是附效,这就是为什么 Haskell 中现代隐式参数的使用明确承认了这一点(例如,在反射包中)。
在今年的 ICFP 上,我看到了有关Koka 中隐式值和函数的有趣技术报告,这是动态作用域的一个新变化。我不禁想到了 Haskell 隐式参数可能从这项工作中学到一些东西。隐式值明智地选择在顶层全局定义隐式值,以便它们可以参与正常的模块命名空间,而不是一组没有命名空间的动态作用域名称(这也是反射在隐式参数上的改进)。但实际上,隐式函数似乎正在借鉴隐式参数的一部分!
最大的创新在于隐式函数,它解决了函数中所有动态引用(不仅仅是词法上,而是所有后续的动态调用)到词法范围(函数定义时的动态范围)的问题,生成一个函数,它不依赖于隐式值(也就是说,没有效果表明在调用函数时必须定义隐式值)。这正是隐式参数let ?x = ...
绑定会做的事情,在定义时直接为隐式函数填充字典,而不是等待。非常具有上下文意识!(当然,Koka 使用代数效应实现了这一点,并通过非常简单的转换得到了正确的语义)。结果并不完全是动态作用域,但正如 TR 所述,它导致更好的抽象。
很难想象隐式值/函数如何重新进入 Haskell,至少不是在某种序列构造(例如,一个单子)潜伏的情况下。尽管隐式函数的行为很像隐式参数,但其余的动态作用域(包括隐式函数本身的绑定)仍然是良好的旧有效果动态作用域。你不能在 Haskell 中轻易实现这一点,因为这会破坏在 Beta-还原和 Eta-扩展下的类型保持性。Haskell 别无选择,只能走到底,一旦你超越了隐式参数的明显问题(这是反射修复的),事情似乎大部分可以解决。
用动机进行消除(在 Coq 中):ezyang 的博客
来源:
blog.ezyang.com/2014/05/elimination-with-a-motive-in-coq/
用动机进行消除(在 Coq 中)
在像 Coq 这样的证明助手中,消除规则在数据类型的计算中起着重要作用。在他的论文《用动机进行消除》中,Conor McBride 论述道:“我们应该利用假设,不是仅仅在其直接后果上,而是在其对任意目标产生的影响上:我们应该给消除一个动机。” 换句话说,在细化设置中的证明(向后推理)应该利用它们的目标来指导消除。
最近我有机会重新阅读这篇历史性的论文,在此过程中,我想将示例移植到 Coq 中。以下是结果:
这基本上是一个激励约翰·梅杰相等性(也称为异构相等性)的简短教程。链接的文本实质上是论文第一部分的注释版本——我大部分文本重复使用,并在必要时添加了评论。源代码也可以在以下链接找到:
Ely 自行车:ezyang 的博客
Ely 自行车
昨天我从剑桥骑车到了 Ely,然后又回来了。这条路线是英国城镇和乡村壮丽的 38 英里(往返)。剑桥周围的骑车非常好,因为那里没有太多的山坡,在农田地区你可以看到拖拉机经过。我之前骑过的最长的距离是波特兰的春水走廊,我骑的部分只有大约 10 英里。
不合逻辑。 ICFP 提交截止日期是今天!全球的函数式程序员报告称草稿论文的目击次数显著增加。
拥抱 Windows:ezyang 的博客
拥抱 Windows
有些事情终将循环回来。
作为一个高中生,我是一个真正的 Windows 爱好者。作为一名初出茅庐的程序员,我出于必要积累了一个完整的开发环境,包括 Cygwin、手写批处理脚本、PuTTY、LogMeIn、一套自制的 PHP 构建脚本和 Notepad++。我对这个事业如此投入,甚至为 Git 贡献了 一个补丁,以便让 Git 在 Windows 上与 plink 顺畅运行。这个设置虽然能工作,但总感觉是一个由不同组件拼凑而成的拼图,彼此之间不完全协调。当我发现 Linux 能为我提供一个非常一致的开发环境时,我果断放弃了 Windows。
有些事情终将循环回来。 Windows 最终总会回到你身边。我在 Galois 暑期工作时,需要支持 Windows 的产品,因此花费了好几天时间确保我的更改能在 Windows 上正确构建。接着,我转向了 GHC 的修改,Simon Marlow 要求我在 Windows 上实现相同的功能。
我决定停止把 Microsoft Windows 视为开发者中不受欢迎的黑羊操作系统。不管喜欢与否,Windows 将会存在;即使我从未在笔记本电脑上启动过 Windows,作为开发者,考虑并在 Windows 上测试我的代码是一个良好的实践。甚至可能有这样的情况:Windows 是一个 完全合理 的底层开发平台。
开发者可能觉得针对其他平台感到恼火的原因似乎有两个:
-
他们无法访问运行该操作系统的计算机,这使得调试问题变得极为恼人——毕竟,这就是可重现测试案例成为 Bug 报告黄金标准的原因。我们应该建立易于访问和使用的构建服务器来让人们在这些不同环境中进行测试。这涉及到投入一些资金购买适当的许可证,而开源作者可能不愿意这样做:那些有站点许可证的地方的人们可以通过捐赠计算机来帮助这些人进行测试,就像公司和大学捐赠磁盘空间和带宽用于镜像一样。
-
他们必须学习另一个平台,包括其所有的复杂性和陷阱。一方面,这很烦人,因为“我已经知道如何在 Unix 上做这件事了,现在我必须花 N 分钟弄清楚如何在 Windows 上做,再花 N 分钟弄清楚为什么在某些边缘情况下它不起作用。”另一方面,学习一个已经知道如何做某事的平台可能会有点有趣:你可以看到不同的设计决策,并对同一个问题发展出多种视角,我发现这总是有助于我解决问题。
在 Windows 编程中仍然有一些部分我继续不感兴趣:例如,我觉得清单文件的变化相当无聊。但另一方面,我觉得 Linux 发行版中的打包也很无聊。别再责怪 Windows 了!
等式,粗略地说:ezyang 的博客
等式,粗略地说
在《软件基础》中,等式是以这种方式定义的:
即使是 Coq 的等式关系也不是内建的。它有(大致)以下归纳定义。
Inductive eq0 {X:Type} : X -> X -> Prop := refl_equal0 : forall x, eq0 x x.
为什么是“粗略地说”? 好吧,事实证明,Coq 对等式的定义略有不同(重新格式化以匹配《软件基础》的呈现):
Inductive eq1 {X:Type} (x:X) : X -> Prop :=
refl_equal1 : eq1 x x.
什么是区别?诀窍在于查看 Coq 为每个这些生成的归纳原理:
eq0_ind
: forall (X : Type) (P : X -> X -> Prop),
(forall x : X, P x x) -> forall y y0 : X, eq0 y y0 -> P y y0
eq1_ind
: forall (X : Type) (x : X) (P : X -> Prop),
P x -> forall y : X, eq1 x y -> P y
在我们的同伦类型论阅读小组中,Jeremy 指出这两个原则之间的区别正是路径归纳(eq0)和基于路径归纳(eq1)之间的确切区别。(这在同伦类型论书的第 1.12 节中有涵盖)因此,Coq 使用略微更奇怪的定义,因为它恰好更方便一些。(我确信这是传统知识,但直到现在我才注意到这一点!欲了解更多,请阅读 Dan Licata 的优秀博文。)
“gin and monotonic”的勘误:ezyang 的博客
Errata for gin and monotonic
在忙于 GHC 的打包和黑客之间,我没有足够的时间来准备系列的下一篇文章或编辑上一篇文章的图片,所以今天只有一个小勘误贴。
-
完全列表图表缺少一些排序:★:⊥ ≤ ★:⊥:⊥ 等等。
-
在通常的指称语义中,你无法区分 ⊥ 和
λ_.⊥
。然而,正如 Anders Kaseorg 和 Haskell 报告指出的那样,使用seq
你可以区分它们。这或许是为何seq
是一种不好的函数的真正原因。我一直假设它有更强的保证(这是 zygoloid 指出的),但对于 Haskell 实际上并非如此。 -
“停滞”图表中的“应该存在”箭头方向错误。
-
在完全列表图表的相同方式中,
head
缺少一些排序,因此实际上灰色的 blob 是完全连接的。在某些情况下,我们可以有断开的 blob,但对于只有一个最大值的域来说不行。 -
fst 明显的拼写错误。
-
函数的正式偏序没有正确定义:最初它声明对于 f ≤ g,有 f(x) = g(x);实际上,它比这弱:f(x) ≤ g(x)。
-
非勘误:头部图表的右侧被省略了,因为……添加所有箭头使其看起来相当难看。这是我在决定它不是一个好图之前所做的草图。
感谢 Anders Kaseorg、zygoloid、polux 和指出函数偏序错误的其他人(现在找不到那些信件了)的更正。
不合逻辑的推论。这是一个真的很简单的多变函数。相同的基本思想是 Text.Printf 的工作原理。希望它能帮助你在多变的旅程中。
{-# LANGUAGE FlexibleInstances #-}
class PolyDisj t where
disj :: Bool -> t
instance PolyDisj Bool where
disj x = x
instance PolyDisj t => PolyDisj (Bool -> t) where
disj x y = disj (x || y)
Evaluation on the Haskell Heap : ezyang’s blog
The Ghost of the Christmas Present
In today’s post, we’ll do a brief survey of the various things that can happen when you open haunted presents in the Haskell heap. Asides from constants and things that have already been evaluated, mostly everything on the Haskell heap is haunted. The real question is what the ghost haunting the present does.
在最简单的情况下,几乎什么也不会发生!
与礼品卡不同,你必须打开下一个礼物(Haskell 不允许你评估一个 thunk,然后决定不跟随间接引用...)
More commonly, the ghost was lazy and, when woken up, has to open other presents to figure out what was in your present in the first place!
简单的原始操作需要打开所有涉及的礼物。
但是鬼魂也可能会无故地打开另一个礼物...
或者执行一些 IO 操作...
Note that any presents he opens may trigger more ghosts:
结果是一场真正的鬼魂盛会,全都是为了打开一个礼物!
打开一个礼物(thunk)可能会引起这样的级联效应,这正是让惯于认为堆中所有对象都已解包(评估)的人感到惊讶的原因。因此,摆脱这种惊讶的关键在于了解鬼魂何时决定需要打开一个礼物(严格性分析),以及你的礼物是否已经被解包(摊销分析)。
Last time: The Haskell Heap
Next time: IO evaluates the Haskell Heap
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
共享 Web 主机的演变:ezyang 的博客
共享 Web 主机的演变
爱德华继续发布他的系统文章。波士顿的空气中一定有什么东西。
昨天,我在 SIPB 线索导论 上介绍了 scripts.mit.edu 的使用和实施,这是 SIPB 为 MIT 社区提供的共享主机服务。我几乎所有的系统管理员经验都来自于帮助维护这项服务。
Scripts 是 SIPB 为 MIT 社区提供的共享托管服务。然而,它所做的远不止普通的 $10 主机:哪些共享托管服务可以直接集成到你的 Athena 帐户中,通过 Linux-HA 管理的服务器集群上复制你的网站,让你请求 *.mit.edu 的主机名,提供常见 Web 软件的自动安装,允许你自定义并为你进行升级?Scripts 是一个蓬勃发展的开发平台,拥有超过 2600 名用户和许多有趣的技术问题。
我最终将演讲分为两个部分:一个简短的高级用户脚本演示,以及一个更长的技术文章,名为共享 Web 主机的演变。演讲中还分发了一份速查表。
在此演讲中讨论的技术包括 Apache、MySQL、OpenAFS、Kerberos、LDAP 和 LVS。
存在类型 - 柯里的博客
这篇文章是给那些一直想知道为什么 Haskell 中有 forall
关键字但没有 exists
关键字的人。大部分现有的网络教程都非常“操作性”,对存在类型是什么持有一种观点,并且展示将 forall 放在“正确的位置”会得到正确的行为。我将采取一种不同的方法,利用柯里-霍华德同构来解释翻译。其中一些逻辑示例是从 Aaron Coble 的逻辑与证明讲座笔记中无耻地借鉴过来的。
首先,让我们稍微恢复一下逻辑知识。(如有需要可跳过。)
逻辑系统层次结构的最底层是命题逻辑。每当你在 Haskell 中编写一个非多态函数时,你的函数定义对应于命题逻辑中的一个陈述—这就是简单类型 λ 演算。你会得到一些命题符号 P、Q 和 R(对应于类型),以及一些逻辑连接符 . 特别地, 对应于函数箭头 ->
,因此你可以将 ![P \to Q](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/to Q") 理解为类型 P -> Q
。
下一个阶段是一阶谓词逻辑,允许你在个体 x、y 和 z 上使用全称量词 ∀ 和 存在量词 ∃(谓词接受个体并返回命题)。在这个系统中的逻辑公式开始看起来很像 Haskell 的多态性,但实际上对应于依赖类型:个体是术语,而不是类型。
在这篇文章中,我们将会让 x、y 和 z 范围在命题(类型)上(除了两个一阶逻辑的例子来获得一些量词的直觉)。然后多态函数的定义是所谓的命题二阶逻辑中的陈述。
命题二阶逻辑给了我们一些自由度,我们可以用它做一些相当直观的事情。存在类型就是其中之一的应用。然而,大多数 Haskeller 对多态函数有一个相当好的直觉,比如 id :: a -> a
,它实际上在最开始有一个 ∀ 量词,像 id :: forall a. a -> a
或者 ![ \forall x. x \to x](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/to x"). 接下来我想要做的是将我们对多态函数的直觉感受与我们对全称量词的直觉感受联系起来。
考虑以下英语句子:所有教授都能教书和进行研究。 我们可以将其翻译为一阶逻辑中的陈述(x 范围在个体上):
“缩小”普遍量化变量的技巧的直觉是直接对应于使用类型类时发生的隐式字典传递(这也会缩小普遍量化变量)。
我们可以对存在量化器进行类似的转换。每个人都爱着某人 和 有人是每个人都爱 分别对应于:
请花一些时间说服自己这些不是相同的陈述,并弄清楚蕴含的方向。
现在我们直接跳到蕴含的等价关系,这是重点所在。在这里,x 范围涵盖命题(即类型)。
考虑第一个等价关系:直觉上,它说明我们可以通过使用 forall x. (A x -> B)
模拟接受存在类型的函数。这正是存在数据构造函数:
data OpaqueBox = forall a. OpaqueBox a
它的类型是 forall a. (a -> OpaqueBox)
。
第二个命题有点难以理解:从右到左的方向上,似乎很明显,如果存在一个推论 A(x) 到 B,那么如果我提供所有的 x,我会得到 B。然而,从左到右,如果我提供所有的 A(x) 来得到 B,那么其中一个 A(x) 必须已经被使用,但我没有好方法找出是哪一个。
我们可以通过序言演算严格证明这种等价性。我们可以将这些看作是“推理规则”,类似于假言推断(如果 A 则 B,A;因此,B)。然而,序言演算中的陈述采用形式 ,其中 Γ 是共同形成假设的命题集合,Δ 是形成结果的命题集合的析取。( 叫做“推导”,表示蕴含。)
特别是,![全称 L](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/forall L") 和 ![存在 R](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/exists R") 相当有趣:![全称 L](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/forall L") 表示我可以通过选择某个子项并用新的全称量化变量替换所有实例来使任意假设的命题“多态化”(这是一个更弱的假设,所以我们正在减弱我们的蕴涵)。我们确实可以在 Haskell 中做到这一点(就像将(Int -> Bool) -> Int -> Bool
转换为(a -> b) -> a -> b
),只要我们的证明不看实际的类型来执行其计算。![存在 R](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/exists R") 则表示我可以通过说某些更弱的东西来“隐藏”我的工作,而不是 A[t],我只是说存在一些 x 使得 A[x]为真。这与存在类型隐藏表示的直觉非常对应。另一个很好的对偶是,全称量化将信息隐藏在证明内部,而存在量化则将信息隐藏在证明外部。
![全称 R](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/forall R") 和 ![存在 L](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/exists L") 的作用不大,但使用起来有些棘手:转换符号右侧的任何全称量化可以创建/销毁自由变量,而在转换符号左侧的任何存在量化可以创建/销毁自由变量。注意,![全称 L](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/forall L") 和 ![存在 R](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/exists R") 不能以这种方式使用;虽然它们可以使用现有的自由变量,但不能创建或销毁它们。
这是等价性的双向证明。我们试图证明的内容位于底部;顶部是重言式。
证明非常对称:一个使用∀L 和∃L,另一个使用∀R 和∃R。→R 的应用“非柯里化”了每个蕴涵。此外,两个证明都是构造性的表明了这种等价关系可以通过 Haskell 程序见证!你可以查看kmc 提供的 Coq 版本的证明。
后记。 最初我选择了错误的等价性,但我觉得不分享它会很可惜。这是:![](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/to B) \vdash (\forall x\ A[x]) \to B") 的证明。
这是完全通过直觉逻辑完成的:另一方向需要经典逻辑。这留给读者作为练习,解决方案在这里,由 monochrom 提供。同时也有 Coq 中的 kmc 版本 能够在两个方向上进行。这个结果对于存在于函数上的存在性有一个有趣的含义:我们可以从存在性转换到全称性,但反之则不行!
极端编程:ezyang 的博客
函数很棒。如果我们制作一种只有函数的编程语言呢?
对象是很棒的。如果我们制作一种编程语言,所有东西都是对象呢?
惰性评估很棒。如果我们制作一种编程语言,每种数据类型都是惰性的呢?
极端编程(与极限编程无关)是将某些原则提升到高于一切的地位,并在各处应用它的行为。在尘埃落定后,人们经常会看到这种极端主义,并认为,“嗯,这有点有趣,但在 Y 中使用 X 显然是不合适的。你需要选择适合工作的正确工具!”
这里的关键是:有时你应该使用错误的工具——因为它可能是正确的工具,只是你还不知道而已。如果你不到处尝试使用函数,你可能不会意识到接受函数作为参数[1]或廉价 lambda[2]的函数的实用性。如果你不到处尝试使用对象,你可能不会意识到整数[3]和对象的类[4]也是对象。如果你不到处尝试使用惰性,你可能不会意识到纯度是更重要的语言特性[5]。
这导致两个建议:
-
在学习新原则时,尝试到处应用它。 这样,你将更快地了解它何时适用,何时不适用,即使你对它的最初直觉是错误的。(另一方面,如果你没有意识到某种情况下该原则是适用的,正确的工具可能会导致你错失机会)。
-
在试图阐明某个原则的本质时,极端系统是最清晰的。 如果你想知道使用惰性评估编程的感觉,你应该使用 Haskell,而不是一个具有可选惰性的语言。即使极端系统不太实用,它确实能更快地抓住问题的核心。
有很多情况下,极端主义是不合适的,但对于有趣的项目、小项目和研究,它确实可以教会你很多东西。在过去一年中,我最记得的一次互动是与 Adam Chlipala 合作时。我们正在 Coq 中进行一些证明,我一直采取逐步进行证明,然后再使用 Ltac 自动化的温和路线。亚当告诉我:“你应该从一开始就自动化证明,不要费心手动探索。” [6] 这是一条明智的建议,让我的生活好了很多:我想我只是不够极端而已!
文件很棒。如果我们制作一个操作系统,所有东西都是文件呢?
Cons 单元格很棒。如果我们制作一种编程语言,所有东西都由 Cons 单元格构成呢?
数学很棒。如果我们制作一种编程语言,所有东西都来自数学呢?
数组真棒。如果我们创造一种编程语言,一切都是数组会怎样?
[1] 高阶函数和组合子:这些往往不太流行,因为可能编写起来非常冗长,或者因为语言没有很好的词汇来描述高阶函数的接口。(类型在这里有些帮助。)
[2] 廉价的 lambda 对于方便使用许多功能是必要的,包括:单子、作用域分配(以及一般情况下的上下文)、回调、高阶函数。
[3] 考虑 Java 的早期版本,在整型和其他基本类型的自动装箱之前。
[4] Smalltalk 使用这个技术效果很好,JavaScript 也是如此。
[5] 这是我最喜欢的一个关于 Haskell 的叙述,出自 Simon Peyton Jones 的演讲 Wearing the hair shirt(在这种情况下,指的是惰性)。
[6] 这是 Chlipala Coq 证明学派的核心,认识到有多么令人惊讶地能够欺骗经验丰富的计算机科学家手写等效于直线程序,而没有任何抽象。
BarnOwl 的 Facebook 支持:ezyang 的博客
BarnOwl 的 Facebook 支持
这是专为 MIT 的人群准备的。今天早上,我满意地完成了我对 BarnOwl 的 Facebook 模块 的修改(我的满意度表现在对 Facebook API 调用的异步支持上,即不再随机冻结!)。但是,让它在 Linerva 上运行有点复杂,所以这里有个详细的步骤。
-
使用 MIT 网站上的说明 设置本地 CPAN 安装,使用
local::lib
。不要忘记将设置代码添加到.bashrc.mine
,而不是.bashrc
,然后进行源操作。不要忘记遵循先决条件:否则,CPAN 将会提示很多信息。 -
安装所有你需要的 CPAN 依赖项。对于 Facebook 模块,这意味着需要安装
Facebook::Graph
和AnyEvent::HTTP
。我建议使用notest
,因为Any::Moose
在 Linerva 上似乎会失败一个无害的测试。Facebook::Graph
失败了几个测试,但不用担心,因为我们将使用预打包版本。如果你想使用其他模块,你也需要在 CPAN 中安装它们。 -
克隆 BarnOwl 到本地目录 (
git clone git://github.com/ezyang/barnowl.git barnowl
),然后运行./autogen.sh
,configure
和make
。 -
使用
./barnowl
运行,然后输入命令:facebook-auth
并按照说明操作!
欢迎使用 Facebook!
附言. 我真的很惊讶,竟然没有一种流行的命令式语言有绿色线程和抢占式调度,允许你实际上编写看起来是阻塞的代码,尽管它在内部使用事件循环。也许这是因为在保证安全性的同时进行抢占是很难的……
已知的 bug. 读/写验证 bug 已修复。我们似乎在 BarnOwl 的事件循环实现中触发了一些 bug,这导致每天都会出现崩溃(这使得调试变得困难)。保持备份的 BarnOwl 实例是个好主意。
模块化编程的第一印象:ezyang 的博客
来源:
blog.ezyang.com/2011/08/first-impressions-of-module-programming/
在我在 Jane Street 的时间里,我做了大量涉及模块的编程工作。我涉及了函子、类型和模块约束、嵌套模块,甚至是一等公民模块(尽管只是次要的)。不幸的是,在《类型与编程语言高级主题》中关于模块的章节让我无法专注,所以我不能真正称自己在模块系统上是“有知识的”,但我认为我已经足够使用它们来对它们发表一些评论。 (所有关于惯例的评论都应该被视为 Jane Street 风格的指示。注:他们已经开源了部分他们的软件,如果你真的想看看我谈论的一些东西。)
好消息是它们基本上按照你的期望工作。事实上,它们非常巧妙。当你开始使用大量使用模块的代码库时,你会注意到的最基本的习惯用法是这样的:
module Sexp = struct
type t = ...
...
end
实际上,我曾经在 Henning Thielemann 的 Hackage 上看到过这种风格的地方,特别是data-accessor,我之前有过涵盖。与 Haskell 不同,在 OCaml 中,这种风格确实有意义,因为你从未像在 Haskell 术语中的未限定导入一样,你通常会将类型称为Sexp.t
。因此,抽象的基本单位可以被认为是一种类型——大多数简单的模块恰好是这样——但你可以辅助类型和操作该类型的函数。这是相当容易理解的,你可以将模块系统大多解析为一种便捷的命名空间机制。
然后事情变得有趣。
当你使用 Haskell 的类型类时,每个函数都会单独指定对参数的约束。OCaml 没有任何类型类,因此如果你想要这样做,你必须手动将字典传递给函数。你可以这样做,但这很烦人,OCaml 程序员更喜欢更大的东西。所以,你不是将字典传递给函数,而是将模块传递给函子,并一次性专门化所有“通用”函数。这更加强大,这种力量克服了在任何给定时间显式指定你使用的模块的烦恼。约束和嵌套模块从这个基本思想中自然而然地产生,当你实际尝试在实践中使用模块系统时。
对于我来说,关于模块系统最难理解的事情之一是类型推断和检查是如何在其上操作的。部分原因是类型类如何工作与之间存在的不匹配。当我有一个函数时:
f :: Monoid m => m -> Int -> m
m
是一个可以与任何特定类型统一的多态值。因此,如果我执行f 5 + 2
,如果为Int
定义了适当的 Monoid 实例(即使+
不是 Monoid 实例方法),那是完全合理的。
然而,如果我用模块做同样的技巧,我必须小心添加额外的类型约束来教编译器某些类型确实是相同的。这是一个额外的类型限制的例子,感觉应该被统一化消除,但实际上并没有:
module type SIG = sig
type t
val t_of_string : string -> t
end
module N : SIG = struct
type t = string
let t_of_string x = x
end
let () = print_endline (N.t_of_string "foo")
实际上,在您添加那个SIG
声明时,您必须指定t
和string
是相同的:
module N : SIG with type t = string = struct
有趣!(实际上,当您为大量类型指定约束时,而不仅仅是一个类型时,情况会变得更加恼人。)涉及到函子时,正确性也很棘手,在 OCaml 3.12 之前有一些错误,这意味着您必须采取一些丑陋的措施来确保您实际上可以编写您想要的类型约束(with type t = t
… 这些ts
是不同的…)
有时候,您确实会觉得在 OCaml 中真的很想要类型类。高度多态功能通常是关键因素:如果您有类似Sexpable
(可以转换为 S 表达式的类型),使用模块系统感觉非常像鸭子类型:如果它有一个sexp_of_t
函数,并且类型正确,它就是“sexpable”。天哪,我们基础库中大多数复杂的函子都是因为我们需要处理多参数类型类的道德等价物。
单子绑定当然是没有希望的。好吧,如果您的程序中只使用一个单子(然后您只需通过打开模块来专门化您的>>=
到该模块的实现)。但在大多数应用程序中,您通常在一个特定的单子中,如果您想快速切换到option
单子,您就没那么幸运了。或者您可以重新定义运算符为>>=~
,希望没有人刺伤您。:-)
VX-8R 的第一印象:ezyang 的博客
VX-8R 的第一印象
VX-8R 是我拥有的第一台业余无线电;我之前使用过 VX-7R,但我使用它的范围是有人递给我这台无线电说,“这是预先配置好了你需要的频率的无线电;这是静噪如何工作;这是如何调试常见问题;不要搞砸了。”这是我对 VX-8R 的印象。
-
尽管构造坚固,我还是要退回去更换保修。电池指示器有问题;在放电时它卡在 100% 电量,而在充电时是 0% 电量。根据 HRO 的代表,这是非常不寻常的。不得不送回无线电更换有点麻烦,但嘛,能怎么办呢。
-
Yaesu 试图尽量避免模式,但当它处于模式时,稍微难以确定哪个键执行什么功能。例如,在扫描时,按下 PTT 可以终止扫描,但 BAND 键和箭头键也有同样的作用。PTT 实际上是一个相对可靠的方法来退出 FOO 模式。
-
我喜欢扫描界面。按住 UP/DOWN 开始扫描,如果停留在错误的地方,使用旋钮微调它,当听到有趣的东西时按下 PTT。
-
VX-8R 最棒的地方之一是立体声耳机插孔,部分弥补了需要两个适配器才能获取分割扬声器和 PTT 麦克风套装的不便。我已经用 Yaesu 听了很多 FM 收音机(也许不是最有趣的用途,但总归有用!)立体插头位于一个相当可感知的深井内,所以你可能会发现较短的插头插入困难。
-
关于改装,尽管约一年前发布,看起来 VX-8R 仍然没有可用的软件修改软件。当前的硬件修改只开放了 MARS/CAP 发射频率。当前的硬件修改
关于麦克风困境还没有消息;我可能只是花点钱买个 Pryme 耳机(它们比我想象的要贵一点点)。
c2hs 的第一步:ezyang 的博客
来源:ezyang 博客
这是 关于 c2hs 的六部分教程系列中的第四部分。今天我们讨论 c2hs 中的简单事物,即类型、枚举、指针、导入和上下文指令。
Prior art. c2hs 支持的所有指令都在 “tutorial”页面 中简要描述(也许更准确地说是“参考手册”,而非教程)。此外,在 c2hs 的 研究论文 中,对大多数指令也有更为非正式的介绍。
Type. C 代码偶尔包含宏条件重新定义类型的情况,具体取决于某些构建条件(以下是真实代码):
#if defined(__ccdoc__)
typedef platform_dependent_type ABC_PTRUINT_T;
#elif defined(LIN64)
typedef unsigned long ABC_PTRUINT_T;
#elif defined(NT64)
typedef unsigned long long ABC_PTRUINT_T;
#elif defined(NT) || defined(LIN) || defined(WIN32)
typedef unsigned int ABC_PTRUINT_T;
#else
#error unknown platform
#endif /* defined(PLATFORM) */
如果你想要编写引用使用 ABC_PTRUINT_T
函数的 FFI 代码,你可能需要对 Haskell 中值的真实情况进行猜测或使用 C 预处理器重新实现条件。使用 c2hs,你可以通过 type
获取 typedef 的真实值:
type ABC_PTRUINT_T = {#type ABC_PTRUINT_T #}
考虑一个 64 位 Linux 系统的情况(__ccdoc__
未定义,LIN64
已定义),则结果是:
type ABC_PTRUINT_T = CLong
Enum. 枚举在编写良好的(即避免魔术数字)C 代码中经常出现:
enum Abc_VerbLevel
{
ABC_PROMPT = -2,
ABC_ERROR = -1,
ABC_WARNING = 0,
ABC_STANDARD = 1,
ABC_VERBOSE = 2
};
然而,在底层,这些实际上只是整数(ints),因此希望在 Haskell 代码中将枚举值传递给函数的代码必须:
-
创建一个新的数据类型来表示枚举,并
-
编写一个函数,将该数据类型映射到 C 整数,然后再次映射回来,以便创建
Enum
实例。
我们可以让 c2hs 为我们完成所有工作:
{#enum Abc_VerbLevel {underscoreToCase} deriving (Show, Eq) #}
变成了:
data Abc_VerbLevel = AbcPrompt | AbcError | AbcWarning | AbcStandard | AbcVerbose
deriving (Show, Eq)
instance Enum Abc_VerbLevel
fromEnum AbcPrompt = -2
-- ...
注意,由于 ABC_PROMPT
在 Haskell 中是一个非常难看的构造函数,我们使用如上述的 underscoreToCase
算法转换名称。您也可以明确列出这些重命名:
{#enum Abc_VerbLevel {AbcPrompt, AbcError, AbcWarning, AbcStandard, AbcVerbose} #}
或者更改数据类型的名称:
{#enum Abc_VerbLevel as AbcVerbLevel {underscoreToCase} #}
还有另外两种变换(可以与 underscoreToCase
结合使用:upcaseFirstLetter
和 downcaseFirstLetter
,尽管我不确定后者何时会导致有效的 Haskell 代码。
Pointer. 与指定在 Foreign.C.Types
中的 C 原语不同,Haskell 需要告知如何将指针类型(foo*
)映射到 Haskell 类型。考虑某些结构体:
struct foobar {
int foo;
int bar;
}
完全有可能在 Haskell 代码库中存在 data Foobar = Foobar Int Int
,在这种情况下,我们希望 Ptr Foobar
表示原始 C 代码中的 struct foobar*
。c2hs 无法直接推导出这些信息,因此我们向其提供这些信息:
{#pointer *foobar as FoobarPtr -> Foobar #}
这生成了以下代码:
type FoobarPtr = Ptr Foobar
但更重要的是,允许 c2hs 在为 FFI 绑定编写的签名中放置更具体的类型(我们将在本系列的下一篇文章中看到)。
一些主题的变种:
-
如果你想表示一个不会进行马歇尔处理的不透明指针,你可以选择空数据声明:
data Foobar {#pointer *foobar as FoobarPtr -> Foobar #}
或者你可以让 c2hs 使用新类型技巧生成代码:
{#pointer *foobar as FoobarPtr newtype #}
我更喜欢空数据声明,因为在这种情况下不需要包装和解包新类型:新类型将生成:
newtype FoobarPtr = FoobarPtr (Ptr FoobarPtr)
如果代码期望
Ptr a
,则需要将其解包。 -
如果你不喜欢
FoobarPtr
这个名称,而只想显式地说Ptr Foobar
,你可以告诉 c2hs 不要发出类型定义,使用nocode
:{#pointer *foobar -> Foobar nocode #}
-
如果没有指定 Haskell 名称映射,它将简单地使用 C 名称:
-- if it was struct Foobar... {#pointer *Foobar #}
-
如果你想引用 C 中已经是指针的 typedef,只需省略星号:
typedef struct Foobar* FoobarPtr {#pointer FoobarPtr #}
-
c2hs 也支持有限的声明指针为 foreign 或 stable,并相应地生成代码。我没有在这方面使用过,除了一个情况,发现指针的生成绑定不够灵活。效果可能有所不同。
导入. 包含多个头文件的 C 库可能会有一些头文件包含其他头文件以获取重要的类型定义。如果你组织你的 Haskell 模块类似地,你需要模仿这些包含:这可以通过 import 来实现。
{#import Foobar.Internal.Common #}
特别是,这会设置来自其他模块的 pointer
映射,并生成通常的 import
语句。
上下文(可选). 上下文有两个所谓的目的。第一个是指定文件中 FFI 声明应链接的库;然而,在 Cabal 中,这实际上没有任何作用——所以你仍然需要将库添加到 Extra-libraries
。第二个是通过为你引用的每个 C 标识符添加隐式前缀来节省击键次数,假设原始的 C 代码被命名空间为 gtk_
或类似的。我个人喜欢不需要将我的导入限定到更低级别的 API,并喜欢 C 前缀的视觉区分,所以我倾向于省略这一点。一些指令允许你在局部改变前缀,特别是 enum
。
下次. 使用 get 和 set 进行马歇尔处理。
五种高级的 Git 合并技巧:ezyang 的博客
五种高级的 Git 合并技巧
你是否曾经在 Git 中执行过合并,但结果并不如你所希望的那样?例如,你意外地将所有 UNIX 换行符转换为 DOS 换行符,现在整个文件都报告有冲突?也许你看到一个你并不想解决的冲突,想要以他们的版本解决?或者,冲突的文件是空的,你无法弄清楚发生了什么?
这里有一些高级技巧,你可以应用到冲突的合并中,使事情变得更容易一些。其中许多技巧利用了 Git 的底层命令;也就是说,直接与 Git 抽象层(索引、树、提交图)交互的内部命令。其他技巧则简单到只需改变一个配置开关。
-
使用
git config --global merge.conflictstyle diff3
来转换diff3
冲突。diff3
冲突风格在新的|||||||
标记和=======
标记之间添加了一个额外的部分,该部分显示了原始内容,你的修改在上面,他们(被合并的分支)的修改在下面。diff3
是重新建立你几个月前做出的更改背景的强大方式(要查看你的更改,比较中间部分和上部分;要查看他们的更改,比较中间部分和下部分),默认情况下应该开启这个选项,真的没有理由不这样做。 -
如果你曾经使用过 Subversion,你可能熟悉
FILE.mine
、FILE.r2
(你最初使用的原始文件)和FILE.r3
(最新版本检入的文件),以及运行svn resolve --accept theirs-full
或mine-full
的能力,这些命令表示“我不关心其他的更改,只使用这个文件的版本”。Git 提供了类似的功能,利用合并的父提交,尽管它们可能更为隐蔽。你可能已经熟悉了
git show
命令,它允许你查看提交以及在任何给定提交的树中查看任意的 blob。当你处于合并状态时,你可以使用特殊的:N:
语法,其中N
是一个数字,来自动选择其中一个合并父提交。1
选择共同的基础提交(较低的版本),2
选择你的版本("mine"),3
选择他们的版本(较高的版本)。因此,git show :3:foobar.txt
会显示foobar.txt
的上游版本。要实际使用其中一种版本作为合并的解决方案,请使用
git checkout {--ours|--theirs} filename.txt
。 -
当你处于冲突状态时,
git diff
会提供所有发生冲突的详细信息,有时这些信息太多了。在这种情况下,你可以运行git ls-files -u
查看所有未合并的文件(这比git status
快得多,并且会省略所有已正确合并的文件)。你可能会注意到列表中存在多达三份文件的副本;这告诉你之前提到的“公共”,“我们的”和“他们的”副本的状态。如果 1(公共)丢失,这意味着该文件同时出现在我们的分支和他们的分支中。如果 2(我们的)丢失,这意味着我们删除了该文件,但它在上游有了变更。如果 3(他们的)丢失,这意味着我们做了一些更改,但上游删除了该文件。如果一个文件有冲突,但你无法弄清原因(因为没有冲突标记),这尤其有用。
-
有时生活会给你柠檬。许多人建议你制作柠檬汁。然而,如果 Git 给了你一个非常糟糕的冲突标记集,例如,你不小心颠倒了一个文件的换行样式,现在整个文件都发生了冲突,那就不要妥协:重新为该文件进行合并。你可以使用方便的
git merge-file
命令来做到这一点。这将运行一个三方文件合并,并接受三个参数:当前文件,公共文件和上游文件,并将合并写入当前文件(第一个参数)。使用git show
来转储你的文件,公共文件和上游文件,对这些文件进行必要的更改(例如运行dos2unix
),运行git merge-file mine common theirs
,然后将mine
复制到旧的有冲突的文件上。哇,即时得到新的冲突标记集。如果你在合并过程中较早发现了全局冲突,并且是你的错,回退合并可能更容易
git reset --hard
,修复错误,然后再尝试合并。然而,如果你已经在合并一个副本时取得了重大进展,重新合并一个单独的文件可能会拯救你的一命。 -
不要合并,应该变基!而不是运行
git pull
,运行git pull --rebase
。而不是运行git merge master
,运行git rebase master
。结果将会使你的历史记录更清晰,如果你想向上游提交补丁,你将不需要进行大规模的变基马拉松。
现在,继续前进,尽情合并吧!
五个保持可维护 Shell 脚本的技巧 : ezyang’s 博客
来源:
blog.ezyang.com/2010/03/five-tips-for-maintainable-shell-scripts/
五个保持可维护 Shell 脚本的技巧
当我十七岁时,我写了我的第一个 Shell 脚本。那是一个 Windows 批处理文件,小心地从网络上各种代码示例中摘录的片段。我已经体验过与 pear.bat
交互的 绝妙 乐趣,脚本编写并不是我喜欢的事情;“为什么不用一个真正的编程语言来写这个该死的东西!”(额外美味的是,“真正的编程语言”是 PHP。嘻。)
最终我转向了完全的 Unix 环境,随之开始广泛使用 bash。突然间,Shell 脚本变得更加有意义:你一直在日复一日地输入命令,不如把它们写成脚本!然而,Shell 脚本有个讨厌的小问题:它们是永远的;不管你喜不喜欢,它们已经成为维护代码的一部分。整个构建基础设施都建立在 Shell 脚本之上。它们像兔子一样繁殖;你必须小心这些小家伙。
这里有五个提示和技巧,当你将命令写入一个 Shell 脚本时,请记住,这些将使得长期维护变得更加愉快!
-
学会并喜欢使用
set
。几乎没有理由不使用-e
标志,这会导致如果任何命令返回非零退出码,你的脚本会报错,并且-x
可以通过在执行命令前打印出脚本正在执行的确切命令,帮你节省数小时的调试时间。启用这两个选项后,你在 Shell 脚本中得到了非常简单的“断言”:check_some_condition ! [ -s "$1" ]
尽管如此,如果可能的话,你应该编写错误消息来陪伴它们。
-
就因为你在终端时不定义子过程(或者你有吗?看看
alias
和朋友们)并使用C-r
进行反向命令历史搜索,这并不意味着在你的 Shell 脚本中重复命令是可以接受的。特别是,如果你有一组可能单独放入脚本中的命令,但又觉得单独建立文件有点奇怪,可以像这样将它们放在子过程中:subcommand() { do_something_with "$1" "$2" }
特别是,参数传递的行为与真实的 Shell 脚本完全一样,通常你可以把子命令当作它自己的脚本来处理;标准输入和输出的工作方式也符合你的预期。唯一的区别是
exit
会退出整个脚本,所以如果你想中断一个命令,应该使用return
代替。 -
Shell 脚本中的参数引用是一个奇怪而深奥的领域(虽然它不必如此;查看沃尔德曼关于 shell 引用的笔记)。简而言之,总是要用引号包裹将被插值的变量,除非你确实想要多个参数的语义。关于是否应该引用字面值,我的感觉参差不齐,最近我已经养成了不引用它们的恶习。
-
信不信由你,shell 脚本具有函数式编程的倾向。例如,
xargs
就是典型的“map”功能。然而,如果你将参数传递给的命令不接受多个参数,你可以使用这个技巧:pgrep bash | while read name; do echo "PID: $name" done
-
Shell 脚本在命令式编程时感觉非常自然,并且在控制流程时大多保持这种方式。然而,对于任何数据处理来说,它绝对是一个糟糕的语言(例如:sed 和 perl 管道),你应该避免在其中进行过多的数据处理。在更合理的语言中创建实用脚本可以有效地使你的 shell 脚本更加优雅。
在 coBurger King 中翻转箭头:ezyang 的博客
来源:
blog.ezyang.com/2010/07/flipping-arrows-in-coburger-king/
为工作中的 Haskell 程序员提供的范畴论速成课程。
在讨论对偶数据结构(最常见的是 co-monad)时经常出现的一个问题是:“co- 是什么意思?”范畴论的口气答案是:“因为你翻转了箭头。”这令人困惑,因为如果你看一看 monad 和 co-monad 类型类的一个变体:
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
class Comonad w where
(=>>) :: w a -> (w a -> b) -> w b
extract :: w a -> a
这里有很多“箭头”,只有少数箭头被翻转(具体来说,是>>=
和=>>
函数的第二个参数内的箭头,以及 return/extract 中的箭头)。本文将准确解释“翻转箭头”的含义和使用“对偶范畴”,即使你对范畴论一窍不通也不例外。
符号. 本文中将会有几个图表。你可以把任何节点(又名对象)看作是 Haskell 类型,把任何实线箭头(又名态射)看作是连接这两种类型的 Haskell 函数。(不同的概念将用不同的颜色箭头来区分。)所以如果我有f :: Int -> Bool
,我会这样画出来:
Functors. Functor 类型类对于工作中的 Haskell 程序员来说并不陌生:
class Functor t where
fmap :: (a -> b) -> (t a -> t b)
虽然类型类似乎暗示了 Functor 实例的只有一个部分,即fmap
的实现,但还有另一个几乎微不足道的部分:t
现在是一个 kind 为* -> *
的类型函数:它接受一个类型(a
)并输出一个新的类型(无聊地命名为t a
)。因此,我们可以用这个图表示它:
箭头以不同的颜色标注是有充分理由的:它们指示完全不同的东西(并且碰巧出现在同一个图表中)。红色箭头表示一个具体的函数a -> b
(fmap
的第一个参数),而虚线蓝色箭头并不是声称存在一个函数a -> t a
:它只是指示 functor 如何从一个类型映射到另一个类型。它可能是一个没有合法值的类型!我们也可以假设该类型的一个函数的存在;在这种情况下,我们将有一个 pointed functor:
class Functor f => Pointed f where
pure :: a -> f a -- aka return
但是对于我们的目的来说,这样一个函数(或者说是吗?)在我们达到 monads 之前并不是很有趣。
你可能听说过 Functor 定律,这是所有 Functor 都应满足的一个等式。在这里,它以文本形式出现:
fmap (g . f) == fmap g . fmap f
并且以下是以图形方式表示:
你可以将这个图想象成一个巨大的if..then
语句:如果存在f
、g
和g . f
,那么fmap f
、fmap g
和fmap (g . f)
也存在(只需对它们应用fmap
!),并且它们恰好以相同的方式组合。
事实上,如果我们有f :: a -> b
和g :: b -> c
,则g . f
也必然存在,因此我们实际上不需要绘制箭头。这是函数组合的一个如此隐含的概念,所以我们会花一点时间问一下:为什么会这样?
原来当我画红色箭头的图表时,我在画数学家称为带有对象和箭头的范畴。最近几个图表都是在所谓的范畴 Hask 中绘制的,该范畴的对象是 Haskell 类型,箭头是 Haskell 函数。范畴的定义内置了箭头的组合和身份:
class Category (~>) where
(.) :: (b ~> c) -> (a ~> b) -> (a ~> c)
id :: a ~> a
(你可以在头脑中将~>
与->
替换为 Hask),并且有使箭头组合成为可结合的箭头的法则。最相关的是,当你谈论对偶范畴时,范畴箭头恰好是你翻转的箭头。
“太棒了!”你说,“这意味着我们完成了吗?”不幸的是,还没有。虽然余单子是对偶(或双重)范畴的单子,但它并不是范畴Hask.
(这不是你要找的范畴!)尽管如此,我们花了这么多时间在Hask
中舒适地绘制图表,如果不好好利用一下就太可惜了。因此,我们将看到 Hask 的对偶范畴的一个例子。
逆变函子。 你可能听说过fmap
被描述为将函数“提升”到函子上下文的函数:这个“函子上下文”实际上只是另一个范畴。(要真正数学地证明这一点,我们需要证明函子定律足以保留范畴定律。)对于普通函子来说,这个范畴就是 Hask(实际上是它的子范畴,因为只有类型t _
符合对象的条件)。对于逆变函子来说,这个范畴是 Hask^op。
在 Hask 中的任何函数f :: a -> b
都会成为逆变函子中的函数contramap f :: f b -> f a
:
class ContraFunctor t where
contramap :: (a -> b) -> t b -> t a
这里是对应的图表:
请注意,我们将图表分成了两部分:一部分在 Hask 中,另一部分在 Hask^op 中,注意从一个范畴到另一个范畴的函数箭头(红色)翻转,而函子箭头(蓝色)则没有翻转。t a
仍然是一个逆变函子值。
你可能会想,头疼不已地想知道:我们是否可以使用contramap
的任何实例?事实上,有一个非常简单的例子直接来自我们的图表:
newtype ContraF a b = ContraF (b -> a)
instance ContraFunctor (ContraF a) where
contramap g (ContraF f) = ContraF (f . g)
对于本文其余部分来说,理解这个实例并不太重要,但感兴趣的读者应该将其与普通函数的函子进行比较。除了新类型的包装和解包之外,只有一个变化。
自然变换。 我要提前给出结论:在余单子的情况下,你要找的箭头是自然变换。什么是自然变换?什么样的范畴以自然变换为箭头?在 Haskell 中,自然变换大致上是多态函数:它们是在函子上定义的映射。我们将用灰色表示它们,并且引入一些新的符号,因为我们将处理多个函子:下标表示类型:fmap_t
是fmap :: (a -> b) -> t a -> t b)
,而η_a
是η :: t a -> s a
。
让我们回顾一下围绕的三种箭头类型。红色箭头是函数,它们是 Hask 范畴中的态射。蓝色箭头指示了类型之间的函子映射;它们还作用于函数以生成更多函数(同样在 Hask 范畴中:这使它们成为自函子)。灰色箭头同样是函数,因此它们也可以被视为 Hask 范畴中的态射,但是在从一个函子到另一个函子的所有类型(对象)之间,灰色箭头的集合共同形成了自然变换(图表中描绘了自然变换的两个分量)。单个蓝色箭头不是函子;单个灰色箭头不是自然变换。相反,适当类型的集合才是函子和自然变换。
因为f
似乎在图表中杂乱无章,我们可以轻松地省略它:
Monad. 这是类型类,为了提醒你:
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
你可能听说过一种定义Monad类型类的另一种方法:
class Functor m => Monad m where
join :: m (m a) -> m a
return :: a -> m a
其中:
m >>= f = join (fmap f m)
join m = m >>= id
join
更深入地扎根于范畴论(事实上,它定义了使 monad 成为 monoid 的臭名昭著的二元运算的自然变换),你应该确信join
或>>=
都能胜任。
假设我们对我们正在处理的 monad 一无所知,只知道它是一个 monad。我们可能会看到什么类型?
趣味的是,我这里将箭头标成了自然变换,而不是我们在 Hask 中为不显著函数所做的红色标记。但是,函子在哪里?m a
很简单:任何 Monad 也都是函子的有效实例。a
看起来像一个普通值,但也可以视为Identity a
,即a
在恒等函子中的形式:
newtype Identity a = Identity a
instance Functor Identity where
fmap f (Identity x) = Identity (f x)
而 Monad m => m (m a)
只是一个函子两层深:
fmap2 f m = fmap (fmap f) m
或者,以无参数风格:
fmap2 = fmap . fmap
(每个 fmap 将函数嵌入到更深的函子中。)我们可以精确地表示这些函子与类似以下内容组合的事实(抄袭自 sigfpe):
type (f :<*> g) x = f (g x)
在这种情况下 m :<*> m
是一个函子。
尽管这些图表直接源自 monad 的定义,但也有重要的 monad 定律,我们也可以为其绘制图表。我将只画带有 f
的 monad 恒等律:
return_a
表示return :: a -> m a
,而join_a
表示join :: m (m a) -> m a
。这里是其余的部分,去除了f
:
你可以将浅蓝色文字解释为“新鲜”—它是自然变换创建(或压缩)的新“层”。第一个图表示恒等律(传统上为return x >>= f == f x
和f >>= return == f
);第二个表示结合律(传统上为(m >>= f) >>= g == m >>= (\x -> f x >>= g)
)。这些图表等同于以下代码:
join . return == id == join . fmap return
join . join == join . fmap join
余单子。 单子属于自函子 Hask -> Hask
的范畴。自函子的范畴以自函子为对象,并(毫不奇怪地)以自然变换为箭头。因此,当我们制作余单子时,我们翻转自然变换。有两种:join 和 return。
这是类型类:
class Functor w => Comonad w where
cojoin :: w a -> w (w a)
coreturn :: w a -> a
它们分别已重命名为duplicate
和extract
。
我们还可以翻转自然变换箭头来得到我们的余单子法则:
extract . duplicate == id == duplicate . extract
duplicate . duplicate == fmap duplicate . duplicate
下一次。 尽管从联结和核返推导出<<=
是完全合理的,但一些读者可能会感到被愚弄,因为我实际上从未讨论过 Haskell 程序员经常处理的单子功能:我只是改变了定义,直到哪些箭头翻转为明显为止。因此,希望在未来某个时候,我能为 Kleisli 箭头绘制一些图表,并展示其含义:特别是为什么>=>
和<=<
被称为 Kleisli 组合。
致歉。 早晨三点,我竟然遗漏了所有正式定义和证明!对此我是个非常糟糕的数学家。希望在阅读完这篇文章后,你能去查阅每个主题的维基百科文章,并理解它们的描述!
附言。 你可能会对这篇关于在更简单环境中的对偶性的后续文章感兴趣。
食品相关的函数幽默:ezyang 的博客
食品相关的函数幽默
秋天即将来临,而随之而来的是一大群饥饿的新生们蜂拥而至麻省理工学院校园。我将举办三场食品活动... 全部都是函数式编程的双关语。嗨!
饺子复合主义
合成:结构的构建。消费:结构的消耗。复合:既是合成又是消费。这个活动?是饺子的复合。来学习基本的折叠,或者只是对食物进行新陈代谢还原。
我过去做过这个活动好几次,总是有趣的(尽管有点汗流浃背)。包饺子,事实证明,是一项非常可并行化的任务:你可以让多个人擀皮、包饺子,还有一些勇敢的厨师真正地煮或者煎它们。(其实,在中国,没有人再自己擀皮了,因为市售的饺子皮太好了。但在美国不是这样……)
炒菜锅组合器
计算机科学家熟悉这种组合方式,但食品科学家可能不太了解。在中国,炒菜锅组合器是一个严密保密的秘密,保证在最短时间内将蔬菜和肉类组合在一起。(也适合素食者。)
饺子在学期紧张的麻省理工学院学生来说有些不太实际;它们通常会被保留到学期初的特殊活动中,如果有的话。然而,炒菜是快捷、便宜、简单的选择,是任何大学生健康饮食的重要组成部分。我个人最喜欢的是西兰花和鸡肉(几乎不可能出错),但最近我也开始喜欢甜椒和西班牙香肠了(见下文)。这个活动运行起来的一个困难是确保有足够的电饭煲……米饭不够了可不好,因为重新煮一锅米饭需要很长时间!
多重烘烤主义
Roast(X) 其中 X = {西兰花,大蒜,猪肉,甜椒,西班牙香肠,胡萝卜,洋葱,芦笋,甜土豆}。
这是一个新的尝试。实际上,我只是想做烤西兰花和大蒜。真的,太好吃了。我以前从未烤过西班牙香肠,但菜单上的肉类似乎不够,所以我就加进去了。在波特兰时,我经常在农贸市场购买各种随机的蔬菜,回家后又得想办法如何烹饪它们。在许多情况下,烤制是一个相当不错的选择!我忘了在活动描述中加上甜菜根;也许我会去买一些...
从数据类型定义到代码:ezyang 的博客
这些问题有什么共同点:递归的相等性/排序检查,打印字符串表示,序列化/反序列化二进制协议,哈希,生成获取器/设置器?它们是具有强烈依赖于它们操作的数据结构的重复样板代码。由于程序员喜欢自动化事务,因此出现了各种关于如何做到这一点的思想流派:
-
让你的 IDE 为你生成这些样板代码。你右键单击上下文菜单,点击“生成
hashCode()
”,你的 IDE 就会为你进行必要的程序分析; -
创建一个自定义的元数据格式(通常是 XML),然后运行另一个程序,将这个描述转换为代码;
-
在你的语言中添加足够强大的宏/高阶功能,这样你就可以编写生成程序内实现的程序;
-
在你的语言中添加足够强大的反射功能,这样你就可以为这个功能编写一个完全通用的动态实现;
-
做一个编译器,并在抽象语法树上进行静态分析,以找出如何实现相关操作。
直到我遇到 camlp4 系统广泛使用的一个特定方面时,我才意识到第五个选项有多么普遍。虽然它自称为“宏系统”,但在 sexplib 和 bin-prot 中使用的宏并不是 C 传统的宏(这对于实现 3 是有好处的),而是 Lisp 传统的宏,包括访问 OCaml 的完整语法树和修改 OCaml 的语法的能力。然而,与大多数 Lisp 不同,camlp4 可以访问 数据类型定义 的抽象语法树(在非类型化语言中,这些通常是隐式的),它可以用来转换成代码。
我感兴趣的一个问题是,这种元编程是否能在语言的休闲用户中流行起来。如果我编写代码将数据结构转换为类似 Lisp 的版本,那么将这段代码概括为元编程代码,是否是一个逻辑上的下一步,还是一个仅由极限用户完成的非常大的飞跃?至少从用户的角度来看,camlp4 非常不显眼。事实上,一个月后我甚至没有意识到我在使用它!例如,使用 sexplib 就是一个简单的事情,只需写:
type t = bar | baz of int * int
with sexp
几乎像魔法一样,sexp_of_t
和 sexp_to_t
就会出现。
但是定义新的转换显然更加复杂。问题的一部分在于你操作的抽象语法树非常复杂,这是使语言编程友好的不可避免的副作用。我可以理论上使用求和和乘积定义我关心的所有类型,但是真实的 OCaml 程序使用带标签的构造函数、记录、匿名类型、匿名变体、可变字段等。因此,我必须为所有这些情况编写案例,如果我不是一个语言专家的话,这就很困难了。
解决这个问题的一个可能的方法是定义一个更简单的核心语言进行操作,这与 GHC Haskell 在代码生成之前编译到核心语言的方式类似。然后,您可以通过注解系统(即使您可以访问完整的 AST 时也是如此)提供额外的信息。如果这个想法基本上很简单,就不要强迫最终用户处理所有与创建良好的编程语言相关的附带复杂性。当然,除非他们愿意。
后记. 我绝对不擅长文献检索。与大多数想法一样,可以安全地假设其他人已经做过了。但我在这里找不到任何先前的研究成果。也许我需要一个比“用于元编程的中间语言”更好的搜索查询。
功能加密:ezyang's 博客
功能加密
最近,Joe Zimmerman 向我分享了一种关于各种加密方案的新思路,称为功能加密。更深入地阐述了这一概念的是丹·博内等人在一篇非常易于理解的最新论文中。下面是摘录的摘要第一段:
我们通过给出概念及其安全性的精确定义,开始了对功能加密的正式研究。粗略地说,功能加密支持受限制的密钥,使密钥持有者能够学习加密数据的特定函数,但不会了解数据的其他信息。例如,给定一个加密程序,密钥可能使持有者能够学习在特定输入上程序的输出,而不会了解程序的其他任何信息。
值得注意的是,功能加密泛化了许多现有的加密方案,包括公钥加密、基于身份的加密和同态加密。不幸的是,在某些安全模型中,功能加密总体上存在一些不可能的结果(链接的论文对仿真模型有一个不可能的结果)。功能加密还没有维基百科页面;也许你可以写一个!
说来也奇怪, 我的一位数学博士朋友最近问我:“你认为 RSA 有效吗?” 我说:“不,但也许目前没有人知道如何破解它。” 然后我问他为什么这么问,他提到他正在上密码学课程,考虑到所有的假设,他很惊讶其中任何一个都能工作。我回答说:“是的,听起来大概是这样。”
函数产生 Haskell 堆:ezyang 的博客
来源:
blog.ezyang.com/2011/04/functions-produce-the-haskell-heap/
我们已经讨论过如何在 Haskell 堆中打开(评估)礼物(thunk):我们使用 IO。但是所有这些礼物都是从哪里来的呢?今天我们介绍的是所有这些礼物来自哪里,那就是 Ghost-o-matic 机器(一个 Haskell 程序中的函数)。
使用一个函数涉及三个步骤。
我们可以把这台机器看作是一个黑匣子,它接受礼物标签并产出礼物,但你可以想象其内部有无限多的相同幽灵和空的礼品盒:当你运行这台机器时,它会把一个幽灵的副本放入盒子中。
如果我们放入礼物中的幽灵是相同的,它们是否会表现得一样?是的,但有一个注意事项:幽灵的行为由脚本(原始源代码)决定,但在脚本内部有空洞,这些空洞由您插入到机器中的标签填充。
由于盒子里实际上什么也没有,我们可以通过困扰它的幽灵精确地描述一个礼物。
使用 Ghost-o-matic 的人经常遇到的问题是他们期望它像 Strict-o-matic(传统严格求值语言中的函数)一样工作。它们甚至不接受相同的输入:Strict-o-matic 接受未包装的、未幽灵化(未解除提升)的对象和礼品卡,并输出其他未幽灵化的礼物和礼品卡。
但是很容易忘记,因为严格函数应用和惰性函数应用的源代码语法非常相似。
这是一个必须非常强调的重点。事实上,为了强调这一点,我画了另外两幅图来重申 Ghost-o-matic 机器允许的输入和输出是什么。
Ghost-o-matic 机器只接受礼物的标签,而不是实际的礼物本身。这意味着 Ghost-o-matic 并不会打开任何礼物:毕竟,它只有标签,而没有实际的礼物。这与 Strict-o-matic 机器形成对比,后者接受实际礼物作为输入并打开它们:有人可能称这种机器为force
函数,类型为Thunk a -> a
。在 Haskell 中,并没有这样的东西。
Ghost-o-matic 总是会创建一个包装好的礼物。即使没有幽灵在礼物中(函数是常量),它也永远不会产生未包装的礼物。
我们先前说过在 Haskell 中没有force
函数。但是函数seq
似乎做了与强制求值 thunk 类似的事情。一个被seq
幽灵所困扰的礼物,在被打开时会导致另外两个礼物被打开(即使第一个是不必要的)。看起来第一个参数被强制执行;因此seq x x
可能是对命令式语言中force
的一个合理近似。但当我们打开一个被seq
幽灵所困扰的礼物时会发生什么呢?
虽然鬼魂最终会打开礼物而不是我们,但对于它来说已经为时已晚:在鬼魂打开礼物之后立即,我们将要打开它(它已经是)。关键观察是seq x x
鬼魂只在打开seq x x
礼物时打开x
礼物,并且在seq x x
打开后,我们必须通过间接方式去打开x
。seq 鬼魂的严格性被放入一个礼物中,直到需要x
时才打开,这一事实所击败。
一个有趣的观察是 Strict-o-matic 机器在运行时做一些事情。它可以打开礼物,发射导弹或执行其他副作用。
但是 Ghost-o-matic 机器完全是纯的。
为了避免混淆,Strict-o-matic 和 Ghost-o-matic 机器的用户可能会发现比较每台机器的礼物创建生命周期有用。
惰性 Ghost-o-matic 机器分为两个离散阶段:函数应用,实际上什么也不做,只是创建礼物,并且实际打开礼物。Strict-o-matic 在一个瞬间完成所有操作——尽管它可以输出一个礼物(这就是在严格语言中实现惰性时发生的事情)。但在严格语言中,你必须自己做所有事情。
Ghost-o-matic 被人类和鬼魂批准使用。
这确实意味着打开一个鬼魂礼物可能会产生更多的礼物。例如,如果礼物是给那些还没有在堆上存在的礼物的礼物卡。
对于一个脊柱严格的数据结构,它可以产生很多礼物。
哦,还有一件事:Ghost-o-matic 机器是给鬼魂和家人的绝佳礼物。它们也可以用礼物包装起来。毕竟,在 Haskell 中的一切都是礼物。
技术注释。通过优化,函数可能不一定在堆上分配。确保的唯一方法是查看程序生成的优化核心。事实上,传统严格的函数在 Haskell 中并不不存在:非装箱原语可以用来编写传统的命令式代码。这看起来可能有点吓人,但实际上和在 ML 中编写程序没什么不同。
我完全忽略了部分应用,这应该是以后帖子的主题,但我会注意到,从内部来看,GHC 确实尽其所能在应用时传递函数想要的所有参数;如果所有参数都可用,它将不会麻烦地创建部分应用(PAP)。但这些可以被认为是修改过的 Ghost-o-matics,其鬼魂已经具有一些(但不是全部)参数。天赋的 Ghost-o-matics(堆中的函数)也可以这样看待:但不是预先给鬼魂一些参数,而是给鬼魂其自由变量(闭包)。
下一篇文章:Grinch 是如何窃取 Haskell 堆的
本作品采用知识共享署名-相同方式共享 3.0 未本地化许可协议授权。
规范中的泛化和模糊性:ezyang’s 博客
来源:
blog.ezyang.com/2010/12/generalization-and-vagueness-in-specifications/
语义对规范的看法
普遍认为,过早泛化是不好的(架构宇航员),模糊的规范适合自上而下的工程,但不适合自下而上。我们能对此说得更具体一些吗?
语义是编程语言的形式规范。它们可能是最被深入研究的规范形式之一,因为计算机科学家喜欢调整他们使用的工具。他们也喜欢有很多语义可供选择:越多越好。我们有小步和大步操作语义;我们有公理语义和指称语义;我们有游戏语义、代数语义和并发语义。描述我们实际编写的程序是一项困难的工作,拥有尽可能多的不同解释是有帮助的。
根据我的经验,软件很少有多个规范,每个规范都被同等对待。重复使得在更多信息可用和需求变化时,难以演变规范(好像本来就不够难!)两个权威来源可能相互冲突。规范的一个版本可能要求系统的某一部分实施得非常精确,而另一个则保持开放(直到某种外部行为)。更常见的可能是单一的、权威的规范,然后是一系列信息参考,你在日常工作中可能真正会参考的。
当然,这种情况在编程语言语义世界中经常发生。关于冲突和不同的具体性问题,这里有两个例子来自指称语义(斯科特语义)和游戏语义。
太泛了吗? 这里,规范允许一些额外的行为(并行或在 PCF 中),这是无法以显而易见的方式实现的。这个问题困扰研究人员一段时间:如果规范太松散,你是添加规范建议的特性(PCF+por),还是尝试修改语义,使得这种额外行为被排除(逻辑关系)?泛化可能有好处,但通常以增加实现复杂性为代价。然而,在并行或的情况下,这种实现复杂性是一个线程化运行时系统,出于无关的原因也是有用的。
太模糊了吗? 在这里,规范未能捕捉到行为上的差异(seq 和 pseq 在语义上等同(Scott)),而这恰好在操作上是重要的(控制评估顺序)。游戏语义巧妙地解决了这个问题:我们可以区分x `pseq` y
和y `pseq` x
,因为在相应的对话中,前者的表达式首先询问 x 的值,后者首先询问 y 的值。然而,模糊的规范为编译器的优化提供了更多的自由度。
像“适合工作的正确语言”这样的口头禅一样,我怀疑在“适合工作的正确规范风格”方面也有类似的真理。但更甚的是,我主张从不同的视角审视同一领域会加深你对领域本身的理解。在使用语义学时,我们包含某些细节并排除其他细节:作为程序员,我们时常这样做——这对于处理任何复杂系统至关重要。在构建语义时,我们语义之间的差异提供了关于抽象边界和我们原始目标潜在不一致性的重要线索。
有一点需要注意,许多不同的计算思维范式存在一个明显的缺点:你必须学会它们全部!公理语义回忆起你可能记得的高中数学中的符号操作:机械而不是非常有趣。指称语义要求先解释一下,然后才能得到正确的直觉。游戏语义作为“对话”似乎相当直观(对我来说),但是有一些重要的细节最好通过某种形式来解决。当然,我们总是可以回到操作性的讨论,但这种方法在大型系统中不具可扩展性(“阅读源代码”)。
通用化 API:ezyang 博客
编辑. ddarius 指出,类型族的例子是反过来的,所以我把它们调整成了与函数依赖相同的方式。
类型函数可用于执行各种精妙的类型级计算,但也许最基本的用途是允许构建通用 API,而不仅仅依赖于模块导出的“大部分相同的函数”。你需要多少类型技巧取决于 API 的属性,也许最重要的是你的数据类型的属性。
假设我有一个单一数据类型上的单一函数:
defaultInt :: Int
而我想要通用化它。我可以通过创建一个类型类来轻松实现:
class Default a where
def :: a
对单个类型的抽象通常只需要普通的类型类。
假设我有一个在多个数据类型上的函数:
data IntSet
insert :: IntSet -> Int -> IntSet
lookup :: IntSet -> Int -> Bool
我们希望对IntSet
和Int
进行抽象化。由于我们所有的函数都提到了这两种类型,我们所需做的就是编写一个多参数类型类:
class Set c e where
insert :: c -> e -> c
lookup :: c -> e -> Bool
instance Set IntSet Int where ...
如果我们运气不好,一些函数可能不会使用所有的数据类型:
empty :: IntSet
在这种情况下,当我们尝试使用该函数时,GHC 会告诉我们它无法确定使用哪个实例:
No instance for (Set IntMap e)
arising from a use of `empty'
其中一件事要做的就是引入 IntSet
和 Int
之间的功能依赖。依赖意味着某些东西依赖于另一些东西,那么哪种类型依赖于什么?在这里我们没有太多选择:因为我们想要支持函数 empty
,其签名中并没有任何地方提到 Int
,因此依赖将从 IntSet
到 Int
,也就是说,给定一个集合(IntSet
),我可以告诉你它包含的是什么(一个 Int
)。
class Set c e | c -> e where
empty :: c
insert :: c -> e -> c
lookup :: c -> e -> Bool
注意,这仍然基本上是一个多参数类型类,我们只是给 GHC 一个小提示,告诉它如何选择正确的实例。如果需要,我们也可以引入反方向的功能依赖。出于教育目的,让我们假设我们的老板真的想要一个“null”元素,它总是集合的成员,并且在插入时不做任何事情:
class Set c e | c -> e, e -> c where
empty :: c
null :: e
insert :: c -> e -> c
lookup :: c -> e -> Bool
还要注意,每当我们添加功能依赖时,我们就排除了提供另一个实例的可能性。在最后一个类型类对于 Set
是非法的:
instance Set IntSet Int where ...
instance Set IntSet Int32 where ...
instance Set BetterIntSet Int where ...
这将报告“功能依赖冲突。”
功能依赖有时会因为与其他某些类型特性的交互而受到诟病。GHC 最近添加的等效功能是关联类型(也称为类型族或数据族)。
而不是告诉 GHC 如何自动从另一个类型中推断(通过依赖),我们创建一个显式的类型族(也称为类型函数),它提供了映射:
class Set c where
data Elem c :: *
empty :: c
null :: Elem c
insert :: c -> Elem c -> c
lookup :: c -> Elem c -> Bool
注意我们的类型类不再是多参数的:它有点像如果我们从 c -> e
引入了一个函数依赖。但是,它如何知道 null
的类型应该是什么?简单:它让你告诉它:
instance Set IntSet where
data Elem IntSet = IntContainer Int
empty = emptyIntSet
null = IntContainer 0
注意 data
的右侧不是一个类型:它是一个数据构造函数,然后是一个类型。数据构造函数将告诉 GHC 使用哪个 Elem
的实例。
在本文的原始版本中,我定义了相反方向的类型类:
class Key e where
data Set e :: *
empty :: Set e
null :: e
insert :: Set e -> e -> Set e
lookup :: Set e -> e -> Bool
我们的类型函数朝着另一个方向发展,我们可以根据正在使用的类型变体实现容器,这可能不是我们拥有的类型。这是数据族的一个主要用例,但与通用化 API 的问题不直接相关,所以我们暂时不考虑它。
IntContainer
看起来很像一个 newtype,并且实际上可以成为一个:
instance Set IntSet where
newtype Elem IntSet = IntContainer Int
如果你觉得包装和解包 newtype 很烦人,在某些情况下,你可以只使用类型同义词:
class Set c where
type Elem c :: *
instance Set IntSet where
type Elem IntSet = Int
然而,这样做会排除一些你可能想写的功能,例如自动专门化你的通用函数:
x :: Int
x = null
GHC 会报错:
Couldn't match expected type `Elem e'
against inferred type `[Int]'
NB: `Container' is a type function, and may not be injective
既然我也可以写成:
instance Set BetterIntSet where
type Elem BetterIntSet = Int
GHC 不知道要使用 null
的哪个 Set
实例:IntSet
还是 BetterIntSet
?你需要通过另一种方式将此信息传递给编译器,如果这完全在幕后进行,你就有点倒霉了。这与函数依赖有着明显的不同,如果你有一个非单射关系,它们会产生冲突。
另一种方法,如果你有幸定义你的数据类型,是在实例内部定义数据类型:
instance Set RecordMap where
data Elem RecordMap = Record { field1 :: Int, field2 :: Bool }
然而,请注意,新 Record
的类型不是 Record
;它是 Elem RecordMap
。你可能会发现类型同义词有用:
type Record = Elem RecordMap
与 newtype 方法相比,没有太大区别,只是避免了添加额外的包装和解包层。
在许多情况下,我们希望规定我们 API 中的数据类型具有某些类型类:
instance Ord Int where ...
强制执行这一点的一种低技术方式是将其添加到我们所有函数的类型签名中:
class Set c where
data Elem c :: *
empty :: c
null :: Ord (Elem c) => Elem c
insert :: Ord (Elem c) => c -> Elem c -> c
lookup :: Ord (Elem c) => c -> Elem c -> Bool
但更好的方法是只需在 Set
上添加一个类约束,使用灵活的上下文:
class Ord (Elem c) => Set c where
data Elem c :: *
empty :: c
null :: Elem c
insert :: c -> Elem c -> c
lookup :: c -> Elem c -> Bool
我们可以使函数和数据类型通用化。我们还可以使类型类通用化吗?
class ToBloomFilter a where
toBloomFilter :: a -> BloomFilter
假设我们决定允许多个 BloomFilter
的实现,但仍然希望为转换成任何你想要的布隆过滤器提供统一的 API。
不是直接,但我们可以伪造它:只需创建一个捕捉所有通用类型类,并将其参数化为真实类型类的参数:
class BloomFilter c where
data Elem c :: *
class BloomFilter c => ToBloomFilter c a where
toBloomFilter :: a -> c
稍微退后一步,比较函数依赖和类型族产生的类型签名:
insertFunDeps :: Set c e => c -> e -> c
insertTypeFamilies :: Set c => c -> Elem c -> c
emptyFunDeps :: Set c e => c
emptyTypeFamilies :: Set c => c
因此,类型族(type families)将实现细节隐藏在类型签名之后(你只使用你需要的关联类型,与Set c e => c
相反,其中e
是必需的但没有用于任何操作—如果你有 20 个关联数据类型,这更加明显)。然而,当你需要为你的关联数据引入新类型包装器(Elem
)时,它们可能会显得有些啰嗦。功能依赖(functional dependencies)非常适合自动推断其他类型,而无需重复自己。
(感谢 Edward Kmett 指出这一点。)
从这里开始要做什么呢?我们只是初步了解了类型级编程的表面,但是为了通用化 API,这基本上就是你需要知道的全部!找到你写过的在多个模块中重复的 API,每个模块提供不同的实现。找出哪些函数和数据类型是基本的。如果你有很多数据类型,就应用这里描述的技巧来确定你需要多少类型机制。然后,让你的 API 变得通用起来吧!
泛化可编程分号:ezyang 的博客
来源:
blog.ezyang.com/2012/10/generalizing-the-programmable-semicolon/
购买者注意:前方有半成品研究思路。
什么是单子(monad)?一个答案是,它是在非严格语言中排序操作的一种方式,一种表达“这应该在那之前执行”的方式。但另一个答案是,它是可编程分号,一种在进行计算时实现自定义副作用的方式。这些包括基本的效果,如状态、控制流和非确定性,以及更奇特的效果,比如labeled IO。即使你不需要单子来排序,这样的功能也是有用的!
让我们来个大逆转:对于按需调用语言来说,可编程分号会是什么样子呢?也就是说,我们能否在不对计算进行排序的情况下实现这种可扩展性呢?
乍一看,答案是否定的。大多数按值调用语言无法抵制副作用的诱惑,但在按需调用中,副作用是足够痛苦的,以至于 Haskell 设法避免了它们(在大多数情况下!)任何使用过带有 NOINLINE
修饰的 unsafePerformIO
的人都可以证明这一点:依赖于优化,效果可能会执行一次,或者执行多次!正如保罗·列维所说:“第三种评估方法,按需调用,对于实现目的是有用的。但它缺乏干净的指称语义——至少对于除了发散和不规则选择之外的效果来说是如此,它们的特殊属性被利用在[Hen80]中提供按需模型。”所以我们不考虑按需调用。保罗·列维并不是说对于纯按需调用,没有指称语义(这些语义与称为名字调用的语义完全一致),而是当你添加副作用时,事情变得复杂。
但是这里有一个攻击角度的提示:列维继续展示了如何在名字调用中讨论副作用,并且在这里指定指称语义毫无困难。直觉上来看,其原因在于,在名字调用中,所有对带有附加效果的延迟值的使用(例如 case-matches)都会导致效果显现。一些效果可能会被丢弃(因为它们的值从未被使用),但除此之外,效果的发生完全是确定性的。
嗯!
当然,我们可以轻松通过放弃记忆化来实现这一点,但这是一个难以接受的牺牲。因此,我们的新问题是:如何在保留共享的同时恢复具有影响力的按名字调用语义?
在Writer
单子的情况下,我们可以保留所有原始共享。过程非常简单:每个 thunk a
现在的类型是(w, a)
(对于某个固定的单子w
)。这个元组可以像原始的a
一样共享,但现在它还有一个嵌入的效果w
。每当a
被强制时,我们简单地将效果追加到结果 thunk 的w
中。下面是一个简单的解释器,实现了这一点:
{-# LANGUAGE GADTs #-}
import Control.Monad.Writer
data Expr a where
Abs :: (Expr a -> Expr b) -> Expr (a -> b)
App :: Expr (a -> b) -> Expr a -> Expr b
Int :: Int -> Expr Int
Add :: Expr Int -> Expr Int -> Expr Int
Print :: String -> Expr a -> Expr a
instance Show (Expr a) where
show (Abs _) = "Abs"
show (App _ _) = "App"
show (Int i) = show i
show (Add _ _) = "Add"
show (Print _ _) = "Print"
type M a = Writer String a
cbneed :: Expr a -> M (Expr a)
cbneed e@(Abs _) = return e
cbneed (App (Abs f) e) =
let ~(x,w) = run (cbneed e)
in cbneed (f (Print w x))
cbneed e@(Int _) = return e
cbneed (Add e1 e2) = do
Int e1' <- cbneed e1
Int e2' <- cbneed e2
return (Int (e1' + e2'))
cbneed (Print s e) = do
tell s
cbneed e
sample = App (Abs (\x -> Print "1" (Add x x))) (Add (Print "2" (Int 2)) (Int 3))
run = runWriter
尽管最终输出是"122"
(数字2
出现两次),但将2
添加到3
的实际加法只发生了一次(您可以通过添加适当的跟踪调用来验证)。对于Maybe
,您可以做类似的事情:通过稍微作弊,因为在Nothing
的情况下,我们没有x
的值,我们提供 bottom。我们永远不会被追究,因为我们总是在任何人获得值之前就进行了短路。
这里与应用函子有些相似之处,但我们要求更严格的条件:不仅计算的控制流需要固定,计算的值也必须固定!很明显,我们无法为大多数单子做到这一点。昨天在Twitter,我提出了以下签名和定律(回想起逆元),任何您想要对此过程执行的单子都必须实现这些:
extract :: Functor f => f a -> (a, f ())
s.t. m == let (x,y) = extract m in fmap (const x) y
但似乎只有Writer
具有适当的结构来正确执行这一点(既是单子又是余单子)。这很遗憾,因为我想要进行这种理论化的应用需要分配单元的能力。
然而,并非一无所获。即使无法完全共享,您仍可能实现部分共享:一种完全惰性和部分求值的混合体。不幸的是,这将需要对您的运行时进行重大和侵入性的更改(如果您想要将您的代码转换为 CPS,我不确定您将如何做到这一点),因此在这一点上我放下了这个问题,而是写了这篇博客文章。
GET /browser.exe:ezyang 的博客
Jon Howell 梦想着一个新的互联网。在这个新的互联网上,跨浏览器的兼容性检查成为了一个遥远的记忆,并且可以单方面地向浏览器添加新功能,而不必先说服整个世界进行升级。这种使这个互联网成为可能的想法如此疯狂,以至于它可能行得通。
如果一个网络请求不仅仅是下载一个网页,而是下载整个浏览器呢?
“这太愚蠢了”,你可能会说,“我绝不会从互联网上运行随机二进制文件!” 但你错了:豪威尔知道如何做到这一点,而且还知道如何以比你的浏览器经常接收和执行的 JavaScript 更安全的方式来执行。这个想法很简单:你正在执行的代码(无论是本地代码、字节码还是文本)并不重要,真正重要的是代码可以访问的系统 API,这决定了系统的安全性。
考虑今天的浏览器,这是安装在您计算机上的最复杂的软件之一。它提供了“HTTP、MIME、HTML、DOM、CSS、JavaScript、JPG、PNG、Java、Flash、Silverlight、SVG、Canvas 等”接口,几乎肯定都有漏洞。API 的丰富性是它们在安全性方面的致命弱点。现在再考虑一下,一个本地客户端需要暴露哪些 API,假设网站提供了浏览器和所有库。
答案非常简单:你只需要一个本地执行环境,一个最小化的持久状态接口,一个用于外部网络通信的接口,以及一个用于在屏幕上绘制像素的接口(如 VNC)。这就是全部:其他所有功能都可以作为网站提供的不受信任的本地代码来实现。这种接口足够小,我们有希望确保它没有漏洞。
从这种与原始互联网彻底不同的彻底离去中,你得到的是对应用程序栈的所有方面的精细控制。网站可以编写类似于本地应用程序的等价物(如应用商店),但无需按安装按钮。因为你控制了整个栈,你不再需要解决浏览器的错误或缺失功能的问题;只需选择一个适合你需求的引擎。如果你需要推送通知,不需要通过轮询循环来实现,只需正确地实现它。Web 标准仍然存在,但不再代表网站开发者与用户之间的合约(后者对底层技术一无所知);它们只是开发者与其他网络爬虫等之间的合约。
Jon Howell 和他的团队已经实现了这个系统的原型,你可以阅读更多关于实施这样一个系统所面临的(众多)技术困难。(我每次都要下载浏览器吗?如何实现 Facebook Like 按钮?浏览器历史怎么办?难道 Google Native Client 不已经做到了吗?这会不会很慢?)
作为开发者,我渴望这个新互联网。我再也不用编写 JavaScript 或担心浏览器兼容性了。我可以像管理服务器软件栈一样管理客户端软件栈,并在必要时使用现成组件。)作为客户端,我的感受更加矛盾。我不能再使用 Adblock 或 Greasemonkey(这需要将代码注入任意可执行文件),而且更难以使用网站的方式超出其所有者最初的预期。(在这个新世界秩序中,搜索引擎是否会以相同形式存在?)啊,勇敢的新世界,你有如此多的应用程序!
Getting a fix on fixpoints : ezyang’s blog
以前,我们已经 绘制了各种 Haskell 类型的哈斯图,从数据类型到函数类型,并查看了 可计算性和单调性之间的关系。事实上,所有可计算函数都是单调的,但并非所有单调函数都是可计算的。是否有某些函数描述涉及可计算性?是的:Scott 连续函数。在这篇文章中,我们将探讨定义连续性所需的数学机制。特别地,我们将研究最小上界、链、链完备偏序集(CPOs)和域。我们还将研究连续函数自然产生的不动点。
在我们之前的类型图中,我们让值以省略号一直延伸到无穷远。
正如几位评论者所指出的,这并不完全正确:所有的 Haskell 数据类型都有一个或多个顶值,即不小于任何其他值的值。(注意,这与大于或等于所有其他值的值不同:某些值是不可比较的,因为我们讨论的是偏序。)在 Nat 的情况下,有许多顶值:Z、S Z、S (S Z),等等是你可以得到的最明确的。然而,还有一个更多:fix S
,又名无穷大。
在这个值中没有潜伏的底部,但它似乎有点奇怪:如果我们去掉一个 S 构造子(减少自然数),我们又回到了 fix S
:显然,无限减一还是无限。
实际上,fix S
是链 ⊥, S ⊥, S (S ⊥)... 的最小上界。链只是一个值序列,其中 d_1 ≤ d_2 ≤ d_3 ...;它们是我们绘制的图表中向上移动的线条。
自然数的链 0 ≤ 1 ≤ 2 ≤ ... 尽管没有 0 ≤ 1 ≤ 2 ≤ ... 的上界,但自然数有许多最小上界,因为每个元素 n 形成平凡链 n ≤ n ≤ n...
在偏序集中,链不一定有最小上界。考虑具有通常偏序关系的自然数。
链 0 ≤ 1 ≤ 2 ≤ ... 没有上界,因为自然数集合不包含无穷大。我们必须转向 Ω,这是自然数和最小可能的无穷大,序数 ω。
这里链有一个最小上界。
尽管 0 ≤ 1 ≤ 2 ≤ ... 没有 0 ≤ 1 ≤ 2 ≤ ... 的上确界,自然数有许多最小上界,因为每个元素 n 形成平凡链 n ≤ n ≤ n...
这里是一些上界的图形表示。
如果一个链始终小于或等于另一个链,那么该链的上界小于或等于另一个链的上界。
双上界链的工作方式与您预期的方式相同;此外,我们可以对这条链进行对角线处理,以获取两个方向的上界。
所以,如果我们回想起先前绘制的任何图表,在任何地方有一个“...”,实际上我们可以在顶部放置一个上界,这归功于 Haskell 的惰性。以下是列表类型中具有最小上界的一个链:
正如我们前面看到的,对于所有偏序来说,这并不总是成立,因此我们为总是具有最小上界的偏序赋予了一个特殊的名称:链完备偏序或 CPO。
您可能还注意到在每个图表中,⊥位于底部。这也不一定适用于偏序。我们将称具有底部元素的 CPO 为域。
(术语域实际上在指示语义文献中被相当宽松地使用,许多时候具有超出此处给出的定义的额外属性。我从 Marcelo Fiore 的指示语义讲座中使用了这个最小定义,并且我相信这是域的 Scott 构思,尽管我尚未验证。)
因此,实际上我们一直在处理域,尽管我们一直忽略最小上界。我们将发现,一旦考虑了上界,我们将找到一个比单调性更强的条件,即可计算性。
考虑以下 Haskell 数据类型,它表示垂直自然数 Omega。
这是一个不可计算的单调函数。
为什么它不可计算?这要求我们对任意大的数和无穷大有不同的处理方式:在有限自然数和无穷大之间存在不连续性。从计算的角度来看,我们无法在有限时间内检查任何给定值是否实际上是无穷大:我们只能不断剥离 Ws,并希望我们不会达到底部。
我们可以如下形式地正式化:一个函数D -> D
,其中 D 是一个域,如果它是单调的并且保留最小上界,则称为连续。这并不是说所有上界都保持不变,而是说如果 e_1 ≤ e_2 ≤ e_3 ...的上界是 lub(e),那么 f(e_1) ≤ f(e_2) ≤ f(e_3) ...的上界是 f(lub(e))。符号化地:
图形化地表示:
现在是查看不动点的时候了!我们直接跳到要点:Tarski 的不动点定理声明,连续函数的最小不动点是序列⊥ ≤ f(⊥) ≤ f(f(⊥)) ...的最小上界。
因为函数是连续的,它被迫保持这个最小上界,自动使其成为一个固定点。我们可以将这个序列看作是给我们提供固定点的越来越好的逼近值。事实上,在有限的定义域内,我们可以利用这个事实来机械地计算函数的精确固定点。
我们将首先查看的函数并没有非常有趣的固定点。
如果我们将底部传递给它,我们得到底部。
这里有一个稍微有趣的函数。
从定义上并不明显(尽管在哈斯图上看起来更明显),这个函数的固定点是什么。然而,通过重复在 ⊥ 上迭代 f,我们可以看到我们的值发生了什么变化:
最终我们会达到固定点!更重要的是,我们已经达到了最小的固定点:这个特定函数有另一个固定点,因为 f (C ()) = C ()。
为了完整起见,这里还有一个集合。
我们可以从这些图表中看到为什么塔斯基的固定点定理可能有效:我们逐渐向上移动定义域,直到我们停止向上移动,这就是固定点的定义,并且由于我们从底部开始,我们最终得到最小的固定点。
有几个问题需要回答。如果函数将值向下移动会怎样?那么我们可能会陷入无限循环。
然而,我们是安全的,因为任何这样的函数都会违反单调性:对于 e₁ ≤ e₂的循环将导致 f(e₁) ≥ f(e₂)。
我们的有限例子也是全序的:我们的图表没有分支。如果我们的函数将一个分支映射到另一个分支(这是完全合法的操作:考虑not
)会怎样?
幸运的是,要达到这样的循环,我们必须打破单调性:从一个分支跳到另一个分支意味着某种程度的严格性。这种情况的特例是,严格函数的固定点是底部。
固定点的典范示例是递归函数的“Hello world”:阶乘。与我们之前的例子不同,这里的定义域是无限的,因此需要无限次地应用 f 才能得到真正的阶乘。幸运的是,计算阶乘n!
只需要n
次应用。请记住,阶乘的固定点风格定义如下:
factorial = fix (\f n -> if n == 0 then 1 else n * f (n - 1))
下面是阶乘函数的定义域随着连续应用的增长方式:
鼓励读者验证这是否为真。下次,我们将不再看自然数的平面域,而是看垂直域的自然数,这将很好地将我们迄今为止涵盖的许多内容联系在一起。
GHC 和可变数组:一个脏小秘密:ezyang 的博客
来源:
blog.ezyang.com/2014/05/ghc-and-mutable-arrays-a-dirty-little-secret/
GHC 和可变数组:一个脏小秘密
Brandon Simmon 最近在 glasgow-haskell-users 邮件列表上发布了一个帖子,问了以下问题:
我一直在研究 一个问题,在这个库中,随着分配更多可变数组,GC 占主导地位(我想我验证了这个?),所有代码的速度与挂起的可变数组数量成正比地变慢。
...对此,我回复道:
在当前的 GC 设计中,指针数组的可变数组总是放置在可变列表上。未收集的代的可变代的列表总是被遍历;因此,指针数组的数量对于小 GC 产生了线性的开销。
如果你从传统的命令式语言转过来,你可能会发现这非常令人惊讶:如果你为系统中所有可变数组支付了 Java 中每个 GC 的线性开销... 嗯,你可能永远都不会使用 Java。但大多数 Haskell 用户似乎过得很好;主要因为 Haskell 鼓励不可变性,使得大多数情况下不需要大量的可变指针数组。
当然,当你确实需要时,这可能有点痛苦。我们有一个 GHC bug 跟踪这个问题,还有一些低 hanging fruit(一种变体的可变指针数组,写操作更昂贵,但只有在写入时才放入可变列表中),以及一些有前途的实现卡标记堆的方向,这是像 JVM 这样的 GC 策略所使用的策略。
更加元层次上,为不可变语言实现一个性能良好的分代垃圾收集器要比为可变语言实现一个更容易得多。这是我个人的假设,解释了为什么 Go 仍然没有一个分代收集器,以及为什么 GHC 在某些突变类别上表现如此糟糕。
后记。 标题是一个双关语,因为“DIRTY”用于描述自上次 GC 以来已写入的可变对象。这些对象是记忆集的一部分,必须在垃圾收集期间遍历,即使它们位于旧代中也是如此。
GHC migrating to Git : ezyang’s blog
来源:GHC 迁移到 Git
GHC migrating to Git
From cvs-ghc@haskell.org
:
Hi all,
We now plan to do the git switchover this Thursday, 31 March.
Thanks
Ian
我将会怀念 Darcs(darcs send
和“一切皆为补丁”确实运作良好的情况),但总体而言,看到 GHC 迁移到 Git 我感到非常满意。
ghc-shake:重新实现 ghc --make:ezyang 的博客
来源:
blog.ezyang.com/2016/01/ghc-shake-reimplementing-ghc-make/
ghc-shake:重新实现 ghc --make
ghc --make
是 GHC 中的一种有用模式,它会自动确定需要编译的模块,并为您编译它们。它不仅是构建 Haskell 项目的便捷方式,其单线程性能也很好,通过重用读取和反序列化外部接口文件的工作。然而,ghc --make
也存在一些缺点:
-
具有大模块图的项目在重新编译开始之前有相当长的延迟。这是因为
ghc --make
在实际进行任何工作之前会重新计算完整的模块图,解析每个源文件的头文件。如果您使用预处理器,情况会更糟(参见这里)。 -
这是一个单体构建系统,如果需要比 GHC 默认功能更复杂的东西,将其与其他构建系统集成起来会很困难。(例如,GHC 精心设计的构建系统知道如何在包边界之间并行构建,而 Cabal 不知道如何做。)
-
它无法提供有关构建性能的洞察,例如哪些模块需要很长时间构建,或者哪些“阻塞”模块很大。
ghc-shake 是使用 Shake 构建系统 重新实现的 ghc --make
。它可以作为 ghc
的替代品。ghc-shake 具有以下特性:
-
大大减少了重新编译的延迟。这是因为 Shake 不会通过解析每个文件的头文件来重新计算模块图;它会重用缓存的信息,仅重新解析已更改的源文件。
-
如果重新构建文件(并更新其时间戳),但构建输出未更改,我们就不会重新编译任何依赖于它的内容。这与
ghc --make
相比,后者必须在确定没有要做的工作之前运行每个下游模块的重新编译检查,有所不同。事实上,ghc-shake 从不运行重新编译测试,因为我们在 Shake 中本地实现了这种依赖结构。 -
使用
-ffrontend-opt=--profile
,你可以获得有关构建的详细分析信息,包括每个模块构建所花费的时间,以及更改一个模块的成本。 -
在单线程构建上与
ghc --make
一样快。与另一个使用 Shake 构建 Haskell 的构建系统 ghc-make 相比,ghc-make 并不使用 GHC API,并且必须使用(慢速的)ghc -M
来获取项目的初始依赖信息。 -
它是准确的。它正确处理许多边缘情况(如
-dynamic-too
),因为它是使用 GHC API 编写的,原则上可以与ghc --make
功能完全兼容。(当前情况不是这样,只是因为我还没有实现它们。)
也有一些缺点:
-
Shake 构建系统需要一个
.shake
目录来实际存储有关构建的元数据。这与ghc --make
相反,后者完全依赖于目录中构建产品的时间戳。 -
因为它直接使用了 GHC API 实现,所以只能与特定版本的 GHC(即即将发布的 GHC 8.0 版本)一起使用。
-
它需要一个修补过的 Shake 库版本,因为我们有一个基于 Shake 的(未导出的)文件表示的自定义模块构建规则。我已经在这里报告了。
-
仍然存在一些缺失的功能和 bug。我遇到的问题是(1)在某些情况下我们忘记了重新链接,以及(2)它不能用于构建分析代码。
如果你想今天就使用 ghc-shake
(不适合心脏虚弱的人),试试 git clone https://github.com/ezyang/ghc-shake
,然后按照 README
中的说明操作。但即使你不打算使用它,我认为 ghc-shake
的代码对任何想编写涉及 Haskell 代码的构建系统的人来说都有一些好的教训。其中最重要的架构决策之一是使 ghc-shake
中的规则不是围绕输出文件(例如 dist/build/Data/Foo.hi
,如 make
中那样)组织,而是围绕 Haskell 模块(例如 Data.Foo
)组织的。语义化的构建系统比强制将一切都放入“文件抽象”中要好得多(尽管 Shake 在我希望的模式下使用上并不完全支持)。还有一些其他有趣的经验教训... 但那应该是另一篇博客文章的主题!
这个项目的未来方向在哪里?在不太近的未来,我考虑做一些事情:
-
为了支持多个 GHC 版本,我们应该将 GHC 特定的代码分离出来成为一个单独的可执行文件,并通过 IPC 进行通信(向 Duncan Coutts 致敬)。这也将使我们能够支持独立进程的并行 GHC 构建,仍然可以重用读取接口文件。无论如何,
ghc-shake
可以作为 GHC 需要使构建系统更易于访问所需信息的蓝图。 -
我们可以考虑将这些代码移回 GHC。不幸的是,Shake 是一个太大的依赖项,无法实际让 GHC 依赖它,但可以考虑设计一些抽象接口(你好,Backpack!),用于表示类似 Shake 的构建系统,并让 GHC 提供
--make
的简单实现(但用户可以选择切换到 Shake)。 -
我们可以将这段代码扩展到
ghc --make
以了解如何构建整个 Cabal 项目(或更大的项目),比如ToolCabal,这是使用 Shake 重新实现的 Cabal。这将允许我们捕捉类似于 GHC 构建系统的模式,该系统可以并行构建所有引导包中的模块(而不必等待包完全构建完成)。
P.S. ghc-shake 不应与shaking-up-ghc混淆,后者是一个旨在用 Shake 基础构建系统替换 GHC 基于 Makefile 的构建系统的项目。
Gin and monotonic : ezyang’s blog
Gin, because you’ll need it by the time you’re done reading this.
上次我们看了数据类型值的部分顺序。有两件事情我想补充:一个是星下标底部如何扩展,一个是不使用星下标底部符号的列表图解。
这是三个星下标底部扩展的三重体现,形成了熟悉的 Hasse 图,通过包含关系排序的三个元素集的幂集:
下面是列表的部分顺序,在其全指数荣耀中(为了适应所有,灰色脊柱的部分顺序在向右增加时增加)。
现在,谈谈今天的主题,函数!到目前为止,我们只讨论了数据类型。在本篇文章中,我们将更仔细地研究函数具有的偏序关系。我们将介绍单调性的概念。并且会有很多图片。
让我们从一个简单的例子开始:从单元到单元的函数,() -> ()
。在你看图之前,你认为我们可以写多少不同的这种函数实现呢?
结果,一共有三种。一种无论我们传递什么都返回底部,一种是恒等函数(如果传递单元则返回单元,如果传递底部则返回底部),还有一种是const ()
,即无论传递什么都返回单元。注意这些不同函数与其参数的严格和惰性评估之间的直接对应关系。(你可以称底部函数为部分的,因为它对于任何参数都未定义,尽管没有直接编写此内容的方法,如果仅使用 undefined GHC 不会发出部分函数警告。)
在我提出的图中,我展示了关于偏序的三种等效思考方式。第一种只是λ演算中的术语:如果你更喜欢 Haskell 的表示法,你可以将λx.x 翻译为\x -> x
。第二种是将输入值映射到输出值,明确处理了底部(这种表示法有助于明确看到底部,但不太适合确定哪些值是合法的——即可计算的)。第三种仅仅是函数的定义域:你可以看到这些定义域逐渐变得越来越大,从空到整个输入类型。
在这一点上,一点正式性是有用的。我们可以定义一个函数的偏序如下:f ≤ g 当且仅当 dom(f)(f 的定义域,例如所有不会导致 f 返回底部的值)⊆ dom(g),对于所有 x ∈ dom(f),f(x) ≤ g(x)。你应该验证上面的图表是否一致(第二个条件非常容易,因为函数的唯一可能值是()
)。
一个敏锐的读者可能已经注意到,我忽略了一些可能的函数。特别是,第三个图表并不包含域的所有可能排列:只有底部的集合如何?事实证明,这样的函数是不可计算的(如果我们有一个函数 () -> ()
,如果其第一个参数是底部则返回 ()
,如果其第一个参数是 ()
则返回底部,那么如何解决停机问题)。我们稍后再回到这个问题。
由于 () -> ()
有三种可能的取值,一个问题是是否存在一个更简单的函数类型,其取值更少?如果我们接受空类型,也可以写为 ⊥,我们可以看到 a -> ⊥
只有一种可能的取值:⊥。
类型为 ⊥ -> a
的函数也具有一些有趣的属性:它们与类型 a
的普通值是同构的。
在没有公共子表达式消除的情况下,这可以是防止惰性计算结果共享的有效方式。然而,写 f undefined
是很麻烦的,因此人们可能会看到 () -> a
,它的语义并不完全相同,但类似。
到目前为止,我们只考虑了以 ⊥
或 ()
作为参数的函数,这些函数并不是很有趣。因此,我们可以考虑下一个可能最简单的函数:Bool -> ()
。尽管这种类型看起来很简单,实际上有五种不同的可能函数具有这种类型。
要看为什么可能是这种情况,我们可以看看函数对其三个可能参数的行为:
或者每个函数的定义域是什么:
尽管看起来域中元素可能有其他可能的排列,但这些偏序是完备的。再次强调,这是因为我们排除了不可计算的函数。接下来我们会看看这一点。
考虑下面的函数 halts
。如果传递给它的计算最终终止,则返回 True
,如果不终止,则返回 False
。正如我们通过 fix id
所见,我们可以将底部视为一个不终止的计算。我们可以通过绘制输入和输出类型的哈斯图,并绘制箭头将一个图表中的值映射到另一个图表中来绘制此图表。我还用灰色背景着色了不映射到底部的值。
众所周知,停机问题是不可计算的。那么这个看起来完全合理的图表有什么问题?
答案是我们的排序没有被函数保留。在第一个定义域中,⊥ ≤ ()
。然而,结果值却没有这种不等式:False ≰ True
。我们可以总结这种情况为单调性,即,当 x ≤ y 时,若 f(x) ≤ f(y),则 f 是单调的。
这里值得注意的两种退化情况:
-
在 f(⊥) = ⊥ 的情况下,即函数是严格的,你永远不必担心 ⊥ 不小于任何其他值,因为根据定义 ⊥ 小于所有值。从这个意义上说,使函数严格是“安全的做法”。
-
当 f(x) = c(即常数函数)对所有 x 都成立时,您同样是安全的,因为任何在原始域中存在的排序在新域中也是存在的,因为 c ≤ c。因此,常数函数是向 f(⊥)分配非底值的简单方法。这也清楚地表明单调性推论只是单向的。
更有趣(并且有些不明显)的是,我们可以编写计算函数,它们不是常量,但在传递⊥
时却提供了非⊥值!但在我们进入这种乐趣之前,让我们首先考虑一些可计算函数,并验证单调性是否保持。
最简单的所有函数是恒等函数:
它几乎什么都不做,但是您应该验证自己是否能理解这种表示法。
更不那么琐碎的是fst
函数,它返回一对中的第一个元素。
查看并验证函数保留了所有偏序关系:因为只有一个非底输出值,所以我们只需要验证灰色是否“位于”其他所有值之上。还要注意,我们的函数不关心对偶中的snd
值是否为底。
图表指出,fst
仅仅是一个未柯里化的const
,所以让我们接着看这个。
我们希望考虑const
的意义是a -> (b -> a)
,一个接受一个值并返回一个函数的函数。为了读者的利益,我们还绘制了导致这些函数的哈斯图。如果我们固定了a
或b
的类型,那么我们的偏序关系中将会有更多的函数,但在没有这些限制的情况下,通过参数性质,我们的函数能做的事情很少。
考虑到const
与seq
的对比是有用的,seq
是一种有点恶劣的函数,尽管它可以很好地使用我们的表示法来绘制。
这个函数之所以如此难缠,是因为它适用于任何类型(它将是一个完全合法且自动推导的类型类):它能够查看任何类型a
,并看到它是底部还是其构造函数之一。
让我们看看一些列表上的函数,它们与底部可能有非平凡的交互方式。null
有一个非常简单的对应关系,因为它真正询问的是“这是cons
构造函数还是null
构造函数?”
head
看起来有点有趣。
有多个灰色区域,但单调性从未被违反:尽管脊椎朝上无限扩展,每个叶子都包含偏序的最大值。
length
有类似的模式,但叶子的排列略有不同:
虽然head
只关心列表的第一个值不是底部,length
却关心cons
单元的cdr
是否为空。
我们还可以使用此符号来查看数据构造函数和新类型。
考虑下面的函数caseSplit
,它作用在一个具有唯一字段的未知数据类型上。
我们有非严格构造函数:
严格构造函数:
最后是新类型:
现在我们准备进行一个力作示例,研究从 Bool -> Bool
函数的偏序,并考虑布尔函数 ||
。为了刷新您的记忆,||
通常以这种方式实现:
从这张图表中不太明显的一点(我们希望很快能够明显),是这个运算符是从左到右的:True || ⊥ = True
,但 ⊥ || True = ⊥
(在命令式措辞中,它会短路)。我们将开发一个偏序,让我们能够解释这个左或及其表亲右或和更奇特的平行或之间的差异。
记住 ||
是柯里化的:它的类型是 Bool -> (Bool -> Bool)
。我们之前已经画出了 Bool
的偏序,那么 Bool -> Bool
的完全偏序是什么?一个非常有趣的结构!
我违反了我之前声明的约定,即更明确定义的类型位于其他类型之上,以展示这个偏序的对称性。我还缩写了 True 为 T,False 为 F。(作为补偿,我已经明确画出了所有的箭头。在未引起兴趣的未来图表中,我将省略它们。)
这些明确的 lambda 表达式有些模糊了每个函数的实际作用,因此这里是一个简写表示:
每个球或 bottom 的三重表示说明了函数对 True、False 和 bottom 的反应。
注意顶部/底部和左侧/右侧之间的轻微不对称性:如果我们的函数能够区分 True 和 False,那么就没有非严格可计算的函数。练习:画出哈斯图并说服自己这一事实。
从现在开始我们将使用这种简写表示法;如果你感到困惑,请参考原始图表。
首要任务(咳嗽)是重新绘制带有完全偏序的左或哈斯到哈斯图的图。
使用传递性验证,我们可以恢复简化的偏序的部分图。红色箭头表示原始布尔顺序中保留的排序。
百万美元的问题是:我们能写一个不同的映射来保持顺序(即单调吗)?正如你可能已经猜到的那样,答案是肯定的!作为一个练习,画出严格或的图表,它在其两个参数中都是严格的。
这是右或的图表:
注意一个非常有趣的事情发生了:bottom 不再映射到 bottom,但我们仍然成功地保留了顺序。这是因为目标域具有足够丰富的结构,可以让我们做到这一点!如果这对你来说有点神奇,请考虑我们如何在 Haskell 中编写一个右或:
rightOr x = \y -> if y then True else x
在我们查看 x 之前,我们先看 y;在我们的图中,如果 y 为 False,看起来 x 就被插入到结果中。
还有一件事情我们可以做(你现在可能已经想到了),使我们在面对 bottom 时能够给出最大能力的答案,平行或:
真的这是我们能走的最远:我们不能把我们的函数进一步推入定义链的底部,也不能移动我们的底部而不改变函数的严格语义。在 Haskell 中如何实现这一点也不明显:似乎我们真的需要能够模式匹配第一个参数,以决定是否返回const True
。但这个函数肯定是可计算的,因为单调性没有被违反。
这个名字极具暗示正确的策略:并行评估两个参数,并在任何一个返回 True 时返回 True。这种方式,哈斯图相当具有误导性:我们实际上从未返回三个不同的函数。然而,我真的不确定如何正确地说明这种并行方法。
这整个练习与卡诺图和电路中的亚稳态有很明显的并行。在电气工程中,你不仅要担心一条线是 1 还是 0,还要担心它是否从一个状态过渡到另一个状态。根据电路的构造方式,这种过渡可能导致危险,即使开始和结束状态相同(严格函数),或者无论第二行的操作如何都保持稳定(惰性函数)。我鼓励电气工程师评论一下在晶体管级别上严格或、左或、右或和并行或(我认为通常实现的方式)看起来像什么。这些类比让我觉得我花在学习电气工程上的时间并不浪费。😃
今天就到这里。下次,我们将扩展我们对函数的理解,并看一下连续性和不动点。(点击此处查看原文)
附言。 有一些本文的勘误。
Google Nexus 7 设置笔记:ezyang's 博客
Google Nexus 7 设置笔记
我在寒假期间购买了一台 Google Nexus 7(仅 Wi-Fi 版)。我不太喜欢购买新设备:它们通常需要大量工作来按我的喜好设置。以下是一些笔记:
-
在 Linux 上越狱设备仍然有些麻烦。最终,最简单的方法可能是找一台 Windows 电脑并使用 Nexus Root Toolkit。这个工具有些不稳定;如果第一次检测失败,可以再试一次检测代码。
-
在 Linux 上进行文件传输真是痛苦。我已经通过 SSHDroid 使用 SCP 进行了工作;我还尝试了 DropBear SSH 服务器,但它们没有附带 scp 二进制文件,因此对文件传输目的来说几乎没有用。SSHDroid 并没有 out-of-the-box 解决:我需要应用 comment 14 来使真正的 scp 二进制文件在路径中被找到。默认情况下,这些应用程序配置为接受密码验证(甚至不是键盘交互式!)使用极其弱的默认密码:确保您禁用了这一功能。仍在寻找一个好的 rsync 实现。在 USB 方面,Ubuntu/Gnome/Nautilus 在 PTP 模式下本地识别 Nexus,但当我尝试复制文件时却挂起了。Ubuntu 12.10 对 MTP 支持较少,但是 go-mtpfs 在现代 libmtp 的支持下表现还算不错。Adam Glasgall 已经为 Quantal 打包了 libmtp,所以添加他的 PPA,然后 按照 go-mtpfs 的安装说明 进行安装。更新: 直接向可移动媒体传输文件也效果不错。
-
这款平板确实感觉像一部手机,因为两者都在 Android 平台上。但是没有 3G 意味着离线功能变得更加重要,而更大的屏幕使某些类型的应用程序使用起来更加愉快(更新: 我已经选择了 MX Player 作为我的视频播放器,因为它支持高级字幕 Alpha 和 MKV 文件。不幸的是,它不支持深色(例如 10 位)。)
-
微型 USB 到 USB OTG 电缆非常方便,特别是用于连接键盘或外部媒体。我敢说,它比外壳更为重要。请注意,微型 USB 端口无法为具有高功率需求的 USB 设备供电(例如旋转碟外置硬盘),因此您需要使用带电 USB 集线器连接它们。这会导致挂载时的一个症状是如果您尝试挂载功率不足的硬盘,目录列表会持续为空。它还可能发出点击声:这对硬盘可能不是好事。我使用 USB-OTG 进行挂载。
-
我试图将我的 Mendeley 论文数据库镜像到我的平板电脑上,但这相当困难。我一直在尝试使用 Referey,这是一个适用于安卓设备的 Mendeley 客户端,但它要求我以某种方式传播我的 Mendeley SQLite 数据库和所有的 PDF 文件。Dropbox 在这里看起来是一个很好的选择,但官方 Dropbox 客户端不支持保持整个文件夹同步(只支持收藏的文件)。如果你和我一样,不确定将阅读哪些论文,你必须使用其他方法,比如 Dropsync。(顺便说一句,如果你像我一样,有个聪明的主意,把 SQLite 数据库和 PDF 放在一起,这样它们就会在一个文件夹中同步,永远不要“整理”:Mendeley 会高兴地将你的 SQLite 数据库删除为“外来物”)。Mendeley 和 Dropbox 在各种方面似乎互动不良(区分大小写;此外,Mendeley 喜欢生成过长的文件名,而 Dropbox 则愚蠢而乐意接受它们)。
-
“打开窗口”按钮似乎没有正确尊重应用程序通过其自己的意愿关闭时的状态(即通过应用程序本身支持的退出按钮)。这有点恼人。
哦对了,祝你新年快乐。 😃
更新: 我的 Nexus 7 突然变砖了。幸运的是,一旦手机解锁,重新刷新镜像非常容易(并且我没有丢失数据,这通常会在首次解锁手机时发生)。我是在手机处于引导程序状态时(同时按住两个音量键并开机),通过 fastboot update image-nakasi-jop40d.zip
来完成的,然后按照这里的最后一组步骤来重新安装 SuperSu(即通过 fastboot 进入 ClockworkMod,然后通过 sideload 安装 SuperSu)。
Grad School, Oh My : ezyang’s blog
Grad School, Oh My
It still feels a little strange how this happened. Not a year ago, I was pretty sure I was going to do the Masters of Engineering program at MIT, to make up for a “missed year” that was spent abroad (four years at MIT plus one at Cambridge, not a bad deal.)
但是,通过与几位我非常尊敬的研究人员的交流、我父亲的唠叨以及在 MIT 期间实际上我将在哪里完成硕士论文的选择上的困惑,大约一个半月前,我决定参加今年秋季的研究生院入学循环。哎哟。感觉就像是再次经历本科入学一样(那并不是一个令人愉快的经历),尽管这一次,我需要写的是“研究声明”。
一个原因是我最近在博客上谈论 Mendeley,希望 Mendeley 能为我提供关于我感兴趣的论文类型的一些见解,并让我轻松地了解这些研究人员的机构隶属情况。(哎呀,还没有完全实现。)实际上,与 Simon Marlow 的一次对话更加富有成效:我目前正在积极调查 Ramsey(Tufts)、Morrisett(Harvard)、Harper(CMU)、Pierce/Weirich(UPenn)和 Mazieres(Stanford)。当然,我不禁在想,我是否错过了一些关于我在博客上经常讨论的主题的关键人物,所以如果你们有任何想法,请一定告诉我。
The process (well, what little of it I’ve started) has been quite bipolar. I frequently switch between thinking, “Oh, look at this grad student, he didn’t start having any publications till he started grad school—so I’m OK for not having any either” to “Wow, this person had multiple papers out while an undergraduate, solved multiple open problems with his thesis, and won an NSF fellowship—what am I supposed to do!” I’m still uncertain as to whether or not I’m cut out to do research—it’s certainly not for everyone. But I do know I greatly enjoyed the two times I worked at industrial research shops, and I do know that I love teaching, and I definitely know I do not want a conventional industry job. Grad school it is.
图形而非网格:缓存如何破坏年轻算法设计师及其修复方法:ezyang’s 博客
小标题:大规模多线程处理器让你的本科计算机科学教育再次变得相关。
快速排序。分而治之。搜索树。这些及其他算法构成经典本科算法课程的基础,展示算法设计的重要思想,以及性能模型是一个指令,一个时间单位。“一个指令,一个时间单位?多么古雅!”高速缓存无视算法研究人员和真实世界工程师知道,传统课程虽然不错,但却颇具误导性。仅仅看一些理论计算机是不够的:高性能算法的下一代需要与其运行的硬件保持协调。他们绝对正确。
上周五,John Feo 博士在 Galois Tech Talk 上发表了题为数据密集、不规则应用的要求和性能的演讲(幻灯片 1)。然而,Feo 还带来了另一个讲述更广泛的自适应超级计算软件中心的幻灯片(幻灯片 2)。最终的演示是关于大规模多线程处理器架构原则——特别是Cray XMT——以及在编写此类机器软件时遇到的实际工程问题的结合。由于我无法抵挡美好演示的诱惑,这些笔记的标题来自我与 Feo 在技术讨论后的一段对话;我并不是要贬低那些在传统处理器上进行研究的人,只是建议有另一种方法,Feo 认为这种方法应该得到更多关注。对于那些喜欢解谜题的人,本文末尾还将有一个“这是为什么会死锁?”的问题。
图表不是网格。 约翰·费奥开始区分科学问题和信息学问题。科学问题通常采用网格形式,是演化缓慢的系统,展示局部性原理,并仅涉及网格内部的最近邻通信。这类问题非常适合通过集群并行化解决:平面网格易于分割,最近邻通信意味着大部分计算将局限于包含分割的节点。局部性还意味着,在稍加注意的情况下,这些算法可以很好地与 CPU 缓存兼容:对于无关缓存的算法,这只是将问题分割直至适合板载的过程。
然而,数据信息学涉及的数据集却大不相同。考虑一下 Facebook 上的朋友关系图,或者互联网页面的相互链接,或者你国家的电力网络。这些都不是网格(即使是电网也不是):它们是图表。与量子色动力学模拟不同,这些图表是动态的,不断地被许多自主代理修改,这对传统处理器和并行化提出了一些独特的问题。
难以处理的图表。 有几种类型的图表特别难以运行算法。不幸的是,它们在现实世界的数据集中经常出现。
低直径(又称“小世界”)图是一种图表,其中任意两个节点之间的分离程度非常低。在这些图表上需要的工作量激增;任何查看节点邻居的算法很快就会发现自己不得不一次性操作整个图表。说再见内存局部性!紧密耦合还使得图表难以分割,而这是并行化图表计算的经典方法。
无标度图是一种图表,其中少数节点有大量的邻居,而大多数节点只有少量的邻居。这些图表也难以分割,并且导致高度不对称的工作负载:那些有大量邻居的少数节点往往会吸引大部分的工作。
图表的某些属性可能使计算更加困难。非平面图通常更难分割;动态图有并发的参与者插入和删除节点和边;加权图可能具有病理性的权重分布;最后,具有类型边的图阻止将图操作简化为稀疏矩阵操作。
这张来自 Feo 的幻灯片很好地总结了这些类型图表的即时效果。
多线程处理器:计算世界的加特林机枪。 加特林机枪是最早知名的快速射击枪之一。其他枪械简单增加射速,但很快发现,如果试图射击过快,枪管会过热。加特林机枪使用多管,每管独立射击速度较慢,但依次旋转时可以持续不断地发射子弹,同时允许未使用的枪管冷却。
空闲枪管冷却的时间类似于内存访问的延迟。由于内存访问开销大,传统处理器尝试“减少子弹使用”,通过处理器缓存来避免内存访问。然而,大规模多线程处理器采取不同的方法:而不是试图消除内存延迟,它通过上下文切换远离请求内存的线程来隐藏它,这样在切换回来时,访问已经完成并且数据可用。不需要无聊地等待数据;去做其他事情吧!在专用硬件上,PNNL 的研究人员已经能够使处理器利用率超过 90%;在非专用硬件上,性能目标要逊色一些,大约为 40%左右。
影响。 因为大规模多线程处理器隐藏了内存访问延迟,而不是试图消除它,传统的约束条件如内存局部性变得不重要。你不需要数据靠近计算,也不需要在处理器之间平衡工作(因为它们都进入共存的线程),也不需要像定时炸弹一样处理同步。你在本科计算机科学中学到的东西再次变得相关了!用 Feo 的话说:
-
自适应和动态方法都可以,
-
图算法和稀疏方法都可以,以及
-
递归,动态规划,分支和界限,数据流都可以!
因此,你的硬件将被定制用于类似图的计算。这包括一个巨大的全局地址空间来存放你的图,极其轻量级的同步形式如全/空位标志(Haskell 用户可能会认出它们与MVars非常相似;事实上,它们来自于数据流语言的同一血统)以及硬件支持线程迁移,以平衡工作负载。对于函数式语言来说,这是一种神圣的硬件圣杯!
Cray XMT 是约翰·Feo 及其研究伙伴一直在评估的一种特定架构。在处理具有较差引用局部性的算法时,它轻松击败传统处理器;然而,当你给传统处理器和具有良好引用局部性的算法时,它会慢一些。
最大权重匹配。有许多图问题——最短路径、节点间的介数中心性、最小/最大流、生成树、连通分量、图同构、着色、划分和等价性,仅举几例。Feo 选择详细介绍的是最大权重匹配。匹配是边的一个子集,使得任意两条边不相邻于同一个顶点;因此最大权重匹配是一种使所选边的权重最大化的匹配(也可以考虑其他成本函数,例如在无权重图中可能希望最大化边的数量)。
虽然存在一种多项式时间算法用于找到最大权重匹配,但是我们可以通过一种称为Hoepman 的算法的贪婪并行算法更快地得到近似答案。它类似于稳定婚姻(Gale-Shapely)算法;算法运行如下:每个节点请求与其最昂贵的本地顶点对配。如果两个节点相互请求,则它们被配对,并拒绝所有其他配对请求。如果一个节点被拒绝,则尝试下一个最高的顶点,依此类推。由于一个节点只会接受一个配对请求,配对中的边永远不会与同一个顶点相邻。
Hoepman 的算法依赖于一个能够为每个节点分配处理器的理论机器。这对传统的集群机器并不利,因此Halappanavar, Dobrian 和 Pothen提出了一个并行版本,将图分割成分区,每个分区分配给处理器,并使用队列来协调跨分区的通信。不幸的是,这种方法在某些情况下表现极差。Feo 对此现象进行了一些可视化:下面的图片展示了处理器核心的视觉图,绿色表示核心正在忙碌,白线表示处理器间的通信。尽管美国道路常规的平面图能很好地处理这个问题,但是由Erdős–Rényi 模型和无标度图(我们之前提到的“难以处理”的图类型之一)生成的图表现出了大量的处理器间通信爆炸。
然而,像 Cray XMT 这样的机器使得更接近实现 Hoepman 原始算法成为可能。为每个节点分配一个线程,并按描述的方式实现算法。
为了实现信号传递,我们可以使用完整/空位原语。每条边有两个完整/空位位,每个端点分别拥有其中一个。当一个节点尝试与一个顶点配对时,它将自己的位填充为 1,然后尝试读取另一个位。当该位为空时,节点的线程将阻塞。如果另一个位读取为 1,则节点已配对:将节点拥有的所有其他位填充为 0,然后终止。如果另一个位读取为 0,则尝试与下一个具有最高边的邻居。
这种方法并不完全奏效,因为在 Cray XMT 上存在实际约束。特别是对于大图,不可能同时运行每个线程;只有一部分节点可以同时运行。如果恰好每个节点都在等待另一个当前未运行的节点,所有节点都会阻塞,我们就会陷入死锁。特别是,Cray XMT 不会默认抢占一个被阻塞的线程,因为上下文切换的成本如此之高。(你可以打开抢占,这样死锁会消失,但运行时间会大大增加。虽然 Cray 每个周期进行线程级上下文切换,但实际上从处理器中驱逐线程是非常昂贵的。)
Feo 应用的简单修复方法是以下观察:只要我们安排在昂贵边附近的节点,总是会有工作要做:特别是,两个与最昂贵的未配对边相邻的节点总是能够配对。因此,按照它们最昂贵的顶点对节点进行排序,然后运行算法。这解决了大部分死锁问题。
结尾注释. 尽管高度多线程的架构很有前景,但硬件方面仍需大量工作(使这项技术在大宗硬件上可用,而不仅限于 Cray XMT),以及软件生态系统(构建新的编程 API 以利用这种架构)。更进一步,这个领域的问题如此多样化,以至于没有一台机器能真正攻击所有问题。
尽管如此,Feo 仍然持乐观态度:如果问题足够重要,机器会被建造起来的。
谜题. 即使进行了排序修改,在禁用抢占的 Cray XMT 上实现最大匹配仍然会在一些大图上发生死锁。什么样的图会导致死锁,以及解决这个问题的简单方法是什么?(根据 Feo 的说法,他花了三天时间调试这个死锁!而且,不,打开抢占不是答案。)解决方案将在星期五发布。
(可能会有答案在评论部分,所以如果你不想被剧透,请避开目光。)
更新。我已删除到 CACM 文章的链接;虽然我认为这对 Reddit 读者来说很及时,但它暗示 Varnish 的设计者是一个“被缓存局部性腐蚀的年轻算法设计师”,这完全是错误的。这种表达意在表达 Feo 对算法社区普遍对复杂的缓存感知/无感知算法的过分关注的一般不满,并非针对任何特定人物。
(此处故意留白)
瑞士的问候:ezyang 的博客
瑞士的问候
“说来也粗糙。”
没有预订也没有地方可去,希望是在“雾线”上方的少女峰地区找个地方睡觉
但这些计划被我发现 Wengen 没有青年旅舍所挫败了。啊,好吧。
还是相当美。
我没有一张照片,但在夜晚 Lauterbrunnen 的一个惊人的景象之一(我登记并问了业主:“有空床吗?”他们回答:“只有一个!”唯一可能的回答:“太棒了!”)是镶嵌在山上的城镇和火车几乎看起来像星星(由于它们的稀疏性,山被遮挡,形成彩色星系群)。
不合逻辑的结论。 一个问题给读者:“你有没有一个在寻找问题的解决方案?”
Groom:用于 Haskell 的人类可读的 Show:ezyang 的博客
来源:
blog.ezyang.com/2010/07/groom-human-readable-show-for-haskell/
Groom:用于 Haskell 的人类可读的 Show
在一个复杂的数据结构上敲击,我发现自己面对一堵巨大的语言困境之墙。
“天哪!”我惊叹道,“GHC 的神灵又一次用没有空白的派生 Show 实例咒骂了我!”我不满地自言自语,并开始匹配括号和方括号,扫描文本页以寻找可能告诉我正在寻找的数据的可辨识特征。
但是,我突然想到:“显示被指定为有效的 Haskell 表达式,不带空白。如果我解析它,然后漂亮地打印出生成的 AST 呢?”
几行代码后(借助Language.Haskell
的帮助)...
如何使用它。 在你的 shell 中:
cabal install groom
以及在你的程序中:
import Text.Groom
main = putStrLn . groom $ yourDataStructureHere
更新。 Gleb 提到了 ipprint,它基本上也是做同样的事情,但还有一个putStrLn . show
的函数,并且有一些调整后的默认设置,包括知道您终端的大小。
更新 2。 Don 向我提到了 pretty-show 这个由 Iavor S. Diatchki 开发的软件包,它也具有类似的功能,并配备了一个可让您离线美化输出的可执行文件!
Hacking git-rerere:ezyang 的博客
Git 的一个非常规工作流,Wizard广泛使用,是单个开发者需要在大量工作副本中执行合并。通常,维护者会从他关心的分支拉取,并将大量工作分配给那些有兴趣贡献补丁的人。然而,Wizard 正在使用 Git 为那些不了解并且不感兴趣学习 Git 的人提供服务,因此我们需要推送更新并为他们合并他们的软件。
在进行大量合并时遇到的问题是“重复解决相同的冲突”。至少对于经典案例来说,解决方法是使用git rerere
。此功能保存冲突的解决方案,然后如果再次遇到冲突,则自动应用这些解决方案。如果查看man git-rerere
,您可以了解到这些信息。
不幸的是,这个合并解决数据存储在每个.git
目录中,具体在rr-cache
子目录中,因此需要一些适度的聪明才能使其在多个仓库中正常工作。幸运的是,将所有rr-cache
目录符号链接到一个共同的目录的简单解决方案既有效又在最初合并时安全(写出解决方案时不是竞争安全,但我认为这种低竞争足以忽略不计)。
为什么这个解决方案是竞争安全的?初看rerere.c
中的代码,这似乎并非如此:如果发生两次合并以生成相同的合并冲突(这正是 git rerere 的用例),则以下代码将使用相同的hex
值执行:
ret = handle_file(path, sha1, NULL);
if (ret < 1)
continue;
hex = xstrdup(sha1_to_hex(sha1));
string_list_insert(path, rr)->util = hex;
if (mkdir(git_path("rr-cache/%s", hex), 0755))
continue;
handle_file(path, NULL, rerere_path(hex, "preimage"));
最后三行访问了(现在共享的)rr-cache
目录,并且handle_file
将尝试写出文件rr-cache/$HEX/preimage
的预影像内容;如果两个实例同时运行handle_file
,则此文件将被覆盖。
但事实证明,我们并不在乎;除非发生 SHA-1 碰撞,否则两个实例将写出相同的文件。handle_file
的签名是:
static int handle_file(const char *path,
unsigned char *sha1, const char *output)
第一个参数是从中读取冲突标记的路径,是必需的。sha1
和output
是可选的;如果output
不为 NULL,则其包含整个文件的内容,减去任何 diff3 冲突部分(由|||||||
和=======
分隔);如果sha1
不为 NULL,则写入其内容的 20 字节二进制摘要,这些内容output
本来会收到。于是,世界恢复了平衡。
附录
Anders 提出了一个有趣的问题,即两个进程是否将相同内容写入同一个文件是否真的是竞争安全的。事实上,有一个非常相似的情况涉及到两个进程将相同内容写入同一个文件,这是竞争条件的一个经典例子:
((echo "a"; sleep 1; echo "b") & (echo "a"; sleep 1; echo "b")) > test
在正常情况下,测试的内容是:
a
a
b
b
但是偶尔会出现其中一个进程输掉比赛的情况,你会得到:
a
a
b
因为写入和更新文件偏移量的非原子组合。
但是,这个例子与 Git 的情况的区别在于,在这个例子中只有一个文件描述符。然而,在 Git 的情况下,由于每个进程都独立调用 open
,所以有两个文件描述符。一个更类似的 shell 脚本可能是:
((echo "a"; sleep 1; echo "b") > test & (echo "a"; sleep 1; echo "b") > test)
其内容(据我所知)无疑是:
a
b
现在,POSIX 实际上并没有说明如果两个写入相同偏移量相同内容的情况会发生什么。然而,简单的测试似乎表明,Linux 和 ext3 能够更强地保证写入相同值不会导致随机损坏(注意,如果文件的内容不同,则可能会有任何组合,这是实际中的情况)。
Hails: 在不受信任的 Web 应用程序中保护数据隐私:ezyang's 博客
来源:
blog.ezyang.com/2012/10/hails-protecting-data-privacy-in-untrusted-web-applications/
这篇文章是从 Deian Stefan 在 OSDI 2012 上为 Hails 发表的演讲改编而来。
它是一个广为人知的真理,任何网站(例如 Facebook)都渴望一个 Web 平台(例如 Facebook API)。Web 平台是很棒的,因为它们允许第三方开发者构建能够操作我们个人数据的应用程序。
但 Web 平台也是可怕的。毕竟,它们允许第三方开发者构建能够操作我们个人数据的应用程序。据我们所知,他们可能会将我们的电子邮件地址出售给垃圾邮件发送者或窥探我们的个人消息。随着第三方应用程序的普及,窃取个人数据几乎变得微不足道。即使我们假设所有开发者都抱着最好的意图,我们仍然必须担心那些不理解(或不关心)安全性的开发者。
当这些第三方应用程序存在于不受信任的服务器上时,我们无能为力:一旦信息泄露,第三方就可以随心所欲地做任何事情。为了减轻这种情况,像 Facebook 这样的平台采用了 CYA(“自我保护”)的方法:
Hails 项目的论点是我们可以做得更好。以下是如何实现的:
首先,第三方应用程序必须托管在一个受信任的运行时上,以便我们可以在软件中强制执行安全策略。至少,这意味着我们需要一种机制来运行不受信任的代码,并公开受信任的 API,例如数据库访问。Hails 使用Safe Haskell来实现和强制执行这样的 API。
接下来,我们需要一种方法在我们信任的运行时中指定安全策略。Hails 观察到大多数数据模型在相关对象中都内置了所有权信息。因此,一个策略可以被表示为一个对文档到可读人员标签集合和可写人员标签集合的函数。例如,“只有珍的朋友可以看她的邮箱地址”这个策略是一个函数,它接受一个代表用户的文档,并将文档的“朋友”字段作为有效读者的集合返回。我们称之为应用的 MP,因为它结合了模型和策略,并且我们提供了一个 DSL 来指定策略。策略往往非常简洁,更重要的是集中在一个地方,而不是散布在代码库中的多个条件语句中。
最后,即使在运行不受信任的代码时,我们也需要一种强制执行这些安全策略的方法。Hails 通过实现线程级动态信息流控制来实现这一点,利用 Haskell 的可编程分号来跟踪和执行信息流。如果第三方应用试图与 Bob 共享一些数据,但这些数据未标记为 Bob 可读取,运行时将引发异常。这种功能被称为LIO(标记输入输出),建立在 Safe Haskell 之上。
第三方应用运行在这三种机制之上,实现 Web 应用程序的视图和控制器(VC)组件。这些组件是完全不受信任的:即使它们存在安全漏洞或者是恶意的,运行时也会阻止它们泄露私人信息。您根本不需要考虑安全问题!这使得我们的系统甚至适合用于实现官方 VC。
我们开发的一个示例应用是GitStar,一个类似 GitHub 的 Git 项目托管网站。其主要区别在于,GitStar 的几乎所有功能都是通过第三方应用实现的,包括项目和用户管理、代码查看和 wiki。GitStar 仅仅为项目和用户提供了 MPs(模型策略)。其余组件都是不受信任的。
当前的 Web 平台让用户在功能和隐私之间做选择。Hails 让你两者兼得。Hails 已经成熟到可以在实际系统中使用;请访问www.gitstar.com/scs/hails
,或直接cabal install hails
查看。
Haskell:不够纯粹?:ezyang 的博客
Haskell:不够纯粹?
众所周知,unsafePerformIO
是一个邪恶的工具,通过它,不纯的效果可以进入本来纯洁的 Haskell 代码。但是 Haskell 的其余部分真的那么纯粹吗?这里有一些问题需要问:
-
maxBound :: Int
的值是多少? -
(\x y -> x / y == (3 / 7 :: Double))
,传入3
和7
作为参数时的值是多少? -
os :: String
的值来自System.Info
吗? -
foldr (+) 0 [1..100] :: Int
的值是多少?
对于这些问题的每一个答案都是模糊不清的——或者你可以说它们是明确定义的,但你需要一些额外的信息来确定实际结果。
-
Haskell 98 报告保证
Int
的值至少是-2²⁹
到2²⁹ - 1
。但是确切的值取决于你使用的 Haskell 实现(是否需要用于垃圾回收的位)以及你是在 32 位还是 64 位系统上。 -
根据浮点寄存器的过度精度是否用于计算除法,或者是否遵循 IEEE 标准,此等式可能成立也可能不成立。
-
程序运行的操作系统不同,此值将会改变。
-
程序在运行时分配的栈空间不同,可能会返回结果,也可能会栈溢出。
在某些方面,这些构造以有趣的方式破坏了引用透明性:虽然它们的值在程序的单次执行期间保证一致,但它们可能在我们程序的不同编译和运行时执行之间有所变化。
这个合理吗?如果不合理,我们应该怎么说这些 Haskell 程序的语义?
在#haskell
讨论了这个话题,我和一些参与者就此进行了热烈的讨论。我会尝试在这里总结一些观点。
-
数学学派认为所有这一切都非常不令人满意,他们的编程语言应该在所有编译和运行时执行中遵循一些精确的语义。人们应该使用任意大小的整数,如果需要模运算,要明确指定模数大小(
Int32
?Int64
?)。os
简直是该放在IO
罪恶箱中的一个悲剧。正如 tolkad 所说:“没有标准,你将迷失在未指定语义的海洋中。坚守规范的规则,否则你将被模糊性所吞噬。” 我们生活在的宇宙的局限性对数学家来说有些尴尬,但只要程序以一个漂亮的栈溢出崩溃,他们就愿意接受部分正确性的结果。一个有趣的子组是分布式系统学派,他们同样关心对计算环境所作的假设,但出于非常实际的原因。如果您的程序在异构机器上运行多个副本,则最好不要对传输中的指针大小做任何假设。 -
编译时学派认为数学方法在现实世界的编程中是不可行的:应该考虑编译编程。他们愿意在源代码程序中接受一些不确定性,但所有的歧义应该在程序编译后清除。如果他们感觉特别大胆,他们会根据编译时选项以多种含义编写程序。他们可以接受运行时确定的栈溢出,但对此也感到有些不舒服。这当然比
os
的情况要好,后者可能因运行时而异。数学家们用这样的例子取笑他们:“动态链接器或虚拟机怎么样,其中一些编译工作直到运行时才完成呢?” -
运行时学派说:“对执行间的引用透明度无所谓”,只关心程序运行期间的内部一致性。他们不仅可以接受栈溢出,还可以接受命令行参数设置全局(纯粹!)变量,因为这些在执行期间不会改变(也许他们认为
getArgs
的签名应该是[String]
而不是IO [String]
),或者不安全地读取外部数据文件的内容在程序启动时。他们在文档中写道:“这个整数在应用程序的一次执行到另一次执行之间不需要保持一致。”其他人都有些发抖,但大多数人在某个时候都会沉迷于这种罪恶的快感。
所以,你属于哪个学派呢?
附言. 由于 Rob Harper 最近发布了另一篇非常叛逆的博客文章,而且因为他的结尾言论与本文主题(纯度)有些关联,我觉得我忍不住要偷偷加上几句话。Rob Harper 说到:
那么为什么我们不默认这样做呢?因为这不是一个好主意。是的,起初听起来很美好,但后来你意识到这其实很可怕。一旦你进入 IO 单子,你就永远被困在那里,且被降为 Algol 风格的命令式编程。你不能轻易地在函数式和单子式风格之间转换,而不进行根本性的代码重构。而且你不可避免地需要使用 unsafePerformIO 来完成任何重要的工作。从实际角度来看,你失去了一个有用的概念——良性效应,这简直糟透了!
我认为 Harper 夸大了在 Haskell 中写函数式命令式程序的能力不足(从函数式到单子式的转换,在实践中确实很烦人,但相对来说是比较公式化的)。但这些实际上的关注确实影响了程序员的日常工作,正如我们在这里所看到的,纯度有各种各样的灰色阴影。在 Haskell 当前的情况上方和下方都有设计空间,但我认为认为纯度应该被完全放弃是错失了重点。
Haskell, The Hard Sell : ezyang 的博客
Haskell, The Hard Sell
上周我谈到了我们如何用等效的 Haskell 代码替换了一个小 C 程序。这里。尽管我很想说我们部署了代码,有很多欢呼和客户端缓存,但实际情况比这复杂得多。我们需要考虑一些非常好的问题:
-
在任何特定时间,有多少维护者知道这种语言? Scripts 项目由学生管理,并且具有异常高的人员流动率:任何给定的维护者只能保证在这里工作四到五年(如果他们留在城里可能会长一点,但除了一些显著的例外,大多数人在完成学生时代后就会离开)。这意味着在任何特定时点,我们都必须担心活跃贡献者的总知识是否足以涵盖系统的所有方面,而语言的熟练程度对于能够有效地管理组件至关重要(我们是学生,我们经常同时担任系统管理员和开发者的角色)。在企业环境中,这种情况不那么突出,但仍然起到作用:员工从一个组转移到另一个组,最终人们会离开或退休。我们目前有两位相当精通 Haskell 的维护者。这种方法的长期可持续性不确定,并且取决于我们能否吸引已经了解或有兴趣学习 Haskell 的潜在学生;在最坏的情况下,人们可能会打开代码,说“这到底是什么鬼”,然后用另一种语言重写它。
-
在任何特定时间,有多少维护者感觉在这种语言中能够轻松地进行编程? 虽然表面上类似于第一个观点,但实际上却大不相同;用不同的方式提出,这是“我能在这种语言中编写完整的程序”与“我能有效地对已写好的程序进行更改”的区别。在某种程度的流畅度上,程序员掌握了一项特殊的技能:能够查看任何 C/Fortran 衍生语言,并从周围的代码中获取他们需要的任何语法知识。这是学习语法和学习新编程范式的区别。我们可能不会同时是 Python/Perl/PHP/Java/Ruby/C 专家,但这些语言的经验相互促进,很多人都能在所有这些语言中拥有工作中的“黑客”知识。但 Haskell 是不同的:它的血统与 Lisp、Miranda 和 ML 相似,而命令式的知识无法转换。人们希望仍然能够理解任何给定的 Haskell 代码块做什么,但这仅限于只读能力。
-
还有谁在使用它? 对于团队的一名成员来说,从 Subversion 迁移到 Git 曾是一个非常难以推动的过程,但到目前为止,除了缺少进行迁移的正确基础设施外,他基本上已经被说服这是正确的前进方式。不过,这样做可以接受的一个重要原因是,他们能够列出一些他们经常使用的项目(Linux,我们的内核;AFS,我们的文件系统;Fedora,我们的发行版),这些项目也在使用 Git。但对于 Haskell 来说,我们无法这样说:Haskell 中“大”型的开源高可见应用程序是 Xmonad 和 Darcs,其中许多人从未使用过。作为一个学生团体,我们有更大的自由度来尝试新技术,但缺乏普及意味着更大的风险,而企业对这种风险过敏。
-
生态系统成熟吗? 在内部,我们对 Ruby 的维护者和打包者给予了很多批评,因为他们在向后兼容性方面的记录很糟糕(一次事件使我们无法全局更新我们的 Rails 实例,因为代码会在检测到版本不匹配时自动破坏网站)。在 Haskell 中也能看到一些相似的情况:static-cat 实际上不能在安装了默认软件包的 stock Fedora 11 服务器上构建,因为旧版本的 cgi 模块使用了异常的向后兼容包装器,因此与程序中其他异常处理代码不兼容。进一步的调查发现,cgi 模块实际上并没有在积极维护,并且 Fedora 的
cabal2spec
脚本存在问题。我个人也曾经有过这样的经历,从 Hackage 获得最新库的 Haskell 代码不再编译,因为 API 的漂移使得我的代码无法编译。Cabal install 拒绝一次性升级所有包。有许多解决方法。一个缓解因素是,一旦编译了 Haskell 程序,你就不必再担心包的组合问题了。解决方法包括重写我们的代码以前向和后向兼容,对我们的服务器做愚蠢的 Fedora 打包技巧以使 cgi 的两个版本同时存在,说服上游他们确实希望接受新版本,或者维护一个单独的系统范围 cabal 安装。但这并不理想,会让人产生疑问。
我非常幸运能在一个第一点真正重要的环境中工作。我们可以引入 Haskell 到代码库中,并期望长期维护吗?团队中总会有 C 语言的黑客(或者至少应该有;我们一些最重要的安全属性包含在对内核模块的补丁中),但团队中是否总会有 Haskell 的黑客?对于这个问题真的没有一个确切的答案。
我个人保持乐观态度。这是一次实验,你不会在这种环境下有更好的机会让事情发生。Haskell 代码的存在可能会吸引到项目的贡献者,这些贡献者可能最初并不是因为我们是社区的“免费共享网络托管提供者”而被吸引的。Haskell 似乎是唯一一个能够打破主流的语言(抱歉 Simon!),而且在没有一点风险的情况下,哪里会有创新呢?
Coq 程序员的 Haskell:ezyang 的博客
所以你可能听说过这个流行的新编程语言叫做 Haskell。Haskell 是什么?Haskell 是一种非依赖类型的编程语言,支持一般递归、类型推断和内置副作用。诚然,依赖类型被认为是现代、表现力强的类型系统的一个基本组成部分。然而,放弃依赖性可能会对软件工程的其他方面带来某些好处,本文将讨论 Haskell 为支持这些变化而做出的省略。
语法
在本文中,我们将指出 Coq 和 Haskell 之间的一些句法差异。首先,我们注意到在 Coq 中,类型用单冒号表示(false : Bool
);而在 Haskell 中,使用双冒号(False :: Bool
)。此外,Haskell 有一个句法限制,构造子必须大写,而变量必须小写。
类似于我的OCaml for Haskellers文章,代码片段将采用以下形式:
(* Coq *)
{- Haskell -}
宇宙/类型分类
宇宙是一种其元素为类型的类型。最初由 Per Martin-Löf 引入构造型理论。Coq 拥有无限的宇宙层次结构(例如,Type (* 0 *) : Type (* 1 *)
,Type (* 1 *) : Type (* 2 *)
等)。
因此,很容易将宇宙与 Haskell 类型的*
(发音为“star”)之间的类比,这种类型分类方式与 Coq 中的Type (* 0 *)
类似原始类型。此外,box类别也可以分类种类(* : BOX
),尽管这种类别严格来说是内部的,不能在源语言中书写。然而,这里的相似之处仅仅是表面的:把 Haskell 看作只有两个宇宙的语言是误导性的。这些差异可以总结如下:
-
在 Coq 中,宇宙纯粹作为一个尺寸机制使用,以防止创建过大的类型。在 Haskell 中,类型和种类兼具以强制阶段区分:如果
a
的种类是*
,那么x :: a
保证是一个运行时值;同样地,如果k
具有 box 类别,那么a :: k
保证是一个编译时值。这种结构是传统编程语言中的常见模式,尽管像Conor McBride这样的知识渊博的人认为,最终这是一个设计错误,因为不真正需要种类化系统来进行类型擦除。 -
在 Coq 中,宇宙是累积的:具有类型
Type (* 0 *)
的术语也具有类型Type (* 1 *)
。在 Haskell 中,类型和种类之间没有累积性:如果Nat
是一个类型(即具有类型*
),它不会自动成为一种。然而,在某些情况下,可以使用datatype promotion实现部分累积性,它构造了类型级别的构造函数的独立种级别副本,其中数据构造函数现在是类型级别的构造函数。提升还能够将类型构造函数提升为种构造函数。 -
在 Coq 中,所有级别的宇宙都使用共同的术语语言。在 Haskell 中,有三种不同的语言:用于处理基本术语(运行时值)的语言,用于处理类型级术语(例如类型和类型构造函数)的语言,以及用于处理种级术语的语言。在某些情况下,此语法是重载的,但在后续章节中,我们经常需要说明如何在种系统的每个级别上单独制定构造。
进一步说明:在 Coq 中,Type
是预测的;在 Haskell 中,*
是非预测的,遵循 System F 和 lambda 立方体中其他语言的传统,在这些风格的种系统中,这种类型的系统易于建模。
函数类型
在 Coq 中,给定两种类型A
和B
,我们可以构造类型A -> B
表示从 A 到 B 的函数(对于任何宇宙的 A 和 B)。与 Coq 类似,使用柯里化本地支持具有多个参数的函数。Haskell 支持类型(Int -> Int
)和种类(* -> *
,通常称为类型构造器)的函数类型,并通过并置应用(例如f x
)。(函数类型被 pi 类型所包含,但我们将此讨论推迟到以后。)然而,Haskell 对如何构造函数有一些限制,并在处理类型和种类时使用不同的语法:
对于表达式(类型为a -> b
,其中a, b :: *
),支持直接定义和 lambda。直接定义以等式风格书写:
Definition f x := x + x.
f x = x + x
而 lambda 使用反斜杠表示:
fun x => x + x
\x -> x + x
对于类型族(类型为k1 -> k2
,其中k1
和k2
是种类),不支持 lambda 语法。实际上,在类型级别不允许高阶行为;虽然我们可以直接定义适当种类的类型函数,但最终,这些函数必须完全应用,否则它们将被类型检查器拒绝。从实现的角度来看,省略类型 lambda 使得类型推断和检查变得更容易。
-
类型同义词:
Definition Endo A := A -> A.
type Endo a = a -> a
类型同义词在语义上等同于它们的扩展。正如在介绍中提到的,它们不能被部分应用。最初,它们旨在作为一种有限的语法机制,使类型签名更易读。
-
封闭类型(同义词)族:
Inductive fcode := | intcode : fcode | anycode : fcode. Definition interp (c : fcode) : Type := match c with | intcode -> bool | anycode -> char end.
type family F a where F Int = Bool F a = Char
尽管封闭类型家族看起来像是类型案例的添加(并且可能会违反参数性),但实际情况并非如此,因为封闭类型家族只能返回类型。事实上,封闭类型家族对应于 Coq 中的一个众所周知的设计模式,其中编写表示类型代码的归纳数据类型,然后具有解释函数,将代码解释为实际类型。正如我们之前所述,Haskell 没有直接的机制来定义类型上的函数,因此必须直接在类型家族功能中支持这种有用的模式。再次强调,封闭类型家族不能部分应用。
实际上,封闭类型家族的功能性比归纳代码更具表现力。特别是,封闭类型家族支持非线性模式匹配(
F a a = Int
),有时可以在没有 iota 缩减可用时减少术语,因为一些输入是未知的。其原因是封闭类型家族使用统一和约束求解进行“评估”,而不是像 Coq 中的代码那样进行普通术语缩减。事实上,在 Haskell 中进行的几乎所有“类型级计算”实际上只是约束求解。封闭类型家族尚未在 GHC 的发布版本中可用,但有一篇Haskell 维基页面详细描述了封闭类型家族。 -
开放类型(同义词)家族:
(* Not directly supported in Coq *)
type family F a type instance F Int = Char type instance F Char = Int
与封闭类型家族不同,开放类型家族在开放的宇宙中运行,在 Coq 中没有类似物。开放类型家族不支持非线性匹配,并且必须完全统一以减少。此外,在维持可决定类型推断的情况下,左侧和右侧的这类家族还有一些限制。GHC 手册的部分类型实例声明详细说明了这些限制。
封闭和类型级家族均可用于在数据构造函数的类型级别上实现计算,这些函数通过提升转换到了类型级别。不幸的是,任何此类算法必须实现两次:一次在表达级别,一次在类型级别。使用元编程可以减少一些必要的样板代码;例如,请参阅singletons库。
依赖函数类型(Π-类型)
Π-类型是一个函数类型,其目标类型可以根据应用函数的域中的元素而变化。在任何有意义的意义上,Haskell 都没有Π-类型。然而,如果您仅想单纯地使用Π-类型进行多态性,Haskell 确实支持。对于类型的多态性(例如具有类型forall a : k, a -> a
,其中k
是一种类型),Haskell 有一个技巧:
Definition id : forall (A : Type), A -> A := fun A => fun x => x.
id :: a -> a
id = \x -> x
特别是,在 Haskell 中,标准的表示法是省略类型 lambda(在表达级别)和量化(在类型级别)。可以使用显式的全称量化扩展来恢复类型级别的量化:
id :: forall a. a -> a
然而,没有办法直接显式地声明类型 lambda。当量化不在顶层时,Haskell 需要一个明确的类型签名,并在正确的位置放置量化。这需要排名-2(或排名-n,取决于嵌套)多态性扩展:
Definition f : (forall A, A -> A) -> bool := fun g => g bool true.
f :: (forall a. a -> a) -> Bool
f g = g True
类型级别的多态性也可以使用 kind polymorphism extension 支持。然而,对于种类变量,没有显式的 forall;你只需在种类签名中提到一种种类变量。
不能直接支持适当的依赖类型,但可以通过首先将数据类型从表达级别提升到类型级别来模拟它们。然后使用运行时数据结构称为单例来将运行时模式匹配的结果细化为类型信息。这种在 Haskell 中的编程模式并不标准,尽管最近有学术论文描述了如何使用它。其中特别好的一篇是 Hasochism: The Pleasure and Pain of Dependently Typed Haskell Program,由 Sam Lindley 和 Conor McBride 编写。
乘积类型
Coq 支持类型之间的笛卡尔乘积,以及一个称为空元的空乘类型。非常类似的构造也实现在 Haskell 标准库中:
(true, false) : bool * bool
(True, False) :: (Bool, Bool)
tt : unit
() :: ()
对偶可以通过模式匹配来解构:
match p with
| (x, y) => ...
end
case p of
(x, y) -> ...
有血性的类型理论家可能会对这种认同提出异议:特别是,Haskell 的默认对偶类型被认为是一个负类型,因为它对其值是惰性的。(更多内容请参阅polarity。)由于 Coq 的对偶类型是归纳定义的,即正的,更准确的认同应该是与严格对偶类型,定义为 data SPair a b = SPair !a !b
;即,在构造时,两个参数都被评估。这种区别在 Coq 中很难看到,因为正对偶和负对偶在逻辑上是等价的,而 Coq 并不区分它们。(作为一种总语言,它对评估策略的选择是漠不关心的。)此外,在进行代码提取时,将对偶类型提取为它们的惰性变体是相对常见的做法。
依赖对偶类型(Σ-类型)
依赖对偶类型是将乘积类型推广为依赖形式的一般化。与之前一样,Σ-类型不能直接表达,除非第一个分量是一个类型。在这种情况下,有一种利用数据类型的编码技巧,可以用来表达所谓的存在类型:
Definition p := exist bool not : { A : Type & A -> bool }
data Ex = forall a. Ex (a -> Bool)
p = Ex not
正如在多态性的情况下一样,依赖对的类型参数是隐式的。可以通过适当放置的类型注释来显式指定它。
递归
在 Coq 中,所有递归函数必须有一个结构上递减的参数,以确保所有函数都终止。在 Haskell 中,这个限制在表达级别上被解除了;结果是,表达级函数可能不会终止。在类型级别上,默认情况下,Haskell 强制执行类型级计算是可判定的。但是,可以使用UndecidableInstances
标志解除此限制。通常认为不可判定的实例不能用于违反类型安全性,因为非终止实例只会导致编译器无限循环,并且由于在 Haskell 中,类型不能(直接)引起运行时行为的改变。
归纳类型/递归类型
在 Coq 中,可以定义归纳数据类型。Haskell 有一个类似的机制来定义数据类型,但是有许多重要的区别,这导致许多人避免在 Haskell 数据类型中使用 归纳数据类型 这个术语(尽管对于 Haskeller 来说使用这个术语是相当普遍的)。
在两种语言中都可以轻松定义基本类型,例如布尔值(在所有情况下,我们将使用Haskell 数据类型扩展中的 GADT 语法,因为它更接近 Coq 的语法形式,且严格更强大):
Inductive bool : Type :=
| true : bool
| false : bool.
data Bool :: * where
True :: Bool
False :: Bool
两者也支持正在定义的类型的递归出现:
Inductive nat : Type :=
| z : nat
| s : nat -> nat.
data Nat :: * where
Z :: Nat
S :: Nat -> Nat
但是必须小心:我们在 Haskell 中对 Nat
的定义接受了一个额外的术语:无穷大(一个无限的后继链)。这类似于产品的情况,并且源于 Haskell 是惰性的这一事实。
Haskell 的数据类型支持参数,但这些参数只能是类型,而不能是值。(尽管,记住数据类型可以提升到类型级别)。因此,可以定义向量的标准类型族,假设适当的类型级 nat(通常情况下,显式的 forall 已被省略):
Inductive vec (A : Type) : nat -> Type :=
| vnil : vec A 0
| vcons : forall n, A -> vec A n -> vec A (S n)
data Vec :: Nat -> * -> * where
VNil :: Vec Z a
VCons :: a -> Vec n a -> Vec (S n) a
由于类型级λ不支持,但数据类型的部分应用是支持的(与类型族相反),因此必须谨慎选择类型中参数的顺序。(可以定义类型级的 flip,但不能部分应用它。)
Haskell 数据类型定义不具有 严格正性要求,因为我们不要求终止;因此,可以编写在 Coq 中不允许的奇怪的数据类型:
data Free f a where
Free :: f (Free f a) -> Free f a
Pure :: a -> Free f a
data Mu f where
Roll :: f (Mu f) -> Mu f
推断
Coq 支持请求通过统一引擎推断术语,可以通过在上下文中放置下划线或将参数指定为 implicit(在 Coq 中实现像 Haskell 中看到的省略多态函数的类型参数)。通常不可能期望在依赖类型语言中解决所有推断问题,Coq 的统一引擎(复数!)的内部工作被认为是黑魔法(别担心,受信任的内核将验证推断的参数是类型良好的)。
Haskell 如同 Haskell'98 规定的那样,在 Hindley-Milner 下享有主类型和完整类型推断。然而,为了恢复 Coq 所享有的许多高级特性,Haskell 添加了许多扩展,这些扩展不易适应于 Hindley-Milner,包括类型类约束、多参数类型类、GADTs 和类型族。当前的最新算法是一种名为 OutsideIn(X) 的算法。使用这些特性,没有完整性保证。然而,如果推断算法接受一个定义,那么该定义具有一个主类型,并且该类型就是算法找到的类型。
结论
这篇文章最初是在 OPLSS'13 开玩笑时开始的,我在那里发现自己向 Jason Gross 解释了 Haskell 类型系统的一些复杂方面。它的构建曾经中断了一段时间,但后来我意识到我可以按照同伦类型论书的第一章的模式来构建这篇文章。虽然我不确定这篇文档对学习 Haskell 有多大帮助,但我认为它提出了一种非常有趣的方式来组织 Haskell 更复杂的类型系统特性。合适的依赖类型更简单吗?当然是。但考虑到 Haskell 在大多数现有依赖类型语言之外的地方更进一步,这也值得思考。
后记
Bob Harper 在 Twitter 上抱怨,指出这篇文章在某些情况下提出了误导性的类比。我尝试修正了他的一些评论,但在某些情况下我无法推测出他评论的全部内容。我邀请读者看看是否能回答以下问题:
-
由于阶段区分,Haskell 的 类型族 实际上不是像 Coq、Nuprl 或 Agda 那样的类型族。为什么?
-
这篇文章对推导(类型推断)和语义(类型结构)之间的区别感到困惑。这种困惑出现在哪里?
-
对种类的量化不同于对类型的量化。为什么?
Haskell Implementor’s Workshop ’14 : ezyang 的博客
来源:
blog.ezyang.com/2014/09/haskell-implementors-workshop-14/
Haskell Implementor’s Workshop ’14
今年在 ICFP 上,我们对Haskell 实现者工作坊的参与人数非常多(有时甚至是站立空间不足)。我很荣幸能够介绍我在夏季关于 Backpack 的工作。
你可以获取幻灯片或者观看演示本身(感谢 ICFP 组织者今年在视频上的高效率!)这次讲座在某种程度上与我的博文A taste of Cabalized Backpack有交集,但有更多图片,并且我也强调了我们未来发展的长期方向(也许有点过头了)。
HiW 上有很多非常好的讲座。以下是我个人的一些亮点:
Haskell.org 委员会:ezyang 的博客
Haskell.org 委员会
试过访问 haskell.org 发现它宕机了吗?现在你有人可以抱怨了:haskell.org 委员会已经成立,而我显然是其中一员。8-)
我们将首先要做的事情之一是将 haskell.org 从目前托管在耶鲁大学的服务器(目前的托管效果不错,但周末服务器将会宕机,直到周一才有人去启动它)迁移到一些专用硬件上。我必须承认,我作为委员会的一员却没有任何经验(或直接经验)来帮助进行维护工作,对此我感到有些不好意思,希望这种情况会很快改变。
帮助我们进行“无需重新安装 Cabal”的 Beta 测试:ezyang 博客
来源:
blog.ezyang.com/2015/08/help-us-beta-test-no-reinstall-cabal/
帮助我们进行“无需重新安装 Cabal”的 Beta 测试
在今年夏天,Vishal Agrawal 正在进行一个 GSoC 项目,将 Cabal 移动到更类似 Nix 的包管理系统中。更简单地说,他正在努力确保您将不会再从 cabal-install 中遇到这类错误:
Resolving dependencies...
In order, the following would be installed:
directory-1.2.1.0 (reinstall) changes: time-1.4.2 -> 1.5
process-1.2.1.0 (reinstall)
extra-1.0 (new package)
cabal: The following packages are likely to be broken by the reinstalls:
process-1.2.0.0
hoogle-4.2.35
haskell98-2.0.0.3
ghc-7.8.3
Cabal-1.22.0.0
...
但是,这些补丁改变了 Cabal 和 cabal-install 中许多复杂的部分,因此在将其合并到 Cabal HEAD 之前,有意愿的小白鼠帮助我们消除一些错误将非常有帮助。作为奖励,您将能够运行“无需重新安装 Cabal”:Cabal 永远 不会告诉您无法安装包,因为需要一些重新安装。
以下是您可以提供帮助的方式:
-
确保你正在运行 GHC 7.10。早期版本的 GHC 存在一个严格的限制,不允许你针对不同的依赖多次重新安装同一个包。(实际上,如果你能测试旧版本的 GHC 7.8,这将非常有用,主要是为了确保我们在这方面没有引入任何退化。)
-
git clone https://github.com/ezyang/cabal.git
(在我的测试中,我已经在 Vishal 的版本基础上添加了一些额外的修正补丁),然后git checkout cabal-no-pks
。 -
在
Cabal
和cabal-install
目录中,运行cabal install
。 -
尝试在没有沙盒的情况下构建项目,看看会发生什么!(在我的测试中,我曾尝试同时安装多个版本的 Yesod。)
在测试之前不需要清除您的包数据库。如果您完全破坏了您的 Haskell 安装(可能性不大,但确实可能发生),您可以使用旧版的 cabal-install
清理掉您的 .ghc
和 .cabal
目录(不要忘记保存您的 .cabal/config
文件),然后重新引导安装。
请在此处报告问题,或者在 Cabal 跟踪器中的此 PR 中报告。或者下周在 ICFP 会议上与我面对面交流。 😃
高性能单子:ezyang 的博客
延续以其难以使用而闻名:它们是函数式编程世界中的“goto”。它们可以搞砸或者做出惊人的事情(毕竟,异常不过是一个结构良好的非本地 goto)。本文适合那些对延续有一定了解但怀疑它们能否用于日常编程任务的读者:我想展示延续如何让我们以一种相当系统的方式定义高性能单子,如逻辑单子。一个(可能)相关的帖子是所有单子之母。
> import Prelude hiding (Maybe(..), maybe)
我们将从一个热身开始:身份单子。
> data Id a = Id a
> instance Monad Id where
> Id x >>= f = f x
> return = Id
这个单子的延续传递风格(CPS)版本是您的标准Cont
单子,但没有定义callCC
。
> data IdCPS r a = IdCPS { runIdCPS :: (a -> r) -> r }
> instance Monad (IdCPS r) where
> IdCPS c >>= f =
> IdCPS (\k -> c (\a -> runIdCPS (f a) k))
> return x = IdCPS (\k -> k x)
虽然解释 CPS 不在本文的范围内,但我想指出这个翻译中的一些习语,我们将在一些更高级的单子中重复使用它们。
-
为了“提取”
c
的值,我们传递了一个 lambda(\a -> ...)
,其中a
是c
计算的结果。 -
只有一个成功的延续
k :: a -> r
,它总是最终被使用。在绑定的情况下,它被传递给runIdCPS
,在返回的情况下,它被直接调用。在后续的单子中,我们会有更多的延续漂浮。
顺着单子教程的步伐,下一步是看看那古老的 Maybe 数据类型及其相关的单子实例。
> data Maybe a = Nothing | Just a
> instance Monad Maybe where
> Just x >>= f = f x
> Nothing >>= f = Nothing
> return = Just
在实现此单子的 CPS 版本时,我们将需要两个延续:一个成功的延续(sk
)和一个失败的延续(fk
)。
> newtype MaybeCPS r a = MaybeCPS { runMaybeCPS :: (a -> r) -> r -> r }
> instance Monad (MaybeCPS r) where
> MaybeCPS c >>= f =
> MaybeCPS (\sk fk -> c (\a -> runMaybeCPS (f a) sk fk) fk)
> return x = MaybeCPS (\sk fk -> sk x)
将此单子与IdCPS
进行比较:你会注意到它们非常相似。实际上,如果我们从代码中消除所有关于fk
的提及,它们将是相同的!我们的单子实例大力支持成功。但是如果我们添加以下函数,情况就会改变:
> nothingCPS = MaybeCPS (\_ fk -> fk)
此函数忽略了成功的延续并调用失败的延续:你应该确信一旦调用失败的延续,它立即退出MaybeCPS
计算。(提示:看看我们运行MaybeCPS
延续的任何情况:我们为失败延续传递了什么?我们为成功延续传递了什么?)
为了更好地说明,我们还可以定义:
> justCPS x = MaybeCPS (\sk _ -> sk x)
其实这只是伪装的return
。
您可能还会注意到我们的MaybeCPS
新类型的签名与maybe
“析构”函数的签名非常相似,因此被称为它破坏了数据结构:
> maybe :: Maybe a -> (a -> r) -> r -> r
> maybe m sk fk =
> case m of
> Just a -> sk a
> Nothing -> fk
(为了教学目的,类型已重新排序。)我特意将“默认值”命名为fk
:它们是同一回事!
> monadicAddition mx my = do
> x <- mx
> y <- my
> return (x + y)
> maybeTest = maybe (monadicAddition (Just 2) Nothing) print (return ())
> maybeCPSTest = runMaybeCPS (monadicAddition (return 2) nothingCPS) print (return ())
这两段代码的最终结果相同。然而,maybeTest
在单子部分内部构造了一个 Maybe
数据结构,然后再次拆除它。runMaybeCPS
则完全跳过了这个过程:这就是 CPS 转换获得性能优势的地方:没有数据结构的构建和拆除。
现在,公平地说原始的 Maybe 单子,在许多情况下 GHC 会为您执行此转换。因为代数数据类型鼓励创建大量小数据结构,GHC 将尽最大努力确定何时创建数据结构,然后立即拆除它,从而优化掉这种浪费的行为。前进!
列表单子(也称为“流”单子)编码了非确定性。
> data List a = Nil | Cons a (List a)
> instance Monad List where
> Nil >>= _ = Nil
> Cons x xs >>= f = append (f x) (xs >>= f)
> return x = Cons x Nil
> append Nil ys = ys
> append (Cons x xs) ys = Cons x (append xs ys)
Nil
本质上等同于 Nothing
,因此我们的失败延续再次出现。但是,我们必须稍微不同地处理我们的成功延续:虽然我们可以简单地传递列表的第一个 Cons
的值给它,但这将阻止我们继续处理列表的其余部分。因此,我们需要向成功延续传递一个恢复延续 (rk
),以便在需要时继续其路径。
> newtype LogicCPS r a = LogicCPS { runLogicCPS :: (a -> r -> r) -> r -> r }
> instance Monad (LogicCPS r) where
> LogicCPS c >>= f =
> LogicCPS (\sk fk -> c (\a rk -> runLogicCPS (f a) sk rk) fk)
> return x = LogicCPS (\sk fk -> sk x fk)
请记住,return
生成的是单元素列表,因此没有更多的内容可以继续,我们将成功的延续 fk
作为恢复的延续。
旧的数据构造函数也可以进行 CPS 变换:nilCPS
看起来就像 nothingCPS
。consCPS
调用成功的延续,并且需要生成一个恢复的延续,这恰好可以通过它的第二个参数来方便地完成:
> nilCPS =
> LogicCPS (\_ fk -> fk)
> consCPS x (LogicCPS c) =
> LogicCPS (\sk fk -> sk x (c sk fk))
> appendCPS (LogicCPS cl) (LogicCPS cr) =
> LogicCPS (\sk fk -> cl sk (cr sk fk))
这些类型看起来应该非常熟悉。稍微调整一下这种类型(并将 b 重命名为 r):
foldr :: (a -> b -> b) -> b -> [a] -> b
我得到:
fold :: List a -> (a -> r -> r) -> r -> r
嘿,这是我的延续。所以我们所做的一切就是一个折叠操作,只是没有实际构造列表!
敏锐的读者可能也会注意到,列表的 CPS 表达式仅仅是列表的高阶 Church 编码。
在几个方面,CPS 转换后的列表单子赢得了巨大的优势:我们从不需要构造和拆除列表,并且连接两个列表只需 O(1)
时间。
最后一个例子:树叶单子(来自 Edward Kmett 的指示树幻灯片):
> data Leafy a = Leaf a | Fork (Leafy a) (Leafy a)
> instance Monad Leafy where
> Leaf a >>= f = f a
> Fork l r >>= f = Fork (l >>= f) (r >>= f)
> return a = Leaf a
事实证明,如果我们想要对这种数据类型进行折叠,我们可以重用 LogicCPS
:
> leafCPS x = return x
> forkCPS l r = appendCPS l r
要反向进行操作,如果我们结合到目前为止定义的所有关于逻辑的 CPS 操作,并将它们转换回数据类型,我们将得到一个可连接的列表:
> data Catenable a = Append (Catenable a) (Catenable a) | List (List a)
总结,我们已经表明,当我们构建一个大数据结构,只有在完成时才会被销毁时,我们最好将这两个过程合并,并且将我们的数据结构重新转换回代码。类似地,如果我们想对我们的数据结构执行“数据结构”-样的操作,实际上构建它可能更好:像tail
这样的 Church 编码因其效率低下而臭名昭著。我并未讨论编码某种状态的单子:在许多方面,它们与控制流单子是不同类别的(或许更准确地说“Cont 是所有控制流单子的鼻祖”)。
引用《星球大战》,下次当你发现自己陷入连续操作的混乱中时,使用数据结构!
附录. CPS(Continuation Passing Style)转换数据结构遍历与单子(monads)无关。你可以对任何东西进行这种操作。恰巧控制流单子的杀手级特性——非确定性,正好从这种转换中受益良多。
参考. 这个主题已经有大量现有的讨论。
我可能还错过了其他显而易见的一些内容。
Hindley-Milner with top-level existentials:ezyang's 博客
来源:
blog.ezyang.com/2016/04/hindley-milner-with-top-level-existentials/
内容警示:这是一篇半成品的研究文章。
摘要. 存在类型的顶层解包很容易集成到 Hindley-Milner 类型推断中。Haskell 应该支持它们。这个想法也可能适用于存在的内部绑定(例如 F-ing 模块),但我还没有想出如何实现。
更新. 而 UHC 是第一个做到这一点的!
更新 2. 并且 rank-2 类型推断是可判定的(而 rank-1 的存在类型是一个更弱的系统),尽管 rank-2 推断的算法需要半一致化。
背景
Hindley-Milner 与 System F 的区别。 尽管在非正式讨论中,Hindley-Milner 常被描述为“类型推断算法”,但其实它应该被正确地描述为比 System F 更为限制的类型系统。两种类型系统都通过变量的全称量化来实现多态性,但在 System F 中,这种多态性是显式的,并且可以出现在任何地方;而在 Hindley-Milner 中,多态性是隐式的,只能发生在“顶层”(在所谓的“多态类型”或“类型方案”中)。这种多态性的限制是使得 Hindley-Milner 的推断(通过算法 W)成为可判定(和实际可行)的关键,而 System F 的推断则是不可判定的。
-- Hindley Milner
id :: a -> a
id = λx. x
-- System F
id :: ∀a. a -> a
id = Λa. λ(x : a). x
System F 中的存在类型。 System F 的一个常见泛化是配备存在类型:
Types τ ::= ... | ∃a. τ
Terms e ::= ... | pack <τ, e>_τ | unpack <a, x> = e in e
在 System F 中,从技术上讲,不需要将存在类型作为原始概念添加进来,因为它们可以通过使用全称量词编码来实现,比如说 ∃a. τ = ∀r. (∀a. τ → r) → r
。
Hindley-Milner 中的存在类型? 这种策略对 Hindley-Milner 不起作用:编码需要更高阶的类型,而这恰恰是 Hindley-Milner 为了推断而排除的。
无论如何,试图推断存在类型是一个愚蠢的游戏:没有最佳类型!HM 总是为表达式推断出最一般的类型:例如,我们会推断 f :: a -> a
对于函数 f = \x -> x
,而不是 Int -> Int
。但数据抽象的整个点是选择一个更抽象的类型,这不会是最一般的类型,因此不会是唯一的。什么应该是抽象的,什么应该是具体的?只有用户知道。
Haskell 中的存在类型。 假设我们愿意在打包存在类型时写下显式类型,Hindley-Milner 是否能完成程序中其余类型的完整且可判定的推断呢?
Haskell 是一个存在(咳咳)证明,这是可行的。实际上,有两种方法可以实现它。第一种是当你搜索“Haskell 存在类型”时会看到的内容。
{-# LANGUAGE ExistentialQuantification #-}
data Ex f = forall a. Ex (f a)
pack :: f a -> Ex f
pack = Ex
unpack :: Ex f -> (forall a. f a -> r) -> r
unpack m k = case m of Ex x -> f x
Ex f
等同于 ∃a. f a
,类似于 System F 语法,它们可以通过Ex
构造函数打包,并通过模式匹配解包。
第二种方法是直接使用 Haskell 对于秩-n 类型的支持使用 System F 编码:
{-# LANGUAGE RankNTypes #-}
type Ex f = forall r. (forall a. f a -> r) -> r
pack :: f a -> Ex f
pack x = \k -> k x
unpack :: Ex f -> (forall a. f a -> r) -> r
unpack m k = m k
盒子类型论文展示了你可以进行推断,只要你的所有高阶类型都有注解。尽管如此,或许事情并不像希望的那样简单,因为不可预测的类型是 GHC 类型检查器中常见的 bug 源。
问题
显式解包很糟糕。 正如任何试图在 Haskell 中使用存在类型编程的人所能证明的那样,由于需要在使用之前对存在类型进行解包(即对其进行模式匹配),使用存在类型仍然可能相当笨拙。也就是说,语法let Ex x = ... in ...
是不允许的,这是让 GHC 告诉你它的大脑爆炸的简单方法。
Leijen研究了如何处理存在类型无需显式解包。
没有显式解包会导致主类型的丢失,以及 Leijen 的解决方案。 不幸的是,天真的类型系统没有主类型。Leijen 给出了一个没有主类型的例子:
wrap :: forall a. a -> [a]
key :: exists b. Key b
-- What is the type of 'wrap key'?
-- [exists b. Key b]?
-- exists b. [key b]?
两种类型都不是对方的子类型。在他的论文中,Leijen 建议应尽可能晚地解包存在类型(因为你可以从第一种类型到第二种类型,但反之则不行),因此应优先选择第一种类型。
解决方案
另一种方法。 如果我们总是将存在类型提升到顶层会怎样?如果你将解包限制在程序的顶层,这实际上是非常容易做到的,并且结果非常好。
每个顶层的 Haskell 代数数据类型中都有一个存在类型。 首先,我想说服你这并不是一个那么奇怪的想法。为了做到这一点,我们看一下 Haskell 对代数数据类型的支持。Haskell 中的代数数据类型是生成的:每个数据类型必须有一个顶层声明,并且被认为是与任何其他数据类型不同的独立类型。事实上,Haskell 用户利用这种生成性与隐藏构造子的能力来实现数据抽象。尽管实际上并没有存在类型潜藏其中——生成性并不是数据抽象——生成性是数据抽象的一个重要部分,并且 HM 对此没有任何问题。
顶层生成性对应于在程序的顶层解包的存在量词(类似于 F-ing 模块)。 我们不需要存在于 Haskell 表达式内部来支持代数数据类型的生成性:我们所需要的只是在顶层包装一个存在类型,然后立即将其解包到顶层上下文中。事实上,F-ing 模块甚至走得更远:存在量词可以始终被提升,直到它们达到程序的顶层。使用适用函子(ML 类型)进行模块化编程可以通过立即解包作为其定义的顶层存在来编码。
建议。 因此,让我们建议以下类型系统,带有顶层存在量词(其中a*
表示零到多个类型变量):
Term variables ∈ f, x, y, z
Type variables ∈ a, b, c
Programs
prog ::= let f = e in prog
| seal (b*, f :: σ) = (τ*, e) in prog
| {- -}
Type schemes (polytypes)
σ ::= ∀a*. τ
Expressions
e ::= x
| \x -> e
| e e
Monotypes
τ ::= a
| τ -> τ
有一个新的顶层绑定形式,seal
。我们可以给出以下类型规则:
Γ ⊢ e :: τ₀[b* → τ*]
a* = free-vars(τ₀[b* → τ*])
Γ, b*, (f :: ∀a*. τ₀) ⊢ prog
---------------------------------------------
Γ ⊢ seal (b*, f :: ∀a*. τ₀) = (τ*, e) in prog
它还可以直接展开为带有存在量词的 System F:
seal (b*, f :: σ) = (τ*, e) in prog
===>
unpack <b*, f> = pack <τ*, e>_{∃b*. σ} in prog
几点观察:
-
在 HM 的传统呈现中,let 绑定允许嵌套在表达式内部(并且在添加到上下文之前被泛化为多态类型)。我们是否可以类似地处理
seal
?这应该是可能的,但绑定的存在量词类型变量必须向上传播。 -
这导致了第二个问题:天真地,量词的顺序必须是
∃b. ∀a. τ
而不是∀a. ∃b. τ
,否则我们无法将存在量词添加到顶层上下文。然而,存在“斯克莱姆化”技巧(参见 Shao 和 F-ing 模块),通过这种方式你可以将b
作为一个高阶类型变量,以a
作为参数,例如,∀a. ∃b. b
等价于∃b'. ∀a. b' a
。这个技巧可以作为支持内部seal
绑定的方式,但编码往往相当复杂(因为你必须闭合整个环境)。 -
这条规则并不适用于直接建模机器学习模块,因为“模块”通常被认为是多态函数的记录。也许你可以将这条规则概括为绑定多个多态函数?
结论。 到此为止,这就是我所想出来的。我希望有人能告诉我(1)谁已经提出了这个想法,以及(2)为什么它不起作用。
历史作为文档:ezyang 的博客
历史作为文档
这里提到的 real 和 easy argue 讨论的是源代码注释的实用性、风格和实现方式,这些注释在 pure code isn't enough 时尝试添加补充信息。
然而,仅仅关注特定源文件的最新快照,就会忽略文件内未包含的大量信息;即文件的历史和每一行的源流。这在快速原型功能时可能并不重要,因为源代码控制历史中的文件版本代表了不完整的、半成品的思想碎片,但一旦代码库过渡到更多的维护型工作流程,历史记录就显得尤为重要和不同寻常。特别是:
-
随着时间推移文件演变的日志可以说明模块的 original 意图,以及随后如何通过改装、扩展或修改进行调整。如果你需要重构其他人编写的代码,研究他们经历的修订版本是理解原始设计者思路的最佳方式。
-
任何特定行可能仅仅是初始检入时的环境代码的一部分,或者可能被针对某些问题的高度定位的提交所触及。在这种情况下,
git blame
的输出对于识别为何这一特定行可能特殊,或者为何一个细微不同的排列方式是不正确的,是非常相关的。在非局部性更改的情况下,将一行与提交关联起来可以让您快速理解一个操作如何与其他许多操作一起产生全局效果。
鼓励开发人员编写非常描述性的提交信息(手中有差异:绝不在手上没有差异时编写提交信息),以便未来可能查看日志的人使用。即使在内联注释中可能会略显啰嗦,也是可以接受的,因为:
-
日志信息永不过时:它们始终与所附的修订版本相关!
-
一个良好的提交信息有助于代码审查,因为它提供了更改的非正式规范,外部观察者可以拿来验证代码。否则,审阅者需要确定代码的预期语义和实际语义,不考虑风格问题。
最后,关于保持历史记录清晰易用的几句话:
-
逻辑上组织良好的补丁集意味着任何给定的更改立即与日志消息相关联。如果您提交了一个包含大量语义更改的大提交,读者必须消除哪些特定的语义更改与差异的哪个部分相关。有意识地花时间使用
git add -p
逐个暂存片段是绝对值得的。 -
制作高质量的差异,避免触及不必要的代码。高流量的邮件列表,如 LKML,接收大量补丁,已经发布了补丁提交指南,以使差异尽可能易读,以供可能的审阅者查看。即使您不需要说服一个反应激烈的上游采纳您的更改,以后您可能会关心您的差异。
-
格式上的更改极大地扰乱了
git blame
输出,因为它们导致一行被标记为已更改,尽管没有语义上的差异发生。如果必须,它们应该是严格单独的。不频繁地进行更改是最好的选择。 -
利用历史重写来实现廉价提交,稍后再进行润色以供提交。
HIW’18:让 Eta 走向主流!:ezyang’s 博客
来源:
blog.ezyang.com/2018/09/hiw18-lets-go-mainstream-with-eta/
HIW’18:让 Eta 走向主流!
我的名字是 Rahul Muttineni,TypeLead 的 CTO,致力于构建围绕一种名为 Eta 的语言的服务。为了开始,我将概述项目的起源和当前状态。
它起初是一个 HSOC 项目。当时它被称为 GHCVM;当时我们计划使其同时在 JVM 和 CLR 上运行...我们不再考虑 CLR 了。我由 Edward Kmett 指导。我们对此收到了非常好的反馈,所以 Jo 和我决定冒险全职工作在此上。
对 GHC 团队表示衷心的感谢,做得真的很好。我们已经与这个代码库一起工作了两年,随着工作的深入,我们看到了其中有多少令人惊叹的东西。通过与代码的互动,我学到了很多。
什么是 Eta?Eta 是 GHC 的一个分支。在 GSOC 项目期间,它起初是一个使用 GHC API 的 Haskell 程序。在项目的中途,我发现有些事情我想做但做不到,于是花了 3-4 天时间设置了一个分支。我会谈谈那些限制是什么。像 Haskell 一样,它是一种...语言,但其关键区别在于它在 JVM 上运行。这是它自己的一套挑战,主要是尾调用方面。Eta 的好处是它可以在 JVM 上运行,并且可以像那样运行大部分项目。最近,我们在上个月让 Yesod 运行起来了...它的状态很好。Eta 的下一个真正伟大的类型是强类型的 FFI。它与 JVM 中的子类型非常兼容,这个话题的大部分内容都是关于我们如何让它工作的。Eta 的一个主要关注点之一是专注于工业使用。GHC 则专注于工业使用和研究。两者之间存在紧张关系...对于 Eta 的好处是我们不必面对这种紧张关系;很容易做出决策来添加新功能,因为它会帮助公司吗?如果是的话,我们就会添加,否则不会。(SPJ:这可能是一个难以回答的问题!)
Haskell:不惜一切代价避免成功。我们不会为了好处而牺牲语言的核心原则。追求成功,以最小的成本。我们希望尽可能地成功,但是我们希望尽可能少地做出牺牲。这会有点棘手...
什么是 Eta?它支持哪些语言特性?它起初是 GHC 7.10.3 的一个分支。所有在那里工作的扩展,在 Eta 中同样适用。唯一的问题是 TemplateHaskell 和 QuasiQuotes 很长时间内都无法工作。我们在 3-4 个月前解决了这个问题。最大的变化是 JavaFFI。GHC 7.10.3 减去 C FFI。我们本来可以支持它:Java 有 JNI,但我们试图避免这样做,因为我们不想对所有的库做平台特定的绑定。
Joe 将一堆 GHC 8 的特性进行了回溯移植:StrictData、ApplicativeDo、OverloadedLabels。Backpack 是最近获取的。我们不得不这样做有一个非常特别的原因:这与我们默认没有绿色线程有关,我们希望给用户选择线程运行时与阻塞运行时的选择。
编译器?它是 GHC 的一个分支,所以所有编译器通道都是相同的。我们只是在 STG 之后截断了所有内容;例如,C--已经不存在了。我们从 STG 生成字节码。目前我们不进行任何优化,而且未来一段时间也不需要进行优化。我们不必这样做,因为在 JVM 中,它是即时编译的,所以我们不需要像以前那样进行大量优化,因为 JVM 会自动删除未使用的大量代码。关于驱动程序:GHC 生成对象文件...我们决定使用 JAR 文件。它们只是将大量存储 Java 字节码的类文件打包在一起的 ZIP 文件。我们还增加了一种 Uberjars 的模式。这些是将 JAR 文件打包成一个巨大的包。
我会简要谈一下我们如何实现 REPL;模板 Haskell。它通过外部解释器架构工作。在 GHC 中称为 iserv:该进程负责运行代码。因此,编译器仍将执行类型检查和一切工作,但一旦完成所有这些工作,GHC 将生成一个特定的字节码集,以便有效地解释 Haskell。因为我们已经生成了 JVM 字节码。我们不需要那个自定义的字节码集;我们只需关闭优化进行 JVM 字节码编译,然后将其发送到外部进程,加载并执行它们。实现 REPL 非常容易,如何让所有这些工作在一起。JVM 有一个称为类加载的机制,非常灵活。您可以从网络下载字节码,获取代码和运行时。一旦加载了类,它就是静态编译的代码,优化相同等等。
我们使用的构建工具是 Etlas。我们不想离开 GHC 太远,所以我们坚持使用 Cabal。在我们开始使用它的时候,我们从 Cabal 2.0 分支出来。主要区别在于它允许你管理 Eta 的版本。Etlas 几乎像 Stack,但它更接近 Cabal。我们将 Stack 的优秀特性加入到了 Cabal 中。另外一点是它进行补丁管理。随着我们添加更多功能并进行反向移植,我们发现 Eta 不完全是 GHC 7.10,也不是 GHC 8.0,它处于一种奇怪的中间状态,因此某些包在没有小改动的情况下不会精确编译,所以我们需要一些系统在实际运行构建之前应用这些改动。因此,我们设置了一个 GitHub 仓库来存储所有的补丁文件。Etlas 的工作方式是,它将为你获取最新的补丁集。然后如果你安装一个包,比如 lens,它将下载 lens,应用补丁,然后进行构建。就在最近,我们一直使用 base 4.8,最近升级到了 base 4.11。但我们无法更新到新的 Generics 架构,因为它会减慢编译时间。因此有一些包会检查它们是否是 GHC 8... 然后使用新的 Generics。所以我们必须为此做一些补丁。但这就是我们必须处理的那种事情。
这次讲座的标题是让 eta 成为主流。我想花一点时间说一下,这意味着什么?“被大多数人分享并视为正常或传统的思想、态度或活动。”那么编程语言在何时变得普遍或传统?它必须被大公司使用,解决大型现实世界问题,并且人们必须相信它有效。这是一个非常复杂的问题,多方面的,其中一部分答案是,它应该比现状更容易解决实际问题。举个例子,PHP 就是这样。PHP 是在没有更好的动态网页应用程序编程语言时出现的。它只具备了使其有用于构建这些应用程序所需的最低功能。现在大家都在问这个问题:Haskell 明显比现状更好地解决了许多问题。那么为什么它没有继续发展?这是一个很大的问题,我将讨论我们如何解决它。
我们内部使用的策略是戴上一顶“大公司的帽子”;我们假装自己是一个拥有大量员工、数百万或数十亿行代码的大公司,试图找出他们将面临的问题。一些问题包括构建庞大软件时的漫长构建时间,动态的情况需要确保初级开发人员能够快速上手... 等等。这是为了启动这场对话。
经过长时间的思考,我们将其简化为三个基本原则,我们将如何开发 Eta。
1. 用户体验
2. 性能
3. 安全性
用户体验主要是一种情感体验,当您使用 Eta 技术时的感受,您与之交互时的感觉,当您遇到错误时的感受,心理上的反应。当某物具有良好的用户体验时,我们感觉很好。这是非常主观的事情,不同的人可能有所不同,我们必须找到一种标准化/普遍化的方式。作为软件和工具开发者,我们有时会忘记,开发软件的人是人类。如果他们长时间内不断遇到错误,他们会感到沮丧。机器会一遍又一遍地执行您告诉它们要做的事情。
所以在 Eta 中,我们关注了什么?我们最近做了一些事情;它还没有在主分支中。Jo 和我花了一周时间重构了类型检查器中的错误报告部分。它存储了一个字符串列表;在 GHC 内部,有一个漂亮打印的数据类型,一个这样的列表。问题是我们不能在其上进行后处理。因此,Jo 做的是创建了一个巨大的数据类型,有三百个数据构造器,每个构造器对应 GHC 中的一个错误消息。那个重构花了一周(SPJ:只有一周?!)现在是这样的,它是解耦的,现在你有了,而不是在类型检查 monad 中存储字符串,你存储了一个数据类型,用于存储打印出错误消息所需的相关数据。然后在最后一点,您可以遍历数据类型;根据其他错误的存在,您可以决定要做什么。现在是在某些错误模式上进行模式匹配并进行良好的报告。这是一个例子。我们谈到了简单的错误:重构,添加参数,更改类型,这是您使用 Haskell 时可能遇到的最常见的错误之一。因此,我们首先专注于这些。这显示了一个类型错误的例子... '检查器',这是一个 IO 操作。
GHC 会告诉你,无法匹配 Int -> IO ()
和 IO ()
。问题在于,对于不了解类型检查器工作方式的人,他们无法理解类型检查器的操作:逐个参数进行。由于我们进行了重构,很容易在这种特定情况下进行模式匹配,并说,嘿,如果用户忘记放置一个参数,可以打印出这种形式的错误消息。你打印一个参数丢失,你突出显示。(SM: 在这种情况下,你可能错过了第一个参数!)这是真的。这很棘手;有时您提供的建议可能不会。我们不告诉人们他们到底做错了什么,因为我们不知道。这不是一件完美的事情,但我们试图给出我们能给出的最佳建议。这的一个重要特征,大多数我们决定这个布局的方式,我们研究了像 Elm 和 Purescript 这样的语言,它们在这种错误上做了很好的工作。PureScript 和 Elm,它们为一种特定类型的错误所做的事情,如果您不确定该怎么做……例如,我们的信息不完整,他们可以转到特定的链接并查看可能发生的其他事情。因此,我们不必向用户提供每一个建议,我们只需向用户显示可能是其原因的东西。如果是一个棘手的案例,不是我们发布的,我们也会在链接中提到该案例。
(BG: 还有其他可能相关的信息;扩展类型同义词等。你有这些信息吗?) 我们还在考虑这个问题。可能我们会有额外的标志和其他东西。最终,我们将有一个模式,用于 IDE 打印 JSON,这样在 IDE 端更容易解析。(BG: 顺便说一下,有一个学生和理查德一起工作,试图找出类似的东西。)
用户体验的另一个方面是我们添加了 REPL。试图简化入口点,试图使其更容易。你想要类型、种类以及如何找到更多信息。这是一种静态类型语言:你总是要考虑类型。所以我们:set +t:打印出你打印东西时的类型。还有一件事,前 Scala 工程师之一一直在学习 Haskell,并对 REPL 体验的一个方面提出了批评。f 是一个有两个参数的函数。在 REPL 的第二条语句中,我应用了 1. 找到实例,显示实例,对于 a 转到 a。他说……没有找到显示实例,只是说这是一个函数,你不能打印它。这是我们所做的改变。这对我们来说非常容易做到。
性能:它可以指很多事情。我们讨论的是快速的开发者反馈循环。编译时间和开发时间,减少这种反馈循环。在这方面我们做了一些工作是可重现构建。截至目前,我们在 Eta 中实现了 bit-for-bit 的可重现性。这相当于……尼基塔已经在可重现性方面做了大量工作,他使 Haskell 接口可重现;但最后一英里的 bit-for-bit 非常困难,有很多地方。对于我们的代码生成器来说,情况简单得多,我们不需要做太多工作。只需 20 行代码就能使其确定性。GHC 中的主要非确定性来源是 Unique 数据类型,它在不同的运行环境下会发生变化。我们所做的是添加了一个计数器。我们曾经在 Java 类名中打印 uniques;这将使其变得不确定。所以我们做了一个计数器:将绑定按照进入 STG 的顺序排列相同。
GHCi 以其占用大量内存而闻名,尤其是在使用 IDE 时。Simon Marlow 对此做了许多修复;我们也进行了这些修复的回溯。
性能的另一个方面是实际的运行时性能。我们使用的是 JVM,这让我们处于一个巨大的劣势。我们无法控制许多事情。运行时系统……这是 Java。它是面向对象的,所以运行时系统是用 Java 实现的。我们为在 Eta 中定义的值设置了一个层次结构。我们有一个 Closure 类,它是所有值、thunk、WNF 的父类。Closure 类有两个方法。evaluate,评估到 WHNF,enter 实际上会进入……这与 GHC 运行时系统相似。最初的版本完全模仿了 GHC,除了尾调用。术语是相似的。它主要用于函数主体时。Closure 的主要子类是 Thunk 和 Value。Value 将是诸如函数、部分应用函数和数据构造函数之类的事物的父类。Thunk 将是诸如 CAFs、单入口 thunk 和可更新 thunk 的超类。CAF 没有自由变量,因此有一个特例,每次创建一个 blackholing entry,以避免两个线程评估相同的 thunk。UpdatableThunk 在完成评估时会推送一个更新帧,将 thunk 更新为新计算的值。SingleEntryThunk 只评估一次,因此可以直接评估它而不推送更新帧。这些术语也是从 GHC 借来的。
VAlues: DataCon, Function and PAPs. 在早期,甚至现在,每次尾递归调用都只是一个方法调用。这是使其稍微有效的唯一方法。(关于堆栈的更多信息很快就会出现)。对于静态尾递归调用:单递归或互递归,它们会被编译成循环。在大多数情况下,它们会得到一个紧凑的循环。在互递归的情况下,会发生的是,我们收集所有的 SCC,并制作一个进入循环的巨大方法。假设你在偶数/奇数示例中,会发生的是,当偶数调用奇数时,有一个名为目标的变量,一个整数。偶数将被分配为 0,奇数分配为 1,然后设置 1 并重新启动。(BG: 您编译的函数总是有展开可用吗?)这是在同一模块中定义的互递归函数。(SPJ: 它们可能有非常不同的类型参数。)我们将所有参数连接成一个。这个参数的主要问题是,使用 Happy 和 Alex 生成的解析器,我们会遇到限制。(BG: 崩溃?)不是堆栈溢出。JVM 有方法大小限制,所以你只能有 65000 个字节码。这是 Eta 与自身编译的情况。这是唯一阻止我们使用 Eta 的东西。但你需要做的就是将方法分成更小的块。
那么我们如何处理尾调用?当我们知道它,尾递归,假设你不知道。假设你正在使用 CPS。在 Haskell 中,这是如此普遍,任何快速解析器都使用 CPS。在早期,Aeson 只会爆栈,情况非常糟糕。因此,我们默认探索了 trampolining,但那太糟糕了,非常慢。我们所做的是关闭它,让栈溢出。我们找到了一个更好的解决方案。JVM 有...卷栈的唯一方法是抛出异常,或者返回,并继续返回直到完全返回。结果是,通过异常,你可以关闭捕获堆栈跟踪的功能:这是异常中最昂贵的部分。所以我们有一个通用异常。因此,这种 trampoline 机制是可选的。所以,我们有一个函数'trampoline :: a -> a',运行时原语,它的作用是在上下文中激活一个布尔值,告诉它现在要跳跃,它激活一个代码路径来转换计数器,一旦达到一个可配置的特定数字,它将解开栈,然后继续它所需去的地方。我们的限制是 400,然后我们解开。它曾经在 1000 多个的时候,但是使用 Happy 和 Alex 后,我们需要一个更小的数字。(BG:在这种情况下,成本如何?但观察起来更快。几个月前,我们让 PureScript 在 Eta 上工作,并不是默认的坏?)(SPJ:因此,您可以默认启用它:您所做的只是计数。)计数是我们知道堆栈有多大的方式。在您的主函数中,您可以调用 trampolineIO,并使整个程序跳跃。(SPJ:也许开销很低,您可以随时这样做。)如果低,我们会这样做。(你如何恢复?一旦引发异常,你存储什么?)计数器发生在入口点,并由布尔值保护。因此,如果超出了限制,它将调用另一个接受上下文的函数。因此,我们将所有参数存储在传递给每个 eta 函数的上下文变量中。我们将所有参数存储在一个具有状态的函数中,然后当它解开时,由这个函数标记,它将使用该函数和这些参数调用那个。
如我所提到的,它由一个布尔值来保护。JVM 有一个优化,当它观察到布尔值在许多时刻为 true 时,它甚至不会编译本地代码中的该分支。因此,如果你不使用 trampolining,它对你没有任何影响;计数器的代码将不会存在。
我喜欢 Eta 的一个好处是,你实际上可以获得异常的堆栈跟踪。这是因为,为了让 Eta 有良好的性能,你必须在 JVM 栈上实现大多数原语。这是一个示例堆栈。你有一个调度循环,你正在评估一些 IO 行动。applyV/applyN,这些是部分应用。执行一个 IO 行动。还有一个不错的地方,我们尝试将其编码接近原始名称。因此,你可以看到这个函数调用发生在 statistics.Regression、rnfAll 中。如果你看到,你会注意到有行号。这并不完美,但我们肯定可以稍后做得更好…… GHC 在 STG 时间给了你很多调试信息,但因为 JVM 没有太多的灵活性,我们只能将一个行号附加到代码中,所以我们必须丢弃所有那些信息。这将会变得更好;我们将把调试信息存储在类文件本身中,然后访问它并呈现更好的堆栈跟踪。(BG:这是源注释吗?)是的。
并发性:一个好处是,它是好的还是不好的。如果你正在评估一长串 thunk,你将会耗尽栈空间。这恰好与 GHC 也有空间泄漏的情况相吻合。Neil Mitchell 写了一篇关于如何检测空间泄漏的博文:限制堆栈大小,然后找出正在评估的 thunk。如果你看到这样的堆栈跟踪,并且看到一个巨大的评估链,在一个长链中,你可能有一个空间泄漏。
我如何进行互操作?我们进行互操作的方式是,做了一个叫做 Java monad 的东西。它应该给你编程 JAva 的体验。基本实现受到 IO monad 的启发。Object# c 是“this”,即正在传递的对象。由于这种编码,你得到了 Java 的体验:你可以在 Java 对象上调用点操作符。几乎就像在内部使用 Java 一样。这个参数被称为……这是迫使我们分叉而不是使用 API 的类型构造器。你不能在 API 中声明原始类型。我们不得不引入一个新的低级表示。声明包装器类型,包装 Java 中的 iterable 接口。我们偷了更好的语法,这些是类型应用……以某种方式解决它。我正在声明一个 Eta 类型,包装一个 JAva 类型,@java.lang.Iterable。
你使用 java 函数来运行 Java monad。所有这些都必须被导入。newArrayList,newInteger,但我们引入了一些组合子,让你调用方法。它与 monad 相匹配。这是一个做与 Java 代码相同事情的示例代码。它只是使用标准的 monadic 组合子。如果它是一个固定的 c,那么它是一个实例。
你可以使用 Eta 作为更好的 JAva,具有引用透明性!与 Kotlin 或 Scala 不同。
如何处理子类型?我们通过内建类型家族来定义。我们有一个名为 extends 的类型类。每当你声明一个函数,它接受一个给定类及其任何子类型时,你可以使用约束条件而不是实际的子类型。extends 从 inherits 中获取信息并进行计算。你可以对任何 Iterator 子类使用点操作符。我们不得不稍微扩展类型检查器:很多时候,类型会被卡在 Extends' (List JSTring) (List a)的形式中,其中 a 是无约束的。
导入是令人厌烦的,所以我们正在设置直接的 Java 互操作性;实际上使用 Java 反射获取信息类文件,并生成导入。"import java java.lang.Math"虽然有效,但不可扩展。今年剩下的最重要的优先事项是 Java 互操作性,真正好的 IDE 支持,文档,语言扩展:UnboxedSums,TypeApplications,DerivingVia,QuantifiedConstraints。我们还有一些新的语言扩展在计划中,AnonymousRecords,RowTypePolymorphism...我们会看看会怎样。
我正在考虑方法……我们在同一个代码库上工作,如何进行协作?我们对编译性能感兴趣,支持 unboxed sums。Worker wrapper 有一些小问题,但还没有人来解决。在某个时候,也许不是很快的时候,还有可变字段。这对我们非常重要。(BG:Unboxed sums 使用频繁吗?为什么 unboxed sums?Eta 代码经常使用吗?)不,但很多 JVM 上的人对 Maybe 总是装箱感到恼火。但如果你有 unboxed sums,你可以将其表示为 null。(SPJ:或者你可以说,只需将其装箱,你就不会注意到它。如果始终如此快速,专注于能够产生差异的事物。)
Q: 你考虑过使用 Graal 吗(它是一个支持部分评估和部分逃逸分析的新虚拟机,非常适合函数语言)?
A: 我们研究过,但目前还不完全可以使用,我们也不确定是否值得投入时间。我们一直在关注它。(BG:但你失去了 JVM!)这是阻止我们前进的因素。也许如果它被集成到主流 VN 中,我们可能会考虑。 (主流 Java 正计划集成 Graal)
Q: (SM)你们是否将分支与主 GHC 保持更新?
A: 对于我们来说,有一件事情很难做到,长期以来,那就是所有依赖的 Haskell 工作。其他所有事情,我们都在跟进。如果有任何好的 bug 修复...(SM:所以你们是有选择性地回溯)。
Q: (BG)你考虑过解除 fork 吗?
A: 还没有,没有。
Hoopl:数据流分析:ezyang 的博客
一旦确定了你将收集的数据流事实,下一步就是编写实际执行此分析的传递函数!
记住你的数据流事实的含义,这一步应该相对容易:编写传递函数通常涉及浏览语言中的每个可能语句,并思考它如何改变你的状态。我们将详细介绍常量传播和活跃性分析的传递函数。
这是活跃性分析的传递函数(再次在Live.hs
中):
liveness :: BwdTransfer Insn Live
liveness = mkBTransfer live
where
live :: Insn e x -> Fact x Live -> Live
live (Label _) f = f
live n@(Assign x _) f = addUses (S.delete x f) n
live n@(Store _ _) f = addUses f n
live n@(Branch l) f = addUses (fact f l) n
live n@(Cond _ tl fl) f = addUses (fact f tl `S.union` fact f fl) n
live n@(Call vs _ _ l) f = addUses (fact f l `S.difference` S.fromList vs) n
live n@(Return _) _ = addUses (fact_bot liveLattice) n
fact :: FactBase (S.Set Var) -> Label -> Live
fact f l = fromMaybe S.empty $ lookupFact l f
addUses :: S.Set Var -> Insn e x -> Live
addUses = fold_EN (fold_EE addVar)
addVar s (Var v) = S.insert v s
addVar s _ = s
live
是我们传递函数的核心:它接受一个指令和当前的事实,然后根据这些信息修改事实。因为这是一个向后传递(BwdTransfer
),传递给live
的Fact x Live
是此指令之后的数据流事实,我们的任务是计算这些指令之前的数据流事实(数据流向后流动)。
如果你仔细观察这个函数,会发现有一些非常奇怪的地方:在live (Label _) f = f
这一行中,我们简单地将f
(显然具有类型Fact x Live
)作为结果传递出去。这是如何工作的呢?嗯,Fact
实际上是一个类型族:
type family Fact x f :: *
type instance Fact C f = FactBase f
type instance Fact O f = f
看,又是 O 和 C 虚类型!如果我们回顾一下Insn
的定义(在IR.hs
中):
data Insn e x where
Label :: Label -> Insn C O
Assign :: Var -> Expr -> Insn O O
Store :: Expr -> Expr -> Insn O O
Branch :: Label -> Insn O C
Cond :: Expr -> Label -> Label -> Insn O C
Call :: [Var] -> String -> [Expr] -> Label -> Insn O C
Return :: [Expr] -> Insn O C
这意味着对于任何在退出时是开放的指令(对于 Label、Assign 和 Store 是x = O
),我们的函数得到Live
,而对于在退出时是封闭的指令(对于 Branch、Cond、Call 和 Return 是x = C
),我们得到FactBase Live
,这是一个标签到事实的映射(LabelMap Live
)——我们稍后会讨论原因。
由于我们接收的指令的参数类型实际上会根据指令的形状而改变,因此一些人(包括 GHC 开发人员在内)更喜欢使用长形式mkBTransfer3
,它接受三个函数,分别对应每种节点形状。因此,重写后的代码如下所示:
liveness' :: BwdTransfer Insn Live
liveness' = mkBTransfer3 firstLive middleLive lastLive
where
firstLive :: Insn C O -> Live -> Live
firstLive (Label _) f = f
middleLive :: Insn O O -> Live -> Live
middleLive n@(Assign x _) f = addUses (S.delete x f) n
middleLive n@(Store _ _) f = addUses f n
lastLive :: Insn O C -> FactBase Live -> Live
lastLive n@(Branch l) f = addUses (fact f l) n
lastLive n@(Cond _ tl fl) f = addUses (fact f tl `S.union` fact f fl) n
lastLive n@(Call vs _ _ l) f = addUses (fact f l `S.difference` S.fromList vs) n
lastLive n@(Return _) _ = addUses (fact_bot liveLattice) n
(使用相同的定义来fact
、addUses
和addVar
)。
有了这个理解,解析firstLive
和middleLive
的代码应该相当容易。标签不会改变活跃库的集合,因此我们的事实f
保持不变。对于赋值和存储,表达式中对寄存器的任何使用都会使该寄存器变为活跃(addUses
是一个计算这一点的实用函数),但如果我们对寄存器赋值,则会失去其先前的值,因此它不再是活跃的。以下是一些演示伪代码:
// a is live
x = a;
// a is not live
foo();
// a is not live
a = 2;
// a is live
y = a;
如果你对addUses
的实现好奇,fold_EE
和fold_EN
函数可以在OptSupport.hs
中找到:
fold_EE :: (a -> Expr -> a) -> a -> Expr -> a
fold_EN :: (a -> Expr -> a) -> a -> Insn e x -> a
fold_EE f z e@(Lit _) = f z e
fold_EE f z e@(Var _) = f z e
fold_EE f z e@(Load addr) = f (f z addr) e
fold_EE f z e@(Binop _ e1 e2) = f (f (f z e2) e1) e
fold_EN _ z (Label _) = z
fold_EN f z (Assign _ e) = f z e
fold_EN f z (Store addr e) = f (f z e) addr
fold_EN _ z (Branch _) = z
fold_EN f z (Cond e _ _) = f z e
fold_EN f z (Call _ _ es _) = foldl f z es
fold_EN f z (Return es) = foldl f z es
命名约定如下:E
代表 Expr
,而 N
代表 Node
(Insn
)。左边的字母表示传递给结合函数的值的种类,而右边的字母表示正在折叠的内容。因此,fold_EN
折叠节点中的所有 Expr
并对其调用结合函数,而 fold_EE
折叠 Expr
中的所有 Expr
(注意像 Load
和 Binop
中可能包含内部表达式!)。因此 fold_EN (fold_EE f)
的效果是,如果我们正在检查 Var
的使用情况,那么 f
将在节点中的每个表达式上调用,这正是我们想要的。
我们也可以明确地写出递归:
addUses :: S.Set Var -> Insn e x -> Live
addUses s (Assign _ e) = expr s e
addUses s (Store e1 e2) = expr (expr s e1) e2
addUses s (Cond e _ _) = expr s e
addUses s (Call _ _ es _) = foldl expr s es
addUses s (Return es) = foldl expr s es
addUses s _ = s
expr :: S.Set Var -> Expr -> Live
expr s e@(Load e') = addVar (addVar s e) e'
expr s e@(Binop _ e1 e2) = addVar (addVar (addVar s e) e1) e2
expr s e = addVar s e
但是正如你所见,递归结构中涉及很多无用的内容,你可能会无意中忘记某个 Expr
,因此使用预定义的折叠操作符更可取。然而,如果你对复杂数据类型上的折叠不太熟悉,在至少完整写出一次整个内容后也是一个不错的练习。
最后要看的部分是 lastLives
:
lastLive :: Insn O C -> FactBase Live -> Live
lastLive n@(Branch l) f = addUses (fact f l) n
lastLive n@(Cond _ tl fl) f = addUses (fact f tl `S.union` fact f fl) n
lastLive n@(Call vs _ _ l) f = addUses (fact f l `S.difference` S.fromList vs) n
lastLive n@(Return _) _ = addUses (fact_bot liveLattice) n
有几个问题需要问。
-
为什么它接收
FactBase Live
而不是Live
?这是因为作为向后分析的结束节点,我们可能从多个位置接收事实:控制流可能经过的每个可能路径。在
Return
的情况下,没有更多的路径,因此我们使用fact_bot liveLattice
(无活跃变量)。在Branch
和Call
的情况下,只有一条进一步路径l
(我们正在分支或返回的标签),因此我们简单地调用fact f l
。最后,在Cond
的情况下,有两条路径:tl
和fl
,因此我们必须获取它们的事实并将它们与数据流格的结合操作结合。 -
为什么我们仍然需要调用
addUses
?因为基本块末尾的指令可以使用变量(Cond
在其条件语句中可能使用它们,Return
在指定其返回内容时可能使用它们等)。 -
Call
中为什么要调用S.difference
?记住,vs
是函数调用写入其返回结果的变量列表。因此,我们需要从活跃变量集中移除这些变量,因为它们将被此指令覆盖:f (x, y) { L100: goto L101 L101: if x > 0 then goto L102 else goto L104 L102: // z is not live here (z) = f(x-1, y-1) goto L103 L103: // z is live here y = y + z x = x - 1 goto L101 L104: ret (y) }
你应该已经弄清楚了 fact
的作用:它查找与标签相关联的数据流事实集,并且如果该标签尚未在我们的映射中,则返回一个空集(无活跃变量)。
一旦你看过一个 Hoopl 分析,你就看过它们全部了!常量传播的传递函数看起来非常相似:
-- Only interesting semantic choice: values of variables are live across
-- a call site.
-- Note that we don't need a case for x := y, where y holds a constant.
-- We can write the simplest solution and rely on the interleaved optimization.
--------------------------------------------------
-- Analysis: variable equals a literal constant
varHasLit :: FwdTransfer Node ConstFact
varHasLit = mkFTransfer ft
where
ft :: Node e x -> ConstFact -> Fact x ConstFact
ft (Label _) f = f
ft (Assign x (Lit k)) f = Map.insert x (PElem k) f
ft (Assign x _) f = Map.insert x Top f
ft (Store _ _) f = f
ft (Branch l) f = mapSingleton l f
ft (Cond (Var x) tl fl) f
= mkFactBase constLattice
[(tl, Map.insert x (PElem (Bool True)) f),
(fl, Map.insert x (PElem (Bool False)) f)]
ft (Cond _ tl fl) f
= mkFactBase constLattice [(tl, f), (fl, f)]
ft (Call vs _ _ bid) f = mapSingleton bid (foldl toTop f vs)
where toTop f v = Map.insert v Top f
ft (Return _) _ = mapEmpty
显著的区别在于,与活跃性分析不同,常量传播分析是一种向前分析 FwdTransfer
。 这也意味着函数的类型是 Node e x -> f -> Fact x f
,而不是 Node e x -> Fact x f -> f
:当控制流分裂时,我们可以为可能的出口标签提供不同的事实集。 这在 Cond (Var x)
中得到了很好的应用,我们知道如果我们采取第一个分支,条件变量为真,反之亦然。 其余是管道:
-
Branch
: 无条件分支不会导致我们的任何变量停止为常量。 Hoopl 将自动注意到,如果到该标签的不同路径具有矛盾的事实,并将映射转换为Top
作为通知,使用我们格的连接函数。mapSingleton
从标签l
到事实f
创建一个单例映射。 -
Cond
: 我们需要创建一个包含两个条目的映射,可以方便地通过mkFactBase
完成,其中最后一个参数是标签到映射的列表。 -
Call
: 函数调用相当于将所有返回变量分配给许多未知变量,因此我们用toTop
将它们全部设置为未知。 -
Return
: 不会前进任何地方,因此空映射就足够了。
下次,我们将讨论一些关于传递函数和连接函数的更精细的细微差别,并讨论图重写,并用一些 Hoopl 的调试工具来总结如何观察 Hoopl 如何重写图。
Hoopl:数据流格 lattice :ezyang’s 博客
数据流优化的本质是分析和转换,并且毫不奇怪,一旦您定义了中间表示,您与 Hoopl 的大部分工作将涉及在基本块图上定义分析和转换。分析本身可以进一步分为我们正在计算的数据流事实的规范化,以及我们在分析过程中如何推导这些数据流事实。在这个Hoopl 系列的第二部分中,我们将看看分析背后的基本结构:数据流格 lattice。我们讨论使用格的理论原因,并且给出您可以为诸如常量传播和活跃性分析等优化定义的格的示例。
尽管其听起来复杂的名称,数据流分析与人类程序员在不实际在计算机上运行代码的情况下推理代码的方式非常相似。我们从对系统状态的一些初始信念开始,然后随着我们逐步执行指令,我们会用新信息更新我们的信念。例如,如果我有以下代码:
f(x) {
a = 3;
b = 4;
return (x * a + b);
}
在函数的最顶部,我对x
一无所知。当我看到表达式a = 3
和b = 4
时,我知道a
等于3
,b
等于4
。在最小的表达式中,我可以使用常量传播来简化结果为x * 3 + 4
。确实,在没有控制流的情况下,我们可以将分析简单地看作是逐行执行代码并更新我们的假设,也称为数据流事实。我们可以在两个方向上做到这一点:我们刚刚完成的分析是前向分析,但我们也可以进行反向分析,这在活跃性分析的情况下是如此。
唉,如果事情能这么简单就好了!这里有两个问题:Y 型控制流和循环控制流。
Y 型控制流(称为连接,原因显而易见,也因为很快就会变得明显)之所以被命名为这样,是因为有两条明显的执行路径,然后合并成一条。然后我们对程序状态有两种不同的信念,我们需要在继续之前调和这些信念:
f(x) {
if (x) {
a = 2; // branch A
} else {
a = 3; // branch B
}
return a;
}
在分支 A 内部,我们知道a
是 2,在分支 B 内部,我们知道a
是 3,但在此条件之外,我们只能说a
是 2 或 3。(由于两个可能的值对于常量传播并不是很有用,我们将代替说a
的值是top:没有一个值代表变量的持有值。)结果是,您正在进行分析的任何一组数据流事实必须定义一个join
操作:
data DataflowFactsTry1 a = DF1 { fact_join :: a -> a -> a }
反向分析也有类似的情况,当你有一个条件跳转时发生,两个控制流的“未来”再次连接在一起,因此需要进行类似的连接。
循环控制流也有连接,但它们面临更进一步的问题,即我们不知道其中一个传入的代码路径的状态是什么:我们在分析循环体之前无法弄清楚,但要分析循环体,我们需要知道传入状态是什么。这是一个进退两难的局面!解决这个问题的技巧是定义一个底部事实,直观地表示可能的最保守数据流事实:当它与其他数据流事实结合时,它是身份。因此,当我们遇到这些循环边时,与其尝试计算边(这是一个进退两难的问题),我们反而输入底部元素,并得到该循环边上事实的近似值。如果这个近似值比底部更好,我们就用新结果替代旧的,并且这个过程重复,直到不再有变化为止:达到了不动点。
有兴趣数学的人可能会注意到,我们所定义的看起来非常像格:
data DataflowLattice a = DataflowLattice
{ fact_name :: String -- Documentation
, fact_bot :: a -- Lattice bottom element
, fact_join :: JoinFun a -- Lattice join plus change flag
-- (changes iff result > old fact)
}
type JoinFun a = Label -> OldFact a -> NewFact a -> (ChangeFlag, a)
这里有一点额外的噪音:Label
严格用于调试目的(它告诉连接函数连接正在进行的标签),而 ChangeFlag
用于优化目的:它让 fact_join
在达到不动点时高效地通知NoChange
。
旁注:格. 在这里,我们回顾了一些关于格的基本术语和直觉。格是一个偏序集,对于所有元素对存在最小上界(lub)和最大下界(glb)。如果想象一个哈斯图,最小上界的存在意味着我可以从两个元素向上沿图追溯,直到找到一个共享元素;最大下界是向下同样的过程。最小上界也称为两个元素的连接,最大下界称为交。(我更喜欢 lub 和 glb 因为我总是搞混 join 和 meet!)在符号上,最小上界用逻辑或符号或方括号并运算符表示,而最大下界用逻辑与符号或方括号交运算符表示。符号的选择具有暗示性:逻辑符号的重载对应于逻辑命题可以使用一种特殊类型的格——布尔代数——定义其语义,其中 lub 等价于或,glb 等价于且(底部是虚假,顶部是公理)。集合运算符的重载对应于关于幂集构造的通常顺序上的格:lub 是集合并运算,glb 是集合交运算。
在 Hoopl 中,我们处理有界格,即存在顶部和底部元素的格。这些是特殊元素,比所有其他元素大(相应地,小于所有其他元素)。将底部元素与任何其他元素连接是一个无操作:另一个元素是结果(这就是为什么我们使用底部作为初始化值的原因!)将顶部元素与任何其他元素连接会导致顶部元素(因此,如果你达到顶部,你就“卡住”了,可以这么说)。
对于一丝不苟的人,严格来说,Hoopl 不需要格:相反,我们需要一个有界半格(因为我们只需要定义连接,而不是相遇)。还有另一个不足之处:Lerner-Grove-Chambers 和 Hoopl 使用底部和连接,但大多数现有的数据流格文献使用顶部和相遇(实质上,将格上下颠倒)。事实上,哪种选择“自然”取决于分析:正如我们将看到的,活性分析自然倾向于使用底部和连接,而常量传播则建议使用顶部和相遇。为了与 Hoopl 保持一致,我们将始终使用底部和连接;只要我们保持一致,格的方向就不重要。
现在我们将具体示例用于活性分析和常量传播的数据流格。这两个示例展示了要查看的格的良好分布:活性分析是变量名的集合,而常量传播是变量名到可能值的平面格的映射。
活性分析(Live.hs
)使用非常简单的格,因此它作为设置DataflowLattice
所涉及的额外仪式的良好入门示例:
type Live = S.Set Var
liveLattice :: DataflowLattice Live
liveLattice = DataflowLattice
{ fact_name = "Live variables"
, fact_bot = S.empty
, fact_join = add
}
where add _ (OldFact old) (NewFact new) = (ch, j)
where
j = new `S.union` old
ch = changeIf (S.size j > S.size old)
类型Live
是我们数据流事实的类型。这代表的是活跃的变量集合(即,稍后代码将使用的变量):
f() {
// live: {x, y}
x = 3;
y = 4;
y = x + 2;
// live: {y}
return y;
// live: {}
}
记住,活性分析是反向分析:我们从过程的底部开始并向上工作:变量的使用意味着它在其上方的所有点都是活跃的。我们用文档、显著元素(底部)和这些事实的操作(连接)填写DataflowLattice
。Var
是Expr.hs
,只是变量的字符串名称。我们的底部元素(用于初始化我们无法立即计算的边缘)是空集,因为在任何过程的底部,所有变量都是死的。
连接是集合并,可以在此示例中清楚地看到:
f (x) {
// live: {a,b,x,r} (union of the two branches,
// as well as x, due to its usage in the conditional)
a = 2;
b = 3;
if (x) {
// live: {a,r}
r = a;
} else {
// live: {b,r}
r = b;
}
// live: {r}
return r;
// live: {}
}
我们还看到一些计算更改ch
的代码,这是集合大小比较的简单方式,因为并集只会增加集合的大小,而不会减少它。changeIf
是一个实用函数,将Bool
转换为ChangeFlag
。
如果我们有三个变量,这里是格结构的示意图:它只是幂集构造上的通常排序。
这是常量传播的格子(ConstProp.hs
)。虽然与活跃集相比稍微复杂一些,但部分复杂性被 Hoopl 提供的一些实用数据类型和函数隐藏了起来。
-- ConstFact:
-- Not present in map => bottom
-- PElem v => variable has value v
-- Top => variable's value is not constant
type ConstFact = Map.Map Var (WithTop Lit)
constLattice :: DataflowLattice ConstFact
constLattice = DataflowLattice
{ fact_name = "Const var value"
, fact_bot = Map.empty
, fact_join = joinMaps (extendJoinDomain constFactAdd) }
where
constFactAdd _ (OldFact old) (NewFact new)
= if new == old then (NoChange, PElem new)
else (SomeChange, Top)
在这个结构中实际上有两个格子。 “外部” 格子是映射,其中底部元素是空映射,加入是将两个映射合并在一起,使用内部格子合并元素。 “内部” (半)格子是 WithTop Lit
,由 Hoopl 提供。(可以说内部格子是点逐点地提升到映射中。)我们在这里举例说明了包含布尔变量的内部格子:
关于内部格点,有一点需要强调的是底部和顶部之间的区别。两者都表示一种“不知道变量内容”的状态,但在底部的情况下,变量可能是常量也可能不是常量,而在顶部的情况下,变量肯定不是常量。很容易搞混的是,“底部意味着我们不知道变量的值是什么”,而“顶部意味着变量的值可以是任何东西”。如果我们把这个格子看作是一个集合,其中 {True}
表示这个变量的值为真,则 {True,False}
(底部)表示变量可能是常量真或常量假,而不是变量可以是真或假。这也意味着我们可以恰当地解释 {}
(顶部):对于这个变量来说,没有一个值是常量。(注意,这是倒置的幂集格点!)
在这个例子中有几个有趣的实用函数:extendJoinDomain
和 joinMaps
。extendJoinDomain
免去了我们完全编写与顶部的所有交互的麻烦,例如:
constFactAddExtended _ (OldFact old) (NewFact new)
= case (old, new) of
(Top, _) -> (NoChange, Top)
(_, Top) -> (SomeChange, Top)
(PElem old, PElem new) | new == old -> (NoChange, PElem new)
| otherwise -> (SomeChange, Top)
joinMaps
将我们的内部格点提升为映射形式,并且处理了 ChangeFlag
的连接(如果新映射中的任何条目在旧映射中不存在,或者加入的条目发生了变化,则输出 SomeChange
)。
这就结束了我们关于 Hoopl 和数据流格子的讨论。我们还没有涵盖 Hoopl 提供的所有操作数据流格子的函数;以下是一些进一步查看的模块:
-
Compiler.Hoopl.Combinators
定义了pairLattice
,它对两个格子进行了乘积构造。它可以用来同时执行多个分析。 -
Compiler.Hoopl.Pointed
定义了许多辅助数据结构和函数,用于向现有数据类型添加Top
和Bottom
。这就是extendJoinDomain
的来源。 -
Compiler.Hoopl.Collections
和Compiler.Hoopl.Unique
定义了在唯一键上的映射和集合(最突出的是标签)。您很可能会在数据流格子中使用这些。
下次,我们将讨论转移函数,这是我们计算数据流事实的机制。
进一步阅读。 数据流格被覆盖在《编译器原理、技术与工具》(红龙书)的第 10.11 章中。原始论文是基尔达尔在 1973 年发表的《统一的全局程序优化方法》。有趣的是,红龙书指出:“它并没有被广泛使用,可能是因为系统节省的工作量不及诸如语法分析器生成器等工具。” 我觉得这在传统编译器优化中是正确的,但对于 Lerner-Grove-Chambers 风格的通过程来说可能不是(其中分析和重写是交错进行的)。
Hoopl 导览:基础系统:ezyang 的博客
Hoopl 是一个高阶优化库。我们觉得它非常酷!这系列博文旨在向您介绍这个库,作为对Hoopl 相关论文和源代码的教程式补充。我希望这个系列对那些不想使用 Hoopl 编写优化传递的人也有所帮助,但对 Haskell 中高阶 API 设计感兴趣的人也有所帮助。通过本教程的学习,您将能够理解代码中对analyzeAndRewriteFwd
和DataflowLattice
等名称的引用,并能够解读诸如以下的类型签名:
analyzeAndRewriteFwd
:: forall m n f e x entries. (CheckpointMonad m, NonLocal n, LabelsPtr entries)
=> FwdPass m n f
-> MaybeC e entries
-> Graph n e x -> Fact e f
-> m (Graph n e x, FactBase f, MaybeO x f)
我们假设您对函数式编程和编译技术有基本的了解,但我会在适当的地方介绍适当的基本概念。
旁白:介绍。 对于已经熟悉正在讨论的主题的人来说,可以自由跳过像这样格式化的部分。
我们将导览 Hoopl 的testing
子目录,其中包含一个示例客户端。(你可以通过克隆 Git 仓库git clone git://ghc.cs.tufts.edu/hoopl/hoopl.git
获取一份副本)。你可以通过查看README
文件来了解基础情况。我们首先将查看“Base System”,该系统定义了抽象语法树和 Hoopl 化的中间表示的数据类型。
抽象语法与标准一样(Ast.hs
):
data Proc = Proc { name :: String, args :: [Var], body :: [Block] }
data Block = Block { first :: Lbl, mids :: [Insn], last :: Control }
data Insn = Assign Var Expr
| Store Expr Expr
data Control = Branch Lbl
| Cond Expr Lbl Lbl
| Call [Var] String [Expr] Lbl
| Return [Expr]
type Lbl = String
我们有一种命名的过程语言,它由基本块组成。我们支持无条件分支Branch
,条件分支Cond
,函数调用Call
([Var]
是存储返回值的变量,String
是函数名,[Expr]
是参数,Lbl
是函数调用完成后跳转的位置),以及函数返回Return
(支持多返回值,因此使用[Expr]
而不是Expr
)。
我们没有任何高级流控制结构(这种语言的控制流思想就是大量的 goto 语句——不用担心,这对我们有利),所以我们可能会期望将这种“高级汇编语言”相对容易地映射到机器代码中,事实上确实如此(但需要注意的是,这种语言不需要考虑寄存器分配,但我们如何使用变量将明显影响寄存器分配)。高级汇编语言的真实世界例子包括 C--。
这里是一个可能在这种语言中编写的代码的简单示例:
旁白:基本块。 完全解释什么是抽象语法树(AST)略有超出本文的范围,但如果你知道如何在 Haskell 中编写 Scheme 解释器,你已经了解了语言的 表达式 组件的大部分内容:例如二元运算符和变量(例如
a + b
)。然后,我们以明显的方式扩展这个计算器,加入低级别的命令式特性。如果你已经做过任何命令式编程,大多数这些特性也是熟悉的(分支、函数调用、变量赋值):唯一的新概念是 基本块。基本块是流程控制的原子单位:如果我进入了一个基本块,我知道我会从另一端出来,不管怎样。这意味着在这个块的内部不会有非本地的控制转移(例如异常),也不会有能够跳到这个块 内部 的代码(例如 goto)。任何控制流发生在基本块的末尾,我们可能无条件地跳转到另一个块,或者进行函数调用等操作。真实的程序不会以这种方式编写,但我们可以轻松地将它们转换成这种形式,并且我们希望采用这种表示方式,因为它将更容易进行数据流分析。因此,我们的示例抽象语法树实际上并不像你会编程的命令式语言,但它很容易成为代码生成的目标,所以示例抽象语法树以这种方式设置。
Hoopl 是对底层表示的抽象,但不幸的是,我们不能直接使用这个 AST;Hoopl 有自己的图表示。无论如何,我们也不想使用我们自己的表示方式:我们已经将控制流图表示为块列表 [Block]
。如果我想取出某个特定标签的块,我必须遍历整个列表。与其发明自己更高效的块表示(类似于标签到块的映射),不如使用 Hoopl 给我们提供的表示 Graph n e x
(毕竟它将要在这个表示上操作)。n
代表“节点”,你提供构成图节点的数据结构,而 Hoopl 管理图本身。e
和 x
参数将用于存储关于节点形状的信息,不代表任何特定数据。
这里是我们的中间表示(IR.hs
):
data Proc = Proc { name :: String, args :: [Var], entry :: Label, body :: Graph Insn C C }
data Insn e x where
Label :: Label -> Insn C O
Assign :: Var -> Expr -> Insn O O
Store :: Expr -> Expr -> Insn O O
Branch :: Label -> Insn O C
Cond :: Expr -> Label -> Label -> Insn O C
Call :: [Var] -> String -> [Expr] -> Label -> Insn O C
Return :: [Expr] -> Insn O C
显著的差异是:
-
Proc
的主体是Graph Insn C C
,而不是[Block]
。此外,由于Graph
没有“第一个”块的概念,我们必须用entry
明确指出入口点是什么。 -
我们不再使用
String
作为Lbl
,而是切换到了 Hoopl 提供的Label
数据类型。 -
Insn
、Control
和Label
都被合并为一个Insn
广义抽象数据类型(GADT),它处理所有这些情况。
然而,重要的是,我们通过e
和x
参数保留了关于节点是什么形状的信息。e
代表进入,x
代表退出,O
代表开放,C
代表关闭。每个指令都有一个形状,你可以想象成一系列的管道,它们是相互连接的。形状为C O
(进入时关闭,退出时开放)的管道开始了这个块,形状为O C
(进入时开放,退出时关闭)的管道结束了这个块,而在中间可以有任意数量的O O
管道。我们可以看到Insn C O
对应于我们旧的数据类型Ast.Lbl
,Insn O O
对应于Ast.Insn
,而Insn O C
对应于Ast.Control
。当我们把节点放在一起时,我们得到了图,它也可以是各种开放或关闭的。
另外:广义抽象数据类型. GADTs 是类型级编程中不可或缺的瑞士军刀。在这个另外的部分中,我们简要描述了一些可以与我们上面提到的
Insn e x
一起使用的技巧(类似子类型化)。第一个“技巧”是你可以完全忽略幻象类型变量,并且像使用普通数据类型
Insn
一样使用它:isLabel :: Insn e x -> Bool isLabel Label{} = True isLabel _ = False
我可以把一个
Label
传给这个函数,它将返回True
,或者我可以传一个Branch
给它,它将返回False
。在这个特定的例子中,对 GADT 进行模式匹配不会导致我关心的类型细化,因为在任何构造函数或函数返回类型中都没有类型变量e
或x
。当然,我可以以一种不可能将不是
Label
的东西传递给它的方式编写这个函数:assertLabel :: Insn C O -> Bool assertLabel Label{} = True
如果你尝试调用
assertLabel (Branch undefined)
,你将会从 GHC 得到这个很好的类型错误:<interactive>:1:13: Couldn't match expected type `C' against inferred type `O' Expected type: Insn C O Inferred type: Insn O C In the first argument of `assertLabel', namely `(Branch undefined)' In the expression: assertLabel (Branch undefined)
让我们来解析一下:任何构造函数
Branch
都将得到一个类型为Insn O C
的值。然而,我们函数的类型签名却声明了Insn C O
,而且C ≠ O
。这个类型错误非常直接明了,足以告诉我们出了什么问题!同样地,我可以编写一个不同的函数:
transferMiddle :: Insn O O -> Bool transferMiddle Assign{} = True transferMiddle Store{} = False
在类型级别上没有办法区分
Assign
和Store
,但是我不必对数据类型中的任何其他内容进行模式匹配:Insn O O
意味着我只需要处理符合这种形状的构造函数。我甚至可以部分指定允许的形状是什么:
transferMiddleOrEnd :: Insn O x -> Bool
对于这个函数,我需要对指令和控制操作进行模式匹配,但是不需要针对
IR.Label
进行模式匹配。这并不是我在原始 AST 中容易做到的事情:我本来需要创建一个和类型Ast.InsnOrControl
对应的求和类型。快速问题. 如果我有一个以
Insn e x
作为参数的函数,并且我想把这个值传给一个只接受Insn C x
的函数,我该怎么做?另一种情况呢?练习. 假设你正在为 Hoopl 设计一个
Graph
表示,但不能使用 GADTs。Graph IR.Insn
(其中IR.Insn
就像我们的IR
GADT,但没有幻象类型)和Graph Ast.Label Ast.Insn Ast.Control
之间的表示有什么区别?
我们今天将看的最后一个文件是一些管道工作,用于将抽象语法树转换为中间表示,Ast2ir.hs
。由于存在一些名称重载,我们使用 A.
作为前缀来区分来自 Ast
的数据类型和 I.
来自 IR
的数据类型。主要函数是 astToIR
:
astToIR :: A.Proc -> I.M I.Proc
astToIR (A.Proc {A.name = n, A.args = as, A.body = b}) = run $
do entry <- getEntry b
body <- toBody b
return $ I.Proc { I.name = n, I.args = as, I.body = body, I.entry = entry }
代码是单子的,因为当我们将字符串转换为标签(在内部是任意的唯一整数)时,我们需要跟踪我们已经分配的标签。单子本身是一个普通的状态单子变换器,位于“新标签”单子之上。(实际上,堆栈中还有另一个单子;请参阅 IR.M
获取更多细节,但在这个阶段它没有被使用,所以我们忽略它。)
getEntry
查看过程主体中的第一个块,并使用它确定入口点:
getEntry :: [A.Block] -> LabelMapM Label
getEntry [] = error "Parsed procedures should not be empty"
getEntry (b : _) = labelFor $ A.first b
labelFor
是一个单子函数,如果我们之前没有见过字符串 Lbl
,它会给我们一个新的标签,否则会返回已存在的标签。
toBody
使用了一些更有趣的 Hoopl 函数:
toBody :: [A.Block] -> LabelMapM (Graph I.Insn C C)
toBody bs =
do g <- foldl (liftM2 (|*><*|)) (return emptyClosedGraph) (map toBlock bs)
getBody g
Hoopl 提供的函数包括 |*><*|
和 emptyClosedGraph
。请注意,Hoopl 图不必连接(即它们可以包含多个基本块),因此 |*><*|
是一种图连接运算符,将两个封闭的图连接在一起(Graph n e C -> Graph n C x -> Graph n e x
),可能通过间接控制操作符连接在一起(我们除了在运行时无法知道这一点外,还用红色箭头画出)。这是一个有些笨拙的运算符,因为 Hoopl 希望尽可能使用 <*>
。
toBlock
给出了 <*>
的一个例子:
toBlock :: A.Block -> LabelMapM (Graph I.Insn C C)
toBlock (A.Block { A.first = f, A.mids = ms, A.last = l }) =
do f' <- toFirst f
ms' <- mapM toMid ms
l' <- toLast l
return $ mkFirst f' <*> mkMiddles ms' <*> mkLast l'
我们从底部向上工作。mkFirst f'
、mkMiddle ms'
和 mkLast l'
的类型是什么?它们都是 (Graph I.Insn e x)
,但 f'
是 C O
,ms'
是 O O
,l'
是 O C
。我们建立部分图形,这些图形两侧未封闭,然后使用 <*>
将它们连接在一起:Graph n e O -> Graph n O x -> Graph n e x
。mkFirst
、mkMiddles
和 mkLast
是由 Hoopl 提供的函数,将 I.Insn e x
提升为 (Graph I.Insn e x)
(或者在 mkMiddles
的情况下是 [I.Insn O O]
)。
最后,toFirst
、toMid
和 toLast
实际上执行了翻译:
toFirst :: A.Lbl -> LabelMapM (I.Insn C O)
toFirst = liftM I.Label . labelFor
toMid :: A.Insn -> LabelMapM (I.Insn O O)
toMid (A.Assign v e) = return $ I.Assign v e
toMid (A.Store a e) = return $ I.Store a e
toLast :: A.Control -> LabelMapM (I.Insn O C)
toLast (A.Branch l) = labelFor l >>= return . I.Branch
toLast (A.Cond e t f) = labelFor t >>= \t' ->
labelFor f >>= \f' -> return (I.Cond e t' f')
toLast (A.Call rs f as l) = labelFor l >>= return . I.Call rs f as
toLast (A.Return es) = return $ I.Return es
注意,我们仔细指定返回形状,以便可以使用 mkFirst
、mkMiddles
和 mkLast
。最有趣的事情是,我们必须将 Lbl
字符串转换为 Label
;否则,代码就是琐碎的。
数据表示到此结束,下次我们将看看 Hoopl 中的数据流事实分析。
HoTT 在 Coq 中的练习(正在进行中):ezyang 的博客
来源:
blog.ezyang.com/2013/07/hott-exercises-in-coq-in-progress/
昨天在飞机上,我花了些时间用 Coq 实现了《HoTT 书》中的练习。我完成了 1.6 部分(是的,进展不大,也许我应该建一个 GitHub 仓库,如果其他人有兴趣贡献框架的话。不过,对于解决方案我还不知道该怎么办)。所有这些都已经测试求解了。
为了运行这个开发,你需要 HoTT/coq;安装说明在这里。
更新。 可以在HoTT-coqex存储库中找到解决方案和更多练习。我完成了所有非平凡的同伦特定练习,并略过了一些更标准的类型理论练习(这些并不真正是同伦特定的)。有些解决方案实在太糟糕了,需要一些改进。
Require Import HoTT.
Definition admit {T: Type} : T. Admitted.
(* Exercise 1.1 *)
Definition mycompose {A B C : Type} (g : B -> C) (f : A -> B) : A -> C := admit.
Goal forall (A B C D : Type) (f : A -> B) (g : B -> C) (h : C -> D),
mycompose h (mycompose g f) = mycompose (mycompose h g) f.
Admitted.
(* Exercise 1.2 *)
Section ex_1_2_prod.
Variable A B : Type.
Check @fst.
Check @snd.
Definition my_prod_rec (C : Type) (g : A -> B -> C) (p : A * B) : C := admit.
Goal fst = my_prod_rec A (fun a => fun b => a). Admitted.
Goal snd = my_prod_rec B (fun a => fun b => b). Admitted.
End ex_1_2_prod.
Section ex_1_2_sig.
Variable A : Type.
Variable B : A -> Type.
Check @projT1.
Check @projT2.
Definition my_sig_rec (C : Type) (g : forall (x : A), B x -> C) (p : exists (x : A), B x) : C := admit.
Goal @projT1 A B = my_sig_rec A (fun a => fun b => a). Admitted.
(* What goes wrong when you try to prove this for projT2? *)
End ex_1_2_sig.
(* Exercise 1.3 *)
Definition refl {A : Type} (x : A) : x = x := 1%path.
Section ex_1_3_prod.
Variable A B : Type.
(* Given by the book *)
Definition uppt : forall (x : A * B), ((fst x, snd x) = x) :=
fun p => match p with (a,b) => refl (a,b) end.
Definition my_prod_ind (C : A * B -> Type) (g : forall (x : A) (y : B), C (x, y)) (x : A * B) : C x := admit.
Goal forall C g a b, my_prod_ind C g (a, b) = g a b. Admitted.
End ex_1_3_prod.
Section ex_1_3_sig.
Variable A : Type.
Variable B : A -> Type.
Definition sig_uppt : forall (x : exists (a : A), B a), ((projT1 x; projT2 x) = x) := admit.
Definition mysig_ind (C : (exists (a : A), B a) -> Type) (g : forall (a : A) (b : B a), C (a; b)) (x : exists (a : A), B a) : C x := admit.
Goal forall C g a b, mysig_ind C g (a; b) = g a b. Admitted.
End ex_1_3_sig.
(* Exercise 1.4 *)
Fixpoint iter (C : Type) (c0 : C) (cs : C -> C) (n : nat) : C :=
match n with
| 0 => c0
| S n' => cs (iter C c0 cs n')
end.
Definition mynat_rec (C : Type) : C -> (nat -> C -> C) -> nat -> C := admit.
Eval compute in mynat_rec (list nat) nil (@cons nat) 2.
Eval compute in nat_rect (fun _ => list nat) nil (@cons nat) 2.
(* Exercise 1.5 *)
Definition mycoprod (A B : Type) := exists (x : Bool), Bool_rect (fun _ => Type) A B x.
Section ex_1_5.
Variable A B : Type.
Definition inl := existT (Bool_rect (fun _ => Type) A B) true.
Definition inr := existT (Bool_rect (fun _ => Type) A B) false.
Definition mycoprod_ind (C : mycoprod A B -> Type)
(l : forall (a : A), C (inl a))
(r : forall (b : B), C (inr b))
(x : mycoprod A B) : C x := admit.
Goal forall C l r x, mycoprod_ind C l r (inl x) = l x. Admitted.
Goal forall C l r x, mycoprod_ind C l r (inr x) = r x. Admitted.
End ex_1_5.
(* Exercise 1.6 *)
Definition myprod (A B : Type) := forall (x : Bool), Bool_rect (fun _ => Type) A B x.
Section ex_1_6.
Context `{Funext}.
Variable A B : Type.
Definition mypr1 (p : myprod A B) := p true.
Definition mypr2 (p : myprod A B) := p false.
Definition mymkprod (a : A) (b : B) : myprod A B := Bool_rect (Bool_rect (fun _ => Type) A B) a b.
Definition myprod_ind (C : myprod A B -> Type)
(g : forall (x : A) (y : B), C (mymkprod x y)) (x : myprod A B) : C x := admit.
Goal forall C g a b, myprod_ind C g (mymkprod a b) = g a b. Admitted.
End ex_1_6.
实际上,我撒了个谎。我还没有证明练习 1.6 中的最后一个目标;我的困难在于我不知道如何让函数外延性计算,但我确信这是一些简单的事情...
亚里士多德为何错误地理解概念框架的重要性:ezyang 的博客
来源:
blog.ezyang.com/2011/05/how-aristotle-got-it-wrongon-the-importance-of-conceptual-frameworks/
亚里士多德为何错误地理解概念框架
关于概念框架的重要性
关于亚里士多德物理学的一个持久的神话——古希腊人提出的物理学,直到牛顿和伽利略出现之前都被认为是亚里士多德认为“更重的物体下降得比轻的更快”的一个例子,典型的例子是大炮弹和羽毛。尽管当今人类社会中的某些人确实相信这个“事实”,但是亚里士多德对这个问题有一个更加微妙和深思熟虑的观点。如果你不信,他原始文本的英文翻译(第八部分)能够充分传达这种印象。这里是一个相关的引用(重点在我自己):
我们看到同样重量或体积的物体之间移动快慢有两个原因,要么是因为它们穿过的介质不同,比如水、空气和地球,要么是因为在“其他条件相等”的情况下,运动体与其他物体之间的差异来自于重量或轻重的过剩。
“其他条件相等”是关键的四个词,甚至在对这个理论的罗马账户中也被遗漏。例如罗马哲学家卢克莱修斯:
因为无论物体通过水和稀薄的空气下降,它们加速下降的速度与它们的重量成比例...
虽然我并不是说亚里士多德是正确的,或者接近牛顿的物理学概念,亚里士多德确实意识到下落物体的形状可能影响其下降(“因为运动物体通过其形状与介质结合”),事实上,这些引用的背景不是关于物体如何移动的论文,而是关于物体如何通过介质移动的:特别是,亚里士多德试图论证物体如何在“空无”中移动。但是,这种表述不知何故被误解为西方大多数人直到伽利略物理学的出现之前的神话。所有这些关键细节到底去了哪里?
我的信念是,亚里士多德缺乏关键的概念框架——牛顿物理学——来将这些不同的事实整合起来。相反,亚里士多德相信目的论原则,即所有物体都有它们自然移动向的自然位置,这在许多物理经验实例中都无法提供任何解释力。他能够做出的一般化归纳都充满了特例和需要仔细思考的地方,这使得这些想法难以在传达中不失其精华。这并不是说仔细思考不重要:即使在今天教授物理时,我们也极易犯与我们历史前辈相同的错误:事实上可能都“包含在牛顿的法则中”,但这并不一定显而易见!
但当你了解理论的应用及其易出错的地方时,重要的是要记住,这些思想和直觉必须依附于更大的、统一且充分的理论树上。那些不这样做的人永远注定要摆弄令人困惑的各种特例和分散的事实,并且无法简洁地表达他们的想法。虽然我在这里没有足够的空间来论证这一点,但我也认为这适用于你必须积累知识的任何其他学科。
tl;dr — 无结构的事实容易被遗忘。
如何学会不再担心并爱上 ⊥:ezyang 的博客
来源:
blog.ezyang.com/2010/12/how-i-learned-to-stop-worrying-and-love-the-bottom/
- ⊥ 的指示语义和游戏语义的扩展类比 *
这是在改进Haskell Wikibooks 关于指示语义的文章,通过一种受到奇异博士启发的类比。
这个类比。 为了防止布里格里尔将军发动对俄罗斯的核攻击,五角大楼决定最好是每个核武器都需要两把独立的钥匙才能启动,而且这两把钥匙在正常情况下不应由同一个人同时知道。爱丽丝得到一半钥匙,鲍勃得到另一半。如果布里格里尔问爱丽丝要她的钥匙的一半,她可以告诉他她的钥匙,A。然而,问爱丽丝鲍勃的钥匙是不起作用的,因为她不知道鲍勃的钥匙是什么。
假设里珀还是问了爱丽丝,她告诉他“我不知道鲍勃的钥匙。” 在这种情况下,里珀现在有了一个具体的信息:爱丽丝没有鲍勃的钥匙。他现在可以据此行动,并要求鲍勃提供第二把钥匙。但是假设,与其直接告诉他她不知道钥匙,她告诉他,“我可以告诉你,但你能等一会儿吗?” 里珀决定等待——他可能会花些时间说服鲍勃交出钥匙。但是爱丽丝从未告诉过里珀钥匙,并且他一直在等待。即使里珀最终决定放弃等待爱丽丝,他也很难在爱丽丝声称她有钥匙但从未交出时制定战略。
爱丽丝好奇地想知道如果她试图引爆核弹会发生什么,于是她去找负责输入代码的拉里。她告诉技术员,“我有爱丽丝的钥匙和鲍勃的钥匙。”(当然,我们知道她实际上并没有鲍勃的钥匙。)拉里感到懒惰,所以在向爱丽丝要钥匙之前,他打电话给五角大楼询问是否允许核爆炸。答案是否定的,他礼貌地告诉了爱丽丝。爱丽丝毫不在乎,继续找到史蒂夫,负责输入代码。她告诉史蒂夫她有爱丽丝的钥匙和鲍勃的钥匙。史蒂夫很乐意,问爱丽丝,“好的,请告诉我你的钥匙和鲍勃的钥匙。” 爱丽丝递上了她的钥匙,但在鲍勃的钥匙上停顿了,谈话却没有继续下去。
然而,尽管我们的最大努力,里珀还是设法获得了两把钥匙,并且世界最终还是毁于核灾难。 ☢
符号。因为这个钥匙有两部分,所以可以表示为一个元组。瑞佩尔知道的完整密钥是(A, B),爱丽丝对完整密钥的了解是(A, ⊥),而鲍勃了解的是(⊥, B)。如果我是(无知的)市民查理,我的知识可能是(⊥, ⊥)。我们可以直观地将⊥视为当某事未知时的占位符。(为简单起见,A 和 B 的类型只是单元。)
我比你知道更多。我们可以形成一个部分顺序,来判断谁比谁知道更多。瑞佩尔拥有完整的密钥,比起爱丽丝、鲍勃或查理来说,知道得更多。爱丽丝比查理知道得更多,而鲍勃比查理知道得更多。我们无法真正说爱丽丝比鲍勃知道得更多,反之亦然,因为他们知道不同的数据片段。⊥在此排序中处于底部,因为它代表可能拥有的最少信息。
什么都不同,底部不同。当爱丽丝说“我不知道”与爱丽丝无休止地延迟提供答案时,事情有些不同。这是因为前者根本不是底部!我们可以看到这一点,因为在第一种情况下,爱丽丝实际上说了些什么。尽管这不是关键,但它是信息,具体来说是来自 Maybe 的 Nothing 构造器。在这种情况下,更准确地表示爱丽丝的知识为(Just A, Nothing)。在第二种情况下,爱丽丝在任何时候都可以给出一个真正的答案,但她没有。
一个奇怪的游戏。唯一的胜利之举是不参与游戏。人们强调向他人询问信息片段的情况很多,那些人要么回应,要么无休止地延迟。实际上,这直接对应于游戏语义中的底部概念。当瑞佩尔问爱丽丝关于她的钥匙的信息时,我们可以将对话写成以下顺序:“告诉我元组的第一个值”,“值是 A”,“告诉我元组的第二个值”,“...”爱丽丝对最后一个问题无言以对,因为在游戏语义中,她没有策略(知识)来回答“告诉我元组的第二个值”的问题。无助的查理更糟糕,因为他对任何问题都没有策略:唯一让他高兴的时候是没有人问他任何问题。他有空策略。
不问,不告. 考虑函数应用。我们可能将这个概念化为:“这里是值 A,这里是值 B,请告诉我是否可以引爆核设备。” 这相当于 Steve 的严格评估。但我们不必以这种方式设定对话:与 Larry 的对话从“我有第一个键和第二个键。请告诉我是否可以引爆核设备。”开始。如果 Larry 决定对第一个键进行案例语句,他可能会问 Alice,“好的,第一个键是什么?”——特别是在 Larry 决定不需要再向 Alice 询问更多信息时,这种情况会发生。这会让 Charlie 非常高兴,因为只要他根本不被问问题,他就会很开心。
同时询问几个人. 在现实生活中,如果有人在一段时间内没有给我们答案,我们可以决定停止倾听,去做其他事情。程序也可以做到这一点吗?这取决于你所用的编程语言。在 Haskell 中,我们可以在 IO 单子中使用非确定性来做到这一点(或者将其推入纯代码中,但需要注意一些限制,就像unamb做的那样)。
类比中不包含的内容. 函数也是数据:它们可以部分定义,例如部分函数。不动点操作符可以被认为使用函数的较少定义版本来生成更多定义的版本。这非常酷,但我想不出一个间接的方法来呈现它。省略了指称语义和游戏语义中的正式定义;特别是域和连续函数没有解释(这可能是最重要的要点,通常需要在定义它们之前设置数学机制)。
进一步阅读. 如果你觉得我已经帮助你理解了底层,那么请再次检查你对新类型示例的理解,这可能是一个非常微妙的情况,需要明确考虑底层以及函数、数据构造函数和未定义值(底层)之间的交互。严格性注解意味着与数据构造函数的交互大致如下:“我有第一个参数,请告诉我值是多少。” “好的,第一个参数是什么?”这些关于游戏语义的笔记(PDF)非常不错,尽管假定你熟悉指称语义。找到这些术语的正式定义,并查看它们是否符合你的直觉,是一个很好的练习。
OfflineIMAP 工作原理:ezyang 的博客
作为软件工程师,我们对这样的营销文案总是有些怀疑:
OfflineIMAP 是安全的;它使用一种设计来防止任何情况下的邮件丢失。由于该算法的设计,即使是编程错误也不会导致邮件丢失。我对这个算法如此自信,以至于我用自己的个人和工作账户来测试 OfflineIMAP 的预发布、开发和测试版发布。
这个算法是什么?它为什么有效?它的正确性证明在哪里?不幸的是,OfflineIMAP 的最终用户文档中没有详细描述这个算法的内容,以便软件工程师能够确信 OfflineIMAP 的正确性。幸运的是,OfflineIMAP 是开源的,因此我们可以找出这个神秘算法的内容。实际上,OfflineIMAP 的同步算法非常简单和优雅。(Nota bene:为简单起见,我们不考虑消息标志的同步。)
准备工作
定义我们的本地和远程仓库(分别为 Maildir 和 IMAP)为消息集合 L 和 R。在不删除同步方案中,我们希望执行一些操作集,以使仓库 L' 和 R' 的最终状态为 L ∪ R。
然而,对于邮件而言,不删除的同步方案效果不佳,我们希望能够删除消息并使这些更改传播。为此,OfflineIMAP 定义了第三个称为状态库的仓库,也是一组消息的集合,用于指示消息是否过去曾同步过而没有中间同步删除。现在消息有七种可能的状态,基于它们属于哪些仓库:
考虑所有可能的组合:
-
已同步 (L,R,S):消息已完全同步,无需进一步处理。
-
本地新增 (L):消息是新添加到本地仓库中,需要上传。
-
远程新增 (R):消息是新添加到远程仓库中,需要下载。
-
状态丢失 (L,R):消息已同步,但我们的状态已过时。
-
远程删除 (L,S):消息已同步,但之后从远程删除;现在应该从本地删除。
-
本地删除 (R,S):消息已同步,但之后从本地删除;现在应该从远程删除。
-
丢失 (S):消息已在所有地方删除,而我们的状态中有一个过时的条目。
Venn 图表的绿色阴影区域是我们希望在同步结束时 L、R 和 S 覆盖的部分。
算法
定义同步操作源、目的地和状态库 syncto(src, dst, status)
为以下两个步骤:
-
计算集合差异
src - status
,并将这些消息复制到dst
和status
。 -
计算集合差异
status - src
,并从dst
和status
中删除这些消息。
然后完整的同步算法是:
-
syncto(R, L, S)
(下载变更) -
syncto(L, R, S)
(上传变更)
如何运作
在没有崩溃的情况下,正确性证明仅涉及验证状态仓库不变式(过去已同步消息且没有中间同步删除的消息),这一不变式在所有四个操作中都得到保持,并且确实是我们希望复制和删除的消息集的精确集合差异。然而,我们也可以尝试查看随着算法的进行,本地、远程和状态仓库的变化。特别是,在第一个 syncto
中,状态仓库的内容演变方式略有惊讶,尽管对它应用了相同的操作(然后它与 remote
一起同步进展)。
另一个重要的正确性声明是,OfflineIMAP 永远不会“丢失邮件”。在什么情况下会删除邮件?当它存在于状态仓库中,但不在本地或远程仓库中时。因此,很容易看出,当状态仓库“丢失”(无论是损坏还是按照指示删除本地文件夹内容时),OfflineIMAP 将在两个源之间进行全面的、不删除的同步。只要状态仓库不包含比应该有的更多消息的数据,OfflineIMAP 就不会删除您的邮件。
变体
假设我在本地磁盘上为 Maildir 提供的磁盘空间比远程 IMAP 服务器多。最终,你将会处于这样的尴尬境地:希望从远程 IMAP 服务器删除消息,而不会从本地邮件存储中彻底删除它们。OfflineIMAP 提供了 maxage
选项(其中 OfflineIMAP 拒绝承认比某个滑动窗口更老的消息的存在),但如果我们真的希望确保 OfflineIMAP 永远不会从我的本地仓库中删除消息,会怎样呢?
简单:跳过步骤 1-2。
结论
通过利用第三个仓库,对程序的部分数据丢失导致保守的操作,OfflineIMAP 实现了其不惜一切代价防止邮件丢失的算法声明。这也是一个简单的算法,我希望任何使用这个软件的计算机科学家或软件工程师都能花时间确保其正确性,而不是依赖于某些市场材料的传闻。
Grinch 如何窃取 Haskell 堆:ezyang’s 博客
来源:
blog.ezyang.com/2011/04/how-the-grinch-stole-the-haskell-heap/
今天,我们来介绍一下 Grinch。
曾经是个糟糕而令人讨厌的角色,Grinch 已经改过自新。他仍然喜欢偷礼物,但现在他是道德的:只有当没有人再关心一个礼物时,他才会拿走。他是 Haskell 堆的垃圾收集器,在保持 Haskell 堆小(从而使我们的内存使用低)方面扮演着非常重要的角色——特别是因为函数式程序会生成大量垃圾。我们不是特别环保的一群人。
Grinch 在传统的命令式语言中也收集垃圾,因为这一过程基本上是相同的。(我们在此描述了使用 Cheney's 算法的复制收集。)Grinch 首先询问我们在堆中关心哪些对象(根)。他将这些对象移到新堆(疏散),其中将包含要保存的对象。然后,他逐个检查新堆中的对象,确保它们不指向旧堆中的其他对象。如果有的话,他也将这些礼物搬到新堆中(搜刮)。最终,他检查了新堆中的所有礼物,这意味着剩下的一切都是垃圾,他会把它拖走。
但 Haskell 堆和传统堆之间存在差异。
传统堆是所有礼物都已经打开的堆:只有未拆封的盒子和礼品卡,所以 Grinch 只需检查礼品卡引用的礼物,以决定还能搜刮什么其他东西。
然而,Haskell 堆中有未打开的礼物,并且困扰这些礼物的幽灵在关于它们知道的礼物时也非常敏感。所以 Grinch 必须与它们协商,并清除它们指向的任何礼物。
怎么会有礼物变成垃圾呢?在 Haskell 堆和传统堆中,如果我们告诉 Grinch 我们不再关心一个礼物(根集发生变化),那么这个礼物显然就成了垃圾。此外,如果编辑礼品卡使其指向不同的礼物,它曾指向的礼物也可能变得多余(突变)。但 Haskell 堆的独特之处在于:我们打开一个礼物(评估一个 thunk)后,幽灵就消失在虚空中,它的使命完成了。现在 Grinch 可能可以垃圾回收幽灵之前关心的那些礼物。
让我们回顾一下 Haskell 堆上一个礼物的生命周期,特别强调这个礼物与堆上其他礼物的关系。(这些阶段名称实际上是 GHC 堆分析中使用的。)
假设我们想通过保持堆中的礼物数量低来最小化内存使用。有两种方法可以做到这一点:我们可以减少我们关心的礼物数量,或者我们可以减少我们创建的礼物数量。前者对应于使礼物变得死掉,通常是通过打开礼物并释放任何现在不存在的幽灵关心的礼物来完成。后者对应于避免函数应用,通常是在不必要时不打开礼物。
那么,哪一种方法会导致较小的堆?这取决于情况!
并不是只有惰性导致堆上的空间泄漏。过度严格性也可能导致空间泄漏。修复已识别的空间泄漏的关键在于弄清楚情况是哪种情况。请注意:我已经说了很多关于堆上的空间泄漏,但我还没有触及一个困扰许多人的常见堆上的空间泄漏:堆栈上的空间泄漏。敬请关注。
技术注释. 格林奇从一个堆到另一个堆移动礼物的比喻只有在我们假设有复制垃圾收集(GHC 也有紧凑型垃圾收集器,其运行方式不同)时才准确,还有一些细节被省略了(尤其是格林奇如何知道礼物已经移动到新堆中,以及格林奇如何跟踪他在新堆中的位置)。此外,“格林奇拖走垃圾礼物”的形象有些误导:我们只是覆盖旧的内存!此外,GHC 并不只有一个堆:我们有分代垃圾收集器,这实际上意味着有多个堆(格林奇频繁访问年轻堆而不是老堆)。
对于真正的垃圾收集器来说,礼品和礼品卡看起来完全相同:礼品卡只是指向构造信息表和一些指针字段的指针,而礼品(thunk)只是指向可执行代码信息表和其闭包变量字段的指针。对于将数据视为代码的 Haskell 来说,它们是一样的。在没有内置匿名函数的语言中,对于匿名函数的实现可能手动将其表示为指向静态函数代码和其参数附加空间的数据结构。毕竟,惰性值的评估只是一种受控的变异形式!
本作品根据 知识共享署名-相同方式共享 3.0 未本地化版本许可协议 授权。
如何构建可信的数字版权管理系统:ezyang’s 博客
- 摘要。 携带证明的代码 可以用来实现 数字版权管理方案,采用验证证据的 CPU 形式。我们描述了这种方案的工作原理,并认为以这种方式实施的 DRM 既可取又优于可信(“背叛性”)计算方案。这种方案允许用户保留对自己机器的控制,同时允许对软件能力进行特定限制。当 3D 打印机和生物合成机普及时,强加这些限制的能力将变得尤为重要。本文假设读者有一定的技术知识,尽管不需要背景的形式化方法。(如果你知道携带证明的代码如何工作,请离开;这篇文章不适合你。)
-
- ~
众所周知,数字版权管理方案对用户普遍有害。现有的 DRM 方案令人讨厌、适得其反、存在缺陷且基本无效。在实施过程中,它们几乎和间谍软件无异。
我想挑战这种假设。
我对当前数字版权管理技术的状态并不感兴趣。但我确信有更好的方法。我的目标是说服你,基于证明的数字版权管理可能是可行的;它有坚实的理论基础,提供了类似于数字版权管理的有用功能子集,并且它以一种避免了许多与现有可信计算平台相关的隐私、安全和信任问题的方式进行。我想描述一下这个系统会是什么样子,以及它的影响会是什么(例如,它确实提供了一些控制,但当然不能解决模拟洞的问题)。不幸的是,这个系统还不存在;形式化方法背后的技术仍在积极研究中,尚未准备好投入市场。
为什么我现在觉得有必要对这种“象牙塔虚构”的东西说话呢?我们目前正处于科里·多克托罗所称的“通用计算之战”中,国会正在考虑像 SOPA/PIPA 这样的法案,而大型软件供应商正在积极推动像UEFI这样的技术标准。我觉得至关重要的是我们说服在数字版权管理中有利益的行业投资于这种形式化方法的研究。目前正在追求的工具,即可信(“背叛性的”)计算,可能会使数字版权管理首次在人类历史上有效实施,但这将以今天我们所知的通用计算为代价。
如何构建基于证明的数字版权管理
因为我们无法在不描述系统本身的情况下描述系统的影响,所以首先要做的是描述如何实施基于证明的数字版权管理。这一描述还将为讨论围绕这样一个系统的一些问题设置舞台;主要是,这个系统是否可能,以及是否可取。
基于证明的数字版权管理(Proof-based DRM)由两个组件组成。第一个组件是证明验证器,它接受一个定理和该定理的证明作为输入,并返回一个是/否答案,即该证明是否正确。(我们将很快详细讨论“定理”和“证明”在这一背景下的确切含义。)第二个组件是一组定理,这些定理描述了运行在硬件上的软件的期望行为(DRM 策略)。这两个组件集成到硬件中,共同作为运行在 CPU 上的程序的守门员。要加载和运行在这个 CPU 上的代码必须首先通过证明验证器芯片;如果证明无误,用户提供的代码可能会直接执行,其遵守某些数字版权策略由逻辑的力量确保。(须知:在本文的其余部分,我们将不考虑信任底层硬件的问题;这是我们文章的不足之处,但它是一个深刻且棘手的问题,也影响着当前使用的 CPU。)
证明验证器是这一系统的核心。它可以被看作是一个“小数学家”:一个审查证明以检查其正确性的人。他配备一组假设和一个目标(“定理”),以及从假设到目标的一系列推导过程(“证明”)。验证器只需为每个目标检查每一步是否逻辑上从前一步推导出来。“P,并且 P 蕴含 Q。因此,Q!” 证明验证器相对来说研究比较充分,并且存在多个实现,其中包括 Coq、HOL Light、Isabelle 和 F*。通常这些是以软件形式编写的,但也有关于适用于嵌入式设备的证明验证器设计的持续研究。
让我们更深入地探讨一下证明验证器的运行方式。证明验证器的第一个输入是待证明的定理。因此,用户在使用证明验证器时的第一个任务是以计算机可以理解的方式陈述定理。期望证明验证器能理解一段英文或某些 LaTeX 公式显然是不合理的!我们所做的是将数理逻辑写成计算机语言,这就是我们写定理的语言。例如,看看Fermat's Last Theorem的陈述:对于任何三个正整数 a, b, 和 c,不存在整数 n > 2 使得 。在 Coq 中,这个陈述可以写成 forall (x y z:Z) (n:nat), x^(n+3) + y^(n+3) = z^(n+3) -> x <= 0 \/ y <= 0 \/ z <= 0
。直白地说,它表示“对于所有整数 x, y, z (类型为 Z
),以及所有自然数 n (类型为 nat
),如果 成立,则 x, y 或 z 至少有一个小于或等于零。” 尽管“计算机友好”的版本看起来与非正式版本有所不同(我们取了原命题的逆否命题以避免否定,并使用加三来表达指数必须大于二的事实),但它们基本上是相似的。
不幸的是,用类似精确语言来表达程序“内存安全”的含义(即,它永远不会对无效指针进行解引用)要困难得多。将非正式的陈述转化为正式的陈述是一种艺术,没有“正确”的答案。在这个过程中,你必须权衡不同的需求:陈述应该易于人类理解,但也应该容易在计算机中证明。即使在费马定理的情况下,我们也省略了一些细节:什么是整数或自然数?什么是指数运算?又或者,什么是加法?两个整数相等意味着什么?幸运的是,这些问题有传统的答案;即使在更复杂的属性如“内存安全”的情况下,对于编写这类定理的一般设计原则也有合理的理解。
对于基于证明的数字版权管理(DRM),我们需要将“内存安全”等安全属性扩展到数字版权管理方案中可能需要强制执行的属性。如何展示程序永不泄漏基于硬件的私钥的内容,或者证明程序在法律规定的有限无线电频率集内传输?可能性增加,风险也增加。当我们从明确定义的概念领域移动到更不规则、模糊的领域时,很难确定一个形式化是否符合我们的要求,或者它仅仅是一个带有漏洞的规则集。罪犯可能奉行法律的文字,但不奉行精神。在计算机系统中,没有法官可以做出这种评估。因此,一个合理的问题是是否有任何我们希望形式化的属性。幸运的是,答案是肯定的;我们将在下一节回到这个问题,因为它将影响我们能够实现的 DRM 类型。
证明验证器的第二个输入是一个证明。现在,我们声称一个证明是一个长列表(实际上更像是一棵树,因为一个目标的证明可能需要你证明几个子目标)的逻辑步骤,每一步都可以轻松检查。现在,如果你曾尝试阅读真正的数学证明,你会知道检查证明是否正确永远不是这么简单的事情。数学证明会省略步骤。像作家一样,数学家会为他的观众优化他的证明。如果他们有相关的背景知识,他将省略信息以确保更高层次结构的清晰性。你看,数学家不仅对真理感兴趣,还对为什么是真理感兴趣。但当涉及到计算机的证明时,我们不能如此轻率:作为一个愚蠢的计算机,计算机需要每一步的证明都明确说明。这是机械化检验证明的主要挑战之一:一个人可能为欧几里得算法写出一个三行的证明,但对于计算机来说,你可能会写一页。对于涉及计算机程序的更复杂的定理,一个验证项目很容易被涉及的大量代码所淹没。扩展自动定理证明技术是另一个活跃研究领域,当前技术在抽象层面上与传统编程的汇编语言水平相当。
然而,一旦我们拥有了经过验证的机械化证明,我们就拥有了传统数学家所没有的东西:确保证明是正确的,并且程序具有我们要求的属性。 (相反,发表在论文中的数学证明可能是错误的,有时确实是错误的!尽管更有趣的是,定理最终仍然是真实的。)这是一个很好的情况:根据证明抹除原则,我们可以忽略我们构建的复杂证明并直接执行程序。我们可以直接在我们的硬件上运行程序,而不需要在其下运行任何不受信任的启用了 DRM 的操作系统。稍后,当我们将我们的方案与现有的“叛逆计算”方案进行比较时,我们将回到这个问题。
所以我们在这里讨论了什么?我们通过更仔细地观察证明验证器的两个输入:定理和证明,描述了它的工作原理,并涉及了一些与将这项技术应用于现实世界相关的活跃研究领域。在接下来的两节中,我想更详细地讨论这个系统的两个特定方面,即它们如何与数字版权管理相关联:与数字版权管理相关的定理,以及这种方案与现有的“可信计算”方案之间的关系。
什么政策是机器可检查的?
如果有一种方法可以使复制数字视频成为不可能,MPAA 将非常欣赏。但即使是最复杂的技术方案也无法绕过这样一个事实:我可以简单地设置一个录像机,对准我观看电影时的屏幕:这就是所谓的“模拟洞”,是任何复制保护方案的基本限制。基于证明的数字版权管理不能直接用于消除静态材料(如书籍、音乐或电影)的复制。
这是否意味着基于证明的数字版权管理没有任何有用的应用?通过这种方案,我们获得的新能力是能够选择在硬件上运行哪些代码。任何法律后果都严格来说是这种技术实施的副作用。此外,由于通用计算设备随处可见,证明认证的 CPU 对我们来说没有任何好处,除非硬件本身提供了额外的东西。可以将证明验证的 CPU 看作是像烤面包机或微波炉这样的专用设备,它们只有在执行非计算任务(如烤面包或加热食物)时才有趣。
但是肯定有很多有趣的硬件外围设备可以派上用场。事实上,现代 CPU 已经具备了一些沿着这些方向发展的专用芯片:可信平台模块,一种用于安全存储加密密钥的加密处理器规范,已经存在于大多数现代笔记本电脑中。英特尔的可信执行技术允许指定“帷幕”内存区域,操作系统可能无法访问。这些功能的创建是由可信(“背叛”)计算运动推动的,这些功能可以被用于善恶两用。在基于证据的数字版权管理世界中,我们可以为用户提供更精确的控制,保护这些机密数据,用户只需证明这些代码不会泄漏到模块外部。这是信息流控制分析,允许我们追踪私密数据的流动。(对于比特数较低的秘密数据,例如秘密密钥,需要采取大量措施来缓解定时攻击,这些攻击可以以非明显的方式慢慢泄露数据。)这些私密数据甚至可以是专有代码,例如在使用验证证明 CPU 来辅助软件分发的情况下。这将比当前的软件分发方案更加灵活,当前的方案要么是“云端”(软件即服务),要么是必须由客户物理托管的专有全一体“应用盒子”。
基于证据的数字版权管理的另一个重要应用是审计;例如,对某些事件的保证记录到外部存储。记录存储可能是某种只写设备,并且我们通过证明,每当触发与审计要求相关的事件时,相关的记录调用也将被调用。这对电子投票系统将是一个巨大的福音,这类机器特别容易出现无法追责的问题。
然而,更进一步展望未来,我认为最有趣的硬件外围设备将不会真正是外围设备。相反,关系将被颠倒:我们将看到需要通用计算机能力的专用机械。我们不是乘坐汽车和飞行机:我们是乘坐计算机和飞行计算机。但就像我希望我的汽车不会被黑客攻击或感染计算机病毒一样,我希望汽车计算机的计算能力受到限制。这正是基于证据的数字版权管理所做的:它限制可运行程序的集合。3D 打印机和生物合成机器是其他“外围设备”的例子,我怀疑世界各国政府将对其进行大规模监管。
提出定义涉及设备本质的有用定理更具挑战性:如何定义合法使用的界限,或者试图干扰飞机无线电、创建生物武器或伪造货币的企图?如何在数学上指定“正常工作的汽车软件”,而不是“会导致事故的汽车软件”?关键的洞察力在于,虽然完全概述“成为一台无线电”或“成为一辆汽车”的含义是不可能的,但我们可以创建实用的部分规范。我们可以声明“转向正常工作”,而是声明,“在轮子适当输入的情况下,N 毫秒内会产生适当的机械输出。”我们可以声明“GPS 正常工作”,而是声明,“在 GPS 操作期间,记录车辆当前位置信息不会传输到公共互联网。”
也可以将规范模块化,以便独立系统可以验证操作的极其复杂的方面,并且我们的证明检查器仅验证独立系统是否被调用并执行。这里有两个具体例子:
-
我们可以要求特定参数的特定范围,这些范围可能由法律规定。对于简单参数(如无线电频率),允许的内容可以建立在规范中;更复杂的执行规则可能依赖于黑匣子芯片,这些芯片可以被调用以进行是非答案。重要的是要注意,虽然这种芯片的内部实现对用户不可见,但它们对程序行为的影响有限(仅在我们的程序代码调用时),它们也没有网络访问能力来“回家”。规范可能只规定这些子程序被调用,并且它们的结果(成功或失败)得到适当处理。
-
我们可以要求实施隐写协议来进行水印和防伪措施。这些协议可以依赖于一个对设备制造商未知的身份,但在设备首次使用时不可变地初始化,并且如果原始设备被合法搜查和扣押,可以帮助执法机构,而不侵犯第四修正案权利。验证代码是否符合这样的协议需要明确隐写协议正确的定义,并且证明没有其他输入/输出调用干扰隐写输出。
显然,限制设备上运行的代码的能力具有实际应用。确实,基于证明的数字版权管理(DRM)非常类似于可信(不可信)计算平台。因此,在下一节中,我想直接描述这两种方案之间的区别。
这样做为什么更好?
基于证明的数字版权管理与当前的可信(“背叛性”)计算方案有什么区别?两者都通过限制可以直接在硬件上运行的代码来运作。
我认为看到它们之间的区别最简单的方法是考虑它们如何定义可以在计算机上运行的允许程序集。在可信计算中,此集合定义为由某些公司持有的私钥签名的程序集。公司完全控制您被允许运行的程序集。想加载自己的软件吗?没门:它还没有被签名。
在基于证明的数字版权管理中,允许的程序集更大。它将包括公司提供的代码,但也将包括您或开源社区可以编写的任何其他程序,只要它们为数字策略提供适当的证明。这意味着,例如,没有理由接受可能安装了 Rootkit、可能窥探您使用习惯等功能的专有软件。用户保持控制权。这些定理是公共知识,任何人都可以检查、分析,并基于它们编写自己的软件。
如果用户在实践中不能加载自己的代码怎么办?鉴于当前定理证明的困难,可以肯定的是,实际上可能发生的情况是,公司会生成过度适应其专有代码的规范:这些规范对代码的操作有着严格的参数限制,使得其他人几乎无法运行其他任何代码。或者更无辜地说,验证软件所需的努力可能使其只能被资金充裕的公司使用。不幸的是,这是一个目前无法解决的问题:在这个领域我们没有数据。但我有理由乐观。其中一个原因是,当前形式化方法工作中,规范要求简单;复杂的规范更可能存在漏洞,更难以证明其属性。自然选择将在这里发挥作用,如果公司试图通过“后门”将其代码引入,这个后门也将被开源黑客利用。另一个乐观的理由是,我们可能能够开发出构建正确的编程语言,这些语言是你编写的,编译后会自动为你提供你所寻找的特定定理的证明。
当然,也许还有最后一个原因。经过历代证明,开源黑客们极具动力。我们没有理由认为这里会有什么不同。
结论
当然,证明基于证明的数字版权管理比当前相当糟糕的替代方案“更好”,并不意味着它是“好的”。但是,通过本文,我触及了许多我认为这样一个方案可能有价值的原因。它将进一步推动证明载体代码的发展,这项技术本身就很有趣。它将为限制通用计算机功能提供坚实基础,在真正不希望通用计算设备时,而无需使用 rootkit 或间谍软件。更精确地说明您希望产品如何使用的方式,使生产者在市场谈判中拥有更大的灵活性(例如,如果我可以确保视频游戏在首个月内分发有限,我可能愿意以更低的价格出售)。随着通用计算机获得影响现实方式的能力,对这种能提供此功能的技术将会越来越渴望。我认为,很多强大的实体将有兴趣控制某些计算设备上可以运行的内容是无争议的。Cory Doctorow 曾说过“所有试图控制个人电脑的尝试都将聚焦于 rootkit”;如果有另一种方式,至少值得考虑一下。
如何在 Ubuntu 上构建 i686 glibc:ezyang 的博客
来源:
blog.ezyang.com/2011/12/how-to-build-i686-glibc-on-ubuntu/
如何在 Ubuntu 上构建 i686 glibc
一个“简单”的两步过程:
-
为 i686 应用此补丁。(为什么他们还没有在主干中修复这个问题,我不知道。)
-
使用
CFLAGS="-U_FORTIFY_SOURCE -fno-stack-protector -O2"
进行配置(这会禁用 Ubuntu 默认启用的 fortify source 和 stack protection,这些干扰了 glibc。你需要保持优化开启,因为没有优化 glibc 将无法构建。)你需要做一些额外的步骤,比如创建一个单独的构建目录并指定一个前缀。
希望这对其他人有所帮助。如果你想知道为什么我要构建 glibc,那是因为我在 iconv 中报告了这两个 bug:
如何从忙碌的维护者那里得到答案:ezyang's blog
来源:
blog.ezyang.com/2010/09/how-to-get-answers-from-busy-maintainers/
如何从忙碌的维护者那里得到答案
作为那些“忙碌维护者”之一,我注意到在处理支持请求时,我会假设一定的认知模式。本文讨论了我处理支持请求的方式,但我也广泛地看到这种行为在其他项目中普遍存在。虽然作为提出请求的人,你可能会觉得这种心态令人沮丧和迂腐,但如果你是一名同行开发者,你可以利用它来为自己谋利。
当我看到你的支持请求时,我会怎么想?
-
我希望帮助你。 如果我对回答你的问题没有兴趣,我早就停止支持这个项目了。但是...
-
我希望尽可能用最少的认知努力来解决问题。 我希望用我的脑力去思考新功能和理论,而不是为用户做侦探工作。相关地,
-
我希望尽可能用最少的认知努力来解决问题。 如果我实际上需要做一些事情来找出如何解决你的问题,我更倾向于拖延。如果我不确定需要做什么,可能会加倍努力。矛盾的是,如果我能够访问你的设置,我可能会直接去修复它,因为我的常规调试周期比“要求你尝试一些东西或获取一些信息并向我报告”的速度要快得多。
注意到特性请求遵循不同的规则。对于一些用来解决问题的软件,我可能对不帮助我的特性不感兴趣。但是,如果我要添加一个对你没有立即帮助的特性,我希望能做得正确——仅仅因为它对你有效果,并不意味着它应该在我的代码库中。当然,一旦有其他人试图加入代码,我的代码标准就会大幅提升。
遵循这些懒惰善意的原则,你得到的支持请求的答案可能是:
-
简而言之,
-
猜测它可能是一些常见问题,你可能在谷歌搜索时已经注意到了(你确实尝试过先谷歌搜索了,对吧?),
-
要求你提供更多(有时看似无关紧要的)信息,
-
要求你尝试一些可能有效或无效的方法,并
-
晚了。(我每隔几周会留出一些时间来处理他们的未解决问题,那时你才会得到答复。)
那么你如何利用这些事实为自己谋利呢?
-
不要假设你的问题和其他人的问题相同。 支持请求不是 bug!它们可能由 bug 产生,但每个支持请求都有其独特的特点,直到其原因被确认为特定的技术问题之前,如果你决定你的问题与其他复杂问题相同,那么你只会搅混水。在你的请求中引用现有的 bug 或支持请求,并让维护者决定。
-
提供大量信息,即使你认为这些信息是不必要的。 你为什么提出这个支持请求?我不能代表每个人,但当我请求帮助时,通常是因为我相信有人拥有一个更全面的大局观,因为我相信他们会有一些我没有的见解。那么,我有什么理由相信一些我认为无用的调试信息不会帮助到比我更熟悉软件的人发现问题呢?此外,如果我每两周只看一次支持请求,你最好希望在我实际查看你的请求时给我足够的信息来解决问题。
同样重要的是,不要试图“精选”信息:我经历过的一些最糟糕的调试会话之一就是因为我误解了一些证据,然后基于错误的假设采取行动。
-
确保维护者能够轻松调试问题。 最小化测试案例,最小化测试案例,最小化测试案例。如果能够提供无需任何设置即可运行的代码,那就更好了,但通常特定的设置将包含维护者需要解决你的问题所需的信息。
-
自己进行调试。 你已经把一个维护者不知如何回答的问题交给了他们。这可能是一个真正的错误。如果他们特别愿意帮助,他们会尝试重现你的问题,然后从他们的本地设置进行调试;如果不愿意,他们会通过你来模拟调试过程。当他们开始要求你提供具体的信息或调整时,你就可以知道情况了。不要让他们启动这个漫长而耗时的过程:自己进行调试。因为你能看到问题,所以你是最适合实际进行调试的人。
“但是,”你可能会说,“我为什么要自愿调试别人的代码!”这种情况有点不同:这不是原作者不可见的遗留代码:你有一个愿意帮助你的上游。你有人来验证你的结果,告诉你要使用哪些工具(像 PHP 这样的东西很容易调试:只需编辑一些源文件;像 C 应用程序这样的东西更难,但带有调试符号的 gdb 可以起到奇效),指导你朝正确的方向前进,并在你告诉他们一些关键信息时快速解决其余的问题。关键在于:如果你对软件有疑问,并且知道可以运行实验来回答这个问题,不要问:运行这个实验。
-
促使我修复我的文档。 我担心回答你的问题,而不一定担心修复我的软件。如果有些文档不清楚,或者有些运行时检查可以避免你需要提问,那么通过指出它来促使我修复它!不同的人以不同方式吸收信息,因此,如果能提供尽可能多的信息传达某些信息,那就很好。
如何将 GHC API 程序与 Cabal 集成:ezyang 的博客
来源:
blog.ezyang.com/2017/02/how-to-integrate-ghc-api-programs-with-cabal/
GHC 不仅是一个编译器:它还是一个库,提供了多种功能,任何对 Haskell 源代码进行分析感兴趣的人都可以使用。Haddock、hint 和 ghc-mod 都是使用 GHC API 的包。
对于希望使用 GHC API 的任何程序而言,与 Cabal(以及通过它的 cabal-install 和 Stack)集成是一个挑战。最明显的问题是,在构建时针对由 Cabal 安装的包时,需要向 GHC 传递适当的标志,告诉它应该使用哪些包数据库和实际包。在这一点上,人们往往采用 某些不太正规的策略 来获取这些标志,并希望一切顺利。对于常用的包,这种策略可以完成任务,但对于需要额外处理的罕见包(例如预处理、额外的 GHC 标志、构建 C 源码),不太可能得到正确处理。
与 Cabal 集成 GHC API 程序的一个更可靠的方法是控制反转:让 Cabal 调用你的 GHC API 程序,而不是反过来!我们要如何让 Cabal/Stack 调用我们的 GHC API 程序?我们将替换掉经过所有命令的普通 GHC 的 GHC 可执行文件,除了 ghc --interactive
,我们将将其传递给 GHC API 程序。然后,我们将使用我们重载的 GHC 调用 cabal repl
/stack repl
,在我们本来会打开 GHCi 提示符的地方,我们将运行我们的 API 程序。
通过这种方式,所有应该传递给 ghc --interactive
调用的标志都传递给我们的 GHC API 程序。我们如何解析这些标志?最方便的方法是创建一个 前端插件,这样你可以为 GHC 创建一个新的主要模式。当你的代码被调用时,所有标志已经被处理过了(无需与 DynFlags
纠缠!)。
言归正传,是时候写一些代码了。首先,让我们看一个简单的前端插件:
module Hello (frontendPlugin) where
import GhcPlugins
import DriverPhases
import GhcMonad
frontendPlugin :: FrontendPlugin
frontendPlugin = defaultFrontendPlugin {
frontend = hello
}
hello :: [String] -> [(String, Maybe Phase)] -> Ghc ()
hello flags args = do
liftIO $ print flags
liftIO $ print args
这个前端插件直接来自 GHC 文档(但导入了足够的内容以使其能够编译;-))。它打印出传递给它的参数。
接下来,我们需要一个围绕 GHC 的包装程序,当以 --interactive
标志调用时,将调用我们的插件而不是常规的 GHC。以下是适用于类 Unix 系统的简单脚本:
import GHC.Paths
import System.Posix.Process
import System.Environment
main = do
args <- getArgs
let interactive = "--interactive" `elem` args
args' = do
arg <- args
case arg of
"--interactive" ->
["--frontend", "Hello",
"-plugin-package", "hello-plugin"]
_ -> return arg
executeFile ghc False (args' ++ if interactive then ["-user-package-db"] else []) Nothing
给这个 Cabal 文件,并使用 cabal install
将其安装到用户包数据库中(如果你想使用非标准的 GHC,请参阅下面的第二个要点):
name: hello-plugin
version: 0.1.0.0
license: BSD3
author: Edward Z. Yang
maintainer: ezyang@cs.stanford.edu
build-type: Simple
cabal-version: >=1.10
library
exposed-modules: Hello
build-depends: base, ghc >= 8.0
default-language: Haskell2010
executable hello-plugin
main-is: HelloWrapper.hs
build-depends: base, ghc-paths, unix
default-language: Haskell2010
现在,要运行你的插件,你可以执行以下任意一种方法:
-
cabal repl -w hello-plugin
-
cabal new-repl -w hello-plugin
-
stack repl --system-ghc --with-ghc hello-plugin
要在特定包上运行插件,请将适当的标志传递给repl
命令。
此示例的完整代码可以在 GitHub 上的ezyang/hello-plugin检索到。
这里有一些杂项提示和技巧:
-
如有必要,可以添加
--ghc-options=-ffrontend-opt=arg
来向插件传递额外的标志(如果愿意,可以围绕这一点编写另一个包装脚本!) -
如果您使用的 GHC 不是来自您的 PATH 的那个安装了
hello-plugin
,您需要将正确的ghc
/ghc-pkg
/等可执行文件放在 PATH 的最前面;如果您仅使用-w
,Cabal 的自动检测将会混淆。如果您正在运行cabal
,解决此问题的另一种方法是通过传递--with-ghc-pkg=PATH
来指定ghc-pkg
的位置(Stack 不支持此功能)。 -
您不必将插件安装到用户包数据库中,但是需要调整包装程序以便能够找到包实际安装的位置。我不知道有什么方法可以在不编写自定义设置脚本的情况下获取此信息;希望将插件安装到用户包数据库中对于普通用户来说不会太麻烦。
-
cabal-install
和stack
在如何传递主模块给 GHCi 的调用上略有不同:cabal-install
将为每个主包模块调用 GHC;Stack 将传递一个 GHCi 脚本以加载这些内容。我不确定哪种方法更方便,但如果您已经知道要查看哪个模块(可能是从前端选项中获得的),那可能并不太重要。
如何维护配置文件的原始副本:ezyang 的博客
来源:
blog.ezyang.com/2014/01/how-to-maintain-a-pristine-copy-of-your-configuration-files/
etckeeper 是一个非常好的工具,用于将你的 /etc 目录置于版本控制之下,但它不会告诉你一件事情,即你的配置与配置的原始版本之间的差异(如果你在系统上安装了相同的软件包,但没有更改任何配置)。人们曾希望实现这一点,但我找不到真正能够实现这一点的工具。一个月前,我找到了在 etckeeper 中用 Git 仓库实现这一目标的一个简单而好的方法。这个想法是维护一个原始分支,在升级发生时,自动将补丁(自动生成的)应用到原始分支上。这个过程最适合在新安装的系统上运行,因为如果你没有从一开始跟踪原始分支,我没有很好的方法来重建历史。
这是一个示例:
-
安装 etckeeper。最好使用 etckeeper 1.10 或更高版本,但如果没有,你应该从最新版本中替换 30store-metadata 的副本。这很重要,因为在 1.10 之前的版本中,元数据存储包含了被忽略的文件,这意味着你将会得到很多假冲突。
-
初始化 Git 仓库,使用
etckeeper init
命令,并进行首次提交git commit
。 -
创建一个原始分支:
git branch pristine
(但保留在主分支上) -
修改 etckeeper 配置,使得
VCS="git"
,AVOID_DAILY_AUTOCOMMITS=1
和AVOID_COMMIT_BEFORE_INSTALL=1
:diff --git a/etckeeper/etckeeper.conf b/etckeeper/etckeeper.conf index aedf20b..99b4e43 100644 --- a/etckeeper/etckeeper.conf +++ b/etckeeper/etckeeper.conf @@ -1,7 +1,7 @@ # The VCS to use. #VCS="hg" -#VCS="git" -VCS="bzr" +VCS="git" +#VCS="bzr" #VCS="darcs" # Options passed to git commit when run by etckeeper. @@ -18,7 +18,7 @@ DARCS_COMMIT_OPTIONS="-a" # Uncomment to avoid etckeeper committing existing changes # to /etc automatically once per day. -#AVOID_DAILY_AUTOCOMMITS=1 +AVOID_DAILY_AUTOCOMMITS=1 # Uncomment the following to avoid special file warning # (the option is enabled automatically by cronjob regardless). @@ -27,7 +27,7 @@ DARCS_COMMIT_OPTIONS="-a" # Uncomment to avoid etckeeper committing existing changes to # /etc before installation. It will cancel the installation, # so you can commit the changes by hand. -#AVOID_COMMIT_BEFORE_INSTALL=1 +AVOID_COMMIT_BEFORE_INSTALL=1 # The high-level package manager that's being used. # (apt, pacman-g2, yum, zypper etc)
-
将 这个补丁应用到 etckeeper/commit.d/50vcs-commit。这个补丁负责保持原始分支的最新状态(下面有更多解释)。
-
创建一个
.gitattributes
文件,内容为.etckeeper merge=union
。这样做可以使元数据文件上的合并使用联合策略,显著减少假冲突:diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b7a1f4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.etckeeper merge=union
-
提交这些更改。
-
允许推送到已检出的
/etc
目录,通过运行git config receive.denyCurrentBranch warn
来配置。 -
完成!尝试安装一个包含一些配置的软件包,然后在
/etc
目录中运行sudo gitk
来查看结果。你可以通过运行sudo git diff pristine master
来进行差异比较。
所以,底层是怎么回事呢?过去我想要设置这样一个环境的最大问题是,你希望包管理器将其更改应用到原始的/etc
,这样你就可以在生产版本上自己合并这些更改,但如何说服dpkg
让它相信/etc
其实在别的地方呢?也不希望恢复系统配置到原始版本,应用更新,然后再恢复:这样只会麻烦不断。所以,想法是正常应用(生成的)补丁,然后重新应用补丁(使用cherry-pick
)到原始分支,并且重写历史记录,使得父指针正确。所有这些都发生在/etc
之外,因此生产环境的配置文件副本永远不会被触及。
当然,有时cherry-pick
可能会失败。在这种情况下,你会收到这样的错误信息:
Branch pristine set up to track remote branch pristine from origin.
Switched to a new branch 'pristine'
error: could not apply 4fed9ce... committing changes in /etc after apt run
hint: after resolving the conflicts, mark the corrected paths
hint: with 'git add <paths>' or 'git rm <paths>'
hint: and commit the result with 'git commit'
Failed to import changes to pristine
TMPREPO = /tmp/etckeeper-gitrepo.CUCpBEuVXg
TREEID = 8c2fbef8a8f3a4bcc4d66d996c5362c7ba8b17df
PARENTID = 94037457fa47eb130d8adfbb4d67a80232ddd214
不要担心:发生的一切只是pristine
分支不是最新的。你可以通过查看$TMPREPO/etc
解决此问题,在那里你会看到某种合并冲突。解决冲突并提交。现在你需要手动完成剩余的脚本,可以这样做:
git checkout master
git reset --hard HEAD~ # this is the commit we're discarding
git merge -s ours pristine
git push -f origin master
git push origin pristine
要确保你做得对,回到/etc
并运行git status
:它应报告工作目录干净。否则,可能存在差异,合并可能没有做对。
我已经测试了这个设置一个月了,进展非常顺利(尽管我从未尝试过使用这种设置进行完整的发布升级)。不幸的是,正如我之前所说的,我没有一种方法可以从头构建一个原始分支,如果你想将这个技巧应用到现有系统。当然没有什么能阻止你:你总是可以决定开始,然后你只记录从你开始记录原始的差异。试试看吧!
如何在 Haskell 中选择你的字符串库:ezyang 的博客
如何在 Haskell 中选择你的字符串库
注意。 在来自布莱恩·奥沙利文的批评后,我重构了页面。
“不同的文本处理库如何比较,我们在什么情况下应该使用哪个包?” 克里斯·艾德霍夫提问。后一个问题更容易回答。使用bytestring处理二进制数据——原始的位和字节,没有关于语义含义的明确信息。使用text处理表示人类书面语言的 Unicode 数据,通常表示为带有字符编码的二进制数据。两者(尤其是 bytestring)广泛使用,并且很可能会成为——如果它们还没有成为的话——标准。
但是,在 Hackage 上还有很多更专业的字符串处理库。由于没有在实际项目中使用过所有这些库,我不会对它们的稳定性或实现进行评判;相反,我们将根据它们填补的特定需求对它们进行分类。有几个维度可以用来分类字符串库或模块:
-
二进制还是文本? 二进制是原始的位和字节:它不包含关于
0
或0x0A
的明确信息。文本用于表示人类语言,通常是带有字符编码的二进制数据。这是程序员需要了解的最重要的区别。 -
如果是文本,ASCII、8 位或 Unicode? ASCII 简单但只支持英语;8 位(例如 Latin-1)无处不在,经常因向后兼容性而必需;Unicode 是“正确的方式”但稍微复杂。Unicode 进一步问,内存编码是什么? UTF-16 易于处理,而 UTF-8 对英文文本可能会节省一倍的内存。大多数语言选择 Unicode 和 UTF-16 供程序员使用。
-
解包还是打包? 解包字符串是本地选择,只是字符的链表。打包字符串是经典的 C 数组,允许高效的处理和内存使用。大多数语言使用打包字符串:Haskell 以其使用链表而闻名(或者说是臭名昭著)。
-
懒惰还是严格? 懒惰更灵活,允许诸如流式处理之类的操作。严格字符串必须完全保存在内存中,但在整个字符串需要计算的情况下可能更快。打包的懒惰表示通常使用分块来减少生成的惰性求值。毋庸置疑,严格字符串是经典解释,尽管懒惰字符串在流式处理中有用。
根据这些问题,以下是 Hackage 字符串库的分类:
除了内存编码外,还涉及到源和目标编码的问题:希望是正常的东西,但偶尔你会遇到 Shift_JIS 文本,需要对其进行处理。你可以用 encoding(处理 String
或严格/懒惰 ByteString
,可以通过 ByteSource
和 ByteSink
扩展)或者 iconv(处理严格/懒惰 ByteString
)。
Unicode 笑话。
Well done, mortal! But now thou must face the final Test...--More--
Wizard the Evoker St:10 Dx:14 Co:12 In:16 Wi:11 Ch:12 Chaotic
Dlvl:BMP $:0 HP:11(11) Pw:7(7) AC:9 Xp:1/0 T:1
Alt 文本。 是的,我到了补充特殊用途平面,但后来被 TAG LATIN CAPITAL LETTER A 给干掉了。看起来像是普通的 A,所以我以为它只是一个 Archon……
如何像 Pythonista 一样阅读 Haskell 代码 : ezyang’s blog
tl;dr — 保存此页面以供将来参考。
你是否曾经处于需要快速理解某种陌生语言代码功能的情况?如果该语言看起来很像你熟悉的语言,通常你可以猜出大部分代码的作用;即使你可能不完全熟悉所有语言特性的工作方式。
对于 Haskell 来说,这有点困难,因为 Haskell 语法看起来与传统语言非常不同。但这里没有真正的深层区别;你只需要适当地看待它。以下是一个快速的、大部分不正确但希望对解释 Haskell 代码有用的指南,就像一个 Python 程序员一样。最后,你应该能够解释这段 Haskell 代码片段(某些代码被省略为...
):
runCommand env cmd state = ...
retrieveState = ...
saveState state = ...
main :: IO ()
main = do
args <- getArgs
let (actions, nonOptions, errors) = getOpt Permute options args
opts <- foldl (>>=) (return startOptions) actions
when (null nonOptions) $ printHelp >> throw NotEnoughArguments
command <- fromError $ parseCommand nonOptions
currentTerm <- getCurrentTerm
let env = Environment
{ envCurrentTerm = currentTerm
, envOpts = opts
}
saveState =<< runCommand env command =<< retrieveState
类型. 忽略::
后的所有内容(同样,你可以忽略type
, class
, instance
和newtype
)。有些人声称类型帮助他们理解代码;如果你是完全的初学者,像Int
和String
可能会有所帮助,而像LayoutClass
和MonadError
则不会。不要太担心这些。
参数. f a b c
翻译成 f(a, b, c)
。Haskell 代码省略括号和逗号。这导致我们有时需要用括号来表示参数:f a (b1 + b2) c
翻译成 f(a, b1 + b2, c)
。
美元符号. 因为像a + b
这样的复杂语句很常见,而 Haskell 程序员不太喜欢括号,所以美元符号用于避免括号:f $ a + b
等同于 Haskell 代码 f (a + b)
,翻译成 f(a + b)
。你可以把它想象成一个大的左括号,自动在行尾关闭(不再需要写))))))
!特别是,如果你堆叠它们,每一个都会创建更深的嵌套:f $ g x $ h y $ a + b
等同于 f (g x (h y (a + b)))
,翻译成 f(g(x,h(y,a + b))
(尽管有些人认为这是不良实践)。
在某些代码中,你可能会看到 <$>
的变体(带有尖括号)。你可以将 <$>
看作与 $
同样的方式处理。(你可能还会看到 <*>
;假装它是一个逗号,所以 f <$> a <*> b
翻译成 f(a, b)
。对于普通的 $
,没有真正的等价物)
反引号. x `f` y
翻译成 f(x,y)
。反引号中的内容通常是一个函数,通常是二元的,左右两边是参数。
等号. 有两种可能的含义。如果它在代码块的开头,它只是表示你正在定义一个函数:
doThisThing a b c = ...
==>
def doThisThing(a, b, c):
...
或者如果你看到它靠近let
关键字,它就像一个赋值操作符:
let a = b + c in ...
==>
a = b + c
...
左箭头. 也起到了赋值操作符的作用:
a <- createEntry x
==>
a = createEntry(x)
为什么不使用等号?骗局。(更准确地说,createEntry x
有副作用。更确切地说,这意味着表达式是单子的。但这只是小把戏。现在先忽略它。)
右箭头。 它很复杂。我们稍后会回头再说。
Do 关键字。 线噪声。你可以忽略它。(它确实提供一些信息,即下面存在副作用,但你在 Python 中看不到这种区别。)
返回。 线噪声。也可以忽略。(你永远不会看到它用于控制流。)
点。 f . g $ a + b
翻译成 f(g(a + b))
。实际上,在 Python 程序中,你可能更容易看到:
x = g(a + b)
y = f(x)
但 Haskell 程序员对额外的变量过敏。
绑定和鱼操作符。 你可能会看到类似 =<<
, >>=
, <=<
和 >=>
的东西。这些基本上只是更多摆脱中间变量的方法:
doSomething >>= doSomethingElse >>= finishItUp
==>
x = doSomething()
y = doSomethingElse(x)
finishItUp(y)
有时,Haskell 程序员决定如果变量在某处被赋值,将其在另一方向中进行可能更漂亮:
z <- finishItUp =<< doSomethingElse =<< doSomething
==>
x = doSomething()
y = doSomethingElse(x)
z = finishItUp(y)
最重要的是通过查看 doSomething
、doSomethingElse
和 finishItUp
的定义来反向工程实际发生的事情:这将给你一个线索,指出鱼操作符“流动”的方式。如果你这样做,你可以以相同的方式读取 <=<
和 >=>
(它们实际上执行函数组合,就像点操作符一样)。将 >>
看作分号(例如,没有赋值涉及):
doSomething >> doSomethingElse
==>
doSomething()
doSomethingElse()
部分应用。 有时,Haskell 程序员会调用一个函数,但是他们没有传足够的参数。不要担心;他们可能已经在别处安排了剩余的参数给函数。忽略它,或者寻找接受匿名函数作为参数的函数。一些常见的罪魁祸首包括 map
、fold
(及其变体)、filter
、组合操作符.
、鱼操作符(=<<
等)。这在数值操作符上经常发生:(+3)
翻译成lambda x: x + 3
。
控制操作符。 凭直觉使用它们:它们做你想要的事情!(即使你认为它们不应该那样做。)所以如果你看到:when (x == y) $ doSomething x
,它读起来像是“当 x 等于 y 时,调用带有 x 作为参数的 doSomething。”
忽略你无法真正将其翻译成 when(x == y, doSomething(x))
(因为那样会导致 doSomething
总是被调用)。事实上,when(x == y, lambda: doSomething x)
更准确,但也许假装 when
也是一种语言构造更舒服。
if
和 case
是内置关键字。它们的工作方式符合你的预期。
右箭头(真的!) 右箭头与左箭头无关。把它们看作冒号:它们总是靠近case
关键字和反斜杠符号,后者是 lambda 函数:\x -> x
翻译成lambda x: x
。
使用 case
进行模式匹配是一个非常好的功能,但在这篇博文中有点难以解释。可能最容易的近似是带有一些变量绑定的 if..elif..else
链:
case moose of
Foo x y z -> x + y * z
Bar z -> z * 3
==>
if isinstance(moose, Foo):
x = moose.x # the variable binding!
y = moose.y
z = moose.z
return x + y * z
elif isinstance(moose, Bar):
z = moose.z
return z * 3
else:
raise Exception("Pattern match failure!")
括号。 如果一个函数以 with
开头,你可以知道它是一个括号函数。它们的工作方式类似于 Python 中的上下文:
withFile "foo.txt" ReadMode $ \h -> do
...
==>
with open("foo.txt", "r") as h:
...
(你可能还记得前面的反斜杠。是的,那是一个 lambda 表达式。是的,withFile
是一个函数。是的,你可以定义你自己的。)
异常。 throw
、catch
、catches
、throwIO
、finally
、handle
等看起来像这样的函数实际上都按你预期的方式工作。然而它们看起来可能有点奇怪,因为这些都不是关键字:它们都是函数,并遵循所有这些规则。例如:
trySomething x `catch` \(e :: IOException) -> handleError e
===
catch (trySomething x) (\(e :: IOException) -> handleError e)
==>
try:
trySomething(x)
except IOError as e:
handleError(e)
也许吧。 如果你看到 Nothing,可以将其视为 None
。因此 isNothing x
用于测试 x
是否为 None
。它的反义词是什么?Just
。例如,isJust x
用于测试 x
是否不为 None
。
你可能会看到很多与保持 Just
和 None
有关的噪音。这是其中一个最常见的:
maybe someDefault (\x -> ...) mx
==>
if mx is None:
x = someDefault
else:
x = mx
...
这里有一个特定的变体,用于当 null 是一个错误条件时:
maybe (error "bad value!") (\x -> ...) x
==>
if x is None:
raise Exception("bad value!")
记录。 它们的工作方式符合你的预期,尽管 Haskell 允许你创建没有名称的字段:
data NoNames = NoNames Int Int
data WithNames = WithNames {
firstField :: Int,
secondField :: Int
}
所以 NoNames
在 Python 中可能被表示为元组 (1, 2)
,而 WithNames
则是一个类:
class WithNames:
def __init__(self, firstField, secondField):
self.firstField = firstField
self.secondField = secondField
然后创建是非常简单的 NoNames 2 3
翻译为 (2, 3)
,而 WithNames 2 3
或 WithNames { firstField = 2, secondField = 3 }
翻译为 WithNames(2,3)
。
访问器有点不同。最重要的记住的是 Haskeller 把他们的访问器放在变量之前,而你可能更熟悉它们放在之后。所以 field x
翻译为 x.field
。如何拼写 x.field = 2
?嗯,你真的做不到。不过你可以复制一个并进行修改:
return $ x { field = 2 }
==>
y = copy(x)
y.field = 2
return y
或者,如果你用数据结构的名称(以大写字母开头)替换 x
,你也可以从头开始创建一个。为什么我们只允许你复制数据结构?这是因为 Haskell 是一种 纯 函数语言;但不要让这太让你担心。这只是 Haskell 的另一个怪癖。
列表推导式。 它们最初来自 Miranda-Haskell 衍生!只是符号更多。
[ x * y | x <- xs, y <- ys, y > 2 ]
==>
[ x * y for x in xs for y in ys if y > 2 ]
原来 Haskeller 经常更喜欢以多行形式书写列表推导式(也许他们觉得更容易阅读)。它们看起来像这样:
do
x <- xs
y <- ys
guard (y > 2)
return (x * y)
因此,如果你看到一个左箭头,它看起来并不像在执行副作用,也许它是一个列表推导式。
更多的符号。列表在 Python 中的工作方式与您期望的相同;[1, 2, 3]
实际上是一个包含三个元素的列表。冒号,如x:xs
表示构造一个以x
开头、xs
结尾的列表(对于 Lisp 爱好者来说是cons
)。++
是列表连接操作。!!
表示索引。反斜杠表示 lambda。如果您看到一个您不理解的符号,请尝试在Hoogle上查找它(是的,它适用于符号!)。
更多的行噪声。以下函数可能是行噪声,可以忽略不计。liftIO
、lift
、runX
(例如runState
)、unX
(例如unConstructor
)、fromJust
、fmap
、const
、evaluate
、参数前的感叹号(f !x
)、seq
、井号(例如I# x
)。
汇总所有信息。让我们回到原始代码片段:
runCommand env cmd state = ...
retrieveState = ...
saveState state = ...
main :: IO ()
main = do
args <- getArgs
let (actions, nonOptions, errors) = getOpt Permute options args
opts <- foldl (>>=) (return startOptions) actions
when (null nonOptions) $ printHelp >> throw NotEnoughArguments
command <- fromError $ parseCommand nonOptions
currentTerm <- getCurrentTerm
let env = Environment
{ envCurrentTerm = currentTerm
, envOpts = opts
}
saveState =<< runCommand env command =<< retrieveState
通过一些猜测,我们可以得出这个翻译:
def runCommand(env, cmd, state):
...
def retrieveState():
...
def saveState(state):
...
def main():
args = getArgs()
(actions, nonOptions, errors) = getOpt(Permute(), options, args)
opts = **mumble**
if nonOptions is None:
printHelp()
raise NotEnoughArguments
command = parseCommand(nonOptions)
currentTerm = getCurrentTerm()
env = Environment(envCurrentTerm=currentTerm, envOpts=opts)
state = retrieveState()
result = runCommand(env, command, state)
saveState(result)
对于对 Haskell 语法的非常肤浅的理解来说,这并不差(只有一个明显无法翻译的部分,需要知道 fold 是什么。并非所有的 Haskell 代码都是折叠;我会再次重申,请不要过多担心它!)
我称之为“行噪声”的大多数东西实际上都有深刻的原因,如果您对这些区别背后的真正原因感到好奇,我建议您学习如何编写Haskell。但如果您只是阅读 Haskell,我认为这些规则应该已经足够了。
感谢Keegan McAllister、Mats Ahlgren、Nelson Elhage、Patrick Hurst、Richard Tibbetts、Andrew Farrell 和 Geoffrey Thomas 的评论。还要感谢两位 Python 使用者#python
的友好居民,asdf
和talljosh
,因为他们是 Python 使用的试验品。
附言。如果您真的好奇foldl (>>=) (return startOptions) actions
做了什么,它实现了责任链模式。当然。
如何像专业人士一样使用 Vim 的 textwidth:ezyang 的博客
有许多小的博客文章包含关于 Vim 中各种单行选项的建议。这篇文章属于这一类别,但我希望能对 Vim 配置的一个小子系统:自动换行,进行更全面的介绍。
在编程时,自动换行可能有点讨厌,因为即使代码段超过推荐的 72/80 列宽度,你可能也不想立即将其换行;但如果你在写文档或电子邮件,那确实是你想要的行为。默认情况下,vim 不会为你自动换行;打开它只是一个在需要时能够切换开关的问题。
这里是你关心的配置选项:
-
textwidth(或tw):控制你想要使用的换行宽度。使用
:set tw=72
设置换行宽度;默认情况下未设置,因此禁用换行。如果设置了此值,则完全受下面的formatoptions影响,这通常是filetype敏感的。 -
formatoptions(或fo):控制是否启用自动换行,取决于是否设置了
t
标志。使用:set fo+=t
打开该标志,使用:set fo-=t
关闭该标志。还有一些辅助的格式选项,但它们并不那么重要。 -
wrapmargin(或wm):根据终端大小控制何时换行;我通常认为使用这个功能是一个坏主意。
理解这两个选项之间的互动非常重要。下面是一个简短的互动表:
-
tw=0 fo=cq wm=0: 不自动换行,重新换行会在第 80 列处换行
-
tw=72 fo=cq wm=0: 不自动换行,重新换行会在第 72 列处换行
-
tw=0 fo=cqt wm=0: 不自动换行,重新换行会在第 72 列处换行
-
tw=0 fo=cqt wm=5: 在右边缘 5 列处自动换行
-
tw=72 fo=cqt wm=0: 在第 72 列自动换行
注意,要实现自动换行,你需要同时设置fo+=t和tw或wm为非零值。还要注意,某些filetype会自动给你fo+=t,而其他的则不会。
这里是你关心的按键操作:
-
gq:执行“格式化操作”,在我们的宇宙中意味着“重新排列文本”。这将尊重前导缩进和符号字符,通常很好,但如果你重新排列一个项目符号点(因为文本将突然在每个前面添加星号),会有点讨厌。
-
段落移动。最重要的是 vip(前面的 v 将我们放入选择模式),它选择一个“内部段落”;这意味着如果你在段落内的任何位置,你可以输入 vip,整个段落将立即为你选择,可能随后可以运行 gq。vap 也是等效的,尽管它选择整个段落,如果你想删除它,这更合适。大括号在段落之间移动。
format-options 的值将大大改变 Vim 的行为,因此我强烈建议将其显示在一个你可以快速参考的地方。我使用:
set statusline=...[%{&fo}]...
你可能有自己的状态栏;只需将那个小片段减去省略号添加到方便的地方即可。为了更好的效果,我在我的 vimrc 中明确地写入 set fo-=t
,以防止自己感到惊讶(因为我主要在 vim 中编写代码)。
再来一个巧妙的技巧:
augroup vimrc_autocmds
autocmd BufEnter * highlight OverLength ctermbg=darkgrey guibg=#592929
autocmd BufEnter * match OverLength /\%74v.*/
augroup END
这将突出显示超过 74 列的所有字符(可以根据需要调整该数字)为深灰色(可以根据需要调整该颜色),在没有自动换行时是一个很好的视觉提示,当你需要考虑分行时。
Ur/Web 记录的工作原理及其对 Haskell 可能意味着什么:ezyang 的博客
来源:
blog.ezyang.com/2012/04/how-urweb-records-work-and-what-it-might-mean-for-haskell/
Ur是一种编程语言,除其他外,它具有一个非常有趣的记录系统。记录系统在 Haskell 社区中是一个非常激烈辩论的话题,我注意到有人曾评论过“Ur/Web 有一个非常先进的记录系统。如果有人能够看一看 UR 实现论文,并尝试从 Haskell 的角度来梳理记录的解释,那将非常有帮助!”本文试图执行这种提炼,基于我与 Ur 记录系统互动和其主要存在原因之一:元编程的经验。(次要命名注意事项:Ur 是基础语言,而 Ur/Web 是用于 Web 编程的基础语言的专业化版本,它实际上也有一个编译器。为了技术上的精确性,我将在本文中始终将语言称为 Ur。)
记录和代数数据类型并不相同
在 Haskell 中,如果要定义记录,您必须去编写data
声明:
data Foo = Foo { bar :: Int, baz :: Bool }
在 Ur 中,这两个概念是分开的:您可以定义一个代数数据类型(Foo
构造函数),并且可以编写描述记录的类型(类型的{ foo :: Int, bar :: Bool}
部分)。为了强调这一点,实际上有很多种方式可以在 Ur/Web 中拼写这个记录。我可以定义一个类型同义词:
type foo = { Bar : int, Baz : bool }
这对我来说没有提供保护,以免将其与结构上相似但语义上不同的type qux = { Bar : int, Baz : bool }
混淆,或者我可以定义:
datatype foo = Foo of { Bar : int, Baz : bool }
这被展开为:
type foo' = { Bar : int, Baz : bool }
datatype foo = Foo of foo'
也就是说,这种数据类型只有一个构造函数,只接受一个参数,即记录!这个定义更接近原始 Haskell 定义的精神。(ML 用户可能熟悉这种风格;Ur 明显来自于这一传统。)
这种将代数数据类型与记录分离的设计意味着现在我们有了明显的记录构造设施(let val x = { Bar = 2, Baz = true }
)和记录投影(x.Bar
);虽然如果我有一个数据类型,我必须先解包它才能从中投影。这些记录类型在排列上是唯一的(顺序无关紧要),这使它们比HList
更有趣。它们也非常简洁:单元就是空记录类型{}
,元组就是带有特殊字段名的记录:1
,2
等。
记录的类型和种类
现在,如果这就是 Ur 记录系统的全部内容,那就不会有什么意思。但实际上,字段 #Bar
在语言中是一个一流表达式,而花括号记录类型语法实际上是语法糖!解开这一点将要求我们定义相当多的新种类,以及大量的类型级别计算。
在纯 Haskell 中,我们只有一种种类:*
,在 Ur 的术语中是 Type
。值居住于类型,这些类型居住于这种种类。然而,Ur 的记录系统要求更多的外来种类:其中一种是 Name
种类,它表示记录字段名(#Foo
是其中之一)。然而,GHC 已经有了这个:它是 最近添加的 Symbol
种类。然而,GHC 没有的是 {k}
的种构造子,它是“类型级记录”的种类。如果值级别记录是包含数据的东西,那么类型级别记录就是描述值级别记录的东西。然而,它们并不是值级别记录的类型(因为如果它们是的话,它们的种类将是 Type
)。让我们看一个具体的例子。
当我写:
type foo = { Bar : int, Baz : bool }
我真正要写的是:
type foo = $[ Bar = int, Baz = bool ]
$
是一个类型级别的操作符,被应用于表达式 [ Bar = int, Baz = bool ]
,它是一个类型级别的记录,具体来说是 {Type}
的一种(记录的“值”是类型)。美元符号接受类型级别的记录,并将它们转换为 Type
(以便它们实际上可以被值居住)。
这可能看起来是一个毫无意义的区分,直到你意识到,Ur 有类型级别的操作符,它们仅适用于类型级别的记录,而不是一般的类型。两个最重要的原始类型级别操作是连接和映射。它们的功能正如你所期望的:连接将两个记录放在一起,而映射将类型级别的函数应用于记录的每个成员:因此,我可以通过映射列表类型构造函数轻松地将 [ Bar = int, Baz = bool ]
转换为 [ Bar = list int, Baz = list bool ]
。可扩展记录和元编程一举完成!
现在,请回想一下,字段名都存在于全局命名空间中。那么,如果我尝试执行 [ Bar = bool ] ++ [ Bar = int ]
会发生什么?Ur 类型检查器将拒绝这个声明,因为我没有提供这些记录“不相交”的(无法满足的)证明义务。一般来说,如果我有两个记录类型 t1
和 t2
,我想要连接它们,我需要一个不相交证明 [t1 ~ t2]
。处理不相交证明对于习惯于传统函数式编程语言的用户来说可能感觉相当不寻常,但对于依赖类型语言的用户来说并不那么奇怪。事实上,Ur/Web 编译器使处理不相交义务变得非常容易,如果可能的话会自动推断它们,并了解有关连接和映射的一些基本事实。
类型级别计算
Ur 记录系统至关重要地依赖于类型级计算来增强其表达能力:我们可以展开、收缩和映射记录,我们还可以利用“折叠器”,这些是利用类型级记录作为结构的函数,允许对记录进行通用折叠。有关这些更多信息,请参阅类型级计算教程。但为了以用户友好的方式提供这些功能,Ur 关键依赖于编译器具有对这些运算符如何工作的某种了解,以避免用户解除大量微不足道的证明义务。
不幸的是,在这里,我必须承认对于其余的 Haskell 记录提案的工作方式以及这样一个记录系统如何与 Haskell 交互(Ur 确实有类型类,因此这种交互至少已经有了相当深入的研究。)我并不了解。虽然这个提案有一个在现有语言中有着明确定义的系统的好处,但它很复杂,并且显然是在追求完美。但我认为它对于理解除了类型级字符串之外可能需要添加的内容有所帮助,以实现Gershom Bazerman 在这里的愿景:
在我看来,只有一个基本缺失的语言特性,那就是适当类型化的类型级字符串(理想情况下,还能将这些字符串反映回值级)。鉴于此,模板 Haskell 和 HList 的种种技巧,我相信可以设计出相当多优雅的记录包。基于这种经验,我们可以决定哪些语法糖能够有助于完全省略 TH 层。
hp/D3.js: 一个交互式堆视图查看器:ezyang 的博客
来源:
blog.ezyang.com/2012/11/hpd3-js-an-interactive-heap-profile-viewer/
hp/D3.js: 一个交互式堆视图查看器
我正在秋季学习数据可视化课程,我们的一个作业是创建一个交互式可视化。所以我考虑了一下这个问题,意识到,“嘿,如果我们有一个版本的 hp2ps 既交互式又可以从浏览器访问,那不是很好吗?”(hp2any
部分填补了这一空白,但作为一个 GTK 应用程序)。
一个星期的黑客后:hp/D3.js,GHC 堆的交互式堆视图查看器。上传你的hp
文件,与朋友分享!我们希望下次你需要与他人分享堆文件时,不要再运行hp2ps
并发送同事ps
文件,而只需在这里上传hp
文件并发送同事你的链接。我们已在最新的 Firefox 和 Chrome 上进行了测试,它可能在任何足够现代的浏览器上运行,但绝对不会在 Internet Explorer 上运行。
一些特点:
-
您可以通过单击图形并填写出现的文本框来注释数据点。这些注释将被保存,并且将显示给任何查看图形的人。
-
您可以通过在“过滤”字段中输入子字符串来筛选堆元素。
-
通过单击图例元素之一,您可以深入了解更多细节。如果单击
OTHER
,它将展开以显示该带中堆元素的更多信息。然后,您可以通过按“返回”按钮来恢复视图。
尝试一下,并告诉我任何错误或功能建议!(一些已知的问题:有时 Yesod 500s,请刷新直到它正常启动。此外,我们缺乏后退动画,轴变更有些卡顿,您无法保存关于 OTHER 带的注释。)
HTML 净化器 4.3.0 发布:ezyang's 博客
HTML 净化器 4.3.0 发布
发布周期变得越来越长……可能会让所有下游开心吧。
HTML 净化器 4.3.0 是一个重要的安全发布版本,解决了与用户提交的代码和合法客户端脚本相关的各种安全漏洞。它还包含了半年来的新功能和错误修复累积。新的配置选项包括%CSS.Trusted、%CSS.AllowedFonts 和%Cache.SerializerPermissions。为了定制原始定义,API 发生了不兼容的变化,请参阅定制文档获取详细信息。
HTML 净化器是一个用 PHP 编写的符合标准的 HTML 过滤器库。
无关逻辑。 在研究这个版本的 HTML 净化器中修复的安全漏洞时,我有了一个想法:在 JavaScript 中使用高阶函数编程有多容易?JavaScript 在传递函数方面非常流畅(有人可能会说它的面向对象编程设施只是在某些基础结构上放置函数),但由于缺乏类型系统,可能会很烦人,需要说明某个特定函数具有类似Direction -> DataflowLattice -> (Block -> Fact -> (DG, Fact)) -> [Block] -> (Fact -> DG, Fact)
(简化的真实例子,我不是在开玩笑!)。我在 Python 中的经验是,向同事解释这种事情需要花费太多时间,调试也是个头疼的问题(检查函数以查看实际内容很困难),所以最好不要涉及。
寻找数学中的抽象:ezyang's 博客
来源:
blog.ezyang.com/2010/03/abstractions-in-mathematics/
抽象(名词)思想上的分离或独立考虑某物,不考虑其关联;或独立考虑某物质,不考虑其属性;或独立考虑某属性或质量,不考虑其隶属的物质。 (牛津英语词典)
抽象是编程领域中最强大的工具之一,但也是最难捉摸的。可能找到抽象的地方有很多:
-
优秀的艺术家模仿。伟大的艺术家偷窃。 其他人发现(重新)使用和(重新)实现的抽象远比其他方式更容易找到。
-
第一次做某事,只要去做。第二次,对重复感到不悦。第三次,进行重构。 重构引入了小片段的临时抽象。质量各异:结果可能更深刻,但也可能只是平庸的代码复用。
-
Grow your framework. 一个漫长的过程,你在其中建立抽象和应用,为抽象构建另一个独特的应用,并进行调和。这需要很多时间,完全依赖于人们愿意打破 BC(向后兼容性)并进行全面变革。
-
把问题交给一个真正聪明的人。 设计是一个创造性的过程,委员会设计会导致过时的代码。一个人统一了抽象并选择在抽象需要改变时要争论的问题。
-
转向自然。 用户界面经常引入一种形式的抽象,通常会转向现实生活并看看那里有什么。请注意,有些非常好的抽象在现实生活中是不存在的:撤销的概念在现实世界中完全荒谬,但可能是计算机中最好的发明之一。
我想提出另一个在寻找抽象时可以转向的地方:纯数学。
"纯数学?"
是的,纯数学。不是应用数学,后者很容易在程序员工具箱中找到用于解决特定类别的问题。
"好吧,数学家可能会做出巧妙的事情... 但他们对我来说太理论化了。"
但这正是我们正在寻找的!纯数学就是关于操纵抽象对象并通过演绎推证它们的性质。数学家们并不谈论一种不同类型的抽象,他们只是从程序员可能选择的不同具体对象开始。数学家在这方面做得比程序员更久(将时间设定在公元前约 600 年的希腊人)。在这段时间里,数学家们已经非常擅长创造抽象、处理抽象、推断抽象的性质、找到抽象之间的关系等等。事实上,他们非常擅长将概念抽象化,超出了任何“正常”人所能忍受的范围:刘易斯·卡罗尔就因讽刺 19 世纪中期数学中他认为荒谬的想法而闻名。
当然,你不能拿一个任意的数学概念,试图把它塞进你喜欢的编程语言中。第一步是看一些抽象对象,并寻找程序员关心的具体实例。即使是具有明显具体实例的结构也有更微妙的实例,它们同样有用。
同样,许多强大的数学抽象也是不直观的。程序员自然会避开不直观的想法:这是过于聪明的一个例子。直觉性是数学讨论的一个塑造因素:在可计算性理论发展时,有许多竞争的计算模型争夺计算机科学家的注意力。阿隆佐·邱奇创立了λ演算,这是一种高度象征性和抽象的计算概念;艾伦·图灵创立了图灵机,这是一种非常物理和具体的计算概念。在教学中,图灵机占了上风:打开任何介绍性的可计算性教科书(比如我的情况下,Sipser's 教科书),你只会看到图灵机被用来讲述关于停机问题不可判定性的经典证明。图灵机更容易理解;它更干净地映射到数学家急切地进行计算的心理模型。
但没有计算机科学家(尤其不是阿隆佐·邱奇)会声称图灵机是研究的唯一有用模型。λ演算很优雅;一旦你理解了它,你可以简洁地表达想法和操作,而在图灵机中,这些则需要涉及编码、头部扫描和大量繁琐的记录。坦率地说,编写图灵机是一件麻烦事。
现在,我展示两个数学中好的想法导致了编程中好的想法的例子。
第一个涉及到 Lisp 的起源。正如 Sussman 告诉我的,Lisp 最初是为了 McCarthy 能够证明哥德尔的不完全性定理而创建的,而无需诉诸数论。(McCarthy 曾说过略微弱化的话:Lisp 是一种比起图灵机或递归函数理论中使用的一般递归定义更为整洁地描述可计算函数的方法。)与其严谨地将"这个陈述不能被证明"编码成严格的数学形式,可以简单地在 Lisp 中描述它(PDF)。因此,其最初的构想包括了 m-表达式,看起来有点像 function[arg1 arg2]
,代表实际的机器,而不是象征性的 s-表达式。直到后来,一些研究生才想到,“嗯,这实际上会是一种有用的编程语言”,并着手实现 Lisp。通过哥德尔的不完全性定理,代码即数据的强大概念应运而生:当时没有其他语言像这样思考程序。
第二个是 Category Theory 在 Haskell 中的成功。典型的例子是单子(monads),这一数学创新使得惰性语言中的输入/输出操作不那么糟糕(尽管我的数学朋友告诉我,单子实际上并不有趣,因为它们并不足够通用)。但是,函子和应用函子背后的思想包括了所有编程语言中普遍存在的模式。这个概念的重新表述的一个例子可以在 numpy 的 universal functions 中看到。他们并不称之为函子,而是使用诸如“广播”和“类型转换”之类的术语,并讨论了在 numpy 数组上使用特殊的通用函数版本进行逐元素操作的必要性。接口足够可用,但缺乏从实际意识到,“嘿,这只是一个函子…”时得到的简单性、优雅性和一致性。
那些数学家,他们可真是聪明的人。也许我们程序员也能从他们身上学到一些东西。
后记. 感谢 Daniel Kane 回答我即兴提出的“数学究竟是关于什么?”并建议了一些数学渗透回计算机工程中的例子。
寻找约束:ezyang 的博客
> import Data.List
> import Control.Monad
以下问题作为基于数字的谜题的一部分出现在2010 年 MIT 神秘猎中:
他在Wizard of Wor的最终级别等于以精确 9 种方式写成 4 个非零平方数之和的最小数。
我想探索在 Haskell 中使用约束搜索来解决这个问题。希望能找到一个(搜索)程序,直接反映出所提出的问题,并给出一个答案!
因为我们正在寻找最小的数,所以从一个小数开始测试并逐渐计数是有意义的。我们假设这个问题的答案不会导致 Int 溢出。
现在,我们需要测试是否可以将其写成精确 9 种方式的 4 个非零平方数的和。这个问题归结为“n 可以用多少种方式写成平方和”,这是另一个搜索问题。
我们假设 4+1+1+1
和 1+4+1+1
在我们的九宫格目的中不构成不同的格局。这带来了第一个巧妙之处:如果我们对我们的九宫格施加严格的顺序,我们再次得到唯一性。
我们还需要限定我们的搜索空间;虽然公平搜索可以在某种程度上帮助我们处理无限失败,但如果我们可以进行一些早期终止,那么我们的实现将会简单得多。一个非常简单的终止条件是如果平方和超过我们正在寻找的数字。
考虑我们匹配 x 的情况,并且我们有候选根 a、b 和 c。然后,剩余平方的最大值可以是 x - a² - b² - c²,d 的最大值是平方根的底部。平方根很便宜,我们使用机器大小的整数,所以情况很好。
> floorSqrt :: Int -> Int
> floorSqrt = floor . sqrt . fromIntegral
>
> sumSquares :: [Int] -> Int
> sumSquares as = sum (map (²) as)
>
> rootMax :: Int -> [Int] -> Int
> rootMax x as = floorSqrt (x - sumSquares as)
从那里开始,我们仅需列出搜索非重复的平方数和的方法:
> searchSumFourSquares :: Int -> [(Int, Int, Int, Int)]
> searchSumFourSquares x = do
> a <- [1..(rootMax x [])]
> b <- [a..(rootMax x [a])]
> c <- [b..(rootMax x [a,b])]
> d <- [c..(rootMax x [a,b,c])]
> guard $ sumSquares [a,b,c,d] == x
> return (a,b,c,d)
从那里,解决方案自然而然地得出:
> search :: Maybe Int
> search = findIndex (==9) (map (length . searchSumFourSquares) [0..])
我们巧妙地使用[0..]
,这样索引就与数字本身相同。其他方法可能使用元组。
把 Haskell 类型推入 Hasse 图表:ezyang 的博客
来源:
blog.ezyang.com/2010/12/hussling-haskell-types-into-hasse-diagrams/
Haskell 类型的值形成偏序。我们可以使用称为Hasse 图来说明这个偏序。这些图表非常适合强迫自己明确看到每种类型中潜藏的底部。自从我关于表达语义的上篇文章未能引起任何响应之后,我决定我会在一些更多的图片中运气更好。毕竟,每个人都喜欢图片!
我们将从一些简单的东西开始:()`` or unit
。
立即有几件有趣的事情要注意。虽然我们通常认为单元只有一个可能的值,()``,但实际上它们有两个:
()``和 bottom(在 Haskell 中通常写作undefined
,但fix id
同样适用。)我们省略了连接我们偏序的线条上的箭头,所以我们约定较高的值比它们的较低对应物“更大”。
我们的几个类型也工作得很类似,例如,Int
和Bool
看起来非常相似:
注意,没有底部的Int
在我们的公式之外具有独立的全序(通常的-3 小于 5 的情况,Int
的Ord
实例所暗示的)。然而,这不是你要找的顺序! 特别是,如果底部是游戏规则:二是否小于或大于底部?在这个部分排序中,它是“更大”的。
这些图表看起来相似并非巧合:它们的未升级集合(即排除底部的类型)是离散的偏序:没有元素小于或大于另一个。
如果我们引入包含其他数据类型的数据类型会发生什么?以下是自然数的一种,Peano 风格(自然数要么是零,要么是另一个自然数的后继。)
我们不再拥有一个平面图!如果我们处于严格的语言环境中,这将会崩溃回到我们以前拥有的无聊的偏序,但因为 Haskell 是惰性的,每个后继构造函数内部都是一个自然数的惰性计算,它可以是任何数量的令人兴奋的事物(底部,零,或另一个后继构造函数。)
当我们查看列表时,我们会再次看到类似的结构。
我想现在讨论多态数据类型。在Haskell Denotational semantics wikibook中,为了说明这些数据类型,他们必须显式实例化所有类型。我们将采用以下简写:当我需要展示某个多态类型的值时,我将绘制一个星号。此外,我将向这些值绘制楔形,暗示该类型可能有多个构造函数(就像 Int,Bool 和 Nat 的情况一样)。在本节的末尾,我将向您展示如何填写类型变量。
这里是 Maybe:
如果 Haskell 允许我们构造无限类型,我们可以通过定义 Maybe (Maybe (Maybe ...))来恢复 Nat。
虽然看起来相似,但我们用右而不是 Nothing:
在这个偏序关系中,Left ⊥是否比 Right ()大还是小?这是个技巧问题:因为它们是不同的构造函数,所以它们不再可比。
这里是一个更有趣的二元组的图表:
这些值在顶部汇合!这是因为虽然((), ⊥)与(⊥, ())无法比较,但它们都小于((), ())(只需想象在这两种情况下将⊥处填入())。
如果我们允许惰性数据结构,我们将得到比强制使用严格数据结构更丰富的可能值空间。如果这些构造函数是严格的,我们的哈斯图仍然看起来像前几个。实际上,我们可以在惰性构造函数和严格构造函数之间明显地看到这一点:
严格构造函数压扁了⊥和 C ⊥成为同一件事情。
查看 newtype 也可能是有用的,它仅仅在两种类型之间构建了一个同构:
它看起来有点像严格构造函数,但实际上完全不同。关于这一点,我们将在下一篇博客中详细讨论。
我们如何扩展星号?这里有一个显示的图表:
在星形类型的图表中嫁接(不包括底部,因为我们已经将其绘制到图表中),并根据需要复制任何传入和传出的箭头(因此楔形)。这可能导致可能值数量的指数爆炸,这就是为什么我更喜欢星号表示法。
现在,tour de force,惰性列表:
更新. 还有一个额外的符号:带有下标 ⊥ 的星号意味着您需要将底部也嫁接进去(感谢 Anonymous 指出这一点)。明天我们将看到列表在其完整的指数荣耀中展开。
如果我们将 a 设置为()
,我们几乎可以恢复 Nat,但它们并不完全是同构的:每个()
实际上可能是底部,所以虽然[()]
和[⊥]
等效于一个,但它们是不同的。事实上,我们实际上想将 a 设置为空类型。然后我们会将 5 写成[⊥,⊥,⊥,⊥,⊥]。
下次,我们将绘制函数的偏序关系图并说明单调性。
我讨厌补丁:开源开发者的自白 : ezyang's 博客
来源:
blog.ezyang.com/2010/05/i-hate-patches-confessions-of-an-open-source-developer/
我讨厌补丁:
开源开发者的自白
众所周知,如果你真的希望将一个变更提交到开源项目中,你需要在 bug 报告中附上一个补丁。当然,你可能会抱怨普通用户没有任何编程经验,希望他们学习某些复杂的系统然后弄清楚如何做出他们寻求的变更是不合理的,但你只是不属于那些修复自己该死的软件并回馈他们使用的项目的黑客爱好者秘密社会。
我讨厌补丁。我感到道义上的义务去审查和修补它们,而这通常不仅仅是几个周期。
并非所有补丁都是相等的。我将它们分为以下层次:
-
表面上不足. 开发者对提交补丁一无所知;也许他们甚至还没有发现版本控制。他们往往会发送整个修改后的文件以及他们的更改。那些使用
diff -u
的人也不会去查看补丁的输出;他们提交的补丁会将空格与制表符互换,随意更改空白和过度的表面更改。许多开发者直接拒绝这些补丁。 -
语义上不足. 对于一些开发者来说,编写补丁的行为是拿一个源文件,尝试一个模糊可行的变更,看看变更是否达到了预期效果,如果没有,再试其他方法。在极端情况下,补丁是毫无意义的,根本不正确。更频繁地,提交的补丁未考虑到应用程序中的常见边界情况、适当的错误处理或与系统其他部分的交互。许多开发者会友好地回复这样的补丁,并要求用另一种方式进行修补。
-
工程上不足. 这个补丁写得很好,看起来不错,做了正确的事情。但是……他们没有添加测试来测试新的更改,也没有修复由功能差异引起的旧单元测试,并且没有在适当的位置为修复添加文档。许多开发者会专注于为补丁添加工程附加项。一些开发者没有这样的测试(咳咳,Linux 内核咳咳)。更少见的是,一些项目可以让提交者添加测试;通常这只发生在面向相当有识字编程的终端用户社区的项目中。
Git 邮件列表可以并且确实期望社区提交优秀的补丁;这是一个版本控制系统,这就是重点!一个主要由从未编写过单元测试或提交过上游审查统一差异的开发人员使用的 PHP 写的库,灵活性要小得多。对于 HTML Purifier 收到的大多数补丁,都未能解决表面问题。更糟糕的是,开发人员根本没有时间互动地改进补丁的最终版本:如果我回复补丁审查,他们永远无法使自己的补丁达到可以接受提交的水平,而不是一点点拔牙。但我觉得我的软件有问题,所以当我收到补丁时,我会去清理它,把它整合进来,重写其中一半,添加测试,然后发布这些更改。
所以,最终,即使维护者没有节省任何时间,软件也通过提交补丁得到了改进。所以是的,我讨厌补丁。也许我应该停止脾气暴躁,回到改进我的开源项目。
Python 中用数据类和 Union 定义惯用的代数数据类型:ezyang 博客
来源:
blog.ezyang.com/2020/10/idiomatic-algebraic-data-types-in-python-with-dataclasses-and-union/
在非 Haskell 编程语言中,我最怀念的特性之一就是代数数据类型(ADT)。ADT 在其他语言中类似于对象,但有更多限制:对象是一个开放的宇宙,客户端可以实现在定义时未知的新子类;ADT 是一个封闭的宇宙,ADT 的定义精确地指定了所有可能的情况。我们经常认为限制是一件坏事,但在 ADT 的情况下,限制为封闭的宇宙使程序更易于理解(理解一组固定的案例,而不是可能无限的案例)并且允许新的表达方式(模式匹配)。ADT 使得准确建模数据结构非常容易;它们鼓励您选择精确的类型,使非法状态不可表示。但是,尝试在您使用的每种其他编程语言中手动重新实现您喜爱的 Haskell 语言特性通常不是一个好主意,因此多年来,我在 Python 中遭受了 ADT 无法使用的印象。
然而,最近我注意到 Python 3 中的许多新特性使得可以在 Python 中以惯用的方式使用对象,几乎没有样板文件。关键特性:
-
使用 mypy 的结构静态类型检查系统;特别是声明
Union
类型的能力,这让您可以表示可能是一组其他类型中的一个的值,并通过对其执行isinstance
检查来细化变量的类型。 -
数据类库允许您方便地定义(可能是不可变的)数据结构,而无需为构造函数编写样板文件。
核心思想是:将每个构造函数定义为一个数据类,将构造函数组合成一个 ADT 使用 Union 类型,并使用isinstance
测试对结果进行模式匹配。结果与 ADT 一样好(或者可能更好;它们的结构性质更类似于 OCaml 的多态变体)。
下面是它的工作原理。假设您想要定义一个具有两个结果的代数数据类型:
data Result
= OK Int
| Failure String
showResult :: Result -> String
showResult (OK result) = show result
showResult (Failure msg) = "Failure: " ++ msg
首先,我们将每个构造函数定义为一个数据类:
from dataclasses import dataclass
@dataclass(frozen=True)
class OK:
result: int
@dataclass(frozen=True)
class Failure:
msg: str
使用数据类自动生成的构造函数,我们可以使用OK(2)
或Failure("something wrong")
构造这些数据类的值。接下来,我们为这两个类的联合定义一个类型同义词:
Result = Union[OK, Failure]
最后,我们可以通过执行isinstance
测试对结果进行模式匹配:
def assert_never(x: NoReturn) -> NoReturn:
raise AssertionError("Unhandled type: {}".format(type(x).__name__))
def showResult(r: Result) -> str:
if isinstance(r, OK):
return str(r.result)
elif isinstance(r, Failure):
return "Failure: " + r.msg
else:
assert_never(r)
assert_never
是在 mypy 中做穷尽性检查 的一个 众所周知的技巧。如果我们用足够的 isinstance
检查未覆盖所有情况,mypy 将会抱怨 assert_never
被赋予了 UnhandledCtor
类型,而它期望的是 Python 中的不可居住类型 NoReturn
。
就是这么简单。作为额外的奖励,这种联合类型的写法与 结构化模式匹配 PEP 兼容,如果它被实际接受的话。我在最近重写 PyTorch 代码生成器时,已经成功地使用了这种模式。如果你有机会在静态类型的 Python 代码库中工作,不妨试试这种代码风格!
如果有很多注释,那可能就有 bug:ezyang 的博客
如果有很多注释,那么它可能有 bug
昨天,我们邀请到了特邀演讲嘉宾拜伦·库克,他来讲解关于SLAM的话题,这是一个将定理证明技术应用于设备驱动程序的很好的实际例子。
拜伦曾在设备驱动程序开发方面有过一些非常搞笑(和有趣)的评论。毕竟,当设备驱动程序崩溃时,责任不在设备驱动程序编写者身上,而是在微软身上。他指出,在硬件公司,“如果你不够聪明,你会被分配去写软件驱动程序。聪明人会去做硬件工作”,以及当你阅读设备驱动程序代码时,“如果有很多注释而且拼写错误,那可能就有 bug。” 尖锐!我们一直习惯于赞扬注释代码的好处,但毫无疑问,编写注释可以帮助澄清对自己而言令人困惑的代码,而如果代码一开始就不那么令人困惑,你就不会感到有必要写注释了。因此,有时候是过去的某位大师写了非常聪明的代码,然后你来到这里,却不够聪明完全理解当时的情况,因此在修改时写了很多注释来解释代码。嗯,这不是注释的错,但事实上代码对你来说太聪明了,可能意味着在修改时引入了 bug。
SLAM 用于处理指数级状态空间爆炸的方法也非常有趣。他们的做法是尽可能地丢弃多余的状态(而不是消除错误),然后查看简化后的程序是否触发错误。通常情况下会触发,尽管由于虚假的转换,所以他们会引入足够的额外状态来消除这个虚假路径,然后重复这个过程,直到简化后的程序被认为满足断言(成功),或者我们在简化后的程序中发现一个在真实程序中不是虚假的路径。另一个非常有趣的地方是,他们选择的规约语言本质上是增强的断言。在像时态逻辑这样的学术课程中,你会花费大部分时间研究诸如 CTL 和 LTL 之类的逻辑,这些对于设备驱动程序编写者来说很陌生和奇怪;断言则更容易让人们开始。我确实可以看到这个方法也适用于形式验证的其他领域(基于断言的类型注释,任何人?)
附言. 我有一些绝对巨大的帖子即将推出,但在复习考试和临时复习会议之间,我还没有说服自己在考试前完成这些帖子是一个好的时间利用。但它们最终会来!很快!希望!
如果你在使用 lift,你做错了(可能):ezyang’s blog
来源:
blog.ezyang.com/2013/09/if-youre-using-lift-youre-doing-it-wrong-probably/
如果你在使用 lift,你做错了(可能)。
David Darais 要求我发布这则公告:如果你在使用 lift,那你肯定是错的。 这个请求是由于 ICFP 的几次关于 Haskell 中单子变换器替代方案的演讲所引发的,它们都以这样的动机开始演讲:“每个人都讨厌将他们的操作向上提升到单子堆栈中;因此,我们需要另一种组织效果的方式。”这个 StackOverflow 问题 描述了 mtl
用来消除大多数单子代码中使用 lift 的标准技术。
现在,正如大多数情况一样,情况比“永远不使用 lift”更加微妙,技术上不正确的快言快语并不能否定其他效果系统背后的动机。以下是一些细微之处:
-
众所周知,当单子变换器在单子堆栈中出现多次时,自动类型类解析机制不起作用,您需要明确指出您想要与之交互的单子变换器。
-
这种机制仅在您与之交互的单子操作被适当地推广时才有效,例如
MonadReader a m => m a
,而不是Monad m => ReaderT m a
或Reader a
。这在IO
单子中尤为明显,大多数人并未将其定义推广到MonadIO
。幸运的是,通常情况下只需要一个liftIO
。
当然,仍然有许多理由您想要抛弃单子变换器:
-
类型类实例本质上是无序的,因此一个广义的
MonadCont m, MonadState m => m a
单子值并不说明两个相关的单子组合的顺序。但是,这种组合的顺序对单子如何进行有重要的语义影响(状态在连续跳转中传递或重置)。因此,当单子变换器彼此相互作用时,有时您需要真正相互不干扰的效果。而事实上,当您使用类型类方法时,通常只使用能够彼此交换的单子。 -
不同单子变换器之间的干扰使得提升某些函数变得困难。例如,
mask :: ((forall a. IO a -> IO a) -> IO b) -> IO b
的类型。如果我们从操作角度考虑 IO 与 State 组合时的情况,提升者必须设法确保状态传递到执行带有恢复异常的代码中。这在一般情况下非常棘手。当这些回调被多次调用时,情况变得更加糟糕。 -
最终,尽管使用类型类使得单子栈有些抽象,并允许省略 lift 操作,但大多数代码仍然是针对某个特定的单子栈编写的。因此,非平凡程序以模块化方式使用多个效果非常罕见,或者说效果被实例化(即选择具体的单子)时,必须具体化其余的单子栈。
单子变换器存在问题,让我们因正确的原因对其提出异议!
在 Python 中实现 Haskell 堆,v1:ezyang 的博客
来源:
blog.ezyang.com/2011/04/implementing-the-haskell-heap-in-python-v1/
这里是到目前为止我们讨论的所有 Haskell 堆部分的简单实现,包括所有的鬼魂。
heap = {} # global
# ---------------------------------------------------------------------#
class Present(object): # Thunk
def __init__(self, ghost):
self.ghost = ghost # Ghost haunting the present
self.opened = False # Evaluated?
self.contents = None
def open(self):
if not self.opened:
# Hey ghost! Give me your present!
self.contents = self.ghost.disturb()
self.opened = True
self.ghost = None
return self.contents
class Ghost(object): # Code and closure
def __init__(self, *args):
self.tags = args # List of names of presents (closure)
def disturb(self):
raise NotImplemented
class Inside(object):
pass
class GiftCard(Inside): # Constructor
def __init__(self, name, *args):
self.name = name # Name of gift card
self.items = args # List of presents on heap you can redeem!
def __str__(self):
return " ".join([self.name] + map(str, self.items))
class Box(Inside): # Boxed, primitive data type
def __init__(self, prim):
self.prim = prim # Like an integer
def __str__(self):
return str(self.prim)
# ---------------------------------------------------------------------#
class IndirectionGhost(Ghost):
def disturb(self):
# Your present is in another castle!
return heap[self.tags[0]].open()
class AddingGhost(Ghost):
def disturb(self):
# Gotta make your gift, be back in a jiffy!
item_1 = heap[self.tags[0]].open()
item_2 = heap[self.tags[1]].open()
result = item_1.prim + item_2.prim
return Box(result)
class UnsafePerformIOGhost(Ghost):
def disturb(self):
print "Fire ze missiles!"
return heap[self.tags[0]].open()
class PSeqGhost(Ghost):
def disturb(self):
heap[self.tags[0]].open() # Result ignored!
return heap[self.tags[1]].open()
class TraceGhost(Ghost):
def disturb(self):
print "Tracing %s" % self.tags[0]
return heap[self.tags[0]].open()
class ExplodingGhost(Ghost):
def disturb(self):
print "Boom!"
raise Exception
# ---------------------------------------------------------------------#
def evaluate(tag):
print "> evaluate %s" % tag
heap[tag].open()
def makeOpenPresent(x):
present = Present(None)
present.opened = True
present.contents = x
return present
# Let's put some presents in the heap (since we can't make presents
# of our own yet.)
heap['bottom'] = Present(ExplodingGhost())
heap['io'] = Present(UnsafePerformIOGhost('just_1'))
heap['just_1'] = makeOpenPresent(GiftCard('Just', 'bottom'))
heap['1'] = makeOpenPresent(Box(1))
heap['2'] = makeOpenPresent(Box(2))
heap['3'] = makeOpenPresent(Box(3))
heap['traced_1']= Present(TraceGhost('1'))
heap['traced_2']= Present(TraceGhost('2'))
heap['traced_x']= Present(TraceGhost('x'))
heap['x'] = Present(AddingGhost('traced_1', '3'))
heap['y'] = Present(PSeqGhost('traced_2', 'x'))
heap['z'] = Present(IndirectionGhost('traced_x'))
print """$ cat src.hs
import Debug.Trace
import System.IO.Unsafe
import Control.Parallel
import Control.Exception
bottom = error "Boom!"
io = unsafePerformIO (putStrLn "Fire ze missiles" >> return (Just 1))
traced_1 = trace "Tracing 1" 1
traced_2 = trace "Tracing 2" 2
traced_x = trace "Tracing x" x
x = traced_1 + 3
y = pseq traced_2 x
z = traced_x
main = do
putStrLn "> evaluate 1"
evaluate 1
putStrLn "> evaluate z"
evaluate z
putStrLn "> evaluate z"
evaluate z
putStrLn "> evaluate y"
evaluate y
putStrLn "> evaluate io"
evaluate io
putStrLn "> evaluate io"
evaluate io
putStrLn "> evaluate bottom"
evaluate bottom
$ runghc src.hs"""
# Evaluating an already opened present doesn't do anything
evaluate('1')
# Evaluating an indirection ghost forces us to evaluate another present
evaluate('z')
# Once a present is opened, it stays opened
evaluate('z')
# Evaluating a pseq ghost may mean extra presents get opened
evaluate('y')
# unsafePerformIO can do anything, but maybe only once..
evaluate('io')
evaluate('io')
# Exploding presents may live in the heap, but they're only dangerous
# if you evaluate them...
evaluate('bottom')
技术说明。 您已经可以看到鬼魂的 Python 实现与实际的 Core GHC 产生的代码之间的某些相似之处。这里是 pseq 的一个示例:
pseq =
\ (@ a) (@ b) (x :: a) (y :: b) ->
case x of _ { __DEFAULT -> y }
对 x 的 case 操作对应于打开 x,一旦它打开,我们对 y 进行间接引用(return heap['y'].open()
)。这里是另一个非多态添加鬼魂的示例:
add =
\ (bx :: GHC.Types.Int) (by :: GHC.Types.Int) ->
case bx of _ { GHC.Types.I# x ->
case by of _ { GHC.Types.I# y ->
GHC.Types.I# (GHC.Prim.+# x y)
}
}
在这种情况下,Box
扮演了 GHC.Types.I#
的角色。看看你能否找到其他一些对应关系(在 bx
和 by
上的模式匹配是什么? GHC.Prim.+#
是什么?)
我可能会在下一个版本中用 C 来开发,只是为了好玩(而且因为那样看起来实际上会像 Haskell 程序中真实的堆。)
上次:IO evaluates the Haskell Heap
下次:Functions produce the Haskell Heap
这项工作根据 知识共享署名-相同方式共享 3.0 未本地化许可协议 授权。
In-program GC stats for GHC : ezyang’s blog
-
我将参加今年的Hac Phi(将在一周半后举行),我计划在 GHC 的程序中工作,收集垃圾收集器的统计信息。这个任务并不是技术上的难题(我们只需要在运行时系统中暴露一些函数),但迄今尚未完成。我知道许多注重性能和长期运行服务器的人都希望看到这样的功能。
-
我想问你的问题是:你希望这样的 API 看起来如何?它应该提供哪些功能,你希望如何与之交互?
Here’s one sample API to get the ball rolling:
module GHC.RTS.Stats where
-- Info is not collected unless you run with certain RTS options. If
-- you are planning on using this on a long-running server, costs of the
-- options would be good to have (we also probably need to add extra
-- options which record, but have no outwardly visible effect.)
-- Read out static parameters that were provided via +RTS
generations :: IO Int
---------------------------------------------------------------------
-- Full statistics
-- Many stats are internally collected as words. Should be publish
-- words?
-- Names off of machine readable formats
bytesAllocated :: IO Int64
numGCs :: IO Int64
numByteUsageSamples :: IO Int64
averageBytesUsed :: IO Int64 -- cumulativeBytesUsed / numByteUsageSamples
maxBytesUsed :: IO Int64
-- peakMemoryBlocksAllocated :: IO Int64
peakMegabytesAllocated :: IO Int64
initCpuSeconds :: IO Double
initWallSeconds :: IO Double
mutatorCpuSeconds :: IO Double
mutatorWallSeconds :: IO Double
gcCpuSeconds :: IO Double
gcWallSeconds :: IO Double
-- Wouldn't be too unreasonable to offer a data structure with all of
-- this? Unclear. At least, it would prevent related data from
-- desynchronizing.
data GlobalStats = GlobalStats
{ g_bytes_allocated :: Int64
, g_num_GCs :: Int64
, g_num_byte_usage_samples :: Int64
, g_average_bytes_used :: Int64
, g_max_bytes_used :: Int64
, g_peak_megabytes_allocated :: Int64
, g_init_cpu_seconds :: Double
, g_init_wall_seconds :: Double
, g_mutator_cpu_seconds :: Double
, g_mutator_wall_seconds :: Double
, g_gc_cpu_seconds :: Double
, g_gc_wall_seconds :: Double
}
globalStats :: IO GlobalStats
generationStats :: Int -> IO GlobalStats
---------------------------------------------------------------------
-- GC statistics
-- We can't offer a realtime stream of GC events, because they come
-- to fast. (Test? eventlog comes to fast, maybe GC is manageable,
-- but you don't want to trigger GC in your handler.)
data GCStats = GCStats
{ gc_alloc :: Int64
, gc_live :: Int64
, gc_copied :: Int64
, gc_gen :: Int
, gc_max_copied :: Int64
, gc_avg_copied :: Int64
, gc_slop :: Int64
, gc_wall_seconds :: Int64
, gc_cpu_seconds :: Int64
, gc_faults :: Int
}
lastGC :: IO GCStats
lastMajorGC :: IO GCStats
allocationRate :: IO Double
---------------------------------------------------------------------
-- Parallel GC statistics
data ParGCStats = ParGCStats
{ par_avg_copied :: Int64
, par_max_copied :: Int64
}
parGCStats :: IO ParGCStats
parGCNodes :: IO Int64
---------------------------------------------------------------------
-- Threaded runtime statistics
data TaskStats = TaskStats
-- Inconsistent naming convention here: mut_time or mut_cpu_seconds?
-- mut_etime or mut_wall_seconds? Hmm...
{ task_mut_time :: Int64
, task_mut_etime :: Int64
, task_gc_time :: Int64
, task_gc_etime :: Int64
}
---------------------------------------------------------------------
-- Spark statistics
data SparkStats = SparkStats
{ s_created :: Int64
, s_dud :: Int64
, s_overflowed :: Int64
, s_converted :: Int64
, s_gcd :: Int64
, s_fizzled :: Int64
}
sparkStats :: IO SparkStats
sparkStatsCapability :: Int -> IO SparkStats
程序内 GC 统计 redux : ezyang’s 博客
程序内 GC 统计 redux
Hac Phi 还是相当富有成效的(因为我成功地写了两篇博客文章!)周六,我提交了一个新的模块 GHC.Stats
到 base,它实现了我之前提出的 API 的修改子集。 这里是 API;要使用它,你需要从 Git 编译 GHC。请测试并告诉我是否需要做出更改或澄清!
-- | Global garbage collection and memory statistics.
data GCStats = GCStats
{ bytes_allocated :: Int64 -- ^ Total number of bytes allocated
, num_gcs :: Int64 -- ^ Number of garbage collections performed
, max_bytes_used :: Int64 -- ^ Maximum number of live bytes seen so far
, num_byte_usage_samples :: Int64 -- ^ Number of byte usage samples taken
-- | Sum of all byte usage samples, can be used with
-- 'num_byte_usage_samples' to calculate averages with
-- arbitrary weighting (if you are sampling this record multiple
-- times).
, cumulative_bytes_used :: Int64
, bytes_copied :: Int64 -- ^ Number of bytes copied during GC
, current_bytes_used :: Int64 -- ^ Current number of live bytes
, current_bytes_slop :: Int64 -- ^ Current number of bytes lost to slop
, max_bytes_slop :: Int64 -- ^ Maximum number of bytes lost to slop at any one time so far
, peak_megabytes_allocated :: Int64 -- ^ Maximum number of megabytes allocated
-- | CPU time spent running mutator threads. This does not include
-- any profiling overhead or initialization.
, mutator_cpu_seconds :: Double
-- | Wall clock time spent running mutator threads. This does not
-- include initialization.
, mutator_wall_seconds :: Double
, gc_cpu_seconds :: Double -- ^ CPU time spent running GC
, gc_wall_seconds :: Double -- ^ Wall clock time spent running GC
-- | Number of bytes copied during GC, minus space held by mutable
-- lists held by the capabilities. Can be used with
-- 'par_max_bytes_copied' to determine how well parallel GC utilized
-- all cores.
, par_avg_bytes_copied :: Int64
-- | Sum of number of bytes copied each GC by the most active GC
-- thread each GC. The ratio of 'par_avg_bytes_copied' divided by
-- 'par_max_bytes_copied' approaches 1 for a maximally sequential
-- run and approaches the number of threads (set by the RTS flag
-- @-N@) for a maximally parallel run.
, par_max_bytes_copied :: Int64
} deriving (Show, Read)
-- | Retrieves garbage collection and memory statistics as of the last
-- garbage collection. If you would like your statistics as recent as
-- possible, first run a 'performGC' from "System.Mem".
getGCStats :: IO GCStats
归纳和逻辑关系:ezyang 的博客
逻辑关系是一种证明技术,允许您证明归一化(所有程序终止)和程序等价性(这两个程序在所有程序上下文下观察上等价)。如果你以前从未遇到过这些内容,我强烈推荐 Amal Ahmed 在 OPLSS 讲座中的内容;您可以从这里找到我的视频和笔记。 (您还可以访问她以前年份的讲座。)本文是讨论我在 OPLSS 期间和之后几周内在 Agda 上进行的两个逻辑关系证明的形式化的借口。我不打算逐行解释代码,但我确实想扩展关于逻辑关系的两个观点:
-
当简单归纳无法工作时它们会起作用,
-
逻辑关系不是一个归纳定义。
全部的发展在Github 上的 lr-agda 仓库中。非常感谢 Dan Licata 为他的 OPLSS Agda 课程提供了初始发展和为解决关于 lambda 演算的证明而努力的替换引理。
如果你不知道更好的办法,你可能会尝试通过归纳来证明归一化,如下所示:
为了显示所有程序都归一化到一个值,让我们按照类型推导进行归纳。例如,在应用案例中,我们需要展示
e1 e2
归一化到某个值v
,假设e1
归一化到v1
,e2
归一化到v2
。好吧,v1
的类型是t1 -> t2
,这意味着v1 = λx. e'
。哎呀:这应该步进到e'[v2/x]
,但我对这个表达式一无所知(e'
可以是任何东西)。卡住了!
逻辑关系提供了额外的威力,使您能够证明通常无法证明的东西。让我们考虑一下我们的第二个证明草图:问题在于我们对e'
一无所知。如果我们对它有额外的了解,比如说,“嗯,对于一些合适的 v,e'[v/x]将会归一化”,那么我们就能够完成证明。因此,如果这个WN
的定义是我们旧的证明目标:
WN : (τ : Tp) → [] ⊢ τ → Set
WN τ e = e ⇓
然后我们想要做的是扩展这个定义来包括那些“额外的东西”:
WN : (τ : Tp) → [] ⊢ τ → Set
WN τ e = e ⇓ × WN' τ e
在这一点上,现在应该好好讨论一下如何阅读这里的 Agda 代码。WN 是类型族,即
WN τ e
是表达式e
的类型τ
的一元逻辑关系。类型的类型是Tp
,这是一个简单的归纳定义;术语的类型是更复杂的[] ⊢ τ
(利用 Agda 的混合操作符;如果没有它们,你可能会写成Expr [] τ
),它不仅告诉我们e
是一个表达式,而且是良类型,在空上下文[]
下具有类型τ
。(这是一个一般模式的实例,其中归纳定义与良形性推导相一致,在这种情况下是类型推导。)e ⇓
是另一个混合操作符,它被定义为传统的规范化(存在某个值v
,使得e
缩减到v
,例如Σ (λ v → value v × e ↦* v)
)。
但是这额外的内容是什么呢?在简单类型的情况下,例如布尔类型,我们实际上不需要任何额外的东西,因为我们永远不会尝试像函数一样应用它们:
WN' : (τ : Tp) → [] ⊢ τ → Set
WN' bool e = Unit
对于函数类型,我们可以说一个函数是 WN 的(即在逻辑关系中),如果给定一个 WN 参数,它产生一个 WN 结果。(因为我们涉及 WN,这实际上是一个相互递归的定义。)这个陈述实际上是关键的证明思路!
WN' (τ1 ⇒ τ2) e = (e1 : [] ⊢ τ1) → WN τ1 e1 → WN τ2 (app e e1)
还有一些细节,但基本上,当你重做证明时,证明 WN 而不是普通的规范化时,你不会再卡在应用案例上。太棒了!然而,反过来,λ案例的证明不再是微不足道的;你需要做一些工作来展示额外的内容(WN' 的成立)。有人将这描述为"气球"原理。
气球的两侧是"归纳假设的使用"和"证明义务"。当你有一个较弱的归纳假设时,它并没有提供很多信息,但你也不必费太多力气来证明它。当你加强归纳假设时,你可以用它证明更多的东西;然而,相应地,你的证明义务也会增加。在规范化证明的背景下,"归纳假设的使用"出现在应用案例中,而"证明义务"则出现在λ案例中。当你尝试直接的归纳证明时,λ案例是微不足道的,但归纳假设非常薄弱,所以应用案例是不可能的。在逻辑关系证明中,应用案例很容易从归纳假设中得出,但在λ案例中,你需要做更多的工作。
现在让我们稍微退一步,谈谈我们如何定义 WN' 类型族的方式,以便讨论为什么 WN' 不是一个归纳定义。在 Agda 中,通常有两种定义类型族的方式:可以作为递归函数进行定义,也可以作为归纳定义进行定义。一个简单的例子是长度索引列表的定义。标准的归纳定义如下:
data Vec : Set → Nat → Set where
vnil : {A : Set} → Vec A 0
vcons : {A : Set} → A → Vec A n → Vec A (S n)
但我也可以用普通的产品建立列表,使用索引上的递归函数:
Vec : Set → Nat → Set
Vec A 0 = Unit
Vec A (S n) = A × Vec A n
这两种不同的编码各有优缺点:使用递归函数通常意味着某些相等性是定义性的(而你必须使用归纳定义证明引理),但归纳定义允许你对不同可能性进行案例分析。
有时,简单地无法使用归纳定义,逻辑关系就是这种情况。这加强了归纳假设的负担:
data WN' : τ → [] ⊢ τ → Set where
WN/bool : {e : [] ⊢ bool} → WN' bool e
WN/⇒ : {e1 : [] ⊢ τ1} → (WN τ1 e1 → WN τ2 (app e e1)) → WN' (τ1 ⇒ τ2) e
Agda 对归纳-递归定义并不抱怨(尽管应注意:它们在元理论上并不是太好地基础),但它会对这个定义抱怨。问题是一个熟悉的问题:WN 不出现在严格正位置;特别是,它作为 WN/⇒构造子的参数出现。因此我们不能使用它!
事实证明,无法归纳地定义逻辑关系对于规范化并不是什么大问题。然而,对于更复杂的逻辑关系证明,例如程序等价性,它会带来更多头痛。在考虑程序等价性时,你需要一个二元关系来关联值与值,表明两个值何时相等。这可以非常自然地用归纳方式表述:
data V'⟦_⟧ : (τ : Tp) → [] ⊢ τ → [] ⊢ τ → Set where
V/bool-#t : V'⟦ bool ⟧ #t #t
V/bool-#f : V'⟦ bool ⟧ #f #f
V/⇒ : {τ₁ τ₂ : Tp} {e₁ e₂ : [] ,, τ₁ ⊢ τ₂}
→ ((v₁ v₂ : [] ⊢ τ₁) → V'⟦ τ₁ ⟧ v₁ v₂ → E⟦ τ₂ ⟧ (subst1 v₁ e₁) (subst1 v₂ e₂))
→ V'⟦ τ₁ ⇒ τ₂ ⟧ (lam e₁) (lam e₂)
我们通过类型定义关系。如果一个值是布尔值,那么我们说#t
(真)与其自身相关联,#f
与其自身相关联。如果该值是一个函数,那么我们说一个 lambda 项与另一个 lambda 项相关联,如果应用于两个相关联的值,则结果也是相关联的。这个“函数”与我们为规范化证明添加的额外内容直接类似。 (如果你愿意,你可以在心理上用“相等”替换“相关”,但这是误导的,因为它并不捕捉到函数情况中发生的情况)。但这不通过严格正性检查,因此我们必须递归地定义它:
V⟦_⟧ : (τ : Tp) → [] ⊢ τ → [] ⊢ τ → Set
V⟦ bool ⟧ #t #t = Unit
V⟦ bool ⟧ #f #f = Unit
V⟦ bool ⟧ _ _ = Void
V⟦ τ₁ ⇒ τ₂ ⟧ (lam e) (lam e') = (v v' : [] ⊢ τ₁) → V⟦ τ₁ ⟧ v v' → E⟦ τ₂ ⟧ (subst1 v e) (subst1 v' e')
V⟦ τ₁ ⇒ τ₂ ⟧ _ _ = Void
注意,这里的定义远不如归纳定义那么美好:我们需要两个穿越情况,当两个东西不可能相等时断言矛盾,例如#t
不可能等于#f
。此外,假设我们在进行证明时将 V 作为一个假设给出,我们不能再仅仅在其上分情况以找出我们所拥有的信息;我们必须费力地首先对类型和表达式进行情况分割,此时函数会减少。为了让你感受到这有多糟糕,考虑一下这个函数,它将从归纳定义转换为递归定义:
pV : {τ : Tp} → {e e' : [] ⊢ τ} → V⟦ τ ⟧ e e' → V'⟦ τ ⟧ e e'
pV {bool} {#t} {#t} V = V/bool-#t
pV {bool} {#t} {#f} ()
pV {bool} {#t} {if _ then _ else _} ()
pV {bool} {#t} {var _} ()
pV {bool} {#t} {app _ _} ()
pV {bool} {#f} {#t} ()
pV {bool} {#f} {#f} V = V/bool-#f
pV {bool} {#f} {if _ then _ else _} ()
pV {bool} {#f} {var _} ()
pV {bool} {#f} {app _ _} ()
pV {bool} {if _ then _ else _} ()
pV {bool} {var _} ()
pV {bool} {app _ _} ()
pV {_ ⇒ _} {if _ then _ else _} ()
pV {_ ⇒ _} {var _} ()
pV {_ ⇒ _} {lam _} {if _ then _ else _} ()
pV {_ ⇒ _} {lam _} {var _} ()
pV {_ ⇒ _} {lam _} {lam _} f = V/⇒ (\ v v' V → pE (f v v' V))
pV {_ ⇒ _} {lam _} {app _ _} ()
pV {_ ⇒ _} {app _ _} ()
天哪!也许通过改进 Agda 处理模式匹配中的通配符的方式可以改善这种情况,但目前来看,所有这些都是必需的。
“但等等,爱德华!”你可能会说,“你不是刚刚说你不能归纳定义它吗?”确实,这个函数不是基于我之前提出的归纳定义运行的,而是稍微修改了一个,通过用 V 替换非严格正出现的情况,这是递归定义:
V/⇒ : {τ₁ τ₂ : Tp} {e e' : [] ,, τ₁ ⊢ τ₂}
→ (V : (v v' : [] ⊢ τ₁) → V⟦ τ₁ ⟧ v v' {- the critical position! -} → E'⟦ τ₂ ⟧ (subst1 v e) (subst1 v' e'))
→ V'⟦ τ₁ ⇒ τ₂ ⟧ (lam e) (lam e')
这个转换函数非常有帮助,因为在像这样的情况中,agda-mode 与归纳定义(C-c C-c
有效!)的互动要比与递归定义更加顺畅。
为什么在 Agda 中使用逻辑关系?(或者任何证明助手,无论如何?)使用逻辑关系的证明通常遵循以下模式:为你的问题定义一个适当的逻辑关系,然后进行大量的簿记以实际推动关系通过证明。计算机在做簿记方面非常出色,我认为通过证明助手逐步进行逻辑关系证明是极具信息价值的。一个有趣的挑战将是将这个框架扩展到非终止语言(在关系中添加步骤索引:簿记的顶峰)或将 lambda 演算扩展为多态(这需要一些其他有趣的逻辑关系技术)。
data-accessor 不太重要:ezyang 的博客
来源:
blog.ezyang.com/2010/04/inessential-guide-to-data-accessor/
data-accessor 是一个使记录更有用的包。与这段代码不同:
newRecord = record {field = newVal}
你可以写这样:
newRecord = field ^= newVal
$ record
特别是 (field ^= newVal)
现在是一个值,而不是额外语法的一部分,你可以将其视为一级公民。
当我尝试使用Chart(以 criterion 著称)绘制一些数据时,我遇到了这个模块。起初我并没有意识到这一点,直到我尝试了一些代码示例后才意识到,^=
并不是 Chart 为自己发明的一个组合子(与你可能在 xmonad.hs 中看到的 -->
、<+>
、|||
和其他朋友们不同)。在使用 Template Haskell 时,Data.Accessor 代表了普通记录系统的一种替代,因此了解一个模块何时使用这种其他语言是很有用的。使用 Data.Accessor 的模块的迹象包括:
-
在代码示例中使用
^=
运算符 -
所有的记录都有下划线后缀,例如
plot_lines_title_
-
Template Haskell 魔法(包括看起来像
x[acGI]
的类型变量,尤其是 Template Haskell 生成的“真实”访问器中)。 -
浮动的不合格的
T
数据类型。 (正如 Brent Yorgey 告诉我的,这是 Henning-ism,他将定义一个类型 T 或类型类 C,只用于带有限定导入,但 Haddock 会丢弃此信息。如果你不确定,可以在 GHC 中使用:t
来获取此信息。)
一旦确认一个模块确实使用 Data.Accessor,你已经赢得了大部分战斗。这是一个关于如何使用使用 data-accessor 记录的快速教程。
解释类型。 一个访问器(由类型 Data.Accessor.T r a
表示)被定义为一个获取器(r -> a
)和设置器(a -> r -> r
)。r
是记录的类型,a
是可以检索或设置的值的类型。如果使用 Template Haskell 生成定义,a
和 r
中的多态类型通常会使用看起来像 x[acGI]
的类型变量普遍量化,不用太担心它们;你可以假装它们是普通的类型变量。对于好奇的人来说,这些是由 Template Haskell 中的引用单子生成的。
访问记录字段。 旧的方法:
fieldValue = fieldName record
使用 Data.Accessor 可以有多种方式:
fieldValue = getVal fieldname record
fieldValue = record ^. fieldname
设置记录字段。 旧的方法:
newRecord = record {fieldName = newValue}
新的方法:
newRecord = setVal fieldName newValue record
newRecord = fieldName ^= newValue $ record
访问和设置子记录字段。 旧的方法:
innerValue = innerField (outerField record)
newRecord = record {
outerField = (outerField record) {
innerField = newValue
}
}
新的方法(这有点像语义编辑器组合子):
innerValue = getVal (outerField .> innerField) record
newRecord = setVal (outerField .> innerField) newValue record
还有一些用于修改状态单子内部记录的函数,但我会把这些解释留给Haddock 文档。现在,继续前行,并以时尚方式访问你的数据!
fclabels 的非必需指南:ezyang 的博客
上次我做了一个关于数据访问器的非必需指南,所有人告诉我,“你应该使用 fclabels!”所以这里是伙伴指南,fclabels 的非必需指南。像数据访问器一样,其目标是使记录的访问和编辑变得更加顺畅。然而,它为您提供了一些更有用的抽象。它在您的记录之上使用了模板哈斯克尔,因此与数据访问器不兼容。
识别。有三个显著特征:
-
类型签名中包含
:->
(“哦,看起来有点像函数箭头...但不是?好奇怪!”), -
记录包含具有前导下划线的字段(与数据访问器约定使用尾随下划线不同),以及
-
一个
import Prelude hiding (id, (.), mod)
,用来自Control.Category
的导入替换它们。
解释类型。标签由r :-> a
表示,其中包含一个 getterr -> a
和一个 settera -> r -> r
。在内部,包装的标签仅仅是一个点,一个由r -> a
和b -> r -> r
组成的结构,要求a
等于b
。(稍后我们将看到,点本身非常有用,但不适用于基本功能。)
访问记录字段。
get fieldname record
设置记录字段。
set fieldname newval record
修改记录字段。对于fieldname :: f a :-> a
,modifier
应具有类型a -> a
。
mod fieldname modifier record
访问、设置和修改子记录字段。组合使用点运算符(.)
进行组合,但不能使用 Prelude 中的那个,因为那个只适用于函数。这种组合被视为您正在组合 getter。
get (innerField . outerField) record
set (innerField . outerField) newVal record
mod (innerField . outerField) modifier record
访问器胜于应用程序。您可以使用fmapL
将访问器提升到应用程序上下文中。如果您的记录实际上是Maybe r
(您可以将r :-> a
转换为Maybe r :-> Maybe a
),这将非常有用。
但等等,还有更多!
更有趣的视图。请记住,点是一个 getter 和一个 setter,但它们不必是相同类型的。结合巧妙的应用实例,我们可以使用这一点逐步构建由多个标签组成的标签。结果看起来很像您在关系数据库上创建的视图。配方如下:
-
有生成类型的构造函数(例如
(,)
,元组构造函数), -
对于生成类型的所有访问器(例如
fst
和snd
),以及 -
您希望组合在一起的标签(比如
label1
和label2
)。
使用for
结合每个生成类型的访问器(2)和要访问该访问器的标签(3),将所有这些生成的点与生成类型的构造函数结合使用,即<$>
和<*>
,然后将其放入带有Label
的标签中:
(,) <$> fst `for` label1 <*> snd `for` label2
令人惊讶的是,你将无法混淆放置访问器(2)的参数的方向;结果将无法通过类型检查!(详见后记中的更详细的论证。)
与镜头(lenses)更多有趣的事情。一个函数暗示着方向性:从 a 到 b。但光可以通过镜头双向过滤,因此镜头代表了双向函数。我们可以通过一个标签f :-> a
通过一个镜头a :<->: b
来获取一个新的标签f :-> b
(请记住,与常规函数的组合不够,因为我们需要放入值和取出值)。必须小心你的镜头指向的方向。如果label :: r :-> a
,in :: b -> a
和out :: a -> b
,那么:
(out <-> in) `iso` label :: r :-> b
(in <-> out) `osi` label :: r :-> b
如果a != b
,其他方向将无法通过类型检查。
你可以使用lmap
将一个镜头提升为一个函子(它简单地在两个方向上运行fmap
)。
进一步阅读。Hackage 文档中有大量优秀的例子。
后记。以我们原始的例子为例:
label1 :: r -> a
label2 :: r -> b
(,) <$> fst `for` label1 <*> snd `for` label2 :: r :-> (a, b)
我们考虑我们构建的点的类型,在与应用实例组合之前:
fst `for` label1 :: Point Person (a, b) a
snd `for` label2 :: Point Person (a, b) b
我们有一个共享的应用函子Point Person (a, b)
,如果我们将其视为f
,显然:
(,) :: a -> b -> (a, b)
fst `for` label1 :: f a
snd `for` label2 :: f b
(,) <$> fst `for` label1 <*> snd `for` label2 :: f (a, b)
这等同于Point Person (a, b) (a, b)
,这是一个有效的Label
。
那么for
在做什么呢?源代码文档说:
将部分析构器与标签结合起来,轻松地在隐藏的点数据类型的应用实例中使用 getter 中的协变,setter 中的逆变双函子映射函数。(请参考示例,因为这个函数本身无法单独解释。)
嗯,我要忽略这个建议,因为你已经看过例子了。让我们来解析一下。for
在 getter r -> a
中是协变的,在 setter a -> f -> f
中是逆变的。这些术语来自范畴论,用来描述函子。协变函子是“正常”的函子,而逆变函子是将组合反过来的函子。因此,虽然通常情况下 fmap f g == f . g
,在逆变世界中 fmap f g == g . f
:
for :: (i -> o) -> (f :-> o) -> Point f i o
for a b = dimap id a (unLabel b)
好吧,我们对 getter 并没有做太多有趣的事情,但我们将a :: (a, b) -> a
(在我们的例子中)映射到 setter a -> f -> f
上。幸运的是(对于困惑的人来说),协变映射不会类型检查((a, b) != (f -> f)
),但是逆变映射会:(a, b) -> f -> f
,这是一个新的 setter,接受(a, b)
,正是我们从类型签名中期望的。
因此,for
设置了我们的 setter 和部分 getter,并且应用实例完成了我们的 getter 设置。
每个计算机科学家都应该知道的整数数列?:ezyang's 博客
来源:
blog.ezyang.com/2010/11/integer-sequences-every-computer-scientist-should-know/
整数数列在线百科全书 是一个非常棒的网站。假设你在解决一个问题,然后得到了以下的整数数列:0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2...
然后你自己想:“嗯,这是什么数列?”嗯,只需输入它,答案就出来了:A007814,还有各种有趣的小贴士,如构造方式,封闭形式,数学属性等等。甚至像二的幂这样简单的数列都有成千上万种不同的解释和生成方式。
这让我想知道:每个计算机科学家都应该知道哪些整数数列?也就是说,他们应该能够看到前几项并想:“哦,我知道这个数列!”然后稍微费点脑筋去记住构造方式、封闭形式或一些关键的属性。例如,几乎所有有基本数学背景的人都会认识数列1, 2, 3, 4, 5;0, 1, 4, 9, 16;或1, 1, 2, 3, 5。我在本文中引用的第一个数列对我来说有特殊意义,因为我在为《Monad.Reader》写作的文章Adventures in Three Monads中意外地推导出它。也许不那么熟悉的数列可能是1, 1, 2, 5, 14, 42或3, 7, 31, 127, 8191, 131071, 524287, 2147483647,但它们对计算机科学家来说仍然非常重要。
那么,每个计算机科学家都应该知道哪些整数数列?(或者说,你最喜欢的整数数列是什么?)
Intelligence is the ability to make finer distinctions: Another Haskell Advocacy Post : ezyang’s blog
我父母喜欢向我推销各种自助书籍,虽然我有时会对此嗤之以鼻,但我确实会阅读(或至少粗略翻阅)它们,并从中提取出有用的信息。这句标题的引用来自罗伯特·清崎在《富爸爸,穷爸爸》中的“富爸爸”。
“Intelligence is the ability to make finer distinctions” really spoke to me. I’ve since found it to be an extremely effective litmus test to determine if I’ve really understood something. A recent example comes from my concurrent systems class, where there are many extremely similar methods of mutual exclusion: mutexes, semaphores, critical regions, monitors, synchronized region, active objects, etc. True knowledge entails an understanding of the conceptual details differentiating these gadgets. What are the semantics of a signal on a condition variable with no waiting threads? Monitors and synchronized regions will silently ignore the signal, thus requiring an atomic release-and-wait, whereas a semaphore will pass it on to the next wait. Subtle.
We can frequently get away with a little sloppiness of thinking, indeed, this is the mark of an expert: they know precisely how much sloppiness they can get away with. However, from a learning perspective, we’d like to be able to make as fine a distinction as possible, and hopefully derive some benefit (either in the form of deeper understanding or a new tool) from it.
Since this is, after all, an advocacy piece, how does learning Haskell help you make finer distinctions in software? You don’t have to look hard:
Haskell is a standardized, general-purpose purely functional programming language, with non-strict semantics and strong static typing.
These two bolded terms are concepts that Haskell asks you to make a finer distinction on.
Purity. Haskell requires you to make the distinction between pure code and input-output code. The very first time you get the error message “Couldn't match expected type [a]
against inferred type IO String
” you are well on your way to learning this distinction. Fundamentally, it is the difference between computation and interaction with the outside world, and no matter how imperative your task is, both of these elements will be present in a program, frequently lumped together with no indication which is which.
纯代码带来了巨大的好处。它自动线程安全和异步异常安全。它与外部世界没有隐藏的依赖关系。它是可测试和确定性的。系统可以在没有对外部世界承诺的情况下对纯代码进行推测性评估,并且可以缓存结果而无需担忧。Haskellers 痴迷于尽可能多地将代码移到 IO 之外:你不必如此,但即使在小剂量中,Haskell 也会使你意识到被视为良好工程实践的东西如何变得严格。
非严格语义。 有些事情是理所当然的,生活中的一些小恒定,你无法想象会有所不同。也许如果你停下来思考一下,还有另一种方式,但这种可能性从未发生在你身上。你驾驶的道路哪一边是这些事情之一;严格评估是另一种。Haskell 要求你区分严格评估和惰性评估。
Haskell 对这一区别不像对纯度和静态类型那样张扬,因此你可以在不理解这一区别的情况下愉快地进行编程,直到第一次堆栈溢出。此时,如果你不理解这一区别,错误会显得难以解决(“但在我知道的其他语言中是有效的”),但如果你了解,堆栈溢出很容易修复——也许只需明确地使奇怪的参数或数据构造函数严格。
隐式惰性具有许多显著优点。它允许用户级控制结构。它编码流和其他无限数据结构。它比严格评估更一般化。它在摊销持久数据结构的构建中至关重要。(Okasaki)它也不总是适合使用:Haskell 促进了对严格性和惰性优缺点的理解。
- 嗯,几乎是。只有在你拥有无限内存的情况下,它才完全泛化严格评估,此时任何严格评估的表达式也会惰性评估,而反之则不成立。但在实践中,我们有限制堆栈大小等讨厌的事物。
缺点。 你能够做出更细微的区分表明了你的智力。但同样地,如果这些区分不成为第二天性,每次需要调用它们时都会带来认知负担。此外,这使得那些不理解区别的人难以有效地进行代码开发。(保持简单!)
对于有经验的 Haskell 程序员来说,管理纯度已经是驾轻就熟的事情:他们通过类型检查器长时间训练,知道什么是可接受的,什么是不可接受的。鉴于单子的神秘性,通常在开始学习 Haskell 时人们会积极尝试学习如何管理纯度。管理严格性对有经验的 Haskell 程序员来说也很容易,但我觉得它的学习曲线更高:没有严格性分析器在你做出次优选择时大声警告你,大多数情况下你可以不考虑它而逃避问题。有人可能会说,默认惰性不是正确的选择方式,并且正在探索严格的设计空间。我仍然乐观地认为,我们 Haskell 程序员可以建立起一套知识体系和教学技巧,引导新手进入非严格评估的奥秘和奇迹中去。
所以,这就是它们:纯度和非严格性,这两个 Haskell 希望你能区分的概念。即使你从未计划在严肃的项目中使用 Haskell,对这两个概念有直观的感受也将极大地影响你的其他编程实践。纯度将在你编写线程安全代码、管理副作用、处理中断等方面提供帮助。惰性将在你使用生成器、处理流、控制结构、记忆化、使用函数指针等高级技巧时发挥作用。这些都是非常强大的工程工具,你应该自己尝试一下。
交互式零知识证明演示:ezyang 的博客
来源:
blog.ezyang.com/2011/12/interactive-demo-of-zero-knowledge-proofs/
交互式零知识证明演示
使用 Jupyter 和 Puppeteer 进行交互式抓取:ezyang 的博客
来源:
blog.ezyang.com/2021/11/interactive-scraping-with-jupyter-and-puppeteer/
使用 Jupyter 和 Puppeteer 进行交互式抓取
抓取网站的讨厌之处之一是在浏览器和实际抓取脚本之间来回跳转,您在浏览器中使用开发工具来确定应该使用哪些选择器来抓取数据,而您的实际抓取脚本通常是一些批处理程序,可能在调试之前需要执行几个步骤。一旦您的抓取器运行起来了,批处理脚本就没问题了,但在开发过程中,在某个页面上暂停抓取过程并在 DOM 中摆弄一下以查看该做什么,这真的非常方便。
这种交互式开发正是 Jupyter 笔记本擅长的;当与基于浏览器的抓取库如 Puppeteer 结合使用时,您可以正好达到这种工作流程。以下是设置步骤:
-
Puppeteer 是一个 JavaScript 库,因此您需要一个支持 JavaScript 的 Jupyter 内核来运行它。作为额外的复杂性,Puppeteer 也是异步的,因此您需要一个支持异步执行的内核。幸运的是,ijavascript-await 正好提供了这一功能。请注意,在最近的 node 版本上,此软件包无法编译;您可以安装此 PR 来解决这个问题:
github.com/n-riesco/ijavascript/pull/257
假设我们能够在 node 支持顶级 await 时使用原始 ijavascript,但目前尚不支持:github.com/nodejs/node/issues/40898
-
在存储 snotebooks 的目录内,您需要
npm install puppeteer
,以便在笔记本中使用。 -
使用
let puppeteer = require('puppeteer'); let browser = await puppeteer.launch({headless: false});
启动 Puppeteer,并获利!
会有一个实时浏览器实例,您可以使用开发工具进行操作,然后在 Jupyter 笔记本中键入命令,查看它们如何影响浏览器状态。
我 在推特上发过一条推文,评论员提供了一些关于可以尝试的其他好建议:
-
您不必使用 Puppeteer;Selenium 也可以驱动浏览器,并且它还有 Python API(因此无需与替代 Jupyter 内核打交道)。我个人更喜欢在 JavaScript 中工作,因为页面脚本本身也是 JavaScript,但这主要是个人偏好的问题。
-
对于简单的交互,如果你只是想进行几次交互并记录它们,Headless Recorder 提供了一个很好的扩展,可以直接在你的浏览器中记录操作,然后以可执行形式导出。我还没有试过它,但看起来使用起来会非常方便。
交错流处理器:ezyang 的博客
状态机中的幽灵
大约在 2007-2008 年间,我写了 HTML Purifier 中可能是最复杂的一段代码——那种你觉得别人根本无法理解的真正怪兽,而且你非常感激自己有全面的测试套件来验证它。我的想法是这样的:我有一个状态机,用来修改一个标记流(因为这是一系列 HTML 标签和文本的流,状态机维护了诸如当前嵌套堆栈之类的信息),我想以模块化的方式允许用户在这个流处理器的基础上添加额外的功能(第一个处理器在遇到双换行时插入段落标签)。
我本可以做的最简单的事情是抽象出基本状态机,为我想做的每一种转换创建一个单独的处理器,然后依次运行它们。但出于某种原因,我从未想过这个主意,并且我不想要为了避免多次迭代标记列表而降低性能(过早的优化,对吧?)相反,我决定在原始状态机的各个关键点添加挂钩,插件可以连接到这些挂钩上来执行它们自己的流转换。
甚至在我拥有超过一个“注入器”(它们就是这样称呼的)之前,我已经决定当一个注入器创建了另一个注入器能够处理的标记时,我必须做些明智的事情。假设你运行注入器 A,然后是注入器 B。注入器 A 创建的任何标记都将被注入器 B 捕获,但反之则不然。这对我来说似乎是一个完全随意的决定:我能不能做到顺序无关紧要?我实现这个功能的方式是让管理状态机找出任何给定注入器创建的新标记,并将它们传递给所有其他注入器(小心不要将标记传回到原始注入器)。搞清楚这一点很棘手;最初我将“已见过”的标记范围单独存储在标记流之外,但随着其他注入器的修改,这些范围极易失步,所以最终我决定将信息存储在标记本身。另一个困难是防止 A 创建一个由 B 转换为另一个由 A 转换为 B 等的标记;因此这种跳过信息必须在标记之间保持不变。(看起来可能会排除某些注入器之间潜在的有益互动,但我决定终止更为重要。)
额外的功能也增加了复杂性。 一个特定的功能需要能够倒回到流的早期部分并重新处理所有这些令牌;由于大多数其他注入器不会期望回到过去,我决定,如果发生倒带,其他注入器都将被暂停会是最简单的。 我怀疑整个系统应该做什么样的正式语义,但实际上似乎能工作。 毕竟,复杂性不是一天创造的:它随时间演变。
这个故事有几个结局。 其中一个结局是,我很高兴和惊讶地看到,客户端进行更改然后递归传播到其他客户端,然后可以进行其他更改,这在分布式系统中是一个相当常见的现象。 如果你将其压缩成算法形式,你会得到(倒吸一口凉气)像 Lerner、Grove 和 Chamber 的研究论文Composing Dataflow Analyses and Transformations,当我在与 Hoopl 打交道时发现的(注:我并不轻视他们的工作:编程语言的数据流分析比 HTML 的层次分析复杂得多,并且它们允许 A 所做的更改影响 B 所做的更改,反过来也可以影响 A:他们通过确保他们的分析最终通过限制信息格子来终止来切断这个结,可能是另一个帖子讨论的内容)。
另一个结局是,令人着迷的是,这个复杂的系统实际上成为 HTML 净化器的第一个外部贡献的基础。 这位贡献者对该系统有以下评价:“我必须说,我对 HTML 净化器中看到的设计印象深刻;一旦我指向正确的地方,这就变得相当容易理解了。” 显然,有经验的程序员对具有复杂内部状态的系统的演变非常了解,我看到通常会有经验丰富的开发人员处理这个子系统,通常会取得成功。 从经验的角度来看,我并不觉得这太令人惊讶——多年前我写的代码后来我记起来并不需要花太多时间。 但我确实想知道这是否只是许多长时间黑客会议的副产品,有很多状态的系统。
最后的结局是一个假设,“假设老爱德华回来并决定重写系统。” 这看起来很奇怪,但我可能没有耐心再做一遍。 或者我可能已经意识到即将到来的复杂性并避免了它。 但多年来让我最疯狂的事情,也是一个技术问题,尽管基于流的表示,HTML 净化器处理的一切都加载到内存中,我们根本没有利用令牌流。 令人讨厌!
中断 GHC:ezyang 的博客
在我的有关 abcBridge 的技术讲座中,我在将 FFI 代码用作普通 Haskell 代码时遇到的一个“未解决”问题是中断处理。在这里,我描述了一种涉及 GHC 运行时系统更改的实验性解决方案,这是由Simon Marlow建议的。导论部分可能对寻找代码工作示例的从业者感兴趣,用于捕获信号的代码;后面的部分是我希望能够完全完成的补丁的概念验证。
> {-# LANGUAGE ForeignFunctionInterface #-}
> {-# LANGUAGE DeriveDataTypeable #-}
> {-# LANGUAGE ScopedTypeVariables #-}
>
> import qualified Control.Exception as E
>
> import Foreign.C.Types (CInt)
>
> import Control.Monad
> import Control.Concurrent (threadDelay, myThreadId, throwTo, forkIO)
> import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar)
>
> import System.IO (hPutStrLn, stderr)
> import System.Posix.Signals (installHandler, sigINT, Handler(..))
在许多交互式应用程序(特别是 REPL)中,您希望能够捕获用户按下^C
时终止当前计算,而不是整个程序。fooHs
是一个可能需要很长时间才能运行的函数(在这种情况下,fooHs
永远不会终止)。
> fooHs :: Int -> IO Int
> fooHs n = do
> putStrLn $ "Arf HS " ++ show n
> threadDelay 1000000
> fooHs n
默认情况下,GHC 生成一个异步异常,我们可以使用正常的异常处理设施来捕获,以表明“还不要退出”:
> reallySimpleInterruptible :: a -> IO a -> IO a
> reallySimpleInterruptible defaultVal m = do
> let useDefault action =
> E.catch action
> (\(e :: E.AsyncException) ->
> return $ case e of
> E.UserInterrupt -> defaultVal
> _ -> E.throw e
> )
> useDefault m
>
> reallySimpleMain = do
> r <- reallySimpleInterruptible 42 (fooHs 1)
> putStrLn $ "Finished with " ++ show r
有时,您不希望生成任何异常,并希望在信号到达时立即进行研究。您可能处于程序的某个关键部分,不希望被中断!在这种情况下,您可以使用来自System.Posix.Signals的installHandler
安装信号处理程序。
> installIntHandler :: Handler -> IO Handler
> installIntHandler h = installHandler sigINT h Nothing
在完成后,应确保恢复原始的信号处理程序。
如果您决定从信号处理程序内生成异常,需要小心处理:如果我们仅尝试简单地抛出异常,我们的异常似乎会消失到虚无中!这是因为中断处理程序在不同的线程上运行,我们必须使用来自Control.Concurrent的throwTo
确保我们的异常发送到正确的线程。
> simpleInterruptible :: a -> IO a -> IO a
> simpleInterruptible defaultVal m = do
> tid <- myThreadId
> let install = installIntHandler (Catch ctrlc)
> ctrlc = do
> -- This runs in a different thread!
> hPutStrLn stderr "Caught signal"
> E.throwTo tid E.UserInterrupt
> cleanup oldHandler = installIntHandler oldHandler >> return ()
> useDefault action =
> E.catch action
> (\(e :: E.AsyncException) ->
> return $ case e of
> E.UserInterrupt -> defaultVal
> _ -> E.throw e
> )
> useDefault . E.bracket install cleanup $ const m
>
> simpleMain = do
> r <- simpleInterruptible 42 (fooHs 1)
> putStrLn $ "Finished with " ++ show r
这段代码对纯 Haskell 工作很好。
然而,我们的问题是,我们是否可以中断处于 FFI 中的 Haskell 线程,而不仅仅是纯 Haskell 代码。也就是说,我们想用fooHs
替换:
> foreign import ccall "foo.h" foo :: CInt -> IO ()
其中foo.h
包含:
void foo(int);
而foo.c
包含:
#include <stdio.h>
#include "foo.h"
void foo(int d) {
while (1) {
printf("Arf C %d!\n", d);
sleep(1);
}
}
在实际应用中,foo
将是一些在 C 语言中编写的高度优化的函数,可能需要很长时间才能运行。我们也不能随意终止函数:我们应该能够随时强制终止它,而不会破坏一些全局状态。
如果我们尝试使用现有的interruptible
函数,我们发现它们不起作用:
-
reallySimpleInterruptible
注册了 SIGINT,但外部调用仍在继续。在第二个 SIGINT 上,程序终止。这是运行时系统的 默认行为:RTS 会试图优雅地中止计算,但无法终止 FFI 调用,并在第二个 SIGINT 到来时强制终止程序。 -
simpleInterruptible
的表现甚至更糟:没有“在第二个信号上退出”的行为,我们发现无法通过按^C
来终止程序!请求 FFI 调用的线程正在忽略我们的异常。
Nota bene. 请告知作者本节中的任何事实错误。
是时候深入了解运行时系统了!管理异步异常的代码位于 rts
目录下的 RaiseAsync.c
中。特别是这个函数:
nat throwToMsg (Capability *cap, MessageThrowTo *msg)
当线程调用 throwTo
在另一个线程中创建异常时会调用。
首先看一下没有任何幽默的情况发生时会发生什么,也就是说,当线程没有被阻塞时:
case NotBlocked:
{
if ((target->flags & TSO_BLOCKEX) == 0) {
// It's on our run queue and not blocking exceptions
raiseAsync(cap, target, msg->exception, rtsFalse, NULL);
return THROWTO_SUCCESS;
} else {
blockedThrowTo(cap,target,msg);
return THROWTO_BLOCKED;
}
}
如果线程正常运行,我们使用 raiseAsync
来引发异常,然后完成!然而,线程可能已调用 block
(来自 Control.Exception),在这种情况下,我们将异常添加到目标的阻塞异常队列,并等待目标解除阻塞。
另一个 Haskell 线程可能处于的状态是这样的:
case BlockedOnCCall:
case BlockedOnCCall_NoUnblockExc:
{
blockedThrowTo(cap,target,msg);
return THROWTO_BLOCKED;
}
运行时系统等待线程停止在 FFI 调用上的阻塞,然后再传递异常——它最终会到达那里!但如果 FFI 调用时间太长,这将为时已晚。我们可以用 raiseAsync
替换此调用,但我们发现,虽然异常被引发并且 Haskell 线程恢复正常执行,FFI 计算继续进行!
如果这看起来很神秘,回顾一下 GHC 运行时系统的多线程调度器 的工作方式将会很有帮助。Haskell 线程是轻量级的,与操作系统线程没有一对一的对应关系。相反,Haskell 线程使用 TSO(线程状态对象)表示,在 RTS 中被调度为一小部分操作系统线程,被抽象为任务。每个操作系统线程与一个 CPU 核心相关联,在 RTS 中被抽象为能力。
在执行的最初阶段,操作系统线程的数量与虚拟核心的数量相同(由 -N
RTS 选项指定):就 Haskell 代码而言,我们通过拥有多个能力而获得并行性,而不是多个任务!能力一次只能属于一个任务。然而,如果一个任务在操作系统上阻塞,它可能会放弃它的能力给另一个任务,后者可以继续运行 Haskell 代码,因此我们经常将这些任务称为工作线程。
任务(操作系统线程)通过执行由 TSO(Haskell 线程)在运行队列中请求的 InCall 来执行工作,以循环轮换的方式进行调度。在执行过程中,可能会遇到 FFI 调用。这里的行为会根据 FFI 调用是安全还是不安全而有所不同。
-
如果调用是不安全的,我们直接进行调用,不放弃能力!这意味着没有其他 Haskell 代码可以运行这个虚拟核心,如果 FFI 调用花费很长时间或者阻塞,这是个坏消息,但如果速度真的很快,我们不必放弃能力只为了随后再夺回来。
-
如果调用是安全的,我们释放能力(允许其他 Haskell 线程继续),而 Haskell 线程则暂停在一个外部调用上。当前的操作系统线程接着运行 FFI 调用。
因此,如果我们试图通过向其抛出异常来直接唤醒原始的 Haskell 线程,它最终会被调度到不同的操作系统线程(而原始线程继续运行 FFI 调用!)
诀窍是终止正在运行 FFI 调用的操作系统线程。
case BlockedOnCCall:
case BlockedOnCCall_NoUnblockExc:
{
#ifdef THREADED_RTS
Task *task = NULL;
if (!target->bound) {
// walk all_tasks to find the correct worker thread
for (task = all_tasks; task != NULL; task = task->all_link) {
if (task->incall->suspended_tso == target) {
break;
}
}
if (task != NULL) {
raiseAsync(cap, target, msg->exception, rtsFalse, NULL);
pthread_cancel(task->id);
task->cap = NULL;
task->stopped = rtsTrue;
return THROWTO_SUCCESS;
}
}
#endif
blockedThrowTo(cap,target,msg);
return THROWTO_BLOCKED;
}
无论它是哪个操作系统线程?它不可能是试图抛出异常的线程,也与暂停的 Haskell 线程无关,后者正在等待被唤醒但不知道它在等待被唤醒的原因。然而,运行 FFI 调用的任务知道哪个 Haskell 线程正在等待它,因此我们可以遍历所有任务列表,查找与我们的异常目标匹配的任务。一旦找到它,我们就使用 pthread_cancel
杀死线程,并用异常唤醒原始的 Haskell 线程。
有一个微妙之处是 Marlow 指出的:我们不想销毁绑定线程,因为它们可能包含线程本地状态。工作线程是相同的,因此是可以牺牲的,但不能轻视绑定线程。
我们有点不友好:在中断时,我们没有给库一个清理的机会。幸运的是,库可以使用 pthread_setcancelstate
和 pthread_setcanceltype
在退出之前给它一个清理的机会。
结果表明,即使使用了 RTS 补丁,我们仍然无法完全中断 FFI 调用。但如果我们添加一个明确的新 Haskell 线程,事情就会起作用:
> interruptible :: a -> IO a -> IO a
> interruptible defaultVal m = do
> mresult <- newEmptyMVar -- transfer exception to caller
> mtid <- newEmptyMVar
> let install = installIntHandler (Catch ctrlc)
> cleanup oldHandler = installIntHandler oldHandler >> return ()
> ctrlc = do
> hPutStrLn stderr "Caught signal"
> tid <- readMVar mtid
> throwTo tid E.UserInterrupt
> bracket = reportBracket . E.bracket install cleanup . const
> reportBracket action = do
> putMVar mresult =<< E.catches (liftM Right action)
> [ E.Handler (\(e :: E.AsyncException) ->
> return $ case e of
> E.UserInterrupt -> Right defaultVal
> _ -> Left (E.toException e)
> )
> , E.Handler (\(e :: E.SomeException) -> return (Left e))
> ]
> putMVar mtid =<< forkIO (bracket m)
> either E.throw return =<< readMVar mresult -- one write only
>
> main = main' 3
>
> main' 0 = putStrLn "Quitting"
> main' n = do
> interruptible () $ do
> (r :: Either E.AsyncException ()) <- E.try $ foo n
> putStrLn $ "Thread " ++ show n ++ " was able to catch exception"
> main' (pred n)
当以修补后的 RTS 编译并使用 -threaded
选项时,这个 Literate Haskell 文件的输出如下:
Arf C 3!
Arf C 3!
^CCaught signal
Thread 3 was able to catch exception
Arf C 2!
Arf C 2!
Arf C 2!
^CCaught signal
Thread 2 was able to catch exception
Arf C 1!
Arf C 1!
^CCaught signal
Thread 1 was able to catch exception
Quitting
概念证明成功!现在让它在 Windows 上工作…
IO 评估 Haskell 堆:ezyang's 博客
在今天的文章中,我们关注的是你,在 Haskell 堆中翻找打开一个礼物的人。毕竟,Haskell 堆中的礼物并不会自行打开。
某人必须打开第一个礼物。
如果 Haskell 堆不与外界交互,就不需要打开礼物:因此 IO 函数是打开礼物的函数。它们将打开什么礼物对于许多函数来说并不明显,因此我们将专注于一个特别明显的函数:evaluate
。这告诉你要...
...打开一个礼物。
如果你得到一个原始值,你就完成了。但当然,你可能会得到一个礼品卡(构造函数):
你会打开其余的礼物吗?尽管内心深处充满了不满,答案是否定的。evaluate
只要求你打开一个礼物。如果它已经被打开,那你就无需做任何事情。
高级技巧:如果你想评估更多东西,可以制作一个包含一个幽灵的礼物,他将帮你打开那些东西!当涉及到延迟 IO 时,这是一个经常使用的例子:
evaluate (length xs)
,但如果你还不明白,不用太担心:我还没有说过我们如何制作礼物!
即使我们只打开了一个礼物,很多事情可能会发生,正如上篇所述。它可能执行一些 IO...
这是我们在评估过程中直接窥探的窗口:通常情况下运行程序时,我们看不到礼物被打开的过程;但如果我们让幽灵在被扰动时也喊出来,我们就能得到这些信息。事实上,这正是Debug.Trace
所做的!
还有其他方法可以查看正在进行的评估。一个礼物可能会爆炸:这是爆炸的陷阱礼物,也称为“底部”。
或许爆炸是由undefined
或error "Foobar"
引起的。
砰。
我们将以一个实用的笔记结束。正如我们所提到的,你只有在显式要求从 IO 打开一个礼物时才能确定它已经被打开。否则,幽灵可能会捉弄你。毕竟,你实际上是看不到 Haskell 堆的,所以没有直接的方法可以直接告诉一个礼物是否已经被打开。
如果你不确定一个 thunk 何时被评估,请在其中添加一个跟踪语句。如果幽灵在你背后懒惰,跟踪语句就永远不会出现。
然而,更频繁地,跟踪语句将会出现;它可能会比你预期的晚一些(幽灵可能会懒惰,但他们最终会完成工作)。因此,过早终止你的程序或添加额外的打印语句来划分程序的各个阶段是很有用的。
技术说明。 与我之前说过的相反,理论上我们可以在堆上自发地评估thunk
,这种评估方法称为推测性评估。有些令人困惑的是,IO
操作本身也可以是thunk
:这相当于传递IO a
的值,而不实际“运行”它们。但因为我不在这里讨论单子,我将简单地忽略包含IO
操作的存在——它们的工作方式相同,但你必须保持间接层级清晰。当然,无限循环也算作bottom
,但将其打开一个礼物直到永远的形象,并不像一个爆炸性礼物那样吸引眼球。
这项工作根据知识共享署名-相同方式共享 3.0 未本地化许可协议进行许可。
Is Haskell liberal or conservative? : ezyang’s blog
来源:
blog.ezyang.com/2012/08/is-haskell-liberal-or-conservative/
Haskell 是自由派还是保守派?
Steve Yegge 发表了一篇有趣的文章试图将自由派和保守派标签应用于软件工程。当然,这是一个极端简化(Yegge 自己承认)。例如,他得出结论说 Haskell 必须是“极端保守”的,主要是因为它极力强调安全性。这完全忽略了 Haskell 最好的一点,即我们做一些疯狂的事情,正常情况下没有人会在没有 Haskell 安全功能的情况下这样做。
所以我想我会借鉴一些 Yegge 的思路,通过提出的标准来评估一个语言用户的保守程度,并尽量穿上我“Haskell 帽子”来回答:
-
软件在发布之前应该目标是无缺陷的。 是的。尽管,“小心以上代码中的错误;我只是证明了它的正确性,而没有尝试它。”
-
程序员应该免受错误的影响。 是的。但是,Yegge 接着补充道:“许多语言特性本质上容易出错和危险,应禁止我们编写的所有代码使用。” 这并不是 Haskell 的做法:如果你想要带有可变状态的延续,Haskell 会提供给你。(试试在 Python 中做到这一点。)它并不禁止语言特性,只是使它们更啰嗦(
unsafePerformIO
)或更难使用。Haskell 对于逃生口的信念很健康。 -
程序员学习新语法有困难。 不。 Haskell 完全站在了这个围栏的错误一侧,拥有任意的中缀操作符;甚至更极端的语言(例如 Coq)在语法制定上走得更远。当然,这并不是为了语法本身,而是为了紧密模拟数学家和其他从业者已经使用的现有语法。因此,我们允许操作符重载,但只有在支持代数法则的情况下。我们允许元编程,尽管我怀疑它目前很少使用,只因为它非常笨重(但在文化上,我认为 Haskell 社区非常愿意接受元编程的概念)。
-
生产代码必须经过编译器的安全检查。 是的。但是,任何使用依赖类型语言的人对于“安全检查”的标准要求更高,而我们经常在决定静态编码会非常烦人的不变量时玩得很随意。请注意,Yegge 声称编译器安全检查的对立面是简洁性,这是一个完全错误的神话,由于非 Hindley Milner 类型系统缺乏类型推断而流传开来。
-
数据存储必须遵循一个明确定义的、公开的架构。 明确定义的?是的。公开的?不是。Haskell 对静态检查的重视意味着编写数据类型的人更愿意在应用需求变化时更新它们,而且并不介意全局地重构数据库,因为这样做非常容易做到正确。
-
公共接口应该严格建模。 是的。(尽管 咳咳 “理想情况下应该面向对象” 咳咳)
-
生产系统绝不能有危险或者有风险的后门。 意外的。 这里工具的匮乏意味着很难窥视正在运行的编译后可执行文件并且操纵内部数据:这是目前 Haskell 生态系统的一个大问题。但抽象来说,我们非常灵活:例如,XMonad 可以重新启动以运行任意的新代码 同时保留你的全部工作状态。
-
如果对某个组件的安全性有任何疑问,它不能被允许在生产环境中使用。 这有点个人问题,实际上取决于你的项目,而不是语言本身。Haskell 对于安全关键项目非常合适,但我也用它写一些临时脚本。
-
快速胜于慢速。 不。 Haskell 代码有机会非常快,而且通常从一开始就很快。但我们强调的特性(惰性和抽象)已知会导致性能问题,大多数 Haskell 程序员的做法是只有在我们(非常棒的)性能分析工具提醒我们时才进行优化。一些 Haskell 程序员本能地在他们的数据类型中加入
! {-# UNPACK #-}
,但我不会 —— 至少在我认为我的代码太慢之前不会加。
Haskell 有很多功能都出现在 Yegge 的 “Liberal Stuff” 中。这里是其中一些:
-
Eval: 我们喜欢编写解释器,这有点像类型安全的 eval。
-
Metaprogramming: Template Haskell。
-
Dynamic scoping: Reader monad。
-
all-errors-are-warnings: 我们可以将类型错误延迟到运行时!。
-
Reflection and dynamic invocation:
class Data
。 -
RTTI: 我听说这被称为“字典”。
-
The C preprocessor: 不情愿地不可或缺。
-
Lisp macros: 为什么要使用宏,当你可以在 Template Haskell 中正确地做!
-
Domain-specific languages: Haskell 对 EDSLs 简直游刃有余。
-
Optional parameters: 这被称为组合器库。
-
Extensible syntax: 当然啦中缀表达式!
-
Auto-casting: 数字字面量,有谁不会?
-
Automatic stringification:
class Show
和 deriving。 -
Sixty-pass compilers: GHC 运行 非常多 的编译步骤。
-
Whole-namespace imports: 是的(虽然既方便又有点烦人)。
我从这次对话中得到的感觉是,大多数人认为“Haskell”和“静态类型”,同时想着在 Haskell 中编写传统动态类型代码有多糟糕,却忘了 Haskell 实际上是一种令人惊讶的自由语言,重视可理解性、简洁性和冒险精神。Haskell 是自由派还是保守派?我认为它是设计空间中的一个有趣点,将一些保守观点视为基础,然后看它能走多远。它折向了极右,结果绕到了极左。
Is it better to teach formalism or intuition? : ezyang’s blog
来源:
blog.ezyang.com/2012/03/is-it-better-to-teach-formalism-or-intuition/
注意:这不是讨论希尔伯特的形式主义与布劳威的直觉主义的问题;我使用形式主义和直觉主义的意义更宽泛,其中形式主义代表我们用于严格定义论证的符号和形式系统(尽管不是逻辑:在这里允许一定程度的严谨度滑动),而直觉则代表一个手势模型,数学家实际在脑中运行的心理模型。
Formalism and intuition should be taught together.
但尽管这个声明可能毫无争议,我认为值得详细说明为什么会这样——我们找到的原因将会说出如何学习新技术概念的重要事项。
Taken in isolation, formalism and intuition are remarkably poor teachers. Have you had the reading a mathematical textbook written in the classic style, all of the definitions crammed up at the beginning of the chapter in what seems like a formidable, almost impenetrable barrier. One thinks, "If only the author had bothered to throw us a few sentences explaining what it is we're actually trying to do here!" But at the same time, I think that we have all also had the experience of humming along to a nice, informal description, when we really haven't learned much at all: when we sit down and try to actually solve some problems, we find these descriptions frustratingly vague. It's easy to conclude that there really is no royal road to intuition, that it must be hard won by climbing the mountains of formalism.
那么,当我们将它们放在一起时,情况为什么会不同呢?
我喜欢的类比是这样的:形式主义是研究对象,而直觉则是这种研究的结果。想象一个考古学家试图向他的同事解释他从最近发掘的文物中得出的新结论。没有直觉的形式主义等同于交出实际文物而没有任何记录。原则上你可以重建出结论,但可能需要一些时间。然而,没有形式主义的直觉,等同于通过电话描述你的研究结果。它传达了一些信息,但不具备实际拿到文物并进行深入研究的分辨率或保真度。
这种解释在一定程度上解释了我学习数学的方式。例如,大多数数学形式化并不是你可以“手持”的工具——至少不是在稍加练习之前。相反,它们是巨大的迷宫,你可能一次只能看到一小部分。 (可以想象成盲人摸象的寓言。)能够在脑海中同时保留更多形式化内容,增加了你的视野。快速推理的能力也增加了你的视野。因此,记忆和机械练习在学习数学中确实起到了重要作用;它们增强了我们处理裸形式的能力。
然而,当涉及到人类时,直觉是我们最强大的武器。有了它,我们可以在有任何充分理由之前就猜测事物是真实的。但它与描述它的形式化密切相关,一个有效的直觉远没有描述形式系统那么容易转移。直觉不是你可以学习的东西;它是在你探索形式化过程中协助你的东西:“直觉的地图”。你仍然必须探索形式化过程,但你不应该因频繁查阅你的地图而感到不好意思。
我开始在面向证明的课程中采用这个想法,例如概率方法笔记集(PDF)。黑色文字是正式的证明;但是在其中穿插着蓝色和红色的评论,试图阐明“我们在这里做什么”。对我来说,这些评论是讲师提供的任何内容中增值最大的部分,因为我发现这些备注通常在任何书面笔记甚至是抄写笔记中都找不到。即使我不知道直觉意味着什么(因为通常我之后会弄明白),我也会把它们记下来。我还决定,我的理想笔记方式已经包括所有的形式化文字,这样我可以更专注于讲师可能提到的任何即兴评论。(这也意味着,如果我感到无聊,我可以提前阅读讲座内容,而不是失去兴趣。)
我们应该在静态媒体中鼓励这种演示方式,我认为使这成为可能的关键步骤之一是更容易进行注释。黑板是二维媒体,但是当我们用 LaTeX 排版时,我们进入了一维世界,大部分工作依赖于书面文字。这样做没问题,但有点麻烦,这意味着人们不会像我们希望的那样频繁地这样做。我不确定最佳的系统会是什么样子(我个人喜欢手写笔记,但这显然并不适合所有人!),但我对最近教学初创公司推出的带注释的教材感到乐观。
进一步阅读,我鼓励你阅读威廉·瑟斯顿的论文,《数学中的证明与进展》,这篇论文对我的思考在这个问题上产生了巨大影响。
multiply-carry强通用吗? : ezyang’s blog
来源:
blog.ezyang.com/2010/11/is-multiply-carry-strongly-universal/
我一直想要实现一个count-min sketch;这个结构比布隆过滤器稍微不那么广为人知,它是一种相关的sketch数据结构(即,一种概率数据结构,用于近似回答某些查询),但它看起来是一个相当实用的结构,并且已经在一些有趣的方式中被使用。
可惜的是,当你想要实现一个不到十年前提出的数据结构,而且还没有进入教科书时,会遇到很多理论上的模糊点。在这个特定情况下,理论上的模糊点是选择通用哈希族。因为我还没有修过研究生级别的算法课程,所以必须去查书。
通过我对课程笔记、论文和教科书的调查,我注意到两件事情。
首先,通用哈希族可能具有许多不同的独立性保证,每一种保证可能会有许多不同的名称。假设我们的哈希族H
是由函数h:M → N
组成,其中M = {0, 1, ..., m-1}
,N = {0, 1, ..., n-1}
,且m >= n
。这里,M 对应我们的“全集”,即将被哈希的可能值,而 N 则是哈希函数的范围。
-
弱通用哈希族,也称为弱 2-universal 哈希族,有时候会简称为weak,是一种哈希族,对于从
H
中随机选择的哈希函数h
:∀ x,y ∈ M, x != y. Pr[h(x) = h(y)] ≤ 1/n
-
强 2-universal 哈希族,也称为(强) 2-independent 通用哈希族,有时候会简称为2-universal,是满足以下条件的哈希族:
∀ x,y ∈ M, a,b ∈ N. Pr[h(x) = a ∧ h(y) = b] ≤ 1/n²
-
(强) k-independent 通用哈希族将上述概念推广到以下条件:
∀ x₁,x₂...x_k ∈ M, a₁,a₂...a_k ∈ N. Pr[h(x₁) = a₁ ∧ h(x₂) = a₂ ∧ ...] ≤ 1/n^k
其次,“弱”通常在“弱哈希函数”中省略,是因为 2-universal 哈希族往往也是 2-independent。《随机化算法》指出:“大多数已知的 2-universal 哈希族的构造实际上产生了一个强 2-universal 哈希族。因此,这两个定义通常没有区别。” 并要求学生证明,如果n = m = p
是一个素数,那么卡特和韦格曼的哈希族是强 2-universal 的。(我马上会说明这是什么。) 因此,维基百科 愉快地采纳了弱标准,并在最后一节中简要提到了 2-independence。(我没有编辑文章,因为我不确定是否需要做任何更改。)
那么,卡特和韦格曼的通用哈希族是什么?非常简单:
鉴于 p ≥ m 是质数且 ![a,b \in {0, 1, \cdots, p-1}](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/cdots, p-1}")。除此之外,呃,实际上没有人在实践中使用模数。这里有一个来自 Cormode 的实现 的例子:
#define MOD 2147483647
#define HL 31
long hash31(long long a, long long b, long long x)
{
long long result;
long lresult;
// return a hash of x using a and b mod (2³¹ - 1)
// may need to do another mod afterwards, or drop high bits
// depending on d, number of bad guys
// 2³¹ - 1 = 2147483647
result=(a * x) + b;
result = ((result >> HL) + result) & MOD;
lresult=(long) result;
return(lresult);
}
这个实现显然是正确的:
-
乘法和加法不能使
long long
结果溢出,并且 -
第二行利用了我们利用 Mersenne 质数进行快速取模的能力,结合了几种替代的位运算。当然,为了做到这一点,我们需要非常小心地选择质数。嗯,神奇的数字。
好的,那很好。有一个小小的疏忽,我们没有明确确保 n = m = p
,所以我不能百分之百确定我们保留了强一般化。但我还没有做完 Randomized Algorithms 练习,所以我不知道这个属性在实践中有多重要。
顺便说一下,这个函数也声称是这种非常通用的哈希,但我很难相信它:
Tools::UniversalHash::value_type Tools::UniversalHash::hash(
UniversalHash::value_type x
) const
{
uint64_t r = m_a[0];
uint64_t xd = 1;
for (uint16_t i = 1; i < m_k; i++)
{
xd = (xd * x) % m_P;
r += (m_a[i] * xd) % m_P;
// FIXME: multiplications here might overflow.
}
r = (r % m_P) & 0xFFFFFFFF;
// this is the same as x % 2³²\. The modulo operation with powers
// of 2 (2^n) is a simple bitwise AND with 2^n - 1.
return static_cast<value_type>(r);
}
现在我们把注意力转向 multiply-carry,维基百科声称它是 目前已知整数最快的通用哈希族。它设计成在计算机上易于实现:(unsigned) (a*x) >> (w-M)
(其中 a
是奇数)就是你所需要的全部。嗯,准确地说,它是目前已知的最快 2-一般化 哈希族:相关论文仅就弱一般化给出了证明,详见 相关论文。
所以,我的问题是:multiply-carry 是否强一般化?Motwani 和 Raghavan 暗示它可能是,但我找不到证明。
Postscript. 幸运的是,对于 count-min-sketch,我们实际上并不需要强一般化。我向 Graham Cormode 确认过,他们在论文中只使用了 2-一般化。但原始问题仍然存在……在严格的理论基础上,无论如何。
Non sequitur. 这里有一个有趣的组合器,用于组合在折叠中使用的函数:
f1 <&> f2 = \(r1, r2) a -> (f1 r1 a, f2 r2 a)
它允许你将两个组合函数捆绑在一起,这样你可以一次性将它们应用到列表中:
(foldl xs f1 z1, foldl xs f2 z2) == foldl xs (f1 <&> f2) (z1, z2)
翻转组合器可以使其适用于右折叠。这使我们得到了 average
函数的以下可爱实现:
average = uncurry (/) . foldl' ((+) <&> (flip (const (+1)))) (0,0)
或许我们可以写一条重写规则来为我们做这件事。
不需重新安装的 Cabal 是否即将到来 GHC 8.0?:ezyang’s 博客
来源:
blog.ezyang.com/2015/09/is-no-reinstall-cabal-coming-to-ghc-8/
你可能会想:使用 不需重新安装的 Cabal 的测试版,这个功能是否会被引入到 GHC 8.0 中?(甚至是新版本的 Cabal,因为不需重新安装功能在 GHC 7.10 上也有效)。不幸的是,Cabal 的开发人员在是否应将实际的不需重新安装行为默认加入 Cabal 中存在分歧。尤其是 Duncan Coutts 认为在没有其他(未实施的)对 Cabal 的更改的情况下启用不需重新安装是一个坏主意。由于额外所需的更改尚未完全实施,目前不清楚 Duncan 是否会在 GHC 8.0 中管理它们。
我听说不需要重新安装的 Cabal 对于大多数人来说实际上运行良好,因此我怀疑很多人会赞成直接采取“好”(而非“最佳”)解决方案并将其纳入 Cabal。但我希望促进一个理性的讨论,因此我想解释一下不需要重新安装的(已知的)问题。
什么是不需重新安装?
当前,GHC 和 Cabal 在已安装的软件包数据库中维护一个不变量,即对于任何软件包名称和版本(即(源)软件包 ID),数据库中最多只有一个匹配的软件包:
箭头表示“依赖于”关系:因此,如果你有一个数据库,其中包含 bar-0.1、bar-0.2 和一个构建在 bar-0.1 上的 foo-0.1 实例,那么你将不允许安装另一个构建在 bar-0.2 上的 foo-0.1 实例(尽管你可以安装构建在 bar-0.2 上的 foo-0.2)。
如果 cabal-install 希望安装一个与已在数据库中的软件包具有相同软件包 ID 但具有不同依赖项的软件包,则必须破坏性地覆盖先前的条目以维护下面显示的此不变量:
无需重新安装此不变量,因此“重新安装”具有不同依赖项的软件包正常运行:
最近发布的不需重新安装的 Cabal 通过两个小功能实现了这一点。首先,在 GHC 7.10 中,我们为 ghc-pkg
添加了 --enable-multi-instance
标志,使得 ghc-pkg
在尝试向数据库中添加同一软件包的多个副本时不再报错。其次,在 Vishal Agrawal 的 Cabal 补丁集中,修改了 cabal-install 以使用此标志,因此依赖解析器不再需要避免重新安装。
不过,破坏此不变量会产生后果。让我们看看其中的一些后果。
问题 1:它在旧版本的 GHC 上不起作用
总结: 在 GHC 7.8 及之前的版本中,直接实现无重新安装是不可能的(因为 ghc-pkg
将拒绝它)。即使可能,安装一个具有与现有包相同源包 ID 的新包的实例,会导致以下情况之一:(1)使旧包及其所有依赖项从 GHC 的默认视图中隐藏,尽管它们仍然可用;或者(2)未能在 GHC 的默认视图中暴露。
假设包 foo-0.1
定义了类型 Foo
,并且已经使用其依赖项的不同版本编译了两次:
GHC 7.8 无法区分包的两次编译:来自两个包的符号将存在于 foo-0.1
命名空间中,并且冲突的符号将简单地被视为相同。灾难!为避免这种情况,GHC 有一个遮蔽算法,从其可见集中移除不兼容的包。以下是一个例子:
我们有两个包数据库,用户数据库和全局数据库,侧边放置(用户数据库位于“顶部”)。当组合数据库中存在冲突的包 ID 时,GHC 更喜欢来自顶部数据库的包:因此,在我们的例子中,全局 foo-0.1
被遮蔽(任何直接或间接依赖于它的包也被遮蔽)。当一个包被遮蔽时,对于 GHC 来说它根本不存在:GHC 不会提及它或者暗示它存在。
无重新安装要求我们允许这些重复包存在于同一数据库中!在这种情况下,GHC 将应用遮蔽;然而,不清楚应该遮蔽哪个包。如果 GHC 选择遮蔽旧包,则它们会从 GHC 的默认视图中“消失”(就像它们根本不存在一样);如果 GHC 选择遮蔽新包,则用户刚刚通过 cabal-install
安装的包会神秘地消失!令人头疼。
问题 2: 使用多个相同包实例很令人困惑。
总结: 在 GHC 7.10 或更新版本中,可以在同一 GHC/GHCi 会话中同时使用多个相同包的实例,这可能导致混乱的类型不等式。
在 GHC 7.10 中,我们现在使用“包键”来测试类型标识。包键是源包 ID 加上所有依赖包键的哈希值。这意味着 GHC 不再需要应用遮蔽来保证完整性,你可以使用 ghc-pkg
上的 --enable-mult-instances
标志注册包的重复实例。
然而,这仍然可能导致混乱的行为。考虑在 GHC 7.10 中的前述示例:
foo
的两个版本都是可见的,因此如果我们尝试导入 Foo
,GHC 将抱怨不知道我们想要哪个 Foo
。可以通过隐藏其中一个包或另一个包来解决这个问题。然而,假设 baz
和 qux
都是公开的,并且它们都导出了类型为 Foo
的值 foo
。尽管它们: (1) 都命名为 Foo
,并且 (2) 来自名为 foo-0.1
的包:它们是 foo-0.1
的两个不同实例。令人困惑!
问题 3:Nix 对非 sdist 包的哈希是困难的
很容易“欺骗”Cabal 去哈希一组源文件,这些文件并不代表构建系统的真正输入:例如,您可以省略 other-modules
字段中的文件,或者在 Cabal 计算源哈希后和构建文件之间修改文件。如果你不能信任 Nix 哈希,那么当你真正需要覆盖 Nix 数据库中的旧条目(它错误地具有与您尝试安装的内容“相同”的哈希)时,现在你必须担心会发生什么。
从 Hackage 下载的 tarballs 不会存在这个问题,因为你可以简单地对 tarball 进行哈希,这可以保证是用于构建文件的完整源代码集。
Duncan 的补丁集
为了解决这些问题,Duncan 一直在开发一个更大的补丁集,具有以下特性:
-
为了支持旧版本的 GHC,他维护了一个单独的“默认视图”包数据库(它由裸露的 GHC 和 GHCi 调用使用),与实际的“Nix 存储”包数据库不同。
cabal-install
负责维护一个一致的默认视图,但也将所有内容安装到 Nix 存储数据库中。 -
Nix 风格的哈希仅针对 Hackage 包执行;本地源代码树只能构建并安装到沙盒数据库中,而不是全局数据库。因此,实际的 Nix 哈希仅由
cabal-install
计算。 -
他还希望使
cabal-install
的安装计划不依赖于 Nix 数据库的本地状态:无论您之前安装了什么,它都应该提供相同的计划。这通过依赖解析而没有任何参考 Nix 数据库的方式来完成,然后一旦为每个包计算了 IPID,就检查它们是否已经构建。这个计划还将使支持cabal install --enable-profiling
成为可能,而无需清空并重建整个包数据库。
Vishal 的补丁集
Vishal 也意识到了包数据库默认视图的问题,并且他为支持修改包环境而在 GHC 上工作了一些补丁,这将起到与 Duncan 的额外包数据库类似的作用。不幸的是,这些补丁目前在代码审查中遇到了些许问题,并且它们不会帮助那些使用旧版本 GHC 的用户。虽然这些补丁的代码审查过程可能在不久的将来会有所进展,但我对这些变化是否能够落地表示怀疑。
结论
我的观点是,历史上,问题一和问题二一直是“无需重新安装”未被添加为 Cabal 的默认操作模式的主要原因。然而,有越来越多的观点(我认为可以安全地引用 Simon Marlow在这方面)认为这些问题被夸大了,并且我们应该接受这一现实。
如果我们想在 Duncan 完成他的补丁集之前(不管那会是什么时候 — 或者也许有其他人会完成它),我认为需要一些协同努力来表明,这些问题是为了无需重新安装 Cabal 所付出的小代价,并且 Haskell 社区愿意承担…… 至少在更好的实现方法出现之前。
It’s just a longjmp to the left : ezyang’s blog
然后向右边发出信号。
一个与 readline 相关的明显缺陷是,如果在提示期间按下^C
,什么也不会发生,如果再次按下^C
(并且没有在信号处理程序中搞砸),整个程序将不情不愿地终止。那不太好!幸运的是,readline
似乎是少数几个确实投入了一些工作来确保您可以从信号处理程序中 longjmp 出来而不完全破坏库内部状态的 C 库之一(他们通过自由掩码和解除掩码,以及他们自己的信号处理程序来做到这一点,清理然后重新抛出信号)。
所以我决定看看能否修补 readline,使其能从信号处理程序(由yours truly提供的信号)中的 longjmp 中恢复控制权,并将控制权交还给 Haskell。这个怪物就这样诞生了。
static jmp_buf hs_readline_jmp_buf;
static struct sigaction old_sigpipe_action;
void hs_readline_sigaction (int signo, siginfo_t *info, void *data)
{
sigaction(SIGALRM, &old_sigpipe_action, NULL);
siglongjmp(hs_readline_jmp_buf, signo);
}
char *hs_readline (const char *s)
{
struct sigaction action;
int signo;
sigset_t mask;
memset(&action, 0, sizeof(struct sigaction));
sigemptyset(&mask);
sigaddset(&mask, SIGALRM);
action.sa_sigaction = hs_readline_sigaction;
action.sa_mask = mask;
action.sa_flags = SA_RESETHAND;
sigaction(SIGALRM, &action, &old_sigpipe_action);
if (signo = sigsetjmp(hs_readline_jmp_buf, 1)) {
return NULL;
}
char *r = readline(s);
sigaction(SIGALRM, &old_sigpipe_action, NULL);
return r;
}
它实际上运行得非常好,尽管信号的路径有些迂回:SIGINT 首先由readline安装的信号处理程序处理,它会清理终端的更改,然后重新抛出到 GHC 的信号处理程序。 GHC 将告诉 IO 管理器发生了信号,然后返回到 readline 的内部(它重新安装了终端的所有更改)。然后,IO 管理器读取信号,并发送一个ThreadKilled
异常,这将导致 RTS 尝试中断外部调用。SIGALRM
(实际上,这是一个谎言,GHC 中的代码发送了一个SIGPIPE
,但 readline 认为SIGPIPE
不是应该清理的信号,所以我改变了它——欢迎更好的建议)再次命中 readline 的信号处理程序,我们清理终端,然后命中我们的信号处理程序,它 longjmp 到一个return NULL
,这将把我们带回 Haskell。然后捕获信号,大家都很高兴。
不幸的是,几乎所有的代码都是样板,我不能把它放进一个漂亮的 Haskell 组合器中,因为当 Haskell 在执行时,几乎没有堆栈可言,我敢打赌一个setjmp
的 FFI 调用会让 RTS 非常困惑。它也不是可重入的,尽管我怀疑readline
也不是可重入的。当然,从信号处理程序进行非本地控制转移是你妈妈告诉你不要做的事情。所以这种方法可能不通用。但它相当有趣。
IVar 泄漏:ezyang 的博客
IVar 泄漏
首先要说服自己的是,确实存在一个问题,即我上周发布的代码。 由于这是一个内存泄漏,我们需要跟踪 IVar 的创建和访问。IVar 分配发生在我们示例的以下情况中:
-
对
return
的调用,返回一个完整的 IVar, -
对
tick
的调用,返回一个空的 IVar 并安排一个线程来填充此 IVar, -
对
>>=
的调用,返回一个空的 IVar 和一个附加到左 IVar 的回调的引用。
当我们解引用 IORef、添加回调或填充 IVar 时,发生 IVar 访问。这发生在以下情况下:
-
对
>>=
的调用,解引用左 IVar 并添加一个回调, -
对
>>=
左参数的回调调用,将一个回调添加到f x
的结果中, -
对
f x
的结果(从上述回调中)进行回调调用,填充在(3)中分配的原始 IVar, -
由
tick
调度的线程的回调调用,用于填充其调度的空 IVar。
现在我们可以追踪代码loop = tick >>= loop
中由>>=
分配的 IVar 的生命周期。
-
由
>>=
分配的 IVar。生成两个引用:一个在附加到tick
的回调中,一个返回的引用中。 -
调度程序运行填充来自
tick
的 IVar 的线程,运行其回调。IVar 可通过新分配给f x
的回调访问。请注意,此处的f
是\() -> loop
,因此此时发生递归调用。 -
调度程序运行填充来自
f x
的 IVar 的线程,运行其回调。IVar 已填充,并且回调链中对其的引用现在已经失效。IVar 的生命周期仅依赖于我们向客户端返回的引用。
请注意,在第一轮和第二轮调度器中,绑定分配的 IVar 通过非返回给客户端的引用保持活跃。在第一种情况下,它通过对 tick 的回调保持活跃(进而通过其在执行计划中的位置保持活跃);在第二种情况下,它通过对f x
的回调保持活跃。如果我们能够进入第三种情况,所有内容都将设法被 GC 回收,但这是一个大if:在我们的无限循环中,f x
永远不会被填充。
即使最终被填充,我们也会积累递归长度的IVar
,而如果我们有某种“尾递归优化”,我们可以立即丢弃这些IVars
。
约瑟夫与令人惊叹的彩色箱子:ezyang 的博客
来源:
blog.ezyang.com/2011/08/joseph-and-the-amazing-technicolor-box/
约瑟夫与令人惊叹的彩色箱子
在 Haskell 中考虑以下数据类型:
data Box a = B a
有多少类型为 Box a -> Box a
的可计算函数?如果我们严格使用表义语义,有七个:
但如果我们进一步区分底部的源头(一个非常操作性的概念),一些具有相同表达的函数有更多的实现方式……
-
不可反驳的模式匹配:
f ~(B x) = B x
。无多余内容。 -
身份:
f b = b
。无多余内容。 -
严格:
f (B !x) = B x
。无多余内容。 -
常数箱底: 有三种可能性:
f _ = B (error "1")
;f b = B (case b of B _ -> error "2")
;以及f b = B (case b of B !x -> error "3")
。 -
不存在: 有两种可能性:
f (B _) = B (error "4")
;以及f (B x) = B (x `seq` error "5")
。 -
严格的常数箱底:
f (B !x) = B (error "6")
. -
底部: 有三种可能性:
f _ = error "7"
;f (B _) = error "8"
;以及f (B !x) = error "9"
。
列表按彩虹颜色排序。如果这对你来说像象形文字一样难懂,我可以向你介绍这篇博文?
附录. GHC 可以且将优化 f b = B (case b of B !x -> error "3")
,f (B x) = B (x `seq` error "5")
和 f (B !x) = error "9"
到替代形式,因为一般来说我们不会说 seq (error "1") (error "2")
在语义上等同于 error "1"
或 error "2"
:由于不精确的异常,任何一个都有可能。但是如果你真的在乎,你可以使用 pseq
。然而,即使使用异常集语义,这个“精炼”视图中仍有更多函数的正常表示语义。
Haskell 中的关键字参数:ezyang 的博客
Haskell 中的关键字参数
语言设计者通常认为关键字参数是一件好事:位置参数容易出错,试图猜测作为函数第三个参数的37
究竟意味着什么真是令人头疼。Python 是一种广泛使用关键字参数的语言;它们具有以下特性:
-
函数可以混合使用位置参数和关键字参数(向位置参数的紧凑性致敬),
-
关键字局限于任何给定函数;你可以重复使用命名函数参数来调用另一个函数,
-
在 Python 3.0 中,你可以强制某些参数只能通过关键字指定。
Haskell 是否有关键字参数?在很多方面,由于静态类型系统,它们不太必要:如果你在参数中不小心交错了Int
和Bool
,编译器会提醒你。类型签名会指导你!
不过,如果我们坚持使用(也许我们的函数接受很多相同类型的参数),一种可能性是将记录数据类型作为唯一参数传入,但这与 Python 关键字参数有些不同:
-
位置参数和关键字之间有严格的界定:要么完全使用关键字指定你的记录,要么完全使用位置参数,但不能两者兼而有之,
-
记录字段进入全局命名空间,因此你必须使用某些唯一标识符来前缀/后缀它们,并且
-
即使使用命名记录,用户仍然可以选择在构造记录时不指定关键字参数。对于大型参数列表,这并不是问题,但对于短参数列表,这种诱惑是很大的。
我发现第二个问题是我不真正使用这个技巧的原因;我觉得如果我想使用命名参数,为每个函数都创建数据结构会相当烦人。
我想建议另一个技巧来模拟命名参数:使用新类型!考虑这个不够类型化的函数:
renderBox :: Int -> Int -> Int -> Int -> IO ()
renderBox x y width height = undefined
main = renderBox 2 4 50 60
我们可以将其转换为使用新类型,例如:
newtype XPos = XPos Int
newtype YPos = YPos Int
newtype Width = Width Int
newtype Height = Height Int
renderBox :: XPos -> YPos -> Width -> Height -> IO ()
renderBox (XPos x) (YPos y) (Width width) (Height height) = undefined
main = renderBox (XPos 2) (YPos 4) (Width 50) (Height 60)
与通常使用新类型不同,我们的新类型生命周期极其短暂:它们只能在进入renderBox
的函数体内部存在一段时间,然后它们就会被模式匹配到遗忘:函数体可以依赖良好的局部变量名来完成其余部分。但它仍然能够实现关键字参数的目标:调用renderBox
时清楚地表明每个整数的含义。我们还保持以下良好的属性:
-
如果类型已经包含了关于参数的所有信息,就不需要再次使用新类型。因此,你可以同时拥有常规参数和新类型参数的混合。
-
新类型可以被重复使用。更进一步,只有在其内部语义内容相同时才能重复使用,这鼓励良好的命名实践。
-
用户被迫进行新类型的包装:无法绕过这一点。如果您发布智能构造函数而不是通常的构造函数,则可以将验证内容分离出来。
新类型非常灵活!
Killer mutants attack (mutation gone wrong):ezyang’s 博客
来源:
blog.ezyang.com/2011/03/killer-mutants-attack-mutation-gone-wrong/
这是由于误用可变状态导致的一系列 WTFs。
我们将从一些 Java 开始。你期望这段代码做什么?
Sensor Accel = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
Sensor Orient = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION);
sm.registerListener((SensorEventListener) this, Accel, sm.SENSOR_DELAY_FASTEST);
表面上,它注册当前对象以接收加速度计更新。但如果我告诉你 getDefaultSensor 是这样实现的呢:
public Sensor getDefaultSensor (int type){
if(sensors == null) {
sensors = new Sensor(mContext, type);
return sensors;
}else if(sensors.checkList(type)){
sensors.addSensor(type);
return sensors;
}else{
sensors.removeSensor(type);
return sensors;
}
}
这段代码完全无法管理预期的语义:有一个宽为 sm
的单个 Sensor
对象(存储在 sensors
中),随着 getDefaultSensor
的调用累积传感器值。因此,实际上,this
将同时接收来自加速度计和磁力计的事件。唯一的救赎是,当我们注册事件监听器时,通常确实希望它们接收所有事件,所以如果我们不仔细看的话可能不会注意到这一点。这是来自 OpenIntents SensorSimulator 的真实代码。
以免你认为我只取笑别人的代码,这里是我自己项目的一处差异:
@@ -197,13 +197,7 @@ def upload_all(tree, ftp, base):
ftp.cwd(base)
for blob in tree.blobs:
- logging.info('Uploading ' + '/'.join((base, blob.name)))
- try:
- ftp.delete(blob.name)
- except ftplib.error_perm:
- pass
- ftp.storbinary('STOR ' + blob.name, blob.data_stream)
- ftp.voidcmd('SITE CHMOD ' + format_mode(blob.mode) + ' ' + blob.name)
+ upload_blob(blob, ftp, base)
@@ -260,11 +254,25 @@ def upload_diff(diff, tree, ftp, base):
node = subtree/components[-1]
assert isinstance(node, Blob)
- logging.info('Uploading ' + full_path)
- ftp.storbinary('STOR ' + file, node.data_stream)
- ftp.voidcmd('SITE CHMOD ' + format_mode(node.mode) + ' ' + file)
- # Don't do anything if there isn't any item; maybe it
- # was deleted.
+ upload_blob(node, ftp, base)
这看起来相当合理:我已经分离出一些常见的storebinary
逻辑。你能看出 bug 是什么吗?这里有个提示。
问题在于 upload_all
在 FTP 连接上改变了当前工作目录(可变状态!),而 upload_diff
则没有(完全从 base
工作目录操作)。上传函数假设了 upload_all
风格的工作目录更改,因此所有 upload_diff
的上传都被放在了基本目录中。可变性损害了模块化!解决方法是摆脱这种变化,并手动计算完整路径;这也消除了原始 upload_all
实现中一些复杂的不变性维护。
令人矛盾的是,尽管 Haskell 鼓励你不要使用突变,但当你使用它时,Haskell 强大的静态类型系统赋予了你一种非同寻常的能力,即在静态地编码关于突变的复杂不变量——如果你没有使用突变,这些不变量是不必要的。一个小例子是 ST monad,它使用 rank-2 类型来确保对可变内存的引用不能逃逸出 runST
,这是孤立的“突变线程”。
如果你尝试静态地排除对可变 API 的不正确使用,你可能会发现自己深陷于高级类型系统特性之中。我在与abcBridge合作时发现了这一点,并且非常努力地使用类型来防止底层 C 库的不当使用。这里有一个相关的代码引用:
-- | When you duplicate a network, node indexes may change, so if you
-- would like to use old references, they need to be translated first.
-- You can read this type as @Node n -> NT n2 (Node n2)@ or @Node n ->
-- NQ n2 (Node n2)@, with the condition that the @n2@ index was one
-- generated by 'branch' or 'speculate'.
translate :: (G.NetworkMonad m AIG (Dup n n2)) => Node n -> m AIG (Dup n n2) (Node (Dup n n2))
这确实是一些 WTF,等级-2-幻影类型的代码,但它源于我偶然发现的一个非常具体的错误,并且我并不确定我会记得在未来避免它(你能猜到是什么吗?)一个好奇的读者可能会问,为什么我需要在第一次重复网络?因为底层库提供的一些操作是破坏性的,我可以提供持久网络的假象的唯一方法是在破坏之前复制。
总结起来:
-
变异通常不符合人们的预期,
-
变异不是模块化的,而且
-
变异是复杂的。
尽量避免它!
Kindle 不适合教科书:ezyang 的博客
来源:
blog.ezyang.com/2013/04/kindle-is-not-good-for-textbooks/
Kindle 不适合教科书阅读
在我的 Kindle 上尝试阅读了几本教科书后,我郑重其事地得出结论,事实上 Kindle 对于阅读教科书来说是一个非常糟糕的设备。根本问题在于,由于技术限制,Kindle 主要优化了顺序阅读。这可以从许多方面看出:
-
在 Kindle 上翻页不是即时的(我没有一个良好的设置来计算屏幕刷新需要多长时间,但确实在你滑动时有可感知的延迟,在 Kindle 成功重新绘制屏幕之间有明显的延迟—如果你尝试往回翻更糟)。
-
为了扫描视觉特征而快速翻页,加剧了延迟问题。
-
无法采用“指头”方法进行随机访问(即用手指夹在两页之间以快速切换);使用当前的 Kindle 界面在书签之间跳转需要四次按键!
-
Kindle 的屏幕尺寸显著小于普通教科书的尺寸,这导致在一个屏幕上显示的信息量减少,并进一步加剧了翻页的缓慢问题。
教科书不能像轻小说那样阅读。因此,虽然 Kindle 提供了随身携带一堆教科书的诱人可能性,但实际上,如果你打算从中认真学习,最好还是获得实体版本。这并不是说电子教科书没有用;事实上,在笔记本电脑上拥有可搜索的教科书确实非常棒—但这是当你把教科书作为参考资料使用时,并不是当你试图真正学习材料时。
Kindle Paperwhite 笔记:ezyang 的博客
在寒假期间,除了Nexus 7,我还购买了Kindle Paperwhite。 (仅 Wi-Fi)我对这次购买感到非常满意,尽管方式出乎意料:虽然我没有增加阅读的书籍数量,但 Kindle 实际上改变了我阅读互联网文章的方式。不是通过其基本上无法使用的网络浏览器,而是通过将互联网文章转换为电子书形式的工具。
对于博客文章,我使用Calibre与 Google Reader 源。这是一场革命:我不再在浏览器中阅读博客文章;相反,我将它们捆绑在 Kindle 上,然后在闲暇时阅读。这个改变也意味着我更加仔细地阅读博客文章(interfluidity以前只是粗略浏览;现在我实际上可以读完文章)。这个设置有点复杂,所以我可能会在以后的博客文章中描述它。(我以前使用Klip.me,它设置简单,但是(1)无法处理图片(所以方程式和图表都不行),以及(2)格式化pre
格式文本出现问题。)
对于长篇文章,我使用Longform,它提供了一系列深入报道和非虚构文章。他们有一个非常方便的“发送到 Kindle”按钮,据说是由 Readability 提供的;我想知道是否应该在我的博客中添加一个类似的按钮。我还在使用亚马逊的Send to Kindle Firefox 扩展阅读我的付费文章(主要是LWN.net),尽管其效果似乎比 Readability 差一些。
对于学术论文,情况有点艰难,但我已经在使用cut2col处理双栏论文时取得了不错的结果,它可以使文本变大以便在 Kindle 的 PDF 阅读器中阅读。横向模式也有助于阅读 PDF。然而,管理设备上的 PDF 文件仍然是一个难题。我还没有开始使用 Calibre 来解决这个问题,尽管我已经用它在电子书格式之间进行了一些转换。这些转换效果并不是特别好,但可以使用。最好获取最新版本的 Calibre,因为当前的 Quantal 版本中存在一些错误;我使用ppa:n-muench/calibre。
对于教科书,电子书版本仍然非常昂贵。我很想随身携带所有著名的计算机科学教科书,但迄今为止我没有任何好方法可以做到而不花费大量资金。书籍也有类似问题:事实证明,在 Kindle 出现之前,我大部分阅读都是从图书馆借书;然而,我听说Palo Alto 公共图书馆提供 Kindle 借阅,我打算在某个时候去看看。由像古腾堡计划这样的机构发布的公有领域书籍的排版质量往往不稳定;Readability 和类似服务似乎做得更好!遗憾的是,斯坦福图书馆系统似乎没有电子书。亚马逊书籍的免费样本也很有趣,我收集了不少。
有关 Kindle 本身的一些烦恼:
-
亚马逊特别优惠,我似乎无法摆脱(显然,如果 Kindle 与购买设备的原始亚马逊账户相关联,你只能支付 20 美元来移除它们;而这不是我的情况)
-
最近视图的重新排序行为:如果你在阅读完一本书后回到主页,Kindle 会在重新排序并将你刚刚阅读完的项目移到最前面之前,短暂地显示旧项目顺序。如果在此期间尝试点击其他项目,点击将在新的重新排序发生之前不会注册;这让我多次不愉快地意外点击了错误的项目!
-
对于超文本文章,它们往往包含链接,这意味着如果你使用“轻点”前进到下一页,而链接出现在你的手指下,你将意外地打开浏览器。这非常令人恼火,我只想完全关闭链接。(是的,我知道你可以轻扫,但那很烦人,我不想麻烦自己重新训练。)
-
对于某些格式,特别是 PDF,页面刷新速度相当慢;这使得像在真正的书中那样快速翻页变得困难。这可能是 Kindle 相对于传统书籍的主要缺点;另一个是无法看到并快速翻到书签(在 Kindle 上移动到书签需要多次轻点)。
-
我还越狱了我的 Kindle,但似乎没有任何有趣的软件可供使用。
总的来说,我对这个设备非常满意,我会推荐给任何对减少盯着电脑显示器的时间感兴趣的人。
VX-8R 的后期印象:ezyang 的博客
VX-8R 的后期印象
一月初,我发表了一些有关 VX-8R 的第一印象。现在已经三个月过去了,我在更多的实地测试中使用了我的无线电。考虑到以下原因,我正在考虑将我的 VX-8R 出售给 7R:
-
我通常需要五小时的接收和中等传输。我只能用标准的 VX-8R 电池接收大约 3.5 小时,这实在是不可接受。(有可能会被“别在我草坪上”笑话,卡尔·拉姆评论说,他 90 年代的旧 Icom W32 电台接收时间长达 12 小时。)
-
AA 电池适配器令人发笑,可能在接收前只能持续 20 分钟。AA 电池可以用于数码相机使我误以为我可以在无线电上做同样的事情,但这个适配器只适合紧急接收情况。
-
剩余电池指示器不可靠,从 8.5V 下降到 7.5V,然后直接降至零。这是我上次寄回去的缺陷,但我在替换机上也看到了这个问题,一个朋友确认他的设备也有同样的问题。
-
电台在运行时会变得非常热。我在 7R 上从未注意到温度。
-
这里的其他人都拥有 7R,这严重限制了各种组件(特别是电池)的互换性。
我确实会想念专用的立体声插孔和更轻更薄(而且,在我看来,更好的)接口,但这些确实是无法容忍的问题。 C'ést la vie.
Lav’net 在看着你:ezyang 的博客
Lav’net 在看着你
这张照片拍摄于我住所两个街区外的巴黎。一些背景信息。
懒惰异常与 IO : ezyang 的博客
懒惰异常与 IO
考虑下面的代码片段:
import Prelude hiding (catch)
import Control.Exception
main :: IO ()
main = do
t <- safeCall
unsafeCall t
putStrLn "Done."
safeCall :: IO String
safeCall = do
return alwaysFails `catch` errorHandler
--alwaysFails = throw (ErrorCall "Oh no!")
alwaysFails = error "Oh no!"
errorHandler :: SomeException -> IO String
errorHandler e = do
putStrLn "Caught"
return "Ok."
errorHandler_ e = errorHandler e >> return ()
unsafeCall :: String -> IO ()
unsafeCall = putStrLn
你可能期望的输出是什么?直接转录到 Python 可能看起来像:
def main():
t = safeCall()
unsafeCall(t)
print "Done"
def safeCall():
try:
return alwaysFails()
except:
return errorHandler()
def alwaysFails():
raise Exception("Oh no!")
def errorHandler():
print "Caught."
return "Ok."
def unsafeCall(output):
print output
任何对任何严格语言有一定了解的人都会说:“当然,它会输出:”
Caught.
Ok.
Done.
当然,懒惰异常(error
发出的就是这种)并非无缘无故地被称为懒惰;Haskell 代码输出:
*** Exception: Oh no!
发生了什么?Haskell 是懒惰的,直到它需要为 unsafeCall 评估 IO return alwaysFails
的纯内部代码时,它才会这样做。在那时,没有更多的catch
调用保护代码了。如果你不相信我,可以在alwaysFails
周围添加一个追踪。您也可以尝试在unsafeCall
上安装errorHandler_
。
这个故事的寓意是什么?嗯,其中一个是错误
是邪恶的,但我们早已知道这一点…
-
对于大多数基于 IO 的错误,您可以以显而易见的方式安装异常处理程序。(如果我们用
return alwaysFails
替换了alwaysFails
,结果就会是严格的。)对于源自纯代码的错误,您不能安装异常处理程序,因为 GHC 保留在执行代码的时间上任意调度的权利。 -
如果纯代码正在抛出异常,而您希望它停止这样做,您可能需要使用
$!
deepseq
或rnf
来强制严格性,这将迫使 GHC 在受保护区域内执行计算。正如我的读者指出的那样,一个很好的思考方式是,调用不是异常的,结构才是。 -
如果您从纯代码中获得不精确的异常,但是无法弄清楚原因,祝您好运!我还没有找到解决这个问题的好办法。(给我的博客读者的一个小提示。)
附言. 请注意,我们需要使用Control.Exception.catch
。Prelude.catch
,按照 Haskell98 的定义,仅捕获基于 IO 的错误。
Haskell 导入和声明的左递归解析
来源:
blog.ezyang.com/2016/12/left-recursive-parsing-of-haskell-imports-and-declarations/
假设您想解析一个由换行符分隔的列表,但希望自动忽略额外的换行符(就像 Haskell 文件中的 import
声明可以由一个或多个换行符分隔一样)。从历史上看,GHC 使用了一种奇怪的语法来执行这种解析(这里,分号表示换行符):
decls : decls ';' decl
| decls ';'
| decl
| {- empty -}
需要一点努力才能理解,但这个语法的要点是接受一个由 decls 组成的列表,其中夹杂着一个或多个分号,并且可以有零个或多个前导/尾随分号。例如,;decl;;decl;
解析为:
{- empty -} (rule 4)
{- empty -} ';' decl (rule 1)
{- empty -} ';' decl ';' (rule 2)
{- empty -} ';' decl ';' ';' decl (rule 1)
{- empty -} ';' decl ';' ';' decl ';' (rule 2)
(如果没有前导分号,则执行规则 3。)
此语法有两个优点:首先,它只需要一个单一状态,这减少了解析器的大小;其次,它是左递归的,这意味着 LALR 解析器(如 Happy)可以在恒定的堆栈空间中解析它。
这段代码很长时间以来运行得很好,但当我们向 GHC 添加注释时,它最终变得复杂起来。注释是一种跟踪源代码中所有关键字/标点符号/空白位置的功能,因此我们可以逐字节地从抽象语法树重建源代码(通常情况下,格式信息在抽象语法中丢失)。有了注释,我们需要保存关于每个分号的信息;出于我不太理解的原因,我们花了很大力气将每个分号与前面的声明关联起来(前导分号传播到封闭元素)。
这导致了一些非常恶心的解析器代码:
importdecls :: { ([AddAnn],[LImportDecl RdrName]) }
: importdecls ';' importdecl
{% if null (snd $1)
then return (mj AnnSemi $2:fst $1,$3 : snd $1)
else do
{ addAnnotation (gl $ head $ snd $1)
AnnSemi (gl $2)
; return (fst $1,$3 : snd $1) } }
| importdecls ';' {% if null (snd $1)
then return ((mj AnnSemi $2:fst $1),snd $1)
else do
{ addAnnotation (gl $ head $ snd $1)
AnnSemi (gl $2)
; return $1} }
| importdecl { ([],[$1]) }
| {- empty -} { ([],[]) }
你能说出这段代码做了什么吗?我花了一段时间才明白代码在做什么:空测试是为了检查是否有前面的元素可以附加分号注释:如果没有,则将分号传播到顶层。
问题的关键在于,一旦添加了注释,语法就不再与语法树的逻辑结构匹配了。这很糟糕。让我们让它们匹配起来。以下是一些约束条件:
-
前导分号与封闭的 AST 元素相关联。因此,我们希望在开始时解析它们一次,然后在递归规则中不再处理它们。称解析零个或多个分号的规则为
semis
:semis : semis ';' | {- empty -}
-
如果有重复的分号,我们希望一次解析它们全部,然后将它们与前面的声明关联起来。因此,我们还需要一条规则来解析一个或多个分号,我们将其称为
semis1
;然后当我们解析单个声明时,我们想将其解析为decl semis1
:semis1 : semis1 ';' | ';'
然后,我们可以以下列方式建立我们的解析器:
-- Possibly empty decls with mandatory trailing semicolons
decls_semi : decls_semi decl semis1
| {- empty -}
-- Non-empty decls with no trailing semicolons
decls : decls_semi decl
-- Possibly empty decls with optional trailing semicolons
top1 : decls_semi
| decls
-- Possibly empty decls with optional leading/trailing semicolons
top : semi top1
我们特别注意不引入任何移位-归约冲突。实际上,如何做到这一点有点不明显,因为在 Haskell 源文件中,我们需要解析一系列导入声明(importdecl
),然后是一系列顶层声明(topdecl
)。在不引入移位-归约冲突的情况下定义这两个列表的语法有些困难,但似乎这样做是有效的:
top : importdecls_semi topdecls_semi
| importdecls_semi topdecls
| importdecls
看起来很简单,但有很多看似合理的替代方案,这些方案会引入移位/归约冲突。这里有一个重要的元教训,就是在尝试像这样做某事时,最好先在一个较小的语法上进行实验,这样重新检查是即时的(happy 花费了相当长的时间来处理所有的 GHC,这使得编辑-重新编译周期有点痛苦。)
我很想知道是否有更简单的方法来做到这一点,或者是否我犯了错误并改变了我接受的语言集。在评论中告诉我。我附上了一个简单的 Happy 语法供你玩耍(使用happy filename.y; ghc --make filename.hs
构建)。
{
module Main where
import Data.Char
}
%name parse
%expect 0
%tokentype { Token }
%error { parseError }
%token
import { TokenImport }
decl { TokenDecl }
';' { TokenSemi }
%%
top : semis top1 { $2 }
top1 : importdecls_semi topdecls_semi { (reverse $1, reverse $2) }
| importdecls_semi topdecls { (reverse $1, reverse $2) }
| importdecls { (reverse $1, []) }
id_semi : importdecl semis1 { $1 }
importdecls
: importdecls_semi importdecl { $2:$1 }
importdecls_semi
: importdecls_semi id_semi { $2:$1 }
| {- empty -} { [] }
topdecls
: topdecls_semi topdecl { $2:$1 }
topdecls_semi
: topdecls_semi topdecl semis1 { $2:$1 }
| {- empty -} { [] }
semis : semis ';' { () }
| {- empty -} { () }
semis1 : semis1 ';' { () }
| ';' { () }
importdecl
: import { "import" }
topdecl : decl { "decl" }
{
parseError :: [Token] -> a
parseError p = error ("Parse error: " ++ show p)
data Token
= TokenImport
| TokenDecl
| TokenSemi
deriving Show
lexer :: String -> [Token]
lexer [] = []
lexer (c:cs)
| isSpace c = lexer cs
| isAlpha c = lexVar (c:cs)
lexer (';':cs) = TokenSemi : lexer cs
lexVar cs =
case span isAlpha cs of
("import",rest) -> TokenImport : lexer rest
("decl",rest) -> TokenDecl : lexer rest
main = print . parse . lexer $ "import;;import;;decl"
}
让我们玩一个游戏:ezyang 的博客
曾经想过哈斯克尔人是如何神奇地通过查看函数类型签名就能够推断出函数实现的吗?现在,你也可以学会这种能力。让我们来玩一个游戏。
你是一个发明家,以能够制造将一种东西转化为另一种东西的机器而闻名于世。你是一个提议者。
但是有很多人会怀疑你发明这些东西的能力。他们就是验证者。
我们玩的游戏如下。你作为提议者,声明某种你知道如何实现的神奇机器,比如 (a -> b) -> a -> b
(这表示给定一个将 A 转换为 B 的机器和一个 A,它可以创建一个 B)。验证者对你是否能够创造出这样的机器表示怀疑,但作为一个公正的怀疑论者,提供给你你机器的输入(假设),希望你能够达到目标。
作为提议者,你可以将验证者提供给你的输入和机器应用在一起。
但这并不是很有趣。有时,在验证者给你一些机器后,你想提出另一个提案。通常,这是因为其中一个机器接受一个你没有的机器,但你也知道如何制造。
验证者有责任为这个新提案提供更多假设,但这些假设被放置在抽象的云层中。
你可以使用验证者之前提供的假设(在抽象的云层下面),
但一旦你完成了提案,所有新的假设都会消失。你剩下的只是一台闪亮的新机器(你明显希望传递给另一台机器),可以用于原始目标。
这些就是我们现在所需的所有规则。(它们构成了你在建设性逻辑中所能做的最有用的子集。)
让我们来玩一个游戏。
我们的验证者为我们提供了玩这个游戏所需的机器。我们的目标是 r
。
这可是一大堆机器,看起来我们无法运行任何一台。我们无法从头开始制造一个 a
来运行底下的那个,所以也许我们可以做一个 a -> r
。(看起来像是我凭空提出了这个建议,但如果你仔细看,这是在这种情况下唯一可能有效的选择。)让我们为 a -> r
提出一个新建议。
我们这个子提案的新目标也是 r
,但与我们原来的情况不同,我们可以利用额外的成分 a
来创造 r
:只需取两台原始的机器和新提供的 a
。哇,一个 r
出来啦!
这样就解除了抽象的云层,留下了一个闪亮的新 a -> r
,可以传递给剩下的机器,并且实现原始的目标。
让我们给这些机器起些名字。我会为你挑一些富有启发性的名字。
哦,嘿,你刚刚为延续单子实现了绑定。
这是逐步的转换步骤:
m a -> (a -> m b) -> m b
Cont r a -> (a -> Cont r b) -> Cont r b
((a -> r) -> r) -> (a -> ((b -> r) -> r)) -> ((b -> r) -> r)
((a -> r) -> r) -> (a -> (b -> r) -> r) -> (b -> r) -> r
最后一步可能是最微妙的,但是可以完成,因为箭头右关联。
作为练习,执行return :: a -> (a -> r) -> r
(等等,这看起来有点熟悉……),fmap :: (a -> b) -> ((a -> r) -> r) -> (b -> r) -> r
和callCC :: ((a -> (b -> r) -> r) -> (a -> r) -> r) -> (a -> r) -> r
(重要提示:第一个参数里是b
,不是a
!)。
这个展示是直觉逻辑的游戏语义描述,尽管我省略了否定和量词的处理,这些比延续单子更为高级,在这种情况下至少是这样。
让我们谈谈 PyTorch 调度器:ezyang's 博客
来源:
blog.ezyang.com/2020/09/lets-talk-about-the-pytorch-dispatcher/
如果这是你第一次了解 PyTorch 内部工作原理,你可能想先看看我的PyTorch internals文章。在这篇文章中,我想谈谈 PyTorch 内部的一个特定部分:调度器。乍一看,调度器只是一个被美化的 if 语句:基于一些关于张量输入的信息,决定调用哪段代码。那么我们为什么要关注调度器呢?
嗯,在 PyTorch 中,让一个操作符起作用需要很多东西。当然,有实际工作的内核;但还有对反向模式自动微分的支持,例如使loss.backward()
工作的位。哦,如果你的代码在torch.jit.trace
下,你可以获得运行的所有操作的跟踪。我提到了如果你在vmap
调用内部运行这些操作,操作符的批处理行为会有所不同吗?有很多不同的方式来解释 PyTorch 操作符的不同,如果我们试图在一个名为add
的单个函数内处理它们所有,我们的实现代码很快就会变得难以维护。调度器不仅仅是一个 if 语句:它是我们在 PyTorch 内部结构化代码的一个非常重要的抽象...而且它必须在不太降低 PyTorch 性能的情况下做到这一点。
在本文末尾,我们的目标是理解这幅图中的所有不同部分是如何组合在一起的。本文将分为三个部分进行。
首先,我们将讨论调度器本身。什么是调度器,它如何决定调用哪个内核?其次,我们将讨论操作符注册 API,这是我们将内核注册到调度器的接口。最后,我们将讨论装箱和解箱,这是调度器中的一个横切特性,让您可以一次编写代码,然后使其在所有内核上工作。
什么是调度器?
好的,那么什么是调度器?对于每个操作符,调度器维护一个函数指针表,该表为每个调度键提供实现,这些键大致对应于 PyTorch 中的一些横切关注点。在上面的图表中,您可以看到该表中有针对后端(CPU、CUDA、XLA)以及像 autograd 和追踪等更高级别概念的调度条目。调度器的工作是根据输入张量和其他一些内容(稍后详述)计算调度键,然后对表中指向的函数进行间接跳转。
如果你熟悉 C++ 的话,你可能会注意到函数指针表在某种程度上和 C++ 中的虚函数表很相似。在 C++ 中,对象的虚方法是通过将每个对象关联到一个虚函数表的指针来实现的,该表包含了该对象的每个虚方法的具体实现。在 PyTorch 中,我们本质上重新实现了虚函数表,但存在一些区别:
-
分派表是按操作员分配的,而虚函数表是按类分配的。这意味着我们可以通过简单地分配新的分派表来扩展支持的操作员集合,这与常规对象不同,常规对象可以从类扩展,但不能轻松添加虚方法。与正常的面向对象系统不同,在 PyTorch 中,大部分的可扩展性在于定义新操作员(而不是新子类),因此这种权衡是有道理的。分派键不是公开可扩展的,我们通常期望希望分配新分派键的扩展,向 PyTorch 核心提交补丁以添加它们的分派键。
-
关于这一点,在下一张幻灯片中会有更多信息,但我们计算分派键的过程考虑了操作员的所有参数(多分派)以及线程本地状态(TLS)。这与虚函数表不同,后者只考虑第一个对象(
this
)的内容。 -
最后,分派程序支持封装和解封装作为操作员调用约定的一部分。关于这一点,在演讲的最后部分会有更多解释!
有趣的历史注解:我们曾经使用虚方法来实现动态分派,当我们意识到我们需要更多功能而虚函数表无法提供时,我们重新实现了它们。
那么我们究竟如何计算用于索引到分派表的分派键呢?我们用于计算要使用的分派键的基本抽象是分派键集,它是对分派键的位集合。总体概念是,我们从各种来源联合分派键集(有时排除某些分派键),得到最终的分派键集。然后,我们选择集合中的第一个分派键(分派键按某种优先级隐式排序),这就是我们应该分派到的地方。这些来源是什么?
-
每个张量输入都会贡献一个分派键集,其中包含了张量上的所有分派键(直观上,这些分派键可能是诸如 CPU 的内容,告诉我们相关张量是 CPU 张量,并应由分派表上的 CPU 处理器处理)
-
我们还有一个本地包含集,用于“模态”功能,例如追踪,它与任何张量无关联,而是某种用户可以在某些范围内开启和关闭的线程本地模式。
-
最后,我们有一个全局集合,其中包含始终考虑的分派键。(自撰写本幻灯片以来,Autograd 已经从全局集合移至张量。然而,系统的高层结构并未改变。)
还有一个本地排除集合,用于在调度中排除调度键。一个常见模式是某些处理程序处理一个调度键,然后通过本地排除集合将自身屏蔽,这样我们就不会尝试在稍后重新处理此调度键。
让我们通过一些示例来逐步了解调度键的演变。
(警告:此描述对于 PyTorch 主版本已过时。Autograd 不再全局存在,而是存在于张量上。其余一切如前所述。)
调度机制运行的最典型例子是它如何处理自动求导。从顶部到底部阅读图表。在最顶部,Autograd 位于全局集合中,并且本地排除集为空。当我们进行调度时,我们发现自动求导是最高优先级的键(比 CPU 优先级更高),我们将操作符的调度分派给自动求导处理程序。在自动求导处理程序内部,我们进行一些自动求导操作,但更重要的是,我们创建 RAII 保护AutoNonVariableTypeMode
,将 Autograd 添加到本地排除集合中,防止在此操作符内部的所有操作中处理自动求导。当我们重新调度时,我们现在跳过自动求导键(因为它被排除),并将操作分派给下一个调度键,例如 CPU。由于在调用树的其余部分维护本地 TLS,所有后续的调度也都会绕过自动求导。最后,在函数结束时,RAII 保护从本地排除集合中移除 Autograd,因此后续的操作符调用再次触发自动求导处理程序。
另一个类似的例子是追踪,它类似于自动求导,当我们进入追踪处理程序时,会使用ExcludeDispatchKeyGuard
来禁用嵌套调用的追踪。然而,它与自动求导的不同之处在于追踪如何最初被触发:追踪是通过将调度键添加到本地包含集合来切换的,当你启用追踪时(使用IncludeDispatchKeyGuard
),而不是像自动求导那样使用全局调度键(更新:现在是张量上的调度键)。
最后一个例子是BackendSelect键,它与普通键的操作有些不同。BackendSelect 解决的问题是有时,默认的调度键集合计算算法不知道如何确定正确的调度键应该是什么。这种情况的一个显著案例是工厂函数,它们没有任何张量参数(因此,天真地说,不会分配到任何东西)。BackendSelect 在全局调度键集中,但仅为少数操作员注册(对于其余操作员,它是一个回退键)。BackendSelect 处理程序检查参数并决定最终的调度键应该是什么,然后直接分派到该键,绕过调度键计算。
该幻灯片总结了在 PyTorch 中分发某些操作时处理的一些最常见的处理程序序列。大多数情况下,它是 autograd,然后是后端(如果是工厂函数,则在中间有一个后端选择)。对于 XLA,还有一个 XLAPreAutograd 键(更新:此键现在简称为 AutogradXLA),它可以用来覆盖 Autograd 键的行为。当然,如果你同时打开 PyTorch 中的每个功能,你可能会停在很多处理程序上。请注意,处理程序处理的顺序很重要,因为处理程序不一定是可交换的。
操作符注册
因此,我们谈论了如何决定调用调度表中的函数指针,但这些指针首先如何进入调度表呢?这是通过操作符注册 API。如果你以前从未见过这个 API,请查看 C++ 中的调度器 教程,它在高层次上描述了 API 的工作原理。在本节中,我们将更详细地探讨注册 API 如何精确映射到调度表。下面,您可以看到与操作符注册 API 交互的三种主要方式:您定义操作符的模式,然后在调度键上注册实现;最后,还有一种 fallback
方法,您可以使用它来为所有操作符在某些调度键下定义一个处理程序。
为了可视化这些注册操作符的影响,让我们想象所有操作符的调度表集体形成一个网格,如下所示:
在一个轴上,我们有 PyTorch 支持的每个操作符。在另一个轴上,我们有系统中支持的每个调度键。操作符注册的行为涉及在这两个轴下填充实现的单元格。
当我们为特定调度键上的单个操作符注册一个内核时,我们会填充一个单元格(下面显示为蓝色):
当你将一个内核注册为操作符中所有调度键的“通用”内核时,你会用一个内核填充操作符的整行(下面显示为红色)。顺便说一句,如果这看起来像是一个奇怪的想法,那是因为确实如此!我们正在努力取消这种能力,而是更倾向于为一部分键填充更具体的值。
当您将一个内核注册为单个调度键的后备内核时,您会填充该调度键的列(绿色)。
这些注册有一个优先顺序:精确的内核注册具有最高优先级,并且通用内核优先于后备。
包装和取消包装
我想在这篇文章的最后部分谈论我们调度器中的包装和取消包装功能,这些功能在启用后端后备时非常重要。当你是一个程序设计语言设计者时,你必须做一个经典的权衡决策,即决定是否使用数据的包装或非包装表示:
装箱或同构表示是一种数据表示,其中系统中每种类型的对象具有相同的布局。通常,这意味着你有一些表示,其中包含描述所讨论对象的头部,然后是一些常规载荷。同构表示在代码中很容易处理:因为你总是可以假设数据有某种常规布局,所以可以编写可以在任何类型数据上多态工作的函数(例如,在 Java 中接受任意对象的函数)。大多数垃圾收集语言都有堆对象的某种装箱表示,因为垃圾收集器需要能够处理任何类型的堆对象。
相比之下,未装箱或异构表示允许对象根据问题的数据有不同的布局。这比同构表示更有效,因为每个对象可以根据需要的任务调整其内部表示。然而,缺点是我们不能再轻松地编写单个函数,使其在许多类型的对象上多态工作。在 C++中,通过使用模板来解决这个问题:如果需要一个函数能够处理多种类型,C++编译器会为每种使用的类型专门创建函数的新副本。
默认情况下,C++采用异构布局,但我们通过 IValue 结构(解释器值的缩写)在 PyTorch 中实现了同构布局,该结构实现了一个装箱表示,我们可以在解释器中使用。一个 IValue 是一个两个字长的结构,由一个载荷字(通常是指针,但也可以是直接打包到字段中的整数或浮点数)和一个标签字组成,告诉我们 IValue 是什么类型的值。
这意味着在 PyTorch 中函数有两种调用约定:通常的、C++的、未装箱约定,以及使用堆栈上的 IValues 的装箱约定。调用(来自最终用户)可以来自未装箱 API(直接 C++调用)或装箱 API(来自 JIT 解释器);同样,内核可以作为直接的 C++函数(未装箱约定)实现,也可以作为装箱回退(由于它们在所有操作符上是多态的,因此必须装箱)实现。
如果我从装箱 API 调用到装箱回退,很容易看出如何将这两个组件连接在一起...
...但是我如何从未装箱 API 到装箱回退?
我们需要某种适配器来将未装箱的输入转换为 IValues,以便可以通过装箱的调用约定传递它们。这是通过装箱适配器完成的,该适配器是通过 C++模板自动生成的,基于外部 API 中的未装箱 C++类型。
还有一个反向的问题,即如果我们有来自装箱 API 的输入,并且需要调用未装箱内核,该怎么办。类似地,我们有一个拆箱适配器,负责执行这种转换。与装箱适配器不同的是,这个适配器应用于内核本身,因为 C++模板只能在未装箱类型静态可用的地方工作(在装箱 API 站点,这些类型是未知的,因此你实际上无法实现这个。)请注意,我们始终保留未装箱 API,以便如果用户从未装箱 API 调用,我们可以直接快速通往未装箱内核。
所以这就是整体上看待装箱和拆箱的方式:
装箱和拆箱是实现装箱回退的关键特性:没有它们,我们无法让人们编写可以在任何地方运行的单个内核(事实上,在过去,人们会编写代码生成器来为每个函数生成重复的内核)。通过基于模板的装箱和拆箱,您可以编写一个单一的装箱内核,然后使其适用于操作符,即使这些操作符是从库外定义的也是如此。
结论
这就是 PyTorch 调度程序的要点!调度程序仍在不断地进行工作;例如,Ailing Zhang 最近重新设计了如何处理自动求导调度键,这意味着我们实际上不再具有单一的自动求导键,而是为 AutogradCPU/AutogradCUDA 等分拆了自动求导键。我们通常对改进为调度程序注册内核的用户体验感兴趣。如果您有任何问题或意见,请告诉我们!
Little’s law : ezyang’s blog
Little’s law
从在世博会排队的短暂思考中得到的一个见解:Little’s law 是一个引人注目的结果,它关联着排队中的人数、人们加入排队的速率以及在队列中等待的时间。似乎可以轻松地应用到主题公园中最普遍的特征之一:等待队列。与那些不可靠的方法不同,例如给游客标记来测试他们在排队某段路程上花费的时间,然后从那里直观地估计等待时间,安装两个门来计数进入游客和离开游客将是一件简单的事情,通过这些数据可以基于平滑的队列大小和到达率的实时平均值推导出一个瞬时的“队列等待时间”数字。电子设备的额外好处是,你可以轻松地将其传送到公园各处的信息板上!
我想到一个问题,我没有任何证据表明主题公园没有已经在使用这种技术,除了它们发布的等待时间极其不准确之外。
邮箱:Sup 初学者的建议:ezyang 的博客
来源:
blog.ezyang.com/2011/04/mailbox-advice-for-a-sup-first-timer/
邮箱:Sup 初学者的建议
邮件箱是什么? 这是我从我的邮箱中选择的一些有趣的电子邮件对话,当我懒得写原创内容时可以使用它们。我从 Matt Might 那里得到了这个想法,他在一篇关于低成本学术博客建议的文章中提供了一系列精彩的建议。
From: Brent Yorgey
我看到您是sup 邮件客户端的贡献者。至少我认为是您,我怀疑世界上没有太多 Edward Z. Yang。=)我正在考虑从 mutt 切换。您仍在使用 sup 吗?有什么想法/鼓励/警告可以分享吗?
To: Brent Yorgey
是的!我仍然在使用 Sup,并且我曾在过去略微提到它,这基本上仍然是我使用的设置。嗯,一些注意事项:
-
我想你想要从 Mutt 切换的原因可能是因为某些难以忍受的问题。我会警告你,没有哪个邮件客户端是完美的;我有很多朋友曾经使用过 Sup,但最终放弃了它。(我听说他们现在使用 GMail。)我坚持下来,部分原因是因为惯性,部分原因是因为其他人已经使用了 ezyang@gmail.com,但也部分原因是因为我认为这是值得的 😃
-
阅读电子邮件的方式中最大的变化之一是收件箱和未读邮件之间的区别,以及非收件箱和未读邮件之间的区别。特别是,虽然我有一套相当广泛的过滤器,可以给我从邮件列表中收到的邮件打标签,但在日常基础上,我会先检查我的收件箱中的重要内容,然后再检查我的非收件箱中的“有趣”内容,大多数邮件我只是浏览主题头。这意味着早上查看邮件大约需要十分钟时间。这是对我非常重要的事情。
-
你几乎肯定会想要设置 OfflineIMAP,因为从互联网下载一篇包含 80 封邮件的线程会非常快速变得令人厌烦。但是 Sup 不会通过这种方式传播回更改到您的邮箱(特别是,在 Sup 中的“已读”并不意味着您的收件箱中的“已读”),除非您使用邮件目录同步分支上的一些实验性代码。我已经使用它相当长时间了,但获取其他上游更改确实有点麻烦。
-
设置 Sup 会花一些时间;初始导入需要很长时间,调整也需要一点时间。确保你没有即将到期的截止日期。我还发现,如果不深入研究一些 Ruby 编程(虽然可能只是我自己 😃; 现在我的源代码库上有四个手工补丁,而且重新基于主分支不是轻松的事情。我实际上因为喜欢尝试各种技巧而陷入了一些麻烦,但当需要修复问题时也很方便(Sup 不工作会非常破坏)。不幸的是,Ruby 不是静态类型的。 😃
希望这些有帮助。如果需要帮助,请随时大声喊出来。
Maildir 同步 Sup:ezyang 的博客
Maildir 同步 Sup
在 Steven Hum 的推动下,我对我的 Sup 补丁集进行了一些最后的修饰,并将其“发布”到世界上(稍后会详细说明“发布”的含义)。该补丁集的总体主题是尽可能将 Sup 元数据与 Maildir 数据集成。具体来说:
-
它将 Damien Leone 的同步后补丁集与最新的 Sup 主线合并了起来。同步后补丁集可以将诸如“已读”或“已删除”等标志同步到 Maildir,然后可以使用 OfflineIMAP 将其传播回您的 IMAP 服务器。
-
此外,该补丁集具有同步任意标签的能力,具有一组简单的规则,根据消息的标签确定应将其移动到哪个文件夹。例如,收件箱和已归档的消息可以保存在单独的文件夹中,以便非 Sup 客户端可以有用地访问您关心的邮件。(相信我,这真的很棒。)这与一个实现快速远程消息移动的额外 OfflineIMAP 补丁配对使用。
-
它在 Maildir 上实现了 inotify,因此不再需要进行完整的目录扫描来检索新消息。轮询的瓶颈现在严格限制在 OfflineIMAP 上。
-
它实现了将已发送和草稿消息保存到 Maildir,以便它们显示在第三方客户端中。
-
最后,它还包含了一些我个人发现有用的杂项 bug 修复和额外的钩子。
由于我已经积极使用了一段时间,所以这个补丁集对您来说至少有很高的概率能够正常工作。Sup 有时会崩溃;如果这种情况不是可复现的或者不会导致数据丢失,我可能不会过于深入调查。我的一些补丁有点粗糙(特别是那些标记为 HACK
的:我已尝试在提交消息和代码注释中记录所有不规范的部分)。那么,这个版本的 Sup 有多少支持呢?嗯:
-
因此,我在所有我关心的用例和环境中都在使用这个补丁集,它将继续正常工作;
-
我可能不会修复我没有受影响的问题,绝对不会修复我无法复现的问题;
-
我不保证一个稳定的提交历史:我已经多次重新基于这个补丁集,并将继续这样做。
但有些早期的补丁是相当不具争议性的,我希望它们最终能够进入主线。您可以在这里获取代码:gitorious.org/~ezyang/sup/ezyang/commits/maildir-sync/
新钩子
sent-save-to
Configures where to save sent mail to. If this hook doesn't exist,
the global sent setting will be used (possibly defaulting to sup://sent)
Variables:
message: RMail::Message instance of the mail to send.
account: Account instance matching the From address
Return value:
Source to save mail to, nil to use default
compose-from
Selects a default address for the From: header of a new message
being composed.
Variables:
opts: a dictionary of ComposeMode options, including :from, :to,
:cc, :bcc, :subject, :refs and :replytos
Return value:
A Person to be used as the default for the From: header
draft-save-to
Selects a source to save a draft to.
Variables:
from_email: the email part of the From: line, or nil if empty
Return value:
A source to save the draft to.
标签同步
要使用此功能,在 config.yaml
中,您需要一个新选项 :maildir_labels
:
:maildir_labels:
:stanford: [[:inbox, 4], [null, 6]]
此选项的值是一个“账户”到“优先级列表”的字典。(账户标签 stanford
实际上并没有任何意义;它只是用于文档。)读取方法如下:
对于属于来源 4 或来源 6 的消息(请参阅
sources.yaml
),如果消息具有:inbox
标签,则将其移动到来源 4;否则将其移动到来源 6。
这将自动开始对您更改标签的任何新邮件进行处理。为了将其应用于旧邮件,您需要运行sup-sync-back-maildir
。如果您打算移动大量邮件,可能需要运行此版本的 OfflineIMAP:github.com/ezyang/offlineimap
有效管理外部指针:ezyang 的博客
来源:
blog.ezyang.com/2010/07/managing-foreign-pointers-effectively/
有效管理外部指针
Foreign.ForeignPtr是您可以向 C 库挥动的魔棒,使它们突然变得可垃圾回收。虽然事实并非如此简单,但它确实相当简单。以下是在使用 Haskell FFI 有效管理外部指针时的一些来自前线的快速提示:
-
尽早使用它们。一旦从外部导入函数传递给您一个您预期要释放的指针,您应该在做任何其他操作之前将其包装在 ForeignPtr 中:这一责任完全在低级绑定中。找到您必须作为
FunPtr
导入的函数。如果您正在使用 c2hs,请将您的指针声明为foreign
。 -
作为上述观点的例外,如果 C 库提供了多种释放指针的方式,则可能需要小心处理;一个示例是一个接受指针并销毁它的函数(可能不释放内存,而是重新使用它),并返回一个新的指针。如果将其包装在 ForeignPtr 中,当它被垃圾回收时,您将面临双重释放问题。如果这是主要操作模式,请考虑使用
ForeignPtr (Ptr a)
和自定义释放函数,该函数在外部 ForeignPtr 中释放内部指针后释放外部指针。如果在释放指针时没有逻辑连续性,可以使用StablePtr
来保持您的ForeignPtr
永远不会被垃圾回收,但这实际上是一种内存泄漏。一旦是外部指针,就永远是外部指针,因此如果不能承诺直到垃圾回收分手,请不要使用它们。 -
您可以将外部指针作为不透明引用传递给用户代码,这可能导致新类型的普及。定义
withOpaqueType
非常有用,这样您就不必每次自己的代码窥视黑匣子时都进行模式匹配,然后使用withForeignPtr
。 -
要小心使用库的
free
等效方法。尽管在由 libc 统一的系统上,您可能可以通过对获取的int*
数组使用free
来摆脱问题(因为大多数库在内部使用malloc
),但这段代码不具备可移植性,如果尝试在 Windows 上编译,则几乎肯定会崩溃。当然,复杂的结构可能需要更复杂的释放策略。(实际上,这是我在测试自己的库在 Windows 上时遇到的唯一错误,直到我记起雷蒙德的博客文章才感到非常沮丧。) -
如果你有指向由另一个指针进行内存管理的数据的指针,而这另一个指针位于 ForeignPtr 内部,则必须极其小心以防止在你还有这些指针的情况下释放 ForeignPtr。有几种方法可以解决这个问题:
-
在具有秩-2 类型的 Monad 中捕获子指针(参见
ST
Monad 的示例),并要求该 Monad 在withForeignPtr
内运行,以确保主指针在子指针存在时保持活动状态,并保证子指针不会泄漏出上下文。 -
使用
Foreign.ForeignPtr.Concurrent
可以进行有趣的事情,它允许你将 Haskell 代码用作最终器:参考计数和依赖跟踪(只要你的最终器能在主最终器运行后继续运行)。我觉得这种方式非常不理想,你能得到的保证并不总是很好。
-
-
如果不需要将指针释放到外界,最好别这么做!Simon Marlow 承认最终器可能会带来各种麻烦,如果你可以只给用户一个括号函数,你应该考虑这种方式。这样做可以使你的内存使用和对象生命周期更加可预测。
管理 Ur/Web 中的服务器/客户端分离:ezyang 的博客
来源:
blog.ezyang.com/2012/07/managing-the-server-client-split-in-ur-web/
Web 应用程序开发的圣杯是一种单一语言,既可以在服务器端运行,也可以在客户端运行。其原因是多方面的:单一语言促进了组件的重用,不再需要在两种语言中重新实现,并且允许服务器和客户端之间进行更轻松的通信。明确努力处理服务器和客户端的 Web 框架包括Meteor、Ur/Web、Opa和Google Web Toolkit。
任何希望构建这样一个系统的人面临的最大实施困难之一是存在多个运行时:服务器运行时和浏览器运行时,每个都有相应不同的原语和可用的 API。此外,我们可能希望某些代码仅存在于服务器上,而不发送到客户端。当某个语言特性可以在两个运行时上实现时,我们保持客户端和服务器不可区分的假象;当无法时,这种假象就破灭了。
因此,为了支持在这种集成语言中运行时特定的 FFI 调用,必须回答以下问题:
-
代码何时发送到客户端,何时保留在服务器上?这些信息必须对用户进行公开(而不是作为“实现细节”保留)。
-
如何强制在服务器上执行?
-
如何强制在客户端执行?
在这篇博文中,我将讨论Ur/Web如何解决这些问题。答案相当简单(如果有幸可以推广到其他类似系统),但如果你把编译器视为黑匣子,那么找到它们就相当困难。
1. 客户端/服务器分割
解决客户端/服务器分割问题的一个明显解决方案是标记入口点(例如主函数或 onClick 处理程序)为从服务器(main)或客户端(onClick)开始,然后进行可达性分析以标记所有其他函数。因此,在下面的 Ur/Web 代码中,txn : transaction unit
将在这里在服务器上执行:
fun main () =
txn;
return <xml><body>Done!</body></xml>
在此处它将在客户端执行:
fun main () =
return <xml><body><a onclick={txn}>Click me!</a></body></xml>
当给定像这样的片段时:
fun foo body =
r <- txn body;
return <xml>{r}</xml>
无法知道txn
是否在客户端或服务器端需要执行,除非分析所有调用方并检查它们是客户端还是服务器端。像这样的情况对于强制服务器端或客户端行为至关重要。
2. 强制服务器端
假设我们希望强制txn
在服务器端执行。如果我们已经在服务器上,那就没有更多的事情要做了。但是,如果我们在客户端,我们需要向服务器发起 RPC 调用。在 Ur/Web 中,这很容易实现:
fun fooClient body =
r <- rpc (txn body);
return <xml>{r}</xml>
然而,由于rpc
在 Ur/Web 中仅限于客户端功能,我们无法再将此功能用于服务器端计算。这种选择的一个后果是,它迫使我们明确何时发生 RPC,这对于理解和安全性来说是个好消息。
3. 强制客户端执行
假设我们希望强制txn
在客户端执行。这很棘手:如果我们已经在客户端,我们可以像往常一样继续,但如果我们在服务器端执行,在客户端执行某些代码是什么意思呢?
一种解释是这样的:因为我们正在构建一些要显示给客户端的 HTML,当客户端实际显示 HTML 时,应该运行txn
。最近 Ur/Web 添加了active
标签,实现了这种效果:
fun fooServer body =
return <xml><active code={txn body} /></xml>
code
属性的行为与onclick
和其他类似属性类似,因为它定义了一个入口点,当在浏览器中显示时自动运行。它仍然是一个事件处理程序,因为如果有人调用了fooServer
,但随后没有使用 HTML,txn
就不会被调用:active
可以被视为一种延迟执行。
如果我们真的希望客户端立即执行某些代码,我们最好的选择是将active
标签插入到与活动dyn
元素挂钩的source
中:
fun fooServer body source =
set source <xml><active code={txn body} /></xml>;
return <xml>???</xml>
但在这种情况下,实际上不可能询问客户端计算的结果(服务器不允许阻塞!)这种移动代码传递方法甚至可以异步完成,使用通道:
fun fooServerAsync body channel =
send channel <xml><active code={txn body} /></xml>;
return <xml>???</xml>
4. 与优化器的交互
HTML 事件处理程序中的代码(例如onclick={...}
和active code={...}
)可以有自由变量,这些自由变量绑定到它们的词法作用域中的变量,这些变量可能是从服务器计算的。因此,在这种情况下,您可以期望foo: int -> xbody
在服务器上执行:
fun main n =
let val x = foo n
in return <xml><body><active code={txn; return x} /></body></xml>
然而,Ur/Web 的优化器太聪明了:由于foo
是纯的,因此引用透明,它总是可以安全地内联(特别是当只有一个使用点时):
fun main n =
return <xml><body><active code={txn; return (foo n)} /></body></xml>
这样写,清楚地表明foo
是从客户端运行的。因此,如果foo
是一个在客户端未实现的服务器 FFI 调用,那么一个无辜的转换就可能破坏您的代码。
令人困扰的结论是变量替换可能使有效的程序无效。当然,在急切评估、不纯的语言中,变量替换是无效的。但我们可能期望在像 Ur/Web 这样的纯语言中是真的。无论如何,我们可以通过在我们的urp
文件中将foo
标记为benignEffectful
来教会 Ur/Web 不内联。
5. 结论
总的来说,在编写 Ur/Web 应用程序时,以下是一些有用的指导原则:
-
始终在你的
urp
文件中用serverOnly
和clientOnly
标记仅限服务器或仅限客户端的标识符。Ur/Web 通常会适当处理单向 FFI 函数,但如果你的代码利用了只在一侧实现的语言特性(例如服务器端的闭包),请确保适当标记这些函数。 -
使用
rpc
从客户端到服务器传输,使用active
从服务器到客户端传输。由于“rpc
必须引用命名函数”的不变性,Ur/Web 应用程序的一般结构将是服务器代码块内部嵌入的客户端代码。 -
如果你对生成包含纯服务器计算数据的客户端代码感兴趣,请确保计算该数据的函数被标记为
benignEffectful
。 -
一般来说,不必担心服务器/客户端分离!Ur/Web 会在需要移动东西时提醒你,但大部分情况下,事情应该顺利进行。
最后关于在共享服务器/客户端模型中的安全性问题:Ur/Web 如何确保输入验证最终在服务器端而不是客户端?这相当简单:程序中唯一关心输入验证的部分是涉及持久数据的部分,而所有这些函数都是仅限服务器的。因此,任何传递给这些函数的用户数据必定通过顶级页面处理程序或rpc
,这使得确保验证在“正确”的一侧非常简单。如果你使用的是正确构造的数据结构,那么验证就会自动完成!
多值逻辑与底部:ezyang 的博客
多值逻辑与底部
我翻阅了 Graham Priest 的《非经典逻辑导论》,其中的多值逻辑部分引起了我的注意。多值逻辑是具有非传统真值true和false之外的更多真值的逻辑。克里尼三值逻辑(强)建立了以下真值表,包括 0、1 和 x(被认为是既非真也非假的某个值):
NOT
1 0
x x
0 1
AND
1 x 0
1 1 x 0
x x x 0
0 0 0 0
OR
1 x 0
1 1 1 1
x 1 x x
0 1 x 0
IMPLICATION
1 x 0
1 1 x 0
x 1 x x
0 1 1 1
我一直认为多值逻辑有点像是为了处理自指悖论而“应急”的方式,但实际上,克里尼通过思考部分函数在未定义值上的应用来发明他的逻辑:一种符号失效的情况。因此,这些真值表对应于表示语义预测的并行或与和运算符并不令人意外。
读者被邀请考虑是否可以将此逻辑用于柯里-霍华德风格的对应;特别是,在 K3 中排中律是无效的。
使用 get 和 set 进行数据编组:ezyang’s 博客
这是六部分介绍 c2hs 的第五部分。今天,我们解释如何对 C 结构体进行数据编组。
重要提示。 struct foo
和foo
之间有区别;c2hs 仅认为后者是类型,因此您可能需要添加一些形式为typedef struct foo foo
的 typedef 以便 c2hs 识别这些结构体。
Get. Haskell FFI 对 C 结构体一无所知;Haskell 读取结构体成员的想法是查看某个内存位置的字节偏移量,这是您手动计算的。这很可怕,而hsc2hs
有#peek
可以让您摆脱这种非可移植的枯燥工作。c2hs
更简单:您可以指定{#get StructName->struct_field #}
,c2hs 将其替换为正确类型的 lambda,执行正确类型的 peek 操作:(\ptr -> do {peekByteOff ptr 12 ::IO CInt})
(在 IO 单子中!)请注意以下陷阱:
-
您需要手动将生成的原始 C 类型转换为更友好的 Haskell 类型,
-
表达式的左侧是类型或结构名,而不是包含您想要 peek 的指针/结构的 Haskell 变量。通常这将放在 lambda 的右侧。
get
指令实际上比仅仅结构体访问更通用:它可以解引用指针(*StructName
)或访问成员而不解引用(StructName.struct_field
)。
Set. 与get
相反,set
允许您将值填充到任意内存位置。与get
不同,传入的值需要是指针(语法使用点号)。{#set StructName.struct_field #}
扩展为(\ptr val -> do {pokeByteOff ptr 12 (val::CInt)})
;指针是第一个参数,值是第二个。您还需要手动转换输入值。
定义可存储性。 如果您不是在不透明指针中单独获取和设置结构体中的字段,创建Storable
实例是一个好方法。然而,由于get
和set
创建的所有 lambda 都在 IO 单子中,组合它们可能会稍微复杂一些。审慎使用单子提升和适用实例可以使代码变得更简单,但是:
data StructName = StructName
{ struct_field1'StructName :: Int
, struct_field2'StructName :: Int
}
instance Storable StructName where
sizeOf _ = {#sizeof StructName #}
alignment _ = 4
peek p = StructName
<$> liftM fromIntegral ({#get StructName->struct_field1 #} p)
<*> liftM fromIntegral ({#get StructName->struct_field2 #} p)
poke p x = do
{#set StructName.struct_field1 #} p (fromIntegral $ struct_field1'StructName x)
{#set StructName.struct_field2 #} p (fromIntegral $ struct_field2'StructName x)
StructName
中的奇怪命名约定是为了考虑不同结构体可能共享字段名,而 Haskell 字段名可能不行。
注意。 最近为alignment
指令增加了 c2hs 支持,用于计算 C 数据结构的对齐。然而,截至 0.6.12,这尚未对一般公众发布。
请求。 描述 c2hs 的论文陈述如下:“[将复合 C 值马歇尔化为 Haskell 值] 更普遍地有用;然而,我们通常并不真正希望将整个 C 结构体马歇尔化为 Haskell。” 不幸的是,当前的 c2hs 版本没有提供任何可选功能来减少编写“直接” Storable 实例的繁琐性,这将是非常可爱的。bindings-dsl 和 GreenCard 在这方面似乎表现更好。
下次见。 调用和乐趣:调用重置
最大匹配死锁解决方案:ezyang’s 博客
来源:
blog.ezyang.com/2010/07/maximum-matching-deadlock-solution/
最大匹配死锁解决方案
上周一,我介绍了一种计算最大加权匹配的并行算法,并指出在实际硬件上,一个天真的实现会导致死锁。
有几位读者正确指出,仅对节点按其最加权顶点排序一次是不够的:当一个节点成对出现并从未成对节点池中移除时,它可能会显著影响排序。建议使用优先队列来解决这个问题,这当然是一个很好的答案,尽管不是 Feo 最终采用的答案。
Feo 的解决方案。 为每个节点分配一个“正在处理位”。当一个节点试图读取其邻居的完整/空位并发现该位为空时,检查该节点是否正在被处理。如果没有,原子地将“正在处理位”设置为 1 并递归处理该节点。淘汰已计划但其节点已在处理中的线程。开销是每个节点一个位。
我认为这是一个特别优雅的解决方案,因为它展示了递归如何使工作能够轻松地分配给原本会处于空闲状态的线程。
测量、量化和还原:ezyang 的博客
来源:
blog.ezyang.com/2011/06/measurement-quantification-and-reduction/
今天我们继续讨论主题,“哲学科学对软件工程有何启示”,从物理科学哲学中选取了一些话题进行探讨。
测量与量化
量化是现代社会中根深蒂固的活动。我们生活在数字中,不管是温度读数、速度、智商点数、大学排名还是安全评级等等。其中一些是无争议的,但有些则完全不同,软件工程师必须始终小心处理他们所处理的数字,因为量化是一门非常棘手的业务。
科学哲学家可以从历史中寻找一些见解来解决这个难题,因为温度计并非总是一种无争议的生成数字的方法。尽管温度计本身在 16 世纪就已发明,但要建立起现代的温度测量标准却花费了几个世纪的时间。这是什么让它如此困难?早期的热力学实验者非常清楚,通过在各种固定点(冰点和沸点)测试其结果并相应地分级,可以校准温度计,而在某些时期内,这被认为足以校准温度计。
然而,液体的热膨胀并非各种液体都一致,像赫尔曼·博尔哈弗和丹尼尔·华氏这样的勇敢实验者发现,很多情况下,即使两个用同样方法校准过的温度计,它们的读数也可能不一致。他们如何确定哪一个温度计更准确,而不是再依赖于……另一个温度计?大多数涉及液体“粒子”及其力量性质的理论原则(当时)是无法证明的。
如果没有现代热力学的发明,最引人注目的案例可能是亨利·维克多·雷诺。作为一位杰出的实验家,雷诺试图通过系统地消除其工作中的所有理论假设来解决这个问题:比如比热、热量守恒等,这些对他来说都不重要。雷诺关心的是温度计的可比性:一个在不同情况下给出不同数值的仪器是不可信的。如果温度计对其中的酒精比例或者玻璃吹制方式敏感,那就不能认为它反映了现实。
面对不确定性和不确定的理论基础,甚至像可比性这样简单的标准在掌握局势时也很有用。人们不应低估这种技术的力量,部分原因是它能够在不假定任何任务的理论知识的情况下运行。
简化解释
泄漏抽象法则表明,所有试图隐藏系统低级细节的尝试在某种程度上都会失败。将其推广到极端情况,则类似于对计算机系统理解的还原方法:为了理解某个系统的工作原理,理解其下所有层次既是可取又是必要的。
当然,当我们说诸如“真正的男人用磁铁在硬盘上编程”之类的话时,我们对这种还原主义进行了嘲笑。仅仅通过阅读其基础上所有的汇编语言,一个人是不可能理解现代软件的。即使是在低级别编写的系统也有隐含的更高层次结构,使工程师能够忽略不相关的细节(当然,除非这些不相关的细节导致了 bug)。
这种情况非常迷人,因为在许多意义上它与科学中的还原主义辩论相反。对于软件来说,系统最终行为的许多方面可以从最低级别的细节中以演绎方式知道——我们只是知道这种复杂性对于人类来说太过巨大。科学则以相反的方向运作:科学家们在深入研究更基础的现象时寻求简化和统一的原则。生物学是应用化学,化学是应用物理学,物理学是应用量子力学等等。大多数科学家持有本体论还原主义的态度:我们与之互动的任何东西最终都可以被打碎成基本粒子。
但即使这种简化是可能的,这并不意味着我们在理论上可以实现这种简化。我们不同层次的理论甚至可能彼此矛盾(所谓的库恩不可比性),然而这些理论是近似的和有效的。那么,在科学中不断追求更基础的解释是否是值得的追求,或者像软件工程师可能认为的那样,只有在抽象层出现漏洞的情况下才是必要的呢?
后记。明天是我的最后一次考试,届时我们将恢复我们定期的 GHC 编程计划。
中世纪医学和计算机:ezyang 的博客
这篇文章有些挑衅性,并且它的印象(我不敢将它们提升到结论的层面)应该像麦当劳快乐餐中发现的盐一样保留。基本上,我正在阅读一些关于中世纪医学的文章,并被其中一些与计算机工程的相似之处所震撼,我尝试在下面描述。
计算机科学家和软件工程师之间的分隔。在那些研究医学的人和那些实际从事医学的人之间的分隔——这些称为物理学(一种源自物理或自然科学的名称)和实验医学——是非常明显的。存在相互不信任——彼特拉克写道,“我从不相信医生,也永远不会”(Porter 169)——这在一定程度上源于物理学和实验医学之间的社会分隔。物理学家可能从大学获得博士学位,他们研究了可能是最高三个学科之一的学科(其他两个是神学和法律),并且倾向于属于社会的上层阶层。事实上,实际的医学艺术并未被视为“值得研究”,尽管自然科学的研究是如此。(Cook 407)。
计算机科学家和软件工程师之间的分隔并没有在 16 世纪时那么严重,但确实存在明显的社会分隔(计算机科学家在学术界工作,软件工程师在工业界工作),以及这两个社区之间的沟通隔阂。在许多其他领域,要被考虑进入本专业工作,通常需要获得博士学位;而在这里,我们经常看到高中生开设软件公司(偶尔取得成功),如果编程 Reddit 是任何指示,对纯理论计算机科学家存在一定的厌恶。
纯计算机科学在现实世界中的不适用性。尽管在此期间,纯医学的研究备受推崇,但其理论和知识却大大脱离了实际。在 16 世纪初期,希波克拉底医学的四种体液仍被广泛认为是真理:患病就是体液不平衡,包括黑胆汁、黄胆汁、痰和血液,因此治愈方法是应用对立的体液,这解释了血让疗法的技术实践(故意让一个人出血)。甚至对人体基本运作原理的理解也很不透彻:直到威廉·哈维(William Harvey)及其《论心脏与血液的运动》(1628 年)提出心脏是一个泵的概念,挑战了早期的观点,即食物在器官中被调和,然后通过静脉、动脉和神经流向身体其他部位。(Cook 426)如果循环系统是正确的,那么其他器官的功能是什么?哈维的理论彻底颠覆了人体运作的现有理解,他的理论曾引起很大争议。
我对计算机科学有足够的信心,认为我们现有的大部分知识并不 fundamentally wrong。但我也认为,即使在中等规模的情况下,我们对计算的实际本质了解甚少,这是一个令人谦卑的事实。但我对计算机科学在处理大型系统方面的未来持乐观态度—更多内容请见最后章节。
测试而非正式方法。然而,知识的缺乏并没有阻止医生(与物理学有所不同)从事他们的医学实践。即使学术界也承认“中世纪实践”的重要性;这些手册从头到脚列出了各种疾病的症状和治疗方法。(Porter 172)观察(希波克拉底哲学)继续占主导地位:当被问及解剖时,托马斯·西德南(Thomas Sydenham)说道:“解剖学—植物学—废话!先生,我知道一位住在科文特花园的老妇人比你更懂植物学,至于解剖学,我的屠夫能够精心解剖一整块肉,现在,年轻人,这些全都是胡说八道;你必须去床边,只有在那里你才能学到疾病。”(Porter 229)
在没有令人信服的理论框架的情况下,经验主义占主导地位。获取知识的方法是制定实验、进行观察并据此行动。如果一段代码有问题,你会如何修复它?你会添加调试语句并观察输出,而不是构建形式化语义然后证明相关属性。如果后者成为首选方法的那一天,全世界的形式化方法从业者都会感到欢欣鼓舞。
没有银弹。 在缺乏可靠的医学理论的情况下,江湖医术在 18 世纪蓬勃发展,这个世纪通常被称为“江湖医术的黄金时代”(Porter 284)。不需要解释你的商品为什么有效:你只需要做一场精彩的表演(“先吸引人群,然后也许是一些牙齿,两者伴随鼓声和喇叭声”(Porter 285)),卖几十瓶你的疗法,然后转移到下一个城镇。这些“药物”声称可以治愈癌症,恢复青春等等。尽管一些江湖医师纯粹是江湖骗子,但其他人却真诚地相信他们治疗的功效,偶尔也会有江湖医法真正有效的情况发生。
我认为现代对应于江湖医术的是各种软件方法论。就像江湖药物一样,其中一些可能是有效的,但在没有科学解释的情况下,我们只能看戏,投资,看看它是否奏效。在我们的基础理论更为完善之前,我们不能抱太大希望。
未来。 现代医学科学最终得以拯救,尽管在多年不足的理论和江湖医术之后,它陷入了极大的声誉危机。这门学科的复杂性必须得到合法化,但这要等到一场强大的科学革命建立现代医疗实践的基础之后才会发生。
引用的作品:
-
Porter, Roy. 造福人类的最大好处。 Fontana Press: 1999.
-
Park, Katherine; Daston, Lorraine. 科学的剑桥历史:第 3 卷,早期现代科学。
Metro Maps of the News : ezyang’s blog
Metro Maps of the News
地铁地图是由Dafna Shahaf开发的复杂、相互依赖的故事线的视觉隐喻。达芙娜的论文涉及自动处理新闻文章语料库并提取涵盖整体空间的连贯叙述技术。在我们的最终CS448b项目中,我们选取了达芙娜生成的叙述之一,并创建了一个显示地图的系统。(最好在大屏幕上观看演示。)
我们只有足够的时间去完善查看者方面,但我们认为扩展这个框架以便构建地铁地图并不会太困难(以防您无法访问达芙娜的算法)。
这是与 Russell Chou 和 Jacob Jensen 的联合工作。
Surface Book 2:ezyang 的博客
Surface Book 2
长期阅读我的人可能知道,过去十年我一直在使用 ThinkPad X61T。在我的第二台机器的铰链出现问题后,我决定终于是时候换一台新的笔记本了。我对一款特定的型号情有独钟,这源自上次 Haskell 实现者工作坊上 Simon Peyton Jones 向我展示他的新笔记本:Microsoft Surface Book 2。它符合我对笔记本的主要要求:它是可转换为平板模式的笔记本,并配备数字笔。这支笔虽然不是 Wacom 品牌的,但有一个橡皮擦端,并可以磁性吸附在笔记本上(没有笔的外壳,但我认为对于现代硬件来说,这种限制是无法满足的)。此外,还有一个Linux 爱好者社区关注这款设备,这让我觉得我更有可能让 Linux 正常工作。所以几周前,我决定尝试一下,花了三千美元买了一台自己的 Surface Book 2。事实证明效果不错,但典型的 Linux 风格,没有少费一点功夫。
快速评估
The good:
-
我已经成功使所有“重要”功能正常工作。这包括 Xournal 与 XInput 笔以及休眠功能(尽管存在一些注意事项)。
-
对于其他随机功能的 Linux 支持令我惊喜不小:我成功安装了 CUDA 和驱动(用于 PyTorch 开发),能够裸机启动我的 Linux 分区以及在 Windows 的 VM 中启动,甚至可以在进入 Linux 时拆卸屏幕。
-
键盘很好用;虽然不及经典 Thinkpad 键盘那么好,但它有真正的功能键(不像我在工作中使用的 Macbook Pro)。
-
两个标准 USB 端口和一个 USB-C 端口意味着我在大多数情况下不需要转接头(不像我的 Macbook Pro,它只有 USB-C 端口)。
The bad:
-
(更新于 2019 年 3 月 19 日)暂停功能非常慢。尽管 jakeday 的 setup.sh 建议暂停功能不起作用,某种功能正在运行,如果我关闭笔记本盖子,笔记本会进入某种低功耗状态。但进入暂停状态的时间相当长,重新启动时间更长,你仍然需要点击过引导加载程序(这让我严重怀疑我们是否真的在暂停)。
-
当我把它放进背包时,笔记本有时会自动解除休眠状态。我目前的假设是电源按钮被按下了(不像大多数笔记本电脑,电源按钮位于屏幕顶部且无保护)。也许通过一些 ACPI 设置的调整可能会有所帮助,但我还没有仔细研究过。
-
这是一个高 DPI 屏幕。本质上这并没有问题(而且现在基本上买不到非高 DPI 的笔记本电脑了),但是任何不懂如何处理高 DPI 的软件(VMWare 和 Xournal,我在看你们)看起来都很糟糕。然而,Ubuntu Unity 对高 DPI 的支持自从我上次尝试以来已经好多了;如果我只使用终端和浏览器,一切看起来都还可以。
-
功能键被硬编码为切换 fn 锁定。这有点让人恼火,因为你必须记住它处于哪个设置,决定是否按住它来获取另一个切换。我也感到失去了专门的页面向上/向下键有些遗憾。
-
显然,NVIDIA GPU 由于热传感器问题自行降频(大概是因为风扇在主板上而不是 GPU 上,所以驱动程序认为风扇坏了并且进行了降频?模模糊糊的)。
-
扬声器…还行。不是很好,只是还行。
-
微软选择了一些定制的 Surface Book 2 充电器,有点可惜。
Linux 设置
我进行了最新的 Ubuntu LTS(18.04)的标准安装,与 Windows 双启动(1TB 硬盘真心好用!),然后安装了 jakeday 的 自定义 Linux 内核和驱动程序。 关于这个过程的一些说明:
-
我花了一段时间琢磨为什么我不能安装 Linux 双启动。一些搜索建议问题是因为 Windows 没有真正关机;它只是休眠了(为了快速启动)。我没能禁用这个功能,所以我只是在 Windows 内部调整了分区大小,然后在那个分区上安装了 Linux。
-
不要忘记为 Linux 分配一个专用的交换分区;如果没有它,你将无法休眠。
-
Surface Book 2 启用了安全启动。你必须按照 SIGNING.md 中的说明来获取已签名的内核。
-
生成带有签名的内核的一个后果是,如果你同时安装了未签名和签名的内核,
update-initramfs -u
将为你的 未签名 内核更新 initrd,这意味着你不会看到你的更改,除非你复制 initrd!这让我对下一步感到非常困惑… -
如果你想为你闪亮的 NVIDIA GPU 使用 NVIDIA 驱动程序,你需要拉黑 nouveau。网上有很多指导,但我可以亲自保证 remingtonlang 的指导。确保你在更新正确的 initrd;参见我上面的要点。修复了这个问题后,CUDA 安装程序的标准调用让我开始运行
nvidia-smi
。请注意,我手动使用了 这里的指南,因为我已经生成了一个私钥,所以看起来很蠢再生成另一个,因为 NVIDIA 的安装程序要求我这样做。 -
安装 NVIDIA 驱动程序后,你必须小心相反的问题:Xorg 决定在 NVIDIA 卡上进行所有渲染!当这种情况发生时的常见症状是 Linux 的鼠标输入非常卡顿。如果你有工作的
nvidia-smi
,你也可以看到 Xorg 在你的 GPU 上作为一个正在运行的进程。无论如何,这是不好的:你不想使用独立显卡来进行普通的桌面渲染;你需要集成的。我发现取消注释/etc/X11/xorg.conf.d
中的 Intel 配置示例可以解决这个问题:Section "Device" Identifier "Intel Graphics" Driver "intel" EndSection
但这在 VMWare 上并不太友好;后面会详细讨论。
-
声音在我升级到 Linux 5.0.1 之前无法正常工作(声音太小,或者右侧扬声器不工作)。
-
在我的 Xournal 分支上启用 XInput 后,它在我重新启动 Xournal 后才开始起作用。橡皮擦在开箱即用时就可以使用。
-
别忘了创建交换分区(Ubuntu 默认安装程序没有提示我创建交换分区,可能是因为我是作为双引导安装的);否则,休眠功能将无法使用。
-
有时候,从休眠状态唤醒后,网络无法正常工作。幸运的是,可以通过手动重新加载 WiFi 内核模块来解决这个问题:
modprobe mwifiex_pcie
和systemctl restart NetworkManager.service
。关于此问题的更多讨论。 -
有时候,从休眠/挂起状态唤醒时,我会看到一个大的温度计图标。重新启动后,它会消失,但我的休眠/挂起功能却消失了。令人困惑!我不知道为什么会这样发生。
通过虚拟机引导
生活的悲哀之处在于,Windows 平板的体验要比 Linux 的体验好得多——以至于许多人只会安装 Windows,然后从虚拟机(或 Windows Subsystem for Linux)中引导 Linux。对我来说这是行不通的:必须从裸金属引导 Linux 才能获得最佳的笔输入体验。不过,为什么不也让从运行在 Windows 上的 VMWare 引导 Linux 分区成为可能呢?这种设置是被VMWare 明确支持的,但实际上需要几天的折腾才能让它真正起作用。
-
首先,你需要 VMWare Workstation Pro 来配置一个能够访问原始磁盘的虚拟机(尽管生成的虚拟机映像可以在免费的 VMWare Player 中运行)。你可以注册获取 30 天的试用期来配置它,然后从此使用 Player,如果你喜欢的话。在设置磁盘时,VMWare 将提供原始磁盘作为选项;选择它并选择你的机器上的 Linux 分区。
-
设置这个系统的主要挑战在于,Surface Book 2 上的标准 Linux 安装没有传统的 Linux 引导分区;相反,它有一个 EFI 分区。最显著的是,这个分区在 Windows 启动时被永久挂载,因此你无法重新挂载它用于你的虚拟机。你的常规分区没有引导加载程序,这就是为什么当你启动虚拟机时,你被强制进入通过 PXE 进行网络启动的原因。我最终采取的解决方法是制作一个新的虚拟磁盘(支持 vmdk 格式),并在上面安装引导分区(你实际上不需要任何内核或 initrd,因为它们存在于你的根文件系统中;只有
/boot/efi
是从 EFI 分区挂载的)。当然,你必须实际设置这个引导分区;我做的方法是在救援 CD 中 chroot 到我的分区,然后运行grub-install /dev/sda1
。在折腾过程中,我还不小心运行了update-grub
,导致我的 Windows 引导选项消失了,但在裸机 Linux 启动时重新运行此命令修复了问题(因为真正的/boot/efi
将被挂载,因此 Grub 将找到 Windows 引导选项)。 -
一些关于双启动的文档是针对 VMWare Fusion 的。这是特定于 OS X 的,因此与微软 Surface Book 2 无关。
-
获取一个可启动的 Linux CD(我个人使用SystemRescueCd)来帮助调试安装过程中的问题。
-
确保你的
/etc/fstab
中的所有条目对应于真实的磁盘,否则你的 Ubuntu 启动过程将花费一段时间等待永远不会显示的磁盘。我在/boot/efi
挂载上遇到了这个问题,因为挂载是基于 UUID 的;我通过将挂载改为基于 LABEL 并相应地为我的 vmdk 标记标签来“修复”它(我想也可以尝试更改我的 vmdk 的 UUID,但我找不到在 Windows 上合理的操作说明)。注意,卷实际上不必成功挂载(我的没有,因为我忘记将其格式化为 vfat);它只需存在,以便系统不会等待查看它是否在稍后的时间点连接。 -
我真的不明白 Unity 如何决定提供缩放选项,但尽管在裸机启动时提供放大选项,但在虚拟机下运行时却不可用。通过将分辨率设置为 1680 x 1050,我得到了一个相对合适大小的显示(只有轻微模糊)。在 VMWare 中我启用了“拉伸模式”。
-
你能否登录你的账户取决于你的 X11 配置;如果你像我一样取消了 Intel 的配置,我发现这会导致我的登录失败(你可以通过再次注释掉来解决)。如何使两者都正常工作?别问我,我还在摸索中。
窗口管理器
我还没有开始设置 xmonad;这在很大程度上是因为 Unity 似乎只支持一种非常基础的平铺方式:Windows-left 和 Windows-right 会将窗口移动到显示器的左侧或右侧的一半,而 Windows-up 则会使窗口全屏。也许我还会尝试在 18.04 上设置 xmonad,但现在不用与 trayer 为标准图标而斗争的感觉很好。
接下来做什么
我改善 Surface Book 2 上 Linux 状态的两个主要优先事项:
-
重写 Xournal 以支持 hDPI(这有多难呢,哈哈)
-
弄清楚如何让挂起/休眠更可靠
除此之外,我对这台新笔记本非常满意。特别是我的邮件客户端(仍然是 sup)运行得快得多;以前搜索新邮件会很慢,但在这台笔记本上它们像闪电般流入。这只是说明从 1.6GHz 处理器升级到 4.2GHz 处理器有多大的提升啊 :3
MOCHA: Federated Multi-Tasks Learning (Virginia Smith) : ezyang’s blog
来源:
blog.ezyang.com/2017/12/mocha-federated-multi-tasks-learning-virginia-smith/
下面是Virginia Smith在MOCHA上的讲话记录,于ML Systems Workshop上进行,这在 NIPS'17 上举办。
这项工作的动机来自于我们在实际中解决机器学习问题的方式正在改变。典型的机器学习工作流程是这样的。你从数据集和要解决的问题开始。假设你想建立一个分类器来识别高质量的新闻文章。下一步是选择一个机器学习模型来解决问题。在幕后,为了将模型拟合到你的数据上,你必须选择一个优化算法。目标是找到一个能在数据上最小化某个函数的最优模型。
在实践中,工作流程中有一个非常重要的部分缺失。对于新的数据集、有趣的系统和系统属性,优化算法的选择起着重要作用。举个例子,在过去几年中,数据量变得非常大,必须分布在多台机器上,处于数据中心环境中。我一直在思考如何在这种情况下进行快速的分布式优化,当数据如此之大时。
但越来越频繁地,数据并非从数据中心优雅地包装而来。它来自手机、设备,分布在全国乃至全球各地。在这种设置下进行机器学习训练是具有挑战性的。首先,在数据中心中,你有数百到数千台设备,而在这里,你有数百万甚至数十亿个设备。此外,在数据中心中,设备具有相似的能力;而在这里,你有旧手机、低电量、未连接 wifi 的情况。这会影响到每次迭代中进行计算的能力。
此外,数据本身的异质性也很重要。出于隐私和计算原因,数据可能在网络中变得非常不平衡。它可能是非独立同分布的,因此数据本身可能存在有趣的底层结构。我很兴奋,因为这些挑战可以分解为系统和统计挑战。这项工作的一句总结是,在联邦设置中思考系统和统计问题;关键在于系统设置不仅在优化算法中起作用,而且在选择适合的模型时也起作用。在整个工作流程中,系统设置扮演着更加重要的角色。
我们将全面解决系统和统计挑战的方法进行概述。
从统计学角度出发。目标是我们有一堆生成数据的设备,可能是不平衡的;一些设备比其他设备拥有更多的数据。过去使用的一种方法是在所有这些数据上拟合单一模型。所有数据可以聚合;您找到一个在所有数据上同时实现准确性的模型。另一个极端是为每个数据设备找到一个模型,并且不共享信息。从系统的角度来看这是很好的,但从统计学角度来看,你可能会有一些设备只有…在实践中表现不佳。我们提出的是介于这两个极端之间的方法。我们想为每个设备找到本地模型,但以一种结构化的方式共享信息。这可以在一个称为多任务学习的框架中捕获。
目标是为每个设备拟合单独的损失函数。这些模型可以在矩阵 W 中进行聚合,并且正则化函数的作用是强制施加某种结构 omega 在其上。这个 omega 是一个任务关系矩阵,捕捉有趣的关系,例如,所有任务相关并且你想学习权重,或者大多数任务相关并且有一些异常值,或者有集群和群体,或者更复杂的关系像是非对称的关系。所有这些都可以在多任务中捕获。
我们开发了一个真实的联合数据基准集。这包括尝试从手机预测人类活动,预测是否进食或饮酒,地雷和车辆传感器;分布式传感器来确定车辆是否经过。
对于这些不同的数据集,我们比较了全局、本地和多任务学习(MTL)。目标是拟合一个奇异值分解(SVD)模型。对于每个数据集,我们查看了跨任务的平均误差,其中每个模型都是一个任务。您可以看到,对于 SVD,平均误差显著低于全局和本地方法。这是有道理的,因为多任务学习更加表达丰富;它让您可以在这些极端之间切换。有趣的是,在这些真实数据集中,它确实有所帮助。实践中减少了一半。这在实践中是一个显著的改进。
鉴于我们喜欢在联合环境中使用多任务学习来建模数据,下一个问题是如何在分布式设置中训练这个模型,考虑到大规模分布式。特别是,目标是解决以下优化目标。在研究如何解决这个目标时,我们注意到通常是交替解决 W 和 omega 的问题。当你解决 omega 时,它是集中的,你只需要访问模型。但是 W 必须是分布式的,因为数据分布在设备之间。在实践中解决这个问题的关键组成部分是 W 的更新。做这件事的挑战是通信非常昂贵。并且由于异构性,你可能会遇到大量的问题,如拖延者和容错性问题;例如,有人把手机关掉。
我们正在实施这个高层次的想法,采用一种通信高效的方法,在数据中心中运行良好,并修改为在联邦设置中运行。它将处理多任务学习以及 stragglers 和容错。
我们正在使用的方法是什么?我们正在使用的方法是 COCOA,这是一种用于经验风险最小化问题的最先进方法。COCOA 的优点在于它涵盖了迷你批处理和一次性通信的先前工作,通过将通信作为方法的第一类参数来实现灵活性。它通过不解决原始形式而解决对偶形式来实现这一点。对偶形式之所以好,是因为我们可以通过形成客观函数的二次近似来轻松地近似它;这种方法更容易在多台机器之间分解。
要将其分布到联邦设置,一个关键挑战是找出如何将其推广到 MTL 框架。第二个挑战;在 COCOA 中,假设子问题解决到某个精度θ。这很好,因为θ从 0 到 1 变化,其中 0 是精确解,1 是不精确解。这可以看作是本地通信与通信时间的比例。然而,在实际中,这并不像在联邦设置中应该那样灵活。对所有迭代和所有节点设置的唯一θ。由于θ不能完全设定为 1,所以它无法处理容错,在任何迭代中都没有工作执行。在实践中,这使得通信参数更加灵活。
我们是如何做到这一点的?我们开发了 MOCHA。其目标是解决多任务学习框架;以交替的方式解决 W 和Ω。特别是,我们能够形成以下类似于 COCOA 的对偶形式,使其分解。相比之下,我们对子问题参数做出了更加灵活的假设。这是重要的,因为 stragglers 有统计原因、不平衡、不同分布,解决这些子问题的难度可能会有很大不同。此外,由于系统问题,还可能出现 stragglers。以及容错问题。因此,看起来这是一个简单的修复:我们使这个准确性参数更加灵活:允许它根据节点和迭代 t 变化,并让它确切为 1。困难在于表明它收敛到最优解。
在采纳这一新假设之后,你不能让设备每一轮都出现问题,我们展示了以下的收敛保证。对于 L-Lipschitz 损失,我们得到 1/ε的收敛率;对于平滑模型(逻辑回归),我们得到线性率。
在实践中,这种方法表现如何?这种方法非常简单。假设我们的数据存储在 m 个不同的设备上。我们在解决Ω和 W 存储在每个设备上时交替进行。在解决 w 更新时,它通过为每台机器定义这些本地子问题,并调用进行近似解的求解器来工作。这是灵活的,因为它可以根据节点和迭代变化。
就比较这个方法与其他方法而言,我们所看到的是以下内容。将 MOCHA 与 CoCoA、Mb-SDCA 和 Mb-SGD 进行比较。我们有模拟,用真实数据来看如果我们在 WiFi 上进行会发生什么。我们有模拟的时间以及接近最优解的情况。你可以看到的是,MoCHA 更快地收敛到最优解,因为 MoCHA 没有统计异质性的问题,并且不会被拖累。这对所有不同类型的网络都适用;LET 和 3G。蓝线和 MOCHA 以及 CoCOA,在高通信环境中表现良好,因为它们更灵活。但与 CoCOA 相比,MOCHA 对统计异质性更加稳健。
有趣的是,如果我们施加一些系统异质性,一些设备比其他设备慢,我们看了低和高系统异质性的情况,MOCHA 在这种额外的异质性下,达到最优解的速度提高了两个数量级。
特别是对于 MOCHA,我们关注了容错性的问题。我们在这里展示的是,我们增加了设备在任何分布中掉线的概率。直到有一半的设备,我们对 MOCHA 的收敛仍然相当稳健,几乎在同样的时间内。但是我们看到绿色虚线,如果同一设备每次迭代都掉线,它就不会收敛。这表明我们实际做出的假设是合理的。
关键是,在思考这种新的设置中,在这些大规模设备网络上训练 ML,这既是统计问题也是系统问题。我们已经以整体的方式解决了这个问题。代码位于cs.berkeley.edu/~vsmith
,我还想重申一下 SysML 会议将于 2 月举行。
Q: 当你比较全局和本地时?为什么总是比全局好?
A: 你想使用本地模型而不是全局模型的动机是,如果你有大量本地数据,你可能会表现得更好。这提升了整体样本量。我有一些额外的实验,我们在那里采取了原始数据,并且比它已经存在的情况更进一步倾斜。我们采取了本地数据,那里的数据更少,它们有全局方法。这只是设备中数据的功能。
Q: 我真的很喜欢你的方法有保证,但我想知道一种在本地创建元学习算法并在本地运行的方法?
A: 从经验来看,这是值得研究的,因为你可以在本地进行微调。我们最初试图达到确切的最优解,但你可能只想要在经验上表现良好,与此设置进行比较将是很好的。
模拟 IO:MonadIO 及其进一步的应用:ezyang 博客
MonadIO
问题表面上看起来很简单:我们希望获取一些包含IO
的函数签名,并用另一个基于IO
的单子m
替换所有IO
的实例。MonadIO
类型类本身允许我们将形如IO a
的值转换为m a
(并通过组合,任何结果为IO a
的函数)。这个接口既没有争议,也非常灵活;自从2001 年创建以来,它一直在引导库中(最初在 base 中,后来迁移到 transformers)。然而,很快发现,当存在许多形如IO a -> IO a
的函数时,我们希望将它们转换为m a -> m a
时,MonadIO
没有处理函数负位置参数的规定。这在异常处理的情况下尤其麻烦,这些高阶函数是原始的。因此,社区开始寻找一个更能捕捉 IO 更多特性的新类型类。
尽管升力的语义已经很清楚(通过变换器定律),但更强大的机制是什么并不清楚。因此,早期解决这个问题的方法是挑选一些我们想要的特定函数,将它们放入一个类型类中,并手动实现它们的升力版本。这导致已经存在的MonadError
类发展成为更专业的MonadCatchIO
类。然而,安德斯·卡塞奥格意识到这些函数升力版本的实现有一个共同的模式,他将其提取出来形成了MonadMorphIO
类。这种方法被进一步完善成了MonadPeelIO
和MonadTransControlIO
类型类。然而,只有MonadError
位于核心,并且由于一些根本性问题而未能获得广泛认可。
我认为社区库编写者收敛到其中一个这些类型类是重要且可取的,因为如果希望导出仅需要MonadIO
接口的界面,则无法正确实现异常处理任务。我完全预期 monad-control 将成为“赢家”,它是类型类的长期系列的终点。然而,我认为将MonadError
和MonadCatchIO
描述为一种思想流派,将MonadMorphIO
、MonadPeelIO
和MonadTransControlIO
描述为另一种流派更为准确。
在本博客文章中,我想研究并对比这两种思想流派。类型类是一个接口:它定义了某些对象支持的操作,以及该对象遵循的法则。类型类的实用性既在于其通用性(支持多个实现的单一接口)也在于其精确性(通过法则对可接受的实现进行限制,使得使用接口的代码更易于推理)。这是一个基本的张力:这两种流派在如何解决这一问题上有非常不同的结论。
对异常建模
这种一般的技术可以描述为在一个类型类中选择几个函数进行泛化。由于通用性的原因,一个功能较少的类型类更可取于一个功能较多的类型类,因此 MonadError
和 MonadCatchIO
对异常有着非常特殊的强调:
class (Monad m) => MonadError e m | m -> e where
throwError :: e -> m a
catchError :: m a -> (e -> m a) -> m a
class MonadIO m => MonadCatchIO m where
catch :: Exception e => m a -> (e -> m a) -> m a
block :: m a -> m a
unblock :: m a -> m a
不幸的是,这些函数被一些问题所困扰:
-
MonadError
封装了一个关于错误的抽象概念,并不一定包括异步异常。也就是说,catchError undefined h
不一定会运行异常处理程序h
。 -
MonadError
对于强大的异步异常处理来说是不足的,因为它不包含mask
的接口;这使得编写健壮的括号函数变得困难。 -
MonadCatchIO
明确只处理异步异常,这意味着任何纯粹的错误处理不由它处理。这就是“最终器有时被跳过”的问题。 -
通过
MonadIO
约束,MonadCatchIO
要求 API 支持将任意 IO 操作提升到该单子(而单子设计者可能创建一个限制了用户访问的 IO 支持单子)。 -
MonadCatchIO
导出了过时的block
和unblock
函数,而现代代码应该使用mask
。 -
MonadCatchIO
导出了ContT
变换器的一个实例。然而,续体和异常之间有已知的非平凡交互,需要额外的注意来正确处理。
从某种意义上说,MonadError
是一个不合逻辑的论断,因为它与 IO 没有任何关联;它对于非 IO 支持的单子也存在完全有效的实例。MonadCatchIO
更接近;后三点并不致命,可以很容易地加以考虑:
class MonadException m where
throwM :: Exception e => e -> m a
catch :: Exception e => m a -> (e -> m a) -> m a
mask :: ((forall a. m a -> m a) -> m b) -> m b
(去除了 ContT
实例。) 然而,“最终器有时被跳过”问题更为棘手。事实上,可能存在某些实例的 MonadCatchIO
不知道的零存在。有人认为这些零与 MonadCatchIO
无关;从中可以推断出,如果你想要通过使用 MonadException
安装的最终器来尊重短路,则应该使用异步异常来实现。换句话说,ErrorT
是一个糟糕的想法。
然而,您可以采取另一种观点:MonadException
不仅仅与异步异常有关,而是与任何遵循异常规则的零值有关。这些异常的语义在文章Asynchronous Exceptions in Haskell中有详细描述。它们确切地规定了掩码、抛出和捕获的互动方式,以及其他线程如何引入中断。从这个角度来看,无论这种行为是由运行时系统规定还是通过传递纯值来实现,都是实现细节:只要实例编写正确,零值将得到正确处理。这也意味着,如果我们没有内层单子的基础MonadException
,为ErrorT e
提供MonadException
实例就不再可接受:我们不能忽略低层的异常!
采用这种方法还存在一个最后的问题:一旦选择了原语,标准库的大部分内容就必须通过“复制粘贴”其定义来重新定义,但是它们必须引用广义版本。这对基于这一原则实现库来说是一个重大的实际障碍:仅仅在函数开头加上liftIO
是远远不够的!
我认为强调定义类型类语义将对这一类型类谱系的未来至关重要;这是过去并没有真正存在的一种强调。从这个角度来看,我们定义了我们的类型类,不仅可以访问 IO 中否则无法访问的函数,还可以定义这些函数的行为方式。实际上,我们正在对 IO 的一个子集进行建模。我认为 Conal Elliott 会为此感到自豪。
关于异步异常原始语义扩展的激烈辩论正在进行中,允许“可恢复”和“不可恢复”错误的概念。(这是线程末尾附近的内容。)
线程纯效果
这种技术可以描述为概括了一个常见的实现技术,用于实现MonadCatchIO
中许多原始函数。这些是一组相当奇怪的签名:
class Monad m => MonadMorphIO m where
morphIO :: (forall b. (m a -> IO b) -> IO b) -> m a
class MonadIO m => MonadPeelIO m where
peelIO :: m (m a -> IO (m a))
class MonadBase b m => MonadBaseControl b m | m -> b where
data StM m :: * -> *
liftBaseWith :: (RunInBase m b -> b a) -> m a
restoreM :: StM m a → m a
type RunInBase m b = forall a. m a -> b (StM m a)
这些类型类的关键直觉是它们利用了在被提升的 IO 函数中的多态性,以便在 IO 的顶部线索纯粹效果。你可以把这看作是morphIO
中的普遍量化,peelIO
的返回类型(这是IO (m a)
,而不是IO a
),以及MonadBaseControl
中的StM
关联类型。例如,Int -> StateT s IO a
相当于类型Int -> s -> IO (s, a)
。我们可以部分应用这个函数与当前状态,得到Int -> IO (s, a)
;很明显,只要我们提升的 IO 函数让我们秘密地传出任意值,我们就能传出我们更新的状态,并在提升的函数完成时重新整合它。能够适用于这种技术的函数集合恰好是那些能够进行这种线索的函数集合。
正如我在这篇文章中描述的,这意味着如果它们不是由函数返回的话,你将无法获得任何变换器堆叠效果。因此,MonadBaseControl 的一个更好的词可能不是它是不安全的(尽管它确实允许奇怪的行为),而是它是不完整的:它无法将所有 IO 函数提升到一个形式,其中基础 monad 效果和变换器效果总是同步进行的。
这有一些有趣的含义。例如,这种遗忘性实际上正是为什么一个提升的括号函数将始终运行的精确原因,无论是否存在其他的零值:finally
根据定义只能察觉异步异常。这使得 monad-control 提升的函数非常明确地只处理异步异常:提升的catch
函数将不会捕获ErrorT
的零值。然而,如果您使用更原始函数的提升版本手动实现finally
,则可能会丢弃最终器。
它还建议了一种 monad-control 的替代实现策略:与其通过函数的返回类型将状态线索化,不如将其嵌入到隐藏的 IORef 中,并在计算结束时读取出来。实际上,我们希望嵌入纯 monad 变换器堆栈的语义到 IO 中。然而,在forkIO
情况下需要注意一些细节:IORefs 需要适当地复制,以保持线程本地性,或者使用 MVars 代替,以允许一致的非局部通信。
众所周知,MonadBaseControl 不允许 ContT 有一个合理的实例。Mikhail Vorozhtsov 认为这太过于限制性。困难在于,虽然无限制的继续与异常不兼容,但在有限的延续传递风格中,可以以一种明智的方式结合异常。不幸的是,monad-control 对此情况没有作任何处理:它要求用户实现的功能太过强大。似乎明确建模 IO 子集的类型类,在某种意义上更为一般化!这也突显了这些类型类首先和主要地是受到通用实现模式抽象的驱动,而不是任何语义上的考量。
结论
我希望这篇文章已经清楚地表明了,我为什么将 MonadBaseControl 视为一种实现策略,而不是一个合理的编程接口。MonadException 是一个更合理的接口,它具有语义,但面临重要的实现障碍。
mod_fcgid 2.3 是有问题的(在 2.3.6 中修复):ezyang 的博客
mod_fcgid 2.3 是有问题的(在 2.3.6 中修复)
这篇文章旨在为一个问题获得谷歌关键词排名,该问题基本上阻止了 Scripts 从 Fedora 11 切换到 Fedora 13。新机群一直因为负载而崩溃,我们一直在琢磨,“为什么?”
结果,以下提交 在相当可怕的方式下破坏了 mod_fcgid:基本上,mod_fcgid 无法管理运行中的 FastCGI 进程池,因此它会不断生成新的进程,直到系统内存耗尽。这在拥有大量生成虚拟主机的系统中尤为明显,例如使用 mod_vhost_ldap 的用户。这在上周发布的 mod_fcgid 2.3.6 中得到了修复。
与此无关的,我一直在头脑中转悠着一系列计算机科学哲学的文章,试图在我们领域之外的一些有趣哲学问题中进行识别(咳咳 AI 咳咳)。希望能引入一些传统上与科学哲学、数学哲学、生物学哲学等相关的主题,也许提出一些我自己的问题。哲学家们最喜欢提出听起来合理的理论,然后提出一些令人困惑的例子,似乎能够打破它们,听起来这本身就可以产生一些禅宗公案,这总是很有趣的。
monad-control 很棘手:ezyang 的博客
编辑注。我已经减少了这篇文章中一些修辞。原标题是“monad-control 是不稳定的”。
来自 monad-control 包的 MonadBaseControl 和 MonadTransControl 指定了一种吸引人的方式,自动提升在 IO 中采用“回调”函数的函数到基于 IO 的任意单子栈。它们的吸引力在于,它们似乎提供了比替代方案更通用的机制:选择一些函数,提升它们,然后手动重新实现所有在其上构建的函数的通用版本。
不幸的是,monad-control 对于您可能提升的许多函数具有相当令人惊讶的行为。
例如,它不能在多次调用回调函数的函数上工作:
{-# LANGUAGE FlexibleContexts #-}
import Control.Monad.Trans.Control
import Control.Monad.State
double :: IO a -> IO a
double m = m >> m
doubleG :: MonadBaseControl IO m => m a -> m a
doubleG = liftBaseOp_ double
incState :: MonadState Int m => m ()
incState = get >>= \x -> put (x + 1)
main = execStateT (doubleG (incState)) 0 >>= print
结果是1
,而不是我们预期的2
。如果你还不信,请假设 double 的签名是 Identity a -> Identity a
,比如 a -> a
。在这种情况下,这个签名只有一个可能的实现:id
。这种情况下会发生什么应该是显而易见的。
如果你仔细查看 MonadBaseControl 中涉及的类型,其中的原因应该变得显而易见:我们依赖于要提升的函数的多态性,以便在单子变换器中传递 StM m
,这是封装的单子变换器的“状态”。如果这个返回值被 IO
丢弃,就像我们的函数 double
中那样,那么恢复这个状态的方式就不存在了。(甚至在 liftBaseDiscard
函数中也提到了这一点!)
我的结论是,尽管 monad-control 可能是提升函数的方便实现机制,但它导出的函数存在严重的语义不一致性。最终用户,请注意!
附言。对于之前的 MonadBaseControl/MonadTransControl 的版本,其名称分别为 MonadPeel 和 MonadMorphIO,也有类似的限制。
更多关于 Futamura 投影的乐趣:ezyang's 博客
来源:
blog.ezyang.com/2010/03/more-fun-with-futamura-projections/
Anders Kaseorg 编写的代码。
在Doctor Futamura 的三个投影中,Dan Piponi 向非程序员解释了 Futamura 投影,这是部分求值的一系列令人费解的应用。如果你还没有读过,请去看一看;这篇文章旨在成为那篇文章的精神继承者,在这篇文章中我们将编写一些 Haskell 代码。
铸币的图像类型。 在原始文章中,Piponi 绘制出各种硬币、模板或其他机器作为输入,并输出硬币或机器。让我们用更像 Haskell 类型的东西来重新定义这个定义。
首先,来点简单的:第一个机器,它接收空白硬币并铸造新硬币。
现在我们使用箭头来表示输入输出关系。事实上,这只是一个将空白硬币作为输入并输出镌刻硬币的函数。我们可以用以下类型同义词来概括这个概念:
> type Machine input output = input -> output
那么我们让我们输入硬币的描述是什么呢?好吧,首先我们需要一个简单的数据类型来表示这个描述:
> data Program input output = Program
(是的,这种数据类型真的不能做任何有趣的事情。我们实际上不会为这些机器编写实现。) 从这里开始,我们有了我们下一个"类型化"的解释器的图片:
或者,在代码中表示为:
> type Interpreter input output = (Program input output, input) -> output
从那里开始,看看编译器是什么样子的也不是难事:
> type Compiler input output = Program input output -> Machine input output
我想指出,我们完全可以像这样完全写出这个类型:
type Compiler input output = Program input output -> (input -> output)
我们故意保留了不必要的括号,因为 Haskell 诱人地表明你可以把a -> b -> c
当作一个二元函数处理,而我们希望它与(a, b) -> c
保持不同。
最后,我们有了专用程序:
> type Specializer program input output =
> ((program, input) -> output, program) -> (input -> output)
我们已经用富有启发性的方式命名了我们的 Specializer 类型同义词中的变量,但程序不仅仅是程序:Futamura 投影的整个要点是我们可以在那里放置不同的东西。另一个有趣的事情是,任何给定的 Specializer 都需要在输入和输出之外还根据它操作的程序进行参数化。这意味着 Specializer 假设的具体类型因实际上让program
变化而变化。它不依赖于 Specializer 的第一个参数,这是由program
、input
和output
强制的(program,input)-> output
。
那么,这些具体类型是什么呢?对于这个任务,我们可以问问 GHC。
到第四个投影,以及更远的地方! 首先,几个准备工作。我们保留了input
和output
在我们的类型同义词中完全一般化,但实际上我们应该用具体的数据类型来填充它们。还有一些更空洞的定义:
> data In = In
> data Out = Out
>
> type P = Program In Out
> p :: P
> p = undefined
>
> type I = Interpreter In Out
> i :: I
> i = undefined
我们实际上不关心如何实现我们的程序或解释器,因此 undefined
;考虑到我们的虚无数据定义,确实存在这些的有效实例,但它们并不特别增加洞察力。
> s :: Specializer program input output
> -- s (x, p) i = x (p, i)
> s = uncurry curry
我们对待专用化器有点不同:部分求值和部分应用非常相似:事实上,对外部用户来说,它们确实做着完全相同的事情,只是部分求值最终更快,因为它实际上在做一些工作,而不是形成一个闭包,中间参数无所作为地挂在空中。然而,我们需要取消柯里化,因为 Haskell 函数默认情况下是柯里化的。
现在,Futamura 投影:
> type M = Machine In Out
> m :: M
> m = s1 (i, p)
没有单态限制,s
也可以正常工作,但我们将很快为 s1
给出一个显式类型,这将破坏其余投影的乐趣。(实际上,因为我们给 s
指定了显式类型,单态限制不适用。)
那么,s1
的类型是什么?它绝对不是通用的:i
和 p
完全明确,并且专门化器不会引入任何其他多态类型。这应该很容易判断,但我们还是问问 GHC 以防万一:
Main> :t s1
s1 :: ((P, In) -> Out, P) -> In -> Out
当然。它与我们的变量名匹配!
> type S1 = Specializer P In Out
> s1 :: S1
> s1 = s
是时候进行第二个 Futamura 投影了:
> type C = Compiler In Out
> c :: C
> c = s2 (s1, i)
请注意,这次我写了 s2
。那是因为 s1 (s1, i)
无法通过类型检查;如果你进行统一,你会看到具体类型不匹配。那么 s2
的具体类型是什么?稍微多想一会儿,或许快速浏览一下 Piponi 的文章就能阐明答案了。
> type S2 = Specializer I P M
> s2 :: S2
> s2 = s
第三个 Futamura 投影,解释器到编译器机器:
> type IC = I -> C
> ic :: IC
> ic = s3 (s2, s1)
(你应该验证 s2 (s2, s1)
和 s1 (s1, s2)
以及任何其排列都不能通过类型检查。)我们也设法丢失了与具体性的直接基础:看不到 p
或 i
。但 s2
和 s1
明显是具体类型,正如我们之前展示的那样,而 GHC 可以为我们执行统一:
Main> :t s3
s3 :: ((S1, I) -> C, S1) -> I -> Program In Out -> In -> Out
事实上,它已经很友好地用相关类型同义词替换了一些更加棘手的类型供我们使用。如果我们加入一些额外的括号并只获取输出:
I -> (Program In Out -> (In -> Out))
这就是我们的解释器到编译器机器!
> type S3 = Specializer S1 I C
> s3 :: S3
> s3 = s
但为什么停在这里呢?
> s1ic :: S1 -> IC
> s1ic = s4 (s3, s2)
>
> type S4 = Specializer S2 S1 IC
> s4 :: S4
> s4 = s
或者甚至在这里?
> s2ic :: S2 -> (S1 -> IC)
> s2ic = s5 (s4, s3)
>
> type S5 = Specializer S3 S2 (S1 -> IC)
> s5 :: S5
> s5 = s
>
> s3ic :: S3 -> (S2 -> (S1 -> IC))
> s3ic = s6 (s5, s4)
>
> type S6 = Specializer S4 S3 (S2 -> (S1 -> IC))
> s6 :: S6
> s6 = s
我们可以继续,使用我们用于 n-1 和 n-2 投影的专用化器构造第 n 个投影。
这可能看起来像一堆类型奇技淫巧。我认为不仅仅是这样。
部分求值器的实现者关心,因为这代表了部分求值器组合的机制。S2
和 S1
可能是不同类型的专用化器,各自具有其优势和劣势。这也是部分求值器编写者面临的哲学挑战的生动示范:他们需要编写一段可以在 Sn 中任意 n 上工作的代码。也许在实践中,它只需要在低 n 上表现良好,但它确实能够工作是一个令人印象深刻的技术成就。
对于部分应用的信徒来说,这有点像是一种客厅戏法:
*Main> :t s (s,s) s
s (s,s) s
:: ((program, input) -> output) -> program -> input -> output
*Main> :t s (s,s) s s
s (s,s) s s
:: ((input, input1) -> output) -> input -> input1 -> output
*Main> :t s (s,s) s s s
s (s,s) s s s
:: ((input, input1) -> output) -> input -> input1 -> output
*Main> :t s (s,s) s s s s
s (s,s) s s s s
:: ((input, input1) -> output) -> input -> input1 -> output
*Main> :t s (s,s) s s s s s
s (s,s) s s s s s
:: ((input, input1) -> output) -> input -> input1 -> output
但这是一个有用的客厅戏法:我们设法使一个任意可变参数的函数!我相信这种技术在某些地方被野生使用,尽管在撰写本文时,我找不到任何实际的例子(Text.Printf 可能有,尽管很难将其与它们的类型类技巧区分开来)。
多日调试:ezyang 的博客
目前,我大部分的编程时间都投入到调试 GHC 的新代码生成器上。GHC 的代码生成阶段将 Spineless Tagless G-machine(STG)中间表示转换为 C-- 高级汇编表示;旧代码生成器基本上一次性完成了这个步骤。新代码生成器则是多功能的。它是一个更模块化、更易理解和更灵活的代码库。它是控制流优化高阶框架研究的客户端。
调试也非常难。
过去,如果我在经过几个小时的深入分析后仍然无法弄清楚 bug 的原因,我会感到沮丧并放弃。但在 GHC 上的工作使我对多日调试有了更深入的了解:一个明确定义的 bug,尽管经过数日的强烈分析仍然存在。 (过去我只成功过几次,我很自豪地说我设法收集足够的信息来解决“这个 bug”。什么是“这个 bug”?你曾经在浏览 MediaWiki 站点时神秘地被要求下载 PHP 文件吗?是的,那就是“这个 bug”)。 这大大提高了我在 gdb 上的熟练程度,并且在编译器构造的理论和实践中进行了惊险的冒险。我觉得自己愚蠢,因为没有立即理解回顾来看似乎完全清晰和显而易见的概念。我感到一种惊人的冲动,不是因为问题解决了(尽管当然这会带来好的感觉),而是因为我的攻击计划正在取得进展。我看到我的理论从一个到另一个再到另一个发展,并且学会了从不信任任何第一眼的实验观察。
虽然调试过程尚未完成(尽管我认为我接近拥有正确但慢的新代码生成流水线),我想抽出一些时间来描述这段旅程。
为什么调试 GHC 如此容易?
有趣的是,虽然错误导致编译程序表现出极其奇怪的行为,需要花费很长时间才能解析,但一旦完全理解了错误行为,修复通常只需一行代码。正是这个事实使得调试 GHC 既令人沮丧又光辉:有时你正在调试的代码基本上是错的,你必须重写整个东西。 GHC 的代码基本上是清晰的(这是写它的人的证明),而 bug 通常只是有人忘记处理的一个小细节。解决方案就像是神秘的寻宝解:简短,你找到它时就知道。没有杂乱的情况,比如,“这实际上应该做什么?”
我有一个现有的代码生成管道,可以用来与我的结果进行比较,尽管这样做并不容易,因为新的代码生成器在编译过程中采用了基本不同的方式,所以代码部分经常无法比较。
我也有一个奇妙的测试套件,可以轻松地生成能够在单线程情况下导致段错误的程序,并且相对幸运地遇到只在单线程情况下显示的 bug。我的程序有明确定义的输入和输出,并且我有复杂的机制来检查多通道编译器的内部状态。
我到目前为止修复了什么?
警告:接下来是繁琐的技术细节。
构建它
我的第一个工作是使最新的代码生成代码与最新的 GHC 分支编译(在这段时间内有些陈旧)。这基本上进行得很顺利,除了一个问题,Norman Ramsey 真的喜欢多态本地定义,MonoLocalBinds 在 Hoopl 和其他几个模块中显现了它丑陋的一面。
测试 4030
测试 4030是这个“简单”程序(简单用引号,因为正如 Simon Peyton-Jones 所说的那样,“这看起来像一个难以开始的... 线程、异常等”)。
main = do tid <- block $ forkIO $ let x = x in x
killThread tid
尝试解引用“something”时,生成的代码在 stg_BLACKHOLE_info 处导致段错误。
0x822a6e0 <stg_CAF_BLACKHOLE_info>: jmp 0x822a620 <stg_BLACKHOLE_info>
0x822a620 <stg_BLACKHOLE_info>: mov 0x4(%esi),%eax
0x822a623 <stg_BLACKHOLE_info+3>: test $0x3,%eax
0x822a628 <stg_BLACKHOLE_info+8>: jne 0x822a663 <stg_BLACKHOLE_info+67>
0x822a62a <stg_BLACKHOLE_info+10>: mov (%eax),%ecx -- SEGFAULT!
这个“something”最终成为了 Simon Marlow 在他重写黑洞方案时引入的一个新的栈插槽。解决方案是将这些更改移植到新的代码生成器上。我最终在合并时间窗口内手动审核了每个补丁,以确保所有更改都已移植,并在这个过程中可能消灭了一些潜在的 bug。没有补丁,因为我最终将这个改动合并到了一起(因为新的黑洞方案在新代码生成器分支冻结时还不存在)。
测试 ffi021
测试 ffi021 包括创建指向导入的 FFI 函数的指针,然后动态执行它。(我甚至不知道你可以用 FFI 做到这一点!)
type Malloc = CSize -> IO (Ptr ())
foreign import ccall unsafe "&malloc" pmalloc:: FunPtr Malloc
foreign import ccall unsafe "dynamic" callMalloc :: FunPtr Malloc -> Malloc
这最终是内联语句优化器中的潜在 bug(不是新代码生成器中的 bug,而是新代码生成器触发的优化 bug)。我得出结论认为这是本地代码生成器中的优化 bug,然后 Simon Marlow 辨认出了这个 bug,并且我们得到了一个单行补丁。
hunk ./compiler/cmm/CmmOpt.hs 156
- where infn (CmmCallee fn cconv) = CmmCallee fn cconv
+ where infn (CmmCallee fn cconv) = CmmCallee (inlineExpr u a fn) cconv
测试 4221
这个问题花了三周时间解决。原始的测试代码相当复杂,对代码变更非常敏感。我最初的理论是,我们试图访问一个从未溢出到栈上的变量,但在与 Simon Peyton Jones 讨论栈溢出工作原理后,我开始怀疑这可能并不是问题,并停止试图理解做溢出的 Hoopl 代码,重新进行分析。关于优化燃料还有另一个错误的尝试,我希望它能帮助我找到错误点,但事实上并不起作用。(优化燃料允许您逐步增加应用的优化数量,因此您可以二分搜索引入错误的优化。不幸的是,大部分所谓的“优化”实际上是通往机器码的关键程序转换。)
突破口在于我意识到,当我将输入程序中的类型从 CDouble 改为 CInt64 时,错误仍然存在,但当我将类型更改为 CInt32 时却不存在。这使我能够识别出涉及 垃圾收集 的错误 C-- 代码,并将测试用例缩减为一个非常小的程序,它不会崩溃,但显示出错误的代码(因为程序需要运行一段时间才能在正确的位置触发堆栈溢出):
{-# LANGUAGE ForeignFunctionInterface #-}
module Main(main) where
import Foreign.C
foreign import ccall safe "foo" foo :: CLLong -> CLLong
-- Changing to unsafe causes stg_gc_l1 to not be generated
-- Changing to IO causes slight cosmetic changes, but it's still wrong
main = print (foo 0)
在对调用约定产生了巨大误解并在栈布局代码中找不到 bug 的徒劳尝试之后(我认为 slot<foo> + 4
表示更高的内存位置;实际上它表示比 slot<foo>
更低的内存位置),我最终确认问题出在 stg_gc_*
的调用约定上。
我的第一个修补程序是将被调用者(stg_gc_*
函数)更改为使用新代码生成器发出的观察到的调用约定,因为我看不出那段代码有什么问题。但有一个异常的地方:按照这个理论,所有调用 GC 的地方都应该使用错误的调用约定,然而只有双精度和 64 位整数表现出了这种行为。我的修补程序起了作用,但有些不对劲。这个不对劲实际上是 32 位 x86 没有通用目的的非 32 位寄存器,这就是代码生成器只将这些类型的参数溢出到栈上的原因。我对 GHC 的虚拟寄存器有了更多了解,并确定了另一个一行修复方案。
hunk ./compiler/cmm/CmmCallConv.hs 50
- (_, GC) -> getRegsWithNode
+ (_, GC) -> allRegs
测试 2047(bagels)
这个正在进行中。修复了 GC bug 后,所有剩余的神秘测试套件失败问题都解决了(万岁),我也能够用新的代码生成器重新编译 GHC 和所有库。这导致了 test 2047 开始出现段错误。
我花了一点时间确认我没有在用新的代码生成器编译第二阶段编译器时引入错误(我做得过于热情了),并确认哪个库代码有错误,但一旦我这样做了,我就设法将它减少到以下程序(我曾经贴心地命名为“begals”):
import Bagel
main = do
l <- getContents
length l `seq` putStr (sort l)
在模块 Bagel 中,sort 定义如下:
module Bagel where
-- a bastardized version of sort that still exhibits the bug
sort :: Ord a => [a] -> [a]
sort = mergeAll . sequences
where
sequences (a:xs) = compare a a `seq` []:sequences xs
sequences _ = []
mergeAll [x] = x
mergeAll xs = mergeAll (mergePairs xs)
mergePairs (a:b:xs) = merge a b: mergePairs xs
mergePairs xs = xs
merge (a:as') (b:bs') = compare a a `seq` merge as' as'
merge _ _ = []
并使用以下数据运行:
$ hexdump master-data
0000000 7755 7755 7755 7755 7755 7755 7755 7755
*
000b040
该程序具有一些奇怪的特性。如果我:
-
关闭紧凑式 GC
-
减少主数据的大小
-
关闭优化
-
使用旧的代码生成器
-
将所有代码放在一个文件中
-
从'sort'中删除 seqs(实际上不是一个排序)
-
从'main'中删除 seqs
-
使 sort 函数在 Char 上具有单态性
当前的理论是某人(可能是新的代码生成器或紧凑式 GC)没有正确处理标签位,但我还没有完全弄清楚具体是哪里。这是新代码生成器唯一的突出问题。
Gnome 上的多显示器 xmobar 放置:ezyang 的博客
来源:
blog.ezyang.com/2011/06/multi-monitor-xmobar-placement-on-nome/
Gnome 上的多显示器 xmobar 放置
本文描述了如何在多显示器设置中更改 xmobar 出现的监视器。对我来说,这一直是一个烦恼,因为在切换到多显示器后,xmobar 可能会显示在正确的监视器上,但如果之后重新启动 XMonad,它会迁移到我的另一个监视器,让我非常恼火。请注意,监视器不同于 X 屏幕,可以使用 -x
命令行直接从 xmobar 配置。
如何让 xmobar 选择使用哪个屏幕?它会选择“主要”监视器,默认情况下是您的 xrandr
列表中的第一个条目:
Screen 0: minimum 320 x 200, current 2464 x 900, maximum 8192 x 8192
VGA1 connected 1440x900+1024+0 (normal left inverted right x axis y axis) 408mm x 255mm
1440x900 59.9*+ 75.0
1280x1024 75.0 60.0
1280x960 60.0
1152x864 75.0
1024x768 75.1 70.1 66.0 60.0
832x624 74.6
800x600 72.2 75.0 60.3 56.2
640x480 72.8 75.0 66.7 66.0 60.0
720x400 70.1
LVDS1 connected 1024x768+0+0 (normal left inverted right x axis y axis) 245mm x 184mm
1024x768 60.0*+
800x600 60.3 56.2
640x480 59.9
我们可以使用 xrandr --output $MONITOR --primary
命令切换主要监视器。但这种更改不是持久的;您每次添加新监视器时都需要运行此命令。
幸运的是,gnome-settings-daemon
实际上记录了它看到的监视器信息,以便正确配置它们。此信息位于 .config/monitors.xml
中。
<monitors version="1">
<configuration>
<clone>no</clone>
<output name="VGA1">
<vendor>HSD</vendor>
<product>0x8991</product>
<serial>0x01010101</serial>
<width>1440</width>
<height>900</height>
<rate>60</rate>
<x>0</x>
<y>0</y>
<rotation>normal</rotation>
<reflect_x>no</reflect_x>
<reflect_y>no</reflect_y>
<primary>no</primary>
</output>
<output name="LVDS1">
<vendor>LEN</vendor>
<product>0x4002</product>
<serial>0x00000000</serial>
<width>1024</width>
<height>768</height>
<rate>60</rate>
<x>1440</x>
<y>0</y>
<rotation>normal</rotation>
<reflect_x>no</reflect_x>
<reflect_y>no</reflect_y>
<primary>no</primary>
</output>
</configuration>
</monitors>
因此,我们只需在适当的监视器上将 primary
调整为 yes
。
特别感谢 David Benjamin 和 Evan Broder 告诉我如何做到这一点。
Python 中的变异追踪:ezyang 的博客
Python 是一种赋予你很大自由度的语言,特别是任何特定的封装方案都只是弱约束,可以被足够精明的黑客绕过。我属于“我的编译器应该阻止我做愚蠢事情”的阵营,但我肯定会说,动态能力确实非常方便。但问题来了:语言必须告诉你,你做了什么愚蠢的事情。
在这种情况下,我们希望看到当你错误地改变了一些内部状态时。你可能会嗤之以鼻地说:“嗯,我知道当我改变我的状态时”,但当你调试两个你没有编写的第三方库之间的交互时,情况肯定不是这样。具体来说,我应该能够指向一个变量(它可能是一个局部变量、全局变量或类/实例属性),并对 Python 说:“告诉我这个变量何时发生了变化。”当变量改变时,Python 应该告诉我谁改变了变量(通过一个回溯)以及变量变成了什么。我应该能够说:“告诉我这个变量何时改变成了这个值。”
好的,这里有一个小模块可以做到这一点:变异侦察员。导入这个模块并通过 mutsleuth.watch
传递一个表达式,这个表达式会评估为你想要检查的变量。
这里有一个例子:假设我有以下文件:
good.py
:
b = "default value"
evil.py
:
import good
good.b = "monkey patch monkey patch ha ha ha"
test.py
:
import mutsleuth
mutsleuth.watch("good.b")
import good
import evil
当你运行 test.py 时,你将得到以下的追踪:
ezyang@javelin:~/Dev/mutsleuth$ python test.py
Initialized by:
File "test.py", line 5, in <module>
import evil
File "/home/ezyang/Dev/mutsleuth/good.py", line 1, in <module>
b = "good default value"
Replaced by:
File "test.py", line 5, in <module>
import evil
File "/home/ezyang/Dev/mutsleuth/evil.py", line 2, in <module>
good.b = "monkey patch monkey patch ha ha ha"
有几个注意事项:
-
跟踪不会开始,直到你进入另一个本地作用域,无论是调用一个函数还是导入一个模块。对于大多数较大的应用程序,你肯定会获得这个作用域,但对于一次性脚本可能不是这样。
-
为了保持性能的可接受性,我们仅对实例之间进行浅层比较,因此你需要专门关注一个值,以获取有关它的真实变异信息。
欢迎提交 Bug 报告、建议和改进!我测试过了,找到了一个旧 Bug,我本来希望有这个模块(它涉及由两个不同站点初始化的日志代码被初始化两次),并验证了它可以工作,但我还没有“冷”测试过它。
感谢 Bengt Richter 最初建议这种追踪方式。
MVC 和纯度:ezyang’s 博客
注意保护注意. 纯函数式编程展示了面向对象 MVC 实践推荐的相同实践。
模型-视图-控制器 是一种广泛使用的面向对象设计模式,用于组织带有用户界面的应用程序功能。我在早期编写 Web 应用程序时首次接触到它。模型/视图分离对我作为 PHP 程序员来说有深刻的直觉意义:如果没有它,你将得到一堆将 HTML 打印语句与 MySQL 查询混合在一起的意大利面模板。但是控制器总是有些模糊不清(控制器到底是什么?)。它究竟是做什么的?它是一种“粘合”代码,将模型和视图绑在一起并给它们命令的代码。但对我来说,这总是一个不太满意的答案(输入验证应该放在哪里?),不久之后我离开了 Web 应用程序的世界,我的问题没有答案。
通过接触纯函数式编程,我现在相信控制器和模型/视图分离确实是副作用代码(IO)和纯代码之间的分离。
控制器依赖于模型和视图,但模型和视图不应(直接)依赖于控制器。 纯代码和不纯代码不能自由混合。特别是,你不能从纯代码引用不纯代码(除非使用 unsafePerformIO
)。但是,不纯代码可以调用纯代码(尽管可能涉及一些技术细节),而生成的代码是不纯的。因此,如果控制器是不纯的代码,而模型/视图是纯代码,分离两者只是确保如果我们有任何不纯的代码,我们已尽可能多地将纯计算提取出来。换句话说,如果我有一个读取和写入数据的函数,并且其中有些行与 IO 无关,我应该将它们移动到它们自己的函数中。也许这些行是模板系统,这时它是视图;也许这些行在运行一些复杂的方程式,这时它是模型。纯/不纯并不能捕捉到模型/视图的区别。
控制器接收输入并启动响应. 因此,控制器是输入输出,即 IO。
控制器处理影响模型或视图的事件。纯代码有点像活在真空中:它可以进行计算,但它不能做任何有用的事情,因为它不能有任何副作用,因此我们无法告诉它该计算什么,或者查看计算结果。不纯代码是我们通过将这些信息传递给我们的纯代码来完成任何实际工作的方式。
对这种区分存在几种可能的反对意见。以下是其中一些:
大多数面向对象模型都是有状态的,而状态并不是纯粹的!这种误解可能源于 IO 和 State 都是单子的事实。然而,通过简单运行状态机,我可以将状态单子转换为单一的纯值:具有状态的代码是单子的,但也是纯粹的,因为它没有任何外部副作用。共享状态则稍微棘手,通常不是纯粹的。
控制器代码不一定非要是不纯的,这里举一个例子。在这里我会有点先入为主:我打赌你有一个模型,但这个模型只是与你的核心业务逻辑有关的某个边缘联系。如果你有代码将二进制字符串解析为消息对象(但实际上并不处理在网络上传输或接收这些二进制字符串),你就有了一个网络消息的迷你模型。你应该将其与真实的模型分开,但为了可测试性,你还应该将其与网络代码分开。关注点分离可能是可以改变的,但你类型签名中的少量 IO 始终是诚实的。
关于纯度“苦衣”还有一些告别话语:相当广泛地认为,采用 MVC 模式会使你的应用程序在初始阶段变得更加复杂,在纯函数语言中,你被迫从一开始就尊重这种区分。因此,在纯函数语言中编写小程序可能会令人沮丧,因为你不想从一开始就使用笨重但可扩展的工程实践,而语言却要求你这样做。Haskell 为你提供了许多方式来让编程变得愉快,但需要一段时间来适应。好处是,当你的程序增长时,这种分离将继续得到强制执行,可能会避免混乱的重构。
我在 Isabelle 中的第一个证明:ezyang’s blog
美国和英国学术机构之间的一个显著差异是课外补充学习。在美国,我们有课后讲习班,类似额外的讲座,而在英国,他们有教程,或者按剑桥的说法称为辅导。正如往常一样,它们是一种混合包:一些导师很糟糕,其他人则仅仅称职,还有些则激发并鼓励对课程主题的兴趣,远远超出课程大纲的范围。
尼克·苏尔塔纳(Nik Sultana),我们的逻辑与证明监督员,就是这样的人物之一。在我们最后一次辅导中,凭借一时的冲动(我们这些被监督者的怂恿),他建议我们尝试在Isabelle中证明以下逻辑陈述,这是他一直在进行研究的证明助手。
我首先推导了关于该命题的序言演算证明(留给读者作为练习),然后我找到了 Isabelle,下载了手册,启动了 Proof General,开始了我在 Isabelle 中的第一个证明。
语法. 我遇到的第一个问题是得到一个最小的理论以编译。这是因为 Isabelle 要求你始终有一个 imports 行,所以我提供了Main
作为一个 import。
然后我尝试证明一个微不足道的理论,A --> A,并因为声明为“by (impI)”而被绊倒,而不是“by (rule impI)”(在这一点上,仍然不清楚‘rule’实际上是做什么的)。
我尝试证明另一个理论,即conj_rule
,直接参考文档,但是我把 Unicode 转录成 ASCII 时出错了,最终得到了一个与他们所做步骤不符的理论。(这是阅读手册的一个让人恼火的事情,尽管我理解他们为什么这样做。)最终我意识到了问题所在,并决定实际开始证明:
lemma "(ALL x. ~ P x --> Q x) & (EX x. ~ Q x) --> (EX x. P x)"
我首先尝试了非点记法,但语法检查失败了,所以我为所有绑定变量引入了点。
语义. 这个证明很简单:
by blast
不过,那是作弊 😃
在这一点上,我感到很不自在:Isabelle 使用自然演绎系统,而(通过我的学习)我最有经验的是等价推理、序言演算或表演演算(更不用说我已经掌握了序言演算的证明)。事实证明,移除量词后看起来确实像在正常的序言演算中一样,但我还没有意识到这一点。
我摸索着,盲目地应用allE
、allI
、exE
和exI
,看看它们会做什么。我还没有意识到rule
、drule
和erule
之间的区别,所以偶尔我会应用一个规则,得到大量的子目标展开,然后对自己说:“嗯,这似乎不对啊。”
最后,从通用部分反向阅读,我意识到 ==>
与 -->
有些不同,代表着一种元蕴含,某些规则对其特别对待,所以我将其转换为它:
-- "Massage formula"
apply (rule impI)
再一次,我试图应用通用规则,通常无法使公式看起来漂亮。然后我更仔细地查看了伊莎贝尔的示例,注意到它们在 ==>
的左侧使用了 [| P; Q |]
,而不是 P & Q
,所以我找到了适当的规则将公式整理成这种形式(分号是序言演算的冒号)。然后我意识到还有这个 erule
的东西,虽然我仍然认为当规则末尾有 E 时只需应用它:
apply (erule conjE)
证明。 每个人都喜欢通过排列来编码,所以我再次通过规则。这一次,exE
似乎使公式保持简单,经过几秒钟的思考,也应该是序言演算证明中正确的做法。我也意识到我正在进行反向证明(即,我们将我们的目标分解为子目标),突然手册中的蕴含语句更加清晰了(看右侧,而不是左侧!):
apply (erule exE)
接下来的步骤花费了一些时间。我很容易地应用了 (erule allE)
,这消除了方程右侧的全称量词,但引入了一个新的斯科莱姆函数,这似乎不是我想要的。我也知道理论上我应该能够消除右侧的存在量词,但无法弄清楚应该使用什么规则。尝试使用通常的规则结果很荒谬,尽管我认为在这一点上我已经搞清楚了何时使用各种 rule
的变体。最终,我重新阅读了手册中显式替换变量部分,脑补了 drule_tac
语法,它奏效了!
apply (drule_tac x = "x" in spec)
有经验的伊莎贝尔将会意识到我可以消除右侧的存在量词,但因为我已经得出结论这是不可能的,所以我选择了绕道而行。我决定尝试结合一些我的假设来得出一些有用的结论。此时,证明状态如下:
!!x. [| ~ Q x; ~ P x -> Q x |] ==> (EX x. P x)
我觉得我应该能够在左侧得到 P x
,然后应用一些存在量词规则,因为真语句 P x ==> (EX x. P x)
。但是没有一条蕴含规则或假言推理适用得恰当。我还尝试使用反证法交换假设和目标:
!!x. [| ~ (EX x. P x); ~ P x -> Q x |] ==> Q x
实际上,我希望蕴含物在右侧。然而,存在量词在左侧,所以我本应该能够消除它……但有一个否定,所以规则失败了。
进一步阅读揭示了我可以使用一些特殊的语法将蕴含物放在右侧:
!!x. [| ~ Q x; ~ (EX. P x) |] ==> ~ (~ P x -> Q x)
但再次,条件使我无法执行我的邪恶计划,破坏推论并在假设中推导出矛盾。
所以我回头去,再玩了一些,最终发现从目标端消除存在量的方法是引入它(倒过来!)我还发现_tac
可以适用于基本上任何规则:
apply (rule_tac x = "x" in exI)
在这一点上,这是一个简单的命题证明,而我之前对反证法的探索给了我一个做法的想法:
apply (erule contrapos_np)
by (drule mp)
Sweet。在我完成证明后,我回头去掉了tacs
,并检查了一下伊莎贝尔是否能够自行统一变量;她可以,但中间的证明目标看起来更丑,所以我又把它们加了回去。这样就完成了我在伊莎贝尔中的第一个证明。这并不算多,但其中的漫步足以让我在完成时感到相当满意。以下是整个过程:
theory LogicAndProof
imports Main
begin
lemma "(ALL x. ~ P x --> Q x) & (EX x. ~ Q x) --> (EX x. P x)"
-- "Massage the formula into a nicer form to apply deduction rules"
apply (rule impI)
apply (erule conjE)
-- "Start introducing the safe quantifiers"
apply (erule exE)
apply (drule_tac x = "x" in spec)
apply (rule_tac x =" x" in exI)
apply (erule contrapos_np)
by (drule mp)
My type signature overfloweth : ezyang’s blog
我最近开始研究使用 会话类型 进行实际编码,这个想法从我曾参与构建网络协同文本编辑器团队开始,我就一直在思考。当时我花了大量时间仔细审查服务器和客户端,以确保它们实现了正确的协议。这些协议的本质通常相对简单,但在错误流(例如断开连接后的重新同步)存在的情况下很快变得复杂起来。错误条件也很难进行自动化测试!因此,静态类型似乎是解决这一任务的一种吸引人的方式。
Haskell 中有三种会话类型的实现:sessions,full-sessions 和 simple-sessions。如果你感到特别天真,你可能会尝试访问 Haddock 页面 来了解 API 的外观。在继续阅读之前,请检查那个页面。
眼睛剜出来了吗?我们继续吧。
在《Coders at Work》的采访中,Simon Peyton Jones 提到类型的一个显著好处是它提供了函数可能做什么的简明、清晰描述。但那个 API 根本不是简明和清晰的,我仅仅通过查看相应的函数定义就无法理解它。因此,当前会话类型编码的一个关键卖点是它们不会破坏类型推断:我们放弃用户理解一堆类型类代表的含义,只期待传输一个信息位,“协议是否匹配?”
这个问题并不是会话类型的根本问题:任何大量使用类型类的功能都很容易陷入这些冗长的类型签名中。对于如何更好地向用户展示这种复杂性,我有两个(相当未完成的)想法,尽管并不能完全消除:
-
类型系统黑客的一种喜爱的消遣是使用 Peano 数(
Z
和S a
)对自然数进行类型级编码,附加到类似于Vector (S (S Z))
的东西。Vector 是一个类型构造器,类型为* -> *
。然而,由于 Haskell 中只有一个原始种类,我们实际上可以将任何类型传递给 Vector,比如说Vector Int
,这将是荒谬的。防止这种情况发生的一种方法是声明我们的 Peano 数是类型类Nat
的实例,然后声明Nat a => Vector a
。但是,由于在任何这样的语句中a
只使用一次,如果我们能够写成Vector :: Nat -> *
,那不是更好吗?如果需要指定类型相等性,可以想象某种类型模式匹配concat :: Vector a -> Vector b -> Vector c with c ~ a :+: b
。类型和种类的折叠是朝这个方向迈出的有趣一步。 -
当数学家提出证明时,他们可能会明确地指定“对于所有的 F,使得 F 是一个字段……”,但更频繁地,他们会说类似于“在以下证明中,假设以下变量命名约定。” 这样一来,他们就避免了反复显式地重新声明所有变量名的含义。对于类型变量的类似系统将大大减少长类型签名的需求。
但实际上,这与我当前正在研究的内容无关。
我正在看的是:会话类型还受到另一种类型签名爆炸现象的困扰:协议中的任何函数在其类型中包含从该时刻起整个协议的完整规范。正如Neubauer and Thiemann 承认(PDF),“对完整 SMTP 的会话类型相当难以阅读。” 我正在追求的两条研究路线如下:
-
是否可以通过在会话类型中构建异常支持(目前是一个未解决的问题),允许通过省略与错误情况对应的会话类型来实现更简单的会话类型?
-
是否可以使用
type
来允许协议的单一全局规范,然后个别函数简单地引用它?我们需要更强大的一些东西吗?
到目前为止,我只是在进行思考和阅读论文,但我希望很快开始编写代码。不过我很乐意听听你的想法。
Mystery Hunt and the Scientific Endeavour : ezyang’s blog
来源:
blog.ezyang.com/2012/01/mystery-hunt-and-the-scientific-endeavour/
有时很难理解为什么要花三天时间解决一些人称之为“世界上最难的休闲难题之一”的问题,而且还要彻夜未眠;但在这个周末,数百人聚集在麻省理工学院校园参加了麻省理工迷题猎的活动。为了庆祝找到硬币的事件,我想分享一篇我在档案中找到的小文章,比较了迷题猎和科学探索。(如果你对迷题猎不熟悉,我建议你听一下链接的This American Life节目。)
托马斯·库恩在他著名的书籍科学革命的结构中提到,“正常科学”就是“解谜题”:他的意思是科学家们日常的努力是解决小而可解的问题,这些问题是“谜题”,而不是“宇宙的伟大奥秘”。库恩继续描述了正常科学的内容:事实的生成,理论与观察之间的适应性增强,以及范式的阐述。我们将看到,正如人们所预期的那样,这些活动也是“正常”解谜的一部分。但(也许更出乎意料的是)波普尔的伪证和库恩的革命也对这种情况有所影响。解谜和科学之间的类比存在一些局限性,其中最显著的是谜题有一个单一明确的解答。但由于在解谜过程中不能打电话给谜题的作者,“我在正确的道路上吗?”这样的问题只能在最后报答案时才能询问,因此中间步骤对科学实践仍然有一定的信息性。在这种情况下,我认为波普尔假设了科学进展的微观视角,而库恩则假设了科学进展的宏观视角。
什么是谜题猎?这是一个乍一看似乎难以回答的问题:谜题可以是从填字游戏到一本鸟类图片集到一个单独的看似随机电子音高的音频文件。答案总是一个短语,也许是“开普勒第三定律”或“靴子”,但是一个谜题,就像一个科学问题一样,并没有附带如何解决它的说明。因此,每一个谜题猎的谜题都是从库恩的前科学阶段开始的:没有关于谜题如何工作的理论,解谜者们挽起袖子开始收集可能与手头的谜题相关的各种事实。如果有鸟类的图片,就开始识别这些鸟类;如果有短视频片段展示人们挥舞旗帜,就开始解码信号旗语。这个阶段不需要太多的洞察力:有一个明显的事情可以做。收集到的信息中可能有些是无关紧要的(就像林奈对物种的分类在现代关于植物和动物可观察特征的信息下得到了广泛的修改),但总体来说,这些信息构成了理论形成的有用基础。但是,虽然波普对数据收集没有太多的言论,库恩关于观察的理论负荷性的概念是重要的。观察的理论负荷性表明,不可能在没有关于世界如何运作的某些预设和理论的情况下进行观察。例如,一系列鸟类的图像可能暗示需要识别每只鸟,但在这个识别过程中可能会意识到这些图像直接对应于奥杜邦《美国鸟类》的水彩雕版(此时的新问题是,哪些版?)。即使在前科学阶段,也会不断地创造和证伪小理论。
一旦数据收集完毕,必须形成一个关于如何整合所有数据的理论:这一步骤被称为“答案提取”。在常规科学中,形成正确的理论可能需要很多年时间;对于谜题来说,这个过程被一系列现有理论的集合加速,经验丰富的解谜者可能会尝试(例如,每个项目都有一个与字母对应的编号),以及谜题作者可能会在谜题的风味文本和标题中放置的提示(例如,“鸟鸣之歌”指的是“推特”,意味着团队应该把一个提取出来的短语理解为一个推特账号)。天真的归纳主义认为,对数据的无偏观察应该会导致一个能解释所有信息的理论。然而,谜题专门设计来抵制这种直接的观察方式:更频繁发生的是类似波普尔的反证法,即通过纯粹的创造力(或者对先前谜题的历史知识)创造理论,然后将其与谜题进行测试。再次以鸟类谜题为例,一个提出的理论可能是鸟类的学名的第一个字母拼出一个英语单词。当收集到学名并发现第一个字母并没有拼成一个单词时,该理论被证伪,我们就去寻找其他事情去做。这使得波普尔的观点高度个体化:只有一些人可能提出“好主意”,但任何人都可以通过外出并做必要的计算来证伪理论。复杂的证伪允许某人可能错误地进行计算(从而使其他解谜者进入一场大狐狸猎取,直到有人回到最初的想法并意识到它实际上确实有效)。然而,它只在非常高的分辨率上解释了科学的努力:它解释了单一理论的过程;而我们将看到库恩的范式如何帮助拓宽我们对整体解谜努力的视角。
库恩指出,正常科学围绕范式组织自己,这些范式以一些基本定律(如麦克斯韦方程、牛顿定律)和解决问题的约定为特征。与理论不同,范式在波普尔的意义上不能被“证伪”:一个范式可以容纳异常情况,这些异常情况在进一步调查后可能会得到解决。区别在于范围。因此,在一个谜题中,虽然我们可能有一个直接可证伪的理论“第一个字母形成一个单词”,但更复杂、主题性的想法,比如“这个谜题是博士谁(Doctor Who)主题的”,更接近于一个范式,因为从“博士谁”得到答案的具体机制未经指定,需要通过“正常的解谜方法”来解决。范式是模糊的,但它有“正确的想法”,通过足够的努力,细节可以被解决出来。决定要研究哪个范式是一个社会过程:如果一个解谜者刚刚参与一个谜题,发现一群人已经在一个范式内工作,他更有可能沿着这些线索继续。当然,如果这个群体陷入困境,他们可能会召唤外部人员,特别是考虑“超出”范式的思考。库恩描述的革命就是这样发生的。当一个范式在取得进展时失败(例如无法给出答案),解谜者们将继续尝试应用它,直到一个令人信服的替代范式出现,他们可能会全部转向这种新方法。革命是压缩的,但它仍然具有许多共同特征:包括那些仍然认为旧方法只需“再多一点工作”就能得到答案的孤立解谜者。(有时候他们是对的!)
我们看到在实验室工作的科学家和在神秘猎寻谜题上工作的科学家的活动之间存在显著的对应关系。如果您允许我稍微进行心理分析,我认为这种相似性部分原因是为什么神秘猎寻对科学、技术、工程和数学背景的人如此吸引:在日常生活中,您面对着人类已知最棘手的谜题,因为它们根据定义是那些抵抗任何解决尝试的谜题。数月可能会毫无进展。在神秘猎寻中,您再次面对着一些人类已知最棘手的谜题。但有所不同。您看,在神秘猎寻中,有一个答案。而且您可以找到它。
祝解谜愉快!
Hackage 上的名称冲突:ezyang’s 博客
注意保护环境声明。 Hackage 上使用最多的非限定标识符。
或许你害怕这个错误信息:
Ambiguous occurrence `lookup'
It could refer to either `Prelude.lookup', imported from Prelude
or `Data.Map.lookup', imported from Data.Map
这是风笛手的信息,他来收取你不卫生不合格的无限制模块导入风格的应得之债。
或者你是一个库作者,正试图想出一个新的符号来表示你的时髦中缀组合子,但你不确定其他库已经使用了什么。
我取出了 最新 Hackage 包的归档(TAR),为所有内容编写了一个脚本,提取所有公共模块导出的非限定名称,然后统计了使用最多的名称。
免责声明。 数据构造器和记录字段,除非它们被明确地导出,否则不包括在此计数中。我也不会计算那些从全局命名空间导出所有内容的模块,因为它们忽略了要导出的名称列表。计数是按模块计算的,而不是按包计算的。由于 haskell-src-exts 的限制,CPP 和 HSC 文件未被计数。
前二十个标识符(截至 2012 年 9 月 2 日)。
106 empty
69 insert
69 toList
66 fromList
56 null
54 singleton
44 run
42 encode
41 decode
41 delete
39 size
37 theModule
35 member
32 parse
31 get
30 lookup
30 union
29 Name
29 space
28 Node
前二十个中缀运算符(截至 2012 年 9 月 2 日)。
25 !
19 <>
17 <+>
14 </>
11 <$>
10 //
10 ><
9 .:
9 <$$>
9 ∅
8 &
8 .=
8 <?>
8 <||>
8 \\
8 |>
7 #
7 $$
7 *.
7 <->
惊叹号已经赢得了“索引”运算符的声誉,并且毫不奇怪地位居榜首。我从 Edward Kmett 那里听说 <>
正在以 mappend
的形式进入基础库,这是受欢迎的,尽管对其他六个重定义了它用于自己不可告人目的的模块可能不太友好。
按使用频率和词典顺序排序的所有中缀运算符(截至 2012 年 9 月 2 日)。
! <> <+> </> <$> // >< .: <$$> ∅ & .= <?> <||> \\ |> # $$ *. <-> <. <//>
<| <|> ==> >. ||. ∈ ∉ !! &&. ++ +++ /=. <=. =: ==. >=. ∋ ∌ ∩ ∪ .|. :->
<: ? ∆ ∖ .&. .* .-. <&> <.> << === ?? @@ \/ ^^ |+ |- ||| ~~ !!! !> !? ##
$+$ += +> -<- .*. .:? .<. .==. .>. /=? /\ :- :> :~> <$?> <+< <=> <=? <?
<|?> =. ==? =~ >-> >=? >? @# ^ ~> ¬ ∘ ∧ ∨ ≡ ≢ ⊂ ⊃ ⊄ ⊅ ⊆ ⊇ ⊈ ⊉ !: $# $>
$~ % %> && &&? &= ** *|* + --> ->- -| . .!= .!=. .&&. .&.? .*> .+ .++.
.+. ... ./. ./\. .:: .<=. .=. .=> .>=. .\/. .| .||. :* :+ :. := :=: <*.
<*> <++ <++> <..> <:> <<|> <== <|*|> =$= >+> >=> >>>= >|< ?> ?>= @@@ ^#
^$ ^: ^^^ |* || ||* ||+ ||? ~: ~? ≠ ≮ ≯ ⊕ ⧺ !$ !$? !. !=. !>>= #! #!!
#~~ $ $! $$$ $$$? $$+ $$++ $$+- $$= $- $. $.// $/ $// $= $=! $? $| $~!
%% %&& %+ %/= %: %< %<= %== %>= %|| &#& &&& &+ &. &.// &/ &// &=# &> &@
&| * *! *& *&&&* *&* ***** ****/* ****/*** ****//* ****//*** ****|*
****|*** ****||* ****||*** ***/* ***/** ***/**** ***//* ***//**
***//**** ***|* ***|** ***|**** ***||* ***||** ***||**** **. **/* **/***
**//* **//*** **> **|* **|*** **||* **||*** */* */** */*** */**** *//*
*//** *//*** *//**** *<<<* *=* *=. *=>* *> *>>>* *? *@ *^ *|** *|***
*|**** *||* *||** *||*** *||**** +% ++. ++> ++>> ++@ +/+ +: +:+ +=. +>>
+@ +^ +| - -!- -$ -->> -/\- -: -< -<< -<=- -=. -=> ->> -?- -?-> -?> -?>>
-@ -\/- -^ -|- -~> .! .# .$. .- .--. .->. .... ./ ./= ./=. .:. .::: .<
.<<. .<= .== .>>. .@ .@$ .@~ .\. .|| .~ .~. / /+/ /- /. /<-. /=: />/ /^
/| /~ /~? :*: :+: :-: :<-> :<: :<=: :<> :<~> :=+ :><: :~ <! <#$> <$| <%
<&&> <* <+ <-$ <-- <-. <-: </=? <<! <</ <<: <<< <<? <<\ <<| <<~ <=! <=:
<==? <=@ <=@@ <>>= <?< <??> <@ <@> <@@ <~ =$ =$$= =*= =/= =< =<< =<<!
=<<< =<= =<>= =<? ==! =>> =~= =≪ >! >$$< >$< >*> >-- >-< >: >:> >=! >=:
>== >===> >=>=> >=@ >=@@ >> >>-> >>. >>= >>=# >>== >>=\/ >>=|\/ >>=||
>>=||| >>> >>@ >?> >@ >@@ >||< ?! ?+ ?/= ?: ?< ?<= ?= ?== @! @= @==? @=?
@? @?= @?== \== ^% ^-^ ^. ^>>= ^@ ^^. |#| |$> |*| |-> |-| |. |/ |// |:
|<- |= |=> |=| |? |@ |\ |\\ |||| ~/= ~== ~=? ~?= ~|||~ ~||~ ~|~ ~~# ~~>
~~? ~~~> · ·× × ×· ÷ ⇒ ⇔ ∀ ∃ ≫ ≫= ⊛ ⊥ ⊨ ⊭ ⊲ ⊳ ⋅ ⋈ ⋘ ⋙ ▷ ◁ ★
这简直就是一个动物园!(我个人想起了 Nethack 游戏。)
来源。 驱动这个演习的可怕代码可以在 Github 找到。我使用了以下 shell 单行命令:
for i in *; do for j in $i/*; do cd $j; tar xf *.tar.gz; cd ../..; done; done
以提取压缩文件中的所有 tar 文件。
附言。 如果有人能修复我早些时候描述的不一致之处,并在这个领域进行更全面/正确的搜索,那将是很棒的。
NDSEG : ezyang’s blog
嵌套数据并行性对比创造性范畴论:ezyang 的博客
嵌套数据并行性对比创造性范畴论
我得看了(不幸的是不是亲自看到)西蒙·佩顿·琼斯的出色演讲(真的,如果你还没看过,应该腾出一个小时去看看)关于数据并行哈斯克尔(幻灯片)。这个讲座让我想起了盖伊·斯蒂尔最近看过的有关并行性的前一次讲座。
这两个讲座之间有什么关系?起初我以为,“嘿,盖伊·斯蒂尔一定在为程序员提倡一种纪律,而西蒙·佩顿·琼斯则在为编译器提倡一种纪律。” 但这似乎并不完全合适:也许你对这个问题有一个聪明的范畴论解决方案,全面并行化的开销是不可承受的。正如斯蒂尔所指出的,我们需要“混合顺序/并行策略”,其中最简单的是“将其并行化直到可管理,然后在其上运行快速的顺序算法”,就像平坦数据并行性一样。而嵌套数据并行性也不是万能药;虽然它具有更广泛的适用性,但仍然有一些领域并不适合它。
我相信,嵌套数据并行性将是一个强大且实用(至少在数据并行哈斯克尔团队解决问题后)的工具,用于高效实现范畴性程序。特别是,它继承了平坦数据并行程序中的分块优势,并结合了嵌套并行数据的强大抽象。它承诺消除将并行数据结构分割为均匀块以传递给各个处理器的繁琐工作。它并没有解决诸如当输入数据不以并行结构提供时该如何处理等问题(你可能注意到数据并行哈斯克尔主要适用于数字类型:双精度数、整数和字)。它仍然依赖于适用于你选择的并行结构的方便的约化函数的存在。
嵌套循环和延续:ezyang 的博客
一位命令式程序员的主要技能是循环。从 C/汇编的角度来看,循环只是一个结构化的跳转,如果某些条件不满足,则跳回到指定位置。通常,这种循环遍历某些列表数据结构的元素。在 C 语言中,你可能会对数组的元素进行指针算术运算,或者沿着链表的指针进行操作,直到获得NULL
;在 Python 和其他高级语言中,你会用到for x in xs
的结构,它巧妙地抽象了这种功能。在循环内部,你还可以使用流程控制操作符break
和continue
,它们也是高度结构化的跳转。更紧凑的循环形式和嵌套循环是列表推导式,它们不允许使用这些流程控制操作符。
Haskell 鼓励您使用诸如map
和fold
之类的高阶形式,这进一步限制了数据的操作。在 Haskell 中,您肯定不会看到for
循环... 然而,作为一个有害的小练习,同时也是更深入了解callCC
可能有用的一种方式,我决定使用continue
和break
关键字来实现for...in
循环。最终的希望是能够编写如下代码:
import Prelude hiding (break)
loopLookForIt :: ContT () IO ()
loopLookForIt =
for_in [0..100] $ \loop x -> do
when (x `mod` 3 == 1) $ continue loop
when (x `div` 17 == 2) $ break loop
lift $ print x
以及:
loopBreakOuter :: ContT () IO ()
loopBreakOuter =
for_in [1,2,3] $ \outer x -> do
for_in [4,5,6] $ \inner y -> do
lift $ print y
break outer
lift $ print x
后者通过显式标记每个循环来解决经典的“嵌套循环”问题。我们可以使用以下代码运行这些片段:
runContT loopBreakOuter return :: IO ()
由于延续表示程序流的“延续”,我们应该有某种作为break
的延续的概念,以及作为continue
的延续的概念。我们将存储与在循环“标签”内部跳出和继续相对应的延续,这是我们悬挂的 lambda 函数的第一个参数:
data (MonadCont m) => Label m = Label {
continue :: m (),
break :: m ()
}
然后只需在单子内部调用continue label
或break label
来提取和跟随继续。
接下来要做的是实现实际的for_in
构造。如果我们不必提供任何继续,这实际上只是一个反转的mapM_
:
for_in' :: (Monad m) => [a] -> (a -> m ()) -> m ()
for_in' xs f = mapM_ f xs
当然,示例代码中,f
的类型是Label m -> a -> m ()
,所以这行不通!考虑这第一种转换:
for_in'' :: (MonadCont m) => [a] -> (a -> m ()) -> m ()
for_in'' xs f = callCC $ \c -> mapM_ f xs
这个函数与for_in'
做了同样的事情,但我们把它放在了延续单子内部,并明确了一个变量c
。在这种情况下,当前的延续c
对应的是什么呢?嗯,它位于非常外部的上下文中,这意味着“当前的延续”完全不在循环内部。这必须意味着它是break
的延续。酷!
考虑这第二种替代转换:
for_in''' :: (MonadCont m) => [a] -> (a -> m ()) -> m ()
for_in''' xs f = mapM_ (\x -> callCC $ \c -> f x) xs
这一次,我们用一个包装器 lambda 替换了f
,在实际调用f
之前使用了callCC
,当前的延续结果是调用mapM_
的下一步。这是continue
的延续。
只剩下把它们粘在一起,并将它们打包到Label
数据类型中。
for_in :: (MonadCont m) => [a] -> (Label m -> a -> m ()) -> m ()
for_in xs f = callCC $ \breakCont ->
mapM_ (\x -> callCC $ \continueCont -> f (Label (continueCont ()) (breakCont ())) x) xs
Et voila! Haskell 中的命令式循环结构。(尽管你可能永远不想使用它们,挤眼、眨眼)
附录. 感谢 Nelson Elhage 和 Anders Kaseorg 指出一个风格上的错误:将延续作为() -> m ()
存储是不必要的,因为 Haskell 是惰性语言(我为此辩护,命令式范式正在泄漏!)
附录 2. 添加了类型签名和运行最初两个示例的代码。
附录 3. Sebastian Fischer 指出附录 1 引入的错误。这就是我因未测试我的修改而遭遇的后果!
嵌套循环和异常(Oleg Kiselyov):ezyang’s 博客
来源:
blog.ezyang.com/2010/02/nested-loops-and-exceptions-oleg-kiselyov/
编者按. 今天我们中断常规节目,为您带来 Oleg Kiselyov 的客座文章,重新解释我们之前关于 嵌套循环和延续 的文章,使用异常。
你好!
我注意到您最近关于嵌套循环和延续的文章。我应该使用提供的表单进行评论,但我不确定格式化结果如何。评论包含大量代码。请随意发布全部或部分代码,或做任何其他操作。
我的评论论点是,在单个和嵌套循环中,callCC
是不必要的用于实现 break 和 continue。我们观察到每次迭代和整个循环的延续被调用 0 次或 1 次(但从不超过一次)。这是异常的模式。因此,您的文章提出的问题可以用异常解决。这里有几种解决方案的变体。
首先,一些预备知识:这条消息是完整的文学化 Haskell 代码。
> import Prelude hiding (break, catch)
> import Control.Monad
> import Control.Monad.Trans
唉,Control.Monad.Error
中的 ErrorT
有一个愚蠢的 Error
约束。因此,我们不得不编写我们自己的异常 monad transformer。下面的代码是标准的。
> newtype ExT e m a = ExT{unExT :: m (Either e a)}
>
> instance Monad m => Monad (ExT e m) where
> return = ExT . return . Right
> m >>= f = ExT $ unExT m >>= either (return . Left) (unExT . f)
>
> instance MonadTrans (ExT e) where
> lift m = ExT $ m >>= return . Right
>
> instance MonadIO m => MonadIO (ExT e m) where
> liftIO m = ExT $ liftIO m >>= return . Right
>
> raise :: Monad m => e -> ExT e m a
> raise = ExT . return . Left
>
> catch :: Monad m => ExT e m a -> (e -> ExT e' m a) -> ExT e' m a
> catch m h = ExT $ unExT m >>= either (unExT . h) (return . Right)
>
> runExT :: Monad m => ExT e m a -> m a
> runExT m = unExT m >>= either (const $ fail "Unhandled exc") return
我们准备编码第一个解决方案,用于简单的非嵌套循环。其思想是将 'break' 和 'continue' 视为异常。毕竟,这两个控制运算符都会导致跳过计算,这正是异常所做的。我们定义了我们 'exceptions' 的数据类型:
> data BC = Break | Cont
>
> break, continue :: Monad m => ExT BC m a
> break = raise Break
> continue = raise Cont
这是循环的代码:它在某些点上捕捉异常:
> for_in :: Monad m => [a] -> (a -> ExT BC m ()) -> m ()
> for_in xs f = runExT $ mapM_ iter xs `catch` hbreak
> where
> iter x = catch (f x) hcont
> hcont Cont = return () -- handle Cont, re-raise Break
> hcont e = raise e
> hbreak Break = return ()
> hbreak Cont = return () -- Shouldn't happen actually
这是您的测试:
> loopLookForIt1 :: IO ()
> loopLookForIt1 =
> for_in [0..100] $ \x -> do
> when (x `mod` 3 == 1) $ continue
> when (x `div` 17 == 2) $ break
> lift $ print x
运行它:
> tf1 = loopLookForIt1 :: IO ()
打印从 0、2、3 开始,以 30、32、33 结束的 23 个数字。
我们必须推广到嵌套循环。显然有两种解决方案。我称第一种为 'dynamic'。我们通过级别(自然数)对异常进行索引。级别 0 适用于当前循环,级别 1 适用于父循环,依此类推。
> data BCN = BCN BC Int -- Add the level of breaking
现在,break 和 continue 运算符需要带上数字:中断多少个循环作用域。我认为 Perl 有类似的带数字中断操作符。
> breakN = raise . BCN Break
> continueN = raise . BCN Cont
新的迭代器:
> for_inN :: Monad m => [a] -> (a -> ExT BCN m ()) -> ExT BCN m ()
> for_inN xs f = mapM_ iter xs `catch` hbreak
> where
> iter x = catch (f x) hcont
> hcont (BCN Cont 0) = return () -- handle Cont, re-raise Break
> hcont e = raise e
> -- If the exception is for a parent, re-raise it, decrementing its level
> hbreak (BCN Break 0) = return ()
> hbreak (BCN Cont 0) = return () -- Shouldn't happen actually
> hbreak (BCN exc n) = raise (BCN exc (n-1))
现在单循环测试如下所示。
> loopLookForItN :: ExT BCN IO ()
> loopLookForItN =
> for_inN [0..100] $ \x -> do
> when (x `mod` 3 == 1) $ continueN 0
> when (x `div` 17 == 2) $ breakN 0
> lift $ print x
>
> tfN = runExT loopLookForItN :: IO ()
现在我们可以编写嵌套循环测试了。我稍作修改您文章中的示例,以涵盖所有情况:
> loopBreakOuter1 :: ExT BCN IO ()
> loopBreakOuter1 =
> for_inN [1,2,3] $ \x -> do
> lift $ print x
> for_inN [4,5,6] $ \y -> do
> lift $ print y
> when (y == 4) $ continueN 0
> when (x == 1) $ breakN 0
> when (x == 3) $ breakN 1
> when (y == 5) $ continueN 1
> breakN 1
> lift $ print x
>
> tbN1 = runExT loopBreakOuter1 :: IO ()
结果是数字序列:1 4 5 1 2 4 5 3 4 5
存在另一种解决嵌套循环问题的方法,我称之为 'static'。如果我们只是迭代单循环解决方案会怎样?我们可以将 ExT
BC
monad transformers 嵌套到任意给定的深度。要引用转换器堆栈中的特定层,我们使用 lift。我们可以使用之前定义的 for_in 迭代器和操作符 break、continue。我们将嵌套测试编写如下:
> loopBreakOuterS1 :: IO ()
> loopBreakOuterS1 =
> for_in [1,2,3] $ \x -> do
> liftIO $ print x
> for_in [4,5,6] $ \y -> do
> liftIO $ print y
> when (y == 4) $ continue
> when (x == 1) $ break
> when (x == 3) $ lift $ break
> when (y == 5) $ lift $ continue
> lift $ break
> liftIO $ print x
> tbS1 = loopBreakOuterS1 :: IO ()
我猜这里的教训可能是callCC
通常是不需要的(我会争辩说callCC
从来不需要,但那是另一个时间的论点)。这里是另一个简单异常足够的例子,而在那里人们认为需要call/cc
:
okmij.org/ftp/Computation/lem.html
新主题!:ezyang 的博客
新主题!
亲爱的忠实读者们:《Inside 206-105》有了新的主题!我要退休 Manifest 这个主题了,虽然它相当不错,但是(1)字体太小了,(2)我决定我实际上并不喜欢这些字体,所以我重新设计了我的博客主题,基于 Brent Jackson 的 Ashley,并移植到了 WordPress 上。希望你们喜欢这个新主题,请告诉我如果你们在老页面上发现了任何渲染问题。谢谢!
NLP:缺失的框架 : ezyang's 博客
你想要制作一个网络应用程序。在今天的世界里,有许多软件可以帮助你:你可以使用一个全能框架,或者使用库来处理模板、数据库访问、交互等常见需求。这些库统一了常见功能,并处理了你可能没有资源处理的边缘情况。
但有一个明显缺失的工具:自然语言处理(natural language processing)库。
“等等!” 你可能会说,“当然有 NLP 库,nltk 和 lingpipe 一直存在。” 当然,但你真的在使用这些库吗?“也许不是,但我的应用程序并不需要 NLP,你看。”
事实上,即使你没有意识到,你的应用程序中也在进行语言处理:“字符串连接”实际上只是自然语言生成的一种简单形式,它是 NLP 的一个子领域。[1] 如果你需要执行更复杂的任务,比如复数化名词、首字母大写句子或者改变动词的语法形式,你需要语言数据。[2] 这些数据是许多传统 NLP 任务的重要组成部分。然而,如果你今天需要复数化某些东西,你更可能从互联网上复制粘贴一些正则表达式列表,而不是想:“嗯,我应该安装一个 NLP 库。” 部分原因是,虽然 NLP 库确实包含这些数据,但它们的宣传并不充分。
此外,值得考虑的是,你的应用程序是否能从传统的 NLP 中受益,包括关键词生成、规范化(两个稍微不同写法是否相同?)、语言识别、全文搜索、自动完成、主题检测和聚类、内容摘要、解析人类编写的日期和位置等等。虽然很少有应用程序需要所有这些功能,但大多数应用程序会从中受益。例如,博客应用可能希望通过关键词生成来生成标签,通过全文搜索来搜索帖子,通过内容摘要来生成非全页视图的帖子,通过日期解析来安排帖子。然而,这些功能通常缺失,因为它们往往很难正确实现。现代方法通常需要在大型数据语料库上训练模型,这就是所谓的数据驱动模型。大多数情况下,这种设置成本似乎不值得;如果要实施该功能(例如作为扩展),一袋启发式算法可能更快速。
这两个问题都暗示了当前自然语言处理框架的问题:它们假设用户对构建自然语言处理系统感兴趣,而不是使用自然语言处理系统。我不应该需要计算语言学博士学位才能正确处理名词的复数形式或者强大地解析日期。我也不应该需要博士学位才能在传统、广为研究的自然语言处理应用中获得可以接受的结果。默认期望不应该是用户需要训练一个模型:现有的模型可以轻松地被重复使用。虽然自然语言处理算法在没有任何调整的情况下能达到的最佳水平有限,但是原则性方法仍然可以比启发式方法带来改进。但更重要的是,一旦使用了模型,希望改进结果的开发人员可以根据自己应用中的文本训练自己的模型,这可能涉及特定领域的术语和模式。库应该最初易于使用,并且足够原则性,以便成为进入计算语言学奇妙世界的引路人。当开发人员意识到自然语言处理是他们工具箱中的一个可访问工具时,谁知道还会出现什么其他应用呢?[3]
这是我的呼吁:我希望看到所有当前的“初级自然语言处理”功能汇集到一个地方,从而受益于共享的语言数据,并作为最初吸引开发人员的易于使用的特性。我希望看到更复杂但有用的自然语言处理技术变得更容易访问非语言学的受众。而且我希望所有这些都基于原则性的自然语言处理基础,以便能够改进现有的模型和算法。与 20 世纪 80 年代的非理性狂热相比,自然语言处理从业者通常非常谨慎,不会夸大他们的系统能力。这没关系:有时,标准确实如此低。
感谢 Gregory Price、Eric Kow 和 Richard Tibbetts 帮助审阅本文初稿。
[1] 作为一个领域,自然语言生成并不真正将字符串连接视为一种真正的方法;相反,它关注的是如何从功能性意图描述中生成文本。一个很好的例子是指称表达生成。
[2] 例如,功能(例如复数化规则在 MediaWiki 的language/
文件夹中收集。MediaWiki 是最国际化的开源项目之一,我发现它是了解外语中语言怪异之处的一个迷人来源。
[3] 举个例子,我想简要描述一下自然语言生成如何帮助应用国际化。假设您想让用户知道“您有三条新消息”。最明显的实现方式是:printf("You have %d new message(s).", numMessages)
。现在,这里采取了一些捷径:我们总是打印数字,而不是 AP 风格,后者在零到九之间的数字使用英语,并且我们绕过了“消息”是否应该加复数的问题,直接在末尾加上了一个(s)。
如果我们想处理这些情况,下一个显而易见的做法是添加一些新函数:我们将需要一个函数apnumber
将3
转换为three
,并且我们将需要一个函数pluralize
在numMessages
大于一时将message
转换为messages
。因此,您最终会得到类似这样的东西:printf("You have %s new %s", apnumber(numMessages), pluralize("message", numMessages))
。这是一种特定应用的方法,在英语上运行得相当好,但当您意识到其他语言存在名词-形容词一致性问题(“nouveau message”与“nouveaux messages”)时,会陷入困境。国际化框架长期以来已经认识到并提供了处理这些情况的机制;然而,平均基于英语的项目不太可能在国际化之前了解这些问题。
然而,存在一种与这些问题无关的表示形式。考虑一下这个句子的依赖语法,我们用一点 NLP 提取了它:
nsubj(have-2, You-1)
root(ROOT-0, have-2)
num(messages-5, three-3)
amod(messages-5, new-4)
dobj(have-2, messages-5)
我们可能会问,“在有这种形式的数据的情况下,我们是否可以自动生成一种适当的语言句子,传达信息并且语法正确?”这是一个相当困难的任务:这是自然语言生成的基本问题。 (这与机器翻译并不完全相同,因为我们可能需要用户添加关于功能意图的额外信息,否则从文本中提取这些信息将非常困难。)即使今天,我们希望有一个神奇的黑盒子能够产生结果句子,由 NLG 开发的工具也可能有助于减少翻译负担并增加灵活性。我认为这值得深入研究。
没有语法?没问题!:ezyang 的博客
有一天,当你漫步在代码的领域时,突然间你发现一个你不理解的语法结构。
也许你会问你的同事,他会立刻告诉你它是什么。
也许你的编程工具链可以告诉你。也许 IDE 会在鼠标悬停在构造上时告诉你,或者你正在使用 Coq,它允许你Locate
自定义表示。
也许你会拉起手册(或者更可能的是,众多的教程之一),并且扫描寻找所讨论的语法结构。
但是当所有这些都失败时,该怎么办呢?如果所讨论的代码是写在编译器的内部语言中的,并且其细节自从上次文档化以来就发生了变化,而文档又已经过时了呢?
没问题。 只要你愿意卷起袖子,查看相关编译器的源代码,你通常可以在比查阅手册更少的时间内解决你的问题(而且它保证是最新的!)。关键在于现代编译器都使用解析器生成器,而这些输入本质上是可执行的规范。
我将从 GHC 中给出两个例子。第一个来自 C--,GHC 的高级汇编语言。考虑这个函数:
INFO_TABLE_RET(stg_maskUninterruptiblezh_ret, RET_SMALL, W_ info_ptr)
return (P_ ret)
{
StgTSO_flags(CurrentTSO) =
%lobits32(
(TO_W_(StgTSO_flags(CurrentTSO))
| TSO_BLOCKEX)
& ~TSO_INTERRUPTIBLE
);
return (ret);
}
这个定义的一些方面对于之前写过 C 语言的人来说很熟悉,但还有一些神秘的部分。例如,在导言中return (P_ ret)
是什么意思?
首要任务是找到相关的文件。当所讨论的代码具有非常独特的关键字(就像这个例子一样)时,grep 通常可以解决问题:
ezyang@javelin:~/Dev/ghc-clean/rts$ grep -R INFO_TABLE_RET ../compiler/
../compiler/cmm/CmmParse.y:INFO_TABLE_RET ( label, FRAME_TYPE, info_ptr, field1, ..., fieldN )
../compiler/cmm/CmmParse.y: 'INFO_TABLE_RET'{ L _ (CmmT_INFO_TABLE_RET) }
../compiler/cmm/CmmParse.y: | 'INFO_TABLE_RET' '(' NAME ',' INT ')'
../compiler/cmm/CmmParse.y: | 'INFO_TABLE_RET' '(' NAME ',' INT ',' formals0 ')'
../compiler/cmm/CmmParse.y:-- is. That is, for an INFO_TABLE_RET we want the return convention,
../compiler/cmm/CmmLex.x: | CmmT_INFO_TABLE_RET
../compiler/cmm/CmmLex.x: ( "INFO_TABLE_RET", CmmT_INFO_TABLE_RET ),
文件扩展名也可能是明显的线索;GHC 使用一个名为 Happy 的解析器生成器,而 Happy 文件的文件扩展名是.y
:
ezyang@javelin:~/Dev/ghc-clean/rts$ find ../compiler -name *.y
../compiler/cmm/CmmParse.y
../compiler/parser/ParserCore.y
从这里,我们可以搜索文件中的关键字或符号(检查是否使用了字符串标记名称的词法分析器;还要确保引用了字母数字文本)。符号可能会出现在多个地方,就像return
一样:
maybe_conv :: { Convention }
: {- empty -} { NativeNodeCall }
| 'return' { NativeReturn }
以及:
stmt :: { CmmParse () }
: ';' { return () }
...
| 'goto' NAME ';'
{ do l <- lookupLabel $2; emit (mkBranch l) }
| 'return' '(' exprs0 ')' ';'
{ doReturn $3 }
根据产生式的名称和上下文猜测,maybe_conv
似乎是相关的产生式。它在这里使用:
cmmproc :: { CmmParse () }
: info maybe_conv maybe_formals maybe_body
{ do ((entry_ret_label, info, stk_formals, formals), agraph) <-
getCodeR $ loopDecls $ do {
(entry_ret_label, info, stk_formals) <- $1;
formals <- sequence (fromMaybe [] $3);
$4;
return (entry_ret_label, info, stk_formals, formals) }
let do_layout = isJust $3
code (emitProcWithStackFrame $2 info
entry_ret_label stk_formals formals agraph
do_layout ) }
现在,如果你真的需要准确了解它是如何布局的,你可以去查看emitProcWithStackFrame
是如何实现的。或者,你可能希望源文件中有一个有用的注释来解释这是什么:
A stack frame is written like this:
INFO_TABLE_RET ( label, FRAME_TYPE, info_ptr, field1, ..., fieldN )
return ( arg1, ..., argM )
{
... code ...
}
where field1 ... fieldN are the fields of the stack frame (with types)
arg1...argN are the values returned to the stack frame (with types).
The return values are assumed to be passed according to the
NativeReturn convention.
第二个例子是针对 STG 的,你可以要求 GHC 使用-ddump-stg
打印出来。现在,STG 没有解析器,所以你将不得不查看pretty-printer。这不太困难。看这个简单的函数:
Gnam.$WKST =
\r [tpl_sl4 tpl_sl6]
case tpl_sl4 of tpl_sl8 {
__DEFAULT ->
case tpl_sl6 of tpl_sl9 {
__DEFAULT -> Gnam.KST [tpl_sl8 tpl_sl9];
};
};
有些方面很熟悉。但\r
是什么意思呢?
再次,我们必须找到相关的源文件。由于只有在通过-ddump-stg
标志时才打印出 STG,追踪该标志通过源代码是一个好的开始:
ezyang@javelin:~/Dev/ghc-clean/compiler$ grep -R ddump-stg .
./main/DynFlags.hs: , Flag "ddump-stg" (setDumpFlag Opt_D_dump_stg)
ezyang@javelin:~/Dev/ghc-clean/compiler$ grep -R Opt_D_dump_stg .
./main/DynFlags.hs: | Opt_D_dump_stg
./main/DynFlags.hs: , Flag "ddump-stg" (setDumpFlag Opt_D_dump_stg)
./simplStg/SimplStg.lhs: ; dumpIfSet_dyn dflags Opt_D_dump_stg "STG syntax:"
这是一个好迹象!打开SimpleStg.lhs
给了我们:
; dumpIfSet_dyn dflags Opt_D_dump_stg "STG syntax:"
(pprStgBindings un_binds)
而pprStgBindings
的位置(compiler/stgSyn/StgSyn.lhs
)实际上就是关键。
STG 相当简单,事实证明,如果你只是快速浏览文件,你很可能会找到你需要的东西。但是如果没有找到,你仍然可以有意识地找出答案。假设我们搜索带引号的反斜杠:
pprStgExpr (StgLam bndrs body)
= sep [ char '\\' <+> ppr_list (map (pprBndr LambdaBind) bndrs)
<+> ptext (sLit "->"),
pprStgExpr body ]
where ppr_list = brackets . fsep . punctuate comma
...
-- general case
pprStgRhs (StgRhsClosure cc bi free_vars upd_flag srt args body)
= sdocWithDynFlags $ \dflags ->
hang (hsep [if gopt Opt_SccProfilingOn dflags then ppr cc else empty,
pp_binder_info bi,
ifPprDebug (brackets (interppSP free_vars)),
char '\\' <> ppr upd_flag, pprMaybeSRT srt, brackets (interppSP args)])
4 (ppr body)
这是什么?事实证明:
StgLam is used *only* during CoreToStg's work. Before CoreToStg has
finished it encodes (\x -> e) as (let f = \x -> e in f)
因为-ddump-stg
是在 CoreToSTG 之后,我们必须看看StgRhsClosure
,ppr upd_flag
看起来像是关键。r
必须是一个upd_flag
,不管那是什么。正如事实证明的那样,是UpdateFlag
:
data UpdateFlag = ReEntrant | Updatable | SingleEntry
instance Outputable UpdateFlag where
ppr u = char $ case u of
ReEntrant -> 'r'
Updatable -> 'u'
SingleEntry -> 's'
r
表示函数是可重入的!(当然,关于这是什么意思,你得查阅其他文档。)
当然,在理想的世界中,所有这些都将有文档记录。但即使没有,也没有理由不能自己帮助自己。如果你的代码库像 GHC 那样好,将会有很多线索和注释来帮助你。希望这能为你在遇到不熟悉的东西和不知道如何学习时的思考过程提供一些见解。(当然,有时最好还是忽略它!)
没有人预料到斯科特归纳!: ezyang’s 博客
来源:
blog.ezyang.com/2010/12/no-one-expects-the-scott-induction/
新来这个系列?请从最开始!开始。
递归可能是你学习函数式编程(或者说计算机科学,希望如此)时首先了解的概念之一。经典的例子是阶乘:
fact :: Int -> Int
fact 0 = 1 -- base case
fact n = n * fact (pred n) -- recursive case
自然数上的递归与自然数上的归纳密切相关,如这里解释的。
有趣的一点是,Haskell 中数据类型 Int
没有涉及到无穷大,因此这个定义在严格语言和惰性语言中都能完美工作。(记住 Int
是一个扁平的数据类型。)然而,请考虑一下,我们之前正在玩耍的 Omega
,它确实有一个无穷大!因此,我们还需要展示,当阶乘传入无穷大时也会产生一些合理的结果:它输出无穷大。幸运的是,阶乘的定义对于 Omega 类型也是完全相同的(鉴于适当的类型类)。但是它为什么有效呢?
一种操作性的回答是,程序的任何执行都只能处理有限数量:我们永远不能真正“看到”类型 Omega 的值是无穷大。因此,如果我们将一切都限制在某个大数之下(比如,我们计算机的 RAM),我们可以使用同样的推理技术来处理 Int
。然而,我希望你对这个答案感到深感不满:你想要将无限数据类型想象为无限的,即使实际上你永远也不会需要无穷大。这是自然和流畅的推理方式。事实证明,还有一个归纳原理与之对应:超越归纳。
自然数上的递归 - 归纳
Omega 上的递归 - 超越归纳
Omega 或许并不是一个非常有趣的数据类型,它具有无限的值,但在 Haskell 中有许多无限数据类型的例子,无限列表就是其中一个特别的例子。因此,实际上,我们可以将有限和无限情况推广到任意数据结构,如下所示:
在有限数据结构上的递归 - 结构归纳
无限数据结构上的递归 - 斯科特归纳
斯科特归纳是关键:有了它,我们有一个多功能的工具来推理惰性语言中递归函数的正确性。然而,它的定义可能有点难以理解:
让 D 是一个 cpo。D 的一个子集 S 如果是链闭的,则对于 D 中的所有链,如果链中的每个元素都在 S 中,则链的上确界也在 S 中。如果 D 是一个 domain,一个子集 S 如果既是链闭的又包含 bottom,则它是可接受的。斯科特的不动点归纳原理表明,要证明 fix(f) 在 S 中,我们只需证明对于 D 中的所有 d,如果 d 在 S 中,则 f(d) 也在 S 中。
当我第一次学习斯科特归纳时,我不明白为什么所有这些可接受性的东西是必要的:有人告诉我,这些是“使归纳原理生效所必需的东西”。最后,我也认同了这个观点,但是要在其全面性上看清楚还是有点困难。
因此,在本文中,我们将展示从自然数归纳到超限归纳的跃迁是如何对应从结构归纳到斯科特归纳的跃迁。
自然数归纳. 这是你在小学学到的归纳法,也许是最简单的归纳形式。简单来说,它规定如果某个性质对 n = 0 成立,并且如果某个性质对 n + 1 成立,那么它对所有自然数都成立。
把基本情况和归纳步骤看作推理规则的一种方式是:我们需要证明它们是正确的,如果它们是正确的,我们就得到了另一个推理规则,让我们能够避开无限应用归纳步骤来满足我们对所有自然数成立的性质的要求。(请注意,如果我们只想证明某个性质对一个任意自然数成立,那只需要有限次应用归纳步骤!)
Omega 上的超限归纳. 回忆一下,Omega 是自然数加上最小的无限序数 ω。假设我们想要证明某个性质对所有自然数以及无穷大都成立。如果我们仅仅使用自然数归纳,我们会注意到我们可以证明某个有限自然数具有某个性质,但未必对无穷大成立(例如,我们可能会得出每个自然数都有比它大的另一个数,但在 Omega 中大于无穷大的值却不存在)。
这意味着我们需要一个情况:如果一个性质对所有自然数成立,那么它也对 ω 成立。然后我们可以应用自然数归纳,并推断该性质对无穷大也成立。
在 Omega 上的超限归纳需要证明的情况比自然数归纳多得多,因此能够得出更强的结论。
旁白。 在其全面性中,我们可能有许多无限序数,因此第二种情况推广到继承者序数(例如添加 1),而第三种情况推广到极限序数(即,不能通过有限次应用继承者函数达到的序数——例如从零到无穷大)。这听起来熟悉吗?希望是的:这种极限的概念应该让你想起链的最小上界(事实上,ω 是域 Omega 中唯一非平凡链的最小上界)。
让我们再次看一下 Scott 归纳的定义:
让 D 是一个 cpo。D 的子集 S 是链闭的,当且仅当对于 D 中的所有链,如果链中的每个元素都在 S 中,则链的最小上界也在 S 中。如果 D 是一个域,子集 S 是可接受的,如果它是链闭的并且包含底部。Scott 的不动点归纳原理表明,要证明 fix(f) 在 S 中,我们只需证明对于所有 d 属于 D,如果 d 属于 S,则 f(d) 也属于 S。
现在我们可以找出与这个定义中的语句对应的超限归纳的部分。S 对应于具有我们想要展示属性的值的集合,因此 S = {d | d in D and prop(d)}
。基础情况 是底部包含在 S 中。继承者情况 是“如果 d 属于 S,则 f(d) 属于 S”(注意现在 f 是我们的继承者函数,而不是加一)。极限情况 对应于链闭条件。
这里是我们需要展示的所有推理规则!
我们用于证明阶乘在 Omega 上正确的域 D 是函数 Omega -> Omega
的域,继承者函数是 (Omega -> Omega) -> (Omega -> Omega)
,而子集 S 对应于阶乘不断定义版本的链。有了所有这些要素,我们可以看到 fix(f)
确实是我们要找的阶乘函数。
Scott 归纳法有许多有趣的“怪癖”。其中一个是这个属性必须对底部成立,这是一个部分正确性结果(“如果程序终止,则如此如此成立”),而不是一个完全正确性结果(“程序终止且如此如此成立”)。另一个是继承者情况通常不是涉及 Scott 归纳的证明中最困难的部分:显示属性的可接受性是。
这结束了我们关于指称语义的系列。这并不是完整的:通常接下来要看的是一个称为 PCF 的简单函数式编程语言,然后将这种语言的操作语义和指称语义联系起来。但即使你决定不想再听有关指称语义的更多内容,我希望这些对这个迷人世界的一瞥能帮助你在 Haskell 程序中思考惰性。
后记. 最初我想将所有这些归纳形式与 TAPL 中提出的广义归纳联系起来:归纳原理是单调函数 F : P(U) -> P(U)(这里 P(U) 表示宇宙的幂集)的最小不动点是 U 的所有 F-闭子集的交集。但这导致了一个非常有趣的情况,即函数的最大不动点需要接受值的集合,而不仅仅是单个值。我对此并不确定应该如何解释,所以我将其略过了。
无关的是,出于教学目的,也很好有一个由于错误(但似乎合理)应用斯科特归纳而产生的“悖论”。可惜,在我写作时,这样的例子让我无法捉摸。
显然正确:ezyang 的博客
什么是自动内存管理、静态类型和纯度的共同点?它们是利用我们可以通过视觉检查使程序在某种程度上显然正确(某种正确性的部分定义)的方法。使用自动内存管理的代码对于一类内存错误来说是显然正确的。使用静态类型的代码对于一类类型错误来说是显然正确的。使用纯度(无可变引用或副作用)的代码对于一类并发错误来说是显然正确的。当我利用这些技术中的任何一种时,我不必证明我的代码没有错误:它只是自动地是!
不幸的是,这里有一个限制。所有这些“显然正确”的方法论所要求你做的是在它们的祭坛上牺牲不同程度的表现力。不能再使用指针技巧了。不能再随意处理数据表示了。不能再有变异了。如果这种表现力实际上是大多数人并不真正想要的(比如内存管理),那么它很乐意被交换掉。但如果这是他们想要的东西,作为语言设计者,我们正在使他们做自己想做的事情更加困难,当他们拿起火把和干草叉攻击象牙塔时,对正确性和可维护性的断言就不应该让我们感到惊讶。
在我看来,我们必须以牙还牙:如果我们要剥夺功能,我们最好给他们提供引人入胜的新特性。使用静态类型,你还可以得到模式匹配、QuickCheck 风格的属性测试和性能优势。使用纯度,你会得到软件事务内存和推测性评估。发现和实现更多这样的“杀手级应用程序”是推广的关键。(我目前与亚当·克利帕拉进行的一些研究是利用纯度为 Web 应用程序提供自动缓存。这还不算什么,但我认为它朝着正确的方向发展。)
我对正确性依然有着狂热的虔诚。但是如今,我怀疑对于大多数人来说,这就像是一种苦药,需要加入一些更好口感的特性。那也没关系。作为编程语言研究者,我们的挑战在于利用正确性带来即时的实际好处,而不是稍后的模糊的可维护性好处。
感谢尼尔森·埃尔哈吉和基根·麦卡利斯特的评论。
附言:静态类型与动态类型的性能比较。 本文的早期草案指出了Quora 决定从 Python 转向 Scala作为这一事实的明确指示。不幸的是,正如几位预读者指出的那样,有太多混杂因素使得这一主张无法被确立:CPython 从未专门为性能而设计,而 JVM 则已经进行了几十年的工作。因此,我只能用更理论的论据来讨论静态类型的性能优化技术:动态编译器的即时编译器优化技术涉及识别实际上是静态类型的代码段,并将它们编译成静态编译器的形式。因此,如果你提前知道这些信息,你总是会比后来知道这些信息做得更好:这只是程度上的问题。(当然,这并不解决 JIT 可以识别静态编译器难以确定的信息的可能性。)
附言:共享事务内存。 乔·达菲在事务内存回顾中有一篇很棒的文章,介绍了他尝试为 Microsoft 的技术栈实现事务内存的经历。尽管对这个想法充满热情,但有趣的是注意到这样一句话:
在所有这些过程中,我们不断寻找并寻找杀手级 TM 应用程序。把这件事归咎于 TM 是不公平的,因为整个行业仍在寻找一个杀手级并发应用程序。但随着我们在后者中发现更多成功案例,我越来越不认为未来 5 年广泛部署的杀手级并发应用程序需要 TM。大多数享受自然隔离,如令人尴尬的并行图像处理应用程序。如果你需要共享,那么你就做错了。
理查德·蒂贝茨指出,并发通常是在比大多数工作程序员想处理的更低的架构层次上解决的,因此虽然 STM 对于这些平台来说是一个杀手级应用程序,但大多数开发人员根本不想考虑并发。
Haskell 程序员的 OCaml:ezyang 的博客
我开始正式学习 OCaml(我一直在阅读 ML,自 Okasaki 以来,但从未实际写过),这里是关于与 Haskell 不同的一些笔记,来源于 Jason Hickey 的《Objective Caml 简介》。最显著的两个区别是 OCaml 是不纯的和严格的。
特性. 这里是 OCaml 具有而 Haskell 没有的一些特性:
-
OCaml 有命名参数(
~x:i
绑定到命名参数x
的值i
,~x
是~x:x
的简写)。 -
OCaml 有可选参数(
?(x:i = default)
将i
绑定到带有默认值default
的可选命名参数x
)。 -
OCaml 有开放联合类型(
[> 'Integer of int | 'Real of float]
,其中类型保存了实现;你可以将其分配给具有type 'a number = [> 'Integer of int | 'Real of float] as a
的类型。匿名闭合联合类型也被允许([< 'Integer of int | 'Real of float]
)。 -
OCaml 有可变记录(在定义中用
mutable
作为字段的前缀,然后使用<-
运算符赋值)。 -
OCaml 有一个模块系统(今天只是简单提及)。
-
OCaml 有本地对象(本文未涵盖)。
语法. 省略意味着相关语言特性的工作方式相同(例如,let f x y = x + y
是相同的)
组织:
{- Haskell -}
(* OCaml *)
类型:
() Int Float Char String Bool (capitalized)
unit int float char string bool (lower case)
运算符:
== /= .&. .|. xor shiftL shiftR complement
= == != land lor lxor [la]sl [la]sr lnot
(在 Haskell 中,算术与逻辑移位取决于位的类型。)
OCaml 中的浮点运算符:用点号作为前缀(即+.
)
浮点数转换:
floor fromIntegral
int_of_float float_of_int
字符串操作符:
++ !!i
^ .[i] (note, string != char list)
复合类型:
(Int, Int) [Bool]
int * int bool list
列表:
x : [1, 2, 3]
x :: [1; 2; 3]
数据类型:
data Tree a = Node a (Tree a) (Tree a) | Leaf
type 'a tree = Node of 'a * 'a tree * 'a tree | Leaf;;
(请注意,在 OCaml 中,你需要 Node (v,l,r)
来匹配,尽管实际上并不存在这样的元组。)
记录:
data MyRecord = MyRecord { x :: Int, y :: Int }
type myrecord = { x : int; y : int };;
Field access:
x r
r.x
Functional update:
r { x = 2 }
{ r with x = 2 }
(OCaml 记录也支持破坏性更新。)
Maybe:
data Maybe a = Just a | Nothing
type 'a option = None | Some of 'a;;
数组:
readArray a i writeArray a i v
[|1; 3|] a.(i) a.(i) <- v
引用:
newIORef writeIORef readIORef
ref := !
顶层定义:
x = 1
let x = 1;;
Lambda:
\x y -> f y x
fun x y -> f y x
递归:
let f x = if x == 0 then 1 else x * f (x-1)
let rec f x = if x == 0 then 1 else x * f (x-1)
互递归(请注意,Haskell 中的let
始终是递归的):
let f x = g x
g x = f x
let rec f x = g x
and g x = f x
函数模式匹配:
let f 0 = 1
f 1 = 2
let f = function
| 0 -> 1
| 1 -> 2
(注意:你可以在 OCaml 的参数中放置模式匹配,但由于缺乏等式函数定义风格,这种方式并不实用)
Case:
case f x of
0 -> 1
y | y > 5 -> 2
y | y == 1 || y == 2 -> y
_ -> -1
match f x with
| 0 -> 1
| y when y > 5 -> 2
| (1 | 2) as y -> y
| _ -> -1
异常:
Definition
data MyException = MyException String
exception MyException of string;;
Throw exception
throw (MyException "error")
raise (MyException "error")
Catch exception
catch expr $ \e -> case e of
x -> result
try expr with
| x -> result
Assertion
assert (f == 1) expr
assert (f == 1); expr
Build:
ghc --make file.hs
ocamlopt -o file file.ml
运行:
runghc file.hs
ocaml file.ml
类型签名. Haskell 支持使用双冒号为表达式指定类型签名。OCaml 有两种指定类型的方式,可以内联进行:
let intEq (x : int) (y : int) : bool = ...
或者它们可以放置在接口文件中(扩展名为 mli
):
val intEq : int -> int -> bool
后一种方法更为推荐,类似于 GHC 支持的hs-boot
文件(http://www.haskell.org/ghc/docs/6.10.2/html/users_guide/separate-compilation.html#mutual-recursion)。
Eta 展开。 以'_a
形式的多态类型可以被视为类似于 Haskell 的单态化限制:它们只能被实例化为一个具体类型。然而,在 Haskell 中,单态化限制旨在避免用户不期望的额外重新计算值;在 OCaml 中,值限制要求在面对副作用时保持类型系统的完整性,并且也适用于函数(只需查找签名中的'_a
)。更根本地,'a
表示广义类型,而'_a
表示一个在此时未知的具体类型—在 Haskell 中,所有类型变量都是隐式地普遍量化的,因此前者始终成立(除非单态化限制介入,即使这时也不会显示任何类型变量给你看。但 OCaml 要求单态类型变量不会从编译单元中逃逸,因此存在一些相似性。这听起来没有意义吗?不要惊慌。)
在 Haskell 中,我们可以通过指定显式类型签名来使我们的单态值再次变成多态。在 OCaml 中,我们通过 eta 展开来泛化类型。典型的例子是 id
函数,当应用于自身 (id id
) 时,结果是一个类型为 '_a -> '_a
的函数(即受限制的)。我们可以通过编写 fun x -> id id x
来恢复 'a -> 'a
。
还有一个细微之处需要处理 OCaml 的不纯和严格性:eta 展开类似于一个延迟计算,因此如果你 eta 展开的表达式具有副作用,它们将被延迟执行。当然,你可以编写 fun () -> expr
来模拟一个经典的延迟计算。
尾递归。 在 Haskell 中,当计算是惰性时,你不必担心尾递归;相反,你要努力将计算放入数据结构中,以便用户不会强制获取比他们所需更多的计算(受限递归),并且“堆栈帧”在你深入模式匹配结构时会被高兴地丢弃。然而,如果你正在实现像foldl'
这样的严格函数,你需要注意这一点(并且不要建立一个非常大的延迟计算。)
好吧,OCaml 默认是严格的,所以你总是要注意确保有尾调用。一个有趣的地方是在 map 的实现中,其中的简单版本无法进行尾调用优化。在 Haskell 中,这不是问题,因为我们的 map 是惰性的,并且递归隐藏在 cons 构造函数中;在 OCaml 中,存在一个权衡:复制整个列表以实现尾调用优化,或者不复制并在处理大列表时可能耗尽堆栈空间。(注意,Haskell 中的严格 map 函数也会遇到相同的问题;这是惰性和严格性之间的区别,而不是 Haskell 和 OCaml 的区别。)
文件组织。 单个文件的 OCaml 脚本包含一系列按顺序执行的语句(没有 main
函数)。
Haskell 模块的道德等价物在 OCaml 中被称为编译单元,命名约定为foo.ml
(小写!)对应于Foo
模块,或者Foo.foo
指的是Foo
中的foo
函数。
按照上述描述编写接口文件mli
被认为是一种良好的实践;这些文件类似于导出列表。接口文件还将包含数据定义(构造函数被省略以实现隐藏)。
默认情况下,所有模块都像import qualified Foo
一样自动“导入”(无需导入列表)。在 OCaml 中,可以通过open Foo
进行传统的import Foo
风格的导入(以便可以不加限定地使用名称)。
模块系统。 OCaml 没有类型类,但它有模块系统,您可以通过它们实现相似的效果。 (获得类型类风格效果的另一种经典方法是使用对象,但我今天不涵盖它们。)我本来想今天讨论这个问题,但这篇文章变得很长,所以也许我会把它留到另一天。
开放问题。 我不确定这些内容在 OCaml 特定情况下有多少通用性,以及它们如何推广到所有 ML 语言。
更新。 ocamlrun 并不同于 runghc;我已经相应地更新了文章。
更新 2。 Raphael Poss 写了一篇反向的好文章:Haskell for OCaml programmers
OCaml 的陷阱:ezyang 的博客
OCaml 的陷阱
我花了一些时间完善了我的 count min sketch 在 OCaml 中的实现(将成为另一篇博客文章的主题),在此过程中,我注意到了 OCaml 语言的一些怪异之处(从 Haskell 的视角来看)。
-
不像 Haskell 的
Int
,它可以是 32 位或 64 位,内置的 OCamlint
类型只能是 31 位或 63 位。位操作者要小心!(有一个nativeint
类型可以提供完整的机器精度,但效率比int
类型低。) -
分号与 Haskell do 块的“可编程分号”有很大不同的优先级。特别是:
let rec repeat n thunk = if n == 0 then () else thunk (); repeat (n-1) thunk
不像 Haskell 中类似表达的预期,OCaml 并不会做出相同的事情。(听说我应该使用
begin
和end
。) -
甚至在 64 位平台上,从 Random 模块获取的随机数只能获得 30 位(使用 Random.bits 获得正整数),因此您必须手动将多次调用生成器拼接在一起。
-
我不喜欢缩进的“军队阶梯”,所以我把我的
in
放在它们的语句之后 —— 但是,当它们放在那里时,它们很容易被忘记(因为在 Haskell 中,do 块中的 let 语句不需要 in)。 -
关键字参数非常有用,但它们会使类型系统有些混乱,并且在高阶上下文中使关键字函数和非关键字函数的互操作变得更加困难。 (特别是当您仅出于文档目的使用关键字参数时,而不是因为函数接受两个整数并且您确实需要消除歧义。)
关于纯度和随机性的一个观察:我认为在 Haskell 中经常让人感到烦恼的一件事是,随机性涉及状态的变化,因此必须包装在单子中。这使得构建概率数据结构有些笨拙,因为您不能再公开纯接口。OCaml 不是纯的,因此您可以随时查询随机数生成器。
不过,在某些情况下,我认为 Haskell 可能会在最后一笑。特别是,如果您使用随机数生成器为代码生成随机测试案例,您需要能够再现特定的随机测试集。通常,通过提供一个种子来完成此操作,然后将其提供给测试脚本,以实现确定性行为。但是因为 OCaml 的随机数生成器操作全局状态,很容易在请求与其他不相关的内容的随机数时意外地破坏确定性。您可以通过手动括起全局状态来解决此问题,但显式处理随机状态意味着提供确定性更加自然。
Of Monadic Fixpoints and Heap Offsets : ezyang’s blog
来源:
blog.ezyang.com/2013/09/of-monadic-fixpoints-and-heap-offsets/
在 ICFP,有时所谓的“走廊轨道”有时比普通轨道还要重要。Johan Tibell 希望在 GHC 中避免对 allocate
函数的非行内调用,当静态已知大小的小数组被分配时。但他发现 GHC 的新代码生成器处理堆分配的方式有点令人困惑,所以今天我们中有人放弃了一场会议来解决这个问题。在这篇文章中,我想解释一下代码生成单子如何通过一种有趣(同时也有点烦人)的技巧来计算代码中的堆偏移量,这涉及到一个“单子”修复点的方式。
首先,关于代码生成器的一些背景。 GHC 需要为其生成代码的函数的大致模式如下:
-
检查是否有足够的堆空间,如果没有则进行垃圾回收,
-
将一堆数据写入堆,
-
将一些东西推到栈上,
-
跳转到适当的继续。
具体来说,代码将是这样的:
c2EP:
_s28e::P64 = R2;
// Check if there is enough heap space
Hp = Hp + 40;
if (Hp > HpLim) goto c2ET; else goto c2ES;
c2ET:
// If not enough space, GC
HpAlloc = 40;
R2 = _s28e::P64;
R1 = withEmpty_riC_static_closure;
call (stg_gc_fun)(R2, R1) args: 8, res: 0, upd: 8;
c2ES:
// Write a bunch of data to the heap
I64[Hp - 32] = sat_s28f_info;
_c2EG::P64 = Hp - 32;
I64[Hp - 16] = :_con_info;
P64[Hp - 8] = _c2EG::P64;
P64[Hp] = _s28e::P64;
_c2EO::P64 = Hp - 14;
R1 = _c2EO::P64;
// No stack updates this time
// Jump to the continuation
call (P64[Sp])(R1) args: 8, res: 0, upd: 8;
这看起来是合理的,但如何实际生成这段代码呢?代码是按顺序生成的,但在完成布局其余代码之前,我们并不知道需要检查多少堆。如果我们戴上突变帽子,我们可能会说:“好吧,暂时略过它,等你知道实际值时再进行突变”,但是仍然有一个棘手的问题,即当我们向堆写入值时偏移量应该是多少。请注意,在上面的代码中,我们只增加了堆指针一次;如果我们反复增加堆指针,那么偏移量就很容易计算,但我们会浪费指令;x86 寻址模式支持直接将写入寄存器加上一些偏移量。
让我们看看当 GHC 将动态闭包分配到堆时的操作(简化版):
allocDynClosureCmm info_tbl args_offsets
= do { virt_hp <- getVirtHp
; let rep = cit_rep info_tbl -- cit = c info table
info_offset = virt_hp + 1 -- virtual heap offset of first word of new object
info_ptr = CmmLit (CmmLabel (cit_lbl info_tbl))
; base <- getHpRelOffset (virt_hp + 1)
; emitSetDynHdr base info_ptr
; let (args, offsets) = unzip args_offsets
; hpStore base args offsets
; setVirtHp (virt_hp + heapClosureSize rep)
; getHpRelOffset info_offset
}
换句话说,它:
-
检索一个“虚拟堆指针”(稍后详细介绍),
-
使用虚拟堆指针 (
getHpRelOffset
,注意偏差一个单位) 获取真正的Hp - n
表达式 (base
), -
发出一系列写入到
base
内存的操作(emitSetDynHdr
和hpStore
), -
将虚拟 Hp 的位置上升到刚刚分配的闭包的大小,
-
返回
Hp - n
表达式。
正如事实证明的那样,虚拟堆指针只是代码生成单子 FCode
中的普通状态变量(查看您正在使用的单子的实现是件好事!):
newtype FCode a = FCode (CgInfoDownwards -> CgState -> (# a, CgState #))
data CgState
= MkCgState { ...
cgs_hp_usg :: HeapUsage,
... }
data HeapUsage =
HeapUsage {
virtHp :: VirtualHpOffset, -- Virtual offset of highest-allocated word
-- Incremented whenever we allocate
realHp :: VirtualHpOffset -- realHp: Virtual offset of real heap ptr
-- Used in instruction addressing modes
}
因此,virtHp
只需在我们分配东西时向上移动;实际上,它是我们低效的重新增加实现中 Hp
寄存器的内容。
这留给我们一个紧迫的问题,realHp
是什么?嗯,它最初是零(因为真实堆指针的偏移量只是零),但一旦我们推动堆指针进行栈检查,它现在恰好是我们进行堆检查的堆量。回顾我们的例子:
c2EP:
_s28e::P64 = R2;
// Check if there is enough heap space
// virtHp = 0; realHp = 0
Hp = Hp + 40;
// virtHp = 0; realHp = 40
if (Hp > HpLim) goto c2ET; else goto c2ES;
c2ET:
// If not enough space, GC
HpAlloc = 40;
R2 = _s28e::P64;
R1 = withEmpty_riC_static_closure;
call (stg_gc_fun)(R2, R1) args: 8, res: 0, upd: 8;
c2ES:
// Write a bunch of data to the heap
// First closure
// virtHp = 0; realHp = 40
I64[Hp - 32] = sat_s28f_info;
_c2EG::P64 = Hp - 32;
// virtHp = 8; realHp = 40
I64[Hp - 16] = :_con_info;
P64[Hp - 8] = _c2EG::P64;
P64[Hp] = _s28e::P64;
_c2EO::P64 = Hp - 14;
// virtHp = 32; realHp = 40
R1 = _c2EO::P64;
// No stack updates this time
// Jump to the continuation
call (P64[Sp])(R1) args: 8, res: 0, upd: 8;
(实际上,内部偏移量记录为单词,所以在这个 64 位代码中,一切都要除以八。顺便说一句,virtHp + 8 == realHp,这就是偏差为一的原因。)数学有点复杂,但 getHpRelOffset
会为你计算偏移量;你只需确保虚拟偏移量正确即可!
好的,但我们仍然没有弄清楚最初这个神奇数字 40 是从哪里来的!关键是要看负责堆检查的代码生成器 heapCheck
,它包裹了对 code
的调用,后者实际上负责代码生成:
heapCheck :: Bool -> Bool -> CmmAGraph -> FCode a -> FCode a
heapCheck checkStack checkYield do_gc code
= getHeapUsage $ \ hpHw ->
嘿,那个神奇的 getHeapUsage
函数是什么?
-- 'getHeapUsage' applies a function to the amount of heap that it uses.
-- It initialises the heap usage to zeros, and passes on an unchanged
-- heap usage.
--
-- It is usually a prelude to performing a GC check, so everything must
-- be in a tidy and consistent state.
--
-- Note the slightly subtle fixed point behaviour needed here
getHeapUsage :: (VirtualHpOffset -> FCode a) -> FCode a
getHeapUsage fcode
= do { info_down <- getInfoDown
; state <- getState
; let fstate_in = state { cgs_hp_usg = initHpUsage }
(r, fstate_out) = doFCode (fcode hp_hw) info_down fstate_in
hp_hw = heapHWM (cgs_hp_usg fstate_out) -- Loop here!
; setState $ fstate_out { cgs_hp_usg = cgs_hp_usg state }
; return r }
在这里,我们看到了单子的不动点。为了将堆使用情况提供给 fcode
,GHC 为自己编写了一个检查:hp_hw
。检查借鉴了生成 fcode
的结果,并附加的字符串是:“只要在生成代码之前不兑现这个检查,一切都会没问题!”(在某种程度上有点像一个大银行。)可爱—我们只需要进行一次代码生成!
这种技术并非没有其阴暗面。hp_hw
是危险的;如果你在错误的地方强制它,你将陷入无限循环。这个变量有两个用途,都在 compiler/codeGen/StgCmmLayout.hs
中,它们都小心地不强制使用它。如果能够显式地将 hp_hw
标记为黑洞,并附加自定义错误消息,以便在无限循环发生时发出,那将是非常好的。如何实现这一点留给读者作为练习。
顺便提一句,在你还不知道的情况下,我一直在实时转发 ICFP 的报道,链接在这里 — 虽然报道并非百分之百完整,编辑也比较草率,但你可以看看!
OfflineIMAP 很糟糕:ezyang 的博客
我将向你分享一个小秘密,只有使用和改进 OfflineIMAP 的人才能合理地了解:OfflineIMAP 很糟糕。当然,你仍然可以使用糟糕的软件(我经常这样做),但了解它的一些缺陷是很有用的,这样你可以决定是否愿意忍受它的糟糕表现。那么为什么 OfflineIMAP 糟糕呢?
这并不是一个真正有建设性的帖子。如果我真的想要有建设性,我会去修复所有这些问题。但所有这些都是需要大量精力才能解决的大问题... 不幸的是,我并不够在意这个软件。
项目健康状况不佳
最初的作者,John Goerzen,已经转向更“绿色”的、更像 Haskell 的牧场,而当前的维护团队在找到足够时间和专业知识来进行适当的软件维护方面有困难。这里是最近一次维护者招募的通知,因为两位共同维护者在正确跟踪所有提交的补丁方面都没有足够的空闲时间。似乎仍有足够多的人对让 OfflineIMAP 不陷入过时感兴趣,所以这个项目在可预见的未来应该会继续运作,但不应期望在代码库上进行任何重大新功能或密集工作。
几乎没有测试
OfflineIMAP 大部分历史上都没有测试。尽管现在有一个小小的测试套件,但它远远不够覆盖这样一个数据关键性程序所需的范围。开发者修复 OfflineIMAP 中的错误时,并不习惯添加新的回归测试。但或许更为恶劣的是,没有基础设施能够测试 OfflineIMAP 与尽可能多的 IMAP 服务器进行兼容性测试。这里才是真正会出现严重错误的地方,但这个项目却没有相关的基础设施。
对 UID 的过度依赖
OfflineIMAP 将 UID 作为确定两个消息是否对应的唯一依据。这在大多数情况下工作得很好,但也有例外。一旦出现问题,你将面临严重的后果。OfflineIMAP 不支持使用Message-Id
头部或文件的校验和进行一致性检查,并且针对不支持UIDPLUS
的服务器的X-OfflineIMAP
补丁应该被淘汰。但它确实通过积累了大多数特例来使其在所有出现 UID 问题的怪异情况下正常工作。
空间复杂度差
OfflineIMAP 的内存使用量与收件箱中消息的数量成正比。对于大邮箱来说,这实际上意味着将数十万个元素加载到集合中并对其进行昂贵的操作(当我运行它时,OfflineIMAP 始终占用我的 CPU)。OfflineIMAP 应该能够在常量空间内运行,但在这个问题空间中没有考虑算法思想。它还有一个极其愚蠢的默认状态文件夹实现(认为每次上传文件时都重复写入 100MB 到磁盘),尽管通过设置 status_backend = sqlite
可以相对容易地解决这个问题。为什么它不是默认的?因为它仍然是实验性的。嗯...
未优化的关键路径
OfflineIMAP 从来没有真正被设计成为速度快的工具。即使在没有任何更改或只下载少量消息的普通情况下,同步所需的时间也很长。如果一个人的目标是尽快下载新消息,可以做很多调整,包括减少 IMAP 命令的数量(特别是冗余的选择和清除),减少对文件系统的访问次数,异步文件系统访问,不将下载的消息完整加载到内存中等等。一个推论是,OfflineIMAP 似乎并不真正了解它可以丢失哪些数据,以及在继续下一个操作之前必须执行 fsync 的数据:"安全"操作只是随意地散布在代码中,没有明确定义的纪律。哦,还有 inotify 怎么样?
脑残的 IMAP 库
好吧,这个问题并不完全是 OfflineIMAP 的错,但是 imaplib2
实际上根本没有保护你免受 IMAP 协议(以及实际实现中如何实现)的尖锐边缘。你必须自己做所有的事情。这很愚蠢,当你忘记在写新的 IMAP 代码时检查 UIDVALIDITY 时,这将是一场灾难的食谱。此外,它几乎没有对 IMAP RFC 中关于命令响应的知识进行编码。在这里,更多的类型安全性确实会很有用:它将有助于强制人们考虑处理任何给定命令时的所有错误情况和所有可能发生的数据。
算法黑暗
OfflineIMAP 在其核心算法中有大量的调试输出和 UI 更新代码交织在一起,总体效果是很难判断所使用的算法的整体形状。如果算法比较微妙,并依赖于整个执行过程的一些全局属性来确保其正确性,这是不好的。有太多样板代码。
结论
总结来说,如果你想在一个表现良好、流行的、开源的 IMAP 服务器上使用 OfflineIMAP,其中一个维护者也偶尔使用它,并且你的收件箱中的消息数量相对较少,并且愿意接受 OfflineIMAP 作为一个不可修改的黑匣子,以一种完全神秘的方式同步,而且永远不想对 OfflineIMAP 进行修改,那么没有比这更好的选择了。对于其他所有人,嗯,祝你好运!也许对你来说会有所帮助!(对我来说大部分情况下都是如此。)
无所不在的 Cabal:ezyang 的博客
无所不在的 Cabal
一个简短的公共服务声明:你可能认为你不需要 Cabal。哦,你可能只是在编写一个小型的临时脚本或一个你根本不打算分发的小应用程序。Cabal?那不是你打算将包发布到 Hackage 上时才会做的事情吗? 但是 Cabal 总是知道的。Cabal 总是在那里。即使你认为自己不重要,你也应该拥抱 Cabal。以下是为什么:
-
编写一个
cabal
文件会迫使你记录在最初编写脚本时使用的模块和版本。如果你决定在另一个环境中运行或构建你的脚本,cabal 文件将大大简化获取依赖项并快速运行的过程。如果你更新了模块,cabal 文件将在一定程度上保护你免受 API 更改的影响(假设包遵循Hackage 的 PVP)。这比 GHC 的包限定导入要好得多。 -
你可能对编写
Makefile
或ant
文件来构建你的项目感到不爽;只要是一个或两个文件,与这些构建语言相关的痛苦似乎比运行gcc foo.c -o foo
还要大。编写 Cabal 文件非常简单。甚至有一个cabal init来为你完成脚手架工作。抛弃你一直用来运行ghc --make
的微小 shell 脚本,改用cabal configure && cabal build
。 -
它可以免费为你提供很多好东西!你想要 Haddock 文档吗?传统的 GNU 风格的 Makefile?代码着色?Cabal 都可以为你做到,而且在编写完
cabal
文件后,只需要很少的努力。
关于模仿与黑客技术:ezyang 的博客
关于模仿与黑客技术
两个激进的小插曲
模仿这个术语带有贬义:它表明了在不理解背后因果结构的情况下,仅仅模仿表面的外观。暗示是在做某事之前应该理解正在做的事情。然而,模仿的做法中确实有一分真理:当你处于一个你确实不知道发生什么事情的情境中(例如实验的背景),最安全的做法是尽可能保留许多表面特征,以防一个“表面”特征实际上与正在研究的系统有深刻而不明显的联系。但在这方面,有益的“模仿”与岛民们建造飞机跑道以吸引飞机的做法完全不同——了解哪些条件适用于这种处理往往是经验的标志:新手会忽略应该保留的条件,并不知道如何深入探索。
黑客是偶然泛化的艺术。它是在一组特定条件下(一个硬编码的测试输入,一个特定的目录结构,一个单一的 URL)开发程序,并(也许)希望它在更一般的情况下也能工作。任何妨碍特异性的东西——证明,类型,安全性,冗长,边缘情况,思考——对于纯粹的创作来说都是敌人。它是当下的艺术,从中可以获得很多利润和乐趣。它是激光精度的艺术,每个问题都是随着其到来而处理。这是一门随着经验而变得更为可接受的工程实践的艺术:一个人会培养出一些小的内部审查员,他们会不断地提出心理标志,指出你需要额外付出一点努力才能使泛化工作。建议新手们带上他们的检查表。
对受检异常和证明义务的看法:ezyang's 博客
来源:
blog.ezyang.com/2011/02/on-checked-exceptions-and-proof-obligations/
Java 中的受检异常是一个非常受批评的特性,尽管理论上它应该是一个非常好的主意。其中的张力在于这两种推理之间:
精心编写的程序处理所有可能的边缘情况,如果可能的话,对其进行处理,并在无法处理时优雅地失败。很难跟踪所有可能的异常,因此我们应该让编译器帮助我们,提醒我们是否有我们忘记处理的边缘情况。因此,受检异常提供了一种机制,确保我们已经处理了所有边缘情况。
and
频繁检查的异常是我们在接近错误位置无法合理恢复的错误条件。将受检异常通过所有中介代码传递需要每一层了解其所有异常。受检异常的心理设计鼓励开发人员不负责任地吞下异常。受检异常在大量代码中不可扩展。
在这篇文章中,我提出了另一种管理受检异常的方法:证明代码不可能抛出这样的异常。
“证明代码不可能抛出异常?”你可能会说。“不可能!毕竟,大多数受检异常来自外部世界,我们肯定不能预测将会发生什么。恶魔可能会选择最坏的情况并将其输入到我们的代码中。”
对怀疑论者的第一个回答是确实存在完全确定性发生的受检异常的例子,并且可以证明不会抛出。例如,考虑 Java 反射 API 中的这段代码:
Object o, Field f; // defined elsewhere
f.setAccessible(true);
f.get(o);
最后一次调用可能会抛出受检异常IllegalAccessException
,但假设setAccessible
调用未失败(在多种条件下可能失败),这个异常不会发生!因此,实际上,即使它确实抛出了IllegalAccessException
,它也违反了我们程序员对 API 应该执行的期望,而一个漂亮的运行时错误会让我们注意到发生了什么。setAccessible
的调用解除了对IllegalAccessException
情况的证明义务。
但这可能只是在一个以 IO 为主的检查异常世界中的一个边缘案例。所以我对质疑者的第二个回答是,当我们编写与外部世界交互的代码时,我们通常并不假设恶魔会给我们提供最糟糕的输入数据。(也许我们应该假设!)我们有自己内部的模型来预测这些交互可能如何工作,如果写一些快速而粗糙的东西,假设交互会按照某种方式进行可能会很方便。因此,一旦我们编写了所有的验证代码以确保这确实如此(如果不是的话,抛出类似于失败的断言的运行时异常),我们再次可以假设静态知识可以解除我们的证明义务。是的,在某种程度上这是一种逃避,因为我们没有证明任何东西,只是告诉编译器,“我知道我在做什么”,但关键的额外因素是,一旦我们建立了我们的假设,我们就可以用它们来证明事实,而且只需要在运行时检查我们的假设。
当然,Java 不会很快引入依赖类型,所以这都是一场相当理论性的讨论。但是检查异常,就像类型一样,是形式化方法的一种形式,即使你不是用依赖类型语言编写你的应用程序,该领域能够为你的应用程序的基本结构提供有用的见解。
资源
我在听康纳·麦克布赖德(Conor McBride)关于《命运的荒谬箭头》的讲座时,突然间对检查异常与证明之间的对应关系有了一些想法。希望能尽快撰写关于这次讲座的总结;它阐明了我一直在思考的一些关于会话类型的问题。
在描述现有的 Java 检查异常观点时,我参考了以下文章。
关于表达力:ezyang 的博客
在这篇文章中,我要取笑函数式编程的倡导者。
在这篇文章中,我想讨论“命令式编程”和“函数式编程”在语言特性上的意识形态,特别是它们允许开发人员用更少代码表达自己的机制。我认为构成命令式编程的一组特性形成了部分与函数式编程的受欢迎特性不兼容的主导编程单一文化,需要函数式编程的倡导者采取一些有趣的方式来吸引程序员的注意。
首先要尝试表达性,这里是一些经常见到的增加表达力的语言特性:
-
宏
-
并发性
-
变异
-
间接引用
-
惰性
-
动态类型
-
多态性
-
高阶函数
-
异常
-
Eval
-
输入/输出
-
续体
-
匿名函数
其中一些条目可能会让你笑,因为你可能不明白没有它们你怎么能编程。你可能会认出一些你喜欢的语言良好支持的特性,一些你的语言支持不太好的特性,以及一些你的语言根本不支持的特性。关于语言的文化也会有关于哪些特性可以使用、哪些特性不可以使用的传说(想想 Pythonic 或JavaScript: The Good Parts)。你选择的语言决定了你能够熟悉哪些特性。
表达力是有代价的,大多数开发人员在领域经验稍微积累后便能体会到。这里存在一种自然选择:语言特性如果被广泛支持、其他程序员知晓如何使用,并且能够完成工作,那么它们就会受到青睐——特别是社区效应加强了这些优势者。因此,我们有了开发者单一文化,这种文化大多数人对于变异、输入/输出、间接引用、异常、多态性等都感到舒适。但是,即使是当前编程实践的基本操作也不是没有代价的:想想“懂得指针和不懂得指针的人”之间的分歧,或者在 C++中使用异常时的运行时成本,以及多态性的表示复杂性(例如 Java 中的自动装箱和 Go 中的缺失)。
当有人进行函数式编程倡导时,他们实际上是在要求你更加仔细地审视我们增强表达力的其他机制。你可能会感觉到这些是你唯一听到的声音,因为提倡已被所有人使用的事物没什么意义。你可能会觉得热情不合理,因为这个特性似乎异常复杂(续延,有人吗?)或者你曾尝试在你喜欢的语言中使用它,没有什么比看到有人试图在 Python 中进行函数式编程更痛苦的了。事实上,向现有的单一文化中添加这些额外的语言特性并不容易。新特性之间的互动非常复杂和微妙。
这就是为什么函数式编程倡导者经常会要求你放弃一些你旧有的表达工具。他们会要求你放弃共享状态的变异,因为否则处理并发真的非常困难。他们会要求你放弃动态类型,因为否则高阶函数会变得更难以理解。 retic 会趋于“停止这样做!”,因为这是常见的实践-他们实际上不是要完全停下来,但对于功能性编程
我鼓励程序员尽可能多地了解表达自己的方式,即使他们的语言或工作场所不一定允许他们使用这种方法。原因多种多样:
-
“任何足够复杂的程序最终都包含了一段糟糕的 Lisp 实现。” 不管你喜不喜欢,最终你将面对一个通过这些深入研究的语言特性轻松解决的难题,如果你必须手工实现它,最好从一开始就知道它会是什么样子。正如四人组曾经说过的那样,不实际支持语言的特性通常会表现为设计模式;了解这些模式会使你的代码更清晰、更干净。
-
相反,如果你在阅读别人的代码时,他们倾向于使用其中的某个模式,知道这个特性应该如何工作将极大地帮助理解。
-
库和框架被认为是开发者工具箱中必不可少的部分,然而它们似乎以令人眩晕的速度增长并过时。语言特性则是永恒的:1936 年(当阿隆佐·丘奇发明λ演算时),匿名函数至今仍然是匿名函数。
-
学习语言特性是有趣的!与“又一个要记忆的 API”不同,语言特性会激发你的思维,使你对发生的事情非常认真地思考。
tl;dr 某些编程语言特性增强了开发者的表达能力,而“命令式编程方法论”则涵盖了广泛使用的具有这些语言特性的主导单一文化。但也有其他表达方式,鼓励程序员探索这些方法,即使实际使用时,需要停止使用一些他们喜爱的表达工具。
关于类型同义词:ezyang 的博客
关于类型同义词
我最近不得不从 GHC 代码库中移除了一些类型同义词,例如 type CmmActuals = [CmmActual]
。这个过程让我对 Haskell 代码中适合使用类型同义词的时机产生了一些思考。Wikibooks 的文章 表示类型同义词是用来“使类型的角色更加清晰或者为复杂的列表或元组类型提供一个别名”,而 Learn You a Haskell 则说它们“使得代码和文档对于读者更加易于理解”。但是真正的情况是在什么情况下呢?
让我们尝试将以下类型同义词的用例进行分类:
-
它们可以提供额外的语义内容,例如
DateString
比String
更详尽地描述其内容,尽管它们实际上是相同的。 -
它们可以缩写长构造类型,例如
TcSigFun
可以缩写Name -> Maybe TcSigInfo
。
第一个例子展示了代码阅读者的好处:带有额外语义信息的类型使得理解函数的操作更加容易;第二个例子展示了代码编写者的好处:长类型的缩写使得编写类型签名更加愉快。有时候,类型同义词可以同时提供这两种好处。
类型签名的缺点在于其实现的不透明性。看到一个类型为 Address
的值,我不知道它是一个代数数据类型还是一个类型同义词,而如果它是一个 String
的话,我会立即知道可以在其上使用哪些函数。类型同义词增加了一个额外的间接层,以便弄清如何操作该值:因此,这对于编写者来说是一个缺点。当然,代数数据类型和新类型也会增加一个间接层,但它们也带来了类型安全性,而类型同义词则不会。 (此外,一个代数数据类型通常在自我文档中是非常出色的,因为它的每个构造函数都有自己的名称。)
我认为我的见解如下:
-
如果类型同义词没有提供除了类型结构之外的额外语义信息,就不要使用它们。
-
对于原子类型的同义词可以自由使用,如果对应关系是唯一的。如果有多个同义词引用同一个原子类型,考虑使用新类型(newtypes)。
-
非函数复合类型的同义词应该谨慎使用。它们不应该泄露到模块边界之外,并且适合提升为代数数据类型。
-
对于函数复合类型的同义词,大多数情况下是可以接受的(因为将其转换为代数数据类型并不会带来太多好处,并且它们不太可能混淆),但一定要确保有适当的文档记录。
-
更倾向于保持类型同义词在模块边界内部,不导出。(尽管我知道一些例外情况,我也打破了这个规则。)
你如何看待类型同义词?
在线/离线连续集成:ezyang 的博客
来源:
blog.ezyang.com/2018/03/online-offline-continuous-integration/
如果您在连续集成脚本中使用过这些命令,请举手:
-
apt install somepackage
-
pip install -r requirements.txt
或pip install somepkg
-
conda install blah
-
cabal update
或cabal install blah
-
git clone https://github.com/someguy/somerepo
-
wget http://some-website/thingy-latest.tgz
您能说出问题在哪里吗?这些命令不可再现:取决于运行时机,它们可能会产生不同的结果。更隐蔽的是,大多数情况下它们给出的结果相同(或许对您的用例仍然有效的不同结果)。
我知道,我们需要一个可再现的构建! 工具作者对此问题的主要回应是夺取生产资料并用可再现的东西替换它。如果您生活在 npm/yarn 生态系统中,锁定文件确保每次构建时所有依赖项都以相同的方式重新下载(除非不是这样的)。如果您生活在 Stack 生态系统中,Stackage 发行版确保每次构建时都获取相同的 Hackage 包(除非不是这样的...)。如果您生活在 Nix 生态系统中,这意味着您必须实际替换系统上的所有打包系统才能实现可再现性。
所以看起来:
-
如果您完全依赖所使用的工具园区,事情可能是相当可再现的,但是在更新依赖项时,您仍然需要自行处理。
-
一旦您走出园区,完全由您来确保可再现性。通常的“简便方法”往往不可复制。
如果我们改变问题的方式呢? 我们在讨论中假设可再现性是我们的终端价值。但事实并非如此:它是我们可以实现其他目标的机制。在连续集成的环境中,我们真正关心的是一个能够提供我们信号的系统,指示特定变更集是否正确或破坏了事物。一个不可再现的构建只会以一种方式干扰这一目标:如果某个随机依赖项已自行更新并破坏了您的构建。如果发生这种情况,您将受阻:在您解决依赖问题之前,您将无法得到清洁的信号。损坏窗户理论要求您放下一切并修复构建。
显然,我们不在乎我们的依赖关系在开发过程中是否在静默中升级;事实上,我们可能更喜欢这样,因为“自动”比“手动”少摩擦,至少在它工作时是这样的。我们在乎的是能够阻止如果已知会导致我们出现问题的升级,或者回滚如果后来发现它造成了一些问题。
在线/离线持续集成。 我们传统上认为持续集成构建是一个单一的流水线,从头到尾运行,为我们提供代码是否工作的信号。但我认为把 CI 流水线看作分成两个阶段更好:
-
在线环境配置。 在这个阶段,你下载所有依赖于那些讨厌的第三方世界的外部软件,设置一个完整的构建环境。完成后,通过某种机制(例如文件系统快照或创建一个 Docker 镜像)快照这个环境。
-
离线实际构建和测试。 在这个阶段,使用步骤(1)的快照环境,关闭你的互联网连接并运行实际的构建和测试。
关键在于你不必在每次构建时都运行步骤(1)(出于性能原因,你也不想这样做)。相反,由步骤(1)生成的不可变构建环境的快照系列使你能够回滚或锁定到所有依赖的特定版本,而不必使整个宇宙可复现。你可以每周设置一个定时任务来重建你的环境、运行测试,只有在一切顺利通过时才决定推进激活快照。在运行步骤(2)时,你并不一定要真的关闭互联网,但这可能有助于保持诚实。
离线思考。 在今天互联的世界中,很容易构建假设你始终连接到互联网的系统。然而,这样做会使你的工具受到现实世界的变化和嘈杂的影响。通过应用一个简单的原则:“我可以离线做什么;我必须在线做什么?”我们可以反向设计一个持续集成的设计,让你得到几乎和可复现性一样好的东西,而不必重新编写整个宇宙。毫无疑问,这是有价值的。
开放类型族不是模块化:ezyang 的博客
来源:
blog.ezyang.com/2014/09/open-type-families-are-not-modular/
在构建 Haskell 模块系统时面临的一个主要问题是处理类型类,我在此博客中之前讨论过。我指出了目前在 Haskell 中使用类型类的模式假设“全局唯一性”,这在本质上是反模块化的;打破这一假设会有违许多现有数据类型的封装。
好像我们有选择一样。
实际上,开放类型族在 Haskell 中强制我们这样做,它们具有与类型类非常相似的特性,但附加的属性是对类型安全性 要求 的全局唯一性。我们别无选择(除非我们希望具有关联类型的类型类与类型类行为有所不同):我们必须想办法调和类型族固有的非模块化特性与 Backpack 模块系统。
在这篇博文中,我想仔细阐述为什么开放类型族 本质上 是非模块化的,并提出一些解决这种非模块化的方案。如果你知道问题所在,可以跳过前两节,直接进入提出的解决方案部分。
在我们讨论开放类型族实例之前,首先值得强调的(直观)事实是,模块的签名应能够 隐藏 有关其实现的信息。这里有一个简单的例子:
module A where
x :: Int
module B where
import A
y = 0
z = x + y
在这里,A
是一个签名,而 B
是一个导入该签名的模块。模块系统的一个要点是,我们应该能够针对 A
来检查 B
的类型,而不知道我们实际上使用了哪个模块作为实现。此外,如果此类型检查成功,则对于 任何 提供 A
接口的实现,组合程序也应该能够通过类型检查。即使 A
的实现定义了签名中未提到的其他标识符:
module A where
x = 1
y = 2
如果 B
直接导入了这个实现,标识符 y
就会有歧义;但签名 过滤掉 了声明,以便 B
只能看到签名中的标识符。
考虑到这一点,现在让我们考虑一下与开放类型族类似的情况。假设我们在预置中定义了一些类型族F
,我们有相同的例子:
module A where
type instance F Int
f :: F Bool
module B where
import A
type instance F Bool = Int -> Bool
x = f 2
现在,以下模块A
是否可以作为签名的合法实现?
module A where
type instance F Int = Int
type instance F Bool = Int
f = 42
如果我们用正常的眼光看这个例子,我们可能会认为它是一个合法的实现。毕竟,A
的实现提供了额外的类型实例,但在先前出现(值级)声明时,它被签名隐藏了。
但是,如果我们戴上眼镜看整个示例,就会发生不好的事情:我们试图将整数 42 作为从整数到布尔值的函数。问题在于在模块A
和模块B
中,F Bool
已被赋予不同的类型,这是不合理的…像段错误一样不合理。如果我们再仔细考虑一下,这并不奇怪:我们已经知道有重叠的类型家族是不合理的(并且热切地检查这一点),而签名样式的隐藏是允许重叠 sneak in 的一种简单方法。
令人沮丧的结论:开放类型家族不是模块化的。
那么,这是什么意思?我们应该举起双手放弃给 Haskell 一个新的模块系统吗?显然,我们不会束手无策。以下是一些对抗问题的方法。
基本提议:要求签名中的所有实例
解决不完全性最简单和最直接的方法是要求签名中提及由模块传递导出的所有家族实例。因此,在我们之前的例子中,A
的实现不符合签名,因为它具有未在签名中提及的实例,但将满足此签名:
module A where
type instance F Int
type instance F Bool
虽然乍一看这似乎并不太繁重,但重要的是要注意,此要求是传递的。如果A
碰巧导入另一个模块Internal
,它本身有其自己的类型家族实例,那些也必须在签名中表示。(可以想象对于类型类来说,文件顶部的四十个导入中的任何一个可能会将各种类型类引入范围内,这很容易使情况失控。)这有两个主要用户可见的后果:
-
模块导入不是实现细节——您需要在签名文件中复制此结构,并且
-
添加实例总是一种不兼容的更改(没有减弱)。
当然,正如理查德对我指出的那样,这对于 Haskell 程序来说已经是事实(而您只是希望添加一个额外实例是“可以接受的”)。
尽管它不友好,但这一提议为其余提议提供了基础,您可以将其概念化为试图描述“何时可以避免编写所有签名中的实例”。
扩展 1:孤儿限制
假设我写了以下两个模块:
module A where
data T = T
type instance F T = Bool
module B where
import A
type instance F T = Int -> Int
尽管这两种类型实例确实重叠且被正确拒绝,但它们并非同样有问题:特别是,在模块B
中的实例是一个孤儿。孤儿实例是类型类/族F
和数据类型T
的实例(它只需出现在左侧的任何位置),它位于一个既不定义的模块中。 (A
不是孤儿,因为实例位于与数据类型T
定义相同的模块中)。
我们可能会想知道的是,“如果我们禁止所有孤立实例,这是否会排除重叠的可能性?”答案是,“是的!(...有些技术性问题)。”以下是规则:
-
签名必须提到所有被考虑的实现所导出的所有我们将称之为流浪汉实例的实例。如果一个家族
F
的实例不是与家族定义一起定义的,或者不是在第一个参数的头部与类型构造函数一起定义的,那么它就是一个流浪汉。所有孤立实例都是流浪汉,但并非所有流浪汉都是孤立实例。 -
一个导出类型族的签名必须提到所有与类型族定义在同一模块中定义的实例。
-
在签名中提及非流浪汉实例是完全可选的。
(旁注:我认为这不是最安全、但我认为这是最直接的规则版本。)这些规则的整点在于使得不可能编写重叠的实例,同时只要在写入实例时进行本地检查即可。为什么我们需要将孤立条件加强为流浪汉条件来得到这种非重叠性呢?答案是孤立的缺失并不意味着没有重叠,正如这个简单的例子所示:
module A where
data A = A
type instance F A y = Int
module B where
data B = B
type instance F x B = Bool -> Bool
在这里,F
的两个实例是重叠的,但都不是孤立的(因为它们的左手边提到了模块中定义的数据类型)。然而,B
实例是一个流浪汉实例,因为在 F
的第一个参数中没有提到 B
。(当然,无论你检查第一个参数还是第二个参数,只要你保持一致即可。)
另一种思考这个规则的方式是,开放类型族实例并不是独立的实例,而是与类型构造函数在其构造时关联的元数据。这种方式下,非流浪汉类型族实例是模块化的!
然而,这种技术的一个主要缺点是它对于 Haskell 生态系统中孤立实例的合法用途实际上无济于事:当第三方同时定义了类型族(或类型类)和数据类型,并且你需要这个实例来满足自己的需求时。
扩展 2:孤立解决
这个提案基于 Edward Kmett 提出的一个提案,但我进行了改进。动机是为提供孤立实例的功能提供一个更好的解决方案,同时不会搞乱模块系统。提案的要点是允许包管理器有选择地启用/禁用孤立定义;然而,为了恰当地解释它,我想首先描述几种涉及孤立类型类实例的情况。(示例使用类型类而不是类型族,因为用例更清晰。如果你想象所讨论的类型类有关联类型,那么情况与开放类型族相同。)
故事始于一个第三方库,定义了一个数据类型 T
,但没有提供你所需的实例:
module Data.Foo where
data Foo = Foo
module MyApp where
import Data.Foo
fooString = show Foo -- XXX no instance for Show
如果你确实需要这个实例,你可能会被诱惑去定义它:
module MyApp where
import Data.Foo
instance Show Foo where -- orphan
show Foo = "Foo"
fooString = show Foo
后来,你将Data.Foo
升级到了 1.0.0 版本,现在你的重叠实例错误!哎呀。
我们如何摆脱困境?线索在于,目前许多包作者是通过使用预处理器宏来“逃脱监狱”的:
{-# LANGUAGE CPP #-}
module MyApp where
import Data.Foo
#if MIN_VERSION_foo(1,0,0)
instance Show Foo where -- orphan
show Foo = "Foo"
#endif
fooString = show Foo
从道德上讲,我们希望在真实实例可用时隐藏孤立实例:我们希望在两个变体的MyApp
之间进行透明切换:一个定义了孤立实例,另一个不定义并使用Data.Foo
中定义的非孤立实例。选择取决于选择了哪个foo
,这是由包管理器做出的决定。
让我们稍微混合一下。实例不必来自于Data.Foo
,它可以是一个孤立实例的定义来自另一个库:
module MyOtherApp where
import Data.Foo
instance Show Foo where ... -- orphan
otherFooString = show Foo
module MyApp where
import Data.Foo
instance Show Foo where ... -- orphan
fooString = show Foo
module Main where
import MyOtherApp
import MyApp
main = print (fooString ++ otherFooString ++ show Foo)
使用预处理器宏使其工作有些糟糕,但我们有 两种 方法可以手动解决重叠:我们可以从MyOtherApp
中删除孤立的实例,或者从MyApp
中删除孤立的实例。先验地,没有理由偏好其中一个。但是,根据删除的实例,Main
可能需要以 不同的 方式编译(如果实例中的代码不同)。此外,我们需要在定义实例的模块与被删除实例的模块之间设置 新的 (仅实例的)导入。
从这些例子中可以得出几个要点。首先,解决重叠孤立实例的最自然方式是简单地“删除”重叠实例;然而,删除哪个实例是一个全局决策。其次,启用哪些重叠的孤立实例会影响编译:您可能需要添加模块依赖关系才能编译您的模块。因此,我们可以想象一个解决方案,允许我们在不修改源代码的情况下做到这两点。
这是我们的计划:与以往一样,包可以定义孤立实例。但是,包定义的孤立实例列表是包的元数据的一部分,而实例本身在我们实际编译包(或其依赖项)时可能会被使用或不被使用。当我们对一组包进行依赖解析时,我们必须考虑所提供的孤立实例集合,并且只启用一个非重叠的实例集合,这就是所谓的 孤立实例解析。此外,我们需要从禁用其实例的包到唯一定义其实例的包添加额外的依赖关系(这可能限制我们实际可以选择为规范实例的孤立实例)。
这个提议的好处是,它解决了类型类用户已经存在的一个痛点,即在上游添加适当实例时定义孤立类型类实例不会出错。但你也可以把它看作是一个大型的 hack,并且它需要包管理器(或者其他管理孤立解决方案的工具)的配合。
对基本提议的扩展并不是互斥的,但是否值得为了现有的孤立实例的使用带来的好处而增加复杂性,这是一个开放的问题。当然,解决问题的其他方法可能还有,但眼下这些看起来是最合理的。
在 ICFP 会议上,我与 Derek Dreyer 有过一次有趣的交谈,他提到当开放类型族最初进入 GHC 时,他曾警告 Simon 它们不会是模块化的。随着封闭类型族的最近添加,原始论文中陈述的开放类型族的许多主要用例已经过时。然而,即使从未将开放类型族添加到 Haskell 中,我们仍然可能需要采纳这些解决方案:实例的全局唯一性深深植根于 Haskell 社区,即使在某些情况下我们对这个约束的实施比较松散,也不意味着我们应该积极鼓励人们打破它。
我对 ML 社区有一个离别的评论,因为类型类从 Haskell 进入:当你在你的语言中引入类型类时,不要犯和 Haskell 社区同样的错误,开始使用它们来强制 API 中的不变性。这种方式导致了实例的全局唯一性,而且失去了模块化可能是一个付出的代价太大。
后记. 一个自然的问题是,如果重叠的类型族实例中有一个“在外部不可见”,那么是否可以接受?当然,魔鬼在细节中;我们所说的类型族实例F
的外部可见性是什么意思呢?
对于某些可见性的定义,我们可以找到一个等效的本地转换,它具有相同的效果。例如,如果我们根本不使用这个实例,那么有重叠是完全可以接受的。在这种情况下,直接删除该实例也是可以的。另一个例子是,我们可以要求在模块签名中没有类型族F
的(传递性)提及。然而,消除类型族的提及要求了解足够的参数和方程式以减少:在这种情况下,类型族可以被替换为本地的封闭类型族。
有一个明确不起作用的定义是,如果F
可以在一些未指定的类型变量中提及。这里有一个函数,它将Int
强制转换为一个函数:
module A where
type instance F Int = Int
f :: Typeable a => a -> F a
f x = case eqT of
Just Refl -> x :: Int
Nothing -> undefined
module ASig where
f :: Typeable a => a -> F a
module B where
import ASig
type instance F Int = Bool -> Bool
g :: Bool
g = f 0 True -- oops
...重点在于,即使一个签名并没有直接提到重叠实例 F Int
,类型细化(通常通过某种类似 GADT 的结构)可以意味着一个有问题的实例可以在内部使用。
OPLSS 讲座笔记:ezyang 的博客
OPLSS 讲座笔记
我从阳光明媚的俄勒冈写信过来,早上七点阳光洒进你的房间,而俄勒冈编程语言暑期学校正在进行中。到目前为止,一切进展顺利——讲座涵盖了 Coq、Agda、同伦类型论、线性逻辑、逻辑关系等内容,我们还有一周的时间!
如果你未能参加,不要担心:你可以访问课程表页面,不仅可以获取视频(听说它们仍然相当庞大;我们一直在努力说服组织者将它们上传到 YouTube),还可以获取本人的讲座笔记。(逻辑关系讲座的样例。) 早期的笔记可能有些问题,但我在后来的笔记中详细了一些。
技术说明:我听说一些使用 Mac 的朋友在默认的 PDF 阅读器上无法查看 PDF 文档,文字不显示。我也听说 Chrome 的 PDF 阅读器以及 Acrobat Reader 能够正常阅读这些 PDF 文档。无论问题出在何处,可能是 Xournal 的 PDF 导出功能出了些问题。如果有人有办法在不丢失元数据的情况下解决这个问题(例如能够复制/粘贴文本的功能),我将拭目以待。
优化增量编译:ezyang 的博客
来源:
blog.ezyang.com/2016/08/optimizing-incremental-compilation/
当你运行 make
来构建软件时,你期望对先前构建过的软件进行构建所需的时间比从头构建的软件少。这其中的原因在于增量编译:通过缓存预编译的中间结果,程序中只有依赖于依赖图变化部分的部分需要重新编译。
“增量编译”这个术语并未详述依赖图是如何设置的,这可能导致对“增量编译器”的性能特征产生一些误解。例如,维基百科关于增量编译声称增量编译器无法轻松优化其编译的代码,这是错误的:这完全取决于你的依赖图设置方式。
以 C 为例,考虑 gcc
:
目标文件 a.o
依赖于 a.c
,以及它(传递性地)包含的任何头文件(在这种情况下是 a.h
)。由于 a.o
和 main.o
互不依赖,如果重建 a.c
,main.o
就不需要重建。从这个意义上讲,C 实际上是非常适合增量编译的(没有任何 C 程序员会这样说)。C 之所以在增量编译方面名声不佳,是因为在简单情况下,头文件的预处理完全没有做到增量化(预编译头文件是解决这个问题的一种尝试)。
依赖图也意味着另外一件事情:除非函数体放在 a.h
中,否则生成 main.o
的编译器无法将函数体内联进去:它对 C 文件一无所知。在生成 main.o
的时候,甚至 a.c
可能还不存在(并行性!)这种优化只能在链接时发生(这就是为什么链接时优化存在的原因)。
Haskell 的 ghc
提供了一个很好的对比:
在这里,Main.{hi,o}
不仅依赖于 Main.hs
,还依赖于它导入的模块 A.hi
。GHC 仍然是增量的:如果你修改了一个 hs
文件,只有导入该源文件的模块需要重新编译。事情甚至比这个依赖图暗示的更好:Main.{hi,o}
可能仅依赖于 A.hi
的特定部分;如果这些部分未改变,GHC 将提前退出并报告无需编译。
尽管是增量的,GHC 支持内联,因为函数的展开可以存储在 hi
文件中,随后由导入它的模块使用。但现在有一个权衡:如果内联一个函数,你现在依赖于 hi
文件中的展开,这使得当 A.hi
变化时更有可能需要重新编译。
作为最后的例子,IDE 中的增量编译器,例如 Eclipse 中的 Java 编译器,与 GHC 的操作没有根本上的不同。主要区别在于(1)中间产品保存在内存中,这可以节省大量时间,因为解析和加载接口到内存中是一个巨大的时间消耗者,以及(2)他们试图使依赖图尽可能细粒度化。
这些都是相当出名的,所以我想转换思路,思考一个不太被理解的问题:如何为参数化的构建产品进行增量编译?当我说参数化时,我指的是 C 语言和 Haskell 语言范式的融合:
-
分离编译。 应该可以依赖于接口而不是依赖于实现(就像一个 C 文件依赖于头文件时一样)。
-
零成本抽象。 当提供了实现时,我们应该(重新)编译我们的模块,以便我们可以内联来自实现的定义(就像 Haskell 模块导入另一个模块时一样)。
这个问题对于 Backpack 非常重要,Backpack 引入了针对 Haskell 签名参数化的库。对于 Backpack,我们提出了以下设计:为以下两类构建产品生成不同的构建产品:(1)未实例化的代码,我们知道接口但不知道其实现,以及(2)已实例化的代码,我们知道它们的所有实现:
在蓝色框中,我们生成A.hi
和Main.hi
,这些文件仅包含对接口的类型检查结果。仅在粉色框中,我们将A
的实现(在红色框中)与Main
的用户组合在一起。这只是一个图表;因此,增量编译的工作方式与之前的工作方式完全相同。
我们在支持多个接口时遇到了一个有趣的问题:如果客户端实例化了一个接口但没有实例化另一个接口,我们该怎么办?我们是否有义务为这些部分实例化的模块生成构建产品?这并不是很有用,因为我们目前还不能生成代码(因为我们还不知道所有的实现)。
一个重要的观察是,生成这些接口实际上很便宜(因为你不进行任何编译)。因此,我们的想法是在需要时即时进行实例化,而不实际生成构建产品。部分实例化的接口可以缓存在内存中,但生成它们的成本很低,如果我们不需要它们(在这种情况下我们不实例化它们),我们就会获胜。
这是一个有点聪明的方案,而聪明总是有其暗面。在即时实例化中的一个主要复杂性来源是,现在对于道德上相同的构建产品存在两种表示:即时生成的产品和实际编译的产品:
这两个产品之间的子类型关系表明,我们总是可以用一个编译后的接口来代替即时实例化的接口,但反之则不行:即时接口缺少展开和其他编译代码可能需要的重要信息。
如果我们仅进行类型检查(即我们有未实例化的接口),我们可能更喜欢即时接口,因为它们需要较少的工作来创建:
相反,如果我们正在编译一个包,我们必须使用编译后的接口,以确保我们看到必要的展开内容用于内联:
特别复杂的情况是,如果我们正在对一组未实例化的模块进行类型检查,这些模块本身依赖于一些已编译接口。如果我们正在使用接口p+a/M.hi
,我们应该对此保持一致性,因为r
必须使用编译后的接口,q
也必须如此:
另一种选择是确保我们始终构建产品,这些产品是根据即时接口进行了类型检查的,如下所示:
但这会带来一个令人不快的效果,即需要一切都建立两次(首先针对即时接口进行类型检查,然后进行真实构建)。
提前编译器的构建产品的依赖图传统上是编译器的公共 API 的一部分。正如我之前写过的,为了实现更好的增量性、更好的并行性和更多的功能(如参数化模块),依赖图变得越来越复杂。当编译器作者不愿意承诺一个接口,而构建工具作者对复杂的编译模型不感兴趣时,唯一运作良好的系统就是集成的系统。
Backpack 的即时接口实例化系统是否聪明过头了?我认为它对尝试解决的问题设计良好,但如果您仍然有复杂的设计,也许您正在解决错误的问题。我很想听听您的想法。
Ott ⇔ PLT Redex:ezyang 的博客
Ott 和 PLT Redex 是一对互补的工具,适用于工作中的语义学家。Ott 是一个用 ASCII 符号写程序语言定义的工具,可以用 LaTeX 排版,也可用于生成定理证明器(如 Coq)的定义。PLT Redex 是一种用于指定和调试操作语义的工具。这两个工具都很容易安装,这是一个很大的优点。由于这两个工具相似,我觉得对它们进行比较执行各种常见任务可能会很有趣。(而且我认为 Redex 的手册相当糟糕。)
变量。 在 Ott 中,变量通过元变量(metavar x
)定义,然后作为变量使用(可以单独使用元变量,也可以将其后缀为数字、索引变量或 tick)。
在 Redex 中,没有“元变量”的概念;变量只是另一种产生式。有几种不同的方法可以表明一个产生式是变量:最简单的方法是使用 variable-not-otherwise-mentioned
,这可以自动防止关键字被视为变量。还有几种其他变量模式 variable
、variable-except
和 variable-prefix
,可以更精确地控制哪些符号被视为变量。如果你有一个分类变量的函数,side-condition
也许会很有用。
语法。 Ott 和 Redex 都可以识别模糊匹配。Ott 在遇到模糊解析时会报错。而 Redex 则会生成所有有效的解析结果;尽管在解析术语时这并不那么有用,但在指定非确定性操作语义时却非常有用(尽管这可能会对性能产生不良影响)。check-redundancy
可能对识别模糊模式很有用。
绑定器。 在 Ott 中,绑定器通过在语法中明确声明 bind x in t
来定义;还有一个用于模式匹配收集绑定器的绑定语言。Ott 还可以为语义生成替换/自由变量函数。在 Redex 中,绑定器不在语法中声明;而是仅在规约语言中实现,通常使用替换(Redex 提供了一个实用的替换函数用于此目的),并明确要求变量是新鲜的。Redex 还在元语言中提供了一个用于立即进行 let 绑定的特殊形式(term-let
)。
列表。 Ott 支持两种形式的列表:点形式和列表理解。点形式看起来像x1 , .. , xn
并需要上限。列表理解看起来像</ xi // i IN 1 .. n />
;上下限可以省略。目前 Ott 的一个限制是它不理解如何处理嵌套的点形式,可以通过在制品上做理解,然后在其他地方说明制品满足的适当等式来解决这个问题。
Redex 使用省略号模式支持列表,看起来像(e ...)
。这里没有语义内容:省略号只是匹配零个或多个e
的副本,当存在多个省略号时可能导致非确定性匹配。支持嵌套的省略号,并且简单地导致嵌套列表。可以使用侧条件指定边界;但是 Redex 支持使用命名省略号进行有限形式的绑定(例如..._1
),其中具有相同名称的所有省略号必须具有相同的长度。
语义。 Ott 对您想定义的任何语义都是不可知的;可以指定任意判断。在 Redex 中也可以像通常一样定义判断,但 Redex 专门支持评估语义,其中语义是通过评估上下文来给出的,从而允许您避免使用结构规则。因此,通常的用例是定义一个正常的表达式语言,扩展该语言以具有评估上下文,然后使用in-hole
定义一个reduction-relation
进行上下文分解。限制在于,如果需要做任何复杂操作(例如multi-hole evaluation contexts),则必须返回到判断形式。
排版。 Ott 支持通过转换为 LaTeX 进行排版。制品可以有与之关联的自定义 LaTeX,用于生成它们的输出。Redex 有一个pict
库,可以直接排版成 PDF 或 Postscript;虽然 PLT Redex 似乎不支持定制排版作为预期用例,但它可以生成合理的类似 Lisp 的输出。
结论。 如果我必须说 Ott 和 PLT Redex 之间最大的区别是什么,那就是 Ott 主要关注于您定义的抽象语义含义,而 PLT Redex 主要关注于如何匹配语法(运行)。可以通过观察到,在 Ott 中,您的语法是 BNF,这被馈送到 CFG 解析器中;而在 PLT Redex 中,您的语法是用于模式匹配机器的模式语言。这不应该令人惊讶:人们期望每个工具的设计理念符合其预期的使用方式。
Paper Monday : ezyang’s blog
上周末,我乘坐灰狗巴士去西雅图见了一些朋友。灰狗巴士非常晚:在去程的情况下晚了四十五分钟,这意味着我在没有互联网的车站有了一些自己的时间。我制定了唯一明显的行动方案:开始处理我的待读论文堆积。在这个过程中,我发现一个自 2009 年 12 月以来一直在我的待读列表中的论文实际上直接涉及到我上周四在 Galois 进行调试(不成功)时遇到的一个重要问题。
这里是我阅读过的论文和幻灯片——有些是旧的,有些是新的——以及为什么你可能也对它们感兴趣。(天啊,并不全是 Haskell!)
流行就是一切(2010),由 Schechter、Herley 和 Mitzenmacher 撰写。标语:当假阳性是一件好事!
我们建议通过允许互联网规模系统的用户选择任何他们想要的密码来加强用户选定的密码对统计猜测攻击的抵抗力,只要这些密码不是已经太过流行。我们创建一个用现有用户密码填充并且每次有新用户密码更新的 Oracle 来识别不受欢迎的流行密码,使用的是一个称为count-min sketch的现有数据结构。与大多数概率数据结构的应用不同,这些只追求最大可接受的假阳性率,我们设定了一个最低可接受的假阳性率,以困扰可能查询或甚至获取它的副本的攻击者。
Nelson向我介绍了这篇论文;它是对诸如Bloom filters之类的概率数据结构的实际应用,利用它们的假阳性率:试图使用你的密码流行度数据库来弄清哪些密码流行的攻击者将得到大量被称为流行但实际上并非如此的密码。这个数据结构也相当简单:有人应该将其作为一个周末项目与一个流行的 Web 框架的认证机制集成起来!
Ropes: an Alternative to Strings(1995),由 Boehm、Atkinson 和 Plass 撰写。标语:你所需的只是串联。
编程语言通常提供‘字符串’或‘文本’类型来允许操作字符序列。这种类型通常至关重要,因为它通常在系统组件之间的大多数接口中被提到。我们认为传统的字符串实现,以及通常支持的功能,不适合这样的通用用途。它们应该限于具有特定且不寻常的性能要求的应用程序。我们提出‘ropes’或‘重量级’字符串作为一种选择,根据我们的经验,这种选择会导致功能和性能更加强大的系统。
当你上次索引字符串以获取单个字符是什么时候?如果你正在处理多字节编码,那么这个操作可能毫无意义!相反,你更可能关心搜索、切片或连接字符串。从业者们可能会认为这是对渐近性能而非真实世界性能的关注,但是这篇论文非常明确地指出,文本编辑器是传统 C 字符串极不高效的非常实际的例子。对于大部分时间都在连接字符串的 Web 开发者来说,Ropes 似乎是一个很好的选择。
Autotools 教程(最后更新于 2010 年)由 Duret-Lutz 撰写。(因为经典网站在撰写时似乎宕机,此处再次托管。)标语:Hello World:Autotools 版。
这份演示文稿针对熟悉 Unix 开发工具(shell、make、编译器)并希望学习 Autotools 的开发者。
尽管它的标题不起眼,但这份幻灯片已成为大多数朋友的默认推荐,如果你想弄清楚这个“autogoo”究竟是什么。在我看来,它是可移植编译共享库。也许这份演示文稿之所以如此出色,是因为它假定了正确的背景(即大多数对 autotools 感兴趣但是新手的背景),并用许多动画图表清楚地解释了程序生成哪些文件的黑魔法。
类型函数乐趣(2009)由 Oleg Kiselyov、Simon Peyton Jones 和 Chung-chieh Shan 撰写。另请参阅Haskellwiki。标语:放下那些 GHC 文档,来阅读这篇文章吧。
Haskell 的类型系统通过两个独特的特性扩展了 Hindley-Milner:类型构造器上的多态性和使用类型类进行重载。这些特性从 Haskell 诞生之初就成为其不可或缺的一部分,广泛被使用和欢迎。最近,Haskell 还增加了类型族或关联类型,允许将类型上的函数像对值的函数一样直接表达。这一功能使得程序员能够通过编写在类型检查期间执行的函数式程序有效地扩展编译器。
我认识的许多程序员对论文和 PDF 有所厌恶:我认识的其中一个曾经说过,如果可以的话,他愿意付钱请人写博客文章而不是写论文。这种态度可能会让他们忽略掉像这样的论文,而这篇论文真正是你一直在寻找的有关类型族的教程。论文中没有讨论底层实现:只有三十五页的类型级别编程示例。在此过程中,他们涵盖了可变引用的接口(考虑 STRef 和 IORef)、算术、图形、记忆化、会话类型、sprintf/scanf、指针对齐和锁!在许多方面,它就是我之前提到的那本烹饪书,我一直在寻找我的博文 Friday。
纯函数式惰性非确定性编程(2009)由 Sebastian Fischer、Oleg Kiselyov 和 Chung-chieh Shan。标语:分享和关心也可以很有趣!
函数逻辑编程和概率编程展示了将惰性(非严格评估与结果共享)与非确定性结合起来的广泛好处。然而,由于功能语言中用于非严格性、共享和非确定性的现有特性很难组合,这些好处很少被享受到。
我们提出了一种实用的方式来编写纯函数式的惰性非确定性程序,既高效又明了。我们通过将程序嵌入现有语言(如 Haskell、SML 和 OCaml)的高质量实现中实现了这一目标,通过惰性选择和非确定性组件来表示数据,通过使用自定义的单子数据类型和搜索策略来工作,并为程序员提供方程法则,以便他们推理自己的代码。
这篇论文正好对我在工作中处理的一些代码问题如实地击中要害:我基本上已经将一个纯粹的有向无环图转换为了一个单子结构,并且在此过程中,我设法破坏了共享常见节点,导致结果树呈指数增长。在处理非确定性环境中共享的显式处理中,为了得到一些理想的特性,帮助我澄清了我对如何破坏共享的思考(我现在完全同意约翰·马修斯的观点,我需要一个显式的记忆机制),因此我期待明天在工作中应用一些这些技术。
到此为止,或者至少是,直到下一次Paper Monday!(如果读者们不先因此杀了我的话。对于那些好奇的人来说,当前的积压稿件有六十六篇,大部分只是浏览过,并没有完全理解。)
并行化以堵住空间泄漏:ezyang 的博客
创造一个组合器将两个折叠操作合并成单次输入列表操作的折叠,并不是太困难(请滚动至“非语句”)。如果你的输入列表很大,这一点非常重要,因为分开折叠可能导致空间泄漏,正如著名的“平均”空间泄漏所示:
import Data.List
big = [1..10000000]
sum' = foldl' (+) 0
average xs = fromIntegral (sum' xs) / fromIntegral (length xs)
main = print (average big)
(我重新定义了 sum
,以免栈溢出。)我曾认为合并折叠函数非常模块化,因为它们具有相当规范的接口,可以互相组合,并且真正代表了什么时候可以消除这种空间泄漏的核心概念:显然,如果你有两个需要对列表元素进行随机访问的函数,它们将始终保留整个列表。
当然,我的一位同事抱怨说:“不!这实际上并不是模块化的!”他想要编写代码的漂亮版本,而不是一些可怕的巨大折叠函数。这让我思考:编译器是否真的无法判断在流数据结构上两个计算是否可以并行运行?
等等!我们可以告诉编译器并行运行这些:
import Data.List
import Control.Parallel
big = [1..10000000]
sum' = foldl' (+) 0
average' xs =
let s = sum' xs
l = length xs
in s `par` l `par` fromIntegral s / fromIntegral l
main = print (average big)
令人惊讶的是,空间泄漏消失了(不要忘记用 -threaded
编译并至少用 -N2
运行)。利用多线程的力量,两个操作可以同时运行,因此没有不必要的保留。
或许并不奇怪,par
能够解决空间泄漏问题,因为 seq
也能做到。但是 seq
具有表示内容;par
没有,并且在单线程时什么也不做。这使得这种解决方案非常脆弱:在运行时,我们可能会根据核心的可用性决定是否并行评估其他惰性求值。但是,在单线程环境中,我们仍然可以有益地使用 par
,如果它能够管理流的两个消费者之间的抢占式切换。这将是一个非常有趣的原语,并且看到某种明确说明这样一个函数有益于空间效果的语义也是很有趣的。另一个未成形的想法是,我们已经有了对流融合的好的生产者和消费者的概念。看起来我们可以使用这种分析来确定何时可以合并消费者,从而改善空间使用。
Parsec:“try a <|> b”被认为是有害的:ezyang 的博客
来源:
blog.ezyang.com/2014/05/parsec-try-a-or-b-considered-harmful/
tl;dr 应该将回溯 try 的范围最小化,通常是将其放置在解析器定义的内部。
你是否曾经编写过一个 Parsec 解析器并得到了一个非常不具信息性的错误消息?
"test.txt" (line 15, column 7):
unexpected 'A'
expecting end of input
行号和列号在文档中随机地某个地方,并且你非常确定应该在某些解析器组合器的堆栈中间。但是等等!Parsec 已经以某种方式得出结论,文档应该立即结束。你继续思考并发现真正的错误在实际报告的行号和列号之后一段距离。
你想:“难怪 Parsec 的错误处理声名狼藉。”
假设你所询问的语法并不太奇怪,通常对于这样的错误消息有一个简单的解释:程序员在他们的代码中撒入了太多的回溯try
语句,并且回溯已经破坏了有用的错误状态。实际上,在某些时候,解析器因为我们想向用户报告的原因而失败,但是一个封闭的try
语句迫使解析器回溯并尝试另一种(徒劳无功的)可能性。
这可以通过一个例子来说明。一个 Haskell 程序员正在使用解析组合器玩耍,并决定通过编写 Haskell 模块导入的解析器来测试他们的解析技能:
stmt ::= import qualified A as B
| import A
利用 Parsec 内置的token 组合器(以及示例代码),他们的第一个版本可能看起来像这样:
import Text.Parsec
import qualified Text.Parsec.Token as P
import Text.Parsec.Language (haskellDef)
data Stmt = QualifiedImport String String | Import String
deriving (Show)
pStmt = pQualifiedImport <|> pImport
pQualifiedImport = do
reserved "import"
reserved "qualified"
i <- identifier
reserved "as"
i' <- identifier
return (QualifiedImport i i')
pImport = do
reserved "import"
i <- identifier
return (Import i)
lexer = P.makeTokenParser (haskellDef
{ P.reservedNames = P.reservedNames haskellDef ++ ["qualified", "as"] })
identifier = P.identifier lexer
reserved = P.reserved lexer
parseStmt input = parse (pStmt >> eof) "(unknown)" input
不幸的是,该解析器对于常规的导入不起作用,它们会收到以下错误消息:
*Main> parseStmt "import Foo"
Left "(unknown)" (line 1, column 8):
unexpected "F"
expecting "qualified"
经过一番搜索,他们发现 Parsec 不默认回溯。好吧,那很好;为什么不只是在解析器中插入一个 try 呢。
pStmt = try pQualifiedImport <|> pImport
这既修复了两种解析,并提出了撰写未来解析器的以下规则:
如果我需要在多个解析器之间做出选择,但其中一些解析器可能会消耗输入,我最好在每个解析器上添加一个
try
,这样我就可以回溯。
用户并不知道,他们已经引入了不良的错误报告行为:
*Main> parseStmt "import qualified Foo s B"
Left "(unknown)" (line 1, column 17):
unexpected reserved word "qualified"
expecting letter or digit or "#"
等一下!我们想要的错误是,当我们期望 as
时,出现了意外的标识符s
。但是,Parsec 没有在此发生时报告错误,而是回溯,并尝试匹配 pImport
规则,仅在该规则失败后才失败。到那时,我们的选择分支失败的知识已经永远丢失了。
我们该如何修复它?问题在于我们的代码在我们,开发者,知道会徒劳无功时回溯。特别是,一旦我们解析了import qualified
,我们就知道这个语句实际上是一个有资格的导入,我们就不应该再回溯了。我们如何让 Parsec 理解这一点?简单:减少 try 回溯操作符的作用范围:
pStmt = pQualifiedImport <|> pImport
pQualifiedImport = do
try $ do
reserved "import"
reserved "qualified"
i <- identifier
reserved "as"
i' <- identifier
return (QualifiedImport i i')
在这里,我们将try
从pStmt
移动到pQualifiedImport
,只有在无法解析import qualified
时才回溯。一旦解析成功,我们消耗这些标记,现在我们已经选择了一个有资格的导入。错误消息相应地变得更好:
*Main> parseStmt "import qualified Foo s F"
Left "(unknown)" (line 1, column 22):
unexpected "s"
expecting "as"
故事的寓意:应该尽量减少回溯 try 的范围,通常是通过将其放置在解析器的定义内部来实现。需要一定的技巧:您必须能够确定需要多少前瞻来承诺到一个分支,这通常取决于解析器的使用方式。幸运的是,许多语言专门设计,以便所需的前瞻不会太大,对于我可能使用 Parsec 的项目类型,我愿意牺牲这种模块化。
另一种看待这场灾难的方式是 Parsec 的问题:它不应该提供一个 API,使得错误消息混乱变得如此容易——为什么它不能自动确定必要的前瞻呢?虽然传统的解析器生成器可以做到这一点(并通过在我们之前的例子中完全避免回溯来提高效率),但是有一些基本原因解释了为什么 Parsec(以及像它一样的单子解析器组合库)不能自动地确定需要什么前瞻。这是为什么(众多原因之一),许多 Haskeller 更喜欢更快的解析器,它们根本就不试图进行任何错误处理。
为什么我首先写这篇帖子呢?目前还有大量的文档建议使用 Parsec,并且初学者更有可能在 Parsec 中实现他们的第一个解析器。如果有人要写一个 Parsec 解析器,那么最好花点时间来限制回溯:这样可以使得与 Parsec 解析器一起工作变得更加愉快。
PEPM’14: The HERMIT in the Stream:ezyang’s 博客
POPL 就快来了!当会议正式开始时,我将在Tumblr 上实时更新,但与此同时,我想在 PEPM'14 程序的一篇论文中写点东西:The HERMIT in the Stream,作者是 Andrew Farmer, Christian Höner zu Sierdissen 和 Andy Gill。该论文提出了一种优化方案的实现,用于在stream fusion 框架中消除对 concatMap 组合子的使用,该框架是使用HERMIT 优化框架开发的。HERMIT 项目已经进行了一段时间,各种应用该框架的论文陆续发表(任何参加 Haskell 实现者研讨会的人都能证明这一点)。
“但是等等”,你可能会问,“我们不是已经有了stream fusion吗?” 你是对的:但是尽管 stream fusion 作为一个库是可用的,它并没有取代 GHC 默认的 foldr/build 融合系统。什么使得融合方案好呢?一个重要的度量标准是它支持的列表组合子的数量。几乎可以说 stream fusion 几乎完全取代了 foldr/build 融合,除了 concatMap 的情况,这个问题已经持续了七年,阻止了 GHC 将 stream fusion 作为其默认选项。
原来,我们很久以前就知道如何优化 concatMap 了;Duncan Coutts 在他的论文中给出了一个基本的概述。 这篇论文的主要贡献是这一优化的原型实现,包括重要技术细节的阐述(增加原始规则的适用性,简化器的必要修改以及用于解糖列表推导的规则)。论文还提供了一些微基准测试和真实世界基准测试,论证了优化 concatMap 的重要性。
我很高兴看到这篇论文,因为它是在替换 GHC 标准库中的 foldr/build 融合与流融合之路上的一个重要里程碑。同时,开发这一优化似乎在很大程度上得益于使用 HERMIT,这对于 HERMIT 的验证是一个很好的例证(尽管论文没有详细介绍 HERMIT 如何在开发这一优化过程中起作用)。
论文中所述的优化还有一些令人不太满意的地方,最好通过考虑从流融合实施者的角度来表达。她有两个选择:
-
她可以尝试直接使用HERMIT 系统。然而,HERMIT 会导致 5-20 倍的编译减速,这对实际使用来说相当令人泄气。这种减速可能并非根本性的问题,在适当的时候会消失,但今天显然不是那个时候。在原型中有限的流融合实现(它们没有实现所有的组合器,只是足够用来运行他们的数据)也建议不直接使用该系统。
-
她可以直接按照论文中所述的规则将其整合到编译器中。这将需要特殊情况代码,仅适用于应用非语义保持简化的流,并且基本上需要重新实现系统,并且这篇论文提供了指导。但这种特殊情况代码的适用性有限,超出了对 concatMap 的实用性,这是一个负面评价。
因此,至少从普通 GHC 用户的角度来看,我们在手中拥有流融合还需要等待一段时间。尽管如此,我同意微基准测试和ADPFusion案例研究显示了这种方法的可行性,而且新的简化规则的一般原则似乎是合理的,尽管有些特殊。
如果你在阅读 nofib 性能部分时要注意一点:实验是将他们的系统与 foldr/build 进行比较的,因此增量主要显示出流融合的好处(在文本中,他们指出哪些基准测试最从 concatMap 融合中受益)。无论如何,这确实是一篇相当棒的论文:一定要看看!
Petri 网并发 : ezyang's 博客
Petri 网并发
一个 Petri 网 是一种有趣的小型图形建模语言,用于并发控制流。几周前在这次演讲中提到了它们:Petri-nets as an Intermediate Representation for Heterogeneous Architectures,但我觉得有趣的是我可以用这种建模语言描述一些常见的并发结构。
例如,这里是备受推崇的锁:
解释图的方式是这样的:每个圆圈是一个“Petri 碟”(位置),可能包含一些令牌。方形框(转换)是希望触发的操作,但为了执行这些操作,所有输入它们的 Petri 碟必须有令牌。这种表示方法可以说是可以变成某种棋盘游戏的形式!
如果多个转换可以触发,我们选择其中一个,只有那一个成功;一个令牌沿着一个或另一个箭头流动的能力在这个模型中编码了非确定性。在锁图中,只有一个分支可以获取中间的锁令牌,但它们在退出关键区域(解锁)时将其归还。
这里是一个信号量:
它和之前的完全相同,只是中间的位置可能包含多个令牌。当然,没有人说独立的进程必须在发出信号之前等待。我们可以像这样实现一个简单的生产者-消费者链:
注意,Petri 网中的位置类似于 MVar ()
,尽管在 Haskell 中需要小心确保我们不是在空中制造令牌,这是由于线性类型的缺失。你可能还注意到,Petri 网对于 数据流 并没有说太多;我们可以想象这些令牌代表数据,但形式主义并没有详细说明这些令牌实际上代表什么。
二项式系数恒等式的图像化
来源:
blog.ezyang.com/2011/02/picturing-binomial-coefficient-identities/
伙计们,我有一个秘密要承认:我害怕二项式系数。当我上高中时,我和唐纳德·克努斯的《计算机程序设计艺术》有一次创伤性的经历:是的,那本每个人都推荐但实际上没有人真正读过的书。(这并不完全正确,但这个主题是另一篇博客文章的话题。)我无法解决数学第一章中的任何推荐练习,也不熟练于计算机,无法理解汇编语言的用途。但最令我痛苦的部分可能是克努斯在我们预期记忆的第一章数学恒等式中极其紧凑的处理方式。正如我后来在我的数学生涯中发现的那样,在实际证明之前,说服自己某个给定的陈述是真实的是值得的,以避免陷入代数操作的混乱中。
我最喜欢的说服自己的方法之一是可视化。天哪,这甚至是记忆恒等式的有用方法,尤其是当二项式系数涉及多个参数时。如果我需要计算一个二项式系数,我更可能使用帕斯卡三角形而不是使用原始方程式。
当然,有时你必须写数学符号,当你需要这样做时,依赖帕斯卡三角形的对称呈现(显示在右边)可能是有害的。在不偷看的情况下,帕斯卡三角形中的加法规则是显而易见的,但正确的数学公式是什么呢?
我讨厌记忆这种细节,因为我知道如果我不经常使用这些知识,迟早会弄错(尽管二项式系数确实对计算机科学家非常有用,但我不能说我经常使用它们。)
但是图片,我能记住图片。
如果你将帕斯卡三角形视为一个真实的表格,n在 y 轴上,k在 x 轴上,知道盒子的空间关系意味着你也知道指数是什么。这有点像可视化动态规划。你还可以更容易地看到一对方程之间的对称性,例如:
这些方程由左边的盒子呈现。
当然,并不是我第一个想到这些视觉辅助工具的人。传统帕斯卡三角形对角线上的“曲棍球杆恒等式”是相当有名的。不过,我还没有看到它们以表格形式呈现过。(为了完整起见,我还添加了行求和。)
对称性很好,但不幸的是,我们的符号不对称,所以对我来说,记住这种方式的曲棍球棒恒等式可以避免我随后必须弄清楚索引是什么的麻烦。虽然我必须承认,我很好奇我的读者是否有同样的感受。
描绘 Hoopl 的传输/重写函数:ezyang 的博客
来源:
blog.ezyang.com/2011/02/picturing-hoopl-transferrewrite-functions/
描绘 Hoopl 的传输/重写函数
Hoopl 是一个“高阶优化库”。为什么称之为“高阶”?因为使用 Hoopl 的用户只需编写优化的各种片段,而 Hoopl 将把它们组合在一起,就像使用 fold 的人只需编写函数在一个元素上的操作,而 fold 将把它们组合在一起一样。
不幸的是,如果您对问题结构不熟悉,那么以这种风格编写的代码可能有点难以理解。幸运的是,Hoopl 的两个主要高阶组成部分:传输函数(收集程序数据)和重写函数(利用数据重写程序),相对较容易可视化。
大程序中定位空间泄漏 : ezyang’s 博客
来源:
blog.ezyang.com/2011/06/pinpointing-space-leaks-in-big-programs/
在你能够尝试调试空间泄漏的最大可能的 Haskell 程序中,有一个非常好的选择是 GHC,它接近 10 万行代码(尽管,值得庆幸的是,这个数字的 25% 是注释)。今天,我将描述我在 GHC 中修复的一个这样的空间泄漏。这不是一个完整的故事:我的代码最终是罪魁祸首,所以没有涵盖如何调试你没有写的代码。但我仍然愿意认为这个故事涵盖了一些主要的要点:
我真的很喜欢这个案例,因为我必须按顺序做所有这些事情,才能准确定位并最终修复这个空间泄漏。希望你也能喜欢!
设置一个测试用例
当我最终认真解决这个 bug 时,我想要做的第一件事是制作一个Parser.hs
的简化测试用例,实际上就是导致内存溢出的输入文件。为什么不直接在Parser.hs
上测试呢?
-
大输入导致大量数据,如果你不知道自己在找什么,大量数据会让人感到无所适从和困惑。答案可能显而易见,但如果有太多无用信息,你可能会错过。
-
这是一个让 2GB 内存的机器 OOM 的文件。空间就是时间,使用这么多内存的 Haskell 程序运行时间相对较长。如果我想进行增量更改和重新测试(这是希望),在迭代之间等待半小时是不可取的。
-
这是一个在过去曾对我有效的策略,所以它似乎是一个收集信息的好地方。
实际上,我作弊了:我能够在 GHC 的测试套件中找到另一个大幅较小的测试文件,它与运行在Parser.hs
上时 GHC 的堆分析匹配,所以我将我的注意力转移到那里。
在你的情况下,你可能没有一个“更小”的测试用例可以使用。那么你就需要减少你的测试用例。幸运的是,非源程序的输入往往更容易减少!
-
二分查找一个更小的大小。你的零假设应该是空间泄漏是由更多数据引起的,所以如果你删除一半的输入数据,泄漏仍然应该存在,只是不那么严重。如果可以削减并重新测试,就不要费心搞复杂。
-
有时空间泄漏是由特定类型的输入引起的,在这种情况下,删除输入集合的一半可能会使泄漏消失。在这种情况下,您应该首先测试输入集合的另一半:问题数据可能在那里,您可以继续在该代码块上进行二进制搜索。在最坏的情况下(删除任何一半都会导致泄漏消失),请专心开始有选择地删除您认为“低风险”的行。如果删除一行数据后泄漏消失,则您对算法可能实际发生的事情有了非常好的数据。
-
对于具有依赖关系的输入数据(例如,源代码模块导入),尝试首先通过使用存根数据来消除这些依赖关系。
在最理想的情况下,这个过程只需要几分钟时间。在最坏的情况下,这个过程可能需要一个小时左右,但会为问题的本质提供深刻的见解。事实上,在我得到我的新测试用例之后,我进一步减少了它,直到它成为一个很好的紧凑大小,可以包含在博客文章中:
main = print $ length [
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),
([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0),([0,0,0],0)]
一个普通的堆分析看起来与更大的解析器案例非常相似,即使它是不同的空间泄漏,修复它仍然是值得的。
管道和延续:ezyang 的博客
注意保守提醒。 函数管道提供了一种直观的方法来思考延续:延续传递风格仅仅实现了管道。如果你了解延续,这篇文章可能不会给你太多新东西;否则,我希望这是一个有趣的新视角。为什么你要关心延续?它们通常是实现算法的极快方法,因为它们本质上是纯粹的(管道)流控制。
在 Real World Haskell 中,一种有趣的模式在使用函数组合 (.)
的函数式程序中反复出现,称为:管道。它有几种形式:Lisper 可能将其知为“我需要多少个闭括号?”综合症:
(cons 2 (cdr (cdr (car (car x)))))
Haskeller 可以看到它以多种形式出现:带括号的形式:
sum (map (+2) (toList (transform inputMap)))
或者是 $
符号的行军:
sum $ map (+2) $ toList $ transform inputMap
或者更高阶的组合操作符(正如几位 #haskell
的居民建议的良好风格):
sum . map (+2) . toList . transform $ inputMap
这种最终形式在词法上有一些有趣之处:$
符号将其分为两个标记,一个是函数,一个是输入参数。我可以轻松地复制左侧并将其插入到另一个管道中(与括号相比,插入后我必须手动插入缺失的右括号)。这个函数也是一流的值,我可以以点无关的风格编写它并将其赋给一个变量。
当然,如果我想要移动它,我必须剪切和粘贴它。如果我想把它分成几个小部分,我必须用键盘拉开点。如果我想在一个情况中使用一个管道,在另一个情况中使用另一个管道,我必须决定在编写程序时处于哪种情况下。如果一个程序能在运行时为我做这件事,那岂不是很好?眨眼。
考虑一个类似 Lisp 的语言中的以下管道:
(h (g (f expr)))
当我们提到expr
的“延续”时,通常会尝试将整个管道可视化,移除expr
后,其位置留下一个空洞。这就是延续的概念:
(h (g (f ____)))
就视觉效果而言,它可能更糟。因为延续实际上是一个函数,为了真正准确,我们应该写一些极端无教育意义的东西,如下所示:
(lambda (x) (h (g (f x))))
但这是好的:它准确地捕捉了延续的概念,并且适合更简洁的形式。也就是说,这可以在 Haskell 中以点无关的形式写成:
h . g . f
所以延续就是表达式左侧的管道!
稍微详细一点,涉及更多的管道。 在大多数延续的处理中有两个混合因素:
-
它们并不是用纯语言编写的,一系列连续的操作并不立即适合进行管道化(尽管通过 monad 的力量,我们可以这样做),而且
-
我所给出的例子仍然涉及复制粘贴:通过复制粘贴,我已经忽略了一些细节。程序如何知道当前 continuation 是
h . g . f
?在 callCC 中,它如何知道当前 continuation 何时被调用?
供参考,这里是Cont
单子的一个实现:
newtype Cont r a = Cont { runCont :: (a -> r) -> r }
instance Monad (Cont r) where
return x = Cont (\k -> k x)
(Cont c) >>= f = Cont (\k -> c (\r -> runCont (f r) k))
我的好管道去哪儿了?我看到了很多 lambda 函数...也许Functor
实例会提供更多线索:
instance Functor (Cont r) where
fmap f = \c -> Cont (\k -> runCont c (k . f))
那个小的组合操作符应该显眼:它阐述了这个 Functor 定义的本质。其余的只是管道工作。也就是说,当我们将一些常规函数(或管道)提升到 continuation 单子中时,我们增加了将任意函数组合到其左端的能力。也就是说,k . g . f
,其中k
是我添加的函数(continuation)。更详细地说,从:
g . f
到:
\k -> k . (g . f)
或者,用点:
\x k -> (k . g . f $ x)
现在有一个谜题:假设我有一个函数h
。如果我不在 continuation land 中,我可以将它与g . f
组合为h . g . f
。但如果两者都在 continuation land 中:\k1 -> k1 . (g . f)
和\k2 -> k2 . h
,现在我如何组合它们呢?
k1
处于我通常会放置 h 的位置,所以第一步是将第一个提升的函数应用于第二个提升的函数作为它的参数:
\k1 -> k1 . (g . f) $ \k2 -> k2 . h
(\k2 -> k2 . h) . (g . f)
那不太对;lambda 函数太早地关闭了它的括号。我们想要的是:
\k2 -> k2 . h . (g . f)
通过稍微多想一下(留给读者作为练习),我们找到了正确的形式:
\k -> (\k1 -> k1 . (g . f)) (\r -> (\k2 -> k2 . h) k r)
\-- 1st lifted fn --/ \-- 2nd fn --/
这是 continuation passing style 的基本扭曲思维风格,读者会注意到,我们必须引入两个新的 lambda 函数来使整个过程运行(类似于我们的 Monad 实例)。这是 Continuation 单子的丑陋/优雅内部。此后,还有 newtype 包装和解包的重要问题,以及这实现了 Kleisli 箭头组合((a -> m b) -> (b -> m c) -> a -> m c
,而不是绑定m a -> (a -> m b) -> m b
)。一切留给读者作为练习!(你觉得幸运吗。)
我们的最后一个话题是 callCC,生成有趣 continuation 实例的传统方法。在Cont
单子中普通旧函数的基本特征是它们“不知道自己将去何处”。请注意,在我们所有的例子中,我们假设能够在左侧组合一个函数k
,但实际上并没有指定那个函数是什么:它只是我们 lambda 中的一个参数。这引出了默认隐含 continuation 的概念:如果你不知道自己将去何处,这里有一个地方可以去。你可能会在Cont
单子中编写的 monadic 代码,都在确定这些隐含 continuation 中发挥作用,当你运行 continuation 单子以获得结果时,你必须告诉它在最后要去何处。
callCC
提供了一个“辣”的函数(当前延续),它知道它要去哪里。我们仍然向它传递一个值作为k
(隐式延续),以防它是一个普通的旧函数,但当前延续会忽略它。延续单子中的函数不再必须遵循严谨的\k -> k . f
公式。callCC
的定义如下:
callCC f = Cont (\k -> runCont (f (\x -> Cont (\_ -> k a))) k)
这个“辣”的函数是\x -> Cont (\_ -> k x)
(去掉封装后是\x _ -> k x
),正如我们所见,它忽略了局部当前延续(对应于调用此函数的位置),而是使用了外部上下文中的k
。k
是在callCC
调用时的当前延续。
与管道类似(尽管不完美):考虑一个管道,其中我希望管道中的最后一个函数在成功时是一种类型的函数,在失败时是另一种类型的函数:
\succ fail -> either fail succ . h . g . f
这个管道有两种结果,成功:
\succ _ -> succ . fromRight . h . g . f
或者失败:
\_ fail -> fail . fromLeft . h . g . f
在每种情况下,另一种延续被忽略。对于callCC
来说,关键在于,虽然显而易见如何忽略显式延续,但需要一点思考才能弄清如何忽略隐式延续。但是callCC
生成的延续正是做到了这一点,并且可以在延续单子中的任何地方使用(你只需想出如何将它们放在那里:从callCC
返回它或在带有状态的单子上使用ContT
变换器都是可行的方式)。
注意。逻辑单子使用成功(SK)和失败(FK)延续,而不使用Cont
单子来实现回溯搜索,这表明延续传递风格可以在没有Cont
单子的情况下存在,并且如果从默认隐式延续中不获益,则通常更清晰。Cont
和callCC
特别适合逃逸操作,这并非巧合。
Plan 9 挂载和依赖注入:ezyang 的博客
来源:
blog.ezyang.com/2012/11/plan-9-mounts-and-dependency-injection/
“一切皆文件。”[1] 这是在Plan 9中被推向逻辑极限的设计哲学。你能想象到的任何接口都被表示为一个文件。网络端口、像素缓冲区、内核接口——所有这些都统一在一个常规 API 下:文件操作(open
、read
、write
...)。Plan 9 利用这一点消除了大部分系统调用:只有三十九个,与现代 Linux 的庞大的三百二十六个形成了对比。
当我第一次听说 Plan 9 时,我首先想到的是,“但这是作弊,对吧?”毕竟,他们减少了系统调用的数量,但增加了定制文件的数量:复杂性只是被重新分配了。但是我的一个实验室同事给了我一个理由,说明这仍然很有用:每个进程的挂载点。这些挂载点意味着我可以给每个进程提供他们自己的文件系统视图——通常是相同的,但有时会有一些关键的不同之处。假设我想要隧道化我的一个应用程序的网络连接:这个应用程序将通过某些文件访问网络,因此我可以将一个网络文件系统挂载到另一个系统的网络文件上,并且透明地实现代理,而不需要我的应用程序的任何合作。[2]
让我们暂时放下编程语言的帽子。假设文件是一个抽象数据类型,而用于操作文件的系统调用接口是此数据类型的接口。在这个宇宙中,挂载是什么?我的另一个朋友指出了一个非常明显的类比:
文件:挂载 :: 抽象数据类型:依赖注入
特别是,挂载是一种修改本地命名空间的机制,因此当请求文件时,可能由完全不同的文件系统提供文件,这与进程可能期望的不同。类似地,依赖注入指定了一个命名空间,因此当请求对象时,具体实现可能完全不同于调用者可能期望的内容。
总体结论是,当开发人员实施依赖注入时,他们重新实现了 Plan 9 的本地挂载。你的依赖注入是否是分层的?能否替换层次结构(MREPL
),或在现有文件系统之前(MBEFORE
)或之后(MAFTER
)挂载你的文件?支持挂载时的运行时更改?支持层次结构中实体之间的词法引用(例如点点 ..
)?我怀疑现有的依赖注入框架可以从 Plan 9 的设计中学到一些东西。而在 Haskell 中,似乎人们能够在不创建依赖注入框架的情况下取得更大进展,这些经验是否能映射回可挂载文件系统的设计呢?我在想。
[1] 功能程序员可能会想起一个类似的口号,“一切皆函数。”
[2] 长期以来,Linux 未提供进程级别的挂载命名空间,即使今天这一特性对非特权用户也不可用——相比之下,Plan 9 从一开始就向所有用户提供了这一功能。还有一个小问题,即在 Linux 中,处理进程级挂载实际上非常麻烦,我敢说主要是由于缺乏适当的工具来帮助系统管理员理解他们的应用程序。
多语言编程:ezyang 的博客
多语言编程
在麻省理工学院无限活动期间回到城里,让我想到我想尝试教授什么样的短讲。我一直在思考一个多语言编程课程的想法:这个想法是,虽然大多数人只对一两种编程语言感到非常舒适,但你可以学会如何使这些知识在几乎任何你可能遇到的编程语言中发挥作用。
不幸的是,我对这些技能实际上是什么没有一个好主意,也不知道人们想要知道什么样的东西。我也不认为我能在两个小时的讲座中涵盖这些内容:我可能想涵盖的主题包括:
编程语言的历史. 知道所有这些语言的血统如何联系在一起将帮助您确定语言特性何时按照您的预期工作(因为它们只是从同一条血统的另一种语言中偷走的),以及何时实际上不会工作。它让您可以很好地封装语言特性的主要大思想,然后您可以探索无限变化。它为您提供了大多数具有相同习惯用语的语言组。
街头智慧和引导. 当你开始熟悉一种新语言时,你应该首先看什么?语法?速查表?教程?如何组织这些信息的洪流,该做什么,何处提问,该学会什么。如何解释你完全不了解的错误信息。如何导航开发生态系统并评估你完全不了解的库。如何在你完全不了解的语言中深入源代码。Hello World 之路上的常见颠簸。调试的不寻常和通用方法。
互操作性和 FFI. 这些语言中的高级数据类型的基本构建块是什么样的,它们在内存中是什么样的?如何使许多不同的语言有效地相互通信!关于垃圾收集,引用固定和并发性的问题。语言之间的常见阻抗不匹配。
建议和评论将不胜感激。
Ur/Web 中的多态变体:ezyang 的博客
本文解释了Ur/Web中多态变体的工作原理。编写本文的原因是官方教程没有提及它们,手册只在该主题上留下了一段话,而我在Logitext中使用它们时学到了一些有用的技巧。
什么是多态变体?
对于 OCaml 用户来说,多态变体可能会很熟悉:它们允许您在多种类型中使用变体的标签,而不仅仅是在原始代数数据类型中定义构造函数时需要保持名称唯一:
datatype yn = Yes1 | No1
datatype ynm = Yes2 | No2 | Maybe2
我们可以简单地重用它们:
con yn = variant [Yes = unit, No = unit]
con ynm = variant [Yes = unit, No = unit, Maybe = unit]
如果您有很多构造函数想要在多个逻辑类型之间共享,则这非常方便。不幸的是,它们有许多讨厌的 影响,这主要源于它们向语言引入了子类型化。在 Ur 中,这种固有的子类型化通过与 Ur 记录系统驱动相同的行类型进行调节,因此处理多态变体非常类似于处理记录,并且两者都基于 Ur/Web 的类型级记录。
如何创建多态变体?
要创建多态变体类型,不要应用$
运算符,而是将variant
应用于类型级记录。因此:
$[A = int, B = bool]
生成一个具有两个字段的记录,A
包含一个int
,B
包含一个bool
,而:
variant [A = int, B = bool]
生成一个具有两个构造函数的变体,A
仅包含一个int
,或者B
仅包含一个bool
。
要创建多态变体值,请使用make
函数,该函数需要一个标签(指示构造函数)和值:
make [#A] 2
从技术上讲,在构建多态变体时,您还需要知道此值将与哪些完整集合的构造函数一起使用。通常 Ur/Web 会为您推断出这一点,但这是一个重要的限制,将影响对变体进行操作的代码。make
的完整签名如下:
val make : nm :: Name
-> t ::: Type
-> ts ::: {Type}
-> [[nm] ~ ts]
=> t -> variant ([nm = t] ++ ts)
函数nm
和t
的功能应该是不言自明的,而ts
是类型级记录,用于包含变体中其余值的拼接,附加[nm = t]
以生成一个保证包含nm
的类型级记录。
如何解构多态变量?
使用match
函数,该函数接受一个变量和一个函数记录,指示如何处理该变量的每个可能构造函数:
match t { A = fn a => a + 2,
B = fn b => if b then 3 else 6 }
实际上,变体和记录使用相同类型级记录,尽管记录的类型有些不同,如在匹配类型中所见:
val match : ts ::: {Type} (* the type-level record *)
-> t ::: Type
-> variant ts (* the variant *)
-> $(map (fn t' => t' -> t) ts) (* the record *)
-> t
我可以对变体执行哪些其他操作?
make
和match
是您唯一需要的基本操作:其他所有操作都可以派生出来。但是,meta库中有一个Variant
模块,其中包含用于处理变体的多个有用的派生函数。例如,这对函数:
val read : r ::: {Unit} -> t ::: Type -> folder r
-> $(mapU t r) -> variant (mapU {} r) -> t
val write : r ::: {Unit} -> t ::: Type -> folder r
-> $(mapU t r) -> variant (mapU {} r) -> t -> $(mapU t r)
允许您将变体用作标签,以从同类型记录中投影和编辑值。签名并不难阅读:r
是定义变体的类型级记录,t
是同类型记录的类型,folder r
是记录的折叠器(通常会被推断出),$(mapU t r)
是同类型记录的类型(我们没有写$r
,因为那将是仅包含单元的记录),而variant (mapU {} r)
是充当“标签”的变体。以下是这个库中一些简单函数的一些示例用法:
read {A = 1, B = 2} (make [#A] ())
== 1
write {A = 1, B = 2} (make [#B] ()) 3
== {A = 1, B = 3}
search (fn v => match v {A = fn () => None, B = fn () => Some 2})
== Some 2
find (fn v => match v {A = fn () => True, B = fn () => False})
== Some (make [#A] ())
test [#A] (make [#A] 2)
== Some 2
weaken (make [#A] 2 : variant [A = int])
== make [#A] 2 : variant [A = int, B = int]
eq (make [#A] ()) (make [#B] ())
== False
mp (fn v => match v {A = fn () => 2, B = fn () => True})
== {A = 2, B = True}
fold (fn v i => match v {A = fn () => i + 1, B = fn () => i + 2}) 0
== 3
mapR (fn v x => match v {A = fn i => i * 2, B = fn i => i * 3}) { A = 2 , B = 3 }
== { A = 4, B = 9 }
destrR
做什么?
这个函数的类型有点令人生畏:
val destrR : K --> f :: (K -> Type) -> fr :: (K -> Type) -> t ::: Type
-> (p :: K -> f p -> fr p -> t)
-> r ::: {K} -> folder r -> variant (map f r) -> $(map fr r) -> t
但实际上,它只是一个更一般的match
。match
可以很容易地用destrR
实现:
match [ts] [t] v fs =
destrR [ident] [fn p => p -> t] (fn [p ::_] f x => f x) v fs
当记录不完全是函数,而是包含函数、类型类甚至是当变体是函数而记录是数据时,destrR
提供了更多的灵活性。
是否有更简洁的方法来匹配多个构造函数?
多态变体经常有很多构造函数,它们看起来基本相同:
con tactic a =
[Cut = logic * a * a,
LExact = int,
LConj = int * a,
LDisj = int * a * a,
LImp = int * a * a,
LIff = int * a,
LBot = int,
LTop = int * a,
LNot = int * a,
LForall = int * universe * a,
LExists = int * a,
LContract = int * a,
LWeaken = int * a,
RExact = int,
RConj = int * a * a,
RDisj = int * a,
RImp = int * a,
RIff = int * a * a,
RTop = int,
RBot = int * a,
RNot = int * a,
RForall = int * a,
RExists = int * universe * a,
RWeaken = int * a,
RContract = int * a]
快速填充以与match
匹配的记录很快变得老套,特别是对于任何两个具有相同类型数据的构造函数而言:
let fun empty _ = True
fun single (_, a) = proofComplete a
fun singleQ (_, _, a) = proofComplete a
fun double (_, a, b) = andB (proofComplete a) (proofComplete b)
in match t {Cut = fn (_, a, b) => andB (proofComplete a) (proofComplete b),
LExact = empty,
LBot = empty,
RExact = empty,
RTop = empty,
LConj = single,
LNot = single,
LExists = single,
LContract = single,
LWeaken = single,
LTop = single,
RDisj = single,
RImp = single,
LIff = single,
RNot = single,
RBot = single,
RForall = single,
RContract = single,
RWeaken = single,
LForall = singleQ,
RExists = singleQ,
LDisj = double,
LImp = double,
RIff = double,
RConj = double
}
end
Adam Chlipala 和我开发了一种不错的方法,通过滥用局部类型类来减少这种样板代码,这允许我们依赖 Ur/Web 的推理引擎自动填写处理特定类型元素的函数。这里是使用我们的新方法进行的递归遍历:
let val empty = declareCase (fn _ (_ : int) => True)
val single = declareCase (fn _ (_ : int, a) => proofComplete a)
val singleQ = declareCase (fn _ (_ : int, _ : Universe.r, a) => proofComplete a)
val double = declareCase (fn _ (_ : int, a, b) => andB (proofComplete a) (proofComplete b))
val cut = declareCase (fn _ (_ : Logic.r, a, b) => andB (proofComplete a) (proofComplete b))
in typeCase t end
对于每个变体中的“类型”,您需要编写一个declareCase
函数,它接受该类型并将其转换为所需的返回类型。(作为第一个构造函数,您还会得到一个构造函数,用于创建原始构造函数;例如,declareCase (fn f x => f x)
就是恒等变换。然后您运行typeCase
,并观察魔法发生。更详细的使用说明请参阅variant.urs。)
我该如何扩展我的变体类型?
在编写创建变体类型的元程序时,一个常见的问题是您刚刚创建的变体太窄了:也就是说,在variant ts
中的ts
中的条目不足。当ts
是您正在折叠的记录时,特别是这种情况尤为常见。考虑一个简单的例子,我们想要编写这个函数,它为变体的每个构造函数生成一个构造函数的记录:
fun ctors : ts ::: {Type} -> fl : folder ts -> $(map (fn t => t -> variant ts) ts)
Ur/Web 并不聪明到足以理解这种天真的方法:
fun ctors [ts] fl =
@fold [fn ts' => $(map (fn t => t -> variant ts) ts')]
(fn [nm ::_] [v ::_] [r ::_] [[nm] ~ r] n => n ++ {nm = make [nm]})
{} fl
因为它并不知道 nm
是类型级记录 ts
的成员(Ur/Web 并不直接具有字段包含的编码方式)。
修复此问题的方法是使用一种在变体元程序中反复出现的技巧:使累加器在已处理的字段中多态化。这与值级程序中使用的技巧相同,当通过 foldr
反转列表时,可以观察到您希望将累加器作为一个函数:
con accum r = s :: {Type} -> [r ~ s] => $(map (fn t => t -> variant (r ++ s)) r)
fun ctors [ts] fl =
@fold [accum]
(fn [nm::_] [v::_] [r::_] [[nm] ~ r]
(k : accum r)
[s::_] [[nm = v] ++ r ~ s] => k [[nm = v] ++ s] ++ {nm = make [nm]})
(fn [s::_] [[] ~ s] => {}) fl [[]] !
accum
是累加器的类型,并且我们可以看到它具有新的类型参数 s :: {Type}
。此参数与要处理的字段 r
和当前字段 nm
进行连接,以提供完整的字段集 ts
。在对类似 [A = int, B = bool, C = string]
的记录进行折叠时,我们可以看到:
r = [], nm = A, s = [B = bool, C = string]
r = [A = int], nm = B, s = [C = string]
r = [A = int, B = bool], nm = C, s = []
r
按照通常的折叠方式构建字段,但 s
则反向构建其字段,因为类似于列表反转,只有在整个结构折叠后,s
才能确定,并且现在在外部逐层评估类型函数的堆栈。因此,很容易看出 k [[nm = v] ++ s]
总是具有正确的类型。
结论
Ur/Web 中的多态变体非常有用,并且避免了与无限制子类型化相关的许多问题。Logitext 最初并不打算使用多态变体,但当发现它们是通过元编程快速实现 JSON 序列化的最可靠方法时,我们采用了它们,并且我们也开始欣赏它们在各种其他情境中的元编程能力。与传统代数数据类型相比,它们可能最大的缺点是缺乏递归,但这也可以通过在 Ur/Web 的模块系统中手动实现 mu 操作符来模拟。我希望本教程已经为您提供了足够的知识,以便自己使用多态变体,并且也可以通过它们进行一些元编程。