Java面向对象编程核心:封装、继承、多态与抽象实战解析

Java面向对象编程核心:封装、继承、多态与抽象实战解析
1. 项目概述为什么面向对象是Java的基石如果你刚开始学Java或者已经写了几个月代码但总觉得代码像一坨乱麻改一个地方牵一发动全身那多半是没把面向对象OOP这玩意儿吃透。我见过太多新手把Java当C语言来写满屏的static方法和全局变量美其名曰“工具类”结果项目稍微大点就彻底失控。今天咱们不聊那些虚头巴脑的概念就从一个干了十多年的老码农视角掰开了揉碎了讲讲Java面向对象到底该怎么用以及为什么说它是你写出“像样”代码的起点。面向对象不是Java的专利但Java是把它贯彻得最彻底的主流语言之一。它的核心就四句话把数据和对数据的操作打包在一起封装、从现有类派生出新类继承、用统一的接口操作不同的对象多态、把现实事物抽象成代码模型抽象。听起来简单吧但坑全在细节里。比如什么时候该用继承什么时候该用组合接口和抽象类到底差在哪儿为什么都说要“面向接口编程”这些问题的答案直接决定了你代码的质量是“学生作业”级别还是“生产可用”级别。这篇文章我会结合大量我踩过的坑和总结的最佳实践带你重新梳理Java面向对象的基础。目标很明确让你不仅知道这四个词是什么意思更能理解在什么场景下、为什么要这么用最终写出结构清晰、易于维护的Java代码。无论你是正在啃基础的新手还是想回头巩固的中级开发者相信都能找到对你有用的东西。2. 核心概念深度拆解不止于定义很多人对面向对象概念的理解停留在背诵阶段这会导致在实际编码时做出错误的设计决策。我们需要深入一层理解每个概念设计的初衷和要解决的问题。2.1 封装不仅仅是private封装的字面意思是“打包”但它的深层目的是隐藏复杂性和建立访问边界。private关键字只是实现封装的手段不是目的。2.1.1 封装的真正价值想象你开车你只需要知道方向盘、油门、刹车怎么用不需要了解发动机的四冲程原理和变速箱的齿轮比。汽车厂商把复杂的机械结构封装在引擎盖下只给你暴露一个简单的操作界面。这就是封装。在代码里一个设计良好的类就应该像一辆车。例如我们有一个BankAccount银行账户类public class BankAccount { private String accountNumber; // 账号 private double balance; // 余额 private String ownerName; // 户主名 // 构造函数 public BankAccount(String accountNumber, String ownerName) { this.accountNumber accountNumber; this.ownerName ownerName; this.balance 0.0; // 新账户余额为0 } // 存款对外暴露的安全操作 public void deposit(double amount) { if (amount 0) { balance amount; System.out.println(成功存入: amount); } else { System.out.println(存款金额必须为正数); } } // 取款包含业务规则校验 public boolean withdraw(double amount) { if (amount 0 amount balance) { balance - amount; System.out.println(成功取出: amount); return true; } else { System.out.println(取款失败金额无效或余额不足); return false; } } // 查询余额提供只读访问 public double getBalance() { return balance; } // 不提供setBalance方法余额只能通过deposit/withdraw改变 }注意这里的关键是没有提供setBalance()方法。如果余额可以被随意设置那么“取款前检查余额”这个核心业务规则就形同虚设。封装确保了数据完整性所有对余额的修改都必须通过你定义的安全通道deposit和withdraw进行并在这些方法内嵌入业务逻辑。2.1.2 实操心得封装的程度如何把握原则是尽可能严格。一开始所有字段都设为private然后根据需求像挤牙膏一样一点一点提供公开的getter和setter。永远问自己外部真的需要直接修改这个字段吗有没有更安全、更符合业务逻辑的方式一个常见的反例是所谓的“贫血模型”类里只有一堆private字段和对应的public getter/setter没有任何业务方法。这本质上只是把结构化数据用类包装了一下并没有实现真正的封装业务逻辑会散落在各种服务类中导致代码高度耦合。2.2 继承慎用“是一个is-a”关系继承的目的是实现代码复用和建立类型层次。但滥用继承是面向对象设计中最常见的错误之一。2.2.1 继承的陷阱与“组合优于继承”原则经典的例子Rectangle矩形和Square正方形。在数学上正方形“是一个”矩形。所以你可能会这样设计class Rectangle { protected int width; protected int height; // ... getters and setters public void setWidth(int w) { width w; } public void setHeight(int h) { height h; } public int getArea() { return width * height; } } class Square extends Rectangle { Override public void setWidth(int w) { super.setWidth(w); super.setHeight(w); // 正方形设置宽高也同步改变 } Override public void setHeight(int h) { super.setHeight(h); super.setWidth(h); // 正方形设置高宽也同步改变 } }问题来了如果有一段客户端代码它接收一个Rectangle参数并假设改变宽度不会影响高度void testArea(Rectangle r) { r.setWidth(5); r.setHeight(4); assert r.getArea() 20; // 对于正方形这里会失败因为setHeight(4)会把宽也改成4面积变成16。 }这就是著名的“里氏替换原则LSP”被违反的情况子类Square无法完全替换其父类Rectangle的所有行为。这个例子告诉我们不能仅仅根据现实世界的“is-a”关系来决定继承更要考虑在代码行为上是否真的可替换。2.2.2 什么时候该用继承当你确实存在严格的层次关系且子类是父类的一个特化并且所有父类的行为在子类中都依然合理。当你需要实现多态即通过父类类型来统一处理不同的子类对象。框架设计时为了让用户扩展功能如Spring中的ApplicationListener接口。在大多数业务代码中组合Composition是更安全、更灵活的选择。即一个类持有另一个类的实例作为自己的字段通过调用实例的方法来实现功能。// 使用组合而非继承 class Engine { public void start() { /* 启动逻辑 */ } } class Car { private Engine engine; // 汽车有一个发动机 public void start() { engine.start(); // 委托给Engine对象 // ... 汽车其他的启动逻辑 } }Car不是Engine但它拥有一个Engine。这种方式降低了耦合你以后可以轻松替换不同的Engine实现而Car的代码几乎不用变。2.3 多态面向接口编程的灵魂多态是面向对象最强大的特性之一它允许你用统一的接口操作不同的对象。多态有两种主要形式编译时多态方法重载和运行时多态方法重写 继承/接口。我们通常说的多态主要指运行时多态。2.3.1 多态如何提升代码的扩展性假设我们有一个绘图程序需要绘制多种形状。没有多态的写法难以扩展public class Painter { public void drawShape(String shapeType) { if (Circle.equals(shapeType)) { drawCircle(); } else if (Rectangle.equals(shapeType)) { drawRectangle(); } else if (Triangle.equals(shapeType)) { drawTriangle(); } // 每增加一种新形状就要修改这里的if-else逻辑 } private void drawCircle() { /* ... */ } private void drawRectangle() { /* ... */ } private void drawTriangle() { /* ... */ } }利用多态面向接口编程的写法// 1. 定义统一的接口 interface Shape { void draw(); } // 2. 各个具体类实现接口 class Circle implements Shape { Override public void draw() { System.out.println(Drawing a Circle); } } class Rectangle implements Shape { Override public void draw() { System.out.println(Drawing a Rectangle); } } // 3. 绘图器只依赖接口不依赖具体实现 public class Painter { public void drawShape(Shape shape) { // 参数是接口类型 shape.draw(); // 具体画什么由运行时传入的对象决定 } } // 使用 public class Main { public static void main(String[] args) { Painter painter new Painter(); painter.drawShape(new Circle()); // 画圆 painter.drawShape(new Rectangle()); // 画矩形 // 未来新增三角形只需新增类Painter类完全不用改 class Triangle implements Shape { Override public void draw() { System.out.println(Drawing a Triangle); } } painter.drawShape(new Triangle()); } }Painter类的drawShape方法现在非常稳定它只和Shape接口对话。无论未来世界有多少种形状只要它们实现了Shape接口Painter都能画这就是“对扩展开放对修改关闭”开闭原则。2.3.2 实操心得依赖抽象而非具体多态的精髓在于类型声明用父类/接口实例创建用具体子类。在方法参数、返回值类型、字段类型上尽量使用最抽象的接口或父类。这能极大降低模块间的耦合度。2.4 抽象化繁为简的建模能力抽象是忽略事物中与当前目标无关的非本质特征而强调与当前目标相关的本质特征。在Java中抽象主要通过抽象类abstract class和接口interface来实现。2.4.1 抽象类 vs 接口历史演变与选择这是一个经典面试题也是实际设计中每天都要做的选择。特性抽象类 (Abstract Class)接口 (Interface)定义用abstract修饰的类可以包含抽象方法和具体方法。用interface定义在Java 8前只能包含抽象方法之后可以包含默认方法(default)和静态方法(static)。设计目的代码复用 定义模板。为相关类提供一个共同的基类包含部分通用实现。定义契约/行为。规定实现类“必须能做什么”不关心“如何做”。多继承一个类只能继承一个抽象类。一个类可以实现多个接口。字段可以定义任意类型的实例变量和静态变量。Java 9前所有字段默认是public static final常量。构造器有构造器但不能实例化用于子类初始化。没有构造器。方法可以有抽象方法、具体方法、private方法等。Java 8可以有抽象方法、default方法、static方法、private方法。典型使用场景InputStream是抽象类提供了read()的抽象模板和read(byte[] b)等具体实现。List,Comparable,Runnable都是接口只定义行为。2.4.2 如何选择一个简单的决策流需要定义一组相关类的模板并且有公共的代码需要复用吗是- 考虑抽象类。例如游戏中的GameCharacter基类包含health,position字段和move()的部分实现。否- 进入第2步。需要为不相关的类定义共同的行为契约吗或者一个类需要多种不同的角色吗是- 使用接口。例如一个SmartPhone类可以实现Callable可打电话、Photographable可拍照、Playable可播放媒体等多个接口。否- 你可能不需要抽象直接用具体类。现代Java开发中接口优先是更流行的原则因为它提供了更大的灵活性多继承并且通过default方法也能提供一些默认实现模糊了与抽象类的界限。抽象类更适用于“is-a”关系非常明确且确实存在大量可共享代码的紧密类族中。3. 面向对象核心机制详解理解了四大概念我们还需要深入Java语言层面是如何实现这些概念的这涉及到内存、JVM机制等。3.1 对象创建与内存模型new一下背后发生了什么当你写下Person p new Person(张三, 25);时JVM里发生了一系列复杂操作类加载检查JVM首先检查Person类是否已被加载、链接、初始化。如果没有则执行类加载过程。分配内存在堆Heap内存中为新对象分配空间。分配方式有“指针碰撞”和“空闲列表”两种取决于垃圾收集器的算法和堆内存是否规整。初始化零值将分配到的内存空间不包括对象头都初始化为零值。这保证了对象的实例字段在不赋初值的情况下也能直接使用是数据类型的默认值如int是0boolean是false引用是null。设置对象头对象头Object Header包含两部分信息Mark Word存储对象自身的运行时数据如哈希码、GC分代年龄、锁状态标志等。类型指针指向该对象的类元数据Class对象的指针JVM通过它来确定这个对象是哪个类的实例。执行init方法这里的方法指实例构造器。首先执行父类的init方法递归然后按顺序执行本类的字段初始化赋值动作即你在代码中写的private String name “默认名”;最后执行构造器方法体中的代码即Person(String name, int age){...}里的内容。注意init方法与clinit方法不同。clinit是类构造器用于执行静态变量初始化和静态代码块在类加载的初始化阶段执行只执行一次。而init是实例构造器每次new对象都会执行。3.1.1 对象在内存中的布局了解这个有助于理解序列化、锁升级等高级话题。一个普通的Java对象在堆中的结构如下以64位JVM、开启压缩指针为例|------------------------------------------------| | 对象头 (Object Header) (12 bytes) | |------------------------------------------------| | Mark Word (8 bytes) | Klass Pointer (4 bytes) | |------------------------------------------------| | 实例数据 (Instance Data) | |------------------------------------------------| | 对齐填充 (Padding可选) | |------------------------------------------------|对齐填充不是必然存在仅起占位作用。HotSpot VM要求对象起始地址必须是8字节的整数倍因此对象大小必须是8字节的整数倍。当实例数据部分对齐后总大小不满足时就需要对齐填充。3.2 方法调用与绑定静态 vs 动态方法调用决定了JVM如何找到正确的方法体来执行。3.2.1 静态绑定前期绑定在编译期就能确定具体调用哪个方法。包括静态方法static方法与类关联而非对象。私有方法private方法外部不可访问不会被重写。final方法不可被重写。构造器。通过super关键字调用的父类方法。对于这些方法编译器在生成字节码指令时如invokestatic,invokespecial就能直接指向目标方法所在类的符号引用。3.2.2 动态绑定后期绑定在运行期根据对象的实际类型来确定调用哪个方法。主要针对重写Override的方法。JVM通过虚方法表Virtual Method Table, vtable来实现。每个类都有一个vtable里面列出了该类所有虚方法的实际入口地址。如果子类重写了父类的方法那么子类vtable中该方法的入口地址就会指向子类自己的实现。调用时JVM通过对象头中的类型指针找到对应的类再在类的vtable中找到方法的偏移量最终定位到具体的方法代码。class Animal { public void speak() { System.out.println(Animal sound); } } class Dog extends Animal { Override public void speak() { System.out.println(Woof!); } } public class Test { public static void main(String[] args) { Animal myAnimal new Dog(); // 编译时类型是Animal运行时类型是Dog myAnimal.speak(); // 输出 Woof!。编译时只知道调用Animal.speak()运行时通过vtable找到Dog.speak() } }3.2.3 实操心得理解绑定机制对性能的影响静态绑定效率高于动态绑定因为省去了运行时查表的过程。这也是为什么一些追求极致性能的库如Disruptor会大量使用final类和方法。但在大多数业务场景下动态绑定带来的多态灵活性远比那一点性能开销重要。不要过早优化清晰的设计优先。3.3 访问控制权限设计安全的API边界Java提供了四个访问权限修饰符它们定义了类、接口、成员字段、方法、构造器的可访问范围。修饰符当前类同包不同包子类不同包非子类适用场景private√×××隐藏内部实现细节仅供本类使用。default(包私有)√√××包内共享包外隔离。用于包级别的模块化。protected√√√×允许子类访问用于扩展。注意不同包的非子类不能访问。public√√√√对外公开的API。3.3.1 设计原则从最严格开始字段Field几乎总是private。通过public的getter/setter方法来提供受控的访问。对于不可变字段可以提供public的getter不提供setter。方法Method只将真正构成类对外契约的部分设为public。内部使用的辅助方法设为private。如果希望子类可以覆盖或使用但不想对外公开设为protected。类Class顶级类只能用public或default。public类名必须与文件名一致。内部类可以使用所有权限修饰符。接口Interface接口中的方法默认是public abstract字段默认是public static final。即使你不写这些修饰符编译器也会自动加上。在接口中写private方法是Java 9之后才支持的特性。注意protected的访问范围容易被误解。它允许不同包的子类访问但不允许不同包的非子类访问。这意味着如果你写了一个protected方法另一个包里的类只要继承了你这个类就能访问该方法但如果不继承即使它在同一个项目里也无法访问。4. 高级特性与设计模式初探掌握了基础我们可以看看如何运用这些特性来解决更复杂的问题这通常会引向一些经典的设计模式。4.1 构造器与初始化块对象诞生的细节对象初始化是创建对象的关键环节有多种方式可以介入这个过程。4.1.1 构造器重载与this/super关键字构造器用于初始化对象状态。可以重载多个构造器通过this(...)在一个构造器中调用另一个构造器必须放在第一行。public class User { private String username; private String email; private boolean active; // 全参构造器 public User(String username, String email, boolean active) { this.username username; this.email email; this.active active; } // 委托构造器提供默认active值 public User(String username, String email) { this(username, email, true); // 调用全参构造器 } // 无参构造器提供默认值 public User() { this(guest, guestexample.com); // 调用双参构造器 } }super(...)用于显式调用父类构造器也必须放在子类构造器第一行。如果子类构造器没有显式调用super(...)或this(...)编译器会自动插入一个无参的super()调用。如果父类没有无参构造器则子类必须显式调用父类的有参构造器。4.1.2 初始化块静态与非静态初始化块用于在构造器执行之前执行一些代码。实例初始化块每次创建对象时都会执行在父类构造器调用之后、本类构造器主体执行之前执行。多个实例初始化块按代码顺序执行。{ System.out.println(实例初始化块执行); // 可以在这里进行一些复杂的初始化逻辑 }静态初始化块在类首次加载时执行一次用于初始化静态变量。多个静态初始化块按代码顺序执行。static { System.out.println(静态初始化块执行); // 例如加载配置文件到静态变量中 }初始化顺序总结重要父类静态变量/静态块 - 子类静态变量/静态块 仅一次父类实例变量/实例块 - 父类构造器子类实例变量/实例块 - 子类构造器 每次new都执行4.2 final关键字不可变的威力final可以修饰类、方法、变量含义都是“不可变”。final类不能被继承。例如String,Integer等包装类。用于设计不希望被修改的类或者出于安全考虑如防止通过继承来篡改行为。final方法不能被子类重写。用于锁定方法行为确保父类中的关键逻辑不被修改。有时也用于优化提示编译器可以进行静态绑定。final变量final局部变量一旦赋值就不能修改。final实例变量必须在声明时、实例初始化块中或每个构造器结束前被赋值一次。赋值后不能再修改。final静态变量常量必须在声明时或静态初始化块中被赋值一次。命名通常全大写用下划线分隔。4.2.1 final与不可变对象final修饰引用变量指的是引用不可变而不是对象内容不可变。final ListString list new ArrayList(); list.add(Hello); // 正确可以修改list指向的对象的内容 list new ArrayList(); // 编译错误不能改变list的引用使其指向另一个对象要创建真正的不可变对象如String需要满足1) 类用final修饰2) 所有字段用private final修饰3) 不提供修改字段内容的方法setter4) 如果字段是可变对象的引用需要在构造器中进行防御性拷贝并且getter中返回拷贝或不可变视图。4.3 静态static的世界属于类而非对象static成员属于类在类加载时初始化与任何对象实例无关。静态变量被所有实例共享。常用于常量、计数器、共享配置等。public class Employee { private static int nextId 1; // 静态变量用于生成唯一ID private int id; public Employee() { id nextId; nextId; } }静态方法只能访问类的静态成员不能直接访问实例变量和方法因为没有this。常用于工具方法如Math.sqrt()、工厂方法。静态代码块如前所述用于初始化静态变量。静态内部类与外部类的实例无关可以独立存在。常用于Builder模式、仅与外部类相关的辅助类。注意滥用static是坏味道。它破坏了封装引入了全局状态使得代码难以测试因为状态在类之间隐式共享。除非有明确的理由如工具方法、工厂、常量否则优先考虑实例成员。4.4 常见设计模式在OOP中的体现设计模式是面向对象设计经验的总结。这里简单介绍两个最常用、最基础的模式感受一下OOP特性是如何组合起来解决特定问题的。4.4.1 单例模式Singleton确保一个类只有一个实例并提供一个全局访问点。public class Singleton { // 1. 私有静态实例类加载时创建饿汉式 private static final Singleton INSTANCE new Singleton(); // 2. 私有构造器防止外部new private Singleton() { // 初始化代码 } // 3. 公共静态方法提供全局访问点 public static Singleton getInstance() { return INSTANCE; } // 实例方法 public void doSomething() { /* ... */ } }变体懒汉式线程安全public class LazySingleton { private static volatile LazySingleton instance; // volatile防止指令重排 private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { // 第一次检查 synchronized (LazySingleton.class) { if (instance null) { // 第二次检查双重检查锁定DCL instance new LazySingleton(); } } } return instance; } }单例模式用到了private构造器封装、static变量类级唯一实例、static方法全局访问点。4.4.2 策略模式Strategy定义一系列算法将每个算法封装起来并使它们可以互相替换。策略模式让算法的变化独立于使用算法的客户。// 策略接口 interface PaymentStrategy { void pay(int amount); } // 具体策略 class CreditCardPayment implements PaymentStrategy { private String cardNumber; public CreditCardPayment(String cardNumber) { this.cardNumber cardNumber; } Override public void pay(int amount) { System.out.println(使用信用卡 cardNumber 支付 amount 元); } } class AlipayPayment implements PaymentStrategy { private String accountId; public AlipayPayment(String accountId) { this.accountId accountId; } Override public void pay(int amount) { System.out.println(使用支付宝 accountId 支付 amount 元); } } // 上下文使用策略的类 class ShoppingCart { private PaymentStrategy paymentStrategy; public void setPaymentStrategy(PaymentStrategy strategy) { this.paymentStrategy strategy; } public void checkout(int totalAmount) { if (paymentStrategy null) { throw new IllegalStateException(请先设置支付方式); } paymentStrategy.pay(totalAmount); } } // 使用 public class Main { public static void main(String[] args) { ShoppingCart cart new ShoppingCart(); cart.setPaymentStrategy(new CreditCardPayment(1234-5678)); cart.checkout(100); cart.setPaymentStrategy(new AlipayPayment(zhangsanalipay.com)); cart.checkout(200); } }策略模式完美体现了面向接口编程和多态。ShoppingCart不依赖任何具体的支付实现只依赖PaymentStrategy接口。新增一种支付方式如微信支付只需新增一个策略类ShoppingCart的代码完全不用修改符合开闭原则。5. 面向对象设计原则SOLID入门SOLID是五个重要设计原则的首字母缩写它们是写出高质量、可维护面向对象代码的指南。5.1 单一职责原则SRP一个类应该只有一个引起它变化的原因。换句话说一个类只负责一项职责。违反示例一个User类既包含用户属性name,email又包含将用户保存到数据库的方法saveToDatabase()还包含发送欢迎邮件的方法sendWelcomeEmail()。如果数据库表结构变化、邮件服务商变化都需要修改User类。遵循示例将User类拆分为User数据模型、UserRepository负责数据持久化、EmailService负责发送邮件。每个类职责单一。5.2 开闭原则OCP软件实体类、模块、函数应该对扩展开放对修改关闭。即不修改原有代码的情况下通过扩展来增加新功能。前面多态和策略模式的例子就是OCP的完美体现。5.3 里氏替换原则LSP子类必须能够替换掉它们的父类并且程序的行为不会发生变化。前面Rectangle和Square的例子就是违反了LSP。在设计继承关系时务必确保子类在所有使用父类的地方都能正常工作。5.4 接口隔离原则ISP客户端不应该被迫依赖于它不使用的接口。一个类对另一个类的依赖应该建立在最小的接口上。换句话说不要设计庞大臃肿的接口而应该将其拆分成更小、更具体的接口。违反示例一个Worker接口有work(),eat(),sleep()方法。Robot类实现了Worker但Robot不需要eat()和sleep()却被迫要实现它们可能抛出异常。遵循示例拆分为Workable有work()、Eatable有eat()、Sleepable有sleep()三个接口。Human实现所有三个Robot只实现Workable。5.5 依赖倒置原则DIP高层模块不应该依赖低层模块二者都应该依赖抽象。抽象不应该依赖细节细节应该依赖抽象。违反示例高层业务类ReportGenerator直接依赖具体的数据库操作类MySQLDatabase。如果想换成PostgreSQLDatabase必须修改ReportGenerator。遵循示例ReportGenerator依赖一个抽象的Database接口。MySQLDatabase和PostgreSQLDatabase都实现这个接口。这样更换数据库只需更换具体的实现类高层模块无需改动。这五个原则是相互关联的共同指导我们进行松耦合、高内聚的设计。刚开始可能觉得难以完全遵循但要有意识地在设计时思考这些原则代码质量会逐步提升。6. 常见问题与避坑指南在实际开发中即使理解了概念和原则还是会遇到各种坑。这里记录一些高频问题和我的处理经验。6.1 如何决定使用抽象类还是接口需要默认实现或共享状态吗是 - 抽象类否 - 接口。需要多重继承吗是 - 接口否 - 两者皆可但接口更灵活。是“is-a”关系且是紧密的类族吗是 - 抽象类否 - 接口。现代Java开发中默认选择接口除非有明确的理由需要抽象类。接口可以通过default方法提供一些实现灵活性更高。6.2 为什么重写equals()必须重写hashCode()这主要关系到基于哈希的集合类如HashMap,HashSet的正确性。这些集合依赖两个约定如果两个对象equals()返回true那么它们的hashCode()必须返回相同的值。如果两个对象hashCode()相等它们不一定equals()哈希冲突。如果只重写equals()而不重写hashCode()可能会导致两个逻辑上相等的对象因为hashCode()不同被HashMap放在不同的桶里从而无法被正确识别为同一个键。IDE如IntelliJ IDEA, Eclipse都可以一键生成符合规范的equals()和hashCode()方法。6.3 组合与继承到底用哪个记住一个简单的法则优先使用组合或聚合除非你确定需要继承。问自己“B 是一个 A 吗”is-a还是“B 有一个 A 吗”has-a。如果是“has-a”就用组合。组合示例Carhas anEngine。继承示例Circleis aShape如果符合LSP。组合更灵活降低了耦合度。继承会暴露父类的实现细节给子类“白箱复用”破坏了封装而且父类的任何改动都可能影响子类。6.4 如何处理循环依赖两个类互相引用对方例如ClassA有一个ClassB的字段ClassB也有一个ClassA的字段。这通常是设计有问题的信号表明这两个类的职责划分不清。解决方案重新审视设计看能否将公共部分提取到第三个类中或者将其中一个类作为另一个类的内部组件。使用接口解耦让ClassA依赖InterfaceBClassB实现InterfaceBClassB依赖InterfaceAClassA实现InterfaceA。这样至少在编译期解除了循环。依赖注入使用Spring这样的框架通过Setter方法或构造器在运行时注入依赖可以解决某些循环依赖问题Spring有三级缓存等机制处理循环依赖但最好还是从设计上避免。6.5 静态方法滥用有哪些坏处静态方法最大的问题是破坏了可测试性和多态性。难以模拟Mock在单元测试中你无法轻松地模拟一个静态方法调用。如果静态方法内部依赖了数据库、网络等外部资源测试会变得困难。隐藏依赖静态方法调用像全局函数它隐藏了类之间的依赖关系使得代码的理解和维护成本变高。状态共享静态变量是全局状态在多线程环境下需要小心处理同步问题容易引入难以发现的并发Bug。6.6 如何设计不可变类不可变对象是线程安全的可以自由共享简化了并发编程。设计步骤将类声明为final防止被继承和修改。将所有字段声明为private final。不提供修改对象状态的方法即没有setter。通过构造器初始化所有字段进行深度拷贝如果字段是可变对象的引用。在getter方法中返回字段的防御性拷贝而不是直接返回引用。public final class ImmutablePerson { private final String name; private final ListString hobbies; // 可变对象引用 public ImmutablePerson(String name, ListString hobbies) { this.name name; this.hobbies new ArrayList(hobbies); // 防御性拷贝 } public String getName() { return name; } public ListString getHobbies() { return new ArrayList(hobbies); // 返回拷贝保护内部状态 } }面向对象不是一天就能精通的它需要大量的实践、踩坑和反思。最好的学习方式就是阅读优秀的开源代码如JDK源码、Spring框架看看大师们是如何运用这些原则的。然后在自己写代码时时刻问自己我这样设计耦合度高吗以后好改吗别人看得懂吗多问几个为什么你的代码水平自然会不断提升。

最新新闻

日新闻

周新闻

月新闻