004_面向对象编程

面向对象编程是什么

编程的模式,是一种世界观。从机器码到汇编,从汇编到C,利用C开发出的各种高级语言,这个过程的抽象程度越来越高,越来越接近于人的思维。人的思维很复杂,面对各种复杂事物,人类本能会进行标签化,会进行细节的丢弃,制造出各种抽象概念。文字是一种是一种抽象,其本质只不过是各种符号。而人类抽象出符号来代指世间万物。任何物质的组成都是极其复杂的,其包含的信息是没有办法被穷尽的,而人类依赖文字,依赖抽象的力量理解世界。

编程世界是个小世界,你会发现它时时刻刻模仿真实的世界。语言的长相,就是语言制造者看世界的方式。语言的更迭,无非就是语言制造者对于世界的认知更近了一步。因而制造出来的语言结构更符合世界的本源,也容易让人类理解并传播。

而Java,最根本上的认知认为:这个世界是由一系列对象构成。对象,也就是我们常说的事物。任何事物本身都具备信息与行为。

例如面向过程,它对世界的思考就是:这个世界由一系列的事件构成。它只关心行为,不关心个体

从世界认知到语言设计

接下来我们来站在语言制造者的角度上,利用对世界的认知来一步一步摸索出【Java语言】。

对象与类的理解

现实世界中每一个实体都是一个对象,它是一种具体的概念。例如对我自己进行抽象,那么就会成为一个叫做【人】的对象。

  1. 【我】自带一些信息来刻画【我】的特征:名字,性别,等等
  2. 【我】自带一些行为来描述【我】的能力:吃饭,睡觉,上厕所
  3. 【我】是独一无二的,无论如何在世界上找不到第二个【我】,需要有独一无二的信息来表达这个唯一性

那么这里会出现一个问题,【人】的概念是众多类似【我】的存在的统称,那么【人】算是一个对象吗?有点像,但是又有点不对。【人】有区别于【我】,【人】相对【我】是一个更加抽象的存在,我们应该赋予其另外一个含义,想出个【对象的类别】的含义来代指【人】。缩减一下描述,使用【类】来描述【对象的类别】是个好办法。

【类】和【对象】的关系是可以这么描述:上帝制造了一个【人】,然后赋予这个【人】各种信息(生物属性信息,社会属性信息),然后就成了【我】。从这个过程上看,【类】像是一种上帝手中的模板,使用这个模板咵嚓的一下把【我】给造了出来。

好,那么我们就可以有这样的总结:

  1. 【类】是【对象】的统称
  2. 【类】分化出【对象】
  3. 【对象】有能力知道自己是什么【类】

对象与类的语法定义

上面我们描述了【对象】与【类】的关系,回归到语言设计层面,第一件要做的事情就是定义出【类】的描述语法。

回顾前面的分析,【类】是一种模板,天生描述了使用这个模板制造出来的【人】应该具备的性质。

那么我们就需要去考虑一个问题:不同的【人】,本质上什么东西是不一样的?换句话说,是什么东西的不同导致了【人】的不同。

稍加思考,回归最开始对世界的认知抽象:我们认定一个对象天生具备两种东西:【信息】与【能力】。

设计者认为:【能力】是需要诞生之后方可显现的,而【信息】是【对象】诞生之初就应该具备的。

因此,类与对象的代码上的语法定义区别,在认知上,只剩下对象诞生时候的【信息】不一样。

这么一想,我们的设计只要满足:

  1. 类的定义
  2. 使用类的定义创建对象,并且在创建对象的时候需要主动赋予诞生之初的【信息】

类的定义与初始化实例

设计者稍加思索,决定使用【人】的定义尝试一下:

类型-人
	信息:
		名字:*未知*
		性别:*未知*
	能力:
	  吃饭
	  睡觉
	  上厕所

上面这种距离实际上是有点像C++的对象的语法的

那么再制造个语法来表达【创建对象】

// 使用 类型-人 创建一个 人的对象,标明是我,并且在创建实例的时候赋予初始时候的信息
use 人 -> 人-我[张三,男的]
// 我可以干各种事情
我#吃饭
我#睡觉
我#上厕所

设计者想来想去,怎么让代码看起来更加自然,并且不违背上面所推理得到的结论。

// ClassName是类的名字,{}内是类的定义
class ClassName{
    // 目前类的定义分为两个部分,一个是信息体
		fieldType fieldName;
    // 一个是能力,即方法,方法定义为: 返回值 方法名(参数列表){ 行为 }
    // 如果不需要返回东西,则写void即可
		returnType functionName(ParameterType1 parameter1, ParameterType1 parameter2){
		    do something;
		}
}

然后对应的初始化语法为:

// new关键字表达,现在要新制造一个类型为ClassName的实例
ClassName v = new ClassName();

我们举一个实际的例子:

class Human{
  String sex;
  String age;
  void eat(){
    System.out.println("吃饭");
  }
  void sleep(){
    System.out.println("睡觉");
  }
}

好,有了类的定义之后,我们就可以制造出实例,并且操作实例帮我们干很多事情,回到HelloWorld的写法,我们写一个main函数,告诉系统代码从这个地方跑。main函数是一个能力,因此也需要挂在某个类里:

class Human{
    String sex;
    String age;
    void eat(){
        System.out.println("吃饭");
    }
    void sleep(){
        System.out.println("睡觉");
    }
}

class Main{
    public static void main(String[] args) {
        Human human = new Human();
        human.eat();
        human.sleep();
    }
}

实例需要外部信息初始化

前面提到,我们会在创建一个实例的时候,可以主动赋予实例一些信息,意味着类内应该具备一种能力,上帝告诉你,你的名字是什么,性别是什么,然后实例自己就可以记住这部分信息到自身。准确的说,这种行为是记忆住被动赋予的信息的能力,然后这种行为发生在创造对象的的时候——这个能力肯定应该是方法,但是呢,这个方法和一般的方法不一样,这种方法是专门用来进行初始化用的。

设计者赋予这种能力一个新的概念:构造函数

 Human human = new Human();

上面这句话,意味着我在创建一个对象,但是就是不赋予你信息,因此,实例内部的sex,age(引用类型)是null,就是未知的状态。

但是回过头来一想,我即使想给你赋予信息,也没有地儿赋予啊?所以,被动接收外部信息赋予的能力应该是类自带的。我们需要更改一下类的能力。

class Human{
    String sex;
    String age;
    Human(String sex, String age){
        // 从外部接收到信息,至于如何处理这部分信息全部由类自己来控制
        sex = sex;
        age = age;
    }
    void eat(){
        System.out.println("吃饭");
    }
    void sleep(){
        System.out.println("睡觉");
    }
}

然后我们就可以使用以下的代码进行初始化,可以把对应的初始化信息在制造实例的时候一并传给实例。然后借助类内的【构造方法】进行自身的信息赋予。

class Main{
    public static void main(String[] args) {
        Human human = new Human("男","26岁");
        human.eat();
        human.sleep();
    }
}

但是啊,仔细看看上面的构造方法内的写法,感觉有点问题:

class Human{
    String sex;
    String age;
    Human(String sex, String age){
        // 从外部接收到信息,至于如何处理这部分信息全部由类自己来控制
        sex = sex;
        age = age;
    }
}

=左边的sex到底是指代String sex;sex还是Human(String sex, String age)里的sex?

有人说,肯定指代的是类内的域啊,我就是想赋予String sex值啊,那我这里再把事情搞得迷糊一些:

class Human{
    String sex;
    String age;
    Human(String sex, String age){
        // 从外部接收到信息,至于如何处理这部分信息全部由类自己来控制
        sex = "男的";
        age = age;
        System.out.println(sex);
    }
}

是不是感觉有点乱,如果我不告诉你我的代码的意图,你看到这样的代码之后和我想要做的事情是一样的吗?

这里本质问题是因为作用域的问题。{}你把他想象成房子,那么上面的代码就有个感觉是,大房子里有个小房子,住在大房子里面有个叫张三的人,小房子里面也住着一个叫张三的人,然后我在小房子里和"张三"聊天,问,这个张三是住在大房子里面的张三还是住在小房子里面的张三。

这就涉及到设计者的设计意图与对世界的认知问题了,最终设计者认为,应该和作用域距离小的那个域产生交流。

那问题又来了,那岂不是在构造方法里面就完全不能访问当前实例的信息了嘛?当然不,我们只要指明了,说我要和当前实例的sex域交流即可。重点在,指明了,语义唯一确定了即可。表达【当前实例】,就需要引入一个新的关键字:this。上面的代码就需要改成:

class Human{
    String sex;
    String age;
    Human(String sex, String age){
        // 从外部接收到信息,至于如何处理这部分信息全部由类自己来控制
        this.sex = "男的";
        this.age = age;
        System.out.println(sex);
        System.out.println(this.sex);
    }
}

class Main{
    public static void main(String[] args) {
        Human human = new Human("女","26岁");
        human.eat();
        human.sleep();
    }
}
// 输出
女
男的

如此一来,代码的语义就唯一确定的表达开发者的意图了。

构造函数也是方法,只不过很特殊,这个特殊性简单的看就是方法名与类名完全一致。

问题又来了,假定一个类,里面可以存储信息的坑位有成百上千个,你说初始化,对于开发者而言,他就要凑齐成百上千个参数才能喂饱构造函数。但是仔细一想,这个类有必要去把所有的坑位填满吗?啥意思,就是我拿这个类可能有不同的目的,不同的目的的实例可能需要的参数不一样。看来一个构造函数是不够的。我们的语言必须具备多个构造函数的能力!

class Human{
    String sex;
    String age;
    Human(String sex, String age){
        this.sex = sex;
        this.age = age;
    }
    Human(String sex){
        this.sex = sex;
    }
}

从上面Human类的构造函数定义看,语义是很明确的,而我们可以通过new Human("",""),new Human("")两种语句来调用其构造函数。

我们看一个语法,只需要看其是不是语义明确,是不是不会产生语义上的二义性,事实上就可以被认为是一个正确的语法。而好语法则是将写法更贴近于人类的思考路线。

如此一来,你就会发现一个神奇的现象:一个类可以拥有名称一样的能力,只不过参数可以不同。说的直白一些,干得事儿还是同样的事儿,但是怎么干变了。这种特性很符合人类的行为,我们赋予它一个重要的概念:方法重载。这部分将会在后面聊到方法的时候详细描述。

实例的内部信息初始化

到这里为止,我们初步解决了类的语法定义,通过类创造实例,并赋予初始值。

赋予初始值导致两个问题的出现:

  1. 构造函数需要指引当前类的成员变量,需要区分成员变量与方法入参,因此引入this的关键字
  2. 构造函数可以存在多个,方法入参必须不同。(这里事实上需要考虑很多问题,后面慢慢铺开,简单的说就是重载的条件)

上面的逻辑是有漏洞的,那就是,实例的初始化信息为什么都需要从外部获得呢?就像人生来就会有的生物学特征,而外部传入的信息更多偏向于社会学信息。因此,我们需要让实例具备初始化能力的另一个方面——自己可以初始化出信息来。

这就需要提到我们的初始化块儿了。它使用大括号 {} 包裹需要执行的代码。

class Human{
    String sex;
    String age;
    {  
    	sex = "男的";
    	age = "18岁";
    }
    Human(String sex, String age){
        this.sex = sex;
        this.age = age;
    }
    Human(String sex){
        this.sex = sex;
    }
}

那么我们继续推演,内部信息的初始化块儿执行和外部信息初始化,哪个先?感觉上虽然内部有能力初始化自己,但是毕竟实例是生长在群体中,有环境,而环境的力量是惊人的。外部信息初始化可以覆盖内部信息初始化,当然实例自身需要具备被外部信息覆盖的能力,也就是初始化块儿的引用到的域和构造函数里面引用的域可以一样。但是执行顺序是:内部实例初始化块儿 -> 构造函数。

延伸开来,又有其行为上的考量了:

问题1:初始化块儿的数量应该有限制吗?直觉上不应该限制。

问题2:那如果定义了多个初始化块儿,放在前放在后有关系吗?按照顺序执行是满足人类的感知的,不应该乱序。

问题3:块儿1定义一个局部变量,块儿2修改块儿1的局部变量,块儿三打印这个局部变量,可行吗?

问题3,本质问题是,他们定义域是不是共享的?如果选择共享,(用到一些字节码的知识),共享变量就成了个麻烦的事儿,块儿代码就是个方法体,如果在编译的时候把块儿内代码全部合并呢?事儿好像变得更复杂,虽说是共享的状态,但是又出现了使用同一个fieldName的问题出现编译异常。设计者一拍脑袋,算了,随他去吧~不让他们共享!编译后不同的块儿就是不同的方法,整体隔离,爱咋咋地!

实例的信息体

实例的信息体指的就是一个实例的属性。属性定义挂在类定义内,这样的类创造出来的实例,意味着天生具备携带信息的能力,其内部有很多信息的"坑位"。这种想法在很多语言内出现,即是是面向过程的C(这个语言看上去很想底层CPU运行的机器码,只不过对人类而言更容易理解),里面也有叫做Struts的结构体。所谓结构体就是来描述数据结构的。有了结构体,信息就是有序的,如论理解也好,执行也好,更容易做到。

java领域的属性也是如此,我们刚开始聊的基本数据类型自然是可以成为其属性的。但是同样的引用类型同样也可以被当成其属性。

实例持有另外一个实例——很容易理解,Human有个嘴巴,嘴巴能吃饭,嘴巴也是个抽象。

接下来,我们造一个拥有所有基本属性,并拥有其他实例的类:

class Human{
    boolean bo;
		byte b;
		short s;
		char c;
		int i;
		long l;
		float f;
		double d;
		Dog dog;
}

上文中的Human实例持有一个Dog,是一个引用类型。如果初始化的时候没有赋予其值的话,自然,实例内的dog的值是null(未知)。

基础数据类型不能指向null,因此必须无论如何要给于一个值,那就赋予默认值(这部分在基础数据类型有过详细描述)。

面对上面这个多属性,有个疑问就出现在心上。虽然我们有可以重载的构造函数进行初始化,然后有初始化块儿在实例内部进行初始化。但是如果我想全部初始化其属性,构造函数又不希望方法入参太多。那就只能使用初始化块儿进行初始化。这也太累了,有没有更简便,更直接的方式帮助开发者进行初始化呢?

设计者一拍脑袋,要不然直接把初始化的语句放在属性上不就好了吗?

boolean bo;
// 变成在初始化的时候自动进行属性的初始化赋值
boolean bo = false;

如此一来,看着就很舒服,人类很容易理解这个含义。

好嘛,我们但凡做了点事情,就会产生出一些新的问题——初始化顺序问题。现在看上去我们有三种手段进行初始化了:

  1. 构造函数
  2. 初始化块儿
  3. 属性赋值的初始化语句

上面我们描述过内部实例初始化块儿 -> 构造函数,这里还需要插入属性赋值的初始化语句。这个又要回退到我们针对初始化块儿赋予的意义了,这个东西是与生俱来诞生那一刻进行执行,字段信息的初始化相当于是为了后面的逻辑而作准备,这样看初始化块儿的优先级很高。字段初始化如果在构造函数之后,可能会覆盖外部信息的注入。这个行为是不对的,外部力量可以决定一切。

总结下来就是这样的顺序:内部实例初始化块儿 -> 字段初始化 -> 构造函数

实例的行为

我们之前说过,设计者对世界的抽象是类与实例,实例具备存储信息的能力,并且具备行为。而通用性做法,行为即是方法。

Human human = new Human();
human.eat();

语法上使用 点号.进行行为的引用,实例名.方法名的方式进行调用行为的动作。我们先来看看单个方法的一些概念。

一个方法的语法结构可以是:

returnType functionName(ParameterType1 parameterName1,ParameterType2 parameterName2){
	方法体
}

returnType

returnType是只这个方法执行之后的反馈是什么,方法定义是存储在类上的,因此只会表达实例调用这个方法之后我会给你个什么东西,肯定不是说具体给你个什么东西(实例),因此只需要一个类类型(returnType)来占个坑位即可。

如果你这个方法的确没有什么需要对外反馈的,你就写成void即可。(Void是void的包装类,这个也是ok的,但是一般都是使用void)

本质上字节码领域都是会执行一个返回指令,只不过语言层面可以省略

构造方法也是一个方法,这个方法就不需要返回类型,也不需要明面上写着return语句返回给调用方。因为这个方法地位比较特殊,当我们使用new的关键字进行初始化的时候,将实例诞生出来赋予变量引用这个过程是虚拟机搞定的,不需要明确的使用return语句。除此之外构造函数的返回类型必须是当前的类,不然就没啥意义了,因此这部分也可以省略。舒服!

functionName

方法名,规矩也是有的,包括你不能占用人家构造函数方法名,不能用关键字,不能数字打头,反正和我们制造名称的规矩是差不多的。

忘记他们这些复杂的小规矩吧,我们写的是高级语言,高级语言是给人看的。

我们的目标是方法名一看,人家就知道你这个是干嘛的。为了这个目标,我们只遵从前辈们觉得好看的方式:小写打头,多个单词则变为驼峰状,例如EAT FOOD -> eatFood

参数列表

方法名表达干什么,不同的参数表达怎么干。相同的方法名具有不同的参数我们称为重载。这是一个重要的概念。刚刚的描述事实上不精确,什么叫做不同的参数呢?换言之,被认定为重载应该的条件应该是怎么样的?我们列下这样的方法尝试一下,这里所有的入参只有基本数据类型。

class Human{
    void handle(boolean a){System.out.println("#boolean:"+a);}
    void handle(byte a){System.out.println("#byte:"+a);}
    void handle(short a){System.out.println("#short:"+a);}
    void handle(char a){System.out.println("#char:"+a);}
    void handle(int a){System.out.println("#int:"+a);}
    void handle(long a){System.out.println("#long:"+a);}
    void handle(float a){System.out.println("#float:"+a);}
    void handle(double a){System.out.println("#double:"+a);}

    public static void main(String[] args) {
        Human human = new Human();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
    }
}

获得的打印为:

#boolean:true
#short:1
#byte:1
#char:c
#char:c
#int:1
#long:1
#float:1.1
#double:1.1

从结果上证明在main方法中的确找到对应的方法进行处理了。

但是问题来了,入参是可以有引用类型的,这里提前再介绍一下,任何一个基本类型都有包装类型,后面会详细描述。

包装类型就是引用形式的基本数据类型。如果使用引用类型那么事儿又会变得复杂很多。

class Human2 {
    void handle(boolean a){System.out.println("#boolean:"+a);}
    void handle(byte a){System.out.println("#byte:"+a);}
    void handle(short a){System.out.println("#short:"+a);}
    void handle(char a){System.out.println("#char:"+a);}
    void handle(int a){System.out.println("#int:"+a);}
    void handle(long a){System.out.println("#long:"+a);}
    void handle(float a){System.out.println("#float:"+a);}
    void handle(double a){System.out.println("#double:"+a);}
    void handle(Boolean a){System.out.println("#Boolean:"+a);}
    void handle(Byte a){System.out.println("#Byte:"+a);}
    void handle(Short a){System.out.println("#Short:"+a);}
    void handle(Character a){System.out.println("#Character:"+a);}
    void handle(Integer a){System.out.println("#Integer:"+a);}
    void handle(Long a){System.out.println("#Long:"+a);}
    void handle(Float a){System.out.println("#Float:"+a);}
    void handle(Double a){System.out.println("#Double:"+a);}

    public static void main(String[] args) {
        Human2 human = new Human2();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
    }
}
#boolean:true
#short:1
#byte:1
#char:c
#char:c
#int:1
#long:1
#float:1.1
#double:1.1

打印出来还是使用的基本参数类型进行处理。如果我们把基本参数类型的方法拿掉:

class Human3 {
//    void handle(boolean a){System.out.println("#boolean:"+a);}
//    void handle(byte a){System.out.println("#byte:"+a);}
//    void handle(short a){System.out.println("#short:"+a);}
//    void handle(char a){System.out.println("#char:"+a);}
//    void handle(int a){System.out.println("#int:"+a);}
//    void handle(long a){System.out.println("#long:"+a);}
//    void handle(float a){System.out.println("#float:"+a);}
//    void handle(double a){System.out.println("#double:"+a);}
    void handle(Boolean a){System.out.println("#Boolean:"+a);}
    void handle(Byte a){System.out.println("#Byte:"+a);}
    void handle(Short a){System.out.println("#Short:"+a);}
    void handle(Character a){System.out.println("#Character:"+a);}
    void handle(Integer a){System.out.println("#Integer:"+a);}
    void handle(Long a){System.out.println("#Long:"+a);}
    void handle(Float a){System.out.println("#Float:"+a);}
    void handle(Double a){System.out.println("#Double:"+a);}

    public static void main(String[] args) {
        Human3 human = new Human3();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
    }
}

获得到的结果是:

#Boolean:true
#Short:1
#Byte:1
#Character:c
#Character:c
#Integer:1
#Long:1
#Float:1.1
#Double:1.1

看来每种基本类型可以直接找到对应的包装类型进行调用。

正常理解上应该找不到才对,事实上,背地里java干了自动包装的事情,就是一看到你方法入参(基本参数类型)和真实放入的数据是对应的包装类的关系的时候,自动帮你做下转换。

那如果调用既有基本类型与包装类型怎么办?

class Human4 {
    void handle(boolean a){System.out.println("#boolean:"+a);}
    void handle(byte a){System.out.println("#byte:"+a);}
    void handle(short a){System.out.println("#short:"+a);}
    void handle(char a){System.out.println("#char:"+a);}
    void handle(int a){System.out.println("#int:"+a);}
    void handle(long a){System.out.println("#long:"+a);}
    void handle(float a){System.out.println("#float:"+a);}
    void handle(double a){System.out.println("#double:"+a);}
    void handle(Boolean a){System.out.println("#Boolean:"+a);}
    void handle(Byte a){System.out.println("#Byte:"+a);}
    void handle(Short a){System.out.println("#Short:"+a);}
    void handle(Character a){System.out.println("#Character:"+a);}
    void handle(Integer a){System.out.println("#Integer:"+a);}
    void handle(Long a){System.out.println("#Long:"+a);}
    void handle(Float a){System.out.println("#Float:"+a);}
    void handle(Double a){System.out.println("#Double:"+a);}

    public static void main(String[] args) {
        Human4 human = new Human4();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
        human.handle(Boolean.valueOf(true));
        human.handle(Short.valueOf((short) 1));
        human.handle(Byte.valueOf((byte) 1));
        human.handle(Character.valueOf('c'));
        human.handle(Character.valueOf((char)99));
        human.handle(Integer.valueOf(1));
        human.handle(Long.valueOf(1L));
        human.handle(Float.valueOf(1.1F));
        human.handle(Double.valueOf(1.1D));
    }
}

Type.valueOf可以将基本数据类型转换为包装类

获得到的结果是:

#boolean:true
#short:1
#byte:1
#char:c
#char:c
#int:1
#long:1
#float:1.1
#double:1.1
#Boolean:true
#Short:1
#Byte:1
#Character:c
#Character:c
#Integer:1
#Long:1
#Float:1.1
#Double:1.1

看来也能够精确找到对应的

但是这里还有问题:我们说方法参数列表放引用类型的类型,但是每种引用类型事实上都有父类的概念(后面会说,你只需要知道,任何实例事实上都是一个Object),因此方法可以写成:

class Human5 {
    void handle(Object a){System.out.println("#object:"+a);}

    public static void main(String[] args) {
        Human5 human = new Human5();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
        human.handle(Boolean.valueOf(true));
        human.handle(Short.valueOf((short) 1));
        human.handle(Byte.valueOf((byte) 1));
        human.handle(Character.valueOf('c'));
        human.handle(Character.valueOf((char)99));
        human.handle(Integer.valueOf(1));
        human.handle(Long.valueOf(1L));
        human.handle(Float.valueOf(1.1F));
        human.handle(Double.valueOf(1.1D));
    }
}

调用之后的结果是:

#object:true
#object:1
#object:1
#object:c
#object:c
#object:1
#object:1
#object:1.1
#object:1.1
#object:true
#object:1
#object:1
#object:c
#object:c
#object:1
#object:1
#object:1.1
#object:1.1

你会发现,所有的调用都去找void handle(Object a)了。

如果void handle(Object a)和其他方法放在一起呢?

class Human6 {
    void handle(boolean a){System.out.println("#boolean:"+a);}
    void handle(byte a){System.out.println("#byte:"+a);}
    void handle(short a){System.out.println("#short:"+a);}
    void handle(char a){System.out.println("#char:"+a);}
    void handle(int a){System.out.println("#int:"+a);}
    void handle(long a){System.out.println("#long:"+a);}
    void handle(float a){System.out.println("#float:"+a);}
    void handle(double a){System.out.println("#double:"+a);}
    void handle(Boolean a){System.out.println("#Boolean:"+a);}
    void handle(Byte a){System.out.println("#Byte:"+a);}
    void handle(Short a){System.out.println("#Short:"+a);}
    void handle(Character a){System.out.println("#Character:"+a);}
    void handle(Integer a){System.out.println("#Integer:"+a);}
    void handle(Long a){System.out.println("#Long:"+a);}
    void handle(Float a){System.out.println("#Float:"+a);}
    void handle(Double a){System.out.println("#Double:"+a);}
    void handle(Object a){System.out.println("#object:"+a);}

    public static void main(String[] args) {
        Human6 human = new Human6();
        human.handle(true);
        human.handle((short) 1);
        human.handle((byte) 1);
        human.handle('c');
        human.handle((char)99);
        human.handle(1);
        human.handle(1L);
        human.handle(1.1F);
        human.handle(1.1D);
        human.handle(Boolean.valueOf(true));
        human.handle(Short.valueOf((short) 1));
        human.handle(Byte.valueOf((byte) 1));
        human.handle(Character.valueOf('c'));
        human.handle(Character.valueOf((char)99));
        human.handle(Integer.valueOf(1));
        human.handle(Long.valueOf(1L));
        human.handle(Float.valueOf(1.1F));
        human.handle(Double.valueOf(1.1D));
    }
}

获得到的打印是:

#boolean:true
#short:1
#byte:1
#char:c
#char:c
#int:1
#long:1
#float:1.1
#double:1.1
#Boolean:true
#Short:1
#Byte:1
#Character:c
#Character:c
#Integer:1
#Long:1
#Float:1.1
#Double:1.1

没有调用会去找void handle(Object a)进行调用。

看来我们可以得到一个阶段性的总结了:

本质上就是依靠入参的类型,来找到对应的方法,相当于是一个匹配的过程,说到匹配,原则上肯定是越接近越好,如果没有完全匹配的话就找相近的。匹配程度可以这样描述  参数匹配顺序: 完全对等的方法入参 -> 找到对应的包装类 -> 找到Object(入参的父类类型)

总之,就一句话:找和入参最为贴切的方法。

你以为完了吗,没有,在java发展过程中,还引入了一些神奇的东西:可变参数列表

class Human7 {
    void handle(int... d){
        for (int dd : d){
            System.out.println(dd);
        }
    }

    public static void main(String[] args) {
        Human7 human = new Human7();
        human.handle(1,2,3,4);
    }
}

... 这个符号就表达了参数是可变的,在调用的时候你可以随意放入多少个参数。但是呢,这里会有语法的限制:…这个只能出现在方法列表的最后,最后的坑位只有一个,所以一个方法只能出现一个可变参数的入参。

在方法体内获得到的可变参数事实上就是个数组,java在执行过程中,将参数转换为数组传入。

好嘛,这么一来,又会碰上重载的问题了。

class Human8 {
    void handle(int d){
        System.out.println("handle(int d)");
    }
    void handle(int a ,int b){
        System.out.println("handle(int a ,int b)");
    }
    void handle(int... d){
        System.out.println("int... d");
    }

    public static void main(String[] args) {
        Human8 human = new Human8();
        human.handle(1);
        human.handle(1,2);
        human.handle(1,2,3);
    }
}

打印出

handle(int d)
handle(int a ,int b)
int... d

看来可变参数列表被识别的能力最低,优先级最低。这个垃圾。

实例需要隐藏信息

到此,我们已经详细描述了一个类的简单组成。我们继续从现实生活中获得灵感,让我们的代码层面的类的抽象更接近日常生活中所遇到的概念。我们抽象的目的是为了理解方便,意味着将一个复杂事物封装为一个概念或者是一个类,一个实例的时候,对于使用者不需要知道那么多信息,这当然是有好处的。看来我们的面对对象编程也应该具备这样的特性。有隐藏,就有暴露,在面向对象程式设计方法中,封装(英语:Encapsulation)的概念就自然而然地出现了。

被封装的东西是对象的信息和能力,很好理解,有些信息实例自己就知道,有些能力实例自己内部玩儿,不给别人调用。

然后呢,设计者就再引入关键字,public 和 private 从英文上也很容易理解,一个是公开,一个是私有,可以来描述字段或者方法。

class Human{
    public String sex;
    private String age;
    public void eat(){
    	System.out.println("我要吃饭");
      goWc();
    }
    private void goWc(){
    	System.out.println("吃完饭就上个厕所");
    }
    Human(String sex){
        this.sex = sex;
    }
    public static void main(String[] args){
    	Human human = new Human();
    	human.eat();
    	System.out.println(human.sex);
    }
}

从上面就可以清楚地感觉到公开和私有的好处,我可以去吃个饭,但是上个厕所我自己会上,你没必要让我上。

但是呢,这样还是有问题,这个人的信息怎么获取呢?我们可以使用这样的语句human.sex获得实例的信息。但是总觉得有点怪怪的,我虽然使用的public和private来限制你得到信息,但是事实上这里有两种行为:获得信息和修改信息。如果我暴露出来域,意味着任何人都能去修改我的信息。这可不行,还要想个招。难道要再添加一个关键字来约束获得信息和修改信息吗?

可以就这么办,引入final关键字,放在字段上,让它但凡有了初始值之后就不再改变,限制赋值的动作,只允许赋值一次。

final的字段,必须在定义的时候赋予初始值,不然就报错,牛逼吧,而且一旦这么定义了之后,你就不能改它的值(引用值)

class Human{
    public final String sex = "女的";
    private String age;
    public void eat(){
    	System.out.println("我要吃饭");
      goWc();
    }
    private void goWc(){
    	System.out.println("吃完饭就上个厕所");
    }
    public static void main(String[] args){
    	Human human = new Human();
    	human.eat();
    	System.out.println(human.sex);
      // 无法访问
      human.sex = "男的"
    }
}

上面引入final机制,把赋值的能力给阻断了,但是回过头一想,感觉又太细了,实例要控制的事儿太多了,很累。设计者一想,要么这样,final呢也给你保留着,你想用就用,我技术上我就不限制你了,但是我们强化封装的概念——所有信息都直接私有化,然后所有需要暴露信息的能力委托给方法不就可以了吗?这样看上去也好看呢不是。

class Human{
    private String sex;
    private String age;
    public void eat(){
    	System.out.println("我要吃饭");
      goWc();
    }
    private void goWc(){
    	System.out.println("吃完饭就上个厕所");
    }
    Human(String sex){
        this.sex = sex;
    }
    public static void main(String[] args){
    	Human human = new Human();
    	human.eat();
    	System.out.println(human.sex);
    }
    public String getSex(){
    	return sex;
    }
    public String getAge(){
    	return age;
    }
    public void setSex(String sex){
      this.sex = sex;
    }
    public void setAge(String age){
      this.age = age;
    }
}

我们把所有的字段私有化,读取和修改都使用方法进行操作。虽然加了很多的代码,代码也很简单,但是这就是规范啊,看上去就好看。

如果弱化我们的类,变成一个只存储信息的类,那么我们就称之为Bean,Bean在规范上只有很多的get/set方法暴露给别人。

类的静态信息

实例具备信息和能力,类是创造实例的根本性存在。我们还是举个之前的例子,Human类与人的关系,我们经常描述这个人天生怎么样怎么样,这是实例的表现,但是描述"这个人骨子里有一份热血","此人天性如此"之类的描述是在描述实例吗?每个人骨子里都可能有份热血,只不过没有在某个时机表现出来,每个人都有天性,这份天性是受到生理性支配的,也是每个个体都具备的一个性质。

设计的语言为了抄大自然抄的更加自然,将上面的"每一个个体都具备的性质"抽象为"静态信息的概念",挂在类上正合适,在实例初始化的时候自动携带,这不就很舒服了嘛?这也同样符合人的认知,我们说的类和对象,那你说类算不算广义上的对象?我觉得还是蛮合适的,既然类也算是广义上的对象,那么自然按照之前对于实例的定义,我们赋予类上的静态信息包含两部分:值域(字段),方法。

