天天看點

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

本節書摘來自華章計算機《從問題到程式:用python學程式設計和計算》一書中的第3章,第3.4節,作者 裘宗燕,更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。

在最簡單的程式中,可能隻用到表達式、語句和幾種控制結構。但是,僅限于這些基本機制,很難寫出很長的解決複雜問題的程式。随着遇到的問題更複雜,我們必須組織好程式的結構,在語句層面之上的基本結構就是函數。一個函數包裝起一段代碼并給予命名,引進參數将其通用化。定義好的函數可以通過調用表達式使用,非常友善。學習程式設計的重要一步就是學習定義函數:了解為什麼需要定義函數,學會識别程式設計中定義函數的需求,掌握正确定義函數的技術。本小節和下一章将集中讨論這個問題。

實際中需要用程式處理的問題都很複雜,學習程式設計,也必須學習處理複雜問題的思想和技術。要處理的問題越複雜,解決它的程式也會越長。越長的程式将更難開發、更難閱讀和了解,程式設計式的人也更難把握,這些情況又影響到開發者對程式功能的把握和檢查,以及後續的維護。在修改一個程式時,必須清楚地了解所做改動對整個程式的影響,修改不當就可能破壞程式的内在一緻性。顯然,程式變得更大了之後,了解要做的修改對程式行為的影響也更困難。此外,随着程式變大,其中更容易出現在不同地方需要做相同或類似工作的情況,分别寫出代碼既會使程式變長,也增加了不同部分之間的互相聯系。

在科學與工程領域,解決複雜問題的基本方法就是将其分解為相對簡單的子問題,分别處理,然後用子問題的解去構造整個問題的解。為了支援複雜計算過程的描述,程式語言需要提供分解手段。随着人們對程式設計實踐的總結,一些抽象機制被引進各種程式設計語言。這些機制非常重要,不了解它們的功能和使用技術,就不可能把握和處理複雜的計算過程,完成複雜的程式或軟體系統。python中最基本的抽象機制就是函數。

從理論上說,程式設計語言裡的函數定義功能并沒有帶來新的計算描述能力。沒有這種功能的程式設計語言也可以描述所有可能的計算。但從程式設計實踐的角度看,選擇适當的計算描述代碼,将其定義為函數,卻是一項極其重要的工作。沒有這樣一套結構,根本不可能寫出解決複雜問題的好程式。

在計算機發展的早期,确實出現過不提供函數定義功能的程式設計語言。但人們在使用中發現,用這樣的語言描述比較大的程式,例如幾千行代碼的程式,是非常困難的。是以,今天所有流行的語言都提供了函數定義或其他類似功能。下面先讨論函數定義的意義。

一個函數是一段代碼的包裝和抽象,它應該實作某種有用的計算。定義好的函數可以在程式裡調用,要求執行其中包裝的代碼。但是,我們顯然可以以适當方式把這段代碼直接寫在調用該函數的地方,為什麼要把它定義為一個函數呢?

例如,我們可以定義一個函數cube實作立方的計算,在需要時寫調用:

也可以不寫函數定義,在這些地方直接寫:

采用前一寫法有什麼好處?這是一個功利性的問題。

實際上,定義函數的效益是多方面的,下面說明一些情況:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

函數和函數分解還有很多可能的作用。此外,除了函數可以作為有用的程式子產品外,python還提供了另外一些可以作為程式子產品的結構,有關情況将在後面介紹。

上述讨論中提出了函數的一些重要作用,其中許多還派生出一些對任何軟體開發都非常重要的原則或技術。例如,作用2中讨論的問題被人們總結為一條重要的程式設計原則(唯一定義原則):程式中的任何重要功能都應該隻有一個定義。作用3倡導的程式構造方式被稱為自下而上的程式開發,從底層出發,一步步向上構造有用的功能塊,支援複雜功能的實作;作用4提出的程式構造方式被稱為自頂向下的程式開發,又稱為逐漸求精。這些原則和做法都非常重要,在後面的章節和後續課程裡,可以看到許多相關的例子和讨論。

一個python程式由一系列語句構成,執行這個程式就是一個個地執行其中的語句。一些語句直接完成計算,或者做指派,或者産生輸入輸出,控制語句的執行将指揮其中的語句塊完成各種操作。python程式中的函數定義也是語句,其執行并不完成任何有價值的實際計算,而是完成一個函數的定義工作。

每個函數的體中封裝了一段代碼,函數頭部描述外部與這個函數的聯系:函數名是什麼,外部可以通過哪些參數給函數送進資訊。參數表裡列出函數的形式參數,簡稱形參,用參數名表示。函數有傳回值,但傳回值的情況在函數頭部并不描述,由函數體裡的return語句确定。如果沒提供傳回值,函數自動傳回none值。

到目前為止,我們已經看到的python程式都是由一系列普通的語句(包括控制結構)和一些函數定義組成,後面還會看到其他結構。在程式執行時,普通語句直接産生效果,而函數定義的執行隻是做好一個函數對象,并給它命名,并不執行函數體裡的語句。隻有被明确調用時函數才進入執行狀态。易見,要想在程式的執行中起作用,一個函數或者需要被程式裡的普通語句直接調用,或者需要被另一個被調用執行的函數調用。沒被調用的函數不會在程式的執行中起任何作用。

前面章節裡已經給出過一些函數定義執行個體。下面主要關注程式的函數分解,研究與函數的定義和使用有關的各種問題,其中将特别關注函數頭部的設計問題。

函數定義是一種比較複雜的程式結構。要定義好一個函數,必須按語言規定的形式寫出函數的各個結構成分,包括函數頭部的函數名和參數表,以及相應的函數體。不滿足有關文法就不是一個函數定義。顯然,定義函數,最重要的問題還是函數功能的選擇和程式功能的分解。下面将讨論與函數定義有關的思考過程和一些重要技術細節。

顯然,函數定義不應是随心所欲的産物,應該是深入分析和了解問題之後的設計。定義函數時需要做的工作很多,包括:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

最後一個問題雖然很重要,但卻是一般程式設計都需要考慮和處理的問題,不是僅與定義函數有關的特殊問題,是以不是本節主題。下面主要關注前兩個問題。

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數
《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

從形式上看,一個函數就是包裝起來并予以命名的一段代碼(還有參數化),是程式中具有邏輯獨立性的動作性實體。函數需要定義,又能作為整體在程式中調用,完成其代碼描述的工作。函數封裝把函數内部與其外部分開,形成兩個互相隔離的世界,站在這兩個不同的世界看問題,就形成了對于函數的内部觀點和外部觀點。一邊是站在在函數之外,從函數使用者的角度看函數;另一邊是站在函數内部,從定義者的角度看。看到兩者之間的差異和聯系,對認識函數,思考與函數相關的問題,都是非常重要的。

圖3.5列出了從這兩種不同角度看函數時需要考慮的一些重要問題。函數頭部規定了函數内部和外部之間的交流方式和通道,定義了函數内部和外部都需要遵守的共同規範。

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

從一個函數的外部看,該函數實作了某種有用的功能。隻要知道函數名和參數的情況就可以使用它,利用其功能。在調用函數時提供數目和類型适當的實參,正确接受傳回值,就能得到預期的計算結果或者效果。

使用函數時,我們不應該關心函數功能的具體實作。這種超脫很重要,不掌握這種思想方法,就無法擺脫瑣碎細節的幹擾,不能處理複雜問題。初學者常犯的一個毛病是事事都想弄清楚。這種考慮不但常常不必要,有時甚至不可能。例如,對python内置函數,我們不知道它們的實作方法,這并不妨礙在程式中正确使用它們。

内部觀點是函數實作者的考慮,所關心的問題自然不同。這時的重要問題包括函數調用時外部将提供哪些資料(由參數表規定),各為什麼類型(對python程式,我們無法在描述上對參數提出類型要求,但在心裡應該有明确的認識);如何從這些參數出發完成所需計算,得到所需結果(算法問題);函數應在什麼情況下結束?如何産生傳回值?在考慮函數實作時,不應關心程式的哪些地方将調用它,提供的具體實參值是什麼等。

函數頭部的重要性就在于它描述了函數内部和外部之間的聯系,是兩方交換資訊的接口。如前面執行個體所示,在定義函數之前應首先有一個全面考慮,據此定義好函數的頭部,規定好一套規範(特别是函數的參數)。此後開發者的角色就分裂了,應該根據是定義函數還是使用函數去觀察和思考問題。實際上,一旦清晰地确定了函數的功能,描述好函數頭部之後,函數的定義和使用完全可以由兩個人或兩批人分别做。隻要他們遵循共同規範,對函數功能有共同了解,就不會有問題。在大型軟體的開發中,經常需要做這種分解。

注意,這兩句話很重要:“遵循共同規範”,“對函數功能有共同了解”,人們經常在這裡出現偏差。我們寫程式時也必須注意,務必保證對同一函數的兩種觀點之間的一緻性。

下面分别進一步研究從這兩個角度考慮函數時遇到的問題。

确定了需要定義的函數的功能、參數和傳回值的安排,并選擇了适當的函數名之後,下面的工作就是寫出函數體的代碼,完成函數的定義。

如果所需函數的功能比較簡單,很容易基于python基本操作和内置函數描述好,就可以直接完成函數的定義。如果函數要做的工作比較複雜,可以考慮進一步對它做功能分解,把其中有意義的重要部分抽象為另外的一個(或幾個)函數,通過函數調用完成操作,而後再實作那個(或那些)函數。這樣做就産生了另一層功能分解。這種分解可以一層層做下去,直到所需功能可以比較容易地直接實作為止。

在python語言裡定義函數,有一個問題需要注意:定義的頭部無法描述對參數的要求,而實際上,多數函數對其參數都有某些特殊要求。例如:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

雖然python中的文字量都有确定的類型,但是一個變量可以以任何類型的對象為值。是以,一般而言,我們無法根據上下文确定一個表達式(包括函數實參)的類型。換句話說,某個調用的實參是否滿足函數的需要,要到實際執行該函數調用時才能确定。在這種情況下,要保證函數裡的計算有意義,就需要在程式裡做一些必要的檢查。這種檢查有可能很複雜,前面遇到過這樣的例子,例如定義基于三角形的三條邊求面積函數:

由于python沒有對實參類型的強制性要求,是以上面有關函數參數的條件還不夠。實際上,為提供完整的保證,這裡首先需要檢查幾個參數的類型。

要求一個表達式e的類型是t,可以寫條件表達式type(e) == t,例如上面函數中可以增加條件type(a) == float等。python的标準寫法是調用内置函數isinstance(a, int),它是檢查a的值是否為類型float的一個執行個體。

把上面函數的檢查補充完全,應該寫:

這裡假設允許整數和浮點數作為邊長。上面的條件總共寫了5行,前面幾行都需要續行符。還應注意or的優先級低于and,這裡必須寫括号。

上面讨論中提出的方法是在函數開始用一個條件語句檢查參數,在參數滿足條件時才去做正常的計算。這種做法很合理。但是,如果實際參數不滿足函數的需要,後面的代碼應該怎麼寫?這是一個很棘手的問題,隻能根據具體情況處理。上面函數中采用了傳回特殊浮點值的方式,是一種可能的做法。實際上,python語言為執行中發現錯誤和錯誤的處理提供了更進階的處理機制,有關情況将在第6章讨論。

另一可能想法是設法給使用者提供一些資訊,希望他們總用合法的參數調用函數。這方面的正常做法是用注釋說明函數對參數的要求,還可以同時說明函數的功能、用法等。注釋是僅供人閱讀的程式成分,python解釋器在處理程式時,将簡單丢掉其中的所有注釋。為了能在程式執行中提供資訊,python增加了稱為文檔串的機制。

如果在一個函數體裡的第一個語句是一個字元串,這個串就是函數的文檔串。python對出現在這裡的串做特殊處理,将其儲存在執行環境中,使人可以在程式運作中檢視。人們通常用函數的文檔串描述函數對參數的要求和函數的功能。由于這種描述可能較長,一般采用一對三引号的字元串形式,在程式中占據多行。

實際上,為函數提供文檔串,已經成為python程式設計中的一種正常做法。python的内置函數,标準庫程式包裡的各種函數等都有文檔串。例如:

上面print輸出的三行就是内置函數abs的文檔串内容。解釋器把文檔串儲存在函數名下的 doc 成分中(注意,doc前後各有兩個下劃線符)。内置函數abs的文檔串說該函數要求一個數作為參數,傳回一個數。實際功能是傳回參數的絕對值。

前面說過,在函數體裡,形參也看作局部變量,其特點就是在函數體開始執行前已經有了值,它們的值由函數調用時的實參(表達式)得到。形參在函數體内的使用方式與其他變量一樣,可以再次指派。如果執行到某個位置這個函數應該結束,就應該寫一個return語句,并根據需要用return之後的表達式描述傳回值。

如果我們定義的一些函數非常有用,可以将它們包裝成子產品,供自己在今後的程式設計中使用。有價值的子產品還可以提供給别人使用。各種标準庫、重要的第三方python程式庫,也就是這樣逐漸發展起來的。

函數調用的形式是函數名後面跟一對圓括号,括起用逗号分隔的若幹表達式,這些表達式稱為實際參數,簡稱實參。調用函數時,必須提供一組數目正确、類型和值滿足函數需要的實參,才能得到我們期望的結果。

如果要調用的是無參函數(函數定義的參數表為空),也必須寫一對空括号,不能省略。如果提供的實參個數不對(多了或者少了),執行這個函數調用時,解釋器就會報typeerror錯(類型錯誤)。如果實參的類型或者值不符合需要,函數執行中有可能報出某種錯誤,也可能得到奇怪的結果,或者出現其他問題(例如進入死循環)。例如:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

具展現象将因情況的不同而不同。

函數調用是一種基本表達式,它們經常出現在表達式裡(進而出現在語句裡),調用代碼段通過指派等方式獲得函數的傳回值。實際上,即使一個函數傳回有意義的值,python也允許我們不使用其傳回值,為此隻需把函數調用寫成一個獨立的語句。如果函數有傳回值,但在調用時沒有用,解釋器就把這個傳回值簡單丢掉。對于傳回值為none的函數,通常總是寫獨立的調用語句。例如前面反複使用的内置函數print。

在函數調用執行時,解釋器順序地(從左到右)算出每一個實參表達式的值,得到一組結果對象;讓對應的函數形參分别以這些對象為值,然後執行這個函數的體。在函數裡對形參指派不會影響函數調用時的實參,即使相應的實參是變量。python明确規定從左到右求值實參表達式。這個規定在一些情況下也可能造成影響。後面會看到這樣的情況。

圖3.6用一個例子顯示了函數調用中實參與形參的關系。這裡的變量m和n作為調用f的實參,f(m,n)執行時,實參m和n的值分别送給f的形參a和b。圖中箭頭表示變量與值的關聯關系,實線箭頭表示的是函數f調用後,開始執行函數體的時刻變量和值的關聯情況。可以看到,這時變量m和函數形參a以同一個對象為值,n和形參b以同一個對象為值。如果執行到函數裡對b指派的語句,就會導緻b的值被修改,使b以另一個(字元串)對象為值(如圖中虛線箭頭所示)。但從圖示可見,這個修改不會影響變量n的值。

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

如果函數調用的實參表達式又是一個函數調用,解釋器就會轉過去,先完成那個函數調用,把調用傳回的結果作為目前調用的實參。python允許在表達式裡寫出任意嵌套深度的函數調用,解釋器總按上述規則處理。

要保證函數的使用能得到預期效果,函數調用就必須與定義互相協調,互相配合。在實際中,我們常常希望所用的函數是“全函數”,也就是說,給它任意一個或一組類型合适的實參,它總能給出正确的函數值,或者總能完成所需工作(對于無傳回值的函數)。有些函數确實是這樣,例如内置函數print,它甚至對參數個數也沒有明确規定。

相對而言,我們比較容易保證明際參數的類型滿足函數的需要,下面讨論主要針對實參的值。一般而言,很多函數對于實參的值有要求,即隻能處理合法類型參數的一些情況。

前面讨論的求最大公約數函數是一個典型例子。如果兩個整數都是0,其最大公約數(在數學裡)沒有定義。前面考慮讓函數在這種情況下傳回0,是自己設計的一種權宜之計。這樣做有兩個優點:1)使函數對所有實參情況都能傳回值(把函數“補全”),以友善其使用。2)由于任何一對整數的最大公約數都不是0,是以0(相對于最大公約數的計算結果而言)是個閑置值,在計算中不會被誤解。而且,在調用這個函數之後,隻要檢查得到的結果是不是0,就可以判斷是否得到了真正的最大公約數。

應該看到,這樣定義也對函數的調用提出要求。由于原來的函數不是“全函數”,調用這種函數時,有兩種可能的做法:

1)保證隻用符合函數實際需要的實參去調用。這就要求在每個函數調用前檢查實參的值,滿足條件時才調用函數。這件事可以用if語句做。但是這裡也有麻煩:沒有通過檢查的情況怎麼辦?還是處理錯誤資料的問題,逃不掉。

2)在調用函數之後檢查結果,确定傳回值正确後再使用,不正确的情況另行處理。

前一方式是在調用前檢查和處理,後一方式是在調用後檢查和處理。兩種方式都能解決問題,但都需要在每個調用的上下文中檢查和處理,實作起來比較麻煩。

進一步說,有時還會遇到無法給出合适傳回值的情況。舉個簡單例子。假定要定義一個函數,計算數軸上兩個線段的長度比(取整),線段由兩個端點的坐标給出。函數定義為:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

這個函數很簡單,但它對有些參數情況無定義:後一線段的兩坐标相同時(退化為一個點),比率為無窮大。這個情況很難辦,因為沒有閑置的整數值可用(每個整數都可能是某個調用的正确結果)。這種情況下,隻能采用上面的第一種辦法處理:采用如上方式簡單地定義好函數,要求使用者調用函數前檢查參數,遇到y2 – y1為0時另行處理。

這些情況說明,一般而言,在函數的定義和調用之間往往有必要的配合。定義函數是把完成某種計算的代碼包裝為一個邏輯體,使之可以友善地調用。但要注意函數可能不是全的,對一些參數值不能給出結果。有些是本質性的(如兩個0無最大公約數等),有些可能是實作方式造成的。在定義函數時,應盡可能定義全函數,對特殊情況給以說明。使用時必須關注函數對特殊情況的處理,采取相應措施:或是在調用前檢查參數,保證函數執行不會出錯;或是在調用函數後檢查得到的結果,保證使用有關結果繼續計算還有意義。

參數檢查和斷言語句assert

如果認為需要,我們可以在函數開始用條件語句檢查參數并适當處理。但是,很多時候,不滿足需要的實際參數應該看作運作錯誤,而不應該讓函數傳回一個任選的值。此外,有時某些變量(不一定是參數)的值不滿足特定條件,操作也無法進行下去,也應該看作運作時錯誤。典型情況如在做除法之前發現除數為0。這些情況都說明,我們需要一種機制,以便能說明在一定條件下應該中斷目前的計算。為滿足這類需求,程式設計語言都提供了一種稱為斷言的機制,python的機制是斷言語句。

斷言語句用關鍵字assert描述,這是是一種非常特殊的語句,專門用于檢查某些條件是否成立。斷言語句有兩種形式:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

這裡的條件也稱為斷言,它應該是一個表示某種邏輯條件的表達式。第二種形式裡的表達式可以是任意的表達式。

如果在執行中遇到第一種形式的斷言語句,解釋器求值其條件。如果求出的結果是真,解釋器繼續向下執行,就像沒遇到這個斷言語句一樣。如果條件不為真,解釋器就報assertionerror錯誤。預設情況下這将導緻程式的執行終止。綜合這兩條可以看出,斷言語句也就是強制要求斷言成立,否則就報錯。

第二種語句形式執行時的基本情況與第一種相同,隻是當條件的值為假時,解釋器繼續求值語句中的表達式部分,把得到的值作為assertionerror的參數。

與if語句不同,斷言語句隻應用于描述程式(函數)正确執行的必要條件。如果用斷言語句描述了參數需要滿足的條件,就可以保證隻有參數正确時,函數才會執行所需的計算。人們主要利用斷言語句幫助程式調試,檢查一些重要的執行條件。

以求階乘的函數為例。顯然,這個函數的參數必須是整數,此外,函數的參數為負時,階乘也沒有定義(在前面的函數定義中,對後者采用了權宜的做法)。加入适當的斷言語句後,函數的定義是:

這裡加入了參數類型檢查,其實,前面許多程式(包括函數)裡都可以增加這種檢查。如果用不滿足條件的實參調用,解釋器就會報錯:

錯誤資訊告訴我們,在執行哪個檔案的哪個函數(給出了函數名fact)時發生錯誤,而且給出了行号(第4行)和出錯的斷言語句。根據這些資訊很容易找到出錯位置。斷言語句的第二種形式用于提供進一步的資訊。例如,将函數定義改為:

如果出錯,解釋器不但給出前面的資訊,還會給出當時實參的值。例如:

如果在程式裡的某個位置,隻有一些變量的值滿足某些要求時,才能繼續計算,就可以用斷言語句描述這種要求,這樣做有幾方面的益處:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

程式設計式就是為了解決問題,而要解決問題,首先要設法找到能解決問題的方法。實際上,存在着一些應用面比較廣泛的問題解決方法,可能用于解決許多問題。另一方面,也可能存在解決某個問題的特殊方法。前一類方法可稱為通用的方法,後一類則是專用的方法。本節讨論這方面的一些情況。

對于計算問題的通用方法,前面已有些讨論。例如生成和篩選,設法生成一組候選解,從中找出真正的解。對于一些問題,如果沒辦法直接找到解,而判斷一個結果是否為解卻比較簡單,就可以考慮通過生成和篩選的方式求解。

實際上,生成和篩選隻是一種求解模式,要想将它應用于具體問題,還需要針對具體問題定制這個方法。首先需要針對具體問題,設計一種生成候選解的方法,該方法應該比較簡單,易于實作,而且必須保證問題的解位于其生成的候選集中,這樣才能確定得到解。再就是要找到一種有效方法,判别一個候選是不是真正的解,以保證不會漏掉所需的解。一般而言,篩選出的可能是一組對象(一組解)。合用的篩選函數就是一個做判斷的謂詞。下面通過例子說明其中的情況。

假設現在希望做出一個函數,求出任一浮點數(參數)的立方根。根據計算機的特點,我們隻能期望找到一個接近參數立方根的浮點數。具體怎樣“接近”要看問題的需要,例如,要求得到的結果的立方與原數之差不超過0.001。

解決這個問題的一種簡單想法是采用生成和篩選的一種特例,枚舉和檢查:選擇一系列數值做試驗,從中選出一個滿足需要的值,作為立方根的近似值。

下面的第一個問題是被檢查的數值怎麼選。最友善的方法是用一個循環,生成一組等距的浮點數。如果試驗的數值足夠密集,就可能得到足夠好的解。至于篩選,自然是用有關數值的立方與原數比較,根據誤差篩選出滿足要求的解。

我們首先考慮按照0.001步長做試驗。寫出的函數定義如下:

這裡把負數的求根也歸結到正數,統一處理,為此,函數開始時用一個條件表達式提取出x的符号,再求出x的絕對值用于後續計算。

現在可以做試驗,檢查這個函數的功能。不難看到:采用一定的步長檢查,未必能保證對所有數值找到滿足要求的根,對較大的數都找不到,而且誤差越來越大。例如:

反思這裡的計算方法,可以看到一些問題:采用固定步長的一系列數自做試驗,固定了解的小數點之後的有效位數。立方根的值随着參數而單調增長,而随着試驗的數變大,前後兩個數的立方之差也會變得越來越大。要想對較大的數值(例如200)得到滿足要求的立方根近似值,就需要縮短步長(例如從0.001改為0.0001)。但是這種方法不能解決問題,對于更大的數,步長可能仍然不夠小。另一方面,參數變大,縮小步長,都會導緻函數裡的循環做更多次疊代,使計算時間變得更長。這些讨論說明,将枚舉和檢查以上面方式應用于求立方根,不太合适。當然,這并不說明枚舉和檢查方法不好,隻是使用不當。

現在考慮另一種采用逐漸逼近方式的數值計算方法:取一個包含解(立方根)的區間,在工作中的每一步設法縮小區間的範圍,而且保證所需的解仍在區間裡。這樣不斷做下去,到區間足夠小的時候,就可以用區間中點作為解的近似值。

不難看到,這也是一種通用方法,計算立方根隻是它的一個具體應用。要實作這種方法,也需要解決幾個問題:初始區間如何選擇?用什麼方法縮小區間的範圍?實際上,任何能保證不丢掉解的方法都可以考慮。下面考慮一種方法:每一步将原區間二分(稱為二分法),從中選出合适的半區間(包含解的半區間)。我們知道,“一尺之棰,日取其半,萬世不絕”。但另一方面,反複折半,可以把區間變得任意短,是以可以得到任意精度的解。

相應的函數定義:

這裡用變量a和b界定考慮的區間範圍,先設定初始區間,然後進入一段重複計算,不斷縮小區間的範圍。上面函數裡的循環用true作為條件,說明這個循環不通過頭部的條件檢查而退出,這裡用條件下的return語句結束循環,條件是m的立方根值與參數之差滿足我們的需要,其中m的值是區間的中點。如果m不滿足需要,就根據其值的情況決定半區的選擇,為此隻需要修改a或者b的值。然後反複。

不難确認,前一方法的缺點現在已經解決了:

似乎問題都解決了。但是,其實這個程式有錯,對一些參數不能給出正确的結果。

實際上,如果參數x的絕對值小于1,其絕對值的立方根将不在 [0, y] 的範圍内。對這樣的參數調用上述函數,将會出現什麼情況呢?請讀者首先通過分析給出一個判斷,而後在計算機上做些試驗,看看自己的分析對不對。

糾正錯誤的方法很簡單,隻需要修改a和b的初始化語句:

讀者還可以進一步試驗,考察這個函數逼近解的速度。例如對不同的數,函數裡的循環需要做多少次疊代。對于不同的精度要求呢?還可以做些理論分析。

通用方法具有較廣泛的适用性,但解決問題的效率相對較低。針對要解決的具體問題,通過研究,也可能開發出一些針對具體問題的專用方法。

對于求立方根,人們給出了一個逼近公式:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

并證明了,從任何一個非0初始值x0開始,按這個公式遞推得到的無窮序列,其極限就是x的立方根。也就是說,序列中的值将能任意接近實際的立方根。

根據這個公式,可以定義出下面函數,其中采用前面提出的結束條件:

這個函數不需要檢查參數正負,但需要把0作為特殊情況專門處理。

我們說,一般而言,專用的方法比通用方法效率更高。上面兩個函數(前一個是二分法逼近)以完全不同的方式解決同一個問題,可以用它們做些試驗。對這兩個簡單函數,一個合理的評價标準是循環執行的次數,它反映了在計算過程中變量逼近最終結果的速度。為了考察循環的執行次數,隻需要在函數裡增加一個計數變量,在适當的時候輸出該變量的值。這個工作非常簡單,請讀者自己完成。

在結束本節之前,這裡還想介紹在逼近計算中經常提到的兩個概念。在前面兩個函數的定義中,我們都要求結果的立方根值與原參數之差不超過一個固定的數,這樣的允許誤內插補點稱為絕對誤差,因為這種判斷依據(判據)是直接給定的,與實際計算的情況無關。在一些情況下,采用這種判據是合理的。但在另一些情況下,這種判據就不太合理了。以求立方根為例,如果參數是20000.0,結果的誤差不超過0.001應該可以滿足通常的需要了。但如果求0.00001的立方根,誤差0.001的結果完全是沒有意義的。

為解決這個問題,人們提出了相對誤差的概念,也就是說,要求基于計算中處理的資料考慮允許誤差。對于求立方根,可以考慮用下面判據:

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

這樣,對任何實際參數,得到的結果都能比較合理。這裡寫0.001隻是示例,很容易修改為所需的其他值。實際中人們也經常采用逼近序列中的前後兩個值之差作為結束的判據,因為如果一步移動的距離很短,估計距離目标也不太遠了。例如要求達到

《從問題到程式:用Python學程式設計和計算》——3.4 定義函數

基于這種誤差判斷,可以寫出下面的函數定義:

注意,由于結束判斷牽涉到前後兩個近似值,這裡用了兩個變量,其中x2儲存最新求出的近似值,x1儲存前一個近似值。在确定了x2還不夠好的時候,就把它的值交給x1,以便繼續工作下去。這是一種亦步亦趨的遞推。下面是兩個例子:

從這兩個算例中可以看到,對較大和較小的參數,函數給出的結果都比較合理。

上面求立方根的方法也是牛頓疊代法,有關情況後面還有介紹。這種方法由著名科學家牛頓提出,其收斂性有理論的保證。

一般而言,通用方法可能用于解決許多不同的問題,而專用方法隻能用于解決特定的問題。從效率看,專用方法通常效率較高。如果需要解決一個具體問題,但一時找不到專用的特殊算法,也可以考慮通用的方法。由于計算機長于做反複操作,可以在很短時間裡做很多嘗試,是以,在許多情況下,某種通用方法也就足夠了。