Chương 17: Những đặc điểm của lập trình hướng đối tượng

Trở về Mục lục cuốn sách

Python là một ngôn ngữ lập trình hướng đối tượng, theo nghĩa là nó có những đặc điểm hỗ trợ lập trình hướng đối tượng.

Thật không dễ định nghĩa lập trình hướng đối tượng là gì, nhưng ta đã thấy một số đặc điểm của nó:

  • Các chương trình được cấu thành từ các định nghĩa đối tượng và định nghĩa hàm, và phần lớn việc tính toán đều được diễn đạt trên những thao tác với đối tượng.
  • Mỗi định nghĩa đối tượng đều tương ứng với một đối tượng hoặc khái niệm nào đó trong thực tại, và các hàm thao tác trên đối tượng đó tương ứng với cách mà những đối tượng thực tại này tương tác với nhau.

Chẳng hạn, lớp Time định nghĩa trong Chương 16 tương ứng với cách mà con người ghi chép thời gian trong ngày, và các hàm ta đã định nghĩa tương ứng với những thao tác mà con người thường tính với thời gian. Tương tự, các lớp PointRectangle tương ứng với những khái niệm toán học là điểm và hình chữ nhật.

Cho đến giờ, ta vẫn chưa lợi dụng những đặc tính sẵn có của Python để phục vụ cho lập trình hướng đối tượng. Những đặc tính này đều không nhất thiết phải có, chúng hầu hết chỉ là dạng cú pháp thay thế cho những câu lệnh ta đã viết. Tuy nhiên trong nhiều trường hợp, dạng thay thế này gọn hơn và chuyển tại ý tưởng về cấu trúc chương trình một cách chính xác hơn.

Chẳng hạn, trong chương trình Time, không có mối liên hệ rõ nét nào giữa lời định nghĩa lớp và các định nghĩa hàm theo sau nó. Song khi xem xét kĩ hơn, ta thấy được mỗi hàm đều nhận ít nhất là một đối tượng Time làm đối số.

Nhận định trên là cơ sở cho việc hình thành các phương thức; một phương thức là một hàm được gắn liền với một lớp cụ thể. Ta đã gặp những phương thức dành cho chuỗi, danh sách, từ điển, và bộ. Trong chương này, ta sẽ định nghĩa các phương thức cho kiểu dữ liệu do người dùng định nghĩa.

Các phương thức về mặt ngữ nghĩa thì cũng giống như hàm, nhưng về cú pháp thì có hai điểm khác biệt:

  • Các phương thức được định nghĩa trong lời định nghĩa lớp nhằm biểu thị rõ mối quan hệ giữa lớp và phương thức đó.
  • Cú pháp để gọi một phương thức khác với cú pháp để gọi hàm.

Trong một vài mục tiếp theo, ta sẽ lấy các hàm từ hai chương trước và chuyển đổi chúng thành các phương thức. Việc chuyển đổi này chỉ là máy móc; bạn có thể thực hiện được theo một số bước. Khi đã thạo việc chuyển đổi giữa các dạng, bạn có thể chọn được dạng phù hợp nhất với công việc đang làm.

Các đối tượng thực hiện việc in

Ở Chương 16, ta đã định nghĩa một lớp tên là Time và trong Bài tập {printtime}, bạn đã viết một hàm có tên print_time:

class Time(object):
    """represents the time of day.
       attributes: hour, minute, second"""

def print_time(time):
    print '%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second)

Để gọi hàm này, bạn cần phải truyền một đối tượng Time như là đối số:

>>> start = Time()
>>> start.hour = 9
>>> start.minute = 45
>>> start.second = 00
>>> print_time(start)
09:45:00

Để biến print_time thành một phương thức, toàn bộ những gì ta phải làm chỉ là chuyển lời định nghĩa hàm vào trong định nghĩa lớp. Chú ý rằng khoảng thụt đầu dòng được thay đổi.

class Time(object):
    def print_time(time):
        print '%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second)

Bây giờ có hai cách gọi print_time. Cách thứ nhất (ít thông dụng) là dùng cú pháp của hàm:

>>> Time.print_time(start)
09:45:00

Trong cách dùng kí hiệu dấu chấm này, Time là tên của lớp, còn print_time là tên của phương thức. start được truyền vào làm tham biến.

Cách thứ hai (gọn gàng hơn) là dùng cú pháp của phương thức:

>>> start.print_time()
09:45:00

Trong cách dùng kí hiệu dấu chấm này, print_time (một lần nữa) là tên của phương thức, còn start là tên của đối tượng mà phương phức đó được gọi, còn có tên là chủ thể. Vai trò của chủ thể trong một lời gọi phương thức cũng như vai trò của chủ ngữ trong một câu văn.

Bên trong phương thức, chủ thể được gán là tham biến thứ nhất. VÌ vậy trong trường hợp này, start được gán cho time.

Theo quy ước, tham biến thứ nhất của một phương thức được gọi là self, vì vậy cách viết print_time như sau là thông dụng hơn:

class Time(object):
    def print_time(self):
        print '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)

Lí do có quy ước này là từ phép ẩn dụ:

  • Cú pháp của một lời gọi hàm, print_time(start), hàm ý rằng hàm đó là chủ thể. Nó kiểu như lời yêu cầu “Này print_time! Đây là một đối tượng mà cậu cần in ra”.
  • Trong lập trình hướng đối tượng, các đối tượng là chủ thể. Một lời gọi phương thức như start.print_time() giống câu nói “Này start! Hãy tự in bản thân cậu đi”.

Sự thay đổi về góc nhìn này có vẻ khiêm tốn, nhưng thật không dễ thấy lợi ích mà nó mang lại. Ở những ví dụ ta đã thảo luận, có thể là chưa. Nhưng đôi khi việc chuyển trách nhiệm từ hàm sang đối tượng có thể giúp ta dễ dàng viết được các hàm linh động hơn, cũng dễ bảo trì và sử dụng lại mã lệnh hơn.

Hãy viết lại time_to_int (ở Mục {hình mẫu}) dưới dạng phương thức. Có thể sẽ không phù hợp khi viết lại int_to_time như vậy; thật không rõ ràng là bạn muốn gọi phương thức đó từ đối tượng nào!

Một ví dụ khác

Sau đây là một dạng của increment (trong Mục {phát triển tăng dần}) được viết lại như một phương thức:

# inside class Time:

    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)

Dạng này có giả sử rằng time_to_int được viết như một phương thức, như trong Bài tập {chuyển đổi}. Đồng thời, cần thấy rằng đó là một hàm thuần túy, không phải một hàm sửa đổi.

Sau đây là cách gọi increment:

>>> start.print_time()
09:45:00
>>> end = start.increment(1337)
>>> end.print_time()
10:07:17

Chủ thể, start, được gán cho tham biến thứ nhất, self. Đối số, 1337, được gán cho tham biến thứ hai, seconds.

Cơ chế làm việc này có thể gây nhầm lẫn, đặc biệt khi bạn mắc phải lỗi. Chẳng hạn, nếu gọi increment với hai đối số, bạn sẽ nhận được:

>>> end = start.increment(1337, 460)
TypeError: increment() takes exactly 2 arguments (3 given)

Thông báo lỗi thoạt đầu nhìn rất khó hiểu, vì chỉ có hai đối số trong ngoặc kép. Nhưng chủ thể cũng được coi là một đối số, nên tổng cộng có ba đối số.

Một ví dụ phức tạp hơn

is_after (trong Bài tập {is_after}) khó hơn một chút vì nó nhận vào hai đối tượng Time làm tham biến. Ở trường hợp này theo quy ước thì tham biến thứ nhất được gọi là self và tham biến thứ hai là other:

# inside class Time:

    def is_after(self, other):
        return self.time_to_int() > other.time_to_int()

Để dùng phương thức này, bạn phải gọi nó từ một đối tượng và truyền đối tượng kia như một đối số:

>>> end.is_after(start)
True

Một điều hay ở dạng cú pháp này là cách đọc nó giống với tiếng Anh: “end xảy ra sau start?”

Phương thức init

Phương thức init (gọi tắt của “initialization”) là một phương thức đặc biệt được gọi khi một đối tượng được khởi tạo (cá thể hóa). Tên đầy đủ của nó là __init__ (hai dấu gạch thấp, tiếp theo là init, rồi hai dấu gạch thấp khác). Một phương thức init cho lớp Time có thể trông như sau:

# inside class Time:

    def __init__(self, hour=0, minute=0, second=0):
        self.hour = hour
        self.minute = minute
        self.second = second

Thường thì các tham biến của __init__ cũng có tên giống như các thuộc tính. Câu lệnh

        self.hour = hour

lưu lại giá trị của tham biến hour như một thuộc tính của self.

Các tham biến đều là tùy chọn, vì vậy nếu gọi Time mà không có đối số nào, bạn sẽ thu được các giá trị mặc định.

>>> time = Time()
>>> time.print_time()
00:00:00

Nếu bạn cấp cho một đối số, nó sẽ thay cho giá trị mặc định của hour:

>>> time = Time (9)
>>> time.print_time()
09:00:00

Nếu bạn cấp hai đối số, chúng sẽ thay cho các giá trị mặc định của hourminute.

>>> time = Time(9, 45)
>>> time.print_time()
09:45:00

Và nếu bạn cấp ba đối số, chúng sẽ thay thế tất cả ba giá trị mặc định.

Hãy viết một phương thức init cho lớp Point nhận vào các tham biến tùy chọn xy rồi gán chúng cho các thuộc tính tương ứng.

Phương thức __str__

__str__ là một phương thức đặc biệt, cũng như __init__. Nó được dùng để trả lại một chuỗi biểu diễn cho một đối tượng.

Chẳng hạn, sau đây là một phương thức str cho đối tượng Time:

# ben trong lop Time:

    def __str__(self):
        return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)

Khi bạn print một đối tượng, Python sẽ gọi phương thức str:

>>> time = Time(9, 45)
>>> print time
09:45:00

Khi viết một lớp mới, tôi luôn bắt đầu bằng việc viết __init__, để làm cho việc cá thể hóa đối tượng dễ dàng hơn, và __str__, để cho việc gỡ lỗi được dễ dàng hơn.

Hãy viết một phương thức str cho lớp Point. Sau đó tạo một đối tượng Point và in nó ra.

Toán tử đa năng

Bằng việc định nghĩa các phương thức đặc biệt, bạn có thể chỉ định tính năng của các toán tử hoạt động trên kiểu dữ liệu do người dùng định nghĩa. Chẳng hạn, nếu bạn định nghĩa một phương thức có tên __add__ cho lớp Time, bạn có thể dùng toán tử + với các đối tượng Time.

Sau đây là một ví dụ về cách định nghĩa nêu trên:

# ben trong lop Time:

    def __add__(self, other):
        seconds = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)

Và sau đây là cách sử dụng nó:

>>> start = Time(9, 45)

>>> duration = Time(1, 35)
>>> print start + duration
11:20:00

Khi áp dụng toán tử + với các đối tượng Time, Python sẽ gọi __add__. Khi bạn in ra kết quả, Python gọi __str__. Như vậy là có khá nhiều điều đang diễn ra nơi “hậu trường”!

Việc thay đổi tính năng của một toán tử sao cho nó có thể làm việc được với các kiểu do người dùng định nghĩa được gọi là làm cho toán tử trở nên đa năng. Mỗi toán tử trong Python có một phương thức đặc biệt tương ứng, như __add__. Để biết thêm thông tin, hãy xem docs.python.org/ref/specialnames.html.

Hãy viết một phương thức add cho lớp Point.

Cắt cử dựa theo kiểu dữ liệu

Ở mục trước, ta đã cộng hai đối tượng Time, nhưng bạn có lẽ cũng muốn cộng một số nguyên với đối tượng Time. Sau đây là một dạng của __add__ để kiểm tra kiểu của other và gọi add_time hoặc increment:

# ben trong lop Time:

    def __add__(self, other):
        if isinstance(other, Time):
            return self.add_time(other)
        else:
            return self.increment(other)

    def add_time(self, other):
        seconds = self.time_to_int() + other.time_to_int()
        return int_to_time(seconds)

    def increment(self, seconds):
        seconds += self.time_to_int()
        return int_to_time(seconds)

Hàm có sẵn isinstance nhận một giá trị và một đối tượng lớp rồi trả về True nếu giá trị là một cá thể thuộc lớp đó.

Nếu other là một đối tượng Time thì __add__ sẽ gọi add_time. Trái lại, nó sẽ giả sử rằng tham biến là một số và gọi increment. Thao tác này được gọi là cắt cử dựa theo kiểu dữ liệu vì nó cắt cử các phương thức khác nhau để thực hiện phép tính, tuỳ theo kiểu của đối số.

Sau đây là các ví dụ trong đó dùng toán tử + với các kiểu dữ liệu khác nhau:

>>> start = Time(9, 45)
>>> duration = Time(1, 35)
>>> print start + duration
11:20:00
>>> print start + 1337
10:07:17

Thật không may là kiểu thực hiện phép cộng này không có tính giao hoán. Nếu số nguyên đứng bên trái toán tử, bạn sẽ nhận được

>>> print 1337 + start
TypeError: unsupported operand type(s) for +: 'int' and 'instance'

Vấn đề là ở chỗ, thay vì yêu cầu đối tượng Time cộng với một số nguyên, Python lại yêu cầu số nguyên cộng với đối tượng Time, và nó không biết thực hiện điều đó. Nhưng có một giải pháp khéo léo: phương thức đặc biệt __radd__, viết tắt của “right-side add”. Phương thức này được gọi mỗi khi đối tượng Time xuất hiện bên phải của toán tử +. Sau đây là lời định nghĩa:

# ben trong lop Time:

    def __radd__(self, other):
        return self.__add__(other)

Và sau đây là cách dùng nó:

>>> print 1337 + start
10:07:17

Hãy viết một phương thức add cho các Point sao cho nó có thể làm việc với từng đối tượng Point hoặc một bộ:

  • Nếu toán hạng thứ hai là một Point, phương thức cần trả lại một Point mới với toạ độ x bằng tổng các toạ độ x của các toán hạng, và tương tự như vậy với các toạ độ y.
  • Nếu toán hạng thứ hai là một bộ, phương thức cần cộng phần tử thứ nhất của bộ vào toạ độ x và phần tử thứ hai vào toạ độ y, rồi trả lại kết quả là một Point mới.

Đa hình

Việc cắt cử theo kiểu dữ liệu có thể hữu dụng khi cần thiết, nhưng (thật may là) nó không phải luôn cần đến. Thường bạn có thể tránh được cách đó bằng cách viết các hàm làm việc được với các đối số có kiểu khác nhau.

Nhiều hàm ta viết cho chuỗi thực ra cũng phát huy tác dụng với bất kì kiểu dãy nào. Chẳng hạn, trong Mục {bảng tần số} ta đã dùng histogram để đếm số lần mỗi chữ cái xuất hiện trong một từ.

def histogram(s):
    d = dict()
    for c in s:
        if c not in d:
            d[c] = 1
        else:
            d[c] = d[c]+1
    return d

Hàm này cũng dùng được với các danh sách, bộ, và thậm chí cả từ điển, miễn là các phần tử của s phải băm được, để chúng có thể dùng như các khoá trong d.

>>> t = ['spam', 'egg', 'spam', 'spam', 'bacon', 'spam']
>>> histogram(t)
{'bacon': 1, 'egg': 1, 'spam': 4}

Các hàm có khả năng làm việc được với vài kiểu dữ liệu được gọi là có tính đa hình. Tính đa hình giúp cho việc tái sử dụng mã lệnh. Chẳng hạn, hàm có sẵn sum, để cộng các phần tử trong một dãy, luôn có tác dụng chỉ cần các phần tử trong dãy cộng được với nhau.

Vì các đối tượng Time có phương thức add, chúng sẽ dùng được với sum:

>>> t1 = Time(7, 43)
>>> t2 = Time(7, 41)
>>> t3 = Time(7, 37)
>>> total = sum([t1, t2, t3])
>>> print total
23:01:00

Nói chung, nếu tất cả các thao tác bên trong một hàm hoạt động được với một kiểu dữ liệu cho trước, thì hàm đó cũng hoạt động được với kiểu nói trên.

Tính đa hình sẽ tốt nhất là lúc không định trước, khi bạn phát hiện thấy một hàm mình đã viết có thể được áp dụng cho một kiểu dữ liệu mà bạn chưa từng có ý định xử lý đến.

Gỡ lỗi

Sẽ hoàn toàn hợp lệ khi thêm các thuộc tính vào đối tượng bất kì chỗ nào trong quá trình thực hiện chương trình; nhưng nếu bạn gắt gao về lý thuyết kiểu dữ liệu thì để cho các đối tượng cùng kiểu có những thuộc tính khác nhau sẽ là cách làm không đáng tin cậy. Tốt hơn là khởi tạo tất cả thuộc tính của đối tượng trong phương thức init.

Nếu không chắc rằng một đối tượng có chứa một thuộc tính cụ thể hay không, bạn có thể dùng hàm có sẵn hasattr (xem Mục {hasattr}).

Một cách khác để truy cập các thuộc tính của đối tượng là thông qua thuộc tính đặc biệt __dict__, vốn là từ điển có ánh xạ các tên thuộc tính (kiểu chuỗi) đến giá trị:

>>> p = Point(3, 4)
>>> print p.__dict__
{'y': 4, 'x': 3}

Bạn có thể ghi nhớ hàm sau để tiện cho việc gỡ lỗi:

def print_attributes(obj):
    for attr in obj.__dict__:
        print attr, getattr(obj, attr)

print_attributes duyệt các mục trong từ điển của đối tượng và in ra mỗi tên thuộc tính kèm theo giá trị tương ứng.

Hàm có sẵn getattr nhận vào một đối tượng cùng tên của một thuộc tính (kiểu chuỗi) rồi trả lại giá trị của thuộc tính đó.

Thuật ngữ

ngôn ngữ hướng đối tượng:
Ngôn ngữ cung cấp những đặc điểm, như lớp do người dùng định nghĩa và cú pháp dành cho phương thức, nhằm giúp cho việc lập trình hướng đối tượng.

lập trình hướng đối tượng:
Phong cách lập trình trong đó dữ liệu và các thao tác trên dữ liệu được tổ chức thành các lớp và phương thức.

phương thức:
Hàm được định nghĩa bên trong lời định nghĩa lớp và được gọi với các cá thể của lớp đó.

chủ thể:
Đối tượng mà phương thức được kích hoạt trên đó.

toán tử đa năng:
Một toán tử trở thành đa năng như + khi nó thay đổi tính năng để làm việc với kiểu dữ liệu do người dùng định nghĩa.

cắt cử dựa theo kiểu:
Dạng mẫu lập trình trong đó kiểm tra kiểu của toán hạng và gọi các hàm khác nhau tùy theo kiểu dữ liệu của toán hạng đó.

đa hình:
Tính chất của một hàm có thể làm việc với vài kiểu dữ liệu khác nhau.

Bài tập

Bài tập này cũng là câu chuyện cảnh tỉnh về một lỗi phổ biến nhưng khó phát hiện của Python.

Hãy viết định nghĩa cho một lớp có tên Kangaroo gồm những phương thức sau:

  1. Một phương thức __init__ để khởi tạo một thuộc tính có tên là pouch_contents (“túi” của Kangaroo) bằng một danh sách rỗng.
  2. Một phương thức có tên put_in_pouch nhận một đối tượng có kiểu tùy ý và bổ sung nó vào pouch_contents.
  3. Một phương thức __str__ để trả về chuỗi biểu diễn cho đối tượng Kangaroo và nội dung của pouch_contents.

Hãy kiểm tra mã lệnh vừa viết bằng cách tạo ra hai đối tượng Kangaroo, gán chúng cho các biến có tên kangaroo, sau đó thêm roo vào trong “túi” của kanga.

Hãy tải về file thinkpython.com/code/BadKangaroo.py. Nó chứa lời giải của bài tập nhưng có một lỗi rất nghiêm trọng. Hãy tìm và sửa lỗi này.

Nếu bị vướng mắc, bạn có thể tải về thinkpython.com/code/GoodKangaroo.py, trong đó giải thích bài toán và chỉ ra một cách giải.

Visual là một module Python cho phép thao tác với đồ thị ba chiều. Nó thường không đi kèm theo bản cài đặt Python, vì vậy bạn có thể sẽ phải cài đặt nó từ kho phần mềm của hệ điều hành, hoặc nếu không có, thì ở vpython.org.

Ví dụ sau đây tạo ra một không gian 3 chiều có bề rộng, chiều dài, chiều cao đều bằng 256 đơn vị, sau đặt điểm gốc (“center”) tại vị trí (128, 128, 128). Tiếp theo một hình cầu màu xanh lam được vẽ ra.

from visual import *

scene.range = (256, 256, 256)
scene.center = (128, 128, 128)

color = (0.1, 0.1, 0.9)          # mau gan giong xanh lam
sphere(pos=scene.center, radius=128, color=color)

color là một bộ màu RGB; trong đó các thành tố màu Đỏ-Lục-Lam có mức từ 0.0 đến 1.0 (xem wikipedia.org/wiki/RGB_color_model).

Nếu chạy đoạn mã này, bạn sẽ thấy một cửa sổ có nền màu đen và một hình cầu màu xanh lam. Nếu lăn nút chuột giữa lên hoặc xuống, bạn sẽ phóng to hoặc thu nhỏ hình. Bạn còn có thể xoay hình bằng cách di nút chuột phải, nhưng chỉ với một quả cầu trên hình thì khó quan sát được sự khác biệt nào.

Vòng lặp tiếp theo tạo ra một khối lập phương gồm các quả cầu:

t = range(0, 256, 51)
for x in t:
    for y in t:
        for z in t:
            pos = x, y, z
            sphere(pos=pos, radius=10, color=color)
  1. Hãy đưa đoạn mã này vào trong một file lệnh và đảm bảo rằng nó chạy đúng.
  2. Hãy sửa lại chương trình sao cho mỗi hình cầu trong khối lập phương có màu tương ứng với vị trí của nó trong không gian màu RGB. Chú ý rằng các tọa độ nằm trong khoảng 0–255, còn bộ RGB thì lại nằm trong khoảng 0.0–1.0.
  3. Tải về thinkpython.com/code/color_list.py và dùng hàm read_colors để phát sinh một danh sách các màu hiện có trên hệ thống máy của bạn, tên của chúng và các giá trị RGB. Với mỗi màu có tên trên, hãy vẽ một quả cầu ở vị trí tương ứng với giá trị RGB của nó.

Bạn có thể tham khảo lời giải của tôi tại thinkpython.com/code/color_space.py.

1 bình luận

Filed under Think Python

1 responses to “Chương 17: Những đặc điểm của lập trình hướng đối tượng

  1. Pingback: Think Python: Cách tư duy như nhà khoa học máy tính | Blog của Chiến

Bình luận về bài viết này