聚利亚朗反模式

2020-06-05 00:31:48

反模式是解决总体上使事情变得更糟的问题的常见解决方案。这篇博客文章旨在强调Julia代码中常见的几个反模式。我怀疑这在很大程度上是由于其他语言带来的包袱,这些语言不是反模式,但实际上是好的模式。这篇文章是为了澄清事情。

在这些问题上,有些人会不同意我的观点。没关系,他们可以犯错(😂I jest)。引用普赖斯、特科尔斯基、威廷和弗兰纳里的“数字处方”的引言:

因此,我们会尽可能地向您提供我们的实际判断。随着您积累经验,您将形成您自己的看法,即我们的建议是多么可靠。请放心,它不是完美的!

这体现在定义其他人应该实现的API的包中。在这里,人们想要声明和记录API实现者应该重载的函数。在这个反模式中,一个人声明一个抽象类型,并声明一个以该抽象类型为参数(通常是第一个参数)的函数。声明的函数只是抛出一个错误,指出该函数不是为该类型实现的。

其逻辑是,如果有人只实现了一半的API,用户会收到这个“有用的”错误消息。

抽象类型AbstractModel end";";";Probability_Estimate(model,Observation::AbstractArray)::Real对于给定的`model`,返回`Observation`发生的可能性。";";";函数Probability_Estiate(model::AbstractModel,Observation::AbstractArray)错误(";;$(typeof(Model))";没有实现`Probability_Estiate`)。:AbstractModel只是猜测的模型。即使是经过深思熟虑的猜测也不行。只是随机猜测。";";";struct GuessingModel<;:AbstractModel end Probability_Estiate(Guesser::GuessingModel,Observation::AbstractMatrix)=rand()。

发生了什么?看起来GuessingModel确实实现了PRABILITY_ESTIMATE方法。错误根本不能帮助我们看到错误的地方。敏锐的读者会看到错误的地方:GuessingModel()被错误地实现为只对AbstractMatrix起作用,但它是用Vector调用的,所以它退回到AbstractModel的泛型方法。但是错误消息的信息量不是很大(甚至可以说是不正确的)。如果我们深入到程序的深处,我们将不知道发生了什么,但它是用Vector调用的,所以它退回到AbstractModel的泛型方法。但是错误消息的信息量不是很大(甚至可以说是不正确的)。如果我们深入到程序的深处,我们将不知道发生了什么,

只是不要实现您不想实现的东西。MethodError很好地表明了这一点,并且如图所示,它提供了比您要编写的更详细的错误消息。

一个经常被忽略的特性是,您可以在不提供任何方法的情况下声明一个函数。这是为您期望被重载的函数添加文档的理想方式。这是通过函数PRABILITY_ESTIMATE END完成的。如图所示。(使用PRABILITY_ESTIMATEAT2显示应该如何正确完成)。

";";";Probability_Estiate2(model,Observation::Vector)::Real对于给定的`model`,返回`Observation`发生的可能性。";";";函数Probability_Estiate2 End Probability_Estiate2(Guesser::GuessingModel,Observation::AbstractMatrix)=rand()。

!MATCHED,表示候选项中的哪个参数不匹配。在REPL中,为清楚起见,这将显示为红色。

这不是这篇博客文章的主题,而是顺便提一下:在这种情况下,定义另一个包将实现的接口时,可以提供一个测试套件来测试它是否正确实现。这是一个他们可以在测试中调用的函数,至少可以检查他们是否有基本的权利。这可以取代正式的接口(Julia没有),以确保合同得到满足。

正在为$(typeof(Model))";BEGIN#.@Testset";BEGIN#.@Testset";BEGIN p=PRABILITY_ESTIMATE(MODEL,[1,2,3])@TEST p Isa Real@test 0<;=p<;=1 end#使用测试函数model_testSuite@Testset";=p<;=1 end#.。End end model_testSuite(GuessingModel())。

PRABILITY_ESTIMATE:在[11]中测试时出错:5在@TEST.测试外出现异常.测试摘要:|用于GuessingModel的Error TotalModel API测试套件|1 1 PRBILITY_ESTIMATE|1 1。

(前面注意:这不是关于使用@inbound、@simd、@fast ath之类的宏。这个反模式是关于编写您自己的宏,而不是编写函数。)。

宏的主要用途不是性能,而是允许语法转换。例如,@view xs[4:end]被转换为view(xs,4:lastindex(Xs)):函数无法完成end的转换。

