When I discover or want to explain a concept, I quite like to rely on a Kata, a short exercise that highlight a programming practice. I therefore propose the DnD Kata, whose objective is to model a team of characters from the Dungeon and Dragons role-playing game. Of course, modeling the set of rules is a complex exercise.
We will content ourselves here with:
1) representing a character by:
- his/her name
- his/her race
- his/her skills, including and of course his racial bonuses
2) A team is a collection of characters, which can be of different races.
The objective of this solution is to illustrate OCaml functors and demonstrate how they contribute to applying the S.O.L.I.D principles in OCaml.
This post is a translation from my blog post: https://oteku.github.io/ocaml-functors/ (FR)
About DnD
Dunjon & Dragons a.k.a DnD is a role playing game where players play heroes in a fantasy setup.
The main setup for this game is Faerûn, a continent of the Abeir-Toril planet.
We will use the Dungeons & Dragons 5th Edition System under the Open-Gaming License (OGL).
We are the Dwarves
First we want to modelize Dwarves, one of the playable races in Faerûn, by their names.
We already know that a good way to have a namespace in OCaml is to use modules, so we can start with this representation:
module Dwarf = struct
type t = string
end
In this implementation, the type of the module is infered. We can also make it explicit by adding a module signature and modelize Elves at the same time:
module Dwarf : sig
type t = string
end = struct
type t = string
end
module Elf : sig
type t = string
end = struct
type t = string
end
At this step we notice that the 2 modules are sharing the same signature. Since both Elf and Dwarf modules are representing playable heroes, it seems legit and we would make explicit that all playable heroes are sharing the same signature. To do that we can use a module type:
module type PLAYABLE = sig
type t = string
end
module Dwarf : PLAYABLE = struct
type t = string
end
module Elf : PLAYABLE = struct
type t = string
end
Other modules do not need to know the shape of a PLAYABLE.t
, they only need to know it exists and the module should expose functions to work with it.
We call this make an abstraction:
module type PLAYABLE = sig
type t
val to_string : t -> string
val of_string : string -> t
end
Now each module of type PLAYABLE must implement those functions. Let's do it:
module Dwarf : PLAYABLE = struct
type t = {name : string}
let to_string dwarf = dwarf.name
let of_string name = {name}
end
module Elf : PLAYABLE = struct
type t = string
let to_string elf = elf
let of_string name = name
end
Since t
is abstract, you may notice that each module implementing PLAYABLE
may have a different concret type for t
. It's totally fine while they respect their module type contract.
Other modules cannot access a concrete value of t
, but we can create a dwarf or get a string representation.
let gimly = Dwarf.of_string "Gimly"
let () = Dwarf.to_string gimply |> print_endline
Heroes have abilities
In DnD, a Hero is also represented by its abilities.
There is several option rules for abilities at the creation, we will only implement the Standard scores one. At the beginning each ability have a value of 10:
module Abilities = struct
type t = {
strength : int
; dexterity : int
; constitution : int
; intelligence : int
; wisdom : int
; charisma : int
}
let init () = {
strength = 10
; dexterity = 10
; constitution = 10
; intelligence = 10
; wisdom = 10
; charisma = 10
}
end
We can upgrade our Dwarf modules this way:
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let to_string dwarf = dwarf.name
let of_string name = {name ; abilities = Abilities.init()}
end
Naming for our function is no more logical, so we will update PLAYABLE
module type and then Elf
and Dwarf
modules:
module type PLAYABLE = sig
type t
val name : t -> string
val make : string -> t
end
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name dwarf = dwarf.name
let make name = {name ; abilities = Abilities.init()}
end
module Elf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name elf = elf.name
let make name = {name ; abilities = Abilities.init()}
end
Races give modifiers
The Darves have a constition bonus +2.
In OCaml, modules are first-class, it means you can use module as value. So we can create a new module type to represent a bonus and functions to represent a bonus of 2:
module type BONUS = sig
type t
val value : t
end
let bonus_2 : (module BONUS with type t = int) = (module struct
type t = int
let value = 2
end)
bonus_2
is a module as value. Because t
is abstract we must add a type witness with type t = int
.
To unwrap the value of the bonus we also need a getter:
let get_bonus b = let module M = (val (b : (module BONUS with type t = int))) in M.value
If you need more explaination about First-Class, you should read : https://dev.realworldocaml.org/first-class-modules.html
Now we can write:
module Dwarf: PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name dwarf = dwarf.name
let make name = {name ; abilities = Abilities.init()}
let constitution dwarf = dwarf.abilities.constitution + get_bonus bonus_2
end
Also are Elves, Half-orc, Halflings, Tieflings
Dwarves are not the only race in Faerun. Each have a different constitution bonus. Half orcs have +1 while Elves, Halflings and Tieflings don't have constitution bonus.
When data varies inside a function we add a function parameter to avoid code duplication. We can do the same at module level. OCaml provides functors which are functional modules : function from module to module.
So we can create a Race
functor:
module Race (B : BONUS with type t = int) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let constitution_bonus = B.value (* here we get the value from module B *)
let constitution character = character.abilities.constitution + constitution_bonus
end
You read this as : the functor Race
take a module B
of type BONUS
whom type t
is int
as parameter and then return a module of type PLAYBLE
.
Then we can easily have our modules:
(* we add a function to manage all bonus *)
let bonus (x:int) : (module BONUS with type t = int) = (module struct
type t = int
let value = x
end)
(* we use our Race functor to create the five races *)
module Dwarf = Race (val bonus 2)
module Elf = Race (val bonus 0)
module Tiefling = Race (val bonus 0)
module Halfling = Race (val bonus 0)
module HalfOrc = Race (val bonus 1)
All abilities may have bonus
Functors are not limited to one parameter, so we can use the same trick to manage all bonuses:
module Race
(BS : BONUS with type t = int)
(BD : BONUS with type t = int)
(BC : BONUS with type t = int)
(BI : BONUS with type t = int)
(BW : BONUS with type t = int)
(BCh : BONUS with type t = int) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = BS.value
; dexterity = BD.value
; constitution = BC.value
; intelligence = BI.value
; wisdom = BW.value
; charisma = BCh.value
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
module Dwarf = Race (val bonus 0) (val bonus 0) (val bonus 2)(val bonus 0) (val bonus 0) (val bonus 0)
For your use case it's not so convenient, we have to remember the order of bonuses. We already have a type that represent all abilities values Abilities.t
, just use it instead of int
:
(* just create a bonus function that take a Abilities.t and return a Bonus module *)
let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) = (module struct
type t = Abilities.t
let value = x
end)
(* the functor `Race` take a module `B` of type `BONUS` whom type `t` is `Abilities.t`
** as parameter and then return a module of type `PLAYBLE` *)
module Race
(B : BONUS with type t = Abilities.t) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = B.value.strength
; dexterity = B.value.dexterity
; constitution = B.value.constitution
; intelligence = B.value.intelligence
; wisdom = B.value.wisdom
; charisma = B.value.charisma
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
(* create our Dwarf module *)
module Dwarf = Race (val bonus Abilities.{
strength = 0
; dexterity = 0
; constitution = 2
; intelligence = 0
; wisdom = 0
; charisma = 0
})
To be more concise and explicit we can work from a no_bonus
value:
let no_bonus = Abilities.{
strength = 0
; dexterity = 0
; constitution = 0
; intelligence = 0
; wisdom = 0
; charisma = 0
}
module Dwarf = Race (val bonus Abilities.{
no_bonus with constitution = 2
})
module Elf = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Halfling = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Tiefling = Race (val bonus Abilities.{
no_bonus with charisma = 2 ; intelligence = 1
})
module HalfOrc = Race (val bonus Abilities.{
no_bonus with strength = 2
})
Summary
At the end of this section you should have:
module Abilities = struct
type t = {
strength : int
; dexterity : int
; constitution : int
; intelligence : int
; wisdom : int
; charisma : int
}
let init () = {
strength = 10
; dexterity = 10
; constitution = 10
; intelligence = 10
; wisdom = 10
; charisma = 10
}
end
module type BONUS = sig
type t
val value : t
end
let bonus (x:Abilities.t) : (module BONUS with type t = Abilities.t) =
(module struct
type t = Abilities.t
let value = x
end)
let no_bonus = Abilities.{
strength = 0
; dexterity = 0
; constitution = 0
; intelligence = 0
; wisdom = 0
; charisma = 0
}
module type PLAYABLE = sig
type t
val make : string -> t
val name : t -> string
val abilities : t -> Abilities.t
end
module Race
(B : BONUS with type t = Abilities.t) : PLAYABLE = struct
type t = {name : string ; abilities : Abilities.t}
let name character = character.name
let make name = {name ; abilities = Abilities.init()}
let bonus = Abilities.{
strength = B.value.strength
; dexterity = B.value.dexterity
; constitution = B.value.constitution
; intelligence = B.value.intelligence
; wisdom = B.value.wisdom
; charisma = B.value.charisma
}
let abilities character = Abilities.{
strength = character.abilities.strength + bonus.strength
; dexterity = character.abilities.dexterity + bonus.dexterity
; constitution = character.abilities.constitution + bonus.constitution
; intelligence = character.abilities.intelligence + bonus.intelligence
; wisdom = character.abilities.wisdom + bonus.wisdom
; charisma = character.abilities.charisma + bonus.charisma
}
end
module Dwarf = Race (val bonus Abilities.{
no_bonus with constitution = 2
})
module Elf = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Halfling = Race (val bonus Abilities.{
no_bonus with dexterity = 2
})
module Tiefling = Race (val bonus Abilities.{
no_bonus with charisma = 2 ; intelligence = 1
})
module HalfOrc = Race (val bonus Abilities.{
no_bonus with strength = 2
})
(* We can add new race with ease. Humans have +1 for all abilities *)
module Human = Race (val bonus Abilities.{
strength = 1
; dexterity = 1
; constitution = 1
; intelligence = 1
; wisdom = 1
; charisma = 1
})
United color of Faerûn
Each player may play a character from different race. How to modelize a team ?
The companions of the Hall
The companions is a book from R.A. Salvatore a novelist who has written many novels set in Faerûn
We can create value for our teammates:
let catti = Human.make "Catti-brie"
let regis = Halfling.make "Regis"
let brenor = Dwarf.make "Bruenor Battlehammer"
let wulfgar = Human.make "Wulfgar"
let drizzt = Elf.make "Drizzt Do'Urden"
What if we create the companions:
❌ let companions = [catti; regis; bruenor; wulfgar; drizzt]
Error: This expression has type HalfLing.t but an expression was expected of type
Human.t
Remember the type of list
has type type 'a t = 'a list
, inference engine set'a = Human.t
because it's the type of he first element of our list catti
, but regis
type is Halfling.t
.
How could we help the compiler ? Type parameters must be concrete types.
(* won't compile PLAYABLE is a module type *)
❌ type team = PLAYABLE.t list
(* won't compile RACE is a functor
** aka a function from module to module *)
❌ type team = RACE.t list
In reality, there is nothing too complicated, the main point is that OCaml lists are monomorphic, so we need a unique type that can represent a character, whatever their race:
type buddy =
| Dwarf of Dwarf.t
| Elf of Elf.t
| Halfling of Halfling.t
| Tiefling of Tiefling.t
| HalfOrc of HalfOrc.t
| Human of Human.t
let companions = [Human catti; Halfling regis; Dwarf bruenor; Human wulfgar; Elf drizzt]
However, there are many other races in Faerûn, as well as variants. Drizzt for example is actually a dark elf and not an elf. It would be more appropriate to use polymorphic variants in order to facilitate the extensions of our library, because we are still at the early stage of a real character generator:
let companions_final =
[`Human catti; `Halfling regis; `Dwarf bruenor; `Human wulfgar; `Elf drizzt]
whose type will be
val companions_final :
[> `Dwarf of Dwarf.t
| `Elf of Elf.t
| `Halfling of Halfling.t
| `Human of Human.t ]
list =
[`Human <abstr>; `Halfling <abstr>; `Dwarf <abstr>; `Human <abstr>;
`Elf <abstr>]
Take away
1 - OCaml provides abstractions for:
- namespaces: module
- protocole: module type
- extension: functor
- default value or implementation: functor or first-class module
- functors are function from module to module
- first-class modules are values and give a way to communicate between the type level and the module level. Exemple: a function from value to module.
2 - S.O.L.I.D is not only a OOP good pratice:
- Single responsibility principle => use module
- Open/closed principle => use module
- Liskov substitution principle => use module type
- Interface segregation principle => use module type
- Dependency inversion principle => use functor
Top comments (1)
Absolutely fantastic. Thank you. I had to port it to reason to ge the most out of it. github.com/idkjs/reason-functors-kata