משאבים ושגיאות בעולם מנוהל¶
נניח שהאפליקציה פותחת קובץ נתונים וקובץ אינדקס. פתיחת הקובץ הראשון מצליחה, והפתיחה השנייה נכשלת. הזיכרון של FileStream הוא מנוהל, אבל ה־handle שהוא מחזיק צורך משאב של המערכת ויכול להשאיר קובץ נעול. המתנה ל־GC אינה חוזה סביר לסגירתו. נרצה שחרור בנקודה ידועה גם כשבנאי לא מגיע לסופו.
ה־using מתרגם את חיי המשתנה ל־try/finally שמפעיל Dispose. הוא אינו מונע references נוספים לאותו אובייקט ואינו אוסר שימוש אחרי Dispose ברמת מערכת הטיפוסים. האחריות של הטיפוס היא לזהות שימוש כזה או להגדיר התנהגות אחרת באופן ברור. נבדוק את הגבול עם טיפוס שרוכש שני משאבים.
using var pair = FilePair.Open("data.bin", "index.bin");
Console.WriteLine(pair.DataLength);
sealed class FilePair : IDisposable
{
private FileStream? data;
private FileStream? index;
private FilePair(FileStream data, FileStream index)
=> (this.data, this.index) = (data, index);
public long DataLength =>
(data ?? throw new ObjectDisposedException(nameof(FilePair))).Length;
public static FilePair Open(string dataPath, string indexPath)
{
FileStream first = File.OpenRead(dataPath);
try
{
FileStream second = File.OpenRead(indexPath);
try { return new FilePair(first, second); }
catch { second.Dispose(); throw; }
}
catch { first.Dispose(); throw; }
}
public void Dispose()
{
FileStream? first = data;
FileStream? second = index;
data = null;
index = null;
try { second?.Dispose(); }
finally { first?.Dispose(); }
}
}
צרו תחילה שני קבצים קטנים כדי לבדוק הצלחה. אחר כך מחקו רק את index.bin ונסו לפתוח שוב. אחרי הכשל נסו לפתוח את data.bin ב־FileShare.None או לשנות את שמו לפי מדיניות השיתוף שבחרתם, כדי לבדוק שהרכישה הראשונה לא נשארה תלויה. החוזה של FilePair כאן חד־תהליכי; השמה ל־null אינה תחליף לסנכרון מול קריאה שמתרחשת במקביל.
ה־factory שומר את first במשתנה מקומי עד להשלמת הבנייה. כך הוא יודע מי הבעלים בכל מסלול. ה־catch הפנימי מגן גם על הכשל שביצירת המעטפת אחרי שנפתחו שני הקבצים. זה אינו תחליף להתמודדות עם סיום תהליך כפוי, אבל הוא מכסה יציאה רגילה באמצעות exception. ה־finally ב־Dispose מנסה לשחרר גם את המשאב הראשון אם השני זורק. עם זאת אם שניהם זורקים, החריגה המאוחרת תסתיר את הקודמת; API שמחויב לשמור את שתי השגיאות צריך לצבור אותן במפורש.
ה־finalizer רץ בתזמון שאין לנו שליטה עליו, על תהליכון של המערכת ובסביבה שבה סדר finalization של אובייקטים אחרים אינו מובטח. לכן לא נוסיף finalizer לכל class שמכיל FileStream. האובייקט המנוהל שכבר מחזיק את המשאב יודע לטפל בו, והוספת finalizer מיותר מגדילה את מחיר חיי האובייקט. כשנחזיק handle טבעי בעצמנו נעדיף SafeHandle, ונלמד את החוזה שלו בפרק הבא.
השחרור האסינכרוני מבוטא באמצעות IAsyncDisposable. אפשר להשתמש ב־await using כדי להמתין ל־DisposeAsync, למשל כשסגירה כוללת השלמת כתיבה. העובדה שמתודה מחזירה ValueTask אינה אומרת שהעבודה מתרחשת על thread אחר. כרגע מספיק להבין שהקורא חייב להמתין להשלמת השחרור; את מכונת המצבים ואת הביטול ננתח בפרק הבא. אין לדלג על ההמתנה רק מפני שהתוכנית ממילא עומדת לצאת.
במסלולי שגיאה נשתמש ב־throw ללא ארגומנט כדי לזרוק מחדש את אותה חריגה בתוך catch תוך שמירת המידע על מסלול הזריקה. השימוש ב־throw ex מתחיל מידע מחודש בנקודת הזריקה מחדש ומקשה על האבחון. כשצריך להעביר חריגה לזמן או להקשר אחר, ExceptionDispatchInfo מבטא את הכוונה. אין צורך לזרוק exception לכל קלט לא תקין: פרסר צפוי יכול להשתמש ב־TryParse ולהחזיר תוצאה מפורשת.
התיעוד על מימוש Dispose ועל שחרור אסינכרוני מבהיר את ההפרדה בין זיכרון לבין משאב. בביקורת קוד נשאל לא רק "מי קורא Dispose", אלא גם "מי משחרר כאשר הרכישה הבאה נכשלת".