然而,有时人们使用宏时认为它们比函数更快。我认为这一次主要来自90年代学习C语言的人,或者90年代学习C语言的人教的C语言,但没有跟上编译器的现状。回到90年代,确保函数内联的方法是编写宏而不是函数。所以偶尔人们会建议使用宏来执行简单的函数。例如,这个堆栈溢出问题。

这方面最惊人的例子是简单的数字操作,它利用编译时未知的东西。

编写函数而不是编写宏。如有必要,可以使用@inline修饰器宏来鼓励函数内联。通常不需要这样做,如下例所示:

熟悉性和可读性:大多数人在编写第一个宏之前已经使用Julia相当长一段时间(甚至几年)了;但是第一天就在那里编写函数。

可扩展性:添加更多分派非常容易(在我们的区域示例中,这只是一个小的重构,而不是成形)。对于宏,唯一可以调度的是AST组件:文字、符号或表达式。

使用时的可理解性:所有函数的行为基本相同,宏可以有很大不同,例如,一些参数可能必须是文字,一些参数可能会复制函数调用,如果传入,其他参数可能不会,等等。在大多数情况下,作为用户,我更喜欢调用函数而不是宏。

如果某些东西确实对性能非常关键,并且在解析时有相当多的信息(例如,文字)是编译器无法利用的(例如,通过不以常量折叠它们的方式),那么您可以尝试宏(或生成的函数),但绝对要确保在前后对其进行基准测试。

@btime COMPUTE_POLY(1,(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17))

宏COMPUTE_POLY(x,coeffs_tuple)#a*x^i元。isexpr(coeffs_tuple,:tuple)||ArgumentError(";@COMPUTE_POLY只接受元组文字作为第二个参数";)coeffs=coeffs_tuple。参数Terms=map(枚举(Coeffs))do(i,a)a=coeffs[i],如果ISA编号&;&;x Isa number#它是编译时的文字计算a*x^(i-1)Else#一个表达式,因此返回表达式Esc(:($a*$x^$(i-1))End if all(X Isa Number In X)#整个东西可以在编译时运行返回sum(Terms),否则返回expr(:call,:+,Terms.)。终点终点。

@btime@COMPUTE_POLY(1,(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17))。

dicts或hashmap是一种奇妙的数据结构,具有预期的O(1)设置和获取。然而,它们并不是适用于所有场合的工具。撇开它们作为数据结构的优缺点和对给定应用程序的适用性不谈,我经常看到它们被过度用作简单的容器。我看到了dict{符号}或dict{String}的相当多的使用,它们只是保存一组固定的变量,因为人们想要将它们组合在一起。比如配置设置,我看到有相当多的人使用dict{符号}或dict{String},它们只是保存一组固定的变量,因为人们想要将它们组合在一起。比如配置设置,或者模型超参数。在Julia0.7dict之前,如果人们不愿意声明一个结构,可以说它是Base中最好的对象。它有两个问题:引入可变状态和性能。

可变状态是不好的,原因有几个。主流语言从函数式语言引入的一件事是,在可能的情况下避免状态。首先,这很难推理,因为人们在跟踪逻辑的同时需要记住它的状态,并注意该状态可能发生变化的地方,如果在for循环过程中这是可以的,但如果它是像我提到的大多数设置那样的全局状态,这就不那么好了。第二,它的可变性和可能性是你一开始就不希望它发生变异。如果在for循环过程中这是可以的,但如果它是全局的,就不那么好了,就像我提到的大多数设置一样。第二,它的可变性和可能性是你一开始就不希望它发生变化。如果。如果它们在字典中,我必须相信没有人犯了错误并覆盖了那些设置。使用不可变的数据结构可以避免这种情况。

下面是一个Dic值的例子。它突出显示虽然获取一个值所需的时间是O(1),即常数时间,但该常数不是微小的。常数时间隐藏在O(1)中,它需要计算散列。符号的散列是相当快的,因为字符串稍大一些。对于更复杂的对象,散列可能非常大,通常大于等于设计一个好的散列函数是困难的。但是我们在这个反模式中看到的情况大多是dict{symbol}或dict{string},但是我们在这个反模式中看到的情况大多是dict{symbol}或dict{string}。

str_dict=dict(string(K)=>;v for(k,v)in dict)#将所有密钥转换为字符串@btime$(Str_Dict)[";d";];

一种替代方案是OrderedCollection的LittleDic.这是一对天真的基于列表的字典,预期时间为O(N),但每个实际元素的成本要低得多,因为它不需要对任何内容进行散列。因此,对于较小的集合,它可以比DICT更快。(如果您有少量的键,并且它们不是硬编码的文字,这是一个不错的选择)。

通过Freeze(或通过使用元组构造它),它也以不可变的形式出现

对于如何表示这样一个固定的变量集合,真正的解决方案是NamedTuple.这就是它的全部目的.它还有其他很好的特性,比如能够编写nt.d作为NT[:D]的替代,以及它像元组一样拆分的方式,这有利于解包.但最重要的是,它解决了我们的两个问题:易变性和性能.它是不变的,不可能有更好的性能.。

索引到命名元组的好处在于常量折叠可以完全消除索引操作,它在编译时解析,并将结果直接编译到编译输出中。

name_tuple=(;dict.)#创建与dict@btime$(Name_Tuple)[:d]内容相同的NamedTuple;

如果有人在想“我有一些常量,我想对它们进行分组”,那么只需看看NamedTuple就可以了。这是正确的答案。当您在编写代码时不知道所有的键,并且/或者需要更新值时,Dict是最好的。

我将以一个粗体声明开始:Julia中的类型约束仅用于分派。如果一个函数没有多个方法,则不需要任何类型约束。如果必须添加类型约束(用于分派),请尽可能松散。我将在以下各节中证明这一声明的合理性。

注意:虽然类型约束只用于分派,但这并不意味着不能用于其他事情,而且实际上甚至可以成功地用于其他事情。但是类型约束的目的并没有改变是为了分派:毕竟我可以用锤子作为纸重,但锤子的用途仍然是锤子钉钉子。

我认为参数类型的过度约束主要来自三个方面:相信它会使代码更快(FALSE)、更安全(大部分是FALSE)或更容易理解(TRUE,但…)。前两个来自不同的语言,不像朱莉娅。关于易懂的最后一点是绝对正确的,但在大多数情况下不值得这样做。还有更好的方法。

认为添加类型约束会使代码更快的想法来自对Julia编译器工作的误解。Julia的JIT编译器在不考虑类型约束的情况下使代码变得更快(除了一些有限的例外)。专门化是一些其他语言如何使用类型注释来提高性能,但Julia一直(而且是及时地)应用这种技术。Julia JIT的标准操作是将每个函数专门化于所有参数的类型(而不是方法的类型约束,参数的类型)。这意味着它生成更适合特定类型的不同机器代码。这包括诸如删除此类型不能满足的分支、静态分派以及实际上比正常动态语言可能使用的更好的CPU指令。人们可以通过比较@code_type((X)->;2x)(1.0)vs@code_type((X)->;2x)(0x1)。一些语言,例如Cython,使用类型注释确实变得快得多,因为它们没有在输入类型发生时专门化每个函数的JIT。它们提前生成代码,所以要么必须处理所有情况(如果没有指定),要么可以针对特定情况(如果指定)进行优化。在Julia中,为函数生成的代码将在有或没有类型约束的情况下一样快。另一个可能的原因与其他语言无关,因为这是误解了这一部分。在Julia中,为函数生成的代码将在有或没有类型约束的情况下同样快。另一个可能的原因与其他语言无关,因为这是误解了这一部分

增加类型约束使代码更安全的信念来自类型安全的思想。静态类型提前编译语言的一个巨大优势是在编译时使用类型系统捕捉和报告程序员错误并查找违反的约束的能力。Julia不是这些语言中的一种,它不是静态类型的,所以关于类型的推理永远只能是部分的,并且Julia不是提前编译的,所以Julia不是这些语言中的一种,所以关于类型的推理永远只能是部分的,并且Julia不是提前编译的,所以Julia不是这些语言之一,所以它不是静态类型的,所以关于类型的推理永远只能是部分的,Julia没有提前编译,因此,在代码以任何方式执行之前都不会报告任何错误。Julia一开始也没有正式的接口或契约断言的概念。在鸭子类型如何允许更简单的合规性方面,这一缺陷确实有一个很好的优势-通过假设它可以工作,并且只实现不工作的部分。请参阅我之前关于这一点的帖子。当您做一些不受支持的事情时,错误最终会被抛出。有时,对于最终用户来说,早期的MethodError可能比代码中更深层的方法错误更清楚,但代价是放弃鸭子类型吗?这很少是值得的。(注:不是更安全(因为没有编译时错误),而是更清楚。)如果您这样做了,至少要确保您的松散程度是正确的,这样您就可以接受您所能接受的一切。请参阅下面的示例,以获得特别正确的结果。

