Menu

C# Record: שוויון לפי ערך, ביטויי with ו-record struct

מה הם records ב-C# (מ-C# 9 ואילך): תחביר מיקום, האיברים שהקומפיילר יוצר, שוויון לפי ערך, עותקים לא הורסים עם with, ה-ToString המובנה, record struct מ-C# 10, ירושה בין records, והמחלקה המקבילה כשכותבים אותה ביד.

בדף הזה יש עורכים שאפשר להריץ - לערוך, להריץ ולראות את הפלט מיד.

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 פועלים נגד מעקב השינויים.

איור של שפות התכנות ב-Coddy

ללמוד תכנות עם Coddy

להתחיל