如何利用 Either 和 Option 进行函数式错误处理
前言
我将讨论Scala风格的模式匹配,但首先我需要通过Either概念建立一些背景知识。Either的其中一个用法是函数式风格的错误处理,我会在本期文章中对其进行介绍。
在Java中,错误的处理在传统上由异常以及创建和传播异常的语言支持进行。但是,如果不存在结构化异常处理又如何呢?许多函数式语言不支持异常范式,所以它们必须找到表达错误条件的替代方式。在本文中,我将演示Java中类型安全的错误处理机制,该机制绕过正常的异常传播机制(并通过FunctionalJava框架的一些示例协助说明)。
函数式错误处理
如果您想在Java中不使用异常来处理错误,最根本的障碍是语言的限制,因为方法只能返回单个值。但是,当然,方法可以返回单个Object(或子类)引用,其中可包含多个值。那么,我可以使用一个Map来启用多个返回值。请看看清单1中的divide()方法:
清单1.使用Map处理多个返回值
publicstaticMapdivide(intx,inty){ Map result=newHashMap (); if(y==0) result.put("exception",newException("divbyzero")); else result.put("answer",(double)x/y); returnresult; }
在清单1中,我创建了一个Map,以String为键,并以Object为值。在divide()方法中,我输出exception来表示失败,或者输出answer来表示成功。清单2中对两种模式都进行了测试:
清单2.使用Map测试成功与失败
@Test publicvoidmaps_success(){ Mapresult=RomanNumeralParser.divide(4,2); assertEquals(2.0,(Double)result.get("answer"),0.1); } @Test publicvoidmaps_failure(){ Map result=RomanNumeralParser.divide(4,0); assertEquals("divbyzero",((Exception)result.get("exception")).getMessage()); }
在清单2中,maps_success测试验证在返回的Map中是否存在正确的条目。maps_failure测试检查异常情况。
这种方法有一些明显的问题。首先,Map中的结果无论如何都不是类型安全的,它禁用了编译器捕获特定错误的能力。键的枚举可以略微改善这种情况,但效果不大。其次,该方法调用器并不知道方法调用是否成功,这加重了调用程序的负担,它要检查可能结果的词典。第三,没有什么能阻止这两个键都有值,这使得结果模棱两可。
我需要的是一种让我能够以类型安全的方式返回两个(或多个)值的机制。
Either类
返回两个不同值的需求经常出现在函数式语言中,用来模拟这种行为的一个常用数据结构是Either类。在Java中,我可以使用泛型创建一个简单的Either类,如清单3所示:
清单3.通过Either类返回两个(类型安全的)值
publicclassEither{ privateAleft=null; privateBright=null; privateEither(Aa,Bb){ left=a; right=b; } publicstaticEitherleft(Aa){ returnnewEither(a,null); } publicAleft(){ returnleft; } publicbooleanisLeft(){ returnleft!=null; } publicbooleanisRight(){ returnright!=null; } publicBright(){ returnright; } publicstaticEitherright(Bb){ returnnewEither(null,b); } publicvoidfold(FleftOption,FrightOption){ if(right==null) leftOption.f(left); else rightOption.f(right); } }
在清单3中,Either旨在保存一个left或right值(但从来都不会同时保存这两个值)。该数据结构被称为不相交并集。一些基于C的语言包含union数据类型,它可以保存含若干种不同类型的一个实例。不相交并集的槽可以保存两种类型,但只保存其中一种类型的一个实例。Either类有一个private构造函数,使构造成为静态方法left(Aa)或right(Bb)的责任。在类中的其他方法是辅助程序,负责检索和调研类的成员。
利用Either,我可以编写代码来返回异常或一个合法结果(但从来都不会同时返回两种结果),同时保持类型安全。常见的函数式约定是Either类的left包含异常(如有),而right包含结果。
解析罗马数字
我有一个名为RomanNumeral的类(我将其实现留给读者去想象)和一个名为RomanNumeralParser的类,该类调用RomanNumeral类。parseNumber()方法和说明性测试如清单4所示:
清单4.解析罗马数字
publicstaticEitherparseNumber(Strings){ if(!s.matches("[IVXLXCDM]+")) returnEither.left(newException("InvalidRomannumeral")); else returnEither.right(newRomanNumeral(s).toInt()); } @Test publicvoidparsing_success(){ Either result=RomanNumeralParser.parseNumber("XLII"); assertEquals(Integer.valueOf(42),result.right()); } @Test publicvoidparsing_failure(){ Either result=RomanNumeralParser.parseNumber("FOO"); assertEquals(INVALID_ROMAN_NUMERAL,result.left().getMessage()); }
在清单4中,parseNumber()方法执行一个验证(用于显示错误),将错误条件放置在Either的left中,或将结果放在它的right中。单元测试中显示了这两种情况。
比起到处传递Map,这是一个很大的改进。我保持类型安全(请注意,我可以按自己喜欢使异常尽量具体);在通过泛型的方法声明中,错误是明显的;返回的结果带有一个额外的间接级别,可以解压Either的结果(是异常还是答案)。额外的间接级别支持惰性。
惰性解析和FunctionalJava
Either类出现在许多函数式算法中,并且在函数式世界中如此之常见,以致FunctionalJava框架(参阅参考资料)也包含了一个Either实现,该实现将在清单3和清单4的示例中使用。但它的目的就是与其他FunctionalJava构造配合使用。因此,我可以结合使用Either和FunctionalJava的P1类来创建惰性错误评估。惰性表达式是一个按需执行的表达式(参阅参考资料)。
在FunctionalJava中,P1类是一个简单的包装器,包括名为_1()的方法,该方法不带任何参数。(其他变体:P2和P3等,包含多种方法。)P1在FunctionalJava中用于传递一个代码块,而不执行它,使您能够在自己选择的上下文中执行代码。
在Java中,只要您throw一个异常,异常就会被实例化。通过返回一个惰性评估的方法,我可以将异常创建推迟到以后。请看看清单5中的示例及相关测试:
清单5.使用FunctionalJava创建一个惰性解析器
publicstaticP1>parseNumberLazy(finalStrings){ if(!s.matches("[IVXLXCDM]+")) returnnewP1 >(){ publicEither _1(){ returnEither.left(newException("InvalidRomannumeral")); } }; else returnnewP1 >(){ publicEither _1(){ returnEither.right(newRomanNumeral(s).toInt()); } }; } @Test publicvoidparse_lazy(){ P1 >result=FjRomanNumeralParser.parseNumberLazy("XLII"); assertEquals((long)42,(long)result._1().right().value()); } @Test publicvoidparse_lazy_exception(){ P1 >result=FjRomanNumeralParser.parseNumberLazy("FOO"); assertTrue(result._1().isLeft()); assertEquals(INVALID_ROMAN_NUMERAL,result._1().left().value().getMessage()); }
清单5中的代码与清单4中的类似,但多了一个P1包装器。在parse_lazy测试中,我必须通过在结果上调用_1()来解压结果,该方法返回Either的right,从该返回值中,我可以检索值。在parse_lazy_exception测试中,我可以检查是否存在一个left,并且我可以解压异常,以辨别它的消息。
在您调用_1()解压Either的left之前,异常(连同其生成成本昂贵的堆栈跟踪)不会被创建。因此,异常是惰性的,让您推迟异常的构造程序的执行。
提供默认值
惰性不是使用Either进行错误处理的惟一好处。另一个好处是,您可以提供默认值。请看清单6中的代码:
清单6.提供合理的默认返回值
publicstaticEitherparseNumberDefaults(finalStrings){ if(!s.matches("[IVXLXCDM]+")) returnEither.left(newException("InvalidRomannumeral")); else{ intnumber=newRomanNumeral(s).toInt(); returnEither.right(newRomanNumeral(number>=MAX?MAX:number).toInt()); } } @Test publicvoidparse_defaults_normal(){ Either result=FjRomanNumeralParser.parseNumberDefaults("XLII"); assertEquals((long)42,(long)result.right().value()); } @Test publicvoidparse_defaults_triggered(){ Either result=FjRomanNumeralParser.parseNumberDefaults("MM"); assertEquals((long)1000,(long)result.right().value()); }
在清单6中,假设我不接受任何大于MAX的罗马数字,任何企图大于该值的数字都将被默认设置为MAX。parseNumberDefaults()方法确保默认值被放置在Either的right中。
包装异常
我也可以使用Either来包装异常,将结构化异常处理转换成函数式,如清单7所示:
清单7.捕获其他人的异常
publicstaticEitherdivide(intx,inty){ try{ returnEither.right(x/y); }catch(Exceptione){ returnEither.left(e); } } @Test publicvoidcatching_other_people_exceptions(){ Either result=FjRomanNumeralParser.divide(4,2); assertEquals((long)2,(long)result.right().value()); Either failure=FjRomanNumeralParser.divide(4,0); assertEquals("/byzero",failure.left().value().getMessage()); }
在清单7中,我尝试除法,这可能引发一个ArithmeticException。如果发生异常,我将它包装在Either的left中;否则我在right中返回结果。使用Either使您可以将传统的异常(包括检查的异常)转换成更偏向于函数式的风格。
当然,您也可以惰性包装从被调用的方法抛出的异常,如清单8所示:
清单8.惰性捕获异常
publicstaticP1>divideLazily(finalintx,finalinty){ returnnewP1 >(){ publicEither _1(){ try{ returnEither.right(x/y); }catch(Exceptione){ returnEither.left(e); } } }; } @Test publicvoidlazily_catching_other_people_exceptions(){ P1 >result=FjRomanNumeralParser.divideLazily(4,2); assertEquals((long)2,(long)result._1().right().value()); P1 >failure=FjRomanNumeralParser.divideLazily(4,0); assertEquals("/byzero",failure._1().left().value().getMessage()); }
嵌套异常
Java异常有一个不错的特性,它能够将若干种不同的潜在异常类型声明为方法签名的一部分。尽管语法越来越复杂,但Either也可以做到这一点。例如,如果我需要RomanNumeralParser上的一个方法允许我对两个罗马数字执行除法,但我需要返回两种不同的可能异常情况,那么是解析错误还是除法错误?使用标准的Java泛型,我可以嵌套异常,如清单9所示:
清单9.嵌套异常
publicstaticEither> divideRoman(finalStringx,finalStringy){ Either possibleX=parseNumber(x); Either possibleY=parseNumber(y); if(possibleX.isLeft()||possibleY.isLeft()) returnEither.left(newNumberFormatException("invalidparameter")); intintY=possibleY.right().value().intValue(); Either errorForY= Either.left(newArithmeticException("divby1")); if(intY==1) returnEither.right((fj.data.Either )errorForY); intintX=possibleX.right().value().intValue(); Either result= Either.right(newDouble((double)intX)/intY); returnEither.right(result); } @Test publicvoidtest_divide_romans_success(){ fj.data.Either >result= FjRomanNumeralParser.divideRoman("IV","II"); assertEquals(2.0,result.right().value().right().value().doubleValue(),0.1); } @Test publicvoidtest_divide_romans_number_format_error(){ Either >result= FjRomanNumeralParser.divideRoman("IVooo","II"); assertEquals("invalidparameter",result.left().value().getMessage()); } @Test publicvoidtest_divide_romans_arthmetic_exception(){ Either >result= FjRomanNumeralParser.divideRoman("IV","I"); assertEquals("divby1",result.right().value().left().value().getMessage()); }
在清单9中,divideRoman()方法首先解压从清单4的原始parseNumber()方法返回的Either。如果在这两次数字转换的任一次中发生一个异常,Eitherleft与异常一同返回。接下来,我必须解压实际的整数值,然后执行其他验证标准。罗马数字没有零的概念,所以我制定了一个规则,不允许除数为1:如果分母是1,我打包我的异常,并放置在right的left中。
换句话说,我有三个槽,按类型划分:NumberFormatException、ArithmeticException和Double。第一个Either的left保存潜在的NumberFormatException,它的right保存另一个Either。第二个Either的left包含一个潜在的ArithmeticException,它的right包含有效载荷,即结果。因此,为了得到实际的答案,我必须遍历result.right().value().right().value().doubleValue()!显然,这种方法的实用性迅速瓦解,但它确实提供了一个类型安全的方式,将异常嵌套为类签名的一部分。
Option类
Either是一个方便的概念,在下期文章中,我将使用这个概念构建树形数据结构。Scala中有一个名为Option的类与之类似,该类在FunctionalJava中被复制,提供了一个更简单的异常情况:none表示不合法的值,some表示成功返回。Option如清单10所示:
清单10.使用Option
publicstaticOptiondivide(doublex,doubley){ if(y==0) returnOption.none(); returnOption.some(x/y); } @Test publicvoidoption_test_success(){ Optionresult=FjRomanNumeralParser.divide(4.0,2); assertEquals(2.0,(Double)result.some(),0.1); } @Test publicvoidoption_test_failure(){ Optionresult=FjRomanNumeralParser.divide(4.0,0); assertEquals(Option.none(),result); }
如清单10所示,Option包含none()或some(),类似于Either中的left和right,但特定于可能没有合法返回值的方法。
FunctionalJava中的Either和Option都是单体,表示计算的特殊数据结构,在函数式语言中大量使用。在下一期中,我将探讨有关Either的单体概念,并在不同的示例中演示它如何支持Scala风格的模式匹配。
结束语
当您学习一种新范式时,您需要重新考虑所有熟悉的问题解决方式。函数式编程使用不同的习惯用语来报告错误条件,其中大部分可以在Java中复制,不可否认,也有一些令人费解的语法。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。