我确实认为最后一个原因是为了便于理解。在函数参数上插入类型约束使它们更容易理解。添加类型约束可以澄清代码,考虑Apply_Internal(f::function,c::Vector{<;:Vector})VS Apply_INTER(f,c)。但是,我们有其他工具来阐明这一点。首先,我们有更好的名称,例如Apply_INTER(func,list_of_list)。以及文档:我们可以也应该在大多数函数上放置文档字符串。但我有时确实承认这一点,特别是在遵循特定代码库的规范时。我有时会添加类型约束,因为我觉得它确实澄清了很多事情(它总是可以在以后删除)。虽然我尽量不让代码丢失,并使用其他工具使我的代码清晰。其他工具,如文档和注释,不会遇到下面示例中提到的问题。

我认为当人们对没有断言他们的输入是可迭代的感到不舒服时,这是很常见的,因为Julia没有一个类型来表示是可迭代的。但这实际上不是问题,因为当您尝试迭代它时会发生MethodError-不需要先发制人。

函数My_Average(xs::AbstractVector)len=0 Total=零(eltype(Xs)),对于xs中的x,len+=1 Total+=x End Return Total/len End。

哪里出了问题?有一些有用的迭代器不对子类型AbstractVector子类型,通过Collect(Itr)将它们转换为AbstractVector会分配不必要的内存,这是我们在高性能代码中努力避免的。

完全不要约束参数,这样它就可以接受迭代器。特别是在这种情况下,制作可以使用迭代器的函数是非常值得的。有时这可能意味着添加第二个方法,一个手动优化的处理数组的方法,以及一个更通用的(没有约束的)迭代器方法。

您只需要极少数情况下才需要这样做,因为如果类型仅用于分派,则必须同时调度AbstractVector{<;:Real}和一些其他备选方案,如AbstractVector{<;:AbstractString}或普通AbstractVector。而且,根据元素类型需要不同的实现通常很奇怪。(确实会发生这种情况,但您基本上需要实现性能优化。)。

如果x[ii]<;x[ind]ind=ii end返回ind end,则使用BenchmarkTools函数indmin(x::AbstractVector{<;:real})ind=1表示每个索引(X)中的II

这里可能会出错的是,有很多种元素类型可能只包含实数,但没有将其作为类型参数。

例如,如果数据曾经包含缺失值(在数据科学中很常见),但您以某种方式将其过滤掉,则数组仍将为UNION{MISSING,T}类型。

如果你的函数要求元素是Real,那么你很快就会调用一些没有在你给它的元素类型上定义的函数,然后就会MethodError。在这种情况下,它是indmin(data::array)。特别是如果您已经有一个完全通用的indmin(Data)for迭代器。

但这对不包含构造函数的子类型Function的可调用对象不起作用。

有人可能认为函数和构造函数不是可以使用是Union{Type,function}的Base.Callable,而是函数和构造函数。然而,这只是同一反模式的较小版本,它仍然会错过其他可调用对象,如DiffEqBase.ODESolution和Flos.Chain。

处理的正确方法是不约束可调用参数,就像迭代器一样,没有必要抢占在尝试调用不可调用对象时抛出的MethodError。

我还看过其他几个例子,但不足以举一反三。一般说来,人们不应该急急忙忙地说:

DenseArray它不是稀疏数组的补码。AbstractArray有很多子类型,其中大多数不是明显稀疏的,也不是DenseArray的子类型。具体而言,可以包装密集或稀疏的包装器数组类型不会将其子类型化,例如对称

dataType:这将排除UNION和UnionAll的类型,因此除非这是目标,否则请改用Type。

在编写Julia代码时,这些是要避免的事情。以下是我没有包括的其他一些事情--有很多方法可以编写不太理想的代码。我可能会在将来写一篇后续文章,讨论更多的事情,比如使用软件包,而不是子模块(一些优点在前面的帖子中提到)。或者,也许还有一篇关于代码味道的文章,比如使用if x Isa T(它可能暗示使用多个分派的地方)。希望这篇文章有助于选择更好的模式。

遵循风格指南:我遵循BlueStyle(虽然我不同意每一个选择,但一致性更重要)。

是否练习持续交付。

..