5.面向对象初步


Java的面向对象部分基础语法和其它面向对象语言并无太大出入,以C++为例,由于之前有过相关学习经验,这里不说得那么详细了,最基础的东西带一下就好,有差异的地方我们再说.

基本概念:

:即Class,是一种引用类型,一般是多个基本类型的结合体.

对象:即Object,是类的实例化,也就是一个引用类型的数据.

成员变量:即Field,是类内部的一些数据,既可以是基本类型也可是引用类型.

方法:即Method,是对类内部数据进行一些操作的函数,有的地方叫做类内函数.

构造器:即Constructor,是特殊的方法,用于对类实例化对象时进行初始化,写法和普通方法稍有不同.

析构函数:Java有比较好的垃圾回收机制(GC),因此不需要写析构函数,强烈不建议写finalize()进行清理.


定义类

类的定义:

[修饰符] class ClassName {
    // >= 0 个成员变量定义;
    // >= 0 个构造器定义;
    // >= 0 个方法定义;
}
  • 类之前的修饰符可以是publicfinalabstract,也可以省略修饰符.
  • 一个文件中只能有一个public类,文件名必须与此类名相同.
  • 关于ClassName,只要是合法的标识符即可,建议使用驼峰命名法,遵循Java规范.

让我们再细化一些,看看类内部这些元素如何定义:

扫描二维码关注公众号,回复: 5125450 查看本文章

成员变量的定义:

[修饰符] type fieldName [= index];
  • 成员变量之前的修饰符可以是publicprotectedprivatestaticfinal.
  • publicprotectedprivate称为访问限定符,至多使用一个,然后与(或不与)static和(或)final组合使用.
  • 也可以没有访问限定符,此时默认是default.
  • 可以指定成员变量的默认值index,但对于普通成员建议不要这么做.

构造器的定义:

[修饰符] ClassName(args) {
    statement;
}
  • 构造器之前的修饰符可以是访问限定符其中之一.
  • 也可以没有修饰符,此时默认是default.

方法定义:

[修饰符] 返回值类型 methodName(args) {
    statement;
}
  • 方法之前的修饰符可以是访问限定符其中之一,finalabstract其中之一,和static.
  • methodName建议使用动词开头,强调"行为".
  • 也可以没有访问限定符,此时默认是default.
  • 返回值类型包括基本类型和引用类型,不可省略,空返回应指定void.

修饰符说明及this关键字

this关键字和C++几乎一摸一样,只是由于Java对指针进行了封装处理,完全面向对象,因此不允许使用->来调用成员,只让使用.调用.因此只需把C++的this->xxx换成this.xxx即可.this其实是一个指针,用处只有一个: this指向当前对象.我们将在后续例子中看到其用法.

修饰符除了abstract,其它和C++基本一样,在这里简要说明一下各个修饰符的含义:

修饰符 说明
public 类中公开访问的内容,允许用在任意包的通过本类实例化对象/本类类名访问
private 类中私有的内容,只能在本类中访问
protected 类的保护内容,在任意包通过本类子类/本类类名访问
default 默认访问属性,在**同一包内所有类均可以通过实例化对象/本类类名访问,**不允许显式写出
static 类的共享内容,用类名调用,也可以被本类的所有实例化对象调用,但不属于任何对象,属于类
final 类的常量部分,其值不可改变
abstract 抽象类/抽象方法,用于派生/实现接口时覆盖的方法

注意事项:

  • 所谓"包",就是指类存放的目录,在同一目录下的类称为同一个包的类.
  • default不允许显式指定,省略所有访问限定符即相当于写了default.
  • static方法只能访问本类static数据成员和方法,不能访问普通方法/数据成员.
  • 若想在static方法访问本类普通方法/数据成员,必须新建一个对象.
  • this关键字不允许出现在static方法里,因为这样会找不到当前对象(static主调由类发起,而非对象发起).

this关键字的示例程序:

class Dog {
    private String name;
    Dog(String name) {
        this.name = name;
    }
}

this用于访问在方法内被局部变量所覆盖的成员变量,即局部变量和成员变量同名的情况.


深入方法

方法的性质

Java是完全面向对象的语言,几乎所有的内容都要写在类中(import例外),方法也不例外,它具有如下性质:

  • 方法必须在某个类内定义,不能独立定义
  • 方法要么属于类,要么属于某个类的对象
  • 方法要么被类调用,要么被某个类的对象调用
  • 方法调用同类中的方法,实际上也是遵循第二点,有如下两种情况:
    • 调用普通方法,实际调用者是this指向的当前对象,属于对象调用
    • 调用静态方法,需要实例化对象来调用,实际上属于类调用

参数传递

Java程序没有指针的概念,因此参数传递只有一种形式----值传递,请看一个经典程序:

public class SwapTest {
    public static void swap(int a,int b) {
        int tmp = a;
        a = b;
        b = tmp;
        System.out.println("In swap() : a = "+a+" b = "+b);
    }
    public static void main(String[] args) {
        int a = 5,b = 10;
        swap(a,b);
        System.out.println("In main() : a = "+a+" b = "+b);
    }
}

几乎在每一个语言都会讲到这样经典的swap"陷阱".在C++中我们会用地址传递解决,一个小小的引用即可,那么Java呢?Java不支持地址传递啊?难道Java无法解决如此小的问题吗?当然不是,请看下面的示例:

class Data {	//Data类;
    int a;
    int b;
}
public class SwapTest {
    public static void swap(Data dt) {	//传递Data类的实例化对象;
        int tmp = dt.a;
        dt.a = dt.b;
        dt.b = tmp;
        System.out.println("In swap() : a = "+dt.a+" b = "+dt.b);
    }
    public static void main(String[] args) {
        Data data = new Data();
        data.a = 5;
        data.b = 10;
        swap(data);
        System.out.println("In main() : a = "+data.a+" b = "+data.b);
    }
}

我们可以看到,不但在swap()里发生了交换,main()内的data成员变量的值也发生了改变.这样参数的传递的是引用类型,有的地方又称引用传递,但实际上,引用传递也是一种值传递,其原理解释如下:

  • 进入方法前,栈内存的引用变量指向堆内存的对象
  • 进入方法后,栈内存的引用变量生成一个副本引用变量入栈,指向之前堆内存的对象
  • 方法执行时,对副本引用变量的操作,实际上是对其指向的堆内存对象的操作
  • 方法结束后,副本引用变量出栈,内存回收,此时只有一个引用变量指向堆内存的对象

具体过程参照下图(以单一对象传递为例):

ParameterPath

这里的值传递其实传递的是引用变量引用的地址值,这种方式其实和C语言的指针是一样的,C++由于引用变量不占内存,因此和这种方式还不太一样.总之记住Java基本类型作参数是纯值传递,而引用类型作参数则和C的地址传递一样(但也还是值传递).


形参个数可变的方法

Java 1.5版本以后,支持一种形数个数可变的形式定义方法,方便了一些情况下参数个数不确定时,不用多次重载方法,其语法就是在方法形参的最后一个类型后面加上英文的省略号(…),表示可以接受不定数量的( \ge 0个)该类型参数,并将这些参数当成数组处理,其形式如下:

[修饰符] func(type... a) {
    statement;
}

或者有多个参数时:

[修饰符] func(typeA a,typeB b,typeC... c) {
    statement;
}

需要注意的是,这会将多个参数当成数组处理,如:

public class UncertainArgs {
    public static void func(String... str) {
        for(String tmp : str)
            System.out.println(tmp);
    }
    public static void main(String[] args) {
        String[] str = new String[]{"I","AM","HAPPY"};
        func();	//0个参数;
        func("Hello World");	//1个参数;
        func("I","AM","FINE");	//2各参数;
        func(str);	//你甚至可以直接传数组进去;
    }
}

那么这样的方法和形参直接是数组有什么区别呢?当然有区别,区别主要在以下两点:

  • 形参是数组,则传递进去的实参必须是数组;而形参是可变参数,参数传递不一定需要数组,是它的灵活性.
  • 数组作形参,可以在任意位置定义,可变参数作形参,只能在最后一个参数位置定义,是它的不灵活性.

关于可变参数的不灵活性,这是语法规定,目的是防止误判.可以参考C++的带默认参数的函数,一样的道理.

递归方法

递归实际上在面向过程的语言中说得更多一些,可能面向对象的语言不太强调这些技巧?不太清楚,总之一些编程技巧是无关语言的,递归就是这样,这是一种自己调用自己的方法,随便写一个例子:

public class RecursionTest {
    public static void littleCarp(int count) {
        if(count == 7) {
            System.out.println();
            return;
        }
        if(count == 0)  System.out.println("吓得我抱起了");
        if(count < 3) System.out.print("抱着");
        else if(count == 3) System.out.print("我的小鲤鱼");
        else System.out.print("的我");
        littleCarp(count+1);
    }
    public static void main(String[] args) {
        littleCarp(0);
    }
}

递归我们比较熟悉,就不再多说了.

方法的重载

和C++一样,Java可以对方法进行重载(Overload),重载的条件有两个不同一个相同:

  • 参数的类型至少有一个不同
  • 参数的个数至少有一个不同
  • 重载的多个方法的方法名相同

总结就是:参数的类型或个数至少有一个不同.

重载的意义在于,使用同名的方法,实现相似但不完全一样的功能.以下可能是最经典的例子之一了:

public class OverloadTest {
    public static int max(int a,int b) {
        return a > b ? a : b;
    }
    public static int max(int a,int b,int c) {
        int tmp = max(a,b);
        return tmp > c ? tmp : c;
    }
    public static void main(String[] args) {
        int a = 10;
        int b = 15;
        int c = -50;
        System.out.println(max(a,c));
        System.out.println(max(a,b,c));
    }
}

注意:重载不能以方法的返回值作为条件,原因如下:

假设我们定义如下方法:

public SomeClass {
    public void test() {
        System.out.println("void return");
    }

    public int test() {
        System.out.println("int return");
        return 1;
    }
}

调用是怎么个情况呢?是这样:

SomeClass sc = new SomeClass();
sc.test();

此时就会产生歧义:sc.test到底是哪一个方法?从调用的角度来看,Java应是根据参数类型和个数判断所调方法的,而方法返回值是会被忽略的部分.因此方法返回值不能用作方法重载条件.

深入构造器

构造器是一种特殊的方法,本来想写在上面一节中.但考虑到构造器的特殊性,就单独拿出来写了.

使用构造初始化

明确一点:构造器有初始化的功能,且只该有初始化的功能.任何与初始化无关的操作不要在构造器里进行.

构造器不同于方法的几个特征:

  • 构造器名必须与类名相同
  • 构造器不能写返回类型,void也不行
  • 构造器通过new关键字调用
  • 一个实例化对象能且只能调用一次构造器

如以下例子:

class Complex {
    private double real;
    private double imag;
    public Complex(double real,double imag) {
        this.real = real;
        this.imag = imag;
    }
    public void show() {
        if(imag < 0)
            System.out.println(real+""+imag+"i");
        else if(imag == 0)
            System.out.println(real);
        else
            System.out.println(real+"+"+imag+"i");
    }
}
public class ConstructorTest {
    public static void main(String[] args) {
        Complex c = new Complex(10,5.5);
        Complex d = new Complex(-10,-5.5);
        c.show();
        d.show();
    }
}

因为我的学习路线是C->C+±>Java,因此我们还是来从C++入手,比较一下Java和C++的构造器.

首先是调用方式,C++支持隐式调用,不需要new关键字显式调用,且C++的new关键字调用后返回的是本类对象的一个指针,对应到Java呢?其实是一样的.虽然我们说构造器不能写返回类型,但实际上构造器的返回类型是当前类型的一个实例化对象的首地址,默认有返回值当然不让用户再定义返回类型.很多书都说返回的是一个对象,但我认为说返回的是一个指向对象的指针/对象的首地址比较准确.Java使用new关键字调用构造器,实际上是让返回的地址赋值给了所声明的一个当前类型的引用变量,就像下面的形式:

Complex c = new Complex(10,5.5);	//Complex(double,double)返回的地址赋值给引用变量c

Complex d;	//d空指向;
d = Complex(-10,-5.5);	//Complex(double,double)返回的地址赋值给引用变量d

之前就解释过了,引用变量实际上就是指针,类名也是一样的,就是一个指针.Java限定用户使用指针是为了防止对内存的修改,让用户脱离内存,专注于封装和面向对象的设计思路.然而Java语言本身的设计绕不开对内存的应用,自然也就处处出现指针的身影.无需太过纠结,相信学过C++的你一定轻松掌握.

那么我们来看另一个例子:

//Complex类同上;

public class ConstructorTest {
    public static void main(String[] args) {
        Complex c = new Complex(10,5.5);
        c = new Complex(5.5,5.5);
        c.show();
    }
}

这个例子主要是想说明,类名是指针的性质,最后输出结果为:

5.5+5.5i

此程序在内存的活动为:

DoubleConstruct

那么这样的例子是否有悖一个实例化对象能且只能调用一次构造器的结论呢?

当然没有,虽然程序里看似是同一个c构造两次,但是c只是一个引用变量,跟实例化对象没有关系.这两次构造实际上产生了两个对象,c只是指向从一个到了另一个,但是两次调用构造器,确实产生两个对象,这是因为一个实例化对象构造一次即确定下来(指内存),因此不能再被构造一次.

构造器重载

既然构造器是方法,那自然可以重载,条件和方法的重载一致.构造器的重载,希望做到的是:在不同条件下对对象的不同成员变量进行初始化.比如有时需要对部分成员变量初始化,有时又要对所有成员变量初始化.但本质上还是实现相似而不同的功能,符合方法重载的目的.

构造器重载也不多说,就当是一个初始化方法的重载即可,我们重写一下Complex类,如下:

class Complex {
    private double real;
    private double imag;
    public Complex(double real) {
        this.real = real;
        imag = 0;
        System.out.println("Constructor 'Complex(double)' called.");
    }
    public Complex(double real,double imag) {
        this(real);
        this.imag = imag;
    }
    public void show() {
        if(imag < 0)
            System.out.println(real+""+imag+"i");
        else if(imag == 0)
            System.out.println(real);
        else
            System.out.println(real+"+"+imag+"i");
    }
}
public class ConstructorTest {
    public static void main(String[] args) {
        Complex c = new Complex(10);
        Complex d = new Complex(10,-5.5);
        c.show();
        d.show();
    }
}

可以看到我们在Complex(double,double)中使用了this(double),这实际上就是调用了Complex(double),因为构造器的特殊性,不要在构造器中直接调用其它构造器(普通方法重载则没有这个问题).

原因:一个对象只能调用一次构造器,在构造器中直接用new调用另一构造器,将会产生一个匿名对象,完成的是对匿名对象的初始化,而当前对象成员并没有被初始化.比如上面的代码,作如下修改:

    public Complex(double real,double imag) {
        new Complex(real);
        this.imag = imag;
    }

有人可能会认为这是对的,但是这不是当前对象的调用,而是构造了新的对象,因此结果是错误的

使用this指代当前对象,也就是声明:在调用构造器时,在当前对象(this)范围内,进行操作,不再额外分配空间,也就不再创建匿名对象,这样就在Complex(double,double)中使用了Complex(double),提高了代码重用率.

注:

  • 遇到构造器重载的情况,先考虑能否直接调用其它构造器,而不要将其它构造器代码贴到当前构造器中.
  • 实际上C++里的placement new和这种用法原理一样,可以相互参考一下.

理解封装

成员变量和局部变量

基本概念

  • 成员变量:定义在类中的变量,分为类变量和实例变量两种
    • 类变量:以static修饰的成员变量,又称静态变量,是类所有的变量,应该使用类名而不是实例对象名调用
    • 实例变量:没有static修饰的变量,又称非静态变量,是对象所有,使用对象名调用
  • 局部变量:定义在方法中的变量,又称临时变量,在方法结束后销毁

用法

类变量:

ClassName.variableName;
//或
objectName.variableName;
  • 不建议用对象名调用类变量.这应该是Java沿袭C++的一个错误.
  • static变量是为了方便"共享",一个类有多个对象,static变量允许所有对象使用,这就是一种数据共享.

实例变量:

objectName.variableName;

变量生命周期

对于成员变量:生命周期是类的生命周期,当类不可见时,成员变量就不可见了

对于局部变量:从方法起调,到方法结束.方法结束了局部变量就不可见了

变量使用内存的机制

对于成员变量:系统会在类加载时自动为其分配内存,自动为其赋初值,这部分变量存在系统堆内存区,占用内存时间较长,有垃圾回收机制对其进行回收处理.

对于局部变量:系统不会为其分配内存,不赋初值.只有程序初始化它们时系统才给它们分配内存空间,这部分变量存在栈内存区,占用内存时间较短,不需要垃圾回收机制处理,随方法结束自动消亡.

封装和隐藏

封装是面向对象三大特征之一,指的是将对象状态信息隐藏在对象内部,外部程序只能通过类方法进行数据操作,而不能直接修改类的数据.良好的封装应包括:

  • 将对象的成员变量和实现细节隐藏起来.
  • 将方法暴露出来,作为对外接口来对成员变量进行安全的操作.

为了实现不同程度的封装,Java使用四个等级的访问限定符,访问控制级别由小到大如下:

private->default->protected->public.

也可以理解为封装性由好到差,具体的访问控制范围如下表:

private default protected public
同一个类中 \checkmark \checkmark \checkmark \checkmark
同一个包中 \checkmark \checkmark \checkmark
子类中 \checkmark \checkmark
全局范围内 \checkmark

对于使用private修饰的成员变量,我们建议用settergetter一对方法来进行修改和访问.通常settergetter都是public方法,命名规则是set/get+成员变量名(首字母全大写),如getName(),setAge(int age)等.如果一个类每个实例变量都用private修饰,且每个实例变量都有一对public修饰的settergetter方法,那么这个类就是一个符合JavaBean规范的类.

  • 外部类的访问控制

一个Java源文件中,外部类可以用publicdefault(省略不写)修饰,因为不在某个类的内部,因此不能用privateprotected.使用public修饰的外部类可以被所有类使用,default修饰的外部类只能被同一个包中的类使用.且一个源文件只能有一个public修饰的外部类,一旦出现这样的外部类,源文件的要和此类同名.

package

声明package

package,即包,Java允许将一组功能相关的类放在同一个package下.在源文件中添加package的语法很简单:

package packagename;

public class Test {}

class TestTwo {}

此时的Test.java文件就在packagename这个包下了.尝试对其进行编译:

javac -d . Test.java

发现生成的Test.classTestTwo.class文件位置在./packageName/下,这说明,将源文件添加在某个包里,会影响编译生成的文件目录结构.

而如果使用javac Test.java编译的话,则会在./下生成两个.class文件,因此编译的时候-d选项决定了处不处理package声明,建议编译的时候带上-d选项指定生成目标文件的位置.

关于package的注意事项:

  • 一个源文件只能声明一个package.
  • package可以有子包,父包和子包用.连接表示包含关系,具体可以看看之前说过的Java命名规范.
  • 一个源文件编译生成的所有的.class文件都在同一个包下.
  • package采用相对路径管理,即不同源文件声明在同一个包下,则不管编译生成的.class的绝对路径是否一致,只要能通过CLASSPATH环境变量找到,在使用时都会算作同一个包下的类.

在正式项目中,往往会有很多级包,有源文件也有目标文件,为了方便项目管理,建议的目录结构如下:

ProjectTree
  • projectName/src/下是各级"包"和Java源文件("包"是手动创建的目录,要求和编译以后的层级关系一一对应,目的是方便以后项目的管理) .
  • projectName/library/下是编译生成对应的各级包和Java目标文件.

导入package

Java语言经过很多年的沉淀,已经有很多已经实现得很好的小功能了,我们可以使用Java提供的这些包,但大部分包需要导入才能使用,就好比C/C++需要导入头文件一样,导入的语法很简单,如下:

import packagename;

这样就可以导入一些你需要使用的包了,譬如之前的ScannerArrays类,需要导包才能使用:

import java.util.Scanner;
import java.util.Arrays;

也可以直接导入某个包下的所有类,使用Unix的文件通配符即可:

import java.util.*;		//导入java.util下的所有类;

然而一般建议写类的全名,不建议将包下所有类全部导入.因为有的包下有同名的类,当你使用到这个类时,编译器将会报二义性错误.如java.utiljava.sql下都有Date类.

Java 1.5以后支持导入一个类的单个/多个/所有静态成员(static成员变量/方法),用import static即可.如:

import static package.subpackage...ClassName.field | methodName;	//导入单个静态成员/方法;
//或
import static package.subpackage...ClassName.*;		//导入所有静态成员/方法;

请看以下示例:

import static java.lang.System.out;	//导入java.lang.System类下的静态out成员变量;
import static java.lang.Math.sqrt;	//导入java.lang.Math类下的静态sqrt()方法;
import static java.lang.Math.*;	//导入java.lang.Math类下所有的静态成员和方法;

关于导入package的总结:

  • import只导入到当前指定的包下所有类,并不导入其子包下的类.
  • import的时候尽量详细到类名,避免一次导入太多的无用包.

Java常用包

Java的核心类都在java及其子包下,Java的扩展类都在javax及其子包下.下面介绍几个常用Java包:

  • java.lang:包含了语言核心类,如StringThreadMathSystem等,无需使用import导入,系统将自动导入这个包下的所有类(不包括子类)
  • java.util:包含了大量工具类/接口和集合框架类/接口,如ArraysListSet
  • java.net:包含了一些Java网络编程相关的类/接口
  • java.io:包含了一些Java输入/输出编程相关的类/接口
  • java.text:包含了一些和Java格式化相关的类
  • java.sql:包含了Java进行JDBC数据库编程的相关类/接口
  • java.awt:包含了抽象工具集(Abstract Window Toolkits)的相关类/接口,主要用于构建GUI程序
  • java.swing:包含了Swing图形用户编程相关的类/接口,用于构建与平台无关的GUI程序

