Skip to content

第一部分-面向对象


在 Java 中,类和对象是最核心的一组概念。但它本质上不只是语法,而是一种看待问题和组织代码的方式

我们写程序,本质是在“描述世界”,而类,就是我们对某一类事物的抽象定义。可以用一个更贴近直觉的例子来理解:

  • 类像是“狼的设定图”
  • 对象才是“真正存在的一只只狼”

设定图里写着:有毛色、年龄、体型,也定义了会嚎叫、会奔跑。但设定图本身不会动,只有真正创建出来的狼才是活的。

类的组成

在 Java 中,一个类主要由两部分组成:

  • 成员变量:描述“这只狼有什么特征”
  • 成员方法:描述“这只狼能做什么”

比如:

java
class Wolf {
    int age;        // 年龄
    String color;   // 毛色

    public void howl() {
        System.out.println("嗷——");
    }
}

这里把“狼”这个概念拆成了一组 数据 + 行为

类的定义,本质上就是在用程序的方式描述一类事物:它有什么,它能做什么。

从类到对象

有了类这个“设定图”,就可以创建具体的对象。基本用法如下:

java
// 创建对象
类名 对象名 = new 类名();

// 调用方法
对象名.方法名(参数);

// 访问属性
对象名.属性名;

套用我们刚刚创建的 Wolf 类 :

java
Wolf wolf = new Wolf();

这行代码做了两件事:

  • 创建了一只真正的“狼对象”(数据本体)
  • 定义了一个变量 wolf,用来后续操作这只狼

如果此时打印这个对象名 wolf ,就能看到其地址值 Wolf@3f99bd52

这个内容并不是对象的具体数据,而是一个地址标识。

  • Wolf 表示类名(对象属于哪一类)
  • @ 后面的内容,是对象在内存中的标识值(类似地址)

如果是带包的完整写法,这一部分还会显示全类名(包名 + 类名),例如:

java
com.example.demo.Wolf@3f99bd52

用于区分不同位置、不同来源的类。

类和对象内存

创建对象之后,就可以通过变量 wolf 来访问对象中的属性和方法:

java
wolf.age = 3;
wolf.howl();

程序里操作对象时,表面上是在用 wolf,实际上是通过 wolf 去找到后面 new Wolf(); 出来的对象。

从内存角度来看,可以简单理解为:

  • 栈内存中存放变量 wolf
  • 堆内存中存放真正创建出来的对象
  • wolf 里保存的是这个对象的位置
text
wolf(栈) → 狼对象(堆)

所以,wolf.age = 3;wolf.howl(); 这样的写法,本质上都是先通过 wolf 找到对象,再去访问对象中的数据或调用对象的方法。

这样设计后,变量本身只需要保存一个位置,不需要把整个对象都放进去。
对象的数据统一放在堆内存中,而变量只负责记录“找到它的方法”。

这就是类和对象在内存中的基本关系。

成员变量与局部变量

前面在 Wolf 类里写过这样的内容:

java
class Wolf {
    int age;
    String color;

    public void howl() {
        System.out.println("嗷——");
    }
}

这里的 agecolor,就属于成员变量

  • 成员变量,描述的是“对象长期拥有的状态”
  • 局部变量,描述的是“某段代码临时使用的数据”
区别成员变量局部变量
定义位置类中,方法外方法中、方法参数中
属于谁属于对象属于方法
默认值有默认值没有默认值,必须先赋值再使用
生命周期随对象创建而存在,随对象消失而消失随方法调用而创建,方法结束就消失
存储位置一般在堆中,跟着对象一起存在一般在栈中,方法运行时临时存在
作用范围在整个类中都可以使用只能在当前方法或代码块中使用

这个区别非常重要。别看它们都叫“变量”,但职责根本不是一回事。

java
class Wolf {
    int age = 3;           // 成员变量
    String color = "灰色"; // 成员变量

    public void run() {
        int speed = 20;    // 局部变量
        System.out.println("狼正在奔跑,速度是:" + speed);
    }
}

成员变量就算你不手动赋值,Java 也会先给它默认值;但局部变量没有这个待遇,必须先赋值才能使用。

this 关键字

有时候我们会遇到成员变量和局部变量重名,先看这个例子:

java
class Wolf {
    int age = 3;

    public void setAge(int age) {
        age = age;
    }
}

这段代码看起来像是在“把参数赋值给成员变量”,但实际上什么都没发生

因为这里的 age

  • 方法参数里的 age局部变量
  • 方法内部写的 age,优先访问的也是局部变量

也就是说,这一行:

java
age = age;

本质是“把局部变量赋值给自己”,成员变量根本没被改到。问题就出在:两个同名变量冲突了,但程序默认只认离得更近的那个(局部变量)。

这时候,this 就该登场了。

java
class Wolf {
    int age = 3;

    public void setAge(int age) {
        this.age = age;
    }
}
  • this.age 表示“当前对象的成员变量 age”
  • 右边的 age 是方法参数(局部变量)

这样就把两者明确区分开了。可以把 this 理解成:“当前这只对象自己” 。
也就是:this ≈ 当前对象

基本用法

this 主要用来调用“本类中的成员”,包括变量和方法:

java
this.成员变量
this.成员方法()

例如:

java
class Wolf {
    int age = 3;

    public void show() {
        this.howl();
        System.out.println(this.age);
    }

    public void howl() {
        System.out.println("嗷——");
    }
}

核心就一个场景:当成员变量和局部变量重名时,必须用 this 区分

java
public void setAge(int age) {
    this.age = age;
}

不写 this,你就永远改不到成员变量。如果没有重名问题,this. 是可以不写的。

java
class Wolf {
    int age = 3;

    public void show() {
        System.out.println(age); // 默认就是 this.age
        howl();                 // 默认就是 this.howl()
    }

    public void howl() {
        System.out.println("嗷——");
    }
}

这里其实编译器帮你做了补全:

java
System.out.println(this.age);
this.howl();

只是平时可以省略。

this 的作用就是:明确指出“当前对象的成员”,尤其是在变量重名时用来区分成员变量和局部变量。

如果再换成更直观一点的理解:

  • 不写 this:优先用“眼前这个变量”(局部变量)
  • 写了 this:强行指向“这只对象身上的那个变量”(成员变量)

封装

封装(Encapsulation)是面向对象的第一大特性,简单来说,就是:

合理隐藏,合理暴露

  • 把相关的数据和操作这些数据的方法打包在一起
  • 对外隐藏实现细节,只公开必要的接口

就像手机一样,只需要知道怎么点屏幕,不需要知道内部电路怎么工作。

从过程式到面向对象的转变

为什么需要封装?让我们用一个计算薪资的例子来对比:

过程式风格

先来看一种更偏过程式的写法:

java
public class SalaryDemo {
    public static void main(String[] args) {
        // 数据散落各处
        int baseSalary = 5000;
        int bonus = 10000;
        char grade = 'B';

        // 独立的方法处理数据
        int salary = calculateSalary(baseSalary, bonus, grade);
        System.out.println(salary);
    }

    public static int calculateSalary(int baseSalary, int bonus, char grade) {
        double rate = switch (grade) {
            case 'A' -> 1.0;
            case 'B' -> 0.8;
            case 'C' -> 0.6;
            case 'D' -> 0.4;
            default -> 0;
        };
        return baseSalary + (int)(bonus * rate);
    }
}

在这种写法中,数据和逻辑是分开的

baseSalarybonusgrade 这些数据放在外面,计算工资的方法单独写在另一边。每次调用时,都要把这些参数一个个传进去。

代码虽然直白,但不够紧凑,维护起来容易出错。

面向对象风格

换成面向对象的写法,思路就不一样了。

  1. 先把员工相关的数据和行为放进同一个类里:
java
// 定义员工类
public class Employee {
    // 数据(属性)和方法放在一起
    int baseSalary;
    int bonus;

    // 计算薪资的方法直接访问类内部的数据
    public int calculateSalary(char grade) {
        double rate = switch (grade) {
            case 'A' -> 1.0;
            case 'B' -> 0.8;
            case 'C' -> 0.6;
            case 'D' -> 0.4;
            default -> 0;
        };
        return baseSalary + (int)(bonus * rate);
    }
}

这里最关键的变化在于:原本分散在外面的数据和处理逻辑,被放进了同一个对象里。

不过,这一步还只是把“员工”这个类型定义出来,还没有真正产生一个可以使用的员工对象。
也就是说,类写完之后,事情还没结束,还得继续创建对象并使用它。

  1. 再使用这个员工类:
java
public static void main(String[] args) {
    // 创建员工对象
    Employee employee = new Employee();

    // 设置属性值
    employee.baseSalary = 5000;
    employee.bonus = 10000;

    // 调用方法计算薪资
    int salary = employee.calculateSalary('A');
    System.out.println(salary);
}

这种写法看起来更像是在和对象打交道,而不是在手动拼装一堆参数。调用者不需要操心任何细节,只需要设置参数,然后问对象:"你自己的工资是多少?"

所以,封装不只是“把东西塞进类里”这么简单。更准确地说,它是在做两件事:

  • 让数据和操作这些数据的行为尽量放在一起
  • 让外部通过规定好的方式使用对象,而不是直接干预对象内部细节

说到底,封装的目的就是:

让使用者只看到"操作界面",而不用在意"内部电路"

这不仅仅是代码风格问题,而是软件设计中组织复杂性的一种武器。

Getter 和 Setter

然已经把数据和行为放进了同一个类里,但还有一个问题:对象内部的数据依然可以被外部随意更改,甚至出现不合理的值。

比如下面这样:

java
employee.baseSalary = -5000;

从语法上看是没问题,但从业务上看就很离谱了。基本工资显然不应该是负数。

光把数据放进类里还不够,如果外部依然能直接乱改,那封装就还没做完整。比较常见的做法是两步:

  1. 将属性设为私有(private),不让外部直接访问
  2. 提供公开的方法来访问和修改这些私有属性
java
public class Employee {
    // 私有化属性,外部不能直接访问
    private int baseSalary;
    private int bonus;

    // 提供设置基本工资的方法,可以添加验证逻辑
    public void setBaseSalary(int baseSalary) {
        // 添加验证逻辑
        if (baseSalary < 0) {
            System.out.println("基本工资不能为负数!");
            return;
        }
        // 通过this关键字区分成员变量和参数
        this.baseSalary = baseSalary;
    }

    // 提供获取基本工资的方法
    public int getBaseSalary() {
        return baseSalary;
    }

    // 同样方式处理bonus属性
    // ...
}

baseSalaryprivate 修饰后,外部就不能再直接赋值,而是只能通过 setBaseSalary() 来修改。这样一来,我们就可以在方法里加上判断逻辑,把不合理的数据拦住。

这就是 getter 和 setter 的意义,给字段的访问加上一道控制。

  • getter 用来获取数据
  • setter 用来修改数据

顺带一提,这里的:

java
this.baseSalary = baseSalary;

左边的 this.baseSalary 是成员变量,右边的 baseSalary 是方法参数。this 表示当前对象,用来区分成员变量和同名的局部变量。

字段与属性

写到这里,有两个词很容易混:字段(Field)属性(Property)

字段(Field)

先说字段。字段就是类中直接声明的成员变量,例如:

java
public class Student {
    private String name;    // 这是字段
    private int age;        // 这也是字段
    public String school;   // 这还是字段
}

这里 nameageschool 都是字段。只要是类里定义的成员变量,都可以叫字段。

属性(Property)

站在“对外访问”的角度去看,通常把有对应 getter / setter 方法的字段,称为属性。例如:

java
public class Student {
    private String name;  // 字段

    // getter 和 setter 方法
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

这里的 name 首先是一个字段;
同时,因为它有 getName()setName() 这组访问方法,所以通常也会把它看作一个属性。

所以这两个词的关系可以简单记成:

  • 字段:类里定义的成员变量
  • 属性:对外提供访问方式的字段

不是所有字段都会被当成属性,但属性背后一般都对应着字段。

标准 JavaBean

当一个类专门用来封装数据,并且写法存在一定规范时,这种类通常就叫 JavaBean。

它本质上还是一个普通的 Java 类,只是大家约定了一套更标准的写法。要求有如下三个:

  1. 字段使用 private 修饰
  2. 提供对应的 public getter / setter 方法
  3. 提供一个无参构造方法

一个标准的 JavaBean 示范:

java
public class Student {
    // 1. 私有字段
    private String name;
    private int age;

    // 2. 无参构造器
    public Student() {}

    // 3. 公共访问器
    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;
    }
}

这个类只是把“私有字段 + 公共访问方法 + 无参构造器”这套写法固定下来。
所以你可以把 JavaBean 理解成一种更规范的封装写法,只是更符合约定,特别适合拿来表示一组数据。

构造方法

当我们使用new关键字创建对象时,其实是在调用 构造方法 来创建对象:

java
Wolf wolf = new Wolf(); // 调用了构造方法

所谓构造方法,就是把对象出生时要执行的一段初始化逻辑。比如,一只狼刚被创建出来的时候,可以顺手给它一些初始状态:

java
public class Wolf {
    int age;
    String color;

    public Wolf() {
        age = 1;
        color = "灰色";
    }
}

当你 new Wolf(); 时,实际上会发生两件事:

  1. 在堆中为对象分配空间
  2. 成员变量先进行默认初始化
  3. 执行构造方法完成进一步初始化

也就是说,这只狼从类变为对象时,就已经有了默认的年龄和毛色。

无参构造方法

如果你不写什么构造方法,类同样能够创建对象。是因为 Java 会帮你补一个默认的构造方法:

java
public class Wolf {
    // Java 会自动生成如下的构造方法:
    // public Wolf() { }
}

这样你依然可以正常创建对象。

但要注意:
一旦你自己写了构造方法,Java 就不会再帮你补默认的了。

所以如果你写了别的构造方法,还需要保留无参构造的创建方式,就必须自己显式写出来。

有参构造方法

刚才这个写法有一个问题:

Java
public Wolf() {
    age = 1;
    color = "灰色";
}

这个模板已经被赋予了初始值。因此当你每次 new 来创建出来的狼,数据都是一样的。
这显然不足以应对现实多变的情况,这时候就可以让构造方法接收参数:

java
public class Wolf {
    int age;
    String color;

    public Wolf(int age, String color) {
        this.age = age;
        this.color = color;
    }
}

再创建对象时,就可以直接把数据带进去:

java
Wolf wolf = new Wolf(3, "黑色");

这样,在需要创建多个对象时,比 对象.属性 的初始化方式方便很多。对象在创建的同时就完成了初始化,代码会更集中,也更不容易漏掉某个字段。

至此,再来总结构造方法的特征:

  • 方法名必须和类名完全一致(包括大小写)
  • 没有返回值类型(连 void 都不能写)
  • 不能通过 return 返回数据

构造方法会在每次 new 对象时,都会自动执行一次。

java
public class Wolf {
    public Wolf() {}        // 构造方法
    public void Wolf() {}   // 普通方法(不推荐这样写)
}

权限修饰符

在前面做封装时,我们把字段改成了 private

java
private int baseSalary;

是为了让这个数据能不能被外部直接访问。在 Java 中,这种控制访问范围的方式,就叫权限修饰符

Java 提供了四种权限修饰符:

修饰符说明
private只能在当前类中使用
默认(不写)同一个包中可以使用
protected子类中也可以使用
public所有地方都可以使用

private 最严格,public 最开放,中间逐渐放开。目前只用简单了解这两条

  • 字段一般用 private(不让外部乱改)
  • 对外提供的方法用 public

Static 关键字

前面在学习类和对象时,我们接触到的变量,大多都是“属于对象”的。

实例变量与类变量

如果一个变量没有 static,那它就是普通成员变量,也被称作 实例变量,实例变量是属于对象本身的。

java
public class Wolf {
    int age;
}

这里的 age 就是实例变量,属于每一只具体的狼。如果创建两个对象,它们都会有各自的 age

像年龄、名字、毛色这些数据,本来就是每个对象各自不同的状态,自然应该各存各的。
但有些数据并不是每个对象都要单独保存一份。

比如狼的种类,如果一批对象本来就都属于同一种狼,那么“种类”这个信息对每个对象来说都是一样的。
这时候如果还放在每个对象里,就会重复。

这时候就可以用 static 修饰这个变量,让同类的对象共享:

java
public class Wolf {
    static String species = "雪原狼";
}

这里的 species 就不再属于某一只具体的狼,而是属于 Wolf 这个类。
不管创建多少个 Wolf 对象,这个变量都只有一份,大家共用。

这种被 static 修饰的变量,就被称为 类变量

  • 实例变量:属于对象,每创建一个对象,就会有一份自己的数据
  • 类变量:属于类本身,所有对象共享同一份数据

所以,static 的作用之一,就是把变量从“属于对象”变成“属于类”。

实例方法与类方法

变量有属于对象的,也有属于类的,方法也一样。前面接触到的方法,大多都是属于对象的,调用前通常要先创建对象:

java
public class Wolf {
    public void howl() {
        System.out.println("嗷——");
    }
}

// 通过类创建出具体的对象再调用
Wolf wolf = new Wolf();
wolf.howl();

这是我们之前常用的方式,也被称之为 实例方法
实例方法依附于对象,调用前必须先 new。它既能访问实例变量,也能访问类变量,并支持使用 this 引用当前对象。

因为 howl() 这种行为,是由某一只具体的狼发出的。它依赖对象,所以应该属于对象。
但不是所有方法都一定要依赖对象。

比如现在有一个需求:写一个“攻击力计算器”,专门根据等级计算攻击力。
先按前面学过的方式写,可能会是这样:

java
public class AttackCalculator {
    public int calcAttack(int level) {
        // 计算攻击力的方法
        return 攻击力值;
    }
}

因为方法属于对象,那么首先得创建对象才能使用:

