Julia 语言可重用性高竟源于缺陷和不完美?
【编者按】关于Julia编程语言,最值得注意的最大优势之一就是程序包的编写方式。你几乎总是可以在自己的软件中重用他人的类型或方法,而不会出现问题。
通常来说,从高层角度来说,对于所有编程语言而言这是正确的,因为这就是库该有的样子。但是,经验丰富的软件工程师通常会指出,在实践中很难把从一个项目中获取东西在不进行任何改动的情况下完全照搬到另一个项目中,做到这一点很难。但是,在Julia生态系统中,似乎可以做到这一点。
作者 | Lyndon White
译者&责编 | 夕颜
出品 | CSDN(CSDNnews)
这篇文章将探讨一下其中原因的理论,以及对未来语言设计者的一些建议。本文基于作者受邀在2020 F(by)会议上发表的演讲,且部分内容受到了Stefan Karpinski在JuliaCon 2019上发表的《多重调度的不合理有效性》的启发。
以下为译文:
我说的可组合是什么意思?
例子:
如果要将跟踪测量误差添加到标量数字,则无需赘述新类型与数组的交互方式(Measurements.jl) 如果你有一个微分方程求解器和一个神经网络库,那么你应该只能够得到神经ODE(DifferentialEquations.jl / Flux.jl) 如果你有一个程序包可以为数组的尺寸添加名称,并且可以在GPU上添加名称,那么你不必编写代码即可在GPU上命名数组(NamedDims.jl / CUArrays.jl)
Julia为什么是这样的?
我的理论是,Julia代码之所以可重用性高,这不仅是因为该语言具有一些强大的功能,而且还因为其弱点或缺失的特定特征。
它缺少以下功能:
有关名称空间干扰的规则不完善 从来没有尝试使其易于使用包外部的本地模块 类型系统无法用于检查正确性
但是这些缺陷被其他功能抵消或放大了其他功能:
与他人交流的习惯 非常容易创建包 结合鸭子类型和多次调度
以有漏洞的方式使用Julia命名空间
在大多数语言社区中,从另一个模块加载代码时,常见的建议是:仅导入所需的内容。例如使用Foo:a,b c
而在Julia中,通常的做法是:使用Foo,它将导入Foo作者标记为要导出的所有内容。
你不必这样做,但这很普遍。
但是,如果有一对软件包会发生什么:
Foo导出预测(:: FooModel,数据) Bar导出预测(::BarModel,数据),
一个会:
using Foo
using Bar
training_data, test_data = ...
mbar = BarModel(training_data)
mfoo = FooModel(training_data)
evaluate(predict(mbar), test_data)
evaluate(predict(mfoo), test_data)
如果你有多次尝试将using同一个名称纳入范围,那么Julia会抛出错误,因为它无法确定使用哪个名称。
作为用户,你可以告诉它使用什么。
evaluate(Bar.predict(mbar), test_data)
evaluate(Foo.predict(mfoo), test_data)
但是软件包作者可以解决此问题:
如果两个重载的名称都来自同一名称空间,则不会发生名称冲突。
如果Foo和Bar都在重载StatsBase.predict,则一切正常。
using StatsBase # exports predict
using Foo # overloads `StatsBase.predict(::FooModel)
using Bar # overloads `StatsBase.predict(::BarModel)
training_data, test_data = ...
mbar = BarModel(training_data)
mfoo = FooModel(training_data)
evaluate(predict(mbar), test_data)
evaluate(predict(mfoo), test_data)
这鼓励人们协同工作。
名称冲突促使程序包作者聚在一起,创建基本程序包(如StatsBase),并就功能的含义达成一致。
他们不是必须这样做,因为用户仍然可以解决它,但这鼓励了实践。因此,我们让软件包作者思考如何将其他软件包与他们的软件包一起使用。
包作者甚至可以根据需要从多个名称空间重载函数。例如MLJBase.predict,StatsBase.predict,SkLearn.predict的全部。针对不同用例的接口可能都略有不同。
创建软件包比本地模块更容易
许多语言每个文件都有对应的一个模块,你可以加载该模块,例如通过从当前目录导入文件名。
你也可以在Julia进行这项工作,但这对精准度的要求出奇的高。
但是,还有一个更简单的方法,就是创建和使用程序包。
制作本地模块通常会给你带来什么?
命名空间 你做了一个很棒的软件工程,很有成就感 以后更容易过渡到软件包
做一个Julia包装会给你带来什么?
以上所有并加上 标准目录结构 托管依赖项,最新和以往的版本 易于重新分配——难以获得本地状态 可使用套件管理员的pkg> test MyPackage测试
推荐的创建包的方法还可以确保:
持续集成设置 代码覆盖率 文档设置 许可证集
测试Julia代码很重要。
Julia使用的是JIT编译器,因此即使编译错误也要等到运行时才能出现。作为一种动态语言,类型系统很少指出正确性如何。
测试Julia代码很重要。如果测试中未涵盖代码路径,那么Julia语言本身几乎没有任何措施可以保护它们免受任何类型错误的拖累。
因此,设置持续集成和其他此类工具非常重要。
琐碎的包装创建很重要
许多创建Julia软件包的人都不是传统的软件开发人员。例如很大一部分是学术研究人员。那些不认为自己是“开发人员”的人不太愿意采取措施将其代码打包。
说起来,许多Julia软件包的作者不乏忙着完成下一篇论文的研究生。许多科学代码永远不会被发布,而其中许多代码根本不会被其他人使用。但是,如果他们开始编写一个程序包(而不是只能在他们的脚本中运行的本地模块),那么离发布已经更近好几步了。一旦成为软件包,人们便开始像软件包作者一样思考,并开始考虑如何使用它。
这不是灵丹妙药,但它可以把你向正确的方向推一把。
多次分派+鸭式输入
假设它走路像鸭子,说话像鸭子,但不能解决这个问题。
Julia鸭式输入与多次发送相结合的方式非常简洁。它使我们能够支持任何满足函数所期望的隐式接口的对象(鸭式输入);同时也有机会将其作为特殊情况处理(多次分派)。以完全可扩展的方式。
这与Julia缺乏静态类型系统有关。静态类型系统的好处来自确保在编译时满足接口。这在很大程度上与鸭式输入不兼容。(不过,在该空间中还有其他有趣的选项,例如结构化键入。)
本节中的示例将用来说明如何进行鸭式输入和多次发送,使表达具有可组合性。
我们想使用库中的一些代码
假如我可能有一个来自Ducks库的类型。
输入:
struct Duck end
walk(self) = println("🚶 Waddle")
talk(self) = println("🦆 Quack")
raise_young(self, child) = println("🐤 ➡️ 💧 Lead to water")
我想要运行一些代码并写入:
function simulate_farm(adult_animals, baby_animals)
for animal in adult_animals
walk(animal)
talk(animal)
end
# choose the first adult and make it the parent for all the baby_animals
parent = first(adult_animals)
for child in baby_animals
raise_young(parent, child)
end
end
试试:3只发育成熟的鸭子,2个小鸭子:输入:
simulate_farm([Duck(), Duck(), Duck()], [Duck(), Duck()])
输出:
🚶 Waddle
🦆 Quack
🚶 Waddle
🦆 Quack
🚶 Waddle
🦆 Quack
🐤 ➡️ 💧 Lead to water
🐤 ➡️ 💧 Lead to water
很好,成功运行了。
好了现在我想用它拓展自己的类型。一只天鹅
输入:
struct Swan end
首先用1只来测试:
simulate_farm([Swan()], [])
输出:
🚶 Waddle
🦆 Quack
天鹅是可以蹒跚而行了,但是却没叫。
我们做了一些鸭式输入——天鹅走路像鸭子,但它们却不像鸭子叫唤。
我们可以通过单分派解决。
talk(self::Swan) = println("🦢 Hiss")
输入:
simulate_farm([Swan()], [])
输出:
🚶 Waddle
🦢 Hiss
好了,现在我们试着用一整个农场的天鹅来写入:
输入:
simulate_farm([Swan(), Swan(), Swan()], [Swan(), Swan()])
输出:
🚶 蹒跚而行
🦢 嘶鸣
🚶 蹒跚而行
🦢 嘶鸣
🚶 蹒跚而行
🦢 嘶鸣
🐤 ➡️ 💧 领着下水
🐤 ➡️ 💧 领着下水
有点不对劲。天鹅不会领着它们的孩子入水,而是驮着它们。
我们依然可以通过单分派解决这个问题。
raise_young(self::Swan, child::Swan) = println("🐤 ↗️ 🦢 Carry on back")
再试一次:
输入:
simulate_farm([Swan(), Swan(), Swan()], [Swan(), Swan()])
输出:
🚶 蹒跚而行
🦢 嘶鸣
🚶 蹒跚而行
🦢 嘶鸣
🚶 蹒跚而行
🦢 嘶鸣
🐤 ↗️ 🦢 驮在背上
🐤 ↗️ 🦢 驮在背上
现在,我想得到农场中有多种家禽的结果。
2只鸭子,1只天鹅和2只小天鹅
输入:
simulate_farm([Duck(), Duck(), Swan()], [Swan(), Swan()])
输出:
🚶 Waddle
🦆 Quack
🚶 Waddle
🦆 Quack
🚶 Waddle
🦢 Hiss
🐤 ➡️ 💧 Lead to water
🐤 ➡️ 💧 Lead to water
又不对了。
🐤 ➡️ 💧 Lead to water
发生了什么?
我们有一只鸭子在抚养一只小天鹅,它把小天鹅引到水里。
如果你了解饲养家禽的知识,那么就会知道:给小天鹅喂鸭子的鸭子将放弃小鸭子。
但是我们将如何编码呢?
选择1:重写鸭子
function raise_young(self::Duck, child::Any)
if child isa Swan
println("🐤😢 Abandon")
else
println("🐤 ➡️ 💧 Lead to water")
end
end
但是重写鸭子还有问题
必须编辑其他的资料库,以添加对我的类型的支持。 这可能意味着要添加许多代码以供他们维护。 不能拓展,如果其他人想要添加鸡、鹅等,该怎么办?
变体:猴子补丁
如果该语言支持猴子补丁,则可以这样做。 但这意味着将他们的代码复制到我的库中会遇到无法更新的问题。 由于不再是要复制的主要规范来源,因此与其他人扩展时添加新类型的情况更糟。
变体:可以分叉他们的代码
那就是放弃代码重用。
设计模式
设计模式允许人们模仿一种语言所没有的功能。例如,鸭子可能允许一个人为给定的小动物记录行为,这基本上是即席运行时多重调度。但这将需要以这种方式重写Duck。
选项2:从鸭子继承
(注意:此示例是无效的Julia代码)
struct DuckWithSwanSupport <: Duck end
function raise_young(self::DuckWithSwanSupport, child::Any)
if child isa Swan
println("🐤😢 Abandon")
else
raise_young(upcast(Duck, self), child)
end
end
从鸭子继承也有问题:
必须用DuckWithSwanSupport替换我代码库中的每个Duck 如果我正在使用其他可能返回Duck的库,我也必须处理 有一些设计模式可以提供帮助,例如使用“依赖注入”来控制如何创建所有Duck。但现在必须重写所有库才能使用它。
仍然无法扩展:
如果其他人实现了DuckWithChickenSupport,并且我想同时使用他们的代码和我的代码,该怎么办?
两者都继承?DuckWithChickenAndSwan支持 这是经典的多继承钻石问题。 这个很难(即使在支持多重继承的语言中,如果我没有为许多事情写特殊案例,他们也可能无法以一种有用的方式来支持它。
选项3:多次派送
这很简单:
尝试一下:
raise_young(parent::Duck, child::Swan) = println("🐤😢 Abandon")
输入:
simulate_farm([Duck(), Duck(), Swan()], [Swan(), Swan()])
输出:
🚶蹒跚而行
🦆嘎嘎
🚶蹒跚
🦆嘎嘎
🚶蹒跚
🦢嘶嘶声
🐤😢放弃
🐤😢放弃
是否有现实世界中的多次派送用例?
事实证明是有的。
在科学计算中,一直需要扩展操作以对新的类型组合进行操作。我怀疑它很常见,但是我们已经学会了忽略它。
如果查看BLAS方法列表,你将仅看到此编码在函数名称中,例如:
SGEMM-矩阵矩阵乘法 SSYMM-对称矩阵矩阵乘法 … ZHBMV-复杂的Hermitian带状矩阵向量乘法
事实证明,人们一直希望发明越来越多的矩阵类型。
块矩阵 带状矩阵 块带状矩阵(其中带由块组成) 带状块带状矩阵(频带由自身带状的块组成)。
在此之前,你可能想对矩阵进行其他操作,并希望对其进行编码:
在GPU上运行 AutoDiff的跟踪操作 命名尺寸,便于查找 在群集上分布
这些都很重要,并在关键应用程序中使用。当你开始进行跨学科应用时,它们的出现频率会更高。就像神经微分方程的进步一样,需要:
机器学习研究已经发明了所有类型 所有类型的微分方程求解研究都已具备
并希望将它们一起使用。
因此,对于数字语言来说,列举你可能需要的所有矩阵类型并不合理。
手动干预JIT
跟踪JIT的基本功能:
通过跟踪发现重要案例 为它们编译定制的方法
这称为定制化。
Julia的JIT的基本功能:
在调用它们的所有类型上定制化所有方法
这非常好:合理地假设类型将成为重要案例。
在Julia的JIT之上,多重调度又增加了什么?
它让人们分辨应该如何进行定制化,里面可以添加很多信息。
思考下矩阵乘法。
我们有
将问题扔给BLAS或GPU,任何人都可以进行基本的快速阵列处理。
但是并不是每个人都有标量类型参数化的数组类型,以及在两者中都具有同样速度的能力。
没有这个,就无法解开数组代码和标量代码。
例如BLAS就没有此功能,它对标量和矩阵的每种组合都有唯一的代码。
通过这种分离,可以添加新的标量类型:
双数 测量误差跟踪编号 符号代数
无需改变数组代码,除非是后期优化。
否则,就需要在标量中实现数组支持,以实现合理的性能。
发明新的语言真的很棒!
人们需要发明新的语言。现在是发明新语言的好时机。这对世界有益,也对我有益,因为我喜欢很棒的新鲜事物。
我真的很喜欢那些新语言能够有以下特征:
多次派发至: 允许通过单独包中需要的任何特殊情况进行扩展。(例如,鸭子会把小鸭子带到水里,但会放弃小天鹅) 包括允许添加领域知识(如矩阵乘法示例) 公开类型: 因此你可以在包中为在另一个包中声明的类型/函数创建新方法 在类型级别按标量类型参数化的数组类型 这样就不必为提高性能而捣鼓数组代码和标量代码。 每个人都使用的内置包管理解决方案。 因为这可以提供一致的工具,并对软件标准产生乘法效应。 像Julia社区中的每个人一样,编写测试并使用CI。 不要直接跳到每个文件1个命名空间,要隔离所有东西。 命名空间冲突不要太糟糕 名称空间的价值是什么这一点值得思考:超出“名称空间是一个很棒的主意——让我们做更多的事情!”
原文链接:
https://white.ucc.asn.au/2020/02/09/whycompositionalJulia.html
本文为CSDN翻译文章,转载请注明出处。
猛戳“阅读原文”,填写中国远程办公-调查问卷