Skip to main content

Provider Explicit

In this tutorial, we draw a parallel between a way to use the provider library, and modular explicit.

Introduction

Modular-explicit allows module-dependent functions to take a module implementing a Trait signature as an argument and use a type from the module to annotate subsequent arguments. For example:

module type Id = sig
type t

val id : t -> t
end

let id (module A : Id) (x : A.t) = A.id x ;;
Line 7, characters 23-32:
7 | let id (module A : Id) (x : A.t) = A.id x ;;
                           ^^^^^^^^^
Error: This pattern matches values of type A.t
       but a pattern was expected which matches values of type 'a
       The type constructor A.t would escape its scope

As you can see above, constructs of these kinds are currently not in the language, but they are introduced in the version 5.5 of OCaml. We'll make sure to update that part of the doc when we migrate to these new features!

Back to our tutorial: we titled it provider-explicit in reference to this. In the pattern we present here, functions take an additional provider argument to achieve a similar type-class style parametrization.

In a nutshell:

module type Id = sig
type t

val id : t -> t
end

type id = [ `Id ]

module Id : sig
val t : ('a, (module Id with type t = 'a), [> id ]) Provider.Trait.t
end = Provider.Trait.Create (struct
type 'a module_type = (module Id with type t = 'a)
end)

let id : type a. (a, [> id ]) Provider.t -> a -> a =
fun provider x ->
let module M = (val Provider.lookup provider ~trait:Id.t) in
M.id x
;;

In the rest of the tutorial, we cover this pattern in greater details and demonstrate how to use it with providers that implement multiple traits. We also provide examples where the Trait type is parametrized.

Let's jump in!

Functional providers

In the getting-started tutorial, we explored a scenario where providers were bundled with the value on which the Traits operate. Whether the functions exported by the Traits interfaces mutate the t value or not, this approach closely resembles how objects work in Object-Oriented languages.

In contrast, this tutorial focuses on manipulating providers directly, without bundling them with values. This allows us to work with Traits that contain purely functional functions.

Defining Traits

Imagine we start with the following Trait:

module type Doublable = sig
type t

val double : t -> t
end

We define the expected Provider machinery, including a Provider.Trait for it:

type doublable = [ `Doublable ]

module Doublable : sig
val t : ('a, (module Doublable with type t = 'a), [> doublable ]) Provider.Trait.t
end = Provider.Trait.Create (struct
type 'a module_type = (module Doublable with type t = 'a)
end)

Writing Parametrized Code

With no dependencies on actual providers, we can define functionality depending on the Trait interface only. This may look like this:

let quadruple : type a. (a, [> doublable ]) Provider.t -> a -> a =
fun provider t ->
let module M = (val Provider.lookup provider ~trait:Doublable.t) in
M.double (M.double t)
;;

Implementing Providers

Somewhere else, imagine we have modules implementing the expected signature:

module Doublable_int = struct
type t = int

let double x = x * 2
end

module Doublable_float = struct
type t = float

let double x = x *. 2.
end

We build providers values for these modules:

let doublable_int () : (int, [> doublable ]) Provider.t =
Provider.make [ Provider.implement Doublable.t ~impl:(module Doublable_int) ]
;;

let doublable_float () : (float, [> doublable ]) Provider.t =
Provider.make [ Provider.implement Doublable.t ~impl:(module Doublable_float) ]
;;

Instantiation

And now, it is time to instantiate!

let%expect_test "quadruple" =
Printf.printf "%d\n" (quadruple (doublable_int ()) 1);
[%expect {| 4 |}];
Printf.printf "%.1f\n" (quadruple (doublable_float ()) 2.1);
[%expect {| 8.4 |}];
()
;;

Multiple Traits

Let's define another Trait and write some code that require both Traits.

Defining Traits

module type Repeatable = sig
type t

val repeat : t -> t
end
type repeatable = [ `Repeatable ]

module Repeatable : sig
val t : ('a, (module Repeatable with type t = 'a), [> repeatable ]) Provider.Trait.t
end = Provider.Trait.Create (struct
type 'a module_type = (module Repeatable with type t = 'a)
end)

Writing Parametrized Code

The function below requires both repeatable and doublable Traits:

let double_then_repeat : type a. (a, [> doublable | repeatable ]) Provider.t -> a -> a =
fun provider t ->
let module D = (val Provider.lookup provider ~trait:Doublable.t) in
let module R = (val Provider.lookup provider ~trait:Repeatable.t) in
t |> D.double |> R.repeat
;;

Implementing Providers

Let's create a module working on int that implements both Traits:

module Versatile_int = struct
type t = int

let double x = x * 2
let repeat x = int_of_string (string_of_int x ^ string_of_int x)
end

We can now build a provider for it:

let versatile_int () : (int, [> doublable | repeatable ]) Provider.t =
Provider.make
[ Provider.implement Doublable.t ~impl:(module Versatile_int)
; Provider.implement Repeatable.t ~impl:(module Versatile_int)
]
;;

The careful reader will note that this section requires careful handling, as there is no compiler assistance here. When defining providers, you must tag them correctly, or you may not be able to supply them to the functions you want, some traits may not be found at runtime, etc.

Instantiation

And now, time to instantiate!

let%expect_test "double_then_repeat" =
Printf.printf "%d\n" (double_then_repeat (versatile_int ()) 21);
[%expect {| 4242 |}];
()
;;

Parametrized types

In this part, we'll demonstrate how to write code that is parametrized by an interface working on a parametrized type, a concept known as higher-kinded polymorphism.

Consider values that can be mapped:

module type Mappable = sig
type 'a t

val map : 'a t -> f:('a -> 'b) -> 'b t
end

Imagine you want to write a function that applies the same mapping function multiple times for some reason.

This kind of higher-kinded polymorphism will be achievable using modular explicit. It might look something like this in the future:

let map_n_times (type a) (module A : Mappable) (x : a A.t) ~(f : a -> a) ~n =
let rec loop n x = if n = 0 then x else loop (n - 1) (A.map f x) in
loop n x
;;
val map_n_times : (module A : Mappable) -> 'a A.t -> f:('a -> 'a) -> n:int -> 'a = <fun>

In this section we show how to do this with the provider library, leveraging the higher_kinded library.

Defining Traits

We add the Higher_kinded machinery to our Trait signature, like so:

module type Mappable = sig
type 'a t

val map : 'a t -> f:('a -> 'b) -> 'b t

type higher_kinded

val inject : 'a t -> ('a -> higher_kinded) Higher_kinded.t
val project : ('a -> higher_kinded) Higher_kinded.t -> 'a t
end

We define a provider trait for this interface:

type mappable = [ `Mappable ]

Note, you cannot write this (the 'a 't syntax doesn't mean anything):

module Mappable : sig
val t : ('a 't, (module Mappable with type 'a t = 'a 't), [> mappable ]) Provider.Trait.t
end = Provider.Trait.Create (struct
type 'a 't module_type = (module Mappable with type 'a t = 'a 't)
end)
Line 2, characters 14-15:
2 |   val t : ('a 't, (module Mappable with type 'a t = 'a 't), [> mappable ]) Provider.Trait.t
                  ^
Error: Syntax error

This is where Higher_kinded comes to the rescue:

module Mappable : sig
val t
: ( ('a -> 'higher_kinded) Higher_kinded.t
, (module Mappable with type higher_kinded = 'higher_kinded)
, [> mappable ] )
Provider.Trait.t
end = Provider.Trait.Create1 (struct
type (!'higher_kinded, 'a) t = ('a -> 'higher_kinded) Higher_kinded.t

type 'higher_kinded module_type =
(module Mappable with type higher_kinded = 'higher_kinded)
end)

Writing Parametrized Code

That's it, we are on our way to write higher-kinded polymorphic functions:

let map_n_times
: type a t.
((a -> t) Higher_kinded.t, [> mappable ]) Provider.t
-> (a -> t) Higher_kinded.t
-> int
-> f:(a -> a)
-> (a -> t) Higher_kinded.t
=
fun provider t n ~f ->
let module M = (val Provider.lookup provider ~trait:Mappable.t) in
let at = M.project t in
let rec loop n at = if Int.equal n 0 then at else loop (n - 1) (M.map at ~f) in
M.inject (loop n at)
;;

Granted, writing the type is quite a journey :-). But the implementation looks clear enough, doesn't it?

Implementing Providers

To make it work with higher-kinded types, we'll invoke the functor Higher_kinded.Make to create ready-to-use modules with the expected Mappable signature:

module Higher_kinded_list = struct
include List
include Higher_kinded.Make (List)
end

module Higher_kinded_array = struct
include Array
include Higher_kinded.Make (Array)
end

We can verify that the modules indeed implement the expected signatures:

module _ : Mappable with type 'a t = 'a list = Higher_kinded_list
module _ : Mappable with type 'a t = 'a array = Higher_kinded_array

We build providers values for these modules:

let mappable_list ()
: (('a -> Higher_kinded_list.higher_kinded) Higher_kinded.t, [> mappable ]) Provider.t
=
Provider.make [ Provider.implement Mappable.t ~impl:(module Higher_kinded_list) ]
;;

let mappable_array ()
: (('a -> Higher_kinded_array.higher_kinded) Higher_kinded.t, [> mappable ]) Provider.t
=
Provider.make [ Provider.implement Mappable.t ~impl:(module Higher_kinded_array) ]
;;

Instantiation

And, again, time to instantiate our polymorphic code!

let%expect_test "map_n_times" =
map_n_times
(mappable_list ())
(List.init 10 ~f:Fun.id |> Higher_kinded_list.inject)
3
~f:(fun x -> x + 1)
|> Higher_kinded_list.project
|> List.iter ~f:(fun x -> Printf.printf "%d " x);
[%expect {| 3 4 5 6 7 8 9 10 11 12 |}];
map_n_times
(mappable_array ())
([| "a"; "b" |] |> Higher_kinded_array.inject)
4
~f:(fun x -> x ^ x)
|> Higher_kinded_array.project
|> Array.iter ~f:(fun x -> Printf.printf "%s " x);
[%expect {| aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb |}];
()
;;

Conclusion

In this tutorial, we've demonstrated examples using the provider library that go beyond typical object-oriented patterns. We've shown how to write code parametrized by providers and how to make this work with purely functional functions, as well as with parametrized types.

These techniques should offer convenient ways to parametrize code depending on various needs, and we hope they'll find practical applications in your favorite projects!

We'll be keeping an eye on modular explicit too, and we're excited about the future of the module language!