Java面向对象(高级)

Java面向对象

封装

Java 也支持面向对象的三大特征∶封装、继承和多态,Java 提供了 private、protected 和 public 三个访问控制修饰符来实现良好的封装,提供了extends 关键字来让子类继承父类,子类继承父类就可以继承到父类的成员变量和方法,如果访问控制允许,子类实例可以直接调用父类里定义的方法。继承是实现类复用的重要手段,除此之外,也可通过组合关系来实现这种复用,从某种程度上来看,继承和组合具有相同的功能。使用继承关系来实现复用时,子类对象可以直接赋给父类变量,这个变量具有多态性,编程更加灵活;而利用组合关系来实现复用时,则不具备这种灵活性。

构造器用于对类实例进行初始化操作,构造器支持重载,如果多个重载的构造器里包含了相同的初始化代码,则可以把这些初始化代码放置在普通初始化块里完成。初始化块总在构造器执行之前被调用。除此之外,Java 还提供了一种静态初始化块,静态初始化块用于初始化类,在类初始化阶段被执行。如果继承树里的某一个类需要被初始化时,系统将会同时初始化该类的所有父类。

理解封装

封装(encapsulation)是面向对象的三大特征之一,它的含义是指将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象内部信息,而是通过该类提供的方法来实现对内部信息的操作和访问。

对一个类或者对象实现良好的封装,可以实现以下目的:

  1. 隐藏类的实现细节,例如我们定义一个学生类,并创建了对象,对象的年龄是不能随意修改和访问的。例如我们使用的Arrays工具类中的sort()方法,我们并不需要关注方法内实现的细节,我们只需要知道该方法可以对数组进行排序即可
  2. 让使用者只能通过预定的方法来访问数据,从而可以在该方法中加入控制逻辑,限制对成员变量的不合理访问。
  3. 便于修改,提高代码的可维护性

为实现良好的封装,需要从两个方面考虑:

  • 将对象的成员变量和实现细节隐藏在对象内部,不允许外部直接访问

  • 把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作

使用访问控制符

ava提供了3个访问控制符,private,protected和public,分别代表了3种级别的访问控制权限,另外有一个缺省的访问修饰符,因此Java提供了4个访问控制级别。Java的访问控制级别有小到大,如下图所示:

当不使用任何访问控制符来修饰类或者类成员是,系统默认使用该访问控制级别

  • private(当前类访问权限)∶ 如果类里的一个成员(包括成员变量、方法和构造器等)使用 private
访问控制符来修饰,则这个成员只能在当前类的内部被访问。很显然,这个访问控制符用于修饰成员变量最合适,使用它来修饰成员变量就可以把成员变量隐藏在该类的内部。
  • default(包访问权限)∶如果类里的一个成员(包括成员变量、方法和构造器等)或者一个外部
类不使用任何访问控制符修饰,就称它是包访问权限的,default 访问控制的成员或外部类可以被相同包下的其他类访问。
  • protected(子类访问权限)∶如果一个成员(包括成员变量、方法和构造器等)使用protected 访
问控制符修饰,那么这个成员既可以被同一个包中的其他类访问,也可以被不同包中的子类访问。在通常情况下,如果使用 protected 来修饰一个方法,通常是希望其子类来重写这个方法。
  • public (公共访问权限)∶ 这是一个最宽松的访问控制级别,如果一个成员(包括成员变量、方
法和构造器等)或者一个外部类使用 public 访问控制符修饰,那么这个成员或外部类就可以被所有类访问,不管访问类和被访问类是否处于同一个包中,是否具有父子继承关系。

可以用下表来概括上述内容:

修饰符 范围
public 公共的
protected 同包,子类
default 同包
private 类内
修饰符/范围 类内 同包子类 同包非子类 非同包子类 非同包非子类
public
protected ×
default × ×
private × × × ×

对于外部类而言,它也可以使用访问控制符修饰,但外部类只能有两种访问控制级别;public 和默认,外部类不能使用 private 和 protected 修饰,因为外部类没有处于任何类的内部,也就没有其所在类的内部、所在类的子类两个范围,因此 private 和 protected 访问控制符对外部类没有意义。
外部类可以使用 public 和包访问控制权限,使用 public 修饰的外部类可以被所有类使用,如声明变量、创建实例; 不使用任何访问控制符修饰的外部类只能被同一个包中的其他类使用。

1.不能用private:
对于一个外部类,用private修饰是没有意义的。因为如果使用private修饰外部类,其它类就不能访问的这个类,那么这个类就不能创建实
例,这个类的属性和方法就不能被外界访问,所以没有意义。

2.不能用protected:
protected;是用来修饰类中的属性和方法的,不是用来修饰类的。假如定义一个A类用protected修饰,再在与A类不可包的另一个保重定义一个B类,B类如果要继承A类,前提是B类能够访问到A类。仔细想想就会发现这是冲突的。(你要成为A类的子类,你的先访问到A类,但你要访问到A类,那你先得成为A类的子类。因为protected修饰的类就是给子类访问的)这个逻辑明显是冲突的。
所以不仅是外部类,普通类也不能用protected修饰。
这时肯定有人会说:把B类放在A类同一个包下,那B类不就可以访问到A类了呜?
但是:如果你把B类放在和A类同一个包下,那和用default修饰A类有什么区别,既然和default没有区别,那干麻还要用protected修饰A类,
而且protected本身可以跳出同一个包访问的意义也不存在了,所以用protected修饰类是没有意义的。

关于访问控制符的使用,存在如下几条基本原则。

  • 类里的绝大部分成员变量都应该使用 private 修饰,只有一些 static 修饰的、类似全局变量的成
员变量,才可能考虑使用public 修饰。除此之外,有些方法只用于辅助实现该类的其他方法,这些方法被称为工具方法,工具方法也应该使用private 修饰。
  • 如果某个类主要用做其他类的父类,该类里包含的大部分方法可能仅希望被其子类重写,而不
想被外界直接调用,则应该使用protected 修饰这些方法。
  • 希望暴露出来给其他类自由调用的方法应该使用public 修饰。因此,类的构造器通过使用 public
修饰,从而允许在其他地方创建该类的实例。因为外部类通常都希望被其他类自由使用,所以大部分外部类都使用 public 修饰。

package、import和import static

所谓的包从操作系统层面来看就是一个文件夹,如果从项目开发的角度来看,就是用于管理代码的目录,一个项目可能会由多个人进行开发,那么类名相同也就不可避免,那么如何确保我们写的类不被其他开发者覆盖呢,那么我们就可以用包加以区分。

简而言之,Java中的包就是为了管理Java类,以及控制权限的目录。
如果需要使用不同包中的其他类是,我们需要在类名前加包名。例如如下写法:

1
cn.bytecollege.Student s = new cn.bytecollege.Student();

这种写法在语法上是完全合法的。但是这是一件很麻烦的事情。为了简化编程,Java引入了import关键字,import可以导入指定包层次下某个类或者全部类,import语句应该出现在package语句后、类定义之前,一个Java源文件只能包含一个package语句,但可以包含多个import语句,多个import语句用于导入多个包层次下的类。

深入构造器

构造器是一个特殊的方法,所以也叫构造方法,这个特殊的方法用于创建对象时执行初始化。构造方法是创建对象的基本方式,因此,Java类必须包含一个或者以上的构造器。

构造器的作用

构造器最大的用处就是在创建对象时执行初始化。

当创建一个对象时,系统为这个对象的实例变量进行默认初始化,这种默认的初始化把所有基本类型的实例变量设为 0(对数值型实例变量)或 false(对布尔型实例变量),把所有引用类型的实例变量设为 null。
如果想改变这种默认的初始化,想让系统创建对象时就为该对象的实例变量显式指定初始值,就可以通过构造器来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Student {
private String name;
private int age;
/**
* 提供一个自定义构造方法用于初始化实例变量
* @param name
* @param age
*/
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
public static void main(String[] args) {
Student s = new Student("周芷若",18);
System.out.println(s.name);
System.out.println(s.age);
}
}

运行上面的程序,可以看到输出的name值是周芷若,年龄是18,这就是构造方法的作用——为实例变量初始化值

构造器是创建 Java 对象的重要途径,通过 new 关键字调用构造器时,构造器也确实返回了该类的对象,但这个对象并不是完全由构造器负责创建的实际上,当程序员调用构造器时,系统会先为该对象分配内存空间,并为这个对象执行默认初始化,这个对象已经产生了——这些操作在构造器执行之前就都完成了。也就是说,当系统开始执行构造器的执行体之前,系统已经创建了一个对象,只是这个对象还不能被外部程序访问,只能在该构造器中通过 this 来引用。当构造器的执行体执行结束来后,这个对象作为构造器的返回值被返回,通常还会赋给另一个引用类型的变量,从而让外部程序可以访问该对象。

因为构造器主要用于被其他方法调用,用以返回该类的实例,因而通常把构造器设置成public 访问权限,从而允许系统中任何位置的类来创建该类的对象。除非在一些极端的情况下,业务需要限制创建该类的对象,可以把构造器设置成其他访问权限,例如设置为 protected,主要用于被其子类调用;把其设置为private,阻止其他类创建该类的实例。

构造器重载

同一个类里具有多个构造器,多个构造器的形参列表不同,即被称为构造器重载。

因为构造器必须与类名相同,所以同一个类的所有构造器名肯定相同。为了让系统能区分不同的构造器,多个构造器的参数列表必须不同。构造方法名称相同,参数列表不同,系统通过new调用构造方法时,根据传入的参数列表来决定调用哪个构造方法。

类的继承

继承也是面向对象的三大特征之一,是实现代码复用的重要手段之一,Java的继承具有单继承的特点,也就是所每个类有且只能有一个父类。

继承的特点

所谓继承就是在已有类的基础上构建一个新类,使用extends关键字来实现,从而达到代码复用的目的,其中已有类也叫做父类、基类或者超类,新扩展的的类叫做子类或者派生类。

Java使用extends作为继承的关键字,其实extends翻译为扩展更为合适,而不是继承,因为翻译做继承的话,很容易和我们理解意义上的继承混淆。并且子类更多的是作为父类的扩展,也就是说父类中更多的描述的是共性,也就是大家都具有的特性,而子类中不但有共性,也可能有特性。

定义父类:

1
2
3
4
5
6
public class Father {
public String name;
public int age;
public void printInfo() {
System.out.println(name+"====="+age);
}

定义子类,该子类继承Father,并且类本身中不定义任何内容。

1
2
public class Son extends Father{
}

编写测试类:

1
2
3
4
5
6
public static void main(String[] args) {
Son son = new Son();
son.age = 18;
son.name = "张三";
son.printInfo();
}

运行测试类,我们发现打印了张三===18岁,我们发现Son中没有定义任何内容,但是我们依然可以访问到实例变量name和age,这说明子类Son从父类继承了这两个属性,并且我们调用了son对象的printInfo()方法,这说明子类不但继承了父类的实例变量,也继承了父类的实例方法。

Java中采用了单继承,也就是说一个类有且只能有一个直接父类,多个间接父类,例如:类A继承了类B,类B又继承了类C,类C又继承了类D,那么类A的直接的直接父类是类B,间接父类则有类C和类D。

此外,需要注意的是,如果一个Java类没有显式的指定直接父类,那么这个类默认隐式的extends了java.lang.Object类。

方法重写

子类扩展了父类,那么就获得了父类中定义的可访问的成员变量和方法。并且子类在父类的基础上可以对父类进行扩展,增加自己的状态或者行为。子类扩展了父类,当父类方法不能满足子类需要时,子类就可以重写父类方法,也叫做方法覆盖。

所谓方法重写就是,子类扩展父类以后,为满足自身需要,对父类行为或者方法进行覆盖,
子类包含与父类同名方法的现象叫做方法重写。这里需要注意以下几点:

  1. 方法重写只存在于继承关系中
  2. 方法重写子类方法名和父类方法名相同,并且参数列表和父类方法参数列表完全相同
  3. 子类方法返回值类型与父类方法返回值类型一致,或者子类方法返回值类型是父类方法返回值类型的子类
  4. 子类方法抛出的异常要小于父类方法抛出的异常。
  5. 子类方法的访问权限要大于等于父类方法访问权限

简单归纳为方法重写中的注意事项为“两小一大两相同”,其中两小指的是子类方法返回值和抛出异常小于父类方法返回值和抛出异常。一大则是指子类方法访问权限大于等于父类访问权限。两相同则是指方法名和参数列表相同。
此外,需要注意的是,如果父类方法具有 private 访问权限,则该方法对其子类是隐藏的,因此其子类无法访问该方法,也就是无法重写该方法。如果子类中定义了一个与父类 private 方法具有相同的方法名、相同的形参列表、相同的返回值类型的方法,依然不是重写,只是在子类中重新定义了一个新方法。

方法重写和方法重载的区别:
(1)方法重载(Overload)方法重写(Override)
(2)方法重载发生在同一个类里面,方法重写只发生在继承里面。
(3)方法重载和返回值类型无关,方法重写子类方法的返回值类型要和父类方法返回值的类型一致,或者是父类返回值的子类。
(4)方法重载和抛出异常无关,方法重写子类方法抛出异常小于等于父类方法抛出异常
(5)方法重载和修饰符的权限无关,方法重写子类方法的访问权限大于等于父类方法的访问权限
(6)方法重载参数列表要参数个数不同或类型顺序不同,方法重写的参数列表要与父类的参数列表一致。

首先我们定义一个父类:

1
2
3
4
public class Bird {
public void run(String name) {
System.out.println(name+"====run");
}

定义子类,并重写父类方法:

1
2
3
4
5
public class Sparrow extends Bird{
@Override
public void run(String name) {
System.out.println(name+"是一只麻雀,不能快速的跑,但是我可以跳");
}

我们定义了Bird类,并在类中定义了run方法,因为鸟类都具有跑这个行为,但是麻雀准确意义上不能称之为跑,所以重写了父类的方法,并且严格遵循了重写方法的规则。需要注意的是在代码第8行,有一行@Override,这叫做注解,当发生方法重写时,添加在方法上方。
注意:父类构造方法不能被继承,因此也不能被重写。可以重写的一定是从父类继承的可访问的方法。

super关键字

super可以指代父类对象,用于访问从父类继承得到的实例变量或者方法,同时也可以访问父类的构造方法。super和this一样,都不出现在类方法中,如果出现在构造方法中,必须放在第一行,因此super()和this()调用构造方法时,不能同时出现。

  • super访问父类属性

首先,我们定义父类,父类中包含了name和age两个属性

1
2
3
4
public class Father {
public String name;
public int age;
}

接着我们定义子类,在子类中不定义任何实例变量,定义个print()方法,方法中使用super访问限定父类实例变量:

1
2
3
4
5
6
7
8
public class Son extends Father{
/**
* 定义实例方法使用super访问父类实例变量
*/
public void print() {
System.out.println(super.age);
System.out.println(super.name);
}

定义测试类:

1
2
3
4
5
6
public static void main(String[] args) {
Son son = new Son();
son.age = 18;
son.name ="张三";
son.print();
}

测试类中我们创建了Son对象,并为其从父类继承来的属性赋值,代码12行调用son对象print方法后,在方法内部,我们使用super访问了父类的属性,并成功打印。
在上面的示例中,Son中没有定义任何实例变量,Son中所有的实例变量实际上都是从父类继承过来的,那么如果Son中如果定义了实例变量,并且和父类中实例变量同名,那么又会发生什么呢?那么,下面我们修改上述示例:
定义父类:

1
2
3
4
5
6
public class Father {
public String name;
public int age;
public void info() {
System.out.println(name+"===="+age);
}

在父类中定义实例变量name和age,并定义inof,打印自身的name和age。

1
2
3
4
5
6
7
public class Son extends Father{
public String name;
public void print() {
System.out.println("打印name:"+name);
System.out.println("访问父类name:"+super.name);
System.out.println("访问父类age:"+super.age);
}

定义子类,并在子类中定义和父类相同的name实例变量,在print()方法中分别打印自身定义的name实例变量和父类的两个实例变量。
编写测试类:

1
2
3
4
5
6
7
public static void main(String[] args) {
Son son = new Son();
son.age = 18;
son.name ="张三";
son.print();
son.info();
}

运行测试类,结果如下:

根据结果我们可以看出,如果子类和父类具有同名的实例变量,那么在子类实例方法中访问该成员变量时,无需显式的添加super,访问的就是自身的实例变量。也就是说如果在某个方法中访问某个成员变量,但是没有显式的指定调用者,则编译器查找顺序如下:

  1. 查找该方法中是否含有该局部变量

  2. 查找当前类中是否包含该实例变量

