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

Polimorfizm w C# — statyczny i dynamiczny, na przykładzie kolekcji

“Jakie rodzaje polimorfizmu są w C#?” to pytanie, na które większość kandydatów odpowiada tylko połową prawdy — mówią o override, zapominając, że przeciążanie metod (ObliczPole(int) i ObliczPole(double)) to też polimorfizm, tylko innego rodzaju. Ten artykuł zakłada, że mechanikę virtual/override znasz już z Dziedziczenia w C# — tu pokazujemy, po co to w ogóle jest i jaki jest drugi rodzaj polimorfizmu, o którym rzadziej się mówi.

Dwa rodzaje polimorfizmu

Polimorfizm statyczny (compile-time) — kompilator decyduje, którą metodę wywołać, na podstawie sygnatury, jeszcze przed uruchomieniem programu. To przeciążanie metod (method overloading):

public class Kalkulator
{
    public int Dodaj(int a, int b) => a + b;
    public double Dodaj(double a, double b) => a + b;
    public int Dodaj(int a, int b, int c) => a + b + c;
}

kalkulator.Dodaj(2, 3);       // kompilator wybiera wersję (int, int)
kalkulator.Dodaj(2.5, 3.5);   // kompilator wybiera wersję (double, double)

Polimorfizm dynamiczny (runtime) — decyzja, która implementacja się wykona, zapada dopiero w trakcie działania programu, na podstawie rzeczywistego typu obiektu. To virtual/override, opisane szczegółowo w poprzednim artykule.

Oba to “polimorfizm” w sensie akademickim — “wiele form tego samego interfejsu” — ale mechanizm i moment podejmowania decyzji są zupełnie inne.

Polimorfizm statycznyPolimorfizm dynamiczny
Mechanizmprzeciążanie metodvirtual + override
Kiedy decyzja zapadaw trakcie kompilacjiw trakcie działania programu
Na podstawie czegosygnatura (typy argumentów)rzeczywisty typ obiektu w pamięci
Typowe zastosowanieta sama operacja dla różnych typów danych wejściowychta sama operacja, różne zachowanie zależnie od klasy

Prawdziwy powód, dla którego polimorfizm dynamiczny ma znaczenie

public abstract class Ksztalt
{
    public abstract double ObliczPole();
}

public class Kolo : Ksztalt
{
    public double Promien;
    public override double ObliczPole() => Math.PI * Promien * Promien;
}

public class Prostokat : Ksztalt
{
    public double Szerokosc, Wysokosc;
    public override double ObliczPole() => Szerokosc * Wysokosc;
}

public class Trojkat : Ksztalt
{
    public double Podstawa, Wysokosc;
    public override double ObliczPole() => 0.5 * Podstawa * Wysokosc;
}
List<Ksztalt> ksztalty = new()
{
    new Kolo { Promien = 3 },
    new Prostokat { Szerokosc = 4, Wysokosc = 5 },
    new Trojkat { Podstawa = 6, Wysokosc = 2 }
};

double sumaPol = 0;
foreach (var ksztalt in ksztalty)
{
    sumaPol += ksztalt.ObliczPole(); // ✅ za każdym razem wywołuje WŁAŚCIWĄ wersję, bez sprawdzania typu
}

To jest cały sens: pętla foreach nie zawiera ani jednego if (ksztalt is Kolo)List<Ksztalt> przechowuje trzy zupełnie różne konkretne typy, a wywołanie ObliczPole() samo trafia do właściwej implementacji dla każdego z nich. Dodanie czwartego kształtu (Trapez) nie wymaga zmiany ani jednej linijki tej pętli — tylko nowej klasy dziedziczącej po Ksztalt.

Bez polimorfizmu — ta sama logika z if/else

double ObliczPoleZle(object ksztalt)
{
    if (ksztalt is Kolo k) return Math.PI * k.Promien * k.Promien;
    if (ksztalt is Prostokat p) return p.Szerokosc * p.Wysokosc;
    if (ksztalt is Trojkat t) return 0.5 * t.Podstawa * t.Wysokosc;
    // każdy nowy kształt = kolejny if TUTAJ, w tym samym miejscu
    throw new ArgumentException("Nieznany kształt");
}

Bez polimorfizmu logika “co zrobić z tym typem” żyje w jednym scentralizowanym miejscu, które trzeba edytować przy każdym nowym typie. Z polimorfizmem ta logika jest rozproszona tam, gdzie logicznie należy — w każdej klasie kształtu osobno. To ten sam mechanizm, który napędza wzorzec Strategy i wiele innych wzorców projektowych.

Polimorfizm przez interfejs — nie tylko dziedziczenie

public interface IEksportowalny
{
    string DoJson();
}

public class Zamowienie : IEksportowalny
{
    public string DoJson() => "{ \"typ\": \"zamowienie\" }";
}

public class Faktura : IEksportowalny
{
    public string DoJson() => "{ \"typ\": \"faktura\" }";
}
List<IEksportowalny> doEksportu = new() { new Zamowienie(), new Faktura() };
foreach (var element in doEksportu)
{
    Console.WriteLine(element.DoJson()); // polimorfizm bez wspólnej klasy bazowej
}

Zamowienie i Faktura nie mają ze sobą nic wspólnego poza tym samym interfejsem — a mimo to kolekcja traktuje je jednolicie. To polimorfizm przez interfejs, alternatywa dla dziedziczenia, gdy klasy nie mają realnej relacji “jest-czymś” (szczegóły różnicy w Klasa abstrakcyjna vs interfejs).

Polimorfizm ułatwia testowanie

public interface IProcesorPlatnosci
{
    bool Przetworz(decimal kwota);
}

public class FakeProcesorPlatnosci : IProcesorPlatnosci // fałszywa implementacja tylko do testów
{
    public bool Przetworz(decimal kwota) => kwota > 0; // brak realnego połączenia z bramką płatności
}

[Fact]
public void SkladanieZamowienia_OdrzucaZeroweKwoty()
{
    var serwis = new SerwisZamowien(new FakeProcesorPlatnosci()); // podstawiony fake
    var wynik = serwis.ZlozZamowienie(kwota: 0);
    Assert.False(wynik);
}

Kod korzystający z IProcesorPlatnosci (a nie z konkretnej klasy StripeProcesor) w ogóle nie musi wiedzieć, że w teście dostał fałszywą implementację — polimorfizm sprawia, że FakeProcesorPlatnosci jest tak samo “prawdziwym” IProcesorPlatnosci, jak realna integracja z bramką płatności. To ten sam mechanizm, który widziałeś przy testowaniu wzorców Observer i Command — polimorfizm jest fundamentem, na którym stoi cały testowalny kod w C#.

Pułapka: przeciążanie i niejawna konwersja typów

void Pokaz(int liczba) => Console.WriteLine("int: " + liczba);
void Pokaz(long liczba) => Console.WriteLine("long: " + liczba);

Pokaz(42); // "int: 42" — 42 to int, dopasowanie dokładne wygrywa
short s = 5;
Pokaz(s);  // "int: 5" — short niejawnie konwertuje się do int PRZED próbą dopasowania do long

Przy przeciążaniu kompilator wybiera najbardziej dokładne dopasowanie sygnatury, a gdy go brak — najbliższe przez niejawną konwersję. To zwykle działa intuicyjnie, ale przy typach, które konwertują się niejawnie w kilku kierunkach naraz, wybór “najlepszego” przeciążenia bywa nieoczywisty — jeśli overload resolution zaczyna zaskakiwać, jawne rzutowanie argumentu na oczekiwany typ usuwa niejednoznaczność.

Podsumowanie

C# ma dwa rodzaje polimorfizmu: statyczny (przeciążanie metod, decyzja kompilatora na podstawie sygnatury) i dynamiczny (virtual/override, decyzja w runtime na podstawie rzeczywistego typu obiektu). Prawdziwa wartość polimorfizmu dynamicznego ujawnia się w kolekcjach różnych typów traktowanych jednolicie — foreach bez jednego if-a sprawdzającego typ, z logiką rozproszoną tam, gdzie logicznie należy. Ten sam efekt osiągniesz przez wspólną klasę bazową (dziedziczenie) albo wspólny interfejs — wybór zależy od tego, czy typy łączy realna relacja “jest-czymś”, czy tylko wspólna zdolność.

👨‍💻
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.

Dodaj komentarz

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ę →