对象命名为何需要避免'-er'和'-or'后缀

之前写过两篇关于软件工程中对象命名的文章:开发中对象命名的一点思考对象命名怎么上手?从现实世界,但感觉还是没有说透,

在软件工程中,如果问我什么最重要,我的答案是对象命名。良好的命名能够反映系统的本质,使代码更具可读性和可维护性。本文通过具体例子,探讨为何应该以对象本质而非功能来命名,以及不当命名可能带来的长期问题。

一个例子

这个例子是我最近看到的一段代码,用于解释SOLID中的依赖倒置原则的好处用来隔离变化,代码如下:

复制代码
public interface IPaymentProcessor
{
    void ProcessPayment(decimal amount);
}

public class CreditCardPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        // 信用卡支付的具体实现
    }
}

public class PayPalPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        // PayPal支付的具体实现
    }
}

如之前文章提到,er或or结尾的命名,本质上是动词+施动者后缀组成的,本质是词汇匮乏的表现,这种其实可以有很多,比如:

  • Executor(执行者)
  • Handler(处理者)
  • Provider(提供者)
  • Builder(构建者)
  • Dispatcher(调度者)
  • Processor(处理器)
  • Checker(检查者)
  • Manager(管理者)
  • Converter(转换者)
  • Watcher(观察者)
  • Runner(运行者)
  • Fetcher(获取者)
  • Adapter(适配者)
  • Keeper(保持者)
  • Coordinator(协调者)

这些命名在现代软件工程中非常常见,但并不代表正确,本质是面向过程的命令式编程,而不是面向对象更现代的声明式编程,会潜移默化影响我们的思维方式。

问题在哪

这种命名方式更多强调对象的更能,而非本质,命名应该遵循以事物本质命名,而不是事物做什么(what the object is, not what it does)。

下面我们以另一个案例来看,例如,我希望设计一个对象,该对象用于满足人类坐下时的支撑需求,那么应该叫什么?如果按照IPaymentProcessor例子中提到的同样命名规则,则应该使用"人体支撑器",而不是椅子。

下面是代码示例:

复制代码
class HumanSupporter {
  supportHuman() { /* ... */ }
}

缺乏时间韧性

这种命名,可能是由于在当前上下文中,我们仅考虑椅子用于坐的功能这一点,并没有考虑未来的需求,后续,例如我们希望在椅子下面储存一些东西,该怎么做?

第一种选项是修改对象名称,以满足新的需求:

复制代码
// 选项1:改名(同时修改所有引用...)
class HumanSupporterAndItemStorer { 
  supportHuman() { /* ... */ }
  storeItems() { /* ... */ }
}

第二种选项,也是我们实际上使用最多的办法,无视类名称,直接硬加一个方法,反正过几个月这个东西不一定是谁负责了-.-

复制代码
// 选项2:保留不准确的名称(误导接盘侠)
class HumanSupporter {
  supportHuman() { /* ... */ }
  storeItems() { /* ... */ } // 名称与功能不符
}

第三种选项,将功能隔离到一个单独类中,但随着这类需求的增多,很多分散的类之间会存在复杂的调用关系,同时新增类由于是临时起意设计出来,很难在后续的功能中复用:

复制代码
// 选项3:创建新类(功能分散,关系复杂)
class ItemStorer {
  storeItems() { /* ... */ }
}

而当我们使用更符合本质的命名时,代码演进的节奏如下:

复制代码
// 初始版本
class Chair {
  sitOn() { /* ... */ }
}

// 第二版本 - 增加存储功能
class Chair {
  sitOn() { /* ... */ }
  storeItemsUnderneath() { /* ... */ } // 自然扩展,符合椅子的本质
}

// 需要更专业化时,创建子类
class StorageChair extends Chair {
  // 扩展而非替代,保持概念一致性
}

基于对象本质的命名可以看出拥有足够的时间韧性。

命名过于抽象或泛化可能导致膨胀

"人体支撑器"这种命名很容易让类的膨胀显得合情合理,首先从语义上来看,"-er"/"-or"结尾的词在语法上创造了一个施动者(agent),但语义边界不清。"人体支撑器"到底支撑什么?支撑到什么程度?

同时强调行为,而淡化对象的本质。

同时"支撑器"从语义学角度存在双重问题:

上位词过宽:支撑器是椅子、凳子、桌子、沙发等众多物品的上位词,失去了分类的精确性。语言学中,这种上位词(hypernym)过于宽泛时,语义信息密度大幅降低。同时引起抽象维度的混乱,可能导致很多不相干的内容全部塞进类中。

下位词过窄:将椅子定义为"支撑器"忽略了其他属性------舒适性、美学价值、文化符号意义。这是语义要素(semantic features)的不当减少。

随着演进,我们可能看到这样一个类的膨胀方式:

复制代码
class HumanSupporter {
    public void supportHuman() {
        // 原始功能
    }
    
    public void maintainPosture() {
        // 第二版添加的功能
    }
    
    // 存储物品也可以解释为"支持人类活动"的一部分
    public void storeItems() {
        // 存储物品的实现
    }
    
    // 在模糊的功能定义下,越来越多不相关的功能被添加进来
    public void provideWarmth() {
        // 提供温暖的实现
    }
    
    public void massageUser() {
        // 按摩功能实现
    }
    
    // 完全不相关的功能也可以通过宽泛解释而加入
    public void playMusic() {
        // "这也是支撑人类放松,对吧?"
    }
    
    public void chargeMobileDevices() {
        // "现代人需要充电,这也是支持现代人类的需求!"
    }
    
    // 随着时间推移,类可能继续膨胀...
    public void provideSnacks() {
        // "提供零食也是支撑人体的一种方式!"
    }
    
    public void controlRoomLighting() {
        // "控制灯光也是为了支持人类工作环境!"
    }
    
    // 很多功能都可以塞进这种不当的抽象中...
}

从例子中看貌似有点夸张,但只要Codebase生命周期足够久,就能看到许多疯狂膨胀的类,如果没有监督或严格的Code Review,人们会倾向于短平快的实现手段,我见过很多后缀为Handler、base、manager的类膨胀到上万行,被上百处引用。

而使用符合本质的命名时,新增功能如下:

复制代码
* Chair - 椅子
 * 初始设计:简单的椅子类
 */
class Chair {
    // 核心功能明确定义了椅子的基本用途
    public void sitOn() {
    }
}

/**
 * Chair - 第二版
 * 增加了新功能,但都严格符合"椅子"的本质特性
 */
class Chair {
    public void sitOn() {
        // 坐的实现
    }
    
    // 存储物品在椅子下方是椅子的自然扩展,符合我们对椅子的理解
    public void storeItemsUnderneath() {
        // 存储功能实现
    }
    
    // 调整高度也是椅子可能具有的功能
    public void adjustHeight() {
        // 高度调整实现
    }
    
    // 注意:我们不会想到给椅子添加"播放音乐"的功能
    // 因为这明显不符合我们对"椅子"这个概念的理解
}

/**
 * 当需要更多功能时,我们创建专门的子类
 * 而不是向基类添加不相关的功能
 */
class StorageChair extends Chair {
    // 扩展存储功能,而不是改变椅子的基本概念
    @Override
    public void storeItemsUnderneath() {
        // 增强的存储功能实现
    }
    
    // 添加符合"储物椅"概念的特殊功能
    public void openStorage() {
        // 打开储物区实现
    }
}

class Massager {
    // 单一职责:专注于按摩功能
    public void massageUser() {
    }
}

// 使用组合将按摩功能添加到椅子中,直接定义,或通过构造函数注入或DI
class MassageChair extends Chair {
    private Massager massager;
    
    // 通过组合添加按摩功能,而不是直接在Chair类中添加
    public void activateMassage() {
    }
}

类图如下:

我们可以看到,HumanSupporter (功能性命名) 随着需求增加容易变得臃肿,因为几乎任何功能都可以归为"支持人类",Chair (实体命名) 自然限制了类的职责范围,不相关功能明显感觉格格不入,当需要添加新功能时,具体命名引导我们创建专门的子类或使用组合,而不是膨胀基类。

命名增加认知负载

HumanSupporter这种不符合我们日常交流中的习惯,属于开发人员在开发过程中的临场发挥,现实世界中并没有"人体支撑器"这种抽象的概念。而椅子(Chair)的概念在现实生活中非常容易理解,其职责和边界在现实世界这么多年的演化中基本稳定,那么在短暂的软件生命周期中也应该是稳定的。

同时在代码抽象角度,现实生活中的概念更容易进行抽象,同时抽象维度也会比较合理,例如:

HumanSupporter可能继承自Supporter,但这个继承层次是否有意义?这种功能性抽象通常是临时起意,并不健壮,而Chair、Table可以更自然的抽象成Furniture,这反映了真实世界的抽象规则。

同时在和其他开发人员或业务人员沟通时,请把"请把人体支撑器搬过来",这种沟通会不会让人抓狂?

那么开头例子该如何重构?

通过易于理解的椅子代码示例,理解对象命名的重要性,那么对于开头的例子IPaymentProcessor接口,直接重构为更符合本质的IPayment即可,有什么好处?

功能扩展对比

IPaymentProcessor:添加功能需修改接口

复制代码
// 原始接口
public interface IPaymentProcessor {
    void ProcessPayment(decimal amount);
}

// 需要添加退款功能 - 所有实现类都必须修改
public interface IPaymentProcessor {
    void ProcessPayment(decimal amount);
    void ProcessRefund(string transactionId, decimal amount); // 新增方法
}

// 所有实现类都被迫实现新方法
public class PayPalPaymentProcessor : IPaymentProcessor {
    public void ProcessPayment(decimal amount) { /* 原有代码 */ }
    
    // 即使此支付方式不支持退款,也必须实现
    public void ProcessRefund(string transactionId, decimal amount) {
        throw new NotSupportedException("PayPal不支持退款");
    }
}

IPayment:添加功能通过扩展接口

复制代码
// 原始接口保持不变
public interface IPayment {
    PaymentResult Execute(decimal amount);
}

// 新增退款接口
public interface IRefundablePayment : IPayment {
    RefundResult Refund(decimal amount);
}

// 只有支持退款的支付方式实现新接口
public class CreditCardPayment : IRefundablePayment {
    private string _lastTransactionId;
    
    public PaymentResult Execute(decimal amount) {
        // 处理支付并记录交易ID
        _lastTransactionId = "tx_" + Guid.NewGuid().ToString();
        return new PaymentResult { Success = true };
    }
    
    public RefundResult Refund(decimal amount) {
        // 使用交易ID处理退款
        return new RefundResult { Success = true };
    }
}

// 不支持退款的支付方式不需要变更
public class GiftCardPayment : IPayment {
    public PaymentResult Execute(decimal amount) {
        // 礼品卡支付
        return new PaymentResult { Success = true };
    }
}

状态管理

IPaymentProcessor 没有合适的状态管理位置

复制代码
// 处理器没有内部状态
public class CreditCardPaymentProcessor : IPaymentProcessor {
    // 状态必须在外部管理
    public void ProcessPayment(decimal amount) {
        // 从哪里获取卡号和有效期?
        
    }
}

IPayment:状态自然封装

复制代码
// 支付对象封装所需的所有状态
public class CreditCardPayment : IPayment {
    private readonly string _cardNumber;
    private readonly string _expiryDate;
    
    public CreditCardPayment(string cardNumber, string expiryDate) {
        _cardNumber = cardNumber;
        _expiryDate = expiryDate;
    }
    
    public PaymentResult Execute(decimal amount) {
        // 直接使用内部保存的状态
        return ProcessCreditCardPayment(_cardNumber, _expiryDate, amount);
    }
}

// 使用代码简洁明了
public void CheckoutCart(ShoppingCart cart, CustomerInput input) {
    var payment = new CreditCardPayment(input.CardNumber, input.ExpiryDate);
    var result = payment.Execute(cart.Total);
}

小结

对象命名是软件工程中最基础也最重要的环节之一。遵循"以事物本质命名,而非事物功能"的原则,能够创建更清晰、更稳定、更易于理解和维护的代码。

一个简单的办法是,在日常开发中遇到使用"er"/"or"结尾的对象命名时,需要引起警觉,考虑如何使用反映领域实体本质的命名方式。