抽象初学者指南

2020-07-05 10:07:00

安德鲁·亨特(Andrew Hunt)和大卫·托马斯(David Thomas)在“务实程序员”一书中介绍了Dry(不要重复自己)原则。其基本原理是,如果您看到相同的代码复制+粘贴了10次,您可能应该将该代码放入它自己的方法/类中。

抽象很难定义,但这个过程通常是这样的:1)您标识了您认为基本上都在做相同事情的不同代码块2)您创建了一个具有窄接口的方法或类,该方法或类可以替换您找到的所有代码块3)您可以通过调用您的方法/类来交换这些代码块。

当很长一段时间过去时,您将知道您已经进行了正确的抽象,并且不需要扩展接口(扩展接口的一个示例是添加可选的标志参数)。您也会知道,当另一个开发人员没有发现要理解代码在给定用例中的行为方式时,您已经进行了正确的抽象,这并不比如果有人编写代码来满足没有抽象的用例要难得多。

过了一段时间,当接口扩展到支持各种可选标志(每个用于不同的用例)时,您就会知道自己做了错误的抽象,并且您需要成为天才来推断代码对于给定用例的实际作用。顺便说一句,如果您有一个字符串arg,该字符串arg只被馈送到方法内的switch语句中,并且对于每个新的用例,您都会为它提供一个新的接受值,那么您就是在扩展隐式接口,即使您的类型系统中没有捕捉到这个事实。

完美的抽象和完全错误的抽象之间有很多灰色地带,所以这一节的重点不是规定你应该抽象多少,而是鼓励你从这两个角度思考,并能够在公关评论中提出理由,说明为什么你认为抽象应该/不应该存在。

考虑到不可能每次都对抽象做出正确的决定,您可能要么是过度抽象的人,要么是抽象不足的人。

如果对PR审查的常见反馈是您应该干掉您的代码,那么在提交PR之前扫描一下重复的代码并考虑它是否属于自己的方法/类可能会对您有所裨益。

如果您经常得到反馈说您的方法很难理解,因为它们同时支持太多的用例,那么您就过度抽象了,应该考虑是否应该提高对重复的容忍度。

请注意,这并不总是像抽象不足和抽象过度那么简单。有时抽象是合适的,但是您可能采取了错误的方法。如果团队认为抽象是错误的,这并不意味着没有抽象一定是最好的选择。

您可能抽象不足的主要迹象是,您有一堆代码在一堆地方做着完全相同的事情,没有任何明显的理由会让这些代码分道扬镳。

#sphere半径为11sphere_volume=4*Math::Pi/3*11**3put";球体体积为#{sphere_volume}cm^3";.Radius=Calculate_Radiusvolume=4*Math::Pi/3*RADIUS**3sphere e.volume=volume。

def sphere_volume(Radius)4*Math::Pi/3*Radius**3end#sphere的半径为11sphere_volume=sphere_volume(11)put";球体的体积为#{sphere_volume}cm^3";.Radius=Calculate_Radiusvolume=sphere_volume(Radius)sphere e.volume=volume。

为什么把球体体积的公式抽象成它自己的方法是个好主意?因为如果数学家发现他们把公式弄错了,你会想要检查代码中使用公式的所有地方,并将其更新为正确的。也就是说,我们提前知道我们希望代码步调一致。

您重新抽象化的主要标志是您的方法接受一系列可选的参数:

如果arr.any?(&;:nil?),则定义平均值(arr,type=Integer,IGNORE_NULLS=FALSE)。If Ignore_nulls arr=arr.Compact否则,如果type==string arr=arr.map(&;:to_i)end arr.sum/arr.sizeendput Average([1,2,3])=>;2put Average([';1';,';2';,';3';],String)=>;2put Average([';1&。,nil],String,true)=>;2put Average([1,2,3,nil],Integer,False)=>;nil。

如果您想知道在处理没有nil值的字符串数组时Average方法的行为,那么在到达与您的用例无关的代码之前,您必须通读第一个If条件,该条件与您的用例无关。同样,如果您想知道当数组包含Nils或整数时Average方法的行为,第二个If条件是不相关的,但是您仍然需要通读该条件才能理解整个过程。

如果每个用例出现几十次或数百次,那么保留抽象可能是有意义的,但是当可选参数的数量大致等于不同用例的数量时,您很可能得到了错误的抽象。

def Average(Arr)arr.sum/arr.sizeendput Average([1,2,3])=>;2arr=[';1';,';2';,';3';].map(&;:to_i)放置Average(Arr)=>;2arr=[';1';,';2';,';3&。:to_i)如果ar.any?(&;:nil?)则将Average(Arr)=>;2arr=[1,2,3,nil]放入平均值(Arr)=>;2arr=[1,2,3,nil]。看跌期权平均值(Arr)结束=>;nil。

在这种情况下,我们不会完全删除抽象:我们只是保留实际适用于所有情况的部分。现在,理解我们的平均方法的任何一次调用的逻辑是微不足道的。

我们现在已经复制了.map(&;:to_i),虽然它只在Bad Alternative中出现了一次,但是对于一个巨大的改进来说,这只是一个很小的成本。

请注意,看一看好的变体,很明显每个用例的行为都有很大的不同,但在坏的变体中这一点就完全不清楚了,因为方法调用看起来都很简单,而且每个用例平均应用了多少代码,这是任何人都不知道的。

这就是抽象随着时间的推移变得糟糕的原因:因为随着接口的扩展越来越多,判断抽象对于任何给定用例的适用性变得越来越困难,而开发人员最终会假设所有那些错综复杂的代码都与大多数用例隐约相关,而实际上并非如此。

类形状定义初始化(RADIUS:NIL,WIDTH:NIL,TYPE:)@RADIUS=RADIUS@WIDTH=WIDTH@type=type end def Area case@type When:Square@width**2When:Circle(@RADIUS**2)*Math::PI End Def周长Case@type When:Square@width*4 When:Circle@RADIUS*2*Math::PI End Def Diameter Case@type When:Square nil When:Circle@RADIUS*2 End End Square=Shape.。100圆形=形状。新建(类型::圆形,半径:10)圆形。面积=>;314.159。

类圆定义初始化(半径:零)@Radius=半径端点定义区域(@Radius**2)*数学::PI端点定义周长@半径*2*数学::PI端点定义直径@半径*2端点类正方形定义初始化(宽度:零)@宽度=宽度端点定义区域(@宽度**2)端点定义周长@宽度*4端点广场=Square.new(宽度:10)square.area=>;100 Circle=

仅仅因为正方形和圆形都有面积和周长,并不意味着它们应该是同一类,当我们获得这两样东西的方式几乎没有重叠的时候。同样,直径法只适用于圆形,不适用于正方形。在类型化语言中,我们可能希望定义一个包含面积/周长方法的Shape接口,但是没有很好的理由组合这些类。

Const SuccessNotice=({message})=>;<;Box color=";green";>;{`Success:${message}`}<;/Box>;Const WarningNotice=({message})=>;<;Box color=";黄色";>;{`Warning:${message}`}<;/Box>;cong。{`错误:${message}`}<;/Box>;.<;SuccessNotice message=";您赢了!";/>;

持续通知=({type,message})=>;{switch(Type){case';Success';:Return<;Box color=";green";>;{`Success:${message}`}<;/Box>;case';Warning';:return<;Box color=";Huang";>;{`Warning:${message}`}&。Box color=";red";>;{`Error:${message}`}<;/Box>;}return null}.<;通知类型=";成功";message=";您赢了!";/>;

常量通知地图={成功:{color:{color:';green';,前缀:';成功:';},警告:{color:';黄色';,前缀:';警告:';},错误:{color:';red';,前缀:';error:';},}const ReallyAbstractedNotice=({type,message})=&。{Const Options=NotieMap[TYPE]Return<;Box color={options.color}>;{`${options.prefix}${message}`}<;/Box>;}.<;ReallyAbstractedNotice=";Success";Message=";您赢了!";/>;

选项2比选项1有什么优势?没什么。唯一可能的好处是,我们实际上可能会收到类型和消息属性,并且需要将其映射到正确的JSX,但是我们通常在编译时知道我们正在处理的是错误消息还是成功消息。如果在编译时,我们已经知道需要显示错误通知,为什么要强迫程序员向通知组件传递任意类型的道具呢?这只是增加了复杂性,而这并不是我们想要的行为所固有的。

选项2认识到我们的各种用例共享一个接口,但随后除了用Switch语句将它们塞进组件之外,什么也做不了。选项2犯了与我们在Shape类的示例中看到的相同的错误;认为具有相似接口的类一定都应该在同一个类中。

选项3采用了选项2的做法,并通过将用例之间的差异提取到单个映射对象中来证明抽象是合理的,这样相似性就不需要重复。它标识了我们三个通知之间的唯一区别是框的颜色和前缀消息。

这个问题的答案归根结底取决于我们的信心,即新的用例将符合当前的行为。如果我们需要添加新的信息变量,选项3会更简单,因为您只需在NoteeMap对象中添加颜色/前缀即可。

另一方面,如果我们希望成功案例在一定时间后消失,但错误和警告案例需要用户确认,该怎么办呢?当每个箱子都是它自己的组件时,易于处理:当它都被包装成一个时,就不那么容易处理了。如果我们不需要错误的前缀,因为它们通常都有自己的前缀,那该怎么办?这意味着NotieMap中的前缀键现在只适用于3个用例中的2个。如果我们想要向Box组件传递更多道具(如果我们正在处理警告),该怎么办?我们需要在NoteeMap对象中添加一个新选项,并在其他情况下具有不传递该道具的逻辑。

在这种情况下,只需选择选项1。如果随着时间的推移,这些事情确实是步调一致地发展,那么切换到选项3要容易得多,但我们不能在一开始就知道这一点。

对于我们想要编码的任何行为,都需要一定程度的内在复杂性。然而,我们最终往往会编写远比这复杂得多的代码。我们应该一直在问,这种抽象是使代码变得更复杂还是不那么复杂?而且,即使抽象在理论上不那么复杂,它的认知是否足够简单,足以让另一个人理解呢?

如果您的抽象在三个用例中工作得很好,但是不太适合第四个用例,那么在扩展接口时要非常小心。有了足够多的新用例,您可能会发现,与没有抽象相比,抽象使生活变得更加困难。

如果您遇到只在三个地方使用并且已经有两个可选标志的抽象,不要害怕简单地将其代码复制并粘贴回调用它的地方,然后在每个地方,去掉与用例无关的代码(请参见上面的Average方法)。您将只剩下一些重复的代码,但是正如Sandi Metz所说,复制比错误的抽象便宜。准备好在公关审查中为这一决定辩护。

如果您找到两个文件,每个文件共享一个代码块,但是您不确定您是否希望代码总是步调一致地发展,那么不要抽象。抽象要比去抽象容易得多,所以等到更多的用例出现,你会更好地了解一个好的界面可能是什么样子的时候,再去做也没什么坏处。

如果您有一个代码块,您认为应该为将来的用例进行抽象,那么请为公关评审中的回击做好准备。您有责任解释为什么这种抽象是有意义的,因为在代码库中几乎没有证据表明它为什么应该存在。

不同的人需要在这里听到不同的信息。也许您的代码不够干练。也许你抓住机会太容易抽象化,这会让你头疼不已。也许你已经取得了很好的平衡。不管你站在什么位置,重要的是要知道,在围绕抽象的大多数争论中,没有明确的正确答案。只要你能考虑多抽象化、少抽象化和不同抽象化的利弊,并且在公关评审中把你的情况说清楚,你就应该没问题。快乐的编码!