Clean Code
06 Phần III · Cấu trúc & Độ bền Chương 6

Đối tượng & Cấu trúc dữ liệu

Objects and Data Structures

Có lý do ta giữ biến private: ta không muốn ai phụ thuộc vào chúng. Chương này tách rạch hai thế giới đối nghịch — đối tượng (giấu dữ liệu, phơi hành vi) và cấu trúc dữ liệu (phơi dữ liệu, không hành vi) — và chỉ ra mỗi bên giỏi gì, dở gì. Không bên nào "đúng" tuyệt đối; chọn nhầm bên mới là cái sai.

01 Trừu tượng hóa dữ liệu

Giữ biến private không chỉ là chuyện đặt getter và setter lên trên chúng.

Robert C. Martin · Chương 6 (diễn giải sát)

Trừu tượng hóa (data abstraction) = phơi một giao diện trừu tượng để người dùng thao tác trên bản chất dữ liệu mà không cần biết nó được hiện thực ra sao. Phơi biến qua getter/setter một cách mù quáng không phải là trừu tượng — nó chỉ là đẩy cấu trúc nội bộ ra ngoài.

Point cụ thể

concrete

Phơi thẳng x, y public. Lộ rằng dữ liệu lưu theo hệ tọa độ chữ nhật, và buộc người dùng thao tác từng tọa độ độc lập.

Point trừu tượng

abstract

Phơi getX/getY, setCartesian, getR/getTheta, setPolar. Ẩn việc lưu theo hệ tọa độ nào, lại còn áp chính sách truy cập: đọc lẻ được, set phải set chung (nguyên tử).

RAG  Ví dụ Vehicle của sách: getGallonsOfGasoline() lộ đơn vị đo, còn getPercentFuelRemaining() là trừu tượng. Trong hệ RAG, đừng phơi cách lưu ngữ cảnh — hãy phơi câu hỏi nghiệp vụ trên nó:

Vì sao: getter/setter mù quáng phơi nguyên cấu trúc lưu trữ (danh sách chunk + điểm thô); mọi nơi gọi phải tự biết "thế nào là đủ ngữ cảnh". Giao diện trừu tượng giấu hiện thực và phơi ý nghĩa — đổi cách lưu sau này không phá nơi gọi.

Python · cụ thể✗ Bẩn
class RetrievedContext:
    def __init__(self):
        self.chunks = []          # public: lộ là 1 list
        self.scores = []          # public: lộ điểm thô từng cái
# nơi dùng phải tự ghép: biết cấu trúc bên trong
ctx.chunks; ctx.scores          # phơi hiện thực ra ngoài
Python · trừu tượng✓ Sạch
class RetrievedContext:
    def top_passages(self, k: int) -> list[str]: ...   # phơi BẢN CHẤT
    def is_confident(self) -> bool: ...                 # không lộ cách lưu
# nơi dùng nói bằng ngôn ngữ nghiệp vụ:
ctx.top_passages(5); ctx.is_confident()

02 Bất đối xứng Data/Object

Hai khái niệm này đối nghịch nhau như âm với dương — đó là lý do gọi là "bất đối xứng" (anti-symmetry):

Đối tượng

Object

Giấu dữ liệu sau lớp trừu tượng, và phơi các hàm thao tác trên dữ liệu đó.

Cấu trúc dữ liệu

Data Structure

Phơi dữ liệu và không có hàm hành vi đáng kể nào.

Sách minh họa bằng bài toán hình học (Shape) viết theo hai lối. Cùng một bài toán, hai cách tổ chức ngược nhau:

Khía cạnhLối thủ tục (data structure)Lối OO (object)
Các lớp hìnhSquare/Rectangle/Circle chỉ chứa data (struct), không hàmMỗi lớp tự chứa area() của mình
Nơi đặt phép tínhMột lớp Geometry gom area(), dùng instanceof rẽ nhánh theo loạiKhông cần Geometry; gọi area() đa hình trên từng hình
Hành vi nằm ở đâuTách rời khỏi dữ liệu, gom vào hàm ngoàiGói cùng dữ liệu trong từng đối tượng

Lưu ý đọc bảng: đây không phải bảng "tốt – xấu". Hai lối chỉ là hai cách tổ chức; ưu thế của bên này là điểm yếu của bên kia — phần kế giải thích đánh đổi.

03 Hệ quả của đánh đổi

Vì hai lối đối nghịch, ưu/nhược của chúng cũng đối nghịch. Cái khó của OO lại là cái dễ của thủ tục, và ngược lại. Đây là so sánh trung tính — không bên nào sai:

Thủ tục + cấu trúc dữ liệu

procedural

Dễ thêm HÀM mới: thêm perimeter() vào Geometry mà các lớp hình không phải đổi.
Khó thêm KIỂU mới: thêm một hình là phải sửa mọi hàm trong Geometry.

Hướng đối tượng

object-oriented

Dễ thêm KIỂU mới: thêm một lớp hình mới mà không đụng các hàm cũ.
Khó thêm HÀM mới: thêm một phép tính là mọi lớp hình phải đổi theo.

"Mọi thứ đều là object" là một myth. Lập trình viên trưởng thành biết: đôi khi một cấu trúc dữ liệu đơn giản cộng vài thủ tục lại phù hợp hơn. Hãy chọn cách hợp với việc, không vì thành kiến.

RAG  Soi vào hệ RAG: nếu bạn đoán sẽ liên tục thêm kiểu retriever mới (vector, BM25, hybrid…) thì lối OO (mỗi retriever một lớp đa hình) êm hơn. Nếu tập kiểu cố định nhưng bạn hay thêm phép xử lý trên một bản ghi (rerank, dedup, highlight…), thì để bản ghi là cấu trúc dữ liệu phẳng rồi viết các thủ tục lại tiện hơn. Cùng một hệ, hai quyết định khác nhau — tùy trục thay đổi nào năng động hơn.

04 Luật Demeter & Train Wreck

Nói chuyện với bạn bè, đừng nói chuyện với người lạ.

Cách diễn nôm của Law of Demeter · Chương 6

Luật Demeter (Law of Demeter): một module không nên biết nội thất của object mà nó thao tác. Cụ thể, method f của lớp C chỉ nên gọi method của:

Bốn nhóm được phép

allowed callees

chính C; object do f tạo ra; object được truyền vào f làm tham số; object giữ trong instance variable của C.

Điều cấm

no chaining on returns

Không gọi method trên object do các hàm trên trả về. Đó là nói chuyện với "người lạ".

Train Wreck — "đoàn tàu trật bánh"

Chuỗi gọi nối đuôi như đoàn toa tàu vừa khó đọc vừa ràng buộc chặt code gọi vào cấu trúc nội bộ nhiều tầng:

Vì sao: nếu ctxt/Options/ScratchDirobject thật (có hành vi) thì chuỗi này vi phạm Demeter — code gọi biết quá nhiều nội thất. (Nếu chúng chỉ là cấu trúc dữ liệu thuần, chúng vốn phơi cấu trúc nên Demeter không áp dụng — xem mục 06.) Cách chữa: tách từng bước hoặc, tốt hơn, bảo object làm hộ.

Python · train wreck✗ Bẩn
# a.getB().getC().doD() — toa nối toa, lộ 3 tầng nội thất
out_dir = ctxt.get_options().get_scratch_dir().get_absolute_path()
path = out_dir + "/" + class_file_name
stream = open(path, "wb")     # code gọi phụ thuộc vào cả Options & ScratchDir
Python · tách bước✓ Sạch
# tối thiểu: tách chuỗi, không gọi trên giá trị trả về của giá trị trả về
opts = ctxt.get_options()
scratch_dir = opts.get_scratch_dir()
path = scratch_dir.get_absolute_path() + "/" + class_file_name
# (vẫn còn lộ cấu trúc — bản vá triệt để ở mục 06: bảo ctxt làm hộ)

05 Hybrid — tệ nhất cả hai

Tệ nhất là cấu trúc nửa đối tượng nửa cấu trúc dữ liệu: vừa có hàm làm việc quan trọng, vừa phơi biến private qua getter/setter công khai như thể đó là biến public.

Vì sao: Hybrid gánh nhược điểm của cả hai phía — khó thêm hàm mới (vì có cả hành vi) lẫn khó thêm kiểu mới (vì có cả dữ liệu phơi ra). Nó là dấu hiệu của thiết kế rối hoặc thiếu hiểu biết. Hãy quyết dứt khoát: hoặc là object (giấu hết, chỉ phơi hành vi), hoặc là data structure (phơi hết, không hành vi).

Python · hybrid✗ Bẩn
class SearchSession:
    def run_query(self, q): ...        # hành vi quan trọng (giống object)
    def get_raw_index(self):           # NHƯNG lại phơi nội thất ra (giống data)
        return self._index
    def set_raw_index(self, idx):
        self._index = idx              # nửa nạc nửa mỡ → tệ cả hai
Python · chọn một phía✓ Sạch
class SearchSession:                   # rõ ràng là OBJECT
    def run_query(self, q): ...        # chỉ phơi hành vi
    def reindex(self, docs): ...       # không phơi _index ra ngoài
    # _index hoàn toàn private — không getter/setter mù quáng

06 Che giấu cấu trúc · Tell, Don't Ask

Quay lại train wreck mục 04: nếu ctxtobject có hành vi thật, đừng điều hướng xuyên qua nó. Hãy tự hỏi vì sao ta cần đường dẫn tuyệt đối của scratch dir? Câu trả lời: để tạo một scratch file tên cho trước. Vậy thì hãy bảo object làm điều đó, thay vì hỏi nó về nội thất rồi tự làm.

Vì sao — Tell, Don't Ask: bản "bẩn" lôi absolutePath ra rồi tự ghép đường dẫn và mở file — đây là Feature Envy, code gọi thèm muốn làm việc của ctxt. Bản "sạch" giao việc cho ctxt: nó giấu nội thất (Options, ScratchDir), trả thẳng thứ ta cần, và không còn vi phạm Luật Demeter.

Python · ask & tự làm✗ Bẩn
# HỎI nội thất rồi tự ghép — lộ cấu trúc, vi phạm Demeter
abs_path = ctxt.get_options().get_scratch_dir().get_absolute_path()
file_path = abs_path + "/" + class_file_name
stream = open(file_path, "wb")
Python · tell, don't ask✓ Sạch
# BẢO ctxt làm hộ — nó tự giấu Options/ScratchDir bên trong
stream = ctxt.create_scratch_file_stream(class_file_name)
# tương đương Java: BufferedOutputStream bos =
#   ctxt.createScratchFileStream(classFileName);

07 DTO & Active Record

DTO

Data Transfer Object

Dạng tinh túy của cấu trúc dữ liệu: lớp chỉ có biến public, không hàm. Hữu ích khi nói chuyện với DB hay parse message từ socket — là tầng dịch đầu tiên biến raw data thành object. ("Bean" = biến private + getter/setter — giả-đóng-gói, thường chẳng lợi gì thêm.)

Active Record

Active Record

Dạng đặc biệt của DTO: biến public (hoặc bean) cộng vài method điều hướng như save/find, dịch trực tiếp từ một bảng DB.

RAG  Sai lầm phổ biến: nhét luật nghiệp vụ vào Active Record — biến nó thành Hybrid (mục 05). Trong hệ RAG, một dòng bảng documents nên ở yên là dữ liệu; logic "tài liệu này có đủ điều kiện đưa vào index không" thuộc về một object nghiệp vụ riêng:

Vì sao: coi Active Record thuần là cấu trúc dữ liệu; đặt luật nghiệp vụ vào một object riêng (object này thường giữ một instance Active Record và ẩn dữ liệu đó đi). Nhờ vậy mỗi bên giữ đúng bản chất — không sinh ra Hybrid.

Python · AR ôm nghiệp vụ✗ Bẩn
class DocumentRecord:        # Active Record (dịch từ bảng)
    text: str                # biến public
    lang: str
    def save(self): ...      # method điều hướng — OK
    def is_indexable(self):  # NHÉT luật nghiệp vụ vào → thành hybrid
        return self.lang == "vi" and len(self.text) > 50
Python · tách object nghiệp vụ✓ Sạch
class DocumentRecord:        # AR thuần = cấu trúc dữ liệu
    text: str; lang: str
    def save(self): ...

class IndexPolicy:           # object nghiệp vụ, GIẤU 1 instance AR
    def __init__(self, rec): self._rec = rec
    def is_indexable(self) -> bool:
        return self._rec.lang == "vi" and len(self._rec.text) > 50

Ranh giới service (PHP ↔ Python): khi Laravel gửi tài liệu sang pipeline Python, payload đi qua dây chính là một DTO thuần — bên PHP cũng nên coi nó là cấu trúc dữ liệu để dịch, đừng gắn luật nghiệp vụ vào DTO truyền đi.

PHP · DTO ở ranh giới service
// DTO thuần: chỉ chở dữ liệu sang Python, KHÔNG chứa luật nghiệp vụ
final class IngestPayload {
    public function __construct(
        public string $docId,
        public string $text,
        public string $lang,
    ) {}
}
Http::post('http://rag-py/ingest', (array) $payload);

08 Ghi nhớ nhanh

Trừu tượng hóa ≠ getter/setter. Phơi bản chất dữ liệu, giấu cách lưu (Vehicle: getPercentFuelRemaining chứ không getGallonsOfGasoline).

Object giấu data – phơi hàm; Data structure phơi data – không hàm. Hai thứ đối nghịch, không thứ nào "đúng" tuyệt đối.

Thủ tục dễ thêm HÀM, khó thêm KIỂU; OO ngược lại. "Mọi thứ là object" là myth — chọn theo trục thay đổi năng động hơn.

Tránh Train Wreck. Đừng gọi method trên giá trị do method khác trả về (Luật Demeter) — khi đối tượng có hành vi thật.

Tránh Hybrid. Nửa object nửa data structure = tệ cả hai chiều. Quyết dứt khoát một phía.

Tell, Don't Ask. Bảo object làm hộ (createScratchFileStream) thay vì hỏi nội thất rồi tự làm.

Đừng nhét luật nghiệp vụ vào Active Record. Coi nó là data structure; tách object nghiệp vụ riêng giữ một instance AR.

NguồnChương 6 (Objects and Data Structures), Clean Code: A Handbook of Agile Software Craftsmanship — Robert C. Martin, Prentice Hall 2008.