ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 150
Numărul 149 Numărul 148 Numărul 147 Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 124
Abonament PDF

Scala și programarea funcțională

Andrei Muja
Scala Engineer @ Zenitech



PROGRAMARE

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;

NUMĂRUL 149 - Development with AI

Sponsori

  • Accenture
  • BT Code Crafters
  • Accesa
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • Colors in projects