Note: see the link below for the English version of this article.

https://duongnt.com/init_subclass-metaclass

Use init_subclass to replace metaclass

Metaclass là một tính năng thú vị trong Python. Nó giúp ta lấy thông tin và thay đổi hành vi của lớp mà không cần thay đổi mã nguồn. Nhưng với các bài toán đơn giản, sử dụng metaclass cũng giống như dùng dao giết ruồi. Từ phiên bản 3.6, Python bổ sung một hàm mới với tên gọi là __init_subclass__. Ta có thể dùng hàm này thay cho metaclass trong phần lớn các trường hợp.

Khái quát về metaclass

Metaclass là một lớp đặc biệt, nó kế thừa trực tiếp từ type và định nghĩa hàm __new__. Hàm này được gọi ngay tại thời điểm ta import lớp, trước khi bất kỳ đối tượng nào thuộc lớp được khởi tạo.

class DummyMeta(type):
    def __new__(cls, name, bases, class_dict):
        # Code trong hàm này được chạy khi import lớp

class DummyClass(metaclass=DummyClass):
    # Code để định nghĩa lớp

Dưới đây là ý nghĩa của các tham số trong hàm __new__.

  • cls: metaclass hiện tại. Trong ví dụ của ta, giá trị này là DummyMeta.
  • name: tên của lớp sử dụng metaclass. Trong ví dụ của ta, giá trị này là DummyClass.
  • bases: tất cả các lớp cha của DummyClass.
  • class_dict: các thành viên trong DummyClass. Chúng bao gồm cả thuộc tính lẫn hàm.

Dùng metaclass để bắt buộc các class khác phải đặt tên sử dụng snake case

Theo quy ước chung, tên của hàm và thuộc tính trong Python thường sử dụng snake case. Ta sẽ viết một metaclass để tạo exception khi một lớp định nghĩa hàm hay tham số với tên gọi sử dụng camel case hoặc pascal case. Bước kiểm tra này được thực hiện ngay khi ta import lớp, trước khi các đối tượng thuộc lớp được khởi tạo.

Đầu tiên, ta định nghĩa một metaclass với tên gọi SnakeCaseMeta.

class SnakeCaseMeta(type):
    def __new__(cls, name, bases, class_dict):
        not_camel_case = set()

        for ele in class_dict:
            if cls._not_snake_case(ele) and ele not in not_camel_case:
                not_camel_case.add(ele)

        if not_camel_case:
            raise ValueError(f'Các thành viên sau đây có tên không dùng snake case: {", ".join(not_camel_case)}')

        return type.__new__(cls, name, bases, class_dict)

    @classmethod
    def _not_snake_case(cls, txt):
        return txt.lower() != txt

Trong hàm __new__, ta duyệt qua tất cả các thuộc tính và hàm của lớp, rồi kiểm tra xem tên của chúng có dùng snake case hay không. Tất cả những tên không dùng snake case sẽ được lưu vào trong một list, rồi ta hiển thị chúng trong thông báo lỗi.

Chú ý là hàm _not_snake_case chỉ kiểm tra xem text có chứa ký tự viết hoa hay không. Cách kiểm tra này là không đủ để dùng trong các project thực tế.

Cách dùng SnakeCaseMeta rất đơn giản.

class Animal(metaclass=SnakeCaseMeta):
    def EatMethod(self):
        print('This animal can eat.')

    def sleepMethod(self):
        print('This animal can sleep.')

Khi ta import module chứa lớp Animal, interpreter sẽ gặp lỗi dưới đây.

ValueError: Các thành viên sau đây có tên không dùng snake case: EatMethod, sleepMethod

Bước kiểm tra này không chỉ được áp dụng cho Animal mà còn được áp dụng cho các lớp con của nó.

Hạn chế của metaclass

Ta phải định nghĩa metaclass riêng

Giả sử ta có cây các lớp con của Animal như dưới đây.

Animal classes with metaclass

Animal sử dụng SnakeCaseMeta làm metaclass nên bước kiểm tra sẽ được áp dụng cho tất cả các lớp con. Đây là điều ta mong muốn, nhưng sẽ còn tiện hơn nếu ta có thể loại bỏ metaclass và chuyển bước kiểm tra vào trong Animal.

Một class chỉ có thể có một metaclass

Giả sử ta muốn ghi lại tất cả những thời điểm lớp Animal được import bằng metaclass dưới đây.

class LogImportMeta(type):
    def __new__(cls, name, bases, class_dict):
        print(f'Import class {name}')

    return type.__new__(cls, name, bases, class_dict)

Nhưng ở đây ta gặp khó khăn: một lớp chỉ có thể có một metaclass. Vì Animal đã dùng SnakeCaseMeta làm metaclass nên nó không thể dùng LogImportMeta được nữa. Ta có thể giải quyết vấn đề này bằng một vài cách dưới đây, nhưng không cách nào là hoàn hảo.

  • Kết hợp 2 lớp LogImportMetaSnakeCaseMeta lại làm một. Cách này vi phạm quy tắc single-responsibility principle, vì metaclass mới sẽ đảm nhiệm 2 việc hoàn toàn khác nhau.
  • Tạo cây các metaclass như dưới đây. Cách này làm tăng độ phức tạp của code. Và cây metaclass đó cũng không mấy hợp lý. Metaclasses hierarchy

Thay thế metaclass bằng init_subclass

Từ bản 3.6, Python bổ sung một hàm mới với tên gọi là __init_subclass__. Trong nhiều trường hợp, ta có thể thay thế metaclass bằng việc định nghĩa hàm __init__subclass__. Dưới đây là cách dùng __init__subclass__ để kiểm tra xem tên thành viên trong class có dùng snake case hay không.

class VerifySnakeCase:
    def __init_subclass__(cls):
        super().__init_subclass__()

        not_camel_case = set()
        for ele in cls.__dict__:
            if cls._not_snake_case(ele) and ele not in not_camel_case:
                not_camel_case.add(ele)

        if not_camel_case:
            raise ValueError(f'Các thành viên sau đây có tên không dùng snake case: {", ".join(not_camel_case)}')

    @classmethod
    def _not_snake_case(cls, txt):
        return txt.lower() != txt

Sau đó ta có thể áp dùng VerifySnakeCase cho các lớp khác bằng cách dùng kế thừa.

class Animal(VerifySnakeCase):
    # lược bỏ phần còn lại

Tương tự trên, ta định nghĩa lớp LogImport như sau.

class LogImport:
    def __init_subclass__(cls):
        super().__init_subclass__()
        print(f'Import {cls}')

Lúc này, ta có thể dùng đồng thời cả VerifySnakeCase lẫn LogImport.

class Animal(VerifySnakeCase, LogImport):
    # lược bỏ phần còn lại

Kết thúc

Metaprogramming là một trong những tính năng thú vị nhất của Python. Nó giúp tăng sức mạnh và độ linh hoạt cho Python. Và với sự bổ sung của hàm __init_subclass__, metaprogramming lại càng trở nên dễ dàng và đơn giản hơn.

A software developer from Vietnam and is currently living in Japan.

One Thought on “Dùng init_subclass để thay thế cho metaclass”

Leave a Reply