iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 29 篇

Day 29|Iterator (迭代器模式) 走訪與儲存的職責分離:讓遍歷演算法不再被資料結構綁架

  • 分享至 

  • xImage
  •  

昨天用 Mediator 把廚房、收銀、外送平台之間的協調邏輯收進一個中介者

今天聊聊柴咖啡最後一個 Pattern:Iterator 迭代器模式

原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software

Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation.

用中文理解可以是

提供一種方法順序存取一個聚合(集合)物件中的各個元素,而又不暴露該物件的內部表示法

核心角色

  1. Iterator(迭代器介面):定義走訪用的方法,通常是「有沒有下一個」「拿目前這一個」(C# 是 IEnumerator<T> 的 MoveNext()/Current)
  2. Aggregate(聚合介面):定義一個「給我一個迭代器」的方法(C# 是 IEnumerable<T> 的 GetEnumerator())
  3. ConcreteIterator(具體迭代器):記錄「現在走到哪裡了」的實作,知道怎麼從目前位置移到下一個
  4. ConcreteAggregate(具體聚合物件):實際持有一堆元素的集合本身

UML 會是長什麼樣
https://ithelp.ithome.com.tw/upload/images/20260923/20183470wCihkPOm0P.jpg

我們來聊聊: 為什麼會有 Iterator Design Pattern?

在 Iterator 出現以前,我們寫程式要巡訪一包資料,往往被底層資料結構死死綁架:

  • 如果今天這包資料是用 陣列(Array / List) 存的,我們會寫:
for (int i = 0; i < collection.Length; i++) { ... }
  • 如果明天需求改變,工程師為了效能把資料改成 鏈結串列(LinkedList),取資料的方法就得改成指標走訪
var node = collection.First;
while (node != null) { ... node = node.Next; }
  • 如果改用 樹狀結構(Binary Tree),外部程式碼甚至得自己寫遞迴(先序、中序、後序遍歷)

所帶來致命的問題,就是外面負責「使用資料的人」,必須對內部「怎麼儲存資料」的細節一清二楚

如果底層結構更改,外面所有寫的 for 迴圈就會出問題

Iterator 的誕生,解決了以下問題

  1. 職責單一原則(SRP):使「資料怎麼存」和「資料怎麼走訪」分離
  2. 資訊隱藏(封裝底層細節): 外部使用者不需要知道內部結構
  3. 支援多個獨立走訪(游標互不干擾): 把游標做成獨立的 Iterator 物件,你拿你的游標、我拿我的游標,同一份資料愛怎麼各自讀取都沒問題

我們來舉個生活化的例子,我開車的時候很喜歡聽廣播,也很喜歡切來切去

我不需要知道收音機內部的結構機制,晶片陣列邏輯,只要按下「下一台」,收音機就依序切到下一個頻道

我們來定義角色概念

  1. 駕駛 ME(Client):手握方向盤,只要按方向盤上的「下一台(Next Channel)」按鍵。

  2. 收音機旋鈕 / 切台器(Iterator):負責記住目前停在第幾台,每次按鍵就切到下一台

  3. 收音機內部儲存(Aggregate):底層到底怎麼存頻率的,駕駛(ME)完全不用在乎

// 1. 迭代器介面:負責轉台與輸出當前播放內容
public interface IRadioTuner
{
    bool NextStation();         // 轉動旋鈕:切到下一台並播放 (成功切台回傳 true)
    string CurrentPlaying { get; } // 目前喇叭正在播放的電台名稱
}

// 2. 聚合介面:收音機能提供切台旋鈕
public interface ICarRadio
{
    IRadioTuner CreateTuner();
}

// 3. 具體迭代器:收音機旋轉鈕,負責管理游標(指針)狀態並直接輸出
public class RadioTuner : IRadioTuner
{
    private readonly List<string> _stations;
    private int _currentIndex = -1; // 游標狀態:初始為 -1,代表尚未轉台

    public RadioTuner(List<string> stations)
    {
        _stations = stations;
    }

    // 取得目前正在播放的電台
    public string CurrentPlaying => _currentIndex >= 0 && _currentIndex < _stations.Count
        ? _stations[_currentIndex]
        : "靜音 (無播放)";

    // 轉動旋鈕:指針前進一格,直接完成切台
    public bool NextStation()
    {
        if (_currentIndex + 1 < _stations.Count)
        {
            _currentIndex++;
            return true; // 切台成功,CurrentPlaying 即時更新
        }

        Console.WriteLine(">> [嗶] 轉到底了,沒有下一個頻道!");
        return false;
    }
}

// 4. 具體收音機主機(資料本體)
public class CarRadio : ICarRadio
{
    // 資料儲存:內部用 List 儲存,外界完全不需知道底層結構
    private readonly List<string> _stations = new();

    public void AddStation(string stationName) => _stations.Add(stationName);

    // 產出旋鈕迭代器
    public IRadioTuner CreateTuner() => new RadioTuner(_stations);
}

// 5. 駕駛開車情境
class Program
{
    static void Main()
    {
        // 建立收音機與預設電台
        var radio = new CarRadio();
        radio.AddStation("FM 91.7 - 流行音樂台");
        radio.AddStation("FM 99.7 - 古典音樂台");
        radio.AddStation("FM 103.3 - 中廣流行網");

        // 駕駛握住選台旋鈕 (Iterator)
        IRadioTuner tuner = radio.CreateTuner();

        Console.WriteLine("阿柴開車上路...\n");

        // 轉一下,直接換台並播放
        RotateKnob(tuner);

        Console.WriteLine("\n...開了 10 分鐘,聽膩了順手再轉一下...");
        RotateKnob(tuner);

        Console.WriteLine("\n...這首也不合胃口,再轉一下...");
        RotateKnob(tuner);

        Console.WriteLine("\n...想再轉下一首...");
        RotateKnob(tuner); // 到底了,跳出提示
    }

    // 旋鈕轉動動作:轉一下直接發聲
    static void RotateKnob(IRadioTuner tuner)
    {
        if (tuner.NextStation())
        {
            Console.WriteLine($"[轉動旋鈕]  喇叭直接播放:{tuner.CurrentPlaying}");
        }
    }
}

換作 C# 譬喻,平常我們很常用到的是 foreach

 foreach (var item in list)
 {
    Console.WriteLine(item);
 }

C# 編譯器在編譯時,實際上會把它展開成標準的 Iterator 模式操作:

 using (IEnumerator<int> enumerator = list.GetEnumerator()) // 1. 向容器索取 Iterator
 {
    while (enumerator.MoveNext()) // 2. 相當於 HasNext() + 移動游標
    {
        var item = enumerator.Current; // 3. 取得當前元素
        Console.WriteLine(item);
    }
 }

不知道上述舉例有沒有讓你能更認識 Iterator

回到柴咖啡

柴咖啡展店到好幾間分店,各分店回報「今天賣出的品項」給總部的方式都不一樣

開最久的那幾間店 POS 系統老舊,資料本來就是用陣列存的

後來新開的店換了新系統,改用 List<T> 存

小黑最近一口氣丟了三個需求過來:

  1. 電子發票系統要「今天全店賣出的每一個品項」攤平成一份清單
  2. 行銷想找出「所有 100 元以下的品項」做促銷
  3. 小黑自己想知道「今天全店總共賣了幾件」

三個需求要拿走訪結果去做的事完全不一樣,但共同點是都得把每一間分店的銷售紀錄從頭到尾看過一遍

如果總部的統計程式碼要分別認得「這間店是陣列、要用 for 迴圈」「那間店是 List、要用 foreach」

以後只要哪間店換系統、底層資料結構一變,統計程式碼就得跟著回頭改

阿柴心想,這正是 Iterator 要解決的問題:每間店的資料要怎麼存,是那間店自己的事,總部只要能用同一種方式走訪就好

Iterator 上場

跟剛剛收音機的例子一樣,自己刻一組 Iterator 角色:一個負責走訪、一個負責交出走訪器

public class SoldItem
{
    public string Name { get; }
    public int Price { get; }

    public SoldItem(string name, int price)
    {
        Name = name;
        Price = price;
    }
}

// 1. Iterator(迭代器介面) / 走訪職責
public interface ISalesIterator
{
    bool HasNext();
    SoldItem Next();
}

// 2. Aggregate(聚合介面)/ 儲存職責
public interface ISalesRecord
{
    ISalesIterator CreateIterator();
}

// 3. ConcreteIterator:走訪陣列版的分店資料
public class ArraySalesIterator : ISalesIterator
{
    private readonly SoldItem[] _items;
    private int _index = 0;

    public ArraySalesIterator(SoldItem[] items) => _items = items;

    public bool HasNext() => _index < _items.Length;
    public SoldItem Next() => _items[_index++];
}

// 3. ConcreteIterator:走訪 List 版的分店資料
public class ListSalesIterator : ISalesIterator
{
    private readonly List<SoldItem> _items;
    private int _index = 0;

    public ListSalesIterator(List<SoldItem> items) => _items = items;

    public bool HasNext() => _index < _items.Count;
    public SoldItem Next() => _items[_index++];
}

// 4. ConcreteAggregate:舊分店,資料本來就是用陣列存
public class LegacyStoreSales : ISalesRecord
{
    private readonly SoldItem[] _items;

    public LegacyStoreSales(SoldItem[] items) => _items = items;

    public ISalesIterator CreateIterator() => new ArraySalesIterator(_items);
}

// 4. ConcreteAggregate:新分店,資料改用 List 存
public class NewStoreSales : ISalesRecord
{
    private readonly List<SoldItem> _items = new();

    public void Add(SoldItem item) => _items.Add(item);

    public ISalesIterator CreateIterator() => new ListSalesIterator(_items);
}

不管底層是陣列還是 List,兩間店都實作了同一個 ISalesRecord,呼叫端只認識 CreateIterator() 拿到的 ISalesIterator,不用管背後是怎麼存的

三個需求呼叫端可以直接這樣寫

List<ISalesRecord> allStores = new() { legacyStore, newStore };

var allItems = new List<SoldItem>();
foreach (var store in allStores)
{
    var iterator = store.CreateIterator();
    while (iterator.HasNext())
    {
        allItems.Add(iterator.Next()); // 攤平成一份清單,餵給發票系統
    }
}

var cheapItems = allItems.Where(item => item.Price < 100).ToList(); // 行銷要的促銷名單
int totalCount = allItems.Count;                                    // 小黑要的今天賣出總數

LegacyStoreSales、NewStoreSales 不知道呼叫端到底要拿這份走訪結果去算總數、篩選,還是丟給發票系統

它們只負責回答「怎麼走訪」這一件事,以後就算又多開一間分店、用了第三種資料結構,只要一樣實作

ISalesRecord/ISalesIterator,上面這段呼叫端程式碼完全不用改

其實你每天都在用

不管是收音機的 IRadioTuner/RadioTuner

還是柴咖啡分店的 ISalesIterator/ArraySalesIterator/ListSalesIterator

做的都是同一件事:自己刻一組 Iterator 角色,配合對應的 Aggregate 一起用

C# 其實已經把 Iterator 這個角色定義成語言內建的兩個介面:

IEnumerator<T>(迭代器)、IEnumerable<T>(聚合物件)

還提供 yield return 讓實作變得很簡短

真的會自己實作的時機,通常只剩下「我有一個自訂的資料結構,想讓它也能被 foreach、被 LINQ 直接使用」

今天學到的事

Iterator 要解決的問題:

  1. 隱藏底層資料結構(資訊隱藏):使用者只想「一個一個拿出資料」,不需要知道內部是用陣列、二元樹、鏈結串列還是圖結構儲存

  2. 職責單一化(SRP):若將「資料儲存」與「遍歷演算法」混在同一個集合類別中,集合負擔過重。Iterator 將「遍歷與游標管理」抽離出來,讓集合專注於資料儲存與增刪

  3. 提供一致的走訪介面:對呼叫端而言,無論面對何種集合,存取迴圈的程式碼風格完全統一

優點:

  • 呼叫端不用管底層資料結構長什麼樣,統一用同一種方式走訪(foreach、或語言提供的迭代語法)

  • 走訪邏輯集中在迭代器自己,不會散落在每一個想走訪這個聚合物件的呼叫端,各自重寫一次遞迴或索引邏輯

  • 同一個聚合物件可以同時存在多個獨立的走訪過程,彼此互不干擾,各自記著自己走到哪

缺點:

  • 主流語言大多把這個 Pattern 內建進語言基礎設施(foreach/for...of/生成器),日常業務程式碼很少需要自己手刻一個,真正動手實作的情境比其他 Pattern 少見

  • 走訪過程中如果底層集合的內容被修改,容易跟走訪狀態不同步,需要額外設計因應


明天,30 天的最後一篇,來做整個系列的總結

參考資料

Refactoring Guru - Iterator

Microsoft Docs - IEnumerable<T> Interface

C# Iterators


上一篇
Day 28|Mediator (中介者模式) 用 Mediator 打造多方即時調度中心
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉 共 29 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言