Ce e programarea

Procesul de dezvoltare sau de “producție” al unui produs software poartă de multe ori denumirea generică de “programare”, dar adevărul e că nu e doar atât.

Mă găsesc des în situația în care denumesc astfel procesul ăsta, pentru a simplifica exprimarea, dar dezvoltarea unui produs software nu ține doar de “programare” în sine.

Poate la punctul ăsta nu înțelegi exact ce vreau să spun, așa-i?

Până la urmă un produs software este compus în esență de codul scris de un programator.

Hai să vedem întâi ce e programarea, sau și mai corect spus ce este “programarea calculatoarelor”, pentru că în informatică despre asta vorbim.

Prin definiție, programarea (calculatoarelor) e transpunerea în cod a unui set de instrucțiuni logice care să fie executate de către un procesor, automatizând astfel anumite acțiuni care de altfel ar fi făcute manual.

Putem spune, la modul cel mai simplist, că la baza dezvoltării unui produs software stă programarea, deci ar fi fundamentul procesului de dezvoltare.

Totuși, produsele software nu sunt colecții de scripturi care să fie rulate de către un utilizator uman, ele sunt experiențe complete, companioni ce ne ajută să fim mai eficienți în toate domeniile de activitate posibile, iar din acest motiv, doar “programare” nu este suficient.

Codul curat

Domeniul ingineriei software presupune dezvoltarea de aplicații, sau produse software astfel încât ele să îndeplinească cele mai complexe necesități într-o formă cât mai simplă și mai abstractă pentru utilizatorul de rând.

Mai mult decât atât, complexitatea lor trebuie ținută sub control pentru ca noi, inginerii, să avem o înțelegere cât mai completă a proceselor tehnice care se desfășoară “sub capotă”, pentru că asta aduce ușurință în dezvoltare și adăugarea de funcții noi, cât și ușurință în depanare și rezolvarea de probleme atunci când e cazul.

Conceptul de “cod curat” sau clean code în engleză a apărut ca o nevoie pentru a rezolva această problemă a creșterii exponențiale a complexității codului.

Clean code reprezintă un set de tehnici și strategii menite să țină complexitatea tehnică sub control, în timp ce produsul devine unul din ce în ce mai mare, mai complet și mai complex pentru utilizatorii lui.

Astfel conceptul de inginerie software prinde contur și înglobează programarea cu aceste tehnici, deci după cum vezi, programarea e doar procesul de execuție al planului ingineresc, nu e dezvoltarea de software.

Problema codului cuplat

Una din cele mai frecvente probleme apărute în procesul de dezvoltare este cea a codului cuplat.

Codul cuplat este cel al căror elemente constructive, cum ar fi clasele, în cazul programării orientate pe obiect, depind puternic unele de celelalte.

Uite un exemplu:

class Car
{
  private readonly Gearbox _gearbox = new Gearbox();
  private readonly Engine _engine = new Engine();

  private const int GearUpThreshold = 2200;

  public void StartEngine()
  {
    if (HasFuel())
    {
      _engine.InitiateIgnition();
    }
  }

  public void Drive()
  {
    if (_engine.RevolutionsPerMinute > GearUpThreshold)
    {
      _gearbox.ChangeGear();
    }
  }

  // .. am omis alte implementări pentru simplificare
}

În acest caz, clasa Car depinde direct de celelalte 2 clase pentru a funcționa, dar mai mult decât atât, un obiect obținut din clasa Car va conține automat și codul scris în celelalte 2 clase, care introduce o problemă destul de mare în codul aplicației noastre.

Această problemă poate fi sesizată destul de rapid chiar și de cei mai începători și vine la pachet cu două compromisuri majore:

  1. Imposibilitatea testării unitare a codului.

Dacă nu știi ce e testarea unitară, reprezintă procesul de testare a instrucțiunilor transpuse în cod, tot prin cod. Toate presupunerile pe care noi le facem despre ceea ce se întâmplă în codul nostru sunt transpuse în teste (adică instrucțiuni în cod), care pot fi executate ad-hoc în oricare moment pentru a ne valida corectitudinea codului.

Testarea unitară se face granularizând extrem de tare codul, până la nivel de metodă, sau funcție, dar grupând testele pe fiecare clasă (în cazul programării orientate pe obiect). Această practică nu e obligatorie dar e extrem de des întâlnită.

  1. Propagarea modificărilor mai mult decât e necesar.

Gândește-te la situația în care vom avea nevoie ca aceeași mașină să funcționeze cu mai multe tipuri de cutii de viteze, să spunem, facând necesară pasarea unui parametru în apelul metodei, de exemplu: ChangeGear(int gear).

Asta înseamnă că această modificare se va propaga și asupra clasei Car și va trebui să mergem și să schimbăm codul și acolo pentru a ajusta.

Acum gândește-te că această clasă Gearbox e folosită prin foarte multe alte clase, pentru alte tipuri de vehicule. O schimbare a semnăturii unei metode înseamnă că va trebui să mergem în toate clasele și să ajustăm codul lor, astfel încât să se adapteze noii semnături.

Acest proces poate produce o explozie de modificări pe lanțul apelurilor dintre clase, care la rândul lor pot introduce noi defecte sau comportamente neașteptate în aplicația noastră.

A doua problemă se rezolvă prin ascunderea implementărilor în spatele unor abstracții, precum o interfață, stabilind astfel o înțelegere “contractuală” între clase, nu legături directe.

interface IGearbox
{
  void ChangeGear();
}

interface IEngine
{
  void InitiateIgnition();
}
class Car
{
  private readonly IGearbox _gearbox = new Gearbox();
  private readonly IEngine _engine = new Engine();

  private const int GearUpThreshold = 2200;

  // ..
}

Injectarea dependențelor

Totuși, asta nu rezolvă toate problemele noastre, clasa Car încă e destul de strâns legată de Gearbox și Engine chiar dacă între ele există acum un set de abstracții.

Dar cum ar trebui să se facă totuși interacțiunea între ele? Pentru că ea este necesară pentru a putea construi funcționalități complexe.

Răspunspul la problema asta este tehnica denumită “injectarea dependențelor” (en. dependency injection).

Asta presupune ca instanțele obiectelor de tipul Gearbox și Engine să nu mai fie obținute de clasa Car direct, ci să fie primite din exterior.

Aici există 2 posibilități:

  1. Injectarea prin constructor
class Car
{
  private readonly IGearbox _gearbox;
  private readonly IEngine _engine;

  private const int GearUpThreshold = 2200;

  public Car(IGearbox gearbox, IEngine engine)
  {
    _gearbox = gearbox;
    _engine = engine;
  }
}
  1. Injectarea fiecărei dependență prin metode individuale
class Car
{
  private readonly IGearbox _gearbox;
  private readonly IEngine _engine;

  private const int GearUpThreshold = 2200;

  public void SetGearbox(IGearbox gearbox)
  {
    _gearbox = gearbox;
  }

  public void SetEngine(IEngine engine)
  {
    _engine = engine;
  }
}

Prima metodă, cea prin constructor, e mult mai populară decât a doua totuși.

În momentul ăsta, un obiect obținut din clasa noastră Car operează complet independent de celelalte 2, interacționând cu ele prin intermediul unor interfețe fără să știe ce se află dincolo de ele.

Asta ne dă nouă posibilitatea să testăm clasa Car manipulând cele 2 dependențe ale ei, astfel încât să testăm toate scenariile posibile într-un mediu complet izolat și independent una de cealaltă.

Fiecare clasă poate fi testată individual, astfel avem posibilitatea să ne asigurăm că orice potențială modificare în una din ele nu va introduce defecte noi în aplicația noastră.

De asemenea, avem posibilitatea să dezvoltăm codul într-o manieră modulară, înlocuind să spunem instanțele din spatele interfețelor cu alte implementări ce respectă aceeași interfață.

class AutomaticGearbox : IGearbox
{
  public void ChangeGear()
  {
    // ..
  }
}

Această tehnică e un criteriu pentru procesul de injectare al dependențelor, dar vei mai auzi de ea și sub numele de “inversarea dependenței” (en. dependency inversion).

Prin folosirea acestei tehnici, toate instanțele necesare tuturor claselor din aplicația noastră vor fi obținute la cel mai înalt nivel și pasate în jos pe arborele de dependențe.

Asta se poate face manual, de către dezvoltator, sau automat printr-un container pentru inversarea dependențelor (en. dependency inversion container).

Graful dependențelor în cod

Semantică

Acum că ai înțeles procesul, e nevoie să mai clarific un lucru legat de terminologia folosită în limba română, pentru că mulți oameni folosesc greșit anumiți termeni aici.

Vei auzi această expresie tradusă în limba română de alți colegi de breaslă și sub forma “injectarea dependințelor”, care este greșită din punct de vedere al limbii române.

Un procent extrem de mare de oameni din industrie folosesc greșit expresia asta, așa că vreau să fii atent la asta și să o eviți.

Conform dicționarului explicativ al limbii române, termenul de dependință înseamnă: “Încăpere accesorie a unei case de locuit; Cladire, încăpere care depinde de o construcție principală”.

Ori semantica pe care noi vrem să o transmitem aici e cea a unei relații de reciprocitate dintre 2 elemente, nimic legat de încăperile unei locuințe.

De-asta termenul corect este acela de “injectarea dependențelor”, nu “dependințelor”.

Concluzie

Injectarea dependențelor e o tehnică ce te va ajuta, nu doar să obții cod testabil din punct de vedere unitar, dar și să izolezi mai bine componentele unele de altele, păstrând totuși comunicarea între ele.

În cele din urmă, vei obține și un cod mai bine organizat și mai ușor de citit și înțeles.

Atât pentru azi, pe data viitoare.