四、面向对象(上)
面向对象程序设计(object-oriented programing ,OOP)是当今主流的程序设计范型,Java 是面向对象的程序设计语言,提供了类、成员变量、方法等基本的功能。类是一种自定义的数据类型,所有使用类定义的变量都是引用变量,引用到类的对象。类用于描述客观世界里某一类对象的共同特征,而对象则是类的具体存在。
传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题,一旦确立过程就开始考虑存储数据的适当方式,即算法 + 数据结构 = 程序。而 OOP 将数据放在第一位,然后再考虑操作数据的算法。
Java 支持面向对象的三大特征:封装、继承和多态:
- Java 提供了
private
、protected
和public
三个访问控制修饰符实现封装; extends
关键字来让子类继承父类的成员变量和方法,如果访问控制允许,子类实例可以直接调用父类定义的方法;- 继承关系实现类复用时,子类对象可以直接赋给父类变量,变量具有多态性;
构造器用于对类实例进行初始化操作,构造器支持重载,如果多个重载的构造器里包含了相同的初始化代码,则可以把初始化放置在普通初始化块里完成,初始化块总在构造器执行执行被调用。静态初始化块用于初始化类,在类初始化阶段被执行,如果继承树里面的某一个类需要被初始化时,系统会同时初始化该类的所有父类。
4.1 类和对象
面向对象程序设计(OOP)中,类(class)是构造对象的模板或蓝图,由类构造(construct)对象的过程称为创建类的实例(instance)。
4.1.1 类
Java 中最简单的类定义形式为:
[修饰符] class ClassName
{
field1
field2
...
constructor1
constructor2
...
method1
method2
...
}
- 修饰符:
public
、final
、abstract
,或者省略这三个修饰符; - 类名:UpperCamelCase 风格,其中包含了三种最常见的成员:构造器、成员变量和方法:
- 成员变量用于定义该类或该类的实例所包含的状态数据;
- 方法用于定义该类或该类的实例的行为特征或功能实现;
- 构造器用于构造该类的实例,Java 语言通过
new
关键字来调用构造器,返回该类的实例;
- 各成员之间可以相互调用,但 static 修饰的成员不能访问没有 static 修饰的成员;
- 系统会给类提供默认的构造器;
4.1.2 成员变量
定义成员变量的语法格式为:
[修饰符] 类型 成员变量名 [ = 默认值]
- 修饰符:省略或
public
、protected
、private
、static
、final
,其中public
、protected
、private
三个最多选其一,可以与static
、final
组合起来修饰成员变量; - 类型:所有基本类型和引用类型;
- 成员变量名:lowerCamelCase 风格;
4.1.3 方法
[修饰符] 方法返回值类型 方法名(形参列表)
{
//可执行语句组成的方法体
}
- 修饰符:省略或
public
、protected
、private
、static
、final
、abstract
,其中public
、protected、private
三个最多选其一;abstract
和final
最多出现其一,可以与static
组合修饰方法; - 方法返回值类型:基本数据类型和引用类型;
- 方法名:lowerCamelCase 风格;
- 形参列表:形参类型与形参名空格隔开,参数间逗号隔开;
- static 关键字修饰的成员(方法、成员变量等)标明它属于这个类本身,而不属于该类的单个实例,因此 statIc 修饰的成员变量和方法也称为类变量、类方法,不适用 static 修饰的成员变量和方法称为实例变量、实例方法。
- static 是静态的意思,因此 statIc 修饰的成员变量和方法也称为静态变量和静态方法,反之为非静态变量和非静态方法,静态成员不能直接访问非静态成员。
- static 的作用就是区分成员变量、方法、内部类、初始化块这四种成员到底属于类本身还是属于实例。
4.1.4 构造器
构造器是一个特殊的方法,不能定义返回值,语法格式如下:
[修饰符] 构造器名 (形参列表)
{
//可执行语句组成的构造器执行体
}
- 修饰符:省略或
public
、protected
、private
其中之一; - 构造器名:构造器名必须和类名相同;
- 形参列表:形参类型与形参名空格隔开,参数间逗号隔开;
类的作用: 定义变量、创建对象、调用类的类方法和访问类的类变量,下面定义 Person 类:
public class Person {
public String name;
public int age;
public void say()
{
System.out.println(this.name);
}
}
修饰符对比如下:
名称 | 修饰符 |
---|---|
类 | 省略或 public、final、abstract |
成员变量 | 省略或 public、protected、private、static、final |
方法 | 省略或 public、protected、private、static、final、abstract |
构造器 | 省略或 public、protected、private |
4.1.5 对象
对象是类的具体存在,其作用:访问对象的实例变量、调用对象的方法,下面创建 Person 对象:
public class Test {
public static void main(String[] args) {
//创建 Person 对象
Person p = new Person();
p.age = 18;
p.name = "John";
p.say();
}
}
创建 Person 对象时,定义的 Person 对象类型的变量 p 实际是一个引用,存放在堆内存中,指向实际的 Person 对象,真正的 Person 对象存放在堆内存中。Java 程序不允许直接访问堆内存中的对象,只能通过该对象的引用操作该对象。
图中可以看出 p 引用变量本身只存储了一个地址值,因此可以多个引用变量指向同一个对象。栈内存里的引用变量并未真正存储对象的成员变量,对象的成员变量数据实际存放在堆内存里,而引用变量只是指向该堆内存里的对象。
如果堆内存里的对象没有任何变量指向该对象,那么程序将无法再访问该对象,Java 的回收机制将会回收该对象,释放该对象所占的内存区并将这些引用变量赋值为 nulll。
Java 提供了一个this
关键字,this
关键字总是指向调用该方法的对象:1. 构造器中引用该构造器正在初始化的对象;2. 在方法中引用调用该方法的对象。如果方法中有个局部变量和成员变量同名,又需要在该方法中访问这个被覆盖的成员变量,必须使用this
前缀。
static 修饰的方法用类直接调用该方法,不能使用 this 引用,即静态成员不能直接访问非静态成员。Java 中不建议使用对象去调用 static 修饰的成员变量和方法,虽然这样允许使用对象来调用,但底层依然是使用这些实例所属的类作为调用者,而且会造成含义混淆;在静态方法中访问另一个普通方法,需要重新创建一个对象。
4.2 方法详解
方法是类或对象行为特征的抽象,在逻辑上要么属于类,要么属于对象。如果方法使用 static 修饰,则这个方法属于这个类,否则这个方法属于这个类的实例,采用“类.方法”或“对象.方法”的形式来调用。
4.2.1 参数传递
Java 中方法的参数传递方式只有一种:值传递,即将实际参数值的副本(复制品)传入方法内,而参数不会受到任何影响。堆内存中保存了对象本身,栈内存中保存了引用该对象的引用变量。
4.2.2 形参可变
JDK 1.5 后,Java 允许定义形参个数可变的参数,从而允许为方法指定数量不确定的形参,即在最后一个形参的类型后增加三点(…),则表明该形参可以接受多个参数值。
public class Test {
/**
* 形参个数可变方法与形参数组方法
*/
//个数可变的形参只能处于形参表的最后,即一个方法中最多一个个数可变的形参
public static void test(int a, String... books)
{
for(String temp : books)
{
System.out.println(temp);
}
}
//采用数组形参来声明方法,调用时必须传给该形参一个数组
public static void test(String[] books)
{
for(String temp : books)
{
System.out.println(temp);
}
}
public static void main(String[] args) {
test(2, "hello", "java");
test(new String[]{"hello", "java"});
}
}
4.2.3 方法重载
Java 允许同一个类中定义多个同名方法,只要形参列表不同就行。如果一个类中包含了两个或两个以上方法的方法名相同,但形参列表不同,则成为方法重载(overload)。Java 程序中确定一个方法需要三个要素:
- 调用者,也就是方法的所属者,既可以是类,也可以是对象。
- 方法名,方法的标识;
- 形参列表,当调用方法时系统会根据传入的实参列表匹配,包括参数的个数、参数的顺序、参数的类型,只要有一个不同就叫做参数列表不同,参数类型相同名称不同不包括在内;编译器通过方法名和参数类型区别同一个类不同的方法 ,例如test(int, int),不关心参数名。
方法重载的要求就是两同一不同:同一个类中,方法名相同,参数列表不同。其他部分如方法返回值、修饰符等与方法重载没有任何关系。
4.3 成员变量和局部变量
Java 语言中根据定义变量位置的不同,可以分为:成员变量和局部变量。成员变量(fileld)指的是在类里定义的变量,而局部变量指的是方法里定义的变量。一个类在使用之前要经过类加载、类验证、类准备、类解析和类初始化等几个阶段。
4.3.1 成员变量
成员变量分为类变量和实例变量两种:
- 初始化:成员变量无需显式初始化,只要一个类定义了类变量或实例变量,系统会自动在类的准备阶段或创建类实例时进行默认初始化,赋值规则与数组动态初始化赋值规则完全相同;
系统会在第一次使用类的时候加载并初始化这个类,包括为该类的类变量分配内存空间并指定默认初始值。
实例变量是在创建实例时分配内存空间并指定初始值,此时只需要为属于对象的实例变量分配内存,而不需要为类变量分配内存。
- 作用域与生存范围:类变量从该类的准备阶段起存在直到系统销毁这个类,类变量的作用域与这个类的生存范围相同;而实例变量从该类的实例被创建开始存在,直到系统销毁这个实例,实例变量的作用域与对应实例的生存范围相同。
public class Person {
public String name;
public static int age;
public void say()
{
System.out.println(this.name);
}
}
初始化 Person 类后的存储示意图如下:
创建两个对象并赋值代码如下:
public class Test {
/**
* 成员变量的初始化和内存中的运行机制
*/
public static void main(String[] args) {
/*
第一次使用 person 类时加载并初始化
在堆内存中为 Person 类分配一块存储区,其中包含类变量默认值为0
堆中创建类对象,并赋给栈中的变量 p1
*/
Person p1 = new Person();
Person p2 = new Person();
//为实例变量赋值
p1.name = "wang";
p2.name = "zhang";
//为类变量赋值,此处仅为了说明类变量与对象关系,应使用类调用类变量
p1.age = 15;
p2.age = 18;
//类.类变量,输出 18
System.out.println(Person.age);
}
}
可以看出,创建对象时并不需要为 age 分配内存,name 实例是属于单个 Person 实例的,因此修改第一个 Person 对象的 name 实例变量时仅仅与该对象有关;而所有的 Person 实例访问 age 类变量时都将访问到 Person 类的 age 类变量内存区。
4.3.2 局部变量
局部变量分为形参、方法局部变量和代码块局部变量 3 种:
局部变量类型 | 作用域 |
---|---|
形参 | 整个方法内有效 |
方法局部变量 | 定义该变量的地方到该方法结束的地方 |
代码块局部变量 | 定义该变量的地方到该代码块结束的地方 |
与成员变量不同,局部变量除了形参之外,必须显式初始化,也就是说,必须给方法局部变量和代码块局部变量指定初始值,系统才会为局部变量分配内存并保存初始值,否则不可访问。
- 同一个类里不能定义两个同名的成员变量,即使一个是类变量,一个是实例变量 。
- 同一个方法内,不能定义两个同名的局部变量,方法局部变量和形参也不能同名;但不同代码块局部变量可以同名。如果先定义代码块局部变量,后定义方法局部变量,那么这两个局部变量可以同名。
- Java 允许局部变量和成员变量同名,如果方法里的局部变量和成员变量同名,局部变量会覆盖成员变量,this(实例变量) 或 类名(类变量)作为调用者来访问被覆盖的成员变量。
局部变量不属于任何类和实例,总是保存在其所在方法的栈内存中。如果局部变量是基本 类型的变量,则直接把这个变量的值保存在该变量对应的内存中;如果局部变量是引用类型,则变量存放的是引用到该变量实际引用的对象或数组的地址。栈中的内存无需系统垃圾回收,往往随着方法或代码块结束而结束。
成员变量的使用:
- 描述某个类或某个对象的固有信息,若该信息对于这个类所有实例完全相同或者说类相关则定义为类变量,若为实例相关则定义为实例变量 ;
- 保存该类或者该实例运行时的状态信息;
- 在某个类的多个方法之间进行共享;
局部变量的使用:尽可能缩小局部变量的作用范围;
4.4 隐藏和封装
封装(Encapsulation)是面向对象的三大特征之一,它指的是将对象的状态信息隐藏在对象的内部,不允许外部程序直接访问对象的内部信息,而是通过该类提供的方法来实现对内部信息的操作和访问。良好的封装应该从以下两个方面:
- 将对象的成员变量和实现细节隐藏起来,不允许外部直接访问;
- 把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作;
4.4.1 访问控制符
类里的一个成员(包括成员变量、方法和构造器等)可以使用访问控制符来修饰:
- private(当前类访问权限):成员只能在当前类的内部被访问,可用来修饰成员变量实现封装;
- default(包访问权限):成员或外部类可以被相同包中的其他类访问;
- protected(子类访问权限):成员既可以被同一个包中的其他类访问,也可以被不同包中的子类访问。通常情况下,protected 修饰的的方法是为了子类重写这个方法;
- public(公共访问权限):成员或外部类可以被所有类访问;
private | default | protected | public | |
---|---|---|---|---|
同一个类中 | √ | √ | √ | √ |
同一个包中 | √ | √ | √ | |
子类中 | √ | √ | ||
全局范围内 | √ |
可知访问控制符用于控制一个类的成员是否可以被其他类访问,对于局部变量而言,其作用域就是它所在的方法,不可能被其他类访问,因此不能用访问控制符访问。对于外部类而言,只有两种级别的访问控制级别:public 和 default。
Java 类里的实例变量的 setter 和 getter 方法具有非常重要的意义。如果一个 Java 类的每个实例变量都使用 private 修饰,并为每个实例变量都提供了 public 修饰 setter 和 getter 方法,那么这个类就是一个符合 JavaBean 规范的类,因此 JavaBean 总是一个封装良好的类。
一个类就是一个小的模块,模块设计常常追求高内聚(尽可能把模块的内部数据、功能实现细节隐藏在模块内部独立完成,不允许外部直接干预),低耦合(仅暴露少量的方法给外部使用)。
访问控制符的使用应遵循以下基本原则:
- 类里的绝大部分成员变量都应该使用
private
修饰,只有一些static
修饰的、类似全局变量的成员变量才考虑用public
修饰。除此之外,有些方法只用于辅助实现该类的其他方法,这些方法被称为工具方法,工具方法也应该使用private
修饰; - 如果某个类主要用作其他类的父类,该类包含的大部分方法可能仅希望被其子类重写,而不想被外界直接调用,则应该使用
protected
修饰; - 希望暴露出来给其他类自由调用的方法应该使用
public
修饰,因此类的构造器使public
修饰,允许在其他地方创建该类的实例。外部类通常都希望被其他类自由使用,所以大部分外部类都使用public
修饰。
4.4.2 导入包
Java 引入包(package)机制,提供类的多层命名空间,用于解决类的命名冲突问题、类文件管理问题。Java 允许将一组功能相关的类放到同一个 package 下,从而组成逻辑上的类库单元,应该在 Java 源程序的第一个非注释行写上 package 语句:
//package packagename;
package namespace;
public class Test
{
public static void main(String[] args)
{
System.out.println("Hello World!");
}
}
Java规定:位于包中的类,在文件系统中也必须有与包名层次相同的目录结构,使用命令行编译和运行这个 java 文件,如果编译 Java 文件时不使用-d
选项,编译器不会为 Java 源文件生成相应的文件结构。
/*
-d 选项用于设置编译生成 .class 文件的保存位置,.代表当前路径
编译后会多出一个 namespace 文件夹存放 Test.class 文件
注意:Test.class文件必须放在 namespace 文件夹下才是有效的
*/
javac -d . Test.java
/*
在当前文件夹执行该命令
如果进入 namespace 文件夹使用该命令,系统会提示错误
*/
java namespace.Test
当虚拟机要装载 namespace.Test 类时,它会一次搜索 CLASSPATH 环境变量所指定的系列路径,查找这些路径下是否包含 namespace 路径,并在 namespace 路径下查找是否包含 Hello.class 文件。虚拟机在装载带有包名的 类时,会优先搜索 CLASSPATH 环境变量指定的目录,然后在这些目录中按与包层次对应的目录结构去查找 .class 文件。
同一个包中的类不必位于相同的目录下,只要让 CLASSPATH 环境变量里面包含这些类路径即可。Java 包机制需要两个方面保证:1. 源文件中使用 package 语句指定包名;2. class 文件必须放在对应的路径下。
为避免不同公司之间类名的重复,Oracle 建议使用公司 Internet 域名倒写来作为包名,还可以依次建立项目子包、模块子包、组件子包等。使用某个类时就需要该类的全名(即包名加类名),例如 namespace.Test。如果在 namespace 中定义一个子包 sub,定义一个空类 Empty:
package namespace.sub;
public class Empty {}
namespace.sub.Empty 类位于 sub 子包下,与 namespace.Test 不在同一个包下,使用时就需要该类的全名即 namespace.sub.Empty 。虽然 namespace.sub 是子包,但在 Test 类中使用时不能省略 namespace 包名。
父包和子包之间确实表示了某种内在的逻辑联系,但用法上不存在任何联系。如果父包中的类需要使用子包中的类,则必须使用子包的全名,而不能省略父包部分。
为简化编程,Java 引入import
关键字,可以向某个 Java 文件中导入指定包层次下某个类或全部类,import 语句应该出现在 package 语句之后、类定义之前,import
语句中*
只能代表类,不能代表包。
import namespace.sub.Empty;
import namespace.*;
Java 默认为所有源文件导入 java.lang 包下的所有类,因此在 Java 程序中使用 String、System 类时都无须使用import
来导入这些类。在极端情况下只能在源文件中使用类全名,例如 Date 类,java.sql 和 java.util 包中都包含 Date 类,此时只能使用该类的全名。
JDK 1.5 后增加了一种静态导入的语法,用于导入指定类的某个静态成员变量、方法或全部的静态成员变量、方法,静态导入使用import static
语句。
import static java.lang.System.*;
import static java.lang.Math.*;
public class Test {
/**
* test
*/
public static void main(String[] args) {
/*
out 是 java.lang.System 类的静态成员变量,代表标准输出
public static final PrintStream out;
PI是 java.lang.Math类的静态成员变量,表示π常量
*/
out.println(PI);
//调用 Math 类的静态方法 sqrt
out.println(sqrt(16));
}
}
可以看出 import
和import static
的功能十分相似,只是导入对象不一样。总结 Java 源文件的大体结构如下:
//0个或1个,必须放在文件开始
package 语句
//0个或多个,必须放在类定义之前
import | import static 语句
//0个或1个public 类、接口或枚举定义
public classDefinition | interfaceDefinition | enumDefinition
//0个或多个普通类、接口或枚举定义
classDefinition | interfaceDefinition | enumDefinition
4.4.3 Java 常用包
Java 的核心类都放在 java 包以及子包下,Java 扩展的许多类都放在 javax 包以及其子包下。这些实用类就是 API (应用程序接口),常见的有:
常用包 | 说明 |
---|---|
java.lang | Java 语言的核心类,如 String、Math、System 和 Thread 类等,使用时无需使用 import 语句导入,系统会自动导入这个包下的所有类 |
java.util | Java 的大量工具类/接口和集合框架类/接口,如 Arrays 和 List、Set 等; |
java.net | Java 网络编程相关的类/接口; |
java.io | Java 输入/输出编程相关的类/接口 |
java.text | Java 格式化相关的类 |
java.sql | Java 进行 JDBC 数据库编程的相关类/接口 |
java.awt | 抽象窗口工具集(Abstract Windows Toolkits)的相关类/接口,用于构建图形用户界面(GUI)程序 |
4.5 深入构造器
构造器是一个特殊的方法,用于创建实例时执行初始化,即使是使用工厂模式、反射等方式创建对象,其实质依然是依赖构造器。Java类中必须包含一个或一个以上的构造器,如果程序员没有为 Java 类提供任何构造器,则系统会为这个类提供一个无参数的构造器。
4.5.1 构造器初始化
构造器最大的作用就是在创建对象时执行初始化,如果想改变这种默认的初始化,在系统创建对象时就为该对象的实例变量显式指定初始值。
public class Person {
private String name;
private int age;
//自定义构造器
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public static void main(String[] args) {
Person p = new Person("wang", 18);
System.out.println(p.name);
System.out.println(p.age);
}
}
注意:构造器是创建 Java 对象的重要途径,通过 new 关键字调用构造器时返回该类的对象,但这个对象并不是完全由构造器负责创建。事实上,当程序员调用构造器时,系统会为该对象分配内存空间,并执行默认初始化,即对象的产生在构造器执行之前已经完成,且已经执行默认初始化,只不过这个对象还不能被外部程序访问,只能在该构造器中用 this 引用。构造器执行后,对象作为构造器的返回值返回,通常赋值给另一个引用类型的变量,从而让外部程序访问。
4.3.2 构造器重载
同一个类中有多个不同形参列表的构造器称为构造器重载,它允许类中包含多个初始化逻辑,从而允许不同构造器来初始化 Java 对象。如果构造器之间存在包含关系,为了程序代码的简洁性和可维护性,可以使用this
关键字来调用相应的构造器。this
调用构造器中必须作为构造器执行体的第一条语句,系统会根据this
后括号的实参来调用形参列表与之对应的构造器。
public class Person {
private String name;
private int age;
private String gender;
//无参构造器
public Person() {}
//两个参数构造器
public Person(String name, int age) {
this.name = name;
this.age = age;
}
//三个参数构造器
public Person(String name, int age, String gender) {
this(name, age);
this.gender = gender;
}
public static void main(String[] args) {
Person p = new Person("wang", 18, "男");
System.out.println(p.name);
System.out.println(p.age);
System.out.println(p.gender);
}
}
4.6 类的继承
继承是面向对象的三大特征之一,Java 的继承具有单继承的特点,每个子类只要一个直接父类。
4.6.1 继承的特点
Java 的继承通过extends
关键字实现,实现继承的类被称为子类,被继承的被称为父类,父类和子类的关系是一种一般和特殊的关系,因此子类可以看成一种特殊的父类,父类包含的范围比子类包含的范围要大。如果定义一个 Java 类时并未显式指定这个类的直接父类,则这个类默认扩展 java.lang.Object 类,因此 java.lang.Object 是所有类的父类。语法格式如下:
[修饰符] class SubClass extends SuperClass
{
//类定义部分
}
子类继承了父类的全部成员变量和方法,不能获得父类的构造器,子类扩展(extends)父类,父类派生(derive)子类。
//父类
class Fruit {
public double weight;
public void info()
{
System.out.println("我是一种水果!重" + weight + "g");
}
}
//子类
public class Apple extends Fruit{
public static void main(String[] args) {
//创建Apple对象
Apple a = new Apple();
//Apple对象本身没有weight成员变量,从父类Fruit类继承
a.weight = 25;
//调用继承的方法
a.info();
}
}
4.6.2 重写父类方法
子类包含与父类同名方法的现象被称为方法重写(override),也称为方法覆盖。方法的重写要遵循“两同两小一大”原则:
- 两同即方法名相同、形参列表相同;
- 两小即子类返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
- 一大即子类方法的访问权限应比父类方法的访问权限更大或相等;
需要注意的是,
- 父类和子类同名方法要么都是类方法,要么都是实例方法,否则会引起编译错误;
- 子类可以继承父类的静态方法但不能重写;
- 编译器通过方法名和参数类型区别父类和子类不同的方法 ,例如test(int, int),不关心参数名。
- 如果父类方法具有
private
访问权限,则该方法对其子类是隐藏的,因此子类无法访问或重写该方法;如果子类中定义了一个与父类private
方法具有相同方法名、形参列表和返回值类型的方法,依然不是重写,只是定义了一个新方法。 - 父类和子类方法也会发生重载:子类获得父类方法并定义与父类方法相同但参数列表不同的方法就会形成重载;
- 当子类覆盖父类方法后,子类对象将无法访问父类中被覆盖的方法,但可以使用
super
(被覆盖的是实例方法)或 父类类名(被覆盖的是类方法)作为调用者来调用父类中被调用的方法;
class Animals {
public static void printA() {
System.out.println("父类的静态方法");
}
public void printB() {
System.out.println("父类的普通方法");
}
}
class Cat extends Animals {
//@Override
public static void printA() {
System.out.println("子类的静态方法");
}
@Override
public void printB() {
System.out.println("子类的普通方法");
}
}
public class Main {
public static void main(String[] args) {
Cat cat = new Cat();
cat.printA();//输出子类的静态方法
cat.printB();//输出子类的普通方法
/*
多态发生时,发现子类的静态方法并没有覆盖父类的静态方法
*/
Animals animals = new Cat();
animals.printA();//输出父类的静态方法
animals.printB();//输出子类的普通方法
}
}
方法重载(overload) | 方法重写(override) | |
---|---|---|
原则 | 两同一不同 | 两同两小一大 |
类 | 同一个类 | 子类和父类 |
访问控制符 | - | 子类方法的访问权限应比父类方法的访问权限更大或相等 |
static | - | 子类与父类都 static 修饰或都不用 static 修饰 |
返回值 | - | 子类返回值类型应比父类方法返回值类型更小或相等 |
方法名 | 相同 | 相同 |
异常 | - | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等 |
形参列表 | 参数的个数、顺序、类型有不同 | 相同 |
4.6.3 super 限定
super 是 Java 提供的关键字,用于限定该对象调用它从父类继承得到的实例变量或方法。如同this
不能出现在static
修饰的方法中一样,super
也不能出现在static
修饰的方法中,因为static
修饰的方法属于类。
- 如果子类定义了和父类同名的实例变量,则会发生子类实例变量隐藏父类实例变量的情形,也可以通过 super 访问父类中被隐藏的实例变量。
- 如果子类里没有包含和父类同名的成员变量,那么子类实例方法中访问该成员变量时,则无须显式使用 super 或父类名作为调用者。
//子类
class BaseClass {
public int a = 5;
//test()方法是private访问权限,子类不可访问该方法
private void test(){}
}
//父类
public class SubClass extends BaseClass{
public int a = 7;
//此处并不是重写,所以增加static关键字不会编译错误
public static void test(){}
public void accessOwner()
{
System.out.println(a);
}
public void accessBase()
{
//通过super来限定访问从父类继承得到的a实例变量
System.out.println(super.a);
}
public static void main(String[] args) {
SubClass sc = new SubClass();
//输出5
sc.accessBase();
//输出7
sc.accessOwner();
}
}
如果在某个方法中访问名为 a 的成员变量,但没有显式指定调用者,则系统查找 a 的顺序为:
- 查找该方法中是否有名为 a 的局部变量;
- 查找当前类中是否包含名为 a 的成员变量;
- 查找 a 的直接父类中是否包含名为 a 的成员变量,依次上溯 a 的所有父类,直到 java.lang.Object 类,如果最终不能找到名为 a 的成员变量,则系统出现编译错误。
如果被覆盖的父类变量是类变量,在子类的方法中可以通过父类类名作为调用者来访问被覆盖的类变量;系统不仅会为该类中定义的实例变量分配内存,也会为它从父类继承得到的所有实例变量分配内存(包括间接父类 );下面介绍一种特殊情况:
class Parent
{
public String tag = "父类";
}
class Derived extends Parent
{
//定义一个私有tag实例变量来隐藏父类的tag变量
private String tag = "子类";
}
public class Test {
public static void main(String[] args) {
Derived de = new Derived();
//程序不可访问d的私有变量tag,编译错误
//System.out.println(de.tag);
/*
main是static修饰的方法,不能出现super
将de变量显式地向上转型为Parent,即可访问tag实例变量
注意.运算符优先级高于强制类型转换(),输出“父类”
*/
System.out.println(((Parent)de).tag);
}
}
4.6.4 调用父类构造器
子类不会获得父类的构造器,但子类构造器可以用super
调用父类构造器的初始化代码,类似于一个构造器使用this
调用另一个重载的构造器,使用 super 调用构造器也必须出现在子类构造执行体的第一条语句,所以 this 调用和 super 调用不会同时出现。
不管是否使用super
调用来执行父类构造器的初始化代码,子类构造器总会调用父类构造器一次。子类构造器调用父类构造器分如下情况:
- 子类构造器执行体的第一行使用 super 显式调用父类构造器,系统将根据 super 调用里传入的实参列表调用父类对应的构造器。
- 子类构造器执行体的第一行代码使用 this 显式调用本类中重载的构造器,系统将根据 this 调用里传入的实参列表调用本类中的另一个构造器
- 子类构造器执行体中既没有 super 调用,也没有 this 调用,系统将会在执行子类构造器之前,隐式调用父类无参数构造器。
不管哪种情况,父类构造器总会在子类构造器之前执行(没有 super 调用则调用父类无参数构造器,super 调用有参数构造器则只调用该父类构造器);依次类推,创建任何 Java 对象,最先执行的总是 java.lang.Object 类的构造器。
class Creature
{
public Creature()
{
System.out.println("Creature 无参数构造器");
}
}
class Animal extends Creature
{
public Animal(String name)
{
System.out.println("Animal带一个参数的构造器,该动物的名字为" + name);
}
public Animal(String name, int age)
{
this(name);
System.out.println("Animal带两个参数的构造器,其年龄为"+ age);
}
}
public class Wolf extends Animal{
public Wolf()
{
super("灰太狼", 3);
System.out.println("wolf无参数构造器");
}
public static void main(String[] args) {
new Wolf();
}
}
运行结果:
Creature 无参数构造器
Animal带一个参数的构造器,该动物的名字为灰太狼
Animal带两个参数的构造器,其年龄为3
wolf无参数构造器
总结:创建任何对象总是从该类所在继承树最顶层类的构造器往下执行,最后才执行本类的构造器,如果某个父类通过this
调用了同类中重载的构造器,就会依次执行此父类的多个构造器。
4.7 多态
4.7.1 多态性
Java 引用变量有两个类型:一个是编译时类型,一个是运行时类型。编译时类型由声明该变量时使用的类型决定,运行时类型由实际赋给该变量的对象决定。如果编译时类型和运行时类型不一致,就可能出现所谓的多态(Polymorphism)。
class BaseClass {
public int book = 6;
public void base()
{
System.out.println("父类的普通方法");
}
public void test()
{
System.out.println("父类的被覆盖的方法");
}
}
public class SubClass extends BaseClass{
//重新定义一个book实例变量隐藏父类的book实例变量
public String book = "Java 学习";
public void test()
{
System.out.println("子类的覆盖父类的方法");
}
public void sub()
{
System.out.println("子类的普通方法");
}
public static void main(String[] args) {
//编译时类型和运行时类型完全一样,不存在多态
BaseClass bc = new BaseClass();
//输出6
System.out.println(bc.book);
//调用BaseClass的方法
bc.base();
bc.test();
//编译时类型和运行时类型完全一样,不存在多态
SubClass sc = new SubClass();
//输出Java 学习
System.out.println(sc.book);
//调用从父类中继承的base()方法
sc.base();
//调用子类的覆盖父类的test()方法
sc.test();
//调用子类的普通方法sub()
sc.sub();
//编译时类型和运行时类型不一样,多态发生
BaseClass ploymophicbc = new SubClass();
//输出6 - 表明访问的是父类的实例变量
System.out.println(ploymophicbc.book);
//调用从父类继承的base()方法
ploymophicbc.base();
//调用子类覆盖父类的test()方法
ploymophicbc.test();
/*
因为ploymophicbc编译时类型是BaseClass
BaseClass类没有提供sub()方法,所以代码出现编译错误
*/
//ploymophicbc.sub();
//强制转换后完全表现子类的变量和行为特征
//输出Java 学习
System.out.println(((SubClass)ploymophicbc).book);
//父类的普通方法
((SubClass)ploymophicbc).base();
//子类的覆盖父类的方法
((SubClass)ploymophicbc).test();
//子类的普通方法
((SubClass)ploymophicbc).sub();
}
}
对于变量ploymophicbc
编译时类型是 BaseClass,运行类型是 SubClass,当调用父类类型引用变量的test()
方法(父类 BaseClass 中定义了该方法,子类 SubClass 覆盖了父类的方法),实际执行的是 SubClass 类中覆盖的test()
方法,出现多态。因为子类是一种特殊的父类,Java 允许将一个子类对象直接赋给一个父类引用变量,无需任何类型转换或称之为向上转型(upcasting),向上转型由系统自动完成。但 ploymophicbc.sub() 在编译时会引发错误,虽然变量确实包含sub()
方法(例如,可以用反射来执行该方法),但因为编译时类型为 BaseClass,因此编译时无法调用sub()
方法。
当一个子类对象直接赋给父类引用变量时,运行调用该引用变量方法时,其方法行为总是表现出子类行为的行为特征,这样就出现同类型的变量调用同一个方法时呈现不同行为特征,这就是多态。与方法不同的是,对象的实例变量不具备多态性。
引用变量在编译阶段只能调用其编译时类型所具有的方法,但运行时则执行它运行时类型所具有的方法,因此,在编写 Java 代码时,引用变量只能调用声明该变量所用类里包含的方法。例如通过 Object p = new Person() 代码定义一个变量 p,则这个 p 变量只能调用 Object 类的方法,而不能调用 Person 类里定义的方法。
4.7.2 引用类型的强制转换
Java 中引用类型只能调用它编译时类型的方法,而不能调用运行时类型的方法,即使它实际所引用的对象确实包含该方法。如果需要让这个引用变量调用它运行时类型的方法,则必须强制类型转换成运行时类型。本小节需特别注意 null 类型的情况,类型转换符为小括号,语法为:(type)variable
,类型转换符作用有以下两个方面:
- 将一个基本类型变量转换成另一个基本类型:基本类型之间的转换只能在数值类型(整数型、字符型、浮点型)之间进行,数值类型和布尔类型不能进行类型转换;
- 将一个引用类型变量转换成其子类类型:引用类型的转换只能在具有继承关系的两个类型之间进行,父类要转换成子类实例,对象必须实际上是子类实例,即编译时是父类类型,运行时是子类类型。
- 两个没有任何继承关系的类型进行类型转换会发生编译错误,
- 两个具有继承关系的类型进行类型转换且对象不是子类实例,将引发 ClassCastException 异常。该种情况例如 Object obj = new Object(),String str = (String)obj 。
- null强制转换成任何引用类型。
//强制转换后完全表现子类的变量和行为特征
//输出Java 学习
System.out.println(((SubClass)ploymophicbc).book);
//父类的普通方法
((SubClass)ploymophicbc).base();
//子类的覆盖父类的方法
((SubClass)ploymophicbc).test();
//子类的普通方法
((SubClass)ploymophicbc).sub();
总结: 两个没有任何继承关系的类型转换会出现编译错误;具有继承关系的父类实例强制转换为子类实例,但对象实际不是子类实例,运行时引发 ClassCastException 异常;特别地,null 强制转换成任何引用类型,或者赋给给引用类型然后强制转换成任意具有父子继承关系的引用类型(无继承关系会引发编译错误)。
考虑到强制类型转换是可能出现异常,因此进行类型转换之前先通过instanceof
运算符来判断是否可以成功转换。instance
运算符的前一个操作数通常是一个引用类型变量,后一个操作数通常是一个类(或接口,可以把接口理解成一种特殊的类),用于判断前面的对象是否是后面的类、子类或实现类的实例。
- 当把子类对象赋给父类引用变量时,被称为向上转型(upcasting),这种转型总是可以成功,即子类是一种特殊的父类。这种转型只是表明这个引用变量的编译时类型是父类,但实际执行它的方法时,依然表现出子类对象的行为方式。但把一个父类对象赋给子类引用变量时,需要强制类型转换,而且还可能在运行时产生 ClassCastException 异常,使用 instanceof 运算符可以让强制类型转换更安全。
- instanceof 运算符前面操作数的编译时类型要么与后面的类相同,要么与后面的类具有父子继承关系,否则会引起编译错误。
- 带有 null 值的引用类型的变量,instanceof 操作会返回 false。
import static java.lang.Math.*;
public class Test {
public static void main(String[] args) {
//double强制类型转换成long,输出3
double dd = PI;
long l = (long)dd;
System.out.println(l);
//obj变量编译时类型是Object,运行时类型是String
Object obj = "Hello";
//Object与String存在继承关系,且obj实际是String实例,可以强制类型转换
String str = (String)obj;
//输出Hello
System.out.println(str);
//子类直接赋值给父类,向上转型
str = "Java";
obj = str;
System.out.println(obj);
//obj对象是后面的类的实例,返回true
System.out.println(obj instanceof String);
//str对象是后面的类的子类实例,返回true
System.out.println(str instanceof Object);
/*
obj对象是后面的类的父类实例,返回false
也就是说此时强制类型转换会抛出ClassCastException异常
*/
obj = new Object();
System.out.println(obj instanceof String);
//抛出异常
//str = (String)obj;
/*
注意!!!
带有null值的引用类型的变量,instanceof操作会返回false
且null强制转换成任何引用类型,所以并没有抛出ClassCastException异常
*/
SubClass sc;
BaseClass bc = null;
System.out.println(bc instanceof SubClass);
sc = (SubClass)bc;
}
}
总结: instanceof运算符首先检测前面操作数编译时类型是否与后面类相同或者具有父子继承关系,否则编译错误。再判断前面的对象是否是后面类、子类、实现类的实例,若是返回 true,否则返回 false。特别地, null 值的引用类型的变量, instanceof 操作总是返回 false 。
4.7.3 null类型
Java 中共有3种类型,基本数据类型、引用类型和 null
型,同时直接量通常也只有三种类型:基本类型、字符串型和 null
类型。null
具有以下特点:
null
是 Java 中的关键字,大小写敏感;- java 中的任何引用变量都将
null
作为默认值; null
既不是对象也不是一种类型,它仅是一种特殊的值,null
可以赋值给任何引用类型变量,或者强制转换成任何引用类型(上一节提到);null
不能赋值给基本类型,会出现编译错误,但将null
赋值给类 Object,然后将 Object 赋给各自的基本类型,编译不会报错,但运行会空指针,这是自动拆箱导致的;- 任何含有
null
值的包装类在 Java 拆箱生成基本数据类型时候都会抛出一个空指针异常;
import java.util.HashMap;
import java.util.Map;
public class Test {
/*
调用put方法时,自动装箱会自己处理好将int装箱成Interger
但当一个数字没有计数值的时候,HashMap的get()方法将会返回null,而不是0
因为Integer的默认值是null而不是0
当把null值传递给一个int型变量的时候自动拆箱将会返回空指针异常。
*/
public static void main(String args[]) throws InterruptedException {
Map numberAndCount = new HashMap<>();
int[] numbers = {3, 5, 7,9, 11, 13, 17, 19, 2, 3, 5, 33, 12, 5};
for(int i : numbers){
int count = numberAndCount.get(i);// NullPointerException here
numberAndCount.put(i, count++);
}
}
}
- 带有
null
值的引用类型变量,instanceof
操作将会返回false
(上一节提到); - 一个值为
null
的引用类型变量不能调用非静态方法,会抛出空指针异常;但它可以调用静态方法(静态绑定)。 - 可以使用
==
或者!=
操作来比较null
值(null == null
会返回true),但是不能使用其他算法或者逻辑操作,如大于、小于。 null + ""
输出的字符串仍是 null,null + null
的字符串输出是nullnull
;
4.8 初始化块
初始化块可以用来对 Java 对象进行初始化操作,一个类可以由多个初始化块,相同类型的初始化块按顺序执行。
[修饰符]{
//初始化块的可执行性代码
}
初始化块的修饰符只能是 static,使用 static 修饰的初始化块成为静态初始化块。初始化块里的代码可以包含任何可执行语句,包括定义局部变量,调用其他对象的方法,以及使用分支循环语句等。初始化块虽然也是 Java 类的成员之一,但没有名字和标识,因此无法通过类、对象来调用初始化块。初始化块只在创建 Java 对象时隐式执行,而且在执行构造器之前执行。
普通初始化块、声明实例变量指定的默认值都可以认为是对象的初始化代码,执行顺序与源代码中排列顺序相同。
public class InitialTest {
{//先执行初始化块赋值为9
a = 9;
}
//按顺序后执行实例变量赋值
public int a = 6;
public static void main(String[] args) {
System.out.println(new InitialTest().a);
}
}
Java 创建一个对象后,系统先为该对象的所有实例变量分配内存(前提是该类已经被加载过),然后执行实例变量的初始化,顺序为:先按照源代码中顺序执行初始化块或声明实例变量时指定的初始值,再执行构造器里指定的初始值。因此,可以将初始化代码中任意对象相同且无需接收任何参数的部分提取到初始化块中,即把多个构造器中相同且无需参数的初始化代码放入初始化块,提高应用的可维护性。
实际上,使用 javac 命令编译 Java 类后,该 Java 类中的初始化块会还原到每个构造器中,且位于构造器所有代码的前面。
与构造器类似,创建一个 Java 对象时,不仅会执行该类的普通初始化块和构造器,还会一直追溯先执行 java.lang.Object 类的初始化块和构造器,依次向下直到当前类。
如果定义初始化块使用了 static 修饰符,则初始化块就变成了静态初始化块,也称为类初始化块(普通初始化块对对象执行初始化,类初始化块则负责对类执行初始化),系统将在类初始化阶段执行静态初始化块,因此静态初始化块总是比普通初始化块先执行,但不能对实例变量进行初始化处理。
class Root
{
static{
System.out.println("Root的静态初始化块");
}
{
System.out.println("Root的普通初始化块");
}
public Root()
{
System.out.println("Root的无参数构造器");
}
}
class Mid extends Root
{
static{
System.out.println("Mid的静态初始化块");
}
{
System.out.println("Mid的普通初始化块");
}
public Mid()
{
System.out.println("Mid的无参数构造器");
}
public Mid(String msg)
{
//通过this调用同一类中重载的构造器
this();
System.out.println("Mid的带参数构造器,其参数值为" + msg);
System.out.println(a);
}
}
class Leaf extends Mid
{
static{
System.out.println("Leaf的静态初始化块");
}
{
System.out.println("Leaf的普通初始化块");
}
public Leaf()
{
//通过super调用父类中带参数构造器
super("Java");
System.out.println("执行Leaf的构造器");
}
}
public class InitialTest {
{//没有初始化该类对象,所以不会执行该初始化块
System.out.println("hello");
}
public static void main(String[] args) {
new Leaf();
System.out.println("--------------");
new Leaf();
}
}
运行结果如下:
Root的静态初始化块
Mid的静态初始化块
Leaf的静态初始化块
Root的普通初始化块
Root的无参数构造器
Mid的普通初始化块
Mid的无参数构造器
Mid的带参数构造器,其参数值为Java
Leaf的普通初始化块
执行Leaf的构造器
--------------
Root的普通初始化块
Root的无参数构造器
Mid的普通初始化块
Mid的无参数构造器
Mid的带参数构造器,其参数值为Java
Leaf的普通初始化块
执行Leaf的构造器
可以看出,第一次创建 Leaf 对象时,系统中还不存在 Leaf 类,因此需要先加载并初始化 Leaf 类,初始化 Leaf 类时总是先执行其顶层父类的静态初始化块,再执行其直接父类的静态初始化块,直至执行 Leaf 本身的初始化块。一旦 Leaf 类初始化成功后,Leaf 类在该虚拟机中一直存在,因此第二次创建 Leaf 实例无需初始化 Leaf 类。每次创建一个 Leaf 对象都需要先执行最顶层父类的初始化块、构造器直至当前类。
Java 系统加载并初始化某个类时,总是保证该类的所有父类(包括直接父类和间接父类)全部加载并初始化。
静态初始化块和声明静态成员变量所指定的初始值都是该类的初始化代码,执行顺序与源代码中排列顺序相同。
JVM 第一次主动使用某个类时,系统在类准备阶段为该类的所有静态成员变量分配内存;在初始化阶段则负责初始化这些静态成员变量,初始化静态成员变量就是执行类初始化代码或声明类成员变量时指定的初始值。