  3. 查找直接父类中是否包含该变量,依次向上追溯所有父类,如果直到Object类还是没有找到,则编译错误

我们在子类中定义了和父类同名的实例变量,当我们创建对象后,为name属性赋值,此时JVM查找到类本身中具有name属性,因此直接为子类对象name属性赋值,而age值此时子类对象中并不存在,因此只能为从父类继承的属性赋值,当调用print()时,print()方法是在Son中定义的,在该方法中第一行因为没有使用super,所有访问的是自身的name属性,因此打印张三,第二行访问父类属性时,因为将张三赋值给了子类的name属性,父类的属性并没有赋值,因此只有默认值,所以为null。紧接着我们访问了父类的age属性,因为age属性子类自身没有定义,所以只能访问父类的,并且明确限定了super,如果不加super,那么打印的也是18,在父类中访问自身的属性时,就会打印null和18,其实当上述代码运行时,实际上会为Son对象分配两块内存,一块用于存储在子类中定义的变量,一块用于存在从父类继承过来得到的实例变量。

  • super访问父类实例方法

访问父类的实例方法和访问实例变量类似,下面通过示例来学习,使用super调用父类实例方法。

1
2
3
4
public class Father2 {
public void info() {
System.out.println("执行了Father2的info");
}
1
2
3
4
5
6
7
8
public class Son2 extends Father2{
/**
* 子类实例方法中调用父类方法
*/
public void test() {
System.out.println("调用Son2 test方法");
super.info();
}

定义测试类:

1
2
3
4
public static void main(String[] args) {
Son2 son2 = new Son2();
son2.test();
}

运行上述代码我们发现当我们调用test方法时,test方法中使用super调用了父类对象的info方法,并成功执行

  • super访问父类构造方法

我们知道子类不能继承父类的构造器,但是子类的构造方法中可以调用父类的构造方法。下面我们定义Base类和Sub类,并在Sub类中调用父类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
/**
* 定义父类,并提供无参构造
* @author MR.W
*
*/
public class Base extends Object {
public Base() {
System.out.println("执行了Base的构造方法");
}
}
package cn.bytecollege.extend;
/**
* 定义子类
* @author MR.W
*
*/
public class Sub extends Base{
public Sub() {
super();
System.out.println("执行了Sub()构造方法");
}
public static void main(String[] args) {
Sub s = new Sub();
}

运行结果如下:

从代码中可以看出使用super()调用了父类构造方法,和this一样super调用构造方法,也必须放在代码的第一行。需要注意的是子类不管是否显示的使用super调用父类构造方法,JVM总会在创建子类对象时调用父类构造器,即使把上述代码中Sub类中第9行代码删了,仍旧会出现上述效果。子类调用父类构造方法分以下几种情况。

  • 子类构造器执行体的第一行使用 super 显式调用父类构造器,系统将根据 super 调用里传入的实
参列表调用父类对应的构造器。
  • 子类构造器执行体的第一行代码使用 this 显式调用本类中重载的构造器,系统将根据 this 调用
传入的实参列表调用本类中的另一个构造器。执行本类中另一个构造器时即会调用父类构造器。
  • 子类构造器执行体中既没有 super 调用,也没有 this 调用,系统将会在执行子类构造器之前,隐
式调用父类无参数的构造器。

换句话说就是创建子类对象时会先创建父类对象

下面,我们通过示例来学习,我们定义3个类,A继承B,B继承C

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
public class A extends B{
public A() {
System.out.println("创建了A对象");
}
}
package cn.bytecollege;

public class B extends C{
public B() {
System.out.println("创建了B对象");
}
}
package cn.bytecollege;

public class C {
public C() {
System.out.println("创建了C对象");
}
}
package cn.bytecollege;

public class Test2 {
public static void main(String[] args) {
A a = new A();
}

运行结果如图:

通过上例中的代码我们可以看出两点:

  1. 即使不显式的使用super,程序在创建对象时,也会先调用父类构造方法,这是因为编译器会为我们在每个构造方法内第一行添加super();
  2. 创建子类对象时一定会先创建父类对象。创建任何对象总是从该类所在继承树最顶项层类的构造器开始执行,然后依次向下执行,最后才执行本类的构造器。如果某个父类通过 this 调用了同类中重载的构造器,就会依次执行此父类的多个构造器。

super关键字和this关键字的区别

1.属性的区别:
this访问本类中的属性,如果本类没有此属性则从父类中续继查找。super访问父类中的属性。

2.方法的以别:
tihs访问本类中的方法,如果本类没有此方法则从父类中继续查找。super访问父类中的方法。

3.构造的区别:
this调用本类构造,必须放在构造方法的首行。super调用父类构造,必须放在子类构造方法首行。

4.其他区别:
this表示当前对象。sper不能表示当前对象
A、this.变量和super.变量
this.变量 调 用的当前对象的变量;
super.变量 直接调用的是父类中的变量。
B、this(参数)和super(参数)方法
this(参数)调用(转发)的是当前类中的构造器;
super(参数)用于确认要使用父类中的哪一个构造器。

Object类

Object类的方法

Object是所有类的父类,当一个类没有使用extends关键字显式的指定父类,则该类继承Object类,因为所有类都是Object的子类,任何Java对象都可以调用Object类的方法。Object提供了以下几个方法:

方法 描述
getClass() 获得当前对象的类对象
hashCode() 返回当前对象的hashCode()
equals() 判断两个对象是否相等
clone() 克隆并返回当前对象副本
toString() 打印该对象
notify() 线程唤醒
notifyAll() 线程唤醒
wait() 线程等待
finalize() 通知垃圾回收器回收,该方法不确实实际执行时间,不推荐使用

重写equals()方法

在上一小节我们知道Object类是所有Java类的父类,也就是说所有Java类都继承了Object类的可访问的方法,这其中就包括equals()方法,equals方法是用于判断两个对象是否相等。首先我们来看一下Object中equals方法的源码

1
2
3
public boolean equals(Object obj) {
return (this == obj);
}

从源码中我们可以看到Object中equals(),方法代码很简单,其中this表示当前对象,也就是说谁调用equals方法,this就是谁,obj则是指要和当前对象比对的对象,源码中只是简单的判断this是否等于obj,也就是说判断当前对象和传入对象是否是同一引用,那么这里又产生了新的概念,“同一引用”,下面我们通过示例来学习同一引用
定义Student类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Student {
private String name;
private int age;
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
public static void main(String[] args) {
Student s1 = new Student("张三",18);
Student s2 = s1;
System.out.println(s1.equals(s2));
}
}

此时运行程序会发现打印true,在代码16行,我们创建了Student对象并将对象的引用赋值给了变量s1,在代码17行,我们又将s1中的引用赋值给了s2,此时变量s1和s2中都保存的是同一引用或者地址,调用equals方法时返回了true,那么我们现在再从内存的角度理解同一引用:
当代码执行到16行是,此时内存中示意图如下:

当代码执行到底17行是,定义了变量s2,并将s1的值赋值给了s2,也就是说将栈内存中的s1中保存的值复制了一份放到了s2中,此时内存示意图如下:

时s1和s2就是同一引用,因为他们指向了堆内存中的同一块内存区域,那么调用equals方法后,判断栈内存中值是否相等,换句话说就是判断自己是否和自己相等的,结果是肯定的。
但是在实际情况中只有在少数情况下才会出现两个对象指向同一引用的情况,那么该怎么判断两个对象相等呢,例如s1和s2所有的属性都相等,我们就认为这两个对象相等,此时Object类提供的equals()方法是不能满足我们的需要的,这就需要重写equals()方法。
通常重写equals()方法需要满足以下几个条件:

  • 自反性∶ 对任意 x,x.equals(x)一定返回 true。
  • 对称性∶ 对任意x和 y,如果 y.equals(x)返回 true,则x.equals(y)也返回 true。
  • 传递性∶ 对任意x,y,z,如果x.equals(y)返回 ture,y.equals(z)返回 true,则x.equals(z)一定返回 true。
  • 一致性∶ 对任意x和 y,如果对象中用于等价比较的信息没有改变,那么无论调用 x.equals(y)
多少次,返回的结果应该保持一致,要么一直是 true,要么一直是 false。
  • 对任何不是 null 的x,x.equals(null)一定返回 false。

下面,我们根据上述规则重写Student的equals()方法。

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
30
31
public class Student {
private String name;
private int age;
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
//1.判断是否是同一引用,如果是则直接返回true
if(this == obj) {
return true;
}
//2.判断obj是否为null,如果是则返回false
if(obj == null) {
return false;
}
//3.判断是否是同一类型
if(!(obj instanceof Student) ) {
return false;
}
//4.转换为同一类型对象
Student s = (Student) obj;
//5.判断所有实例变量是否相等
if(this.name.equals(s.name)&&this.age==s.age) {//这里的equals是比较String对象的
return true;
}
return false;
}
}

根据重写的步骤,我们可以将重写 equals()方法归纳为以下5:

  1. 判断是否是同一引用,如果是则直接返回true
  2. 判断obj是否为null,如果是则返回false
  3. 判断是否是同一类型
  4. 转换为同一类型对象
  5. 判断所有实例变量是否相等

下面,我们编写测试类:

1
2
3
4
5
6
7
8
9
10
11
12
public class EuqalsTest {
public static void main(String[] args) {

Student s1 = new Student("张三", 18);
Student s2 = new Student("张三", 18);
Student s3 = new Student("李四",19);

System.out.println(s1.equals(s2));
System.out.println(s1.equals(s3));
System.out.println(s2.equals(s3));//这里的equals是比较Object对象的
}
}

==和equals的区别

我们在判断两个基本类型是否相等时,通常使用双等号,但是判断两个对象相等,使用==就比较有局限性了,因为使用双等号只能判断两个变量指向同一引用的情况,而我们在日常开发中通常两个对象的所有实例变量相等,即可认为两个对象相等。从内存的角度来说,==用于判断变量栈内存中保存的内容是否相等,而equals则是判断对象在堆内存中的内容是否相等。简而言之,基本类型相等的判断使用==,而判断两个对象是否相等,则需要重写equals方法来判断。

多态

什么是多态

继承、方法重写、父类引用指向子类对象,对象调用同一个方法时,展现出不同的行为特性

Java引用变量有两个类型:编译时类型和运行时类型。编译时类型有声明该变量时使用的类型决定,运行时类型由实际赋值给该变量的对象决定。如果两种类型不一致,就会出现多态性(也就是父类引用指向了子类对象)。

定义父类,父类中定义了实例变量和两个实例方法

1
2
3
4
5
6
7
8
9
10
package cn.bytecollege.poly;
public class Base {
public int a = 100;
public void base() {
System.out.println("父类的普通方法");
}
public void test() {
System.out.println("父类被子类覆盖的方法");
}}

定义子类,子类中定义了实例变量,两个实例方法,其中一个方法重写了父类方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package cn.bytecollege.poly;
public class Sub extends Base{
public int a = 20;

public void sub() {
System.out.println("子类中的普通方法");
}
@Override
public void test() {
System.out.println("子类覆盖了父类中的方法");
}
}

定义测试类,在测试类创建3个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package cn.bytecollege.poly;
public class Test {
public static void main(String[] args) {
//创建父类对象,编译时类型和运行时类型一致
Base base = new Base();
System.out.println(base.a);
base.base();
base.test();
//创建子类对象,编译时类型和运行时类型一致
Sub sub = new Sub();
System.out.println(sub.a);
sub.sub();
sub.test();
//编译时类型和运行时类型不一致,多态发生
Base ploy = new Sub();
System.out.println(ploy.a);
ploy.base();
ploy.test();
}}

上面的程序中,显示创建了3个对象,前面两个对象base和sub,编译时类型和运行时类型完全相同,因此不会出现多态,他们调用成员变量和成员方法是结果正常,第三个poly比较特殊,他的编译时类型是Base,运行时类型则是Sub,当调用改对象的test()方法时,实际上执行的是子类中覆盖后的test()方法,这就可能出现多态了。

当把一个子类对象直接赋值给父类引用变量时,就同上面的代码一样,当运行时调用该变量的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征,这就是相同类型的变量、调用同一个方法是呈现出多种不同的行为特征,这就是多态。

与方法不同的是,对象的实例变量不具备多态性。比如上面的ploy引用变量,程序中输出实例变量时,输出的了父类的实例变量。

instanceof 运算符

instanceof 运算符的作用是判断对象是否是某个类型,第一个操作数通常是一个引用类型的变量,后面的操作数通常是一个类,或者是其子类等等,如果是则返回true,如果不是则返回false。

在使用 instanceof 运算符时需要注意; instanceof 运算符前面操作数的编译时类型要么与后面的类相同,要么与后面的类具有父子继承关系,否则会引起编译错误。下面程序示范了instanceof运算符的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package cn.bytecollege.instance;/**
* 定义Person类型
*/public class Person {
}package cn.bytecollege.instance;/**
* 定义Student类
*/public class Student extends Person{}package cn.bytecollege.instance;
public class Test {
public static void main(String[] args) {

//定义Student类型对象
Student s = new Student();
//s 是Student类型的对象,因此返回true
System.out.println(s instanceof Student);
//父类引用指向子类对象
Person p = new Student();
//因为Student和Person存在继承关系,所以p可以看做是Person类型
//返回true
System.out.println(p instanceof Person);
//因为运行时类型就是Student类型,所以返回true
System.out.println(p instanceof Student);
//p不是String类型,编译出错
System.out.println(p instanceof String);
}}

向上转型和向下转型

在前面重写equals方法时,我们在第4步做了一个转型操作,从代码中我们可以看出,我们将Object类型的对象转成了Student类型的对象,我们知道Object是所有Java类的父类,也就是说Student是Object的子类,这种将父类对象转型成子类的操作就叫做向下转型,类似我们在基本类型的表示大范围的数据类型转成表示小范围的数据类型,需要强制转换。反之,我们也可以将子类对象转换成父类型,这种操作在Java中我们称之为向下转型。需要注意的是:引用类型之间的转换只能在具有继承关系的两个类型之间进行,如果是两个没有任何继承关系的类型,则无法进行类型转换,否则编译时就会出现错误。如果试图把一个父类实例转换成子类类型,则这个对象必须实际上是子类实例才行(即编译时类型为父类类却,而运行时类型是子类类型),否则将在运行时引发 ClassCastException 异常。

下面,我们通过示例来学习向上转型和向下转型:

1
2
3
4
5
6
7
8
9
10
11
12
13
package cn.bytecollege.instance;
public class CastDemo {
public static void main(String[] args) {
Student s = new Student();
//子类对象转成父类,向上转型,自动转换
Person p = s;
Person p2 = new Person();
//父类对象转子类,向下转型,强制转换
Student s2 = (Student) p;
//String和Person没有任何关系,转换时会编译出错
String str = p2;
}}

初始化块

我们知道构造方法可以对对象进行状态的初始化,和构造方法具有相同功能的是初始化块。

初始化块

一个类中可以存在多个初始化块,相同类型的初始化块按照书写的先后顺序执行,其语法格式如下:

1
2
[修饰符]{
//代码块 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package cn.bytecollege.init;
public class Student {
private String name;
//定义初始化块
{
System.out.println("执行了第1个初始化块");
//初始化中也可以对实例变量初始化
name = "张三";
}
public Student() {
System.out.println("执行了构造方法");
}
//定义初始化块
{
System.out.println("执行了第2个初始化块");
}
public static void main(String[] args) {
Student student = new Student();
System.out.println("main方法中访问name属性:"+student.name);
}

运行结果如下:

从上面的结果可以看出,当创建Java对象时,系统总是先调用类中定义的初始化块,如果一个类中定义了2个普通的初始化块,前面定义的初始化块先执行,后面定义的初始化块后执行。

虽然 Java 允许一个类里定义 2个普通初始化块,但这没有任何意义。因为初始化块是在创建 Java对象时隐式执行的,而且它们总是全部执行,因此完全可以把多个普通初始化块合并成一个初始化块,从而可以让程序更加简洁,可读性更强。

从上面的示例中我们可以看出,实例变量也可以在初始化块中进行初始化,也就是说从某种程度上来说,初始化块是构造器的补充,但是初始化块不能替代构造方法。因为初始化块是一段固定执行的代码,他不能接受任何参数。如果有一段初始化处理代码对所有对象完全相同,且无需接受任何参数,就可以把这段初始化代码提取到初始化块中。如果从反编译的角度来看,初始化块中的代码在运行时会合并进构造器中。

从结果中我们可以看出在构造方法中多了几行指令,因此我们可以得出结论,初始化块中的代码最终也会合并进构造方法中执行。

静态初始化块

如果定义初始化块使用了static修饰,则这个初始化块就变成了静态初始化块,也被称为类初始化块(普通初始化块负责对对象进行初始化,类初始化块则负责对类进行初始化)。静态初始化块是类相关的,系统将在类初始化阶段执行静态初始化块,而不是在创建对象时才执行,因此静态初始化块比普通初始化块先执行。并且类初始化块通常用于对类变量进行初始化处理,静态初始化块不能对实例变量进行初始化。
与普通初始化块类似的是,系统在类初始化阶段执行静态初始化块时,不仅会执行本类的静态初始化块,而且还会一直上溯到 java.lang.Object 类(如果它包含静态初始化块),先执行java.lang.Object 类的静态初始化块(如果有),然后执行其父类的静态初始化块,最后才执行该类的静态初始化块,经过这个过程,才完成了该类的初始化过程。只有当类初始化完成后,才可以在系统中使用这个类,包括访问这个类的类方法、类变量或者用这个类来创建实例。
注意:静态初始化块也被称为类初始化块,同样静态成员不能访问非静态成员,因此静态初始化块不能访问实例变量和实例方法。

下面,我们通过示例来学习静态初始化块:

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
package cn.bytecollege.init;
public class Base {
//静态初始化块
static{
System.out.println("Base静态块");
}
{
System.out.println("Base初始化块");
}
public Base() {
System.out.println("Base构造方法");
}}package cn.bytecollege.init;
public class Sub extends Base{

static {
System.out.println("Sub静态块");
}
{
System.out.println("Sub初始化块");
}
public Sub() {
System.out.println("Sub构造方法");
}
}package cn.bytecollege.init;
public class Test {
public static void main(String[] args) {
Sub s = new Sub();
}}

从结果我们可以看出,我们创建Sub对象时,先追溯到父类,执行了父类的静态块代码,然后执行了子类的静态块代码,然后执行了父类的初始化块中的代码和构造方法,最后才到子类中执行了子类的初始化代码块和构造方法。

总结一下,我们可以归纳出静态代码块和初始化块的顺序

初始化块中的陷阱

需要注意的是在类运行过程中,一定是类成员先初始化,查看下面的示例:

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
30
package cn.bytecollege.init;
public class Base {
static Sub sub = new Sub();
//静态初始化块
static{
System.out.println("Base静态块");
}
{
System.out.println("Base初始化块");
}
public Base() {
System.out.println("Base构造方法");
}}package cn.bytecollege.init;
public class Sub extends Base{

static {
System.out.println("Sub静态块");
}
{
System.out.println("Sub初始化块");
}
public Sub() {
System.out.println("Sub构造方法");
}
}package cn.bytecollege.init;
public class Test {
public static void main(String[] args) {
Sub s = new Sub();
}}

这个结果你可能会有疑惑,但是你始终记得static修饰的成员先初始化就能得到答案,当程序运行时,我们创建了sub对象,此时,因为static修饰的会先运行,代码第4行我们创建了sub对象,创建子类对象时会先创建父类对象,因此会先创建父类对象,创建完父类对象后再创建子类对象,然后继续根据我们上一小节总结的顺序进行执行。

final 关键字

final关键字可以用于修饰类,变量和方法,用于表示不可变的意思。

final修饰变量时,表示该变量一旦获得初始值以后,就不能被不能被重新赋值,final既可以修饰成员变量,也可以修饰局部变量、形参。

final 修饰成员变量

成员变量是随类初始化或对象初始化而初始化的。当类初始化时,系统会为该类的类变量分配内存,并分配默认值∶当创建对象时,系统会为该对象的实例变量分配内存,并分配默认值。也就是说,当执行静态初始化块时可以对类变量赋初始值; 当执行普通初始化块、构造器时可对实例变量赋初始值。因此,成员变量的初始值可以在定义该变量时指定默认值,也可以在初始化块、构造器中指定初始值。
对于 final 修饰的成员变量而言,一旦有了初始值,就不能被重新赋值,如果既没有在定义成员变量时指定初始值,也没有在初始化块、构造器中为成员变量指定初始值,那么这些成员变量的值将一直是系统默认分配的 0、”\u0000’、false 或 null,这些成员变量也就完全失去了存在的意义。因此 Java 语法规定∶ final 修饰的成员变量必须由程序员显式地指定初始值。
归纳起来,final 修饰的类变量、实例变量能指定初始值的地方如下。

  • 类变量:必须在静态初始化块中指定初始值或声明该类变量时指定初始值,而且只能在两个地
方的其中之一指定。
  • 实例变量∶ 必须在非静态初始化块、声明该实例变量或构造器中指定初始值,而且只能在三个
地方的其中之一指定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package cn.bytecolleg.fin;
public class Final1 {
static int age;
String gender;
final String name;
//静态块中初始化类变量
static {
age = 18;
//静态块中不能初始化实例变量// gender = "男";
}
{
//初始化块中可以初始化final修饰的变量
gender = "女";
name = "张三";
}}

与普通成员变量不同的是,****final 成员变量(包括实例变量和类变量)必须由程序员显式初始化

final 修饰局部变量

系统不会对局部变量进行初始化,局部变量必须由程序员显式初始化。因此使用 final 修饰局部变量时,既可以在定义时指定默认值,也可以不指定默认值。
如果 final 修饰的局部变量在定义时没有指定默认值,则可以在后面代码中对该 final 变量赋初始值,但只能一次,不能重复赋值;如果 final 修饰的局部变量在定义时已经指定默认值,则后面代码中不能再对该变量赋值。下面程序示范了 final 修饰局部变量、形参的情形。

1
2
3
4
5
6
7
8
9
10
11
package cn.bytecolleg.fin;
public class Final2 {

public static void test(final int a) {
//方法中不能对final修饰的形参赋值// a = 100;
System.out.println(a);
}
public static void main(String[] args) {
test(300);
}}

final修饰方法

final 修饰的方法不可被重写,如果出于某些原因,不希望子类重写父类的某个方法,则可以使用 final 修饰该方法。
Java 提供的 Object 类里就有一个 final方法∶ getClassO),因为 Java不希望任何类重写这个方法,所以使用 final把这个方法密封起来。但对于该类提供的 toString()和 equals()方法,都允许子类重写,因此没有使用 final 修饰它们。

1
2
3
4
5
6
7
8
9
10
11
12
package cn.bytecollege.init;
public class Father {
public final void test() {

}}package cn.bytecollege.init;
public class Son extends Father{
//编译出错,final修饰的方法不能被重写
@Override
public final void test() {

}}

final 修饰类

inal修饰的类不能有子类,也就是说final修饰的类不能被继承,当子类继承父类时,父类的有些方法可能被重写,属性可以被访问,如果不希望出现上述情况,可以使用final修饰类,这样讲阻止子类继承。

不可变类

不可变(immutable)类的意思是创建该类的实例后,该实例的实例变量是不可改变的。Java 提供的 8 个包装类和 java.lang.String 类都是不可变类,当创建它们的实例后,其实例的实例变量不可改变。
如果需要创建自定义的不可变类,可遵守如下规则。

  • 使用 private 和 final 修饰符来修饰该类的成员变量。
  • 提供带参数构造器,用于根据传入参数来初始化类里的成员变量。
  • 仅为该类的成员变量提供getter 方法,不要为该类的成员变量提供 setter 方法,因为普通方法无
法修改 final 修饰的成员变量。

软件设计原则

在软件开发中,软件的可维护性和代码的可复用性是一个开发者必须所思考的内容,为了增加软件的可扩展性和灵活性,开发者应该尽可能的根据以下这6条原则开发程序。

单一职责原则(Single Responsibility Principle)

单一职责简要来说就是对于一个类而言,应该只专注做一件事情。单一职责元素是一种对对象的理想期望,对象不应该承担太多的职责。这样就可以保证对象的高内聚,以及细粒度,方便对对象的重用。如果一个对象承担了太多的职责,当客户端需要该对象的某个职责时,就不得不把所有的职责都包含进来,从而造成代码冗余。

里式替换原则(Liskov Substitution Principle)

在面向对象的语言中,继承是一种非常优秀的机制,继承主要有2下几个优点:

  • 代码复用,减少子类的工作量,每个子类都拥有父类的方法和属性
  • 提高代码的可重用性及可扩展性

同样,继承也存在若干缺点,主要体现在以下几个方面:

  • 继承是入侵式的,只要继承就必须拥有父类的方法和属性
  • 增强了耦合性,当父类的常量、变量、方法修改时,必须考虑子类的修改,这有可能造成大量的代码需要重构

里式替换原则可以简单的概况为所有引用基类的地方必须能透明的使用其子类,换句话说,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常。但是反过来则不行,子类能出现的地方,父类一不定能出现,这一点要尤为注意。

依赖倒置原则(Dependence Inversion Principle)

依赖倒置原则是指:高层模块不应该依赖底层模块,两者都依赖其抽象,并且抽象不应该依赖细节,而应该是细节依赖于抽象。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是具体的实现类,实现类实现了接口或继承了抽象类,其特点是可以直接被实例化。依赖倒置原则在Java语言中的表现是:

  • 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生;
  • 接口或抽象类不依赖于实现类;
  • 实现类依赖于接口或抽象类。

依赖倒置原则更加精确的定义就是“面向接口编程”——OOD(Object-OrientedDesign)的精髓之一。依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。依赖倒置原则是JavaBean、EJB等组件设计模型背后的基本原则。

接口隔离原则(Interface Segregation Principle)

接口隔离原则的具体含义如下。

  • 一个类对另外一个类的依赖性应当是建立在最小的接口上的。
  • 一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。因此使用多个专门的接口比使用单一的总接口要好。
  • 不应该强迫客户依赖于它们不用的方法。接口属于客户,不属于它所在的类层次结构,即不要强迫客户使用它们不用的方法,否则这些客户就会面临由于这些不使用的方法的改变所带来的改变。

迪米特法则(Law of Demeter)

迪米特法则又叫最少知识原则(Least Knowledge Principle,LKP),意思是一个对象应当对其他对象尽可能少的了解。迪米特法则最初是用来作为面向对象的系统设计风格的一种法则,在1987年由Ian Holland在美国东北大学为一个叫迪米特的项目设计提出的,因此叫做迪米特法则。
按照迪米特法则,如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用;如果一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。

开闭原则(Open-Closed Principle)

开闭原则是指一个软件实体应当对扩展开放,对修改关闭。
这个原则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即应当可以在不必修改源代码的情况下改变这个模块的行为。在面向对象的编程中,开闭原则是最基础的原则,起到总的指导作用,其他原则(单一职责、里氏替换、依赖倒置、接口隔离、迪米特法则)都是开闭原则的具体形态,即其他原则都是开闭原则的手段和工具。开闭原则的重要性可以通过以下几个方面来体现。

  • 开闭原则提高复用性。在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑,代码粒度越小,被复用的可能性就越大,避免相同的逻辑重复增加。开闭原则的设计保证系统是一个在高层次上实现了复用的系统。

  • 开闭原则提高可维护性。一个软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能对程序进行扩展,就是扩展一个类,而不是修改一个类。开闭原则对已有软件模块,特别是最重要的抽象层模块要求不能再修改,这就使变化中的软件系统有一定的稳定性和延续性,便于系统的维护。

  • 开闭原则提高灵活性。所有的软件系统都有一个共同的性质,即对系统的需求都会随时间的推移而发生变化。在软件系统面临新的需求时,系统的设计必须是稳定的。开闭原则可以通过扩展已有的软件系统,提供新的行为,能快速应对变化,以满足对软件新的需求,使变化中的软件系统有一定的适应性和灵活性。

  • 开闭原则易于测试。测试是软件开发过程中必不可少的一个环节。测试代码不仅要保证逻辑的正确性,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此当有变化提出时,原有健壮的代码要尽量不修改,而是通过扩展来实现。否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试,甚至是验收测试。开闭原则的使用,保证软件是通过扩展来实现业务逻辑的变化,而不是修改。因此,对于新增加的类,只需新增相应的测试类,编写对应的测试方法,只要保证新增的类是正确的就可以了。