为了将类的静态字段,静态方法和实例的实例字段和实例方法进行区别,设计者使用static来修饰他们的定义。

class Human{
    // 人类的极限寿命受到大自然的限制,每个实例都逃脱不了大自然的控制
    public static String ageLimit = "100岁";
    private String sex;
    private String age;
    public void eat(){
    	System.out.println("我要吃饭");
      goWc();
    }
  	// 知天命,知晓大自然赋予的极限寿命
    public void fetchAgeLimit(){
    	System.out.println(Human.ageLimit);
    }
    private void goWc(){
    	System.out.println("吃完饭就上个厕所");
    }
    // 刻在骨子里的人性:自私,是全人类共有的属性,打实例诞生就会伴随
    public static boolean isSelfish(){
    	return true;
    }
    public static void main(String[] args){
    	Human human = new Human();
    	human.eat();
    	Human.isSelfish();
    }
}

从上面我们可以看到,ageLimit被赋予了static ,意味着这个值是Human类本身自带的,在所有的实例内携带这样的信息。那么实例肯定可以访问这种信息,fetchAgeLimit方法就展示了这个能力。

那么反过来,静态方法能不能知道实例的域信息呢?比方说我在上面的代码中再放上一个方法:

// 获得当前人类的性别属性
public static String humanSex(){
	this.sex;
}

编译不通过,是语法限制你吗?我们需要拔高我们的想法,所有的语法都是为了逻辑的完整性。只要逻辑上是对的,语法上就不应该给你限制,不然就是设计者的脑子有问题。

反观我们之前的定义,类与实例的关系事实上是 1对多的关系,多个实例持有了一份类的静态信息,共享,逻辑上说的通。但是类上的静态方法想去获取实例信息,意味着一对多的关系必须要有个明确的指向,不然类方法怎么知道要去哪个实例上获得信息呢?这个逻辑上说不过去。

类的静态方法不能访问类实例信息,类的实例方法可以访问类的静态信息

同样的,我们去获得类上的信息,操作类的方法干活的方式也变了,事儿是挂在类上的,行为的发起者是类,不是实例,所以会变成这样的语法:

String ageLimit = Human.ageLimit; // 获得类的静态字段数据
boolean isSelfish =  Human.isSelfish();// 调用类的静态方法

很直观,很好理解。

我们还有个问题没有解决,实例创造是需要进行初始化的,广义上来说类的出现也算是一个对象被创造到这个世界上来了(JVM来发起这个创造的过程),那么类需要进行初始化吗?应该是需要的,但是这种初始化有区别与类实例的初始化,初始化的发起者是世界本身(JVM),看来没有类的构造函数的说法了,设计者一拍脑袋,初始化块儿不就很好的满足我们的诉求吗?刚好我们也有static的关键字:

class Human10 {

    static {
        aa = "吃饭";
    }
    public static String aa;
    public final String sex = "女的";

    private String age;

    public void eat(){
        System.out.println("我要吃饭");
        goWc();
    }
    private void goWc(){
        System.out.println("吃完饭就上个厕所");
    }
    public static void main(String[] args){
        Human10 human = new Human10();
        System.out.println(Human10.aa);
    }
}

这样一来,我们就可以获得到aa的数据是吃饭,同样的,static的块儿数量没有限制,块儿内部的局部变量不共享。

static初始化块儿和final之间有点问题,如果上面的aa是final的类型,我的aa还需要先进行字段初始化操作吗?参考实例初始化块儿和字段初始化的推演过程,放入静态的初始化部分,整体的初始化流程应该是 静态初始化块儿 -> 字符初始化 -> 实例初始化块儿 -> 字段初始化 -> 构造器

class Test {
    static {
        System.out.println("Test.static{}");
    }
    {
        System.out.println("Test.{}");
    }
    private static Test1 staticTest1 = new Test1("staticTest1");
    private Test1 test1 = new Test1("Test1");
    public Test(){
        System.out.println("Test()");
    }

    public static void main(String[] args) {
        Test test = new Test();
    }
}
class Test1{
    public Test1(String str){
        System.out.println("test1("+str+")");
    }
}

获得到结果是:

Test.static{}
test1(staticTest1)
Test.{}
test1(Test1)
Test()

如此即可证明上面的结论。

我们之前说过final字段无论如何在定义的时候就需要赋予点什么,这个时候你会发现,字段初始化前可以有一个初始化块儿存在,那是不是可以把赋值的部分放在初始化块儿?逻辑可行,那么我们就要支持!

class Human10 {

    public final static String aa;

    static {
        aa = "cc";
        System.out.println(aa);
    }
    
    public final String sex = "女的";

    private String age;

    public void eat(){
        System.out.println("我要吃饭");
        goWc();
    }
    private void goWc(){
        System.out.println("吃完饭就上个厕所");
    }
    public static void main(String[] args){
        Human10 human = new Human10();
        human.eat();
        System.out.println(human.sex);
        //human.sex = "男的";
    }
}

static 块儿必须放在public static String aa;之后,感觉上太不合理了,面向对象的语法上竟然出现了需要向前引用的感觉,这是C语言的特征,就跟动画片主角放大招要先叫一声,要使用要先声明。

多个类的共同协作思路

有人的地方就有江湖,一个人我们称为生物体,几个人我们称为群体,再多就是组织,再多就是国家,再多就是社会了。人一多,就会产生交易,就会产生对货币的诉求,产生对金融的诉求,就会产生一系列经济学现象。人一多,就会产生管理需求,政治专注国家力量治理,法律约束上到政府下到个体的行为。总之,人一多,事儿就变得复杂的多得多。

我们写的代码也是一个小世界,到处都在模拟真实现实的方方面面。

随着社会的诞生,组织分工协作更为频繁,生产效率大大提高,组织分工,就像是我们的设计模式,抽象出了个体与个体的协作方式。

上面我们已经抽象出了个体所应该具备的一些能力,但是毕竟不可能就一个人干活干到底,我们的类与类之间是需要协作的。

这部分我们就感知一下,协作关系应该都有哪些。

前辈们苦思冥想,想出来这样几种关系:

  1. 泛化
    表达继承关系,例如爸爸和儿子的关系
  2. 实现
    表示的是一个类和接口之间的关系,表达行为和规矩的关系
  3. 关联
    一个类A包含着另外一个或多个实体类B,例如客户和订单
  4. 聚合
    整体与部分的关系,例如教室和老师的关系,局部可以脱离主体
  5. 组合
    整体与局部的关系,例如轮子和车子的关系,局部脱离没什么用
  6. 依赖
    一种使用关系,一个类的实现需要另外的类参与,A类的变化引起了B类的变化,则说名B类依赖于A类。例如司机和车子。

他们的关系紧密程度是:泛化 = 实现 > 组合 > 聚合 > 关联 > 依赖

这事实上是UML的基础知识,这里只是简单介绍一下,详细我们移步到设计模式模块做详细解释。

前辈们整理出这样的关系之后,回顾代码语言,发现,关联,聚合,组合,依赖,都是需要逻辑引入才能清楚地表达的,单从语言角度是没有办法理清的。那么前辈们的思考就简单了,只要解决在语言上明白地表达泛化,实现关系即可。这不需要逻辑参与,只看语言就能清晰辨别。

父子协作关系

类与类的泛化关系

泛化是java面向对象编程技术的一块基石,因为它允许创建分等级层次的类。我们这一代人,生存环境还是很恶劣的,有些人诞生之初就有着别人没有的东西,金钱,地位,权利。虽说需要后天成长奋斗才能获得到你想获得的各种东西,但是你的终点可能只是别人的起点。

因为,富家子弟从生存环境中继承到太多优质的资源了。赤裸裸的不公平,但是却无可奈何。但是与其羡慕别人是富二代,为什么不让自己成为富一代,让自己的后代成为富二代呢?

回到语言设计的角度理解泛化,泛化(继承)就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。

假定现在有个有个程序员,叫张三,这是个人,所以天生应该具备人的属性和行为,代码层面我们就可以这么描述:

class Human11{
    private String age;
    public void eat(){
        System.out.println("我要吃饭");
        goWc();
    }
    private void goWc(){
        System.out.println("吃完饭就上个厕所");
    }
}

