Value types and reference types behave differently the moment they cross a method boundary. A value type variable holds its data directly. A reference type variable holds a pointer to data that lives somewhere else. This distinction explains why some methods can change what a caller sees just by mutating a field, while others cannot change anything at all even though they look almost identical. This article works through struct versus class, stack versus heap and the exact rule that decides which behavior you get, including where ref actually helps.
Οι value types και οι reference types συμπεριφέρονται διαφορετικά τη στιγμή που περνούν τα όρια μιας μεθόδου. Μια μεταβλητή value type κρατά τα δεδομένα της απευθείας. Μια μεταβλητή reference type κρατά έναν pointer προς δεδομένα που ζουν κάπου αλλού. Αυτή η διάκριση εξηγεί γιατί κάποιες μέθοδοι μπορούν να αλλάξουν αυτό που βλέπει ο caller απλώς τροποποιώντας ένα field, ενώ άλλες δεν αλλάζουν τίποτα απολύτως, παρόλο που μοιάζουν σχεδόν ίδιες. Αυτό το άρθρο εξηγεί το struct έναντι το class, το stack έναντι το heap και τον ακριβή κανόνα που αποφασίζει ποια συμπεριφορά θα πάρεις, συμπεριλαμβανομένου του πού πραγματικά βοηθάει το ref.
What are value types and reference types?
Τι είναι τα value types και τα reference types;
struct declares a value type. class declares a reference type. A value type variable holds its data directly, inline, wherever that variable lives. A reference type variable does not hold the object itself. It holds a reference, effectively an address, that points to where the object actually lives. Copy a value type and you copy the data. Copy a reference type and you copy the address. Both copies still point at the exact same object.
Το struct δηλώνει έναν value type. Το class δηλώνει έναν reference type. Μια μεταβλητή value type κρατά τα δεδομένα της απευθείας, inline, όπου κι αν βρίσκεται αυτή η μεταβλητή. Μια μεταβλητή reference type δεν κρατά το ίδιο το αντικείμενο. Κρατά μια αναφορά, ουσιαστικά μια διεύθυνση, που δείχνει εκεί όπου πραγματικά ζει το αντικείμενο. Αντέγραψε έναν value type και αντιγράφεις τα δεδομένα. Αντέγραψε έναν reference type και αντιγράφεις τη διεύθυνση. Και τα δύο αντίγραφα δείχνουν ακριβώς στο ίδιο αντικείμενο.
Stack vs heap
Stack έναντι heap
Value types generally live on the stack, or inline inside whatever contains them, a field in a class, an element in an array. Reference types live on the heap. The variable itself still sits wherever it was declared, but all it holds is a reference pointing at the heap. This is why assigning one reference type variable to another does not copy the object, it copies the pointer, so both variables end up pointing at the same heap allocation.
Οι value types γενικά ζουν στο stack, ή inline μέσα σε ό,τι τις περιέχει, ένα field σε μια κλάση, ένα στοιχείο σε έναν πίνακα. Οι reference types ζουν στο heap. Η ίδια η μεταβλητή παραμένει εκεί που δηλώθηκε, αλλά ό,τι κρατά είναι μια αναφορά που δείχνει στο heap. Γι' αυτό η ανάθεση μιας μεταβλητής reference type σε μια άλλη δεν αντιγράφει το αντικείμενο, αντιγράφει τον pointer, οπότε και οι δύο μεταβλητές καταλήγουν να δείχνουν στην ίδια heap allocation.
class vs struct: when to use which
class έναντι struct: πότε να χρησιμοποιήσεις το καθένα
Same shape, different behavior:
Ίδιο σχήμα, διαφορετική συμπεριφορά:
public struct Point
{
public int X;
public int Y;
}
public class PointClass
{
public int X;
public int Y;
}
Reach for struct when a type is small, immutable in spirit and represents a single value, such as coordinates, a color or a money amount. Reach for class for anything with identity, anything that should be shared and mutated through references, or anything larger than a few fields, since copying a large struct on every assignment and every method call gets expensive. When in doubt, class is the safer default. Most everyday types in C# code are reference types for exactly this reason.
struct also does not support the OOP features covered elsewhere on this site: it cannot inherit from another class or struct, cannot be inherited from and cannot declare virtual members for other types to override. If a type needs to sit in an inheritance hierarchy or share behavior through virtual and override, that alone is one more reason to reach for class instead.
Διάλεξε struct όταν ένας τύπος είναι μικρός, immutable στη φιλοσοφία του και αντιπροσωπεύει μία τιμή, όπως συντεταγμένες, ένα χρώμα ή ένα χρηματικό ποσό. Διάλεξε class για οτιδήποτε έχει ταυτότητα, οτιδήποτε πρέπει να μοιράζεται και να τροποποιείται μέσω αναφορών, ή οτιδήποτε είναι μεγαλύτερο από λίγα fields, αφού η αντιγραφή ενός μεγάλου struct σε κάθε ανάθεση και κάθε κλήση μεθόδου γίνεται ακριβή. Στην αμφιβολία, το class είναι η ασφαλέστερη προεπιλογή. Οι περισσότεροι καθημερινοί τύποι στον κώδικα C# είναι reference types ακριβώς γι' αυτόν τον λόγο.
Το struct επίσης δεν υποστηρίζει τα OOP features που καλύπτονται αλλού σε αυτό το site: δεν μπορεί να κάνει inherit από άλλη class ή struct, δεν μπορεί να γίνει inherited και δεν μπορεί να δηλώσει virtual members για να τα κάνουν override άλλοι τύποι. Αν ένας τύπος χρειάζεται να συμμετέχει σε μια ιεραρχία κληρονομικότητας ή να μοιράζεται συμπεριφορά μέσω virtual και override, αυτό από μόνο του είναι ένας ακόμα λόγος να επιλέξεις class.
Passing by value vs by reference: the core gotcha
Πέρασμα by value έναντι by reference: το βασικό gotcha
Four small examples settle this once and for all. Each one passes something into a method, changes it inside that method and checks what the caller sees afterward.
Example A: a value type passed normally. Pass an int into a method that changes it and the caller's copy is untouched:
Τέσσερα μικρά παραδείγματα το ξεκαθαρίζουν μια για πάντα. Το καθένα περνά κάτι σε μια μέθοδο, το αλλάζει μέσα σε αυτή τη μέθοδο και ελέγχει τι βλέπει ο caller μετά.
Παράδειγμα Α: πέρασμα ενός value type κανονικά. Πέρασε ένα int σε μια μέθοδο που το αλλάζει και το αντίγραφο του caller μένει ανέπαφο:
static void TryDouble(int number)
{
number = number * 2;
}
int value = 10;
TryDouble(value);
Console.WriteLine(value); // 10
value never becomes 20. TryDouble received a copy of the number 10. Doubling that copy inside the method has no effect on the variable value declared by the caller, because the two were never connected in the first place.
Example B: a reference type, mutating a field. Pass a class instance into a method and change one of its fields, no ref needed:
Το value δεν γίνεται ποτέ 20. Το TryDouble πήρε ένα αντίγραφο του αριθμού 10. Ο διπλασιασμός αυτού του αντιγράφου μέσα στη μέθοδο δεν έχει καμία επίδραση στη μεταβλητή value που δήλωσε ο caller, γιατί τα δύο δεν συνδέθηκαν ποτέ καν εξαρχής.
Παράδειγμα Β: reference type, τροποποίηση ενός field. Πέρασε ένα instance μιας κλάσης σε μια μέθοδο και άλλαξε ένα από τα fields του, χωρίς να χρειάζεται ref:
public class Person
{
public string Name;
public Person(string name) => Name = name;
}
static void Rename(Person person)
{
person.Name = "Renamed";
}
var original = new Person("Alice");
Rename(original);
Console.WriteLine(original.Name); // Renamed
original.Name really does become Renamed. Rename received a copy, but it is a copy of the reference, not a copy of the Person object. Both the original variable and the person parameter point at the exact same object on the heap, so mutating a field through either one is visible through the other.
Example C: a reference type, reassigned with new. Same method shape, but this time the method replaces its local copy with a brand-new object instead of mutating a field:
Το original.Name πράγματι γίνεται Renamed. Το Rename πήρε ένα αντίγραφο, αλλά είναι αντίγραφο της αναφοράς, όχι αντίγραφο του αντικειμένου Person. Τόσο η μεταβλητή original όσο και η παράμετρος person δείχνουν ακριβώς στο ίδιο αντικείμενο στο heap, οπότε η τροποποίηση ενός field μέσω του ενός είναι ορατή και μέσω του άλλου.
Παράδειγμα Γ: reference type, επανανάθεση με new. Ίδιο σχήμα μεθόδου, αλλά αυτή τη φορά η μέθοδος αντικαθιστά το τοπικό της αντίγραφο με ένα ολοκαίνουριο αντικείμενο αντί να τροποποιήσει ένα field:
static void Replace(Person person)
{
person = new Person("Bob");
}
var original = new Person("Alice");
Replace(original);
Console.WriteLine(original.Name); // Alice, not Bob
original.Name is still Alice. This is the exact point that trips people up. The person parameter is a copy of the reference, so person = new Person("Bob") only points that local copy at a new object. The original variable back in the caller was never touched, it still points at the first Person. Mutating a field through the parameter reaches the shared object. Reassigning the parameter itself does not.
Το original.Name παραμένει Alice. Αυτό είναι το ακριβές σημείο που μπερδεύει τον κόσμο. Η παράμετρος person είναι αντίγραφο της αναφοράς, οπότε το person = new Person("Bob") απλώς κάνει το τοπικό αντίγραφο να δείχνει σε ένα νέο αντικείμενο. Η μεταβλητή original πίσω στον caller ποτέ δεν αγγίχτηκε, συνεχίζει να δείχνει στο πρώτο Person. Η τροποποίηση ενός field μέσω της παραμέτρου φτάνει στο κοινό αντικείμενο. Η επανανάθεση της ίδιας της παραμέτρου όχι.
This exact mistake shows up in real code more often than it should. A common shape: a method meant to replace or reset an object, something like TryLoadFromCache(Person person) that falls back to person = new Person(...) when the cache misses, called without ref because ref felt unnecessary. Every call compiles cleanly. Every call runs without error. And the caller's variable never changes, because the reassignment only ever touched the local copy of the reference. The bug hides in plain sight until someone reads the value back and finds the old object still sitting there.
Αυτό ακριβώς το λάθος εμφανίζεται σε πραγματικό κώδικα πιο συχνά απ' όσο θα έπρεπε. Ένα συνηθισμένο σχήμα: μια μέθοδος που σκοπό έχει να αντικαταστήσει ή να επαναφέρει ένα αντικείμενο, κάτι σαν TryLoadFromCache(Person person) που καταφεύγει σε person = new Person(...) όταν αποτυγχάνει η αναζήτηση στη cache, καλεσμένη χωρίς ref γιατί το ref φαινόταν περιττό. Κάθε κλήση κάνει compile κανονικά. Κάθε κλήση τρέχει χωρίς σφάλμα. Και η μεταβλητή του caller δεν αλλάζει ποτέ, γιατί η επανανάθεση άγγιξε μόνο το τοπικό αντίγραφο της αναφοράς. Το bug κρύβεται φανερά μέχρι κάποιος να ξαναδιαβάσει την τιμή και να βρει το παλιό αντικείμενο ακόμα εκεί.
Example D: the same reassignment, now with ref. Same idea, but the parameter is declared ref Person person:
Παράδειγμα Δ: η ίδια επανανάθεση, τώρα με ref. Ίδια ιδέα, αλλά η παράμετρος δηλώνεται ref Person person:
static void Replace(ref Person person)
{
person = new Person("Bob");
}
var original = new Person("Alice");
Replace(ref original);
Console.WriteLine(original.Name); // Bob
original.Name is now Bob. ref changes what gets passed. Instead of handing the method a copy of the reference, ref hands it the actual variable, its storage location back in the caller. Reassigning person inside the method reassigns original itself, because they are now the same storage location, not two references that happen to point at the same object.
Το original.Name είναι τώρα Bob. Το ref αλλάζει τι περνάει. Αντί να δίνει στη μέθοδο ένα αντίγραφο της αναφοράς, το ref της δίνει την ίδια τη μεταβλητή, τη θέση αποθήκευσής της πίσω στον caller. Η επανανάθεση του person μέσα στη μέθοδο επανανθέτει το ίδιο το original, γιατί τώρα είναι η ίδια θέση αποθήκευσης, όχι δύο αναφορές που τυχαίνει να δείχνουν στο ίδιο αντικείμενο.
All four examples together, runnable and editable:
Και τα τέσσερα παραδείγματα μαζί, εκτελέσιμα και επεξεργάσιμα:
The out keyword
Η λέξη-κλειδί out
out is a variant of ref built for a specific promise: the method must assign the parameter before it returns. The caller does not even need to initialize the variable first, the compiler enforces that the callee will. This shape is everywhere in the TryParse and TryGetValue family of methods.
Το out είναι μια παραλλαγή του ref φτιαγμένη για μια συγκεκριμένη υπόσχεση: η μέθοδος πρέπει να αναθέσει τιμή στην παράμετρο πριν επιστρέψει. Ο caller δεν χρειάζεται καν να αρχικοποιήσει πρώτα τη μεταβλητή, ο compiler επιβάλλει ότι ο callee θα το κάνει. Αυτό το σχήμα υπάρχει παντού στην οικογένεια μεθόδων TryParse και TryGetValue.
static bool TryGetAge(string input, out int age)
{
if (int.TryParse(input, out age))
{
return age >= 0;
}
age = 0;
return false;
}
if (TryGetAge("42", out int result))
{
Console.WriteLine($"Parsed age: {result}");
}
TryGetAge always assigns age on every path through the method, even the failure path, because out requires it. The caller gets a real value back either way and uses the bool return to decide whether that value is meaningful.
Το TryGetAge πάντα αναθέτει τιμή στο age σε κάθε μονοπάτι μέσα στη μέθοδο, ακόμα και στο μονοπάτι αποτυχίας, γιατί το out το απαιτεί. Ο caller παίρνει μια πραγματική τιμή πίσω σε κάθε περίπτωση και χρησιμοποιεί το bool return για να αποφασίσει αν αυτή η τιμή έχει νόημα.
Try it yourself
Δοκίμασέ το μόνος σου
Take Example C from the live playground above. Add ref to both the method signature and the call, exactly like Example D. Before you hit Run, write down what you expect original.Name to print. Then run it and check.
Πάρε το Παράδειγμα Γ από το live playground παραπάνω. Πρόσθεσε ref τόσο στην υπογραφή της μεθόδου όσο και στην κλήση, ακριβώς όπως το Παράδειγμα Δ. Πριν πατήσεις Run, γράψε τι περιμένεις να τυπώσει το original.Name. Μετά τρέξε το και έλεγξέ το.
Takeaways
Συμπεράσματα
- Value types: each variable owns its own data. Passing one copies the data.
- Reference types: each variable holds a reference. Passing one copies the reference, not the object it points to.
- Mutating a field through a reference type parameter affects the caller's object. No
refneeded. - Reassigning a reference type parameter to a new object does not affect the caller's variable, unless the parameter is passed with
ref. outis likeref, but designed specifically for always assigning a value before the method returns.- Boxing, unboxing, readonly struct and record types build on these same rules and get their own articles.
- Value types: κάθε μεταβλητή έχει τα δικά της δεδομένα. Το πέρασμά της αντιγράφει τα δεδομένα.
- Reference types: κάθε μεταβλητή κρατά μια αναφορά. Το πέρασμά της αντιγράφει την αναφορά, όχι το αντικείμενο στο οποίο δείχνει.
- Η τροποποίηση ενός field μέσω μιας παραμέτρου reference type επηρεάζει το αντικείμενο του caller. Δεν χρειάζεται
ref. - Η επανανάθεση μιας παραμέτρου reference type σε ένα νέο αντικείμενο δεν επηρεάζει τη μεταβλητή του caller, εκτός αν η παράμετρος περάσει με
ref. - Το
outείναι σαν τοref, αλλά φτιαγμένο ειδικά για να αναθέτει πάντα τιμή πριν επιστρέψει η μέθοδος. - Το boxing, το unboxing, τα readonly struct και οι record types χτίζουν πάνω σε αυτούς τους ίδιους κανόνες και θα καλυφθούν σε ξεχωριστά άρθρα.