《On Java 中文版 基础卷》阅读笔记 实现隐藏 12

本文深入解析Java中的访问控制机制,涵盖接口实现、类与成员的访问权限(public/包私有/private/protected)、实现隐藏与封装的关系,以及JDK 9模块系统对封装边界的强化。重点说明访问权限如何分离接口与实现、保障库演进兼容性,并探讨包私有类的设计约束、单例模式实现及反射带来的封装挑战。

《On Java 中文版 基础卷》阅读笔记 实现隐藏 12

7.3 接口和实现

访问控制常常被称为实现隐藏。
将数据和方法包装在类中,并与实现隐藏相结合,称为封装 (encapsulation)。
把变量和方法定义到类中,并与访问控制相结合,称为封装。
其结果就是具有特征和行为的数据类型。

出于两个重要的原因,访问权限控制在数据类型的内部设置了访问边界。
第一个原因是确定客户程序员可以使用和不可以使用的内容。你在这个结构里创建自己的内部机制,而不必担心客户程序员会不小心将这个内部机制视为他们可以使用的接口的一部分。

这直接引出了第二个原因:将接口与实现分离。如果在一组程序里使用了该结构,客户程序员除了将消息发送到 public 接口之外什么都不能做,这样你就可以自由地更改任何非 public 的代码(例如包访问权限、protected 或 private)​,而不会破坏客户端代码。

为了清楚起见,你可能喜欢这样一种创建类的风格:将 public 成员放在开头,后面依次跟着 protected 成员、包访问权限成员和 private 成员。4这样做的好处是类的使用者可以从上往下读,首先看到的是对他们最为重要的 public 成员(因为它们可以在文件外访问)​,遇到非 public 成员的时候就可以停止阅读,因为它们是内部实现的一部分:

package hiding;
// hiding/OrganizedByAccess.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.

public class OrganizedByAccess {
  public void pub1() { /* ... */ }
  public void pub2() { /* ... */ }
  public void pub3() { /* ... */ }
  private void priv1() { /* ... */ }
  private void priv2() { /* ... */ }
  private void priv3() { /* ... */ }
  private int i;
  // ...
}

这样做仅仅使得代码阅读起来稍微容易一些,因为接口和实现仍然混合在一起。也就是说,你仍然可以看到源代码(即实现)​,因为它就在类中。
此外,Javadoc 支持的注释文档功能降低了代码可读性对客户程序员的重要程度。
向类的使用者显示接口实际上是类浏览器的工作,该工具显示了所有可用的类以及可以用它们做什么(即哪些成员可用)​。
在 Java 中,JDK 文档提供了与类浏览器相同的作用。

7.4 类的访问权限

访问权限修饰符还决定了库内部的哪些类可以提供给用户使用。
如果你希望客户程序员能使用某个类,在整个类定义时使用 public 关键字即可。
这样甚至可以控制客户程序员能否创建该类的对象。

要控制对类的访问,访问权限修饰符必须出现在关键字 class 之前:

public class Widget{

如果库的名称是 hiding,则任何客户程序员都可以通过以下方式访问 Widget:

import hiding.Widget;

或者

import hiding.*;

不过这里还有一些额外的限制。

每个编译单元(文件)都只能有一个 public 类。
这里的设计思想是,每个编译单元都有一个由该 public 类表示的公共接口。
它可以根据需要拥有任意数量的包访问权限的类。

编译单元中如果有多个 public 类,则会产生编译时错误。

public 类的名称必须与包含编译单元的文件名完全匹配,包括大小写。
所以对于 Widget,文件名必须是 Widget.java,而不是 widget.java 或 WIDGET.java。
同样,如果你不这么做,就会产生编译时错误。

尽管不常见,但编译单元里可以没有 public 类。这时你可以随意命名文件(不过随意命名会给阅读和维护代码的人带来困扰)​。

如果你在 hiding 包里有一个类,只是用来完成 Widget 类或 hiding 包中的一些 public 类的任务,那要怎么做呢?
你不想花力气为客户程序员创建文档,认为以后可能会彻底改变原来的方案,而且会删除现在的这个类,代之以不同的类。
为了实现这种灵活性,并确保没有客户程序员依赖隐藏在 hiding 包中的特定实现细节,你可以移除类的 public 关键字,来赋予它包访问权限。

当创建包访问权限的类时,将类的字段设为 private 仍然是有意义的——你应该始终尽可能地将字段设为 private ——但通常来说,给方法赋予和类相同的权限(包访问权限)是合理的。
具有包访问权限的类通常仅在包内使用,因此只有在受到强制要求的场景下,才应该将此类的方法设为 public,至于什么时候处于这些场景中,编译器会提示你的。

具体看几个例子

1. 实现接口
public interface Runnable {
    void run(); // 接口方法本质是 public abstract
}

class MyTask implements Runnable {
    // 必须写成 public,否则编译报错
    public void run() {
        System.out.println("running");
    }
}

接口里的方法默认是 public abstract,所以实现类即使本身是包私有,实现方法也必须声明为 public。

2. 重写父类 public 方法
package com.a;

public class Base {
    public void hello() {}
}

package com.a;

class Sub extends Base {
    // 不能写成默认访问,也不能写成 private/protected
    public void hello() {}
}

重写方法不能比被重写方法更受限,所以这里必须保留 public。

3. 跨包子类继承包私有父类
package com.a;

class PackagePrivateBase {
    public void doWork() {}
}

package com.b;

public class PublicChild extends PackagePrivateBase {
    public void call() {
        doWork(); // 继承到了 public 方法
    }
}

这里 PackagePrivateBase 本身对外不可见,但它的 public 方法可能被其他包的子类继承。运行时如果访问控制不一致,甚至可能出现 IllegalAccessError。

更稳妥的写法

如果这个类只在包内使用,通常优先这样写:

class Helper {
    void doSomething() { ... }        // 包内可用
    private void internal() { ... }   // 仅类内可用
}

只有当它实现接口或重写 public 方法时,才把方法写成 public。

所以可以记成:包私有类的方法默认不需要 public;但一旦涉及接口实现、方法重写、跨包继承,public 就不是风格问题,而是编译器或继承规则的要求。


请注意,类不能是 private(这将使除该类之外的任何类都无法访问它)或 protected 的。
因此,对于类访问权限,只有两种选择:包访问权限和 public。(包访问权限的类,也可以被其它包的子类继承,不能在其它包的类中,创建这个类的对象)
如果想要防止对该类的访问,可以将其所有的构造器都设为 private,从而禁止其他人创建该类的对象,而你则可以在这个类的静态方法中创建对象:

package hiding;
// hiding/Lunch.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Demonstrates class access specifiers. Make a class
// effectively private with private constructors:

class Soup1 {
  private Soup1() {}
  public static Soup1 makeSoup() {          // [1]
    return new Soup1();
  }
}

class Soup2 {
  private Soup2() {}
  private static Soup2 ps1 = new Soup2();   // [2]
  public static Soup2 access() {
    return ps1;
  }
  public void f() {}
}

// Only one public class allowed per file:
public class Lunch {
  void testPrivate() {
    // Can't do this! Private constructor:
    //- Soup1 soup = new Soup1();
  }
  void testStatic() {
    Soup1 soup = Soup1.makeSoup();
  }
  void testSingleton() {
    Soup2.access().f();
  }
}

你可以像方式 [1] 那样通过静态方法来创建对象,也可以像方式 [2] 那样创建一个静态对象,并在用户请求时返回这个对象的引用。
在这里插入图片描述
到目前为止,大多数方法返回 void 或基本类型,因此方式 [1] 中的定义乍一看可能让人有点迷惑。
方法名 (makeSoup) 之前的单词 Soup1 表示该方法返回的是什么。
以前我们通常用 void,这意味着不返回任何内容。但你也可以返回对象的引用,就像示例中展示的那样。
这个方法返回了一个 Soup1 类对象的引用。

Soup1 和 Soup2 展示了如何通过将所有构造器设为 pirvate 来阻止直接创建类的对象。
记住,如果你没有明确创建至少一个构造器,无参构造器(没有参数的构造函数)就会自动创建。如果我们自己编写了无参构造器,它就不会自动创建。
通过将它设为 private,没有人可以创建该类的对象。但是其他人要如何使用这个类呢?前面的示例提供了两种方式。
Soup1 创建了一个静态方法,用来生成 Soup1 的新对象并返回对它的引用。
如果想要在返回之前对 Soup1 执行额外的操作,或者计算一下 Soup1 对象生成的数量(可能是为了限制它们的多少)​,这种方式就十分有用了。

Soup2 使用了所谓的设计模式 (design pattern)。我们使用的这个特定模式称为单例模式 (Singleton),因为它从始至终都只有一个对象被创建。
Soup2 类的对象是作为 Soup2 的静态 private 成员被创建的,所以有且仅有一个,并且只能通过 public 方法 access() 来获取它。
在这里插入图片描述
在这里插入图片描述

7.5 新特性:模块

在 JDK 9 之前,Java 程序会依赖整个 Java 库。这意味着即使最简单的程序也带有大量从未使用过的库代码。
如果你使用了组件 A,那么 Java 语言没有提供任何支持来告诉编译器,组件 A 依赖了哪些其他组件。
如果没有这些信息,编译器唯一能做的就是将整个 Java 库包含在内。

还有一个更重要的问题。尽管包访问似乎提供了有效的隐藏,使类不能在该包外使用,但还是可以使用反射(参见第19章)来规避它。
多年来,一些 Java 程序员一直在访问部分底层的 Java 库组件,而这些库组件从未打算要被外部直接使用。
这些程序员的代码与隐藏的组件耦合了起来。
这意味着 Java 库设计者无法在不破坏用户代码的情况下修改这些组件,这极大地阻碍了对 Java 库的改进。
为了解决第二个问题,库组件需要一个对外部程序员完全不可用的选项。

JDK 9 最终引入了模块 (module),它解决了这两个问题。
Java 库设计者现在可以将代码清晰地划分为模块,这些模块以编程的方式指定它们所依赖的每个模块,并定义导出哪些组件以及哪些组件完全不可用。

JDK 9 的 Jigsaw 项目将 JDK 库拆分为一百多个平台模块。现在,当使用库组件时,你会仅仅获得该组件的模块及其依赖项,不会有不使用的模块。
如果想继续使用隐藏的库组件,你必须启用 “逃生舱口” (escape hatch),它清楚地表明你这样做违反了库的预期设计,因此将来如果因为更新这个隐藏组件(甚至完全删除)而引起任何破坏,都要由你自己来负责。

需要使用一些新的命令行标识来探索这个新的模块系统。要显示所有可用的模块,在命令提示符下运行以下命令:

java --list-modules

它会产生这样的输出:
在这里插入图片描述
@17.0.19 表示正在使用的 JDK 的版本,当引用模块时该信息不需要包括在内。要查看模块的内容,例如 base,请在命令提示符下运行:

java --describe-module java.base

输出如下:
在这里插入图片描述
这可以让你大概了解模块给 Java 库带来的是什么样的组件结构。

你也可以将模块系统用于自己的应用程序。在撰写本书时,对于大多数项目而言,收益似乎并没有高于所付出的努力。
你可以在不使用模块的情况下继续编写应用程序,这样仍然可以受益于标准 Java 库的模块化。

如果你正在编写自己的库,并且它足够大和复杂的话,你可能需要投入精力学习使用模块系统来实现它。
而对于除了大型第三方库之外的任何项目,在不使用模块的情况下构建就可以了。

7.6 总结

界限在任何关系中都很重要,所有相关方都应该遵守。
当创建了一个库时,你就与该库的使用者——客户程序员——建立了一种关系,这些使用者是另外的一些程序员,他们使用你的库来构建应用程序或更大的库。

如果没有规则,客户程序员就可以对库里的所有成员做任何事情,即使你可能并不希望他们直接操作某些成员。
在这种情况下,所有一切都是公开的。

本章研究了类如何生成,以方便构建库:
首先,介绍了一组类是如何被打包到一个库里的;
其次,介绍了类是如何控制对其成员的访问的。

据估计,用 C 语言开发的项目,当代码量达到 5 万 ~ 10 万行时就会出现问题,因为 C 语言只有单一的命名空间,这时候名称就开始冲突,导致额外的管理开销。
而在 Java 中,通过 package 关键字、包命名方案和 import 关键字,你可以完全控制名称,因此很容易避免名称冲突的问题。

控制对成员的访问权限有两个原因。
第一个原因是让用户远离他们不应该接触的部分。这部分对于类的内部操作是必需的,但并不属于客户程序员所需接口的一部分。因此,将这些方法和字段设为 private,对客户程序员而言也是一种服务,因为这样的话他们就可以轻松地了解到什么对他们重要,什么又是他们可以忽略的。这简化了他们对类的理解。
访问权限控制的第二个(也是最重要的)原因是,让库设计者可以改变类的内部实现,而不必担心会影响到客户程序员。
例如,你最初可能以某种方式构建一个类,然后发现如果重构代码的话,可以提供更快的速度。
如果接口和实现被明确分离并加以保护的话,你就可以实现这一点,而不必强迫客户程序员重写他们的代码。
访问权限控制确保客户程序员不会依赖于类的底层实现。

当有能力改变底层实现时,你不仅有了改进设计的自由,还有了犯错误的自由。无论如何仔细地规划和设计,你都会犯错误。知道所犯错误造成的影响比较小的话,你会更有实验精神,学得更快,也能更快地完成自己的项目。

类的公共接口是用户真正看到的,因此这一部分在分析和设计期间正确实现是最重要的。
即便如此,你仍然有改变的余地。如果刚开始没有设计出正确的接口,你可以添加更多方法,只要不删除客户程序员已经在代码里使用的任何方法。

注意,访问权限控制侧重于库开发者和该库的外部客户之间的关系,这也是一种通信方式。
不过有很多情况并非如此。例如,你自己编写所有的代码,或者你与一个小团队密切合作,而且所有的内容都放在同一个包中。这些情况是另一种不同的通信方式,严格遵守访问权限规则可能不是最佳选择。默认的(包)访问权限可能就够用了。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值