Programación funcional en Scala: 

Las partes buenas y fáciles

¿De que se trata esta presentación?

¿De que se trata esta presentación?

  • Una breve introducción a qué es programación funcional

¿De que se trata esta presentación?

  • Una breve introducción a qué es programación funcional (aplicada en Scala)

¿De que se trata esta presentación?

  • Una breve introducción a qué es programación funcional (aplicada en Scala)
  • Nada de las partes mas teóricas de programación funcional

¿De que se trata esta presentación?

  • Una breve introducción a qué es programación funcional (aplicada en Scala)
  • Nada de las partes mas teóricas de programación funcional
  • Solamente los principios mas básicos

Inspirado en:

Functional Principles for Object-Oriented Development por Jessica Kerr

Los principios:

Los principios:

  • Data in => Data out

Los principios:

  • Data in => Data out
  • Estilo declarativo

Los principios:

  • Data in => Data out
  • "Los verbos también son personas"
  • Estilo declarativo

Los principios:

  • Data in => Data out
  • "Los verbos también son personas"
  • Inmutabilidad
  • Estilo declarativo

Los principios:

  • Data in => Data out
  • "Los verbos también son personas"
  • Inmutabilidad
  • Estilo declarativo
  • Tipado estático

Data in => Data out

Data in => Data out

Todas las funciones de un sistema (o su mayoría) deberían depender exclusivamente de sus parámetros de entrada y no deberían hacer otra cosa distinta a producir un resultado.

Es decir, una función no debería:

Es decir, una función no debería:

  • Acceder a estado global

Es decir, una función no debería:

  • Acceder a estado global
  • Mutar los parámetros de entrada u otras variables

Es decir, una función no debería:

  • Acceder a estado global
  • Mutar los parámetros de entrada u otras variables
  • Molestar el mundo externo, por ejemplo realizando operaciones de IO

¿Que ganamos con esto?

¿Que ganamos con esto?

  • Funciones fáciles de testear

¿Que ganamos con esto?

  • Funciones fáciles de testear
  • Funciones fáciles de entender

¿Que ganamos con esto?

  • Funciones fáciles de testear
  • Funciones fáciles de entender
  • Funciones fáciles de reutilizar y componer

¿Que ganamos con esto?

  • Funciones fáciles de testear
  • Funciones fáciles de entender
  • Funciones fáciles de reutilizar y componer
  • Funciones fácilmente paralelizables

¿A que apunta esto?

Un core puramente funcional (que consiste en funciones data in => data out) y una delgada capa exterior que se encargue de los efectos (metodos que ejecuten los side effects)

Una "arquitectura" funcional 

(Un paréntesis)

Data In => Data Out es una versión mas "suave" de lo que llaman Transparencia Referencial

(Un paréntesis)

Data In => Data Out es una versión mas "suave" de lo que llaman Transparencia Referencial

Una expresión e es referencialmente transparente si, para todos los programas p, todas las ocurrencias de e en p pueden ser reemplazadas por el resultado de evaluar e sin afectar el significado de p.

(Un paréntesis)

Data In => Data Out es una versión mas "suave" de lo que llaman Transparencia Referencial

Una expresión e es referencialmente transparente si, para todos los programas p, todas las ocurrencias de e en p pueden ser reemplazadas por el resultado de evaluar e sin afectar el significado de p.

Una función es pura si la expresión f(x) es referencialmente transparente para todos los x que son referencialmente transparentes

Un ejemplo de diseño funcional

¿Es ésta función data in => data out?

class Cafe {
	def buyCoffee(cc: CreditCard): Coffee = {
		val cup = new Coffee()
		cc.charge(cup.price) //Calls CC service & store charge locally
		cup
	}
}

Primer refactor:

class Cafe {
	def buyCoffee(cc: CreditCard, p: Payments): Coffee = {
		val cup = new Coffee()
		p.charge(cc, cup.price)
		cup
	}
}

Primer refactor:

class Cafe {
	def buyCoffee(cc: CreditCard, p: Payments): Coffee = {
		val cup = new Coffee()
		p.charge(cc, cup.price)
		cup
	}
}
  • ¿Es esta función reutilizable?

Primer refactor:

class Cafe {
	def buyCoffee(cc: CreditCard, p: Payments): Coffee = {
		val cup = new Coffee()
		p.charge(cc, cup.price)
		cup
	}
}
  • ¿Es esta función reutilizable?
  • ¿La puedo llamar tantas veces quiera sin preocuparme qué efectos pueda tener?

Primer refactor:

class Cafe {
	def buyCoffee(cc: CreditCard, p: Payments): Coffee = {
		val cup = new Coffee()
		p.charge(cc, cup.price)
		cup
	}
}
  • ¿Es esta función reutilizable?
  • ¿La puedo llamar tantas veces quiera sin preocuparme qué efectos pueda tener?
def buyCoffees(cc: CreditCard, p: Payments, n: Int): List[Coffee]
  • ¿Podría utilizarla para implementar la siguiente función?

Un refactor funcional:

Un refactor funcional:

Eliminar completamente los side effects

Un refactor funcional:

class Cafe {
	def buyCoffee(cc: CreditCard): (Coffee, Charge) = {
		val cup = new Coffee()
		(cup, Charge(cc, cup.price))
	}
}

case class Charge(cc: CreditCard, amount: Double) {
	def combine(other: Charge): Charge = {
		if ( cc == other.cc )
			Charge(cc, amount + other.amount )
		else
			throw new Exception("Can't combine charges to different cards")
	}
}

Eliminar completamente los side effects

¿Qué hemos ganado?

  • Podemos testear fácilmente, sin tener que usar mocks (todavía)

¿Qué hemos ganado?

class Cafe {
	def buyCoffee(cc: CreditCard): (Coffee, Charge) = ...
	def buyCoffees(cc: CreditCard, n: Int): (List[Coffee], Charge) = {
		val purchases : List[(Coffee, Charge)] = List.fill(n)(buyCoffee(cc))
		val (coffees, charges) = purchases.unzip
		(coffees, purchases.reduce( (c1,c2) => c1.combine(c2) ) )
	}
}
  • Podemos testear fácilmente, sin tener que usar mocks (todavía)
  • Podemos reutilizar la función libremente, por ejemplo:

¿Qué hemos ganado?

  • Hacer que el cargo (Charge) sea un valor nos permite ensamblar lógica de negocio con él:
def merge(charges: List[Charge]): List[Charge] = {
    val grouped: Map[CreditCard, List[Charge]] = charges.groupBy(_.cc)
    val values: List[List[Charge]] = grouped.values
    values.map( charges => charges.reduce( (a,b) => a.combine(b) ).toList
}

¿Qué hemos ganado?

  • Hacer que el cargo (Charge) sea un valor nos permite ensamblar lógica de negocio con él:
def merge(charges: List[Charge]): List[Charge] = {
    val grouped: Map[CreditCard, List[Charge]] = charges.groupBy(_.cc)
    val values: List[List[Charge]] = grouped.values
    values.map( charges => charges.reduce( (a,b) => a.combine(b) ).toList
}
  • Al final, en los "bordes" de nuestro sistema podríamos tener una única función que ejecute los side effects:
def executeCharge(charge: Charge): Unit = {
    ...
}

Estilo declarativo

Estilo declarativo

Decir qué queremos obtener, pero no cómo queremos obtenerlo

(...) programs must be written for people to read, and only incidentally for machines to execute 

- Structure and interpretation of computer programs

Ejemplo

public List<String> encontrarReportesDeBugs(List<String> lines) {
	List<String> resultado = new ArrayList<String>();
	for(String line: lines) {
		if(line.startsWith("BUG"))  {//<- Esto es lo único importante!
			resultado.add(line)
		}
	}
	return resultado;
}

Ejemplo

def encontrarReportesDeBugs(lines: List[String]): List[String] = {
	lines.filter(line => line.startsWith("BUG"))
}

"Los verbos también son personas"

Poder pasar verbos y no solamente sujetos

Computar la desviación estándar, paso a paso

Calcular el promedio, usando funciones de orden superior

Reduce es una función de alto nivel que funciona sobre iterables para combinar sus valores en un único valor:

class List[A] {
    ...
    def reduce[A](f: (A,A) => A): A = ...
}

Computar la desviación estándar, paso a paso

def add(a: Double, b: Double): Double = a + b

def sum(xs: List[Double]): Double = xs.reduce(add)

def mean(xs: List[Double]): Double = sum(xs) / xs.length

Calcular el promedio, usando funciones de orden superior

Computar la desviación estándar, paso a paso

Computar las desviaciones:

class List[A] {
    ...
    def map[B](f: A => B): List[B] = ...
}

Map es una función de orden superior sobre iterables que devuelve otro iterable con el resultado de aplicar la función a cada elemento del iterable:

def squaredDesviations(xs: List[Double]): List[Double] = {
    val _mean = mean(xs)
    xs.map { x => square(x - _mean) }
}

Computar la desviación estándar, paso a paso

Computar las desviaciones:

Computar la desviación estándar, paso a paso

  • La desviación estándar es el resultado de :
    • Calcular las desviaciones al cuadrado de cada elemento
    • Calcular el promedio de ese valor
    • Calcular la raiz cuadrada de ese valor

Computar la desviación estándar, paso a paso

  • La desviación estándar es el resultado de :
    • Calcular las desviaciones al cuadrado de cada elemento
    • Calcular el promedio de ese valor
    • Calcular la raiz cuadrada de ese valor
def std(xs: List[Double]): Double = Math.sqrt( mean( squaredDesviations(xs) ) )

Computar la desviación estándar, paso a paso

  • La desviación estándar es el resultado de :
    • Calcular las desviaciones al cuadrado de cada elemento
    • Calcular el promedio de ese valor
    • Calcular la raiz cuadrada de ese valor
def std = (squaredDesviations _) andThen (mean _) andThen (Math.sqrt _)
def std(xs: List[Double]): Double = Math.sqrt( mean( squaredDesviations(xs) ) )
def square(x: Double): Double = x * x

def add(a: Double, b: Double): Double = a + b

def sum(xs: List[Double]): Double = xs.reduce(add)

def mean(xs: List[Double]): Double = sum(xs) / xs.length

def squaredDesviations(xs: List[Double]): List[Double] = {
    val _mean = mean(xs)
    xs.map { x => square(x - _mean) }
}

def std = (squaredDesviations _) andThen (mean _) andThen (Math.sqrt _)

Computar la desviación estándar, paso a paso

Juntandolo todo:

Inmutabilidad

¿La inmutabilidad no es un paso hacia atrás?

¿La inmutabilidad no es un paso hacia atrás?

  • ¿No es una limitación a lo que nos permite hacer el lenguaje?

¿La inmutabilidad no es un paso hacia atrás?

  • ¿No es una limitación a lo que nos permite hacer el lenguaje?
  • ¿La mutabilidad no permite una mayor expresibilidad?

¿Qué ventajas se tienen al diseñar datos inmutables?

¿Qué ventajas se tienen al diseñar datos inmutables?

  • Menor el estado -> Menor la cantidad de cosas que uno debe tener en la cabeza

¿Qué ventajas se tienen al diseñar datos inmutables?

  • Menor el estado -> Menor la cantidad de cosas que uno debe tener en la cabeza
  • Las modificaciones concurrentes no son un problema por definición

¿Qué ventajas se tienen al diseñar datos inmutables?

  • Menor el estado -> Menor la cantidad de cosas que uno debe tener en la cabeza
  • Las modificaciones concurrentes no son un problema por definición
  • Mutabilidad por defecto == Complejidad innecesaria

Modelando estado inmutablemente

  • Un juego puede estar en uno de tres estados:
    • Activo
    • Un jugador ganó
    • Empate

 

sealed trait Game {
  def board: Board
  def isDraw: Boolean
  def hasWinner: Boolean
  def isFinished: Boolean = isDraw || hasWinner
}

Una definición general de un juego:

Un juego empatado

case class DrawGame(board: Board) extends Game {
  def isDraw: Boolean = true
  def hasWinner: Boolean = false
}

Un juego ganado

case class WonGame(board: Board, winner: Winner) extends Game {
  def isDraw: Boolean = false
  def hasWinner: Boolean = true
}

Un juego activo

case class ActiveGame(board: Board, currentPlayer: Player) extends Game {

  def isDraw: Boolean = false
  def hasWinner: Boolean = false
  def otherPlayer: Player = if(currentPlayer == PlayerX) PlayerO else PlayerX

  def putMark(mark: Player, position: Position): Game = {
    if(currentPlayer != mark) {
      throw IncorrectPlayerError
    } else {
      val nextBoard = board.putMark(mark, position)
      nextBoard.winner match {
        case Some(winner) => WonGame(nextBoard, winner)
        case None =>
          if(nextBoard.somebodyCanWin) {
            ActiveGame(nextBoard, otherPlayer)
          } else {
            DrawGame(nextBoard)
          }
      }
    }
  }

}

¿Que hemos ganado?

¿Que hemos ganado?

  • Modelamos el dominio como una maquina de estados

¿Que hemos ganado?

  • Modelamos el dominio como una maquina de estados
  • Evitamos que los usuarios intenten hacer llamadas que no tienen sentido:
    • Pedir el ganador de un juego que todavía está activo
    • Intentar hacer una jugada sobre un juego que ya acabó

¿Que hemos ganado?

  • Modelamos el dominio como una maquina de estados
  • Evitamos que los usuarios intenten hacer llamadas que no tienen sentido:
    • Pedir el ganador de un juego que todavía está activo
    • Intentar hacer una jugada sobre un juego que ya acabó

¡Nada de esto sería posible si hubieramos modelado el estado permitiendo mutaciones sobre un objeto de la misma clase!

Tipado estático

Tipado estático

Los tipos no tienen por que limitarnos.

Tipado estático

Los tipos no tienen por que limitarnos.

Pueden servir para expresarnos mejor:

Tipado estático

Los tipos no tienen por que limitarnos.

Pueden servir para expresarnos mejor:

  • Para transmitir mejor nuestras intenciones

Tipado estático

Los tipos no tienen por que limitarnos.

Pueden servir para expresarnos mejor:

  • Para transmitir mejor nuestras intenciones
  • Para evitar que cometamos errores

Queremos una función que nos devuelva un usuario según su nombre de usuario.

Una posible firma de la función sería:

String => User

¿Qué pasa si no se encuentra el usuario?

Algo mejor

Utilizar un tipo más específico

Username => Option[User]

¡En la firma de la función se dice mucho!

Dice que se espera recibir y qué puede pasar al ejecutar la función

Otro ejemplo

¿Como representar unidades?

Otro ejemplo

¿Como representar unidades?

  • Envolver un Double:
case class Meter(value: Double)

Otro ejemplo

¿Como representar unidades?

  • Envolver un Double:
  • Pero no queremos incurrir en un costo en tiempo de ejecución
case class Meter(value: Double)

Otro ejemplo

¿Como representar unidades?

  • Envolver un Double:
  • Pero no queremos incurrir en un costo en tiempo de ejecución
case class Meter(value: Double)
  • Scala permite envolver un tipo cualquiera dentro de otro sin costo alguno:
case class Meter(value: Double) extends AnyVal {
    def +(other: Meter): Meter = Meter(value + other.value)
    def toInches: Inch = Inch(39.3700787 * value)
}

¿Que hemos ganado?

¿Que hemos ganado?

  • No podemos combinar peras con manzanas:
val x = Meter(1.5)
val y = Meter(2.0)
val z = Inch(34.0)

x + y // <- OK
x + z // <- No compila!
x.toInches + z //<- OK

¿Que hemos ganado?

  • No podemos combinar peras con manzanas:
val x = Meter(1.5)
val y = Meter(2.0)
val z = Inch(34.0)

x + y // <- OK
x + z // <- No compila!
x.toInches + z //<- OK
  • Los métodos que reciben o devuelven objetos de ese tipo se están documentando en sus firmas:
def getSideLengths(figure: Figure): List[Meter] = ...
def getPerimeterLength(lengths: List[Meter]): Meter = ...

Un ejemplo mas complejo

¡Vamos a cifrar, descifrar mensajes y enviarlos!

Un ejemplo mas complejo

def encrypt(msg: Array[Byte]): Array[Byte] = ...

def decrypt(msg: Array[Byte]): Array[Byte] = ...

def send(msg: Array[Byte]) = ...

Una primera aproximación:

¡Vamos a cifrar, descifrar mensajes y enviarlos!

Una mejor forma

trait Encrypted
trait PlainText

case class Message[T](bytes: Array[Byte]) extends AnyVal {
    ...
}

def encrypt(msg: Message[PlainText]): Message[Encrypted] = ...

def decrypt(msg: Message[Encrypted]): Message[Decrypted] = ...

def send(msg: Message[Encrypted]) = ...

Una mejor forma

trait Encrypted
trait PlainText

case class Message[T](bytes: Array[Byte]) extends AnyVal {
    ...
}

def encrypt(msg: Message[PlainText]): Message[Encrypted] = ...

def decrypt(msg: Message[Encrypted]): Message[Decrypted] = ...

def send(msg: Message[Encrypted]) = ...

¿Que pasa si hacemos que la única forma de construir valores del tipo Message[Encrypted] sea a través de la función encrypt?

Una mejor forma

trait Encrypted
trait PlainText

case class Message[T](bytes: Array[Byte]) extends AnyVal {
    ...
}

def encrypt(msg: Message[PlainText]): Message[Encrypted] = ...

def decrypt(msg: Message[Encrypted]): Message[Decrypted] = ...

def send(msg: Message[Encrypted]) = ...

¿Que pasa si hacemos que la única forma de construir valores del tipo Message[Encrypted] sea a través de la función encrypt?

¡Entonces la única forma de llamar la función send será cifrando mensajes!

Advertencia

Usar el sentido común

Advertencia

Usar el sentido común

  • Tener en cuenta los trade-offs
    • Podemos expresar mucho usando tipos pero también podemos complicarnos

Advertencia

Usar el sentido común

  • Tener en cuenta los trade-offs
    • Podemos expresar mucho usando tipos pero también podemos complicarnos
  • El tipo de una función no necesariamente tiene que tener en cuenta todas las posibilidades de ejecución

Advertencia

Usar el sentido común

  • Tener en cuenta los trade-offs
    • Podemos expresar mucho usando tipos pero también podemos complicarnos
  • El tipo de una función no necesariamente tiene que tener en cuenta todas las posibilidades de ejecución
    • Mala idea: Future[Either[Error,Option[User]]]

Advertencia

Usar el sentido común

  • Tener en cuenta los trade-offs
    • Podemos expresar mucho usando tipos pero también podemos complicarnos
  • El tipo de una función no necesariamente tiene que tener en cuenta todas las posibilidades de ejecución
    • Mala idea: Future[Either[Error,Option[User]]]
    • Si el camino ideal de nuestro código es que el usuario exista entonces Future[User] puede bastar.

¡Fin!

¿Que viene después de los principios básicos?

  • Tipos de orden superior
  • "Patrones" de programación funcional mas avanzados (¡pero no tan difíciles!):
    • Monoides
    • Monadas
    • Functores
    • Aplicativos
    • Transformadores de monadas
    • ...

Referencias

Programación funcional en Scala: Las partes buenas y fáciles

By Miguel Vilá

Programación funcional en Scala: Las partes buenas y fáciles

  • 1,784