學習目標
- 理解函數、參數、聲明和調用的概念
- 掌握用變量和函數應對變化的方法
學習用時:60分鐘
通過前幾課的學習,我們已經掌握了用代碼來畫點、線和方塊的方法,現在大家都能在畫布上畫出自己喜歡的圖案了。
接下來,我們希望讓畫面能夠動起來。比如說,讓你畫出的小人從畫面的一邊,移動到另一邊去。
但是,怎樣才能把我們畫出的圖案,移動到另一個位置呢?
工匠的困境
在很久很久以前,有一位法老想要給自己建造一座雕像。于是他找來了一位手藝精湛的工匠,并選好了一座山頭。于是工匠拿來錘子和鑿子,叮叮當當地開工了……
就這樣日復一日、年復一年;不知不覺間,五十年過去了……
工匠終于完成了雕像,此時他已是個白發蒼蒼的老人。他把幾乎一生的心血都花在了這座雕像上,看著自己完成的作品,心中感到無比地自豪和驕傲。然而,法老在視察完他的工作成果后,冒出輕描淡寫的一句話來,讓他頓時萬念俱灰:
“挺好的,就是有點歪。往右邊挪上一米吧!”
上面這個故事來源于《The Art of Readable Code》一書。這個故事生動地闡釋了下面這條原則:
Change Is The Only Constant:唯一不變的就是變化
根據默菲定律,任何可能發生的事情,只要給足夠的時間,就一定會發生。所以我們在編程時,要盡可能地考慮到需求變化的可能性。否則需求一旦發生變化,就會不可避免地陷入到工匠的困境中去。
移動一下試試看
請在Chrome瀏覽器中打開下面的鏈接:
http://codepen.io/zhangshenjia/pen/ZKbWEz
網頁加載完成之后,應該能看到這樣的界面:
在這個程序中,我們畫好了一個十字,像不像射擊游戲里的準星?這是一個僅由五個點組成,簡單得不能再簡單的圖形了。
如果我們想把這個準星向右挪動一個像素,該怎么修改程序呢?
我們首先想到的是,應該把所有點的水平坐標都加一,也就是把所有畫點語句中的第一個數字加一。讓我們來試試看:
到現在為止,你感覺還OK吧?那是因為這個圖案只有五個點。那如果我讓你把上節課的作業里的圖案移動一下呢?
現在,我們正面臨著和工匠一樣的困境:由于在我們用代碼畫出的圖案里,每一個點的坐標都是固定的數字。因此如果想要移動圖案,哪怕只有一個像素的距離,都必須修改所有畫點語句中的坐標。如果我們的圖案由成百上千的點組成,那全部修改一遍簡直就是個噩夢!
還記得上節課我們學過DRY原則(Don’t Repeat Yourself)嗎?有沒有覺得這樣的修改很重復呢?要是能夠只修改一個地方,就自動同步到所有使用的地方就好了……
用變量來適應變化
不妨先想一想,在生活中遇到會變化的需求時,我們是怎么處理的呢?
首先,我們得設計一個可以變化的組件,并通過它來對變化進行適應。比如汽車座椅中可以調節角度的軸承、活動扳手中的可以調節卡口尺寸的蝸輪。
那在編程中,有沒有可以這樣變化的東西呢?當然有,那就是上節課我們就用過的變量。
首先刷新一下頁面,把代碼復原。然后在第一句畫點代碼上方添加一個空行,輸入下面的代碼:
var x = 0;
這樣我們就定義了一個變量 x用來保存水平的坐標,并給它賦值為 0。接下來,我們把所有畫點代碼中第一個數字前面都加上 x +:
注意:在符號 + 左右各有一個空格。它們雖然沒有實際意義,但能使我們代碼顯得更清晰、讀起來更省力。對此感興趣的同學可以課后自行搜索一下“代碼風格”。
現在我們可以修改 var x = 0 中的初始值看看,比方說改成 5 :
Oh Yeah,我們只修改了一行代碼,就可以讓整個圖案進行水平移動了!
接下來,我們可以用同樣的方法來實現圖案的垂直移動。定義一個變量 y ,并在所有畫點代碼中的第二個數字前面都加上 y +:
這樣一來,我們就可以通過修改變量 x 和 y 的值,把圖案移動到畫布的任何位置。再也不怕修改位置的需求了!
想要更多怎么辦?
要知道,需求的任何一部分都可能發生變化。除了圖案所處的位置之外,圖案的數量也會可能會變。現在畫布上只有一個十字,要是我們需要畫更多的十字怎么辦?
有同學說:這好辦,只要把畫十字的代碼再復制一份,然后修改 x 和 y 的值就可以了嘛!想畫多少十字,就復制多少次唄!
那如果我們要畫100個十字該怎么辦,把代碼復制100次嗎?我們畫十字的這段代碼只有短短幾行,多復制幾遍貌似還可以接受。但如果我們畫的是一個復雜的圖案,需要幾百行代碼來完成呢?
復制代碼確實可以簡單粗暴地臨時解決問題,但事后修改起來就很麻煩了。比如說,我們想把畫面上所有的十字都改成紅色,就需要在所有復制出來的代碼里都加上一行更換顏色的代碼。萬一改完發現還是黑色好看的話,還得把剛才添加的代碼一行行刪掉……
需求只發生了一個很小的變動,就要修改一大堆重復代碼,業內把這樣的情況稱之為“霰彈式修改”。由于我們是人不是機器,這樣做很累自不用說,在做大量修改時也難免會發生疏忽,比如漏加了一處代碼,又或者在刪除換顏色代碼時錯把畫點代碼刪掉……
又有同學說:那我們能不能用上節課學過的循環呢?把畫十字的代碼放在循環體里,然后每次循環改變 x 和 y 的值不就行了嗎?這樣畫十字的代碼就只會出現一次了呀!
問題是,循環只能用來處理連續性的重復工作,對非連續的重復無能為力。我們可以用循環來一次性畫出N個十字,但是不能中途停下來。然而,有很多重復性的工作都不是連續性的。
比如在某個網絡游戲中,獲取經驗值有很多方法(殺死敵人、完成任務、掛機……),經驗值滿了之后就需要升級,然后提升人物的一系列屬性,還有可能學得新的技能。那么在獲取經驗值之后,判斷是否需要升級的邏輯就需要多次重復運行,但獲取經驗值的邏輯卻散落在程序中多個不同的地方……這樣的需求,是無法通過循環來解決的。
那除了循環之外,還有什么辦法可以讓一段代碼能夠重復使用呢?答案就是:函數。
什么是函數?
想象一下,如果你家沒有醬油了,需要去超市買,但你自己又不想跑腿,正好孩子放學回家,就想讓他去打醬油。因為孩子之前沒干過這事,所以你得教他具體該怎么做。
「打醬油」的流程:
- 帶上足夠的錢,出門去超市
- 找到調味品區,拿一瓶醬油
- 在收銀臺結帳,收好找零
- 把醬油拿回家,交到你手上
這樣一來,以后再需要買醬油的時候,只要告訴孩子“打醬油去”就行了,而不用再把整個流程重新講一遍了。( 你說啥,都忘光了?那我再給你講一遍……)
「打醬油」就是一個函數,同時它也是這個函數的函數名。而打醬油的具體流程,就是這個函數的函數體。
函數(Function):可以在程序內被重復調用的一段代碼
函數名(Function Name):函數對外的名稱
函數體(Function Statement):函數內部執行的具體流程
教孩子怎么打醬油,就是在聲明這個函數。對孩子說“打醬油去”,就是在調用這個函數。而孩子最后交到你手上的醬油,就是函數的返回值。
聲明(Declare):告知程序的執行者有這么一個函數存在
調用(Call):在程序運行的過程中,要求執行某個函數
返回值(Return Value):函數調用完畢后的返回結果
顯然,如果你從來沒有教過孩子,就讓他去打醬油,他肯定會蒙圈的。一個函數必須得先經過聲明,才能進行調用。因為如果不進行聲明,程序的執行者根本不知道有這個函數存在,當然也就無法去執行了。
函數的返回值并不是必須提供的。有的函數要求提供一個明確的返回值,比如「買醬油」這個函數,就明確要求拿到一瓶醬油,即便因為各種原因沒有買到,那也得給出個說法;而有的函數則只看重運行的過程,比如「冥想」這個函數,并不需要最后拿出個什么成果來。
函數可以使一段邏輯在不同地方被重復調用。可以用函數來解決那些循環無法解決的非連續性重復問題。由于每個調用的地方只會出現函數名,而不會出現具體的邏輯。這樣在需求發生變化時,不管這個函數被調用了多少次,我們都只需要修改函數體里的邏輯就行。
當然,更改函數名的時候,所有調用這個函數的地方還是不可避免地要同步修改。所以起一個好名字,非常非常地重要!關于怎么給函數起一個好名字來盡量避免修改,同學們可以在課后搜索一下。
需求有變化怎么辦?
不過,這樣的函數雖然解決了在不同地方重復調用的問題,但每次執行的邏輯都是固定不變的。比如「打醬油」函數,在不出意外(超市關門、沒貨……)的情況下,每次都會得到一瓶醬油。
然而我們知道:需求是不可能一成不變的。今天我們需要一瓶醬油,明天可能要十個饅頭,后天則可能要一打可樂……要怎樣才讓函數可以應對這些變化呢?
首先想到的是,我們能不能給購買每種商品的流程都聲明一個函數,并在需要的時候調用它們呢?就像這樣:「買饅頭」、「買可樂」……
這樣雖然貌似解決了問題,卻產生了一大堆邏輯雷同的函數。如果購買流程中的任一環節的邏輯變更,就需要同步修改所有的函數。何況即便是相同的商品,每次買的數量也可能不同,難道還要聲明「打醬油」、 「打2瓶醬油」、「打3瓶醬油」……這樣一系列的函數嗎?
我們可以把函數調整修改一下,來應對可能發生的變化:
「買東西」的流程:(調用時需要說明要買的「東西」及「數量」)
- 帶上足夠的錢,出門去超市
- 找到貨架,拿「數量」的「東西」
- 在收銀臺結帳,收好找零
- 拿回家,交到你手上
「買東西」也是一個函數。但和「打醬油」有所不同的是,在調用「買東西」時需要指明「數量」和「東西」,它們都是函數的參數。
參數(Arguments):調用函數時所提供的數據
在函數體內,可以用與參數同名的變量,來訪問傳入的數據。假設我們在調用「買東西」函數時傳入的「數量」是 3、「東西」是** 辣條,那么函數的第二步實際執行的流程是這樣的:“找到貨架,拿三包辣條”。
參數不一定都是必須提供的,提供了默認值的參數可以省略。有的參數是必須提供的,比如要買的「東西」,如果不說清楚,就根本不知道要買啥;而有的參數是可以省略的,比如要買的「數量」,在沒有提供的情況下,那就默認只買一份。
通過更換傳入的參數,我們不需要對函數內部邏輯進行改動,就能控制邏輯的變化。比如,我們可以發起這樣調用:「買兩包鹽」、「買五瓶啤酒」……
用函數來畫十字
接下來,我們要聲明一個「畫十字」的函數,在調用時把坐標當成參數傳進去,這樣就可以在畫布的任意坐標位置畫出十字了。如果想畫多個十字的話,多調用幾次就行了。
首先,我們把剛才添加那兩行 var 語句刪掉,替換成下面的代碼:
function drawCross(x, y) {
然后,在最后一句畫點語句后面增加一個空行,輸入一個符號 } :
這樣我們就聲明了一個函數,名為 drawCross (draw是“畫”,cross是“十字”,聯合起來就是“畫十字”的意思)。這個函數有兩個參數:x 和 y,指定了十字在水平和垂直兩個方向上的位置坐標。在函數體內會自動聲明兩個和參數同名的對應變量 x 和 y,它們只能在函數體內部使用。
需要注意的是,函數名里是不允許有空格的。像drawCross這樣把多個單詞直接連起來,并讓首字母大寫的方法叫做駝峰命名法。也有draw_cross這樣的命名法,不過還是駝峰命名法比較常用。雖然我們也可以直接用中文「畫十字」來當函數名,但我強烈建議不要這么做。
現在的函數體沒有縮進,看起來結構不清晰。讓我們選中函數體里所有的畫點代碼,按下 TAB 鍵增加縮進,這樣代碼看起來就舒服多了:
但是現在畫布是空的,我們的十字到哪里去了呢?原來我們只聲明了函數,并沒有調用它,所以函數體里的邏輯并不會被執行。接下來,就讓我們添加一個函數調用吧。
在程序的最底部添加一個空行,輸入下面的代碼:
drawCross(0, 0);
十字出現了!在程序執行到我們剛剛添加的這一句時,就會跳轉到 drawCross 函數內部去執行,執行完后再回來繼續往下走。就像我們讀外文書時,發現一個不認識的單詞就停下來去查字典,查完回來接著讀一樣。
現在我們可以通過繼續調用這個函數,在畫面的不同位置畫出更多的十字了。試著在程序底部添加這幾行代碼:
drawCross(0, 3);
drawCross(3, 0);
drawCross(3, 3);
我們通過四個十字組合出了一個符號 #,顯然這是個更復雜的圖案。那如果我們想要把這個圖案移動到其他位置,該怎么做呢?
在函數里調用函數
這個圖案是通過對 drawCross 函數進行四次調用畫出來的。那么我們直接修改這四行代碼里的調用參數行不行呢?
當然可以!畢竟要修改的只有四行代碼,但要是我們的圖案是由100個十字組成的呢?那要修改多少行代碼?
我們再一次遭遇了工匠的困境,那我們是不是還可以用變量來隔離變化呢?
當然可以!不過,如果我們需要畫多個符號 # 呢?還是得復制一堆代碼……這樣一下霰彈式修改還是無法避免。那么,我們能不能像聲明 drawCross 函數來畫十字一樣,再聲明一個 drawHash 函數來畫符號 # 呢?
當然可以!要知道函數體是一段代碼,而函數的調用也是一行代碼。所以我們可以在函數體里再調用別的函數,就可以我們在循環體內使用循環一樣。
那能不能在函數里聲明函數呢?當然也可以,但這樣聲明出來新函數只能在舊函數里使用。關于函數作用域的內容,感興趣的同學可以課后搜索一下。
我們在四句對 drawCross 函數的調用前面加上一句代碼:
function drawHash(x, y) {
然后在程序最后面加上一個 },這樣就定義了一個 drawHash 函數。不要忘記給函數體縮進噢:
現在圖案消失了,因為我們還沒有添加調用呢。隨便給個坐標,調用一下看看吧:
為什么圖案還是畫在左上角,沒有畫在我們指定的坐標呢?因為在 drawHash 函數里對 drawCross 函數進行調用時,并沒有把我們指定的坐標傳遞過去。雖然這兩個函數里都有 x 和 y 這兩個參數,各自函數體里都有同名的兩個變量,但是它們互相是沒有關系的。
我們在調用 drawHash 函數時使用的參數是 10, 10,所以在 drawHash 函數的變量 x 和 y 的值都是 10。但在調用 drawCross 函數時的參數就不一樣了,比如第二次調用時的參數是 0, 3 ,那在 drawCross 函數內的變量 x 和 y 的值就分別為 0, 3。
每個函數的參數變量都只能在函數內部使用,外部是無法訪問的,只能通過調用時傳入參數來對其進行賦值。關于變量作用域的內容,感興趣的同學可以課后搜索一下。
如果想讓我們給 drawHash 函數傳遞的參數影響 drawCross 函數,就得在調用 drawCross 函數時改變參數,也就是把 x 和 y 加進去:
大功告成!現在我們有了 drawCross 和 drawHash 兩個函數,可以用一行代碼畫出十字,也可以用一行代碼畫出#。當然,你總是可以在現有函數的基礎上,構造出更復雜的函數……最終,你就可以僅僅用一行代碼,就畫出一個很復雜的圖案來。
能不能在drawHash函數里再調用drawHash函數自己呢?理論上是可以的,這種做法叫做遞歸(Recursion)。遞歸是一種比較有難度的編程技巧,需要精心設計控制流程,避免發生無限調用。現在我們還用不著它,感興趣的同學可以課后搜索一下。
「自底而上」vs「自頂向下」
到目前為止,我們做了下面這些事:
- 先想辦法畫一個點
- 用同樣的方法畫一堆點來組成圖案
- 把這一堆畫點的代碼聲明為一個函數
- 通過調用函數和畫點,畫出更復雜的圖案
- 把這一堆畫圖的代碼再聲明為一個函數
- ……
這種“先看看能做點什么,然后再看看能做點別的什么”的思考和行動模式,我們稱之為自底而上(Bottom-up)。每走一步就能看到對應變化,一步一個腳印,走得很踏實。
然而,在解決實際問題時,僅僅靠「自底而上」是不行的。因為能做的事情實在太多了,但可能絕大多數都和我們現在想做的事情沒什么關系。只著眼于當下能做什么,而不思考我們想做什么,就可能會迷失方向,一直在原地踏步;甚至于南轅北轍,離目標越來越遠……
另外一種思路是,先確定好要達成的目標,制定一個整體規劃,再分解成具體的行動計劃并執行。這正是我們之前學過的萬金油思路——「拆分」。這種“先想清楚要做什么,然后再看看怎么去做”的模式,我們稱之為自頂向下(Top-down)。
當然,僅僅靠「自頂向下」也是不行的。我們想做的很多事情,現在是做不到的。總是紙上談兵,想太多不切實際的東西,只會浪費時間。結合使用「自底而上」和「自頂向下」這兩種模式,理論聯合實際才是王道。
在用「自頂向下」的思路來分解目標,作出初步的規劃設想的同時;也需要根據目前具備的資源和能力,用「自底而上」的思路來檢驗設想的可行性。只有當我們在這兩種思路之間找到了結合點,才能將設想進一步細化成計劃進而執行。
當設想不可行時,是放棄目標或降低標準,還是去獲取現在不具備的資源和能力呢?這得看目標的優先級有多高、是否是核心需求,在達成目標的期望價值和獲取資源能力的代價中反復做權衡……這已經遠遠超出了本教程的范疇,容我不再細表。
寫一個畫笑臉的函數
假設我們現在的目標是:在畫布上畫出一個笑臉。由于這是一個獨立且完整的任務,所以我們可以聲明一個 drawFace 函數來完成它:
先用「自底而上」的思路分析:我們已經具備了在畫布的任何位置用任何顏色畫出像素的能力,而畫布上的笑臉肯定是由一堆像素構成的,所以這個目標必然是可達成的。 所以,盡管此時我們的函數里一行代碼都沒有,但我們完全可以相信,這個函數的功能是可以實現的。
所以,這個函數也沒必要現在就寫,可以先去做更重要或更緊迫的事;依賴這個函數的工作(比如寫一個畫小人的函數drawPerson)現在就可以同步開展,而不必非得等到這個函數完成后再進行。只要在必須在畫布上看到笑臉時,把它完成就好。
隨后我們可以用「自頂向下」的思路來分解這個函數。一般來說,一個笑臉由眼睛、嘴、鼻子、眉毛等部分組成。其中眼睛和嘴巴是必需的,所以我們可以再添加兩個函數 drawEye 和 drawMouth,其余非必須的部分可以先寫成注釋,以后有時間再添加:
基于同樣的原因,我們斷定 drawEye 和 drawMouth 函數是可以實現的。所以這時盡管這兩個函數現在還是空的,我們也可以宣告 drawFace 函數完成了,因為它已經完成了自己的使命:羅列所有必要的組成部分,并整理好它們之間的關系。
當然,眼睛和嘴巴之間的距離可能還需要不斷調整,但這無關緊要。至于眼睛和嘴巴到底畫了沒有,畫得怎么樣,我們在驗收 drawFace 函數時并不關心。因為那是 drawEye 和 drawMouth 函數要完成的任務。
接下來的任務就是完成 drawEye 和 drawMouth 函數了。我們可以找時間分別來完成它們,也可以分配給別人來干。為簡單起見,我們只畫一個點來當眼睛,畫四個點來當嘴巴:
笑臉完成了!
函數的價值和意義
“工欲善其事,必先利其器” —— 《論語?衛靈公》
在「我的世界」這款游戲里,玩家一開始手里空空如也,什么都沒有。只能赤手空拳去擼樹,然后拿到木頭做成斧頭等工具,再去高效率地采集更多的資源。
寫函數的過程,就是打造工具的過程。雖然寫函數的過程比較吃力,但寫出來的函數可以大大地方便我們之后的工作。雖然函數內部的代碼邏輯會比直接堆代碼要復雜一點點,但在調用函數時的代碼卻簡潔了許多。這和解魔方一樣,用初級方法會比較簡單易懂,但步數要多一些;而用高級方法會比較復雜,但步數會少一些。
當一段邏輯需要多次使用時,簡單地復制粘貼一遍代碼貌似是第一時間就能想到的方法。要是需求稍有變化,那就做一點適當的改動。結果可能就會產生一大堆雷同或者大同小異的代碼:
我們把一段需要多次使用的邏輯封裝成函數后再調用,顯著地減少了重復代碼。從而避免了直接復制代碼可能導致的“霰彈式修改”,可以更好的適應需求的不斷變化。關于這一點,我們已經通過上面的實踐得到了深刻的體會。
函數隱藏了不必要的實現細節,同時降低了在修改代碼的過程中出錯的可能性。以機械表為例,如果不用表盤遮住內部,就會給使用者帶來不必要的心理壓力,也很容易損壞其內部精密的結構。
我們還通過函數名傳達了邏輯意圖 ,使本來需要注釋的代碼意圖變得更直觀,更容易理解。用術語來說,就是提升了代碼的可讀性。很明顯,面對一堆畫點語句,你不看注釋或者不手動運行測試一下,根本不可能明白它畫的是什么。而對一個名為drawCross的函數進行調用,則明明白白地告訴了讀者這行代碼的作用:我要畫一個十字。
最重要的一點是,我們通過函數隔離出了一個抽象層次。這使我們可以將當前的思維局限在某個環節之中,將全部的注意力用于在當前層次上進行完整自洽的思考上。于是我們得以自頂向下地進行框架式思考,將一個復雜的任務不斷地拆分到可以在單位時間內完成的粒度,并最終逐步完成。
內容回顧
函數(Function):可以在程序內被重復調用的一段代碼
函數名(Function Name):函數對外的名稱
函數體(Function Statement):函數內部執行的具體流程
聲明(Declare):告知程序的執行者有這么一個函數存在
調用(Call):在程序運行的過程中,要求執行某個函數
返回值(Return Value):函數調用完畢后的返回結果
參數(Arguments):調用函數時所提供的數據
課后作業
在Chrome中打開下面的地址:
http://codepen.io/zhangshenjia/pen/MmyreE
這里已經寫好了兩個函數 drawPoint 和 drawBox,分別實現了畫點和畫長方形的功能,請先體驗一下它們的威力。
1、用「自頂向下」的方式來實現一個函數,畫出自己喜歡的圖案。你可能需要基于 drawPoint 和 drawBox,聲明更多的自定義函數,并組合使用它們;
2、在每個函數的聲明之前增加一行注釋來說明函數的作用(可參考已有的兩個函數),除此之外盡量少寫或不寫注釋,在函數命名上多下功夫,讓代碼簡明易懂。
有的同學可能會疑惑,為什么函數聲明可以放在函數調用的下面?程序不是按從上向下的順序執行代碼的嗎?執行到函數調用那一行時,函數還沒聲明不是嗎?這個是因為JS獨有的提升(Hoisting)機制,感興趣的同學可以課后搜索一下。