เดินผ่านสมาชิกของกลุ่มข้อมูลทีละรายการ โดยให้โค้ดผู้ใช้ไม่ต้องรู้รายละเอียดการจัดเก็บภายใน

info-iterator-pattern-with-java

หมวดหมู่: Behavioral Design Pattern

#1. Iterator Pattern คืออะไร

Iterator Pattern เป็นรูปแบบการออกแบบที่แยก การเก็บข้อมูล ออกจาก การเดินผ่านข้อมูล โครงสร้างข้อมูลรับผิดชอบสมาชิกที่จัดเก็บ ส่วน Iterator รับผิดชอบสถานะและลำดับการเดินในแต่ละรอบ

ลองนึกถึงชั้นหนังสือ ผู้ใช้ต้องการอ่านชื่อหนังสือทีละเล่ม โดยไม่จำเป็นต้องทราบว่าระบบเก็บหนังสือไว้ในอาร์เรย์ ลิงก์ลิสต์ หรือโครงสร้างต้นไม้

แนวคิดหลักมีสองส่วน:

  • กลุ่มข้อมูล: ให้ทางเข้าสำหรับขอตัวเดินข้อมูล
  • ตัวเดินข้อมูล: จำตำแหน่งปัจจุบัน ตรวจว่ามีรายการต่อไปหรือไม่ และคืนรายการถัดไป

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

#2. ปัญหาที่ต้องการแก้

สมมติว่าโค้ดผู้ใช้เข้าถึงอาร์เรย์ภายในโดยตรง:

// แนวคิดก่อนปรับโครงสร้าง ไม่ใช่โค้ดจาก BookShelf ในตัวอย่างหลัก
Book[] books = shelf.getBooks();
for (int i = 0; i < books.length; i++) {
    System.out.println(books[i].title());
}

โค้ดนี้ผูกกับอาร์เรย์ และอาจเปิดโอกาสให้ผู้ใช้แก้ข้อมูลภายในผ่านอาร์เรย์ที่ได้รับ หากเปลี่ยนวิธีจัดเก็บ โค้ดผู้ใช้ก็อาจต้องเปลี่ยนตาม

เมื่อมี Iterator ผู้ใช้สื่อสารผ่านสัญญาการเดินข้อมูล:

Iterator<Book> iterator = shelf.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next().title());
}

ตัว Iterator ต้องรู้โครงสร้างภายในเพื่อเดินข้อมูล แต่ผู้ใช้ไม่ต้องรู้รายละเอียดนั้น ทั้งนี้ for แบบใช้ดัชนียังเหมาะเมื่อจำเป็นต้องใช้ดัชนีหรือเข้าถึงตำแหน่งแบบสุ่ม

#3. องค์ประกอบของ Pattern ใน Java

บทบาทใน Pattern ตัวแทนในตัวอย่าง หน้าที่
Aggregate Iterable<Book> กำหนดทางเข้าสำหรับขอ Iterator
Concrete Aggregate BookShelf เก็บหนังสือและสร้างตัวเดินข้อมูล
Iterator Iterator<Book> กำหนดสัญญาการเดินข้อมูล
Concrete Iterator BookIterator จำตำแหน่งและเดินอาร์เรย์หนังสือ
Client IteratorDemo ขอ Iterator และอ่านข้อมูลผ่านสัญญา

แผนภาพต่อไปนี้แสดงความสัมพันธ์ของตัวอย่าง โดยย่อชนิดสมาชิกให้เป็น Book เพื่อให้อ่านง่าย:

classDiagram
    direction TB
    class IterableBook {
        <<interface>>
        +iterator() IteratorBook
    }
    class IteratorBook {
        <<interface>>
        +hasNext() boolean
        +next() Book
    }
    class BookShelf {
        -Book[] books
        +iterator() IteratorBook
    }
    class BookIterator {
        -int index
        +hasNext() boolean
        +next() Book
    }
    class IteratorDemo

    IterableBook <|.. BookShelf
    IteratorBook <|.. BookIterator
    BookShelf ..> BookIterator : creates
    BookIterator --> BookShelf : reads books
    IteratorDemo ..> IterableBook : requests iterator
    IteratorDemo ..> IteratorBook : traverses

ในโค้ดจริง IterableBook และ IteratorBook ในแผนภาพหมายถึง Iterable<Book> และ Iterator<Book> ของ Java ตามลำดับ ไม่ต้องสร้างอินเทอร์เฟซใหม่

#4. แยก Iterable กับ Iterator ให้ชัด

ประเด็น Iterable<T> Iterator<T>
แนวคิด แหล่งที่ขอตัวเดินข้อมูลได้ ตัวเดินข้อมูลหนึ่งชุดที่มีสถานะ
เมธอดหลัก iterator() hasNext() และ next()
ตำแหน่งการเดิน ตัวอย่างนี้ไม่เก็บตำแหน่งไว้ที่แหล่งข้อมูล เก็บใน Iterator แต่ละตัว
ใช้กับ enhanced for ใช้ได้ การ implement เพียง Iterator ยังไม่เพียงพอ
เริ่มรอบใหม่ ตัวอย่างนี้เรียก iterator() เพื่อได้ตัวใหม่ ไม่มี reset() ในอินเทอร์เฟซมาตรฐาน

Iterable ช่วยให้ออบเจ็กต์ใช้กับ for-each ได้ ส่วนอาร์เรย์ใช้กับ for-each ได้เช่นกันตามกฎของภาษา แม้อาร์เรย์ไม่ได้ implement Iterable [2][4]

การคืน Iterator ใหม่ทุกครั้งเป็นนโยบายของ BookShelf ในบทความนี้ ไม่ใช่ข้อรับประกันว่าทุก implementation ของ Iterable ต้องอ่านซ้ำได้

#5. เวิร์กช็อป: สร้างตัวเดินชั้นหนังสือ

#5.1 ขอบเขตของตัวอย่าง

  • ชั้นหนังสือเก็บข้อมูลในอาร์เรย์ส่วนตัว
  • คัดลอกอาร์เรย์ที่รับเข้าใน constructor เพื่อแยกจากอาร์เรย์ของผู้เรียก
  • สมาชิกและลำดับในชั้นหนังสือเปลี่ยนไม่ได้หลังสร้าง
  • ใช้ record Book ที่มีชื่อเป็น String เพื่อให้ข้อมูลหนังสือในตัวอย่างเปลี่ยนไม่ได้เช่นกัน
  • ทุกการเรียก iterator() ได้ตัวใหม่ที่เริ่มจากตำแหน่งศูนย์
  • ไม่รองรับการลบสมาชิกผ่าน Iterator

การคัดลอกอาร์เรย์เป็น shallow copy: คัดลอกช่องเก็บ reference ไม่ได้สร้าง Book ใหม่ทุกเล่ม แต่เพียงพอสำหรับตัวอย่างนี้เพราะข้อมูลของ Book เปลี่ยนไม่ได้ หากสมาชิกเป็นออบเจ็กต์ที่แก้ค่าได้ ต้องกำหนดนโยบายการเปลี่ยนข้อมูลเพิ่มเติม

#5.2 โค้ดครบในไฟล์เดียว

สร้างไฟล์ชื่อ IteratorDemo.java แล้ววางโค้ดต่อไปนี้:

import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;

record Book(String title) {
    Book {
        Objects.requireNonNull(title, "title must not be null");
    }
}

final class BookShelf implements Iterable<Book> {
    private final Book[] books;

    BookShelf(Book... books) {
        Objects.requireNonNull(books, "books must not be null");
        this.books = books.clone();
        for (Book book : this.books) {
            Objects.requireNonNull(book, "book must not be null");
        }
    }

    @Override
    public Iterator<Book> iterator() {
        return new BookIterator();
    }

    private final class BookIterator implements Iterator<Book> {
        private int index = 0;

        @Override
        public boolean hasNext() {
            return index < books.length;
        }

        @Override
        public Book next() {
            if (!hasNext()) {
                throw new NoSuchElementException("No more books");
            }
            return books[index++];
        }
    }
}

public class IteratorDemo {
    public static void main(String[] args) {
        BookShelf shelf = new BookShelf(
            new Book("Effective Java"),
            new Book("Clean Code"),
            new Book("Design Patterns")
        );

        System.out.println("Explicit iterator:");
        Iterator<Book> iterator = shelf.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next().title());
        }

        System.out.println("Enhanced for:");
        for (Book book : shelf) {
            System.out.println(book.title());
        }

        System.out.println("Independent cursors:");
        Iterator<Book> left = shelf.iterator();
        Iterator<Book> right = shelf.iterator();
        System.out.println("left: " + left.next().title());
        System.out.println("left: " + left.next().title());
        System.out.println("right: " + right.next().title());

        System.out.println("Exhausted iterator:");
        try {
            iterator.next();
        } catch (NoSuchElementException ex) {
            System.out.println(ex.getClass().getSimpleName());
        }
    }
}

BookIterator เป็น inner class จึงอ่านอาร์เรย์ส่วนตัวของ BookShelf ได้ โดยไม่ต้องเปิดเผยอาร์เรย์ผ่านเมธอดสาธารณะ

