封面来源:碧蓝航线 愚者的天平 活动 CG
参考链接:
1. 定义与概念 
Primitives,即原语,是系统或语言中最基础、不可再分的构建块(building blocks)。原语是直接由底层实现提供的原子操作或数据类型,无法通过其他更简单的元素组合而成。在编程语言中,原语是编译器直接支持的数据类型或操作,比如整数 int、字符 char、布尔 boolean,以及基本的算术操作、比较操作等。
Combinator,即组合子,特指在计算中不引用任何自由变量的函数。它本身不直接实现功能,而是通过组合已有的组件来构建更复杂的行为。所有的变量都是作为参数传递给它的,组合子是不依赖外部环境的纯函数。组合子的概念最早由美国数学家 Haskell Brooks Curry 在 20 世纪 20 年代提出,用于研究函数和变量的基本性质。
Combinator Pattern 中使用的原语是域(domain)中最简单的元素,比如整数域中的加法和乘法。Combinator Pattern 提供了将原语和已经组合成的结构组合成更复杂结构的手段,比如组合乘法和加法。
原语是「基础」,组合子是「胶水」:
原语提供原子能力,是构建一起的起点。 
组合子通过组合这些原语(或更低阶的组合子)实现复杂的逻辑。 
 
在 Java 中,组合子的概念并不常见,因为 Java 是一门面向对象的编程语言,而组合子更多的是在函数式编程语言中使用。Java 8 中引入了 Lambda 表达式和函数式接口,在 Java 中也能使用一些函数式编程的概念,也包括组合子。
2. 校验用户信息 
2.1 使用组合子校验 
假设现在需要校验用户信息,当用户的 name 不为空,并且 email 中包含 @ 符号,那这个用户信息就是合法的。最直接的做法是在 User 类中添加校验的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public  record  User (String name, int  age, String email)  {    public  boolean  isValid ()  {         return  nameIsNotEmpty() && eMailContainsAtSign();     }     private  boolean  nameIsNotEmpty ()  {         return  !name.trim().isEmpty();     }     private  boolean  eMailContainsAtSign ()  {         return  email.contains("@" );     } } 
 
1 new  User ("mofan" , 22 , "cy.mofan@foxmail.com" ).isValid(); 
 
这看起来很不错,但也有一些缺点。当校验规则发生变化时应该怎么办?
比如在某些案例中,要求用户的年龄要大于 16。在这种情况下,首先能想到的解决方式是修改 isValid() 方法,但这似乎违背了 SRP(单一职责原则,Single Responsibility Principle)。
在重构并把校验逻辑提取到新类之前,需要静下心来好好想想到底要表达什么。
当且仅当给定的一组校验规则得到满足时,用户信息才是有效的。每个校验规则描述了用户必须具备的属性。不同的规则可以组合成更复杂的规则。
执行一组校验规则时会产生一个校验结果,假设用布尔值 true 表示用户信息是有效的,以函数式编程的方式来编码,可以得到以下代码:
1 2 3 4 5 6 public  interface  UserValidation  extends  Function <User, Boolean> {}UserValidation  nameIsNotEmpty  =  user -> !user.name.trim().isEmpty();UserValidation  eMailContainsAtSign  =  user -> user.email.contains("@" );User  mofan  =  new  User ("mofan" , 22 , "cy.mofan@foxmail.com" );nameIsNotEmpty.apply(mofan) && eMailContainsAtSing.apply(mofan);  
 
这里有些样板代码:
为什么要自己创建 nameIsNotEmpty 和 eMailContainsAtSign 规则? 
为什么要使用 && 手动合并结果? 
为什么有 1 行应用代码却有 4 行初始化代码? 
 
尽管这段代码有些冗长,但它已经包含了构建 Combinator Pattern 的所有要素: nameIsNotEmpty 和 eMailContainsAtSign 是原语,&& 是组合子。使用 Java 8 可以将这些要素移动到 UserValidation 类中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 interface  UserValidation  extends  Function <User, Boolean> {    static  UserValidation nameIsNotEmpty ()  {         return  user -> !user.name.trim().isEmpty();     }     static  UserValidation eMailContainsAtSign ()  {         return  user -> user.email.contains("@" );     }     default  UserValidation and (UserValidation other)  {         return  user -> this .apply(user) && other.apply(user);     } } User  mofan  =  new  User ("mofan" , 22 , "cy.mofan@foxmail.com" );nameIsNotEmpty().and(eMailContainsAtSign()).apply(mofan);  
 
在上述代码中,原语被转换为静态方法,组合子被转换为 default 方法。nameIsNotEmpty 和 eMailContainsAtSign 两个原语都返回了以 UserValidation 为目标类型的 Lambda 表达式,与原语不同的是,组合子使用 && 操作符来组合 UserValidation。
采用上述这种方式来验证用户信息有两个优点:
原语在组合时没用立即被应用,这意味着需要先构建 UserValidation 然后再执行它。使用这种方式,能够将 UserValidation 作用于更多需要被校验的用户; 
UserValidation 是 无状态的 ,它能够在并行环境下执行,而不会出现竞争。 
 
2.2 完善校验结果 
将 Boolean 类型作为校验结果是合理的吗?
一般来说,内置语言类型通常不适合用来表示域(domain)信息。原因有两点:
在 Java 中,很难修改不属于自己的代码。这就像不能在 Boolean 类型中添加一个 query() 方法,这使得很难确定究竟是哪些规则让用户信息校验不过。简单来说,一个 Boolean 类型没法表示出更多的细节信息,比如用户信息校验失败时,究竟是哪些规则导致的失败呢。 
内置类型的语义是隐式的、特定与上下文的。例如在校验用户信息的上下文中,true 表示用户信息是有效的。如果把这个校验结果传递给其他上下文时,这个假设就可能会不成立。 
 
针对当前场景而言,需要一个新的校验结果类型 ValidationResult,它包含了在特定规则下,用户信息无效的原因。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public  interface  ValidationResult  {         boolean  isValid () ;          Optional<String> getReason () ;          static  ValidationResult valid ()  {         return  ValidationSupport.valid();     }          static  ValidationResult invalid (String reason)  {         return  new  Invalid (reason);     } } 
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class  Invalid  implements  ValidationResult  {    private  final  String reason;     public  Invalid (String reason)  {         this .reason = reason;     }     @Override      public  boolean  isValid ()  {         return  false ;     }     @Override      public  Optional<String> getReason ()  {         return  Optional.of(this .reason);     } } 
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 final  class  ValidationSupport  {    private  static  final  ValidationResult  valid  =  new  ValidationResult () {         @Override          public  boolean  isValid ()  {             return  true ;         }         @Override          public  Optional<String> getReason ()  {             return  Optional.empty();         }     };     public  static  ValidationResult valid ()  {         return  valid;     } } 
 
校验结果要么是有效,要么是无效。ValidationResult 实例的相等性体现在类型和属性上。如果要两个 Invalid 相等,那么它们的 reason 属性值也要相等。
针对校验成功的场景,使用 ValidationSupport.valid() 方法返回 ValidationResult 单例 。
为了使用 ValidationResult,先前的 UserValidation 必须要调整:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public  interface  UserValidation  extends  Function <User, ValidationResult> {    static  UserValidation nameIsNotEmpty ()  {         return  holds(user -> !StringUtils.trim(user.name()).isEmpty(), "Name is empty." );     }     static  UserValidation eMailContainsAtSign ()  {         return  holds(user -> StringUtils.contains(user.email(), "@" ), "Missing @-sign." );     }     private  static  UserValidation holds (Predicate<User> predicate, String reason)  {         return  user -> predicate.test(user)                 ? ValidationResult.valid()                 : ValidationResult.invalid(reason);     }     default  UserValidation and (UserValidation other)  {         return  user -> {             ValidationResult  res  =  this .apply(user);             return  res.isValid() ? other.apply(user) : res;         };     } } 
 
1 2 3 4 5 6 7 8 9 @Test public  void  testValidateUser ()  {    UserValidation  validation  =  UserValidation.nameIsNotEmpty().and(UserValidation.eMailContainsAtSign());     User  emptyNameUser  =  new  User ("" , 22 , "mail@xx.com" );     ValidationResult  result  =  validation.apply(emptyNameUser);     assertThat(result.isValid()).isFalse();     assertThat(result.getReason()).isPresent().hasValue("Name is empty." ); } 
 
原语 holds() 接收给定的 Predicate 参数值,返回单例 valid 或新实例 Invalid。原语 nameIsNotEmpty() 和 eMailContainsAtSign() 通过重构后不再返回布尔值,而是 UserValidation 实例。
组合子 and() 只在当前校验结果是单例 valid 时,才会计算传入的 other 对象。以测试方法 testValidateUser() 为例,如果 nameIsNotEmpty() 返回的是 Invalid 实例,将不会执行 eMailContainsAtSign()。从开发人员的角度来看,与返回布尔值相比,返回 UserValidation 能够传递更多的信息。
这种设计也有一些缺陷,在某些情况下可能需要包含所有规则的校验结果,但由于组合子 and() 是短路的,无法实现这样的功能,此时需要一个组合子 all() 来执行所有校验规则,它的签名会是这样的:
1 2 3 static  UserValidation all (UserValidation... validations)  {     } 
 
3. 查找目标行 
3.1 需求分析 
给定一个多行文本 text(以换行符 \n 分割的普通字符串),然后获取多行文本中符合某些规则的行(最终返回 List<String>)。
对于一行文本,应用在其上的规则通常只有两种:
但根据这两种规则能够派生出许多复杂的规则,比如:
包含字符串 A 或字符串 B,但不包含字符串 C 
包含字符串 A,但不包含字符串 B、C 中的任意一个 
包含所有字符串 A、B、C 
包含字符串 A、B、C 中的任意一个 
… 
 
从当前描述来看,包含  与 不包含  是两个原语,将他们组合后能够得到许多组合子。
再深度思考下,不包含  其实是 包含  的取反操作,不包含  其实就是在目标行中移除 包含  的行。比如现有 A \Alpha A  、B \Beta B  、Γ \Gamma Γ   三行字符串,需要获取不包含字符串 α \alpha α   的字符串,已知字符串 α \alpha α   存在于 A \Alpha A   中,此时将 A \Alpha A   从三行字符串中移除即可得到不包含字符串 α \alpha α   的 B \Beta B   和 Γ \Gamma Γ   两行字符串。按照这样的理解,不包含  其实也是一种组合子,包含  是唯一的原语。
3.2 代码实现 
声明 Finder 接口,定义 find() 方法,用于在多行文本 text 中查找出目标行:
1 2 3 4 5 6 public  interface  Finder  {         List<String> find (String text) ; } 
 
在 Finder 内部定义静态方法 contains(),相当于是 Finder 的默认实现:
1 2 3 4 5 6 7 8 9 10 11 static  Finder contains (String word)  {    return  txt -> Stream.of(txt.split("\n" ))             .filter(i -> i.toLowerCase().contains(word.toLowerCase()))             .collect(Collectors.toList()); } 
 
contains() 也相当于是一个 原语 ,就像前文 UserValidation 中的 nameIsNotEmpty() 和 eMailContainsAtSign() 一样。
基于 contains() 原语,能够派生出一系列组合子,比如:
not:不包含 
or:包含任意一个 
and:同时包含 
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 default  Finder not (Finder notFinder)  {    return  txt -> {         List<String> res = this .find(txt);         res.removeAll(notFinder.find(txt));         return  res;     }; } default  Finder or (Finder orFinder)  {    return  txt -> {         List<String> res = this .find(txt);         res.addAll(orFinder.find(txt));         return  res;     }; } default  Finder and (Finder andFinder)  {    return  txt -> this .find(txt).stream()             .flatMap(i -> andFinder.find(i).stream())             .collect(Collectors.toList()); } 
 
和 UserValidation 一样,组合子都是由 default 修饰的默认方法。
或许这些组合子比较难以理解,但细想一下其实不难,把它们当成匿名内部类就行。not 和 or 的实现很类似,and 的实现则是使用了 Stream#flatMap(),为了不影响整体的阅读体验,有一个它的使用细节放在了文末的补充内容中。
为了方便使用,这些组合子还能够进一步组合,以工具类的方式将不同的查找规则展现出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 public  class  Finders  {    private  Finders ()  {     }          public  static  Finder advancedFinder (String query, String orQuery, String notQuery)  {         return  Finder.contains(query)                 .or(Finder.contains(orQuery))                 .not(Finder.contains(notQuery));     }          public  static  Finder filteredFinder (String query, String... excludeQueries)  {         var  finder  =  Finder.contains(query);         for  (String excludeQuery : excludeQueries) {             finder = finder.not(Finder.contains(excludeQuery));         }         return  finder;     }          public  static  Finder specializedFinder (String... queries)  {         Finder  finder  =  identMulti();         for  (String query : queries) {             finder = finder.and(Finder.contains(query));         }         return  finder;     }          public  static  Finder expandedFinder (String... queries)  {         Finder  finder  =  identSum();         for  (String query : queries) {             finder = finder.or(Finder.contains(query));         }         return  finder;     }     private  static  Finder identMulti ()  {         return  txt -> Stream.of(txt.split("\n" )).collect(Collectors.toList());     }     private  static  Finder identSum ()  {         return  txt -> new  ArrayList <>();     } } 
 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public  class  CombinatorTest  implements  WithAssertions  {    static  final  String  TEXT  =  """              It was many and many a year ago,             In a kingdom by the sea,             That a maiden there lived whom you may know             By the name of ANNABEL LEE;             And this maiden she lived with no other thought             Than to love and be loved by me.             I was a child and she was a child,             In this kingdom by the sea;             But we loved with a love that was more than love-             I and my Annabel Lee;             With a love that the winged seraphs of heaven             Coveted her and me.""" ;    @Test      public  void  testFinder ()  {         String[] queriesOr = {"many" , "Annabel" };         Finder  finder  =  Finders.expandedFinder(queriesOr);         List<String> res = finder.find(TEXT);         assertThat(res).containsExactly(                 "It was many and many a year ago," ,                 "By the name of ANNABEL LEE;" ,                 "I and my Annabel Lee;"          );         String[] queriesAnd = {"Annabel" , "my" };         finder = Finders.specializedFinder(queriesAnd);         res = finder.find(TEXT);         assertThat(res).singleElement().isEqualTo("I and my Annabel Lee;" );         finder = Finders.advancedFinder("it was" , "kingdom" , "sea" );         res = finder.find(TEXT);         assertThat(res).singleElement().isEqualTo("It was many and many a year ago," );         finder = Finders.filteredFinder(" was " , "many" , "child" );         res = finder.find(TEXT);         assertThat(res).singleElement().isEqualTo("But we loved with a love that was more than love-" );     } } 
 
4. FizzBuzz 
FizzBuzz 是一款儿童游戏,它要求一群孩子围成一个圈,按顺序报数:
如果一个数字是 3 的倍数,对应位置的孩子要说“Fizz”而不是数字;
 
如果一个数字是 5 的倍数,对应位置的孩子要说“Buzz”而不是数字;
 
如果一个数字同时是 3 和 5 的倍数,对应位置的孩子要说“FizzBuzz”而不是数字;
 
如果说错了,那他就会被淘汰出局,游戏继续,直到只剩下最后一个人。
 
 
这也是一种常见的编程面试题,要求候选人按照以上规则打印从 1 到 n 的所有数字。
逻辑并不难,要实现也很简单,但如果是用 Combinator Pattern 来实现呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public  class  FizzBuzz  {    public  static  Function<Integer, String> fizzBuzz ()  {         return  FizzBuzzFunction.fizzbuzz()                 .orElse(FizzBuzzFunction.fizz())                 .orElse(FizzBuzzFunction.buzz())                 .orElse(FizzBuzzFunction.number());     }     private  interface  FizzBuzzFunction  extends  Function <Integer, String> {         static  FizzBuzzFunction word (String word)  {             return  i -> word;         }         default  FizzBuzzFunction ifDivisibleBy (int  modulo)  {             return  i -> i % modulo == 0  ? apply(i) : "" ;         }         static  FizzBuzzFunction fizz ()  {             return  word("fizz" ).ifDivisibleBy(3 );         }         static  FizzBuzzFunction buzz ()  {             return  word("buzz" ).ifDivisibleBy(5 );         }         static  FizzBuzzFunction fizzbuzz ()  {             return  fizz().and(buzz());         }         static  FizzBuzzFunction number ()  {             return  String::valueOf;         }         default  FizzBuzzFunction and (FizzBuzzFunction other)  {             return  i -> {                 String  first  =  this .apply(i);                 String  second  =  other.apply(i);                 return  (first.isEmpty() && second.isEmpty()) ? ""  : first + second;             };         }         default  FizzBuzzFunction orElse (FizzBuzzFunction other)  {             return  i -> {                 String  first  =  this .apply(i);                 if  (first.isEmpty()) {                     return  other.apply(i);                 }                 return  first;             };         }     } } 
 
1 2 3 4 5 6 7 8 9 10 11 @Test public  void  testFizzBuzz ()  {    String  str  =  FizzBuzz.fizzBuzz().apply(3 );     assertThat(str).isEqualTo("fizz" );     str = FizzBuzz.fizzBuzz().apply(5 );     assertThat(str).isEqualTo("buzz" );     str = FizzBuzz.fizzBuzz().apply(15 );     assertThat(str).isEqualTo("fizzbuzz" ); } 
 
FizzBuzzFunction 中的部分方法可能有点难以理解,比如 word()、ifDivisibleBy() 以及它们俩组成的 fizz()、buzz() 等方法。
首先得明白:
word() 是一个静态方法(又或者说是静态工厂),它返回一个 FizzBuzzFunction 实例,针对这个实例,如果调用它的 apply() 方法,按照 word() 方法的实现,就会返回传入的 word 字符串; 
ifDivisibleBy() 是一个由 default 关键字修饰的方法,相当于是成员方法,只能通过 FizzBuzzFunction 实例进行调用。 
 
以 fizz() 为例:
1 2 3 static  FizzBuzzFunction fizz ()  {    return  word("fizz" ).ifDivisibleBy(3 ); } 
 
它首先调用了 word("fizz"),这相当于创建了一个 FizzBuzzFunction 实例,如果调用这个实例的 apply() 方法,无论传入什么,都会返回 fizz 字符串,因为 word() 方法的实现就是这个意思:
1 2 3 static  FizzBuzzFunction word (String word)  {    return  i -> word; } 
 
然后在创建的 FizzBuzzFunction 实例的基础上调用了 ifDivisibleBy(3),它的实现是:
1 2 3 default  FizzBuzzFunction ifDivisibleBy (int  modulo)  {    return  i -> i % modulo == 0  ? apply(i) : "" ; } 
 
也就是说,如果一个数能被传入的 modulo 整除,就调用 apply(i) 作为返回值,否则返回空字符串。
ifDivisibleBy(3) 就相当于如果一个数能被 3 整除,就调用 apply(i) 作为返回值。
还记得前面说的吗?
通过 word() 方法创建出的 FizzBuzzFunction 实例,调用其 apply() 方法时,将返回传入的字符串。
因此 fizz() 方法表示的含义是:如果一个数能被 3 整除,就返回 fizz 字符串,否则返回空字符串。
其余的方法也能按照类似的思路来理解,就不赘述了。
5. 实现总结 
初次接触 Combinator Pattern 时,可能有些难以理解,但通过上述案例的讲解,对于 Combinator Pattern 的实现基本可以总结出一套流程:
定义一个函数式接口 (可以继承 java.util.function.Function,也可以不继承),内部的 抽象方法接收操作对象类型,返回所需的目标类型 。比如校验人员信息时,接收 User 对象,返回校验结果 UserValidation;再比如查找目标行时,接收以 \n 作为分隔符的多行文本 text(实际类型是 String),返回符合要求的 List<String> 类型文本行;又或者在 FizzBuzz 的实现中,接收整数,按规定返回对应的字符串; 
接口中声明 静态方法作为原语 ,声明 default 方法作为组合子 ,组合子围绕原语进行组合; 
为了便于使用,还可以声明一个工具类 ,进一步对组合子进行组合,编写出易用的工具方法。 
 
实现的关键是对原语和组合子的定义,如何定义出能够复用的原语、如何定义出能够将原语进行组合的组合子,两者息息相关,要互相考虑,这之间没有固定的公式模板,只有多写、多练、多思考才能融会贯通。
5. 补充内容 
5.1 图解 flatMap 
flatMap 是流的扁平化,比如:
1 2 3 List.of(List.of("A" , "B" , "C" ), List.of("D" , "E" , "F" )).stream()         .flatMap(Collection::stream)         .collect(Collectors.toList()); 
 
它的执行流程是这样的:
flatMap() 的并不难,日常开发也经常使用,那下述代码的执行流程又是怎么样的呢?
说实话,我第一次看到的时候也懵了一会。
1 2 3 Stream.of("A" , "B" , "C" )         .flatMap(i -> Stream.of("X" , "Y" ))         .collect(Collectors.toList());