java
AttackCalculator calculator = new AttackCalculator();
int attack = calculator.calcAttack(5);
System.out.println(attack);

这段代码当然能运行,但这里的 calculator 本身并没有什么对象状态。
我们创建它,不是为了保存数据,而只是为了调用一次 calcAttack() 方法。

明明只是想用一个功能,却被迫先造出一个对象,这就有点别扭了。

问题的根源在于:calcAttack() 并不依赖某个具体对象。
它只需要一个 level,就可以完成计算。

像这种“不依赖对象状态,只负责完成一段独立逻辑”的方法,就更适合写成 类方法,也就是用 static 修饰的方法。

java
public class AttackCalculator {
    public static int calcAttack(int level) {
		    // 计算攻击力的方法
        return 攻击力值;
    }
}

这样一来,调用时就不需要再 new 对象了:

java
int attack = AttackCalculator.calcAttack(5);
System.out.println(attack);

这就是 static 修饰方法后的变化:

  • 实例方法:属于对象,调用前通常要先创建对象
  • 类方法:属于类本身,可以直接通过类名调用

所以,如果一个方法不依赖对象状态,只是提供一段通用规则或功能,就可以考虑写成 static

重识 main 方法

这时候再回过头看看最初的 main 方法:

java
public static void main(String[] args) {
}

之所以要写成 static,就是因为程序启动时,JVM 需要直接通过类找到入口方法并执行,让 main 方法可以脱离对象存在。

  • public:main 方法要被 JVM 调用,所以访问权限必须足够大。
  • void:表示这个方法没有返回值。
  • main:main 不是关键字,而是一个普通的方法名。但这个名字又普通的比较特殊,因为 JVM 会把它识别为程序入口方法的名字。

正因为 main 是静态方法,所以 main 中没有 当前对象。因此,如果不手动创建对象,就只能直接调用同样不依赖对象的静态方法。

例如下面这样就可以直接调用:

java
public class Demo {
    public static void main(String[] args) {
        show();
    }

    public static void show() {
        System.out.println("测试");
    }
}

如果 show() 不是静态的,那就必须先创建对象才能调用。

String[] args 是 JVM 在启动程序时,传给 main 方法的“命令行参数”。

作为拓展,可以看一个例子。假设在命令行中这样运行程序:

java
java Demo hello world 123

那么 JVM 可以理解成是在调用:

java
Demo.main(new String[]{"hello", "world", "123"});

命令行中写在类名后面的内容,会被按顺序放进 args 数组中。
打印出来看看:

java
public class Demo {
    public static void main(String[] args) {
        System.out.println("第一个参数:" + args[0]);
    }
}

就会在控制台输出:

第一个参数:hello

所以,String[] args 的作用就是:让程序在启动时能够接收外部输入。

不过在日常开发中,这个参数通常用得不多。因为现在大多数程序都是直接在 IDE 中运行,很多时候并不会专门通过命令行传入参数。

工具类

如果一个类里的方法大多都是这种“不依赖对象、直接通过类名调用”的方法,那这个类往往就不是拿来描述某个具体事物的,而是拿来提供一组通用功能的。

这种类,通常就会被整理成 工具类

例如这段伪代码:

java
public class MathUtil {
    public static int add(int a, int b) {
        // 返回两个整数相加的结果
    }

    public static int max(int a, int b) {
        // 返回两个整数中较大的那个
    }
}

使用时直接通过类名调用即可:

java
int sum = MathUtil.add(5, 3);
int bigger = MathUtil.max(10, 20);

不过,为了与普通类区分,工具类都会被命名成 XxxUtil/XxxHelper 等。名字本身不是语法要求,但最好一眼就能看出“这是一个拿来直接用功能的类”。

除此之外,为了避免别人误把工具类当成普通类去创建对象,通常还会把构造方法私有化。

java
public class MathUtil {
    private MathUtil() {}
    public static final double PI = 3.14159;

    public static int add(int a, int b) {
        // 返回两个整数相加的结果
    }

    public static int max(int a, int b) {
        // 返回两个整数中较大的那个
    }
}

如果有一些固定不变的数据,也常常写成 static final

至此,工具类的常见设计可以概括成:

  • 大多数方法用 static 修饰
  • 构造方法私有化,防止外部创建对象
  • 类中通常放的是通用功能,而不是对象状态

这里有一个小 tips,辨识类的修饰符和构造方法的修饰符:

java
public class MathUtil {
}

此处的 public 类是允许“别人能用这个类”。

java
public class MathUtil {
    private MathUtil() {}
}

而类中的 private 构造方法是限制“不能 new”。

评论