OOP 的甜头与苦头:当抽象开始反过来折磨我们

我主要写 C#,所以很自然地会喜欢面向对象。 对象、接口、封装、抽象类、继承、虚方法,这些东西在刚开始理解的时候,会让人有一种很强的掌控感。它们像是给混乱的业务世界搭了一套骨架:这里是订单,那里是仓库,这边是服务,那边是策略。只要边界划得好,代码就不再是一团过程,而是一组彼此协作的角色。 但写久了也会发现,OOP 并不总是优雅的。 有时候一个问题出了错,要从子类追到父类,从 override 追到 virtual,从接口追到实现,再从实现追到某个工厂、容器、生命周期钩子。明明只是想看清楚“这段逻辑到底怎么跑的”,最后却像是在一座有很多暗门的房子里找电闸。 于是就会产生一个问题:这是设计问题,还是 OOP 本身的问题? 我的答案大概是:两者都有一点。但更多时候,不是 OOP 不行,而是我们把“抽象”太快等同成了“继承”和“模式”。

一、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、模式匹配。真正成熟的写法,不是把每一种工具都用上,而是知道什么时候不用。

喜欢抽象是一件好事。

只是写代码久了以后,会慢慢从“我要把它设计得足够通用”,变成“我要让下一个读代码的人尽快看懂”。而那个下一个人,经常就是几个月后的自己。

抽象如果能让自己在未来少皱几次眉,那它就是温柔的。

如果不能,那就写直白一点。

代码不需要每一处都显得聪明。很多时候,它只需要诚实。

版权声明:本站文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

评论加载中...