Function trong Python

Sử dụng hàm trong Python (25/02/2024, 04:56)

Trong nội dụng bài viết này mình sẽ nói về 2 thứ mình mới biết được gần đây của Function trong Python:

  1. Function Call Overhead
  2. Function Default Argument Các đề cập trong bài viết này được thử nghiệm dùng CPython version 3.12.

Function Call Overhead

Không chỉ trong Python, bất cứ ngôn ngữ nào đều có function call overhead. Trong Python, nếu chúng ta chạy một function mặc dù không có nội dung (dùng keyword pass) thì vẫn sẽ một khoảng thời gian để thực thi:

def main():
    pass

if __name__ == '__main__':
    import timeit
    timeit.timeit('main()', setup='from __main__ import main')
    # Result: 0.056 - The result may vary on different machines

Tức chỉ với một function rỗng chúng ta mất khoảng ~0.05s để thực thi. Đào sâu một chút, do đâu mà chúng ta lại có điều này? Có thể lý giải bằng 2 điểm sau:

  • Khi một function được gọi, nó sẽ tạo 1 call stack frame và thêm vào call stack, store các local variables, register vị trí trả về sau khi thực thi xong function, v.v.

    Be suspicious of function/method calls; creating a stack frame is expensive - Guido van Rossum

  • Python là một dynamic type language, function là một variable, trước khi thực thi được function, interpreter sẽ phải lấy thông tin về variable trong scope trước, sau đó mới thực sự thực thi function. Hãy cùng quan sát Bytecode của Python để thấy rõ hơn điều này.
def do_something():
    return	

def main():
    do_something()

if __name__ == '__main__':
    import dis
    print(dis.dis(main))
    # 0 LOAD_GLOBAL 0 (do_something)
    # 2 CALL_FUNCTION 0
    # 4 POP_TOP
    # 6 LOAD_CONST 0 (None)
    # 8 RETURN_VALUE

Do đó, để giảm tối thiểu cost của function call chúng ta có thể tránh sử dụng nó, đặc biệt là trong vòng lặp. Hãy xem thử ví dụ sau:

# Case 1
def update_users_data(users: list):
    for user in users:
        if user.is_active:
            create_account(user)
        else:
            deactivate_account(user)

def main():
    users: list = fetch_users()
    update_users_data(users)

# Case 2
def update_user_data(user):
    if user.is_active:
            create_account(user)
        else:
            deactivate_account(user)

def main():
    for user in users:
        update_user_data(user)	

Cả 2 hàm main trong ví dụ trường hợp 1 và 2 đều thực hiện những việc giống nhau, tuy nhiên điều khác biệt là trong trường hợp 2, chúng ta sẽ gọi đến 3 functions cho mỗi user (1 trong hàm main và 2 trong update_users_data), trong khi trường hợp 1 chỉ có 2. Và do Python sẽ luôn phải "reload" lại hàm trước khi chạy, do đó trong trường hợp 2 chúng ta sẽ tốn thêm chi phí để load hàm update_user_data với mỗi user trong danh sách.

Để tránh việc load lại này chúng ta có thể assign hàm vào một local variable (biến local). Vì biến local có tốc độ truy cập nhanh hơn rất nhiều so với tìm kiếm biến ở global scope, ngoài ra hàm cũng sẽ chỉ được load một lần và lưu lại trong biến local đó với mỗi user. Bạn có thể đọc thêm bài viết này của mình: Python và performance (Phần 1)

Function Default Argument

Python có một đặc tính tương đối lạ khi chúng sử dụng default argument cho một hàm bất kì với kiểu dữ liệu mutable. Hãy xem ví dụ sau đây:

import random

def add_dict(data: dict = {}):
    print(data)
    data[random.randrange(1000)] = random.randrange(1000, 2000)

add_dict() # {}
add_dict() # {412: 1685}
add_dict() # {412: 1685, 532: 1142}
add_dict() # {412: 1685, 532: 1142, 823: 1546}

Một điều ta có thể nhận thấy là giá trị mặc định của data trong add_dict không được khởi tạo lại với mỗi lần sử dụng mà sẽ luôn là một giá trị duy nhất cho tất cả những lần sử dụng tiếp theo.

It is often expected that a function call creates new objects for default values. This is not what happens. Default values are created exactly once, when the function is defined - Python 3 Documentation, FAQ

Tuy nhiên nếu chúng ta sử dụng các kiểu dữ liệu immutable như int, string, v.v thì điều này sẽ không xảy ra, bởi vì mỗi lần chúng ta thay đổi giá trị của chúng, Python sẽ tạo ra một địa chỉ mới để lưu giá trị này, thay vì sử dụng trực tiếp địa chỉ cũ. Mình đã thấy code này (và bản thân mình cũng code như vậy) trong code production và thường những người reviewer (bao gồm mình) lại không để ý đến nó, vì thường trong code chúng ta sử dụng một set những key cố định (với dict) để thao tác trên dữ liệu hơn là việc ngẫu nhiên sử dụng key nào đó.

def update_username(user, data: dict = {}):
    if 'username' not in data:
        # Random username
        data['username'] = random.randrange(1000, 2000)
    user.update_username(data['username'])

Trong ví dụ trên, nếu chúng ta không bao giờ truyền đối số data vào trong hàm update_username thì tất cả những user sẽ có chung một username và được cập nhật vào trong database. Ví dụ trên chỉ là minh hoạ cho việc có thể xảy ra nếu dùng kiểu dữ liệu mutable để dùng làm default value, trong thực tế sẽ có nhiều cơ chế để ngăn chặn việc lưu dữ liệu giống nhau.

Để việc này hạn chế xảy ra, trong document của Python có đề cập:

Because of this feature, it is good programming practice to not use mutable objects as default values. Instead, use None as the default value and inside the function, check if the parameter is None and create a new list/dictionary/whatever if it is.

Tức, nếu chúng ta dự định dùng giá trị mặc đinh, hãy sử dụng kiểu dữ liệu immutable, hoặc giá trị None và kiểm tra trong Hàm để khởi tạo giá trị mặc định, không nên là việc khởi tạo ngay lúc khai báo hàm.

Theo quan điểm cá nhân của mình, việc này sẽ khá hiệu quả nếu sử dụng chung với type annotation hoặc có docstring rõ ràng cho hàm . Nếu chỉ để None thì người sử dụng hàm sẽ khó có thể biết được chắc chắn là phải sử dụng giá trị/kiểu dữ liệu nào. Đây cũng là một trong những điểm mạnh và cũng là điểm không hay của Duck Typing.

Hy vọng mọi người đã biết thêm được gì đó khi đọc bài viết này!

Reference:

  1. Some patterns for fast Python
  2. Why are default values shared between objects? - Python 3 Documentation
  3. Data Aggregation - Wiki Python, Performance Tips
Go live on 11/07/2023 by Huy Vu