函数式思维: 利用 Either 和 Option 进行函数式错误处理

类型安全的函数式异常

Java™ 开发人员习惯于通过抛出和捕获异常来处理错误,然而,这不符合函数式范式。在本期 函数式思维 的文章中,将探讨在以函数方式显示 Java 错误的同时仍保留类型安全的方法,说明如何通过函数式返回来包装经过检查的异常,并介绍一个方便的抽象,其名称为 Either

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2012 年 7 月 09 日

关于本系列

本系列的目标是重新定向您对函数式思维的认识,帮助您以全新的方式看待常见问题,并提升您的日常编码能力。本系列文章将探讨函数式编程概念、允许在 Java 语言中进行函数式编程的框架、在 JVM 上运行的函数式编程语言,以及语言设计的未来方向。本系列面向那些了解 Java 及其抽象工作原理,但对使用函数语言不甚了解的开发人员。

当您研究函数式编程等深奥学科时,令人着迷的分支偶尔会出现。在 函数式思维:函数设计模式,第 3 部分 中,我在迷你系列中继续以函数的方式重新思考传统的 Gang of Four 设计模式。在下一期文章中,我将回到这一主题,讨论 Scala 风格的模式匹配,但首先我需要通过 Either 概念建立一些背景知识。Either 的其中一个用法是函数式风格的错误处理,我会在本期文章中对其进行介绍。当您了解 Either 可以对错误施展的魔法之后,我将在下期文章中转向模式匹配和树。

在 Java 中,错误的处理在传统上由异常以及创建和传播异常的语言支持进行。但是,如果不存在结构化异常处理又如何呢?许多函数式语言不支持异常范式,所以它们必须找到表达错误条件的替代方式。在本文中,我将演示 Java 中类型安全的错误处理机制,该机制绕过正常的异常传播机制(并通过 Functional Java 框架的一些示例协助说明)。

函数式错误处理

如果您想在 Java 中不使用异常来处理错误,最根本的障碍是语言的限制,因为方法只能返回单个值。但是,当然,方法可以 返回单个 Object(或子类)引用,其中可包含多个值。那么,我可以使用一个 Map 来启用多个返回值。请看看清单 1 中的 divide() 方法:

清单 1. 使用 Map 处理多个返回值
public static Map<String, Object> divide(int x, int y) {
    Map<String, Object> result = new HashMap<String, Object>();
    if (y == 0)
        result.put("exception", new Exception("div by zero"));
    else
        result.put("answer", (double) x / y);
    return result;
}

清单 1 中,我创建了一个 Map,以 String 为键,并以 Object 为值。在 divide() 方法中,我输出 exception 来表示失败,或者输出 answer 来表示成功。清单 2 中对两种模式都进行了测试:

清单 2. 使用 Map 测试成功与失败
@Test
public void maps_success() {
    Map<String, Object> result = RomanNumeralParser.divide(4, 2);
    assertEquals(2.0, (Double) result.get("answer"), 0.1);
}

@Test
public void maps_failure() {
    Map<String, Object> result = RomanNumeralParser.divide(4, 0);
    assertEquals("div by zero", ((Exception) result.get("exception")).getMessage());
}

清单 2 中,maps_success 测试验证在返回的 Map 中是否存在正确的条目。maps_failure 测试检查异常情况。

这种方法有一些明显的问题。首先,Map 中的结果无论如何都不是类型安全的,它禁用了编译器捕获特定错误的能力。键的枚举可以略微改善这种情况,但效果不大。其次,该方法调用器并不知道方法调用是否成功,这加重了调用程序的负担,它要检查可能结果的词典。第三,没有什么能阻止这两个键都有值,这使得结果模棱两可。

我需要的是一种让我能够以类型安全的方式返回两个(或多个)值的机制。


Either

返回两个不同值的需求经常出现在函数式语言中,用来模拟这种行为的一个常用数据结构是 Either 类。在 Java 中,我可以使用泛型创建一个简单的 Either 类,如清单 3 所示:

清单 3. 通过 Either 类返回两个(类型安全的)值
public class Either<A,B> {
    private A left = null;
    private B right = null;

    private Either(A a,B b) {
        left = a;
        right = b;
    }

    public static <A,B> Either<A,B> left(A a) {
        return new Either<A,B>(a,null);
    }

    public A left() {
        return left;
    }

    public boolean isLeft() {
        return left != null;
    }

    public boolean isRight() {
        return right != null;
    }

    public B right() {
        return right;
    }

