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 statyczny | Polimorfizm dynamiczny | |
|---|---|---|
| Mechanizm | przeciążanie metod | virtual + override |
| Kiedy decyzja zapada | w trakcie kompilacji | w trakcie działania programu |
| Na podstawie czego | sygnatura (typy argumentów) | rzeczywisty typ obiektu w pamięci |
| Typowe zastosowanie | ta sama operacja dla różnych typów danych wejściowych | ta 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ść.
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →