一、OOP 最迷人的地方
OOP 最好用的地方,是它能把现实世界里复杂的概念收拢起来。
比如一个订单,不只是几行数据。它有状态,有规则,有行为,有边界:
public class Order
{
public OrderStatus Status { get; private set; }
public void Cancel()
{
if (Status == OrderStatus.Shipped)
{
throw new InvalidOperationException("已发货的订单不能取消");
}
Status = OrderStatus.Cancelled;
}
}
这就是很舒服的封装。
调用方不需要到处判断订单能不能取消,也不需要知道订单状态该怎么流转。对象自己守住自己的规则。这种抽象是非常有价值的。
我觉得很多 C# 程序员喜欢 OOP,也正是因为它能把业务语言变成代码语言。类名、方法名、接口名,写得好的时候,代码读起来像是在描述业务本身,而不是在描述机器怎么一步一步执行。
二、痛苦通常从“继承树”开始
但 OOP 的麻烦,也常常从这里开始:我们为了复用和扩展,开始写抽象类、虚方法、模板方法。
比如:
public abstract class OrderProcessor
{
public void Process(Order order)
{
Validate(order);
Save(order);
Notify(order);
}
protected abstract void Validate(Order order);
protected virtual void Save(Order order)
{
// default save
}
protected virtual void Notify(Order order)
{
// default notify
}
}
刚开始看,这段代码挺漂亮。流程统一,差异交给子类。
但项目慢慢长大以后,可能会出现这些情况:
某个子类重写了
Validate另一个子类重写了
Save有的子类重写
Notify,但还要调用base.Notify某个父类方法里悄悄改了状态
新增一个流程时,不确定要改父类还是改子类
出 bug 时,调试器一路跳来跳去,像是在拆盲盒
这时候抽象没有减少复杂度,反而把复杂度藏起来了。
好的抽象应该让人少知道一些细节。坏的抽象会让人不得不知道更多上下文。
这就是很多 OOP 代码难维护的根源:不是类太多,而是行为被分散到了太多层级里。
三、设计模式不是问题,模式感太重才是问题
设计模式本身不是坏东西。
策略模式、模板方法、工厂、装饰器、观察者,这些模式能解决很多真实问题。问题在于,有些代码会为了“看起来可扩展”而提前设计一整套结构。
比如一个业务现在只有两个分支,却已经抽象出:
一个接口
一个抽象基类
三个默认实现
一个工厂
一个注册器
一组扩展点
然后半年后大家发现,这个业务其实根本不会扩展到那么复杂。代码倒是很有架构感,但读起来很累。
我现在更倾向于一种朴素的判断:
如果抽象没有让调用方更轻松,它就还不算成功。
抽象不是把代码变“高级”,而是把变化隔离开,把意图表达清楚。
四、在 C# 里,优先组合,而不是继承
很多时候,我们不需要继承。我们需要的是组合。
还是订单处理的例子,与其让子类重写流程,不如把变化点拆成几个清楚的协作者:
public class OrderProcessor
{
private readonly IOrderValidator _validator;
private readonly IOrderRepository _repository;
private readonly IOrderNotifier _notifier;
public OrderProcessor(
IOrderValidator validator,
IOrderRepository repository,
IOrderNotifier notifier)
{
_validator = validator;
_repository = repository;
_notifier = notifier;
}
public async Task ProcessAsync(Order order)
{
_validator.Validate(order);
await _repository.SaveAsync(order);
await _notifier.NotifyAsync(order);
}
}
这还是 OOP。
但它比继承树更容易看懂。因为流程在一个地方,变化点也很明确。验证是谁做的,保存是谁做的,通知是谁做的,都摆在构造函数里。
组合的好处是,它把“继承层级里的隐式规则”,变成了“对象之间的显式协作”。
继承像血缘关系,一旦建立就很难轻易拆开。组合更像搭积木,需要什么就接什么,不合适就换掉。
五、少一点 protected virtual,多一点显式流程
我现在对 protected virtual 会比较谨慎。
它当然有用,尤其在框架代码里,比如 ASP.NET Core、ORM、UI 框架,很多地方都需要留扩展点。但在普通业务代码里,它经常会制造一种隐式协议:
“子类可以重写这里,但最好在某个时机调用 base;某个字段在父类里初始化,另一个字段在子类里补上;这个方法不能单独调用,只能由模板流程调用。”
这些规则如果没有被类型系统表达出来,就会变成维护者脑子里的负担。
有时候,直接一点反而更好:
public async Task CompleteOrderAsync(Order order)
{
EnsureOrderCanBeCompleted(order);
await ReserveInventoryAsync(order);
await CreateShipmentAsync(order);
await MarkOrderCompletedAsync(order);
await SendCompletedNotificationAsync(order);
}
这段代码不一定“模式感”很强,但很好读。
业务流程类代码,很多时候就应该让人一眼看见顺序。过度抽象会让流程断裂,读者只能靠跳转和猜测把它拼起来。
六、接口应该表达能力,而不是制造层级
C# 里的接口很好用,但接口也容易被滥用。
一个好的接口通常是在表达一种稳定的能力:
public interface IShippingFeeCalculator
{
Money Calculate(Order order);
}
调用方只关心“你能不能计算运费”,不关心具体是按重量、地区、会员等级,还是促销规则计算。
但如果接口变成这样:
public interface IOrderService
{
void Create(Order order);
void Update(Order order);
void Delete(long id);
void Approve(long id);
void Reject(long id);
void Complete(long id);
void Cancel(long id);
void Export(long id);
}
它就不太像能力抽象,更像一个业务杂物箱。
接口不是越多越好,也不是越大越好。它应该让依赖变得清晰,而不是让实现类被迫背上一堆不属于自己的责任。
七、没有 OOP 的语言,也一样有抽象
如果离开 C#,去看一些不那么强调 OOP 的语言,会发现抽象其实并不只属于类。
不同语言只是把抽象放在了不同地方。
Go:用接口描述行为
Go 没有传统继承。它更强调组合和接口。
type ShippingCalculator interface {
Calculate(order Order) Money
}
一个类型只要实现了 Calculate 方法,就自然满足这个接口,不需要显式声明“我继承了谁”。
这有点像是在说:别急着定义家族关系,先看你能做什么。
这种设计会让代码少一些继承层级,多一些小接口和组合关系。
Rust:用 trait 表达能力
Rust 也没有传统 OOP 那种类继承,但它有 trait:
trait ShippingCalculator {
fn calculate(&self, order: &Order) -> Money;
}
trait 很像接口,但它又结合了 Rust 的泛型、所有权和模式匹配。它表达的是一种能力,而不是一棵继承树。
Rust 里还有很强的枚举和模式匹配:
enum OrderStatus {
Pending,
Paid,
Shipped,
Cancelled,
}
当状态变化被明确列出来,很多逻辑就不一定要藏进对象层级里,而可以通过匹配分支清楚表达。
F#:用数据和函数组合流程
F# 这样的函数式语言,会更自然地把业务写成数据流:
order
|> validate
|> reserveInventory
|> createShipment
|> notify
这里抽象的重点不是“哪个对象负责这个行为”,而是“数据经过哪些转换”。
这种思路对 C# 程序员也有启发。不是所有逻辑都必须塞进类里。有些计算逻辑,如果没有副作用,用函数表达反而更清楚。
C:没有类,也能有模块边界
C 语言没有类,但可以用 struct、函数指针、头文件和源文件来组织抽象。
typedef struct {
int (*calculate)(Order* order);
} ShippingCalculator;
这当然没有 C# 那么舒服,但它说明一件事:抽象的本质不是 class 关键字,而是边界、协议和隐藏细节。
只要能把“不该知道的细节”藏起来,把“应该依赖的能力”暴露出来,就已经在做抽象了。
八、回到 C#:实用一点的取舍
如果继续写 C#,我觉得可以保留对 OOP 的喜欢,但少一点对继承的依赖。
一些比较实用的习惯是:
能用组合解决的,不急着上继承
能用小接口表达能力的,不做大而全的抽象
能把流程写清楚的,不藏进多层 virtual 调用
抽象类只在共性非常稳定时使用
业务对象负责守规则,服务对象负责组织流程
对复杂设计模式保持尊重,但不要过早使用
每次新增抽象时问一句:它真的降低了理解成本吗?
这里最关键的可能是最后一句。
抽象不是目的,降低理解成本才是目的。
如果一个抽象让未来的修改更局部,让调用方更少关心细节,让业务意图更清楚,那它就是好抽象。
如果一个抽象只是让类图更漂亮,但让调试路径更长,让行为更难定位,那它就可能只是复杂度换了一身衣服。
九、结语
所以,OOP 是问题吗?
我觉得不是。
OOP 像一把很顺手的刀。它能切开复杂业务,也可能在设计过度时划伤维护者。C# 给了我们很多强大的工具:类、接口、继承、泛型、委托、LINQ、record、模式匹配。真正成熟的写法,不是把每一种工具都用上,而是知道什么时候不用。
喜欢抽象是一件好事。
只是写代码久了以后,会慢慢从“我要把它设计得足够通用”,变成“我要让下一个读代码的人尽快看懂”。而那个下一个人,经常就是几个月后的自己。
抽象如果能让自己在未来少皱几次眉,那它就是温柔的。
如果不能,那就写直白一点。
代码不需要每一处都显得聪明。很多时候,它只需要诚实。