The ergonomics of labels

I thought I’d try out Base (actually, Core_kernel, but those extras aren’t relevant here). In the process of porting my small codebase, I encountered labels for the first time, and perhaps some of their pitfalls. e.g.:

─( 11:48:57 )─< command 0 >───────────────────────────
utop # open Base;;
─( 11:48:57 )─< command 1 >───────────────────────────
utop # let x = List.fold [1;2;3] 0 (+);;
val x :
  init:(int -> (int -> int -> int) -> '_weak1) ->
  f:((int -> (int -> int -> int) -> '_weak1) ->
     int -> int -> (int -> int -> int) -> '_weak1) ->
  '_weak1 = <fun>

I’m now aware that fully labeling the arguments there will yield the desired behaviour (List.fold [1;2;3] ~init:0 ~f:(+)), and I’ve read some on why this apparently total function call actually isn’t.

Based on my hour-long experience adding labels everywhere and eliminating many uses of |>, I’m not particularly keen on them, at least as an unavoidable part of a standard library replacement.

However, I’m willing to be persuaded that there are benefits I’m not seeing yet (obviously, a lot of people use Base/Core/etc), so I thought I’d ask: how does the use of labels work out in practice given the above?

The first rule of label club is: do not overuse labels. The second rule of label club is: do not overuse labels.

That said, labels can sometimes be rather useful. There are cases where you’d have functions of type string -> string -> unit and it can be rather error prone to switch up the order of arguments accidentally. Of course you can wrap your strings in some 1-element ADT or similar, but that is syntactically rather heavyhanded and creates pointless allocations.

The other time I find them useful is when the order of the arguments to be curried over is not really clear, you could either curry on the first or the second argument and both cases make similar sense. Of course you could then employ some trickery, but it tends to look awful and impossible to read to the point where declaring an anonymous “flipped” function makes more sense.

I also like the fact that a label ~foo will apply as ~foo:foo, so the syntactical overhead over applying labels is not really big.

But I also enjoyed the fact that most Python functions can be applied by specifying the named argument in any order. That, coupled with partial made Python surprisingly pleasant to use currying in.

2 Likes

Well, by that metric, it seems Base/Core is going to have to wait on the porch for a long time, given its prevalent usage.

I definitely see the value of labels in things that you might call “application API” (AAPIs? :stuck_out_tongue:), where there are relatively more arguments, perhaps with difficult-to-discern types and orderings that no one can reasonably recall (probably because they’re used relatively less frequently). There, the callsite labels can be very useful documentation.

In APIs for foundational data structures, etc., I’m not sure the same balance exists.

I suppose the root of my frustration was that what look like total applications, aren’t; where I might be creating a partial, then tending carefully to the labels there feels like a reasonable cost.

Labels are quite ergonomic to use with |>, though, so I’m confused why you would eliminate those uses?

let _ =
  [1; 2; 3]
  |> List.map ~f:(fun x -> x + 1)
  |> List.fold ~init:0 ~f:(fun x y -> x + y)
3 Likes

Yes, you’re right. That part of my comment came from when I was attempting to not fully label such calls, while I was still operating under the misunderstanding from the manual that total applications didn’t require labeling at all.

I have to confess that after a relatively short time using OCaml (about a year and a half now?), I’ve never really felt the desire to use labels or optional arguments in my own code. I’ve had to use them a bit when invoking other people’s APIs, but it’s never occurred to me to want them myself. Perhaps this is just because of decades of using languages where function calls didn’t have labels on arguments, or perhaps not; I can’t really say. That said, I’ve noted that many other people don’t seem to use labels much, either. The place I’ve seen them the most is the Jane Street libraries.

1 Like

I’d use labels in functions where more than one parameter has the same type. Labels effectively become part of the function type, and disambiguate them nicely. E.g.,

let string_replace ~find ~replace string = ...

Conceptually we can think of a set of labelled parameters as an ad-hoc record parameter. That’s in fact exactly how it works in SML:

fun string_replace {find=find, replace=replace} string = ...

I recognize this is a frequent cited use. It just never occurs to me to do it.

That said, much of the use I’ve seen in the field, especially in the Jane Street libraries does not seem to have this flavor, that is, there are a lot of uses (say the Core version of List.fold) where an argument that’s a function is labeled ~f but there are no other similarly typed arguments at all, and where I’m not really sure why the label is there.

Jane Street libs have a ‘design philosophy’ of ‘t first, other params are labelled’. It’s for consistency as much as anything, really. I imagine it makes sense for them because they need to onboard lots of people to OCaml and things are easier if all the libs follow the convention.

Oh, I’d actually also use labels for data constructor functions (let make ~id ~name ~age () = {id; name; age}). It’s way easier to read, write, and extend for constructing data values.

A few quick thoughts on labels:

  • We like labels, and liked them well before we had tons of people to onboard.
  • Consistency helps everyone. This old post is still worth reading I think.
  • We like ~f because for many iterators, it’s sometimes easier to read with the function first (and with the function partially applied), and in an almost CPS-like style, where the (large) function body goes second.
  • Labels are great for disambiguating arguments of the same type. Look at String.sub:
    var sub : ?pos:int -> ?len:int -> string -> string 
    
  • They also serve as documentation for the meaning of a function, where the type alone isn’t enough, even if there isn’t another value to confuse it with. e.g., look at String.Escaping.index, whose signature is:
     val index : string -> escape_char:char -> int option`.
    
    the escape_char clarifies the purpose of that argument.
  • The combination of labeled arguments, label punning, and record field punning encourages you to carry the same names across your codebase, which again can improve clarity and uniformity.
11 Likes

I’d like to mention that ListLabels.fold_left has the same labelled arguments as Core.List.fold.

2 Likes

Thanks for that. FWIW, I can’t disagree with hardly anything you say. My main niggle on those points might be that, in at least some cases, the explicitness required by labels where the types make things evident is not actually doing any work to improve clarity. The original example related to lists I think is one such category, but fundamentally I’m happy to chalk up differences on this aspect to mere aesthetics.

Just to illustrate what I think the nut of the problem is, consider these contrived (but I think representative) interactions:

utop # let foo thing ~a ~b : int = a b thing;;
val foo : 'a -> a:('b -> 'a -> int) -> b:'b -> int = <fun>
─( 19:36:23 )─< command 10 >────────────────────────────────────────
utop # foo 3 (+) 12;;
- : int = 15


─( 19:36:39 )─< command 11 >────────────────────────────────────────
utop # let foo thing ~a ~b = a b thing;;
val foo : 'a -> a:('b -> 'a -> 'c) -> b:'b -> 'c = <fun>
─( 19:36:44 )─< command 12 >────────────────────────────────────────
utop # foo 3 (+) 12;;
- : a:('_weak5 -> int -> (int -> int -> int) -> int -> '_weak6) ->
    b:'_weak5 -> '_weak6
= <fun>

Per the manual, “…if an application is total (omitting all optional arguments), labels may be omitted. In practice, many applications are total, so that labels can often be omitted.” This describes well the intuition I blindly brought to the feature, and I’d offer the above as some evidence that maybe support for reasonable expectations sort of drop off sharply when polymorphic returns come into play.

(For those interested, @Leonidas linked in IRC to the paper describing labels, and another linked to the spot in the compiler where ignoring labels is implemented.)

Anyway, I hope the above is taken as intended, a hopefully constructive experience report, etc. :blush:

1 Like

Sure, there’s some trade-off here, as there often is, between explicitness and concision.

FWIW, in our standard setup, we use warnings to make labels obligatory in all circumstances, rather than allowing them to be omitted in some cases. I think this is simpler and more predictable, and all in a win.

1 Like

The manual says right below that:

But beware that functions like ListLabels.fold_left whose result type is a type variable will never be considered as totally applied.

Since your (second) foo function has a type variable as its result type, it’s considered as not totally applied. (Why? Because a type variable can be any type, including a function meaning it might be expecting more arguments–we don’t know!)

2 Likes

Correct. To be clear, I’m not saying anything is broken; I just happened to find my intuition mirrored in the text.

Nevertheless, the semantics of totality implied right afterwards and demonstrated in the examples here remain confusing me. I grok very clearly that e.g. an application to a function argument with inferred arguments and a polymorphic return can’t be considered total; there’s no possibility for information sufficient to determine that, as you say. However, if the function in question is named and declared to accept N arguments, then it’s not obvious IMO why that can’t be considered total with respect to those declared arguments.

That’s how it is, and it’s likely very reasonable from an implementation standpoint to generalize over both cases, but does the underlying theory require it somehow?

(Yeah, I know I have 20 years of OCaml literature to catch up on :smile:)

dune will also enable warning 6 (“Label omitted in function application”) by default, so many new projects use the style Yaron described, where labels always need to be specified.

A point that is obvious in hindsight but was not obvious to me when first encountering unwanted partial applications: The shortest way to get a total application from a function with many labels is to define a new function by eta-expanding:

open Base;;
let myfold l = List.fold l 0 (+) ;;
let x = myfold [1;2;3];;
2 Likes

After using OCaml for about a year (and with a similar clj background to yours), let me say that I love, love, love, labels and believe they should be used a lot more.

List fold is an excellent example. I never remember the order of the arguments to fold. Which is the init and which is the list? If you get it wrong the type checker doesn’t help much either. It also allows you more flexibility for partial application.

Similar with functions like is_prefix, substring, etc, where there are multiple arguments of the same type.

Most useful of all is in arguments that you might want to pipe: labels make it easy to pipe and when you read the code it’s super obvious which position the argument goes in, as there’s only one option.

I recently started building a stdlib for OCaml/Bucklescript/Reason called Tablecloth (I haven’t announced it yet cause I haven’t had time), and one of the major reasons was that I left the Belt wasn’t nearly as ergonomic to use as Core/Base, primarily because of labels. I also spent a lot of time in Elm, which doesn’t have labels, and I think it’s a (possibly the only) really nice ergonomic advantage that OCaml has over Elm.

Anyway, I’d really encourage more libraries to go deep on labels like Base does. I also hated it at the start and learned it’s fantastic and now miss it anywhere that doesn’t have it.

5 Likes