以Gopher的视角学Java-基础篇

173 阅读54分钟

PS: 是半成品,后续会更新和补齐,上传的原因是随着字数的增加,无异于在对本地的typora进行压测,目前发现typora在我16GB内存的机器上已经偶尔出现大纲跳转功能挂了的情况,而想到掘金肯定已经经受过了压测的考验,故以安全性为目的先上传到这里

Java概述

Java的跨平台性

java的跨平台性体现于:.java文件在编译后会成为.class(字节码文件)文件(类似go build后的.exe文件),.class文件可以在不同平台上运行(如linux、windows),这是基于Java虚拟机实现

JVM

是一个虚拟的计算机,有它自己的指令集并使用不同的存储区域,负责执行代码中指令、管理数据、内存、寄存器,其包含在JDK中 对于不同的平台(os),需要安装不同的JVM,其屏蔽的了底层运行平台的差异,实现了跨平台性

常用命令

javac - 编译(go build)

java - 运行(go run)

javap - 反编译(go不支持)

JDK&JRE

JDK = JRE + javaSE 开发工具(java、javac、javadoc、javap等)

类似于go的SDK

JRE = JVM + JavaSE的核心类库,只要一个os中具备JRE,就可以运行编译好的.class文件

类似于go的runtime

Java规则

1.一个.java文件中只能有一个public类,其他类的个数不限

2.如果.java文件中有一个public类,则文件名与类名必须一致

3.java中的入口也是main方法,但不一定需要放在public类中,放到哪个类里哪里就是入口,且可以有多个main方法,用户可以自行选择jvm的执行入口

4.java源文件使用utf-8编码

5.特殊的加号

java作为一门强类型的语言,"+"具有它的特殊之处

  • 当相加的变量中有一方为字符串时,则做拼接运算
  • 当相加的变量都是数值类型时,则作加法运算

注意,以上法则以前后顺序在相邻两个变量中生效

System.out.println(100 + 3 + "hello");
System.out.println("hello" + 100 + 3);

输出:

103hello
hello1003

而这种用法,在go中是会报错的,go中严格要求+衔接的两个变量的类型一致,比如int和float用加号衔接是不支持的

对于第三点,和go的差别还是蛮大的,go中一个包只允许有一个main方法

Java基础

转义字符

转义字符似乎在各语言中都大差不差

  1. /t 制表符,四个空格
  2. /n 换行符
  3. \ 一个\
  4. " 一个"
  5. ' 一个'
  6. \r 一个回车,并将光标定义到当前行行首

注释

  • 单行注释: 与go完全一样 //

  • 多行注释

    /*
      注释内容
    */
    
  • 文档注释

    它以 /** 开始,以 * / 结束。

    文档注释允许你在程序中嵌入关于程序的信息。

    你可以使用 javadoc 工具软件来生成信息,并输出到 HTML 文件中。

    /**
     * @author ryan
     * @version 0.0.1
     */
    

更多文档注释的标签:

@author标识一个类的作者@author description
@deprecated指名一个过期的类或成员@deprecated description
{@docRoot}指明当前文档根目录的路径Directory Path
@exception标志一个类抛出的异常@exception exception-name explanation
{@inheritDoc}从直接父类继承的注释Inherits a comment from the immediate surperclass.
{@link}插入一个到另一个主题的链接{@link name text}
{@linkplain}插入一个到另一个主题的链接,但是该链接显示纯文本字体Inserts an in-line link to another topic.
@param说明一个方法的参数@param parameter-name explanation
@return说明返回值类型@return explanation
@see指定一个到另一个主题的链接@see anchor
@serial说明一个序列化属性@serial description
@serialData说明通过writeObject( ) 和 writeExternal( )方法写的数据@serialData description
@serialField说明一个ObjectStreamField组件@serialField name type description
@since标记当引入一个特定的变化时@since release
@throws和 @exception标签一样.The @throws tag has the same meaning as the @exception tag.
{@value}显示常量的值,该常量必须是static属性。Displays the value of a constant, which must be a static field.
@version指定类的版本@version info

javadoc工具

javadoc 工具将你 Java 程序的源代码作为输入,输出一些包含你程序注释的HTML文件。

每一个类的信息将在独自的HTML文件里。javadoc 也可以输出继承的树形结构和索引。

由于 javadoc 的实现不同,工作也可能不同,你需要检查你的 Java 开发系统的版本等细节,选择合适的 Javadoc 版本。

使用格式

javadoc -d 路径 -注释1 -注释2 文件名

实例

import java.io.*;
 
/**
* 这个类演示了文档注释
* @author Ayan Amhed
* @version 1.2
*/
public class SquareNum {
   /**
   * This method returns the square of num.
   * This is a multiline description. You can use
   * as many lines as you like.
   * @param num The value to be squared.
   * @return num squared.
   */
   public double square(double num) {
      return num * num;
   }
   /**
   * This method inputs a number from the user.
   * @return The value input as a double.
   * @exception IOException On input error.
   * @see IOException
   */
   public double getNumber() throws IOException {
      InputStreamReader isr = new InputStreamReader(System.in);
      BufferedReader inData = new BufferedReader(isr);
      String str;
      str = inData.readLine();
      return (new Double(str)).doubleValue();
   }
   /**
   * This method demonstrates square().
   * @param args Unused.
   * @return Nothing.
   * @exception IOException On input error.
   * @see IOException
   */
   public static void main(String args[]) throws IOException
   {
      SquareNum ob = new SquareNum();
      double val;
      System.out.println("Enter value to be squared: ");
      val = ob.getNumber();
      val = ob.square(val);
      System.out.println("Squared value is " + val);
   }
}

在经过 javadoc 处理之后,SquareNum 类的注释将在 SquareNum.html 中找到。

变量

变量声明

两种方式:

  • 先声明再赋值

    int a;
    a = 10;
    
  • 声明时赋值

    int a = 10;
    

需要注意的是,如果你采用了第一种方式,则需要初始化,这是和go语言中有很大不同的地方

go中如果声明了一个变量,就已经被赋予了其类型的零值,除了一些引用类型,比如切片、map、chan,需要去make去初始化

var a int
fmt.Println(a) // 0

而java中不一样

int holiday;
System.out.println(holiday); // 会报错 “可能尚未初始化”

java10的新特性

从java10开始,对于局部变量,如果可以从变量的初始值判断出它的类型,就不再需要声明类型,只需要使用关键字var而无须指定类型

var holiday = 10;

变量声明上和go是比较相似的,除了go独有的:=

数据类型

分为两大类:基本数据类型、引用数据类型

基本数据类型

数值型

  • 整数类型,存放整数

    • byte(1个字节), short(2个字节), int(4个字节), long(8个字节)

image.png

  • 浮点数类型,存放小数

    • float(4个字节), double(8个字节)

image.png

一个字节是8位有符号数,能表示的数据范围是-128~127

float在小数点后7位之后的数据就会被舍弃掉了

和go语言类比一下的话:(左go右java)

int32 -> int

int64 -> long

go中还有较java独特的uint系列,即无符号的整型

float32 -> float

float64 -> double

但是在go中,如果我们给一个浮点类型的变量赋予了一个整数的值,那么在打印后,console输出的也是一个整数值,但在java中不同,对于浮点类型在控制台的输出一定是一个小数,如是整数则会自动添加小数点并在小数点后加0

使用规则

整型

  1. java中的整型的范围与长度不受不同os的影响
  2. java中的整型常量默认是* *int,若声明的是long类型的常量需在值后加"l"或"L"long类型最好以大写L来添加尾缀,因为小写l容易和数字1混淆。
// 会报错的情况
int a = 127l;  // 因为由8字节转为4字节,会有精度丢失
// 正确情况
long a = 127l;

浮点型

  1. 关于浮点数在机器中存放的形式是: 浮点数 = 符号位 + 指数位 + 尾数位
  2. 尾数部分可能会丢失,造成精度损失
  3. java中的浮点型常量默认是double,是java官方所建议的,若声明的是float型常量需在值后加"f"或"F" ,'
  4. 特殊的赋值方式
// 四种声明方式
  double num1 = 10.0;
  float num2 = 10.0f;
  double num3 = 1.1f;
  double num4 = .123; // 将num4赋值为0.123
  1. 科学计数法

e2 = 10^2 E-2 = 10^-2

System.out.println(5.12e2);
System.out.println(512e-2);
// 输出
512.0
5.12

对于科学计数法这一直接使用e的方式我今天才知道原来在go语言中也适用...

  1. 浮点数陷阱

对于两个浮点数不要进行比较运算,而应该是通过两个数的差值的绝对值是否在某个精度范围内来作为判断依据

原因是对于被除数,计算机无法判断其在我们所看到的最后一位小数之后是否还有数字

同样,这一点我也是今天才发现在go语言中这个问题也存在...

Go:

image.png Java:

image.png 正确的判断两个浮点数是否相等的写法:

if (Math.abs(num5 - num6) < 0.0001) {
    System.out.println("两者相等");
}

字符型

  • char(两个字节) 存放单个字符,可以是汉字

      char c1 = 'a';
      char c2 = '\t';
      char c3 = '嗨';
      char c4 = 97;
    

    输出:

image.png

char不能严格类比go语言中的类型,但在字符集这一方面,可以认为:

byte&rune -> char

因为char既可以处理utf-8(unicode的最多编码数量是65535个字符)也可以处理ascii字符集(一共有128个值,是8位二进制数到英语字符的映射集合,最前面1位统一规定为0)

不过go中的rune占四个字节长度,byte占一个字节长度,这么一想,类比的话还是byte更合适些,但是在输出是,char会直接输出整数对应的unicode字符,这是一个特殊的地方

使用规则
  1. java中赋予了char使用转义字符''的能力,来将其后的字符转为特殊字符型常量
  2. java中char本质是一个整数,在输出时输出其所对应的unicode字符

布尔值类型

布尔值类型均是1个字节

  • true
  • false

用法和go中几乎没有区别

只不过声明的时候go中是bool,而java中是boolean

还有在内存上的区别: go中的布尔占4个字节,而java中占1个字节

数据类型自动转换

核心原则: 精度小的类型可以自动转换为精度大的类型,即向上转换

image.png

如:

int a = 'a';
double d = 80;
使用规则
  1. 注意点: 当有多种数据类型的数据进行混合运算时,jvm会将所有非容量最大的数据的类型转化为其中容量最大的数据类型后,再进行运算

     float f = 'a' + 1.1; // 会报错,因为最大的容量的数据类型是double,float装不下
    
  2. 如果给1中运算的结果赋予了比起精度小的数据类型,那么会报错的,否则会自动进行类型转换

  3. (byte、short)和char之间不能进行自动转换(其实这感觉是java设计中的比较有意思的地方),但是你可以通过强转来实现char和byte的相互转换

    char b = 'b';
    System.out.println((int)b);
    byte c = 99;
    System.out.println((char)c);
    // 输出
    98
    c
    

数据类型强制转换

强转是自动转换的逆过程,即将容量大的数据类型转换为容量小的数据类型,但有可能会造成精度丢失或数据溢出

int i = (int)1.9;
System.out.println(i); // 1 精度损失
int n = 2000;
byte b = (byte)n;
System.out.println(b); // -49 数据溢出

和go中的用法也是很类似的,只不过就是语法上有有区别,java中的强转符需要写到值的前面

使用规则
  1. 需要注意的一个例子,是否需要强装需要考虑数据是否会溢出,比如int -> char这种情况就会溢出

    char d;
    int f = 100;
    d = f; // 会报错,溢出了
    d = (char)f; // 需要通过强转
    System.out.println(d);
    // 输出
    d
    

基本数据类型与String之间的转换

对于基本数据类型的变量转String,只需+ ""即可

对于String转基本数据类型,需要通过基本数据类型的包装类调用parsexx方法

且对于parsexx方法传入的参数,我们需要先人为判断是否能转为对应的xx类型,否则jvm会抛出异常,且在未被捕捉的情况下会终止程序,即这是一个运行时错误

int num1 = Integer.parseInt(s2);
double num2 = Double.parseDouble(s2);
float num3 = Float.parseFloat(s2);
long num4 = Long.parseLong(s2);
byte num5 = Byte.parseByte(s2);
boolean b = Boolean.parseBoolean("true");
short num6 = Short.parseShort(s2);

对于char类型,则取String中的第一个字符

引用数据类型

类、接口、数组,会再后续的篇幅中进一步学习

运算符

算数运算符

和go中基本一致,除了前++与后++,因为go中是没有前++的,因此这里的重点是温习前++与后++

  1. ++作为独立语句使用

    int i = 1;
    i++; // i = i + 1
    ++i; // i = i + 1
    

    那么前++与后++的含义是等价的

  2. ++作为表达式使用

    int j = 2;
    int k = ++j; // j = j + 1; -> k = j
    int m = j++; // m = j; -> j = j + 1 
    

    区别在于:

    前++: 原变量先加1,再将增加后的原变量的值赋给新变量,先自增,再赋值

    后++: 先将原变量的值赋给新变量,再将原变量的值加1,先赋值,再自增

这里有个有很意思的问题,就是赋值的对象是自己

int i = 1;
i = i++;

上述代码执行完后,i的值是多少?

在这里会有一个较为特殊的规则:

会产生一个隐式的新变量temp,有:

temp = i;
i = i + 1
i = temp

因此,i的值是1

那对于前++呢?

int i = 1;
i = ++i;

相应的,会有:

i = i + 1
temp = i
i = temp

因此,对于前++,i的值是2

关系运算符&逻辑运算符&赋值运算符

和go中完全一致

image.png

  1. a&b : & 叫逻辑与:规则:当 a 和 b 同时为 true ,则结果为 true, 否则为 false
  2. a&&b : && 叫短路与:规则:当 a 和 b 同时为 true ,则结果为 true,否则为 false
  3. a|b : | 叫逻辑或,规则:当 a 和 b ,有一个为 true ,则结果为 true,否则为 false
  4. a||b : || 叫短路或,规则:当 a 和 b ,有一个为 true ,则结果为 true,否则为 false
  5. !a : 叫取反,或者非运算。当 a 为 true, 则结果为 false, 当 a 为 false 是,结果为 true
  6. a^b: 叫逻辑异或,当 a 和 b 不同时,则结果为 true, 否则为 false

&& 和 & 使用区别

  • &&短路与:如果第一个条件为 false,则第二个条件不会判断,最终结果为 false,效率高
  • & 逻辑与:不管第一个条件是否为 false,第二个条件都要判断,效率低

||和|的区别也是同理

有一点是需要注意的,在复合赋值是,底层会自动进行类型转换

byte b = 3; // b的数据类型是byte
b += 2; // b的数据类型会因为有int类型的2参与运算,而转换为int
// 即 b = (byte)(b + 2)

三元运算符

这是go中所不支持的,因此也需要再温习一下

基本语法

条件表达式?表达式1:表达式2;

  • 如果条件表达式为true,则运算后的结果是表达式1
  • 如果条件表达式为false,则运算后的结果是表达式2
int a = 1;
int b = 2;
int result = a > b? a++:b--;
System.out.println(result); // 2

需要注意的是,表达式1和2的数据类型都需要是可以匹配新变量的数据类型(包括自动转换)

比如,你不能:

int a = 1;
int b = 2;
int c = a > b ? 1.1:2.2;

比较运算符

==和equals方法

对于==:

既能比较两个基本类型也能比较引用类型

  • 如果比较的两者是基本类型,则判断的是两者的是否相等

    int a = 10;
    int b = 10;
    System.out.println(a == b) // true
    
  • 如果比较的两种是引用类型,则判断是地址是否相等,即指向的是否是同一个对象

    A a = new A();
    A b = a;
    A c = a;
    System.out.println(c == b) // true
    

对于equals方法:

equals()是Object类中的方法,只能判断引用类型,其源码是:

public boolean equals(Object anObject) {
    if (this == anObject) { // 如果传入的参数是当前对象,则返回true
        return true;
    }
    return false;
}

equals方法往往会被重写,用于赋予更多的能力或改变逻辑以满足在不同类中判断逻辑,这其中往往会需要向下转型(会在后面的篇幅中详细讨论)后去判断两者值是否相同,以JDK 11中String类的equals()的源码为例,其重写的目的是在判断两者是否是同一对象的基础上加了一层判断两者值是否相等的逻辑:

public boolean equals(Object anObject) {
    if (this == anObject) { // 如果传入的参数是当前对象,则返回true
        return true;
    }
    if (anObject instanceof String) { // 判断是否是String类型或其子类
        String aString = (String)anObject; // 向下转型
        // 判断字符串中的值是否相等
        if (coder() == aString.coder()) {
            return isLatin1() ? StringLatin1.equals(value, aString.value)
                              : StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

标识符命名规范

  • 包名: 多单词组成时,所有字母都小写: aaa.bbb.ccc,如 com.ryan.crm
  • 类名、接口名: 多单词组成时,所有单词的首字母大写,即大驼峰: XxxYyyZzz,如StudentManagerSystem
  • 变量名、方法名: 驼峰式写法,如isSuccess
  • 常量名: 所有字母都大写+蛇形写法,如TAX_RATE

关键字

image.png

image.png

保留字

Java 保留字:现有 Java 版本尚未使用,但以后版本可能会作为关键字使用。自己命名标识符时要避免使用这些保留

字 byValue、cast、future、 generic、 inner、 operator、 outer、 rest、 goto 、const

键盘输入

即接收控制台输入的变量,也是必须要掌握的,毕竟笔试是避免不了ACM模式的

java中需要借助JDK中的Scanner类,其在java.util.Scanner包下

import java.util.Scanner;

这里的scanner的设计和用法和go语言标准库中的bufio.Scanner是很像的,下面的是一个在java中接收字符串、整型、薪水的例子(一行一个参数)

Scanner myScanner = new Scanner(System.in);
​
String name = myScanner.next();
int age = myScanner.nextInt();
double salary = myScanner.nextDouble();

进制

二进制:0,1 ,满 2 进 1.以 0b 或 0B 开头。

十进制:0-9 ,满 10 进 1。

八进制:0-7 ,满 8 进 1. 以数字 0 开头表示。

十六进制:0-9 及 A(10)-F(15),满 16 进 1. 以 0x 0X 开头表示。此处的 A-F 不区分大小写。

原码、反码、补码复习

image.png

位运算

和go中的基本一致,这里的篇幅也主要是复习的目的

需要注意的两点是:

  • 计算时以补码计算
  • 运算结果是补码计算后的原码

&运算例子

// 2 & 3的运算过程
// 2的原码: 00000000 00000000 00000000 00000010 因为2是正数,2的补码也是如此
// 3的原码: 00000000 00000000 00000000 00000011
// 因此运算后的补码是: 00000000 00000000 00000000 00000010 因为其是一个正数,故其的原码也是如此,也就是最后的结果
System.out.println(2&3); // 2

~运算例子

// ~-2的运算过程
// -2的原码: 10000000 00000000 00000000 00000010
// -2的反码: 11111111 11111111 11111111 11111101 (符号位不变,其他位取反)
// -2的补码: 11111111 11111111 11111111 11111110 (反码+1)
// -2取反:  00000000 00000000 00000000 00000001 因为其是一个正数,故其的原码也是如此,也就是最后的结果(符号位为0)
System.out.println(~-2); // 1

算数右移&左移

  • 算数右移>>: 低位溢出,符号位不变,并用符号位补溢出的高位(除2)
  • 算数左移<<: 符号位不变,低位补0 (乘2)
  • 无符号右移>>>: 低位溢出,高位补0 (go中没有)
  • java中没有<<<
System.out.println(1>>2); // 0
System.out.println(1<<2); // 4

流程控制

分支控制

if语句

if (条件表达式) {
    执行代码块;
} else if (条件表达式2) {
    执行代码块;
} else {
    执行代码块;
}

和go唯一的区别是条件表达式部分需要用小括号括起来

Scanner myScanner = new Scanner(System.in);
int num = myScanner.nextInt();
if (num == 100) {
    System.out.println("信用极好");
} else if (num < 100 && num > 80) {
    System.out.println("信用优秀");
} else if (num <= 80 && num >= 60) {
    System.out.println("信用一般");
} else {
    System.out.println("信用不及格");
}

switch语句

还有switch,和go中的语法基本是相似的这里不做介绍

其实我自己写go从没写过switch,不过go中的select的语法和switch很相像

需要注意的是

  • switch中的表达式的返回值必须是byte、short、int、char、enum、String,不能是double
  • case字句中的值必须是常量,而不能是变量
  • 记得选择是否要写break

还有一个switch穿透的例子

int month = myScanner.nextInt();
switch (month) {
    case 3:
    case 4:
    case 5 :
        System.out.println("春季");
        break;
    case 6:
    case 7:
    case 8 :
        System.out.println("夏季");
        break;
}

即一个case中如果没有break语句,则会继续执行接下来的case

规范性

考虑到可读性,嵌套不要超过3层

循环控制

for循环

go中的for循环是比较独特的,有三种用法,而java中的for循环的用法也可分为大致三类

  1. 写明for循环中的所有元素
for (循环变量初始化; 循环条件; 循环变量迭代) {
    循环操作;
}
  1. java中也可以像go一样,在for循环中仅写明部分元素(将变量初始化和变量迭代的部分写到其他地方),但不能省略分号:
// 没有变量初始化
int i = 0;
for (; i < 10;) {
    System.out.println(123);
    i++;
}

但此时,ide会提升建议直接使用while,可能这是更适合while语义的场景

image.png

  1. java中有增强的for循环for each,有些类型于go中的for range

    int[] array = {2, 3, 1, 4, 5, 5, 6};
    for (int element : array) {
        System.out.println(element);
    }
    

    其应用场景是依次处理数组(集合)中的每个元素,而不必考虑指定的下标值

    和go中如下列中这样应用for range是一样的效果:

    array := []int{2, 3, 1, 4, 5, 5, 6}
    for _, element := range array {
        fmt.Println(element)
    }
    

不过java中不能像go中的for一样用作死循环

// 死循环
for {
​
}

java中for循环也有go中不能做的,java中的for循环初始语句可以有多条,中间用逗号隔开,但要求类型一样,变量迭代部分也可以用多条语句,中间也用逗号隔开

int count = 5;
for (int i = 0, j = 0; i < count; i++, j+=2) {
    System.out.println("i=" + i + "j=" + j);
}
// 输出:  
// i=0j=0
// i=1j=2
// i=2j=4
// i=3j=6
// i=4j=8

while循环

和go中的for死循环用法基本一样

循环变量初始化;
while(循环条件) {
    循环体;
    循环变量迭代;
}

需要注意的是,要在while循环外部初始化变量

int i = 0;
while (i != 3) {
    i++;
}

do-while循环

这是go中所没有的,适用于一些特殊场景

循环变量初始化;
do {
    循环体;
    循环变量迭代;
} while(循环条件);

和while区别是:

while是先判断再执行,do-while是先执行再判断,也就是说一定至少会执行一次

int i = 1;
do {
    System.out.println(123);
} while (i == 0);
// 控制台输出
// 123

break和continue和go中完全一致,就不占用篇幅了

不过关于break、continue,java中还有关于标签(label) 的用法,可以指定退出到代码中的那一部分,不过不常使用,感兴趣的读者可以自行了解

规范性

建议一般使用两层,不要超过3层,否则O(n3)的时间复杂度,代码执行效率低,还不易读

数组

和go中类似,数组是存放多个同一类型的数据的容器,是引用类型(go中的数组是值类型),是一个对象,关于什么是java中的引用类型会在之后的篇幅中详细学习

double[] stocks = {1.1, 2.2, 3.5, 7.2};
for (int i = 0; i < stocks.length; i++) {
    System.out.println(stocks[i]);
}

动态初始化

  1. 声明时直接new出来

    int[] array = new int[5];
    
  2. 先声明再创建

    // 声明
    int array[]; // c语言样式,在java中不推荐
    int[] array; // java样式
    // 创建
    array = new int[5];
    

静态初始化

适用于已经知道数组中的所有元素的场景

int[] array = {2, 3, 1, 4, 5, 5, 6};

规范与细节

  • 数组中的元素可以是任何数据类型,包括基本类型和引用类型,但要保证一个数组中的元素类型唯一

  • 不同于变量的初始化,如果你已经创建了一个数组,且没有进行赋值操作,那么数组中的所有元素的值是对应数据类型的零值

    int -> 0
    short -> 0
    byte -> 0
    long -> 0
    float -> 0.0
    double -> 0.0
    char -> \u0000
    boolean -> false
    String -> null
    
  • 数组在java中时引用类型,虽然还没有深究理解java中的引用类型的含义,但在数组赋值时可以理解为被赋值的数组是一个指针,指向了原数组,所以在改变被赋值数组中的元素时,原数组中的元素也会发生改变

    int[] arr1 = {1,2,3};
    int[] arr2 = arr1;
    arr2[0] = 10;
    // arr1: 10,2,3
    // arr2: 10,2,3
    

    在ide中也有相应的提示,表示这是一个冗余的操作:

image.png

应用: 冒泡排序示例

class BubbleSort {
    public static void main(String[] args) {
        int[] nums = {24, 67, 81, 57, 11};
        int temp = 0;
        for (int i = 0; i < nums.length-1; i++) {
            for (int j = 0; j < nums.length - i - 1; j++) {
                if (nums[j] > nums[j+1]) {
                    temp = nums[j];
                    nums[j] = nums[j+1];
                    nums[j+1] = temp;
                }
            }
        }
​
        System.out.println(Arrays.toString(nums));
    }
}

二维数组

关于二维数组,就以一个例子来有一个印象,对于应用其实参照go中或者在写其他语言时的思想就好了

int[][] arr = new int[3][]; // 此时尚未初始化二维数组中的行元素
for (int i = 0; i < arr.length; i++) {
    // 初始化二维数组中的行元素
    arr[i] = new int[i+1];
    // 给列赋值
    for (int j = 0; j < arr[i].length; j++) {
        arr[i][j] = i + 1;
    }
}

且在java中有一种相对于go比较独特的声明方式

int[] x[] // x是一个int类型的二维数组变量

静态初始化

还有值得提一嘴的是,关于二维数组的静态初始化,和go中是很像的

int[][] arr = {{1,2,3}, {2,3,1}, {3,5,1}};

在ide中刷题时,可能会常常使用到这种初始化的方法

类与对象

直接直观的把java中的类与对象和go中做一个类比:

类(class) -> 结构体(struct)

属性(attributes) -> 字段(field)

举个在代码中的直观类比

java:

class Cat {
    String name, color;
    int age;
}

go:

type Cat struct {
  name, color string
  age         int
}

实例化(创建对象)

java:

public class javaReview {
    public static void main(String[] args) {
        Cat cat = new Cat();
        cat.age = 2;
        cat.color = "white";
        cat.name = "puppy";
    }
}

go:

func main() {
    var cat Cat
    cat.age = 2
    cat.color = "white"
    cat.name = "puppy"
}

这里这样写是为了和java能更好的对比,而go中一般的实例化的写法是这样的:

func main() {
    cat := &Cat{
      name:  "puppy",
      color: "white",
      age:   2,
    }
}

由于java中没有赋予用户使用指针的能力,所以在操作对象的时候,和go中有一些不同:

  • go语言中,struct是一个值类型,但在操作大对象时,根据内存逃逸分析,我们往往会选择操作结构体指针,以减小GC的压力
  • 而在java中,我们可以直接将对象就理解为一个结构体指针,因此我们类比说java中的class,不同于go中的struct的是,它是一个引用类型,相对go语言java中省去是否要去选择操作结构体还是结构体指针这个过程

在java中,属性在和类进行绑定时就会进行初始化,未被赋值的属性会被赋予其所属数据类型的零值,这和在方法中单独声明变量时是不同的,这点和go语言十分相似

Cat cat = new Cat();
cat.color = "white";
cat.name = "puppy";
​
System.out.println(cat.age); // 0

对象在JVM的内存中存在形式

这里先有一个大概的了解,后续的篇幅中会有较为深入的学习,大致的重点是:

对于jvm的内存结构大致的分配机制是

  • 栈: 一般存放基本数据类型(局部变量)
  • 堆: 存放对象
  • 方法区: 常量池(常量,比如字符串)、类加载信息

在对象创建的流程中有

  • 实例化的对象存放在jvm中的栈区

  • 类的属性根据数据类型的不同,来决定存放的是值还是地址

    • 若属性为引用类型,则以地址的形式存放在堆区
    • 若属性为基本数据类型,则直接存放的是值
  • 堆中存放的地址指向的是在方法区中的常量池中实际存放的值

  • 在实例化的过程中,jvm会把类的信息(属性、方法)加载到方法区

在调用方法时

  • 当执行到一个方法时,会开辟一个独立的栈空间,即方法的所需的内存分配在栈上

方法

这里还是先通过一个例子和go语言做类比,声明一个Person类(结构体),有两个属性(字段),有一个speak方法(绑定方法)

java:

class Person {
    String name;
    int age;
​
    public void speak() {
        System.out.println("have a good day!");
    }
}

go:

type Person struct {
  name string
  age  int
}
​
func (p *Person) Speak() {
  fmt.Println("have a good day!")
}

不同于go的是,java是一门纯面向对象的语言,在为某个类写方法的时候,方法的方法体会写在class中,我个人认为这样在代码的可读性上相对于go要更好,更加直观,可以轻松看到某个类具有哪些方法,而在go中如果没有刻意的在代码中给不同结构体的方法排个序,可能不够直观

java成员方法的定义是:

访问修饰符 返回参数的数据类型 方法名(参数列表) {
    方法体;
    return 返回值;
}

和go中函数的写法中所需要的部分基本是一致的,除了访问修饰符的部分,

访问修饰符

用于控制方法和属性以及类(部分) 的访问权限

  • public: 任何类都可以访问
  • protected: 仅子类和在同一个包中的类可以访问
  • 默认(即不写): 仅同一个包中的类可以访问
  • private: 只有类本身的方法可以访问
类内部本包子类外部包
public
protected×
default××
private×××

访问修饰符的设计是后续很多特定业务场景所遵循的设计模式的基底之一

而在go中,由于并不是一门严格的纯面向对象语言,源码在架构划分上最小的单位是包而不是类,仅有是否将方法名以及字段名大写来控制访问权限

需要注意的是: 当访问修饰符去控制类的访问权限时,只有默认和public才可以修饰

规则

  • 一个方法最多只能有一个返回值,如果希望返回多个值,则可以考虑换成返回的是一个集合类型,这和go是很不一样的,go中支持函数有多个返回值

  • java中方法的返回值可以向下兼容,即精度高的返回值数据类型可以接收精度低的数据类型,如double可以接收int,反之则不可以,因为会发生溢出

    public double test() {
        int i;
        i = 2;
        return i;
    }
    

    而在go中,这是不可以的,如果要返回和函数声明的返回值类型不同的数据类型,需要手动强转

    func test() float64 {
        var i int
        i = 2
        return i // 会报错
    }
    
  • 在java中由于是纯面向对象,不会有方法在类外部,由此有了调用类内部方法和外部方法上的规则

    • 调用类内部的方法时,通过方法名调用即可
    • 调用外部类中的方法时,先创建一个外部类的实例化对象,通过对象名.方法名调用,调用时需要注意被调用方法的访问修饰符是否允许该方法被调用

可变参数

和go中的用法类似,当我们不知道一个方法中要传入多少个同数据类型的参数时,可以采用可变参数的方法来入参

public int sum(int... nums) {
    int ans = 0;
    for (int i = 0; i < nums.length; i++) {
        ans += nums[i];
    }
​
    return ans;
}

需要注意的是:

  • 可变参数的实质是一个指定为可变的数据类型的数组,即我们也可以直接传入一个同类型的数组,这和go中是一样的

    java:

    public class javaReview {
        public static void main(String[] args) {
            VarPara v = new VarPara();
            int[] nums = {1,2,3};
            v.VarPar(nums);
        }
    }
    ​
    class VarPara {
        public void VarPar(int... nums) {
            System.out.println(Arrays.toString(nums));
        }
    }
    

    go:

    func main() {
        VarPar([]int{1, 2, 3}...)
    }
    ​
    func VarPar(nums ...int) {
        fmt.Println(nums)
    }
    
  • 当一个方法中既有普通参数也有可变参数时,需要保证可变参数在形参列表的最后,也与go中的要求一样

    public void VarPar(String word, int... nums) {
        System.out.println(Arrays.toString(nums));
    }
    
  • 根据上一个注意点的要求,即同时要求了一个方法中只能有一个可变参数

属性与局部变量

在面向对象编程中java在类的使用方式上,有一点和go中实现面向对象编程的结构体是不一样的,java中允许直接给属性(即go中结构体的字段)赋值,且属性的作用域是在整个类中(类比的话就是go中的全局变量),当然,同go中一样,在类中方法里的局部变量的优先级大于属性,也就是就近原则

class Dog {
    int age = 3;
​
    public void eat(int age) {
        System.out.println(age);
    }
}
​
public class javaReview {
    public static void main(String[] args) {
        Dog d = new Dog();
        System.out.println(d.age); // 3
​
        d.eat(6); // 6
    }
}

属性的生命周期会往往长于局部变量,这是因为

  • 属性会随着对象创建而被创建,在对象被GC回收时而死亡
  • 局部变量会随着其方法的调用而创建,随着方法的任务结束(方法栈被回收)而死亡

如果我们给类的属性赋值,则会在类被加载时进行默认初始化,若构造方法中有对对象属性的修改,则会进行显示初始化,覆盖默认初始化的值

方法重载

方法重载(Overload)即允许在一个类中,允许多个同名方法的存在,但要求形参列表不一致

一个很好的的应用例子就是: print

System.out.println(123);
System.out.println("123");
System.out.println(1.23);
System.out.println('s');

out是JDK中的PrintStream类中的一个实例化的对象,从使用体验上看,对于同一个方法,我们可以传入不同数据类型的参数,而在源码中可以看到有很多个println方法(形参列表不同),这里就通过应用方法重载,赋予了方法更多的能力

比如,我们创建一个可以计算int和double类型数据的计算器类,并应用方法重载来整合计算方法的能力

class Calculator {
    public int calculate(int n1, int n2) {
        return n1 + n2;
    }
​
    public double calculate(double n1, double n2) {
        return n1 + n2;
    }
​
    public double calculate(double n1, int n2) {
        return n1 + n2;
    }
}

对于方法重载,我们需要注意的是:

  • 方法名必须与被重载方法同名
  • 形参列表不能与被重载方法一致

而go语言中是不支持方法重载的,go官方的解释说:在go的类型系统中,仅通过名称匹配并要求类型的一致性是一个主要的简化决策。 (搬自www.zhihu.com/question/40…)

构造方法

构造方法/构造器是为了满足我们在实例化某个类创建对象时就可以完成对于属性的赋值操作,用于简化初始化对象的过程

修饰符 方法名(形参列表) {
    方法体;
}

比如我们通过构造方法初始化一个Dog对象:

public class javaReview {
    public static void main(String[] args) {
        Dog d = new Dog("puppy", 3);
    }
}
​
class Dog {
    int age;
    String name;
    public Dog(String dName, int dAge) {
        name = dName;
        age = dAge;
    }
}

而在go中其实是天然支持构造方法,如果我们仅需要实现上述java实现的效果(谁都能创建Dog对象),我们并不需要为Dog结构体编写一个构造方法

d := &Dog{
  name:  "puppy",
  age:   3,
}

但go中并不天然支持构造方法的访问权限以及除了给结构体字段赋值以外其他初始化需要的操作,因此当我们需要遵从某个设计模式或职责的时候,我们会常常为某个结构体编写一个构造方法,比如很常见的一道面试题LRU缓存的实现:

type LRUCache struct {
  capacity   int
  cache      map[int]*LRUNode
  head, tail *LRUNode
}
​
type LRUNode struct {
  key, value int
  pre, next  *LRUNode
}
​
func InitLRUNode(key, value int) *LRUNode {
  return &LRUNode{
    key:   key,
    value: value,
  }
}
​
// Constructor 构造方法
func Constructor(capacity int) LRUCache {
  cache := make(map[int]*LRUNode)
​
  res := LRUCache{
    cache:    cache,
    capacity: capacity,
    head:     InitLRUNode(0, 0),
    tail:     InitLRUNode(0, 0),
  }
​
  res.head.next = res.tail
  res.tail.pre = res.head
​
  return res
}

规则

  • 构造方法没有返回值,仅仅是对已经被分配了内存的对象的属性做初始化

  • 构造方法名和类名必须一致

  • 构造方法也可以重载,应用在初始化对象时需要传入不同数量属性的场景

  • 要把构造方法和成员方法区分开来,我们不能去像调用成员方法一样通过对象名.方法名去调用,构造方法的调用是JVM去完成的,当我们编写了它,就赋予了在new对象时直接传属性值的能力

  • JVM会为每个类生成一个默认无参的构造方法,可以通过javap反编译指令查看,当用户自行编写了构造方法时,会覆盖默认的构造方法

    class Dog {
        int age;
        String name;
        // 无参构造方法
        public Dog() {
            age = 2;
        }
    }
    

this关键字

this是一个指针,指向的是当前对象的内存地址,是本类的引用,在java中是显式的,类似于go中绑定方法中的隐式对象指针

java:

class Dog {
    int age;
    String name;
​
    public Dog(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

go:

type Dog struct {
    age int
    name string
}
​
func (d *Dog) constructor(age int, name string) {
    d.age = age
    d.name = name
}

其应用场景有:

  • 类中的方法中访问本类的属性、方法、构造方法

    比如,通过this我们可以去创造一个方法用于判断传入的对象和当前对象是否指向同一块内存:

    class Dog {
        int age;
        String name;
    ​
        public Dog(String name, int age) {
            this.name = name;
            this.age = age;
        }
        // 判断传入的对象和当前对象是否指向同一块内存
        public Boolean compareTo(Dog d) {
            return this.name.equals(d.name) && this.age == d.age;
        }
    }
    
  • 访问构造方法

    this(参数列表)
    

    只能在构造方法中使用,其必须作为构造方法的第一条语句,顾名思义,只能在构造方法中通过this访问其他的构造方法

面向对象特性复习

封装

封装(encapsulation) 即把抽象出的数据(属性)和对数据的操作(方法)集合在一起,将数据保护在内部,访问者只有通过被授权(通过访问修饰符控制)的操作才能对数据进行操作

一个很好的解释例子就是: 在华为还没推出标志突破芯片工艺技术封锁时,他们可以拿到支持5G通信的iPhone中的芯片,但却不能即刻知道该芯片内部是如何设计的(数据),这即是一种封装

封装的好处

  • 隐藏实现细节,使我们在项目中集成各个类的能力时操作方便
  • 保护了类中数据的安全性,我们可以通过访问修饰符与自己定义的判断逻辑去实现

实现封装

  1. 对类的属性进行私有化,即使用private修饰符

  2. 为类提供一个公有的(public)的set方法,用于对实现对可否对属性操作的逻辑判断与实现对属性操作(写)

    public void setXxx(类型 参数) {
        // 验证权限的逻辑
        this.Xxx = 参数;
    }
    

    set方法中覆盖了构造方法能力,因此我们可以将set方法集成到构造方法中去,使得在调用构造方法创建对象时可以利用上封装对于数据权限的检验逻辑,保护数据安全性

    public Employee(String name, int age, double salary, String job) {
        this.setName(name);
        this.setAge(age);
        this.setSalary(salary);
        this.setJob(job);
    }
    
  3. 为类提供一个公有的(public)的get方法,用户获取属性的值(读)

    public 访问修饰符 getXxx() {
        return this.xx;
    }
    

如:

class Employee {
    // 需要实现的逻辑:
    /*
        1. 年龄、工资不能直接查看
        2. 年龄的合理范围在[1,120]岁,否则赋默认值18
        3. 姓名的长度在[2,6]
     */
    public String name;
    private int age;
    private double salary;
    private String job;
​
    public void setName(String name) {
        if (name.length() < 2 || name.length() > 6) {
            return;
        }
​
        this.name = name;
    }
​
    public String getName() {
        return name;
    }
​
    public int getAge() {
        // 权限判断的逻辑
​
        return age;
    }
​
    public void setAge(int age) {
        if (age < 1 || age > 120) {
            this.age = 18;
            return;
        }
​
        this.age = age;
    }
​
    public double getSalary() {
        // 权限判断的逻辑
​
        return salary;
    }
​
    public void setSalary(double salary) {
        this.salary = salary;
    }
​
    public String getJob() {
        return job;
    }
​
    public void setJob(String job) {
        this.job = job;
    }
}

而在go中,主要是以方法名、字段名的首字母是否大写来实现封装的,以下是一个例子

package model
import "fmt"
type employee struct {
    Name string
    age int   //其它包不能直接访问
    sal float64
}
//写一个工厂模式的函数,相当于构造函数
func NewEmployee(name string) *employee {
    return &employee{
        Name : name,
    }
}
​
func (e *employee) SetAge(age int) {
    if age < 0 || age > 120 {
        e.age = 18
        return
    }
    e.age = age
}
func (e *employee) GetAge() int {
    return e.age
}
​
func (e *employee) SetSal(sal float64) {
    if sal >= 3000 && sal <= 30000 {
        e.sal = sal
    } else {
        fmt.Println("薪水范围不正确..")
    }
}
​
func (e *employee) GetSal() float64 {
    return e.sal
}

继承

继承即某个类可以去复用其父类(超类、基类)的方法和属性,从而在子类(派生类)中减少代码冗余,并使我们在业务实现的设计上更贴合实际生活中的设计思维,是一种is -a的关系

class 子类 extends 父类 {
}

image.png

注意点

  • 子类继承的是父类所有的公开的属性与方法,如需访问父类私有的属性,则需要通过父类的公共方法(如getXxx方法)去访问
  • 子类中的对象再通过任意构造方法被创建的时候,会默认先调用父类的无参构造方法,完成父类的初始化(这也是一种贴近实际生活的思维,即先有父亲才能有孩子),再调用子类的构造方法,这是由于JVM会默认在子类的任意构造方法中调用父类的无参构造方法,在代码上相当于在子类的构造方法中隐式的调用了super()

    class Sub extends Base {
        public Sub() {
            // 隐式调用,写与不写效果相同
            super();
        }
    }
    
  • 如果父类没有提供无参构造方法,则必须在子类的构造方法中去用super(参数列表)去指定使用父类中的哪个构造器来完成父类的初始化,否则编译会不通过

    如果希望指定使用父类中的某个构造方法,也可以直接使用super(参数列表)显式调用,且必须编写在子类构造方法体中的第一行

    class Base {
        String name;
        int age;
    ​
        public Base(String name, int age) {
            this.name = name;
            this.age = age;
        }
    }
    ​
    class Sub extends Base {
        public Sub() {
            // 通过super中传入不同的参数来指定父类中的构造方法
            super("ryan", 18);
        }
    }
    
  • 当子类的构造方法中使用了super(),就不能使用this(),这也很好理解,因为两者都要求写在构造方法体的第一行

规则

  • java中所有的类都是Object类的子类,即Object类是所有类的父类(基类、超类),称为顶级父类
  • 子类最多只能继承一个父类,即java中是单继承的机制
  • 继承不应被滥用,在决定是否继承某个类是应先判断是否遵循is-a的设计理念,而这个主观判断的思考过程我们可以将其套用在实际生活中去,比如: cat is animal, glass is not animal
  • 当子类的属性与父类中的属性重名时,则会优先在本类中查找值,若没有再根据继承关系的依次向上查找
浅识在继承关系中的对象内存分布
  • 当一个类继承了某个类时,在创建其对象时,会在方法区依次从顶级父类开始加载其被继承的类,直到该类的信息也被加载
  • 中在分配对象的内存空间时,会根据辈分依次创建好指向其父类的属性以及该类的属性的值的指针
  • 中分配指向堆中该对象信息的指针
super关键字

super代表队对父类的引用,是用于访问父类的属性、方法、构造方法的指针

和this的用法类似,只不过是指向的父类

但和this在用法上不同的是,super并不能访问到父类中用priviate修饰符修饰的属性和方法

image-20231113193017493

class Base {
    String name;
    int age;
​
    public Base() {
    }
    
    public void test() {
        System.out.println("this method for test");
    }
}
​
class Sub extends Base {
    public Sub() {
        super.test();
        System.out.println(super.name + super.age);
    }
  
    public void querySalary() {
        System.out.println(super.salary); // 编译不通过
    }
}

super在实现继承中的应用意义

  • super赋予用户在编写子类的构造方法时,将参数的初始化分工的能力

    class Computer {
        String cpu, memory;
        int disk;
        
        public Computer(String cpu, String memory, int disk) {
            this.cpu = cpu;
            this.memory = memory;
            this.disk = disk;
        }
    }
    ​
    class PC extends Computer {
        private String brand;
        
        public PC(String cpu, String memory, String brand, int disk) {
            // 父类构造器去初始化父类属性
            super(cpu, memory, disk);
            // 初始化子类属性
            this.brand = brand;
        }
    }
    
  • 当子类和父类中的属性、方法出现重名时,super具有区分的意义,使我们得以访问父类中的属性和方法(遵从从年轻到老年的访问顺序)

方法重写

方法重写(覆盖)是继承关系下的概念,若在子类中编写了与父类中重名的方法,且返回类型(有特殊情况)与参数列表相同,则是方法重写,并且在子类的对象调用该方法时会调用子类重写后的方法,即在子类对象的视角中覆盖了父类的方法

注意点

  • 方法名称与参数列表均相同才是方法重写

  • 返回类型并不严格要求相同,特殊的情况是子类返回类型是父类返回类型的子类,如:

    class Animal {
        public Object say() {
            return null;
        }
    }
    ​
    class Bird extends Animal {
        public String say() { // String是Object类的子类
            return null;
        }
    }
    
  • 子类重写后的方法的访问权限相对于父类中的访问权限不能缩小,比如

    protected 父类方法 xxx()  ->  public 子类方法 xxx()  // 正确
    public 父类方法 xxx()  ->  protected 子类方法 xxx()  // 错误
    

这里将名称上类似的方法重载方法重写作一个对比:

image.png

可以看到实现方法重写的要求更加严格

接下来我们对比一下在go语言中的继承,go中并不像java中有extends关键字去指明继承谁的显式继承,而是通过字段嵌入的方式

func main() {
    s := &Sub{
      salary: 3000,
    }
​
    fmt.Println(s.age) // 0
}
​
type Base struct {
    name string
    age  int
}
​
type Sub struct {
    // 继承父类Base的方法和属性
    Base
    salary int
}
  • go中虽在继承中并不严格要求有构造方法,这也体现了go语言追求简洁的设计思路
  • go中并没有super关键字,因为其继承的实现采用的是字段嵌入的方式,因此当我们需要在子类的实例中去调用父类的字段或方法时,采用子类实例.父类名.父类方法(属性)即可

  • go中虽不支持方法重载,但支持方法重写(覆盖),其机制类似于java

    func main() {
      m := &Man{}
      m.Eat()
      m.Run()
      m.Sleep()
    }
    ​
    type Person struct {
    }
    ​
    func (this *Person) Eat() {
      fmt.Println("Person Eat")
    }
    ​
    func (this *Person) Run() {
      fmt.Println("Person Run")
    }
    ​
    func (this *Person) Sleep() {
      fmt.Println("Person Sleep")
    }
    ​
    type Man struct {
      Person
    }
    ​
    func (this *Man) Eat() {
      // 类似于Java的 super.Eat()
      this.Person.Eat()
      fmt.Println("Man Eat")
    }
    ​
    func (this *Man) Run() {
      fmt.Println("Man Run")
    }
    

    输出:

    Person Eat
    Man Eat
    Man Run
    Person Sleep
    

继承丰富了我们在代码设计上的自由度以及减少了代码的冗余,我目前还没有阅读过JDK的源码,但仅就go的SDK和看过的部分k8s的client包的来说,继承是应用十分广泛的(不过go中应用的更多是基于接口和继承组合机制)

多态

使方法和对象具有多种状态,多态的实现是建立在封装和继承基础之上的

对于状态一词,我的理解是这样的:

在方法层面
  • 方法重载: 通过重载可以赋予方法更多的能力,使其能接收不同的形参列表,不同的形参列表会去调用不同的方法体,这就是一种状态上的不同,方法可以接收不同的形态

  • 方法重写: 通过重写可以赋予方法不同的能力,使其具有不同的操作权限(比如private的属性和方法只有本类的方法才可以访问)以及逻辑,比如在如下的例子中:

    class Meituan {
        public String companyName;
        private String companyStrategy;
        public String job() {
            return "帮助大家吃的更好,生活的更好";
        }
    ​
        public String strategyForMedia() {
            // 可以访问到本类的private
            return companyStrategy;
        }
    }
    ​
    class ProductManager extends Meituan {
        public String job() {
            return "I'm a ProductManager of " + super.companyName + "根据用户体验和战略为开发创造需求";
        }
    }
    ​
    class Programmer extends Meituan {
        public String job() {
            return "I'm a Programmer of " + super.companyName + "敲代码";
        }
    }
    

    我们分别为这三个类创建创建一个对象,并调用job()方法

    public class javaReview {
        public static void main(String[] args) {
            Meituan m = new Meituan();
            System.out.println(m.job());
    ​
            ProductManager pm = new ProductManager();
            System.out.println(pm.job());
    ​
            Programmer p = new Programmer();
            System.out.println(p.job());
        }
    }
    

    可见,我们赋予了job()方法在隶属于不同类的对象时拥有了不同的状态,这也是面向对象编程去贴合实际生活的导向体现,再比如,每个人都有指纹,但每个人的指纹是不一样,这就是指纹的多态

在对象层面
编译类型&运行类型

编译类型和运行类型是理解和运用多态特性的必备知识

编译类型简而言之,即是在编译期间JVM给某个变量分配的类型,其在进程/线程/协程运行的整个生命周期中都不会改变

而运行类型,顾名思义是指在运行期间某个变量的类型,而在运行期间就具有变化性和不确定性,即变量的类型可以随着进程/线程/协程的运行的运行而改变,其变化的核心原则是向上兼容、向下转型,父类类型可以兼容其子类的类型,而子类的引用若要转为父类的类型则需要强转

在判断向上还是向下转型时,类型的由上到下的梯度关系即继承关系中的由老年到年轻的梯度关系,且转型的视角是基于对象而言的,即我们要从运行类型出发

如下面两条语句

// Animal是Dog和Cat的父类
Animal a = new Dog(); // 编译类型是Animal,运行类型是Dog
a = new Cat(); // 编译类型仍是Animal,运行类型变为了Cat

之所以要区分出这两个概念,我个人的理解是这样的:

  • 编译类型决定了我们在运行过程中可以给某个变量集成哪些类的能力
  • 运行类是集成的某个类的能力的封装
public class ploy {
    public static void main(String[] args) {
        Animal animal = new Dog();
        animal.speak(); // wang~
        animal = new Cat();
        animal.speak(); // miao~
    }
}
​
class Animal {
    public void speak() {
​
    }
}
​
class Cat extends Animal {
    public void speak() {
        System.out.println("miao~");
    }
}
​
class Dog extends Animal {
    public void speak() {
        System.out.println("wang~");
    }
}

比如在上述的代码示例中,我们就为animal这一变量集成了Cat、Dog这两个类的能力的封装,只要我们对变量变换了其可以接收的运行类型,变量就具备该运行类型的能力(属性、方法)

在这一点上,与上述的方法层面中的方法重写部分所述内容是相互照应的,具体的实现相同,但站在不同的视角(方法、对象),就对多态有了不同的诠释

多态我认为也为GC减轻了压力,因为利用多态特性就可以对同一变量赋予不同的能力,避免了去声明其他类的类型的而创造的变量的内存空间,提高了java程序的性能

向下转型

我们先总结一下在向上转型中调用方法时的规则:

  • 对象可以调用父类中所有的成员(受访问修饰符限制)
  • 不能调用子类中未重写父类方法的方法,即子类特有的方法
  • 调用方法时查找方法的顺序遵从从子类开始向上查找

其中第二点限制了我们去赋予对象最大化的能力,解决这一点的方法就是向下转型

向下转型在实际生活中能映射的例子还是蛮好举例的,比如,把我们自己类比于java中的一个对象(变量),在我们出生时(程序的生命周期的早期)我们的类型是Person这个父类,有了speak()、run()等能力(方法),id、gender等属性,即所有人都有的,但在我们的成长过程中(随着程序运行),我们有了一份职业,我们成为了一名程序员,成了Programmer这个子类的实例化对象,而这个类中coding()这个方法是Person类中没有的,这时候我们就需要通过向下转型使得我们coding()的能力也体现出来

简而言之,向下转型就是为了满足对象可以调用子类类型中所有的成员

package javaReview.ploy;
​
public class ploy {
    public static void main(String[] args) {
        Person ryan = new Person("123456", "male", "ryan");
        ryan.run(); // ryan is running!
        ryan.coding(); // 无法调用,编译不通过
        // 后来ryan长大了,有了新的身份programmer
        ryan = new Programmer("123456", "male", "ryan", "Meituan");
        // 向下转型
        Programmer ryanHasAJob = (Programmer) ryan;
        ryanHasAJob.coding(); // ryan is coding at Meituan
    }
}
​
class Person {
    String id;
    String gender;
    String name;
​
    public Person(String id, String gender, String name) {
        this.id = id;
        this.gender = gender;
        this.name = name;
    }
​
    public void run() {
        System.out.println(this.name + " is running!");
    }
​
    public void speak() {
        System.out.println(this.name + " is speaking!");
    }
}
​
class Programmer extends Person {
    String company;
​
    public Programmer(String id, String gender, String name, String company) {
        super(id, gender, name);
        this.company = company;
    }
​
    public void coding() {
        System.out.println(this.name + " is coding at " + this.company);
    }
​
    public void run() {
        System.out.println("A programmer is  running!");
    }
​
    public void speak() {
        System.out.println("A programmer speaking!");
    }
}

instanceof

instanceof是一个java中的比较操作符,用于判断对象的运行类型是否属于某个类型或某类型的子类型

比如:

Person ryan = new Person("123456", "male", "ryan");
// 是否属于某个类型
System.out.println(ryan instanceof Person); // true
ryan = new Programmer("123456", "male", "ryan", "Meituan");
Programmer ryanHasAJob = (Programmer) ryan;
// 是否属于某个类型的子类型
System.out.println(ryanHasAJob instanceof Person); // true

instanceof可以帮助在强转之前进行判断,以防空指针的出现导致程序崩溃,instanceof在比较保险的编程风格上是很常见的,因为我们也希望赋予对象子类的全部能力

方法动态绑定机制

java中的动态绑定机制是指:

  • 当调用对象方法时,方法会和对象的内存地址,即运行类型进行绑定,this指向的是运行类型
  • 当调用对象的属性时,没有动态绑定机制,this指向的是当前类型

我们从一个例子中来理解上述对于方法动态绑定的机制

package javaReview.ploy;
​
public class ploy {
    public static void main(String[] args) {
        Person ryan = new Programmer("123456", "male", "ryan", "Meituan");
        System.out.println(ryan.age);
        ryan.info();
        ryan.birthInfo(); 
    }
}
​
class Person {
    String id;
    String gender;
    String name;
    int age = 0;
​
    public Person(String id, String gender, String name) {
        this.id = id;
        this.gender = gender;
        this.name = name;
    }
​
    public int getAge() {
        return this.age;
    }
​
    public void info() {
        System.out.println(this.name + " is " + this.getAge() + " years old");
    }
​
    public void birthInfo() {
        System.out.println(this.name + " is " + this.getAge() + " years old");
    }
​
}
​
class Programmer extends Person {
    String company;
    int age = 18;
​
    public Programmer(String id, String gender, String name, String company) {
        super(id, gender, name);
        this.company = company;
    }
​
    public int getAge() {
        return this.age;
    }
​
    public void coding() {
        System.out.println(this.name + " is coding at " + this.company);
    }
​
    public void info() {
        System.out.println(this.name + " is " + this.getAge() + " years old");
    }
}

输出是:

0
ryan is 18 years old
ryan is 18 years old

我们来分析一下结果

对于ryan这个变量,其编译类型是Person,因此当访问属性时,看Person中的属性,age = 0

对于ryan.info, 调用的是方法,有自己的查找顺序,在运行类型Programmer中发现重写了Person类中的info方法,故只关注Programmer中的info方法即可,其调用了this.getAge,即本类返回年龄的方法,故age输出的是18

对于ryan.birthInfo, 调用的是方法,有自己的查找顺序,在运行类型Programmer中发现并没有此方法,而Person中有,故只关注Person中的birthInfo方法即可,其调用了this.getAge可返回的为什么是子类Programmer中的age?

这里就是方法动态绑定机制的体现,即对象的内存和其运行类型绑定在了一起,故this关键字指向的是其运行类型,无论要访问的方法在什么类中才被查找到,只要方法体中调用的方法有被运行类型的类中重写过,调用的都是运行类型中的方法

延伸讨论

现在我们可以思考为什么说多态是基于继承和封装实现的呢?

我个人的理解是,多态的核心是转型能力赋予

而转型避免不了继承,更具体的说是避免不了方法重写,且多态的本质就是:

父类的引用指向了子类的对象

能力赋予以对应着封装,我们将能力(属性、方法)集成在了一个类中,需要注意的是,这里可以赋予的方法只能是重写过的

而对于当使用对象的属性,对应是对象的编译类型中的属性,即访问属性看编译类型,在访问方法时,才有查找顺序

应用: 多态数组

通过多态,我们可以声明一个拥有若干子类的父类类型的数组,由此创建了一个可以存贮了不同类型的容器(不过所有的类型都是数组的声明类型的子类型或者其本身),本质上是利用了向上转型的特性

在访问到数组中的某个元素时,JVM会去判断它当前的运行类型,并赋予其运行类型中封装的能力

父类Person:

package javaReview.ployArray;
​
public class Person {
    private String name;
    private int age;
​
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
​
    public String getName() {
        return name;
    }
​
    public void setName(String name) {
        this.name = name;
    }
​
    public int getAge() {
        return age;
    }
​
    public void setAge(int age) {
        this.age = age;
    }
​
    public String say() {
        return name + "\t" + age;
    }
}

子类: Student

package javaReview.ployArray;
​
public class Student extends Person {
    private double score;
​
    public Student(String name, int age, double score) {
        super(name, age);
        this.score = score;
    }
​
    public double getScore() {
        return score;
    }
​
    public void setScore(double score) {
        this.score = score;
    }
​
    public String say() {
        return super.say() + " score=" + this.score;
    }
  
    public void study() {
        System.out.println(this.getName() + " is studying");
    }
}

子类: Teacher

package javaReview.ployArray;
​
public class Teacher extends Person {
    private double salary;
​
    public Teacher(String name, int age, double salary) {
        super(name, age);
        this.salary = salary;
    }
​
    public String say() {
        return super.say() + " salary=" + this.salary;
    }
  
    public void teach() {
        System.out.println(this.getName() + " is teaching");
    }
}

多态数组实现:

package javaReview.ployArray;
​
public class PloyArray {
    public static void main(String[] args) {
        Person[] persons = new Person[3];
​
        persons[0] = new Person("ryan", 18);
        persons[1] = new Student("steak", 22, 98);
        persons[2] = new Teacher("vuri", 29, 20000);
​
        for (Person person:persons) {
            System.out.println(person.say());
            // 向下转型,使得对象也具有子类所有的能力
            if (person instanceof Student) {
                Student student = (Student) person;
                student.study();
            } else if (person instanceof Teacher) {
                Teacher teacher = (Teacher) person;
                teacher.teach();
            }
        }
    }
}

输出:

ryan  18
steak 22 score=98.0
steak is studying
vuri  29 salary=20000.0
vuri is teaching

常见的应用还有动态参数等,本质还是向上转型兼容,就不列出实例

Object类常用方法

这里篇幅列出一些常常会被子类重写的Object类中的方法

首先是equals()方法

public boolean equals(Object obj)

常常会被重写,用于赋予更贴合的比较逻辑,在之前运算符的篇幅中有所描述

hashCode()方法

public int hashCode()

用于提高诸如HashMap、HashTable等具有哈希结构的容器的效率

hashCode()方法保证了在程序的生命周期中同一对象调用该方法返回的值是相等的,前提是不修改对象上的equals()方法比较中使用的信息

由于java程序是跑在JVM中的,我们无法获取java中某个对象在本计算机上实际内存逻辑地址,而hashCode()返回的值,我们可以在比较是否是同一个对象时把它当做是内存地址一样去使用,在逻辑上可以认为该值是由JVM从实际内存逻辑地址上映射而来的

Person ryan = new Person("ryan", 18);
Person Ryan = ryan;
System.out.println(ryan.hashCode());
System.out.println(Ryan.hashCode());

输出:

757108857
757108857

在集合中,改方法也往往会被重写,后续会在集合的篇幅中讨论

toString()方法

public String toString() {
    // getClass().getName()即类的全类名(包名 + 类名)
    // Integer.toHexString(hashCode())将对象的hashCode转换为16进制
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

改方法的意义是返回对象的属性信息,子类中重写该方法的意义也是如此

仅目的上有些类似go中gorm框架中的TableName方法,返回表名信息用于在数据库链接时绑定

比如,我们重写toString()方法输出Person类中对象的信息

public class Person {
    private String name;
    private int age;
​
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String toString() {
        return "Person{" +
                "name='" + name + ''' +
                ", age=" + age +
                '}';
    }
}

如果我们在控制台输出一个对象,则在输出时会默认调用toString方法

Person ryan = new Person("ryan", 18);
System.out.println(ryan);  // 等价于System.out.println(ryan.toString());

finalize()方法

@Deprecated(since="9") protected void finalize() throws Throwable

这在java中的GC中会涉及到的方法,java的GC采用的策略是引用计数,当对象的没有任何引用时,JVM会自动调用该对象的finalize方法回收它的内存空间

JDK11 API文档对该方法的定义是:

当Java finalize虚拟机确定不再有任何方法可以被任何尚未死亡的线程访问时,它被调用,除非是因为最终确定其他一些准备完成的对象或类所采取的行动。 finalize方法可以采取任何操作,包括使该对象再次可用于其他线程; 然而, finalize的通常目的是在对象被不可撤销地丢弃之前执行清理操作。 例如,表示输入/输出连接的对象的finalize方法可能会执行显式I / O事务,以在永久丢弃对象之前断开连接。

包是模块化编程的基础,也是任何大型项目最基本的架构单位,java中的包关键字和go中一样都是package,通过import关键字引入包

在go中,不同的包下,我们可以命名相同名称的结构体或函数

对应的在java中,不同的包下,我们可以命名相同名称的类

命名规则

  • 在java的src目录下,创建包时.代表目录分隔符,如创建包名为com.google表示创建了com、google两个目录,其中com是google的父目录

  • 命名规范: com.公司名.项目名.模块名,如

    com.meituan.crm.user //用户模块
    com.meituan.crm.order // 订单模块
    

    java作为在业务开发中最主流的语言,包的命名规范是参考了URL的设计,故源码中的根目录往往是com,如果你所开发的项目的顶级域名是net,那可能根目录的名称会是net

  • java在编码过程中会隐式自动引入java.lang.*包,其中包含了很多常用的api,而在go中,任何包都需要显式引入

  • java中相对于go在引入过程中可以精确到某个类,如java.util.Scanner,而在go中引入的最小单位只能是包,以在做k8s开发时常常会引入的sigs包为例sigs.k8s.io/controller-runtime/pkg/client