record הוא טיפוס שהתפקיד העיקרי שלו הוא להחזיק נתונים, והשוויון שלו מוגדר לפי הנתונים האלה. records הגיעו ב-C# 9. כותבים שורה אחת, והקומפיילר יוצר את האיברים שמחלקת נתונים צריכה: מאפיינים, בנאי, Equals ו-== לפי ערך, GetHashCode, ToString קריא, Deconstruct ותמיכה בעותקי with.
records דורשים C# 9 ואילך (.NET 5 ומעלה), ולכן קוד ה-records בדף הזה מוצג כ-C# רגיל, עם הפלט בהערות. הפרק האחרון כותב את אותם איברים ביד ב-C# 7, ואותו אפשר להריץ.
records מיקומיים
הצורה הקצרה ביותר מפרטת את המאפיינים בסוגריים אחרי השם. כל פרמטר הופך למאפיין ציבורי מסוג init בלבד עם אותו שם:
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
מהשורה האחת הזו הקומפיילר יוצר:
- בנאי שמקבל
(string Sku, string Name, decimal Price); - שלושה מאפיינים מסוג
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()ואת האופרטורים==ו-!=, שכולם משווים את שלושת המאפיינים;ToString()שמדפיס את שם הטיפוס ואת כל המאפיינים הציבוריים;Deconstruct(out string Sku, out string Name, out decimal Price);- בנאי העתקה (protected, או private ב-record חתום) ומתודת שכפול נסתרת ש-
withמשתמש בה.
אפשר גם לכתוב record עם גוף רגיל, וזה שימושי כשמאפיינים צריכים ערכי ברירת מחדל או אימות:
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
ואפשר לשלב את שתי הצורות: פרמטרי מיקום ועוד איברים נוספים בסוגריים מסולסלים.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
שוויון לפי ערך
במחלקה רגילה, == שואל "האם זה אותו אובייקט?". ב-record הוא שואל "האם יש להם אותם ערכים?":
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode נוצר בהתאם, כך ש-records עובדים נכון כמפתחות ב-dictionary וב-HashSet<T>: record שני עם אותם ערכים מוצא את הרשומה של הראשון.
השוויון משווה כל שדה (ב-records מיקומיים, את השדה שמאחורי כל מאפיין) עם EqualityComparer<T>.Default, שקורא ל-Equals של הטיפוס עצמו. במאפיין שהוא אוסף זה שוויון לפי הפניה, וזה מפתיע אנשים:
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
אם record מחזיק אוסף וצריך להשוות לפי התוכן שלו, דרסו את Equals(Basket other) ואת GetHashCode(), או השתמשו באוסף בלתי ניתן לשינוי עם סמנטיקת ערך משלכם.
ביטויי with: שינויים לא הורסים
records הם בדרך כלל בלתי ניתנים לשינוי, ולכן "משנים" אחד על ידי יצירת עותק משונה. with מעתיק כל מאפיין, ואז מחיל את ההשמות שבסוגריים המסולסלים:
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
העותק רדוד. מאפיין מטיפוס הפניה מועתק כהפניה, כך ששני ה-records חולקים את האובייקט:
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
או שתשמרו על מאפייני ה-record בלתי ניתנים לשינוי עד הסוף (IReadOnlyList<T> שממולא פעם אחת, או ImmutableList<T>), או שתיצרו רשימה חדשה בתוך ה-with: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
ה-ToString שנוצר מדפיס את שם הטיפוס ואת כל המאפיינים הציבוריים, וזה הופך records לנוחים בלוגים ובדיבאגר:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
אוספים מודפסים כשם הטיפוס שלהם (System.Collections.Generic.List`1[System.String]), ו-records מקוננים מודפסים באופן רקורסיבי. אפשר להחליף את כל הפלט על ידי דריסה של ToString:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record לבדו פירושו record class: טיפוס הפניה. C# 10 הוסיפה את record struct, טיפוס ערך עם אותם איברים שנוצרים:
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
כדאי לזכור את ההבדל בברירות המחדל: ל-record struct מיקומי יש מאפיינים ניתנים לשינוי ({ get; set; }), בהתאם לאופן שבו structs מתנהגים בדרך כלל, ואילו ל-readonly record struct ול-record class יש מאפייני init בלבד. בחרו ביניהם כמו שהייתם בוחרים בין struct ל-class: ערכים קטנים שמועתקים בחופשיות מתאימים ל-readonly record struct; כל השאר, record.
ירושה
record יכול לרשת מ-record אחר (לא ממחלקה, ומחלקה לא יכולה לרשת מ-record). פרמטרי מיקום מועברים למחלקת הבסיס כמו ארגומנטים של בנאי:
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
השוויון כולל את טיפוס זמן הריצה, דרך מאפיין EqualityContract שנוצר אוטומטית. לכן Circle אף פעם לא שווה ל-Square עם אותו Color, אף ששניהם מושווים דרך Shape, ולכן ToString ו-with עובדים על הטיפוס הנגזר גם כשהמשתנה מוגדר מטיפוס הבסיס.
אותו דבר ב-C# 7: מחלקה עם שוויון לפי ערך
records יוצרים קוד שאפשר לכתוב בעצמכם, וכשרואים אותו מבינים את ההתנהגות שלהם. הנה מחלקה ב-C# 7 שמקבילה ל-public record Point(int X, int Y);: מאפייני קריאה בלבד, בנאי, Deconstruct, שוויון לפי ערך, קוד גיבוב תואם, ==, ToString, ומתודת With שמחליפה את ביטוי ה-with.
פלט:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
בערך 30 שורות בשביל שני מאפיינים, וכל מאפיין חדש מחייב לגעת שוב בבנאי, ב-Deconstruct, ב-Equals, ב-GetHashCode וב-ToString. שכחה של אחד מהם היא באג קלאסי (שתי נקודות שהן == אבל עם גיבוב שונה, כך ש-HashSet מאבד אותן). את התחזוקה הזו records חוסכים.
המחלקה sealed בכוונה: שוויון לפי ערך בשילוב עם ירושה דורש את בדיקת הטיפוס הנוספת ש-records יוצרים דרך EqualityContract, וחתימת המחלקה עוקפת את הבעיה.
מתי להשתמש ב-record
records מתאימים לנתונים שמוגדרים לפי הערכים שלהם ולא משתנים אחרי היצירה:
- מודלים של בקשה ותגובה ל-API של web;
- הודעות, פקודות ואירועים שעוברים בין חלקי מערכת;
- אובייקטים של הגדרות ואפשרויות;
- מפתחות מורכבים למילון (
record CacheKey(string Region, int Year)); - תוצאות של חישוב (
record PriceQuote(decimal Net, decimal Tax)).
הם מתאימים פחות כשהזהות חשובה יותר מהערכים: ישות של Entity Framework היא "לקוח 42" גם אחרי שהשם שלה משתנה, ומעקב השינויים של EF Core נשען על זהות לפי הפניה. שם השתמשו במחלקה.
טעויות נפוצות
- ציפייה לשוויון עמוק באוספים. מאפיין
List<T>משווה לפי הפניה. שני records עם רשימות שנראות שוות אינם שווים. - ציפייה ש-
withיבצע העתקה עמוקה. אובייקטים ואוספים מקוננים משותפים למקור ולעותק. record structמיקומי שניתן לשינוי בטעות. הוסיפוreadonlyאלא אם אתם רוצים מאפיינים שאפשר להציב בהם.- שימוש ב-records כישויות של EF Core. שוויון לפי ערך והעתקה מתנגשים עם מעקב שינויים.
- הוספת record לפרויקט C# 8. records דורשים C# 9 (ברירת המחדל ב-.NET 5 ואילך). ביעדים ישנים יותר, כתבו את המחלקה ביד כמו שמוצג למעלה.
שאלות נפוצות
מה זה record ב-C#?
record (C# 9) הוא מחלקה, או עם record struct (C# 10) struct, שעבורה הקומפיילר יוצר שוויון לפי ערך, ToString() קריא, מתודת Deconstruct ותמיכה בעותקי with. public record Product(string Sku, decimal Price); הוא טיפוס שלם עם שני מאפייני init בלבד. שני records עם ערכי מאפיינים שווים הם שווים, אף שהם אובייקטים שונים.
מה ההבדל בין record ל-class ב-C#?
record הוא מחלקה מתחת לפני השטח, ולכן הוא טיפוס הפניה ויכול לרשת מ-records אחרים. ההבדלים הם באיברים שנוצרים אוטומטית: records משווים לפי ערך (== ו-Equals בודקים כל שדה), מדפיסים את המאפיינים שלהם מ-ToString(), ותומכים ב-with. מחלקה רגילה משווה לפי הפניה ומדפיסה את שם הטיפוס שלה, אלא אם כותבים את האיברים האלה בעצמכם.
מה עושה ביטוי with ב-C#?
var sale = product with { Price = 6.00m }; יוצר record חדש שמעתיק כל מאפיין של product ואז מציב את אלה שברשימה. המקורי לא משתנה. העותק רדוד: מאפיין List<T> משותף לשני ה-records, כך שהוספה אליו דרך אחד נראית גם דרך השני.
מה זה record struct ב-C#?
record struct (C# 10) הוא טיפוס ערך עם אותם איברים שנוצרים כמו ב-record class: שוויון לפי ערך, ToString, Deconstruct ו-with. בניגוד ל-record class, מאפייני המיקום שלו ניתנים לשינוי כברירת מחדל; הצהירו עליו כ-readonly record struct כדי להפוך אותם ל-init בלבד. השתמשו בו לערכים קטנים כמו קואורדינטות או סכומי כסף.
מתי להשתמש ב-record ב-C#?
לנתונים שהזהות שלהם היא הערכים שלהם: DTOs, מודלים של בקשה ותגובה ב-API, הודעות ואירועים, הגדרות, ומפתחות למילונים. הימנעו מ-records לישויות שמשתנות לאורך זמן ומזוהות לפי id, כמו ישויות של Entity Framework, כי שוויון לפי ערך ועותקי with פועלים נגד מעקב השינויים.