User Tools

Site Tools


python:function_with_return

6. ฟังก์ชันที่ให้ผล

ฟังก์ชันไพธอนหลายอันที่เราได้ใช้มา เช่น ฟังก์ชันทางคณิตศาสตร์ จะให้ค่าที่คืนกลับไป ยังผู้เรียก (return value) แต่ฟังก์ชันที่เราเขียนขึ้นมาเองจนถึงตอนนี้เป็นฟังก์ชันที่ไม่คืนค่า พวกมันมีการทำงาน เช่น พิมพ์ค่า หรือทำให้ turtle เคลื่อนที่ แต่มันไม่มีค่าที่จะคืน กลับไป ในบทนี้ เราจะเรียนรู้เกี่ยวกับการเขียนฟังก์ชันที่มีค่าที่คืนกลับไป หรือ ฟังก์ชัน ที่ให้ผล (fruitful function)

6.1 ค่าคืนกลับ

การเรียกฟังก์ชันจะทำให้เกิดค่าที่ถูกส่งคืนกลับมา ซึ่งโดยปกติแล้วเรากำหนดค่านี้ให้กับตัวแปร หรือใช้เป็นส่วนหนึ่งของนิพจน์

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

6.2 การพัฒนาโปรแกรมแบบเพิ่มส่วน

เมื่อเราเขียนฟังก์ชันที่ใหญ่ขึ้น เราอาจจะเจอว่าเราใช้เวลาในการดีบักนานขึ้น

เพื่อที่จะจัดการกับโปรแกรมที่ซับซ้อนมากขึ้นเรื่อยๆ เราอาจจะพยายามทำกระบวนการที่เรียกว่า การพัฒนาโปรแกรมแบบเพิ่มส่วน (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 บรรทัด แต่เมื่อเรามีประสบการณ์ ในการเขียนโปรแกรมมากขึ้นแล้ว เราจะพบว่าเราจะเขียนและดีบักโค้ดก้อนที่ใหญ่ขึ้น ไม่ว่าจะแบบไหนก็ตาม การพัฒนาโปรแกรมแบบเพิ่มส่วนสามารถทำให้เราประหยัดเวลาดีบักได้มาก

ลักษณะสำคัญของขั้นตอนเหล่านี้คือ:

  1. เริ่มด้วยโปรแกรมที่ทำงานได้และเพิ่มขึ้นทีละน้อย ณ จุดใดๆ ถ้ามีข้อผิดพลาดเราจะรู้ดี ว่ามันอยู่ตรงไหน
  2. ใช้ตัวแปรในการเก็บค่าที่ใช้ระหว่างทาง เพื่อที่เราจะสามารถแสดงและตรวจสอบค่ามันได้
  3. เมื่อโปรแกรมทำงานได้แล้ว เราอาจจะต้องเอาโค้ดนั่งร้านออก หรือ รวมคำสั่งหลายคำสั่งเข้าเป็น นิพจน์ประกอบ แต่ทำเฉพาะกรณีที่มันไม่ทำให้โปรแกรมนั้นอ่านยากขึ้น

เพื่อเป็นการฝึกทำ ให้ใช้การพัฒนาโปรแกรมแบบเพิ่มส่วนเขียนฟังก์ชันที่ชื่อว่า hypotenuse ซึ่ง คืนค่าเป็นความยาวของด้านตรงข้ามของสามเหลี่ยมมุมฉาก (hypotenuse) เมื่อกำหนดความยาวของ สองด้านที่เหลือให้เป็นอาร์กิวเมนต์ บันทึกแต่ละช่วงของขั้นตอนการพัฒนาควบคู่ไปกับการทำ

6.3 การประกอบ

ถึงตอนนี้เราควรจะคาดได้แล้วว่า เราสามารถเรียกฟังก์ชันหนึ่งจากข้างในฟังก์ชันอีกอันหนึ่ง เช่น เราจะเขียนฟังก์ชันที่รับจุดสองจุด คือ จุดกึ่งกลางของวงกลม และจุดที่อยู่บนเส้นรอบวง และคำนวณพื้นให้ของวงกลม

สมมติว่าจุดกึ่งกลางนั้นถูกเก็บอยู่ในตัวแปร 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))

6.4 ฟังก์ชันบูลีน

ฟังก์ชันสามารถคืนค่าเป็นชนิดบูลีนได้ ซึ่งหลายครั้งทำให้สะดวกสำหรับการซ่อนการทดสอบ ที่ซับซ้อนในฟังก์ชัน ตัวอย่างเช่น:

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

6.5 การเรียกซ้ำ เพิ่มเติม

เราได้เรียนรู้ไปเพียงแค่ซับเซ็ตเล็กๆ ของไพธอน แต่เราอาจจะสนใจที่รู้ว่าซับเซ็ตอันนี้มัน เป็นภาษาโปรแกรมที่ สมบูรณ์ แล้ว ซึ่งหมายความว่า อะไรก็ตามที่สามารถคำนวณได้ จะสามารถเขียนแทนได้ด้วยภาษานี้ โปรแกรมใดๆ ที่เคยถูกเขียนมาจะสามารถถูกเขียนใหม่ได้ โดยใช้แค่คุณลักษณะที่เราได้เรียนมาจนบัดนี้ (ที่จริงแล้ว แล้วต้องใช้คำสั่งอย่างอื่นอีกหน่อย เพื่อที่จะควบคุมเม้าส์ ดิสก์ และอื่นๆ แต่ก็เท่านั้นแหละ)

การพิสูจน์ข้อกล่าวอ้างนี้ เป็นอะไรที่ไม่ง่ายนักซึ่งทำสำเร็จเป็นครั้งแรกโดยอลัน ทัวริง (Alan Turing) หนึ่งในนักวิทยาการคอมพิวเตอร์คนแรกๆ (บางคนอาจจะเถียงว่าเขาเป็นนักคณิตศาสตร์ แต่ว่า นักวิทยาการคอมพิวเตอร์หลายคนก็เริ่มด้วยการเป็นนักคณิตศาสตร์) ดังนั้น มันจึงเป็นที่รู้จักกัน ในชื่อ ข้อวินิจฉัยของทัวริง (Turing Thesis) ผมแนะนำให้อ่านหนังสือของไมเคิล ซิปเซอร์ (Michael Sipser) ที่ชื่อว่า Introduction to the Theory of Computation

เพื่อที่จะทำให้เห็นว่าเราสามารถทำอะไรกับเครื่องมือที่เราเรียนมาได้บ้าง เราจะประเมินฟังก์ชันทาง คณิตศาสตร์ที่ถูกเขียนแบบเรียกซ้ำ (recursive) สองสามตัวอย่าง นิยามการเรียกซ้ำนั้นเหมือนกับ นิยามการเวียน (circular definition) ในแง่ที่ว่านิยามมีการอ้างอิงไปยังสิ่งที่มันกำลังถูกนิยามอยู่ (อ้างอิงไปถึงตัวมันเอง) นิยามการเวียนของจริงไม่ค่อยมีประโยชน์เท่าไหร่นัก:

  • vorpal: คือ คุณศัพท์ที่ใช้บรรยายบางสิ่งที่มีลักษณะ vorpal

ถ้าเราเห็นนิยามนี้ในพจนานุกรม เราอาจจะรำคาญได้ ในอีกแง่หนึ่ง ถ้าเราเปิดดูนิยามของฟังก์ชัน แฟกทอเรียล ที่แสดงด้วยเครื่องหมาย $!$ เราก็จะได้อะไรประมาณนี้ $$\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 แสดงให้เห็นว่าแผนภาพแบบกองซ้อนสำหรับเป็นอย่างไร สำหรับลำดับการเรียกฟังก์ชันนี้

 แผนภาพแบบกองซ้อน
รูปที่ 6.1 แผนภาพแบบกองซ้อน

ค่าคืนกลับถูกแสดงว่ากำลังถูกส่งกลับขึ้นไปบนกอง (stack) ในแต่ละกรอบ ค่าคืนกลับ คือ ค่าของ result ซึ่งเป็นผลคูณของ n และ recurse

ในกรอบสุดท้าย มันไม่มีตัวแปรเฉพาะที่ recurse และ result เพราะว่า แขนงที่ทำให้มันเกิดขึ้นนั้นไม่ได้ทำงาน

6.6 การปล่อยไปตามโชคชะตา

การไล่ตามกระแสการดำเนินการเป็นหนทางหนึ่งที่จะอ่านโปรแกรม แต่มันก็สามารถทำให้เรารู้สึกท่วมท้นได้เร็ว อีกทางเลือกหนึ่ง คือ สิ่งที่ผมเรียกว่า “การปล่อยไปตามโชคชะตา (Leap of faith)” เมื่อเรามาถึง การเรียกฟังก์ชัน แทนที่จะไล่ตามกระแสการดำเนินการ เราจะ สมมติ ว่าฟังก์ชันทำงานได้อย่าง ถูกต้อง และคืนค่าที่ถูกต้องกลับมาให้

ที่จริงแล้ว เราได้ฝึกการปล่อยไปตามโชคชะตามาแล้ว เมื่อเราใช้ฟังก์ชันที่มีอยู่ในตัว (built-in) เมื่อเราเรียก math.cos หรือ math.exp เราไม่ได้สำรวจส่วนตัวของฟังก์ชันดังกล่าวเลย เราแค่สมมติว่ามันทำงานได้ เพราะว่าคนที่เขียนฟังก์ชันพวกนี้เป็นโปรแกรมเมอร์ที่เก่ง

เป็นจริงเช่นเดียวกันเมื่อเราเรียกฟังก์ชันของเราเอง เช่น ในหัวข้อ 6.4 เราเขียนฟังก์ชันที่ชื่อว่า is_divisible ซึ่งหาว่าเลขหนึ่งถูกเลขอีกตัวหนึ่งหารลงตัวหรือไม่ เมื่อเรามั่นใจแล้ว ว่าฟังก์ชันนี้น่าจะทำงานถูกต้อง—โดยการสำรวจโค้ดและทดสอบมัน—เราสามารถใช้ฟังก์ชัน นี้ไปเลยโดยไม่ต้องดูส่วนของตัวฟังก์ชันอีก

เป็นจริงเช่นเดียวกันกับโปรแกรมเรียกซ้ำ เมื่อเราทำการเรียกซ้ำ แทนที่เราจะไล่ตามกระแสการดำเนินการ เราควรจะสมมติว่าการเรียกซ้ำนั้นทำงานได้ (คืนค่าที่ถูกต้องกลับมา) และจากนั้นให้ถามตัวเองว่า “สมมติว่าเราสามารถหาค่าแฟกทอเรียบของ $n-1$ แล้ว เราจะสามารถคำนวณค่าแฟกทอเรียลของ $n$ ได้หรือเปล่า” มันชัดเจนว่าเราสามารถทำได้ โดยการคูณค่า $n$ เข้าไป

แน่นอนว่ามันแปลกหน่อยๆ ที่จะสมมติว่าฟังก์ชันมันทำงานได้ถูกต้อง เมื่อเรายังเขียนไม่เสร็จเลย แต่มันก็เป็นเหตุผลที่ว่าทำไมเราถึงเรียกมันว่า การปล่อยไปตามโชคชะตา ยังไงล่ะ!

6.7 อีกตัวอย่างหนึ่ง

หลังจากฟังก์ชัน 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$ หัวของเราจะระเบิด แต่จากหลักการปล่อยให้เป็นไปตามโชคชะตาแล้ว ถ้าเราสมมติว่าการเรียกซ้ำทั้งสองนั้นทำงาน ได้อย่างถูกต้องแล้ว มันก็จะชัดเจนว่าเราจะได้ผลลัพธ์ที่ถูกต้องจากการบวกค่าทั้งสองเข้า ด้วยกัน

6.8 การตรวจสอบชนิดของข้อมูล

จะเกิดอะไรขึ้นถ้าเราเรียกฟังก์ชัน 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)

6.9 การดีบัก

การแตกโปรแกรมใหญ่ๆ ให้เป็นฟังก์ชันเล็กๆ ทำให้เกิดจุดตรวจสอบโค้ด (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

ถ้าเรางงเกี่ยวกับกระแสการดำเนินการ เอ้าต์พุตลักษณะนี้จะมีประโยชน์ มันใช้เวลาระยะหนึ่งในการพัฒนา นั่งร้านที่มีประสิทธิภาพ แต่การสร้างนั่งร้านขึ้นมาเล็กน้อยนั้นสามารถประหยัดการดีบักได้มาก

6.10 อภิธานศัพท์

  • ตัวแปรชั่วคราว (temporary variable): ตัวแปรที่ใช้เก็บค่าระหว่างทางในการคำนวณที่ซับซ้อน
  • โค้ดตาย (dead code): ส่วนของโปรแกรมที่ไม่มีวันทำงาน บ่อยครั้งเพราะมันอยู่หลังจากคำสั่ง return
  • การพัฒนาแบบเพิ่มส่วน (incremental development): แผนการพัฒนาโปรแกรมที่มุ่งหลีกเลี่ยงการดีบัก โดยการคูณค่า เพิ่มและทดสอบโค้ดทีละน้อย
  • นั่งร้าน (scaffolding): โค้ดที่ใช้ในระหว่างการพัฒนาโปรแกรม แต่ไม่ใช่ส่วนหนึ่งของโปรแกรมเวอร์ชันสุดท้าย
  • ผู้พิทักษ์ (guardian): รูปแบบการเขียนโปรแกรมที่ใช้คำสั่งเงื่อนไขเพื่อตรวจสอบและจัดการกับเหตุการณ์ที่อาจจะทำให้เกิดข้อผิดพลาด

6.11 แบบฝึกหัด

แบบฝึกหัด 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

  1. ให้พิมพ์ฟังก์ชันเหล่านี้ลงในไฟล์ชื่อว่า palindrome.py และทดสอบมันดู จะเกิดอะไรขึ้นถ้าเราเรียก middle ด้วยสายอักขระที่มีสองตัวอักษร? แล้วถ้าเรียกด้วยตัวเดียวล่ะ? แล้วถ้าเป็นสายอักขระว่าง (empty string) ซึ่งเขียนว่า'' และไม่มีตัวอักษรเลยล่ะ?
  2. ให้เขียนฟังก์ชันชื่อว่า 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

python/function_with_return.txt · Last modified: 2021/08/30 09:55 (external edit)

Page Tools