class Coder extends Human11{
    public void wirteCode(){
        System.out.println("愉快地写代码");
    }
    public static void main(String[] args){
        Coder coder = new Coder();
        coder.eat();// human的行为
        coder.wirteCode();// 张三的行为
    }
}

可以看到,我们从coder张三上可以进行human的行为,这是理所应当的嘛,张三是个人,自然会吃东西。

但是,但凡引入了一个新的概念之后,前面所有的概念都要全部领出来碰撞一下,是不是冲击了世界观。

现在我们对于继承的理解,只限制与概念上,就一点:子类继承了父类的特征和行为。我们一点一点来看这个定义。

泛化关系下的初始化问题

首先就是初始化过程,多了个老爹,事儿就变得不一样了。你子类的诞生,子类你要初始化,就得先有老爹吧?老爹从面向对象的角度出发,也是一个活生生的对象啊,所以老爹也要经历。老爹的初始化过程结束之后才能轮到子类。但是还有类的静态初始化部分,我们从之前的推导上来看,静态信息的初始化必然优先于实例初始化,甚至不管是不是子类。因此初始化顺序就变成,父类的静态初始化 -> 子类的静态初始化 -> 父类的实例初始化 -> 子类的实例初始化。初始化又分为字段初始化,构造器初始化。这部分的初始化步骤也需要镶嵌在整个初始化链条上。你看,随意引入的一个概念就导致的问题极具复杂化!

class Human12{
    static{
        System.out.println("Human12.static{}");
    }

    {
        System.out.println("Human12.{}");
    }

    private static Human12Field1 human12Field1 = new Human12Field1();
    private Human12Field2 human12Field2 = new Human12Field2();

    public Human12(){
        System.out.println("Human12.()");
    }

    private String age;
    public void eat(){
        System.out.println("我要吃饭");
        goWc();
    }
    private void goWc(){
        System.out.println("吃完饭就上个厕所");
    }
}

class Coder2 extends Human12{

    static{
        System.out.println("Coder2.static{}");
    }

    {
        System.out.println("Coder2.{}");
    }

    private static Coder2Field1 coder2Field1 = new Coder2Field1();
    private Coder2Field2 coder2Field2 = new Coder2Field2();


    public Coder2(){
        System.out.println("Coder2.()");
    }

    public void wirteCode(){
        System.out.println("愉快地写代码");
    }
    public static void main(String[] args){
        Coder2 coder = new Coder2();
        coder.eat();// human的行为
        coder.wirteCode();// 张三的行为
    }
}

class Human12Field1{
    public Human12Field1(){
        System.out.println("Human12Field1.()");
    }
}
class Human12Field2{
    public Human12Field2(){
        System.out.println("Human12Field2.()");
    }
}


class Coder2Field1{
    public Coder2Field1(){
        System.out.println("Coder2Field1.()");
    }
}
class Coder2Field2{
    public Coder2Field2(){
        System.out.println("Coder2Field2.()");
    }
}

获得到的打印是:

Human12.static{}
Human12Field1.()
Coder2.static{}
Coder2Field1.()
Human12.{}
Human12Field2.()
Human12.()
Coder2.{}
Coder2Field2.()
Coder2.()
我要吃饭
吃完饭就上个厕所
愉快地写代码

如此就可以证明我们的结论了。

子类继承行为与信息

上面我们描述了父子类初始化的过程,初始化完毕之后,子类逻辑上就可以访问父类的行为与信息了。

class Father{
    
    public int money = 100;

    public void fishing(){
        System.out.println("钓鱼");
    }
}

class Son extends Father{

    public void writeCode(){
        System.out.println("写代码");
    }
    public static void main(String[] args) {
        Son son = new Son();
      	Son.money; // 得到父亲的钱
        son.fishing(); // 得到父亲的钓鱼能力
        son.writeCode(); // 子类自己可以写代码
    }
}

使用访问权限控制继承行为

你会看到子类会写代码,也会钓鱼。但是这个时候发现,老爸的私房钱拿不到呢?看来需要明确一下private的语义,private指的是完全的封闭,子类完全没有办法获得到这样的信息。如果使用public呢?那也不对,私房钱随便被人拿了。看来我们需要一种东西来表达,这个东西不光是父类的,也可以传递到子类上去。那么设计者就制造出来了一个关键字,protected,来表达只有父亲自己或者儿子才能获得到这部分信息。

class Father2{

    protected int money = 100;

    public void fishing(){
        System.out.println("钓鱼");
    }

}
class Son2 extends Father2{

    public void writeCode(){
        System.out.println("写代码");
    }

    public static void main(String[] args) {
        Son2 son = new Son2();
        son.fishing();
        son.writeCode();
        System.out.println("获得到私房钱:"+son.money);
    }
}

son直接使用句点获得从父亲那里获得到的信息。那如果Son类内有和父亲类同一个名称的属性呢?

class Father3{

    protected int money = 100;

    public void fishing(){
        System.out.println("钓鱼");
    }

}

class Son3 extends Father3{

    private int money = 50;

    public void writeCode(){
        System.out.println("写代码");
    }

    public void getMoney(){
        System.out.println(money);
    }


    public static void main(String[] args) {
        Son3 son = new Son3();
        son.fishing();
        son.writeCode();
        son.getMoney();
    }
}

打印出50,看来是取到了子类的money。但是子类我就是想获得到父亲的money呢?没办法,继续加关键字 super 来区分子类和父类。

就像是this,来区分方法体内局部变量与当前类实例的属性

class Father4{

    protected int money = 100;

    public void fishing(){
        System.out.println("钓鱼");
    }

}

class Son4 extends Father4{

    private int money = 50;

    public void writeCode(){
        System.out.println("写代码");
    }

    public void getMoney(){
        System.out.println(super.money);
    }


    public static void main(String[] args) {
        Son4 son = new Son4();
        son.fishing();
        son.writeCode();
        son.getMoney();
    }
}

打印100,看来是正确的获得到父亲的money值了。

这里有个事儿我们没有证明,不是子类的类是不是能够操作father类获得到Money? 逻辑上肯定不应该了,但是现实是有些情况下的确可以访问。这个归结到本质上,是设计者认为,拥有父子关系的访问权限之外,还有类似于家庭的概念。啥意思呢,关系密切的类相对都集中,意味着,把他们视为一个家庭。一个家庭嘛,名义上只给儿子用的信息或者方法,没道理不给家庭里的人看。但是也有问题,你既然引入了家庭的概念,是不是意味着,某些方法脱离家庭的范畴是不是也应该对家人屏蔽?同样的有没有一种可能的信息,只对家人开放,而不管是不是儿子?

捋一捋,捋一捋,现在很乱,我们到底应该有什么访问权限,总结下来,应该有四种:自己,家庭,儿子,其他人,这里的家庭对应到代码,就是同一个包。我们现在应该有三个关键字了,分别是private,protectd,public,按照刚刚分析的逻辑,将权限放进去,就得到了这个表格。

修饰词本类同一个包的类继承类其他类
private×××
protected×
public

看到上面的表格,总觉得不好看,缺了些什么,发现竟然没有区分,可以同一个包访问,但是儿子不能访问的可能性。这可不行,设计者抓耳挠腮,发现,我干脆就不写关键字嘛,然后赋予其含义是:可以同一个包访问,但是继承类不能访问的特性。大功告成,加上不写关键字的场合,我们造出来下面这个表格。

修饰词本类同一个包的类继承类其他类
private×××
无(默认)××
protected×
public

这样一来我们就能理解,那些厉害的书籍,在描述protected的时候只潇洒的描述了两点:

  1. 父类的protected成员是包内可见的,并且对子类可见;
  2. 若子类与父类不在同一包中,那么在子类中,子类实例可以访问其从父类继承而来的protected方法,而不能访问父类实例的protected方法。

简单的一点点东西,感受它的存在,思考其为什么存在,别有一番滋味啊。

解决子类和父类的信息冲突

子类想调用父类的方法,一般直接调用就可以了,但是就是会出现一种情况:子类有个方法和父类的一个方法名字一样,参数列表一样。那么再子类内使用这个方法的时候就会迷惑,到底是调用的父类的方法还是子类自己的方法?

按照之前的套路,我们使用super.functionName()来区别当前类方法和父类方法。好像问题已经解决完了,没什么漏洞。但是,富有远见的设计者们嗅到了不一样的味道。这是一个哲学性问题:继承下来的信息和方法,子类能改这些东西吗?

