TSM - Problema efectelor secundare

Ovidiu Deac - Consultant software

Cunoscută şi sub acronimul OOP, programarea obiectuală a fost introdusă în anii ‘60, cu apariţia limbajului Simula67. E paradigma folosită de majoritatea firmelor de software. Limbaje cum ar fi Java, C#, C++, Python, Ruby sunt în topul preferinţele iar stilul de lucru “orientat pe obiecte” este cel mai popular. 

OOP este o paradigma imperativă adică programul descrie felul în care se modifica starea sistemului pe parcursul rulării aplicaţiei. Sistemul este modelat prin clase de obiecte. Fiecare clasa descrie ce variabile de stare au obiectele de tipul respectiv, ce proprietăţi au şi ce acţiuni putem face asupra lor. Prin încapsulare ascundem detaliile de implementare şi astfel utilizatorul clasei este interesat exclusiv de interfaţa expusă de clasa respectivă. Putem modela astfel toate lucrurile din jurul nostru. Pare o abordare foarte naturală, dar totuşi sunt câteva probleme majore cu aceasta. 

Problemele pornesc de la faptul că, în afara metodelor constante, funcţiile membru produc efecte secundare pentru ca ele modifica starea obiectului pe care sunt apelate sau stările altor obiecte la care au acces. Majoritatea codului OOP este scris astfel. 

O funcţie ar trebui să returneze o valoare dar uneori aceasta produce efecte secundare care pot fi: modificarea unor structuri de date din afara funcţiei, interacţiune cu sistemul de operare/maşina virtuală (creare de fişiere, procese sau fire de execuţie), alocare sau dealocare de resurse, aruncarea excepţiilor etc. 

O funcţie care nu produce efecte secundare şi nu e influenţată de exterior se numeşte “funcţie pură” si rezultatul ei depinde exclusiv de parametrii de intrare. 

În cele ce urmează voi discuta despre problemele puse de funcţiile “impure” şi voi încerca sa arăt avantajele construirii aplicatiilor in principal pe funcţiilor pure. 

Testarea 

Variabilele globale mutabile folosite în cod sunt, în general, o sursa de probleme. Indiferent ca ele sunt simple variabile globale sau obiecte singleton, în esenţă reprezintă acelaşi lucru: date mutabile accesibile din mai multe locuri. Acest cod este foarte greu de testat şi de înţeles pentru ca valoarea unei variabile se poate schimba pe neaşteptate. Astfel pot apărea nenumărate căi de execuţie care nu sunt evidente şi pe care pentru a le putea înţelege trebuie sa parcurgem cod din afara funcţiei respective. 

Mai mult, putem spune că, în general, o funcţie care are efecte secundare este greu de testat deoarece comportamentul sistemului depinde de ordinea în care sunt produse efectele secundare deci pentru a testa funcţia trebuie sa o testăm în toate scenariile posibile în care ar putea fi apelată alături de celelalte funcţii care produc efecte secundare de tipul respectiv. 

Îmbunătăţirea testabilităţii sistemului se poate face folosind predominant funcţii pure. Acestea sunt de multe ori trivial de testat pentru că nu necesita un setup/teardown pentru că nu depind de componente externe. Rezultatul produs depinde exclusiv de parametrii de intrare. 

Paralelismul 

În momentul în care un obiect este accesat concurent din mai multe fire de execuţie avem nevoie de un mecanism de sincronizare. 

Ideal ar fi ca firele de execuţie să fie total independente, altfel sincronizarea va face ca sistemul sa nu fie folosit la capacitate maximă. Orice interacţiune între ele încetineşte sistemul. Alta problema este coerenţa cache-urilor în cazul în care procesoare diferite împart date mutabile. Mai mult, dacă avem un sistem distribuit problema e şi mai dificilă deoarece costurile de sincronizare sunt foarte mari. 

Un alt aspect este dificultatea de a scrie şi de a înţelege un cod care rulează pe mai multe fire de execuţie. Bugurile de sincronizare sunt poate cele mai dificile fiind sunt de multe ori surprinzătoare şi greu de reprodus. 

Ideal ar fi ca obiectele folosite din mai multe fire de execuţie sa fie imutabile iar datele transmise thread-ului prin mesaje asincrone. Astfel programatorul nu se loveşte de complexitatea lucrului cu primitivele de sincronizare ci lucrează la un nivel mai înalt şi mai uşor de înţeles. 

Paternul acesta este cunoscut sub numele de “thread pool pattern”. Fiecare worker-thread primeşte un mesaj care descrie jobul pe care îl are de făcut, şi returnează un rezultat pe care îl pune în alta coada. Este esenţial ca thread-ul sa nu modifice date din exterior ci sa producă un rezultat pe care să îl pună într-o coada de rezultate. 

Folosind o coada de mesaje standard a limbajului folosit, problemele noastre sunt rezolvate. Putem scrie relativ uşor o aplicaţie paralelă sau una distribuită, datorită în primul rând faptului ca worker-ul nostru nu produce efecte secundare. El produce doar un rezultat. 

Să mergem mai departe şi să presupunem ca worker-ul respectiv are acces în exterior doar la date imutabile. Asta înseamnă ca la pornire îşi poate face o copie a datelor respective nu va avea nevoie de sincronizare. Referitor la paralelismul masiv faptul că nu trebuie sincronizate firele de executie constituie un avantaj important. 

Execuţia asincronă 

În multe situaţii avem nevoie să executăm o anumită secvenţă asincron. Apelul asincron va primi pe lângă parametrii normali şi o funcţie care va procesa rezultatele. Acest stil de lucru este destul de dificil în paradigma imperativă. Problema vine de la faptul ca în stilul imperativ e foarte importantă ordinea în care se execută operaţiile. 

Ordinea este importantă tocmai din cauza faptului ca funcţiile cu care se lucrează au efecte secundare iar ordinea în care sunt produse efectele secundare e importantă. 

În lipsa efectelor secundare problema se transforma în a stabili felul în care datele depind unele de altele. Nu ne interesează cât durează calculul şi nici ordinea în care se fac calculele respective atâta timp cât rezultatele sunt disponibile când e nevoie de ele. Astfel în cazul unui apel asincron obţinem un rezultat de tip “promisiune” (future) care va fi folosit probabil pentru calculul unui alt future etc. 

Optimizarea 

Teoria spune ca optimizarea ar trebui făcută după ce funcţionalitatea dorită e implementată dar unele optimizări sunt foarte dificil de făcut dacă codul scris se bazează pe efecte secundare. 

În urma măsurătorilor tragem concluzia că se justifică sa apelăm o funcţie f în paralel. Dacă implementarea funcţia f este pură schimbarea din execuţie secvenţială în execuţie paralelă este mult mai simple comparativ cu situatia cand f produce efecte secundare. De asemenea, funcţiile pure ne dau posibilitatea de schimba relativ uşor anumite calcule din sincron în asincron faze târzii ale dezvoltării aplicaţiei. 

Pe lângă paralelizare şi execuţie asincronă putem face uşor şi alte optimizări cum ar fi execuţie lazy sau memoizare. 

Execuţia “lazy”

În unele situaţii dorim să nu executăm un anumit calcul decât dacă este într-adevăr nevoie de acesta, de exemplu încărcarea unui modul să fie facută doar la nevoie (adică “evaluare lazy”). 

Dacă încărcarea modulului produce efecte secundare aceasta optimizare poate fi dificil sau chiar imposibil de făcut. 

În concluzie pentru a face un anumit calcul lazy în fazele târzii ale scrierii aplicaţiei, mai exact în faza de optimizare, este esenţial ca acesta sa nu producă efecte secundare deci calculul respectiv să fie făcut de o funcţie pură. 

Memoizarea

O alta optimizare frecvent întâlnită este “memoizarea”. Aceasta se aplică în cazuri în care o anumită funcţie e apelată de multe ori cu aceeaşi parametrii. Memoizarea se face prin stocarea rezultatelor calculate şi la apelul următor făcut cu aceeaşi parametrii în loc să refacem calculul returnăm rezultatul calculat anterior. Aceasta optimizare poate fi convenabilă în situaţiile în care apelurile se fac repetat cu un anumit set de parametrii iar căutarea rezultatului între rezultatele calculate anterior este considerabil mai rapidă decât calculul respectiv. 

Dacă funcţia care face calculul cu pricina are efecte secundare memoizarea nu se poate face. În schimb în cazul unei funcţii pure aceasta este aproape trivială. 

Concluzie 

Problema programării orientate obiect vine de la filozofia ei că obiectele îşi modifica starea în timp. Aplicaţiile sunt construite în mare parte pe funcţii care produc efecte secundare, întregul concept de efect secundar fiind tratat superficial în OOP şi în programarea imperativă în general. 

Dacă s-ar da atenţie mai mare efectelor secundare produse de funcţii codul rezultat ar fi mai simplu, mai robust, mai uşor de paralelizat şi de optimizat. 

În articolele următoare din serie vom discuta despre programarea funcţională care diferă de programarea imperativă tocmai prin felul în care abordează problema efectelor secundare.