1. 为什么三大特性是Java的地基
先问一个很实在的问题:你写Java多久了?是不是感觉语法都认识,但一遇到稍微复杂点的项目就不知道代码该往哪儿放,类该怎么设计,改一个功能像拆炸弹一样动哪儿哪儿塌?
如果戳中你了,那问题多半出在三大特性上——封装、继承、多态。这三个词你可能在Java入门第一天就听过,甚至能背出定义,但说实话,我刚学的时候也是一脸懵:这三个东西到底是干什么用的?学完了代码该咋写还是咋写啊?直到后来写了大量代码、回过头的再看,才真正理解它们不是三个孤立的知识点,而是面向对象编程的基石,是解决“代码怎么组织才不乱”这件事的三板斧。
这篇博客就是冲着“零基础也能真正搞懂”来写的,不会像教科书那样绕概念,我会直接用大白话拆解每一个特性:它是什么、它解决了什么问题、它怎么写、它有什么坑。核心代码都会贴出来,每个例子都是实际开发里用得上的场景。不管你是刚学完Java语法的新手,还是准备面试想捡起基础的校招生,或者写了两三年代码但对设计总是“感觉不对”的工程师,这篇内容都值得你存下来反复看,尤其是面试前,它基本把你可能会被问到的点都覆盖了。
很多人在面试的时候被问“封装、继承、多态分别是什么”都能答上两句,但被问到“它们之间的关系”或者“多态的实现原理”就直接卡壳,原因就在于只背了结论,没理解本质。所以我们从最根本的“为什么需要它们”讲起。
2. 封装:把细节藏起来,把接口露出来
2.1 封装到底封的是什么
封装,听起来高大上,其实干的事特别朴素:把数据和操作数据的方法绑定在一起,同时限制外部对数据的直接访问。
用一个生活中的例子来理解。你去餐厅吃饭,只需要看菜单点菜、等菜上桌就够了。你不需要知道后厨是怎么切菜、用什么火候炒菜、用了哪些调料——这些都是“实现细节”。餐厅把细节藏起来,只暴露“点菜”这个入口给你,这就是封装。好处很明显:后厨改菜单、换厨师,只要菜品不换,你的用餐体验完全不受影响。
换成代码就更具体了。假设你在写一个银行账户类,里面有个余额字段balance。如果不做封装,直接把这个字段设为public,那任何代码都能对它随意操作:
public class BankAccount { public double balance; // 谁都能直接改 }这会造成什么后果?你辛辛苦苦写了存款、取款的业务规则(比如取款不能透支),结果外部代码直接来一句account.balance = -10000;就把所有规则绕过去了。更恐怖的是,将来你想把余额从double改成BigDecimal(金额用double会有精度问题,做金融的应该都懂),整个项目里所有碰过balance的地方全部要改,直接改到崩溃。
封装的做法是把字段私有化,只通过公开的方法来操作:
public class BankAccount { private double balance; // 私有,外部无法直接访问 public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("存款金额必须大于0"); } balance += amount; } public boolean withdraw(double amount) { if (amount <= 0 || amount > balance) { return false; // 余额不足或非法金额 } balance -= amount; return true; } public double getBalance() { return balance; } }这样外部就再也无法“绕过你的规则”去乱改余额了。想存钱?走deposit方法,先做校验再做操作。想取钱?走withdraw方法,余额不足就不让你取。将来把内部实现从double换成BigDecimal,只要方法签名不变,外部所有调用代码一行都不用动。
2.2 访问修饰符:控制谁有钥匙
封装的基础是Java的访问权限控制。很多人对private、public、protected的区别背得滚瓜烂熟,却不知道在真实项目中怎么选。这里给一个非常实用的经验:
- public:对外暴露的入口,数量越少越好。类是给别人看的“说明书”,接口(这里泛指公开方法)设计得越精简,后续改动越自由。
- private:默认首选。字段一律私有,只给自己类内部用。
- protected:只开放给子类和自己包内的类。这个用的场景相对少一些,一旦用了意味着你在为“继承扩展”留口子。
- 默认(包私有):不加任何修饰符,只在同一个包内可见。这个很容易被忽略,但它其实非常有用。比如一些内部协作的辅助类,不希望被包外部随便使用,就可以用默认权限。
我经常看一些新手代码,一上来就把所有字段都写成public“图省事”。短期看确实少写了很多getter/setter,但项目一变大,这种类就变成了一堆谁都能乱动的共享变量,出了bug根本没办法查。写代码不只是写给编译器看的,更是写给三个月后的自己和其他协作同事看的。封装最直接的价值就是让外部使用者“只能通过你设计好的方式”来操作对象,减少误操作的可能。
2.3 getter和setter的合理设计
谈到封装就绕不开getter和setter。不过我得说句泼冷水的话:“所有字段都配上getter/setter”这种操作并不叫封装,那只是把字段搬了个家而已。
真正有意义的封装,getter和setter里面应该有“业务逻辑”。举个最常见的例子:
public class User { private String password; // setter不只是赋值,还做加密 public void setPassword(String password) { if (password.length() < 8) { throw new IllegalArgumentException("密码长度至少8位"); } this.password = encrypt(password); // 加密后存储 } public boolean checkPassword(String inputPassword) { return decrypt(this.password).equals(inputPassword); } }你想想,如果密码在setter这一步就做了加密,那外部使用的时候根本不需要关心密码是怎么存的,只需要调用setPassword传入明文就行了。这就是“把复杂性藏在内部”。
再比如一个年龄字段:
public class Person { private int age; public void setAge(int age) { if (age < 0 || age > 150) { throw new IllegalArgumentException("年龄不合法"); } this.age = age; } }如果setter里什么都没做,只是this.age = age;,那跟直接用public字段其实差别不大。所以设计的时候多问自己一句:这个setter需要做什么校验?这个getter需要做什么转换?如果没有,那这个字段真的需要暴露吗?
这里有个实操中很容易踩的坑:getter返回的是对象引用还是值拷贝。如果字段是数组或集合,直接返回引用会让外部拿到地址后随意修改内部数据,破坏封装。比如
private List<String> tags;,getter应该返回new ArrayList<>(tags)或者用Collections.unmodifiableList包装一下。
2.4 封装不只是语法,更是一种设计思维
封装的深层含义其实是一种控制复杂度的手段。人脑同时能处理的上下文是有限的,一个类如果细节全摊开,谁都hold不住。封装让你面对一个对象时,只需要了解它的公开接口——怎么调用、返回什么——而不必关心内部是怎么实现的。
这就好比你用手机,根本不需要知道里面的芯片怎么布线。手机厂商把复杂度藏起来了,你只需要会点屏幕就行了。类设计也是一样的道理。
在团队协作开发中这种价值更明显。你写一个工具类给别人用,对方只需要调用你提供的方法即可,你不希望他掉进你的实现细节里被搞晕,更不希望哪天你重构了内部实现,对方却因为“依赖了内部细节”而导致代码崩溃,这时候双方都会非常痛苦。封装就是那堵墙,两边谁都不越界,协作才能顺畅。
3. 继承:复用的利器,也是责任的开始
3.1 继承的本质是一个“复用机制”
继承在Java中用extends关键字实现,语义是“子类是一个更具体的父类”。比如狗是一种动物,那么Dog类就extends Animal类。这样Animal里已经写好的属性(比如名字、年龄)和行为(比如吃东西),Dog不需要重新写,直接就能用。
为什么要搞这个东西?最直接的原因就是代码复用。想象一下没有继承的世界:你得分别写Dog类和Cat类,里面各自实现一个eat()方法,代码几乎一模一样。这时候如果需求变了,比如所有动物吃饭前都得先喝水,你就要同时改两个类,如果一共有50个动物类,就改50次。有继承的话,只需要改一次父类的eat()方法就够了,子类自动生效。
来看一个具体的代码示例:
public class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + "正在吃东西"); } } public class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name + "正在汪汪叫"); } } public class Cat extends Animal { public Cat(String name) { super(name); } public void meow() { System.out.println(name + "正在喵喵叫"); } }这样Dog类和Cat类就自动拥有了eat()方法,同时又保留了自己的独特行为。你看,代码量直接减半,以后想给所有动物加一个sleep()方法,在Animal里写一次就够了。
3.2 super关键字:子类如何正确搭父类的“便车”
super是继承里最核心的关键字,它的作用有两个:调用父类构造器和调用父类被重写的方法。
先讲构造器。Java有一条硬性规则:在子类的构造器里,必须直接或间接调用父类的构造器。如果你不写,编译器会默认自动加一个super(),调用父类的无参构造器。但是这里有个大坑:如果父类只有带参构造器而没有无参构造器,编译就报错了。绝大多数新手都在这里卡过。
public class Animal { private String name; // 只有带参构造器 public Animal(String name) { this.name = name; } } // 这样写会报错:Implicit super constructor Animal() is undefined public class Dog extends Animal { public Dog(String name) { // 编译失败,因为父类没有无参构造器 } }解决办法是显式调用父类带参构造器:
public class Dog extends Animal { public Dog(String name) { super(name); // 必须放在第一行 } }注意,super()调用必须是构造器的第一行代码,这是语法强制规定的,原因是父类的初始化必须先于子类完成——相当于盖房子要先打地基,否则子类用到了父类的字段但父类还没初始化,就会出问题。
super的第二个作用是调用父类方法。当子类重写了父类的方法,但还想调用父类的原始逻辑(很多重写是在父类基础上的扩展而非完全替代),就可以用super.方法名():
public class Dog extends Animal { @Override public void eat() { super.eat(); // 先让父类的逻辑执行 System.out.println(name + "还喜欢啃骨头"); } }这种“先父后子”的扩展方式,在项目里非常常用。比如父类里做了一个埋点统计,子类重写的时候不能丢,就必须先调用super。
3.3 方法重写的规则与常识
方法重写(Override)是继承中最容易出错的点,它是指子类对父类的某个方法重新实现。Java对重写有一堆细碎的要求,这里帮你做一个总结,建议收藏:
- 方法名必须相同,参数列表必须完全相同,否则不叫重写,叫重载。
- 返回值类型必须相同,或者返回父类返回值类型的子类型(这个叫协变返回类型,了解即可)。
- 访问权限不能比父类的更严格。比如父类是public,子类就不能把它改成protected或private,否则就不叫“重写”而叫“削弱能力”。
- 子类方法不能抛出比父类更宽泛的受检异常(checked exception)。
- 如果父类方法被final修饰,则子类不能重写。
为了让编译器帮你检查这些规则,建议重写方法时一律加上@Override注解。这个注解特别简单但功能很强大:如果你写错了规则(比如方法签名写错),编译器会直接报错提醒你,而不是让你稀里糊涂地当成新方法去写。
我自己以前就犯过这种错误:父类方法叫getUserInfo(),子类想重写它,结果不小心写成了getUserinfo()(i没大写),编译能通过,但运行时调用的根本不是同一个方法,导致各种诡异bug。后来养成了习惯,凡是想重写的方法必加@Override,这个坑就再也没踩过了。
3.4 继承的边界:单继承、final、组合优先
Java的继承有两个硬性约束值得单独拿出来说。
第一个是类的单继承。一个类只能继承一个父类,不能同时继承多个类。这是Java设计上的取舍——多继承在C++等语言里虽然强大,但极易引发“菱形继承”等问题(打个比方,两个父类都有同一个方法,子类到底继承哪一个?),所以Java直接用单继承规避了这种复杂性。那想实现“多继承”的效果怎么办?用接口(interface),一个类可以实现多个接口,接口之间的多继承是允许的。很多人在面试里被问“Java为什么不支持多继承”“那接口为什么可以多继承”,其实就是想考察你对这个取舍的理解。
第二个是final关键字。final修饰的类不能被继承,final修饰的方法不能被子类重写。一个常见的封神操作就是String类——你发现没有,String是final的,原因很直白:String是Java中使用频率最高的类型之一,它的行为被整个系统所依赖,如果允许继承并重写,一个子类完全可以破坏String的不可变特性,带来无法预估的安全风险。
不过经验之谈是:继承不是万能的,过度使用继承会让代码特别僵硬。父类和子类一旦建立关系,这个耦合就会伴随整个生命周期。父类改了某个方法,所有子类的行为都被影响,有时候这种影响是有害的。所以业界有一条很主流的设计原则叫“组合优于继承”——你并不需要“是一个”Animal才能复用eat(),完全可以持有(组合)一个Animal对象并在合适时机调用它。这个点我放到后面综合案例里再展开聊。
4. 多态:让代码对扩展开放
4.1 多态是三大特性中最“高级”的一个
如果前面的封装和继承是基础,那么多态的价值就像把前面所有的能力引爆出来。它的定义一句话就能说清:同一个类型的引用,指向不同对象时,表现出不同的行为。
面试最常考的一个案例,我觉得也是最好的案例:
Animal dog = new Dog("旺财"); Animal cat = new Cat("咪咪"); dog.eat(); // 输出:旺财正在吃东西?还是旺财正在啃骨头? cat.eat();答案是:输出的是“旺财正在进食”还是“旺财正啃骨头”,取决于Dog和Cat是否重写了eat()方法。如果用Animal类型的引用来调用eat(),实际执行的是对象真实类型所对应的方法,而不是引用类型的同名方法。这就是Java的动态绑定——运行期间,Java虚拟机根据对象的实际类型来决定调用哪个方法。这就是多态的实现原理。
这个机制带来的最大价值是:你可以面向父类型编写通用代码,而不用关心具体子类型。
4.2 重载 vs 重写:面试必考,别再搞混了
多态分为两种:编译期多态和运行期多态。编译期多态靠的是方法重载(Overload),运行期多态靠的是方法重写(Override)。
我在面试别人的时候经常问这个问题,十个人里有八个说不清楚区别。这里给你一个记忆口诀:重载看“方法名相同、参数不同”,重写看“继承后重新实现”。编译期决定重载调用,运行期决定重写调用。
表格对比更直观:
| 对比点 | 重载(Overload) | 重写(Override) |
|---|---|---|
| 类关系 | 同一个类内 | 父子类之间 |
| 方法签名 | 方法名相同,参数列表必须不同 | 方法名、参数列表都必须相同 |
| 返回类型 | 可以不同 | 必须相同或为父类的子类型 |
| 访问权限 | 随意 | 不能比父类更严格 |
| 关键字 | 无要求 | 建议加 @Override 注解 |
| 绑定时机 | 编译期 | 运行期 |
看个重载的例子:
public class Calculator { public int add(int a, int b) { return a + b; } public double add(double a, double b) { return a + b; } }这里两个add方法构成了重载,编译时根据传入参数的类型决定调用哪一个。注意,重载与返回值类型无关,只有参数列表不同才叫重载。
4.3 向上转型与向下转型
多态必然涉及对象类型的转换问题。
向上转型(把子类对象赋给父类引用)是安全的,也是多态的基础。比如Animal dog = new Dog("旺财");,这句话就像说“这只狗可以被当作动物来看待”。子类一定是一个更具体的父类,所以这个转换永远不会出错,也没有任何运行时风险。
但有意思的问题来了:向上转型后,你还能调用Dog类独有的bark()方法吗?答案是不能。
Animal dog = new Dog("旺财"); dog.bark(); // 编译错误!Animal类型没有bark方法因为编译器只认引用类型,Animal类型里根本没有定义bark()。这里暴露了很多新手的困惑点。想解决这个尴尬,就要用向下转型——把父类引用重新转回子类:
if (dog instanceof Dog) { Dog realDog = (Dog) dog; realDog.bark(); // 正常调用 }向下转型的风险在于,如果父类引用实际指向的不是这个子类对象,运行时就会抛ClassCastException。比如:
Animal cat = new Cat("咪咪"); Dog realDog = (Dog) cat; // 运行时报错:ClassCastException所以在向下转型前,建议先用instanceof做类型判断。Java 16开始支持了更简洁的instanceof模式匹配写法:
if (dog instanceof Dog realDog) { realDog.bark(); }这不仅代码更简洁,还能同时完成判断和转换,推荐使用。
4.4 多态最强大的应用场景:面向抽象编程
多态最好的实践方式是“父类定义行为规范,子类各自实现细节”。这在设计模式里被总结成“开闭原则”——对扩展开放,对修改关闭。
来一个经典案例。假设你在开发一个日志系统,对接多个日志平台:
public interface LogSender { void send(String message); } public class ConsoleLogSender implements LogSender { @Override public void send(String message) { System.out.println("控制台日志: " + message); } } public class FileLogSender implements LogSender { @Override public void send(String message) { // 假设这里实现写文件逻辑 System.out.println("文件日志: " + message); } }然后在业务代码里,你可以不关心具体是哪个平台,只面向LogSender编程:
public class Logger { private LogSender sender; public Logger(LogSender sender) { this.sender = sender; } public void log(String message) { sender.send(message); } } // 使用时: Logger logger = new Logger(new FileLogSender()); logger.log("用户登录成功");这段代码的核心价值在于:如果想对接一个新的日志平台,只需要新增一个实现LogSender的类,完全不需要改动Logger的代码。相比用if-else去判断平台类型再调用不同方法,这种“多态式”的写法扩展起来简直不要太爽。实际开发中的各种框架设计都遵循这种思路——面向接口编程,用多态把变化隔离在一个个独立的实现类里。
4.5 多态中的两个隐藏知识点:成员变量与静态方法
这是非常多人在多态上栽跟头的地方,面试也爱考。记住两条规则:
第一,成员变量不存在多态。如果父类和子类有同名字段,访问时以引用类型决定。比如:
public class Animal { String name = "动物"; } public class Dog extends Animal { String name = "狗"; } // 输出什么? Animal dog = new Dog(); System.out.println(dog.name); // 输出:动物因为字段的访问是编译期绑定,不受多态影响,它跟引用类型走。实际开发中我建议尽量避免父子类使用同名字段,这种写法迷惑性太强,代码评审时基本是个坑。
第二,静态方法不存在多态。静态方法是类级别的,编译期就绑定了,所以永远按引用类型去调用,不存在“动态绑定”一说。所以如果你把某个方法设计成static同时又企图在子类里重写它来达到多态效果,这是不可能的,这会让你写出让人困惑的代码。如果子类和父类有同名静态方法,那不是重写,是“隐藏”,调用哪个取决于引用类型,本质上跟对象没关系。
5. 三大特性协同:一个完整的实战案例
学了三个特性,很多人会问:它们到底是各干各的,还是组合在一起用?这里给一个综合案例,把封装、继承、多态全部串起来,看完你就明白它们是怎么协作的。
场景:开发一个简单的支付系统,支持支付宝支付和微信支付。
第一步:用抽象类或接口定义行为规范(多态的基础)
public abstract class Payment { protected double amount; // 封装:金额字段只能子类使用 public Payment(double amount) { this.amount = amount; } // 抽象方法:具体怎么支付,子类自己实现 public abstract void pay(); // 模板方法:支付前的通用校验 public void checkAmount() { if (amount <= 0) { throw new IllegalArgumentException("支付金额必须大于0"); } } }第二步:子类继承并用封装思想实现细节(继承+封装)
public class Alipay extends Payment { private String account; // 封装:账号私有,谁也别想从外部直接改 public Alipay(double amount, String account) { super(amount); setAccount(account); // 构造时就校验 } public void setAccount(String account) { if (account == null || account.isEmpty()) { throw new IllegalArgumentException("支付宝账号不能为空"); } this.account = account; } @Override public void pay() { checkAmount(); // 调用父类的模板方法 System.out.println("使用支付宝 " + account + " 支付 " + amount + " 元"); // 这里可能还有真实的支付接口调用逻辑 } } public class WechatPay extends Payment { private String openId; public WechatPay(double amount, String openId) { super(amount); this.openId = openId; } @Override public void pay() { checkAmount(); System.out.println("使用微信支付 openId=" + openId + " 支付 " + amount + " 元"); } }第三步:业务代码面向父类编程(多态的威力)
public class OrderService { public void checkout(Payment payment) { payment.pay(); // 同一行代码,不同支付方式表现不同行为 } } // 客户端使用: OrderService orderService = new OrderService(); orderService.checkout(new Alipay(199.00, "user@example.com")); orderService.checkout(new WechatPay(299.00, "openid_123456"));看到没有,OrderService完全不知道支付宝和微信支付的存在,它只面向Payment这个父类型。将来加一个“银行卡支付”,只需要再new一个BankCardPay类出来传进去,OrderService一行都不用改——这就是开闭原则在实践中的样子。
这时候你再回头想三大特性的关系就清晰了:封装是基础,把数据和逻辑保护在类内部;继承是手段,让子类复用父类代码并扩展新功能;多态是目标,让代码在应对需求变化时保持灵活性。三者环环相扣,谁也离不开谁。
6. 面试高频题与易错点速查
收藏这篇博客最大的价值之一就是面试前能快速过一遍高频考点。这一节我把面试中经常考到的和实际开发中容易犯错的点整理出来,一张表加几条心得,帮你在最短时间内把最容易踩的坑都扫一遍。
6.1 高频面试题整理
| 面试题 | 考察点 | 参考答案要点 |
|---|---|---|
| 重写和重载的区别 | Override vs Overload | 类关系不同、方法签名要求不同、绑定时机不同、返回类型和访问权限约束不同 |
| super和this的区别 | 关键字理解 | this指向当前对象,用于构造器重载调用、区分同名变量;super指向父类,用于调用父类构造器、方法 |
| 子类构造器为什么必须先调用父类构造器 | 构造过程 | 父类先初始化完成,子类才能安全地使用继承来的字段;super()必须是第一行 |
| 抽象类和接口的区别 | 抽象设计 | 抽象类是“是什么”,接口是“能做什么”;单继承但不限接口数量;接口默认方法可以在JDK8后给出实现;接口侧重行为规范 |
| Object类有哪些常用方法 | 根类认知 | equals、hashCode、toString、getClass、clone、finalize等 |
| 为什么Java不支持多继承 | 语言设计 | 避免菱形继承问题,接口可以变相实现多继承 |
| 向上转型和向下转型的区别 | 类型转换 | 向上转型安全自动,向下转型需要强转,配合instanceof避免异常 |
| static方法能不能重写 | 多态边界 | 不能,静态方法编译期绑定,子类同名静态方法是隐藏而非重写 |
6.2 实际开发中最容易忽略的细节
第一个是equals和hashCode必须成对重写。很多人知道这个规则,但不知道为什么。我告诉你一个最简单的场景:你把一个对象放到HashSet或HashMap里,容器是通过hashCode先定位桶,再用equals判断是否相同的。如果你只重写equals不重写hashCode,两个内容相同的对象hashCode却不同,HashMap就会把同一个逻辑上的键当成两个不同的键,导致你get(对象)拿到的是null。这在业务代码里是非常隐蔽的事故源头。
第二个是集合字段的防御性拷贝。这个前面封装部分提到过,再强调一次。如果类里有List、Map等字段,返回时务必小心,别直接返回内部引用,否则外部代码一改,你的类内部数据就悄悄变了。这是实际线上问题排查时非常常见的一个根因。
第三个是final和static的恰到好处。比如工具类,一般用final修饰类(禁止继承),构造器私有化(禁止实例化),方法用static(无需创建对象)。但要注意,static方法天然不支持多态,所以不要在业务核心逻辑里滥用static,它会把你绕开多态的设计。我在代码评审里看到有人把业务主流程写成一串static方法调用,后面想扩展、想测试,全都难搞。
6.3 学习路线建议:从会用到底层原理
大概整理一下你接下来可以怎么学,这一步我觉得对新手尤其有用:
- 第一层:会用——能够写出有封装、有继承、有多态的代码,理解基本语法和规则。
- 第二层:会设计——能判断什么场景该用继承、什么场景该用接口,避免“上来就继承”的冲动;理解开闭原则、里氏替换原则等面向对象设计原则。
- 第三层:懂原理——能说清动态绑定机制(运行时常量池里方法引用如何解析)、Java类加载过程中方法分派的具体环节。
- 第四层:能优化——知道多态虽然灵活,但JVM里方法调用经历了方法解析和分派流程,在极致性能场景下如何权衡设计模式和高频调用方法。
这些层次不是一天能跨越的,但第一、二层是你能拿到offer和把日常工作做好的底线。从今天开始,每写一个类都问自己:字段该私有吗?这里真的需要继承吗?这个设计以后加新需求会不会很痛苦?带着这些问题写代码,成长会快得多。
7. 写在最后:多年实战下来的一点心得
说实话,我写Java挺多年了,回头看自己刚入门时对三大特性的理解,真的就是“名词都认识,字面都理解,但代码该怎么乱还是怎么乱”。后来让我真正开窍的,不是多看了几本书,而是开始写健壮的业务代码、给别人做代码评审之后——被迫思考“为什么这个类要这么设计”,被迫面对“为什么线上性能出问题”,才逐步理解了这些概念背后的现实意义。
给你几个我实际工作中体会最深的建议。
第一,封装的目的不是隐藏,而是“允许被修改的自由”。你关掉字段的直接访问权限,换来的是将来修改内部实现时不用担心破坏外部调用。这种自由度在需求频繁变化的业务里价值巨大。
第二,继承之前先问“是不是”。Dog是Animal吗?是,那就继承。但Student“是”Person吗?更像是,Person扩展出了Student。如果脑子里浮现的是“我想复用Person里的toString方法”,那就不该用继承,而应该用组合或者接口。这个判断标准极其好用,我每次做设计都会过一遍。
第三,多态的威力大于你现在的直觉。当你还在一个类里写满if-else处理不同分支时,想想能不能抽一个接口、派几个实现类、用多态替换掉分支判断。我第一次把十来层if-else换成多态之后,回头再看感觉像换了一个世界——代码短了、逻辑清晰了、加新功能也不慌了。这个体验真的建议你亲测一次。
最后再分享一个小技巧:利用“行为参数化”来识别多态的使用场景。当你发现某个方法的逻辑是“根据不同类型做不同处理”时,停一下,想一想这些处理逻辑能不能抽成不同实现类里的方法,然后让调用方直接传入一个“行为对象”。这一步想通了,你的代码就开始有“设计感”了。
这篇关于三大特性的分享就到这里了。纸上得来终觉浅,绝知此事要躬行——把上面的代码亲手敲一遍,把案例自己动手改一改,再找个真实的小项目练一次手,这些概念才会真正变成你的东西。祝每个看这篇博客的人,都能写出有设计感的代码。