Skip to content

Instantly share code, notes, and snippets.

@samgj18
Last active April 20, 2022 23:29
Show Gist options
  • Save samgj18/e7090029a8c27e61c821f3efad3ff65b to your computer and use it in GitHub Desktop.
Save samgj18/e7090029a8c27e61c821f3efad3ff65b to your computer and use it in GitHub Desktop.
sealed trait MyList[A]
object MyList {
def empty[A]: MyList[A] = Empty()
/// A* means zero or more (variadic)
def apply[A](head: A, tail: A*): MyList[A] =
(head +: tail).foldRight(empty[A])(Cons(_, _))
case class Empty[A]() extends MyList[A]
case class Cons[A](head: A, tail: MyList[A]) extends MyList[A]
}
sealed trait Maybe[A]
object Maybe {
def none[A]: Maybe[A] = Empty()
def apply[A](a: A): Maybe[A] =
if (a == null) none else Just(a)
case class Empty[A]() extends Maybe[A]
case class Just[A](a: A) extends Maybe[A]
}
/// This technique is called "ad hoc polymorphism"
/// It is the main principle behind type classes which is not a part of the standard Scala library but is very easy to implement
trait Mapper[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
}
object Mapper {
val maybeMapper: Mapper[Maybe] = new Mapper[Maybe] {
def map[A, B](fa: Maybe[A])(f: A => B): Maybe[B] = fa match {
case Maybe.Just(a) => Maybe.Just(f(a))
case Maybe.Empty() => Maybe.none
}
}
val myListMapper: Mapper[MyList] = new Mapper[MyList] {
def map[A, B](fa: MyList[A])(f: A => B): MyList[B] = fa match {
case MyList.Empty() => MyList.Empty()
case MyList.Cons(h, t) => MyList.Cons(f(h), map(t)(f))
}
}
}
def addInt(i: Int): Int => Int = _ + i
def mapMaybeInt(f: Int => Int)(mi: Maybe[Int]): Maybe[Int] =
mi match {
case Maybe.Just(i0) => Maybe(f(i0))
case n => n // none case
}
mapMaybeInt(addInt(2))(Maybe(1)) == Maybe(3)
// res0: Boolean = true
mapMaybeInt(addInt(2))(Maybe.none) == Maybe.none
// res1: Boolean = true
/// Before going to type classes, we need to understand how implicit works
// They come in several flavours:
// 1. Implicit parameters/arguments
// 2. Implicit classes
// 3. Implicit conversions
// Implicit parameters/arguments
// An implicit argument can be ommitted from a function call
// The missing parameter has to be provided
// The compiler will look for an implicit value of the same type and with the "implicit" keyword
// The compiler will look for the implicit as a local definitions
// if it is not found, it will look for an implicit value as an import
def add[A](a: A, b: A)(implicit combine: (A, A) => (A)): A = combine(a, b)
// First thing to look
implicit val addIntImplicit: (Int, Int) => Int = _ + _
// addIntImplicit: (Int, Int) => Int = <function2>
implicit val addStringImplicit: (String, String) => String = _ + _
// addStringImplicit: (String, String) => String = <function2>
// Second thing to look
/* object SomeObject {
implicit val addIntImplicit: (Int, Int) => Int = _ + _
implicit val addStringImplicit: (String, String) => String = _ + _
}
import SomeObject._ */
// Third thing to look -> Companion objects
// This one is advantegous because it doesn't require an import at the call site
case class Bar(i: Int)
object Bar {
implicit val plusBar: (Bar, Bar) => Bar = (a, b) => Bar(a.i + b.i)
}
add(Bar(1), Bar(2))
// res2: Bar = Bar(i = 3)
add(1, 2)
// res3: Int = 3
add("a", "b")
// res4: String = "ab"
// Implicit conversions
// Allows to convert a type to another type implicitly
// ⚠️ Highly discouraged
case class Foo(i: Int)
implicit def int2Foo(i: Int): Foo = Foo(i)
val foo: Foo = 1
// foo: Foo = Foo(i = 1)
// Implicit classes
// Allows to add methods to an existing type
// Resolution works the same as the other implicits
// They take only one parameter, the type to which the methods are added
// It could be defined as a package object, define locally or in a separate file
implicit class IntOps(i: Int) {
def isEven: Boolean = i % 2 == 0
}
12.isEven
// res5: Boolean = true
// Implicits can be chained
class Foo2
object Foo2 {
implicit val foo2: Foo2 = new Foo2
}
class Bar2(fa: Foo2)
object Bar2 {
implicit def bar2(implicit fa: Foo2): Bar2 = new Bar2(fa)
}
def foobar(implicit b: Bar2) = b
foobar
// res6: Bar2 = repl.MdocSession$App$Bar2@6754a617
// Context Boundings
// A context bounding is a type parameter that is used to constrain the type of a type parameter
trait Foo3[A] {
def foo: A
}
case class Bar3[A](a: A)
implicit def bar3[A](implicit fa: Foo3[A]): Bar3[A] = Bar3(fa.foo)
// Can be translated to:
implicit def bar3_implicitly[A: Foo3]: Bar3[A] = Bar3(
implicitly[Foo3[A]].foo
) // ‼️ Note you don't have an explicit type parameter "fa" here,
// that's why you need the "implicitly" keyword to get the value of the implicit parameter
// Let's build the Mapper type class with implicits
/// 1. Make `MapperTypeClass` instances implicits
trait MapperTypeClass[F[_]] {
def map[A, B](fa: F[A])(f: A => B): F[B]
def flatMap[A, B <: A](fa: F[A])(f: A => F[B]): F[B]
def ++[A, B >: A](fa: F[B], fb: F[B]): F[B]
}
object MapperTypeClass {
// Summoner pattern
// This is a way to create instances of MapperTypeClass
def apply[F[_]: MapperTypeClass]: MapperTypeClass[F] =
implicitly[MapperTypeClass[F]]
implicit val maybeMapper: MapperTypeClass[Maybe] =
new MapperTypeClass[Maybe] {
def map[A, B](fa: Maybe[A])(f: A => B): Maybe[B] = fa match {
case Maybe.Just(a) => Maybe.Just(f(a))
case Maybe.Empty() => Maybe.none
}
def flatMap[A, B](fa: Maybe[A])(f: A => Maybe[B]): Maybe[B] = fa match {
case Maybe.Just(a) => f(a)
case Maybe.Empty() => Maybe.none
}
def ++[A, B >: A](fa: Maybe[B], fb: Maybe[B]): Maybe[B] =
(fa, fb) match {
case (Maybe.Just(a), Maybe.Just(b)) => Maybe.Just((a))
case (Maybe.Just(a), Maybe.Empty()) => Maybe.Just(a)
case (Maybe.Empty(), Maybe.Just(b)) => Maybe.Just(b)
case (Maybe.Empty(), Maybe.Empty()) => Maybe.none
}
}
implicit val myListMapper: MapperTypeClass[MyList] =
new MapperTypeClass[MyList] {
def map[A, B](fa: MyList[A])(f: A => B): MyList[B] = fa match {
case MyList.Empty() => MyList.Empty()
case MyList.Cons(h, t) => MyList.Cons(f(h), map(t)(f))
}
def flatMap[A, B](fa: MyList[A])(f: A => MyList[B]): MyList[B] =
fa match {
case MyList.Empty() => MyList.Empty()
case MyList.Cons(h, t) => f(h) ++ flatMap(t)(f)
}
def ++[A, B >: A](fa: MyList[B], fb: MyList[B]): MyList[B] =
(fa, fb) match {
case (MyList.Empty(), MyList.Empty()) => MyList.Empty()
case (MyList.Empty(), MyList.Cons(h, t)) => MyList.Cons(h, t)
case (MyList.Cons(h, t), MyList.Empty()) => MyList.Cons(h, t)
case (MyList.Cons(h, t), MyList.Cons(h2, t2)) =>
MyList.Cons(h, t ++ MyList.Cons(h2, t2))
}
}
implicit class syntaxOps[F[_]: MapperTypeClass, A](fa: F[A]) {
def map[B](f: A => B): F[B] = implicitly[MapperTypeClass[F]].map(fa)(f)
def flatMap[B <: A](f: A => F[B]): F[B] =
implicitly[MapperTypeClass[F]].flatMap(fa)(f)
def ++[B >: A](fb: F[A]) = implicitly[MapperTypeClass[F]].++(fa, fb)
}
}
// Bound addTypeClass so that it can be used only if there's an implicit instance of `MapperTypeClass` for `F[_]`
// from: def addTypeClass[F[_]](fi: F[Int], mm: MapperTypeClass[F]): F[Int] = mm.map(fi)(_ + 1)
def addTypeClass[F[_]: MapperTypeClass](fi: F[Int]): F[Int] =
implicitly[MapperTypeClass[F]].map(fi)(_ + 1)
import MapperTypeClass._ // Importing for line 186, line 187 will work without it due to implicit resolution rules
Maybe(1).map(_ + 1) == Maybe(2)
// res7: Boolean = true
addTypeClass(MyList(1, 2, 3)) == MyList(2, 3, 4)
// res8: Boolean = true
// With the summoner pattern, we can create instances of MapperTypeClass without having to define them explicitly with the "implicitly" keyword 👀
def addTypeClass2[F[_]: MapperTypeClass](fi: F[Int]): F[Int] =
MapperTypeClass[F].map(fi)(_ + 1)
addTypeClass2(MyList(1, 2, 3)) == MyList(2, 3, 4)
// res9: Boolean = true
MyList(1, 2, 3).flatMap(x => MyList(x, x)) == MyList(1, 1, 2, 2, 3, 3)
// res10: Boolean = true
MyList(1, 2, 3) ++ MyList(4, 5, 6) == MyList(1, 2, 3, 4, 5, 6)
// res11: Boolean = true
MyList(1, 2, 3) ++ MyList("4, 5, 6") == MyList(1, 2, 3, "4, 5, 6")
// res12: Boolean = true
// A type class is a type that defines a set of operations on types, is often referred to as a type class instance
// Type classes should live in the type class companion object or the instance type's companion object so you don't have to import them
// All type classes must comply with the following laws:
// 1. Identity
/// fa.map(identity) == fa
// 2. Composition
/// fa.map(a => f(g(a))) == fa.map(g).map(f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment