3. 面向对象与设计原则
面试中问到面向对象,很多候选人会背“封装继承多态”六个字,但真正拉开差距的是:你能不能把这三个词落到代码结构上,并解释清楚为什么这样设计。本节围绕封装、继承、多态、接口与抽象类、SOLID、组合优于继承、依赖注入这几条主线,讲清楚面试官想听什么,以及你该怎么答。
一、封装、继承、多态:面试官真正在考什么
封装不是“把字段改成 private 再加属性”,而是把变化约束在边界内。面试常问:“为什么属性不直接用 public 字段?”标准答法是:属性是方法,可以加校验、日志、懒加载,且二进制兼容性更好。但更值得说的是:封装的本质是隐藏实现细节,暴露稳定契约。比如一个订单金额字段,如果外部能随意赋值,那么“金额不能为负”这条规则就散落在调用方;把它收进 Order.SetAmount() 或构造函数,规则就只有一处。
继承要谨慎。面试高频陷阱题:“子类继承父类,父类新增一个方法,子类行为会不会被破坏?”这就是里氏替换原则要解决的问题。继承表达的是“is-a”,但很多场景其实是“has-a”或“can-do”,用继承会带来强耦合。一个典型反例:让 Stack 继承 List,结果 Stack 就暴露了 Insert(0, x),破坏了栈的语义。
多态的价值在于调用方依赖抽象,运行期决定具体行为。面试中常让你手写一个多态示例,建议不要只写 Animal.Speak(),而是结合业务:支付方式 IPayment,有 AlipayPayment、WechatPayment,下单时注入不同实现,调用方代码不变。这样既展示语法,也展示设计意图。
二、接口与抽象类:怎么选,怎么答
面试标准对比是:接口定义“能做什么”,抽象类定义“是什么”,并共享部分实现。但真正加分的回答是给出选择依据:
- 需要多继承行为契约、跨不相关类型统一能力 → 接口,例如
IDisposable、IComparable。 - 多个子类共享公共字段、构造函数逻辑、模板方法 → 抽象类,例如
Stream。 - 既要契约又要默认实现 → C# 8 之后接口可有默认实现,但不要滥用,它主要解决版本兼容,不是替代抽象类。
一个常见追问:“接口能不能有字段?”不能,接口不能有实例字段,但可以有静态字段(C# 8+ 静态成员)。回答时点出“接口描述能力,不保存状态”即可。
三、SOLID:不要背字母,要讲场景
SOLID 五条原则,面试官更想听你踩过哪些坑。下面用一张表把“原则—反例—修正”对应起来,便于记忆:
| 原则 | 典型反例 | 修正方向 |
|---|---|---|
| 单一职责 SRP | 一个 UserService 同时管注册、发邮件、写日志 | 拆成 UserService、IEmailSender、ILogger |
| 开闭 OCP | 每加一种支付方式就改 switch | 抽象 IPayment,新增实现类 |
| 里氏替换 LSP | 子类抛异常或改变父类语义 | 改用组合,或重新抽象 |
| 接口隔离 ISP | 一个胖接口逼实现类写空方法 | 拆成多个小接口 |
| 依赖倒置 DIP | 高层直接 new 低层具体类 | 依赖抽象,由容器注入 |
注意:SOLID 不是教条。面试中如果被问“是不是所有类都要单一职责”,正确态度是:职责划分取决于变化原因,同一个变化原因归一处,不同变化原因分开。过度拆分同样会增加复杂度。
四、组合优于继承:一个可运行的判断过程
假设要做日志功能,有“写文件”和“写数据库”两种输出,还要支持“加时间戳”。如果用继承,会得到 FileLogger、DbLogger、TimestampFileLogger、TimestampDbLogger,组合爆炸。用组合则拆成两个维度:
public interface ILogWriter {
void Write(string message);
}
public class FileWriter : ILogWriter {
public void Write(string message) => File.AppendAllText("app.log", message);
}
public class TimestampDecorator : ILogWriter {
private readonly ILogWriter _inner;
public TimestampDecorator(ILogWriter inner) => _inner = inner;
public void Write(string message) =>
_inner.Write($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}");
}
// 使用:文件 + 时间戳
ILogWriter writer = new TimestampDecorator(new FileWriter());
writer.Write("订单创建成功");
运行后 app.log 中会出现类似 [2025-01-01 10:00:00] 订单创建成功 的一行(时间以实际运行为准)。这个例子说明:组合把“输出方式”和“增强逻辑”解耦,新增一种输出或一种装饰都不需要改已有类。面试时能写出装饰器模式并解释它如何替代继承,通常能明显加分。
五、依赖注入:面试里怎么体现“思想”而不只是“会用”
依赖注入不是“用了 IServiceCollection 就算会”。面试官常问:“为什么要注入,直接 new 不行吗?”核心答案是:把依赖的创建权从类内部移到外部,从而让类依赖抽象、便于替换和测试。下面是一个可测试的对比:
// 反例:硬编码依赖,无法替换
public class OrderService {
private readonly EmailSender _sender = new EmailSender();
public void Create() { /* ... */ _sender.Send("..."); }
}
// 正例:依赖抽象,构造注入
public class OrderService {
private readonly IEmailSender _sender;
public OrderService(IEmailSender sender) => _sender = sender;
public void Create() { /* ... */ _sender.Send("..."); }
}
在 ASP.NET Core 中注册:
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddScoped<OrderService>();
生命周期是高频追问点:Transient 每次请求都新建,Scoped 每请求一个,Singleton 全局一个。常见坑是“Singleton 依赖 Scoped”,会抛 InvalidOperationException,因为单例持有短生命周期对象。回答时能说出这个异常和原因,比只背三个生命周期更有说服力。
六、面试答题模板与自检清单
遇到设计类问题,可以按这个顺序组织答案:先说明业务场景和变化点,再给出抽象(接口或抽象类),然后说明如何注入或组合,最后指出这样设计带来的可测试性和扩展性。下面是一份可直接用于复习的自检清单:
- 我能否用一个业务例子说明封装,而不是只说 private?
- 我能否解释为什么这里用接口而不是抽象类?
- 我能否指出一段代码违反了哪条 SOLID,并给出改法?
- 我能否写出一个装饰器或策略模式替代继承的例子?
- 我能否说清 DI 三种生命周期及一个常见异常?
把这几条练到能脱口而出,面向对象与设计原则这一关就基本稳了。真正拉开差距的,永远是你能否把原则落到具体代码和具体场景上。