1. 这道题到底在考什么:从“下午题第三道”说起
软件设计师下午卷的第三道题,历来是整张卷子里最“接地气”的一道。上午题考的是广度,从数据结构到操作系统再到标准化知识,什么都可能冒出来;下午题前两道考的是数据流图和数据库设计,偏建模;而第三道,几乎年年落在面向对象与程序设计这一块,语言载体通常是 Java 或 C++,2018 上半年这一份也不例外,落在 Java 上。
很多人第一次接触这道题时的反应是“代码好像看得懂,但不知道从哪下手”。这很正常。这道题给出一段不长的 Java 代码,配上若干空位,要求你补全代码、指出设计模式、说明类与类之间的关系。它不像算法题那样要求你从零推导,也不像数据库题那样要求你画 E-R 图,它考的是你能不能读懂一段别人写好的、结构完整的面向对象代码,并且理解它为什么这么设计。
我把这类题的核心能力拆成三层:第一层是语法层,你得看得懂继承、接口、抽象类、泛型、集合这些基本写法;第二层是设计层,你得能识别出这里用了什么设计模式,为什么用这个模式而不是别的;第三层是表达层,你得能用规范的语言把类之间的关系、方法的职责说清楚。三层里任何一层卡住,这道题就丢分。
这篇文章不打算只给你一份“参考答案”就完事。参考答案网上到处都是,但很多人对着答案看一遍,下次遇到同类题还是不会。我想做的是把这道题背后的东西拆开:它为什么这么出、每个空位的判断依据是什么、设计模式怎么快速识别、以及我在实际做题和带人做题过程中总结出来的那些“看答案看不出来”的经验。适合正在备考软件设计师的读者,也适合那些 Java 基础还行、但一遇到“设计模式识别”就发怵的开发者。
需要说明的是,由于原始题目正文没有提供,下面涉及的具体代码结构和空位设置,是我基于这类题目的常见命题规律做的合理还原与演绎,重点在于方法和思路,而不是逐字复现某一份真题。你完全可以把这里讲的分析框架套到任何一份同类题目上。
2. 命题人的出题逻辑:为什么偏偏是这段代码
2.1 下午第三题的稳定套路
把近些年的下午第三题摆在一起看,会发现一个很稳定的结构:给一个贴近实际业务的小场景,比如订单处理、图形绘制、设备管理、报表导出,然后用一段 Java 代码把这个场景的骨架搭出来,中间挖四到五个空。空位的位置也很有讲究,通常分布在这么几个地方:抽象类或接口的定义处、具体子类的实现处、工厂或上下文类的创建逻辑处、以及客户端调用处。
为什么这么分布?因为这几个位置恰好对应面向对象设计的几个关键决策点。抽象类定义处考的是你对“什么是稳定的、什么是变化的”的判断;子类实现处考的是多态落地;工厂或上下文处考的是对象创建与使用的分离;客户端调用处考的是你懂不懂“面向接口编程”。命题人不是随便挖空的,每一个空都对应一个知识点,这是这类题最值得琢磨的地方。
我见过不少考生,做题时习惯从第一个空顺着往下填,填不出来就卡在那里。这个策略其实不太好。更有效的做法是先通读整段代码,把场景、类名、方法名、注释都看一遍,在脑子里建立起“谁调用谁、谁继承谁”的图景,然后再回头逐个攻空。因为空与空之间往往有依赖关系,后面的空可能反过来提示前面的空该怎么填。
2.2 场景选择背后的考察意图
2018 上半年这道题所属的批次,场景设计上偏向“多类型对象的统一处理”。这类场景的好处是天然适合用多态来表达:有一组相似但不完全相同的对象,它们有共同的行为契约,但各自实现不同。命题人把共同行为抽到抽象类或接口里,把差异留给子类,这个结构一旦立起来,设计模式的影子就出来了。
这里有个容易被忽略的点:场景里的名词往往就是类名的来源,动词往往就是方法名的来源。读题时把场景描述里的关键名词圈出来,基本就能对上代码里的类;把关键动作圈出来,基本就能对上方法。这个对应关系不是巧合,是命题人为了让题目“可读”而刻意保持的一致性。抓住这一点,读代码的速度会快很多。
2.3 空位设置的四种典型类型
我把这类题的空位归纳成四种类型,做题时先给每个空归个类,心里就有底了:
| 空位类型 | 常见位置 | 判断依据 | 典型填法 |
|---|---|---|---|
| 声明型 | 类/接口/抽象方法的定义处 | 看子类有没有统一实现、看客户端怎么调用 | abstract、interface、方法签名 |
| 实现型 | 具体子类的方法体 | 看方法名语义、看父类契约 | 具体业务逻辑代码 |
| 创建型 | 工厂方法或上下文类内部 | 看返回类型、看分支条件 | new具体子类、返回对象 |
| 调用型 | 客户端或上下文的使用处 | 看多态调用意图 | 通过父类型引用调用方法 |
归好类之后,你会发现声明型和调用型往往互相印证:客户端如果用父类型引用去调某个方法,那父类型里就一定有这个方法声明;反过来,父类型里声明了抽象方法,子类就必须实现。这种互相印证是检查答案对不对的好办法。
3. 逐空拆解:每个空位的判断链路
3.1 先建立类图心智模型再动笔
动笔填第一个空之前,我强烈建议先在草稿纸上画一个极简的类关系草图。不用画得多规范,就是把类名写下来,用箭头标出继承和组合关系,把每个类里出现的方法名列在旁边。这个过程花不了两分钟,但能帮你避开大量“填了后面发现前面错了”的返工。
画草图的时候重点关注三件事:第一,有没有一个类是“根”,其他类都从它派生;第二,有没有一个类是“管理者”,它内部持有其他类的引用;第三,客户端代码里出现的变量类型是父类型还是子类型。这三件事基本决定了整段代码的设计骨架,骨架清楚了,空位填起来就是顺水推舟。
我个人的习惯是用不同符号区分:实线箭头表示继承,菱形表示持有引用,方法名直接写在类名下面。画完之后再对照代码看一遍,确认没有遗漏的类。有时候题目会故意给一个“看起来像子类但其实没被用到”的类,这种类往往是干扰项,画图时标出来,填代码时就不用管它。
3.2 抽象方法声明空:从子类反推父类契约
声明型的空最考验“反向推理”能力。题目往往把父类里某个方法的修饰符或方法体挖掉,让你填abstract或者补全方法签名。这时候不要盯着父类看,要去看子类。
具体做法是:找到所有继承这个父类的子类,看它们各自实现了哪些同名方法。如果多个子类都有同一个签名的方法,且方法体各不相同,那父类里这个方法大概率就是抽象方法。反过来,如果某个方法在父类里有默认实现、子类只是选择性覆盖,那它就不是抽象的。这个判断逻辑很朴素,但非常可靠。
还有一个细节值得注意:抽象方法的声明里不能有方法体,结尾是分号;而普通方法即使方法体为空,也得有花括号。有些空位就是考你这个格式细节,填abstract void doSomething();是对的,填abstract void doSomething(){}就错了。这种分丢得冤枉,但确实常见。
3.3 子类实现空:方法名就是需求说明书
实现型的空看起来最难,其实最好拿分,因为方法名已经把需求写明白了。比如方法叫calculateTotal,那方法体里必然是累加逻辑;方法叫formatOutput,那必然是字符串拼接或格式化。命题人起方法名不会故弄玄虚,基本都是“见名知意”。
填这类空的时候,我的经验是先看这个子类特有的属性有哪些。子类里定义的成员变量,往往就是方法体里要用到的数据。比如子类里有个unitPrice和quantity,那calculateTotal里大概率就是这两个相乘。再看父类或其他子类有没有可参考的实现,同类题里经常有“一个子类给了完整实现,另一个子类挖空”的情况,照着已给的那个改一改,逻辑就出来了。
需要提醒的是,子类实现空有时候会涉及对父类方法的调用,也就是super.xxx()。如果子类的逻辑是在父类基础上做增量,那这个调用就跑不掉。判断依据是看子类方法是不是“在通用逻辑上加点东西”,如果是,先调super再补自己的部分。
3.4 工厂与上下文空:对象创建为什么不能散落各处
创建型的空通常出现在一个“工厂”或“管理器”类里,方法根据某个参数返回不同的子类实例。这类空的核心是分支逻辑加对象实例化,填起来不难,但背后的设计意图值得说透。
为什么命题人喜欢在这里挖空?因为这里是“对象创建与对象使用分离”的体现点。如果客户端直接new具体子类,那客户端就依赖了具体实现,一旦新增子类,客户端也得改。而把创建逻辑收拢到工厂里,客户端只依赖父类型,新增子类时只改工厂,客户端不动。这就是开闭原则的落地。命题人挖这个空,本质是在考你懂不懂“为什么要多一层工厂”。
填的时候注意两点:一是分支条件要和参数语义对应,参数是字符串就按字符串匹配,是枚举就按枚举分支;二是返回值类型要是父类型,不能是具体子类型,否则多态就白搭了。这两点看着简单,但每年都有人在这里栽跟头。
3.5 客户端调用空:面向接口编程的最后一公里
调用型的空一般在main方法或某个客户端类里,考的是“拿到父类型引用之后怎么用”。正确写法永远是通过父类型变量调用方法,而不是强转成子类型再调。这个空填对了,说明你真正理解了多态;填错了,说明还停留在“知道有继承但不会用”的阶段。
举个典型场景:工厂返回一个父类型引用,客户端要调用它的业务方法。正确写法是Product p = factory.create(type); p.doSomething();,而不是Product p = factory.create(type); ((ConcreteProduct)p).doSomething();。后者虽然也能跑,但把多态的优势全丢了,是典型的“能运行但设计错误”。命题人挖这个空,就是想看你会不会犯这个错。
4. 设计模式识别:从代码结构反推模式名
4.1 模式识别的三个抓手
设计模式识别是这道题的高频考点,也是很多人最没底的部分。其实识别模式不需要背下所有模式的完整定义,抓住三个抓手就够了:看对象怎么创建、看对象怎么组合、看行为怎么分配。
对象创建集中在一个方法里、根据参数返回不同子类,这是工厂模式家族的特征;对象组合上出现“整体持有部分、部分可以独立变化”,往组合模式或装饰器模式想;行为分配上出现“把算法封装成独立类、上下文持有算法引用”,那是策略模式。这三个抓手覆盖了下午题里出现频率最高的几类模式。
我整理了一个快速对照表,做题时对着扫一眼,命中率很高:
| 代码特征 | 高度疑似模式 | 关键佐证 |
|---|---|---|
| 一个方法根据参数 new 不同子类并返回父类型 | 简单工厂 / 工厂方法 | 有分支、返回父类型 |
| 类内部持有同类型引用并可层层嵌套 | 装饰器 / 组合 | 构造器接收同类型参数 |
| 上下文类持有算法接口引用,可替换 | 策略 | 有 set 方法或构造注入 |
| 全局唯一实例、私有构造器 | 单例 | 静态方法返回自身 |
| 一系列算法各自成类、客户端选择 | 策略 / 命令 | 有统一接口、可互换 |
需要强调的是,模式识别不是猜谜,答案必须能从代码里找到证据。你说这是策略模式,就得指出哪个是策略接口、哪个是上下文、哪里发生了替换。光写个模式名不给理由,阅卷时是拿不到完整分的。
4.2 工厂家族与策略模式的易混点
工厂和策略是下午题里最容易混的一对,因为两者都涉及“一个父类型、多个子类型、一个选择逻辑”。区别在于意图:工厂解决的是“对象怎么来”,策略解决的是“行为怎么选”。
判断方法很直接:看那个持有父类型引用的类,它是“创建”了子类对象,还是“接收”了子类对象。如果是自己new出来的,偏工厂;如果是通过构造器或 setter 传进来的,偏策略。再进一步,工厂方法通常返回对象给外部用,策略模式通常是上下文内部用这个对象干活。抓住“创建”和“使用”这两个动词,基本不会混。
还有一种情况是两者结合:工厂负责造策略对象,上下文负责用。这种复合结构在真题里出现过,识别时分别指出两段逻辑即可,不用纠结“到底算哪个模式”,把两处意图都说清楚才是得分点。
4.3 模式名写对了但理由写错,照样丢分
这是我在带人做题时反复强调的一点:模式识别题的评分是按“模式名 + 判断依据”两部分给的。模式名蒙对了但依据写偏了,分数会打折。比如你写“这是工厂模式,因为有一个 create 方法”,这个理由太弱,create 方法谁都能叫。有力的理由应该是“因为该方法根据类型参数实例化不同的具体子类,并将它们统一以父类型返回,使客户端不依赖具体类”。
写理由的时候,尽量用“因为……使得……”的句式,把设计意图带出来。阅卷人看的是你懂不懂这个模式解决了什么问题,而不是你能不能背出模式的定义。这一点在备考时就要有意识地练,别等到考场上现编。
5. 类关系与多态:那些答案里不会写的细节
5.1 继承、实现、组合的表述规范
下午题里经常要求用文字描述类之间的关系,比如“类 A 与类 B 是什么关系”。这类题不难,但表述不规范会丢分。规范的说法是:继承叫“is-a”关系,写成“A 继承自 B”或“A 是 B 的子类”;实现叫“A 实现接口 B”;组合叫“A 包含 B”或“A 持有 B 的引用”,强调生命周期一致时可以说“A 由 B 组成”。
很多人习惯笼统地说“A 和 B 有关系”,这种表述在阅卷时等于没说。还有人不区分继承和实现,把implements也说成继承,严格来说是不准确的。Java 里类与类之间是单继承,类与接口之间是实现,接口与接口之间才是多继承,这个区分在描述关系时必须清楚。
5.2 多态调用在运行时的真实走向
理解多态不能停留在“父类引用指向子类对象”这句话上,得知道运行时到底发生了什么。当你写Animal a = new Dog(); a.speak();时,编译期检查的是Animal里有没有speak方法,运行期执行的是Dog里覆盖后的speak。这个“编译看左边、运行看右边”的规则,是判断调用型空位对错的根本依据。
有个常见的坑:如果子类没有覆盖父类方法,那运行时执行的就是父类的版本;如果父类方法是private或static的,那根本谈不上多态,调用的永远是父类版本。命题人有时候会在这里设陷阱,给一个static方法让你判断输出,很多人想当然地按多态去推,结果就错了。看到static和private修饰的方法,脑子里要立刻拉响警报。
5.3 抽象类与接口的取舍判断
题目里有时会问“这里为什么用抽象类而不是接口”或者反过来。回答这类问题要抓住两者的本质差异:抽象类可以包含状态(成员变量)和部分实现,接口在传统意义上只定义行为契约。如果这组类有共享的状态或通用的实现逻辑,用抽象类更合适;如果只是约定一组行为、各类实现完全独立,用接口更合适。
Java 8 之后接口可以有默认方法,这个界限变得模糊了一些,但下午题的代码风格通常还是偏传统的,看到“有成员变量、有部分方法体”基本就是抽象类,看到“全是方法签名、没有状态”基本就是接口。判断时以代码实际结构为准,别被理论上的可能性绕进去。
6. 考场上的时间分配与检查策略
6.1 这道题该给多少分钟
下午卷总共两个半小时,四道题(有些年份是五道选四)。第三道题的分值通常是 15 分,按分值比例,给它 25 到 30 分钟比较合理。但实际做题时,我建议把它放在第二或第三顺位做,不要一上来就啃它,也不要留到最后。
原因是这道题需要“进入状态”:你得先把整段代码读顺,建立类图心智模型,这个过程需要一点沉浸感。如果放在最后、时间紧张,读代码会变得急躁,容易漏看关键信息。放在中间做,前面已经做完了数据流图,脑子热起来了,后面还有时间检查,节奏最舒服。
6.2 填完之后怎么自查
填完所有空之后,别急着翻页,花三分钟做一次“通读验证”:把填好的代码从头到尾读一遍,重点看三件事。第一,语法通不通,抽象方法有没有方法体、子类有没有实现所有抽象方法、返回值类型对不对。第二,逻辑通不通,工厂的分支有没有覆盖所有子类、客户端调用的方法在父类型里存不存在。第三,设计意图通不通,你填的模式名和代码结构对不对得上。
这三遍读下来,大部分低级错误都能揪出来。我见过太多人因为少写一个abstract或者返回值类型写错而丢分,这些错误通读一遍就能发现,可惜考场上很多人填完就走。
6.3 遇到完全没思路的空怎么办
总会有那么一个空,怎么看都没头绪。这时候的策略是“先跳过、后回补”,别在一个空上耗超过三分钟。跳过去之后,先做后面的空,后面的空往往能反过来提示前面的空该怎么填。比如工厂方法里的分支条件填不出来,先看客户端传的参数是什么,参数类型和取值往往就暗示了分支该怎么写。
如果到最后还是没思路,那就按“最合理的猜测”填一个,别空着。这类题按空给分,空着必然零分,填了至少有蒙对的概率。填的时候优先填语法上说得通的,哪怕业务逻辑不确定,语法正确也能拿到部分分。
7. 从这道题延伸出去:真正该练的能力
做完这道题,如果只是对完答案就放下,那它的价值就浪费了一大半。这道题真正想训练的能力,是“读懂一段陌生代码并判断其设计合理性”,这个能力在实际开发里比任何单个知识点都值钱。
我的建议是,做完真题之后,自己动手把这段代码敲进 IDE 里跑一遍,把空位故意填错几个,看看编译报什么错、运行出什么结果。这种“故意犯错”的练习比看十遍答案都管用,因为错误信息会逼着你去理解每个修饰符、每个类型声明的真实作用。跑通之后,再试着给这段代码加一个新子类,看看需要改哪些地方。如果只需要改工厂里加一个分支、其他地方不动,说明你理解了开闭原则;如果到处都要改,说明这段代码的设计还有改进空间,这本身就是很好的思考素材。
另外,设计模式的识别能力不是靠背定义背出来的,是靠“见过足够多的真实结构”练出来的。平时看开源代码或者公司项目时,有意识地去找“这里是不是用了工厂”“这里是不是策略”,久而久之,考场上扫一眼代码结构,模式名自己就冒出来了。这种直觉,才是这道题真正想筛选的东西。
最后分享一个我自己做题时的小习惯:每道题做完,在草稿纸角落写一句话总结“这道题考的核心是什么”。2018 这道题,我写的是“多态落地与创建分离”。一句话概括,既是对这道题的收束,也是对自己知识体系的一次归档。下次遇到同类题,脑子里调出来的就是这句话,而不是一堆零散的知识点。