ออกแบบโครงสร้างแบบต้นไม้ เพื่อเรียกใช้งานวัตถุชิ้นเดียวและกลุ่มวัตถุผ่านชนิดข้อมูลร่วมกัน

info-composite-pattern-with-java

Composite Pattern เป็น Structural Design Pattern ที่ใช้จัดวัตถุเป็นโครงสร้างแบบต้นไม้ (Tree) เพื่อแทนความสัมพันธ์ระหว่างส่วนย่อยกับส่วนรวม (Part–Whole) โดยให้ทั้งวัตถุเดี่ยวและวัตถุที่รวมสมาชิกหลายชิ้นใช้สัญญาการทำงานร่วมกัน

ตัวอย่างที่เข้าใจได้ง่ายคือระบบไฟล์: ไฟล์ มีขนาดของตัวเอง ส่วน โฟลเดอร์ มีไฟล์หรือโฟลเดอร์ย่อย และคำนวณขนาดจากสมาชิกทั้งหมด ผู้เรียกสามารถใช้ sizeInBytes() ได้เหมือนกัน

#1. เป้าหมายการเรียนรู้

เมื่อจบบทความ ผู้อ่านจะสามารถ:

  • อธิบายบทบาท Component, Leaf, Composite และ Client
  • ออกแบบโครงสร้างที่ซ้อนกันได้หลายระดับด้วย Java
  • ใช้ Polymorphism ร่วมกับ Recursion โดยไม่ตรวจชนิดวัตถุใน Client
  • เลือกตำแหน่งของเมธอดจัดการสมาชิกให้เหมาะกับงาน
  • ป้องกันวงจรและการใช้โหนดร่วมกันเมื่อโดเมนต้องเป็นต้นไม้

#2. ปัญหาที่ Composite Pattern ช่วยแก้

สมมติว่าโปรแกรมมีทั้งไฟล์และโฟลเดอร์ หากไม่มีชนิดข้อมูลร่วมกัน ผู้พัฒนาอาจแยกรายการไฟล์กับรายการโฟลเดอร์ หรือเขียนเงื่อนไขตรวจชนิดซ้ำในหลายจุด เช่น การหาขนาด การแสดงรายการ และการส่งออกรายงาน

เมื่อเพิ่มโฟลเดอร์ซ้อนหลายระดับ โปรแกรมยังต้องเดินสมาชิกต่อไปเรื่อย ๆ หาก Client รับผิดชอบรายละเอียดเหล่านี้เอง ความรู้เกี่ยวกับโครงสร้างจะกระจายไปหลายส่วน

Composite Pattern ย้ายความรับผิดชอบของการจัดการกลุ่มไปไว้ที่ Composite และให้ Client ทำงานผ่าน Component

สิ่งที่ต้องการ แนวทางในตัวอย่าง
เรียกขนาดของไฟล์และโฟลเดอร์ด้วยเมธอดเดียวกัน ประกาศ sizeInBytes() ใน FsNode
โฟลเดอร์เก็บได้ทั้งไฟล์และโฟลเดอร์ ใช้ List<FsNode>
รองรับโฟลเดอร์หลายระดับ ให้โฟลเดอร์เรียกการทำงานของลูกแบบ Recursive
Client ไม่ต้องรู้ชนิดจริง รับพารามิเตอร์เป็น FsNode

จุดสำคัญ: การมี List อยู่ในคลาสเพียงอย่างเดียวยังไม่เพียงพอที่จะเป็น Composite Pattern กลุ่มและสมาชิกต้องใช้งานผ่าน Component ร่วมกัน และกลุ่มสามารถมีสมาชิกชนิดเดียวกับตัวมันเองได้ผ่าน Component นั้น

#3. องค์ประกอบของแพตเทิร์น

บทบาท หน้าที่ คลาสในตัวอย่าง
Component กำหนดการทำงานร่วมกันของทุกโหนด FsNode
Leaf วัตถุปลายทาง ไม่มีลูก FileNode
Composite เก็บลูกและประสานการทำงานของลูก FolderNode
Client เรียกใช้งานผ่าน Component showSummary(FsNode node)

Component จะเป็น interface หรือ abstract class ก็ได้ ตัวอย่างนี้เลือก abstract class เพื่อเก็บชื่อและข้อมูลโฟลเดอร์แม่ร่วมกัน

