לדלג לתוכן

גנריקה וייצוג נתונים

נרצה לחשב את סך העלויות של רשומות בלי להעתיק כל רשומה אל object. ב־C++ פתרנו בעיה דומה באמצעות templates. ב־C# הגנריקה מתוארת גם ב־metadata, וה־runtime מכיר את הארגומנטים של הטיפוס. אין זה אומר שכל שילוב יוצר תמיד עותק נפרד של קוד מכונה; מדיניות שיתוף הקוד היא פרט של המימוש. ההבדל החשוב לנו הוא שה־API יכול לשמור את הטיפוס ואת חוזה הפעולה בלי להמיר כל ערך לממשק בזמן שמירתו.

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

var packets = new Packet[] { new(12), new(30) };
Console.WriteLine(Sum<Packet>(packets));
Console.WriteLine(typeof(List<Packet>).GenericTypeArguments[0]);

static long Sum<T>(ReadOnlySpan<T> values) where T : struct, ICost
{
    long total = 0;
    foreach (ref readonly T value in values)
        total = checked(total + value.Cost);
    return total;
}

interface ICost { int Cost { get; } }
readonly record struct Packet(int Cost) : ICost;

ה־constraint מאפשר לקומפיילר לוודא שלכל T יש Cost. הקריאה על פרמטר הטיפוס יכולה להשתמש במנגנון constrained במקום לדרוש box לכל איבר. אם נשנה את האחסון ל־ICost[] ונכניס אליו Packet-ים, ההמרה כבר מתרחשת בזמן יצירת המערך. נמדוד הקצאות בנפרד מן הסכימה כדי לא להאשים את הלולאה בעלות שהופיעה בהכנת הנתונים.

הטיפוס record struct מספק שוויון מבוסס ערכים וקוד שימושי נוסף, אבל אינו מבטיח deep immutability. השוויון של שדה מערך משתמש בחוזה השוויון של המערך, ולא משווה אוטומטית כל byte. ההעתקה באמצעות with משכפלת את שדות ה־record, ולכן references פנימיים יכולים להישאר משותפים. כשנרצה בעלות על payload נצטרך להעתיק אותו או להגדיר באופן מפורש שהוא מושאל ואסור לשנותו.

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

foreach (string line in new[] { "12", "-4", "oops" })
{
    Result result = Parse(line);
    string text = result switch
    {
        Accepted { Bytes: 0 } => "empty",
        Accepted { Bytes: var count } => $"accepted {count}",
        Rejected { Reason: var reason } => $"rejected {reason}",
        _ => throw new InvalidOperationException("unknown result")
    };
    Console.WriteLine(text);
}
static Result Parse(string text) =>
    int.TryParse(text, out int value) && value >= 0
        ? new Accepted(value) : new Rejected("nonnegative integer required");
abstract record Result;
sealed record Accepted(int Bytes) : Result;
sealed record Rejected(string Reason) : Result;

ה־switch מציג property patterns ופירוק בלי casts מפוזרים. עם זאת ההיררכיה הזאת אינה union סגור שהקומפיילר מבטיח למצות בכל מקום; אפשר להוסיף טיפוס נגזר במקום אחר. המסלול האחרון מבטא את המדיניות שלנו כלפי מצב לא מוכר. הוספת טיפוס לתוצאה היא שינוי חוזה גם אם הבנייה של צרכנים ישנים עדיין מצליחה.

ה־nullable analysis עוקב אחר מצב של references במהלך זרימת השליטה. הסימון string? וה־operator של null-forgiving אינם בודקים נתון מבחוץ בזמן ריצה. כאשר מידע מגיע מ־reflection, מספרייה עם annotations חסרות או מקוד native, עדיין נצטרך לאמת אותו. נרצה שהבדיקה תופיע בנקודת הכניסה, ולא לפזר סימני ! כדי להשתיק אזהרות.

השונות של פרמטרי טיפוס מאפשרת למשל להשתמש ב־IEnumerable כ־IEnumerable, משום שהממשק מוציא ערכים וההמרה היא בין reference types. היא אינה מאפשרת להתייחס ל־List כ־List: צרכן היה יכול להכניס מספר לרשימת מחרוזות. גם המרה גנרית משתנה אינה מבצעת boxing של סדרה שלמה מערכי int אל object.

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