Decorator ของตกแต่ง Function บน Python

เมื่อหลายวันก่อนมีน้องถามเรื่องของ Functools ใน Python เลยทำให้นึกถึงความเป็น First Class Citizen ของ Function ใน Python ว่ามันเป็นชนชั้นที่ความสามารถเยอะมาก ๆ เราสามารถ Pass เป็น Argument เข้าไปซ้อน ๆ Function อีกทีได้ แต่ Feature ตัวนึงที่เจ๋งมาก ๆ และหลาย ๆ คนมองข้ามไปคือ Decorator

Decorator คืออะไร ?

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

Function คือโคตรของ First Class Citizen บน Python

อย่างที่เราบอกว่า Function ใน Python เรียกได้ว่า เป็น First Class Citizen บน Python เลย เพราะสิ่งที่มันทำได้ มันทำได้เยอะมาก ๆ ต่างจากบางภาษาที่มันเกิดมาเป็นแค่ Function ให้เรา Reuse Code เฉย ๆ เราลองค่อย ๆ มาดูกันว่า Function ใน Python มันจะทำอะไรได้เยอะขนาดไหนกัน

def greeting (name) :
	print('Hello ' + name)

a = greeting
a("World")
Hello World

อย่างแรกคือ การ Assign Function ลงไปในตัวแปร ทำให้ตัวแปรนั้นสามารถนำตัวได้เหมือน Function ทุกอย่าง จะเห็นได้ว่า ในบรรทัดสุดท้าย เราสามารถเรียก greeting ที่เป็น Function จาก a ได้เลยตรง ๆ เพราะเรา Assign ให้มันก่อนหน้าแล้ว เป็น Pattern ที่อ่านครั้งแรก กำหมัดมาก มันเหมือนกับว่า Function เดียวกัน แต่มีหลายร่างจำแลงได้ ปวดหัวเลยละ

def show_me () :
	print("This is me")
    
def wrapper (func) :
	print("I am Wrapper")
    func()

wrapper(show_me)
I am Wrapper
This is me

นอกจากที่เราจะ Assign สร้างร่างจำแลงได้แล้ว เรายังสามารถ Pass Function เป็น Argument เข้าไปที่ Function อื่น ๆ ได้อีก หรือตรงกันข้าม เรายังมามารถ Return กลับมาเป็น Function ก็ได้เช่นกัน ปังปุไปเลยค่าาาา

def add_number (number) :
	def add_one (number) :
    	return number + 1
    return add_one(number)
    
print(add_number(1))
2

เช่นเดียวกับการเป็นตัวแปรทุกประการ มันมีคุณสมบัติของ Scope เหมือนกับตัวแปรทุกประการ ทำให้เราสามารถประกาศ Function ภายใน Function ได้ (Nested Function) แต่ Function ที่เราประกาศภายใน Function จะมี Scope อยู่ภายใน Function แม่ของมันเท่านั้น ทำให้เราไม่สามารถเรียก Function add_one จากภายนอก Function add_number ได้นั่นเอง

def add_number (number) :
	def add_one () :
    	return number + 1
        
    return add_one()
 
 print(add_number(1))
2

มองผ่าน ๆ จะคล้าย ๆ กับตัวอย่างก่อนหน้าเลย แต่มันต่างอยู่นิดนึง สังเกตตรง add_one() เราไม่ได้ใส่ Argument ลงไปให้มัน แต่ในการทำงาน เราเรียก number ขึ้นมา ใช่แล้วการที่เป็น Nested Function ทำให้มันสามารถเข้าถึงตัวแปรที่อยู่ด้านบนของมันได้ทั้งหมด รวมถึง number ที่เป็น Argument ใส่เข้ามาตอนเรียก add_number

จากความสามารถตรงนี้ ทำให้เราบอกได้เลยว่า Function คือ First Class Citizen จริง ๆ แล้วเผลอ ๆ เราแทบไม่ต้องแยกด้วยซ้ำว่าอะไรคือตัวแปร หรือ อะไรคือ Function เพราะคุณสมบัติของมันแทบจะเหมือนกันทุกประการ มันทำได้เยอะสุด ๆ ไปเลย อ่านมาถึงตอนนี้ อาจจะ งง ว่า แล้วเรามาเรียกร้องสิทธิ์อะไรให้กับ Function มิทราบ จะบอกว่า มันเป็นพื้นฐานของ Decorator ที่เราจะคุยกันในบทความนี้ เลยต้องเอามาเล่าซะหน่อย

ทดลองสร้าง Decorator

ก่อนหน้าที่เราอธิบายว่า Decorator มันคืออะไร อาจจะ งง ว่าแล้วยังไง เราลองมาทำให้มันดูเป็นรูปธรรมขึ้นกันดีกว่า ในตัวอย่างนี้ เราจะลองยกตัวอย่างของ Decorator อย่างง่ายขึ้นมาให้ดูกัน

