Τα προθέματα και τα επιθήματα δεν ανήκουν στα ονόματα διασύνδεσης

2 χρόνια πριν Από το David Grudl  

Η χρήση του επιθέματος I prefix or Interface για τις διεπαφές και Abstract για τις αφηρημένες κλάσεις αποτελεί αντιπρότυπο. Δεν ανήκει σε καθαρό κώδικα. Η διάκριση των ονομάτων διεπαφών στην πραγματικότητα θολώνει τις αρχές OOP, προσθέτει θόρυβο στον κώδικα και προκαλεί επιπλοκές κατά την ανάπτυξη. Ακολουθούν οι λόγοι.

Τύπος = Κλάση + Διεπαφή + Απόγονοι

Στον κόσμο της OOP, τόσο οι κλάσεις όσο και οι διεπαφές θεωρούνται τύποι. Εάν χρησιμοποιώ έναν τύπο κατά τη δήλωση μιας ιδιότητας ή μιας παραμέτρου, από την οπτική γωνία του προγραμματιστή, δεν υπάρχει καμία διαφορά μεταξύ του αν ο τύπος στον οποίο βασίζονται είναι μια κλάση ή μια διεπαφή. Αυτό είναι το ωραίο πράγμα που κάνει τις διεπαφές τόσο χρήσιμες, στην πραγματικότητα. Είναι αυτό που δίνει νόημα στην ύπαρξή τους. (Σοβαρά: για ποιο λόγο θα υπήρχαν οι διεπαφές αν δεν ίσχυε αυτή η αρχή; Προσπαθήστε να το σκεφτείτε).

Ρίξτε μια ματιά σε αυτόν τον κώδικα:

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

Ο κατασκευαστής λέει: "Χρειάζομαι μια φόρμα και έναν μεταφραστή. " Και δεν τον ενδιαφέρει αν θα πάρει ένα αντικείμενο GettextTranslator ή ένα αντικείμενο DatabaseTranslator. Και ταυτόχρονα, εγώ ως χρήστης δεν με ενδιαφέρει αν το Translator είναι μια διεπαφή, μια αφηρημένη κλάση ή μια συγκεκριμένη κλάση.

Πραγματικά δεν με νοιάζει; Για την ακρίβεια, όχι, παραδέχομαι ότι είμαι αρκετά περίεργος, οπότε όταν εξερευνώ τη βιβλιοθήκη κάποιου άλλου, ρίχνω μια ματιά στο τι κρύβεται πίσω από τον τύπο και αιωρούμαι πάνω του:

Α, κατάλαβα. Και αυτό είναι το τέλος της ιστορίας. Το να ξέρω αν πρόκειται για κλάση ή διεπαφή θα ήταν σημαντικό αν ήθελα να δημιουργήσω μια παρουσία της, αλλά δεν είναι αυτή η περίπτωση, μιλάω απλώς για τον τύπο της μεταβλητής τώρα. Και εδώ είναι που θέλω να μείνω έξω από αυτές τις λεπτομέρειες υλοποίησης. Και σίγουρα δεν θέλω να ενσωματωθούν στον κώδικά μου! Αυτό που βρίσκεται πίσω από έναν τύπο είναι μέρος του ορισμού του, όχι ο ίδιος ο τύπος.

Τώρα κοιτάξτε τον άλλο κώδικα:

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

Αυτός ο ορισμός του κατασκευαστή λέει κυριολεκτικά: "Χρειάζομαι μια αφηρημένη μορφή και μια διεπαφή του μεταφραστή. " Αλλά αυτό είναι ανόητο. Χρειάζεται μια συγκεκριμένη μορφή για την απόδοση. Όχι μια αφηρημένη μορφή. Και χρειάζεται ένα αντικείμενο που ενεργεί ως μεταφραστής. Δεν χρειάζεται μια διεπαφή.

Γνωρίζετε ότι οι λέξεις Interface και Abstract πρέπει να αγνοηθούν. Ότι ο κατασκευαστής θέλει το ίδιο πράγμα όπως στο προηγούμενο παράδειγμα. Αλλά… αλήθεια; Φαίνεται πραγματικά καλή ιδέα να εισάγουμε στις συμβάσεις ονοματοδοσίας τη χρήση λέξεων που πρέπει να αγνοούνται;

Εξάλλου, δημιουργεί μια λανθασμένη ιδέα για τις αρχές της OOP. Ένας αρχάριος πρέπει να μπερδεύεται: “Αν ο τύπος Translator σημαίνει είτε 1) ένα αντικείμενο της κλάσης Translator 2) ένα αντικείμενο που υλοποιεί τη διεπαφή Translator ή 3) ένα αντικείμενο που κληρονομεί από αυτές, τότε τι σημαίνει το TranslatorInterface;” Δεν υπάρχει καμία λογική απάντηση σε αυτό το ερώτημα.

Όταν χρησιμοποιούμε το TranslatorInterface, ακόμη και αν το Translator μπορεί να είναι μια διεπαφή, διαπράττουμε μια ταυτολογία. Το ίδιο συμβαίνει και όταν δηλώνουμε το interface TranslatorInterface. Σταδιακά, θα συμβεί ένα προγραμματιστικό ανέκδοτο:

interface TranslatorInterface
{
}

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

Εξαιρετική εφαρμογή

Όταν βλέπω κάτι όπως TranslatorInterface, είναι πιθανό να υπάρχει μια υλοποίηση που ονομάζεται Translator implements TranslatorInterface. Αυτό με κάνει να αναρωτιέμαι: τι κάνει το Translator τόσο ξεχωριστό ώστε να έχει το μοναδικό προνόμιο να ονομάζεται Translator; Κάθε άλλη υλοποίηση χρειάζεται ένα περιγραφικό όνομα, όπως το GettextTranslator ή το DatabaseTranslator, αλλά αυτή είναι κατά κάποιο τρόπο “προεπιλεγμένη”, όπως υποδηλώνει η προτιμώμενη ιδιότητά της να ονομάζεται Translator χωρίς την ετικέτα.

Ακόμα και οι άνθρωποι μπερδεύονται και δεν ξέρουν αν πρέπει να πληκτρολογήσουνε για το Translator ή το TranslatorInterface. Τότε και τα δύο μπερδεύονται στον κώδικα του πελάτη, είμαι σίγουρος ότι το έχετε συναντήσει αυτό πολλές φορές (στο Nette για παράδειγμα στη σύνδεση με το Nette\Http\Request vs IRequest).

Δεν θα ήταν καλύτερα να απαλλαγούμε από την ειδική υλοποίηση και να διατηρήσουμε το γενικό όνομα Translator για τη διεπαφή; Δηλαδή, να έχουμε ειδικές υλοποιήσεις με ειδικό όνομα + μια γενική διεπαφή με γενικό όνομα. Αυτό είναι λογικό.

Το βάρος ενός περιγραφικού ονόματος πέφτει τότε καθαρά στις υλοποιήσεις. Αν μετονομάσουμε το TranslatorInterface σε Translator, η πρώην κλάση Translator χρειάζεται ένα νέο όνομα. Οι άνθρωποι τείνουν να λύνουν αυτό το πρόβλημα ονομάζοντάς την DefaultTranslator, ακόμη και εγώ είμαι ένοχος γι' αυτό. Αλλά και πάλι, τι την κάνει τόσο ξεχωριστή ώστε να ονομάζεται Default; Μην είστε τεμπέληδες και σκεφτείτε καλά τι κάνει και γιατί διαφέρει από άλλες πιθανές υλοποιήσεις.

Και τι γίνεται αν δεν μπορώ να φανταστώ διάφορες πιθανές υλοποιήσεις; Τι γίνεται αν μπορώ να σκεφτώ μόνο έναν έγκυρο τρόπο; Οπότε απλά μην δημιουργήσετε τη διεπαφή. Τουλάχιστον προς το παρόν.

Υπάρχει και άλλη υλοποίηση

Και είναι εδώ! Χρειαζόμαστε μια δεύτερη εφαρμογή. Συμβαίνει συνέχεια. Ποτέ δεν υπήρξε ανάγκη να αποθηκεύουμε τις μεταφράσεις με περισσότερους από έναν αποδεδειγμένους τρόπους, π.χ. σε μια βάση δεδομένων, αλλά τώρα υπάρχει μια νέα απαίτηση και πρέπει να έχουμε περισσότερους μεταφραστές στην εφαρμογή.

Τότε είναι επίσης που συνειδητοποιείτε ξεκάθαρα ποιες ήταν οι ιδιαιτερότητες του αρχικού μοναδικού μεταφραστή. Ήταν ένας μεταφραστής βάσης δεδομένων, χωρίς προεπιλογή.

Τι γίνεται με αυτό;

  1. Κάντε το όνομα Translator το περιβάλλον εργασίας
  2. Μετονομάστε την αρχική κλάση σε DatabaseTranslator και θα υλοποιεί Translator
  3. Και δημιουργείτε νέες κλάσεις GettextTranslator και ίσως NeonTranslator

Όλες αυτές οι αλλαγές είναι πολύ βολικές και εύκολες να γίνουν, ειδικά αν η εφαρμογή έχει κατασκευαστεί σύμφωνα με τις αρχές της έγχυσης εξαρτήσεων. Δεν χρειάζεται να αλλάξετε τίποτα στον κώδικα, απλά αλλάξτε το Translator σε DatabaseTranslator στη διαμόρφωση του DI container. Αυτό είναι υπέροχο!

Ωστόσο, μια εκ διαμέτρου διαφορετική κατάσταση θα προέκυπτε αν επιμέναμε στην προθέσπιση/επιγραφή. Θα έπρεπε να μετονομάσουμε τους τύπους από Translator σε TranslatorInterface στον κώδικα σε όλη την εφαρμογή. Μια τέτοια μετονομασία θα γινόταν καθαρά για λόγους σύμβασης, αλλά θα ήταν αντίθετη με το πνεύμα της OOP, όπως μόλις δείξαμε. Η διεπαφή δεν έχει αλλάξει, ο κώδικας του χρήστη δεν έχει αλλάξει, αλλά η σύμβαση απαιτεί μετονομασία; Τότε είναι μια ελαττωματική σύμβαση.

Επιπλέον, αν με την πάροδο του χρόνου αποδεικνυόταν ότι μια αφηρημένη κλάση θα ήταν καλύτερη από τη διεπαφή, θα μετονομαζόμασταν ξανά. Μια τέτοια ενέργεια μπορεί να μην είναι καθόλου τετριμμένη, για παράδειγμα όταν ο κώδικας είναι διασκορπισμένος σε πολλά πακέτα ή χρησιμοποιείται από τρίτους.

Αλλά όλοι το κάνουν

Δεν το κάνουν όλοι. Είναι αλήθεια ότι στον κόσμο της PHP, το Zend Framework, ακολουθούμενο από το Symfony, τους μεγάλους παίκτες, διέδωσε τη διάκριση της διεπαφής και των αφηρημένων ονομάτων κλάσεων. Αυτή η προσέγγιση έχει υιοθετηθεί από το PSR, το οποίο ειρωνικά δημοσιεύει μόνο διεπαφές, αλλά περιλαμβάνει τη λέξη διεπαφή στο όνομα της καθεμιάς.

Από την άλλη πλευρά, ένα άλλο μεγάλο πλαίσιο, το Laravel, δεν κάνει διάκριση μεταξύ διεπαφών και αφηρημένων κλάσεων. Ακόμη και το δημοφιλές επίπεδο βάσης δεδομένων Doctrine δεν το κάνει αυτό, για παράδειγμα. Ούτε και η τυπική βιβλιοθήκη της PHP (έτσι έχουμε τις διεπαφές Throwable ή Iterator, την αφηρημένη κλάση FilterIterator κ.λπ.)

Αν κοιτάξουμε τον κόσμο εκτός PHP, η C# χρησιμοποιεί το πρόθεμα I για τις διεπαφές, ενώ στη Java ή την TypeScript τα ονόματα δεν διαφέρουν.

Επομένως, δεν το κάνουν όλοι, αλλά ακόμα και αν το έκαναν, αυτό δεν σημαίνει ότι είναι κάτι καλό. Δεν είναι σοφό να υιοθετείτε χωρίς μυαλό ό,τι κάνουν οι άλλοι, γιατί μπορεί να υιοθετήσετε και εσείς λάθη. Λάθη από τα οποία ο άλλος θα προτιμούσε να απαλλαγεί, είναι απλά πολύ μεγάλη δαγκωματιά στις μέρες μας.

Δεν ξέρω στον κώδικα ποια είναι η διεπαφή

Πολλοί προγραμματιστές θα υποστηρίξουν ότι τα προθέματα/υποθέματα είναι χρήσιμα γι' αυτούς επειδή τους επιτρέπουν να γνωρίζουν αμέσως στον κώδικα ποιες είναι οι διεπαφές. Θεωρούν ότι θα τους έλειπε μια τέτοια διάκριση. Για να δούμε λοιπόν, μπορείτε να καταλάβετε τι είναι κλάση και τι διεπαφή σε αυτά τα παραδείγματα;

$o = new X;

class X extends X implements Y
{}

interface Y
{}

X::fn();

X::$v;

X is always a class, Y είναι μια διεπαφή, είναι ξεκάθαρο ακόμα και χωρίς προθέματα/μεταθέματα. Φυσικά, το IDE το γνωρίζει και αυτό και θα σας δίνει πάντα τη σωστή υπόδειξη σε ένα δεδομένο πλαίσιο.

Αλλά τι γίνεται εδώ:

function foo(A $param): A
{}

public A $property;

$o instanceof A

A::CONST

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

Σε αυτές τις περιπτώσεις, δεν θα το ξέρετε. Όπως είπαμε και στην αρχή, από τη σκοπιά ενός προγραμματιστή δεν θα έπρεπε να υπάρχει καμία διαφορά μεταξύ του τι είναι κλάση και τι είναι διεπαφή. Αυτό είναι που δίνει στις διεπαφές και τις αφηρημένες κλάσεις το νόημά τους.

Αν μπορούσατε να κάνετε διάκριση μεταξύ μιας κλάσης και μιας διεπαφής εδώ, θα αρνιόταν τη βασική αρχή του OOP. Και οι διεπαφές θα έπαυαν να έχουν νόημα.

Το έχω συνηθίσει

Η αλλαγή συνηθειών απλά πονάει 🙂 Συχνά και μόνο η ιδέα της πονάει. Ας μην κατηγορούμε τους ανθρώπους που έλκονται από τις αλλαγές και τις περιμένουν με ανυπομονησία, για τους περισσότερους όπως ισχύει η τσεχική παροιμία ότι η συνήθεια είναι ένα σιδερένιο πουκάμισο.

Αλλά απλά κοιτάξτε το παρελθόν, πώς κάποιες συνήθειες έχουν σαρώσει ο χρόνος. Ίσως η πιο διάσημη είναι η λεγόμενη ουγγρική σημειογραφία που χρησιμοποιείται από τη δεκαετία του '80 και διαδόθηκε από τη Microsoft. Η σημειογραφία αυτή συνίστατο στο να ξεκινά το όνομα κάθε μεταβλητής με μια συντομογραφία που συμβόλιζε τον τύπο δεδομένων της. Στην PHP θα έμοιαζε με echo $this->strName ή $this->intCount++. Η ουγγρική σημειογραφία άρχισε να εγκαταλείπεται τη δεκαετία του '90 και σήμερα οι οδηγίες της Microsoft αποθαρρύνουν άμεσα τους προγραμματιστές να τη χρησιμοποιούν.

Κάποτε ήταν ένα απαραίτητο χαρακτηριστικό και σήμερα δεν λείπει από κανέναν.

Αλλά γιατί να πάμε σε τόσο μακρινό παρελθόν; Ίσως θυμάστε ότι στην PHP, συνηθιζόταν να διακρίνουμε τα μη δημόσια μέλη των κλάσεων με μια υπογράμμιση (sample from Zend Framework). Αυτό συνέβαινε τότε που υπήρχε η PHP 5, η οποία είχε τους τροποποιητές ορατότητας public/protected/private. Αλλά οι προγραμματιστές το έκαναν από συνήθεια. Ήταν πεπεισμένοι ότι χωρίς υπογράμμιση θα σταματούσαν να κατανοούν τον κώδικα. “Πώς θα μπορούσα να διακρίνω τις δημόσιες από τις ιδιωτικές ιδιότητες στον κώδικα, ε;”

Κανείς δεν χρησιμοποιεί υπογράμμιση σήμερα. Και σε κανέναν δεν λείπουν. Ο χρόνος έχει αποδείξει περίτρανα ότι οι φόβοι ήταν λανθασμένοι.

Ωστόσο, είναι ακριβώς το ίδιο με την ένσταση: “Πώς θα μπορούσα να ξεχωρίσω μια διεπαφή από μια κλάση στον κώδικα, ε;”.

Σταμάτησα να χρησιμοποιώ προθέματα/μεταθέματα πριν από δέκα χρόνια. Δεν θα επέστρεφα ποτέ, ήταν μια σπουδαία απόφαση. Δεν γνωρίζω κανέναν άλλο προγραμματιστή που να θέλει να επιστρέψει. Όπως είπε ένας φίλος: “Δοκιμάστε το και σε ένα μήνα δεν θα καταλαβαίνετε ότι το κάνατε ποτέ διαφορετικά”.

Θέλω να διατηρήσω τη συνέπεια

Μπορώ να φανταστώ έναν προγραμματιστή να λέει: “Η χρήση προθεμάτων και επιθέτων είναι πραγματικά ανοησία, το καταλαβαίνω, απλώς ο κώδικάς μου είναι ήδη φτιαγμένος με αυτόν τον τρόπο και η αλλαγή του είναι πολύ δύσκολη. Και αν αρχίσω να γράφω νέο κώδικα σωστά χωρίς αυτά, θα καταλήξω σε ασυνέπεια, η οποία είναι ίσως χειρότερη και από την κακή σύμβαση”.

Στην πραγματικότητα, ο κώδικάς σας είναι ήδη ασυνεπής επειδή χρησιμοποιείτε τη βιβλιοθήκη συστήματος της PHP:

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

Και με το χέρι στην καρδιά, έχει σημασία; Σκεφτήκατε ποτέ ότι αυτό θα ήταν πιο συνεπές;

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

Ή αυτό;

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

Δεν το νομίζω. Η συνέπεια δεν παίζει τόσο μεγάλο ρόλο όσο φαίνεται. Αντιθέτως, το μάτι προτιμά λιγότερο οπτικό θόρυβο, ο εγκέφαλος προτιμά τη σαφήνεια του σχεδιασμού. Οπότε η προσαρμογή της σύμβασης και η έναρξη της σωστής γραφής νέων διεπαφών χωρίς προθέματα και επιθήματα έχει νόημα.

Μπορούν να αφαιρεθούν σκόπιμα ακόμη και από μεγάλα έργα. Ένα παράδειγμα είναι το Nette Framework, το οποίο χρησιμοποιούσε ιστορικά τα προθέματα I στα ονόματα των διεπαφών, τα οποία άρχισε να καταργεί σταδιακά πριν από μερικά χρόνια, διατηρώντας πλήρη συμβατότητα προς τα πίσω.

Τελευταίες θέσεις