美文网首页编程镓教
编程镓教・入门篇|004 一切为了变化

编程镓教・入门篇|004 一切为了变化

作者: 深加思考 | 来源:发表于2017-05-08 08:03 被阅读149次

    学习目标

    • 理解函数、参数、声明和调用的概念
    • 掌握用变量和函数应对变化的方法

    学习用时:60分钟

    通过前几课的学习,我们已经掌握了用代码来画点、线和方块的方法,现在大家都能在画布上画出自己喜欢的图案了。

    接下来,我们希望让画面能够动起来。比如说,让你画出的小人从画面的一边,移动到另一边去。

    但是,怎样才能把我们画出的图案,移动到另一个位置呢?

    工匠的困境

    在很久很久以前,有一位法老想要给自己建造一座雕像。于是他找来了一位手艺精湛的工匠,并选好了一座山头。于是工匠拿来锤子和凿子,叮叮当当地开工了……

    image.png

    就这样日复一日、年复一年;不知不觉间,五十年过去了……

    image.png

    工匠终于完成了雕像,此时他已是个白发苍苍的老人。他把几乎一生的心血都花在了这座雕像上,看着自己完成的作品,心中感到无比地自豪和骄傲。然而,法老在视察完他的工作成果后,冒出轻描淡写的一句话来,让他顿时万念俱灰:

    “挺好的,就是有点歪。往右边挪上一米吧!”

    上面这个故事来源于《The Art of Readable Code》一书。这个故事生动地阐释了下面这条原则:

    Change Is The Only Constant:唯一不变的就是变化

    image.png

    根据默菲定律,任何可能发生的事情,只要给足够的时间,就一定会发生。所以我们在编程时,要尽可能地考虑到需求变化的可能性。否则需求一旦发生变化,就会不可避免地陷入到工匠的困境中去。

    移动一下试试看

    image.png

    请在Chrome浏览器中打开下面的链接:

    http://codepen.io/zhangshenjia/pen/ZKbWEz

    网页加载完成之后,应该能看到这样的界面:

    image.png

    在这个程序中,我们画好了一个十字,像不像射击游戏里的准星?这是一个仅由五个点组成,简单得不能再简单的图形了。

    如果我们想把这个准星向右挪动一个像素,该怎么修改程序呢?

    我们首先想到的是,应该把所有点的水平坐标都加一,也就是把所有画点语句中的第一个数字加一。让我们来试试看:

    image.png

    到现在为止,你感觉还OK吧?那是因为这个图案只有五个点。那如果我让你把上节课的作业里的图案移动一下呢?

    现在,我们正面临着和工匠一样的困境:由于在我们用代码画出的图案里,每一个点的坐标都是固定的数字。因此如果想要移动图案,哪怕只有一个像素的距离,都必须修改所有画点语句中的坐标。如果我们的图案由成百上千的点组成,那全部修改一遍简直就是个噩梦!

    还记得上节课我们学过DRY原则(Don’t Repeat Yourself)吗?有没有觉得这样的修改很重复呢?要是能够只修改一个地方,就自动同步到所有使用的地方就好了……

    用变量来适应变化

    不妨先想一想,在生活中遇到会变化的需求时,我们是怎么处理的呢?

    image.png

    首先,我们得设计一个可以变化的组件,并通过它来对变化进行适应。比如汽车座椅中可以调节角度的轴承、活动扳手中的可以调节卡口尺寸的蜗轮。

    那在编程中,有没有可以这样变化的东西呢?当然有,那就是上节课我们就用过的变量

    首先刷新一下页面,把代码复原。然后在第一句画点代码上方添加一个空行,输入下面的代码:

    var x = 0;

    image.png

    这样我们就定义了一个变量 x用来保存水平的坐标,并给它赋值为 0。接下来,我们把所有画点代码中第一个数字前面都加上 x +

    image.png

    注意:在符号 + 左右各有一个空格。它们虽然没有实际意义,但能使我们代码显得更清晰、读起来更省力。对此感兴趣的同学可以课后自行搜索一下“代码风格”。

    现在我们可以修改 var x = 0 中的初始值看看,比方说改成 5

    image.png

    Oh Yeah,我们只修改了一行代码,就可以让整个图案进行水平移动了!

    接下来,我们可以用同样的方法来实现图案的垂直移动。定义一个变量 y ,并在所有画点代码中的第二个数字前面都加上 y +

    image.png

    这样一来,我们就可以通过修改变量 xy 的值,把图案移动到画布的任何位置。再也不怕修改位置的需求了!

    想要更多怎么办?

    image.png

    要知道,需求的任何一部分都可能发生变化。除了图案所处的位置之外,图案的数量也会可能会变。现在画布上只有一个十字,要是我们需要画更多的十字怎么办?

    有同学说:这好办,只要把画十字的代码再复制一份,然后修改 xy 的值就可以了嘛!想画多少十字,就复制多少次呗!

    image.png

    那如果我们要画100个十字该怎么办,把代码复制100次吗?我们画十字的这段代码只有短短几行,多复制几遍貌似还可以接受。但如果我们画的是一个复杂的图案,需要几百行代码来完成呢?

    复制代码确实可以简单粗暴地临时解决问题,但事后修改起来就很麻烦了。比如说,我们想把画面上所有的十字都改成红色,就需要在所有复制出来的代码里都加上一行更换颜色的代码。万一改完发现还是黑色好看的话,还得把刚才添加的代码一行行删掉……

    需求只发生了一个很小的变动,就要修改一大堆重复代码,业内把这样的情况称之为“霰弹式修改”。由于我们是人不是机器,这样做很累自不用说,在做大量修改时也难免会发生疏忽,比如漏加了一处代码,又或者在删除换颜色代码时错把画点代码删掉……

    啥?我都写了这么多WORK了,你告诉我你其实要的是WORD?

    又有同学说:那我们能不能用上节课学过的循环呢?把画十字的代码放在循环体里,然后每次循环改变 xy 的值不就行了吗?这样画十字的代码就只会出现一次了呀!

    问题是,循环只能用来处理连续性的重复工作,对非连续的重复无能为力。我们可以用循环来一次性画出N个十字,但是不能中途停下来。然而,有很多重复性的工作都不是连续性的。

    比如在某个网络游戏中,获取经验值有很多方法(杀死敌人、完成任务、挂机……),经验值满了之后就需要升级,然后提升人物的一系列属性,还有可能学得新的技能。那么在获取经验值之后,判断是否需要升级的逻辑就需要多次重复运行,但获取经验值的逻辑却散落在程序中多个不同的地方……这样的需求,是无法通过循环来解决的。

    那除了循环之外,还有什么办法可以让一段代码能够重复使用呢?答案就是:函数

    什么是函数?

    打酱油去!

    想象一下,如果你家没有酱油了,需要去超市买,但你自己又不想跑腿,正好孩子放学回家,就想让他去打酱油。因为孩子之前没干过这事,所以你得教他具体该怎么做。

    「打酱油」的流程:

    • 带上足够的钱,出门去超市
    • 找到调味品区,拿一瓶酱油
    • 在收银台结帐,收好找零
    • 把酱油拿回家,交到你手上

    这样一来,以后再需要买酱油的时候,只要告诉孩子“打酱油去”就行了,而不用再把整个流程重新讲一遍了。( 你说啥,都忘光了?那我再给你讲一遍……)

    「打酱油」就是一个函数,同时它也是这个函数的函数名。而打酱油的具体流程,就是这个函数的函数体

    函数(Function):可以在程序内被重复调用的一段代码
    函数名(Function Name):函数对外的名称
    函数体(Function Statement):函数内部执行的具体流程

    教孩子怎么打酱油,就是在声明这个函数。对孩子说“打酱油去”,就是在调用这个函数。而孩子最后交到你手上的酱油,就是函数的返回值

    声明(Declare):告知程序的执行者有这么一个函数存在
    调用(Call):在程序运行的过程中,要求执行某个函数
    返回值(Return Value):函数调用完毕后的返回结果

    显然,如果你从来没有教过孩子,就让他去打酱油,他肯定会蒙圈的。一个函数必须得先经过声明,才能进行调用。因为如果不进行声明,程序的执行者根本不知道有这个函数存在,当然也就无法去执行了。

    函数的返回值并不是必须提供的。有的函数要求提供一个明确的返回值,比如「买酱油」这个函数,就明确要求拿到一瓶酱油,即便因为各种原因没有买到,那也得给出个说法;而有的函数则只看重运行的过程,比如「冥想」这个函数,并不需要最后拿出个什么成果来。

    image.png

    函数可以使一段逻辑在不同地方被重复调用。可以用函数来解决那些循环无法解决的非连续性重复问题。由于每个调用的地方只会出现函数名,而不会出现具体的逻辑。这样在需求发生变化时,不管这个函数被调用了多少次,我们都只需要修改函数体里的逻辑就行。

    当然,更改函数名的时候,所有调用这个函数的地方还是不可避免地要同步修改。所以起一个好名字,非常非常地重要!关于怎么给函数起一个好名字来尽量避免修改,同学们可以在课后搜索一下。

    需求有变化怎么办?

    image.png

    不过,这样的函数虽然解决了在不同地方重复调用的问题,但每次执行的逻辑都是固定不变的。比如「打酱油」函数,在不出意外(超市关门、没货……)的情况下,每次都会得到一瓶酱油。

    然而我们知道:需求是不可能一成不变的。今天我们需要一瓶酱油,明天可能要十个馒头,后天则可能要一打可乐……要怎样才让函数可以应对这些变化呢?

    首先想到的是,我们能不能给购买每种商品的流程都声明一个函数,并在需要的时候调用它们呢?就像这样:「买馒头」、「买可乐」……

    这样虽然貌似解决了问题,却产生了一大堆逻辑雷同的函数。如果购买流程中的任一环节的逻辑变更,就需要同步修改所有的函数。何况即便是相同的商品,每次买的数量也可能不同,难道还要声明「打酱油」、 「打2瓶酱油」、「打3瓶酱油」……这样一系列的函数吗?

    image.png

    我们可以把函数调整修改一下,来应对可能发生的变化:

    「买东西」的流程:(调用时需要说明要买的「东西」及「数量」)

    • 带上足够的钱,出门去超市
    • 找到货架,拿「数量」的「东西」
    • 在收银台结帐,收好找零
    • 拿回家,交到你手上

    「买东西」也是一个函数。但和「打酱油」有所不同的是,在调用「买东西」时需要指明「数量」「东西」,它们都是函数的参数

    参数(Arguments):调用函数时所提供的数据

    在函数体内,可以用与参数同名的变量,来访问传入的数据。假设我们在调用「买东西」函数时传入的「数量」3「东西」是** 辣条,那么函数的第二步实际执行的流程是这样的:“找到货架,拿三包辣条”

    参数不一定都是必须提供的,提供了默认值的参数可以省略。有的参数是必须提供的,比如要买的「东西」,如果不说清楚,就根本不知道要买啥;而有的参数是可以省略的,比如要买的「数量」,在没有提供的情况下,那就默认只买一份。

    image.png

    通过更换传入的参数,我们不需要对函数内部逻辑进行改动,就能控制逻辑的变化。比如,我们可以发起这样调用:「买两包盐」、「买五瓶啤酒」……

    用函数来画十字

    接下来,我们要声明一个「画十字」的函数,在调用时把坐标当成参数传进去,这样就可以在画布的任意坐标位置画出十字了。如果想画多个十字的话,多调用几次就行了。

    首先,我们把刚才添加那两行 var 语句删掉,替换成下面的代码:

    function drawCross(x, y) {

    然后,在最后一句画点语句后面增加一个空行,输入一个符号 }

    image.png

    这样我们就声明了一个函数,名为 drawCross (draw是“画”,cross是“十字”,联合起来就是“画十字”的意思)。这个函数有两个参数:xy,指定了十字在水平和垂直两个方向上的位置坐标。在函数体内会自动声明两个和参数同名的对应变量 xy,它们只能在函数体内部使用。

    需要注意的是,函数名里是不允许有空格的。像drawCross这样把多个单词直接连起来,并让首字母大写的方法叫做驼峰命名法。也有draw_cross这样的命名法,不过还是驼峰命名法比较常用。虽然我们也可以直接用中文「画十字」来当函数名,但我强烈建议不要这么做。

    现在的函数体没有缩进,看起来结构不清晰。让我们选中函数体里所有的画点代码,按下 TAB 键增加缩进,这样代码看起来就舒服多了:

    image.png

    但是现在画布是空的,我们的十字到哪里去了呢?原来我们只声明了函数,并没有调用它,所以函数体里的逻辑并不会被执行。接下来,就让我们添加一个函数调用吧。

    image.png

    在程序的最底部添加一个空行,输入下面的代码:

    drawCross(0, 0);

    image.png

    十字出现了!在程序执行到我们刚刚添加的这一句时,就会跳转到 drawCross 函数内部去执行,执行完后再回来继续往下走。就像我们读外文书时,发现一个不认识的单词就停下来去查字典,查完回来接着读一样。

    现在我们可以通过继续调用这个函数,在画面的不同位置画出更多的十字了。试着在程序底部添加这几行代码:

    drawCross(0, 3);
    drawCross(3, 0);
    drawCross(3, 3);

    image.png

    我们通过四个十字组合出了一个符号 #,显然这是个更复杂的图案。那如果我们想要把这个图案移动到其他位置,该怎么做呢?

    在函数里调用函数

    这个图案是通过对 drawCross 函数进行四次调用画出来的。那么我们直接修改这四行代码里的调用参数行不行呢?

    image.png

    当然可以!毕竟要修改的只有四行代码,但要是我们的图案是由100个十字组成的呢?那要修改多少行代码?

    我们再一次遭遇了工匠的困境,那我们是不是还可以用变量来隔离变化呢?

    image.png

    当然可以!不过,如果我们需要画多个符号 # 呢?还是得复制一堆代码……这样一下霰弹式修改还是无法避免。那么,我们能不能像声明 drawCross 函数来画十字一样,再声明一个 drawHash 函数来画符号 # 呢?

    image.png

    当然可以!要知道函数体是一段代码,而函数的调用也是一行代码。所以我们可以在函数体里再调用别的函数,就可以我们在循环体内使用循环一样。

    image.png

    那能不能在函数里声明函数呢?当然也可以,但这样声明出来新函数只能在旧函数里使用。关于函数作用域的内容,感兴趣的同学可以课后搜索一下。

    我们在四句对 drawCross 函数的调用前面加上一句代码:

    function drawHash(x, y) {

    然后在程序最后面加上一个 },这样就定义了一个 drawHash 函数。不要忘记给函数体缩进噢:

    image.png

    现在图案消失了,因为我们还没有添加调用呢。随便给个坐标,调用一下看看吧:

    image.png

    为什么图案还是画在左上角,没有画在我们指定的坐标呢?因为在 drawHash 函数里对 drawCross 函数进行调用时,并没有把我们指定的坐标传递过去。虽然这两个函数里都有 xy 这两个参数,各自函数体里都有同名的两个变量,但是它们互相是没有关系的。

    image.png

    我们在调用 drawHash 函数时使用的参数是 10, 10,所以在 drawHash 函数的变量 xy 的值都是 10。但在调用 drawCross 函数时的参数就不一样了,比如第二次调用时的参数是 0, 3 ,那在 drawCross 函数内的变量 xy 的值就分别为 0, 3

    每个函数的参数变量都只能在函数内部使用,外部是无法访问的,只能通过调用时传入参数来对其进行赋值。关于变量作用域的内容,感兴趣的同学可以课后搜索一下。

    如果想让我们给 drawHash 函数传递的参数影响 drawCross 函数,就得在调用 drawCross 函数时改变参数,也就是把 xy 加进去:

    image.png

    大功告成!现在我们有了 drawCrossdrawHash 两个函数,可以用一行代码画出十字,也可以用一行代码画出#。当然,你总是可以在现有函数的基础上,构造出更复杂的函数……最终,你就可以仅仅用一行代码,就画出一个很复杂的图案来。

    能不能在drawHash函数里再调用drawHash函数自己呢?理论上是可以的,这种做法叫做递归(Recursion)。递归是一种比较有难度的编程技巧,需要精心设计控制流程,避免发生无限调用。现在我们还用不着它,感兴趣的同学可以课后搜索一下。

    「自底而上」vs「自顶向下」

    到目前为止,我们做了下面这些事:

    • 先想办法画一个点
    • 用同样的方法画一堆点来组成图案
    • 把这一堆画点的代码声明为一个函数
    • 通过调用函数和画点,画出更复杂的图案
    • 把这一堆画图的代码再声明为一个函数
    • ……

    这种“先看看能做点什么,然后再看看能做点别的什么”的思考和行动模式,我们称之为自底而上(Bottom-up)。每走一步就能看到对应变化,一步一个脚印,走得很踏实。

    image.png

    然而,在解决实际问题时,仅仅靠「自底而上」是不行的。因为能做的事情实在太多了,但可能绝大多数都和我们现在想做的事情没什么关系。只着眼于当下能做什么,而不思考我们想做什么,就可能会迷失方向,一直在原地踏步;甚至于南辕北辙,离目标越来越远……

    另外一种思路是,先确定好要达成的目标,制定一个整体规划,再分解成具体的行动计划并执行。这正是我们之前学过的万金油思路——「拆分」。这种“先想清楚要做什么,然后再看看怎么去做”的模式,我们称之为自顶向下(Top-down)

    image.png

    当然,仅仅靠「自顶向下」也是不行的。我们想做的很多事情,现在是做不到的。总是纸上谈兵,想太多不切实际的东西,只会浪费时间。结合使用「自底而上」和「自顶向下」这两种模式,理论联合实际才是王道。

    在用「自顶向下」的思路来分解目标,作出初步的规划设想的同时;也需要根据目前具备的资源和能力,用「自底而上」的思路来检验设想的可行性。只有当我们在这两种思路之间找到了结合点,才能将设想进一步细化成计划进而执行。

    当设想不可行时,是放弃目标或降低标准,还是去获取现在不具备的资源和能力呢?这得看目标的优先级有多高、是否是核心需求,在达成目标的期望价值和获取资源能力的代价中反复做权衡……这已经远远超出了本教程的范畴,容我不再细表。

    写一个画笑脸的函数

    假设我们现在的目标是:在画布上画出一个笑脸。由于这是一个独立且完整的任务,所以我们可以声明一个 drawFace 函数来完成它:

    image.png

    先用「自底而上」的思路分析:我们已经具备了在画布的任何位置用任何颜色画出像素的能力,而画布上的笑脸肯定是由一堆像素构成的,所以这个目标必然是可达成的。 所以,尽管此时我们的函数里一行代码都没有,但我们完全可以相信,这个函数的功能是可以实现的。

    所以,这个函数也没必要现在就写,可以先去做更重要或更紧迫的事;依赖这个函数的工作(比如写一个画小人的函数drawPerson)现在就可以同步开展,而不必非得等到这个函数完成后再进行。只要在必须在画布上看到笑脸时,把它完成就好。

    随后我们可以用「自顶向下」的思路来分解这个函数。一般来说,一个笑脸由眼睛、嘴、鼻子、眉毛等部分组成。其中眼睛和嘴巴是必需的,所以我们可以再添加两个函数 drawEyedrawMouth,其余非必须的部分可以先写成注释,以后有时间再添加:

    image.png

    基于同样的原因,我们断定 drawEyedrawMouth 函数是可以实现的。所以这时尽管这两个函数现在还是空的,我们也可以宣告 drawFace 函数完成了,因为它已经完成了自己的使命:罗列所有必要的组成部分,并整理好它们之间的关系。

    当然,眼睛和嘴巴之间的距离可能还需要不断调整,但这无关紧要。至于眼睛和嘴巴到底画了没有,画得怎么样,我们在验收 drawFace 函数时并不关心。因为那是 drawEyedrawMouth 函数要完成的任务。

    接下来的任务就是完成 drawEyedrawMouth 函数了。我们可以找时间分别来完成它们,也可以分配给别人来干。为简单起见,我们只画一个点来当眼睛,画四个点来当嘴巴:

    image.png

    笑脸完成了!

    函数的价值和意义

    “工欲善其事,必先利其器” —— 《论语・卫灵公》

    在「我的世界」这款游戏里,玩家一开始手里空空如也,什么都没有。只能赤手空拳去撸树,然后拿到木头做成斧头等工具,再去高效率地采集更多的资源。

    image.png

    写函数的过程,就是打造工具的过程。虽然写函数的过程比较吃力,但写出来的函数可以大大地方便我们之后的工作。虽然函数内部的代码逻辑会比直接堆代码要复杂一点点,但在调用函数时的代码却简洁了许多。这和解魔方一样,用初级方法会比较简单易懂,但步数要多一些;而用高级方法会比较复杂,但步数会少一些。

    image.png

    当一段逻辑需要多次使用时,简单地复制粘贴一遍代码貌似是第一时间就能想到的方法。要是需求稍有变化,那就做一点适当的改动。结果可能就会产生一大堆雷同或者大同小异的代码:

    image.png

    我们把一段需要多次使用的逻辑封装成函数后再调用,显著地减少了重复代码。从而避免了直接复制代码可能导致的“霰弹式修改”,可以更好的适应需求的不断变化。关于这一点,我们已经通过上面的实践得到了深刻的体会。

    image.png

    函数隐藏了不必要的实现细节,同时降低了在修改代码的过程中出错的可能性。以机械表为例,如果不用表盘遮住内部,就会给使用者带来不必要的心理压力,也很容易损坏其内部精密的结构。

    image.png

    我们还通过函数名传达了逻辑意图 ,使本来需要注释的代码意图变得更直观,更容易理解。用术语来说,就是提升了代码的可读性。很明显,面对一堆画点语句,你不看注释或者不手动运行测试一下,根本不可能明白它画的是什么。而对一个名为drawCross的函数进行调用,则明明白白地告诉了读者这行代码的作用:我要画一个十字

    image.png

    最重要的一点是,我们通过函数隔离出了一个抽象层次。这使我们可以将当前的思维局限在某个环节之中,将全部的注意力用于在当前层次上进行完整自洽的思考上。于是我们得以自顶向下地进行框架式思考,将一个复杂的任务不断地拆分到可以在单位时间内完成的粒度,并最终逐步完成。

    image.png

    内容回顾

    image.png

    函数(Function):可以在程序内被重复调用的一段代码
    函数名(Function Name):函数对外的名称
    函数体(Function Statement):函数内部执行的具体流程
    声明(Declare):告知程序的执行者有这么一个函数存在
    调用(Call):在程序运行的过程中,要求执行某个函数
    返回值(Return Value):函数调用完毕后的返回结果
    参数(Arguments):调用函数时所提供的数据

    课后作业

    image.png

    在Chrome中打开下面的地址:

    http://codepen.io/zhangshenjia/pen/MmyreE

    这里已经写好了两个函数 drawPointdrawBox,分别实现了画点和画长方形的功能,请先体验一下它们的威力。

    1、用「自顶向下」的方式来实现一个函数,画出自己喜欢的图案。你可能需要基于 drawPointdrawBox,声明更多的自定义函数,并组合使用它们;

    2、在每个函数的声明之前增加一行注释来说明函数的作用(可参考已有的两个函数),除此之外尽量少写或不写注释,在函数命名上多下功夫,让代码简明易懂。

    有的同学可能会疑惑,为什么函数声明可以放在函数调用的下面?程序不是按从上向下的顺序执行代码的吗?执行到函数调用那一行时,函数还没声明不是吗?这个是因为JS独有的提升(Hoisting)机制,感兴趣的同学可以课后搜索一下。

    相关文章

      网友评论

      • 1f5172cbcde1:作者写得通俗易懂,一方面有利于理解变量、函数等编程相关的重要概念,另一方面对编程思维具有很好的启发作用,自顶向下和自下而上的编程思维方式的结合能让我们写出更好的程序
      • LiMeng梦:
        这什么软件制作的🤓
        深加思考:@LiMeng梦 这不是什么软件,是一个网站,可以在上面编程。想学跟着教程从第一课开始就好了
        LiMeng梦: @张砷镓 我想学学 但不知道这是什么软件
        深加思考:@LiMeng梦 啥意思?

      本文标题:编程镓教・入门篇|004 一切为了变化

      本文链接:https://www.haomeiwen.com/subject/adxmtxtx.html