Wszystkie wpisy

Programowanie obiektowe w C# - klasy, dziedziczenie, polimorfizm

Klasy i obiekty, hermetyzacja przez właściwości, dziedziczenie z virtual/override, polimorfizm w praktyce i interfejsy jako kontrakty. Druga część kursu C#.


W poprzednim wpisie Składnia i system typów C# poznałeś składnię, typy oraz różnicę między stosem a stertą. Teraz wchodzimy w paradygmat, który definiuje cały C# i większość kodu, jaki napiszesz: programowanie obiektowe. Klasy, dziedziczenie, polimorfizm i interfejsy to nie modne hasła z rozmów rekrutacyjnych - to narzędzia do organizowania złożoności.

W tej części zbudujemy fundament pod system symulacji pojazdów. Po kolei, z naciskiem na dlaczego każdy mechanizm istnieje, a nie tylko jak go zapisać.

1. Klasy i obiekty #

Klasa to projekt, szablon, plan budowy. Określa, jakie dane będzie przechowywał obiekt oraz co będzie potrafił. Obiekt to konkretna instancja stworzona z tego projektu - jeden, namacalny egzemplarz z własną kopią pól.

public class Vehicle
{
    public string Brand { get; set; }
    public double Speed { get; set; }

    public void Accelerate(double delta)
    {
        Speed += delta;
    }
}

// Tworzymy konkretne obiekty (instancje):
Vehicle car = new Vehicle { Brand = "Toyota", Speed = 0 };
Vehicle bicycle = new Vehicle { Brand = "Romet", Speed = 0 };

car.Accelerate(50);
// car.Speed == 50, bicycle.Speed nadal 0 - każdy obiekt ma własny stan

Powiązanie z poprzednim wpisem: klasy to typy referencyjne #

Każda klasa jest typem referencyjnym (alokowana na stercie). Instrukcja Vehicle bicycle = car; nie kopiuje obiektu - przepisuje adres. Po bicycle.Speed = 100 zmieni się również car.Speed. To wciąż to samo miejsce w pamięci.

Jeśli potrzebujesz lekkiego, kopiowanego odpowiednika klasy, użyj struct (typu wartościowego). W praktyce klasy są jednak domyślnym wyborem w zdecydowanej większości przypadków - struktury rezerwujesz dla małych, niemutowalnych “pakietów danych” (Point, Vector2, DateTime).

Konstruktor #

Konstruktor to specjalna metoda, która uruchamia się przy new. Pozwala wymusić, że obiekt nie powstanie bez wymaganych danych:

public class Vehicle
{
    public string Brand { get; }   // tylko get → niemutowalna po stworzeniu

    public Vehicle(string brand)
    {
        Brand = brand;
    }
}

var p = new Vehicle("Skoda");      // OK
var pusty = new Vehicle();          // BŁĄD kompilacji - brak konstruktora bezparametrowego

Słowo kluczowe this #

Co to jest: this to referencja do bieżącego obiektu - tego, na którym aktualnie wywołano metodę. Wewnątrz metody klasy zawsze masz dostęp do this bez deklarowania.

Dlaczego istnieje: metoda klasy jest jednym fragmentem kodu, ale działa na wielu obiektach. Gdy piszesz car.Accelerate(50) i bicycle.Accelerate(20), ten sam kod metody Accelerate musi wiedzieć, na którym obiekcie operuje. this to właśnie ten ukryty parametr, który CLR przekazuje za kulisami.

Trzy typowe zastosowania:

public class Vehicle
{
    private double _speed;

    // 1. Rozróżnienie pola od parametru o tej samej nazwie
    public Vehicle(double speed)
    {
        this._speed = speed;   // this = pole klasy, bez this = parametr
    }

    // 2. Przekazanie samego siebie do innej metody/obiektu
    public void Register(Garage garaz)
    {
        garaz.Dodaj(this);            // "weź mnie i mnie zapisz"
    }

    // 3. Chainowanie konstruktorów (jeden konstruktor woła drugi)
    public Vehicle() : this(0) { }     // domyślny → wywołaj ten z parametrem
}

Pułapka: this nie istnieje w metodach statycznych (static). Metody statyczne nie należą do obiektu, należą do klasy - nie ma więc “bieżącego obiektu”, do którego można by się odwołać.

2. Hermetyzacja - kontrola dostępu #

Hermetyzacja to ukrywanie wewnętrznego stanu i wymuszanie, że dostęp do niego prowadzi przez ściśle określone bramki. Dlaczego? Ponieważ bez tego dowolny fragment kodu w aplikacji może wprowadzić obiekt w niespójny stan:

// BEZ hermetyzacji:
public class Vehicle
{
    public double Speed;   // pole publiczne
}

var p = new Vehicle();
p.Speed = -500;            // brak ochrony przed nieprawidłową wartością

Z hermetyzacją kontrolujesz każdą zmianę:

public class Vehicle
{
    private double _speed;  // pole prywatne - niedostępne z zewnątrz

    public double Speed
    {
        get => _speed;
        set
        {
            if (value < 0)
                throw new ArgumentException("Speed cannot be negative");
            _speed = value;
        }
    }
}

Modyfikatory dostępu - od najszerszego do najwęższego #

ModyfikatorSkąd widoczneKiedy używać
publicwszędziepubliczne API klasy - co kod zewnętrzny może z nią zrobić
internalwewnątrz tego samego assembly (DLL/EXE)typy pomocnicze, których nie chcemy eksportować
protectedklasa oraz jej potomkowieAPI dla klas dziedziczących, ukryte przed resztą
protected internalklasa, potomkowie i to samo assemblyrzadko - bardziej “lub” niż “i”
private protectedklasa i potomkowie w tym samym assemblyC# 7.2+, rzadko stosowane
privatewyłącznie ta klasawszystko, czego reszta kodu nie musi widzieć

Zasada ogólna: zaczynaj od private. Otwieraj na zewnątrz tylko to, co rzeczywiście musi być widoczne. Każde public to długoterminowe zobowiązanie - obietnica, że ten fragment API nie zniknie znienacka.

Właściwość a pole #

Pole (private double _speed;) to surowa zmienna w pamięci. Właściwość (public double Speed { get; set; }) to “pole z bramkami” - kompilator generuje pod spodem metody get_Speed() oraz set_Speed(value). Z zewnątrz wygląda jak pole, od wewnątrz daje ci pełną kontrolę.

Automatyczne właściwości od C# 6:

public string Brand { get; set; }                  // publiczny get/set
public string Brand { get; private set; }           // publiczny get, prywatny set
public string Brand { get; init; }                  // ustawiane wyłącznie przy new (C# 9+)
public required string Brand { get; init; }         // C# 11+ - obowiązkowe przy new

Niemutowalność (init, required) to potężne narzędzie - jeśli pole nie powinno się zmieniać po stworzeniu obiektu, wymuś to typem, a nie konwencją.

3. Dziedziczenie i polimorfizm #

Dziedziczenie to mechanizm budowania klas pochodnych na bazie już istniejących. Klasa Car “jest pojazdem” - dziedziczy wszystko z Vehicle i dodaje własne właściwości oraz zachowania.

public abstract class Vehicle
{
    public double Speed { get; protected set; }
    public abstract void Move();    // musi zostać nadpisana
    public virtual string Describe()           // ma domyślną implementację, można nadpisać
        => $"Vehicle drives at {Speed} km/h";
}

public class Car : Vehicle
{
    public override void Move()
    {
        Speed = 80;
        Console.WriteLine("Car starts");
    }
}

Pułapki dziedziczenia #

  • Pojedyncze dziedziczenie: klasa dziedziczy z maksymalnie jednej klasy. C# rozwiązuje znany z C++ “diamentowy problem” właśnie przez ten zakaz.
  • sealed: klasę można zamknąć słowem sealed class X, by zabronić dalszego dziedziczenia. Postawa defensywna - “ta klasa jest finalna, nie buduj na niej”.
  • Każda klasa dziedziczy z System.Object - stąd metody ToString(), GetHashCode() i Equals() dostępne wszędzie.

virtual kontra abstract kontra new #

To trzy różne strategie projektowania klas bazowych. Wybór między nimi zmienia kontrakt z klasami pochodnymi.

abstract - “ty musisz zdecydować” #

Co to jest: metoda zadeklarowana bez ciała. Klasa bazowa mówi: “wiem, że ta operacja musi istnieć, ale nie mam pojęcia, jak ją wykonać - to twoja sprawa, klaso pochodna”.

Dlaczego istnieje: czasem wspólna nazwa operacji ma sens (każdy pojazd “porusza się”), ale nie istnieje sensowna domyślna implementacja. Samochód jedzie inaczej niż rower inaczej niż łódź. Klasa bazowa jest zbyt ogólna, by cokolwiek konkretnego zrobić.

public abstract class Vehicle
{
    public abstract void Move();   // brak ciała - kropka, nie klamry
}

Konsekwencje:

  • Klasa z choć jedną metodą abstract sama musi być abstract (kompilator wymusi).
  • Klasy abstrakcyjnej nie można utworzyć przez new. To “niedokończony projekt”.
  • Każda klasa pochodna musi dostarczyć implementację (przez override) - inaczej kod się nie skompiluje.

virtual - “mam pomysł, ale możesz mieć lepszy” #

Co to jest: metoda z domyślną implementacją, którą klasy pochodne mogą nadpisać.

Dlaczego istnieje: często klasa bazowa ma sensowny domyślny algorytm, ale chce zostawić furtkę dla specjalnych przypadków. Domyślny Describe() pojazdu może po prostu wypisać markę i prędkość. Samochód policyjny chce dodać do tego “numer patrolu” - więc nadpisze metodę.

public class Vehicle
{
    public virtual string Describe() => $"Vehicle at {Speed} km/h";
}

public class PoliceCar : Vehicle
{
    public override string Describe() => base.Describe() + " (patrol car)";
}

Konsekwencje:

  • Klasa pochodna może, ale nie musi nadpisywać.
  • Wywołanie metody przez referencję typu bazowego trafia do najbardziej pochodnej wersji (polimorfizm - szczegóły niżej).

new - ukrywanie, nie nadpisywanie #

Co to jest: sposób na “schowanie” metody z klasy bazowej pod tą samą nazwą w klasie pochodnej. To nie jest polimorfizm - to dwie różne metody dzielące nazwę.

Dlaczego istnieje (rzadko ma sens): czasem dziedziczysz z klasy, której nie kontrolujesz (np. z biblioteki), a chcesz dodać metodę o nazwie, której tamten autor już użył. new mówi kompilatorowi: “wiem, że nadpisuję nazwę - i robię to świadomie”.

Pułapka: jeśli zmienna ma typ klasy bazowej, wywoła się wersja z klasy bazowej, mimo że obiekt jest klasy pochodnej. To zachowanie zależne od typu zmiennej, nie typu obiektu:

public class Vehicle
{
    public virtual void MakeSound() => Console.WriteLine("Vehicle");
}
public class Car : Vehicle
{
    public override void MakeSound() => Console.WriteLine("Brum");
}
public class Bicycle : Vehicle
{
    public new void MakeSound() => Console.WriteLine("Dzyń");  // UKRYWANIE
}

Vehicle p = new Bicycle();
p.MakeSound();   // → "Vehicle" - decyduje typ ZMIENNEJ, nie obiektu

Vehicle s = new Car();
s.MakeSound();   // → "Brum" - polimorfizm: decyduje typ OBIEKTU

To kluczowa różnica. override daje prawdziwy polimorfizm (wyszukanie metody przez vtable w trakcie wykonania). new jest jedynie sztuczką składniową, która rozsynchronizowuje typ zmiennej z typem obiektu.

Polimorfizm - jedna z trzech podstaw OOP #

Co to jest: zdolność pojedynczego wywołania metody do uruchamiania różnego kodu w zależności od rzeczywistego typu obiektu, na którym jest wywoływana. “Polimorfizm” = “wiele kształtów” - jedna nazwa, wiele zachowań.

Dlaczego istnieje: bez polimorfizmu kod obsługujący różne typy musiałby cały czas pytać “jakim ty jesteś typem?” przez kaskady if/switch:

// BEZ polimorfizmu - kruchy, rozrasta się przy każdym nowym typie
foreach (Vehicle p in fleet)
{
    if (p is Car) { Console.WriteLine("Brum"); }
    else if (p is Bicycle) { Console.WriteLine("Dzyń"); }
    else if (p is Motorcycle) { Console.WriteLine("Wrrr"); }
    // dodajesz nowy typ → musisz znaleźć i edytować wszystkie kaskady w aplikacji
}
// Z polimorfizmem - każda klasa zna swoje zachowanie, kod kliencki nie zmienia się
foreach (Vehicle p in fleet)
{
    p.MakeSound();   // sam obiekt wie, co zagrać
}

Mechanizm pod spodem - tablica wirtualnych metod (vtable):

Gdy oznaczasz metodę jako virtual, kompilator generuje dla klasy specjalną tablicę - vtable. Każdy slot w niej wskazuje na konkretną implementację metody. Każda klasa pochodna dziedziczy tę tablicę, ale podmienia wskaźniki dla metod, które nadpisała przez override.

Każdy obiekt (instancja klasy) ma ukrytą “etykietkę” mówiącą, do której vtable należy. Gdy piszesz p.MakeSound():

  1. Środowisko uruchomieniowe sprawdza etykietkę obiektu p.
  2. Trafia do vtable klasy rzeczywistej (np. Car), nie deklarowanej (Vehicle).
  3. Pobiera adres właściwej implementacji MakeSound i ją wywołuje.

To wszystko dzieje się w trakcie wykonania, w jednej dodatkowej operacji - praktycznie bez kosztu. Dlatego virtual ma narzut, a zwykłe metody nie - kompilator może dla zwykłych po prostu wkleić adres na sztywno.

List<Vehicle> fleet = new List<Vehicle>
{
    new Car(),
    new Bicycle(),
    new Motorcycle(),
};

foreach (Vehicle p in fleet)
{
    p.Move();   // każdy element reaguje po swojemu
}

Operujesz na wszystkich elementach jako na Vehicle, lecz środowisko uruchomieniowe wie, że to konkretny Car czy Bicycle, i wywołuje właściwą wersję metody Move(). Na tym polega siła programowania obiektowego - wspólny interfejs, różne implementacje.

Trzy rodzaje polimorfizmu w C#:

  1. Polimorfizm dziedziczenia - to, co właśnie omówiliśmy. Klasy pochodne nadpisują virtual/abstract metody.
  2. Polimorfizm interfejsów - różne klasy implementują ten sam interfejs (IEnumerable, IComparable). Omówimy szczegółowo w sekcji 4.
  3. Polimorfizm ad-hoc (overloading) - kilka metod o tej samej nazwie różniących się parametrami. To rozróżnia kompilator, nie środowisko uruchomieniowe:
public void Dodaj(int x) { ... }
public void Dodaj(string s) { ... }
public void Dodaj(int x, int y) { ... }
// kompilator wybiera wersję na podstawie argumentów

base. - odwołanie do klasy bazowej #

Co to jest: base to słowo kluczowe, które w klasie pochodnej daje dostęp do wersji metod/właściwości/konstruktorów z klasy bazowej, nawet jeśli klasa pochodna je nadpisała.

Dlaczego istnieje: nadpisanie metody (override) zastępuje wersję z klasy bazowej. Czasem jednak nadpisana wersja chce rozszerzyć, a nie zastąpić dotychczasową logikę - dodać coś na początku albo na końcu. Bez base. nie miałbyś sposobu odwołać się do oryginału - twoja własna metoda by się zaczęła wywoływać rekurencyjnie i wpadła w pętlę.

Dwa główne zastosowania:

public class Vehicle
{
    public virtual string Describe() => $"Vehicle at {Speed} km/h";

    public Vehicle(double speed)
    {
        Speed = speed;
    }
}

public class Car : Vehicle
{
    // 1. Rozszerzenie nadpisanej metody - "to co rodzic + coś własnego"
    public override string Describe()
        => base.Describe() + " (4x4 drive)";

    // 2. Wywołanie konstruktora klasy bazowej - "rodzic ustawi swoje pola, ja swoje"
    public Car(double speed, int doorCount)
        : base(speed)              // wywołaj Vehicle(speed) zanim zaczniesz
    {
        DoorCount = doorCount;
    }

    public int DoorCount { get; }
}

Pułapka: base.Cos() zawsze wskazuje na klasę bezpośrednio nadrzędną. Jeśli masz łańcuch Cieżarowka : Car : Vehicle, to base.Describe() w Cieżarowka wywoła wersję z Car, nie z Vehicle. Nie ma sposobu (poza refleksją) “przeskoczyć” jednego poziomu w górę.

Konstruktor bazowy uruchamia się zawsze pierwszy. Nawet jeśli nie napiszesz : base(...), kompilator domyślnie woła base() - bezparametrowy konstruktor klasy bazowej. Jeśli ten nie istnieje, kod się nie skompiluje i musisz jawnie wskazać, który konstruktor bazowy chcesz wywołać.

4. Interfejsy - kontrakty #

Co to jest: interfejs to lista obietnic - zbiór sygnatur metod, właściwości i zdarzeń, które każda klasa implementująca ten interfejs musi dostarczyć. Określa co ma zostać zrobione, lecz nie jak. To umowa bez kodu wykonawczego.

Dlaczego istnieje: OOP postuluje rozdzielenie kontraktu od implementacji. Kod kliencki powinien móc powiedzieć “potrzebuję czegoś, co umie się tankować”, a nie “potrzebuję klasy Samochód, klasy Motorcycle, klasy Łódź…”. Interfejs jest tym kontraktem - wspólnym językiem, którym klasy komunikują swoje możliwości, bez ujawniania, jak je realizują.

Analogia: umowa o pracę w firmie nie określa, w której kawiarni zjesz lunch ani jakimi długopisami pracujesz. Mówi, jakie zadania masz wykonywać i jakie wyniki dostarczyć. Implementacja (twój dzień pracy) jest twoją sprawą. Tak samo IEnumerable mówi “umiem przechodzić po elementach”, nic więcej.

public interface IFuelable
{
    void Refuel(double liters);
    double FuelLevel { get; }
}

W interfejsie:

  • Brak modyfikatorów dostępu - wszystko jest domyślnie publiczne (każdy, kto widzi typ interfejsu, widzi wszystkie jego metody).
  • Brak ciał metod (od C# 8 można dodać domyślne implementacje - patrz tabela niżej).
  • Brak pól instancji - tylko właściwości (które są parą metod get/set pod spodem).
  • Brak konstruktora - interfejsu nie tworzy się przez new.

Klasa, która chce być “tankowalna”, realizuje ten kontrakt:

public class Car : Vehicle, IFuelable
{
    public double FuelLevel { get; private set; }

    public void Refuel(double liters)
    {
        if (liters <= 0) return;
        FuelLevel += liters;
    }
}

Klasa musi dostarczyć wszystkie elementy z interfejsu - inaczej kod się nie skompiluje. Kompilator pełni rolę strażnika kontraktu.

Dlaczego interfejsy, skoro mamy klasy abstrakcyjne? #

Klasa abstrakcyjnaInterfejs
Wielokrotne dziedziczenieNIE - jedna bazaTAK - wiele interfejsów
Stan (pola)TAKNIE (tylko właściwości, do C# 8)
KonstruktorTAKNIE
Modyfikatory dostępu metodpublic, protected, privatewszystkie public (do C# 8)
Częściowa implementacjaTAK (mieszanka virtual i abstract)TAK od C# 8 (metody domyślne)

Zasada: jeśli typy tym samym (Car jest Vehicle) → klasa bazowa. Jeśli typy robią to samo (Car jest IFuelable, drukarka również może być IFuelable) → interfejs.

W C# klasa może dziedziczyć tylko po jednej klasie, ale implementować dowolnie wiele interfejsów:

public class Car : Vehicle, IFuelable, IInsurable, IIdentifiable
{
    // musi zaimplementować wszystkie sygnatury ze wszystkich interfejsów
}

Konwencja: prefiks I #

Wszystkie interfejsy w bibliotece standardowej zaczynają się od I: IDisposable, IEnumerable, IComparable, IDictionary. Warto trzymać się tej konwencji - daje to natychmiastową czytelność: gdy widzisz IFoo w sygnaturze, od razu wiesz, że to kontrakt, a nie konkretna klasa.

Programowanie do interfejsu #

Najpotężniejszy idiom OOP: zależ od abstrakcji, a nie od konkretnych klas.

// ŹLE: wiążesz się z konkretną listą
public void Wyswietl(List<string> elementy) { ... }

// LEPIEJ: każda kolekcja "wyliczalna" pasuje
public void Wyswietl(IEnumerable<string> elementy) { ... }

Drugi wariant przyjmie List<string>, string[], HashSet<string>, wynik Select(...) z LINQ oraz dowolną własną klasę implementującą IEnumerable<string>. Pierwsza wersja zaakceptuje wyłącznie List<string>. Programowanie do interfejsu to elastyczność.

To samo dotyczy wstrzykiwania zależności (Dependency Injection) - klasy zależą od ILogger, a nie od konkretnego ConsoleLogger. W testach podstawiasz obiekt zastępczy (mock), w środowisku produkcyjnym - prawdziwą implementację. Wpis o DI w osobnej części kursu.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Klasy i obiekty - szablon kontra instancja, konstruktor wymusza poprawny stan początkowy, this to referencja do bieżącego obiektu
  • Hermetyzacja - private jako domyślny modyfikator, właściwości jako “pola z bramkami”, init i required do niemutowalności
  • Dziedziczenie - składnia : Baza, pojedyncze dziedziczenie, abstract wymusza implementację, virtual z override daje polimorfizm, new to jedynie ukrywanie
  • Polimorfizm - List<Vehicle> z mieszanką potomków, każdy obiekt reaguje po swojemu dzięki vtable
  • Interfejsy - kontrakt bez stanu, prefiks I, programowanie do abstrakcji daje elastyczność

W następnym wpisie Generyki i kolekcje w C# przejdziemy do kolekcji (List<T>, Dictionary<K,V>, HashSet<T>) i mechanizmu, który czyni je bezpiecznymi - generyków z ograniczeniami where.

Najpierw quiz i zadanie poniżej. Zadanie uruchomisz na żywo - przycisk Uruchom w modalu kompiluje twój kod na codapi.org.

Podobne wpisy

🔗 Linkują tu