Closure คืออะไร? รู้จัก Free Variables และ Closure ในภาษา Python
ตัวแปร (variables) ไม่ว่าจะเป็นภาษาใดเมื่อเกิดขึ้นแล้วย่อมมีขอบเขต (scope) เมื่อขอบเขตที่ตัวแปรนั้นอาศัยอยู่สิ้นสุดลง โดยพื้นฐานแล้วตัวแปรนั้นควรตายไปพร้อมกับการดับสิ้นของ scope นั้น
1def my_family(last_name):2 def print_name(first_name):3 print('%s %s' % (first_name, last_name))45 # ฟังก์ชัน my_family คืนค่ากลับเป็นฟังก์ชัน print_name6 return print_name78haha_family = my_family('Haha')910# การเรียก haha_family คือการเรียกฟังก์ชัน print_name11haha_family('Somchai') # Somchai Haha12haha_family('Somsree') # Somsree Haha13haha_family('Somset') # Somset Haha
ตัวแปร last_name ของฟังก์ชัน my_family
เป็นตัวแปรที่รับเข้ามาเป็นพารามิเตอร์ของฟังก์ชัน มันจึงดูเหมือนเป็นส่วนหนึ่งของฟังก์ชันนี้ และควรจะถูกทำลายเมื่อฟังก์ชันนี้สิ้นสุดลง
บรรทัดที่ 8 ฟังก์ชัน my_family
ถูกเรียกพร้อมกำหนดค่าให้ last_name
เป็น Haha
ฟังก์ชันนี้ได้สิ้นสุดลงและดูเหมือนตัวแปร last_name
ควรจะหมดอายุขัยตามไปด้วย แต่ไม่เลยมันกลับถูกปลุกชีพอีกครั้งในบรรทัดที่ 3 ผ่านการเรียกของฟังก์ชัน print_name
ทำไมตัวแปร last_name
จึงไม่สิ้นชีพชีวาวายตามฟังก์ชันไปด้วย? เราจะมาหาคำตอบกันในบทความนี้กับเรื่องของ Free Variables และ Closure
รู้จักกับตัวแปรประเภท Free Variables
ตัวแปรในภาษา Python นั้นแบ่งหลัก ๆ ออกเป็นตัวแปรประเภท global, local variables และ free variables โดยตัวแปรไหนอยู่ในขอบเขตของโมดูลก็จะเรียกเป็น global variables แต่หากถูกนิยามภายใต้บลอคใดก็จะถือเป็น local variables ของบลอคนั้น
ทุกอย่างดูเหมือนจะตรงไปตรงมา แต่สำหรับ free variables นั้นจะแตกต่างไปนิดหน่อย เพราะตัวแปรไหนที่ถูกเรียกใช้ในบลอคนั้น แต่ไม่เคยมีการนิยามใต้บลอคนั้นมาก่อนนั่นแหละคือ free variables งงเด้ งงเด้
กลับมาดูโค้ดที่แสดงไว้ข้างต้นกันอีกรอบ
1def my_family(last_name):2 def print_name(first_name):3 print('%s %s' % (first_name, last_name))45 return print_name67haha_family = my_family('Haha')89haha_family('Somchai') # Somchai Haha10haha_family('Somsree') # Somsree Haha11haha_family('Somset') # Somset Haha
สำหรับฟังก์ชัน print_name นั้น ตัวแปร last_name ไม่ได้มีการนิยามภายใต้ฟังกชันนี้ แต่มันกลับถูกเรียกใช้งานเราจึงถือว่ามันเป็น free variables สำหรับฟังก์ชันนี้
ภาษา Python อนุญาตให้เราดูว่าฟังก์ชันไหนมี free variables อะไรบ้างผ่าน co_freevars
ดังนี้
1haha_family.__code__.co_freevars
ผลลัพธ์จากการทำงานโค้ดดังกล่าวคือ ('last_name',)
เป็นการบอกว่าฟังก์ชัน print_name มี free variables แค่ตัวเดียวคือ last_name
Closure คืออะไร
เราพบว่าแม้ฟังก์ชัน my_family จะถูกเรียกและสิ้นสุดขอบเขตของฟังก์ชันแล้ว แต่ตัวแปร last_name ซึ่งเป็นพารามิเตอร์ของฟังก์ชันกลับไม่ถูกทำลาย นั่นเพราะตัวแปรดังกล่าวถูกเรียกใช้งานในฟังก์ชัน print_name ที่อยู่ข้างในอีกทีนึง ด้วยเหตุนี้เราจึงกล่าวได้ว่าฟังก์ชัน print_name กำลังถูกขยายขอบเขตการมองเห็นตัวแปรที่มากขึ้น
Closure คือออบเจ็กต์ประเภทฟังก์ชันที่ถูกขยายขอบเขตให้มองเห็นตัวแปรของ scope ที่ห่อหุ้มมันอยู่ เช่นเดียวกับที่ print_name มองเห็นตัวแปรจาก scope ของ my_family โดยตัวแปรที่จะปรากฎในฟังก์ชัน closure นี้จะอยู่ในรูปของ co_cellvars
ของ scope ภายนอก
1my_family.__code__.co_cellvars # ('last_name',)
ด้วยเหตุที่ว่า last_name เป็น co_cellvars
ของ my_family ที่เป็น scope ห่อหุ้ม print_name อยู่ ดังนั้น print_name ซึ่งเป็นฟังก์ชันภายในจึงสามารถนำ last_name ไปใช้ต่อได้ในฐานะของ free variables โดยตัวแปรไม่ถูกทำลาย
ดึงค่า free variables ด้วย __closure__
แม้ว่า co_freevars
จะระบุให้เราทราบว่าตัวแปรใดบ้างเป็น free variables หากแต่ผลลัพธ์ที่เราได้กลับเป็นเพียง tuple ของชื่อตัวแปร ถ้าหากสิ่งที่เราต้องการจริงคือค่าของมันมิใช่ชื่อ เราสามารถทำได้หรือไม่?
free variables นั้นจะถูกจัดเก็บไว้ใน __closure__
หากเราต้องการเข้าถึงมันสามารถเรียกผ่าน attribute ดังกล่าวได้ ดังนี้
1# (<cell at 0x10c855558: str object at 0x10c860150>,)2print(haha_family.__closure__)
ค่าที่คืนกลับจาก attributes นี้สำหรับฟังก์ชันดังกล่าวคือ tuple ที่ประกอบไปด้วย free variables แต่เนื่องจากฟังก์ชันของเรามีเพียง last_name ที่เป็น free variables เท่านั้น จึงได้ผลลัพธ์เป็น tuple ที่มีสมาชิกเพียงหนึ่งเดียว
สิ่งที่คืนกลับจาก __closure__
นั้นเป็น tuple ของ cell หากเราต้องการดึงข้อมูลที่แท้จริงของ free variables เราต้องเรียก cell_contents
ผ่าน cell แต่ละตัว ดังนี้
1# Haha2print(haha_family.__closure__[0].cell_contents)
free variables และการแก้ไขค่าตัวแปร
สมมติเราต้องการสร้างฟังก์ชัน find_gpa
ที่คืนฟังก์ชันอีกตัวกลับออกมา เมื่อเรียกฟังก์ชันไส้ในดังกล่าวพร้อมส่งจำนวนหน่วยกิตและเกรดไปแล้วต้องคืนค่า GPA กลับออกมาด้วย ดังนี้
1def find_gpa():2 credits = 03 weight = 045 def avg(credit, grade):6 credits += credit7 weight += (grade * credit)89 return weight / credits1011 return avg1213gpa = find_gpa()14gpa(3, 4) # 3 หน่วยกิต ได้เกรด A15gpa(2, 3) # 2 หน่วยกิต ได้เกรด B16gpa(1, 4) # 1 หน่วยกิต ได้เกรด A
หลังจากรันโปรแกรมเพื่อทบสอบ Python ก็จะกร่นด่าเราด้วยข้อความนี้ UnboundLocalError: local variable 'credits' referenced before assignment
free variables ที่เราอ้างถึงนั้นสามารถอ่านค่าได้เท่านั้น ไม่สามารถเขียนค่าทับได้ นั่นเพราะตอนนี้ Python จะมองว่า credits ที่เรากำลังจะเขียนค่าทับนั้นหมายถึง local variables ไม่ใช่ free variables นี่จึงเป็นเหตุผลที่ว่าทำไมเราจึงไม่สามารถสะสมค่าทับลงไปใน credits ของบรรทัดที่ 6 ได้
อาศัยความจริงที่ว่าตัวแปรใดที่กำหนดค่าเป็นออบเจ็กต์ ตัวแปรนั้นไม่ได้เก็บออบเจ็กต์ไว้กับมันหากแต่เก็บตัวชี้ (reference) ไปหาออบเจ็กต์นั้น หากเรากำหนดค่า credits และ weight ไว้ในออบเจ็กต์แทน ข้อผิดพลาดนี้จะไม่เกิดขึ้นอีก
1def find_gpa():2 state = {}3 state['credits'] = 04 state['weight'] = 056 def avg(credit, grade):7 state['credits'] += credit8 state['weight'] += (grade * credit)910 return state['weight'] / state['credits']1112 return avg1314gpa = find_gpa()15print(gpa(3, 4)) # 4.016print(gpa(2, 3)) # 3.617print(gpa(1, 4)) # 3.6666666666666665
state เป็นตัวแปรที่ชี้ไปยังออบเจ็กต์ประเภท dict การสะสมค่าทับไปใน credits ของ dict สามารถทำได้เพราะตัวแปร state เองไม่ถูกเปลี่ยนค่ามันยังคงชี้ไปที่เดิมคือออบเจ็กต์ dict Python จึงไม่พยายามมองว่า state คือ local variables ของ avg
หากเราไม่ต้องการสร้างตัวแปรพิเศษเช่น state ก็สามารถเก็บค่าลงไปในฟังก์ชัน avg เลยก็ได้เช่นกัน
1def find_gpa():2 def avg(credit, grade):3 avg.credits += credit4 avg.weight += (grade * credit)56 return avg.weight / avg.credits78 avg.credits = 09 avg.weight = 01011 return avg1213gpa = find_gpa()14print(gpa(3, 4)) # 4.015print(gpa(2, 3)) # 3.616print(gpa(1, 4)) # 3.6666666666666665
การนำค่าไปแปะบน avg เช่นนี้ ส่งผลให้ avg มีสถานะเป็น free variables ของตัวมันเอง
1print(gpa.__code__.co_freevars) # ('avg',)
รู้จักกับคีย์เวิร์ด nonlocal
สาเหตุที่เราแก้ไข free variables ไม่ได้ดั่งใจคิด นั่นเพราะ Python มองตัวแปรที่กำลังจะแก้ไขเป็น local variables แทน หากเราสามารถบอก Python ได้ว่าตัวแปรที่กำลังใช้งานอยู่นี้ให้ปรากฎในฐานะของ free variables และการแก้ไขใดให้กระทำโดยตรงไปที่ free variables นั้น หากทำเช่นนี้ได้ปัญหาของเราจึงถือว่าได้รับการแก้ไข
nonlocal เป็นคีย์เวิร์ดที่ใช้เพื่อสื่อความว่าตัวแปรดังกล่าวให้หมายถึง free variables ดังนี้
1def find_gpa():2 credits = 03 weight = 045 def avg(credit, grade):6 nonlocal credits, weight78 credits += credit9 weight += (grade * credit)1011 return weight / credits1213 return avg1415gpa = find_gpa()16print(gpa(3, 4)) # 4.017print(gpa(2, 3)) # 3.618print(gpa(1, 4)) # 3.6666666666666665
ด้วยความช่วยเหลือของ nonlocal ทำให้ตัวแปร credits และ weight ที่เราอ้างถึงใน avg จะไม่ใช่ local variables อีกต่อไป
สรุป
Closure คือฟังก์ชันที่ได้รับการขยายขอบเขตการมองเห็นตัวแปรประเภท free variables ที่อยู่ใน scope ที่ห่อหุ้มมันอยู่ เราจึงสามารถเรียกใช้เพื่อเข้าถึงค่าต่าง ๆ ของ free variables เหล่านั้นได้ แต่เมื่อใดที่ตัวแปรเหล่านี้จะถูกแก้ไขในฟังก์ชัน closure มันจะไม่ใช่ free variables อีกต่อไป หากแต่เป็น local variables ของฟังก์ชัน closure นั้น หากเราต้องการให้ตัวแปรนั้นหมายถึง free variables เพื่อให้การแก้ไขนั้นกระทำได้ถูกต้อง เราต้องใช้คีย์เวิร์ดคือ nonlocal กำกับตัวแปรไว้นั่นเอง
สารบัญ
- รู้จักกับตัวแปรประเภท Free Variables
- Closure คืออะไร
- ดึงค่า free variables ด้วย __closure__
- free variables และการแก้ไขค่าตัวแปร
- รู้จักกับคีย์เวิร์ด nonlocal
- สรุป