Tablecloth - a new standard library for OCaml and ReasonML

Hey folks, I’ve been working on a standard library for OCaml and ReasonML, which has the same API in both. This is to help us with code reuse between our OCaml backend and our ReasonML frontend.

It’s called tablecloth, and we wrote up an announcement here.

Would love to folks contributing back to it - if you’re interested there’s a long list of ways to help.

10 Likes

What is the reason to provide multiple ground types such as IntDict and StrDict instead of just having one interface to the polymorphic Map/Set/etc?

  • have both snake_case and camelCase versions of all functions and types,

personal :heart: for this :slight_smile:

The two libraries on which we’re built (Belt and Core/Base) have specialized Maps for ints and strings. I personally haven’t had use for polymorphic maps, and have also found them hard to use. I would hope to add an easy-to-use polymorphic map at some point, so long as it’s easy to use.

1 Like

So I can’t tell for Belt, but in the new Core/Base the polymorphic maps are easy to use, and in fact is now the default way of handling maps and sets. The main benefits are portability between Core, Core_kernel, and Base; much better compilation time; and small binaries. Besides, you’re actually using polymorphic maps in Base/Core, as 'a Int.Map.t is just 'a Map.M(Int).t.

Here is the primer on how to switch from monomorphic maps/sets/tables/etc interfaces to corresponding polymorphic interfaces. Given a module T that defines your type (e.g., Int, String, etc):

  1. type 'a T.Map.t is denoted with 'a Map.M(T).t (the same for Set, Hashtbl, Hash_set, etc)
  2. an empty table T.Map.empty is denoted with Map.empty (module T) (same for the rest)
  3. a singleton T.Map.singleton key data is denoted with Map.singleton (module T) key data (again the same for corresponding data structures).

The signature of module T is quite small, it actually requires only a comparator. I usually over-deliver, and provide the Base.Comparable.S for all my types which are comparable. This still a light abstraction (the compare, equal function, and infix operators), not like the Core’s heavy Comparable or Identifiable which takes hours to compile. It is also easily derivable using Base.Comparable.Make and the friends.

Therefore, given that the common style of programming in OCaml involves creating lots of types and intermediate abstractions, having polymorphic data structures is extremely necessary for a library to be successful.

2 Likes

Thanks for the info. Maybe once I figure it out in Belt we’ll be able to get a generic interface to polymorphic maps and sets.

Agreed. If people start to use Tablecloth, we may get a contribution back from someone who has an answer about how to make them both generic and nice. Else I’ll probably figure it out some day :slight_smile:

