Wstęp

„Po co interfejsy, skoro mamy klasy po których można dziedziczyć” – wielu młodych programistów zadaje takie pytanie. Sam też kiedyś o to pytałem, nie rozumiejąc w ogóle istoty interfejsów. No bo po co używać tworu, który niczego nie robi i niczego nie potrafi?

Zrozumienie tego może zabrać trochę czasu. Przyjmuję wyzwanie i postaram Ci się to wszystko wyjaśnić w tym artykule.

Co to interfejs?

Prawdopodobnie już wiesz, że interfejs to taki twór, który niczego nie potrafi. Ma tylko nagłówki metod i tyle. W C# interfejs może mieć też zdefiniowane właściwości (a i od jakiegoś czasu nieco więcej…)

Interfejs a klasa abstrakcyjna

„Czym się różni interfejs od klasy abstrakcyjnej” – to częste pytanie, które pada w rozmowach o pracę dla juniorów, ale uwaga… też i dla seniorów. Okazuje się, że zbyt dużo programistów (co mnie osobiście bardzo szokuje) nie jest w stanie przedstawić jasnych różnic między interfejsem a klasą abstrakcyjną. Zacznijmy więc od podobieństw:

Podobieństwa:

  • nie można utworzyć instancji interfejsu ani instancji klasy abstrakcyjnej
  • klasa abstrakcyjna i interfejs zawierają nagłówki metod – bez ciał

I to właściwie tyle jeśli chodzi o podobieństwa. Różnic jest znacznie więcej:

Różnice:

  • klasa może dziedziczyć po jednej klasie abstrakcyjnej, ale może implementować wiele interfejsów
  • klasa abstrakcyjna może zawierać metody nieabstrakcyjne (z ciałami) – interfejs nie może*
  • klasa abstrakcyjna może zawierać pola i oprogramowane właściwości – interfejs nie może
  • klasa abstrakcyjna może dziedziczyć po innej klasie, a także implementować interfejsy; interfejs może jedynie rozszerzać inny interfejs

*UWAGA! Od C# 8.0 interfejs może zawierać domyślną implementację metod. Nie jest to jednak oczywiste działanie i nie zajmujemy się tym w tym artykule. Wspominam o tym z poczucia obowiązku.

Definiowanie interfejsu

W C# interfejs jest definiowany za pomocą słówka interface:

public interface IFlyable
{
    bool IsFlying { get; }
    void Fly();
}

Utworzyliśmy sobie interfejs IFlyable. Konwencja mówi tak, że nazwy interfejsów zaczynają się literką I (i jak igła).

Interfejs sam w sobie może mieć (tak jak klasa) określoną widoczność. W tym przypadku IFlyable jest publiczny. Natomiast jego składniki nie mogą mieć określanych widoczności. Wszystkie właściwości i metody są publiczne.

Stara konwencja, która właściwie już nie obowiązuje (ale pomoże Ci zrozumieć interfejs), mówi że nazwa interfejsu powinna OPISYWAĆ cechę (kończyć się na -able). Np: IFlyable, ITalkable, IWalkable, IEnumerable…

Interfejs w roli opisywacza

Końcówka -able w nazwie interfejsu powinna dać Ci do myślenia… „Interfejs OPISUJE jakieś zachowanie.” – tak. Interfejs opisuje zachowanie, a właściwie cechę. Klasa, która implementuje dany interfejs, musi też utworzyć dane zachowanie.

Z pomocą przyjdzie przykład. Załóżmy, że tworzysz świat. I masz taką fantazję, że tworzysz organizmy żywe. Podzieliłeś je na jakieś grupy – ssaki, ptaki, gady, owady.

I utworzyłeś analogiczne klasy abstrakcyjne:

public abstract class Mammal
{
    
}

public abstract class Reptile
{

}

public abstract class Bird
{

}

itd. Każda z tych klas abstrakcyjnych ma jakieś elementy wspólne dla całej grupy. I teraz zaczynasz tworzyć sobie konkretnych osobników:

public class Human: Mammal
{

}

public class Bat: Mammal
{

}

public class Pigeon: Bird
{

}

public class Pingeuin: Bird
{

}

I już coś Ci przestaje działać. Dlaczego?

No spójrz. Pewnie na początku wyszedłeś z założenia, że ptaki latają. Okazuje się, że latanie nie jest domeną ptaków. Pingwin nie lata, kura nie lata, struś nie lata… Oczywiście większość ptaków jednak lata ale nie wszystkie. I co dalej? Mamy nietoperza. Lata, ale nie jest ptakiem. Jednak większość ssaków nie lata.

Skoro większość ssaków nie lata, klasa Mammal nie może w żaden sposób zaimplementować latania. Klasa Bird też nie może zaimplementować latania, ponieważ nie wszystkie ptaki latają. No a nie będziesz przecież latania implementował od nowa w każdym gatunku, no bo to jednak jakaś cecha grupy. A my nie chcemy dublować kodu.

Ok, idźmy dalej tą drogą i zróbmy latające ssaki i latające ptaki:

public abstract class Mammal
{
    
}

public abstract class FlyingMammal: Mammal
{
    public abstract void Fly();
}

public class Human: Mammal
{

}

public class Bat: FlyingMammal
{
    public override void Fly()
    {
        
    }
}

public abstract class Bird
{

}

public abstract class FlyingBird
{
    public abstract void Fly();
}    

public class Pigeon: FlyingBird
{
    public override void Fly()
    {
        
    }
}

public class Pingeuin: Bird
{

}

Uff, udało się napisać abstrakcyjne klasy i nawet po nich dziedziczyć. Super.

I teraz przychodzi Ci napisać metodę, która w parametrze przyjmuje zwierzęta latające… BUM. Wszystko wybuchło…

public void StartFlying(FlyingBird bird)
{
    bird.Fly();
}

public void StartFlying(FlyingMammal mammal)
{
    mammal.Fly();
}

Zamiast jednej prostej metody, masz ich wiele (tyle, ile masz klas zwierząt latających). Sam widzisz, że nie tędy droga. I teraz na scenę wkraczają interfejsy. Wróćmy do naszego interfejsu, który napisaliśmy na początku:

public interface IFlyable
{
    bool IsFlying { get; }
    void Fly();
}

Okazuje się, że interfejs pełni rolę cechy. W tym przypadku tą cechą jest latanie. Więc zmieńmy teraz nasze klasy w taki sposób, żeby pozbyć się latających klas abstrakcyjnych na rzecz interfejsu:

public abstract class Mammal
{
    
}

public class Human: Mammal
{

}

public class Bat: Mammal, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {
        
    }
}

public abstract class Bird
{

}

public class Pigeon: Bird, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {
        
    }
}

public class Pingeuin: Bird
{
    
}

I co się okazuje? Niektóre ssaki (ale nie wszystkie) potrafią latać. Niektóre ptaki (ale nie wszystkie) potrafią latać. I jak teraz będzie wyglądała metoda, która w parametrze przyjmuje latające zwierzę?

public void StartFlying(IFlyable f)
{
    if (!f.IsFlying)
        f.Fly();
}

Mamy tutaj polimorfizm w najczystszej postaci. Metoda StartFlying nie wie jakie zwierzę dostanie w parametrze. Wie natomiast, że to co dostanie – na pewno umie latać.

Skoro wiemy, że jednak większość ptaków lata, możemy w tym momencie pokusić się o stworzenie dodatkowej klasy – latające ptaki:

public abstract class FlyingBird: Bird, IFlyable
{
    public bool IsFlying { get; private set; }
    public void Fly()
    {

    }
}

Zauważ, że metoda StartFlying w ogóle się nie zmieni, ponieważ latające ptaki implementują interfejs IFlyable. Klasa FlyingBird jest taką trochę pomocniczą. Ona wie jak ptak powinien latać. A chyba wszystkie latające ptaki robią to w ten sam sposób.

Można napisać taką klasę, ponieważ większość ptaków lata. Jednak nie tworzyłbym klasy FlyingMammal, ponieważ latanie wśród ssaków jest wyjątkową cechą. Zatem zachowałbym ją dla konkretnych gatunków (chociaż to wymaga przemyślenia).

Zróbmy trochę bardziej śmieszny świat. Ssaki… Niektóre są żyworodne, niektóre są jajorodne (kolczatka). Jeśli chodzi o ptaki… to chyba wszystkie są jajorodne (chociaż aż tak się nie znam ;)). Ale widzisz, że tutaj żyworodność, czy też jajorodność nie jest domeną całej grupy, więc idealnie nadaje się na interfejs. Tak samo jak umiejętność pływania. Są ptaki, ssaki, owady i inne, które potrafią pływać, ale są też takie, które tego nie potrafią. Tak jak z lataniem.

Klasa abstrakcyjna zamiast interfejsu

Czasem możesz mieć taką pokusę, żeby zastosować klasę abstrakcyjną zamiast interfejsu. Zwłaszcza jak w przykładzie ze zwierzętami latającymi. I czasem będzie to dobre rozwiązanie. Jednak w powyższym przykładzie widzisz, że nie zadziała. Zarówno latające ptaki, jak i latające ssaki powinny dziedziczyć po takiej klasie – a to się nie da. W C# nie mamy wielodziedziczenia.

W niektórych językach (np. C++) jest możliwość dziedziczenia po wielu klasach. Wtedy takie rozwiązanie jest jak najbardziej ok. Ale nie w C#. Czy to ograniczenie? Może i tak. Ale takie samo jak to, które zabrania Ci jechać na czerwonym świetle. Wielodziedziczenie bardzo łatwo może stać się powodem problemów. Do tego stopnia, że niektórzy programiści uważają, że jeśli musisz dziedziczyć po wielu klasach, to coś pewnie zaprojektowałeś źle.

I dlatego mamy też interfejsy. Żeby zaimplementować cechy z różnych „światów”.

Interfejs i wstrzykiwanie zależności

Jeśli nie wiesz, czym jest wstrzykiwanie zależności, koniecznie przeczytaj ten artykuł.

Jeśli chodzi o DI, to interfejsy jakoś tak samoczynnie stały się standardem. Do obiektu nie wstrzykujemy klasy, tylko interfejs. Chociaż czasem wstrzykiwanie klasy abstrakcyjnej jest jak najbardziej ok. Jednak wstrzykiwanie interfejsu czasem jest po prostu szybsze do zaimplementowania. Załóżmy, że masz taką klasę:

public class ConsoleWriter
{
    public void Write(string msg)
    {
        Console.WriteLine(msg);
    }
}

I chcesz ją wstrzyknąć do innej klasy. Więc z ConsoleWriter wyekstrahujesz albo interfejs IWriter, albo klasę abstrakcyjną AbstractWriter. Interfejs w tym momencie jest zdecydowanie bardziej naturalnym podejściem. Daje Ci pewność, że w przyszłości niczego Ci nie popsuje. Interfejs żyje trochę z boku wszystkiego.

Jeśli wyekstrahowałbyś klasę abstrakcyjną, musiałbyś się zastanowić, czy to dobre rozwiązanie. Czy nagle nie pojawi się kiedyś potrzeba, żeby jakaś klasa dziedziczyła po takim writerze, np:

public class MySuperStringList: List<string>//, AbstractWriter???
{

}

Oczywiście nie jesteś w stanie przewidzieć przyszłości. Ale jesteś w stanie przewidzieć, że w DI klasa abstrakcyjna może w pewnym momencie coś zblokować. Natomiast interfejs nigdy niczego nie zablokuje.

Interfejs w roli pluginu / adaptera

W takim przypadku wybór interfejsu jest raczej jednoznaczny. Załóżmy, że piszesz odtwarzacz mp3. I chcesz, żeby taki odtwarzacz mógł być rozszerzany przez pluginy. W pierwszym kroku musisz jakoś zaprojektować taki plugin:

public interface IMyMp3Plugin
{
    void Mp3Started(string fileName);
    void Mp3Finished(string fileName);
}

W taki sposób Twoja aplikacja będzie mogła poinformować pluginy, że piosenka się zaczęła lub skończyła. Pluginy będą implementować ten interfejs i odpowiednio reagować na zmiany. Np. może być plugin, który napisze post na FB o tym, że po raz piąty w tym dniu słuchasz tej samej piosenki. Może być inny plugin, który będzie zapisywał do bazy danych historie słuchanych przez Ciebie utworów i analizował ją. Itd.

Ty z poziomu swojej aplikacji mp3 musisz tylko wywołać metodę z interfejsu.

public void PlayMp3(string fileName)
{
    //w jakiś sposób odtwórz piosenkę, a potem poinformuj pluginy
    foreach(IMyMp3Plugin plugin in plugins)
    {
        plugin.Mp3Started(fileName);
    }
}


No to tyle jeśli chodzi o interfejsy. Mam nadzieję, że wszystko udało mi się wyjaśnić. Jeśli jednak nadal czegoś nie rozumiesz lub też znalazłeś błąd w tekście, koniecznie daj znać w komentarzu.

Podziel się artykułem na: