CodeGym /Kursy /C# SELF /Związek polimorfizmu z metodami abstrakcyjnymi

Związek polimorfizmu z metodami abstrakcyjnymi

C# SELF
Poziom 21 , Lekcja 4
Dostępny

1. Wprowadzenie

Wróćmy do naszego zoo. Mamy bazową klasę Animal (Zwierzę) i jej pochodne: Dog (Pies), Cat (Kot), Fish (Ryba).

W poprzednich wykładach dodaliśmy do Animal metodę virtual MakeSound():

public class Animal
{
    public string Name { get; set; }
    public int Age { get; set; }

    public virtual void MakeSound() // Metoda virtual
    {
        Console.WriteLine("Jakieś zwierzę wydaje dźwięk."); // Domyślna implementacja
    }

    public void Sleep() // Zwykła metoda
    {
        Console.WriteLine($"{Name} śpi.");
    }
}

public class Dog : Animal
{
    public override void MakeSound() // Nadpisujemy dźwięk dla psa
    {
        Console.WriteLine("Hau-hau!");
    }
}

public class Cat : Animal
{
    public override void MakeSound() // Nadpisujemy dźwięk dla kota
    {
        Console.WriteLine("Miau!");
    }
}

To działa super! Gdy tworzymy Dog albo Cat i wywołujemy MakeSound(), słyszymy ich unikalne dźwięki. Ale co, jeśli stworzymy po prostu Animal?

Animal genericAnimal = new Animal();
genericAnimal.Name = "Nieznana istota";
genericAnimal.MakeSound(); // Wypisze: "Jakieś zwierzę wydaje dźwięk."

Wygląda logicznie. Ale czasem bywa tak, że domyślna implementacja po prostu nie ma sensu. Co, jeśli Animal – to nie konkretne zwierzę, a raczej koncepcja? "Zwierzę" samo w sobie nie wydaje konkretnego dźwięku, to robią już konkretne gatunki. Albo wyobraź sobie, że mamy klasę Shape (Figura) i ona ma metodę CalculateArea() (ObliczPole). Jakie pole powinna obliczać po prostu Figura? Żadne! Pole ma Koło, Kwadrat, ale nie abstrakcyjna "Figura".

W takich przypadkach, gdy klasa bazowa nie może (albo nie powinna) dawać sensownej domyślnej implementacji, ale zmusza wszystkie swoje pochodne do zaimplementowania tej metody, z pomocą przychodzą metody abstrakcyjne i klasy abstrakcyjne.

2. Klasy abstrakcyjne: kiedy projekt to jeszcze nie dom

Wyobraź sobie, że jesteś architektem i tworzysz projekt typowego domu. Ale nie po prostu domu, a "Koncepcyjnego Domu". Ma on wspólne cechy: ściany, dach, fundament. Ale jeszcze nie wiesz, czy to będzie parterowy domek czy wielopiętrowy wieżowiec. Niektóre części projektu będą konkretne (np. wysokość sufitów na parterze), a niektóre – na razie tylko sugestią (np. "ilość pięter", która będzie określona później).

W świecie C# taki "Koncepcyjny Dom" to klasa abstrakcyjna.
Klasa abstrakcyjna – to klasa oznaczona słowem kluczowym abstract.


public abstract class Animal // Teraz Animal to klasa abstrakcyjna
{
    // ...
}
Deklaracja klasy abstrakcyjnej

Kluczowa cecha klas abstrakcyjnych:

  • Nie można ich tworzyć bezpośrednio. Nie możesz napisać new Animal(). Dlaczego? Bo Animal – to teraz coś nieokreślonego, koncepcja. Nie możesz zbudować "Koncepcyjnego Domu", możesz zbudować tylko konkretny domek albo wieżowiec.
    Jeśli spróbujesz new Animal(), kompilator od razu Cię zatrzyma:
    Cannot create an instance of the abstract type or interface 'Animal'
    (Nie można utworzyć instancji abstrakcyjnego typu lub interfejsu 'Animal')
    To bardzo ważne ograniczenie!
  • Mogą zawierać członki abstrakcyjne. I tu robi się najciekawiej!

3. Metody abstrakcyjne: kontrakt bez implementacji

Jeśli klasa abstrakcyjna to "Koncepcyjny Dom", to metoda abstrakcyjna to ta jej część, która na projekcie jest zaznaczona jako "zrobić to", ale bez konkretnych instrukcji "jak". Na przykład, "Zbudować fundament" – to obowiązkowa część domu, ale konkretne wymiary i materiały fundamentu zależą od typu domu.

Metoda abstrakcyjna to metoda, która:

  • Oznaczona jest słowem kluczowym abstract.
  • Nie ma ciała (czyli nie ma bloku kodu {}). Kończy się średnikiem ;.
  • Może być zadeklarowana tylko wewnątrz klasy abstrakcyjnej.

Zróbmy naszą metodę MakeSound() abstrakcyjną:


public abstract class Animal // Zrobiliśmy Animal abstrakcyjnym
{
    public string Name { get; set; }
    public int Age { get; set; }

    public abstract void MakeSound(); // Oto ona, metoda abstrakcyjna! Bez ciała!

    public void Sleep() // Ta metoda została zwykła, "konkretna"
    {
        Console.WriteLine($"{Name} śpi.");
    }
}

Zobacz, jak zmieniła się MakeSound()! Nie ma już nawiasów klamrowych i domyślnej implementacji. Teraz po prostu mówi: "Każde zwierzę musi umieć wydawać dźwięk. A jak dokładnie – niech zdecydują ci, którzy po mnie dziedziczą."

Ważna zasada: Jeśli Twoja klasa dziedziczy po klasie abstrakcyjnej i sama nie jest abstrakcyjna, musi nadpisać (przez override) wszystkie metody abstrakcyjne klasy bazowej. To nie opcja, to wymóg, kontrakt! Kompilator C# jest tu bardzo surowy. Jeśli zapomnisz, od razu Ci o tym przypomni:

public class Dog : Animal // Zwykła, nieabstrakcyjna klasa
{
    // BŁĄD KOMPILACJI!
    // 'Dog' does not implement inherited abstract member 'Animal.MakeSound()'
    // (Klasa 'Dog' nie implementuje odziedziczonego abstrakcyjnego członka 'Animal.MakeSound()')
    // Kompilator czeka na MakeSound() z override!
}

Żeby pozbyć się błędu, Dog i Cat muszą koniecznie nadpisać MakeSound():

public class Dog : Animal
{
    public override void MakeSound() // Koniecznie nadpisujemy!
    {
        Console.WriteLine("Hau-hau!");
    }
}

public class Cat : Animal
{
    public override void MakeSound() // I tutaj też!
    {
        Console.WriteLine("Miau!");
    }
}

Porównanie virtual, abstract i override

Charakterystyka virtual metoda abstract metoda override słowo kluczowe
Umiejscowienie W zwykłej lub abstrakcyjnej klasie Tylko w klasie abstrakcyjnej W klasie pochodnej (dziecięcej)
Ciało metody Ma ciało (domyślną implementację) Nie ma ciała (kończy się na ;) Ma ciało (nową implementację)
Cel Daje domyślną implementację, ale pozwala klasom pochodnym ją zmienić Deklaruje kontrakt: klasy pochodne muszą dać własną implementację Daje specyficzną implementację dla virtual lub abstract metody klasy bazowej
Tworzenie instancji klasy bazowej? Tak (jeśli klasa bazowa nie jest abstrakcyjna) Nie (jeśli klasa bazowa jest abstrakcyjna) N/A (dotyczy metody, nie klasy)
Obowiązek klasy pochodnej Opcjonalnie nadpisać (override) Koniecznie nadpisać (override), jeśli klasa pochodna nie jest abstrakcyjna N/A

4. Polimorfizm w akcji z metodami abstrakcyjnymi

Teraz zaczyna się najciekawsze! Już wiemy, że polimorfizm pozwala nam pracować z obiektami różnych klas pochodnych przez wspólne odniesienie do klasy bazowej. I to działa świetnie nawet wtedy, gdy klasa bazowa jest abstrakcyjna!

Chociaż nie możemy stworzyć instancji Animal bezpośrednio (pamiętaj, new Animal() da błąd), możemy używać typu Animal jako referencji do obiektów klas pochodnych. To bardzo mocne!

Kontynuujmy nasze zoo. Wyobraź sobie, że mamy farmę, na której mieszkają różne zwierzęta. Chcemy, żeby każde zwierzę wydało swój dźwięk.


using System;

// Klasa abstrakcyjna Animal
public abstract class Animal
{
    public string Name;
    public int Age;
    public Animal(string name, int age) { Name = name; Age = age; }
    public abstract void MakeSound();
    public void Sleep() { Console.WriteLine($"{Name} śpi."); }
}

public class Dog : Animal
{
    public Dog(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("Hau-hau!"); }
}

public class Cat : Animal
{
    public Cat(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("Miau!"); }
}

public class Fish : Animal
{
    public Fish(string name, int age) : base(name, age) { }
    public override void MakeSound() { Console.WriteLine("Bul-bul"); }
}

class Program
{
    static void Main()
    {
        Animal[] animals = {
            new Dog("Szaryk", 3),
            new Cat("Mruzek", 5),
            new Fish("Nemo", 1)
        };

        foreach (Animal animal in animals)
        {
            Console.WriteLine($"\nCześć, jestem {animal.Name}, mam {animal.Age} lat.");
            animal.MakeSound();
            animal.Sleep();
        }
    }
}

Co się dzieje w tym kodzie?

  1. Zadeklarowaliśmy Animal jako abstract class. To mówi kompilatorowi: "Ta klasa to szablon, nie można jej samej stworzyć, ale można po niej dziedziczyć".
  2. Zadeklarowaliśmy public abstract void MakeSound(); w Animal. To oznacza: "Każda klasa, która dziedziczy po Animal (i sama nie jest abstrakcyjna), musi zaimplementować metodę MakeSound()". To nasz kontrakt!
  3. Dog, Cat i Fish grzecznie realizują ten kontrakt, nadpisując MakeSound() swoją unikalną implementacją. Gdybyśmy zapomnieli zrobić to choćby dla jednej z nich, kompilator nie przepuściłby naszego kodu.
  4. W metodzie Main tworzymy tablicę Animal[]. Mimo że Animal jest abstrakcyjny, tablica może przechowywać referencje do obiektów klas pochodnych (Dog, Cat, Fish), bo one Animal!
  5. Kiedy przechodzimy po tablicy w pętli foreach i wywołujemy animal.MakeSound(), dzięki polimorfizmowi C# "wie", którą dokładnie MakeSound() wywołać: Dog.MakeSound(), Cat.MakeSound() czy Fish.MakeSound(). Wywoływana jest ta metoda, która należy do rzeczywistego typu obiektu, na który wskazuje Animal w danym momencie, a nie do typu referencji. I to jest cała magia polimorfizmu!
  6. A animal.Sleep() wywołuje konkretną implementację z klasy bazowej Animal, bo ta metoda nie była oznaczona jako virtual ani abstract i nie została nadpisana w klasach potomnych.

5. Po co to w prawdziwym życiu?

"Okej, zoo, zwierzęta... Ale gdzie mi się to przyda, jak będę pisał prawdziwą aplikację do banku albo sklepu?" — zapytasz. I to świetne pytanie! Klasy i metody abstrakcyjne to potężne narzędzie do projektowania elastycznych i rozszerzalnych systemów.

Wymuszenie realizacji kontraktu: To najważniejsza zaleta. Wyobraź sobie, że tworzysz framework do systemów płatności. Masz bazowy abstract class PaymentProcessor (ProcesorPłatności). I wiesz na pewno, że każdy procesor płatności musi umieć ProcessPayment() (PrzetwórzPłatność), RefundPayment() (ZwróćPłatność) i CheckStatus() (SprawdźStatus). Ale jak dokładnie to się dzieje dla PayPal, karty bankowej czy Bitcoin — to zupełnie różne rzeczy.
Deklarujesz te metody jako abstract w PaymentProcessor.


public abstract class PaymentProcessor
{
    public abstract bool ProcessPayment(decimal amount, string currency, string cardNumber);
    public abstract bool RefundPayment(string transactionId);
    public abstract string CheckStatus(string transactionId);
    // ... inne metody, które mogą być konkretne, np. logowanie
    public void LogTransaction(string message)
    {
        Console.WriteLine($"[LOG]: {message}");
    }
}

public class PayPalProcessor : PaymentProcessor
{
    public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
    {
        // Tu skomplikowana logika pracy z API PayPal
        Console.WriteLine($"PayPal: przetwarzamy {amount} {currency}...");
        return true;
    }
    public override bool RefundPayment(string transactionId) { /* ... */ return true; }
    public override string CheckStatus(string transactionId) { /* ... */ return "Completed"; }
}

public class CreditCardProcessor : PaymentProcessor
{
    public override bool ProcessPayment(decimal amount, string currency, string cardNumber)
    {
        // Tu logika pracy z bankiem-akceptantem
        Console.WriteLine($"CreditCard: {amount} {currency} z karty {cardNumber.Substring(0,4)}XXXX...");
        return true;
    }
    public override bool RefundPayment(string transactionId) { /* ... */ return true; }
    public override string CheckStatus(string transactionId) { /* ... */ return "Processing"; }
}

Teraz każdy programista, który będzie chciał stworzyć nowy procesor płatności (np. BitcoinProcessor), będzie zmuszony zaimplementować wszystkie te trzy metody. Nie będzie mógł przypadkiem zapomnieć o RefundPayment(), bo kompilator mu na to nie pozwoli! To zapewnia spójność w Twoim systemie.

Elastyczność i rozszerzalność: Możesz napisać kod, który pracuje z PaymentProcessor, nie wiedząc, jaka dokładnie implementacja będzie użyta. Na przykład, w kodzie koszyka sklepu internetowego po prostu wywołujesz currentProcessor.ProcessPayment(), a system sam podstawia odpowiedni procesor w zależności od wybranej przez użytkownika metody płatności. Jutro pojawi się nowa metoda płatności – po prostu tworzysz nową klasę, dziedziczysz po PaymentProcessor, implementujesz metody abstrakcyjne i nie musisz zmieniać głównego kodu sklepu!

Unikanie pustych implementacji: Gdybyśmy użyli virtual zamiast abstract, musielibyśmy dać im pustą domyślną implementację, co mogłoby być misleading (mylące). abstract jasno mówi: "Tu nie ma implementacji i nie może jej być – idź do potomków!"

Poprawa architektury kodu: Klasy abstrakcyjne pomagają jasno oddzielić logikę wspólną od specyficznej. Wspólna (np. LogTransaction w PaymentProcessor) żyje w klasie bazowej, a specyficzna (jak ProcessPayment) — w pochodnych. To sprawia, że kod jest czytelniejszy, łatwiejszy w utrzymaniu i testowaniu. Pozwala projektantom frameworków i bibliotek określić "punkty rozszerzenia", które użytkownicy ich bibliotek muszą wypełnić.

6. Typowe błędy i niuanse

Błąd nr 1: próba utworzenia instancji klasy abstrakcyjnej.
To zawsze błąd kompilacji. Klasa abstrakcyjna to koncepcja, nie konkretny obiekt. Możesz jej używać jako typu referencji, ale nie możesz napisać new Animal().

Błąd nr 2: zapomniano nadpisać metodę abstrakcyjną.
Jeśli klasa pochodna nie jest abstrakcyjna, musi zaimplementować wszystkie metody abstract klasy bazowej. W przeciwnym razie – błąd kompilacji. Jedyny sposób, by to obejść, to też zrobić klasę pochodną abstrakcyjną, ale najczęściej nie o to Ci chodzi.

Błąd nr 3: mylenie abstract z virtual.
abstract wymusza nadpisanie metody, nie ma ciała. virtual daje domyślną implementację i może być nadpisany. Nie można zadeklarować abstract z ciałem ani virtual bez ciała – to błąd składni.

Błąd nr 4: użycie new zamiast override.
Jeśli nie użyjesz override, a po prostu stworzysz metodę o tej samej nazwie w potomku, nie nadpisujesz, tylko ukrywasz metodę klasy bazowej. To prowadzi do nieoczekiwanego zachowania przy polimorficznych wywołaniach: wywołana zostanie metoda z klasy bazowej.


public class Base
{
    public void DoSomething() { Console.WriteLine("Base"); }
}

public class Derived : Base
{
    public new void DoSomething() { Console.WriteLine("Derived"); }
}

Base obj = new Derived();
obj.DoSomething(); // Wypisze: Base
2
Zadanie
C# SELF, poziom 21, lekcja 4
Niedostępne
Tworzenie klasy abstrakcyjnej i jej implementacja
Tworzenie klasy abstrakcyjnej i jej implementacja
2
Zadanie
C# SELF, poziom 21, lekcja 4
Niedostępne
Polimorfizm z metodami abstrakcyjnymi
Polimorfizm z metodami abstrakcyjnymi
1
Ankieta/quiz
Pojęcie polimorfizmu, poziom 21, lekcja 4
Niedostępny
Pojęcie polimorfizmu
Polimorfizm i przeciążanie metod
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION