一、重构、测试和调试
(一)、为改善可读性和灵活性重构代码
- 用更紧凑的方式描述程序的行为 —— Lambda表达式
- 将一个既有的方法作为参数传递给另一个方法 —— 方法引用
如何运用前几章介绍的Lambda表达式、方法引用以及Stream接口等特性重构遗留代码,改善程序的可读性和灵活性?
1、改善代码的可读性
- Java 8的新特性也可以帮助提升代码的可读性:
使用Java 8,你可以减少冗长的代码,让代码更易于理解
通过方法引用和Stream API,你的代码会变得更直观
- 介绍三种简单的重构,利用Lambda表达式、方法引用以及Stream改善程序代码的可读性:
用方法引用重构 Lambda 表达式
用 Stream API 重构命令式的数据处理
2、从匿名类到 Lambda 表达式的转换
- 即:将实现单一抽象方法的匿名类转换为Lambda表达式。
-->传统的方式,使用匿名类
Runnable r1 = new Runnable(){
public void run(){
System.out.println("Hello");
}
};
--> Lambda表达式
Runnable r2 = () -> System.out.println("Hello");
- 有时将匿名类转换为Lambda表达式可能是一个比较复杂的过程:
匿名类和Lambda表达式中的this和super的含义是不同的。 在匿名类中, this代表的是类自身,但是在Lambda中,它代表的是包含类。
匿名类可以屏蔽包含类的变量,而Lambda表达式不能(它们会导致编译错误)
int a = 10;
Runnable rl = () -> {
int a= 3; --> 编译错误(书写代码提示错误)
System.out.println(a);
} ;
Runnable ra = new Runnable() {
@Override
public void run() {
int a= 3; --> 正确
System.out.println(a);
}
};
- 匿名类的类型是在初始化时确定的,而Lambda的类型取决于它的上下文 。
3、从 Lambda 表达式到方法引用的转换
为了改善代码的可读性,请尽量使用方法引用。Map<Dish.CaloricLevel, List<Dish>> dishByLevel = menu.stream().collect(Collectors.groupingBy(
dish -> {
if (dish.getCalories() <= 400) {
return Dish.CaloricLevel.DIET;
}
else if (dish.getCalories() <= 700) {
return Dish.CaloricLevel.NORMAL;
}
else {
return Dish.CaloricLevel.FAT;
}
}
));
改为方法引用,提高可读性:
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(Collectors.groupingBy(Dish::getCaloricLevel));
public class Dish{
…
public CaloricLevel getCaloricLevel(){
if (this.getCalories() <= 400) return CaloricLevel.DIET;
else if (this.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}
}
除此之外,我们还应该尽量考虑使用
静态辅助方法,比如comparing、 maxBy。
inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
改进为 -->
inventory.sort(Collectors.comparing(Apple::getWeight));
此外,很多通用的
归约操作,比如sum、 maximum,都有内建的辅助方法可以和方法引用结合使用。
int totalCalories =menu.stream().map(Dish::getCalories).reduce(0, (c1, c2) -> c1 + c2);
改为 -->
int totalCalories = menu.stream().collect(Collectors.summingInt(Dish::getCalories));
4、从命令式的数据处理切换到 Stream
建议将所有使用迭代器这种数据处理模式处理集合的代码都转换成Stream API的方式。 为什么呢?原因:Stream API能更清晰地表达数据处理管道的意图。除此之外,通过短路和延迟载入以及利用第7章介绍的现代计算机的多核架构,我们可以对Stream进行优化。
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu){
if(dish.getCalories() > 300){
dishNames.add(dish.getName());
}
}
使用Stream API替代 -->
menu.parallelStream().filter(d -> d.getCalories() > 300).map(Dish::getName).collect(Collectors.toList());
5、增加代码的灵活性
(1)、采用函数接口
没有函数接口,就无法使用Lambda表达式。引入函数接口。
(2)、有条件的延迟执行
一些代码:控制语句被混杂在业务逻辑代码之中。典型的情况包括进行安全性检查以及日志输出。
Java语言内置的Logger类 -->
if (logger.isLoggable(Log.FINER)){
logger.finer("Problem: " + generateDiagnostic());
}
上述代码存在问题:
日志器的状态(它支持哪些日志等级)通过isLoggable方法暴露给了客户端代码。
为什么要在每次输出一条日志之前都去查询日志器对象的状态?这只能搞砸你的代码。
方案改进一:使用log方法,该方法在输出日志消息之前,会在内部检查日志对象是否已经设置为恰当的日志等级 -->
logger.log(Level.FINER, "Problem: " + generateDiagnostic());
这种方式更好的原因是你不再需要在代码中插入那些条件判断,与此同时日志器的状态也不再被暴露出去。但仍有一个问题:
日志消息的输出与否每次都需要判断,即使已经传递了参数,不开启日志。
方案改进二:使用lambda表达式,需要做的仅仅是延迟消息构造, 日志就只会在某些特定的情况下才开启。
Java8引入了一个对log方法的重载版本,这个版本的log方法接受一个Supplier作为参数。这个替代版本的log方法的函数签名如下:
public void log(Level level, Supplier<String> msgSupplier)
调用:
logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
如果日志器的级别设置恰当,
log
方法会在内部执行作为参数传递进来的
Lambda
表达式。
Log方法的内部实现如下:
public void log(Level level, Supplier<String> msgSupplier){
if(logger.isLoggable(level)){
log(level, msgSupplier.get()); --> 执行Lambda表达式
}
}
- 总结:如果需要频繁地从客户端代码去查询一个对象的状态(比如前文例子中的日志器的状态),只是为了传递参数、调用该对象的一个方法(比如输出一条日志),那么可以考虑实现一个新的方法,以Lambda或者方法表达式作为参数,新方法在检查完该对象的状态之后才调用原来的方法。代码会变得更易读(结构更清晰),封装性更好(对象的状态也不会暴露给客户端代码了)。
(3)、环绕执行
如果发现业务代码千差万别,但是它们拥有同样的准备和清理阶段,这时完全可以将这部分代码用Lambda实现。这种方式的好处:可以重用准备和清理阶段的逻辑,减少重复冗余的代码。
String oneLine = processFile((BufferedReader b) -> b.readLine()); // Lambda表达式
String twoLines = processFile((BufferedReader b) -> b.readLine() + b.readLine()); // Lambda表达式
public static String processFile(BufferedReaderProcessor p) throws IOException {
try(BufferedReader br = new BufferedReader(new FileReader("java8inaction/chap8/data.txt"))){
return p.process(br); // 将 BufferedReaderProcessor 作为执行参数传入
}
}
// 使用Lambda表达式的函数接口,该接口能够抛出一个IOException
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
这一优化是凭借函数式接口BufferedReaderProcessor达成的,通过这个接口,可以传递各种Lamba表达式对BufferedReader对象进行处理。
(二)、使用 Lambda 重构面向对象的设计模式
针对五个设计模式展开讨论,它们分别是: 策略模式
模板方法
观察者模式
责任链模式
工厂模式
1、策略模式
策略模式代表了解决一类算法的通用解决方案,可以在运行时选择使用哪种方案。策略模式包含三部分内容:
一个代表某个算法的接口(它是策略模式的接口)。
一个或多个该接口的具体实现,它们代表了算法的多种实现(比如,实体类 ConcreteStrategyA 或者 ConcreteStrategyB )。
一个或多个使用策略对象的客户。
- eg:验证输入的内容是否根据标准进行了恰当的格式化(比如只包含小写字母或数字):
public class StrategyModelTest {
public static void main(String[] args) {
//原策略模式
Validator v1 = new Validator(new IsNumeric());
System.out.println(v1.validate("1263"));
System.out.println(v1.validate("126aq1"));
Validator v2 = new Validator(new IsAllLowerCase());
System.out.println(v2.validate("asdaa"));
System.out.println(v2.validate("asDaa"));
//改进:Lambda表达式
Validator numV = new Validator(s -> s.matches("\\d+"));
System.out.println(v1.validate("1263"));
System.out.println(v1.validate("126aq1"));
Validator caseV = new Validator(s -> s.matches("[a-z]+"));
System.out.println(v2.validate("asdaa"));
System.out.println(v2.validate("asDaa"));
}
public interface ValidationStrategy {
boolean execute(String s);
}
public static class IsAllLowerCase implements ValidationStrategy {
@Override
public boolean execute(String s) {
return s.matches("[a-z]+");
}
}
public static class IsNumeric implements ValidationStrategy {
@Override
public boolean execute(String s) {
return s.matches("\\d+");
}
}
public static class Validator {
private final ValidationStrategy strategy;
public Validator(ValidationStrategy strategy) {
this.strategy = strategy;
}
public boolean validate (String s) {
return strategy.execute(s);
}
}
}
2、模板方法
如果需要采用某个算法的框架,同时又希望有一定的灵活度,能对它的某些部分进行改进,那么采用模板方法设计模式是比较通用的方案。- eg:假设需要编写一个简单的在线银行应用。 通常, 用户需要输入一个用户账户,之后应用才能从银行的数据库中得到用户的详细信息,最终完成一些让用户满意的操作。
abstract class OnlineBanking {
public void processCustomer(int id){
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy(c);
}
abstract void makeCustomerHappy(Customer c);
}
processCustomer方法搭建了在线银行算法的框架:获取客户提供的ID,然后提供服务让用户满意。不同的支行可以通过继承OnlineBanking类,对该方法提供差异化的实现。
- Lambda表达式
public void processCustomer(int id, Consumer<Customer> makeCustomerHappy){
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy.accept(c);
}
new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println("Hello " + c.getName());
3、观察者模式
观察者模式是一种比较常见的方案,某些事件发生时(比如状态转变),如果一个对象(通常我们称之为主题)需要自动地通知其他多个对象(称为观察者),就会采用该方案。
例如,创建一个图形用户界面(GUI)程序,在图形用户界面组件(比如按钮)上注册一系列的观察者。如果点击按钮,观察者就会收到通知,并随即执行某个特定的行为。
- eg:好几家报纸机构,比如《纽约时报》《卫报》以及《世界报》都订阅了新闻,他们希望当接收的新闻中包含他们感兴趣的关键字时,能得到特别通知。
public class ObserverModelTest {
public static void main(String[] args) {
Feed f = new Feed();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
//可见: Guardian《卫报》会特别关注这条新闻!
f.notifyObservers("The queen said her favourite book is Java 8 in Action!");
//改进: 使用Lambda表达式
f = new Feed();
f.registerObserver(tweet -> {
if (null != tweet && tweet.contains("money")) {
System.out.println("Breaking news in NY! " + tweet);
}
});
f.registerObserver(tweet -> {
if(tweet != null && tweet.contains("queen")){
System.out.println("Yet another news in London... " + tweet);
}
});
f.registerObserver(tweet -> {
if(tweet != null && tweet.contains("wine")){
System.out.println("Today cheese, wine and news! " + tweet);
}
});
//可见: Guardian《卫报》会特别关注这条新闻!
f.notifyObservers("The queen said her favourite book is Java 8 in Action!");
}
public interface Subject{
/**
* 注册一个新的观察者
* @param o
*/
void registerObserver(Observer o);
/**
* 通知它的观察者一个新闻的到来
* @param tweet
*/
void notifyObservers(String tweet);
}
public static class Feed implements Subject {
private final List<Observer> observers = new ArrayList<>();
@Override
public void registerObserver(Observer o) {
this.observers.add(o);
}
@Override
public void notifyObservers(String tweet) {
observers.forEach(o -> o.notify(tweet));
}
}
public interface Observer {
/**
* 一个观察者接口, 它将不同的观察者聚合在一起
* @param tweet
*/
void notify(String tweet);
}
/**声明不同的观察者:三家不同的报纸机构*/
/**纽约时报*/
public static class NYTimes implements Observer {
@Override
public void notify(String tweet) {
if (null != tweet && tweet.contains("money")) {
System.out.println("Breaking news in NY! " + tweet);
}
}
}
/**卫报*/
public static class Guardian implements Observer{
@Override
public void notify(String tweet) {
if(tweet != null && tweet.contains("queen")){
System.out.println("Yet another news in London... " + tweet);
}
}
}
/**世界报*/
public static class LeMonde implements Observer{
@Override
public void notify(String tweet) {
if(tweet != null && tweet.contains("wine")){
System.out.println("Today cheese, wine and news! " + tweet);
}
}
}
}
----> Yet another news in London... The queen said her favourite book is Java 8 in Action!
显然,《卫报》会特别关注这条新闻!
4、责任链模式
责任链模式是一种创建处理对象序列(比如操作序列)的通用方案。一个处理对象可能需要在完成一些工作之后,将结果传递给另一个对象,这个对象接着做一些工作,再转交给下一个处理对象,以此类推。
通常,这种模式是通过定义一个代表处理对象的抽象类来实现的,在抽象类中会定义一个字段来记录后续对象。一旦对象完成它的工作,处理对象就会将它的工作转交给它的后继。
public class ResponsibilityModelTest {
public static void main(String[] args) {
ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2);
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result);
//改进: 使用Lambda表达式
UnaryOperator<String> headerProcessing = text -> "From Raoul, Mario and Alan: " + text;
UnaryOperator<String> spellCheckerProcessing = text -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing);
result = pipeline.apply("Aren't labdas really sexy?!!");
System.out.println(result);
}
public static class HeaderTextProcessing extends ProcessingObject<String> {
@Override
public String handleWork(String text){
return "From Raoul, Mario and Alan: " + text;
}
}
public static class SpellCheckerProcessing extends ProcessingObject<String> {
@Override
public String handleWork(String text){
return text.replaceAll("labda", "lambda");
}
}
public static abstract class ProcessingObject<T> {
protected ProcessingObject<T> successor;
public void setSuccessor(ProcessingObject<T> successor) {
this.successor = successor;
}
public T handle(T input){
T r = handleWork(input);
if(successor != null){
return successor.handle(r);
}
return r;
}
abstract protected T handleWork(T input);
}
}
----> 输出:From Raoul, Mario and Alan: Aren't lambdas really sexy?!!
5、工厂模式
使用工厂模式,无需向客户暴露实例化的逻辑就能完成对象的创建。- eg:创建一个工厂类,它包含一个负责实现不同对象的方法,如下所示:
public class ProductFactory {
public static Product createProduct(String name){
switch(name){
case "loan": return new Loan();
case "stock": return new Stock();
case "bond": return new Bond();
default: throw new RuntimeException("No such product " + name);
}
}
}
贷款(Loan)、股票(Stock)和债券(Bond)都是产品(Product)的子类。createProduct方法可以通过附加的逻辑来设置每个创建的产品。
好处:创建对象时不用再担心会将构造函数或者配置暴露给客户,使得客户创建产品时更加简单:
Product p = ProductFactory.createProduct("loan");
- 使用Lambda表达式
引用贷款(Loan)构造函数的示例:
Supplier<Product> loanSupplier = Loan::new;
Loan loan = loanSupplier.get();
创建一个Map,将产品名映射到对应的构造函数:
final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
map.put("loan", Loan::new);
map.put("stock", Stock::new);
map.put("bond", Bond::new);
}
类似于之前使用工厂设计模式,利用这个Map来实例化不同的产品:
public static Product createProduct(String name){
Supplier<Product> p = map.get(name);
if(p != null) return p.get();
throw new IllegalArgumentException("No such product " + name);
}
以上使用Java 8中的新特性达到了传统工厂模式同样的效果。但是,如果工厂方法createProduct需要接收多个传递给产品构造方法的参数,这种方式的
扩展性不是很好。
- 改进:创建一个特殊的函数接口TriFunction
public interface TriFunction<T, U, V, R>{
R apply(T t, U u, V v);
}
Map<String, TriFunction<Integer, Integer, String, Product>> map = new HashMap<>();
(三)、测试 Lambda 表达式
1、测试可见 Lambda 函数的行为
有时,可以借助某个字段访问Lambda函数,这种情况,你可以利用这些字段,通过它们对封装在Lambda函数内的逻辑进行测试。(compareByXAndThenY)
public class LambdaExpressionTest {
/**
* 测试可见 Lambda 函数的行为
* @throws Exception
*/
@Test
public void testComparingTwoPoints() throws Exception {
Point p1 = new Point(10, 15);
Point p2 = new Point(10, 20);
int result = Point.compareByXAndThenY.compare(p1 , p2);
assertEquals(-1, result);
}
public static class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public Point moveRightBy(int x) {
return new Point(this.x + x, this.y);
}
public final static Comparator<Point> compareByXAndThenY = Comparator.comparing(Point::getX).thenComparing(Point::getY);
}
}
2、测试使用 Lambda 的方法的行为
但是Lambda的初衷是将一部分逻辑封装起来给另一个方法使用。从这个角度出发,你不应该将Lambda表达式声明为public,它们仅是具体的实现细节。相反,我们需要对使用Lambda表达式的方法进行测试。(moveAllPointsRightBy)
public class LambdaExpressionTest {
/**
* 测试使用 Lambda 的方法的行为
* @throws Exception
*/
@Test
public void testMoveAllPointsRightBy() throws Exception{
List<Point> points = Arrays.asList(new Point(5, 5), new Point(10, 5));
List<Point> expectedPoints = Arrays.asList(new Point(15, 5), new Point(20, 5));
List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
assertEquals(expectedPoints, newPoints);
}
public static class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Point)) {
return false;
}
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
public Point moveRightBy(int x) {
return new Point(this.x + x, this.y);
}
public static List<Point> moveAllPointsRightBy(List<Point> points, int x){
return points.stream()
.map(p -> new Point(p.getX() + x, p.getY()))
.collect(Collectors.toList());
}
}
}
3、将复杂的 Lambda 表达式分到不同的方法
可能会碰到非常复杂的Lambda表达式,包含大量的业务逻辑,比如需要处理复杂情况的定价算法。你无法在测试程序中引用Lambda表达式,这种情况该如何处理呢?
- 一种策略:将Lambda表达式转换为方法引用(这时往往需要声明一个新的常规方法)。这之后,可以用常规的方式对新的方法进行测试。(从 Lambda 表达式到方法引用的转换 已经讲述过)
4、高阶函数的测试
接受函数作为参数的方法或者返回一个函数的方法(所谓的“高阶函数”, higher-orderfunction,我们在第14章会深入展开介绍)更难测试。
- 如果一个方法接受Lambda表达式作为参数,可以采用的一个方案是使用不同的Lambda表达式对它进行测试。
eg:可以使用不同的谓词对创建的filter方法进行测试:
@Test
public void testFilter() throws Exception{
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
List<Integer> even = filter(numbers, i -> i % 2 == 0);
List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
assertEquals(Arrays.asList(2, 4), even);
assertEquals(Arrays.asList(1, 2), smallerThanThree);
}
(四)、调试
调试有问题的代码时,程序员的兵器库里有两大老式武器,分别是:
查看栈跟踪
输出日志
1、查看栈跟踪
程序的每次方法调用都会产生相应的调用信息,包括程序中方法调用的位置、该方法调用使用的参数、被调用方法的本地变量。这些信息被保存在栈帧上。
通过这些你能得到程序失败时的方法调用列表。这些方法调用列表最终会帮助你发现问题出现的原因。
- Lambda表达式和栈跟踪
import java.util.*;
public class Debugging{
public static void main(String[] args) {
List<Point> points = Arrays.asList(new Point(12, 2), null);
points.stream().map(p -> p.getX()).forEach(System.out::println);
}
}
错误信息:
Exception in thread "main" java.lang.NullPointerException
at Debugging.lambda$main$0(Debugging.java:6) --> 错误发生在Lambda表达式内部
at Debugging$$Lambda$5/284720968.apply(Unknown Source)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
.java:193)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators
.java:948)
由于Lambda表达式没有名字,所以编译器只能为它们指定一个名字。这个例子中,它的名字是lambda$main$0,看起来非常不直观。
2、 使用日志调试
Lambda表达式使用流操作方法peek,peek的设计初衷就是在流的每个元素恢复运行之前,插入执行一个动作。 但是它不像forEach那样恢复整个流的运行,而是在一个元素上完成操作之后,它只会将操作顺承到流水线中的下一个操作。
(五)、小结
Lambda表达式能提升代码的可读性和灵活性。
若代码中使用匿名类,尽量用Lambda表达式替换它们,但要注意二者间语义的微妙差别,eg:关键字this,以及变量隐藏。
跟Lambda表达式比起来,方法引用的可读性更好 。
尽量使用Stream API替换迭代式的集合处理。
Lambda表达式有助于避免使用面向对象设计模式时容易出现的僵化的模板代码,典型的比如策略模式、模板方法、观察者模式、责任链模式,以及工厂模式。
即使采用了Lambda表达式,也同样可以进行单元测试,但是通常应该关注使用了Lambda表达式的方法的行为。
尽量将复杂的Lambda表达式抽象到普通方法中。
Lambda表达式会让栈跟踪的分析变得更为复杂。
流提供的peek方法在分析Stream流水线时,能将中间变量的值输出到日志中,是非常有用的工具。
二、默认方法
- 传统上, Java程序的接口是将相关方法按照约定组合到一起的方式。实现接口的类必须为接口中定义的每个方法提供一个实现,或者从父类中继承它的实现。但是,一旦类库的设计者需要更新接口,向其中加入新的方法,这种方式就会出现问题。
- 现实情况是:现存的实体类往往不在接口设计者的控制范围之内,这些实体类为了适配新的接口约定也需要进行修改。
- 在接口中引入很多方法,要修改好多实现该接口的类,显而易见问题很大,于是Java8引入一种新的机制:Java 8中的接口现在支持在声明方法的同时提供实现,通过两种方式可以完成这种操作:
Java 8允许在接口内声明静态方法。
Java 8引入了一个新功能,叫默认方法,通过默认方法可以指定接口方法的默认实现。(接口能提供方法的具体实现)
实现接口的类如果不显式地提供该方法的具体实现,就会自动继承默认的实现。这种机制可以使平滑地进行接口的优化和演进。
- eg:使用默认方法的例子:List接口中的sort,以及Collection接口中的stream。
List接口中的sort方法是Java 8中全新的方法,它的定义如下:
default void sort(Comparator<? super E> c){
Collections.sort(this, c);
}
判断一个方法是否为默认方法的标志:default修饰符。默认方法sort的使用方式:
List<Integer> numbers = Arrays.asList(3, 5, 1, 2, 6);
numbers.sort(Comparator.naturalOrder());
Comparator.naturalOrder方法是Comparator接口的一个全新的
静态方法,它返回一个Comparator对象,并按自然序列对其中的元素进行排序(即标准的字母数字方式排序)
List<Integer> nums = Arrays.asList(3, 5, 1, 2, 6);
List<String> words = Arrays.asList("hi", "sandy", "12", "aboard", "n");
nums.sort(Comparator.naturalOrder());
System.out.println("数字自然排序:" + nums); --》 [1, 2, 3, 5, 6]
words.sort(Comparator.naturalOrder());
System.out.println("字符串自然排序:" + words); --》 [12, aboard, hi, n, sandy]
- 默认方法的引入就是为了以兼容的方式解决像Java API这样的类库的演进问题的:
默认方法为方法的多继承提供了一种更灵活的机制,可以帮助你更好地规划你的代码结构:类可以从多个接口继承默认方法。
- 静态方法及接口
同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式,工具类定义了与接口实例协作的很多静态方法。比如, Collections就是处理Collection对象的辅助类。
由于静态方法可以存在于接口内部,代码中的这些辅助类就没有了存在的必要,你可以把这些静态方法转移到接口内部。为了保持后向的兼容性,这些类依然会存在于Java应用程序的接口之中。
(一)、不断演进的 API
定义了一个简单的可缩放形状必须支持的很多方法的接口并已经发布,发现Resizable接口遗漏了一些功能,怎么对一个已发布的接口进行改变?
1、初始版本的 API
Resizable接口的最初版本提供了下面这些方法:
public interface Resizable extends Drawable{
int getWidth();
int getHeight();
void setWidth(int width);
void setHeight(int height);
void setAbsoluteSize(int width, int height);
}
- 用户实现
创建了Ellipse类,实现了Resizable接口:
public class Ellipse implements Resizable {
…//重写Resizable接口的all方法
}
实现了一个处理各种Resizable形状(包括Ellipse)的游戏:
public class Game{
public static void main(String...args){
List<Resizable> resizableShapes = Arrays.asList(new Square(), new Rectangle(), new Ellipse());
Utils.paint(resizableShapes);
}
}
public class Utils{
public static void paint(List<Resizable> l){
l.forEach(r -> {
r.setAbsoluteSize(42, 42);
r.draw();
});
}
}
2、第二版 API
要求更新Resizable的实现,让Square、Rectangle以及其他的形状都能支持setRelativeSize方法。
- 用户面临的窘境
对Resizable接口的更新导致了一系列的问题。
接口现在要求它所有的实现类添加setRelativeSize方法的实现。向接口添加新方法是二进制兼容的,这意味着如果不重新编译该类,即使不实现新的方法,现有类的实现依旧可以运行。不过,用户可能修改他的游戏,在他的Utils.paint方法中调用
setRelativeSize方法,因为paint方法接受一个Resizable对象列表作为参数。如果传递的是一个Ellipse对象,程序就会抛出一个运行时错误,因为它并未实现setRelativeSize方法,报错:
Exception in thread "main" java.lang.AbstractMethodError:
lambdasinaction.chap9.Ellipse.setRelativeSize(II)V
若用户试图重新编译整个应用(包括Ellipse类),他会遭遇下面的编译错误:
lambdasinaction/chap9/Ellipse.java:6: error: Ellipse is not abstract and does
not override abstract method setRelativeSize(int,int) in Resizable
更新已发布API会导致后向兼容性问题。
对于以上问题,还有其他方式能够实现对API的改进,但是都不是明智的选择。比如,你可以为你的API创建不同的发布版本,同时维护老版本和新版本,但这是非常费时费力的,原因如下:
这增加了作为类库的设计者维护类库的复杂度。
类库的用户不得不同时使用一套代码的两个版本,而这会增大内存的消耗,延长程序的载入时间,因为这种方式下项目使用的类文件数量更多了。
- 解决方案:默认方法
默认方法让类库的设计者放心地改进应用程序接口,无需担忧对遗留代码的影响,这是因为实现更新接口的类现在会自动继承一个默认的方法实现。
- 不同类型的兼容性:二进制、源代码和函数行为
变更对Java程序的影响大体可以分成三种类型的兼容性,分别是:二进制级的兼容、源代码级的兼容,以及函数行为的兼容。
二进制级的兼容性:表示现有的二进制执行文件能无缝持续链接(包括验证、准备和解析)和运行。eg:为接口添加一个方法就是二进制级的兼容,这种方式下,如果新添加的方法不被调用,接口已经实现的方法可以继续运行,不会出现错误。
源代码级的兼容性:表示引入变化之后,现有的程序依然能成功编译通过。eg:向接口添加新的方法就不是源码级的兼容,因为遗留代码并没有实现新引入的方法,所以它们无法顺利通过编译。
函数行为的兼容性:表示变更发生之后,程序接受同样的输入能得到同样的结果。eg:为接口添加新的方法就是函数行为兼容的,因为新添加的方法在程序中并未被调用(抑或该接口在实现中被覆盖了)。
(二)、概述默认方法
接口包含的方法签名在它的实现类中也可以不提供实现。那么,谁来具体实现这些方法呢?
缺失的方法实现会作为接口的一部分由实现类继承(所以命名为默认实现),而无需由实现类提供。
- 默认方法由default修饰符修饰,包含方法体。
//public void setRelativeSize(int widthFactor, int heightFactor);
default void setRelativeSize(int widthFactor, int heightFactor) { --> 接口中默认方法
setAbsoluteSize(getWidth() / widthFactor, getHeight() / heightFactor);
}
很多函数式接口,比如Predicate、 Function以及Comparator也引入了新的默认方法,比如Predicate.and或者Function.andThen(记住,函数式接口只包含一个抽象方法,默认方法是种非抽象方法)。
Java 8中的抽象类和抽象接口
那么抽象类和抽象接口之间的区别是什么呢?它们不都能包含抽象方法和包含方法体的实现吗?
首先,一个类只能继承一个抽象类,但是一个类可以实现多个接口。
其次,一个抽象类可以通过实例变量(字段)保存一个通用状态,而接口是不能有实例变量的。
eg:假设你是Java语言和API的一个负责人。你收到了关于removeIf方法的很多请求,希望能为ArrayList、 TreeSet、 LinkedList以及其他集合类型添加removeIf方法。removeIf方法的功能是
删除满足给定谓词的所有元素。你的任务是找到添加这个新方法、优化Collection API的最佳途径。
- 改进Collection API破坏性最大的方式是什么?把removeIf的实现直接复制到Collection API的每个实体类中,但这种做法实际是在对Java界的犯罪。
- 解决方法:所有的Collection类都实现了一个名为java.util.Collection的接口。在这个接口中添加默认方法(一种以源码兼容方式向接口内添加实现的方法),这样实现Collction的所有类(包括并不隶属Collection API的用户扩展类)都能使用removeIf的默认实现。 removeIf的代码实现如下(它实际就是Java 8 Collection API的实现)。它是Collection接口的一个默认方法:
default boolean removeIf(Predicate<? super E> filter) {
boolean removed = false;
Iterator<E> each = iterator();
while(each.hasNext()) {
if(filter.test(each.next())) {
each.remove();
removed = true;
}
}
return removed;
}
(三)、默认方法的使用模式
已经了解了默认方法怎样以兼容的方式演进库函数了。除了这种用例,其他场景也有使用该特性的。例如,可以创建自己的接口,并为其提供默认方法。下面介绍使用默认方法的两种用例: 可选方法和行为的多继承。
1、可选方法
- Java8之前,类实现了接口,不过却刻意地将一些方法的实现留白。以Iterator接口为例,Iterator接口定义了hasNext、 next,还定义了remove方法。
- 利用Java8提出的默认方法进行改进,例如,Iterator接口就为remove方法提供了一个默认实现:
interface Iterator<T> {
boolean hasNext();
T next();
default void remove() {
throw new UnsupportedOperationException();
}
}
- 通过这种方式可以减少无效的模板代码。实现Iterator接口的每一个类都不需要再声明一个空的remove方法了,因为它现在已经有一个默认的实现。
2、行为的多继承
行为的多继承是一种让类从多个来源重用代码的能力,如下图。
Java的类只能继承单一的类,但是一个类可以实现多接口。以ArrayList为例,定义如下:
public class ArrayList<E> extends AbstractList<E> --> 继承一个类
implements List<E>, RandomAccess, Cloneable, Serializable, Iterable<E>, Collection<E> { --> 实现六个接口
}
(1)、类型的多继承
- ArrayList实现了6个接口,因此ArrayList实际是7个类型的直接子类,分别是: AbstractList、List、RandomAccess、 Cloneable、 Serializable、Iterable和Collection。这就是类型的多继承。
- 由于Java 8中接口方法可以包含实现,类可以从多个接口中继承它们的行为(即实现的代码)。
- 保持接口的精致性和正交性能帮助在现有的代码基上最大程度地实现代码复用和行为组合。
(2)、 利用正交方法的精简接口
eg:假设需要为正在创建的游戏定义多个具有不同特质的形状。有的形状需要调整大小,但是不需要有旋转的功能;有的需要能旋转和移动,但是不需要调整大小。这种情况下,怎么设计才能尽可能地重用代码?
定义一个单独的Rotatable接口,并提供两个抽象方法setRotationAngle和getRotationAngle:
public interface Rotatable {
void setRotationAngle(int angleInDegrees);
int getRotationAngle();
default void rotateBy(int angleInDegrees){
setRotationAngle((getRotationAngle () + angle) % 360);
}
}
Now,实现了Rotatable的所有类都需要提供setRotationAngle和getRotationAngle的实现,但与此同时它们也会天然地继承rotateBy的默认实现。
(3)、 组合接口
Monster类会自动继承Rotatable、 Moveable和Resizable接口的默认方法。例子中,Monster继承了rotateBy、 moveHorizontally、 moveVertically和setRelativeSize的实现。(四)、解决冲突的规则
Java语言中一个类只能继承一个父类,但是一个类可以实现多个接口。随着默认方法在Java 8中引入,有可能出现一个类继承了多个方法而它们使用的却是同样的函数签名。这种情况下,类会选择使用哪一个函数?
1、解决问题的三条规则
若一个类使用相同的函数签名从多个地方(比如另一个类或接口)继承了方法,通过三条规则可以进行判断:
类中的方法优先级最高。类或父类中声明的方法的优先级高于任何声明为默认方法的优先级。
若无法依据第一条进行判断,那么子接口的优先级更高:函数签名相同时,优先选择拥有最具体实现的默认方法的接口,即如果B继承了A,那么B就比A更加具体。
最后,若还是无法判断,继承多个接口的类必须通过显式覆盖和调用期望的方法, 显式地选择使用哪一个默认方法的实现 。
2、选择提供了最具体实现的默认方法的接口
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B extends A {
default void hello() {
System.out.println("Hello from B");
}
}
- eg1: 根据规则2前半句判断
public class C implements B, A {
public static void main(String... args) {
new C().hello(); ---> 打印结果: Hello from B
}
}
- eg2: 根据规则2后半句判断
public class D implements A{ }
public class C extends D implements B, A {
public static void main(String... args) {
new C().hello(); ----> 打印结果: Hello from B
}
}
3、冲突及如何显式地消除歧义
若上例中B不在继承A:
public interface A {
default void hello() {
System.out.println("Hello from A");
}
}
public interface B {
default void hello() {
System.out.println("Hello from B");
}
}
无法判断哪一个,Java编辑器抛出编译异常:
Error: class C inherits unrelated defaults for hello()from types B and A.
- 解决冲突:只能显式地决定使用哪一个方法。
public class C implements B, A {
void hello(){
B.super.hello();
}
}
4、菱形继承问题
public interface A{
default void hello(){
System.out.println("Hello from A");
}
}
public interface B extends A { }
public interface C extends A { }
public class D implements B, C {
public static void main(String... args) {
new D().hello(); ----> Hello from A
}
}
- 若B中提供一个默认的hello方法,并且函数签名跟A中的方法也完全一致,这时会发生什么情况呢?根据规则(2),编译器会选择提供了更具体实现的接口中的方法。由于B比A更加具体,所以编译器会选择B中声明的默认方法。
- 如果B和C都使用相同的函数签名声明了hello方法,就会出现冲突,需要显式地指定使用哪个方法。
5、小结
Java 8中的接口可以通过默认方法和静态方法提供方法的代码实现。
默认方法的开头以关键字default修饰,方法体与常规的类方法相同。
向发布的接口添加抽象方法不是源码兼容的。
默认方法的出现能帮助库的设计者以后向兼容的方式演进API。
默认方法可以用于创建可选方法和行为的多继承。
我们有办法解决由于一个类从多个接口中继承了拥有相同函数签名的方法而导致的冲突。
类或者父类中声明的方法的优先级高于任何默认方法。如果前一条无法解决冲突,那就选择同函数签名的方法中实现得最具体的那个接口的方法。
两个默认方法都同样具体时,你需要在类中覆盖该方法,显式地选择使用哪个接口中提供的默认方法。