-
Notifications
You must be signed in to change notification settings - Fork 1.1k
[Documentation] Trying to rewrite typeclasses-new #8147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 26 commits
a5c8aef
1bd517e
171a09a
6b7e497
74ff540
8118ed7
a076eea
66ebcfb
334c4b4
cd501b6
ff7ca15
cf29e82
8607014
4c81c46
6b6cd39
c6ec581
716ae0c
c15cd63
8b85b34
86615f6
f2d7a6d
df15a24
4b04603
cb6c511
cd9834e
bf7b005
9c1509b
466bff9
219bc3c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,276 @@ | ||||||||||
--- | ||||||||||
layout: doc-page | ||||||||||
title: "Implementing Typeclasses" | ||||||||||
--- | ||||||||||
|
||||||||||
A _typeclass_ is an abstract, parameterized type that lets you add new behavior to any closed data type without using sub-typing. This can be useful in multiple use-cases, for example: | ||||||||||
* expressing how a type you don't own (from the standard or 3rd-party library) conforms to such behavior | ||||||||||
* expressing such a behavior for multiple types without involving sub-typing relationships (one `extends` another) between those types (see: [ad hoc polymorphism](https://en.wikipedia.org/wiki/Ad_hoc_polymorphism) for instance) | ||||||||||
|
||||||||||
Therefore in Scala 3, _typeclasses_ are just _traits_ with one or more parameters whose implementations are not defined through the `extends` keyword, but by **given instances**. | ||||||||||
Here are some examples of usual typeclasses: | ||||||||||
|
||||||||||
### Semigroups and monoids: | ||||||||||
|
||||||||||
Here's the `Monoid` typeclass definition: | ||||||||||
|
||||||||||
```scala | ||||||||||
trait SemiGroup[T] { | ||||||||||
@infix def (x: T) combine (y: T): T | ||||||||||
} | ||||||||||
|
||||||||||
trait Monoid[T] extends SemiGroup[T] { | ||||||||||
def unit: T | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
An implementation of this `Monoid` typeclass for the type `String` can be the following: | ||||||||||
|
||||||||||
```scala | ||||||||||
given Monoid[String] { | ||||||||||
def (x: String) combine (y: String): String = x.concat(y) | ||||||||||
def unit: String = "" | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
Whereas for the type `Int` one could write the following: | ||||||||||
```scala | ||||||||||
given Monoid[Int] { | ||||||||||
def (x: Int) combine (y: Int): Int = x + y | ||||||||||
def unit: Int = 0 | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
This monoid can now be used as _context bound_ in the following `combineAll` method: | ||||||||||
|
||||||||||
```scala | ||||||||||
def combineAll[T: Monoid](xs: List[T]): T = | ||||||||||
xs.foldLeft(summon[Monoid[T]].unit)(_ combine _) | ||||||||||
``` | ||||||||||
|
||||||||||
To get rid of the `summon[...]` we can define a `Monoid` object as follows: | ||||||||||
|
||||||||||
```scala | ||||||||||
object Monoid { | ||||||||||
def apply[T](using m: Monoid[T]) = m | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
Which would allow to re-write the `combineAll` method this way: | ||||||||||
|
||||||||||
```scala | ||||||||||
def combineAll[T: Monoid](xs: List[T]): T = | ||||||||||
xs.foldLeft(Monoid[T].unit)(_ combine _) | ||||||||||
``` | ||||||||||
|
||||||||||
We can also benefit from [extension methods](extension-methods-new.html) to make this `combineAll` function accessible as a method on the `List` type: | ||||||||||
|
||||||||||
|
||||||||||
```scala | ||||||||||
def [T: Monoid](xs: List[T]).combineAll: T = | ||||||||||
xs.foldLeft(Monoid[T].unit)(_ combine _) | ||||||||||
``` | ||||||||||
|
||||||||||
Which allows one to write: | ||||||||||
|
||||||||||
```scala | ||||||||||
assert("ab" == List("a", "b").combineAll) | ||||||||||
``` | ||||||||||
or: | ||||||||||
```scala | ||||||||||
assert(3 == List(1, 2).combineAll) | ||||||||||
``` | ||||||||||
|
||||||||||
### Functors: | ||||||||||
|
||||||||||
A `Functor` for a type provides the ability for its values to be "mapped over", i.e. apply a function that transforms inside a value while remembering its shape. For example, to modify every element of a collection without dropping or adding elements. | ||||||||||
We can represent all types that can be "mapped over" with `F`. It's a type constructor: the type of its values becomes concrete when provided a type argument. | ||||||||||
Therefore we write it `F[_]`, hinting that it is a type with internal details we can inspect. | ||||||||||
The definition of a generic `Functor` would thus be written as: | ||||||||||
|
||||||||||
```scala | ||||||||||
trait Functor[F[_]] { | ||||||||||
def map[A, B](original: F[A], mapper: A => B): F[B] | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
Which could read as follows: "A `Functor` for the type constructor `F[_]` represents the ability to transform `F[A]` to `F[B]` through the application of the `mapper` function whose type is `A => B`". We call the `Functor` definition here a _typeclass_. | ||||||||||
This way, we could define an instance of `Functor` for the `List` type: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
```scala | ||||||||||
given Functor[List] { | ||||||||||
def map[A, B](original: List[A], mapper: A => B): List[B] = | ||||||||||
original.map(mapper) // List already has a `map` method | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
With this `given` instance in scope, everywhere a `Functor` is expected, the compiler will accept a `List` to be used. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
For instance, we may write such a testing method: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. idk, too many instance words floating around
Suggested change
|
||||||||||
```scala | ||||||||||
def assertTransformation[F[_]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit = | ||||||||||
assert(expected == summon[Functor[F]].map(original, mapping)) | ||||||||||
``` | ||||||||||
|
||||||||||
And use it this way, for example: | ||||||||||
|
||||||||||
```scala | ||||||||||
assertTransformation(List("a1", "b1"), List("a", "b"), elt => s"${elt}1") | ||||||||||
``` | ||||||||||
|
||||||||||
That's a first step, but in practice we probably would like the `map` function to be a method directly accessible on the type `F`. So that we can call `map` directly on instances of `F`, and get rid of the `summon[Functor[F]]` part. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
As in the previous example of Monoids, [`extension` methods](extension-methods-new.html) help achieving that. Let's re-define the `Functor` _typeclass_ with extension methods. | ||||||||||
|
||||||||||
```scala | ||||||||||
trait Functor[F[_]] { | ||||||||||
def [A, B](original: F[A]).map(mapper: A => B): F[B] | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
The instance of `Functor` for `List` now becomes: | ||||||||||
|
||||||||||
```scala | ||||||||||
given Functor[List] { | ||||||||||
def [A, B](original: List[A]).map(mapper: A => B): List[B] = | ||||||||||
original.map(mapper) // List already has a `map` method | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
It simplifies the `assertTransformation` method: | ||||||||||
|
||||||||||
```scala | ||||||||||
def assertTransformation[F[_]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit = | ||||||||||
assert(expected == original.map(mapping)) | ||||||||||
``` | ||||||||||
|
||||||||||
The `map` method is now directly used on `original` since it is of type `F[A]` (where `F` is a `Functor`). | ||||||||||
|
||||||||||
|
||||||||||
### Monads | ||||||||||
|
||||||||||
Now we have a `Functor` for `List`. | ||||||||||
|
||||||||||
Applying the `List.map` ability with the following mapping function as parameter: `mapping: A => B` would result in a `List[B]`. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
Now, applying the `List.map` ability with the following mapping function as parameter: `mapping: A => List[B]` would result in a `List[List[B]]`. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
To avoid avoid managing lists of lists, we may want to "flatten" the values in a single list. | ||||||||||
|
||||||||||
That's where `Monad` enters the party. A `Monad` for type `F[_]` is a `Functor[F]` with 2 more abilities: | ||||||||||
* the flatten ability we just described: turning `F[A]` to `F[B]` when given a `mapping: A => F[B]` function | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
* the ability to create `F[A]` from a single value `A` | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
Here is the translation of this definition in Scala 3: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
```scala | ||||||||||
trait Monad[F[_]] extends Functor[F] { // "A `Monad` for type `F[_]` is a `Functor[F]`" => thus has the `map` ability | ||||||||||
def pure[A](x: A): F[A] // `pure` can construct F[A] from a single value A | ||||||||||
def [A, B](x: F[A]).flatMap(f: A => F[B]): F[B] // the flattening ability is named `flatMap`, using extension methods as previous examples | ||||||||||
def [A, B](x: F[A]).map(f: A => B) = x.flatMap(f `andThen` pure) // the `map(f)` ability is simply a combination of applying `f` then turning the result into an `F[A]` then applying `flatMap` to it | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
#### List | ||||||||||
|
||||||||||
Let us declare the `Monad` ability for type `List` | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
```scala | ||||||||||
given listMonad: Monad[List] { | ||||||||||
def pure[A](x: A): List[A] = | ||||||||||
List(x) | ||||||||||
def [A, B](xs: List[A]).flatMap(f: A => List[B]): List[B] = | ||||||||||
xs.flatMap(f) // let's rely on the existing `flatMap` method of `List` | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
`map` implementation is no longer needed. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
#### Option | ||||||||||
|
||||||||||
`Option` is an other type having the same kind of behaviour: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
* the `map` ability turning `Option[A]` into `Option[B]` if passed a function `f: A => B` | ||||||||||
* the `flatMap` ability turning `Option[A]` into `Option[B]` if passed a function `f: A => Option[B]` | ||||||||||
* the `pure` ability turning `A` into `Option[A]` | ||||||||||
|
||||||||||
```scala | ||||||||||
given optionMonad: Monad[Option] { | ||||||||||
def pure[A](x: A): Option[A] = | ||||||||||
Option(x) | ||||||||||
def [A, B](xs: Option[A]).flatMap(f: A => Option[B]): Option[B] = | ||||||||||
xs.flatMap(f) // let's rely on the existing `flatMap` method of `Option` | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
#### The Reader Monad | ||||||||||
|
||||||||||
Another example of a `Monad` is the Reader Monad. It no longer acts on a type like `List` or `Option`, but on a function. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
It can be used for example for combining functions that all need the same type of parameter. For instance multiple functions needing access to some configuration, context, environment variables, etc. | ||||||||||
|
||||||||||
Let us have a `Config` type, and two functions using it: | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
```scala | ||||||||||
trait Config | ||||||||||
def compute(i: Int)(config: Config): String = ??? | ||||||||||
def show(str: String)(config: Config): Unit = ??? | ||||||||||
``` | ||||||||||
|
||||||||||
We may want to combine `compute` and `show` into a single function, accepting a `Config` as parameter, and showing the result of the computation. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
If we had a `flatMap` function as in the examples above, we would be able to write the following: | ||||||||||
|
||||||||||
```scala | ||||||||||
def computeAndShow(i: Int): Config => Unit = compute(i).flatMap(show) | ||||||||||
``` | ||||||||||
|
||||||||||
Let's define this `Monad` then. First, we are going to define a type named `ConfigDependent` representing a function that when passed a `Config` produces a `Result`. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
```scala | ||||||||||
trait Config // the Config defined above | ||||||||||
type ConfigDependent[Result] = Config => Result | ||||||||||
``` | ||||||||||
|
||||||||||
The monad will look like this: | ||||||||||
|
||||||||||
```scala | ||||||||||
given configDependentMonad as Monad[ConfigDependent] | ||||||||||
def [A, B](r: ConfigDependent[A]).flatMap(f: A => ConfigDependent[B]): ConfigDependent[B] = | ||||||||||
config => f(r(config))(config) | ||||||||||
def pure[A](x: A): ConfigDependent[A] = | ||||||||||
config => x | ||||||||||
``` | ||||||||||
|
||||||||||
The type `ConfigDependent` can be written using [type lambdas](../new-types/type-lambdas.html): | ||||||||||
|
||||||||||
```scala | ||||||||||
type ConfigDependent = [Result] =>> Config => Result | ||||||||||
``` | ||||||||||
|
||||||||||
Using this syntax would turn the previous `configReaderMonad` into: | ||||||||||
|
||||||||||
|
||||||||||
```scala | ||||||||||
given configDependentMonad as Monad[[Result] =>> Config => Result] | ||||||||||
def [A, B](r: Config => A).flatMap(f: A => Config => B): Config => B = | ||||||||||
config => f(r(config))(config) | ||||||||||
def pure[A](x: A): Config => A = | ||||||||||
config => x | ||||||||||
``` | ||||||||||
|
||||||||||
|
||||||||||
|
||||||||||
It is likely that we would like to use this pattern with other kinds of environments than our `Config` trait. The Reader monad allows us to abstract away `Config` as a type _parameter_, named `Ctx` in the following definition: | ||||||||||
|
||||||||||
```scala | ||||||||||
given readerMonad[Ctx] as Monad[[X] =>> Ctx => X] { | ||||||||||
def [A, B](r: Ctx => A).flatMap(f: A => Ctx => B): Ctx => B = | ||||||||||
ctx => f(r(ctx))(ctx) | ||||||||||
def pure[A](x: A): Ctx => A = | ||||||||||
ctx => x | ||||||||||
} | ||||||||||
``` | ||||||||||
|
||||||||||
### Summary | ||||||||||
|
||||||||||
The definition of a _typeclass_ is expressed via a parameterised type with abstract members, such as a `trait`. | ||||||||||
The main difference between object oriented polymorphism, and ad-hoc polymorphism with _typeclasses_, is how the definition of the _typeclass_ is implemented, in relation to the type it acts upon. | ||||||||||
In the case of a _typeclass_, its implementation for a concrete type is expressed through a `given` term definition, which is supplied as an implicit argument alongside the value it acts upon. With object oriented polymorphism, the implementation is mixed into the parents of a class, and only a single term is required to perform a polymorphic operation. | ||||||||||
|
||||||||||
To conclude, in addition to given instances, other constructs like extension methods, context bounds and type lambdas allow a concise and natural expression of _typeclasses_. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for doing this, it's a lot more with the style of the rest of the documentation. If you apply the same style to the Functors and Monads section this will be really nice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK I'm not sure I have enough knowledge to re-write the Functor/Monad part in the same fashion (you need to understand properly to explain properly) but I will definitely give it a try.
If I'm struggling I'll ping you, so that you know you can merge this as is (best if the ennemy of good ;) ). But that's worth trying.