继承

我们来讲讲面向对象的第二大特性————继承.

继承的特点

Java使用extends关键字实现继承,字面意思翻译是扩展:父类扩展出子类的意思.我们通常说:子类继承父类.

  • 和C++一样,Java的子类除了不能继承父类的构造器以外,父类其余的元素都可以继承.
  • 和C++一样,Java父类的private成员变量/方法对子类是不可见的.
  • 和C++不一样的是,Java摒弃了多继承,仅支持单继承,即一个类至多只能有一个直接父类.
  • 如果一个类未继承任意一个类,那么它默认扩展java.lang.Object,即java.lang.Object是所有类的父类.
  • 子类继承了(inherits)父类,父类派生了(derive)子类,继承有子类 is a kind of 父类的关系.

super关键字

如同this那样,super也是一个指针,指向当前对象所继承的父类对象.这么说可能有些奇怪,哪里存在一个父类对象?实际上父类对象就存在于子类内部,有一个匿名的父类对象(关于这一点,有人有不同的看法,我个人愿意理解这种说法).我们能用this来引用父类对象的可见方法/成员变量.

如以下例子:

class Person {
    private int age;
    private String sex;
    private String name;
    Person() {}
    Person(String name,int age,String sex) {
        this.age = age;
        this.sex = sex;
        this.name = name;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public void setSex(String sex) {
        this.sex = sex;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public String getSex() {
        return sex;
    }
    public String getName() {
        return name;
    }
}

class Student extends Person{
    private String studentID;
    Student() {
        super();
    }
    Student(String name,int age,String sex,String studentID) {
        super(name,age,sex);
        this.studentID = studentID;
    }
    public void setStudentID(String studentID) {
        this.studentID = studentID;
    }
    public String getStudentID() {
        return studentID;
    }
    public void show() {
        System.out.println("Student's name: "+super.getName());
        System.out.println("Student's age: "+super.getAge());
        System.out.println("Student's sex: "+super.getSex());
        System.out.println("Student's ID: "+studentID);
    }
}

public class InheritTest {
    public static void main(String[] args) {
        Student stu1 = new Student();
        Student stu2 = new Student("Aaron Alex Mahone",20,"male","201736025030");
        stu1.show();
        stu2.show();
    }
}

可以看到在上例中,super关键字的作用有:

  • 用super(args)调用父类的构造函数. \checkmark
  • 用super.methodName()调用父类的可见方法. \checkmark
  • 实际上还可以用super.field访问父类的可见成员变量.

正是因为superthis的用法如此接近,我才坚信子类中有父类对象的说法:因为this指向对象,而superthis的使用形式几乎一样,我认为,子类中有父类的匿名对象,子类对继承内容的使用,是因为隐式调用super罢了.而子类调用super,实际上应该是子类的super指向了父类对象的this,那么这一切都解释得通了.

关于例子中程序在内存的活动,我认为应该是这样的:

InheritTest

上图画的是类的成员变量和方法分离的存放方式(参见C++类和对象的存储方式),以及每一个类的superthis指针的指向,类中的矩形框表示父类的匿名实例对象.

重写父类的成员变量/方法

子类继承了父类,同时允许子类有独有的方法,不仅如此,还可以重写(Override) 父类的成员变量/方法,这样可以重新定义父类的方法,以达到在子类中实现不同的功能的效果.

重写的条件很简单:

  • 当前类继承某个类
  • 在子类对父类的方法进行重新定义,重新定义满足的条件:两同两小一大三不可.
  • 两同:子类方法名、参数列表和父类相同.
  • 两小:子类方法返回值类型 \le 父类方法返回值类型,子类方法抛出的异常类 \le 父类方法抛出的异常类.
  • 一大:子类方法访问限定符优先级 \ge 父类方法访问限定符.
  • 三不可:父类final修饰的方法不可重写,父类的类方法(static)不可重写,父类的不可见成员变量/方法不可重写.

Q:为什么finalstaticprivate修饰的方法不可重写?为什么不能对父类构造器重写?

A:

final修饰的方法为常方法,不可改动,自然不支持重写.

static修饰的是类方法,而继承是依赖于实例对象的(从super关键字的调用方式就可以看出),因此只能重写实例方法,而无法重写类方法.

private方法在子类不可见,可以实现同名方法,但这里既不存在重写也不存在重载,因为子类根本感觉不到其存在,只能说是重新实现该方法.

至于父类构造器,子类根本无法继承,继承都没有继承的东西,就别谈重写了.

Q:重写之后,父类方法去哪儿了?能调用吗?

A:

方法调用顺序是:子类对象调用方法->搜索this的方法->(this不存在该方法)->搜索super的方法.当子类重写了父类方法,则调用子类方法后就不再搜索父类的方法了此时称父类方法对子类隐藏.方法并未消失,要调用父类方法需要使用super指定.

注意隐藏方法和不可见方法的区别:

  • 隐藏方法:可以用super调用,子类对其访问权限依然存在.
  • 不可见方法:不可以用super调用,子类无权访问.

一个简单的例子:

class Bird {
    private String variety;
    Bird() {}
    Bird(String variety) {
        this.variety = variety;
    }
    protected void setVariety(String variety) {
        if(variety == null)
            this.variety = variety;
    }
    protected String getVariety() {
        return variety;
    }
    protected void fly() {
        System.out.println("I'm "+variety);
        System.out.println("I'm flying in the sky~");
    }
}

class Penguin extends Bird {
    Penguin() {
        super("Penguin");
    }
    public void fly() {     //重写父类的方法;
        System.out.println("I'm Penguin");
        System.out.println("I can't fly");
    }
    public void test() {
        super.fly();        //调用父类被隐藏的方法;
    }
}

public class OverrideTest {
    public static void main(String[] args) {
        Penguin p = new Penguin();
        p.fly();
        p.test();
    }
}

重载父类的方法

方法的重载我们之前讲过,重载父类的方法没什么不同,要满足方法同名、参数列表不同的特点.有三种情况:

  • 子类没有重写父类的方法,却有 \ge 0个方法和父类方法同名,则这是对父类方法的重载.
  • 子类重写了父类的方法,并且有 \ge 2个方法和父类方法同名,则这是父类方法的重写,对子类方法的重载.
  • 子类重写了父类的方法,且仅有1个方法和父类方法同名,则这是父类方法的重写.

例子就不举了,没有必要纠结这些细节.

调用父类的构造器

如同前面讲的一样,我们要初始化子类,使用super关键字调用父类构造器是最好的一种方式,这样既可以保证代码重用,又可以不用考虑继承的部分初始化问题,只需要为子类新增的成员变量设计初始化方式即可.

这里说明一下构造器的调用次序问题:和C++一样,类构造是自顶向下的,如以下程序示例:

class Base {
    Base() {
        System.out.println("Base constructor called!");
    }
}

class SubBase extends Base {
    SubBase() {
        super();
        System.out.println("SubBase constructor called!");
    }
}

class SubSubBase extends SubBase{
    SubSubBase() {
        super();
        System.out.println("SubSubBase constructor called!");
    }
}

public class ConstructOrderTest {
    public static void main(String[] args) {
        SubSubBase ssb = new SubSubBase();
    }
}

从程序输出结果可以看出,构造器调用顺序:Base()->SubBase()->SubSubBase()->java.lang.Object().因为java.lang.Object()没有任何输出,因此我们感觉不到其调用,但不要忘了这个类是所有类的父类.学过C++的人应该能很好地理解,这其实是为了满足一个思想:先构造的后析构,Java虽然不用用户管析构的事情,但是为了以后清理不出错,显然还是沿袭了C++的设计思路.

多态

讲完继承,是时候讲讲面向对象的第三大特性————多态了.

可能发生多态的条件

Java引用变量有两个类型:编译类型和运行类型.示例如下:

  • 编译类型:声明引用变量时使用的类型,如:String str;则引用变量str的编译类型是String.
  • 运行类型:实际赋值给引用变量的对象的类型,如:str = "hello";则引用变量的运行类型是String.

多态:编译类型和运行类型不一致,就可能会出现多态.

乍一看似乎不可能出现这样的情况,因为编译类型和运行类型不一样时,要么系统会自动进行强制类型转化,要么会报错,但是在类的继承上面,这样的情况却极为常见,因为Java允许子类对象直接赋值给父类引用变量.

这是为什么?因为我们说过,子类 is a kind of 父类.子类是父类的一种,最多是一种特殊的父类,但它还是父类的一种.既然如此,子类对象向父类引用变量的行为,属于同类型转换,Java自然是不会报错也不会强制转换的了.子类对象向父类引用变量赋值,就可能出现编译类型和运行类型不一致的情况,就可能发生多态.

什么是多态

刚刚只是说了可能发生多态,那么什么是多态?怎么能一定发生多态?

请先看下面的例子:

class Base {
    public String testString = "Base";
    Base() {}
    public void test() {
        System.out.println("This is the test method of Base class");
    }
}

class SubBase extends Base {
    public int index = 0;
    public String testString = "SubBase";    //重写父类成员变量,使父类成员变量隐藏;
    SubBase() {
        super();
    }
    public void test() {	//重写父类的方法;
        System.out.println("This is the test method of SubBase class");
    }
}

public class PolymorphismTest {
    public static void main(String[] args) {
        Base base1 = new Base();
        Base base2 = new SubBase(); //编译类型和运行类型不一致;
        System.out.println(base1.testString);
        System.out.println(base2.testString);
        base1.test();
        base2.test();
        // System.out.println(base2.index);
    }
}

输出结果耐人寻味:

Base
Base
This is the test method of Base class
This is the test method of SubBase class

我们来分析一下:

前提:base1和base2的编译类型都是Base类型,但base1运行类型是Base,而base2的运行类型是SubBase.

结果:base1和base2调用的是同一个testString,base1调用的Base里的test(),base2调用SubBase里的test()

分析:base2的成员变量保持自身的属性,而方法表现子类的行为特征.


我们再来看看多态的定义:编译类型相同的引用变量,调用同一个方法时呈现出不同的行为特征.

显然,以上例子发生了多态.注意示例中第28行的注释,没有注释编译时会提示"找不到符号"的错误,因为编译时会严格按照编译类型处理,而Base中不存在index,虽然最后赋值的是SubBase的对象,但是编译类型没有成员变量/方法的就是不能使用.


根据以上例子我们可以总结出,在子类对象赋值给父类引用变量前提下:

  • 父类变量只能访问父类中有的成员变量/方法,不能访问子类新增的(显然).
  • 若发生方法重写,则:
    • 父类引用变量调用被重写的方法呈现子类方法的行为特征,访问父类成员变量则保持父类的属性.
  • 若不发生方法重写,则:
    • 父类引用变量调用方法/访问父类成员变量都保持父类自身的属性.

即,子类对象赋值给父类引用变量后,不管父类成员变量是否被重写,对其访问都保持自身属性.父类方法一旦被重写,调用时就呈现子类方法的行为特征.

因此,编译类型和运行类型不一致只是多态发生的充分不必要条件,多态发生的充要条件为:

  • 发生继承 + 子类对父类方法进行重写.
  • 子类对象赋值给父类引用变量(特殊情况) + 父类对象赋值给父类引用变量(正常情况).
  • 用特殊父类引用变量调用被重写的方法 + 用正常父类引用变量调用相同方法.

引用变量的强制类型转换

我们说过,子类对象赋值给父类引用变量,是合理的.因为 子类 is a kind of 父类.

那么反过来呢? 父类 is not a kind of 子类. 因此Java是不允许将父类对象直接赋值给子类引用变量的.

如果非要这么做,就必须使用强制类型转换,强制类型转换我们再熟悉不过了,格式如下:

typeA a;
typeB b;
a = (typeA) b;	//强制类型转换;

//如:
long a = (long) 3.14;

强制转换的注意点:

  • 基本类型的强制转换只能在数值类型中(整型/字符型/浮点型)进行,无法在数值类型和布尔类型之间进行.
  • 引用类型的强制转换,必须存在继承关系.
  • 将子类对象赋值给父类引用变量称为向上转换(upcasting),这种转换总是能成功.
  • 将父类对象赋值给子类引用变量,则该父类对象必须是子类实例(即编译类型是父类类型,运行类型是子类类型),若不满足则会发生ClassCastException异常

使用instanceof运算符避免异常

Java提供instanceof运算符,用以判断一个实例化对象的运行类型是否是另一个类的对象/其子类的对象,是则返回true,否则返回false.

objectName instanceof ClassName;

如果instanceof判断结果是true,则说明该对象和此类的对象可以进行强制类型转换而不出现错误.

引用类型的强制类型转换之前应该先用instanceof判断一下,避免异常的发生,如下例:

class Base {
    public String testString = "Base";
    Base() {}
    public void test() {
        System.out.println("This is the test method of Base class");
    }
}

class SubBase extends Base {
    public int index = 0;
    public String testString = "SubBase";    //重写父类成员变量,使父类成员变量隐藏;
    SubBase() {
        super();
    }
    public void test() {	//重写父类的方法;
        System.out.println("This is the test method of SubBase class");
    }
}

public class InstanceofTest {
    public static void main(String[] args) {
        Object hello = "hello";     //编译类型是java.lang.Object;
        if(hello instanceof String) {   //判断hello的运行类型是否是String/String的子类;
            String str = (String) hello;
            System.out.println(str);
        }
        Base base = new SubBase();     //编译类型是Base;
        if(base instanceof SubBase) {   //判断base的运行类型是否是SubBase/SubBase的子类;
            SubBase subBase = (SubBase) base;
            subBase.test();
            System.out.println(subBase.index);
        }
    }
}

可以看出:

  • 子类对象转化为父类对象,子类新增的内容对父类隐藏了.
  • 父类对象转化为子类对象,原本对父类隐藏的内容又可以用子类对象访问了.

继承与组合

使用继承的注意事项

继承大法好,子类继承了父类的所有成员变量和方法,可以直接复用父类的成员变量和方法,非常方便.但是子类通常能够直接修改父类的成员变量值(内部信息),造成了父类和子类的高度耦合.因此,为了有限度地使用继承,我们一般要注意以下事项:

  • 尽量隐藏父类内部数据.多用private将父类成员变量对子类隐藏,不让子类直接访问父类内部信息
  • 不让子类随意访问、修改父类方法.父类中的工具方法,应用private限定.
  • 父类中希望被外部类调用的方法,必须用public修饰,若不想被子类重写,则加上final限定.
  • 希望子类重写而又不想被其它类自由访问的方法,用protected修饰.
  • 不要在父类构造器中调用要被子类重写的方法.
  • 子类需要额外增加属性.
  • 子类要有自己独特的行为方式,可以通过重写父类方法、增加新方法来实现.

用组合实现复用

所为组合,就是将一个类作为成员变量嵌入一个新类中.这样新类可以复用内嵌类的public成员变量/方法.

很容易看出:继承是纵向的,组合是横向的.继承容易破坏父类封装性,组合可以保护封装性(只能用public).

继承强调子类 is a kind of 父类. 由父到子,根据族谱关系是从上到下的.

组合强调Class A has a Class B. A包含B,将A展开得到B和其它.显然是横向展开.

请看下面的例子:

class Person {
    private String name;
    private String sex;
    private int age;
    Person() {}
    Person(String name,int age,String sex) {
        this.name = name;
        this.age = age;
        this.sex = sex;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public int getAge() {
        return age;
    }
    public void setSex(String sex) {
        this.sex = sex;
    }
    public String getSex() {
        return sex;
    }
    public void show() {
        System.out.println(name+","+age+","+sex);
    }
}

class Couple {
    private Person boy;
    private Person girl;
    Couple() {}
    Couple(Person boy,Person girl) {
        this.boy = boy;
        this.girl = girl;
    }
    public void show() {
        System.out.println(boy.getName()+" and "+girl.getName()+" is a couple.");
    }
}

public class CompositeTest {
    public static void main(String[] args) {
        Person boy = new Person("Aaron",20,"male");     //被组合的类应该显示创建对象;
        Person girl = new Person("Sara",20,"female");
        Couple cp = new Couple(boy,girl);               //以被组合的类的实例为参数初始化组合类;
        boy = girl = null;  //修改临时变量的指向,避免对Couple类的内部对象进行错误修改;
        cp.show();
    }
}

上述例子,Couple类有两个成员boy和girl,在进行初始化时构造器使用的参数是两个Person类型.

Q:为什么不把Person类的构造器参数写两份放到Couple的构造器里而是要显式创建两个对象传入构造器呢?

A:

因为这样有很大的局限性,设想一下如果Person类有多个不同的构造函数,那Couple是不是需要写多个复制两遍Person类的构造器参数的构造器?直接把初始化好的Person实例传进来多么方便!根本无需考虑它们是怎么初始化的.在"万物皆对象"的Java,我们只需要考虑到成员变量这一层即可,哪怕成员变量有时候是引用类型的,也不需要我们再向下考虑了,而是那个引用类型变量自己的事情.

Q_Q:那为什么继承需要考虑父类的构造呢?

A:

因为继承是需要在子类中构造一个父类对象的,且只能在子类构造,当然要考虑了.

Q:每次使用都显式新建几个对象,组合会不会比继承开销更大?

A:

不会,虽然要显式创建对象,但是在组合类中并没有再创建对象,只是简单的引用变量赋值,让被组合类的引用变量指向显式创建的对象而已.最多就是几个引用变量的开销,实际上可以忽略不计.

Q:继承和组合都分别什么时候用?

A:

看关系咯,组合是包含关系,继承是从属关系,应该根据实际情况判断.

初始化块

初始化块是在类加载/对象创建时执行的代码块.它比构造器更早执行.形式如下:

[修饰符] {
    statement;
}

其中修饰符只能是static或省略,statement可以是一切可执行语句.

普通初始化块

省略修饰符的初始化块叫做普通初始化块,它只能对实例变量/方法进行操作.普通初始化块、声明实例变量时指定默认值都可以看作是对实例变量的初始化过程,它们的执行顺序和Java源程序中的位置相关:谁在前面先执行谁.多个初始化块的执行顺序也是一样,但无论如何初始化块都在构造器之前执行.如:

public class Test {
    {
        a = 6;
    }
    int a = 9;
    public static void main(String[] args) {
        System.out.println(new Test().a);			//输出9;
    }
}

看起来似乎不可思议:初始化块在没有定义a的时候就对a进行操作了.但实则不然,Java创建对象时,先分配内存,再进行初始化.也就是说执行初始化块之前,已经有了实例变量a的空间,因此可以这么写.

虽然Java允许写多个普通初始化块,但是为了程序的可读性,尽量把普通初始化块写成一个.

静态初始化块

static修饰的初始化块叫做静态初始化块,又叫类初始化块.执行顺序和普通初始化块没有区别,区别是静态初始化块是对类变量/方法进行操作的而已.

public class Test {
    static {
        a = 6;
    }
    static int a = 9;
    public static void main(String[] args) {
        System.out.println(Test.a);			//输出9;
    }
}

同样建议把静态初始化块写成一个.

初始化块的本质

实际上,初始化块是对构造器的补充.

由于初始化块没有名称不接收参数,因此外界无法调用,只能通过类加载/对象创建时系统隐式调用.因此我们可以发现初始化块的用法:如果有一段初始化代码对本类所有对象的处理完全相同,且无需任何参数,那么就可以提取到普通初始化块中.而静态初始化块,建议使用它对所有类变量赋初值.

本质:实际上编译后,所以初始化块的代码就会被嵌入每一个构造器的所有代码之前,因此能保证它们在构造器之前执行,且静态初始化块在类加载时就执行,比普通初始化块更早.如下例:

class Root {
    static {
        System.out.println("Root's static initBlock.");
    }
    {
        System.out.println("Root's initBlock.");
    }
    Root() {
        System.out.println("Root's constructor.");
    }
}

class Mid extends Root {
    static {
        System.out.println("Mid's static initBlock.");
    }
    {
        System.out.println("Mid's initBlock.");
    }
    Mid() {
        super();
        System.out.println("Mid's constructor.");
    }
}

class Leaf extends Mid {
    static {
        System.out.println("Leaf's static initBlock.");
    }
    {
        System.out.println("Leaf's initBlock.");
    }
    Leaf() {
        super();
        System.out.println("Leaf's constructor.");
    }
}


public class InitBlockTest {
    public static void main(String[] args) {
        Leaf l1 = new Leaf();
        Leaf l2 = new Leaf();
    }
}

以上程序输出为:

Root's static initBlock.
Mid's static initBlock.
Leaf's static initBlock.
Root's initBlock.
Root's constructor.
Mid's initBlock.
Mid's constructor.
Leaf's initBlock.
Leaf's constructor.
Root's initBlock.
Root's constructor.
Mid's initBlock.
Mid's constructor.
Leaf's initBlock.
Leaf's constructor.

可以看出,虽然创建了两个Leaf变量,但Root、Mid和Leaf的静态初始化块都只执行了一次.这是因为开始虚拟机中没有这些类,加载一次以后,就一直存在虚拟机,不需要加载第二次,因次静态初始化块一般只有一次的执行机会.而普通初始化块的执行顺序和构造函数一样,是自顶向下的.

猜你喜欢

转载自blog.csdn.net/AAMahone/article/details/86689835