1. Wprowadzenie
Kiedy początkujący słyszy słowo dziedziczenie, może mu się wydawać, że to proste: bierzesz jakąś istniejącą klasę, trochę ją rozszerzasz albo zmieniasz — i gotowe! W rzeczywistości jest sporo niuansów. Błędy przy dziedziczeniu mogą objawiać się na różne sposoby: złe sygnatury, zapomniane kluczowe słowa, kiepski design hierarchii — wszystko to prowadzi do trudnych bugów.
Czasem błędy wychodzą od razu: kod się nie kompiluje. Innym razem — bugi przypominają o sobie dopiero w runtime, kiedy twój nowy SuperMegaLogger wypisuje zupełnie coś innego niż oczekują użytkownicy, albo w ogóle nic nie wypisuje. Smutek, rozczarowanie, długie godziny debugowania — znasz to uczucie?
Chodźmy razem przez najczęstsze błędy związane z dziedziczeniem i nadpisywaniem metod i "uzdrawiajmy" nasz kod po drodze.
2. Zapomniane virtual: dlaczego nie można nadpisać metody?
Problem
W C# można nadpisać (override) tylko te metody, które w klasie bazowej są oznaczone słowem virtual, albo jako abstract, albo jako override samego siebie (w łańcuchu dziedziczenia). Jeśli metoda nie ma tego słowa, próba napisania w klasie pochodnej override wywoła błąd kompilacji.
class Animal
{
public void Speak()
{
Console.WriteLine("Zwierzę coś mówi.");
}
}
class Cat : Animal
{
// Błąd kompilacji! Metoda w klasie bazowej nie jest virtual, abstract ani override.
public override void Speak()
{
Console.WriteLine("Miau!");
}
}
Błąd będzie mniej więcej taki: "'Cat.Speak()': cannot override inherited member 'Animal.Speak()' because it is not marked virtual, abstract, or override".
Jak nie wpaść w tę pułapkę?
Żeby nadpisywać metody, koniecznie oznaczaj je w klasie bazowej jako virtual. Przy okazji, jeśli projektujesz klasy do rozszerzania, zawsze przemyśl, które metody mogą być przydatne do nadpisania.
class Animal
{
public virtual void Speak()
{
Console.WriteLine("Zwierzę wydaje dźwięk.");
}
}
class Cat : Animal
{
public override void Speak()
{
Console.WriteLine("Miau!");
}
}
Po co to wszystko?
Oznaczając metodę jako virtual, jawnie pozwalasz potomkom zmieniać jej zachowanie, a kompilator staje się twoim sojusznikiem — nie pozwoli przypadkowo nadpisać "nie tej" metody.
3. Błąd z kluczowym słowem override i new
Czasem pojawia się odwrotny problem: autor klasy pochodnej chce "nadpisać" metodę, ale w klasie bazowej nie jest ona virtual. Kompilator nie pozwoli użyć override, ale pozwoli użyć new. Ale to już zupełnie inne zachowanie!
class Dog : Animal
{
// To nie jest override, tylko ukrycie (hiding) metody z klasy bazowej.
public new void Speak()
{
Console.WriteLine("Hau!");
}
}
Jeśli wywołasz taką metodę przez referencję typu Dog — wszystko gra:
Dog dog = new Dog();
dog.Speak(); // "Hau!"
Ale jeśli podejdziesz do obiektu przez referencję typu bazowego, wywoła się metoda bazowa:
Animal dog2 = new Dog();
dog2.Speak(); // "Zwierzę wydaje dźwięk."
Wyjaśnienie:
Kluczowe słowo new NIE nadpisuje metody, tylko ją ukrywa. To się nazywa "hiding". Takie podejście może mylić, gdy polimorficzne zachowanie nie działa tak, jak programista oczekuje.
Jak uniknąć pułapek z new vs override?
Jeśli chcesz klasyczne nadpisanie z obsługą polimorfizmu — używaj virtual/override. Jeśli dodajesz zupełnie nową funkcjonalność (albo świadomie chcesz ukryć zachowanie metody bazowej, ale bądź ostrożny!), to użyj new.
| Sytuacja | Kluczowe słowo | Czy polimorfizm działa? | Zachowanie przy wywołaniu przez typ bazowy |
|---|---|---|---|
| Zmienić zachowanie | override | Tak | Wywołuje się metoda klasy pochodnej |
| Ukryć/zastąpić metodę | new | Nie | Wywołuje się metoda klasy bazowej |
4. Niepasujące sygnatury metod
Początkujący, a czasem i doświadczeni programiści, mylą się, zmieniając typy parametrów lub typ zwracany w metodzie potomnej. Na przykład, jeśli metoda bazowa jest zadeklarowana jako public virtual void Print(string message), a w klasie pochodnej "nadpisują" public override void Print(object message), to już NIE jest override, tylko nowe zdefiniowanie metody.
class Printer
{
public virtual void Print(string msg)
{
Console.WriteLine("Drukarka bazowa: " + msg);
}
}
class SmartPrinter : Printer
{
// Błąd kompilacji! Sygnatura nie pasuje do metody bazowej.
public override void Print(object msg)
{
Console.WriteLine("Smart drukarka: " + msg);
}
}
Podpowiedź:
Muszą się zgadzać i nazwa metody, i typ zwracany, i parametry (typy, ilość i kolejność).
Jeśli przypadkiem zmienisz typ któregoś z parametrów albo zrobisz literówkę w nazwie — kompilator ostrzeże.
5. Niezgodność modyfikatorów dostępu
Jeszcze jedna mina, na którą często wpadają początkujący — modyfikatory dostępu. Metoda potomna nie może być bardziej zamknięta niż bazowa. Przykład złego kodu:
public class Vehicle
{
public virtual void StartEngine() { /* ... */ }
}
public class Car : Vehicle
{
// Błąd! Modyfikator 'private' jest bardziej restrykcyjny niż 'public' w bazowej metodzie.
private override void StartEngine() { /* ... */ }
}
Co robić?
Modyfikator dostępu w metodzie potomnej musi być taki sam lub bardziej otwarty niż w metodzie bazowej. W większości przypadków public albo protected.
6. Zapomniana/zbędna metoda abstrakcyjna
Jeśli w klasie bazowej metoda jest zadeklarowana jako abstract, to w klasie pochodnej MUSI być jej override, inaczej klasa też staje się abstrakcyjna (i nie można jej utworzyć).
abstract class Shape
{
public abstract double Area();
}
class Circle : Shape
{
// Błąd! Niezaimplementowana metoda abstrakcyjna Area()
}
Rozwiązanie:
Trzeba zaimplementować tę metodę:
class Circle : Shape
{
public override double Area()
{
return 3.14 * 2 * 2; // Tak mniej więcej...
}
}
7. Wywołanie bazowej implementacji: base.Method()
Czasem trzeba nie całkiem zastąpić implementację metody, tylko coś do niej dodać. Wtedy często się zapomina (albo nie wie), że w metodzie potomnej można wywołać implementację klasy bazowej przez słowo base.
class Logger
{
public virtual void Log(string msg)
{
Console.WriteLine("Bazowy log: " + msg);
}
}
class FancyLogger : Logger
{
public override void Log(string msg)
{
// Można dodać "bajery" i wywołać bazową metodę:
Console.WriteLine("[FANCY] " + msg);
base.Log(msg);
}
}
Wyjaśnienie:
Jeśli nie wywołasz base.Log(msg), logika z klasy bazowej zostanie całkiem utracona.
8. Wywołanie bazowego konstruktora (base)
Jeśli klasa bazowa wymaga obowiązkowych parametrów w swoim konstruktorze, klasa pochodna musi jawnie wywołać odpowiedni konstruktor przez słowo base.
class Engine
{
public Engine(int cylinders)
{
Console.WriteLine("Silnik z cylindrami: " + cylinders);
}
}
class RaceEngine : Engine
{
// Błąd kompilacji! Brak domyślnego konstruktora w Engine.
public RaceEngine() { }
}
// Poprawiona wersja:
class RaceEngine2 : Engine
{
public RaceEngine2() : base(8) // Jawnie wywołujemy bazowy konstruktor
{
Console.WriteLine("Wyścigowy silnik gotowy!");
}
}
9. "Zapomniane" oznaczenie metod jako sealed
Czasem trzeba zabronić dalszego nadpisywania metody w klasach pochodnych — do tego w C# jest słowo sealed w połączeniu z override. Jeśli go nie użyjesz, ktoś może nadpisać twoją metodę, być może psując twoje założenia lub logikę.
class Hero
{
public virtual void Attack() => Console.WriteLine("Bohater atakuje!");
}
class Warrior : Hero
{
public sealed override void Attack() => Console.WriteLine("Wojownik zadaje cios!");
}
class Mutant : Warrior
{
// Błąd! Metoda Attack oznaczona jako sealed wyżej.
// public override void Attack() { ... }
}
Wyjaśnienie:
W ten sposób "zapieczętowujesz" implementację na tym poziomie i niższe klasy nie mogą jej zmienić.
10. Zbędne lub błędne przeciążenia zamiast nadpisania
Czasem programiści mylą przeciążenie metody (overloading) z nadpisaniem (overriding). Przeciążenie — to zdefiniowanie metody o tej samej nazwie, ale z inną sygnaturą (np. inna liczba parametrów), i nie ma to nic wspólnego z polimorfizmem.
class Animal
{
public virtual void Eat()
{
Console.WriteLine("Zwierzę je.");
}
}
class Panda : Animal
{
// To NIE jest override! Po prostu nowa przeciążona metoda.
public void Eat(string what)
{
Console.WriteLine("Panda je: " + what);
}
}
...
Animal a = new Panda();
a.Eat(); // Jeśli metoda jest nadpisana — wywoła się implementacja z klasy pochodnej. Jeśli nie — użyje się bazowa
// a.Eat("bambus"); // Błąd kompilacji: taka metoda nie istnieje w Animal
Żeby dodać polimorficzne zachowanie, trzeba właśnie nadpisywać, a nie tylko przeciążać.
11. Formalne i nieformalne błędy projektowe
Kiedy klasy są źle zaprojektowane, listy dziedziczenia robią się zagmatwane:
- wewnętrzna "karuzela" override'ów bez sensownego użycia base.,
- niekonsekwentne użycie modyfikatorów,
- zbyt głębokie lub nieoczywiste hierarchie,
- metody, których część logiki jest "rozmazana" między poziomami dziedziczenia,
- brak komentarzy do virtual/abstract metod,
- nieoczywiste efekty uboczne (np. wywołanie metody virtual w konstruktorze klasy bazowej, gdy pochodna nie jest jeszcze do końca zainicjalizowana).
Rada:
Jeśli czujesz, że się pogubiłeś — nie żałuj czasu, narysuj sobie na kartce (stary, ale skuteczny sposób!) hierarchię i podpisz, która metoda gdzie jest, kto ją nadpisuje, co wywołuje i gdzie powinno być wywołane base..
W prawdziwych projektach mega ważne jest też w dokumentacji zaznaczyć, czego się oczekuje od nadpisania danej metody.
GO TO FULL VERSION