ข้าม​ไป​ยัง​เนื้อหา

Iterator

วน​ดู​สมาชิก​ของ collection โดย​ไม่​เปิดเผย​โครงสร้าง​ภายใน

เวลา​ต้อง​เขียน code ที่ “วน​ดู​สมาชิก​ที​ละ​ตัว” ของ collection ปัญหา​ที่​พบ​บ่อย​คือ collection แต่ละ​ชนิด​มี​โครงสร้าง​ภายใน​ต่าง​กัน — array ใช้ index, linked list ต้อง​ไล่​ตาม pointer, ต้นไม้ (tree) ต้อง​เดิน​แบบ depth-first หรือ breadth-first, บาง sequence ก็​ยัง​ไม่มี​อยู่​จริง​จนกว่า​จะ​ถูก​ขอ (เช่น stream ข้อมูล​จาก​เซนเซอร์) ถ้า​ปล่อย​ให้ client code รู้จัก​โครงสร้าง​ภายใน​เหล่า​นี้​โดยตรง จะ​เกิด​ปัญหา​สอง​ทาง คือ (1) client ผูก​ติด​กับ implementation ของ collection แน่น​เกิน​ไป เปลี่ยน​โครงสร้าง​เก็บ​ข้อมูล​ที​ก็​กระทบ code ที่​ใช้งาน​ทันที และ (2) ตัว collection เอง​ก็​ต้อง​แบกรับ​ทั้ง​หน้าที่ “เก็บ​ข้อมูล​อย่าง​มี​ประสิทธิภาพ” และ “รู้​วิธี​ท่อง​ตัวเอง​ใน​ทุก​รูปแบบ​ที่ client ต้องการ” ซึ่ง​ขัด​กับ Single Responsibility Principle

Iterator แก้​ปัญหา​นี้​ด้วย​การ แยก​ตรรกะ​การ​ท่อง (traversal) ออก​จาก​ตัว collection ไป​ไว้​ใน object แยก​ต่างหาก​ที่​เรียก​ว่า iterator ตัว iterator เก็บ​สถานะ​ของ​การวน (เช่น ตำแหน่ง​ปัจจุบัน) ไว้​ใน​ตัวเอง และ​เปิด interface กลาง ๆ ให้ client เรียก​ใช้ได้​เหมือน​กัน​ไม่​ว่า​เบื้องหลัง​จะ​เป็น collection ชนิด​ใด — โดย​ทั่วไป​คือ “ขอ​สมาชิก​ตัว​ถัด​ไป” (MoveNext() หรือ next()) และ “อ่าน​ค่า​ปัจจุบัน” (Current) client จึง​ไม่​จำเป็น​ต้อง​รู้​เลย​ว่า​กำลัง​วน​อยู่​บน array, tree หรือ lazy sequence

ใน C# pattern นี้​ถูก​ฝัง​เข้าไป​ใน​ภาษา​และ BCL เรียบร้อย​แล้ว​ผ่าน​คู่ interface IEnumerable<T> / IEnumerator<T> คำ​สั่ง foreach เป็น​เพียง syntactic sugar ที่ compiler ขยาย​ออก​มา​เป็นการ​เรียก GetEnumerator(), MoveNext(), Current และ​ดูแล Dispose() ให้​อัตโนมัติ ส่วน​คำ​สั่ง yield return ทำให้ compiler สร้าง class iterator แบบ lazy ให้​เรา​โดย​ไม่​ต้อง​เขียน state machine เอง​เลย และ LINQ ทั้ง​ชุด​ก็​สร้าง​อยู่​บน​รากฐาน​ของ IEnumerable<T> นี้

classDiagram
    class Client
    class IAggregate
    IAggregate : +CreateIterator() IIterator
    class ConcreteAggregate
    ConcreteAggregate : -items
    ConcreteAggregate : +CreateIterator() IIterator
    class IIterator
    IIterator : +MoveNext() bool
    IIterator : +Current() Item
    IIterator : +Reset()
    class ConcreteIterator
    ConcreteIterator : -aggregate
    ConcreteIterator : -position
    ConcreteIterator : +MoveNext() bool
    ConcreteIterator : +Current() Item
    ConcreteIterator : +Reset()
    Client --> IAggregate
    Client --> IIterator
    IAggregate <|.. ConcreteAggregate
    IIterator <|.. ConcreteIterator
    ConcreteAggregate ..> ConcreteIterator : creates
    ConcreteIterator --> ConcreteAggregate
  • IAggregate — interface ของ collection ที่​ประกาศ method factory สำหรับ​สร้าง iterator (ใน .NET คือ IEnumerable<T>.GetEnumerator())
  • ConcreteAggregate — collection จริง (array, tree, custom data structure) ที่​เก็บ​ข้อมูล​และ​รู้​วิธี​สร้าง iterator ที่​เหมาะ​กับ​โครงสร้าง​ของ​ตัวเอง
  • IIterator — interface กลาง​ที่ client ใช้​คุย​ด้วย ประกาศ MoveNext(), Current, บาง​แบบ​ก็​มี Reset() (ใน .NET คือ IEnumerator<T>)
  • ConcreteIterator — เก็บ​ตำแหน่ง​ปัจจุบัน​และ​อ้างอิง​กลับ​ไป​ยัง aggregate ที่​มัน​วน​อยู่ เป็น​ตัว​เดียว​ที่​รู้​ว่า​จะ​เดิน​ไป​ยัง “ตัว​ถัด​ไป” อย่างไร​สำหรับ​โครงสร้าง​ข้อมูล​นั้น ๆ

ลำดับ​การ​ทำงาน​เริ่ม​จาก client ขอ iterator จาก aggregate แทนที่​จะ​เข้าถึง​ข้อมูล​ภายใน​ตรง ๆ จาก​นั้น​ก็​วน​เรียก MoveNext() ควบคู่​กับ​อ่าน Current จนกว่า​จะ​หมด collection — ตัว aggregate ไม่​ต้อง​รู้​เลย​ว่า client จะ​วน​แบบ​ไหน​หรือ​หยุด​กลาง​ทาง​เมื่อไหร่ ส่วน iterator ก็​ไม่​ต้อง​รู้​เลย​ว่า client จะ​เอา​ค่า​ไป​ทำ​อะไร​ต่อ นี่​คือ​จุด​ที่​ทำให้​ทั้ง​สอง​ฝั่ง​แยก​อิสระ​จาก​กัน (decoupled)

sequenceDiagram
    participant Client
    participant Aggregate as ConcreteAggregate
    participant Iterator as ConcreteIterator

    Client->>Aggregate: CreateIterator()
    Aggregate->>Iterator: new ConcreteIterator(this)
    Aggregate-->>Client: iterator
    loop จนกว่าจะหมด collection
        Client->>Iterator: MoveNext()
        Iterator-->>Client: true / false
        Client->>Iterator: Current
        Iterator-->>Client: item
    end

ใน C# ขั้นตอน​นี้​เกิด​ขึ้น​ทุก​ครั้ง​ที่​เรา​เขียน foreach compiler จะ​ขยาย​เป็น code ประมาณ​นี้​โดย​อัตโนมัติ (และ​ครอบ​ด้วย try/finally เพื่อ​เรียก Dispose() ให้​ด้วย):

IEnumerator<int> enumerator = collection.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
var item = enumerator.Current;
// ทำงานกับ item
}
}
finally
{
(enumerator as IDisposable)?.Dispose();
}

จุด​ที่​ทำให้ yield return ทรง​พลัง​คือ​มัน สร้าง state machine ของ ConcreteIterator ให้​อัตโนมัติ​แบบ lazy — code ใน method จะ​ไม่​ทำงาน​ทันที​ตอน​เรียก แต่​จะ​ทำงาน​ที​ละ “ก้าว” ทุก​ครั้ง​ที่ MoveNext() ถูก​เรียก ทำให้​สร้าง sequence ที่​ไม่​จำกัด​ความ​ยาว (infinite sequence) หรือ sequence ที่​คำนวณ​แพง​ได้​โดย​ไม่​เปลือง​หน่วย​ความ​จำ​เก็บ​ทั้ง​ชุด​ล่วงหน้า

ตัวอย่าง​นี้​จำลอง collection แบบ binary tree ที่​ต้อง​เดิน​แบบ in-order traversal — เป็น​กรณี​คลาสสิก​ที่ Iterator ช่วย​ได้​มาก เพราะ client ไม่​จำเป็น​ต้อง​รู้​เลย​ว่า​เบื้องหลัง​เป็น​โครงสร้าง​ต้นไม้:

// node ของ binary search tree
public class TreeNode<T>
{
public T Value { get; }
public TreeNode<T>? Left { get; set; }
public TreeNode<T>? Right { get; set; }
public TreeNode(T value) => Value = value;
}
// collection ที่ implement IEnumerable<T> เพื่อให้ foreach ใช้งานได้
public class BinarySearchTree<T> : IEnumerable<T>
{
private TreeNode<T>? _root;
public void Add(TreeNode<T> node) => _root = node; // ตัวอย่างแบบย่อ ไม่รวม logic การแทรกจริง
// เปิดเผยแค่วิธีการขอ iterator เท่านั้น ไม่เปิดเผยโครงสร้างต้นไม้เลย
public IEnumerator<T> GetEnumerator() => InOrderTraverse(_root);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
// yield return ให้ compiler สร้าง ConcreteIterator แบบ lazy ให้อัตโนมัติ
private static IEnumerator<T> InOrderTraverse(TreeNode<T>? node)
{
if (node is null) yield break;
// เดิน subtree ซ้ายก่อน แล้วค่อยคืนค่าตัวเอง แล้วค่อยเดิน subtree ขวา
if (node.Left is not null)
{
var left = InOrderTraverse(node.Left);
while (left.MoveNext())
yield return left.Current;
}
yield return node.Value;
if (node.Right is not null)
{
var right = InOrderTraverse(node.Right);
while (right.MoveNext())
yield return right.Current;
}
}
}
// client ไม่รู้เลยว่าเบื้องหลังเป็น tree — วนด้วย foreach เหมือน collection ทั่วไป
var tree = new BinarySearchTree<int>();
// ... เติมข้อมูลเข้า tree ...
foreach (var number in tree)
{
Console.WriteLine(number); // ได้ค่าตามลำดับ in-order โดยไม่ต้องรู้จัก TreeNode เลย
}

ถ้า​ต้องการ iterator ที่​ควบคุม​เอง​แบบ​ไม่​ผ่าน foreach ก็​ทำได้​ตรง ๆ ผ่าน IEnumerator<T>:

using var it = tree.GetEnumerator();
while (it.MoveNext())
{
Console.WriteLine(it.Current);
}
  • collection มี​โครงสร้าง​ภายใน​ซับซ้อน (tree, graph, linked structure) ที่​ไม่​อยาก​ให้ client ต้อง​รู้จัก
  • ต้องการ​รองรับ​การวน​หลาย​รูปแบบ​พร้อม​กัน (เช่น หลาย iterator วนบน collection เดียวกัน​พร้อม​กัน​แบบ​อิสระ)
  • ต้องการ​เปิด​ให้​เพิ่ม algorithm การ​ท่อง​แบบ​ใหม่​ใน​อนาคต​โดย​ไม่​ต้อง​แก้ collection เดิม (เปิด​ขยาย ปิด​แก้ไข — Open/Closed Principle)
  • ต้องการ sequence แบบ lazy ที่​คำนวณ​ที​ละ​ตัว​ตาม​ต้องการ เช่น stream ข้อมูล​ขนาด​ใหญ่​หรือ sequence ไม่​จำกัด​ความ​ยาว
  • ต้องการ​ให้ client เขียน code แบบ​เดียวกัน​ไม่​ว่า​จะ​วน​บน collection ชนิด​ใด (polymorphic traversal)
  • collection เรียบ​ง่าย​มาก เช่น array หรือ List<T> ที่ index-based access ตรง​ไป​ตรง​มา​อยู่​แล้ว การ​เพิ่ม iterator ของ​ตัวเอง​มี​แต่​จะ​เพิ่ม​ความ​ซับซ้อน​โดย​ไม่​จำเป็น (ใน .NET แทบ​ทุก​กรณี​ใช้ IEnumerable<T> ที่​มี​อยู่​แล้ว​ได้​เลย)
  • ต้องการ​ประสิทธิภาพ​สูงสุด​ใน​การ​เข้าถึง​แบบ​สุ่ม (random access) — การ​ครอบ​ด้วย iterator interface เพิ่ม overhead ของ​การ​เรียก​ผ่าน interface และ​ตัด optimization เฉพาะ​โครงสร้าง (เช่น index จริง​ของ array) ออก​ไป
  • ต้องการ​แก้ไข collection ระหว่าง​วน (add/remove) โดย​ไม่​ระวัง — iterator ส่วน​ใหญ่ (รวม​ถึง IEnumerator<T> ของ .NET) จะ throw InvalidOperationException เมื่อ collection ถูก​แก้ไข​ระหว่าง​วน ต้อง​ออกแบบ​เผื่อ​กรณี​นี้​เป็น​พิเศษ
ด้านรายละเอียด
ข้อดีแยก​ตรรกะ​การ​ท่อง​ออก​จาก collection ตรง​ตาม Single Responsibility Principle
ข้อดีเพิ่ม​วิธี​ท่อง​แบบ​ใหม่​ได้​โดย​ไม่​แก้ collection เดิม (Open/Closed Principle)
ข้อดีรองรับ​หลาย iterator วนบน collection เดียวกัน​พร้อม​กัน​ได้​อย่าง​อิสระ
ข้อดีเปิด​ทาง​ให้​ทำ lazy evaluation ผ่าน yield return ประหยัด​หน่วย​ความ​จำ​สำหรับ sequence ขนาด​ใหญ่
ข้อ​เสียเพิ่ม class/object อีก​ชั้น​หนึ่ง อาจ​ดู​เกิน​ความ​จำเป็น​สำหรับ collection ง่าย ๆ
ข้อ​เสียมี overhead เล็กน้อย​จาก​การ​เรียก​ผ่าน interface เทียบ​กับ index-based access ตรง ๆ
ข้อ​เสียต้อง​ระวัง​เรื่อง collection ถูก​แก้ไข​ระหว่าง​วน (invalidated iterator)
  • Encapsulation — Iterator คือ​การนำ​หลัก encapsulation มา​ใช้​กับ​การ​ท่อง collection โดย​เฉพาะ
  • Composite — โครงสร้าง​ต้นไม้​แบบ Composite มัก​ถูกวน​ด้วย Iterator เพื่อ​เดิน​ทั้ง object ที่​เป็น​ใบ​และ​ที่​เป็น​กิ่ง
  • Visitor — ทั้ง​คู่​แยก​ตรรกะ​ออก​จาก collection แต่ Visitor เน้น​แยก “การกระทำ” ส่วน Iterator เน้น​แยก “การ​เดิน”
  • Strategy — algorithm การ​ท่อง​แบบ​ต่าง ๆ (เช่น pre-order vs in-order) สามารถ​มอง​เป็น strategy ที่​สลับ​กัน​ได้
  • Lazy Loadyield return ใช้​หลักการ​เดียว​กับ lazy loading คือ​คำนวณ/โหลด​ค่า​เมื่อ​ถูก​ร้องขอ​จริง​เท่านั้น