第四单元 · 类、对象、封装、综合 · 第 10 / 12 节
类与对象:从设计图到真实对象
写一个有字段和成员方法的 Student 类,用 new 创建对象,用对象名访问字段/方法,写第二个文件 StudentDemo.java 作为入口并编译两个文件。
这一节仍然按老规矩来:左边网页看讲解,右边 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 混在一起。
$ 提示符;输出和输入示例没有复制按钮。已完成 0 / 7 步
第 1 步:建目录,写下 Student.java
先进入本节自己的工作目录。如果 Student.java 已经存在,先改名备份,绝不直接覆盖你以前写的内容:
$ 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 退出。这三个动作要记熟:保存、确认、退出。
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 只是设计图,不能直接启动。现在写入口文件。先检查文件是否存在,存在就备份,再打开编辑器:
$ if [ -e StudentDemo.java ]; then mv StudentDemo.java "StudentDemo.java.bak.$(date +%s)"; fi
$ nano 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();,它可以拆成三件事看:
Student s1:声明一个名字s1,它以后要指向一个Student对象。new Student():按Student这张图创建一个真实对象。=:把这个对象交给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:
$ ls -l Student.java StudentDemo.java
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
$ ls -l Student.class StudentDemo.class
屏幕上应该出现:
我叫张三,今年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 的年龄,再把两个对象的年龄都打印出来。
先备份再编辑,避免改坏后没有退路:
$ cp StudentDemo.java "StudentDemo.java.bak.$(date +%s)"
$ nano StudentDemo.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);
}
}
保存退出后重新编译、运行:
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
我叫张三,今年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 的名字。
$ cp StudentDemo.java "StudentDemo.java.bak.$(date +%s)"
$ nano StudentDemo.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);
}
}
编译、运行:
$ javac -encoding UTF-8 Student.java StudentDemo.java
$ java StudentDemo
我叫张三,今年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。
$ cp Student.java "Student.java.bak.$(date +%s)"
$ nano Student.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 类创建对象,改年龄,观察返回值变化:
$ if [ -e AdultCheck.java ]; then mv AdultCheck.java "AdultCheck.java.bak.$(date +%s)"; fi
$ nano 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());
}
}
编译并运行这个检查文件:
$ javac -encoding UTF-8 Student.java AdultCheck.java
$ java AdultCheck
王五 是成年人吗?false
王五 是成年人吗?true
同一个对象 s,年龄从 17 改成 18,isAdult() 的返回值就从 false 变成 true。这说明方法不是“背固定答案”,它每次都用对象当前的字段值计算。
第 7 步:故意让 public 类名和文件名不一致,认出这类错误
Java 对 public 类名和文件名的要求是硬规则,不是建议。我们做一个不影响主文件的实验:把 Student.java 复制到 name-check/ 子目录,但故意把副本命名为小写 student.java,再编译它。
$ 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 会失败,这是故意的。你会看到类似这样的错误:
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/ 目录可以留着当反例,它不影响主目录。现在回到主目录,把三个正确的源文件一起编译一遍,确认没有把主文件改坏:
$ 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。
练习
- 在
StudentDemo里加第三个学生:姓名王五,年龄 19,调用introduce()和study()。先写出你预测的输出,再编译运行对照。 - 再写一个
Student s4 = new Student();,把s1的name、age复制给s4,然后把s4.name改成张三(副本)。s1.name应该变吗?说明理由。
先自己写,再展开对答案。答案单独放在 AnswerLesson10.java,不会覆盖你的 StudentDemo.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);
}
}
编译运行:
$ javac -encoding UTF-8 Student.java AnswerLesson10.java
$ java AnswerLesson10
我叫张三,今年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。
说明:本站是个人学习整理,不是官方教材;不包含原课程的书本、视频或受版权保护的整本内容。原课程与商标归其权利人。