我带实习生也带了好几年了,每年新同学进来,前两周的代码一交上来,问题高度集中:类名特别抽象、一个类里塞了一堆不相干的事、改一个业务逻辑要牵连三四个类、问他对面向对象怎么理解,他能把封装继承多态背得滚瓜烂熟,但你让他讲清楚这三个东西到底解决了什么问题,他就愣住了。
这套东西确实挺“虚”的。它不像HashMap的扩容机制、JVM的垃圾回收那样有标准答案,它更像是软件工程里的“软技能”,但它恰恰决定了你从“能跑通代码”到“能设计好系统”之间的那道分水岭。这篇内容主要围绕面向对象范型展开,把内聚、耦合、封装、继承、多态、UML建模这些关键词串起来,结合我实际带人和评审代码时遇到的真实场景,讲清楚每一个概念背后的“为什么”。不管你是正在准备面试的实习生,还是刚工作不久想提升代码质量的后端开发,这篇都值得认真读完,最好边读边拿自己最近写的代码对照一下。
1. 面向对象不是语法糖,而是一种思维范式
1.1 从“做菜流程”说起:两种思维模式的根本差异
我经常问新同学一个问题:你写第一行Java代码的时候,用的是面向过程思维还是面向对象思维?大多数人没想过这个问题。
一个很直观的类比是做菜。面向过程的思路是:洗菜、切菜、热锅、倒油、下菜、翻炒、加调料、出锅,这是一条完整的时间线,每一步都是操作。如果需求变了,比如今天要做的是水煮鱼而不是炒青菜,你可能要整条流程都改。面向对象的思路则不一样,你先把厨房里的东西抽象成对象:菜、锅、灶、调料、厨师,每个对象有它自己的属性和行为,比如锅能加热、菜有重量和新鲜度、厨师有拿手菜。然后你只需要让“厨师”这个对象指挥其他对象协作:“菜跳进锅里,锅加热,调料撒上去”,最终得到一道菜。
写Java代码也是一样的逻辑。面向过程把数据和操作分离,数据扔给方法处理;面向对象把数据和操作绑在一起,对象自己知道该怎么处理自己的数据。Java这个语言天生就是面向对象的,所以你在Java里哪怕写一个静态工具方法,你也是把行为挂到了一个类上,这就已经在用对象思维组织代码了。但很多人只是“用Java写C语言风格代码”,方法一个接一个,类只是方法容器,完全没有发挥出面向对象的优势。
面向对象范型最核心的价值,不是让你“用类组织代码”,而是让你把真实世界的业务概念和代码结构一一映射起来。订单、用户、商品、优惠券,这些词在需求文档里是什么含义,在代码里就应该有对应的类,类之间的关系也要尽量贴近它们在业务里的关系。这样当业务变化时,你能顺着这个映射关系精准地找到该改的地方,而不是全局搜索挨个试。
1.2 类和对象:图纸、模具和实物之间的关系
类和对象的关系,最常被拿来类比的就是图纸和实物。图纸上定义了汽车有哪些属性——颜色、排量、座位数,定义了有哪些行为——加速、刹车、转弯。这一张图纸可以造出一万辆一模一样的车,每辆车是独立的对象,自己的颜色和自己当前的速度各管各的。Java里的class就是这张图纸,new关键字就是生产线,每new一次就生产一个实例对象。
但有一个细节很多人忽略了:对象不只是数据的集合,对象必须对它的数据负责。我发现有些实习生设计的类,属性全是public,或者明明有private却配了一堆无关的getter/setter,然后把所有业务逻辑都写在Service层里操作这些数据。这本质上就是用面向对象语言写面向过程代码。对象应该对外暴露行为,而不是暴露数据。你在真实世界不会说“把这个变量的值改成500”,你会说“向这个账户存入500元”。存入这个动作里有校验、有利息计算、有日志记录,这些都应该发生在对象内部,而不是由外部调用方替它做。
面向对象设计的三个步骤很重要:找名词、定行为、画关系。找名词就是提取业务里的核心实体,定行为就是确定每个实体能做什么,画关系就是搞清楚实体之间是一对多、多对多,还是组合、依赖。这个过程其实就是后面要讲的UML建模的雏形,一开始就养成这种分析习惯,后面画类图会非常顺。
1.3 面向对象解决的三个核心问题
理解了类和对象,还要再深挖一层:面向对象的封装、继承、多态,到底是为了解决什么问题?
我总结下来是三个:控制复杂度、管理变化、提高复用。
控制复杂度很好理解,一个几千行的业务逻辑如果拆成十几个相互协作的对象,每个对象只负责自己那一小块,出了问题只需要定位一个类,而不是在几百行代码里上下翻。管理变化是面向对象的另外一个核心优势,今天支付方式是微信,明天多一个支付宝,后天多一个银行卡,如果代码是写死的if-else,每一次加新支付方式都要改老代码,改着改着就改出bug了。而多态和继承就是为这种变化准备的,你只需要新增一个实现类,老代码一行不用动。复用更好理解,公共逻辑抽到父类或工具类里,一处编写处处使用,不用反复复制粘贴。
从软件工程的角度看,这三个问题的本质都指向一个词:可维护性。软件开发里有一个经验数据,一个软件系统在整个生命周期中,维护成本占了总成本的70%以上,而维护成本的大部分花在“读懂现有代码”和“修改现有代码”上。面向对象设计做得好不好,直接决定了一个团队改需求的时候是“轻轻松松加个扩展点”还是“牵一发动全身”。所以后面讲内聚和耦合,本质上都是在为可维护性服务。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内聚与耦合:代码质量的隐形天平,面试重灾区
2.1 高内聚:一个类凭什么“只做一件事”
内聚(Cohesion)这个词,字面意思是“一个模块内部元素之间结合的紧密程度”。放在Java里,就是衡量一个类里面的属性和方法是不是都围绕同一个职责展开。如果一个类里所有的方法都在围绕“订单”这个核心概念工作,它就是高内聚的;如果一个类里一半方法管订单、一半方法管用户、还有几个方法负责发短信,它就是低内聚的。
软件工程教材里给内聚划分了七个层次,从低到高分别是:偶然内聚、逻辑内聚、时间内聚、过程内聚、通信内聚、顺序内聚、功能内聚。我举几个典型例子。
偶然内聚是同一个类里放了几个毫无关系的代码片段,只是因为它们凑巧都写在同一个地方,这最典型的场景是有人把工具方法全都扔进一个叫Utils的类里,里面既有字符串处理、又有日期转换、还有MD5加密。逻辑内聚是把几个逻辑上相关的操作放在一起,比如一个方法接收一个type参数,根据type是1还是2走不同的业务分支,这种代码看起来是一个方法,实际干了三件事。功能内聚是最理想的,一个方法只做一件完整的事,比如calculateDiscount就只负责计算折扣,不负责保存到数据库、不负责通知用户。
判断一个类是否高内聚,有个非常实用的小技巧:用一句话概括这个类的职责。如果概括完之后还需要补充半句“顺便还干了……”,这个类大概率需要拆分。我在评审代码的时候经常说,类名应该能说清楚一切,看到OrderService你就能猜到里面是订单相关逻辑;看到OrderUtil、OrderHelper这种命名,你就要警惕里面是不是什么都有。
高内聚的好处是极致明显的:测试容易写,因为你不需要mock一堆不相关的依赖;代码好找,出bug了直接去对应类里翻;多人协作不会冲突,因为每个类的修改边界清晰。这三点在团队开发里每一条都至关重要。
2.2 低耦合:让修改的“地震波”传不出去
如果说内聚衡量的是“类内部拧成一股绳”的程度,那么耦合(Coupling)衡量的就是“类和类之间互相牵连”的程度。两个类之间如果修改一个必然要带动修改另一个,它们的耦合就太紧密了。
耦合也有六个等级,从高到低分别是:内容耦合、公共耦合、外部耦合、控制耦合、标记耦合、数据耦合。内容耦合是两个类直接访问对方的私有成员,这基本属于纪律问题,得杜绝。公共耦合是多个类共享同一个全局数据区,一个改了全部受影响,你如果写过一段代码把一个全局map当缓存供所有类读写,一定体会过那种改数据时胆战心惊的感觉。控制耦合是一个类通过参数控制另一个类内部的执行流程,最典型的就是传一个flag进去,A方法说“flag为1你走这个分支,flag为2你走那个分支”,这会让被调用方完全失去自主性,也最难扩展。数据耦合是最良性的,一个方法只通过参数传递基本数据,不传内部结构。
这里要说清楚一个概念,耦合不是越低越好,零耦合也意味着零协作,系统就跑不起来。我们要追求的是“低耦合”,更准确地说是有意义的、适当的耦合。比如一个OrderService依赖OrderRepository去存取数据,这种依赖合情合理,是正常的协作;但是如果OrderService还直接依赖PayService内部的PayResultHandler再去拿支付结果,这就跨越了合理边界。
降低耦合最常用的手段是面向接口编程。你在类A里调用类B的方法,如果A直接依赖B这个具体类,那A以后想换一个实现就得改A的代码;如果A依赖的是一个接口IB,然后B1、B2都实现了IB,那A完全不用动。这就是设计模式里依赖倒置原则的基本思想,也是Spring框架为什么这么流行的底层逻辑——Spring的核心就是帮你管理依赖关系,让对象之间的耦合降到最低。
2.3 动手识别坏味道:内聚耦合在代码里的具体表现
理论讲得再多,落到代码上很多人还是不知道怎么判断。我分享几个真实的“坏味道”信号,你在自己的项目里看到任何一个,都要警觉了。
第一个信号是一个类超过两百行。Java里一个高内聚的类通常不会太长,几百行就该有拆分的迹象了,上千行的类不用看内容,基本能断定是职责过多。当然例外存在于一些密集的数学计算类或者常量类,但90%的场景适用。第二个信号是某个方法超过五十行且不能一眼说出它在干嘛。过长的方法往往混入了多段逻辑,你在方法中间看到的每个注释块,都是应该抽取成独立方法的分界点。第三个信号是类里面超过五个依赖。OrderService里注入了OrderRepository、UserClient、CouponService、MessageSender、PayClient、LogService等等,这个类明显成了一个“接线中枢”,它知道太多其他类的细节,改一个周边类就可能波及它。
还有一个更隐蔽的信号是循环依赖。A依赖B、B又依赖A,这在Spring里会直接启动报错,但在非Spring项目里可能是悄悄存在的。循环依赖出现的原因是职责划分失败,A和B的边界没有切干净,有些逻辑放A里也行放B里也行,最后两边互相引用。解决思路通常是抽一个C出来,把公共部分放C里,A和B都依赖C,循环就断掉了。
我每次做Code Review的时候基本就盯这四件事:类的行数、方法里if-else的层数、依赖数量、有没有重复代码。这四件事背后对应的问题就是低内聚和高耦合。把这个自查习惯养成之后,你写的代码质量会有肉眼可见的提升。
3. 封装、继承、多态:三大基石的正确打开方式
3.1 封装:不是private加getter/setter就完事了
很多Java新手对封装的理解停留在“把属性设为private,然后生成getter/setter”。这个理解不完整,甚至有点歪。封装的核心价值是“隐藏实现细节,暴露稳定接口,约束数据合法性”。
我举个例子。你要开发一个银行账户类,账户余额balance。如果只是简单地private BigDecimal balance然后配上getBalance()和setBalance(),那setBalance这个方法本身就是个安全隐患——外部调用方想设置成负数、想直接覆盖成1个亿,你从类的外部完全没有防御手段。真正的封装应该暴露行为而不是暴露字段:提供一个deposit(BigDecimal amount)方法用于存款,内部校验amount必须大于0;提供一个withdraw(BigDecimal amount)方法用于取款,内部校验余额是否足够。这样“余额不能被改成负数”这个业务规则就固化在了类的内部,任何人调用都绕不开这个校验。
所以封装的意义有几个层面:一是保护数据完整性,业务规则在类内部统一校验,不依赖外部调用方的自觉;二是隔离变化,内部的实现细节无论怎么变,比如余额从BigDecimal改成Long、汇率计算逻辑换了,只要对外的方法签名不变,外部调用方一行代码都不用改;三是降低使用门槛,使用方不需要知道类的内部实现,只需要看方法名就知道该怎么用,这和维护“高内聚低耦合”的目标是完全一致的。
关于getter/setter,我多说一句。不是所有属性都需要setter,也不是所有属性都需要getter。如果一个字段只在类内部使用,就别暴露getter;如果一个字段不允许外部修改,就别暴露setter。把所有的字段都毫无保留地暴露成getter/setter,等于把封装的口子全打开了。你去看一些优秀的开源项目,比如Guava、Apache Commons里的一些核心类,很多字段都是private且没有getter的,这才是封装的本意——要暴露的是能力,不是数据。
3.2 继承:用对了是复用利器,用错了是灾难源头
继承是三大特性里被滥用得最严重的一个,没有之一。它的本意是“子类复用父类的属性和方法,并在此基础上扩展”,适用场景是两个类之间存在清晰的“is-a”关系,比如Dog extends Animal,猫是动物,狗也是动物,都具备“呼吸”和“移动”这些共性。
但大家在写实际业务代码的时候,经常会为了省事强行使用继承。例如有一个OrderBase父类,里面放了一些通用字段,然后NormalOrder、GiftOrder、GroupOrder都继承它。一开始当然很爽,公共代码都写在基类里了。但业务一旦复杂起来,问题就来了:GiftOrder不需要父类里的某些字段、GroupOrder想重写父类里的某个方法但怕影响其他子类、有人往父类里加了一个新字段结果所有子类都连带受影响。这种“为了复用而继承”的场景,最后都会变成维护的噩梦。
继承最大的问题在于它把父类和子类牢牢绑定在一起。父类的任何改动,哪怕只是修改一个私有方法的实现,都可能间接影响所有子类的行为。Java里又没有多继承,你一旦选了继承这条路,父类的位置就被占用了,后面想做其他维度的扩展就难了。所以现在业内更推崇的是“组合优于继承”:如果两个类之间的关系是“有一个”而不是“是一个”,就用组合。比如Car和Engine就是组合关系,车有一个引擎,而不是车继承引擎;如果业务上有DieselCar和ElectricCar,也要先考虑它们是不是应该组合一个Engine接口而不是用一个继承结构。
什么时候该用继承?我给出四个参考条件:子类确实是父类的一种、子类不需要修改父类的大部分方法、父类的方法不会频繁变化、你确定不会出现多层继承链。如果这四条有一条不满足,就优先考虑组合。还有一个经典的反面教材是“正方形继承长方形”的问题:正方形是长方形吗?数学上是,但在代码设计上不是。长方形有独立的width和height,而正方形的宽高必须联动,如果正方形继承长方形,就会违背里氏替换原则——能用长方形的地方换成正方形行为会变错。
里氏替换原则(LSP)是继承里必须懂的一条原则:子类对象必须能够替换所有父类对象,而且替换之后程序行为不会出错。你在设计继承结构时,先问自己:把父类对象换成子类对象,行为还一样吗?如果不一样,你的继承设计就是有问题的。
3.3 多态:让扩展只写新代码,不改旧代码
多态是三大特性里最优雅的一个,也是面向对象设计思想的集中体现。它的技术基础是:父类引用指向子类对象,调用同一个方法时,表现出的行为却不一样。Java里实现多态有三个必要条件:继承或者实现接口、子类重写父类方法、父类引用指向子类对象。
你可能觉得这有什么可稀奇的,但放在业务场景里,多态就是“开闭原则”的最好实现——对扩展开放,对修改关闭。举个例子,一个电商系统需要支持多种支付方式:微信支付、支付宝、银行卡支付。如果你写一个PayService,里面用if-else判断支付类型,那每加一种新支付方式就要改动这个类,改动就可能引入风险。如果你定义了一个Payment接口,里面有一个pay(PayRequest request)方法,然后WechatPay、AliPay、CardPay各自实现它,那么在OrderService里只需要注入Payment,由工厂或者Spring容器根据条件选择具体的实现类。以后再来一种“云闪付”,你只需要新建一个CloudPay类,OrderService和PayService一行代码都不用动。这就是多态的价值。
再延伸一步,多态衍生出的设计模式太多了:策略模式用多态消除if-else;模板方法模式在父类定义骨架、子类覆盖步骤;工厂模式用多态创建对象;观察者模式用多态解耦事件源和事件处理。所以别再小看多态,它是你在Java里写出优雅代码的地基。
面试里还有一个关于多态的经典问题值得准备:重载和重写的区别。重载是同一个类里方法名相同、参数列表不同,它属于编译期多态(静态多态);重写是子类覆盖父类方法,方法签名完全一致,它属于运行期多态(动态多态)。你如果能把“编译期”和“运行期”这两个词说出来,再配合一个例子,面试官基本就会对你的理解程度点头了。还有,JVM里多态的底层实现依赖“虚方法表”和“方法分派”机制,这块感兴趣的话可以再去翻一翻《深入理解Java虚拟机》的方法调用那一章。
4. UML建模实战:从需求到类图的“翻译”能力
4.1 为什么要学会画UML,UML有哪些类型
很多实习生觉得UML是“学校里学完就扔的东西”,工作中根本用不到,这其实是一种偏见。UML(Unified Modeling Language,统一建模语言)是软件设计阶段用来沟通的语言。你在开发一个稍微复杂的功能时,靠文字需求文档描述类与类之间的关系,效率极低。一张UML类图放在桌面上,参与评审的人扫一眼就能理解你的设计,哪里有问题也能快速指出来。
UML一共有十几种图,但对Java后端开发来说,最常用的是三类:类图、时序图、用例图。类图用来表达系统的静态结构——有哪些类、类之间是什么关系;时序图用来表达动态交互——某个业务流程里各个对象之间按时间顺序怎么调用;用例图用来表达用户和系统功能之间的关系,多在需求分析阶段用。如果你只学一张图,那就是类图,它是整个系统设计阶段的骨架,几乎所有面向对象设计都要靠类图把关系钉死,后面写代码只是把类图翻译成Java而已。
有人会问,现在都用Spring Boot了,画图还有意义吗?有,意义反而更大了。Spring Boot把很多底层细节替你封装了,你上手的门槛很低,但这也意味着你很少思考“对象之间到底是什么关系”。依赖注入、AOP、自动装配这些机制,本质上都是在帮你管理对象关系,而UML类图恰恰是帮助你把“对象关系”想清楚最直接的方法。你画不画图,决定了你是“拼积木”,还是“设计积木”。
4.2 类图箭头全解:依赖、关联、聚合、组合、继承、实现
UML类图里线的含义是网上搜索量很大的话题,也是很多面试官喜欢考的点。这部分我直接整理成速查表,先把六种关系中最关键的部分讲清楚。
| 关系类型 | 表示方法 | 箭头方向 | 代码体现 |
|---|---|---|---|
| 依赖 | 虚线箭头 | 指向被依赖方 | 方法参数、局部变量、返回类型 |
| 关联 | 实线箭头 | 指向被关联方 | 成员变量 |
| 聚合 | 实线+空心菱形 | 菱形在整体侧 | 成员变量,整体不拥有部分生命周期 |
| 组合 | 实线+实心菱形 | 菱形在整体侧 | 成员变量,整体管理部分生命周期 |
| 继承 | 实线+空心三角 | 三角指向父类 | extends |
| 实现 | 虚线+空心三角 | 三角指向接口 | implements |
光给出表格还不够,我给你拆开细讲一遍。
依赖(Dependency)是最弱的关系,表示一个类“用到了”另一个类,但只是临时性的,比如OrderService的一个方法里需要调用DiscountCalculator,那DiscountCalculator只是作为参数或者局部变量出现在OrderService中,这两个类地位平等,不存在谁属于谁。
关联(Association)比依赖略强,一个类把另一个类作为自己的成员变量,比如OrderService里有一个OrderRepository,这是长期稳定的合作关系。关联还可以在两端标注多重性,比如一个订单Order和OrderItem的关系是1对多,图上就在Order端写1,在OrderItem端写*。
聚合(Aggregation)和组合(Composition)是两种特殊的关联,都表示“整体-部分”的关系,区别在于生命周期的管理。聚合是弱“拥有”——比如班级和学生,班级没了,学生还在,图中用空心菱形;组合是强“拥有”——比如订单和订单项,订单没了,订单项的存在就失去意义,图中用实心菱形。在Java代码里聚合和组合常常表现不出来明显差别,都是成员变量,所以更需要在设计阶段用UML把这种语义明确下来。
继承和实现是最好记的,都是三角形箭头,实线三角是继承(extends),虚线三角是实现(implements),箭头指向父类或接口方向。这里有一个常见的错误——箭头方向画反,记住永远是“子类指向父类”,也就是箭头在父类这一端。
画UML工具方面,追求效率的可以用PlantUML,它用纯文本写脚本就能生成类图,还能直接放进Markdown文档里维护,代码仓库里记录设计文档非常方便。喜欢图形化操作的就用StarUML或者draw.io,免费好用。如果你在公司里被要求写设计文档,用PlantUML写脚本是最推荐的方式,改起来快,也方便大家Review。
4.3 实战:用类图设计一个点餐系统,再翻译成Java骨架代码
到这里我们动一次真格,走一遍从需求到类图再到Java代码的完整流程,这样前面的概念就全都串起来了。
假设需求是:顾客可以浏览菜单,把菜品加入购物车,下单后系统生成订单和订单项,顾客可以支付订单,商家可以接单。
第一步先找名词。需求文本里出现过的名词有:顾客、菜单、菜品、购物车、订单、订单项、支付。这里我们初步确定核心实体类是Customer、Menu、Dish、Order、OrderItem、Payment。
第二步定行为。Customer可以添加菜品到购物车、提交订单;Order可以计算总金额、更新状态;Payment可以发起支付、回调更新状态;Menu可以查询菜品列表;Dish有名称、价格、描述。
第三步画关系。Customer与Order是一对多的聚合关系,一个顾客可以下多个订单,订单没了不影响顾客存在,所以用空心菱形。Order与OrderItem是一对多的组合关系,订单删除后订单项没有存在意义,所以用实心菱形,且Order端标注1,OrderItem端标注*。Menu与Dish是一对多的组合关系,菜单由多个菜品组成。Order与Payment是一对一的关联关系,每个订单对应一个支付记录。OrderItem与Dish是多对一的关联关系,多个订单项指向同一个菜品,这样菜品信息可以冗余在订单项快照里,防止菜品改价后历史订单价格变动。
用PlantUML表达出来大概是这样一个类图脚本的核心部分:
plantuml复制@startuml
class Customer {
- id: Long
- name: String
+ addOrder(order: Order): void
}
class Order {
- id: Long
- status: String
+ getTotalAmount(): BigDecimal
}
class OrderItem {
- id: Long
- quantity: Integer
- price: BigDecimal
}
class Dish {
- id: Long
- name: String
- price: BigDecimal
}
class Payment {
- id: Long
- amount: BigDecimal
- status: String
}
Customer "1" o-- "0..*" Order
Order "1" *-- "1..*" OrderItem
OrderItem "0..*" --> "1" Dish
Order "1" o-- "0..1" Payment
@enduml
第四步把这个模型翻译成Java骨架代码,核心就三块。Order内部持有List<OrderItem>,并提供添加订单项、计算总金额的方法;OrderItem指向Dish的引用作为快照源;Customer持有List<Order>并维护自己的下单关系。
java复制public class Order {
private Long id;
private OrderStatus status;
private List<OrderItem> items;
public void addItem(Dish dish, int quantity) {
OrderItem item = new OrderItem(dish, quantity);
items.add(item);
}
public BigDecimal getTotalAmount() {
return items.stream()
.map(OrderItem::getSubTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
java复制public class OrderItem {
private Long id;
private Dish dish;
private int quantity;
private BigDecimal snapshotPrice;
public OrderItem(Dish dish, int quantity) {
this.dish = dish;
this.quantity = quantity;
this.snapshotPrice = dish.getPrice();
}
public BigDecimal getSubTotal() {
return snapshotPrice.multiply(BigDecimal.valueOf(quantity));
}
}
java复制public class Customer {
private Long id;
private String name;
private List<Order> orders = new ArrayList<>();
public Order createOrder() {
Order order = new Order();
orders.add(order);
return order;
}
}
这段代码设计完成之后,你再回头看前面的内聚和耦合概念:Order类只负责订单自身的状态管理,它没有把支付逻辑写进来;OrderItem保存了快照价格,不依赖Dish的价格实时变动;Customer只维护自己的订单列表,不知道订单内部怎么计算金额。这几个类的内部都是高内聚的,类之间的依赖都是合理且可控的低耦合。这种设计带来的好处是,以后要加“优惠券功能”,你只需要扩展Order,加一个Coupon关联,完全不会波及Customer和OrderItem。
5. Java实习生的进阶实操:面试怎么答、代码怎么写
5.1 面试被问“面向对象三特性”时,怎么答出层次感
“请说一下面向对象的三大特性”,这是一道面试出现频率极高的基础题。很多同学的回答是:“封装就是把属性私有化,继承就是子类继承父类,多态就是同一个方法有不同的实现。”这么说不能算错,但毫无区分度。
我建议的回答框架是这样:先一句话点出三特性的目的,再分别用业务场景举例,最后落到设计原则上。举个例子,你可以这么答:
面向对象的三大特性封装、继承、多态,本质上是为软件的“可维护性”和“可扩展性”服务的。封装的核心是隐藏实现细节、约束数据合法入口,比如一个账户余额字段,我们不直接暴露setter,而是通过deposit和withdraw两个方法来保证余额不被篡改。继承解决的是代码复用,但要注意使用场景,只有“is-a”关系才适合继承,而且要满足里氏替换原则,否则推荐用组合替代。多态是开闭原则的落地,比如支付场景,定义一个Payment接口,让微信、支付宝、银行卡各自实现,后续新增支付方式时只加类不改旧代码。
这段话包含了概念、原理、示例、设计原则四个层次,面试官听到“里氏替换”“开闭原则”“is-a关系”这些关键词,就会知道你不仅仅是背了概念,而是真正理解过。
面试再深一层可能会问:“你是怎么在实际项目中使用了面向对象设计的?”这个时候你如果回答“我写过实体类、Service、Controller”就太浅了,更好的回答是讲一个你重构过的故事,比如“原来某个Service里有一个if-else判断支付方式的路由,后来我用策略模式抽取了PaymentStrategy接口,三个实现类分别处理三种支付,新增支付方式时不需要改动原有逻辑”。这种故事不需要多宏大,但必须真实,哪怕是一个很小的功能点重构,只要能说明你理解了面向对象解决实际问题的方式,就比背书强很多。
5.2 日常开发里的自查清单:拿到代码先看这五个问题
面试表现只是一方面,日常写代码的质量才是真正拉开差距的地方。我带实习生的时候会给一份Code Review自查清单,整理出来你可以直接抄作业。
第一个问题:类名是否直观传达了职责?看到OrderService知道是订单服务,看到OrderProcessTaskHandler你就得想一想,这说明命名没到位。第二个问题:每个方法是否只做了一件事?一个方法里如果有多个“段落”,每个段落用注释块分隔,那每个段落都应该拆成独立方法。第三个问题:类之间是依赖具体类还是依赖接口?如果调用方直接依赖具体实现类,后面要替换实现的时候就会被卡住。第四个问题:有没有循环依赖或过深的调用链?A调B、B调C、C又调A,这种代码定位问题极其痛苦。第五个问题:这个类是否有明显超过职责的东西?比如一个订单服务里出现了短信文案、Excel导出、邮件模板,就要考虑把横切关注点拆出来。
另外我建议每个实习生都做一个练习:找一个你自己写过的最“丑”的类,试着按下面的方式重构一遍。第一步把所有私有字段改成不可变,尽可能减少setter;第二步把方法按职责分组,超过五十行的方法拆开;第三步把if-else分支替换成多态或者策略模式;第四步把类之间的直接依赖改成接口依赖。一套操作下来你会发现,代码行数可能没变,但可读性和可维护性完全不是一个量级了。
顺着这个思路,想进阶的话可以去看《Effective Java》这本书。我印象最深的是它开篇就讲“用静态工厂方法代替构造器”“用私有构造器强化不可实例化”,这些建议看起来和面向对象关系不大,但本质上都是在帮你写出更规范的对象生命周期。再看一些成熟开源项目的源码,比如Spring框架里的类设计,你会发现几乎所有类都是高内聚低耦合的——它们的接口划分极其精细,每个类只关注自己那一小段职责,这正是面向对象设计做到位的样子。
在整个带新人的过程里,我见过太多把八股文背得滚瓜烂熟、但一写代码就原形毕露的同学,也见过那种基础语法还不太熟练、但分得清哪些逻辑该放哪个类、哪些变化点需要预留扩展口的同学。后者往往过半年就能独当一面,前者则还困在“能跑就行”的舒适区里。面向对象的设计能力不是突击出来的,它是在一次次重构、一次次评审、一次次踩坑之后沉淀下来的,你手上最值得练的就是那些你正在写、正在维护的真实代码。把这篇文章里的方法拿回去用起来,比记住任何概念都管用。
