类与对象

前面写 Java 程序时,我们已经用过变量和方法。到了面向对象这一部分,可以进一步把一类事物的属性和行为放在一起描述,再根据这份描述创建具体的对象。

我觉得可以先把类理解成一张设计图,把对象理解成按照设计图创建出来的具体事物:

  • 类(class):描述一类事物共同的属性和行为。
  • 对象(object):根据类创建出来的具体实例。

例如,手机都有品牌、价格等属性,也能打电话、玩游戏。我们可以先定义一个 Phone 类,再根据它创建具体的手机对象。

定义一个类

在类中,常见的内容包括成员变量和成员方法:成员变量描述属性,成员方法描述行为。构造方法、代码块、内部类等内容也可以写在类里,后面会逐步接触。

先创建 Phone.java,定义一个手机类:

java
public class Phone {
    //属性(成员变量)
    String brand;
    double price;
    //行为(方法)
    public void call() {
        System.out.println("打电话");
    }

    public void play_game() {
        System.out.println("玩游戏");
    }
}

这里的 brand、price 是成员变量,call()、play_game() 是成员方法。此时我们只是定义了手机这类事物,还没有创建出某一部具体的手机。

创建和使用对象

定义好类之后,就可以使用 new 创建对象。格式为:

java
类名 对象名 = new 类名();

例如:

java
Phone p = new Phone();

创建对象后,可以通过 . 访问它的成员:

  • 访问属性:对象名.成员变量
  • 调用方法:对象名.方法名()

我们把创建对象、设置属性和调用方法放在一起看:

java
public class Test1 {

    public static void main(String[] args) {
        //创建手机对象
        Phone p1 = new Phone();
        //给p1这个对象赋值
        p1.brand = "小米";
        p1.price = 1999.9;
        //获取p1对象的值
        System.out.println(p1.brand);
        System.out.println(p1.price);
        //调用手机类中的方法
        p1.call();
        p1.play_game();
    }
}

运行结果:

纯文本
小米
1999.9
打电话
玩游戏

这里的 p1 引用着新创建的 Phone 对象。我们给这部手机设置了品牌和价格,再调用它的两个方法。以后再创建一个 Phone 对象,就可以为它设置另一组属性,而不需要重新定义一个手机类。

类的命名与文件组织

编写类时,还有几个常用约定:

  • 类名建议见名知意,采用大驼峰命名法,例如 Phone、Student。
  • 一个 .java 文件可以包含多个顶层类,但最多只有一个 public 顶层类。如果存在这样的类,文件名必须与它的类名一致,例如 public class Phone 应放在 Phone.java 中。
  • 学习时常把存放数据的类和带 main() 的测试类分开:前者描述事物,后者负责创建对象、赋值、调用方法等操作。

比如学生管理系统中,可以定义 Student、Teacher 等类。以后增加一名学生时,只需要创建一个新的 Student 对象,再为姓名、年龄等属性赋值,不必为每名学生单独写一个类。

成员变量的默认值

成员变量的基本定义格式是 修饰符 数据类型 变量名 = 初始值;。如果没有显式指定初始值,成员变量会获得对应类型的默认值。

数据类别常见类型默认值
整数类型byte、short、int、long0
浮点类型float、double0.0
字符类型char\u0000(空字符)
布尔类型booleanfalse
引用类型类、接口、数组,如 Stringnull

这里的默认值适用于成员变量和数组元素。方法中的局部变量不一样,使用前需要先赋值,后面会专门对比。

封装:从学生年龄开始理解

面向对象常说的三大特性是封装、继承和多态,这里先记录封装。

我的理解是:一个对象代表什么,就应该组织好与它相关的数据和行为。外部需要做什么,可以通过对象提供的方法完成,而不一定要直接操作它内部的所有变量。

为什么需要 private

假设我们定义一个 Student 类,保存学生的姓名和年龄。先不做任何访问限制,直接给年龄赋值:

java
public class Test1 {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.age = -1;
        System.out.println(s1.age);
    }
}

class Student {
    String name;
    int age;
}

这段代码能正常运行,但问题也很明显:s1.age = -1 可以把年龄设成不合理的负数。每次都让使用对象的代码自己检查年龄,既麻烦,也容易遗漏。

这时就可以用到 private:

  • private 是 Java 的一种访问权限修饰符。
  • 它可以修饰成员变量和成员方法。
  • 被 private 修饰的成员,只能在声明它的类内部直接访问。

例如把 Student 中的 int age; 改为 private int age; 后,Test1 类就不能再通过 s1.age = -1 直接修改它;这样写会出现编译错误。

不过,private 的作用只是限制外部直接访问,并不会自动判断年龄是否合理。既然不允许外部直接修改,又该怎样正常设置和查看年龄呢?

使用 set 和 get 方法

可以在 Student 类中提供公共方法:setAge() 负责设置年龄,getAge() 负责读取年龄。这样就能把校验逻辑集中放在 setAge() 中,而不用每次在外部重新判断。

java
public class Test1 {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.setAge(-1);
        System.out.println(s1.getAge());
        s1.setAge(100);
        System.out.println(s1.getAge());
    }
}

class Student {
    String name;
    private int age;

    public void setAge(int a) {
        if (a >= 0 && a < 150)
            age = a;
        else
            System.out.println("年龄非法");
    }

    public int getAge() {
        return age;
    }
}

运行结果:

Shell
年龄非法
0
100

第一次传入 -1,不满足 0 <= a < 150,所以打印“年龄非法”,age 仍然保持 int 的默认值 0;第二次传入 100,校验通过,年龄就被成功修改了。

这样一来,不管谁调用 setAge(),都会经过同一处校验。这比公开成员变量、让外部代码随意赋值更容易管理数据。

this 关键字

刚才的 setAge() 使用形参 a 来区分成员变量 age。但如果把形参也命名为 age,看起来会更直观:

java
class Student {
    String name;
    private int age;

    public void setAge(int age) {
        if (age >= 0 && age < 150)
            age = age;
        else
            System.out.println("年龄非法");
    }

    public int getAge() {
        return age;
    }
}

这里有个容易搞混的地方:age = age; 不会编译报错,但它也不会修改对象的成员变量。因为方法的形参和成员变量同名时,直接写 age 会优先指向形参;这一句只是把形参的值赋给它自己。

要明确修改当前对象的成员变量,可以写成:

java
this.age = age;
  • this.age:当前对象的成员变量 age。
  • 右侧的 age:传进来的形参 age。

所以,this 可以帮助我们在同名变量之间明确指出当前对象的成员。

这个现象也可以结合“就近原则”理解。看下面的例子:

java
public class Test1 {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.method();
    }
}

class Student {
    String name;
    private int age;
    public void method() {
        int age = 10;
        System.out.println(age);
        System.out.println(this.age);
    }
}

运行结果:

纯文本
10
0

方法里新定义了局部变量 age = 10,直接打印 age 得到的是局部变量的值;使用 this.age,访问的则是对象中的成员变量。成员变量没有主动赋值,因此仍然是默认值 0。

构造方法

前面的例子都是先创建对象,再给成员变量赋值。那么,能不能在创建对象的时候就把初始数据传进去?这就需要用到构造方法,也叫构造器。

构造方法的名称必须与类名相同,并且没有返回值类型,连 void 都不写。它通常用来完成对象创建时的初始化工作。

格式可以简单理解为:

java
class 类名 {
    类名(参数列表) {
        // 创建对象时要执行的代码
    }
}

使用 new 创建对象时,会执行对应的构造方法。创建几个对象,就分别执行几次构造过程。构造方法不能像普通成员方法那样,通过 对象名.方法名() 手动调用,也不能使用 return 返回一个具体结果。

空参构造

空参构造就是不需要传入参数的构造方法。比如下面创建两次 Student 对象:

java
public class Test1 {
    public static void main(String[] args) {
        Student s1 = new Student();
        Student s2 = new Student();
    }
}

class Student {
    String name;
    int age;
    public Student(){
        System.out.println("???");
    }
}

因为每创建一个对象都会执行一次构造方法,所以这里会打印两次 ???。

还有一个容易忽略的规则:如果类中没有声明任何构造方法,Java 编译器会自动提供一个默认的无参构造方法。但只要我们自己声明了构造方法,编译器就不会再自动补上这个默认构造方法。

所以,如果已经写了有参构造,又希望能使用 new Student() 创建对象,就需要自己再定义一个无参构造方法。

有参构造

如果创建对象时已经知道姓名和年龄,就可以把它们作为参数直接传进去:

java
public class Test1 {
    public static void main(String[] args) {
        Student s1 = new Student("张三", 18);
        System.out.println(s1.name);
        System.out.println(s1.age);
    }
}

class Student {
    String name;
    int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

new Student("张三", 18) 会调用接收 String 和 int 两个参数的构造方法。通过 this.name = name; 和 this.age = age;,我们就可以在创建对象时完成赋值,不需要再分别调用设置方法。

那是不是有参构造更方便,以后只写它就行了?也不一定。如果创建对象时还不知道具体的属性值,先使用无参构造创建对象、以后再设置也很常见。

同一个类中可以同时定义无参构造和有参构造。它们名称相同、参数列表不同,正好也是前面学过的方法重载思想在构造方法上的应用。

常见的 JavaBean 类

前面说过,开发时经常需要一个类来保存学生、老师等对象的数据。对于符合一定编写约定、主要用于封装数据的类,我们经常会接触到 JavaBean 的写法。

在这部分学习中,可以先按照下面的结构来写:

  1. 类名见名知意,例如 Student。
  2. 成员变量通常使用 private 修饰。
  3. 提供无参构造方法,常常也会提供包含全部属性的有参构造方法。
  4. 为需要读写的属性提供相应的 getXxx()、setXxx() 方法。
  5. 如果类还有其他行为,也可以继续定义成员方法。

要注意的是:全参构造是常见的便利写法,并不是所有 JavaBean 都必须具备的硬性要求。这里先掌握课程中最常用的组合即可。

把之前的学生类整理一下,就有了下面的形式:

java
class Student {
    private String name;
    private int age;

    public Student() {

    }

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

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

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }
}

这样创建对象时,可以选择无参构造后再设置属性,也可以通过有参构造直接传入姓名和年龄;需要读取属性时则使用 getName()、getAge()。

这段示例主要展示类的完整结构。set 方法并不会自动进行数据校验:如果希望年龄必须在某个范围内,仍然要像前面的 setAge() 示例一样,主动加入判断。

成员变量与局部变量的区别

在 Student 类中,我们已经同时接触过成员变量和局部变量:

  • 成员变量:定义在类中、方法外,例如 private int age;。
  • 局部变量:定义在方法、构造方法或代码块内,例如 setAge(int age) 的形参 age,或者方法里的 int age = 10;。

它们的区别可以整理成一张表:

对比项成员变量(以实例成员变量为例)局部变量
定义位置类中、方法外方法、构造方法或代码块中;形参也属于局部变量
初始值没有显式赋值时有默认值不会自动获得可直接使用的默认值,使用前必须赋值
所属关系属于对象,可通过对象的成员进行访问属于当前方法调用或代码块的执行过程
内存理解随对象保存,通常可以理解为对象位于堆内存中初学时可结合方法调用的栈帧来理解
生命周期通常随对象存在,直到对象不再可达并可能被回收只在所属作用域内有效,离开作用域后无法再访问
作用范围受成员访问权限控制,可被类中的多个方法使用只能在声明它的作用域内使用

这里的内存位置是便于入门理解的简化说明,表格比较的是实例成员变量和方法内局部变量,不包括 static 成员等其他情况。

回到前面的代码:this.age 是当前对象的成员变量,而 setAge(int age) 中的 age 是形参。把它们区分清楚,this、构造方法中的赋值,以及封装的写法就更容易理解了。

谢谢你读到这里。 ✧

聊两句

评论审核后显示

有疑问、补充,或者想交流一下,都可以写在这里。

评论区将在滚动到这里时加载。