classDiagram
    class FsNode {
        <<abstract>>
        +getName() String
        +sizeInBytes() long
        +print(indent) void
    }
    class FileNode {
        -bytes : long
        +sizeInBytes() long
        +print(indent) void
    }
    class FolderNode {
        -children : List~FsNode~
        +add(child) void
        +remove(child) boolean
        +sizeInBytes() long
        +print(indent) void
    }
    FsNode <|-- FileNode
    FsNode <|-- FolderNode
    FolderNode "0..1" *-- "0..*" FsNode : children

FolderNode เก็บสมาชิกเป็น FsNode จึงเก็บได้ทั้ง FileNode และ FolderNode โดยไม่ต้องแยกคอลเลกชันตามชนิดจริง ส่วนจำนวนแม่ไม่เกินหนึ่งตัวในแผนภาพเป็นข้อกำหนดของตัวอย่างนี้

#4. สถานการณ์ตัวอย่าง: โครงการที่มีไฟล์และโฟลเดอร์

เราจะสร้างโฟลเดอร์ project ซึ่งมีไฟล์ README.md และโฟลเดอร์ย่อย src กับ docs

flowchart TD
    P["project: FolderNode"] --> R["README.md: 100 bytes"]
    P --> S["src: FolderNode"]
    P --> D["docs: FolderNode"]
    S --> M["Main.java: 300 bytes"]
    S --> U["Utils.java: 200 bytes"]
    D --> G["guide.md: 400 bytes"]

กติกาของแบบจำลอง:

  • ไฟล์คืนขนาดที่กำหนดไว้เป็นจำนวนไบต์
  • โฟลเดอร์คืนผลรวมขนาดของสมาชิกทุกระดับ
  • โฟลเดอร์ว่างมีขนาดรวมเท่ากับ 0
  • ขนาดรวมไม่รวมพื้นที่ metadata ของโฟลเดอร์
  • โหนดหนึ่งมีโฟลเดอร์แม่ได้ไม่เกินหนึ่งตัว และไม่อนุญาตวงจร

นี่เป็นแบบจำลองเพื่อเรียนรู้แพตเทิร์น ไม่ได้จำลองพฤติกรรมระบบไฟล์จริงทั้งหมด เช่น symbolic link, hard link หรือพื้นที่จัดเก็บที่ใช้จริง

#5. โค้ด Java ฉบับสมบูรณ์

สร้างไฟล์ชื่อ CompositeDemo.java แล้ววางโค้ดต่อไปนี้ คลาสย่อยอยู่ภายในคลาสหลักเพื่อให้ทดลองได้ด้วยไฟล์เดียว

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class CompositeDemo {
    // Component: operations shared by a file and a folder.
    static abstract class FsNode {
        private final String name;
        private FolderNode parent;

        protected FsNode(String name) {
            this.name = Objects.requireNonNull(name, "name");
            if (name.isBlank()) {
                throw new IllegalArgumentException("Name must not be blank");
            }
        }

        public final String getName() {
            return name;
        }

        public abstract long sizeInBytes();
        public abstract void print(String indent);
    }

    // Leaf: an individual item without children.
    static final class FileNode extends FsNode {
        private final long bytes;

        public FileNode(String name, long bytes) {
            super(name);
            if (bytes < 0) {
                throw new IllegalArgumentException("Size must not be negative");
            }
            this.bytes = bytes;
        }

        @Override
        public long sizeInBytes() {
            return bytes;
        }

        @Override
        public void print(String indent) {
            System.out.printf("%s- %s (%d bytes)%n",
                    indent, getName(), bytes);
        }
    }

    // Composite: stores children using the shared Component type.
    static final class FolderNode extends FsNode {
        private final List<FsNode> children = new ArrayList<>();

        public FolderNode(String name) {
            super(name);
        }

        public void add(FsNode child) {
            Objects.requireNonNull(child, "child");

            // An ancestor cannot become a child: that would create a cycle.
            for (FsNode current = this; current != null;
                    current = current.parent) {
                if (current == child) {
                    throw new IllegalArgumentException("Cycle is not allowed");
                }
            }

            // Keep a tree: each node has at most one parent.
            if (child.parent != null) {
                throw new IllegalStateException("Node already has a parent");
            }

            children.add(child);
            child.parent = this;
        }

        public boolean remove(FsNode child) {
            if (children.remove(child)) {
                child.parent = null;
                return true;
            }
            return false;
        }

        @Override
        public long sizeInBytes() {
            long total = 0;
            for (FsNode child : children) {
                total = Math.addExact(total, child.sizeInBytes());
            }
            return total;
        }

        @Override
        public void print(String indent) {
            System.out.printf("%s+ %s/%n", indent, getName());
            for (FsNode child : children) {
                child.print(indent + "  ");
            }
        }
    }

    // Client: accepts the Component type without inspecting concrete classes.
    static void showSummary(FsNode node) {
        System.out.printf("%s = %d bytes%n",
                node.getName(), node.sizeInBytes());
    }

    public static void main(String[] args) {
        FolderNode project = new FolderNode("project");
        FolderNode src = new FolderNode("src");
        FolderNode docs = new FolderNode("docs");
        FileNode readme = new FileNode("README.md", 100);

        src.add(new FileNode("Main.java", 300));
        src.add(new FileNode("Utils.java", 200));
        docs.add(new FileNode("guide.md", 400));

        project.add(readme);
        project.add(src);
        project.add(docs);

        project.print("");
        System.out.println();
        showSummary(readme);
        showSummary(src);
        showSummary(project);
    }
}

#วิธีรัน

ตรวจสอบว่าเครื่องติดตั้ง JDK แล้ว:

java -version
javac -version

คอมไพล์และรันจากโฟลเดอร์ที่เก็บไฟล์:

javac -encoding UTF-8 CompositeDemo.java
java CompositeDemo

หรือใช้การรันไฟล์ซอร์สโดยตรง:

java CompositeDemo.java

#ผลลัพธ์ที่คาดหวัง

+ project/
  - README.md (100 bytes)
  + src/
    - Main.java (300 bytes)
    - Utils.java (200 bytes)
  + docs/
    - guide.md (400 bytes)

README.md = 100 bytes
src = 500 bytes
project = 1000 bytes

#6. อธิบายการทำงานของโค้ด

#6.1 Component: FsNode

FsNode กำหนดเมธอดที่ใช้ร่วมกัน คือ getName(), sizeInBytes() และ print() ส่วน name และ parent เป็นข้อมูลของโหนด

ตัวแปร parent ใช้รักษาเงื่อนไขของต้นไม้และตรวจวงจร ไม่ใช่ส่วนที่ทุก Composite Pattern ต้องมี หากโดเมนอนุญาตให้ใช้สมาชิกซ้ำในหลายกลุ่ม อาจออกแบบแตกต่างออกไป

#6.2 Leaf: FileNode

ไฟล์ไม่มีสมาชิกภายใน จึงคืนค่า bytes ได้ทันที:

@Override
public long sizeInBytes() {
    return bytes;
}

คลาสนี้ไม่ต้องรู้จักโฟลเดอร์อื่น และไม่ต้องมีเมธอด add() หรือ remove()

#6.3 Composite: FolderNode

ส่วนที่เป็นหัวใจของแพตเทิร์นคือการเก็บสมาชิกด้วยชนิดข้อมูลร่วมกัน:

private final List<FsNode> children = new ArrayList<>();

และเรียกเมธอดของสมาชิกโดยไม่ตรวจว่าเป็นไฟล์หรือโฟลเดอร์:

for (FsNode child : children) {
    total = Math.addExact(total, child.sizeInBytes());
}

ถ้า child เป็นไฟล์ Java จะเรียกการทำงานของ FileNode ถ้าเป็นโฟลเดอร์ Java จะเรียกการทำงานของ FolderNode ซึ่งเดินลูกของตัวเองต่อไป นี่คือการใช้ Polymorphism ร่วมกับ Recursion

Math.addExact() ทำให้การรวมค่าที่เกินขอบเขต long เกิด ArithmeticException แทนการล้นแล้วได้ค่าที่ผิดโดยเงียบ ๆ

#6.4 Client: showSummary()

static void showSummary(FsNode node) {
    System.out.printf("%s = %d bytes%n",
            node.getName(), node.sizeInBytes());
}

เมธอดนี้รับได้ทั้งไฟล์ โฟลเดอร์ย่อย และโฟลเดอร์ราก โดยไม่ใช้ instanceof หรือแปลงชนิดข้อมูล

ผู้ประกอบโครงสร้างใน main() ยังต้องรู้ว่าโฟลเดอร์มี add() แต่ส่วนที่อ่านข้อมูลรวมสามารถทำงานผ่าน FsNode ได้ทั้งหมด

#7. การคำนวณแบบ Recursive เกิดขึ้นอย่างไร

เมื่อเรียก project.sizeInBytes() การคำนวณเกิดตามสมาชิกที่พบ:

  1. README.md คืนค่า 100
  2. src รวม Main.java และ Utils.java ได้ 300 + 200 = 500
  3. docs รวม guide.md ได้ 400
  4. project รวมผลลัพธ์ของลูกโดยตรง ได้ 100 + 500 + 400 = 1000

แต่ละโฟลเดอร์รู้เฉพาะลูกโดยตรงของตัวเอง ส่วนการเดินลงไปยังระดับลึกเกิดจากการเรียกเมธอดของโฟลเดอร์ลูก

การเรียกเมธอดเหมือนกันไม่ได้หมายความว่าการทำงานภายในเหมือนกัน: Leaf ทำงานกับตัวเอง ส่วน Composite ประสานการทำงานของสมาชิก

#8. เหตุใดต้องป้องกันวงจรและโหนดที่มีแม่หลายตัว

ตัวอย่างกำหนดให้ข้อมูลเป็นต้นไม้ จึงตรวจเงื่อนไขเมื่อเพิ่มลูก แทนที่จะรอให้การคำนวณพบปัญหา

#เพิ่มตัวเองเป็นลูก

project.add(project); // IllegalArgumentException

หากอนุญาต เมื่อเรียกหาขนาด โฟลเดอร์จะเรียกตัวเองซ้ำไม่สิ้นสุด

#เพิ่มบรรพบุรุษเป็นลูก

หลัง project.add(src) แล้ว คำสั่งนี้ต้องถูกปฏิเสธ:

src.add(project); // IllegalArgumentException

โค้ดจึงเดินจากโฟลเดอร์ปัจจุบันขึ้นไปตาม parent หากพบโหนดที่จะเพิ่ม แสดงว่าจะเกิดวงจร

#เพิ่มโหนดเดิมซ้ำหรือย้ายโดยไม่ถอดออกก่อน

docs.add(readme); // IllegalStateException: readme belongs to project

ถ้าต้องการย้ายไฟล์ ให้ถอดจากแม่เดิมก่อน:

project.remove(readme);
docs.add(readme);

เมื่อย้ายภายใน project ขนาดรวมของ project ยังเป็น 1000 ไบต์ แต่ขนาดของ docs เปลี่ยนจาก 400 เป็น 500 ไบต์

กติกาแม่ตัวเดียวเป็นการตัดสินใจของโดเมนนี้ Composite บางแบบอนุญาตให้หลายกลุ่มอ้างถึงสมาชิกเดียวกันได้ ซึ่งต้องระบุความหมายของการนับซ้ำและจัดการวงจรเพิ่มเติม

#9. ควรวาง add() และ remove() ไว้ที่ไหน

มีแนวทางหลักสองแบบ:

แนวทาง วางเมธอดจัดการลูกที่ใด ข้อดี สิ่งที่ต้องแลก
Transparent Composite Component Client เห็น API จัดการลูกเหมือนกันทุกโหนด Leaf ต้องปฏิเสธหรือกำหนดพฤติกรรมสำหรับเมธอดที่ใช้ไม่ได้
Safe Composite Composite เท่านั้น เพิ่มลูกให้ Leaf ไม่ได้ตั้งแต่ระดับชนิดข้อมูล ผู้ประกอบโครงสร้างต้องรู้ว่าโหนดใดเป็น Composite

บทความนี้ใช้ Safe Composite: มีเพียง FolderNode ที่มี add() และ remove() ส่วนการหาขนาดและแสดงรายการยังใช้ร่วมกันผ่าน FsNode

อย่าเพิ่มเมธอดลงใน Component เพียงเพื่อให้ทุกคลาสมีเมธอดจำนวนเท่ากัน ควรเลือกเฉพาะการทำงานที่มีความหมายร่วมกันจริง

#10. เวิร์กช็อป: ขยายตัวอย่างทีละขั้น

#กิจกรรมที่ 1: เพิ่มโฟลเดอร์ว่าง

เพิ่มก่อนคำสั่งแสดงผลใน main():

FolderNode assets = new FolderNode("assets");
project.add(assets);
showSummary(assets);

ผลที่ควรได้คือ assets = 0 bytes และขนาดรวมของ project ยังเท่ากับ 1000 ไบต์

#กิจกรรมที่ 2: เพิ่มระดับความลึก

ต่อจากกิจกรรมที่ 1:

FolderNode images = new FolderNode("images");
images.add(new FileNode("logo.png", 250));
assets.add(images);
showSummary(project);

ขนาดรวมควรเป็น 1250 ไบต์ โดยไม่ต้องแก้ showSummary() หรือโค้ดรวมขนาดเดิม

#กิจกรรมที่ 3: เพิ่มการนับจำนวนไฟล์

เพิ่มเมธอดนี้ใน FsNode:

public abstract long fileCount();

ใน FileNode ให้คืนค่า 1 ส่วน FolderNode รวมค่า fileCount() จากลูกทุกตัว โฟลเดอร์ว่างต้องคืนค่า 0

โจทย์นี้แสดงอีกด้านของแพตเทิร์น: การเพิ่มชนิด Leaf ใหม่อาจทำได้โดยไม่แก้ Client แต่การเพิ่ม operation ใหม่ลงใน Component ต้องปรับคลาสที่เกี่ยวข้องด้วย

#ค่าที่ใช้ตรวจงาน

กรณี ผลที่ควรได้
ตัวอย่างเริ่มต้น: project.sizeInBytes() 1000
โฟลเดอร์ว่าง: assets.sizeInBytes() 0
หลังเพิ่ม logo.png ขนาด 250 ไบต์ project เท่ากับ 1250
จำนวนไฟล์ในตัวอย่างเริ่มต้น 4
จำนวนไฟล์หลังเพิ่ม logo.png 5
เพิ่มโฟลเดอร์ให้เป็นลูกของตัวเอง ปฏิเสธด้วย IllegalArgumentException
เพิ่มบรรพบุรุษเป็นลูก ปฏิเสธด้วย IllegalArgumentException
เพิ่มโหนดที่มีแม่อยู่แล้ว ปฏิเสธด้วย IllegalStateException

#11. ข้อดีและข้อจำกัด

#ข้อดี

  • Client เรียกการทำงานร่วมกันได้โดยไม่แยกชนิดไฟล์กับโฟลเดอร์
  • สร้างโครงสร้างที่ซ้อนกันหลายระดับได้ด้วยกติกาเดียว
  • ความรับผิดชอบในการรวมผลอยู่ใกล้กับกลุ่มที่เป็นเจ้าของสมาชิก
  • เพิ่ม Leaf ชนิดใหม่ได้สะดวกเมื่อ operation ที่มีอยู่รองรับพฤติกรรมของชนิดนั้น

#ข้อจำกัดที่ควรออกแบบต่อเมื่อใช้งานจริง

  • Operation ใหม่: การเพิ่มเมธอดใน Component อาจกระทบ Leaf และ Composite หลายคลาส หากเพิ่มบ่อยอาจพิจารณา Visitor ตามบริบท
  • ต้นไม้ลึกมาก: การเรียก Recursive อาจทำให้ stack เต็ม ควรพิจารณาเดินโครงสร้างด้วย explicit stack
  • การแก้ข้อมูลพร้อมกัน: ตัวอย่างไม่ได้ออกแบบให้ thread-safe ต้องกำหนดวิธีประสานการอ่านและการแก้ไขหากใช้หลายเธรด
  • การแคชผลรวม: ช่วยลดการเดินซ้ำได้ แต่ต้องปรับหรือยกเลิกแคชเมื่อสมาชิกเปลี่ยน รวมถึงส่งผลขึ้นไปยังบรรพบุรุษ
  • กฎของสมาชิก: หากบางกลุ่มรับสมาชิกได้เฉพาะบางชนิด ต้องออกแบบข้อจำกัดเพิ่ม การมี Component ร่วมกันไม่ได้บังคับกฎโดเมนให้เอง

#ต้นทุนการทำงานในตัวอย่าง

กำหนดให้ N คือจำนวนโหนดในต้นไม้ย่อยที่กำลังอ่าน และ H คือความสูงหรือจำนวนระดับตามเส้นทางที่เกี่ยวข้อง

การทำงาน ต้นทุน
FileNode.sizeInBytes() เวลา O(1)
FolderNode.sizeInBytes() เวลา O(N) และ call stack O(H)
FolderNode.add() ตรวจบรรพบุรุษ O(H) และเพิ่มใน ArrayList แบบ amortized O(1)
FolderNode.remove() เวลา O(k) เมื่อมีลูกโดยตรง k ตัว

print() เยี่ยมชมทุกโหนดหนึ่งครั้ง แต่เวลาจริงยังขึ้นกับความยาวข้อความ ระยะเยื้อง และการเขียนลงปลายทางด้วย

#12. เปรียบเทียบกับแพตเทิร์นที่ใกล้เคียง

แพตเทิร์น จุดประสงค์หลัก คำถามที่ช่วยแยก
Composite รวมชิ้นส่วนและกลุ่มให้ทำงานผ่านสัญญาร่วมกัน ต้องมีองค์ประกอบที่ซ้อนเป็นส่วนรวมหลายระดับหรือไม่?
Decorator ห่อวัตถุเพื่อเพิ่มพฤติกรรม ต้องเพิ่มความสามารถให้วัตถุผ่านตัวห่อหรือไม่?
Adapter ทำให้ API ที่ต่างกันใช้งานร่วมกันได้ ต้องแปลงอินเทอร์เฟซที่ไม่ตรงกันหรือไม่?
Visitor แยก operation ออกจากโครงสร้างวัตถุ มี operation ใหม่เพิ่มบ่อยบนชนิดโหนดที่ค่อนข้างคงที่หรือไม่?

Composite และ Decorator อาจมีโครงสร้างคลาสบางส่วนคล้ายกัน แต่ควรแยกจากเจตนา: ตัวอย่างนี้รวมสมาชิกหลายตัวเพื่อเป็นส่วนรวม ส่วน Decorator เน้นการเพิ่มพฤติกรรมผ่านการห่อวัตถุ

#13. แนวคิดลักษณะเดียวกันใน Java API

เอกสาร Java AWT ระบุว่า java.awt.Container สืบทอดจาก java.awt.Component และเก็บ Component อื่นได้ การที่ Container เองก็เป็น Component ทำให้เกิดโครงสร้าง UI ที่ซ้อนกันหลายระดับได้

เราจึงใช้ความสัมพันธ์นี้เป็นตัวอย่างเปรียบเทียบแนวคิด Composite ได้: Component เป็นชนิดร่วม และ Container เป็นองค์ประกอบที่บรรจุสมาชิก โดย API จริงยังมีรายละเอียดด้าน layout, event และ lifecycle อีกมาก

ตัวอย่างในบทความเป็นโมเดลที่เขียนขึ้นเพื่อสาธิต ไม่ได้เรียกใช้ AWT

#14. คำถามทบทวน

  1. เพราะเหตุใด FolderNode จึงเก็บ List<FsNode> แทน List<FileNode>?
  2. เหตุใด showSummary() จึงไม่ต้องตรวจชนิดข้อมูลด้วย instanceof?
  3. จะเกิดอะไรขึ้นถ้าโฟลเดอร์สามารถเก็บตัวเองเป็นลูกได้?
  4. Safe Composite และ Transparent Composite แลกความสะดวกกับความปลอดภัยของชนิดข้อมูลอย่างไร?
  5. หากต้องการเพิ่ม fileCount() ต้องแก้ส่วนใดบ้าง และแตกต่างจากการเพิ่ม Leaf ใหม่อย่างไร?

#15. แหล่งอ้างอิงและอ่านเพิ่มเติม

  • Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. บท Structural Patterns: Composite — แหล่งอ้างอิงพื้นฐานของแพตเทิร์น
  • Oracle. Class Container — Java SE 21 API — ความสัมพันธ์ระหว่าง Container, Component และสมาชิกภายใน
  • Oracle. Class Component — Java SE 21 API — API ขององค์ประกอบ UI ที่ใช้อธิบายแนวคิดเปรียบเทียบ