Ierarhia dreptunghi-pătrat și alte aberații ale școlilor de programare
Nu știu sigur dacă toată lumea a trecut prin aceeași experiență ca mine, dar am învățat programare orientată pe obiecte în două rânduri la școală, și de fiecare dată am trecut prin aceeași discuție. Cum arată o ierarhie de obiecte, și de ce ai face o ierarhie de obiecte. Și cel mai simplu caz-școală este aproape întotdeauna „cum anume modelezi în programarea orientată pe obiecte un dreptunghi și un pătrat”. Pentru cei mai răsăriți, un cerc și o elipsă. Pentru cei care știu chestii, dreptunghi-pătrat-cub, respectiv elipsă-cerc-sferă. Pentru cei mai super-deștepți elipsă-cerc-sferă-elipsoid.
De obicei, problema vine din faptul că cel care vrea să explice programarea orientată pe obiecte vrea să dovedească că este deștept și a fost atent și la orele de geometrie. Altfel nu îmi explic de ce se alege acest model drept exemplu - și pentru că vă va fi evident că în cele ce urmează voi scrie despre de ce nu e un model bun, o să-mi permit să încep cu o anecdotă personală.
În anii ‘90 era o obsesie pentru două lucruri când te angajai ca programator: Principiile programării orientate pe obiect și Design patterns cu UML. Programarea orientată pe obiect era ceva fără care nu reușeai să treci de un interviu, iar partea de șabloane de design era o culminare a OOP (o să folosesc prescurtarea americănească pentru că cea românească intră în niște paralele care îmi displac - cel puțin ca ton de conversație). Așadar, era natural să înveți cum se face programarea orientată pe obiect ca învățarea să culmineze în design patterns, acestea din urmă fiind doar ceva ce enunți și sunt aplicate automat. Idealul, mi s-a explicat, era ca un arhitect să scrie UML (într-un produs ca Rational Rose) și din acel UML să rezulte direct programul exact așa cum ar fi trebuit scris. Și pentru că tehnologia încă nu era acolo, programatorii încă trebuiau să traducă din UML în cod real, folosind aceste design patterns, și gata, software-ul era gata. Trebuia doar să gândești lucrurile foarte bine și produsul era ca și făcut.
Cum nu știți pe nimeni să programeze cu Rational Rose, vă imaginați cam cum a funcționat treaba asta. Dar toată experiența m-a scârbit, și am decis să fiu anti-curent. În loc să învăț UML am evitat complet subiectul, în materie de design patterns mi-am format o singură întrebare capcană (de ce ai folosi Singleton și de ce ar trebui să fii concediat pentru asta), iar în materie de OOP… Hai să discutăm despre ierarhia dreptunghi-pătrat.
Pătratul care derivă din dreptunghi
Cel mai des o să vedem următoarea logică exprimată în cod sau pe tablă de către un profesor.
class Dreptunghi {
public:
Dreptunghi (int x, int y) : x(x),y(y) {...}
virtual int aria() {return x*y;}
private:
int x, y;
};
class Patrat : public Dreptunghi {
public:
Patrat (int x);
virtual int aria() {return x*x;}
};
În general, după cum ați observat, nici măcar nu ne obosim să facem câteva lucruri de bază. De exemplu să scriem constructorul clasei Patrat, pentru că putem da mereu vina pe faptul că e slideware, adică nu e cod care se execută, deci e ok. E clar că toată lumea știe cum să scrie constructorul acela, nu asta e interesant. Și nu o să mă iau nici de lucruri precum „lipsește destructorul virtual”, sau că ar trebui folosit override în loc de virtual pentru Patrat. Sau că ar fi fain să punem un explicit la constructorul lui Patrat. Codul scris de profesorii de informatică e în general sub orice critică, nu e vina lor că manualele după care predau au fost scrise de niște oameni care urăsc subiectul, în anii ‘90 sau cel mult la începutul anilor ‘00. Nu, nu ne vom uita aici. Hai să ne uităm la câteva aspecte fundamentale, după ce ne uităm la un exemplu de astfel de ierarhie propusă de către un profesor universitar care predă materia Programare Orientată pe Obiect.

O ierarhie de clase în care singurul lucru de care nu ducem lipsă e entuziasmul
În primul rând clasa Patrat are un membru, y, pe care nu-l folosește. Nu are de ce. Rețineți acest aspect; vom discuta în capitolul următor o soluție improvizată în momentul în care ne aplecăm asupra acestui subiect.
Variante pentru justificarea acestei derivări m-au dus până la punctul în care cineva mi-a explicat că trebuie adăugat un nou membru x pentru că oricum membrul x din dreptunghi nu e vizibil, și sunt de acord cu ei, dar am zis că ignorăm erorile de slideware. Însă după o discuție mai adâncă mi-am dat seama că respectivul credea că având un membru x, va reduce dimensiunea clasei Patrat la un singur întreg. Nu pot să vă explic șocul persoanei când am făcut sizeof pe clasă.
A doua problemă e mai subtilă. Orice exemplu de folosire a acestei ierarhii este extrem de artificial, și nu răspunde la o întrebare esențială: care e diferența dintre Patrat și Dreptunghi. De ce a fost necesară crearea acestei distincții? Argumentul este că întotdeauna clasele derivate și clasele de bază sunt în relația IsA. Și uitându-ne la acest argument, putem să fim de acord că într-adevăr, „Pătrat” este un „Dreptunghi” în lumea reală, în cartea de geometrie. Deci ar trebui să fie foarte naturală această variantă de derivare. Nu este, dar înainte să facem o mică pauză, și să ne uităm la o derivare alternativă.
Dreptunghiul care derivă din pătrat
După ce e evident că derivarea adaugă noi membri, a fost natural ca profesorul să îmi explice că „așa e, derivarea ar trebui făcută invers”. Și culmea, e un argument care are mai multă logică decât pare la început. Mi-a explicat că „este paradoxal, dar corect”. Să ne mai uităm la niște cod.
class Patrat {
public:
Patrat(int x): x(x) {}
virtual ~Patrat() = default; // we're modern!
virtual int aria() {return x*x;}
protected:
int x;
};
class Dreptunghi : public Patrat {
public:
Dreptunghi (int x, int y) : Patrat(x), y(y) {...}
~Dreptunghi() override = default;
int aria() override {return x*y;}
private:
int y;
};
Aceasta este ceea ce se numește neștiințific o ierarhie paradoxală - în cod Dreptunghi IsA Patrat, dar în viața reală e invers! Motivul pentru care vă zic că e neștiințific este că nimeni nu s-ar atinge de așa o prostie nici cu mănuși de azbest, dar nu subestima un profesor de informatică extrem de motivat. Rolul derivării aici este mai pragmatic: adaugă informație, și îți permite să ai eficiență în stocare când vorbim despre pătrate. Cu alte cuvinte, obiecțiile mele ar trebui să dispară.
Îmi este foarte greu să explic faptul că un dreptunghi nu este în niciun caz un pătrat. Pentru că pentru mine acest aspect este evident, și singura justificare a acestei împărțiri este faptul că prin derivare se adaugă funcționalitate pe care o vrem accesibilă pentru toate formele pe care le modelăm. În cazul acesta, se adaugă nu doar încă o dimensiune (în mod greșit, și pătratul și dreptunghiul sunt în două dimensiuni) dar și o metodă diferită de calcul a ariei. Și deci se justifică nu doar dimensiunea diferită, dar și faptul că avem o metodă virtuală pentru calculul ariei!
Și nu poți să nu-i dai dreptate. Adaugi date noi și funcționalitate diferită pe ceva ce funcționează similar, deci ar putea să fie o justificare. Singura problemă, în mintea celui care a propus soluția aceasta, e că un dreptunghi nu e un pătrat, caz în care realitatea e problematică, nu modelul lui. E foarte greu să vorbești cu un om ferm convins că realitatea e greșită.
De ce?
Întotdeauna acest exemplu este folosit pentru a explica studenților aflați la primul contact cu o ierarhie de clase pentru că e simplu. Teoretic folosește un set de noțiuni cunoscute - geometria e parte din corpusul necesar pentru admiterea la liceu și facultate, deci și la primele lecții de programare din liceu, cât si la cele de facultate, aceste noțiuni sunt considerate cunoscute. Faptul că între pătrat și dreptunghi funcționează aceeași relație de legătură precum cea care ar trebui să fie între o clasă derivată și una de bază face exemplul… natural. Numai că este în același timp ilogic și ineficient. Și cumva, profesorii ajung să se contrazică de la primul contact, pentru că afirmă mult prea siguri pe ei înșiși că relația de tip IsA se modelează ca derivare, în scopul refolosirii funcționalității.
Cum putem salva cele două ierarhii propuse mai sus? Cel mai simplu de salvat e a doua, pentru că principiile fundamentale ale derivării funcționează, doar că salvează logica. Dacă aș fi un profesor care predă așa ceva unor studenți le-aș zice: *Astea sunt principiile: adaugi date și funcționalitate diferită” și le-aș da temă pentru acasă să găsească o ierarhie de obiecte care să aibă sens. Pentru că e evident că eu nu sunt capabil de așa ceva.
Dar prima ierarhie e de nesalvat, și, după cum ați văzut în splendidul model extensiv, principiile aplicate pentru modelare sunt atât de aleatorii încât putem crea un lanț de derivare aproape infinit. Dificultatea vine din incapacitatea de a identifica diferența dintre o clasă și o instanță cu proprietăți speciale. O clasă este un șablon pentru obiecte. Un obiect este o instanțiere a acelui șablon, și dacă șablonul este că „avem paralelograme cu unghiuri de 90 de grade”, atunci dacă lungimea și lățimea diferă sunt dreptunghi și dacă lungimea și lățimea sunt egale, atunci sunt un pătrat.
De asemenea, avem o neînțelegere a faptului că derivarea implică existența unei table virtuale de funcții, deci avem dimensiune mai mare din start, o apelare mai lentă a metodelor virtuale, și faptul că nu se pot suprascrie datele clasei de bază, ci doar se poate adăuga la dimensiunea clasei de bază. Confuzia vine uneori din faptul că tipul int este pe 32 de biți, și următorul int care se adaugă intră în spațiul prealocat pentru structură (arhitectură de 64biți înseamnă că structurile sunt cel mai des aliniate la 64 de biți pentru o mai rapidă manipulare).
O eventuală soluție
Sunt mai multe posibile soluții, care au același invariant. Probabil că ceea ce îți dorești e un Dreptunghi și un Patrat care derivă direct din FiguraGeometrica.
class Figura {
public:
virtual ~Figura() = default;
virtual int Aria() = 0;
};
class Patrat final : public Figura {
public:
explicit Patrat(int x) : x_(x) {}
~Patrat() override = default;
int Aria() override {return x_*x_;}
private:
int x_;
};
class Dreptunghi final : public Figura {
public:
Dreptunghi(int x, int y) : x_(x), y_(y) {}
~Dreptunghi() override = default;
int Aria() override {return x_*y_;}
private:
int x_;
int y_;
};
Poți avea un std::vector<std::unique_ptr<Figura>> și să îl folosești cum ți-ai dorit. Dar chiar și-așa m-aș îndepărta puternic de acest exemplu artificial, tocmai pentru că unui începător îi va fi greu să înțeleagă care relație IsA este suficient de importantă cât să fie modelată ca o relație de derivare, și, mai ales, care este costul acestor derivări. Neînțelegând costul, modelarea se face la cost zero, deci există această credință că orice ierarhie s-ar contrui, nu există niciun fel de costuri.
Din păcate, aceste exemple artificiale domină conversațiile despre cod curat, și chiar recent (acum vreo doi ani, parcă) au dus la un conflict foarte public între Casey Muratori și tabăra Clean Code. Casey folosește un exemplu extrem de artificial, dar există merite la argumentele lui. Ceea ce e important, totuși, este să nu vă lăsați păcăliți și să credeți că orice încercare de modelare e inutilă și că programarea orientată pe obiect este o tâmpenie.
Din păcate, OOP este afectată de principiul ciocanului de aur - ideea că dacă avem o unealtă trebuie s-o folosim la absolut orice. Ciocanul de aur OOP a fost folosit în crearea limbajului Java, un favorit al corporațiilor și un adversar al bunului simț. Java între timp a lăsat-o mai moale cu strictețea OOP, și ultimele versiuni sunt vag mai palatabile.
Concluzie
Aș evita în învățare folosirea unor exemple ambigue care nu rezistă unei minime examinări critice. Din păcate, foarte des se cedează lejerității, și în loc să se abordeze niște exemple practice, se creează aceste construcții artificiale care nici măcar nu au vreo oarecare aplicabilitate practică. Și faptul că în cele din urmă exemplele pică la o minimă examinare duce la frustrare și neîncredere - este OOP un procedeu util dacă exemplul cu care mi-a fost explicat este complet inutil și ilogic?
În practică, nevoia de a modela o ierarhie de clase apare foarte rar. Poate că accentul ar trebui mutat de pe paradigma OOP și regulile ei inconsecvente pe o abordare multi-paradigmă. OOP, funcțional, structurat, data-oriented. O materie de OOP într-un semestru de facultate e o pierdere de vreme - mai ales că cel mai probabil, la finalul semestrului studenții nu vor ști oricum ce este un vtable și cum s-ar implementa unul - și, mai important, dacă există alt fel de a programa decât OOP.
Și există.