bash - staycrafting.dev

~$ cat chu-l-trong-solid-nguoi-trong-quan-ho-ban-tra-nong-cho-khach-goi-tra-da.md

Chữ L trong SOLID: người trông quán hộ bán trà nóng cho khách gọi trà đá

Chữ L trong SOLID (Liskov Substitution): thay class này bằng class khác thì phải chạy y như cũ, người gọi không cần biết. Bài thứ ba series SOLID quán trà đá.

Bà chủ quán trà đá đi ăn cỗ, nhờ cô em họ trông quán hộ một buổi. Cô em gật đầu cái rụp: "Bán trà thì ai chả bán được". Khách gọi trà đá, cô bưng ra ly trà nóng, vì "đá hết rồi, mà trà nóng thì cũng là trà". Khách đưa tờ 50 nghìn, cô bảo không có tiền lẻ, anh mai quay lại lấy.

Xét về hình thức, quán vẫn mở, vẫn có người bán trà. Xét về khách hàng, quán coi như đóng cửa.

Lời dẫn

Đây là bài thứ ba của series SOLID, mỗi bài một chữ:

Chữ Tên đầy đủ Nói kiểu quán trà đá
S Single Responsibility Principle Mỗi người nghe một sếp
O Open/Closed Principle Thêm món thì gài thêm thẻ, đừng sơn lại biển
L Liskov Substitution Principle Người thay ca phải bán đúng như người cũ
I Interface Segregation Principle
D Dependency Inversion Principle

Bài O kết luận: muốn thêm biến thể mới thì viết thêm class mới, cắm vào chỗ cũ, code cũ để yên. Nghe rất đẹp. Nhưng có một điều kiện ngầm mà bài O chưa nói: cái mới cắm vào phải chạy đúng như cái cũ. Cắm nồi chiên vào ổ điện mà nồi chiên làm nhảy cầu dao cả nhà thì ổ điện có chuẩn đến mấy cũng vô ích.

Điều kiện ngầm đó chính là chữ L. Bài này đọc riêng vẫn hiểu, nhưng đọc bài O trước thì sẽ thấy L là mảnh ghép còn thiếu.

Trong bài có bài refactor mẫu đi từng bước, và bài tập nhanh ở cuối để anh em tự kiểm tra.

1. L là gì?

Liskov Substitution Principle (LSP), nguyên tắc thay thế Liskov. Đặt theo tên bà Barbara Liskov, người phát biểu ý này trong bài nói Data Abstraction and Hierarchy năm 1987, rồi cùng Jeannette Wing viết lại chặt chẽ vào năm 1994 (bà còn nhận giải Turing năm 2008, nên tên bà nằm ở chữ L là xứng đáng). Phát biểu gọn:

Nếu B là kiểu con của A, thì ở mọi chỗ đang dùng A, thay bằng B vào chương trình vẫn chạy đúng.

Dịch cabin: đứa thay thế phải giữ đúng lời hứa của đứa được thay. Người gọi không cần biết, không cần hỏi "ủa, đứa nào đây?", không cần xử lý riêng.

Chữ quan trọng nhất ở đây là lời hứa. Một class cha không chỉ có tên method, mà còn có lời hứa đi kèm:

  • Nhận vào cái gì: cha nhận mọi số tiền dương, thì con không được chỉ nhận số tròn nghìn.
  • Trả ra cái gì: cha hứa trả về danh sách (có thể rỗng), thì con không được trả về nil.
  • Làm được gì: cha hứa hoàn tiền được, thì con không được ném lỗi "không hoàn được".

Nói theo kiểu giáo trình: con không được đòi hỏi nhiều hơn cha (điều kiện đầu vào không được chặt hơn), và không được hứa ít hơn cha (kết quả đầu ra không được lỏng hơn).

Hiểu nhầm phổ biến

"Ngoài đời A là một loại B thì trong code cho A kế thừa B là đúng." Ví dụ kinh điển: chim cánh cụt là một loại chim. Chim biết bay. Vậy...

class Bird
  def fly
    "Vỗ cánh bay lên"
  end
end

class Penguin < Bird
  def fly
    raise "Cánh cụt không bay được"
  end
end

Ngoài đời đúng là chim. Trong code thì không: ở chỗ nào đang gọi bird.fly, thả một con cánh cụt vào là sập. Quan hệ "là một loại" trong code phải xét theo hành vi, không xét theo sách sinh học.

"Ruby không bắt buộc kế thừa, nên L không liên quan." Có liên quan đấy. Bài O, các khuyến mãi không kế thừa ai, chỉ cùng có method discount_for. Nhưng CheckoutCalculator vẫn ngầm tin rằng mọi khuyến mãi trả về một con số, không âm, không nil. Một khuyến mãi trả về nil là sập cả quầy thu ngân. Duck typing thì lời hứa vẫn còn, chỉ là không ai viết ra.

Kết luận phần này: thay được hay không là do hành vi, không phải do cái tên hay cây kế thừa.

2. Chuyện đời thường: người trông quán hộ

Quay lại cô em họ. Bà chủ quán, dù không nói ra, có một bộ "lời hứa" với khách:

Lời hứa của quán Cô em họ làm
Gọi trà đá thì có trà đá Bưng trà nóng, "cũng là trà mà"
Đưa tiền thì được trả lại tiền thừa "Không có lẻ, mai lấy"
Gửi xe thì lấy được xe Không biết xe nào của ai

Cô em vẫn "biết bán trà", về mặt kỹ thuật. Nhưng khách quen phải tự hỏi trước khi gọi: "Hôm nay ai bán đấy? Bà chủ hay cô kia?". Lúc khách phải hỏi câu đó, quán đã vi phạm chữ L.

Người thay ca tốt là người mà khách không nhận ra có sự thay đổi. Có thể pha nhanh hơn, có thể nói chuyện vui hơn (con được làm tốt hơn cha), nhưng gọi trà đá thì phải ra trà đá.

Mẹo nhỏ: Nếu thấy code kiểu if obj.is_a?(Something) hoặc if obj.respond_to?(:method) trước khi gọi một method chung, rất có thể có một đứa con đang không giữ lời hứa, và người gọi đang phải tự hỏi "hôm nay ai bán đấy?".

3. Vi phạm L thì đã sao?

Lỗi L có một tính chất khó chịu: không thấy lúc viết, chỉ thấy lúc thay. Code của class con chạy ngon khi test riêng. Nó chỉ nổ khi bị thả vào một chỗ đang tin tưởng class cha.

  • Nổ ở chỗ không ngờ. Class con ném lỗi, và người gọi (viết từ hai năm trước, cho class cha) không hề biết phải bắt lỗi đó.
  • Sai mà không nổ, còn tệ hơn. Class con lẳng lặng không làm gì, hoặc làm một nửa. Chương trình chạy tiếp vui vẻ với dữ liệu sai. Phát hiện ra khi đối soát cuối tháng.
  • Kéo theo vi phạm O. Để chữa cháy, ai đó thêm if payment.is_a?(VoucherPayment) vào người gọi. Từ đó, mỗi đứa con "đặc biệt" mới lại thêm một nhánh if. Cái khung đóng của bài O bị mổ ra lần nữa.

Dấu hiệu nhận biết

  • Class con override method của cha bằng raise NotImplementedError hoặc raise "không hỗ trợ".
  • Class con override method bằng cách không làm gì trong khi lời hứa của cha là phải làm.
  • Người gọi phải kiểm tra is_a?, respond_to?, kind_of? trước khi dùng.
  • Class con trả về kiểu khác cha (cha trả mảng, con trả nil; cha trả số, con trả chuỗi).
  • Test viết cho class cha, chạy với class con thì đỏ.

4. Bài refactor: thanh toán ở quán trà đá

Code bằng Ruby như các bài trước. Anh em ngôn ngữ khác cứ đọc như mã giả.

Bối cảnh: phần mềm tính tiền của quán (cái đã có khuyến mãi ở bài O) giờ hỗ trợ nhiều cách trả tiền: tiền mặt, quét QR, voucher, và ghi sổ cho khách quen cuối tháng trả. Khi khách đổi ý (gọi nhầm, ly đổ), thu ngân bấm hủy đơn và hoàn tiền.

4.1. Code ban đầu: cả họ nhà Payment

class Payment
  def pay(amount)
    raise NotImplementedError
  end

  def refund(amount)
    raise NotImplementedError
  end
end

class CashPayment < Payment
  def pay(amount)
    puts "Thu #{amount}đ tiền mặt"
  end

  def refund(amount)
    puts "Trả lại #{amount}đ tiền mặt"
    amount
  end
end

class QrPayment < Payment
  def pay(amount)
    puts "Khách quét QR #{amount}đ"
  end

  def refund(amount)
    puts "Chuyển khoản trả lại #{amount}đ"
    amount
  end
end

class VoucherPayment < Payment
  def pay(amount)
    puts "Trừ #{amount}đ vào voucher"
  end

  def refund(amount)
    raise "Voucher không hoàn tiền được!"
  end
end

class TabPayment < Payment # ghi sổ
  def pay(amount)
    puts "Ghi sổ nợ #{amount}đ"
  end

  def refund(amount)
    0 # sổ nợ thì thôi, khỏi trả gì
  end
end

Và chỗ dùng:

class CancelOrder
  def call(order)
    refunded = order.payment.refund(order.amount)
    puts "Đã hoàn #{refunded}đ cho khách"
  end
end

CancelOrder viết cho Payment, tin rằng refund hoàn tiền xong và trả về số tiền đã hoàn. Với tiền mặt và QR thì đúng. Còn hai đứa kia:

  • VoucherPayment nổ. Khách trả bằng voucher, hủy đơn, màn hình thu ngân hiện lỗi đỏ chót. Khách đứng chờ, thu ngân gọi điện cho "anh IT".
  • TabPayment sai mà không nổ. Hủy đơn chạy êm, in ra "Đã hoàn 0đ". Nhưng sổ nợ không được gạch. Cuối tháng anh khách quen bị tính tiền ly trà đã hủy. Anh ấy không chửi, anh ấy chỉ lặng lẽ chuyển sang quán đối diện. Đây là kiểu lỗi đắt nhất.

4.2. Cách chữa cháy sai: thêm if

Phản xạ đầu tiên của nhiều anh em (bao gồm mình):

class CancelOrder
  def call(order)
    if order.payment.is_a?(VoucherPayment)
      puts "Voucher không hoàn được, mời khách dùng món khác"
    elsif order.payment.is_a?(TabPayment)
      order.customer.tab.debt -= order.amount
    else
      refunded = order.payment.refund(order.amount)
      puts "Đã hoàn #{refunded}đ cho khách"
    end
  end
end

Hết nổ. Nhưng CancelOrder giờ phải biết từng loại thanh toán. Thêm "thanh toán bằng điểm tích lũy" là lại thêm một elsif. Và đâu chỉ có CancelOrder: báo cáo cuối ngày, đối soát, in hóa đơn... chỗ nào cũng mọc if. Vi phạm L kéo theo vi phạm O, đúng như mục 3 đã nói.

Chữa đúng là chữa ở đứa con, không phải ở người gọi.

4.3. Bước 1: viết lời hứa ra giấy

Lời hứa đang nằm trong đầu người viết CancelOrder. Lôi nó ra, viết rõ ngay trên class cha:

class Payment
  # Thu tiền của khách.
  def pay(amount)
    raise NotImplementedError
  end

  # Lời hứa của refund:
  # - Sau khi gọi, khách nhận lại ĐÚNG giá trị amount,
  #   bằng hình thức phù hợp với cách đã trả.
  # - Không ném lỗi với số tiền hợp lệ.
  # - Trả về một Refund cho biết đã hoàn bao nhiêu, bằng cách nào.
  def refund(amount)
    raise NotImplementedError
  end
end

Refund = Struct.new(:amount, :via, keyword_init: true)

NotImplementedError ở class cha là bình thường: cha là bản mô tả, không phải đứa đi bán hàng. Cái sai là khi đứa con cụ thể ném lỗi đó ra.

Để ý cụm "bằng hình thức phù hợp". Lời hứa không nói "trả tiền mặt", vì trả tiền mặt cho khách ghi sổ là vô lý. Lời hứa nói về giá trị: khách không bị thiệt đồng nào.

4.4. Bước 2: soi từng đứa con theo lời hứa

