Chương 16: Lớp và hàm

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

Để lấy một ví dụ khác về kiểu người dùng định nghĩa, ta sẽ định nghĩa một lớp tên là Time để ghi lại thời gian trong ngày. Lời định nghĩa của lớp này như sau:

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

Ta có thể tạo ra một đối tượng Time mới và gán các thuộc tính cho số giờ, phút, và giây:

time = Time()
time.hour = 11
time.minute = 59
time.second = 30

Sơ đồ trạng thái cho đối tượng Time trông như sau:

đối tượng Time

Hãy viết một hàm có tên print_time nhận vào một đối tượng Time và in nội dung ra dưới dạng hour:minute:second. Gợi ý: dãy định dạng '%.2d' dùng để in ra một số nguyên với ít nhất là hai chữ số, trong đó có một chữ số 0 đứng đầu nếu cần.

Hãy viết một hàm boole có tên is_after nhận vào hai đối tượng Time, t1t2, rồi trả lại True nếu t1 xếp sau t2 về mặt thời gian và False nếu ngược lại. Đố bạn thực hiện mà không cần lệnh if ?

Hàm thuần túy

Ở các mục tiếp theo, ta sẽ viết hai hàm để cộng các giá trị thời gian. Chúng sẽ đại diện cho hai loại hàm: hàm thuần túy và hàm sửa đổi. Đây cũng là minh họa cho một kế hoạch phát triển mà tôi gọi là hình mẫu và sửa chữa, một cách xử lý vấn đề khó bằng cách bắt đầu bằng một hình mẫu đơn giản và dần xử lý những điểm phức tạp.

Sau đây là một hình mẫu đơn giản của add_time:

def add_time(t1, t2):
    sum = Time()
    sum.hour = t1.hour + t2.hour
    sum.minute = t1.minute + t2.minute
    sum.second = t1.second + t2.second
    return sum

Hàm này tạo ra một đối tượng Time mới, khởi tạo các thuộc tính của nó, và trả lại một tham chiếu đến đối tượng mới. Nó được gọi là một hàm thuần túy vì không làm thay đổi gì đến đối tượng được truyền đến như đối số; và nó cũng không có hiệu ứng như hiển thị một giá trị hay thu kết quả do người nhập vào, ngoại trừ việc trả lại một giá trị.

Để kiểm tra hàm này, tôi sẽ tạo ra hai đối tượng Time: start chứa thời điểm bắt đầu chiếu một bộ phim, như Monty Python and the Holy Grail, cùng với duration chứa thời lượng của phim, bằng 1 giờ 35 phút.

add_time sẽ tính ra khi nào bộ phim kết thúc.

>>> start = Time()
>>> start.hour = 9

>>> start.minute = 45
>>> start.second =  0

>>> duration = Time()
>>> duration.hour = 1
>>> duration.minute = 35
>>> duration.second = 0

>>> done = add_time(start, duration)
>>> print_time(done)
10:80:00

Kết quả, 10:80:00 có thể không được như bạn mong đợi. Vấn đề là ở chỗ hàm này không tính được các trường hợp mà số phút hoặc số giây cộng lại lớn hơn 60. Khi đó, ta cần phải “nhớ” đưa số giây còn dư vào trong cột số phút, hoặc số phút còn dư vào trong cột số giờ.

Sau đây là một phiên bản được sửa chữa:

def add_time(t1, t2):
    sum = Time()
    sum.hour = t1.hour + t2.hour
    sum.minute = t1.minute + t2.minute
    sum.second = t1.second + t2.second

    if sum.second >= 60:
        sum.second -= 60
        sum.minute += 1

    if sum.minute >= 60:
        sum.minute -= 60
        sum.hour += 1

    return sum

Mặc dù hàm này đã đúng, nó bắt đầu phình to lên. Chúng ta sẽ xét đến một cách khác ngay sau đây.

Hàm sửa đổi

Đôi sẽ hữu ích hơn khi một hàm có thể thay đổi các đối tượng khi chúng được truyền vào như tham biến. Trong trường hợp này, hàm gọi sẽ nhận thấy được các thay đổi đó. Những hàm kiểu này được gọi là hàm thay đổi.

Có thể viết increment, một hàm để cộng một số giây cho trước vào đối tượng Time, dưới dạng một hàm thay đổi theo cách tự nhiên. Sau đây là một bản phác thảo:

def increment(time, seconds):
    time.second += seconds

    if time.second >= 60:
        time.second -= 60
        time.minute += 1

    if time.minute >= 60:
        time.minute -= 60
        time.hour += 1

Dòng đầu tiên thực hiện phép tính cơ bản; phần còn lại xử lý các trường hợp đặc biệt như ta đã thấy trước đây.

Hàm này có đúng không? Điều gì sẽ xảy ra khi tham biến seconds lớn hơn nhiều so với 60?

Trong trường hợp như vậy, việc cộng nhớ một lần là chưa đủ; ta còn phải tiếp tục làm đến khi time.second dưới 60. Một cách giải quyết là thay các lệnh if bằng các lệnh while. Việc này sẽ giúp ta có một hàm đúng, nhưng không hiệu quả lắm.

Hãy viết một dạng đúng của increment mà không chứa vòng lặp nào.

Hàm thuần túy có thể làm được bất cứ công việc nào thực hiện bởi hàm thay đổi. Thật ra, một số ngôn ngữ lập trình chỉ cho phép dùng hàm thuần túy. Đã có một số bằng chứng cho thấy những chương trình chỉ dùng hàm thuần túy thì được phát triển nhanh hơn và ít gây lỗi hơn so với chương trình dùng hàm thay đổi. Nhưng có những lúc dùng hàm thay đổi lại thuận tiện, và chương trình dùng hàm thuần túy lại kém hiệu quả.

Nói chung, tôi khuyên bạn dùng hàm thuần túy mỗi khi có thể, và chỉ dùng hàm thay đổi khi có một ưu điểm rõ rệt. Cách tiếp cận này có thể được gọi là phong cách lập trình hàm.

Hãy viết một hàm thuần túy cho increment để tạo ra và trả về một đối tượng Time mới thay vì thay đổi tham biến.

So sánh việc tạo nguyên mẫu với lập kế hoạch

Kế hoạch phát triển mà tôi sẽ trình bày sau đây được gọi là “nguyên mẫu và sửa chữa1”. Với mỗi hàm, tôi viết một nguyên mẫu để thực hiện những phép tính cơ bản và sau đó chạy thử nó, đồng thời sửa chữa những lỗi phát sinh.

Cách làm này có thể hiệu quả, đặc biệt nếu bạn chưa có hiểu biết sâu sắc về bài toán. Nhưng các sửa chữa dần có thể hình thành mã lệnh phức tạp quá mức cần thiết—vì nó giải quyết nhiều trường hợp đặc biệt—và không đáng tin cậy—vì bạn khó có thể biết được rằng liệu đã tìm được tất cả lỗi hay chưa.

Một cách làm khác là phát triển theo kế hoạch, trong đó những nhận định sâu sắc ở cấp độ cao về bài toán trước mắt có thể làm việc lập trình đơn giản hơn nhiều. Trong trường hợp này, nhận định đó là việc một đối tượng Time thật ra là số có 3 chữ số trong hệ cơ số 60 (xem wikipedia.org/wiki/Sexagesimal).! Thuộc tính second là “cột 1”, thuộc tính minute là “cột 60”, còn thuộc tính hour là “cột 3600”.

Khi viết add_timeincrement, ta đã thực hiện rất hiệu quả phép cộng trên hệ 60, đó là lí do tại sao ta đã phải cộng có nhớ khi chuyển sang cột tiếp theo.

Kết quả quan sát này gợi ý cho ta một cách tiếp cận khác đến tổng thể bài toán—ta có thể chuyển đối tượng Time ra các số nguyên và lợi dụng khả năng làm tính cộng của máy tính.

Sau đây là một hàm để chuyển Time ra số nguyên:

def time_to_int(time):
    minutes = time.hour * 60 + time.minute
    seconds = minutes * 60 + time.second
    return seconds

Và sau đây là hàm để chuyển số nguyên thành Time (nhớ lại rằng divmod chia đối số thứ nhất cho đối số thứ hai và trả về một bộ gồm có thương và số dư).

def int_to_time(seconds):
    time = Time()
    minutes, time.second = divmod(seconds, 60)
    time.hour, time.minute = divmod(minutes, 60)
    return time

Có thể bạn phải suy nghĩ thêm một chút, chạy thử một vài lần, để tin rằng các hàm đó đều đúng. Một cách chạy thử là kiểm tra rằng time_to_int(int_to_time(x)) == x cho nhiều giá trị của x. Đây là ví dụ về một phép kiểm tra sự nhất quán.

Một khi đã tin rằng các hàm đều đúng, bạn có thể dùng chúng để viết lại add_time:

def add_time(t1, t2):
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

Bản này ngắn hơn so với bản đầu tiên, và dễ kiểm tra tính đúng đắn hơn.

Hãy viết lại increment dùng time_to_intint_to_time.

Bằng cách nào đó, các việc đổi từ hệ cơ số 60 sang hệ cơ số 10 và ngược lại khó hơn so với chỉ thao tác với thời gian. Chuyển đổi giữa các hệ cơ số thì trừu tượng hơn; trực giác của chúng ta hợp với việc tính toán các giá trị thời gian hơn.

Nhưng nếu chúng ta hiểu sâu rằng có thể coi thời gian như số trong hệ 60 và dành thời gian để viết các hàm chuyển đổi (time_to_intint_to_time), ta sẽ thu được chương trình ngắn hơn, dễ đọc và sửa lỗi, và đáng tin cậy hơn.

Ngoài ra, sau này ta cũng dễ thêm vào các tính năng khác. Chẳng hạn, hãy hình dung việc trừ hai Time để tìm ra khoảng thời gian giữa chúng. Cách làm tự nhiên là thực hiện phép trừ có nhớ. Nhưng việc dùng các hàm chuyển đổi sẽ dễ hơn và có nhiều khả năng sẽ chính xác hơn.

