Table of Contents

16. คลาสและฟังก์ชัน

ตอนนี้เราได้รู้วิธีการสร้างชนิดข้อมูลใหม่ ขั้นต่อไป เป็นการเขียนฟังก์ชัน ที่รับออบเจ๊คต์ชนิดผู้เขียนโปรแกรม กำหนดขึ้นเป็นพารามิเตอร์ และให้ค่าออกมาเป็นออบเจ๊คต์เหล่านั้น ในบทนี้ได้นำเสนอ “การเขียนโปรแกรมเชิงฟังก์ชัน (functional programming style)” และ แผนการพัฒนาสองโปรแกรมใหม่

ตัวอย่างโปรแกรมของบทนี้สามารถดาวน์โหลดได้ที่ http://thinkpython2.com/code/Time1.py เฉลยสำหรับแบบฝึกหัดอยู่ที่ http://thinkpython2.com/code/Time1_soln.py

16.1 เวลา

เช่นกันกับตัวอย่างอื่นของชนิดข้อมูลที่ผู้เขียนโปรแกรมกำหนดเอง เราจะประกาศคลาส Time ซึ่งใช้บันทึกเวลาของวัน นิยามของคลาสมีลักษณะดังนี้

class Time:
    """Represents the time of day.
 
    attributes: hour, minute, second
    """

เราสามารถสร้างออบเจ๊คต์ใหม่ของ Time และกำหนดค่าของแอตทริบิวต์ชั่วโมง (hour) นาที (minute) และ วินาที (second)

time = Time()
time.hour = 11
time.minute = 59
time.second = 30

แผนภาพสถานะของออบเจ๊คต์ Time มีลักษณะดังแสดงในรูปที่ 16.1

เพื่อเป็นการฝึก ให้เขียนฟังก์ชันชื่อ print_time ซึ่งรับออบเจ๊คต์ Time และพิมพ์ค่าเวลาในรูปแบบ ชั่วโมง:นาที:วินาที ข้อแนะนำคือ ใช้รูปแบบ '%.2d' สำหรับพิมพ์ตัวเลขอย่างน้อยสองตำแหน่ง โดยจะเติมศูนย์ด้านหน้ากรณีค่าน้อยกว่าสิบ

เขียนฟังก์ชันบูลีนชื่อ is_after ซึ่งรับออบเจ๊คต์ของ Time สองออบเจ๊คต์ t1 และ t2 แล้วให้ค่าออกมาเป็น True ถ้าเวลา t1 ตามหลังเวลา t2 ตามลำดับเหตุการณ์ ไม่เช่นนั้นจะให้ค่าออกมาเป็น False ท้าทาย: ลองเขียนฟังก์ชันโดยไม่ใช้คำสั่ง if

 แผนภาพออบเจ๊คต์
รูปที่ 16.1 แผนภาพออบเจ๊คต์

16.2 ฟังก์ชันบริสุทธิ์

ในหัวข้อถัดๆ ไป เราจะเขียนสองฟังก์ชันสำหรับบวกเวลาซึ่งแสดงรูปแบบการเขียนฟังก์ชันไว้สองแบบคือ แบบฟังก์ชันบริสุทธิ์ (pure function) และ แบบตัวดัดแปลง (modifier) และได้แสดงแผนการพัฒนาที่เรียกว่า วิธี ต้นแบบและเติมแต่ง (prototype and patch) ซึ่งเป็นวิธีการแก้ปัญหาที่ซับซ้อนโดยเริ่มจากต้นแบบอย่างง่าย และเพิ่มเติมการจัดการกับความซับซ้อนอย่างค่อยเป็นค่อยไป

นี่เป็นต้นแบบอย่างง่ายของฟังก์ชัน add_time

def add_time(t1, t2):
    sum = Time()
    sum.hour = t1.hour + t2.hour
    sum.minute = t1.minute + t2.minute
    sum.second = t1.second + t2.second
    return sum

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

เพื่อการทดสอบฟังก์ชันจึงสร้างออบเจ๊คต์ของ Time ขึ้นมาสองออบเจ๊คต์ ออบเจ๊คต์ start ใช้เก็บค่าเวลาเริ่มต้นของภาพยนต์ เช่นเรื่อง ไพธอนยักษ์กับจอกศักดิ์สิทธิ์ (Monty Python and the Holy Grail) และออบเจ๊คต์ duration สำหรับเก็บค่าเวลาที่ใช้ในการฉายภาพยนต์ ซึ่งจะเป็น 1 ชั่วโมง 35 นาที

ฟังก์ชัน add_time จะหาว่าภาพยนต์ถูกฉายจบตอนไหน

>>> start = Time()
>>> start.hour = 9
>>> start.minute = 45
>>> start.second =  0
 
>>> duration = Time()
>>> duration.hour = 1
>>> duration.minute = 35
>>> duration.second = 0
 
>>> done = add_time(start, duration)
>>> print_time(done)
10:80:00

ผลลัพธ์คือ 10:80:00 อาจจะไม่ใช่อย่างที่เราหวังไว้ ปัญหาคือ ฟังก์ชันไม่ได้จัดการกับกรณีจำนวนเวลาวินาทีและนาทีที่มีผลรวมเกิน 60 เมื่อเกิดเหตุการณ์ดังกล่าวเราจะต้อง “ทด” เวลาส่วนเกินของวินาทีไปที่นาที และยกยอดนาทีส่วนเกินไปเพิ่มให้กับชั่วโมง

นี่คือฟังก์ชันรุ่นที่ได้รับการปรับปรุง

def add_time(t1, t2):
    sum = Time()
    sum.hour = t1.hour + t2.hour
    sum.minute = t1.minute + t2.minute
    sum.second = t1.second + t2.second
 
    if sum.second >= 60:
        sum.second -= 60
        sum.minute += 1
 
    if sum.minute >= 60:
        sum.minute -= 60
        sum.hour += 1
 
    return sum

แม้ว่าฟังก์ชันนี้จะถูกต้องแล้ว แต่ก็ค่อนข้างใหญ่ เราจะมาดูเวอร์ชันที่สั้นกว่านี้ในภายหลัง

16.3 ตัวดัดแปลง

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

ฟังก์ชัน increment ทำการบวกเพิ่มตัวเลขจำนวนวินาทีให้กับออบเจ๊คต์ Time สามารถเขียนเป็นตัวดัดแปลงได้อย่างเป็นธรรมชาติ และนี่เป็นร่างคร่าวๆ

def increment(time, seconds):
    time.second += seconds
 
    if time.second >= 60:
        time.second -= 60
        time.minute += 1
 
    if time.minute >= 60:
        time.minute -= 60
        time.hour += 1

บรรทัดแรกทำหน้าที่บวกเพิ่มวินาที ส่วนที่เหลือเป็นการจัดการกับกรณีพิเศษที่เราเจอก่อนหน้านี้

ฟังก์ชันนี้ทำงานได้ถูกต้องหรือไม่? อะไรจะเกิดขึ้นถ้าค่าของ วินาที มีค่ามากกว่า 60 มากๆ?

ในกรณีนั้น การทดค่าเพียงแค่ครั้งเดียวจะยังไม่เพียงพอ เราจะต้องทำซ้ำจนกว่าค่าของ time.second จะน้อยกว่า 60 แนวทางหนึ่งที่ทำได้คือ แทนที่คำสั่ง if ด้วยคำสั่ง while จะทำให้การทำงานของฟังก์ชันถูกต้องแต่ก็ไม่ค่อยมีประสิทธิภาพนัก ทำเป็นแบบฝึกหัด คือ เขียนฟังก์ชัน increment เวอร์ชันที่ถูกต้องแต่ไม่มีการทำงานแบบวนซ้ำ

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

โดยทั่วไป ผมแนะนำให้คุณเขียนฟังก์ชันบริสุทธิ์เมื่อใดก็ตามที่สมเหตุสมผล และใช้ตัวดัดแปลงเฉพาะเมื่อมีข้อได้เปรียบที่น่าสนใจเท่านั้น แนวทางนี้อาจเรียกว่ารูปแบบ การเขียนโปรแกรมเชิงฟังก์ชัน (functional programming style)

เพื่อเป็นการฝึก ให้เขียนฟังก์ชัน increment ที่เป็นรุ่น “บริสุทธิ์ (pure)” ซึ่งจะสร้างออบเจ๊คต์ Time ขึ้นมาใหม่และให้ค่าคืนกลับเป็นออบเจ๊คต์นั้นแทนการไก้ไขค่าของพารามิเตอร์

16.4 การสร้างต้นแบบเทียบกับการวางแผน

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

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

อีกทางเลือกหนึ่งคือ การออกแบบแล้วพัฒนา (designed development) ซึ่งมีการเข้าใจปัญหาในระดับสูงช่วยทำให้การเขียนโปรแกรมง่ายขึ้นมาก ในกรณีนี้ความเข้าใจระดับสูงคือ ออบเจ๊คต์ Time เป็นเลขฐาน 60 จำนวน 3 ตำแหน่ง ! (ดูข้อมูลเพิ่มเติม http://en.wikipedia.org/wiki/Sexagesimal) โดยมีวินาทีเป็นหลักหน่วย มีนาทีเป็นหลัก 60 และชั่วโมงเป็นหลัก 3600

เมื่อเราเขียนฟังก์ชัน add_time และ increment เราได้ทำการบวกในฐาน 60 และนี่คือเหตุผลว่าทำไมเราจึงทดจากหลักหนึ่งไปยังหลักถัดไป

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

นี่คือฟังก์ชันที่แปลงเวลาเป็นจำนวนเต็ม

def time_to_int(time):
    minutes = time.hour * 60 + time.minute
    seconds = minutes * 60 + time.second
    return seconds

และนี่คือฟังก์ชันที่แปลงจำนวนเต็มเป็นเวลา (พึงระลึกว่าฟังก์ชัน divmod หารอาร์กูเมนต์แรกด้วยอาร์กูเมนต์ที่สองแล้วให้ค่าออกมาเป็นผลหารและเศษในรูปของทูเพิล)

def int_to_time(seconds):
    time = Time()
    minutes, time.second = divmod(seconds, 60)
    time.hour, time.minute = divmod(minutes, 60)
    return time

อาจจะต้องใช้เวลาคิดสักหน่อยหรือรันทดสอบเพื่อให้มั่นใจว่าฟังก์ชันนี้ทำงานได้ถูกต้อง วิธีหนึ่งที่จะช่วยทดสอบได้คือ ตรวจสอบว่า time_to_int(int_to_time(x)) == x สำหรับหลายๆ ค่าของ x นี่เป็นตัวอย่างของการตรวจสอบความสอดคล้อง

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

def add_time(t1, t2):
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

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

บางทีการแปลงค่าจากฐาน 60 เป็นฐาน 10 ไปและกลับก็ยากกว่าการจัดการกับเวลา การแปลงฐานเข้าใจได้ยากกว่า สัญชาตญาณของเราจึงบอกว่าการจัดการกับเวลาน่าจะเป็นวิธีที่ดีกว่า

แต่ถ้าเราเข้าใจได้อย่างถ่องแท้เรื่องการจัดการกับเลขฐาน 60 และได้ลงทุนเขียนฟังก์ชันการแปลงแล้ว (time_to_int และ int_to_time) เราจะได้โปรแกรมที่สั้นกว่า ง่ายต่อการทำความเข้าใจและแก้ไข แถมยังน่าเชื่อถือกว่า

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

พูดอย่างกระทบกระเทียบ บางครั้งทำปัญหาให้ยากกว่า (มีความกว้างขวางกว่า) ทำให้ทำได้ง่ายกว่า เพราะว่ามีกรณีเฉพาะน้อยกว่าและมีโอกาสผิดพลาดน้อยกว่า

16.5 การดีบัก

ออบเจ๊คต์ Time เป็นรูปแบบที่ดีถ้าค่าของนาทีและวินาทีมีค่าเป็นจำนวนเต็มระหว่าง 0 ถึง 60 (รวม 0 แต่ไม่รวม 60) และค่าชั่วโมงเป็นเลขบวก ชั่วโมง และ นาที ควรเป็นจำนวนเต็ม เราอาจจะอนุญาตให้วินาทีมีค่าเป็นทศนิยมได้

ข้อกำหนดเหล่านี้ถูกเรียกว่า ความคงที่ (invariants) เพราะจะต้องเป็นจริงเสมอ ถ้ามีอะไรที่ไม่สอดคล้อง คือ ไม่ถูกต้องตามข้อกำหนด จะทำให้เกิดข้อผิดพลาดบางประการขึ้น

การเขียนคำสั่งสำหรับตรวจสอบความคงที่ของข้อมูลสามารถช่วยตรวจสอบข้อผิดพลาดและช่วยหาสาเหตุของข้อผิดพลาดได้ ตัวอย่างเช่น เราอาจจะมีฟังก์ชัน valid_time ที่รับออบเจ๊คต์ Time และให้ค่าออกมาเป็น False ถ้ามีการละเมิดข้อกำหนด

def valid_time(time):
    if time.hour < 0 or time.minute < 0 or time.second < 0:
        return False
    if time.minute >= 60 or time.second >= 60:
        return False
    return True

ในช่วงเริ่มต้นของแต่ละฟังก์ชัน เราสามารถตรวจสอบอาร์กูเมนต์เพื่อให้มันใจว่ามีความถูกต้อง

def add_time(t1, t2):
    if not valid_time(t1) or not valid_time(t2):
        raise ValueError('invalid Time object in add_time')
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

หรือเราอาจใช้คำสั่ง assert ซึ่งจะตรวจสอบข้อกำหนดความคงที่และสร้างเอ็กเซ็ปชั่นถ้าไม่ผ่านข้อกำหนด

def add_time(t1, t2):
    assert valid_time(t1) and valid_time(t2)
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

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

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

16.7 แบบฝึกหัด

ตัวอย่างโปรแกรมของบทนี้สามารถดาวน์โหลดได้ที่ http://thinkpython2.com/code/Time1.py เฉลยสำหรับแบบฝึกหัดอยู่ที่ http://thinkpython2.com/code/Time1_soln.py

แบบฝึกหัด 1
เขียนฟังก์ชันชื่อ mul_time ซึ่งรับออบเจ๊คต์ Time และตัวเลข แล้วให้ค่าออกมาเป็นออบเจ๊คต์ Time ใหม่ ที่บรรจุผลคูณของเวลาและตัวเลขนั้น

จากนั้นใช้ฟังก์ชัน mul_time เพื่อเขียนฟังก์ชันซึ่งรับออบเจ๊คต์ Time ซึ่งเป็นเวลาที่ใช้เพื่อไปถึงเส้นชัยในการแข่งขันและรับตัวเลขที่เป็นระยะทาง จากนั้นให้ค่าออกมาเป็นออบเจ๊คต์ Time ที่แสดงค่าก้าวเฉลี่ย (average pace: เวลาต่อไมล์)

แบบฝึกหัด 2
โมดูล datetime มีออบเจ๊คต์ time ที่คล้ายกับออบเจ๊คต์ Time ในบทนี้ แต่มีเมธอดและตัวดำเนินการมากมาย ศึกษาเอกสารได้ที่ http://docs.python.org/3/library/datetime.html

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

เฉลย: http://thinkpython2.com/code/double.py

https://greenteapress.com/thinkpython2/html/thinkpython2017.html