How to Make Better Reuse of PHP Code using Traits

Am descoperit de curând traits la un proiect nou. Reprezintă o rezolvare destul de elegantă la problema moștenirilor multiple, dar dacă nu ești atent s-ar putea să generezi mai multe probleme decât rezolvi.

Am aflat the hard way că:

  1. La fel ca și clasele, un trait ar trebui să facă un singur lucru;
  • la fel ca o clasă, nu ar trebui să știe nimic de lumea exterioară. Altfel spus, clasa care folosește trait-urile ar trebui să apeleze metodele moștenite, niciodată invers (e.g. să apelezi din trait o metodă ce nu face parte din trait)
  • Trait în trait este, cel mai probabil, o idee proastă.

Două articole interesante pe tema asta:

  1. http://www.phpclasses.org/blog/post/305-How-to-Make-Better-Reuse-of-PHP-Code-using-Traits-Part-1-Basic-Code-Reuse.html
4 Likes

Cel mai usor e sa gandesti Trait-urile ca “behavior”-uri.

Totusi nu as merge prea departe cu separarea call-urilor de metode. Pana la urma metodele de pe obiecte reprezinta un “contract” de lucru cu acele obiecte si este OK sa le poti apela. Sa zicem ca avem diverse componente html: containere, popovere, modale si vrem sa imlementam “Draggable” (pentru JS- traiturile are fi mixinuri): trebuie sa poti interfata cumva cu obiectul gazda pentru pozitionare.

Problema cea mai mare cu traiturile e ca nu obliga obiectul gazda la un contract. Mi-ar fi placut sa poti spune unui Trait, ca poate fi atasat doar la obiecte care implementeaza o interfata anume. Cum ar fi pentru exemplu de mai sus, sa poti pune respectivul Trait Draggable doar pe obiecte care au metoda setXY(x,y) implementata.

2 Likes

Poti declara metode abstracte in trait-uri.

3 Likes

Nice. Nu stiu de ce am avut mereu impresia ca nu pot face asta. Sa vedem daca crapa ceva prin cod :slight_smile:

2 Likes

Asta ar viola SOLID.

1 Like

Care principiu, mai exact?

În principal SRP.

Dacă @Catalin_Banu caută ceea ce suspectez eu că vrea, friend ca-n C++ e mult mai potrivit, fiind un concept ortogonal cu toate celelalte concepte. Trait e (chiar la nivel intern în PHP) un mecanism de copy/paste de cod, nimic mai mult.

Într-o arhitectură care respectă SOLID, nevoia de Trait apare destul de rar. Când faci TDD, TDD îți șoptește că ai nevoie de Trait. Trebuie doar să asculți ce are TDD de zis.

1 Like

Uite, am avut o discuție cu @andySF pe o problemă ce ar fi putut fi rezolvată cu tratits, dar limbajul lui (c#) nu-i permitea asta (sau cel puțin nu in mod ușor). Voi încerca să expun problema cât mai simplu:

Să zicem că avem niște clase ce ne ajută să stabilim proprietățile unor obiecte¹. Problema este că unele obiecte au nevoie de anumite proprietăți, altele… mai puțin.

De exemplu:

  • Clasa Persoană ar avea nevoie de proprietățile vârstă și greutate;
  • Clasa Mașină ar avea nevoie de proprietățile vârstă, culoare și capacitate-cilindrică;
  • Clasa Jucărie ar avea nevoie de proprietatea culoare.

Consider că e destul de evidentă direcția în care mă îndrept: sunt anumite lucruri care sunt comune mai multor obiecte, dar care nu au ce să caute într-o singură clasă.

Din punctul meu de vedere, ori traits ori moștenirile multiple în această situație sunt cele mai potrivite.

¹ Obiect scris italic se referă la un obiect fizic: pix, carte etc. Obiect scris normal se referă la un obiect din OOP.

1 Like

Nu înțeleg drumul tău mental de la clase la proprietăți.

Cu clase modelezi behaviour, nu proprietăți.

Nu văd relevanța exemplelor tale de clase. Ce nu vrei să ai sunt obiecte într-un state invalid, deci vrei să eviți crearea de setteri. Def setter = artefact, metodă care nu face nimic în termeni de business value.

Clasele tale ar primi valorile necesare în constructor. Ori ai obiectul creat, și e garantat să fie valid, ori nu-l ai. Dacă nu faci asta, vor răsări prin cod o grămadă de if-uri care complică codul.

Relevant:

Marea majoritate a metodele care setează ceva, trebuie să și facă ceva în termeni de business logic. La astfel de cod ajungi și dacă respecți SOLID.

1 Like

Să zicem că fiecare trait ar conține câte o metodă de genul getXXXX: getVârstă, getCuloare etc.

A introduce un trait doar pentru getteri e evitarea repetiției codului dusă la extrem. Nu e nevoie să eviți repetarea de cod dacă încalci alte principii. Principiul încălcat aici e: un trait e folosit pentru modelarea de encapsulated shared behavior.

Un getter nu are behaviour, e doar un artefact necesar datorită private la proprietate.

Un trait care ar avea sens ar fi Deplasabil, cu o metodă deplasare(Coordonata) si o metoda getCoordonata(). getCoordonata() ar fi doar un artefact datorita encapsularii, insa motivul pentru introducerea trait-ului e exclusiv metoda deplasare().

Toate trei clase ar putea refolosi trait-ul, care in general va fi acompaniat si de o interfata, asta daca limbajul nu-ti permite sa faci contracte direct cu trait-ul (sa folosesti trait-ul ca pe o interfata in semnaturile metodelor).

Ei, ce faci tu aici e cherry picking :smile:

A fost doar un exemplu simplificat la maximum.

La proiectul la care am folosit eu traits a fost un pic mai mult: o clasă ce se ocupa cu prepararea field-urilor comune pentru un admin panel. Pe lângă aceste field-uri comune mai aveam și field-uri specifice fiecărei secțiuni, cu mențiunea că aveam câteva secțiuni ce aveau unele field-uri comune, dar nu toate.

Probabil dependințele injectate în constructor ar fi funcționat și ele, dar mi se părea un pic overkill, așa că traits și-au făcut treaba destul de bine.

Nu e cherry-picking, ti-am dat exemplu de trait care ar avea sens, probabil la fel a fost si in cazul tau concret.

Inteleg ca ai simplificat, dar ai simplificat prea mult exemplul. The devil is in the details.

2 Likes

As face cateva observatii despre modul in care vad eu clasele (nu zic ca e by the book)

  • metodele (publice) reprezinta “contractul” pe care il expun pentru a stii cum sa lucrezi cu ele. Astfel geterele si seterele fac parte din clasa, daca fara ele clasa nu ar avea niciun sens.
  • clasa are un singur scop (single responsibility principle) asa ca acele setere si gettere deservesc acel scop.
  • daca vreau sa ii aduc behaviouri, folosesc Traituri, acestea aducand singur un intreg “value” pe obiectul respectiv. Obiectul trebuie sa aiba sens si fara trait.
  • in ceea ce priveste constructorul el trebuie dus la minim cand vorbim de parametrii. O regula care incep sa o aplic in ultimul timp: nu pune in constructor decat lucruri fara de care obiectul nu se poate initializa.

Nu vad cum ar viola SOLID

Orice unitate trebuie sa-si poata indeplini responsabilitatea singura, iar un trait este o unitate.

Daca ai nevoie de acea restrictie, inseamna ca ai nevoie de apelarea lui $this->metodaDinInterfata(), desi metodaDinInterfata nu se afla in unitatea respectiva (in trait).

Deci violeaza SRP. Pana sa ataci acest punct, citeste in continuare si ataca intregul raspuns :wink:

Daca ai acea nevoie de a apela medodaDinInterfata(), te gandesti la OCP si faci un sistem de plugin, iar pe trait il transformi in clasa care implementeaza interfata comuna tuturor pluginurilor.

Sistemul de pluginuri (sistemul de atasare / inlaturare a unui plugin) astfel rezultat il poti muta intr-un nou trait, dar ceea ce a fost trait cu acel apel la metodaDinInterfata() devine plugin.

Adica muti totul cu un nivel de abstractizare mai sus, si codul devine intuitiv w.r.t. SOLID (atat SRP, cat si OCP).

Observi ca un astfel de trait ar fi ciudat si atunci cand incerci sa-l testezi, pentru ca iti cere sa creezi metode noi in test double - daca vrei sa faci unit testing pentru trait.

Violeaza si DI, deoarece cu un trait (linia aia inocenta “use Trait”) importa behaviour strain in clasa actuala, insa acest behaviour nu poate fi alterat, e fix, hardcoded, la fel ca apelurile la new sau apelurile statice la metode. Adica: in loc sa ii dai unei clase dependintele (DI), ii spui clasei sa si le traga singura de fiecare data cand folosesti un trait.

D-aia zic, cand faci TDD si respecti SOLID, rareori vei simti nevoia de traits. Foarte rar.

2 Likes

Nu imi doresc sa atac raspunsul tau. Doream doar sa stiu unde vezi tu acea violare de SRP.

Mi se pare foarte buna solutia ta de pluginuri: decupleaza foarte mult lucrurile.

1 Like

O sa dau eu alt use case.

Avem un REST API ce este digerat de angular. Chestii destul de simple in general, acel API nu face absolut nimic inafara de:

  • Scoate resursele din mediile de storage
  • Le filtreaza, sorteaza si pagineaza
  • Le face output

Faci metoda index la resursa attachments, faci metoa index la resursa affiliates. Ce observam? Se repeta, este exact acelasi lucru.

Creezi urmatorul trait: ListHelper, contine metoda index care se foloseste de $this>repository si face tot ce e mai sus, dar trimitand o parte din responsabilitate catre repository.

Behaviorul acelei metode inca poate fi alterat, metodele sunt pur si simplu copiate de catre interpretor.
Trait-urile contin metode normale, sunt prin definitie alterate de state-ul clasei in care au fost copiate.
In cazul de fata, ListHelper este alterat de repository-ul care a fost injectat in constructorului controllerului.

De asemenea, el poate fi extins, inconjurat de alt behavior redenumind metoda copiata si scriind alta logica in jurul ei.
(de exemplu, poti adauga cateva filtre obligatorii sau poti injecta un FormRequest)

Nu cred ca exista alternative care sa nu te scoata din “Controllers”, pierzand astfel toata flexibilitatea pe care ti-a oferit-o framework-ul pana in punctul acela.

Cazul de fata a fost folosit in proiecte reale extinse pe mai multe luni de dezvoltare, rezultatele au fost multumitoare. Eu preluand metoda de la programatorul care a inceput proiectul, nefiind cel ce a gandit-o in faza initiala.

PS: Iar sunt pe fuga, scuze daca au ramas greseli.

Deci testezi acel trait (pentru ca n-ai de ales, e hard coded ca un apel de metoda statica) de fiecare data cand testezi orice clasa care il foloseste. Cam cat test coverage ii trebuie sarmanului trait? E mai verde ca jungla amazoniana :stuck_out_tongue:

Daca modifici ceva in trait, si teoretic ar trebui sa fail-uiasca un singur test, incep sa fail-uiasca 20 de teste.

Deci ai brittle tests si increderea ta in propriile teste scade, pentru ca scade valoarea lor de defect identification.

Daca ai respecta insa SOLID, in particular OCP si DI, ai putea testa totul in izolare. Cand ai rula testele dupa ce ai introdus un bug in “trait” care e acum un plugin (multumita OCP), ti-ar pica o gramada de integration tests, dar un singur unit test, cel cu pluginul.

Cum ar fi viata daca dintr-o simpla rulare a unui software (cel de test), poti vedea vinovatul unei regresii, si toate locurile pe care le afecteaza?

Eu cred ca ai putea lua decizii de design/refactoring/bug fixing mai bine informate.

1 Like

Nu programez TDD, si nici in unit teste nu sunt expert, asa ca intreb.

De ce ar trebui sa faci teste pentru fiecare clasa care foloseste traitul? Nu e suficient sa testezi traitul respectiv si atat? Practic in testul traitului ar trebui sa ma folosesc de o clasa care sa imi serveasca doar pentru acel test. Nu? Adica in test as pune doar dependintele de Trait, si nu intregile clase cara fac altceva.