学习目标
- 理解函数、参数、声明和调用的概念
- 掌握用变量和函数应对变化的方法
学习用时:60分钟
通过前几课的学习,我们已经掌握了用代码来画点、线和方块的方法,现在大家都能在画布上画出自己喜欢的图案了。
接下来,我们希望让画面能够动起来。比如说,让你画出的小人从画面的一边,移动到另一边去。
但是,怎样才能把我们画出的图案,移动到另一个位置呢?
工匠的困境
在很久很久以前,有一位法老想要给自己建造一座雕像。于是他找来了一位手艺精湛的工匠,并选好了一座山头。于是工匠拿来锤子和凿子,叮叮当当地开工了……
image.png就这样日复一日、年复一年;不知不觉间,五十年过去了……
image.png工匠终于完成了雕像,此时他已是个白发苍苍的老人。他把几乎一生的心血都花在了这座雕像上,看着自己完成的作品,心中感到无比地自豪和骄傲。然而,法老在视察完他的工作成果后,冒出轻描淡写的一句话来,让他顿时万念俱灰:
“挺好的,就是有点歪。往右边挪上一米吧!”
上面这个故事来源于《The Art of Readable Code》一书。这个故事生动地阐释了下面这条原则:
image.pngChange Is The Only Constant:唯一不变的就是变化
根据默菲定律,任何可能发生的事情,只要给足够的时间,就一定会发生。所以我们在编程时,要尽可能地考虑到需求变化的可能性。否则需求一旦发生变化,就会不可避免地陷入到工匠的困境中去。
移动一下试试看
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.pngOh Yeah,我们只修改了一行代码,就可以让整个图案进行水平移动了!
接下来,我们可以用同样的方法来实现图案的垂直移动。定义一个变量 y ,并在所有画点代码中的第二个数字前面都加上 y +:
image.png这样一来,我们就可以通过修改变量 x 和 y 的值,把图案移动到画布的任何位置。再也不怕修改位置的需求了!
想要更多怎么办?
image.png要知道,需求的任何一部分都可能发生变化。除了图案所处的位置之外,图案的数量也会可能会变。现在画布上只有一个十字,要是我们需要画更多的十字怎么办?
有同学说:这好办,只要把画十字的代码再复制一份,然后修改 x 和 y 的值就可以了嘛!想画多少十字,就复制多少次呗!
image.png那如果我们要画100个十字该怎么办,把代码复制100次吗?我们画十字的这段代码只有短短几行,多复制几遍貌似还可以接受。但如果我们画的是一个复杂的图案,需要几百行代码来完成呢?
复制代码确实可以简单粗暴地临时解决问题,但事后修改起来就很麻烦了。比如说,我们想把画面上所有的十字都改成红色,就需要在所有复制出来的代码里都加上一行更换颜色的代码。万一改完发现还是黑色好看的话,还得把刚才添加的代码一行行删掉……
需求只发生了一个很小的变动,就要修改一大堆重复代码,业内把这样的情况称之为“霰弹式修改”。由于我们是人不是机器,这样做很累自不用说,在做大量修改时也难免会发生疏忽,比如漏加了一处代码,又或者在删除换颜色代码时错把画点代码删掉……
啥?我都写了这么多WORK了,你告诉我你其实要的是WORD?又有同学说:那我们能不能用上节课学过的循环呢?把画十字的代码放在循环体里,然后每次循环改变 x 和 y 的值不就行了吗?这样画十字的代码就只会出现一次了呀!
问题是,循环只能用来处理连续性的重复工作,对非连续的重复无能为力。我们可以用循环来一次性画出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是“十字”,联合起来就是“画十字”的意思)。这个函数有两个参数:x 和 y,指定了十字在水平和垂直两个方向上的位置坐标。在函数体内会自动声明两个和参数同名的对应变量 x 和 y,它们只能在函数体内部使用。
需要注意的是,函数名里是不允许有空格的。像drawCross这样把多个单词直接连起来,并让首字母大写的方法叫做驼峰命名法。也有draw_cross这样的命名法,不过还是驼峰命名法比较常用。虽然我们也可以直接用中文「画十字」来当函数名,但我强烈建议不要这么做。
现在的函数体没有缩进,看起来结构不清晰。让我们选中函数体里所有的画点代码,按下 TAB 键增加缩进,这样代码看起来就舒服多了:
image.png但是现在画布是空的,我们的十字到哪里去了呢?原来我们只声明了函数,并没有调用它,所以函数体里的逻辑并不会被执行。接下来,就让我们添加一个函数调用吧。
image.png在程序的最底部添加一个空行,输入下面的代码:
drawCross(0, 0);
image.png十字出现了!在程序执行到我们刚刚添加的这一句时,就会跳转到 drawCross 函数内部去执行,执行完后再回来继续往下走。就像我们读外文书时,发现一个不认识的单词就停下来去查字典,查完回来接着读一样。
现在我们可以通过继续调用这个函数,在画面的不同位置画出更多的十字了。试着在程序底部添加这几行代码:
drawCross(0, 3);
drawCross(3, 0);
drawCross(3, 3);
我们通过四个十字组合出了一个符号 #,显然这是个更复杂的图案。那如果我们想要把这个图案移动到其他位置,该怎么做呢?
在函数里调用函数
这个图案是通过对 drawCross 函数进行四次调用画出来的。那么我们直接修改这四行代码里的调用参数行不行呢?
image.png当然可以!毕竟要修改的只有四行代码,但要是我们的图案是由100个十字组成的呢?那要修改多少行代码?
我们再一次遭遇了工匠的困境,那我们是不是还可以用变量来隔离变化呢?
image.png当然可以!不过,如果我们需要画多个符号 # 呢?还是得复制一堆代码……这样一下霰弹式修改还是无法避免。那么,我们能不能像声明 drawCross 函数来画十字一样,再声明一个 drawHash 函数来画符号 # 呢?
image.png当然可以!要知道函数体是一段代码,而函数的调用也是一行代码。所以我们可以在函数体里再调用别的函数,就可以我们在循环体内使用循环一样。
image.png那能不能在函数里声明函数呢?当然也可以,但这样声明出来新函数只能在旧函数里使用。关于函数作用域的内容,感兴趣的同学可以课后搜索一下。
我们在四句对 drawCross 函数的调用前面加上一句代码:
function drawHash(x, y) {
然后在程序最后面加上一个 },这样就定义了一个 drawHash 函数。不要忘记给函数体缩进噢:
image.png现在图案消失了,因为我们还没有添加调用呢。随便给个坐标,调用一下看看吧:
image.png为什么图案还是画在左上角,没有画在我们指定的坐标呢?因为在 drawHash 函数里对 drawCross 函数进行调用时,并没有把我们指定的坐标传递过去。虽然这两个函数里都有 x 和 y 这两个参数,各自函数体里都有同名的两个变量,但是它们互相是没有关系的。
image.png我们在调用 drawHash 函数时使用的参数是 10, 10,所以在 drawHash 函数的变量 x 和 y 的值都是 10。但在调用 drawCross 函数时的参数就不一样了,比如第二次调用时的参数是 0, 3 ,那在 drawCross 函数内的变量 x 和 y 的值就分别为 0, 3。
每个函数的参数变量都只能在函数内部使用,外部是无法访问的,只能通过调用时传入参数来对其进行赋值。关于变量作用域的内容,感兴趣的同学可以课后搜索一下。
如果想让我们给 drawHash 函数传递的参数影响 drawCross 函数,就得在调用 drawCross 函数时改变参数,也就是把 x 和 y 加进去:
image.png大功告成!现在我们有了 drawCross 和 drawHash 两个函数,可以用一行代码画出十字,也可以用一行代码画出#。当然,你总是可以在现有函数的基础上,构造出更复杂的函数……最终,你就可以仅仅用一行代码,就画出一个很复杂的图案来。
能不能在drawHash函数里再调用drawHash函数自己呢?理论上是可以的,这种做法叫做递归(Recursion)。递归是一种比较有难度的编程技巧,需要精心设计控制流程,避免发生无限调用。现在我们还用不着它,感兴趣的同学可以课后搜索一下。
「自底而上」vs「自顶向下」
到目前为止,我们做了下面这些事:
- 先想办法画一个点
- 用同样的方法画一堆点来组成图案
- 把这一堆画点的代码声明为一个函数
- 通过调用函数和画点,画出更复杂的图案
- 把这一堆画图的代码再声明为一个函数
- ……
这种“先看看能做点什么,然后再看看能做点别的什么”的思考和行动模式,我们称之为自底而上(Bottom-up)。每走一步就能看到对应变化,一步一个脚印,走得很踏实。
image.png然而,在解决实际问题时,仅仅靠「自底而上」是不行的。因为能做的事情实在太多了,但可能绝大多数都和我们现在想做的事情没什么关系。只着眼于当下能做什么,而不思考我们想做什么,就可能会迷失方向,一直在原地踏步;甚至于南辕北辙,离目标越来越远……
另外一种思路是,先确定好要达成的目标,制定一个整体规划,再分解成具体的行动计划并执行。这正是我们之前学过的万金油思路——「拆分」。这种“先想清楚要做什么,然后再看看怎么去做”的模式,我们称之为自顶向下(Top-down)。
image.png当然,仅仅靠「自顶向下」也是不行的。我们想做的很多事情,现在是做不到的。总是纸上谈兵,想太多不切实际的东西,只会浪费时间。结合使用「自底而上」和「自顶向下」这两种模式,理论联合实际才是王道。
在用「自顶向下」的思路来分解目标,作出初步的规划设想的同时;也需要根据目前具备的资源和能力,用「自底而上」的思路来检验设想的可行性。只有当我们在这两种思路之间找到了结合点,才能将设想进一步细化成计划进而执行。
当设想不可行时,是放弃目标或降低标准,还是去获取现在不具备的资源和能力呢?这得看目标的优先级有多高、是否是核心需求,在达成目标的期望价值和获取资源能力的代价中反复做权衡……这已经远远超出了本教程的范畴,容我不再细表。
写一个画笑脸的函数
假设我们现在的目标是:在画布上画出一个笑脸。由于这是一个独立且完整的任务,所以我们可以声明一个 drawFace 函数来完成它:
image.png先用「自底而上」的思路分析:我们已经具备了在画布的任何位置用任何颜色画出像素的能力,而画布上的笑脸肯定是由一堆像素构成的,所以这个目标必然是可达成的。 所以,尽管此时我们的函数里一行代码都没有,但我们完全可以相信,这个函数的功能是可以实现的。
所以,这个函数也没必要现在就写,可以先去做更重要或更紧迫的事;依赖这个函数的工作(比如写一个画小人的函数drawPerson)现在就可以同步开展,而不必非得等到这个函数完成后再进行。只要在必须在画布上看到笑脸时,把它完成就好。
随后我们可以用「自顶向下」的思路来分解这个函数。一般来说,一个笑脸由眼睛、嘴、鼻子、眉毛等部分组成。其中眼睛和嘴巴是必需的,所以我们可以再添加两个函数 drawEye 和 drawMouth,其余非必须的部分可以先写成注释,以后有时间再添加:
image.png基于同样的原因,我们断定 drawEye 和 drawMouth 函数是可以实现的。所以这时尽管这两个函数现在还是空的,我们也可以宣告 drawFace 函数完成了,因为它已经完成了自己的使命:罗列所有必要的组成部分,并整理好它们之间的关系。
当然,眼睛和嘴巴之间的距离可能还需要不断调整,但这无关紧要。至于眼睛和嘴巴到底画了没有,画得怎么样,我们在验收 drawFace 函数时并不关心。因为那是 drawEye 和 drawMouth 函数要完成的任务。
接下来的任务就是完成 drawEye 和 drawMouth 函数了。我们可以找时间分别来完成它们,也可以分配给别人来干。为简单起见,我们只画一个点来当眼睛,画四个点来当嘴巴:
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
这里已经写好了两个函数 drawPoint 和 drawBox,分别实现了画点和画长方形的功能,请先体验一下它们的威力。
1、用「自顶向下」的方式来实现一个函数,画出自己喜欢的图案。你可能需要基于 drawPoint 和 drawBox,声明更多的自定义函数,并组合使用它们;
2、在每个函数的声明之前增加一行注释来说明函数的作用(可参考已有的两个函数),除此之外尽量少写或不写注释,在函数命名上多下功夫,让代码简明易懂。
有的同学可能会疑惑,为什么函数声明可以放在函数调用的下面?程序不是按从上向下的顺序执行代码的吗?执行到函数调用那一行时,函数还没声明不是吗?这个是因为JS独有的提升(Hoisting)机制,感兴趣的同学可以课后搜索一下。
网友评论
这什么软件制作的🤓