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 printr-o prezentare similară. Ni s-a vorbit despre cum arată o ierarhie de obiecte, și de ce ai face o ierarhie de obiecte, pornind de la un exemplu considerat relevant: „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.

Eu cred că problema provine 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 obiecția mea vă va deveni mult mai clară după acest articol. Și pentru că nu ne grăbim nicăieri, o să-mi permit să încep cu o anecdotă personală.

Dau vina pe anii ‘90, și obsesia lor pentru principiile programării orientate pe obiect și design patterns (și UML). Fără programare orientată pe obiect era imposibil să treci de primele zece minute dintr-un interviu, iar partea de șabloane de design era o culminare a princiilor 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). Inevitabil se pusese semnul egal între programare și „programare orientată pe obiect”, iar dacă vroiai să fii luat în serios trebuia să ai cunoștințe solide de design patterns.

Idealul, mi s-a explicat, era ca un arhitect software să scrie UML (într-un produs ca Rational Rose) și din acel UML să rezulte aproape direct soluția software întreagă. Și pentru că tehnologia încă nu era acolo, programatorii încă trebuiau să traducă din UML în cod real, folosind aceste design patterns. Nici nu prea aveai nevoie de niște programatori buni, doar de niște oameni care să recite aceste design patterns și gata. Trebuia doar să ai un arhitect bun care să gândească lucrurile foarte bine și produsul era ca și făcut.

Cum nu știți pe nimeni să programeze cu Rational Rose, vă puteți imagina cam cum a funcționat treaba asta. Toată experiența m-a scârbit, și am decis să fiu anti-curent (eram și eu adolescent, deh). Î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

Aproape inevitabil cel mai simplu exemplu de derivare care se oferă studenților, fie pe un slide, fie pe tablă, este ceva similar cu ce vedem mai jos:

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;}
};

Uneori lucrurile merg mai departe de atât, și profesorul, entuziast, va începe să deseneze o ierarhie de potențiale obiecte care nu rezolvă nicio problemă, dar care „pare logică”.

O ierarhie de clase din care unul din lucrurile de care nu ducem lipsă e entuziasmul

O ierarhie de clase din care unul din lucrurile de care nu ducem lipsă e entuziasmul

Revenind la cod, 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. Așadar vom încerca să nu ne luăm de fiecare virguliță de pe slide, ci vă voi invita să ne uităm la câteva aspecte fundamentale.

În primul rând clasa Patrat are un membru moștenit, 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.

Fapt divers: Discutând despre justificarea acestei derivări am ajuns chiar la următoarea explicație: trebuie adăugat un nou membru x pentru că oricum membrul x din dreptunghi nu e vizibil (este privat în slide și nu trebuia să o interpretăm ca eroare de slide, ci ca intenție). Î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ă. Este foarte greu să creezi un exemplu de folosire a acestei ierarhii care să nu se simtă extrem de artificial. De exemplu o ierarhie Pătrat-Dreptunghi nu răspunde la o întrebare esențială: care e diferența dintre cele două clase? De ce a fost necesară crearea acestei distincții într-un fel atât de drastic? Avem nevoie să creăm o clasă nouă pentru Pătrat? Răspunsul că întotdeauna clasele derivate și clasele de bază sunt în relația IsA pare corect, pentru că „Pătrat” este un „Dreptunghi” în lumea reală, în cartea de geometrie. Numai că noi nu scriem o carte de geometrie ci încercăm să rezolvăm niște probleme concrete, și necesitatea (avem nevoie?) ar trebui să fie cea care să ne ghideze alegerile. Ori necesitatea ne spune că o astfel de încrucișare consumă și mai multă memorie și nu aduce nicio funcționalitate nouă.

Să ne uităm deci la o abordare alternativă.

Dreptunghiul care derivă din pătrat

După ce e evident că derivarea adaugă noi membri, a fost natural ca interlocutorul să îmi explice că „așa e, derivarea ar trebui făcută invers”. Rezultatul este (citez) „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ă.

Lipsește însă aspectul practic. Există vreo utilitate pentru o astfel de împărțire? Ne-ar ajuta să avem o colecție de Pătrate care ar putea fi și Dreptunghiuri în anumite condiții? De ce?

De ce?

Ierarhia dreptunghi-pătrat este folosită pentru a le explica studenților aflați la primul contact cu o ierarhie de clase pentru că la suprafață pare un exemplu simplu și intuitiv. Se bazează pe niște noțiuni cunoscute - geometria e parte din materia necesară pentru admiterea la liceu și facultate, deci ar trebui să fie cunoscută la primele lecții de programare, fie că sunt la liceu sau la facultate. Relația dintre pătrat și dreptunghi este similară cu cea dintre clasa derivată și clasa de bază. Exemplul pare natural, însă e ineficient, și pare inutil. În loc să ai o altă clasă „Pătrat” ar trebui să fie suficient să ai o instanță de dreptunghi cu două laturi egale. Cel mult, un generator ar putea rezolva această problemă, și eu (și nu numai eu) am sesizat acest aspect de la primul contact cu acest exemplu. Profesorii însă au un plan de lecție, și atunci vin cu argumente de tipul relația de tip IsA se modelează ca derivare, și justificarea absolută este că „vrem să refolosim funcționalitatea”.

Urăsc argumentul dogmatic: pentru că pătratul e un dreptunghi trebuie să îl modelăm ca un dreptunghi și să simbolizăm asta prin derivare. Argumentul dogmatic are exact la fel de multă forță ca și în viața reală. Trebuie să faci așa cum ți se zice, pentru că așa ți se zice. Dacă un pătrat e un dreptunghi, atunci trebuie să faci derivare, altfel nu iei nota. Și dacă ai un minimum de gândire critică o să ai probleme majore să te adaptezi într-un mediu care îți impune să execuți, nu să gândești.

Revenind la exemplele de mai sus. Putem cumva salva cele vreuna din variante? Le putem face să fie un pic mai logice?

Cel mai simplu de salvat e a doua (Dreptunghi derivat din Pătrat), pentru că acolo funcționează niște principii de derivare la nivel de cod, dar nu la nivel logic. 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 aș fi capabil să dau vreo noimă.

Dar prima ierarhie (Pătratul derivat din Dreptunghi) e de nerecuperat. Lanțul de derivare poate să se prelungească înspre absurd, după cum ați văzut pe ierarhia extinsă. Logica are de-a face cu manualul de geometrie, dar nu se referă la niște necesități importante în materie de software. Aria unui pătrat și a unui dreptunghi nu se calculează diferit în acel model. Extensia cu triunghiuri are și mai puțină logică.

Suspectez că 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 aici poate să vină și 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. Se preferă abordarea unui model mult mai complex și costisitor în numele unei presupuse clarități superioare a codului.

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 folosirea în învățare a unor exemple ambigue care nu rezistă unei minime examinări critice. Mi-aș dori să nu se înceapă discuția despre programare orientată pe obiect pornind de la construcții artificiale care nici măcar nu au vreo aplicabilitate practică. Faptul că exemplele vor pica la o minimă examinare duce la frustrare și neîncredere (în procesul de învățare). Un student se va întreba dacă OOP este o paradigmă utilă atunci când își dă seama că exemplele sunt ilogice sau inutile.

Î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 pare o pierdere de vreme - mai ales așa cum pare că încă se practică. La finalul semestrului studenții nu vor ști, de exemplu, ce este un vtable, cum s-ar implementa unul, și care sunt costurile folosirii paradigmei OOP. Dar, cel mai important, nu vor ști dacă există alt fel de a programa decât OOP.

Și există.