Điều nghịch lý là, đôi khi việc làm bài toán trở nên khó hơn (hoặc tổng quát hơn) hóa ra lại làm nó dễ hơn (vì còn ít trường hợp đặc biệt và ít khả năng gây lỗi).

Gỡ lỗi

Một đối tượng Time sẽ hợp lệ nếu các giá trị của minutesseconds đề nằm giữa 0 và 60 (có thể bằng 0 nhưng không bằng 60) và nếu hours là số dương. hoursminutes phải là các số nguyên, nhưng seconds được phép có phần thập phân.

Những yêu cầu kiểu như vậy được gọi là các bất biến vì chúng cần phải luôn đúng. Nói cách khác, nếu chúng không đúng thì chương trình sẽ có lỗi ở đâu đó.

Việc viết mã lệnh để kiểm tra các bất biến có thể giúp bạn phát hiện các lỗi và tìm ra nguyên nhân gây lỗi. Chẳng hạn, giả sử bạn có một hàm như valid_time nhận vào một đối tượng Time và trả lại False nếu một bất biến bị vi phạm:

def valid_time(time):
    if time.hours < 0 or time.minutes < 0 or time.seconds < 0:
        return False
    if time.minutes >= 60 or time.seconds >= 60:
        return False
    return True

Tiếp theo, ở đoạn đầu mỗi hàm, bạn có thể kiểm tra các đối số để chắc rằng chúng đều hợp lệ:

def add_time(t1, t2):
    if not valid_time(t1) or not valid_time(t2):
        raise ValueError, 'invalid Time object in add_time'
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

hoặc có thể dùng lệnh assert, vốn để kiểm tra một bất biến và báo biệt lệ nếu có sự vi phạm:

def add_time(t1, t2):
    assert valid_time(t1) and valid_time(t2)
    seconds = time_to_int(t1) + time_to_int(t2)
    return int_to_time(seconds)

Lệnh assert có ích vì chúng phân biệt rạch ròi giữa đoạn mã giải quyết trường hợp bình thường và mã lệnh để kiểm tra lỗi.

Thuật ngữ

nguyên mẫu và sửa chữa:
Kế hoạch phát triển trong đó bao gồm việc viết một bản nháp của chương trình, chạy thử, và sửa những lỗi phát sinh.

phát triển theo kế hoạch:
Kế hoạch phát triển trong đó bao gồm nhận định sâu sắc về bài toán, đồng thời kế hoạch được xác lập kĩ hơn so với các cách phát triển tăng dần và phát triển nguyên mẫu.

hàm thuần túy:
Hàm không thay đổi bất kì đối tượng nào được nhận làm đối số. Hầu hết các hàm thuần túy đều cho kết quả.

hàm thay đổi:
Hàm có làm thay đổi (các) đối tượng được nhận làm đối số. Hầu hết các hàm thay đổi đều không cho kết quả.

phong cách lập trình hàm:
Phong cách thiết kế chương trình trong đó đa số các hàm đều là thuần túy.

bất biến:
Điều kiện cần luôn được đảm bảo đúng trong quá trình chạy chương trình.

Bài tập

Hãy viết một hàm có tên mul_time nhận vào một đối tượng Time và một số, rồi trả về một đối tượng Time mới có chứa tích của Time ban đầu với số đó.

Tiếp theo, dùng mul_time để viết một hàm nhận vào đối tượng Time biểu thị thời gian về đích trong một cuộc đua, và một số biểu thị khoảng cách, rồi trả về một đối tượng Time biểu thị thời gian trung bình để đi hết một dặm đường.

Hãy viết một định nghĩa lớp cho đối tượng Date gồm các thuộc tính day, monthyear. Viết một hàm increment_date nhận vào một đối tượng Date, date và một số nguyên, n, rồi trả về một đối tượng Date mới biểu thị ngày tháng xuất hiện vào n ngày sau date. Gợi ý: Dùng khớp xương bàn tay để nhớ số ngày trong tháng. Đố bạn dùng hàm này để tính với năm nhuận? Xem wikipedia.org/wiki/Leap_year.

Module datetime có các đối tượng datetime; chúng cũng gần giống các đối tượng Date và Time trong chương này, nhưng có tập hợp các phương thức và toán tử rất phong phú. Hãy tìm hiểu tài liệu ở docs.python.org/lib/datetime-date.html.

  1. Hãy dùng module datetime để viết một chương trình nhận vào ngày hôm nay và in ra thứ của ngày trong tuần.
  2. Hãy viết một chương trình nhận vào ngày sinh của bạn rồi in ra tuổi bạn cùng số ngày, giờ, phút, giây cho đến sinh nhật tiếp theo.

  1. thuật ngữ gốc có nghĩa là “chắp vá”.

3 bình luận

Filed under Think Python

3 responses to “Chương 16: Lớp và hàm

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

  2. Pingback: Chương 17: Những đặc điểm của lập trình hướng đối tượng | Blog của Chiến

  3. Pingback: Phụ lục: Lumpy | Blog của Chiến

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