Closure คืออะไร? รู้จัก Free Variables และ Closure ในภาษา Python

Nuttavut Thongjor

ตัวแปร (variables) ไม่ว่าจะเป็นภาษาใดเมื่อเกิดขึ้นแล้วย่อมมีขอบเขต (scope) เมื่อขอบเขตที่ตัวแปรนั้นอาศัยอยู่สิ้นสุดลง โดยพื้นฐานแล้วตัวแปรนั้นควรตายไปพร้อมกับการดับสิ้นของ scope นั้น

Python
1def my_family(last_name):
2 def print_name(first_name):
3 print('%s %s' % (first_name, last_name))
4
5 # ฟังก์ชัน my_family คืนค่ากลับเป็นฟังก์ชัน print_name
6 return print_name
7
8haha_family = my_family('Haha')
9
10# การเรียก haha_family คือการเรียกฟังก์ชัน print_name
11haha_family('Somchai') # Somchai Haha
12haha_family('Somsree') # Somsree Haha
13haha_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 งงเด้ งงเด้

กลับมาดูโค้ดที่แสดงไว้ข้างต้นกันอีกรอบ

Python
1def my_family(last_name):
2 def print_name(first_name):
3 print('%s %s' % (first_name, last_name))
4
5 return print_name
6
7haha_family = my_family('Haha')
8
9haha_family('Somchai') # Somchai Haha
10haha_family('Somsree') # Somsree Haha
11haha_family('Somset') # Somset Haha

สำหรับฟังก์ชัน print_name นั้น ตัวแปร last_name ไม่ได้มีการนิยามภายใต้ฟังกชันนี้ แต่มันกลับถูกเรียกใช้งานเราจึงถือว่ามันเป็น free variables สำหรับฟังก์ชันนี้

ภาษา Python อนุญาตให้เราดูว่าฟังก์ชันไหนมี free variables อะไรบ้างผ่าน co_freevars ดังนี้

Python
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 ภายนอก

Python
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 ดังกล่าวได้ ดังนี้

Python
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 แต่ละตัว ดังนี้

Python
1# Haha
2print(haha_family.__closure__[0].cell_contents)

free variables และการแก้ไขค่าตัวแปร

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

Python
1def find_gpa():
2 credits = 0
3 weight = 0
4
5 def avg(credit, grade):
6 credits += credit
7 weight += (grade * credit)
8
9 return weight / credits
10
11 return avg
12
13gpa = find_gpa()
14gpa(3, 4) # 3 หน่วยกิต ได้เกรด A
15gpa(2, 3) # 2 หน่วยกิต ได้เกรด B
16gpa(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 ไว้ในออบเจ็กต์แทน ข้อผิดพลาดนี้จะไม่เกิดขึ้นอีก

Python
1def find_gpa():
2 state = {}
3 state['credits'] = 0
4 state['weight'] = 0
5
6 def avg(credit, grade):
7 state['credits'] += credit
8 state['weight'] += (grade * credit)
9
10 return state['weight'] / state['credits']
11
12 return avg
13
14gpa = find_gpa()
15print(gpa(3, 4)) # 4.0
16print(gpa(2, 3)) # 3.6
17print(gpa(1, 4)) # 3.6666666666666665

state เป็นตัวแปรที่ชี้ไปยังออบเจ็กต์ประเภท dict การสะสมค่าทับไปใน credits ของ dict สามารถทำได้เพราะตัวแปร state เองไม่ถูกเปลี่ยนค่ามันยังคงชี้ไปที่เดิมคือออบเจ็กต์ dict Python จึงไม่พยายามมองว่า state คือ local variables ของ avg

หากเราไม่ต้องการสร้างตัวแปรพิเศษเช่น state ก็สามารถเก็บค่าลงไปในฟังก์ชัน avg เลยก็ได้เช่นกัน

Python
1def find_gpa():
2 def avg(credit, grade):
3 avg.credits += credit
4 avg.weight += (grade * credit)
5
6 return avg.weight / avg.credits
7
8 avg.credits = 0
9 avg.weight = 0
10
11 return avg
12
13gpa = find_gpa()
14print(gpa(3, 4)) # 4.0
15print(gpa(2, 3)) # 3.6
16print(gpa(1, 4)) # 3.6666666666666665

การนำค่าไปแปะบน avg เช่นนี้ ส่งผลให้ avg มีสถานะเป็น free variables ของตัวมันเอง

Python
1print(gpa.__code__.co_freevars) # ('avg',)

รู้จักกับคีย์เวิร์ด nonlocal

สาเหตุที่เราแก้ไข free variables ไม่ได้ดั่งใจคิด นั่นเพราะ Python มองตัวแปรที่กำลังจะแก้ไขเป็น local variables แทน หากเราสามารถบอก Python ได้ว่าตัวแปรที่กำลังใช้งานอยู่นี้ให้ปรากฎในฐานะของ free variables และการแก้ไขใดให้กระทำโดยตรงไปที่ free variables นั้น หากทำเช่นนี้ได้ปัญหาของเราจึงถือว่าได้รับการแก้ไข

nonlocal เป็นคีย์เวิร์ดที่ใช้เพื่อสื่อความว่าตัวแปรดังกล่าวให้หมายถึง free variables ดังนี้

Python
1def find_gpa():
2 credits = 0
3 weight = 0
4
5 def avg(credit, grade):
6 nonlocal credits, weight
7
8 credits += credit
9 weight += (grade * credit)
10
11 return weight / credits
12
13 return avg
14
15gpa = find_gpa()
16print(gpa(3, 4)) # 4.0
17print(gpa(2, 3)) # 3.6
18print(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
  • สรุป