Hàm `super()` trong Python và một số vấn đề liên quan

Khi lập trình hướng đối tượng với Python, ta thường bắt gặp các câu lệnh như super().__init__() hoặc super().method() nhất là khi đọc doc của các thư viện có các lớp kế thừa nhiều lần. Bài viết hôm nay của mình sẽ hướng đến việc giới thiệu hàm super() và các trường hợp sử dụng

Khi lập trình hướng đối tượng với Python, ta thường bắt gặp các câu lệnh như super().__init__() hoặc super().method() nhất là khi đọc doc của các thư viện có các lớp kế thừa nhiều lần. Bài viết hôm nay của mình sẽ hướng đến việc giới thiệu hàm super() và các trường hợp sử dụng nó.

Photo by Unsplash

1. Kế thừa trong Python

Để hiểu rõ hơn về vai trò của super(). Mình sẽ bắt đầu với trường hợp không sử dụng super() trong kế thừa trước.
Cho lớp cha Parent và được kế thừa bởi lớp con Children(Parent), khi đó lớp Children có thể gọi các phương thức hoặc thuộc tính từ lớp cha.

classParent:defself_intro(self):print("This is parent class")defparent_method(self):print("This is parent method")classChildren(Parent):defself_intro(self):print("This is children class")

c = Children()
c.parent_method()# >>> This is parent method

Tuy nhiên, sẽ xảy ra trường hợp ChildrenParent có phương thức trùng tên với nhau là self_intro và ta cần gọi phương thức self_intro của Parent bên trong phương thức family_intro của Children.
Trường hợp này vẫn có thể được giải quyết mà không cần dùng đến super() bằng cách gọi trực tiếp Parent.self_intro(self) bên trong family_intro()

classParent:defself_intro(self):print("This is parent class")defparent_method(self):print("This is parent method")classChildren(Parent):defself_intro(self):print("This is children class")deffamily_intro(self):
        self.self_intro()# Same as Children.self_intro(self)
        Parent.self_intro(self)

c = Children()
c.family_intro()# >>> This is children class# >>> This is parent class

Nhiều bạn đã biết về bound method, unbound method có thể sẽ thắc mắc tại sao lại sử dụng tham số self cho phương thức self_intro dù hàm này không sử dụng thuộc tính hay phương thức của self. Vì mình muốn tập trung vào việc giới thiệu phương pháp kế thừa nên đã viết đơn giản hơn thay vì thêm các decorator và thay đổi cách gọi hàm. Bạn nào có nhu cầu biết thêm về bound method, unbound method có thể tham khảo tại đây

Chúng ta cũng có thể giải quyết trường hợp này bằng super() thay vì gọi trực tiếp

classChildren(Parent):defself_intro(self):print("This is children class")deffamily_intro(self):
        self.self_intro()# Same as Children.self_intro(self)super().self_intro()

c = Children()
c.family_intro()# >>> This is children class# >>> This is parent class

Hàm super() lúc này sẽ trả về một đối tượng thuộc lớp kế thừa từ Children lúc này là Parent và gọi self_intro(). Khác với cách gọi trực tiếp, super() không cần viết lại tên lớp Parent khi gọi hàm, việc này sẽ giúp tránh bị các lỗi chính tả hoặc bạn có nhu cầu đổi tên lớp cha hoặc kế thừa từ lớp khác.
Nhưng đấy vẫn chưa phải là tất cả điểm mạnh của super(). super() được sử dụng linh hoạt trong các trường hợp đa kế thừa đặc biệt là Diamond Problem mà mình sẽ giới thiệu sau đây.
Trước tiên chúng ta cần phải hiểu rõ một vài khái niệm và các tham số của super()

2. Method resolution order (MRO)

Photo by Unsplash
MRO có thể hiểu đơn giản là trình tự kế thừa của lớp. MRO của một lớp có thể được truy xuất bằng phương thức mro()
MRO sẽ được tạo để đảm bảo các lớp chỉ được liệt kê một lần và các lớp con phải được gọi trước lớp cha. Nếu bạn muốn tìm hiểu thêm về thuật toán tạo MRO của Python thì tham khảo tại đây

classParent:passclassChildren(Parent):pass

Children.mro()# >>> [__main__.Children, __main__.Parent, object]

Khi sử dụng một phương thức với đối tượng thuộc lớp Children, chương trình sẽ tìm kiếm phương thức dựa trên thứ tự MRO như trên, tức là bắt đầu từ Children, nếu không có thì sẽ tìm đến Parent và sau cùng là object (base class mặc định cho mọi loại dữ liệu Python)

3. Tham số của hàm super()

Hàm super(type, object) sẽ nhận vào hai tham số typeobject:

  • type sẽ nhận giá trị kiểu lớp để khi tìm kiếm phương thức hoặc thuộc tính, chương trình sẽ tìm các lớp cha sau type trong MRO của lớp. Dựa vào type ta có thể quyết định phương thức cần gọi được cài đặt trong lớp cha hoặc lớp ông nội.
  • object sẽ nhận vào một đối tượng để ràng buộc (bound) với phương thức hoặc thuộc tính được gọi bởi super().

Để dễ hình dung, super(type, object).method() có thể hiểu là object.method() với phương thức method được cài đặt trong lớp cha của type.

Xét ví dụ trên, hàm super() được gọi trong lớp Children sẽ có giá trị tham số mặc định là super(Children, self).

classGrandparent:defcall_method(self):print("This is Grandparent method")classParent(Grandparent):defcall_method(self):print("This is Parent method")classChildren(Parent):defcall_method(self):# call call_method of GrandParent# class instead of Parent classsuper(Parent, self).call_method()

c = Children()
c.call_method()# >>> This is Grandparent method

Ngoài ra typeobject còn có một số ràng buộc để chương trình chạy không bị lỗi mà bạn có thể tham khảo tại doc của super()

Lưu ý: Nếu trong call_method() của lớp Grandparent gọi tiếp super().call_method(). Hàm sẽ tìm call_method() của các lớp phía sau lớp Grandparent trong MRO. Ở đây MRO sẽ là [__main__.Children, __main__.Parent, __main__.Grandparent, object], vì object không được cài đặt call_method() nên sẽ trả về lỗi AttributeError: 'super' object has no attribute 'call_method'.

4. Giải quyết Diamond Problem bằng super()

Source: me
Diamond Problem xuất hiện khi ta thực hiện đa kế thừa trên hai lớp cha cùng kế thừa từ một lớp ông nội.
Xét trường hợp ta có các lớp sau:

  • Lớp Grandparent
  • Lớp ParentA(Grandparent)ParentB(Grandparent) cùng kế thừa từ lớp GrandParent
  • Lớp Children(ParentA, ParentB) đa kế thừa từ hai lớp ParentAParentB

Khi đó chúng ta sẽ gặp các vấn đề phát sinh sau:

ParentAParentB có phương thức trùng tên nhau

Nếu bạn gọi phương thức bằng super(), phương thức của lớp có thứ tự nhỏ hơn trong MRO sẽ được gọi trước. Trong trường hợp này, thứ tự lớp cha trong MRO sẽ là thứ tự liệt kê lớp cha trong lúc khai báo lớp Children.

classGrandparent:defcall_method(self):print("This is Grandparent method")classParentA(Grandparent):defcall_method(self):print("This is ParentA method")classParentB(Grandparent):defcall_method(self):print("This is ParentB method")classChildren(ParentA, ParentB):defsay_name(self):super().say_name()

c = Children()print(Children.mro())# >>> [<class '__main__.Children'>, <class '__main__.ParentA'>, #      <class '__main__.ParentB'>, <class '__main__.Grandparent'>, <class 'object'>]
c.call_method()# >>> This is ParentA method

Nếu muốn gọi call_method() của ParentB ta có thể làm như sau:

  • Đổi thứ tự khai báo class Children(ParentB, ParentA)
  • Gọi trực tiếp ParentB.say_name(self)

Phương thức của lớp Grandparent được gọi lại hai lần

Vấn đề này gặp khi chúng ta muốn gọi phương thức khởi tạo của ParentAParentB trực tiếp bên trong Children, nhưng phương thức khởi tạo của ParentAParentB lại gọi phương thức khởi tạo của GrandParent. Khi đó sẽ xảy ra việc phương thức khởi tạo của GrandParent bị gọi 2 lần.

classGrandparent:def__init__(self):print("Grandparent Init")classParentA(Grandparent):def__init__(self):print("ParentA Init")
        GrandParent.__init__(self)classParentB(Grandparent):def__init__(self):print("ParentB Init")
        GrandParent.__init__(self)classChildren(ParentA, ParentB):def__init__(self):print("Children Init")
        ParentA.__init__(self)
        ParentB.__init__(self)

Children()# >>> Children Init# >>> ParentA Init# >>> Grandparent Init# >>> ParentB Init# >>> Grandparent Init

Cách giải quyết: sử dụng hàm super() thay vì gọi trực tiếp. Như đã lưu ý ở mục 3. hàm super() sẽ tìm các phương thức khởi tạo dựa trên MRO và lớp hiện tại. Vì các lớp chỉ xuất hiện trong MRO duy nhất một lần nên khi gọi super().__init__() sẽ tránh được việc gọi nhiều lần, giảm thiểu thời gian chạy và tránh bị ghi đè không cần thiết.

classGrandparent:def__init__(self):print("Grandparent Init")classParentA(Grandparent):def__init__(self):print("ParentA Init")super().__init__()classParentB(Grandparent):def__init__(self):print("ParentB Init")super().__init__()classChildren(ParentA, ParentB):def__init__(self):print("Children Init")super().__init__()
        

Children()# >>> Children Init# >>> ParentA Init# >>> ParentB Init# >>> Grandparent Init

Tuy nhiên cách này lại dẫn đến vấn đề sau

Phương thức khởi tạo của ParentAParentB cần các tham số khác nhau
Giả sử ta cần lưu số tuổi của mỗi lớp thông qua các biến gp_age, pb_age, pa_agec_age
Xét đoạn code sau đây:

classGrandparent:def__init__(self, gp_age):
        self.gp_age = gp_age
        print(f"Grandparent age: {self.gp_age}")classParentB(Grandparent):def__init__(self, pb_age, gp_age):
        self.pb_age = pb_age
        print(f"ParentB age: {self.pb_age}")super().__init__(gp_age)classParentA(Grandparent):def__init__(self, pa_age, pb_age, gp_age):
        self.pa_age = pa_age
        print(f"ParentA age: {self.pa_age}")super().__init__(pb_age, gp_age)classChildren(ParentA, ParentB):def__init__(self, c_age, pa_age, pb_age, gp_age):
        self.c_age = c_age
        print(f"Children age: {self.c_age}")super().__init__(pa_age, pb_age, gp_age)

Children(c_age="15", pa_age="40", pb_age="45", gp_age="70")# >>> Children age: 15# >>> ParentA age: 40# >>> ParentB age: 45# >>> Grandparent age: 70

Mỗi lớp đều cần một tham số khác nhau khi khởi tạo nên theo thứ tự MRO biết trước, chúng ta có thể viết code như trên. Tuy code chạy đúng như ý muốn nhưng mình tin chẳng ai muốn code như trên vì các lí do sau:

  • Sai bản chất: trừ khi ParentAParentB chỉ được khởi tạo đúng một lần và chỉ dùng để tạo lớp Children, với code trên khi tạo đối tượng thuộc lớp ParentA hoặc ParentB sẽ bị lỗi dư tham số.
  • Quá cứng nhắc: Vì tham số truyền vào __init__ dựa trên MRO nên sẽ phụ thuộc thứ tự khai báo của lớp, nên chỉ cần thay đổi lại thứ tự có thể làm code chạy bị lỗi.
  • Khó tái sử dụng: Giả sử như chúng ta có thêm lớp ParentC và muốn tạo lớp đa kế thừa từ ParentAParentC. Khi đó ta phải viết lại __init__ của ParentA để đa kế thừa, điều này có thể làm ảnh hưởng đến lớp Children hiện tại.

Cách giải quyết: sử dụng **kwargs trong __init__. Khi đó chúng ta cần phải thiết kế lại tất cả các phương thức __init__ của tất cả các lớp:

classGrandparent:def__init__(self, gp_age,**kwargs):
        self.gp_age = gp_age
        print(f"Grandparent age: {self.gp_age}")classParentB(Grandparent):def__init__(self, pb_age,**kwargs):
        self.pb_age = pb_age
        print(f"ParrentB age: {self.pb_age}")super().__init__(**kwargs)classParentA(Grandparent):def__init__(self, pa_age,**kwargs):
        self.pa_age = pa_age
        print(f"ParentA age: {self.pa_age}")super().__init__(**kwargs)classChildren(ParentA, ParentB):def__init__(self, c_age,**kwargs):
        self.c_age = c_age
        print(f"Children age: {self.c_age}")super().__init__(**kwargs)

Children(c_age="15", pa_age="40", pb_age="45", gp_age="70")# >>> Children age: 15# >>> ParentA age: 40# >>> ParentB age: 45# >>> Grandparent age: 70

Code vẫn trả về kết quả như ý nhưng gọn hơn và dễ bảo trì, mở rộng hơn!

Chúng ta vẫn có thể giải quyết trường hợp này bằng cách gọi phương thức khởi tạo trực tiếp nếu bạn đảm bảo không bị ghi đè cho lớp Grandparent hoặc việc ghi đè không gây ảnh hưởng gì (nhưng mình vẫn khuyến khích sử dụng super() hơn vì lí do bảo trì và mở rộng!)

5. Kết bài

Qua bài này mình đã trình bày với các bạn:

  • Gọi phương thức từ lớp cha bằng super() hoặc trực tiếp
  • Khái niệm MRO và ý nghĩa tham số của super()
  • Diamond Problem và cách giải quyết bằng super()

Nếu bài viết có chỗ nào không rõ hoặc sai thì xin hãy cho mình biết. Cảm ơn các bạn đã dành thời gian đọc bài viết này!

6. Tham khảo

Nguồn: viblo.asia

Bài viết liên quan

WebP là gì? Hướng dẫn cách để chuyển hình ảnh jpg, png qua webp

WebP là gì? WebP là một định dạng ảnh hiện đại, được phát triển bởi Google

Điểm khác biệt giữa IPv4 và IPv6 là gì?

IPv4 và IPv6 là hai phiên bản của hệ thống địa chỉ Giao thức Internet (IP). IP l

Check nameservers của tên miền xem website trỏ đúng chưa

Tìm hiểu cách check nameservers của tên miền để xác định tên miền đó đang dùn

Mình đang dùng Google Domains để check tên miền hàng ngày

Từ khi thông báo dịch vụ Google Domains bỏ mác Beta, mình mới để ý và bắt đầ