struct הוא טיפוס ערך שאתם מגדירים בעצמכם. הוא נראה כמו מחלקה, עם שדות, מאפיינים, מתודות ובנאים, אבל משתנה מטיפוס struct מכיל את הנתונים ישירות במקום הפניה לאובייקט. ההבדל האחד הזה משנה את ההתנהגות של השמה, קריאות למתודות, שוויון ו-null.
סמנטיקת ערך: השמה מעתיקה
הטיפוסים המספריים המובנים, bool, char, DateTime, TimeSpan ו-Guid הם כולם structs. ה-structs שלכם עובדים באותו אופן:
פלט:
struct: s1.X = 1, s2.X = 50
class: c1.X = 50, c2.X = 50
after Move: s1.X = 1, c1.X = 150
עם ה-struct, s2 הוא עותק עצמאי, וגם Move עובדת על עותק משלה. כדי לאפשר למתודה לשנות את ה-struct של הקורא, העבירו אותו לפי הפניה עם ref (ראו ref ו-out). כדי להימנע מהעתקה של struct גדול בלי לאפשר שינויים, C# 7.2 הוסיפה פרמטרי in.
השלכות נוספות של היותו טיפוס ערך:
- משתנה struct לעולם לא יכול להיות
null.PointS p = null;לא מתקמפל. השתמשו ב-PointS?(טיפוס ערך שמאפשר null) כש"אין ערך" הוא מצב בעל משמעות. - struct שלא אותחל הוא כולו אפסים: שדות מספריים
0, שדותboolהםfalse, שדות הפניהnull. - struct לא יכול לרשת מ-struct או ממחלקה אחרים, ושום דבר לא יכול לרשת מ-struct. הוא יכול לממש ממשקים.
- משתני struct מקומיים חיים בדרך כלל על המחסנית או בתוך האובייקט שמכיל אותם, כך שיצירה שלהם לא מקצה ב-heap.
המלכודת של List של structs: CS1612
מכיוון שקריאה של struct מייצרת עותק, השורה התמימה הזו לא מתקמפלת:
var points = new List<PointS> { new PointS(1, 2) };
points[0].X = 10;
// error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable
האינדקסר של List<T> הוא מתודה שמחזירה עותק של האיבר. הגדרת X על העותק הזמני הזה לא הייתה משנה כלום, ולכן הקומפיילר עוצר אתכם. אותה שגיאה מופיעה כשמאפיין מחזיר struct: order.Location.X = 10. העתיקו, שנו, כתבו בחזרה:
פלט:
35
7
מערכים הם היוצא מן הכלל: array[0] הוא האיבר עצמו, לא עותק. הכאב החוזר הזה הוא הסיבה לעצה המקובלת להפוך structs לבלתי ניתנים לשינוי: אם אי אפשר לשנות struct, לא מפסידים כלום משינוי של עותק.
בנאים וערכי ברירת מחדל
ב-C# 7 עד 9, הכללים לבנאים של struct נוקשים:
- אי אפשר להצהיר על בנאי בלי פרמטרים.
new PointS()תמיד קיים ומאפס כל שדה. - בנאי שכן מצהירים עליו חייב להשים ערך לכל שדה (ומאפיין אוטומטי) לפני שהוא חוזר.
- מאתחלי שדות (
public int X = 1;) אסורים על שדות מופע.
פלט:
19.90 EUR
0.00 (none)
0.00 (none)
0.00 (none)
Money מראה גם למה ערך ברירת המחדל חשוב: ל-Money מאופס יש מטבע null, והקוד שלכם חייב לטפל בזה, כי מערכים, default(T) ושדות שלא אותחלו מייצרים כולם ערך כזה.
גרסאות חדשות יותר הקלו על הכללים האלה:
// C# 10: explicit parameterless constructors and field initializers
public struct Settings
{
public int Retries = 3;
public Settings() { }
}
// C# 11: fields you do not assign in a constructor are zeroed automatically,
// instead of being a compile error.
בנאי בלי פרמטרים של C# 10 רץ עבור new Settings() אבל לא עבור default(Settings) או איברי מערך, שעדיין כולם אפסים. הפיצול הזה מפתיע אנשים, אז השתמשו בו בזהירות.
readonly struct (C# 7.2)
סימון ה-struct עצמו כ-readonly גורם לקומפיילר לאכוף אי-שינוי: כל שדה חייב להיות readonly וכל מאפיין אוטומטי לקריאה בלבד.
public readonly struct Temperature
{
public double Celsius { get; }
public Temperature(double celsius) { Celsius = celsius; }
public Temperature WarmerBy(double delta) => new Temperature(Celsius + delta); // returns a new value
}
מעבר לתיעוד הכוונה, זה עוזר לביצועים: כש-struct שאינו readonly נשמר בשדה readonly או מועבר כפרמטר in, הקומפיילר מעתיק אותו לפני כל קריאה למתודה (הוא לא יכול לדעת שהמתודה לא תשנה אותו). readonly struct לא צריך עותקים הגנתיים כאלה. ב-C# 7.0 עדיין אפשר להפוך כל שדה ל-readonly, כמו ש-Money עושה עם מאפיינים לקריאה בלבד: זה נותן את אי-השינוי, אבל לא את חיסכון העותקים, כי הקומפיילר סומך רק על struct שהוצהר כ-readonly.
שוויון
Equals על struct משווה שדה אחר שדה כברירת מחדל, וזו התנהגות הערך שרוצים. אבל המימוש של ברירת המחדל (ValueType.Equals) עשוי להשתמש ב-reflection והוא איטי, והאופרטור == לא מוגדר בכלל: a == b על struct שלכם היא שגיאה CS0019. ממשו את שניהם כשמשווים את ה-struct:
פלט:
True
True
True
מימוש IEquatable<T> חשוב לאוספים: HashSet<T>, Dictionary<TKey, TValue> ו-List<T>.Contains קוראים ישירות ל-Equals(GridCell) במקום לבצע boxing לכל ערך כדי לקרוא ל-Equals(object). Record structs (בהמשך) מייצרים את כל זה עבורכם.
Boxing
המרה של struct ל-object או לטיפוס ממשק מבצעת לו boxing: סביבת הריצה מעתיקה את הערך לאובייקט חדש ב-heap. מאותו רגע ה-box והמקור בלתי תלויים:
פלט:
2
0
boxing עולה הקצאה בכל פעם, ולכן אוספים לא גנריים ישנים כמו ArrayList היו איטיים עם טיפוסי ערך, ולכן generics החליפו אותם. זה גם אומר ש-struct שניתן לשינוי ושניגשים אליו דרך ממשק משתנה בתוך ה-box שלו, לא במקור, וזו עוד סיבה לשמור על structs בלתי ניתנים לשינוי.
struct מול class
| struct | class | |
|---|---|---|
| סוג | טיפוס ערך | טיפוס הפניה |
| השמה ופרמטרים | מעתיקים את הנתונים | מעתיקים את ההפניה |
יכול להיות null | לא (T? יכול) | כן |
| ערך ברירת מחדל | כל השדות מאופסים | null |
| ירושה | אין; יכול לממש ממשקים | מחלקת בסיס אחת, ממשקים |
== | לא מוגדר אלא אם מעמיסים אותו | שוויון הפניות אלא אם הועמס |
Equals כברירת מחדל | משווה שדות | משווה הפניות |
| הקצאה | בתוך השורה (מחסנית או האובייקט המכיל) | heap, עם איסוף זבל |
| מתאים ל | ערכים קטנים ובלתי ניתנים לשינוי | ישויות, מצב גדול או משותף |
מתי להשתמש ב-struct
בחרו ב-struct כשכל התנאים האלה מתקיימים: הטיפוס הוא ערך לוגי אחד (קואורדינטה, סכום כסף, צבע, טווח תאריכים), הוא קטן (ההנחיה של Microsoft היא בערך 16 בתים, בערך ארבעה int), הוא בלתי ניתן לשינוי, ולא צריך ירושה. structs משתלמים כשיוצרים מהם הרבה מאוד, למשל מיליוני נקודות במערך, כי הם חוסכים הקצאה ב-heap ורשומה של אוסף הזבל לכל פריט.
בחרו ב-class לכל דבר עם זהות (לקוח, הזמנה), לכל דבר גדול, לכל דבר שמשתנה מכמה מקומות, ובכל פעם שאתם לא בטוחים. struct גדול שניתן לשינוי נותן לכם את עלויות ההעתקה ובנוסף את הבלבול של שינוי עותקים.
record struct (C# 10)
C# 10 הוסיפה את record struct, שמייצר שוויון ערכים, ==, ToString ותמיכה ב-with עבור struct בשורה אחת:
public readonly record struct Coordinate(double Lat, double Lng);
var home = new Coordinate(38.72, -9.14);
var same = new Coordinate(38.72, -9.14);
Console.WriteLine(home == same); // True
Console.WriteLine(home); // Coordinate { Lat = 38.72, Lng = -9.14 }
var north = home with { Lat = 38.80 };
ל-record struct רגיל יש מאפיינים פוזיציוניים שניתנים לשינוי; readonly record struct הופך אותם ל-init-only, וזה בדרך כלל מה שרוצים. העמוד על records מכסה את גרסת המחלקה ומראה את האיברים שהקומפיילר כותב.
טעויות נפוצות
- שינוי struct דרך עותק.
list[0].X = 1(CS1612), משתנה שלforeach(CS1654), התוצאה של getter של מאפיין. כתבו את העותק ששיניתם בחזרה, או הפכו את ה-struct לבלתי ניתן לשינוי. - structs גדולים. כל השמה וכל קריאה מעתיקות את כולו. מעבר לכמה שדות, מחלקה בדרך כלל מהירה יותר.
- שכחה של ברירת המחדל המאופסת. מערכים ו-
default(T)יוצרים ערכי struct בלי להריץ את הבנאי שלכם, כך ששדות שאתם מאמתים שם עדיין יכולים להיות אפס אוnull. - השוואה עם
==בלי להגדיר אותו. CS0019. ממשו אתIEquatable<T>ואת האופרטורים, או השתמשו ב-record struct. - structs שניתנים לשינוי מאחורי ממשקים. boxing אומר שהשינוי קורה בעותק שאתם לא מסתכלים עליו.
שאלות נפוצות
מה זה struct ב-C#?
struct הוא טיפוס ערך שמוגדר על ידי המשתמש: struct Point { public int X; public int Y; }. משתנה מטיפוס struct מחזיק את הנתונים עצמם ולא הפניה לאובייקט, כך שהשמה והעברה למתודה מעתיקות את כל הערך. int, double, DateTime ו-Guid הם כולם structs.
מה ההבדל בין struct ל-class ב-C#?
class הוא טיפוס הפניה: משתנים חולקים אובייקט אחד, ו-null מותר. struct הוא טיפוס ערך: לכל משתנה יש עותק משלו, הוא לא יכול להיות null (אלא אם משתמשים ב-Point?), הוא לא יכול לרשת או לשמש בסיס לירושה, וערך ברירת המחדל שלו הוא כל השדות מאופסים. structs מתאימים לערכים קטנים ובלתי ניתנים לשינוי; מחלקות מתאימות לישויות עם זהות והתנהגות.
מתי כדאי להשתמש ב-struct במקום ב-class ב-C#?
כשהטיפוס מייצג ערך קטן אחד (קואורדינטה, סכום כסף, טווח תאריכים), רצוי שיהיה בלתי ניתן לשינוי, משווים אותו לפי התוכן שלו, ונוצרים ממנו מספרים גדולים של מופעים כך שחשוב להימנע מהקצאות ב-heap. ההנחיות של Microsoft מוסיפות גודל של בערך 16 בתים או פחות. אם לטיפוס יש זהות, הרבה שדות, או שמשנים אותו דרך הפניות, השתמשו ב-class.
למה מתקבלת השגיאה "Cannot modify the return value because it is not a variable"?
זו שגיאה CS1612, שמגיעה בדרך כלל מ-list[0].X = 5 על List<Point> של structs. האינדקסר מחזיר עותק של ה-struct, כך ששינוי העותק היה הולך לאיבוד, והקומפיילר מסרב. העתיקו את האיבר למשתנה, שנו אותו והשימו אותו בחזרה: var p = list[0]; p.X = 5; list[0] = p;. למערכים אין את הבעיה הזו כי array[0] מפנה לאיבר עצמו.
מה זה readonly struct ב-C#?
readonly struct (C# 7.2) מצהיר שאף איבר של ה-struct לא משנה את המצב שלו: כל השדות חייבים להיות readonly ומאפיינים אוטומטיים לקריאה בלבד. הקומפיילר אוכף את זה, וזה מאפשר לו להימנע מעותקים הגנתיים כשה-struct מועבר עם in או נשמר בשדה readonly. רוב ה-structs צריכים להיות readonly.