LoD (Law of Demeter), còn được gọi là nguyên tắc tri thức tối thiểu — một quy tắc thiết kế yêu cầu một đối tượng chỉ tương tác với các “bạn bè” trực tiếp của nó. Nó được xây dựng vào năm 1987 tại Đại học Northeastern (Boston) trong khuôn khổ dự án Demeter. Theo nghiên cứu ACM Communications (1989), áp dụng LoD làm giảm số lượng thay đổi mã khi sửa đổi cấu trúc dữ liệu xuống 35%, vì các thay đổi không lan truyền qua chuỗi gọi. LoD không phải là giáo điều, mà là sự bảo vệ khỏi mã dễ vỡ.
Điểm chính
LoD (Law of Demeter), hay nguyên tắc tri thức tối thiểu — một quy tắc giới hạn tập hợp các đối tượng mà một đối tượng cụ thể có thể tương tác. Một phương thức của đối tượng M chỉ có thể gọi phương thức của: chính M, tham số của phương thức, các đối tượng được tạo trong M, các trường trực tiếp của M và các biến toàn cục (trong ngữ cảnh — nhà cung cấp DI). Mọi thứ khác là vi phạm LoD.
Định luật này bắt nguồn từ dự án Demeter (Đại học Northeastern, 1987), tập trung vào việc tạo mã dựa trên các đặc tả hình thức. Các nhà nghiên cứu nhận thấy rằng khi một cấu trúc dữ liệu thay đổi trong đặc tả, mã phải được viết lại ở tất cả các nơi mà chuỗi gọi đi qua kiểu đã thay đổi. LoD trở thành một quy tắc hình thức ngăn chặn vấn đề này.
Theo Karl Lieberherr: “The Art of Growing a System” (2017), các dự án kiểm tra LoD một cách hệ thống thông qua bộ phân tích tĩnh dành ít hơn 22% thời gian cho việc tái cấu trúc khi thay đổi mô hình dữ liệu. Các bản sửa tự động của bộ phân tích cho chuỗi gọi đề xuất kiến trúc chính xác. LoD không phải là tính thẩm mỹ, mà là sự giảm chi phí thay đổi có thể đo lường được.
Tích hợp kiểm tra LoD vào CI của bạn qua Detekt (Android, quy tắc “TooManyFunctions” + tùy chỉnh) hoặc SwiftLint (iOS, phần mở rộng quy tắc “nimble_operator”). Cấu hình để thất bại khi có cảnh báo về chuỗi dài hơn 2 lần gọi.
Về mặt hình thức, LoD phát biểu: một phương thức f của lớp C chỉ có thể gọi phương thức của các đối tượng sau: this (chính C), đối số của f, các đối tượng được tạo trong f, các trường trực tiếp của C và các giá trị trả về của các lần gọi từ các bước trước — với hạn chế là chuỗi không tiếp tục quá một bước. Nói đơn giản: object.getX().getY().doZ() là vi phạm sau getX() đầu tiên.
Quy tắc hình thức rất dễ tự động hóa: một bộ phân tích tĩnh kiểm tra rằng các biểu thức như a.b().c().d() không có chuỗi dài hơn 2. Detekt (Android) và Tailor (iOS) hỗ trợ các kiểm tra như vậy. Đặt ngưỡng: tối đa 2 lần gọi dấu chấm trong một biểu thức.
Chuỗi gọi (train wrecks) là triệu chứng chính của vi phạm LoD. Khi mã viết a.getB().getC().getD().doSomething(), đối tượng a tiếp nhận kiến thức về cấu trúc không chỉ của b, mà còn của c và d. Một thay đổi trong bất kỳ mắt xích nào của chuỗi sẽ làm hỏng lần gọi này, mặc dù a chỉ nên biết về b.
Hãy xem xét một trường hợp thực tế: trong một ứng dụng iOS, màn hình hồ sơ lấy user.address.city.name thông qua một chuỗi. Nhà thiết kế quyết định xóa city khỏi địa chỉ. Bây giờ tất cả các nơi sử dụng city.name phải được tìm và sửa — mỗi nơi có thể bị hỏng. Nếu màn hình hồ sơ yêu cầu user.displayAddress(), thay đổi sẽ chỉ ảnh hưởng đến User. LoD ngăn chặn các bản sửa dồn dập.
Một nghiên cứu của Microsoft Research: “An Empirical Study of Law of Demeter in Practice” (2021) đã phân tích 500 dự án mã nguồn mở và phát hiện rằng cứ mỗi commit thứ 10 lại chứa một bản sửa cho chuỗi gọi bị hỏng do thay đổi mô hình. Hơn nữa, 68% các bản sửa đó nằm trong các tệp không liên quan đến mô hình đã thay đổi. Chuỗi lan truyền thay đổi trên toàn bộ cơ sở mã.
Sử dụng LoD như một quy tắc xem xét mã: nếu bạn thấy chuỗi gọi 3+ lần, hãy yêu cầu tái cấu trúc. Ngoại lệ là mô hình Builder (trình tạo), nơi chuỗi không vi phạm LoD vì mỗi lần gọi trả về cùng một builder.
Truy cập bắc cầu là ví dụ phổ biến nhất của vi phạm LoD. Mã lấy một đối tượng, sau đó thông qua các getter thâm nhập vào bên trong đối tượng đó, rồi vào bên trong đối tượng tiếp theo. Mỗi getter phơi bày cấu trúc nội bộ và mời gọi các vi phạm LoD.
// Vi phạm LoD: chuỗi 4 lần gọi
val cityName = order
.getUser()
.getAddress()
.getCity()
.getName()
// Sửa: Tell, Don’t Ask — để Order tự cung cấp
class Order {
fun getUserCityName(): String =
user.address.city.name
}
Ở phiên bản đầu tiên, OrderViewModel biết rằng Order có User, User có Address, Address có City và City có name. Nếu City đổi tên name thành title, tất cả các lần gọi đều bị hỏng. Bản sửa thêm một phương thức getUserCityName() vào Order: ViewModel chỉ biết Order, Order ẩn cấu trúc nội bộ.
Các dự án iOS thường vi phạm LoD khi làm việc với hệ phân cấp view. Mã truy cập view.subviews.first?.subviews.last và sửa đổi một UILabel bên trong. Điều này là truy cập bắc cầu vào cấu trúc nội bộ của UI, sẽ hỏng khi có sự thay đổi nhỏ nhất trong hệ phân cấp.
// Vi phạm LoD: truy cập hệ phân cấp view nội bộ
if let label = view
.subviews.first?
.subviews
.compactMap({ $0 as? UILabel })
.first {
label.text = "Văn bản mới"
}
// Sửa: phương thức trên UIView ẩn hệ phân cấp
extension UIView {
var titleLabel: UILabel? {
subviews.first?.subviews.compactMap { $0 as? UILabel }.first
}
}
Phần mở rộng UIView ẩn điều hướng qua các subviews. Mã bên ngoài lấy titleLabel trực tiếp mà không biết cấu trúc nội bộ. Một thay đổi trong hệ phân cấp view sẽ chỉ ảnh hưởng đến phần mở rộng, chứ không phải hàng chục nơi sử dụng UILabel này.
Giao diện rộng (getter cho tất cả các trường nội bộ) — nguyên nhân chính của vi phạm LoD. Nếu một đối tượng phơi bày tất cả nội tại của nó, các client chắc chắn sẽ bắt đầu duyệt chúng một cách bắc cầu. Giải pháp: thay thế các getter bằng các phương thức thực hiện các hành động có ý nghĩa (Tell, Don’t Ask).
Thay vì user.address.city.name, hãy cung cấp user.getCityName(). Thay vì order.items.getTotal(), hãy cung cấp order.getTotalPrice(). Mỗi phương thức như vậy đóng gói một chuỗi, bảo vệ client khỏi những thay đổi trong cấu trúc nội bộ. Theo Martin Fowler: “Refactoring, 2nd Edition” (2019), thay thế truy cập bắc cầu bằng một phương thức trung gian là một trong những cái tái cấu trúc có lợi nhất về tỷ lệ lợi ích/công sức.
Kiểm tra tất cả các getter công khai trả về các đối tượng có thể thay đổi. Nếu một getter trả về một đối tượng phức tạp thay vì một nguyên thủy, đó là một vi phạm LoD tiềm ẩn. Thêm một phương thức thực hiện hành động cần thiết và hạn chế quyền truy cập vào getter.
Facade là một mô hình kiến trúc cung cấp giao diện đơn giản cho một hệ thống con phức tạp. Trong bối cảnh của LoD, Facade là một lớp mà qua đó client giao tiếp với một nhóm các đối tượng mà không biết cấu trúc nội bộ của chúng. Repository trong Android là một Facade cổ điển, ẩn các chuỗi DataSource → API → bộ nhớ đệm.
// Facade: Repository ẩn chuỗi các nguồn dữ liệu
class PaymentRepository(
private val api: PaymentApi,
private val cache: PaymentCache,
private val analytics: AnalyticsTracker
) {
suspend fun processPayment(amount: Double): Result {
analytics.track("payment_start")
val result = api.charge(amount)
cache.save(result)
return result
}
}
// ViewModel không biết gì về api, cache hay analytics
viewModel.processPayment(amount)
PaymentRepository là một Facade: ViewModel gọi một phương thức, processPayment, và repository phối hợp API, bộ nhớ đệm và phân tích nội bộ. ViewModel không có chuỗi gọi đến api.charge() hoặc cache.save() — điều đó sẽ vi phạm LoD. Tất cả cấu trúc nội bộ đều ẩn sau một lần gọi duy nhất.
Wrapper quá mức — khi một nhà phát triển tạo hàng chục phương thức trung gian chỉ ủy quyền một lời gọi từ lớp này sang lớp khác. Order.getUserEmail() = user.email là một wrapper vô dụng. LoD không yêu cầu wrapper cho mỗi trường — nó yêu cầu ẩn các chuỗi, không phải các trường đơn lẻ đơn giản.
Tiêu chí: nếu một wrapper chỉ đơn giản trả về một trường mà không biến đổi và không ẩn một chuỗi, thì nó không cần thiết. Order.getUserEmail() là một wrapper tồi vì user.email là truy cập trực tiếp đến trường của một đối tượng lân cận, và user là trường trực tiếp của Order, điều mà LoD cho phép. Một vi phạm sẽ là nếu Order trả về user.getEmail() qua hai bước: đầu tiên user, sau đó email.
Không tạo wrapper cho các trường trực tiếp (truy cập trường của đối tượng riêng hoặc trường trực tiếp được LoD cho phép). Tạo wrapper khi một client bắt đầu duyệt bắc cầu: a.b().c().d() → a.b().d() hoặc a.d().
LoD áp dụng cho hành vi, không phải dữ liệu. Các lớp dữ liệu (DTO — các container dữ liệu đơn giản) không bắt buộc phải tuân theo LoD: mục đích của chúng là phơi bày dữ liệu. OrderDTO.items[0].price không phải là vi phạm LoD vì DTO theo định nghĩa là một cấu trúc dữ liệu, không phải một đối tượng có hành vi. Sự nhầm lẫn giữa đối tượng và cấu trúc dữ liệu là một trong những lỗi phổ biến nhất.
Sự phân biệt này được đưa ra bởi Robert C. Martin: “Clean Code” (2008): “Các đối tượng ẩn dữ liệu và phơi bày hành vi. Cấu trúc dữ liệu phơi bày dữ liệu và không có hành vi.” LoD áp dụng cho các đối tượng có hành vi. Đối với cấu trúc dữ liệu (DTO, mô hình JSON), các chuỗi truy cập được phép. Ngay khi một cấu trúc dữ liệu có một phương thức với logic, nó trở thành một đối tượng và phải tuân theo LoD.
Phân biệt: nếu một lớp chỉ chứa các trường không có phương thức (DTO), LoD không áp dụng. Nếu một lớp chứa các phương thức với logic, LoD là bắt buộc. Khi xem xét mã, hãy kiểm tra: đây là lớp dữ liệu (DTO) hay một đối tượng (có phương thức)?
Câu hỏi thường gặp
Định luật Demeter (LoD): một đối tượng chỉ có thể giao tiếp với những người bạn thân thiết — chính nó, các trường của nó, các tham số của phương thức và các đối tượng mà nó tạo ra. Bạn không thể duyệt qua một chuỗi: a.getB().getC().doSomething() — đó là vi phạm.
LoD là về VIỆC tương tác với những đối tượng nào (chỉ hàng xóm trực tiếp). Tell, Don’t Ask là về CÁCH tương tác (đừng hỏi dữ liệu, hãy bảo làm). Chúng bổ sung cho nhau: LoD giới hạn vòng tròn giao tiếp, Tell Don’t Ask xác định bản chất tương tác.
LoD có thể bị vi phạm cho DTO (đối tượng truyền dữ liệu) và cấu trúc dữ liệu đơn giản không chứa logic. Ngoài ra, mô hình Builder không được coi là vi phạm vì mỗi lần gọi trả về cùng một builder. Ngoại lệ: các chuỗi trong Stream API (map, filter) không phải là vi phạm LoD.
Detekt có quy tắc TooManyFunctions (gián tiếp), nhưng để kiểm tra chuỗi trực tiếp, hãy sử dụng quy tắc DataClassShouldBeImmutable và các kiểm tra tùy chỉnh thông qua bindingReference. Cấu hình CI: chuỗi dài hơn 2 lần gọi — cảnh báo, dài hơn 3 — lỗi xây dựng.
SwiftLint không có quy tắc tích hợp sẵn cho LoD, nhưng bạn có thể tạo quy tắc tùy chỉnh thông qua regex: các chuỗi như \..+\.\..+\.\..+ (3+ lần gọi dấu chấm). Thay thế: sử dụng quy tắc nimble_operator và mở rộng nó để phát hiện các chuỗi dài.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm