小程序中的大道理之二--抽象与封装

继续扒

接着 上一篇 的叙述, 健壮性也有了, 现在是时候处理点实际的东西了, 但我们依然不会一步到底, 让我们来看看.

一而再地抽象(Abstraction Again)

让我们继续无视那些空格以及星号等细节, 我们看到什么呢?

我们只看到一整行 的内容, 当传入 3 时就有 3 行, 传入 4 时就有 4 行. 我们用一个方法 getLineContent 来表示这样一个抽象. 代码如下:

java 复制代码
public String getPattern(int lineCount) {
    if (lineCount < 1) {
        throw new IllegalArgumentException("行数不能小于1!");
    }
    if (lineCount > 20) {
        throw new IllegalArgumentException("行数不能大于20!");
    }
    
    StringBuilder pattern = new StringBuilder();
    for (int lineNumber = 0; lineNumber < lineCount; lineNumber++) {
        pattern.append(getLineContent(lineNumber));
    }
    return pattern.toString();
}

黑盒子, 输入以及输出(Black Box, Input & Output)

先不急着让 IDE 生成代码, 现在集中精力思考一下, 我们仅仅在这一层面上去思考, 把 getLineContent 看作类似电路那样有一些输入端和输出端的黑盒子:

  1. 返回的值是我们想要的吗?
  2. 传入的参数是否足够让 getLineContent 里面完成它的工作呢?

第一点是可以肯定的, 但传入的参数是否足够了呢?

如果按上述代码, 不管是 3 行的情况, 还是 5 行的情况, 获取第一行的内容时, 调用的都是 getLineContent(0), 按照输入决定输出的原则, 结果将一样.

但我们很清楚, 5 行情况下的第一行前面的空格肯定要多于 3 行的情况, 如下图:

所以很显然, 只传入一个 lineNumber 是不够的, 还要把总的行数 lineCount 也传进去.

自顶向下(Top-down)

现在把代码改下, 多传入一个参数, 并让 IDE 为我们生成 getLineContent 的代码, 作些简单修改, 最终如下:

java 复制代码
public String getPattern(int lineCount) {
    if (lineCount < 1) {
        throw new IllegalArgumentException("行数不能小于1!");
    }
    if (lineCount > 20) {
        throw new IllegalArgumentException("行数不能大于20!");
    }
    
    StringBuilder pattern = new StringBuilder();
    for (int lineNumber = 0; lineNumber < lineCount; lineNumber++) {
        pattern.append(getLineContent(lineCount, lineNumber));
    }
    return pattern.toString();
}

private String getLineContent(int lineCount, int lineNumber) {
    // TODO Auto-generated method stub
    return null;
}

那么, 这样一种先从高层考虑起的做法, 就是所谓的自顶向下了, 接下来我们还会不断地以这种方式来完成这个小程序.

自顶向下是一种很重要的思考及处理问题的方式, 如果你还不习惯这样去考虑问题(包括写代码), 现在是时候尝试一下了.

项目进度(Project Progress)

另外, getPattern 方法里面的 TODO 标识可以去掉了, 这个方法已经算是完成了, 如果现在太阳就快下山了, 那么你也可以提交它了, 你的项目经理也很乐意看到"代码量天天在增长", 这给了他信心, 让他觉得"项目正在稳步推进", 当他给项目总监或者客户汇报时, 他就可以展示一些"进度"给他们看了.

当管理者看不到进度时, 他们就会感觉到压力, 这种压力会转移到你身上, 你甚至会"被志愿加班". 这种压力除了损害我们的健康外没有任何好处, 所以你要学聪明一点, 当管理者问起你的时候, 你就大声对他们说: "我今天又提交了 XXX 行代码. ", 然后你就拍拍屁股准时下班了.

再而三的抽象(Abstraction, again and again)

现在把目光投向 getLineContent 方法. 经过观察, 可以看出一行内容由三个部分组成, 我们再一次忽略具体的细节:

如上, 三种颜色表示了三个部分, 我们再一次运用抽象, 先不考虑传什么参数, 有点像是写 伪代码(pseudo code) 那样快速把程序的 骨架(Skeleton) 写出来:

java 复制代码
private String getLineContent(int lineCount, int lineNumber) {
    // TODO Auto-generated method stub
    StringBuilder content = new StringBuilder();
    
    // 1. 空格部分
    content.append(getFirstPart());
    // 2. 星号部分
    content.append(getSecondPart());
    // 3. 换行部分
    content.append(getThirdPart());
    
    return content.toString();
}

现在再来仔细考虑往里面传入参数的问题:

  • 第一个方法 getFirstPart, 其实是有关于输出前置空格的, 前面已经分析过"5 行情况下的第一行前面的空格肯定要多于 3 行的情况", 所以它需要两个参数.

  • 第二个方法 getSecondPart, 是关于输出星号的, 可以看出, 无论是 3 行还是 5 行, 第一行都是 1 个星, 第二行都是 3 个星, 所以这个跟总行数 lineCount 无关, 只与行号 lineNumber 有关, 所以只要传入一个参数即可.

  • 第三个方法 getThirdPart, 其实就是一个换行, 所以不需要传任何参数.

有人可能有些疑问: 这样是不是分得太细了? 抽象与封装究竟要到什么样的程度呢?

过度工程(Overengineer)

特别地, 让我们看看第三个方法: getThirdPart. 我们知道, 这最后其实就是一个换行, 一条语句即可搞掂, 所以再封装就没有必要了.

过度的抽象与封装有时反而使得程序臃肿难读, 半天也找不到具体"干活"的语句在哪, 性能方面也会受到损害.

Java 语言中已经可以直接表达换行的语义, 最终结果如下:

java 复制代码
private String getLineContent(int lineCount, int lineNumber) {
    StringBuilder content = new StringBuilder();
    
    // 1. 空格部分
    content.append(getFirstPart(lineCount, lineNumber));
    // 2. 星号部分
    content.append(getSecondPart(lineNumber));
    // 3. 换行部分
    content.append(System.lineSeparator());
    
    return content.toString();
}

private String getFirstPart(int lineCount, int lineNumber) {
    // TODO Auto-generated method stub
    return null;
}

private String getSecondPart(int lineNumber) {
    // TODO Auto-generated method stub
    return null;
}

抽象不足(Lack of Abstraction)

另一方面, 我们也要警惕缺少必要的封装层次的情况. 不幸的是, 很多情况, 我们都是缺少必要的抽象与封装.

做过维护的同学可能都见过那种超长超恐怖的方法, 里面的语句有的甚至高达几千行, 哪怕是在方法内找一个变量的定义, 也能让你想起周杰伦与费正清合唱的那首歌--<<千里之外>>, 去维护这样的方法自然不是什么愉快的经历.

这里之所以不厌其烦地对这个小程序不断的抽象下去, 是想告诉大家, 即使是如此之小的一个程序, 抽象到这一地步, 语义层面依然还没有过度的倾向.

通常, 如果程序语言已经可以直接表达出我们想要的语义, 封装就可以结束了. 我们来审视一下前两个方法, 显然, 还不能直接表达, 所以封装还可以继续.

一般地, 如果一条语句就能表达的时候, 抽象与封装也就基本到头了.

同时, 不必过于刻板地去遵循这些, 有时三两条语句可以表达时, 不封装也是很正常的;

而有时为了提供更清晰的语义, 哪怕只有一条语句, 你再封装一下也是可取的.

当然了, 对于目前这个小程序, 大家可能觉得已经有些过度封装了, 但在后面我们将看出, 其实还没到最抽象的阶段. 现在先不争论这一点, 说到后面我们就明白了.

分而治之(Divide and Conquer)

其实抽象与封装还能带来什么好处呢? 那就是这里要讨论的分而治之了.

我们可以回顾一下程序写到现在, 我们可曾遇到什么"阻碍"没有?

答案是没有. 你可以看看前面的代码, 都是简简单单的 for 循环, append 之类的.

有人可能不服气地说:

"困难的地方都被你这种一层又一层的抽象与封装延后了, 代码写了半天啥事也没干到. "

这种评价对不对呢? 的确, 前面通过抽象不断地压制 那些细节的表达, 不断地推迟对其的处理.

想像有一个房间, 衣服, 物品堆放得乱七八糟, 这时有人拿来一个大箱子, 把这些东西通通塞了进去. 把这些东西"封装"起来后, 房间自然整洁了, 但我们也很清楚, 箱子里依旧是一团糟.

但这个比喻并不适合这里的情况, 我们的抽象并不是简单地把问题转移 了, 通过一层层抽象的手段, 一个大问题在不断被分解成一个个小问题.

  1. 有些足够清晰的小问题, 我们已经在这一过程中把它解决掉了.

比如, 输出一个换行的问题.

  1. 而那些还不够清晰的小问题, 也已经通过抽象被我们所 孤立(isolate) 或者叫 隔离 出来了, 有的已经看到了解决的曙光.

比如, 在上一步, 我们还是有两个参数传了进来, 但通过在里面进一步划分成新的子问题, 可以看到, 有些子问题只要一个参数即可解决了.

所以, 抽象并不是什么事也没干, 相反, 它干了很重要的事情.

通过抽象, 问题正在被分解与简化;通过抽象, 我们构建出了程序的骨架.

在这一过程中, 大问题分解成小问题并被安排到了适当的位置, 与其它的小问题隔离开来, 有个词怎么说的, "众神归位", 大概就是这样一个意思.

群魔乱舞, 你怎么去应付呢? 如果他们都呆在自己的位置上, 我们就可挨个收拾他们了.

抽象不存在"事不过三"(No Limits for Abstraction)

让我们继续, 我们还可以继续抽象吗? 答案是肯定的. 无论是参数更多的 getFirstPart, 还是参数更少的 getSecondPart, 它们都还可以分成两部分:

  1. 拿到一个数量 N(你甭管怎么算出来)
  2. 输出 N 个空格或星号

代码如下:

java 复制代码
private String getFirstPart(int lineCount, int lineNumber) {
    int count = getElementCountOfFirstPart(lineCount, lineNumber);
    StringBuilder part = new StringBuilder();
    for (int i = 0; i < count; i++) {
        part.append(" ");
    }
    return part.toString();
}

private String getSecondPart(int lineNumber) {
    int count = getElementCountOfSecondPart(lineNumber);
    StringBuilder part = new StringBuilder();
    for (int i = 0; i < count; i++) {
        part.append("*");
    }
    return part.toString();
}

private int getElementCountOfFirstPart(int lineCount, int lineNumber) {
    // TODO Auto-generated method stub
    return 0;
}

private int getElementCountOfSecondPart(int lineNumber) {
    // TODO Auto-generated method stub
    return 0;
}

现在再来看看如何实现最后的两个方法, 以一个四行的图案为例:

图中规律已经很明显, 最终结果如下:

java 复制代码
/**
 * 获取每行第一部分的元素个数
 * @param lineCount 总行数
 * @param lineNumber 行号, 从0开始
 * @return
 */
public int getElementCountOfFirstPart(int lineCount, int lineNumber) {
    return lineCount - lineNumber - 1;
}

/**
 * 获取每行第二部分的元素个数
 * @param lineNumber 行号, 从0开始
 * @return
 */
public int getElementCountOfSecondPart(int lineNumber) {
    return lineNumber * 2 + 1;
}

这里把最后的两个方法加了注释, 并把它们改成了 public, 为什么呢? 下面将作些解释.

抽象到数字

有人可能不太理解, 为什么要抽象到如此之深, 这里最后两个方法都只有一条语句, 直接在上一层就写了不就完了?

可以看到, 最后两个方法, 返回的都是 int 类型, 也即一个数字. 我们都知道, 数字是非常纯粹, 非常抽象的一种概念, 抽象到了这一层, 已经不能再抽象了. 比如, 单独拿一个"1"出来, 它是非常抽象的:

1 可以是一粒土豆, 1 也可以是一颗红薯;

1 可以是一匹元代马, 1 也可以是一头程序猿.

抽象(abstract)作为一个动词而言, 它的原始意义, 有"把...抽取出来"的意思, 即有把东西抽离, 剥离的意思.

我们说数字很纯粹, 为什么要追求这种纯粹呢? 这一过程中我们又把什么剥离了?

耦合, 解耦合, 得意而忘形(Coupling, Decoupling, $%#&...)

我们都听过一种说法, 叫"言不达意"或者又叫"词不达意", 表明我们用"言"来表达"意", 当然"达不达"就是另一回事了;另一方面:

"言者所以在意, 得意而忘言. "--<<庄子 外物>>

而<<晋书·阮籍传>>中有一段对阮籍的描述:

"嗜酒能啸, 善弹琴. 当其得意, 忽忘形骸. "

这就是所谓的"得意忘形"的最初意义:

指得其意, 即其思想精髓, 而不必计较形, 即表现形式.

而"形意交融"则表明形跟意常常是混在一起的, "意"需要通过"形"传递给我们.

要表达的意思与它的载体之间的这种紧密关系, 用我们软件领域的说法, 就叫"耦合".

这里可以算是耦合的一种, 耦合还可以有很多其它方面的理解.

这种形与意的交融有时并不是件什么好事, 陶渊明在他的<<归去来兮辞>>里说:

既自以**心为形役,**奚惆怅而独悲.

回到我们的问题, 前面一直在处理这么一个图案, 那么, 这个图案它的"形"是什么呢? 而它的"意"又是什么呢?

显然, 那些一个个的星号(以及前面的空格)就是所谓的"形"了, 而"意"呢?

其实就是前面说的"抽象到了极致的数字"了, 这就是图案的"意".

通过把"形"从图案中剥离, 或者说把"意"从图案中抽取出来, 我们就能"得意而忘形", 从而达到解耦合的目的.

把握住了"意", 我们就不必拘泥于空格或者星号, 我们可以使用各种各样的"形", 最终出来的图案依然可以看到"三角形"的影子.

如果你已经对所谓的 MVC(Model-View-Control, 模型-视图-控制)有些了解, 那你是否在这里看到了 Model 跟 View 的影子呢?

再一次的, 由于篇幅过长, 这次还是不能"扒到底", 美腿有点长, 再扒一半, 就此膝斩. Hold 住, 余下主题我们下回再见. 下一篇见

小程序中的大道理三

相关推荐
zth4130212 天前
CSP-J模拟赛三补题报告
c++·算法·抽象·csp·c--
网始如芯3 天前
Allegro从.brd文件中导出器件封装
封装·allegro
职创未来官方14 天前
大话C++:第11篇 类的定义与封装
c++·面向对象·封装··嵌入式物联网·访问修饰符
爱桥代码的程序媛24 天前
HarmonyOS开发5.0【封装request泛型方法】axios
axios·harmonyos·鸿蒙·鸿蒙系统·openharmony·封装·泛型
得物技术3 个月前
基于Rspack实现大仓应用构建提效实践|得物技术
最佳实践·rspack·前端大仓
R-QWERT3 个月前
音视频封装demo:将h264数据和aac数据封装(mux)成FLV文件(纯手工,不依赖第三方开源库)
aac·h264·封装·flv·音视频容器
R-QWERT3 个月前
音视频封装demo:将h264数据和aac数据封装(mux)成TS文件(纯手工,不依赖第三方开源库)
ts·aac·h264·封装·音视频容器
大小胖虎3 个月前
专业课笔记——(第九章:类的封装、继承、多态)
笔记·多态·继承·封装·构造函数·静态成员·友元函数
雨天行舟3 个月前
abap 类封装Excel转换到内表
excel·封装·sap abap
aaasssdddd963 个月前
C++的模板(八):子系统
c++·stl·继承·模板·封装·构造函数