อธิบาย 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

  1. เลือกสถานการณ์เปลี่ยนแปลงจริง เช่น เปลี่ยนแหล่งข้อมูลหัวหน้า หรือเปลี่ยนวิธีเก็บยอดเงิน
  2. ระบุว่าผู้เรียกแต่ละแห่งจำเป็นต้องรู้ข้อมูลใด และกำลังรู้รายละเอียดเกินความต้องการหรือไม่
  3. ตรวจหลักฐานพฤติกรรมเดิมก่อนย้าย เช่น ค่าผลลัพธ์ ข้อยกเว้น และผลต่อสถานะ
  4. ปรับทีละส่วน ย้ายเมธอดหรือซ่อนเส้นทางเฉพาะที่มีเหตุผล
  5. ตรวจว่าพฤติกรรมเดิมยังอยู่ และการแก้กฎครั้งถัดไปแตะส่วนที่เกี่ยวข้องน้อยลงหรือไม่

#กรณีตรวจสอบสำหรับตัวอย่าง

ตัวอย่าง อินพุตหรือสถานการณ์ ผลลัพธ์ที่ควรเหมือนเดิม
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 ที่มีประโยชน์ต้องช่วยให้เปลี่ยนระบบได้ง่ายขึ้นโดยยังเข้าใจเส้นทางการทำงานได้

#แหล่งอ้างอิง

  1. Refactoring.Guru — Feature Envy
  2. Refactoring.Guru — Inappropriate Intimacy
  3. Refactoring.Guru — Message Chains
  4. Refactoring.Guru — Middle Man