Az interfésznevekhez nem tartoznak előtagok és utótagok

3 éve írta David Grudl  

Az I előtag vagy az Interface utótag használata az interfészeknél, valamint az Abstract az absztrakt osztályoknál, antipattern. Tiszta kódban nincs helye. Az interfésznevek megkülönböztetése valójában elhomályosítja az OOP elveit, zajt visz a kódba, és bonyodalmakat okoz a fejlesztés során. Az okok a következők.

Típus = osztály + interfész + leszármazottak

Az OOP világában az osztályokat és az interfészeket is típusoknak tekintjük. Ha típust használok egy property vagy paraméter deklarálásakor, a fejlesztő szempontjából nincs különbség abban, hogy a típus, amelyre támaszkodik, osztály vagy interfész. Ez egy nagyszerű dolog, aminek köszönhetően az interfészek valójában olyan hasznosak. Ez ad értelmet a létezésüknek. (Komolyan: mire lennének jók az interfészek, ha ez az elv nem érvényesülne? Próbáljon meg ezen elgondolkodni.)

Nézze meg ezt a kódot:

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

A konstruktor azt mondja: “Szükségem van egy űrlapra és egy fordítóra.” És teljesen mindegy neki, hogy GettextTranslator vagy DatabaseTranslator objektumot kap. És ugyanakkor felhasználóként nekem teljesen mindegy, hogy a Translator interfész, absztrakt osztály vagy konkrét osztály.

Teljesen mindegy nekem? Valójában nem, bevallom, elég kíváncsi vagyok, így amikor egy idegen könyvtárat vizsgálok, belenézek, mi rejtőzik a típus mögött, és ráviszem az egeret:

Aha, már tudom. És ezzel vége. Annak ismerete, hogy osztályról vagy interfészről van-e szó, akkor lenne fontos, ha példányt akarnék létrehozni belőle, de ez nem az az eset, most csak a változó típusáról beszélek. És itt el akarok vonatkoztatni ezektől a részletektől. És egyáltalán nem akarom őket bevinni a kódomba! Ami a típus mögött van, az a definíciójának része, nem magának a típusnak.

És most nézze meg a következő kódot:

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

Ez a konstruktor definíció szó szerint ezt mondja: “Szükségem van egy absztrakt űrlapra és egy fordító interfészre.” De ez butaság. Konkrét űrlapra van szüksége, amelyet ki kell renderelnie. Nem absztrakt űrlapra. És szüksége van egy objektumra, amely a fordító szerepét tölti be. Nincs szüksége interfészre.

Ön tudja, hogy az Interface és Abstract szavakat figyelmen kívül kell hagyni. Hogy a konstruktor ugyanazt akarja, mint az előző példában. De… komolyan? Tényleg jó ötletnek tartja, hogy a névkonvenciókba olyan szavak használatát vezesse be, amelyeket figyelmen kívül kell hagyni?

Hiszen hamis képet alkot az OOP elveiről. Egy kezdőnek össze kell zavarodnia: „Ha a Translator típus alatt 1) a Translator osztály objektumát 2) a Translator interfészt implementáló objektumot vagy 3) az ezektől öröklődő objektumot értjük, akkor mit értünk a TranslatorInterface alatt?” Erre nem lehet ésszerűen válaszolni.

Amikor TranslatorInterface-t írunk, bár a Translator is lehet interfész, tautológiát követünk el. Ugyanez, amikor interface TranslatorInterface-t deklarálunk. És így tovább. Egészen addig, amíg egy programozói vicc nem születik:

interface TranslatorInterface
{
}

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

Kivételes implementáció

Amikor valami olyasmit látok, mint TranslatorInterface, valószínű, hogy létezik egy Translator implements TranslatorInterface nevű implementáció is. Ez elgondolkodtat: mitől olyan kivételes a Translator, hogy egyedülálló joga van Translatornak nevezni magát? Minden más implementációnak leíró névre van szüksége, például GettextTranslator vagy DatabaseTranslator, de ez valahogy “alapértelmezett”, ahogy azt a kiemelt helyzete sugallja, amikor Translator-nak hívják jelző nélkül.

Ez még az embereket is elbizonytalanítja, és nem tudják, hogy a Translator-ra vagy a TranslatorInterface-re írjanak-e typehintet. Az ügyfélkódban aztán keveredik mindkettő, biztosan sokszor találkozott már ezzel (Nette-ben például a Nette\Http\Request vs IRequest kapcsán).

Nem lenne jobb megszabadulni a kivételes implementációtól, és az általános Translator nevet meghagyni az interfésznek? Tehát konkrét implementációk konkrét névvel + általános interfész általános névvel. Ennek van értelme.

A leíró név terhe ekkor tisztán az implementációkra hárul. Ha átnevezzük a TranslatorInterface-t Translator-ra, a korábbi Translator osztályunknak új névre van szüksége. Az emberek hajlamosak ezt a problémát úgy megoldani, hogy DefaultTranslator-nak nevezik, én is bűnös vagyok ebben. De megint, mitől olyan kivételes, hogy Defaultnak hívják? Ne legyen lusta, és gondolja át alaposan, mit csinál, és miért különbözik a többi lehetséges implementációtól.

És mi van, ha nem tudok elképzelni több implementációt? Mi van, ha csak egy érvényes mód jut eszembe? Akkor egyszerűen ne hozzon létre interfészt. Legalábbis egyelőre.

Íme, megjelent egy másik implementáció

És itt van! Szükségünk van egy második implementációra. Ez gyakran előfordul. Soha nem volt szükség a fordítások más módon történő tárolására, mint egy bevált módszerrel, pl. adatbázisban, de most új követelmény jelent meg, és több fordítóra van szükség az alkalmazásban.

Ez az a pillanat is, amikor világossá válik, mi volt az eredeti egyetlen fordító specifikussága. Ez egy adatbázis-fordító volt, nem default.

Mit tegyünk?

  1. A Translator névből interfészt csinálunk
  2. Az eredeti osztályt átnevezzük DatabaseTranslator-ra, és implementálja a Translator-t
  3. És létrehozunk új GettextTranslator és mondjuk NeonTranslator osztályokat

Mindezek a változtatások nagyon kényelmesen és könnyen elvégezhetők, különösen, ha az alkalmazás a dependency injection elvei szerint épül fel. A kódban semmit sem kell változtatni, csak a DI konténer konfigurációjában cseréljük le a Translator-t DatabaseTranslator-ra. Ez nagyszerű!

Gyökeresen eltérő helyzet állna elő azonban, ha ragaszkodnánk az előtagokhoz/utótagokhoz. A kódban az egész alkalmazáson keresztül át kellene neveznünk a típusokat Translator-ról TranslatorInterface-re. Egy ilyen átnevezés tisztán célszerű lenne a konvenció betartása miatt, de ellentmondana az OOP értelmének, ahogy azt az imént bemutattuk. Az interfész nem változott, a felhasználói kód nem változott, de a konvenció átnevezést követel? Akkor ez egy hibás konvenció.

Ha ráadásul idővel kiderülne, hogy az interfésznél jobb lenne egy absztrakt osztály, újra átneveznénk. Egy ilyen beavatkozás egyáltalán nem biztos, hogy triviális, például ha a kód több csomagra van bontva, vagy harmadik felek használják.

De hát mindenki így csinálja

Nem mindenki. Igaz, hogy a PHP világában az interfészek és absztrakt osztályok nevének megkülönböztetését a Zend Framework és utána a Symfony népszerűsítette, tehát nagy játékosok. Ezt a megközelítést átvette a PSR is, amely paradox módon csak interfészeket publikál, és mégis mindegyiknél szerepel a nevében az interfész szó.

Másrészt egy másik jelentős keretrendszer, a Laravel semmilyen módon nem különbözteti meg az interfészeket és az absztrakt osztályokat. Nem teszi ezt például a népszerű Doctrine adatbázisréteg sem. És nem teszi ezt a PHP standard könyvtára sem (így van Throwable vagy Iterator interfészünk, FilterIterator absztrakt osztályunk, stb.).

Ha a PHP világán kívül néznénk körül, akkor például a C# az I előtagot használja az interfészekhez, ezzel szemben a Java-ban vagy a TypeScriptben nem különböztetik meg a neveket.

Tehát nem mindenki csinálja így, de még ha így is tennék, az nem jelenti azt, hogy ez így jó. Értelmetlenül átvenni, amit mások csinálnak, nem ésszerű, mert hibákat is átvehet. Hibákat, amelyektől a másik talán nagyon szívesen megszabadulna maga is, csak túl nehézkes.

Nem ismerem fel a kódban, mi az interfész

Sok programozó fog azzal érvelni, hogy számukra hasznosak az elő-/utótagok, mert nekik köszönhetően azonnal felismerik a kódban, mik az interfészek. Úgy érzik, hiányozna nekik egy ilyen megkülönböztetés. Lássuk csak, felismeri ezekben a példákban, mi az osztály és mi az interfész?

$o = new X;

class X extends X implements Y
{}

interface Y
{}

X::fn();

X::$v;

Az X mindig osztály, az Y interfész, ez egyértelmű előtagok/utótagok nélkül is. Természetesen az IDE is tudja ezt, és az adott kontextusban mindig helyesen fog súgni.

De mi a helyzet itt:

function foo(A $param): A
{}

public A $property;

$o instanceof A

A::CONST

try {
} catch (A $x) {
}

Ezekben az esetekben nem ismeri fel. Ahogy azt a legelején mondtuk, itt a fejlesztő szempontjából nem szabadna különbségnek lennie aközött, hogy mi az osztály és mi az interfész. Ami éppen értelmet ad az interfészeknek és az absztrakt osztályoknak.

Ha itt képes lenne megkülönböztetni az osztályt az interfésztől, az tagadná az alapvető OOP elvet. És az interfészek elveszítenék az értelmüket.

Ehhez vagyok szokva

A szokások megváltoztatása egyszerűen fáj 🙂 Néha már a gondolat is. De hogy ne legyünk igazságtalanok, sok embert éppen ellenkezőleg, vonzanak a változások és örülnek nekik, azonban a többségre igaz, hogy a megszokás nagy úr.

De elég csak a múltba tekinteni, hogyan fújta el az idő néhány szokást. Talán a leghíresebb az ún. magyar jelölés, amelyet a nyolcvanas évektől használtak és a Microsoft népszerűsített. A jelölés abból állt, hogy minden változó neve egy rövidítéssel kezdődött, amely az adattípusát szimbolizálta. PHP-ban ez így nézett ki: echo $this->strName vagy $this->intCount++. A magyar jelöléstől a kilencvenes években kezdtek elállni, és ma a Microsoft az iránymutatásaiban kifejezetten lebeszéli róla a fejlesztőket.

Valaha elengedhetetlen volt, ma senkinek sem hiányzik.

De miért mennénk ilyen régmúltba? Talán emlékszik rá, hogy PHP-ban szokás volt aláhúzással megkülönböztetni a nem nyilvános osztálytagokat (példa a Zend Frameworkből). Ez abban az időben volt, amikor már régóta létezett a PHP 5, amelynek voltak public/protected/private láthatósági módosítói. De a programozók megszokásból csinálták. Meg voltak győződve arról, hogy aláhúzások nélkül elvesznének a kódban. „Hogyan különböztetném meg a kódban a nyilvánosakat a privát változóktól, aha?”

Ma senki sem használ aláhúzásokat. És senkinek sem hiányoznak. Az idő kiválóan bizonyította, hogy a félelmek alaptalanok voltak.

Pedig ez pontosan ugyanaz, mint az ellenvetés: „Hogyan különböztetném meg a kódban az interfészt az osztálytól, aha?”

Én tíz éve hagytam abba az elő-/utótagok használatát. Soha nem térnék vissza, nagyszerű döntés volt. Nem ismerek más programozót sem, aki vissza akarna térni. Ahogy egy barátom mondta: „Próbáld ki, és egy hónap múlva nem fogod érteni, hogy valaha is másképp csináltad.”

Szeretném fenntartani a következetességet

El tudom képzelni, hogy egy programozó azt mondja: „Az előtagok és utótagok használata valóban értelmetlen, értem, de már így van felépítve a kódom, és a változtatás nagyon nehéz. És ha az új kódot helyesen, nélkülük kezdeném írni, következetlenség jönne létre, ami talán még rosszabb, mint egy rossz konvenció.”

Valójában már most is következetlen a kódja, mert a PHP rendszerkönyvtárát használja, amelynek nincsenek előtagjai és utótagjai:

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

És őszintén, zavarja ez? Eszébe jutott valaha, hogy ez következetesebb lenne?

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

Vagy ez?

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

Szerintem nem. A következetesség nem játszik olyan jelentős szerepet, mint amilyennek tűnhet. Ezzel szemben a szem a kevesebb vizuális zajt, az agy a tervezés tisztaságát részesíti előnyben. Tehát a konvenció módosítása és az új interfészek helyes, előtagok és utótagok nélküli írásának megkezdése értelmes.

Célzottan el lehet távolítani őket nagy projektekből is. Példa erre a Nette Framework, amely történelmileg I előtagokat használt az interfésznevekben, amelyektől néhány éve fokozatosan és a teljes visszamenőleges kompatibilitás megőrzésével kezdett megszabadulni.