Polymorphism means the same method call can run different code depending on the actual object behind it. C# gives you two ways to redefine a method in a derived class that look almost identical in the code but behave completely differently at runtime: virtual with override and new. Only one of them is real polymorphism. This article shows both side by side with the same Animal example so the difference is impossible to miss.
This matters in real code because the concrete implementation behind a method call often needs to change over time without anyone touching the class that uses it. A logger is a common example: today it writes to a local file, tomorrow it needs to write to a third-party logging system instead. If the class that logs depends on a base Logger type and calls a virtual method, swapping FileLogger for a different override is enough. If it depends on a new-hidden method, the class using it can silently keep calling the wrong implementation depending on how it was declared. The last section of this article works through that exact scenario.
Ο πολυμορφισμός σημαίνει ότι η ίδια κλήση μεθόδου μπορεί να τρέξει διαφορετικό κώδικα ανάλογα με το πραγματικό αντικείμενο από πίσω της. Η C# σου δίνει δύο τρόπους να ξαναορίσεις μια μέθοδο σε μια παράγωγη κλάση που μοιάζουν σχεδόν ίδιοι στον κώδικα αλλά συμπεριφέρονται εντελώς διαφορετικά κατά την εκτέλεση: virtual με override και new. Μόνο ο ένας από τους δύο είναι πραγματικός πολυμορφισμός. Αυτό το άρθρο δείχνει και τους δύο δίπλα δίπλα με το ίδιο παράδειγμα Animal ώστε η διαφορά να είναι αδύνατο να περάσει απαρατήρητη.
Αυτό έχει σημασία σε πραγματικό κώδικα γιατί η συγκεκριμένη υλοποίηση πίσω από μια κλήση μεθόδου συχνά χρειάζεται να αλλάξει με τον καιρό χωρίς κανείς να αγγίξει την κλάση που τη χρησιμοποιεί. Ένας logger είναι ένα συνηθισμένο παράδειγμα: σήμερα γράφει σε ένα τοπικό αρχείο, αύριο χρειάζεται να γράφει σε ένα τρίτο σύστημα καταγραφής. Αν η κλάση που κάνει logging εξαρτάται από έναν βασικό τύπο Logger και καλεί μια virtual μέθοδο, η αντικατάσταση του FileLogger με ένα διαφορετικό override είναι αρκετή. Αν εξαρτάται από μια κρυμμένη με new μέθοδο, η κλάση που τη χρησιμοποιεί μπορεί σιωπηλά να συνεχίσει να καλεί τη λάθος υλοποίηση ανάλογα με το πώς δηλώθηκε. Η τελευταία ενότητα αυτού του άρθρου δουλεύει ακριβώς πάνω σε αυτό το σενάριο.
Runtime polymorphism: virtual and override
Πολυμορφισμός κατά την εκτέλεση: virtual και override
Mark a base class method virtual and a derived class can override it with its own implementation. When you call that method, .NET looks at the actual type of the object at runtime, not the type of the variable you are calling it through, then runs whichever version belongs to that object. This is called dynamic dispatch, which is what most people mean when they say polymorphism.
Σημείωσε μια μέθοδο της βασικής κλάσης ως virtual και μια παράγωγη κλάση μπορεί να την κάνει override με τη δική της υλοποίηση. Όταν καλείς αυτή τη μέθοδο, το .NET κοιτάζει τον πραγματικό τύπο του αντικειμένου κατά την εκτέλεση, όχι τον τύπο της μεταβλητής μέσα από την οποία την καλείς, έπειτα τρέχει όποια έκδοση ανήκει σε αυτό το αντικείμενο. Αυτό λέγεται dynamic dispatch, κάτι που εννοούν οι περισσότεροι όταν λένε πολυμορφισμός.
public class Animal
{
public virtual void Speak() => Console.WriteLine("The animal makes a sound.");
}
public class Dog : Animal
{
public override void Speak() => Console.WriteLine("The dog barks.");
}
Now call Speak() through a variable declared as Animal but actually holding a Dog:
Τώρα κάλεσε το Speak() μέσα από μια μεταβλητή δηλωμένη ως Animal που όμως κρατά πραγματικά ένα Dog:
Animal a = new Dog();
a.Speak(); // "The dog barks."
The variable is typed as Animal, but the output is Dog's version. What decides which method runs is the object on the right of new, not the type on the left of the variable. That is the entire point of virtual and override.
Η μεταβλητή έχει τύπο Animal, αλλά το output είναι η έκδοση του Dog. Αυτό που αποφασίζει ποια μέθοδος τρέχει είναι το αντικείμενο στα δεξιά του new, όχι ο τύπος στα αριστερά της μεταβλητής. Αυτό είναι όλο το νόημα του virtual και του override.
Method hiding: new
Απόκρυψη μεθόδου: new
Now take the same shape, Animal and Dog, but without virtual. If the derived class declares a method with the same name using new instead of override, it does not replace the base method. It hides it. The two Speak() methods become two separate, unrelated methods that happen to share a name.
Πάρε τώρα το ίδιο σχήμα, Animal και Dog, αλλά χωρίς virtual. Αν η παράγωγη κλάση δηλώσει μια μέθοδο με το ίδιο όνομα χρησιμοποιώντας new αντί για override, δεν αντικαθιστά τη μέθοδο της βασικής κλάσης. Την κρύβει. Οι δύο μέθοδοι Speak() γίνονται δύο ξεχωριστές, άσχετες μεταξύ τους μεθόδους που τυχαίνει να μοιράζονται ένα όνομα.
public class AnimalBase
{
public void Speak() => Console.WriteLine("The animal makes a sound.");
}
public class DogHidden : AnimalBase
{
public new void Speak() => Console.WriteLine("The dog barks.");
}
Call Speak() the same way as before, through a base-typed variable holding a derived object:
Κάλεσε το Speak() με τον ίδιο τρόπο όπως πριν, μέσα από μια μεταβλητή τύπου βάσης που κρατά ένα παράγωγο αντικείμενο:
AnimalBase h = new DogHidden();
h.Speak(); // "The animal makes a sound."
DogHidden d = new DogHidden();
d.Speak(); // "The dog barks."
Same underlying object in both lines, the same DogHidden instance, two different outputs. Without virtual, the compiler decides which Speak() to call while it reads your code, using the declared type of the variable. h is declared as AnimalBase, so h.Speak() compiles straight to AnimalBase's method regardless of what h actually points to at runtime. d is declared as DogHidden, so d.Speak() compiles to DogHidden's method. Nothing here looks at the real object. That is why new does not give you polymorphism: the result depends on whatever type the reference was last declared or cast as, not on what the object actually is.
Το ίδιο υποκείμενο αντικείμενο και στις δύο γραμμές, το ίδιο instance DogHidden, δύο διαφορετικά αποτελέσματα. Χωρίς virtual, ο compiler αποφασίζει ποιο Speak() θα καλέσει τη στιγμή που διαβάζει τον κώδικά σου, χρησιμοποιώντας τον δηλωμένο τύπο της μεταβλητής. Το h είναι δηλωμένο ως AnimalBase, οπότε το h.Speak() γίνεται compile κατευθείαν στη μέθοδο του AnimalBase ανεξάρτητα από το τι δείχνει πραγματικά το h κατά την εκτέλεση. Το d είναι δηλωμένο ως DogHidden, οπότε το d.Speak() γίνεται compile στη μέθοδο του DogHidden. Τίποτα εδώ δεν κοιτάζει το πραγματικό αντικείμενο. Γι' αυτό το new δεν σου δίνει πολυμορφισμό: το αποτέλεσμα εξαρτάται από το ποιος τύπος δηλώθηκε ή έγινε cast τελευταίος στην αναφορά, όχι από το τι είναι πραγματικά το αντικείμενο.
Αν γράψεις μια μέθοδο σε μια παράγωγη κλάση που ταιριάζει με το όνομα και την υπογραφή μιας μεθόδου της βασικής κλάσης χωρίς virtual ή override, ο compiler σε προειδοποιεί (CS0108) και προτείνει να προσθέσεις ρητά το new. Αυτή η προειδοποίηση υπάρχει ακριβώς επειδή αυτό το λάθος γίνεται πολύ εύκολα κατά λάθος.
If you write a method in a derived class that matches a base method's name and signature without virtual or override, the compiler warns you (CS0108) and suggests adding new explicitly. That warning exists specifically because this mistake is so easy to make by accident.
Side by side: same object, two different answers
Δίπλα δίπλα: ίδιο αντικείμενο, δύο διαφορετικές απαντήσεις
This is exactly the kind of thing interviewers ask about, because it looks like a small syntax choice but changes the entire behavior of a class hierarchy. With override, a list of the base type full of different subclasses calls the right Speak() for each one automatically. With new, the same kind of list calls the base version for every single item, no matter what is actually in it, because the loop sees each element as the base type.
Αυτό είναι ακριβώς το είδος πράγματος που ρωτούν σε συνεντεύξεις, γιατί μοιάζει με μικρή επιλογή syntax αλλά αλλάζει ολόκληρη τη συμπεριφορά μιας ιεραρχίας κλάσεων. Με override, μια λίστα τύπου βάσης γεμάτη διαφορετικές υποκλάσεις καλεί τη σωστή Speak() για την καθεμία αυτόματα. Με new, η ίδια λίστα καλεί την έκδοση της βάσης για κάθε στοιχείο, ανεξάρτητα από το τι πραγματικά περιέχει, γιατί το loop βλέπει κάθε στοιχείο ως τον τύπο βάσης.
List<Animal> animals = new() { new Animal(), new Dog() };
foreach (var item in animals)
item.Speak();
// The animal makes a sound.
// The dog barks.
List<AnimalBase> hiddenAnimals = new() { new AnimalBase(), new DogHidden() };
foreach (var item in hiddenAnimals)
item.Speak();
// The animal makes a sound.
// The animal makes a sound.
Both examples together, runnable and editable:
Και τα δύο παραδείγματα μαζί, εκτελέσιμα και επεξεργάσιμα:
Why this matters: swapping a logger in a payment system
Γιατί έχει σημασία: αλλαγή logger σε ένα σύστημα πληρωμών
Here is the same idea in a shape you will actually build: a PaymentService that logs every payment through a base Logger type. Swapping where the logs go, a local file today, a third-party system tomorrow, only means writing a new class that inherits Logger, doing whatever setup it needs internally and overriding Log. PaymentService itself never changes.
Ορίστε η ίδια ιδέα σε ένα σχήμα που πραγματικά θα χτίσεις: ένα PaymentService που κάνει log κάθε πληρωμή μέσα από έναν βασικό τύπο Logger. Η αλλαγή του πού πηγαίνουν τα logs, σε ένα τοπικό αρχείο σήμερα, σε ένα τρίτο σύστημα αύριο, σημαίνει μόνο να γράψεις μια νέα κλάση που κληρονομεί το Logger, να κάνεις όποιο setup χρειάζεσαι εσωτερικά και να κάνεις override το Log. Το PaymentService δεν αλλάζει ποτέ.
public class Logger
{
public virtual void Log(string message) => Console.WriteLine($"[Logger] {message}");
}
public class FileLogger : Logger
{
private readonly string _filePath;
public FileLogger(string filePath)
{
_filePath = filePath;
// any file-specific setup happens here
}
public override void Log(string message) =>
Console.WriteLine($"[File: {_filePath}] {message}");
}
public class RemoteLogger : Logger
{
private readonly string _endpoint;
public RemoteLogger(string endpoint)
{
_endpoint = endpoint;
// any remote-system setup happens here, such as authentication
}
public override void Log(string message) =>
Console.WriteLine($"[Remote: {_endpoint}] {message}");
}
PaymentService only knows about the base Logger type:
Το PaymentService ξέρει μόνο για τον βασικό τύπο Logger:
public class PaymentService
{
private readonly Logger _logger;
public PaymentService(Logger logger)
{
_logger = logger;
}
public void Pay(decimal amount)
{
// payment logic here
_logger.Log($"Payment of {amount:C} processed.");
}
}
Switching where payments get logged is just a different argument at the call site:
Η αλλαγή του πού καταγράφονται οι πληρωμές είναι απλώς διαφορετικό όρισμα στο σημείο κλήσης:
var localPayments = new PaymentService(new FileLogger("payments.log"));
localPayments.Pay(120m);
var remotePayments = new PaymentService(new RemoteLogger("https://logs.example.com"));
remotePayments.Pay(120m);
Same Pay method, same PaymentService class, two completely different destinations for the log entry. Because Log is virtual and both loggers override it, PaymentService never has to know or care which one it is holding. Run it below: swap FileLogger for RemoteLogger on either line and the output changes, PaymentService.Pay never does.
Ίδια μέθοδος Pay, ίδια κλάση PaymentService, δύο εντελώς διαφορετικοί προορισμοί για την καταχώρηση του log. Επειδή το Log είναι virtual και οι δύο loggers το κάνουν override, το PaymentService δεν χρειάζεται ποτέ να ξέρει ή να νοιάζεται ποιον από τους δύο κρατά. Τρέξε το παρακάτω: άλλαξε το FileLogger με RemoteLogger σε οποιαδήποτε γραμμή και το output αλλάζει, το PaymentService.Pay ποτέ.
Takeaways
Συμπεράσματα
- Mark a base method virtual and override it in the derived class to get real polymorphism: the actual runtime type of the object decides which version runs.
- new does not override anything. It hides the base method behind a second, unrelated method with the same name and the compiler picks the version to call based on the declared type of the variable.
- The clearest way to see the difference is a collection typed as the base class: override calls the right method for every item, new calls the base version for everything.
- If the compiler warns CS0108 about hiding an inherited member, that is your signal to either add override (if the base method is virtual) or add new deliberately (if hiding is actually what you want, which is rare).
- Default to virtual and override for anything meant to be specialized by a subclass. Reach for new only when you deliberately want a derived class to offer an unrelated method that happens to share a name, which is uncommon and often a design smell.
- This is not just an academic distinction: a logger, a payment provider or any other dependency that is likely to be swapped later should sit behind a virtual method, so the class using it keeps working unchanged no matter which implementation ends up behind it.
- Σημείωσε μια μέθοδο της βασικής κλάσης ως virtual και κάνε την override στην παράγωγη κλάση για να πάρεις πραγματικό πολυμορφισμό: ο πραγματικός τύπος του αντικειμένου κατά την εκτέλεση αποφασίζει ποια έκδοση τρέχει.
- Το new δεν κάνει override τίποτα. Κρύβει τη μέθοδο της βασικής κλάσης πίσω από μια δεύτερη, άσχετη μέθοδο με το ίδιο όνομα και ο compiler διαλέγει ποια έκδοση θα καλέσει με βάση τον δηλωμένο τύπο της μεταβλητής.
- Ο πιο καθαρός τρόπος να δεις τη διαφορά είναι μια συλλογή τύπου βάσης: το override καλεί τη σωστή μέθοδο για κάθε στοιχείο, το new καλεί την έκδοση της βάσης για όλα.
- Αν ο compiler σε προειδοποιήσει με CS0108 για απόκρυψη κληρονομημένου μέλους, αυτό είναι το σήμα να προσθέσεις είτε override (αν η μέθοδος της βάσης είναι virtual) είτε new επίτηδες (αν όντως θέλεις απόκρυψη, κάτι σπάνιο).
- Προτίμησε πάντα virtual και override για οτιδήποτε προορίζεται να εξειδικευτεί από μια υποκλάση. Χρησιμοποίησε new μόνο όταν θέλεις επίτηδες μια παράγωγη κλάση να προσφέρει μια άσχετη μέθοδο που τυχαίνει να μοιράζεται ένα όνομα, κάτι ασυνήθιστο και συχνά design smell.
- Αυτό δεν είναι απλώς ακαδημαϊκή διάκριση: ένας logger, ένας πάροχος πληρωμών ή οποιαδήποτε άλλη εξάρτηση που είναι πιθανό να αλλάξει αργότερα πρέπει να κάθεται πίσω από μια virtual μέθοδο, ώστε η κλάση που τη χρησιμοποιεί να συνεχίζει να δουλεύει χωρίς αλλαγές ανεξάρτητα από το ποια υλοποίηση καταλήγει από πίσω της.