第 10 章 接口#
本章介绍了抽象类和接口的定义,使用,以及区别和意义。这章进一步阐释了 “使用与功能实现分离” 的优秀设计,还顺带介绍了一些设计模式。图灵导读目标如图所示:
接口,到底代表了什么?#
接口描述了一个类应该是什么样的和可以做什么,但是不包括怎么做。它实际上是定义了一种类之间的 “协议”。我个人认为接口是一种更高阶的抽象:在代码中对一些相同行为的类进行了进一步的抽象,这带来了代码复用和很强的扩展性,但也带来了额外的复杂性。从这里可以看出,要保持合理设计,就要适当封装,将抽象控制在合理的层次,否则这是一种过度设计!
接口的默认方法#
接口本来是不能实现任何方法的,它仅仅代表 “协议”,需要交给子类实现(implements)。但JDK 8
依然为借口提供了默认方法。当一个接口有默认方法时,子类可以不用重写这个方法,以作为默认实现。一种说法是默认方法增强了代码的兼容性和灵活性。《On Java》认为:这允许向现有接口中添加方法,而不会破坏已经在使用该接口的所有代码。默认方法应该是为JDK 8
引入流的解决方案。
抽象类与接口的区别#
特性 | 接口 | 抽象类 |
---|---|---|
组合 | 可以在新类中组合多个接口 | 只能继承一个抽象类 |
状态(字段) | 不能包含字段(静态字段除外,但它们不支持对象状态) | 可以包含字段,非抽象方法可以引用这些字段 |
默认方法与抽象方法 | 默认方法不用被子类实现,它只能引用接口内的方法(字段不行) | 抽象方法必须在子类实现 |
构造器 | 不能有构造器 | 可以有构造器 |
访问权限限制 | 隐式的 public | 可以为protected 或包访问权限 |
使用准则:“在合理的范围内尽可能抽象”。所以一般是优先用接口,等需要使用抽象类的时候你自然会知道。但除非必要,否则两个都不要使用。在后面会讲到,这是一种过早优化。接口和抽象类可以作为我们重构代码的强力工具。
完全解耦#
比起抽象类,接口更受人喜欢些,毕竟如果你调用的方法传入抽象类及其子类,你将只能使用基类及其子类,不能跳出其继承体系。而使用接口不受继承体系限制,能编写更多可复用的代码。
浅谈策略设计模式#
首先,有关策略设计模式,这篇文章非常好,后续如果有设计模式相关问题我也会引用。
策略设计模式就是创建一个方法,它能根据传递的参数对象来表现出不同的行为。这使得上下文没有必要知道它这个策略是怎么实现的,我直接用就行了,后续如果有新策略,不用更改上下文,使得方法更加灵活,通用,可复用性也更高。也就是 “我这个方法可以操作任何对象,只要这个对象遵循我的接口”。这也是 “将变化的事物与不变的事物分开” 的设计体现。
这里写上Java
的简单的实现,后续可以展开讲讲:
import java.util.Random;
enum concreteStrategy {
// 加法
addition,
// 减法
subtraction,
// 乘法
multiplication
}
/**
* 策略接口声明了某个算法各个不同版本间所共有的操作。上下文会使用该接口来
* 调用有具体策略定义的算法。
*/
interface Strategy {
/**
* 该方法可实现某种二元运算,并返回值。
*
* @param num1 运算数 1
* @param num2 运算数 2
* @return double 进行运算后的结果
* @author BlackFlame33
*/
double execute(int num1, int num2);
}
/**
* 具体策略会在遵循策略基础接口的情况下实现算法。该接口实现了它们在上下文
* 中的互换性。
*/
class ConcreteStrategyAdd implements Strategy {
@Override
public double execute(int num1, int num2) {
return num1 + num2;
}
}
class ConcreteStrategySubtract implements Strategy {
@Override
public double execute(int num1, int num2) {
return num1 - num2;
}
}
class ConcreteStrategyMultiply implements Strategy {
@Override
public double execute(int num1, int num2) {
return num1 * num2;
}
}
/**
* 上下文定义了客户端关注的接口。
*/
class Context {
/**
* 上下文会维护指向某个策略对象的引用。上下文不知晓策略的具体类。上下
* 文必须通过策略接口来与所有策略进行交互。
*/
private Strategy strategy;
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
/**
* 上下文会将一些工作委派给策略对象,而不是自行实现不同版本的算法。
*
* @param num1 运算数 1
* @param num2 运算数 2
* @return double 运算结果
*/
public double executeStrategy(int num1, int num2) {
return strategy.execute(num1, num2);
}
}
/**
* 客户端代码会选择具体策略并将其传递给上下文。客户端必须知晓策略之间的差
* 异,才能做出正确的选择。
*
* @author BlackFlame33
*/
public class ExampleApplication {
public static void main(String[] args) {
// 创建上下文对象。
Context context = new Context();
// 读取第一个数。
int num1 = new Random().nextInt(10);
// 读取最后一个数。
int num2 = new Random().nextInt(10);
// 应用会知道应该执行的具体策略
concreteStrategy action = randomStrategy();
if (action == concreteStrategy.addition) {
context.setStrategy(new ConcreteStrategyAdd());
}
if (action == concreteStrategy.subtraction) {
context.setStrategy(new ConcreteStrategySubtract());
}
if (action == concreteStrategy.multiplication) {
context.setStrategy(new ConcreteStrategyMultiply());
}
double result = context.executeStrategy(num1, num2);
// 打印结果
System.out.println(num1 + " 与 " + num2 + " 的 " + action + " 等于 " + result);
}
private static concreteStrategy randomStrategy() {
return concreteStrategy.values()[new Random().nextInt(concreteStrategy.values().length)];
}
}
组合接口时的名称冲突#
将接口组合在一起时,在不同的接口中使用相同的方法名称通常会导致代码可读性变差。(不要这样命名。。。)
适配接口#
适配器模式#
一样,看这篇文章就行。
个人理解:适配器就好像在不兼容的对象之间的一层兼容层一样。有时候不兼容的代码我们没有权限修改,这时候我们通过适配器内的方法,将参数适配到合适的格式, 然后调用定向到其封装对象中的一个或多个方法。
如此以来,一个把接口作为参数的方法,几乎可以让任何类适应它,只要实现接口的方法就行。这让我深刻感受到接口的威力。
接口的字段#
抽象类与接口的区别我们了解到接口只有静态字段。其静态字段存储在接口的静态存储区中。
嵌套接口#
浅谈工厂模式#
工厂模式将对创建对象的代码与使用对象的代码分离,成为其间接层,使得我们在添加新种类的对象时可以直接添加新种类的类,而不用修改客户端代码。通常发生在复杂的创建对象的情况时。
更多信息还是看这篇文章。
接口,用起来?#
指导方针:“优先使用类而不是接口”。从类开始设计,如果明显感觉接口是必要,那么就重构吧!
第 11 章 内部类#
定义在另一个类中的类称为内部类。
除了内部类的相关语法外,匿名内部类在后续会非常实用,应该熟练掌握。
内部类到外部类的链接#
内部类会隐含一个指向外部类对象的引用。因此内部类拥有访问外部类所有元素的访问权。也是因此,创建内部类时,为了让内部类持有外部类对象的引用,必须先创建外部类对象,再通过外部类对象创建内部类。
匿名内部类#
public Contents contents() {
return new Contents() {
int i = 11;
};
}
这种声明类的方式初看起来比较怪:我正写到return
的时候,要返回一个Contents
对象了,哦~等等,让我先定义一个Contents
的子类。但当类仅需要使用一次或极少重复使用时,也没有必要专门为它写一个类。
内部类的价值#
内部类的价值体现为:每个内部类可以独立地继承类或者实现接口,这完善了多重继承问题的解决方案。
内部类还可以实现闭包。它能保留它被创建时所处作用域的信息。
第 12 章 集合#
如果对象的数量是固定的,而且这些对象的生命周期都是已知的,那么这样的程序是相当简单的。
数组非常实用,但是其大小长度是已经固定了的,这会带来很大的局限性,因为通常情况下我们并不能准确知道需要多少个对象。
Iterator
#
迭代器可以实现向任意类型的集合的正向遍历。它让程序员不用关心所处理的集合的类型(我们说不用关心,其实就是在说,我们不必考虑传入集合的类型而被迫做不同的处理),它统一了对集合的访问。
糟糕的设计 ——Stack
类#
尽管Java
提供了Stack
类,但这个类的设计非常糟糕(这很可能与其不合理的继承关系有关),所以JDK 6
加入了ArrayDeque
,提供了直接使用栈的方法。
警钟长鸣,谨慎考虑继承关系!
面向接口#
通过面向接口而不是面向实现来编写代码,可以让我们的代码可以应用于更多对象类型。
总结#
Java
提供了很多持有对象的方式。最重要的四大类:List
、Map
、Set
,Queue
:
Collection
保存单个元素,Map
保存键值对;集合的大小可以自动调节;通过泛型,我们可以约束可存放的类型,且取出时也不用强制类型转换;集合只能保存引用类型的数据,基本类型的数据可以通过包装类自动装箱存入。List
线性表是一种有序集合。某种意义上,它将数字索引与对象关联起来。- 在
List
中,ArrayList
在随机访问上效率很高;而LinkedList
在插入删除上效率很高。
- 在
Map
将对象与其他对象关联起来。- 在
Map
中,HashMap
可以快速访问元素;TreeMap
将它的键以有序方式保存;LinkedHashMap
按照元素的插入顺序保存,但也通过哈希实现快速访问。
- 在
Set
中,相同的元素只能保存一个,Set
的底层实现其实就是Map
,所以它的子实现的特性与Map
的子实现的特性很相似。HashSet
可以快速访问元素;TreeSet
以有序方式保存元素;LinkedHashSet
按照元素的插入顺序保存。
- 集合类中的部分不常使用,且设计不合理的类被称为 “遗留类”。
Hashtable
、Vector
和Stack
,它们都是线程安全的,但不要在新代码中使用它们。
关于集合还有很多可总结的,二次笔记时可以以集合为主题展开。
第 13 章 函数式编程#
函数式编程语言处理代码片段就像处理数据一样简单。
就是有点费头发
函数式编程基于这样一种想法:使用代码以某种方式操纵其他代码。我们并非从零开始构建代码,而是从现有的、可靠的、经过测试的小片代码开始组合在一起,创建新的代码。
可以这样理解函数式编程:面对对象编程抽象数据,函数式编程抽象行为。
由于函数式编程规定所有的数据必须是不可变的:设置一次,永不改变。这非常适合在并行编程场景中使用。
lambda
表达式#
建议lambda
表达式的行数控制还 3 行内,如果超过 3 行,考虑使用方法引用。
函数式接口#
当一个接口只包含一个抽象方法时,这种接口也叫 “功能接口”。
从抽象的层面,可以理解为这就是将方法作为参数或者返回值。但从JVM
实现的角度,类和对象才是Java
的一等公民,方法是依附于对象或类的,无法独立存在,所以Java
选择将其与功能接口绑定在一起。
使用函数式接口时,名字并不重要,重要的只有参数类型和返回类型。当然对功能接口而言,其命名模式能帮助我们快速理解其作用,例如:
- 只处理对象的接口,命名通常为
Function
,Consumer
和Predicate
; - 只接受一个基本类型参数的接口,命名通常使用第一部分表示其基本类型,如
LongConsumber
,DoubleFunction
,IntPredicate
; - 返回类型为基本类型的接口,命名通常使用 'To' 来表示,如
ToLongFunction<T>
和IntToLongFunction
; - 参数类型与返回值类型相同的接口,命名使用
Operator
。UnaryOperator
表示一个参数,BinaryOperator
表示两个参数; - 接受一个参数并返回
boolean
类型的接口,命名使用Predicate
; - 接受两个参数的接口,命名通常使用
BiXxx
,如BiPredicate
表示接受两个参数,返回类型为boolean
。
闭包#
在内部类章节我们也讲到闭包。闭包可以作为函数对象或者匿名函数,持有上下文数据,可以传递或保存。Java
要求其变量是 == 最终 == 不可变的。
第 14 章 流#
集合优化了对象的存储。而流(stream)与对象的成批处理有关。
我们平常说流,一般都是指I/O
流,而用stream流
指代本章所讲的流。大多数时候,我们将对象存储在一个集合中是为了处理它们,所以你会发现,自己编程的重点会从集合转向流。流使我们的程序更小,可读性更高,配合lambda
表达式和方法引用,流大大提升了JDK 8
的吸引力。
声明式编程#
声明式编程是一种有别于命令式编程的编程风格,我们声明 “What to do”(做什么),而不是像命令式编程那样指明 “How to do”(怎么做)。
内部迭代#
我们平常显式使用for
循环遍历的迭代被称为 “外部迭代”,而流使用 “内部迭代”:你不知道它具体是如何执行迭代的。然而,通过放宽对迭代方式的掌控,我们可以将其交给某种并行机制 —— 流的内部迭代效率更高。
惰性求值#
惰性求值意味着流只有在必要时才会被求值。你可以认为在 “被求值” 之前,流就像没有被调用的方法一样,只是声明了逻辑;又或者流被声明塑造成了能如此求值的模型,当真正需要 “被求值” 时,模型开始运行。
JDK 8
对流的支持#
接口的默认方法也是在JDK 8
新增的,它与流的添加或许有着千丝万缕的联系。
在创造了流这个概念后,Java
的设计者们遇到了一个难题:如何将流整合进库中,而又不影响已经使用了库的代码?因为,如果向接口加入新方法,会破坏每一个实现了该接口,但还没有实现这个新方法的类。。。无所谓,默认方法会出手默认方法使得我们可以向接口添加默认实现,实现子类而不必重写这些方法。
Optional
类型#
设计Optional
类型的初衷是为了避免流中发生异常而中断。如果流中没有元素,在执行某些流操作时会返回Optional
对象。
reduce(BinaryOperator)
#
形参命名(acc, i)#
由于reduce(BinaryOperator)
传入的lambda
表达式中的第一个参数是上次调用时的结果,第二个参数是来自流中的新值,最好将第一个参数命名为acc
、第二个参数命名为i
用以见名之意。
第 15 章 异常#
Java
的基本哲学是‘写得不好的代码无法运行’。
缺陷:异常缺失#
异常使用不当可能会导致异常被 “吞掉” 的情况。
异常说明#
“异常说明” 可以缩小,但不可以扩大,这与类在继承过程中的规则相反
Java
的缺点#
除了内存的清理,其他清理都不会自动发生,必须告知客户程序员,让他们自己处理。
使用异常的准则#
- 尽可能使用
try-with-resources
。 - 要在恰当的层次处理问题。除非你知道该如何处理,否则不要捕捉异常。