从结构到行为

面向对象不是万能药,但程序的世界发展到今天,至少证明了它在大多数地方都行之有效,我们不禁要问,面向对象究竟在解决什么问题?

目录

1. 面向对象解决的问题

2.避免重复

3. 适配

4.寻找共性

 4.1 小猫、小狗与宠物

4.2 雨伞的故事

5. 属性的属性

5.1 值、状态和范围

5.2 属性的关联

6. 结构与行为

7.可读性与性能

8.路在何方

1. 面向对象解决的问题

有时候我们会说:某些的代码是优雅的,它们看起来赏心悦目。

但更多的时候会说:这些代码简直就是一坨屎!

“屎”代码有什么特点?不写注释,啰嗦,逻辑混乱……我们总是能列举出众多理由,它们的臭味几乎是相同的。

如果某一天,我们加上了大量的注释,捋顺了逻辑,就真能称呼这是优雅的代码吗?

也许能,但大多数时候并非如此。

我们欣赏面向对象,主要原因就是面向对象带来的可读性,它试图让我们以人的思维去理解冰冷的机器世界,而增加注释、捋顺逻辑,只是编程的基本素养,并不能帮我们去了解何为对象。

想想我们通常是怎样构建代码的?创建一个类,添加几个属性,再写一个方法,之后去完善这个方法……

或许,我们可以总结出这样的结论——面向对象解决的问题,是对象到行为的映射。

这也是我们常说面向对象简单的原因,因为它解决的问题很简单,只要找到映射关系就解决了面向对象的问题;但我们也说它很难,因为这个映射关系通常不容易被发现。

下面的代码是构建一棵树的接口:


public Interface TreeProduce {
     
     boolean insert(Node root, Node n);
     ……
     void visit(Node root);
}

其中visit是打算通过根节点遍历一棵树,这似乎没什么问题,但事实真的如此吗?

我们知道,遍历有包括前序、中序、后序和层序在内的多种方式,每种方式都适用于不同的场景,如果想要切换不同的遍历方式该如何处理呢?很自然地写出下面的方法:


public Interface TreeProduce {
     ……
     void visitF(Node root);
     void visitR(Node root);
     void visitXXX(Node root);
}

不同的行为当然会有不同的实现,问题是,TreeProduce的实现类为什么一定要实现众多visit方法?也就是说,visit和TreeProducer的映射关系是否正确?

实际上,这是一种典型的策略模式:对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。

2.避免重复

多年来,每次codereview我都会看到大量重复的代码,“避免重复”这四个字还真是说起来容易做起来难。

请看下面的代码:

for (Link link : lnks) {
    if (nodeId == link.getSrcNodeId) {
        subInstance = find(instance.getId(), link.getSrcIfIdx());
        if (subInstance != null) {
            link.setSrcInstanceId(instance.getId());
            link.setSrcSubInstanceId(subInstance.getId());
        }
    } else {
        subInstance = find(instance.getId(), link.getDestIfIdx());
        if (subInstance != null) {
            link.setDestInstanceId(instance.getId());
            link.setDestSubInstanceId(subInstance.getId());
        }
    }
}

if和else中的语句除了src和dest之外全部相同,当然,我也并不十分反对这么写,但是仍不免问一句:“能去掉重复吗?”

实际上我总是收到类似的答案:“那没有办法”,对此,我的回答是:“想想,再想想,别轻易说没有办法”。

我们对传递变量习以为常,实际上,行为也是可以传递的。上面的代码的行为看似不同,实际上却有相同的模板,可以根据这一点对其改造:


for (Link link : lnks) {
    if (nodeId == link.getSrcNodeId) {
        writeBack(instance, link::getSrcIfIndex, link::setSrcInstanceId, link::setSrcSubInstanceId);
    } else {
        writeBack(instance, link::getDestIfIndex, link::setDestInstanceId, link::setDestSubInstanceId);       
    }
}

private void writeBack(ModelInstance instance, IntSupplier getIfIndex,
                                             LongConsumer setInstanceId, LongConsumer setSubInstanceId) {
        Instance subInstance = find(instance.getId(), getIfIndex.getAsInt());
        if (subInstance != null) {
            setInstanceId.accept(instance.getId());
            setSubInstanceId.accept(subInstance.getId());
        }
    }
}

函数式编程真是个不错的东西,虽然代码变得更多了,但主方法却相应的简化了不少,因此并非每次避免重复都会减少代码。

3. 适配

在众多设计模式中,适配器模式似乎是最简单的一种,它能使不兼容的接口能够兼容,从而和客户程序在一起工作。遗憾的是,我们往往忽略适配。

忽略适配除了会造成大量的重复外,还会造成严重的内卷。

不久前,团队中的程序员写了一个使用ping命令检测网段中ip联通状态的程序。实际上,系统中早就存在了一个类似的插件,而且该插件考虑了各种情况并经过了众多实际项目的检验,我们需要做的仅仅是适配而已。

也许有众多理由使我们忽略适配,除了程序员们总是抵御不住重新创造的乐趣外,还因为我们总是不愿意阅读别人的代码——正如别人不愿意阅读自己的代码一样。

可不是嘛,大家本来就觉得其他人写的东西是一坨屎。

4.寻找共性

人们很善于寻找不同事物的共同特点,甚至于将事物按照特点归类。这种训练从认知阶段就开始了,比如幼儿园的小朋友会被要求将积木的形状归类,寻找生活中“圆形”的物体;再比如生物学中的生物分类:界、门、纲、目、科、属、种。

在程序的世界中也是如此。想要将现实世界的问题建模,就需要不断地寻找共性,最终规约、映射为可解决的问题。

 4.1 小猫、小狗与宠物

这看似容易,小猫和小狗都是动物,都会叫,因此可以自然地定义出包含call方法的Pet接口:

public Interface Pet {    String call();}

之所以容易,是由于给定了已知事物(小猫、小狗),通过已知事物本身抽象出共性。现在,我在野外采摘了一朵芬芳的野花,野花和宠物间的共性又是什么?

4.2 雨伞的故事

我经常给人讲雨伞的故事。

我有一把雨伞,它不但能在雨天为我遮风挡雨,还很舒适,甚至能行走。没错,我的雨伞就是小汽车。

也许有人会觉得强词夺理,汽车怎么可以说成雨伞?如果从挡雨的角度看,为什么不能?仅因为它成本较高,而且使用燃料驱动?

实际上,我们之所以不把雨伞和汽车归类到一起,是由于寻找共性的方式不同,即按照事物的主要特性分类。雨伞的主要特性是防雨,汽车是代步,除此之外,它们还有很多额外的特点:雨伞也可以做武器,豪车可以用来象征身份。

宠物和野花的共性又是什么?一个显而易见的答案是生命,动物和植物都是有生命的生物。当然,它们都有气味,只不过一个芬芳四溢,另一个并不能令人愉悦。

5. 属性的属性

我们将寻找共性映射到程序上,并且再深入一点。

假设我们有两个截然不同的业务对象:

