+6

Python: Cách sử dụng hàm lồng nhau

Bài viết gốc: https://manhhomienbienthuy.github.io/2017/12/25/python-ham-long-nhau-va-cach-su-dung.html

Như chúng ta đã biết, trong Python, hàm cũng là đối tượng, hơn nữa, nó còn là đối tượng first-class. Nhờ đó, chúng ta có thể thao tác với hàm như mọi đối tượng khác. Chúng ta có thể tạo ra hàm, xoá bỏ nó, gán cho biến, truyền làm tham số, v.v...

Cũng vì hàm là đối tượng first-class, nên nó có thể được định nghĩa bên trong một hàm khác (hàm lồng nhau). Một ứng dụng của việc này là sử dụng hàm lồng nhau để tạo ra các decorator.

Trong bài viết này, chúng ta sẽ tìm hiểu những ứng dụng khác nữa của việc sử dụng hàm lồng nhau trong Python.

Đơn giản hoá thuật toán

Đây là một trong những ứng dụng quan trọng nhất của chương trình con, và hàm chính là một chương trình con như vậy. Chương trình con nói chung là hàm nói riêng giúp chúng ta lập trình có cấu trúc.

Thuật toán để giải các bài toán sẽ trở nên đơn giản hơn do bài toán lớn được chia thành các bài toán con, mỗi bài toán con được giải trong một hàm. Mỗi bài toán con lại có thể chia nhỏ hơn nữa tuỳ độ phức tạp, quá trình "làm mịn" này diễn ra đến mức nào thì tuỳ vào lập trình viên.

Ngoài ra, những bài toán con này lại có thể tái sử dụng ở những bài toán lớn hơn. Không như ngôn ngữ C, các hàm hoàn toàn ngang hàng với nhau, hàm trong Python có thể lồng nhau, giúp chúng ta làm mịn bài toán dễ dàng hơn rất nhiều.

Dưới đây là một ví dụ "minh hoạ" cho việc sử dụng hàm lồng nhau để đơn giản hoá thuật toán.

>>> def f(a, b, c):
...     l = 0
...     for x in a:
...         l += x
...     for x in b:
...         l += 2*x
...     for x in c:
...         l += 3*x
...     return l
...
>>> f(range(0, 10), range(0, 10), range(0, 10))
270

Đây là một ví khá đơn giản. Hàm f nhận vào 3 list, sau đó tính một tổng theo quy luật cho trước. Quy luật đã được thể hiện trong code rồi, cũng không có gì khó hiểu cả.

Code trên đây chạy tốt, tuy nhiên nó rất xấu. Dưới đây là một phiên bản đẹp hơn, sử dụng hàm lồng nhau.

>>> def f(a ,b, c):
...     l = 0
...     def inner(l, m=1):
...         return sum(x*m for x in l)
...     l += inner(a, 1)
...     l += inner(b, 2)
...     l += inner(c, 3)
...     return l
...
>>> f(range(0, 10), range(0, 10), range(0, 10))
270

Rất dễ nhận ra, bằng cách sử dụng một hàm con inner, chúng ta đã gom những công việc tương tự nhau vào một chỗ. Code không ngắn đi nhiều lắm, nhưng rõ ràng là mạch lạc hơn rất nhiều. Sử dụng hàm lồng nhau trong trường hợp này rõ ràng là phát huy tác dụng rất đúng lúc.

Không sử dụng hàm lồng nhau có được không? Câu trả lời là được, chúng ta có thể định nghĩa một hàm khác ngang hàng với hàm f, tuy nhiên trong trường hợp này, hàm con bên trong sẽ tốt hơn. Còn tại sao nó tốt hơn, chúng ta sẽ tìm hiểu tiếp ở những phần tiếp theo.

Chúng ta có thể rút ngắn code hơn nữa bằng cách sử dụng lambda như dưới đây.

>>> def f(a ,b, c):
...     l = 0
...     inner = lambda l, m: sum(x*m for x in l)
...     l += inner(a, 1)
...     l += inner(b, 2)
...     l += inner(c, 3)
...     return l
...
>>> f(range(0, 10), range(0, 10), range(0, 10))
270

Trong trường hợp này, lambda cũng là một hàm, hàm đó trả về giá trị thông qua một biểu thức duy nhất. Ví dụ, hai đoạn code dưới đây cho chúng ta cùng một kết quả:

>>> f = lambda x: x + 1
>>> f(1)
2
>>> def f(x):
...     return x + 1
...
>>> f(1)
2

Thực ra dùng lambda cho chúng ta kết quả không đáng kể lắm (code chỉ ngắn hơn có 1 dòng). Hơn nữa, trong trường hợp phức tạp, dùng hàm vẫn tốt hơn.

Truyền hàm làm tham số

Việc truyền hàm vào một hàm khác nhiều khi rất quan trọng, đặc biệt là khi bạn lập trình kiểu generic, cho phép hàm hoạt động theo nhiều cách khác nhau, tuỳ vào tham số được truyền.

Một ví dụ kinh điển cho trường hợp này là bài toán sắp xếp. Sắp xếp là một công việc thường xuyên trong lập trình, và nếu có thể lập trình generic các hàm sắp xếp thì còn gì bằng.

Ứng dụng của việc truyền hàm làm tham số trong trường hợp này là, chúng ta có thể nhận tham số là một hàm so sánh, hàm sẽ trả về -1, 0, 1 khi so sánh hai phần tử với nhau. Bằng cách này, hàm sắp xếp không cần quan tâm đến cách so sánh dữ liệu, còn người lập trình, chỉ cần tuỳ biến nó là có thể nhận được kết quả sắp xếp theo nhiều cách khác nhau, với nhiều kiểu dữ liệu khác nhau.

Ví dụ, chúng ta cần sắp xếp dữ liệu nhân sự dựa trên tên của họ. Chúng ta có thể sắp xếp theo tên như sau:

>>> employees = [
...     {'firstname': 'John', 'lastname': 'Joseph'},
...     {'firstname': 'Celine', 'lastname': 'Adams'},
...     {'firstname': 'Paul', 'lastname': 'Johnson'},
...     {'firstname': 'Aswathy', 'lastname': 'Govind'},
... ]
>>> def order(names):
...     def inner(item):
...         return item['firstname']
...     sl = zip(range(1,10), sorted(names, key=inner))
...     return list(sl)
...
>>> order(employees)
[(1, {'firstname': 'Aswathy', 'lastname': 'Govind'}),
 (2, {'firstname': 'Celine', 'lastname': 'Adams'}),
 (3, {'firstname': 'John', 'lastname': 'Joseph'}),
 (4, {'firstname': 'Paul', 'lastname': 'Johnson'})]
>>>

Bây giờ, giả sử yêu cầu thay đổi, chúng ta cần sắp xếp theo họ chứ không phải tên, rất đơn giản, chỉ cần thay đổi một chút hàm con truyền vào sorted là xong:

>>> order(employees)
[(1, {'firstname': 'Celine', 'lastname': 'Adams'}),
 (2, {'firstname': 'Aswathy', 'lastname': 'Govind'}),
 (3, {'firstname': 'Paul', 'lastname': 'Johnson'}),
 (4, {'firstname': 'John', 'lastname': 'Joseph'})]

Chúng ta đã vận dụng hàm lồng nhau để thay đổi phương pháp sắp xếp, tuỳ theo yêu cầu (theo firstname hay lastname). Sau này, khi bài toán mở rộng, chúng ta cũng không cần phải thay đổi quá nhiều code.

Tương tự như trường hợp trên, trong trường hợp này, chúng ta vẫn nên sử dụng hàm lồng nhau thay vì dùng các hàm độc lập.

Tạo hàm động

Bởi vì hàm là đối tượng first-class, chúng ta có thể tạo ra hàm mới và trả về hàm trong một hàm khác. Bằng cách này, chúng ta có thể tạo ra các hàm real time theo nhu cầu.

Trong ví dụ dưới đây, chúng ta có thể sử dụng hàm lồng nhau để tạo ra các hàm luỹ thừa theo nhu cầu. Thay vì phải định nghĩa các hàm như thế này

>>> def square(x):
...     return x ** 2
...
>>> def cube(x):
...     return x ** 3
...
>>> square(2)
4
>>> cube(3)
27

Chúng ta có thể dùng cách tạo hàm động như sau, trông "thông minh" hơn rất nhiều:

>>> def fpower(exp):
...     def inner(x):
...         return x ** exp
...     return inner
...
>>> square = fpower(2)
>>> square(2)
4
>>> cube = fpower(3)
>>> cube(3)
27

Một lợi ích khác của việc này là chúng ta có thể dễ dàng tạo ra các hàm luỹ thừa bao nhiêu tuỳ ý

>>> quartic = fpower(4)
>>> quartic(4)
256
>>> quintic = fpower(5)
>>> quintic(5)
3125

Đóng gói

Hàm lồng nhau có một kết quả rất hay là chúng được bảo vệ khỏi sự ảnh hưởng từ bên ngoài. Hàm con bên trong một hàm khác là vô hình với thế giới. Chúng ta hãy xem xét ví dụ sau:

>>> def outer(num1):
...     def inner(num1):
...         return num1 + 1
...     num2 = inner(num1)
...     print(num1, num2)
...

Nếu chúng ta gọi hàm inner thì sẽ nhận được kết quả thế này

>>> inner(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'inner' is not defined

Ở đây, chúng ta chỉ có thể gọi hàm outer được định nghĩa bên ngoài mà thôi.

>>> outer(1)
1 2

Như vậy, hàm con được định nghĩa trong một hàm khác hoàn toàn cách ly với bên ngoài. Tính chất này là tốt hay xấu hoàn toàn phụ thuộc vào cách chúng ta dùng nó như thế nào. Trong ví dụ dưới đây, chúng ta sẽ lợi dụng tính chất này một cách hữu ích.

>>> def factorial(number):
...     if not isinstance(number, int):
...         raise TypeError("Sorry. 'number' must be an integer.")
...     if number < 0:
...         raise ValueError("Sorry. 'number' must be zero or positive.")
...     def inner(number):
...         if number <= 1:
...             return 1
...         return number * inner(number - 1)
...     return inner(number)
...
>>> factorial(10)
3628800
>>> factorial(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in factorial
ValueError: Sorry. 'number' must be zero or positive.
>>> factorial(1/5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in factorial
TypeError: Sorry. 'number' must be an integer.

Bằng cách sử dụng hàm lồng nhau, chúng ta có thể định nghĩa một hàm nhận bất cứ đầu vào nào, việc kiểm tra đầu vào này hoàn toàn thực hiện bên trong hàm. Chúng ta không cần phải kiểm tra đầu vào trước khi gọi hàm factorial. Việc sử dụng hàm lồng nhau cho mục đích này đặc biệt phát huy tác dụng của nó khi chúng ta cần đến đệ quy, khi đó chúng ta cần thực hiện một số kiểm tra tham số trước khi thực sự gọi đệ quy.

Tránh lặp code (DRY)

Nhiều khi có những phần code được lặp đi lặp lại và chúng ta nên định nghĩa hàm cho nó để có thể gọi được nhiều lần. Khác với phần trước, việc định nghĩa hàm này không giúp ích gì được thuật toán hay cấu trúc chương trình, nó chỉ đơn giản là tránh lặp code mà thôi.

Ví dụ, trong trường hợp dưới đây, chúng ta cần thao tác xử lý với dữ liệu được ghi trong file. Hàm có thể nhận đầu vào là tên file hoặc một đối tượng File đã được mở.

>>> def process(file):
...     def do_something(processing_file):
...         for line in processing_file:
...             print(line)
...     if isinstance(file, str):
...         with open(file, 'r') as f:
...             do_something(f)
...     else:
...         do_something(file)
...

Tất nhiên, trong trường hợp này chúng ta có thể định nghĩa hàm do_something ở bên ngoài, ngang hàng với hàm process cũng không vấn đề gì. Tuy nhiên, chúng ta nên sử dụng hàm lồng nhau bởi vì tính đóng gói của nó. Chương trình bên ngoài không cần biết đến sự tồn tại của do_something.

Closures

Closure chính là ứng dụng quan trọng nhất của hàm lồng nhau. Xem lại những ứng dụng trên đây, chúng ta thấy rằng, dùng hàm lồng nhau cho kết quả tốt hơn nhưng điều đó là không bắt buộc. Chúng ta vẫn có thể định nghĩa các hàm ngang hàng với nhau mà chương trình cũng không thay đổi gì nhiều.

Nhưng với closure, chúng ta bắt buộc phải sử dụng đến hàm lồng nhau.

Closure là gì

Closure có thể hiểu là những hàm ghi nhớ "môi trường" nơi mà nó tạo ra. Môi trường được ghi nhớ này sẽ được sử dụng khi hàm được gọi. Thông thường, closure được tạo ra bởi hàm lồng nhau.

>>> def make_printer(msg):
...     def printer():
...         print(msg)
...     return printer
...
>>> printer = make_printer('Foo!')
>>> printer()
Foo!

Cần chú ý rằng, closure được tạo ra bởi hàm lồng nhau nhưng không phải hàm lồng nhau nào cũng là closure. Closure chỉ được tạo ra khi hàm con truy cập đến những biến cục bộ trong scope được đóng bởi hàm cha.

Trong ví dụ trên, khi make_printer được gọi, hàm con printer sẽ được định nghĩa cùng với giá trị của msg cục bộ. Sau đó, hàm printer này được trả về. Khi chúng ta thực thi hàm, bởi vì printer tham chiếu đến msg nên giá trị này sẽ được "ghi nhớ" ngay cả khi hàm make_printer đã kết thúc từ lâu.

Vì vậy, nếu một hàm lồng trong hàm khác không thực hiện một trong những điều sau thì không phải là closure

  • Truy cập đến các biến cục bộ được đóng trong scope, nơi mà nó được định nghĩa
  • Truy cập đến các biến này khi nó được gọi thực thi từ bên ngoài.

Dưới đây là một cách định nghĩa hàm lồng nhau mà không phát sinh closure.

>>> def make_printer(msg):
...     def printer(msg=msg):
...         print(msg)
...     return printer
...
>>> printer = make_printer("Foo!")
>>> printer()
Foo!

Ở đây, chúng ta sử dụng msg cục bộ làm giá trị mặc định của tham số trong hàm con. Vì vậy, không có việc tham chiếu đến giá trị cục bộ này nữa sau khi make_printer kết thúc. Giá trị msg giờ đây chỉ đơn giản là một giá trị được gán khi printer được định nghĩa mà thôi, mà rõ ràng hàm không hề "ghi nhớ" bất cứ điều gì về môi trường của nó cả.

Closure có tác dụng gì

Closure có thể phòng tránh được việc sử dụng biến cục bộ mà có thể đóng gói các hàm. Điều này giúp chúng ta lập trình thủ tục nhưng có thể tạo ra các hàm có nhiều tính chất của lập trình hướng đối tượng.

Khi chúng ta cần định nghĩa một class với chỉ vài phương thức, closure có thể được sử dụng như một giải pháp thay thế và thường là closure sẽ nhẹ nhàng hơn lập trình hướng đối tượng. Tuy nhiên là khi các thuộc tính và phương thức nhiều nên, chúng ta nên sử dụng lập trình hướng đối tượng thì hơn.

Một ví dụ về sử dụng closure đã được đưa ra ở trên, chúng ta sử dụng closure để định nghĩa các hàm động. Trong trường hợp này, sử dụng phương pháp lập trình hướng đối tượng có vẻ không phải là một giải pháp hay cho lắm.

>>> def fpower(exp):
...     def inner(x):
...         return x ** exp
...     return inner
...
>>> square = fpower(2)
>>> square(2)
4
>>> cube = fpower(3)
>>> cube(3)
27

Ví dụ thực tế

Trong thực tế, closure nói riêng mà hàm lồng nhau nói chung của Python được sử dụng rất phổ biến. Dưới đây là một ứng dụng của closure trong việc login/logout cũng như authorize cho một ứng dụng Web viết bằng CherryPy.

#!python
# -*- encoding: UTF-8 -*-
#
# Form based authentication for CherryPy. Requires the
# Session tool to be loaded.
#

import cherrypy

SESSION_KEY = '_cp_username'

def check_credentials(username, password):
    """Verifies credentials for username and password.
    Returns None on success or a string describing the error on failure"""
    # Adapt to your needs
    if username in ('joe', 'steve') and password == 'secret':
        return None
    else:
        return u"Incorrect username or password."

    # An example implementation which uses an ORM could be:
    # u = User.get(username)
    # if u is None:
    #     return u"Username %s is unknown to me." % username
    # if u.password != md5.new(password).hexdigest():
    #     return u"Incorrect password"

def check_auth(*args, **kwargs):
    """A tool that looks in config for 'auth.require'. If found and it
    is not None, a login is required and the entry is evaluated as a list of
    conditions that the user must fulfill"""
    conditions = cherrypy.request.config.get('auth.require', None)
    if conditions is not None:
        username = cherrypy.session.get(SESSION_KEY)
        if username:
            cherrypy.request.login = username
            for condition in conditions:
                # A condition is just a callable that returns true or false
                if not condition():
                    raise cherrypy.HTTPRedirect("/auth/login")
        else:
            raise cherrypy.HTTPRedirect("/auth/login")

cherrypy.tools.auth = cherrypy.Tool('before_handler', check_auth)

def require(*conditions):
    """A decorator that appends conditions to the auth.require config
    variable."""
    def decorate(f):
        if not hasattr(f, '_cp_config'):
            f._cp_config = dict()
        if 'auth.require' not in f._cp_config:
            f._cp_config['auth.require'] = []
        f._cp_config['auth.require'].extend(conditions)
        return f
    return decorate


# Conditions are callables that return True
# if the user fulfills the conditions they define, False otherwise
#
# They can access the current username as cherrypy.request.login
#
# Define those at will however suits the application.

def member_of(groupname):
    def check():
        # replace with actual check if <username> is in <groupname>
        return cherrypy.request.login == 'joe' and groupname == 'admin'
    return check

def name_is(reqd_username):
    return lambda: reqd_username == cherrypy.request.login

# These might be handy

def any_of(*conditions):
    """Returns True if any of the conditions match"""
    def check():
        for c in conditions:
            if c():
                return True
        return False
    return check

# By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions):
    """Returns True if all of the conditions match"""
    def check():
        for c in conditions:
            if not c():
                return False
        return True
    return check


# Controller to provide login and logout actions

class AuthController(object):

    def on_login(self, username):
        """Called on successful login"""

    def on_logout(self, username):
        """Called on logout"""

    def get_loginform(self, username, msg="Enter login information", from_page="/"):
        return """<html><body>
            <form method="post" action="/auth/login">
            <input type="hidden" name="from_page" value="%(from_page)s" />
            %(msg)s<br />
            Username: <input type="text" name="username" value="%(username)s" /><br />
            Password: <input type="password" name="password" /><br />
            <input type="submit" value="Log in" />
        </body></html>""" % locals()

    @cherrypy.expose
    def login(self, username=None, password=None, from_page="/"):
        if username is None or password is None:
            return self.get_loginform("", from_page=from_page)

        error_msg = check_credentials(username, password)
        if error_msg:
            return self.get_loginform(username, error_msg, from_page)
        else:
            cherrypy.session[SESSION_KEY] = cherrypy.request.login = username
            self.on_login(username)
            raise cherrypy.HTTPRedirect(from_page or "/")

    @cherrypy.expose
    def logout(self, from_page="/"):
        sess = cherrypy.session
        username = sess.get(SESSION_KEY, None)
        sess[SESSION_KEY] = None
        if username:
            cherrypy.request.login = None
            self.on_logout(username)
        raise cherrypy.HTTPRedirect(from_page or "/")

Bạn thấy đó, closure được sử dụng rất thường xuyên.

Kết luận

Hàm lồng nhau trong Python là rất quan trọng, và nếu biết sử dụng nó đúng cách, chúng ta sẽ viết được những đoạn code đẹp, hiệu suất cao và dễ bảo trì. Trong những ứng dụng của hàm lồng nhau, closure chính là ứng dụng quan trọng nhất. Thực ra, nếu bạn sử dụng decorator trong Python, bạn đã sử dụng closure rồi đó.

Hy vọng bài viết có ích, các bạn code Python ngày càng bá hơn!!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí