Java从环境到类与对象 · SSH 实操

第四单元 · 类、对象、封装、综合 · 第 10 / 12 节

类与对象:从设计图到真实对象

写一个有字段和成员方法的 Student 类,用 new 创建对象,用对象名访问字段/方法,写第二个文件 StudentDemo.java 作为入口并编译两个文件。

约 35 分钟 · 7 个步骤 · 进度只存本机

这一节仍然按老规矩来:左边网页看讲解,右边 SSH 终端敲命令。你已经会变量、if、循环、数组和方法;现在换一个组织方式:不再把“姓名”放一个数组、把“年龄”放另一个数组,而是让一个 Student 对象同时带着自己的姓名和年龄,还会做自己的事。

先把四种东西分清,后面每一节都一样:

你看到的东西它是什么要不要复制
root@host:~#SSH 的 Linux shell 提示符不复制
public class Student { ... }Java 源码,属于 Student.java复制到文件里
javac -encoding UTF-8 Student.java StudentDemo.java编译命令,输入给 shell复制到终端
我叫张三,今年20岁。程序运行后的标准输出(stdout)不复制,只用来对照

先建立两个词:

  • 类(class):一张设计图。它规定“一个学生应该有哪些数据(字段),能做什么事(方法)”,但还不是某一个具体学生。
  • 对象(object):照着设计图做出来的具体个体。new Student() 就是“按图做一个学生对象”。

比如 Student 这张图规定每个学生都有 name、age,还能 introduce()。用 new 做两次,就得到两个具体学生;他们的姓名、年龄各存各的,互不影响。

这一节的主线是两个文件,这是 Java 的硬规则决定的:一个文件里最多一个 public 类,而且 public 类的名字必须和文件名完全一致。所以:

  • Student.java 里放 public class Student,它是“设计图”,不负责启动程序。
  • StudentDemo.java 里放 public class StudentDemo,它带 main,是程序入口。

main 只是入口方法的名字,不是 Student 的字段。Student 的字段只有 name 和 age;不要把它们和 main 混在一起。

SSH 服务器终端Java 源码程序交互输入输出示例(只读)复制 Java 源码不带文件名;复制命令不带 $ 提示符;输出和输入示例没有复制按钮。
这是教程网页,不是在线运行环境。Java 代码和 SSH 命令都要在你的服务器上执行;网页不会执行代码、不会连接服务器、也不会接收密码。

已完成 0 / 7 步

第 1 步:建目录,写下 Student.java

先进入本节自己的工作目录。如果 Student.java 已经存在,先改名备份,绝不直接覆盖你以前写的内容:

SSH 服务器终端bash
$ source ~/java-practice/env.sh
$ mkdir -p ~/java-practice/lesson10
$ cd ~/java-practice/lesson10
$ if [ -e Student.java ]; then mv Student.java "Student.java.bak.$(date +%s)"; fi
$ nano Student.java

nano 打开后,把下面整段 Student.java 粘贴进去,按 Ctrl+O,回车确认文件名,再按 Ctrl+X 退出。这三个动作要记熟:保存、确认、退出。

Java 源码Student.java
public class Student {
    public String name;
    public int age;

    public void introduce() {
        System.out.println("我叫" + name + ",今年" + age + "岁。");
    }

    public void study() {
        System.out.println(name + " 正在学习 Java。");
    }
}

逐行看:

  • public class Student:定义一个叫 Student 的类。文件名必须是 Student.java,大小写一致。
  • public String name;:字段,表示“每个学生都有一个名字”。String 是文本类型。
  • public int age;:字段,表示“每个学生都有一个年龄”。int 是整数类型。
  • public void introduce():成员方法,表示“这个学生能介绍自己”。void 表示不返回结果,只打印。
  • 方法体里直接写 name、age,没有写成 s1.name。因为这是成员方法,它自动使用“调用它的那个对象”的字段。

现在还没有写构造方法,编译器会先提供一个看不见的无参构造方法,所以下一步 new Student() 能成立。下一节你自己写构造方法后,这条“自动提供”的规则会发生变化。

说明 为什么方法前面没有 static

main 前面有 static,它是整个程序的入口;introduce()、study() 前面没有 static,它们属于具体对象,必须通过 s1.introduce() 这样的对象名来调用。现在先照写,不展开 static 专题。

第 2 步:写 StudentDemo.java,用 new 创建两个对象

Student 只是设计图,不能直接启动。现在写入口文件。先检查文件是否存在,存在就备份,再打开编辑器:

SSH 服务器终端bash
$ if [ -e StudentDemo.java ]; then mv StudentDemo.java "StudentDemo.java.bak.$(date +%s)"; fi
$ nano StudentDemo.java

把下面整段粘贴进去,保存退出:

Java 源码StudentDemo.java
public class StudentDemo {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.name = "张三";
        s1.age = 20;
        s1.introduce();
        s1.study();

        Student s2 = new Student();
        s2.name = "李四";
        s2.age = 21;
        s2.introduce();
        s2.study();
    }
}

这一段的关键是 Student s1 = new Student();,它可以拆成三件事看:

  1. Student s1:声明一个名字 s1,它以后要指向一个 Student 对象。
  2. new Student():按 Student 这张图创建一个真实对象。
  3. =:把这个对象交给 s1 这个名字来引用。

所以后面 s1.name = "张三"; 是“把 s1 指向的那个对象的 name 字段改成张三”,s1.introduce(); 是“让 s1 指向的那个对象执行 introduce()”。

new 写了两次,就是两个不同的对象。s1 和 s2 各自有自己的一份 name、age。

提示 先预测再运行

先不要往下翻。你觉得 s1.study() 和 s2.study() 会打印出哪个名字?如果把 s2.name 也改成 "张三",两个对象会互相影响吗?先在心里答,再运行。

第 3 步:两个文件一起编译,运行 StudentDemo

Student.java 和 StudentDemo.java 有依赖关系:StudentDemo 用到了 Student。最稳妥的方式是把两个源文件一起交给 javac:

SSH 服务器终端bash
$ ls -l Student.java StudentDemo.java
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
$ ls -l Student.class StudentDemo.class

屏幕上应该出现:

输出示例(只读)output不可复制
我叫张三,今年20岁。
张三 正在学习 Java。
我叫李四,今年21岁。
李四 正在学习 Java。

javac 成功时不会打印“成功”,而是安静地生成两个 .class 文件:Student.class 和 StudentDemo.class。java StudentDemo 启动的是带 main 的那个类,所以运行写 StudentDemo,不要写 Student(Student 没有 main,不能作为启动类)。

注意 为什么不能只编译 StudentDemo.java

只写 javac StudentDemo.java 时,只要 Student.java 就在当前目录,javac 会按默认 sourcepath 自动找到并一起编译,通常不会报错;但这依赖当前目录和隐式编译,换目录或文件不全时才会出现 cannot find symbol。本节统一把两个 .java 一起编译;改动了哪个文件,就把两个都重新编译一遍。

第 4 步:只改 s1 的年龄,确认 s2 不受影响

现在做一个最小实验:在 main 的最后一个 } 之前,加入三行,只改 s1 的年龄,再把两个对象的年龄都打印出来。

先备份再编辑,避免改坏后没有退路:

SSH 服务器终端bash
$ cp StudentDemo.java "StudentDemo.java.bak.$(date +%s)"
$ nano StudentDemo.java

把 StudentDemo.java 改成下面这样(新增的是最后三行):

Java 源码StudentDemo.java
public class StudentDemo {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.name = "张三";
        s1.age = 20;
        s1.introduce();
        s1.study();

        Student s2 = new Student();
        s2.name = "李四";
        s2.age = 21;
        s2.introduce();
        s2.study();

        s1.age = 22;
        System.out.println("s1.age = " + s1.age);
        System.out.println("s2.age = " + s2.age);
    }
}

保存退出后重新编译、运行:

SSH 服务器终端bash
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
输出示例(只读)output不可复制
我叫张三,今年20岁。
张三 正在学习 Java。
我叫李四,今年21岁。
李四 正在学习 Java。
s1.age = 22
s2.age = 21

s1.age = 22 只改了 s1 指向的那个对象。s2 是另一次 new 做出来的另一个对象,它的 age 还是 21。字段是跟着对象走的,不是跟着类名走的。

可以把两个对象想成两间独立的房间:s1 是 1 号房间的门牌,s2 是 2 号房间的门牌。你拿 1 号门牌进屋把年龄改成 22,2 号房间不会跟着变。

第 5 步:引用赋值 s3 = s1,看清“同一对象”和“复制对象”的区别

再做一个实验:不写 new,直接把 s1 赋给 s3,然后通过 s3 改名字,再看 s1 的名字。

SSH 服务器终端bash
$ cp StudentDemo.java "StudentDemo.java.bak.$(date +%s)"
$ nano StudentDemo.java

把 StudentDemo.java 改成:

Java 源码StudentDemo.java
public class StudentDemo {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.name = "张三";
        s1.age = 20;
        s1.introduce();
        s1.study();

        Student s2 = new Student();
        s2.name = "李四";
        s2.age = 21;
        s2.introduce();
        s2.study();

        s1.age = 22;
        System.out.println("s1.age = " + s1.age);
        System.out.println("s2.age = " + s2.age);

        Student s3 = s1;
        s3.name = "张三(改名)";
        System.out.println("s1.name = " + s1.name);
        System.out.println("s3.name = " + s3.name);
    }
}

编译、运行:

SSH 服务器终端bash
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
输出示例(只读)output不可复制
我叫张三,今年20岁。
张三 正在学习 Java。
我叫李四,今年21岁。
李四 正在学习 Java。
s1.age = 22
s2.age = 21
s1.name = 张三(改名)
s3.name = 张三(改名)

Student s3 = s1; 没有 new,所以没有做新对象。它只是把 s1 这张“门牌”抄了一份给 s3;两张门牌写的是同一个房间号,进的是同一间房。通过 s3.name 改名,s1.name 看到的当然也变了。

这两件事要分清:

  • Student s2 = new Student();:又做了一间新房,s1 和 s2 是两个独立对象。
  • Student s3 = s1;:没有新房,只是多了一个名字指向 s1 已经指向的那个对象。

如果你想要一个“内容相同但互相独立”的对象,必须再 new 一次,再把字段逐个复制过去。下一节学了构造方法,这一步会更省事。

第 6 步:给 Student 加一个 isAdult 方法,让对象用自己的字段算结果

成员方法不只能打印,还能返回结果。给 Student 加一个 isAdult():年龄大于等于 18 返回 true,否则返回 false。

SSH 服务器终端bash
$ cp Student.java "Student.java.bak.$(date +%s)"
$ nano Student.java

把 Student.java 改成:

Java 源码Student.java
public class Student {
    public String name;
    public int age;

    public void introduce() {
        System.out.println("我叫" + name + ",今年" + age + "岁。");
    }

    public void study() {
        System.out.println(name + " 正在学习 Java。");
    }

    public boolean isAdult() {
        return age >= 18;
    }
}

这里的 boolean 表示方法返回 true 或 false;return age >= 18; 把比较结果直接返回。方法体里的 age 仍然是“调用这个方法的那个对象”的年龄:s1.isAdult() 看 s1 的年龄,s2.isAdult() 看 s2 的年龄。

再写一个小检查文件 AdultCheck.java,用同一个 Student 类创建对象,改年龄,观察返回值变化:

SSH 服务器终端bash
$ if [ -e AdultCheck.java ]; then mv AdultCheck.java "AdultCheck.java.bak.$(date +%s)"; fi
$ nano AdultCheck.java
Java 源码AdultCheck.java
public class AdultCheck {
    public static void main(String[] args) {
        Student s = new Student();
        s.name = "王五";
        s.age = 17;
        System.out.println(s.name + " 是成年人吗?" + s.isAdult());

        s.age = 18;
        System.out.println(s.name + " 是成年人吗?" + s.isAdult());
    }
}

编译并运行这个检查文件:

SSH 服务器终端bash
$ javac -encoding UTF-8 Student.java AdultCheck.java
$ java AdultCheck
输出示例(只读)output不可复制
王五 是成年人吗?false
王五 是成年人吗?true

同一个对象 s,年龄从 17 改成 18,isAdult() 的返回值就从 false 变成 true。这说明方法不是“背固定答案”,它每次都用对象当前的字段值计算。

第 7 步:故意让 public 类名和文件名不一致,认出这类错误

Java 对 public 类名和文件名的要求是硬规则,不是建议。我们做一个不影响主文件的实验:把 Student.java 复制到 name-check/ 子目录,但故意把副本命名为小写 student.java,再编译它。

SSH 服务器终端bash
$ mkdir -p name-check
$ if [ -e name-check/student.java ]; then mv name-check/student.java "name-check/student.java.bak.$(date +%s)"; fi
$ cp Student.java name-check/student.java
$ javac -encoding UTF-8 name-check/student.java

这一行 javac 会失败,这是故意的。你会看到类似这样的错误:

输出示例(只读)text不可复制
student.java:1: error: class Student is public, should be declared in a file named Student.java
public class Student {
       ^
1 error

错误的意思很直接:文件里的 public class Student 要求文件名必须是 Student.java,但当前文件叫 student.java。Linux 区分大小写,student.java 和 Student.java 是两个不同的文件名。

这个 name-check/ 目录可以留着当反例,它不影响主目录。现在回到主目录,把三个正确的源文件一起编译一遍,确认没有把主文件改坏:

SSH 服务器终端bash
$ javac -encoding UTF-8 Student.java StudentDemo.java AdultCheck.java
$ java StudentDemo

javac 没有输出就表示成功;java StudentDemo 应该仍然打印前面那 8 行。如果这里报错,先看是不是在 ~/java-practice/lesson10 目录里,再看三个文件名有没有拼错。

注意 编译错误要看第一行

javac 的错误可能很长,但第一行通常已经给出类别:文件名不对、找不到符号、类型不匹配。先读第一行和它指出的行号,不要一上来就整段重写。

常见错误

  • **class Student is public, should be declared in a file named Student.java**:public class 的名字和文件名不一致,或者大小写不同。先 ls -l 看真实文件名,再改成完全一致;不要只改类名而不改文件名。
  • **cannot find symbol ... class Student**:Student.java 没有参与编译,或当前目录不对。先 pwd 确认在 ~/java-practice/lesson10,再用 javac -encoding UTF-8 Student.java StudentDemo.java 把两个文件一起编译。
  • **non-static variable name cannot be referenced from a static context**:在 main 里直接写了 name = "张三";。main 是静态入口,name 是某个对象的字段;必须先 new Student(),再写 s1.name = "张三";。
  • **variable s1 might not have been initialized**:只写了 Student s1; 就使用 s1.name。声明一个名字不等于创建对象;要写 Student s1 = new Student();。
  • **NullPointerException**:写了 Student s1 = null; 然后访问 s1.name。null 表示这个名字没有指向任何对象;先 new 出对象再访问字段。
  • **Could not find or load main class StudentDemo**:编译没有成功,所以没有 StudentDemo.class;或者当前目录不对;或者运行时写成了 java Student。先确认 javac 成功、ls 能看到 StudentDemo.class,再在同一个目录运行 java StudentDemo。

练习

  1. 在 StudentDemo 里加第三个学生:姓名 王五,年龄 19,调用 introduce() 和 study()。先写出你预测的输出,再编译运行对照。
  2. 再写一个 Student s4 = new Student();,把 s1 的 name、age 复制给 s4,然后把 s4.name 改成 张三(副本)。s1.name 应该变吗?说明理由。

先自己写,再展开对答案。答案单独放在 AnswerLesson10.java,不会覆盖你的 StudentDemo.java。

参考答案 对答案
Java 源码AnswerLesson10.java
public class AnswerLesson10 {
    public static void main(String[] args) {
        Student s1 = new Student();
        s1.name = "张三";
        s1.age = 20;
        s1.introduce();

        Student s2 = new Student();
        s2.name = "李四";
        s2.age = 21;
        s2.introduce();

        Student s3 = new Student();
        s3.name = "王五";
        s3.age = 19;
        s3.introduce();
        s3.study();

        Student s4 = new Student();
        s4.name = s1.name;
        s4.age = s1.age;
        s4.name = "张三(副本)";
        System.out.println("s1.name = " + s1.name);
        System.out.println("s4.name = " + s4.name);
    }
}

编译运行:

SSH 服务器终端bash
$ javac -encoding UTF-8 Student.java AnswerLesson10.java
$ java AnswerLesson10
输出示例(只读)output不可复制
我叫张三,今年20岁。
我叫李四,今年21岁。
我叫王五,今年19岁。
王五 正在学习 Java。
s1.name = 张三
s4.name = 张三(副本)

s4 是第三次 new 出来的独立对象,所以改 s4.name 不会影响 s1.name。注意这里复制的是字段的值(s1.name 这个字符串的引用),不是把 s1 这个名字直接赋给 s4;如果写成 Student s4 = s1;,两个名字就会指向同一个对象。

做到这里

做到这里今天可以停。你已经会:

  • 写一个有字段、有成员方法的类,并把它放在与 public class 同名的文件里;
  • 用 new 创建对象,用对象名访问字段、调用方法;
  • 把 Student.java 和 StudentDemo.java 一起编译,运行带 main 的入口类;
  • 分清“两个独立对象”和“两个名字指向同一个对象”。

下一节才讲构造方法、this、private 和 getter/setter;本节先用 public 字段把对象的状态看清楚,不提前展开封装。

来源 来源与改编说明(非官方)

主题参考:黑马程序员 Java 入门到起飞(BV17F411T7Ao,2022-04-27)P081–P082、P087–P088、P090 的面向对象、类和对象、成员与局部、引用类型主题。本站为独立原创讲解,非官方站点/教材,未获授权,不逐字复刻视频或讲义;示例、错误清单与练习为原创补充。详见 source/SOURCE.md。

说明:本站是个人学习整理,不是官方教材;不包含原课程的书本、视频或受版权保护的整本内容。原课程与商标归其权利人。