    public static <A,B> Either<A,B> right(B b) {
        return new Either<A,B>(null,b);
    }

   public void fold(F<A> leftOption, F<B> rightOption) {
        if(right == null)
            leftOption.f(left);
        else
            rightOption.f(right);
    }
}

清单 3中,Either 旨在保存一个 leftright 值(但从来都不会同时保存这两个值)。该数据结构被称为不相交并集。一些基于 C 的语言包含 union 数据类型,它可以保存含若干种不同类型的一个实例。不相交并集的槽可以保存两种类型,但只保存其中一种类型的一个实例。Either 类有一个 private 构造函数,使构造成为静态方法 left(A a)right(B b) 的责任。在类中的其他方法是辅助程序,负责检索和调研类的成员。

利用 Either,我可以编写代码来返回异常 一个合法结果(但从来都不会同时返回两种结果),同时保持类型安全。常见的函数式约定是 Either 类的 left 包含异常(如有),而 right 包含结果。

解析罗马数字

我有一个名为 RomanNumeral 的类(我将其实现​​留给读者去想象)和一个名为 RomanNumeralParser 的类,该类调用 RomanNumeral 类。parseNumber() 方法和说明性测试如清单 4 所示:

清单 4. 解析罗马数字
public static Either<Exception, Integer> parseNumber(String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return Either.left(new Exception("Invalid Roman numeral"));
    else
        return Either.right(new RomanNumeral(s).toInt());
}

@Test
public void parsing_success() {
    Either<Exception, Integer> result = RomanNumeralParser.parseNumber("XLII");
    assertEquals(Integer.valueOf(42), result.right());
}

@Test
public void parsing_failure() {
    Either<Exception, Integer> result = RomanNumeralParser.parseNumber("FOO");
    assertEquals(INVALID_ROMAN_NUMERAL, result.left().getMessage());
}

清单 4 中,parseNumber() 方法执行一个验证(用于显示错误),将错误条件放置在 Eitherleft 中,或将结果放在它的 right中。单元测试中显示了这两种情况。

比起到处传递 Map,这是一个很大的改进。我保持类型安全(请注意,我可以按自己喜欢使异常尽量具体);在通过泛型的方法声明中,错误是明显的;返回的结果带有一个额外的间接级别,可以解压 Either 的结果(是异常还是答案)。额外的间接级别支持惰性

惰性解析和 Functional Java

Either 类出现在许多函数式算法中,并且在函数式世界中如此之常见,以致 Functional Java 框架(参阅 参考资料)也包含了一个 Either 实现,该实现将在 清单 3清单 4 的示例中使用。但它的目的就是与其他 Functional Java 构造配合使用。因此,我可以结合使用 Either 和 Functional Java 的 P1 类来创建惰性 错误评估。惰性表达式是一个按需执行的表达式(参阅 参考资料)。

在 Functional Java 中,P1 类是一个简单的包装器,包括名为 _1() 的方法,该方法不带任何参数。(其他变体:P2P3 等,包含多种方法。)P1 在 Functional Java 中用于传递一个代码块,而不执行它,使您能够在自己选择的上下文中执行代码。

在 Java 中,只要您 throw 一个异常,异常就会被实例化。通过返回一个惰性评估的方法,我可以将异常创建推迟到以后。请看看清单 5 中的示例及相关测试:

清单 5. 使用 Functional Java 创建一个惰性解析器
public static P1<Either<Exception, Integer>> parseNumberLazy(final String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return new P1<Either<Exception, Integer>>() {
            public Either<Exception, Integer> _1() {
                return Either.left(new Exception("Invalid Roman numeral"));
            }
        };
    else
        return new P1<Either<Exception, Integer>>() {
            public Either<Exception, Integer> _1() {
                return Either.right(new RomanNumeral(s).toInt());
            }
        };
}

@Test
public void parse_lazy() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.parseNumberLazy("XLII");
    assertEquals((long) 42, (long) result._1().right().value());
}

@Test
public void parse_lazy_exception() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.parseNumberLazy("FOO");
    assertTrue(result._1().isLeft());
    assertEquals(INVALID_ROMAN_NUMERAL, result._1().left().value().getMessage());
}

清单 5 中的代码与 清单 4 中的类似,但多了一个 P1 包装器。在 parse_lazy 测试中,我必须通过在结果上调用 _1() 来解压结果,该方法返回 Eitherright,从该返回值中,我可以检索值。在 parse_lazy_exception 测试中,我可以检查是否存在一个 left,并且我可以解压异常,以辨别它的消息。

在您调用 _1() 解压 Eitherleft 之前,异常(连同其生成成本昂贵的堆栈跟踪)不会被创建。因此,异常是惰性的,让您推迟异常的构造程序的执行。

提供默认值

惰性不是使用 Either 进行错误处理的惟一好处。另一个好处是,您可以提供默认值。请看清单 6 中的代码:

清单 6. 提供合理的默认返回值
public static Either<Exception, Integer> parseNumberDefaults(final String s) {
    if (! s.matches("[IVXLXCDM]+"))
        return Either.left(new Exception("Invalid Roman numeral"));
    else {
        int number = new RomanNumeral(s).toInt();
        return Either.right(new RomanNumeral(number >= MAX ? MAX : number).toInt());
    }
}

@Test
public void parse_defaults_normal() {
    Either<Exception, Integer> result = FjRomanNumeralParser.parseNumberDefaults("XLII");
    assertEquals((long) 42, (long) result.right().value());
}

@Test
public void parse_defaults_triggered() {
    Either<Exception, Integer> result = FjRomanNumeralParser.parseNumberDefaults("MM");
    assertEquals((long) 1000, (long) result.right().value());
}

清单 6 中,假设我不接受任何大于 MAX 的罗马数字,任何企图大于该值的数字都将被默认设置为 MAXparseNumberDefaults() 方法确保默认值被放置在 Eitherright 中。

包装异常

我也可以使用 Either 来包装异常,将结构化异常处理转换成函数式,如清单 7 所示:

清单 7. 捕获其他人的异常
public static Either<Exception, Integer> divide(int x, int y) {
    try {
        return Either.right(x / y);
    } catch (Exception e) {
        return Either.left(e);
    }
}

@Test
public void catching_other_people_exceptions() {
    Either<Exception, Integer> result = FjRomanNumeralParser.divide(4, 2);
    assertEquals((long) 2, (long) result.right().value());
    Either<Exception, Integer> failure = FjRomanNumeralParser.divide(4, 0);
    assertEquals("/ by zero", failure.left().value().getMessage());
}

清单 7 中,我尝试除法,这可能引发一个 ArithmeticException。如果发生异常,我将它包装在 Eitherleft 中;否则我在 right 中返回结果。使用 Either 使您可以将传统的异常(包括检查的异常)转换成更偏向于函数式的风格。

当然,您也可以惰性包装从被调用的方法抛出的异常,如清单 8 所示:

清单 8. 惰性捕获异常
public static P1<Either<Exception, Integer>> divideLazily(final int x, final int y) {
    return new P1<Either<Exception, Integer>>() {
        public Either<Exception, Integer> _1() {
            try {
                return Either.right(x / y);
            } catch (Exception e) {
                return Either.left(e);
            }
        }
    };
}

@Test
public void lazily_catching_other_people_exceptions() {
    P1<Either<Exception, Integer>> result = FjRomanNumeralParser.divideLazily(4, 2);
    assertEquals((long) 2, (long) result._1().right().value());
    P1<Either<Exception, Integer>> failure = FjRomanNumeralParser.divideLazily(4, 0);
    assertEquals("/ by zero", failure._1().left().value().getMessage());
}

嵌套异常

Java 异常有一个不错的特性,它能够将若干种不同的潜在异常类型声明为方法签名的一部分。尽管语法越来越复杂,但 Either 也可以做到这一点。例如,如果我需要 RomanNumeralParser 上的一个方法允许我对两个罗马数字执行除法,但我需要返回两种不同的可能异常情况,那么是解析错误还是除法错误?使用标准的 Java 泛型,我可以嵌套异常,如清单 9 所示:

清单 9. 嵌套异常
public static Either<NumberFormatException, Either<ArithmeticException, Double>> 
        divideRoman(final String x, final String y) {
    Either<Exception, Integer> possibleX = parseNumber(x);
    Either<Exception, Integer> possibleY = parseNumber(y);
    if (possibleX.isLeft() || possibleY.isLeft())
        return Either.left(new NumberFormatException("invalid parameter"));
    int intY = possibleY.right().value().intValue();
    Either<ArithmeticException, Double> errorForY = 
            Either.left(new ArithmeticException("div by 1"));
    if (intY == 1)
        return Either.right((fj.data.Either<ArithmeticException, Double>) errorForY);
    int intX = possibleX.right().value().intValue();
    Either<ArithmeticException, Double> result = 
            Either.right(new Double((double) intX) / intY);
    return Either.right(result);
}

@Test
public void test_divide_romans_success() {
    fj.data.Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IV", "II");
    assertEquals(2.0,result.right().value().right().value().doubleValue(), 0.1);
}

@Test

public void test_divide_romans_number_format_error() {
    Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IVooo", "II");
    assertEquals("invalid parameter", result.left().value().getMessage());
}

@Test
public void test_divide_romans_arthmetic_exception() {
    Either<NumberFormatException, Either<ArithmeticException, Double>> result = 
        FjRomanNumeralParser.divideRoman("IV", "I");
    assertEquals("div by 1", result.right().value().left().value().getMessage());
}

清单 9 中,divideRoman() 方法首先解压从 清单 4 的原始 parseNumber() 方法返回的 Either。如果在这两次数字转换的任一次中发生一个异常,Either left 与异常一同返回。接下来,我必须解压实际的整数值,然后执行其他验证标准。罗马数字没有零的概念,所以我制定了一个规则,不允许除数为 1:如果分母是 1,我打包我的异常,并放置在 rightleft 中。

换句话说,我有三个槽,按类型划分:NumberFormatExceptionArithmeticExceptionDouble。第一个 Eitherleft 保存潜在的 NumberFormatException,它的 right 保存另一个 Either。第二个 Eitherleft 包含一个潜在的 ArithmeticException,它的 right 包含有效载荷,即结果。因此,为了得到实际的答案,我必须遍历 result.right().value().right().value().doubleValue()!显然,这种方法的实用性迅速瓦解,但它确实提供了一个类型安全的方式,将异常嵌套为类签名的一部分。


Option

Either 是一个方便的概念,在下期文章中,我将使用这个概念构建树形数据结构。Scala 中有一个名为 Option 的类与之类似,该类在 Functional Java 中被复制,提供了一个更简单的异常情况:none 表示不合法的值,some 表示成功返回。Option 如清单 10 所示:

清单 10. 使用 Option
public static Option<Double> divide(double x, double y) {
    if (y == 0)
        return Option.none();
    return Option.some(x / y);
}

@Test
public void option_test_success() {
    Option result = FjRomanNumeralParser.divide(4.0, 2);
    assertEquals(2.0, (Double) result.some(), 0.1);
}

@Test
public void option_test_failure() {
    Option result = FjRomanNumeralParser.divide(4.0, 0);
    assertEquals(Option.none(), result);

}

清单 10 所示,Option 包含 none()some(),类似于 Either 中的 leftright,但特定于可能没有合法返回值的方法。

Functional Java 中的 EitherOption 都是单体,表示计算 的特殊数据结构,在函数式语言中大量使用。在下一期中,我将探讨有关 Either 的单体概念,并在不同的示例中演示它如何支持 Scala 风格的模式匹配。


结束语

当您学习一种新范式时,您需要重新考虑所有熟悉的问题解决方式。函数式编程使用不同的习惯用语来报告错误条件,其中大部分可以在 Java 中复制,不可否认,也有一些令人费解的语法。

在下一期中,我将深入探讨单体,讨论这个迷人的概念的一些用法,并展示如何使用低级的 Either 构建树。

参考资料

学习

  • The Productive Programmer (Neal Ford,O'Reilly Media,2008 年):Neal Ford 的新书讨论了帮助您提高编码效率的工具和实践。
  • Functional Java:Functional Java 框架为 Java 增添了许多函数式的语言构造。
  • Lazy evaluation:了解有关该表达式评估战略的更多信息。
  • Monads:单体,传说中非常难的一个函数式语言主题,将在本系列将来的文章中进行介绍。
  • Scala:Scala 是一种现代函数编程语言,适用于 JVM。
  • 浏览 技术书店,阅读有关这些主题和其他技术主题的图书。
  • 有关 Throwing Away Throws 的博客,尤其是本系列文章,提供了本期文章的灵感和源材料。
  • developerWorks 中国网站 Java 技术专区:查找数百篇关于 Java 编程各个方面的文章。

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


这是您第一次登陆到 developerWorks,已经自动为您创建了您的概要文件。 选择您概要文件中可以公开的信息的信息(如姓名、国家/地区,以及公司),这些信息同时也会与您所发布的内容相关联。 您可以随时更新您的 IBM 账号。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=824586
ArticleTitle=函数式思维: 利用 Either 和 Option 进行函数式错误处理
publish-date=07092012