Java编程原理——面向对象基础

1. 类的定义与理解

1.1 容器的视角

  1. 函数是代码的容器,而类是函数的容器;
  2. 既然类是函数的容器,应当既可以对外暴露相应的行为和操作,也可以屏蔽相应的动作和行为防止误用;
  3. 通过private关键字封装和隐藏函数的内部细节,避免被误操作,是计算机程序中的基本思维方式;

1.2 数据类型的视角

  1. 类也可以看作为自定义数据类型,一个数据类型包含其基本定义以及操作;
  2. 一个表示数据类型的类可以由以下4部分构成
  • 类变量体现出的类型本身具有的属性;
  • 类方法体现出的类型本身具有的操作;
  • 实例变量体现出的类型实例具有的属性;
  • 实例方法体现出的类型实例具有的操作;
1
2
3
4
5
6
7
8
public class Point {
private int x; // 实例变量
private int y; // 实例变量
/* 实例方法 */
public double distance(){
return Math.sqrt(x * x + y * y);
}
}
  1. 通过对象来访问和操作函数内部的数据是一种基本的面向对象思维;
  2. 一般而言,不应该将实例变量声明为public,而只应该通过对象的方法对实例变量进行操作;
  3. 静态初始化代码块在类加载的时候执行,该步骤实在任何对象创建之前,且只执行一次;
  4. 构造函数与类名相同,并且不带有返回值,构造函数隐式返回的就是实例本身;
  5. 一旦自定义构造函数,编译器将不再生成默认构造函数;

1.3 私有构造函数

使用私有构造函数存在于以下几个场景:

  1. 不能创建类的实例,类只能被静态访问;
  2. 能创建类的实例,但是只能够通过类的静态方法调用(也是单例模式常用的情景);
  3. 只是用来被其他多个构造方法调用,用于减少重复代码;

1.4 总结

通过类实现自定义数据类型,封装该类型的数据所具有的属性和操作,隐藏实现细节,从而在更高层次(类和对象层次,而非基本数据类型和函数层次)上考虑和操作数据,是计算机程序解决复杂问题的一种重要的思维方式。

1.5 将现实问题转化为面向对象的层次

    设想现实问题的概念以及其所包含的属性、行为,再理清概念之间的关系,然后再定义类、属性、方法以及类与类之间的关系。概念属性和行为可能非常多,但是定义的类只需要包括与现实生活相关的问题即可。

    分解现实问题中涉及的概念以及概念之间的关系,将概念表示为多个类,通过类之间的组合表达更为复杂的概念以及概念之间的关系,是计算机程序的一种基本思维方式。

2. 类的继承

2.1 基本概念

  1. 使用继承可以复用代码,公共属性和行为可以放至父类中,而子类只需要关注自身特有的行为即可;
  2. 不同子类的对象注重于实现自己的行为。

2.2 有关继承的更多的细节

  1. 构造函数:由于子类继承父类需要重写父类的构造函数,如果父类构造函数调用可被子类重写的方法,则可能导致混淆,应当只调用private方法;
  2. 静态绑定是在程序编译期间决定的,而动态绑定需要等到程序运行时才决定
  3. 实例变量、静态变量、静态方法和private方法都是静态绑定的。
  4. 重载是指方法名称相同但是参数签名不同,重写是指子类重写父类相同参数签名的方法;函数的重写是动态绑定的;

2.3 类型转换与protected关键字

  1. 向上转型:子类型的对象赋值给父类型的引用变量;
  2. 向下转型:父类型的对象赋值给子类型的引用变量;
  3. protected关键字广泛用于模板方法模式中;
  4. 可见性重写:子类重写父类方法时不降低父类方法的可见性,这样的规定是由于子类和父类属于“is-a”关系,子类必须支持父类所有对外的行为,降低可见性将导致子类对外的行为减少;
  5. 继承所带来负面影响就在于有时候我们不希望子类去复写父类的一些方法,因此可以通过final关键字实现;

3. 类加载的过程

3.1 总览

(1) 一个Java类所包含下列信息:

  • 静态变量
  • 类初始化代码
  • 静态方法
  • 实例变量
  • 实例初始化代码
  • 父类信息引用

(2)类初始化代码包括

  • 定义静态变量时的赋值语句
  • 实例初始化代码块
  • 构造函数

(3)类加载的过程

  • 分配内存以及保存类的信息
  • 给类变量赋默认值
  • 加载父类
  • 设置父子关系
  • 执行类初始化代码(先执行父类,再执行子类)

3.2 对象创建的过程

  1. 每个对象除了保存着类的实例变量外,还保存着实际类信息的引用;
  2. 寻找要执行的实例方法时,是从对象的实际类型信息开始找,找不到再去父类信息中寻找;
  3. 动态绑定实际就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类;

3.3 继承的破坏性——破坏封装

  1. 继承使用不当会造成破坏。首先,当父类和子类存在实现细节上的依赖则可能出现破坏封装的行为,如果子类不知道基类实现方法的实现细节,则子类无法正确地进行扩展。
  2. 父类不能随意增加公开方法,因为给父类增加方法就是给子类增加方法,而子类可能必须要重写该方法才能确保方法的正确性;
  3. 对于子类而言,通过继承实现是没有安全保障的,因为父类修改内部实现细节,它的功能就可能会被破坏,而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由。

3.4 继承的破坏性——没有反映”is-a”关系

    按照继承的语义来说,父类拥有的行为,子类也应该有。然而在生活中的例子总有特殊。例如鸟都是会飞的,但是企鹅也属于鸟,但是不会飞。因此父类约束子类的行为在日常中并不能完全保证。

3.5 最佳实践

  1. 避免使用继承——使用final关键字关闭继承;
  • 给父类方法加上final关键字,父类就保留了随意修改这个方法内部实现的自由;
  1. 优先使用组合模式代替继承;
  • 组合替代继承,使得父类的行为不在对外暴露,子类也可以实现自己的逻辑,互不影响;
  1. 使用接口;

4. 接口

4.1 本质

  1. 接口是声明能力的一种方式,其只是一个对对象而言需要遵守的规定。从而衍生出一种新的计算机思维:面向接口编程;
  2. 接口更重要的意义在于降低了代码间的耦合,挺高了代码的灵活性;
  3. 接口可以多继承,也可以继承是实现并行,但是关键字==extends==要放在==implements==前面;

4.2 Java 8和Java 9新增的接口函数:

  1. 新增静态方法,便于直接将函数定义在接口中;
  2. 默认方法,使用default关键字表示,有具体实现,引入默认方法主要是函数式的数据处理请求,为了给接口增加新功能;

4.3 总结

针对接口编程是一种重要的程序思维方式,这种方式不仅可以复用代码,还可以降低耦合,提高灵活性,是分解复杂问题的一种重要工具。

5. 抽象类

  1. 相对于具体类而言,抽象类具有抽象的方法,可以用于表达抽象的概念。
  2. 抽象类和接口是配合而非替代关系,两者经常一起使用。接口声明能力,抽象类提供默认实现,实现全部或者部分方法,一个接口经常有一个对应的抽象类。(参见Collection接口对应的AbstractCollection)

6. 内部类

6.1 定义

顾名思义为定义外部类内部的类称之为内部类,通过内部类可以实现对外部的完全隐藏,可以得到更好的封装性。代码实现上也会更加简洁。内部类也可以很方便地访问外部类的私有变量,可以声明为private从而实现对外完全隐藏。相关代码写在一起,写法也会更加简洁。

6.2 内部类分类

  • 静态内部类:带有static关键字的内部类,如果静态内部类与外部类关系密切,且不依赖于外部实例,则可以考虑定义静态内部类;
  1. Java API中Integer类内部的IntegerCache类,用于支持整数的自动装箱;
  2. LinkedList类内部有一个私有静态内部类Node,用于表示链中的每个节点;
  3. Character类内部有public的UnicodeBlock,用于表示一个UnicodeBlock;
  • 成员内部类:无任何修饰符的内部类,成员内部类对象总是与一个外部对象相连;如果内部类和外部类关系密切,需要访问外部类的实例变量和方法,则可以考虑定义成员内部类。外部类的一些方法的返回值可能是某个接口,为了返回该接口,外部类方法可能使用内部类实现该接口。这个内部类就可以设置为private,对外完全隐藏;
  1. Java API中的LinkedList类中,listIterator和descendingIterator的返回值都是接口Iterator,调用者可以通过Iterator接口对链表进行遍历,listIterator和descendingIterator内部分别使用成员内部类ListItr和DescendingIterator。
  • 方法内部类:定义在方法体中的类,方法内部类可以直接访问外部类的变量以及方法(取决于是静态的还是实例的)。实际上方法内部类操作的并不是外部的变量,而是它自己的实例变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Outer {
public void test() {
final String[] str = new String[]{"hello"};
/* 方法内部类实例 */
class Inner {
public void innerMethod() {
str[0] = "hello world";
}
}
Inner inner = new Inner();
inner.innerMethod();
System.out.println(str[0]);
}
}
  • 匿名内部类:没有具体类名关联,使用new关键字临时创建的类成为匿名内部类;匿名内部类只能被使用一次,用来创建一个对象,没有构造函数,但是可以根据参数列表调用对应父类构造方法,也可以定义实例变量和方法,可以初始化代码块。匿名内部类能做的,方法内部类都可以实现,只不过是用匿名内部类在实现上显得更加简洁一些。
1
2
3
4
5
6
7
8
9
10
11
12
public class Outer {
public void test(final int x, final int y) {
/* 匿名内部类示例 */
Point p = new Point(2, 3) {
@Override
public double distance() {
return distance(new Point(x, y));
}
};
System.out.println(p.distance());
}
}

Java API使用中参见Arrays.sort函数。

6.3 总结

将程序分为保持不变的主体框架,和针对具体情况的可变逻辑,通过回调的方式进行写作,是计算机程序中的一种常见实践。匿名内部类是实现回调接口的一种简便方式。

7. 枚举

7.1 使用枚举的好处

  1. 枚举使得语法更简洁;
  2. 枚举更安全,一个枚举类型的变量,值要么为null,要么为具体枚举值;
  3. 枚举自带便利方法(例如values以及valueOf和toString方法),易于使用;

8. 异常

8.1 基本概念

  1. throw关键字表示异常退出,因此出现异常的地方,后续代码都不会执行;
  2. 异常处理机制会从当前函数开始查找谁能“捕获”该异常,当前函数无法捕获则查看上一层,直至主函数。如果主函数也没有则启用默认机制。即输出至控制台。
  3. try-with-resource语法:
1
2
3
4
5
public static void useResource() throws Exception {
try(AutoClosable r = new FileInputStream("hello")) { // 创建资源
// 使用资源
}
}
  1. 未受检异常表示编程的逻辑错误,编程过程中应当检查以避免这种错误。无论是受检异常还是未受检异常,无论是出现在throws关键字声明中,都应该在合适的地方以适当的方式进行处理。

8.2 异常的使用

  1. 真正出现异常的时候,应当抛出异常,而不是返回一个特殊值;
  2. 异常处理分报告恢复,报告时需说明异常出现的原因以及正确输入的范例或者格式;

8.3 异常的处理逻辑

  1. 自己能处理的异常由自己处理,如果可通过程序自动解决的就不需要向上报告了,直接自动解决;
  2. 自己不能完全解决的,就向上报告,如果自己有额外的信息提供,有助于分析和解决问题,就应该提供,可以以原异常为cause重新抛出一个异常;
  3. 总有一层代码需要为异常负责,可能是知道处理异常的代码,也可能是面向用户的代码,也可能是主程序。如果异常不能自动解决,对于用户而言需要提供对用户有用以及有帮助的信息;对于运维人员应该输出详细的异常链和异常栈日志;

8.4 总结

通过异常机制可以将程序正常逻辑和异常逻辑进行分离,异常情况可以集中处理,也可以自动传递。不需要每层方法都进行处理,异常也不能被自动忽略。从而处理异常的代码可以大大减少,代码的可读性、可靠性以及可维护性也会增加。

9. 基本类型的包装类以及String类型

9.1 共性

  1. 均重写了Object类中的(equals,hashCode以及toString)方法;
  • equals:表示两个数值在逻辑上的相等,而非地址上的相等,因此需要重写;
  • hashCode:返回对象的哈希值,hashCode反映的是其在内存中的地址相同,一般equals和hashCode都需要一起重写;
  1. Comparable接口,用于比较大小,在小于、等于和大于的时分别返回-1,0和1;
  2. 包装类和String
  3. 常用常量:包装类中包含一些常用的常量,例如布尔的TRUE/FALSE,整形中的MIN_VALUEMAX_VALUE以及浮点中的POSITIVE_INFINITY(正无穷) 以及 NEGATIVE_INFINITY(负无穷)
  4. 包装类中带有一个Number类型,可以返回任意基本数据类型;
  5. 不可变性:包装类和String声明都是final的,无法被继承;并且内部基本类型都是私有final的,并且无setter方法;
    使用Immutable是因为可以使得程序更为简单和安全,在多线程环境下不用担心数据会被篡改
  6. 包装类中存在一个Cache的静态内部类,用于缓存共享常量以节约内存空间,借用了享元模式;

10. 单说String

10.1 String类的一些特性

  1. String内部使用的是UTF-16BE模式编码;
  2. 同其他包装类一样,String类使用的也是不可变对象,对象一旦创建将不可再更改;定义不可变类,程序更加简洁,安全以及容易理解。但如果频繁更改字符串则会导致性能底下。

10.2 字符串常量

  1. 如果通过字符串常量赋值,则两个String对象的内存地址都是指向同一块的;
  2. 如果是通过new的方式创建出来的字符串对象在内存中实际上两个不同的对象,因此所在的内存地址是不相同的;

10.3 StringBuilder与StringBuffer

  1. StringBuffer类是线程安全的,而StringBuilder是线程不安全的;
  2. append方法使用了一种类似于指数分配长度的策略。在不知道最终需要多长的情况下,指数扩展是一种常见的策略,广泛应用于各种内存分配相关的计算机程序中。
  3. String支持+,+=运算。由Java编译器提供支持,其会将该运算符转换成append操作;

11 Arrays类

11.1 基本

  1. sort排序:可以使用sort排序,默认返回从小到大排序,基本类型可以直接使用,对象类型需要实现Comparator接口,可以使用Java8的lambda表达式简化语法;
  2. Comparator接口可以接收Collections接口中的reverse或者reverseOrder方法;
  3. 总结:传递比较器Comparator给sort方法,体现了程序设计中的一种重要方式。将不变以及变化进行分离,排序的基本步骤和算法是保持不变的。将不变的算法作为主体,而将变化的部分设计成参数,允许调用者动态绑定。也是一种常见的设计模式。

11.2 查找

  1. Arrays方法支持很多种查找方法,包括二分查找,使用方法如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 针对基本类型
    public static int binarySearch(int[] a, int key);
    public static int binarySearch(int[] a, int fromIndex, int toIndex, int key);

    // 针对对象数组
    public int static int binarySearch(Object[] a, Object key);

    // 自定义比较器
    public static <T> int binarySearch(T[] a, T key, Comparator<? super T> c);
  2. 需要说明的是二分查找若能找到相应的数值,返回的是其所在的索引,数值为正数;否则返回插入点的数值+1并且为负数;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 索引列表为数组下标:0, 1, 2, 3, 4
    // 插入点列表为:0, 1, 2, 3, 4
    int[] arr = {3, 5, 7, 13, 21};
    System.out.println(Arrays.binarySearch(arr, 3); // 0
    System.out.println(Arrays.binarySearch(arr, 5); // 1
    System.out.println(Arrays.binarySearch(arr, 7); // 2
    System.out.println(Arrays.binarySearch(arr, 13); // 3
    System.out.println(Arrays.binarySearch(arr, 21); // 4
    System.out.println(Arrays.binarySearch(arr, 22); // -6 (由21的插入点为5加1得到,再取负数)
    System.out.println(Arrays.binarySearch(arr, 2); // -1 (插在3的前面数插入点为0,再加1取负数得到)
  3. 理解多维数组:多维数组本质上还是一个一维数组,只是每个数组元素都可以再放一个数组,这样就构成了所谓的“多维数组”;其中对于多维数组会包括一个deepXXX的方法;

  4. 排序算法:对于基本数据类型,Java采用的是“双枢轴快速排序”算法;而对于对象类型,使用的是TimSort(Java 7引进),而TimSort实际上是对归并排序做了一系列优化。

12 时间处理

12.1 基本概念:

  • 时区:GMT为格林尼治标准时间,全球共分为24时区,中国在东八区,因此也成为GMT+8;
  • 时刻和纪元时:按照计算机规定:1970年1月1日的0时0分0秒称之为纪元时;
  • 年历:例如中国的公历和年历、日本的农历等等;

12.2 Java8之前的API支持

  • Date:时刻,绝对时间,与年月日无关;
  • Calenda:年历,为抽象类;表示公历的子类为Gregorian-Calendar;
  • TimeZone:表示时区
  • Locale:表示国家(或者地区)和语言;

12.3 Java8之前的API局限性

  • Date中的过时方法有悖常识,因此容易被误用;
  • Calendar类操作繁琐,设计臃肿;
  • DateFormat不是线程安全的,在多线程环境中会存在问题;

13 随机

  1. 种子决定了随机序列的产生,种子相同,产生的随机数序列就是相同的;
  2. 指定种子是为了实现可重复的随机;