TSM - Scala și programarea funcțională

Andrei Muja - Scala Engineer @ Zenitech

Acesta este primul articol despre Scala dintr-o serie de trei ce vor fi publicate aici. În următoarele două, o să prezint câteva din metodele des folosite în colecții de date care oferă o eleganță aparte acestui limbaj, avantajele și dezavantajele programării funcționale în crearea unui produs software performant și de calitate. Apoi, vom rezolva câteva probleme de algoritmică în format 100% pur funcțional. La finalul acestei serii, am deplină siguranță că veți pleca cu un nou bagaj de cunoștințe care ar putea să vă creeze o perspectivă diferită în abordarea problemelor, benefică în cariera voastră de developeri. De asemenea, ar putea să fie un impuls în a dezvolta proiecte personale sau a fonda un start-up cu această tehnologie.

Notă: pe parcursul tuturor articolelor, voi folosi Scala 2.13, însă bucățile de cod sunt compatibile și cu Scala 3, deoarece nu mă voi axa pe schimbările de sintaxă dintre versiuni.

Ce este Scala?

Scala este un limbaj de programare care oferă suport atât pentru programarea orientată pe obiecte (OOP), cât și cea funcțională. A început ca un proiect în Elveția, la École Polytechnique Fédérale din Lausanne, de către Martin Odersky, apărut în 2003. De atunci, a crescut în popularitate, iar multe corporații, precum Twitter, Zalando sau Coursera, l-au adoptat în scrierea sau înlocuirea backendului lor. Comunitatea este în creștere, în România fiind încă la un nivel scăzut, deși cererea pentru persoane capabile să codeze în Scala este mare. Are o mulțime de frameworkuri: Play, Lift dar și librării dezvoltate și menținute de un grup de oameni creativi care împărtășesc interese comune și pun pasiune în munca lor pentru a livra soluții moderne și performante, ținând cont de expresivitatea oferită. Rulează pe JVM, la fel ca Java, dar și Kotlin.

Ce este programarea funcțională?

Înainte de a discuta despre programarea funcțională sau PF/FP, să ne aducem aminte ce era o funcție. O funcție primește niște date de intrare, input, și dă un rezultat, output. Ca să ajungi de la un punct A la B, va trebui să trecem printr-o operație/transformare. Funcția ia elemente dintr-un set inițial numit domeniu și le returnează într-un alt set, numit codomeniu, fiecare input producând exact un output. Cum folosim aceste noțiuni în programare? În paradigma funcțională, funcțiile sunt piatra de temelie în implementarea programelor. Asta nu e de ajuns, întrucât, am dori, și ar fi ideal, însă imposibil mereu, să avem de-a face doar cu funcții pure și imutabilitate. Putem folosi și în programarea orientată pe obiecte funcții care iau sau returnează alte funcții și metode care sunt definite în interiorul altora. Dar în cea funcțională, le întâlnim la orice pas, oferind o naturalețe aparte și reducând drastic numărul liniilor de cod. Am putea numi această paradigmă matematica "pe steroizi" la care apelează developerii.

Particularitățile PF, folosind Scala

O bună parte din aceste caracteristici le regăsim și în alte limbaje ca Haskell, Clojure, chiar și Java Streams (de la 1.8) și Javascript. Asta pentru că Scala este influențat de ele, dar și invers. Totuși, noi le vom vedea în acțiune utilizând Scala și ce "puteri benefice" ascund în acest mediu de dezvoltare.

Imutabilitate: programarea imperativă și OO utilizează pe scară largă date care suferă modificări, adică, în termini tehnici, este vorba de mutable state. ,,We don't do that here", cum ar zice Pantera Neagră din Avengers. Vrem ca fiecare valoare să rămână neschimbată, fără a exista opțiunea de o modifica direct după ce a fost creată. Obiectele imutabile sau, în engleză, immutable duc la mai puține erori în cod și probleme de performanță în aplicațiile multithreading cu multe fire de execuție. Bineînțeles, anumite valori sunt modificate, dar într-un mod special, folosind un construct numit case class. Dacă doriți mai multe informații despre ce este un case class, așa cum am zis în introducere, aruncați un ochi peste celălalt articol în engleză unde prezint fiecare Scala feature în detaliu. Apelând metoda copy asupra unui obiect, putem foarte ușor să accesăm și schimbăm proprietățile acestuia. Mai jos avem:

object Practice {
  case class Asteroid(name: String, 
    yearDiscovered: Int, diameterKm: BigDecimal)

  val asteroid: Asteroid = Asteroid("1 Ceres", 
     1801, 939.4)

  val asteroidDataChanged: Asteroid = 
     asteroid.copy(diameterKm = 941.2)

  def main(args: Array[String]): Unit = {
    println(asteroid)
    println(asteroidDataChanged)
  }
}

Rulați programul și comparați cu outputul din imagine. Diametrul asteroidului a fost modificat, chiar dacă obiectul conține valori immutable - tipul de variabilă este val

Funcții pure: au anumite proprietăți. În primul rând, ele trebuie să returneze mereu aceeași valoare pentru același input. În al doilea rând, să presupunem că avem următoarea funcție:

def add(a: Int, b: Int): Int = a + b 
// referential transparency

val nine: Int = add(4, 5)

Bucata de cod poate fi ușor înlocuită cu rezultatul și vice-versa fără a afecta în vreun fel programul. Acest concept numit referential transparency este foarte important care-i asigură developerului opțiunea să reducă din codul repetitiv, îmbunătățind astfel principiul de clean-code. Oriunde întâlnim această metodă, tot ce trebuie făcut este doar să o înlocuim cu add (4, 5), 4+5 or 9.

object Practice {
  def add(a: Int, b: Int): Int = a + b 
// referential transparency
  val nine: Int = add(4, 5)
  val eighteen: Int = nine + nine
  val eighteen_2: Int = add(4, 5) + add(4, 5)
  val eighteen_3: Int = 9 + add(4, 5)

  def main(args: Array[String]): Unit = {
    println(eighteen)
    println(eighteen_2)
    println(eighteen_3)
  }  }

Încercați acest program miniatural, iar rezultatele vor fi:

Funcțiile pure nu au efecte secundare! Revenind la modul imperativ, remarcăm că aproape fiecare funcție are un efect nedorit : de la a schimba o valoare dintr-o colecție (mic spoiler pentru cel de-al doilea articol, colecțiile în Scala sunt în mod implicit immutable ) la a arunca o excepție. Aici, în "lumea" funcțională, toate efectele secundare dispar, funcțiile sunt mai ușor de testat și menținut, având un comportament determinativ. În Scala, funcțiile returnează întotdeauna o valoare, chiar și în prezența unei erori. Limbajul nostru drag se ocupă de asta pentru noi prin prezența unor obiecte care acceptă valori normale, lipsa acestora sau excepții (Option, Either, Try). Din nou, verificați articolul celălalt pentru mai multe informații.

Legi de compoziție: revenind la matematică, funcția (f ∘ g)(x) = f(g(x))- da, fix aceea de la orele de analiză - pentru orice x, este o lege de compoziție. În programare, vom pasa rezultatul primei funcții ca argument pentru cea de-a doua. Urmărind exemplul de mai jos, vom vedea că Scala are o metodă numită compose. Definit ca un trait, creează o funcție anonimă care acceptă un parametru, aplică funcția internă g, returnează valoarea, o pasează în cea externă, f, și ne oferă rezultatul final.

trait FunctionCompose[X, Y] {
//f:X->Y, g: Z->X, with g(z)=x and f(g(z))=y

  def compose[Z](g:Z=>X):Z=>Y
}

Hai să vedem și funcția compose în acțiune:

object Practice {
  def f(valueF: Int): Int = 2 * valueF + 1
  def g(valueG: Int): Int = valueG + 6
  val fWithG: Int => Int = f _ compose g

  def main(args: Array[String]): Unit = {
    println(fWithG(3))
    println(f(g(3)))
  }
}

Probați codul de mai sus pentru a vedea că răspunsul celor două metode este același.

High-order functions (HOF): acestea preiau una sau mai multe funcții ca parametri și returnează o singură funcție ca rezultat. Pentru a înțelege mai bine, să punem în practică ceea ce tocmai am spus. Să considerăm că vrem să calculăm o distanță în diferite unități de măsură. Creăm funcția ce transformă metrii în yarzi, inch sau mile.

// presupunem că fiecare distanță inițială este 
// în metri

  def calculateDistance(distance: BigDecimal, 
calculateUnitMeasure: BigDecimal => BigDecimal): BigDecimal = calculateUnitMeasure(distance)

  def calculateDistanceFeet(distance: BigDecimal): BigDecimal = distance * 3.28084

  def calculateDistanceInches(distance: BigDecimal): BigDecimal = distance * 39.3701

  def calculateDistanceMiles(distance: BigDecimal): BigDecimal = distance * 0.000621371

  val distanceInFeet: BigDecimal = 
   calculateDistance(200, calculateDistanceFeet)

  val distanceInInches: BigDecimal = 
   calculateDistance(200, calculateDistanceInches)

val distanceInMiles: BigDecimal = 
   calculateDistance(1550, calculateDistanceMiles)

  def main(args: Array[String]): Unit = {
    println(distanceInFeet)
    println(distanceInInches)
    println(distanceInMiles)
  }
}

Prima metodă este un HOF care returnează funcția pasată ca parametru. Observați că nu este nevoie să punem paranteze pentru metodele auxiliare de calculare a distanței. Noile distanțe vor fi:

Mai jos avem un alt exemplu unde folosesc pattern matching pentru a demonstra aplicabilitatea unei HOF:

object Practice {
  def weightMercury(weight: BigDecimal): BigDecimal = 
    weight * 0.38

  def weightJupiter(weight: BigDecimal): BigDecimal = 
    weight * 2.34

  def weightSaturn(weight: BigDecimal): BigDecimal = 
    weight * 0.93

  def calculateWeightOnPlanet(weightOnPlanet:
    BigDecimal, planet: String): BigDecimal =
    planet match {
      case "Mercury" => weightMercury(weightOnPlanet)
      case "Jupiter" => weightJupiter(weightOnPlanet)
      case "Saturn" => weightSaturn(weightOnPlanet)
      case _ => weightOnPlanet 
      // stai pe Pământ, e cel mai sigur :D
    }

  def main(args: Array[String]): Unit = {
    println(calculateWeightOnPlanet(90, "Mercury"))
    println(calculateWeightOnPlanet(90, "Jupiter"))
    println(calculateWeightOnPlanet(90, "Saturn"))
    println(calculateWeightOnPlanet(90, "Earth"))
  }
}

Calcularea greutății a diferitelor planete este posibilă pasând o funcție pe baza unui String. Dacă nu există un match sau avem cazul default, notat cu simbolul wildcard "_", returnăm greutatea planetei noastre, Terra:

Funcții anonime: dacă ați fost atenți, poate ați observat că am menționat acest concept mai devreme. Multe din metodele aplicate colecțiilor iau o funcție anonimă ca argument. Aceasta nu are nume, dar are un body, input și, ocazional, un return type. Are și alte denumiri ca: function literal / lambda expression. Aici este un exemplu de creare a unei noi liste ale cărei valori sunt mai mari decât 12, condiție impusă de noi, dintr-un interval de numere cuprins între 1 și 20:

object Practice {
  val ints: Seq[Int] = List.range(1, 20)
  val greaterThan: Seq[Int] = ints.filter(_ > 12)

  def main(args: Array[String]): Unit = {
    println(greaterThan)
  }
}

Closures: sunt tot funcții, dar au una sau mai multe variabile libere. Dar ce sunt aceste variabile libere? Sunt tot niște variabile, însă nu sunt locale, nici parametri formali. Vedeți imaginea pentru exemplificare:

object Practice { // temperature este data în Celsius
  val kelvin: BigDecimal = 273.15
  def calculateTemperature(temperature: BigDecimal): BigDecimal = temperature + kelvin

  def main(args: Array[String]): Unit = {
    println(calculateTemperature(32))
  }
}

Funcții aplicate parțial / Partially applied functions: Atunci când doar un subset din parametri este pasat într-o funcție, ea se numește, tehnic, partially applied function. Odată ce parametri inițiali sunt furnizați în funcția parțială, una nouă este returnată cu restul de argumente necesar a fi pasate. Considerați situația:

object Practice { 
// formulă simplă de calculare a dobânzii

  def calculateSimpleInterest(price: BigDecimal, 
    rate: BigDecimal, timeSpan: Int): BigDecimal =
    (price * rate * timeSpan) / 100

  val rateApplied: Int => BigDecimal =
    calculateSimpleInterest(1000, 0.2, _)

  val valueAdded: BigDecimal = rateApplied(2)

  def main(args: Array[String]): Unit = {
    println(valueAdded)
  }
}

Metoda calculateSimpleInterest are trei argumente — suma inițială, rata și perioada. Rata a fost setată la 0.2 pentru dobândă. Aici, pasăm primii doi parametri funcției și returnăm o nouă funcție valueAdded cu durata în timp ca parametru lipsă. Apoi, apelăm metoda rateApplied fără să ne intereseze durata, în fond, de doi ani.

Currying: când avem funcții cu mai multe argumente, putem să rearanjăm metoda într-un lanț de apeluri, fiecare cu un singur argument. Funcția returnată este pasată ca un nou argument și tot așa.

object Practice {
  val multiplication: (Int, Int) => Int = (x, y) => 
    x * y

  val curryingMultiplication: Int => Int => Int = x 
    => y => x * y

  val curryingMethod: Int => Int => 
    Int = multiplication.curried

  val multiplicationResult: Int = 
    multiplication(4, 9)

  val curriedMultiplicationResult: Int = 
    curryingMultiplication(4)(9)

  val curryingMethodResult: Int = 
    curryingMethod(4)(9)

  def main(args: Array[String]): Unit = {
    println(multiplicationResult)
    println(curriedMultiplicationResult)
    println(curryingMethodResult)
  }
}

Funcția are două argumente; o convertim într-una curried sau folosim metoda existentă curried. Rezultatul va fi același. Ca o bună practică, Scala construiește multiple liste de argumente, așa cum vedem pentru 4, 5 și 6 în diagrama de mai jos:

object Practice {
  def multiplication(x: Int, y: Int, z: Int): 
    Int = x * y * z

  def curriedMultiplication(x: Int)(y: Int)(z: Int): 
    Int = x * y * z

  val result: Int = multiplication(4, 5, 6)
  val curriedResult: Int = 
    curriedMultiplication(4)(5)(6)

  def main(args: Array[String]): Unit = {
    println(result)
    println(curriedResult)
  }
}

Tail recursion: Iterațiile se bazează pe valori mutabile, care-și schimbă starea. Putem evita oare acest lucru? Desigur. Scala permite scrierea de cod în format cât mai apropiat de Java, ba chiar putem scrie funcții recursive în format clasic, dar este de evitat, deoarece există o abordare eficientă privind mutabilitatea și utilizarea optimă a stivei de memorie. În termini tehnici, tail recursion refolosește același cadru / frame al stivei la fiecare apel recursiv. Dacă dorești să implementezi o funcție tail-recursive, nu uita să-i adaugi adnotarea @tailrec (de obicei, IDE-ul îți va arăta un warning) care va declanșa compilatorul să verifice codul și să analizeze dacă acesta este optimizat sau nu. Exemplul de aici este un demo de scriere a unei funcții tail-recursive pentru a determina dacă un număr este palindrom sau nu.

object NumberPalindrome {
  def firstAndRemainingDigits(n: Int): 
    Option[(Int, Int)] =
    if (n == 0) None else Some((n % 10, n / 10))

  def reverse(n: Int): Int = {
    @tailrec
    def go(remaining: Int, constructed: Int): Int =
     firstAndRemainingDigits(remaining) match {
      case Some((digit, remainingDigits)) => 
        go(remainingDigits, 10 * constructed + digit)
        case None => constructed
      }

    go(n, 0)
  }

  def palindrome(n: Int): Boolean = n == reverse(n)

  def main(args: Array[String]): Unit = {
    println(palindrome(12))
    println(palindrome(121))
    println(palindrome(123454321))
  }
}

Definim o metodă în interiorul alteia, un code pattern des întâlnit în Scala care ne ajută să evităm repetițiile. Adnotăm metoda "go" cu @tailrec, și facem ca ultimul apel din metoda "mamă" să fie cel recursiv.

Cuvânt de final

Mai avem multe concepte de parcurs, programarea funcțională oferă multe avantaje, însă le vom descoperi în articolele viitoare, așa că fiți pe fază. Până atunci, puteți să exersați conceptele Scala și recapitulați cunoștințele dobândite aici: https://www.scala-exercises.org/, nu înainte de a vă loga folosind contul de Github.

Alte resurse pe care le puteți citi:

  1. Baeldung - o mulțime de tutoriale despre Java, Spring Framework, Spring Security, dar și Scala. În opinia mea, unul dintre cele mai bune locuri online de unde poți învăța tehnologii JVM gratis;

  2. RockTheJvm - canal Youtube cu o multitudine de Scala tips and tricks. Cea mai tare parte este că autorul este român și unul dintre cei mai mari contribuitori ai comunității care oferă materiale de studia în amănunt;