02对象与类

7651 字
38 分钟
02对象与类

第 4 章 对象与类#

4.1 面向对象程序设计概述#

这一节只讲一个核心:C 把数据放在 struct、把函数写在外面,Java 则把二者合并进类中。

先看 C 的写法:

struct Employee {
char name[32];
double salary;
};
void raise_salary(struct Employee *e, double pct) {
e->salary *= (1 + pct / 100);
}

数据在 struct 里,操作数据的函数在外面,两边唯一的联系是这个函数碰巧收了个 Employee 指针。Java 把这两半塞进同一个 class:

class Employee {
private String name;
private double salary;
public void raiseSalary(double pct) {
salary *= 1 + pct / 100;
}
public String getName() { return name; }
}

类是构造对象的模板,对象是类的实例;字段保存状态,方法描述行为;封装则通过访问权限隐藏实现细节。

  1. 对象:类是定义,对象是具体实例。每个对象拥有独立的实例字段。a == b 比较的是身份(是不是同一个对象),不是内容(字段值是否相同)——两个字段完全相同的对象,a == b 仍然为 false。变量只是保存引用,new 才创建对象。

  2. 要能识别类:可以先把需求中的名词作为候选类或字段,把动词作为候选方法。例如“雇员有姓名和薪水,可以加薪”,可得到 Employee、name、salary 和 raiseSalary()。

  3. 类之间的关系:类与类之间只有三种关系。

    • 依赖(uses-a):一个类的方法里用到了另一个类。最弱也最常见,例如 Order 的某个方法里调用了 Employee 的方法。书上建议尽量少依赖,去耦合。
    • 聚合(has-a):一个对象里装着另一个对象当字段,即「整体-部分」,例如 Order 里有个 Customer 字段。
    • 继承(is-a):一个类是另一个类的特殊化,例如 Manager is-a Employee。这条 Ch5 会整章展开,这里只要认得。

    UML 类图的画法:依赖用虚线箭头,聚合在线尾画空心菱形,继承在线尾画空心三角。

概括来说,C 主要靠约定连接数据和函数,Java 则用类与访问控制把它们组织在一起。

4.2 使用预定义类#

对象与对象变量#

Java 的对象变量保存的是引用,不是对象本身:

Date deadline = new Date();

它可以粗略类比为 C 中的指针和动态分配:Date d 对应指针,new Date() 对应分配并初始化对象,null 对应空指针。Java 引用不能做指针运算,赋值也只复制引用,不复制对象:

Date a = new Date();
Date b = a;
b.setTime(0);
System.out.println(a.getTime()); // 0,指向同一对象

若引用为 null,调用方法会抛出 NullPointerException;局部变量未赋值则会在编译期报错。单独写 new Date() 虽然合法,但对象无人引用,最终会被回收。

new Date() 中的 Date() 是构造器,不是普通方法。构造器名称必须与类名相同,不能写返回类型,负责初始化新对象。

LocalDate#

旧的 Date 可变且接口混乱。Java 8 的 java.time 提供了更清晰的日期类型:

import java.time.LocalDate;
LocalDate today = LocalDate.now();
LocalDate d = LocalDate.of(2007, 12, 3);
System.out.println(d.getMonthValue()); // 1~12

LocalDate 只表示年月日,不包含时刻和时区。LocalDate.of(...) 是静态工厂方法:类库在内部创建对象并返回引用,因此不需要公开构造器。类类型方法返回的是引用,但不保证一定是新对象。

访问器、更改器与不可变对象#

更改对象状态的方法叫更改器(如 Date.setTime),只读的方法叫访问器(如 getTime)。LocalDate 是不可变类,plusDays 不修改原对象,而是返回新对象:

LocalDate d = LocalDate.of(2000, 1, 1);
LocalDate e = d.plusDays(100);

必须接住返回值;单独写 d.plusDays(100); 是合法但无效果的代码。不可变对象可以安全共享,避免通过同一引用互相修改状态。

4.3 自定义类#

4.1 里那个只有骨架的 Employee,这一节补完整——也是第一次自己写类。

  1. Employee类:完整版长这样:
import java.time.LocalDate;
public class Employee {
private String name;
private double salary;
private LocalDate hireDay;
public Employee(String name, double salary, int year, int month, int day) {
this.name = name;
this.salary = salary;
this.hireDay = LocalDate.of(year, month, day);
}
public String getName() { return name; }
public double getSalary() { return salary; }
public LocalDate getHireDay() { return hireDay; }
public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
}
}

对照 4.1 里那版 C:

CJava
struct Employee 里放字段class Employee 里放字段
raise_salary(&e, 5) 外面的函数e.raiseSalary(5) 类里的方法
字段谁都能改字段 private,只能走方法

用起来:

Employee e = new Employee("张三", 50000, 2000, 1, 1);
e.raiseSalary(5); // 加薪 5%
System.out.println(e.getSalary()); // 52500.0
  1. 使用多个源文件:Java 的硬规矩是一个 .java 文件里最多只能有一个 public 类,而且文件名必须和这个类名一模一样。所以 Employee 存成 Employee.java,测试程序(带 main 的那个)另存 EmployeeTest.java:
Terminal window
javac EmployeeTest.java
java EmployeeTest

书里强调的一点:编译 EmployeeTest.java 时,javac 发现它用到了 Employee,会自动去同目录找 Employee.java 一起编译,不用手动把两个文件都列上。(它靠比对时间戳判断:Employee.java 比 Employee.class 新就重新编。)

vs C:C 靠「#include "employee.h" 抄声明 + 链接器找实现」两套机制拼装;Java 没有头文件,编译器顺着源码和类路径自己找。

同一个目录(默认包)下的类互相引用不需要 import,直接写类名就行。

  1. 剖析Employee类:一个类就三样东西:

    • 实例字段(name / salary / hireDay)—— 数据,每个对象各存一份
    • 构造器(Employee(...))—— 造对象时初始化字段
    • 方法(raiseSalary 等)—— 行为

    字段全 private、方法全 public,这就是 4.1 说的「封装」落到实处。

    字段带 static 就是「类字段」(所有对象共享一份,4.4 讲),不带就是「实例字段」(每个对象独一份)。这里三个都是实例字段。

  2. 从构造器开始:4.2 前向引用过它,现在正式交代。四条规矩:

    • 名字必须和类名一模一样
    • 没有返回类型——连 void 都不写
    • 只能跟着 new 用,不能写成 e.Employee(...)
    • 可以重载(同名、不同参数),new 时按实参挑

两个容易踩的点:

① 不写构造器 ≠ 没有构造器。 一个构造器都没写时,编译器自动送你一个无参构造器;但只要你自己写了任意一个(哪怕是有参的),这个免费的就立刻消失——new Employee() 直接编译报错。这条 4.6.3 会再细说。

② this.name = name; 参数名和字段同名时,右边是参数、左边是字段,靠 this 区分。this 就是「正在构造的这个对象」(4.3.7 展开):

public Employee(String name, double salary, ...) {
this.name = name; // 左边 this.name 是字段,右边 name 是参数
this.salary = salary;
...
}

参数名起成 aName 这类不重名的,this. 就能省——但 this.x = x 是 Java 惯用写法,认得它。

顺带把「实参填进哪个门」理清。 构造器只在造对象那一刻被调用一次 ,之后你反复调的是方法——填实参这件事发生两次,进的是两个不同的门:

① 造对象:new Employee("张三", 50000, 2000, 1, 1) ← 实参填给【构造器】
↓ 对象造好了,存在变量 e 里
② 用对象:e.raiseSalary(5) ← 实参填给【方法】

术语上,声明里列出的变量叫形参(public Employee(String name, ...) 里的 String name),调用时传进去的值叫实参("张三")。

方法返回什么,由返回类型决定。并不一定返回对象,拿上面那四个方法验一遍:

方法返回类型实际返回什么对象变了吗
getName()String一个已有对象的引用(字段里那个)没变
getSalary()double一个数值,压根不是对象没变
getHireDay()LocalDate一个已有对象的引用没变
raiseSalary(5)void什么都不返回变了(salary 被改)

注意 raiseSalary 恰恰是唯一让对象发生变化的那个,而它返回 void。可见「方法改了对象」和「方法返回了新对象」是两条不同的路:

e.raiseSalary(5); // 更改器:返回 void,改自己
LocalDate f = d.plusDays(100); // 访问器 + 不可变:返回新对象,自己不动

前者是 4.2.3 讲的更改器,后者是访问器+不可变类。两种风格在 Java 里同时存在,别指望统一成一种。

  1. 用var声明局部变量:Java 10 之后,局部变量可以不写类型:
var e = new Employee("张三", 50000, 2000, 1, 1);
// ↑ 编译器从右边推断出 e 的类型是 Employee

动机就是省掉重复——老写法里类型出现了两次:Employee e = new Employee(...);。四条限制:

  • 只能用在局部变量(方法体里的变量),不能用于字段、方法参数、方法返回类型
  • 必须带初始值,var x; 直接编译错误——没有右边就无从推断
  • 不是动态类型,类型在编译期就定死了,只是你没写出来
  • 右边类型必须明确,var x = null; 也是错的
var e = new Employee(...);
e = "字符串"; // ❌ 编译错误,e 已经是 Employee 了
var n = 3; // int
var s = "hello"; // String
var list = new ArrayList<String>(); // ArrayList<String>

vs C++11 的 auto:同一个思路,都是编译期推断。差别是 C++ 的 auto 用得更野(函数返回类型、结构化绑定、lambda 参数都能用),Java 的 var 只管局部变量,保守得多;反过来 C++ 的 auto 会丢掉顶层 const 和引用,是个老坑,Java 这边没有对应问题(对象本来就都是引用语义)。

实用建议:类型一目了然时才用 var。var e = new Employee(...) 挺好;var x = compute(); 就不好了——读者根本不知道 x 是什么,不如老实写 Employee x = compute();。

  1. 使用null引用:4.2.1 讲过「变量可能不指向任何对象」,这里讲怎么应对:
String n = null;
n.length(); // 运行时抛 NullPointerException

三种做法:

// ① 显式判空:能用,但只保护了这一处
if (n != null) {
System.out.println(n.length());
}
// ② 入口处立即失败
public Employee(String name, ...) {
this.name = Objects.requireNonNull(name, "name 不能为 null");
}
// ③ 给个兜底值(Java 9+)
Objects.requireNonNullElse(n, "无名")

Objects 是 java.util.Objects,一个纯工具类,方法全是静态的。

②这种做法叫 fail fast:与其让这个 null 飘到很远的地方才炸、报错现场和出事现场隔了十万八千里,不如在入口就拦下来,让报错直接指向真正的源头。书里推荐的就是这种。

vs C:

char *p = NULL;
strlen(p); /* 段错误,或者更糟——未定义行为,静默破坏别处内存 */

同样是空指针解引用,C 不给你任何交代:运气好当场崩,运气不好带病跑完、在别的地方炸。Java 必定抛 NullPointerException,还带完整堆栈,直接告诉你哪一行。

null 是 Java 被诟病最多的一处设计(Tony Hoare 自己称之为「十亿美元错误」)。现代 Java 在补这个漏:Optional(卷二讲)、Objects 这套工具、以及「不要返回 null 数组/集合,返回空集合」的约定。现阶段记住两条就够——别随便返回 null、参数该查就查。

  1. 隐式参数与显式参数:看这一句:
e.raiseSalary(5);
// │ └─ 显式参数:括号里的 5
// └─ 隐式参数:点号前面的 e,也就是方法体里的 this

显式参数(explicit parameter) 就是括号里列出来的那个 5,也就是方法声明里写明的参数;隐式参数(implicit parameter) 是点号前面的 e——方法所属的那个对象,在方法体里它就是 this。所以在 raiseSalary 的方法体里:

public void raiseSalary(double byPercent) {
double raise = salary * byPercent / 100;
salary += raise;
// 这两个 salary 其实都是 this.salary 的简写
}

这里要分两种情况说清楚,否则很容易以为 this 是多余的一遍:

① 没有同名变量时,salary 和 this.salary 完全等价,写不写随你。上面 raiseSalary 里参数叫 byPercent、不叫 salary,所以裸写的 salary 就是字段。

② 一旦出现同名的参数或局部变量,情况就反过来了——它们会遮蔽(shadow) 字段,裸写的那个名字优先指离得近的那个:

public class Employee {
private String name; // ← 实例字段:写在类的头上,每个对象一份
private double salary;
public Employee(String name, double salary) { // ← name / salary 是参数
System.out.println(name); // 打印的是【参数】
System.out.println(this.name); // 打印的是【实例字段】(此刻还是 null)
name = name; // ❌ 参数赋给自己,实例字段纹丝不动
this.name = name; // ✅ 右边是参数,左边 this.name 才是实例字段
this.salary = salary; // 同理,带 this. 的那个才是字段
}
}

关键在于头两行 private String name; 和下面参数表里的 String name 是两个不同的东西——它们只是名字撞了。这就是遮蔽会发生的前提。

所以 this 不是”把字段名多写一遍”,而是字段被同名变量挡住时,唯一能指到它的路径。这也正是 this.name = name; 左边必须带 this 的原因——不带的话,左边那个 name 同样指参数,整句等于什么都没干。

注意区分「类变量」。private String name; 这种没有 static 的,叫实例字段(instance field)——每个对象各有一份。Java 里的「类变量」是专有词,只指带 static 的字段:它属于类本身,所有对象共享一份。区分方法只有一条——看有没有 static,跟”写没写在类里面”完全无关。类变量 4.4 讲。

vs C 才是关键——同样的逻辑,C 里两个参数都写在括号里:

raise_salary(&e, 5); /* struct Employee *e 和 double pct 都是显式参数 */
e.raiseSalary(5); /* e 被挪到了点号前面,变成隐式参数 */

Java 做的事就是:把第一个参数从括号里提到了点号左边,让它「隐」起来。反过来说,e.raiseSalary(5) 用 C 的写法展开就是 raiseSalary(e, 5),只是 e 被放到了前面。

这就是「面向对象」在调用语法上的体现:数据(e)和方法(raiseSalary)绑在一起,不必每次手动把对象当第一个参数传进去。书里专门用这两个术语,是为了让你看清「对象方法其实多带了一个隐藏参数」。

this 还有个用法是 this(...)——在构造器第一行调用本类另一个构造器,4.6.6 讲。

  1. 封装的优点:字段私有 + 给方法访问,换来四样好处。

    • 内部实现想怎么改就怎么改:把 name 拆成 firstName / lastName,只要 getName() 还返回拼好的全名,外面几十处调用一行都不用动。换成 C 那种公有 struct 成员,字段一改,所有使用方全得改。
    • setter 里能加校验:公有字段拦不住 e.salary = -99999;但方法可以。
    • 只给 getter、不给 setter,就是只读字段
    • 可以惰性计算:getter 里现算,不用提前存
public void raiseSalary(double byPercent) {
if (byPercent < 0) throw new IllegalArgumentException("加薪比例不能为负");
...
}

但这里有个必须知道的坑:访问器不要返回可变对象。

class Employee {
private Date hireDay;
public Date getHireDay() {
return hireDay; // ❌ 把内部状态的控制权交出去了
}
}
Employee e = ...;
Date d = e.getHireDay();
d.setTime(0); // 直接从外面改掉了 e 的内部状态,e 一点没察觉

getHireDay 返回的是引用(引用共享,4.2.1 讲过),对方拿到就能改你的内部数据。两种修法:

// 修法一:字段直接用不可变类型
private LocalDate hireDay;
public LocalDate getHireDay() { return hireDay; } // LocalDate 改不了,安全
// 修法二:返回一份副本
public Date getHireDay() {
return (Date) hireDay.clone(); // 每次给的是拷贝,随便改
}

这就是 4.2.3 讲「不可变」的实战回报——不可变对象可以放心交出去。

  1. 基于类的访问权限:Java 的 private 是类级别,不是对象级别——同一个类里的代码,可以访问该类任何对象的私有成员:
public class Employee {
private String name;
public boolean hasSameName(Employee other) {
return name.equals(other.name); // 直接摸 other 的私有字段,合法
}
}

other 到底是哪个对象?取决于调用时传进去的是谁:

Employee a = new Employee("张三");
Employee b = new Employee("张三");
Employee c = new Employee("李四");
a.hasSameName(b); // this = a,other = b → true
a.hasSameName(c); // this = a,other = c → false

这正好是 4.3.7 的那对术语:this 是隐式参数(点号左边那个),other 是显式参数(括号里那个)。两者都是参数,只是填进去的时机不同。

所以「类级权限」的意思是:同一个类的代码,能摸到任何一个 Employee 对象(不只是 this)的私有字段;换成别的类,即便拿到同一个对象,也不许碰。

vs C:C 没有「类」这层封装单位,最接近的是 static——文件作用域,同一个 .c 文件里的函数能访问文件里的 static 变量。所以 C 的封装单位是「文件」,Java 的封装单位是「类」。C++ 除了 class 级别还有 friend,Java 没有 friend:需要跨类互访时,要么把类放进同一个包(4.8 讲包访问),要么干脆合成一个类。

  1. 私有方法:只给类内部用的辅助方法设成 private:
public class Employee {
private double salary;
public void raiseSalary(double byPercent) {
salary += computeRaise(byPercent);
}
private double computeRaise(double byPercent) { // 外部调不到
return salary * byPercent / 100;
}
}

好处很实在:既然外面调不到,你就可以放心删它、改它、改它的参数列表,不用担心动了谁的代码。这也是把大方法拆成小步骤的常用手段——拆出来的步骤是实现细节,不该进入公开接口。

对照 C:C 里做同样的事就是把函数声明成 static(文件内可见,链接器看不到)。思路一样,只是可见范围从「文件」变成了「类」。

  1. final实例字段:
class Employee {
private final String name; // 构造之后就不能再改
private final int id;
public Employee(String name, int id) {
this.name = name; // 必须在构造器里赋值
this.id = id;
}
}

三条要点:

① 必须在声明处或每个构造器里赋值,一次且仅一次,之后再赋值就是编译错误。所以这种字段必须有构造器来喂——正好呼应 4.3.4 说的「你写了构造器,默认那个就没了」。

② 每个对象可以有不同的 final 值。final 不是「全局唯一」,只是「这个对象一旦定下就不变」:

private final int id; // 每个 Employee 各有各的 id,各自定死
public static final double PI = 3.14159; // static + final 才是全局唯一的常量

前者跟着对象走,后者跟着类走(就是类变量 + 不可变)。

③ final 锁的是引用,不是对象内容。

private final StringBuilder sb = new StringBuilder();
sb.append("x"); // ✅ 合法:改的是对象内容,不是引用
sb = new StringBuilder(); // ❌ 编译错误:不能让它改指向别的对象

字段是引用类型时,final 只保证「永远指向同一个对象」,不保证那个对象本身不变。

vs C 的 const:C 的 const 能靠强制类型转换、或者 memcpy 之类绕过;Java 的 final 字段是真的改不了(除非上反射,那属于非常规手段)。

4.4 静态字段与静态方法#

static 表示成员属于类,而不是某个对象。 下面是静态方法:

class Employee {
private static int nextId = 1;
private int id;
Employee() { id = nextId++; }
}

所有对象共享一份 nextId,但每个对象拥有自己的 id。静态字段可以通过类名访问,虽然也能通过对象访问,但后者容易误导读者。public / private 控制访问范围,static 控制归属,final 控制能否再次赋值;public static final 常用于定义常量。

final 字段必须在声明处或每个构造器中初始化。对于引用类型,final 只保证引用不能换指向,不能保证对象内容不变;真正的不可变性还需要对象本身不提供修改状态的操作。

静态方法没有 this,不能直接访问实例字段或调用实例方法。方法不依赖对象状态时,可以设计为静态方法:

Math.sqrt(x);
LocalDate.of(2007, 12, 3);

静态工厂方法还可以用不同名称表达不同创建意图,并隐藏具体实现。Java 程序通常从以下入口开始:

public static void main(String[] args)

它必须是静态方法,因为启动时还没有现成对象;每个类都可以有自己的 main 用于小测试。

4.5 方法参数#

Java 永远按值传递:实参的值会被复制一份交给形参。区别只在「这个值是什么」——基本类型的值就是那个数,对象的值是引用。由此得到两类结果:

  • 基本类型:形参拿到的是数值副本,怎么改都影响不到外部变量。
  • 对象:形参拿到的是引用副本,和实参指向同一个对象。所以改对象的内容,外部看得见:
static void tripleSalary(Employee x) {
x.raiseSalary(200);
}

但改形参自己的指向,动的只是副本,外部不受影响:

static void swap(Employee a, Employee b) {
Employee temp = a;
a = b;
b = temp;
}

所以准确说法不是“对象按引用传递”,而是“对象引用按值传递”。这与 C 的指针参数规则完全一致:指针按值传递,可以通过它改指向的对象;要改调用方的指针本身,C 也得升到二级指针。

4.6 对象构造#

对象初始化顺序固定:字段先得到默认值(数字为 0、boolean 为 false、引用为 null),再按声明顺序执行字段初始化和初始化块,最后执行构造器体。局部变量没有默认值,未初始化就使用会编译失败。

重载:同一个名字、不同的参数列表——个数、类型、顺序任一不同即可,编译器按实参挑最合适的那个:

Math.abs(-3); // 匹配 Math.abs(int)
Math.abs(-3.5); // 匹配 Math.abs(double)

决定「是不是同一个方法」的是签名 = 方法名 + 参数列表,返回类型不在签名里。所以 void f() 和 int f() 不算重载,而是编译错误;实参挑不出唯一匹配时也会报错。构造器同样可以重载。

C 没有这个机制,只能为类似功能起不同名字:abs / labs / fabs。

类完全没有声明构造器时,才会自动获得一个无参构造器。 字段可以直接初始化:

private String name = "";
private LocalDate hireDay = LocalDate.now();

构造器可以用 this(...) 委托给同类的另一个构造器——括号里的实参决定调哪一个,跟重载挑方法同一套规则。它必须位于第一行,以集中公共初始化逻辑:

public Employee(double salary) {
this("Employee", salary); // 实参是 (String, double),就匹配接收这两个参数的构造器
}

一个构造器里只能有一个 this(...),也不能互相绕圈(A → B → A 是编译错误),所以委托链永远是一条不循环的直线,最终停在一个不调用 this(...) 的构造器上——真正完成字段初始化的就是它。

初始化块是类体里直接写的一对 { },不属于任何方法:

class Employee {
private static int nextId;
private int id;
{ id = nextId++; } // 实例初始化块:每 new 一个对象执行一次
static { nextId = 1; } // 静态初始化块:类第一次初始化时执行一次
}

实例块和字段初始化一起,在构造器体之前、按声明顺序执行(意思就是构造器不管写在哪都最后执行),可以访问实例字段。静态块和静态字段只跑一次,用来初始化静态字段——它在类加载时执行一次,此后任何static控制的字段或块都不再执行。

两者实际都很少用:能用字段初始化直接表达的,就写在字段处。

Java 没有 C 式析构函数。对象不再被引用后由 GC 回收内存,但不能依赖回收时机清理资源;finalize 已废弃。文件句柄、数据库连接等外部资源仍须显式关闭,通常使用 close() 或 try-with-resources。

4.7 记录#

record 是 Java 16+ 的类型声明关键字——和 class 同一层(不是 private 那种修饰符),站的就是 class 的位置,专门用来定义「一组字段 + 原样读出」的不可变类型。一行就够:

record Point(int x, int y) { }

相当于:

class Point {
// ① 自动生成 private final 字段(不可修改!final)
private final int x;
private final int y;
// ② 全参构造器,接收所有参数
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// ③ 访问器方法:名字就是字段名,没有get,没有setter!
public int x() {
return x;
}
public int y() {
return y;
}
// ④ 自动重写 equals()、hashCode()、toString()
@Override
public boolean equals(Object o) { ... }
@Override
public int hashCode() { ... }
@Override
public String toString() { ... }
}

编译器据此生成:私有的 final 字段、一个接收全部字段的构造器、同名访问器 x() / y()(不带 get 前缀),以及 equals / hashCode / toString(Ch5 讲)。

Point p = new Point(3, 4);
p.x(); // 3

构造器有三种形态:

  • 标准构造器:编译器自动生成,逐个字段赋值。
  • 自定义构造器:自己写,但每个字段仍必须被赋值一次。
  • 简洁构造器:连参数列表都不写,只写构造体,用来做校验或修正——赋值由编译器补上:
record Range(int lo, int hi) {
Range { // 简洁构造器
if (lo > hi) throw new IllegalArgumentException();
}
}

vs C:struct 只是数据容器,比较、打印、复制全得自己写;record 自带这一整套,而且不可变。

4.8 包#

包是命名空间,用来避免类名冲突。它在磁盘上就是一个目录:一个包可以装很多类,一个类只能属于一个包;不写 package 语句的类属于默认包(前面那些 Employee.java / EmployeeTest.java 就是)。

包名的惯例是用自己拥有的域名倒过来写——域名是全球唯一的,倒写出来就是一个全球唯一的前缀,跟别人撞不了名(站点 hecloud.top → 包名前缀 top.hecloud)。包名还要与目录一一对应:

com.example.myapp ←→ com/example/myapp/

源文件开头写 package com.example.myapp;文件就必须放在对应目录下。同一个包里的类互相引用不需要 import。

import 不是 C 的 #include——它不复制任何代码,只是让你少写全名:import java.util.ArrayList; 之后写 ArrayList,等价于写全名 java.util.ArrayList。

有两处不用 import:同一个包里的类,以及 java.lang——编译器默认替你执行了 import java.lang.*;,所以 String、System、Math、Integer 这些从来不用导入。注意它是通配符形式,按下面那条规则不覆盖 java.lang.reflect 这样的子包。

  • 通配符 import java.util.*; 只覆盖这一个包,不递归子包——父包和子包本来就是互不包含的两个包,只是名字看起来有层级。
  • 两个包有同名类时通配符失效,必须写全限定名(如java.lang.System.out.println())。
  • 静态导入 import static java.lang.Math.*; 让你直接写 sqrt(x) 和 PI,省掉类名前缀——它只导入静态成员。

包访问:修饰符什么都不写时(既非 public 也非 private),成员只对同一个包里的类可见,包外看不见。

类路径(classpath):告诉 javac / java 去哪里找 .class 文件,默认是当前目录,可以用 -cp 指定:

Terminal window
javac -cp build/classes MyApp.java
java -cp build/classes MyApp

可以有多个路径,用 : 分隔(Windows 上用 ;)。

vs C:C 没有命名空间,全局符号一律裸奔,只能靠命名约定防撞名;Java 用包做命名空间,用 import 声明依赖。

4.9 JAR 文件#

JAR 本质是一个 zip 包,把一棵目录树打成一个文件。打包默认递归,子目录一并装进去;目录结构必须原样保留——类是按「包名 → 路径」找的,路径乱了就找不到了。

Terminal window
jar cf app.jar com/example/ # c = create,f = file;装的是 com/example/ 整棵子树

因此 classpath 上 jar 和目录是等价的:-cp app.jar 和 -cp build/classes 对 javac / java 来说是一回事。jar 里也不只装 .class,资源文件(配置、图片)同样可以放进去——它就是个通用容器。

包内的 META-INF/ 是存放元数据的约定目录,其中的 MANIFEST.MF 就是清单文件——JAR 里唯一必需的文件,内容是一行行 键: 值。在清单里写上入口类,这个 jar 就能直接运行:

Main-Class: com.example.MyApp
Terminal window
java -jar app.jar

多版本 JAR:META-INF/versions/N/ 下放的是另一套 class 文件(照抄包结构),N 是 JDK 主版本号。老 JDK 用根目录里的类,新 JDK 优先用自己版本目录里的类——一个 jar 同时兼容多代 JDK。前提是清单里写了 Multi-Release: true,否则 JVM 会忽略 versions/。

vs C:JAR 类似把多个 .o 打包成 .a / .so 再发布;但 JAR 跨平台、自带元数据,还能直接运行。

4.10 文档注释#

/** ... */ 是文档注释。它与普通注释的区别是可以被 javadoc 工具提取,生成 HTML 格式的 API 文档——JDK 官方那些在线文档就是这么来的。

/**
* 给雇员加薪。
* @param byPercent 加薪百分比
* @return 加薪后的薪水
*/
public double raiseSalary(double byPercent) { ... }

常用标签:@param、@return、@throws 分别描述参数、返回值和异常;类上还可以用 @author、@since、@see。字段也能写文档注释,包级文档则写在 package-info.java 里。

Terminal window
javadoc com/example/myapp

vs C:C 的注释只给人看;Java 的文档注释是可提取的文档,能一键生成在线 API 手册。

4.11 类设计技巧#

这一节的清单可以直接当自查表用:

  • 字段一律私有,需要访问就提供方法。
  • 每个字段都要初始化,别把默认值当成「正常状态」。
  • 方法不访问对象状态,就把它做成静态的。
  • 几个基本类型字段总是一起出现(street / city / zip),把它们包成一个类。
  • 不是所有字段都要配访问器和更改器,只暴露真正需要的。
  • 职责过多的类要拆开,这是最常见的坏味道。
  • 类名用名词、方法名用动词,名字要能体现职责。
  • 优先使用不可变类——LocalDate 的取舍就是这个道理。

两句话贯穿全章:数据私有、边界收窄。对照 C 的 struct 就明白代价在哪——成员全部公开,任何一处改动都波及所有使用方。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
02对象与类
https://hecloud.top/posts/tech/java-se/02对象与类/
作者
贺小云
发布于
2026-09-09
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
贺小云
热爱技术与折腾的博客 Dancing with uncertainty. 与不确定性共舞
公告
出于成本考虑,贺云已全面转型Serverless架构
分类
标签
碎碎念
站点统计
文章
91
分类
5
标签
44
总字数
103,192
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.16.5
文章许可
CC BY-NC-SA 4.0