Well if you don’t like the comparator_witness in generic maps (I’m not sure what do you mean by ugly), and just want your maps to have type ('a,'b) map while still having ('a,'b,'cmp) Base.Map.t as a backing store, you can hush the compare type using an existential. We need to be able to recover the type of the comparator_witness so that we can compare maps to each other (as well as apply other binary methods on maps). For that we need to introduce an extra abstraction - the comparable type class1. Everything else is trivial. See comments in the code, feel free to ask questions, if something is not clear.

module Map : sig

  (** polymorphic finite mapping from ['k] to ['d]  *)
  type ('k,+'d) t

  (** The comparable type class.
      Should be moved to a separate module, but we will keep it here,
      for the sake of experiment *)
  type 'a comparable

  (** [let int = declare (module T)] makes type [T.t] an instance
      of the comparable type class. *)
  val declare : (module Base.Comparator.S with type t = 'a) -> 
                'a comparable

  (** {3 Map interface} *)

  (** [empty comparable] creates an empty [map],
      Example, [empty int].*)
  val empty : 'k comparable -> ('k,'d) t

  (** [find m k] returns data associated with [k] in [m].  *)
  val find : ('k,'d) t -> 'k -> 'd option

  (** [equal equal_values x y] is true if [xs] and [ys] are equal,
       and have equal orderings.
  *)
  val equal : ('d -> 'd -> bool) -> ('k,'d) t -> ('k,'d) t -> bool

end = struct
  type 'a comparable = Comparable : {
      witness : 'c Type_equal.Id.t;
      comparator : ('a,'c) Base.Comparator.comparator;
    } -> 'a comparable

  let declare (type t)
      (module T : Base.Comparator.S with type t = t) = Comparable {
      comparator = T.comparator;
      witness = Type_equal.Id.create ~name:"comparable"
          sexp_of_opaque
    }

  type ('k,+'d) t = Gen : {
      witness : 'c Type_equal.Id.t;
      map : ('k,'d,'c) Base.Map.t;
    } -> ('k,'d) t

  let find (Gen {map}) = Base.Map.find map

  let equal value_equal (Gen lhs) (Gen rhs) =
    match Type_equal.Id.same_witness lhs.witness rhs.witness with
    | Some T -> Base.Map.equal value_equal lhs.map rhs.map
    | None -> false

  let empty (Comparable {comparator; witness}) = Gen {
      witness;
      map = Base.Map.Using_comparator.empty
          ~comparator
    }

end

1)We wouldn’t need this if Janestret will define their witness as an instance of Type_equal.Id.t, instead of just keeping it a phantom. If they will agree to do this, then we can get rid of this intermediate abstraction.

I haven’t thought through your proposal in detail, but if you wanted to propose it as a PR to Base, we’d be happy to take a look.

1 Like

This is as simple as just adding a new field to your comparator witness representation,

type ('a, 'witness) t =
  private
  { compare   : 'a -> 'a -> int
  ; sexp_of_t : 'a -> Sexp.t
  ; witness : 'witness Type_equal.Id.t
  }

This field will be the first-class witness which will allow us to reify value equality into type equality and vice versa.

Now the bad news, Type_equal.Id.Uid.t is Comparable.S therefore it will induce a circular dependency between Type_equal and Comparator compilation units. Which is of course resolvable, through adding a separate module for the Type_equal.Id only, but will explode a PR a little bit.

Sounds reasonable. It doesn’t sound like too bad of a refactor. Again, if you submit a PR, the Base team would be happy to evaluate the proposal.

y

2 Likes

So, from what I understand, as of today you provide two separate libraries that just happen to have the same interface. In particular, you have duplicated the interface file. I wonder how hard it is going to be to keep the two in sync.

Unfortunately, as of today I don’t see a better way, but in the future, hopefully there will be bucklescript support in Dune (https://github.com/ocaml/dune/issues/140) and at that point it could be interesting to start looking at Dune’s concept of “virtual libraries” https://dune.readthedocs.io/en/latest/variants.html to reduce the duplication.

Well, it’s rather deliberate :slight_smile:

Originally, it was an actual duplicate, but then I realized I wanted to expose the types so that they could be unified with existing functions from those libraries, so there are some slight but important differences.

We’ve been using virtual libraries in our backend for a while now for js_of_ocaml/native combined libraries, and it’s a pretty good experience. However, I think the differences in the two mlis might not allow this to work. In practice, the duplication between the mli files is something I don’t have a problem with, so I amn’t in search of a solution to that at the moment.

I don’t think that’s it exactly, the two separate libraries already exist, Tablecloth just puts a common interface on top of them.

I think the reference here is that tablecloth is two implementations and two packages, one for each underlying library (Base/Belt). You install either tablecloth-native from opam or tablecloth-bucklescript from npm, though there’s just one github repo. Depending on your perspective that could be two libs or one :woman_shrugging:

@pbiggar My point was that I was hoping that library authors could transparently write multi-backends libraries by depending on Tablecloth.

If I understand correctly the new variant feature of Dune 1.9.0, that enhances virtual libraries (https://dune.readthedocs.io/en/latest/variants.html), this would now be technically feasible, provided you decide to unify once more the two interfaces and to wrap all this with Dune: library authors could then depend on Tablecloth without having to worry about which backend they target, and application authors would then be the ones that choose which variant they use.

That would be really cool!

Though given that the ReasonML community doesn’t use Dune, I’m not sure how this would work in practice.

Just a nitpick, and not sure how clear the distinction is really, but I’d like to clarify that the community using the bucklescript compiler is not using Dune. The ReasonML people writing native code are using Dune as well.

Ofcourse the question still stands how the transparent usage between Native and Bucklescript could work with Dune.

3 Likes

Unfortunately, at the current time, there is no Bucklescript support in Dune, but the issue that I linked above shows that such support would be welcome, if only an expert in Bucklescript would be ready to contribute this.