paint-brush
IEnumerable זה לא מה שאתה חושב שזה - וזה שובר את הקוד שלךעל ידי@dmitriislabko
היסטוריה חדשה

IEnumerable זה לא מה שאתה חושב שזה - וזה שובר את הקוד שלך

על ידי Dmitrii Slabko14m2025/02/18
Read on Terminal Reader

יותר מדי זמן; לקרוא

נסקור בפירוט את הטעות הנפוצה ביותר ביחס ל-IEnumerable - ספירה חוזרת - אך הפעם נעמיק מעט ונסקור מדוע הספירה החוזרת היא טעות ומה הפוטנציאל לבעיות שהיא עלולה לגרום, כולל באגים שקשה לתפוס ולשחזר.
featured image - IEnumerable זה לא מה שאתה חושב שזה - וזה שובר את הקוד שלך
Dmitrii Slabko HackerNoon profile picture
0-item

TLDR: בואו נסקור בפירוט את הטעות הנפוצה ביותר ביחס ל-IEnumerable - ספירה חוזרת - אבל הפעם נעמיק קצת יותר ונסקור מדוע הספירה החוזרת היא טעות ומה הפוטנציאל לבעיות שהיא עלולה לגרום, כולל באגים שקשה לתפוס ולשחזר באגים.

מה זה IEnumerable

דבר ראשון - בואו נסקור (שוב, מכיוון שיש די הרבה מאמרים על זה) מה זה IEnumerable, גם גנרי וגם לא גנרי. מפתחים רבים, כפי שמראים ראיונות רבים וביקורות קוד, רואים מבלי משים מופעים של IEnumerable כאוספים, וכאן נתחיל.


כאשר אנו מסתכלים על הגדרת הממשק של IEnumerable, הנה מה שאנו רואים:

 public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); }


לא ניכנס לפרטי מונים וכיוצא בזה; מספיק לציין דבר אחד מאוד חשוב: IEnumerable אינו אוסף. רוב סוגי האוסף אכן מיישמים את IEnumerable, אבל זה לא הופך את כל ההטמעות של IEnumerable לאוספים. באופן מפתיע, זה מה שמפתחים רבים מפספסים כשהם מיישמים קוד שצורך או מייצר IEnumerable, וזה מה שיש לו פוטנציאל גדול לבעיות.


אז מה זה IEnumerable? ישנם יישומים רבים ושונים עבור IEnumerable, אך למען הפשטות נוכל לסכם אותם להגדרה אחת (די מעורפלת): זוהי פיסת קוד שמייצרת אלמנטים באיטרציה. עבור אוספים בזיכרון הקוד הזה פשוט יקרא את האלמנט הנוכחי מהאוסף הבסיסי ויעביר את המצביע הפנימי שלו לאלמנט הבא, אם הוא קיים. עבור מקרים מתוחכמים יותר, ההיגיון עשוי להיות מגוון מאוד, ועשויות להיות כל סוג של תופעות לוואי שיכולות לכלול גם שינוי המצב המשותף, או תלוי במצב המשותף.


עכשיו יש לנו תמונה קצת יותר טובה של מה זה IEnumerable, וזה מרמז לנו ליישם את הקוד הצורך בצורה שלא אמורה לעשות שום הנחות בנקודות האלה:

  • העלות הנדרשת לייצור פריטים - כלומר, אם פריט הוחזר מאחסון כלשהו (בשימוש חוזר) או שהוא נוצר;
  • את אותו פריט ניתן לייצר אי פעם שוב באיטרציות הבאות;
  • כל תופעות לוואי אפשריות שעלולות להשפיע (או לא) על איטרציות עוקבות.


כפי שאנו יכולים לראות, זה כמעט ההפך מהמוסכמות הכלליות כאשר חוזרים על אוספים בזיכרון, למשל:

  • לא ניתן לשנות אוסף במהלך איטרציה - אם אוסף משתנה, הדבר יגרום לחריגה בעת מעבר לרכיב הבא באוסף;
  • איטרציה על אותו אוסף (המכיל את אותם אלמנטים) תמיד תניב את אותן תוצאות ותמיד יהיו אותן עלויות.


דרך בטוחה להסתכל על IEnumerable היא לתפוס אותו כ'מפיק נתונים לפי דרישה'. הערובה היחידה שמפיק נתונים זה נותן היא שהוא ירכוש פריט אחר או יאותת שאין יותר פריטים זמינים כשיקרא לו. כל השאר הם פרטי יישום של מפיק נתונים מסוים. אגב, כאן תיארנו את החוזה של ממשק IEnumerator המאפשר לבצע איטרציה על מופע של IEnumerable.


חלק חשוב נוסף של יצרן הנתונים לפי דרישה הוא שהוא מייצר פריט אחד בכל איטרציה, והקוד הצורך עשוי להחליט אם הוא רוצה למצות את כל מה שהיצרן מסוגל לייצר או להפסיק את הצריכה מוקדם יותר. מכיוון שמפיק הנתונים לפי דרישה אפילו לא ניסה לעבוד על פריטים 'עתידיים' פוטנציאליים, הדבר מאפשר לחסוך במשאבים כאשר הצריכה מסתיימת בטרם עת.


לכן, בעת יישום IEnumerable יצרנים, לעולם אל לנו להניח הנחות כלשהן לגבי דפוסי הצריכה. הצרכנים רשאים ליזום ולהפסיק את הצריכה בכל שלב.

השפעות פוטנציאליות של איטרציות חוזרות.

כעת, מכיוון שהגדרנו את הדרך הנכונה לצרוך IEnumerable, בואו נסקור כמה דוגמאות של איטרציות חוזרות ונשנות וההשפעה הפוטנציאלית שלהן.


לפני שנלך לדוגמאות שליליות, כדאי להזכיר שכאשר IEnumerable מתחזה לאוסף בזיכרון - מערך, רשימה, hashset וכו' - אין כל נזק באיטרציות חוזרות כשלעצמן. הקוד שצורך IEnumerable על פני אוספים בזיכרון ברוב המקרים ירוץ (כמעט) ביעילות כמו הקוד שצורך סוגי אוסף תואמים. כמובן, ייתכנו הבדלים במקרים מסוימים, אם כי לא בהכרח שליליים, שכן Linq ראתה שיפורי ביצועים גדולים רבים שיאפשרו, למשל, להשתמש בהוראות מעבד וקטוריות עבור אוספי זיכרון או בקריאות קומפקטיות של שיטת ממשק מרובה לאחד עבור ביטויי Linq מורכבים. אנא קרא מאמרים אלה לפרטים נוספים: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#linq ו- https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/#linq


עם זאת, מנקודת המבט של איכות הקוד, איטרציות מרובות על IEnumerable נחשבות לתרגול גרוע, מכיוון שלעולם לא נוכל להיות בטוחים איזה יישום קונקרטי יגיע מתחת למכסה המנוע.

הערה צדדית: מכיוון ש-IEnumerable הוא ממשק, השימוש בו במקום סוגי קונקרטיים מאלץ את המהדר לפלוט קריאות למתודה וירטואליות (הוראת IL 'callvirt'), גם כאשר מחלקה בסיסית קונקרטית מיישמת את השיטה הזו כלא וירטואלית, לכן קריאה למתודה לא וירטואלית תספיק. קריאות שיטות וירטואליות יקרות יותר, מכיוון שהן תמיד צריכות לעבור דרך טבלת שיטות המופע כדי לפתור את כתובת השיטה; כמו כן, הם מונעים הטמעת שיטה פוטנציאלית. למרות שזה עשוי להיחשב כמיקרו-אופטימיזציה, ישנם די הרבה נתיבי קוד שיציגו מדדי ביצועים שונים אם נעשה שימוש בסוגים קונקרטיים במקום ממשקים.

כשאיטרציה חוזרת היא באמת בחירה גרועה.

כתב ויתור קטן: דוגמה זו מבוססת על קטע קוד מהחיים האמיתיים שעבר אנונימיזציה וכל פרטי היישום האמיתיים מופשטים ממנו.

קטע קוד זה אחזר נתונים מנקודת קצה מרוחקת עבור רשימת הפרמטרים הנכנסים.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }

מה עלול להשתבש כאן? בואו נסקור את הדוגמה הפשוטה ביותר:

 var results = await RetrieveAndProcessDataAsync(ids, cancellationToken); var output = results.ToArray();


מפתחים רבים יחשבו על קוד זה בטוח - מכיוון שהוא מונע איטרציות חוזרות ונשנות על ידי מימוש פלט השיטה לאוסף בזיכרון. אבל האם זה?


לפני שנכנס לפרטים בואו נעשה ריצת מבחן. אנו יכולים לקחת יישום 'externalService' פשוט מאוד לבדיקה:

 record Data(int Value); class Service { private static int counter = 0; public async Task<IData> QueryForDataAsync(int id, CancellationToken ct) { var timestamp = Stopwatch.GetTimestamp(); await Task.Delay(TimeSpan.FromMilliseconds(30), ct); int cv = Interlocked.Increment(ref counter); Console.WriteLine($"QueryForData - id={id} - {cv}; took {Stopwatch.GetElapsedTime(timestamp).TotalMilliseconds:F0} ms"); return new Data(id); } }


אז נוכל להריץ את הבדיקה:

 var externalService = new Service(); var results = (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList(); Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } Console.WriteLine($"Getting the count again: {results.Count()}");


וקבל את הפלט:

 QueryForData - id=3 - 1; took 41 ms QueryForData - id=1 - 3; took 43 ms QueryForData - id=2 - 2; took 42 ms QueryForData - id=1 - 4; took 33 ms QueryForData - id=2 - 5; took 30 ms QueryForData - id=3 - 6; took 31 ms Querying completed Retrieved 3 elements Getting the count again: 3


משהו כאן כבוי, נכון? היינו מצפים לקבל את הפלט 'QueryForData' רק 3 פעמים, מכיוון שיש לנו רק 3 מזהים בארגומנט הקלט. עם זאת, הפלט מראה בבירור שמספר הביצועים הוכפל עוד לפני שהקריאה ToList() הושלמה.


כדי להבין את הסיבה, בואו נסתכל על שיטת RetrieveAndProcessDataAsync:

 1: var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)); 2: await Task.WhenAll(retrievalTasks); 3: return retrievalTasks.Select(t => t.Result);


ובואו נסתכל על השיחה הזו:

 (await RetrieveAndProcessDataAsync([1, 2, 3], CancellationToken.None)).ToList();


כאשר קוראים לשיטת RetrieveAndProcessDataAsync, הדברים הבאים קורים.


בשורה 1 נקבל מופע IEnumerable<Task<Data>> - במקרה שלנו, זה יהיה 3 משימות, מכיוון שאנו מגישים מערך קלט עם 3 אלמנטים. כל משימה מועברת בתור על ידי מאגר השרשורים לביצוע, וברגע שיש שרשור זמין, היא מתחילה. נקודת ההשלמה המדויקת של משימות אלו אינה ידועה עקב תזמון מאגר השרשורים והחומרה הקונקרטית שקוד זה יפעל עליה.


בשורה 2 הקריאה Task.WhenAll מוודאת שכל המשימות ממופע IEnumerable<Task<Data>> הגיעו לסיום; בעצם, בשלב זה אנו מקבלים את 3 הפלטים הראשונים משיטת QueryForDataAsync. כאשר שורה 2 מסתיימת, אנו יכולים להיות בטוחים שגם כל 3 המשימות הושלמו.


עם זאת, קו 3 הוא המקום שבו כל השדים ארבו. בואו נחשוף אותם.


המשתנה 'retrievalTasks' (בשורה 1) הוא מופע IEnumerable<Task<Data>> . כעת, בואו ניקח צעד אחורה ונזכור ש-IEnumerable אינו אלא מפיק - חתיכת קוד שמייצר (יוצר או עושה שימוש חוזר) במופעים מסוג נתון. במקרה זה המשתנה 'retrievalTasks' הוא פיסת קוד שתעשה:


  • לעבור על אוסף 'ID';
  • עבור כל רכיב של אוסף זה, הוא יקרא לשיטת externalService.QueryForDataAsync;
  • להחזיר מופע משימה שהופק מהקריאה הקודמת.


אנחנו יכולים לבטא את כל ההיגיון הזה מאחורי מופע IEnumerable<Task<Data>> שלנו בצורה מעט שונה. אנא שים לב שבעוד שקטע קוד זה נראה די נבדל מהביטוי המקורי ids.Select(id => externalService.QueryForDataAsync(id, ct)) , הוא עושה בדיוק את אותו הדבר.


 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } }


אז, אנחנו יכולים להתייחס למשתנה 'retrievalTasks' כקריאה לפונקציה עם קבוצה קבועה מראש של תשומות. פונקציה זו תיקרא בכל פעם שנפתור את ערך המשתנה. אנו יכולים לשכתב את שיטת RetrieveAndProcessDataAsync באופן שישקף את הרעיון הזה במלואו, ושתעבוד באופן שווה לחלוטין ליישום הראשוני:


 async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); return retrievalFunc().Select(t => t.Result); }


כעת אנו יכולים לראות בבירור מדוע פלט קוד הבדיקה שלנו הוכפל: הפונקציה 'retrievalFunc' נקראת פעמיים... אם הקוד הצורך שלנו ימשיך לעבור על אותו מופע IEnumerable, זה ישתווה לקריאות החוזרות ונשנות לשיטת 'DataProducer', שתפעיל את ההיגיון שלה שוב ושוב עבור כל חזרה.


אני מקווה שעכשיו ההיגיון מאחורי איטרציות חוזרות ונשנות של IEnumerable ברור.

השלכות אפשריות נוספות של איטרציות חוזרות ונשנות.

עם זאת, יש עדיין דבר אחד להזכיר לגבי דגימת הקוד הזו.


בואו נסתכל שוב על היישום המשוכתב:

 IEnumerable<Task<Data>> DataProducer(IList<int> ids, CancellationToken ct) { foreach (int id in ids) { var task = externalService.QueryForData(id, ct); yield return task; } } async Task<IEnumerable<Data>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalFunc = () => DataProducer(ids, ct); await Task.WhenAll(retrievalFunc()); // First producer call. return retrievalFunc().Select(t => t.Result); // Second producer call. }


המפיק במקרה זה יוצר מופעי משימה חדשים בכל פעם, ואנחנו קוראים לזה פעמיים. זה מוביל לעובדה די מוזרה ולא כל כך ברורה שכאשר אנו קוראים Task.WhenAll ו- .Select(t => t.Result) מופעי המשימה ששני חלקי הקוד הללו פועלים עליהם שונים. המשימות שחיכו להן (ולכן הגיעו לסיומן) אינן אותן משימות שהשיטה מחזירה מהן את התוצאות.


אז, כאן המפיק יוצר שתי קבוצות שונות של משימות. לסט הראשון של המשימות ממתינים באופן אסינכרוני - הקריאה Task.WhenAll - אך לא ממתין לסט השני של המשימות. במקום זאת, הקוד קורא ישירות למאפיין Result , שהוא למעשה האנטי-דפוס הידוע לשמצה של סינכרון-על-אסינכרון. לא הייתי נכנס לפרטים של האנטי-דפוס הזה, מכיוון שזה נושא גדול. מאמר זה של סטיבן טוב שופך עליו לא מעט אור: https://devblogs.microsoft.com/pfxteam/should-i-expose-synchronous-wrappers-for-asynchronous-methods/


עם זאת, רק למען השלמות, הנה כמה בעיות אפשריות שקוד זה עלול לגרום:

  • מבוי סתום, כאשר משתמשים בשולחן העבודה (WinForms, WPF, MAUI) או ביישומי .Net Fx ASP.NET;
  • הרעבה של בריכת חוטים בעת עומסים גבוהים יותר.


אם נפשט מדגימת הקוד הנוכחית שהפיקה את המשימות הפשוטות הללו, אנו עומדים בפני עובדה שהאיטרציות החוזרות עלולות לגרום בקלות לביצועים מרובים עבור כל פעולה, וייתכן שהיא לא תהיה חסרת עוצמה (כלומר, קריאות עוקבות עם אותן כניסות מחויבות להניב תוצאות שונות או אפילו פשוט להיכשל). לדוגמה, יתרת החשבון משתנה.


גם אם פעולות אלו היו חסרות יכולת, עשויות להיות להן עלויות חישוב גבוהות, ולפיכך הביצוע החוזר שלהן פשוט ישרוף את המשאבים שלנו לשווא. ואם אנחנו מדברים על קוד שפועל בענן, למשאבים האלה עשויה להיות עלות שעליה נצטרך לשלם.


שוב, מכיוון שחזרות חוזרות ונשנות על פני מספר מופעים של IE די קל לפספס, זה עשוי להיות קשה מאוד לגלות מדוע אפליקציה קורסת, מוציאה משאבים רבים (כולל כסף), או עושה דברים שהיא לא אמורה לעשות.

מתבלים את הדברים רק קצת.

בואו ניקח את קוד הבדיקה המקורי ונשנה אותו מעט:

 var externalService = new Service(); var cts = new CancellationTokenSource(); // New line. var results = (await RetrieveAndProcessDataAsync([1, 2, 3], cts.Token)); // Using cts.Token instead of a default token, and not materializing the IEnumerable. Console.WriteLine("Querying completed"); int count = results.Count(); if (count == 0) { Console.WriteLine("No results"); } else { var array = results.ToArray(); Console.WriteLine($"Retrieved {array.Length} elements"); } cts.Cancel(); // New line. Console.WriteLine($"Getting the count again: {results.Count()}");


אשאיר לקורא לנסות ולהפעיל את הקוד הזה. זו תהיה הדגמה טובה של תופעות לוואי אפשריות שהאיטרציות החוזרות עלולות להיתקל במפתיע.

איך לתקן את הקוד הזה?

בואו נסתכל:

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); // Adding .ToArray() call. await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


על ידי הוספת קריאה בודדת .ToArray() ל- IEnumerable<Task<Data>> הראשוני היינו 'מממשים' את המופע של IEnumerable לאוסף בזיכרון, וכל חזרות עוקבות על אוסף הזיכרון עושות בדיוק מה שהיינו מניחים - פשוט קוראים את הנתונים מהזיכרון ללא כל תופעות לוואי בלתי צפויות שנגרמות על ידי ביצועים חוזרים של קוד.


בעיקרו של דבר, כשמפתחים כותבים קוד כזה (כמו בדגימת הקוד הראשונית), הם בדרך כלל מניחים שהנתונים האלה 'חצובים באבן', ושום דבר בלתי צפוי לעולם לא יקרה כשייגשו אליהם. אם כי, כפי שראינו זה עתה, זה די רחוק מהאמת.


נוכל לשפר את השיטה עוד יותר, אבל נשאיר זאת לפרק הבא.

על הפקת IEnumerable.

הרגע הסתכלנו על הבעיות שעלולות להתעורר מהשימוש ב-IEnumerable כאשר הוא מבוסס על תפיסות שגויות - כאשר הוא אינו לוקח בחשבון שאף אחת מההנחות הללו לא צריכה להתבצע בעת צריכת IEnumerable:


  • העלות הנדרשת לייצור פריטים - כלומר אם פריט הוחזר מאחסון כלשהו (בשימוש חוזר) או שהוא נוצר;
  • אם ניתן לייצר שוב את אותו פריט באיטרציות הבאות;
  • כל תופעות לוואי אפשריות שעלולות להשפיע (או לא) על איטרציות עוקבות.


כעת, בואו נסתכל על ההבטחה שמספר יצרנים צריכים (אידיאלי) לקיים עבור הצרכנים שלהם:

  • פריטים מיוצרים 'לפי דרישה' - לא אמורים להתאמץ 'מראש';
  • הצרכנים חופשיים להפסיק איטרציה בכל רגע, וזה אמור לחסוך את המשאבים שיידרשו אם הצריכה תימשך;
  • אם איטרציה (צריכה) לא התחילה, אין להשתמש במשאבים.


שוב, בואו נסקור את דגימת הקוד הקודמת שלנו מנקודת מבט זו.

 async Task<IEnumerable<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result); }


בעיקרו של דבר, קוד זה אינו מקיים את ההבטחות הללו, שכן כל ההרמה הקשה נעשית בשתי השורות הראשונות, לפני שהוא מתחיל לייצר את ה-IEnumerable. לכן, אם צרכן כלשהו יחליט להפסיק את הצריכה מוקדם יותר, או אפילו לא יתחיל אותה כלל, שיטת QueryForDataAsync עדיין תיקרא עבור כל התשומות.


בהתחשב בהתנהגות של שתי השורות הראשונות, יהיה הרבה יותר טוב לשכתב את השיטה כדי לייצר אוסף בזיכרון, כגון:

 async Task<IList<IData>> RetrieveAndProcessDataAsync(IList<int> ids, CancellationToken ct) { var retrievalTasks = ids.Select(id => externalService.QueryForDataAsync(id, ct)).ToArray(); await Task.WhenAll(retrievalTasks); return retrievalTasks.Select(t => t.Result).ToArray(); }


יישום זה אינו מספק ערבויות 'על פי דרישה' - להיפך, ברור מאוד שכל העבודה הנדרשת לעיבוד הקלט הנתון תושלם, והתוצאות התואמות יוחזרו.


עם זאת, אם אכן אנו זקוקים להתנהגות 'מפיק נתונים לפי דרישה', יהיה צורך לשכתב את השיטה לחלוטין כדי לספק אותה. לְדוּגמָה:

 async IAsyncEnumerable<Data> RetrieveAndProcessDataAsAsyncEnumerable(IList<int> ids, [EnumeratorCancellation] CancellationToken ct) { foreach (int id in ids) { var result = await externalService.QueryForData(id, ct); yield return result; } }


בעוד שמפתחים בדרך כלל לא חושבים על פרטי החוזה של IEnumerable, קוד אחר שצורך אותו יוצר לעתים קרובות הנחות התואמות את הפרטים הללו. לכן, כאשר הקוד המייצר את IEnumerable תואם לפרטים הספציפיים האלה, כל היישום יעבוד טוב יותר.

מַסְקָנָה.

אני מקווה שמאמר זה עזר לקורא לראות את ההבדל בין חוזה גבייה לבין פרטי החוזה של IEnumerable. אוספים בדרך כלל מספקים אחסון לפריטים שלהם (בדרך כלל בזיכרון) ודרכים לעבור על הפריטים המאוחסנים; אוספים שאינם ניתנים לקריאה בלבד מרחיבים את החוזה הזה בכך שהם מאפשרים לשנות/להוסיף/להסיר את הפריטים המאוחסנים. בעוד שהאוספים עקביים מאוד לגבי הפריטים המאוחסנים, ה-IEnumerable בעצם מצהיר על תנודתיות גבוהה מאוד בהקשר זה, מכיוון שהפריטים מיוצרים כאשר עוברים על מופע של IEnumerable.


אז מה יהיו השיטות המומלצות כשמגיעים ל-IEnumerable? בואו רק ניתן את רשימת הנקודות:

  • הימנעו תמיד מאיטרציות חוזרות ונשנות - אלא אם כן אתם באמת מתכוונים ומבינים את ההשלכות. זה בטוח לשרשר מספר שיטות סיומת Linq למופע של IEnumerable (כגון .Where ו- .Select ) אבל כל קריאה אחרת שתגרום לאיטרציה ממשית היא הדבר שיש להימנע ממנו. אם הלוגיקה של העיבוד דורשת מעברים מרובים על IEnumerable, או מימש אותו לאוסף בזיכרון או בדוק אם ניתן לשנות את ההיגיון למעבר יחיד על בסיס לכל פריט.
  • כאשר הפקת IEnumerable כרוכה בקוד אסינכרון, שקול לשנות אותו ל-IAsyncEnumerable או להחליף את IEnumerable בייצוג 'מממש' - למשל, כאשר תעדיף לנצל את הביצוע המקביל, ולהחזיר את התוצאות לאחר השלמת כל המשימות.
  • קוד המייצר IEnumerable צריך להיות בנוי בצורה שתאפשר הימנעות מהוצאת משאבים אם האיטרציה תיעצר מוקדם יותר או לא תתחיל בכלל.
  • אל תשתמש ב-IEnumerable עבור סוגי נתונים אלא אם כן אתה צריך את הפרטים שלו. אם הקוד שלך צריך מידה מסוימת של 'הכללה', העדיפו ממשקים מסוג איסוף אחרים שאינם מרמזים על התנהגות 'מפיק נתונים לפי דרישה', כגון IList או IReadOnlyCollection.