6. Tipos de datos de Scalaz
¿Quién no ama una buena estructura de datos? La respuesta es nadie, porque las estructuras de datos son increíbles.
En este capítulo exploraremos los tipos de datos en Scalaz que son similares a colecciones, así como los tipos de datos que aumentan Scala, como lenguaje, con semántica y seguridad de tipos adicional.
La razón principal por la que nos preocupamos por tener muchas colecciones a nuestra disposición es por rendimiento. Un vector y una lista pueden hacer lo mismo, pero sus características son diferentes: un vector tiene un costo de búsqueda constante mientras que una lista debe ser recorrida.
Todas las colecciones presentadas aquí son persistentes: si agregamos o removemos un elemento todavía podemos usar la versión anterior. Compartir estructuralmente es esencial para el rendimiento de las estructuras de datos persistentes, de otra manera la colección entera se reconstruye con cada operación.
A diferencia de las colecciones de Java y Scala, no hay jerarquía de datos en Scalaz: estas colecciones son mucho más simples de entender. La funcionalidad polimórfica se proporciona por instancias opmitizadas de typeclases que estudiamos en el capítulo anterior. Esto simplifica cambiar implementaciones por razones de rendimiento, y proporcionar la propia.
6.1 Variancia de tipo
Muchos de los tipos de datos de Scalaz son invariantes en sus parámetros de tipo.
Por ejemplo, IList[A] no es un subtipo de IList[B] cuando A <: B.
6.1.1 Covariancia
El problema con los tipos de parámetro covariantes, tales como class List[+A], es
que List[A] es un subtipo de List[Any] y es fácil perder accidentalmente información
de tipo.
scala> List("hello") ++ List(' ') ++ List("world!")
res: List[Any] = List(hello, , world!)
Note que la segunda lista es una List[Char] y que el compilador ha inferido
incorrectamente el LUB (Least Upper Bound, o límite mínimo inferior) es Any.
Compare con IList, que requiere una aplicación explícita de .widen[Any] para
ejecutar el crimen atroz:
scala> IList("hello") ++ IList(' ') ++ IList("world!")
<console>:35: error: type mismatch;
found : Char(' ')
required: String
scala> IList("hello").widen[Any]
++ IList(' ').widen[Any]
++ IList("world!").widen[Any]
res: IList[Any] = [hello, ,world!]
De manera similar, cuando el compilador infiere el tipo como
with Product with Serializable es un indicador fuerte de que un
ensanchamiento accidental ha ocurrido debido a la covariancia.
Desafortunadamente debemos ser cuidadosos cuando construimos tipos de datos invariantes debido a que los cálculos LUB se realizan sobre los parámetros:
scala> IList("hello", ' ', "world")
res: IList[Any] = [hello, ,world]
Otro problema similar surge a partir del tipo Nothing, el cuál es un subtipo del resto
de tipos, incluyendo ADTs selladas (sealed), clases finales (final), primitivas y
null.
No hay valores de tipo Nothing: las funciones que toman un Nothing como un parámetro
no pueden ejecutarse y las funciones que devuelven Nothing nunca devuelven un valor
(o terminan). Nothing fue introducido como un mecanismo para habilitar los tipos de
parámetros covariantes, pero una consecuencia es que podemos escribir código que no puede
ejecutarse, por accidente. Scalaz dice que no necesitamos parámetros de tipo covariantes
lo que significa que nos estamos limitando a nosotros mismos a escribir código práctico
que puede ser ejecutado.
6.1.2 Contrarivarianza
Por otro lado, los tipos de parámetro contravariantes, tales como trait Thing[-A]
pueden poner de manifiesto
errores devastadores en el compilador.
Considere la demostración de Paul Phillips’ (ex miembro del equipo del compilador
scalac) de lo que él llama contrarivarianza:
scala> :paste
trait Thing[-A]
def f(x: Thing[ Seq[Int]]): Byte = 1
def f(x: Thing[List[Int]]): Short = 2
scala> f(new Thing[ Seq[Int]] { })
f(new Thing[List[Int]] { })
res = 1
res = 2
Como era de esperarse, el compilador está encontrando el argumento más específico
en cada invocación de f. Sin embargo, la resolución implícita da resultados
inesperados:
scala> :paste
implicit val t1: Thing[ Seq[Int]] =
new Thing[ Seq[Int]] { override def toString = "1" }
implicit val t2: Thing[List[Int]] =
new Thing[List[Int]] { override def toString = "2" }
scala> implicitly[Thing[ Seq[Int]]]
implicitly[Thing[List[Int]]]
res = 1
res = 1
La resolución implícita hace un intercambio de su definición de “más específico” para los tipos contravariantes, logrando que sean inútiles para los typeclases o para cualquier cosa que requiera funcionalidad polimórfica. Este comportamiento es fijo en Dotty.
6.1.3 Las limitaciones del mecanismo de subclases
scala.Option tiene un método .flatten que los dejará convertir Option[Option[B]]
en un Option[B]. Sin embargo, el sistema de tipos de Scala es incapaz de dejarnos
escribir la signatura de tipos requerida. Considere lo siguiente que parece correcto,
pero que tiene un error sutil:
sealed abstract class Option[+A] {
def flatten[B, A <: Option[B]]: Option[B] = ...
}
The A introducida en .flatten está ocultando la A introducida por la clase.
Es equivalente a escribir
sealed abstract class Option[+A] {
def flatten[B, C <: Option[B]]: Option[B] = ...
}
que no es la restricción deseada.
Para resolver esta limitación, Scala define clases infijas <:< y =:= junto
con la evidencia implícita que siempre crea un testigo (witness).
sealed abstract class <:<[-From, +To] extends (From => To)
implicit def conforms[A]: A <:< A = new <:<[A, A] { def apply(x: A): A = x }
sealed abstract class =:=[ From, To] extends (From => To)
implicit def tpEquals[A]: A =:= A = new =:=[A, A] { def apply(x: A): A = x }
=:= puede usarse para requerir que dos parámetros de tipo sean exactamente iguales y
<:< se usa para describir relaciones de subtipo, dejandonos implementar .flatten como
sealed abstract class Option[+A] {
def flatten[B](implicit ev: A <:< Option[B]): Option[B] = this match {
case None => None
case Some(value) => ev(value)
}
}
final case class Some[+A](value: A) extends Option[A]
case object None extends Option[Nothing]
Scalaz mejora sobre <:< y =:= con Liskov (que tiene el alias <~<) y
Leibniz (====).
sealed abstract class Liskov[-A, +B] {
def apply(a: A): B = ...
def subst[F[-_]](p: F[B]): F[A]
def andThen[C](that: Liskov[B, C]): Liskov[A, C] = ...
def onF[X](fa: X => A): X => B = ...
...
}
object Liskov {
type <~<[-A, +B] = Liskov[A, B]
type >~>[+B, -A] = Liskov[A, B]
implicit def refl[A]: (A <~< A) = ...
implicit def isa[A, B >: A]: A <~< B = ...
implicit def witness[A, B](lt: A <~< B): A => B = ...
...
}
// type signatures have been simplified
sealed abstract class Leibniz[A, B] {
def apply(a: A): B = ...
def subst[F[_]](p: F[A]): F[B]
def flip: Leibniz[B, A] = ...
def andThen[C](that: Leibniz[B, C]): Leibniz[A, C] = ...
def onF[X](fa: X => A): X => B = ...
...
}
object Leibniz {
type ===[A, B] = Leibniz[A, B]
implicit def refl[A]: Leibniz[A, A] = ...
implicit def subst[A, B](a: A)(implicit f: A === B): B = ...
implicit def witness[A, B](f: A === B): A => B = ...
...
}
Además de los métodos generalmente útiles y las conversiones implícitas,
la evidencia de Scalaz <~< y === usa mejores principios que en la librería estándar.
6.2 Evaluación
Java es un lenguaje con evaluación estricta: todos los parámetros para un método
deben evaluarse a un valor antes de llamar el método. Scala introduce la noción de
parámetros por nombre (by-name) en los métodos con la sintaxis a: => A. Estos
parámetros se envuelven en una finción de cero argumentos que se invoca cada vez que
se referencia la a. Hemos visto por nombre muchas veces en las typeclases.
Scala también tiene por necesidad (by-need), con la palabra lazy: el cómputo se
evalúa a lo sumo una vez para producir el valor. Desgraciadamente, Scala no soporta
la evaluación por necesidad de los parámetros del método.
Scalaz formaliza los tres estrategias de evaluación con un ADT
sealed abstract class Name[A] {
def value: A
}
object Name {
def apply[A](a: =>A) = new Name[A] { def value = a }
...
}
sealed abstract class Need[A] extends Name[A]
object Need {
def apply[A](a: =>A): Need[A] = new Need[A] {
private lazy val value0: A = a
def value = value0
}
...
}
final case class Value[A](value: A) extends Need[A]
La forma de evaluación más débil es Name, que no proporciona garantías
computacionales. A continuación está Need, que garantiza la evaluación
como máximo una vez.
Si deseamos ser super escrupulosos podríamos revisar todas las typeclases y hacer
sus métodos tomar parámetros por Name, Need o Value. En vez de esto, podemos
asumir que los parámetros normales siempre pueden envolverse en un Value, y que
los parámetros (by-name) por nombre pueden envolverse con Name.
Cuando escribimos programas puros, somos libres de reemplazar cualquier Name con
Need o Value, y viceversa, sin necesidad de cambiar lo correcto del programa.
Esta es la esencia de la transparencia referencial: la habilidad de cambiar un
cómputo por su valor, o un valor por su respectivo cómputo.
En la programación funcional casi siempre deseamos Value o Need (también conocido
como estricto y perezoso): hay poco valor en Name. Debido a que no hay soporte
a nivel de lenguaje para parámetros de método perezosos, los métodos típicamente
solicitan un parámetro por nombre y entonces lo convierten a Need de manera
interna, proporcionando un aumento en el rendimiento.
Name proporciona instancias de las siguientes typeclases
MonadComonadTraverse1Align-
Zip/Unzip/Cozip
6.3 Memoisation
Scalaz tiene la capacidad de memoizar funciones, formalizada por Memo, que no
hace ninguna garantía sobre la evaluación debido a la diversidad de implementaciones:
sealed abstract class Memo[K, V] {
def apply(z: K => V): K => V
}
object Memo {
def memo[K, V](f: (K => V) => K => V): Memo[K, V]
def nilMemo[K, V]: Memo[K, V] = memo[K, V](identity)
def arrayMemo[V >: Null : ClassTag](n: Int): Memo[Int, V] = ...
def doubleArrayMemo(n: Int, sentinel: Double = 0.0): Memo[Int, Double] = ...
def immutableHashMapMemo[K, V]: Memo[K, V] = ...
def immutableTreeMapMemo[K: scala.Ordering, V]: Memo[K, V] = ...
}
memo nos permite crear implementaciones de Memo, nilMemo no memoiza,
evaluando la función normalmente. Las implementaciones resultantes interceptan
llamadas a la función y guardan en cache los resultados obtenidos en
una implementación que usa colecciones de la librería estándar.
Para usar Memo simplemente envolvemos una función con una implementación de
Memo y entonces invocamos a la función memoizada:
scala> def foo(n: Int): String = {
println("running")
if (n > 10) "wibble" else "wobble"
}
scala> val mem = Memo.arrayMemo[String](100)
val mfoo = mem(foo)
scala> mfoo(1)
running // evaluated
res: String = wobble
scala> mfoo(1)
res: String = wobble // memoised
Si la función toma más de un parámetro, entonces debemos invocar .tupled
sobre el método, con la versión memoisada tomando una tupla.
scala> def bar(n: Int, m: Int): String = "hello"
val mem = Memo.immutableHashMapMemo[(Int, Int), String]
val mbar = mem((bar _).tupled)
scala> mbar((1, 2))
res: String = "hello"
Memo típicamente se trata como un artificio especial y la regla usual sobre
la pureza se relaja para las implementaciones. La pureza únicamente requiere
que nuestras implementaciones de Memo sean referencialmente transparentes en
la evaluación de K => V. Podríamos usar datos mutables y realizar I/O en la
implementación de Memo, por ejemplo usando un LRU o un caché distribuido, sin
tener que declarar un efecto en las signaturas de tipo. Otros lenguajes de
programación funcional tienen memoización manejada por el ambiente de runtime y
Memo es nuestra manera de extender la JVM para tener un soporte similar, aunque
desgraciadamente únicamente de una manera que requiere opt-in.
6.4 Tagging (etiquetar)
En la sección que se introdujo Monoid construimos un Monoid[TradeTeamplate] y
nos dimos cuenta de que Scalaz no hace lo que desearíamos con Monoid[Option[A]].
Este no es una omisión de Scalaz, con frecuencia podemos notar que un tipo de datos
puede implementar una tipeclass fundamental en múltiples formas válidas y que la
implementación por default no hace lo que deseariamos, o simplemente no está
definida.
Los ejemplos básicos son Monoid[Boolean] (la conjunción && vs la disjunción ||)
y Monoid[Int] (multiplicación vs adición).
Para implementar Monoid[TradeTemplate] tuvimos que romper la coherencia de typeclases, o usar una typeclass distinta.
scalaz.Tag está diseñada para lidiar con el problema de coherencia que surge con múltiples implementaciones de typeclases, sin romper la coherencia de las
typeclases.
La definición es algo compleja, pero la sintáxis al usar scalaz.Tag es bastante
limpia. Así es como logramos convencer al compilador para que nos permita definir
el tipo infijo A @@ T que es borrado en A en tiempo de ejecución:
type @@[A, T] = Tag.k.@@[A, T]
object Tag {
@inline val k: TagKind = IdTagKind
@inline def apply[A, T](a: A): A @@ T = k(a)
...
final class TagOf[T] private[Tag]() { ... }
def of[T]: TagOf[T] = new TagOf[T]
}
sealed abstract class TagKind {
type @@[A, T]
def apply[A, T](a: A): A @@ T
...
}
private[scalaz] object IdTagKind extends TagKind {
type @@[A, T] = A
@inline override def apply[A, T](a: A): A = a
...
}
Algunas etiquetas/tags útiles se proporcionan en el objeto Tags
object Tags {
sealed trait First
val First = Tag.of[First]
sealed trait Last
val Last = Tag.of[Last]
sealed trait Multiplication
val Multiplication = Tag.of[Multiplication]
sealed trait Disjunction
val Disjunction = Tag.of[Disjunction]
sealed trait Conjunction
val Conjunction = Tag.of[Conjunction]
...
}
First/Last se usan para seleccionar instancias d Monoid que escogen el primero
o el último (diferente de cero) operando. Multiplication es para multiplicación
numérica en vez de adición. Disjunction/Conjuntion son para seleccionar && o
||, respectivamente.
En nuestro TradeTemplate, en lugar de usar Option[Currency] podríamos usar
Option[Currency] @@ Tags.Last. En verdad, este uso es tan común que podemos usar
el alias previamente definido, LastOption.
type LastOption[A] = Option[A] @@ Tags.Last
y esto nos permite escribir una implementación mucho más limpia de
Monoid[TradeTemplate]
final case class TradeTemplate(
payments: List[java.time.LocalDate],
ccy: LastOption[Currency],
otc: LastOption[Boolean]
)
object TradeTemplate {
implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
(a, b) =>
TradeTemplate(a.payments |+| b.payments,
a.ccy |+| b.ccy,
a.otc |+| b.otc),
TradeTemplate(Nil, Tag(None), Tag(None))
)
}
Para crear un valor de tipo LastOption, aplicamos Tag a un Option. Aquí
estamos invocando Tag(None).
En el capítulo sobre derivación de typeclases, iremos un paso más allá y derivaremos
automáticamente el monoid.
Es tentador usar Tag para marcar tipos de datos para alguna forma de validación
(por ejemplo, String @@ PersonName), pero deberíamos evitar esto porque no existe
un chequeo del contenido en tiempo de ejecución. Tag únicamente debería ser usado
para propósitos de selección de typeclases. Prefiera el uso de la librería Refined,
que se introdujo en el Capítulo 4, para restringir valores.
6.5 Transformaciones naturales
Una función de un tipo a otro se escribe en Scala como A => B, y se trata de una
conveniencia sintáctica para Function1[A, B]. Scalaz proporciona una conveniencia
sintáctica F ~> G para funciones sobre los constructores de tipo F[_] a G[_].
Esta notación, F ~ G, se conoce como transformación natural y son universalmente
cuantificables debido a que no nos importa el contenido de F[_].
type ~>[-F[_], +G[_]] = NaturalTransformation[F, G]
trait NaturalTransformation[-F[_], +G[_]] {
def apply[A](fa: F[A]): G[A]
def compose[E[_]](f: E ~> F): E ~> G = ...
def andThen[H[_]](f: G ~> H): F ~> H = ...
}
Un ejemplo de una transformación natural es la función que convierte una IList
en una List.
scala> val convert = new (IList ~> List) {
def apply[A](fa: IList[A]): List[A] = fa.toList
}
scala> convert(IList(1, 2, 3))
res: List[Int] = List(1, 2, 3)
O, de manera más concisa, usando las conveniencias sintácticas proporcionadas por
el plugin kind-projector:
scala> val convert = λ[IList ~> List](_.toList)
scala> val convert = Lambda[IList ~> List](_.toList)
Sin embargo, en el desarrollo del día a día, es mucho más probable que usemos una
transformación natural para mapear entre álgebras. Por ejemplo, en
drone-dynamic-agents tal vez deseemos implementar nuestra álgebra de Machines
que usa Google Container Engine con una álgebra ad-hoc, BigMachines. En lugar de
cambiar toda nuestra lógica de negocio y probar usando esta nueva interfaz
BigMachines, podríamos escribir una transformación natural de
Machines ~> BigMachines. Volveremos a esta idea en el capítulo de Mónadas Avanzadas.
6.6 Isomorphism (isomorfismos)
Algunas veces tenemos dos tipos que en realidad son equivalentes (la misma cosa), lo que ocasiona problemas de compatibiliad debido a que el compilador no sabe lo que nosotros sí sabemos. Esto pasa típicamente cuando usamos código de terceros que tiene algo que ya tenemos.
Ahí es cuando Isomorphism nos puede ayudar. Un isomorfismo define una relación
formal “es equivalente a” entre dos tipos. Existen tres variantes, para
object Isomorphism {
trait Iso[Arr[_, _], A, B] {
def to: Arr[A, B]
def from: Arr[B, A]
}
type IsoSet[A, B] = Iso[Function1, A, B]
type <=>[A, B] = IsoSet[A, B]
object IsoSet {
def apply[A, B](to: A => B, from: B => A): A <=> B = ...
}
trait Iso2[Arr[_[_], _[_]], F[_], G[_]] {
def to: Arr[F, G]
def from: Arr[G, F]
}
type IsoFunctor[F[_], G[_]] = Iso2[NaturalTransformation, F, G]
type <~>[F[_], G[_]] = IsoFunctor[F, G]
object IsoFunctor {
def apply[F[_], G[_]](to: F ~> G, from: G ~> F): F <~> G = ...
}
trait Iso3[Arr[_[_, _], _[_, _]], F[_, _], G[_, _]] {
def to: Arr[F, G]
def from: Arr[G, F]
}
type IsoBifunctor[F[_, _], G[_, _]] = Iso3[~~>, F, G]
type <~~>[F[_, _], G[_, _]] = IsoBifunctor[F, G]
...
}
Los aliases de tipos IsoSet, IsoFunctor e IsoBifunctor cubren los casos
comunes: una función regular, una transformación natural y binatural. Las funciones
de conveniencia nos permiten generar instancias de funciones existentes o
transformaciones naturales. Sin embargo, con frecuencia es más fácil usar una de
las clases abstractas Template para definir un isomorfismo. Por ejemplo:
val listIListIso: List <~> IList =
new IsoFunctorTemplate[List, IList] {
def to[A](fa: List[A]) = fromList(fa)
def from[A](fa: IList[A]) = fa.toList
}
Si introducimos un isomorfismo, con frecuencia podemos generar muchas de las typeclases estándar. Por ejemplo,
trait IsomorphismSemigroup[F, G] extends Semigroup[F] {
implicit def G: Semigroup[G]
def iso: F <=> G
def append(f1: F, f2: =>F): F = iso.from(G.append(iso.to(f1), iso.to(f2)))
}
lo que nos permite derivar un Semigroup[F] para un tipo F si ya tenemos un
isomorfismo F <=> G y un semigrupo Semigroup[G]. Casi todas las typeclases
en la jerarquía proporcionan una invariante isomórfica. Si nos encontramos en la
situación en la que copiamos y pegamos una implementación de una typeclass,
es útil considerar si Isomorphism es una mejor solución.
6.7 Contenedores
6.7.1 Maybe
Ya nos hemos encontrado con la mejora de Scalaz sobre scala.Option, que se llama
Maybe. Es una mejora porque es invariante y no tiene ningún método inseguro como
Option.get, el cual puede lanzar una excepción.
Con frecuencia se usa para representar una cosa que puede o no estar presente sin proporcionar ninguna información adicional de porqué falta información.
sealed abstract class Maybe[A] { ... }
object Maybe {
final case class Empty[A]() extends Maybe[A]
final case class Just[A](a: A) extends Maybe[A]
def empty[A]: Maybe[A] = Empty()
def just[A](a: A): Maybe[A] = Just(a)
def fromOption[A](oa: Option[A]): Maybe[A] = ...
def fromNullable[A](a: A): Maybe[A] = if (null == a) empty else just(a)
...
}
Los métodos .empty y .just en el objeto compañero son preferidas en lugar de
usar Empty o Just debido a que regresan Maybe, ayudándonos con la inferencia
de tipos. Este patrón con frecuencia es preferido a regresar un tipo suma, que
es cuando tenemos múltiples implementaciones de un sealed trait pero nunca usamos
us subtipo específico en una signatura de método.
UNa clase implícita conveniente nos permite invocar.just sobre cualquier valor y
recibir un Maybe.
implicit class MaybeOps[A](self: A) {
def just: Maybe[A] = Maybe.just(self)
}
Maybe tiene una instancia de typeclass para todas las cosas
AlignTraverse-
MonadPlus/IsEmpty Cobind-
Cozip/Zip/Unzip Optional
e instancias delegadas que dependen de A
-
Monoid/Band -
Equal/Order/Show
Además de las instancias arriba mencionadas, Maybe tiene funcionalidad que no es
soportada por una typeclass polimórfica.
sealed abstract class Maybe[A] {
def cata[B](f: A => B, b: =>B): B = this match {
case Just(a) => f(a)
case Empty() => b
}
def |(a: =>A): A = cata(identity, a)
def toLeft[B](b: =>B): A \/ B = cata(\/.left, \/-(b))
def toRight[B](b: =>B): B \/ A = cata(\/.right, -\/(b))
def <\/[B](b: =>B): A \/ B = toLeft(b)
def \/>[B](b: =>B): B \/ A = toRight(b)
def orZero(implicit A: Monoid[A]): A = getOrElse(A.zero)
def orEmpty[F[_]: Applicative: PlusEmpty]: F[A] =
cata(Applicative[F].point(_), PlusEmpty[F].empty)
...
}
.cata proporciona una alternativa más simple para .map(f).gerOrElse(b) y tiene
la forma más simple | si el mapeo es identity (es decir, simplemente
.getOrElse).
.toLeft y .toRight, y sus aliases simbólicos, crean una disjunción (explicada
en la sección siguiente) al tomar un valor por default para el caso Empty.
.orZero toma un Monoid para definir el valor por default.
.orEmpty usa un ApplicativePlus para crear un elemento único o un contenedor
vacío, sin olvidar que ya tenemos soporte para las colecciones de la librería
estándar con el método .to que viene de la instancia Foldable.
scala> 1.just.orZero
res: Int = 1
scala> Maybe.empty[Int].orZero
res: Int = 0
scala> Maybe.empty[Int].orEmpty[IList]
res: IList[Int] = []
scala> 1.just.orEmpty[IList]
res: IList[Int] = [1]
scala> 1.just.to[List] // from Foldable
res: List[Int] = List(1)
6.7.2 Either
La mejora de Scalaz sobre scalaz.Either es simbólica, pero es común hablar de este
caso como either o una Disjunction.
sealed abstract class \/[+A, +B] { ... }
final case class -\/[+A](a: A) extends (A \/ Nothing)
final case class \/-[+B](b: B) extends (Nothing \/ B)
type Disjunction[+A, +B] = \/[A, B]
object \/ {
def left [A, B]: A => A \/ B = -\/(_)
def right[A, B]: B => A \/ B = \/-(_)
def fromEither[A, B](e: Either[A, B]): A \/ B = ...
...
}
con la sintaxis correspondiente
implicit class EitherOps[A](val self: A) {
final def left [B]: (A \/ B) = -\/(self)
final def right[B]: (B \/ A) = \/-(self)
}
y permite una construcción sencilla de los valores. Note que los métodos de extensión
toman el tipo del otro lado. De modo que si deseamos crear un valor de tipo
String \/ Int y tenemos un Int, debemos pasar String cuando llamamos .right.
scala> 1.right[String]
res: String \/ Int = \/-(1)
scala> "hello".left[Int]
res: String \/ Int = -\/(hello)
La naturaleza simbólica de \/ hace que sea de fácil lectura cuando se muestra con
notación infija. Note que los tipos simbólicos en Scala se asocian desde el lado
izquierdo y que \/ deben tener paréntesis, por ejemplo
(A \/ (B \/ (C \/ D)).
\/tiene instancias sesgadas a la derecha (es decir,flatMapaplica a\/-) para:
-
Monad/MonadError -
Traverse/Bitraverse PlusOptionalCozip
y dependiendo del contenido
-
Equal/Order -
Semigroup/Monoid/Band
Además, hay métodos especiales
sealed abstract class \/[+A, +B] { self =>
def fold[X](l: A => X, r: B => X): X = self match {
case -\/(a) => l(a)
case \/-(b) => r(b)
}
def swap: (B \/ A) = self match {
case -\/(a) => \/-(a)
case \/-(b) => -\/(b)
}
def |[BB >: B](x: =>BB): BB = getOrElse(x) // Optional[_]
def |||[C, BB >: B](x: =>C \/ BB): C \/ BB = orElse(x) // Optional[_]
def +++[AA >: A: Semigroup, BB >: B: Semigroup](x: =>AA \/ BB): AA \/ BB = ...
def toEither: Either[A, B] = ...
final class SwitchingDisjunction[X](right: =>X) {
def <<?:(left: =>X): X = ...
}
def :?>>[X](right: =>X) = new SwitchingDisjunction[X](right)
...
}
.fold es similar a Maybe.cata y requiere que tanto el lado derecho como el
lado izquierdo se mapeen al mismo tipo.
.swap intercambia los lados izquierdo y derecho.
El alias | para getOrElse parece similar a Maybe.
The | alias to getOrElse appears similarly to Maybe. We also get
||| as an alias to orElse.
+++ es para combinar disjunciones con los lados izquierdos tomando precedencia
sobre los lados derechos:
-
right(v1) +++ right(v2)daright(v1 |+| v2) -
right(v1) +++ left (v2)daleft (v2) -
left (v1) +++ right(v2)daleft (v1) -
left (v1) +++ left (v2)daleft (v1 |+| v2)
.toEither está para proporcionar compatibilidad hacia atrás con la librería
estándar de Scala.
La combinación de :?>> y <<? son una sintáxis conveniente para ignorar el
contenido de una \/, pero escoger un valor por default basándose en su tipo.
scala> 1 <<?: foo :?>> 2
res: Int = 2 // foo is a \/-
scala> 1 <<?: foo.swap :?>> 2
res: Int = 1
6.7.3 Validation
A primera vista, Validation (con alias simbólico \?/, Elvis feliz) aparece
ser un clon de Disjunction:
sealed abstract class Validation[+E, +A] { ... }
final case class Success[A](a: A) extends Validation[Nothing, A]
final case class Failure[E](e: E) extends Validation[E, Nothing]
type ValidationNel[E, +X] = Validation[NonEmptyList[E], X]
object Validation {
type \?/[+E, +A] = Validation[E, A]
def success[E, A]: A => Validation[E, A] = Success(_)
def failure[E, A]: E => Validation[E, A] = Failure(_)
def failureNel[E, A](e: E): ValidationNel[E, A] = Failure(NonEmptyList(e))
def lift[E, A](a: A)(f: A => Boolean, fail: E): Validation[E, A] = ...
def liftNel[E, A](a: A)(f: A => Boolean, fail: E): ValidationNel[E, A] = ...
def fromEither[E, A](e: Either[E, A]): Validation[E, A] = ...
...
}
Con sintáxis conveniente
implicit class ValidationOps[A](self: A) {
def success[X]: Validation[X, A] = Validation.success[X, A](self)
def successNel[X]: ValidationNel[X, A] = success
def failure[X]: Validation[A, X] = Validation.failure[A, X](self)
def failureNel[X]: ValidationNel[A, X] = Validation.failureNel[A, X](self)
}
Sin embargo, la estructura de datos no es la historia completa. En Scalaz,
Validation no tiene una instancia de ninguna Monad, y esto es intencional,
restringiéndose a versiones sesgadas a la derecha de:
Applicative-
Traverse/Bitraverse CozipPlusOptional
y dependiendo del contenido
-
Equal/Order Show-
Semigroup/Monoid
La gran ventaja de restringirnos a Applicative es que Validation es
explícitamente para situaciones donde deseamos reportar todas las fallas,
mientras que Disjunction se usa para detenernos en el primer fallo. Para
The big advantage of restricting to Applicative is that Validation
is explicitly for situations where we wish to report all failures,
whereas Disjunction is used to stop at the first failure. To
accommodate failure accumulation, a popular form of Validation is
ValidationNel, having a NonEmptyList[E] in the failure position.
Consider performing input validation of data provided by a user using
Disjunction and flatMap:
scala> :paste
final case class Credentials(user: Username, name: Fullname)
final case class Username(value: String) extends AnyVal
final case class Fullname(value: String) extends AnyVal
def username(in: String): String \/ Username =
if (in.isEmpty) "empty username".left
else if (in.contains(" ")) "username contains spaces".left
else Username(in).right
def realname(in: String): String \/ Fullname =
if (in.isEmpty) "empty real name".left
else Fullname(in).right
scala> for {
u <- username("sam halliday")
r <- realname("")
} yield Credentials(u, r)
res = -\/(username contains spaces)
Si usamos la sintáxis |@|
scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
res = -\/(username contains spaces)
Todavía obetenemos el primer error. Esto es porque Disjuntion es una
Monad, y sus métodos .applyX deben ser consistentes con .flatMap y
no asumir que ninguna operación pueden realizarse fuera de orden.
Compare con:
scala> :paste
def username(in: String): ValidationNel[String, Username] =
if (in.isEmpty) "empty username".failureNel
else if (in.contains(" ")) "username contains spaces".failureNel
else Username(in).success
def realname(in: String): ValidationNel[String, Fullname] =
if (in.isEmpty) "empty real name".failureNel
else Fullname(in).success
scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
res = Failure(NonEmpty[username contains spaces,empty real name])
Esta vez, tenemos todas las fallas!
Validation tiene muchos de los métodos en Disjunction, tales como .fold,
.swap y +++, además de algunas extra:
sealed abstract class Validation[+E, +A] {
def append[F >: E: Semigroup, B >: A: Semigroup](x: F \?/ B]): F \?/ B = ...
def disjunction: (E \/ A) = ...
...
}
.append (con el alias simbólico +|+) tiene la misma signatura de tipo que
+++ pero da preferencia al caso de éxito
-
failure(v1) +|+ failure(v2)dafailure(v1 |+| v2) -
failure(v1) +|+ success(v2)dasuccess(v2) -
success(v1) +|+ failure(v2)dasuccess(v1) -
success(v1) +|+ success(v2)dasuccess(v1 |+| v2)
.disjunction convierte un valor Validated[A, B] en un A \/ B. Disjunction
tiene los métodos .validation y .validationNel para convertir en una
Validation, permitiendo la fácil conversión entre acumulación sequencial y
paralela de errores.
\/ y Validation son las versiones de PF con mayor rendimiento, equivalentes a
una excepción de validación de entrada, evitando tanto un stacktrace y requiriendo
que el que realiza la invocación lidie con las fallas resultando en sistemas más
robustos.
6.7.4 These
Encontramos These, un codificación en forma de datos de un OR lógicamente
inclusivo, cuando aprendimos sobre Align
sealed abstract class \&/[+A, +B] { ... }
object \&/ {
type These[A, B] = A \&/ B
final case class This[A](aa: A) extends (A \&/ Nothing)
final case class That[B](bb: B) extends (Nothing \&/ B)
final case class Both[A, B](aa: A, bb: B) extends (A \&/ B)
def apply[A, B](a: A, b: B): These[A, B] = Both(a, b)
}
y con una sintáxis para una construcción conveniente
implicit class TheseOps[A](self: A) {
final def wrapThis[B]: A \&/ B = \&/.This(self)
final def wrapThat[B]: B \&/ A = \&/.That(self)
}
implicit class ThesePairOps[A, B](self: (A, B)) {
final def both: A \&/ B = \&/.Both(self._1, self._2)
}
These tiene instancias de una typeclass para
MonadBitraverseTraverseCobind
y dependiendo del contenido
-
Semigroup/Monoid/Band -
Equal/Order Show
These (\&/) tiene muchos de los métodos que que esperamos de ``Disjunction
(\/) y Validation (?/`)
sealed abstract class \&/[+A, +B] {
def fold[X](s: A => X, t: B => X, q: (A, B) => X): X = ...
def swap: (B \&/ A) = ...
def append[X >: A: Semigroup, Y >: B: Semigroup](o: =>(X \&/ Y)): X \&/ Y = ...
def &&&[X >: A: Semigroup, C](t: X \&/ C): X \&/ (B, C) = ...
...
}
.append tiene 9 posibles arreglos y los datos nunca se tiran porque los casos de
This y That siempre pueden convertirse en Both.
.flatMap está sesgado hacia la derecha (Both y That), tomando un Semigroup
del contenido de la izquierda (This) para combinar en lugar de fallar temprano.
&&& es una manera conveniente de hacer un binding sobre dos valores de tipo
These, creando una tupla a la derecha y perdiendo datos si no está presente en
cada uno de estos (these).
Aunque es tentador usar \&/ en los tipos de retorno, el uso excesivo es un
antipatrón. La razón principal para usar \&/ es para combinar o dividir streams
de datos potencialmente infinitos en memoria finita. En el objeto compañero existen
funciones convenientes para lidiar con EphemeralStream (con un alias para que quepan en una sola línea) o cualquier cosa con un MonadPlus.
type EStream[A] = EphemeralStream[A]
object \&/ {
def concatThisStream[A, B](x: EStream[A \&/ B]): EStream[A] = ...
def concatThis[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[A] = ...
def concatThatStream[A, B](x: EStream[A \&/ B]): EStream[B] = ...
def concatThat[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[B] = ...
def unalignStream[A, B](x: EStream[A \&/ B]): (EStream[A], EStream[B]) = ...
def unalign[F[_]: MonadPlus, A, B](x: F[A \&/ B]): (F[A], F[B]) = ...
def merge[A: Semigroup](t: A \&/ A): A = ...
...
}
6.7.5 Higher Kinded Either
El tipo de datos Coproduct (no confunda con el concepto más general de un coproducto en un ADT) envuelve una Disjunction para los constructores de tipo:
final case class Coproduct[F[_], G[_], A](run: F[A] \/ G[A]) { ... }
object Coproduct {
def leftc[F[_], G[_], A](x: F[A]): Coproduct[F, G, A] = Coproduct(-\/(x))
def rightc[F[_], G[_], A](x: G[A]): Coproduct[F, G, A] = Coproduct(\/-(x))
...
}
Las instancias de un typeclass simplemente delegan a aquellos de F[_] y G[_].
El caso de uso más popular para un Coproduct es cuando deseamos crear un
coproducto anónimo de múltiples ADTs.
6.7.6 No tan estricta
Las tuplas de Scala integradas, y los tipos de datos básicos como Maybe y la
Disjunction son tipos de valores evaluados de manera estricta.
Por conveniencia, las alternativas por nombre para Name se proporcionan,
teniendo las instancias de typeclass esperadas:
sealed abstract class LazyTuple2[A, B] {
def _1: A
def _2: B
}
...
sealed abstract class LazyTuple4[A, B, C, D] {
def _1: A
def _2: B
def _3: C
def _4: D
}
sealed abstract class LazyOption[+A] { ... }
private final case class LazySome[A](a: () => A) extends LazyOption[A]
private case object LazyNone extends LazyOption[Nothing]
sealed abstract class LazyEither[+A, +B] { ... }
private case class LazyLeft[A, B](a: () => A) extends LazyEither[A, B]
private case class LazyRight[A, B](b: () => B) extends LazyEither[A, B]
El lector astuto notará que Lazy no está bien nombrado, y estos tipos de datos
quizá deberían ser: ByNameTupleX, ByNameOption y ByNameEither.
6.7.7 Const
Const, para constante, es un envoltorio para un valor de tipo A, junto con
un parámetro de tipo B.
final case class Const[A, B](getConst: A)
Const proporciona una instancia de Applicative[Const[A, ?]] si hay un
Monoid[A] disponible:
implicit def applicative[A: Monoid]: Applicative[Const[A, ?]] =
new Applicative[Const[A, ?]] {
def point[B](b: =>B): Const[A, B] =
Const(Monoid[A].zero)
def ap[B, C](fa: =>Const[A, B])(fbc: =>Const[A, B => C]): Const[A, C] =
Const(fbc.getConst |+| fa.getConst)
}
La cosa más importante sobre este Applicative es que ignora los parámetros B,
continuando sin fallar y únicamente combinando los valores constantes que encuentra.
Volviendo atrás a nuestra aplicación de ejemplo drone-dynamic-agents, deberíamos
refactorizar nuestro archivo logic.scala para usar Applicative en lugar de Monad. Escribimos logic.scala antes de que supieramos sobre Applicative y
ahora lo sabemos mejor:
final class DynAgentsModule[F[_]: Applicative](D: Drone[F], M: Machines[F])
extends DynAgents[F] {
...
def act(world: WorldView): F[WorldView] = world match {
case NeedsAgent(node) =>
M.start(node) >| world.copy(pending = Map(node -> world.time))
case Stale(nodes) =>
nodes.traverse { node =>
M.stop(node) >| node
}.map { stopped =>
val updates = stopped.strengthR(world.time).toList.toMap
world.copy(pending = world.pending ++ updates)
}
case _ => world.pure[F]
}
...
}
Dado que nuestra lógica de negocio únicamente requiere de un Applicative, podemos
escribir implementaciones simuladas con F[a] como Const[String, a]. En tal caso,
devolvemos los nombres de la función que se invoca:
object ConstImpl {
type F[a] = Const[String, a]
private val D = new Drone[F] {
def getBacklog: F[Int] = Const("backlog")
def getAgents: F[Int] = Const("agents")
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = Const("alive")
def getManaged: F[NonEmptyList[MachineNode]] = Const("managed")
def getTime: F[Epoch] = Const("time")
def start(node: MachineNode): F[Unit] = Const("start")
def stop(node: MachineNode): F[Unit] = Const("stop")
}
val program = new DynAgentsModule[F](D, M)
}
Con nuestra interpretación de nuestro programa, podemos realizar aserciones sobre los métodos que son invocados:
it should "call the expected methods" in {
import ConstImpl._
val alive = Map(node1 -> time1, node2 -> time1)
val world = WorldView(1, 1, managed, alive, Map.empty, time4)
program.act(world).getConst shouldBe "stopstop"
}
De manera alternativa, podríamos haber contado el total de métodos al usar
Const[Int, ?] o en un IMap[String, Int].
Con esta prueba, hemos ido más allá de realizar pruebas con implementaciones
simuladas con una prueba Const que hace aserciones sobre lo que se invoca sin
tener que proporcionar implementaciones. Esto es útil si nuestra especificación
demanda que hagamos ciertas llamadas para ciertas entradas, por ejemplo, para
propósitos de contabilidad. Además, hemos conseguido esto con seguridad en tiempo
de compilación.
Tomando esta línea de pensamiento un poco más allá, digamos que deseamos monitorear
(en tiempo de producción) los nodos que estamos deteniendo en act. Podemos crear
implementaciones de Drone y Machines con Const, invocándolos desde nuestra
versión envuelta de act
final class Monitored[U[_]: Functor](program: DynAgents[U]) {
type F[a] = Const[Set[MachineNode], a]
private val D = new Drone[F] {
def getBacklog: F[Int] = Const(Set.empty)
def getAgents: F[Int] = Const(Set.empty)
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = Const(Set.empty)
def getManaged: F[NonEmptyList[MachineNode]] = Const(Set.empty)
def getTime: F[Epoch] = Const(Set.empty)
def start(node: MachineNode): F[Unit] = Const(Set.empty)
def stop(node: MachineNode): F[Unit] = Const(Set(node))
}
val monitor = new DynAgentsModule[F](D, M)
def act(world: WorldView): U[(WorldView, Set[MachineNode])] = {
val stopped = monitor.act(world).getConst
program.act(world).strengthR(stopped)
}
}
Podemos hacer esto porque monitor es puro y ejecutarlo no produce efectos
laterales.
Esto ejecuta el programa con ConstImpl, extrayendo todas las llamadas a
Machines.stop, entonces devolviéndolos junto con la WorldView. Podemos
hacer pruebas unitarias así:
it should "monitor stopped nodes" in {
val underlying = new Mutable(needsAgents).program
val alive = Map(node1 -> time1, node2 -> time1)
val world = WorldView(1, 1, managed, alive, Map.empty, time4)
val expected = world.copy(pending = Map(node1 -> time4, node2 -> time4))
val monitored = new Monitored(underlying)
monitored.act(world) shouldBe (expected -> Set(node1, node2))
}
Hemos usado Const para hacer algo que es como la Programación Orientada a Aspectos (Aspect Oriented Programming), que alguna vez fue popular en Java. Construimos
encima de nuestra lógica de negocios para soportar una preocupación de monitoreo,
sin tener que complicar la lógica de negocios.
Se pone incluso mejor. Podemos ejecutar ConstImpl en producción para reunir lo que
deseamos para detenernos (stop), y entonces proporcionar una implementación
optimizada de act que puede usar llamadas por batches/lotes que puede ser
de implementación específica.
El héroe siliencioso de esta historia es Applicative. Const nos deja ver lo que
es posible. Si necesitamos cambiar nuestro programa para que requiera una Monad,
no podemos seguir usando Const y es necesario escribir mocks completos para poder
hacer aserciones sobre lo que se llama sobre ciertas entradas. La Regla del Poder Mínimo demanda que usemos Applicative en lugar de Monad siempre que podamos.
6.8 Colecciones
A diferencia de la API de colecciones de la librería estándar, Scalaz describe
comportamientos en las colecciones en la jerarquía de typeclases, por ejemplo,
Foldable, Traverse, Monoid. Lo que resta por estudiar son las implementaciones
en términos de estructuras de datos, que tienen características de rendimiento
distintas y métodos muy específicos.
Esta sección estudia detalles de implementación para cada tipo de datos. No es esencial recordar todo lo que se presenta aquí: la meta es ganar entendimiento a un nivel de abstracción alto de cómo funciona cada estructura de datos.
Debido a que los tipos de datos de todas las colecciones nos proporcionan más o menos la misma lista de instancias de typeclases, debemos evitar repetir la lista, que siempre es una variación de la lista:
Monoid-
Traverse/Foldable -
MonadPlus/IsEmpty -
Cobind/Comonad -
Zip/Unzip Align-
Equal/Order Show
Las estructuras de datos que es posible probar que no son vacías pueden proporcionar:
-
Traverse1/Foldable1
y proporcionar Semigroup en lugar de Monoid, Plus en lugar de IsEmpty.
6.8.1 Listas
Ya hemos usado IList[A] y NonEmptyList[A] tantas veces al momento que a estas
alturas deberían ser familiares. Codifican una estructura de datos clásica, la lista ligada:
sealed abstract class IList[A] {
def ::(a: A): IList[A] = ...
def :::(as: IList[A]): IList[A] = ...
def toList: List[A] = ...
def toNel: Option[NonEmptyList[A]] = ...
...
}
final case class INil[A]() extends IList[A]
final case class ICons[A](head: A, tail: IList[A]) extends IList[A]
final case class NonEmptyList[A](head: A, tail: IList[A]) {
def <::(b: A): NonEmptyList[A] = nel(b, head :: tail)
def <:::(bs: IList[A]): NonEmptyList[A] = ...
...
}
La ventaja principal de IList sobre List de la librería estándar es que no hay
métodos inseguros, como .head que lanza una excepción sobre listas vacías.
Adicionalmente, IList es mucho más simple, sin tener una jerarquía de clases y
un tamaño del bytecode mucho más pequeño. Además, List de la librería estándar
tiene una implementación terrible que usa var para parchar problemas de
rendimiento en el diseño de las colecciones de la librería estándar:
package scala.collection.immutable
sealed abstract class List[+A]
extends AbstractSeq[A]
with LinearSeq[A]
with GenericTraversableTemplate[A, List]
with LinearSeqOptimized[A, List[A]] { ... }
case object Nil extends List[Nothing] { ... }
final case class ::[B](
override val head: B,
private[scala] var tl: List[B]
) extends List[B] { ... }
La creación de una instancia de List requiere de la creación cuidadosa, y lenta,
de sincronización de Threads para asegurar una publicación segura. IList no
requiere de tales hacks y por lo tanto puede superar a List.
6.8.2 EphemeralStream
Stream de la librería estándar es una versión perezosa de List, pero está
plagada con fugas de memoria y métodos inseguros. EphemeralStream no mantiene
referencias a valores calculados, ayudando a mitigar los problemas de retención de
memoria, removiendo los métodos inseguros en el mismo espíritu que IList.
sealed abstract class EphemeralStream[A] {
def headOption: Option[A]
def tailOption: Option[EphemeralStream[A]]
...
}
// private implementations
object EphemeralStream extends EphemeralStreamInstances {
type EStream[A] = EphemeralStream[A]
def emptyEphemeralStream[A]: EStream[A] = ...
def cons[A](a: =>A, as: =>EStream[A]): EStream[A] = ...
def unfold[A, B](start: =>B)(f: B => Option[(A, B)]): EStream[A] = ...
def iterate[A](start: A)(f: A => A): EStream[A] = ...
implicit class ConsWrap[A](e: =>EStream[A]) {
def ##::(h: A): EStream[A] = cons(h, e)
}
object ##:: {
def unapply[A](xs: EStream[A]): Option[(A, EStream[A])] =
if (xs.isEmpty) None
else Some((xs.head(), xs.tail()))
}
...
}
.cons, .unfold e .iterate son mecanismos para la creación de streams, y la
sintaxis conveniente ##:: pone un nuevo elemento en la cabeza de una referencia
por nombre EStream. .unfold es para la creación de un stream finito (pero
posiblemente infinito) al aplicar repetidamente una función f para obtener el
siguiente valor y la entrada para la siguiente f. .iterate crea un stream
infinito al aplicar repetidamente una función f en el elemento previo.
EStream puede aparecer en patrones de emparejamiento con el símbolo ##::,
haciendo un match para la sintaxis para .cons.
Aunque EStream lidia con el problema de retención de valores de memoria, todavía
es posible sufrir de fugas de memoria lentas si una referencia viva apunta a la
cabeza de un stream infinito. Los problemas de esta naturaleza, así como la
necesidad de realizar composición de streams con efectos, es la razón de que fs2
exista.
6.8.3 CorecursiveList
La correcursión es cuando empezamos de un estado base y producimos pasos
subsecuentes de manera determinística, como el método EphemeralStream.unfold
que acabamos de estudiar:
def unfold[A, B](b: =>B)(f: B => Option[(A, B)]): EStream[A] = ...
Contraste con una recursion, que pone datos en un estado base y entonces termina.
Una CorecursiveList es una codificación de datos de un EphemeralStream.unfold,
ofreciendo una alternativa a EStream que puede lograr un rendimiento mejor en
algunas circunstancias:
sealed abstract class CorecursiveList[A] {
type S
def init: S
def step: S => Maybe[(S, A)]
}
object CorecursiveList {
private final case class CorecursiveListImpl[S0, A](
init: S0,
step: S0 => Maybe[(S0, A)]
) extends CorecursiveList[A] { type S = S0 }
def apply[S, A](init: S)(step: S => Maybe[(S, A)]): CorecursiveList[A] =
CorecursiveListImpl(init, step)
...
}
La correcursión es útil cuando estamos implementando Comonad.cojoin, como en
nuestro ejemplo de Hood. CorecursiveList es una buena manera de codificar ecuaciones con recurrencia no lineales como las que se usan en el modelado de
poblaciones de biología, sistemas de control, macro economía, y los modelos de
inversión de bancos.
6.8.4 ImmutableArray
Un simple wrapper alrededor del Array mutable de la librería estándar, con
especializaciones primitivas:
sealed abstract class ImmutableArray[+A] {
def ++[B >: A: ClassTag](o: ImmutableArray[B]): ImmutableArray[B]
...
}
object ImmutableArray {
final class StringArray(s: String) extends ImmutableArray[Char] { ... }
sealed class ImmutableArray1[+A](as: Array[A]) extends ImmutableArray[A] { ... }
final class ofRef[A <: AnyRef](as: Array[A]) extends ImmutableArray1[A](as)
...
final class ofLong(as: Array[Long]) extends ImmutableArray1[Long](as)
def fromArray[A](x: Array[A]): ImmutableArray[A] = ...
def fromString(str: String): ImmutableArray[Char] = ...
...
}
Array no tiene rival en términos de rendimiento al hacer lectura y el tamaño del
heap. Sin embargo, no se está comportiendo memoria estructuralmente cuando se crean
nuevos arreglos, y por lo tanto los arreglos se usan típicamente cuando no se espera
que los contenidos cambien, o como una manera segura de envolver de manera segura
datos crudos de un sistema antiguo/legacy.
6.8.5 Dequeue
Un Dequeue (pronunciado como en “deck of cards”) es una lista ligada que permite
que los elementos se coloquen o se devuelvan del frente (cons) o en la parte
trasera (snoc) en tiempo constante. Remover un elemento de cualquiera de los extremos es una operación de tiempo constante, en promedio.
sealed abstract class Dequeue[A] {
def frontMaybe: Maybe[A]
def backMaybe: Maybe[A]
def ++(o: Dequeue[A]): Dequeue[A] = ...
def +:(a: A): Dequeue[A] = cons(a)
def :+(a: A): Dequeue[A] = snoc(a)
def cons(a: A): Dequeue[A] = ...
def snoc(a: A): Dequeue[A] = ...
def uncons: Maybe[(A, Dequeue[A])] = ...
def unsnoc: Maybe[(A, Dequeue[A])] = ...
...
}
private final case class SingletonDequeue[A](single: A) extends Dequeue[A] { ... }
private final case class FullDequeue[A](
front: NonEmptyList[A],
fsize: Int,
back: NonEmptyList[A],
backSize: Int) extends Dequeue[A] { ... }
private final case object EmptyDequeue extends Dequeue[Nothing] { ... }
object Dequeue {
def empty[A]: Dequeue[A] = EmptyDequeue()
def apply[A](as: A*): Dequeue[A] = ...
def fromFoldable[F[_]: Foldable, A](fa: F[A]): Dequeue[A] = ...
...
}
La forma en la que funciona es que existen dos listas, una para los datos al frente
y otra para los datos en la parte trasera. Considere una instancia para mantener los
símbolos a0, a1, a2, a3, a4, a5, a6
FullDequeue(
NonEmptyList('a0, IList('a1, 'a2, 'a3)), 4,
NonEmptyList('a6, IList('a5, 'a4)), 3)
que puede visualizarse como
Note que la lista que mantiene back está en orden inverso.
Leer el elemento final, snoc, es una simple lectura en back.head. Añadir un
elemento al final del Dequeue significa añadir un nuevo elemento al frente de
la lista back, y recrear el envoltorio FullDequeue (que incrementará el
tamaño de backSize en uno). Casi toda la estructura original es compartida.
Compare a agregar un nuevo elemento al final de una IList, que envolvería
recrear la estructura completa.
El frontSize y el backSize se usan para rebalancear el front y el back de
modo que casi siempre son del mismo tamaño. Rebalancear significa que algunas
operaciones sean más lentas que otras (por ejemplo, cuando la estructura de datos
debe ser reconstruida) pero debido a que únicamente ocurre ocasionalmente, podríamos
tomar el promedio del costo y decir que es constante.
6.8.6 DList
Las listas ligadas tienen características de rendimiento muy pobres cuando se añaden grandes listas. Considere el trabajo que está envuelto en evaluar lo siguiente:
((as ::: bs) ::: (cs ::: ds)) ::: (es ::: (fs ::: gs))
Esto crea seis listas intermedias, recorriendo y reconstruyendo cada lista tres veces
(con la excepción de gs que se comparte durante todas las etapas).
La DList (de lista por diferencias) es la solución más eficiente para este
escenario. En lugar de realizar cálculos en cada etapa, es representada como la
función IList[A] => IList[A]
final case class DList[A](f: IList[A] => IList[A]) {
def toIList: IList[A] = f(IList.empty)
def ++(as: DList[A]): DList[A] = DList(xs => f(as.f(xs)))
...
}
object DList {
def fromIList[A](as: IList[A]): DList[A] = DList(xs => as ::: xs)
}
El cálculo equivalente es (los símbolos son creados a partir de DList.fromIList)
(((a ++ b) ++ (c ++ d)) ++ (e ++ (f ++ g))).toIList
que reparte el trabajo en appends asociativos a la derecha (es decir, rápidos)
(as ::: (bs ::: (cs ::: (ds ::: (es ::: (fs ::: gs))))))
utilizando el constructor rápido sobre IList.
Como siempre, no hay nada gratis. Existe un costo extra de asignación (alocación)
dinámica de memoria que puede reducir la velocidad del código que resulta
naturalmente en appends asociativos a la derecha. El incremento de velocidad más
grande ocurre cuando operaciones IList son asociativas hacia la izquierda,
por ejemplo
((((((as ::: bs) ::: cs) ::: ds) ::: es) ::: fs) ::: gs)
Las listas de diferencia sufren de un marketing malo. Tal vez si su nombre fuera
ListBuilderFactory estarían en la librería estándar.
6.8.7 ISet
Las estructuras de árboles son excelentes para almacenar datos ordenados, con cada nodo binario manteniendo elementos que son menores en una rama, y mayores en la otra. Sin embargo, implementaciones ingenuas de la estructura de datos árbol pueden desbalancearse dependiendo del orden de inserción. Es posible mantener un árbol perfectamente balanceado, pero es increíblemente ineficiente dado que cada inserción efectivamente reconstruye el árbol completo.
ISet es una implementación de un árbol con balanceo acotado, significando que
está aproximadamente balanceado, usando el tamaño (size) de cada rama para
balancear el nodo.
sealed abstract class ISet[A] {
val size: Int = this match {
case Tip() => 0
case Bin(_, l, r) => 1 + l.size + r.size
}
...
}
object ISet {
private final case class Tip[A]() extends ISet[A]
private final case class Bin[A](a: A, l: ISet[A], r: ISet[A]) extends ISet[A]
def empty[A]: ISet[A] = Tip()
def singleton[A](x: A): ISet[A] = Bin(x, Tip(), Tip())
def fromFoldable[F[_]: Foldable, A: Order](xs: F[A]): ISet[A] =
xs.foldLeft(empty[A])((a, b) => a insert b)
...
}
ISet requiere que A tenga un Order. La instancia Order[A] debe permanecer
igual entre llamadas o las invariantes internas serán inválidas, llevándonos a tener
datos corrompidos: es decir, estamos asumiendo la coherencia de typeclases tales que
Order[A] es única para cualquier A.
La ADT ISet desgraciadamente permite árboles inválidos. Nos esforzamos por escribir
ADTs que describan completamente lo que es y no es válido usando restricciones de
tipo, pero algunas veces tenemos situaciones donde únicamente es posible lograrlo
con el toque inspirado de un inmortal. En lugar de esto, Tip / Bin son private,
para evitar que los usuarios construyan, accidentalmente, árboles inválidos.
.insert es la única manera de construir un ISet, y por lo tanto definir lo que
es un árbol válido.
sealed abstract class ISet[A] {
...
def contains(x: A)(implicit o: Order[A]): Boolean = ...
def union(other: ISet[A])(implicit o: Order[A]): ISet[A] = ...
def delete(x: A)(implicit o: Order[A]): ISet[A] = ...
def insert(x: A)(implicit o: Order[A]): ISet[A] = this match {
case Tip() => ISet.singleton(x)
case self @ Bin(y, l, r) => o.order(x, y) match {
case LT => balanceL(y, l.insert(x), r)
case GT => balanceR(y, l, r.insert(x))
case EQ => self
}
}
...
}
Los métodos internos .balanceL y .balanceR son espejos uno del otro, de modo que
únicamente estudiamos .balanceL, que también se llama cuando el valor que estamos
insertando es menor que el nodo actual. También se invoca por el método .delete.
def balanceL[A](y: A, left: ISet[A], right: ISet[A]): ISet[A] = (left, right) match {
...
El balanceo requiere que clasifiquemos los escenarios que pueden ocurrir.
Estudiaremos cada posible escenario, visualizando (y, left, right) al lado
izquierdo de la página, con la estructura balanceada a la derecha, también conocido
como el árbol rotado.
- círculos llenos visualizan un
Tip - tres columbas visualizan los campos
left | value | rightdeBin - los diamantes visualizan cualquier
ISet
El primer escenario es el caso trivial, que es cuando, tanto el lado left y
el right son Tip. De hecho, nunca encontraremos este escenario a partir de
.insert, pero lo encontramos en .delete.
case (Tip(), Tip()) => singleton(y)
El segundo caso es cuando left es un Bin que contiene únicamente a Tip, y no
necesitamos balancear nada, simplemente creamos la conexión obvia:
case (Bin(lx, Tip(), Tip()), Tip()) => Bin(y, left, Tip())
El tercer caso es cuando esto empieza a ponerse interesante: left es un Bin
conteniendo únicamente un Bin a su right.
case (Bin(lx, Tip(), Bin(lrx, _, _)), Tip()) =>
Bin(lrx, singleton(lx), singleton(y))
Pero qué ocurrió a los dos diamantes que están debajo de lrx? No acabamos de perder
información? No, no perdimos información, porque podemos razonar (basándonos en el
balanceo del tamaño) que siempre son Tip! No hay regla en cuanto a cualquiera de
los siguientes escenarios (o en .balanceR) que pueden producir un árbol donde los
diamantes son Bin.
El cuarto caso es el opuesto del tercer caso.
case (Bin(lx, ll, Tip()), Tip()) => Bin(lx, ll, singleton(y))
El quinto caso es cuando tenemos árboles completos en ambos lados del lado left y
de todos modos debemos usar sus tamañaos relativos para decidir cómo rebalancear.
case (Bin(lx, ll, lr), Tip()) if (2*ll.size > lr.size) =>
Bin(lx, ll, Bin(y, lr, Tip()))
case (Bin(lx, ll, Bin(lrx, lrl, lrr)), Tip()) =>
Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, Tip()))
Para la primera rama, 2*ll.size > lr.size
y para la segunda rama 2*ll.size <= lr.size
El sexto escenario introduce un árbol en lado (derecho) right. Cuando el lado
left está vacío creamos la conexión obvia. Ese escenario nunca surge a partir de
.insert porque el lado .left siempre está no vacío:
case (Tip(), r) => Bin(y, Tip(), r)
El escenario final ocurre cuando tenemos árboles no vacíos en ambos lados. A menos
que el lado left sea tres veces o más el tamaño del lado right, podemos hacer
lo más sencillo y crear un nuevo Bin.
case _ if l.size <= 3 * r.size => Bin(y, l, r)
Sin embargo, si el lado left debiera tener más de tres veces el tamaño del lado
right, debemos balancear basándonos en los tamaños relativos de ll y lr, como
en el escenario cinco.
case (Bin(lx, ll, lr), r) if (2*ll.size > lr.size) =>
Bin(lx, ll, Bin(y, lr, r))
case (Bin(lx, ll, Bin(lrx, lrl, lrr)), r) =>
Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, r))
Esto concluye nuestro estudio del método .insert y cómo se construye un ISet.
No debería ser sorpresivo que Foldable esté implementado en términos de una
búsqueda en lo profundo, junto con left y right, como es apropiado. Métodos
tales como .minimum y .maximum son óptimos porque la estructura de datos
ya codifica el orden.
Es valioso hacer ntar quealgunos métodos de typeclass no pueden ser implementados
de manera tan eficiente como quisieramos. Considere la signatura de
Foldable.element
@typeclass trait Foldable[F[_]] {
...
def element[A: Equal](fa: F[A], a: A): Boolean
...
}
Una implememntación obiva para .element es (de manera práctica) delegar a la
búsqueda binaria ISet.contains. Sin embargo, no es posible debido a que
.element proporciona Equal mientras que .contains requiere de Order.
ISet es incapaz de proporcionar un Functor por la misma razón. En la práctica
esto es una restricción sensible: realizar un .map significaría reconstruir toda
la estructura de datos completa. Tiene sentido convertir a un tipo de datos distinto,
tales como IList, realizando el .map, y convirtiendo de vuelta. Una consecuencia
es que ya no es posible tener un Traverse[ISet] o Applicative[Set].
6.8.8 IMap
sealed abstract class ==>>[A, B] {
val size: Int = this match {
case Tip() => 0
case Bin(_, _, l, r) => 1 + l.size + r.size
}
}
object ==>> {
type IMap[A, B] = A ==>> B
private final case class Tip[A, B]() extends (A ==>> B)
private final case class Bin[A, B](
key: A,
value: B,
left: A ==>> B,
right: A ==>> B
) extends ==>>[A, B]
def apply[A: Order, B](x: (A, B)*): A ==>> B = ...
def empty[A, B]: A ==>> B = Tip[A, B]()
def singleton[A, B](k: A, x: B): A ==>> B = Bin(k, x, Tip(), Tip())
def fromFoldable[F[_]: Foldable, A: Order, B](fa: F[(A, B)]): A ==>> B = ...
...
}
¡Esto es muy familiar! En realidad, IMap (un alias para el operador de la
velocidad de la luz) es otro árbol balanceado en tamaño, pero con un campo extra
value: B en cada rama del árbol binario, permitiendo almacenar pares de
llave/valor. Únicamente el tipo clave A necesita un Order y una clase
conveniente de métodos son proporcionados para permitir una actualización fácil de
entradas.
sealed abstract class ==>>[A, B] {
...
def adjust(k: A, f: B => B)(implicit o: Order[A]): A ==>> B = ...
def adjustWithKey(k: A, f: (A, B) => B)(implicit o: Order[A]): A ==>> B = ...
...
}
Tree es una versión by need (por necesidad) de StrictTree con constructores
convenientes
class Tree[A](
rootc: Need[A],
forestc: Need[Stream[Tree[A]]]
) {
def rootLabel = rootc.value
def subForest = forestc.value
}
object Tree {
object Node {
def apply[A](root: =>A, forest: =>Stream[Tree[A]]): Tree[A] = ...
}
object Leaf {
def apply[A](root: =>A): Tree[A] = ...
}
}
Se espera que el usuario de un Rose Tree lo balancee manualmente, lo que lo hace adecuado para casos donde es útil codificar conocimiento especializado de cierta jerarquía del dominio en la estructura de datos. Por ejemplo, en el campo de la inteligencia artificial, un árbol de Rose puede ser usado en algoritmos de agrupamiento para organizar datos en una jerarquía de cosas cada vez más similares. Es posible representar documentos XML con un árbol Rose.
Cuando trabajamos con datos jerárquicos, considere usar un árbol Rose en lugar de hacer una estructura de datos a la medida.
6.8.9 FingerTree
Los árboles de dedo son secuencias generalizadas con un costo de búsqueda amortizado
y concatenación logarítmica. A es el tipo de datos, ignore V por ahora:
sealed abstract class FingerTree[V, A] {
def +:(a: A): FingerTree[V, A] = ...
def :+(a: =>A): FingerTree[V, A] = ...
def <++>(right: =>FingerTree[V, A]): FingerTree[V, A] = ...
...
}
object FingerTree {
private class Empty[V, A]() extends FingerTree[V, A]
private class Single[V, A](v: V, a: =>A) extends FingerTree[V, A]
private class Deep[V, A](
v: V,
left: Finger[V, A],
spine: =>FingerTree[V, Node[V, A]],
right: Finger[V, A]
) extends FingerTree[V, A]
sealed abstract class Finger[V, A]
final case class One[V, A](v: V, a1: A) extends Finger[V, A]
final case class Two[V, A](v: V, a1: A, a2: A) extends Finger[V, A]
final case class Three[V, A](v: V, a1: A, a2: A, a3: A) extends Finger[V, A]
final case class Four[V, A](v: V, a1: A, a2: A, a3: A, a4: A) extends Finger[V, A]
sealed abstract class Node[V, A]
private class Node2[V, A](v: V, a1: =>A, a2: =>A) extends Node[V, A]
private class Node3[V, A](v: V, a1: =>A, a2: =>A, a3: =>A) extends Node[V, A]
...
}
Visualizando FingerTree como puntos, Finger como cajas y Node como cajas dentro
de cajas:
Agregar elementos al frente de un FingerTree con +: es eficiente porque Deep
simplemente añade un nuevo elemento al dedo del lado left. Si el dedo es un Four,
reconstruimos la espina (spine) para tomar 3 de los elementos como un Node3.
Agregando al final, :+, es lo mismo pero en reversa.
Agregar cosas |+| (también <++>) es más eficiente que agregar un elemento a la vez
debido a que el caso de dos árboles Deep pueden retener las ramas externas,
reconstruyendo la espina basándose en las 16 posibles combinaciones de los dos valores
Finger en la mitad.
En la discusión anterior nos saltamos V. En la descripción de la ADT no se muestra una
implicit measurer: Reducer[A, V] en cada elemento de la ADT.
Reducer es una extensión de Monoid que permite que un solo elemento se agregue a una
M.
class Reducer[C, M: Monoid] {
def unit(c: C): M
def snoc(m: M, c: C): M = append(m, unit(c))
def cons(c: C, m: M): M = append(unit(c), m)
}
Por ejemplo, Reducer[A, IList[A]] puede proporcionar un .cons eficiente
implicit def reducer[A]: Reducer[A, IList[A]] = new Reducer[A, IList[A]] {
override def unit(a: A): IList[A] = IList.single(a)
override def cons(a: A, as: IList[A]): IList[A] = a :: as
}
6.8.9.1 IndSeq
Si usamos Int como V, podemos obtener una secuencia indexada, donde la medida
es el tamaño, permitiéndonos realizar una búsqueda basada en el índice al comparar
el índice deseado con el tamaño de cada rama en la estructura:
final class IndSeq[A](val self: FingerTree[Int, A])
object IndSeq {
private implicit def sizer[A]: Reducer[A, Int] = _ => 1
def apply[A](as: A*): IndSeq[A] = ...
}
Otro uso de FingerTree es una secuencia ordenada, donde la medida almacena el valor
más largo contenido en cada rama:
6.8.9.2 OrdSeq
final class OrdSeq[A: Order](val self: FingerTree[LastOption[A], A]) {
def partition(a: A): (OrdSeq[A], OrdSeq[A]) = ...
def insert(a: A): OrdSeq[A] = ...
def ++(xs: OrdSeq[A]): OrdSeq[A] = ...
}
object OrdSeq {
private implicit def keyer[A]: Reducer[A, LastOption[A]] = a => Tag(Some(a))
def apply[A: Order](as: A*): OrdSeq[A] = ...
}
OrdSeq no tiene instancias de typeclases de modo que únicamente es útil para construir
incrementalmente una secuencia ordenada, con duplicados. Podemos acceder al FingerTree
subyacente cuando sea necesario.
6.8.9.3 Cord
El caso más común de FingerTree es un almacén intermedio para representaciones de String
en Show. Construir una sola String puede ser miles de veces más rápido que la
implementación default de case class de .toString anidadas, que construye una String
para cada capa en la ADT.
final case class Cord(self: FingerTree[Int, String]) {
override def toString: String = {
val sb = new java.lang.StringBuilder(self.measure)
self.foreach(sb.append) // locally scoped side effect
sb.toString
}
...
}
Por ejemplo, la instancia Cord[String] devuelve una Three con la cadena a
la mitad y comillas en ambos lados
implicit val show: Show[String] = s => Cord(FingerTree.Three("\"", s, "\""))
Por lo tanto una String se muestra tal y como se escribe en el código fuente
scala> val s = "foo"
s.toString
res: String = foo
scala> s.show
res: Cord = "foo"
6.8.10 Cola de prioridad Heap
Una cola de prioridad es una estructura de datos que permite una rápida inserción de elementos ordenados, permitiendo duplicados, con un rápido acceso al valor mínimo (la prioridad más alta). La estructura no es necesaria para almacenar los elementos no mínimos en orden. Una implementación ingenua de una cola de prioridad podría ser
final case class Vip[A] private (val peek: Maybe[A], xs: IList[A]) {
def push(a: A)(implicit O: Order[A]): Vip[A] = peek match {
case Maybe.Just(min) if a < min => Vip(a.just, min :: xs)
case _ => Vip(peek, a :: xs)
}
def pop(implicit O: Order[A]): Maybe[(A, Vip[A])] = peek strengthR reorder
private def reorder(implicit O: Order[A]): Vip[A] = xs.sorted match {
case INil() => Vip(Maybe.empty, IList.empty)
case ICons(min, rest) => Vip(min.just, rest)
}
}
object Vip {
def fromList[A: Order](xs: IList[A]): Vip[A] = Vip(Maybe.empty, xs).reorder
}
Este push es muy rápido O(1), pero el reorder (y por lo tanto pop) depende de
IList.sorted con un costo de O(n log n).
Scalaz codifica una cola de prioridad con una estructura de árbol donde cada nodo
tiene un valor menor que sus hijos. Heap tiene un operaciones rápidas push (insert),
union, size, pop (uncons) y peek (minimumO):
sealed abstract class Heap[A] {
def insert(a: A)(implicit O: Order[A]): Heap[A] = ...
def +(a: A)(implicit O: Order[A]): Heap[A] = insert(a)
def union(as: Heap[A])(implicit O: Order[A]): Heap[A] = ...
def uncons(implicit O: Order[A]): Option[(A, Heap[A])] = minimumO strengthR deleteMin
def minimumO: Option[A] = ...
def deleteMin(implicit O: Order[A]): Heap[A] = ...
...
}
object Heap {
def fromData[F[_]: Foldable, A: Order](as: F[A]): Heap[A] = ...
private final case class Ranked[A](rank: Int, value: A)
private final case class Empty[A]() extends Heap[A]
private final case class NonEmpty[A](
size: Int,
tree: Tree[Ranked[A]]
) extends Heap[A]
...
}
Heap está implementado con un Rose Tree de valores Ranked, donde rank
es la profundidad del sub-árbol, permitiéndonos balancear la profundidad del árbol.
Manualmente mantenemos el árbol de modo que el valor mínimo está en la raíz.
Una ventaja de que se codifique el valor mínimo en la estructura de datos es que
minimum0 (conocido también como peek) es una búsqueda inmediata y sin costo
alguno:
def minimumO: Option[A] = this match {
case Empty() => None
case NonEmpty(_, Tree.Node(min, _)) => Some(min.value)
}
Cuando insertamos una nueva entrada, comparamos el valor mínimo actual y lo reemplazamos si la nueva entrada es más pequeña:
def insert(a: A)(implicit O: Order[A]): Heap[A] = this match {
case Empty() =>
NonEmpty(1, Tree.Leaf(Ranked(0, a)))
case NonEmpty(size, tree @ Tree.Node(min, _)) if a <= min.value =>
NonEmpty(size + 1, Tree.Node(Ranked(0, a), Stream(tree)))
...
Las inserciones de valores que no son mínimos resulta en una estructura de datos desordenada en las ramas del nodo mínimo. Cuando encontramos dos o más subárboles de rango idéntico, colocamos el valor mínimo de manera optimista al frente:
...
case NonEmpty(size, Tree.Node(min,
(t1 @ Tree.Node(Ranked(r1, x1), xs1)) #::
(t2 @ Tree.Node(Ranked(r2, x2), xs2)) #:: ts)) if r1 == r2 =>
lazy val t0 = Tree.Leaf(Ranked(0, a))
val sub =
if (x1 <= a && x1 <= x2)
Tree.Node(Ranked(r1 + 1, x1), t0 #:: t2 #:: xs1)
else if (x2 <= a && x2 <= x1)
Tree.Node(Ranked(r2 + 1, x2), t0 #:: t1 #:: xs2)
else
Tree.Node(Ranked(r1 + 1, a), t1 #:: t2 #:: Stream())
NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), sub #:: ts))
case NonEmpty(size, Tree.Node(min, rest)) =>
val t0 = Tree.Leaf(Ranked(0, a))
NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), t0 #:: rest))
}
Al evitar un ordenamiento completo del árbol hacemos que insert sea muy
rápido, O(1), de modo que los productores que agregan elementos a la cola
no son penalizados. Sin embargo, el consumidor paga el costo cuando invoca
uncons, con deleteMin teniendo un costo O(log n) debido a que debe
buscar el valor mínimo, y removerlo del árbol al reconstruirlo. Esto es
rápido cuando se compara con una implementación ingenua.
La operación union también retrasa el ordenamiento permitiéndo que se
realice en O(1).
Si Order[Foo] no captura correctamente la prioridad que deseamos para el
Heap[Foo], podemos usar Tag y proporcionar un Order[Foo @@ Custom]
ad-hoc para un Heap[Foo @@ Custom].
6.8.11 Diev (Discrete Intervals)
Podemos codificar eficientemente los valores enteros (desordenados)
6, 9, 2, 13, 8, 14, 10, 7, 5 como intervalos inclusivos
[2, 2], [5, 10], [13, 14]. Diev tiene una codificación eficiente
de intervalos para elementos A que tienen un Enum[A], haciéndose
más eficientes a medida que el contenido se hace más denso.
sealed abstract class Diev[A] {
def +(interval: (A, A)): Diev[A]
def +(value: A): Diev[A]
def ++(other: Diev[A]): Diev[A]
def -(interval: (A, A)): Diev[A]
def -(value: A): Diev[A]
def --(other: Diev[A]): Diev[A]
def intervals: Vector[(A, A)]
def contains(value: A): Boolean
def contains(interval: (A, A)): Boolean
...
}
object Diev {
private final case class DieVector[A: Enum](
intervals: Vector[(A, A)]
) extends Diev[A]
def empty[A: Enum]: Diev[A] = ...
def fromValuesSeq[A: Enum](values: Seq[A]): Diev[A] = ...
def fromIntervalsSeq[A: Enum](intervals: Seq[(A, A)]): Diev[A] = ...
}
Cuando actualizamos el Diev, los intervalos adyacentes se fusionan
(y entonces ordenados) tales que hay una representación única para un conjunto
dado de valores.
scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5))
res: Diev[Int] = ((2,2)(5,10)(13,14))
scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5).reverse)
res: Diev[Int] = ((2,2)(5,10)(13,14))
Un gran caso de uso para Diev es para almacenar periodos de tiempo. Por
ejemplo, en nuestro TradeTemplate del capítulo anterior
final case class TradeTemplate(
payments: List[java.time.LocalDate],
ccy: Option[Currency],
otc: Option[Boolean]
)
si encontramos que los payments son muy densos, tal vez deseemos intercambiar
a una representación Diev por razones de rendimiento, sin ningún cambio en
nuestra lógica de negocios porque usamos un Monoid, no ningún método específico
de List. Sin embargo, tendríamos que proporcionar un Enum[LocalDate], que de
otra manera sería algo útil y bueno que tener.
6.8.12 OneAnd
Recuerde que Foldable es el equivalente de Scalaz de una API de colecciones y que
Foldable1 es para colecciones no vacías. Hasta el momento hemos revisado únicamente
NonEmptyList para proporcionar un Foldable1. La estructura de datos simple OneAnd
envuelve cualquier otra colección y la convierte en un Foldable1.
final case class OneAnd[F[_], A](head: A, tail: F[A])
NonEmptyList[A] podría ser un alias a OneAnd[IList, A]. De manera similar, podemos
crear Stream no vacío, y estructuras DList y Tree. Sin embargo, podría terminar
con las características de ordenamiento y unicidad de la estructura de datos subyacente:
un OneAnd[ISet, A] no es un ISet no vacío, se trata de un ISet con la garantía de
que se tiene un primer elemento que también puede estar en el ISet.
6.9 Sumario
En este capítulo hemos revisado rápidamente los tipos de datos que Scalaz tiene que ofrecer.
No es necesario recordad todo lo estudiado en este capítulo: piense que cada sección es la semilla o corazón de una idea.
El mundo de las estructuras de datos funcionales es una área activa de investigación. Publicaciones académicas aparecen regularmente con nuevos enfoques a viejos problemas. La implementación de una estructura de datos funcionales a partir de la literatura sería una buena contribución al ecosistema de Scalaz.