《Java 编程入门》3.2 包、模块与可见性

把 library 拆成 com.plume.library 与 com.plume.library.cli 两个包,用 public、包私有、protected、private 四种可见性解释谁能看见谁,并用真实编译错误验证包私有边界;再对比 classpath 与 module path,给出 module-info.java 的 exports/requires 写法与卷一不启用模块的理由。

本节目标:能用包把 library 分层,能准确说出四种访问修饰符的边界,并理解 module-info.java 在 classpath 之上又加了什么。
适用版本:JDK 25 LTS(Temurin 25.0.5)。

包解决三个问题

单文件程序里,类名是全局的。两个人都写了一个 Book,就没法放进同一个程序。包(package)解决的第一个问题就是命名冲突:用 com.plume.library.domain.Book 和 com.example.catalog.Book 区分同名类。

第二个问题是组织。library 会长出领域对象、仓储、服务、命令行四层,如果所有类都堆在一个目录里,读代码的人要靠文件名猜关系。包把「同一职责的类」放在一起,目录结构直接反映模块边界。

第三个问题是访问控制。Java 的包私有(package-private)可见性以包为单位:同一个包里的类可以互相访问,包外的类不行。这给了你一种比 public 更细的边界,既不是「完全公开」,也不是「完全私有」。

包声明必须写在文件第一行(注释除外):

package com.plume.library.domain;

一个文件只能有一个包声明。没有包声明的类属于「无名包」,只能用于临时验证,不能用于正式项目。无名包里的类不能被有包名的类导入,这是 JShell 片段和真实工程之间的一道硬边界。

命名与目录

包名用反写域名:com.plume.library。全小写,不用下划线,不用连字符。每个点对应一层目录:

src/main/java/
└── com/
    └── plume/
        └── library/
            ├── LibraryApp.java
            ├── domain/
            │   └── Book.java
            ├── repository/
            │   └── InMemoryLibraryRepository.java
            ├── service/
            │   └── LibraryService.java
            └── cli/
                ├── CommandNames.java
                └── Usage.java

目录和包名必须一致。package com.plume.library.cli; 的源码放在 src/main/java/com/plume/library/CLI/ 里,编译器可能仍接受,但类路径解析会出问题:JVM 用包名拼路径,大小写不匹配时找不到 class。macOS 默认文件系统不区分大小写,这类错误在本机能跑,到 Linux 上就暴露。

包名不是越深越好。com.plume.library.service.internal.impl 这种五层结构,通常说明分层过度。卷一的规模用两层足够:com.plume.library 放入口,com.plume.library.domain、.repository、.service、.cli 放四类职责。第 8 章引入接口时再考虑是否拆子包。

import 的三种写法

跨包使用类时,有三种写法:

// 1. 单类型导入:只导入 Usage
import com.plume.library.cli.Usage;

// 2. 按需导入:导入 cli 包下所有类型
import com.plume.library.cli.*;

// 3. 全限定名:不写 import,直接写完整类名
com.plume.library.cli.Usage.print();

单类型导入最推荐。它明确告诉读者「这个文件用了哪个类」,也不会因为包内新增同名类而突然冲突。按需导入写起来省事,但在大文件里很难看出依赖来源,两个包有同名类时还会编译失败。全限定名适合只用一次、或者故意要和另一个同名类区分的场景。

同一个包里的类不需要 import;java.lang 包(String、System、Object、Integer 等)也不需要。import 只影响编译期的名字解析,不改变运行时行为,也不会把类「复制」进来。删掉一个没用的 import 不会影响功能,但会让代码更干净。

静态导入是另一种形式:

import static com.plume.library.cli.CommandNames.ADD;

之后可以直接写 ADD,不用写 CommandNames.ADD。静态导入适合常量或工具方法,但过度使用会让读者不知道名字从哪来。卷一的规模不建议大面积使用。

四种可见性

Java 有四个访问级别,作用在类、字段、方法、构造器上:

修饰符同一个类同一个包子类(不同包)任意位置
private可见不可见不可见不可见
无修饰符(包私有)可见可见不可见不可见
protected可见可见可见不可见
public可见可见可见可见

几个容易记错的点:

  • 包私有不是「默认公开」。 它是包级可见,包外一律不可见,哪怕类是 public。
  • protected 的前提是继承。 不同包的子类可以通过 this 或子类实例访问父类的 protected 成员,但不能通过父类实例访问。第 8 章会展开。
  • 顶层类只能用 public 或包私有。 不能写 private class Book,也不能写 protected class Book。内部类不受这个限制。
  • public 类必须放在同名文件里。 包私有类可以放在任意文件名里,但通常还是一个类一个文件。

访问控制不是「越严越好」。字段用 private,对外暴露方法;包内协作的辅助类用包私有;真正给外部用的 API 才用 public。这样改内部实现时,不会牵动包外代码。

在 library 里拆包

把 library 拆成两个包:com.plume.library 放入口,com.plume.library.cli 放命令相关的类。先写一个包私有的命令名常量类:

package com.plume.library.cli;

final class CommandNames {
    static final String ADD = "add";
    static final String LIST = "list";
    static final String BORROW = "borrow";
    static final String RETURN = "return";
    static final String SEARCH = "search";
    static final String QUIT = "quit";

    private CommandNames() {
    }
}

类本身没有 public,所以只有 com.plume.library.cli 包里的代码能访问它。构造器是 private,防止被实例化;所有成员都是 static,这是一个纯常量容器。

再写一个公开的用法提示类:

package com.plume.library.cli;

public final class Usage {
    private Usage() {
    }

    public static void print() {
        System.out.println("usage: library <"
                + String.join("|", CommandNames.ADD, CommandNames.LIST,
                        CommandNames.BORROW, CommandNames.RETURN,
                        CommandNames.SEARCH, CommandNames.QUIT)
                + ">");
    }
}

Usage 是 public,因为入口类要从另一个包调用它。CommandNames 是包私有,因为它只是 cli 包的内部实现,外部不需要知道命令名常量放在哪。这就是「公开的 API 窄,内部的实现可以自由改」。

入口类导入 Usage:

package com.plume.library;

import com.plume.library.cli.Usage;

public final class LibraryApp {
    public static void main(String[] args) {
        System.out.println("Hello, Library");
        if (args.length == 0) {
            Usage.print();
            return;
        }
        System.out.println("command = " + args[0]);
    }
}

编译并运行:

javac -d out $(find src/main/java -name '*.java')
java -cp out com.plume.library.LibraryApp

本机输出:

Hello, Library
usage: library <add|list|borrow|return|search|quit>

现在验证包私有边界。在 com.plume.library 包里写一个类,尝试直接引用 CommandNames:

package com.plume.library;

import com.plume.library.cli.CommandNames;

public final class AccessProbe {
    public static void main(String[] args) {
        System.out.println(CommandNames.ADD);
    }
}

编译失败,报错原文:

AccessProbe.java:3: 错误: CommandNames在com.plume.library.cli中不是公共的; 无法从外部程序包中对其进行访问
import com.plume.library.cli.CommandNames;
                            ^
AccessProbe.java:7: 错误: CommandNames在com.plume.library.cli中不是公共的; 无法从外部程序包中对其进行访问
        System.out.println(CommandNames.ADD);
                           ^
2 个错误

注意错误发生在 import 和引用两处。包私有类连「被导入」都不允许,不是等到调用方法才检查。要跨包使用,只有两条路:把 CommandNames 改成 public,或者让 cli 包提供一个公开方法(比如 Usage.print)把内部细节包起来。后者更好,因为它把变化留在包内。

模块:在包之上再加一层

包是编译期和运行期的命名空间,但它有一个历史遗留问题:只要一个类在类路径上,反射就能访问它的所有成员,不管包有没有导出。 模块(module)是 JDK 9 引入的更强边界,用 module-info.java 声明:

module com.plume.library {
    exports com.plume.library;
}

exports 表示「这个包对其他模块可见」。没有导出的包,即使类是 public,其他模块也不能访问。模块还可以用 requires 声明依赖:

module com.plume.library {
    exports com.plume.library;
    requires java.logging;
}

模块源码的目录结构和 classpath 项目不同:每个模块一个顶层目录,module-info.java 放在模块目录根部。比如 src/com.plume.library/module-info.java 和 src/com.plume.library/com/plume/library/LibraryApp.java。

编译和运行改用模块路径:

javac -d mods --module-source-path src $(find src -name '*.java')
java --module-path mods --module com.plume.library/com.plume.library.LibraryApp

本机 JDK 25 输出:

Hello, Library
usage: library <add|list|borrow|return|search|quit>

--module 后面是「模块名/主类全限定名」。只写模块名会报:

模块 com.plume.library 不具有 ModuleMainClass 属性,请使用 -m <模块>/<主类>

因为模块本身不知道哪个类是入口。要在 module-info.java 里写 mainClass,或者运行时显式指定主类。

模块和类路径的区别可以这样理解:

维度classpathmodule path
边界单位包模块
可见性public 即可见必须 exports
依赖声明靠 classpath 里的 jarrequires 显式声明
反射默认可以深入默认受限,需要 opens
上手成本低高,需要处理拆分包和自动模块

卷一的 library 不启用模块。原因很实际:模块要求所有依赖都模块化,第三方库如果没有 module-info.class,会变成「自动模块」,名字从 jar 文件名推导,容易出现冲突;卷一后面要用的 JUnit 5 在测试路径上也有自己的模块配置。第 17 章讲模块化 jar 时会再处理这些边界。现在只需要知道:包是必须的,模块是可选的;模块提供更强的封装,也带来更高的配置成本。

常见错误与边界

「包声明和目录不一致。」 package com.plume.library.cli; 的源码放在 src/main/java/com/plume/library/ 下,javac 可能仍能编译,但产物路径会按包名生成到 out/com/plume/library/cli/,而不是源码所在目录。运行时用全限定名找类,通常还能找到;但 IDE 的导航和 Maven 的增量编译会混乱。让目录和包名严格对应,是成本最低的做法。

「public 类文件名不对。」 和第 3.1 节一样:public class Usage 必须放在 Usage.java。包私有类没有这个限制,但一个文件放多个类会让包边界更难看清。

「模块里忘记 exports。」 模块编译通过,但另一个模块引用时报「包不可见」。public 只在模块内部有效,跨模块必须 exports。如果还要允许反射,得加 opens,两者不是一回事。

「模块路径和类路径混用。」 --module-path 上的模块不会自动进入 -cp,-cp 上的类也不会自动成为模块。混用时会出现「同一个包在两个地方」「自动模块名字冲突」等问题。卷一只用 classpath,就是为了避开这类复杂度。

小结

  • 包解决命名冲突、代码组织和访问控制三件事;包名反写域名,全小写,目录必须对应。
  • 四种可见性从窄到宽是 private、包私有、protected、public;顶层类只能 public 或包私有。
  • 包私有类不能被包外 import,要跨包使用必须改成 public 或提供公开方法包住实现。
  • 模块用 module-info.java 的 exports/requires 在包之上加边界,public 不再等于跨模块可见。
  • classpath 上手简单,module path 封装更强但配置成本高;卷一使用 classpath。
  • library 的入口留在 com.plume.library,命令相关实现放进 com.plume.library.cli,只暴露 Usage。

包和类都放好了,接下来要让 Maven 把「编译、测试、打包」串成一条可重复的流水线。下一节拆开 compile、test、package 三个阶段,看看每个阶段在 target 下留下什么。

阅读导航:上一节:3.1 Hello, Library:javac 与 java 的完整流程 · 下一节:3.3 Maven 生命周期:compile、test、package 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发