Babel Coder

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

beginner

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

def my_family(last_name):
  def print_name(first_name):
    print('%s %s' % (first_name, last_name))

  # ฟังก์ชัน my_family คืนค่ากลับเป็นฟังก์ชัน print_name
  return print_name

haha_family = my_family('Haha')

# การเรียก haha_family คือการเรียกฟังก์ชัน print_name
haha_family('Somchai') # Somchai Haha
haha_family('Somsree') # Somsree Haha
haha_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 งงเด้ งงเด้

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

def my_family(last_name):
  def print_name(first_name):
    print('%s %s' % (first_name, last_name))

  return print_name

haha_family = my_family('Haha')

haha_family('Somchai') # Somchai Haha
haha_family('Somsree') # Somsree Haha
haha_family('Somset')  # Somset Haha

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

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

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

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

# (<cell at 0x10c855558: str object at 0x10c860150>,)
print(haha_family.__closure__)

ค่าที่คืนกลับจาก attributes นี้สำหรับฟังก์ชันดังกล่าวคือ tuple ที่ประกอบไปด้วย free variables แต่เนื่องจากฟังก์ชันของเรามีเพียง last_name ที่เป็น free variables เท่านั้น จึงได้ผลลัพธ์เป็น tuple ที่มีสมาชิกเพียงหนึ่งเดียว

สิ่งที่คืนกลับจาก __closure__ นั้นเป็น tuple ของ cell หากเราต้องการดึงข้อมูลที่แท้จริงของ free variables เราต้องเรียก cell_contents ผ่าน cell แต่ละตัว ดังนี้

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

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

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

def find_gpa():
  credits = 0
  weight = 0

  def avg(credit, grade):
    credits += credit
    weight += (grade * credit)

    return weight / credits

  return avg

gpa = find_gpa()
gpa(3, 4) # 3 หน่วยกิต ได้เกรด A
gpa(2, 3) # 2 หน่วยกิต ได้เกรด B
gpa(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 ไว้ในออบเจ็กต์แทน ข้อผิดพลาดนี้จะไม่เกิดขึ้นอีก

def find_gpa():
  state = {}
  state['credits'] = 0
  state['weight'] = 0

  def avg(credit, grade):
    state['credits'] += credit
    state['weight'] += (grade * credit)

    return state['weight'] / state['credits']

  return avg

gpa = find_gpa()
print(gpa(3, 4)) # 4.0
print(gpa(2, 3)) # 3.6
print(gpa(1, 4)) # 3.6666666666666665

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

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

def find_gpa():
  def avg(credit, grade):
    avg.credits += credit
    avg.weight += (grade * credit)

    return avg.weight / avg.credits

  avg.credits = 0
  avg.weight = 0

  return avg

gpa = find_gpa()
print(gpa(3, 4)) # 4.0
print(gpa(2, 3)) # 3.6
print(gpa(1, 4)) # 3.6666666666666665

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

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

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

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

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

def find_gpa():
  credits = 0
  weight = 0

  def avg(credit, grade):
    nonlocal credits, weight

    credits += credit
    weight += (grade * credit)

    return weight / credits

  return avg

gpa = find_gpa()
print(gpa(3, 4)) # 4.0
print(gpa(2, 3)) # 3.6
print(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 กำกับตัวแปรไว้นั่นเอง


แสดงความคิดเห็นของคุณ


No any discussions