Klasa abstrakcyjna vs interfejs w C# — kiedy użyć którego
“Jaka jest różnica między klasą abstrakcyjną a interfejsem?” pada na niemal każdej rozmowie rekrutacyjnej na Junior .NET Developera. Odpowiedź “interfejs nie ma implementacji, a klasa abstrakcyjna może mieć” jest technicznie poprawna, ale nie pokazuje, że od C# 8 nawet to przestało być jednoznaczne — i nie odpowiada na prawdziwe pytanie: kiedy użyć którego.
Problem: oba definiują kontrakt
public interface IPlatnosc
{
void Przetworz(decimal kwota);
}
public abstract class PlatnoscBase
{
public abstract void Przetworz(decimal kwota);
}
Obie wersje wymuszają na klasie implementującej metodę Przetworz. Obie kompilują się. Obie “działają”. Różnica ujawnia się dopiero, gdy dochodzą kolejne wymagania — i to one decydują, którego narzędzia użyć.
Interfejs — kontrakt “potrafi to zrobić”
public interface IPlatnosc
{
void Przetworz(decimal kwota);
}
public interface IPodlegaZwrotowi
{
void Zwroc(decimal kwota);
}
public class PlatnoscKarta : IPlatnosc, IPodlegaZwrotowi // ✅ dwa interfejsy naraz
{
public void Przetworz(decimal kwota) => Console.WriteLine($"Karta: {kwota} zł");
public void Zwroc(decimal kwota) => Console.WriteLine($"Zwrot na kartę: {kwota} zł");
}
Klasa może implementować dowolną liczbę interfejsów — to modelowanie “co ten obiekt potrafi zrobić”, niezależnie od tego, czym jest. PlatnoscKarta potrafi przetwarzać płatności i potrafi zwracać pieniądze — to dwie niezależne zdolności, nie hierarchia dziedziczenia.
Interfejs tradycyjnie nie ma stanu (pól) ani implementacji — jest czystym kontraktem. Od C# 8 może mieć domyślną implementację metody:
public interface IPlatnosc
{
void Przetworz(decimal kwota);
void Loguj(decimal kwota) => Console.WriteLine($"[LOG] Płatność: {kwota} zł"); // domyślna implementacja
}
To nie zamienia interfejsu w klasę abstrakcyjną — nadal nie ma pól ani konstruktora, a domyślna implementacja to głównie narzędzie do bezpiecznego rozszerzania istniejących interfejsów bez łamania klas, które je już implementują.
Klasa abstrakcyjna — kontrakt “jest tym” ze wspólnym kodem
public abstract class PracownikBase
{
protected readonly string Imie;
protected PracownikBase(string imie) => Imie = imie; // ✅ konstruktor — interfejs tego nie ma
public abstract decimal ObliczWynagrodzenie(); // musi zaimplementować podklasa
public void WyplacWynagrodzenie() // ✅ wspólna implementacja dla WSZYSTKICH podklas
{
var kwota = ObliczWynagrodzenie();
Console.WriteLine($"{Imie}: wypłacono {kwota} zł");
}
}
public class PracownikEtatowy : PracownikBase
{
private readonly decimal _pensja;
public PracownikEtatowy(string imie, decimal pensja) : base(imie) => _pensja = pensja;
public override decimal ObliczWynagrodzenie() => _pensja;
}
public class PracownikNaUmowe : PracownikBase
{
private readonly decimal _stawka;
private readonly int _godziny;
public PracownikNaUmowe(string imie, decimal stawka, int godziny) : base(imie)
=> (_stawka, _godziny) = (stawka, godziny);
public override decimal ObliczWynagrodzenie() => _stawka * _godziny;
}
Klasa abstrakcyjna może mieć pola, konstruktor i w pełni zaimplementowane metody dzielone przez wszystkie podklasy — WyplacWynagrodzenie() napisane raz, działa dla każdego typu pracownika. To modelowanie “czym ten obiekt jest” (każdy PracownikBase), z realnym, współdzielonym stanem i zachowaniem. Cena: klasa może dziedziczyć tylko po jednej klasie bazowej — C# nie ma wielokrotnego dziedziczenia klas.
Tabela decyzyjna
| Interfejs | Klasa abstrakcyjna | |
|---|---|---|
| Wielokrotne dziedziczenie | ✅ dowolna liczba | ❌ tylko jedna klasa bazowa |
| Pola (stan) | ❌ nie | ✅ tak |
| Konstruktor | ❌ nie | ✅ tak |
| Domyślna implementacja | ⚠️ częściowo (C# 8+) | ✅ pełna |
| Modeluje | “potrafi to zrobić” (capability) | “jest tym” (hierarchia, wspólny kod) |
Praktyczna reguła: jeśli różne, niepowiązane ze sobą klasy mają robić to samo (logowanie, serializacja, walidacja) — interfejs. Jeśli grupa ściśle powiązanych klas dzieli realny kod i stan (nie tylko sygnaturę metody) — klasa abstrakcyjna.
Pułapka: klasa abstrakcyjna tam, gdzie potrzebujesz kilku zdolności
public abstract class EntitaBase { /* ... */ }
public abstract class LogowalnaBase { /* ... */ }
// ❌ nie skompiluje się — C# nie ma wielokrotnego dziedziczenia klas
public class Zamowienie : EntitaBase, LogowalnaBase { }
Jeśli zaprojektujesz LogowalnaBase jako klasę abstrakcyjną, każda klasa, która ma “umieć się logować”, musi z niej dziedziczyć — a jeśli już dziedziczy po czymś innym, utknęła. Rozwiązanie: capability (możliwość logowania) powinna być interfejsem, nie klasą bazową.
public interface ILogowalna { void Zaloguj(); }
public class Zamowienie : EntitaBase, ILogowalna // ✅ jedna klasa bazowa + dowolnie wiele interfejsów
{
public void Zaloguj() => Console.WriteLine("Zamówienie zalogowane");
}
Pułapka: interfejs tam, gdzie potrzebujesz współdzielonego stanu
public interface IPracownik
{
string Imie { get; } // ❌ interfejs wymusza implementację getterów w KAŻDEJ klasie osobno
decimal ObliczWynagrodzenie();
}
public class PracownikEtatowy : IPracownik
{
public string Imie { get; } // ta sama logika...
public PracownikEtatowy(string imie) => Imie = imie;
public decimal ObliczWynagrodzenie() => 5000m;
}
public class PracownikNaUmowe : IPracownik
{
public string Imie { get; } // ...powtórzona w każdej klasie
public PracownikNaUmowe(string imie) => Imie = imie;
public decimal ObliczWynagrodzenie() => 3200m;
}
Każda implementacja IPracownik musi od nowa napisać przechowywanie Imie — interfejs nie może tego zrobić za nie. To dokładnie sytuacja, w której klasa abstrakcyjna z polem protected readonly string Imie (jak w przykładzie wyżej) eliminuje powtórzony kod.
Można łączyć oba naraz
public interface IPodlegaZwrotowi { void Zwroc(decimal kwota); }
public abstract class PlatnoscBase : IPodlegaZwrotowi // klasa abstrakcyjna implementująca interfejs
{
protected readonly string NumerTransakcji;
protected PlatnoscBase(string numer) => NumerTransakcji = numer;
public abstract void Przetworz(decimal kwota);
public virtual void Zwroc(decimal kwota) => Console.WriteLine($"Zwrot {kwota} zł dla {NumerTransakcji}");
}
To nie jest wybór “albo-albo” na całe życie projektu — klasa abstrakcyjna daje wspólny stan i częściową implementację, jednocześnie deklarując zgodność z interfejsem, którego mogą używać inne, niepowiązane klasy w systemie.
Szybkie pytania uzupełniające (typowe na rozmowie)
Czy interfejs może mieć pola? Nie — nawet po C# 8. Może mieć właściwości ({ get; set; }), ale nie gołe pola (private string _imie). Właściwość w interfejsie to nadal tylko sygnatura — implementująca klasa sama decyduje, czy stoi za nią pole, czy coś obliczanego w locie.
Czy można stworzyć instancję klasy abstrakcyjnej? Nie:
var p = new PracownikBase("Anna"); // ❌ błąd kompilacji — nie można utworzyć instancji klasy abstrakcyjnej
Klasa abstrakcyjna istnieje wyłącznie po to, żeby po niej dziedziczyć — tworzysz instancje jej konkretnych podklas (PracownikEtatowy, PracownikNaUmowe), nigdy jej samej.
Czy interfejs może dziedziczyć po innym interfejsie? Tak, i to wielokrotnie:
public interface IPodlegaZwrotowi : IPlatnosc, ILogowalna { } // łączy trzy kontrakty w jeden
Podsumowanie
Interfejs modeluje zdolność (“potrafi to zrobić”) — klasa może mieć ich dowolnie wiele, ale interfejs nie niesie stanu ani konstruktora. Klasa abstrakcyjna modeluje tożsamość i wspólną implementację (“jest tym, dzieli ten kod”) — daje pola, konstruktor i gotowe metody, kosztem ograniczenia do jednej klasy bazowej. W realnym kodzie najczęściej potrzebujesz obu naraz: klasy abstrakcyjnej dla rdzenia współdzielonej logiki i interfejsów dla dodatkowych, niezależnych zdolności doczepianych do niej z boku.
🚀 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ę →
1 comment