之前写过两篇关于软件工程中对象命名的文章:开发中对象命名的一点思考与对象命名怎么上手?从现实世界,但感觉还是没有说透,
在软件工程中,如果问我什么最重要,我的答案是对象命名。良好的命名能够反映系统的本质,使代码更具可读性和可维护性。本文通过具体例子,探讨为何应该以对象本质而非功能来命名,以及不当命名可能带来的长期问题。
一个例子
这个例子是我最近看到的一段代码,用于解释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"结尾的对象命名时,需要引起警觉,考虑如何使用反映领域实体本质的命名方式。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |