20150525

抽象之艰难,一次作业分析

抽象之艰难,一次作业分析

1. 问题的提出,一次作业

要求实现 小学四则运算系统,系统出10道题,用户输入答案,系统判定对错,系统给出对错的数量。

这是以邹欣老师的《构建之法》作为教材,某高校的软件工程课程作业。

该作业要求迭代开发,在《构建之法》群里,企业的工程师、高校教师对某次某位同学提交的作业进行了点评。

这位同学提交的是 JAVA GUI 版本的程序。控制件的选择,每道题的两个数是文本框禁用,要求用户输入答案处是文本框,判断对错是文本框禁用;选择加减乘除题目,是按钮。

该同学实现了出10道题目。问题来了,邹欣老师问,如果我要求出40道题目呢?之所以有此一问,是因为该同学硬编码了10道题目的 控件和业务逻辑,她的代码看起来是这样的。

----节选代码开始

JTextField jfirst1, jfirst2, jfirst3, jfirst4, jfirst5, jfirst6,
            jfirst7, jfirst8, jfirst9, jfirst10;// 10道题的分别的第一
            个数据JLabel jsymbol1, jsymbol2, jsymbol3, jsymbol4,
            jsymbol5, jsymbol6,
            jsymbol7, jsymbol8, jsymbol9, jsymbol10;// 10道题的分别的符号
    JTextField jsecond1, jsecond2, jsecond3, jsecond4, jsecond5, jsecond6,
            jsecond7, jsecond8, jsecond9, jsecond10;// 10道题的分别的第二个数据
    JTextField janswer1, janswer2, janswer3, janswer4, janswer5, janswer6,
            janswer7, janswer8, janswer9, janswer10;// 10道题分别的结果

// 添加每道题的符号标签
        jsymbol1 = new JLabel("?");
        jsymbol2 = new JLabel("?");
        jsymbol3 = new JLabel("?");
        jsymbol4 = new JLabel("?");
...
// 设置答案对错的标签
        jjudge1 = new JLabel("?");
        jjudge2 = new JLabel("?");
        jjudge3 = new JLabel("?");
        jjudge4 = new JLabel("?");
...

/* 加法计算 */
        if (e.getSource() == AddBtn) {
            flag=1;
            jsymbol1.setText("+");
            jsymbol2.setText("+");
            jsymbol3.setText("+");
....
            fun();
            random();
        }

// 计算第二道题
            String answer2 = null;
            a2 = first2 + second2;
            answer2 = Double.toString(a2);
            String result2 = janswer2.getText();
            if (result2.equals(answer2)) {
                jjudge2.setText("对");
                true1 = true1 + 1;
                jjright.setText(Integer.toString(true1));
            } else {
                jjudge2.setText("错");
                jjresult2.setText(answer2);
            }
// 计算第三道题
// 计算第四道题

----节选代码结束


2. bad smell

该同学的全部代码在这里,java语言GUI版本的[http://www.cnblogs.com/tujiangfeng/p/4480053.html]。

事实上,能细心地安置10个控件,复制粘贴->修改其中的不同,是件很不容易的事情。需要耐心和精确,稍有不慎就会操作错误,而查找这个错误非常困难,因为它隐藏在面目相似的群体之中。又,耐心和精确,确是一种非常可贵的品质,难以替代,不过,科学和技术还给我们另一种力量,就是让 平凡的人 拥有更强大的力量。任何人都能学会,只要掌握就能更牛,这是科学与巫术的不同。

该同学的问题,在她稍晚的作业中自己回答了,"Duplicated Cod(重复代码)" [http://www.cnblogs.com/tujiangfeng/p/4500116.html],这是重要的代码坏味道,重要到,它排在了坏味道中的第一名。

所谓重复,就是看起来 它们相似。

该同学其实曾经完美的解决过这个问题,在她更早的作业中,C语言console版本的 [http://www.cnblogs.com/tujiangfeng/p/4399804.html],她用for循环实现了5道题目,判断题目对错,只写了一次代码, (而在JAVA GUI版本中,她写了10次)。

3. 解决之道

沉默的代码(某位资深工程师,他在群里不是这个名字,所以不知道是谁)在作业的博客中批阅道,"使用For循环重构上面的代码"。

那天,在群里得有10位左右资深工程师给出了相同的意见,用了各种不同而相似的说法。相信该同学已经蒙了,"他们到底说的是什么啊。"最打击人的,不少资深工程师对自己给出的方法加了限定和修饰,"这是非常基本的方法"。

如果该同学看到我这篇博客,请一定仔细读一下我下面这一段话。"基本"一词,不是指它简单,或者暗示你在道德 (勤奋、努力之类的)上 理应掌握,而是它极其经常在工业中应用,所以,需要掌握。需要掌握的原因,是这是本领域的"基本"技术,就像战士的射击。

资深工程师们的核心思想是,用循环去除重复。

该同学的疑问有二。一,她们没学过数组控件。对此,邹欣老师建议,用控制台实现,用意大概是先掌握抽象这一方法,至于控件什么的技术,倒在其次。该同学提到,JAVA没有学过控制台的程序。二,怎么循环呢,怎么抽象呢?

对于第二个疑问,即需要A.原则,B.精晰的可操作的指令,也需要C.示范。示范在后面,下面先给出指令。

把上述代码抽象为循环,A.原则是,找出代码中 相同的部分 和 有变化的部分。把相同的部分放在循环 (或者函数)中,把不同的部分,作为循环的变量 (或函数的参数。参数又译为变数,其义正对),或者作为变量的函数 (这个函数的意思是数学上的,即可以由变量求得)。

B. 指令如下:

把那些名字相似,只有最后一个数字不同的,不单独声明为变量,而是声明为数组;不用名字区分它们,而是使用数组的下标区分它们。

凡是对这些变量操作(读或写)的,改为对数组元素操作。对某元素操作的若干次,改为在循环中执行,每次变更 (比如递增1) 数组元素的下标。

一共三种代码需要关注。(1)不变的部分,在每次循环中都一样, (2)数组下标,每次循环都变更, (3)下标或循环变量可能不同,需要找到它们间的关系。本例中没有第 (3)种情况。

C. 示范,上面的代码,按资深工程师们的指示,大概改成下面这个意思。

----节选代码开始
List<JTextField> jfirst = new ArrayList<JTextField>();  // 10道题的分别的第一个数据
List<JLabel> jsymbol = new ArrayList<JLabel>();         // 10道题的分别的符号
List<JTextField> jsecond = new ArrayList<JTextField>(); // 10道题的分别的第二个数据
List<JTextField> janswer = new ArrayList<JTextField>(); // 10道题分别的结果

// 添加每道题的符号标签
// 设置答案对错的标签
int max_length = 10;
for(int i = 0; i< max_length; ++i)
{
        jsymbol.add(new JLabel("?"));
        jjudge1.add(new JLabel("?"));
}

...

/* 加法计算 */
        if (e.getSource() == AddBtn) {
            flag=1;
        for(int i = 0; i< max_length; i++)
        {
            jsymbol[i].setText("+");
            }
            fun();
            random();
        }

// 计算第二道题
// 计算第三道题
// 计算第四道题
for(int i = 0; i< max_length; ++i)
{
            String answer[i] = null;
            a[2] = first[2] + second[2];
            answer[2] = Double.toString(a[2]);
            String result[2] = janswer[2].getText();
        ....//省略的原因不是像循环的类似,而是可按上述方法修改
}
----节选代码结束



4. 不仅如此

对于教学本身的讨论,至此可以告一段落,但是更深的讨论,才刚刚开始。在经典导论教材SICP中,一共五章的书里,用了三章讲抽象,甚至标题就叫做XX抽象。可见抽象之重要。

抽象为循环、抽象为函数,以上给出的原则、指令、示范,是非常形而下的,因此更容易操作。但是,在何处情况下应该做抽象,如何抽象,是个更深刻的问题。比如,我们是否要把四则运算中的加减乘除抽象为同一个函数么,向然后向这个函数传入一个 first class 的操作符;我们是否要把供用户选择题目类型的按钮抽象为一个数组;为什么要这样做,为什么不这样做?为什么不抽象为面向对象,为什么不抽象为AOP,为什么不使用设计模式。

如果仅回答,"使用这些技术会导致代码更复杂",那么,"复杂"在这里,很大程度上只是个人的主观体验。这位同学也可以说,抽象成控件数组,还不如复制粘贴容易呢。在做项目的时候,我的学生就真的这样质疑过。当然,我们可以回答,"当代码规模更大的时候",可是,你又如何知道代码的成长方向呢?

从一次次的执行 (执行,是动态,或复制粘贴,是静态)中,发现其中不变的部分,抽象这些部分,并允许不同的部分可以在不同的情境 (第几次循环,函数的参数)下指定;还要在代码中同时包括不变和变化的部分 (比如,用循环变量和数组下标表示变化)。

这是一项异常艰难的工作。因此,教授抽象,也非常艰难,如果不是更难的话。

刘典说,他是先学会编程,后学的英语。因此,在他看来,for的第一含义就是循环,而不是"为了"。对于工程师而言,把技术内化为本能,值得称道,不过,作为教师,这就是 (如他所说)知识诅咒。因为你忘记了当初如何艰难地掌握了这一点,甚至,你并没有深刻掌握,只是每次都类比过去的成功经验,照样应用了而已。

为什么艰难?因为在编程以外的经验中,我们鲜能发现 循环。有人可能会说,日出而作、日落而怎,每个学期、每周的课表,这不是循环吗?这不是。这不是实现完整的循环所需要的,是粗糙的。在刚刚提到的这些循环中,什么是循环变量,循环的开始和终止条件是什么,有必要存在循环变量吗,是否可以用迭代器取代,用foreach呢,在循环体中,哪些是每次循环要改变的,哪些是不变的。

有人说,这简单啊,上述问题都能答上来。但是,你在日常生活中考虑上述循环的时候,会考虑这些问题吗,还是只是粗糙地应付过去?这就是日常生活中没有循环这一经验的原则。

而在掌握一项技能之初,有类似的经验非常重要。这就是为什么物理学的力学部分,男同学普遍比女同学掌握快的原因--经验。这也是为什么物理学的电学部分,好多同学一下子就蒙了的原因,不是因为看不见摸不着,而是现实中没有这样的东西。水流、水压,这些比喻并不与电学概念完全对应,更何况大家对这些流体 (静?) 力学的经验也就呵呵。

循环的经验来自于哪里,为什么循环对很多人难以掌握?

与循环高度类似的,是高中代数中的数学归纳法,是数列的通项,是求得的sigma上面的那个i。而这些,有很多人本来学得就烂。这就是循环的抽象。当然,前面学得不好,也可以利用计算机的for循环学习这种抽象。

(题外话,类似的,与实验技能相关的,实验现象说明什么,假阴性假阳性,这些对应的是初中平面几体里的充分条件必要条件。很多人没有把逻辑推理内化,或者没有自觉地使用这一工具,因此在做实验的时候会糊涂。)

为什么抽象,如何抽象,这个话题也可以延伸到面向对象的继承和多态,不赘述。我想说的是,抽象,并不是简单和自然的,不是理应掌握的技能,而是必须掌握的技能。

-------

博客会手工同步到以下地址:

[http://zhuanlan.zhihu.com/younggift]

[http://giftdotyoung.blogspot.com]

[http://blog.csdn.net/younggift]