- Couplers คืออะไร
- ภาพรวมทั้ง 4 แบบ
- 1. Feature Envy: พฤติกรรมอยู่ห่างจากข้อมูลที่ใช้
- 2. Inappropriate Intimacy: รู้รายละเอียดภายในของอีกคลาสมากเกินไป
- 3. Message Chains: ผู้เรียกรู้เส้นทางผ่านออบเจ็กต์หลายทอด
- 4. Middle Man: มีตัวกลางแต่ไม่มีหน้าที่ที่คุ้มค่า
- เลือกระหว่าง Hide Delegate กับ Remove Middle Man
- ขั้นตอนนำไปใช้ในการ Code Review
- แบบฝึกหัดอภิปราย
- ข้อคิดสำหรับการออกแบบ
- แหล่งอ้างอิง
อธิบาย Feature Envy, Inappropriate Intimacy, Message Chains และ Middle Man พร้อมตัวอย่าง Java ก่อนและหลัง Refactoring
#Couplers คืออะไร
การเขียนโปรแกรมเชิงวัตถุต้องอาศัยความร่วมมือระหว่างออบเจ็กต์ แต่เมื่อคลาสหนึ่งรู้รายละเอียดของอีกคลาสมากเกินไป การแก้โครงสร้างเพียงจุดเดียวอาจทำให้ต้องแก้ผู้เรียกหลายแห่ง กลุ่ม Couplers ช่วยชี้ให้เห็นความสัมพันธ์ที่ควรตรวจสอบ
เป้าหมายของการปรับโครงสร้างคือทำให้แต่ละคลาสมีหน้าที่ชัดเจน และให้ผู้เรียกพึ่งพาสัญญาที่จำเป็นต่อการทำงาน โดยรักษาพฤติกรรมที่ผู้ใช้สังเกตได้ไว้
Code Smell เป็นสัญญาณให้พิจารณา ไม่ใช่ข้อพิสูจน์ว่าโค้ดผิด ต้องดูเหตุผลของการออกแบบและผลกระทบจากการเปลี่ยนแปลงร่วมด้วย
#ภาพรวมทั้ง 4 แบบ
| Code Smell | สัญญาณที่ควรสังเกต | แนวทางเริ่มต้น |
|---|---|---|
| Feature Envy | เมธอดทำงานกับข้อมูลของออบเจ็กต์อื่นเป็นหลัก | Move Method หรือ Extract Method แล้วค่อยย้าย |
| Inappropriate Intimacy | คลาสเข้าถึงหรือจัดการรายละเอียดภายในของอีกคลาส | Encapsulate Field, Move Method หรือ Extract Class |
| Message Chains | ผู้เรียกไล่ผ่านออบเจ็กต์หลายทอดเพื่อไปถึงสิ่งที่ต้องการ | Hide Delegate หรือย้ายการทำงานไปยังออบเจ็กต์ที่เหมาะสม |
| Middle Man | คลาสส่วนใหญ่ทำหน้าที่ส่งต่อโดยไม่มีเหตุผลเพียงพอ | Remove Middle Man หรือ Inline Class ตามบริบท |
แนวคิดและชื่อเทคนิคอ้างอิงจาก Refactoring.Guru [1–4] ตัวอย่าง Java และคำอธิบายสถานการณ์ในบทความนี้จัดทำขึ้นเพื่อการสอน
#1. Feature Envy: พฤติกรรมอยู่ห่างจากข้อมูลที่ใช้
#สังเกตจากอะไร
เมธอดหนึ่งดึงข้อมูลจากออบเจ็กต์อื่นมาคำนวณหลายขั้นตอน ขณะที่แทบไม่ใช้สถานะของคลาสตัวเอง ให้ลองถามว่า “กฎนี้ควรอยู่กับเจ้าของข้อมูลหรือไม่?”
#ก่อนปรับโครงสร้าง
ตัวอย่างนี้ใช้ record ของ Java 17 ขึ้นไป แต่ละบล็อกก่อนและหลังเป็นตัวอย่างแยกกัน ไม่ควรวางคลาสชื่อซ้ำไว้ในแพ็กเกจเดียวกัน
record Room(double length, double width) {}
class FlooringEstimator {
double area(Room room) {
return room.length() * room.width();
}
double estimate(Room room, double pricePerSquareMeter) {
return area(room) * pricePerSquareMeter;
}
}
เมธอด area() ใช้เฉพาะขนาดของ Room ในตัวอย่างนี้กำหนดให้พื้นที่เป็นความรู้พื้นฐานของห้อง หากส่วนอื่นต้องใช้พื้นที่ด้วย การกระจายสูตรออกไปจะทำให้ดูแลยากขึ้น
#หลังปรับโครงสร้าง: Move Method
record Room(double length, double width) {
double area() {
return length * width;
}
}
class FlooringEstimator {
double estimate(Room room, double pricePerSquareMeter) {
return room.area() * pricePerSquareMeter;
}
}
Room รับผิดชอบพื้นที่ ส่วน FlooringEstimator รับผิดชอบการประเมินราคา ห้องขนาด 4 × 5 เมตรและราคาปูพื้น 300 ต่อหนึ่งตารางเมตรยังให้ผลลัพธ์ 6,000 เหมือนเดิม
#ข้อควรพิจารณา
การใช้ข้อมูลจากคลาสอื่นเพียงอย่างเดียวไม่เพียงพอที่จะตัดสิน เช่น Strategy อาจแยกกฎคำนวณออกมาเพื่อสลับอัลกอริทึมได้โดยตั้งใจ หากสูตรพื้นที่มีหลายแบบตามนโยบายภายนอก การแยกนโยบายอาจเหมาะสมกว่า [1]
#2. Inappropriate Intimacy: รู้รายละเอียดภายในของอีกคลาสมากเกินไป
#สังเกตจากอะไร
คลาสหนึ่งอ่านหรือแก้สถานะภายในของอีกคลาสโดยตรง และต้องรู้ว่าควรรักษากฎใดไว้บ้าง ทำให้เจ้าของข้อมูลควบคุมสถานะตัวเองได้ยาก
#ก่อนปรับโครงสร้าง
ใช้จำนวนเต็มหน่วยย่อยสำหรับยอดเงิน เช่น สตางค์ เพื่อให้ตัวอย่างไม่ต้องใช้ทศนิยม
class Wallet {
long balance;
Wallet(long balance) {
this.balance = balance;
}
}
class Checkout {
void pay(Wallet wallet, long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Invalid amount");
}
if (wallet.balance < amount) {
throw new IllegalStateException("Insufficient balance");
}
wallet.balance -= amount;
}
}
Checkout ต้องรู้ทั้งฟิลด์ยอดเงินและกฎการหักเงิน โค้ดอื่นในแพ็กเกจเดียวกันยังสามารถเปลี่ยน balance โดยข้ามกฎนี้ได้
#หลังปรับโครงสร้าง: Encapsulate Field และ Move Method
class Wallet {
private long balance;
Wallet(long balance) {
this.balance = balance;
}
void pay(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Invalid amount");
}
if (balance < amount) {
throw new IllegalStateException("Insufficient balance");
}
balance -= amount;
}
long balance() {
return balance;
}
}
class Checkout {
void pay(Wallet wallet, long amount) {
wallet.pay(amount);
}
}
เจ้าของยอดเงินเป็นผู้รักษากฎการจ่ายเงิน ผู้เรียกขอให้ทำงานผ่าน pay() แทนการแก้ฟิลด์โดยตรง การเพิ่ม getter/setter ที่เปิดให้แก้ยอดเงินอิสระจะยังเปิดช่องให้ข้ามกฎเดิม
ตัวอย่างนี้ย้ายกฎเดิมโดยไม่เพิ่มกฎใหม่ใน constructor และสมมติการเรียกแบบเธรดเดียว ระบบเงินจริงยังต้องออกแบบเรื่องธุรกรรมและการเข้าถึงพร้อมกันตามสถาปัตยกรรมของระบบ
#ต่างจาก Feature Envy อย่างไร
| ประเด็น | Feature Envy | Inappropriate Intimacy |
|---|---|---|
| จุดที่เน้น | พฤติกรรมควรอยู่ที่คลาสใด | ขอบเขตข้อมูลภายในถูกเปิดเผยเพียงใด |
| ใช้ public getter แล้วเกิดได้หรือไม่ | ได้ | ต้องพิจารณาว่า API เปิดเผยรายละเอียดภายในมากเพียงใด |
| ตัวอย่าง | คำนวณพื้นที่จากขนาดของห้องภายนอก Room |
เปลี่ยนยอดเงินใน Wallet โดยตรง |
สองกลิ่นนี้เกิดร่วมกันได้ หากคลาสหนึ่งทั้งดึงข้อมูลของอีกคลาสมาคำนวณและแก้สถานะภายในกลับไป [1, 2]
#3. Message Chains: ผู้เรียกรู้เส้นทางผ่านออบเจ็กต์หลายทอด
#สังเกตจากอะไร
ผู้เรียกต้องรู้ว่าต้องผ่านออบเจ็กต์ใดบ้างจึงจะได้ข้อมูลปลายทาง เช่น พนักงานมีแผนก แผนกมีหัวหน้า และหัวหน้ามีอีเมล
#ก่อนปรับโครงสร้าง
record Manager(String email) {}
record Department(Manager manager) {}
record Employee(Department department) {}
class ContactDirectory {
String managerEmail(Employee employee) {
return employee.department().manager().email();
}
}
ถ้าการจัดหัวหน้าเปลี่ยนเป็นอิงทีมแทนแผนก ผู้เรียกที่เดินตามเส้นทางเดิมทุกแห่งอาจต้องแก้ตาม แม้ความต้องการยังเป็น “ขออีเมลหัวหน้า” เหมือนเดิม
#หลังปรับโครงสร้าง: Hide Delegate
record Manager(String email) {}
record Department(Manager manager) {
String managerEmail() {
return manager.email();
}
}
record Employee(Department department) {
String managerEmail() {
return department.managerEmail();
}
}
class ContactDirectory {
String managerEmail(Employee employee) {
return employee.managerEmail();
}
}
ContactDirectory ใช้คำถามที่ตรงกับความต้องการ จึงไม่ต้องรู้เส้นทางภายใน ตัวอย่างสมมติว่าทุกความสัมพันธ์มีข้อมูลครบ หากระบบรองรับพนักงานที่ไม่มีหัวหน้า ต้องกำหนดพฤติกรรมกรณีนั้นแยกให้ชัดเจน
#การเรียกต่อกันทุกแบบเป็นปัญหาหรือไม่
ไม่ควรตัดสินด้วยจำนวนจุด ตัวอย่าง Stream ต่อไปนี้ประกอบขั้นตอนของ API เดียว แทนการไล่สำรวจโครงสร้างโดเมนลึกหลายชั้น
// ตัวอย่างส่วนหนึ่งของเมธอด โดย names เป็น List<String>
var cleaned = names.stream()
.map(String::trim)
.filter(name -> !name.isEmpty())
.toList();
ให้ดูว่าผู้เรียกพึ่งพาโครงสร้างความสัมพันธ์ที่เปลี่ยนได้หรือไม่ และการซ่อนเส้นทางช่วยลดงานแก้ไขจริงเพียงใด การสร้างเมธอดส่งต่อให้ทุกฟิลด์โดยอัตโนมัติอาจทำให้เกิด Middle Man [3]
#4. Middle Man: มีตัวกลางแต่ไม่มีหน้าที่ที่คุ้มค่า
#สังเกตจากอะไร
คลาสมีเมธอดจำนวนมากที่เพียงส่งต่อไปยังอีกคลาส โดยไม่ได้รักษาขอบเขต ควบคุมนโยบาย หรือมอบสัญญาที่มีความหมายต่อผู้เรียก
#ก่อนปรับโครงสร้าง
class Catalog {
String productName() {
return "Java Course";
}
}
class CatalogRelay {
private final Catalog catalog;
CatalogRelay(Catalog catalog) {
this.catalog = catalog;
}
String productName() {
return catalog.productName();
}
}
class ProductPage {
String title(CatalogRelay relay) {
return relay.productName();
}
}
สำหรับตัวอย่างนี้ สมมติว่า CatalogRelay ไม่มีบทบาทด้านสถาปัตยกรรมอื่น เมื่อเพิ่มความสามารถใน Catalog ก็ต้องเพิ่มเมธอดส่งต่อในตัวกลางไปด้วย
#หลังปรับโครงสร้าง: Remove Middle Man
class Catalog {
String productName() {
return "Java Course";
}
}
class ProductPage {
String title(Catalog catalog) {
return catalog.productName();
}
}
ผู้เรียกใช้ Catalog โดยตรง ลดคลาสและเมธอดที่ต้องดูแล โดยผลลัพธ์ชื่อสินค้ายังคงเดิม
#เมื่อใดควรเก็บตัวกลางไว้
ตัวกลางที่รักษาขอบเขต dependency หรือเป็น Proxy และ Decorator อาจมีเหตุผลชัดเจน [4] ตัวอย่างเช่น ตรวจสิทธิ์ก่อนอ่านข้อมูล แคชผลลัพธ์ หรือแปลง API ภายนอกให้เป็นสัญญาที่ระบบควบคุมได้
คลาสที่มีเพียงเมธอดเดียวจึงอาจมีคุณค่า และคลาสที่มีหลายสิบเมธอดก็อาจเป็น Middle Man ได้ ต้องดูหน้าที่และต้นทุนการดูแล
Checkout ในตัวอย่างก่อนหน้าก็ต้องใช้เกณฑ์เดียวกัน หากมันทำเพียงส่งต่อ wallet.pay() ตลอดไปโดยไม่มีขอบเขตที่ต้องรักษา ก็อาจพิจารณาตัดตัวกลางนั้นออกได้
#เลือกระหว่าง Hide Delegate กับ Remove Middle Man
สองเทคนิคนี้ปรับความสัมพันธ์คนละทิศทาง แต่ใช้ร่วมกันได้ตามขอบเขตที่เหมาะสม
| สถานการณ์ | สิ่งที่ควรพิจารณา |
|---|---|
| ผู้เรียกหลายแห่งต้องรู้เส้นทางออบเจ็กต์ภายใน | เพิ่มเมธอดที่สื่อความต้องการด้วย Hide Delegate |
| คลาสกลางต้องคัดลอก API ปลายทางแทบทั้งหมด | พิจารณา Remove Middle Man |
| ตัวกลางซ่อนระบบภายนอกหรือรักษาสัญญาที่คงที่ | เก็บขอบเขตนั้นไว้และทบทวน API ที่เปิดเผย |
| ต้องสร้างเมธอดส่งต่อจำนวนมากเพื่อทำงานหนึ่งเรื่อง | พิจารณาย้ายการทำงานไปยังคลาสที่เป็นเจ้าของข้อมูล |
ตัวอย่าง employee.managerEmail() เป็นคำถามระดับโดเมนที่จำกัดขอบเขตชัดเจน แต่หาก Employee ต้องคัดลอกทุกเมธอดของ Department มาเปิดซ้ำ ควรทบทวนการออกแบบอีกครั้ง
#ขั้นตอนนำไปใช้ในการ Code Review
- เลือกสถานการณ์เปลี่ยนแปลงจริง เช่น เปลี่ยนแหล่งข้อมูลหัวหน้า หรือเปลี่ยนวิธีเก็บยอดเงิน
- ระบุว่าผู้เรียกแต่ละแห่งจำเป็นต้องรู้ข้อมูลใด และกำลังรู้รายละเอียดเกินความต้องการหรือไม่
- ตรวจหลักฐานพฤติกรรมเดิมก่อนย้าย เช่น ค่าผลลัพธ์ ข้อยกเว้น และผลต่อสถานะ
- ปรับทีละส่วน ย้ายเมธอดหรือซ่อนเส้นทางเฉพาะที่มีเหตุผล
- ตรวจว่าพฤติกรรมเดิมยังอยู่ และการแก้กฎครั้งถัดไปแตะส่วนที่เกี่ยวข้องน้อยลงหรือไม่
#กรณีตรวจสอบสำหรับตัวอย่าง
| ตัวอย่าง | อินพุตหรือสถานการณ์ | ผลลัพธ์ที่ควรเหมือนเดิม |
|---|---|---|
| Feature Envy | ห้อง 4 × 5 ราคา 300 ต่อหน่วยพื้นที่ | 6,000 |
| Inappropriate Intimacy | ยอด 10,000 จ่าย 2,500 | ยอดคงเหลือ 7,500 |
| Inappropriate Intimacy | จ่าย 0 หรือค่าติดลบ | IllegalArgumentException และยอดเดิม |
| Inappropriate Intimacy | ยอด 1,000 จ่าย 1,001 | IllegalStateException และยอดเดิม |
| Message Chains | หัวหน้ามีอีเมล manager@example.com | คืนอีเมลเดิมก่อนและหลังย้าย |
| Middle Man | ขอชื่อสินค้า | Java Course |
ตารางนี้เป็นกรณีตรวจสอบที่แนะนำ ตัวอย่างในบทความยังไม่ได้ผ่านการคอมไพล์หรือรันทดสอบในสภาพแวดล้อม Java
#แบบฝึกหัดอภิปราย
- หากรายงานดึงข้อมูลจากหลายออบเจ็กต์เพื่อแสดงผล ควรย้ายทุกเมธอดเข้า domain object หรือไม่? อธิบายเจ้าของกฎและเหตุผลของขอบเขตงาน
- หากต้องขออีเมลหัวหน้าเพียงจุดเดียว แต่โครงสร้างองค์กรเปลี่ยนบ่อย การเพิ่ม
managerEmail()มีประโยชน์อย่างไร? - หาก
CatalogRelayตรวจสิทธิ์ก่อนส่งต่อ ยังควรลบหรือไม่? อธิบายผลต่อผู้เรียก - เปรียบเทียบการเพิ่ม setter ของยอดเงินกับเมธอด
pay()ว่าวิธีใดช่วยรักษากฎของWalletได้ชัดเจนกว่า
#ข้อคิดสำหรับการออกแบบ
วางพฤติกรรมใกล้ข้อมูลที่เกี่ยวข้อง ปกป้องกฎของเจ้าของสถานะ เปิด API ที่สื่อความต้องการของผู้เรียก และทบทวนตัวกลางตามหน้าที่จริง การลด coupling ที่มีประโยชน์ต้องช่วยให้เปลี่ยนระบบได้ง่ายขึ้นโดยยังเข้าใจเส้นทางการทำงานได้
#แหล่งอ้างอิง
- Refactoring.Guru — Feature Envy
- Refactoring.Guru — Inappropriate Intimacy
- Refactoring.Guru — Message Chains
- Refactoring.Guru — Middle Man