本文总结自《码处高效:Java开发手册》。
迪米特法则:即A模块使用B模块的某个接口行为,对B模块中除此行为之外的其他信息知道的尽可能少(强调封装,解耦)。
里氏代换原则:任何父类能够出现的地方,子类都能够出现(继承,is-a)。
在不知道什么样的访问控制权合适时,优先采用private控制级别。
谨慎使用继承,滥用继承的危害包括方法污染和方法爆炸:
(1)方法污染:父类具备的行为,通过继承传递给子类,但子类并不具备执行此行为的能力。
(2)方法爆炸:继承树不断扩大,底层类拥有的方法虽然都能够执行,但是由于方法众多,其中部分方法并非当前类的功能定位相关,很容易在实际编程中产生选择困难症。
因此提倡组合优先原则来扩展类的能力,而不是继承。
多态:根据运行时的实际对象类型,同一个方法产生不同的运行结果,使同一个行为在不同的对象上具有不同的表现形式。多态是指在编译层面无法确定最终调用的方法体,以重写(Override)为基础来实现面向对象的特性,在运行期由JVM进行动态绑定,调用合适的重写方法体来执行。
重载(Overload):编译期根据方法签名,就能够确定方法调用,属于静态绑定,本质上重载的结果是完全不同的方法。
JDK5到JDK11的重要类、特性和重大改变:
版本 | 特性 |
JDK5 | foreach迭代方式、可变参数、枚举、自动拆装箱、泛型、注解等 |
JDK6 | Desktop类和SystemTray类、使用Compiler API、轻量级HTTPServer API、对脚本语言的支持、Common Annotations等 |
JDK7 | Switch支持字符串作为匹配条件、泛型类型自动推断、try-with-resources资源关闭技巧、Object工具类、ForkJoinPool等重要类与特性 |
JDK8 | 接口的默认方法实现与静态方法、Lambda表达式、函数式接口、方法与构造函数引用、新的日期与时间API、流式处理等重要特性 |
JDK9 | Jigsaw模块化项目、简化进程API、轻量级JSON API、钱和货币的API、进程改善和锁机制优化、代码分段缓存等重要特性 |
JDK10 | 局部变量的类型判断、改进GC和内存管理、线程本地握手、备用内存设备上的堆分配等重要特性 |
JDK11 | 删除JavaEE和CORBA模块,增加基于嵌套的访问控制,支持动态类文件常量,改进Aarch64内联函数,提供实验性质的可扩展的低延迟垃圾收集器ZGC等重要特性 |
1. 类
1.1 类的定义由访问级别、类型、类名、是否抽象、是否静态、泛型标识、继承和实现关键字、父类和接口名称等组成。
类的访问级别有public和无访问控制符,类型分为class、interface、enum。
Java类主要由两部分组成:成员和方法。推荐的定义顺序如下:
(1)成员变量;
(2)公有方法;(完全暴露出去供外部调用的方法)
(3)保护方法;(对子类可见,重要性仅次于公有方法)
(4)私有方法;(类内部使用的方法)
(5)getter/setter;(一般不包含业务逻辑,承载的信息较少)
1.2 接口与抽象类:
抽象类在被继承时,体现的是is-a关系,接口在被实现时体现的是can-do关系。
is-a关系要符合里氏代换原则。can-do关系要符合接口隔离原则,实现类要有能力去实现并执行接口中定义的行为。
抽象类是模板式设计,而接口是契约式设计。
接口是顶级的“类”,虽然关键字是interface,但是编译后的字节码扩展名还是.class。
与接口相比,抽象类通常是对同类事物相对具体的抽象,通常包含抽象方法、实体方法、属性变量。如果一个抽象类只有一个抽象方法,那么它可以视作等同于一个接口。
接口示例:
public interface VechiSafe {
/**
* @param initSpeed 刹车时的初始速度
* @param brakeTime 从initSpeed开始刹车到停止行驶的时间,单位为ms
* @return 从开始刹车到停止行驶的距离
*/
double brake(int initSpeed, int brakeTime);
}
对于符合安全标准的车来说,一定需要遵循上面的接口实现brake方法,来评估其安全性。
抽象类示例,以AbstractCollection抽象类为例:
import java.util.Collection;
public abstract class AbstractCollection<E> implements Collection {
// 需要子类实现的抽象方法
public abstract int size();
// 子类可以通过继承获取到的实例方法,子类也可以重写该方法,通过多态调用不同的isEmpty方法
public boolean isEmpty() {
return size() == 0;
}
}
接口与抽象类的语法区别:
语法维度 | 抽象类 | 接口 |
定义关键字 | abstract | interface |
子类继承或实现关键字 | extends | implements |
方法实现 | 可以有 | 不能有,JDK8后,允许default实现 |
方法访问控制符 | 无限制 | 有限制,默认是public abstract |
属性访问控制符 | 无限制 | 有限制,默认是public static final |
静态方法 | 可以有 | 不能有,JDK8及以后,允许有 |
static{}静态代码块 | 可以有 | 不能有 |
本类型之间扩展 | 单继承 | 多继承 |
本类型之间扩展关键字 | extends | extends |
1.3 内部类:
在一个.java源文件中,只能定义一个类名与文件名完全一致的public类,使用public class来修饰。但在OOP的语言中,任何一个类都可以在内部定义另外一个类,前者为外部类,后者为内部类。内部类本身就是类的一个属性,与其他属性的定义方式一致。具体分为以下四种:
(1)静态内部类:static class StaticInnerClass{};
(2)成员内部类:private class InstanceInnerClass{};
(3)局部内部类:定义在方法或表达式内部;
(4)匿名内部类:(new Thread(){}).start();
例如,无论是什么类型的内部类,都会 编译成一个独立的.class文件:
public class OuterClass {
// 成员内部类
private class InstanceInnerClass {} // OuterClass$InstanceInnerClass.class
// 静态内部类
static class StaticInnerClass {} // OuterClass$StaticInnerClass.class
public static void main(String[] args) {
// 匿名内部类
(new Thread(){}).start(); // OuterClass$1.class
(new Thread(){}).start(); // OuterClass$2.class
// 方法内部类
class MethodClass1 {} // OuterClass$1MethodClass1.class
class MethodClass2 {} // OuterClass$1MethodClass2.class
}
}
外部类与内部类之间通过$符号分隔,其中匿名内部类使用数字进行编号,而方法内部类,使用编号加方法名称来标识是哪个方法的哪个内部类。
上面四种情况中,匿名内部类和静态内部类是最为常用的。内部类的加载与外部类通常不在同一个阶段进行。在JDK源码中,定义包内可见静态内部类的方式很常见,这样做的好处是:
(1)作用域不会扩散到包外;
(2)可以通过“外部类.内部类”的方式直接访问;
(3)内部类可以访问外部类中的所有静态属性和方法;
1.4 访问控制:
面向对象的核心思想之一就是封装,把有限的方法和成员公开给别人,这也是迪米特法则的内在要求,使外部调用方对方法体内的实现细节知道得尽可能少。实现封装需要使用关键字来限制类外部对类内属性和方法的随意访问,这些关键字就是访问控制符。
Java中的访问权限包括四个等级,权限控制严格程度由低到高:
访问权限控制符 | 任何地方 | 包外子类 | 包内 | 类内 |
public | OK | OK | OK | OK |
protected | NO | OK | OK | OK |
无 | NO | NO | OK | OK |
private | NO | NO | NO | OK |
在定义类时,推荐访问控制级别从严处理,有如下几条指导规则:
(1)如果不允许外部直接通过new创建对象,构造方法必须是private;
(2)工具类不允许有public和defautl构造方法;
(3)类非static成员变量而且与子类共享,必须是protected;
(4)类非static成员变量并且仅在本类使用,必须是private;
(5)类static成员变量如果仅在本类使用,必须是private;
(6)若是static成员变量,必须考虑是否为final;
(7)类成员方法只供类内部调用,必须是private;
(8)类成员方法只对继承类公开,那么限制为protected;
1.5 类关系:
(1)继承:extends(is-a)
(2)实现:implements(can-do)
(3)组合:类是成员变量(contains-a)
(4)聚合:类是成员变量(has-a)
(5)依赖:是除组合和聚合外的单向弱关系。比如使用另一个类的属性、方法,或以其作为方法的参数输入,或一起作为方法的返回值输出(depends-a)
(6)关联:是互相平等的依赖关系(links-a)
其中组合和聚合比较难区分,实际上组合和聚合都是将不同的类具备的功能组合起来产生更强大的类,唯一区别在于这些功能是否是可拆分出去的。如果是,那么是聚合关系;如果不是,那么是组合关系。
1.6 方法:
方法签名:方法签名包括方法名称和参数列表,是JVM标识方法的唯一索引,不包括返回值、访问权限控制符、抛出的异常类型等。
可变参数:JDK5版本引入,主要为了解决当时的反射机制和printf方法问题,适用于不确定参数个数的场景。可变参数通过“参数类型...”的方式定义。例如PrintStram类中printf方法使用了可变参数:
public PrintStream printf(String format, Object... args) {
return format(format, args);
}
// 调用printf方法示例
System.out.printf("%d", n);
System.out.printf("%d %s", n, "something");
尽管用起来很方便,但是如果在实际开发中使用不当,会严重影响代码的可读性和可维护性。因此,使用时要小心谨慎,尽量不要使用可变参数编程。如果一定要使用,则只有相同参数类型,相同业务含义的参数才可以,并且一个方法只能有一个可变参数,且这个参数必须是该方法的最后一个参数。此外建议不要使用Object作为可变参数类型。
参数保护:
(1)入参保护:入参保护实际上是对服务提供方的保护,常见于批量接口。批量接口是指能同时处理一批数据,但其处理能力并不是无限的,因此需要对入参的数据量进行判断和控制,如果超出处理能力,可以直接返回错误给客户端。
(2)参数校验:基于防御式编程理念,在方法内,对方法调用方传入的参数理性上保持不信任,所以对参数的有效值检测是非常有必要的。但是如果所有方法都进行参数校验,就会导致重复代码及不必要的检查影响代码性能。为了对代码健壮性和性能进行折中考量,以下总结了需要进行参数校验和无需校验的场景:
需要校验的场景:
- 调用频度低的方法
- 执行时间开销很大的方法。这种情况下,参数校验的时间几乎可以忽略不计,但如果因参数错误导致中间执行回退或错误,得不偿失
- 需要极高稳定性和可用性的方法
- 对外提供的开放接口
- 敏感权限入口
不要校验的场景:
- 极有可能被循环调用的方法,常见于递归调用的方法中,但在方法说明里必须注明外部参数检查
- 底层调用频度较高的方法。参数错误不太可能到底层才会暴露问题
- 声明成private只会被自己代码调用的方法
1.7 构造方法:
构造方法时方法名与类名同名的特殊方法,在新建对象时调用,可以通过不同的构造方法实现不同方式的对象初始化。有如下特征:
(1)构造方法名称必须与类名相同
(2)构造方法是没有返回类型的,即使是void也不能有。它返回对象的地址,并赋值个引用变量。
(3)构造方法不能被继承,不能被override,不能被直接调用(可以调用的方式:new、super、反射)
(4)类定义时提供了默认的无参构造方法,但如果显示定义有参构造方法,默认的无参构造方法会被覆盖,需要重新显式定义
(5)构造方法可以私有,外部无法使用私有构造方法创建对象
(6)抽象类中可以定义构造方法,接口中不行
(7)枚举类中构造方法是特殊的存在,可以定义,不能加public修饰,默认为private,是绝对的单例,不允许外部以创建对象的方式生成枚举对象
(8)静态代码块仅会在类加载时执行一次,且早于构造方法的执行:
public class Son extends Parent {
static {
System.out.println("Son 静态代码块");
}
Son() {
System.out.println("Son 构造方法");
}
public static void main(String[] args) {
new Son();
new Son();
}
}
class Parent {
static {
System.out.println("Parent 静态代码块");
}
Parent() {
System.out.println("Parent 构造方法");
}
}
输出结果如下:
Parent 静态代码块
Son 静态代码块
Parent 构造方法
Son 构造方法
Parent 构造方法
Son 构造方法
1.8 类内方法:
类中包括三类方法:实例方法、静态方法、静态代码块
(1)实例方法:依附于某个具体的对象,类内部各实例方法间可以互相调用,也可以直接读写类内变量,但是不包含this。当.class字节码文件被加载后,实例方法并不会被分配方法入口地址,只有在对象创建之后才会被分配。实例方法同样可以调用静态变量和静态方法,但是当在外部创建对象后,应尽量使用“类名.方法名”的方式调用静态方法,而不是通过对象名,这可以减轻编译器负担,也可以提升代码可读性。
(2)静态方法:又称为类方法,当类加载后,即分配相应的内存空间,由于其生命周期的限制,使用静态方法需要注意以下两点:
- 静态方法中不能使用实例成员变量和实例方法
- 静态方法中不能使用super和this关键字,这两个关键字指代的都是需要被创建出的对象
(3)静态代码块
1.9 重写、覆写(Override)
Java中有些子类是延迟加载甚至网络加载的,所以最终的实现需要在运行期判断,这就是所谓的动态绑定。动态绑定时多态性得意实现的重要因素,元空间有一个方法表保存着每个可以实例化类的方法信息,JVM通过方法表快速的激活实例方法。如果某个类覆写了父类的某个方法,则方法表中的方法指向引用会指向子类的实现。
向上转型:
Father father = new Son();
father.doSomething();
使用向上转型时,需要注意如下两点:
(1)无法调用子类中存在而父类本身不存在的方法;
(2)可以调用到子类中覆写了父类的方法,这是一种多态实现;
想成功的访问父类方法,需要满足4个条件:
(1)访问权限不能变小
假如父类中的方法是用public修饰的,子类覆写时变成private,如果编译器开后门,允许执行子类更小权限的方法,这会破坏封装。
class Father {
public void method() {
System.out.println("Father's method");
}
}
class Son extends Father {
// 编译器报错,不允许修改为访问权限更严格的修饰符
@Override
private void method() {
System.out.println("Son's method");
}
}
(2)返回类型能够向上转型为父类的返回类型:
虽然返回值并不是方法签名的一部分,但是在覆写时,父类的方法表指向了子类实现方法,编译器会检查返回值是否向上兼容。这里的向上转型必须是严格的继承关系,数据基本类型不存在通过继承向上转型的问题。
再比如int和Integer,他们是不兼容的返回类型,不会自动装箱。再比如子类方法返回int,父类方法返回long,虽然数据表示范围更大,但是它们之间没有继承关系。当然如果是Object,能够兼容任何对象。
(3)异常也要能够向上转型为父类的异常:
异常分为checked(可恢复异常)和unchecked(不可恢复的错误)两种类型。
如果父类抛出一个checked异常,则子类只能抛出此异常或此异常的子类。
而unchecked异常不用显式的向上抛出,所以没有任何兼容问题。
(4)方法签名、参数类型及个数必须严格一致
总结,方法的覆写可以总结为“一大两小两同”。
一大:子类的方法访问权限控制符只能相同或变大;
两小:返回值和异常都只能变小,需要和父类的返回值及抛出的异常具有继承关系;
两同:方法名和参数必须完全相同;
下面是一个完美的示例:
import java.sql.SQLClientInfoException;
import java.sql.SQLException;
public class Father {
protected Number doSomething(int a, Integer b, Object c) throws SQLException {
System.out.println("Father's doSomething");
return new Integer(7);
}
}
class Son extends Father {
@Override
public Integer doSomething(int a, Integer b, Object c) throws SQLClientInfoException {
if (a == 0) {
throw new SQLClientInfoException();
}
return new Integer(17);
}
}
覆写只能针对非静态、非final、非构造方法。非final说明该方法不可被覆写。
如果想在子类中调用父类中的原方法,可以使用super.xxx()的方式。但是一定要注意this,可能会导致方法的循环调用,从而“爆栈”。
public class Father {
protected void doSomething() {
System.out.println("Father's doSomething");
this.doSomething();
}
public static void main(String[] args) {
Father father = new Son();
father.doSomething();
}
}
public class Son {
@Override
public void doSomething() {
System.out.println("Son's doSomething");
super.doSomething();
}
}
这里的this指向的是实例father,调用的是Son类中覆写的doSomething方法,因此会循环调用父类和子类的doSomething方法。
1.10 重载(Overload)
同一个类中,如果多个方法具有相同的方法名称、不同的参数类型、参数个数、参数顺序,即可称为重载。
在编译器眼里,方法名称+参数列表,组成一个唯一键,称为方法签名,JVM通过这个唯一键决定调用哪种重载方法。
所以在重载方法时,一定不要声明两个方法签名一致,但是返回类型不同的方法,否则会导致编译出错。
public class OverloadMethods {
public void overloadMethod() { // ()V
System.out.println("无参方法");
}
public void methodForOverload(int param) { // (I)V
System.out.println("参数为基本类型int的方法");
}
public void methodForOverload(Integer param) { // (Ljava/lang/Integer;)V
System.out.println("参数为包装类型Integer的方法");
}
public void methodForOverload(Integer... param) { // ([Ljava/lang/Integer];)V
System.out.println("可变参数方法");
}
public void methodForOverload(Object param) { // (Ljava/lang/Object;)V
System.out.println("参数为Object的方法");
}
}
methodForOverload(7); // 将会执行哪个方法?
JVM在重载方法中,选择合适的目标方法的顺序如下:
(1)精确匹配;
(2)如果是基本数据类型,自动转换为更大表示范围的基本类型;
(3)通过自动拆箱与装箱;
(4)通过子类向上转型继承路线依次匹配;
(5)通过可变参数匹配;
所以这里毫无疑问会精确匹配入参为int的方法。如果是methodForOverload(new Integer(7)),入参为Integer的方法胜出。
如果是methodForOverload(null),那么会调用入参为Integer的方法。因为null可以匹配任何类对象,而Integer是一个类,而且是Object的子类。此时如果还有一个重载的入参为String的方法,编译器将无法区分使用哪个方法,从而编译出错。
由于重载是编译期就能确定调用的是哪个方法,所以重载又被称为静态绑定。
1.11 泛型
泛型的本质是类型参数化,解决不确定具体对象类型的问题。在OOP语言中,允许程序员在强类型校验下定义某些可变部分,以达到代码复用的目的。
在泛型没有出现前,对于不确定类型的参数,都是使用Object替代,其中往往还涉及到强制类型转换,这会带来一定的风险:
public class Stove {
public static Object heat(Object food) {
System.out.println(food + "is done");
return food;
}
public static void main(String[] args) {
Meat meat = new Meat();
meat = (Meat)Stove.heat(meat);
Soup soup = new Soup();
soup = (Soup)Stove.heat(soup);
}
}
泛型可以完美的解决这个问题。
泛型可以定义在类、接口、方法中,编译器通过识别尖括号和尖括号内的字母来解析泛型。
在泛型定义时,约定俗成的符号包括:E代表Element,用于集合中的元素;T代表the Type of object,表示某个类;K代表Key,V代表Value,用于键值对元素。
public class GenericDefinitionDemo {
static <String, T, Alibaba> String get(String string, Alibaba alibaba) {
return string;
}
public static void main(String[] args) {
Integer first = 222;
Long second = 333L;
Integer result = get(first, second);
}
}
上面这段代码看似有问题,get的第一个参数明明要求是String类型的,调用时确传入Integer类型的参数。但实际上,这段代码是可以编译通过的,因为get方法是泛型方法,参数列表和返回值中的String是<>中的String,而不是java.lang.String。String这里可以理解为和E、T是类似的标记。实际开发中在定义泛型方法时肯定会避免出现这种情况,这里只是期望能够对以下几点加深理解:
(1)尖括号中的每个元素都指代一种未知类型;
(2)尖括号的位置非常讲究,必须在类名之后或方法返回值类型之前;
(3)泛型在定义处只具备执行Object方法的能力。(所以在泛型方法get中想要调用alibaba.intValue()是行不通的,只能调用Object类的实例方法,例如toString);
(4)对于编译后的字节码指令,其实没有这些花里胡哨的方法签名,充分说明了泛型只是一种编写代码时的语法检查(泛型擦除)。
泛型的好处:
(1)类型安全,不用担心抛出classCastException;
(2)提升可读性
(3)代码重用
回到Stove类,我们可以使用泛型这样改写:
public class Stove {
public static <T> T heat(T food) {
System.out.println(food + "is done");
return food;
}
public static void main(String[] args) {
Meat meat = new Meat();
meat = (Meat)Stove.heat(meat);
Soup soup = new Soup();
soup = (Soup)Stove.heat(soup);
}
}