EZYang-博客翻译-二-
EZYang 博客翻译(二)
POPL:ezyang 的博客
POPL
昨晚,我从我非常第一次的 POPL 会议回来,感到非常疲惫,也非常满意。能够见到名字背后的人,与来自美国和英国的潜在博士导师交谈,并享受氛围,真是太棒了。
我文件中的亮点:
-
尽管托尼·霍尔已经获得了很多其他奖项,“被授予 ACM SIGPLAN 编程语言成就奖让我感到有点内疚。我没有主动申请它!”
-
Hoare: “我喜欢错误;如果是我的错,我可以改正。如果是别人的错,那就没什么可做的。”
-
Hoare: “简单并不是工程的目标。”
-
Tony 刚刚描述了他如何撤回一篇被接受的论文,因为其中的推理很复杂,他认为这并没有展示程序证明的良好形象。采访者称赞他的原则,说如果他接受了一篇他认为不合格的论文,他可能没有勇气撤回它。这真是“非常勇敢”。对此,Tony 回答道:“嗯,我希望你也能!”[笑声]“不幸的是,出版的压力已经增加。我们感到每年都必须出版,而平均论文的质量并没有提高。”
-
一个重复出现的主题:Tony Hoare 提到证明和测试并不是竞争对手,实际上它们只是同一件事……只是不同而已。在“Run your Research”讲座中,这个主题再次出现,这次强调的是可执行论文(“Run”中的“Run your Research”)。
-
“不要仅仅支持局部推理,要求它!”(不允许蛮力证明!)
-
在 JavaScript 中:“null 有点像对象,而 undefined 有点像原始类型。”
-
接下来的一组来自计算机网络社区的受邀演讲者。“在 9/11 期间,平均来说,互联网更加稳定。原因在于 NetOps 回家了。”(关于实际中网络中断的原因。)
-
“思科路由器有两千万行代码:实际上里面有一个 Ada 解释器,以及一个 Lisp 解释器。”
-
“过去允许出现 100ms 的延迟……现在我们有了视频游戏。”
-
演讲者:“我是海象。”(咕咕叽喔。)
-
“我认为我们并没有重复使用定理。我们重复使用证明方法,但并不重复使用实际定理。当我们写论文时,我们创造了非常浅显的模型,我们并不建立在以前的工作基础上。这没问题。这就是设计。这并不是太重要。SML 标准发布时带有一个 bug 报告,原始定义中有 100 多个错误。但这并不减少它的影响。”
-
“戴上代数的护目镜。”
-
“海军无法安装它[一个系统,用于检测在不安全通道上传输机密词汇],因为这样做等于承认有错误存在。”
-
“转向一些真正现代的东西,比如 Scheme!”(就像投资一万亿,并从十三世纪移到十四世纪。)
-
Strother Moore(ACL2 的共同创始人):“退休后,我会更多地致力于 ACL2,然后我会死去。”
-
“确保你的 C 程序符合规范的最佳方法是什么?” “编写确定性代码。” [笑声]
编程语言实用基础(第一印象):ezyang 的博客
来源:
blog.ezyang.com/2012/08/practical-foundations-for-programming-languages/
编程语言实用基础(第一印象)
罗伯特·哈珀最近(有点)发布了一本书的预印本(PDF),他一直在努力完成,编程语言实用基础。我在初次发布时下载了一份副本,但一直拖延没有真正深入研究这本大约 590 页的书。直到哈珀最近在他的一篇最新博文中成功引诱我,我才最终坐下来更加仔细地浏览了一下。
立即诱惑的是将 PFPL 与本杰明·皮尔斯的开创性著作类型与编程语言进行比较。乍一看,两者似乎在内容和展示方式上有相当多的重叠。两本书都从非常简单的编程语言开始,然后逐步添加功能,以解释编程语言设计中逐渐复杂的主题。但是 PFPL 在许多方面有意与 TAPL 不同。出于意识形态的原因,哈珀完全跳过了无类型语言,直接进入具有变量 let 绑定的类型语言,以立即引入类型、上下文和安全性。这种展示方式更为简洁,对于没有编程语言经验的新手来说,可能会感觉 PFPL 更像是一本参考手册而不是教科书—一个评论者将其比作数学教科书。(与此无关的是,哈珀的介绍性课程15-312 编程语言原理,使用了 PFPL,确实从无类型 lambda 演算开始。)
尽管如此,这种简洁性对于 PFPL 是一种资产;首先,它使哈珀能够涵盖大量内容,涉及 TAPL 根本不涉及的主题。简洁性也并不意味着哈珀“遗漏了任何内容”,每一章都是自包含的,并且在其选择涵盖的主题上全面而详尽。这也使得像我这样对讨论的主题有些熟悉但从不同视角看待和思考的人们来说,阅读起来颇具乐趣。
Harper 一直在博客中写他的书,我认为他的博客文章很好地指示了书中哪些部分特别值得关注。Harper 在他的博客文章中采用了“全凭直觉”的风格,并将大部分形式主义留给了他的书。我认为这很遗憾,因为他定义的形式主义是相当容易理解的,会让他的许多读者(至少从评论部分来看如此!)更加清楚。以下是一些配对:
-
动态语言是静态语言 是第十八章“动态类型”的伴侣。在那里,他开发了动态 PCF(本质上是 Lisp 的核心),并展示了通常的具体语法掩盖了发生的标记,而通常的动态掩盖了在动态类型语言中普遍存在的冗余检查。在这些圣战中,总是有一种诱惑,试图扩展论点的范围,但如果你接受动态 PCF 作为表述辩论一个方面的有效方式,它是极其精确的。
-
引用透明性 是第三十二章“符号”的伴侣。符号有些奇怪,因为大多数语言甚至没有一种方式来承认这个概念的存在。你可以将它视为一个“广义可变单元”的标识符,除非你实际访问它,但实际上你应该只读形式化处理,因为它非常简单。
-
词语的重要性 是第三十六章“可赋值引用”的伴侣。这是一个简单的术语分割,受 Harper 在他的书第一章中对术语“变量”的定义所启发。
-
Haskell is Exceptionally Unsafe 是第三十四章“动态分类”的伴侣。文章认为能够在运行时生成异常类别非常重要(这里的“类别”具有非常精确的含义,即它是有限求和的索引,本例中为异常类型;这在第十二章中有详细讨论)。至少在 Haskell 社区中,这并不是一个特别常见的“动态”用法(尽管我同意 Harper 认为这是正确的用法),而 PFPL 确切地解释了它的含义,没有多余也没有少数。
总的来说,编程语言实用基础非常值得一读。人们很少真正从头到尾地阅读教科书,但如果你发现自己阅读了 Harper 的博客文章并感到困惑,请给配套的章节一个机会。即使是我读过的书的一小部分,PFPL 也教会了我新的东西,并澄清了我的思绪。
FFI API 设计原则:ezyang 的博客
本文是关于c2hs 的六部分教程系列中的第三部分。今天,我们从 FFI 绑定的细节回顾中退后一步,讨论您的库的更一般设计原则。
一方面,编写 FFI 绑定可能只不过是生成胶水代码,让您能够在“Haskell 中使用 C”,而您的库的 API 则完全取决于原始库的作者的心血。另一方面,您可以立志使您的接口与纯 Haskell 编写的内容无异,引入自己的适度创新,以将 C 代码中非正式记录的不变量编码到类型系统中。
整体设计. 更大的绑定从分为两个层面中受益:低级绑定和更高级的用户友好绑定。使 C 函数可供 Haskell 调用需要大量的代码,将其存储在自己的命名空间中是显而易见的,通常名称中包含Internal
。
低级设计. 在低级绑定中,应按照 C 头文件的方式组织您的外部导入项。保持名称相似。虽然不可能拥有相同的 C 函数名称和 Haskell 函数名称——C 函数允许以大写字母开头,而 Haskell 函数不允许(类型和数据构造函数相反),但仍可以采用一致的转换。默认情况下,c2hs 将 C 中的Foo_BarBaz
转换为fooBarBaz
;即在下划线后的单词大写,第一个字母不大写,并移除下划线。
然而,原始 API 还有改进的空间。经验法则是,如果可以进行改进安全性或可用性的非侵入/局部更改,则应该这样做。这些包括:
-
将普通 C 值(如
int
、float
甚至char*
,如果它是以空字符结尾的字符串)转换为它们自然的 Haskell 形式(Int
、Float
和String
)。需要小心,因为本地 Haskell 类型会损失其 C 对应类型的精度,因此应确保应用程序不需要挤出每一个更高的位(例如通过位字段)。80%的时间,可以接受有损转换, -
将
int
从某种命名约定转换为Bool
(也许布尔值以f
作为flag
的前缀), -
将
malloc
分配的指针放入外部指针的内存管理中。这个建议值得重复强调:Haskell 有内存管理,而且尽快使用它是非常重要的。此外,您不必编写显式的 Haskell 释放函数。 -
将一些初始化某些内存空间的函数(
set_struct_default
)转化为使用unsafePerformIO
、alloca
和peek
的纯版本(structDefault
)。请注意,你应该与适当的Storable
实例一起执行此操作,以将 C 结构体转换为持久的 Haskell 数据类型。 -
将更复杂的 C 值(主要是数组)转换为 Haskell 列表,假设边界信息一致且本地可用。
我们将在接下来的帖子中更详细地讨论这些技术,因为这正是 c2hs 最常用的地方。
知道何时不进行简化是有用的:某些类型的库可能对大型结构有高效的内存表示;将它们从 Haskell 中逐出和入驻是浪费的。编写不好的 C 代码也可能会给你传递数组,你很难找到它们的长度;推迟它们的逐出和入驻到更高级别的接口可能是一个更好的选择。决定哪些结构明确地不逐出和跨越整个委员会。我的偏好是逐出不包含指针的扁平结构,其他的什么都不做。
高级设计。虽然肯定有像箭头、应用函子和余函子这样的异国情调的计算结构,在某些领域可能很有用,但我们将限制讨论到 Haskell 程序员常用的工具:纯函数和单子。
-
纯函数。将 C 语言构建的可变底层转化为更为宝贵的纯函数和持久数据结构是一个棘手的任务,充满了
unsafePerformIO
。特别是,仅仅因为一个 C 函数表面上看起来不涉及任何变异,它可能执行一些共享状态改变或重新平衡输入数据结构,或者在失败时进行printf
,你必须考虑到这一点。除非你的文档非常好,否则你需要进行源代码深入挖掘来手动验证不变量。将一些可参考透明的函数变成纯函数是一个珍贵的商品,可以轻松转换。从这里开始,你需要做出关于库如何使用和不使用的决定。一组内部状态变换函数可能不适合纯处理,但也许一个将它们一起编排的函数不会泄露共享状态。本来打算被改变的数据结构可以转换为不可变的 Haskell 版本,或者通过你的 API 冻结,不向最终用户公开它们的变异方法(好吧,除了带有
unsafe
前缀的方法)。 -
单子。首先是一个显而易见的选择:你是将所有函数都扔进 IO 单子中,还是给用户一个更为受限的单子堆栈,这个堆栈在底层执行 IO 操作,但只允许用户执行与你的库相关的操作。(这并不难做到:你可以使用
newtype
定义你的单子堆栈,然后简单地不导出构造函数并省略MonadIO
实例。)newtype MyMonad a = MyMonad { unMyMonad :: ReaderT Env IO a } deriving (MonadReader Env, Monad, Functor)
您将经常传递隐藏状态,以指针的形式。这些应该用新类型封装起来,而不暴露给最终用户。有时,这些将是指针的指针,例如具有参数
**ppFoo
的库的情况,它接受您的指针并将其重写以指向其他地方,吞并原始对象。newtype OpaqueStruct = OpaqueStruct { unOpaqueStruct :: ForeignPtr (Ptr CStruct) }
共享状态意味着线程安全也成为重要问题。Haskell 是一种非常友好的多线程语言,作为库的用户,很容易假设任何给定的库都是线程安全的。这是任何库作者值得追求的一个令人钦佩的目标,但这一目标因您依赖于基于 C 的库而变得更加困难。幸运的是,Haskell 提供了使线程安全变得更加容易的原语,特别是 MVar、TVar 和 TMVar;只需将您的指针存储在这个共享变量中,并且不让任何其他人使用这个指针。对于复杂的指针图形,需要额外的注意确保,如果您有一个表示某些共享状态锁的 MVar,那么没有其他 C 代码会随意使用别处隐藏的指针。当然,如果您有持久化结构,维护一致性就变得微不足道。
withMVar (unOpaqueStruct o) $ \o_ -> withForeignPtr o_ $ \p -> -- peek ’n poke the piggy, erm, pointer
一种特别好的技术,用于防止最终用户从您的美丽线程安全部分中走私指针,是应用类似于
ST
单子的二阶类型。基本假设是,您编写一个类型为(forall s. m s a) -> a
的函数。对该函数参数的forall
约束要求结果a
在其类型中不包含s
(对于技术上更倾向的人来说,forall
是一个声明,我应该能够将任何s
放在声明中,并且它是有效的。如果某个特定的s'
在a
中,那么只有当我将我的s
设置为那个s'
时,它才有效,而且没有其他s
)。因此,您只需将幻影类型变量s
添加到任何您不希望从单子中走私出去的数据类型中,类型系统将会处理其余的事情。单子区域在此基本概念上构建,赋予其组合性(区域多态性)。newtype LockedMonad i a = LockedMonad { unLockedMonad :: ReaderT Env IO a } deriving (MonadReader Env, Monad, Functor) runLockedMonad :: (forall i. LockedMonad i a) -> IO a runLockedMonad m = runReaderT (unLockedMonad m) =<< newEnv data LockedData i a = LockedData a
我们不会讨论这些想法作为 c2hs 的一部分;预处理器的使用在设计过程的大部分时间内是独立的。但是,这确实是一个非常有趣的主题!
下次再讲. c2hs 的第一步
Prio:私密、强大和可扩展的聚合统计计算:ezyang 的博客
来源:
blog.ezyang.com/2017/03/prio-private-robust-and-scalable-computation-of-aggregate-statistics/
我想借此机会宣传一下我的一位同事Henry Corrigan-Gibbs(与备受尊敬的 Dan Boneh 合作)关于在收集聚合统计数据时保护隐私的一些新工作。他们的新系统名为Prio,将出现在今年的 NSDI 上。
他们解决的基本问题是:假设你是谷歌,你想收集一些关于用户的统计数据以计算一些聚合指标,例如平均值或线性回归拟合:
一个大问题是如何在不损害用户隐私的情况下收集这些数据。为了保护隐私,你不想知道每个个别用户的数据:你希望以完全匿名的形式获取这些数据,并且只在收集期结束时获得一个聚合统计数据。
这是一个古老的问题;有许多现有系统在实现这一目标时有不同的权衡。Prio 解决了在私人聚合数据收集领域中一个特别棘手的问题:面对恶意客户的强大性。假设你正在为线性回归收集数据,而你的客户发送给你的输入是完全匿名的。一个恶意客户可能会发送给你一个糟糕的数据点,这可能会使整个数据集产生偏差;而且由于你从未看到数据集的个别数据点,你永远也不会注意到:
因此,Prio 关注匿名收集数据的问题,同时能够验证数据是否合理。
Prio 实现这一目标的机制非常酷,因此在这篇文章中,我想解释一下他们协议的关键见解。Prio 在一个客户秘密共享他们的秘密给一组被假定为不串通的服务器的情况下运作;只要至少有一个服务器是诚实的,直到服务器共同同意发布聚合统计数据之前,关于客户的秘密不会被透露。
这里是问题所在:给定某个隐藏值的秘密共享,我们如何高效地检查它是否有效?为了回答这个问题,我们首先必须解释一下秘密共享的世界。
秘密分享方案允许您将一个秘密分成许多片段,以便原始秘密除非您拥有某些片段的子集,否则无法恢复。有惊人简单的秘密分享构造:假设您的秘密是某个域中的数字x(例如,模某个质数p的整数),并且您希望将其分成n部分。然后,让前n-1份额是域中的随机数,最后一个随机数是x减去前面份额的总和。通过将所有份额相加来重建秘密。此方案在信息理论上是安全的:仅使用n-1份额,您对底层秘密不知道任何信息。此秘密分享方案的另一个有趣属性是,它在加法上是同态的。让你的x和y的份额分别是 和 :那么 形成x + y的秘密分享,因为域中的加法是可交换的(因此我可以将每对总和重新分配为 x 的总和和 y 的总和)。
通常,设计支持同态加法的方案很容易,但是具有同时支持加法和乘法(以便您可以计算有趣的算术电路)的方案则稍微困难一些。假设您希望在某个秘密分享值上计算算术电路:加法很容易,但要执行乘法,大多数多方计算方案(Prio 使用Beaver 的 MPC 协议)都要求您进行一轮通信:
虽然您可以批量处理电路中相同“级别”的乘法,以便您只需执行电路中最大乘法深度的轮次,但对于大型电路,您可能需要进行相当多的通信。亨利告诉我,全同态秘密分享已经成为一些正在进行的研究课题;例如,去年Crypto 大会关于同态秘密分享的论文获得了最佳论文奖。
返回到 Prio,回想一下我们有用户提供输入的秘密分享,并且我们想要检查它是否根据某个算术电路有效。正如我们上面看到的那样,我们可以尝试使用多方计算协议来计算电路输出的份额,揭示电路的输出:如果它说输入有效,则接受它。但这将需要相当多的通信轮次来实际进行计算!
Prio 的一个关键洞察是:我们不需要服务器来计算电路的结果--一个诚实的客户端完全可以做到这一点--我们只需要它们来验证电路计算的有效性。这可以通过客户端发送电路中每条线上所有中间值的份额,让服务器重新计算这些份额的乘积,然后将结果与客户端提供的中间值进行比较来实现:
当我们将问题从计算问题转换为验证问题时,我们现在有一个尴尬并行的验证电路,只需要一个轮次来乘以电路的每个中间节点。
最后有一个问题:我们如何检查重新计算的份额乘积和客户端提供的中间值是否一致?我们不能发布电线的中间值(这将泄露有关输入的信息!)我们可以建立一个更大的电路来进行比较并将结果组合在一起,但这将需要更多的通信轮次。
为了解决这个问题,Prio 采用了 Ben-Sasson'12 的一个巧妙技巧(《近线性无条件安全的多方计算与不诚实少数派》):不是公开所有中间线的详细内容,而是将它们视为多项式,并在随机点上发布每个多项式的评估。如果服务器行为正确,它们不会泄露任何关于原始多项式的信息;而且很可能,如果原始多项式不相等,那么在随机点上的多项式评估也不会相等。
这一切都非常精彩,但我想以一个警示故事来总结一下:在设置这些多项式时,你必须非常小心。这里有个陷阱:假设一个恶意的服务器同态地修改了它们输入的份额之一,例如,添加了一些增量。由于我们的秘密份额是可加的,将增量添加到一个份额会导致秘密也被这个增量修改!如果对手可以用这个修改后的份额完成协议的其余部分,当协议运行结束时,他会发现修改后的秘密是否有效。这会泄露关于输入的信息:如果你的有效性测试是“输入是否为 0 或 1”,那么如果你(同态地)给输入加一,并且它仍然有效,你就知道它肯定是零!
幸运的是,这个问题可以通过随机化多项式来解决,因此即使输入份额被移动,它计算的其余中间值也不能以相同的方式移动。详细内容请参阅“为什么要随机化多项式?”部分。我认为这只是展示了加密系统设计有多棘手的一个例子!
无论如何,如果这引起了你的兴趣,去阅读这篇论文吧!如果你在麻省理工学院,你还可以在3 月 22 日参加亨利在麻省理工学院 CSAIL 安全研讨会上的演讲。
问题集:Codensity 变换:ezyang's 博客
来源:
blog.ezyang.com/2012/01/problem-set-the-codensity-transformation/
你是否曾经想过codensity 变换,这是一种惊人通用的技巧,可以加速某些类型的单子执行,但始终无法理解论文或 Edward Kmett 在这个主题上的博文?
不用再找了:以下是学习这个变换如何工作的问题集。
这些练习的理念是让你熟悉与 codensity 变换相关的类型,通过使用这些类型来指导你自己找到唯一可能的实现方式。我们从经典的叶树具体实例开始热身,然后泛化到所有自由单子上(如果你不知道是什么也别担心:我们会定义它并给出一些热身练习)。
有经验在延续传递风格中编写可能会很有用,尽管在实践中这归结为“听从类型!”
更多解答和评论可在 Janis Voigtlander 的论文 "计算在自由单子上的渐近改进" 中找到。
要了解更多,请参阅 Edward Kmett 的优秀文章,进一步概括了这个概念:
如果有需求,我可以为练习添加提示部分。
{-# LANGUAGE Rank2Types, MultiParamTypeClasses, FlexibleInstances #-}
import Prelude hiding (abs)
_EXERCISE_ = undefined
-----------------------------------------------------------------------------
-- Warmup: Hughes lists
-----------------------------------------------------------------------------
-- Experienced Haskellers should feel free to skip this section.
-- We first consider the problem of left-associative list append. In
-- order to see the difficulty, we will hand-evaluate a lazy language.
-- For the sake of being as mechanical as possible, here are the
-- operational semantics, where e1, e2 are expressions and x is a
-- variable, and e1[e2/x] is replace all instances of x in e1 with e2.
--
-- e1 ==> e1'
-- ---------------------
-- e1 e2 ==> e1' e2
--
-- (\x -> e1[x]) e2 ==> e1[e2/x]
--
-- For reference, the definition of append is as follows:
--
-- a ++ b = foldr (:) b a
--
-- Assume that, on forcing a saturated foldr, its third argument is
-- forced, as follows:
--
-- e1 ==> e1'
-- -----------------------------------
-- foldr f e2 e1 ==> foldr f e2 e1'
--
-- foldr f e2 (x:xs) ==> f x (foldr f e2 xs)
--
-- Hand evaluate this implementation by forcing the head constructor,
-- assuming 'as' is not null:
listsample as bs cs = (as ++ bs) ++ cs
-- Solution:
--
-- (as ++ bs) ++ cs
-- = foldr (:) cs (as ++ bs)
-- = foldr (:) cs (foldr (:) bs as)
-- = foldr (:) cs (foldr (:) bs (a:as'))
-- = foldr (:) cs (a : foldr (:) b as')
-- = a : foldr (:) cs (foldr (:) bs as')
--
-- Convince yourself that this takes linear time per append, and that
-- processing each element of the resulting tail of the list will also
-- take linear time.
-- We now present Hughes lists:
type Hughes a = [a] -> [a]
listrep :: Hughes a -> [a]
listrep = _EXERCISE_
append :: Hughes a -> Hughes a -> Hughes a
append = _EXERCISE_
-- Now, hand evaluate your implementation on this sample, assuming all
-- arguments are saturated.
listsample' a b c = listrep (append (append a b) c)
-- Solution:
--
-- listrep (append (append a b) c)
-- = (\l -> l []) (append (append a b) c)
-- = (append (append a b) c) []
-- = (\z -> (append a b) (c z)) []
-- = (append a b) (c [])
-- = (\z -> a (b z)) (c [])
-- = a (b (c []))
--
-- Convince yourself that the result requires only constant time per
-- element, assuming a, b and c are of the form (\z -> a1:a2:...:z).
-- Notice the left-associativity has been converted into
-- right-associative function application.
-- The codensity transformation operates on similar principles. This
-- ends the warmup.
-----------------------------------------------------------------------------
-- Case for leafy trees
-----------------------------------------------------------------------------
-- Some simple definitions of trees
data Tree a = Leaf a | Node (Tree a) (Tree a)
-- Here is the obvious monad definition for trees, where each leaf
-- is substituted with a new tree.
instance Monad Tree where
return = Leaf
Leaf a >>= f = f a
Node l r >>= f = Node (l >>= f) (r >>= f)
-- You should convince yourself of the performance problem with this
-- code by considering what happens if you force it to normal form.
sample = (Leaf 0 >>= f) >>= f
where f n = Node (Leaf (n + 1)) (Leaf (n + 1))
-- Let's fix this problem. Now abstract over the /leaves/ of the tree
newtype CTree a = CTree { unCTree :: forall r. (a -> Tree r) -> Tree r }
-- Please write functions which witness the isomorphism between the
-- abstract and concrete versions of trees.
treerep :: Tree a -> CTree a
treerep = _EXERCISE_
treeabs :: CTree a -> Tree a
treeabs = _EXERCISE_
-- How do you construct a node in the case of the abstract version?
-- It is trivial for concrete trees.
class Monad m => TreeLike m where
node :: m a -> m a -> m a
leaf :: a -> m a
leaf = return
instance TreeLike Tree where
node = Node
instance TreeLike CTree where
node = _EXERCISE_
-- As they are isomorphic, the monad instance carries over too. Don't
-- use rep/abs in your implementation.
instance Monad CTree where
return = _EXERCISE_
(>>=) = _EXERCISE_ -- try explicitly writing out the types of the arguments
-- We now gain efficiency by operating on the /abstracted/ version as
-- opposed to the ordinary one.
treeimprove :: (forall m. TreeLike m => m a) -> Tree a
treeimprove m = treeabs m
-- You should convince yourself of the efficiency of this code.
-- Remember that expressions inside lambda abstraction don't evaluate
-- until the lambda is applied.
sample' = treeabs ((leaf 0 >>= f) >>= f)
where f n = node (leaf (n + 1)) (leaf (n + 1))
-----------------------------------------------------------------------------
-- General case
-----------------------------------------------------------------------------
-- Basic properties about free monads
data Free f a = Return a | Wrap (f (Free f a))
instance Functor f => Monad (Free f) where
return = _EXERCISE_
(>>=) = _EXERCISE_ -- tricky!
-- Leafy trees are a special case, with F as the functor. Please write
-- functions which witness this isomorphism.
data F a = N a a
freeFToTree :: Free F a -> Tree a
freeFToTree = _EXERCISE_
treeToFreeF :: Tree a -> Free F a
treeToFreeF = _EXERCISE_
-- We now define an abstract version of arbitrary monads, analogous to
-- abstracted trees. Witness an isomorphism.
newtype C m a = C { unC :: forall r. (a -> m r) -> m r }
rep :: Monad m => m a -> C m a
rep = _EXERCISE_
abs :: Monad m => C m a -> m a
abs = _EXERCISE_
-- Implement the monad instance from scratch, without rep/abs.
instance Monad (C m) where
return = _EXERCISE_
(>>=) = _EXERCISE_ -- also tricky; if you get stuck, look at the
-- implementation for CTrees
-- By analogy of TreeLike for free monads, this typeclass allows
-- the construction of non-Return values.
class (Functor f, Monad m) => FreeLike f m where
wrap :: f (m a) -> m a
instance Functor f => FreeLike f (Free f) where
wrap = Wrap
instance FreeLike f m => FreeLike f (C m) where
-- Toughest one of the bunch. Remember that you have 'wrap' available for the
-- inner type as well as functor and monad instances.
wrap = _EXERCISE_
-- And for our fruits, we now have a fully abstract improver!
improve :: Functor f => (forall m. FreeLike f m => m a) -> Free f a
improve m = abs m
-- Bonus: Why is the universal quantification over 'r' needed? What if
-- we wrote C r m a = ...? Try copypasting your definitions for that
-- case.
Proposal: 建议为 Foldable 的 length 及其伙伴添加显式类型应用:ezyang 的博客
来源:
blog.ezyang.com/2017/03/proposal-suggest-explicit-type-application-for-foldable-length/
tl;dr 如果你使用类似于 length 或 null 这样的可折叠函数,其中实例选择完全由输入参数决定,那么通过引入显式类型应用来使你的代码更加健壮是很有必要的。对于像 fold 这样的函数来说,如果你的类型匹配不正确,返回类型可以进行交叉检查,因此这不是必要的。如果你没有提供这种类型应用,GHC 应该会发出警告建议你显式注释,就像它建议在顶层函数中添加显式类型签名一样。
最近,有些人对Foldable 实例导致“坏”代码编译有所争论。典型例子是这样的:你写了length (f x)
,其中f
是返回列表[Int]
的函数。在未来的某个时刻,同事重构f
以返回(Warnings, [Int])
。在重构后,length (f x)
是否继续类型检查?是的:length (f x)
将始终返回 1,无论内部列表有多长,因为它使用了(,) Warnings
的Foldable
实例。
邮件列表中提出的解决方案是移除Either
的Foldable
,这种疗法可以说比病情更糟糕。但我认为抱怨Foldable
对元组和Either
的实例使你能够编写类型检查但完全错误的代码确实有其道理。
Richard Eisenberg将这个问题描述为“如果它编译,那么它就有效!”与一般的多态代码之间的紧张关系,后者应该在尽可能多的情况下适用。然而,我认为这里有些微妙之处。为什么Functor
多态代码从来不会因为“太通用”而引起问题,但Foldable
会?我们可以构造一个类似的情况:我写了fmap (+2) (f x)
,其中f
再次返回[Int]
。当我的同事将f
重构为返回(Warnings, [Int])
时,fmap
现在使用了(,) Warnings
的Functor
实例,但代码仍然无法编译,因为(+1)
的类型与[Int]
不匹配。是的,我们仍然可以构造出fmap
在类型更改后继续工作的情况,但这些情况要少得多。
这两个程序之间有一个明显的区别:fmap
程序是冗余的,因为类型受输入容器、映射在其上的函数以及使用结果的上下文的约束。就像纠错码一样,冗余使我们能够检测到错误的发生;当你减少冗余时,错误变得更难检测。对于 length
,对所选实例的唯一约束是输入参数;如果你搞错了,我们就无法判断出来。
因此,正确的做法是在需要的地方重新引入冗余。像 fold
和 toList
这样的函数不需要额外的冗余,因为它们通过它们的返回参数的使用进行交叉检查。但是像 length
和 null
(以及可能是 maximum
,它仅弱约束其参数具有 Ord
实例)这样的函数没有任何冗余:我们应该在这些地方引入冗余!
幸运的是,使用 GHC 8.0 提供了一种非常简单的方法来引入这种冗余:显式类型应用。(这也是独立地由Faucelme 建议的。)在这种情况下,不再写 length (f x)
,而是写 length @[] (f x)
,表示你想要列表的长度。如果你想要映射的长度,你会写 length @(Map _) (f x)
。现在,如果有人改变了 f
的类型,由于显式类型应用不再匹配,你将会得到一个类型错误。
现在,你可以在你的 FTP 代码中写入这个。因此,我建议我们向 GHC 添加一个小小的改进:让用户指定函数的类型参数为“建议为显式”。在调用点,如果这个函数在没有给定类型应用的情况下被使用,GHC 将会发出一个警告(可以通过通常的机制禁用),并说:“嘿,我在这个类型上使用了这个函数,也许你应该添加一个类型应用。”如果你真的想要抑制警告,你可以简单地在类型上应用一个类型空位,例如 length @_ (f x)
。作为一个小小的改进,你还可以指定一个“默认”类型参数,这样如果我们推断出这个参数,就不会发出警告(这将让你在不需要显式指定类型参数的情况下使用列表函数)。
就是这样!没有 BC 破坏性标志日,没有污染函数,没有摆脱 FTP,没有丢弃实例:只是一个新的编译指示,和一个可选的警告,让那些想要避免这些 bug 的人们能够使用它。这不会解决所有 Foldable
的 bug,但应该能够消除最明显的一些。
大家怎么看?
pthread_cancel on Windows : ezyang’s blog
来源:
blog.ezyang.com/2010/09/pthread-cancel-on-window/
Edward,很抱歉,我有些坏消息。你的 可中断 GHC 补丁;在移植到 Windows 途中遇到了一场可怕的事故。希望你理解:我们正在尽力修复它,但出现了一些复杂情况...
小测验!这段 pthreads 代码做什么?
#include <pthread.h>
#include <stdio.h>
void *thread1(void *arg) { sleep(10000); }
void *thread2(void *arg) { while (1) {} }
void *psycho_killer(void *arg) {
pthread_t *id = (pthread_t*)arg;
pthread_cancel(*id);
printf("[%p] Psycho killer...\n", id);
pthread_join(*id, NULL);
printf("[%p] ...qu'est-ce que c'est.\n", id);
}
int main(char* argv, int argc) {
pthread_t t1, t2, k1, k2;
pthread_create(&t1, NULL, thread1, NULL);
printf("[%p] I can't sleep 'cause my bed's on fire\n", &t1);
pthread_create(&t2, NULL, thread2, NULL);
printf("[%p] Don't touch me I'm a real live wire\n", &t2);
pthread_create(&k1, NULL, psycho_killer, &t1);
pthread_create(&k2, NULL, psycho_killer, &t2);
pthread_join(k1, NULL);
pthread_join(k2, NULL);
printf("Run run run away!\n");
return 0;
}
它从未成功终止第二个线程...
ezyang@javelin:~/Desktop$ ./test
[0xbf900b4c] I can't sleep 'cause my bed's on fire
[0xbf900b48] Don't touch me I'm a real live wire
[0xbf900b4c] Psycho killer...
[0xbf900b4c] ...qu'est-ce que c'est.
[0xbf900b48] Psycho killer...
^C
如果你只有 pthread_cancel
和 pthread_setcancelstate
的手册页,这可能有点神秘。但 pthreads
页面很清楚:sleep
是一百零二个“可取消”函数之一,如果线程的取消状态为 PTHREAD_CANCEL_DEFERRED
,则必须在其中显式允许延迟取消,使用 pthread_testcancel
。早期的 POSIX 规范版本在系统调用入口或系统调用运行期间是否应进行取消有些不清楚,但 2008 规范 比较明确:
当线程执行以下函数时应发生取消点...
百万美元问题是:“我们能在 Windows 上实现相同的语义吗?”实际上,因为看起来很多人都希望在 Windows 上拥有 pthreads 的功能,你会认为这已经由 pthreads-win32 实现了。我们去看看源代码!
if (tp->cancelType == PTHREAD_CANCEL_ASYNCHRONOUS
&& tp->cancelState == PTHREAD_CANCEL_ENABLE
&& tp->state < PThreadStateCanceling)
{
/* snip */
}
else
{
/*
* Set for deferred cancellation.
*/
if (tp->state < PThreadStateCancelPending)
{
tp->state = PThreadStateCancelPending;
if (!SetEvent (tp->cancelEvent))
{
result = ESRCH;
}
}
else if (tp->state >= PThreadStateCanceling)
{
result = ESRCH;
}
(void) pthread_mutex_unlock (&tp->cancelLock);
}
有趣的是,pthreads-win32 似乎并没有做任何特殊处理:当我们将我们的测试程序翻译并在 pthreads-win32 上运行时,它也在 Sleep
调用上卡住了:
C:\Users\ezyang\pthreads-win32\Pre-built.2\lib>test.exe
[0022FF40] I can't sleep 'cause my bed's on fire
[0022FF38] Don't touch me I'm a real live wire
[0022FF40] Psycho killer...
[0022FF38] Psycho killer...
^C
此时,值得稍作停顿,问一问:“我们到底想做什么?”如果你问如何在 Stack Overflow 上终止线程,你会得到一大堆回复告诉你:“停止那样做,用正确的方式来做”;也就是说,通过另一种消息传递机制在线程本身上显式处理线程终止。
因此,中断调用有许多不同的需求:
-
GHC 希望能够将阻塞的 IO 调用放在工作线程上,但稍后取消它们;目前它可以在 Linux 上做到这一点,但在 Windows 上不行,
-
用户希望编写友好的中断 C 库,并让它们与 Haskell 的异常机制无缝集成,
-
我们希望拥有 IO 世界的黄金触摸,即将阻塞 IO 代码即时转换为良好行为的非阻塞代码。
下次我将讨论针对每个目标可能需要的不同方法。
Punt the Prelude : ezyang’s 博客
注意保护注意事项。 Haskell 98 Prelude 中哪些定义容易被隐藏?我非正式地检查了 Prelude 并提到了一些候选项。
(.)
在 Prelude 中是函数组合,即 (b -> c) -> (a -> b) -> a -> c
。但是 #haskell 的用户知道它可能远比这更多:函数 a -> b
实际上只是函子,因此更一般化的类型是 Functor f => (b -> c) -> f b -> f c
,即 fmap。更一般地说,(.)
可以表示态射的组合,就像在 Control.Category 中一样。
all
、and
、any
、concat
、concatMap
、elem
、foldl
、foldl1
、foldr
、foldr1
、mapM_
、maximum
、minimum
、or
、product
、sequence_
。这些都是操作列表的函数,可以很容易地泛化为 Foldable
类型类;只需用 Foldable t => t a
替换 [a]
。它们可以在 Data.Foldable 中找到。
mapM
、sequence
。这些函数泛化为 Traversable
类型类。它们可以在 Data.Traversable 中找到。
任何数字函数或类型类。 瑟斯顿(Thurston)、蒂勒曼(Thielemann)和约翰逊(Johansson)编写了 numeric-prelude,它显著重新组织了数字类的层次结构,并且通常更接近它们的数学根源。虽然被称为实验性的,但它已经在更多面向数学的 Haskell 模块中得到应用,如约吉(Yorgey)的 species 软件包。
任何列表函数。 许多数据结构看起来和使用起来像列表,并支持一些类似于列表的操作。大多数模块依赖于命名约定,因此像向量、流、字节流等列表样的结构要求您通过限定导入来使用它们自己。不过,有 Data.ListLike,试图编码这些结构之间的相似之处。Prelude.Listless 提供了一个不包含列表函数的 Prelude 版本。
Monad
、Functor
。普遍认为 Monad 可能应该是 Applicative
的一个实例(而且类别理论家们也许还要您在层次结构中插入 Pointed
函数)。The Other Prelude 包含了这种另一种组织形式,尽管实际使用起来很笨拙,因为新的类意味着大多数现有的 Monad 库都无法使用。
repeat
, until
。这两个函数在Control.Monad.HT中有一个确实奇怪的泛化。特别是,repeat
泛化了 identity monad(需要明确的(解)包装),而until
泛化了(->) a
monad。
map
。这是列表的fmap
。
zip
, zipWith
, zipWith3
, unzip
。Conal 的Data.Zip将 zip 操作泛化为Zip
类型类。
IO. 这里会看到最多的变化,有多个模块在多个不同层次上工作,提供额外的功能。(不幸的是,它们并不真正可组合...)
-
System.IO.Encoding 使 IO 函数支持编码,并使用隐式参数允许设置“默认编码”。相关地,System.UTF8IO 导出仅针对 UTF-8 的函数。
-
System.IO.Jail 允许您强制输入输出仅在白名单目录和/或句柄上进行。
-
System.IO.Strict 提供了 IO 函数的严格版本,因此您不必担心文件句柄用完的问题。
-
System.Path.IO,虽然不完全是 IO 本身,但提供了类型安全的文件名操作和相应的 IO 函数来使用这些类型。
-
System.IO.SaferFileHandles 允许在单子区域中使用句柄,并根据它们打开时的 IO 模式对句柄进行参数化。System.IO.ExplicitIOModes 只处理 IOMode。
证明的目的:半形式方法:ezyang 的博客
来源:
blog.ezyang.com/2010/10/purpose-of-proof-sem-formal-methods/
作者沉思“半形式方法”(即非计算机辅助的证明写作)应该在允许软件工程师相互沟通方面发挥更积极的作用。
C++0x 中有很多新的、很牛的特性,其中之一是原子操作库。这个库具有先进的功能,使编译器编写者和并发库作者能够利用宽松的内存模型,从而获得极快的并发代码。
要做好这件事也是相当棘手的。
Mathematizing C++ Concurrency 项目在剑桥大学是一个典型例子,当你面对一个极其棘手的规范时,采用形式化方法会发现许多漏洞。从细微的澄清到实质性的更改,各种问题都有。截至Mark Batty 在周一的演讲时,仍然存在一些未解决的问题:例如,顺序内存模型并不在所有情况下真正是顺序的。您可以查阅预-雷珀斯维尔论文第四部分了解更多细节。
这让我想到一个深刻的问题:
当软件工程师想要说服彼此他们的软件是正确的,他们会怎么做呢?
这个特定问题并不是证明软件“正确”的问题 — 怀疑者们正确地指出,在很多情况下,“正确性”的概念并不明确。相反,我想问的是关于沟通的问题,类似于“我刚刚写了一段异常棘手的代码,现在我想说服我的同事我写得正确。”我们应该怎么做呢?
我们不这么做。
当然,有时解释某段特定代码的成本并不高效。也许我们写的大多数代码都是这样。而且我们确实有“代码审查”的机制。但是大多数情况下,代码审查的形式围绕着补丁展开,通常只有在原始程序员仍在并且仍记得代码工作原理时才能够有效。让审阅者读完整个程序已被证明是一件令人沮丧和难以办到的事情 — 因此,我们专注于风格和局部结构,并希望没有人写出无懈可击的邪恶代码。安全研究人员可能会审查代码,并寻找开发人员往往“弄错”的使用模式,并针对它们进行分析。我们确实有全面的标准,但它们往往倾向于“似乎工作正常”,或者,如果我们幸运的话,“它没有破坏任何自动回归测试”。
我们面临的是一次严重的沟通失败。
数学中证明的一个灵感来源之一。证明已经证明是一个有用的工具,用于将数学思想从一个人传达到另一个人,具有一定的严谨性,以避免歧义和混淆,但不是计算机级别的形式:与计算机科学不同,数学家们最近才开始为计算机消耗形式化证明。编写和阅读证明是一项艰难的任务,但这是传递知识的关键工具。
程序是证明吗?简而言之,是的。但它证明了错误的东西:也就是说,它精确地指定了程序将要做的事情,但随后没有提供任何超出此范围的内容(如正确性或性能或任何其他无形的特性)。而且,它是针对计算机而不是另一个人的。这就是为什么“语言的规范是编译器本身”是一个非常令人不满意的答案之一的原因。
更糟糕的是,在某个时候,您可能会在脑海中对某些黑魔法的工作原理有一个精确的心理模型,经过精心计算并说服自己它是有效的。然后您写下了// 黑魔法:除非您完全理解所有这些内容,请勿触摸!
然后您离开了,知识永远丢失了,直到某个勇敢的灵魂艰难地重新阅读您的代码并重建您的证明。给他们一个提示!如果您甚至没有说服自己代码的关键部分将做正确的事情,那真是可耻!(如果您的代码很简单,应该有一个简单的证明。如果您的代码很复杂,您可能弄错了。)
你可能会认为这只是古老的格言“我们需要更多的文档!”但有一个区别:证明发挥的作用与仅仅是文档完全不同。与程序类似,它们也必须进行维护,但它们的维护不是另一个不必要的工作,与您的程序工作无关——相反,它应该被视为一项关键的设计练习,以确保您和您的同事新功能在理论上是合理的。有人说好的评论是“为什么”,而不是“什么”。我现在要求严格了。
严谨并不意味着证明必须用“希腊字母”(即用正式符号写成)——毕竟,对于以前没有看到过这种语言的人来说,这种语言经常会让人望而却步。但这通常是一个好主意,因为形式化语言可以比英语更精确和简洁地捕捉思想。
因为程序的范围和需求经常发生变化(与数学证明不同),我们需要非常好的抽象来确保我们可以调整我们的证明。我们对高级协议的证明应该能够忽略任何操作的低级细节。相反,它们应该依赖于每个操作所具有的更高级别表示(无论是前置条件和后置条件、表意语义、陈述性语义等)。我们也不应假设我们的抽象是有效的(也不应该举手投降并说“所有抽象都是有漏洞的”):我们应该证明它们具有我们认为它们应该具有的属性(并且也说出它们不具备的属性)。当然,它们最终可能会变成错误的属性,就像在演化软件中经常发生的情况一样,但是证明通常可以揭示这些误解。
PyTorch Developer Podcast:ezyang 的博客
PyTorch 开发者播客
我正在推出一个新的播客,名为 PyTorch 开发者播客。这个想法是为 PyTorch 开发团队提供一个地方,讨论关于 PyTorch 内部开发的各种主题,每个主题都很精简(10-20 分钟)。目前,只有我一个人,每天发布一集,一周五天,直到我没话可说为止(可能还要一段时间,我有太多想说的)。我不编辑播客,只做最少量的策划,所以比写博客要容易一些。快来听听吧!已经发布了两集,一集关于我们如何为我们的 C++ 对象创建 Python 绑定,另一集关于调度程序的历史和约束。如果有任何你想我讨论的主题,就大声说出来。
PyTorch 内部:ezyang 的博客
这篇文章是我在 2019 年 5 月 14 日 PyTorch NYC meetup 上关于 PyTorch 内部的讲座的长篇论文版本。
大家好!今天我想谈谈PyTorch的内部。
这个讲座是为了那些使用过 PyTorch 的人,曾经想过,“如果我能为 PyTorch 做出贡献就好了”,但又被 PyTorch 庞大的 C++代码库吓到的人。我不骗你:PyTorch 的代码库有时候确实会让人感到有些压倒性。这次讲座的目的是给你一张地图:告诉你一个“支持自动求导的张量库”的基本概念结构,并给你一些在代码库中寻找方向的工具和技巧。我假设你之前写过一些 PyTorch 的代码,但不一定深入了解机器学习库是如何编写的。
讲座分为两部分:第一部分,我将首先向你介绍张量库的概念宇宙。我将从谈论你所熟知和喜爱的张量数据类型开始,更详细地讨论这个数据类型到底提供了什么,这将引导我们更好地理解它在内部是如何实现的。如果你是 PyTorch 的高级用户,你会熟悉大部分内容。我们还将讨论“扩展点”的三位一体:布局、设备和数据类型(dtype),这些概念指导着我们如何思考张量类的扩展。在 PyTorch NYC 的现场讲座中,我跳过了关于 autograd 的幻灯片,但我在这些笔记中也会稍微谈一下。
第二部分将处理在 PyTorch 中编码时实际涉及的细节。我会告诉你如何穿越大量的 autograd 代码,什么代码是真正重要的,什么是遗留的,以及 PyTorch 为编写内核提供的所有酷工具。
张量是 PyTorch 中的核心数据结构。你可能对张量直观上代表的内容有一个很好的理解:它是一个 n 维数据结构,包含某种标量类型,例如浮点数、整数等。我们可以将张量看作是由一些数据和一些元数据组成的,元数据描述了张量的大小、包含元素的类型(dtype)、张量所在的设备(CPU 内存?CUDA 内存?)。
还有一个你可能不太熟悉的小元数据:步幅。步幅实际上是 PyTorch 的一个显著特征,所以值得再多讨论一下。
张量是一个数学概念。但要在我们的计算机上表示它们,我们必须为它们定义某种物理表示。最常见的表示方法是在内存中连续地布置张量的每个元素(这就是连续这个术语的来源),按照上面所示,将每一行写入内存。在上面的示例中,我指定张量包含 32 位整数,因此您可以看到每个整数位于一个物理地址,每个偏移量相距四个字节。为了记住张量的实际维度,我们还必须记录额外的元数据来记录大小是多少。
那么,步幅与这个图有什么关系?
假设我想要访问我的逻辑表示中位置 tensor[1, 0]
处的元素。如何将这个逻辑位置转换为物理内存中的位置?步幅告诉我如何做到这一点:为了找出张量中任意元素的位置,我将每个索引乘以该维度的相应步幅,然后将它们全部相加。在上面的图片中,我已经用蓝色标记了第一维度,用红色标记了第二维度,因此您可以按照步幅计算中的索引和步幅。通过这个求和,我得到了两个(从零开始计数),确实,数字三位于连续数组的起始位置以下两个位置。
(谈话后面,我会讲述 TensorAccessor,一个处理索引计算的便利类。当你使用 TensorAccessor 而不是原始指针时,这个计算会在幕后为你处理。)
步幅是我们向 PyTorch 用户提供视图的基础基础。例如,假设我想提取出表示上述张量第二行的张量:
使用高级索引支持,我可以简单地写 tensor[1, :]
来获取这一行。这里的重要一点是:当我这样做时,我不会创建一个新的张量;相反,我只是返回一个在底层数据上的不同视图。这意味着,例如,如果我在该视图中编辑数据,它将反映在原始张量中。在这种情况下,看到如何做到这一点并不太难:三和四存储在连续的内存中,我们需要做的只是记录一个偏移量,指示这个(逻辑)张量的数据位于距离顶部两个位置。 (每个张量都记录一个偏移量,但大多数情况下是零,当情况如此时,我会从我的图表中省略它。)
在谈话中的问题:如果我对一个张量进行视图操作,如何释放底层张量的内存?
答案:你必须复制视图,从而将其与原始物理内存断开连接。实际上,你没有太多其他选择。顺便说一句,如果你以前用过 Java,在旧版本中获取字符串的子串存在类似问题,因为默认情况下不会进行复制,因此子串会保留(可能非常大的字符串)。显然,他们在 Java 7u6 中修复了这个问题。
更有趣的情况是,如果我想取第一列:
当我们查看物理内存时,我们会看到列的元素不是连续的:每个元素之间有一个间隔。在这里,步幅可以帮助解决问题:我们不再指定步幅为一,而是指定步幅为二,表示在一个元素和下一个元素之间,需要跳过两个插槽。(顺便说一句,这就是为什么称之为“步幅”的原因:如果我们将索引视为跨越布局,步幅指定每次迈出步伐时前进的位置数。)
使用步幅表示法实际上可以让您在张量上表示各种有趣的视图;如果您想尝试一下可能性,请查看步幅可视化工具。
让我们退后一步,思考我们实际上如何实现这个功能(毕竟,这是一个内部讨论)。如果我们可以在张量上有视图,这意味着我们必须解耦张量的概念(用户可见的您所熟悉和喜爱的概念)和实际存储张量数据的物理数据(称为存储):
可能有多个张量共享同一存储。存储定义了张量的数据类型和物理大小,而每个张量记录了大小、步幅和偏移,定义了对物理内存的逻辑解释。
有一件事需要意识到的是,即使在“简单”情况下也始终存在张量-存储的配对,即使您不真正需要存储(例如,只是用torch.zeros(2, 2)
分配了一个连续张量)。
顺便说一句,我们有兴趣让这个观点不再成立;而是不再有存储的单独概念,只需定义视图为由基本张量支持的张量。这有点复杂,但它的好处是连续张量可以更直接地表示,而不需要存储的间接性。这样的改变会使 PyTorch 的内部表示更像 NumPy。
我们已经详细讨论了张量的数据布局(有些人可能会说,如果数据表示正确,一切都会就位)。但是简要谈谈如何实现张量上的操作也是值得的。在最抽象的层面上,当您调用torch.mm
时,会发生两次分派:
第一次分派基于张量的设备类型和布局:例如,它是 CPU 张量还是 CUDA 张量(以及例如它是步幅张量还是稀疏张量)。这是一个动态分派:它是一个虚函数调用(确切地说,这个虚函数调用发生在这次谈话的后半段)。这应该是有道理的,你需要在这里进行分派:CPU 矩阵乘法的实现与 CUDA 实现有很大不同。它是一个动态分派,因为这些内核可能存在于不同的库中(例如libcaffe2.so
与libcaffe2_gpu.so
),因此你别无选择:如果你想进入一个你没有直接依赖的库,你必须通过动态分派的方式。
第二次分派是对特定 dtype 的分派。这个分派只是一个简单的开关语句,用于支持内核选择的任何 dtype。反思之后,这也应该是有道理的,我们需要在这里进行分派:实现在float
上的 CPU 代码(或许 CUDA 代码也是如此)与在int
上的代码是不同的。理所当然的是,你需要为每种 dtype 编写单独的内核。
如果你试图理解 PyTorch 中操作符的调用方式,这可能是你头脑中最重要的思维模型。在查看代码时,我们将返回到这个思维模型。
既然我们已经讨论了张量,我还想花点时间介绍张量扩展的世界。毕竟,生活不仅仅是关于稠密的 CPU 浮点张量。还有各种有趣的扩展,如 XLA 张量,量化张量,MKL-DNN 张量,作为张量库的一部分,我们需要考虑如何适应这些扩展。
我们当前的扩展模型为张量提供了四个扩展点。首先,有三个参数的三位一体可以唯一确定张量是什么:
-
设备描述了张量物理内存实际存储位置,例如在 CPU 上,在 NVIDIA GPU(cuda)上,或者可能在 AMD GPU(hip)或 TPU(xla)上。设备的显著特征是它有自己的分配器,不与任何其他设备兼容。
-
布局描述了我们如何逻辑解释这个物理内存。最常见的布局是步幅张量,但稀疏张量有不同的布局,涉及一对张量,一个用于索引,一个用于数据;MKL-DNN 张量可能有更奇特的布局,如块布局,仅用步幅无法表示。
-
dtype描述了张量每个元素实际存储的内容是什么。这可以是浮点数或整数,或者例如量化整数。
如果你想要对 PyTorch 张量进行扩展(顺便说一句,如果这正是你想做的,请与我们联系!目前这些事情都不能在树外完成),你应该考虑扩展这些参数中的哪一个。这些参数的笛卡尔积定义了你可以生成的所有可能的张量。现在,并非所有这些组合实际上都可能有核心(谁会为 FPGA 上的稀疏、量化张量编写核心呢?),但原则上这些组合可能是有意义的,因此我们支持表达它,至少。
还有一种方法可以对 Tensor 功能进行“扩展”,那就是编写一个包装类,将你的对象类型实现在 PyTorch 张量周围。这听起来或许很明显,但有时人们在应该使用包装类而不是扩展这三个参数之一时,却会做出错误的选择。包装类的一个显著优点是它们可以完全在树外开发。
你何时应该编写一个张量包装类,而不是直接扩展 PyTorch 本身?关键测试是你是否需要在自动梯度反向传播过程中传递这个张量。例如,这个测试告诉我们,稀疏张量应该是一个真正的张量扩展,而不仅仅是一个包含索引和值张量的 Python 对象:在涉及嵌入的网络优化时,我们希望由嵌入生成的梯度是稀疏的。
我们对扩展的哲学也影响了张量本身的数据布局。我们真正希望我们的张量结构有一个固定的布局:我们不希望基本的(并且非常频繁调用的)操作像“张量的尺寸是多少?”需要虚拟分派。因此,当你查看张量的实际布局时(在TensorImpl 结构中定义),我们看到的是所有“张量”样式的共同前缀,我们认为所有这些东西都普遍具有,加上一些只对分步张量真正适用的字段,但它们如此重要,我们已经将它们保留在主结构中,然后是可以在每个张量基础上自定义字段的后缀。例如,稀疏张量在这个后缀中存储它们的索引和值。
我们已经讨论了张量的所有内容,但如果这是 PyTorch 提供的唯一功能,那它基本上只是一个 Numpy 的克隆。PyTorch 最初发布时的显著特点是提供了张量的自动微分(如今,我们有其他很酷的功能,比如 TorchScript;但在当时,这就是它的全部!)
自动微分的作用是什么?它是负责对神经网络进行梯度计算的机制:
...并填写实际计算网络梯度的缺失代码:
请花点时间研究这个图表。有很多需要解读的内容;以下是你需要关注的内容:
-
首先,把你的注意力放在红色和蓝色变量上。PyTorch 实现了反向模式自动微分,这意味着我们实际上是“反向”进行前向计算来计算梯度。如果你查看变量名,你会看到这一点:在红色部分的底部,我们计算了
loss
;然后,在程序的蓝色部分中,我们首先计算grad_loss
。loss
是从next_h2
计算出来的,所以我们计算grad_next_h2
。技术上讲,我们称之为grad_
的这些变量并不是真正的梯度;它们实际上是雅可比矩阵左乘以一个向量,但在 PyTorch 中,我们只是称之为grad
,大多数人都知道我们的意思。 -
如果代码结构保持不变,行为则不同:从前向传递的每一行都被替换为代表前向操作导数的不同计算。例如,
tanh
操作被转换为tanh_backward
操作(这两行通过图表左侧的灰色线连接)。前向和反向操作的输入和输出被交换:如果前向操作产生了next_h2
,那么反向操作将以grad_next_h2
作为输入。
自动求导的整个目的是执行由此图表描述的计算,但实际上并不会生成此源代码。PyTorch 的自动求导不进行源到源的转换(尽管 PyTorch JIT 确实知道如何进行符号微分)。
为了做到这一点,我们需要在对张量进行操作时存储更多元数据。让我们调整我们对张量数据结构的看法:现在不仅仅是一个指向存储的张量,我们现在有一个包装了这个张量的变量,并且还存储更多信息(AutogradMeta),这些信息在用户在他们的 PyTorch 脚本中调用loss.backward()
时需要用来执行自动求导。
这是另一张幻灯片,希望很快会过时。Will Feng 正在处理一个在 C++中的变量张量合并,跟随发生在 PyTorch 前端接口的简单合并。
我们也必须更新我们关于调度的看法:
在我们调度到 CPU 或 CUDA 实现之前,还有一个关于变量的调度,负责展开变量,调用底层实现(以绿色表示),然后重新包装结果为变量,并记录必要的反向自动求导元数据。
一些实现不进行展开;它们只是调用其他变量实现。因此,你可能会在变量的宇宙中花费一些时间。然而,一旦你展开并进入非变量张量的宇宙,那就是它;你永远不会回到变量(除非从函数返回)。
在我的纽约聚会演讲中,我跳过了以下七张幻灯片。我也将延迟为它们撰写文稿;你们得等到续集才能看到一些文字。
关于概念的讨论就到此为止,让我们来看看一些代码。
PyTorch 有很多文件夹,在 CONTRIBUTING 文档中对它们进行了详细描述,但实际上,你只需要了解四个目录:
-
首先,
torch/
包含了你最熟悉的内容:你导入和使用的实际 Python 模块。这些都是 Python 代码,很容易进行修改和查看结果。然而,在表面之下并不太深的地方... -
torch/csrc/
,这是实现你可能称为 PyTorch 前端的 C++ 代码。更详细地说,它实现了在 Python 和 C++ 之间转换的绑定代码,以及 PyTorch 的一些重要组成部分,如自动求导引擎和 JIT 编译器。它还包含了 C++ 前端代码。 -
aten/
,简称为 "A Tensor Library"(由 Zachary DeVito 创造),是一个实现张量操作的 C++ 库。如果你在寻找某些核心代码所在地,很可能就在 ATen 中。ATen 本身分为两个操作符的区域:现代的 C++ 实现的 "native" 操作符,以及传统的 C 实现的 "legacy" 操作符(TH、THC、THNN、THCUNN)。传统操作符是糟糕的地方;如果可能的话,尽量不要花太多时间在那里。 -
c10/
,这是一个关于 Caffe2 和 A"Ten"(明白了吗?Caffe 10)的双关语,包含了 PyTorch 的核心抽象,包括 Tensor 和 Storage 数据结构的实际实现。
那里有很多地方可以寻找代码;我们可能应该简化目录结构,但目前情况就是这样。如果你想要处理运算符,你将大部分时间都花在 aten
目录下。
让我们看看这种代码分离在实践中是如何展开的:
当你调用像 torch.add
这样的函数时,实际上发生了什么?如果你记得我们讨论过的分发方式,你已经在脑海中有了基本的概念:
-
我们需要从 Python 领域翻译到 C++ 领域(Python 参数解析)。
-
我们处理变量分发(VariableType--顺便说一句,Type 实际上与编程语言类型没有关系,只是用于执行分发的一个工具)。
-
我们处理设备类型 / 布局分发(Type)。
-
我们有实际的核心代码,它可以是现代的本地函数,也可以是传统的 TH 函数。
这些步骤中的每一步都对应于一些具体的代码。让我们穿越这片丛林。
我们在 C++ 代码中的初始着陆点是 Python 函数的 C 实现,我们已经将其作为类似 torch._C.VariableFunctions.add
的东西暴露给了 Python 端。THPVariable_add
是这样一种实现的实现。
关于这段代码的一件重要事情是,它是自动生成的。如果你在 GitHub 仓库中搜索,你找不到它,因为你必须实际构建 PyTorch 才能看到它。另一件重要的事情是,你不必深入理解这段代码在做什么;你只需略过它,了解它在做什么即可。上面,我用蓝色注释了一些最重要的部分:你可以看到在这里使用了一个 PythonArgParser
类来从 Python 的 args
和 kwargs
中实际提取 C++ 对象;然后我们调用了一个 dispatch_add
函数(我已经用红色内联了它);这会释放全局解释器锁,然后在 C++ Tensor self
上调用一个普通的方法。在返回时,我们将返回的 Tensor
重新包装成一个 PyObject
。
(在这一点上,幻灯片上有一个错误:我应该告诉你关于变量分派代码的事情。我还没有在这里修复它。然后一些魔法发生了...)
当我们在 Tensor
类上调用 add
方法时,还没有发生虚拟分派。相反,我们有一个内联方法,它调用一个 "Type" 对象上的虚拟方法。这个方法是实际的虚拟方法(这就是为什么我说 Type 只是一个 "小工具",让你进行动态分派)。在这个例子的特定情况下,这个虚拟调用会分派给 TypeDefault
类上的 add
实现。这是因为我们有一个对于每种设备类型(CPU 和 CUDA)都相同的 add
实现;如果我们有不同的实现,我们可能会得到类似 CPUFloatType::add
的东西。正是这个虚拟方法的实现最终将我们带到实际的内核代码。
希望这张幻灯片也很快就过时了;Roy Li 正在致力于用另一种机制替换
Type
分发,这将帮助我们更好地支持移动端的 PyTorch。
值得再次强调的是,直到我们到了内核,所有的代码都是自动生成的。
这有点扭曲,所以一旦你对正在发生的事情有了基本的了解,我建议直接跳到内核。
PyTorch 为潜在的内核编写者提供了许多有用的工具。在本节中,我们将简要介绍其中一些。但首先,你需要写一个内核的时候,需要什么?
我们通常认为 PyTorch 中的内核由以下部分组成:
-
首先,有一些关于内核的元数据,我们写在这些元数据中,这些数据驱动着代码生成,并让你在不写一行代码的情况下就可以把所有绑定到 Python。
-
一旦你到了内核,你已经过了设备类型/布局调度。首先要做的事情是错误检查,确保输入张量的尺寸是正确的。(错误检查非常重要!不要马虎!)
-
接下来,我们通常需要分配结果张量,我们将把输出写入其中。
-
现在是核心适当的时间。在这一点上,您现在应该进行第二次 dtype 分发,以跳转到专门针对它操作的核心。 (您不希望太早这样做,因为那样您将无用地复制在任何情况下看起来相同的代码。)
-
大多数性能良好的核心需要某种并行化,以便您可以利用多 CPU 系统。 (CUDA 核心是“隐式”并行化的,因为它们的编程模型建立在大规模并行化之上)。
-
最后,您需要访问数据并执行您想要的计算!
在随后的幻灯片中,我们将介绍 PyTorch 为帮助您执行这些步骤提供的一些工具。
要利用 PyTorch 带来的所有代码生成功能,您需要为您的运算符编写一个模式。该模式提供了函数的类似于 mypy 的类型,并控制我们是否为 Tensor 上的方法或函数生成绑定。您还告诉模式为给定的设备布局组合调用您的运算符的实现。查看native 中的 README获取有关此格式的更多信息。
您还可能需要在derivatives.yaml中为您的操作定义一个导数。
错误检查可以通过低级或高级 API 方式完成。低级 API 只是一个宏,TORCH_CHECK
,它接受一个布尔值,然后任意数量的参数来组成错误字符串以在布尔值不为真时渲染。这个宏的一个好处是,您可以混合字符串和非字符串数据;一切都是使用他们的operator<<
实现格式化的,而 PyTorch 中大多数重要的数据类型都有operator<<
的实现。
高级 API 可以避免您反复编写重复的错误消息。它的工作方式是,您首先将每个Tensor
包装成一个TensorArg
,其中包含关于张量来源的信息(例如,它的参数名)。然后它提供了许多预定义的函数来检查各种属性;例如,checkDim()
测试张量的维度是否为固定数量。如果不是,该函数根据TensorArg
元数据提供一个用户友好的错误消息。
在编写 PyTorch 操作符时要注意的一件重要事情是,您通常要签署编写三个操作符:abs_out
,它在预分配的输出上操作(这实现了out=
关键字参数),abs_
,它是原地操作,以及abs
,它是操作符的普通旧版功能版本。
大多数情况下,abs_out
是真正的工作马,而abs
和abs_
只是围绕abs_out
的薄包装;但有时为每种情况编写专门的实现是有必要的。
要进行数据类型分发,你应该使用 AT_DISPATCH_ALL_TYPES
宏。这个宏接受你想要分发的张量的数据类型,以及一个 lambda 表达式,该 lambda 表达式将针对从宏中可分派的每种数据类型进行特化。通常,这个 lambda 只是调用一个模板化的辅助函数。
这个宏不仅仅是“进行分派”,它还决定了你的内核将支持哪些数据类型。因此,实际上有很多版本的这个宏,让你选择生成特定数据类型的特化。大多数情况下,你只需要 AT_DISPATCH_ALL_TYPES
,但要注意在某些情况下,你可能需要分派到更多类型。在 Dispatch.h 中有关于如何为你的用例选择正确版本的指导。
在 CPU 上,你经常希望并行化你的代码。过去,这通常是通过直接在代码中撒入 OpenMP pragma 来完成的。
在某个时候,我们必须实际访问数据。PyTorch 为此提供了相当多的选项。
-
如果你只是想在某个特定位置获取一个值,你应该使用
TensorAccessor
。一个张量访问器就像一个张量,但它将张量的维度和数据类型硬编码为模板参数。当你像这样检索一个访问器x.accessor<float, 3>();
时,我们会进行运行时测试以确保张量确实是这种格式;但在此之后,每次访问都是无检查的。张量访问器正确处理步幅,因此你应该优先使用它们而不是原始指针访问(不幸的是,一些遗留内核确实会这样做)。还有一个PackedTensorAccessor
,专门用于通过 CUDA 启动发送访问器,这样你可以从 CUDA 内核中获取访问器。(一个值得注意的问题:TensorAccessor
默认为 64 位索引,这在 CUDA 中比 32 位索引要慢得多!) -
如果你正在编写某种具有非常规则元素访问的操作符,例如逐点操作,最好使用更高级的抽象,即
TensorIterator
。这个辅助类会自动处理广播和类型提升,并且非常方便。 -
对于 CPU 上真正的速度,你可能需要使用矢量化的 CPU 指令来编写你的内核。我们也有一些辅助工具!
Vec256
类表示标量的向量,并提供了一些方法,可以一次性对它们执行矢量化操作。像binary_kernel_vec
这样的辅助工具然后让你轻松地运行矢量化操作,然后使用普通的指令完成那些无法完全适配到矢量指令的操作。这里的基础设施还会在不同的指令集下多次编译你的内核,然后在运行时测试你的 CPU 支持什么指令,并在这些情况下使用最佳内核。
PyTorch 中许多核心仍然采用传统的 TH 风格编写。(顺便说一下,TH 代表 TorcH。这是一个相当不错的首字母缩写,但不幸的是它有些负面影响;如果在名称中看到 TH,就假定它是传统的。)什么是传统的 TH 风格呢?
-
它是以 C 风格编写的,几乎不使用 C++。
-
它是手动引用计数的(使用
THTensor_free
手动调用来减少在使用张量后的引用计数),并且 -
它位于
generic/
目录中,这意味着我们实际上会多次编译该文件,但使用不同的#define scalar_t
。
这段代码相当复杂,我们很讨厌审查它,所以请不要再增加内容。如果你喜欢编码但对内核编写了解不多,可以做的一项更有用的任务是将其中一些 TH 函数移植到 ATen。
总结一下,我想谈谈在 PyTorch 上高效工作的一些技巧。如果 PyTorch 的庞大的 C++ 代码库是阻止人们贡献到 PyTorch 的第一个关卡,那么您的工作流程的效率就是第二个关卡。如果您试图用 Python 的习惯来处理 C++,您将会感到很痛苦:重新编译 PyTorch 需要很长时间,而要确定您的更改是否有效也将需要很长时间。
如何高效工作可能可以单独讲一讲,但这张幻灯片指出了一些常见的反模式,我经常听到有人抱怨说:“在 PyTorch 上工作很难。”
-
如果您编辑的是一个头文件,尤其是被许多源文件包含的头文件(特别是被 CUDA 文件包含的),请预计会有非常长的重建时间。尽量只编辑 cpp 文件,并节制地编辑头文件!
-
我们的 CI 是一个非常棒的、零配置的测试工具,可以测试您的更改是否有效。但是请预计需要等待一到两个小时才能收到反馈信号。如果您正在进行需要大量试验的更改工作,请花些时间设置本地开发环境。同样,如果在特定的 CI 配置上遇到难以调试的问题,请在本地设置它。您可以下载并在本地运行 Docker 镜像。
-
贡献指南解释了如何设置 ccache;这是非常推荐的,因为有时它会帮助您幸运地避免在编辑头文件时进行大规模重新编译。它还有助于掩盖我们的构建系统中的错误,使我们在不应该重新编译文件时重新编译它们。
-
最终,我们有大量的 C++ 代码,如果在配置强大的服务器上构建,您将会有更愉快的体验,因为这样做 CUDA 构建会非常慢,而笔记本电脑往往没有足够的处理能力来快速完成。
这就是对 PyTorch 内部的一个风速游览!很多很多东西都被省略了;但希望这里的描述和解释能帮助您至少掌握代码库的一个重要部分。
接下来该怎么做?您可以做哪些贡献?一个好的起点是我们的问题跟踪器。从今年年初开始,我们一直在分类问题;标记为triaged的问题意味着至少有一个 PyTorch 开发人员已经看过它并对问题做了初步评估。您可以使用这些标签查找我们认为是高优先级的问题,或者查找特定模块的问题,例如autograd,或者找到我们认为是小问题的问题(警告:有时我们也会犯错!)
即使您现在不想开始编码,也有许多其他有用的活动,比如改进文档(我喜欢合并文档的 PR,它们非常棒),帮助我们重现其他用户的 bug 报告,以及帮助我们在问题跟踪器上讨论 RFC。没有开源贡献者,PyTorch 就不会走到今天这一步;我们希望您也能加入我们!
Quote Day : ezyang’s blog
引语日
Unattributed to protect the innocent. (But you can probably guess.)
“于是这些可怜的程序员,他们不得不喝这么多威士忌来完成工作。” [得意洋洋地拿出一瓶威士忌放在桌子上。] “这组程序员做了 X,这有多难呢?两瓶威士忌。” [又放上两瓶威士忌] “但这还不够快。于是这组程序员做了 Y。四瓶威士忌。” [又出现四瓶威士忌] “你看,这需要的威士忌量呈指数增长。”
“锁和信号量是苏联时代的技术。”
关于通过将算法移植到低级硬件描述语言来优化算法的主题:“有更多值得年轻博士生去做的事情。”
关于 Tycho Brahe:“醉酒。易怒。肥胖。聪明。绝对是一个榜样。”
“Hindley-Milner 推理的一个好处是它非常高效。” “你是说 EXPTIME-complete。”
“任何敬畏神明的人都应该写上他的类型签名。”
关于 GHC 7.0 中 let 泛化的缺失:“这不是一个特性,而是一个带有好处的 bug。”
“你可能会认为显式类型签名会让类型检查器的工作更轻松,但实际上它们使检查器的工作更加困难。”
“我强烈推荐这篇论文,但带上一罐阿司匹林。”
Rage bug reporting:ezyang 的博客
Rage bug reporting
在 Facebook,我们有一个名为 "rage" 的内部工具约定。当出现问题并且你想要报告 bug 时,工具开发人员通常会要求你提供一个 rage。对于命令行工具,可以通过运行 rage 子命令来完成,该子命令将询问你想要报告的先前 CLI 调用,并为你提供一组日志以发送给开发人员。
rage 有一个重要的特性,与传统的日志级别标志如 -v
相比:rage 记录总是开启的。换句话说,这就像是应用于客户端软件的传统服务器应用程序日志一样。日志记录总是开启的,而 rage 子命令使用户能够轻松地只发送与命令行调用相关的日志部分(例如,正在运行的日志)。
出于某种原因,在开源工具中,rage 功能并不那么常见。我可以想象很多原因为什么会是这种情况:
-
添加适当的日志记录就像使用牙线一样——在当时可能很烦人,但即使后来可以节省很多痛苦。
-
即使有了日志记录,你仍然需要添加基础设施将日志保存在某个地方,并允许用户随后检索它们。
-
编写足够有用的日志,以便开发人员可以简单地通过“阅读茶叶”来诊断问题,这本身就是一门艺术,但不要详细到会减慢程序正常执行的速度。并且不要忘记,最好不要暴露私人信息!
-
大多数程序都很简单,你可以依赖于老旧的方法,在错误报告中要求用户提交复制操作的说明。
尽管如此,就像大多数系统管理员视日志记录为调试服务器问题的宝贵工具一样,我认为 rage 报告对调试客户端问题同样是一种宝贵的工具。在 ghstack 中,实现 rage 报告并没有多少行代码:ghstack.logs(用于将日志写入 rage 目录)和 ghstack.rage(用于读取日志)。但这大大减少了我在项目支持上的负担;有了一个 rage,我通常可以在设置复制器之前找出 bug 的根本原因。
在 Haskell 中快速原型化脚本:ezyang 的博客
我在周末里玩得很痛快,用 Haskell 写了一个叫做 MMR Hammer 的小工具。 我不会跟你讲 Fedora Directory Server 的多主复制的细节,相反,我想谈谈在 Haskell 中快速原型化脚本的经验—这些程序的特点是计算量少而 IO 多。 通过这个脚本作为案例研究,我将描述我如何解决问题,什么容易做到,什么需要花点儿力气。 特别是,我的主要论点是:
-
在高度专业化的脚本中,你可以不指定顶层类型签名,
-
IO 单子是你唯一需要的单子,最后
-
你 可以 也 应该 在 Haskell 中编写一些 hackish 代码,语言会施加适量的严格性,以确保你稍后可以整理它。
我希望说服你,Haskell 可以成为快速原型化脚本的一种优秀语言。
快速原型化脚本的特点是什么? 快速原型化有两个主要目标:让它 工作,并让它快速 工作。 有许多因素汇集成这两个基本目标:
-
你的需求立即显而易见—问题在于将你的想法转化为可工作的代码。(你可能会后来决定你的需求是错误的。)
-
你有一个现有的 API,你希望使用它,这让你可以说“我想将 X 属性设置为 Y”而不是说“我将以这种特定格式的二进制消息和这些数据通过 TCP 传输”。 这应该与你对你想做的事情的构思相匹配。
-
你将通过反复执行你关心的代码路径来进行手动测试。 你没有积极开发的代码通常不会被运行(如果你有很多辅助函数可能会编译失败)。 此外,运行代码应该快速且不涉及长时间的编译过程。
-
你想要避免剃牛毛:解决无关的问题会消耗时间,阻止你的软件正常工作;最好现在就解决问题。
-
你的代码专门为你的特定用例进行了优化:这使得它更容易使用,并提供了一个未来广泛适用时需要支持的具体示例(如果你决定将你的代码更广泛地应用,这似乎会发生)。
-
你并没有做很多计算昂贵的工作,但你的逻辑比在 shell 脚本中维护起来更复杂。
一个能够实现快速原型化的语言是什么样子?
-
它应该简洁,并且至少不要让你重复自己。
-
它应该“随带电池”,并至少有你想要使用的重要 API。
-
它应该是解释性的。
-
它应该被充分利用;也就是说,你试图做的事情应该已经存在于其他人已经用该语言做过的事情的并集中。这意味着你在没有人运行的代码中遇到奇怪的错误条件的可能性较小。
-
它应该有一个快速的写-测试-调试循环,至少对于小程序来说是这样的。
-
编译器不应该妨碍你。
Haskell 的一般原型制作。 如果我们看看上面的列表,Haskell 有几个方面值得推荐。GHC 有一个 runghc
命令,允许你解释你的脚本,这意味着可以快速进行原型制作。函数式编程鼓励高度的代码重用,并且在你熟悉使用高阶函数时可以非常简洁。而且,它正在逐渐增加一个相当庞大的工具集。在 LDAP MMR 的情况下,我需要一个 OpenLDAP 库的绑定,John Goerzen 已经写过了。一个很好的开始。
编译器不应该妨碍你。 这对于任何初学者来说可能是 Haskell 最明显的问题:他们试图编写一些普通的程序,但编译器却开始用复杂的类型错误来“咩咩”地指责他们,而不是通常的语法错误或运行时错误。随着他们对 Haskell 的熟悉程度的提高,他们对 Haskell 类型系统的心理模型也会改进,并且他们修复类型错误的能力也会提高。
百万美元的问题是,你必须对 Haskell 有多了解才能快速解决类型错误?我认为,在 Haskell 的快速原型制作中,并不需要太多!
一个简化的因素是你写的函数通常不是多态的。在 MMR Hammer 中的 73 个完全实现的函数中,只有六个具有推断的非平凡多态类型签名,其中除了一个之外都只在单一类型上下文中使用。
对于这些签名,a
总是 String
:
Inferred type: lookupKey :: forall a.
[Char] -> [([Char], [a])] -> [a]
Inferred type: lookupKey1 :: forall a.
[Char] -> [([Char], [a])] -> Maybe a
m
总是 IO
,t
总是 [String]
,但它是多态的,因为它在函数体中没有被使用:
Inferred type: mungeAgreement :: forall (m :: * -> *).
(Monad m) =>
LDAPEntry -> m LDAPEntry
Inferred type: replicaConfigPredicate :: forall t (m :: * -> *).
(Monad m) =>
([Char], t) -> m Bool
a
在这里总是 (String, String, String)
;然而,这个函数是为数不多的真正通用的函数之一(它旨在实现 IO
的 msum
):
Inferred type: tryAll :: forall a. [IO a] -> IO a
最后,我们的另一个真正通用的函数,一个方便的调试函数:
Inferred type: debugIOVal :: forall b. [Char] -> IO b -> IO b
我认为对于高度特定的原型代码,GHC 通常会推断出相当单态的类型,因此你不需要添加很多显式类型签名来获得良好的错误提示。你可能会注意到 MMR Hammer 几乎没有任何显式的类型签名——我认为对于单态代码来说,这是可以接受的!此外,这意味着你只需要知道如何使用多态函数,而不需要知道如何编写它们。(更不用说更高级的类型技巧了!)
单子,单子,单子。 我怀疑脚本的一个高度简化的假设是避免使用除了 IO 之外的任何单子。例如,下面的代码可能已经使用了 Reader 变换器在 IO 的基础上实现了:
ldapAddEntry ldap (LDAPEntry dn attrs) = ...
ldapDeleteEntry ldap (LDAPEntry dn _ ) = ...
printAgreements ldap = ...
suspendAgreements ldap statefile = ...
restoreAgreements ldap statefile = ...
reinitAgreements ldap statefile = ...
但是只有一个参数传递,这在任何调用 API 时基本上都是必需的(所以我可能会做一点 ask
调用),所以使用读取器转换器可能会增加代码量,因为我的所有 LDAP 代码都需要用 liftIO
提升。
更少的单子也意味着更少的担忧:你不必担心混淆单子,你可以自由地使用 error
作为在关键错误时中止的简写。在 IO 中,这些会被转换为异常,异常会按照通常的方式传播——因为它们是字符串,你不能编写非常健壮的错误处理代码,但嘿,原型通常不需要错误处理。特别是,原型容易出错是好事:宁愿出现错误而不是执行可能正确但可能导致完全无意义的操作。
悬挂的 lambda 风格也使得编写使用括号函数的代码非常愉快。以下是一些示例:
withFile statefile WriteMode $ \h ->
hPutStr h (serializeEntries replicas)
forM_ conflicts $ \(LDAPEntry dn attrs) ->
putStrLn dn
看,没有括号!
收获利益。 有时候,你可能会出于纯粹的教育目的而尝试用另一种语言编写程序。但除此之外,如果你掌握一种语言,并且它对你来说很有效,除非有强有力的理由,否则你不会真的想去改变。以下是用 Haskell 编写代码的强有力理由:
-
当你与外部世界互动时,你会很快发现自己需要某种形式的并发执行:也许你想提交一个查询,但如果十秒钟内没有返回就超时,或者你想要同时进行几个 HTTP 请求,或者你想要监视一个条件直到它被满足然后做其他事情。Haskell 让这类事情变得非常容易,而这在同时也是可以解释的语言中极为罕见的。
-
因为你没有自动测试,一旦你编写了一些代码并手动验证它工作正常,你希望它保持工作状态,即使你在程序的其他部分工作时也是如此。如果你构建了需要演变的辅助函数:如果你更改了一个辅助函数的 API 并忘记更新所有调用站点,你的代码将会编译,但当你回去尝试运行一个旧的代码路径时,你会发现你有一堆微不足道的错误需要修复。静态类型会让这些问题消失。严肃点。
-
Haskell 提供了非常非常便宜的抽象。在 Python 中,你可能会因为更一般的版本需要高阶函数并且看起来很丑,而不得不完整地编写出来,但在 Haskell 中,这些东西非常自然和简单,你真的不需要说太多就能做很多事情。我的一个朋友曾经抱怨说 Haskell 鼓励你花费太多时间在抽象上;这是事实,但我也相信一旦你深入到 Oleg 的领域中一次,你以后会更好地感知何时以及何时不适合使用抽象。
-
使用 Maybe 进行严格的 NULL 处理会让你更早地考虑到错误条件。很多时候,你会想中止,因为你不想去处理那个错误条件,但有时你会想要更优雅地处理事情,而显式的类型会始终提醒你何时可能做到这一点:
case mhost of (Just host) -> do let status = maybe "no status found" id mstatus printf ("%-" ++ show width ++ "s : %s\n") host status _ -> warnIO ("Malformed replication agreement at " ++ dn)
-
在完全随意的方式下进行输入的分割和切割是可行且简洁的:
let section = takeWhile (not . isPrefixOf "profile") . tail . dropWhile (/= "profile default") $ contents getField name = let prefix = name ++ " " in evaluate . fromJust . stripPrefix prefix . fromJust . find (isPrefixOf prefix) $ section
但同时,将这段代码换成一个真正的解析库并不需要太多额外的代码行数。这是 Haskell 中更一般模式的一个实例,即从脆弱的妥协到健壮的代码转换非常容易(另见,静态类型系统。)
一些缺点。 给我的脚本添加选项解析实在是令人烦恼,看了一会儿 cmdargs 和 cmdlib 后,我决定用 getopt 自己写,结果在我的脚本中变成了一个相当不小的代码块。我不太确定这里出了什么问题,但其中一部分问题是我对命令行 API 的非常特殊的口味(毕竟基于 Git),而且不明显如何使用更高级别的库来达到我想要的效果。也许最明显的是,大多数主要的 Haskell 命令行应用程序也都自己编写了命令解析器。更多内容请看另一篇文章。
使用 LDAP 也是一次有趣的练习:这是一个相当高质量的库,可以工作,但它并不全面(最终我提交了一个支持 ldap_initialize
的补丁),也没有经过充分测试(它对 OpenLDAP 和 Fedora DS 之间长期存在的 bug 没有解决方案——更多内容请看另一篇文章。)这是一个随时间而改善的事情,但在那之前期望与上游密切合作,特别是对于专业化的库。
Ray:一个用于新兴 AI 应用的分布式执行框架(Ion Stoica):ezyang's blog
下面是Ion Stoica在NIPS'17 的 ML 系统研讨会上关于Ray的讲话的记录。
我们在伯克利已经研究了一年多了。在过去的几年里,AI 取得了巨大的进步。广告定向、图像和语音等领域都有显著的发展。许多应用都基于使用深度神经网络的监督学习。监督学习和无监督学习是两种主要的方法。
然而,下一代 AI 应用程序将会非常不同。它们部署在关键任务场景中,需要不断地从快速变化的环境中学习。机器人技术、自动驾驶汽车、无人机、对话系统等。实现这一新一代 AI 应用程序需要更广泛的技术应用。随机优化、并行模拟等等。
Ray 为实施这些方法提供了一个统一的平台。为了激励 Ray,我将使用强化学习。RL 通过与环境交互进行学习。策略将状态/观察映射到行动,以最大化某种奖励。RL 的需求是什么?许多应用程序表现出嵌套并行性:搜索中使用数据并行 SGD,然后调用一个组件进行模拟策略评估,这在多个 CPU 上并行运行。其次,这些工作负载在硬件和时间上高度异构。许多计算不仅需要 CPU,还需要 GPU、TPU 和 FPGA。其次,这些计算可能需要非常不同的时间。模拟棋盘游戏:3 步输掉,或者 50 步赢或平局。在机器人技术中,我们需要实时处理,同时并行处理来自传感器的数据,处理时间在十几毫秒之内。
满足这些要求并不容易。为了达到这些要求,您需要一个灵活和高性能的系统。灵活性:它应该能够动态创建和调度任务,并支持任意的依赖关系。性能:它应该能够扩展到数百个节点,毫秒级延迟,数百万个任务,并能高效地共享数值数据。
接下来,我将说明我们如何应对这些挑战。灵活性?我们提供一个非常灵活的模型:动态任务图。在此基础上,我们提供两种模型:并行任务和 actors。
要讨论并行任务,这里是 Python 代码:一个从文件读取数组,另一个将两个数组相加。代码很简单:它从 file1 和 file2 创建了两个数组 a 和 b,并将它们相加。所以现在,很容易并行化这个程序。如果我们想要并行化一个函数,为了做到这一点,我们需要为每个函数添加一个 ray.remote 装饰器。当我们调用这些函数时,需要调用 remote 方法。Remote 不会返回对象本身,只返回对象标识符。这与 futures 抽象非常相似。要获取实际对象,必须对对象标识符调用 ray.get。
要更好地了解 Ray 如何执行,让我们执行一个简单的程序。假设文件存储在不同的节点上。当在 file1 上执行 read_array 时,它会安排在适当的节点上执行 read_array。远程调用会立即返回,而实际读取尚未完成。这允许驱动程序并行运行第二个任务,运行在 file2 上的节点,并启动 add remote 函数。所有函数都已远程调度,但还没有完成。要实际获取结果,你必须对结果调用 ray.get。这是一个阻塞调用,你将等待整个计算图被执行完毕。
Tasks 非常通用,但这还不够。考虑你想要运行一个模拟器,而这个模拟器是闭源的。在这种情况下,你无法访问状态。你有状态、动作、模拟,为了在模拟器中设置状态,你无法做到。所以为了解决这个问题,还有另一种用例,即状态创建成本过高的情况。例如,在 GPU 上的深度神经网络中,你希望初始化一次,并且为每次模拟重新初始化。
为了解决这些用例,我们添加了 Actor 抽象。一个 actor 只是一个远程类。如果你有一个计数器 Counter,你标记它为 ray.remote,然后在创建类或调用方法时使用 remote 关键字。这是一个非常简单的示例的计算图。注意方法调用也返回对象标识符。要获取结果,你需要对对象标识符调用 ray.get。Ray 还允许你为 actors 和 tasks 指定资源数量。
综合起来,为了提供一个更现实的例子,评估策略是 Salimans 等人在 OpenAI 中提出的一种可扩展的 RL 形式。简而言之,进化策略尝试许多策略,并尝试看哪一个运行效果最好。这是高度并行的。因此,这里是并行策略的伪代码。一个做模拟并返回奖励的工作者,创建二十个工作者,然后两百个,进行两百次模拟,更新策略。同样地,如果您想并行化此代码,我们必须添加一堆远程,并且现在在右侧,您会注意到我也在共享计算图。当您调用 Worker.remote 时,您会创建 20 个远程工作者来并行执行它。您使用远程关键字调用。再次注意,在这种情况下,结果不是奖励本身,而是奖励对象的 ID。为了获取奖励以获取策略,您必须调用 ray.get。
希望这能给你一点关于如何在 Ray 中编程的风味。下次,我会转换方向,介绍 Ray 的系统设计;Ray 如何实现高性能和可扩展性。
就像许多经典的计算框架一样,它有一个驱动程序和一群工作者。驱动程序运行一个程序,工作者远程运行任务。你可以运行和编写一群演员。驱动器上的演员在同一节点上,它们共享数据,在共享内存上,工作者和跨节点的演员通过我们构建的分布式对象存储进行共享。每个节点都有一个本地调度程序,因此当驱动程序想要运行另一个任务时,本地调度程序会尝试在本地进行调度。如果无法在本地调度,则调用全局调度程序,并且将在具有资源的另一节点上进行调度。演员,远程方法。最后,我们所做的,设计的一个重要部分,就是我们有一个全局控制状态。它获取系统的所有状态,并对其进行集中管理。对象表的对象的元数据,函数。这使系统成为无状态的。所有这些其他组件都可能失败,您可以将它们启动起来,并从全局控制状态获取最新的数据。它还允许我们对全局调度程序进行并行化处理,因为这些复制品将共享 GCS 中的相同状态。
拥有 GCS 的另一个好处是,它使构建一群分析和调试工具变得容易。
此设计具有高度可扩展性。让我试着说服你为什么这样。要使 GCS 可扩展,我们只需将其分片。所有这些密钥都是伪随机的,因此易于分片和负载平衡。正如您所见,调度程序是分布式的;每个节点都有一个本地调度程序,Ray 尝试调度由工作者/驱动程序生成的任务,该任务是本地生成的另一个任务。全局调度程序成为一个瓶颈,我们还可以复制它。最后,在系统中,即使调度程序超级可扩展,在 Spark 中,还有另一个瓶颈:只有驱动程序可以启动新任务。为了解决这个问题,在 Ray 中,我们允许工作者和演员启动任务。实际上,没有单一的瓶颈点。
关于实现的一些话。GCS 采用 Redis 实现。对于对象存储,我们利用 Apache Arrow。对于容错性,我们使用基于线 age 的容错性,类似于 Spark。Actor 是任务图的一部分;方法被视为任务,因此我们有一个统一的模型来提供容错性。
现在是一些评估结果。这张图表示每秒的任务数,您可以看到节点数;它线性扩展。您可以安排超过 1.8M/s。本地任务执行的延迟为 300 微秒,远程任务的延迟为 1 毫秒。这张图说明了容错性。您可能会问为什么关心容错性?问题在于,程序中可能需要模拟未完成;即使您愿意忽略一些结果,这也使程序变得更加复杂。在这个轴上,您有秒数的时间,有两个 Y 轴,系统中的节点数和吞吐量。正如您所见,节点数从 50 开始,然后是 25,然后到 10,再回到 50。在红色区域,显示每秒的任务数;正如您所预期的那样,与系统中的节点数一致。如果您仔细看一下,会有一些下降;每次您都会看到任务数下降。事实证明,这是由于对象重建引起的。当某些节点离开时,您会丢失节点上的对象,因此必须对其进行重建。Ray 和 Spark 会自动透明地重建它们。通过蓝色,您可以看到重新执行的任务。如果将它们加起来,您将得到一个非常漂亮的填充曲线。
最后,对于进化策略,我们与参考的 ES 进行了比较……我们遵循了 OpenAI 的方式,在 X 轴上,您有 CPU 的数量,解决特定问题的平均时间;模拟器,学习运行,有三点值得注意。一是如预期的那样,随着 CPU 数量的增加,解决问题的时间减少。第二是 Ray 实际上比参考 ES 更好,获得了更好的结果,即使参考 ES 专门用于击败。第三,对于非常大量的 CPU,参考 ES 无法做到,但 Ray 可以做得越来越好。我应该补充说 Ray 只需要一半的代码量,并且在几个小时内实现了。
相关工作:在这个领域,有大量的系统,这就是你在这里的原因,很多系统。Ray 与 TF、MXNet、PyTorch 等相辅相成。我们使用这些系统来实现 DNNs。我们与 TF 和 PyT 进行集成。还有一些更通用的系统,如 MPI 和 Spark;它们对嵌套并行性、计算模型有一定的支持,任务粒度更粗。
总之,Ray 是一个高性能、灵活和可扩展的系统。我们在 Ray 的基础上有两个库:RLlib 和 Ray Tune。它是开源的,请试用,我们很乐意听取您的反馈。感谢我的同事迈克尔·乔丹以及罗伯特、菲利普、亚历克斯、斯蒂芬妮、理查德、埃里克、恒、威廉等人。
Q: 在你们的系统中,你们也使用了 actor;actor 是建立在共享内存上的。你们有单独的邮箱给 actor 吗?你们是怎么做到的?
A: 不,actor 通过将参数传递给共享对象存储进行通信。
Q: 并行性的粒度是什么?它是任务原子的,还是将任务分割了?
A: 任务的粒度由启动任务和调度任务的开销决定。你看到的任务,我们的目标是任务、低延迟和少量的 ms。任务不是实现类似激活函数的东西。我们把这项工作留给更好的框架。任务是以原子方式执行的,方法在 actor 中是串行化的。
Q: 在 Spark 中关于容错性的问题:当一段时间没有响应时,它会说这个节点死了。这里,任务更多,因为 NN,类似这样的东西。所以我们没有相同的时间。
A: 我们不进行猜测;Ray 中的隐式猜测,出于你提到的原因。
Q: 你能详细说明一下参考实现,它不具备可伸缩性
A: 参考实现,这是 OpenAI 的实现,Robert 在这里可以为您提供更详细的答案。
读者单子和隐式参数:ezyang 的博客
当读者单子看起来无望笨拙时
读者单子(也称为环境单子)和隐式参数非常相似,尽管前者是工作中 Haskell 程序员的标准工具,而后者是 GHC 语言扩展的一个使用较少的功能。两者都允许程序员编写代码,就像他们可以访问一个全局环境,这个环境在运行时仍然可以改变。然而,隐式参数非常适合于那些您本来会使用一堆读者转换器的情况。不幸的是,与许多类型系统扩展不同,GHC 不能建议您启用 ImplicitParams
,因为您无意中编写的代码不是有效的 Haskell98 代码,但如果您启用了此扩展,它将是有效的。本文旨在演示一种发现隐式参数的方式,并略微推动一下。
实践中的读者单子。 读者单子实际上非常简单:毕竟,它与 (->) r
同构,唯一的真正区别在于新类型。因此,在工程上下文中,它很少原样使用;特别是:
-
它被用作转换器,为您构建的任何特定应用单子提供“环境”,以及
-
它与记录类型一起使用,因为只有一个原始值的环境通常不是很有趣。
这些选择对 Reader 单子编写的代码如何使用施加了一些约束。特别是,将 ReaderT r
的环境类型 r
内嵌到您的单子代码中意味着,您的单子代码不能轻易地与其他 ReaderT r2
的单子代码配合使用;此外,我无法逐步构建一个复杂的记录类型 Record { field1 :: Int; field2 :: String; field3 :: Bool}
,并在发现环境值时将其放入。我可以将我的记录类型设计为某种映射,在这种情况下,我可以随意在其中放置值,但在这种情况下,我无法静态保证在特定时间点映射中会有哪些值或不会有哪些值。
堆叠的 Reader 变换器。为了允许我们逐步构建环境,我们可以考虑堆叠 Reader Monad 变换器。考虑类型 ReaderT a (ReaderT b (ReaderT c IO)) ()
。如果我们将其解糖成函数应用,我们会得到 a -> (b -> (c -> IO ()))
,这可以进一步简化为 a -> b -> c -> IO ()
。如果 a
、b
和 c
恰好是相同类型,我们没有办法区分不同的值,除了参数列表中的位置。然而,与在函数签名中明确写出参数不同(事实上我们正试图通过 Reader Monad 避免这种情况),我们发现自己不得不反复使用 ask
(对于 a
不用,对于 b
使用一次,对于 c
使用两次)。与具有三个字段的记录不同,每个环境变量都没有名称:我们必须使用某些数量的 ask
来引用它们。
旁注。事实上,这是德布鲁因索引,Oleg 在我们关于嵌套循环和延续的文章后,通过电子邮件友好地指出了这一点。升降机的数量就是索引(嗯,维基百科文章是从 1 开始索引的,所以需要加 1),告诉我们需要弹出多少读者绑定作用域。因此,如果我有:
runReaderT (runReaderT (runReaderT (lift ask) c) b) a \------- outermost/furthest context (3) ------------/ \--- referenced context (2; one lift) -/ \--- inner context (1) -/
我得到了
b
的值。这对λ演算理论家来说非常棒(他们对无障碍的α-转换感到高兴),但对软件工程师来说并不是那么理想,因为德布鲁因索引等同于著名的反模式,即魔法数字。
借助类型类技巧,我们可以在某种程度上恢复名称:例如,Dan Piponi 使用单例数据类型或“标签”重命名变换器,在此过程中引入了OverlappingInstances
的强大功能。Oleg 使用与所属层次相关的词法变量类型化来标识不同的层次,虽然这种方法对于 Reader Monad 堆栈并不真正有用,因为 Reader Monad 的要点不在于必须传递任何词法变量,无论它们是实际变量还是特别类型化的变量。
隐式参数。在许多方面,隐式参数是一种欺骗:虽然 Dan 和 Oleg 的方法利用现有的类型级编程设施,隐式参数定义了一个“全局”命名空间(Lisper 们熟知的动态作用域),我们可以在其中放置变量,并且还扩展了类型系统,以便我们可以表达每个函数调用期望存在的这个命名空间中的变量(而无需使用 Monad,这就是它的魔力!)
而不是一个匿名环境,我们为变量赋予一个名称:
f :: ReaderT r IO a
f' :: (?implicit_r :: r) => IO a
f'
仍然是单子的,但单子不再表达环境中的内容:完全依赖于类型签名来确定是否传递隐式变量:
f = print "foobar" >> g 42 -- Environment always passed on
f' = print "foobar" >> g 42 -- Not so clear!
实际上,g
也可以是纯计算:
f' = print (g 42)
然而,如果类型是:
g :: IO a
隐式变量丢失,而如果它是:
g :: (?implicit_r :: r) => IO a
变量是可用的。
虽然runReader(T)
是我们指定环境的方法,但现在我们有了自定义的let
语法:
runReaderT f value_of_r
let ?implicit_r = value_of_r in f
除了放弃了我们的单子限制外,我们现在可以轻松地表达我们的增量环境:
run = let ?implicit_a = a
?implicit_b = b
?implicit_c = c
in h
h :: (?implicit_a :: a, ?implicit_b :: b, ?implicit_c :: c) => b
h = ?implicit_b
你也可以使用where
。请注意,虽然这看起来像是普通的let
绑定,但实际上有很大不同:你不能混合隐式和普通的变量绑定,如果右侧有同名的隐式绑定,它们指的是let
之外的值。你不能递归!(回想一下runReaderT
:我们在第二个参数中提供的值是纯变量,而不是 Reader 单子中的值,尽管通过>>=
你可以那样处理。)
良好的实践。 随着单子结构的消失,在源码级别上少了一些关于单态性约束和多态递归如何应用的提示。非多态递归将编译,并导致意外的结果,例如当你期望时,你的隐式参数没有变化。通过确保始终提供带有所有隐式参数的类型签名,你可以相对安全地处理事务。我希望能做一个后续的帖子,更仔细地解释这些语义,基于相关论文中类型的形式描述。
通过禁用 mDNS 来降低 Ubuntu 的延迟:ezyang 博客
来源:
blog.ezyang.com/2012/03/reduce-ubuntu-latency-by-disabling-mdns/
通过禁用 mDNS 来降低 Ubuntu 的延迟
这是一个非常快速和简单的修复,使我维护的 Ubuntu 服务器的延迟从三到四秒降至瞬间。如果您注意到 ssh 或 scp(甚至像 remctl 这样的其他软件)存在高延迟,并且您可以控制您的服务器,请在服务器上尝试:aptitude remove libnss-mdns
。原来 Ubuntu 上的多播 DNS 存在长期存在的 bug,他们没有正确调整超时,导致 IP 没有名称时进行反向 DNS 查找的性能极差。
移除多播 DNS 将会破坏一些依赖多播 DNS 的应用程序;不过,如果您正在运行 Linux,可能不会注意到这一点。我在上述链接的 bug 中列出了一些其他解决方案,您也可以尝试。
重构 Haskell 代码?:ezyang's 博客
重构 Haskell 代码?
我必须承认,重构 Haskell 代码(或者甚至只是函数式代码)对我来说有点神秘。对我来说,典型的重构会话可能会是这样的:坐在代码前,重新阅读代码。对代码运行 hlint,修复它给你的问题。再多看一些代码。进行一些局部转换,使管道更紧凑或者给局部子表达式起个名字。认为代码看起来相当漂亮和功能齐备,然后去做其他事情。
部分问题在于我尚未培养出对函数式程序常见代码异味的敏感。我在 Haskell 程序中可能会探测到的代码异味,如过长的函数和方法、重复的代码以及过度耦合的代码,在我的程序中明显较少。我编写的大多数函数只有几行(尽管密度很高),轻量级的一阶辅助函数使得临时代码共享变得非常容易,而默认的纯度则鼓励状态的松耦合。这并不是说我的代码没有问题:在 do 块中编写的代码很快就会膨胀到几十行(如果你在 gtk2hs 上编程,这似乎是不可避免的),更高级别的样板代码需要更高级的技巧来消除,而且将所有东西简单地塞进 IO 单子中非常方便且诱人。但这些问题的程度似乎低到可以被忽略不计。
我可以编写代码,但当我回来时,这些代码真的让我很困扰,要么是为了再次理解它,要么是为了扩展它以执行其他任务。在临时基础上,我发现了一些可以使长期维护变得更加麻烦的问题:
-
类型不够通用. 在调试类型错误时,明确写出你的类型签名是一个好习惯,但通常情况下,如果你让函数被推断,你可能会发现你的函数比明显的签名更通用。像
State ()
这样的类型的代码通常可以泛化为MonadState m => m ()
,在许多情况下(如错误处理),你几乎肯定会希望这样泛化。 -
庞大的函数. 如果你按照功能从上到下编写代码片段,很容易在几个地方写上类似
FilePath -> String -> IO [FilePath]
类型的函数,却忘记内部代码可能对程序的某些未来用途有用。有时这很容易解决,因为你原本应该有三行代码,却写了三个单行代码,或者在不需要的单子中写了太多代码,但即使如此,你仍然必须为所有子函数选择名称,并且在某些情况下,划分甚至不够清晰。 -
数据结构不够通用或递归重复。当你在简化一个复杂的递归结构时,很容易精确地选择包含你想要的数据的数据结构。但如果你后来决定要一些不能强行塞进你结构中的其他信息,你就有两个选择:修改所有已经为递归编写的现有代码,以使其包含你寻找的额外信息,或者编写一整套新的函数来递归遍历数据结构。对于复杂的函数而言,这可能是一大堆需要处理的模式匹配。(是的,我知道你可以 Scrap Your Boilerplate,但在某些情况下,它感觉稍微有些沉重,不适合在代码中使用。)
-
孤儿实例。有时候库的作者并没有在他们的代码中放入你想要的实例,于是你面临选择:采取简单而不道德的方式定义一个孤儿实例,还是像一个好公民一样使用新类型,并且承受额外的包装和解包的复杂性。然后库更新来了,你的代码就崩了。
-
即席解析。虽然非常方便,读和显示实际上并不是为生产而设计的。我花了很多时间来制作读取实例,但其实早该转而使用解析库了。
但我真的很好奇,你在寻找代码中将来可能会让你感到头疼的问题时,会寻找什么,并采取什么措施来降低风险。
Reflexivity. Qed.:ezyang 的博客
在其中讨论了 Mendeley、Software Foundations 和 Coq。
有一天,我在#haskell-blah
上抱怨过,整理我下载的所有论文(当然,还没有阅读)。当你从互联网上下载一篇论文时,它的名称可能会是一些非常不方便的东西,比如2010.pdf
、icfp10.pdf
或paper.pdf
。因此,要找到一个你一个月前浏览过并模糊记得标题的论文,你需要某种组织系统。在使用 Mendeley 之前,我采用了AuthorName-PaperTitle.pdf
的惯例,但是我总觉得从五个人的列表中挑选一个作者作为开头有些不好,而且我仍然找不到我要找的论文。
就在这个时候,有人(我没有日志,所以我不记得确切是谁)向我推荐了Mendeley。Mendeley 是免费的(就像啤酒一样的)软件,帮助你组织论文并将它们上传到云端;作为回报,他们获得了关于人们正在阅读的论文以及像我这样的元数据狂人策划他们数据库的各种有趣数据。
它不必做太多来改善我现有的临时命名方案。但它做得非常出色。在我将我的paper 数据库转移到它之后,花一个下午整理 200 篇论文数据库变得相当容易,以确保所有的论文都附有合理的元数据。这些合理的元数据意味着你可以按作者(显然 Simon Peyton Jones 和 Chris Okasaki 是我最喜欢的两位作者之一)和会议对数据库进行切片(万一我真的写了一篇论文并需要找到发送的地方)。你还可以根据自己的主题对论文进行分类,如果像我这样拥有完全无关的研究文献群体,这将非常有用。简单而有效。
噢,我对 Mendeley 还是有一些抱怨的。它的 PDF 查看器有些欠缺:如果我向下翻页,它会完全跳到下一页而不是连续滚动;元数据提取可能会更好(基本上,它应该足够好以便能够在在线数据库中查找并填写数据库);论文的工作流程应该更好(而不仅仅是一个已读或未读的切换,这完全没有用);等等。但它足够好以提供价值,我愿意忽略这些小问题。
整理完所有文件后,我突然意识到最近并没有添加任何新文件到我的收藏中。文件要么通过朋友转发给我,要么我在寻找某个特定主题时会有相关的论文出现,但我实际上没有任何新的论文可供查看。为了解决这个问题,我决定挑选一些名字,去查看他们的最新出版物。
在此过程中,我注意到了本杰明·皮尔斯的出版物上的一个有趣的幻灯片。这份幻灯片是为一个名为证明助手作为教学助理:从前线看的主题演讲准备的。我认为这是解决教学证明问题的一种非常迷人的方法,而且更好的是,课程笔记可以在线获取!
对我来说,精确地描述软件基础是多么不可思议。我发现开始使用证明助手有点困难,因为不清楚应该用它们证明什么:选择太简单的东西感觉毫无意义,选择太难的东西又不知道如何着手解决问题。证明助手也相当复杂(这让我想起我曾在 Galois 听 Eric 和 Trevor 讨论证明策略的时候...那真是一个非常难懂的对话),所以如果你深入研究手册,你会发现自己掌握了许多工具,但并不知道如何全部运用起来。
软件基础之所以伟大,是因为它不是教你如何使用证明助手:它教你逻辑、函数式编程以及编程语言的基础,都是建立在 Coq 证明助手之上的。因此,你有一袋关于这些主题的有趣且基础的定理,想要证明它们,而这门课程则向你展示如何使用证明助手来证明它们。
这也是自学的一个相当理想的情况,因为与许多教科书的练习不同,你的 Coq 解释器会告诉你何时得到正确的答案。证明助手之所以有趣,正是因为它们有点像你可以在不知道解决方案的情况下创建并解决的谜题。因此,如果你有多余的时间,并且想学习如何使用证明助手但之前从未着手,我强烈推荐去看看。
Reified laziness:ezyang 的博客
Reified laziness
短篇,长篇正在进行中。
Par monad中真正的亮点之一是它如何显式地实现了惰性,使用了一个被称为IVar
的小结构(文献中也称为I-structures)。一个IVar
有点像MVar
,但是一旦你把一个值放进去,就再也取不出来了(也不允许放入另一个值)。事实上,这恰恰对应于惰性求值。
关键区别在于IVar
将一个惰性变量的命名(创建IVar
)和指定将产生该变量结果的代码(在IVar
上执行put
操作)分开。对空IVar
的任何get
操作都会阻塞,就像再次尝试评估正在评估的 thunk 会阻塞一样(这个过程称为 blackholing),并且一旦“惰性计算”完成(即发生put
时),就会被满足。
有趣的是,这种构造方式之所以被采纳,正是因为惰性使得并行性的推理变得非常困难。它还为那些希望提供惰性作为内置结构的语言提供了一些指导(提示:将其实现为记忆化的 thunk 可能不是最佳想法!)
用 Haskell 替换小型 C 程序:ezyang 的博客
来源:
blog.ezyang.com/2010/03/replacing-small-c-programs-with-haskell/
用 Haskell 替换小型 C 程序
C 作为小型程序的经典选择,速度非常快。当scripts.mit.edu需要一个小程序来实现一个增强版的 cat,并且在输出开头添加有用的 HTTP 头时,毫无疑问:它将会用 C 语言编写,并且速度非常快;我们静态内容的服务速度取决于它!(技术细节:我们的 Web 服务器基于网络文件系统,并且我们希望避免在 Apache 被入侵时给予它过多的凭证。因此,我们修改了内核以强制执行额外的规定,即必须以某个用户 ID 的身份运行才能从文件系统中读取这些文件。Apache 以自己的用户身份运行,因此我们需要另一个小程序作为中间人。)
它也是一个 Frankenscript,这是一个根据我们项目非常特定需求而发展出来的程序,在世界上其他地方找不到。因此,保证程序简洁和定义清晰非常重要;这两个特性在 C 代码中很难达到。当您想要添加功能时情况会变得更糟。有一些小功能(最后修改头,字节范围),以及一些大功能(FastCGI 支持)。没有开发团队愿意考虑将 C 文件的大小加倍以添加所有这些增强功能,并且将程序重写为脚本语言将导致性能下降。用 Perl CGI 替换脚本的基准测试使脚本慢了十倍(这在进行端到端 Apache 测试时转化为四倍慢)。
但是还有另一种方式!Anders 写道:
所以我意识到:用编译的 Haskell CGI 脚本替换它可能会让我们保持相同的性能。而且由于 Haskell 的 FastCGI 库具有相同的接口,易于移植到 FastCGI。
几周后,呈现出:Haskell 中的 static-cat。然后我们看到以下基准测试:
$ ab -n 100 http://andersk.scripts.mit.edu/static-cat.cgi/hello/hello.html
Requests per second: 15.68 [#/sec] (mean)
$ ab -n 100 http://andersk.scripts.mit.edu/static-cat.perl.cgi/hello/hello.html
Requests per second: 7.50 [#/sec] (mean)
$ ab -n 100 http://andersk.scripts.mit.edu/static-cat.c.cgi/hello/hello.html
Requests per second: 16.59 [#/sec] (mean)
微基准测试显示没有 Apache 时有 4ms 的差异,Anders 怀疑这是由于 Haskell 可执行文件的大小。肯定需要进行一些性能调查,但 Haskell 版本在端到端测试中比 Perl 版本快两倍以上。
更一般地说,编译成本地代码的语言类别(Haskell 只是其中之一)似乎越来越成为取代具有高性能要求的紧凑 C 程序的吸引力选择。这是相当令人兴奋的,尽管这取决于您是否能说服开发团队将 Haskell 引入您使用的语言组合中是一个好主意。关于这一点,我将在另一篇博客文章中详细讨论。
Haskell 的资源限制:ezyang 博客
上周,我第一次向 ICFP 提交了我的论文!主题是?一个我曾经热衷的老问题:如何限制 Haskell 程序的空间使用。
我们描述了 Haskell 资源限制系统的第一次迭代,利用资源限制与性能分析的语义和实现策略相同的关键观察。我们特别关注限制常驻内存使用的问题:我们描述了一种简单的实现技术来执行增量堆调查,并描述了一种处理强制资源回收的新型信息流控制解决方案。该系统实现为 GHC 的一组补丁。
你可以在这里获取提交的副本。 我在下面重现了关于如何分析 Haskell 的背景部分;如果你对此感兴趣,可以查看论文的其余部分!
Haskell 的性能分析是通过将计算成本分配给“当前成本中心”来完成的。成本中心是一个抽象的、由程序员指定的实体,可以向其收取成本;在任何给定时间内,每个线程只能有一个活动的成本中心,而成本语义决定了程序执行过程中当前成本中心的变化。例如,scc cc e
表达式(设置成本中心)在评估e
期间修改当前成本中心为cc
。成本中心在编译时静态定义。
Haskell 的成本语义是由 Sansom 等人(1995 年)定义的。此前,在惰性评估和高阶函数存在的情况下,如何分配成本并没有形式化的解释;这篇论文解决了这些问题。他们论文的两个关键观点是:首先,他们明确指出成本分配应该与评估顺序无关。为了易于理解,一个 thunk 是立即评估还是稍后评估不应影响谁为其付费。其次,他们观察到在函数的成本分配上有两种方式,直接对应于词法作用域和动态作用域之间的差异。
通过这个程序可以看到成本分配无关顺序的原则:
f x = scc "f" (Just (x * x))
g x = let Just y = f x in scc "g" y
当调用g 4
时,评估x * x
的成本应该由谁承担?对于严格评估,很容易看出应该由f
承担,因为x * x
是在scc
表达式内部立即评估的。而无关顺序则决定,即使将x * x
的执行延迟到scc "g" y
的内部,成本仍然应该归于f
。通常情况下,对于变量x
的scc "f" x
是一个无操作。为了实施这样的方案,必须在分配 thunk 时记录当前成本中心,并在强制 thunk 时恢复它。
函数成本归因方面的词法作用域和动态作用域的区别可以在这个例子中看到:
f = scc "f" (\x -> x * x)
g = \x -> scc "g" (x * x)
这两个函数有什么区别?我们处于类似于 thunks 选择的情况:当前成本中心是否应该与闭包一起保存,并在函数调用时恢复?如果答案是肯定的,我们使用词法作用域,这两个函数是等效的;如果答案是否定的,我们使用动态作用域,f
中的scc
是一个空操作。 GHC 当前为scc
选择的策略是动态作用域。
rxvt-unicode for gnome-terminal refugees : ezyang’s blog
来源:
blog.ezyang.com/2010/01/rxvt-unicode-for-gnome-terminal-refugees/
当我从 Ubuntu 的默认 Gnome 桌面切换到平铺窗口管理器 Xmonad 时,我保留了 Gnome Terminal,尽管删除了菜单栏和滚动条。我从默认的白色切换到了一个很好的 #2B2B2B 色调(Sup 最初向我介绍的一种色调)。
然而,随着时间的推移,当我在切换窗口时(在平铺窗口管理器中是一个常见的任务,特别是在屏幕尺寸相对较小时),我对 Gnome Terminal 渲染速度缓慢感到越来越恼火;基本症状是在旧终端离开和新终端绘制过程中屏幕会闪白。在测试了 xterm 并发现在切换窗口时它并不会闪白后,我决定寻找一个更快的终端仿真器;在 David Benjamin 的建议下,我最终选择了 rxvt-unicode,也称为 urxvt。
rxvt-unicode 是 X 终端仿真器自豪传统的一部分,因此其设置由 X 窗口管理器管理(而不是 gnome-terminal 使用的 gnome-settings)。您可以使用名为xrdb
的程序在运行时操纵设置;但我发现将我想要的设置放在 .Xdefaults
中大多数时候更加简单,它会在会话启动时自动加载。语法很简单:一个以换行分隔的文件,格式为 Appname*option: value
。在 rxvt-unicode 的情况下,Appname 是 URxvt
。
使用了很长时间的 gnome-terminal,我有点不愿意放弃我所喜爱的颜色和行为。因此,这是我的 .Xdefaults
文件,带有关于各部分作用的注释:
URxvt*background: #2B2B2B
URxvt*foreground: #DEDEDE
URxvt*font: xft:Monospace:pixelsize=12
URxvt*boldFont: xft:Monospace:bold:pixelsize=12
URxvt*saveLines: 12000
URxvt*scrollBar: false
URxvt*scrollstyle: rxvt
这些部分都相当容易理解;rxvt-unicode 支持抗锯齿字体,这意味着粗体文本看起来很好(这也是我无法忍受 xterm 的主要原因之一,因为粗体字体在没有抗锯齿的情况下往往会相互混合)。
URxvt*perl-ext-common: default,matcher
URxvt*urlLauncher: firefox
URxvt*matcher.button: 1
URxvt*colorUL: #86a2be
这些行实现了终端内可点击的 URL。启动器在光标上不会给出任何视觉提示,但我发现下划线和颜色变化已经足够。
! black
URxvt*color0 : #2E3436
URxvt*color8 : #555753
! red
URxvt*color1 : #CC0000
URxvt*color9 : #EF2929
! green
URxvt*color2 : #4E9A06
URxvt*color10 : #8AE234
! yellow
URxvt*color3 : #C4A000
URxvt*color11 : #FCE94F
! blue
URxvt*color4 : #3465A4
URxvt*color12 : #729FCF
! magenta
URxvt*color5 : #75507B
URxvt*color13 : #AD7FA8
! cyan
URxvt*color6 : #06989A
URxvt*color14 : #34E2E2
! white
URxvt*color7 : #D3D7CF
URxvt*color15 : #EEEEEC
我非常喜欢 gnome-terminal 的颜色方案,比 rxvt 默认的要低调一些。因此,它被采纳了。第一个颜色是“正常”的;第二个颜色是“亮的”。
安全第一:FFI 和线程:ezyang 的博客
更新。 虽然这篇博文列出了两个事实,但它错误地解释了这两个事实之间的因果关系。这里是更正链接。
注意保守使用。 在 FFI 导入中不要使用unsafe
!我们是认真的!
考虑以下来自旧版 Haskellwiki 的FFI 介绍示例:
{-# INCLUDE <math.h> #-}
{-# LANGUAGE ForeignFunctionInterface #-}
module FfiExample where
import Foreign.C -- get the C types
-- pure function
-- "unsafe" means it's slightly faster but can't callback to haskell
foreign import ccall unsafe "sin" c_sin :: CDouble -> CDouble
sin :: Double -> Double
sin d = realToFrac (c_sin (realToFrac d))
评论轻松指出该函数无法“回调到 Haskell”。初学 FFI 的人可能会想:“哦,这意味着我可以在大多数 FFI 声明上使用大多数unsafe
,因为我不会做任何像回调到 Haskell 那样高级的事情。”
哦,朋友,如果事情能这么简单就好了!
请记住,在 Haskell 中使用forkIO
创建线程时,并不是真正创建操作系统线程;你创建的是一个绿色线程,由 Haskell 的运行时系统在其操作系统线程池中管理。这通常是很好的:真正的线程很重,但 Haskell 线程很轻,你可以使用很多而不用付出太多代价。但问题来了:
运行时系统无法抢占不安全的 FFI 调用!
特别是,当你调用一个unsafe
的 FFI 导入时,你实际上暂停了系统中的其他所有操作:Haskell 无法抢占它(特别是unsafe
表示不需要保存运行时系统的状态),并且外部代码将独自运行,直到完成。
不相信?自己试试(我在 6.12.1 上进行了测试)。你需要一些文件:
/* cbit.c */
#include <stdio.h>
int bottom(int a) {
while (1) {printf("%d\n", a);sleep(1);}
return a;
}
/* cbit.h */
int bottom(int a);
还有UnsafeFFITest.hs
:
{-# LANGUAGE ForeignFunctionInterface #-}
import Foreign.C
import Control.Concurrent
main = do
forkIO $ do
safeBottom 1
return ()
yield
print "Pass (expected)"
forkIO $ do
unsafeBottom 2
return ()
yield
print "Pass (not expected)"
foreign import ccall "cbit.h bottom" safeBottom :: CInt -> IO CInt
foreign import ccall unsafe "cbit.h bottom" unsafeBottom :: CInt -> IO CInt
使用以下命令编译和运行相关文件:
gcc -c -o cbit.o cbit.c
ghc -threaded --make UnsafeFFITest.hs cbit.o
./UnsafeFFITest +RTS -N4
你看到的输出应该类似于这样:
ezyang@javelin:~/Dev/haskell/unsafe-ffi$ ./UnsafeFFITest +RTS -N2
1
"Pass (expected)"
2
1
2
1
2
第一个调用友好,允许 Haskell 继续前进,但第二个调用没有。一些值得尝试的事情包括交换 fork 的顺序,使用forkOS
(许多人,包括我自己,错误地认为它会创建另一个操作系统调用),以及更改 RTS 选项-N
。
这意味着什么?本质上,只有当你非常确定 Haskell 永远不会中断你的 C 调用时(我不会对除了最小、最纯净的 C 函数以外的情况做出这种断言),才不要使用unsafe
。不值得冒险。安全第一!
附录。 感谢#haskell
帮助我梳理这条思路(我之前遇到过这种行为,但没想到可以写成博客。)
附录 2. 感谢 Simon Marlow 在澄清我在原始处理该主题时所犯的一些错误时提供的帮助。如果你对并发和外部函数接口(FFI)的相互作用更多细节感兴趣,请查阅他指向的论文:扩展 Haskell 外部函数接口与并发性。
IVars 调度:ezyang 的博客
IVars 调度
我在先前的IVar monad post中提到的愚蠢调度程序的一个缺点是,由于它将所有待处理操作存储在堆栈上,因此很容易堆栈溢出。我们可以通过实现执行计划来明确地将所有这些待处理的回调移动到堆上。这涉及在我们的单子中添加Schedule
状态(我已经用IORef Schedule
这样做了)。这里是一个稍微聪明一些的调度程序(我还简化了一些代码片段,并添加了一个新的addCallback
函数):
import Data.IORef
data IVarContents a =
Blocking [a -> IO ()]
| Full a
type Schedule = [IO ()]
type IVar a = IORef (IVarContents a)
newtype T a = T { runT :: IORef Schedule -> IO (IVar a) }
instance Monad T where
return x = T (\_ -> newIORef (Full x))
m >>= f = T $ \sched ->
do xref <- runT m sched
mx <- readIORef xref
case mx of
Full x -> runT (f x) sched
Blocking cs -> do
r <- newIORef (Blocking [])
let callback x = do
y <- runT (f x) sched
addCallback y (fillIVar sched r)
addCallback xref callback
return r
addCallback :: IVar a -> (a -> IO ()) -> IO ()
addCallback r c = do
rc <- readIORef r
case rc of
Full x -> c x
Blocking cs -> writeIORef r (Blocking (c:cs))
fillIVar :: IORef Schedule -> IVar a -> a -> IO ()
fillIVar sched ref x = do
r <- readIORef ref
writeIORef ref (Full x)
case r of
Blocking cs -> schedule sched (map ($x) cs)
Full _ -> error "fillIVar: Cannot write twice"
-- FIFO scheduler
schedule :: IORef Schedule -> [IO ()] -> IO ()
schedule sched to_sched = do
cur <- readIORef sched
writeIORef sched (cur ++ to_sched)
run :: T () -> IO ()
run initial_job = do
sched <- newIORef []
writeIORef sched [runT initial_job sched >> return ()]
let go = do
jobs <- readIORef sched
case jobs of
[] -> return ()
(job:rest) -> writeIORef sched rest >> job >> go
go
这里是演示基本思想的一些示例代码:
-- Does more work than return (), but semantically the same
tick :: T ()
tick = T $ \sched ->
do r <- newIORef (Blocking [])
schedule sched [fillIVar sched r ()]
return r
main = run loop
loop = tick >> loop
实际上,这个简单的无限循环会泄漏空间。(读者可以自行尝试。)这正是 LWT 的作者们遇到的问题。我不喜欢把博客文章分成小块,但是这段代码的正确编写花了比我预期的时间长一些,而我也没有时间了——所以请等下次再详细处理吧!
环境策划:ezyang 的博客
环境在 MIT/GNU Scheme 中是一流对象。这很棒,因为 Scheme 的词法结构的一个整体部分也是一个数据结构,能够编码数据和行为。事实上,环境数据结构正是 Yegge 所称的 属性列表,可以与继承链接起来。因此它不仅是一个数据结构,而且还是一个高度通用的数据结构。
即使不能将环境作为一流变量传递,你仍然可以利用其在面向对象方式中隐藏本地状态的语法联系。数据只能在指向适当环境框架的过程内部访问,传统上闭包返回一个 lambda(其双泡指向新出生的环境框架),作为封闭状态的唯一接口。这需要相当多的样板代码,因为此 lambda 必须支持你可能希望对函数内部进行的每种操作。
然而,具有一流环境对象,你可以随意处理闭包的绑定。不幸的是,没有直接 get-current-environent
的方法(除了顶级 REPL 环境,这不算),所以我们采取以下技巧:
(procedure-environment (lambda () '()))
procedure-environment
可以从某些过程的双泡中抓取环境指针。因此,我们强制使用空 lambda 创建指向我们关心的环境的双泡。
我最近在一个 6.945 项目中使用了这种技术,使用 lambda 生成了一堆带有不同参数的过程(鼓励代码重用),类似于多次包含 C 文件并使用不同宏定义的时间荣誉技巧。不是将这些过程作为哈希表返回然后人们必须显式调用,而是返回环境,因此任何消费者都可以通过使用适当的环境进入"一个不同的宇宙"。
Scheme 在其庆祝环境作为一流对象方面非常独特。我可以尝试在 Python 中使用这种技巧,但函数上的 func_closure
属性是只读的,而且 Python 的作用域规则相当弱。这真是遗憾,因为这种技术允许一些可爱的语法简化。
评论。 Oleg Kiselyov 提到 "MIT Scheme 特别是在将环境作为一等环境进行庆祝方面是独一无二的",并指出即使一些 MIT Scheme 的开发者也对该特性有了反思。这使得代码难以优化,从理论和实际上来说都是危险的:从理论上讲,环境实际上是一种实现细节,从实际上讲,这使得对代码的推理变得非常困难。
从面对面的讨论中,我对 Sussman 偏爱的 Scheme 方言允许这样一个危险的特性并不感到惊讶;Sussman 一直支持让人们接触危险的玩具,并相信他们能正确使用它们。
安全的多方比特币匿名化:ezyang’s blog
来源:
blog.ezyang.com/2012/07/secure-multiparty-bitcoin-anonymization/
摘要. 我们描述了如何利用安全的多方排序作为比特币匿名化协议的基础,这种协议改进了当前集中式“混合”设计。
比特币是一个匿名协议:虽然比特币地址原则上完全匿名,但进入和离开钱包的所有流量都是公开可见的。通过一些简单的网络分析,可以将一组地址链接在一起并识别出来。
目前用于比特币匿名化的最先进技术是混合服务,这是一个信任的第三方钱包,接受传入交易,并在未来的随机时间以随机增量将相应数量转移到您选择的新钱包。其结果是,从该服务分发的任何比特币,都可能来自多个身份。
这类混合服务存在一些显著问题。首先,混合服务必须值得信任,不会保留日志或以其他方式监视混合过程:如果它们被攻破,可以完全追踪任何给定比特币的路径。对于这种情况的通常建议是使用多个混合服务,这样所有这些服务必须被攻破才会失去匿名性。其次,混合服务必须值得信任,不会转身拒绝返还您的资金;这使得这种混合服务对于匿名大量比特币存在风险。最后,大多数混合服务在通常的交易费用之外还收取处理费。
我们提出了一种去中心化、安全的多方协议,用于实施混合协议。这样的系统已在摘要中提出(也在这里);在本文中,我们详细描述了如何实现它,特别是展示了多方排序(一个相对研究充分的算法问题)足以实现此协议。该协议不需要信任的第三方(除了协助发现有兴趣执行混合协议的参与者),不需要你透露你的输入输出地址,仅需最终交易即可执行。
协议描述
首先进行一些准备工作:多方排序问题通常如下形式:每个参与方i贡献一个输入元素A[i]。协议执行后,所有参与方都以秘密共享的形式学习到了排序后的序列A,但不知道谁贡献了序列中的任何特定元素A[i]。参考Jónsson, Kreitz and Uddin (2011)的工作,我们将这个协议作为一个基本原语:多方排序的基本思想是构建一个固定大小的排序电路,然后利用生成的电路描述在多方计算框架上进行操作。
现在描述混合问题。假设有若干方参与将 1 BTC 的硬币混合在一起的过程。(目前假设每位参与者拥有相同数量的比特币;稍后我们将进行推广。)特别地,每位参与者i有一个输入钱包A[i](余额至少为 1 BTC)和一个输出钱包B[i],并且只会在转账 1 BTC 到其输出钱包B[i]的交易中签署。任何参与该交易的对手都不应能够获知与A[i]对应的B[i],除非它是参与交易的输出钱包集合中的一员。
协议的执行步骤如下:
-
每位参与者声明将使用的输入钱包,并生成签名以证明他们拥有该钱包。这些钱包公开排序为A。
-
将参与输出钱包的每个公钥的数值定义为排序,进行所有输出钱包的安全多方排序。我们现在得到了一个排序好的输出钱包列表B,交易的任何成员都不知道谁贡献了哪个输出钱包。每位参与者应检查他们的输出钱包是否包含在这个列表中(以防止拜占庭故障);如果没有,他们应该中止协议并销毁他们的输出钱包(其身份已泄露)。
-
构建一笔交易,从排序列表中将 1 BTC 从A[0]转账到B[0],A[1]转账到B[1],依此类推,并广播给所有参与者。客户端使用其输入钱包对交易进行签名。
-
一旦所有签名到位,交易即为有效,并广播以纳入区块链。
混合池只有在所有参与者试图混合相同数量的比特币时才起作用。为了管理希望混合更大数量比特币的参与者,我们建议维护不同大小比特币的发现通道,例如...1/4 BTC,1/2 BTC,1 BTC,2 BTC,4 BTC...
分析
步骤(1)并未提及输出钱包,因此不能泄露有关输入-输出对应关系的信息。根据定义,步骤(2)不会泄露有关谁贡献了输出钱包的信息。此外,我们甚至不要求排序结果是秘密共享的:一旦交易被发布在区块链上,这个排序列表将成为公开信息。在中止交易的情况下,如果你的输出钱包不在结果中(在拜占庭失败的情况下),情况就会棘手:中止会泄露信息,因此你不能在进一步的交易中使用输出钱包。在步骤(3)中,假设攻击者知道正在进行混合交易,输入到输出钱包的确定性映射不会再给所有参与者提供进一步的信息位。因此,这个协议显然满足其安全要求。
这个协议的一个奇怪之处在于,参与者之间没有明确构建任何随机排列。这看起来可能不同寻常,因为人们可能期望的一个自然安全性质是,输出钱包从一个均匀随机选择的输入钱包接收其 1 BTC。然而,这个条件虽然足以保证匿名性,但并非必需。正如添加随机排列会破坏原始排列的所有信息一样,用新的常数排列替换原始排列也会破坏所有原始排列的信息。此外,诚实的参与者会随机选择他们的地址,因此排序过程会自动在这些参与者之间构建一个随机排列(必须排除不诚实的参与者,因为他们可以生成来自倾斜概率分布的地址)。
一个轮次协议授予多少匿名性?考虑这样一种情况,我在一个与我的身份绑定的输入钱包中有 1 BTC,并且我与n个诚实参与者一起进行了混合,使用了一个全新的输出钱包。因为没有泄露关于这个输出钱包来源的信息给参与者,一个对手在交易中猜测哪个输出钱包是我的概率为1/n-1:这被称为匿名因子。在 Sybil 攻击的情况下,授予的匿名性数量会减少。如果攻击者的比例小于参与者的某个比例(对于许多秘密共享协议来说,被动攻击者的魔数是 1/2,主动攻击者的魔数是 1/3),则匿名因子仍为1/n-1,其中n是诚实参与者的数量;但n小于协议中可见参与者的数量:交易的大小不一定与其匿名性相关!如果攻击者的数量超过这个比例,那么秘密共享方案可能会泄露信息,匿名性不再保证。这为混合协议提供了一种拒绝服务攻击的机会;我们稍后描述一些对抗这种攻击的缓解策略。(注意,无论攻击者有多少,由于第(2)步中的验证步骤,您都保证不会丢失任何比特币。)
在实践中
与传统混合一样,应采取一些预防措施,以避免通过侧信道意外泄露有关比特币来源的信息。考虑这样一种情况,Alice 拥有与她真实身份绑定的 2 BTC,并且她希望匿名向 Bob 支付 3/4 BTC。
Alice 首先通过创建一组空钱包来做准备,这些钱包将用于携带匿名的比特币。Alice 通过 Tor 连接到一个 1 BTC 混合的追踪器。(1 BTC 是她想支付给 Bob 的金额,四舍五入。)她等待一个时间窗口到期以进行下一轮混合,然后按照协议提交她的(公开的)输入钱包和(匿名的)输出钱包。如果协议失败,她会丢弃她的输出钱包,并在下次能确定是哪个节点出问题时列入黑名单。
一旦 Alice 成功进行了混合,她会掷一枚硬币(该硬币以概率1/m出现正面)。如果硬币出现正面,她会等待进行另一轮混合。她预计要执行的混合交易数量是m(遵从几何分布,因此选择它使所有输出钱包在重新混合或退出方面表现一致)。一旦 Alice 退出混合,她现在有一个包含匿名比特币的钱包(更确切地说,这个比特币可能以等概率归因于她参与混合的任何其他钱包)。她向 Bob 转移了 3/4 BTC,留下 1/4 BTC 在她的钱包里。
现在应该将钱包中剩余的比特币视为受污染的(因为它们现在与 Bob 有直接关系,Bob 可能有一个公共钱包)。在用于任何其他目的之前,这些比特币应该分成可混合的金额并重新匿名化。即使匿名化后,这些硬币仍然必须小心使用:特别是它们不能转回原始的公共比特币账户。在这种情况下,混合交易的图结构如下:
(绿色节点是您的公共节点,红色节点是您的匿名节点)。在比特币转账中寻找循环的网络分析将能够识别任何交易,即使混淆池中有大量的流动性(尽管有趣的是,如果许多参与者生成循环,这种攻击更难实施)。为了帮助追踪这些硬币,我们建议开发能够处理成千上万个私钥、按“硬币大小”排序,并跟踪与任何给定硬币相关联的易于追踪的交易历史的钱包管理软件。
为了防范 Sybil 攻击,Alice 可能希望慎重选择她的混合跟踪器。某些混合跟踪器可能会收取上市费用:在足够的交易量下,这些费用将使持续的 Sybil 攻击变得更加昂贵。 (然后,这些费用可以被用来支付复杂的混合交易的处理费用,这些交易有着伴随交易费用的社会期望。)每次混合都应该使用不同的 IP 地址进行;如果 Alice 使用 Tor 进行匿名化,她需要每次重新匿名化她的连接。
结论
安全多方计算一直是寻求比特币匿名化的用户关注的焦点,但迄今为止还没有任何可信的实施策略。我们希望本文档描述了这样一种实施策略,并为比特币匿名化的生态系统铺平了道路。正如 Bitcoinica 和其他交易所的惨败所示,依赖第三方钱包是危险的。幸运的是,它们也是不必要的。
致谢
我要感谢 Eric Price 在制定这一协议中发挥的重要作用。
掌握生产资料(API 的):ezyang 的博客
来源:
blog.ezyang.com/2016/09/seize-the-means-of-production-of-apis/
有一个糟糕的 API 正在毁掉你的一天。你会怎么做?
在不进行道德判断的情况下,我想指出这两种方法之间确实存在很大的不同。在 Dropbox 的情况下,Dropbox 无法(直接)影响苹果为其操作系统提供的 API。因此,它别无选择,只能在现有 API 的框架内工作。(当苹果说跳时,你会问,“跳多高?”)但在亚当的情况下,POSIX 由开源项目 Linux 实现,通过一些好主意,亚当可以在 Linux 的顶部实现他的新接口(避免从头开始编写操作系统的必要性)。
API 跨越社会边界:有使用 API 生成软件的无产阶级,有控制 API 的资产阶级。当大公司成为“大人物”时,我们的选择只能是绕过他们糟糕的 API 或者付给他们足够多的钱来修复他们糟糕的 API。但当“大人物”是一个开源项目时,你的选择就会改变。当然,你可以绕过他们糟糕的 API。或者你可以掌握生产资料,从而使你能够修复这些糟糕的 API。
我所说的掌握生产资料是什么意思呢?确实,生产资料到底是什么?一个开源 API 并不孤立存在;它是由提供 API 的软件、为维护这些技术付出时间和专业知识的开发者、甚至是宣传平台共同使其有用。掌握生产资料就是要控制这些方面。如果你能说服体制认为你是开源软件的核心贡献者,那么你就能够修复这些糟糕的 API。如果你不愿意或者不能这样做,你仍然可以分支、提供商或者重写项目,但这并不是掌握生产资料,而是从头开始重新创建它。另一种可能性是在现有 API 的基础上构建你需要的抽象(就像亚当所做的那样),尽管你始终面临原始项目不关注你需求的风险。
一次又一次地,我看到与开源项目合作的人们拒绝掌握生产资料。相反,他们愿意写越来越复杂的变通方法来解决问题,这都是为了保持在界限内。你可能会说,“这只是做事的方法(TM)”,但在某个时候,你解决问题所做的工作量可能比直接修复它还要多。
所以停止吧。不要忍受糟糕的 API。不要局限自己。掌握生产资料吧。
反对意见。
-
这篇文章所倡导的绝非别的,仅仅是无休止的无意义琐事;如果您认真对待这些建议,您将永远无法完成任何事情。
-
虽然从总体上看,解决糟糕的 API 的成本可能超过了修复它的成本,但对于个体而言,成本通常较低。因此,即使您能够完美地预测解决方案与正确修复的成本,个体的激励也会阻止正确的修复。
-
用户(包括开发人员)对他们使用的软件一无所知,并且缺乏设计更好的 API 的能力,即使他们知道痛点所在。
-
很少能够单方面占领生产资料的控制权。在理想的世界中,要成为核心贡献者,仅仅展示对项目的持续、有用的贡献是足够的。我们都知道现实世界更加混乱。
野生中的语义导入版本:ezyang's 博客
来源:
blog.ezyang.com/2018/02/semantic-import-versioning-in-the-wild/
语义导入版本的最大好处和最大坏处在于它使破坏向后兼容的更改变得困难。
最近几天,Russ Cox 在一系列关于Go 与版本控制的白皮书中引起了轰动。在其中,他创造了一个新术语,语义导入版本,并将其概括为以下原则:
如果旧包和新包具有相同的导入路径,则新包必须向后兼容旧包。
我非常高兴 Russ 给语义导入版本命名想出了一个好名字,因为这个概念已经存在了很长时间,但却没有一个简明的名称或其设计的公式化。事实上,我甚至会说,当你承诺永远不会破坏用户代码的前提时,语义导入版本是不可避免的。它是如此不可避免,以至于语义导入版本已经在多种地方的实际应用中进行了实践。以下是一些例子:
-
REST API 通常 在 请求中 明确指定版本号(例如在 URI 中),以便客户端指定他们想要的 API 版本。如果客户端希望升级到 API 的新版本,则必须将他们的 API 请求重写到新的 URL。REST API 由于传统的避免破坏的机制,即版本边界,在这种设置中不可用,因此被迫使用语义导入版本。
-
Stripe 的 REST API 将他们的每个客户固定在他们订阅时的 API 版本上;即使 Stripe 将来进行了向后不兼容的更改,给定客户的 API 也永远不会改变。在这种情况下,语义导入仍然存在,但是它是隐式的(与客户帐户相关联),而不是显式的(在客户端代码中);因此,Stripe 愿意比传统 REST API 能够接受的更频繁地破坏向后兼容性。Stripe 的博客文章指出了维护库中语义导入的一个非常重要的方面,即您需要投入工程努力来可持续管理所有对用户可用的语义导入。
-
编程语言普遍实践语义导入版本控制,以语言标准/时代的形式存在。在 C++ 中,设置 -std=c++xx 指定了要“导入”的特定语义版本。在新版本编译器中,单方面破坏 -std=c++11 的向后兼容性是不可想象的;同样,用户必须显式迁移到新的语言标准才能利用任何新功能。Rust epochs 有类似的风格。选择 Python 2 和 Python 3 之间的差异也是语义导入版本控制的一种形式。
-
语义导入不仅仅是指定一个数字。例如,在 GHC Haskell 中,像{-# LANGUAGE #-} pragma 这样的功能标志允许用户在使用站点中选择支持 BC-breaking 更改。
-
在深度学习领域,ONNX 模型声明了对操作符集的特定版本的语义导入。操作符语义可以以兼容 BC 的方式演变,而无需增加版本号,但要应用 BC-breaking 更改,必须更新导入语句。
我从这些例子中得出的一个见解是,我们称之为“导入版本”的东西实际上是一系列实现的规范。对于那些花费大量时间思考模块系统的人来说,这确实是朝着正确方向迈出的一步:根据接口而不是实现编程。
我们从这些例子中还可以观察到语义导入版本控制的现实世界后果。一个特别突出的影响是:语义导入版本控制对维护人员来说是挑战,因为它迫使他们同时维护多个主要发布分支(毕竟,谁愿意使用 pkg/v2,却在 pkg/v3 发布后立即停止维护)。在传统的发布分支模型中,每个主要版本都创建一个发布分支,只有最充足的软件开发团队才能负担得起同时维护多个活跃的发布分支(回溯补丁是一项繁重的工作!)管理多个实现所涉及的摩擦意味着较少人员的项目将强烈压力以不破坏向后兼容性。
对于观众中的“不要破坏我的东西”的人来说,这听起来可能不像什么大问题,但很多错误和安全问题源于实际上无法禁止有害和危险的 API 使用 BC-breaking 更改。将计算进一步朝向保留向后兼容性的危险,将进一步深化糟糕的“第一次尝试”API。因此,虽然我不否认 Russ 框架的天才之处在于将语义版本控制描述为包的 路径 的一部分,但它也为实施 BC-breaking 更改的可行性设立了错误的期望,我们应该改善工具的状态,以便对进行 BC-breaking 更改“不是什么大事”。对我来说,减少 BC-breaking 更改摩擦的最有希望的方法是组织软件开发,使一个 单一 代码库在 单一 构建下实现 多个 规范(v1、v2 和 v3)。正如我们从示例中看到的,编译器可以管理这一点(GCC 支持多个 C++ 版本),但传统的编程语言使得库难以做同样的事情。
我现在不知道如何准确解决这个问题,但我确实有一些想法:
-
将规范视为数据。 这意味着你可以编写操作规范的代码,例如自动生成从一个实现到另一个实现所需的样板代码。
-
不要求程序员手动编写差异。 我永远不会要求你通过手工编写差异来进行源代码更改,仅仅因为这是版本控制系统存储的最佳表示形式。相反,你只需进行编辑,然后期望系统自行解决。对 API 的 BC-breaking 更改遵循同样的原则;如果你只是 做出更改,而不是编写更改的描述,这将更简单和易于理解。
-
包级别的模块化。 在传统的包管理系统中,我无法发布一个单一的源代码包,其中包含多个“包接口”。即使在 vgo 中,即使我有一个共享的代码库实现 v1 和 v2,我仍然必须发布两个版本来发布新版本的代码。这是倒退的;没有理由一个单元的代码不能提供多个接口,并且包工具应该使这成为可能。
这些想法可能对期望 Go 采纳它们有些激进了,但也许下一代编程语言会进一步探索这一设计空间。
半自动化测试:ezyang’s 博客
半自动化测试
当程序员自动化某些事情时,我们常常希望全面自动化所有事情。但是要记住,手动测试在机器辅助下仍然有其存在的价值:与其花费指数级的努力去自动化一切,不如自动化容易的部分,并对困难的研究问题硬编码答案。当我编制下面的测试数据来源图表时,我注意到在“自动化”和“非自动化”的两端存在显著的极化现象。
一个理想的测试框架将支持结合所有这些数据来源和所有这些测试机制。一些新颖的方法包括:
-
通过手动验证随机生成的测试用例。显然,您无法手动验证成千上万个测试用例,但几个具体的例子对文档目的非常有帮助,而随机生成可以防止我们只选择“好”的输入。
-
作为旧版本代码的参考实现。到极限,您自动接受旧实现的输出并将其保存到您的测试套件中,当测试开始失败时,框架会要求您检查输出,如果比以前“更好”,则用新的数据覆盖旧的测试数据。 GHC 的测试套件就有类似的功能。
-
你已经编写了很多代数定律,现在正使用 Quickcheck 进行验证。您应该能够用来自采样数据源的确定性数据流替换随机生成器。您可能希望为各种源格式和将它们转换为目标表示形式的迷你 DSL。当您选择了手动输入时,这也非常有效,但确切指定输出结果却很痛苦,因为它又大又复杂。这就是数据驱动测试。
-
Quickcheck 和 Smallcheck 这样的非模糊测试框架在处理运行时异常时表现相当不错,但在处理像分段错误这样更严重的故障时则不然。这些框架的驱动程序应利用无状态性质来注意其运行程序何时莫名其妙地终止,并告知用户重现崩溃所需的最小调用方式 —— 经过这种修改,这些框架涵盖了当前以临时方式构建的模糊器。
如果我们不必致力于一种测试方法,并且可以在围墙的两侧重复利用工作以获得巨大的胜利,那将是非常好的。
会话类型、子类型和依赖类型:ezyang 的博客
来源:
blog.ezyang.com/2010/09/session-types-subtyping-and-dependent-types/
在我研究会话类型编码时,我注意到了一些有趣的事情:即会话类型在捕捉协议控制流程时,实际上正在实现某种强烈让人联想到依赖类型的东西。
任何合理的会话类型编码都需要能够表示选择:在 Simon Gay 的论文中,这是 T-Case
规则,在 Neubauer 和 Thiemann 的工作中,这是 ALT
运算符,在 Pucella 和 Tov 的实现中,这是 :+:
类型运算符,以及 offer
、sel1
和 sel2
函数。通常会指出,二进制交替方案在用户界面上较之名称为基础的交替方案要差,但后者实现起来更为困难。
这些论文的作者真正要求的是支持某种看起来像依赖类型的东西。当您尝试为现有协议编写会话类型编码时,这一点变得更加明显。考虑来自 Google 的 SPDY 的以下小片段:
一旦流创建,就可以用来发送任意数量的数据。通常这意味着将在流上发送一系列数据帧,直到设置了包含 FLAG_FIN 标志的帧为止。FLAG_FIN 可以在 SYN_STREAM、SYN_REPLY 或 DATA 帧上设置。一旦发送了 FLAG_FIN,流就被视为半关闭。
数据帧的格式为:
+----------------------------------+
|C| Stream-ID (31bits) |
+----------------------------------+
| Flags (8) | Length (24 bits) |
+----------------------------------+
| Data |
+----------------------------------+
而offer
是通过网络传输单个比特来实现的,在这里,控制流是否关闭的关键比特深藏在数据之中。因此,如果我甚至想考虑编写会话类型编码,我必须使用带有额外幻影类型的数据定义,而不是显而易见的类型:
data DataFrame fin = DataFrame StreamId FlagFin Data
我不得不将 FlagFin
从普通术语提升为适合于 fin
穴位的类型,这种做法明显具有依赖类型的味道。幸运的是,依赖类型的需求被事实上会话类型将立即在类型上进行案例分割所回避,考虑到它是真的情况和它是假的情况。我们在编译时不知道值实际上将是什么,但事实证明我们并不在乎!如果我们小心地只允许 fin
在 FlagFin
实际上为 True
时才能作为记录中的字段,我们甚至不需要将 FlagFin
作为记录中的一个字段。
当人们说你可以在不使用依赖类型的情况下玩弄类型技巧时,我相信他们在指的是这一点。将编译时已知的值推入类型是一个明显的例子(Peano 整数,有人?),但在这种情况下,我们通过处理所有可能的情况,将编译时未知的值推入类型!
啊呀,在 Haskell 中实际做这件事情相当麻烦。考虑一些现实世界中的代数数据类型,一个简化版本的 SPDY 协议,它只允许同时处理一条流:
data ControlFrame = InvalidControlFrame
| SynStream FlagFin FlagUnidirectional Priority NameValueBlock
| SynReply FlagFin NameValueBlock
| RstStream StatusCode
| Settings FlagSettingsClearPreviouslyPersistedSettings IdValuePairs
| NoOp
| Ping Word32
| Headers NameValueBlock
| WindowUpdate DeltaWindowSize
每个构造函数都需要转换为一个类型,FlagFin
也一样,但事实证明其他数据对会话类型不重要。因此,我们最终为每个构造函数编写了一个数据声明,而将它们有效地串联起来的好方法并不存在:
data RstStream
data SynStream fin uni = SynStream Priority NameValueBlock
data SynReply fin = SynReply NameValueBlock
...
我们在这里寻找的线索是子类型化,具体来说是更为奇特的和类型的子类型化(与产品类型的子类型化相对应,一般称为记录子类型化)。另一种思考方式是,我们的类型现在表示了可能出现在变量中的一组有限的可能项:随着程序的发展,越来越多的项可能出现在这个变量中,我们需要进行案例分割,以减少可能性,使其更易管理。
啊呀,我听说子类型化会大大增加推断的复杂性。哎,这是我考虑到的尽头。毫无疑问,肯定有一篇论文存在于某处,我应该读一读,以澄清这一点。你觉得呢?
设置 Cabal、FFI 和 c2hs:ezyang 的博客
来源:
blog.ezyang.com/2010/06/setting-up-cabal-the-ffi-and-c2hs/
这是 c2hs 六部分介绍的第二部分。今天,我们讨论的是首次编译这个该死的东西。
读者先决条件. 你应该知道如何为纯 Haskell 编写、配置和构建一个普通的 Cabal 文件。幸运的是,通过 cabal init,现在比以往任何时候都更容易。我将讨论如何为链接 C 文件设置 Cabal 文件,这对于任何类型的 FFI 编写都适用(事实证明,启用 c2hs 只是小菜一碟)。
启用 c2hs. 这是一个技巧性问题;Cabal 会自动检测扩展名为 chs
的文件,并对其使用适当的标志运行 c2hs
。然而,如果用户没有安装 c2hs
,这个操作可能会失败,因此你应该将以下行添加到你的 Cabal 文件中:
Build-tools: c2hs
现在,你应该能够编译一个以 chs
作为文件扩展名的空 Haskell 模块了。
(这里有一些用于添加 c2hs 预处理器支持的 Cabal 钩子代码,但完全不必要。)
查看生成的 hs 文件. 一旦 chs
文件被预处理,Cabal 就不会再查看它了。你不应该害怕查看预处理器的输出;在许多情况下,当你试图修复类型错误时,这些输出会更有启发性。一般来说,hs
文件会位于 dist/build
目录下,正如这条构建消息(由 GHC 而非 c2hs 生成)所示:
Building abcBridge-0.1...
[ 5 of 11] Compiling Data.ABC.Internal.VecPtr (
dist/build/Data/ABC/Internal/VecPtr.hs,
dist/build/Data/ABC/Internal/VecPtr.o )
你看到的代码会类似于这样:
-- GENERATED by C->Haskell Compiler, version 0.16.2 Crystal Seed, 24 Jan 2009 (Haskell)
-- Edit the ORIGNAL .chs file instead!
{-# LINE 1 "src/Data/ABC/Internal/VecPtr.chs" #-}{-# LANGUAGE ForeignFunctionInterface #-}
module Data.ABC.Internal.VecPtr where
LINE
pragma 会确保当由生成的 Haskell 代码引起类型错误时,你会得到指向原始 chs
文件的行号。这并不是绝对可靠的;有时错误会在 c2hs 声称错误所在的行号之前或之后出现。
导入和语言特性. c2hs 生成的 Haskell 代码需要一些语言特性和导入。你应该明确地在程序的顶部添加 ForeignFunctionInterface
语言 pragma;虽然可以通过 Cabal 文件启用它,但最好使你的 hs 文件尽可能独立。
在当前版本的 c2hs 中,模块导入稍微复杂一些。c2hs 有一个名为 C2HS 的遗留模块,执行导入、重新导出和额外的编组函数(只有在使用 fun
时才需要),默认情况下 c2hs 可能会生成。然而,它已经快被抛弃了,而 c2hs Cabal 包实际上并没有提供这个模块:你需要用 c2hs -l
将它复制到你的源目录中。这个模块依赖于 haskell98
。你不应该重新导出这个模块,因此它应该放在你的 Cabal 文件的 Other-modules
字段中。
现代的方法是显式地导入和定义自己的模块。需要导入的模块是Foreign
和Foreign.C
,并且有一小部分与 Haskell 将在你尝试使用该编码器与其一起使用时,Haskell 将会抱怨没有定义。将来的 C2HS 版本将进一步减少必要的函数。gtk2hs 采用这种方法(尽管他们还放弃了大部分 C2HS 的自动编码支持)。
加载库。 如果幸运的话,你的包管理器已经有了你想要创建绑定的库。在这种情况下,你只需将库的名称添加到 Cabal 文件中Library
部分的Extra-libraries
字段即可。例如,如果你想使用 readline,就将readline
添加到字段中,GHC 将会知道在/usr/include/readline
找到头文件,并在运行时动态链接/usr/lib/libreadline.so
。在某些情况下,库会安装在默认情况下不会搜索的标准位置(例如 Linux 系统上的 Oracle,以及 Windows 上的基本任何库);在这种情况下,你可以告诉 Cabal 这个“非标准标准”位置在哪里,使用Extra-lib-dirs
。
如果你的 C 库不是一个良好的模块(许多小众库就是这种情况),就需要采取一些额外步骤。以下是一些常见情况,以及如何处理它们的建议:
-
这个库很小且构建过程简单。 在这种情况下,可以将库的源代码与你的包捆绑在一起,并完全使用 Cabal 管理其编译。如果你的库没有
make install
,那么除了要求用户手动提供必要的链接器选项来连接这两个安装之外(这并不是一个用户友好的选项,特别是使得运行cabal install
变得复杂)。你应该只在少量源代码的情况下这样做,因为与通常的构建相比,GHC 指导的编译速度要慢得多。详见使用 Cabal 编译库和管理包含文件。 -
出于某些原因,我想捆绑这个库,但它的构建过程很复杂。 在这种情况下,可以设置 Cabal 来调用库的构建过程,然后使用生成的文件进行 Haskell 构建过程。这种方法存在许多缺点,包括 Cabal 文件和安装过程混乱,因此如果你能够选择第三个选项,我建议使用那个。详见使用挂钩编译库。你还应该阅读管理包含文件。
-
我不想捆绑这个库。 在这种情况下,你需要提供给最终用户下载、编译和安装外部库的指南。如果你作为包的作者,将该库打包为各种发行版的工作将会使用户的操作变得更加简单,尽管这是一个安装较少的库。如果用户不愿意在规范路径安装该库,他们将需要向
cabal
传递适当的选项。参见手动链接。
使用 Cabal 编译库. Cabal 有能力以非常简单的方式编译 C 代码:它从 Cabal 字段C-sources
中获取文件列表并按顺序编译它们。特别地,它不执行任何依赖跟踪,因此当你提供文件列表时,请确保它们的顺序正确!这使得这种机制只适用于少量的 C 代码,包括您可能自己编写以帮助绑定过程的 C 代码。在cbits
中放置c
文件,并在include
中放置h
文件是一种日益普遍的惯例。然后,你可以使用以下几行告诉 Cabal 这些目录的位置:
-- This ensures that Cabal places these files in the release tarball,
-- which is important if you plan to release
Extra-source-files: cbits, include
-- ...
Library foobar
-- ...
-- The C source files to compile, in that order
C-sources: cbits/foobar.c, cbits/foobaz.c
-- The location of the header files
Include-dirs: include
-- The header files to be included
Includes: foobar.h, foobaz.h
-- Header files to install
Install-includes: foobar.h, foobaz.h
关于“includes”字段的几句话:
-
当编译顺利进行时,
Includes
字段可能不会对用户造成显著影响。然而,明确指定它是个好习惯,因为 Cabal 会在编译之前检查这些包含文件是否存在并且可用,从而在出现问题时为用户提供更好的错误消息。用法. 指定任何标准头文件和包含在你的软件包中使用的任何捆绑头文件。 -
Install-includes
字段将导致 Cabal 在安装时将这些头文件放置在公共位置。这对于旧版本的 GHC 编译您的代码或者如果使用您的模块的模块需要执行 C 包含您的库或cbits
是必需的;通常情况下,安装您的头文件是一个很好的做法。用法. 指定您的软件包使用和导出的捆绑头文件。
使用钩子编译库. 如果有超过一打的 C 文件需要编译,你可能希望让传统的configure && make
流程为你处理。在这种情况下,使用 Cabal 的Setup.hs
中的小钩子设置可能是合适的,通过实验性钩子接口来调用编译过程。以下是一个简单的示例构建脚本:
import Distribution.Simple
import Distribution.Simple.Setup
import Distribution.Simple.Utils (rawSystemExit)
main = defaultMainWithHooks simpleUserHooks
{ preBuild = \a b -> makeLib a b >> preBuild simpleUserHooks a b }
makeLib :: Args -> BuildFlags -> IO ()
makeLib _ flags =
rawSystemExit (fromFlag $ buildVerbosity flags) "env"
["CFLAGS=-D_LIB", "make", "--directory=abc", "libabc.a"]
我们在preBuild
中添加了我们自己的makeLib
构建脚本(同时保留旧版本的simpleUserHooks
),并使用 Cabal 的实用函数rawSystemExit
来完成大部分工作。请注意,需要将--directory=abc
传递给make
;Cabal 在与cabal
文件相同的目录中运行,因此你可能需要调整你的工作目录到库目录。setCurrentDirectory
可能会派上用场。
你的构建过程可能会将生成的libfoo.a
文件放在不是dist/build
的某个地方。你可以使用Extra-lib-dirs
字段告诉 Cabal 查找那个目录。
上述步骤足以使你的软件的干净源码检查工作正常进行,但是为了确保用户能够安装cabal sdist
的结果,你需要再进一步。
首先,任何构建过程中需要的任何源文件都必须在Extra-source-files
中显式列出。Cabal 只支持一种有限的通配符匹配形式,文件名必须包含文件扩展名,所以这个列表可能会很长(我们建议你使用脚本生成它)。
其次,构建过程创建的静态/动态库可能不会放置在 GHC 编译时会查找的位置,导致此错误:
Linking dist/build/abc-test/abc-test ...
/usr/bin/ld: cannot find -labc
collect2: ld returned 1 exit status
我们可以在与 Cabal 在安装期间放置 Haskell 模块的静态库相同的位置放置我们的库,这需要另一个钩子:
import Distribution.Simple
import Distribution.Simple.Setup
import Distribution.Simple.Utils (rawSystemExit)
import Distribution.PackageDescription (PackageDescription(..))
import Distribution.Simple.LocalBuildInfo (
LocalBuildInfo(..), InstallDirs(..), absoluteInstallDirs)
main = defaultMainWithHooks simpleUserHooks
{ preConf = \a f -> makeAbcLib a f >> preConf simpleUserHooks a f
, copyHook = copyAbcLib
}
-- ...
copyAbcLib :: PackageDescription -> LocalBuildInfo -> UserHooks -> CopyFlags -> IO ()
copyAbcLib pkg_descr lbi _ flags = do
let libPref = libdir . absoluteInstallDirs pkg_descr lbi
. fromFlag . copyDest
$ flags
rawSystemExit (fromFlag $ copyVerbosity flags) "cp"
["abc/libabc.a", libPref]
libPref
右侧的咒语决定了 Cabal 将要安装库文件的位置,然后我们只需将我们的库复制到该位置。
(Nota bene. 如果你确信没有人会全局安装此库,那么只有在确保库不与同名的非二进制兼容库一起浮动时才真正使用此技巧是非常有趣的。)
管理包含。 任何需要位于包含路径中的非标准目录应添加到Include-dirs
。如果库中有很多这样的目录,请考虑另一种解决方案:在include
中为所有相关头文件创建符号链接,然后只需将该目录添加到Include-dirs
。
手动链接。 如果需要手动告诉 Cabal 相关头文件和库的位置,你可以在cabal configure
或cabal install
中使用--extra-include-dirs
和--extra-lib-dirs
标志。它们的功能与Include-dirs
和Extra-lib-dirs
完全相同。
共存的库和可执行部分。 对于测试,你可能会发现在你的 Cabal 文件中定义多个Executable
部分很方便,在这种情况下,你会注意到似乎需要将所有与 C 相关的 Cabal 字段复制到每个可执行部分中。好吧,在 Cabal 1.8.0.4 中,你现在可以将Build-depends
设置为指向你自己的包("self-reference");因此,你为每个可执行文件声明了一个Build-depends
,而与 C 相关的 Cabal 字段是不必要的。
你需要告诉 Cabal 可以使用此字段来使用这个特性:
Cabal-version: >= 1.8.0.4
附言。 感谢 Duncan Coutts 帮助澄清和建议改进本教程的各个部分。
下次再说。 FFI API 设计原则。
美国,再见!:ezyang 的博客
美国,再见!
明天我将乘坐飞机,从我家的三千英里外的地方,飞往一个名叫剑桥的小地方,英国。我将在剑桥大学度过 2010-2011 学年的留学生活(具体来说,我将在菲茨威廉学院)。尽管我知道巴尔的摩如今是一个时尚之地,如果你在附近,给我来个留言:我很乐意在克服时差后见个面。 😃
所以你想要向 GHC 添加一个新的并发原语…:ezyang 的博客
来源:
blog.ezyang.com/2014/01/so-you-want-to-add-a-new-concurrency-primitive-to-ghc/
GHC 的一个吸引人之处在于,即使你不想修补编译器本身,编译器也令人惊讶地易于修改。这种可修改性来自编译器插件,允许你编写自定义优化通道来操作 Core,以及外部原语,允许你嵌入低级别的 C-- 代码来操纵各种原语的低级别表示。这些钩子允许人们实现和分发那些否则会太不稳定或者过于投机以至于无法放入编译器本身的功能。
最近引起了一定兴趣的一个特定用例是并发原语。我们工程师喜欢开玩笑地说,为了性能的名义,我们愿意承担几乎无限复杂的层次:但是当涉及到并发原语时,这几乎肯定是真的,其中使用更多异步内存屏障和并发数据结构可以带来显著的性能提升(只需问问 Linux 内核开发人员)。看到这种情况,很容易想到,“嘿,我们也可以在 GHC 中实现这些东西,使用提供的编译器钩子!”但是这里有很多注意事项。
在 ghc-devs
列表上回答了几个与这个主题相关的问题后,注意到其他回答有些混乱,我觉得应该在一篇合适的博客文章中详细展开我的回答。我想回答以下问题:
-
在像 Haskell 这样的高级语言中有什么意义拥有内存模型?(如果你知道什么是内存模型,可以安全地跳过这一节。)
-
什么是(GHC)Haskell 的内存模型?
-
如何在 GHC Haskell 中实现(快速)内存屏障?
内存模型是语义
什么是内存模型?如果你问一个硬件人员,他们可能会告诉你:“内存模型是描述多处理器 CPU 如何与其内存交互的方式,例如一个处理器写入的内容在何种情况下可以被另一个处理器看到。”如果你问一个编译器人员,他们可能会告诉你:“内存模型说明我可以在修改共享变量的操作上进行什么样的编译器优化。”内存模型必须同时满足这两个目的(一个常见的误解是它只能满足其中一个)。为了明确起见,我们定义内存模型如下(改编自Adve-Boehm):
内存模型是共享变量的语义,即程序中读取操作被允许返回的值集合。
没错:内存模型定义了编程语言中最基本操作的行为。没有它,你无法真正说出你的程序应该做什么。
那么,为什么在一个如此注重语义的语言社区中,内存模型如此少被讨论?在没有并发性的情况下,内存模型是无关紧要的:显而易见的语义适用。在数据竞争不存在的情况下,可以相当简单地描述内存模型。例如,仅使用 MVars 进行线程间通信的 Haskell 程序可以完全使用相对简单的非确定性操作语义来描述其行为(见并发 Haskell 论文 (PS));软件事务内存提供了关于事务变量读取的原子性的高级保证。当程序包含数据竞争时,内存模型变得至关重要:当多个线程在没有任何同步的情况下写入和读取 IORefs 时,内存模型负责定义此程序的行为。在现代处理器上,这种行为可以非常复杂:我们称这些模型为松散的内存模型。复杂的同步原语通常会利用松散的内存模型来避免昂贵的同步操作,并提升额外的性能。
GHC Haskell 的内存(非)模型
有人可能会说 Haskell 的传统强调语义的重要性... 除了一些显著的盲点。内存模型就是其中之一。最初的Haskell98 规范没有包含任何并发规范。并发 Haskell 论文 (PS)描述了如何向语言添加并发性的语义,但该论文仅假定了 MVars 的存在,并未说明 MVars 应如何与 IORefs 交互。
在 2006 年成立的 haskell-prime 委员会上进行的第一次讨论之一是是否应该标准化并发 Haskell。在讨论中很快发现IORefs 需要一个内存模型(续在此处)。截至目前,尚未作出决定,即使 IORefs 是否应具有强内存模型或弱内存模型。
结果是,就标准化语言 Haskell 而言,这里的行为是完全未定义的。要真正能够说些什么,我们将不得不选择一个实现(GHC Haskell),并推断实现的哪些方面是指定的行为,而不是偶然发生的事情。值得注意的是,内存模型对你堆栈的所有层级都有影响(有一个普遍的误解是,可以在没有编译器协作的情况下使用内存屏障),因此为了进行此分析,我们需要查看 GHC 编译链的所有阶段。此外,我们将限制自己在单子读/写上,以避免必须处理惰性带来的麻烦。
简而言之,这是 GHC 的编译流水线:
在编译器流水线的最顶层是中间语言 Core 和 STG。这些语言将通过单子的使用保持顺序一致性,读取和写入的顺序由此固定,并在去糖化和优化传递中保留:对于优化器而言,实现读/写的基本操作是完全的黑盒子。事实上,在许多情况下,单子将过度顺序化!(值得注意的是,重写规则和 GHC 插件可以应用不保留单子强加顺序的优化。当然,这两种方法也可以用于完全改变程序的含义;在考虑内存模型时,这些规则仅仅有更高的正确性负担。)
流水线的下一步是将其翻译成 C--,一种高级汇编语言。在这里,对诸如readMutVar#
和writeMutVar#
之类的基本操作的调用被翻译为 C-- 中的实际内存读取和写入。重要的是,现在消除了在 Core 和 STG 中存在的单子结构,GHC 现在可以应用重新排列读取和写入的优化。实际发生的情况高度依赖于生成的 C-- 以及 GHC 应用的优化,而 C-- 没有内存模型,所以我们甚至无法依赖于它。
话虽如此,我们可以从研究 GHC 实施的优化传递中推断出一些事实:
-
GHC 保留重新排序存储的权利:
WriteBarrier
机器操作(注意:Haskell 中不可用!)被定义为防止将来的存储发生在前面的存储之前。实际上,GHC 实现的任何 C-- 优化都没有重新排序存储,因此,如果你有一个处理流水线后续阶段的方案,你可以危险地假设在这个阶段不会重新排序存储。 -
GHC 保留重新排序加载的权利,并且广泛地这样做。 我们执行的最重要的优化之一是下沉传递,其中对本地变量的赋值尽可能地浮动到其使用位置。 在撰写本文时,尚不支持读取屏障,这将阻止此浮动发生。
有一些情况下我们偶然避免读取重新排序(可能会危险地假设):
-
读取似乎不会在foreign primops(使用
foreign prim
关键字定义的 primops)之间重新排序。 这是因为 foreign primops 被实现为跳转到另一个过程(primop),目前没有跨过程的 C--优化。 -
堆读取似乎不会在堆写入之间重新排序。 这是因为我们目前不执行任何别名分析,并且保守地假设写入会破坏读取。 (这是一个危险的假设,因为您可以轻松地想象从前端获取一些别名信息。)
最后,C--被翻译为汇编(通过 NCG—N 代表本机)或 LLVM。 在翻译过程中,我们将 write-barrier mach-op 转换为适当的汇编指令(在 x86 上为无操作)或 LLVM 内在函数(顺序一致性屏障);此时,行为取决于处理器和/或 LLVM 定义的内存模型。
值得总结这里的讨论,将其与Data.IORef中的文档进行比较,该文档对 IORef 内存模型进行了非正式描述:
在并发程序中,IORef 操作可能对另一个线程呈现出无序状态,这取决于底层处理器架构的内存模型...实现必须确保内存操作的重新排序不会导致类型正确的代码出错。 特别是,在检查从 IORef 读取的值时,创建该值的内存写入必须从当前线程的角度发生。
换句话说,“我们不保证重新排序,除非您不会发生任何类型安全违规。” 这种行为很容易发生,因为重新排序存储或加载。 然而,类型安全保证是一个有趣的保证:最后一句话指出,IORef 不允许指向未初始化的内存;也就是说,我们不允许将写入 IORef 与初始化值的写入重新排序。 这在 x86 上很容易实现,因为 C--不会重新排序存储;我对我们在 ARM 的新代码生成器上是否做对了事情持怀疑态度(但是还没有人提交错误!)
这一切是什么意思?
这次深入 GHC 的内部细节很好,但对于您,即准备实现时髦新并发数据结构的人,这意味着什么? 有三个主要观点:
-
如果没有内联外部原语,您将无法说服 GHC 发出您寻找的快速路径汇编代码。正如我们之前提到的,外部原语当前总是编译为跳转到外部的跳转,如果分支预测器无法理解控制流,这将导致一些额外的成本。另一方面,任何外部原语调用都会无意中强制执行您寻找的编译器侧写/读障碍。
-
使用内联外部原语,您仍然需要修改 GHC,以确保优化过程尊重您时髦的新内存障碍。例如,John Lato 的 对加载-加载障碍的渴望(启动此帖子的电子邮件)将通过外部的外部原语而无需编译器更改实现,但不会通过假设的内联外部原语实现。
-
这些东西真的很微妙;请参阅位置论文 Relaxed memory models must be rigorous,该论文认为内存模型的非正式描述(比如本博文!)太过模糊,无法提供有用的信息:如果您希望正确无误,必须将其形式化!这表明一个立即的第一步:给 C-- 一个内存模型。(这应该是 C 和 C++ 最近收到的内存模型的一项适度创新。)
对于我们其他人来说,我们将改用 STM,进入一个缓慢但组合和无死锁的涅槃境界。
所以你想在 IMAP 上进行黑客攻击... : ezyang’s blog
所以你想在 IMAP 上进行黑客攻击...
(Last IMAP themed post for a while, I promise!)
首先,你的信息是错误的:你实际上不想要在 IMAP 上进行黑客攻击。但是假设,出于某种 masochistic 的原因,你需要深入研究你的邮件同步程序并修复 bug 或添加一些功能。在开始旅程之前,有几件有用的事情需要知道...
-
阅读你的 RFC。RFC 3501 是实际规范,而 RFC 2683 给出了许多有助于解决实践中存在的 IMAP 服务器棘手问题的建议。你还应该了解 UIDPLUS 扩展,RFC 4315,它被广泛支持,极大地简化了客户端实现者的生活。
-
IMAP 幸运地是一个基于文本的协议,因此你可以在命令行上进行实验。一个很好的工具是
imtest
,它有各种花哨的功能,如 SASL 认证。(不要忘记用rlwrap
包装它!)确保在你的命令前加上标识符(UID
是一个有效的标识符,所以输入UID FETCH ...
不会做你想要的事情。) -
通常使用 UID 而不是序列号是个更好的主意,因为它们更稳定,但要小心:根据规范,以
UID
为前缀的命令永远不会失败,因此你需要检查响应中的未标记数据,以查看是否实际发生了任何事情。(如果你有一个糟糕的 IMAP 库,它可能不会在请求之间清除未标记的数据,因此要小心陈旧的数据!)哦,还要查一下UIDVALIDITY
。 -
有很多软件与 IMAP 接口,多年来为了应对 IMAP 服务器的 bug 累积了许多特例。值得一探究竟,以便了解需要处理的问题类型。
一些关于文献综述的想法:ezyang’s blog
来源:
blog.ezyang.com/2012/05/some-thoughts-about-literature-review/
一些关于文献综述的想法
在我写本科论文时,我必须撰写一篇先前工作部分,最终成为我研究主题特定子领域的小调查。在这个过程中,一只小鸟告诉了我一些事情...
-
如果可能的话,询问对该主题略知一二的人给你一个简要介绍:人们头脑中有许多知识从未被记录下来。但同时也要意识到,他们可能会有盲点。
-
最好在深入研究主题后再进行文献综述,而不是过早开始。有人告诉我,如果你太早读文献,你会被影响,停止产生新思维。但我也认为,如果你自己先思考了主题,你对文献的理解会更好一些。另外,很容易认为所有事情之前都已经做过:这并不是真的!(但如果你这么想,会不必要地感到沮丧。)
-
不要随意将论文添加到你的数据库中。你应该对它有所打算:它是一篇你必须引用的重要论文吗?它直接解决了你正在处理的问题吗?它写得特别好吗?它是你稍后可能会仔细阅读的内容吗?不要害怕扔掉论文;如果它真的很重要,你以后会再次遇到它。
-
每个研究人员都是历史学家。当你看一篇论文时,你不仅仅看它里面写了什么,还要看它的社会背景。这就是为什么“先前工作”对学术界如此重要的原因。如果你不了解一篇论文的背景,你很可能无法理解这篇论文。
-
研究人员并不一定会互相交流。注意他们引用了谁;这反映了他们所在的学术社区的一些信息。
-
研究人员乐意给你发送他们写过的论文副本(所以不要害怕你的大学没有订阅的付费墙)。他们甚至可能自愿提供额外信息,这可能会派上用场。
-
要有条不紊。你正在进行一次搜索,这意味着要仔细记录你浏览过的论文,以及从中获取的信息,并跟踪需要后续研究的其他研究方向。这就像追逐兔子进洞一样,但如果你有明确的搜索标准,最终你会有所收获。稍后可以修剪不感兴趣的论文;关键在于避免重复工作。
-
批判性地阅读论文。并非所有发表的东西都是好的;这就是研究的意义所在!
你在查阅文献时最喜欢的格言是什么?
有人在互联网上错了:ezyang’s blog
来源:
blog.ezyang.com/2011/03/someone-is-wrong-on-the-internet/
互联网的扭曲激励结构
假设你有一个随机问题™,你希望得到答案。例如,“弗朗西斯·高尔顿爵士有孩子吗?”这是你会在谷歌上搜索的类型问题。
Answers.com 并不是一个特别好的信息来源,但至少可以看看是否能找到一些额外的信息。所以假设你深挖一点,发现弗朗西斯·高尔顿只结过一次婚,而且这次婚姻是不孕的。所以除非某位历史学家关于高尔顿患梅毒(Kevles 12)并导致他在叙利亚某地与一些女士生了四个方便被命名为“弗朗西斯、哈里特、玛蒂尔达和马克”的孩子的记述,Answers.com 是错误的。
这并不奇怪。但这不是本文讨论的内容。
这篇文章讨论的是当你在互联网上遇到公然的、彻底的错误信息时应该怎么办:也就是说,有人在互联网上错了。有几个显而易见的答案:
-
传播正确的信息,
-
修复它,或者
-
忽略它。
传播正确信息实际上只是“争论”的一个美化术语。每个人都知道,在互联网上争论可能是一个最不体面的职业。然而,这是一个纠正错误信息的重要机制。正如德杰拉西曾经说过(我在此引用),只有 masochist 才会与基督教原教旨主义者就进化论进行辩论,然而,如果没有这样的 masochist 愿意参与这场辩论,我们会更糟糕。对于不那么有争议的问题,“驱散迷思”文章的模式是向愿意接受的观众呈现有趣的、纠正的信息的流行机制。
我认为这种方法的主要困难可以用三个词来概括:成本、效果和影响力。
-
成本。撰写有效的反驳需要时间。辩论是一种耗时的活动。如果这不是你特别关心的事情,更新自己的信念结构并不费力地避免试图说服其他人对你更新的信念感兴趣,这是更有意义的。
-
效果. “在互联网上与别人争论就像和猪争论一样:它会让你沮丧,而且还会激怒那只猪。”但社会心理学研究描绘了一个更加令人不安的图景:即使你成功说服某人你是对的,他们旧的(被证伪的)信念仍然会影响他们的决策过程。更糟糕的是,真实的信息可能会让人更加坚信他们错误的信念。还值得吗?
-
传播力. 即使你写了一篇令人惊叹的论据,说服了所有读者,仍然存在传播问题。互联网使得我们极易挑选我们想要阅读的内容,从志同道合的人那里聚合新闻,正如伊桑·扎克曼所称的同质性现象。恐怕我们会陷入自己舒适信息循环的困境,使我们变得更加愚蠢。
你可以直接修正它的观念似乎是比较近期的,随着维基和社交网络的出现而来。也许无限制地编辑错误信息源是一个新的事物,但是去到源头,给教科书的编辑写封信,这个能力早已存在。这里有一种光荣传统,包括像克努特的奖励支票这样的东西,为那些找到错误的人提供名誉和微薄的金钱。当然你可能需要和一个人争论,但这就是你需要的一切,之后,原始的失误就得到了纠正。
当你去到信息源头时,你部分解决了传播问题:新读者可以立即获取到修正后的文本(尽管这对现有的纸质复印本没有太大帮助)。但成本和效率仍然存在;甚至可能加剧了。你必须说服一个人你是对的;如果做不到,你就不得不回到单独的反驳上。有时内容被放弃,原作者没有更新的意图。
无限制的编辑访问也催生了编辑战争。在这种情况下,你必须积极捍卫你所做的修改,不幸的是,胜利通常属于那些拥有更多时间和资源的人。编辑战争就是一场消耗战。
附注. 在开源软件世界中有一些相似之处。修复它类似于提交错误报告或补丁;说服维护者你是对的需求转化为写出一个好的错误报告。如果一个项目被抛弃,你就没戏了;即使它没有被抛弃,你也需要让维护者关注你的具体问题。分叉就是传播纠正信息,虽然社交编码网站中混杂着不受限制的编辑元素,这使得找到相关副本变得容易。
或者你可以选择不去理会,完全忽略它。也许向你的朋友发泄一下这个问题,他们对这件事并不特别在意,在你的大脑中留下印记,然后让生活继续。事实上,这恰恰是我看到大多数同事采取的态度。他们不为公众写作。他们非常乐意与朋友分享他们的广博知识和见解,交流通常是有效的,成本也不高(他们更喜欢非正式的媒介,如面对面的对话和即时通讯)。
实际上,我敢说,你可能称之为文人和专家的世界中,只有一小部分人大量利用这种广泛信息传播,像维基百科、StackOverflow 和 Quora 这样的网站试图利用。激励结构根本不存在!对于学术界来说,与公众的交流次于与同行和资助者的交流。对于企业家来说,与顾客的交流才是受重视的,而不是我们在这里看到的这种交流方式。对于软件工程师来说,与上游的沟通有利于让你停止维护你的错误修复。想象一下那些尚未开发的专业知识有多少,也许这就是 Quora 这类网站的目标。
要告诉他们不要这样做显然是愚蠢的。毕竟,从他们的角度来看,这是最理性的时间利用方式。我们可以试图通过社会激励和成瘾触发来诱使个体流露出这些信息,但总体而言,我认为这些个体不会上钩。他们太聪明了。
最好希望你在他们的一时冲动中抓住他们。
音乐来源:ezyang 的博客
音乐来源
我 喜欢 听音乐,尤其是那些我从未听过的新奇作品。不幸的是,作为一个有点节俭的花钱者,我个人的音乐收藏增长得非常缓慢;也许对我自己的口味来说有点太慢了。当我需要 新 音乐时,我会去哪里?
-
MixApp 是一个协作音乐收听应用程序。在最糟糕的情况下,它只是你当前音乐库的一个扩展;任何是你的朋友并在线的人,你都可以搜索音乐并为自己排队。然而,MixApp 的意外之处在于当你进入一个你不认识的人和你不认识的音乐的房间时,但是,声音很好,突然间你被带上了声音冒险之旅,穿越你从未听说过的艺术家和你刚刚发现的音乐类型,嘭:你刚刚被 MixApp 化了。更新: MixApp 已经停止运营(创始人们开始建造 Meteor),尽管像 turntable.fm 这样的替代品正不断涌现。
-
Pandora 和 last.fm 都是获取适合流派单曲流的可靠方法,一个接一个地播放。尽管如此,它们的偶然性水平不及 MixApp 那么好,所以我不常求助于它们。
-
没有什么能够超越一个优秀的电台主持人。像大卫·加兰和约翰·舍费尔这样的人拥有如此丰富和深刻的音乐知识板块,他们每晚都有机会在公共广播中与听众分享这一技艺。当 WQXR 终于成功将高质量的网络流重新上线时,我感到非常高兴。
-
有一天晚上,我在 MixApp 上随意切换音乐,被 Kleptone 最新专辑Uptime/Downtime吸引住了。我对混搭艺术家并无偏见:整个音乐传统建立在对早期作品的借鉴、窃取和再创作之上,而在许多情况下,一个熟练的混搭艺术家可以改进它所基于的“流行音乐”材料。或者有时候,源材料本身就非常棒,应该独立听取:我最近最有趣的音乐冒险之一是按照Uptime/Downtime 的样本列表逐个聆听每一个源作品。
-
管弦乐队、管风琴合奏、小型合奏或者任何类型的合奏,排练时间长达数月,使人对特定音乐作品有了深入了解的机会。如果没有这种对音乐作品的深入探索,我可能永远无法完全欣赏到像《为斯托科夫斯基的钟声》或者佩尔希切蒂的《舞会面具》这样的当代作品。
我应该认为自己非常幸运,生活在一个新音乐随时可得的时代。你是如何寻找新鲜有趣的音乐的?
空间泄漏动物园:ezyang 的博客
空间泄漏动物园
感谢所有向我们提供了空间泄漏样本的人!我们的专家已经检查并分类了所有的泄漏,我们很高兴向公众开放空间泄漏动物园的大门!
这里存在几种不同类型的空间泄漏,但它们非常不同,访客最好不要混淆它们(如果在实际应用中遇到,处理它们的方法各不相同,使用错误的技术可能会加剧情况)。
-
内存泄漏是指程序无法将内存释放回操作系统。这是一种罕见的情况,因为现代垃圾收集基本上已经消除了这种情况。在本系列中我们不会看到任何例子,尽管 Haskell 程序在使用非括号化的低级 FFI 分配函数时可能会表现出这种类型的泄漏。
-
强引用泄漏是指程序保留了一个实际上永远不会再被使用的内存引用。纯度和惰性的结合使得这类泄漏在习惯用法的 Haskell 程序中并不常见。纯度通过不鼓励使用可变引用来避免这些泄漏,如果在适当时未清除,可变引用可能会泄漏内存。惰性通过使得在只需要部分数据结构时,无意中生成过多数据结构变得困难来避免这些泄漏:我们从一开始就使用更少的内存。当然,在 Haskell 中使用可变引用或严格性可能会重新引入这些错误(有时可以通过弱引用修复前者——这就是“强引用”泄漏的名称),而存活变量泄漏(后文有描述)是一种让对闭包不熟悉的人感到惊讶的强引用泄漏类型。
-
thunk 泄漏是指程序在内存中建立了大量未评估的 thunk,这些 thunk 本质上占据了大量空间。当堆剖析显示大量
THUNK
对象,或者在评估这些 thunk 链时导致栈溢出时,可以观察到这种情况。这些泄漏依赖于惰性评估,因此在命令式世界中相对罕见。通过引入适当的严格性,可以修复这些问题。 -
活变量泄漏是指某个闭包(无论是 thunk 还是 lambda)包含对程序员预期已经释放的内存的引用。它们的产生是因为 thunk 和函数中的内存引用往往是隐式的(通过活变量),而不是显式的(如数据记录的情况)。在函数结果显著较小的情况下,引入严格性可以修复这些泄漏。然而,这些不像 thunk 泄漏那么容易修复,因为您必须确保所有的引用都已丢弃。此外,评估内存大块的存在可能并不一定表示有活变量泄漏;相反,这可能意味着流处理失败。见下文。
-
流泄漏是指程序应该只需少量输入来生成少量输出,因此只使用少量内存,但事实并非如此。相反,大量输入被强制并保留在内存中。这些泄漏依赖于惰性和中间数据结构,但与 thunk 泄漏不同,引入严格性可能会加剧情况。您可以通过复制工作并仔细跟踪数据依赖关系来修复它们。
-
堆栈溢出是指程序积累了许多需要在当前执行之后执行的挂起操作。当您的程序耗尽堆栈空间时,可以观察到这种情况。严格来说,这不是空间泄漏,而是由于修复不当的 thunk 泄漏或流泄漏可能导致堆栈溢出,因此我们在此包括它。 (我们还强调这与 thunk 泄漏不同,尽管有时它们看起来相同。)这些泄漏依赖于递归。您可以通过将递归代码转换为迭代风格(可以进行尾调用优化)或使用更好的数据结构来修复它们。通常也会打开优化以帮助解决问题。
-
选择器泄漏是thunk 泄漏的一个子类,当一个 thunk 仅使用记录的一个子集时,但由于尚未评估,导致整个记录被保留。这些泄漏大多已被 GHC 的选择器 thunks的处理杀死,但它们有时也由于优化而偶尔显示。见下文。
-
优化诱导泄漏是这里任何泄漏的伪装版本,源代码声称没有泄漏,但编译器的优化引入了空间泄漏。这些非常难以识别;我们不会将它们放在宠物园中!(您可以通过向 GHC Trac 提交 bug 来修复它们。)
-
线程泄漏是指太多的 Haskell 线程还未终止。您可以通过堆分析的 TSO(线程状态对象)来识别这一点:TSO 代表线程状态对象。这些很有趣,因为线程可能不终止的原因有各种各样。
在接下来的文章中,我们将画一些图片,并且给出每种泄漏的例子。作为练习,我邀请感兴趣的读者对上次我们看到的泄漏进行分类。
更新. 我已经将“thunk 泄漏”与我现在称之为“活变量泄漏”分开,并重新澄清了一些其他要点,特别是关于强引用。我将在后续文章中详细展开我认为它们之间关键概念差异的讨论。
Spring 2010: A Random Walk : ezyang’s blog
Spring 2010: A Random Walk
在 2010 年春季学期的前夕,我决定在我的笔记本电脑上运行这个小实验:在过去的六个月内,我修改了哪些文件?
find . \( -path '*/.*' \) -prune -o -mtime -180 -print
结果是修改了超过一百五十万个文件。以下是(稍微)删节的版本:
-
LaTeX 文件,用于"Adventures in Three Monads",这篇文章发表在 Monad Reader 上。还有我在 Advanced Typeclasses 课上的黑板图表,我最终没有能够使用为 Reader 准备的材料。
-
valk.txt
,其中包含我对 Valkyrie Nethack 角色的笔记。我在 3 月{24,25}日首次升级。 -
作为本科研究项目的一部分,一个 Eclipse Java 项目,用作我的HAMT 实验的跳板。
-
htmlpurifier-web
和htmlpurifier
,感谢我在过去一个月内推出的HTML Purifier 4.1版本。这也意味着我为我的超级神奇的 PHP 多版本农场编译了新版本的 PHP 5.2.11, 5.2.12, 5.2.13, 5.3.1 和 5.3.2。自言自语,下次记得从自动备份中排除构建目录,kthxbai。 -
一个
qemu
的检出,我试图在他们的 DHCP 代码中修复同一 MAC 地址请求两个不同 IP 地址的问题,但放弃了,并为我们用于演示实时进程迁移的虚拟机分配了静态地址。嗯... 6.828 final project。 -
hackage-server
和holumbus
的检出,源自于让 Holombus 和 Hackage 合作,实现所有 Haskell 函数最新索引的未竞成功梦想。听说 Holumbus 团队一直在做出改变,以便 Hayoo 能够增量更新其索引。 -
更新以整理
extort
,这是一个用 Haskell 编写的会费追踪应用程序,因为刺客公会的领导最近发生了变化。在换届选举期间,有一位候选人的建议问题是“你懂 Haskell 吗?”我们将看看这个程序能坚持多久... -
一个
abc
源目录,在那里我展示了我的 C 源码技能,并搜索了如何使用该库的信息。我可能会在 Galois 实习期间与它密切合作。有趣的是,这几乎与为 6.005 编写的 SAT 求解器以及我在计算复杂性课程 6.045 中研究 SAT 的时间重叠。 -
一个
mit-scheme
的检出,用于分析他们的红黑树实现,以弄清楚它是否可以轻松持久化(答案是否定的,并且我不得不根据 Okasaki 的笔记编写了自己的实现),以及弄清楚为什么--batch-mode
没有按照它所说的去做。 -
一个
log4j
源码树,我在我的软件工程 6.005 项目中使用过两次。它大多数时候使用起来很顺利,如果你在 Java 中开发软件,我强烈推荐它。 -
有很多用于
wizard
的测试目录(注意备份这些目录也是个坏主意!)。有一天,我将释放这个软件到世界上,但目前它在 MIT 内部的使用正在增长。
真正的精简版本:
-
半年的语言:Java、Haskell 和 Scheme
-
最大的目录:我没有严格计数,但
linux
、wizard
和hackage
都相当大。 -
最佳文件名:
./Dev/exploits/jessica_biel_naked_in_my_bed.c
经历了漫长而随机的学习之旅,涉及了许多学科、软件和自我研究。在一个月后真正专注于某个领域有一些权衡:一方面,一个月的时间确实足够深入学习任何领域(我对我的博客文章也有同样的感觉;它们是做一些小实验和过渡的借口,但没什么大作为),另一方面,这意味着我继续看到计算机科学的许多具体子领域。随着夏天即将来临,我可能会找到另一个有雄心的项目来利用我的空闲时间(或者给我现有的一些项目一些它们需要的关注)。
春季阅读:2011 年版:ezyang’s 博客
春季阅读:2011 年版
书籍很贵,但通过高等教育的力量(也很贵,但不同),大量的书籍可供热心的计算机科学家使用。这是我春季假期借阅期间的阅读清单(其中许多是在#haskell
上推荐的):
-
计算机编程的概念、技术和模型 by Peter Van Roy and Seif Haridi。这是一本非常独创性的书,可能是清单中比较容易阅读的一本。
-
类型与程序语言 by Benjamin Pierce。我已经研究了一段时间了;这次休息期间,我专注于保留、进展和安全的证明策略,并将其用作补充自学课程的一部分,下一本书总结如下。
-
柯里-霍华德同构的讲座 by M.H. Sørensen and P. Urzycyzn。非常好,我大致浏览了前三章,并且正在做第二章的练习。我倾向于在柯里-霍华德同构方面发表愚蠢的错误断言(或者不是吗?),所以我期待更坚实地打下对这种对应关系的理解基础。关于直觉主义逻辑的部分已经非常启发人了。
-
类型论与函数编程 by Simon Thompson。还没看过,但与前两本书的一般课程相符。
-
纯函数数据结构 by Chris Okasaki。这也是我研究了一段时间的书。正在努力在脑中压缩所有信息。
-
计算机科学家的基本范畴论 by Benjamin Pierce。我有两本范畴论书;我随便买了这一本。还没有看。
-
函数算法设计的珍珠 by Richard Bird。类似于一本难题集。我想我会喜欢通读它们并解出其中的微妙之处。这次可能不会进入信息压缩阶段。
-
范畴论 by Steve Awodey。我正在做这本教材的练习,希望能过第一章。
Static Analysis for everyday (not-PhD) man : ezyang’s blog
Bjarne Stroustrup 曾自豪地说:“C++是一种支持面向对象和其他有用编程风格的多范式编程语言。如果你在寻找一种强制你只能用一种方式做事情的语言,那么 C++不是。” 但正如 Taras Glek 讽刺地指出的那样,大多数用于 C++的静态分析和编码标准主要是为了确保开发人员不使用其他范式。
星期二,Taras Glek在Mozilla 进行大规模静态分析上做了演讲。你可以在Galois 的视频流上观看视频。讲座的主题很简单直接:Mozilla 如何利用静态分析来管理其数百万行的 C++和 JavaScript 代码?但背后还有一个潜在的主题:静态分析不仅仅是形式方法专家和博士生的专利;任何人都可以并且应该利用静态分析带来的能力。
由于 Mozilla 是一个 C++的工作室,这次讲座集中讨论了构建在 C++语言之上的工具。然而,Taras 讨论的静态分析的四个部分是广泛适用于你可能进行静态分析的任何语言:
-
解析. 如何将文本文件转换为源代码的抽象表示(AST)?
-
语义 grep. 如何向 AST 询问信息?
-
分析. 如何限制有效的 AST?
-
重构. 如何改变 AST 并将其写回?
解析. 解析 C++很困难。从历史上看,这是因为它继承了许多来自 C 的语法;从技术上讲,这是因为它是一个极其模糊的语法,需要语义知识来消除歧义。Taras 使用Elsa(在 Taras 修复了一堆 bug 之后,可以解析所有 Mozilla 的代码),并急切地期待Clang的稳定(因为它还不能完全解析所有 Mozilla 的代码)。当然,GCC 4.5 的插件接口的添加意味着你可以利用 GCC 的解析能力,并且许多后期工具都是基于此构建的。
语义 grep. grep
已经老掉牙了!如果你幸运的话,你的代码库维护者会遵循使代码“更易搜索”的古怪规则,但否则你会用一个标识符来 grep,却得到一页页的结果,却错过了你要找的。如果你有源代码的内存表示,你可以智能地询问信息。进一步来说,这意味着更好的代码浏览,比如DXR。
请考虑以下的源代码视图。这是您从源代码浏览器期望看到的:右边是源代码,左边是声明。
让我们从左边选一个标识符,比如CopyTo
。让我们先浏览源代码,看看能否找到它。
哎呀!那真是一个很短的课程,但找不到它在哪里。好吧,让我们试着点击一下。
啊哈!它在宏定义中。这个工具可能比未经培训的眼睛更聪明。
分析. 对我来说,这真正是演讲的核心。Mozilla 有一个非常复杂的构建过程;通过使用Dehydra和Treehydra与 GCC 进行交互。Dehydra 项目的想法是利用 GCC 提供给插件的内部结构,并将其转换为类似 JSON 的结构(类似 JSON,因为 JSON 是非循环的,但这些结构是循环的),Dehydra 脚本(用JavaScript编写)可以在其上运行。这些脚本可以生成错误和警告,看起来就像 GCC 的构建错误和警告一样。Treehydra 是 Dehydra 的高级版本,为分析脚本编写者提供了更多灵活性。
那么,Dehydra/Treehydra 有什么有趣之处呢?
-
JavaScript. GCC 的插件接口本来只支持 C 代码,这可能让没有静态分析经验的开发人员望而却步。将这些结构转换为 JavaScript 意味着您可以使用高级语言进行操作,也能让您告诉完全没有静态分析经验的初级开发人员:“这就像在一个 Web 应用程序上进行黑客攻击一样。” 这意味着您可以直接打印出类似 JSON 的结构,并查看所需数据的结果;这意味着当您的代码崩溃时,您会得到漂亮的回溯信息。就像 Firefox 的插件接口一样,Dehydra 将 GCC 扩展带给了大众。
-
语言的胶带. 我在他的帖子开头批评了 Stroustrup,这就是原因。我们可以为类(带有属性
__attribute__((user("NS_final")))
,在宏NS_FINAL_CLASS
中包装)和其他限制,像final
这样的额外语言特性附加功能,纯 C++不提供这些。 -
需要时有力量. Dehydra 是一个简化的接口,适合没有静态分析或编译器背景的人;Treehydra 则更为强大,面向具有这些背景的人,可以让您执行诸如控制流分析之类的操作。
所有这些都透明地集成到构建系统中,因此开发人员无需摸索外部工具来获取这些分析结果。
重构。 或许是其中最雄心勃勃的一个,Taras 讨论了超越像 Java IDEs(比如 Eclipse)提供的简单提取方法的重构,使用Pork。这种重构,比如“重写 Mozilla 所有代码以使用垃圾回收而不是引用计数”。当你拥有像 Mozilla 这样活跃的代码库时,你没有豪华的机会去做“停止所有开发并开始重构...长达六年”的风格重构。分支也会带来类似的问题;一旦主要重构在一个分支上落地,要保持该分支与其他分支同步更新是困难的,最终一个分支会淘汰另一个分支。
关键在于自动化重构工具,因为它让你把重构当作“只是另一个补丁”来对待,并持续从主干重建分支,应用你的自定义补丁并运行重构工具生成一个多兆字节的补丁来应用在堆栈中。
重构 C++很难,因为开发者不仅仅写 C++代码;他们写的是 C++和 CPP(C 预处理器)的结合体。重构工具在写回时需要能够重建 CPP 宏,而不像 ML 等语言那样仅仅进行漂亮的 AST 打印。技术包括尽可能少的漂亮打印,以及强制解析器给出所有预处理器修改的日志。
开源软件。 Taras 留给我们一些关于开源协作的话语,至少SIPB群体应该深知。不要把你依赖的工具当作黑盒子:它们是开源的!如果你在 GCC 中发现了一个 bug,不要仅仅绕过它,查看源代码,编写补丁并提交到上游。这是修复 bug 的最佳方式,而且你为后续提交的 bug 赢得了即时的可信度。在 Web 应用到浏览器、浏览器到编译器、编译器到操作系统的层级中,开源生态系统的优势在于你可以一路阅读源代码。利用源代码,卢克。
拘束编程:ezyang 的博客
制约的重要性是众所周知的,对于那些从事创意事业的人来说尤为如此。告诉某人,“你可以做任何你想做的事情:真的是任何事情”,他们会茫然不知所措,被无限的可能性所束缚。艺术家们欢迎限制。作家们喜欢十四行诗的限制,因为它赋予了形式,并为开始提供了一个起点;角色扮演团体喜欢战役设置的限制,因为它强加规则,并设定了故事讲述的背景;爵士音乐家喜欢即兴演奏中和弦的限制,因为它将独奏者锚定到源曲,并为旋律提供了创意。
然而,许多程序员不喜欢类型系统的限制。“静态类型系统不允许我做我想做的事情。”“我需要写四个类来完成 Python 中两行代码的工作!”“什么?我不能这样做?为什么?”对于他们来说,这就像一种紧箍咒。当限制把你束缚住时,任何人怎么可能完成任何事情呢?
我持不同意见。接受这种限制。它允许你做的事情... 是令人惊讶的。
绞索曾被历史上用作防止危险个体伤害自己和他人的工具。程序员们并非精神病院的病人,尽管乍一看似乎我们一直在尝试减少我们伤害自己的方式。但这些变化通常带来了好处,许多人都迫不及待地放弃了指针和手动内存管理,以换取更高的表现力。
然而,静态类型对许多人来说仍然是一个痛点,Haskell 由于其类型系统而异常受限。Haskell 类型系统的狂热用户可能会惊叹道,“在我让它类型检查通过后,它就奇迹般地运行了!”当然,这种说法实际上并不成立;有一定复杂性的算法类别意味着类型系统不会发现您使用了错误的魔法数字来初始化哈希函数。
但并非所有代码都是如此。很多代码只是乏味的。它是生成你的网站的代码,或记录你的错误的代码;它是作为构建基础设施粘合剂的代码,或将文件中的数据转换为内存表示再存入数据库的代码。它是基础性的代码;它是让你简洁表达简单想法的代码。当你审视这些代码的开发时,所犯的错误非常简单,纯粹是精神上的小错别字,一旦显现出来,追踪和修复只需要十五秒钟,但如果累计到运行测试套件的时间或者,我敢说,手动测试的时间,很快就能变成分钟。一个快速的静态类型检查器可以极大减轻你的痛苦,无论是 Haskell 编译器还是pylint -e
。不同之处在于pylint -e
是可选的;没有任何保证每个 Python 项目都能与其良好合作,而且它经常出错。Haskell 编译器则不会出错。
这是更普遍现象的具体体现:类型减少事物出错的方式。这同样适用于复杂的代码;(a -> r) -> r
也许不能阐明延续的含义,但它确实对如何实现它们施加了很多限制。这使得我们能够在没有理解其含义的情况下查看类型,并机械地推导出你正在寻找的解决方案的一半。
这正是类型如何增强表现力的方式:对于人们来说,理解密集和高度抽象的代码是非常困难的。类型防止我们深陷细节并使处理更强大的抽象形式变得可行。你不会在 Python 中依赖此功能(不要在 Python 中写 Haskell 代码!),在我用此语言编写高阶函数的少数情况下,我总是确保同时提供 Haskell 风格的类型签名。正如 Simon Peyton Jones 所说,类型提供了一个“清晰”而简洁的函数定义。
更令人印象深刻的是 Haskell 对空指针问题的解决方案。那些让 Java 程序员心生恐惧的异常之一就是NullPointerException
:它是一个运行时异常,这意味着在方法的throws
声明中不需要显式地声明它;这基本上意味着任何解引用都可能触发此异常。即使在 Java 这样的静态类型语言中,类型系统也无法编码“我是否保证在这里获取一个值?”这样基本的事实。
Haskell 对这个问题的答案是Maybe
类型,明确指出函数类型中的值可能是Nothing
(空)或Just a
(值)。程序员必须意识到可能什么都没有,并明确处理失败情况(使用maybe
)或忽略它(使用fromJust
,或者更适当地命名为unsafeFromJust
)。数据类型本身并没有什么特别之处;我可以写一个具有相同形式的 Java 泛型。关键在于伴随 Functor、Applicative、Monad、MonadPlus、Monoid 等实例的高阶函数。如果我想在 Java 中编写这段代码,我会立即碰壁:
pureOperation <$> maybeVal
<$>
,也被称为fmap
的高阶函数,对于这段代码至关重要。在等效的 Java 代码中,你必须从泛型中解包值,对其进行操作,然后再次打包(如果为空则使用条件语句)。我可以添加一个方法来实现这个功能到 Maybe 接口,但是然后我将无法优雅地将pureOperation
传递给这些方法而不使用匿名类… 然后你很快就会发现这种方法的长篇大论(在 Java 中)。显而易见为什么设计者们没有选择这种方法:一个本来就冗长的语言会变得更加冗长。其他语言虽然不至于如此糟糕,但它们无法接近庆祝高阶运算符的语言所能提供的简洁性。
总结一下,虽然这对于一个因难以理解而声名狼藉的语言来说可能看起来有些奇怪,但 Haskell 类型系统的约束增加了作者和读者对抽象的容忍度,最终提升了表达能力。那些人们觉得无可奈何的问题,突然变得可以解决了,“如果你想修复这个问题,你将不得不添加大量样板代码”,这样的问题变得可以处理了。这是强大的。
对于喜欢逃避限制的人士,最后需要注意的一点是:如果你需要动态类型(我不会声称没有时机需要它),你可以完全绕过静态类型系统! 只是要小心,并非默认选择。
建议箱:ezyang 的博客
建议箱
借鉴Raymond Chen 的博客,请提出关于我未来博客文章的建议。你希望我解释什么?如果我尝试写一篇关于什么的文章会觉得有趣?我倾向于涵盖的主题:
-
几乎关于 Haskell、GHC 和密切相关的数学的任何事情。
-
一般编程主题。
-
教育、教学、讲课。
-
计算机科学中一般感兴趣的主题。
-
我实习经历的故事(目前,我曾在 OmniTI、ITA Software、Ksplice 和 Galois 实习过。)
-
SIPB。
-
音乐。
由于 Raymond 很出名而我不是,我会对我将发布的建议文章要求不那么严格。
夏季实习在 Galois:ezyang 的博客
夏季实习在 Galois
很高兴报告,我将在夏季实习期间在Galois。我不太确定公司的名字是如何进入我的意识的,但是在一月的某个时候,我决定在一个全 Haskell 的公司工作会非常酷,并且在接下来的两个月中开始骚扰 Don Stewart(和 Galois 的人力资源部)。
我将在 Cryptol 中的某个项目上工作;虽然有几个具体的项目想法被提出来,但不清楚在夏季到来时是否已经完成了其中的一个项目。我也非常期待在一个更加重视研究的环境中工作,因为我需要弄清楚我是否会在本科结束时开始攻读博士学位项目。
你好,波特兰!我迫不及待。 😃
Sup:极客的邮件:ezyang 的博客
Sup:极客的邮件
更新(2012 年 9 月 1 日): 本文已有些过时。我计划写一篇更新文章,但主要新点是:如果你有 SSD,Sup 的启动时间非常快,所以你可以轻松在笔记本上运行它,并且你应该使用 maildir-sync 分支,它提供了标签的反向同步(或者我的 patchset,非常棒但需要打磨和发布)。
我使用 Sup 并且我喜欢它;不要在意朋友们的嘲笑,他们发现当他们的收件箱超过十万封邮件或者管理索引被抹掉时,收件箱变得非常缓慢。通往电子邮件极乐的道路并不轻松,以及一个十封邮件的收件箱,所以这里有一个为你设置 Sup 的逐步指南。我们将使用顶尖的一切,这意味着从下一个分支的 Git 检出运行,使用 Xapian 索引,并使用 OfflineImap。
-
获取一个可以 SSH 连接并且运行 screen 的服务器。Sup 的启动时间不算短,所以绕过它的最佳方法是永远不关闭这个进程。这还可以避免你需要因为 ISP 的敏感 SMTP 切换而麻烦。
-
设置 OfflineIMAP 来下载你的邮件。IMAP 通常很慢,我发现我对我的邮件很重视,希望有一个本地备份。
.offlineimaprc
的配置稍微麻烦(我在得到正确设置之前两次搞砸了);看本文末尾获取我最终使用的模板。由于导入过程会花费很长时间,请在运行之前仔细检查你的配置。 -
设置 Ruby 环境;Sup 在 Ruby 1.8 上可以工作,但在 1.9 上不行。如果你使用的是 Ubuntu Jaunty,你需要手动安装 RubyGems;在 Karmic 上,打包版本可以正常工作。
-
获取依赖 gems。这就像使用
gem install sup
安装 Sup gem,然后仅移除 Sup gem 一样简单。 -
使用
git clone git://gitorious.org/sup/mainline.git sup
从 Git 获取 Sup 的副本。在你的 shell 配置文件(Bash 用户为.bashrc
)中,设置 PATH 包括 $SUPDIR/bin 和 RUBYLIB 包括 $SUPDIR/lib。添加示例行的一组可以在本帖子底部找到。 -
运行
sup-config
来设置通用配置。当它提示你添加一个新源时,添加一个 Maildir 源,指定一个目录内的文件夹,这个目录是你要求 OfflineImap 同步到的(例如,我要求 OfflineImap 下载我的邮件到 ~/Mail/MIT,所以 ~/Mail/MIT/INBOX 将是我的 Maildir 的有效文件夹)。当我转换到 Sup 后,我停止使用服务器端文件夹,所以这是我唯一为之设置源的文件夹;如果你仍然想使用它们,你需要将它们分别添加为独立的源。 -
打开你喜欢的编辑器中的
.sup/config.yaml
文件,在新的一行中添加:index: xapian
。作为更为可靠的方法,另一种选择是设置一个环境变量,但我更倾向于这种方法。 -
当你开始使用 Sup 时,我强烈建议你设置一些钩子。由于你在使用 OfflineImap,执行 OfflineImap 的
before-poll
钩子在进行轮询前是必要的。同时,“自动备份你的标签”startup
钩子也是必须的。 -
在 screen 会话中加载
sup
并享受吧!
.offlineimaprc
模板:
[general]
accounts = MIT
[Account MIT]
localrepository = LocalMIT
remoterepository = RemoteMIT
[Repository LocalMIT]
type = Maildir
localfolders = ~/Mail/MIT
[Repository RemoteMIT]
type = IMAP
ssl = yes
remotehost = $HOST
remoteuser = $USERNAME
remotepass = $PASSWORD
.bashrc
模板(假设 Sup 存在于 $HOME/Dev/sup
中):
export PATH=$HOME/Dev/sup/bin:$PATH
export RUBYLIB=$HOME/Dev/sup/lib
合成 Git 合并:ezyang 的博客
理论上,Git 支持使用merge
配置属性自定义低级别合并驱动程序。实际上,没有人真的想从头开始编写自己的合并驱动程序。对于许多情况下需要自定义合并驱动程序的案例,你不必从头开始编写自己的合并驱动程序!考虑这些情况:
-
你想要合并具有不同换行符样式的文件,
-
你想要合并一个删除了大量尾随空白的文件,
-
当一个分支替换了某些字符串为自定义字符串时,你想要合并文件(例如,一个配置文件实例化了
PASSWORD
,或者需要在合并冲突时匿名化文件), -
你想要合并一个具有稳定文本格式的二进制文件,或
-
你想要掌握关于特定类型冲突的知识以及如何解决它们(一个超智能的
rerere
)。
对于所有这些情况,你可以通过修改输入文件(构建合成合并输入),调用 Git 的git merge-file
来执行实际合并,然后可能编辑结果,再将其交还给你的合并驱动程序的原始调用者。这真的很简单。这里有一个处理具有不同换行符样式文件的示例驱动程序,将它们规范化为 UNIX 的方式:
#!/bin/sh
CURRENT="$1"
ANCESTOR="$2"
OTHER="$3"
dos2unix "$CURRENT"
dos2unix "$ANCESTOR"
dos2unix "$OTHER"
exec git merge-file "$CURRENT" "$ANCESTOR" "$OTHER"
你可以通过调整你的.git/config
来设置它:
[merge.nl]
name = Newline driver
driver = /home/ezyang/merge-fixnewline.sh %A %O %B
以及你的.git/info/attributes
:
*.txt merge=nl
在Wizard中,我们实现了更聪明的换行符规范化、配置值去替换(这减少了上游和下游之间的差异,减少了由于接近性而导致的冲突量),以及自定义的rerere
行为。我也看到我的一位同事在处理包含尾随空白字符的合并冲突时手动使用了这种技术(在 Mercurial 中,更不用说了!)
实际上,我们进一步发展了这个概念:不仅仅创建合成文件,我们创建了完全合成的树,然后适当地调用git merge
。这有几个好处:
-
现在我们可以选择一个任意的祖先提交来执行合并(令人惊讶的是,这对我们的用例非常有用),
-
Git 更容易检测到文件移动和更改换行符样式等,
-
它使用起来更容易一些,因为你只需调用一个自定义命令,而不必记住如何正确设置你的 Git 配置和属性(并保持它们的最新状态!)
合并只是元数据——多个父提交。Git 不在乎你如何获取合并提交的内容。祝合并愉快!
System.Posix.Redirect:ezyang 的博客
System.Posix.Redirect 是一个众所周知的、巧妙而有效的 POSIX 黑客 的 Haskell 实现。它也完全不符合软件工程的标准。大约一周前,我从我的工作代码中去除了这个失败的实验,并将其上传到 Hackage 以供严格学术目的使用。
它是用来做什么的? 当你在 shell 脚本中运行一个命令时,你可以选择将其输出重定向到另一个文件或程序:
$ echo "foo\n" > foo-file
$ cat foo-file
foo
$ cat foo-file | grep oo
foo
许多用于创建新进程的 API 允许自定义 stdin/stdout/stderr 句柄;System.Posix.Redirect 允许您在不创建新进程的情况下重定向 stdout/stderr:
redirectStdout $ print "foo"
它是如何做到的? 在 POSIX 系统上,事实证明,几乎与创建子进程时发生的事情完全相同。我们可以通过跟踪使用不同句柄创建子进程的过程来得到一些线索。考虑这个简单的 Haskell 程序:
import System.Process
import System.IO
main = do
-- redirect stdout to stderr
h <- runProcess "/bin/echo" ["foobar"] Nothing Nothing Nothing (Just stderr) Nothing
waitForProcess h
当我们运行strace -f
(使用-f
标志启用对子进程跟踪)时,我们看到:
vfork(Process 26861 attached
) = 26861
[pid 26860] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
[pid 26860] ioctl(0, SNDCTL_TMR_TIMEBASE or TCGETS, {B38400 opost isig icanon echo ...}) = 0
[pid 26860] ioctl(1, SNDCTL_TMR_TIMEBASE or TCGETS, {B38400 opost isig icanon echo ...}) = 0
[pid 26860] waitpid(26861, Process 26860 suspended
<unfinished ...>
[pid 26861] rt_sigprocmask(SIG_SETMASK, [], NULL, 8) = 0
[pid 26861] dup2(0, 0) = 0
[pid 26861] dup2(2, 1) = 1
[pid 26861] dup2(2, 2) = 2
[pid 26861] execve("/bin/echo", ["/bin/echo", "foobar"], [/* 53 vars */]) = 0
dup2
调用是关键,因为在向vfork
或execve
传递特殊参数时没有必要(“53 vars”是继承的环境变量)去捣鼓子进程的标准句柄,我们需要自己修复它们。dup2
将文件描述符2
(保证是 stderr,0
是 stdin,1
是 stdout)复制到stdout
,这正是我们在原始代码中请求的内容。文件描述符表是进程全局的,因此当我们改变文件描述符1
时,所有人都会注意到。
当我们不打算在dup2
调用后使用execve
时存在一个复杂情况:你的标准库可能会缓冲输出,这种情况下可能仍然存在一些未写入文件描述符的程序中的数据。如果在普通的 POSIX C 应用程序中尝试这种技巧,你只需要刷新stdio.h
中的FILE
句柄。如果你在 Haskell 应用程序中,还需要刷新 Haskell 的缓冲区。(请注意,如果你execve
,则不需要这样做,因为此系统调用会清除新程序的内存空间。)
我为什么写这个模块? 我在编写这个模块时有一个非常具体的用例:我有一个用 C 编写的外部库,它将错误条件写入标准输出。想象一下,如果 hPutStr
在无法写入字符串时打印错误消息,而不是引发 IOException
,那对于想要捕获和处理错误条件的客户端代码将是一件非常糟糕的事情。在调用这些函数之前临时重定向标准输出意味着我可以将这些错误条件传递给 Haskell,同时避免修改外部库或将其降级为子进程(这将导致更慢的进程间通信)。
为什么不能在生产环境中使用它? “它在 Windows 上不起作用!” 这并非完全正确:在某些情况下,你可能可以让它的变体工作。
在 Windows 平台上,主要问题是大量选择的运行时和标准库。幸运的是,Unix 上的大多数应用程序都使用一个标准库:libc,因此可以合理地确定你和你的同事都在使用同一个FILE
抽象。由于文件描述符位于内核层面,无论你使用哪个库,它们都能保证正常工作。在 Windows 上则没有这样的奢侈:你链接的 DLL 可能是由其他编译器工具链编译的,具有自己的运行时。特别是 GHC 在 Windows 上使用 MingW 工具链进行链接,而本地代码更有可能是使用 Microsoft 工具(MSVC++)。如果库能使用 MingW 重新编译,也许就能解决问题,但我决定更简单地修改库,以另一种方式返回错误代码。于是,这个模块从代码库中被完全移除了。
Systems ML workshop panel:ezyang's 博客
-
JG: Joseph Gonzalez
-
GG: Garth Gibson (CMU)
-
DS: Dawn Song(加州大学伯克利分校)
-
JL: John Langford(微软纽约)
-
YQ: Yangqing Jia(Facebook)
-
SB: Sarah Bird
-
M: 主持人
-
A: 观众
M: 这个研讨会汇集了 ML 和系统。你如何定位在这个光谱上?你属于哪个社区?
YJ: 就在中间。我想更多地转向系统方向,但伯克利并行实验室把我踢出来了。ML 是我的主基地。
JL: 我来自机器学习领域,也将一直在这里,但我对系统很感兴趣。我的家是 NIPS 和 ICML。
DS: 我的领域是人工智能和安全,过去从事计算机安全,现在转向 AI。
GG: 系统。
JG: 我最初从事 ML,研究概率方法。在博士期间中途,我转向了系统。现在我正转向成为一个做 ML 的系统人员。
M: 我们看到了大量需要大量开发工作、资金和时间投入的深度学习 / 机器学习框架的蓬勃发展。问:学术界在这一领域的研究角色是什么?你可以进行怎样的大规模机器学习学习?
GG: 我喜欢 YJ 上次的回答。
YJ: 令人惊讶的是,学术界是如此多创新的源泉。尽管恭维不尽,我们在谷歌做了很多好工作,但然后 Alex 推出了两个 GPU,彻底改变了这一领域。学术界是我们发现所有新想法的神奇之地,而工业则是扩展它们的地方。
JL: 一些例子。如果你来自学术界,也许你没有在大公司做研究,但这是一个优势,因为你会花时间去寻找解决问题的正确算法。这将在长期内获胜。短期内,他们会用 AutoML 来蛮力解决。长期来看,学习算法将被设计成不需要参数。一个常见的 ML 论文是“我们消除了这个超参数”。当它们变得更自动化、更高效时,伟大的事情将会发生。资源受限有一个优势,因为你将以正确的方式解决问题。
另一个例子是,机器学习的研究告诉我们,在未来,我们将把任何刚学习并部署的模型视为固有破损和有缺陷的,因为数据收集不是训练和部署过程的一部分。它会腐化并变得无关紧要。ML 的整体范式是你与世界互动并学习,这在学术界可以很容易地进行研究,对你如何设计系统有着巨大的影响。
DS: 人们经常谈论在创业公司中,最好不要筹集大量资金;如果资源受限,你会更加专注和创造力。ML 非常广泛,有很多问题。现在我们从大量数据中学习,但在 NIPS 会议上,人类有能力从很少的例子中学习。这些是学术界要解决的问题,考虑到独特的资源限制。
GG: 缺乏足够的数据,很难集中精力在顶级准确性上,而学生可获得的数据如 DAWNbench 之类的东西往往落后。在学术界,我们与行业建立关系,派学生实习,他们有能力处理大数据,同时在大学探索第一原理。这是一个挑战,但开放出版和代码共享使这个世界更容忍。
JG: 我一直在努力专注于人力资源这一问题。我有研究生,优秀的学生,专注于一个关键问题可以取得很大进展。我们在处理大量数据时遇到困难。在 RL 方面的困难确实存在,我们可以建立模拟器以在这个规模上构建。能够使用模拟来获取数据;要有创意,找到新的有趣问题。
M: 跟进流程。我认为你们很多人都试图在自己的社区发布 ML 研究。他们能正确评估工作吗?他们不重视的常见原因是什么?
JG: 发表 ML 方面的研究在系统领域,或反之,都很难。这两个社区都不擅长评估其他领域的工作。在系统中进行 ML 研究,在这里看到的情况令人惊讶。反之,如果以系统的方式进行 ML 研究,可能不会在系统会场上表现良好。我看到的失败模式是,系统社区无法理解极端复杂性。在 ML 中,我有这个非常复杂的东西,但将它们简化为其基本组成部分。ML 试图通过复杂性扩展作为创新。更广泛地说,每个社区在看待研究时都有自己的偏见。我注意到的一件事是,情况有所改善。系统在评估方面做得更好,在这个研讨会上,人们正在推动先进的研究。
GG: 我年纪大了,见证了会议的创立。所以,你从重叠的领域开始。在我之前的生活中,主要是存储作为研究领域的概念,而不是设备的应用。你开始,提交论文。PC 上有两个了解此事的人,他们未分配,评审草率,有一个会议做得稍微好一点,但其他会议不读。我在容错、数据库、操作系统社区都遇到过这个问题,它们不互相阅读。当你有足够的数量时,在中间得到一个聚焦的会议;评审和 PC 已经看到该领域大部分好的工作。这很难,但我们正在 SysML 的边缘做这件事。我们在竞争中做正确的事情,处于技术最前沿。
M: 这是唯一的解决方案吗,还是我们可以混合 PC?
GG: 我见过很多试验来尝试它。你可能最终会导致永久性的分裂社区。
JL: 乔伊和道恩是 ICML 的一个领域主席。我发现机器学习社区对系统类的事物很友好。有一个系统领域的主席。希望论文能够得到适当分配。
M: 我们在系统方面做得不好。
DS: 关于机器学习和安全性,我们有这个问题。在安全领域,我们也有非常小的机器学习百分比,如果你提交机器学习,很难找到能够审查论文的人,因此审查质量变化很大。在机器学习安全方面,类似的问题。思考为什么会发生这种情况以及如何解决这个问题是很有趣的。总的来说,有时候最有趣的工作是跨学科领域。机器学习和系统,安全性,以及我看到的例子,包括在系统中的机器学习……所以,我真正能理解的一件事情是,尽管审查质量不同,从委员会的角度来看,他们真正想要的是对社区更有意义的论文,帮助人们接触到这个新领域,促进新的探索。随着时间的推移,会有更多的交叉污染。
JG: 我们正在启动一个 SysML 会议。我有一点犹豫:机器学习在系统上的进展很大,但现在我必须决定把论文投到哪里。我们看到的许多机器学习论文都涉及系统。
GG: 当你有一个新的会议领域时,并不是所有的工作都会发送到那里。重叠的是,你有一个喜欢的会议,你的英雄,你会把你最激动人心的工作发送到那个根会议。没有问题。
YJ: SysML 很棒,这就是它的出现方式。新的领域,它值得新的会议。
M: 你认为机器学习专家也需要成为系统专家吗?处在这种交叉点的人有不同的看待方式吗?或者你提出了一个好的算法,然后你
JL: 有一个墙是不行的。
有很多方法可以改变学习算法。如果你不理解,把工程师丢掉是有问题的。但如果你能建立桥梁来理解,它们不是艺术品,你可以打开并修改。这可以让你获得更好的解决方案。
GG: 同意,但最初发生的事情是你跨越到另一边,将其放入系统中,而我创新的部分是冗余性导致容错性,即使从另一边来看它相当普通。如果它是一个实质性的改进,值得去做。我们都在成长。
JG: 我们需要一堵墙,但我们会不断地拆除它。研究生阶段的 Matlab,我们开了它的玩笑,而 MKL 社区则使它变得更快。然后他们说我们将为分布式计算算法构建 ML,而 ML 将为系统编写类算法。然后在 pytorch、TF 等的开发中,这种抽象层次升级了。这个堆栈正在重新构建;系统社区正在使其更加高效。嗯,fp 可能会改变,这可能会影响算法。所以我们又开始拆除它了。但系统设计就是关于设计这堵墙。
YJ: 它更像是一个酒吧凳子。这是一个障碍,但我们不必要做到两者兼而有之,但你需要它来使其高效。一个故事:一个训练系统我们看了看,SGD。那个人发现一个非常好的圆整数:100。但人们皱眉,你应该将其圆整到 128。理解和改进 CS 和工程的共同核心,对于人们如何设计 ML 算法非常有帮助。
M: 有很多关于民主化 AI 的讨论,你们所有人都帮助了这个过程。一个真正民主化的 AI 景观是什么样子,我们离这样的世界有多远。
YJ: 我承认参与了框架战争。阅读计算机科学历史时,有一件很自然的事情,当领域刚刚开始时,会有各种标准和协议。FTP,Gopher,最终 HTTP 占据了主导地位,现在所有东西都在 HTTP 上运行。现在有各种不同的抽象层次;归根结底,每个人都在做计算图、优化。我期待着我们有一个非常好的图形表示、优化图形的协议。这不是一个美好的梦想,因为在编译器中我们已经有了这样的解决方案,LLVM。我不知道我们是否会达到那个状态,但我认为总有一天我们会到达那里。
JL: 当任何人都可以使用时,AI/ML 被民主化了。这意味着什么,一个程序员有一个库,或者语言结构,他们可以轻松地和常规地使用;没有数据不匹配、混淆或偏见的问题。所有人们在数据科学中担心的错误都从系统中移除,因为系统设计得正确并且易于使用。超越这一点的是,当有人使用系统时,该系统正在学习适应你。在人们互动方式上有巨大的改进空间。我不知道有多少次重写规则让我抓狂;为什么它不能按照我想要的方式重写。人们可以向学习算法传递信息,当这些信息能够有效地辅助人们时,你就实现了 AI 的民主化。
DS:我对 AI 民主化有着非常不同的看法。我认为真正有趣的是思考这里的民主化究竟意味着什么。对于系统工程师来说,这意味着让人们更容易学习,使用这些库和平台。但这实际上只是为他们提供工具。对我来说,我在讨论 AI 民主化时,我们是从完全不同的角度来看待它的。即使是代码:谁控制 AI,谁就控制世界。那么谁控制 AI?即使你给了每个人工具,按下按钮,但他们没有数据来进行训练。那么今天和明天谁控制 AI?是 Facebook、微软、谷歌...所以对我来说,民主化意味着完全不同的事情。今天,他们收集数据,训练模型,并控制谁可以使用模型,用户可以获得推荐,但不能直接访问模型。我们有一个项目实际上是要民主化 AI,用户可以控制自己的数据。结合区块链和 AI,用户可以将数据捐赠给智能合约,智能合约将规定条款;例如,如果您训练了一个模型,用户可以使用该模型,如果模型产生利润,用户可以获得一部分利润。智能合约可以规定各种激励条款;例如,如果数据比其他人更好,他们可以获得更多的利润,以及其他机制。开发者将提供 ML 训练算法,并在训练良好时获得收益。我们正在去中心化 AI 的力量;用户将能够直接访问模型并使用它们。在这种情况下,我希望有一个替代的未来,大公司可以继续经营业务,但用户通过以去中心化的方式集合他们的数据,将看到 AI 的真正民主化;他们将访问 AI 的力量,而不仅仅是使用工具。
(掌声)
GG:我认为 AI 民主化中很多内容意味着如何从少数创新者转向大量创新者。工具开发和标准化。我们离这一目标已经很近了。过去有一个例子,就是 VSLI 画框。直到某个时刻,只有电子工程师才能真正开发硬件。他们花了很多时间和精力确保可以在每个部件中都能通过,不会有太多串扰。一个团队聚集在一起,想,好吧,有一些设计规则。这让你可以相对容易地构建硬件。我可以画绿色/红色的框,几个月后,硬件就能工作了。虽然它永远不会像那些电子工程师那样快速工作,但它让我们可以构建一个 RISC 计算机,并且把它交付出去。我们参与了这场比赛,我们可以创新,可以做到。我们现在正试图构建的工具可以建立在统计学的基础上。
JG:当我开始博士学位时,我们手动计算积分和导数。自动微分是一个巨大的进步。我认为这是论文爆炸的原因之一。一年级生可以构建比我能做的更复杂的东西。这推动了算法方面的 AI 发展。
数据方面很有趣,这是我在系统中考虑的问题。有很多机会可以思考安全性如何互动,利用硬件保护它,市场从各种来源买卖数据,并在很多地方保护数据。我认为我们在思考算法的方式上取得了实质性的进展。
M:当我考虑普及人工智能时,最近困扰我们思想的问题,如解释性,公平性等。你能分享……任何解释性成为问题、问题的经验,我们是否需要在机器学习或系统-机器学习中更多地担心这些事情。
JG:我的研究生来找我,说模型停止工作了。我不知道如何解决这个问题;这个过程非常实验性。跟踪实验是这个过程的一个重要部分。我们非常关注可解释的模型,这意味着一些非常具体的东西。现在是可以解释的;我们不需要知道它究竟做了什么,但需要与我们所做的有某种联系。可解释,解释计算,它可能与决策相关或无关。这是关于可解释性的两个答案,以及我们如何调试这些系统。
GG:SOSP 刚刚结束,他们有十年的……他们提交的所有东西的好副本。在会议结束时,Peter Chen 拿走了所有的 PDF 文件,做了一个朴素贝叶斯分类器,看看他能多好地预测它会被接受。它预测被接受的东西中,一半确实被接受了。
那么他们做了什么?他们为流行的作者制作了一个检测器。所以你做的是那些成功了的人,他们会跟在后面。我意识到了这个问题。你可能认为你找到了一个好方法,但实际上是尼古拉·泽尔多维奇的论文。
DS:存在一个很大的争议。有些人认为这非常重要,有时只要模型运行良好就可以了。我们的大脑,我们无法真正解释我们如何做出某些决定,但它运行良好。这取决于应用场景。有些应用对可解释性有更强的要求;例如法律和医疗,而在其他领域则不那么重要。作为整个社区,有很多我们不理解的地方。我们可以谈论因果关系,透明度,所有相关的内容。作为整个社区,我们不真正理解可解释性意味着什么。没有一个好的定义。所有这些概念都相关,我们正在努力弄清楚真正的核心。这是一个非常好的开放性问题。
JL:有两种不同的解释。你能向一个人解释吗?这是有限的;没有可以解释的视觉模型。另一个定义是可调试性。如果你想创建复杂系统,它们需要是可调试的。这对于分布式系统来说是非平凡的,对于机器学习来说也是非平凡的。如果你想创建非平凡的机器学习系统,你必须弄清楚为什么它们不按你想要的方式行事。
DS:我们会调试我们的大脑吗?
JL:漫长的进化过程解决了很多问题……很多人的大脑里都有小问题。我知道我也有小问题。有时我会得视觉偏头痛……非常烦人。不,我们不调试我们的大脑,这是一个问题。
YJ:我确信我的大脑里有些小问题;我曾经在我奶奶家里追过鸡;鸡背上有一个地方,你按一下它,它就会弯腰坐下。它因为害怕而停止了。我们人类不会这样做。但是这些问题也存在于我们的大脑中。追求可解释性有助于理解事物的运作方式。旧日的深度梦境;这一行业始于弄清楚梯度的作用,我们向后传播,发现直接梯度不起作用;然后我们增加了 L1 先验,然后我们得到了图片。这种好奇心导致了卷积神经网络(CNNs)用随机权重编码了局部相关性;我们在 CNNs 中硬编码了结构化信息,这是我们以前不知道的。所以也许我们不会实现完全的可解释性,但一定程度的可解释性和创造力会有所帮助。
(听众提问)
A:我真的很想听听杰夫对系统机器学习的看法。作为系统的一部分,我对此很感兴趣,但有人说,你可以通过启发式方法取得很大进展。
JL:我觉得这很令人兴奋。
GG:索引数据库,当我阅读时,我感叹,“哇!这真的可能吗?”我认为这类应用的新颖性开拓了很多人的思路。现在我们认为机器学习工具是昂贵的东西,重复了人类轻而易举但计算机做得不好的事情。但数据库索引并非如此。我们可以执行它,但我们不会更好。但是通过预测器的压缩思路,让它半大小且速度翻倍,这是一个了不起的见解。
JG:我曾试图在这个领域发表文章。有段时间,系统不喜欢在它们的系统中使用复杂的算法。现在,这些天,系统说,“机器学习很酷。”但在哪里更容易取得成功,你的预测改进了系统,但一个糟糕的预测不会破坏系统。因此,调度是好的。在模型可以提高性能但不会损害的地方。解决系统问题的机器学习工作是成功的。
DS: 系统 ML 非常令人兴奋。我个人对这个领域非常兴奋,尤其是对那些从事系统工作并对 AI 感兴趣的人来说。系统 ML 是 ML 的一个令人惊奇的领域。我不会感到惊讶,我希望看到,在五年内,我们的系统更多地受 ML 驱动。许多系统有许多旋钮可以调节,试错设置,ML 可以帮助解决问题。在这些惊人的技术上,RL、bandits,不是用 bandits 来服务广告,我们可以尝试自动调整系统。就像我们看到 AI 正在改变许多应用领域一样,更智能的系统,旧系统,我们建造的那些,应该更智能。这是一个预测:我认为我们将在这个领域看到很多工作。我认为它将改变系统。
M: 我在这方面工作了很多。在某些设置中,我们在 bandits 中取得了一些成功,但是有些设置确实非常困难:有状态、选择、决策影响未来,这使得应用 RL 变得困难,或者 RL 技术需要大量数据。存在挑战,但也有成功案例。有很多论文在缓存、资源分配中应用 RL。真正的问题是为什么它没有在生产中使用?我不知道我们是否有答案,论文中这样做看起来非常好,但它并不那么主流,特别是在各个地方都有 RL。为什么它不普及。我看不到那个。
A: 难道不是因为它不可验证吗?你想要某种验证分析。
GG: 这被称为回归扫描。如果你在许多系统上部署。这涉及很多钱,它必须有效。如果它倒下来,那就是一场诉讼。我雇了一位软件副总裁。好的,现在我负责,事情将放慢速度。每一行代码都是 bug,如果我想要低 bug 率,我会阻止程序员编写代码,通过制定非常高的标准。这就是 JOy 所谈论的事情;他们需要一个真正引人注目的理由,没有任何不利因素,然后他们必须通过测试才能通过。因此,任何随机的事情都有一个高标准。
SB: 另一件事情正在发生,没有那么多人既了解这两个领域。在没有深入系统专业知识的情况下进行系统 ML 是非常困难的。你真的需要理解来解释它。
GG: 不久以前我们还没有托管服务。
M: 护栏,你约束了 ML 系统不建议不好的东西。我们在 MS 有一个场景,机器无响应。等待多久?你可以在 ML 中做到。选择都是合理的,它们从不超过你希望等待的最大时间。
A: 关于民主化。有很多关于优化模型的讨论,以便它们可以承受成本。另一个是去中心化数据...但是系统和模型有两个非常大的限制。它们成本高昂,而且存在很大的方差。因为成本的原因,如果有人涉足编程并进行研究,他将没有资源来做这件事。所以他们不会进入工程领域;他们会在亚马逊实习。所以,如果有一些社区试图降低门槛,民主化,有什么解决方案可以让人们更容易地进入呢?因为经济成本巨大。人们试图赚取巨额利润,创业公司,但没有...系统在去中心化方面存在缺陷...这只是一个大问题与机器学习相冲突。
JG: 我们在伯克利教授数据科学。总结一下,关于深度学习的成本如何?训练模型的成本,GPU,数据,如何让一个大学新生对此感兴趣,Chromebook,他们可以做研究并探索机会。在伯克利,我们正面临这个问题。我教了 200 名学生,其中很多是新生,Chromebook 和 iPad 是他们的主要计算机。我们使用 Azure 构建了工具...我们在 Azure 上运行云,在这些设备上,他们可以实验模型。他们可以使用预训练的模型,并学会如何...有人建造了一个俄罗斯 Twitter 机器人检测器,并在其中看到了价值和机会。然后他们参与了更多资金和工具的研究项目。
JL: 正确的接口可以起到很大作用,因为它们可以防止由于 bug 而无法执行任务。此外,深度学习正在风靡,但问题的框架比你所做的表示更重要。如果你有正确的问题,即使是一个愚蠢的表示,你仍然会做出有趣的事情。否则,它根本不会很好地工作。
YJ YJ: 作为行业,不要害怕行业并尝试一下。回到伯克利,当伯克利人工智能使用 GPU 时,要求是每个 GPU 一个项目。我们学生,框定了十个不同的项目,我们只要求十个 GPU。英伟达来找我们问,你在干什么。我们就给你四十个 GPU 做研究。现在,FAIR 有实习,Google AI 有实习,所有这些都在行业和学术之间创造了非常好的合作,我想鼓励人们试试看。行业有资金,学术有人才,把它们结合在一起是永恒的主题。
A: 回到关于会议未来的方向,这个研讨会的未来;已经做出了任何决定吗,我们往哪里走?
SB: 这是一个正在进行中的工作。我们对反馈和您的看法感兴趣。我们已经进行了 10 年的工作坊,与 NIPS 和 iCML 一起。然后我们在 SOSP 上做了一个,非常令人兴奋。我们现在将在二月份在斯坦福举办一个单独的会议。我们认为在与 NIPS 和 ICML 共同举办的研讨会中有非常重要的角色要发挥。我们仍然计划继续这一系列的工作坊。在 ICML 和 NIPS 中也有越来越多的系统工作,这是自然扩展来接受这项工作。这个领域正在成长,我们将尝试几个场地,并形成一个社区。如果人们有想法。
JG: 更多的人应该参与进来。
M: 我们计划继续这个;观众很多,参与度也很高。
由于这是一个小组讨论,所以我必须要求你预测未来。告诉我你对未来 50-100 年真正激动的事情。如果你那时还活着,我会找到你,看看你的预测是否成真。或者说出你希望会发生的事情...
YJ: 今天我们用 Python 写作。希望我们能在一行中编写每个 ML 模型。分类器,get a cat。
JL: 现在,人们正处于一个逐渐增加学习曲线的阶段。ML 的核心是减少旋钮。我相信 ML 的视野在减少旋钮。我也相信普及 AI。你不断地转动...周围,开发者可以将学习算法融入系统。这将成为技术的一部分。这是炒作周期的一部分。NIPS 经历了一个阶段性转变。在某些时候,它必须下降。当它变得例行公事时,我们正在普及事物。
DS: 很难做出预测...我猜,现在,我们看到 ML 是一个例子,我们看到了浪潮。不久前,有神经网络的浪潮,图形模型,现在我们回到了神经网络。我认为...我希望我们...有一个稳定期。即使是在今年,我也与许多优秀的 ML 研究人员交谈过,尽管可以说今年写的论文更多,但当你听到人们谈论的里程碑时,许多人提到了过去几年的里程碑。AlexNet,ResNet,...我希望我们能看到超越深度学习的新创新。我确实教授 DL 课程,但我希望我们能看到一些超越 DL 的东西,能带领我们...我们需要更多,才能带领我们走向下一个水平。
GG: 我很想指出 DL 是五年前的事情,互联网泡沫时代也不过五年...我认为,我期待 CS,总体科学的做法变化,从统计 AI 中学到。我最喜欢的是过拟合。我对过拟合的理解很浅显,直到 ML 强调了这一点。我期待有一天,学生告诉我,他们停止写代码,因为他们正在添加参数...他们为测试代码添加了一个体面的随机,iid 过程。我们还远远没有到那一步,但我认为它即将到来。
JG:我期待图形模型的回归……实际上并不期待。当我们使 AI 民主化时,最终发生的是,我们在使技术民主化。我可以走到 Alexa 面前教它。或者我可以教我的特斯拉如何更恰当地停车。技术能够适应我们,因为它能学习;当我能向计算机解释我想要什么时。(就像星际迷航但没有传送装置。)
尾递归使您的循环更清晰:ezyang 的博客
来源:
blog.ezyang.com/2011/05/tail-recursion-makes-your-loops-cleaner/
尾递归使您的循环更清晰
递归是函数式编程语言擅长的事情之一,但让人有点失望的是,在许多情况下,您必须将美丽的递归函数转换回迭代形式。毕竟,迭代是命令式语言最擅长的,对吧?
实际上,在函数式编程语言中,显式尾递归函数可以非常美丽:事实上,在复杂循环的情况下,它们甚至可以比它们的命令式对应物更漂亮。以这个中点画线算法为例:
circleMidpoint d r = go 0 (-r) k0
where k0 = 5 - 4 * r
x1 = ceiling (fromIntegral r / sqrt 2)
go x y k | x > x1 = return ()
| k > 0 = d (x,y) >> go (x+1) (y+1) (k+8*x+8*y+20)
| otherwise = d (x,y) >> go (x+1) y (k+8*x+12)
有三个循环变量:x
、y
和 k
,根据不同的条件,其中一些变量以不同的方式更新。x
是一个标准的循环变量;老式的 C 风格的 for
循环可以很好地处理它。但是 y
和 k
根据一些循环条件以不同的方式更新。但由于它们是 go
辅助函数的参数,总是清楚地知道哪些经常变化的变量。在命令式翻译中,您会失去这种良好的结构:
// global variables and loop variables are all mixed together
int k = 5 - 4 * r;
int y = -r;
int x1 = ceil(r/sqrt(2));
for (int x = 0; x <= x1; x++) { // only x is obviously an index var
draw(x, y);
if (k > 0) {
y++;
k += 8*x + 8*y + 20;
} else {
k += 8*x + 12;
}
// does it ever make sense for any code to live here?
}
我在这个过程中还设法引入了一个错误...
讲座周五:ezyang 的博客
过去几个月里,我参加了许多非常有趣的讲座,以至于我无法像在夏季那样为每一个写详尽的文章。因此,我只好在这里压缩其中的两个。这些摘要显示出重新构思问题在不同输入领域上以达成结果的一种共同主题,希望这一点通过这些摘要变得显而易见。
一个用于数学的语言 by Mohan Ganesalingam。大思想: 将语言学和自然语言处理技术应用于数学语言——这类语言在教科书和证明中都能找到。
Ganesalingam 的目标宏大:他的长期项目是“使计算机能够像人类一样进行数学运算。”“但等等,”你可能会说,“我们不是已经通过证明助手在接近这一目标吗?”不幸的是,对此的答案是否定的:证明助手在捕捉严格的形式推理方面做得很好,但在捕捉数学家在撰写证明和教科书时所指的软性思想方面则做得很糟糕。这个项目的第一步是理解这种数学语言——因此,他的讲话标题如此命名。
我们有什么理由相信这个项目会比当前的语言学和自然语言处理研究更成功?毕竟,大多数论文和教科书使用英语并穿插数学符号,关于语义分析的宏大理念已经让位于更有效但在理论上较不吸引人的统计方法。Ganesalingam 在这里做出了一些关键观察:本质上,数学语言具有适当的形式化程度,使传统上难以解决的问题变得可行。只需要一个小型词汇表,然后可以将数学术语定义为其他数学术语,并且在许多情况下,数学陈述有明确的语义:原则上我们可以将其翻译为高阶逻辑的陈述。
进一步阅读:斯坦福的类似演示幻灯片,非正式的非技术介绍,作者的个人主页。
在图上评估公式 by Anuj Dawar。这里真正有两个大思想。大思想 1: 将图问题概括为问题“这个一阶逻辑公式在这个图上成立吗?”,将您的算法视为对两个输入的函数:图和逻辑公式。大思想 2: 使用图结构理论来描述我们可以有效解决这些 FO 公式的图输入空间。
第一个重要观点:图问题的研究经常集中在一个个单独的图问题上:毕竟,能够假设一个具体的问题实例使得推理变得更加容易。Dawar 的讲座介绍的是通过将它们打包到各种形状和大小的逻辑中,来讨论大类图问题的方法。存在第二阶逻辑给出所有 NP 问题(Fagin);一阶逻辑更加限制但允许更好的分析。将公式从你的问题中分离出来还允许你应用参数化复杂性理论:公式是算法的输入,你可以将其设置为常数或者变化。不幸的是,即使对于固定的图形,问题仍然是 PSPACE 完全的,所以我们需要另一种方法来掌握问题。
第二个重要观点:限制输入图以使算法可处理。这涉及到一些图论知识,我不打算总结,但在这个领域确实有一些非常好的结果:
-
Seese(1996 年):对于度数受限于 k 的图的类别,每一个 FO 可定义属性在线性时间内可判定。
-
Frick 和 Grohe(2001 年):对于局部树宽度受函数 f 限制的图的类别,每一个 FO 可定义属性在二次时间内可判定。
-
Flum 和 Grohe(2001 年):对于排除 K_k 作为子图的图的类别,每一个 FO 可定义属性在 O(n⁵) 的时间内可判定。
一个奇怪的事实是,Flum 和 Grohe 在复杂性上的 O(n⁵) 的界限有一个不可计算的常数因子。
最后,我们来到研究的边缘:他介绍了一类新的图,无处稠密 图,阐明了为什么我们有充分的理由认为这样的特性是可处理的,并表示他们希望建立 FO 是固定参数可处理的。
一个快速的旁白:我真正喜欢写得很好的理论研究讲座的一件事是,它们经常向我介绍我可能不会否则接触到的计算机科学的子领域。这个演示是对图论和参数化复杂性理论的一个风驰电掣的介绍,这两个主题我可能原本认为不那么有趣,但之后我尝试了足够多,想进一步调查。我认为,对于一个从事高度抽象工作的研究人员来说,也要进行讲座,以积累理解其结果所需的背景知识,是非常值得赞赏的。
进一步阅读:这些主题的完整课程
GHC 插件模板项目:ezyang's 博客
来源:
blog.ezyang.com/2012/09/template-project-for-ghc-plugins/
GHC 插件模板项目
制作 Core 到 Core 转换的 GHC 插件涉及一些脚手架工作,因此我创建了一个小项目,基于Max Bolingbroke 的示例,这是一个非常好的、干净的模板项目,你可以用它来创建自己的 GHC 插件。特别是,它包含了关于 GHC 源码的文档和指针,以及一个方便的 shell 脚本 rename.sh MyProjectName
,可以让你轻松地将模板项目重命名为任何你想要的名称。你可以在 GitHub 上找到它。随着项目的进展,我可能会继续添加更多内容;如果有任何 bug,请告诉我。
补丁和树版本控制的张力:ezyang 的博客
来源:
blog.ezyang.com/2010/09/tension-of-patch-and-tree-version-control/
本文并非对 Darcs 的抨击,而是对两种版本控制哲学差异的观察。另外,我对 Darcs 还有些陌生,因此可能存在一些事实不准确的地方。请指出来!
我希望有一天能写一篇给 Git 用户的 Darcs 指南,总结我作为一个高级 Git 用户在与 Darcs 搏斗时的经验。但或许最重要的一点是:不要试图把 Git 的底层存储模型套用在 Darcs 上!一旦我意识到这一点,我发现 Darcs 与我偏爱的 Git 开发风格——持续变基本地补丁直到它们被提交到官方仓库——非常契合。
这种变基工作流是如何运作的?尽管名字有些滑稽,但它是一个早于版本控制的通用工作流:核心操作是提交一个补丁。也就是说,在完成编码、重新编译并清理完你的修改后,你会拉取仓库的原始副本,生成一个统一的 diff,并将其发送到官方邮件列表。如果统一的 diff 不能干净地应用于官方开发版本,上游会要求你将补丁应用到软件的新版本上。
Git 通过变基优化了这一工作流。顾名思义,你正在改变应用补丁的基准提交。补丁的身份比仓库的“历史”更重要。交互式变基允许你重新排序补丁,并将历史切割成供上游阅读的漂亮形式。
由于 Git 同时支持基于树和基于补丁的工作流,两种思想之间的张力是显而易见的。在衍合时,旧的提交对象变得无法访问,你必须依赖像 reflog 这样的机制来检索旧树。良好的实践是永远不要对已发布的仓库进行衍合,因为一旦发布,一致的历史比漂亮的历史更重要。
Darcs 仅支持 基于补丁的工作流。像在衍合时必须保持补丁的良好排序那样,这很难做到,但也没必要:darcs send --dry-run
会让你知道本地的哪些补丁还没放入上游仓库,在进行任何有趣的命令时,都需要用 -p
明确指定你所引用的补丁。Darcs 可以轻松合并和拆分补丁,即使它们深埋在你的 darcs changes
日志中也可以编辑旧补丁。
不过,有时我确实会想念基于树的模型:特别是,虽然接近很容易,但却没有简单的方法来准确获取仓库两天前的结构(比如说,你的构建仍在工作时)。Git 显式地将仓库的任何给定状态实现为一个树对象,这使得补丁抽象变得不那么流畅,但意味着你将永远不会丢失提交的数据。不幸的是,对于 Darcs,没有“特定仓库状态”的简写;你可能会注意到 darcs send
必须明确列出你要发送的特定补丁之前的每一个补丁。从这个角度看,我认为 Darcs 做了太多的工作:虽然最近的 N 次更改应该被视为补丁而不是树的快照,但我可能不太关心项目的古老历史。Darcs 已经通过标签支持了这一点,但我在像 GHC 这样快速移动的仓库上的经验告诉我,你也希望有一个标签时间线来跟踪最新的“官方”仓库 HEAD。
关于冲突解决的主题也有,但由于我还没有遇到任何复杂的情况,这里我就少说几句。
The Art of Posing a Problem : ezyang’s blog
提出问题的艺术
上周,我正在与Alexey Radul讨论一些有趣的研究问题,我可以从中获得一些经验。他的博士论文讨论了“传播网络”,他认为这是比传统方法更一般的计算底层。这是一个长期的工作,它留下了许多问题,无论是理论上的还是实际上的。我现在正在处理系统实施的一个非常小的角度,但在我们还在解决一个问题时,Alexy 评论道:“我越来越意识到要把一个问题提出来做好需要多少工作。”
我完全同意,尽管我的经验来自一个不同的领域:SIPB。将有兴趣的潜在项目分配给工作的关键问题之一是:
-
许多项目非常庞大复杂,很多情况下,将一个有趣且高水平的项目分配给某人,并期望他们能够取得显著进展,这简直不可能。他们更可能在类似于打蜡、擦蜡的训练中取得进展,但这并不有趣。
-
没有人会告诉你他们对什么感兴趣!即使你问了,你可能会得到这样的答案:“嗯,我什么都行。” 作为曾经用过这个短语的人,我也强烈地理解这并不真实;人们有不同的兴趣,并会对同样的任务有截然不同的喜好。
-
在项目的方向上很容易施加过多或过少的控制。控制过多,你就为这个人定义了整个技术规范,剥夺了他们的创造性输入,在他们没有完成工作时让他们感到沮丧,并且当他们一开始未能理解你的苛刻标准时,你很可能感到失望。控制过少,这个人很容易迷失或浪费时间在次要问题上而不是核心问题上。
-
成为一位合格的导师是一个耗时的过程,即使你控制力最小。一旦这个人回来带着一套补丁,你仍然需要阅读它们,确保它们经过了适当的测试,并回传关于补丁需要如何审查的审查意见(对于除了最琐碎的更改外,这是不可避免的)。也许你会想知道为什么你当初不是自己做这该死的任务。如果问题被重新框定为纯粹的教育练习,如果不正确执行,也可能令人失望。
-
随着人们不断完善自我启动的艺术,他们可以参与的项目数量激增,你觉得他们会选择你的项目吗?人们决定他们想要做的事情,不论是因为他们自己创建了它,或者它属于他们感兴趣的领域,又或者它是他们日常使用的工具。如果你不能让这个人投入其中,你很容易失去他们。
我想开源项目维护者、实习项目和 Google Summer of Code 组织者可能也会面临类似的紧张局面。尽管我肯定自己曾处于两个方面,但实际上我对这个领域的真正有效的策略一无所知。我很想听听那些尝试过有趣策略并且成功的人的意见!
AST 类型问题:ezyang 的博客
这篇《Lambda the Ultimate》帖子(2010 年)描述了编译器编写者面临的一个相当普遍的问题:如何向 AST 添加“额外信息”(例如类型)?(帖子本身将问题分为三个组成部分:将信息添加到数据类型中,使用信息来指导节点的构建,使用信息来指导节点的销毁,但我只对如何定义数据类型感兴趣。)在这篇帖子中,我想总结解决这个问题的方法,这些方法在这篇帖子中被描述,并且看看一些真实世界的编译器是如何做的。运行示例 lambda 演算如下:
data Exp = Num Int
| Bool Bool
| Var Var
| If Exp Exp Exp
| Lambda Var Exp
| App Exp Exp
data Type = TyInt | TyBool | TyArrow Type Type
单独的 IR,其中节点带有类型装饰
低技术解决方案:如果您需要一个包含更多信息的新版本 IR,只需定义一个新的 IR 类型,其中每个节点也可以携带信息。使这些定义更简洁的一个技巧是创建一个相互递归的数据结构。[1]
type TExp = (TExp', Type)
data TExp' = TNum Int
| TBool Bool
| TVar Var
| TIf TExp TExp TExp
| TLambda Var TExp
| TApp TExp TExp
尽管(或许正因为)它的简单性,这种方法在许多编译器中非常受欢迎,特别是在 ML 社区中。一些例子包括 OCaml(parsetree/typedtree)、MLton(AST/CoreML)和 Ikarus Scheme。部分原因是从前端语言到类型化语言的转换还伴随着其他一些变化,当定义一个新的 AST 时,这些变化也可以结合在一起。
可空字段
无原则解决方案:使用一个 AST,但有一个可选字段,可以插入信息。[2]
type TExp = (TExp', Maybe Type)
data TExp' = TNum Int
| TBool Bool
| TVar Var
| TIf TExp TExp TExp
| TLambda Var TExp
| TApp TExp TExp
不再进行进一步评论。
显式类型化
虽然与单独的 IR 解决方案密切相关,但明确类型化的 IR 采取的方法是不为每个节点装饰类型,而是安排任何给定节点的类型可以仅使用局部信息快速计算。[3]
data TExp = TNum Int
| TBool Bool
| TVar Var
| TIf TExp TExp TExp
| TLambda Var Type TExp
| TApp TExp TExp
在这里,TExp
和Exp
之间的区别非常微小;TLambda
用显式类型为绑定器进行了注释。就类型检查而言,这是一个天壤之别:我们不再需要查看 lambda 外部来确定绑定器可能是什么。
强制使您的 IR 明确类型化通常是出于元理论原因一个好主意,因为复杂的类型系统通常没有可判定的推理算法。GHC 的核心 IR、Ur/Web 的核心和 Coq 都以这种方式明确类型化。
两级类型
通过延迟递归数据结构的节点连接时机,您可以安排基本函子同时为无类型和类型表示提供服务。[4]
data ExpF a = Num Int
| Bool Bool
| Var Var
| If a a a
| Lambda Var a
| App a a
newtype Exp = Exp (ExpF Exp)
newtype TExp = TExp (ExpF TExp, Type)
Coq 内核使用这种方法来定义其表达式类型,尽管它不用它来定义一个无类型的变体。
(惰性)属性语法
我不敢说我太理解这种方法,但它本质上是一种与通常的代数数据类型不同的编程模型,它将树的节点上的属性关联起来。在某种意义上,它可以被视为从 AST 节点到属性的记忆函数。许多编译器确实使用映射,但仅用于顶层声明。[5]
Haskell 的字符串理论基础:ezyang 的博客
来源:
blog.ezyang.com/2016/09/the-base-of-a-string-theory-for-haskell/
这个博客的早期文章之一,来自 2010 年,是关于Haskell 中如何选择你的字符串库的主题。半个世纪后,Haskell 生态系统在很大程度上仍处于与半个世纪前相同的情况下,大部分与 GHC 一起发货的引导库(例如,base
)仍然使用String
类型,尽管存在更优秀的字符串类型。问题是双重的:
-
没有人想要破坏所有现有的代码,这意味着像
base
这样的库必须保持对所有代码的String
版本。你不能只是搜索替换每个String
出现的地方为Text
。 -
没有人想要维护两个代码副本,它们彼此复制粘贴但略有不同。在实践中,我们必须:例如,unix有所有函数的
ByteString
变体(通过复制粘贴完成);text提供了一些核心 IO 功能(同样是通过复制粘贴完成)。但这非常糟糕且扩展性差:现在每个下游库想要支持两种或更多的字符串类型都必须发布两个自己的副本,并且任何新的字符串实现都必须重新实现整个世界以使自己有用。
Backpack 通过允许您对签名进行参数化而不是对字符串类型的具体实现进行实例化来解决这些问题。这解决了这两个问题:
-
因为你可以随时实例化一个不定的库,我们可以急切地使用
String
来实例化posix-indef
,并将其作为posix
发布,以保持对所有不了解 Backpack 的包的向后兼容性。 -
与此同时,如果包直接依赖于
posix-indef
,它们本身可以对字符串类型进行参数化。整个库生态系统可以将字符串类型的选择推迟到最终用户,在 GHC 的足够新版本上,这为库添加对新字符串类型的支持提供了向后兼容的方式。(我不想说,支持多种字符串类型,因为这本身并不一定是一种优点。)
为此,我想提出一个字符串理论,用于 GHC Haskell 的基础:即今天随 GHC 分发的核心引导库。这些包将为最终使生态系统的其余部分 Backpack 化设定基调。
但首先,我们正在对什么进行参数化?字符串并不那么简单...
一个关于文件路径(和操作系统字符串)的离题讨论
文件路径(FilePath
)是一种重要的 String
形式,实际上并不是 Unicode 字符串。POSIX 指定文件路径可以是任意的 C 字符串,因此,解码文件路径为 Unicode 的代码必须意识到底层的 ByteString
可能包含任意的、无法解码的无意义字符。更糟糕的是,甚至编码也可能不同:在 Windows 中,文件路径编码为 UTF-16(可能存在未配对的代理项),而在现代 Linux 环境中,编码由区域设置决定(base
使用 locale_charset
来确定如何解释文件路径;区域设置通常是 UTF-8,但不总是)。
因此,定义 type FilePath = String
实际上是非常值得怀疑的。已经有一个现有的提案,即 抽象 FilePath 提案,将 FilePath
转换为抽象类型,而不仅仅是 String
的类型同义词。不幸的是,这样的改变会破坏向后兼容性,因此需要一些时间来实现,因为 GHC 必须首先被教导在 FilePath
被错误使用时发出警告,以帮助人们发现他们的错误用法。
Backpack 提供了一种更加分散的方式来迎接未来:只需定义一个抽象签名,让 FilePath
依赖于它。低级别的签名可能看起来像这样:
signature FilePath where
-- | File and directory names, whose precise
-- meaning is operating system dependent. Files can be opened, yielding a
-- handle which can then be used to operate on the contents of that file.
data FilePath
-- | A C string (pointer to an array of C characters terminated by NUL)
-- representing a file path, suitable for use with the operating system
-- C interface for file manipulation. This exact type is architecture
-- dependent.
type CFilePath =
#ifdef mingw32_HOST_OS
CWString
#else
CString
#endif
withFilePath :: FilePath -> (CFilePath -> IO a) -> IO a
newFilePath :: FilePath -> IO CFilePath
peekFilePath :: CFilePath -> IO FilePath
-- peekFilePath >=> newFilePath should be identity
-- (this is tricky to achieve if FilePath is a
-- Unicode-based type, like String)
当然,你会希望所有的 FilePath
操作函数 都能被人们使用。
为了与现有生态系统保持兼容性,你可能会用 type FilePath = String
来实例化你的库。但是没有什么可以阻止你选择自己的抽象 FilePath
类型并使用它。
从这个意义上说,文件路径并不是唯一的;还有其他具有类似属性的字符串(例如环境变量的值):我习惯称之为 OSStrings(就像在 Rust 中称呼它们一样)。
参数化的轴
考虑到这一点,任何给定的库都可以参数化为三种“字符串变体”:
-
它们可以参数化为
FilePath
,适用于处理文件系统的模块(例如,System.Posix.Directory) -
它们可以参数化为
OSString
,因为它们涉及各种操作系统特定的 API(例如,System.Posix.Env) -
它们可以参数化为
String
,因为有时候一个字符串就是一个字符串。(例如,Text.ParserCombinators.ReadP)
有些库可能以多种方式进行参数化:例如,readFile
需要同时参数化 FilePath
和 String
。
为 Backpack 拆分 base(和友好组件)
由于技术原因,Backpack 不能用于对特定模块进行参数化;必须对整个库进行参数化。因此,Backpack 化核心库的副作用是它们将被拆分为多个较小的库。使用模块重导出,您仍然可以保留旧库作为 shims。
有四个 GHC 引导库将从对字符串的模块化中获益最多:
-
-
base-io
(System.IO
及其子模块;参数化为FilePath
和String
) -
还有一些其他模块可以字符串化,但边际效益可能不足以为每个模块制作一个新包(
Data.String
、System.Console.GetOpt
、Text.ParserCombinators.ReadP
、Text.Printf
)。每个模块只需要参数化为 String。 -
Control.Exception
、Text.Read
和Text.Show
是明确的非目标,它们目前与 GHC 的深层次连接太紧密,因此不太可能更改。
-
-
-
unix-env
(System.Posix.Env
,参数化为OSString
) -
unix-fs
(System.Posix.Directory
、System.Posix.Files
、System.Posix.Temp
,参数化为FilePath
) -
unix-process
(System.Posix.Process
,参数化为FilePath
和OSString
)
-
-
pretty(参数化为 String;然后 GHC 可以使用它而不是自己制作副本!)
-
process(参数化为 String、OSString 和 FilePath)
我提议的命名方案是,例如,unix
包继续使用传统的字符串进行实例化。然后 unix-indef
是一个未实例化的包(用户可以根据需要进行实例化,或者将决策传递给他们的用户)。一些包可能会选择还提供其包的 shims,这些 shims 使用特定类型进行实例化,例如 base-io-bytestring
,它将使用 ByteString
而不是 String
进行 base-io
的实例化,尽管这些名称可能会变得相当长,所以不确定这对你有多大用处。
The case of the Hash Array Mapped Trie : ezyang’s blog
来源:
blog.ezyang.com/2010/03/the-case-of-the-hash-array-mapped-trie/
高效的关联映射长期以来一直是函数式编程社区的圣杯。如果你在命令式语言中需要这样的抽象数据结构,那么毫无疑问地,你会使用哈希表。但是哈希表建立在破坏性更新之上,这使得它在纯代码中难以使用。
我们正在寻找的是一个严格更强大的关联映射,它实现了非破坏性更新(即“持久性”)。在 Haskell 的世界中,Data.Map 是一个相当引人注目的通用结构,只需要其键具有 Ord
类型类。对于能够清晰映射到机器大小整数的键来说,IntMap 是一个极快的纯函数数据结构,它在大端 Patricia tries 之上使用了位操作技巧。
其他函数式编程语言推广了自己的数据结构:Clojure 的许多关键数据结构都是由 Phil Bagwell 发明的,其中包括hash-array mapped trie(PDF),这些数据结构驱动了 Clojure 的持久性关联映射。
纸面上,这些实现具有以下的渐近性能:
-
Data.Map. 让 n 和 m 分别表示地图中的元素数量。O(log n) 查找、插入、更新和删除操作。O(n+m) 并集、差集和交集操作。
-
Data.IntMap. 让 n 和 m 分别表示地图中的元素数量和机器大小整数(例如 32 或 64)中的位数。O(min(n,W)) 查找、插入、更新和删除操作。O(n+m) 并集、差集和交集操作。
-
Hash array mapped trie. 让 n 表示地图中的元素数量。由于 Hickey's implementation 没有子树池或根重新调整,我们将在渐近性能中省略它们。O(log(n)) 查找、插入、更新和删除操作。未描述并集、差集和交集的实现。
不幸的是,这些数字实际上并没有告诉我们关于这些数据结构的实际性能有多少,因为关联的世界足够竞争,常数因子真的很重要。因此,我构建了以下基准测试:生成 N 个随机数,并将它们插入映射中。然后,在这些随机数中进行 N/2 次查找,并对另外 N/2 个未使用的数字进行查找(这将构成未命中)。竞争者是 IntMap 和 HAMT(Java 和 Haskell 中的实现)。初步结果表明,IntMap 比 Java HAMT 更快,Java HAMT 比 Haskell HAMT 快得多。
当然,这完全是胡说八道。
我转向 Clojure 邮件列表,并向他们展示了一个奇怪的(不正确的)结果:Haskell 的 IntMap 比 Clojure 内置的 HAMT 实现快了多达五倍。Rich Hickey 立即指出了我的方法论存在三个问题:
-
我使用的是 Java 的默认堆大小(公平地说,我也使用了 Haskell 的默认堆大小),
-
没有使用
-server
标志进行测试, -
我没有考虑 JVM 的基于配置文件的优化。
(还有一些关于随机数生成和交错的评论,但进一步测试表明这些成本可以忽略不计。)Rich 给了我一些新代码,使用 (apply hash-map list-of-vals)
构建哈希映射,在修复了一个问题后,Rich 只将 N/2 个条目插入哈希表,我继续前行。
通过改进的测试用例集,我得出了以下统计数据(源代码请查看此处的 IntMap criterion 测试,以及此博客文章的后记中的 Clojure 测试):
IntMap Java HAMT (32K-512K) Java HAMT (512K-32K)
32K .035s .100s .042s
64K .085s .077s .088s
128K .190s .173s .166s
256K .439s .376s .483s
512K 1.047s 1.107s 1.113s
然而,令人困惑的是,我重新实现的 HAMT 的 Haskell 版本 表现极差,即使在我用位操作技巧、GHC 的装箱和解箱折腾了自己之后,也比原版慢了三到四倍。然后,我有了一个顿悟:
public static PersistentHashMap create(List init){
ITransientMap ret = EMPTY.asTransient();
for(Iterator i = init.iterator(); i.hasNext();)
{
Object key = i.next();
if(!i.hasNext())
throw new IllegalArgumentException(String.format("No value supplied for key: %s", key));
Object val = i.next();
ret = ret.assoc(key, val);
}
return (PersistentHashMap) ret.persistent();
}
}
Hickey 可能是个棘手的家伙:他正在使用变异(请注意 asTransient
调用),以优化 (apply hash-map ...)
的调用!稍作调整后强制使用函数接口,Voila:
Haskell Clojure
128K 0.56s 0.33s
256K 1.20s 0.84s
512K 2.62s 2.80s
更加可比的性能(如果您仔细观察 JVM 的数字,它们从与 Haskell 大致相同的速度开始,然后随着 HotSpot 的启动而加快。)
不幸的是,在 Haskell 的世界中我不能使用类似的技巧。首先,GHC 没有基于运行时配置文件的优化。此外,虽然我确实可以在 GHC 中不安全地冻结单个数组(这是许多包中的标准操作过程),但我不能递归地冻结指向数组的数组,而不是遍历整个结构。因此,使用变异进行递归数据结构的快速构建对于 Haskell 来说仍然是不可能的......暂时是。
这个故事还在不断发展之中。特别是,我还需要:
-
进行更加细致的基准测试,区分插入、查找和其他操作的成本;并
-
在 Java 中实现 IntMap 并观察 JVM 对算法的影响,统一垃圾收集策略也将会很有启发性。
附言. 你可以在 Clojure 邮件列表 上查看基准测试的详细内容。这是用于测试 Java 的 HAMT 实现的测试代码。
首先是变异版本:
(ns maptest (:gen-class))
(defn mk-random-stream []
(let [r (new ec.util.MersenneTwisterFast)]
(repeatedly (fn [] (. r (nextInt))))))
(defn main [i]
(let [vals (vec (take (* i 2) (mk-random-stream)))
dvals (take (* i 2) (doall (interleave vals vals)))]
(dotimes [_ 10]
(time
(let [m (apply hash-map dvals)]
(reduce (fn [s k] (+ s (m k 0)))
0
(take i (drop (/ i 2) vals))))))))
(doseq [n (range 5 10)]
(let [i (* 1000 (int (Math/pow 2 n)))]
(println " I = " i)
(main i)))
这里是强制使用函数接口的备选主要定义:
(defn main [i]
(let [vals (vec (take (* i 2) (mk-random-stream)))]
(dotimes [_ 10]
(time
(let [m (reduce (fn [m x] (assoc m x x)) (hash-map) vals)]
(reduce (fn [s k] (+ s (m k 0)))
0
(take i (drop (/ i 2) vals))))))))
编译器、构建系统和包管理器的融合:ezyang 的博客
来源:
blog.ezyang.com/2015/12/the-convergence-of-compilers-build-systems-and-package-managers/
抽象。传统的编译器、构建系统和包管理器之间的抽象屏障越来越不适合用于 IDE、并行构建系统和现代源代码组织。像 go 和 rustc 这样的最新编译器配备了一个成熟的构建系统;语义化的构建系统如 Bazel 和 Gradle 也期望管理软件的打包。这是否意味着我们应该放弃这些抽象屏障?寻找能够适应这些用例的新接口似乎是值得的。
传统上,我们可以将编程语言的工具分为三部分来理解:
-
编译器接收单个源文件并将其转换为对象文件。(例如:
ghc -c
、go tool 6g
、javac
和gcc -c
。) -
构建系统接收一组源文件(及元数据),并将它们转换为最终的构建产品。它通过多次调用编译器来实现这一点。(例如:
go build
、Setup build
、make
、ant compile
。)通常情况下,构建系统还知道如何安装所需的构建产品。 -
包管理器接收一个包名称,并获取和构建该包及其依赖项,然后将它们安装到某个存储中。它通过调用每个包的构建系统来实现这一点。(例如:
cabal install
、cargo install
、maven package
。)
这种分离构成了一个抽象屏障,允许这些组件可以分别提供。例如,单个构建系统可以与多个不同的编译器一起工作(如 gcc 和 clang);反过来,编译器可以从用户的自定义构建系统中调用。一个库可以被打包成其本地语言的包管理器以及 Linux 发行版的打包系统;反过来,包管理器可能不关心库的实际构建过程。在今天的软件生态系统中,这些抽象屏障被广泛使用,效果显著!
然而,有越来越多的用例不能通过这些抽象屏障充分处理:
-
构建系统需要知道构建源文件的顺序;然而,这些信息的规范来源是源文件中的导入/包含声明。这些信息必须在构建系统内部复制,或者构建系统必须调用编译器以计算要使用的依赖关系图。无论如何,编译器不能只是一个简单的源文件到目标文件的转换器:它必须知道如何生成文件的依赖关系(例如,
gcc -M
)。除了Makefile
桩外,没有标准化的格式来存储这些信息。 -
依赖问题在模块依赖可以是循环时变得更加严重。一个构建系统必须知道如何解决循环依赖,可以通过一次编译模块的强连接组件,或者编译允许分开编译的“接口”文件。这是激发 Rust 开发者不使用单一源码编译器的问题之一。
-
最佳的并行化可以通过源文件上的细粒度依赖图实现。然而,实现并行化的最理想地点是包管理器,因为调用包管理器会导致编译大部分代码。因此,像 Bazel 这样的系统统一了构建系统和包管理器,以便可以在整个构建过程中实现并行处理。(另一个例子是 GHC 的构建系统,它按模块基础并行编译所有内置包。)
-
IDE 想要从编译器获取比
-c
风格接口更深入的信息。但它们不能直接调用编译器,因为通过构建系统 / 包管理器是唯一的以正确标志和环境调用编译器的方式。Go 的内置构建系统意味着它可以更轻松地提供像go oracle
这样的工具;否则,go oracle
需要能够适应外部构建系统。 -
某些语言特性对构建系统非常不友好;只有编译器具有足够的智能来管理构建过程。良好的例子包括宏(特别是可以访问文件系统的宏)、其他形式的编译时元编程和编译器插件。
因此,诱惑是将这些组件整合成一个完成所有工作的单一的巨型工具。这样做有很多好处:单一工具更容易开发,提供更统一的用户体验,并且不需要开发者指定不同组件之间的明确定义的 API。缺点呢?你无法替换巨型系统的部分组件。
我认为,即使面对这些特性,也值得考虑如何保持关注点的分离。不幸的是,我不知道正确的 API 是什么,但这里有一个草案提议:每个编译器和构建系统的编写者都应该有一个替代模式,让用户可以询问:“如何制作 $output
文件?” 这种模式返回 (1) 文件的依赖关系,和 (2) 制作它的方法。想法是将依赖查找逻辑放在编译器中(放置它的标准地方),同时让外部工具实际处理构建依赖关系。但这个提议还有很多细节没有涵盖。
您如何看待编译器、构建系统和包管理器的融合?您认为它们应该是巨型的吗?如果不是,您认为支持这些新用例的正确 API 是什么?我很想知道您的想法。
GHC 中弱指针和终结器的成本:ezyang 的博客
来源:
blog.ezyang.com/2014/05/the-cost-of-weak-pointers-and-finalizers-in-ghc/
弱指针和终结器对于许多类型的程序都是一个非常便利的功能。弱指针用于实现记忆表和解决某些类型的内存泄漏问题,而终结器则用于将“分配/释放”内存模型适配到垃圾回收语言中。当然,这些功能并非免费提供,因此人们可能会想知道在 GHC 中使用这两个(密切相关的)功能的代价是什么。在这篇博文中,我想解释一下在 GHC 运行时系统中如何实现弱指针和终结器,并描述通过使用它们而增加的额外开销。本文假定读者对运行时系统和复制垃圾回收的基本工作原理有一定的了解。
用户界面 API
弱指针的 API 在System.Mem.Weak中;总体而言,弱指针由一个键和一个值组成,其特性是,如果键存活,则值被视为存活。("简单"的弱引用只是键和值相同的引用。)弱指针还可以选择地与终结器关联,当对象被垃圾回收时运行。Haskell 的终结器不能保证运行。
在Foreign.ForeignPtr中的外部指针也具有附加 C 终结器的能力;即,可能在垃圾回收期间运行的函数指针。事实证明,这些终结器也是使用弱指针实现的,但是 C 终结器与 Haskell 终结器处理方式不同。
弱指针的表示
弱指针是一种特殊类型的对象,具有以下布局:
typedef struct _StgWeak { /* Weak v */
StgHeader header;
StgClosure *cfinalizers;
StgClosure *key;
StgClosure *value; /* v */
StgClosure *finalizer;
struct _StgWeak *link;
} StgWeak;
正如我们所见,我们有指向键和值的指针,以及单个 Haskell 终结器的独立指针(只是一个普通闭包)和 C 终结器的指针(其类型为StgCFinalizerList
)。还有一个用于将弱指针链接在一起的链接字段。实际上,当创建弱指针时,它被添加到幼儿园弱指针列表中(恰如其名为weak_ptr_list
)。截至 GHC 7.8,此列表是全局的,因此在分配新的弱指针时我们必须锁定全局锁,但是在 HEAD 中已经移除了该锁。
垃圾回收弱指针
突击测试!当我们对弱引用进行(轻微的)垃圾收集时,StgWeak
中的哪些字段被认为是指针,哪些字段被认为是非指针?正确答案是:只有第一个字段被认为是“指针”;其余字段在正常 GC 中被视为非指针。这其实是可以预料的:如果我们在 GC 期间将键和值字段处理为正常的指针字段,那它们根本就不是弱引用。
一旦垃圾收集完成(扣除所有弱引用),我们会遍历弱引用列表并检查键是否存活。如果存活,那么值和终结器应被视为存活,我们将它们标记为存活,并继续执行更多的垃圾收集。只要我们继续发现新的弱引用来处理,此过程将继续进行;然而,这只会发生在键和值不同的情况下(如果它们相同,则键必定已被 GC 处理)。存活的弱引用将从“旧”列表中移除,并放入下次存活弱引用的新列表中。
一旦没有新发现的存活指针,死指针列表将被收集在一起,并安排终结器(scheduleFinalizers
)。C 终结器在 GC 期间立即运行,而 Haskell 终结器则被批量处理并放入新创建的线程中运行。
还有一些有关如何处理终结器的细节(终结器也是堆对象,因此即使对象已经死亡,我们也必须保留终结器以供下一个 GC 使用),以及线程(弱引用的终结器可以保持线程的存活)。
统计成本
总结一下,以下是弱引用的额外成本:
-
分配弱引用需要获取全局锁(将在 GHC 7.10 中修复),并且消耗六个字(对于 Haskell 堆对象来说相当可观)。
-
在每次轻微 GC 期间,处理弱引用的时间与正在收集的所有代的弱引用列表大小成线性关系。此外,此过程涉及遍历一个链表,因此数据局部性并不好。这个过程可能会多次发生,尽管一旦确定了弱引用是存活的,就不会再次处理。当发现弱引用存活时,重新执行 GC 的成本仅仅是同步所有并行 GC 线程的成本。
-
您必须在 GC 和处理弱引用之间进行切换的次数取决于堆的结构。从一个根对象到达对象所需的最小弱链接数,并添加一个特殊的“弱链接”,从一个键到其依赖的弱值。然后,我们可以根据从根到对象的最小弱链接数分类对象:将其称为“弱距离”。假设给定的弱引用的弱距离为 n,则在轻微 GC 期间处理该弱引用需要 O(n)的时间。最大弱距离决定了我们需要重新执行 GC 的次数。
简而言之,当弱引用没有深度嵌套时,它们的成本是相当便宜的:你只需在每次垃圾回收时支付一次遍历你分配的所有指针的链表的成本。在最坏的情况下(弱链接的链条,其中每个弱指针的值直到我们在前一个迭代中发现其键是活动的时候才被认为是可达的),我们可能会花费二次时间处理弱指针。
一个静态类型的函数式程序员的诞生:ezyang 的博客
来源:
blog.ezyang.com/2011/03/the-creation-of-a-statically-typed-functional-programmer/
一个静态类型的函数式程序员的诞生
早在 2009 年初,我被 MIT 独立活动期间所影响;实际上,是两个影响。第一个是 6.184,在 Scheme 中重新开设的入门计算机科学课程——出于显而易见的原因。但我觉得这还不够:我记得当时觉得 Scheme 很有趣,但并不是我真正想编程的语言。第二个是 Anders Kaseorg 在我结束一个讲座 Introduction to Web Application Security 后的评论(作为 MIT 新生,我认为自己能够讲一些东西)。讲座的重点之一是所有关于类型的事情:也就是说,“字符串”并不能充分表达今天我们应用程序中流动的大多数文本的语义内容。Haskell 出现是为了让你的编译器确保你不会混淆 HTML 和纯文本。
某些事情一定是触动了什么。那年二月,我写道:
哇,Haskell 真漂亮。
有人回答道:
不要盯太久太阳,你的眼睛会被烧伤。
因此,一个静态类型的函数式程序员诞生了。
后记。 我在 Haskell 中的第一个应用是一个拉普拉斯求解器,通过它我也学到了单子(因为一个映射查找返回了一个Maybe
值,安德斯决定谈一谈 do-notation 和 bind 如何处理它是个好主意。也许我第一次听解释时并没有理解,但最终我确实让程序运行起来了。)
比特币的密码学:ezyang 的博客
想要了解比特币使用的密码学,对于普通人来说实际上是非常困难的,如果不直接查看比特币的来源的话。例如,opcode OP_CHECKSIG 明显是用来检查某些东西的签名……但没有明确说明它检查的是什么样的签名!(比特币中的操作码是什么?原来这个协议内建了一个非常巧妙的脚本系统用于构建交易。你可以在这里了解更多。)所以实际上,我在我的文章比特币不是去中心化的中弄错了一些事实上的细节,这是我在评论者 cruzer 声称,在密码哈希中断将只会降低挖矿难度,而不允许伪造交易时才意识到的。
所以我进行了研究并打开了比特币客户端的源代码。简短地说,我的论点的主要内容仍然相同,但是针对密码功能的假设攻击的细节则更加复杂——简单的选择前缀碰撞攻击是不够的。长篇大论?比特币在选择密码学方面做出了一些有趣的选择,本文的其余部分将探讨这些选择。比特币使用了两种哈希函数,SHA-256 和 RIPEMD-160,但它还使用了椭圆曲线 DSA(Elliptic Curve DSA)在 secp256k1 曲线上执行签名。C++ 实现使用了 Crypto++ 库进行挖矿,使用 OpenSSL 进行普通用途。阅读本文的结尾,您将更好地理解比特币如何利用密码学模拟货币的属性。
比特币中的签名
在许多方面,这是比特币中的传统密码学。我们提出问题:“我们怎么知道 Alice 被授权将 100 比特币转给 Bob”,任何使用公钥密码学的人都知道答案是:“Alice 使用她的私钥对交易进行签名,并将此签名发布给比特币网络验证,使用她的公钥。” 这个签名是在 secp256k1 椭圆曲线上进行的(key.h
):
CKey()
{
pkey = EC_KEY_new_by_curve_name(NID_secp256k1);
if (pkey == NULL)
throw key_error("CKey::CKey() : EC_KEY_new_by_curve_name failed");
fSet = false;
}
比特币社区已经讨论了椭圆曲线的选择,似乎这个特定的曲线是为了可能的未来速度优化而选择的。
然而,就像所有公共密码系统一样,比特币并不会对整个交易消息进行签名(那样将会非常昂贵);相反,它对消息的密码哈希进行签名(script.cpp
):
uint256 SignatureHash(CScript scriptCode, const CTransaction& txTo,
unsigned int nIn, int nHashType)
{
// ...
// Serialize and hash
CDataStream ss(SER_GETHASH);
ss.reserve(10000);
ss << txTmp << nHashType;
return Hash(ss.begin(), ss.end());
}
这个哈希是 SHA-256 的双重应用:
template<typename T1>
inline uint256 Hash(const T1 pbegin, const T1 pend)
{
static unsigned char pblank[1];
uint256 hash1;
SHA256((pbegin == pend ? pblank : (unsigned char*)&pbegin[0]), (pend - pbegin) * sizeof(pbegin[0]), (unsigned char*)&hash1);
uint256 hash2;
SHA256((unsigned char*)&hash1, sizeof(hash1), (unsigned char*)&hash2);
return hash2;
}
好的,那我们如何破解呢?有几种方法:
-
我们可以破解底层的椭圆曲线加密,通过解决离散对数问题(这是量子计算机可以做的事情)或者破解选择的特定椭圆曲线来完成。在这个领域的大部分研究都是为了找出特定椭圆曲线中的漏洞,因此后者更有可能。
-
我们可以破解底层的加密哈希函数。在这种情况下,我们拥有一个我们想攻击的用户的已知签名,并生成另一个输入交易,使其哈希值相同,这样我们就可以重放先前的签名。这样的攻击将取决于比特币处理的序列化交易的形式:它对交易进行了一定量的处理,因此攻击者需要一些工作;然而,由于交易包括允许构建复杂交易的脚本系统,攻击者在构建这样一个输入时会有一定的余地。这种攻击无法对单次使用地址起作用,因为没有为重放而存在的这种签名。
破解签名算法需要选择性伪造攻击或更强大的攻击,这意味着任意交易可能被伪造并输入系统中。这将是一个完整的系统破解。对于签名重放攻击,可以通过添加客户端检查来确保相同的签名从未用于两个不同的交易,以增加一些保护措施。
比特币中的哈希算法
这是比特币中技术上新颖的加密使用方式,用来回答一个问题:“只有传统签名,爱丽丝可以无限次重新发送她实际上并不存在的比特币,有效地创建交易树的多个分支。我们如何防止这种情况?” 比特币提供的答案是:“交易链由解决计算难题(挖矿)的结果进行认证,一旦一个交易被包含在一个区块中确认,客户端更倾向于具有最高计算成本的交易链,使其他分支上的任何其他支出无效。” 即使你不相信去中心化货币,你也必须承认,这是相当优雅的解决方案。
更详细地说,计算难题本质上是对哈希函数的第一前像攻击的简化版本。矿工们得到一组解决方案哈希(所有零的哈希到目标哈希),并且需要找到一个具有特定结构的消息(一个区块链加上一个随机数),使其哈希为这些哈希中的一个。
在这种情况下,很容易看到哈希函数的首影像攻击(或者可能是稍弱攻击)意味着这个哈希问题可以更快地解决。如果对手知道这种方法但网络中没有人知道,这是一个安全漏洞;他可以轻易地占据超过 50%的网络计算能力并分裂区块链(记住:这是指数杠杆。我不在乎比特币网络有多少 Teraflops 的计算能力——聪明的算法总是赢)。在更严重的破坏中,他可以重建整个区块链来重写历史,执行足够的“计算工作”以说服网络上的其他客户端他的历史是真实的。这种攻击场景是众所周知的,并且在这里描述。请注意,一旦该方法被广泛传播并被其他矿工采用,计算能力的失衡将再次得到解决,并且哈希问题的难度可以相应地调整。
比特币地址
类似于 PGP 系统,比特币用户生成公钥和私钥对用于签名,但也发布一个便捷的“指纹”,实际上是一个 RIPEMD-160 哈希,供人们用作可以发送比特币到的标识符(util.h
):
inline uint160 Hash160(const std::vector<unsigned char>& vch)
{
uint256 hash1;
SHA256(&vch[0], vch.size(), (unsigned char*)&hash1);
uint160 hash2;
RIPEMD160((unsigned char*)&hash1, sizeof(hash1), (unsigned char*)&hash2);
return hash2;
}
与像 PGP 这样的系统不同,比特币没有公钥分发机制:RIPEMD-160 哈希对于公钥是规范的。因此,如果在此密钥空间中发现碰撞,某人可能会从别人的地址中花费比特币。这种攻击场景在此处描述。这种攻击通过比特币用户被鼓励为他们的钱包使用许多地址以及其他使用此碰撞能力的方式可能对攻击者更有利(如上所述)来减轻。
结论
如我们所见,多种不同的密码原语在集成中用于指定比特币协议。一个原语的妥协不一定会影响系统的其他部分。然而,所有这些原语都被硬编码到比特币协议中,因此我在我以前的文章中提出的论点仍然成立。
递归与归纳的区别:ezyang 的博客
来源:
blog.ezyang.com/2013/04/the-difference-between-recursion-induction/
递归和归纳密切相关。当你在初级计算机科学课程中首次学习递归时,你可能被告知使用归纳来证明你的递归算法是正确的。(为了本文的目的,让我们排除像Collatz 猜想中那样不明显终止的复杂递归函数。)归纳看起来非常像递归:这种相似性来自于归纳假设看起来有点像你正在证明的定理的“递归调用”的结果。如果一个普通的递归计算返回普通的值,你可能会想知道一个“归纳计算”是否返回证明项(根据柯里-霍华德对应,可以将其视为一个值)。
然而,事实证明,当你从范畴论的角度来看递归和归纳时,它们并不等价!直观地说,区别在于当你进行归纳时,你进行归纳的数据类型(例如数字)出现在类型级别,而不是术语级别。用范畴论者的话来说,递归和归纳都有关联的初等代数,但载体集和 endo 函子是不同的。在这篇博文中,我希望精确阐明递归和归纳之间的区别。不幸的是,我需要假设读者某些对初等代数的了解:如果你不知道折叠和初等代数之间的关系,请查看这篇列表在初等代数形式中的导出。
处理广义抽象无意义时,最重要的第一步是使用具体示例!因此,让我们选择最简单的非平凡数据类型之一:自然数(我们的示例在可能的情况下以 Coq 和 Haskell 编写):
Inductive nat : Set := (* defined in standard library *)
| 0 : nat
| S : nat -> nat.
data Nat = Z | S Nat
自然数是一个很好的例子:即使是F-代数的维基百科文章也使用它们。简言之,一个 F-代数(有时简称为“代数”)有三个组成部分:一个(endo)函子f
,一个类型a
和一个减少函数f a -> a
。对于自然数的简单递归,我们需要定义一个生成自然数的函子NatF
;然后我们的类型a
是Nat
,减少函数是类型NatF Nat -> Nat
。该函子定义如下:
Inductive NatF (x : Set) : Set :=
| F0 : NatF x.
| FS : x -> NatF x.
data NatF x = FZ | FS x
本质上,取原始定义,但用多态变量替换任何类型的递归出现。作为练习,展示NatF Nat -> Nat
是存在的:它是() -> Nat
和Nat -> Nat
的(共)积。这个代数的初始性意味着对于任意类型x
的NatF x -> x
的函数可以在Nat -> x
的折叠中使用:这个折叠是从初始代数(NatF Nat -> Nat
)到另一个代数(NatF x -> x
)的同态。关键是自然数的初始代数包括了一个关于集合的自函子。
现在让我们来看看归纳的 F-代数。作为第一次尝试,让我们尝试使用相同的 F-代数,并看看是否存在与“归纳类型”相适应的适当同态(这里我们只能用 Coq 编写,而不能用 Haskell)。假设我们试图证明某个命题P : nat -> Prop
对所有自然数都成立;那么最终证明项的类型必须是forall n : nat, P n
。现在我们可以写出代数的态射:NatF (forall n : nat, P n) -> forall n : nat, P n
。但这个“归纳原理”既是无意义的,也不是真的:
Hint Constructors nat NatF.
Goal ~ (forall (P : nat -> Prop), (NatF (forall n : nat, P n) -> forall n : nat, P n)).
intro H; specialize (H (fun n => False)); auto.
Qed.
(旁注:你可能会说这个证明失败了,因为我提供了一个在所有自然数上都为假的谓词。但归纳仍然“有效”,即使你试图证明的谓词是假的:你应该在尝试提供基础情况或归纳假设时失败!)
我们退后一步,现在想知道,“那么,正确的代数是什么?”很明显,我们的自函子是错误的。幸运的是,我们可以通过检查自然数归纳原理的类型来得出正确自函子的线索:
(* Check nat_ind. *)
nat_ind : forall P : nat -> Prop,
P 0 -> (forall n : nat, P n -> P (S n)) -> forall n : nat, P n
P 0
是基础情况的类型,forall n : nat, P n -> P (S n)
是归纳情况的类型。就像我们为自然数定义了NatF nat -> nat
一样,它是zero : unit -> nat
和succ : nat -> nat
的组合,我们需要定义一个单一的函数,它结合了基础情况和归纳情况。这似乎很困难:结果类型并不相同。但依赖类型来拯救:我们正在寻找的类型是:
fun (P : nat -> Prop) => forall n : nat, match n with 0 => True | S n' => P n' end -> P n
你可以这样阅读这个类型:我将为任意的n
给你一个类型为P n
的证明对象。如果n
是 0,我将为你提供这个证明对象而不需要进一步的帮助(True -> P 0
)。然而,如果n
是S n'
,我将要求你提供P n'
(P n' -> P (S n')
)。
我们快要接近了。如果这是一个初始代数的态射,那么函子IndF
必须是:
fun (P : nat -> Prop) => forall n : nat, match n with 0 => True | S n' => P n' end
这个函子是什么类别上的?不幸的是,这篇文章和我的大脑都没有足够的空间来进行严格的处理,但大致上可以将该类别视为自然数索引的命题。这个类别的对象形式为forall n : nat, P n
,类别的态射形式为forall n : nat, P n -> P' n
。[1] 作为练习,展示恒等和复合存在,并遵守适当的法则。
即将发生一些惊人的事情。我们已经定义了我们的函子,并且现在正在寻找初始代数。就像对自然数的情况一样,初始代数由函子的最小不动点定义:
Fixpoint P (n : nat) : Prop :=
match n with 0 => True | S n' => P n' end.
但这只是 True
!
Hint Unfold P.
Goal forall n, P n = True.
induction n; auto.
Qed.
绘制我们的图表:
我们范畴的代数(向下箭头)对应于归纳论证。因为我们的态射形式为 forall n, P n -> P' n
,所以不能仅仅从 forall n, P n
得出 forall n, P' n
;然而,初始代数的存在意味着当我们有一个代数 forall n, IndF n -> P n
时,True -> forall n, P n
。令人惊叹!(顺便提一下,Lambek 引理表明 Mu P
同构于 P (Mu P)
,因此初始代数实际上是非常非常平凡的。)
总结:
-
自然数递归涉及到与函子
unit + X
对应的 F-代数,这些代数定义在集合范畴上。这个函子的最小不动点是自然数,而由初始代数诱导的态射对应于折叠。 -
自然数归纳涉及到与函子
fun n => match n with 0 => True | S n' => P n'
对应的 F-代数,这些代数定义在自然数索引命题的范畴上。这个函子的最小不动点是True
,而由初始代数诱导的态射确立了归纳证明命题的真实性。
所以,下次有人问你归纳和递归的区别是什么,告诉他们:归纳只是由索引命题上的初始代数诱导的唯一同态,有什么问题吗?
特别感谢 Conor McBride,在 ICFP 会议上向我解释了这个问题。我答应要写博客,但是忘记了,最终不得不重新推导一遍。
[1] 关于态射的另一个合理表述是 (forall n : nat, P n) -> (forall n : nat, P' n)
。然而,在这个范畴中的态射太过强大:它们要求你对所有的n去证明结果… 这需要归纳,但这种方式并不是重点。此外,这个范畴是命题的普通范畴的子范畴。
弱映射和私有符号的二元性:ezyang 的博客
来源:
blog.ezyang.com/2013/03/duality-of-weak-maps-and-private-symbols/
来自 ECMAScript TC39 会议记录文件
我想讨论马克·米勒指出的一个有趣的二元性,即弱映射和私有符号之间的二元性,它们本质上是不同语言特性!
弱映射是一种普通的关联映射,其特点是如果任何条目的键变得不可访问,那么值也将变得不可访问(尽管您必须忽略值本身对键的引用!)弱映射具有多种用途,包括记忆化,我们希望记住计算的结果,但仅当可能再次请求时!弱映射支持get(key)
和set(key, value)
操作。
私有符号是对象上字段的不可伪造标识符。符号很有用,因为它们可以“新鲜”生成;也就是说,它们确保不会与对象上已有的字段冲突(而使用 _private_identifier_no_really
可能会不够幸运);私有符号有额外的规定,即在不具备符号的情况下无法发现它存在于对象上—例如,在枚举对象属性时,对象将拒绝透露私有符号的存在。可以创建一个私有符号 psym
,然后像普通属性名一样使用它来获取(obj[psym]
)和设置(obj[psym] = value
)值。
要了解它们为何相同,请使用私有符号来实现弱映射,反之亦然(警告,前面是伪代码):
function WeakMap() {
var psym = PrivateSymbol();
return {
get: function(key) { return key[psym]; },
set: function(key, value) { key[psym] = value; }
}
}
function PrivateSymbol() {
return WeakMap();
}
// pretend that get/set are magical catch-all getters and setters
Object.prototype.get = function(key) {
if (key instanceof PrivateSymbol) { return key.get(this); }
else { return this.old_get(key); }
}
Object.prototype.set = function(key, value) {
if (key instanceof PrivateSymbol) { return key.get(this, value); }
else { return this.old_set(key, value); }
}
特别注意,枚举弱映射的所有条目是没有意义的;这样的枚举会因垃圾收集器的运行而任意更改。
如果你更仔细地观察这一点,会发现有一些非常有趣的事情正在发生:弱映射和私有符号的实现策略是相反的。对于弱映射,你可能想象一个类似于实际映射的数据结构,它从键到值的映射(加上一些垃圾收集的技巧);而对于私有符号,你期望的是将值存储在对象本身上。也就是说,如果我们说“WeakMap = PrivateSymbol”和“key = this”,那么主要的区别在于关系是存储在 WeakMap/PrivateSymbol 上,还是存储在 key/this 上。WeakMap 暗示前者;PrivateSymbol 暗示后者。
其中一个实现比另一个更好吗?如果系统中的对象是不可变的或不能任意扩展的,那么私有符号的实现可能是不可能的。但如果两种实现都可能,那么哪种更好取决于相关对象的生命周期。垃圾收集弱引用是一件昂贵的事情(它的效率远低于普通的垃圾收集),因此如果你可以通过正常的垃圾收集使你的弱映射死去,那就是一种胜利。因此,最好将映射存储在生命周期较短的对象上。在记忆表的情况下,键将比映射更短暂,这导致了一个非常奇怪的结果:对于弱映射的最佳实现策略根本不涉及创建映射!
不幸的是,与许多优雅的结果一样,ECMAScript 规范的其他部分复杂性导致了一些困难。特别是,“只读弱映射”意味着什么完全不清楚,而“只读私有符号”却有明显的含义。此外,将这两个相当不同的概念合并为一种语言可能只会使 web 开发人员感到困惑;这是一个提案过于巧妙以至于不利于自身的情况。最后,关于如何将私有状态与代理结合仍在进行讨论。这个提案被引入来解决这个问题的一个特定方面,但据我们了解,它只解决了一个具体的子问题,并且只有在相关的代理是膜时才有效。
编辑-重新编译管理器:ezyang 的博客
编辑-重新编译管理器
我经常看到的一个普遍观点是,有太多特定于某种语言的包管理器,我们应该使用发行版的包管理器。例如,我查看了最近的HN 讨论,确实有第三条评论在这个(非常)老生常谈的问题上。(但是 等等! 还有 更多。)但很少觉得在这些讨论中有任何前进。
这是我的假设:这两个阵营的人彼此之间的交流不畅,因为术语“包管理器”已经被过载使用,具有两层含义:
-
对于最终用户而言,它表示一个安装管理器,主要负责安装一些有用的软件,以便他们可以使用。这里的软件通常安装一次,然后长时间使用。
-
对于开发者来说,它表示一个编辑-重新编译管理器:一个软件,用于让您接管正在开发的软件项目,并(重新)构建它,尽快完成。安装软件包只是一种手段,但不是目的。
显然,虽然这两个用例有一些共享的机制,但优先级却是截然不同的:
-
最终用户并不关心一个软件包是如何构建的,只关心他们想要安装的东西已经构建好了。对于开发者来说,重新构建的速度是一个至关重要的问题。为了实现这样的性能,需要对编程语言的结构有深刻的理解。
-
最终用户通常只想要任何软件的一个版本。开发者使用多个版本,因为这是与多样化、快速更新、分散化的软件包生态系统打交道的成本。
-
最终用户关心的是“能够正常工作”:因此,一个发行版的包管理器强调对整个软件栈的控制(通常需要 root 权限)。开发者关心的是对他们正在重新构建的软件的灵活性,对一些设置需要也无所谓。
所以,下次有人说有太多特定于某种语言的包管理器时,心里可以将“包管理器”替换为“编辑-重新编译管理器”。抱怨是否仍然有意义?也许有,但不是通常意义上的:他们可能实际上是在倡导这两个世界之间的接口。这似乎是一个既可行又值得做的项目。
编程语言包管理的根本问题:ezyang 的博客
来源:
blog.ezyang.com/2014/08/the-fundamental-problem-of-programming-language-package-management/
为什么有这么多该死的包管理器?它们横跨操作系统(apt、yum、pacman、Homebrew)以及编程语言(Bundler、Cabal、Composer、CPAN、CRAN、CTAN、EasyInstall、Go Get、Maven、npm、NuGet、OPAM、PEAR、pip、RubyGems 等等等等)。“普遍认为,每一种编程语言都必须需要一个包管理器。” 是什么致使每一种编程语言都跳入这个悬崖?我们为什么不能,你知道的,重复利用一个现有的包管理器?
你可能想到了几个理由,为什么试图使用 apt 管理你的 Ruby gems 会以泪水收场。“系统和语言包管理器完全不同!分发是经过审查的,但对于 GitHub 上投放的大多数库来说,这完全不合理。分发移动速度太慢了。每种编程语言都不同。不同的社区彼此之间不交流。分发全局安装软件包。我想控制使用哪些库。” 这些理由都是正确的,但它们错过了问题的本质。
编程语言包管理的根本问题在于其去中心化。
这种去中心化始于包管理器的核心前提:即安装软件和库,否则这些软件和库将无法在本地使用。即使有一个理想化的集中式分发来管理这些软件包,依然涉及到两个主体:分发和构建应用程序的程序员。然而,在现实生活中,库生态系统进一步分裂,由众多开发者提供的各种软件包组成。当然,这些软件包可能都被上传并在一个地方索引,但这并不意味着任何一个作者知道其他任何一个软件包的情况。然后就有了 Perl 世界所称的 DarkPAN:可能存在的无法计数的代码行,但我们对此一无所知,因为它们锁在专有的服务器和源代码仓库中。只有当你完全控制你应用程序中的所有代码行时,去中心化才能避免...但在那种情况下,你几乎不需要一个包管理器,对吧?(顺便说一句,我的行业朋友告诉我,对于像 Windows 操作系统或 Google Chrome 浏览器这样的软件项目来说,这基本上是强制性的。)
去中心化系统很难。真的非常难。除非你根据此设计你的包管理器,否则你的开发者们一定会陷入依赖地狱。解决这个问题没有一种“正确”的方式:我至少可以辨认出在新一代包管理器中有三种不同的方法来处理这个问题,每一种方法都有其利与弊。
固定版本。 或许最流行的观点是,开发者应该积极地固定软件包的版本;这种方法由 Ruby 的 Bundler、PHP 的 Composer、Python 的 virtualenv 和 pip 倡导,一般来说,任何自称受到 Ruby/node.js 社区启发的包管理器(例如 Java 的 Gradle、Rust 的 Cargo)都采用这种方法。构建的可复现性至关重要:这些包管理器通过简单地假装一旦固定了版本,生态系统就不存在了来解决去中心化问题。这种方法的主要好处在于,你始终控制着你正在运行的代码。当然,这种方法的缺点是,你始终控制着你正在运行的代码。一个很常见的情况是将依赖固定下来,然后就把它们忘记了,即使其中有重要的安全更新。保持捆绑依赖项的最新状态需要开发者的时间——通常这些时间都花在其他事情上(比如新功能)。
稳定的发行版。 如果捆绑要求每个应用程序开发者花时间保持依赖项的最新状态并测试它们是否与他们的应用程序正常工作,我们可能会想知道是否有一种方法可以集中这种努力。这导致了第二种思路:集中管理软件包仓库,创建一组已知能良好协作的软件包,并且将获得 bug 修复和安全更新,同时保持向后兼容性。在编程语言中,这种模式并不常见:我所知道的只有 Anaconda 用于 Python 和 Stackage 用于 Haskell。但是如果我们仔细观察,这个模型与大多数操作系统发行版的模型完全相同。作为系统管理员,我经常建议用户尽可能使用操作系统提供的库。他们不会在我们进行发布升级之前采用不向后兼容的更改,同时您仍将获得您的代码的 bug 修复和安全更新。(您将无法得到最新的炫酷功能,但这与稳定性本质上是相矛盾的!)
拥抱去中心化。 直到现在,这两种方法都抛弃了去中心化,需要一个中央权威,无论是应用开发者还是分发管理者,来进行更新。这是不是舍弃孩子而保留水中的?中心化的主要缺点是维护稳定分发或保持单个应用程序更新所需的大量工作。此外,人们可能不会期望整个宇宙都能彼此兼容,但这并不能阻止某些软件包的子集彼此一起使用。一个理想的去中心化生态系统将问题分布到参与系统的每个人身上,以确定哪些软件包的子集能够共同使用。这也引出了编程语言包管理的根本未解之谜:
我们如何创建一个能够运作的去中心化包生态系统?
这里有几件事情可以帮助:
-
更强的依赖封装。 依赖地狱之所以如此阴险,其中一个原因是一个软件包的依赖通常是其外部面向 API 的不可分割的一部分:因此,依赖的选择不是一个局部选择,而是一个全局选择,影响整个应用程序。当然,如果一个库在内部使用某些库,但这个选择完全是实现细节,这不应该导致任何全局约束。Node.js 的 NPM 将这种选择推向其逻辑极限:默认情况下,它根本不会对依赖进行去重,使每个库都拥有其依赖的副本。虽然我对复制所有内容(它在 Java/Maven 生态系统中确实存在)有些怀疑,但我完全同意保持依赖约束局部化可以提高可组合性。
-
推进语义化版本控制。 在去中心化系统中,图书馆作者提供准确的信息尤为重要,以便工具和用户能够做出知情决策。虚构的版本范围和艺术化的版本号增加了已经存在的难题(正如我在上一篇文章中提到的)。如果你可以强制执行语义化版本控制,或者更好地说,放弃语义版本并记录真实的、类型级的接口依赖关系,我们的工具可以做出更好的选择。在去中心化系统中信息的黄金标准是,“软件包 A 与软件包 B 兼容”,这种信息通常很难(或者对于动态类型系统来说是不可能的)计算。
-
中心化是一种特例。 分布式系统的要点是每个参与者都可以为自己选择合适的策略。这包括维护自己的中央权威,或者推迟到别人的中央权威:中心化只是一种特例。如果我们怀疑用户将尝试创建自己的操作系统风格的稳定发行版,我们需要给予他们相应的工具……并且让这些工具易于使用!
长期以来,源代码控制管理生态系统完全集中在中心化系统上。分布式版本控制系统如 Git 从根本上改变了这一格局:尽管对于非技术用户而言,Git 可能比 Subversion 更难使用,但去中心化的好处却是多样化的。包管理的 Git 尚不存在:如果有人告诉你包管理问题已解决,只需重新实现 Bundler,我恳求你:也要考虑去中心化!
类型编程的入门药物:ezyang 的博客
来源:
blog.ezyang.com/2010/08/the-gateway-drug-to-type-programming/
David Powell 提问,
看起来关于每个 [类型扩展] 都有相当详细的信息,当你不确定从哪里开始时,这可能会让人感到不知所措。 我想知道这些扩展如何相互关联;它们解决了相同的问题吗,还是它们是互斥的?
由于只使用了 GHC 的一部分类型扩展(其中许多只是因为编译器告诉我要添加),我不幸地对回答这个问题一窍不通。 在我特意添加语言扩展的情况下,大多数时候都是因为我在遵循某些特定的配方需要那种类型。(前者的例子包括 FlexibleInstances、MultiParamTypeClasses 和 FlexibleContexts;后者的例子包括 GADTs 和 EmptyDataDecl)。
然而,有一个语言扩展,我发现自己越来越依赖并进行实验——你可以称之为我进入类型级编程的入门药物。 这个扩展就是 Rank2Types
。(Tim Carstens 对这个功能似乎同样着迷。)
这个功能对我如此有力的原因是,它让我能够在命令式代码中经常看到的不变量进行编码: 当一个资源被释放时,你不应再使用它。 无论是内存、文件还是网络连接,资源句柄无处不在。 但通常你只能写:
FILE *fh = fopen("foobar.txt", "r");
fread(buf, sizeof(char), 100, fh);
fclose(fh);
// ...
fread(buf, sizeof(char), 2, fh); // oops
所以,你依赖于文件句柄在足够小的范围内可用,以便明确是否使用不正确,或者如果句柄在全局上下文中可用,你会添加运行时检查以确保它尚未关闭,并希望没有人在代码千行之外搞砸它。
所以在我意识到我实际上可以在静态情况下强制执行这一点时,我感到非常兴奋。 还有哪些不变量可以从运行时移到编译时? 幸运的是,我所在的系统提供了更多的类型级别不变量强制执行的机会,从“已释放的资源不能被重用”到“绑定到一个资源的组件不应与另一个资源混合使用”和“前一规则的例外:组件可以用于另一个资源,但前提是目标资源来自源资源,并且你需要调用一个翻译函数。” 这些都是相当复杂的不变量,当我发现我能够在类型系统中编码它们时,我感到非常高兴。 实际上,这是一个转折点: 我已经超越了简单的类型,进入了类型编程。
那么,你如何发现你进入编程的入门药物?我觉得现在有两种方式:
-
认为所有类型系统特性和扩展本质上都是有用的,研究每一个以了解它们的能力和明显的用例,并希望在某个时候你能够足够了解这些基本元素以开始将它们组合起来。(对于许多其他事情而言,我觉得了解基本原理是真正理解一个系统的唯一途径,但我个人觉得这种方法非常令人畏惧。)
-
认识任何给定类型系统特性和扩展的经典用例,并积累像食谱一样的类型系统可能性库。偶然发现一个确切的使用案例,实施它,然后开始在边缘处摆弄,以扩展你能做的事情。(这就是我沉迷其中的方式,但也让我对方法论——作为一种普遍的思维框架而非孤立的巧思实例——感到困惑。)
实际上,这似乎与任何编程语言的学习过程非常相似。我想了解几种类型的学习材料:
-
一个全面的类型级编码的食谱,其中包含通常在运行时检查的不变量。它将展示低技术的、运行时验证的程序,然后展示将不变量移至类型所需的抽象和转换。它将在统一皮肤下收集所有各种文献探索的各种类型扩展的提议用例,一种模式书的形式。Oleg 的作品目录是个很好的起点。
-
当我在表达式中重复使用类型变量,比如
Foo a -> Foo a
,我已经声明左边的任何类型右边也必须相同。你可能通常将a
关联到像Int
或Char
这样的普通类型,并将Foo
视为某种容器。但我们可以在这个位置放入更奇怪的类型。如果Foo
使用a
作为幻影类型,我可以使用空类型来区分一组固定的类型,而无需为Foo
提供相应的值。如果我使用Rank2Types
让a
绑定到另一个全称量化类型forall b. b
,我有一个可以传递但无法伪造的唯一标签。这里实际上发生了什么?“类型即命题”(Curry-Howard)的观点对此有何解释? -
哪些类型编程会产生可管理的错误消息,哪些类型编程会导致臭名昭著的错误消息?当我首次着手设计 API 时,Galois 的一位工程师警告我:“如果为了简化类型系统而牺牲一些静态分析,那就去做吧。像类型级数字这样的东西不值得。”也许我已经在灌木丛中迷失了!
我确信已经存在一些这样的文献,并且很想看看它们。带来类型吧!
GHC 调度器:ezyang 的博客
我想谈谈 GHC 线程调度的一些细节,这些细节是在为 GHC 实现步幅调度的过程中发现的。大多数选择仅仅是实现细节,并不属于任何规范的一部分。虽然这些选择不应该依赖,但了解它们是值得的,因为许多这些细节是通过许多性能 bug、基准测试和其他斗争而积累起来的。在本文中,我将尝试提供一些历史性见解,解释为什么会做出许多选择。这些见解应该适用于任何希望实现绿色线程的系统,这些线程比传统的操作系统线程使用的内存少。由于篇幅限制,我不会讨论 STM 或者 sparks(尽管它们也非常有趣)。
线程的解剖
首先,我想先讨论一些关于运行时系统的简要背景,并指出一些可能不直观的设计选择。在 GHC 中,线程由 TSO(线程状态对象)表示,即 includes/rts/storage/TSO.h
中的 StgTSO
结构体。 [1] 在 Haskell 中,TSO 可以作为 ThreadId
对象进行传递。结构体名称前的 Stg
表示 TSO 像 Haskell 中的其他闭包一样会被垃圾回收。TSO 和与之分配的栈(STACK)构成线程的主要内存开销。默认的栈大小由 GC 标志 -ki
控制,默认为 1k。 [2] 线程由 Capabilities 运行,可以将其视为 GHC 管理的虚拟核心。Capabilities 又映射到真正的操作系统线程或任务,尽管我们不会详细讨论它们。
作为被垃圾回收的对象对 TSO 有两个主要影响。首先,TSO 不是 GC 根,因此如果没有任何东西持有它们(例如死锁的情况下),它们将会被 GC 回收,并且它们的空间在执行完成后不会自动回收。 [3] 通常情况下,TSO 将由 Capability 的运行队列(一个 GC 根)保留,或者在某些并发变量的等待线程列表中,例如 MVar。其次,TSO 必须被视为可变对象,因此它们受到生成式垃圾回收器中任何可变对象所需的传统 GC 写屏障的约束。 [4] dirty
位跟踪 TSO 是否已被修改;当线程运行时或者修改 TSO 的任何指针字段时,它总是被设置。setTSOLink
和 setTSOPrev
设置的两个字段对调度器特别重要。
运行队列
运行队列是调度程序的核心,因为任何可运行的线程在调度程序实际弹出它并运行之前都会进入运行队列。每个能力有一个 rts/Capability.h
(在旧时代,存在全局运行队列,但对于多线程进程性能表现不佳),它被实现为一个双向链表 run_queue_hd
和 run_queue_tl
。[6] 头部和尾部指针意味着队列实际上是一个双端队列:这很重要,因为调度程序通常必须处理以某种方式中断的线程,并应该让这些线程重新回到队列上。链接本身位于 TSO 上,并通过 setTSOLink
和 setTSOPrev
进行修改,因此修改队列会使涉及的 TSO 变脏。[7] 否则,运行队列完全归调度程序所有。如果存在空闲的能力,并且我们的运行队列中有多于一个线程,线程将被推送到其他队列中使用 schedulePushWork
。
线程被放在 前 (pushOnRunQueue
) 的情况包括:
-
发生堆栈溢出;
-
发生堆溢出;[8]
-
任务尝试运行一个线程,但它是 绑定,而当前任务不是正确的任务;
-
线程与黑洞关联(正在评估的 thunk),并且另一个可能在另一个能力上的线程已经阻塞在其评估上(见 ticket #3838);
-
在线程化运行时,如果一个线程被中断,因为另一个能力需要执行停止世界 GC(参见提交
6d18141d8
); -
在非线程化运行时,当一个等待 IO 的线程取消阻塞时。
线程在 后 (appendToRunQueue
) 放置的情况包括抢占或是新线程的情况;特别是,如果
-
线程通过上下文切换标志被抢占(例如来自另一个线程的消息,定时器触发,线程协作性地放弃等;另请参阅 [8] 了解这与堆溢出的交互方式);
-
它是一个新线程(因此大量线程创建不会饿死旧线程,请参见
conc004
和提交05881ecab
); -
线程变为非阻塞状态;
-
线程被迁移到另一个能力(尽管在这种情况下,队列本来就是空的);
-
线程完成,但出于某些原因我们需要保留它(这与内部调用相关,尽管我不完全确定具体情况;如果您知道,请告诉我!)
结论
GHC 调度器相当复杂!大部分当前行为是针对特定问题而创建的:正确的选择并不显而易见!我希望本文能成为任何未来对 GHC 调度器感兴趣的黑客的宝贵参考,以及对需要为其运行时系统实现调度器的其他人的有价值参考。大部分历史数据来源于评论(尽管我找到了一些过时的评论),大量使用git blame
和与 Bug 跟踪器交叉引用——这些都是弄清楚“嗯,为什么这段代码这样做?”的有用方法。在本文中,我希望我在一定程度上回答了这个问题。
[1] StgTSO
的初始化在rts/Threads.c
的createThread
中处理;然后,此函数由rts/RtsAPI.c
中的createGenThread
、createIOThread
和createStrictIOThread
调用。这些函数设置了初始堆栈状态,控制线程实际运行时执行的内容。这些函数是由fork#
和其他 primops(primops 的入口点位于rts/PrimOps.cmm
中)调用的函数。
[2] 实际上,你可用的堆栈比这个稍小,因为这个大小还包括StgTSO
结构体的大小。(然而,这只适用于将大量线程分配到一个块中,一旦发生 GC,TSO 和堆栈将不再相邻。)
[3] 这里是一个演示如何通过使用稳定指针来保留ThreadId
(这会强制它们指向的对象永不被 GC 回收)可能导致内存泄漏的示例程序:
import Control.Concurrent
import Control.Monad
import Foreign.StablePtr
n = 400000
main = do
ms <- replicateM n (newEmptyMVar >>= \m -> (forkIO (putMVar m ()) >>= newStablePtr) >> return m)
mapM_ takeMVar ms
程序的堆剖析显示,即使在 MVars 排空后,TSO/STACK 对象仍未被释放。
[4] 用于分代 GC 的写屏障不是指多线程执行的内存屏障,而是当旧代中的可变引用发生变化并且可能指向年轻代中的对象时,通知垃圾收集器的操作。在小型收集期间,不会遍历旧代,因此如果旧代可能指向年轻代的对象,我们可能会错过年轻对象仍然存活的事实,即使它没有从其他年轻对象中引用。在 GHC 中,通过将对象添加到能力的可变列表(mut_list
)来实现写屏障,如果对象不在最年轻的代中。(一些对象,如MutArr#
,始终在可变列表上;在这种情况下,可能不需要写屏障。但请参阅[5]以了解更多详细信息。)对象通常会跟踪它们的脏状态,以便它们不会多次将自己添加到可变列表中。(意外添加对象多次是无害的,但意味着 GC 必须额外遍历可变列表。)此外,如果我们可以保证新引用不指向年轻代(例如,它是像END_TSO_QUEUE
这样的静态闭包),则不需要标记该对象为脏。毫无疑问,搞清楚这些东西是相当棘手的!
[5] 这里有一点不太光彩的故事。通过rts/sm/Scav.c
中的scavenge_mutable_list
将对象永久添加到可变列表中,如果它在那里看到它,则将这样的对象无条件地重新添加到可变列表中。对象如何首次添加到可变列表中呢?它并非在创建时放置在列表上;相反,在最年轻代的第一次小型 GC 时,清扫 GC 注意到该对象,并通过gct->failed_to_evac = rtsTrue
将其放置在可变列表上。我们如何最终释放对象?可变列表被视为一组根指针,但仅进行清扫,而不是疏散。如果可变列表上的项目最终未被疏散,则将其清除。(这确实意味着,其元素直到下一次 GC 才会被释放。)总是扫描这些数组真的很低效吗?是的,这曾经是一个问题(票号 #650),现在通过卡片标记来缓解。同样的故事也适用于TSOs(票号 #1589),但修复的方法是正确应用写屏障,并且不将对象永久地保留在可变列表中;这在存在大量线程时显著提高了性能(即使不清扫它们的指针,遍历庞大的可变列表仍然很痛苦)。创建大量小型可变数组很可能是令人头疼的。
[6] 曾经它是单向链表,但修复 ticket #3838 需要从运行队列中移除 TSOs 的能力。
[7] 由于这些字段总是被 GC 遍历,保证它们不包含 NULL 指针或垃圾非常重要。相反,我们将它们设置为静态闭包 END_TSO_QUEUE
。因为这个闭包保证不在年轻代中,这就是为什么在设置完这个字段之后不需要污染 TSO 的原因。
[8] 有时,堆溢出和上下文切换同时发生。如果线程请求了一个大块,我们仍然总是把它放在前面(因为我们不希望另一个线程窃取我们的大块);然而,否则,上下文切换优先,并且线程被移动到队列的末尾——上下文切换尽可能晚地检查。 (见提交 05881ecab
)
Haskell 堆:ezyang 的博客
Haskell 堆
Haskell 堆是一个相当奇怪的地方。它不像传统、严格求值语言的堆...
...里面装了很多垃圾!(普通的旧数据。)
在 Haskell 堆中,每个项目都被精美地包裹在一个盒子里:Haskell 堆是一个礼物(thunk)的堆。
当你实际上想要礼物里面的东西时,你打开它(评估它)。
礼物通常有名字,有时候你打开一个礼物时会得到一个礼品卡(数据构造器)。礼品卡有两个特点:它们有一个名字(Just
礼品卡或 Right
礼品卡),并告诉你其他礼物在哪里。可能会有多个(元组礼品卡),如果你很幸运!
但是就像礼品卡可以闲置在一旁一样(这就是礼品卡公司赚钱的方式!),你不必兑现那些礼物。
Haskell 堆上的礼物相当调皮。有些礼物在你打开它们时会爆炸,而其他一些则被幽灵所缠绕,当被打扰时会打开其他的礼物。
理解当你打开一个礼物时发生的情况对于理解 Haskell 程序的时间和空间行为至关重要。
在这个系列中,Edward 进入了网络漫画世界,以展示惰性评估语言的关键操作概念。希望你喜欢!
下一次:在 Haskell 堆上的评估
技术说明。 从技术上讲,这个系列应该叫做“GHC 堆”。然而,我会尽量避免尽可能多的 GHC 专有名词,简单地提供一个关于任何一种惰性语言运算推理的隐喻。最初,这个系列的标题是“Bomberman 教你懒惰求值”,但是虽然我保留了用于表示延迟计算错误或不终止的 thunk 的炸弹隐喻,我更喜欢礼物隐喻:它特别捕捉到惰性的几个关键方面:它捕捉到了已评估/未评估的区别,以及一旦礼物被打开,就对所有人都打开了。术语“装箱”的使用有点暗示:确实,GHC 中的装箱值或提升值正是那些可能不终止的值,而非装箱值则更类似于 C 堆中所见的内容。然而,像 Java 这样的语言也使用“装箱”一词来指代看起来像对象的基本值。为了清晰起见,我们从现在开始不会再使用“装箱”一词(实际上,我们不会提及非装箱类型)。
本作品根据知识共享署名-相同方式共享 3.0 未本地化许可证许可发布。
Haskell 预处理器层次结构:ezyang 的博客
来源:
blog.ezyang.com/2010/06/the-haskell-preprocessor-hierarchy/
本篇文章是我希望能成为关于使用 c2hs 的多部教程/食谱系列之一。(Hackage)
-
Haskell 预处理器层次结构(本文)
c2hs 是什么? c2hs 是一个 Haskell 预处理器,帮助生成 外部函数接口 绑定,与 hsc2hs 和 GreenCard 一起。(下图展示了 Cabal 支持的当前预处理器。)(对于好奇的人来说,Cpp 被放在其他 FFI 预处理器中,并不是因为它特别有用于生成 FFI 代码,而是因为许多 FFI 预处理器也实现了一些 Cpp 的功能。我根据 Alex 是一个词法分析器生成器,而 Happy 是一个语法分析器生成器的原则来确定了它们的顺序。)
c2hs 是做什么的? 在我告诉你 c2hs 做什么之前,让我告诉你它 不 做的事情:它 不 会魔法般地消除你理解 FFI 规范的需要。事实上,它可能会让你编写更大、更雄心勃勃的绑定,这反过来会测试你对 FFI 的理解。(稍后详细介绍。)
c2hs 帮助做的事情是消除编写 FFI 绑定时的一些枯燥工作。(那些手写过 FFI 绑定的老手们此刻都在会意地点头。)以下是你将不再需要做的一些事情:
-
将枚举定义移植到纯 Haskell 代码中(这意味着需要编写数据定义以及 Enum 实例),
-
手动计算你要进行数据整理的结构体的大小,
-
手动计算结构体中字段的偏移量(并处理相应的可移植性头疼问题),
-
手动编写 C 指针类型,
-
(在某种程度上)编写实际的
foreign import
声明以使用 C 函数
何时使用 c2hs? Haskell 有很多预处理器;你应该选择哪一个?简单(虽然有些不太准确)地说,你可以将上述层次结构特征化为:越往下,你需要写的样板越少,需要阅读的文档越多;因此有人建议,对于小型 FFI 项目应该使用 hsc2hs,而对于更大型的项目则更适合 c2hs。
c2hs 支持而 hsc2hs 不支持的功能:
-
根据 C 头文件的内容自动生成
foreign import
, -
函数调用的半自动封送和解封送,
-
将指针类型和层次结构翻译成 Haskell 类型。
GreenCard 支持而 c2hs 不支持的功能:
-
根据 Haskell 类型签名自动生成
foreign import
(实际上,这是一个主要的哲学区别), -
更全面的封送语言,
-
使用数据接口方案自动生成数据封送,
此外,hsc2hs 和 c2hs 被认为是相当成熟的工具;前者与 GHC 打包在一起,而后者(部分)被用于 gtk2hs,这可以说是 Haskell 中最大的 FFI 绑定。GreenCard 则稍微“年轻”一些,但最近经过更新,看起来非常有前途。
这个教程系列适合我吗? 幸运的是,我不会假设读者对 FFI 有太多了解(我进入时对它的了解肯定没有现在多);不过,会假设读者对 C 有一些了解。特别是,你应该了解将数据传递给 C 函数和从 C 函数中取出数据的标准惯例,并且对于处理指针应该感到自如(尽管可能还会简要复习一下)。
下次再见。 设置 Cabal、FFI 和 c2hs。
SSA 中基本块过程的隐藏问题:ezyang 博客
来源:
blog.ezyang.com/2020/10/the-hidden-problem-with-basic-block-procedures-in-ssa/
多年前,Nadav Rotem 向我讲述了关于为什么 Swift 中的基本块过程并不像它们看起来那么好的故事。Nelson Elhage 在 Twitter 上提醒我这件事,所以我觉得这应该被记录在公众记录中。
基本块过程使得某些优化变得更加困难。考虑以下程序:
block j3 (%y1, %y2) { ... }
block j1 () { jump j3(%x1, %x2) }
block j2 () { jump j3(%x3, %x4) }
这个程序比传统的带有 phi 节点公式的 SSA 更容易还是更难优化?
L1:
goto L3
L2:
goto L3
L3:
%y1 = phi [%x1, %L1] [%x3, %L2]
%y2 = phi [%x2, %L1] [%x4, %L2]
假设优化器确定 j3/L3 内的 y1 未被使用并且可以被消除。在基本块环境中,通过删除 "y1 = phi x1 x3" 就可以简单地消除 y1。然而,在连接点环境中,你不仅需要消除 y1,还要更新 j3 的所有调用点,因为你已经改变了函数签名。在可变 AST 中,改变函数签名很麻烦;特别是,你必须做的突变包括一些无效的中间状态(这很容易意外触发断言)。
当我看到这个例子时,我想知道为什么 GHC(其具有基本块过程道德等价的连接点形式)没有这个问题。嗯,事实证明,这种优化可以作为一系列局部转换来完成。首先,我们进行一个工作者/包装器转换,引入一个中间块(工作者),丢弃无用参数:
block j3 (%y1, %y2) { jump wj3(%y2) }
block j1 () { jump j3(%x1, %x2) }
block j2 () { jump j3(%x3, %x4) }
block wj3 (%y2) { ... }
稍后,我们内联 j3,移除包装器。工作者/包装器是函数式程序的一个非常重要的优化,但很容易想象为什么在可变编译器环境中它不那么受欢迎。
HTML 净化宣言:ezyang’s blog
最近我给 Greg Weber 发了一封电子邮件,关于他的xss-sanitize包,警告他在他自己的包中重用 pandoc 净化算法。他做出了回应(有很好的理由),指出仅仅警告并不是很有建设性!因此,这是我的回应,即“HTML 净化宣言”,HTML Purifier 遵循的准则,我认为这是任何工业级 HTML 净化库的先决条件。我承认这是一个难以遵循的宣言,并且我会讨论您何时可以不完全遵循它。
宣言。
使用语义化数据结构。
白名单和验证一切。
仅输出所有浏览器理解的内容。
使用语义化数据结构。 许多过滤器在处理阶段试图永远不建立 DOM 或标记化的 HTML 表示,出于性能原因。结果证明,这使得保护您的过滤器变得非常困难。考虑XSS cheatsheet:看看多少漏洞涉及非格式良好的 HTML。如果您要求将 HTML 转换为 DOM,然后再序列化输出,您几乎可以保证结果是格式良好的,并消除所有这些漏洞。
这必须应用于 HTML 中的所有子语言,而不仅仅是 HTML 本身。您还必须小心,确保您的序列化程序生成符合标准的输出。例如,HTML Purifier 本身的一个漏洞是由于对标准的松散遵守而引起的。正如其所述:
HTML Purifier 的修复还会对 URI 的每个段落中的任何其他保留字符进行百分比编码。这实际上是一个先前确定的放宽标准遵从部分,严格执行规则消除了漏洞。
语义化数据结构还有助于实现额外功能,例如从文档中提取内容或将所有 URI 转换为绝对形式,并且极大地简化了验证和操作。这对于下一步也将至关重要。
白名单和验证一切。 白名单是一种被广泛接受的做法,在这里我不会多加强调。但是,其应用中存在微妙之处。为了理解宣言的第一部分,您需要理解一切的含义。乍一看,您可能需要白名单的明显内容是元素和属性。但是,如果您决定允许href
属性,则需要确保白名单 URI 方案——要白名单的三件事情。而且最好您决定允许style
属性!
更好的方法是采取不同的白名单方法:每次遇到属性时,找出其合理的值,并将其验证为白名单。认为不需要验证height
?考虑到可以通过将图像的宽度和高度设置为 999999 来使不知情的 Windows 用户蓝屏的图像崩溃攻击。复杂的属性?它可能具有进一步的结构,因此将其扩展为适当的语义数据结构!(最明显的候选者是 URI 和 CSS 字符串。)您不一定需要在内存中创建这些结构(事实上,优化的实现会尽量避免这样做),但这确实使编写算法变得更加容易。此外,操作结果的语义结构比操作一堆字符串要容易得多。
这是一个相当庞大的问题,因为 HTML 中有如此多的元素和属性,而且这些元素中嵌入了许多子语言。但是,通过创建一个特定领域语言来帮助您声明性地指定语义,您可以显著简化问题:尽管当时我并不知道,但这正是 HTML Purifier 在其HTMLModule
系统中采用的方法。
只输出所有浏览器都能理解的内容。 我曾经认为,符合标准就足以确保所有浏览器理解您的语法(大多数攻击的主要目标)。然而,有时浏览器根本不按标准解析。通常,模糊行为的荒野领域在标准的规定之外,但有时标准说应该发生 X,而 Internet Explorer 却做 Y。很诱人的是,只是把手举在空中,然后处理漏洞。
但由于这与标准不兼容,许多网页开发人员发现应该工作的东西却不起作用!对于 CSS,即使是 Internet Explorer 中最隐蔽的解析错误也已被一些网页开发人员碰到,并被记录在维基或编码为 CSS hack 中。尽管这些知识看似与安全问题无关,但对于编写 HTML 过滤器的人来说至关重要!它告诉您,特别是所有浏览器都理解的符合标准的 HTML 子集是什么。
更实际的注意事项。 每当我看到网络上出现一个新的 HTML 过滤器时,我都会对源代码应用以下三个检验标准(我甚至都不会尝试演示):
-
它是否使用 HTML 解析器或尝试进行一堆字符串转换?
-
它有多少白名单?仅元素和属性?它如何处理属性的内容?
-
过滤器是否显得有处理实际世界中问题的经验?
根据过滤器声称支持的内容,我对第二和第三标准的应用不那么严格:例如,一个不支持 CSS 的过滤器无需担心其相关的困难。在极端情况下,如果你只是为<b>
和<i>
编写过滤器,你可以不遵循这些建议而且没问题。但我怀疑用户总是希望拥有更多功能,你也将不可避免地开始添加新的元素和属性。因此,我也要判断源代码是否考虑过在这些方向上进行扩展。
更多语言学上的注意。 我使用“净化”一词,而不是“消毒”,因为“消毒”意味着病原体仅仅被使无害,而“净化”则意味着它们实际上已被完全去除。我认为后者的理念更为安全!
结论。 早些时候,我曾对其他 HTML 过滤器写下高度激烈的评论,试图过分推广我自己的产品。现在我避免直接比较;相反,我希望这份宣言能帮助那些对编写或改进自己过滤器感兴趣的人。
IVar 单子:ezyang 的博客
IVar
是一个不可变变量;你只需写一次,可以多次读取。在Par
单子框架中,我们使用一种提示单子风格的构造方式来编码对IVar
的各种操作,这种框架中的确定性并行代码可能会使用。我在本文中感兴趣的问题是这种功能的另一种编码方式,它支持非确定性并发,并出现在其他上下文中,如 Python Twisted、node.js、任何 JavaScript UI 库和 LWT。许多博客作者已经对此进行了评论,尽管所有关于本质上是诸多回调函数的单子狂热,但实际上却没有人在涉及 Haskell 时使用这种单子。为什么呢?一方面,Haskell 具有廉价而轻松的抢先式绿色线程,因此我们可以在许多线程中以同步方式编写我们的 IO。但另一个原因,我将在稍后的博客文章中探讨,是在这个模型空间中天真地实现 bind 会发生泄漏!(大多数事件库已经以某种方式解决了这个问题,我们也将对此进行调查。)
不过,首先要做的是,在 Haskell 中实现IVar
单子。我们逐步构建它,从演示IO (IORef a)
是一个单子开始。这并不特别有趣:我们可以使用IO
获得它的所有特性。我们对它的主要兴趣在于展示我们将呈现非确定性IVar
单子的基本结构。
import Data.IORef
newtype R a = R { runR :: IO (IORef a) }
instance Functor R where
fmap f m = R $ do xref <- runR m
x <- readIORef xref
newIORef (f x)
instance Monad R where
return x = R (newIORef x)
m >>= f
= R $ do xref <- runR m
x <- readIORef xref
runR (f x)
我们绝对不会传递值:相反,我们把它们放在IORef
盒子里。绑定操作涉及读取盒子的内容,然后从我们要绑定到的函数f
中取出一个新盒子。我们始终知道盒子的内容:我们从不调用writeIORef
。还请注意,检索引用是在IO
中进行的,因此在此过程中可能发生任意其他副作用。当我们有了实际的IVar
时,这些副作用可能涉及启动另一个执行线程,最终填充IVar
。请注意这些“盒子”:我们将关注它们的使用属性以提高性能。
对于IVar
,我们现在希望有“空”盒子,可能只在以后的某个日期被填充。我们可能会被诱惑使用IO (IORef (Maybe a))
来实现这一点:
newtype S a = S { runS :: IO (IORef (Maybe a)) }
instance Monad S where
return x = S (newIORef (Just x))
m >>= f
= S $ do xref <- runS m
mx <- readIORef xref
case mx of
Just x -> runS (f x)
Nothing -> ???
但我们陷入了一种困境(咳嗽):如果盒子仍然是空的,我们实际上并不知道需要向f
传递什么值。我们该怎么办?
传统的解决方案是将 f
存储起来,以备将来值真正可用时使用,此时我们会用新值调用所有阻塞回调。由于我们的单子允许任意副作用,这些回调仍然可以执行有用的工作。(顺便说一句,IORef (IVarContents a)
本质上是 Par
单子用来编码 IVars
的方式。)
data IVarContents a =
Empty
| Blocking [a -> IO ()]
| Full a
newtype T a = T { runT :: IO (IORef (IVarContents a)) }
现在我们可以实现最后一个情况:
instance Monad T where
return x = T (newIORef (Full x))
m >>= f
= T $ do xref <- runT m
mx <- readIORef xref
r <- newIORef Empty
let callback x = runT (f x >>= fillIVar r) >> return ()
case mx of
Full x -> callback x
Empty -> writeIORef xref (Blocking [callback])
Blocking cs -> writeIORef xref (Blocking (callback:cs))
return r
filIVar
是一些神奇的函数,它填充一个空的 IVar,并重新安排等待该值执行的任何人。一个可能的(有点儿愚蠢的)实现,假设单线程操作,可能是:
fillIVar :: IORef (IVarContents a) -> a -> T ()
fillIVar ref x = T $ do
r <- readIORef ref
writeIORef ref (Full x)
case r of
Empty -> newIORef (Full ())
Blocking cs -> mapM_ ($x) cs >> newIORef (Full ())
Full _ -> error "fillIVar: Cannot write twice"
这一切都非常直接,而且任何合作的非阻塞异步库基本上都重新实现了这种变体。在我的下一篇文章中,我想详细解释这种天真的单子编码存在的一些问题,正如 LWT 的作者所解释的,并准确指出我们在实际中确实看到了这种模式的哪些变体。
Monad 读者:第 20 期 : ezyang’s 博客
Monad 读者:第 20 期
新的《反思信任的信任》:ezyang 的博客
来源:
blog.ezyang.com/2011/10/the-new-reflections-on-trusting-trust/
在他的经典文章反思信任的信任中,Ken Thompson 描述了一种通过源代码检查无法检测到的自复制编译器错误。这种自复制是由于大多数编译器是自编译的:旧版本的编译器用于编译新版本,如果旧版本是恶意的,则在检测到它正在编译自身时它可以滑入相同的错误。
一个新的趋势恰恰是这种自托管的过程,但对于自我认证的类型检查器:用于证明其自身正确性的类型检查器。(请注意,这些是强大的类型检查器,几乎能够检查关于代码的任意定理。)这可能看起来有点奇怪,因为我可以编写一个微不足道的类型检查器,它总是声称自己是正确的。为了解决这个问题,我们必须通过在另一种语言中(在 F*的情况下,这种语言是 Coq)证明类型检查器的正确性来启动正确性证明的引导过程。一旦完成了这个过程,我们就可以使用这个经过验证的类型检查器来检查它自身的规范。这个过程如下图所示。
那么问题是是否类似的自我认证的类型检查器对于 Ken 所描述的自托管编译器问题同样容易受到攻击。从论据上来说,让我们假设后端编译器和运行时是验证过的(这是一个几乎普遍不成立的强假设,包括对于 F*也是如此)。由于类型检查器无法在它编译的程序中插入恶意的 bug(它只是,你知道,类型检查),一个人必须依赖于源代码本身的 bug。当然,这样的 bug 肯定是显而易见的!
这是不清楚的:我们已经验证了我们的实现,但是我们的规范呢?在 Coq 中,我们证明了关于我们类型系统的声音性和适当性的各种定理,这至少让我们有些希望它在我们期望的方式上是正确的。但是这些证明在解放的 F* 世界中无处可见。如果我们想要发展我们的规范(对于一个全面依赖类型的语言来说可能性较小,但对于一个不那么强大的语言来说可能性在可能的范围内),我们必须回到 Coq 并调整相关的定理。否则,我们面临将我们的类型系统改变为不完备的风险。
幸运的是,这就是我们所要做的:我们可以使用旧的 F*类型检查器来验证新的类型检查器,而不是试图导出证书并用它们重新验证 Coq。总的来说,不要把你的 Coq 代码扔掉... 至少,如果你认为你的类型系统可能在未来发生变化的话。
可重用和可组合规范的问题:ezyang 的博客
来源:
blog.ezyang.com/2016/12/the-problem-of-reusable-and-composable-specifications/
说服人们认为版本边界是特定 API 的不良近似并不太困难。当我们说 >= 1.0 && < 1.1
时,我们指的是什么?版本边界是代表某些具有特定语义的模块和函数集合的代理,这些模块需要构建库。版本边界是不精确的;从 1.0 到 1.1 的变化意味着什么?显然,我们应该写下我们实际需要的规范(类型或合同)。
这听起来都是一个好主意,直到你试图将其付诸实践,那时你会意识到版本号有一个很大的优点:它们非常简短。规范却可能变得相当庞大:甚至只是写下你依赖的所有函数类型可能就需要几页纸,更不用说描述更复杂行为的可执行合同了。更糟糕的是,同一个函数会被重复依赖;规范必须在每种情况下提供!
所以我们戴上我们的 PL 帽子说:“啊哈!我们需要的是一种能够重用和组合规范的机制。类似于……规范的语言!”但是在这一点上,关于这种语言应该如何工作存在分歧。
规范就是代码。 如果你与一个 Racketeer 聊天,他们会说:“嗯,合同只是代码,而我们知道如何重用和组合代码!”你可以用原始合同描述值,将它们组合成描述函数的合同,然后进一步将这些合同组合成关于模块的合同。你可以将这些合同收集到模块中,并在你的代码中共享它们。
There is one interesting bootstrapping problem: you're using your contracts to represent versions, but your contracts themselves live in a library, so should you version your contracts? Current thinking is that you shouldn't.
但也许你不应该像通常那样组合它们。 当我阅读 Clojure 规范文档的前言时,有一件事引起了我的注意,那就是地图规范应仅包含键集,以及它们如何处理这个问题。
spec 设计的核心原则是记录的规范不应采用{ name: string, age: int }
的形式。相反,规范分为两部分:一组键 { name, age }
,以及从键到规范的映射,一旦注册,将适用于所有地图规范中的所有键。(请注意,键都是命名空间的,因此这并非是全局命名空间中的一场疯狂的自由竞争。)这样做的理由是:
在 Clojure 中,我们通过动态组合、合并和构建地图来增强功能。我们通常处理可选和部分数据,由不可靠外部来源产生的数据,动态查询等。这些地图代表了相同键的各种集合、子集、交集和并集,并且通常在使用相同键的情况下应具有相同的语义。在每个子集/并集/交集定义规范,然后冗余地说明每个键的语义,在最动态的情况下既是反模式又不可行。
回到类型的世界。 契约可以做所有这些,因为它们是代码,我们知道如何重用代码。但是在(非依赖性)类型化的语言中,类型的语言往往比值的语言要贫乏得多。以 Backpack 作为(异常表现出色的)例子,我们可以对签名执行的唯一操作是定义它们(对类型的完整定义)并将它们合并在一起。因此,Backpack 签名正面临着由规范识别出的冗余问题:因为模块的签名包括其函数的签名,所以每当编写略有不同的模块迭代时,您最终不得不重复这些函数签名。
要采用 Clojure 模型,您需要为每个模块编写一个单独的签名(每个位于自己的包中),然后让用户通过在每个他们想要使用的签名上添加build-depends
来将它们组合在一起:
-- In Queue-push package
signature Queue where
data Queue a
push :: a -> Queue a -> Queue a
-- In Queue-pop package
signature Queue where
data Queue a
pop :: Queue a -> Maybe (Queue a, a)
-- In Queue-length package
signature Queue where
data Queue a
length :: Queue a -> Int
-- Putting them together (note that Queue is defined
-- in each signature; mix-in linking merges these
-- abstract data types together)
build-depends: Queue-push, Queue-pop, Queue-length
在我们当前的 Backpack 实现中,这有点不可思议:要为具有一百个方法的模块编写规范,您需要一百个包。在单个包中简明地定义多个公共库的能力可能会有所帮助,但这涉及到尚不存在的设计。(也许治疗比疾病更糟糕。包管理器 - 编译器分层结构再次展现了其丑陋的一面!)(自己的注意:签名包应该受到特殊对待;在实例化时确实不应该构建它们。)
结论。 直到我开始阅读像 Clojure 这样的动态语言如何应对规范问题时,我的许多思考才得以凝结:我认为这只是表明我们通过关注其他系统可以学到多少,即使它们的背景可能完全不同。(如果 Clojure 相信数据抽象,我认为他们可以从 Backpack 混入链接抽象数据声明中学到一些东西。)
在 Clojure 中,无法重用规范是一个不可接受的问题,这导致了它们当前的规范设计。在 Haskell 中,无法重用类型签名则接近无法使用的边缘:类型刚好足够短并且可以复制粘贴以至于可以容忍。对于这些类型的文档,情况稍好;这正是我寻找更好的签名重用机制的原因。
虽然 Backpack 的当前设计已经足够"好"来完成工作,但我仍然怀疑我们是否不能做得更好。一个诱人的选择是允许下游签名有选择地从较大的签名文件中挑选出某些函数添加到它们的需求中。但是,如果你需要Queue.push
,你最好也需要Queue.Queue
(没有它,push
的类型甚至不能声明:避免问题);这可能导致对最终需要的内容有很多疑问。值得深思。
xUnit 的问题:ezyang 的博客
标语:断言被认为不理想。
我认为自动化测试非常棒。我在HTML Purifier中广泛使用了两种特定的测试类型,单元测试和集成测试,这些测试是我能够在修改我在高中时编写的代码时感到放心的唯一原因。自动化测试让我可以轻松进行编码,并通过按下一个按钮来找出我是否破坏了任何东西,而不是手动输入几个输入然后查看它们是否“看起来正常”。它们也是我最初编写代码时“我想让代码做什么”的非正式规范的例子。
单元测试和集成测试都是建立在SimpleTest“单元测试”库之上。我将“单元测试”用引号括起来,因为虽然 SimpleTest 非常适合单元测试(测试单个组件),但它也可以用于集成测试(测试多个组件一起)和系统测试(整个系统,对于 Web 应用程序,通常涉及编写脚本来浏览网站);事实上,它已经提供了便利设施来更轻松地执行后两者!
或许对于 SimpleTest 的更准确描述是,它作为 xUnit 测试框架的后代。你知道,那种“编写一个设置一些东西的测试函数,运行一些代码并进行一些断言”的测试风格。断言的概念是至关重要的;没有异常处理,这是你了解测试代码是失败还是成功的唯一途径。
前些天我在 JUnit 中写了一些测试,这让我有点想起为什么虽然自动化测试很棒,我还是有些不情愿去首先推广它们。它们太冗长了!每个测试方法我都必须实例化我想要的任何类,做任何我需要的初始化,创建我的输入数据(如果我直接用new
来构建,这可能需要几行),运行函数,然后测试输出数据是否符合预期(无论是通过耐心地查看各个字段和方法,还是如果我有远见去实现相等性,构造预期输出结果并比较它们)。"等等,"你说,"这正是setUp
和tearDown
的用途!"然后你将这些代码块移动到那些方法中,但用于创建输入和验证结果的大量样板代码仍然存在,并且你害怕将它们抽象化,因为增加更多代码意味着你的测试可能出错的机会增加了!
但没有好的方法摆脱这一困境,因为对单元测试的单元测试调用列表真正是你的测试套件的“输入”,然后传递给断言的表达式列表真正是你的测试套件的“输出”。你选择使用的特定断言是你的测试套件的“预期值”。那么为什么感觉像是模板呢?
或许是因为 setUp 和 tearDown 方法以及测试方法和断言的模型对许多类型的代码来说都是错误的:正确的模型是输入值、输出值和预期值模型!对于纯净的代码来说,实际上比“代码清单”和“运行代码清单后应用程序的全局状态”有更精细的输入和输出的概念;也许它真的只是“两个整数”和“一个整数”。然后,你编写的测试代码应该真正反映出这一点!
那么,我们如何实现这一点呢?你需要一个 DSL。有些语言足够强大,可以使用一种嵌入式 DSL。但许多语言使这一过程变得太繁琐,因此它们会发明自己的测试格式,并编写必要的模板代码来解析和操纵它。显然,需要有足够多的这种形式的测试,以使编写所有这些基础设施都值得,因此当这不成立时,人们就会回到快速而肮脏的 xUnit 风格测试中。但通过这样做,你已经模糊了你的测试形状,并且由于“快速而肮脏”从未意味着“短暂”,你的测试套件会越来越大,你永远也不会切换到正确的方式。永远。
此刻,是时候进行一点 Haskell 的倡导了。你如何让你的测试从一开始就不那么繁琐呢?使用一种鼓励构建小型 DSL 的语言。Haskell 拥有灵活的语法和类型设施,使这一切成为可能,请查看。 使用一种鼓励你仔细思考函数的语言,函数具有清晰的输入和输出,而不是类和方法以及可变状态。Haskell 是一种函数式编程语言,请查看。 使用一种抽象成本低廉、炉火纯青的语言。Haskell,请查看。 使用一种语言,一旦你厌倦了一遍又一遍地编写输入和输出值,而不是整个 xUnit 测试用例的模板,它可以给你绳子来自动化这个过程!QuickCheck 和 Haskell,请查看。
现在是小小的号召行动的时候了:不要将单元/验收/系统测试层次结构与 xUnit 框架/模板混为一谈。有 xUnit 测试,还有完全随机生成输入的 QuickCheck,但在这两个不同的抽象层次之间仍然有足够的空间供人们和测试居住。当然,当代码清单确实是输入表示的正确范式时,xUnit 风格的测试也是有用的。
PyTorch 开源流程:ezyang 的博客
PyTorch 是一个相当大且活跃的开源项目,有时候有人来问我们如何管理 PyTorch,是否有一些经验可以应用到他们自己的项目中。本文试图描述一些截至 2021 年使 PyTorch 作为一个开源项目有效运作的过程。我不会声称我们所做的一切都是处理事务的最佳方式,但至少可以说,这里描述的一切在实践中都是有效的。
背景。 并非所有开源项目都一样,PyTorch 有一些独特之处,这些独特之处可能会减少我下文所描述内容在其他背景下的适用性。以下是 PyTorch 作为一个项目的一些定义特征:
-
大多数全职 PyTorch 开发人员在 Facebook 工作。 需要明确的是,还有许多全职 PyTorch 开发人员在其他公司工作:NVIDIA、Intel、Quansight、Microsoft、AMD、IBM、Preferred Networks、Google 和 Amazon 都有员工致力于 PyTorch 的开发。但大多数全职开发人员在 Facebook,这使得 PyTorch 区别于业余爱好者的开源项目或由某种基金会运行的项目。
-
PyTorch 是一个联邦项目。 如 Nadia Eghbal 所说,PyTorch 是一个具有高贡献者增长和用户增长的项目。在我的PyTorch 现状(2020)演讲中,我详细介绍了更多细节,但可以简单地说,我们有超过九家公司为 PyTorch 做贡献,还有一大批其他贡献者(占我们所有提交的 40%)。这使得管理 PyTorch 有时特别具有挑战性,下文描述的许多流程都是为了应对这种活动规模的增长带来的困难。
-
PyTorch 的表面积很广。 CPU、CUDA、ROCm、ONNX、XLA、serving、分布式、量化等等。对于单个贡献者来说,精通项目的每一个领域是不可能的,因此其中一些挑战就是确保合适的人看到他们需要看到的东西。
好的,那么 PyTorch 是如何处理其规模的?以下是我们所做的一些事情。
问题分类。 PyTorch 每天收到的 Bug 报告太多,任何一个人都无法跟踪所有这些问题。受到这篇apenwarr 文章的启发,我们在 Facebook 贡献者之间设置了轮值制度,作为所有这些问题的首次分类的第一线。问题分类的黄金法则是,在分类过程中不修复 Bug;分类的目标是(1)通过适当的 GitHub 标签将 Bug 路由到正确的人,以及(2)寻找高优先级 Bug 并提高对这些 Bug 的认识。每周,我们都会举行会议审查高优先级 Bug(以及其他标记为需要分类审查的 Bug)并讨论这些问题。轮值本身每天轮换一次,以防止人们让一周的问题积压在积压队列中,我们使用一个相对复杂的搜索查询,以确保只有相关问题显示给轮值处理。
问题分类的最重要后果是你可以整体取消对 PyTorch 仓库的关注。相反,通过观察各种标签(使用我们的cc bot),您可以相信,即使分类人员不知道您对问题感兴趣,也会抄送与主题相关的问题!每周的会议确保所有维护者共同了解当前影响 PyTorch 的主要问题,并帮助社交化我们作为项目认为的“高优先级”问题。最后,高优先级 标签是在项目中寻找有影响力的问题的好方法,即使您对项目不甚了解。
拉取请求分类。 同样,我们收到了大量来自一次性贡献者的随意拉取请求。这些人并不处于找到评审者审查他们贡献的好位置,因此我们也有一个分类人员查看这些拉取请求,并确保有人被指派进行审查。如果 PR 特别简单,分类人员可能会直接合并它们。实际上,有一些良好的自动化工具可以做到这一点(例如,homu),但我们懒得设置任何一个,并且手动指定评审者似乎不会增加太多负担在现有轮值之上。
树拥抱班次。 PyTorch 拥有一个庞大的 CI 系统,涵盖了许多不同的系统配置,大多数贡献者依赖于此来测试他们的更改是否安全。有时候人们会打破主分支。与问题处理班次分开,我们有一个树拥抱班次,他们的工作是在主分支打破时回滚任务。这个班次主要关注 CI HUD,并在一个配置中导致主分支破坏时回滚提交。
导入到 Facebook 基础设施。 实际上,我们直接在 PyTorch 的 HEAD 分支上运行 Facebook 基础设施。使这一切成为可能的工具是 fbshipit,它在 Facebook 的内部单库和我们的公共 GitHub 仓库之间同步提交。这种设置对我们来说有点双刃剑:要求 Facebook 和 GitHub 同步意味着只有 Facebook 员工才能实际提交拉取请求(我们尽量在外部维护者那里简化这一过程,但归根结底还是要有 Facebook 的员工点击绿色按钮),但这也意味着我们不必担心定期进行 "大规模导入" 到 Facebook 基础设施(我们过去曾经做过,而且相当困难)。我们非常有兴趣解决这种情况,并提出了一些关于如何改变我们进行内部发布的建议,以便让外部贡献者直接提交拉取请求。
RFCs。 大多数功能讨论发生在 GitHub 问题中,但有时候,某个功能太大、太复杂,无法在 GitHub 问题中充分讨论。在这种情况下,它们可以在 rfcs 仓库 中讨论(灵感来自 Rust RFCs 过程)。这个仓库的正式流程尚未完全固定,但通常人们会在那里讨论问题,如果他们觉得在 GitHub 问题中讨论太困难。我们还没有关于引导未经请求的 RFCs 的流程。
结论。 PyTorch 的开源流程并不像火箭科学那样复杂:有一个班次,班次做一些事情。魔鬼在细节中:所有 PyTorch 的班次职责都经过仔细界定,以确保您的班次职责不会花费无限的时间;它们是您可以在一两个小时内完成并结束一天的事情。您可以说我们过分依赖班次,而自动化可能更好,但我们发现班次需要更少的基础设施投入,并且与 Facebook 现有的流程和工作流程很好地整合在一起。它们可能不适合每个地方,但至少对我们来说,它们似乎做得不错。
The return of Hellenistic reasoning : ezyang’s blog
来源:
blog.ezyang.com/2011/03/the-return-of-hellenistic-reasonin/
我最近参加了一场讨论,讨论了如何通过图解推理来扩展证明助手的支持,这有助于打破在这一领域中占主导地位的符号系统的霸权。虽然这项工作在某些方面显然是新颖的,但我也不禁想到我们是否已经回到了古希腊人的轨迹,他们非常喜欢几何学及其相应的视觉推理形式。在我阅读数学文本并对展示同一概念的多种方法感到惊叹时,这个想法再次出现。在本文中,我想探讨这种回归到更古老、更“直观”推理形式的现象:我称之为“希腊式推理”,因为几何学和苏格拉底方法很好地总结了我想讨论的视觉和交互推理。我认为这种复兴是件好事,并且尽管这些推理形式可能不像符号推理那样强大或普适,但它们对抽象数学结果的应用和沟通至关重要。
符号推理涉及对页面上抽象符号的句法操作;正如 Knuth 所说,这是我们在一个符号“同时出现在上方和下方位置,而我们希望它只出现在一个位置”的情况下所做的事情。这是一种相当不自然和机械的推理模式,大多数人必须接受培训才能做到,但它无疑是最流行和有效的推理模式之一。虽然我怀疑导致新数学发现的深刻洞察力不属于这个领域,但符号推理的力量源于它作为一种通用语言,可以用作传播这些洞察力的通道。与自然语言不同,它既紧凑又精确,易于书写。
虽然符号在数学倾向的人群中是一种不完美但可用的沟通基础,但它们在其他人心中却令人恐惧和恐惧。一个由数学家组成的会议室在公理和方程式的幻灯片出现时会松一口气;而系统研究者组成的会议室在同一张幻灯片再次出现时则会心不在焉。这是否意味着我们应该放弃,开始使用 UML?我的答案显而易见:不!精确形式主义的好处太大,不应该放弃。(这些好处是什么?不幸的是,这超出了本篇论文的范围。)我们能否丢弃符号而不是形式主义?也许……
首先是视觉推理。如果你能为你的问题找到一个,它们可能非常优雅。不幸的是,关键词是 if:具有视觉等效证明的情况是例外,而不是规则。但是有一些鼓励定理和其他努力可以展示一类陈述可以“画成纸上的图片”。以下是一些例子:
-
定理. 设 是集合 或 的所有开子集的代数(对于非拓扑倾向者,可以将其视为减去其“边缘”的集合:所有子集的内部)。那么 ![ \mathcal{H} \vdash p ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/vdash p") 当且仅当 是直觉上有效的。这意味着针对实数线或欧几里得平面上的无效公式总是可能给出反例。一个例子是皮尔斯定律 ![ ((p \rightarrow q) \rightarrow p) \rightarrow p ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/rightarrow q) \rightarrow p) \rightarrow p"):在实数线上的反例如下:让 的估值为实数减去零点集, 的估值为空集。方法是反复应用我们的组合规则,看看结果是否覆盖了整个实数线(如果没有,它就不是直觉上有效的)。对于 ![ A \rightarrow B ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/rightarrow B") 的规则是 ![ \mathrm{Int}(-A \cup B) ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/cup B)"),所以我们发现 ![ p \rightarrow q ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/rightarrow q") 是 ,![ (p \rightarrow q) \rightarrow q ](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/rightarrow q) \rightarrow q") 是 ,而完整的表达式是 ,整个数轴仅缺一点。
-
冯·诺依曼曾著名地说过:“我不再绝对相信希尔伯特空间。”他指的是希尔伯特空间形式化在量子力学中的成长烦恼。如果你曾经玩过张量积和量子位,你会知道即使是最简单的计算也需要大量的工作。量子图像主义运动试图通过图形语言重新构建量子计算,其目标是使简单的定理更简单化。目前还不清楚它们是否会真正成功,但这是一个非常有趣的研究方向。
-
范畴论中的交换图使证明属性变得轻而易举:只需理解正在被证明的内容并绘制适当的图表!无类型λ演算的 Church-Rosser 定理的一个尤为优雅的证明使用了一个图表追逐后解决了一个小的技术细节。
我们继续进行交互式推理。这些方法涉及对话和游戏:它们的根源可以追溯到博弈论,但此后它们在各种背景下都有所涌现。
-
制作公式的过程可以被看作是一个对话的过程,一方是声称知道如何构建的证明者,另一方是质疑者(声称这种构建不存在)。我们可以通过为证明者指定一个“策略”来捆绑所有证明的信息内容,但游戏的特定实例的一点具体性可以极大地启发我们。它们还突出显示了各种“有趣”的逻辑转折,这些转折可能并不一定显而易见地适用于推理规则:ex falso sequitur quodlibet 对应于欺骗质疑者提供矛盾信息,而经典逻辑允许Catch-22 tricks(见《Curry-Howard 同构讲义》第 6.5 节)。
-
游戏语义为程序执行赋予了意义。一些有趣的结果包括在比传统的指示性语义更具表现力(游戏语义能“看见”函数是否请求其参数的值),但在我看来,这也许是谈论惰性的最自然方式:当一个 thunk 被强迫时,我正在向某人询问答案;如果我从不强迫 thunk,那么这种对话永远不会发生。
-
在更加平凡的意义上,传统的调试器是一个交互式推理系统:程序员与程序进行对话,提问并得到答案。
(正是最后这种感觉让我想知道交互推理是否会在软件工程师中广泛使用:如果你想用一场游戏来推理整个系统的正确性,你仍然需要证明关于一般策略的事实,但在对抗性环境中(例如编译器错误)这可能非常简单和有用。这只是一种空想:交互式错误系统以前已经构建过,例如 LaTeX,但不幸的是,它们并不是很好。人们不禁要问为什么。)
我本可以在这篇论文中结束时发出“画图并描述交互”的警告,但那样做会显得很愚蠢,因为人们已经在做这些了。我想建议的是,有一个丰富的理论来正式化这两种非常非正式的活动,这种形式主义是一件好事,因为它使我们的工具更加精确,从而更加有效。
成功的自动生成文档秘诀:ezyang 的博客
我在自动生成文档上有了相当成功的任期,既是作者又是读者。因此,当雅各布·卡普兰·莫斯关于撰写“优秀文档”的文章在 Reddit 上重新浮出水面,并对自动生成的文档提出严厉批评时,我坐下来思考自动生成的文档为何让开发人员留下不快的印象。
我解释了莫斯的具体反对意见(除了断言它们“毫无价值”外)如下:
-
它们通常不包含你正在寻找的信息(“At best it’s a slightly improved version of simply browsing through the source”),
-
它们冗长(“good for is filling printed pages when contracts dictate delivery of a certain number of pages of documentation”),
-
作者们跳过了“写作”部分(“There’s no substitute for documentation written...”),
-
作者们跳过了“组织”部分(“...organized...”),
-
作者们跳过了“编辑”部分(“...and edited by hand.”),
-
它给了有文档的错觉(“...it lets maintainers fool themselves into thinking they have documentation”)。
因此,成功自动生成文档的秘密是:
记住你的受众。
毫无疑问,亲爱的读者,你正在挑眉看着我,心里想着,“当然你应该记住你的受众;这是他们在任何写作课程中总是教你的。你没有告诉我们任何有用的东西!”所以,让我详细说明一下。
为什么开发人员会忘记“记住他们的受众”? 自动生成的文档的一个定义特征是其来源于源代码:编程语言的代码行和文档块交织在一起。这有一定的好处:首先,将注释保持在描述的代码附近有助于防止随着代码变化而出现文档腐败,此外,源代码开发人员可以轻松访问与他们正在阅读的源文件相关的文档。但文档经常是面向不愿意阅读代码库的人的,因此同时编写代码和文档会使编写者陷入错误的思维模式。将此与坐下来看教程相比,文本流入一个空白文档,不受代码等琐事的影响。
这很遗憾,因为在最终用户开发人员文档的情况下(真正适合自动文档的唯一时机),最初编写代码的人最有可能拥有关于正在文档化的接口的相关知识。
什么是“记住我的观众”的意思? 简而言之,这意味着把自己放在最终用户的鞋子里,并问自己,“如果我想要找到关于这个 API 的信息,我会寻找什么?” 这可能很困难(不幸的是,这里没有秘密),但首先要考虑这个问题。
在撰写文档块时如何记住观众? 虽然如果我能一挥手就说,“我要以我的观众为中心撰写文档块”那就太好了,但我知道我会忘记,因为有一天我匆忙之下写了一个尖刻的文档块或者完全忘记了写文档块。如果在写代码后立即写文档,五分钟后发现那个函数做错了事情并需要剔除,这会很令人沮丧。
因此,我为自己设立了这两条规则:
-
写代码后立即撰写文档不是必须的(还不如不写)。
像许多使用高级语言的人一样,我喜欢用代码来原型化 API 设计。我会写一些东西,试着使用它,根据我的用例进行修改,再写一些,最终我既有了可工作的代码又有了可行的设计。如果我在原型设计时没有写文档,那也没关系,但当这一切结束时,我需要写文档(希望在代码还在我活跃的思维空间中)。在最后写文档的行为有助于最终确定 API,并可以提出最后的触及点。我还使用我的工具链告诉我何时留下了未记录的代码(使用 Sphinx 时,这是使用
coverage
插件)。 -
在撰写文档时,不断查看最终用户将看到的输出。
当你编辑任何包含格式的文本时,可能会有一个写作/预览的循环。这个循环应该延续到文档块中:你编辑你的文档块,运行你的文档构建脚本,然后在浏览器中查看结果。如果输出美观的话会很有帮助!这也意味着你的文档工具链应该智能地处理你做出的更改并重新编译所需的内容。检查最终用户会看到的内容有助于让你保持正确的心态,并且迫使你承认,“是的,这些文档实际上并不可接受。”
我的自动文档生成器产生的输出冗长且杂乱无章! 我一般发现 Python 或 Haskell 生成的自动化文档比 Java 或 C++生成的文档更易阅读。这些语言的主要区别在于 Python 和 Haskell 将它们的模块组织到文件中;因此,使用这些语言的程序员更容易记住模块文档块!
模块文档块是非常重要的一部分。如果你的代码写得好、命名得好,一个能力强的源码分析者通常可以在比读取你的文档块多几倍的时间内弄清楚某个特定函数的作用。模块是类和函数之上的第一个组织单位,恰好是文档开始变得最有用的地方。它是开发者渴望的“高级别文档”的第一形式。
因此,在 Python 和 Haskell 中,你可以把一个模块中涉及的所有功能写在一个文件中,并且可以在顶部放一个文档块,说明整个文件的作用。很简单!但是在 Java 和 C++中,每个文件都是一个类(通常是一个小类),所以你没有机会这样做。Java 和最近的 C++有命名空间,可以发挥类似的作用,但在 Java 中,对于实际上是目录的东西,你应该把文档块放在哪里呢?
还有大量冗长的污染来自自动文档工具尝试生成文档,用于那些本来不打算供最终用户使用的类和函数。Haddock(Haskell 自动文档工具)通过不为模块未导出的任何函数生成文档来强烈执行此规定。Sphinx(Python 自动文档工具)默认情况下会忽略以下划线开头的函数。那些需要大量类的 Java 文档的人应该仔细考虑哪些类实际上他们希望人们使用。
最后的想法。 “自动生成的文档”这个词是一个误称:没有自动生成文档。相反,自动文档工具应该被视为一种有价值的文档构建工具,让你获得代码和注释的内聚性、格式化、相互链接等好处。
单一导出模式 : 艾德华·杨的博客
来源:艾德华·杨的博客
单一导出模式
来自 ECMAScript TC39 会议记录的文件
单一导出指的是一种设计模式,其中模块标识符被重载为表示模块内部的函数或类型。据我所知,“单一导出”这个术语在 ECMAScript TC39 委员会之外并不特别广泛使用;然而,这个想法在其他上下文中也有出现,所以我希望推广这个特定的名称(因为名称具有力量)。
这个基本概念非常简单。在 JavaScript 中,模块经常表示为一个对象:
var sayHello = require('./sayhello.js');
sayHello.run();
sayHello
的方法是模块导出的函数。但是sayHello
本身呢?因为函数也是对象,我们可以想象sayHello
也是一个函数,因此:
sayHello()
这将是一个有效的代码片段,也许相当于 sayHello.run()
。只能以这种方式导出一个符号,但在许多模块中,有一个明显的选择(例如 jQuery 的$
对象等)。
这种模式在 Haskell 中也很常见,利用了类型和模块存在不同命名空间的事实:
import qualified Data.Map as Map
import Data.Map (Map)
Map
现在被重载为类型和模块两者。
Y 组合子和严格正性:ezyang 的博客
来源:
blog.ezyang.com/2012/09/y-combinator-and-strict-positivity/
无类型 λ 演算最令人费解的特征之一是固定点组合子,即一个具有性质 fix f == f (fix f)
的函数 fix
。编写这些组合子除了 lambda 之外不需要任何东西;其中最著名的之一是 Y 组合子 λf.(λx.f (x x)) (λx.f (x x))
。
现在,如果你像我一样,在像 Haskell 这样的类型化函数式编程语言中看到了这个,并试图实现它:
Prelude> let y = \f -> (\x -> f (x x)) (\x -> f (x x))
<interactive>:2:43:
Occurs check: cannot construct the infinite type: t1 = t1 -> t0
In the first argument of `x', namely `x'
In the first argument of `f', namely `(x x)'
In the expression: f (x x)
糟糕!类型检查不通过。
有一个解决方案流传开来,你可能通过 维基百科文章 或者 Russell O'Connor 的博客 遇到过,它通过定义一个新类型来打破无限类型:
Prelude> newtype Rec a = In { out :: Rec a -> a }
Prelude> let y = \f -> (\x -> f (out x x)) (In (\x -> f (out x x)))
Prelude> :t y
y :: (a -> a) -> a
这里发生了一些非常奇怪的事情,Russell 指出 Rec
被称为“非单调”。事实上,任何合理的依赖类型语言都会拒绝这个定义(在 Coq 中是这样的):
Inductive Rec (A : Type) :=
In : (Rec A -> A) -> Rec A.
(* Error: Non strictly positive occurrence of "Rec" in "(Rec A -> A) -> Rec A". *)
“非严格正的出现”是什么?它让人想起 子类型化中的“协变”和“逆变”,但更严格(毕竟是严格的!)基本上,类型的递归出现(例如 Rec
)不能出现在构造函数参数的函数箭头的左侧。newtype Rec a = In (Rec a)
是可以的,但 Rec a -> a
不行(即使 Rec a
处于正位置,Rec a -> a
也不行)。
拒绝这类定义有很充分的理由。最重要的原因之一是排除定义 Y Combinator 的可能性(搞砸派!),这将允许我们创建一个非终止的术语,而不是显式地使用固定点。在 Haskell 中这并不是大问题(非终止大行其道),但在定理证明语言中,一切都应该是终止的,因为非终止的术语对于任何命题都是有效的证明(通过 Curry-Howard 对应)。因此,通过 Y Combinator 潜入非终止将使类型系统非常不完善。此外,有一种类型(非严格正的类型)“太大”,即它们没有集合论解释(集合不能包含自己的幂集,这基本上是 newtype Rec = In (Rec -> Bool)
声称的内容)。
结论是,像 newtype Rec a = In { out :: Rec a -> a }
这样的类型看起来相当无害,但实际上它们相当讨厌,应该谨慎使用。对于希望编写如下类型的高阶抽象语法(HOAS)的支持者来说,这有点麻烦:
data Term = Lambda (Term -> Term)
| App Term Term
啊!Lambda
中 Term
的非正出现问题又来了!(可以感觉到在场受过匹兹堡训练的类型理论家们的紧张。)幸运的是,我们有像参数化高阶抽象语法(PHOAS)这样的东西来拯救情况。但这是另一个帖子的话题了...
多亏了亚当·克里帕拉,他在去年秋天的时候首次向我介绍了他的 Coq 课程 中的正性条件。康纳·麦克布赖德做了一个旁敲侧击的评论,让我真正理解了这里发生的事情,丹·多尔告诉我非严格正的数据类型在集合论模型中没有。
Thinking about talk : ezyang’s blog
考虑谈话
这是为 MIT 群体准备的。
我很遗憾这个 IAP 我不会在波士顿,所以无法重温我去年教过的班级 Haskell 中的高级类型类. 但是,因为我将在九月份在波士顿,也许现在是时候为今年的 SIPB 做一个 cluedump 了。我喜欢谈论 Haskell,所以我可以做另一个类似的演讲(也许涵盖二阶类型和存在量化?)我还在考虑做一个关于 Scripts 的架构概述也不错。
你希望看到我谈论什么?
三步曲的第三方无人值守升级:ezyang’s 博客
三步曲的第三方无人值守升级
无人值守升级 是一个非常方便的小包,它会在启用后自动安装更新。没有严肃的系统管理员会使用这个(你确实在将更新推送到服务器之前进行测试,对吧?),但对于许多个人用途来说,自动更新确实是你想要的;如果你运行 sudo aptitude full-upgrade
而不阅读变更日志,那么你最好开启无人值守升级。你可以通过向 /etc/apt/apt.conf.d/10periodic
添加行 APT::Periodic::Unattended-Upgrade "1"
来实现这一点(感谢 Ken!)
当然,默认配置是在 /etc/apt/apt.conf.d/50unattended-upgrades
中从他们的安全仓库拉取更新,而且他们只为普通更新提供了一行注释掉的配置。人们已经问过,“那么,我如何从其他仓库拉取自动更新?”也许你已经安装了 Chromium 每日构建版;每天看到“您有更新”的图标可能有点烦人。
好吧,这就是如何做到的:
-
找出你感兴趣的 PPA 指向的网址。你可以通过查看
/etc/apt/sources.list
或/etc/apt/sources.list.d/
来找到这些信息(如果你曾手动添加过一个 PPA,则查看前者;如果你使用了add-apt-repository
,则查看后者)。 -
在浏览器中导航到该网址。导航到
dists
,然后导航到你正在运行的发行版名称(对我来说是karmic
)。最后,点击Release
。(对于那些想要直接输入整个网址的人,它是example.com/apt/dists/karmic/Release
)。 -
你将会看到一些字段
Fieldname: Value
。找到Origin
和Suite
字段。这两个值就是放入 Allowed-Origins 中的值。
例如,Ksplice 仓库 包含以下的 Release
文件:
Origin: Ksplice
Label: Ksplice
Suite: karmic
Codename: karmic
Version: 9.10
Date: Sun, 07 Feb 2010 20:51:12 +0000
Architectures: amd64 i386
Components: ksplice
Description: Ksplice packages for Ubuntu 9.10 karmic
这翻译成以下配置:
Unattended-Upgrade::Allowed-Origins {
"Ksplice karmic";
};
就是这样!前去通过及时更新使你的系统更加安全。
额外小贴士。你可以通过编辑 /etc/uptrack/uptrack.conf
并设置 autoinstall = yes
来开启 Ksplice 的无人值守内核更新。
关于 Spec-ulation 的思考(Rich Hickey):ezyang’s 博客
来源:
blog.ezyang.com/2016/12/thoughts-about-spec-ulation-rich-hickey/
Rich Hickey 最近在 Clojure/conj 2016 上发表了主题演讲,思考语言生态系统中版本控制、规范和向后兼容性的问题。在此,Rich 考虑了"极端"观点,如果我们构建一个语言生态系统,永远不会破坏向后兼容性。
演讲的大部分时间都花在探讨这一观点的后果上。例如:
-
假设你想对一个函数进行破坏性的向后兼容性更改。不要改变函数,Richard 说,给函数取另一个名字。
-
好的,但如果有一些系统性的更改需要应用到许多函数中怎么办?这仍然不是借口:创建一个新的命名空间,把所有函数放在那里。
-
如果有一个你真的不喜欢的函数,你真的想要摆脱它怎么办?不,不要删除它,创建一个新的命名空间,该函数不在其中。
-
要是听起来很多工作要去除一些东西?是的。所以不要去除东西!
总的来说,Rich 希望我们通过将所有变更转化为堆积来避免破坏,这样新旧可以共存。他说:“我们需要将函数式编程[不变性]带到库生态系统中,依赖地狱只是可变性的地狱。” 为此,需要有工具让你承诺库提供和需要的内容,并且在发布新版本软件时不会意外破坏这一承诺。
他在演讲中说了更多,因此我鼓励你如果想要全面了解,请观看整个演讲。
总的来说,我赞同这种思路,因为我认为大量与软件变更相关的破坏只是疏忽的产物;破坏不是因为任何良好的理由,而是可以通过工具帮助避免的。
话虽如此,我确实对他演讲中未被如此突出展示的话题有一些看法。
堆积并非万灵药……如果你信仰数据隐藏。 在他的演讲中,Rich 暗示通过承诺“不移除事物”可以简单地维护向后兼容性。作为一个 Haskell 用户,这对我显然是错误的:如果我改变某些抽象类型的内部表示(甚至是内部不变式),我无法简单地加载新旧版本的库并期望在两者之间传递此类型的值。事实上,即使表示方式没有改变,类型检查器也不会允许这样做。
但至少对于 Clojure 来说,我认为 Rich 是对的。原因在于:Clojure 不相信数据隐藏!Clojure 代码的流行风格是数据类型由不可变记录组成,具有公共字段传递。因此,对数据表示的更改可能是破坏性的变更;非破坏性的表示更改根本不会发生。(我怀疑类似的理念也解释了为什么 Node.js 中的重复依赖 也能够如此成功。)
我不确定我对此的感觉如何。我个人非常信仰数据抽象,但我经常赞赏“一切皆为映射”的实用主义。(我之前在 推特上发表过 这个话题,引发了一些深思的讨论。)
有害的 API。 在演讲中的几个点上,Rich 嘲笑那些痴迷于从用户那里夺走功能的开发者。(“我讨厌这个函数。我讨厌它,我讨厌它,我讨厌人们调用它,我只是想让它从我的生活中消失。”)这忽略了无限向后兼容性之所以对我们今天编写的软件非常重要的原因。
无需更进一步,就能看到 Rich 所引用的具有几十年向后兼容性的 系统 :Unix API、Java 和 HTML。在所有这些情况下,向后兼容性导致有害 API 长期存在,远远超出了它们应该存在的时间:strncpy、gets、HTML 的旧解析器(XSS)、Java 反模式 等等。在 Android、C 库以及各个地方都有大量的例子。
在我看来,库的作者应该设计 API,使得正确操作变得容易,而错误操作变得困难。是的,这意味着有时候你需要阻止人们使用不安全或容易出错的库调用。
语义化版本控制并不会导致级联的版本增加,缺乏版本范围才是问题的根源。 在幻灯片 "Do deps force Versioning?" 中,Rich 描述了 Clojure 生态系统中的一个问题,即遵循语义化版本控制时,一个包的新版本通常会导致系统中的级联版本增加。
尽管级联版本升级的问题是一个真正的问题,适用于语义化版本控制的一般情况,但 Rich 在 Clojure 生态系统中提到的“级联版本升级”源于一个更为世俗的来源:最佳实践是在你的包元数据中指定依赖的特定版本。当依赖的新版本发布时,你需要升级包的版本,以便更新依赖的记录版本…… 等等。
我并不是在说 Clojure 用这种方式做事情是错误的(版本范围有它们自己的挑战),但在他的演讲中 Rich 暗示这是语义化版本控制的失败…… 事实并非如此。如果你使用版本范围,并且不习惯从你的依赖中重新导出 API,更新依赖的版本范围并不是一个破坏性的变化。如果你有一个求解器为整个应用程序选择一个库的单一副本,甚至可以在你的 API 中公开来自依赖的类型。
总的来说,我很高兴 Clojure 正在思考如何将向后兼容性放在首位:通常,最极端的应用原则的情况下,我们学到的最多。这是故事的结尾吗?不是;但我希望所有的语言都能慢慢向明确的规范和工具迈进,以帮助你实现自己的承诺。
Thoughts on discussion : ezyang’s blog
Thoughts on discussion
在今天的社交新闻聚合网站世界中,如 Reddit、Digg、Slashdot,作者与读者之间唯一的对话很少发生在私人频道或自己的网站上。我发现这一点相当直接,当我发现我写的一篇文档已经积累了许多评论时,其中一个指出我写错了,然后没有通知我。
如今,在互联网上跟踪引用需要一定的技巧。Google Alerts、Twitter 搜索、pingback 等等… 列表不胜枚举。如果你想在互联网上与某人对话,你可能需要去他们回应的平台,遵守该站点的社交惯例,也面临被完全忽视的风险(尽管这并不太糟,因为“你说了最后一句话”)。如果你是一家小公司,正在努力培养围绕产品的良好公共关系,你甚至可能会追踪到某人的在线身份,并发送电子邮件询问是否有任何帮助的方法(这被描述为“令人毛骨悚然的超级客户服务”)。
然后还有另一面:要与你的读者对话,他们需要知道你是否已经回复!如果他们在他们喜欢的社交新闻网站上评论了,他们将会收到一个更新流,汇总他们可能参与的所有其他讨论。如果他们在博客本身上评论,选择就更加多样化:有些博客(比如我的)在回复发布时没有通知机制;其他可能提供“邮件通知回复”,但未能区分对话线索;还有一些甚至将他们的讨论外包给像 Disqus 这样的第三方提供商。
当然,有些人对互联网上的治理失去了信心。这些人会发表只带有私人电子邮件链接的文章;这些人会强迫他们的读者在他们喜爱的社交媒体网站上进行非赞助的讨论;这些人在看到一个无知的评论者没有展示任何阅读文章或了解其内容的迹象时会摇头。我认为我们可以做得更好,因为我见过更好的!(眨眼。)我见过人们评论,不是因为他们想展示自负或优越的知识,而是因为在各方之间正在进行真诚的对话,最重要的是专注于知识的传递。我见过这种情况在互联网上发生过;我也见过这种情况在现实生活中发生过;我仍然会受到虚荣心和自我吹嘘的冲动的影响。我更喜欢在目标是沟通而不是赢得争论时与人交谈。当我觉得在交谈中听取意见和表达意见同样重要时,我喜欢与人辩论。我喜欢拥有一群由人组成的观众,而不是匿名的互联网。
关于将教科书变成游戏的思考:ezyang 的博客
今年早些时候,伍迪·弗拉尔斯写了对MITx 的批评:
我们似乎决定提供“课程”,而不是参与将教科书替换为更有效的培训工具的激动人心的新过程。
Logitext,忠于其名,旨在探索下一代形式逻辑教科书的章节会是什么样子。但如果你问任何人这个世纪最重要的教科书会涉及什么学科,我怀疑逻辑不会特别高在任何人的列表上。就相关性而言,Logitext 未能达到预期。但我确实认为 Logitext 有助于阐明一些设计原则。
关于互动性
大都会歌剧院制作的作品质量与他们的网页页面的质量无关,这里是一个明显的互动性为互动性而互动性的例子,对信息呈现没有真正的好处。
互动性能够带来许多东西。但是,互动教科书仍应该看起来像教科书。传统教科书是静态媒介的设计杰作:可以快速浏览,支持随机访问和轻松搜索。你无法在其自身的游戏中击败这种格式,无论你的视频游戏关卡有多么出色。
要谨慎地应用互动性,必须考虑静态媒介的限制:正是在这里,互动性能够带来一些新的东西。以下是一些例子:
-
每个学科都有自己的行话,这有助于精通语言的人,但妨碍不熟悉的人。在静态媒介中,你只能有限次地定义术语:每次术语出现在文本中,重新定义它会使文本混乱,即使你知道你的学生经常忘记术语的含义。在动态媒介中,解决方案很简单:工具提示。Logitext 最初没有工具提示,但我很快学会了读者对“合取”、“析取”和“推理符号”的含义感到困惑。工具提示让我们轻松地扩展单一作品的可能受众。
-
写作媒介要求线性叙述,只在必要时停下来进行问题或详细说明。太多的航点会使读者失去兴趣。在交互式文本中,只有在相关时系统才能提供上下文相关的信息。在 Logitext 中,当读者点击需要变量实例化的子句时,系统会解释这个推理规则是如何工作的。这个解释在更一般的量词工作解释中有提及,但系统也知道如何在适时和有用的时候提供这些信息。
-
有时候,你试图传达的信息也应该以另一种形式呈现。这就像描述一段音乐与真正听到它的区别,或者给某人一张地图与让他们在周围漫步几个小时的区别一样。尽量呈现而非叙述。如果可能的话,用不同方式呈现——不同的直觉适合不同的人。我可以解释“无自由出现”规则直到天荒地老,但当你点击“forall right”时意外地重命名变量会立即引入直观的概念(尽管仍然需要解释才能完全理解)。
令人注目的是,过去很多系统都滥用了这些增强功能。很多人对工具提示和 Clippy 持怀疑态度,我也是其中之一。我认为限制任何滥用的方法之一是要求教材在没有互动时能够优雅地退化。(由于技术原因,Logitext 在没有 JavaScript 的情况下无法正确渲染,但我认为这是一个 bug。)
在练习设计方面
要真正学会一些东西,你必须用它解决一些问题。练习的游戏化确实在提供外在动机方面做得很好,但直到最近,在线练习设计的技术水平一直是类似于这样的。我觉得这令人沮丧:没有迹象表明学生真正理解了基本概念,或者仅仅构建了一个复杂的私人系统,也恰好是错误的。还记得被要求展示你的工作吗?我们在线上也应该要求这样做。
这说起来比做起来容易。我选择 Logitext 的推理演算并非偶然:虽然逻辑在我心中占据了非常特殊的位置,但我也知道这样做将会很容易自动化。即使是如此简单的高中代数系统也需要漫长而艰难的道路。Logitext 避开了很多重要问题,甚至简单的问题也是如此,比如“我们如何将学生的答案(附带工作)输入计算机?”更不用说像最近的一个博士论文所解决的那样复杂的问题:“我们如何判断学生是否需要提供更多工作?”
我认为我从 Logitext 中得出了两个重要的想法。第一个是坚信定理证明器是数学中有趣练习的正确基础技术。构建 Logitext 系统有些工作,但一旦平台设计完成,定义练习就简单多了,每个练习只需一行代码。如果每个练习都必须从头开始实施,成本将会过高,教程也会有很大不同。我们知道,原则上我们可以形式化数学的所有内容;在初等数学的情况下,我们甚至可能不需要解决未解研究问题就能做到这一点。定理证明器也知道你什么时候答对了,我认为从游戏化的角度来看,这可能就是你所需要的。
第二个重要想法是计算机可以在基础不稳固的情况下辅助探索高级概念。对于一些人来说,快速准确地抄写数学符号串是一种苦难:像 Logitext 这样的系统抽象了这一过程,使他们能够看到这些证明的更高阶结构。确实,如果学生有坚实的基础会更好,但如果我们有一个系统能防止他们掉队,我认为这个世界将会更好。应对依赖于对抽象符号进行操作的异常灵巧课程的解决方案不应该是消除符号,而是使操作更容易。教育系统应该具备我所说的可调整的适应性:你应该可以选择进行低级操作,或者让系统替你完成。
结论
我已经阐述了以下关于教科书游戏化设计原则:
-
一个互动教科书应该看起来仍然像一本教科书。
-
利用互动性来通过帮助那些起点知识较少的人,扩展教科书的潜在受众。
-
利用互动性仅在相关时提供上下文敏感信息。
-
利用互动性来展示;但务必在之后解释清楚。
-
(大幅修改)定理证明器将是任何非平凡练习引擎底层的基础技术。
-
计算机对练习的最重要贡献之一不是自动评分,而是辅助探索。
我非常自信地断言了这些,但事实是它们都来自一个样本大小的研究。虽然 Logitext 项目是一次非常具有启发性的练习,但我被自己对 K12 教育知之甚少所震惊。作为一个顶尖学生,我拥有一个相当不具代表性的记忆集合,而且它们现在可能都不可靠了。教育很难,虽然我认为改进的教科书会有所帮助,但我真的不知道它们是否真的会改变游戏规则。我抱有希望,但我有一种困扰的怀疑,我可能最终会等很长时间。
Thriller: Doing it for the thrills : ezyang’s blog
你如何决定要做什么工作? 当我在因为找不到有益的事情做而在互联网上浪费时间时,我开始思考这个问题。 这似乎有点奇怪:有很多事情我 需要 做:计划假期,处理项目,回答支持请求,合并补丁,证明定理,撰写博客文章,阅读论文等等。 所以问题可能不是我没有什么可做的,而是我有太多的事情要做,我需要挑选一些事情。
这个选择并不琐碎。 那天晚上,我也不想整理我的优先事项,所以最终只是 阅读一些漫画 ,告诉自己——呃,我是说归档在“休息和放松”下。 我本可以基于长期计划来选择做什么... 是的,像我有长期计划一样。 看起来更实际的是选择做一些令人愉快的事情(当不涉及阅读漫画时,偶尔会涉及到生产性工作),或者完成后会带来一点满足感的事情,从开始时并不一定有趣,但到了结束时却是充实而令人满足。
那么什么是令人兴奋的呢? 在著名书籍 科学革命的结构 中,托马斯·库恩(Thomas Kuhn)认为,大多数科学家所做的是“正常科学”,在当前领域进行问题解决,没有期望进行革命性的发现或改变我们进行科学研究的方式。 这与“当我长大后,我想成为一名科学家,并发现治愈癌症的方法”的理想化愿景相去甚远 - 要真正做到这一点确实令人兴奋。 但是,如果普通科学家的生活如此平凡,并且不太可能导致革命,为什么那些非常聪明的人要把他们的生命奉献给科学知识的追求? 科学有什么令人兴奋的地方呢?
库恩(Kuhn)认为,这可能是因为人类喜欢解决谜题,而科学是终极解谜活动:存在一些迄今未能解释的现象,科学家试图将这些现象与现有的理论和范 paradigm 中。 因此,尽管普通人可能不会对单个不知名病毒的 DNA 片段的深入分析感兴趣,但对科学家来说,这为他们提供了无数个谜题需要解决,而每一步都向我们的文明增加了一些知识,无论多么微小。 我们人类发现谜题令人兴奋:他们吸引我们并在解决时给予我们 满足感 。
对于软件工程师来说,兴奋可能在于创造出对他人有用且实用的东西(有用户的兴奋),或者兴奋可能在于解决特别棘手的问题(在解决棘手的调试会话后感到的兴奋,或者当你做了非常聪明的事情时感到的兴奋)。你偏爱的兴奋类型决定了你感兴趣的学习类型。如果你追求创造的兴奋,你将努力获得特定工具、库和应用程序的专业知识,这些在你的工艺中是必需的。你将追求超越单纯的“编程”:审美、设计、心理学、营销等等:产品的创造是一个真正跨学科的问题。如果你追求调试的兴奋(黑客),你将寻求不同类型的专业知识,对协议的线结构、源代码、内存布局等的苦心知识。如果你追求科学问题解决的兴奋,你将寻求广义、抽象的知识,这些知识提出了关于思考和做事的新方法。
通向每种类型的兴奋的步骤是相关的,但只到一定程度。我记得一位研究生曾经告诉我:“如果你真的想确保自己理解了某件事情,你应该去实现它。但有时这是适得其反的,因为你花了太多时间让它运行起来,而忘记了所涉及的高层原则。”在这种思维方式下,一些事情变得清晰起来:并非所有的知识都是平等的。第一次学习如何使用一个 Web 框架时,我获得了广义知识——一个 Web 框架可能如何组合在一起,它应该具备什么功能和设计习惯——以及专业知识——这个特定的 Web 框架如何工作。但下次学习一个不同的 Web 框架时,我获得的广义知识就减少了。重复足够多次,剩下的只有实施细节:没有惊喜的元素了。(有一个警告:在极限情况下,学习很多很多 Web 框架可以通过看到这么多东西,给你一些额外的广义知识。我们可以称之为经验。)
对我来说,这带来了一个巨大的难题。我想要创造东西!但是创造需要连续的时间块,这种时间是稀有且宝贵的,同时也需要广义知识(理念)和专业知识(细节)。所以当我没有几周的时间来专注于一个项目(也就是从来没有),我应该怎么办呢?
我不确定我应该做什么,但我对我的当前套路有一种模糊的感觉:在进行项目工作之间轮流(无论是为博客文章做的短期实验还是在 GHC 这个宏大而迷幻的心智领域中进行黑客攻击),以及应对我专业维护工作的负担。虽然有点意思,但这意味着我现在比起编程,更多地从事写作和系统管理工作。C'est la vie.
运行黑客马拉松的建议:ezyang’s 博客
运行黑客马拉松的建议
黑客马拉松是一个事件,从一天到一周不等,黑客(不是破解类型的)聚集在一起,共同致力于一些共同的目标。黑客马拉松的一个用途是让一些开源贡献者聚集在一起,努力完成特定的功能:聚在一起并期望在手头任务上工作意味着人们比起单独工作更有效率。
然而,在SIPB,我们以一种不同的方式利用黑客马拉松:实际上,这种类型的黑客马拉松可以说使维护者在手头的任何任务上都变得不那么有效率。这种黑客马拉松的理念是吸引新人对现有项目感兴趣并投入工作。在大学环境中,这是至关重要的,因为人们仅在四年后毕业,我们需要不断寻找新鲜血液。
以下是我注意到的一些即兴技巧,可以在当天成功举办这类黑客马拉松时发挥作用。
-
告知已有项目成员,在黑客马拉松的凌晨,有可能不会完成工作,因为有些人已经离开。期望回答问题、帮助人们设置好工作环境,并且全神贯注地关注项目周围的活动。
-
在黑客马拉松开始之前建立一个任务列表是很有必要的。即使在现实世界中,任务的并行执行也很困难,因此你应该事先准备好一些独立的小任务,以便给那些愿意立即开始工作的人。
-
任务要多。人们有不同的兴趣,即使他们肯定会告诉你,“哦,任何事情都可以”,你应该明确,如果他们在做某项任务时没有乐趣,也许可以尝试做另一项任务。
-
任务难度应该有所不同。是的,当那个人步入大门,半小时内就设置好开发环境,并且设法在黑客马拉松结束前完成任务时,这是非常好的。但大多数人并非如此;对于一些人来说,设置开发环境可能会占据整个下午和晚上的时间。这是令人沮丧的。让他们知道,他们确实可以在某些事情上取得实际的进展。
-
跟踪谁在做什么及最终的成就是很重要的。在黑客马拉松结束时能够说出取得了什么成就非常好,这会让人们感到愉快并鼓励他们再次参加。
-
跟前景进行沟通,但不要太频繁。在某人开始后半小时询问他们的进展并在他们若有困难时帮助他们是有好处的。但是如果频繁地监督他们,很容易让他们感到紧张或受到压力,这不是一场黑客马拉松所希望的乐趣,因此也不是可接受的行为。
-
利用那些需要“长答案”的问题。如果有人建议 X,实际上,我们已经考虑过但由于一些复杂的原因 Y 无法做 X,不要草草带过这个问题:告诉这个人关于 Y 的情况。他们甚至可能有解决这个问题的方法,无论如何,这样做都能以一种非常有效的方式让他们更多地了解他们正在处理的系统:知识随需应变。
-
做一个推动者。如果做一些你一直拖延的乏味、繁琐的任务能让前景更有效并在任务上更有乐趣,你可以骗他们以后再做这些“家务”任务,当他们投入到项目中时。 (眨眼)
我鼓励过去的黑客马拉松组织者也分享他们的智慧!
向右!可自动完成的名称:ezyang 的博客
来源:
blog.ezyang.com/2010/01/to-the-right-autocompletable-names/
向右!可自动完成的名称
在我年轻的时候,MM/DD/YYYY 的风格约定曾经让我困惑;为什么人们会选择这样一个不合逻辑的系统,将月份、日期和年份放在非层次化的顺序中?显然像 YYYY-MM-DD 这样的顺序会更合理:这种格式可以排序,总体来说相当合理。
不过,最终我不情愿地接受了 MM/DD/YYYY,它为了人性化牺牲了机器友好性;毕竟,年份条目很少改变,对人类来说,月份和日期是最重要的信息。通常情况下,上下文足以隐含地指定年份是多少。
但作为一个自动完成用户,我已经意识到,即使涉及计算机时,这种排序方式也能派上用场。考虑一下按层次命名和非按层次命名的文件列表:
# hierarchally named
test-algorithm.sh
test-bottles.sh
test-capistrano.sh
utils.sh
# non-hierarchally named
algorithm-test.sh
bottles-test.sh
capistrano-test.sh
utils.sh
在层次化情况下,要自动完成test-algorithms.sh
,我需要输入t<tab>a<tab>
;总共四个按键。然而,在非层次化情况下,我只需要输入a<tab>
。如果我经常访问这些文件,额外的按键会累积起来。
因此,我提出一个请求:下次你考虑为存放在目录中的文件制定命名规范时,请考虑将“category”组件移动到末尾,并考虑友好的自动完成名称。你的手指会感谢你的这一举措。
(感谢 GameTeX 给我指引。)
番茄是蔬菜的一个子类型:ezyang 的博客
来源:
blog.ezyang.com/2014/11/tomatoes-are-a-subtype-of-vegetables/
子类型是一个在你学习它时似乎很有道理的概念(“当然,敞篷车是车的一个子类型,因为所有敞篷车都是车,但并非所有车都是敞篷车”),但一旦涉及到函数类型时,情况很快变得令人困惑。例如,如果a
是b
的一个子类型,那么(a -> r) -> r
是(b -> r) -> r
的一个子类型吗?(如果你知道这个问题的答案,那这篇博客不适合你!)当我们问我们的学生这个问题时,总有一些人被引入歧途。确实,你可以通过规则来机械地解决这个问题,但直觉是什么?
或许这个例子能帮到你。让a
代表番茄,b
代表蔬菜。如果我们可以在期望蔬菜的任何上下文中使用番茄,那么番茄就是蔬菜的一个子类型:因为番茄(在烹饪上)是蔬菜,番茄就是蔬菜的一个子类型。
那么a -> r
怎么样呢?让r
表示汤:那么我们可以把番茄 -> 汤
看作是番茄汤的食谱(拿番茄做汤)和蔬菜 -> 汤
看作是蔬菜汤的食谱(拿任何蔬菜做汤)。作为一个简化的假设,让我们假设我们关心的只是结果是汤,而不关心是什么类型的汤。
这两种类型的食谱之间的子类型关系是什么?蔬菜汤食谱更加灵活:你可以把它当作用来制作番茄汤的食谱,因为番茄只是蔬菜的一种。但是你不能用番茄汤的食谱来做茄子汤。因此,蔬菜汤食谱是番茄汤食谱的一个子类型。
这将引导我们进入最后一种类型:(a -> r) -> r
。什么是(蔬菜 -> 汤) -> 汤
?嗯,想象一下以下情景...
有一天晚上,鲍勃打电话给你。他说:“嘿,我冰箱里还有些蔬菜,我知道你爸爸在发明食谱方面是个天才。你知道他有没有一个好的汤食谱吗?”
“我不知道……”你慢慢地说,“什么样的蔬菜?”
“哦,那只是些蔬菜。听着,我会用一些汤来还你的,拿着食谱过来吧!”听筒里传来一声咔哒。
你翻阅爸爸的烹饪书,找到了一份番茄汤的食谱。哎呀!你不能带这个食谱过去,因为鲍勃可能并没有番茄。就在此时,电话再次响起。爱丽丝在电话那头:“牛肉炖菜的食谱很棒;我有些番茄,打算做些番茄汤,你有那种食谱吗?”显然,这种情况经常发生在你身上。
“事实上我知道!”你转回到你的烹饪书,但令你惊讶的是,你再也找不到你的番茄汤食谱了。但是你找到了一个蔬菜汤的食谱。“蔬菜汤食谱行得通吗?”
“当然 — 我不是植物学家:对我来说,番茄也是蔬菜。非常感谢!”
你也感到宽慰,因为现在你也为 Bob 有了一个食谱。
Bob 是一个将蔬菜汤食谱变成汤的人:他是(Vegetable -> Soup) -> Soup
。另一方面,Alice 是一个将番茄汤食谱变成汤的人:她是(Tomato -> Soup) -> Soup
。你可以给 Alice 番茄汤食谱或者蔬菜汤食谱,因为你知道她有番茄,但是 Bob 对手头食材的模糊描述意味着你只能带一个适合所有蔬菜的食谱。像 Alice 这样的调用者更容易适应:(Tomato -> Soup) -> Soup
是(Vegetable -> Soup) -> Soup
的一个子类型。
实际上,正式推理出子类型关系可能比直觉推断更快;然而,希望这种情况已经解释了为什么规则看起来像这样。
太多剩菜了! : ezyang’s blog
Too many leftovers!
我在烹饪领域养成的一个坏习惯,是从当一名挨饿的大学生中养成的——那就是倾向于煮我手头所有的食材。再加上蔬菜可以做出很多食物的事实,以及你计划喂饱 15-20 人(但实际上只喂了 10 人),再加上 Costco 卖的份量非常大,你就会得到一个解谜狂欢后的剩菜残羹的配方。
具体来说,我现在的冰箱里有一个大锅,里面装满了炒西兰花、鸡肉、胡萝卜、芹菜、土豆和牛肉。这是美味的食物(如果我可以这么说的话),但一个人只愿意吃那么多炒菜... 而这里的量可能足够养活一个月的人。也许现在是时候发挥宿舍生活的力量了。
访问分布式 Erlang 应用程序:ezyang 博客
来源:
blog.ezyang.com/2010/08/tour-of-preach-distributed-erlang/
今天的额外帖子!上周二,John Erickson 在 Galois 的技术讲座中发表了一篇名为 “工业强度分布式显式模型检查”(视频)的演讲,在其中他描述了 PReach,这是一个基于 Murphi 的开源模型检查器,Intel 用它来查找其模型中的错误。它旨在作为 Murphi 内置的分布能力的简化替代方案,利用 Erlang 实现了更简单的网络通信代码。
第一个问题。 你为什么在乎呢?
-
模型检查很酷。 想象一下,您有一组复杂的相互作用的并行进程,随时间不确定地演变,使用某种协议相互通信。您认为代码是正确的,但为了确保,您添加了一些检查不变量的断言:也许某些状态配置永远不会被看到,也许您希望确保您的协议永远不会死锁。测试这一点的一种方法是在现场运行一段时间,并在不变量失败时报告。模型检查允许您全面测试系统的所有可能状态演变,以查找死锁或违反不变量的情况。有了这个,您可以找出微妙的错误,并且您可以找出导致该事件的精确输入。
-
分布式应用程序很酷。 正如您可能想象的那样,需要检查的状态数量呈指数级增长。模型检查器应用算法来合并常见状态并减少状态空间,但是在某个时刻,如果您想测试更大的模型,您将需要更多的机器。PReach 已经让 Intel 的基础模型检查器 Murphi 的运行速度提高了五十倍(使用一百台机器)。
这次讲话更多地关注了 PReach 团队在使核心 Murphi 算法分布式化时遇到的挑战,而不是如何对您的应用程序进行模型检查(尽管我相信一些 Galwegians 也会对这个方面感兴趣)。我认为这给出了一个出色的高层次概述,展示了如何在 Erlang 中设计分布式系统。由于软件是开源的,我们将在高层次实现该系统的过程中链接相关的源代码行。
该算法。 从本质上讲,模型检查只是一种广度优先搜索。您获取初始状态,计算它们的后继状态,并将这些状态添加到待处理的状态队列中。
WQ : list of state // work queue
V : set of state // visited states
WQ := initial_states()
while !empty(WQ) {
s = dequeue(WQ)
foreach s' in successors(s) {
if !member(s', V) {
check_invariants(s')
enqueue(s', WQ)
add_element(s', V)
}
}
}
并行算法。 现在我们需要将这个搜索算法并行化。我们可以在多台计算机上复制工作队列,将并行化问题转变为在多台计算机上分发工作负载。然而,访问状态集合比较棘手:如果我们无法将其有效地分区到多台机器上,它将成为共享状态并成为整个过程的瓶颈。
Stern and Dill (PS) 想出了一个巧妙的解决方案:使用哈希函数将状态分配给处理器。这有几个重要的含义:
-
如果哈希函数是均匀的,现在我们可以通过分割函数的输出空间来均匀地分配工作负载。
-
因为哈希函数是确定性的,任何状态都将始终发送到同一台机器。
-
因为状态粘附在机器上,每台机器可以维护独立的访问状态,并相信如果一个状态出现两次,它将被发送到同一台机器,并因此出现在该机器的访问状态中。
其一个缺点是,机器无法通过决定在本地处理自己的后续状态来节省网络延迟,但这对于不必担心共享访问状态的问题来说是一个公平的权衡,这被认为是一个难以有效解决的问题。
实现大部分这一逻辑的相关源函数是 recvStates 和 reach。
积分机制。 在运行 PReach 的早期版本时,PReach 的开发人员会注意到集群中的某些机器偶尔会因非确定性地大幅减速或崩溃。
发现这台机器被内存中的 Erlang 请求队列淹没:尽管哈希函数均匀分配消息,如果一台机器稍慢于其他机器,它将收到更多的状态,而无法及时处理。
为了解决这个问题,PReach 首先实现了一种退避协议,然后实现了一种积分协议。其直觉是:如果一台机器还没有确认你之前的 C 条消息,就不要向其发送消息。每次向另一台机器发送消息时,都会发送一个积分;当机器回复说它已处理完状态时,积分会一同发送回来。如果没有积分,则不发送任何消息。这将队列中待处理消息的数量限制为N * C
,其中N
是节点数(通常情况下是约 100 个,当 Intel 运行时)。为了防止内存中待处理状态的积累,当没有积分时,我们将它们保存到磁盘。
Erickson 不确定 Erlang 是否有一个内建功能来执行这个功能;对他来说,这似乎是网络协议的一个相当基本的扩展。
负载均衡。 虽然状态的分布是均匀的,但由于异构环境,某些机器可能能够比其他机器更快地处理状态。如果这些机器完成了它们所有的状态,它们可能会闲置不动,摆弄着大拇指,而慢速机器仍在处理它们的队列。
当这种情况发生时,一个要做的事情是让繁忙的节点注意到有一台机器正在空闲,并向它们发送它们的状态。Erickson 引用了Kumar 和 Mercer (PDF) 关于这个主题的一些工作。他的见解是,过于热衷于负载均衡和根本不进行负载均衡一样糟糕:如果负载均衡器试图保持所有队列完全相同,它将浪费大量网络时间将状态推送到网络中,因为机器的速度波动。相反,只有在注意到某人的状态比你少 X 倍(其中 X 大约为 5)时才发送状态。
一个可能出现的问题是:这种方式的状态移动是否会导致我们早期用于访问状态检查的聪明办法停止工作?答案幸运地是不会!机器上的状态可以在两个地方之一:内存中的 Erlang 接收队列或磁盘上的工作队列。当将消息从接收队列转移到工作队列时,将执行访问测试。当我们向懒汉推送状态时,这些状态是从我们的工作队列中获取的:懒汉仅执行不变检查和状态扩展(并且还无害地将该状态添加到他们的访问状态列表中)。
恢复共享状态。 当不变量失败时,如何创建一个回溯,显示导致此状态的事件序列?任何给定状态的处理都分散在许多机器上,这些机器需要再次拼接在一起。诀窍是在传递后继状态时,不仅传输当前状态,而且还传输前一个状态。接收方然后将这两个状态记录到磁盘。当您想要追溯时,您始终可以查看先前的状态并对其进行哈希,以确定该状态来自哪台机器。
在现场中。 Intel 在多达 256 个节点的集群上使用 PReach,以测试多达三百亿状态的微体系结构协议的真实模型(据 Erickson 知道,这是任何模型检查器在真实模型上完成的状态最多的数量)。
Erlang 的痛点。 Erickson 对 Erlang 的主要抱怨是它没有为与 C++ 大量接口的代码提供良好的性能分析工具;他们希望能够更优化他们的代码性能,但很难确定最慢的部分在哪里。或许一些 Erlang 爱好者对此有所评论?
旅游者白天,博客作者夜晚:ezyang 的博客
Edward 游历法国
许多年前,我决定在高中学习法语而不是西班牙语。我并不是一个特别有动力的外语学习者:当然我学习得够好拿到了 A(嗯,除了一个学期拿到了 B+),但我从未说服自己将尽可能多的词汇和语法吸收进去。现在,我在法国,这些积满灰尘的两年前的知识终于派上了用场。我真希望当时在课堂上多加注意啊。
或许我初次展示我脆弱的法语知识的例子是当我们到达马赛机场时,我走到售票柜台说:“Excusez-moi, je voudrais un... uhh...” 知识逃离我的脑海,我尝试说“carte”但那不完全正确。工作人员友好地用英语询问我是否要找地图,我感激地回答是。结果原来词汇是“plan”。我感谢了工作人员,但接下来完全无法理解带我们去第一晚住宿地点 Plan-de-Cuques 的出租车司机。
不过,即便我的法语断断续续、不完整,也总比完全不懂要好。很快我就记起了关于 imperatif 和 est-ce que 的一些内容,足以预订出租车,抱怨我们在 Le Moulin Bleu 被提供的咖啡和茶(茶包?真的?遗憾的是,我语言不够流利,没能为我们争取到退款),并且弄清楚我们不小心错过公交车站时该怎么办(这需要我回想 eglise 是什么)。虽然,直到星期一我才想起 Mercredi 是星期三,不是星期一。
我们的行程包括在几个法国小村庄逗留了几天,然后进入大城市(里昂和巴黎)。探索小村庄有点挑战,因为说英语的人所占比例远远较少,很容易误坐公交车去一个城镇,结果发现所有商店都关门,因为那是星期一,当然所有东西都关门!但也有幸运的机会:漫步出去没有特定目的地,我们偶然发现了一个迷人的圣诞市场,并自发地爬上一个山丘,享受了马赛市的美妙景色。
在寒冷的零下摄氏度天气中和你的旅行伙伴们四处走动,他们对体力活动和寒冷的容忍度各不相同,这让你对父母在你小时候度假时曾如何辛苦地带着你四处奔波有了一点同感。参观完里昂的巴士底狄圣母大教堂后,我有些不知所措接下来该做什么:购物之旅失败了(我们都不是很热衷购物),外面很冷,而且团队对漫无目的地在城市里闲逛的容忍度也不高。在一个团队里实现自发性是困难的。但这种情况确实发生了,就像我们登上了 Tête d'Or,然后在 Velo 自行车租赁服务周围骑行一样(如果骑行不到半小时,是“免费”的,但你必须购买订阅服务,一天的费用为 1 欧元,仍然是相当划算的。)
在里昂教我的旅行伙伴们一些法语短语,导致了我们在那里可能偶然发现的最美妙的事情之一。我们在 Croix-de-Rousse 的室内市场,时间是 6:00 点;对于我们这些英国和美国游客来说,这是一个可能考虑晚餐的时间。我刚刚教了一个旅行伙伴如何询问别人是否会讲英语(Vous parlez Anglais?),她决定找一个会说英语的人询问餐厅推荐。前几次尝试失败了,但后来我们遇到了一个非常友好的德国游客,他身边有一个法国本地人,于是我们得到了一些餐厅推荐,其中之一是 Balthaz'art,这是对 Balthasaur 的一个双关语。尴尬地敲了门后,被告知他们直到晚上 7:30 才开门(没错,法国人晚饭吃得很晚),我们决定等一等。
而这一切,真是值得。
至于标题,注意到我的博客文章的长度,我的一位麻省理工学院的朋友评论道,“你现在除了写博客文章还做其他事吗?”我回答道,“白天是游客,夜晚是博客作者”—因为与许多博客作者不同,我并没有预先写好下个月所有的文章。确实,我现在需要尽快睡觉了,因为在写作时(深夜星期三),我们明天就要坐火车去法国了。晚安,或者对于那些时差大的东海岸读者来说,你们好!
照片来源。 我从 Gloria 的相册中借了 Plan-de-Cuques 的美丽照片。在拍照方面,我仍然保持极简主义的习惯(亚洲游客的典型刻板形象!好吧,我穿着极不时髦的滑雪夹克和雪裤也不比他们强多少。)
向平台无关的可中断性迈进:ezyang 的博客
来源:
blog.ezyang.com/2010/09/towards-platform-agnostic-interruptibility/
上一篇文章中,我讨论了在Windows 上模拟 pthread_cancel时遇到的一些显著困难。今天,我想讨论像 GHC 这样的跨平台编译器实际上应该做什么。回想我们的三个设计目标:
-
GHC 希望能够将阻塞 IO 调用放在工作线程上,然后稍后取消;目前在 Linux 上可以做到这一点,但在 Windows 上不行,
-
用户希望编写友好于中断的 C 库,并使其与 Haskell 的异常机制无缝集成,以及
-
我们希望拥有 IO 世界的黄金之手,即将阻塞 IO 代码瞬间转变为良好行为、非阻塞、可中断的代码。
我将讨论这三种情况,简要描述为阻塞系统调用、协作库和阻塞库。我建议,由于缺乏跨平台的中断机制,正确的中断接口是允许用户定义处理程序以处理异步异常。
可中断的阻塞系统调用。在过去,GHC 曾经有一些错误,其中对阻塞 IO 系统调用的外部调用导致 Windows 无法被中断。这是 POSIX 和 Windows 异步 IO 哲学长期存在的差异:POSIX 认为某些看似阻塞但可以被信号中断的函数,而 Windows 则依赖回调函数。因此,看似无害的调用实际上破坏了中断性,并且必须手动重写为既适用于 POSIX 模型又适用于 Windows 模型的形式。
虽然理论上和实际上可以手动将每个阻塞调用转换为异步版本(顺便说一句,在 Linux 上完全可以,因为你可以发送信号),但这非常烦人,违背了我们可以简单地将阻塞调用移到另一个线程以假装它们是非阻塞的这一想法。
自 Windows Vista 以来,我们可以使用一个方便的新函数CancelSynchronousIo来中断阻塞 IO 调用。请注意,取消 IO 与取消线程不同:特别是同步操作仅返回失败,并将上次错误设置为ERROR_OPERATION_ABORTED
,因此系统调用必须由 GHC 直接执行(然后可以注意到中止操作并进一步传播中断),或者出现在能够处理此错误条件的 C 代码中。不幸的是,此功能在较早版本的 Windows 上不存在。
旁注. 有什么我们可以为 Vista 之前的 Windows 做吗?显然没有:Windows Vista 中所做的底层更改部分是为了使
CancelSynchronousIo
这样的功能成为可能。如果我们要对何时调用TerminateThread
强制执行极强的不变量;也就是说,我们必须手动审查我们考虑终止的每个函数,那么在那一点上,你可能会选择重写为异步风格。
可中断的协作库. 这是我们对 C 库有很高控制权的情况:它可能是我们自己的库,或者我们可能正在为 GHC 和一个富有表现力的异步底层库之间编写一个中间 C 层。我们想要做的是让 GHC 无缝地将其异步异常转换为我们的 C 可以注意到并优雅地处理的异常。
正如你现在可能已经意识到的,有很多方法可以实现这一点:
-
信号。仅限 POSIX,信号可以通过
sigprocmask
或pthread_sigmask
临时阻止,并且可以通过sigaction
安装信号处理程序来清理并可能退出线程或长跳转。 -
Pthread 取消。仅限 POSIX,取消可以通过
pthread_setcanceltype
临时阻止,并且可以通过pthread_cleanup_push
安装取消处理程序。 -
Select/poll 循环。取消请求通过正在轮询的套接字发送,处理程序可以选择忽略它们。
-
事件对象。仅限 Windows,线程可以从
OpenEvent
的句柄接收取消请求,但选择忽略它们。 -
IO 取消。仅限 Windows Vista,如上所述。
-
完成队列。仅限 Windows,类似于 select/poll 循环。
试图本地实现所有这些机制并没有太多意义。因此,我的建议是:在接收到异步函数时,让 GHC 在不同的线程中调用用户定义的函数,并让用户自己决定该怎么做。在许多方面,这实际上并不是一个决定:特别是,我们要求程序员自己解决问题。只能与 POSIX 一起工作的库仍然只能与 POSIX 一起工作。然而,这仍然是一个进步,因为当前状态是,Haskell 和 FFI 代码的异步异常必然表现不同。
可中断的阻塞库. 因为阻塞 IO 比非阻塞 IO 更容易编程,阻塞接口往往更普遍且测试更充分。(我的一个朋友在夏季在 Chromium 上工作时对 NSS 的非阻塞接口的bug感到无穷无尽的抱怨。)将一些系统调用重写为异步风格可能是可行的,但当你有一大块现有的 C 代码要进行接口处理时,这种重写的维护成本很快就变得难以承受。该怎么办呢?
唉,世上并无灵丹妙药:如果库从未考虑到可中断性,强行终止它很可能会使你的程序处于破坏状态。然而,对于那些想要走捷径的人来说,用户定义的函数方法仍然可以让你在真的需要时调用TerminateThread
。
总之,我建议可中断性补丁不仅仅局限于简单的interruptible
关键字,还应允许用户定义异步异常处理程序,这些处理程序编译后与运行时系统(RTS)兼容,并提供一些内置处理程序,这些处理程序提供合理的默认行为(无论是特定于平台还是非特定于平台,尽管我预计后者会提供较弱的保证)。
追踪编译 Hello Factorial! : ezyang’s 博客
来源:
blog.ezyang.com/2011/04/tracing-the-compilation-of-hello-factorial/
在函数式编程语言世界中,阶乘函数常被称为函数式编程语言中的“Hello World!”。确实,阶乘是测试模式匹配和递归功能的一种非常有用的方式:我们不用操心像输入输出这样“琐碎”的问题。在本博文中,我们将追踪阶乘函数在 GHC 的编译过程中的详细步骤。您将学习如何阅读 Core、STG 和 Cmm,希望您能体验一下编译函数式程序的过程。想要参与 GHC 源码的朋友可以查看GHC wiki 上一个模块编译的描述。为了保持简单,我们不会进行优化编译;或许优化后的阶乘函数会成为另一篇文章的主题!
本文示例使用 GHC 6.12.1 在一个 32 位 Linux 机器上编译。
Haskell
$ cat Factorial.hs
我们从 Haskell 这个温暖舒适的国度开始:
module Factorial where
fact :: Int -> Int
fact 0 = 1
fact n = n * fact (n - 1)
为了保持代码简单,我们不再检查输入是否为负数,并且还将此函数特化为 Int
,以便最终生成的代码更清晰。但除此之外,这就是标准的阶乘函数。将其放入名为 Factorial.hs
的文件中,您就可以开始体验了。
Core
$ ghc -c Factorial.hs -ddump-ds
Haskell 是一种大而复杂的语言,具有许多特性。这对于编码来说很重要,但对于机器处理来说就不那么好了。因此,一旦我们完成了大多数用户可见的错误处理(如类型检查等),我们将 Haskell 转换成一个称为 Core 的小语言。在这一点上,程序仍然是函数式的,但比我们最初写的要冗长一些。
我们首先看到我们阶乘函数的 Core 版本:
Rec {
Factorial.fact :: GHC.Types.Int -> GHC.Types.Int
LclIdX
[]
Factorial.fact =
\ (ds_dgr :: GHC.Types.Int) ->
let {
n_ade :: GHC.Types.Int
LclId
[]
n_ade = ds_dgr } in
let {
fail_dgt :: GHC.Prim.State# GHC.Prim.RealWorld -> GHC.Types.Int
LclId
[]
fail_dgt =
\ (ds_dgu :: GHC.Prim.State# GHC.Prim.RealWorld) ->
*_agj n_ade (Factorial.fact (-_agi n_ade (GHC.Types.I# 1))) } in
case ds_dgr of wild_B1 { GHC.Types.I# ds_dgs ->
letrec { } in
case ds_dgs of ds_dgs {
__DEFAULT -> fail_dgt GHC.Prim.realWorld#; 0 -> GHC.Types.I# 1
}
}
这可能看起来有点陌生,因此这里是将 Core 重新编写成更像 Haskell 的形式。特别是我省略了绑定器信息(类型签名、LclId
和 []
,这些都在每个绑定之前),删除了一些类型签名并重新缩进:
Factorial.fact =
\ds_dgr ->
let n_ade = ds_dgr in
let fail_dgt = \ds_dgu -> n_ade * Factorial.fact (n_ade - (GHC.Int.I# 1)) in
case ds_dgr of wild_B1 { I# ds_dgs ->
case ds_dgs of ds_dgs {
__DEFAULT -> fail_dgt GHC.Prim.realWorld#
0 -> GHC.Int.I# 1
}
}
这仍然是一段有趣的代码,让我们来逐步分析一下它。
-
不再有
fact n = ...
风格的绑定:一切都被转换成 lambda。我们引入了匿名变量,前缀为ds_
用于此目的。 -
第一个 let 绑定是为了确保我们的变量
n
(在末尾附加了一些额外的东西,以防我们定义了另一个遮盖原始绑定的n
)确实与ds_dgr
相同。它很快会被优化掉。 -
我们对
fact
的递归调用已神秘地放置在一个名为fail_dgt
的 lambda 中。这是什么意思呢?这是我们正在做的模式匹配的产物:如果我们所有的其他匹配失败(我们只有一个零的情况),我们调用fail_dgt
。它接受的值是一个伪 tokenGHC.Prim.realWorld#
,你可以把它看作是单位。 -
我们看到我们的模式匹配已经被解糖成了对
ds_dgr
的 unboxed 值ds_dgs
的case
语句。我们做一个情况切换来解箱它,然后再做另一个情况切换来进行模式匹配。与case
语句附加的一个额外的语法是of
关键字右边的一个变量,它指示评估后的值(在这种特殊情况下,没有人使用它)。 -
最后,我们看到我们递归的每一个分支,我们看到我们必须手动构造一个装箱的整数
GHC.Int.I# 1
作为字面量。
然后我们看到一堆额外的变量和函数,它们表示我们从 Prelude 隐式使用的函数和值,比如乘法、减法和相等性:
$dNum_agq :: GHC.Num.Num GHC.Types.Int
LclId
[]
$dNum_agq = $dNum_agl
*_agj :: GHC.Types.Int -> GHC.Types.Int -> GHC.Types.Int
LclId
[]
*_agj = GHC.Num.* @ GHC.Types.Int $dNum_agq
-_agi :: GHC.Types.Int -> GHC.Types.Int -> GHC.Types.Int
LclId
[]
-_agi = GHC.Num.- @ GHC.Types.Int $dNum_agl
$dNum_agl :: GHC.Num.Num GHC.Types.Int
LclId
[]
$dNum_agl = GHC.Num.$fNumInt
$dEq_agk :: GHC.Classes.Eq GHC.Types.Int
LclId
[]
$dEq_agk = GHC.Num.$p1Num @ GHC.Types.Int $dNum_agl
==_adA :: GHC.Types.Int -> GHC.Types.Int -> GHC.Bool.Bool
LclId
[]
==_adA = GHC.Classes.== @ GHC.Types.Int $dEq_agk
fact_ado :: GHC.Types.Int -> GHC.Types.Int
LclId
[]
fact_ado = Factorial.fact
end Rec }
因为 +
、*
和 ==
是从类型类来的,我们必须为每种类型 dNum_agq
和 dEq_agk
查找字典,然后使用它们来获取我们实际的函数 *_agj
、-_agi
和 ==_adA
,这是我们的 Core 引用的内容,不是 完全通用的版本。如果我们没有提供 Int -> Int
类型签名,这将有所不同。
简化的 Core
ghc -c Factorial.hs -ddump-simpl
从这里开始,我们对核心进行了多次优化。敏锐的读者可能已经注意到,在 n = 0
时,未优化的 Core 分配了一个不必要的 thunk,即 fail_dgt
。这种低效性,以及其他因素,都被优化掉了:
Rec {
Factorial.fact :: GHC.Types.Int -> GHC.Types.Int
GblId
[Arity 1]
Factorial.fact =
\ (ds_dgr :: GHC.Types.Int) ->
case ds_dgr of wild_B1 { GHC.Types.I# ds1_dgs ->
case ds1_dgs of _ {
__DEFAULT ->
GHC.Num.*
@ GHC.Types.Int
GHC.Num.$fNumInt
wild_B1
(Factorial.fact
(GHC.Num.-
@ GHC.Types.Int GHC.Num.$fNumInt wild_B1 (GHC.Types.I# 1)));
0 -> GHC.Types.I# 1
}
}
end Rec }
现在,我们进入时的第一件事是对输入 ds_dgr
进行拆箱并对其进行模式匹配。所有的字典混乱已经内联到 __DEFAULT
分支中,因此 GHC.Num.* @ GHC.Types.Int GHC.Num.$fNumInt
对应于 Int
的乘法,而 GHC.Num.- @ GHC.Types.Int GHC.Num.$fNumInt
对应于 Int
的减法。由于我们可以直接对 unboxed 的 Int
进行模式匹配,所以找不到相等性。
关于装箱(boxing)和拆箱(unboxing)有几点需要说明。一个重要的事情要注意的是,ds_dgr
上的 case
语句迫使这个变量:它可能是一个 thunk,因此在我们进一步进行之前可能会运行一些(潜在的大量)代码。这也是为什么在 Haskell 中获取回溯(backtraces)如此困难的原因之一:我们关心的是 ds_dgr
的 thunk 分配位置,而不是它被评估的位置!但是在我们评估它之前,我们不知道它会出错。
另一个重要的事情要注意的是,尽管我们将整数解包,结果 ds1_dgs
并未用于除了模式匹配之外的任何事情。事实上,每当我们使用 n
时,我们都会使用 wild_B1
,它对应于 ds_dgr
的完全求值版本。这是因为所有这些函数都期望 装箱 的参数,而由于我们已经有了整数的装箱版本,重新装箱未装箱版本就没有意义。
STG
ghc -c Factorial.hs -ddump-stg
现在我们将 Core 转换为无脊柱、无标签的 G 机器,在生成更像传统命令式程序的代码之前的最后表示。
Factorial.fact =
\r srt:(0,*bitmap*) [ds_sgx]
case ds_sgx of wild_sgC {
GHC.Types.I# ds1_sgA ->
case ds1_sgA of ds2_sgG {
__DEFAULT ->
let {
sat_sgJ =
\u srt:(0,*bitmap*) []
let {
sat_sgI =
\u srt:(0,*bitmap*) []
let { sat_sgH = NO_CCS GHC.Types.I#! [1];
} in GHC.Num.- GHC.Num.$fNumInt wild_sgC sat_sgH;
} in Factorial.fact sat_sgI;
} in GHC.Num.* GHC.Num.$fNumInt wild_sgC sat_sgJ;
0 -> GHC.Types.I# [1];
};
};
SRT(Factorial.fact): [GHC.Num.$fNumInt, Factorial.fact]
结构上,STG 与 Core 非常相似,尽管在为代码生成阶段准备的时候有很多额外的杂项:
-
所有变量都已重命名,
-
现在所有的 lambda 表达式都具有形式
\r srt:(0,*bitmap*) [ds_sgx]
。参数位于最右边的列表中:如果没有参数,则只是一个惰性求值。反斜杠后的第一个字符指示闭包是否可重入(r)、可更新(u)或单入口(s,在本例中未使用)。可更新的闭包在求值后可以重写为其结果(因此带有参数的闭包不能是可更新的!)然后显示静态引用表,尽管在我们的程序中没有有趣的静态引用。 -
NO_CCS
是一个用于性能分析的注释,表示此闭包未附加任何成本中心堆栈。由于我们没有使用性能分析进行编译,这并不是很有趣。 -
构造函数应用使用方括号来接收它们的参数:
GHC.Types.I# [1]
。这不仅是风格上的变化:在 STG 中,构造函数需要 所有 的参数(例如,它们是饱和的)。否则,构造函数将被转换为一个 lambda 表达式。
还有一个有趣的结构变化,现在所有的函数应用现在只接受变量作为参数。特别是,我们已经创建了一个新的 sat_sgJ
惰性求值,传递给 factorial
的递归调用。因为我们没有使用优化编译,GHC 没有注意到 fact
的参数将立即被求值。这将导致一些极其迂回的中间代码!
Cmm
ghc -c Factorial.hs -ddump-cmm
Cmm(读作“C 减减”)是 GHC 的高级汇编语言。它在范围上类似于 LLVM,尽管看起来更像 C 而不是汇编语言。在这里,输出开始变得很大,因此我们将它分块处理。Cmm 输出包含许多数据部分,主要编码自 STG 中的额外注释信息和入口点:sgI_entry
、sgJ_entry
、sgC_ret
和 Factorial_fact_entry
。还有两个额外的函数 __stginit_Factorial_
和 __stginit_Factorial
,用于初始化模块,我们不会详细讨论。
因为我们一直在查看 STG
,所以我们可以在这些入口点和 STG
中的名称之间建立直接的对应关系。简言之:
-
sgI_entry
对应于从wild_sgC
减去1
的 thunk。我们预计它将设置调用将Int
减去的函数。 -
sgJ_entry
对应于调用Factorial.fact
在sat_sgI
上的 thunk。我们预计它将设置调用Factorial.fact
。 -
sgC_ret
有点不同,在末尾带有ret
标记。这是一个返回点,在成功评估ds_sgx
(即wild_sgC
)后我们将返回到这里。我们预计它将检查结果是否为0
,并返回一个一(根据“返回”的某种定义)或设置一个调用将Int
与sgJ_entry
及其参数相乘的函数。
该到代码时间了!这是 sgI_entry
:
sgI_entry()
{ has static closure: False update_frame: <none>
type: 0
desc: 0
tag: 17
ptrs: 1
nptrs: 0
srt: (Factorial_fact_srt,0,1)
}
ch0:
if (Sp - 24 < SpLim) goto ch2;
I32[Sp - 4] = R1; // (reordered for clarity)
I32[Sp - 8] = stg_upd_frame_info;
I32[Sp - 12] = stg_INTLIKE_closure+137;
I32[Sp - 16] = I32[R1 + 8];
I32[Sp - 20] = stg_ap_pp_info;
I32[Sp - 24] = base_GHCziNum_zdfNumInt_closure;
Sp = Sp - 24;
jump base_GHCziNum_zm_info ();
ch2: jump stg_gc_enter_1 ();
}
函数顶部给出了一些元数据,这是将存储在此函数实际代码旁边的 信息表 的描述。如果您对值的含义感兴趣,可以查看 cmm/CmmDecl.hs
中的 CmmInfoTable
;特别是标签 17 对应于 THUNK_1_0
:这是一个 thunk,其环境中(自由变量:在本例中是 wild_sgC
)有一个单指针和没有非指针。
不需要试图理解代码,我们可以看到一些有趣的东西:我们跳到了base_GHCziNum_zm_info
,这是一个Z 编码的名称,代表base GHC.Num - info
:嘿,这是我们的减法函数!在这种情况下,一个合理的猜测是我们写入栈的值是这个函数的参数。让我们再次看一下 STG 调用:GHC.Num.- GHC.Num.$fNumInt wild_sgC sat_sgH
(回想起sat_sgH was a constant 1). ``base_GHCziNum_zdfNumInt_closure
是 Z 编码的 base GHC.Num $fNumInt
,所以这是我们的字典函数。stg_INTLIKE_closure+137
是一个相当奇特的常量,它指向一个表示数字 1
的静态分配闭包。这意味着最后我们有 I32[R1 + 8]
,必须指向 wild_sgC
(事实上 R1
是指向这个 thunk 在栈上闭包的指针。)
您可能会问,stg_ap_pp_info
和 stg_upd_frame_info
是什么,为什么 base_GHCziNum_zdfNumInt_closure
在栈的最底部?关键是要意识到实际上,我们在栈上放置了三个不同的实体:base_GHCziNum_zm_info
的参数、一个包含 I32[R1 + 8]
和 stg_INTLIKE_closure+137
的 stg_ap_pp_info
对象的闭包,以及一个包含 R1
的 stg_upd_frame_info
对象的闭包。我们精心设计了一个鲁布·戈尔德堡机器,当运行时,将执行以下操作:
-
在
base_GHCziNum_zm_info
内部,使用参数base_GHCziNum_zdfNumInt_closure
并找出这个字典的正确减法函数,将这个函数放入栈中,然后跳转到它的返回点,栈上的下一个信息表stg_ap_pp_info
。 -
在
stg_ap_pp_info
内部,消耗了base_GHCziNum_zm_info
创建的参数,并使用I32[R1 + 8]
和stg_INTLIKE_closure+137
这两个参数进行应用。 (正如你可以想象的那样,stg_ap_pp_info
非常简单。) -
减法函数运行并执行实际的减法操作。然后,它使用这个参数调用了堆栈上的下一个信息表
stg_upd_frame_info
。 -
因为这是一个可更新的闭包(还记得 STG 中的
u
字符吗?),stg_upd_frame_info
将步骤 3 的结果使用来覆盖R1
指向的闭包(延迟求值的原始闭包),用一个新的只包含新值的闭包来替换它。然后它将调用堆栈上的下一个信息表,这个信息表是我们进入sgI_Entry
时堆栈上的内容。
哦,现在还有一个小问题,即if (Sp - 24 < SpLim) goto ch2;
,它检查我们是否会溢出堆栈,并在如此时跳转到垃圾收集器。
sgJ_entry
做了类似的事情,但这次的继续执行链是从Factorial_fact
到stg_upd_frame_info
再到更远的地方。我们还需要在堆上分配一个新的闭包(sgI_info
),它将作为参数传递进来:
sgJ_entry()
{ has static closure: False update_frame: <none>
type: 0
desc: 0
tag: 17
ptrs: 1
nptrs: 0
srt: (Factorial_fact_srt,0,3)
}
ch5:
if (Sp - 12 < SpLim) goto ch7;
Hp = Hp + 12;
if (Hp > HpLim) goto ch7;
I32[Sp - 8] = stg_upd_frame_info;
I32[Sp - 4] = R1;
I32[Hp - 8] = sgI_info;
I32[Hp + 0] = I32[R1 + 8];
I32[Sp - 12] = Hp - 8;
Sp = Sp - 12;
jump Factorial_fact_info ();
ch7:
HpAlloc = 12;
jump stg_gc_enter_1 ();
}
最后,sgC_ret
实际上进行了计算:
sgC_ret()
{ has static closure: False update_frame: <none>
type: 0
desc: 0
tag: 34
stack: []
srt: (Factorial_fact_srt,0,3)
}
ch9:
Hp = Hp + 12;
if (Hp > HpLim) goto chb;
_sgG::I32 = I32[R1 + 3];
if (_sgG::I32 != 0) goto chd;
R1 = stg_INTLIKE_closure+137;
Sp = Sp + 4;
Hp = Hp - 12;
jump (I32[Sp + 0]) ();
chb:
HpAlloc = 12;
jump stg_gc_enter_1 ();
chd:
I32[Hp - 8] = sgJ_info;
I32[Hp + 0] = R1;
I32[Sp + 0] = Hp - 8;
I32[Sp - 4] = R1;
I32[Sp - 8] = stg_ap_pp_info;
I32[Sp - 12] = base_GHCziNum_zdfNumInt_closure;
Sp = Sp - 12;
jump base_GHCziNum_zt_info ();
}
...虽然内容不是很多。我们从I32[R1 + 3]
(R1 是一个标记指针,所以偏移量看起来有些奇怪)处获取分支情况的结果。然后检查它是否为零,如果是,则将stg_INTLIKE_closure+137
(即字面值 1)推入我们的寄存器,并跳转到我们的继续执行点;否则,我们在堆栈上设置参数以执行乘法base_GHCziNum_zt_info
。同样进行字典传递的操作。就是这样!
当我们在这里的时候,简要提一下“优化的 Cmm”,这只是在 Cmm 上应用了一些轻微的优化。如果你真的对底层汇编的对应关系感兴趣,那么看看这个是很好的。
ghc -c Factorial.hs -ddump-opt-cmm
汇编语言
ghc -c Factorial.hs -ddump-asm
最后,我们来看看汇编语言。它与 Cmm 几乎相同,除了一些优化、指令选择和寄存器分配。特别是,Cmm 中的所有名称都被保留了下来,这在你用 GDB 调试编译后的 Haskell 代码时非常有用,如果你不想深入研究汇编语言:你可以查看 Cmm,了解函数的大致操作。
这里有一个摘录,显示了 Haskell 在 x86-32 上的一些更为显著的方面:
sgK_info:
.Lch9:
leal -24(%ebp),%eax
cmpl 84(%ebx),%eax
jb .Lchb
movl $stg_upd_frame_info,-8(%ebp)
movl %esi,-4(%ebp)
movl $stg_INTLIKE_closure+137,-12(%ebp)
movl 8(%esi),%eax
movl %eax,-16(%ebp)
movl $stg_ap_pp_info,-20(%ebp)
movl $base_GHCziNum_zdfNumInt_closure,-24(%ebp)
addl $-24,%ebp
jmp base_GHCziNum_zm_info
.Lchb:
jmp *-8(%ebx)
一些寄存器被固定在我们在 Cmm 中看到的寄存器上。前两行是栈检查,我们可以看到 %ebp
总是设置为 Sp
的值。84(%ebx)
应该是 SpLim
所在的地方;确实,%ebx
存储了指向 BaseReg
结构的指针,在程序执行过程中我们将各种“类似寄存器”的数据存储在其中(以及垃圾收集函数,见 *-8(%ebx)
)。之后,大量代码将值移动到栈上,我们可以看到 %esi
对应于 R1
。实际上,一旦你分配了所有这些寄存器,就没有多少通用寄存器可以用于实际计算了:只有 %eax
和 %edx
。
结论
就是这样:从阶乘一直到汇编级别!您可能会有几个想法:
-
天啊!下次我需要参加混淆 C 程序设计竞赛时,我只需要让 GHC 为我生成代码就好了。 GHC 的内部运行模型确实与您可能见过的任何命令式语言非常不同,但它非常规律,一旦掌握,就相当容易理解。
-
天啊!我简直无法相信 Haskell 居然能运行! 记住,我们完全没有进行优化编译。使用
-O
编译的同一模块要聪明得多。
感谢您一路阅读!请继续关注不久的将来,我将以漫画形式展示 Haskell 堆上的操作。
“Inventing on Principle” 的文本:ezyang 的博客
来源:
blog.ezyang.com/2012/02/transcript-of-inventing-on-principleb/
这里有一份 Github 的完整记录,Bret Victor 的"Inventing on Principle"。这是由我,An Yu 和 Tal Benisty 转录的。
下面是一份转录副本,我将努力与 Github 的副本保持一致。原始内容根据CC-BY许可。
[[0:07]] 所以,与之前的会议不同,我没有任何奖品要颁发。我只是要告诉你如何过你的生活。
[[0:14]] 这个演讲实际上是关于一种生活方式,大多数人不会谈论的。当你接近你的职业生涯时,你会听到很多关于追随你的激情,或者做你喜欢的事情的建议。我将谈论一些有点不同的事情。我将谈论追随一个原则 —— 找到你工作的一个指导原则,你认为是重要和必要的,并用它来指导你的所作所为。
[[0:46]] 这次演讲分为三个部分。我首先会讲述指导我大部分工作的原则,并试图让你体验到它的成果。我还将讨论一些其他以这种方式生活的人;他们的原则是什么,他们相信什么。但这些只是例子,帮助你思考你自己的信念以及你想如何过你的生活。
[[1:10]] 所以让我开始:对我来说,思想非常重要。我认为将思想带入这个世界是人们做的最重要的事情之一。而且我认为伟大的思想,无论是伟大的艺术、故事、发明、科学理论,这些东西都拥有它们自己的生命,赋予我们作为人生命的意义。所以,我经常考虑人们如何创造思想以及思想如何成长。特别是,什么样的工具能够为思想的成长创造一个健康的环境。多年来,我花了很多时间制作创意工具,使用创意工具,并且深思熟虑。我得出了一个结论:创作者需要与他们正在创造的东西有即时的连接。这是我的原则。创作者需要与他们创造的东西有即时的连接。我的意思是,当你在创造某物时,如果你做了一个改变或者做了一个决定,你需要立即看到那个效果。不能有延迟,也不能有任何隐藏的东西。创作者必须能够看到他们在做什么。接下来,我将展示一系列案例,我注意到这个原则被违反了,并且我会告诉你我是如何解决这个问题的,然后我会谈论我进行这项工作的更大背景。
[[2:32]] 那么,首先,让我们考虑一下编码。编码是怎么工作的:你在文本编辑器中输入一堆代码,想象每一行代码会做什么。然后你编译和运行,然后出现了一些东西。所以在这种情况下,这只是 JavaScript,绘制到 Canvas,绘制了这个小场景,有一棵树。但是如果场景有什么问题,或者如果我去做修改,或者如果我有更多的想法,我必须回到代码,编辑代码,编译和运行,看看它的样子。如果有什么问题,我就回到代码中去。我的大部分时间都花在工作中,盲目地在文本编辑器中工作,没有与我实际想要制作的东西有直接的连接。
[[3:20]] 所以我觉得这违背了我所坚持的原则,即创作者需要与他们正在创作的内容有直接的联系,因此我试图设计出一个编码环境,更符合我这个原则。所以我这里有这样一幅图在一边,代码在另一边,这部分画天空,这部分画山,这部分画树,当我对代码进行任何更改时,图像立即改变。因此代码和图像始终保持同步;没有编译和运行。我只需改变代码中的东西,就能看到图像中的变化。既然我们在代码和图像之间有了这种即时连接,我们可以开始考虑除了键入之外的其他改变方式。例如,这里的这个数字是树枝的长度。如果我想控制这个数字,我只需用鼠标指向它,按住控制键,就可以向上或向下调整。因此,我可以看到大树枝和小树枝的样子,我可以在艺术上找到感觉。这在代码的任何部分都很有效,我只需指向它,向上或向下调整。这些数字中的一些,我知道它们是做什么用的,但看到它们做到这一点还是有些惊讶的。还有一些完全让我惊讶。[笑声] 其他一些则完全出乎意料。[更多笑声]
[[4:48]] 所以在这里,我有一个循环,我数到十六,我在每根树枝上放了十六朵小粉色花朵。我可以减少花朵数量或增加花朵数量。但是,看看我在这里做什么:我只是在大约二十左右左右上下移动数字:它有这种非常有趣的闪烁效果;它看起来好像风在树上吹过。我第一次看到这个效果时,我立即开始考虑如何将这种效果用于动画。如果每次改变都要编译和运行,我怎么会发现这个呢?艺术的很多,创作的很多都是发现,如果你看不到你在做什么,你就什么都发现不了。
[[5:33]] 所以我已经展示了调整代码,现在让我们添加一些代码。我想在天空上放一个太阳,所以我会到 drawSky 函数的末尾,我想要填充一个圆,所以我开始输入 context.fillCircle,一开始我就得到了一个自动完成列表,显示了不同的填充方法。所以这些是我可以在那里输入的内容:fillCircle、fillRect、fillText。当我在这个自动完成列表中上下移动时,我立即看到每个方法在做什么。所以,我不必从方法名想象它会做什么。我也不必查看文档,我只是看到它,立即明白。
[[6:12]] 所以我想要一个圆,我要调整 x 坐标和 y 坐标,稍微改变半径。看起来差不多了。可能应该是黄色的,所以我要设置填充样式,context.fillStyle,和之前一样自动完成,选择 fillStyle,默认给了我白色,我可以像改变任何数字一样改变颜色代码,按住控制键,我得到一个颜色调色板。所以我可以选择一个漂亮的黄色来画我的太阳。虽然,白色也挺有趣的,我觉得。我有点没想到会是这样。但是,用白色,现在看起来像是月亮了,对吧?嘿,看,现在是夜晚了![笑声] 所以这种即时连接使得想法可以表达并发展出以前不可能的方式。
[[7:17]] 但是这里仍然存在一个问题,我认为,就是我有这个图片,还有这里的代码,我必须在脑海中维护两者之间的映射关系。所以我有所有这些行的代码,但是光看这一行,我不立刻知道它做了什么。所以我可以这样做。我按住选项键,我的光标变成了一个放大镜,现在当我滚动每一行代码时,它在图片中高亮显示了这一行所绘制的内容。所以,如果我想知道这个函数中发生了什么,我只需滚动函数并查看高亮部分。所以这里有两个调用 drawMountain 的地方;我不知道哪个是哪个;好吧,这是那座山,那是那座山。这也必须反过来工作;如果我看到图片的一部分,我必须知道是哪段代码负责绘制它。所以我做同样的事情;我按住选项键,现在当我移动图片的每个像素时,你会在右侧看到它跳到了绘制该像素的代码行。所以这绘制了天空,那绘制了树,那绘制了花朵。因此,维护这种映射非常重要,但也非常有用,用于导航。所以你知道,我想把太阳弄大一点;我跳到那里,然后把它弄大一点。或者我想把树抬高一点;我跳到那里,把树抬高一点;我想把山抬高一点,所以我跳到那里,把山抬高一点;我可以在想到它们的时候就做出这些改变,这对创造过程非常重要。能够在想到一个想法时立即尝试它是如此重要。如果在这种反馈循环中有任何延迟,在思考某事和看到它、并在此基础上构建之间,那么这些想法的世界将永远不存在。这些是我们无法思考的想法。
[[9:36]] 对我来说,思想非常重要。而关于思想的一点是,思想起步都很小。思想一开始微小、脆弱。为了发展和成熟,思想需要一个创作者可以培养它们的环境。某种程度上,需要照顾它们、喂养它们,并塑造它们的成长。对我来说,这就是即时连接原则的意义所在。因为思想对我如此珍贵,所以当我看到这一原则被违反时,当我看到思想因为创作者无法看清自己的所作所为而夭折或者停滞不前时,我觉得这是不对的。这不是违反某些界面指导方针或者违背某些最佳实践的意义上的不对,而是更深层次的错误。稍后我会回来讨论这个问题,但我想先展示另一个遵循这一原则的例子。
[[10:26]] 所以在这段代码中,没有状态,没有持久状态,没有时间,没有互动性。我在思考如何以符合我拥有的原则的方式处理编码中的这些方面:创作者需要即时连接。所以这里有一个小平台游戏。这是我的小家伙,他可以四处跑动,可以跳跃,可以死亡[笑声]。他的代码在这里。所以这段代码让他四处跑动,这段让他跳跃,这段让他与物体碰撞……而在这里,我有一些为这只小乌龟写的代码。现在乌龟现在没做什么,因为我还没完成他的代码,所以,我现在就去做这件事。每当他的 x 位置每次间隔时间增加他的方向乘以时间间隔的六十分之一的秒再乘以一些速度,哪怕是一点?可以快,可以慢,如果是负的,他就会向后走。[笑声]而这些都是我可以用来做其他敌人的想法,但我认为乌龟应该是慢的,所以让我们为我们的乌龟设定这个速度。而在这里,我有一些代码说,当我的小家伙与乌龟碰撞时,他会获得一些 Y 速度,所以他会弹到空中,而乌龟则被踩扁了。所以看起来是这样的。而乌龟会在一段时间后起身。
[[12:01]] 问题是,我不希望玩家能够在这里爬上去。我希望玩家能弹跳乌龟,并穿过这里下面的小通道。然后他将不得不绕过去解决谜题之类的事情,然后再回来拿到星星。所以,现在乌龟的弹性太大了。当然,我可以简单地在代码中调整它,现在我可以尝试,但现在它的弹性不够。所以虽然我可以在运行时调整代码而无需停止和重新编译并找到我的位置,这是很好的,但我不能立即看到我需要看到的东西,也就是他是否能够跳过去。
[[12:43]] 所以这是我要做的。我要弹跳乌龟,并暂停游戏。所以我暂停游戏,现在这里有一个滑块,让我可以倒回时间。现在,我可以倒回到在我跳跃之前,并修改代码,让它不那么有弹性,现在,当我向前移动时,它会模拟使用相同的输入控制,相同的键盘命令录制如前,但使用新代码。[掌声]
[[13:20]] 这还不够好。 [笑声] 我需要能立即看到变化。我需要立即看到我的反弹是否正确。不要再用这些东西了。如果你有一个时间过程,并且想要立即看到变化,你必须把时间映射到空间上。所以这是我要做的事情。我要弹起我的海龟,暂停游戏,现在按下这里的按钮,显示我的家伙的轨迹。所以现在我可以看到他去过的地方。当我倒带时,他面前的这条轨迹就是他将要去的地方。这是他的未来。当我改变代码时,我改变了他的未来。 [喘息声] 所以我可以找到我需要的确切值,这样当我播放时,他就可以顺利进入那里。 [鼓掌声]
[[14:26]] 因此,创作者需要能够看到他们正在做的事情。如果你正在设计一个嵌入时间的东西,你需要能够控制时间。你需要能够跨越时间看清楚,否则你就是在盲目设计。
[[14:40]] 当我玩这个的时候,我注意到玩重力很有趣。所以我可以稍微把重力搞负一点,他就开始浮起来。 [笑声] 我可以玩弄一下,试着让他停在那里。你可能可以围绕这个机制做一个完整的游戏,重力操控。事实上,我敢打赌,我可以调整这段代码的任何部分,想出一个游戏的点子。即使我只是注释掉代码中的第一个语句,现在我的家伙就不能向左移动了 - 他只能向右移动。这听起来有点傻,但是 Terry Cavanagh 实际上围绕这个概念制作了一个美丽的游戏,叫做《别回头》。Terry Cavanagh,他还做了另一个非常出色的游戏,你可能见过,叫做《VVVVVV》,用六个 v 拼写。而且,这款游戏的工作方式是你不能跳跃。相反,你只能翻转身体,向上而不是向下掉落。所以它有点像这样。你可以走在天花板上,或者在地面上走。所以你有这些看起来有点像这样的关卡,然后你在这样的地形上行走...你必须学会如何穿越这样的地形。所以如果你像那样有一个,你就不能跳过它。你必须翻转过来,然后翻转过来;他利用这个概念获得了大量的游戏体验。
[[16:07]] 所以再次,能够在你想到它们的时候尝试想法。[暂停] 这个例子,以及上一个关于树的例子,这两个都是非常视觉化的程序;我们可以通过看图片如何改变来看到我们的变化。所以我在思考,我们如何能够写更符合这个原则的更抽象的编码。我们如何写一个通用算法,以便我们能够看到我们在做什么。所以举个例子,让我们看看二分查找。关于二分查找的超快速刷新:你有一个有序值数组,还有一个关键字,这是你试图在数组中定位的值。你要跟踪两个变量,它们是你认为该值可能存在的下界和上界;现在它可以是任何地方。然后你查看这个范围的中间 - 如果找到的值太小,那么关键字必须在后面。查看范围的中间,如果找到的值太大,关键字必须在前面。你继续将你的范围细分,直到你锁定你正在寻找的值。在代码中,二分查找看起来像这样。从我的角度来看,你什么也看不到。你什么也看不到。我看到了 'array' 这个词,但我实际上看不到一个数组。因此,为了编写这样的代码,你必须在脑海中想象一个数组,并且基本上你必须玩电脑。你必须在脑海中模拟每一行代码在计算机上的操作会做什么。在很大程度上,我们认为是熟练的软件工程师的人,其实就是那些非常擅长玩电脑的人。但是如果我们在计算机上写我们的代码...为什么我们要在脑海中模拟计算机会做什么?为什么计算机不只是做它...并且展示给我们呢?
[[18:06]] 所以。让我们写二分查找。函数"binary search"接受一个关键字和一个数组。然后在这边,它说:"好的,它接受一个关键字和一个数组,比如什么?给我一个例子;我需要一些东西来处理。" 所以,例如,我的数组可能是 'a', 'b', 'c', 'd', 'e', 'f'。比如说我们正在寻找 'd'。现在让我们开始编码。下界初始为零。在这边它说 'low equals zero',没什么了不起的。上界初始为数组的末尾,所以 high equals 数组长度减一。在这边,它说 'high equals five'。所以我有了我的代码中的抽象公式。在这边,它给了我对应于这些示例参数的具体值。所以我不必在脑海中维护这个图像;它只是展示给我看。
[[19:09]] 现在我需要数组中间的索引,所以我将取这两者的平均值。Mid 等于 low 加 high 除以二,但...显然这不对。2.5 不是一个有效的数组索引。我想我需要四舍五入一下。所以我会加上 floor 函数并将其向下取整到 2。我刚刚输入时就捕捉到了这个 bug,而不是写整个函数和二十个单元测试。所以现在我从数组中取得了值...然后我需要分割我的范围,所以这里有一个 if 语句,我将它粘贴在这里。在这种情况下,我找到的值小于关键字,因此它采取了 if 语句的第一个分支。这调整了下限。当然,如果关键字更小,那么它将采取 if 语句的第二个分支并调整上限。或者,如果关键字是 'c',那么我们可能第一次就找到它,并返回索引。
[[20:14]] 这是这个算法的第一次迭代。现在我们需要做的是循环。我们已经细分了数组,我们需要继续细分直到我们找到我们要找的东西。所以,我们需要循环;我将简单循环。当 1 时,执行所有这些操作。现在我们有三列对应于这个循环的三次迭代。所以这第一列就是你之前看到的。Low 和 high 跨越整个数组,我们找到了一个 'c',它太低了,所以我们调整了下限,并循环到这里。第二次迭代,边界更紧;我们找到了一个 'e'。调整上限。第三次迭代,循环到这里;low 和 high 是一样的。我们已经缩小到一个单一的候选者 - 确实是我们正在寻找的关键字,并返回这个索引。所以这里没有隐藏的东西;你可以在每个点上清楚地看到算法正在做什么。我可以一直尝试不同的关键字,所以我可以看到算法对这些不同的输入参数的行为。
[[21:20]] 通过分析这些数据,我可以对这个算法的运作方式有所直观理解。所以我在这里尝试不同的键,比如我试试找一个'g'。结果看起来有些不同。实际上并没有返回。原因是,我在查找一个数组中不存在的键。唯一能跳出这个循环的方法,就是找到这个键。所以它在这里陷入了无限循环。因此我们可以看看发生了什么问题,算法出了什么差错。前几次迭代看起来没问题,但这次迭代看起来奇怪,因为低位大于高位。我们的范围完全崩溃了。所以如果我们到了这一步,那么我们知道这个键找不到。我看到了这个错误的条件,然后我说:“哦,这不对;低位必须小于或等于高位。”好的,我只需把这个作为我的 while 语句的条件。低位小于等于高位,然后就能跳出循环,我会返回一些信号来表示找不到。所以这里有三次循环迭代,找不到,我们返回一个未找到的值。这就像是在不盲目的情况下编写算法可能会是什么样子。[掌声]
[[22:45]] 所以我有这样一个原则,即创作者需要能够看到他们在做什么。他们需要与他们正在创造的内容有直接的联系。我试图通过三个编码示例来展示这个原则,但这只是因为这是一个软件工程的会议,我以为我应该谈论编程。但对我来说,这个原则与特定的编程无关。它与任何类型的创作都有关。所以我想展示给你们几个更多的演示,只是为了展示我在这里的想法的广度。
[[23:17]] 所以,首先,让我们看看工程的另一个分支。这里我有一张我画的电子电路图。我还没画完,所以让我完成一下。然后我们加 2。现在我们有一个工作中的电路。我是说我假设这是一个工作中的电路。我实际上没有看到任何东西在这里工作。所以这与编写代码完全相同,我们在一个静态的表示中工作。但是我们实际上关心的是数据。变量的值,所以我们在这里看不到那些。现在在一个电路中,变量是这些不同导线上的电压。所以每根导线都有一个随时间变化的电压,我们必须能够看到这一点。如果我在实验台上构建这个电路,物理上构建它,我至少可以拿一个示波器,看看这些不同导线上发生了什么,这里,或者这里。所以至少,我应该能够做到这一点。所以我这里有这根导线上电压随时间变化的图。你可以看到它是高的,低的,高的和低的,所以这显然是振荡的。如果我物理构建这个,我也能看到电路在做什么。在这种情况下,我有这两个 LED 灯在这里上面。这些是 LED 灯,小灯,据推测它们有原因在那里。我可以点击播放,看它实时模拟出来的情况。所以现在你可以看到电路在做什么。
[[24:50]] 为了设计这样一个电路,你必须理解每根导线上的电压。你必须理解整个电路中所有电压的变化。就像编码一样,要么环境向你展示了这一点,要么你在脑海中模拟它。而我有更重要的事情要用我的头脑来做,而不是模拟电子在做什么。所以我要做的是,我会把它们分开一点。所以同样的电路,稍微分开一点,我要添加每个节点的电压。所以现在你可以看到整个电路中的每个电压。而且我甚至可以点击播放,看它们实时模拟出来。
[[25:30]] 虽然,我更喜欢的是,只需将鼠标移动到上面,我可以查看对我来说有趣的区域,并查看数值。我可以比较任意两个节点。因此,如果你看看这边的节点,而我在此节点上方悬停,你会看到我悬停的那个节点的阴影叠加在上面。实际上,我悬停的节点的阴影叠加在所有节点上。所以,我只需将鼠标悬停在其中一个节点上,就能比较任意两个节点。
[[26:00]] 而且,我可以立即看到我的更改结果。所以,这里有一个 70k 电阻。我想改变它的值,我只需点击并拖动它,现在我立即看到波形立即变化。而且你会注意到,当我点击并拖动时,它会留下我开始拖动前波形的阴影,这样我就可以比较。我可以立即看到我的更改结果。
[[26:26]] 信息设计的两大黄金法则:展示数据,展示比较。这就是我在这里做的一切。但即使这样还不够好。我们在这里看到的是电压,但在电子学中实际上有两种数据类型。有电压和电流。我们看不到的是电流,流过每个组件。为了设计电路,你需要理解电压和电流的两者。你需要理解它们之间的相互作用。这就是模拟设计的内容。
[[26:51]] 所以我要把它们稍微分开一点。现在我要用随时间变化的电流图来替换每个组件。所以每个蓝色的方框代表一个组件。你可以看到每个组件是哪一个,因为它在角落里有一个小徽章,一个小图标,但现在你可以看到电路中的一切。你可以看到电流如何变化,你可以看到电压和电流如何变化。没有什么是隐藏的,没有什么需要在你的脑海中模拟。
[[27:22]] 所以这里我们有一种不同的电路表示方式。总的来说,你可以用这些块绘制任何电路,而不是用小波浪形符号制成,它是由数据制成的。我认为重要的是要问:为什么我们一开始就有这些波浪形符号?它们为什么存在?它们存在是因为用铅笔在纸上很容易画出来。但这不是纸。所以当你有了一种新的媒介,你必须重新思考这些事情。你必须考虑如何利用这种新媒介让我们对我们正在制作的东西有更直接的联系。这种新媒介如何让我们以一种方式工作,我们可以看到我们正在做什么。
[[28:00]] 对编程而言情况基本相同。我们当前对计算机程序的理解——一系列文本定义,你交给编译器——这是直接源自上世纪 50 年代 Fortran 和 ALGOL 的。那些语言是为穿孔卡设计的。所以你会在一叠卡片上打出程序,交给计算机操作员(就是底部图片中的那位),然后过一段时间再回来。所以当时根本没有交互性。这种假设已经深深融入我们当前对编程的概念中。
[[28:34]] C 语言是为电传打字机设计的。上面的是 Ken Thompson 和 Dennis Ritchie。Ritchie 创造了 C 语言。这张图片中没有显示视频显示器。Ritchie 基本上是在一台能够回显的高级打字机上打字。每当你使用控制台或终端窗口时,你正在模拟电传打字机。即使今天,人们仍然认为 REPL 或交互式顶层是交互式编程的最佳体验。因为在电传打字机上这是你能做的最好的事情。
[[29:06]] 我还有一个演示想要展示,因为我想强调这个原则,即即时连接,不仅仅是工程,而是任何类型的创作。所以我要跳到一个完全不同的领域,让我们想想动画。
[[29:22]] 所以我这里有一幅画,上面画了一棵树和一片叶子,我想用一个小视频来表现叶子慢慢飘落到树上的过程。在传统动画软件比如 Flash 中,通常的做法是使用关键帧。你基本上要指定叶子在不同时间点的位置,然后点击播放,看看效果如何。所以,我要说:在第 20 帧,我要创建一个关键帧,叶子应该在那里。然后在第 40 帧,再创建一个关键帧,叶子应该在那里,但我完全是在瞎猜。我看不到动作,感受不到时间,只是随意地把事物放在时间和空间中。
[[30:12]] 所以我在不同的时间点有了这片叶子,然后我要添加一个补间动画,告诉 Flash 如何连接这些点。然后我会点击播放,看看效果。看起来很荒谬,就像台球在来回弹跳。
[[30:32]] 而问题是,我其实知道我想要什么,对吧?就是一片叶子从树上飘落下来。我甚至可以用手来表演:叶子从树上飘落。但是 Flash 不知道如何听取我的手势。也许有一种新的媒介,能够理解我的手势。
[[30:57]] 所以我要向大家展示的是我制作的一个小应用程序,用于进行动画制作。我们目前没有准备好从 iPad 上进行实时演示,所以我只是给你们播放一个我制作视频的视频。这个场景的表现方式是树叶会从树上飘落下来,镜头会移动过去,兔子会做一些动作。有两点需要注意:首先,这一切会非常快速;其次,我几乎会始终使用双手。我有不同的图层,背景、中景和前景。我用左手拇指选择要移动的图层。我要把我的叶子移动到它的位置上。我要把我的兔子移到舞台外并开始运行时间。现在我要演示叶子从树上飘落下来的动作。回放,看看效果如何。动作看起来很好,但是叶子需要有点摇晃。所以我要拿出一个旋转控制器,回放,找到叶子即将脱落的位置,记录下旋转。我在那里添加了一个小翻转,因为那一刻感觉对了。停止,因为我想要移动视角。所以我要一次性拖动很多层,把所有图层都拉成一个列表,我降低了背景层的灵敏度,这样它们移动得更慢,产生一种视差效果。我只想水平移动,所以我拿出一个水平拖动器,看看效果如何。我不太喜欢这种视差效果,所以我稍微调整了灵敏度,再试一次,我喜欢这样的效果,所以我准备好继续了,我回放到开头,这样我可以再次进入作品的节奏中。叶子着陆后,我等待了一拍,然后开始移动视角。我不知道我等了多少帧,也不知道过了多长时间,我就是在感觉对的时候行动了。
[[32:50]] 所以我移动视角到这个冬季场景,并慢慢停了下来。然后我回放,因为我想给我的兔子做点什么。我扔掉这些工具,因为我用完了。然后等到我觉得我的兔子应该移动了,它就跳走了。我有几种不同的姿势给我的兔子。所以我拿出它们。然后找到兔子即将离开地面的点。就是这里。我改变它的姿势,并在它跳跃时切换姿势。然后我回放,因为我想看看效果如何,我会把它全屏给你看。这就是作品。
[[33:50]] 所以我用手做了这个,只用了 2 分钟,就像演奏一个乐器一样。我和我试图创作的东西之间有非常直接的联系。[掌声]
[[34:08]] 这个工具的灵感之一是几年前我尝试制作的一部动画。虽然不是那个动画,但它也是从一片叶子从树上飘落开始的。我花了一整天在 Flash 里尝试关键帧那片叶子。做不到。所以就这样结束了。我仍然保留着我的分镜头。有时我会播放我为这个作品写的音乐。但这件作品本身锁在我的脑海中。所以我总是想到数以百万计的作品锁在数以百万计的头脑中。不仅仅是动画,不仅仅是艺术,而是所有种类的想法。包括非常重要的想法,改变世界的发明,拯救生命的科学发现。所有这些想法都必须得到培育。如果没有一个能够让它们在其中生长的环境,或者它们的创造者可以通过即时的连接来培育它们,那么许多这些想法将不会出现。或者它们将发育不良。
[[35:14]] 所以我有这样一个原则,创作者需要即时的连接,我刚刚展示的所有演示都只是我四处观察,注意到这个原则被违反的地方,并试图修复它们。这真的就是我做的。我只是遵循这个指导原则,它引导我去做我必须做的事情。
[[35:40]] 但我并没有多说这个故事最重要的部分,那就是为什么。为什么我有这个原则。为什么我这样做。
[[35:51]] 当我看到这个原则被违反时,我不把它看作是一个机会。当我看到创作者被他们的工具限制,他们的想法受到损害时,我不会说:哦,好的,一个制造产品的机会。一个开始业务的机会。或者一个进行研究或为某个领域做贡献的机会。我并不因找到问题而感到兴奋。我参与这个并不是为了制造东西的乐趣。对我来说,想法是非常珍贵的。当我看到想法消失时,我感到痛心。我看到了一场悲剧。对我来说,这感觉像是一种道德错误,像是一种不公正。如果我觉得有什么事我能做的,我感觉这是我的责任去做。不是机会,而是责任。
[[36:44]] 现在这只是我的看法。我并不要求你像我一样相信这个。我在这里要说的是,我使用的这些词语:不公正,责任,道德错误,这些不是我们在技术领域通常听到的词语。我们确实听到这些词与社会问题相关联。所以像审查制度、性别歧视、环境破坏这类事情。我们都认识到这些是道德错误。大多数人不会看到侵犯公民权利就想:“哦,好的,一个机会。” 我希望不是。
[[37:23]] 相反,我们非常幸运地有历史上的人们认识到这些社会不公,并认为解决这些问题是他们的责任。因此,有这样一种活动主义生活方式,这些人致力于为他们所信仰的事业而战。这次演讲的目的是告诉你,这种活动主义生活方式不仅仅适用于社会活动主义。作为技术专家,你可以认识到世界上的不公正。你可以对一个更好的世界有所设想。你可以致力于为一个原则而战。社会活动家通常通过组织来斗争,但你可以通过发明来斗争。
[[38:07]] 现在我想告诉你一些其他以这种方式生活过的人,首先是 Larry Tesler。Larry 在他的一生中做了许多了不起的事情,但我要告诉你的是他在上世纪 70 年代中期在施乐帕克研究中心(Xerox PARC)所做的工作。当时,个人计算机并不存在。个人计算的概念非常年轻,Larry 和他在 PARC 的同事们认为它们具有变革潜力,个人计算可以改变人们的思维和生活方式。我认为在座的每一个人都会同意,他们对此的预见是正确的。
[[38:43]] 但是当时,软件界面设计是基于模式的。所以,比如在文本编辑器中,你不能像在打字机上那样直接打字然后文字出现在屏幕上。你会处于命令模式,如果你想插入文本,你得按下I
进入插入模式,然后按Escape
退出到命令模式,或者也许你会按A
进入追加模式。或者如果你想移动文本,你会按M
进入移动模式,然后你得选择并且处于选择和移动事物的模式。Larry 观察人们使用电脑——实际上,他们开创了软件用户研究的概念,这也是他的另一个成就——但是他发现,即使经过培训和使用几周后,许多人对使用电脑仍然感到不舒服。
[[39:30]] 他相信这些模式是问题所在。模式的复杂性是许多人无法跨越的一种障碍。因此,这在某种程度上威胁了个人计算机的梦想。所以拉里把消除软件中的模式作为他的个人使命,并确立了一个原则:不应让任何人被困在模式中。他的口号是“不要让我进入模式”,他还把它印在了 T 恤上。这个原则影响了他所做的每一件事情。他在所有的工作中都思考着这个问题。最终,他开发了一个名为 Gypsy 的文本编辑器,基本上就是今天我们所知道的文本编辑方式。有一个插入点。当你输入时,单词会出现在屏幕上。要选择文本,他发明了无模式选择,即点击和拖动。所以你只需点击并拖动你想选择的文本,就像使用荧光笔一样 —— 这是拖动的最早应用之一。要移动文本,他发明了所谓的剪切、复制、粘贴命令。你选择并剪切。稍后你随时可以粘贴。你永远不会被困在模式中,也不必在模式之间切换。当你在键盘上按下 W 键时,屏幕上就会显示 W。始终如此。
[[40:48]] 他观察人们使用他的软件,发现从未见过计算机的人(当时大多数人)可以在半小时内使用起来。这显然是一种能够让人们与计算机连接的变革性改变。他关于无模式的理念传播到了同时在 PARC 发明的桌面界面的其余部分。今天,这些理念在计算体验中已经根深蒂固,以至于我们现在几乎视之为理所当然。
[[41:20]] 现在我说拉里将消除模式作为他的个人使命。这确实是他的话,如果你认为他在夸大的话,这是拉里过去 30 年的车牌。当然,如今拉里有一个网站,位于 nomodes.com,他还在 Twitter 上:@nomodes。所以就像我说的,拉里在他的职业生涯中做了很多令人惊叹的工作,但他的自我认同显然与这个事业密切相关。
[[41:46]] 所以我想问一下:拉里到底做了什么?我们如何最好地描述拉里做了什么?一个典型的传记可能会说拉里·特斯勒发明了剪切、复制、粘贴。这是事实,但我认为这其实很误导,因为这种发明与说托马斯·爱迪生发明了留声机截然不同。爱迪生基本上是偶然发现了音频录制技术,然后把它作为一种新奇事物来开发。他列出了他技术的可能应用清单,但并没有任何文化意图。而拉里所做的完全是对特定文化背景的反应。
[[42:41]] 另一个你可能听到的事情是 Larry Tesler 解决了无模式文本操作的问题。解决了这个问题。显然,这是真的,他花了很长时间研究这个问题,最终解决了它。但我认为这真的很误导,因为他解决的这个问题只存在于他自己的头脑中。没有其他人认为这是一个问题。对其他人来说,模式只是计算机运行的方式。这跟我们认为双臂有什么问题一样。这只是生活的一个事实。
[[43:18]] 所以 Larry 首先做的事情是他认识到了文化中未被承认的错误。事实上,许多重大的社会变革也是从这样开始的。所以,150 年前,伊丽莎白·卡迪·斯坦顿必须站出来说:女性应该投票。其他人都说,“那太疯狂了,你在说什么啊?”今天,我们认识到性别歧视是错误的。但在那时,它是社会的一部分,是看不见的。她不得不认识到这一点,并且不得不与之斗争。对我来说,这比托马斯·爱迪生发明一堆随意技术然后申请专利的模式更接近 Larry 所做的事情。
[[44:01]] 现在明确一下,我并没有对这两个人的相对重要性或影响力做出任何评判,我只是谈论他们的动机和方法。他们两人都认识到了文化上的错误,他们设想了一个没有这个错误的世界,并且致力于为一个原则而战。她通过组织来斗争,他通过发明来斗争。
[[44:23]] 计算机领域的许多开创性人物也有类似的动机。当然,包括道格·恩格尔巴特。道格·恩格尔巴特基本上发明了交互式计算。把信息放在屏幕上的概念。通过不同方式查看信息。指向事物并操作它们。他在几乎没有人听说过实时与计算机交互的时代就提出了所有这些概念。今天,他最知名的是鼠标的发明者,但他真正发明的是这种全新的处理知识方式。他从一开始就明确的目标是使人类能够解决世界的紧急问题。他有一个愿景,他称之为知识工作者利用复杂而强大的信息工具来利用他们的集体智慧。他之所以涉足计算机,完全是因为他有一种直觉,认为这些新的东西称为计算机的东西可以帮助他实现这一愿景。他所做的几乎一切都是为了追求这个愿景而单刀直入地推动。
[[45:26]] 这里是艾伦·凯。艾伦·凯在施乐帕克研究所负责实验室管理,我们从那里得到了桌面界面,如窗口、图标、命令菜单等。他还发明了面向对象的编程以及许多其他东西。他的目标,我引用他的话,是要“扩展人类的影响力,并为一个急需新思维的摇摇欲坠的文明带来新的方式”。是不是很伟大?他的方法是通过儿童。他相信,如果儿童能够流利地运用计算机的思维方式,也就是说,如果编程成为像阅读和写作一样的基本素养,那么他们长大后就会具备新形式的批判性思维,以及理解世界的新方式。我们将拥有一个更加开明的社会,类似于识字带给社会的变化。他所做的一切,他发明的一切,都是出于追求这一愿景、这一目标,并且通过与皮亚杰、蒙特梭利、杰罗姆·布鲁纳等人采纳的原则来实现,这些人研究了儿童的思维方式。
[[46:37]] 而与软件活动主义最为广泛联系在一起的人物可能是理查德·斯托曼。斯托曼启动了 GNU 项目,这在今天构成了任何 Linux 系统的一个重要组成部分。他还创建了自由软件基金会,编写了 GCC、GPL 等等。他的原则是软件必须自由,即自由的意义上,并且他对此表达了非常明确的含义。他一直非常清楚地认为软件自由是一种道德上的对与错,并且在自己的生活中采取了特别毫不妥协的态度。
[[47:10]] 所有这些极具影响力的人物都将他们的一生奉献给了为特定理想而战,他们对对错有着非常清晰的认识。通常情况下,他们会与不承认他们所认为的错误的权威或主流进行斗争。今天,世界仍然远未实现任何他们理想中的状态,因此他们仍然看到一个危机四伏的世界,他们继续奋斗。他们一直在奋斗。
[[47:41]] 现在我不是在说你必须过这种生活方式。我也不是说你应该过这种生活方式。我要说的是你可以过这种生活方式。这是一种可供选择的生活方式,而且不会经常听到。你的职业顾问不会建议你开始一个个人的十字军东征。在社交领域可能会,但在技术领域不会。相反,世界会试图让你通过一项技能来定义自己。
[[48:08]] 这就是为什么你在大学里有一个主修专业。这就是为什么你有一个职称。你是一名软件工程师。你可能会专门成为数据库工程师或前端工程师,并且会被要求设计前端。这可能是有价值的,如果你想要在追求卓越并练习一项技能上花费你的一生,你可以选择这条路。这是一位工匠的路径。这是最常见的路径。你真正听说的另一条路径就是问题解决者的路径。所以我将创业精神和学术研究看作是这个硬币的两面。有这个领域。有在这个领域中的一系列问题,或市场上的需求。你进入其中,选择一个问题,你解决它,你在那里做出你的贡献。也许后来,你选择另一个问题,你解决它,你在那里做出你的贡献。同样,这可能是有价值的和有意义的,如果这是你想做的,那么你可以选择这条路。
[[49:04]] 但我没看到 Larry Tesler 走过这两条路之一。我不会说他为用户体验设计领域做出了贡献,因为那时还没有这样的领域。他没有选择解决某个开放性问题,而是提出了一些只存在于他自己头脑中的问题,而且没人认可。当然,他也没有以他的手艺定义自己,而是以他的事业。以他为维护的原则。我敢肯定,如果你去查维基百科,会说他是计算机科学家或者用户体验领域的某种东西,但对我来说,这就像说 Elizabeth Cady Stanton 是一个社区组织者一样。不,Elizabeth Cady Stanton 确立了妇女选举权的原则。那才是她的身份。那是她选择的身份,而 Larry Tesler 确立了无模态原则。他有这个愿景,他实现了这个愿景。
[[50:01]] 所以,你可以选择这种生活。或者也许它最终会选择你。这可能不会立即发生。找到一个原则可能需要时间,因为找到一个原则本质上是一种自我发现,你试图弄清楚你的生活应该是关于什么。你想作为一个人站在什么位置。对我来说花了像十年的时间。在我真正理解我的原则之前,我的二十岁过得很艰难。当我年轻的时候,我觉得我必须以这种方式生活,但我只能偶尔看到对我重要的东西,但没有大局观。这对我来说非常困扰。我所要做的就是做很多事情。做许多事情,做许多不同类型的事情。学习许多事情,体验许多,许多事情。并利用所有这些经验来分析自己。将所有这些经验作为一种分析自己的方式。把所有这些经验拿来问自己:这与我产生共鸣吗?这是否排斥我?我是否不在乎?积累这些经验,因为某种原因我对它们有很强烈的感觉,并试图理解其中的意义。试图弄清楚其中的秘密成分,这些让我如此强烈反应的经验中到底是什么。
[[51:16]] 现在我认为每个人都是不同的。而我谈论过的所有人都有他们自己的起源故事,你可以去了解。我只想说,局限于练习一项技能可能会使你难以获得那种似乎对这种工作如此有价值的广泛经验。
[[51:35]] 最后,如果你选择遵循一个原则,这个原则不能仅仅是你相信的一些老生常谈。你会听到很多人说他们想要让软件更易于使用。或者他们想要让用户感到愉悦。或者他们想要简化事物。这是一个当前非常流行的想法。每个人都想要简化事物。这些都是很好的想法,也许会给你一个方向,但它们太模糊了,不足以直接采取行动。拉里·特斯勒喜欢简单。但他的原则是这个特定的见解:没有人应该被困在一种模式中。这是一个强有力的原则,因为它给了他一种新的看待世界的方式。它以一种相当客观的方式将世界划分为对和错。所以,他可以看着某人选择文本,然后问:这个人处于一种模式中吗?是或否?如果是,他必须对此做些什么。同样地,我相信创作者需要强大的工具。这是一个很好的想法,但它并没有真正帮我什么忙。我的原则是创作者需要这种即时的联系。所以我可以看着你改变一行代码,然后问:你立即看到了那个改变的效果吗?是或否?如果不是,我得对此做些什么。
[[52:52]] 而且,我给你展示的所有演示都是我做到了这一点,都是我遵循这个原则,并让它带领我做到了我需要做的事情。因此,如果你有一个指导原则和具体的洞见,它将引导你。你会始终知道你所做的是否正确。
[[53:19]] 生活有许多种方式。也许你在生活中最重要的认识就是,你的生活的每一个方面都是一个选择。但是也有默认的选择。你可以选择在生活中懒散地前行,接受已经为你铺好的道路。你可以选择接受世界的现状。但你不必这样。如果你觉得世界上有什么是不对的,而你又有一个更好世界的愿景,你可以找到你的指导原则。你可以为一个事业而战。所以在这次演讲之后,我希望你花一点时间思考对你而言重要的事情。你相信什么。你可能会为何而奋斗。
[[54:06]] 谢谢。[掌声]
透明的 xmobar:ezyang 的博客
透明的 xmobar
我应该在做的事情:研究生院个人陈述。
我过去五个小时实际上在做的事情:透明的 xmobar。
它使用了可怕的“从根 X 窗口获取像素图”的 hack。你可以在这里获取补丁,但我还没有投入足够的精力来使其成为一个可配置的选项;如果你只是编译了那个分支,你会得到一个 alpha 值为 100/255、着色为黑色的 xmobar。(该算法需要一些工作来正确地泛化到不同的着色;欢迎提出建议!)也许其他人会提供一个更完善的补丁。(也应该鼓动更完整的 XRender 绑定集!)
这在与支持几乎相同的着色和透明行为的 trayer 非常配合得不错。Trayer 在 Oneiric 上也很好,因为它合理地调整了新的电池图标的大小,而 stalonetray 则没有。如果你想知道为什么字体看起来是抗锯齿的,那是因为我编译时启用了 XFT 支持。
(而且是的,显然我的电池容量是 101%。加油!)
更新。 功能已经美化并且可以配置。在你的配置文件中调整alpha
值:0 表示完全透明,255 表示不透明。我已经提交了一个拉取请求。
旅行:2012 年春季版 : ezyang's 博客
旅行:2012 年春季版
由于各种原因(主要是与博士相关),我将在接下来的一个月里有些许旅行。
-
2 月 29 日至 3 月 2 日在新泽西州普林斯顿
-
3 月 5 日至 3 月 7 日在宾夕法尼亚州匹兹堡
-
3 月 9 日至 3 月 12 日在加利福尼亚州帕洛阿尔托
如果你在这些地区之一,想打个招呼,请告诉我!
旅行建议:ezyang 的博客
旅行建议
我将在以下时间到达以下地点:
-
巴黎直到 12/22 日的晚上
-
柏林,从 12/23 到 12/24
-
德累斯顿,12/24 日
-
慕尼黑,从 12/25 到 12/26
-
苏黎世,12/27 日
-
卢塞恩,从 12/28 到 12/29
新年计划仍然有些模糊,所以我会在那时发布另一个更新。如果你想见面,请告诉我!
无关的陈述。我去了蓬皮杜艺术中心的蒙德里安展览,尽管这件特别的珍宝并不在展览本身(它在女性艺术家的收藏中),我还是忍不住拍了一张照片。
尝试 Backpack:Cabal 包:ezyang’s 博客
本文是关于如何尝试使用 Backpack,一个新的 Haskell 混合包系统的系列文章的第二部分。在上一篇文章中,我们描述了如何使用 GHC 的新ghc --backpack
模式快速尝试 Backpack 的新签名特性。不幸的是,目前没有办法将输入文件分发到这种模式作为 Hackage 上的包。因此,在本文中,我们将介绍如何组装具有相同功能的等效 Cabal 包。
GHC 8.2,cabal-install 2.0
在开始本教程之前,您需要确保您已经安装了最新版本的GHC 8.2和cabal-install 2.0。当它们更新后,您应该看到:
ezyang@sabre:~$ ghc-8.2 --version
The Glorious Glasgow Haskell Compilation System, version 8.2.1
ezyang@sabre:~$ /opt/cabal/2.0/bin/cabal --version
cabal-install version 2.0.0.0
compiled using version 2.0.0.2 of the Cabal library
我们的目标
这是我们在上篇文章中开发的代码摘录,我已经删除了所有的模块/签名内容:
unit str-bytestring where
module Str
unit str-string where
module Str
unit regex-types where
module Regex.Types
unit regex-indef where
dependency regex-types
signature Str
module Regex
unit main where
dependency regex-types
dependency regex-indef[Str=str-string:Str] (Regex as Regex.String)
dependency regex-indef[Str=str-bytestring:Str] (Regex as Regex.ByteString)
module Main
将此文件翻译为 Cabal 包的一种明显方法是定义每个单元的包。然而,我们也可以定义一个包含许多内部库的单个包——这是一种独立于 Backpack 的新功能,允许您在单个包内定义私有辅助库。由于这种方法涉及的模板代码较少,我们将在将库“生产化”为单独的包之前首先描述它。
对于所有这些示例,我们假设模块和签名的源代码已经复制粘贴到适当的 hs
和 hsig
文件中。您可以在backpack-regex-example 的 source-only 分支中找到这些文件。
Single package layout
在本节中,我们将逐步介绍将每个单元定义为内部库的 Cabal 文件。您可以在backpack-regex-example 的 single-package 分支找到此版本的所有文件。此包可以使用传统的 cabal configure -w ghc-8.2
(将 ghc-8.2
替换为 GHC 8.2 安装路径,或者如果 ghc
已经是 GHC 8.2,则省略它)构建,然后进行 cabal build
。
包文件的标题非常普通,但由于 Backpack 使用了新的 Cabal 功能,cabal-version
必须设置为 >=1.25
(请注意,Backpack 不支持 Custom
设置):
name: regex-example
version: 0.1.0.0
build-type: Simple
cabal-version: >=1.25
私有库。str-bytestring
,str-string
和 regex-types
都是完全传统的 Cabal 库,只包含模块。在早期的 Cabal 版本中,我们需要为它们中的每一个制作一个包。然而,通过私有库,我们可以简单地列出多个带有库内部名称注释的库段:
library str-bytestring
build-depends: base, bytestring
exposed-modules: Str
hs-source-dirs: str-bytestring
library str-string
build-depends: base
exposed-modules: Str
hs-source-dirs: str-string
library regex-types
build-depends: base
exposed-modules: Regex.Types
hs-source-dirs: regex-types
为了保持每个内部库的模块分开,我们为每个给出了一个不同的 hs-source-dirs
。这些库可以在此包内部依赖,但对外部客户端是隐藏的;只有 公共库(用无名称的 library
段表示)是公开可见的。
不定库。 regex-indef
稍有不同,因为它有一个签名。但编写它的库并不完全不同:签名放在名为 signatures
的适当命名的字段中:
library regex-indef
build-depends: base, regex-types
signatures: Str
exposed-modules: Regex
hs-source-dirs: regex-indef
实例化。 我们如何实例化 regex-indef
?在我们的 bkp
文件中,我们必须明确指定如何填写包的签名:
dependency regex-indef[Str=str-string:Str] (Regex as Regex.String)
dependency regex-indef[Str=str-bytestring:Str] (Regex as Regex.ByteString)
使用 Cabal,这些实例化可以通过更间接的 mix-in linking 过程来指定,其中一个包的依赖关系被 "混合在一起",一个依赖的所需签名被另一个依赖的暴露模块填充。在编写 regex-example
可执行文件之前,让我们编写一个 regex
库,它类似于 regex-indef
,但专门用于 String
:
library regex
build-depends: regex-indef, str-string
reexported-modules: Regex as Regex.String
这里,regex-indef
和 str-string
通过 mix-in linking 混合链接在一起:来自 str-string
的 Str
模块填充了 regex-indef
的 Str
要求。然后,这个库重新导出 Regex
,并使用新名称以明确表示它是 String
的实例化。
我们可以轻松地为 regex-indef
的 ByteString
实例化版本做同样的事情:
library regex-bytestring
build-depends: regex-indef, str-bytestring
reexported-modules: Regex as Regex.ByteString
将所有这些联系起来。 添加可执行文件非常简单,然后构建代码:
executable regex-example
main-is: Main.hs
build-depends: base, regex, regex-bytestring, regex-types
hs-source-dirs: regex-example
在包的根目录下,您可以使用 cabal configure; cabal build
来构建包(确保您传递了 -w ghc-head
!)。或者,您可以使用 cabal new-build
以同样的效果。
有多种方法可以做到这一点
在前面的代码示例中,我们使用 reexported-modules
在 声明时间 重命名模块,以避免它们互相冲突。但是,这仅在我们创建了额外的 regex
和 regex-bytestring
库时才可能。在某些情况下(尤其是如果我们实际上正在创建新的包而不是内部库),这可能会非常麻烦,因此 Backpack 提供了一种在 使用时间 重命名模块的方式,使用 mixins
字段。它的工作方式如下:在 build-depends
中声明的任何包可以在 mixins
中指定,使用显式的重命名,指定应该将哪些模块引入作用域,并使用什么名称。
例如,str-string
和 str-bytestring
都导出一个名为 Str
的模块。为了不使用包限定的导入来引用这两个模块,我们可以如下重命名它们:
executable str-example
main-is: Main.hs
build-depends: base, str-string, str-bytestring
mixins: str-string (Str as Str.String),
str-bytestring (Str as Str.ByteString)
hs-source-dirs: str-example
mixins
字段的语义是我们仅将导入规范中明确列出的模块(Str as Str.String
)引入到导入范围内。如果一个包在 mixins
中从不出现,则默认将所有模块引入范围内(给出 build-depends
的传统行为)。这确实意味着,如果你说 mixins: str-string ()
,你可以强制一个组件依赖于 str-string
,但不会引入其任何模块。
有人认为包作者应避免定义具有冲突模块名称的包。因此,假设我们重构 str-string
和 str-bytestring
以具有唯一的模块名称:
library str-string
build-depends: base
exposed-modules: Str.String
hs-source-dirs: str-string
library str-bytestring
build-depends: base, bytestring
exposed-modules: Str.ByteString
hs-source-dirs: str-bytestring
然后我们需要重写 regex
和 regex-bytestring
,将 Str.String
和 Str.ByteString
重命名为 Str
,以填补 regex-indef
的空缺:
library regex
build-depends: regex-indef, str-string
mixins: str-string (Str.String as Str)
reexported-modules: Regex as Regex.String
library regex-bytestring
build-depends: regex-indef, str-bytestring
mixins: str-bytestring (Str.ByteString as Str)
reexported-modules: Regex as Regex.ByteString
实际上,通过 mixins
字段,我们可以完全避免定义 regex
和 regex-bytestring
的外壳库。我们可以通过在 mixins
中两次声明 regex-indef
,分别重命名其要求来做到这一点:
executable regex-example
main-is: Main.hs
build-depends: base, regex-indef, str-string, str-bytestring, regex-types
mixins: regex-indef (Regex as Regex.String)
requires (Str as Str.String),
regex-indef (Regex as Regex.ByteString)
requires (Str as Str.ByteString)
hs-source-dirs: regex-example
这个特定示例的完整代码在backpack-regex-example 的更好单包分支中给出。
注意,要求的重命名在语法上由 requires
关键字引导。
编写 Backpack 包的艺术仍处于起步阶段,因此尚不清楚最终会采用哪些约定。但这是我的建议:在定义意图实现签名的模块时,遵循现有的无冲突模块名称约定。但是,将您的模块重新导出到签名名称。这个技巧利用了 Cabal 只有在实际使用时才会报告模块冗余的事实。所以,假设我们有:
library str-string
build-depends: base
exposed-modules: Str.String
reexported-modules: Str.String as Str
hs-source-dirs: str-string
library str-bytestring
build-depends: base, bytestring
exposed-modules: Str.ByteString
reexported-modules: Str.ByteString as Str
hs-source-dirs: str-bytestring
现在所有以下组件都可以工作:
library regex
build-depends: regex-indef, str-string
reexported-modules: Regex as Regex.String
library regex-bytestring
build-depends: regex-indef, str-bytestring
reexported-modules: Regex as Regex.ByteString
-- "import Str.String" is unambiguous, even if "import Str" is
executable str-example
main-is: Main.hs
build-depends: base, str-string, str-bytestring
hs-source-dirs: str-example
-- All requirements are renamed away from Str, so all the
-- instantiations are unambiguous
executable regex-example
main-is: Main.hs
build-depends: base, regex-indef, str-string, str-bytestring, regex-types
mixins: regex-indef (Regex as Regex.String)
requires (Str as Str.String),
regex-indef (Regex as Regex.ByteString)
requires (Str as Str.ByteString)
hs-source-dirs: regex-example
独立的包
好的,那么我们如何将其扩展成一个无限制包的生态系统,每个包都可以单独使用并由不同的个人维护呢?库模块基本与上述相同;只需为每个模块创建一个独立的包。不再在此复制所有样板内容,完整的源代码可在backpack-regex-example 的多包分支中找到。
有一个重要的陷阱:包管理器需要知道如何实例化和构建这些 Backpack 包(在单个包情况下,智能完全封装在 Cabal
库中)。截至目前,唯一知道如何做到这一点的命令是 cabal new-build
(我计划最终支持 stack
,但要在完成论文后才会,而且我不打算永远支持旧式的 cabal install
。)
幸运的是,使用cabal new-build
构建regex-example
非常简单;只需说cabal new-build -w ghc-head regex-example
。完成!
结论
如果你真的想要真正地使用 Backpack,你可以做什么?有几种可能性:
-
如果你只想使用 GHC 8.2,并且只需要在内部参数化代码(其中公共库看起来像普通的非 Backpack 包)时,使用内部库与 Backpack 非常合适。生成的包可以使用 Stack 和 cabal-install 构建,只要你使用的是 GHC 8.2。这可能是你能够实际应用 Backpack 的最实用方式;主要问题是 Haddock 不知道如何处理重新导出的模块,但这应该可以解决。
-
如果你只想使用
cabal new-build
,那么你也可以编写有要求的包,并让客户决定如何实现他们的包。
除了潜在的任何潜在错误外,实际世界中使用 Backpack 的最大障碍可能是对 Haddock 的支持不足。但如果你愿意暂时忽略这一点,请试试看!
尝试 Backpack:ghc –backpack:ezyang's 博客
Backpack,一个用于 Haskell 中混合包的新系统,已经随着 GHC 8.2 发布。虽然 Backpack 与 Cabal 包系统紧密集成,但仍然可以使用一个新命令 ghc --backpack
玩耍。在开始之前,请确保你有一个足够新的 GHC 版本:
ezyang@sabre:~$ ghc-8.2 --version
The Glorious Glasgow Haskell Compilation System, version 8.2.1
顺便说一句,如果你想真正地开始使用 Backpack(包括 Cabal 包等),跳过本教程直接参阅 Try Backpack: Cabal packages。
Hello World
GHC 支持一种新的文件格式,bkp
文件,允许你在单个源文件中轻松定义多个模块和包,这样就可以轻松地使用 Backpack 进行实验。这种格式不适合大规模编程(bkp
文件与 Cabal 没有集成,我们也不打算添加这样的集成),但我们会在教程中使用它,因为它非常方便在不与大量 Cabal 包混淆的情况下玩转 Backpack。
这是一个简单的 "Hello World" 程序:
unit main where
module Main where
main = putStrLn "Hello world!"
我们定义了一个单元(类似于包),具有特殊名称 main
,在其中定义了一个 Main
模块(同样是特殊名称),包含我们的 main
函数。将其放入名为 hello.bkp
的文件中,然后运行 ghc --backpack hello.bkp
(使用您的 GHC nightly)。这将在 main/Main
处生成一个可执行文件,您可以运行它;您还可以使用 -o filename
显式指定所需的输出文件名。请注意,默认情况下,ghc --backpack
创建一个与每个单元同名的目录,因此 -o main
不起作用(它会给出链接器错误;请使用其他名称!)
A Play on Regular Expressions
让我们写一些真正使用 Backpack 的非平凡代码。在本教程中,我们将按照 A Play on Regular Expressions(Sebastian Fischer, Frank Huch, Thomas Wilke)中描述的简单正则表达式匹配器写一个简单的示例。匹配器本身效率低下(通过测试所有指数级字符串分解来检查匹配),但足以说明 Backpack 的许多关键概念。
要开始,让我们复制粘贴功能珍珠中的代码到 Backpack 文件的 Regex
模块中,并写一个小测试程序来运行它:
unit regex where
module Regex where
-- | A type of regular expressions.
data Reg = Eps
| Sym Char
| Alt Reg Reg
| Seq Reg Reg
| Rep Reg
-- | Check if a regular expression 'Reg' matches a 'String'
accept :: Reg -> String -> Bool
accept Eps u = null u
accept (Sym c) u = u == [c]
accept (Alt p q) u = accept p u || accept q u
accept (Seq p q) u =
or [accept p u1 && accept q u2 | (u1, u2) <- splits u]
accept (Rep r) u =
or [and [accept r ui | ui <- ps] | ps <- parts u]
-- | Given a string, compute all splits of the string.
-- E.g., splits "ab" == [("","ab"), ("a","b"), ("ab","")]
splits :: String -> [(String, String)]
splits [] = [([], [])]
splits (c:cs) = ([], c:cs):[(c:s1,s2) | (s1,s2) <- splits cs]
-- | Given a string, compute all possible partitions of
-- the string (where all partitions are non-empty).
-- E.g., partitions "ab" == [["ab"],["a","b"]]
parts :: String -> [[String]]
parts [] = [[]]
parts [c] = [[[c]]]
parts (c:cs) = concat [[(c:p):ps, [c]:p:ps] | p:ps <- parts cs]
unit main where
dependency regex
module Main where
import Regex
nocs = Rep (Alt (Sym 'a') (Sym 'b'))
onec = Seq nocs (Sym 'c')
-- | The regular expression which tests for an even number of cs
evencs = Seq (Rep (Seq onec onec)) nocs
main = print (accept evencs "acc")
如果你将这段代码放在 regex.bkp
中,可以再次使用 ghc --backpack regex.bkp
编译它,并在 main/Main
处调用生成的可执行文件。它应该会打印出 True
。
Functorizing the matcher
先前显示的代码并不好,因为它将String
硬编码为用于正则表达式匹配的类型。一个合理的泛化(你可以在原始论文中看到)是在任意符号列表上进行匹配;然而,我们可能也希望在非列表类型(如ByteString
)上进行匹配。为了支持所有这些情况,我们将使用 Backpack 来“泛型化”(在 ML 术语中)我们的匹配器。
我们将通过创建一个新单元regex-indef
并编写一个提供字符串类型的签名(我们决定称其为Str
,以避免与String
混淆)来完成这个任务。以下是我所采取的步骤:
-
首先,我将旧的
Regex
实现复制粘贴到新的单元中。我用Str
替换了所有String
的出现,并删除了splits
和parts
:我们需要在签名中实现这些。 -
接下来,我们创建一个新的
Str
签名,它由Regex
引入,并定义了我们需要支持的类型和操作(splits
和parts
):signature Str where data Str splits :: Str -> [(Str, Str)] parts :: Str -> [[Str]]
-
在这一点上,我运行了
ghc --backpack
来对新单元进行类型检查。但我得到了两个错误!regex.bkp:90:35: error: • Couldn't match expected type ‘t0 a0’ with actual type ‘Str’ • In the first argument of ‘null’, namely ‘u’ In the expression: null u In an equation for ‘accept’: accept Eps u = null u regex.bkp:91:35: error: • Couldn't match expected type ‘Str’ with actual type ‘[Char]’ • In the second argument of ‘(==)’, namely ‘[c]’ In the expression: u == [c] In an equation for ‘accept’: accept (Sym c) u = u == [c]
除了遍历
null
的无意义外,这些错误非常明显:Str
是一个完全抽象的数据类型:我们不能假设它是一个列表,也不知道它有什么实例。为了解决这些类型错误,我引入了组合子null
和singleton
,一个instance Eq Str
,并重写了Regex
以使用这些组合子(这是一个非常谨慎的改变)。 (注意,我们不能写instance Traversable Str
;这是一种类型不匹配。)
这是我们最终的正则表达式单元的不定版本:
unit regex-indef where
signature Str where
data Str
instance Eq Str
null :: Str -> Bool
singleton :: Char -> Str
splits :: Str -> [(Str, Str)]
parts :: Str -> [[Str]]
module Regex where
import Prelude hiding (null)
import Str
data Reg = Eps
| Sym Char
| Alt Reg Reg
| Seq Reg Reg
| Rep Reg
accept :: Reg -> Str -> Bool
accept Eps u = null u
accept (Sym c) u = u == singleton c
accept (Alt p q) u = accept p u || accept q u
accept (Seq p q) u =
or [accept p u1 && accept q u2 | (u1, u2) <- splits u]
accept (Rep r) u =
or [and [accept r ui | ui <- ps] | ps <- parts u]
(为了简单起见,现在我还没有将Char
参数化。)
实例化这个函数(String)
这一切都很好,但我们实际上不能运行这段代码,因为没有Str
的实现。让我们写一个新单元,提供一个模块,其中包含所有这些类型和函数的实现,使用String
,将旧的splits
和parts
实现复制粘贴进来:
unit str-string where
module Str where
import Prelude hiding (null)
import qualified Prelude as P
type Str = String
null :: Str -> Bool
null = P.null
singleton :: Char -> Str
singleton c = [c]
splits :: Str -> [(Str, Str)]
splits [] = [([], [])]
splits (c:cs) = ([], c:cs):[(c:s1,s2) | (s1,s2) <- splits cs]
parts :: Str -> [[Str]]
parts [] = [[]]
parts [c] = [[[c]]]
parts (c:cs) = concat [[(c:p):ps, [c]:p:ps] | p:ps <- parts cs]
当为函数编写 Backpack 实现时,一个怪癖是 Backpack 在多态函数上不执行子类型匹配,因此你不能使用多态函数Traversable t => t a -> Bool
实现Str -> Bool
(添加这个将是一个有趣的扩展,但并不是完全平凡的)。所以我们必须写一个稍微增加阻抗匹配的绑定,将null
单态化到预期的类型。
为了用str-string:Str
实例化regex-indef
,我们在main
中修改了依赖项:
-- dependency regex -- old
dependency regex-indef[Str=str-string:Str]
Backpack 文件要求显式指定实例化(这与 Cabal 文件不同,后者使用混合链接来确定实例化)。在这种情况下,实例化指定regex-indef
的名为Str
的签名应由str-string
中的Str
模块填充。
进行这些更改后,运行ghc --backpack
;你应该会得到一个完全相同的结果。
实例化这个函数(ByteString)
参数化 regex
的整个目的是使我们能够有第二个 Str
的实现。所以让我们继续编写一个 bytestring
实现。经过一点工作,你可能最终得到这个:
unit str-bytestring where
module Str(module Data.ByteString.Char8, module Str) where
import Prelude hiding (length, null, splitAt)
import Data.ByteString.Char8
import Data.ByteString
type Str = ByteString
splits :: Str -> [(Str, Str)]
splits s = fmap (\n -> splitAt n s) [0..length s]
parts :: Str -> [[Str]]
parts s | null s = [[]]
| otherwise = do
n <- [1..length s]
let (l, r) = splitAt n s
fmap (l:) (parts r)
关于这个实现,有两点需要注意:
-
与
str-string
不同,它在其模块体中显式定义了每个所需的方法,str-bytestring
通过重新导出来自Data.ByteString.Char8
的所有实体(适当地单态化)来提供null
和singleton
。我们聪明地选择了我们的命名,以符合现有字符串包的命名约定! -
我们的
splits
和parts
的实现比原始的String
实现中的 consing 和 unconsing 要优化得多。我经常听到人们说String
和ByteString
的性能特性非常不同,因此你不应该在同一个实现中混合它们。我认为这个例子表明,只要你对字符串有足够高级的操作,这些性能差异最终会平滑化;并且仍然有相当大的代码块可以在不同的实现之间重用。
要使用 bytestring-string:Str
实例化 regex-indef
,我们再次修改 main
中的依赖项:
-- dependency regex -- oldest
-- dependency regex-indef[Str=str-string:Str] -- old
dependency regex-indef[Str=str-bytestring:Str]
我们还需要粘贴 {-# LANGUAGE OverloadedStrings #-}
命令,以便将 "acc"
解释为 ByteString
(不幸的是,bkp
文件格式仅支持适用于所有定义的模块的语言命令,因此将此命令放在文件顶部)。但除此之外,一切都按预期工作!
同时使用两个实例
没有任何阻碍我们同时使用 regex-indef
的两个实例,只需取消注释两个 dependency
声明,除了每个依赖项提供的模块名称之间冲突且不明确外。因此,Backpack 文件为模块提供了重命名语法,让你为每个导出的模块指定一个不同的名称:
dependency regex-indef[Str=str-string:Str] (Regex as Regex.String)
dependency regex-indef[Str=str-bytestring:Str] (Regex as Regex.ByteString)
我们应该如何修改 Main
来在 String
和 ByteString
上运行我们的正则表达式?但是 Regex.String.Reg
和 Regex.ByteString.Reg
是一样的吗?编译器的快速查询将揭示它们不是一样的。这是因为 Backpack 的类型标识规则:所有在一个单元中定义的类型的标识都取决于所有签名的实例化方式,即使该类型实际上并不依赖于来自签名的任何类型。如果我们希望只有一个 Reg
类型,我们将不得不从 reg-indef
中提取它,并为它单独创建一个单元,没有签名。
重构后,这是最终的完整程序:
{-# LANGUAGE OverloadedStrings #-}
unit str-bytestring where
module Str(module Data.ByteString.Char8, module Str) where
import Prelude hiding (length, null, splitAt)
import Data.ByteString.Char8
import Data.ByteString
type Str = ByteString
splits :: Str -> [(Str, Str)]
splits s = fmap (\n -> splitAt n s) [0..length s]
parts :: Str -> [[Str]]
parts s | null s = [[]]
| otherwise = do
n <- [1..length s]
let (l, r) = splitAt n s
fmap (l:) (parts r)
unit str-string where
module Str where
import Prelude hiding (null)
import qualified Prelude as P
type Str = String
null :: Str -> Bool
null = P.null
singleton :: Char -> Str
singleton c = [c]
splits :: Str -> [(Str, Str)]
splits [] = [([], [])]
splits (c:cs) = ([], c:cs):[(c:s1,s2) | (s1,s2) <- splits cs]
parts :: Str -> [[Str]]
parts [] = [[]]
parts [c] = [[[c]]]
parts (c:cs) = concat [[(c:p):ps, [c]:p:ps] | p:ps <- parts cs]
unit regex-types where
module Regex.Types where
data Reg = Eps
| Sym Char
| Alt Reg Reg
| Seq Reg Reg
| Rep Reg
unit regex-indef where
dependency regex-types
signature Str where
data Str
instance Eq Str
null :: Str -> Bool
singleton :: Char -> Str
splits :: Str -> [(Str, Str)]
parts :: Str -> [[Str]]
module Regex where
import Prelude hiding (null)
import Str
import Regex.Types
accept :: Reg -> Str -> Bool
accept Eps u = null u
accept (Sym c) u = u == singleton c
accept (Alt p q) u = accept p u || accept q u
accept (Seq p q) u =
or [accept p u1 && accept q u2 | (u1, u2) <- splits u]
accept (Rep r) u =
or [and [accept r ui | ui <- ps] | ps <- parts u]
unit main where
dependency regex-types
dependency regex-indef[Str=str-string:Str] (Regex as Regex.String)
dependency regex-indef[Str=str-bytestring:Str] (Regex as Regex.ByteString)
module Main where
import Regex.Types
import qualified Regex.String
import qualified Regex.ByteString
nocs = Rep (Alt (Sym 'a') (Sym 'b'))
onec = Seq nocs (Sym 'c')
evencs = Seq (Rep (Seq onec onec)) nocs
main = print (Regex.String.accept evencs "acc") >>
print (Regex.ByteString.accept evencs "acc")
还有更多!
继续阅读下一篇博客文章,尝试 Backpack:Cabal packages,我将告诉你如何将这个原型转化为一组 Cabal packages 中的 bkp
文件。
后记。 如果你感到冒险的话,尝试进一步参数化regex-types
,使其不再将Char
硬编码为元素类型,而是某种任意的元素类型Elem
。了解到,你可以使用语法dependency regex-indef[Str=str-string:Str,Elem=str-string:Elem]
来实例化多个签名,而且如果你依赖一个带有签名的包,你必须通过使用语法dependency regex-types[Elem=<Elem>]
来传递该签名。如果这听起来用户不友好,那就是真的!这就是为什么在 Cabal 包的宇宙中,实例化是隐式完成的,使用混合链接。
Rust 开发者都应该了解的借用检查器中的两个 bug:ezyang 的博客
来源:
blog.ezyang.com/2013/12/two-bugs-in-the-borrow-checker-every-rust-developer-should-know-about/
如果是这样的话,你可能已经遇到了借用检查器中两个臭名昭著的 bug 之一。在这篇文章中,我想描述这两个 bug,给出它们可能出现的情况,并描述一些解决方法。希望这类文章很快就会过时,但它们的修复方法相当复杂,如果你今天尝试在 Rust 中编程,不可避免地会遇到这些 bug。
可变借用过于急切(#6268)
总结。 当你使用 &mut
(无论是显式还是隐式)时,Rust 会立即将 lvalue 视为借用,并强加其限制(例如,lvalue 不能再次借用)。然而,在许多情况下,借用指针直到后来才会被使用,因此立即强加限制可能会导致错误。当存在 隐式 使用 &mut
时,这种情况最有可能发生。(Bug #6268)
症状。 你会收到错误消息“因为它也作为不可变借用,所以无法借用 foo
”,但报告的第二次借用是对象调度方法调用,或者在标记的借用发生时看起来不应该被借用。
示例。 原始的 bug 报告描述了嵌套方法调用的情况,其中外部方法调用在其签名中有 &mut self
:
fn main() {
let mut map = std::hashmap::HashMap::new();
map.insert(1, 2);
map.insert(2, *map.get(&1)); // XXX
}
test.rs:4:17: 4:20 error: cannot borrow `map` as immutable because it is also borrowed as mutable
test.rs:4 map.insert(2, *map.get(&1)); // XXX
^~~
test.rs:4:2: 4:5 note: second borrow of `map` occurs here
test.rs:4 map.insert(2, *map.get(&1)); // XXX
^~~
这段代码希望获取键为 1
的值并存储在键为 2
的位置。为什么会失败呢?考虑签名 fn insert(&mut self, key: K, value: V) -> bool
:在尝试评估其参数之前,insert
方法调用会立即对 map
获取一个 &mut
借用。如果我们展开方法调用,顺序就变得清楚了:HashMap::insert(&mut map, 2, *map.get(&1))
(注意:此语法尚未实现)。因为 Rust 会从左到右评估参数,这等效于:
let x_self : &mut HashMap<int> = &mut map;
let x_arg1 : int = 2;
let x_arg2 : int = *map.get(&1); // XXX
HashMap::insert(x_self, x_arg1, x_arg2);
意味着在调用 map.get
时存在活跃的借用。通过进行轻微的重写可以解决该问题:
fn main() {
let mut map = std::hashmap::HashMap::new();
map.insert(1, 2);
let x = *map.get(&1);
map.insert(2, x);
}
敏感到参数顺序的问题,即使没有涉及方法调用。下面是另一个例子,其中没有方法调用:
fn g(x: &mut int) -> int { *x }
fn f(x: &mut int, y: int) { *x += y; }
fn main() {
let mut a = 1;
f(&mut a, g(&mut a));
}
讨论。 幸运的是,这个 bug 很容易解决,虽然有点恼人:在不幸的可变借用之前将所有子表达式移动到 let 绑定中(请参见示例以获取详细操作)。注意:这些子表达式中发生的借用确实必须是临时的;否则,你会遇到合法的“无法两次借用可变”的错误。
借用范围不应总是按词法作用域处理(#6393)
摘要. 当您借用一个指针时,Rust 为其分配一个构成其生命周期的词法范围。这个范围可以小到一个语句,也可以大到整个函数体。然而,Rust 无法计算非词法的生命周期,例如,一个借用的指针仅在函数的一半之前有效。因此,借用可能比用户预期的时间更长,导致借用检查器拒绝某些语句。(Bug #6393)
症状. 您收到“因为它也作为不可变/可变的借用而无法将 foo 借用为不可变/可变”的错误,但您认为先前的借用应该已经过期了。
例子. 这个问题在各种情况下都会出现。引发此错误的最简单的示例如下所示:
fn main() {
let mut x = ~1;
let y = &mut *x;
*y = 1;
let z = &mut *x;
*z = 1;
}
test.rs:5:12: 5:19 error: cannot borrow `*x` as mutable more than once at a time
test.rs:5 let z = &mut *x;
^~~~~~~
test.rs:3:12: 3:19 note: second borrow of `*x` as mutable occurs here
test.rs:3 let y = &mut *x;
^~~~~~~
显然,在*y = 1
之后,y
已经无效了,但是借用检查器无法看到这一点。幸运的是,在这种情况下,很容易添加一个新的词法范围来解决这个问题:
fn main() {
let mut x = ~1;
{
let y = &mut *x;
*y = 1;
}
let z = &mut *x;
*z = 1;
}
那么,这实际上何时成为问题呢?通常的罪魁祸首是match
语句。这里是涉及映射的一些常见代码,您可能希望编写:
extern mod extra;
fn main() {
let mut table = extra::treemap::TreeMap::new();
let key = ~"test1";
match table.find_mut(&key) {
None => table.insert(key.clone(), ~[1]), // XXX
Some(v) => { v.push(1); false }
};
}
test.rs:6:19: 6:24 error: cannot borrow `table` as mutable more than once at a time
test.rs:6 None => table.insert(key.clone(), ~[1]), // XXX
^~~~~
test.rs:5:10: 5:15 note: second borrow of `table` as mutable occurs here
test.rs:5 match table.find_mut(&key) {
^~~~~
table
是整数键到向量的映射。代码在key
处进行插入:如果映射中没有条目,则创建一个新的单元素向量并将其插入该位置;否则,只需将值1
推送到现有向量中。为什么table
在None
分支中被借用?直觉上,对于table.find_mut
的借用应该是无效的,因为我们不再使用任何结果;然而对于 Rust 来说,它只能将借用指针分配给整个match
语句的词法范围,因为借用指针在Some
分支中继续使用(请注意,如果删除Some
分支,则此借用检查)。不幸的是,无法像前面的示例那样插入新的词法范围。(在发布时,我找不到仅使用if
的小示例。)
有时,与变量相关的生命周期可能会强制将其分配给比您预期的更大的词法范围。Issue #9113提供了一个很好的例子(以下是代码摘录):
pub fn read1<'a>(&'a mut self, key: int) -> Option<&'a Data> {
match self.cache.find(&key) {
Some(data) => return Some(data),
None => ()
};
match self.db.find(&key) {
Some(data) => {
let result: &Data = self.cache.find_or_insert(key, data.clone());
Some(result)
},
None => None
}
}
test.rs:22:36: 22:46 error: cannot borrow `(*self).cache` as mutable because it is also borrowed as immutable
test.rs:22 let result: &Data = self.cache.find_or_insert(key, data.clone());
^~~~~~~~~~
test.rs:15:14: 15:24 note: second borrow of `(*self).cache` occurs here
test.rs:15 match self.cache.find(&key) {
^~~~~~~~~~
这段代码试图执行数据库查找;它首先查看缓存并返回缓存的条目(如果有)。否则,它在数据库中查找该值,并在此过程中缓存该值。通常情况下,您希望在第一个匹配中对self.cache
的借用仅扩展到第一个表达式。然而,return
语句却对此产生了影响:它强制data
的生命周期为'a
,包含整个函数体。借用检查器因此得出结论,在函数的任何地方都存在借用,即使函数在获取此借用后立即返回。
讨论. 解决方法取决于导致问题的范围的性质。当涉及match
时,通常可以安排执行不良借用操作,该操作位于match
语句之外,位于一个新的、非重叠的词法范围内。当相关分支不依赖于模式匹配中的任何变量时,可以使用短路控制运算符:
extern mod extra;
use extra::treemap::TreeMap;
fn main() {
let mut table: TreeMap<~str,~[int]> = TreeMap::new();
let key = ~"test1";
match table.find_mut(&key) {
None => {},
Some(v) => { v.push(1); return }
};
table.insert(key.clone(), ~[1]); // None-case
}
或者,与其直接返回,match
语句可以分配一个布尔值,以指示是否应运行None
情况:
extern mod extra;
use extra::treemap::TreeMap;
fn main() {
let mut table: TreeMap<~str,~[int]> = TreeMap::new();
let key = ~"test1";
let is_none = match table.find_mut(&key) {
None => true,
Some(v) => { v.push(1); false }
};
if is_none {
table.insert(key.clone(), ~[1]);
}
}
可以将布尔值详细说明为一个enum
,其中包含可能需要的模式匹配中的任何非引用。请注意,对于借用引用,这种方法不起作用;但在这种情况下,借用确实仍然活跃!
关于生命周期问题的解决方法要困难一些,因为在函数中没有指针不“被借用”的地方。在某些情况下可以起作用的一个技巧是将函数转换为延续传递风格:即,不是返回借用的指针,而是接受一个函数参数,并在函数中调用它。pnkfelix描述了如何修复第三个例子。这消除了变量的生命周期约束并解决了问题。
分配给借用的词法范围可能对代码扰动非常敏感,因为删除对借用的使用可能会导致 Rust 分配(更)小的词法范围给借用,这可能会消除错误。有时,可以通过避免借用来完全避免问题。
FFI 绑定的两个小贴士:ezyang 的博客
FFI 绑定的两个小贴士
主题:[Haskell-cafe] 请审阅我的 Xapian 外部函数接口
谢谢 Oliver!
我还没有时间仔细查看你的绑定,但我有一些初步的想法:
-
你手工编写你的导入。其他几个项目曾经这样做,但当你需要绑定数百个函数并且没有完全正确地执行时,这会很麻烦,然后由于 API 不匹配而导致段错误。考虑使用像 c2hs 这样的工具,可以排除这种可能性(并减少你需要编写的代码!)
-
我看到了很多
unsafePerformIO
,但没有考虑可中断性或线程安全性。使用 Haskell 的人往往希望他们的代码是线程安全和可中断的,所以我们的标准很高;-) 但是,即使看起来是线程安全的 C++ 代码,在底层可能会修改共享内存,所以要仔细检查。
我使用 Sup,因此我日常处理 Xapian。绑定看起来不错。
表示完美二叉树的两种方式:ezyang’s 博客
来源:
blog.ezyang.com/2012/08/statically-checked-perfect-binary-trees/
讨论许多分治算法时的一个常见简化假设是输入列表的大小是二的幂。因此,人们可能会想:我们如何对具有二次幂大小的列表进行编码,以一种不可表示其他属性的方式呢?一个观察是这样的列表是 完美二叉树,因此如果我们有一个完美二叉树的编码,我们也有一个二次幂列表的编码。以下是在 Haskell 中实现此类编码的两种众所周知的方法:一种使用 GADTs,另一种使用嵌套数据类型。我们声称嵌套数据类型的解决方案更为优越。
这篇文章是文学的,但你需要一些类型系统的特性:
{-# LANGUAGE ScopedTypeVariables, GADTs, ImpredicativeTypes #-}
GADTs
一种方法是将树的大小编码到类型中,然后断言两棵树的大小相同。这在 GADTs 中相当容易实现:
data Z
data S n
data L i a where
L :: a -> L Z a
N :: L i a -> L i a -> L (S i) a
通过重用类型变量 i
,N
的构造函数确保我们组合的任意两棵树必须具有相同的大小。这些树可以像普通的二叉树一样解构:
exampleL = N (N (L 1) (L 2)) (N (L 3) (L 4))
toListL :: L i a -> [a] -- type signature is necessary!
toListL (L x) = [x]
toListL (N l r) = toListL l ++ toListL r
从普通列表创建这些树有点微妙,因为 i
类型变量需要小心处理。对列表的存在性也相当有效:
data L' a = forall i. L' { unL' :: L i a }
data Ex a = forall i. Ex [L i a]
fromListL :: [a] -> L' a
fromListL xs = g (Ex (map L xs))
where
g (Ex [x]) = L' x
g (Ex xs) = g (Ex (f xs))
f (x:y:xs) = (N x y) : f xs
f _ = []
嵌套数据类型
另一种方法是直接构建一个等同于 2^n 大小元组的类型(考虑惰性)。例如,在 4-元组的情况下,我们只需写成 ((1, 2), (3, 4))
。然而,还有一个棘手的问题,即如何对这样的结构进行递归。这里使用的技术是引导,由 Adam Buchsbaum 在他的论文中描述,并由 Chris Okasaki 推广:
data B a = Two (B (a, a)) | One a
deriving Show
注意递归提到 B
的情况并不持有 a
,而是 (a, a)
:这就是所谓的“非均匀”递归。
exampleB = Two (Two (One ((1,2), (3,4))))
fromListB :: [a] -> B a
fromListB [x] = One x
fromListB xs = Two (fromListB (pairs xs))
where pairs (x:y:xs) = (x,y) : pairs xs
pairs _ = []
toListB :: B a -> [a]
toListB (One x) = [x]
toListB (Two c) = concatMap (\(x,y) -> [x,y]) (toListB c)
哪个更好?
乍一看,GADT 方法似乎更有吸引力,因为在解构时,数据类型看起来和感觉上很像普通的二叉树。然而,将用户数据解析成嵌套数据类型比解析成 GADTs 要容易得多(由于 Haskell 不是依赖类型语言)。Ralf Hinze 在他的论文 Perfect Trees and Bit-reversal Permutations 中,提出了另一个支持嵌套数据类型的论点:
比较[完美树和二叉树的通常定义],很明显第一个表示比第二个更为简洁。如果我们估计一个k-ary 构造器的空间使用量为k+1个单元,我们可以看到,第一个完美树的排名n消耗了(2n-1)3+(n+1)2*个单元,而第二个则消耗了*(2n-1)3+22^n*个单元。 [这一差异源于所有额外的叶节点。]
尽管如此,解构嵌套数据类型树非常奇怪,如果有一个从传统树上的卡范略图(n :: t a -> t a -> t a , z :: a -> t a)
到嵌套数据类型的有效转换,我们可能会对“异国情调”的嵌套数据类型感到更满意:
cataL :: (t a -> t a -> t a, a -> t a) -> L i a -> t a
cataL (n,z) (N l r) = n (cataL (n,z) l) (cataL (n,z) r)
cataL (n,z) (L x) = z x
对于我们的嵌套数据类型树进行一个卡范略图(f :: a -> t a, g :: t (a, a) -> t a)
:
cataB :: (forall a. a -> t a, forall a. t (a, a) -> t a) -> B a -> t a
cataB (f,g) (One a) = f a
cataB (f,g) (Two t) = g (cataB (f,g) t)
尽管如此,这种转换是可能的,但遗憾的是,它不是一个卡范略图:
cataLB :: forall t a. (t a -> t a -> t a, a -> t a) -> B a -> t a
cataLB (n,z) t = f t z
where
f :: forall b. B b -> (b -> t a) -> t a
f (One a) z = z a
f (Two t) z = f t (\(l,r) -> n (z l) (z r))
思路是创建一个函数(a -> t a) -> t a
,然后我们传入z
以获取最终结果。这是一种历时已久的差异列表/延续传递技巧,我们在其中建立一系列函数调用链,而不是直接尝试建立结果,因为通常嵌套数据类型树上的卡范略图是朝错误的方向进行的。但现在,我们可以轻松地对我们的嵌套数据类型树执行任何我们在普通树上做过的折叠操作,这解决了我们可能有的任何未解决的问题。无论如何,从表示大小的角度来看,嵌套数据类型是优越的。 (有关该问题的另一种看法,请参阅 Jeremy 的评论。)
欲了解更多信息,请查看嵌套数据类型的广义折叠(Richard Bird,Ross Paterson)。
类型类:收敛性、一致性和全局唯一性:ezyang's 博客
来源:
blog.ezyang.com/2014/07/type-classes-confluence-coherence-global-uniqueness/
今天,我想讨论类型类背后的一些核心设计原则,这是 Haskell 中一个非常成功的特性。这里的讨论受到我们在 MSRC 支持背包中使用类型类的工作的密切影响。在我进行背景阅读时,我惊讶地发现人们在谈论类型类时普遍误用了“收敛性”和“一致性”这两个术语。因此,在这篇博文中,我想澄清这一区别,并提出一个新术语,“全局唯一性实例”,用于描述人们口头上所说的收敛性和一致性的属性。
让我们从这两个术语的定义开始。收敛性是来自术语重写的一种属性:如果一组实例是收敛的,那么无论进行约束求解的顺序如何,GHC 都将以一组规范的约束终止,这些约束必须满足任何给定类型类的使用。换句话说,收敛性表明,我们不会因为使用了不同的约束求解算法而得出程序不通过类型检查的结论。
一致性的密切相关者是收敛性(在论文“类型类:探索设计空间”中定义)。该性质表明,程序的每个不同有效的类型推导都会导致具有相同动态语义的生成程序。为什么不同的类型推导会导致不同的动态语义呢?答案是上下文缩减,它选择类型类实例,并将其详细解释为生成代码中的具体字典选择。收敛性是一致性的先决条件,因为对于不能通过类型检查的程序,我们几乎不能谈论其动态语义。
那么,当人们将 Scala 类型类与 Haskell 类型类进行比较时,他们通常指的是全局唯一性实例,定义如下:在完全编译的程序中,对于任何类型,给定类型类的实例解析最多只有一个。像 Scala 这样具有局部类型类实例的语言通常不具备此属性,但在 Haskell 中,我们发现这一属性在构建诸如集合等抽象时非常方便。
那么,实际上 GHC 强制执行哪些属性?在没有任何类型系统扩展的情况下,GHC 使用一组规则来确保类型类解析是一致的和完整的。直观地说,它通过具有非重叠的实例集来实现这一点,确保只有一种方法来解决想要的约束。重叠是比一致性或完整性更严格的限制,通过 OverlappingInstances
和 IncoherentInstances
,GHC 允许用户放宽这一限制,“如果他们知道自己在做什么的话。”
然而,令人惊讶的是,GHC 并不 强制全局唯一性的实例。导入的实例在尝试用于实例解析之前不会被检查是否重叠。考虑以下程序:
-- T.hs
data T = T
-- A.hs
import T
instance Eq T where
-- B.hs
import T
instance Eq T where
-- C.hs
import A
import B
当使用一次性编译时,只有在实际尝试使用 C
中的 Eq
实例时,C
才会报告实例重叠。这是 有意设计:确保没有重叠实例需要及时读取模块可能依赖的所有接口文件。
我们可以总结这三个属性如下。在文化上,Haskell 社区期望实例的全局唯一性能够保持:实例的隐式全局数据库应该是一致的和完整的。然而,GHC 并不强制实例的唯一性:相反,它仅保证在编译任何给定模块时使用的实例数据库的子集是一致的和完整的。当一个实例声明时,GHC 确实会进行一些测试,看看它是否会与可见实例重叠,但检查 绝不完美;真正的类型类约束解析有最终决定权。一个缓解因素是在没有孤儿实例的情况下,GHC 保证会及时注意到实例数据库是否有重叠(假设实例声明检查确实有效……)
显然,GHC 的惰性行为对大多数 Haskeller 来说是令人惊讶的,这意味着懒惰检查通常是足够好的:用户很可能会以某种方式发现重叠的实例。然而,相对简单地构造违反实例的全局唯一性的示例程序是可能的:
-- A.hs
module A where
data U = X | Y deriving (Eq, Show)
-- B.hs
module B where
import Data.Set
import A
instance Ord U where
compare X X = EQ
compare X Y = LT
compare Y X = GT
compare Y Y = EQ
ins :: U -> Set U -> Set U
ins = insert
-- C.hs
module C where
import Data.Set
import A
instance Ord U where
compare X X = EQ
compare X Y = GT
compare Y X = LT
compare Y Y = EQ
ins' :: U -> Set U -> Set U
ins' = insert
-- D.hs
module Main where
import Data.Set
import A
import B
import C
test :: Set U
test = ins' X $ ins X $ ins Y $ empty
main :: IO ()
main = print test
-- OUTPUT
$ ghc -Wall -XSafe -fforce-recomp --make D.hs
[1 of 4] Compiling A ( A.hs, A.o )
[2 of 4] Compiling B ( B.hs, B.o )
B.hs:5:10: Warning: Orphan instance: instance [safe] Ord U
[3 of 4] Compiling C ( C.hs, C.o )
C.hs:5:10: Warning: Orphan instance: instance [safe] Ord U
[4 of 4] Compiling Main ( D.hs, D.o )
Linking D ...
$ ./D
fromList [X,Y,X]
在本地,所有类型类解析都是一致的:在每个模块可见的实例子集中,类型类解析可以无歧义地完成。此外,ins
和 ins'
的类型解决了类型类解析,因此在 D
中,当数据库现在重叠时,不会发生解析,因此错误永远不会被发现。
这个例子很容易被看作是 GHC 中的一个实现上的瑕疵,继续假装类型类实例的全局唯一性是成立的。然而,类型类实例全局唯一性的问题在于它们本质上是非模块化的:你可能会发现自己无法组合两个组件,因为它们意外地定义了相同的类型类实例,尽管这些实例深深地嵌入在组件的实现细节中。对于 Backpack 或者任何模块系统来说,这是一个很大的问题,它们的分离模块化开发宗旨旨在保证,如果库的编写者和应用的编写者按照共同的签名进行开发,链接将会成功。
Type kata: Controlled sharing of references : ezyang’s blog
来源:
blog.ezyang.com/2010/08/type-kata-controlled-sharing-of-references/
命令式. 具有许多子对象的可变数据结构经常迫使任何给定的子对象与一个给定的父数据结构相关联:
class DOMNode {
private DOMDocument $ownerDocument;
// ...
public void appendNode(DOMNode n) {
if (n.ownerDocument != this.ownerDocument) {
throw DOMException("Cannot append node that "
"does not belong to this document");
}
// ...
}
}
客户端代码必须小心,不要混淆属于不同所有者的子对象。对象可以通过特殊函数从一个所有者复制到另一个所有者。
class DOMDocument {
public DOMNode importNode(DOMNode node) {
// ...
}
}
有时,这种风格的函数只能在特定情况下调用。如果复制了可变数据结构,并且你想引用新结构中的一个子对象,但你只有对原始结构的引用,实现可以让你转发这样的指针,但前提是目标结构是最新的副本。
class DOMNode {
private DOMNode $copy;
}
技巧. ST 单子风格的幻影类型允许静态强制将不同单子所有者的子对象分离开来。
{-# LANGUAGE Rank2Types #-}
-- s is the phantom type
newtype DOM s a = ...
newtype Node s = ...
runDom :: (forall s. DOM s ()) -> Document
getNodeById :: Id -> DOM s (Node s)
deleteNode :: Node s -> DOM s ()
-- Does not typecheck, the second runDom uses a fresh
-- phantom variable which does not match node's
runDom $ do
node <- getNodeById "myNode"
let alternateDocument = runDom $ do
deleteNode node
要允许任何单子的值在另一个单子中使用,请实现一个在两个幻影类型中都是多态的函数:
importNode :: Node s -> DOM s2 (Node s2)
setRoot :: Node s -> DOM s ()
-- This now typechecks
runDom $ do
node <- getNodeById "myNode"
let alternateDocument = runDom $ do
node' <- importNode node
setRoot node'
函数可能是单子的,因为实现需要知道Node
被转换为什么所有者。
仅在特定情况下允许翻译,使用类型构造函数(可以使用空数据声明来获取这些)在幻影类型上:
{-# LANGUAGE EmptyDataDecls #-}
data Dup n
getNewNode :: Node s -> DOM (Dup s) (Node (Dup s))
dupDom :: DOM s () -> DOM s (DOM (Dup s) ())
-- This typechecks, and does not recopy the original node
runDom $ do
node <- getNodeById "myNode"
dupDom $ do
node' <- getNewNode node
...
适用性. Haskell 的从业者被鼓励实现和使用纯数据结构,其中共享使得这种关于所有权的细致管理变得不必要。尽管如此,当你通过 FFI 与需要这些不变式的库进行接口时,这种技术仍然是有用的。
类型卡塔:区分具有相同基础表示的不同数据:ezyang 的博客
双关语是最低级的幽默形式。也是无尽的错误来源。
命令式。 在编程中,语义上不同的数据可能具有相同的表示(类型)。使用这些数据需要手动跟踪关于数据的额外信息。当另一种解释大部分时间是正确的时,这是危险的;不完全理解所有额外条件的程序员会被安全感所蒙蔽,可能编写看似正常工作但实际上存在微妙错误的代码。以下是一些真实世界的例子,其中很容易混淆语义。
变量和字面值。 以下是布尔变量(x, y, z
)和布尔字面值(x
或not x
)的空间高效表示。布尔变量简单地从零开始计数,但布尔字面值被左移,最低有效位用于存储补码信息。
int Gia_Var2Lit( int Var, int fCompl ) { return Var + Var + fCompl; }
int Gia_Lit2Var( int Lit ) { return Lit >> 1; }
那么,考虑以下函数:
int Gia_ManHashMux( Gia_Man_t * p, int iCtrl, int iData1, int iData0 )
不清楚iCtrl
、iData1
和iData0
参数是否对应字面值或变量:只有了解这个函数的作用(禁止带补码输入的多路复用器是没有意义的)或检查函数体才能确定这个问题(函数体调用Gia_LitNot
)。幸运的是,由于移位错误地将字面值解释为变量(或反之),通常会导致致命错误。(来源:ABC)
指针位。 众所周知,指针的低两位通常是未使用的:在 32 位系统上,32 位整数是最细粒度的对齐方式,这迫使任何合理的内存地址必须能被四整除。空间高效的表示可能会使用这两个额外位来存储额外信息,但在解引用指针时需要屏蔽这些位。在我们之前的例子基础上,考虑一个变量和字面值的指针表示:如果普通指针表示一个变量,那么我们可以使用最低位来指示变量是否被补码,以实现字面值表示。
考虑以下函数:
Gia_Obj_t * Gia_ObjFanin0( Gia_Obj_t * pObj );
其中iDiff0
是Gia_Obj_t
结构体中的一个int
字段。不清楚输入指针或输出指针是否可能带补码。实际上,输入指针不得带补码,输出指针永远不会带补码。
最初可能会误将输出指针的互补视为无害:所有发生的只是将低两位掩码掉,这在正常指针上是无操作的。然而,这实际上是一个关键逻辑错误:它假设返回的指针的最低位说出了风扇输入是否被补充,而实际上返回的位将始终为零。(来源:ABC)
物理内存和虚拟内存。 在构建操作系统的过程中,内存管理是其中的一个重要步骤。在实现此过程时,关键的区别在于物理内存(实际硬件上的内容)和虚拟内存(由 MMU 翻译的内容)。以下代码来自学生构建的玩具操作系统框架:
/* This macro takes a kernel virtual address -- an address that points above
* KERNBASE, where the machine's maximum 256MB of physical memory is mapped --
* and returns the corresponding physical address. It panics if you pass it a
* non-kernel virtual address.
*/
#define PADDR(kva) \
({ \
physaddr_t __m_kva = (physaddr_t) (kva); \
if (__m_kva < KERNBASE) \
panic("PADDR called with invalid kva %08lx", __m_kva);\
__m_kva - KERNBASE; \
})
/* This macro takes a physical address and returns the corresponding kernel
* virtual address. It panics if you pass an invalid physical address. */
#define KADDR(pa) \
({ \
physaddr_t __m_pa = (pa); \
uint32_t __m_ppn = PPN(__m_pa); \
if (__m_ppn >= npage) \
panic("KADDR called with invalid pa %08lx", __m_pa);\
(void*) (__m_pa + KERNBASE); \
})
请注意,尽管代码使用了类型同义词uintptr_t
(虚拟地址)和physaddr_t
(物理地址)来区分,但编译器不会阻止学生混淆两者。(来源:JOS)
字符串编码。 给定任意字节序列,没有一个标准的解释说明这些字节应该如何理解成人类语言。解码器根据超出带外数据(如 HTTP 标头)或带内数据(如元标签)来确定字节的可能含义,然后将字节流转换为更结构化的内部内存表示(在 Java 中为 UTF-16)。然而,在许多情况下,原始的字节序列是数据的最有效表示方式:考虑 UTF-8 和 UCS-32 在拉丁文本上的空间差异。这鼓励开发人员使用本机字节串来传递数据(PHP 的字符串类型只是一个字节串),但如果不跟踪适当的编码,这可能会导致无尽的问题。Unicode 规范化形式的存在进一步加剧了这一问题,这使得不能对不在同一规范化形式中的 Unicode 字符串进行有意义的相等性检查(或可能完全未规范化)。
字节序。 给定四个字节对应的 32 位整数,没有一个标准的“数值”可以分配给这些字节:你得到的数字取决于系统的字节序。字节序列0A 0B 0C 0D
可以被解释为0x0A0B0C0D
(大端序)或0x0D0C0B0A
(小端序)。
数据验证。 给定一个表示人类的数据结构,包含“真实姓名”、“电子邮件地址”和“电话号码”等字段,可能有两种不同的数据解释:数据被信任为正确,可以直接用于执行操作(例如发送电子邮件),或者数据未经验证,直到处理后才能被信任。程序员必须记住数据的状态,或者强制特定表示永远不包含未验证的数据。“污点”是一种语言特性,动态跟踪此数据的验证/未验证状态。
挑战任务。 每当数据结构(无论简单还是复杂)可能有多种解释时,对每种解释都进行一次newtype
。
newtype GiaLit = GiaLit { unGiaLit :: CInt }
newtype GiaVar = GiaVar { unGiaVar :: CInt }
-- accessor functions omitted for brevity; they should be included
newtype CoGia_Obj_t = CoGia_Obj_t (Gia_Obj_t)
newtype PhysAddr a = PhysAddr (Ptr a)
newtype VirtualAddr a = VirtualAddr (Ptr a)
newtype RawBytestring = RawBytestring ByteString
-- where e is some Encoding
newtype EncodedBytestring e = EncodedBytestring ByteString
-- where n is some Normalization
newtype UTF8Bytestring n = UTF8Bytestring ByteString
type Text = UTF8Bytestring NFC
-- where e is some endianness
newtype EndianByteStream e = EndianByteStream ByteString
newtype Tainted c = Tainted c
newtype Clean c = Clean c
辨别数据可能存在多种解释的情况可能并不明显。如果你处理的是你没有创建的底层表示,请仔细查看变量命名和看起来在同一类型之间进行相互转换的函数。如果你设计高性能数据结构,请确定你的原始数据类型(这些类型不同于int
、char
、bool
,通用编程语言的原始类型)。随着代码增加新功能,多种解释可能逐渐出现:要愿意重构(可能破坏 API 兼容性)或者推测性地为重要的用户可见数据创建新类型。
有关新类型的常见抱怨是类型的包装和解包。虽然这部分是必要之恶,但普通用户通常不需要包装和解包新类型:内部表示应保持隐藏!(这是一个密切相关但正交的属性,新类型有助于强制执行。)尽量不要导出新类型构造函数;而是导出智能构造函数和解构函数,进行运行时合理性检查,并以unsafe
为前缀。
当底层值被新类型包装时,你告诉编译器你相信该值在该新类型下有一个有意义的解释:当你包装某些东西时要做好你的功课!相反,你应假设传入的新类型具有由该新类型隐含的不变量(它是有效的 UTF-8 字符串,其最低有效位为零等):让静态类型检查器为你完成这项工作!新类型在编译时没有运行时开销:它们严格在编译时检查。
适用性。 新类型不能替代适当的数据结构:不要试图在 HTML 的字节字符串上进行 DOM 转换。即使在底层表示仅有一种解释的情况下,新类型也可能有用——然而,即时的好处主要来自封装。然而,当表达方式存在多种解释时,新类型是必不可少的:不要出门忘记它们!
类型 Kata:本地数据类型:ezyang 的博客
命令式。 什么时候应该创建自定义数据类型,而不是重用预先存在的数据类型如Either
、Maybe
或元组?以下是重用通用类型的一些理由:
-
它节省了输入(在声明和模式匹配中),使其对于一次性的事务非常有用。
-
它为你提供了一个预定义函数库,用于处理该类型的函数。
-
其他开发者对类型做什么使理解更快的期望。
硬币的另一面:
-
你可能会失去相同但具有不同含义的类型之间的语义区分(newtype 论据),
-
现有的类型可能允许比你打算允许的更多的值,
-
其他开发者对类型的期望可能会导致问题,如果你的意思不同的话。
在本文中,我想谈谈关于重用自定义类型的最后两个问题,使用 GHC 代码库中的两个案例研究,以及如何通过定义仅在代码库的一小部分中使用的数据类型来缓解这些问题。
大家期待。 Maybe
类型本身有一个非常直观的解释:你要么有值,要么没有。即使 Nothing
意味着类似 Wildcard
、Null
或 UseTheDefault
,其含义通常也很明确。
然而,更有趣的是,当 Maybe
放置在另一个具有其自己无意义概念的数据类型中时。一个非常简单的例子是 Maybe (Maybe a)
,它允许值 Nothing
、Just Nothing
或 Just (Just a)
。在这种情况下,Just Nothing
意味着什么?在这种情况下,我们真正掩盖的是一个具有三个构造函数的数据类型案例:data Maybe2 a = Nothing1 | Nothing2 | Just a
。如果我们打算区分 Nothing
和 Just Nothing
,我们需要为它们分配一些不同的语义含义,这些含义不是从拼凑在一起的数据类型中显而易见的。
另一个例子来自Hoopl 和 GHC,是Map Var (Maybe Lit)
的奇怪情况。映射已经有了它自己的空值概念:也就是说,当键-值对根本不在映射中时!所以一个遇到这段代码的开发者可能会问的第一个问题是,“为什么不只是Map Var Lit
呢?”对于那些已经阅读了数据流格点帖子的人来说,这个问题的答案是,在这种情况下,Nothing
表示 top(变量绝对不是常量),这与映射中的缺失不同,后者表示 bottom(变量是常量或非常量)。我成功地用这段奇怪的代码搞糊涂了两位西蒙斯,经过一些时间解释这种情况后,他们立刻建议我为此制作一个自定义数据类型。更好的是,我发现 Hoopl 已经提供了这种类型:HasTop
,它还具有一系列反映这组语义的实用函数。真是幸运!
不速之客. 我们的一个过多值的数据类型例子来自于 GHC 中的线性寄存器分配器(compiler/nativeGen/RegAlloc/Linear/Main.hs
)。别担心,你不需要知道如何实现线性寄存器分配器来跟进。
线性寄存器分配器是一个相当庞大且笨拙的家伙。这是实际分配和溢出寄存器的函数:
allocateRegsAndSpill reading keep spills alloc (r:rs)
= do assig <- getAssigR
case lookupUFM assig r of
-- case (1a): already in a register
Just (InReg my_reg) ->
allocateRegsAndSpill reading keep spills (my_reg:alloc) rs
-- case (1b): already in a register (and memory)
-- NB1\. if we're writing this register, update its assignment to be
-- InReg, because the memory value is no longer valid.
-- NB2\. This is why we must process written registers here, even if they
-- are also read by the same instruction.
Just (InBoth my_reg _)
-> do when (not reading) (setAssigR (addToUFM assig r (InReg my_reg)))
allocateRegsAndSpill reading keep spills (my_reg:alloc) rs
-- Not already in a register, so we need to find a free one...
loc -> allocRegsAndSpill_spill reading keep spills alloc r rs loc assig
这里有些噪音,但需要注意的重要事情是,这是一个大部分递归的函数。lookupUFM
的前两种情况直接调用allocateRegsAndSpill
,但最后一种情况需要执行一些复杂操作,并调用辅助函数allocRegsAndSpill_spill
。事实证明,这个函数最终总是会调用allocateRegsAndSpill
,所以我们有两个互递归函数。
这段代码正在重用一个数据类型!你能看到吗?这非常微妙,因为类型的原始用途是合法的,但随后以不恰当的方式重复使用了。答案就是loc
,在最后的 case 语句中。特别是因为我们已经在loc
上进行了 case 匹配,我们知道它不可能是Just (InReg{})
或Just (InBoth{})
。如果我们查看Loc
的声明,我们会发现只剩下两种情况:
data Loc
-- | vreg is in a register
= InReg !RealReg
-- | vreg is held in a stack slot
| InMem {-# UNPACK #-} !StackSlot
-- | vreg is held in both a register and a stack slot
| InBoth !RealReg
{-# UNPACK #-} !StackSlot
deriving (Eq, Show, Ord)
也就是说,唯一剩下的情况是Just (InMem{})
和Nothing
。这相当重要,因为我们稍后在allocRegsAndSpill_spill
中依赖这一不变量:
let new_loc
-- if the tmp was in a slot, then now its in a reg as well
| Just (InMem slot) <- loc
, reading
= InBoth my_reg slot
-- tmp has been loaded into a reg
| otherwise
= InReg my_reg
如果你没有看到 allocateRegsAndSpill
中的原始情况分割,这段特定的代码可能会让你想知道最后的保护条件是否也适用于结果是 Just (InReg{})
的情况,这种情况下行为会非常错误。实际上,如果我们在 reading
时,那个最后的分支中 loc
必须是 Nothing
。但是代码现在无法表达这一点:你必须添加一些紧急情况处理,而且变得非常混乱:
let new_loc
-- if the tmp was in a slot, then now its in a reg as well
| Just (InMem slot) <- loc
= if reading then InBoth my_reg slot else InReg my_reg
-- tmp has been loaded into a reg
| Nothing <- loc
= InReg my_reg
| otherwise = panic "Impossible situation!"
此外,我们注意到一个非常有趣的额外不变量:如果我们正在从一个以前从未分配过的位置读取(也就是,reading
是 True
而 loc
是 Nothing
),会发生什么?这显然是错误的,因此实际上我们应该检查是否出现了这种情况。请注意,原始代码没有强制执行这个不变量,这是通过使用 otherwise
掩盖了出来。
与其在不可能的情况下恐慌,我们应该静态地排除这种可能性。我们可以通过引入一个新的数据类型来实现这一点,并适当地进行模式匹配:
-- Why are we performing a spill?
data SpillLoc = ReadMem StackSlot -- reading from register only in memory
| WriteNew -- writing to a new variable
| WriteMem -- writing to register only in memory
-- Note that ReadNew is not valid, since you don't want to be reading
-- from an uninitialized register. We also don't need the location of
-- the register in memory, since that will be invalidated by the write.
-- Technically, we could coalesce WriteNew and WriteMem into a single
-- entry as well. -- EZY
allocateRegsAndSpill reading keep spills alloc (r:rs)
= do assig <- getAssigR
let doSpill = allocRegsAndSpill_spill reading keep spills alloc r rs assig
case lookupUFM assig r of
-- case (1a): already in a register
Just (InReg my_reg) ->
allocateRegsAndSpill reading keep spills (my_reg:alloc) rs
-- case (1b): already in a register (and memory)
-- NB1\. if we're writing this register, update its assignment to be
-- InReg, because the memory value is no longer valid.
-- NB2\. This is why we must process written registers here, even if they
-- are also read by the same instruction.
Just (InBoth my_reg _)
-> do when (not reading) (setAssigR (addToUFM assig r (InReg my_reg)))
allocateRegsAndSpill reading keep spills (my_reg:alloc) rs
-- Not already in a register, so we need to find a free one...
Just (InMem slot) | reading -> doSpill (ReadMem slot)
| otherwise -> doSpill WriteMem
Nothing | reading -> pprPanic "allocateRegsAndSpill: Cannot read from uninitialized register" (ppr r)
| otherwise -> doSpill WriteNew
现在,在 allocateRegsAndSpill_spill
内部的模式匹配变得清晰简洁:
-- | Calculate a new location after a register has been loaded.
newLocation :: SpillLoc -> RealReg -> Loc
-- if the tmp was read from a slot, then now its in a reg as well
newLocation (ReadMem slot) my_reg = InBoth my_reg slot
-- writes will always result in only the register being available
newLocation _ my_reg = InReg my_reg
类型操作:行业诀窍:ezyang 的博客
来源:
blog.ezyang.com/2010/02/type-manipulation-tricks-of-the-trade/
我在这里介绍了一些传统的典故,对于那些擅长 Haskell 的人来说,这些技巧在分析类型看似毫无意义的代码时非常有用。我们将建立实用的技巧来推断类型,以便能够自己推导出 fmap fmap fmap
的类型。请注意,你可以只是问 GHCI 它的类型,但那会破坏乐趣!(更严肃地说,通过手动解决问题集中的例子,就像一个良好的问题集一样,有助于培养对可能发生的事情的直觉。)
柯里化与类型。 三种具有表面相似性的类型签名分别是 a -> b -> c
,(a -> b) -> c
和 a -> (b -> c)
。如果你对 Haskell 的自动柯里化没有直观的感受,很容易混淆这三种类型。在这种特定情况下,a -> b -> c
可以理解为“接受两个参数 a
和 b
并返回 c
”,等价于 a -> (b -> c)
,可以理解为“接受 a
并返回一个接受 b
并返回 c
的函数”。这些与 (a -> b) -> c
是不同的,它表示“接受一个 a -> b
的函数并返回 c
”。在这些情况下,你可以应用一个视觉规则:类型签名右侧与括号对齐的括号可以自由添加或移除,而其他位置的括号则不能。
高阶函数。 如果我将一个 Int
传递给 id :: a -> a
,很显然 id
的类型是 Int -> Int
。如果我将一个函数 a -> a
传递给 id :: a -> a
,那么 id
的类型变成了 (a -> a) -> a -> a
。就我个人而言,我觉得类型参数的重载有点令人困惑,所以如果我有一堆函数,我试图推导它们的类型,我会给它们所有人不同的名称。由于 id id
有点微不足道,我们将考虑一些更恶劣的东西:(.) (.)
。回想一下 (.) :: (b -> c) -> (a -> b) -> a -> c
。我们实际上不会使用这些字母进行操作:因为我们的表达式有两个 (.)
的实例,我们将第一个命名为 a
,第二个命名为 b
,并从一到三编号。然后:
(.) :: (a2 -> a3) -> (a1 -> a2) -> a1 -> a3
(.) :: (b2 -> b3) -> (b1 -> b2) -> b1 -> b3
稍微不那么美观,但我们没有更多的冲突类型了。下一步是识别类型变量中存在的等价性,并消除冗余。因为我们将第二个 (.)
作为第一个 (.)
的参数传递:
(a2 -> a3) == (b2 -> b3) -> (b1 -> b2) -> b1 -> b3
至于你可能会说,“这些函数签名看起来一点都不像!”这将引导我们到下一个要点:
柯里化和类型替换. 如果你的函数类型是n-元的,而你想要匹配的类型是m-元的,请柯里化你的函数使其成为m-元的!因此,如果你有a -> b -> c
,而你想把它当作d -> e
来传递,那么实际上你有a -> (b -> c)
,因此d == a
且e == (b -> c)
。如果情况反过来,d -> e
实际上被限制为d -> (e1 -> e2)
,其中e == (e1 -> e2)
且显然的相等性成立。
回到我们的原始例子,第二个(.)
会被分组如下:
(.) :: (b2 -> b3) -> ((b1 -> b2) -> b1 -> b3)
并且我们得到了类型相等性:
a2 == (b2 -> b3)
a3 == (b1 -> b2) -> b1 -> b3
现在,让我们将这些值替换为第一个(.)
:
(.) :: ((b2 -> b3) -> (b1 -> b2) -> b1 -> b3) ->
(a1 -> b2 -> b3) -> a1 -> (b1 -> b2) -> b1 -> b3
并丢弃第一个参数,因为它已经被应用了:
(.) (.) :: (a1 -> b2 -> b3) -> a1 -> (b1 -> b2) -> b1 -> b3
也许你会想知道那个庞大的类型签名是干什么用的...
解释类型签名. 多态类型的一个很棒的特性是,几乎没有非病理行为可以被指定:因为类型是完全多态的,我们实际上不能把手伸进箱子里并利用它实际上是一个整数的事实。这一特性使得像Djinn这样的程序能够自动推导函数的内容,只要稍加练习,你也能够理解。
逆向思维:我们首先看一下b3
。我们的函数没有办法神奇地生成一个类型为b3
的值(不包括undefined
或底部,这被认为是病态的),因此我们的脚本中必须有其他东西来生成它。果不其然,它就是第一个参数,但我们需要先传递a1
和b2
:
(.) (.) w x y z = w undefined undefined
我们依次重复这些类型的过程:a1
在哪里指定?好吧,我们把它作为第二个参数传递进去。b2
在哪里指定?好吧,我们有另一个函数y :: b1 -> b2
,但我们需要一个b1
,它是z
。太棒了,我们现在有了一个完整的实现:
(.) (.) w x y z = w x (y z)
点无关风格作为操作符组合. 所以,我们现在知道(.) (.)
做什么了,但我们确实没有一个好的理由为什么会这样。(通过理由,我指的是,看看(.) (.)
,将函数组合看作面值,并意识到,“哦,是的,它应该这样做。”)因此,我们真正想要关注的是(.)
的语义,即函数组合,以及我们是如何柯里化它的。可能有一种思路是:
-
函数组合被定义为
(f . g) x = f (g x)
。 -
我们部分应用了组合,所以实际上我们有
(f.) g x
,但是缺少g
。(如果(f.)
看起来对你来说有点奇怪,可以将它与(2+)
比较,后者是部分应用的加法。注意加法是可交换的,所以你更有可能看到(+2)
,当应用时变成(x+2)
。) -
f
实际上是另一个组合运算符。由于函数组合是单参数导向的,我们希望专注于(.)
的柯里化版本,它接受一个函数并返回一个函数(1),后者接受另一个函数(2)和一个值,并返回第一个函数应用于第二个函数应用于该值的结果。 -
读出参数。由于
(f.)
在外面,第一个参数完成了柯里化。接下来的参数是实际将通过第一个参数传递的内容,而其结果将通过f
传递。该返回值是另一个函数,但是(在之前的讨论除外)我们还没有弄清楚那可能是什么。尽管如此,我们已经弄清楚了前两个参数可能是什么样子。
如果我们现在作弊并查看类型签名,我们可以看到我们的假设得到了验证:
(.) (.) :: (a1 -> b2 -> b3) -> a1 -> (b1 -> b2) -> b1 -> b3
第一个参数g :: a1 -> b2 -> b3
完成了柯里化,然后下一个参数直接传递给它,因此它必须是a1
。得到的值b2 -> b3
传递给下一个组合运算符(注意它不是单一变量,因为下一个组合强制它是一个一元函数),现在等待另一个函数来完成柯里化,这就是下一个参数b1 -> b2
(即b1 -> b2 -> b3
)。然后只需提供剩余的参数即可。
我发现将函数视为部分应用并等待“完成”可以更深入地直观理解复杂的高阶函数链可能会做什么。
把这些放在一起。 现在是时候为fmap fmap fmap
确定类型了。我们首先写出每个fmap
的类型:
fmap :: (Functor f) => (a1 -> a2) -> f a1 -> f a2
fmap :: (Functor g) => (b1 -> b2) -> g b1 -> g b2
fmap :: (Functor h) => (c1 -> c2) -> h c1 -> h c2
进行应用后我们看到:
(a1 -> a2) == (b1 -> b2) -> g b1 -> g b2
f a1 == (c1 -> c2) -> h c1 -> h c2
幸运的是,我们有足够的参数来填充第一个fmap
,因此复杂度减少了一层。我们还可以进一步分解这些:
-- from the first argument
a1 == b1 -> b2
a2 == g b1 -> g b2
-- from the second argument
a1 == h c1 -> h c2
f == (->) (c1 -> c2)
最后一个等式源于这样一个事实,即对于(c1 -> c2) -> h c1 -> h c2
,只有一个合理的函子实例;即函数的函子,即读者单子,以(c1 -> c2)
作为其“读入”。
我们可以进行更多的简化:
h c1 -> h c2 == b1 -> b2
b1 == h c1
b2 == h c2
把所有东西代入,现在我们看到:
fmap fmap fmap :: (Functor g, Functor h) =>
(c1 -> c2) -> g (h c1) -> g (h c2)
解释这些类型我们意识到,fmap fmap fmap
将一个函数c1 -> c2
提升了两次到两个函子。所以我们可以运行fmap fmap fmap (+2) [Just 3]
并得到[Just 5]
(利用外部列表和内部 maybe 的函子实例)。
我们还注意到f
函子消失了;这是因为它被迫到了一个特定的形式,所以实际上fmap fmap fmap == fmap . fmap
。这使得我们更清楚我们正在进行双重提升:函数被fmap
一次,然后结果再次被fmap
。
我们甚至可以利用这个结果来弄清楚(.) (.) (.)
(或(.) . (.)
)可能会做什么;在函数中 fmap = (.)
,所以通过第一个 fmap
将一个普通函数提升到一个读取器上下文中,通过第二个 fmap
又提升到另一个读取器上下文中。因此,我们期望(.) . (.) :: (a -> b) -> (r2 -> r1 -> a) -> (r2 -> r1 -> b)
(记住,如果f = (->) r
,那么f a
变成 r -> a
),而事实上确实如此。复合函数与复合函数组合后,只是一个可以将二元函数作为其第二个参数并“做正确事情”的复合函数而已!
类型技术树:ezyang 的博客
类型技术树
他们说,你并不是发现了高级类型系统扩展:相反,类型系统扩展发现了你!尽管如此,了解 GHC 的类型扩展的技术树仍然是值得的,这样你可以决定需要多少能力(以及对应的头疼的错误消息)。
-
一些扩展自动启用其他扩展(蕴含);
-
一些扩展提供了另一扩展提供的所有功能(包含);
-
一些扩展与其他扩展非常良好地协同工作(协同作用);
-
一些扩展提供了与另一扩展相当(但以不同的形式)的功能(等效)。
此外值得注意的是,GHC 手册将这些扩展划分为“数据类型和类型同义词的扩展”、“类和实例声明”、“类型族”和“其他类型系统扩展”。我在这里对它们进行了稍微不同的组织。
等级和数据
我们的第一个技术树将任意等级的多态性和广义代数数据类型结合在一起。
简言之:
-
GADTSyntax 允许普通数据类型以 GADT 风格编写(带有显式构造函数签名):
data C where C :: Int -> C
-
显式的 forall 允许你显式声明多态类型中的量化器:
forall a. a -> a
-
存在量化 允许将类型隐藏在数据构造器中:
data C = forall e. C e
-
GADTs 允许显式构造函数签名:
data C where C :: C a -> C b -> C (a, b)
。包含存在量化因此,存在量化的数据类型只是那些类型变量不在结果中的多态构造函数。 -
多态组件 允许你在数据类型字段中写入
forall
:data C = C (forall a. a)
-
Rank2Types 允许多态参数:
f :: (forall a. a -> a) -> Int -> Int
。与 GADTs 结合,它包含多态组件,因为数据类型字段中的forall
对应于具有二阶类型的数据构造器。 -
RankNTypes:
f :: Int -> (forall a. a -> a)
-
ImpredicativeTypes 允许多态函数和数据结构参数化为多态类型:
Maybe (forall a. a -> a)
实例
我们的下一个技术树涉及类型类实例。
简言之:
-
TypeSynonymInstances 允许在实例声明中类似宏地使用类型同义词:
instance X String
-
FlexibleInstances 允许更多有趣的类型表达式的实例,但限制以保持可判定性:
instance MArray (STArray s) e (ST s)
(经常与多参数类型类一起看到,但不在图表中) -
UndecidableInstances 允许更有趣的类型表达式的实例,没有限制,但牺牲了可判定性。参见Oleg作为合法示例。
-
FlexibleContexts 允许在函数和实例声明的约束中更多的类型表达式:
g :: (C [a], D (a -> b)) => [a] -> b
-
OverlappingInstances 允许实例在有最特定实例的情况下重叠:
instance C a; instance C Int
-
IncoherentInstances 允许实例任意重叠。
或许在此图表中显著缺失的是 MultiParamTypeClasses
,它位于以下。
类型族和函数依赖
我们最终的技术树涉及类型编程:
简言之:
-
KindSignatures 允许声明类型变量的种类:
m :: * -> *
-
MultiParamTypeClasses 允许类型类跨越多个类型变量:
class C a b
-
FunDeps 允许限制多参数类型类的实例,有助于解决歧义:
class C a b | a -> b
-
TypeFamilies 允许在类型上进行“函数”操作:
data family Array e
函数依赖与类型族之间的对应关系众所周知,尽管不完美(类型族可能更啰嗦,无法表达某些相等性,但在广义代数数据类型(GADTs)中更友好)。
类型类很重要 : ezyang’s blog
类型类很重要。
类型类很重要。事实上,我会进一步说,它们有能力替代传统的面向对象编程。然而,要理解为什么,我们必须回顾传统认可的面向对象编程的好处:
-
组织. 对于没有模块系统的 C 风格语言来说,这是非常重要的;没有组织代码的纪律,要找到任何给定函数的位置是很困难的,除非您非常熟悉问题域。通过面向对象编程,所有这些方面都是显而易见的:类映射到明显的文件名,方法放在明显的位置,整体组织是对象模型设计得有多好,而不是头文件设计得有多完善。
-
封装. 对象是广泛使用的首个隐藏数据和代码的方法。声明某些内容为
private
或protected
,您就有了编译时的保证,即客户端不会对您的内部数据和代码做过多的干预。正确使用时,模块化随之而来。 -
多态性. 根据数据改变行为的能力是一个强大的想法,可以追溯到
(void *)
的时代,这可能导致难以理解的代码流,但更常见的是一种比巨大的switch
语句更加优雅和简洁的复杂交互方式。这些好处在适合多重分派的情况下会相互增强,而接口可以在编译时确保特定类别确实完成了其承诺。 -
继承. 虽然作为面向对象编程的一个问题面向显著的一个方面(特别是当表现为多重继承时),继承仍然是面向对象设计中代码重用的一个极为强大的机制。子类会为方法得到一个默认实现,以及能够突破封装级别并使用
protected
方法的能力。
类型类直接满足了其中一些要求,而其他要求则是由于 Haskell 的严格类型和模块系统。
-
组织. 乍一看,这似乎严格来说更糟:我们无法仅仅通过类名找到正确的文件。然而,结合
ghci
,它允许您运行:info
来查找范围内任何声明的位置,以及Hoogle,它允许您仅从类型签名找到所需的函数。这些功能使得不仅可以轻松找到您知道存在的函数,还可以找到您不知道存在的函数。静态类型来拯救! -
封装. 这一特性由 Haskell 的模块导出系统实现:如果不导出任何给定数据类型的构造器,最终用户无法创建或内省该类型;他们必须使用您定义的函数来操作它们。此外,如果函数指定其输入类型应该是类型类的实例,那么静态检查的类型保证函数只会使用类型类定义的函数(即没有不安全的向下转型)。
-
多态性. 这是类型类最明显的应用;当向从命令式语言转入的人解释它们时,最常见的类比是类型类就像接口。然而,它们比接口更具表现力:函数可以轻松地指定一个传入数据类型必须满足多个类型类,并且参数化类型(在其声明中具有额外类型变量的类型)可以有类型类约束其类型参数。此外,可以编写代码以完全泛化类型类,以便在后续推断出具体类型后进行实例化。
-
继承. 接口继承是类型参数化的一个直接子集;我们不是说
class Monad m
,而是说class Functor m => Monad m
,因此声明任何具有 Monad 实例的 m 必须也具有 Functor 实例(因此我们可以自由地使用任何 Monad,就像它是 Functor 一样)。指定默认实现的能力(通常是自引用的,如 Eq 类的x /= y = not (x == y)
和x == y = not (x /= y)
)极大地简化了编写新实例的过程。
经典的对象层次结构是模拟“是一个”关系的优秀机制,但在这个世界上,几乎没有什么东西真正地完全“是一个”,而不是“像一个”;继承已经被许多开发人员滥用,他们创建了大型对象层次结构(咳嗽 GUI 工具包咳嗽),实际上,所有这些都是继承的代码重用机制。对类型类/接口的重视回到了问题的核心:
我能用这种类型做什么?
一模一样。
Ubuntu Oneiric 升级(Thinkpad/Xmonad):ezyang 的博客
Ubuntu Oneiric 升级(Thinkpad/Xmonad)
我今天从 Ubuntu Natty Narwhal 升级到 Oneiric Ocelot(11.10)。很多东西都出问题了。具体来说:
-
“无法计算升级。” 没有指出错误的迹象;在我的情况下,错误最终是由于旧的孤儿 OpenAFS 内核模块(没有相应的内核模块存在)。我也趁机清理了我的 PPA。
-
“阅读变更日志。”
apt-listchanges
并不特别有用,我也不知道为什么我安装了它。但是当阅读变更日志的时间比安装软件还长时,真的很痛苦。Geoffrey 建议gdb -p `pgrep apt-listchanges`
然后强制它调用exit(0)
,这个方法奏效。我不得不多次这样做;以为它会无限循环。 -
图标无法工作,菜单很丑陋。去“系统设置 > 外观”设置一个新主题;很可能你的旧主题已经消失了。这个AskUbuntu问题给了一个线索。
-
网络管理器停止工作。由于某种难以理解的原因,默认的 NetworkManager 配置文件
/etc/NetworkManager/NetworkManager.conf
中对ifupdown
有managed=false
的设定。切换回 true。 -
新的窗口管理器,默认会至少让你试用 Unity 一次。只需确保你从小齿轮图标中选择正确的窗口管理器。
-
gnome-power-manager
消失了。如果你修复了图标,加载gnome-settings-daemon
时会出现一个不太有用的图标。 -
“等待网络配置。” 这里有很多建议。我的
/var/run
和/var/lock
被损坏了,所以我按照这些说明操作了。我还听说你应该从/etc/network/interfaces
中移除wlan0
并从/etc/udev/rules.d70-persistent-net.rules
中删除它。我还为了保险起见注释了/init/failsafe.conf
中的休眠。 -
默认的 GHC 版本是 7.0.3!清除你的
.cabal
(但保留.cabal/config
),重新安装 Haskell 平台。别忘了确保安装了性能分析库,并获取xmonad
和xmonad-contrib
。请注意,之前的 haskell-platform 安装可能会相当混乱,因为缺少 GHC 6 二进制文件(你可以重新安装它们,但看起来它们已经被替换了。) -
ACPI 停止了关于 X 的知识,所以如果你有处理旋转的脚本,请执行
/usr/share/acpi-support/power-funcs
并运行getXuser
和getXconsole
-
DBUS 没有启动。这是由于残留的 pid 和 socket 文件引起的,请参见此 bug
-
每次启动时神秘地在我的根目录驱动上执行 fsck。检查你在
/etc/fstab
中的pass
参数;应该是0
。 -
Redshift 神秘地被 xrandr 调用重置;通过在运行 xrandr 后立即调用 oneshot 来解决。
-
不确定是否与升级有关,但修复了一个令人讨厌的问题,即在启动时暂停检查(以防从休眠中恢复)需要很长时间。在
/etc/initramfs-tools/conf.d/resume
中设置resume
为正确的交换区,并使用极大的决心update-initramfs -u
)。
未解决的烦恼:X11 在 DBUS 中自动启动,电源图标不始终正确显示 AC 信息,在 stalonetray 中太小,xmobar 不支持同时百分比电池和 AC 着色(我有一个补丁),从头构建的 totem 会段错误。
Ubuntu Precise 升级(Thinkpad/Xmonad):ezyang 的博客
来源:
blog.ezyang.com/2012/05/ubuntu-precise-upgrade-thinkpad-xmonad/
Ubuntu Precise 升级(Thinkpad/Xmonad)
又到了 Ubuntu 升级的时候。我从 Ubuntu Oneiric Ocelot 升级到了 Ubuntu Precise Pangolin(12.04),这是一个 LTS 版本。几乎没有什么东西出了问题(万岁!)
-
Monospace 字体变成了一个全新的字体,字形非常宽。旧字体是 DejaVuSansMono,我又切换回去了。
-
Xournal 停止编译;不知何故链接器行为发生了变化,现在需要手动指定链接器标志。
-
gnome-keyring 对于非 Unity 用户来说启动不正常。根本问题似乎是由于 Gnome 的打包错误,但将
eval `gnome-keyring-daemon -s`
添加到我的.xsession
文件后问题解决了。 -
电池图标消失了!我猜是某个守护程序未能正常运行,但由于我有一个很好的 xmobar 显示,我并不为它的失去而感到悲伤。
-
默认的 GHC 版本是 GHC 7.4.1!是时候重新构建了;暂时还没有 Haskell 平台。 (请注意,GHC 7.4.1 不支持 gold 链接器;这是
chunk-size
错误。)
我还从之前的 LTS Lucid Lynx 升级了我的桌面。
-
我遇到了很多无效签名错误,这导致升级脚本无法运行。我通过卸载几乎所有的 PPAs 来解决了这个问题。
-
Offlineimap 需要更新,因为它依赖的一些 Python 库有不兼容的改动(即 imap 库)。
-
VirtualBox 搞乱了它的版本号,里面包含一个被禁止的 下划线。手动编辑文件将其删除似乎解决了问题。
Ubuntu Quantal 升级(Thinkpad/Xmonad):ezyang 的博客
来源:
blog.ezyang.com/2012/10/ubuntu-quantal-upgrade-thinkpadxmonad/
Ubuntu Quantal 升级(Thinkpad/Xmonad)
十月已至,带来了另一个 Ubuntu 发布版(12.10)。我终于屈服并重新安装了我的系统为 64 位(告别 32 位),主要是因为我的升级系统上的图形出现了问题。据我所知,lightdm 在启动后立即崩溃,我无法确定在我的大量配置中哪里出了问题。我还开始加密我的家目录。
-
所有 fstab 挂载项 现在都显示在 Nautilus 中。正确的解决方法似乎是不要将这些挂载项放在
/media
、/mnt
或/home
中,这样它们就不会被检测到。 -
在 rxvt-unicode 中,字体问题仍然是一个棘手的问题。我不得不从
URxvt.letterSpace: -1
切换到URxvt.letterSpace: -2
以保持一切正常运作,但字体看起来仍然有不可思议的差异。(我还没搞清楚原因,但新的世界秩序并不是完全的眼中钉,所以我暂时放弃了。)还有 一个补丁 可以修复这个问题(参考 这个 libxft2 的 bug),但我发现对于 DejaVu 字体来说,letterSpace 的小技巧是等效的。 -
当你手动暂停你的笔记本并过快关闭盖子时,Ubuntu 也会注册关闭笔记本事件,所以当你恢复时,它会重新暂停!幸运的是,这没什么大问题;如果你再次按下电源按钮,它就会正确恢复。你也可以通过在电源设置中关闭盖子关闭后恢复来解决这个问题。
-
在恢复后,网络管理器小程序不再准确反映你连接到哪个网络(它认为你已连接,但不知道连接到哪里,或者信号强度是多少)。这基本上是无害的,但有点烦人;如果有人解决了这个问题,请告诉我!
-
休眠功能依然无法正常工作,虽然我并没有特别努力地去解决这个问题。
-
Firefox 一度非常缓慢,所以我 重置了它。然后它又变快了。天哪!如果你发现 Firefox 非常慢,这值得一试。
-
GHC 现在是 7.4.2,所以你需要重新构建。“我们什么时候可以获得我们的 7.6 新功能呢!”
我的实验室同事们继续取笑我没有转向使用 Arch。我们看看吧…
Ubuntu Utopic 升级(Xmonad)
Ubuntu Utopic 升级(Xmonad)
我终于升级到了 Utopic 版本。一年前我报告说 gnome-settings-daemon 不再提供按键抓取支持。这最终在 Trusty 版本中被撤销,保留了所有人的媒体键。
很抱歉在 Ubuntu Utopic 中报告,传统的按键抓取器不再存在:
------------------------------------------------------------
revno: 4015 [merge]
author: William Hua <william.hua@canonical.com>
committer: Tarmac
branch nick: trunk
timestamp: Tue 2014-02-18 18:22:53 +0000
message:
Revert the legacy key grabber. Fixes: https://bugs.launchpad.net/bugs/1226962.
看起来 Unity 团队已经将 gnome-settings-daemon 分叉成 unity-settings-daemon(实际上这个分叉已经发生在 Trusty 版本),截至到 Utopic 版本,gnome-settings-daemon 和 gnome-control-center 已经被剔除,改为使用 unity-settings-daemon 和 unity-control-center。这使我们重新回到一年前的情况。
我目前还没有解决这个(相当大的)问题的方法。但是,我已经为升级中出现的一些较小问题提供了解决方案:
-
如果你的鼠标光标不可见,尝试运行
gsettings set org.gnome.settings-daemon.plugins.cursor active false
-
如果你不喜欢 GTK 文件对话框不再将文件夹排序在前面,尝试运行
gsettings set org.gtk.Settings.FileChooser sort-directories-first true
。(感谢) -
并且需要重申的是,替换所有对 gnome-settings-daemon 的调用为 unity-settings-daemon,并使用 unity-control-panel 进行一般配置。
Ubuntu Vivid 升级(Xmonad):ezyang 的博客
Ubuntu Vivid 升级(Xmonad)
又是半年过去了,又一次 Ubuntu 升级。这次升级基本上很顺利:唯一出了问题的是我的 xbindkeys 绑定了音量和暂停功能,不过这很容易修复。
调高和调低音量
如果之前有:
#Volume Up
"pactl set-sink-volume 0 -- +5%"
m:0x10 + c:123
Mod2 + XF86AudioRaiseVolume
这个语法不再适用:你必须在命令中早些放置双破折号,如下所示:
#Volume Up
"pactl -- set-sink-volume 0 +5%"
m:0x10 + c:123
Mod2 + XF86AudioRaiseVolume
调低音量时也是同样的操作。
暂停
如果之前有:
#Sleep
"dbus-send --system --print-reply --dest="org.freedesktop.UPower" /org/freedesktop/UPower org.freedesktop.UPower.Suspend"
m:0x10 + c:150
Mod2 + XF86Sleep
UPower 不再处理暂停功能;你必须将命令发送到登录界面:
#Sleep
"dbus-send --system --print-reply --dest=org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager.Suspend boolean:true"
m:0x10 + c:150
Mod2 + XF86Sleep
意外后果:绑定线程和不安全的 FFI 调用:ezyang 的博客
来源:
blog.ezyang.com/2014/12/unintended-consequences-bound-threads-and-unsafe-ffi-calls/
不久前,我写了一篇文章描述了不安全的 FFI 调用如何可能阻塞整个系统,并且给出了以下这种行为的例子:
/* cbit.c */
#include <stdio.h>
int bottom(int a) {
while (1) {printf("%d\n", a);sleep(1);}
return a;
}
/* cbit.h */
int bottom(int a);
/* UnsafeFFITest.hs */
{-# LANGUAGE ForeignFunctionInterface #-}
import Foreign.C
import Control.Concurrent
main = do
forkIO $ do
safeBottom 1
return ()
yield
print "Pass (expected)"
forkIO $ do
unsafeBottom 2
return ()
yield
print "Pass (not expected)"
foreign import ccall "cbit.h bottom" safeBottom :: CInt -> IO CInt
foreign import ccall unsafe "cbit.h bottom" unsafeBottom :: CInt -> IO CInt
在这篇文章中,我解释了发生这种情况的原因是因为不安全的 FFI 调用是不可抢占的,所以当unsafeBottom
无限循环时,Haskell 线程无法继续。
这个解释看起来很合理,但有一个问题:即使在多线程运行时系统中,代码也会挂起。David Barbour 曾经写信询问我关于不安全调用会阻塞整个系统的说法是否过时。但是,根据这篇文章的标题,你能猜到原因吗?如果你认为你知道,请问这些程序的变体会做什么?
-
将
main =
改为main = runInUnboundThread
-
将第二个
forkIO
改为forkOn 2
-
在
unsafeBottom
之前加上一个yield
,在print "Pass (not expected)"
之前再加一个yield
代码阻塞的原因,或者更具体地说,主线程阻塞的原因是因为不安全的 FFI 调用不可抢占地在操作系统线程上运行,而主线程绑定到该线程上。回想一下,默认情况下,主线程在一个绑定的操作系统线程中运行。这意味着必须使用特定的操作系统线程来运行主线程中的代码。如果该线程被 FFI 调用阻塞,即使有其他工作线程可用,主线程也无法运行。
因此,我们可以解释这些变体:
-
main
在一个未绑定的线程中运行,不会发生阻塞,因此第二个打印语句会运行。 -
默认情况下,一个分支线程在与生成它的线程相同的能力上运行(这很好,因为这意味着不需要同步),因此强制糟糕的 FFI 调用在不同的工作线程上运行可以防止它阻塞主线程。
-
或者,如果一个线程让出,它可能会被重新调度到另一个工作线程上,这也可以防止主线程被阻塞。
所以,也许这个故事的真正教训是:如果你有绑定的线程,请小心处理不安全的 FFI 调用。请注意:每个 Haskell 程序都有一个绑定的线程:主线程!
关于安全 Haskell 的不直观事实:ezyang's 博客
来源:
blog.ezyang.com/2012/09/common-misconceptions-about-safe-haskell/
关于安全 Haskell 的不直观事实
安全 Haskell 是 GHC 的一种新的语言扩展,允许你在受信任的代码库之上运行不受信任的代码。关于安全 Haskell 的工作方式,有一些常见的误解。在这篇文章中,我希望帮助纠正其中的一些误解。
[system 'rm -Rf /' :: IO ExitCode
] 被安全 Haskell 所接受
虽然这里的 IO 动作肯定是不安全的,但 Safe Haskell 并不会因为这个表达式的类型明确表达了操作可能具有任意的副作用而拒绝它,你在受信任的代码库中的义务是不在 IO Monad 中运行不受信任的代码!如果你需要允许有限的输入/输出,你必须定义一个受限制的 IO Monad,这在手册中有描述。
安全 Haskell 程序可能会挂起
即使使用 killThread
,也很容易通过创建一个 非分配无限循环 来永久占用一个能力。这个 bug 已经开放了七年了,但我们认为这是 Safe Haskell 的一个主要缺陷,并正在寻找防止这种情况发生的方法。但目前的情况是,安全 Haskell 程序需要通过操作系统级别的措施来控制,而不仅仅是 Haskell 的线程管理协议。
用户可能不信任 Trustworthy
模块
Trustworthy
关键字用于标记那些使用不安全语言特性和/或以“安全”方式使用的模块。这种安全性由维护者保证,其会将此语言扩展插入到模块文件的顶部。购买者请注意!毕竟,并没有理由相信一个维护者一定会这样宣称。因此,你可以通过 ghc-pkg
数据库或 -trust
标志信任一个包。但 GHC 也允许你相信包的维护者的说法,事实上,默认情况下就是这样;要使其不可信,你必须传递 -fpackage-trust
。总之:
模块是否受信任? | (无标志) | -fpackage-trust |
---|---|---|
包是否不受信任 | 是 | 否 |
包是否受信任 | 是 | 是 |
如果你认真使用安全 Haskell 来运行不受信任的代码,你应该始终使用 -fpackage-trust
,并仔细将你的数据库中的包标记为受信任的状态。如果你只是把安全 Haskell 当作一种强制代码风格的方式,那么默认设置是相当不错的。
显式不信任对于维护封装性是重要的
Safe Haskell 提供了安全推断,通过检查模块是否可以使用 -XSafe
标志进行编译来自动确定模块是否安全。推断为安全的模块可以自由地被不受信任的代码使用。现在,假设这个模块(推断为安全)实际上是 Data.HTML.Internal
,它导出了内部数据类型的构造器,允许用户违反数据结构的内部不变性(例如转义)。这看起来并不是很安全!
这种安全性的含义是微妙的:受信任代码库的正确性不能依赖于不受信任代码提供的任何不变量。例如,如果不受信任的代码定义了其自己有缺陷的二叉树实现,那么捕捉不受信任代码的错误不在 Safe Haskell 使命的范围内。但是,如果我们的 TCB(Trusted Computing Base)期望一个经过适当转义的 HTML
值,且没有嵌入的 JavaScript,那么违反此类型的封装性可能意味着不受信任的代码可以注入 XSS 攻击。
David Terei 和我对于在包边界方面使信任表达更加灵活有一些想法,但是我们对于正确的设计尚未达成一致意见。(希望我们能尽快做出决定!)
结论
Safe Haskell 本质上是一个非常简单的想法,但是有一些尖锐的边缘,特别是当 Safe Haskell 要求你进行 Haskell 程序中通常不会做的区分时。不过,Safe Haskell 相当独特:虽然确实存在广泛使用的沙盒编程语言(比如 Java 和 JavaScript),但 Safe Haskell 更进一步,允许你指定自己的自定义安全策略。结合一个与此功能良好兼容的大规模库生态系统,你将拥有一个在编程语言领域中真正独一无二的系统。
揭示 IO 单子的奥秘:ezyang 的博客
来源:
blog.ezyang.com/2011/05/unraveling-the-mystery-of-the-io-monad/
当我们向初学者教授 Haskell 时,我们需要讨论的一件事是 IO 单子的工作原理。是的,它是一个单子,是的,它执行 IO 操作,但它不是你可以在 Haskell 中实现的东西,这使得它具有某种神奇的品质。在今天的帖子中,我想通过描述 GHC 如何在基本操作和真实世界令牌的术语中实现 IO 单子来揭示 IO 单子的奥秘。阅读完本文后,你应该能够理解这个票据的解决方案以及这个 Hello World! 程序的 Core 输出:
main = do
putStr "Hello "
putStrLn "world!"
Nota bene: 这不是单子教程。本文假设读者知道单子是什么!然而,第一部分回顾了严格性作为单子应用的一个关键概念,因为它对 IO 单子的正确功能至关重要。
惰性和严格的 State 单子
作为 IO 单子的序曲,我们将简要回顾 State 单子,它构成了 IO 单子的操作基础(IO 单子被实现为一个带有特殊状态的严格 State 单子,尽管有一些重要的区别——这就是其魔力所在)。如果你对惰性和严格状态单子之间的区别感到舒适,可以跳过本节。否则,请继续阅读。State 单子的数据类型构造器如下:
newtype State s a = State { runState :: s -> (a, s) }
在状态单子中运行计算涉及给它一些输入状态,并从中检索出结果状态和计算的实际值。单子结构涉及通过各种计算来穿越状态。例如,状态单子中的这段代码片段:
do x <- doSomething
y <- doSomethingElse
return (x + y)
可以重写(去掉 newtype 构造器后)如下:
\s ->
let (x, s') = doSomething s
(y, s'') = doSomethingElse s' in
(x + y, s'')
现在,我想向读者提出一个相当有趣的实验:假设 doSomething
和 doSomethingElse
被跟踪:即,在评估时,它们输出一个跟踪消息。也就是说:
doSomething s = trace "doSomething" $ ...
doSomethingElse s = trace "doSomethingElse" $ ...
在 doSomething
的结果被强制执行之后,doSomethingElse
的跟踪是否会在其之前触发?在严格语言中,答案显然是否定的;你必须按顺序执行每个状态计算步骤。但 Haskell 是惰性的,在另一种情况下,doSomethingElse
的结果可能在 doSomething
之前被请求。确实,这里有一个这样的代码示例:
import Debug.Trace
f = \s ->
let (x, s') = doSomething s
(y, s'') = doSomethingElse s'
in (3, s'')
doSomething s = trace "doSomething" $ (0, s)
doSomethingElse s = trace "doSomethingElse" $ (3, s)
main = print (f 2)
发生的情况是,我们对状态值是惰性的,因此当我们要求 s''
的值时,我们强制执行了 doSomethingElse
并得到了一个指向 s'
的间接引用,然后导致我们强制执行了 doSomething
。
假设我们确实希望 doSomething
总是在 doSomethingElse
之前执行。在这种情况下,我们可以通过使我们的状态严格化来解决问题:
f = \s ->
case doSomething s of
(x, s') -> case doSomethingElse s' of
(y, s'') -> (3, s'')
这种从惰性 let
到严格 case
的微妙转换让我们现在可以保持顺序。事实上,事情会变得明朗:由于原语的工作方式,我们必须按照这种方式来做事情。留意 case
:当我们开始查看 Core 时,它会再次出现。
额外内容。有趣的是,如果你使用不可否认的模式,case
代码等同于原始的 let
代码:
f = \s ->
case doSomething s of
~(x, s') -> case doSomethingElse s' of
~(y, s'') -> (3, s'')
原语
我们故事的下一部分是 GHC 提供的原始类型和函数。这些机制是 GHC 导出类型和功能的方式,这些功能通常在 Haskell 中是无法实现的:例如,非装箱类型、两个 32 位整数相加,或执行 IO 操作(主要是将位写入内存位置)。它们非常特定于 GHC,普通的 Haskell 用户从不见它们。事实上,它们如此特殊,你需要启用一个语言扩展来使用它们(MagicHash
)!IO 类型是用 GHC.Types
中的这些原语构建的:
newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
为了理解 IO
类型,我们将需要了解这些原语中的一些。但很明显,这看起来非常像状态单子...
第一个原语是 非装箱元组,在代码中看到的形式为 (# x, y #)
。非装箱元组是一种“多返回”调用约定的语法;它们实际上并不是真正的元组,不能像普通元组那样放在变量中。我们将使用非装箱元组来代替我们在 runState
中看到的元组,因为如果每次执行 IO 操作都要进行堆分配,那将是非常糟糕的。
下一个原语是 State# RealWorld
,它将对应于我们状态单子的 s
参数。实际上,这是两个原语,类型构造子 State#
和魔术类型 RealWorld
(有趣的是,它没有 #
后缀)。之所以将其分为类型构造子和类型参数,是因为 ST
单子也重用了这个框架,但这是另一篇博文的事情。你可以将 State# RealWorld
视为表示非常神奇值的类型:整个真实世界的值。当你运行一个状态单子时,你可以用任何你准备好的值初始化状态,但只有 main
函数接收到真实世界,并且它随后会在你可能要执行的任何 IO 代码中进行线程处理。
你可能会问一个问题:“unsafePerformIO
怎么办?”特别是,由于它可能出现在任何纯计算中,而真实世界可能不一定可用,我们如何虚拟出真实世界的副本来执行等同于嵌套 runState
的操作?在这些情况下,我们有一个最终的原语,realWorld# :: State# RealWorld
,它允许您在任何地方获取对真实世界的引用。但由于这不是与 main
钩连的,你绝对不会得到任何顺序保证。
你好,世界
让我们回到我答应要解释的 Hello World 程序:
main = do
putStr "Hello "
putStrLn "world!"
当我们编译这个程序时,我们会得到一些核心代码,看起来像这样(某些部分,尤其是强制转换(虽然这是展示新类型如何工作的迷人演示,但在运行时没有影响),已经为了您的观看愉快修剪):
Main.main2 :: [GHC.Types.Char]
Main.main2 = GHC.Base.unpackCString# "world!"
Main.main3 :: [GHC.Types.Char]
Main.main3 = GHC.Base.unpackCString# "Hello "
Main.main1 :: GHC.Prim.State# GHC.Prim.RealWorld
-> (# GHC.Prim.State# GHC.Prim.RealWorld, () #)
Main.main1 =
\ (eta_ag6 :: GHC.Prim.State# GHC.Prim.RealWorld) ->
case GHC.IO.Handle.Text.hPutStr1
GHC.IO.Handle.FD.stdout Main.main3 eta_ag6
of _ { (# new_s_alV, _ #) ->
case GHC.IO.Handle.Text.hPutStr1
GHC.IO.Handle.FD.stdout Main.main2 new_s_alV
of _ { (# new_s1_alJ, _ #) ->
GHC.IO.Handle.Text.hPutChar1
GHC.IO.Handle.FD.stdout System.IO.hPrint2 new_s1_alJ
}
}
Main.main4 :: GHC.Prim.State# GHC.Prim.RealWorld
-> (# GHC.Prim.State# GHC.Prim.RealWorld, () #)
Main.main4 =
GHC.TopHandler.runMainIO1 @ () Main.main1
:Main.main :: GHC.Types.IO ()
:Main.main =
Main.main4
重要的部分是Main.main1
。重新格式化并重命名后,它看起来就像我们的去糖化状态单子:
Main.main1 =
\ (s :: State# RealWorld) ->
case hPutStr1 stdout main3 s of _ { (# s', _ #) ->
case hPutStr1 stdout main2 s' of _ { (# s'', _ #) ->
hPutChar1 stdout hPrint2 s''
}}
单子都消失了,而hPutStr1 stdout main3 s
,虽然表面上总是返回类型为(# State# RealWorld, () #)
的值,但却具有副作用。然而,重复的 case 表达式确保我们的优化器不会重新排列 IO 指令(因为那会有非常明显的效果!)
对于那些好奇的人,这里有一些关于核心输出的其他显著部分:
-
我们的
:main
函数(前面带有冒号)实际上并没有直接进入我们的代码:它调用了一个包装函数GHC.TopHandler.runMainIO
,该函数做一些初始化工作,比如安装顶级中断处理程序。 -
unpackCString#
的类型是Addr# -> [Char]
,它的作用是将以空字符结尾的 C 字符串转换为传统的 Haskell 字符串。这是因为我们尽可能地将字符串存储为以空字符结尾的 C 字符串。如果嵌入了空字节或其他恶意的二进制数据,则会使用unpackCStringUtf8#
。 -
putStr
和putStrLn
不见了。这是因为我使用了-O
进行了编译,所以这些函数调用被内联了。
有序的重要性
为了强调顺序的重要性,请考虑当你混淆seq
(传统上用于纯代码,不提供任何顺序约束)和对于 IO 非常重要的 IO 时会发生什么。也就是说,请考虑Bug 5129。Simon Peyton Jones 给出了一个很好的解释,所以我只想强调那些没有正确排序的代码是多么诱人(以及错误)。有问题的代码是x `seq` return ()
。这会编译成以下核心代码:
case x of _ {
__DEFAULT -> \s :: State# RealWorld -> (# s, () #)
}
请注意,seq
编译成一个case
语句(因为 Core 中的 case 语句是严格的),并且还请注意,此语句中的s
参数没有涉及。因此,如果此片段包含在较大的片段中,则这些语句可能会被优化。实际上,在某些情况下确实会发生这种情况,正如 Simon 所描述的那样。故事的寓意?不要写x `seq` return ()
(确实,我认为某些基础库中有这种习惯用法的实例需要修复)。新世界秩序是一个新的 primop:
case seqS# x s of _ {
s' -> (# s', () #)
}
更好!
这也说明了为什么seq x y
绝对不保证x
或y
哪个先评估。优化器可能注意到y
总是引发异常,而由于不精确的异常不关心抛出哪个异常,它可能会丢弃对x
的任何引用。天哪!
进一步阅读
-
大部分定义 IO 的代码位于
base
中的GHC
超模块中,虽然实际的 IO 类型在ghc-prim
中。GHC.Base
和GHC.IO
特别适合阅读。 -
Primops 的描述在 GHC Trac 上详细说明。
-
ST 单子的实现方式基本上也完全相同:不安全的强制转换函数只是进行一些类型重排,实际上并未改变任何内容。你可以在
GHC.ST
中进一步阅读。
Upgrading to Ubuntu Lucid : ezyang’s blog
现在学期结束了,我终于升级了我的笔记本电脑到 Ubuntu 10.04 LTS,即 Lucid Lynx。这个过程比Karmic 的情况要顺利得多,但仍然有一些小问题。
Etckeeper. 一如既往,您在尝试升级发布之前应将AVOID_COMMIT_BEFORE_INSTALL
设置为 0,因为 etckeeper 钩子将被多次调用,而最令人恼火的莫过于收到通知:“etckeeper 中止安装因为存在未提交的更改,请您自行提交它们”,因为那根本行不通。
这一次,出现了一个不同但又搞笑的错误:
/etc/etckeeper/post-install.d/50vcs-commit: 20:
etckeeper: Argument list too long
这已被报告为 Bug #574244。尽管这是一个不祥的警告,但实际上相当无害,您可以使用以下方式完成升级:
aptitude update
aptitude full-upgrade
我因为破碎的 DNS 而不得不重新启动网络;效果因人而异。
无线密钥管理. 我还没有解决这个问题,但基本症状是 Ubuntu 网络管理器无法记住您为受保护网络提供的 WEP 密钥。(我知道您还在校园的麻省理工学院的学生们可能对此并不太关心。)这似乎是一个相当普遍的问题,因为有人在复活早期的这个 bug,虽然这些问题早就存在了。 (典型的糟糕 bug 报告风格,用户们附加在旧的 bug 报告上,而他们实际上应该为 Lucid 提出新的回归。)
从我的调查中,我已经验证了与密钥环守护程序的连接无法正常工作。有一种解决方法正在流传,其中您可以将启动命令从“gnome-keyring-daemon --start --components=pkcs11”更改为只是“gnome-keyring-daemon”,尽管我怀疑这并不是真正的“正确”方法,而且在我这里也不起作用。
PHP. Ubuntu Lucid 最显著地升级了 PHP 5.3.2,但他们还调整了一些默认设置。在我的情况下,log_errors
为我的脚本引起了相当有趣的行为,因此我已经将我的脚本编码为显式关闭此 ini 设置。您应该在升级前保存php -i
的输出副本,并与升级后的输出进行比较。
使用 Monoid:一个实例:ezyang 的博客
注意保存。等效的 Haskell 和 Python 程序用于使用状态从数据结构中检索值。然后,我们将 Haskell 程序重构为没有状态,只有一个 monoid 的程序。
一个工作程序员经常需要做的事情是从一些数据结构中提取一些值(可能是多个),可能同时跟踪额外的元数据。有一天我发现自己写下了这段代码:
getPublicNames :: Module -> State (Map Name (Set ModuleName)) ()
getPublicNames (Module _ m _ _ (Just exports) _ _) = mapM_ handleExport exports
where handleExport x = case x of
EVar (UnQual n) -> add n
EAbs (UnQual n) -> add n
EThingAll (UnQual n) -> add n
EThingWith (UnQual n) cs -> add n >> mapM_ handleCName cs
_ -> return ()
handleCName x = case x of
VarName n -> add n
ConName n -> add n
add n = modify (Map.insertWith Set.union n (Set.singleton m))
getPublicNames _ = return ()
简而言之,getPublicNames
遍历Module
数据结构,查找“公共名称”,每次找到一个名称时,它记录当前模块包含该名称的记录。这使我能够高效地提出问题:“多少(以及哪些)模块使用 FOO 名称?”
Python 中的转录可能如下所示:
def getPublicNames(module, ret=None):
if not ret:
ret = defaultdict(set)
if module.exports is None:
return ret
for export in module.exports:
if isinstance(export, EVar) or \
isinstance(export, EAbs) or \
isinstance(export, EThingAll):
ret[export.name].add(module.name)
elif isinstance(export, EThingWith):
ret[export.name].add(module.name)
for cname in export.cnames:
ret[export.name].add(cname.name)
return ret
这两个版本之间有一些视觉上的差异:
-
Python 版本可以选择接受预先存在的状态;否则,它将进行初始化并具有引用透明性。然而,Haskell 版本没有默认状态的概念;我们相信用户可以用简单的
runState
运行状态单子。 -
Python 版本利用鸭子类型来减少代码;我还与假设的面向对象等价数据结构玩得很快。
-
Python 版本并没有将其代码分离成
handleExport
和handleCname
,尽管我们确实可以通过几个更多的内联函数来实现。
但除此之外,它们几乎完全以完全相同的方式读取和操作,通过改变状态。Python 版本也几乎是尽头;除了将函数推入其成员对象之外,我相信没有更多“Pythonic”的方法来做到这一点。然而,Haskell 版本让我觉得痒痒的…
我们从来没有读出状态! 这是我们应该使用 Writer 单子而不是 State 单子的明显迹象。然而,有一个轻微的技术困难:Writer 要求被“记录”的值是一个 Monoid,而理论上,Map k (Set a)
确实有一个做我们想要的事情的 Monoid 实例,但是对于 Map k v
的一般 Monoid 实例则不够。回想一下,一个 monoid 描述了可以“附加”在一起形成该数据的另一个版本的数据。对于 SetMap
,
-
我们想要一个 monoid 实例,它接受两个
SetMap
结构并将映射并集,通过并集那些集合来解决重复。 -
默认情况下,我们得到一个 monoid 实例,它接受两个
Map
结构并将映射并集,在冲突发生时更喜欢原始值并丢弃其余的值。
新类型来拯救。newtype
来了。我们将其称为SetMap
。用于烹饪新类型的配方如下:
首先,你需要一个新类型声明。在记录语法中显式命名字段为unDataType
是惯用法,并调用"解包"对象的新类型包装:
newtype SetMap k v = SetMap { unSetMap :: Map k (Set v) }
接下来,你需要编写你感兴趣的特殊类型类实例。(并可能使用deriving ...
来导入任何旧的、默认的实例,这些实例仍然很好。)
instance (Ord k, Ord v) => Monoid (SetMap k v) where
mempty = SetMap Map.empty
mappend (SetMap a) (SetMap b) = SetMap $ Map.unionWith Set.union a b
mconcat = SetMap . Map.unionsWith Set.union . map unSetMap
或许需要一些辅助函数:
setMapSingleton :: (Ord k, Ord v) => k -> v -> SetMap k v
setMapSingleton k v = SetMap $ Map.singleton k (Set.singleton v)
然后就完成了!
getPublicNames :: Module -> Writer (SetMap Name ModuleName) ()
getPublicNames (Module _ m _ _ (Just exports) _ _) = mapM_ handleExport exports
where handleExport x = case x of
EVar (UnQual n) -> add n
EAbs (UnQual n) -> add n
EThingAll (UnQual n) -> add n
EThingWith (UnQual n) cs -> add n >> mapM_ handleCName cs
_ -> return ()
handleCName x = case x of
VarName n -> add n
ConName n -> add n
add n = tell (setMapSingleton n m) -- *
getPublicNames _ = return ()
等等,我们使我们的代码更加具体,但它的长度却增加了!也许,亲爱的读者,你可能会因为新的SetMap
支持代码的存在(它构成了我们所写内容的主要部分,并且高度通用和可重用),而稍微感到安心;不过,除了该代码,我们稍微减少了从add n = modify (Map.insertWith Set.union n (Set.singleton m))
到add n = tell (setMapSingleton n m)
的代码量。
更重要的是,我们现在向最终用户表明了这个函数的新契约:我们只会写出值,而不会改变它们。
我们为什么再次使用了 monad 呢? 进一步检查显示,我们从未使用过绑定(>>=
)。事实上,我们并没有真正使用 monad 的任何功能。让我们使我们的代码更加具体:
-- This operator is going into base soon, I swear!
(<>) = mappend
getPublicNames :: Module -> SetMap Name ModuleName
getPublicNames (Module _ m _ _ (Just exports) _ _) = foldMap handleExport exports
where handleExport x = case x of
EVar (UnQual n) -> make n
EAbs (UnQual n) -> make n
EThingAll (UnQual n) -> make n
EThingWith (UnQual n) cs -> make n <> foldMap handleCName cs
_ -> mempty
handleCName x = case x of
VarName n -> make n
ConName n -> make n
make n = setMapSingleton n m
getPublicNames _ = mempty
函数的使用者现在不再需要execWriter
,虽然他们可能最终需要用unSetMap
来解包输出,但这里并没有太多的空间变化。
技术上,我们从未需要 monoid. 特别是,setMapSingleton
强迫我们的代码迎合SetMap
,而不是一般的 Monoids(这也不太合理)。也许"Pointed" Monoid 的概念会有用。所以我们本可以直接写出所有函数;更有可能的是,我们可以定义另一组辅助函数以减少代码大小。但你仍然应该使用 monoid. Monoids 有一些特定的行为方式(例如,monoid 法则)和一组规范的操作函数。通过使用这些函数,即使他们不熟悉你的特定 monoid,其他人也可以快速推理你的代码。
后记. 在写这篇博客文章时,我重构了真实的代码;所有的例子都不是虚构的。我最初计划写一些关于"You ain't gonna need it"和 Haskell 抽象的内容,但是完善这个例子的过程比我预期的要长一些。也许下次吧...
后脚注. Anders Kaseorg 指出,SetMap 已在几个地方(Criterion.MultiMap, Holumbus.Data.MultiMap)实现了,但尚未放入一个特别通用的库中。
- 使用源码,不要阅读它:ezyang 的博客
Use the source, don’t read it
变体类型和 GADTs:ezyang 的博客
OCaml 支持匿名变体类型,形式为type a = [`Foo of int | `Bar of bool]
,具有适当的子类型关系。子类型在一般情况下比较棘手,因此我一直比较保守地使用这些变体类型。(即使一个特性给了你太多的灵活性,如果你有纪律地使用它,它也是可控的和有用的。)事实上,它们对于我通常会使用 GADTs 的一个特定用例非常方便。这就是“将多个和类型合并为单个和类型”的用例。
考虑以下在 Haskell 中的程序:
data A = Foo Int | Bar Bool
data B = Baz Char | Qux
如果你想定义 A 加 B 的道德等价物,最简单的方法是:
data AorB = A A | B B
但这种方法有点糟糕:我更喜欢一种平坦的命名空间,可以引用A
和B
(此编码在惰性存在时不等同于data AorB = Foo Int | Bar Bool | Baz Char | Qux
)。如果在 OCaml 中使用普通的和类型,你也会遇到类似的问题。但是,如果使用变体类型,你可以轻松管理这些情况:
type a = [`Foo of int | `Bar of bool]
type b = [`Baz of char | `Quz]
type a_or_b = [a | b]
很好!请注意,我们并未使用变体类型的完整通用性:我只会在a
、b
或a_or_b
的上下文中引用这些变体构造函数。这可以避免强制转换的混乱。
我实际上可以在 Haskell 中使用 GADTs 完成这个,尽管对于初学者来说显然不明显:
data A
data B
data AorB t where
Foo :: Int -> AorB A
Bar :: Bool -> AorB A
Baz :: Char -> AorB B
Quz :: AorB B
要匹配所有构造函数,我指定类型AorB t
;要仅匹配A
,我使用AorB A
;要仅匹配B
,我使用AorB B
。别问我如何指定超过两个组合和类型的任意子集。(评论区中的解决方案欢迎,但它们的清晰度将会评分。)
Haskell 的方法确实有一个优势,即和类型仍然是封闭的。由于 OCaml 不能做出这样的保证,像bin-prot
这样的东西需要使用完整的四字节来指定变体类型(它们对名称进行哈希并将其用作唯一标识符),而不是这里所需的两位(但更可能是一个字节)。这也意味着更有效的生成代码。
参观月份:普林斯顿:ezyang 的博客
如果你还没注意到,这些内容是按照参观日期的顺序排列的。
在 UPenn 的天气晴朗明媚的情况下,NJ Transit(新泽西州交通公司)的小船开进了非常雾蒙蒙的普林斯顿。幸运的是,我已经为这个参观日适当地注册了,所以酒店也准备妥当。我有点早到了,所以我会见了一个老朋友,他最近撰写了这个短篇故事,我们聊了一些零星琐事(“我听说你要角逐雨果奖了!”),然后我漫步去了计算机科学楼。
对我来说,普林斯顿校园也留下了一些独特的回忆,是我高中时期的一段经历拼接而成,包括在普林斯顿风管乐团做了一个不愉快的月份临时工作(后来我得知该乐团现在好多了),因各种原因访问该地区,一次令人着迷的面试,当时我被告知“我不应该去普林斯顿”,以及一次非常愉快的,尽管略显格格不入的校园预览周末。当然,研究生活与本科生活完全不同,所以我准备将这些经历留待我第二次访问时再做探讨。
在安德鲁·阿佩尔在录取接待晚宴上的演讲中,我发现关于普林斯顿计算机科学最有趣的事情之一是,许多著名的计算机科学家曾经在某个时候与普林斯顿有过联系;其中包括教堂、图灵、哥德尔、冯·诺依曼等人……他演讲的一个见解是:“也许你的博士论文不需要是你最重要的工作,但也许你可以有一个四页的附注,阐述计算机科学中最重要的技术之一。”(他指的是艾伦·图灵的论文,其中介绍了相对计算的概念。)
一些“事实”:在普林斯顿,你的导师们会帮助你完成学业,所以你不必担心在博士学位上浪费太多时间。成为兼职学生不可取,而且普林斯顿确实有资格考试,但你可能会通过。(总的经验是,在这里我得出的结论是,计算机科学的资格考试与其他博士学科的考试非常不同,后者很大程度上是为了淘汰不合格者。在计算机科学中,他们希望你通过。)将军们在某种意义上是一个检查点,你可以弄清楚你是否对与导师一起进行的研究感到满意。你需要修六门课程,其中四门是分配课程,但你可以通过期末考试。你的第二年会担任“预 ceptor”教学,两个学期都是如此。你可以在第一年结束时选择你的导师,而且你的论文委员会有五个人(这通常不会有太多对抗性)。你相对而言不会受到资助的影响。
安德鲁·阿普尔(Andrew Appel) 是一个非常聪明的人。他最近与我的导师亚当·克利帕拉合作,并且他们的研究兴趣有些重叠。他正在研究一个验证软件工具链,但他也很乐意解决一些较小的问题。在我们三对一的会议期间,他向我们展示了一个小的理论问题:计算不是单调的函数的不动点,而是朝向某种收敛震荡(当递归出现在反变位置时会发生这种情况;尽管这可能看起来很奇怪,在面向对象的语言中会有点出现!)他的一个研究生告诉我,他喜欢“语义定义”,并且不怕接受大挑战或者处理 Cminor、整数对齐或者为大型程序扩展理论技术的细节。尽管他是系主任,但很容易安排时间与他会面,并且他似乎有一种不可思议的能力来获得资助。
大卫·沃克(David Walker) 对于用于处理网络编程的 Frenetic 编程语言非常兴奋。我已经对此有些背景,因为 Jennifer Rexford 曾在 POPL 会议上就这个主题发表过邀请演讲。第二次听到关于 OpenFlow 的时候,我注意到一个有趣的事情:它如何处理未知事件与硬件非常相似:如果高效的查找表不知道该怎么做,你就中断并转向一个慢速的通用计算机,后者会找出该怎么做,将规则安装到查找表中,然后将数据包发送出去。我们三个可能都对技术性问题感兴趣,因为我们最后请他详细说明了更新网络规则时每个数据包一致性的细节。我听说在另一个生活中,大卫·沃克也从事了类型系统的工作,并且还涉及了一些语义学工作。一位研究生指出 Appel 和 Walker 之间的一个对比是,Appel 会假设你知道他在说什么(因此如果你不知道,你就得打断他:这是一种有用的技能!),而 Walker 总是会解释一切,并且如果他不知道你在说什么,他会停下来。这使得他在沟通方面非常擅长。(哦,我提到过 Frenetic 是用 Haskell 编写的吗?)
偶然间,我发现其中一位现任研究生竟然是smogon.com的创始人。他现在从事 PL 研究:但看到多年来兴趣如何改变,真是有趣…
访问月份:宾夕法尼亚大学:ezyang 的博客
希望这将是一系列文章的开端,描述我过去一个月参加的所有访问日/开放日。大部分信息都是从我访问期间记下的笔记中汲取出来的,因此风格非常流畅。这有点私人化,如果你决定不阅读,我不会感到冒犯。你已经被警告了!
我在午夜前不久到达宾夕法尼亚旅馆,登记入住。呃,试图入住;他们似乎没有我的预订。看来我实际上并没有注册参加访问周末。糟糕。
后来 Sam 告诉我,第一印象很重要,她对我的第一印象是一个彻底混乱的博士生录取者。把我的头发弄乱!问他们是否也是 CS 博士录取者,无论他们是否有房间,哦,对了,你叫什么名字?(直到 CMU 访问结束时她告诉我这是真正的问题)但 Brent 在 IRC 上,我打给他,他认识一个名叫 Antal 的人也在访问 UPenn,所以我给他打了个电话,他借给我他的房间过了一个晚上,一切都挺好的。(Mike,研究生学习协调员,很好心地把我安排到了第二天的日程中。谢谢你,Mike!)
我以前去过 UPenn。我和我爸爸一起去过;Merck 的前 CEO Roy Vagelos 对 UPenn 有很大影响,我曾私下希望能在计算机科学研究生院之前攻读非计算机科学的本科学位。但是当 MIT 和普林斯顿的录取结果出来后,UPenn 被彻底排除在外,这段经历被收拾整齐,放到一边了。但我认出了一些校园的小片段,这些与我最近参加的 Hac Phi 和 POPL 的经历联系在一起。我们在费城的游览引导我们到了我在 POPL 期间住的那家旅馆,我感到非常惊讶。
本杰明·皮尔斯 正飞往瑞典参加 WG 2.8(一个充满精灵、滑雪和函数式编程的神奇地方),因此他只能参加上午的演示,但在早餐时,他向几位候选人简要介绍了差分隐私,然后主持了演示。我从他的学生那里听说,他已经对 CRASH 项目非常投入,并且更多地关注硬件方面的事情。早上的演讲中,我听到的一个显著的事情是编程语言似乎已经渗透到了所有展示的计算机科学研究中(或者这只是选择偏见?)不过,并非所有人:机器学习研究人员只是“想做很多数学”(但也许我们仍然对隐私有些担忧)。
UPenn 以其 PL 研究组而闻名,“我们每天都会写一点希腊文。”走进 PL 午餐时,令人印象深刻,一群研究生站在后墙上开玩笑,笑谈 Coq 和 Haskell,演示内容是关于 F*的。
这里有一些“事实”。在 UPenn,你要花两个学期担任 TA,有一个办公室抽签,但同一组的人倾向于聚在一起,你不必为了赢得资助而工作,教授们非常理解研究生的生活情况,生活费用大约是每月 500 美元,而且这是一个非常轻松的地方。你的导师在你的学位中非常有权威,只有在每年一次的部门广泛审查中才会检查是否有人掉队。辍学率很低。UPenn 有一个很好的健身房,每年 400 美元。在费城骑自行车很棒,但你不应该住在研究生宿舍。因为有 Pierce、Zdancewic 和 Weirich 这三位,当你要组成论文委员会时,其他两位不是你导师的教授会为你服务。PL 研究组处于一个稍微不寻常的境地,没有 2、3 年级的学生(这是由于某年双重休假以及另一年的倒霉抽签导致的)。但有无数的一年级学生。你必须参加三次轻松的考试。费城是一个美丽而大的城市(第五大!)UPenn 和 CMU 可能是我访问过的纯编程语言部门中最大的两个。
Stephanie Weirich 对依赖类型语言非常感兴趣。她想找出如何让函数式程序员使用依赖类型,并从两个方面进行攻击:你可以选择像 Haskell 这样的语言,我们正在向其中添加越来越多的功能,使其朝向依赖类型发展;或者你可以像 TRELLYS 一样从头开始构建一种语言,试图以一种可用的方式集成依赖类型。她不介意给一年级学生真正困难的项目,但她乐意和学生一起做一些随机的事情。她反思了 Dimitrios Vytiniotis 的职业轨迹:他在多态性和 Haskell 中发现了自己的真正兴趣,现在与 Simon Peyton Jones 在微软研究院一起设计疯狂的类型系统。“我不能让你对某个话题着迷”,(从好的方面来说)但她有兴趣帮助你找到真正激起你热情的事物。我问她关于开发类型系统元理论所涉及的技术技能(毕竟,一个本科生在这方面几乎没有任何经验是非常罕见的):她告诉我,这完全是关于看大量的例子,弄清楚什么会让你陷入麻烦。(从某种意义上说,这并不“深奥”,尽管也许任何试图理解类型理论家的论文的可怜计算机科学家可能会有不同意见。)
Steve Zdancewic 的研究兴趣有点广泛,但其中一件令人兴奋的事情是,他们已经在过去一年里为 LLVM 构建了 Coq 基础设施,现在是时候利用这个基础设施来做一些很酷的事情,比如做大型证明并弄清楚发生了什么。已经有很多情况表明,仅仅做这件事情就带来了许多有趣的见解(包括意识到许多证明都是某种通用技术的实例):机械化验证是一件好事情。他还有一些副项目,包括为量子计算机执行的编程语言编译器(所有计算必须是可逆的!实际上有一个完整的会议专门讨论这个问题;结果表明你可以做一些降低热量排放的事情)。还有一点对程序合成很感兴趣。当 Steve 说“我对我的学生们感兴趣到足以说服我对此感到热情”时,他与 Stephanie 的观点一致。他们不像化学博士那样使用这个移液管一亿次。
某位研究生的话来说,这些教授们“友好且比你聪明得多!”他们乐于在编程语言概念上玩耍和享受乐趣(与在 CMU 进行的非常严格的类型理论研究相反;但稍后再说)。
我们以一张图片作结,它出现在费城的街区中。
可视化块分配器:ezyang 的博客
可视化块分配器
GHC 的block allocator是一个非常棒的低级基础设施。它提供了一种更灵活的管理堆的方式,而不是试图把所有内容都塞进一块连续的内存块中,可能对于像运行时这样实现低级代码的任何人都应该是一件通常感兴趣的事情。其核心思想相当古老(BIBOP: 大袋子页),对于任何对象都标有相同描述符的情况并且不想为每个对象支付标签的成本都非常有用。
管理比页面大的对象有些棘手,因此我写了一篇文档来可视化这种情况,以帮助自己理解。我想这可能会引起一般兴趣,所以你可以在这里获取它:web.mit.edu/~ezyang/Public/blocks.pdf
总有一天我会把它转换成可维基形式,但今天我不想处理图像...
可视化范围树:ezyang's 博客
范围树是一种数据结构,可以让您有效地查询一组点,并找出在某个边界框内的点。它通过维护嵌套树来实现:第一级按 x 坐标排序,第二级按 y 坐标排序,依此类推。不幸的是,由于它们的分形性质,范围树有点难以可视化。(在更高维度的情况下,这绝对是一个“Yo dawg,我听说你喜欢树,所以我把一棵树放在你的树里...”)但是无论如何,我们打算通过利用一个排序列表基本上与平衡二叉搜索树相同的事实来可视化它们。(出于理智的考虑,我们还将限制自己到二维情况。)我还将描述一种用于构建范围树的好算法。
假设我们有一组点 。我们如何构建范围树?我们首先为 x 坐标建立一个平衡二叉搜索树(用蓝色标出)。我们可以通过使用您喜欢的排序算法对列表进行排序,然后从中构建 BBST 来完成此操作;但是,我们可以直接使用具有中位数查找的快速排序来直接构建树,如下图所示左侧。
一旦我们按照 x 坐标排序完毕,我们现在需要重新按照每个 x 子树的 y 坐标(用红色标出)进行排序,排序结果将存储在我们将在 x 子树内部存储的另一棵树中。现在,我们可以从头开始对每个列表进行排序,但是由于对于任何节点,我们正在计算其子节点的 y 排序树,我们可以像归并排序那样将它们合并在一起,如上图所示的右侧。(这就是 ![n\lg^{d-1} n](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg^{d-1} n") 中的 -1 来源!)
所以,当我们创建范围树时,我们首先对 x 坐标进行快速排序,然后对 y 坐标进行归并排序(保存中间结果)。如下图所示:
我们可以将这个图解释为一个范围树:顶层树是 x 坐标的平衡二叉搜索树(BBST),当我们到达叶子节点时,所有点都按照 x 坐标排序。然而,存储在中间节点内部的点代表 y 坐标的 BBST;每个列表都按 y 坐标排序,并隐式地表示另一个 BBST。我还在底部添加了一个显示这个范围树中保存的点的渲染图。
让我们以这个作为我们的工作示例。如果我们想要找到 x 坐标在 1 到 4 之间的点,我们搜索包含 1 的叶子节点,包含 4 的叶子节点,并获取这之间的所有子树。
如果我们想要在 y 坐标为 2 和 4 之间(包括)找到点,而不对 x 进行过滤,我们可以简单地查看存储在根节点中的 BBST 并执行范围查询。
当我们实际上想要执行边界框(例如 (1,2) x (4,4) 包括)时,事情就变得更有趣了:首先,我们定位 x-BBST 中的所有子树;然后,在每个 y-BBST 中进行范围查询。
这里有另一个例子 (4,4) x (7,7) 包括。这一次我们很幸运,只需要检查一个 y-BBST,因为 X 范围直接对应于一个子树。然而,一般情况下,我们只需要检查 ![O(\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)") 个子树。
查询时间为 ![O(\lg² n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg² n)"),这是很容易理解的(因为我们可能需要在 ![O(\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)") 棵树上执行一维范围查询,每次查询花费 ![O(\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)") 的时间)。或许不太明显的是,这种方案只占用 ![O(n\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)") 的空间。此外,我们实际上可以通过一种称为分数级联的技巧将查询时间降低到 ![O(\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)")。但这是另一篇博文!
可视化可满足性、有效性和蕴涵性:ezyang 的博客
来源:
blog.ezyang.com/2012/10/visualizing-satisfiability-validity-and-entailment/
你正在半枯燥地处理命题逻辑问题集(毕竟,作为一名计算机科学家,你知道 AND 和 OR 是什么),突然问题集给出一个真正难解的问题:
是否真的有Γ ⊢ A 意味着Γ ⊢ ¬A 是假的?
然后你想,“双重否定,没问题!”并说,“当然!”当然,这是错误的:在你交卷后,你会想,“哎呀,如果Γ包含矛盾,那么我可以证明 A 和¬A。”然后你会想,“嘿,该死,我对这个东西一点直觉都没有。”
实际上,你可能已经对这类问题有了很好的直觉,只是你还不知道。
我们要做的第一件事是为命题逻辑句子建立一个视觉语言。当我们讨论命题句子如 A ∨ B 时,有一些需要赋值的命题变量,例如 A 为真,B 为假。我们可以将这些赋值看作是形成大小为2^n
的集合,其中n
是正在考虑的命题变量的数量。如果n
很小,我们可以简单地画一个 Venn 图,但由于n
可能相当大,我们将其可视化为一个圆形:
我们感兴趣的是分配的子集。有很多方法来定义这些子集;例如,我们可以考虑将 A 分配为真的分配集。但我们将对一种特定类型的子集感兴趣:特别是,使某个命题句子为真的分配子集。例如,“A ∨ B”对应于集合{A=true B=true, A=true B=false, A=false B=true}
。我们将像这样图形化地绘制一个子集:
逻辑连接词直接对应于集合操作:特别是,合取(AND ∧)对应于集合交(∩),析取(OR ∨)对应于集合并(∪)。注意对应的运算符看起来非常相似:这不是偶然的!(当我首次学习我的逻辑运算符时,就是这样使它们清晰明了的:U 代表并集,从而一切就水到渠成。)
现在我们可以开始进入问题的核心了:比如“不可满足性”、“可满足性”和“有效性”(或者说是重言式)这样的陈述,实际上只是关于这些子集形状的陈述。我们可以通过视觉表达每一个:它们分别对应于空集、非空集和完整集:
这一切听起来很好,但我们还没有讨论“⊢”(即逻辑蕴涵)如何融入其中。实际上,当我说“B ∨ ¬B 是有效的”时,我实际上是在说“⊢ B ∨ ¬B 是真实的”;也就是说,无论我被允许使用什么假设,我总是能证明“B ∨ ¬B”。
所以大问题是:当我添加一些假设时会发生什么?如果我们考虑这里正在发生的事情,当我添加一个假设时,在某种意义上我使自己的生活变得“更容易”:我添加的假设越多,更多的命题句就是真实的。反过来说,我添加的假设越多,我需要担心的分配空间就越小:
Γ ⊢ φ为真所需的一切是Γ中的所有分配引起φ为真,即Γ必须包含在φ中。
太好了!所以让我们再次看看这个问题:
Γ ⊢ A 是否意味着Γ ⊢ ¬A 为假?
重新表述为一个集合论问题,即:
对于所有的Γ和 A,Γ ⊂ A 是否意味着Γ ⊄ A^c(集合的补集)是真的?
我们考虑了一会儿,意识到:“不!因为空集是所有集合的子集是真的!”当然,空集恰好是一个矛盾:在所有事情的子集中(ex falso),而且仅仅是它自己的超集(只有矛盾暗示矛盾)。
结果证明,Γ也是一个集合,并且人们可能会想问Γ上的集合运算是否与我们的集合论模型中的集合运算有任何关系。这是非常诱人的,因为合并Γ似乎非常有效:Γ ∪ Δ
似乎给我们Γ和Δ的合取(如果我们通过 AND 操作它们的所有元素来解释集合)。但最终,给出的最佳答案是“不”。特别是,Γ上的集合交是不连贯的:{A} ∩ {A ∧ A}
应该是什么?一个严格的语法比较会说{}
,即使明显A ∧ A = A
。真正正确的做法是进行一个析取,但这要求我们说{A} ∩ {B} = {A ∨ B}
,这是令人困惑的,最好放在一边不予理会。
vmap in Haskell : ezyang’s blog
vmap 是 JAX 推广的一种接口,为您提供向量化映射。从语义上讲,vmap 与 Haskell 中的 map 完全等效;关键区别在于,在 vmap 下运行的操作是向量化的。如果对卷积和矩阵乘法进行映射,您将得到一个大循环,它会重复调用每个批次条目的卷积和矩阵乘法。如果 vmap 一个卷积和矩阵乘法,您将调用这些操作的批量实现一次。除非您有一个融合器,在大多数现代深度学习框架上,调用这些操作的批处理实现会更快。
JAX 实现 vmap 的方式略显复杂;它们有一个“批量解释器”,将原始操作转换为它们的批量版本,并且必须跟踪有关哪些张量是批量化的以及以何种方式批量化的元数据,以便能够插入适当的广播和展开操作。我向 Simon Peyton Jones 提到了这一点,他立即问道,Haskell 的类型检查器不能自动处理这个吗?答案是可以!JAX 需要进行的所有簿记实际上是在运行时进行类型推断;如果您有一个可以在编译时为您完成这项工作的编译器,那么几乎没有什么需要实现的了。
揭示结论,我们将实现一个 vmap 函数族,用于运行以下两个示例:
example1 :: [Float] -> [Float] -> [Float]
example1 a0 b0 =
vmap0_2 (\a b -> add a b) a0 b0
example2 :: [Float] -> [Float] -> [[Float]]
example2 a0 b0 =
vmap0 (\a -> vmap1 (\b -> add a b) b0) a0
在解释器中运行时,我们将看到:
*Test> example1 [1,2,3] [4,6,8]
[5.0,8.0,11.0]
*Test> example2 [1,2,3] [4,6,8]
[[5.0,7.0,9.0],[6.0,8.0,10.0],[7.0,9.0,11.0]]
这些结果与您使用普通的 map
得到的结果相等;然而,在 vmap 的实现中没有循环。(无法编写一个普适的 vmap 的事实是 Haskell 的一个限制;我们稍后会更详细地讨论这一点。)
我们需要一些语言扩展,所以让我们先把这个问题解决掉:
{-# LANGUAGE RankNTypes, GADTs, MultiParamTypeClasses,
KindSignatures, TypeApplications, FunctionalDependencies,
FlexibleContexts, FlexibleInstances, UndecidableInstances,
IncoherentInstances #-}
我们的攻击计划是,我们希望编写 vmap
的定义,以便推断出 add
的类型,从而清晰地显示出必要的广播。 vmap
的一个微不足道的实现将具有签名 ([a] -> [b]) -> [a] -> [b]
(也就是恒等函数),但标准列表类型并不允许我们区分应一起广播的维度和不应一起广播的维度(这就是为什么 example1
和 example2
得到不同结果的原因:在 example2
中,我们沿着每个维度分别广播,因此最终得到一个笛卡尔积;在 example1
中,我们将维度一起广播并获得了“zip”的行为)。每个不同的 vmap
调用应该给我们一个新的维度,这些维度不应与其他 vmap
调用混淆。当你在 Haskell 中听到这些时,你的第一反应应该是,“我知道了,让我们使用一个二阶类型!” vmap
将我们从普通列表 [Float]
的非类型品牌世界移动到带有大小索引向量 Vec s Float
的类型品牌世界,其中 s
变量都是由我们的二阶类型约束的 skolem 变量:
data Vec s a = Vec { unVec :: [a] }
instance Functor (Vec s) where
fmap f (Vec xs) = Vec (map f xs)
vmap0 :: (forall s. Vec s a -> Vec s b) -> [a] -> [b]
vmap0 f = unVec . f . Vec
vmap0
的实现什么也不做:我们只是将列表包装成它们的类型品牌等效向量。我们还可以提供 vmap0
的二元版本,它一次接受两个列表并分配它们相同的类型品牌:
vmap0_2 :: (forall s. Vec s a -> Vec s b -> Vec s c) -> [a] -> [b] -> [c]
vmap0_2 f a b = unVec (f (Vec a) (Vec b))
(原则上,一些类似 applicative 的东西应该使得我们可以仅写一个 vap
(类似于 ap
),然后免费获取所有 n-ary 版本,但在我简短的调查中,我没有看到一个好的方法来实现这一点。)
当我们嵌套 vmap
时,函数可能并不直接返回 Vec s b
,而是包含 Vec s b
的函子。 vmap1
处理这种情况(我们稍后将更详细地讨论这一点):
vmap1 :: Functor f => (forall s. Vec s a -> f (Vec s b)) -> [a] -> f [b]
vmap1 f = fmap unVec . f . Vec
有了我们手头的 vmap
实现,我们可以查看我们的示例,并询问 Haskell 如果我们没有它的实现,add
的类型应该是什么:
example1 :: [Float] -> [Float] -> [Float]
example1 a0 b0 =
vmap0_2 (\a b -> _add a b) a0 b0
得到:
• Found hole: _add :: Vec s Float -> Vec s Float -> Vec s Float
Where: ‘s’ is a rigid type variable bound by
a type expected by the context:
forall s. Vec s Float -> Vec s Float -> Vec s Float
然而:
example2 :: [Float] -> [Float] -> [[Float]]
example2 a0 b0 =
vmap0 (\a -> vmap1 (\b -> _add a b) b0) a0
得到:
• Found hole:
_add :: Vec s Float -> Vec s1 Float -> Vec s (Vec s1 Float)
Where: ‘s1’ is a rigid type variable bound by
a type expected by the context:
forall s1\. Vec s1 Float -> Vec s (Vec s1 Float)
at test.hs:41:20-44
‘s’ is a rigid type variable bound by
a type expected by the context:
forall s. Vec s Float -> Vec s [Float]
at test.hs:41:7-48
注意,这两种情况下 _add
的推断类型是不同的:在第一个示例中,我们推断出两个张量以相同方式进行批处理,并且我们想要将它们“zip”在一起。在第二个示例中,我们看到每个张量具有不同的批处理维度,最终得到一个二维结果!
到此为止,vmap
的工作已经完成:我们的洞有了我们可以用来确定必要行为的类型。你可以使用这些类型来选择执行矢量化加法的适当内核。但我承诺提供可运行的代码,所以让我们使用传统的 map
实现一个简单版本的 add
。
在 Haskell 中进行类型级计算的传统方式当然是使用类型类!让我们为函数 add
定义一个多参数类型类;与 Num
中的 (+)
定义不同,我们允许输入和输出都具有不同的类型:
class Add a b c | a b -> c where
add :: a -> b -> c
我们可以轻松地对普通浮点数进行加法实现:
instance Add Float Float Float where
add = (+)
如果我传入两个参数,它们最外层的向量类型一致(也就是它们来自同一个 vmap),我应该像我在example1
中所做的那样将它们一起压缩。我可以编写另一个实例来表达这个逻辑:
instance Add a b r => Add (Vec s a) (Vec s b) (Vec s r) where
add (Vec a) (Vec b) = Vec (zipWith add a b)
否则,我应该广播一个维度,然后在内部进行加法。这个选择不能在本地轻易完成,所以我必须定义这两个不一致的实例:
instance Add a b r => Add (Vec s a) b (Vec s r) where
add (Vec a) b = Vec (map (\x -> add x b) a)
instance Add a b r => Add a (Vec s b) (Vec s r) where
add a (Vec b) = Vec (map (\x -> add a x) b)
(GHC 的类型类解析引擎不会回溯,所以我不确定它是如何成功选择要使用的正确实例的,但在我的测试中,无论我如何指定 add 的参数顺序,我都得到了正确的实例。)
就这样!运行这两个示例:
example1 :: [Float] -> [Float] -> [Float]
example1 a0 b0 =
vmap0_2 (\a b -> add a b) a0 b0
example2 :: [Float] -> [Float] -> [[Float]]
example2 a0 b0 =
vmap0 (\a -> vmap1 (\b -> add a b) b0) a0
我得到:
*Test> example1 [1,2,3] [4,6,8]
[5.0,8.0,11.0]
*Test> example2 [1,2,3] [4,6,8]
[[5.0,7.0,9.0],[6.0,8.0,10.0],[7.0,9.0,11.0]]
所以这就是它!在不到十行的 Haskell 代码中使用 vmap。关于这种实现令人不满意的一点是必须定义vmap0
、vmap1
等。我们不能只定义一个通用的vmapG :: (forall s. Vec s a -> f (Vec s b)) -> [a] -> f [b]
,然后在需要时将f
统一为恒等类型 lambda /\a. a
吗?遗憾的是,带类型 lambda 的类型推断是不可判定的(即所谓的高阶一致性问题),所以在这里似乎我们必须帮助 GHC,即使在我们的特定情况下,我们可以在这里进行的统一非常受限制。
Agda 中的良好递归:ezyang’s 博客
上周二,Eric Mertens 在 Galois 的技术讲座上发表了 Introducing Well-Founded Recursion。我得承认,第一次听到时大部分内容都超出了我的理解范围。以下是我重新阅读代码时写下的一些笔记。建议先阅读 slides 以对演示有所了解。这些笔记是针对一个对类型系统感到舒适但不完全理解柯里-霍华德同构的 Haskell 程序员。
> module Quicksort where
>
> open import Data.Nat public using (ℕ; suc; zero)
> open import Data.List public using (List; _∷_; []; _++_; [_]; length; partition)
> open import Data.Bool public using (Bool; true; false)
> open import Data.Product public using (_×_; _,_; proj₁; proj₂)
Agda 是基于直觉主义类型论的证明辅助工具;也就是说,柯里-霍华德同构定理。柯里-霍华德同构表明看起来像类型和数据的东西也可以视为命题和证明,并且在理解 Agda 中的良好递归的关键之一是自由地在这两者之间交换,因为我们将使用类型系统来对我们的代码进行命题,而 Agda 在检查时会使用这些命题。我们将尝试呈现类型和命题的两种视角。
Types : Data :: Propositions : Proofs
Agda 需要确信你的证明是有效的:特别是,Agda 想知道你是否涵盖了所有情况(穷举模式匹配,完全性),并且你是否会推迟回答(终止性)。在情况检查方面,Agda 非常聪明:如果它知道某种情况在实践中无法实现,因为其类型代表一个虚假,它不会要求你填写该情况。然而,在终止性检查方面经常需要帮助,这就是良好递归的用武之地。
热身。
今天我们的第一个数据类型是 top:仅有一个值 unit 的类型,即 () 在 Haskell 中。数据居住在类型中,就像命题存在于命题中的证明一样;你可以把类型想象成“房子”,里面居住着任意数量的居民,即数据类型。经常会看到 Set 弹出:严格来说,它是“小”类型的类型,Set₁ 更大,Set₂ 更大,依此类推……
> data ⊤ : Set where unit : ⊤
Bottom 是一种根本没有任何东西的类型。如果没有命题的证明存在,那么它是假的!同样,在值级别上,这是 Haskell 中的未定义或错误“foobar”;在类型级别上,它被称为 Void,尽管在实际代码中没有人真正使用它。在 Agda 中,它们是同一种东西。
> data ⊥ : Set where
我们从 Data.Nat 中引入了自然数,但这里是最小定义的样子:
data ℕ : Set where
zero : ℕ
suc : ℕ → ℕ
值得注意的是,Agda 中的数值常量如 0 或 2 是零和 suc (suc zero) 的语法糖。它们也可能出现在类型中,因为 Agda 是依赖类型的。 (在 Haskell 中,你必须将自然数的定义推入类型系统;在这里,我们可以写一个正常的数据定义,然后自动提升它们。力量给工人阶级!)
这个函数做了一些非常奇怪的事情:
> Rel : Set → Set₁
> Rel A = A → A → Set
实际上,它等价于这个扩展版本:
Rel A = (_ : A) → (_ : A) → (_ : Set)
因此,结果类型不是 A → A → Set,而是某些 其 类型为 A 的东西,另一些 其 类型也是 A 的东西,结果是某些其类型为 Set 的东西。在 Haskell 的术语中,这不是类型函数的类型 * → *
;这更像是一个非法的 * -> (a -> a -> *)
。
这里是一个简单关系的例子:自然数的小于关系。
> data _<_ (m : ℕ) : ℕ → Set where
> <-base : m < suc m
> <-step : {n : ℕ} → m < n → m < suc n
Agda 语法并不那么简单:
-
(m : ℕ) 表示 < 是由 m 参数化的,使得 m,一个类型为 ℕ 的值,在我们的数据构造函数中可用。参数化意味着它也是 < 的第一个参数;此时,您应该检查所有构造函数的类型签名,确保它们确实是形式为 m<_ 的。
-
{n : ℕ} 表示一个“隐含”参数,这意味着当我们调用 <-step 时,我们不需要传递它;Agda 将自动从后面的参数中找到它,在这种情况下是 m < n。
-
记住,“对于所有 x : A,y : B”,等同于提供一个全函数 f(x : A) : B。因此有一个便捷的缩写 ∀ x →,等同于 (x : _) →(下划线表示任何类型都可以)。
语法已经解释清楚了,这个表达式的数学意图应该是清楚的:对于任意的数,我们自动得到证明 m<m+1;并且有了 m<n → m<n+1,我们可以归纳地得到其余的证明。如果你眯起眼睛看,你也可以理解数据的含义:<-base 是一个零元构造子,而 <-step 是一个递归构造子。
让我们证明 3 < 5。我们从 <-base 开始:3 < 4(我们怎么知道我们应该从这里开始,而不是从 4 < 5 开始?注意到 m,我们的参数,是 3:这是一个提示,我们所有的类型都将被参数化为 3。)应用一次 step:3 < suc 4 = 3 < 5,证毕。
> example₁ : 3 < 5
> example₁ = <-step <-base
记住,真命题由数据类型居住,而假命题则不然。我们如何反转它们呢?在逻辑中,我们可以说,“假设命题成立;推导出矛盾。”在类型理论中,我们使用空函数:这是一个没有定义域的函数,因此虽然存在,却不能接受任何输入。一个函数只有在其输入类型不居住时才没有定义域,所以我们能够避免给出矛盾的唯一方法是……一开始就不让它们提出这个问题!
> _≮_ : Rel ℕ
> a ≮ b = a < b → ⊥
()表示假,比如():5 < 0,这显然永远不可能成立,因为<-base 不匹配它(suc m != 0)。值得一提的是,Agda 要求你的程序是完备的,但不要求你对荒谬情况进行模式匹配。
> example₂ : 5 ≮ 2
> example₂ (<-step (<-step ()))
良好基础性。
我们引入一些 Agda 符号;模块让我们能够在扩展块上对某个变量进行参数化,然后只需‘data’声明的构造函数。模块的成员可以像 WF.Well-founded A(其余参数)那样访问。这非常方便和惯用,虽然不是绝对必要;我们也可以只根据成员参数化。我们还碰巧在一个类型上进行参数化。
> module WF {A : Set} (_<_ : Rel A) where
从逻辑上讲,一个元素被认为是可访问的意思是对于所有 y,如 y < x,y 是可访问的。从数据和逻辑的角度看,它陈述如果你想让我给你 Acc x,你想要的数据/证明,你必须给我一个证明,对于所有 y,如果你给我一个证明 y < x,我可以确定 Acc y。现在我们正试图证明关于我们类型和函数的属性,严格将我们的数据类型视为纯粹数据的做法变得越来越不合理。
> data Acc (x : A) : Set where
> acc : (∀ y → y < x → Acc y) → Acc x
如果它内部的所有元素都是可访问的,整个类型 A 就是良好基础的。或者,如果给定它内部的一个元素,我能为该元素产生一个可访问性证明,整个类型 A 也是良好基础的。请注意,它的类型是 Set;这是我想要证明的命题!
> Well-founded : Set
> Well-founded = ∀ x → Acc x
关于自然数的良好基础性证明。
> <-ℕ-wf : WF.Well-founded _<_
> <-ℕ-wf x = WF.acc (aux x)
> where
> aux : ∀ x y → y < x → WF.Acc _<_ y
> -- : (x : _) → (∀ y → y < x → WF.Acc _<_ y)
> aux .(suc y) y <-base = <-ℕ-wf y
基本情况,(例如 x=5,y=4)。方便的是,这触发了对ℕ上的良好基于结构的递归,通过检查现在是否良好基于 y。
> aux .(suc x) y (<-step {x} y<x) = aux x y y<x
这里的结构递归是在 < 上进行的;我们在剥离<-step 的层级,直到 y<x = <-base,就像 3<4 的情况一样(但不是 3<6)。我们基本上是在诉诸一个较弱的证明,它仍然足以证明我们感兴趣的内容。注意,我们也在 x 上递归;实际上,无论我们了解 x 的多少,我们都是从 y<x 中了解的(信息内容较少!),所以我们用一个点来指示这一点。最终,x 会足够小,以至于 y 不会比 x 小得多(<-base)。
我们在哪里处理零?考虑 aux zero:∀ y -> y < zero → WF.Acc < y。这是一个空函数,因为 y < zero = ⊥(没有自然数小于零!)事实上,这就是我们摆脱不编写 yx(上三角形)情况的方式:它等同于 y≮x,这些都是底部,免费提供给我们空函数。
实际上,在这里有一个双结构递归,一个是 x,另一个是 y<x。对 x 的结构递归只是在 aux 上,但一旦我们得出<-base,我们就对 y 进行不同的结构递归,使用<-ℕ-wf。这填补了由 y=x-1 分割的 xy 平面的右下三角形;上左三角形不太有趣,因为它只是废土的荒原。
标准数学技巧:如果你能将问题简化为你已经解决过的另一个问题,你就解决了你的问题!
> module Inverse-image-Well-founded { A B }
> -- Should actually used ≺, but I decided it looked to similar to < for comfort.
> (_<_ : Rel B)(f : A → B) where
> _⊰_ : Rel A
> x ⊰ y = f x < f y
>
> ii-acc : ∀ {x} → WF.Acc _<_ (f x) → WF.Acc _⊰_ x
> ii-acc (WF.acc g) = WF.acc (λ y fy<fx → ii-acc (g (f y) fy<fx))
类型必须正确,因此我们将旧证明 g 解包并包装成一个新的 lambda,通过 f 推动到我们的证明中(即 WF.acc 数据构造器)。
> ii-wf : WF.Well-founded _<_ → WF.Well-founded _⊰_
> ii-wf wf x = ii-acc (wf (f x))
> -- wf = λ x → ii-acc (wf (f x))
> -- I.e. of course the construction ii-acc will work for any x.
在这里,我们最终使用我们的机制证明列表与它们的长度相比是良基的。
> module <-on-length-Well-founded { A } where
> open Inverse-image-Well-founded { List A } _<_ length public
> wf : WF.Well-founded _⊰_
> wf = ii-wf <-ℕ-wf
一点点支架代码实际上并没有“改变”证明,而是改变了命题。我们需要这个分区引理。
> s<s : ∀ {a b} → a < b → suc a < suc b
> s<s <-base = <-base
> s<s (<-step y) = <-step (s<s y)
显示分区列表不会增加其大小。
> module PartitionLemma { A } where
> _≼_ : Rel (List A)
> x ≼ y = length x < suc (length y) -- succ to let us reuse <
对于所有谓词和列表,每个分区的长度都小于或等于列表的原始长度。proj₁和 proj₂是 Haskell 中的 fst 和 snd。
> partition-size : (p : A → Bool) (xs : List A)
> → proj₁ (partition p xs) ≼ xs
> × proj₂ (partition p xs) ≼ xs
虽然我们用≼表达了我们的命题,但我们仍然使用原始的<构造器。<-base 实际上意味着在这个上下文中是相等的!
> partition-size p [] = <-base , <-base
> partition-size p (x ∷ xs)
> with p x | partition p xs | partition-size p xs
> ... | true | as , bs | as-size , bs-size = s<s as-size , <-step bs-size
> ... | false | as , bs | as-size , bs-size = <-step as-size , s<s bs-size
最后,快速排序。
> module Quick {A} (p : A → A → Bool) where
打开礼物(证明)。
> open <-on-length-Well-founded
> open PartitionLemma
> quicksort' : (xs : List A) → WF.Acc _⊰_ xs → List A
> quicksort' [] _ = []
> quicksort' (x ∷ xs) (WF.acc g) ::
根据分区引理,我们得到了小于或等于 xs 和大于或等于 xs 的小结。通过使长度良基化,我们现在能够“粘合”间接性的层:x ∷ xs 最初严格较小且结构递归,而分区引理让我们能够告诉终止检查器小、大和 xs 本质上是相同的。
> with partition (p x) xs | partition-size (p x) xs
> ... | small , big | small-size , big-size = small' ++ [ x ] ++ big'
> where
> small' = quicksort' small (g small small-size)
> big' = quicksort' big (g big big-size)
> quicksort : List A → List A
> quicksort xs = quicksort' xs (wf xs)
当你将三种研究性编程语言混合在一起时会发生什么:ezyang 的博客
来源:
blog.ezyang.com/2012/05/what-happens-when-you-mix-three-research-programming-languages-together/
“...所以这就是我们要做的!”
“酷!你打算用什么语言写?”
“嗯,我们曾经认为我们需要三种编程语言...”
“...三个?”
“...而且它们也将是研究性编程语言...”
“你疯了吗?”
这就是我决定用 Coq、Haskell 和 Ur/Web 编写最新软件项目时,在我脑海中流淌的对话。我对选择有合理的理由:我想要 Coq 是因为我实际上不想从头开始实现一个定理证明器,我想要 Ur/Web 是因为我实际上不想手写 JavaScript 来实现 AJAX 接口,我想要 Haskell 是因为我不想写一堆 C 来让 Ur/Web 和 Coq 进行通信。但总体来看,整件事情似乎有些荒谬,像是三种研究性编程语言的不祥结合。
最终,效果还不错。现在,这意味着什么取决于你的期望:情况并非“一切都毫不费力并带有非常好的说明”。然而,如果情况是这样:
-
没有单一问题最终需要花费不可估量的时间和刮毛,
-
编写的任何补丁都进入了上游,改善了软件对未来开发者的情况,而且
-
在工程润滑上花费的时间少于用劣质语言构建系统所需的时间,
-
项目中所有参与者都愿意学习所涉及的所有语言(如果只有一个人,这很容易),
那么是的,“效果”“还不错”。在这篇文章中,我想稍微详细描述一下当我将这三种语言组合在一起时发生了什么,并对可能适用于类似活动的一般准则进行疯狂的推测。
Coq
虽然 Coq 是一个研究语言,但它在学术界非常广泛地使用,大部分的不稳定性来自于我在项目中没有使用的高级特性。所以我在 Coq 中遇到的主要问题不是 bug,而是将其与系统集成(即,使其与 Haskell 通信)。
准则 1. 交换格式将不会被记录下来,只是足够好以完成工作。
Coq 已经设计用于允许进程间通信(这是 Proof General/Emacs 和 Coq 互相通信的方式),但是 coqtop 和 Proof General 之间的格式是未记录的、临时的,并且没有为我的应用程序传输足够的信息。在这种情况下,有两种解决方法:忍耐并实现不好的协议或者修改编译器以实现更好的协议。我选择了后者,并学到了一些非常有趣的东西:
Maxim 2. 在类 ML 语言中,由于类型检查器的帮助,对代码库进行简单但影响深远的更改非常容易。
对前端进行的更改非常简单;这个更改没有任何深层次的东西,类型检查器和 grep 的结合使我能够在零调试的情况下完成补丁。通过在几个关键位置放置一些 XML 标记,我得到了足够合理的输出来构建系统的其余部分。
旁白. 后来,我了解到 Coq 的最新版本(8.4 及更高版本)中 coqide 有另一种交换格式。从现在开始,这可能是与 Coq 进行交互的正确机制,尽管这一点因为交换格式未记录而变得更加困难;然而,我已经提交了一个 bug。希望它能比我的补丁做得更好。最初,我的补丁打算部分实现 PGIP,一个通用的与定理证明器交互的交换格式,但后来我和 Coq 开发者发现 PGIP 项目不活跃,另一个用户 Isabelle 也停止使用他们的 PGIP 后端。(有时标准并不总是有帮助!)
Ur/Web
Ur/Web 的使用相对较少,因此我们遇到了各种各样的 bug 和系统各部分的其他不便,从前端到编译器都有。它们是阻碍吗?不是!
Maxim 3. 在一些核心功能中发现的具有确定性可复现性的 bug,原始代码的积极作者会非常快速地修复。
这个格言并不适用于设计中的基本限制(在这种情况下修复会需要大量的精力,尽管作者通常会对这种情况有很好的想法),但是对于其他这种类型的 bug,我发现可以非常快速地得到修复。虽然我可能会把部分原因归功于我的指导教师是编译器的作者,但我认为问题不止于此。当有人向你展示一个 bug 时,你写的有趣而棘手的代码碎片会给你一种不可抗拒的小难题的自豪感。而我们喜欢小难题。
还有一个推论:
Maxim 4. 学术界对问题越不感兴趣,你自己解决问题的可能性就越大。
学术界对于他们不感兴趣且对他们研究不重要的问题有些过敏。这意味着他们不喜欢处理这些细节,但这也意味着他们可能保持了简单,这意味着你更有可能能够弄清楚它。(一个好的类型检查器也确实有很大帮助!见第二条原则。)Ur/Web 编译的 FastCGI 服务 404 时存在一个简单的 bug,有一个非常简单的修复方法;我还对 Ur/Web 做了一些修改,使其可以在没有make install
的情况下运行。积极维护研究软件的维护者通常对这些“工程”补丁非常接受,这些补丁对研究本身没有直接用途,但我认为它们是成为开源社区良好公民的重要组成部分。
Haskell
好的,Haskell 现在不仅仅是一个研究语言;它也是一种非常灵活的通用语言,在现实世界中得到了相当多的应用,并且可以作为“普通”语言来对待。这使得它成为将其他两种语言粘合在一起的好选择;它几乎可以做任何事情,并且在调用 Haskell 中的函数时具有非常好的 FFI 支持。这带我们来到我们的下一个原则:
原则 5. 对于任何 DSL 来说,FFI 都是一个至关重要的功能,并且应该是准备语言供一般使用的任务中的首要任务之一。
通过它们的 FFI 让 Haskell 和 Ur/Web 相互通信对于使所有这些工作都能正常运行至关重要。Ur/Web 是一种用于编写 Web 应用程序的领域特定语言,除了其他事情外,它不包括健壮的系统库(例如执行外部进程并与其交互)。大多数语言都会遇到这个问题,因为要添加库支持需要花费一些功夫,但 Ur/Web 有第二个问题:所有具有副作用的事务也需要能够回滚,这对于一般的输入输出来说相当困难。然而,通过 FFI,我们可以在一个更合适的语言(Haskell)中实现需要这种库支持的任何代码,将其封装在一个提供适当事务保证的 API 中,并让 Ur/Web 使用它。如果没有这个,我们将无法使用 Ur/Web:它是一个非常灵活的逃生舱。
指定一个 FFI 也是展示你的语言与 C 语言“不同”的一个好方法:它迫使你思考你期望外部函数具有的不变量(引用透明性?线程安全性?):这些不变量恰好是你的语言中编写的代码自动满足的那些。这真的很酷!
但是,由于操作 C 指针的函数是非事务性的,Ur/Web 仅限于处理基本 C 类型的 FFI 函数,例如整数和字符串。因此,对于 Ur/Web 来说,解析的问题成为至关重要的问题,因为字符串是复杂结构的首选交换格式。虽然不同的语言会有不同的情况,但通常:
准则 6。 确保你知道如何在涉及的所有语言中进行解析。
结论
我提出了研究多语言能力的六大准则:
-
交换格式将没有文档,并且只足以完成工作。
-
在类 ML 的语言中,由于类型检查器的帮助,对代码库进行简单但影响深远的更改非常容易。
-
某些核心功能中确定性可重现的 bug 将由代码的活跃原始作者非常快速地修复。
-
对学者来说越无趣的问题,你越有可能自己解决。
-
FFI 对于任何 DSL 都是至关重要的功能,并且应该是准备语言以供一般使用中涉及的任务中的首要任务。
-
确保你知道如何在涉及的所有语言中进行解析。
如果你记住了所有这些准则,我相信在一些额外的错误修复和为了研究编程语言的好处而进行的琐事之间的权衡是一个引人注目的选择,应该认真考虑。是的,你必须愿意涉足你使用的所有工具的内部,但对于任何足够重要的工具来说,这是不可避免的。比你的编译器更重要的工具是什么?
附言。 相关的应用是Logitext。
高中代数测验和 NP 完全问题的共同点:ezyang's 博客
来源:
blog.ezyang.com/2010/08/what-high-school-algebra-quizzes-and-np-complete-problems-have-in-common/
我在 Galois 暑期实习中的经历
代数测验的世界。作为一个高中生,我早在了解计算机科学之前就在使用计算机科学的概念。我记得参加数学测验——禁用计算器——面对一个困难的任务:大数的乘法。当涉及到铅笔和纸的算术时,我非常马虎——如果我不检查答案,我肯定会因为“愚蠢的错误”而失分。幸运的是,我知道以下的窍门:如果我将我的因数的数字相加(如果结果是十或更多,重新相加),这两个数的乘积应该与结果的数字之和相匹配。如果不匹配,我就知道我的答案是错的。直到后来我才发现这是校验和的一个非常基础的形式。
实际上,我重新发现的大部分技巧都是出于简单的学术需要:我的答案是否正确?事实上,虽然当时我不知道,但这个问题成为了我今年夏天在 Galois 实习的基本基础。
大约在我开始学习代数的时候,我开始注意到我的检查算术的技巧变得不够用了。如果老师让我计算多项式(x + 2)(x - 3)(x - 5)
的展开式,我必须执行多步算术运算才能得到答案。检查每一步都很麻烦且容易出错——我深知我可能会对自己刚写的工作中的错误视而不见。我想要一种不同的方式来确保我的答案是正确的。
最终,我意识到我所要做的只是选择一个x
的值,并将其代入原问题和答案x³ - 6x² - x + 30
中。如果数值匹配,我会对我的答案相当有信心。我还意识到,如果我选择一个像x = -2
这样的数,我甚至都不需要计算原始问题的值:答案显然是零!我“发明了”单元测试,并且借助这种技术,许多符号表达式都屈服于我的铅笔。(我作为一个刚入门的程序员独立学习了单元测试,但由于 PHP 程序员从不编写太多数学代码,我从未意识到这一点。)
实际软件测试的世界。 在这里,我们从代数测验的世界过渡到软件测试的世界。被测试的表达式比x³ - 6x² - x + 30
更复杂,但大多数人仍然采用类似于高中时期的策略:他们手动挑选几个测试输入,以便能够合理地相信他们的新实现是正确的。如何知道程序的输出是正确的?对于许多简单的程序,被测试的功能足够简单,以至于测试人员能够心理上“知道”正确的结果,并手动记录下来——类似于挑选像x = -2
这样特别容易让人类推断答案的输入。对于更复杂的程序,测试人员可能会使用参考实现来确定预期的行为应该是什么样子的。
测试如此只能显示 bug 的存在,而不能证明它们不存在。但是,正如许多软件公司发现的那样,这已经足够好了!如果程序员错过了一个重要的测试用例并且出现了 bug 报告,他会修复 bug 并添加一个回归测试来处理那个有 bug 的输入。因此,作为实用主义者,我们已经接受了这种状态:手动逐案测试(希望是自动化的)。传统软件测试技术的现状基本上与高中生在代数测验中检查答案的方式是一样的。比这更好的东西超越了理论计算机科学研究的障碍。
旁白。 任何写过自动化测试的人都可以证明,自动化测试有两个主要任务:首先让你的代码能够自动测试(如果是算术比起内核驱动要容易得多),其次是想出一些有趣的情况来测试你的代码。对于后者来说,事实证明,虽然人类可以提出不错的边缘情况,但在提出随机测试用例方面他们真的非常糟糕。因此,一些极端实用的高科技测试技术包括让计算机生成随机输入。模糊测试和QuickCheck风格的测试都以此方法为特征,尽管模糊测试以无意义的输入为荣,而 QuickCheck 则努力生成有意义的输入。
理论计算机科学的世界。 批改你的代数测验的老师并没有像简单地选择几个随机数字,将它们代入你的答案中,看她是否得到正确答案那样简单。相反,她会将你的答案(程序本身)与答案卷上的标准答案(参考实现)进行比较,如果她能够判断答案相同,就会给你打分。如果你用费马最后定理来表达你的答案,她会因为你太过鲁莽而给你打分。
参考实现可能是错误的(答案键中的错误),但在这种情况下,它是我们判断程序是否“正确”的最佳标准。既然我们已经进入理论计算机科学的领域,我们可能会向字面意思的精灵问这个问题:通常能否确定两个程序是否等价? 字面意思的精灵回答:“不!”这个问题是不可判定的:没有算法能够对所有输入回答这个问题。如果你能确定两个程序是否等价,你就能解决停机问题(无法解决问题的典型示例):只需检查程序是否等价于一个无限循环的程序。
尽管工作中的理论家可能经常驯服无法计数的巨大无限,但对于工作中的程序员来说,处理的数量仍然非常有限——他们机器整数的大小、系统内存的数量、程序允许运行的时间。当你处理无限时,会出现各种奇怪的结果。例如,赖斯定理声明,确定一个程序是否具有任何非平凡属性(即存在某些具有该属性的程序和某些没有该属性的程序)是不可判定的!如果我们加入一些合理的约束,比如“程序对所有输入都在多项式时间内终止”,那么这个问题的答案就是肯定的!但我们能否以比测试程序在每个输入上做相同事情更好的方式来做到这一点?
更实际的计算机科学世界。 我们已经放弃了足够的理论纯度,使得我们的问题对软件工程师再次变得有趣,但程序员要证明算法与其参考实现等效仍然非常困难。相比之下,用户很容易证明算法错误:他们只需给程序员一个输入,使得他的实现与参考实现不一致。
计算机科学家为这种情况起了一个名字:NP 问题,即可以在多项式时间内验证其解(在这种情况下,更像是反解:一个反例)。即使两个程序都在恒定时间内运行,如组合逻辑电路可能会(为了模拟这样一个电路,我们只需通过与电路中的门数量相同的门传播输入:没有依赖于输入),用来暴力检查等价性仍需指数时间。每次增加一个输入位,都会加倍需要检查的可能输入量。
实际上,电路非等效性的问题是 NP 完全的。我们一直在讨论程序等效性,但我们也可以讨论问题等效性,例如你可以将一个问题(图着色)转化为另一个问题(旅行推销员问题)。在 70 年代,计算机科学家花了大量时间证明需要“蛮力”的许多问题实际上都是同一个问题。斯蒂芬·库克引入了一个概念,即存在 NP 完全问题:NP 中的问题可以转化为其中的所有其他问题。最著名的 NP 完全问题的例子是 SAT,即给定一个带有布尔变量的逻辑公式,你询问是否存在变量的满足赋值,这些变量将导致该公式为真。
证明电路非等效性是 NP 完全的,我们需要展示它属于 NP(我们已经完成了),并且展示我们可以将某些其他 NP 完全问题转化为这个问题。使用 SAT 进行这个过程非常容易:编写一个程序,将 SAT 的布尔变量作为输入,并输出逻辑公式的结果,然后查看它是否等同于一个总是返回false
的程序。
另一个方向稍微不那么微不足道,但从实际角度来看很重要:如果我们可以将我们的问题简化为 SAT 的一个实例,我可以向它投入一个高度优化的 SAT 求解器。可满足性问题同构于输出单个比特的逻辑电路。我们可以通过将电路合并成所谓的“miter”来将电路等效性问题转化为 SAT:我们将两个原始逻辑电路的输入组合成一个单一的集合,将其输入到两个电路中,然后测试两个电路之间对应的输出位是否相等(XOR),将整个结果进行 OR 运算。如果输出位在两个电路之间相同(所有的 XOR 返回 0),则生成电路输出 0,如果存在不匹配,则输出 1。
“很好”,你可能会想,“但我是程序员,不是硬件设计师。我的大多数程序不能仅用逻辑门来表达!” 这是正确的:要编码状态,你还需要锁存器,并且输入/输出需要通过特殊的输入和输出“端口”进行模拟。然而,有许多重要的问题纯粹是组合的:其中一个闪亮的例子是密码学,它保护你的钱,采用了大量复杂的数学并进行了无情的优化。
但仍然有一个持续的抱怨:即使我的程序只是逻辑电路,我也不想用 AND、OR 和 NOT 来编写它们。那看起来太痛苦了!
进入Cryptol,这是我在 Galois 公司工作的项目。Cryptol 自称如下:
Cryptol 是用于编写密码算法规范的语言。它还是一个工具集,用于在 VHDL、C 和 Haskell 中生成高可靠性、高效的实现。Cryptol 工具包括对比参考规范与实现的等效性检查,无论实现是否从规范编译而来。
但是在我这个菜鸟实习生的谦虚观点中,真正使它显著的是,它可以将用 C、VHDL 或 Cryptol 等编程语言编写的程序转换为逻辑电路,或者我们所称的“形式模型”,然后你可以将其投放到一个 SAT 求解器中,后者会比暴力尝试所有可能的输入更明智地处理。有一次,我心想,“Cryptol 居然能工作真是个奇迹!”但它确实能在其密码算法问题域内非常成功地工作。传统软件测试的最新技术是手工编写的测试,只能显示实现中存在的缺陷;Cryptol 的最新技术是完全自动化的测试,可以保证实现没有缺陷。(当然,Cryptol 也可能有 bug,但这是高可靠性的生活方式。)
SAT 求解器可能是程序员手边最被低估的高科技工具之一。一个工业级别的 SAT 求解器可以在午餐时间内解决大多数 NP 完全问题,而 NP 类问题具有广泛的实际应用。然而,使用 SAT 求解器的常见障碍包括:
-
没有简单的方法将你的问题转化为 SAT 问题,然后在高度优化的求解器之一上运行,这些求解器通常在学术界文档化不足且不友好。
-
当你的 SAT 求解器通过或失败时(取决于什么是“错误”),生成友好的错误消息。
-
说服你的团队,真的,你需要一个 SAT 求解器(而不是构建你自己的,可能不那么高效的实现)。
我的主要项目是通过构建名为ABC,一个用于顺序合成和验证的系统的绑定集来解决 Haskell 中的第一个问题,称为abcBridge
。有人可能会观察到 Haskell 已经有了一些 SAT 求解库:ABC 之所以引人注目,是因为它采用了一种 SAT 的替代表述形式,即与非图(NAND 门能模拟所有布尔逻辑),以及一些处理 AIG 的新技术,比如 fraiging,这是一种高级策略,用于寻找电路中功能等效的子集。
项目本身非常有趣:由于我是从零开始构建这个库,所以在 API 决策上有很大的灵活性,但同时也深入了 Cryptol 代码库,需要将我的绑定与其集成。希望有幸能在实习结束时将代码作为开源发布。但当我的实习在两周后结束时,我会错过更多不仅仅是我的项目。我希望能跟进一篇关于我的实习的非技术性文章。请继续关注!
事后诸事. 嘿,这是我的第一百篇文章。甜蜜!
什么是膜?:ezyang’s 博客
如果你和某个特定群体一起呆得足够长(在我的情况下,是ECMAScript TC39 委员会),你可能会听到“膜”这个术语被提起。最终,你会开始想知道,“嗯,膜到底是什么?”
就像许多聪明但简单的想法一样,膜最初作为博士论文的脚注 [1]被引入。假设您正在构建分布式系统,在其中在两个独立节点之间传递对象的引用。如果我想将进程 A
中的 foo
的引用传递给进程 B
,我几乎不能仅仅交出一个地址 - 内存空间不同!因此,我需要创建一个代表 B
中 foo
的包装对象 wrappedFoo
,它知道如何访问 A
中的原始对象。到目前为止一切顺利。
现在问题来了:如果我将对 wrappedFoo
的引用传回到进程 A
中怎么办?如果我不够聪明,我可能会像最初那样做:在 A
中创建一个新的包装对象 wrappedWrappedFoo
,它知道如何访问 B
中的 wrappedFoo
。但这很愚蠢;实际上,当我再次返回到 A
时,我想要得到原始的 foo
对象。
这种包装和解包行为 正是 膜的本质。我们认为原始对象 foo
位于膜的“内部”(一个所谓的湿对象),当它离开膜时,它会被其自己的小膜包裹。然而,当对象返回到其原始膜时,包装会消失。就像生物学中一样!
还有最后一个操作,称为“门”:这发生在您在包装对象上调用方法时。由于包装对象实际上无法执行方法,它必须将请求转发给原始对象。然而,方法的 参数 在转发时需要被包装(或解包),正如您可能期望的那样。
在展示膜的基本原理时,我使用了类似 RPC 的系统,而更常见的用途是强制访问控制。膜非常重要;Mozilla 在强制执行来自不同网站的对象之间访问限制时大量使用它们,但需要进行安全检查。(事实上,你知道 Mozilla 在他们的安全系统中使用基于能力的系统吗?挺有意思的!)需要注意的是,当我们解开膜时,我们跳过了安全检查——唯一可以接触未封装对象的对象是同一域中的对象。要获取更现代化的主题处理,请查看最近的一篇文章,Trustworthy Proxies: Virtualizing Objects with Invariants,其中包含对膜的清晰解释。
[1] 嗯,实际上它是一个图;确切地说是第 71 页的图 9.3!
什么是无状态用户界面?:ezyang 的博客
来源:
blog.ezyang.com/2015/11/what-is-stateless-user-interface/
无状态用户界面的本质是,您对程序所采取的操作不应取决于隐含状态。无状态界面更容易理解,因为对某些参数执行命令将始终执行相同的操作,而在有状态界面中,命令可能与昨天不同,因为隐含状态已更改并影响程序的含义。
这种哲学是任何 Haskeller 都应该直观理解的……但是今天的 Cabal 和 cabal-install 未能达到这一理想。以下是 Cabal 中现今状态性的一些例子:
-
运行
cabal install
时,构建的软件包被安装到“包数据库”中,使它们可以被 GHC 使用。 -
运行
cabal install
时,要安装哪些包以及版本的选择取决于本地包数据库的状态(当前解算器试图重用已安装的软件)和远程包存储库的状态(指定了可用的包和版本)。 -
运行
./Setup configure
会将LocalBuildInfo
保存到dist/setup-config
,这会影响进一步的Setup
命令(build
、register
等)。
这些状态实例都给用户带来了复杂性:你认为有多少次(1)因为本地包数据库无法逆转而重建了它,(2)因为依赖解算器开始选择了过新版本的包而使项目停止构建,或者(3)因为一些功能未启用而要求重新配置 Cabal?
状态是有成本的,但并非没有理由:
-
包数据库的存在是因为我们不希望每次想要构建某些东西时都必须从头开始重建我们所有的包(实际上,这就是包管理器的全部意义)。
-
解算器依赖于本地包数据库,因为用户不耐烦,希望在构建他们的软件之前避免构建新版本的包;
-
解算器依赖于远程包存储库,因为开发人员和用户都不耐烦,希望尽快将新版本发布给用户;
-
配置会缓存其信息,因为用户不希望每次尝试构建他们正在工作的软件包时都要重新配置该软件包。
面对看似固有的状态性问题领域,无状态用户界面能够取得成功吗?当然可以。
有时状态仅仅被用作缓存。如果缓存被清除,一切应该仍然可以正常工作,只是速度会慢一些。包数据库(原因 1)和配置缓存(原因 4)都属于这一类别,但今天的 Cabal 犯的关键错误是,如果删除这些信息,事情并不会“自动解决”。必须有足够的信息来重建缓存;例如,配置缓存应该补充实际输入到配置步骤的内容。(有时,关注点的分离意味着你根本无法做到这一点。如果你要求 ghc 使用不在缓存中的 lens 包,ghc 会怎么做?)此外,系统的行为不应因缓存数据的存在与否而变化;例如,求解器(原因 2)不应基于缓存的有无做出不同(语义上有意义的)决策。
否则,必须能够显式管理相关的状态:如果状态是远程包仓库(原因 3),必须有一种方式来针对某个状态进行固定。(有一个工具可以做到这一点,它叫做 Stack。)虽然有时是必需的,显式状态会使接口复杂化,并且更难描述系统可以做什么。最好将这种状态保持得尽可能小和集中。
我不认为我在这里说的任何事情特别微妙。但这确实是你需要专门考虑的事情;否则,你将会被有状态接口的陷阱所诱惑。但如果你拒绝这种诱惑,穿上苦衣,你的用户将更为感激你。
致谢. 这些想法不是我自己的:我要感谢 Johan Tibell、Duncan Coutts 和 Simon Marlow,因为他们的讨论让我理解了这一点。本文中的任何错误都是我自己的。这不是号召行动:Cabal 的开发者们意识到了这一点,并正在尝试修复,详见这个hackathon wiki page。但我在互联网上并没有看到这种哲学明确写出来的地方,因此我在这里为你写下它。
软件工程师的科学哲学:ezyang 的博客
来源:
blog.ezyang.com/2011/06/philosophy-of-software-engineering/
在剑桥的一年中,我花时间阅读了科学史与哲学课程。这是一个激动人心且启发性的课程,我强烈推荐任何有幸在剑桥选修 HPS(历史与哲学科学)分支的人参加。当然,我有点格格不入,因为该课程是为自然科学专业设计的,而我当然是计算机科学家。
在接下来的两篇文章中,我想强调科学哲学课程的一些主要主题,以及它们如何适用于软件工程师。(显然不是计算机科学家:看起来他们的哲学根植于数学哲学。)并非所有问题都相关:一个老 Tripos 问题问“是否存在统一的科学哲学,还是各科学的分散哲学?”——我可能会回答“两者都有”。但我认为现有的知识体系可以对我们面临的一些棘手问题提供一些见解:什么构成了 bug 的原因?软件工程师如何调试?我们如何知道软件的特定测量或评估是可靠的?我们扩展我们对软件领域经验的理由是什么?所有关于代码高层行为的解释都可以归结为其背后的抽象吗?我应该小心不要过分陈述我的观点:毫无疑问,你们中的一些人可能认为这些问题根本不有趣,而其他人可能认为我所提出的论点毫无洞见。我谦卑地请求你们的耐心——毕竟,明天我就要被考察这个话题。
因果关系
当我们说一个事件引起另一个事件时,这意味着什么?这是一个似乎与实用性相去甚远的问题,似乎是另一个毫无用处的哲学练习。但答案并不简单。哲学家大卫·休谟观察到,当我们谈论因果关系时,因果之间存在某种必然的联系:bug导致程序崩溃。但我们能直接观察到这种“必然联系”吗?休谟认为不行:我们只能看到一个事件到另一个事件的连续;与程序员不同的是,我们不能检查宇宙的源代码并实际看到“啊,是的,这就是那个因果关系的绑定点”。
一个简单的因果模型是规律理论,受到休谟在询问中的评论启发:一个原因是“一个对象,后跟另一个对象,第一个对象的所有类似对象后面都跟着第二个对象。” 我观察到每次“我按按钮”的事件之后立即是“程序崩溃”,那么我可能合理地推断按按钮是崩溃的原因。这里没有什么不合理的地方,但哲学家现在看到了攻击点。有许多情况下,这样一个简单的规律理论是行不通的。考虑以下情况:
-
我按按钮,但程序只有在某些情况下崩溃。即使错误不是 100%可以重现,我仍然可以合理地说它导致了崩溃。
-
一个警报对话框弹出,我按按钮,程序崩溃了。但不是我按按钮导致了崩溃:更有可能是导致警报对话框弹出的原因。 (你可能曾经试图向一个不那么懂计算机的家人解释这种经历。)
-
我只按了一次按钮,那一次程序崩溃了。的确,每当我按按钮时,之后都会发生崩溃:但现在我按按钮可能不会导致崩溃。
或许没有合理实践的软件工程师会使用这种因果模型。这里是一个更合理的因果模型,反事实模型(由大卫·刘易斯提出)。在这里,我们提出一个假设性的“如果”问题:如果按按钮导致崩溃,我们可以同样说“如果没有按按钮,崩溃就不会发生。” 作为一个练习,读者应该验证上述案例是否被这个改进的因果模型清楚地解决了。然而,反事实模型也并非没有问题:
-
假设我们崩溃的程序有两个 bug(这里我们使用“bug”来表示“源代码缺陷”)。第一个 bug 是否导致了崩溃呢?如果我们移除了那个 bug,程序仍然会崩溃。因此,在因果反事实理论下,第一个 bug 并不会导致崩溃。第二个 bug 也是一样。我们有一个因果超定的案例。(刘易斯声称 bug 的真正原因是这两个 bug 的析取。对于计算机科学家来说这可能不算什么,但当应用到日常生活时,听起来确实有些奇怪。)
-
假设我们崩溃的程序有一个 bug。然而,移除第一个 bug 会暴露出其他地方的潜在 bug,也会导致崩溃。说移除第一个 bug 会使崩溃消失是错误的,因此它并不是导致崩溃的原因。这种情况被称为因果先占。(刘易斯在这里的情况是区分因果依赖和因果链。)
当软件工程师阅读这些哲学家时所意识到的是,复杂和奇怪的因果关系示例实际上与他在日常工作中所依附的因果关系结节非常相似。这里的分析并不复杂,但它为自然法则的理论奠定了基础,并且也很好地介绍了鼓励考虑边缘案例的哲学思维类型:对软件工程师来说是一种有益的特质!
方法论和确认
哲学科学中最著名的辩论之一溢出到普及话语中的辩论是关于科学方法论的辩论——科学家如何进行工作以及如何选择理论。我发现这场辩论直接对应于调试艺术,这是教初学者程序员最为困难的技能之一。在这里,我们将讨论两个主要角色:归纳法(或确认理论)和证伪主义(由卡尔·波普提出)。
夏洛克·福尔摩斯曾经对理论说过这样的话:“在不知不觉中,人们开始扭曲事实以适应理论,而不是调整理论以适应事实。”他提倡归纳方法论,观察者在试图提取一些模式之前,冷静地收集事实——归纳本身是从有限案例的泛化。在这个旗帜下,人们在收集数据时不能简单地得出结论。这似乎是对人们的一个合理要求,特别是也许是在收集性能数据的剖析师。正如 A.F.查尔默斯所说的那样,口号是“科学源于事实。”
不幸的是,众所周知的是,在科学哲学家中,纯粹的归纳法是非常有问题的。这些问题从也许无法解决的基础性问题(休谟的归纳问题)到关于科学家实际实践的极端实际问题都有。以下是一些问题的简要介绍:
-
什么是事实?在某种程度上,事实只是感官表达,怀疑它们是不合理的过度怀疑。但是原始的感官表达并不对大多数人可及:相反,它们与我们当前的知识和倾向结合形成事实。一个专业的程序员会对错误消息看到一个非常不同的东西,而不是一个普通的终端用户。事实收集不是平等主义的。
-
事实可能是靠不住的。你有没有分析过一个情况,从中推导出一些事实,只是后来意识到,等等,你最初的评估是错误的?感官可以撒谎,即使是低层次的解释也可能是错误的。归纳法并没有说我们应该如何放弃可疑的事实。
-
在什么情况下我们给事实更多的权重?归纳主义者说所有事实都是平等的,但显然这不是真的:我们更高度评价那些来自公开积极调查的事实,而不是那些来自私人被动经验的事实。此外,终端用户可能报告了大量的事实,所有这些事实都是真实的,但专家可以立即识别为无用。
-
此外,对于纯粹的哲学问题,归纳问题表明我们没有理由认为归纳是合理的。我们如何知道归纳有效?我们过去成功地使用过。但将过去的成功推广到未来本身就是归纳,因此理由是循环的。
这并不意味着归纳法不能修正这些批评。但显然这个简单的图景是不完整的。(你也可以指责我打打稻草人。在教育背景下,我认为这里没有任何错,因为打打稻草人也可以揭示更复杂立场的弱点——稻草人作为某些类型论证的典型案例。)
卡尔·波普尔提出伪证主义作为回避困扰归纳法的方法。这种方法应该是任何软件工程师都应该熟悉的另一种方法:给定一个理论,然后寻找一个观察或实验来伪证它。如果被伪证了,就放弃它,并寻找另一个理论。如果没有被伪证,那么你就简单地寻找其他东西(波普尔小心地指出,我们不能说这个理论因为这种成功而被证实)。
伪证通过接受观察的理论依赖性而优于归纳法。伪证主义者不关心你的理论从哪里来,只要你试图伪证它,并且接受这样一个事实:没有办法在证据的光线下确定一个理论是否真实。后一点值得强调:归纳试图从几个案例推广到普遍,是非演绎的步骤,而伪证可以从一个负案例推演出一个负普遍。用一个喜爱的例子来说,逻辑上确实如此,如果有一只白色的乌鸦,那么并不是所有的乌鸦都是黑色的。此外,一个理论如果更具伪证性则更好:它提出了一组具体的测试。
顾名思义,天真的伪证主义也有它的问题,其中一些问题让人回忆起先前的某些问题。
-
针对一次伪造,我们总是可以修改我们的理论以解释这个特定的伪造实例。这就是所谓的特例修改。“所有乌鸦都是黑色的,除了我今天看到的这只特殊的乌鸦。”不幸的是,特例修改可能是公平的:毕竟,软件也可以为特定情况进行修改。最好打开源代码。
-
伪证主义建议我们一旦看到伪证证据就应该放弃一个理论。但正如归纳主义所示,证据可能是错误的。有许多历史案例表明,新理论被提出后发现它们实际上并不适合手头的证据(哥白尼的日心说宇宙模型就是一个例子——它在计算行星位置方面并不比现有的托勒密模型更好)。这些新理论应该被放弃吗?真正的科学家是顽强的;他们坚持理论,而且许多时候这种坚持是有用的。
-
把这个论点推翻过来,我们永远不能测试一个孤立的理论;相反,实验测试涵盖了理论及其关于测试设置的任何数量的辅助假设。当找到一个伪证测试时,理论或任何一个辅助假设可能是错误的——但我们不知道哪个是!杜厄姆-奎恩论表明,在任何观察到的一组情况下,我们总是能够修改辅助假设使我们的理论成立(这个论点可能是真实的,也可能不是,但思考它是很有趣的)。
所有这些问题都突显出了准确描述所谓“科学方法”是多么困难。简单的描述似乎是不够的:它们听起来直观吸引人,但也有其不足之处。实际科学家有点像机会主义者:他做有效的事情。调试器也是如此。
下次,我希望谈论量化、测量和减少。
到底模块系统有什么好处呢?:ezyang 的博客
来源:
blog.ezyang.com/2014/08/whats-a-module-system-good-for-anyway/
今年夏天,我在微软研究院工作,实现了Haskell 的 Backpack,一个模块系统。有趣的是,Backpack 并不是一个单一的庞大特性,而是一系列小的基础设施改进,这些改进以一种有趣的方式结合在一起。在这一系列博文中,我想讨论这些个别特性是什么,以及整体如何大于部分的总和。
但首先,有一个重要的问题需要回答:到底模块系统有什么好处呢? 为什么你作为一名普通的 Haskell 程序员,要关心诸如模块系统和模块化这样朦胧的东西。归根结底,你希望你的工具能解决你现有的具体问题,有时候很难理解像 Backpack 这样的模块系统到底解决了什么问题。正如tomejaguar 所说:“有人能清楚地解释 Backpack 解决的确切问题吗?我读过论文,我知道问题是‘模块化’,但我担心我缺乏想象力,无法真正理解问题的实质是什么。”
不用再找了。在这篇博文中,我想具体讨论 Haskell 程序员今天面临的问题,解释这些问题的根本原因,并说明为什么一个模块系统可以帮助解决问题。
字符串、Text、ByteString 的问题
如有经验的 Haskell 程序员们所知,了解多种 Haskell 字符串类型:String、ByteString(延迟与严格)、Text(同样也有延迟与严格)。更加复杂的是,并没有一个“正确”的字符串类型选择:不同的情况适合不同的类型。String 方便且是 Haskell'98 的本地类型,但非常慢;ByteString 快速但只是字节的数组;Text 慢一些但支持 Unicode。
在理想世界中,程序员可以根据他们的应用选择最合适的字符串表示,并相应地编写所有的代码。然而,对于库编写者来说,这并不能解决问题,因为他们不知道用户会使用哪种字符串类型!那么库编写者该怎么办呢?他们只有几种选择:
-
当存在不匹配时,它们会“承诺”使用一种特定的字符串表示,让用户在不同表示之间手动转换。或者更可能的是,库的编写者因为默认方式易于使用而选择了默认方式。例如:base(使用 Strings,因为它完全在其他表示之前存在),diagrams(使用 Strings,因为它实际上不做大量字符串操作)。
-
它们可以为每个变体提供单独的函数,可能命名相同但放置在不同模块中。这种模式经常用于支持严格/惰性变体 Text 和 ByteStringExamples:aeson(为惰性/严格 ByteString 提供 decode/decodeStrict)、attoparsec(提供 Data.Attoparsec.ByteString/Data.Attoparsec.ByteString.Lazy)、lens(提供 Data.ByteString.Lazy.Lens/Data.ByteString.Strict.Lens)。
-
它们可以使用类型类来重载函数,以便与多种表示形式一起工作。使用的特定类型类大不相同:有ListLike,被少数包使用,但大部分包通常自行开发。例如:HDBC 中的 SqlValue,tagsoup 中的内部 StringLike,以及web-encodings 中的另一个内部 StringLike。
最后两种方法有不同的权衡。像第(2)种方式那样定义单独的函数是一种简单易懂的方法,但您仍在拒绝模块化:支持多种字符串表示的能力。尽管为每种表示提供了实现,用户在导入时仍需选择特定表示。如果他们想要更改字符串表示,他们必须遍历所有模块并重命名导入;如果他们想要支持多种表示,他们仍必须为每种表示编写单独的模块。
使用类型类(3)来恢复模块性似乎是一个吸引人的方法。但这种方法既有实际问题,也有理论问题。首先,你如何选择哪些方法放入类型类中呢?理想情况下,你会选择一个最小的集合,其他所有操作都可以从中派生出来。然而,许多操作在直接实现时效率最高,这导致了一个臃肿的类型类,并且对于那些拥有自己的字符串类型并需要编写自己实例的人来说非常困难。其次,类型类使得你的类型签名更加丑陋 String -> String
变成了 StringLike s => s -> s
,并且可能会使类型推断变得更加困难(例如,引入歧义)。最后,类型类 StringLike
与类型类 Monad
的性质截然不同,后者具有一组最小操作和规定其操作的法则。很难(或者说不可能)描述这种接口的法则应该是什么样的。总而言之,与具体实现相比,编写针对类型类的程序要不那么愉快。
如果我能够 import String
,给我提供 String
类型和相关操作,然后稍后再决定要实例化的具体实现,这是件多么好的事情啊!这是模块系统可以为你做到的事情!这篇 Reddit 线程 描述了另外一些情况下 ML 风格模块将会很方便的情况。
(附注:为什么不能只写一堆预处理器宏来交换你想要的实现呢?答案是,“是的,你可以;但是如何在没有尝试每个实现的情况下对其进行类型检查呢?”)
破坏性的包重新安装
当你尝试安装新包时是否遇到过这个错误消息?
$ cabal install hakyll
cabal: The following packages are likely to be broken by the reinstalls:
pandoc-1.9.4.5
Graphalyze-0.14.0.0
Use --force-reinstalls if you want to install anyway.
不知何故,Cabal 得出结论说安装 hakyll 的唯一方法是重新安装某些依赖项。以下是可能导致这种情况发生的一种情况:
-
pandoc 和 Graphalyze 都是针对最新的 unordered-containers-0.2.5.0 进行编译的,这本身又是针对最新的 hashable-1.2.2.0 进行编译的。
-
hakyll 也依赖于 unordered-containers 和 hashable,但对 hashable 有一个排除最新版本的上限限制。Cabal 决定我们需要安装旧版本的 hashable,比如 hashable-0.1.4.5。
-
如果安装了 hashable-0.1.4.5,我们还需要将 unordered-containers 针对这个旧版本进行构建,以便 Hakyll 可以看到一致的类型。然而,生成的版本与现有版本相同:因此,需要重新安装!
此错误的根本原因是 Cabal 当前对包数据库强制执行的不变式:对于任何给定的包名称和版本,只能有一个实例的包。特别地,这意味着不可能安装同一个包的多个实例,编译时使用不同的依赖关系。这有点麻烦,因为有时您确实希望安装具有不同依赖关系的相同包多次:正如上文所示,这可能是满足所有涉及包的版本界限的唯一方法。目前唯一解决此问题的方法是使用 Cabal 沙箱(或清除您的包数据库并重新安装所有内容,这基本上是相同的事情)。
您可能会想,模块系统如何可能帮助解决这个问题?实际上,并不是直接帮助。相反,包的非破坏性重新安装是实现类似 Backpack 的模块系统的关键功能(一个包可以安装多次,具有不同的模块具体实现)。实施 Backpack 需要解决这个问题,将 Haskell 的包管理更接近于 Nix 或 NPM。
版本界限和被忽视的 PVP
当我们讨论cabal-install
出现错误时,您是否曾经尝试安装新包时遇到这个错误?
$ cabal install hledger-0.18
Resolving dependencies...
cabal: Could not resolve dependencies:
# pile of output
出现这种情况可能有很多原因,但通常是因为某些涉及的包有过度约束的版本界限(尤其是上界),导致一组不可满足的约束条件。更让人沮丧的是,通常这些界限没有实际依据(包作者仅仅是猜测了范围),去掉它可能会导致可以工作的编译。这种情况非常普遍,以至于 Cabal 有一个--allow-newer
标志,允许您覆盖包的上界。管理界限的烦恼导致了诸如cabal-bounds之类的工具的开发,这些工具试图让保持上界最新变得不那么繁琐。
尽管我们经常批评它们,版本界限有一个非常重要的功能:它们防止您尝试针对根本无法工作的依赖关系编译包!版本界限不足的一组版本范围可以很容易地使您针对无法通过类型检查的依赖版本进行编译。
一个模块系统如何帮助?归根结底,版本号试图捕捉有关包导出的 API 的一些信息,这由 包版本控制策略 描述。但是当前的技术水平要求用户将 API 的变化手动转换为版本号:即使在 各种工具 的辅助下,这也是一个容易出错的过程。另一方面,一个模块系统将 API 转变为编译器本身理解的一流实体:一个 模块签名。如果包依赖于签名而不是版本号,那不是很棒吗?那么你将不再需要担心版本号与类型检查不准确。当然,版本号仍然对于记录类型中未见的语义变化是有用的,但在这里它们的角色次要而重要。这里需要一些充分的披露:我不打算在实习结束时实现这个功能,但我希望能对其做出一些重要的基础性贡献。
结论
如果你只是粗略地读了《背包论文》的介绍部分,可能会给你留下这样的印象:背包是关于随机数生成器、递归链接和适用语义的某种东西。虽然这些都是关于背包的真实“事实”,但它们低估了一个良好模块系统对工作程序员日常问题可能产生的影响。在这篇文章中,我希望我已经阐明了其中的一些问题,即使我还没有说服你像《背包》这样的模块系统实际上是如何解决这些问题的:这将在接下来的一系列文章中讨论。请继续关注!
What Template Haskell gets wrong and Racket gets right : ezyang’s blog
来源:
blog.ezyang.com/2016/07/what-template-haskell-gets-wrong-and-racket-gets-right/
为什么 Haskell 中的宏 糟糕,而 Racket 中的宏很棒? GHC 的 Template Haskell 支持确实存在许多小问题,但我认为有一个基本设计点 Racket 做对了而 Haskell 做错了:Template Haskell 没有充分区分 编译时 和 运行时 阶段。混淆这两个阶段会导致诸如“Template Haskell 不适用于交叉编译”的奇怪说法,以及 -fexternal-interpreter
这样更奇怪的特性(通过将宏代码发送到目标平台执行来“解决”交叉编译问题)。
只需比较 Haskell 和 Racket 的宏系统设计差异即可见端倪。本文假设您了解 Template Haskell 或 Racket 的知识,但不一定两者皆通。
基本宏。为了建立比较基础,让我们比较一下 Template Haskell 和 Racket 中宏的工作方式。在 Template Haskell 中,调用宏的基本机制是 splice:
{-# LANGUAGE TemplateHaskell #-}
module A where
val = $( litE (intPrimL 2) )
这里,$( ... )
表示插入,它运行 ...
来计算一个 AST,然后将其插入正在编译的程序中。语法树是使用库函数 litE
(字面表达式)和 intPrimL
(整数原始字面量)构造的。
在 Racket 中,宏是通过 transformer bindings 引入,并在扩展器遇到此绑定的使用时调用:
#lang racket
(define-syntax macro (lambda (stx) (datum->syntax #'int 2)))
(define val macro)
这里,define-syntax
定义了一个名为 macro
的宏,它接受其用法的语法 stx
,并无条件地返回代表文字二的 语法对象(使用 datum->syntax
将 Scheme 数据转换为构造它们的 AST)。
Template Haskell 宏显然不如 Racket 的表达力强(标识符不能直接调用宏:插入总是在语法上显而易见);相反,向 Racket 引入插入特殊形式很容易(对于此代码,特别感谢 Sam Tobin-Hochstadt — 如果你不是 Racketeer,不必过于担心具体细节):
#lang racket
(define-syntax (splice stx)
(syntax-case stx ()
[(splice e) #'(let-syntax ([id (lambda _ e)]) (id))]))
(define val (splice (datum->syntax #'int 2)))
我将在一些进一步的示例中重用 splice
;它将被复制粘贴以保持代码自包含性,但不需要重新阅读。
宏帮助函数的阶段。在编写大型宏时,经常希望将一些代码因子化到一个帮助函数中。现在我们将重构我们的示例,使用外部函数来计算数字二。
在模板哈斯克尔中,您不允许在一个模块中定义一个函数,然后立即在一个片段中使用它:
{-# LANGUAGE TemplateHaskell #-}
module A where
import Language.Haskell.TH
f x = x + 1
val = $( litE (intPrimL (f 1)) ) -- ERROR
-- A.hs:5:26:
-- GHC stage restriction:
-- ‘f’ is used in a top-level splice or annotation,
-- and must be imported, not defined locally
-- In the splice: $(litE (intPrimL (f 1)))
-- Failed, modules loaded: none.
然而,如果我们将 f
的定义放在一个模块中(比如 B
),我们可以导入然后在一个片段中使用它:
{-# LANGUAGE TemplateHaskell #-}
module A where
import Language.Haskell.TH
import B (f)
val = $( litE (intPrimL (f 1)) ) -- OK
在 Racket 中,可以在同一个文件中定义一个函数,并在宏中使用它。但是,您必须使用特殊形式 define-for-syntax
将函数放入适合宏使用的正确阶段中:
#lang racket
(define-syntax (splice stx)
(syntax-case stx ()
[(splice e) #'(let-syntax ([id (lambda _ e)]) (id))]))
(define-for-syntax (f x) (+ x 1))
(define val (splice (datum->syntax #'int (f 1))))
如果我们尝试简单地 (define (f x) (+ x 1))
,我们会得到一个错误 “f: unbound identifier in module”。原因是 Racket 的阶段区分。如果我们 (define f ...)
,f
是一个运行时表达式,而运行时表达式不能在编译时使用,这是宏执行时的情况。通过使用 define-for-syntax
,我们将表达式放置在编译时,以便可以使用它。(但同样地,f
现在不能再在运行时使用。从编译时到运行时的唯一通信是通过宏的扩展为语法对象。)
如果我们将 f
放在一个外部模块中,我们也可以加载它。但是,我们必须再次指示我们希望将 f
作为编译时对象引入作用域:
(require (for-syntax f-module))
与通常的 (require f-module)
相反。
反映和结构类型变换绑定。 在模板哈斯克尔中,reify
函数使模板哈斯克尔代码可以访问有关定义的数据类型的信息:
{-# LANGUAGE TemplateHaskell #-}
module A where
import Language.Haskell.TH
data Single a = Single a
$(reify ''Single >>= runIO . print >> return [] )
此示例代码在编译时打印有关 Single
的信息。编译此模块会给我们关于 List
的以下信息:
TyConI (DataD [] A.Single [PlainTV a_1627401583]
[NormalC A.Single [(NotStrict,VarT a_1627401583)]] [])
reify
函数通过交错插入片段和类型检查实现:在顶层片段之前的所有顶层声明在运行顶层片段之前都已完全类型检查。
在 Racket 中,使用 struct
形式定义的结构的信息可以通过 结构类型转换器绑定 传递到编译时:
#lang racket
(require (for-syntax racket/struct-info))
(struct single (a))
(define-syntax (run-at-compile-time stx)
(syntax-case stx () [
(run-at-compile-time e)
#'(let-syntax ([id (lambda _ (begin e #'(void)))]) (id))]))
(run-at-compile-time
(print (extract-struct-info (syntax-local-value (syntax single)))))
输出如下:
'(.#<syntax:3:8 struct:single> .#<syntax:3:8 single>
.#<syntax:3:8 single?> (.#<syntax:3:8 single-a>) (#f) #t)
代码有点冗长,但发生的事情是 struct
宏将 single
定义为语法转换器。语法转换器始终与编译时 lambda 关联,extract-struct-info
可以查询以获取有关 struct
的信息(尽管我们必须使用 syntax-local-value
来获取这个 lambda——在编译时 single
是未绑定的!)
讨论。 Racket 的编译时和运行时阶段是一个非常重要的概念。它们有许多后果:
-
您不需要在编译时运行您的运行时代码,反之亦然。因此,跨编译被支持得非常简单,因为只有您的运行时代码被跨编译。
-
模块导入分为运行时和编译时导入。这意味着您的编译器只需加载编译时导入到内存中即可运行它们;与模板哈斯克尔不同,后者会将所有导入(包括运行时和编译时)加载到 GHC 的地址空间中,以防它们在片段内部被调用。
-
信息不能从运行时流向编译时:因此任何编译时声明(
define-for-syntax
)都可以简单地在执行扩展之前编译,只需忽略文件中的其他所有内容。
Racket 是正确的,Haskell 是错误的。让我们停止模糊编译时和运行时之间的界限,并且设计一个可行的宏系统。
附言. 感谢来自Mike Sperber的一条推文,它让我思考了这个问题,还有与 Sam Tobin-Hochstadt 有趣的早餐讨论。同时也感谢 Alexis King 帮助我调试extract-struct-info
代码。
进一步阅读. 想要了解更多关于 Racket 的宏阶段,可以查阅文档编译和运行时阶段和通用阶段级别。此阶段系统也在论文可组合和可编译的宏中有所描述。
当锁优于 MVar:ezyang 的博客
来源:
blog.ezyang.com/2014/01/when-a-lock-is-better-than-an-mvar/
MVars 是一种非常灵活的同步原语,可以用作锁、单位置通道、屏障等,或用于构建更高级别的抽象。就灵活性而言,MVars 是实现运行时系统的首选原语,而不仅仅是实现锁的选择。
然而,最近我在思考GHC 的 BlockedIndefinitelyOnMVar 异常,我意识到使用锁的本地实现可以允许完美的死锁检测,与我们当前针对 MVars 提供的近似检测不同。(我必须强调,然而,在这里,我定义死锁是指一个循环的等待图,而不是“线程无法进一步前进”。)
下面是新原语的行为方式:
-
将会有一个新类型
Lock
,只有一个函数withLock :: Lock -> IO a -> IO a
。(出于简洁起见,我们不考虑将锁通用化以包含值。) -
在运行时,锁被表示为两种闭包类型,分别表示锁定和解锁状态。锁定的闭包包含一个等待队列,其中包含等待锁的线程。
-
当线程获取一个空闲锁时,它将锁添加到与线程关联的(GC 的)持有锁集合中。当它释放锁时,锁将从此集合中移除。
-
当线程试图获取一个忙碌的锁时,它会阻塞自己(等待锁),并将自己添加到被锁定闭包的等待队列中。
-
关键是,在闭包被锁定时,对锁的引用被视为弱指针。(只有从持有的锁集合中的指针是强的。)直观地说,仅仅因为有锁的指针并不意味着你可以解锁;唯一可以解锁的人是持有锁的线程。
-
如果一个线程试图在一个已经无效的弱指针上获取锁,那么它将会发生死锁。
定理。 在等待循环中的任何一组线程是不可达的,如果除了在循环中的锁的等待队列中的指针以外,没有其他指向线程的指针。
证明。 考虑一个在循环中的单个线程:我们展示唯一(强)指向它的指针来自于循环中前一个线程。当线程被阻塞时,它会从运行队列中移除(这算作一个 GC 根)。根据假设,指向线程的唯一指针来自于它所阻塞的锁的等待队列。现在我们考虑指向它所阻塞的锁的指针。由于这个锁正在被使用,指向它的所有指针都是弱的,除了来自于持有锁的线程的指针。但这恰恰是循环中的前一个线程。■
当锁定时进行弱引用解引用的成本,我们现在可以实现完美的死锁检测。死锁将在下次运行垃圾收集时检测到,该收集会检测到线程的死循环。(最坏情况下,这将是下一个主要的 GC。)
为什么这会引起兴趣?毕竟,通常情况下,从死锁中恢复是困难的,因此,虽然准确的死锁报告可能是件好事,但并不是必需的。一个线索来自 Koskinen 和 Herlihy 的论文Dreadlocks: Efficient Deadlock Detection中的一句话:“一个本质上能够处理可中止锁请求的应用程序……是软件事务内存(STM)。如果你在 STM 事务中,死锁根本不是问题;只需回滚一个事务,打破循环即可。通常情况下,在普通的 STM 使用中不会锁定,但当你使用像事务提升这样的技术时,就可能会发生这种情况(来自同一作者;这两篇论文之间的关系并非巧合!)
读者的练习,为限制为单一位置通道的 MVar 制定类似的 GC 方案。(提示:将 MVar 分为写入端和读取端。)
为什么成为系统管理员会帮助你做科学!:ezyang 的博客
来源:
blog.ezyang.com/2010/10/why-being-a-sysadmin-will-help-you-do-scienc/
为什么成为系统管理员会帮助你做科学!
有人曾经抱怨说 SIPB 偏向于系统管理方面:我们自豪地展示了我们部署的服务,却很少谈论实际的编程或进行新颖的计算机科学研究(尽管事实上我们大多数都是程序员,其中一些人非常研究导向)。所以如果你真的对这种事情一点兴趣都没有(就像我一样),你可能会自言自语地想,“那很好”,然后去做别的事情。
我认为这是一个错误,即使短暂的时间在比个人笔记本更大的系统上做系统管理也对你可能做的任何与计算机有关的工作非常有帮助,无论是软件工程还是计算机科学。系统管理是高级的计算机素养,有点像知道如何操作复杂的望远镜。当然,这与实际研究星空或计算本质无关,但如果你不用摸索你的设备,你肯定会更有效率。系统管理是我们为了完成其他事情而做的事情。
“当然,”你可能会说,“但任何一位实践软件工程师或计算机科学家都会掌握他们需要了解的系统管理的各个部分。”是的,但我会认为,除非你积极寻找系统管理员任务,否则你获得的技能将只是达到所需最低水平。就像力量训练一样,只有当你被推到通常能力以外时,你才能积极地获得系统管理技能。但与力量训练不同的是,这些好处在你完成任务后并不会消失:你会继续使用你为日常任务学到的技巧。还有什么比为其他人管理系统更好地推动自己更进一步的方法呢?
我想说,SIPB 是进行这种工作的绝佳机会。作为本科生能够在这样大小和范围的系统上工作是罕见的:课程项目甚至无法与之相提并论。如果你是研究生,你可能正在构建复杂的系统,但你可能也是唯一使用它们的人,并且有用户是一种启发性的经历(尽管我们有时抱怨,“我们能淘汰用户吗?”)
下面是我从 SIPB 中获得的一些个人好处:
-
现在我知道如何将两个硬盘组成 RAID,因为我们项目中的标准操作程序强迫我学习如何这样做。这不是我之前会去做的事情,但现在我认为这对我设置任何新的物理服务器都是必不可少的。因为对于硬盘故障,问题不是是否会发生,而是何时会发生。
-
我现在知道如何有效地进行源码深入研究。我已经利用这一点来帮助更有效地与上游沟通,修复错误,添加功能,填补缺失的文档等等。
-
我现在更深入地了解了 MIT Athena 基础设施的工作原理。
为什么我不能有点懒呢?:ezyang 的博客
来源:
blog.ezyang.com/2012/11/why-cant-i-just-be-a-little-lazy/
你可以。想象一下,有一个版本的 Haskell,其中每个构造器都是严格的,例如每个字段都有一个 !
前缀。这种语言的语义是明确定义的;事实上,CMU 的好同志们早就知道这一点:
到目前为止,我们经常在各种语言构造的动态中遇到任意选择。例如,在指定对偶的动态时,我们必须选择一个相当随意的方式,是全懒惰的动态,即所有对偶都是值,而不管其组成部分的值状态,还是急迫的动态,即只有其组成部分都是值时,对偶才是值。我们甚至可以考虑半急迫(或等效地,半懒惰)的动态,即一个对偶只有在第一个组成部分是值的情况下才是值,而不考虑第二个组成部分。
关于和求和(所有的注射是值,或者只有值的注射是值),递归类型(所有的折叠是值,或者只有值的折叠是值),以及函数类型(函数应该按名字调用还是按值调用)等类似的问题也会出现。整个语言围绕着坚持某一政策或另一政策而建立。例如,Haskell 规定产品、求和和递归类型是懒惰的,并且函数按名字调用,而 ML 则规定完全相反的政策。这些选择不仅是随意的,而且也不清楚为什么它们应该被联系起来。例如,我们可以非常合理地规定产品、求和和递归类型是懒惰的,但在函数上实施按值调用的纪律。或者我们可以急迫地使用产品、求和和递归类型,但坚持按名字调用。这些选择在空间的哪一个点是正确的一点都不清楚;每一个都有其拥护者,也都有其反对者。
因此,我们是否陷入了主观性的困境中?不!走出这一困境的方法是意识到这些差异不应该由语言设计者来强加,而是应该由程序员来做出选择。这可以通过意识到动态的差异反映了正在被语言所模糊的基本类型区别来实现。我们可以在同一语言中同时拥有急迫和懒惰的对偶,同样地,我们也可以在同一语言中同时拥有急迫和懒惰的求和,以及按名字和按值的函数空间,通过提供足够的类型区别使得这些选择对程序员可用。
为什么选择 Haskell?重要问题:ezyang 的博客
为什么选择 Haskell?重要问题
语言选择是一个有争议的事情,通常是在“选择适合工作的语言”和“尽可能少地使用语言以增加思维共享”之间做出妥协。例如,谷歌限制了他们的员工可以使用的编程语言;我已经开始认为,为自己的项目选择任何想要的语言是不负责任的,曾经有人告诉我,“是的... 那个项目是用 X 语言写的,除了写它的人以外没有人知道 X 语言... 也许把时间花在它身上并不是一个好主意。” 当然,我自己也很有过失;我曾用 Haskell 编写了刺客公会的会员会费跟踪系统,除非发生奇迹,我对未来的维护者能否处理它有些怀疑。
当我不负责任的时候,Python 是我大多数脚本需求的首选语言,因此我对 Haskell 能够消除的语言中的怪癖痛苦地有所了解。
-
Python 代码是动态类型的,变量没有作用域。除非执行了代码路径,否则不会捕捉到脑残类型错误、变量错误命名和纯粹的破损代码。 它变得更好的地方:
pylint -e
可以捕捉到大类错误(但你通常必须在递归限制错误中寻找它,我坚信任何不在编译器内置的错误检查最终都会被最需要它的人忽视),以及无论你有什么自动化测试,都可以完全覆盖代码。 Haskell 的优点: 静态分析足够完整,如果编译通过,那么运行就是正确的。 -
Python 运行速度慢。如果你不相信:问问自己为什么运行时不能快速加载以使 Python 作为 CGI 可行,或者为什么 Google 已经禁止在公共面向代码中使用它,或者为什么工程师们在无法再挤出更多速度时,会认为将 Python 守护程序重写为 C++ 是不可避免的结论。 它变得更好的地方: 并不是所有东西都必须运行得飞快。 Haskell 的优点: 疯狂的人们编写疯狂的编译器,如 GHC,可以编译成本地二进制文件,并具有绝对史诗般的速度。
-
Python 对于可理解的代码高尔夫有其局限性。虽然在 Python 中高级代码结构的重复程度不像在 Java 或 C++ 中那样严重,但是试图进一步净化代码往往会导致需要大量文档的高度难以理解的高阶函数。正如人们所说,“不要用 Python 写 Haskell。” Haskell 的优点: 类型系统不仅成为代码文档的重要部分,还作为一个框架,用户可以像拼乐高积木一样“捻”合子和数据,大大提高了对复杂性的容忍度。
-
Python 继承了一种老旧的面向对象范式。然而,我越来越确信基于类型类的系统(Go 是其中一种明确采纳的命令式语言)是未来的发展方向。结合类型签名,它们提供了面向对象编程的两个主要优点:功能的逻辑组织和多态性,而避免了多重继承、混入、元类等复杂的问题。Haskell 之所以优秀: 对类型类的一流支持。
-
Python 的线程支持极差。它有全局解释器锁。Haskell 之所以优秀: 它不仅拥有快速、绿色线程和纯洁性的概念,使得分割计算变得可行,还极大地简化了用于计算的调度算法的实验。在这个领域我说不了更多,因为我几乎没有编写并行 Haskell 代码的经验。
但是,如果我尝试在 Haskell 中编写我在像 PHP 或 Python 这样的命令式语言中完成的大型项目之一(我提到这两种特定语言,因为我在它们之中构建了 两个 系统 ,而这些系统实际上非常大),我会感到不安,原因如下:
-
Haskell 的库支持尚不足以成为完全多范式。我对于任何给定的 Python 代码的直接移植是否可能持高度怀疑;尽管通过像 Text.Printf 这样的包将 Python 的更动态特性引入 Haskell 的类型系统取得了巨大进展,但命令式程序固有的远距离操作要求在 Haskell 中进行大量的 IO 巧妙操作。
-
在命令式领域中,很难确定哪些问题确实更适合保留在命令式领域,正如 James Hague 最近所思索的。 Haskell 社区普遍认为,尽可能少地将代码放在 IO 单子中是合理的,但是当我们引入命令式世界的小部分来帮助我们,例如状态单子,我们承认命令式范式是有用的…… 或者至少是一种轻松的出路。也许如果我们更加努力,我们可以找到一个更加优雅、可维护的纯函数解决方案;而学术界喜欢做的事情之一就是弄清楚这些事情。但是即使对于那些习惯于功能性思维的人来说,这也是困难的,答案通常需要发现,更不用说实现了。
-
所有从多年对大型命令式代码库的开发中积累的工程传说、智慧和最佳实践,可能不再适用于函数式代码库。如果函数式库鼓励尽可能解耦,我们是否需要在 API 中进一步解耦?纯代码是否需要记录日志,或者其确定性使其易于调试?我们需要进行哪些测试,我们对类型有多少信任?函数式代码库的 API 文档和交叉引用需要如何发展?在纯 Haskell 代码的逻辑错误调试中应该如何进行?
然而,有一些公司正在使用 Haskell 编写生产规模的代码库,这让我对这些问题的答案很乐观;即使不是对于 Haskell,对于其他纯函数式语言也是如此。而在命令式世界中的“经典”解决方案往往导致潜在的错误,特别是在多线程应用程序的世界中,我们决不能满足于“够好”的状态。软件糟糕透了,但具有强大、灵活类型的纯函数式系统有望消除大部分这种问题。这就是为什么我选择 Haskell。
为何我在剑桥:ezyang 的博客
今年我在剑桥大学学习计算机科学,而不是在 MIT。对一些人来说,这似乎很奇怪:当我告诉在 MIT 的老朋友和在剑桥的新朋友,我是剑桥- MIT 交换学生时,他们说,“为什么?”有时,他们不太相信我选择离开熟悉的社交圈和标记 MIT 的情况。其他时候,他们不太相信我会想在剑桥而不是 MIT 学习计算机科学(“只是开玩笑”,他们补充道,尽管我不一定相信他们。)
我想解释一下,无论是在离开剑桥之前还是在仅仅三天的时间里,我的观念如何改变,都会让人明白。这篇文章是给考虑参加 CME 的未来学生、急于知道我在剑桥怎么样的父母,以及所有曾经问过我为什么的人。哦,可能还会提到剑桥的函数式编程研究。眨眼
外国交流项目一直是我在申请大学时模糊打算的事情。当你对一个主题没有充分思考,或者没有足够的经验来真正拥有足够的数据时,你的想法就会受到像“这将是一次很好的经历”和“你会学到比只了解美国更多的世界”这样的陈词滥调和真理的影响。这有点像把飓风描述为“潮湿的”;虽然这是显而易见的真理,但并不是很有用。我对大学生活可能是什么样子的印象同样模糊。
MIT 打破了那些先入为主的观念,让我理解了我的大学生活会是什么样子。老实说,我喜欢它。MIT 非常忙碌——它并不给我太多时间来反思——但当我能够稍事休息,只是一刻钟,看到自己在从斯塔塔中心步行回家时谈论 chroots 如何工作,或者与我非常尊敬和钦佩的人一起探讨如何在 Haskell 中编码 Futamura 投影,或者发现自己在一个实时角色扮演游戏中控制虚拟经济,或者与朋友通宵讨论关系直到太阳透过窗户……这让你对这个地方忠诚到骨子里。在我大一结束的夏天,我非常渴望回到 MIT。
但是麻省理工是一个略微病态的环境(也许这正是它吸引那些人的原因)。它能让你感到疲惫,不仅仅是因为缺觉:还有课堂上午 10 点就开始了,你自己的时间不到一个小时,其他的课程或活动就要求你的时间。虽然我从未彻底疲惫过(通常一个周末的马拉松电视节目就足够让我恢复),但我看到我的许多朋友不断地与(偶尔屈服于)这种困境作斗争。但我发现自己感到疲倦,当 CME 项目的信息会议出现时,它似乎来得非常及时。
我不是唯一有这种感觉的人;我在信息会议上看到了一些熟悉的面孔,他们表达了类似的情绪。最终,其中一些人决定不参加这个项目,因为他们无法让自己离开麻省理工。我怀疑仅仅“感到疲倦”不足以让我去剑桥。但这个话题让我想起了我大三暑假的记忆,这成为了一个驱动力。
这是一个可能的另一个现实的爱德华;一个没有去过计算机科学家不是从墙壁上溢出的大学,我的社交团体并非几乎完全由在某种程度上正在学习科学或工程学的人组成。在这个宇宙中,爱德华去了一个人们没有不断吹嘘他们有多么“完蛋”的文理学院。麻省理工之后,这是不可能想象的,除非我参加了一个叫做州长艺术学校的小项目,把我的电脑收起来,为我的双簧管而活,和一个创意作家的房间里的艺术家、演员和其他音乐家聊天。那只是一个月:仅仅是一点点滋味。但这是一个我永远不会忘记的滋味。
这足以让我认真考虑这个可能性,并让我的父母知道这件事。他们强烈支持,这进一步推动了我。我决定写这篇必要的文章,看看他们是否会录取我。他们录取了我。
我的生活中没有多少个重大决定让我煞费苦心。这个决定在麻省理工学院的喧嚣生活中显得异常平静。我接受了,那只是一个简单的事实,对我的生活没有实质性影响。麻省理工还是麻省理工,我的生活还是普普通通,生活继续着。
我父亲一直催促我“和麻省理工学院的每位计算机科学教授吃过午餐”。我甚至编制了一张所有教授及其办公室的列表,以便亲自找到他们。但我从未这么做过:我始终找不到我们可以聊些什么的内容,在这种情况下,这似乎是对我和教授时间的浪费,除非他当时正想为自己的工作做些宣传。
在 Galois 的夏季工作确定了我作为计算机科学家希望在某种形式上追求函数式编程的决定。一旦我弄清楚我实际在做什么,就像我的眼睛被打开了一样。我了解到在麻省理工我从未听说过的研究小组,突然间我发现自己有一小部分研究思想的种子,我可以在阅读和学习计算机科学研究的过程中不断发展。我从未考虑过剑桥的学术问题(这样一所著名的大学应该有一个足够的计算机科学系。)
现在我看了,发现剑桥大学似乎比麻省理工更符合我的研究兴趣。基础本科课程涵盖诸如指称语义、类型和霍尔逻辑等主题。我两个偶像,Simon Peyton Jones 和 Simon Marlow,在微软剑桥研究院工作。它就在剑桥计算机实验室旁边:在回家之前,我顺便去了那里看看地点。几个研究小组致力于与函数式编程相关的领域:自动推理小组每周举行带有讲座的午餐。苏格兰格拉斯哥几十年前曾是函数式编程运动的中心,距离不远。
我相信个体塑造自己命运的能力,但我并不真的相信人们知道他们正在塑造的是什么:他们只是在发生他们不喜欢的事情时做出调整。有时,需要一个巨大的非理性变化才能将您从局部最优解中移出。我去剑桥的理由是非理性的:我怎么知道剑桥是否比麻省理工压力小,或者我是否能真正恢复那个交错的宇宙爱德华的部分。我是否利用周围的资源完全取决于我自己(我还没有,但只是四天而已...)但我在这里。还没有发生任何事情。但一切都已准备就绪。我是乐观的。
为什么迭代器难以理解:ezyang 的博客
来源:
blog.ezyang.com/2012/01/why-iteratees-are-hard-to-understand/
有两个主要原因解释了为什么迭代器的低级实现——迭代器、枚举器和变换器——往往难以理解:纯函数实现和控制反转。这些特性的奇异性进一步加剧了用户被鼓励将迭代器视为接收器、枚举器视为源头、变换器视为转换器。这种直觉对迭代器库的客户有效,但让那些对内部机制感兴趣的人感到困惑。
在本文中,我想通过将其与传统的命令式面向对象语言中可能看到的实现进行比较,来解释纯函数实现所带来的奇异性。我们将看到,在命令式设置中显而易见且简单的概念,在纯函数设置中稍微困难一些。
类型
以下讨论使用枚举器库的命名约定,因为在撰写本文时,它似乎是当前使用最广泛的迭代器实现。
迭代器背后的基本实体是Step
。通常的直觉是它表示迭代器的“状态”,即完成或等待更多输入。但我们警告过不要过度依赖隐喻,所以让我们看看类型:
data Step a b = Continue (Stream a -> m (Step a b)) | Yield b
type Iteratee a b = m (Step a b)
type Enumerator a b = Step a b -> m (Step a b)
type Enumeratee o a b = Step a b -> Step o (Step a b)
我从枚举器库中进行了一些极为重要的简化,其中最重要的是显式地写出了Step
数据类型,而我们本应看到的是Iteratee
,并使Enumeratee
成为纯函数。接下来的三节的目标是解释每个类型签名的含义;我们将通过将其类比于迭代器的命令式等价物来实现这一目标。对大多数程序员来说,命令式程序应该感觉直观,希望纯编码只是一个小跳跃。
步骤/迭代器
我们希望设计一个对象,它可以等待输入或完成某些结果。以下可能是一个提议的接口:
interface Iteratee<A,B> {
void put(Stream<A>);
Maybe<B> result();
}
这一实现关键依赖于类型为Iteratee
的对象的标识,该对象在对put
进行任意调用时都保持不变。对于我们的目的,我们需要将put :: IORef s -> Stream a -> IO ()
(第一个参数是 Iteratee)转换为纯函数接口。幸运的是,如果我们理解State
Monad 的工作原理,就不难看出如何做到这一点:我们将旧类型替换为put :: s -> Stream a -> s
,它接受迭代器的原始状态(s = Step a b
)和一些输入,并将其转换为新状态。最终定义put :: Step a b -> Stream a -> m (Step a b)
也考虑了当迭代器接收数据时可能存在其他副作用的情况,但我们没有使用此 Monad 实例的必要;如果我们将其设置为身份 Monad,则我们的迭代器没有副作用(在这里可能更合适的术语是StateT
)。实际上,这恰好是Continue
构造函数中字段的访问器。
枚举器
我们希望设计一个对象,它接受一个迭代器并向其提供输入。这非常简单,只是一个变异其输入的函数:
void Enumerator(Iteratee<A,B>);
枚举器的类型意味着什么?
type Enumerator a b = Step a b -> m (Step a b)
如果我们将其解释为状态转换函数,那么明显枚举器是一个将迭代器从一种状态转换为另一种状态的函数,就像put
一样。然而,与put
不同的是,枚举器不从流中获取任何输入,并且可能导致多个状态转换:这是一个重要的步骤,其中所有中间状态都被隐藏起来。
此转换的性质没有指定,但常见的解释是,枚举器重复向步骤中的继续传递输入。执行可能会展开为以下内容:
-- s :: Step a b
-- x0, x1 :: Stream a
case s of
Yield r -> return (Yield r)
Continue k -> do
s' <- k x0
case s' of
Yield r -> return (Yield r)
Continue k -> do
s'' <- k x1
return s''
请注意,我们的类型签名不是:
type Enumerator a b = Step a b -> m ()
就像命令式 API 可能建议的那样。这样的函数将能够运行迭代器(并触发其任何附带的副作用),但我们将丢失迭代器的返回结果。这种轻微的修改也不行:
type Enumerator a b = Step a b -> m (Maybe b)
这里的问题在于,如果枚举器实际上没有成功完成运行迭代器,我们已经丢失了迭代器的最终状态(它从未返回!)这意味着你不能连接枚举器。
现在,我已经展开了所有
Iteratee
的定义,这一点应该是清楚的:在enumerator
库中,枚举器和具有副作用的状态转换器之间的简单对应关系被不幸的类型签名所掩盖:type Enumerator a b = Step a b -> Iteratee a b
关于这一点,Oleg 的原始处理方法在这个问题上要清楚得多,因为他定义了步骤本身就是迭代器。
枚举器
最后,我们现在已经准备好处理最复杂的结构,即枚举器。我们的命令式语法告诉我们,像这样的类可能会起作用:
interface Enumeratee<O,A,B> implements Iteratee<O,B> {
Enumeratee(Iteratee<A,B>);
bool done();
// inherited from Iteratee<O,B>
void put(Stream<O>);
Maybe<B> result();
}
就像我们最初的Iteratee
类一样,它支持put
和result
操作,但在构造时它包装另一个Iteratee
:在这个意义上,它是从类型O
到类型A
的适配器。对外部put
使用类型为O
的对象可能会导致在内部Iteratee
上使用类型为A
的对象的零个、一个或多个调用;对result
的调用只是简单地传递。一个Enumeratee
也可以决定它已经“完成”,也就是说,它将永远不会再调用内部迭代器的put
;done
方法可能对测试这种情况很有用。
在我们继续讨论类型之前,值得反思的是这个命令式表述中涉及的有状态对象:它们是外部的Enumeratee
和内部的Iteratee
。我们需要维护两个而不是一个状态。命令式表述自然为我们管理这些(毕竟,即使枚举器正在运行,我们仍然可以访问内部迭代器),但在纯函数实现中,我们必须手动安排。
这是Enumeratee
的类型:
type Enumeratee o a b = Step a b -> Step o (Step a b)
很容易看出为什么第一个参数是Step a b
;这是我们包装的内部迭代器。不太容易看出为什么Step o (Step a b)
是正确的返回类型。由于我们的命令式接口导致一个实现了Iteratee<O,B>
接口的对象,我们可能会倾向于写出这样的签名:
type Enumeratee o a b = Step a b -> Step o b
但请记住;我们需要跟踪两个状态!我们有外部状态,但内部状态呢?在早些时候提到的我们的替代宇宙Enumerator
类似情况下,内部迭代器的状态将永远丢失。也许如果这个枚举器打算用于输入的其余部分(即done
总是返回 false),这并不是什么大问题,但如果我们需要停止使用Enumeratee
,然后继续在流Step a b
上操作,则这一点非常重要。
通过迭代器的设计,我们只能在它完成后才能得到结果。这迫使我们在第二个参数中返回状态,给出最终类型:
type Enumeratee o a b = Step a b -> Step o (Step a b)
“等等!”你可能会说,“如果迭代器只在最后才返回结果,这是否意味着内部迭代器只在最后更新?”然而,通过控制反转的力量,情况并非如此:当枚举器接收值并更新其自身状态时,它也执行并更新内部迭代器。中间的内部状态是存在的;它们只是对我们不可见。(这与命令式版本形成对比,对于那个版本,我们可以窥视内部迭代器!)
另一个很好的问题是,“为什么
enumerator
库在Enumeratee
中悄悄加入了一个额外的单子?”即,Step a b -> m (Step o (Step a b))
我的理解是,单子是不必要的,但如果您的
Enumeratee
需要在接收任何输入之前执行副作用(例如初始化),它可能会有用。
结论
不幸的是,我在这里不能宣称有很多新颖的东西:所有这些主题都在Oleg 的笔记中有涵盖。然而,我希望通过参考迭代器的命令式类比,使类型选择更加清晰。
使用这种纯编码有一些重要的含义,类似于使用 IORefs 和使用状态单子之间的差异:
-
迭代器可以分叉并在不同线程上运行,同时保持本地状态的隔离。
-
旧的迭代器状态副本可以保留,并稍后恢复,作为一种回溯的形式(用新的输入替换坏的输入)。
这些保证在简单的可变引用情况下是不可能的。然而,有一个重要的警告,即虽然迭代器的纯组件很容易被撤销,但我们无法撤销在单子中执行的任何破坏性副作用。在分叉的情况下,这意味着任何副作用必须是原子的;在回溯的情况下,我们必须能够回滚副作用。据我所知,撰写利用这种风格的迭代器的艺术并没有得到很好的研究,但在我看来,这是值得探讨的。最后,我要指出,新导管背后的一个论点是,纯度对支持大多数流处理并不重要。在我看来,这个问题还有待解决。
为什么验证会导致更高质量的代码:ezyang 的博客
来源:
blog.ezyang.com/2012/06/why-verification-results-in-higher-quality-code/
正确性被高估了。毕竟,对于任何合理复杂的系统,“正确”是什么意思都没人知道,即使我们知道了,里程碑每天都在变化。如果去掉形式验证的raison d'être,我们还能认为它是一个值得追求的目标吗?
或许验证结果会产生更高质量的代码。但这显然并不正确:正确性并非质量。我们可能希望高质量的代码易读且容易理解,应尽可能自包含和独立于系统的其他部分,而且高效且经济。事实上,并没有先验理由相信验证会赋予我们这些属性中的任何一个。无论代码多么糟糕,只要它是正确的,就存在一个可以证明其正确性的证明。
但是,任何经历过验证程序的辛苦与泪水的人都能告诉你,形式验证确实可以让你的代码更好。这是个秘密:证明定理非常困难。如果我们希望成功地证明关于程序的某些事情,我们必须尽可能使对代码的推理变得简单。经过验证的程序不可避免地朝着其最“合理”的形式发展,因为否则证明就会变得太艰难。而在这种形式下,高质量代码的原则便随之而来。
以 Bedrock 为例,这是一个用于构建操作指针和寄存器的验证低级程序的系统。这些程序涉及可变状态,这一特性众所周知地显著增加了推理的难度。Bedrock 及其类似系统之所以得以存在,主要归功于一种重要系统的开发,称为 分离逻辑。其核心思想对于任何有经验的从业者来说显而易见,几乎不值得一提:私有局部状态比公共全局状态更易推理——模块化是好的。它通过一种巧妙的形式化方法,星号运算符,结合了关于内存两个区域的两个断言,同时确保这些区域是不交叉的。不管怎样,最终结果是:如果你的组件是独立的,定理证明就很容易;如果你的组件交织在一起,定理证明就很困难。自己算吧。
但事情并不止于此。当不同的组件相互作用时,封装的原则指出,我不想知道一个组件的所有细节,只需知道其高级接口。在定理证明器的世界中,“所有的细节”意味着关于许多指针的难以管理的大量事实,而“高级接口”是一个抽象谓词,将所有这些事实卷入一个单一的、连贯的逻辑事实中(“这是一个链表。”)。开发这些谓词对于保持你的定理陈述简洁和易理解至关重要,在像 Bedrock 这样的高阶证明器中,它们不仅可以适用于数据,还可以适用于代码,即函数指针。
高质量代码的准则表明,编写的代码应该是为了人类理解,而不仅仅是为了机器执行。但是,为了让机器理解的代码具有许多人类所重视的相同特性,因为如果没有这些特性,让机器“理解”就变得不可能。计算机可能很简单,但这意味着计算机能理解的代码也是你能理解的代码。而那才是高质量的代码。
为什么我们熬夜 : ezyang 的博客
为什么我们熬夜
几晚前我在讨论注意力时,有人提到了连续时间块的珍贵这个事实。一旦有人指出,这显而易见,我就注意到我的倾向将有用的活动分成不同的类别:查邮件、阅读新闻源和简单任务归入“不到一小时”时间段,而真正创造软件、解决难题或阅读代码则属于“一小时以上,最好是几个小时”的时间段。我意识到,在课间和课外活动之间抓住“一小时以上”时间段,简直是在浪费时间。
但与 Paul Graham 在他的文章中描述的白天工作的程序员不同,我是一名大学生。我大部分时间不在白天做事;那是从讲座和课外讲解中吸取信息的时间。特别是,我是麻省理工学院的学生,这意味着没有 5 点“哦,是时候回家放松了”的时期;这是全天候工作(你只能在空闲时刻进行拖延的飞行来获得宝贵的解脱感。)我的早晨和晚上被会议充斥着:它们要求我在别人的时间表上物理上重新定位自己并注意别的事情。加上从 7:30-10PM 的管弦乐排练或从 5-7PM 的小合奏排练或从 7:30-8:30PM 的 SIPB 会议,你就得到了一个碎片化的配方。
你什么时候才能得到那段不间断的时间呢?只有深夜,因为当你在凌晨两点做题时,有一件好事:没有人安排在凌晨 2:30 开会。虽然显然这些好处可能被睡眠剥夺的醉人效应所抵消,但你在其他地方找不到这样的连续时间块。这就是为什么我们熬夜。
为什么你不应该在系统领域攻读博士学位:ezyang 的博客
这篇文章中表达的观点未必代表我的观点。我只是一个非常困惑的大四学生,有很多内心挣扎要做。
当我告诉朋友们,“我要攻读博士学位”,有时会得到回应,“为你感到高兴!”但有时,我会得到回应,“你为什么要这样做?”仿佛我是一位被社会误导,被认为研究是“最高召唤”的可怜的人,而最优秀的人才去做的事情,而其他人则加入工业界。“如果你是一名聪明的黑客并且加入工业界,”他们说,“你会立即更有乐趣,产生更大的影响力和更多的金钱。”
时间。 听说获得博士学位需要很多时间。大多数项目喜欢宣传大约五年,或者可能六年,但如果你实际上看看统计数据,它实际上可能延长到九或十年。这占据了你生活的大部分时间:实际上是你所有的二十多岁,并且,老实说,你可能有更好的事情要做来度过这些宝贵的年华。博士学位只是一个踏脚石,是被认真对待学术界的必要证书,但并不代表对某个领域做出了重大贡献。对于一个踏脚石来说,它是一个极其耗时的踏脚石。
在这段时间里还有许多其他事情可能发生。你可以创办一家初创公司,看它在三年内被收购或沉没:在这样的情境下,六年似乎是两辈子。你可以开始一个职业生涯,成为一个极其火热的软件工程师职位市场上的专业人士,跳槽至你真正感兴趣的工作:作为博士生,你被束缚在你的导师和你的大学之间。变革的条件如此之重:如果你换导师,你实际上必须重新开始,很容易就会想,“我用过去的三年时间做了什么?”
金钱。 在过去的几年里,有一件事你没有做到:赚钱。博士生是使学术界运转的奴隶劳工。并不是说大学没有得到很好的资助:事实上,政府在资助研究项目方面投入了大量资金。但是大部分这些资金并没有流向博士生:你只能拿到三万美元的津贴,而软件工程师在几年内很容易就能赚到 15 万到 20 万美元。即使你进入终身教授的岗位,你的收入仍然经常低于在工业界工作的人。你不会进入学术界指望发财。
稀缺性. 事实上,你不应该指望在学术界得到太多东西。可供申请的终身职位远远不及博士申请者的数量,以至于你进入学术界更像是买彩票。你必须在正好有终身职位空缺的时候(也许是教授去世了),做一个博士后或者花几年时间在学术界建立联系网,希望通过这些关系得到一个职位。大多数人都无法成功,即使在第二或第三流大学也是如此。对于工业研究实验室来说情况也类似,这类实验室年复一年地变得越来越少:微软研究部门非常严格选择,作为未来的博士,你要对其生存能力做出十年的赌注。英特尔实验室显然没有。
终身职位并不那么好. 即使你最终获得了终身职位,实际上也并不那么美好。在竞争激烈的环境中你已经花了十五年时间争取这个位置,而现在呢?你现在将要在你获得终身职位的机构度过余生:你可能每隔几年就有几个月的休假。这是象牙的手铐,戴上它的过程比去华尔街的那个家伙付出的努力还要多。至于工作呢?你仍然需要为你的工作辩护并获得拨款,你仍然需要参加委员会并完成其他根本与你的研究无关的任务。在工业界,你可以雇人来处理你在大学“纪律委员会”的职位——在学术界,这根本行不通。
获取不足. 如果你是一个系统研究员,你甚至没有做大规模研究所需的设施。物理学家有粒子加速器,生物学家有巨大的实验室,但系统研究员有什么?当然不会有一个被全球数百万用户使用的软件系统。要想得到这种系统,你必须去工业界。问问马特·韦尔什,他曾经辞去哈佛大学的终身职位加入谷歌,引起了轰动。在这种环境中工作,你可以真正看看你的疯狂想法是否奏效。
现在不要做. 当然,此时此刻我心里在抗议,认为这对博士非常不公平,你确实会得到更多的自由,也许有些人并不太在乎钱,这仅仅是一个关于价值体系的问题,并且对于某些人来说,这是正确的决定。我可能会说你的二十多岁也是做博士的最佳时机,学术界是正确的晚期职业道路,你还可以作为教授创办一家初创企业。
或许,他们说,但你需要弄清楚这对你是否是正确的决定。你需要在这两个领域都有经验才能做出决定,而最好的时间是在决定是否进入行业之前在行业中尝试两三年。在你获得博士学位之后,人们会在你作为学术界潜力的时钟上设置定时器,如果在那段时间内你没有发表文章,人们就会不再认真对待你。但如果你在二十多岁时开始博士学位,没有人会把这当作一回事。每个人都可能有一段糟糕的软件实习经历;不要让这让你对行业望而却步。我们解决有趣的问题。我们更加多元化,总体上我们提供更多自由。从任何意义上来说,我们都不是二流的。
或许他们是对的。我不知道。
宽度自适应的 XMonad 布局 : ezyang’s 博客
宽度自适应的 XMonad 布局
我通常的笔记本设置是使用宽屏显示器,并将笔记本屏幕作为辅助显示器。长期以来,我使用了两种 XMonad 布局:一种是针对笔记本显示器的全屏布局(我使用大字体以便于眼睛放松),另一种是在大屏幕上使用的两列布局。
但我遇到了一个恼人的问题:如果我从小屏幕切换工作区到大屏幕,XMonad 仍然会使用全屏布局,我必须通过 Alt-Tab 切换到两列布局。更让人气愤的是,如果我又切回去,我还得再次 Alt-Tab。
在 #xmonad 的伙计们的鼓励下,我终于写了一个扩展来根据屏幕大小自动切换布局!这就是它:
{-# LANGUAGE FlexibleInstances, MultiParamTypeClasses #-}
-----------------------------------------------------------------------------
-- |
-- Module : XMonad.Layout.PerScreen
-- Copyright : (c) Edward Z. Yang
-- License : BSD-style (see LICENSE)
--
-- Maintainer : <ezyang@cs.stanford.edu>
-- Stability : unstable
-- Portability : unportable
--
-- Configure layouts based on the width of your screen; use your
-- favorite multi-column layout for wide screens and a full-screen
-- layout for small ones.
-----------------------------------------------------------------------------
module XMonad.Layout.PerScreen
( -- * Usage
-- $usage
PerScreen,
ifWider
) where
import XMonad
import qualified XMonad.StackSet as W
import Data.Maybe (fromMaybe)
-- $usage
-- You can use this module by importing it into your ~\/.xmonad\/xmonad.hs file:
--
-- > import XMonad.Layout.PerScreen
--
-- and modifying your layoutHook as follows (for example):
--
-- > layoutHook = ifWider 1280 (Tall 1 (3/100) (1/2) ||| Full) Full
--
-- Replace any of the layouts with any arbitrarily complicated layout.
-- ifWider can also be used inside other layout combinators.
ifWider :: (LayoutClass l1 a, LayoutClass l2 a)
=> Dimension -- ^ target screen width
-> (l1 a) -- ^ layout to use when the screen is wide enough
-> (l2 a) -- ^ layout to use otherwise
-> PerScreen l1 l2 a
ifWider w = PerScreen w False
data PerScreen l1 l2 a = PerScreen Dimension Bool (l1 a) (l2 a) deriving (Read, Show)
-- | Construct new PerScreen values with possibly modified layouts.
mkNewPerScreenT :: PerScreen l1 l2 a -> Maybe (l1 a) ->
PerScreen l1 l2 a
mkNewPerScreenT (PerScreen w _ lt lf) mlt' =
(\lt' -> PerScreen w True lt' lf) $ fromMaybe lt mlt'
mkNewPerScreenF :: PerScreen l1 l2 a -> Maybe (l2 a) ->
PerScreen l1 l2 a
mkNewPerScreenF (PerScreen w _ lt lf) mlf' =
(\lf' -> PerScreen w False lt lf') $ fromMaybe lf mlf'
instance (LayoutClass l1 a, LayoutClass l2 a, Show a) => LayoutClass (PerScreen l1 l2) a where
runLayout (W.Workspace i p@(PerScreen w _ lt lf) ms) r
| rect_width r > w = do (wrs, mlt') <- runLayout (W.Workspace i lt ms) r
return (wrs, Just $ mkNewPerScreenT p mlt')
| otherwise = do (wrs, mlt') <- runLayout (W.Workspace i lf ms) r
return (wrs, Just $ mkNewPerScreenF p mlt')
handleMessage (PerScreen w bool lt lf) m
| bool = handleMessage lt m >>= maybe (return Nothing) (\nt -> return . Just $ PerScreen w bool nt lf)
| otherwise = handleMessage lf m >>= maybe (return Nothing) (\nf -> return . Just $ PerScreen w bool lt nf)
description (PerScreen _ True l1 _) = description l1
description (PerScreen _ _ _ l2) = description l2
如果我能搞清楚他们该死的补丁提交流程,我会把它提交到 xmonad-contrib...
Git 中的工作流:单用户风格 : ezyang’s 博客
Nelson Elhage 写了一篇关于Git 和可用性的文章,在其中他讨论了 Git 看起来如此令人困惑的原因之一,这对于直接从 Subversion 风格工作流转入的用户来说。在讨论这个问题时,有一件事被提出来,那就是,虽然 Subversion 对其用户施加了相当严格的工作流程,但 Git 足够灵活,可以执行几乎任何类型的工作流。这对于一个使用 Git 的公司来说对用户来说是个噩梦:当他们在 Google 上搜索如何使用 Git 时,他们会得到多种多样的教程,每一个教程都是针对不同的工作流程。
在这个多部分系列中,我想讨论几种我见过或经历过的 Git 工作流类型。本文首先将简要介绍一个非常简单的 Git 工作流示例,即单用户工作流,这将建立一些你可能在其他工作流中看到的 Git 基本习语。
单用户工作流本质上很简单。在其最简单的形式下,它不过是一个略显高级的备份系统;你可以有很多版本的代码。你可以回到过去。因为我假设你对版本控制系统有一般的了解,所以我认为我不需要说服你这是有用的。本文还假设你足够熟悉在代码库中进行提交(虽然我们不会假设你知道如何使用索引;-a
是一个神奇的标志)。
备份
当你从集中式 VCS 转向分布式VCS 时可能会注意到的第一件事是,除非你明确说出来,否则你的数据永远不会离开你的计算机。如果你在飞机上没有互联网访问,这很棒;你不需要堆积一堆变更而不能登入服务器。然而,这意味着你必须稍加考虑你将把变更push
到哪里。一个简单的方法是利用多种免费公共托管。如果你有一个具有 SSH 访问权限的服务器,私人的离线备份也很容易:在另一台服务器上使用 git init --bare
创建一个裸 Git 仓库,然后设置一个远程仓库,你可以向其推送……但我已经过于详细了!
如果你在自己的电脑上使用 git init
创建了一个 Git 仓库和工作副本,现在你将不得不处理 Git 远程。我个人觉得这相当烦人,因此总是安排在 git clone
我的工作副本之前设置好我的裸 Git 仓库(即服务器),这样可以轻松进行推送。我的步骤是:
在我的服务器上,创建一个目录(我喜欢/srv/git/project.git
),然后在其中运行git init --bare
# 在我的客户端上,运行git clone ssh://servername/srv/git/project.git
如果你必须在一个现有的仓库上设置远程仓库,可以使用以下命令来完成:
git remote add origin $REPO_URL
git config branch.master.remote origin
git config branch.master.merge refs/heads/master
对于那些好奇的人,第一行添加了一个名为“origin”的远程仓库(按照约定,这是从你可能克隆的仓库设置的远程仓库),关联到$REPO_URL
。第二和第三行设置了从仓库拉取更改时的默认行为,以模拟通常在克隆时设置的配置。(注意:这有点糟糕。Git 1.7.0 引入了--set-upstream
标志来解决这些问题。)
然后,你只需要使用git commit
提交更改,然后用git push
将它们推送到远程仓库。
主题分支
作为单个用户,在你的仓库中大部分的工作都可以很好地一起进行;你不必担心别人会进来破坏你的提交。然而,偶尔你可能会发现自己在进行一次大的重构时,你不得不结束今天的工作,或者中断来处理一个更紧迫但更小的 bug 修复。在这里,Git 的廉价提交和分支使得这一切变得非常简单。
如果你认为你目前正在进行的更改很大,但是你很快就能回来处理它们,可以使用git stash
命令将你的更改暂时保存到一个暂存区。然后你可以进行你的小改动,完成后使用git stash pop
来恢复你的旧更改。暂存区最适合作为一个临时的存储空间,当可能时应立即清空;你不想看到多个被暂存的更改,并试图弄清楚哪一个包含了你关心的更改。
如果你的更改比那还要大一点,或者你认为你暂时无法继续进行任何大的更改,你可以创建一个所谓的主题分支。首先,使用git checkout -b 新分支名
切换到一个新分支(选择一个描述性的名字)。然后,进行一次提交以保存你的更改。如果你打开gitk
,你会注意到你现在有一个附加在master
上的提交。你可以再次切换到 master 分支使用git checkout master
,并进行你需要的其他更改。
当你最终确定你的主题分支完成时,你需要将它重新合并到 master 分支中。有两种方法可以做到这一点:
-
你可以假装你的主题分支作为一个整体只是一个大补丁,因此,这个补丁应该合理地适用于最新版本的
master
。在主题分支上运行git rebase master
(你可以用git status
检查),这将把这个“补丁”应用到master
上。然后你可以切换到 master 并git pull topic-branch
来快进 master 到主题分支。由于清理旧分支是件好事,我建议之后运行git branch -d topic-branch
。 -
你可以认为历史很重要,并执行一次合并。在主分支上,运行
git merge topic-branch
。就像第一种情况一样,你可以用git branch -d topic-branch
清理主题分支。
清理旧主题分支是一个良好的习惯,因为这意味着你可以使用 git branch
快速提醒自己哪些主题分支可能需要你的关注。
另外,如果你关心备份你的主题分支,你应该运行 git push origin topic-branch
。你可以使用 git push origin :topic-branch
从远程删除主题分支(注意冒号)。
清理历史
许多人在源文件内部的文档中投入了很多注意力,以便解释某段代码的作用。然而,代码文档的另一个优秀来源是查看代码的历史;特定片段是何时引入的,作者在进行更改时对此作了什么解释?git blame
将为你提供每个 Git 文件中每行何时更改的详细描述,而 git log
将展示对特定文件所做的更改的综合情况。
不幸的是,这种机制的有用性高度依赖于你在提交中做出的消息质量,如果你正确使用 Git 并经常提交,可能在某些消息上会有所疏忽。别担心;这种情况发生在我们每个人身上。你只需记住在完成时整理一下(即重写历史)。
在这种情况下,git rebase -i
是你的好朋友。指定一个参数来指定你想要重写历史的距离(HEAD~N
,其中 N 是一个数字可能是个不错的选择),然后根据你的心情重写历史。你有三个主要的工具:
-
edit
,当 Git 到达该提交时,只需运行git commit --amend
:这很简单:你有一个独立的提交,但没有写一个好的提交消息,那么 amend 允许你把提交消息改成有用的内容。 -
squash
:如果你做了一堆非常小的提交,现在你看着它们并决定,不,它们实际上逻辑上是一致的,那么你可以把它们合并在一起。 -
edit
withgit checkout HEAD~
: 这个操作会给你一个带有该提交更改的工作树,但这些更改实际上并不属于一个提交的一部分。你可以使用git add -p
(它会选择性地将你的更改块添加到索引中)然后使用git commit
不带-a
标志,把一个“太大”的提交分解成易处理的小块。
这种策略与专题分支特别配合,适合以下工作流程:
-
使用
git checkout -b 主题名称
创建专题分支, -
在分支上进行大量修改,使用难以理解的总结进行小提交,
-
使用
git log -u master..HEAD
检查你的更改, -
使用
git rebase -i master
编辑你的更改, -
切换到主分支并使用
git pull 主题名称
。
部分一就到这里了!你可能已经注意到,所有这些策略似乎互为补充:这种不寻常的整合是 Git 简单内部模型的一个优点之一。如果大家想看一些这些技术在实际中的例子,我很乐意再多写一些博客。感谢阅读。
编写生成器友好的代码:ezyang 的博客
编写生成器友好的代码
我从向 html5lib 列表抱怨 Python 版本过度使用生成器,导致难以移植到 PHP走了很远。 现在已经沉迷于 Haskell 的惰性编程,我喜欢尝试使我的代码符合生成器习惯。 虽然 Python 生成器与无限惰性列表相比有显著缺点(例如,将它们分叉以供多次使用并不简单),但它们非常不错。
不幸的是,我看到的大多数期望看到列表的代码对生成器的接受程度不够高,当我不得不说list(generator)
时,我很伤心。 如果你的内部代码期望 O(1)访问任意索引,我会原谅你,但我经常看到的是只需要顺序访问却因为调用len()
而搞砸一切。 鸭子类型在这种情况下救不了你。
制作代码生成器友好的技巧很简单:使用迭代接口。 不要改变列表。 不要请求任意项。 不要请求长度。 这也是for range(0, len(l))
是绝对错误遍历列表的提示; 如果你需要索引,请使用enumerate
。
更新(2012 年 9 月 1 日)。 令人发笑的是,PHP 终于引入了生成器。
Xmonad 和 Saucy 上的媒体键:ezyang 的博客
Ubuntu 继续破坏完全正常的软件,在我最近升级到 Saucy Salamander 时,我惊讶地发现我的媒体键(例如音量键,fn(功能)键,挂起按钮等)停止工作。当然,如果我使用 Unity 登录我的用户,它就可以正常工作,但是谁愿意使用那样一个愚蠢的窗口管理器呢...
根本问题在于,根据 这些 Arch Linux 论坛帖子,Gnome 已经将媒体键支持从 gnome-settings-daemon
(任何自尊的 Xmonad 用户都会生成)移到他们的窗口管理器中。当然,这是不好的,因为我不想使用他们的窗口管理器!
目前看来,恢复此功能的最简单方法是运行 3.6 版本的 gnome-settings-daemon。幸运的是,至少对于 Saucy,有一些适用于你的架构的 3.6 构建版本可用(你还需要 gnome-control-center,因为它依赖于 gnome-settings-daemon):
一旦你下载了适当的 deb 文件,运行 dpkg -i $DEBFILE
然后运行 apt-mark hold gnome-control-center gnome-settings-daemon
应该能解决问题。你应该运行 aptitude upgrade
来确保没有破坏其他依赖关系(例如 gnome-shell
)。(高级用户可以将 deb 文件添加到本地仓库,然后通过 apt-get
明确降级。)
未来,我们可能会被迫在其他软件包中重新实现媒体键绑定,并且如果能以某种方式标准化这一点将是很好的。Linux Mint 已经分支了 gnome-settings-daemon,使用他们的 cinnamon-settings-daemon,但我没有尝试过,也不知道它的工作情况如何。
更新。 Trusty 版本更新了这个软件包的版本,恢复了支持,所以我通过我的 PPA 提供后端支持 via my PPA.
你正处在一个迷宫般的扭曲小通道中……(一个关于 GHC 的黑客帖子):ezyang 的博客
大约一个月前,我决定如果我能解决GHC 的运行时从不终止未使用的工作线程的 bug,那将会很酷。好吧,今天我终于抽出时间看了看它,经过大约一个小时在 GHC RTS 这个弯弯曲曲的迷宫里四处游荡后,我终于看到了一个希望之光,以一种让人心情愉悦的简单补丁的形式。我已经给 Simon Marlow 发了封邮件,确认这光明其实不是一列火车,但我突然意识到,查看我的命令历史并记录我是如何得出在Capability.c
的第 464 行添加修改是正确的地方的过程,会是件有趣的事情,因为这种心智旅程实际上从未在任何地方以任何形式被记录过。
跑迷宫前的热身。 在像 GHC 这样不稳定的迷宫中,你希望在尝试任何复杂操作之前,确保引导路线(即干净的构建)是正常工作的。我使用源码树和构建树分离,因此,更新所有内容包括:
cd ghc-clean
./darcs-all get
./darcs-all pull -a
cd ../ghc-build
lndir ../ghc-clean
perl boot && ./configure && make
inplace/bin/ghc-stage2 --interactive
当这个问题在一个令人满意的方式下解决(对于 Windows 平台来说是一个非平凡的任务)后,代码猎取就可以开始了。
准备好你的设备。 什么?你是说你迷失在这个迷宫中,连知道如何确认你已到达目的地的方法都没有?那可不行... 你需要某种寻找正确方向的工具... 一些能告诉你什么时候找对了的东西。
在这个特定情况下,原始的 bug 报告者已经写了一个小的不完整的测试脚本,所以我做的第一件事就是把它完善成一个不需要人为交互的脚本。新脚本的基准很明确:/proc/PID/task
应该报告一个远小于 200 的数字。为了看到当前实现存在问题:
ezyang@javelin:~/Dev/ghc-build/testsuite/tests/ghc-regress/ffi/should_run$ ./cleanupThreads
203
摸清方向。 好的,我们想要什么?我们希望线程不再闲置而是结束掉。有两种方法可以做到这一点:当线程意识到不需要它时,让它自行了断,或者在必要时由某个管理器杀死线程。后者通常被认为是不好的做法,因为你希望确保线程不在死亡时执行任何可能导致数据损坏的关键任务。因此,自行了断是最好的选择。现在,有两个问题:
-
线程何时决定进入等待池?这大概是我们希望它终止自身的地方。
-
线程如何决定它是否应该继续停留或者退出?
绘制地图. GHC 有一个叫做 -Ds
的小运行时标志。它非常有用:它会输出关于线程的一大堆调试信息,这正是我们想要查找的。我们的行动计划是查看我们的测试脚本中线程活动的情况,并确定线程应该死亡而不是徘徊的点。
日志的开头看起来像这样:
b75006d0: allocated 1 capabilities
b75006d0: new task (taskCount: 1)
b75006d0: returning; I want capability 0
b75006d0: resuming capability 0
b75006d0: starting new worker on capability 0
b75006d0: new worker task (taskCount: 2)
b75006d0: task exiting
b75006d0: new task (taskCount: 2)
b75006d0: returning; I want capability 0
b71ffb70: cap 0: schedule()
b71ffb70: giving up capability 0
注意数字 b75006d0
;那是我们的主线程,它将非常忙碌。这是我们首次启动的线程,用来进行一个外部调用,但完成得相当快,并不是我们正在寻找的外部调用:
b75006d0: cap 0: created thread 1
b75006d0: cap 0: thread 1 appended to run queue
b75006d0: new bound thread (1)
b75006d0: cap 0: schedule()
b75006d0: cap 0: running thread 1 (ThreadRunGHC)
b75006d0: cap 0: thread 1 stopped (suspended while making a foreign call)
b75006d0: freeing capability 0
b75006d0: returning; I want capability 0
b75006d0: resuming capability 0
b75006d0: cap 0: running thread 1 (ThreadRunGHC)
b75006d0: cap 0: thread 1 stopped (suspended while making a foreign call)
b75006d0: freeing capability 0
b75006d0: returning; I want capability 0
b75006d0: resuming capability 0
b75006d0: cap 0: running thread 1 (ThreadRunGHC)
b75006d0: cap 0: created thread 2
b75006d0: cap 0: thread 2 appended to run queue
b75006d0: cap 0: thread 1 stopped (finished)
不久之后,我们看到一大堆新线程被创建并添加到运行队列中——这些就是我们的线程:
b75006d0: woken up on capability 0
b75006d0: resuming capability 0
b75006d0: cap 0: running thread 3 (ThreadRunGHC)
b75006d0: cap 0: created thread 4
b75006d0: cap 0: thread 4 appended to run queue
b75006d0: cap 0: created thread 5
b75006d0: cap 0: thread 5 appended to run queue
b75006d0: cap 0: created thread 6
b75006d0: cap 0: thread 6 appended to run queue
b75006d0: cap 0: created thread 7
b75006d0: cap 0: thread 7 appended to run queue
b75006d0: cap 0: created thread 8
b75006d0: cap 0: thread 8 appended to run queue
b75006d0: cap 0: created thread 9
b75006d0: cap 0: thread 9 appended to run queue
b75006d0: cap 0: created thread 10
b75006d0: cap 0: thread 10 appended to run queue
b75006d0: cap 0: created thread 11
b75006d0: cap 0: thread 11 appended to run queue
b75006d0: cap 0: created thread 12
b75006d0: cap 0: thread 12 appended to run queue
b75006d0: cap 0: created thread 13
这个过程一直持续,直到我们把它们都生成出来:
54139b70: starting new worker on capability 0
54139b70: new worker task (taskCount: 201)
53938b70: cap 0: schedule()
53938b70: cap 0: running thread 202 (ThreadRunGHC)
53938b70: cap 0: thread 202 stopped (suspended while making a foreign call)
53938b70: starting new worker on capability 0
53938b70: new worker task (taskCount: 202)
53137b70: cap 0: schedule()
53137b70: cap 0: running thread 203 (ThreadRunGHC)
53137b70: cap 0: thread 203 stopped (suspended while making a foreign call)
53137b70: starting new worker on capability 0
53137b70: new worker task (taskCount: 203)
52936b70: cap 0: schedule()
然后,因为没有什么可做的(我们所有的线程都在 FFI land 中),我们进行了一次大的 GC:
52936b70: woken up on capability 0
52936b70: resuming capability 0
52936b70: deadlocked, forcing major GC...
52936b70: cap 0: requesting parallel GC
52936b70: ready_to_gc, grabbing GC threads
all threads:
threads on capability 0:
other threads:
thread 203 @ 0xb72b5c00 is blocked on an external call (TSO_DIRTY)
thread 202 @ 0xb72b5800 is blocked on an external call (TSO_DIRTY)
thread 201 @ 0xb72b5400 is blocked on an external call (TSO_DIRTY)
thread 200 @ 0xb72b5000 is blocked on an external call (TSO_DIRTY)
thread 199 @ 0xb72b4c00 is blocked on an external call (TSO_DIRTY)
thread 198 @ 0xb72b4800 is blocked on an external call (TSO_DIRTY)
thread 197 @ 0xb72b4400 is blocked on an external call (TSO_DIRTY)
thread 196 @ 0xb72b4000 is blocked on an external call (TSO_DIRTY)
thread 195 @ 0xb72b3c00 is blocked on an external call (TSO_DIRTY)
thread 194 @ 0xb72b3800 is blocked on an external call (TSO_DIRTY)
thread 193 @ 0xb72b3400 is blocked on an external call (TSO_DIRTY)
[snip (you get the idea)]
(我一直在想 FFI 调用是否应该被视为死锁。)
现在线程开始从 FFI-land 回来并处于空闲状态:
b69feb70: cap 0: running thread 4 (ThreadRunGHC)
b69feb70: cap 0: waking up thread 3 on cap 0
b69feb70: cap 0: thread 3 appended to run queue
b69feb70: cap 0: thread 4 stopped (finished)
b69feb70: giving up capability 0
b69feb70: there are 2 spare workers
b69feb70: passing capability 0 to bound task 0xb75006d0
b61fdb70: returning; I want capability 0
b61fdb70: resuming capability 0
b61fdb70: cap 0: running thread 5 (ThreadRunGHC)
b59fcb70: returning; I want capability 0
b61fdb70: cap 0: thread 5 stopped (finished)
b61fdb70: giving up capability 0
b61fdb70: there are 3 spare workers
b61fdb70: passing capability 0 to worker 0xb59fcb70
b75006d0: woken up on capability 0
b75006d0: capability 0 is owned by another task
b51fbb70: returning; I want capability 0
b59fcb70: resuming capability 0
b59fcb70: cap 0: running thread 6 (ThreadRunGHC)
b59fcb70: cap 0: thread 6 stopped (finished)
b59fcb70: giving up capability 0
b49fab70: returning; I want capability 0
b59fcb70: there are 4 spare workers
b59fcb70: passing capability 0 to worker 0xb51fbb70
b51fbb70: resuming capability 0
b51fbb70: cap 0: running thread 7 (ThreadRunGHC)
b51fbb70: cap 0: thread 7 stopped (finished)
b51fbb70: giving up capability 0
b41f9b70: returning; I want capability 0
b51fbb70: there are 5 spare workers
我其实有点作弊:there are X spare workers
的调试语句是我自己添加的。但这一部分很重要;我们特别感兴趣的是这些行:
b61fdb70: cap 0: thread 5 stopped (finished)
b61fdb70: giving up capability 0
线程停止了,但它并没有死,它只是放弃了能力。这两个地方非常适合线程可能会选择杀死自己的地方。
地标. 是时候用信赖的 grep
搞清楚这些调试消息是从哪里发出的了。不幸的是,5
和 finished
可能是动态生成的消息,所以 stopped
是我们能找到的唯一真正的标识符。幸运的是,这足够具体,让我能找到 RTS 中正确的行:
ezyang@javelin:~/Dev/ghc-clean/rts$ grep -R stopped .
./Capability.c: // list of this Capability. A worker can mark itself as stopped,
./Capability.c: if (!isBoundTask(task) && !task->stopped) {
./RaiseAsync.c: - all the other threads in the system are stopped (eg. during GC).
./RaiseAsync.c: // if we got here, then we stopped at stop_here
./Task.c: if (task->stopped) {
./Task.c: task->stopped = rtsFalse;
./Task.c: task->stopped = rtsFalse;
./Task.c: task->stopped = rtsTrue;
./Task.c: task->stopped = rtsTrue;
./Task.c: debugBelch("task %p is %s, ", taskId(task), task->stopped ? "stopped" : "alive");
./Task.c: if (!task->stopped) {
./sm/GC.c: // The other threads are now stopped. We might recurse back to
./Schedule.c: "--<< thread %ld (%s) stopped: requesting a large block (size %ld)\n",
./Schedule.c: "--<< thread %ld (%s) stopped to switch evaluators",
./Schedule.c: // stopped. We need to stop all Haskell threads, including
./Trace.c: debugBelch("cap %d: thread %lu stopped (%s)\n", ### THIS IS THE ONE
./Task.h: rtsBool stopped; // this task has stopped or exited Haskell
./Task.h:// Notify the task manager that a task has stopped. This is used
./Task.h:// Put the task back on the free list, mark it stopped. Used by
./Interpreter.c: // already stopped at just now
./Interpreter.c: // record that this thread is not stopped at a breakpoint anymore
./win32/Ticker.c: // it still hasn't stopped.
Trace.c
中的那一行实际上是在一个通用的调试函数 traceSchedEvent_stderr
中,但幸运的是,其中有一个关于其参数 tag
的大 case
语句:
case EVENT_STOP_THREAD: // (cap, thread, status)
debugBelch("cap %d: thread %lu stopped (%s)\n",·
cap->no, (lnat)tso->id, thread_stop_reasons[other]);
break;
所以 EVENT_STOP_THREAD
是一个很好的下一个 grep
。确实如此:
ezyang@javelin:~/Dev/ghc-clean/rts$ grep -R EVENT_STOP_THREAD .
./Trace.c: case EVENT_STOP_THREAD: // (cap, thread, status)
./eventlog/EventLog.c: [EVENT_STOP_THREAD] = "Stop thread",
./eventlog/EventLog.c: case EVENT_STOP_THREAD: // (cap, thread, status)
./eventlog/EventLog.c: case EVENT_STOP_THREAD: // (cap, thread, status)
./Trace.h: HASKELLEVENT_STOP_THREAD(cap, tid, status)
./Trace.h: traceSchedEvent(cap, EVENT_STOP_THREAD, tso, status);
它看起来是 Trace.h
中的一个内联函数:
INLINE_HEADER void traceEventStopThread(Capability *cap STG_UNUSED,
StgTSO *tso STG_UNUSED,
StgThreadReturnCode status STG_UNUSED)
{
traceSchedEvent(cap, EVENT_STOP_THREAD, tso, status);
dtraceStopThread((EventCapNo)cap->no, (EventThreadID)tso->id,
(EventThreadStatus)status);
}
Classy. 所以 traceEventStopThread
就是那个魔法词,确实:
ezyang@javelin:~/Dev/ghc-clean/rts$ grep -R traceEventStopThread .
./Schedule.c: traceEventStopThread(cap, t, ret);
./Schedule.c: traceEventStopThread(cap, tso, THREAD_SUSPENDED_FOREIGN_CALL);
./Trace.h:INLINE_HEADER void traceEventStopThread(Capability *cap STG_UNUSED,
在 Schedule.c
中有两个可能的位置。
开始挖掘. 首先我们得选一个站点仔细检查。幸运的是,我们注意到第二个跟踪事件对应于在进行安全的 FFI 调用之前暂停线程;那肯定不是我们这里要找的。此外,第一个在调度器中,这很有道理。但在这附近并没有明显的东西,你可能会把它与由于工作不足而保存工作任务关联起来。
那个 giving up
能力消息呢?再多找一下发现它在 yieldCapability
函数里(正如我们所料)。如果我们追踪 yieldCapability
的调用,我们看到它是由 scheduleYield
调用的,而 scheduleYield
又被调度循环调用:
scheduleYield(&cap,task);
if (emptyRunQueue(cap)) continue; // look for work again
// Get a thread to run
t = popRunQueue(cap);
这非常非常有趣。它表明能力本身将告诉我们是否要进行工作,而yieldCapability
是一个有希望进一步探索的函数:
debugTrace(DEBUG_sched, "giving up capability %d", cap->no);
// We must now release the capability and wait to be woken up
// again.
task->wakeup = rtsFalse;
releaseCapabilityAndQueueWorker(cap);
最后那个调用看起来很有趣:
static void
releaseCapabilityAndQueueWorker (Capability* cap USED_IF_THREADS)
{
Task *task;
ACQUIRE_LOCK(&cap->lock);
task = cap->running_task;
// If the current task is a worker, save it on the spare_workers
// list of this Capability. A worker can mark itself as stopped,
// in which case it is not replaced on the spare_worker queue.
// This happens when the system is shutting down (see
// Schedule.c:workerStart()).
if (!isBoundTask(task) && !task->stopped) {
task->next = cap->spare_workers;
cap->spare_workers = task;
}
// Bound tasks just float around attached to their TSOs.
releaseCapability_(cap,rtsFalse);
RELEASE_LOCK(&cap->lock);
}
我们找到了!
检查区域。 spare_workers
队列看起来像是那些没有工作可做的工作线程去放松的队列。我们应该验证这是否属实:
int i;
Task *t;
for (i = 0, t = cap->spare_workers; t != NULL; t = t->next, i++) {}
debugTrace(DEUBG_sched, "there are %d spare workers", i);
确实,正如我们在上面的调试语句中看到的那样,情况确实如此:空闲工作者的数量不断增加:
54139b70: there are 199 spare workers
54139b70: passing capability 0 to worker 0x53938b70
53938b70: resuming capability 0
53938b70: cap 0: running thread 202 (ThreadRunGHC)
53938b70: cap 0: thread 202 stopped (blocked)
thread 202 @ 0xb727a400 is blocked on an MVar @ 0xb72388a8 (TSO_DIRTY)
53938b70: giving up capability 0
53938b70: there are 200 spare workers
53938b70: passing capability 0 to worker 0x53137b70
53137b70: resuming capability 0
53137b70: cap 0: running thread 203 (ThreadRunGHC)
53137b70: cap 0: thread 203 stopped (blocked)
thread 203 @ 0xb727a000 is blocked on an MVar @ 0xb72388a8 (TSO_DIRTY)
53137b70: giving up capability 0
53137b70: there are 201 spare workers
撰写解决方案。 因此,从这里的补丁很简单,因为我们已经找到了正确的位置。我们检查一下空闲工作者的队列是否在某个数量,并且如果是的话,我们不再保存自己到队列中,而是清理然后自杀:
for (i = 1; t != NULL && i < 6; t = t->next, i++) {}
if (i >= 6) {
debugTrace(DEBUG_sched, "Lots of spare workers hanging around, terminating this thread");
releaseCapability_(cap,rtsFalse);
RELEASE_LOCK(&cap->lock);
pthread_exit(NULL);
}
然后我们测试看到这确实起作用了:
ezyang@javelin:~/Dev/ghc-build/testsuite/tests/ghc-regress/ffi/should_run$ ./cleanupThreads
7
附言。这个概念验证中存在一些明显的不足。它不具备可移植性。我们需要确信这确实完成了 RTS 期望工作者执行的所有清理工作。也许我们的数据表示可以更有效(如果我们存储的值数量是固定的,我们显然不需要链表)。但这些问题最好由更了解 RTS 的人来回答,因此我目前已经提交了这个概念验证以供进一步审查。祈祷顺利!
你本可以发明分数级联:ezyang 的博客
来源:
blog.ezyang.com/2012/03/you-could-have-invented-fractional-cascading/
假设你有k个排序数组,每个数组的大小为n。你想要在每个k个数组中搜索单个元素(或其前一个元素,如果不存在)。
显然,你可以分别对每个数组进行二分搜索,结果是 ![O(k\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n") 的运行时间。但我们可能认为我们可以做得更好:毕竟,我们在每次搜索时都做了相同的搜索,也许我们可以“重复使用”第一次搜索的结果。
下面是另一种显而易见的方法:对于第一个数组中的每个元素,让我们给它一个指向第二个数组中具有相同值的元素的指针(或者如果值不存在,则指向前一个元素)。然后,一旦我们在第一个数组中找到了这个元素,我们只需按顺序跟随这些指针就可以找出这个元素在所有其他数组中的位置。
但是有一个问题:有时,这些指针对我们毫无帮助。特别是,如果后面的列表完全“处于”第一个列表的两个元素之间,我们必须重新进行整个搜索,因为指针没有给我们任何我们不已经知道的信息。
那么我们该怎么做呢?考虑k=2的情况;如果我们能保证第一个列表包含了能为第二个数组提供有用信息的正确元素,那么一切都会好转。我们可以简单地合并这些数组,但如果我们在一般情况下这样做,我们最终会得到一个大小为的完全合并数组,如果k很大,这并不理想。
但我们并不需要第二个数组的所有元素;每隔一个元素就够了!
让我们重复这样做。取最后一个数组,取每隔一个元素并合并到倒数第二个数组中。现在,对于新的倒数第二个数组,对下一个数组执行相同操作。反复这样做。第一个数组最终会有多大?你可以解决这个递归式:,这是等比级数 ![n + n/2 + n/4 + n/8 + \ldots = 2n](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/ldots = 2n")。令人惊讶的是,新的第一个列表仅增加了两倍的大小,这只是二分搜索中的一个额外步骤!
我们刚刚实现的就是分数级联!数组中的一部分级联到其他数组中。
还有一个需要注意的细节。当我沿着指针向下跟随时,我可能会落到一个实际上不是当前数组成员的元素上(它是被级联上来的)。我需要能够有效地找到下一个属于当前数组的元素(可能有许多级联元素挤在它和下一个成员元素之间,因此进行左扫描可能需要很长时间);因此,对于每个级联元素,我存储一个指向前任成员元素的指针。
分数级联是一种非常有用的转换,应用于各种上下文中,包括分层范围树和3D 正交范围搜索。实际上,它可以通过几种方式进行泛化。首先,我们可以级联某个固定的分数α的元素,而不是我们这里所做的 1/2。此外,我们不必局限于将数组列表级联起来;我们可以级联任意图形,将许多列表合并在一起,只要我们选择α小于1/d,其中d是节点的入度。
练习. 之前,我们描述了范围树。分数级联如何用于将查询复杂度减少一个因子![O(\lg n)](https://github.com/OpenDocCN/geekdoc-dl-zh/raw/master/ezyang/img/lg n)")?
练习. 实际上,我们可以通过另一种方式设置分数级联数据结构中的指针。与其为每个元素保留向下指针,不如仅在相同的元素之间维护指针(也就是说,它们被级联上来了)。当构建数据结构时,这种方式更为方便。但是,现在你需要维护另一组指针。它们是什么?(提示:考虑搜索落在非级联成员元素上的情况。)
因此,在单个节点上,我们希望快速访问父节点和子节点以及快速更新。快速访问意味着我们需要指向远离这个节点的指针,快速更新意味着我们需要消除指向这个节点的指针。
指针的流动自然地从树的根部流向叶子,像蓝莓馅饼一样容易走到一个节点的子节点。
一开始,有一个二叉树:
struct ctree { // c for children
int val;
struct ctree *left;
struct ctree *right;
}
你本可以发明拉链:ezyang's 博客
不幸的是,给定一个节点,没有好的方法找出其父节点!如果只需要高效的父节点访问,你可以简单地在另一个方向使用单个指针:
struct ptree { // p for parent
int val;
struct ptree *parent;
}
指针的流向然后从树的叶子到根部:
当然,综合起来,你可以拥有两者的最佳:
struct btree {
int val;
struct btree *parent;
struct btree *left;
struct btree *right;
}
我们的数据结构已经变成了循环的,但结果是我们有了非常高效的方法来在树上上下移动,以及插入、删除和移动节点,只需通过改变节点、其子节点和其父节点上的相关指针进行变化。
天堂出问题了。 指针技巧对于可变的情况来说没问题,但我们想要的是不可变的节点。我们想要的节点不会在我们不知情的情况下发生变化,因为其他人决定乱动指针。
对于ctree
,我们可以使用一种称为路径复制的标准做法,我们只需要更改到节点变更的路径上的节点。
实际上,路径复制只是不可变更新规则的一个具体表现:如果你替换(即更新)了某些东西,你必须递归地替换指向它的任何东西。在ptree
中,我们需要知道更新节点的子树并更改它们全部。
但是btree
失败得相当惨重:
指针的流动自然地从树的根部流向叶子,轻而易举地走到一个节点的子节点。
我们想要做的是以更智能的方式结合ptree
和ctree
,这样我们就不会得到一堆额外的指针,但我们仍然可以找到节点的子节点和父节点。
在这里,我们做出了关键的简化假设:我们只关心对单个节点的父母和子女的高效访问以及更新。快速访问意味着我们需要指向远离这个节点的指针,快速更新意味着我们需要消除指向这个节点的指针。
类别:未分类
很简单!只需翻转一些指针(显示为红色)。
恭喜!你看到的数据结构就是我们称之为拉链的东西!现在我们唯一要做的任务是弄清楚如何在struct
定义中实际编码它。在此过程中,我们将为这个图表内部的各种特性分配一些名称。
让我们考虑一个稍微复杂的例子:
我们引入了一些符号便利:三角形代表与给定节点连接的树,当我们不关心任何子节点时。正方形是附加到任何给定节点的值(我们明确显示它们,因为节点与其数据的区别很重要)。红色节点是我们想要围绕的节点,我们已经翻转了必要的指针(红色)使其他所有东西都可以访问。
当我们在这个位置时,我们可以遍历树,或者沿着指向远离绿色节点的红色箭头上升;我们将这个箭头指向的结构称为上下文。树和上下文的结合给我们在拉链中的一个位置。
struct loc {
struct ctree *tree;
struct context *context;
}
就像树一样,上下文也是一个递归数据结构。在下面的图表中,它正是被涂黑的节点。然而,它不是一个普通的节点,因为它缺少一个子节点指针,并且可能包含指向它自己父节点的指针。
这个特定位置包含的是"右上下文",也就是说,指向上下文的箭头指向右边(如下图所示为黑色)。
你可以看到,在我们的树结构中,上下文包含另一个上下文、一棵树和一个值。
同样,"左上下文"对应于指向左边的箭头。它包含相同的组件,尽管从这里的图表可能不太明显:递归子上下文在哪里?好吧,因为我们在树的顶部,所以我们有一个"顶部上下文",它不包含任何值。这相当于Nothing
的道德等价物。
enum context_type {LEFT, RIGHT, TOP}
struct context {
enum context_type type;
// below only filled for LEFT and RIGHT
int val;
struct context *context;
struct ctree *tree;
}
就是这样!你需要制作一个拉链的所有部分:
> data Tree a = Nil | Node a (Tree a) (Tree a)
> data Loc a = Loc (Tree a) (Context a)
> data Context a = Top
> | Left a (Tree a) (Context a)
> | Right a (Tree a) (Context a)
练习:
-
编写函数以向上、向左下和向右下移动我们的
Tree
定义。 -
如果我们有另一种树的定义
data Tree a = Leaf a | Branch Tree a) (Tree a)
,我们的上下文定义会如何改变? -
为链表编写数据和上下文类型。
进一步阅读: 这种模式的最初晶化可以在Huet 的论文(PDF)中找到,两个入门材料的经典来源在维基书籍和Haskell Wiki。从那里,关于类型微分如何导致拉链的迷人讨论!请参阅Conor 的论文(PDF),维基书籍文章,以及 Edward Kmett 在使用生成函数引入更多异国情调数据类型讨论的文章。