当然可以!祖宗传下来的规矩,怎么可以永恒不变呢?这里分为两种东西:信息和能力,我们一项一项来。

先是信息:也就是成员变量。成员变量会有4中访问权限控制,其中除了private之外,子类在满足一定条件下都可以访问。但凡继承性下来的信息可以访问,就会有和子类的成员变量冲突的可能性。

public class Father{
	String defaultValue = "Father-defaultValue";
	protected String protecetedValue = "Father-protecetedValue";
  public String publicValue = "Father-publicValue";
}
public class Son extends Father{
  String defaultValue;
	protected String protecetedValue;
  public String publicValue;
  public static void main(String[] args){
    Son son = new Son();
    System.out.println(son.defaultValue);
    System.out.println(son.protecetedValue);
    System.out.println(son.publicValue);
  }
}

获得到的输入是:

由此我们可以推断,

解决子类和父类的方法冲突

抽象类定义

由此,父类和子类之间的关系我们就明朗了。子类可以继承父类,并且覆盖父亲的行为。但是这里缺少控制,也就是说父类需要具备能力控制子类,要求其必须做某件事。如此一来抽象类的概念就出现了。例如:

class Father{
    void readBook(){
        System.out.println("必须看书")
    }
}

class Son extend Father{
    
}

上述代码实际上和我们之前聊的父子继承的关系一样,子类实际上不太受限,可以随意发展。为了让父类控制子类,存在以下考量:

  1. 存在制约性质的父类需要有个标记,让系统知道这个类有一定的控制力
  2. 体现控制力,从携带信息或者执行行为上考虑,java这里仅考虑了行为控制,当然也需要进行特殊标记

因此,引入abstarct关键字,分别标记在父类与父类方法上:

abstract class Father{
    abstract void readBook();
}

class Son extend Father{
    @Override
    public void readBook(){
        System.out.println("必须看书");
    }
}

一个是使用abstract标记了类,表达是一种特殊的类,对子类进行控制。其中readBook方法被标记上了abstract,表达子类必须实现。由于父类内存在不可用方法,因此不允许直接进行初始化。

规范与行为执行协作关系

上面描述了父子类之间的协作关系,在一定程度上父类限制了子类的行为。引入abstract关键字之后,父类对子类的控制在增强,但是由于父类有自身的信息与方法,看上去父类所承担的职责还是很大的。有没有更加纯粹的控制呢?父子关系继续泛化编程执行者与规范间关系。这个规范指的就是接口,其中只有未实现方法,要求子类必须遵守接口规范。

interface Rule{
    void readBook();
}

class Son implement Rule{
    @Override
    public void readBook(){
        System.out.println("看书")
    }
}

可以看到,interface关键字描述一个类,成为接口。当一个类实现这个接口之后,必须按照接口的要求将期内所有的方法都实现掉。以达到执行者与规范之间的协作。

外部类与内部类协作关系

上面聊的都是父子类继承或者实现之间的关系,但是现实生活中往往需要多个类进行配合。配合方式也很简单,就是在一个类内初始化另一个类进行操作即可。这里的问题出在另一个类在哪的问题。一般都分为外部的类与内部的类。针对内部的类,你可以简单理解为,其内部类与当前类的关系较为紧密,大概率只与当前类沟通。针对内部类所处的位置可细分为以下几种:

  • 成员内部类
public A{
    class AA{
        
    }
    public A(){}
}
  • 静态内部类
public A{
    static class AA{
        
    }
    public A(){}
}
  • 方法内部类
public A{
    public A(){
       class AA{}
        AA aa = new AA();
    }
}
  • 匿名内部类
interface AA{
    void readBook();
}
public A{
    public void accept(AA aa){
        aa.readBook();
    }
    public A(){
        accept(new AA{
            @Override
            public void readBook(){
                System.out.println("看书")
            } 
        });
    }
}

基础类型与其包装类

包装类总览

我们知道我们java有8种基础类型,分别是,byte,short,int,long,float,double,boolean,char。这种基础类型并非是一个对象。这是因为制造一个对象的空间占用还是很够的,而基础类型相对就比较小。因此java使用基础类型与对象共同协作。但是数据类型与其他对象之间打交道就不能这么玩了,需要用对象和对象进行交互。因此我们的八种基本类型都会有相应的封装类。分别是:

  • Byte
  • Short
  • Integer
  • Long
  • Float
  • Double
  • Boolean
  • Character

装包与拆包

由于基础类型会和对象打交道,方法入口与方法出口处如果类型不太匹配需要每次进行转换。为了削减这部分的工作量,java自带装箱与拆箱的能力:

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;
Integer i = 10;  //装箱
int n = i;   //拆箱

上面这两行代码对应的字节码为:

L1
    LINENUMBER 8 L1
    ALOAD 0
    BIPUSH 10
    INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
    PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;
L2
    LINENUMBER 9 L2
    ALOAD 0
    ALOAD 0
    GETFIELD AutoBoxTest.i : Ljava/lang/Integer;
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    PUTFIELD AutoBoxTest.n : I
    RETURN

从字节码中,可以发现装箱其实就是调用了 包装类的valueOf()方法,拆箱其实就是调用了 xxxValue()方法。因此,
integer i = 10 等价于 Integer i = Integer.valueOf(10)
int n = i 等价于 int n = i.intValue();
注意:如果频繁拆装箱的话,也会严重影响系统的性能。应该尽量避免不必要的拆装箱操作。

装包缓存

Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。

两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2);// 输出true

Integer i11 = 333;
Integer i22 = 333;
System.out.println(i11 == i22);// 输出false

Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4);// 输出false

Integer 缓存源代码:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
    return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static {
        // high value may be configured by property
        int h = 127;
    }
}

这么一来会出现这样的情况:

Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2); //输出false

针对这个句子:Integer i1 = 40;Java 在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40); 从而使用常量池中的对象。而另外一句:Integer i1 = new Integer(40);这种情况下会创建新的对象。

枚举对象

Java从JDK1.5开始支持枚举,也就是说,Java一开始是不支持枚举的,就像泛型一样,都是JDK1.5才加入的新特性。通常一个特性如果在一开始没有提供,在语言发展后期才添加,会遇到一个问题,就是向后兼容性的问题。像Java在1.5中引入的很多特性,为了向后兼容,编译器会帮我们写的源代码做很多事情,比如泛型为什么会擦除类型,为什么会生成桥接方法,foreach迭代,自动装箱/拆箱等,这有个术语叫“语法糖”,而编译器的特殊处理叫“解语法糖”。那么像枚举也是在JDK1.5中才引入的,又是怎么实现的呢?

枚举的基本语法

枚举类型(Enum)是一种新的类型,允许用常量来表示特定的数据片断,而且全部都以类型安全的形式来表示。

假如,我们描述一周七天可以这么写:

public class Day {
    public static final int MONDAY =1;
    public static final int TUESDAY=2;
    public static final int WEDNESDAY=3;
    public static final int THURSDAY=4;
    public static final int FRIDAY=5;
    public static final int SATURDAY=6;
    public static final int SUNDAY=7;
}

使用枚举类型将这部分常量更改如下:

enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

这样一来就可以使用Day.xxx的方式获得这个枚举的引用了。

public class DayEnumTest {
    public static void main(String[] args) {
    	// 可以使用HungryLevelEnum.XXX的形式获得到其枚举实例
        System.out.println(HungryLevelEnum.MONDAY);
        System.out.println(HungryLevelEnum.TUESDAY);
        System.out.println(HungryLevelEnum.WEDNESDAY);
    }
}

枚举的本质原理

在使用关键字enum创建枚举类型并编译后,编译器会为我们生成一个相关的类,这个类继承了Java API中的java.lang.Enum类,也就是说通过关键字enum创建枚举类型在编译后事实上也是一个类类型而且该类继承自java.lang.Enum类。

我们写出一段代码:

public class EnumDemo {
    public static void main(String[] args){
        //直接引用
        Day day = Day.MONDAY;
    }
}
//定义枚举类型
enum Day {
    MONDAY, TUESDAY, WEDNESDAY,
    THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

然后将其编译之后,通过反编译的方式打开Day.class的字节码文件:

//反编译Day.class
final class Day extends Enum{
    //编译器为我们添加的静态的values()方法
    public static Day[] values(){
        return (Day[])$VALUES.clone();
    }
    //编译器为我们添加的静态的valueOf()方法,注意间接调用了Enum也类的valueOf方法
    public static Day valueOf(String s){
        return (Day)Enum.valueOf(com/zejian/enumdemo/Day, s);
    }
    //私有构造函数
    private Day(String s, int i){
        super(s, i);
    }
     //前面定义的7种枚举实例
    public static final Day MONDAY;
    public static final Day TUESDAY;
    public static final Day WEDNESDAY;
    public static final Day THURSDAY;
    public static final Day FRIDAY;
    public static final Day SATURDAY;
    public static final Day SUNDAY;
    private static final Day $VALUES[];

    static{    
        //实例化枚举实例
        MONDAY = new Day("MONDAY", 0);
        TUESDAY = new Day("TUESDAY", 1);
        WEDNESDAY = new Day("WEDNESDAY", 2);
        THURSDAY = new Day("THURSDAY", 3);
        FRIDAY = new Day("FRIDAY", 4);
        SATURDAY = new Day("SATURDAY", 5);
        SUNDAY = new Day("SUNDAY", 6);
        $VALUES = (new Day[] {
            MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
        });
    }
}

可以看到枚举本质上是通过普通的类来实现的,只是编译器为我们进行了处理。每个枚举类型都继承自java.lang.Enum,并自动添加了values和valueOf方法。而每个枚举常量是一个静态常量字段,使用内部类实现,该内部类继承了枚举类。所有枚举常量都通过静态代码块来进行初始化,即在类加载期间就初始化。

枚举的常用方法

Enum是所有 Java 语言枚举类型的公共基本类(注意Enum是抽象类),以下是它的常见方法:

方法签名方法说明
int compareTo(E o)方法是比较ordinal值大小
boolean equals(Object other)当指定对象等于此枚举常量时,返回 true。
Class<?> getDeclaringClass()返回与此枚举常量的枚举类型相对应的 Class 对象
String name()返回此枚举常量的名称,在其枚举声明中对其进行声明
int ordinal()获取的是枚举变量在枚举类中声明的顺序,下标从0开始
String toString()返回枚举常量的名称,它包含在声明中
<T extends Enum<T>> T static valueOf(Class<T> enumType, String name)返回带指定名称的指定枚举类型的枚举常量。

接下来我们使用上面的方法测试一下:

public class EnumDemo {

    public static void main(String[] args){

        //创建枚举数组
        Day[] days=new Day[]{Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY,
                Day.THURSDAY, Day.FRIDAY, Day.SATURDAY, Day.SUNDAY};

        for (int i = 0; i <days.length ; i++) {
            System.out.println("day["+i+"].ordinal():"+days[i].ordinal());
        }

        System.out.println("-------------------------------------");
        //通过compareTo方法比较,实际上其内部是通过ordinal()值比较的
        System.out.println("days[0].compareTo(days[1]):"+days[0].compareTo(days[1]));
        System.out.println("days[0].compareTo(days[1]):"+days[0].compareTo(days[2]));

        //获取该枚举对象的Class对象引用,当然也可以通过getClass方法
        Class<?> clazz = days[0].getDeclaringClass();
        System.out.println("clazz:"+clazz);

        System.out.println("-------------------------------------");

        //name()
        System.out.println("days[0].name():"+days[0].name());
        System.out.println("days[1].name():"+days[1].name());
        System.out.println("days[2].name():"+days[2].name());
        System.out.println("days[3].name():"+days[3].name());

        System.out.println("-------------------------------------");

        System.out.println("days[0].toString():"+days[0].toString());
        System.out.println("days[1].toString():"+days[1].toString());
        System.out.println("days[2].toString():"+days[2].toString());
        System.out.println("days[3].toString():"+days[3].toString());

        System.out.println("-------------------------------------");

        Day d=Enum.valueOf(Day.class,days[0].name());
        Day d2=Day.valueOf(Day.class,days[0].name());
        System.out.println("d:"+d);
        System.out.println("d2:"+d2);
    }
}
enum Day {
    MONDAY, TUESDAY, WEDNESDAY,
    THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

输出:

day[0].ordinal():0
day[1].ordinal():1
day[2].ordinal():2
day[3].ordinal():3
day[4].ordinal():4
day[5].ordinal():5
day[6].ordinal():6
-------------------------------------
days[0].compareTo(days[1]):-1
days[0].compareTo(days[1]):-2
clazz:class com.zejian.enumdemo.Day
-------------------------------------
days[0].name():MONDAY
days[1].name():TUESDAY
days[2].name():WEDNESDAY
days[3].name():THURSDAY
-------------------------------------
days[0].toString():MONDAY
days[1].toString():TUESDAY
days[2].toString():WEDNESDAY
days[3].toString():THURSDAY
-------------------------------------
d:MONDAY
d2:MONDAY

枚举实例增加字段信息

最初制造的枚举所携带的信息量太少,干不了复杂的事儿,我们可以往枚举内增加信息

public enum HungryLevelEnum2 {
    HUNGRY_LEVEL_1("一成饱"),
    HUNGRY_LEVEL_2("五成饱"),
    HUNGRY_LEVEL_3("十成饱");

    private String description;
    HungryLevelEnum2(String description){
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }
}

类似于构造函数的写法,将我们需要进行初始化的部分按照正常的方式书写,然后在枚举的最顶部,就像是调用构造函数的方式进行初始化。

public class HungryLevelEnum2Test1 {
    public static void main(String[] args) {
        System.out.println(HungryLevelEnum2.HUNGRY_LEVEL_1.getDescription());
        System.out.println(HungryLevelEnum2.HUNGRY_LEVEL_1.name());
        System.out.println(HungryLevelEnum2.HUNGRY_LEVEL_1.ordinal());
    }
}
// 获得到的输出是:
一成饱
HUNGRY_LEVEL_1
0

如此一来,证明我们的确可以往一个枚举实例内添加上更多的信息。

实例增加方法信息

我们之前一直反反复复强调,信息不光是field,行为也算是这个实例的信息。我们有两种为枚举实例增加方法信息的方式:

  • 为枚举增加抽象方法
  • 为枚举实现接口
public enum HungryLevelEnum3{
    HUNGRY_LEVEL_1("一成饱"){
        @Override
        void eat(){
            System.out.println("饿死了,赶紧吃");
        }
    },
    HUNGRY_LEVEL_2("五成饱"){
        @Override
        void eat(){
            System.out.println("不是很饿,但是吃得下");
        }
    },
    HUNGRY_LEVEL_3("十成饱"){
        @Override
        void eat(){
            System.out.println("要吐了");
        }
    };

    private String description;
    
    HungryLevelEnum3(String description){
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    // 这里是枚举的抽象方法,由每个枚举自行覆盖实现
    abstract void eat();
}

这样一来,实例的行为上是受限,这说的过去,但是方法实现由枚举实例自己决定。在赋予行为的时候,赋予field信息也可以同步进行,可谓完美。

枚举增强Switch

JDK1.6之前的switch语句只支持int,char,enum类型,使用枚举,能让我们的代码可读性更强。这本质上是一种语法糖,在编译的时候又会打回原形,可以查看其字节码,枚举位置将会变成一个数字,进行枚举。

public static void showColor(Color color) {
    switch (color) {
      case Red:
        System.out.println(color);
        break;
      case Blue:
        System.out.println(color);
        break;
      case Yellow:
        System.out.println(color);
        break;
      case Green:
        System.out.println(color);               
        break;
    }
}

枚举的高级特性

枚举是不能被实例化的,其本质是通过把clone、readObject、writeObject这三个方法定义为final的,同时实现是抛出相应的异常。这样保证了每个枚举类型及枚举常量都是不可变的。可以利用枚举的这两个特性来实现线程安全的单例。

枚举禁用了反序列化

/**
 * prevent default deserialization
 */
private void readObject(ObjectInputStream in) throws IOException,
    ClassNotFoundException {
    throw new InvalidObjectException("can't deserialize enum");
}

private void readObjectNoData() throws ObjectStreamException {
    throw new InvalidObjectException("can't deserialize enum");
}

枚举禁用了 clone 方法

protected final Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}

枚举禁用回收

/**
* enum classes cannot have finalize methods.
*/
protected final void finalize() { }
  • 7
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值