Przedrostki i przyrostki nie są częścią nazw interfejsów

2 lata temu Ze strony David Grudl  

Używanie przyrostka I prefix or Interface dla interfejsów, oraz Abstract dla klas abstrakcyjnych, jest antypatycznym wzorcem. Nie należy do czystego kodu. Rozróżnianie nazw interfejsów w rzeczywistości rozmywa zasady OOP, dodaje szumu do kodu i powoduje komplikacje podczas rozwoju. Oto powody.

Typ = Klasa + Interfejs + Potomkowie

W świecie OOP, zarówno klasy jak i interfejsy są uważane za typy. Jeśli używam typu podczas deklarowania właściwości lub parametru, z perspektywy dewelopera nie ma różnicy między tym, czy typ, na którym się opiera, jest klasą czy interfejsem. To jest ta fajna rzecz, która sprawia, że interfejsy są tak użyteczne, w rzeczywistości. To właśnie nadaje sens ich istnieniu. (Poważnie: po co byłyby interfejsy, gdyby ta zasada nie miała zastosowania? Spróbuj o tym pomyśleć).

Spójrz na ten kod:

class FormRenderer
{
	public function __construct(
		private Form $form,
		private Translator $translator,
	) {
	}
}

Konstruktor mówi: "Potrzebuję formularza i tłumacza. " I nie obchodzi go, czy dostanie obiekt GettextTranslator czy obiekt DatabaseTranslator. A jednocześnie ja jako użytkownik nie dbam o to, czy Translator jest interfejsem, klasą abstrakcyjną czy klasą konkretną.

Naprawdę mnie to nie obchodzi? Właściwie nie, przyznaję, że jestem dość ciekawski, więc kiedy eksploruję czyjąś bibliotekę, zerkam na to, co kryje się za typem i najeżdżam na niego:

O, widzę. I na tym kończy się historia. Wiedza o tym, czy jest to klasa czy interfejs byłaby ważna, gdybym chciał stworzyć jej instancję, ale tak nie jest, mówię teraz tylko o typie zmiennej. I to jest miejsce, w którym chcę pozostać z dala od tych szczegółów implementacji. I na pewno nie chcę, aby były one osadzone w moim kodzie! To, co jest za typem, jest częścią jego definicji, a nie samym typem.

Teraz spójrz na inny kod:

class FormRenderer
{
	public function __construct(
		private AbstractForm $form,
		private TranslatorInterface $translator,
	) {
	}
}

Ta definicja konstruktora dosłownie mówi: "Potrzebuję abstrakcyjnej formy i interfejsu tłumacza. " Ale to głupie. Potrzebuje konkretnej formy do renderowania. Nie abstrakcyjnej formy. I potrzebuje obiektu, który działa jako tłumacz. Nie potrzebuje interfejsu.

Wiesz, że słowa Interface i Abstract mają być ignorowane. Że konstruktor chce tego samego, co w poprzednim przykładzie. Ale… naprawdę? Czy naprawdę wydaje się, że wprowadzenie do konwencji nazewniczych użycia słów, które mają być ignorowane, jest dobrym pomysłem?

Przecież tworzy to fałszywe wyobrażenie o zasadach OOP. Początkujący użytkownik musi być zdezorientowany: “Jeśli typ Translator oznacza albo 1) obiekt klasy Translator 2) obiekt implementujący interfejs Translator albo 3) obiekt dziedziczący po nich, to co oznacza TranslatorInterface?”. Nie ma na to sensownej odpowiedzi.

Kiedy używamy TranslatorInterface, nawet jeśli Translator może być interfejsem, popełniamy tautologię. To samo dzieje się, gdy deklarujemy interface TranslatorInterface. Stopniowo dojdzie do żartu programistycznego:

interface TranslatorInterface
{
}

class FormRendererClass
{
	/**
	 * Constructor
	 */
	public function __construct(
		private AbstractForm $privatePropertyForm,
		private TranslatorInterface $privatePropertyTranslator,
	) {
		// 🤷‍♂️
	}
}

Outstanding Implementation

Kiedy widzę coś takiego jak TranslatorInterface, prawdopodobnie istnieje implementacja o nazwie Translator implements TranslatorInterface. To sprawia, że zastanawiam się: co czyni Translator tak wyjątkowym, że ma unikalny przywilej bycia nazywanym Tłumaczem? Każda inna implementacja potrzebuje opisowej nazwy, takiej jak GettextTranslator lub DatabaseTranslator, ale ta jest jakby “domyślna”, jak sugeruje jej preferowany status bycia nazywanym Translator bez etykiety.

Nawet ludzie są zdezorientowani i nie wiedzą, czy wpisać typehint dla Translator lub TranslatorInterface. Następnie oba są mieszane w kodzie klienta, jestem pewien, że natknąłeś się na to wiele razy (w Nette na przykład w połączeniu z Nette\Http\Request vs IRequest).

