Queue<T> עובד כמו תור בקופה: האיבר הראשון שנוסף הוא הראשון שיוצא (FIFO, ראשון נכנס, ראשון יוצא). מוסיפים בסוף עם Enqueue ולוקחים מהראש עם Dequeue, ושתי הפעולות בזמן קבוע.
Enqueue, Dequeue ו-Peek
פלט:
Waiting: 3
Next up: invoice.pdf
Printing invoice.pdf
Next up: photo.png
Printing photo.png
Printing report.docx
Waiting: 0
Peek נועד להחליט מה לעשות עם האיבר הבא לפני שמתחייבים אליו; Dequeue מתחייב. הלולאה while (queue.Count > 0) שמוציאה איברים עד שהתור מתרוקן היא הדרך המקובלת לעבד תור, והיא ממשיכה לעבוד גם אם גוף הלולאה מוסיף איברים נוספים, מה ש-foreach לא מאפשר.
החריגה של תור ריק ו-TryDequeue
Dequeue ו-Peek על תור ריק זורקים InvalidOperationException. אין התנהגות של "null כשהתור ריק", גם לא בטיפוסי הפניה.
פלט:
Caught InvalidOperationException
Serving ticket 41
Serving ticket 42
False
TryDequeue ו-TryPeek מחזירים true ומציבים ערך במשתנה ה-out כשיש איבר, ומחזירים false כשהתור ריק. הם הגיעו ב-.NET Core 2.0; בקוד ישן של .NET Framework, בדקו Count > 0 לפני הקריאה ל-Dequeue.
הצצה לתוך תור
אפשר לעבור על תור בלי להסיר ממנו כלום. foreach, ToArray ו-Contains כולם רואים את האיברים מהראש לסוף.
פלט:
Ana Ben Chloe
True
Ana is first, 3 in line
3
אין אינדקס: line[1] לא מתקמפל, ואי אפשר להסיר איבר מהאמצע. אם צריך אחד מהם, הנתונים הם לא באמת תור; השתמשו ב-List<T> או ב-LinkedList<T>. כמו באוספים אחרים, הוספה או הוצאה בתוך foreach על אותו תור זורקת InvalidOperationException.
חיפוש לרוחב עם תור
הסדר של התור הוא בדיוק מה שחיפוש לרוחב (BFS) צריך: לבקר בכל מה שנמצא במרחק צעד אחד, אחר כך בכל מה שנמצא במרחק שני צעדים, וכן הלאה. כאן הוא מוצא כמה קשרים מפרידים בין Ana לכל השאר ברשת קטנה:
פלט:
Ana: 0 step(s) from Ana
Ben: 1 step(s) from Ana
Chloe: 1 step(s) from Ana
Dev: 2 step(s) from Ana
Eli: 2 step(s) from Ana
Fay: 3 step(s) from Ana
כל אדם נכנס לתור פעם אחת, בפעם הראשונה שמגיעים אליו, ומכיוון שהתור מחזיר אותם בסדר שבו נמצאו, הפעם הראשונה היא תמיד לאורך מסלול קצר ביותר. החליפו את התור ב-Stack ואותה לולאה הופכת לחיפוש לעומק, שכבר לא מוצא מסלולים קצרים ביותר.
אותה צורה מתאימה למעבר על עץ לפי רמות, למילוי שטח ברשת (flood fill) ולסריקת קישורים: מתחילים עם איבר אחד בתור, וכל עוד הוא לא ריק, מוציאים איבר אחד ומכניסים את השכנים שלו שעוד לא ביקרנו בהם.
Queue מול List לעבודת FIFO
אפשר להשתמש ב-List<T> כתור עם Add ו-RemoveAt(0), אבל RemoveAt(0) מזיז כל איבר שנשאר מקום אחד אחורה. ריקון של רשימה עם 100,000 איברים בדרך הזו מבצע בערך חמישה מיליארד הזזות של איברים; Queue<T> מבצע 100,000 צעדים בזמן קבוע. בפנים, תור הוא חוצץ מעגלי: הוא עוקב אחרי אינדקס ראש ואינדקס זנב בתוך מערך ומקצה מחדש רק כשהוא מתמלא.
| פעולה | Queue<T> | List<T> בתפקיד תור |
|---|---|---|
| הוספה בסוף | Enqueue, O(1) | Add, O(1) |
| הסרה מהראש | Dequeue, O(1) | RemoveAt(0), O(n) |
| הצצה בראש | Peek | list[0] |
| גישה לפי אינדקס | לא זמינה | list[i] |
ConcurrentQueue לכמה תהליכונים
Queue<T> אינו בטוח לתהליכונים. כשכמה תהליכונים מוסיפים או לוקחים איברים, השתמשו ב-ConcurrentQueue<T> מ-System.Collections.Concurrent. יש בו Enqueue, TryDequeue ו-TryPeek אבל אין Dequeue, כי כשיש תהליכונים אחרים בסביבה, "בדיקת Count ואז Dequeue" עלולה להיכשל בין שתי הקריאות.
פלט:
4000
4000 processed
עם Queue<T> רגיל במקום ConcurrentQueue<T>, הספירה הייתה יוצאת שגויה או שהתוכנית הייתה זורקת חריגה, תלוי בתזמון. לתהליכוני יצרן וצרכן שצריכים לחכות לעבודה במקום להסתובב בלולאה, BlockingCollection<T> (שעוטף ConcurrentQueue<T> כברירת מחדל) או System.Threading.Channels מוסיפים המתנה וסיום. ראו lock כדי להגן על אוסף רגיל ידנית.
PriorityQueue
כשאיברים צריכים לצאת לפי עדיפות ולא לפי סדר ההגעה (הכרטיס הדחוף ביותר קודם, המסלול הקצר ביותר עד כה באלגוריתם של Dijkstra), .NET 6 ואילך מספקים את PriorityQueue<TElement, TPriority>. ערך העדיפות הנמוך ביותר יוצא ראשון:
var triage = new PriorityQueue<string, int>();
triage.Enqueue("sprained ankle", 3);
triage.Enqueue("chest pain", 1);
triage.Enqueue("headache", 5);
while (triage.TryDequeue(out string patient, out int priority))
{
Console.WriteLine($"{priority}: {patient}");
}
// 1: chest pain
// 3: sprained ankle
// 5: headache
איברים עם עדיפות שווה יכולים לצאת בכל סדר; הוסיפו מספר רץ לעדיפות אם סדר ההגעה חייב להכריע במקרה של שוויון.
טעויות נפוצות
- הוצאה בלי בדיקה. תור ריק זורק
InvalidOperationException; הריצו לולאה עלCount > 0או השתמשו ב-TryDequeue. - קריאה ל-
Peekוציפייה שהאיבר ייעלם. רקDequeueמסיר. - הוספה לתור בתוך
foreachעל התור. זורק חריגה; עבדו עם לולאתwhileבמקום. - שיתוף
Queue<T>בין תהליכונים. השתמשו ב-ConcurrentQueue<T>או בנעילה. - שימוש ב-
List.RemoveAt(0)כתור על נתונים גדולים. כל קריאה היא O(n).
שאלות נפוצות
מה זה Queue ב-C#?
Queue<T> ב-System.Collections.Generic הוא אוסף מסוג "ראשון נכנס, ראשון יוצא" (FIFO). Enqueue מוסיף איבר בסוף, Dequeue מסיר ומחזיר את האיבר שבראש, ו-Peek מחזיר את האיבר שבראש בלי להסיר אותו. שלושתם פועלים בזמן קבוע.
מה קורה כשמבצעים Dequeue על תור ריק ב-C#?
Dequeue ו-Peek על תור ריק זורקים InvalidOperationException. בדקו קודם queue.Count > 0, או השתמשו ב-TryDequeue(out var item) וב-TryPeek(out var item), שמחזירים false במקום לזרוק חריגה (זמינים מאז .NET Core 2.0).
מה ההבדל בין Peek ל-Dequeue?
Peek מחזיר את האיבר שבראש ומשאיר אותו בתור, כך שקריאה כפולה מחזירה את אותו איבר. Dequeue מחזיר את האיבר שבראש ומסיר אותו, כך שהקריאה הבאה מחזירה את האיבר שמאחוריו.
האם Queue<T> בטוח לתהליכונים ב-C#?
לא. שני תהליכונים שקוראים ל-Enqueue או ל-Dequeue על אותו Queue<T> בו זמנית עלולים לשבש אותו. השתמשו ב-ConcurrentQueue<T> מ-System.Collections.Concurrent, שבו בטוח לקרוא ל-Enqueue ול-TryDequeue מתהליכונים רבים, או עטפו כל גישה לתור רגיל ב-lock.
למה להשתמש ב-Queue במקום ב-List?
הסרה של האיבר הראשון מ-List<T> עם RemoveAt(0) מזיזה את כל שאר האיברים, ולכן היא נעשית איטית יותר ככל שהרשימה גדלה. Queue<T> מסיר מהראש בזמן קבוע, וה-API שלו מבטא את הכוונה: האיברים מעובדים לפי סדר ההגעה.