Skip to content

Commit 3eafc18

Browse files
authored
Merge pull request #8147 from aesteve/doc/rework-typeclasses-new
[Documentation] Trying to rewrite typeclasses-new
2 parents 25c0305 + 219bc3c commit 3eafc18

File tree

1 file changed

+228
-16
lines changed

1 file changed

+228
-16
lines changed

docs/docs/reference/contextual/typeclasses.md

Lines changed: 228 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ layout: doc-page
33
title: "Implementing Typeclasses"
44
---
55

6-
Given instances, extension methods and context bounds
7-
allow a concise and natural expression of _typeclasses_. Typeclasses are just traits
8-
with canonical implementations defined by given instances. Here are some examples of standard typeclasses:
6+
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:
7+
* expressing how a type you don't own (from the standard or 3rd-party library) conforms to such behavior
8+
* 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)
9+
10+
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**.
11+
Here are some examples of usual typeclasses:
912

1013
### Semigroups and monoids:
1114

15+
Here's the `Monoid` typeclass definition:
16+
1217
```scala
1318
trait SemiGroup[T] {
1419
def (x: T) combine (y: T): T
@@ -17,50 +22,257 @@ trait SemiGroup[T] {
1722
trait Monoid[T] extends SemiGroup[T] {
1823
def unit: T
1924
}
25+
```
2026

21-
object Monoid {
22-
def apply[T](using m: Monoid[T]) = m
23-
}
27+
An implementation of this `Monoid` typeclass for the type `String` can be the following:
2428

29+
```scala
2530
given Monoid[String] {
2631
def (x: String) combine (y: String): String = x.concat(y)
2732
def unit: String = ""
2833
}
34+
```
2935

36+
Whereas for the type `Int` one could write the following:
37+
```scala
3038
given Monoid[Int] {
3139
def (x: Int) combine (y: Int): Int = x + y
3240
def unit: Int = 0
3341
}
42+
```
43+
44+
This monoid can now be used as _context bound_ in the following `combineAll` method:
45+
46+
```scala
47+
def combineAll[T: Monoid](xs: List[T]): T =
48+
xs.foldLeft(summon[Monoid[T]].unit)(_ combine _)
49+
```
50+
51+
To get rid of the `summon[...]` we can define a `Monoid` object as follows:
52+
53+
```scala
54+
object Monoid {
55+
def apply[T](using m: Monoid[T]) = m
56+
}
57+
```
3458

35-
def sum[T: Monoid](xs: List[T]): T =
59+
Which would allow to re-write the `combineAll` method this way:
60+
61+
```scala
62+
def combineAll[T: Monoid](xs: List[T]): T =
3663
xs.foldLeft(Monoid[T].unit)(_ combine _)
3764
```
3865

39-
### Functors and monads:
66+
We can also benefit from [extension methods](extension-methods-new.html) to make this `combineAll` function accessible as a method on the `List` type:
67+
68+
69+
```scala
70+
def [T: Monoid](xs: List[T]).combineAll: T =
71+
xs.foldLeft(Monoid[T].unit)(_ combine _)
72+
```
73+
74+
Which allows one to write:
75+
76+
```scala
77+
assert("ab" == List("a", "b").combineAll)
78+
```
79+
or:
80+
```scala
81+
assert(3 == List(1, 2).combineAll)
82+
```
83+
84+
### Functors:
85+
86+
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.
87+
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.
88+
Therefore we write it `F[?]`, hinting that it is a type with internal details we can inspect.
89+
The definition of a generic `Functor` would thus be written as:
90+
91+
```scala
92+
trait Functor[F[?]] {
93+
def map[A, B](original: F[A], mapper: A => B): F[B]
94+
}
95+
```
96+
97+
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_.
98+
This way, we could define an instance of `Functor` for the `List` type:
99+
100+
```scala
101+
given Functor[List] {
102+
def map[A, B](original: List[A], mapper: A => B): List[B] =
103+
original.map(mapper) // List already has a `map` method
104+
}
105+
```
106+
107+
With this `given` instance in scope, everywhere a `Functor` is expected, the compiler will accept a `List` to be used.
40108

109+
For instance, we may write such a testing method:
41110
```scala
42-
trait Functor[F[_]] {
43-
def [A, B](x: F[A]).map(f: A => B): F[B]
111+
def assertTransformation[F[?]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit =
112+
assert(expected == summon[Functor[F]].map(original, mapping))
113+
```
114+
115+
And use it this way, for example:
116+
117+
```scala
118+
assertTransformation(List("a1", "b1"), List("a", "b"), elt => s"${elt}1")
119+
```
120+
121+
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.
122+
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.
123+
124+
```scala
125+
trait Functor[F[?]] {
126+
def [A, B](original: F[A]).map(mapper: A => B): F[B]
44127
}
128+
```
129+
130+
The instance of `Functor` for `List` now becomes:
131+
132+
```scala
133+
given Functor[List] {
134+
def [A, B](original: List[A]).map(mapper: A => B): List[B] =
135+
original.map(mapper) // List already has a `map` method
136+
}
137+
```
138+
139+
It simplifies the `assertTransformation` method:
45140

46-
trait Monad[F[_]] extends Functor[F] {
47-
def [A, B](x: F[A]).flatMap(f: A => F[B]): F[B]
48-
def [A, B](x: F[A]).map(f: A => B) = x.flatMap(f `andThen` pure)
141+
```scala
142+
def assertTransformation[F[?]: Functor, A, B](expected: F[B], original: F[A], mapping: A => B): Unit =
143+
assert(expected == original.map(mapping))
144+
```
145+
146+
The `map` method is now directly used on `original` since it is of type `F[A]` (where `F` is a `Functor`).
147+
148+
149+
### Monads
150+
151+
Now we have a `Functor` for `List`.
152+
153+
Applying the `List.map` ability with the following mapping function as parameter: `mapping: A => B` would result in a `List[B]`.
154+
155+
Now, applying the `List.map` ability with the following mapping function as parameter: `mapping: A => List[B]` would result in a `List[List[B]]`.
49156

50-
def pure[A](x: A): F[A]
157+
To avoid avoid managing lists of lists, we may want to "flatten" the values in a single list.
158+
159+
That's where `Monad` enters the party. A `Monad` for type `F[?]` is a `Functor[F]` with 2 more abilities:
160+
* the flatten ability we just described: turning `F[A]` to `F[B]` when given a `mapping: A => F[B]` function
161+
* the ability to create `F[A]` from a single value `A`
162+
163+
Here is the translation of this definition in Scala 3:
164+
165+
```scala
166+
trait Monad[F[?]] extends Functor[F] { // "A `Monad` for type `F[?]` is a `Functor[F]`" => thus has the `map` ability
167+
def pure[A](x: A): F[A] // `pure` can construct F[A] from a single value A
168+
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
169+
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
51170
}
171+
```
172+
173+
#### List
52174

175+
Let us declare the `Monad` ability for type `List`
176+
```scala
53177
given listMonad as Monad[List] {
54-
def [A, B](xs: List[A]).flatMap(f: A => List[B]): List[B] =
55-
xs.flatMap(f)
56178
def pure[A](x: A): List[A] =
57179
List(x)
180+
def [A, B](xs: List[A]).flatMap(f: A => List[B]): List[B] =
181+
xs.flatMap(f) // let's rely on the existing `flatMap` method of `List`
182+
}
183+
```
184+
185+
`map` implementation is no longer needed.
186+
187+
#### Option
188+
189+
`Option` is an other type having the same kind of behaviour:
190+
* the `map` ability turning `Option[A]` into `Option[B]` if passed a function `f: A => B`
191+
* the `flatMap` ability turning `Option[A]` into `Option[B]` if passed a function `f: A => Option[B]`
192+
* the `pure` ability turning `A` into `Option[A]`
193+
194+
```scala
195+
given optionMonad as Monad[Option] {
196+
def pure[A](x: A): Option[A] =
197+
Option(x)
198+
def [A, B](xs: Option[A]).flatMap(f: A => Option[B]): Option[B] =
199+
xs.flatMap(f) // let's rely on the existing `flatMap` method of `Option`
200+
}
201+
```
202+
203+
#### The Reader Monad
204+
205+
Another example of a `Monad` is the Reader Monad. It no longer acts on a type like `List` or `Option`, but on a function.
206+
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.
207+
208+
Let us have a `Config` type, and two functions using it:
209+
210+
```scala
211+
trait Config
212+
// ...
213+
def compute(i: Int)(config: Config): String = ???
214+
def show(str: String)(config: Config): Unit = ???
215+
```
216+
217+
We may want to combine `compute` and `show` into a single function, accepting a `Config` as parameter, and showing the result of the computation.
218+
If we had a `flatMap` function as in the examples above, we would be able to write the following:
219+
220+
```scala
221+
def computeAndShow(i: Int): Config => Unit = compute(i).flatMap(show)
222+
```
223+
224+
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`.
225+
226+
```scala
227+
trait Config // the Config defined above
228+
type ConfigDependent[Result] = Config => Result
229+
```
230+
231+
The monad will look like this:
232+
233+
```scala
234+
given configDependentMonad as Monad[ConfigDependent] {
235+
def [A, B](r: ConfigDependent[A]).flatMap(f: A => ConfigDependent[B]): ConfigDependent[B] =
236+
config => f(r(config))(config)
237+
def pure[A](x: A): ConfigDependent[A] =
238+
config => x
58239
}
240+
```
241+
242+
The type `ConfigDependent` can be written using [type lambdas](../new-types/type-lambdas.html):
59243

244+
```scala
245+
type ConfigDependent = [Result] =>> Config => Result
246+
```
247+
248+
Using this syntax would turn the previous `configReaderMonad` into:
249+
250+
251+
```scala
252+
given configDependentMonad as Monad[[Result] =>> Config => Result]
253+
def [A, B](r: Config => A).flatMap(f: A => Config => B): Config => B =
254+
config => f(r(config))(config)
255+
def pure[A](x: A): Config => A =
256+
config => x
257+
```
258+
259+
260+
261+
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:
262+
263+
```scala
60264
given readerMonad[Ctx] as Monad[[X] =>> Ctx => X] {
61265
def [A, B](r: Ctx => A).flatMap(f: A => Ctx => B): Ctx => B =
62266
ctx => f(r(ctx))(ctx)
63267
def pure[A](x: A): Ctx => A =
64268
ctx => x
65269
}
66270
```
271+
272+
### Summary
273+
274+
The definition of a _typeclass_ is expressed via a parameterised type with abstract members, such as a `trait`.
275+
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.
276+
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.
277+
278+
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_.

0 commit comments

Comments
 (0)