写给前端程序员的java教程

592 阅读17分钟

这是我参与「掘金日新计划 · 4 月更文挑战」的第 2 天,点击查看活动详情

前言

从零开始写实在过于啰嗦,直接从代码入手逐渐熟悉可能会更好。java最重要的可能就是面向对象了,一步步了解java面向对象的全貌,对于前端程序员来说,也算是补齐一块短板,毕竟javascript里的面向对象时基于原型的,与主流的面向对象是有很大不同的。但是会发现,其实也有很多相似的地方,这可能是语音之间相互借鉴所产生的情况吧。

开发环境

最简单的上IDEA官网下载一个社区版的,对于学习来说足够用了。然后建目录,使用maven,一般用Java8的版本。

image.png

代码写在src/main/java下,建一个个的类文件

image.png

怎么运行?

image.png

image.png

点运行就行了。

构造函数

(1)构造函数名必须和类名相同

(2)构造函数不能定义返回值类型,也不能使用void声明构造器没有返回值

(3)创建对象的根本途径是构造函数,通过new关键字来调用某个类的构造器即可创建这个类的实例

(4)如果不显式定义构造器,则系统将会提供默认的构造器,而默认构造器没有参数

Person.java

要记住非常关键的一点,类名必须与文件名一致

public class Person {
    public String name;
    public int age;
    public Person(String name,int age){
        this.name = name;
        this.age = age;
    }
    public void say(String content){
        System.out.println(content);
    }
    public static void main(String[] args){
        Person p = new Person("flten",25);
        p.say("my name is " + p.name);
    }
}

没有显式声明构造器的情况:

public class Person {
    public String name;
    public int age;
    public void say(String content){
        System.out.println(content);
    }
    public static void main(String[] args){
        Person p = new Person();
        p.say("my name is flten");
    }
}

this指向

上面代码中的this是指向什么的呢?在java中,this始终指向调用该方法的对象,那么根据出现位置的不同this作为对象的默认引用就有两种情形:

(1)构造器中引用该构造器正在初始化的对象

(2)在方法中引用调用该方法的对象

另外有两点需要注意一下:

(1)this最大的作用是让同一个类中的一个方法去访问该类中的另一个方法或实例变量。而在对象的一个成员去直接调用另一个成员时,是可以省略this前缀的

(2)static修饰的方法中不能使用this引用。这是因为static是静态方法,也可以说是类方法,也就是需要使用类来直接调用的方法。而this是指向调用对象的,因此不能使用this引入static修饰的静态方法。

public class Dog {
    public void jump(){
        System.out.println("jump");
    }

    public void run(){
        this.jump();
        jump();
        System.out.println("run");
    }
    public static void main(String[] args){
        Dog d = new Dog();
        d.run();
    }
}

/*输出
    jump
    jump
    run
*/

静态成员

使用static关键字修饰的方法、属性就表明这个成员属于类,而不属于类的实例对象。

有以下几点需要注意:

(1)为了进行区分,把static修饰的方法、属性(变量)称为类方法、类属性,它们属于类;而没有使用static修饰的方法、属性(变量)属于实例对象,称它们为实例方法、实例属性(变量)。

(2)静态成员不能访问非静态成员

(3)static修饰的方法/属性,既可以通过类来调用,也可以通过实例来调用,但实际上都是通过类来进行调用的。虽然实例对象可以调用静态方法(本质上还是通过类),但实际编程中应该使用类去调用静态方法/属性(变量)

(4)static修饰的方法中不能使用this引用

(5)如果需要在静态方法中去调用实例成员,那么只能够在静态方法中创建一个新对象,使用该实例对象去调用成员方法。

public class Cat {
    public void run(){
        System.out.println("run");
    }
    
    // 静态方法中创建新对象去访问实例方法
    public  static void jump(){
        Cat c1 = new Cat();
        c1.run();
        System.out.println("run");
    }

    public static void main(String[] args){
        Cat c2 = new Cat();
        c2.jump();
    }
}

/*输出
    run
    run
*/

局部变量

我们在方法中是经常会用到局部变量的,那么局部变量又该如何声明呢?如果局部变量的名字与实例对象冲突又该如何区分呢?

Local.java

public class Local {
    public int foo;
    // 注意这是构造函数,与类名同名
    public Local(){
        int foo = 24;
        this.foo = 25;
    }
    public static void main(String[] args){
        System.out.println(new Local().foo); // 25
    }
}

可以看到,实例变量如果和局部变量命名冲突了,可以用this调用实例变量,区别局部变量。

但是如果实例变量和静态变量冲突了呢?能够解决吗?

public class Local {
    public int foo = 24;
    public static int foo = 25;
    public static void main(String[] args){
        System.out.println(new Local().foo);
    }
}

上面的代码将会报错:java: 已在类 Local中定义了变量 foo

方法

在java里方法是不能够独立存在的,它必须定义在类中,逻辑上方法要么属性类要么属于实例对象。而使用不同对象作为调用者调用同一个方法,可能得到不同的结果。

public class Person {
    public void say(String content){
        System.out.println(content);
    }
    public static void main(String[] args){
        Person p1 = new Person();
        p1.say("my name is flten"); // my name is flten
        Person p2 = new Person();
        p2.say("my age is 25"); // my name is flten
    }
}

可以看到由于传入参数的不同,导致了方法执行的结果不同。那么方法的参数传递有什么需要注意的呢?

方法的参数传递机制

明确一点,方法的参数是按值传递的。

public class Parameter {
    public static void swap(int a, int b){
        int tmp = a;
        a = b;
        b = tmp;
        System.out.println("swap方法中的a是:" + a + "b是:" + b);
    }
    public static void main(String[] args){
        int a = 6;
        int b = 9;
        swap(a, b);
        System.out.println("交换后的变量a是:" + a + "交换后的变量b是:" + b);
        // swap方法中的a是:9b是:6
        // 交换后的变量a是:6交换后的变量b是:9
    }
}

会发现传入swap方法后的a和b,并没有发生变化,这就是参数值传递的本质:系统开始执行方法时,系统为形参执行初始化,把实参变量的值赋给方法的形参变量,这样方法里操作的就不是实际的实参变量,而是它们的复制品。

如果传递的是引用类型呢?

class DataSwap {
    public int a;
    public int b;
    DataSwap(int a, int b){
        this.a = a;
        this.b = b;
    }
}

public class Parameter {
    public static void swap(DataSwap data){
        int tmp = data.a;
        data.a = data.b;
        data.b = tmp;
        System.out.println("swap方法中的a是:" + data.a + "b是:" + data.b);
    }
    public static void main(String[] args){
        DataSwap data = new DataSwap(3, 5);
        swap(data);
        System.out.println("交换后的变量a是:" + data.a + "交换后的变量b是:" + data.b);
        // swap方法中的a是:5b是:3
        // 交换后的变量a是:5交换后的变量b是:3
    }
}

发现执行交换之后,传入的参数也随之改变了,这似乎违背了值传递的原则。但其实不然,因为对于引用类型的传递,其实传递的是对象的引用。也就是说传入方法的变量data其实只是对DataSwap类型对象的一个引用,那么既然传递的是引用,按照值传递的原则,方法中对其复制的也是该引用,那么修改的也是该引用上的数据。

可变形参

在javascript里我们可以通过扩展运算符...来接收不定参数,其实Java中也完全是这样的,使用的也是同样的符号。

public class Varargs {
    public static void test(int a,String ...books){
        for(String tmp : books){
            System.out.println(tmp);
        }
        System.out.println(a);
    }
    public static void main(String[] args){
        test(5, "java", "javascript"); 
        // java  javascript  5
    }
}

由此可以看出,可变形式本质上就是一个数组参数,但是要注意:可变形参只能处于形参列表最后,而且一个方法中最多只能包含一个可变形参。既可参数一个参数,也可以传入多个参数。

public class Varargs {
    public static void test(int a, String...books){
        for(String tmp : books){
            System.out.println(tmp);
        }
        System.out.println(a);
    }
    public static void main(String[] args){
        // 传入一个数组
        String[] tmp = {"java", "javascript"};
        test(5, tmp);
    }
}

方法重载

在javascript里有方法重载吗?其实是没有的,因为如果声明两个同名的方法,后面的会覆盖前面的。

public class Overload {
    public void test(){
        System.out.println("没有参数的test方法");
    }
    public void test(String msg){
        System.out.println("这是有一个参数的重载test方法:" + msg);
    }
    public static void main(String[] args){
        Overload over = new Overload();
        over.test(); // 没有参数的test方法
        over.test("overload"); // 这是有一个参数的重载test方法:overload
    }
}

构造函数也是函数,那么构造函数能够重载吗?在javascript里这是不可以的,但是在java中是可以的:

public class ConstructorOverload {
    String name;
    public ConstructorOverload(){
        System.out.println("空的构造函数");
    }
    public ConstructorOverload(String name){
        this.name = name;
        System.out.println("有一个参数的构造函数" + this.name);
    }
    public static void main(String[] args){
        ConstructorOverload c1 = new ConstructorOverload(); // 空的构造函数
        ConstructorOverload c2 = new ConstructorOverload("flten"); // 有一个参数的构造函数flten
    }
}

类的继承

类继承的基本原则

java的继承看起来和javascript很像,之所以说像,是因为它们使用了同样的关键字,先来看一个例子热热身。

我们首先定义一个父类Fruit.java

public class Fruit {
    public double weight;
    public void info(){
        System.out.println("水果的重量是:" + this.weight + "克");
    }
}

在来定义子类,用它继承父类Apple.java

public class Apple extends Fruit {
    public static void main(String[] args){
        Apple a = new Apple();
        a.weight = 300;
        // 调用了继承自父类的方法
        a.info();
    }
}

咦,它们都不在一个文件里?是的,它们是定义在两个不同的类文件中的,我们看一下文件结构:

image.png

是在同一个文件夹下的,其实正式点说它们是在同一个下的,至于这一块的细节在后面关于封装的部分再来解释,我们这里的重点还是继承,只要知道同一个现在Apple.java是可以直接引入Fruit下的类就可以了。

在这里我们还看不出java的类继承和javascript有什么区别,毕竟这还是一个非常基础的例子,我们来继续探索。

(1)构造函数不能够被继承

首先,我们要明确一点,java的子类是不能继承父类的构造函数的。这听起来没什么稀奇的,因为父类的构造函数是父类的实例对象创建时候自动调用的,自然不应该被继承。只是java里父类的构造函数名是和类名必须是相同的,这让它看起来就像一个实例方法一个可用被继承。而javascript里的构造函数是用统一的constructor(),看起来就是一副不能被继承的样子。因此下面的例子会因为尝试调用父类的构造函数而报错。

public class Apple extends Fruit {
    public static void main(String[] args){
        Apple a = new Apple();
        a.weight = 300;
        a.info();
        // 报错
        a.Fruit();
    }
}

(2)java的类只能有一个直接父类,也就是说它是单继承的。

让我们回顾一下javascript呢?由于javascript是基于原型继承的,所以它是可以继承多个原型的,当然这需要使用ES5的写法。

function A(){};
A.prototype = {a:"a"}
function B(){};
B.prototype = {b:"b"}
function C(){}
Object.assign(C.prototype, A.prototype, B.prototype)
console.log(C.prototype); // {a: 'a', b: 'b'}

上面的例子就使C继承了A和B的原型,虽然是一个很简略的版本,但是说明了这个问题。而ES6使用class的语法糖呢?能够class A extends B, C吗?答案是否定的。因此下面的写法会报错,语法上都是错误的。

class A{}
class B{}
class C extends A,B{}

(3)构造函数

子类虽然不能继承父类构造器,但是却可以调用父类构造器,javascript里也是可以的,使用super,巧的是,java里使用的关键字也是super

在父类BaseClass.java中定义一个成员变量a

public class BaseClass {
    public int a = 5;
}

然后在子类BaseClass.java中使用super直接调用所继承自父类BaseClass.java的成员变量a

public class SubClass extends BaseClass {
    public int a = 7;
    public void accessBase(){
        System.out.println(super.a);
    }
    public static void main(String[] args){
        SubClass s = new SubClass();
        s.accessBase(); // 5
    }
}

上面的代码中,子类SubClass自身定义了一个成员变量a,但它如果要调用继承自父类的成员变量,就需要使用super进行区别。

成员变量的查找机制

那么我们要想一个问题,假如类C继承类B,类B又继承类A,那么类C会继承类A吗?直觉上是可以的,事实也是如此:

A.java

public class A {
    public int a = 1;
}

B.java

public class B extends A {
    public int b = 2;
}

C.java

public class C extends B {
    int c = 3;
    public void accessBandC(){
        System.out.println(super.a);
        System.out.println(super.b);
        System.out.println(this.c);
    }
    public static void main(String[] args){
        C c = new C();
        c.accessBandC(); // 1 2 3
    }
}

C.java也可以完全写成这样:

public class C extends B {
    int c = 3;
    public void accessBandC(){
        System.out.println(a);
        System.out.println(b);
        System.out.println(this.c);
    }
    public static void main(String[] args){
        C c = new C();
        c.accessBandC(); // 1 2 3
    }
}

如果子类里没有包含和父类同名的成员变量,那么在子类实例方法中访问该成员变量时,则无须显示使用super作为调用者。

上面的例子虽然简单,但却说明了在java中子类查找成员变量的顺序,以查找成员变量a为例:

(1)首先查找该方法中是否有名为a的局部变量

(2)查找当前类中是否有包含名为a的成员变量

(3)查找a的直接父类中是否包含名为a的成员变量,依次向上查找所有父类,一直到java.lang.Object,如果还找不到a,系统出现编译错误。

这多么像javascript中原型链的查找过程,一路向上查找到顶级对象Object,再到最后的万物皆空null。我们用一个示例来简单演示一下javasript里的原型链查找:

javascript原型链查找示例:

class A{};
A.prototype.a = 1;
class B extends A{};
B.prototype.b = 2;
class C extends B{
    c = 3;
    accessSuperClass(){
        // 这里的supe是不能够像java一样省略的
        console.log(super.a); // 1
        console.log(super.b); // 2
        console.log(this.c);  // 3
    }
};

let c = new C();
c.accessSuperClass();

上面的代码也很明显的体现了javascript中的原型链的查找机制,但实际上与java是有本质的区别的。因为javascript的原型查找完全是利用指引(指针)来查找的,它不会复制父类定义在原型上的属性和方法,而且因为是指向,所以子类对父类原型的修改就真正的改变了父类的原型。

而在java中,当我们创建一个子类对象时,系统不仅会被该类中定义的实例变量分配内存,同时也会为子类从父类继承得到的所有你实例变量分配内存,即使子类定义了与父类中同名的实例变量。因此在上面C.java的代码中,当对象c被创建时,因为他有两个父类(一个直接父类B,还有一个间接父类A),父类A中定义了一个实例变量,父类B中定义了一个实例变量,子类C本身自己定义了1个实例变量,因此对象c将保存1 + 1 + 1 个实例变量。

super与父类构造器

在javascript中,如果子类要显式声明构造函数,那么构造函数体内第一行必须是执行super(),用以先执行父类的构造函数,来看下面的例子:

class A{
    constructor(a,b){
        this.a = a;
        this.b = b;
    }
}
class B extends A{
    constructor(a,b,c){
        super(a,b);
        this.c = c;
    }
}
let b = new B('a','b','c');
console.log(b.a,b.b,b.c); // a b c

如果企图在super()之前执行别的代码,会报错:

class B extends A{
    constructor(a,b,c){
        this.c = c;
        super(a,b);
    }
}
// ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

毫不意外的是,在Java里也是相同的。使用super调用父类构造函数也必须出现在子类构造器执行体的第一行

Base.java

public class Base {
    public String name;
    public Base(String name){
        this.name = name;
    }
}

Sub.java

public class Sub extends Base{
    public int age;
    public Sub(String name,int age){
        super(name);
        this.age = age;
    }
    public static void main(String[] args){
        Sub s = new Sub("flten", 25);
        // 输出: name: flten ,age:25
        System.out.println("name: " + s.name + ",age:" + s.age);
    }
}

但如果是这样呢?A继承了B,B继承了C,那么B需要在构造器中先调用一次C的构造器,A需要在构造器中先调用一次B的构造器,那么创建一个A的对象时,需要先后执行C、B、A三个构造器吗?

答案是肯定的,确实是要执行前面所有父类(直接和间接)的构造器。

A.java

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

B.java

public class B extends A {
    public B(){
        System.out.println("B");
    }
}

C.java

public class C extends B {
    public C(){
        System.out.println('C');
    }
    public static void main(String[] args){
        C c = new C();
    }
}

/*
    输出:
    A
    B
    C
*/

而在javascript里呢,要求子类构造函数的第一行必须是显示调用super()来初始化父类构造函数:

class A{
    constructor(){
        console.log('A')
    }
}

class B extends A{
    constructor(){
        super();
        console.log('B')
    }
}

class C extends B{
    constructor(){
        super();
        console.log('C')
    }
}

let c =  new  C();

/*
    输出:
    A
    B
    C
*/

抽象类

在javascript里是没有抽象类的,如果想实现抽象类,我们只能用new.target来进行模拟,使其不能初始化。虽然目前的javascript里不直接支持抽象类,但是这样的模拟也是别有一番风趣。

class AbstractClass{
    constructor(){
        if(new.target === AbstractClass){
            throw new Error('此类为抽象类');
        }
    }
}

let c = new AbstractClass(); // Error: 此类为抽象类

继承后再进行实例化就不会报错。

class AbstractClass{
    constructor(){
        if(new.target === AbstractClass){
            throw new Error('此类为抽象类');
        }
    }
}

class SubClass extends AbstractClass{}
let c = new SubClass();

java是支持抽象类的,那么就需要一个专门的关键字来区分普通类和抽象类,这个关键字是abstract

关于抽象类的规则也很简单:

(1)抽象类必须使用abstract修饰符来修饰

(2)抽象方法必须使用abstract修饰符来修饰,且抽象方法不能有方法体

(3)抽象类不能被实例化,即不能通过new来创造实例

(4)抽象类可以没有抽象方法,但即便如此也不能 被实例化

(5)必须被定义为抽象类的情况:

a.直接定义了一个抽象方法

b.继承了一个抽象父类,但是它没有完全实现抽象父类所包含的抽象方法

c.实现了一个接口,但是没有完全实现接口包含的抽象方法

总结成一句话就是,它还有抽象方法没有实现,无论这个方法是自己定义的,还是继承自抽象类或接口的。

public abstract class Shape {
    private String shape;
    public String getShape(){
        return this.shape;
    }
    public abstract double calPerimeter();
    public Shape(String shape){
        this.shape = shape;
    };
}

上面的代码声明了一个抽象类,它有一个需要子类必须实现的calPerimeter抽象方法,还有一个普通的成员方法getShape,可以直接被子类继承使用。

public class Triangle extends Shape {
    private double a;
    private double b;
    private double c;

    public Triangle(double a, double b, double c, String shape){
        super(shape);
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public double calPerimeter(){
        return a * b * c;
    }

    public static void main(String[] args){
        Triangle t = new Triangle(1.0,2.2,3.2, "Triangle");
        System.out.println(t.calPerimeter()); // 7.040000000000001
        System.out.println(t.getShape()); // Triangle
    }
}

抽象的作用就是从多个具有相同特征的具体类中抽象出来父类,具有更高层次的抽象,这个抽象类作为子类的模板,可以避免子类设计的随意性。

接口

即有抽象类作为类的抽象,为何还需要接口呢?是因为抽象类还不够彻底吗?嗯,是的,毕竟抽象类只是作为一种模板,它提供了可以被继承的普通成员属性/方法,以及必须被实现的抽象方法,如果更抽象一点呢?那自然是不能包含任何具体实现的成员方法,只是提供一些声明,作为一种规范,一句话:怎么实现我不管,反正就得根据规定实现。

继续更新java面向对象...... 接口