#5.3 คอมไพล์และรัน

ตรวจว่าติดตั้ง JDK แล้ว จากนั้นเปิด Terminal หรือ Command Prompt ที่โฟลเดอร์ของไฟล์:

javac -version
javac IteratorDemo.java
java IteratorDemo

โค้ดใช้ record ซึ่งใช้ได้ใน JDK 17 ตามข้อกำหนดของเวิร์กช็อป ไม่ต้องใช้ Maven, Gradle หรือ dependency เพิ่มเติม

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

Explicit iterator:
Effective Java
Clean Code
Design Patterns
Enhanced for:
Effective Java
Clean Code
Design Patterns
Independent cursors:
left: Effective Java
left: Clean Code
right: Effective Java
Exhausted iterator:
NoSuchElementException

right ยังอ่านหนังสือเล่มแรก แม้ left อ่านไปสองเล่มแล้ว เพราะแต่ละตัวมี index ของตนเอง ส่วน iterator ตัวแรกอ่านครบตั้งแต่ช่วงแรก จึงเกิดข้อยกเว้นเมื่อขอรายการเพิ่ม

#6. ติดตามสถานะการเดินทีละขั้น

ตารางนี้อ้างถึง Iterator ตัวหนึ่งที่เพิ่งสร้างและมีหนังสือสามเล่ม:

คำสั่ง index ก่อน ผลลัพธ์ index หลัง
hasNext() 0 true 0
hasNext() ซ้ำ 0 true 0
next() 0 Effective Java 1
next() 1 Clean Code 2
next() 2 Design Patterns 3
hasNext() 3 false 3
next() 3 NoSuchElementException 3

ใน implementation นี้ hasNext() ตรวจเงื่อนไขอย่างเดียว ส่วน next() อ่านสมาชิกแล้วเลื่อนตำแหน่งด้วย index++ จึงเรียก hasNext() ซ้ำได้โดยไม่ข้ามข้อมูล

เมื่อไม่มีสมาชิกเหลือ สัญญา Iterator.next() กำหนดให้โยน NoSuchElementException ไม่ควรคืน null แทนการสิ้นสุด [1]

#7. ทำไมใช้ for-each ได้

สำหรับแหล่งข้อมูลที่เป็น Iterable โครงสร้างการทำงานของ:

for (Book book : shelf) {
    System.out.println(book.title());
}

สามารถอธิบายเชิงแนวคิดได้ด้วย:

Iterator<Book> it = shelf.iterator();
while (it.hasNext()) {
    Book book = it.next();
    System.out.println(book.title());
}

นี่คือเหตุผลที่สร้าง Iterable<Book> แล้วใช้งานกับไวยากรณ์วนซ้ำของภาษาได้ทันที กรณีอาร์เรย์มีกฎการแปลงของตัวเอง [4]

หากต้องการประมวลผลเฉพาะรายการที่เหลือ อาจใช้ forEachRemaining() กับ Iterator เช่น หลังอ่านเล่มแรกแล้วให้พิมพ์เล่มที่เหลือ [1]:

Iterator<Book> remaining = shelf.iterator();
remaining.next();
remaining.forEachRemaining(book -> System.out.println(book.title()));
// Clean Code
// Design Patterns

#8. งานจริงควรใช้ Iterator ที่มีอยู่ก่อน

การเขียนตัวเดินเองมีประโยชน์สำหรับการเรียนรู้หรือโครงสร้างข้อมูลเฉพาะ หากคลาสเพียงห่อรายการหนังสือ ใช้ Iterator ของ collection ภายในได้ โดยยังคงซ่อนรายละเอียดจากผู้ใช้:

import java.util.Iterator;
import java.util.List;

final class Catalog implements Iterable<Book> {
    private final List<Book> books;

    Catalog(List<Book> books) {
        this.books = List.copyOf(books);
    }

    @Override
    public Iterator<Book> iterator() {
        return books.iterator();
    }
}

ตัวอย่างนี้ใช้ Book จากตัวอย่างหลัก List.copyOf() สร้างรายการที่แก้ไขผ่านรายการนั้นไม่ได้ และไม่รับสมาชิก null [5] โค้ดของ Catalog จึงลดภาระการดูแลตรรกะตัวเดินของตนเอง ขณะที่ผู้ใช้ยังเรียก for (Book book : catalog) ได้

#9. การลบข้อมูลระหว่างเดิน

remove() เป็นความสามารถทางเลือก ไม่ใช่ทุก Iterator ที่รองรับ ใน BookIterator เราไม่ได้ override เมธอดนี้ จึงใช้ default implementation ที่โยน UnsupportedOperationException [1]

หากใช้ ArrayList ที่แก้ไขได้และต้องการลบระหว่างเดิน สามารถใช้ Iterator ของมัน:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

// วางภายในเมธอด เช่น main()
List<String> languages = new ArrayList<>(
    List.of("Java", "PHP", "JavaScript")
);

Iterator<String> it = languages.iterator();
while (it.hasNext()) {
    String language = it.next();
    if (language.equals("PHP")) {
        it.remove();
    }
}

System.out.println(languages); // [Java, JavaScript]

สำหรับ Iterator ที่รองรับการลบ ต้องเรียก next() ก่อน และลบได้หนึ่งครั้งต่อการอ่านสมาชิกหนึ่งครั้ง [1]

อย่าใช้ languages.remove(...) เพื่อเปลี่ยนโครงสร้าง ArrayList ขณะที่ Iterator เดิมกำลังเดินอยู่ เพราะอาจเกิด ConcurrentModificationException นโยบาย fail-fast ของ ArrayList ตรวจจับแบบ best effort จึงไม่ควรใช้ข้อยกเว้นนี้เป็นกลไกควบคุมความถูกต้องของโปรแกรม [3]

การแก้ข้อมูลระหว่างเดินเป็นนโยบายของแต่ละ implementation: Iterator บางประเภทอ่านจาก snapshot หรือมีนโยบายรองรับการเปลี่ยนแปลงเฉพาะของตน การมีอินเทอร์เฟซ Iterator เพียงอย่างเดียวไม่ได้ทำให้ข้อมูลปลอดภัยสำหรับหลายเธรด

#10. เลือกวิธีวนข้อมูลอย่างไร

ความต้องการ วิธีที่เหมาะ
อ่านสมาชิกทั้งหมดด้วยโค้ดสั้น enhanced for
ควบคุมการขอสมาชิกทีละรายการ Iterator
ลบสมาชิกขณะเดินและ implementation รองรับ Iterator.remove()
ต้องใช้ตำแหน่งหรือเข้าถึงสมาชิกแบบสุ่ม ดัชนีบนอาร์เรย์หรือรายการที่เหมาะสม
เดินหน้าและถอยหลังใน List ListIterator [6]
แปลงและกรองข้อมูลเป็น pipeline Stream API ตามลักษณะงาน
ซ่อนวิธีเดินโครงสร้างเฉพาะ สร้าง Iterator หรือห่อ Iterator ที่มีอยู่

Iterator เป็นกลไกสำหรับควบคุมการเดินข้อมูล ส่วน Stream API เพิ่มการประกอบขั้นตอนประมวลผล เช่น กรองและแปลงข้อมูล การเลือกใช้ขึ้นกับงานและความชัดเจนของโค้ด

#11. ข้อดี ข้อจำกัด และความซับซ้อน

ข้อดี

  • ผู้ใช้ไม่ต้องพึ่งรายละเอียดการจัดเก็บภายใน
  • แยกสถานะการเดินออกจากข้อมูล ช่วยรองรับหลายรอบที่เป็นอิสระ
  • ทำงานกับ for-each และ API ที่รับ Iterable ได้
  • เปลี่ยนวิธีเดินหรือเพิ่มตัวเดินอีกแบบได้ โดยรักษาสัญญาที่ผู้ใช้เรียก

ข้อจำกัด

  • เพิ่มคลาสและสถานะที่ต้องดูแล หากเขียนตัวเดินเอง
  • ต้องกำหนดพฤติกรรมเมื่อข้อมูลเปลี่ยนระหว่างเดิน
  • Iterator ทั่วไปไม่ได้รับประกันการถอยหลัง การอ่านซ้ำ หรือความปลอดภัยต่อหลายเธรด
  • ลำดับข้อมูลเป็นนโยบายของโครงสร้างนั้น ในตัวอย่างนี้คือลำดับอาร์เรย์ ไม่ได้หมายความว่าทุก collection มีลำดับเหมือนกัน

ความซับซ้อนต่อไปนี้เป็นของ BookShelf ในบทความนี้เท่านั้น:

การทำงาน เวลา หน่วยความจำเพิ่ม
สร้างชั้นหนังสือและคัดลอกอาร์เรย์ n เล่ม O(n) O(n) สำหรับอาร์เรย์ใหม่
สร้าง Iterator หนึ่งตัว O(1) O(1)
hasNext() หรือ next() หนึ่งครั้ง O(1) O(1)
เดินครบ n เล่ม O(n) O(1) สำหรับสถานะการเดิน

