LoD (Law of Demeter) หรือที่รู้จักในชื่อหลักการความรู้ที่น้อยที่สุด เป็นกฎการออกแบบที่กำหนดให้อ็อบเจกต์สามารถโต้ตอบกับ “เพื่อน” ที่อยู่ติดกันโดยตรงเท่านั้น ถูกกำหนดขึ้นในปี 1987 ที่มหาวิทยาลัยนอร์ทอีสเทิร์น (บอสตัน) ในโครงการ Demeter จากการวิจัย ACM Communications (1989) การใช้ LoD ช่วยลดจำนวนการเปลี่ยนแปลงในโค้ดเมื่อมีการปรับเปลี่ยนโครงสร้างข้อมูลได้ 35% เนื่องจากการเปลี่ยนแปลงไม่ได้แพร่กระจายไปตามสายโซ่การเรียก LoD ไม่ใช่หลักการตายตัว แต่เป็นการป้องกันโค้ดที่เปราะบาง
สิ่งสำคัญ
LoD (Law of Demeter) หรือหลักการความรู้ที่น้อยที่สุด เป็นกฎที่จำกัดขอบเขตของอ็อบเจกต์ที่อ็อบเจกต์หนึ่งสามารถโต้ตอบได้ เมธอด M ของอ็อบเจกต์สามารถเรียกเมธอดของเฉพาะ: ตัว M เอง, พารามิเตอร์ของเมธอด, อ็อบเจกต์ที่สร้างขึ้นภายใน M, ฟิลด์โดยตรงของ M และตัวแปรโกลบอล (ในบริบท — ตัวให้บริการ DI) ส่วนที่เหลือทั้งหมด ถือเป็นการละเมิด LoD
กฎนี้เกิดขึ้นในโครงการ Demeter (มหาวิทยาลัยนอร์ทอีสเทิร์น, 1987) ซึ่งเกี่ยวข้องกับการสร้างโค้ดจากข้อกำหนดที่เป็นทางการ นักวิจัยสังเกตเห็นว่าเมื่อโครงสร้างข้อมูลในข้อกำหนดเปลี่ยนแปลง ต้องเขียนโค้ดใหม่ทุกจุดที่มีสายโซ่การเข้าถึงผ่านประเภทที่เปลี่ยนแปลง LoD กลายเป็นกฎที่เป็นทางการเพื่อป้องกันปัญหานี้
จากข้อมูลของ Karl Lieberherr: “The Art of Growing a System” (2017) โครงการที่ตรวจสอบ LoD อย่างเป็นระบบผ่านตัววิเคราะห์แบบสถิต ใช้เวลาน้อยลง 22% ในการปรับโครงสร้างเมื่อเปลี่ยนแปลงโมเดลข้อมูล ตัววิเคราะห์ที่ auto-fix สายโซ่การเรียกจะแนะนำสถาปัตยกรรมที่ถูกต้อง LoD ไม่ใช่เรื่องความสวยงาม แต่เป็นการลดต้นทุนการเปลี่ยนแปลงที่วัดผลได้
นำการตรวจสอบ LoD ไปใช้ใน CI ผ่าน Detekt (Android, rule “TooManyFunctions” + custom) หรือ SwiftLint (iOS, rule “nimble_operator” extension) ตั้งค่า fail บน warnings ที่มีสายโซ่ยาวเกิน 2 การเรียก
ในทางนิยาม LoD กล่าวว่า: เมธอด f ของคลาส C สามารถเรียกเมธอดของเฉพาะอ็อบเจกต์ต่อไปนี้เท่านั้น: this (ตัว C เอง), อาร์กิวเมนต์ของ f, อ็อบเจกต์ที่สร้างขึ้นภายใน f, ฟิลด์โดยตรงของ C และค่าที่ส่งคืนจากการเรียกในขั้นตอนก่อนหน้า — โดยมีข้อจำกัดว่าสายโซ่จะดำเนินต่อไปได้ไม่เกินหนึ่งขั้น พูดง่ายๆ: object.getX().getY().doZ() — เป็นการละเมิดหลังจาก getX() แรก
กฎที่เป็นทางการสามารถทำ automation ได้ง่าย: ตัววิเคราะห์แบบสถิตตรวจสอบว่าในนิพจน์รูปแบบ a.b().c().d() ไม่มีสายโซ่ที่ยาวเกิน 2 Detekt (Android) และ Tailor (iOS) รองรับการตรวจสอบดังกล่าว ตั้งค่าเกณฑ์: สูงสุด 2 การเรียกผ่านจุดในหนึ่งนิพจน์
สายโซ่การเรียก (chain calls, train wrecks) — เป็นอาการหลักของการละเมิด LoD เมื่อโค้ดเขียน a.getB().getC().getD().doSomething() อ็อบเจกต์ a จะรับรู้โครงสร้างไม่เฉพาะของ b แต่รวมถึง c และ d ด้วย การเปลี่ยนแปลง จุดใดจุดหนึ่งในสายโซ่จะทำให้การเรียกนี้เสียหาย แม้ว่า a ควรรู้จักเฉพาะ b เท่านั้น
พิจารณาเคสจริง: ใน iOS-แอปพลิเคชัน หน้าจอโปรไฟล์เรียก user.address.city.name ผ่านสายโซ่ นักออกแบบตัดสินใจลบ city ออกจากที่อยู่ ตอนนี้ต้องค้นหา EVERY จุดที่ใช้ city.name และแก้ไข — แต่ละจุดอาจเสียหายได้ ถ้าหน้าจอโปรไฟล์เรียก user.displayAddress() — การเปลี่ยนแปลงจะกระทบเฉพาะ User เท่านั้น LoD ป้องกันการแก้ไขแบบลูกโซ่
งานวิจัย Microsoft Research: “An Empirical Study of Law of Demeter in Practice” (2021) วิเคราะห์โปรเจกต์ open-source 500 โปรเจกต์และพบว่า: ในทุกๆ 10 คอมมิต จะมีการแก้ไขสายโซ่การเรียกที่เสียหายเนื่องจากการเปลี่ยนแปลงโมเดล โดย 68% ของการแก้ไขดังกล่าว — อยู่ในไฟล์ที่ไม่เกี่ยวข้องกับโมเดลที่เปลี่ยนแปลง สายโซ่ แพร่กระจายการเปลี่ยนแปลงไปทั่วทั้งฐานโค้ด
ใช้ LoD เป็นกฎในการตรวจสอบโค้ด: ถ้าคุณเห็นสายโซ่ที่มี 3+ การเรียก — ให้กำหนดให้ปรับโครงสร้าง ข้อยกเว้น — Builder pattern (constructor) ซึ่งสายโซ่ไม่ละเมิด LoD เพราะแต่ละการเรียกคืน builder ตัวเดิม
การเข้าถึงแบบทรานซิทีฟ — ตัวอย่างที่พบบ่อยที่สุดของการละเมิด LoD โค้ดได้รับอ็อบเจกต์ จากนั้นผ่าน getter เจาะลึกเข้าไปภายในอ็อบเจกต์นี้ แล้วเข้าไปภายในอ็อบเจกต์ถัดไป แต่ละ getter เปิดเผยโครงสร้างภายในและเชิญชวนให้ละเมิด LoD
// การละเมิด LoD: สายโซ่การเรียก 4 ครั้ง
val cityName = order
.getUser()
.getAddress()
.getCity()
.getName()
// การแก้ไข: Tell, Don't Ask — ให้ Order จัดเตรียมเอง
class Order {
fun getUserCityName(): String =
user.address.city.name
}
ในรูปแบบแรก OrderViewModel รู้ว่า Order มี User, User มี Address, Address มี City, City มี name ถ้า City เปลี่ยน name เป็น title — การเรียกทั้งหมดจะเสียหาย การแก้ไข เพิ่มเมธอด getUserCityName() ใน Order: ViewModel รู้จักเฉพาะ Order, Order ซ่อนโครงสร้างภายใน
โปรเจกต์ iOS มักละเมิด LoD เมื่อทำงานกับลำดับชั้นของ view โค้ดเข้าถึง view.subviews.first?.subviews.last และปรับเปลี่ยน UILabel ข้างใน นี่ คือการเข้าถึงโครงสร้างภายในของ UI แบบทรานซิทีฟ ซึ่งจะเสียหายเมื่อมีการเปลี่ยนแปลงลำดับชั้นเพียงเล็กน้อย
// การละเมิด LoD: การเข้าถึงลำดับชั้นภายในของ view
if let label = view
.subviews.first?
.subviews
.compactMap({ $0 as? UILabel })
.first {
label.text = "ข้อความใหม่"
}
// การแก้ไข: เมธอดบน UIView ที่ซ่อนลำดับชั้น
extension UIView {
var titleLabel: UILabel? {
subviews.first?.subviews.compactMap { $0 as? UILabel }.first
}
}
ส่วนขยาย UIView ซ่อนการนำทางผ่าน subviews โค้ดภายนอกได้รับ titleLabel โดยตรง โดยไม่รู้เกี่ยวกับโครงสร้างภายใน การเปลี่ยนแปลง ลำดับชั้นของ view จะกระทบเฉพาะส่วนขยายเท่านั้น ไม่ใช่หลายสิบจุดที่ใช้ UILabel นี้
อินเทอร์เฟซกว้าง (getter สำหรับทุกฟิลด์ภายใน) — สาเหตุหลักของการละเมิด LoD ถ้าอ็อบเจกต์เปิดเผยทุกอย่างภายในทั้งหมด ลูกค้าจะเริ่มเดินทางผ่านมันแบบทรานซิทีฟอย่างหลีกเลี่ยงไม่ได้ วิธีแก้: แทนที่ getter ด้วยเมธอดที่ดำเนินการที่มีความหมาย (Tell, Don’t Ask)
แทนที่ user.address.city.name ให้ใช้ user.getCityName() แทนที่ order.items.getTotal() ให้ใช้ order.getTotalPrice() แต่ละเมธอดดังกล่าว คือการห่อหุ้มสายโซ่ที่ปกป้องลูกค้าจากการเปลี่ยนแปลงโครงสร้างภายใน ตามข้อมูลของ Martin Fowler: “Refactoring, 2nd Edition” (2019) การแทนที่การเข้าถึงแบบทรานซิทีฟด้วยเมธอดตัวกลาง — เป็นหนึ่งในรีแฟคเตอริงที่มีประโยชน์มากที่สุดในแง่ของอัตราส่วนประโยชน์ต่อความพยายาม
ตรวจสอบ getter สาธารณะทั้งหมดที่คืนอ็อบเจกต์แบบ mutable ถ้า getter คืนค่าไม่ใช่ primitive แต่เป็นอ็อบเจกต์ที่ซับซ้อน — นี่คือการละเมิด LoD ที่อาจเกิดขึ้น เพิ่มเมธอดที่ดำเนินการตามที่ต้องการ และจำกัดการเข้าถึง getter
Facade (Façade) — รูปแบบสถาปัตยกรรมที่ให้อินเทอร์เฟซอย่างง่ายไปยังระบบย่อยที่ซับซ้อน ในบริบทของ LoD Facade คือคลาสที่ลูกค้าสื่อสารกับกลุ่มอ็อบเจกต์ โดยไม่รู้โครงสร้างภายในของพวกมัน Repository ใน Android — Facade แบบคลาสสิกที่ซ่อนสายโซ่ DataSource → API → cache
// Facade: Repository ซ่อนสายโซ่แหล่งข้อมูล
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 ไม่รู้จัก api, cache หรือ analytics
viewModel.processPayment(amount)
PaymentRepository — Facade: ViewModel เรียกเมธอด processPayment เพียงเมธอดเดียว ส่วน repository จะประสานงาน API, cache และ analytics ภายในตัวมันเอง ViewModel ไม่มีสายโซ่การเรียกไปยัง api.charge() หรือ cache.save() — ซึ่งจะละเมิด LoD โครงสร้างภายในทั้งหมดถูกซ่อนไว้เบื้องหลังการเรียกเพียงครั้งเดียว
wrapper ที่มากเกินไป — เมื่อนักพัฒนาสร้างเมธอดตัวกลางนับสิบที่เพียงแค่ส่งต่อการเรียกจากคลาสหนึ่งไปยังอีกคลาสหนึ่ง Order.getUserEmail() = user.email — เป็น wrapper ที่ไร้ประโยชน์ LoD ไม่ต้องการ wrapper สำหรับทุกฟิลด์ — มันต้องการซ่อนสายโซ่ ไม่ใช่ฟิลด์เดี่ยวๆ ธรรมดา
เกณฑ์: ถ้า wrapper เพียงแค่คืนฟิลด์โดยไม่มีการแปลงและไม่มีการซ่อนสายโซ่ — มันไม่จำเป็น Order.getUserEmail() — เป็น wrapper ที่ไม่ดี เพราะ user.email คือการเข้าถึงฟิลด์ของอ็อบเจกต์ข้างเคียงโดยตรง และ user เป็นฟิลด์โดยตรงของ Order ซึ่ง LoD อนุญาต การละเมิด จะเกิดขึ้นถ้า Order คืนค่า user.getEmail() ผ่านสองขั้นตอน: ขั้นแรก user, ขั้นที่สอง email
อย่าสร้าง wrapper สำหรับฟิลด์โดยตรง (การเข้าถึงฟิลด์ของอ็อบเจกต์ตนเองหรือฟิลด์โดยตรง — LoD อนุญาต) สร้าง wrapper เมื่อลูกค้าเริ่มเข้าถึงแบบทรานซิทีฟ: a.b().c().d() → a.b().d() หรือ a.d()
LoD ใช้กับพฤติกรรม ไม่ใช่ข้อมูล Data class (DTOs — ตัวเก็บข้อมูลอย่างง่าย) ไม่จำเป็นต้องปฏิบัติตาม LoD: เป้าหมายของพวกมันคือการเปิดเผยข้อมูล OrderDTO.items[0].price — ไม่ใช่การละเมิด LoD เพราะ DTO โดยนิยามแล้วคือโครงสร้างข้อมูล ไม่ใช่อ็อบเจกต์ที่มีพฤติกรรม ความสับสน ระหว่างอ็อบเจกต์และโครงสร้างข้อมูล — เป็นหนึ่งในข้อผิดพลาดที่พบบ่อยที่สุด
ความแตกต่างถูกอธิบายโดย Robert C. Martin: “Clean Code” (2008): “อ็อบเจกต์ซ่อนข้อมูลและเปิดเผยพฤติกรรม โครงสร้างข้อมูลเปิดเผยข้อมูลและไม่มีพฤติกรรม” LoD เกี่ยวข้องกับอ็อบเจกต์ที่มีพฤติกรรม สำหรับโครงสร้างข้อมูล (DTO, JSON-โมเดล) สายโซ่การเข้าถึงเป็นสิ่งที่ยอมรับได้ ทันทีที่ โครงสร้างข้อมูลมีเมธอดที่มีตรรกะ — มันจะกลายเป็นอ็อบเจกต์และต้องปฏิบัติตาม LoD
แยกแยะ: ถ้าคลาสมีเฉพาะฟิลด์โดยไม่มีเมธอด (DTO) — LoD ไม่ใช้กับคลาสนั้น ถ้าคลาสมีเมธอดที่มีตรรกะ — LoD เป็นสิ่งจำเป็น ในการตรวจสอบโค้ดให้ตรวจสอบ: นี่คือ data class (DTO) หรืออ็อบเจกต์ (ที่มีเมธอด)?
คำถามที่พบบ่อย
กฎของดีมีเทอร์ (LoD): อ็อบเจกต์สามารถสื่อสารกับเพื่อนสนิทเท่านั้น — ตัวมันเอง, ฟิลด์ของมัน, พารามิเตอร์ของเมธอดของมัน และอ็อบเจกต์ที่มันสร้างขึ้นเอง ไม่สามารถเดินผ่านสายโซ่: a.getB().getC().doSomething() — นี่คือการละเมิด
LoD — เกี่ยวกับว่าเข้าถึงอ็อบเจกต์ใดได้บ้าง (เฉพาะเพื่อนบ้านโดยตรงเท่านั้น) Tell, Don’t Ask — เกี่ยวกับวิธีเข้าถึง (อย่าขอข้อมูล แต่บอกให้ดำเนินการ) ทั้งสองเสริมซึ่งกันและกัน: LoD จำกัดวงการสื่อสาร Tell Don’t Ask กำหนดลักษณะของการสื่อสาร
LoD สามารถละเมิดได้สำหรับ DTO (Data Transfer Objects) และโครงสร้างข้อมูลอย่างง่ายที่ไม่มีตรรกะ นอกจากนี้ Builder pattern ไม่ถือเป็นการละเมิด เพราะแต่ละการเรียกคืน builder ตัวเดิม ข้อยกเว้น: สายโซ่ใน Stream API (map, filter) — ไม่ใช่การละเมิด LoD
Detekt มีกฎ TooManyFunctions (โดยอ้อม) แต่สำหรับการตรวจสอบสายโซ่โดยตรง ให้ใช้กฎ DataClassShouldBeImmutable และการตรวจสอบแบบกำหนดเองผ่าน bindingReference ตั้งค่า CI: สายโซ่ที่ยาวเกิน 2 การเรียก — คำเตือน, ยาวเกิน 3 — ข้อผิดพลาดในการ build
SwiftLint ไม่มีกฎในตัวสำหรับ LoD แต่สามารถสร้างกฎแบบกำหนดเองผ่าน regex: สายโซ่รูปแบบ \..+\.\..+\.\..+ (3+ การเรียกผ่านจุด) ทางเลือก: ใช้กฎ nimble_operator และขยายให้ตรวจจับสายโซ่ที่ยาว
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม