《Effective Java》第四章
第十五条:使类和成员的可访问性最小化
一、重要性
- 信息隐藏:降低类和成员的可访问性可以实现信息隐藏,将实现细节封装起来,只暴露必要的接口。这有助于提高软件的可维护性和可扩展性,减少因意外修改内部实现而导致的错误。
- 安全性:限制访问可以防止外部代码对关键数据和操作进行不当的访问,增强软件的安全性。
二、实现方法
- 类的访问级别:尽可能将类声明为包私有的(没有明确的访问修饰符),只有在确实需要被外部代码访问时才使用更广泛的访问修饰符(如 public)。
- 成员的访问级别:
- 对于实例变量,应该始终使用私有访问修饰符,通过公共的方法来提供对变量的访问和修改,以控制访问和确保数据的一致性。
- 方法和静态变量也应该尽可能使用最小的访问级别,只有在必要时才提高访问性。
三、考虑因素
- 设计灵活性:较低的访问级别可以在未来更轻松地修改实现,而不会影响外部代码。
- 可维护性:隐藏实现细节可以使代码更易于理解和维护,因为外部代码只需要关注公开的接口,而不必了解内部的复杂实现。
- 性能影响:虽然降低访问性本身通常不会对性能产生直接影响,但合理的封装可以减少不必要的访问和修改,从而在某些情况下提高性能。
四、特殊情况
- 框架和库:在开发框架和库时,需要仔细考虑公开的接口,以确保提供足够的功能,同时又不暴露过多的内部实现细节。
- 继承:在继承体系中,子类可能需要访问父类的受保护成员。在这种情况下,要谨慎考虑访问的必要性,并确保不会破坏封装性。
五、总结
使类和成员的可访问性最小化是良好的编程实践,可以提高软件的质量、安全性和可维护性。在设计类和接口时,应始终优先考虑最小的访问级别,只在必要时才提高访问性。
第十六条:要在公有类而非公有域中使用访问方法
一、公有域的问题
- 缺乏封装性:公有域直接暴露了类的内部状态,破坏了封装性。这使得外部代码可以随意修改对象的状态,可能导致数据不一致和错误的行为。
- 不可控的变化:如果后续需要对数据的存储方式或计算方式进行修改,由于公有域被直接访问,可能会导致大量依赖该类的代码需要进行修改,降低了软件的可维护性。
- 安全性风险:公有域可能被恶意代码利用,进行不当的操作,从而带来安全风险。
二、使用访问方法的好处
- 封装和控制:通过访问方法(getter 和 setter)可以控制对类内部状态的访问和修改。可以在方法中添加验证逻辑、计算逻辑或同步机制,确保数据的有效性和一致性。
- 灵活性:可以在不影响外部代码的情况下修改内部实现。例如,可以改变数据的存储方式、添加缓存机制或进行懒加载,而外部代码仍然通过相同的访问方法进行操作。
- 可扩展性:可以在访问方法中添加额外的功能,如日志记录、事件通知等,以便在数据被访问或修改时执行特定的操作。
三、设计原则
- 信息隐藏:将类的内部状态隐藏起来,只通过精心设计的访问方法提供对外的接口。这样可以更好地管理类的复杂性,提高代码的可读性和可维护性。
- 单一职责原则:访问方法应该专注于提供对特定数据的访问和修改功能,避免在其中包含过多与数据访问无关的逻辑。
- 一致性:确保访问方法的行为与类的整体设计和语义一致,避免出现意外的结果。
四、特殊情况
- 不可变类:对于不可变类,可以直接公开其域,因为不可变对象的状态不能被修改,不存在封装性被破坏的问题。但是,即使是不可变类,也可以考虑使用访问方法来提供更好的可读性和可维护性。
- 小型工具类:对于非常简单的工具类,可能没有必要使用访问方法,但在这种情况下也应该谨慎考虑公有域的使用是否会带来潜在的问题。
五、总结
在设计公有类时,应该避免直接公开公有域,而是使用访问方法来控制对类内部状态的访问。这样可以提高软件的封装性、可维护性和安全性,同时也为未来的扩展和修改提供了更大的灵活性。
第十七条:使可变性最小化
一、可变性带来的问题
- 复杂性增加:可变对象的状态可能在任何时候被改变,这使得代码的逻辑更加复杂,难以理解和调试。
- 线程安全问题:可变对象在多线程环境中需要额外的同步机制来确保线程安全,否则可能会出现数据不一致或竞态条件等问题。
- 不可预测性:外部代码对可变对象的修改可能会导致意外的结果,使得程序的行为难以预测。
二、使可变性最小化的方法
- 创建不可变对象:
- 声明所有的域为私有和最终的(final),确保它们在对象创建后不能被修改。
- 不提供修改对象状态的方法(setter 方法)。
- 如果对象包含对可变对象的引用,确保不返回该可变对象的引用,或者返回其不可变的副本。
- 使用不可变集合:在需要集合类型的对象时,优先使用不可变集合,如 Java 中的
Collections.unmodifiableXXX
方法创建的不可变视图,或者使用不可变集合类如java.util.ImmutableList
等。 - 避免副作用:方法应该尽量避免产生副作用,即不修改对象的状态或外部状态,而是返回一个新的对象或值。
三、好处
- 简单性和可维护性:不可变对象的行为更容易理解和预测,减少了代码的复杂性,提高了可维护性。
- 线程安全:不可变对象天然是线程安全的,无需额外的同步机制,降低了多线程编程的难度。
- 可靠性:减少了由于意外的状态改变而导致的错误,提高了程序的可靠性。
四、特殊情况
- 确实需要可变性的场景:在某些情况下,可能确实需要可变对象,例如在需要频繁修改状态的情况下。但在这种情况下,应该谨慎地设计可变部分,并确保正确处理线程安全和状态一致性问题。
- 性能考虑:在某些情况下,不可变对象的创建可能会带来一定的性能开销。但通常情况下,这种开销可以通过合理的设计和优化来降低,而且与可变性带来的问题相比,这种开销往往是可以接受的。
五、总结
使可变性最小化是一种良好的编程实践,可以提高代码的质量、可维护性和可靠性。在设计类和接口时,应该优先考虑创建不可变对象,只有在确实需要可变性时才谨慎地引入可变部分,并确保正确处理相关的问题。
第十八条:复合优先于继承
一、继承的问题
- 脆弱性:子类紧密依赖于父类的实现细节,父类的任何变化都可能导致子类出现问题。这使得继承关系较为脆弱,维护成本高。
- 有限的灵活性:Java 只支持单继承,这限制了代码的复用方式。同时,继承可能导致子类继承了一些不需要的功能,造成代码的臃肿。
- 破坏封装性:子类可以访问父类的 protected 成员,这可能破坏父类的封装性,使得父类的实现细节更容易被意外修改。
二、复合的优势
- 更灵活:通过在新的类中包含现有类的实例,可以根据需要组合不同的功能,而不受继承的限制。这种方式更加灵活,可以更好地适应变化。
- 强封装性:不会破坏被包含类的封装性,只通过公开的接口与被包含的对象交互,降低了代码之间的耦合度。
- 可维护性高:当被包含的类发生变化时,只需要在包含它的类中进行相应的调整,而不会影响其他使用该包含类的代码。
三、实现复合的方法
- 委托:将某些功能委托给被包含的对象来实现。例如,在新的类中定义方法,这些方法调用被包含对象的相应方法来完成任务。
- 组合:将多个对象组合在一起,形成一个更复杂的对象。每个对象负责自己的特定功能,通过协作来实现整体的功能。
四、适用场景
- 当需要复用多个不相关类的功能时,复合比继承更合适。
- 当希望避免继承带来的脆弱性和限制时,应优先考虑复合。
五、总结
在大多数情况下,复合比继承更能提供灵活、可维护和封装良好的代码结构。应该谨慎使用继承,只有在真正符合“is-a”关系且继承带来的好处明显大于其问题时才选择继承。而在其他情况下,优先考虑使用复合来实现代码的复用和功能扩展。
第十九条:要么设计继承并提供文档说明,要么禁止继承
一、继承的两面性
- 潜在风险:继承如果使用不当,可能会导致代码脆弱、难以维护。子类过度依赖父类的实现细节,父类的任何变化都可能对子类产生不可预测的影响。
- 强大功能:当正确设计和使用时,继承可以实现代码复用和多态性,提高开发效率。
二、设计继承的要求
- 精心设计:如果决定允许继承,类的设计必须考虑到子类的需求。方法不能过于具体或紧密耦合特定的实现,要为子类提供合理的扩展点。
- 文档说明:必须提供详细的文档,明确说明哪些方法可以被重写、重写的规则和注意事项,以及对父类状态的依赖关系等。文档应该清晰地传达类的设计意图和使用限制。
三、禁止继承的情况
- 不可变类:不可变类通常不应该被继承,因为它们的状态不能被修改,继承可能会破坏这种不变性。
- 为特定目的设计的类:如果一个类是为了特定的、独立的功能而设计,不希望被扩展或修改,应该禁止继承。可以通过将类声明为 final 或者不提供公共的或受保护的构造函数来实现。
四、决策依据
- 可维护性:考虑代码的长期可维护性。如果继承可能导致未来的维护困难,应谨慎使用或禁止继承。
- 功能需求:根据具体的功能需求来决定是否允许继承。如果不需要子类化或者继承可能带来更多问题,应禁止继承。
五、总结
在设计类时,必须明确地决定是否允许继承。如果允许继承,要精心设计并提供详细的文档说明;如果禁止继承,要采取适当的措施确保类不能被继承,以提高代码的可靠性和可维护性。
第二十条:接口优于抽象类
一、接口的优势
- 灵活性更高:
- 一个类可以实现多个接口,实现了代码的多继承效果,允许类组合不同的行为,更加灵活地适应不同的场景。
- 而一个类只能继承一个抽象类,限制了代码的复用方式。
- 更纯粹的契约:
- 接口只定义了行为规范,不包含任何实现细节,是一种更纯粹的契约。实现接口的类可以自由选择实现方式,不被强制要求遵循特定的实现路径。
- 抽象类可能包含部分实现,这可能会对子类的实现产生一定的约束。
- 解耦性更好:
- 依赖接口的代码只关心接口所定义的行为,不依赖于具体的实现类,降低了代码之间的耦合度。
- 当接口的实现发生变化时,依赖接口的代码不需要进行大量的修改。
二、抽象类的适用场景
- 部分实现共享:当存在一些通用的实现可以被多个子类共享时,抽象类可以提供这些实现,避免代码重复。
- 建立类层次结构:在建立复杂的类层次结构时,抽象类可以作为基类,为子类提供共同的属性和方法,方便代码的组织和管理。
三、选择的原则
- 优先考虑接口:在大多数情况下,应该优先考虑使用接口来定义行为规范,以获得更高的灵活性和解耦性。
- 谨慎使用抽象类:只有在确实需要共享部分实现或者建立类层次结构时,才考虑使用抽象类。并且在使用抽象类时,要注意避免过度依赖具体的实现,保持一定的灵活性。
四、总结
接口和抽象类各有其适用场景,但总体而言,接口更加灵活、解耦性更好,在设计中应该优先考虑使用接口。只有在特定的情况下,才使用抽象类来补充接口的不足。
第二十一条:为后代设计接口
一、重要性
- 前瞻性设计:设计接口时考虑到未来可能的扩展和变化,可以使接口更具生命力和适应性,避免频繁的修改和重构。
- 可维护性:良好设计的接口可以方便后续的开发和维护,减少因接口不适应新需求而带来的麻烦。
二、设计原则
- 通用性:
- 接口应该定义通用的行为和功能,而不是过于具体的操作。这样可以适应不同的实现场景和未来可能出现的新需求。
- 避免在接口中包含特定于当前实现的方法,保持接口的抽象性。
- 稳定性:
- 接口一旦发布,就应该尽量保持稳定,避免频繁的变化。因为接口的变化可能会影响到所有实现该接口的类。
- 在设计接口时,要充分考虑各种可能的情况,确保接口的设计具有足够的弹性,能够应对未来的变化。
- 可扩展性:
- 提供扩展点,让实现类有机会在不破坏接口契约的情况下扩展功能。
- 可以通过定义默认方法或使用接口扩展的方式来实现可扩展性。
三、注意事项
- 避免过度设计:不要为了未来可能的需求而过度设计接口,导致接口过于复杂和难以理解。应该在通用性和简洁性之间找到平衡。
- 兼容性:如果需要对接口进行修改,要尽量保持向后兼容性,避免破坏现有的实现。可以通过添加新方法而不是修改现有方法的方式来扩展接口。
- 文档说明:为接口提供详细的文档说明,包括接口的用途、方法的含义和使用场景等。这可以帮助后续的开发者更好地理解和使用接口。
四、总结
在设计接口时,要具有前瞻性,考虑到未来的需求和变化。遵循通用性、稳定性和可扩展性的原则,设计出简洁、灵活且易于维护的接口,为软件的长期发展奠定良好的基础。
第二十二条:接口只用于定义类型
一、明确接口的核心作用
- 定义类型:接口的主要目的是为了定义一种特定的行为规范和类型标识。它描述了一组方法签名,任何实现该接口的类都必须提供这些方法的具体实现。通过接口,我们可以以一种统一的方式来处理不同的实现类,而无需关心具体的实现细节。
- 类型标识的重要性:接口作为一种类型,可以在代码中用于变量声明、方法参数和返回值类型等。这使得代码更加灵活和可扩展,因为可以在运行时根据实际情况传入不同的实现类,而无需修改调用代码。
二、避免不恰当的使用
- 不要在接口中定义常量以外的变量:接口不应该被用作存储数据的地方。如果在接口中定义变量,可能会导致实现类之间的耦合度增加,并且难以维护。接口应该专注于定义行为,而不是存储状态。
- 不要在接口中实现方法:接口中的方法应该都是抽象的,由实现类来具体实现。如果在接口中实现方法,就违背了接口的定义类型的初衷,并且可能会导致代码的混乱和难以理解。
三、设计原则
- 单一职责原则:接口应该具有单一的职责,只定义与特定功能相关的方法。避免将多个不相关的功能放在一个接口中,以免导致接口过于庞大和复杂。
- 最小化接口:只定义必要的方法,避免过多的方法导致实现类的负担过重。接口应该是简洁而有效的,能够满足特定的需求即可。
四、好处
- 提高代码的可维护性:通过明确接口的作用,代码更加清晰易懂,易于维护和扩展。当需要修改某个功能时,只需要修改相应的实现类,而不会影响到其他部分的代码。
- 增强代码的灵活性:接口作为一种类型,可以在不同的场景中使用不同的实现类,提高了代码的灵活性和可扩展性。可以根据实际情况选择最合适的实现类,而无需修改调用代码。
- 促进代码的复用:接口可以被多个实现类实现,实现了代码的复用。不同的实现类可以根据自己的需求来实现接口中的方法,从而提高了开发效率。
五、总结
接口应该只用于定义类型,即描述一组方法签名,而不应该被用于其他不恰当的用途。遵循接口的正确使用原则,可以提高代码的可维护性、灵活性和复用性,为软件的开发带来诸多好处。
第二十三条:类层次优于标签类
一、标签类的问题
- 代码复杂:标签类通常包含一个标签字段和多个根据标签值进行不同操作的方法。这种设计会导致代码复杂、难以理解和维护。
- 类型安全问题:使用标签类时,可能会出现类型错误,因为编译器无法在编译时检查标签值的正确性。
- 性能问题:标签类的方法可能需要进行大量的条件判断,根据标签值执行不同的操作,这会影响性能。
二、类层次的优势
- 清晰的结构:通过创建类层次结构,可以将不同的行为封装在不同的子类中,使代码结构更加清晰,易于理解和维护。
- 类型安全:每个子类代表一种特定的类型,编译器可以在编译时进行类型检查,确保代码的正确性。
- 可扩展性:可以方便地添加新的子类来扩展功能,而不需要修改现有的代码。
三、实现类层次的方法
- 抽象基类:创建一个抽象基类,定义通用的方法和属性。子类继承这个基类,并实现特定的行为。
- 多态性:利用多态性,可以使用基类的引用来调用子类的方法,从而实现不同的行为。
- 工厂方法:可以使用工厂方法来创建不同类型的子类对象,根据特定的条件选择合适的子类进行实例化。
四、适用场景
- 多种行为:当需要根据不同的条件执行不同的行为时,类层次结构比标签类更加合适。
- 可扩展性要求高:如果需要频繁地添加新的行为或扩展功能,类层次结构更容易实现。
五、总结
类层次结构优于标签类,因为它提供了更清晰的代码结构、更好的类型安全和可扩展性。在设计软件时,应该优先考虑使用类层次结构来实现不同的行为,而不是使用标签类。
第二十四条:静态成员类优于非静态成员类
一、非静态成员类的局限性
- 依赖外部实例:非静态成员类与外部类的实例紧密相关,需要通过外部类的实例来创建。这会导致一些不必要的复杂性,尤其是当非静态成员类并不真正需要访问外部类实例的状态时。
- 潜在的内存占用和性能问题:由于非静态成员类与外部类实例的关联,可能会导致额外的内存占用和性能开销。在一些情况下,这可能会影响程序的性能。
二、静态成员类的优势
- 独立性:静态成员类不依赖于外部类的实例,可以独立存在和使用。这使得代码更加清晰和易于理解,也减少了不必要的依赖关系。
- 可重用性:静态成员类可以在不同的上下文中被重用,而不需要与特定的外部类实例相关联。这提高了代码的可重用性和灵活性。
- 更好的封装性:静态成员类可以更好地封装其内部实现,不会暴露外部类的实例状态。这有助于提高代码的安全性和可维护性。
三、使用场景
- 辅助类:当一个类主要是为了辅助外部类的功能而存在,并且不需要访问外部类实例的状态时,应该使用静态成员类。例如,一些工具类或辅助类可以作为静态成员类实现。
- 独立的逻辑单元:如果一个类代表一个独立的逻辑单元,与外部类的实例没有直接关系,那么静态成员类是更好的选择。
四、设计原则
- 最小化依赖:尽量减少类之间的不必要依赖关系。如果一个类不需要依赖外部类的实例,就应该将其设计为静态成员类。
- 清晰的结构:使用静态成员类可以使代码结构更加清晰,易于理解和维护。避免使用非静态成员类,除非有明确的理由需要访问外部类实例的状态。
五、总结
静态成员类在很多情况下优于非静态成员类,因为它具有更高的独立性、可重用性和更好的封装性。在设计类结构时,应该优先考虑使用静态成员类,除非有特定的需求需要使用非静态成员类来访问外部类实例的状态。
第二十五条:限制源文件为单个顶级类
一、清晰性和可读性
- 组织有序:将每个顶级类放在独立的源文件中,使得代码结构更加清晰,易于理解和维护。开发人员可以快速定位到特定的类,减少了在大型项目中查找代码的时间成本。
- 可读性提升:单个源文件专注于一个顶级类,减少了代码的混乱和干扰,提高了代码的可读性。这使得其他开发人员更容易理解类的目的、功能和结构。
二、可维护性
- 独立修改:每个顶级类的修改可以独立进行,不会影响其他顶级类所在的源文件。这降低了修改代码时引入错误的风险,并且使得版本控制和代码审查更加容易。
- 明确责任:单个源文件对应一个顶级类,明确了每个类的责任范围。开发人员可以更清楚地了解每个类的作用和功能,有助于提高代码的可维护性。
三、编译和部署效率
- 快速编译:当只需要修改一个顶级类时,编译器可以只编译该类所在的源文件,而无需重新编译整个项目。这可以大大提高编译速度,特别是在大型项目中。
- 精简部署:在部署应用程序时,只需要包含实际使用的顶级类的源文件,而无需打包整个项目的所有源文件。这可以减少部署包的大小,提高部署效率。
四、设计原则
- 单一职责原则:每个顶级类应该具有单一的职责,专注于解决一个特定的问题或提供一种特定的功能。将顶级类放在独立的源文件中有助于强化单一职责原则,使代码更加模块化和可维护。
- 封装性:独立的源文件可以更好地封装顶级类的实现细节,减少外部对类内部的依赖。这有助于提高代码的封装性和安全性。
五、总结
限制源文件为单个顶级类可以提高代码的清晰性、可读性、可维护性、编译效率和部署效率。遵循这一原则有助于构建更加模块化、易于理解和维护的软件系统。