ตัวเดินต้นไม้หรือข้อมูลจากระบบภายนอกอาจมีต้นทุนต่างออกไป Pattern ไม่ได้รับประกันว่าการอ่านสมาชิกถัดไปทุกแบบต้องใช้เวลา O(1)

#12. ข้อผิดพลาดที่พบบ่อย

ข้อผิดพลาด ผลที่อาจเกิด แนวทางแก้
เก็บ index กลางไว้ที่ BookShelf ตัวเดินหลายตัวรบกวนตำแหน่งกัน เก็บตำแหน่งใน Iterator แต่ละตัว
คืน Iterator ตัวเดิมทุกครั้งโดยไม่ได้ตั้งใจ รอบใหม่เริ่มต่อจากตำแหน่งเดิม สร้างตัวใหม่เมื่อแหล่งข้อมูลรองรับการอ่านซ้ำ
เลื่อนตำแหน่งทุกครั้งที่เรียก hasNext() เรียกตรวจซ้ำแล้วข้ามสมาชิก รักษารายการที่จะคืนจนกว่า next() จะอ่าน
คืน null เมื่อเดินจบ ผู้ใช้แยกไม่ออกจากสมาชิกที่เป็น null โยน NoSuchElementException
เปลี่ยนโครงสร้าง collection ระหว่างเดินโดยไม่ดูนโยบาย ข้อยกเว้นหรือผลลัพธ์ไม่เป็นไปตามคาด ใช้วิธีที่ implementation รองรับ
สมมติว่า remove() ใช้ได้กับทุกตัว UnsupportedOperationException ตรวจสัญญาของตัวเดินนั้น

Iterator บางแบบอาจอ่านล่วงหน้าและเก็บรายการไว้ใน hasNext() เช่น ตัวกรองข้อมูล แต่การตรวจซ้ำต้องไม่ทำให้ผู้ใช้พลาดสมาชิกที่ยังไม่ได้รับจาก next()

#13. แบบฝึกหัดต่อยอด

  1. สร้าง new BookShelf() ที่ไม่มีหนังสือ อธิบายผลของ hasNext() และ next()
  2. เรียก hasNext() สามครั้งก่อนอ่านเล่มแรก แล้วตรวจว่ายังได้ Effective Java
  3. ทดลองแก้ช่องในอาร์เรย์ต้นฉบับหลังสร้างชั้นหนังสือ ตรวจว่าชั้นหนังสือยังอ่านข้อมูลเดิมเพราะมีการ clone
  4. เพิ่ม reverseIterator() ให้เริ่มจากเล่มสุดท้าย โดยไม่เปลี่ยนลำดับการเดินปกติของ iterator()
  5. สร้างตัวเดินที่คืนเฉพาะหนังสือซึ่งชื่อมีคำว่า Java โดยระวังการเรียก hasNext() ซ้ำ และกรณีไม่พบหนังสือเลย

เกณฑ์ตรวจงาน: ผลลัพธ์ต้องตรงตามลำดับที่กำหนด ตัวเดินหลายตัวต้องไม่แชร์ตำแหน่งโดยไม่ตั้งใจ และการอ่านหลังหมดข้อมูลต้องเป็นไปตามสัญญา Iterator

#14. ประเด็นสำหรับใช้สอน

Iterator Pattern ทำให้ผู้ใช้สั่งอ่านข้อมูลได้ผ่านสัญญาเดียว แม้รายละเอียดการจัดเก็บและวิธีเดินจะต่างกัน ใน Java ให้เริ่มจากแยกบทบาท Iterable กับ Iterator แล้วตรวจว่า ใครเก็บข้อมูล ใครเก็บตำแหน่ง และใครกำหนดลำดับการเดิน

เมื่อใช้ collection มาตรฐาน ส่วนใหญ่สามารถใช้ Iterator ที่มีอยู่ได้ทันที การสร้างตัวเดินเองเหมาะเมื่อโครงสร้างหรือนโยบายการเดินมีลักษณะเฉพาะ และต้องอธิบายสัญญาของมันให้ชัดเจน

#เอกสารอ้างอิง

ใช้เอกสาร Java SE 21 สำหรับตรวจสัญญา API ที่กล่าวถึง โค้ดตัวอย่างใช้เฉพาะความสามารถที่มีใน Java 17

  1. Oracle Java API — Iterator
  2. Oracle Java API — Iterable
  3. Oracle Java API — ArrayList
  4. Java Language Specification — Enhanced for Statement
  5. Oracle Java API — List.copyOf
  6. Oracle Java API — ListIterator