def add_more_dec (call_func):
	def add_one () :
    	return call_func() + 1
    
    return add_one
 
 def init_one () :
 	return 1
    
 print(add_more_dec(init_one))
2

ใน Code ด้านบน อาจจะดู งง ๆ นิดหน่อย แต่ขอให้นึกถึงสมบัติของ Function ใน Python ให้ดี ๆ ก่อนอื่น เราสร้าง add_more_dec เป็น Function ตัวนึง รับ Function ตัวนึงเข้ามา ที่ด้านในเราก็สร้าง Nested Function อีก เป็น add_one ที่เราไม่ได้รับค่าอะไรเลย แต่เรียก call_func ที่มาจาก add_more_dec สุดท้ายก็ ให้ค่าจาก call_func แล้วบวกด้วย 1 กลับไป สุดท้าย add_more_dec ก็เอา add_one Function กลับไป

จากนั้น เราสร้าง Function init_one ขึ้นมา เพื่อให้มัน Return 1 กลับไป และสุดท้าย เราก็เรียก add_more_dec ที่ด้านใน Pass init_one() เป็น Argument เข้าไป ท้ายสุด มันจะ Evaluate ออกมาเป็น 1 + 1 ทำให้เราได้คำตอบ 2 ออกมา ถึงแม้ว่าเราจะไม่ได้แก้ init_one()  เลยแม้แต่บรรทัดเดียว ซึ่งขัดกับ init_one() แน่นอน

def add_more_dec (call_func):
	def add_one () :
    	return call_func() + 1
    
    return add_one
 
 @add_more_dec
 def init_one () :
 	return 1
    
 print(init_one)

แต่การเขียนแบบนี้มันยุ่งยาก มันต้องซ้อน ๆ เข้าไปเรื่อย ๆ สุดท้าย Code เราจะบานออกข้างเยอะไปหน่อย ทำให้ Python บอกว่า ไม่เอาแล้ว เราจะไม่เขียนแบบนี้ แต่เราจะเขียนเป็น Decorator แทน ก็คือ การใช้เครื่องหมาย @ และตามด้วย Decorator ที่เราต้องการ จากนั้นเวลาเราจะเรียกอีกกี่ครั้ง Decorator ที่เรากำหนดไว้มันก็จะทำงานพร้อม ๆ กับ Function ที่เราใส่เข้าไปเสมอ

 @add_more_two_dec
 @add_more_dec
 def init_one () :
 	return 1
    
 print(init_one)

หรือกระทั่ง เราสามารถใส่หลาย ๆ Decorator ใน 1 Function ได้เช่นกัน โดยที่เราใส่ @ เพิ่มไปเรื่อย ๆ จนกว่าเราจะหมด

ถึงตรงนี้ เราน่าจะพอเห็นภาพมากขึ้นแล้วว่าจริง ๆ แล้วการที่เราใส่ @ ด้านหน้า จริง ๆ แล้วมันคือ การลดรูปของการเอา Function มาต่อ ๆ กันเรื่อย ๆ นั่นเอง เพื่อให้มันอ่านได้ง่ายขึ้น และ เราเองก็ยังสามารถสร้าง Decorator ขึ้นมาใช้เองได้ ต่อไปเราลองมาดูกันบ้างดีกว่าว่ามันยังทำอะไรได้อีกบ้าง บอกเลย แซ่บ เผ็ดช์ร้อน !!!!

More A d v a n c e d ? Decorator

เราลองมาดูความ Advance ของมันขึ้นไปอีก (ถ้าพิมพ์ติดกัน ใจมันไม่เกเร อยู่ไม่สุข) ความเจ๋งของมันทำได้อีกหลายอย่าง Advance เข้าไปอีก

def time_two_dec (call_func):
    def time_two (number) :
        return call_func(number) + number
    return time_two
 
@time_two_dec
def init_number (number) :
    print(number)
    return number

print(init_number(9))
9
18

เอาละ เริ่ม Advance ขึ้นอีกนิด ในตัวอย่างนี้ เราปรับปรุง Decorator ให้เจ๋งขึ้นอีก โดยการเพิ่มความสามารถในการรับ Argument เข้ามาได้ด้วย เราขอเปลี่ยนตัวอย่างเป็นการ คูณ 2 ละกัน เรารู้ว่า การคูณ 2 คือการเอาจำนวนนั้น ๆ บวกเข้าไปซ้ำอีกที สิ่งที่เราทำคล้ายกับตัวอย่างก่อนหน้าเลย แต่เปลี่ยนนิดหน่อย จากเดิมที่ init_one มันจะคืนกลับมาเป็น 1 เสมอ เราเปลี่ยนให้เป็น init_number() แทนที่สามารถรับตัวเลขเข้ามาได้ตัวนึง จากนั้นก็ Return ตัวเลขนั้นกลับไป

ในส่วนของ Decorator เราสร้าง Nested Function ด้านในคือ time_two สิ่งที่มันทำคือ เราจะเรียก Function ที่ Pass เข้ามาในชื่อว่า call_func โดยใส่ Parameter เป็นตัวเลขที่ Input เข้ามาจาก Parameter ของ time_two() อีกที และเอาผลลัพธ์นั้นมาบวกกับ number อีกรอบ เลย กลายเป็นการคูณ 2

สังเกตในผลลัพธ์ เราจะมี 2 บรรทัด ซึ่งอันแรกมันเกิดจากอันที่เราสั่ง print() ใน init_number() ซึ่งมันจะถูกเรียกตอน return call_func(number) + number เท่านี้ก็ทำให้เราสามารถ Pass Argument ลงไปใน Decorator ได้แล้ว หรือ ถ้าเราอยากได้ General-Purposed Argument เราก็สามารถใช้ท่าเดิมได้ โดยการกำหนด Argument ของ Function เป็น *args,**kwargs หรือก็คือ การเอา Positional และ Keyword Argument ตามท่าปกติที่เราใช้สร้าง Function ใน Python อยู่แล้ว

def greet_more (dec_arg1, dec_arg2) :
    def decorator (call_func) :
        def get_greeting_msg (func_arg1, func_arg2) :
            print("Greeting", func_arg1, func_arg2, dec_arg1, dec_arg2, " from inside wrapper")
            return call_func(func_arg1, func_arg2)
        
        return get_greeting_msg
        
    return decorator

@greet_more('Thomas', 'Tom')
def greet_two_people (name1, name2) :
	print('Greeting', name1, name2, 'From Normal Function')
 
greet_two_people('Alice', 'Edison')
Greeting Alice Edison Thomas Tom  from inside wrapper
Greeting Alice Edison From Normal Function

ไปให้สุดหยุดที่มึนไปเลยจ้าาา ตัวอย่างนี้ เราต้องการให้ Decorator ของเราสามารถรับค่าเข้ามาได้ เราเลยทำการสร้าง Decorator ซ้อน Decorator เข้าไปอีก จะเป็น greet_more > decorator > get_greeting_msg เราลองค่อย ๆ มาดูทีละส่วนละกัน ดูทั้งหมดเลย ตอนแรกที่คุยกับเพื่อนเรื่องนี้แล้วอ่านกัน ทะเลาะกันซะงั้นอีบ้าาา !!!

def greet_more (dec_arg1, dec_arg2) :  
    ...
    return decorator

เราเริ่มจากชั้นนอกสุดก่อน เราสร้าง Function ชื่อ greet_mode ขึ้นมา โดยที่รับ Argument เข้าไป 2 ค่าด้วยกัน และสุดท้าย เราก็คืนกลับเป็น Function เหมือนกับ Decorator ปกติเลย คือ decorator() ที่เป็น Function ที่กำลังจะประกาศด้านล่างต่อในขั้นตอนต่อไป

def decorator (call_func) :
        def get_greeting_msg (func_arg1, func_arg2) :
            print("Greeting", func_arg1, func_arg2, dec_arg1, dec_arg2, " from inside wrapper")
            return call_func(func_arg1, func_arg2)
        
        return get_greeting_msg
    

ความซับซ้อนเอ๋ยจงเยอะขึ้นเรื่อย ๆ ใช่แล้ววว ด้านในของ decorator() เราจะรับ Argument เป็น Function เหมือน Decorator ที่เราสร้างกันก่อนหน้านี้ทุกประการ จริง ๆ เอาใหม่ decorator ตัวนี้ เหมือนกับตัวที่เราสร้างก่อนหน้านี้เลย แค่ว่า ภายใน Nested Function ของ decorator() มันมีการรับค่า ดั่งที่เรายกตัวอย่างมาก่อนหน้านี้แล้ว สุดท้ายไส้ในสุด เราก็เรียก print() โดยที่สังเกตค่าที่เราใช้ Print ออกมา มันจะมี func_arg1 และ func_arg2 อันนี้ปกติ เป็น Parameter ที่ได้จาก get_greeting_msg() แน่นอน แต่ตัว dec_arg1 และ dec_arg2 นี่สิ

จำที่เราคุยกันก่อนหน้านี้ได้มั้ยว่า มันมี Variable Scope อยู่ dec_arg1 และ dec_arg2 มันเป็น Parameter ที่ใส่เข้ามาตอน greet_more และ get_greeting_msg ก็อยู่ในตระกูลเดียวกัน (เรียกว่าเป็น หลาน ก็ได้) ทำให้ภายใน get_greeting_msg สามารถเรียก Parameter ที่มาจาก greet_more ได้

@greet_more('Thomas', 'Tom')
def greet_two_people (name1, name2) :
	print('Greeting', name1, name2, 'From Normal Function')
 
greet_two_people('Alice', 'Edison')

ท้ายสุด เราก็ใช้ call_func ซึ่งเป็น Function ที่เป็น Parameter จาก decorator() อีกที มันก็จะมาจากการที่เราเอา Function นี้แหละ ไป Decorate ที่ greet_two_people() ทำให้ call_function มันเลยเท่ากับ greet_two_people() นั่นเอง

ที่เหลือก็ง่ายละ เราเรียก greet_two_people() ซึ่งมัน Decorate ด้วย greet_more() ทำให้มันก็จะไปทำงานตรงนั้นก่อน และสุดท้ายภายในสุดของ get_greeting_msg() มันก็จะกลับมาเรียก greet_two_people() อีกที ทำให้เราได้ผลลัพธ์อย่างที่เราเห็น

Example: Function Timer

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

import time

def time_counter_decorator (func) :
    def measure_time (max_num) :
        start_time = time.time()
        result = func(max_num)
        elapsed = time.time() - start_time
        print(func.__name__ , 'spend', elapsed , 'sec(s)')
        
        return result        

    return measure_time

@time_counter_decorator
def adding_number (max_num) :
    result = 0
    for i in range(max_num):
        result += i

    return result

result = adding_number(100000000)
print('Result is', result)
adding_number spend 2.8512260913848877 sec(s)
Result is 4999999950000000

จากตัวอย่างนี้ เราสร้าง time_counter_decorator() ที่มี measure_time() สำหรับการวัดเวลาในการทำงานของ Function ที่เราใส่เข้าไป เราเลยสร้างเป็น Closure ในการวัดเวลาทั่ว ๆ ไปขึ้นมา โดยให้มันเก็บเวลาก่อนที่จะรัน และ เก็บเวลาหลังรัน ลบกัน เราก็จะได้เวลาในการรันออกมาแค่นี้เลย เป็น Decorator ง่าย ๆ หรือถ้าเราอยากให้มัน Reuse ได้มากขึ้น เราสามารถเปลี่ยนเป็น Argument max_num ให้กลายเป็น *args,**kwargs มันก็จะได้แบบด้านล่างนี้

import time

def time_counter_decorator (func) :
    def measure_time (*args,**kwargs) :
        start_time = time.time()
        result = func(*args,**kwargs)
        elapsed = time.time() - start_time
        print(func.__name__ , 'spend', elapsed , 'sec(s)')
        
        return result        

    return measure_time

@time_counter_decorator
def adding_number (max_num) :
    result = 0
    for i in range(max_num):
        result += i

    return result

result = adding_number(100000000)
print('Result is', result)

แค่เปลี่ยนเท่านี้ก็ทำให้ Decorator ที่เราสร้างขึ้นมา สามารถเอาไปใช้วัดเวลาในการรันกับ Function อะไรก็ได้แล้ว เราแค่เดิม Decoration เข้าไปใน Function นั้น ๆ ก็เป็นอันใช้ได้แล้ว ง่ายมาก ๆ

สรุป

วันนี้ก็ยาวมากพอควรแล้ว ขอตัดเพียงเท่านี้ก่อน วันนี้เรามาทำความรู้จัก Decorator ที่เป็น Feature บน Python ทำให้เราสามารถเพิ่มความสามารถ หรือเปลี่ยนแปลงการทำงานของ Function โดยที่เราไม่ต้องเข้าไปแก้ไข Function เดิมเลย หลักการของมันสั้น ๆ คือ เราพยายามมาคล่อม Function ที่เป็นของเรา ทำให้เราสามารถเปลี่ยน หรือเพิ่มความสามารถได้นั่นเอง โดย Python อนุญาติให้เราเขียนอยู่ในรูปแบบของ @ ตามด้วยชื่อ Decorator ได้เลยทำให้การใช้งานง่าย และ Code ดูสะอาดตามากขึ้น จากตัวอย่างวันนี้ถือว่าเป็นของเล็กน้อยมาก ๆ ในตอนหน้า เราจะพามาดูตัวอย่างของ Decorator ที่เราใช้บ่อย ๆ ทั้งเก็บเป็น Snippet และโหลดมาใช้เลย บอกเลยว่า แซ่บมาก ทำให้เราเขียน Code ได้ดีขึ้น วัดผลง่ายขึ้น และ ทำให้ตรวจสอบความผิดพลาดได้ง่ายขึ้นด้วย