ฟังก์ชันไพธอนหลายอันที่เราได้ใช้มา เช่น ฟังก์ชันทางคณิตศาสตร์ จะให้ค่าที่คืนกลับไป ยังผู้เรียก (return value) แต่ฟังก์ชันที่เราเขียนขึ้นมาเองจนถึงตอนนี้เป็นฟังก์ชันที่ไม่คืนค่า พวกมันมีการทำงาน เช่น พิมพ์ค่า หรือทำให้ turtle เคลื่อนที่ แต่มันไม่มีค่าที่จะคืน กลับไป ในบทนี้ เราจะเรียนรู้เกี่ยวกับการเขียนฟังก์ชันที่มีค่าที่คืนกลับไป หรือ ฟังก์ชัน ที่ให้ผล (fruitful function)
การเรียกฟังก์ชันจะทำให้เกิดค่าที่ถูกส่งคืนกลับมา ซึ่งโดยปกติแล้วเรากำหนดค่านี้ให้กับตัวแปร หรือใช้เป็นส่วนหนึ่งของนิพจน์
e = math.exp(1.0) height = radius * math.sin(radians)
ฟังก์ชันที่เราเขียนมาจนถึงตอนนี้เป็นฟังก์ชันวอยด์ (void) ถ้าให้พูดแบบสบายๆ ก็คือ มันไม่คืนค่า แต่จริงๆ แล้วค่าที่ถูกส่งคืนกลับมาคือ None
ในบทนี้ (ในที่สุด) เราจะเขียนฟังก์ชันที่ให้ผลออกมา ตัวอย่างแรกคือฟังก์ชัน area
ซึ่ง คืนค่าเป็นพื้นที่ของวงกลมที่มีรัศมีที่กำหนดให้:
def area(radius): a = math.pi * radius**2 return a
เราเจอคำสั่ง return
มาก่อนหน้านี้แล้ว แต่ในฟังก์ชันที่ให้ผลนี้ คำสั่ง return
จะมี นิพจน์ด้วย คำสั่งนี้หมายความว่า “ให้กลับออกไปจากฟังก์ชันนี้ทันที และใช้ค่าต่อไปนี้เป็นค่าที่ถูกคืน กลับไป” นิพจน์ในฟังก์ชันนี้อาจจะดูยุ่งยากแบบไม่มีเหตุผลหน่อย ดังนั้น เราสามารถเขียนฟังก์ชันนี้ใหม่ แบบกระชับขึ้น:
def area(radius): return math.pi * radius**2
แต่ในอีกทางหนึ่ง การมี ตัวแปรชั่วคราว เช่น a
สามารถทำให้การดีบักง่ายขึ้น
ในบางครั้ง มันก็มีประโยชน์ที่จะมีคำสั่ง return หลายอัน แต่ละอันอยู่ในแต่ละแขนงของเงื่อนไข:
def absolute_value(x): if x < 0: return -x else: return x
เนื่องจากคำสั่ง return
อยู่ในเงื่อนไขทางเลือก เพราะฉะนั้นจะมีคำสั่งอันเดียวเท่านั้น ที่จะทำงาน
ทันทีที่คำสั่ง return ทำงาน ฟังก์ชันจะหยุดการทำงานโดยไม่ทำคำสั่งที่เหลืออีก โค้ดที่ปรากฏ หลังจากคำสั่ง return
หรือ ณ ที่ใดก็ตามที่กระแสการดำเนินการของโปรแกรมไปไม่ถึง เรียกว่า โค้ดตาย (dead code)
ในฟังก์ชันที่ให้ผล มันเป็นความคิดที่ดีที่จะทำให้มั่นใจว่าทุกเส้นทางในโปรแกรมนั้นจบด้วยคำสั่ง return
เช่น:
def absolute_value(x): if x < 0: return -x if x > 0: return x
ฟังก์ชันนี้ไม่ถูกต้องเพราะว่า ถ้า x
เป็น 0 แล้ว ไม่มีเงื่อนไขใดที่เป็นจริง และ ฟังก์ชันก็จบโดยไม่ได้รันคำสั่ง return
ถ้ากระแสการดำเนินการไปถึงตอนจบของฟังก์ชัน ค่าที่ถูกคืนกลับไปจะเป็น None
ซึ่งไม่ใช่ค่า 0 ซะทีเดียว
>>> print(absolute_value(0)) None
อย่างไรก็ตาม ไพธอนได้เตรียมฟังก์ชันภายในเรียกว่า abs
ที่หาค่าสัมบูรณ์ (absolute value)
เพื่อเป็นการฝึกทำ ให้เขียนฟังก์ชันที่ชื่อว่า compare
ซึ่งรับค่าเข้ามา 2 ค่า คือ x
และ y
และคืนค่า 1
ถ้า x > y
, คืนค่า 0
ถ้า x == y
, และคืนค่า -1
ถ้า x < y
เมื่อเราเขียนฟังก์ชันที่ใหญ่ขึ้น เราอาจจะเจอว่าเราใช้เวลาในการดีบักนานขึ้น
เพื่อที่จะจัดการกับโปรแกรมที่ซับซ้อนมากขึ้นเรื่อยๆ เราอาจจะพยายามทำกระบวนการที่เรียกว่า การพัฒนาโปรแกรมแบบเพิ่มส่วน (incremental development) จุดประสงค์ของการพัฒนา โปรแกรมแบบเพิ่มส่วน คือ การหลีกเลี่ยงช่วงเวลาดีบักที่ยาว โดยการเติมและ ทดสอบโค้ดทีละน้อย
ตัวอย่างเช่น สมมติว่าเราต้องการจะหาระยะทางระหว่างจุดสองจุด โดยกำหนดพิกัด $(x_1, y_1)$ และ $(x_2, y_2)$ จากทฏษฎีพิธากอรัส ระยะทาง คือ:
$$\mathrm{distance} = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}$$ ขั้นตอนแรก คือ การพิจารณาว่าฟังก์ชัน distance
จะหน้าตาเป็นอย่างไรในไพธอน อีกนัย หนึ่งคือ อะไรคืออินพุต (พารามิเตอร์) และอะไรคือเอ้าต์พุต (ค่าคืนกลับ)
ในกรณีนี้ อินพุต คือ จุดสองจุด ซึ่งเราสามารถแทนด้วยตัวเลข 4 ตัว ค่าคืนกลับ คือ ระยะทาง ซึ่งเป็นค่าจุดลอย
เราสามารถเขียนโครงฟังก์ชันได้ในทันที:
def distance(x1, y1, x2, y2): return 0.0
เป็นที่ชัดเจนว่า เวอร์ชันนี้ยังไม่ได้คำนวณระยะทาง; มันจะส่งค่า 0 กลับไปตลอด แต่มันก็ถูกต้อง ตามกฎวากยสัมพันธ์และมันก็ทำงานได้ ซึ่งหมายความว่า เราสามารถทดสอบมันก่อนที่เราจะทำให้มัน ซับซ้อนไปมากกว่านี้
ในการทดสอบฟังก์ชันใหม่นี้ เราจะเรียกมันโดยใช้อาร์กิวเมนต์ตัวอย่าง:
>>> distance(1, 2, 4, 6) 0.0
ผมเลือกค่าเหล่านี้ เพื่อทำให้ระยะทางราบเท่ากับ 3 และระยะทางดิ่งเท่ากับ 4; ซึ่งจะทำให้ ผลลัพธ์เป็น 5 ซึ่งตรงกับสามเหลี่ยมพิธากอรัส 3-4-5 เมื่อทำการทดสอบฟังก์ชัน มันมี ประโยชน์ที่รู้คำตอบที่ถูก
ถึงตรงนี้ เราได้ยืนยันว่าฟังก์ชันนี้ถูกเชิงวากยสัมพันธ์ และเราสามารถเริ่มเพิ่มโค้ดให้กับส่วนตัว ของฟังก์ชันได้ ขั้นตอนต่อไปที่เหมาะสม คือ การหาผลต่างของ $x_2 - x_1$และ $y_2 - y_1$ เวอร์ชันต่อไปของฟังก์ชันเก็บค่าเหล่านี้ในตัวแปรชั่วคราวและพิมพ์มันออกมา
def distance(x1, y1, x2, y2): dx = x2 - x1 dy = y2 - y1 print('dx is', dx) print('dy is', dy) return 0.0
ถ้าฟังก์ชันทำงานได้ถูกต้อง มันควรจะแสดงผลว่า dx is 3
และ dy is 4
ถ้าเป็นเช่นนั้น เรารู้ว่าฟังก์ชันรับค่าอาร์กิวเมนต์เข้าไปได้ และทำการคำนวณอย่างแรกได้ถูกต้อง ไม่เช่นนั้น ก็จะมีแค่ไม่กี่บรรทัดเท่านั้นที่เราต้องตรวจสอบ
ขั้นต่อไป เราคำนวณผลรวมของกำลังที่สองของ dx
และ dy
:
def distance(x1, y1, x2, y2): dx = x2 - x1 dy = y2 - y1 dsquared = dx**2 + dy**2 print('dsquared is: ', dsquared) return 0.0
อีกครั้งหนึ่ง เราจะรันโปรแกรมตอนนี้และตรวจสอบเอ้าต์พุต (ซึ่งควรจะเป็น 25) ท้ายที่สุด เราสามารถใช้ math.sqrt
เพื่อที่จะคำนวณและคืนค่ากลับมาได้:
def distance(x1, y1, x2, y2): dx = x2 - x1 dy = y2 - y1 dsquared = dx**2 + dy**2 result = math.sqrt(dsquared) return result
ถ้ามันทำงานได้ถูกต้อง เราก็เสร็จเรียบร้อย ไม่เช่นนั้น เราอาจจะต้องพิมพ์ค่าของ result
ก่อนคำสั่ง return
เวอร์ชันสุดท้ายของฟังก์ชันนี้ไม่ได้แสดงอะไรเลยตอนที่ทำงาน มันแค่คืนค่ากลับมา คำสั่ง print
ที่เราเขียนนั้นมีประโยชน์กับการดีบัก แต่เมื่อเราทำให้ฟังก์ชันทำงานได้แล้ว เราควรที่จะเอามันออกไป โค้ดพวกนี้เรียกว่า นั่งร้าน (scaffolding) เพราะว่ามันมี ประโยชน์กับการสร้างโปรแกรม แต่มันไม่ใช่ส่วนหนึ่งของผลลัพธ์สุดท้าย
เมื่อเราเริ่มฝึกเขียนโปรแกรม เราควรจะเพิ่มโค้ดแค่ทีละ 1-2 บรรทัด แต่เมื่อเรามีประสบการณ์ ในการเขียนโปรแกรมมากขึ้นแล้ว เราจะพบว่าเราจะเขียนและดีบักโค้ดก้อนที่ใหญ่ขึ้น ไม่ว่าจะแบบไหนก็ตาม การพัฒนาโปรแกรมแบบเพิ่มส่วนสามารถทำให้เราประหยัดเวลาดีบักได้มาก
ลักษณะสำคัญของขั้นตอนเหล่านี้คือ:
เพื่อเป็นการฝึกทำ ให้ใช้การพัฒนาโปรแกรมแบบเพิ่มส่วนเขียนฟังก์ชันที่ชื่อว่า hypotenuse
ซึ่ง คืนค่าเป็นความยาวของด้านตรงข้ามของสามเหลี่ยมมุมฉาก (hypotenuse) เมื่อกำหนดความยาวของ สองด้านที่เหลือให้เป็นอาร์กิวเมนต์ บันทึกแต่ละช่วงของขั้นตอนการพัฒนาควบคู่ไปกับการทำ
ถึงตอนนี้เราควรจะคาดได้แล้วว่า เราสามารถเรียกฟังก์ชันหนึ่งจากข้างในฟังก์ชันอีกอันหนึ่ง เช่น เราจะเขียนฟังก์ชันที่รับจุดสองจุด คือ จุดกึ่งกลางของวงกลม และจุดที่อยู่บนเส้นรอบวง และคำนวณพื้นให้ของวงกลม
สมมติว่าจุดกึ่งกลางนั้นถูกเก็บอยู่ในตัวแปร xc
และ yc
และจุดบนเส้นรอบวงถูกเก็บอยู่ใน xp
และ yp
ขั้นตอนแรกคือการหารัศมีของวงกลม ซึ่งคือระยะทางระหว่างจุดสองจุดนี้ เราเพิ่งจะเขียนฟังก์ชัน distance ที่จะหาระยะทางนี้:
radius = distance(xc, yc, xp, yp)
ขั้นตอนต่อไป คือ การหาพื้นที่ของวงกลมด้วยรัศมีที่เราได้มา; เราก็เพิ่งเขียนมันไปด้วยเช่นกัน:
result = area(radius)
เราก็ห่อหุ้มขั้นตอนเหล่านี้ให้มาอยู่ในฟังก์ชัน ได้เป็น:
def circle_area(xc, yc, xp, yp): radius = distance(xc, yc, xp, yp) result = area(radius) return result
ตัวแปรชั่วคราว radius
และ result
มีประโยชน์ในการพัฒนาและดีบัก แต่เมื่อโปรแกรมรันได้อย่างถูกต้องแล้ว เราก็สามารถทำให้มันกระชับมากขึ้น โดยการเขียนฟังก์ชันให้เป็น:
def circle_area(xc, yc, xp, yp): return area(distance(xc, yc, xp, yp))
ฟังก์ชันสามารถคืนค่าเป็นชนิดบูลีนได้ ซึ่งหลายครั้งทำให้สะดวกสำหรับการซ่อนการทดสอบ ที่ซับซ้อนในฟังก์ชัน ตัวอย่างเช่น:
def is_divisible(x, y): if x % y == 0: return True else: return False
มันเป็นเรื่องธรรมดาที่จะตั้งชื่อฟังก์ชันบูลีนด้วยชื่อที่ฟังคล้ายกับคำถามใช่หรือไม่ (yes/no question); ฟังก์ชัน is_divisible
คืนค่ากลับมาเป็นถ้าไม่ True
ก็ False
เพื่อ ระบุว่า x
ถูกหารด้วย y
ลงตัวหรือไม่
นี่คือตัวอย่าง:
>>> is_divisible(6, 4) False >>> is_divisible(6, 3) True
ผลลัพธ์ของตัวดำเนินการ ==
คือค่าบูลีน ดังนั้น เราสามารถเขียนฟังก์ชันให้กระชับมาก ยิ่งขึ้นด้วยการคืนค่าการดำเนินการไปตรงๆ เลย:
def is_divisible(x, y): return x % y == 0
ฟังก์ชันบูลีนนิยมใช้ในคำสั่งเงื่อนไข (conditional statement):
if is_divisible(x, y): print('x is divisible by y')
เราอาจจะอยากเขียนแบบนี้:
if is_divisible(x, y) == True: print('x is divisible by y')
แต่การเปรียบเทียบที่เพิ่มขึ้นมานั้นไม่จำเป็น
เพื่อเป็นการฝึกทำ ให้เขียนฟังก์ชันที่ชื่อว่า is_between(x, y, z)
ที่คืนค่า True
ถ้า $x \le y \le z$ หรือไม่เช่นนั้นให้คืนค่า False
เราได้เรียนรู้ไปเพียงแค่ซับเซ็ตเล็กๆ ของไพธอน แต่เราอาจจะสนใจที่รู้ว่าซับเซ็ตอันนี้มัน เป็นภาษาโปรแกรมที่ สมบูรณ์ แล้ว ซึ่งหมายความว่า อะไรก็ตามที่สามารถคำนวณได้ จะสามารถเขียนแทนได้ด้วยภาษานี้ โปรแกรมใดๆ ที่เคยถูกเขียนมาจะสามารถถูกเขียนใหม่ได้ โดยใช้แค่คุณลักษณะที่เราได้เรียนมาจนบัดนี้ (ที่จริงแล้ว แล้วต้องใช้คำสั่งอย่างอื่นอีกหน่อย เพื่อที่จะควบคุมเม้าส์ ดิสก์ และอื่นๆ แต่ก็เท่านั้นแหละ)
การพิสูจน์ข้อกล่าวอ้างนี้ เป็นอะไรที่ไม่ง่ายนักซึ่งทำสำเร็จเป็นครั้งแรกโดยอลัน ทัวริง (Alan Turing) หนึ่งในนักวิทยาการคอมพิวเตอร์คนแรกๆ (บางคนอาจจะเถียงว่าเขาเป็นนักคณิตศาสตร์ แต่ว่า นักวิทยาการคอมพิวเตอร์หลายคนก็เริ่มด้วยการเป็นนักคณิตศาสตร์) ดังนั้น มันจึงเป็นที่รู้จักกัน ในชื่อ ข้อวินิจฉัยของทัวริง (Turing Thesis) ผมแนะนำให้อ่านหนังสือของไมเคิล ซิปเซอร์ (Michael Sipser) ที่ชื่อว่า Introduction to the Theory of Computation
เพื่อที่จะทำให้เห็นว่าเราสามารถทำอะไรกับเครื่องมือที่เราเรียนมาได้บ้าง เราจะประเมินฟังก์ชันทาง คณิตศาสตร์ที่ถูกเขียนแบบเรียกซ้ำ (recursive) สองสามตัวอย่าง นิยามการเรียกซ้ำนั้นเหมือนกับ นิยามการเวียน (circular definition) ในแง่ที่ว่านิยามมีการอ้างอิงไปยังสิ่งที่มันกำลังถูกนิยามอยู่ (อ้างอิงไปถึงตัวมันเอง) นิยามการเวียนของจริงไม่ค่อยมีประโยชน์เท่าไหร่นัก:
ถ้าเราเห็นนิยามนี้ในพจนานุกรม เราอาจจะรำคาญได้ ในอีกแง่หนึ่ง ถ้าเราเปิดดูนิยามของฟังก์ชัน แฟกทอเรียล ที่แสดงด้วยเครื่องหมาย $!$ เราก็จะได้อะไรประมาณนี้ $$\begin{aligned} 0! &= 1 \\ n! &= n (n-1)!\end{aligned}$$ นิยามนี้กล่าวว่า แฟกทอเรียลของ 0 คือ 1 และแฟกทอเรียลของค่าอื่นๆ, $n$, คือ $n$ คูณ ด้วยค่าแฟกทอเรียลของ $n-1$
ดังนั้น $3!$ คือ 3 คูณกับ $2!$, ซึ่งคือ 2 คูณกับ $1!$, ซึ่งคือ 1 คูณกับ $0!$. พอเอามารวมๆ กันแล้ว, $3!$ เท่ากับ 3 คูณกับ 2 คูณกับ 1 คูณกับ 1, ซึ่งคือ 6.
ถ้าเราสามารถเขียนนิยามเรียกซ้ำของอะไรสักอย่าง เราก็สามารถเขียนโปรแกรมไพธอนเพื่อที่จะ ประเมินผลมัน ขั้นตอนแรกคือการตัดสินใจว่าพารามิเตอร์ควรจะเป็นอะไร ในกรณีนี้ มันควรจะ ชัดเจนว่าฟังก์ชัน factorial
รับค่าเป็นจำนวนเต็ม:
def factorial(n):
ถ้าอาร์กิวเมนต์ดันเป็น 0 สิ่งที่เราต้องทำก็คือ คืนค่า 1 กลับไป:
def factorial(n): if n == 0: return 1
ไม่เช่นนั้น, และนี่คือส่วนที่น่าสนใจ, เราจะต้องเรียกซ้ำเพื่อหาค่าแฟกทอเรียลของ $n-1$ และคูณด้วย $n$:
def factorial(n): if n == 0: return 1 else: recurse = factorial(n-1) result = n * recurse return result
กระแสการดำเนินการสำหรับโปรแกรมนี้จะเหมือนกับกระแสของคำสั่งใน countdown
ในหัวข้อที่ 5.8 ถ้าเราเรียกฟังก์ชัน factorial
ด้วยค่า 3:
เนื่องจาก 3 ไม่ใช่ 0 เราจะไปทำแขนงที่สองและคำนวณแฟกทอเรียลของn-1
…เนื่องจาก 2 ไม่ใช่ 0 เราจะไปทำแขนงที่สองและคำนวณแฟกทอเรียลของn-1
…เนื่องจาก 1 ไม่ใช่ 0 เราจะไปทำแขนงที่สองและคำนวณแฟกทอเรียลของn-1
…เนื่องจาก 0 เท่ากับ 0 เราทำแขนงแรก และคืนค่า 1 โดยไม่ต้องเรียกซ้ำอีกค่าที่ถูกคืนกลับมา, 1, จะถูกคูณด้วย $n$, ซึ่งคือ 1, และผลลัพธ์ก็จะถูกส่งคืนกลับไป
ค่าที่ถูกคืนกลับมา, 1, จะถูกคูณด้วย $n$, ซึ่งคือ 2, และผลลัพธ์ก็จะถูกส่งคืนกลับไป
ค่าที่ถูกคืนกลับมา (2) จะถูกคูณด้วย $n$, ซึ่งคือ 3, และผลลัพธ์, 6, ก็จะกลายเป็นค่าที่ถูกส่งคืนไปยัง การเรียกฟังก์ชันที่เริ่มกระบวนการทั้งหมดนี้
รูปที่ 6.1 แสดงให้เห็นว่าแผนภาพแบบกองซ้อนสำหรับเป็นอย่างไร สำหรับลำดับการเรียกฟังก์ชันนี้
ค่าคืนกลับถูกแสดงว่ากำลังถูกส่งกลับขึ้นไปบนกอง (stack) ในแต่ละกรอบ ค่าคืนกลับ คือ ค่าของ result
ซึ่งเป็นผลคูณของ n
และ recurse
ในกรอบสุดท้าย มันไม่มีตัวแปรเฉพาะที่ recurse
และ result
เพราะว่า แขนงที่ทำให้มันเกิดขึ้นนั้นไม่ได้ทำงาน
การไล่ตามกระแสการดำเนินการเป็นหนทางหนึ่งที่จะอ่านโปรแกรม แต่มันก็สามารถทำให้เรารู้สึกท่วมท้นได้เร็ว อีกทางเลือกหนึ่ง คือ สิ่งที่ผมเรียกว่า “การปล่อยไปตามโชคชะตา (Leap of faith)” เมื่อเรามาถึง การเรียกฟังก์ชัน แทนที่จะไล่ตามกระแสการดำเนินการ เราจะ สมมติ ว่าฟังก์ชันทำงานได้อย่าง ถูกต้อง และคืนค่าที่ถูกต้องกลับมาให้
ที่จริงแล้ว เราได้ฝึกการปล่อยไปตามโชคชะตามาแล้ว เมื่อเราใช้ฟังก์ชันที่มีอยู่ในตัว (built-in) เมื่อเราเรียก math.cos
หรือ math.exp
เราไม่ได้สำรวจส่วนตัวของฟังก์ชันดังกล่าวเลย เราแค่สมมติว่ามันทำงานได้ เพราะว่าคนที่เขียนฟังก์ชันพวกนี้เป็นโปรแกรมเมอร์ที่เก่ง
เป็นจริงเช่นเดียวกันเมื่อเราเรียกฟังก์ชันของเราเอง เช่น ในหัวข้อ 6.4 เราเขียนฟังก์ชันที่ชื่อว่า is_divisible
ซึ่งหาว่าเลขหนึ่งถูกเลขอีกตัวหนึ่งหารลงตัวหรือไม่ เมื่อเรามั่นใจแล้ว ว่าฟังก์ชันนี้น่าจะทำงานถูกต้อง—โดยการสำรวจโค้ดและทดสอบมัน—เราสามารถใช้ฟังก์ชัน นี้ไปเลยโดยไม่ต้องดูส่วนของตัวฟังก์ชันอีก
เป็นจริงเช่นเดียวกันกับโปรแกรมเรียกซ้ำ เมื่อเราทำการเรียกซ้ำ แทนที่เราจะไล่ตามกระแสการดำเนินการ เราควรจะสมมติว่าการเรียกซ้ำนั้นทำงานได้ (คืนค่าที่ถูกต้องกลับมา) และจากนั้นให้ถามตัวเองว่า “สมมติว่าเราสามารถหาค่าแฟกทอเรียบของ $n-1$ แล้ว เราจะสามารถคำนวณค่าแฟกทอเรียลของ $n$ ได้หรือเปล่า” มันชัดเจนว่าเราสามารถทำได้ โดยการคูณค่า $n$ เข้าไป
แน่นอนว่ามันแปลกหน่อยๆ ที่จะสมมติว่าฟังก์ชันมันทำงานได้ถูกต้อง เมื่อเรายังเขียนไม่เสร็จเลย แต่มันก็เป็นเหตุผลที่ว่าทำไมเราถึงเรียกมันว่า การปล่อยไปตามโชคชะตา ยังไงล่ะ!
หลังจากฟังก์ชัน factorial
แล้ว ตัวอย่างที่นิยมใช้ของฟังก์ชันทางคณิตศาสตร์แบบเรียกซ้ำ คือ ฟังก์ชัน fibonacci
ซึ่งมีนิยามดังนี้ (ดู http://en.wikipedia.org/wiki/Fibonacci_number):
$$\begin{aligned} \mathrm{fibonacci}(0) &= 0 \\ \mathrm{fibonacci}(1) &= 1 \\ \mathrm{fibonacci}(n) &= \mathrm{fibonacci}(n-1) + \mathrm{fibonacci}(n-2) \end{aligned}$$
เมื่อแปลมาเป็นไพธอนจะมีหน้าตาแบบนี้:
def fibonacci(n): if n == 0: return 0 elif n == 1: return 1 else: return fibonacci(n-1) + fibonacci(n-2)
ถ้าเราพยายามที่จะไล่ตามกระแสการดำเนินการตรงนี้ แม้แต่เป็นค่าน้อยๆ ของ $n$ หัวของเราจะระเบิด แต่จากหลักการปล่อยให้เป็นไปตามโชคชะตาแล้ว ถ้าเราสมมติว่าการเรียกซ้ำทั้งสองนั้นทำงาน ได้อย่างถูกต้องแล้ว มันก็จะชัดเจนว่าเราจะได้ผลลัพธ์ที่ถูกต้องจากการบวกค่าทั้งสองเข้า ด้วยกัน
จะเกิดอะไรขึ้นถ้าเราเรียกฟังก์ชัน factorial
และผ่านค่า 1.5 เป็นอาร์กิวเมนต์?
>>> factorial(1.5) RuntimeError: Maximum recursion depth exceeded
ดูเหมือนว่ามันเป็นการเรียกซ้ำไม่รู้จบ เป็นไปได้ยังไงกัน? ฟังก์ชันนี้มีกรณีฐาน—เมื่อ n == 0
แต่ถ้า n
ไม่ใช่จำนวนเต็ม เราสามารถจะ พลาด การเจอกรณีฐาน และเรียกซ้ำไปชั่วนิรันดร์ได้
ในการเรียกซ้ำครั้งแรก ค่าของ n
คือ 0.5 ส่วนในครั้งถัดไปคือ -0.5 จากนั้น ค่ามันก็จะน้อยลงเรื่อยๆ (เป็นลบมากขึ้น) แต่มันจะไม่มีวันเป็น 0
เรามีสองทางเลือก เราสามารถที่จะทำให้ฟังก์ชัน factorial
ครอบคลุมถึงเลขจุดลอย หรือเราสามารถ ทำให้ factorial
ตรวจสอบชนิดของอาร์กิวเมนต์ได้ ทางเลือกแรก เรียกว่า ฟังก์ชันแกมม่า (gamma function) และมันเกินขอบเขตของหนังสือเล่มนี้ ดังนั้น เราจะทำทางเลือกที่สอง
เราสามารถใช้ฟังก์ชันที่มีอยู่ในตัวที่ชื่อว่า isinstance
เพื่อตรวจสอบชนิดของอาร์กิวเมนต์ ในเวลาเดียวกัน เราสามารถตรวจสอบว่ามันเป็นจำนวนบวกหรือไม่ได้ด้วย
def factorial(n): if not isinstance(n, int): print('Factorial is only defined for integers.') return None elif n < 0: print('Factorial is not defined for negative integers.') return None elif n == 0: return 1 else: return n * factorial(n-1)
กรณีฐานแรกจัดการกับเลขที่ไม่ใช่จำนวนเต็ม; กรณีฐานที่สองจัดการกับจำนวนเต็มลบ ในทั้งสองกรณี โปรแกรมพิมพ์ข้อความแจ้งและคืนค่าเป็น None
เพื่อระบุว่าบางสิ่งบางอย่างนั้นไม่ถูกต้อง:
>>> print(factorial('fred')) Factorial is only defined for integers. None >>> print(factorial(-2)) Factorial is not defined for negative integers. None
ถ้าเราสามารถผ่านการตรวจสอบทั้งสองอย่างได้ เรารู้ว่า $n$ เป็นจำนวนบวก หรือ ศูนย์ ดังนั้น เราสามารถพิสูจน์ได้ว่าการเรียกซ้ำนั้นจะสิ้นสุด
โปรแกรมนี้สาธิตรูปแบบที่บางทีเรียกว่า ผู้พิทักษ์ (guardian) เงื่อนไขสองข้อแรกทำตัว เป็นผู้พิทักษ์ ป้องกันโค้ดจากค่าที่ทำให้เกิดข้อผิดพลาดขึ้นได้ ผู้พิทักษ์ทำให้การพิสูจน์ ความถูกต้องของโค้ดนั้นเป็นไปได้
ในหัวข้อที่ 11.4 เราจะเห็นทางเลือกที่ยืดหยุ่นกว่านี้ในการพิมพ์ข้อความแจ้งข้อผิดพลาด: การชูข้อยกเว้น (raising an exception)
การแตกโปรแกรมใหญ่ๆ ให้เป็นฟังก์ชันเล็กๆ ทำให้เกิดจุดตรวจสอบโค้ด (checkpoints for debugging) โดยธรรมชาติ ถ้าฟังก์ชันใดทำงานไม่ได้ มีความเป็นไปได้สามอย่างที่จะต้องพิจารณา:
เพื่อที่จะตัดความเป็นไปได้ข้อแรกออกไป เราสามารถเพิ่มคำสั่ง print
ตอนเริ่มฟังก์ชัน และแสดงค่าของพารามิเตอร์ต่างๆ (และอาจจะชนิดของข้อมูลด้วย) หรือเราสามารถเขียนโค้ดที่ ตรวจสอบเงื่อนไขเบื้องต้นโดยตรงเลย
ถ้าพารามิเตอร์ก็ดูใช้ได้ดี ให้เพิ่มคำสั่ง print
ก่อนคำสั่ง return แต่ละอัน และแสดง ค่าคืนกลับ ถ้าเป็นไปได้ ให้ตรวจสอบผลลัพธ์โดยมือ พิจารณาการเรียกฟังก์ชันด้วยค่าที่ง่ายต่อ การตรวจสอบผลลัพธ์ที่ถูกต้อง (เหมือนในหัวข้อที่ 6.2)
ถ้าฟังก์ชันก็น่าจะทำงานถูกต้อง ให้ดูที่การเรียกฟังก์ชันเพื่อที่จะมั่นใจว่าค่าที่ถูกส่งคืนกลับมานั้น ถูกนำมาใช้ได้อย่างถูกต้อง (หรือถูกนำมาใช้จริงๆ!)
การเพิ่มคำสั่ง print ในตอนเริ่มและจบของฟังก์ชัน ช่วยให้กระแสการดำเนินการนั้นชัดเจนขึ้น เช่น นี่เป็น เวอร์ชันอันหนึ่งของฟังก์ชัน factorial
ที่มีคำสั่ง print อยู่:
def factorial(n): space = ' ' * (4 * n) print(space, 'factorial', n) if n == 0: print(space, 'returning 1') return 1 else: recurse = factorial(n-1) result = n * recurse print(space, 'returning', result) return result
space
เป็นสายอักขระของอักขระเว้นวรรค ที่ควบคุมการย่อหน้าของเอ้าต์พุต นี่คือผลการทำงานของ factorial(4)
:
factorial 4 factorial 3 factorial 2 factorial 1 factorial 0 returning 1 returning 1 returning 2 returning 6 returning 24
ถ้าเรางงเกี่ยวกับกระแสการดำเนินการ เอ้าต์พุตลักษณะนี้จะมีประโยชน์ มันใช้เวลาระยะหนึ่งในการพัฒนา นั่งร้านที่มีประสิทธิภาพ แต่การสร้างนั่งร้านขึ้นมาเล็กน้อยนั้นสามารถประหยัดการดีบักได้มาก
return
แบบฝึกหัด 1
ให้เขียนแผนภาพแบบกองซ้อนสำหรับโปรแกรมต่อไปนี้ โปรแกรมนี้พิมพ์อะไรออกมา?
def b(z): prod = a(z, z) print(z, prod) return prod def a(x, y): x = x + 1 return x * y def c(x, y, z): total = x + y + z square = b(total)**2 return square x = 1 y = x + 1 print(c(x, y+3, x+y))
แบบฝึกหัด 2
ฟังก์ชันแอคเคอร์มานน์, $A(m, n)$, นิยามว่า:
$$\begin{aligned} A(m, n) = \begin{cases} n+1 & \mbox{if } m = 0 \\ A(m-1, 1) & \mbox{if } m > 0 \mbox{ and } n = 0 \\ A(m-1, A(m, n-1)) & \mbox{if } m > 0 \mbox{ and } n > 0. \end{cases} \end{aligned}$$
ดูเพิ่มเติมที่ http://en.wikipedia.org/wiki/Ackermann_function.
ให้เขียนฟังก์ชันที่ชื่อว่า ack
ซึ่งประเมินค่าของฟังก์ชันแอคเคอร์มานน์ ใช้ฟังก์ชันของเราเพื่อหา ack(3, 4)
ซึ่งควรจะมีค่าเป็น 125 เกิดอะไรขึ้นกับค่าของ m
และ n
ที่มากกว่านี้? เฉลย: http://thinkpython2.com/code/ackermann.py.
แบบฝึกหัด 3
พาลินโดรม (palindrome) คือ คำที่สะกดเหมือนกันทั้งเวลาเริ่มอ่านจากข้างหน้าและเริ่มอ่านจากข้างหลัง เช่น “noon” และ “redivider” ถ้าคิดแบบการเรียกซ้ำ คำใดๆ เป็นพาลินโดรม เมื่ออักษรตัวแรก และตัวสุดท้ายเป็นตัวเดียวกัน และตรงกลางเป็นพาลินโดรม
ต่อไปนี้ คือ ฟังก์ชันที่รับอาร์กิวเมนต์ที่เป็นสายอักขระและส่งค่าคืนกลับเป็น อักษรตัวแรก อักษรตัวสุดท้าย และกลุ่มอักษรตรงกลาง:
def first(word): return word[0] def last(word): return word[-1] def middle(word): return word[1:-1]
เราจะเห็นว่ามันทำงานอย่างไรใน บทที่ 8
palindrome.py
และทดสอบมันดู จะเกิดอะไรขึ้นถ้าเราเรียก middle
ด้วยสายอักขระที่มีสองตัวอักษร? แล้วถ้าเรียกด้วยตัวเดียวล่ะ? แล้วถ้าเป็นสายอักขระว่าง (empty string) ซึ่งเขียนว่า''
และไม่มีตัวอักษรเลยล่ะ?is_palindrome
ซึ่งรับอาร์กิวเมนต์ที่เป็นสายอักขระ และคืนค่า True
ถ้าเป็นพาลินโดรม และ False
ถ้าไม่ใช่ จำไว้ว่าเราสามารถใช้ฟังก์ชันที่มีอยู่ในตัว len
เพื่อที่จะตรวจสอบความยาวของสายอักขระเฉลย: http://thinkpython2.com/code/palindrome_soln.py.
แบบฝึกหัด 4
เลข $a$ เป็นค่ายกกำลังของ $b$ หากมันสามารถหารด้วย $b$ ลงตัว และ $a/b$ เป็นค่ายกกำลังของ $b$ ให้เขียนการเรียกฟังก์ชัน is_power
ซึ่งรับพารามิเตอร์ a
และ b
และคืนค่า True
ถ้า a
เป็นค่ายกกำลังของ b
. หมายเหตุ: เราจะต้องคิดเรื่องกรณีฐานด้วย
แบบฝึกหัด 5
ตัวหารร่วมมาก (ห.ร.ม.) หรือ the greatest common divisor (GCD) ของ $a$ และ $b$ คือเลขจำนวนที่มากที่สุดที่สามารถหารพวกมันได้โดยไม่มีเศษ
วิธีหนึ่งที่จะหาค่า ห.ร.ม. ของเลขสองตัวมาจากข้อสังเกตที่ว่า ถ้า $r$ เป็นเศษของการหาร เมื่อ $a$ ถูกหารด้วย $b$ แล้ว $gcd(a, b) = gcd(b, r)$ สำหรับกรณีฐาน เราสามารถใช้ $gcd(a, 0) = a$.
ให้เขียนฟังก์ชันที่ชื่อว่า gcd
ซึ่งรับพารามิเตอร์ a
และ b
และคืนค่า ตัวหารร่วมมาก
ขอบคุณ: แบบฝึกหัดนี้มาจากตัวอย่างจากหนังสือของ Abelson และ Sussman ชื่อว่า Structure and Interpretation of Computer Programs
https://greenteapress.com/thinkpython2/html/thinkpython2007.html