Class Hoàn đúng giá trị? Không nổ? Trả về Refund? Kết luận
CashPayment Có Có Trả số Sửa nhẹ kiểu trả về
QrPayment Có Có Trả số Sửa nhẹ kiểu trả về
VoucherPayment Không Nổ Không Vi phạm
TabPayment Không (sổ không gạch) Có Trả 0 Vi phạm, kiểu im lặng

Soi xong thì mỗi đứa vi phạm sẽ rơi vào một trong hai ngả:

  • Giữ được lời hứa, chỉ là đang làm ẩu: sửa ở đứa con. Class cha và người gọi để yên.
  • Không thể giữ lời hứa, vì bản chất nó không làm được việc đó (cánh cụt không bay, file chỉ đọc không ghi): đừng cho nó làm con nữa, hoặc chia lại vai trò để cha không hứa điều mà đứa con không làm nổi.

Câu hỏi để chọn ngả: "Có cách nào để đứa con này làm đúng lời hứa mà người gọi không cần biết nó là đứa nào không?". Có thì sửa, không thì tách. Hai đứa vi phạm ở đây may mắn đều thuộc ngả thứ nhất: voucher cộng lại được, sổ nợ gạch được. Ngả thứ hai sẽ gặp lại ở bước 3 (khi quán có luật voucher không hoàn) và ở mục 5.

4.5. Bước 3: sửa từng đứa cho giữ lời

Tiền mặt và QR chỉ cần đổi kiểu trả về:

class CashPayment < Payment
  def pay(amount)
    puts "Thu #{amount}đ tiền mặt"
  end

  def refund(amount)
    puts "Trả lại #{amount}đ tiền mặt"
    Refund.new(amount: amount, via: "tiền mặt")
  end
end

class QrPayment < Payment
  def pay(amount)
    puts "Khách quét QR #{amount}đ"
  end

  def refund(amount)
    puts "Chuyển khoản trả lại #{amount}đ"
    Refund.new(amount: amount, via: "chuyển khoản")
  end
end

Voucher: không hoàn ra tiền mặt được, nhưng cộng lại vào voucher được. Khách không thiệt, lời hứa vẫn giữ:

class VoucherPayment < Payment
  attr_reader :balance

  def initialize(balance)
    @balance = balance
  end

  def pay(amount)
    @balance -= amount
    puts "Trừ #{amount}đ vào voucher"
  end

  def refund(amount)
    @balance += amount
    Refund.new(amount: amount, via: "cộng lại vào voucher")
  end
end

Nếu quán thật sự có quy định "voucher không hoàn", thì cách chữa trên là đổi luật của quán, không còn là refactor nữa. Lúc đó đúng ra là Payment không nên hứa refund cho mọi đứa con. Tách riêng một vai trò "hoàn được" cho những đứa làm được, giống cách tách FlyingBird ra khỏi Bird ở mục 5. Chữ I sẽ bàn kỹ chuyện tách vai trò này.

Ghi sổ: hoàn tiền nghĩa là gạch nợ, làm thật chứ không giả vờ:

Tab = Struct.new(:customer, :debt)

class TabPayment < Payment
  def initialize(tab)
    @tab = tab
  end

  def pay(amount)
    @tab.debt += amount
    puts "Ghi sổ nợ #{amount}đ"
  end

  def refund(amount)
    @tab.debt -= amount
    Refund.new(amount: amount, via: "gạch sổ nợ")
  end
end

VoucherPayment và TabPayment cần tham số khi tạo, còn tiền mặt thì không. Như thế không vi phạm L: L nói về chỗ dùng đối tượng. Còn chỗ tạo ra đối tượng thì biết rõ mình đang tạo đứa nào, nên constructor không nằm trong lời hứa.

4.6. Bước 4: người gọi trở lại hồn nhiên

class CancelOrder
  def call(order)
    refund = order.payment.refund(order.amount)
    puts "Đã hoàn #{refund.amount}đ (#{refund.via})"
  end
end

Không if, không is_a?. CancelOrder không cần biết hôm nay "ai bán". Thêm "thanh toán bằng điểm tích lũy"? Viết class mới, giữ đúng lời hứa của refund, cắm vào. Đúng tinh thần bài O, và lần này cắm vào không nhảy cầu dao.

4.7. Test: một bài kiểm tra cho cả họ

Đây là món quà lớn nhất của chữ L: lời hứa đã viết ra thì test được, và test một lần cho mọi đứa con:

require "minitest/autorun"

class PaymentContractTest < Minitest::Test
  def all_payments
    [
      CashPayment.new,
      QrPayment.new,
      VoucherPayment.new(50_000),
      TabPayment.new(Tab.new("Anh Tư", 0))
    ]
  end

  # Lời hứa chung: ai cũng hoàn đủ tiền, không ai nổ
  def test_moi_kieu_thanh_toan_hoan_du_tien
    all_payments.each do |payment|
      payment.pay(10_000)
      refund = payment.refund(10_000)
      assert_equal 10_000, refund.amount, "#{payment.class} không giữ lời hứa"
    end
  end

  # Hoàn thật, không hoàn giả vờ
  def test_voucher_duoc_cong_lai_du
    voucher = VoucherPayment.new(50_000)
    voucher.pay(10_000)
    voucher.refund(10_000)
    assert_equal 50_000, voucher.balance
  end

  def test_ghi_so_thi_gach_no_that
    tab = Tab.new("Anh Tư", 0)
    payment = TabPayment.new(tab)
    payment.pay(10_000)
    payment.refund(10_000)
    assert_equal 0, tab.debt
  end
end

Test đầu tiên chạy một lần cho cả họ. Hai test sau kiểm tra chuyện "hoàn thật": chỉ trả về con số 10.000 thì chưa đủ, voucher phải được cộng lại và sổ nợ phải được gạch thật.

Anh em dùng RSpec thì có sẵn shared_examples và it_behaves_like để làm đúng việc này: viết bộ test lời hứa một lần, mỗi class con chỉ cần một dòng it_behaves_like "a payment". Thêm đứa con mới mà quên chạy bộ test này là quên kiểm tra lý lịch người thay ca.

4.8. So sánh trước và sau

Trước Sau
Lời hứa của refund Nằm trong đầu ai đó Viết rõ trên class cha
Hủy đơn voucher Nổ lỗi ở quầy Cộng lại vào voucher
Hủy đơn ghi sổ Êm, nhưng khách bị tính tiền oan Gạch nợ thật
Người gọi Phải biết từng loại, đầy if Không cần biết ai là ai
Test Mỗi class test riêng, không ai thử thay thế Một bộ test lời hứa cho cả họ

5. Hai kiểu quá tay sau khi học L

Học xong L, có hai kiểu quá tay hay gặp.

Kiểu 1: sợ kế thừa, cấm luôn. L không cấm kế thừa, chỉ đòi kế thừa thì phải thay được. Chỉ khi đứa con không giữ nổi lời hứa của cha thì mới tính chuyện đừng cho làm con. Cánh cụt không bay được thì đừng cho nó kế thừa Bird có fly. Cho nó một chỗ khác, hoặc chia lại cây: Bird không hứa bay, FlyingBird mới hứa. Không phải cứ có quan hệ ngoài đời là phải có quan hệ trong code.

Kiểu 2: bẻ cong lời hứa cho vừa mọi đứa con. Thấy voucher khó hoàn, sửa lời hứa của cha thành "refund có thể hoàn hoặc không, tùy". Lời hứa kiểu này giữ được hết, vì nó chẳng hứa gì. Người gọi lại phải kiểm tra kết quả từng trường hợp, quay về vạch xuất phát. Lời hứa phải có ích cho người gọi, không phải dễ dãi cho người cài đặt.

Không phải method "không làm gì" nào cũng sai. Một NullLogger có log(message) rỗng là hoàn toàn đúng, vì lời hứa của logger là "nhận message mà không làm phiền ai", không ai trông chờ nó in ra gì. TabPayment#refund rỗng thì sai, vì lời hứa nói phải hoàn giá trị. Đúng hay sai nằm ở lời hứa, không nằm ở chuyện method có code hay không.

Nói riêng với anh em Rails

  • STI (Single Table Inheritance) là chỗ L hay bị vi phạm nhất trong Rails. AdminUser < User, GuestUser < User, rồi GuestUser override email trả về nil... Mọi chỗ gửi mail cho User bắt đầu phải hỏi "ủa, guest à?". Trước khi tạo STI, hỏi: mọi chỗ dùng User có dùng được đứa con này không?
  • Active Storage là ví dụ đẹp. Disk, S3, GCS đều phải giữ cùng lời hứa (upload, download, delete, url...). Code của anh em gọi attachment.attach mà không cần biết bên dưới là đứa nào. Ảnh blog này lên GCS, máy local thì lưu Disk, code gần như không đổi một dòng. Người thay ca chuẩn là vậy. Chữ "gần như" là vì gọi thẳng blob.url với Disk thì phải set ActiveStorage::Current.url_options trước, còn S3 hay GCS thì không cần. Lời hứa chỉ lệch một chút như vậy thôi cũng đủ làm ai đó mất một buổi chiều.

6. Bài tập nhanh

Đến lượt anh em. Một bài khởi động nhẹ, không cần mở editor, chỉ cần một ly trà đá: đọc từng tình huống và đoán xem class con nào vi phạm, class nào không. Bài tập sửa code dài hơn sẽ gom vào một bài tổng ôn riêng của series.

Cố tự làm trước rồi hãy xem lời giải. Xem lời giải trước khi làm thì giống chép đáp án đề thi thử: cảm giác rất giỏi, cho đến hôm thi thật.

Class nào vi phạm L? Giải thích bằng câu hỏi "nó có giữ đúng lời hứa của cha (hoặc của vai trò nó đóng) không?".

  1. ReadOnlyFile < AppFile. AppFile có read và write(content). ReadOnlyFile override write thành raise "File chỉ đọc".
  2. CachedUserRepository < UserRepository. find(id) trả về đúng user như cha, nhưng lấy từ cache nên nhanh hơn.
  3. StrictMailer < Mailer. Mailer#deliver(subject:, body:) nhận subject dài bao nhiêu cũng được. StrictMailer ném lỗi nếu subject dài quá 50 ký tự.
  4. NoPromotion, không kế thừa ai, cắm vào danh sách khuyến mãi của CheckoutCalculator ở bài O khi quán tạm ngưng khuyến mãi. discount_for(order) luôn trả về 0.
  5. SortedList < List. List#add(item) hứa: sau khi thêm, phần tử cuối cùng là item. SortedList#add chèn item vào đúng vị trí để danh sách luôn được sắp xếp.

Túm cái váy lại

Ghi nhớ
Định nghĩa Kiểu con thay được kiểu cha ở mọi chỗ mà chương trình vẫn đúng
Dịch cabin Đứa thay thế phải giữ đúng lời hứa của đứa được thay
Câu hỏi thần chú "Người gọi có phải hỏi 'đứa nào đây?' không?"
Quy tắc Con không đòi hỏi nhiều hơn cha, không hứa ít hơn cha
Dấu hiệu Con raise hoặc rỗng ở method cha hứa; người gọi đầy is_a?
Cách làm Viết lời hứa ra, soi từng đứa con, giữ được lời hứa thì sửa ở đứa con, không giữ được thì tách nó ra, test lời hứa cho cả họ
Hiểu nhầm "Ngoài đời là một loại" thì trong code kế thừa được. Sai, xét theo hành vi
Đừng Bẻ lời hứa dễ dãi cho vừa mọi đứa. Lời hứa phải có ích cho người gọi

Sau bài này, anh em đã sở hữu:

  • Một câu hỏi để soi mọi cây kế thừa: người gọi có phải hỏi "đứa nào đây?" không?
  • Một quy trình 4 bước: viết lời hứa ra giấy, soi từng đứa con, sửa ở đứa con (hoặc tách nó ra nếu nó không thể giữ lời), test lời hứa cho cả họ.
  • Và hy vọng là một chuỗi is_a? nào đó trong dự án sắp được nghỉ hưu.

Chúc anh em code thay chỗ nào chạy chỗ đó, test chạy xanh, và trà luôn đủ đá. Hẹn anh em ở chữ I.

À mà, nếu anh em đang nghĩ "class con của em có raise NotImplementedError mà mấy năm nay chưa ai gọi tới", thì mình tin. Cô em họ cũng trông quán được mấy buổi rồi, chỉ là hôm đó chưa ai gọi trà đá.

Bạn có muốn xem đáp án không?
[y/]

~$ cd ..