The shape design problem

And how would you do in an already existing large code base with polymorphic variant? Say the standard library. :wink:

Imagine we want to add the stringable type that is, as its name says, the type of all values that we can convert to string. Adding a whole new kind of type class or a new behavior to an existing class is the same problem (you add a method to a class with an empty set of methods, the famous insane interface {} of Golang).

module Stringable : sig
  module type S = sig
    type t
    val to_string : t -> string
  end
  type t
  val create : (module S with type t = 'a) -> 'a -> t
  val to_string : t -> string 
end = struct
  module type S = sig
    type t
    val to_string : t -> string
  end

  type t = < to_string : string >

  let create (type a) (module Meth : S with type t = a) x : t =
    object val this = x method to_string = Meth.to_string x end

  let to_string obj = obj#to_string
end

module Stringable :
  sig
    module type S = sig type t val to_string : t -> string end
    type t
    val create : (module S with type t = 'a) -> 'a -> t
    val to_string : t -> string
  end;;

As you see, I kept the existential abstract and I even choose an object (for this use the case, the possibility offers by existential GADT is not necessary :wink: )

And now

type stringable = Stringable.t
let string = Stringable.create

let l = [string (module Int) 1; string (module Float) 3.5; string (module Bool) true];;
val l : stringable list = [<abstr>; <abstr>; <abstr>]

List.iter (fun s -> print_endline (Stringable.to_string s)) l;;
1
3.5
true
- : unit = ()

:rocket:

Edit: you can also notice that the theory about stringable (its module type) says this beautiful truism that a stringable is stringable (it satisfies its own type class :slight_smile: ).

2 Likes

If you are working with an already existing large code base then you go where that code base leads you. I was answering the OP’s question, which involved starting a new code base involving a Point, a Rectangle and a Circle and two behaviors on them (area and draw). One additional requirement was “You should be able to isolate change in the system”, in connection with two questions: “How easy would it be to add a new shape, say, triangle?” and “How easy would it be to add a new behaviour to all shapes, like save/load from a SQL database?”

So the question about isolating change becomes: Which change is predominant – additional shapes or additional behaviors? Your approach is more suitable to the first. Sure, your latest example adds a new behavior without controversy but this was a weak example - you were able to leverage the various to_string functions that the standard library already provides for ints, floats and bools. ints, floats and bools are not shapes. To stringify shapes you would have to revise each shape module to add an equivalent function in order to construct a first class module to which to apply your Stringable.create function. With variants you can write a generic to_string function for all the shapes of interest in one go.

That’s the theory of it. Of course, in order to implement such a generic to_string function you may end up modifying other modules in other translation units anyway which somewhat muddies the waters. There is a world of trade-offs. This is well explored territory.

I guess that @ivg said there’s problem with GADT and serialization. But that’s where implementation details are no more details. What I understand when programmers use this expression is that there is two point of view with semantic : the denotational and operational one. From a denotational point of view if you replace a type with an isomorphic one, nothing will change; but it’s not the case from an operational point of view. The former ask the question: what do you compute? and the latter: how do you compute it? And, for sure, the art of the programmer is to solve the different trade off between the what and the how.

By the way, I still don’t see the problem when you say:

For sure I had leverage already existing methods (that way I had less code to write, it was easier to illustrate the how it works)., but with polymorphic variant I had already answer to this objection. When you want to add behavior, you will need to pattern match on all cases, and write a particular code for this kind of object. What I object is that this code should not be found in a case analysis but in the module that expose the theory of that object, as it is already the case for the stdlib. If find this way of writing code anti-modular by design.

What is clear to me is that in the type hierarchy this infinite sum lies between all polymorphic variant (it’s the lowest supertype of any imaginable such variants) and the object type studied above. Therefore : it is easier to produce than the variant, and easier to consume than the object (that’s exactly what say the notion of variance). And now, you try to explain me that’s is easier to produce the polymorphic variant. It’s clearly false, both from a practical and a theoretical point of view. From a practical point view I can already define it even when there is still no object to inject in it. For instance, even if there were no to_string in stdlib code base, the type would had still been already define. You can take this code change to_string : t -> string with to_foo : t -> foo and it still compile absolutely independently of any other compilation unit.

I do want to believe you, but I still don’t understand this objection (advantage of polymorphic variant to add behavior).

I realized that if I gave theoretical argument in favor of existential GADT compare to object type (its is located lower in the type hierarchy and hence easier to consume), I did not give some practical example of function that you can’t use with the object type. It was the function that I named with_shape that lifts functions that operate functorially on typed shapes in order to be used with the untyped version.

module type Shape = sig
  type t
  val area : t -> float
  val draw : t -> unit
end

type 'a meth = (module Shape with type t = 'a)
type 'a obj = {meth : 'a meth; this : 'a}
type shape = Shape : 'a obj -> shape

For instance if, following the DRY principle, you write first-class functor as this one:

let foo (type a) (meth : a meth) s =
  let module M = (val meth) in
  Int.of_float (M.area s)
;;
val foo : 'a meth -> 'a -> int = <fun>

You won’t be able to use it if you choose an object instead of a GADT, and you can’t define this function:

List.map (fun (Shape shape) -> foo shape.meth shape.this);;
- : shape list -> int list = <fun>

Because here, you need to access the type in the closure of the existential and there is no such type in the object.