CodeGym /Kursy /C# SELF /Problemy z polimorfizmem i abstrakcją

Problemy z polimorfizmem i abstrakcją

C# SELF
Poziom 25, Lekcja 3
Dostępny

1. Polimorfizm to nie zawsze magia

Na pierwszy rzut oka polimorfizm w C# wygląda jak totalny zdrowy rozsądek: dziedziczysz, nadpisujesz, wywołujesz wszystko przez typ bazowy — i wszystko działa. Ale w praktyce wychodzą różne niuanse. Poznajmy je bliżej.

Ukrywanie metod i słowo kluczowe new

Wyobraź sobie, że mamy klasę bazową i pochodną, w każdej jest metoda o tej samej nazwie, ale bez słowa kluczowego override. Jeśli w klasie pochodnej taka metoda zostanie zdefiniowana na nowo, ale bez override, to ukrywa implementację metody bazowej, a nie ją nadpisuje. Kompilator, jak troskliwy rodzic, od razu rzuci ostrzeżeniem i zaproponuje jawnie użyć new:

class Animal
{
    public void Speak()
    {
        Console.WriteLine("Zwierzę wydaje dźwięk.");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("Miau!");
    }
}

// Użycie
Animal animal = new Cat();
animal.Speak(); // Wypisze: "Zwierzę wydaje dźwięk."

Wow! Nawet jeśli tworzysz obiekt typu Cat i wrzucasz go do zmiennej typu Animal, wywoła się oryginalna metoda klasy bazowej. Dlaczego? Bo metoda nie została zadeklarowana jako wirtualna! Pułapka numer jeden: jeśli chcesz używać polimorfizmu, nie zapominaj o słowach kluczowych virtual i override. Używaj new tylko jeśli świadomie chcesz ukryć, a nie nadpisać metodę (a to, swoją drogą, przydaje się bardzo rzadko i tylko z dobrego powodu).

Wywołania konstruktorów i polimorfizm

Jeszcze jedna nieoczywista rzecz: konstruktory nie są wirtualne. Jeśli w klasie bazowej zadeklarujesz konstruktor, a w pochodnej swój własny, to nie będą one polimorficzne. Przykład:

class Animal
{
    public Animal()
    {
        Console.WriteLine("Konstruktor Animal");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Konstruktor Cat");
    }
}

// Użycie
Animal animal = new Cat();
// Wypisze:
// Konstruktor Animal
// Konstruktor Cat

Ale jeśli wywołasz metody z konstruktora klasy bazowej, które mogą być nadpisane w pochodnej, wynik może być zaskakujący — metoda wirtualna wywoła się przed inicjalizacją potomka! To powód, żeby nie wywoływać metod wirtualnych/abstrakcyjnych w konstruktorach.

Problem "psującej się" hermetyzacji przy override

Metody wirtualne są fajne, ale jeśli w klasie bazowej założyłeś, że dana metoda będzie się zachowywać w określony sposób, a potem ta metoda zostanie nadpisana w klasie potomnej i zacznie psuć logikę — mogą pojawić się nieoczekiwane bugi.

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("Zwierzę je.");
    }

    public void Live()
    {
        Eat(); // Może wywołać dowolną nadpisaną wersję!
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("Kot je rybę.");
    }
}

Animal a = new Cat();
a.Live(); // Wypisze "Kot je rybę."

Jeśli w klasie bazowej Animal metoda Eat() wypisywała "zwierzę je", a potem w pochodnej w nadpisanej Eat() dodasz coś niebezpiecznego, możesz rozwalić działanie całej klasy. Ten problem nazywa się naruszeniem zasady podstawienia Liskov (Liskov Substitution Principle, LSP). Projektując, zawsze myśl, czy zachowanie klas potomnych będzie logiczne względem bazowej.

Rzutowanie i problemy z konwersją typów

Polimorfizm pozwala trzymać różne obiekty w jednej "kupie": np. listę typu bazowego, gdzie mogą być i psy, i koty (oba dziedziczą Animal). Ale jeśli chcesz wywołać coś specyficznego:

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

Jeśli zapomnisz o sprawdzeniu typu i zrobisz nieostrożny cast, dostaniesz niemiły błąd InvalidCastException. Czasem prowadzi to do nadmiaru sprawdzeń typów, komplikuje kod i sugeruje, że może twoje projektowanie wymaga poprawy.

2. Problemy z abstrakcją

Abstrakcja — świetne narzędzie do uproszczenia pracy z obiektem i ograniczenia dostępu do wewnętrznego stanu. Ale i tu są pułapki!

Przesada z poziomami abstrakcji (Over-Abstraction)

Niektórzy początkujący (i nie tylko) programiści tak się wkręcają w "prawdziwe OOP", że tworzą całe torty warstwowe z klas bazowych, interfejsów i abstrakcyjnych warstw. W efekcie nawet autor nie ogarnia, jak to działa.

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("Miau!");
    }
}

Niby po co pośrednia klasa abstrakcyjna, skoro nic nie dodaje? Abstrakcja dla samej abstrakcji prowadzi do trudnej w utrzymaniu i zagmatwanej architektury.

Nieprzemyślana hierarchia

Pomyśl, co będzie jeśli jakieś działanie wrzucisz na samą górę hierarchii, a tak naprawdę nie pasuje ono do wszystkich:

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("Koty nie latają!");
    }
}

Trzeba będzie wypełniać klasy potomne atrapami z wyjątkami albo pogodzić się z tym, że twój interfejs nie odzwierciedla rzeczywistości. To typowy antywzorzec "zła hierarchia". W takich przypadkach lepiej wydzielić takie metody do osobnych interfejsów (np. IFlyable).

Porada: nie próbuj robić jednej abstrakcji "na każdą okazję".

Problemy z klasami abstrakcyjnymi i zmianami API

Jak tylko klasa abstrakcyjna trafi do produkcji i ktoś zacznie po niej dziedziczyć, każda zmiana staje się ryzykowna. Dodanie nowej metody abstrakcyjnej wymaga od wszystkich potomków jej implementacji — inaczej kod się nie skompiluje. To mocno utrudnia utrzymanie bibliotek i publicznych API.

Właśnie po to wprowadzono interfejsy z domyślną implementacją (Default Interface Methods — patrz wykład 116): pozwalają rozszerzać interfejsy bez konieczności natychmiastowej zmiany całego istniejącego kodu.

Naruszenie hermetyzacji przy abstrakcji

Gdy robisz klasę abstrakcyjną, często musisz zadeklarować jej członków jako protected, żeby klasy pochodne mogły się do nich dobrać. To często prowadzi do wycieku wewnętrznej logiki, którą lepiej byłoby ukryć. W efekcie potomkowie mają dostęp do danych i operacji, których ruszanie może rozwalić spójność klasy bazowej.

3. Praktyczne scenariusze błędów

Żebyś nie myślał, że to wszystko tylko na zadaniach domowych wychodzi, zobaczmy realne przykłady z życia — czasem nawet doświadczeni programiści wchodzą na te same grabie.

Przykład z metodami nie zadeklarowanymi jako virtual

Załóżmy, że rozbudowujesz swoją aplikację do logowania (patrz Dzień 24). Masz bazowego loggera:

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // Zapisujemy do pliku
        Console.WriteLine("Do pliku: " + message);
    }
}

// Użycie:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Oczekiwanie: "Do pliku: Hello!", rzeczywistość: "Hello!"

Twórca FileLogger myślał, że nadpisał metodę, ale zapomniał dodać override i nie zrobił metody bazowej wirtualną. W efekcie wywołuje się wersja bazowa.

Rekomendacja: zawsze oznaczaj metody do nadpisania jako virtual w klasie bazowej i override w pochodnych.

Przykład ze złą abstrakcją: "Elastyczne" zwierzęta

Ciągniemy temat zwierząt! Wydzielmy interfejs IFlyable, żeby nie zmuszać wszystkich zwierząt do implementacji metody Fly:

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("Ptak leci!");
}

class Cat
{
    // Kot nie implementuje IFlyable
}

Teraz można napisać funkcję, która działa z "latającymi", nie dotykając kotów:

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("To zwierzę nie umie latać.");
    }
}

Takie podejście pozwala nie psuć architektury fikcyjnymi metodami abstrakcyjnymi.

Problemy ze "sztywnymi" klasami abstrakcyjnymi przy rozbudowie

Wyobraź sobie, że wypuściłeś bibliotekę z taką klasą abstrakcyjną:

public abstract class Creature
{
    public abstract void DoAction();
}

Użytkownicy twojej biblioteki zaczęli tworzyć własne klasy dziedzicząc po tej. Po roku chcesz rozbudować API i dodajesz:

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // Nowa metoda!
}

Teraz wszystkie klasy użytkowników się nie kompilują, bo muszą zaimplementować nową metodę abstrakcyjną. To powód, żeby bardzo uważać przy projektowaniu abstrakcji i w miarę możliwości preferować interfejsy z metodami domyślnymi.

4. Porady jak unikać typowych pułapek

Niech twoje aplikacje będą elastyczne jak gimnastycy, ale nie łam sobie nóg!

  • Nie nadużywaj dziedziczenia: jeśli możesz użyć kompozycji (wstawianie jednego obiektu w drugi), zrób to.
  • Rób metody wirtualne tylko jeśli naprawdę muszą być nadpisywane.
  • Nie deklaruj bezsensownych klas abstrakcyjnych i nie buduj hierarchii "na zapas".
  • Sprawdzaj poprawność logiki przy nadpisywaniu metod: nie łam zasad (inwariantów) klas bazowych.
  • Nie dodawaj nowych metod abstrakcyjnych do publicznych klas bazowych i interfejsów po publikacji biblioteki.
  • Używaj interfejsów do luźnego powiązania części programu.
  • Do rozbudowy API używaj Default Interface Methods.
2
Zadanie
C# SELF, poziom 25, lekcja 3
Niedostępne
Użycie słowa kluczowego `new` do ukrycia metody
Użycie słowa kluczowego `new` do ukrycia metody
2
Zadanie
C# SELF, poziom 25, lekcja 3
Niedostępne
Wykorzystanie klas abstrakcyjnych i zasady specjalizacji
Wykorzystanie klas abstrakcyjnych i zasady specjalizacji
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION