对象命名为何需要避免'-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"结尾的对象命名时,需要引起警觉,考虑如何使用反映领域实体本质的命名方式。
对象命名为何需要避免'-er'和'-or'后缀的更多相关文章
- SQL Server 数据库对象命名参考
一. 引言 编码规范是一个优秀程序员的必备素质,然而,有很多人非常注重程序中变量.方法.类的命名,却忽视了同样重要的数据库对象命名.这篇文章结合许多技术文章和资料,以及我自己的开发经验,对数据库对象的 ...
- Java编码规范之数据对象命名
数据对象分多种,为方便阅读并区分各数据对象的用途,习惯将数据对象分为以下几类,供参考: 持久对象 PO(persistant object)对象关系映射(ORM)概念的产物,基本上对象的成员变量对应了 ...
- SQL Server解惑——对象命名的唯一性小结
关于SQL Server数据库中的对象命名的唯一性问题.例如表.索引.约束等数据库对象,有时候DBA在做数据库维护时,经常要创建对象或重命名对象,此时就会遇到一个问题,对象命名的唯一性问题.虽然是一个 ...
- js 对象命名
JS 标识符的命名规则,即变量的命名规则: 标识符只能由字母.数字.下划线和'$'组成 数字不可以作为标识符的首字符 对象属性的命名规则 通过[]操作符为对象添加属性时,属性名称可以是任何字符串(包括 ...
- Qt ------ 我定义的规则 之 对象命名规则
类型 + 特性,比如 button_closeLigth 非公有的变量前面要加上小写m_ (指的修饰符为private时) 静态变量前面加上小写s_ 其它变量以小写字母开头 静态变量全大写 (sta ...
- 命名对象继承1-验证Create*命名对象安全属性的传递
windows核心编程 第5版 48页 下半部写道 进程B调用CreateMutex时,它会向函数传递安全属性信息和第二参数.如果已经存在一个指定名称的对象,这些对象就会被忽略 于是我通过代码来验证这 ...
- 数据库设计和ER模型-------之ER模型的基本概念(第二章)
ER模型(实体联系模型)的基本元素 实体:是一个数据对象,在ER模型中,实体用方框表示,方框内注明实体的名称 联系:表示一个或多个实体之间的关联关系,联系用菱形框表示,并用线段将其与相关的实体联系起来 ...
- html页面的CSS、DIV命名规则
CSS命名规则 头:header 内容:content/containe 尾:footer 导航:nav 侧栏:sidebar 栏目:column 页面外围控制整体布局宽度:wrapper 左右中:l ...
- .NET设计规范————命名规范
NET设计规范:约定.惯用法与模式———命名规范 前言: 最近在看<.NET设计规范:约定.惯用法与模式>一书,主要还是讲.NET的设计规范,以前对这一块也不是特别在意, ...
- Oracle命名规范
1.编写目的 使用统一的命名和编码规范,使数据库命名及编码风格标准化,以便于阅读.理解和继承. 2.适用范围 本规范适用于公司范围内所有以ORACLE作为后台数据库的应用系统和项目开发工作. 3.对象 ...
随机推荐
- C#HTTP网络编程的一般流程
1.同步HTTP网络要求 //第1步: 送出要求 string url="https://www.baidu.com/"; HttpWebRequest request = (Ht ...
- IM跨平台技术学习(十一):环信基于Electron打包Web IM桌面端的技术实践
本文由环信技术黄飞鹏分享,原题"实战|如何利用 Electron 快速开发一个桌面端应用",本文进行了排版和内容优化等. 1.引言 早就听说利用Electron可以非常便捷的将网页 ...
- Java Web学生自习管理系统
一.项目背景与需求分析 随着网络技术的不断发展和学校规模的扩大,学生自习管理系统的需求日益增加.传统的自习管理方式存在效率低下.资源浪费等问题,因此,开发一个智能化的学生自习管理系统显得尤为重要.该系 ...
- SpringBoot进阶教程(八十四)spring-retry
在日常的一些场景中, 很多需要进行重试的操作.而spring-retry是spring提供的一个基于spring的重试框架,某些场景需要对一些异常情况下的方法进行重试就会用到spring-retry. ...
- OpenCL入门笔记
1.概述 1.1.OpenCL标准 OpenCL(Open Computing Language)是一个开放标准的并行编程框架,它允许开发者在异构系统上利用各种计算设备(例如CPU.GPU.FPGA等 ...
- Mac安装brew的四种方法(指定能行)
一,执行brew官网命令安装brew https://brew.sh/ 官网中复制下图中命令,在terminal中输入该命令,即: /bin/bash -c "$(curl -fsSL ht ...
- SM4代码实现
算法过程 更多的原理介绍参考:SM4原理介绍 代码实现 S盒实现 #include <stdio.h> /* SM4-S盒实现: 由三个复合函数组成,S(x)=L(I(L(x))),其中L ...
- dart 中在实例化 new 关键字可以省略不写
dart 中在实例化 new 关键字可以省略不写 class Person { String name; int age; String sex; Person(this.name, this.age ...
- 使用SOUI播放视频
播放视频是一个常规需求. 如果将每一个视频帧转换成rgb格式,再使用gdi贴图,效率会很低,只能适合分辨率很低的视频,1080P全屏软渲染一般的电脑都撑不住. 因此渲染视频通常需要启用硬件渲染.开启硬 ...
- mybatis mysql count(*) 返回结果为null的解决
具体错误信息: org.apache.ibatis.binding.BindingException: Mapper method 'com.xx.xx.xx.xx.xx.getCount attem ...