Czy nie byłoby lepiej pozbyć się specjalnej implementacji i zachować ogólną nazwę Translator dla interfejsu? Czyli posiadanie konkretnych implementacji z konkretną nazwą + generyczny interfejs z generyczną nazwą. To ma sens.

Ciężar opisowej nazwy leży wtedy wyłącznie na implementacjach. Jeśli zmienimy nazwę TranslatorInterface na Translator, nasza dawna klasa Translator potrzebuje nowej nazwy. Ludzie mają tendencję do rozwiązywania tego problemu poprzez nazywanie jej DefaultTranslator, nawet ja jestem temu winny. Ale znowu, co czyni go tak wyjątkowym, że nazywa się Default? Nie bądź leniwy i zastanów się dobrze, co to robi i dlaczego różni się od innych możliwych implementacji.

A co jeśli nie mogę sobie wyobrazić kilku możliwych implementacji? Co jeśli mogę myśleć tylko o jednym poprawnym sposobie? Więc po prostu nie twórz interfejsu. Przynajmniej na razie.

Eh, jest inna implementacja

I to jest tutaj! Potrzebujemy drugiej implementacji. To się zdarza cały czas. Nigdy nie było potrzeby przechowywania tłumaczeń w więcej niż jeden sprawdzony sposób, np. w bazie danych, ale teraz jest nowe wymaganie i musimy mieć więcej tłumaczy w aplikacji.

Wtedy też wyraźnie widać, jaka była specyfika pierwotnego pojedynczego translatora. Był to translator bazodanowy, bez default.

Co w związku z tym?

  1. Nadać nazwę Translator interfejsowi.
  2. Zmień nazwę oryginalnej klasy na DatabaseTranslator i będzie ona implementować Translator
  3. I tworzysz nowe klasy GettextTranslator i może NeonTranslator

Wszystkie te zmiany są bardzo wygodne i łatwe do wykonania, zwłaszcza jeśli aplikacja jest zbudowana zgodnie z zasadami dependency injection trzeba nic zmieniać w kodzie**, wystarczy zmienić Translator na DatabaseTranslator w konfiguracji kontenera DI. Świetnie!!!

Jednak diametralnie inna sytuacja powstałaby, gdybyśmy uparli się na prefiksowanie/sufiksowanie. Musielibyśmy zmienić nazwy typów z Translator na TranslatorInterface w kodzie w całej aplikacji. Taka zmiana nazwy byłaby czysto dla dobra konwencji, ale byłaby sprzeczna z duchem OOP, jak właśnie pokazaliśmy. Interfejs się nie zmienił, kod użytkownika się nie zmienił, ale konwencja wymaga zmiany nazwy? W takim razie jest to wadliwa konwencja.

Co więcej, gdyby z czasem okazało się, że klasa abstrakcyjna byłaby lepsza od interfejsu, zmienilibyśmy nazwę ponownie. Takie działanie może wcale nie być trywialne, na przykład gdy kod jest rozłożony na wiele pakietów lub używany przez osoby trzecie.

Ale wszyscy to robią

Nie wszyscy tak robią. To prawda, że w świecie PHP, Zend Framework, a następnie Symfony, czyli wielcy gracze, spopularyzowali rozróżnienie nazw interfejsów i klas abstrakcyjnych. To podejście zostało przyjęte przez PSR, który jak na ironię publikuje tylko interfejsy, a mimo to w nazwie każdego z nich znajduje się słowo interface.

Z drugiej strony, inny duży framework, Laravel, nie rozróżnia interfejsów i klas abstrakcyjnych. Nie robi tego na przykład nawet popularna warstwa bazodanowa Doctrine. Nie robi tego również standardowa biblioteka w PHP (mamy więc interfejsy Throwable lub Iterator, klasę abstrakcyjną FilterIterator itd.)

Jeśli spojrzymy na świat poza PHP, to C# używa przedrostka I dla interfejsów, natomiast w Javie czy TypeScript nazwy te nie różnią się niczym.

Tak więc nie wszyscy to robią, ale nawet gdyby tak było, to nie znaczy, że jest to dobre rozwiązanie. Nie jest mądre bezmyślne przyjmowanie tego, co robią inni, ponieważ możesz również przyjmować błędy. Błędy, których ten drugi wolałby się pozbyć, to po prostu zbyt duży zgryz w dzisiejszych czasach.

Nie wiem w kodzie, jaki jest interfejs

Wielu programistów będzie twierdzić, że prefiksy/sufiksy są dla nich przydatne, bo dzięki nim od razu w kodzie wiedzą, czym są interfejsy. Czują, że brakowałoby im takiego rozróżnienia. Zobaczmy więc, czy potrafisz powiedzieć, co jest klasą, a co interfejsem w tych przykładach?

$o = new X;

class X extends X implements Y
{}

interface Y
{}

X::fn();

X::$v;

X is always a class, Y jest interfejsem, jest jednoznaczny nawet bez prefiksów / postfiksów. Oczywiście IDE też o tym wie i zawsze da ci poprawną podpowiedź w danym kontekście.

Ale co w tym przypadku:

function foo(A $param): A
{}

public A $property;

$o instanceof A

A::CONST

try { ... } catch (A $x) { ... }

W tych przypadkach nie będziesz tego wiedział. Jak powiedzieliśmy na samym początku, z punktu widzenia dewelopera nie powinno być różnicy między tym, co jest klasą, a tym, co jest interfejsem. Co właśnie nadaje interfejsom i klasom abstrakcyjnym ich sens.

Gdyby udało się tutaj odróżnić klasę od interfejsu, zaprzeczyłoby to podstawowej zasadzie OOP. A interfejsy stałyby się bez znaczenia.

Jestem do tego przyzwyczajony

Zmiana nawyków po prostu boli 🙂 Często boli sama myśl o niej. Nie obwiniajmy ludzi, których pociągają zmiany i cieszą się na nie, dla większości jak mówi czeskie przysłowie, że przyzwyczajenie to żelazna koszula.

Wystarczy jednak spojrzeć w przeszłość, jak niektóre przyzwyczajenia zostały zmiecione przez czas. Chyba najbardziej znanym jest tzw. notacja węgierska stosowana od lat osiemdziesiątych i spopularyzowana przez Microsoft. Notacja ta polegała na rozpoczynaniu nazwy każdej zmiennej od skrótu symbolizującego jej typ danych. W PHP wyglądałoby to jak echo $this->strName lub $this->intCount++. Notacja węgierska zaczęła być porzucana w latach dziewięćdziesiątych, a dziś wytyczne Microsoftu wprost zniechęcają programistów do jej stosowania.

Kiedyś była to niezbędna funkcja i dziś nikt za nią nie tęskni.

Ale po co sięgać do tak odległych czasów? Być może pamiętasz, że w PHP zwyczajowo wyróżniano niepublicznych członków klas podkreślnikiem (sample from Zend Framework). Było to z powrotem, gdy istniał PHP 5, który miał modyfikatory widoczności public/protected/private. Jednak programiści robili to z przyzwyczajenia. Byli przekonani, że bez podkreślników przestaną rozumieć kod. “Jak miałbym odróżnić właściwości publiczne od prywatnych w kodzie, co?”

Dziś nikt nie używa podkreślników. I nikt ich nie omija. Czas doskonale udowodnił, że obawy były fałszywe.

A jednak to dokładnie to samo, co zarzut: “Jak miałbym odróżnić w kodzie interfejs od klasy, co?”.

Przestałem używać prefiksów/postfiksów dziesięć lat temu. Nigdy bym nie wrócił, to była świetna decyzja. Nie znam żadnego innego programisty, który chciałby również wrócić. Jak powiedział przyjaciel: “Spróbuj tego, a za miesiąc nie zrozumiesz, że kiedykolwiek zrobiłeś to inaczej”.

Chcę zachować konsekwencję

Mogę sobie wyobrazić programistę, który mówi: “Używanie prefiksów i sufiksów jest naprawdę nonsensowne, rozumiem to, tylko że ja już mam swój kod zbudowany w ten sposób i zmiana go jest bardzo trudna. A jeśli zacznę pisać nowy kod poprawnie bez nich, skończę z niespójnością, co jest chyba nawet gorsze niż zła konwencja.”

W rzeczywistości twój kod jest już niespójny, ponieważ używasz biblioteki systemowej PHP:

class Collection implements ArrayAccess, Countable, IteratorAggregate
{
	public function add(string|Stringable $item): void
	{
	}
}

I ręka na sercu, czy to ma znaczenie? Czy kiedykolwiek myślałeś, że to będzie bardziej spójne?

class Collection implements ArrayAccessInterface, CountableInterface, IteratorAggregateInterface
{
	public function add(string|StringableInterface $item): void
	{
	}
}

Albo to?

try {
	$command = $this->find($name);
} catch (ThrowableInterface $e) {
	return $e->getMessage();
}

Nie wydaje mi się. Spójność nie odgrywa tak dużej roli, jak mogłoby się wydawać. Wręcz przeciwnie, oko woli mniej wizualnego hałasu, mózg woli jasność projektu. Tak więc dostosowanie konwencji i rozpoczęcie pisania nowych interfejsów poprawnie bez prefiksów i sufiksów ma sens.

Można je celowo usunąć nawet z dużych projektów. Przykładem jest Nette Framework, który historycznie używał prefiksów I w nazwach interfejsów, które zaczął wycofywać kilka lat temu, zachowując pełną kompatybilność wsteczną.