在面向对象程序员的圈子里,getter和setter貌似是绕不过去的一对孪生兄弟。简单来讲,它俩一个是取值器(getter),一个是设值器(setter)。看一段代码就一目了然了:

    1. public class Player {
    2. private String name;
    3. public Player(String name) {
    4. this.name = name;
    5. }
    6. // getter
    7. public String getName() {
    8. return name;
    9. }
    10. // setter
    11. public void setName(String name) {
    12. this.name = name;
    13. }
    14. }

    getter和setter就是两个普通的方法,这两个方法为了方便外界操纵Player对象,获取Player的属性值,以及重置Player对象的属性值。

    看着标题你可能心里开始嘀咕:“危言耸听了啊,不就是两个方法吗?怎么还能害人?我写这么多年的代码,都是无脑生成getter和setter的,而且都不用手写,IDE一键智能生成!”

    好家伙,写了这么多年代码,你可曾想过为什么需要getter/setter?启蒙老师教的?书里讲的?年长的同事敦促的?IDE提倡的?……

    不知道你中枪了没,我反正中枪了。半路出家的我在软件培训班中启蒙老师能行云流水般的生成getter/setter,而且还是纯手工的,当时我还觉得特酷。从此我染上了恶习,更巧合的是职场上遇到一帮跟我“同流合污”的程序员乐此不疲地吹捧getter/setter。甚至有很多技术书也在这么干,这让我坚定地不去怀疑getter/setter存在的价值,虽然有时候也不知有何价值,哪怕是被IDE灰显的Dead Code,也要把它祭在那里。就好比这段断码:

    image.png
    setScore、setName两个setter就是灰显的Dead Code。

    setter害人不浅并不是说setter毫无价值,在一些本身就是为成为容器而生的数据类中,getter/setter可以说是与生俱来的。这些对象唯一用途是承载特定的数据,用于不同上下文中的数据包装和转换,生命周期也随着转换和传递的完成而终止。

    现在程序员喜欢用的DTO (Data Transfer Object)、VO (View Object)、PO (Persistence Object)都属于这类对象。这些对象就是纯粹的数据类,数据类只需要提供取值和赋值的方法让用户能够交换数据即可,它们不承载任何业务,便不出现业务行为。

    setter什么时候不合理呢?

    Talk is cheap,Let me show you the code:

    public void cancelRiskInvestigationTask(RevertRiskInvestigationTaskRequestModel requestModel) {
            long riskInvestigationTaskId = Long.parseLong(requestModel.getRiskInvestigationTaskId());
    
            RiskInvestigationTask riskInvestigationTask = riskInvestigationTaskRepository.loadById(riskInvestigationTaskId)
                    .orElseThrow(() -> new RevertTaskBlockException("没有找到该风险排查任务,无法撤销"));
    
            riskInvestigationTask.setTaskStatus(RiskInvestigationTaskStatusEnum.CANCELED);
    
            riskInvestigationTaskRepository.update(riskInvestigationTask);
    
            // ...
    }
    

    注意,上述代码的riskInvestigationTask.setTaskStatus的调用就是熊出没的地方,它将riskInvestigationTask实体中的任务状态设置为取消(CANCELED)。很多人可能看到这里觉得没什么不妥,好像自己也一直这么干,程序仍然工作得好好的。在Service类中去重置领域对象(RiskInvestigationTask)的状态确实是很常见的做法,有的场景甚至需要先获取对象原来的状态,然后判断当前状态是否可以将其设置为CANCELED的状态。比如下面代码:

    public void cancelRiskInvestigationTask(RevertRiskInvestigationTaskRequestModel requestModel) {
            long riskInvestigationTaskId = Long.parseLong(requestModel.getRiskInvestigationTaskId());
    
            RiskInvestigationTask riskInvestigationTask = riskInvestigationTaskRepository.loadById(riskInvestigationTaskId)
                    .orElseThrow(() -> new RevertTaskBlockException("没有找到该风险排查任务,无法撤销"));
    
            RiskInvestigationTaskStatusEnum originalStatus = riskInvestigationTask.getTaskStatus();
    
            if (originalStatus == RiskInvestigationTaskStatusEnum.PROCESSINIG) {
                riskInvestigationTask.setTaskStatus(RiskInvestigationTaskStatusEnum.CANCELED);
            }
    
            riskInvestigationTaskRepository.update(riskInvestigationTask);
    
            // ...
    }
    

    上述的做法更多的是将RiskInvestigationTask当做一个纯数据容器在使唤,所有的业务逻辑都交给外层Service来做。

    补充点上下文,这个RiskInvestigationTask 是一个Entity,是DDD战术建模中定义的实体,简单点就当它是一个面向对象设计的对象实体好了(数据 + 行为)。

    先上结论:这是一个典型的不合理运用,注意不是错误,因为程序是可以工作的。

    那不合理的点是什么呢?我从三个方面来掰扯一下不合理的地方:

    1. 代码坏味道。《重构》一书里面有个Feature Envy(特性依恋)的坏道:

    某个函数为了计算某个值,从另一个对象那儿调用了几乎半打的取值方法,显而易见,这个函数更应该放在另一个对象那儿,因为大部分取值都来自那个对象的属性。

    虽然这里没有半打取值方法,背后暴露的问题是类似的,都是通过取得别人的数据来做运算和判断。

    如果说Feature Envy你有点儿难以接受,说我强词夺理,我虽不情愿,也认了。那这里面还散发另外一个坏味道 — Mutable Data(可变数据)。由于这个公开的setter存在,导致这个对象的某个属性可以在外部被随意更改,这就是setter带来的副作用。

    2. 面向对象。面向对象设计提倡数据跟对操作的数据行为绑定在一起,否则很可能破坏了对象的封装性。这里就破坏了封装性。

    3. 领域模型。RiskInvestigationTask是一个领域对象Entity,如果在仔细琢磨一下,这里面是不是缺少什么领域行为,比如取消,比如通过审批等。

    相比于前2点,第3点更难把握。如果前2点看成术(它们是具体可参考落地的指导建议),那么第3点就上升到道层面了,比如如何恰到好处的理解业务,对业务进行合理的建模,识别出恰当的领域行为,它很难有标准答案的,不可言说,说了也不一定会,除非认知到了。

    那怎么办呢?绕了这么久,我好像在说:“这个很难,大家别努力了,听天由命吧。” 恰恰不是,都说苍蝇不叮无缝的蛋。既然是不好的设计,肯定有缝隙。让我们来跟苍蝇比一下敏感度,留意到这个缝隙,趁机修复漏洞。

    划重点:它的缝隙就在那个setter上,但凡出现了setter的地方,都值得你停下来去思考几个问题:

    1. 为什么这里有setter? — 锁住缝隙
    2. 它本身的定位是数据容器类吗? — 拷问1 【大部分不是】
    3. 应该在外部获取他的数据来做逻辑判断吗? — 拷问2 【大部分会破坏封装性】
    4. 是什么业务行为会导致这个状态发生这样的变化? — 拷问3 【大部分因为缺失了领域行为】

    通过以上的思考,最终很可能发现是领域行为的缺失,通过进一步合理的业务抽象,为RiskInvestigationTask添加了一个领域行为cancel(),代码如下:

    public class RiskInvestigationTask {
       private RiskInvestigationTaskStatusEnum status;
    
       // ...
    
       public void cancel() {
            if (status == RiskInvestigationTaskStatusEnum.PROCESSINIG) {
                this.status = RiskInvestigationTaskStatusEnum.CANCELED;
            }
       }
    }
    

    做了合理的抽象建模,在外部Service层也就不需要知道其内部信息了,外部只需要调用cancel()这个跟业务相匹配的领域行为:

    public void cancelRiskInvestigationTask(RevertRiskInvestigationTaskRequestModel requestModel) {
            long riskInvestigationTaskId = Long.parseLong(requestModel.getRiskInvestigationTaskId());
    
            RiskInvestigationTask riskInvestigationTask = riskInvestigationTaskRepository.loadById(riskInvestigationTaskId)
                    .orElseThrow(() -> new RevertTaskBlockException("没有找到该风险排查任务,无法撤销"));
    
            riskInvestigationTask.cancel()
    
            riskInvestigationTaskRepository.update(riskInvestigationTask);
    
            // ...
    }
    

    上述这样的代码是不是更整洁干净了呢? — 这个问题不用回答~ ~

    总结,再划重点:setter虽有用,但要注意不要被setter给耽误了。啰嗦了这么多,不知道我有没有说清道明setter的误用之处。如果你中枪了,而且你也认为这枪中的冤枉,那赶紧拿起setter放大镜去搜罗你的码场吧,多长点心眼,总能躲掉几颗子弹,少点皮(加)肉(班)之苦。