Skip to content

Instantly share code, notes, and snippets.

@jisantuc
Last active March 30, 2020 01:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jisantuc/99a958e9eac67bd525c728fa39626077 to your computer and use it in GitHub Desktop.
Save jisantuc/99a958e9eac67bd525c728fa39626077 to your computer and use it in GitHub Desktop.
title author patat
Typeclasses and Variance
James Santucci
wrap margins
true
left right
40
40

Typeclasses

General form

  • Things with the shape F[_]
  • That are used as: TYPE -> CONSTRAINT
  • CONSTRAINT constructors, without inheritance

Why are they good

  • Let us define interfaces --
trait CsvEncoder[T] {
  def encodeRow(row: T): CsvRow = ???
}
  • Let us define standard interfaces for common operations
trait Functor[F[_]] {
  def map[A, B](f: A => B)(fa: F[A]): F[B] = ???
}
  • In ways that don't mess with OOP challenges! i.e., no diamond problem
  • Also, many typeclasses have laws, and we can test those laws in different ways. In Scala, we use Discipline

Some common typeclasses

Semigroup

  • Semigroup is for things that you can combine
trait Semigroup[T] {
  def combine(thisOne: T, thatOne: T): T
}
  • Laws: associativity
combine(combine(x, y), z) == combine(x, combine(y, z))

Monoid

  • Monoid is for things you can combine that also have an identity.
  • Having a Monoid implies having a Semigroup
trait Monoid[T] {
  def combine(thisOne: T, thatOne: T): T
  def empty: T
}
  • Laws: associativity (as above), left and right identity:
// for some type T
combine(x, Monoid[T].empty) == combine(Monoid[T].empty, x) == x

What do these have in common

  • Extra capabilities for some concrete type
  • T, not F[_]
  • T is a type, while F[_] is a type constructor
  • typeclasses of T will define things we can do with values. typeclasses of F[_] will change how we can combine and compose values in contexts
    • Some contexts: eventuality (Future, IO), optionality (Option), potential for failure (Try, Either)
    • Typeclasses on higher-kinded types let us express common operations over a range of contexts

Typeclasses with higher-kinded type parameters

Functor

  • For contexts where we can lift a function of one argument into the context
trait Functor[F[_]] {
  def map[A, B](f: A => B)(fa: F[A]): F[B] = ???
}
  • laws: identity, composition
// identity
map(identity)(fa) == fa

// composition
// harder to express succinctly in Scala, but basically:
// composing then lifting should result in the same thing as composing
// lifted functions

Applicative

  • For contexts where we want to combine several values in context
  • Functor gave us a map, but what is the return type in this case?
def f(x: Int, y: Int): Int = ???

// pretend this is partial application
Option(3).map(f)
  • So how do we apply f in context?
(Option(3), None).mapN(f)
(Option(3), Option(4)).mapN(f)

Applicative

  • what's happening?
  • as long as F[_] has an applicative, we call functions of n parameters on n instances of F[_], provided the function arguments align with the wrapped types in F
trait Applicative[F[_]] extends Functor[F] {
  def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]

  def pure[A](a: A): F[A]
}
  • product and a syntax method .tupled are what let us switch between tuples of F[_] and F of a tuple

Monads

  • for sequential composition in context
trait Monad[F[_]] extends Applicative[F] {
  def flatMap[A, B](f: A => F[B])(fa: F[A]): F[B]
}

Monads

  • power for comprehension syntax -- for comprehensions desugar to chained flatMaps
@ desugar {
    for {
      x <- Option(4)
      y <- Option(5)
      z <- Option.empty[Int]
    } yield x + y + z
  }
res0: Desugared =
  Option[Int](4)
    .flatMap(((x: Int) => Option[Int](5)
	  .flatMap(((y: Int) => None.map(((z: Int) => x.+(y).+(z)))))))
  • for short-circuiting computations in context

Traversable

  • for transforming context... IN CONTEXT
trait Traversable[F[_]] {
  def traverse[G[_], A, B](f: A => G[B])(fa: F[A]): G[F[B]]
  def sequence[G[_], A](fga: F[G[A]]): G[F[A]]
}
  • a trick (always be suspicious if you find yourself needing to .sequence):
def f[G[_], A](a: A): G[A] = ???

fa.map(f).sequence == fa.traverse(f)

Traversable, an example

  • you maybe have a value. If you have a value, you want to use it to make an http call.
type Data

def makeHttpCall(d: Data): IO[ResponseData] = ???
def getData(): IO[Option[Data]] = ???

for {
  d <- getData
  result <- d match {
    case Some(data) => makeHttpCall(data)
	case _ => Option.empty[Data].pure[IO]
  }
} yield result
  • no!

Traversable, an example

type Data

def makeHttpCall(d: Data): IO[ResponseData] = ???
def getData(): IO[Option[Data]] = ???

for {
  d <- getData
  result <- d traverse { makeHttpCall _ }
} yield result
  • now future readers don't have to guess if you're doing anything weird in your None/Some cases!
  • but obviously if you need to do something weird you should match and do that

Variance

Functors again

  • gonna switch to some Haskell syntax to match some examples
newtype T1 a = T1 (Int -> a)

newtype T2 a = T2 (a -> Int)

newtype T3 a = T3 (a -> a)

newtype T4 a = T4 ((Int -> a) -> Int)

newtype T5 a = T5 ((a -> Int) -> Int)
  • which of these have functors?
  • or: for which of these, if I give you an f: a => b, can you give me a Tx b?

Why don't some of them work?

  • positive and negative position
  • a variable appears in positive position when it is on the right-hand side of an arrow, or as a type parameter to a coproduct or product
  • a variable appears in negative position when it is on the left-hand side of an arrow
  • these multiply the way you'd expect

The examples again

-- a in positive only
newtype T1 a = T1 (Int -> a)

-- a in negative only
newtype T2 a = T2 (a -> Int)

-- a in _both_ positions
newtype T3 a = T3 (a -> a)

-- a in positive position in (Int -> a), but (Int -> a) in negative position
newtype T4 a = T4 ((Int -> a) -> Int)

-- a in negative position in (a -> Int), but (a -> Int) in negative position
newtype T5 a = T5 ((a -> Int) -> Int)

Other typeclasses

  • when a is in strictly negative position, we have a Contravariant
  • Contravariant provides contramap
  • when a appears in both positive and negative position, we have an Invariant
  • Invariant provides imap

A more concrete example

  • circe provides us a Decoder typeclass
// simplified
trait Decoder[T] {
  def decode(js: Json): Either[Error, T]
}
  • what position is T in?
  • and so:
val decTimeRange: Decoder[(Option[Instant], Option[Instant])] = Decoder[String] map { str =>
  ???
}

A more concrete example

  • circe provides us an Encoder typeclass
// simplified
trait Encoder[T] {
  def encode(thing: T): Json
}
  • what position in T in?
  • and so:
// Encoder.encodeString: Encoder[String]
// _.toString: Instant => String
implicit val encodeInstant: Encoder[Instant] =
  Encoder.encodeString.contramap[Instant](_.toString)

A more concrete example

  • Skunk provides us a Codec ... not actually a typeclass, but work with me here
// simplified
trait Codec[T] {
  def toProduct(thing: T): Product
  def fromProduct(p: Product): T
}
  • what position is T in?
  • and so:
val codec: Codec[Brand] =
  (uuid.cimap[BrandId] ~ varchar.cimap[BrandName]).imap {
    case i ~ n => Brand(i, n)
  }(b => b.uuid ~ b.name)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment