yield return 到底做了什麼?從 Enumerator 理解它的運作方式

前言 上一篇文章,我理解了 foreach 的運作方式。原來 foreach 並不知道 List、Dictionary 或 Queue 的內部實作,它只會透過 IEnumerator 一次取得一個元素。理解這件事情後,我又開始好奇另一件事。 Enumerator 到底是誰寫的? 如果每一個 Collection 都要自己實作 IEnumerator,不是很麻煩嗎? 如果沒有 yield return 假設今天我要建立一個可以被 foreach 遍歷的物件: public class NumberCollection { } 如果沒有 yield return,那我就必須自己建立一個 Enumerator。 public class NumberEnumerator : IEnumerator<int> { public int Current => ... object IEnumerator.Current => Current; public bool MoveNext() { ... } public void Reset() { ... } public void Dispose() { } } 還要讓 Collection 回傳這個 Enumerator。 ...

July 23, 2026 · 2 min · Yen Tsai

為什麼 foreach 可以遍歷所有 Collection?從設計角度理解 IEnumerable

前言 上一篇文章,我開始理解不同 Collection 存在的原因。Array、List、Dictionary、Queue、Stack 各自解決不同的資料管理問題,底層實作也不一樣。 理解這件事後,我突然想到另一個問題。 如果每一種 Collection 都長得不一樣,為什麼 foreach 卻能遍歷所有 Collection? 例如: List<int> list = new() { 1, 2, 3 }; foreach (var item in list) { Console.WriteLine(item); } 可以正常運作。換成 Dictionary: Dictionary<int, string> dictionary = new() { { 1, "Jack" }, { 2, "Amy" } }; foreach (var item in dictionary) { Console.WriteLine(item); } 也完全沒問題,甚至 Queue、Stack、HashSet 都能使用 foreach。 它們明明是不同的資料結構,foreach 又是怎麼做到的? 如果 foreach 必須認識每一種 Collection 我試著站在 .NET Framework 設計者的角度思考。 假設沒有任何共同規範,那 foreach 要怎麼設計? 最直接的方法,大概會變成: 如果是 List 就用 List 的方式遍歷 如果是 Dictionary 就用 Dictionary 的方式遍歷 如果是 Queue 就用 Queue 的方式遍歷 ... 問題很快就出現了:每新增一種 Collection,foreach 就必須修改一次。如果有人自己寫了一個新的 Collection,foreach 也不知道該怎麼遍歷它。 ...

July 23, 2026 · 2 min · Yen Tsai

為什麼 Array 無法解決所有問題?我是如何理解 .NET Collection 設計的

前言 以前我學 Collection,就是背: List 是動態陣列 Dictionary 是 Hash Table Queue 是 FIFO Stack 是 LIFO 但我一直沒有想通一個問題。 既然 Array 已經可以存很多資料了,為什麼 .NET 還要設計這麼多 Collection? 如果世界只有 Array 假設 .NET 只有 Array。 Student[] students = new Student[100]; 其實很多事情都做得到,例如: 存放資料 修改資料 使用索引快速取得資料 Array 還有一個很大的優點:透過索引取出指定元素。 students[50] 時間複雜度是: O(1) 但當需求開始改變,Array 的限制也會慢慢出現。 當資料超出原本預留的空間 假設: Student[] students = new Student[100]; 突然第 101 個學生來了。 Array 的長度建立後就固定了。要放進第 101 筆資料,只能: 建立新的 Array ↓ Copy 所有資料 ↓ 丟掉舊的 Array 這時我開始思考。 如果有一個容器可以自己長大,不就好了? 這正是 List<T> 想解決的問題。它的內部仍然使用 Array,但容量不足時,會建立更大的 Array,再把原有元素複製過去。 ...

July 23, 2026 · 2 min · Yen Tsai

C# Generic(泛型)- 我真正理解 Generic 的方式

前言 以前學 Generic,我只記住一句話: Generic 可以重複利用程式碼。 這句話沒有錯,但一直讓我無法真正理解 Generic 的設計目的。 直到我開始站在 Framework 設計者 的角度思考,我才發現: Generic 真正想解決的是「保留真正的型別」。 如果沒有 Generic,我會怎麼設計? 假設今天要交換兩個變數。 第一個想到的方法就是每個型別都寫一份。 void Swap(int a, int b) { } void Swap(string a, string b) { } void Swap(Student a, Student b) { } 問題就在,如果有 100 種型別,就要寫 100 個版本。 因此想要有個方法能夠接受任意型別的參數。 那全部改成 object 不就好了? 第二個想到的方法就是: void Swap(object a, object b) { } 這方法能夠接受所有型態,但是會有問題。 Value Type 需要 Boxing int x = 10; object obj = x; CLR 必須建立一個新的 Heap Object,因此會產生 Boxing。 ...

July 23, 2026 · 2 min · Yen Tsai

RAG 入門到實作:Chunking、Embedding、Top-K 與檢索品質

photo by Reed Mok LLM有時會產生看似合理、但其實不正確的回答,這種不懂裝懂的答案會讓使用者得到錯誤的資訊,還毫不自覺。 這種情況常發生在模型缺乏最新的資訊,像是詢問公司內部的東西,如果是沒有公開的事情,LLM根本不會知道,但是他仍然會亂回答一通。 RAG (Retrieval-Augmented Generation)就是為了解決這類問題而誕生的技術。它的核心概念是: 在LLM回答問題之前,先從指定的資料庫中看一次有沒有相關的文件能參考,讓模型根據可參考內容產生回答。 我自己最近也開始學習RAG,並且實作了一個小專案:MediVector-Chat。 會想做這個專案,其實跟我自己的經驗有關。之前我有腳痛的問題,在查資料或問GPT的時候,我會希望能夠參考更多相關資料,例如衛教資訊或最新論文,再根據這些內容回答我的問題。 這也是我覺得RAG很有價值的地方。一般聊天型LLM雖然很方便,但很多時候它其實只是根據訓練資料去生成看起來合理的文字。如果問題涉及特定領域知識、內部文件或最新資訊,模型未必真的知道答案。 所以我想做一個能夠「先查資料,再回答問題」的AI chatbot。 RAG的流程 文件準備:收集文件、網頁、FAQ、內部知識庫等資料。 切分資料:將長文件切成較小的 chunk,方便後續檢索。 建立索引:使用 embedding model 將 chunk 轉成向量,存入 vector database。 查詢檢索:使用者提問後,系統將問題轉成向量,找出最相關的資料片段。 生成回答:把檢索到的資料片段放進 prompt,讓 LLM 根據參考內容回答。 簡單來說,流程大概像這樣: 文件 / PDF → Chunking → Embedding → 存入 向量資料庫 → 使用者提問 → Vector Search → Top-K Retrieval → Prompt 組合 → LLM 回答 → 顯示來源段落 在我自己的 MediVector-Chat 專案中,也有實際把這些流程做出來。目前有做到: 文件 chunking embedding vector search Top-K retrieval prompt 組合 顯示來源段落 前後端分離 Docker 啟動本機環境 使用技術主要是: ...

May 20, 2026 · 1 min · Yen Tsai

深入淺出談 Queue:順序很重要

photo by John Cameron 剛好昨天看了小Lin老師的影片,提到委內瑞拉這個國家,天天都在排隊 加油排隊、去超市也排隊 排隊這件事,其實跟系統處理工作的順序一樣 順序一亂,待季就大條了,這時候使用Queue來管理執行順序,是一個很好的方法 什麼是 Queue Queue 是一種 先進先出 (First-In,First-Out,FIFO) 的資料結構 Queue跟Stack一樣,定義了使用者只能用那些操作 換來一致性的時間複雜度 Queue的時間複雜度 操作 Queue 時間複雜度 push 插入資料到最後位置 O(1) pop 刪除最前面的資料 O(1) front 取得最前面的資料 O(1) 使用Queue最佳場景 假設現在有三個Job依序執行,而執行中很可能因為系統當機導致需要重做 這時候應該把Job放在哪種資料結構中: A:List搭配index B:Queue 看起來A、B都可以,實際上會造成很大的落差 情境A List搭配index list = [A, B, C] i = load_index() // 持久化的進度 while i < list.length: job = list[i] do_side_effect(job) // 例如:寄信 / 扣款 / 發送檔案 // 這裡可能成功、也可能一半成功、也可能成功但你還沒來得及記錄 i = i + 1 save_index(i) // 持久化進度 crash可能發生在: ...

February 5, 2026 · 1 min · Yen Tsai

深入淺出談 Hash Table:從理論 O(1) 到實作中的 rehash 現實

photo by Jessica Johnston 什麼是 Hash Table Hash Table 是一種透過 hash function 將 key 映射到 bucket, 以達成 平均 O(1) 查找效率 的資料結構 資料通常以 key–value 的形式儲存, 使用者只需要提供 key,就能快速取得對應的 value 在實作上,Hash Table 內部會維護一組 bucket array, 每個 bucket 可以儲存一個或多個元素 當資料量持續增加、bucket 內元素變得過於擁擠時, Hash Table 會透過 resize / rehash 重新分配資料, 以維持查找效能 為什麼 Hash Table 可以快速查詢資料? Hash Table 能快速查詢資料的關鍵,在於 hash function 當一個 key 被插入或查詢時, Hash Table 會先對 key 計算 hash 值, 再將 hash 值映射成一個 bucket index, 用來決定資料應該存放或查找的位置 ...

January 28, 2026 · 3 min · Yen Tsai

深入淺出談 Stack:為什麼「後進先出」不是限制,而是力量

photo by Reed Mok 玩過河內塔嗎? 這是是一個只能從一個桿子中拿最上面圓盤的遊戲, 然後再把圓盤放到另一個桿子的最上方 看到Stack的結構馬上就想到這個例子 什麼是 Stack 一個定義好行為的資料結構 使用者只能把資料一層一層疊在最上方 使用者一次只能取最上方的資料 為什麼要定義行為 先看看Array跟Stack的差異 操作 Array Stack Search 可以查詢指定index 只能看最上方資料 Insert 可以Insert指定index 只能push資料到最上方 Remove 可以Remove指定index 只能pop最上方資料 可以看到Array跟Stack的差異在於限制了行為:新增、刪除、查詢的位置, 為的是換來操作的一致性, 跟較好的時間複雜度 Stack 的時間複雜度 操作 複雜度 說明 讀取 (Seek) O(1) 讀取最上方資料 插入 (Push) O(1) 插入資料到最上方 刪除 (Pop) O(1) 刪除最上方資料 使用 Stack 的最佳場景 後進先出的場景 剪貼簿的Undo, Redo功能 Rollback 實作Undo, Redo功能 #include <iostream> #include <stack> #include <string> using namespace std; int main() { stack<string> undoStack; stack<string> redoStack; while (true) { cout << "\nChoose:\n" << "1) Add data\n" << "2) Undo\n" << "3) Redo\n" << "4) Exit\n" << ">> "; int choice = 0; if (!(cin >> choice)) break; if (choice == 1) { cout << "Enter data (one word): "; string x; cin >> x; undoStack.push(x); while (!redoStack.empty()) redoStack.pop(); // 清空 redo cout << "Added: " << x << endl; } else if (choice == 2) { if (undoStack.empty()) { cout << "Nothing to undo." << endl; } else { string x = undoStack.top(); undoStack.pop(); redoStack.push(x); cout << "Undo: " << x << endl; } } else if (choice == 3) { if (redoStack.empty()) { cout << "Nothing to redo." << endl; } else { string x = redoStack.top(); redoStack.pop(); undoStack.push(x); cout << "Redo: " << x << endl; } } else if (choice == 4) { cout << "Bye." << endl; break; } else { cout << "Invalid choice." << endl; } cout << "Undo size: " << undoStack.size() << ", Redo size: " << redoStack.size() << endl; if (!undoStack.empty()) cout << "Undo top: " << undoStack.top() << endl; if (!redoStack.empty()) cout << "Redo top: " << redoStack.top() << endl; } return 0; } 程式碼說明: ...

January 27, 2026 · 2 min · Yen Tsai

深入淺出談 Linked List:為什麼它只能一個一個走

photo by Shubham Dhage 你玩過心理測驗嗎? 有些心理測驗的題目是這樣的 你是男生, 請跳到第 8 題, 妳是女生請跳到第 3 題… 這樣照著前一個指令跳到下一個指定位置的遊戲真的很像 Linked List 的結構 什麼是 Linked List 一個資料結構, 每一筆資料除了自己的值外, 還知道下一筆資料是誰, 因此所有操作都必須照順序進行 Linked List 的時間複雜度 操作 複雜度 說明 讀取 (Read) O(n) 必須從頭開始遍歷資料 插入 (Insert) O(1) 在已知插入的位置時, 只需調整前後關係 刪除 (Delete) O(1) 在已知刪除的位置時, 只需修改前後關係 使用 Linked List 的最佳場景 已知要在某個元素的位置, 並且在元素前後頻繁操作插入刪除 這也是 LRU Cache 常使用 Linked List 的原因之一

January 26, 2026 · 1 min · Yen Tsai

深入淺出談 Array:從記憶體配置看時間複雜度

photo by Reed Mok 想像你是一位老師,要管理一個新班級的學生名單 人數少的時候,你可以直接用名字來記住每一位學生,但當學生人數一多,要快速找到某一位學生就變得困難 情境 A:變數分散管理 如果我們像這樣單獨宣告變數來存學生名字: string student1 = "Alice"; string student2 = "Bob"; string student3 = "Charlie"; // ... 如果有 50 個學生,變數會多到無法管理 這種方式的問題在於: 沒有順序性:變數之間沒有邏輯上的關聯 無法索引:不能用「第 3 個學生」這種方式來存取 難以自動化:無法用迴圈(Loop)來批次處理這群資料 情境 B:使用 Array 統一管理 這時候,如果替每位學生分配一個固定的「座號」,並依照座號的順序將學生資料排排站(存放起來),只要知道座號,就能直接找到對應的學生 struct Student { string name; }; // 學生名單 (Array) Student students[] = { {"Alice"}, // index 0 (座號 1) {"Bob"}, // index 1 (座號 2) {"Charlie"} // index 2 (座號 3) }; const int numStudents = 3; Student* findStudentBySeatNumber(int seatNumber) { // 檢查座號是否合法 if (seatNumber < 1 || seatNumber > numStudents) { return nullptr; } // 透過索引直接取得資料 (Array Index 從 0 開始) return &students[seatNumber - 1]; } 這就是 Array 的核心精神 ...

January 23, 2026 · 2 min · Yen Tsai