public class User {    private int age;    private String name;    private String idCard;    ……}
public class Alarm {    private Enum level;     // 告警级别    private boolean enable; // 是否启用    ……}

User和Alarm一个处理人员,一个处理告警,看起来毫无关系,它们是否存在共性呢?

当然存在,他们都是对象,或者说都是业务对象,都有属性和行为。

5.1 值、状态和范围

现在来看属性本身,age和name,一个是int,一个是String,一个是基本类型,一个继承自Object,这次的共性不那么容易寻找。

是否还存在比属性更细的粒度呢?当然存在。

假设我们制作了一个表单收集人员信息,为了保证数据的有效性,在提交表单时必须要进行校验,一种校验规则是,年龄的只能填写0~150,姓名不能超过50字符,身份证号必须是18位。

收集到的数据是表单的值,每种规则是数据的范围,由此我们抽象出属性的两种共性,值和范围——值是属性的当前形态,可以规约为一个可见的字符串;范围是取值的约束。

实际上,范围仍然可以泛化。比如年龄的范围是0~150,可以看做相对连续的线性数字,不妨叫做线性范围;告警级别是枚举类型,只能选择有限的几个级别,属于离散型范围;而身份证号需要通过算法验证是否是有效的身份证,属于逻辑范围;姓名这种只有长度的范围都归于普通型范围。

属性除了值和范围外,还存在另一种属性,这在Alarm上表现得较为明显。

通常来说,只有在启用告警的情况下才能选择告警级别,因此告警级别出现了“显示/隐藏”或“可编辑/不可编辑”几种状态,因此可以把状态抽象为另一种属性的属性。

5.2 属性的关联

我们还注意到两种典型的情况,只有在启用告警的情况下才能选择告警级别,身份证号可以直接带出年龄。这意味着属性间存在着关联关系,某一属性的值、状态、范围的改变,会驱动与之关联的其他属性的值、状态、范围发生改变。根据这种特性,可以写出一系列有意思的程序。

6. 结构与行为

软件设计本身是一个从结构到行为的过程,软件所能产生的行为是由它的结构决定的,一个优良的结构往往能够较为容易地让人找到问题的关注点。比如小猫能够轻松地爬树,这是由于它有柔软的脊柱和锋利的爪子;相反,人的身体结构对爬树的支持并不友好,即便经过一定的训练或附加外挂也很难比得上小猫,而且一旦失手,代价将极其昂贵。

假设有一个场景,需要根据某种业务规则产生的订单号,惯性思维很容易让我们写出下面的代码:

static public String createOrderNo(String busCode) {    // 时间戳    String dateStr = ……    // 流水号    String serNo = ……        return busCode + "-" + dateStr + "-" + serNo;}

上述代码以业务编码为参数,生成了一个三段式订单号,好不好呢?大多数时候没有问题,但是经不起折腾。假设不同业务编码对应了不同的时间戳,有的是YYYY-MM-DD,有的到了分秒一级,这该如何扩展呢?我们当然可以不假思索地增加另外一个方法,用业务编码去匹配时间戳的格式化。这或许能解决一部分问题,但如果在订单中插入物料类别,变成四段式订单号呢?

或许只有流水号是属于整个系统的,其他几段根据订单的类别高度个性化。此时可以使用装配器重新设计结构,用装配的方式生成个性化的订单,订单的每一段都是一个装配器,每一个装配器都可以重用,这有点类似于java的IO,BufferedInputStream为FileInputStream赋予了Buffer的能力:

InputStream in = new BufferedInputStream(new FileInputStream(f));

7.可读性与性能

关于程序的性能,我在《程序员数学从零开始》中有过一些描述,这里不再赘述。

如果在可读性和性能间进行选择的话,我会首选可读性。我们会发现,可读性越好的代码越不容易出错,也越容易进行下一步的性能调试。高可读性会使我们的关注点聚焦,从而直指问题的中心。

有时候,人们会喜欢用性能去搪塞自己的懒惰,会说一个方法之所以多达500行,是为了避免使用反射、继承和封装导致的性能下降。这简直是一本正经的胡说八道。方法调用、分层处理都会导致性能的下降,能降多少呢?

总之一句话,对于代码,程序员们必须精雕细琢,时刻打理,因为那是你的的家,你就生活在那里。

8.路在何方

曾有无数次,信息技术前沿的探路者们都宣称找到了软件设计的最佳实践。时至今日,软件设计越来越复杂,软件设计的根本问题——如何降低设计的难度仍没有解决,这源于我们面对的问题的复杂度。尽管手中的武器多了,但问题的难度和规模也远比过去更大,新的实践似乎只证明了一件事——没有银弹。未来之路,究竟通往何方?

END.

 出处:微信公众号 "我是8位的"

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我是8位的

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值