异常的适用情况是?
答:- 异常应该且仅用于异常情况,即异常不能代替正常的条件判断。
- 真正出现异常的时候,应该抛出异常,而不是返回特殊值。
异常的来源大概是?
答:- 用户:用户的输入有问题。
- 程序员:编程错误。
- 第三方:泛指其他情况,如 I/O 错误、网络、数据库、第三方服务等。
- 每种异常都应该进行适当的处理。
异常处理的目标是?
答:- 处理的目标可以分为恢复和报告。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。
- 对用户,如果用户输入不对,可以提示用户具体哪里输入不对;如果是编程错误,可以提示用户系统错误、建议联系客服;如果是第三方连接问题,可以提示用户稍后重试。
- 程序都不应该假定第三方是可靠的,应该有容错机制。
异常处理的一般逻辑是?
答:- 如果自己知道怎么处理异常,就进行处理;如果可以通过程序自动解决,就自动解决;如果异常可以被自己解决,就不需要再向上报告。
- 如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为
cause
重新抛出一个异常。 - 总有一层代码需要为异常负责。可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是是主程序;如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息;对运维和开发人员,则应该输出详细的异常链和异常栈到日志。
为什么要有异常处理机制?
答:- 在没有异常机制的情况下,唯一的退出机制就是
return
,判断是否异常的方法就是返回值。 - 程序的正常逻辑和异常逻辑混杂在一起,代码的可读性和可维护性变差。
- 程序员经常偷懒,假装异常不会发生。
- 在没有异常机制的情况下,唯一的退出机制就是
异常处理机制的好处?
答:处理异常情况的代码大大减少,代码的可读性、可靠性、可维护性都得到提高,具体表现在:- 程序的正常逻辑与异常逻辑可以相分离。
- 异常情况可以集中进行处理。
- 异常还可以自动向上传递,不再需要每层方法都进行处理。
- 异常也不再可能被自动忽略。
Java 异常(三):异常处理
怎样理解异常处理中的
catch
匹配?
答:catch
语句可以有多条,每条对应一种异常类型。- 自 Java 7 开始支持一种新的语法,多个异常之间可以用 “ | ” 操作符。
怎样理解重新抛出(
throw
)异常?
答:在catch
块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的。为什么要重新抛出(
throw
)异常?
答:因为当前代码不能够完全处理该异常,需要调用者进一步处理。为什么要抛出(
throw
)一个新的异常?
答:- 当然是因为当前异常不太合适,不合适可能是信息不够,需要补充一些新信息。
- 还可能是过于细节,不便于调用者理解和使用。
- 如果调用者对细节感兴趣,还可以通过
getCause()
方法获取到原始异常。
怎样理解异常机制中的
finally
?
答:finally
内的代码不管有无异常发生,都会执行。具体来说:- 如果没有异常发生,在
try
内的代码执行结束后执行。 - 如果有异常发生且被
catch
捕获,在catch
内的代码执行结束后执行。 - 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。
- 如果没有异常发生,在
使用
finally
的场景?
答:finally
一般用于释放资源,如数据库连接、文件流等。finally
语句的执行细节?
答:- 如果在
try
或catch
语句内有return
语句,则return
语句在finally
语句执行结束后才执行,但finally
并不能改变返回值,即finally
中对返回值的修改不会被返回。(这点也是挺奇葩的) - 如果在
finally
语句中也有return
语句,那么try
或catch
内的return
会丢失,实际会返回finally
中的返回值。 finally
中有return
不仅会覆盖try
和catch
内的返回值,还会掩盖try
和catch
内的异常,就像异常没有发生一样。也就是在异常抛出之前执行了finally
里的return
语句。- 如果
finally
中抛出了异常,则原异常也会被掩盖。 - 一般而言,为避免混淆,应该避免在
finally
中使用return
语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理,即异常嵌套。
- 如果在
怎样理解
try-with-resources
?
答:对于一些使用资源的场景,比如文件和数据库连接,典型的使用流程是首先打开资源,最后在finally
语句中调用资源的关闭方法。针对这种场景,Java 7 开始支持一种新的语法,称之为try-with-resources
。这种语法针对实现了
java.lang.AutoCloseable
接口的对象,该接口的定义为:1
2
3public interface AutoCloseable {
void close() throws Exception;
}
* 没有 `try-with-resources` 时,使用形式如下:
1
2
3
4
5
6
7
8
public static void useResource() throws Exception {
AutoCloseable r = new FileInputStream("hello"); // 创建资源
try {
// 使用资源
} finally {
r.close();
}
}
* 使用 `try-with-resources` 语法时,形式如下:
1
2
3
4
5
public static void useResource() throws Exception {
try(AutoCloseable r = new FileInputStream("hello")) { // 创建资源
// 使用资源
}
}
资源 `r` 的声明和初始化放在 `try` 语句内,不用再调用 `finally`,在语句执行完 `try` 语句后,会**自动调用资源的 `close()` 方法**,对程序员更加友好。
* **资源可以定义多个,以分号分隔**。在 **Java 9** 之前,资源必须声明和初始化在 `try` 语句块内,Java 9 去除了这个限制,资源可以在 `try` 语句外被声明和初始化,但必须是 final 的或者是事实上 `final` 的(即虽然没有声明为 `final` 但也没有被重新赋值)。
怎样理解
throws
关键字?
答:- 用于声明一个方法可能抛出的异常。
throws
跟在方法的括号后面,可以声明多个异常,以逗号分隔。- 这个声明的含义是:这个方法内可能抛出这些异常,且没有对这些异常进行处理,至少没有处理完,调用者必须进行处理。
- 这个声明没有说明具体什么情况会抛出异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好地处理异常。
throws
关键字的细节?
答:- 对于未受检异常,是不要求使用 throws 进行声明的,但对于受检异常,则必须进行声明。换句话说,对于受检异常,如果没有声明,则不能抛出。
- 对于受检异常,不可以抛出而不声明,但可以声明抛出但实际不抛出。这主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了。子类不能抛出父类方法中没有声明的受检异常,所以就将所有可能抛出的异常都写到父类上了。
- 如果一个方法内调用了另一个声明抛出受检异常的方法,则必须处理这些受检异常。处理方式既可以是
catch
,也可以是继续使用throws
。
未受检异常和受检异常的区别?
答:受检异常必须出现在throws
语句中,调用者必须处理,Java 编译器会强制这一点,而未受检异常则没有这个要求。为什么要有这个区分?我们自定义异常的时候使用使用受检还是未受检异常?
答:- 对于这个问题,业界有各种各种的观点和争论,没有特别一致的结论。
- 目前一种更被认同的观点是:Java 中对受检异常和未受检异常的区分是没有太大意义的,可以统一使用未受检异常来代替。
- 无论是受检异常还是未受检异常,无论是否出现在 throws 声明中,都应该在合适的地方以适当的方式进行处理。
- 观点本身不重要,重要的是一致性:一个项目中,应该对如何使用异常达成一致,并按照约定使用。
Java 异常(二):异常类
怎样理解
Throwable
?
答:Throwable
是所有异常类的父类。Throwable
类有两个主要参数:一个是message
,表示异常信息;另一个是cause
,表示触发该异常的其他异常。- 异常可以形成一个异常链,上层的异常由底层的异常触发,
cause
表示底层异常。 Throwable
还有一个public
方法用于设置cause
:Throwable initCause(Throwable cause)
。Throwable
的某些子类没有带cause
参数的构造方法,就可以通过这个方法来设置,这个方法最多只能被调用一次。- 在所有构造方法的内部,都有一句重要的方法调用:
fillInStackTrace()
,它会将异常栈信息保存下来,这是我们能看到异常栈的关键。
Throwable
中常用的用于获取异常信息的方法有哪些?
答:void printStackTrace()
,打印异常栈信息到标准错误输出流。void printStackTrace(PrintStream s)
/void printStackTrace(PrintWriter s)
,打印栈信息到指定的流。String getMessage()
,获取设置的异常message
。Throwable getCause()
,获取异常的cause
。StackTraceElement[] getStackTrace()
,获取异常栈每一层的信息,每个StackTraceElement
包括文件名、类名、方法名、行号等信息。
异常类的体系是怎样的?
答:Throwable
是所有异常类的基类,它有两个子类:Error
和Exception
。Error
表示系统错误或资源耗尽,即不可控的内部原因,由 Java 系统自己使用,应用程序不应抛出和处理。常见的子类有:VirtualMachineError
、OutOfMemoryError
、StackOverflowError
Exception
表示应用程序错误,即不可控的外部原因,它有很多子类,应用程序也可以通过继承Exception
或其子类创建自定义异常。常见的子类有:IOException
、RuntimeException
、SQLException
。
怎么理解未受检异常和受检异常?
答:- 未受检异常包括:
RuntimeException
及其子类和Error
及其子类。 - 受检异常包括:
Exception
及其除了RuntimeException
之外的其他子类。 RuntimeException
比较特殊,它的名字有点误导,因为其他异常也是运行时产生的,它表示的实际含义就是未受检异常。- 受检和未受检的区别在于 Java 如何处理这两种异常。对于受检异常,Java 会强制要求程序员进行处理,否则会有编译错误,而对于未受检异常则没有这个要求。有点类似于编译时和运行时的区别。
- 未受检异常包括:
为什么定义这么多不同的异常类?
答:- 这么多不同的异常类其实并没有比
Throwable
这个基类多多少属性和方法,大部分类在继承父类后只是定义了几个构造方法,这些构造方法也只是调用了父类的构造方法,并没有额外的操作。 - 之所以定义这么多不同的异常类主要是为了名字不同。异常类的名字本身就代表了异常的关键信息,无论是抛出异常还是捕获异常,使用合适的名字都有助于代码的可读性和可维护性。
- 这么多不同的异常类其实并没有比
怎样自定义异常?
答:- 一般是继承
Exception
或者它的某个子类。 - 如果继承的父类是
RuntimeException
或它的某个子类,那么此时自定义异常也是未受检异常。 - 如果继承的父类是
Exception
或它的除了RuntimeException
之外的其他子类,那么此时自定义的异常是受检异常。
- 一般是继承
Java 异常(一):初识异常
发生异常的原因?
答:- 不可控的内部原因,比如内存不够了、磁盘满了。
- 不可控的外部原因,比如网络连接出现问题。
- 更多的是程序编写错误,比如空指针异常等。
怎样理解异常这个概念?
答:- 这些非正常情况在 Java 中统一被认为是异常,Java 使用异常机制来统一处理。
- 异常是相对于
return
的一种退出机制,可以由系统触发,也可以由程序通过throw
语句触发。 - 异常可以通过
try/catch
语句进行捕获并处理,如果没有捕获,则会触发默认处理机制,即输出异常栈信息并退出程序。 - 异常有不同的类型。
发生空指针异常时,具体发生了什么?
答:- JVM 发现对象引用为 null 时,没有办法继续执行了,这时就启用异常处理机制,首先创建一个异常对象,这里是类
NullPointerException
的对象,然后查找看谁能处理这个异常。如果没有代码可以处理这个异常,那么 Java 会启用默认处理机制,即打印异常栈信息到屏幕,并退出程序。 - 异常栈信息就包括了从异常发生点到最上层调用者的轨迹,还包括行号,可以说,这个栈信息是分析异常最为重要的信息。
- Java 的默认异常处理机制是退出程序,异常发生点后的代码都不会执行。
- JVM 发现对象引用为 null 时,没有办法继续执行了,这时就启用异常处理机制,首先创建一个异常对象,这里是类
throw
关键字的含义?
答:throw
的意思是抛出异常,它会触发 Java 的异常处理机制。在比如说空指针异常中,我们没有看到throw
的代码,可以认为throw
是由 Java 虚拟机自己实现的。对比一下
throw
关键字和return
关键字?
答:return
代表正常退出,throw
代表异常退出。return
的返回位置是确定的,就是上一级调用者;throw
后执行哪行代码则经常是不确定的,由异常处理机制动态确定。
异常处理机制的流程?
答:- 异常处理机制会从当前函数开始查看谁“捕获”了这个异常,当前函数没有就查看上一层,直到主函数,如果主函数也没有,就使用默认机制,即输出异常栈信息并退出。
- “捕获”是指使用
try/catch
关键字,try
后面的花括号 {} 包含可能抛出异常的代码。捕获异常后,程序就不会异常退出了,但try
语句内异常点之后的其他代码就不会执行了,执行完catch
内的语句后,程序会继续执行catch
花括号外的代码。
Java 类的扩展(四):枚举的本质
怎样理解枚举这个概念?
答:- 枚举是一种取值有限的特殊的数据。
- 使用关键字
enum
定义枚举,值一般是大写字母,多个值之间以逗号分隔。 - 枚举类型可以定义为一个单独的文件,也可以定义在其他类内部。
枚举的细节?
答:- 枚举变量的
toString()
方法返回其字面值,所有枚举类型也都有一个name()
方法,返回值与toString()
一样。 - 枚举变量可以使用
equals
和==
进行比较,结果是一样的。 - 枚举值是有顺序的,可以比较大小。枚举类型都有一个方法
int ordinal()
,表示枚举值在声明时的顺序,从 0 开始。 - 枚举类型都实现了 Java API 中的
Comparable
接口,都可以通过方法compareTo()
与其他枚举值进行比较,其实就是比较ordinal
的大小。 - 枚举变量可以用于和其他类型变量一样的地方,如方法参数、类变量、实例变量等。枚举还可以用于
switch
语句。在switch
语句中,枚举值会被转换为其对应的ordinal
值。 - 在
switch
语句内部的case
值部分,枚举值不能带枚举类型前缀。 - 枚举类型都有一个静态的
valueOf(String)
方法,返回字符串对应的枚举值。 - 枚举类型也都有一个静态的
values()
方法,返回一个包括所有枚举值的数组,顺序与声明时的一致。 - Java 是从 Java 5 才开始支持枚举的,在此之前,一般是在类中定义静态整型变量来实现类似功能。
- 枚举类型本质上也是类,但由于编译器自动做了很多事情,因此它的使用更为简洁、安全和方便。
- 枚举变量的
枚举的好处?
答:- 定义枚举的语法更为简洁,可读性更强。
- 枚举更为安全。一个枚举类型的变量,它的值要么为
null
,要么为枚举值之一,不可能为其他值;但使用整型变量,它的值就没有办法强制,值可能就是无效的。 - 枚举类型自带很多便利的方法,易于使用,对程序员更友好。
枚举是怎么实现的?
答:- 枚举类型实际上会被 Java 编译器转换为一个对应的类,这个类继承了 Java API 中的
java.lang.Enum
类。 Enum
类有name
和ordinal
两个实例变量,在构造方法中需要传递,name()
、toString()
、ordinal()
、compareTo()
、equals()
方法都是由Enum
类根据其实例变量name
和ordinal
实现的。values()
和valueOf()
方法是编译器给每个枚举类型自动添加的。
- 枚举类型实际上会被 Java 编译器转换为一个对应的类,这个类继承了 Java API 中的
枚举的典型应用场景?
答:- 实际中枚举经常会有关联的实例变量和方法,每个枚举值可能有关联的缩写和中文名称。
- 需要注意的是,枚举值的定义需要放在最上面,以逗号分隔,枚举值写完之后,要以分号结尾,然后才能写其他代码。
- 每个枚举值经常有一个关联的标识符(id),通常用 int 整数表示,使用整数可以节约存储空间,减少网络传输。
为什么不能使用枚举自带的
ordinal
值表示枚举值的 id?
答:使用ordinal
值并不是一个好的选择,因为ordinal
值会随着枚举值在定义中的位置变化而变化,但一般来说,我们希望 id 值和枚举值的关系保持不变,尤其是表示枚举值的 id 已经保存在了很多地方的时候。枚举还有哪些高级用法?
答:- 每个枚举值可以有关联的类定义体。
- 枚举类型可以声明抽象方法,每个枚举值中可以实现该方法,也可以重写枚举类型的其他方法。
- 枚举可以实现接口,也可以在接口中定义枚举。
Java 类的扩展(三):内部类的本质
怎样理解内部类?
答:- 一个类放在另一个类的内部,称为内部类。
- 内部类只是 Java 编译器的概念,对于 JVM 而言,它是不知道内部类这回事的,每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件。
- 内部类可以方便地访问外部类的私有变量。
- 内部类本质上都会被转换为独立的类,但一般而言,它们可以实现更好的封装,代码实现上也更简洁。
内部类的好处?
答:- 可以实现对外部完全隐藏。
- 更好的封装性。
- 写法上也更简洁。
内部类的分类?
答:根据定义的位置和方式不同,主要有 4 种内部类:- 静态内部类。
- 成员内部类。
- 方法内部类。
- 匿名内部类。
怎样理解静态内部类?
答:- 语法上,静态内部类除了位置放在其他类内部外,它与一个独立的类差别不大,可以有静态变量、静态方法、成员方法、成员变量、构造方法等。
- 静态内部类与外部类的联系不大,它可以访问外部类的静态变量和方法,但不能访问实例变量和方法。在类内部,可以直接使用内部静态类。
public
静态内部类可以被外部使用,语法是:外部类.静态内部类
。
静态内部类是怎样实现的?
答:- 静态内部类会被编译为
Outer$Inner
的形式。 - 静态内部类访问外部类的私有静态变量的实现是:Java 自动为外部类生成一个非私有静态方法
access$0
,这个方法返回这个私有静态变量。
- 静态内部类会被编译为
静态内部类的使用场景?
答:如果它与外部类关系密切,且不依赖于外部类实例,则可以考虑定义为静态内部类。试举出几个 Java API 中使用静态内部类的例子?
答:Integer
类内部有一个私有静态内部类IntegerCache
,用于支持整数的自动装箱。- 表示链表的
LinkedList
类内部有一个私有静态内部类Node
,表示链表中的每个节点。 Character
类内部有一个 public 静态内部类UnicodeBlock
,用于表示一个 Unicode Block。
怎样理解成员内部类?
答:- 成员内部类没有
static
修饰符。 - 与静态内部类不同,除了静态变量和方法,成员内部类还可以直接访问外部类的实例变量和方法。
- 如果和外部类有重名,成员内部类还可以通过
外部类.this.xxx
的方式引用外部类的实例变量和方法,如Outer.this.action()
。
- 成员内部类没有
怎样创建成员内部类对象?
答:- 与静态内部类不同,成员内部类对象总是与一个外部类对象相连,在外部使用时,因为不是静态,所以它不能直接通过
new Outer.Inner()
的方式创建对象,而是要先创建一个外部类对象。 - 语法:
外部类对象.new 内部类()
,如outer.new Inner()
。
- 与静态内部类不同,成员内部类对象总是与一个外部类对象相连,在外部使用时,因为不是静态,所以它不能直接通过
成员内部类、方法内部类和匿名内部类中都不可以定义静态变量和方法,Java 为什么要有这个规定?
答:可以这么理解,这些内部类是与外部实例相连的,不应独立使用,而静态变量和方法作为类的属性和方法,一般是独立使用的,在内部类中意义不大,而如果内部类确实需要静态变量和方法,那么也可以挪到外部类中。说白了,静态与非静态,一个是类的属性,一个是对象的属性,二者是泾渭分明的。成员内部类是怎么实现的?
答:- 生成两个类:一个是 Outer,一个是 Outer$Inner。
- 外部类一般会相应生成两个非私有静态方法:
access$0
用于访问变量,access$1
用于访问方法。
成员内部类有哪些应用场景?
答:- 如果内部类与外部类关系密切,需要访问外部类的实例变量或方法,则可以考虑定义为成员内部类。
- 外部类的一些方法的返回值可能是某个接口,为了返回这个接口,外部类方法可能使用内部类实现这个接口,这个内部类可以被设为 private,对外完全隐藏。
试举出 Java API 中使用成员内部类的例子?
答:类LinkedList
中,它的两个方法listIterator
和descendingIterator
的返回值都是接口Iterator
,调用者可以通过Iterator
接口对链表遍历,listIterator
和descendingIterator
内部分别使用了成员内部类ListItr
和DescendingIterator
,这两个内部类都实现了接口Iterator
。怎样理解方法内部类?
答:- 内部类还可以定义在一个方法体中,即为方法内部类。
- 方法内部类只能在定义的方法内被使用。
- 如果方法是实例方法,则除了静态变量和方法,方法内部类还可以直接访问外部类的实例变量和方法;如果方法是静态方法,则方法内部类只能访问外部类的静态变量和方法。
- 方法内部类还可以直接访问方法的参数和方法中的局部变量,不过,这些变量必须被声明为
final
。 - 方法内部类可以用成员内部类代替,至于方法参数,也可以作为参数传递给成员内部类。不过,如果类只在某个方法内被使用,使用方法内部类,可以实现更好的封装。
- 实际使用中较少,感觉主要是 Java 的一个语法糖,也许 JDK 源码中应用得较多些。
为什么方法内部类访问外部方法中的参数和局部变量时,这些变量必须被声明为 final?
答:- 因为实际上,方法内部类操作的并不是外部的变量,而是它自己的实例变量,只是这些变量的值和外部一样,对这些变量赋值,并不会改变外部的值,为避免混淆,左移干脆强制规定必须声明为 final。
- 如果的确需要修改外部的变量,那么可以将变量改为只含该变量的数组,修改数组中的值。
怎样理解匿名内部类?
答:- 匿名内部类没有单独的类定义,它在创建对象的同时定义类。
- 匿名内部类只能被使用一次,用来创建一个对象。
- 匿名内部类没有名字,没有构造方法,但可以根据参数列表,调用对应的父类构造方法。
- 匿名内部类可以定义实例变量和方法,可以有初始化代码块,初始化代码块可以起到构造方法的作用,只是构造方法可以有多个,而初始化代码块只能有一份。
- 因为没有构造方法,它自己无法接受参数,如果必须要参数,则应该使用其他内部类。
- 与方法内部类一样,匿名内部类也可以访问外部类的所有变量和方法,可以访问方法中的
final
参数和final
局部变量。
匿名内部类是怎么实现的?
答:每个匿名内部类也都被生成一个独立的类,只是类的名字以外部类加数字编号,没有有意义的名字。匿名内部类的使用场景?
答:匿名内部类能做的,方法内部类都能做。但如果对象只会创建一次,且不需要构造方法来接收参数,则可以使用匿名内部类,写法上也更简洁。怎么理解回调这个概念?
答:- 回调即回过头来调用,是相对于一般的正向调用而言的。
- 匿名内部类是实现回调接口的一种简便方式。
Java 类的扩展(二):抽象类
怎么理解抽象类和抽象方法?
答:- 抽象类:就是抽象的类,抽象类没有直接对应的对象,表达的是抽象的概念,一般是具体类的比较上层的父类。
- 抽象方法:只有子类才知道如何实现的方法。
- 关键字:
abstract
。
抽象类的细节?
答:- 定义了抽象方法的类必须被声明为抽象类,但抽象类可以没有抽象方法。
- 抽象类和具体类一样,可以定义具体方法、实例变量等。
- 抽象类和具体类的核心区别是:抽象类不能创建对象。
- 抽象类不能创建对象,要创建对象,必须使用它的具体子类。
- 一个类在继承抽象类后,必须实现抽象类中定义的所有抽象方法。
- 与接口类似,抽象类虽然不能使用
new
,但可以声明抽象类的变量,引用抽象类具体子类的对象。
为什么需要抽象类、为什么不能定义一个空方法表示抽象的含义?
答:- 引入抽象方法和抽象类,是 Java 提供的一种语法工具,对于一些类和方法,引导使用者正确使用它们。虽然从语法上抽象类不是必需的,但它能使程序更清晰,可以减少误用。
- 使用抽象方法而非空方法体,子类就知道它必须要实现该方法,而不可能忽略,若忽略 Java 编译器会提示错误。
- 使用抽象类,类的使用者创建对象的时候,就知道必须要使用它的某个具体子类,而不可能误用不完整的父类。
- 每个人都可能犯错,减少错误不能只依赖人的优秀素质,还需要一些机制,使得一个普通人都容易把事情做对,而难以把事情做错。抽象类就是 Java 提供的这样的一种减少犯错的机制。
抽象类和接口的区别?
答:- 接口中不能定义实例变量,抽象类可以。
- 抽象类和接口是配合而非替代关系,接口声明能力,抽象类提供默认实现,实现全部或部分方法。
- 一个接口经常有一个对应的抽象类。
Java 类的扩展(一):接口的本质
接口的本质是什么?
答:接口是一种能力,很多时候反映的是对象以及对对象操作的本质。定义接口的格式?
答:- 用
interface
这个关键字声明接口,修饰符一般都是public
。 - 没有定义方法体,Java 8 之前,接口内不能实现方法。
- 接口方法不需要加修饰符,加与不加都相当于
public abstract
。 - 定义一个接口本身并没有做什么,也没有太大的用处,它还需要至少两个参与者:一个需要实现接口,另一个使用接口。
- 类可以实现接口,表示类的对象具有接口所表示的能力。
- 用
使用接口的格式?
答:- 接口不能 new,不能直接创建一个接口对象,对象只能通过类来创建。
- 可以声明接口类型的变量,引用实现了接口的类对象。
针对接口编程的好处是?
答:代码复用,降低耦合,提高灵活性。接口的几个小细节?
答:- 接口中的变量。接口中可以定义变量,修饰符是
public static final
,但这个修饰符是可选的,即使不写,也是public static final
,通过接口名.变量名
访问变量。 - 接口的继承。接口也可以继承,和类一样的是,关键字也是
extends
;和类不同的是,接口可以有多个父接口,即public interface IChild extends IBase1, IBase2 {}
的写法是合法的。 - 类的继承与接口。类的继承与接口可以共存,需要注意的是关键字
extends
要放在implements
之前。 instanceof
。与类一样,接口也可以使用instanceof
关键字,用来判断一个对象是否实现了某接口。
- 接口中的变量。接口中可以定义变量,修饰符是
怎么理解使用组合和接口替代继承?
答:- 继承至少有两个好处:一个是复用代码;另一个是利用多态和动态绑定统一处理多种不同子类的对象。
- 使用组合替代继承,可以复用代码,但不能统一处理。
- 使用接口替代继承,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。
- 将组合和接口结合起来替代继承,就既可以统一处理,又可以复用代码了。
Java 8 和 Java 9 对接口做了哪些增强?
答:- 在 Java 8 之前,接口中的方法都是抽象方法,都没有实现体。
- Java 8 允许在接口中定义两类新方法:静态方法和默认方法,它们有实现体。
示例:
1
2
3
4
5
6
7
8
9public interface IDemo {
void hello();
public static void test() {
System.out.println("hello");
}
default void hi() {
System.out.println("hi");
}
}静态方法
test()
可以通过IDemo.test()
调用。
* 默认方法用关键字default
修饰。默认方法有默认的实现,实现类可以改变它的默认实现,也可以不改变。- 在 Java 8 中,静态方法和默认方法都必须是
public
的,Java 9 去除了这个限制,它们都可以是private
的,引入private
方法主要是为了方便多个静态或默认方法复用代码。
Java 8 引入默认方法的原因是?
答:- 引入默认方法主要是函数式数据处理的需求,是为了便于给接口增加功能。
- 在没有默认方法之前,Java 是很难给接口增加功能的。比如
List
接口,因为有太多非 Java JDK 控制的代码实现了该接口,如果给接口增加一个方法,则那些接口的实现就无法在新版 Java 上运行,必须改写代码实现新的方法,这显然是无法接受的。 - 函数式数据处理需要给一些接口增加一些新的方法,所以就有了默认方法的概念,接口增加了新方法,而接口现有的实现类也不需要必须实现。
接口增加默认方法的示例?
答:List 接口增加了 sort 默认方法,定义为:
1
2
3
4
5
6
7
8
9
10default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for(Object e : a) {
i.next();
i.set((E) e);
}
}Collection 接口增加了
stream
默认方法,其定义为:1
2
3default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
Java 类的继承(四):为什么说继承是把双刃剑
继承的强大表现在哪?
答:- 继承广泛应用于各种 Java API、框架和类库之中。
- 一方面它们内部大量使用继承,另一方面它们设计了良好的框架结构,提供了大量基类和基础公共代码。
- 使用者可以使用继承,重写适当方法进行定制,就可以简单方便地实现强大的功能。
继承为什么会有破坏力?
答:- 继承可能破坏封装,而封装可以说是程序设计的第一原则。
- 继承可能没有反映出 is-a 的关系。
怎样理解封装?
答:- 封装就是隐藏实现细节,提供简化接口。
- 使用者只需要关注怎么用而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。
- 方法是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。
- 可以说,封装是程序设计的第一原则,没有封装,代码之间会到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的,可阅读性和可维护性就会变得很差。
怎样理解继承可能破坏封装?
答:- 因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。
- 如果子类不知道父类方法的实现细节,它就不能正确地进行扩展。
- 子类和父类之间是细节依赖,子类扩展父类,但仅仅知道父类能做什么还是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随便修改,否则可能影响子类。
- 更具体地说,子类需要知道父类的可重写方法之间的依赖关系,而且这个依赖关系,父类不能随意改变。但即使这个依赖关系不变,封装还是可能因为细节修改而被破坏。
- 父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性。
- 总结:对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏;对于父类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由。
为什么说继承可能没有反映 is-a 关系?
答:- 继承关系是设计用来反映 is-a 关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也适用于子类。
- 但现实中,设计完全符合 is-a 关系的继承关系是困难的。在 is-a 关系中,重写方法时,子类不应该改变父类预期的行为,但是这是没有办法约束的。
- 继承是应该被当做 is-a 关系使用的,但是,Java 并没有办法约束。父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。
- 对于通过父类引用操作子类对象的程序而言,它是把对象当作父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,则有可能会造成混乱,代码可维护性变差。
继承既强大又有破坏性,那怎么办?
答:- 避免使用继承。
- 正确使用继承。
怎样避免使用继承?
答:- 使用 final 关键字。
- 优先使用组合而非继承。
- 使用接口。
final 关键字对于继承关系的影响?
答:- final 方法不能被重写,final 类不能被继承。
- 给方法加 final 修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。
- 给类加 final 修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心地使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。
使用组合而非继承的优劣是?
答:- 使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合。
- 但组合的问题是,子类对象不能当作基类对象来统一处理了。解决方法是使用接口。
怎样正确使用继承呢?
答:使用继承大概主要有三种场景:基类是别人写的,我们写子类。
基类主要是 Java API、其他框架或类库中的类,在这种情况下,我们主要通过扩展基类实现自定义行为,这种情况下需要注意的是:
- 重写方法不要改变预期的行为。
- 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的依赖关系。
- 在基类修改的情况下,阅读其修改说明,相应修改子类。
我们写基类,别人可能写子类。
我们写基类给别人用,在这种情况下,需要注意的是:
- 使用继承反映真正的 is-a 关系,只将真正公共的部分放到基类。
- 对不希望被重写的公开方法添加 final 修饰符。
- 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写。
- 在基类修改可能影响子类时,写修改说明。
基类、子类都是我们写的。
我们既写基类也写子类,关于基类,注意事项和第 2 种场景类似。关于子类,注意事项和第 1 种场景类似。不过程序都由我们控制,要求可以适当放松一些。
Java 类的继承(三):继承实现的基本原理
请写出下面代码的输出结果?
Base 类
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
30public class Base {
public static int s;
private int a;
static {
System.out.println("基类静态代码块,s: " + s);
s = 1;
}
{
System.out.println("基类实例代码块,a: " + a);
a = 1;
}
public Base() {
System.out.println("基类构造方法,a: " + a);
a = 2;
}
protected void step() {
System.out.println("base s: " + s + ", a: " + a);
}
public void action() {
System.out.println("start");
step();
System.out.println("end");
}
}
* Child 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Child extends Base {
public static int s;
private int a;
static {
System.out.println("子类静态代码块,s: " + s);
s = 10;
}
{
System.out.println("子类实例代码块,a: " + a);
a = 10;
}
public Child() {
System.out.println("子类构造方法,a: " + a);
a = 20;
}
protected void step() {
System.out.println("child s: " + s + ", a: " + a);
}
}
* main 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
System.out.println("---- new Child()");
Child c = new Child();
System.out.println("\n---- c.action()");
c.action();
Base b = c;
System.out.println("\n---- b.action()");
b.action();
System.out.println("\n---- b.s " + b.s);
System.out.println("\n---- c.s: " + c.s);
}
答:
* 输出结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---- new Child()
基类静态代码块,s: 0
子类静态代码块,s: 0
基类实例代码块,a: 0
基类构造方法,a: 1
子类实例代码块,a: 0
子类构造方法, a: 10
---- c.action()
start
child s: 10, a: 20
end
---- b.action()
start
child s: 10, a: 20
end
---- b.s: 1
---- c.s: 10
* 总结:
1
2
3
基静态 -> 子静态
基实例代码块 -> 基构造
子实例代码块 -> 子构造
类的加载什么意思?怎么理解这个概念?
答:- 类的加载是指将类的相关信息加载进内存。
- 在 Java 中,类是动态加载的,当第一次使用这个类的时候才会加载。
- 加载一个类时,会查看其父类是否已加载,如果没有,则会加载其父类。
一个类的信息包括哪些部分?
答:- 类变量(静态变量)
- 类初始化代码
- 类方法(静态方法)
- 实例变量
- 实例初始化代码
- 实例方法
- 父类信息引用
类初始化代码包括哪些部分?
答:- 定义静态变量时的赋值语句
- 静态初始化代码块
实例初始化代码包括哪些部分?
答:- 定义实例变量时的赋值语句
- 实例初始化代码块
- 构造方法
类加载过程是怎样的?
答:- 分配内存保存类的信息
- 给类变量赋默认值
- 加载父类
- 设置父子关系
- 执行类初始化代码
怎样理解方法区这个概念?
答:存放类的信息的一个内存区,Object 类肯定会存在于方法区。创建对象的过程是怎样的?
答:- 分配内存
- 对所有实例变量赋默认值
执行实例初始化代码
注意:
- 分配的内存包括本类和所有父类的实例变量,但不包括任何静态变量。
- 实例初始化代码的执行从父类开始,再执行子类的。但在任何类执行初始化代码之前,所有实例变量都已设置完默认值。
- 每个对象除了保存类的实例变量之外,还保存着实际类信息的引用。
动态绑定机制中方法的调用过程是怎样的?
答:动态绑定实现的机制就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类。虚方法表的含义及作用?
答:- 含义:在类加载的时候为每个类创建一个表,这个表叫做虚方法表,这个表记录该类的对象所有动态绑定的方法(包括父类的方法)及其地址,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。
- 作用:优化方法调用的效率。如果继承的层次比较深,要调用的方法位于比较上层的父类,如果不用虚方法表的话则调用的效率是比较低的,因为每次调用都要进行很多次查找。使用虚方法表的话,当通过对象动态绑定方法的时候,只需要查找这个表就可以了,不需要挨个查找每个父类。
对变量的访问是静态绑定的,这句话对吗?
答:对。对象访问类变量(静态变量)的实现机制是?
答:通过对象访问类变量,系统会转换为类名直接访问静态变量。