类型操作:行业诀窍: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_2, y_2), \cdots (x_n, y_n)")。我们如何构建范围树?我们首先为 x 坐标建立一个平衡二叉搜索树(用蓝色标出)。我们可以通过使用您喜欢的排序算法对列表进行排序,然后从中构建 BBST 来完成此操作;但是,我们可以直接使用具有中位数查找的快速排序来直接构建树,如下图所示左侧。
一旦我们按照 x 坐标排序完毕,我们现在需要重新按照每个 x 子树的 y 坐标(用红色标出)进行排序,排序结果将存储在我们将在 x 子树内部存储的另一棵树中。现在,我们可以从头开始对每个列表进行排序,但是由于对于任何节点,我们正在计算其子节点的 y 排序树,我们可以像归并排序那样将它们合并在一起,如上图所示的右侧。(这就是 中的 -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 范围直接对应于一个子树。然而,一般情况下,我们只需要检查 ") 个子树。
查询时间为 "),这是很容易理解的(因为我们可能需要在
") 棵树上执行一维范围查询,每次查询花费
") 的时间)。或许不太明显的是,这种方案只占用
") 的空间。此外,我们实际上可以通过一种称为分数级联的技巧将查询时间降低到
")。但这是另一篇博文!
可视化可满足性、有效性和蕴涵性: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 和使用状态单子之间的差异:
-
迭代器可以分叉并在不同线程上运行,同时保持本地状态的隔离。
-
旧的迭代器状态副本可以保留,并稍后恢复,作为一种回溯的形式(用新的输入替换坏的输入)。
这些保证在简单的可变引用情况下是不可能的。然而,有一个重要的警告,即虽然迭代器的纯组件很容易被撤销,但我们无法撤销在单子中执行的任何破坏性副作用。在分叉的情况下,这意味着任何副作用必须是原子的;在回溯的情况下,我们必须能够回滚副作用。据我所知,撰写利用这种风格的迭代器的艺术并没有得到很好的研究,但在我看来,这是值得探讨的。最后,我要指出,新导管背后的一个论点是,纯度对支持大多数流处理并不重要。在我看来,这个问题还有待解决。