Decorator trong Python

Thứ năm, ngày 1 tháng 4 năm 2021

Decorator là một trong những design pattern phổ biến trong lập trình. Nó thật sự hữu dụng và linh hoạt trong quá trình phát triển phần mềm. Trong bài viết này, mình sẽ chia sẻ cách hoạt động và sử dụng nó trong python.

1. Decorator là gì và nó hoạt động ra sao?

Decorator là một pattern trong đó một hàm (class) sẽ nhận input là một hàm (class) và trả ra output là một hàm (class). Hàm được trả ra sẽ có hành vi khác so với hàm input, có thể được extends,...

OK, mình sẽ lấy ví dụ trực quan hơn

Giả sử mình có hàm tính tổng như sau:

def add(*args):
	return sum(args)

Bây giờ, mình có thêm yêu cầu là log thời gian thực thi hàm đó chẳng hạn, cách dễ nhất là thêm code vào chính hàm đó như sau:

def add(*args):
	start_at = time.time()
	s = sum(*args)
	print("Take", time.time()-s, "second")
	return s

Bây giờ, mình có thêm mấy hàm và cũng được yêu cầu log thời gian thực thi. Sau 2 tháng, các sếp yêu cầu mình ngoài log thời gian thực thi, cần log thêm tên hàm và input chẳng hạn, mình sẽ phải sử code ở toàn bộ các hàm đó, thật tệ :sad:

Đây là lúc chúng ta cần đến decorator

def log(func):
	def func_with_log(*args, **kwargs):
		start_at = time.time()
		ret = func(*args, **kwargs)
		print("Take", time.time()-start_at, "second")
		return ret
	return func_with_log

Sử dụng nó nào

def add(*args):
	return sum(args)

add_with_log = log(add)

Hàm log đơn giản nhận vào một hàm, thực thi hàm đó (pass nguyên bộ tham số) và in thời gian thực thi (extend hàm gốc). Bạn có thể sử dụng hàm log này ở đâu bạn muốn và có thể dễ dàng thêm các yêu cầu mà không tốn công :smile:

Ngoài cách gọi rõ ràng như thế kia, Python còn hỗ trợ syntax cho decorator như này

@log
def add(*args):
	return sum(args)

Amazing!

2. Nested decorator

Chúng ta sẽ thắc mắc, một hàm có thể có nhiều decorator hay không, câu trả lời là có

Chúng được gọi là decorator stack (các bạn tự tìm hiểu thêm nhé :smile:)

Hay cũng xét ví dụ sau

def decorator1(func):
	def new_func(*args, **kwargs):
		print('Decorator 1')
		return func(*args, **kwargs)
	return new_func

def decorator2(func):
	def new_func(*args, **kwargs):
		print('Decorator 2')
		return func(*args, **kwargs)
	return new_func

@decorator1
@decorator2
def func(msg):
	print('Original func:', msg)

func("Hello world")

Output sẽ là:

Decorator 1
Decorator 2
Original func: Hello world

Oh, vậy chúng có thứ tự thực hiện như thế nào?

Các decorator lồng nhau theo thứ tự decorator1 -> decorator2.

Còn thứ tự thực thi sẽ tuỳ vào việc bạn gọi func trong các decorator ra sao.

Đây chính xác là những gì syntax @ là với các decorator

def func(msg):
	print("Original func:", msg)

func_with_deco2 = decorator2(func)
func_with_deco1 = decorator1(func)

3. Decorator có tham số

Vẫn là ví dụ về log ở phần 1, chúng ta giờ muốn thêm log tên hàm và input của hàm đó. Nhưng log input của hàm là một tuỳ chọn có thể log hoặc không log

Giải pháp 1: viết 2 decorator

def log_with_input(func):
	def func_with_log(*args, **kwargs):
		start_at = time.time()
		ret = func(*args, **kwargs)
		print(func.__name__, "takes", time.time()-start_at, "second")
		print("Input:", args, kwargs)
		return ret
	return func_with_log
def log_without_input(func):
	def func_with_log(*args, **kwargs):
		start_at = time.time()
		ret = func(*args, **kwargs)
		print(func.__name__, "takes", time.time()-start_at, "second")
		return ret
	return func_with_log

Giờ đâu cần log input thì dùng log_with_input, nếu không thì xài log_without_input. Nếu bạn có n biến là tuỳ chọn thì bạn tiêu rồi, bạn cần viết 2 ^ n cái decorator

OK, mình có giải pháp cho bạn đây: decorator của decorator.

def log(show_input=False):
	def config(func):
		def func_with_log(*args, **kwargs):
			start_at = time.time()
			ret = func(*args, **kwargs)
			print(func.__name__, "takes", time.time()-start_at, "second")
			if show_input:
				print("Input:", args, kwargs)
			return ret
		return func_with_log
	return config

và đơn giản chỉ cần sử dụng như

@log(show_input=True)
def need_log_input():
	...

@log(show_input=False)
def dont_need_log_input():
	...

OK, mình sẽ giải thích thằng Python nó làm ngầm chuyện này như thế nào nhé :smile:

log_deco = log(show_input=True)		# cái này trả về hàm decorator đã được config: show_input=True
need_log_input = log_deco(need_log_input)

Bạn có thể thấy, hàm config nhận hàm ban đầu và trả ra một hàm mới là func_with_log có chứa đoạn code log thông tin, và thực thi hàm gốc.

Và hàm func_with_log dựa vào biến show_input để quyết định xem có nên log ra input của hàm gốc hay không?

Đây là một lợi ích đến từ nested function. Việc define các hàm lồng nhau sẽ cho chúng ta lợi ích variable scope. Tức là sau khi hàm config trả về func_with_log, biến show_input vẫn tồn tại trong hàm func_with_config mà không bị giải phóng :smile:, lí do tại sao mình sẽ giải thích trong bài viết sau nhé.

OK, các bạn có thể hiểu rõ cơ chế làm việc của decorator rồi, nhưng Python có hẳn syntax cho pattern này

Nhanh gọn phải không nào :smile:

Các bạn có thể thấy pattern này được sử dụng trong rất nhiều các framework như Flask chẳng hạn

app = Flask(__name__)

@app.route("/")
def homepage():
	return "Hello world"

Nếu các bạn để ý, sẽ gặp trường hợp này:

from functools import lru_cache

@lru_cache
def f1():
	...

@lru_cache(max_size=1024)
def f2():
	...

Tại sao chỗ thì cần (max_size=1024) chỗ lại không? Vậy hãy đến phần nâng cao hơn nhé :smile:

4. Tham số tuỳ chọn trong decorator

Quay lại ví dụ ở phần trước, show_input là một tuỳ chọn, giả sử nếu không truyền vào ta có thể hiểu nó là False, và mình cũng muốn sử dụng nó như lru_cache ở trên :smile:

def log(func=None, show_input=None):
	if func is None:	# lúc gọi @log(show_input=True)
		return lambda origin_func: log(origin_func, show_input=show_input)

	show_input = False 	# giá trị mặc định
	def func_with_log(*args, **kwargs):
		start_at = time.time()
		ret = func(*args, **kwargs)
		print(func.__name__, "takes", time.time()-start_at, "second")
		if show_input:
			print("Input:", args, kwargs)
		return ret
	return func_with_log

Cùng phân tích nhé

Với trường hợp bạn gọi @log, sẽ tương đương như sau:

new_func = log(old_func)

nó sẽ lấy giá trị mặc định là false

Với trường hợp gọi @log(show_input=True), sẽ tương đương:

deco = log(show_input=True).   # deco đã được config
new_func = deco(old_func)

Vậy là vấn đề đã được giả quyết :smile:

Tóm lại, bằng việc sử dụng decorator, code của bạn sẽ:

  • tái sử dụng code
  • dễ bảo trì
  • linh hoạt

Đến đây mình đã chia sẻ khái niệm decorator nói chung và trong Python nói riêng, hi vọng bài viết này hữu dụng với các bạn, bye bye.