949 Profunctor Intro
Functional Programming
Tutorial
The Problem
Introduce profunctors — abstractions that are covariant in their output type and contravariant in their input type. Implement a concrete Mapper<A, B> struct (wrapping A -> B) with dimap, lmap (contramap input), and rmap (covariant map output). Also implement Star<A, B> (wrapping A -> Option<B>) to show the same profunctor pattern in a richer context.
🎯 Learning Outcomes
dimap :: (C -> A) -> (B -> D) -> p A B -> p C Ddimap f g p = g ∘ p ∘ f — pre-compose input adapter, post-compose output adapterlmap as dimap f id (contramap — adapt only the input)rmap as dimap id g (covariant map — adapt only the output)Profunctor trait (no HKT) and how to work around it with concrete typesCode Example
#![allow(clippy::all)]
// Profunctor: contravariant in input, covariant in output.
//
// A profunctor `p a b` supports:
// dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
//
// Functions `a -> b` are the classic example:
// dimap f g p = g . p . f ("adapt input with f, output with g")
//
// Rust can't express full HKT profunctors, but we show the concept
// with a concrete `Mapper<A, B>` struct + dimap method.
// ── Concrete Mapper ──────────────────────────────────────────────────────────
pub struct Mapper<A, B> {
f: Box<dyn Fn(A) -> B>,
}
impl<A: 'static, B: 'static> Mapper<A, B> {
pub fn new<F: Fn(A) -> B + 'static>(f: F) -> Self {
Mapper { f: Box::new(f) }
}
pub fn apply(&self, a: A) -> B {
(self.f)(a)
}
/// dimap: pre-compose with `pre` (contramap input), post-compose with `post` (map output).
/// dimap f g p = post ∘ p ∘ pre
pub fn dimap<C: 'static, D: 'static>(
self,
pre: impl Fn(C) -> A + 'static,
post: impl Fn(B) -> D + 'static,
) -> Mapper<C, D> {
Mapper::new(move |c| post((self.f)(pre(c))))
}
/// lmap: adapt only the input (contramap) — dimap f id
pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Mapper<C, B> {
Mapper::new(move |c| (self.f)(pre(c)))
}
/// rmap: adapt only the output (covariant map) — dimap id g
pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Mapper<A, D> {
Mapper::new(move |a| post((self.f)(a)))
}
}
// ── Star: Mapper lifted into a context ──────────────────────────────────────
// Star f a b = a -> f b (like Mapper but output is wrapped)
// Demonstrates the same dimap pattern in a richer context.
pub struct Star<A, B> {
run: Box<dyn Fn(A) -> Option<B>>,
}
impl<A: 'static, B: 'static> Star<A, B> {
pub fn new<F: Fn(A) -> Option<B> + 'static>(f: F) -> Self {
Star { run: Box::new(f) }
}
pub fn apply(&self, a: A) -> Option<B> {
(self.run)(a)
}
pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Star<C, B> {
Star::new(move |c| (self.run)(pre(c)))
}
pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Star<A, D> {
Star::new(move |a| (self.run)(a).map(|b| post(b)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lmap() {
let m = Mapper::new(|s: String| s.len()).lmap(|n: i32| n.to_string());
// 42.to_string() = "42", len = 2
assert_eq!(m.apply(42), 2);
}
#[test]
fn test_rmap() {
let m = Mapper::new(|s: String| s.to_uppercase()).rmap(|s: String| s.len());
assert_eq!(m.apply("hello".to_string()), 5);
}
#[test]
fn test_dimap() {
// dimap (to_string) (len) (to_uppercase)
// 7 -> "7" -> "7" -> 1
let m = Mapper::new(|s: String| s.to_uppercase())
.dimap(|n: i32| n.to_string(), |s: String| s.len());
assert_eq!(m.apply(7), 1);
}
#[test]
fn test_profunctor_identity_law() {
// dimap id id p = p
let p1 = Mapper::new(|x: i32| x * 2);
let p2 = Mapper::new(|x: i32| x * 2).dimap(|x| x, |x| x);
assert_eq!(p1.apply(21), p2.apply(21));
}
#[test]
fn test_star_lmap_rmap() {
let parse = Star::new(|s: String| s.parse::<i32>().ok()).rmap(|n| n + 10);
assert_eq!(parse.apply("5".to_string()), Some(15));
assert_eq!(parse.apply("bad".to_string()), None);
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Generic profunctor trait | Not possible without HKT | module type PROFUNCTOR with type parameter |
| Concrete implementation | Mapper<A,B> with methods | Module implementing the signature |
| Type erasure | Box<dyn Fn> | Not needed — closures are GC values |
| Composition | Method chaining: .lmap(...).rmap(...) | Function application: dimap pre post f |
| Laws | Tested with concrete values | Provable from module abstraction |
Profunctors generalize the idea of "things that can be mapped on both ends." They appear in optics (lenses/prisms), arrow composition, and parser combinators. The concrete Mapper is the simplest example.
OCaml Approach
(* Haskell: class Profunctor p where
dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
OCaml can express this with modules and functors *)
module type PROFUNCTOR = sig
type ('a, 'b) t
val dimap : ('c -> 'a) -> ('b -> 'd) -> ('a, 'b) t -> ('c, 'd) t
end
(* Functions are the canonical profunctor *)
module FnProfunctor : PROFUNCTOR with type ('a, 'b) t = 'a -> 'b = struct
type ('a, 'b) t = 'a -> 'b
let dimap pre post f = fun c -> post (f (pre c))
end
(* Using it *)
let double_string =
FnProfunctor.dimap
int_of_string (* pre: string -> int *)
string_of_int (* post: int -> string *)
(fun n -> n * 2) (* core: int -> int *)
(* double_string "21" = "42" *)
OCaml modules and functors allow a generic PROFUNCTOR interface. The implementation is more composable than Rust's concrete type approach, but requires more boilerplate for module instantiation.
Full Source
#![allow(clippy::all)]
// Profunctor: contravariant in input, covariant in output.
//
// A profunctor `p a b` supports:
// dimap :: (c -> a) -> (b -> d) -> p a b -> p c d
//
// Functions `a -> b` are the classic example:
// dimap f g p = g . p . f ("adapt input with f, output with g")
//
// Rust can't express full HKT profunctors, but we show the concept
// with a concrete `Mapper<A, B>` struct + dimap method.
// ── Concrete Mapper ──────────────────────────────────────────────────────────
pub struct Mapper<A, B> {
f: Box<dyn Fn(A) -> B>,
}
impl<A: 'static, B: 'static> Mapper<A, B> {
pub fn new<F: Fn(A) -> B + 'static>(f: F) -> Self {
Mapper { f: Box::new(f) }
}
pub fn apply(&self, a: A) -> B {
(self.f)(a)
}
/// dimap: pre-compose with `pre` (contramap input), post-compose with `post` (map output).
/// dimap f g p = post ∘ p ∘ pre
pub fn dimap<C: 'static, D: 'static>(
self,
pre: impl Fn(C) -> A + 'static,
post: impl Fn(B) -> D + 'static,
) -> Mapper<C, D> {
Mapper::new(move |c| post((self.f)(pre(c))))
}
/// lmap: adapt only the input (contramap) — dimap f id
pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Mapper<C, B> {
Mapper::new(move |c| (self.f)(pre(c)))
}
/// rmap: adapt only the output (covariant map) — dimap id g
pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Mapper<A, D> {
Mapper::new(move |a| post((self.f)(a)))
}
}
// ── Star: Mapper lifted into a context ──────────────────────────────────────
// Star f a b = a -> f b (like Mapper but output is wrapped)
// Demonstrates the same dimap pattern in a richer context.
pub struct Star<A, B> {
run: Box<dyn Fn(A) -> Option<B>>,
}
impl<A: 'static, B: 'static> Star<A, B> {
pub fn new<F: Fn(A) -> Option<B> + 'static>(f: F) -> Self {
Star { run: Box::new(f) }
}
pub fn apply(&self, a: A) -> Option<B> {
(self.run)(a)
}
pub fn lmap<C: 'static>(self, pre: impl Fn(C) -> A + 'static) -> Star<C, B> {
Star::new(move |c| (self.run)(pre(c)))
}
pub fn rmap<D: 'static>(self, post: impl Fn(B) -> D + 'static) -> Star<A, D> {
Star::new(move |a| (self.run)(a).map(|b| post(b)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lmap() {
let m = Mapper::new(|s: String| s.len()).lmap(|n: i32| n.to_string());
// 42.to_string() = "42", len = 2
assert_eq!(m.apply(42), 2);
}
#[test]
fn test_rmap() {
let m = Mapper::new(|s: String| s.to_uppercase()).rmap(|s: String| s.len());
assert_eq!(m.apply("hello".to_string()), 5);
}
#[test]
fn test_dimap() {
// dimap (to_string) (len) (to_uppercase)
// 7 -> "7" -> "7" -> 1
let m = Mapper::new(|s: String| s.to_uppercase())
.dimap(|n: i32| n.to_string(), |s: String| s.len());
assert_eq!(m.apply(7), 1);
}
#[test]
fn test_profunctor_identity_law() {
// dimap id id p = p
let p1 = Mapper::new(|x: i32| x * 2);
let p2 = Mapper::new(|x: i32| x * 2).dimap(|x| x, |x| x);
assert_eq!(p1.apply(21), p2.apply(21));
}
#[test]
fn test_star_lmap_rmap() {
let parse = Star::new(|s: String| s.parse::<i32>().ok()).rmap(|n| n + 10);
assert_eq!(parse.apply("5".to_string()), Some(15));
assert_eq!(parse.apply("bad".to_string()), None);
}
}
✓ Tests
Rust test suite
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lmap() {
let m = Mapper::new(|s: String| s.len()).lmap(|n: i32| n.to_string());
// 42.to_string() = "42", len = 2
assert_eq!(m.apply(42), 2);
}
#[test]
fn test_rmap() {
let m = Mapper::new(|s: String| s.to_uppercase()).rmap(|s: String| s.len());
assert_eq!(m.apply("hello".to_string()), 5);
}
#[test]
fn test_dimap() {
// dimap (to_string) (len) (to_uppercase)
// 7 -> "7" -> "7" -> 1
let m = Mapper::new(|s: String| s.to_uppercase())
.dimap(|n: i32| n.to_string(), |s: String| s.len());
assert_eq!(m.apply(7), 1);
}
#[test]
fn test_profunctor_identity_law() {
// dimap id id p = p
let p1 = Mapper::new(|x: i32| x * 2);
let p2 = Mapper::new(|x: i32| x * 2).dimap(|x| x, |x| x);
assert_eq!(p1.apply(21), p2.apply(21));
}
#[test]
fn test_star_lmap_rmap() {
let parse = Star::new(|s: String| s.parse::<i32>().ok()).rmap(|n| n + 10);
assert_eq!(parse.apply("5".to_string()), Some(15));
assert_eq!(parse.apply("bad".to_string()), None);
}
}
Exercises
dimap id id p = p — applying both identity functions leaves the mapper unchanged.dimap (f ∘ g) (h ∘ k) = dimap g h ∘ dimap f k.Star fully: Star<A, B> wrapping Fn(A) -> Option<B> with its own dimap.Costar<A, B> wrapping Fn(Vec<A>) -> B> (works over the input collection).Mapper where lmap converts raw string input and rmap formats the validated output.