🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Dziedziczenie w C# — base, virtual/override i pułapka słowa new

public class Pracownik
{
    public string Imie;
    public decimal ObliczWynagrodzenie() => 5000m;
}

public class Kierownik
{
    public string Imie;              // ❌ powtórzone pole
    public decimal ObliczWynagrodzenie() => 5000m; // ❌ powtórzona logika
    public decimal Premia() => 1000m;
}

Dwie klasy, ten sam kod skopiowany w dwóch miejscach. Zmiana sposobu liczenia ObliczWynagrodzenie wymaga teraz pamiętania o edycji obu klas — a przy dziesięciu podobnych typach pracowników o dziesięciu. Dziedziczenie pozwala napisać wspólny kod raz, w jednym miejscu.

Podstawowa składnia

public class Pracownik
{
    public string Imie;
    public virtual decimal ObliczWynagrodzenie() => 5000m;
}

public class Kierownik : Pracownik // ':' oznacza dziedziczenie
{
    public override decimal ObliczWynagrodzenie() => base.ObliczWynagrodzenie() + 1000m; // premia kierownicza
}

Kierownik dziedziczy po Pracownik — automatycznie ma pole Imie i metodę ObliczWynagrodzenie, bez przepisywania ich. override pozwala dostarczyć własną wersję metody, a base.ObliczWynagrodzenie() wywołuje oryginalną implementację z klasy bazowej, zamiast pisać jej logikę od nowa.

Konstruktory — zawsze wywołują konstruktor bazowy

public class Pracownik
{
    public string Imie;
    public Pracownik(string imie) => Imie = imie; // brak konstruktora bezparametrowego
}

public class Kierownik : Pracownik
{
    public string Dzial;

    public Kierownik(string imie, string dzial) : base(imie) // ✅ jawne wywołanie konstruktora bazowego
    {
        Dzial = dzial;
    }
}

Jeśli klasa bazowa nie ma konstruktora bezparametrowego, każda klasa dziedzicząca musi jawnie wywołać jeden z jej konstruktorów przez : base(...) — inaczej kod się nie skompiluje. To nie ograniczenie, tylko gwarancja: obiekt Kierownik nie może istnieć bez poprawnie zainicjalizowanego Imie, bo to część kontraktu klasy bazowej.

virtual / override kontra zwykłe przesłonięcie

public class Pracownik
{
    public decimal ObliczWynagrodzenie() => 5000m; // BEZ virtual
}

public class Kierownik : Pracownik
{
    public new decimal ObliczWynagrodzenie() => 8000m; // 'new' zamiast 'override'
}

To się skompiluje — ale nie robi tego, czego się spodziewasznew nie nadpisuje metody, tylko ją ukrywa — która wersja się wykona, zależy od tego, przez jaki typ referencji wywołujesz metodę, nie od rzeczywistego typu obiektu:

Kierownik kierownik = new Kierownik();
Pracownik jakoPracownik = kierownik; // ta sama instancja, inny typ referencji

Console.WriteLine(kierownik.ObliczWynagrodzenie());     // 8000 — wersja Kierownika
Console.WriteLine(jakoPracownik.ObliczWynagrodzenie());  // 5000 — wersja Pracownika! Ten sam obiekt!

To jedna z najbardziej mylących pułapek dziedziczenia w C# — ten sam obiekt zwraca różne wyniki w zależności od typu zmiennej, przez którą go widzisz. Poprawne rozwiązanie to virtual w klasie bazowej i override w pochodnej:

public class Pracownik
{
    public virtual decimal ObliczWynagrodzenie() => 5000m; // 'virtual' — może być nadpisana
}

public class Kierownik : Pracownik
{
    public override decimal ObliczWynagrodzenie() => 8000m; // 'override' — realne nadpisanie
}
Pracownik jakoPracownik = new Kierownik();
Console.WriteLine(jakoPracownik.ObliczWynagrodzenie()); // 8000 — teraz zawsze wersja rzeczywistego typu

virtual/override wynik zależy od rzeczywistego typu obiektu w pamięci, nie od typu zmiennej — to jest polimorfizm, i to jest zachowanie, którego programiści intuicyjnie oczekują.

protected — widoczne dla podklas, ukryte dla reszty świata

public class Pracownik
{
    protected decimal PodstawaWynagrodzenia = 5000m; // widoczne w Pracownik i jego podklasach

    public virtual decimal ObliczWynagrodzenie() => PodstawaWynagrodzenia;
}

public class Kierownik : Pracownik
{
    public override decimal ObliczWynagrodzenie() => PodstawaWynagrodzenia + 1000m; // ✅ ma dostęp
}

// gdzieś indziej w kodzie:
var k = new Kierownik();
var x = k.PodstawaWynagrodzenia; // ❌ błąd kompilacji — protected nie jest widoczne z zewnątrz

protected to trzeci poziom dostępu obok public i private — pole albo metoda jest widoczna dla klasy, w której jest zdefiniowana, i dla wszystkich klas dziedziczących, ale niewidoczna dla reszty kodu. To naturalny wybór dla elementów, które podklasy powinny móc wykorzystać lub nadpisać, ale które nie powinny być częścią publicznego API klasy.

sealed — blokada dalszego dziedziczenia

public sealed class RaportKoncowy : Pracownik
{
    // ...
}

public class Nadpisanie : RaportKoncowy { } // ❌ błąd kompilacji — RaportKoncowy jest sealed

sealed na klasie zabrania dziedziczenia po niej — przydatne, gdy chcesz zagwarantować, że zachowanie klasy nie zostanie zmienione przez podklasę (bezpieczeństwo, przewidywalność) albo gdy klasa nie została zaprojektowana z myślą o rozszerzaniu. sealed można też dodać do pojedynczej nadpisanej metody, blokując jej dalsze nadpisywanie w kolejnych poziomach dziedziczenia:

public override sealed decimal ObliczWynagrodzenie() => PodstawaWynagrodzenia + 1000m;
// klasy dziedziczące po Kierowniku nie mogą już nadpisać tej metody

Kiedy NIE używać dziedziczenia

Dziedziczenie modeluje relację “jest-czymś” (Kierownik jest Pracownikiem). Jeśli relacja to raczej “posiada” albo “korzysta z” (samochód ma silnik, ale nim nie jest), dziedziczenie to zły wybór — prowadzi do sztywnych, głębokich hierarchii trudnych do zmiany. W takich przypadkach kompozycja (obiekt trzymający referencję do innego obiektu jako pole) jest zwykle elastyczniejsza — to temat sam w sobie, ale warto zapamiętać regułę: dziedzicz, gdy relacja to “jest-czymś” z prawdziwie współdzielonym zachowaniem; komponuj, gdy to “korzysta z”.

// ❌ Dziedziczenie tam, gdzie relacja to "ma", nie "jest"
public class SamochodZly : Silnik { } // samochód NIE JEST silnikiem

// ✅ Kompozycja — samochód MA silnik
public class Samochod
{
    private readonly Silnik _silnik; // pole, nie klasa bazowa
    public Samochod(Silnik silnik) => _silnik = silnik;
    public void Uruchom() => _silnik.Wystartuj();
}

Kompozycja daje też coś, czego dziedziczenie nie potrafi łatwo: Silnik można podmienić w runtime (inny obiekt przekazany do konstruktora) — z dziedziczeniem klasa bazowa jest ustalona raz, na stałe, przy kompilacji.

Zobacz to w praktyce

Ten artykuł pokazał składnię i zasady na małych przykładach. Żeby zobaczyć dziedziczenie i polimorfizm w akcji na większym, realnym projekcie (system klas postaci w grze RPG — WojownikŁucznikMag dziedziczące po wspólnym Bohater), sprawdź Dziedziczenie i Polimorfizm w Grze RPG z serii Clean Architecture RPG.

Podsumowanie

Dziedziczenie eliminuje powtórzony kod między klasami modelującymi tę samą, szerszą kategorię bytów. Klucz do poprawnego polimorfizmu to virtual w klasie bazowej i override w pochodnej — słowo new kompiluje się, ale ukrywa metodę zamiast ją nadpisywać, dając różne wyniki w zależności od typu zmiennej, nie rzeczywistego typu obiektu. protected udostępnia szczegóły implementacji podklasom bez wystawiania ich na zewnątrz, a sealed świadomie blokuje dalsze rozszerzanie, gdy to część projektu, nie przeoczenie.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →