Specs as function headers

Switching between various languages, I somewhat miss the ability to use function spec as a function header.

For example, I need to write this:

-spec leave(Group :: group(), PidOrPids :: pid() | [pid()]) -> ok.
leave(Group, PidOrPids) ->
    leave(?DEFAULT_SCOPE, Group, PidOrPids).

While what I want to write is

leave(Group :: group(), PidOrPids :: pid() | [pid()]) -> ok.
    leave(?DEFAULT_SCOPE, Group, PidOrPids).

Combining function spec and its header. I feel like there must be some reason behind it (other than “specs are latest add-ons that aren’t a part of original Erlang”).
What could that be? And what would it take to allow combined specs/headers?

8 Likes

Well… Not to say that it would make it impossible, but multi-claused functions or functions with complex patterns instead of just variables as arguments can make this kind of thing a bit… less clear (?)

For instance…

leave( _ :: pid() | [pid()], Group :: group() ) when map_size(Group) == 0 -> ok.
  …;
leave( Pid :: pid(), #{Pid := _} :: group() ) -> ok.
  …;
leave( [Pid | Pids] :: [pid()], #{Pid := _} :: group() ) -> ok.
  …;
…
7 Likes

There is a project that accomplishes this in Elixir. I suppose the same could be done in Erlang using a parse transform.

3 Likes

I’m pretty sure this is the exact reason. Combined with pattern matching and multiple clauses this gets very messy.

To enable something like this, IMO, we’d need to forgo multi-head functions likely in favour of in-line case, something similar to OCaml function - though there you can conveniently match only on the last argument.

Either way, this would have been a significant syntax change that probably would touch more than just this.

Some options for syntax that could be more ergonomic with inline types were researched years ago by Joe Armstrong in GitHub - joearms/erl2: a new dialect of erlang.

1 Like

It also has a possible downside of mixing the specification of a function with its implementation, at least conceptually, but maybe even practically (if you move a clause around, for example).

I would actually prefer to skip the -spec funName bit:

- Group :: group(), PidOrPids :: pid() | [pid()] -> ok.
leave(Group, PidOrPids) ->
    leave(?DEFAULT_SCOPE, Group, PidOrPids).

In this case, it means it is the spec for the next to be defined function.

I used - as a prefix but it could be anything else. But I do think it would remove some of the repetition (specially with multiple clauses were you already end-up repeating the function name).

2 Likes

I wonder if it would be possible to do a shorthand annotation:

f(X :: integer()) :: return_type() ->
    y(X).

As equivalent to:

-spec f(X::integer()) -> return_type().
f(X) ->
    y(X).

By marking them as shorthand and adding a special return type marker, you could handle multiple clauses fine.

It could however not support tricker declarations such as

-spec f(X, Y) -> Y
    when X :: integer(),
         Y :: number().

Which would still require the full format.

(I still don’t if the shorthand return type would go before or after the guard clauses)

3 Likes

Maybe it can be

f(X :: integer(), Y :: number()) :: Y -> Y.
1 Like

I can tell you from personal experience, this gets messy :slight_smile:

I understand the desire for it though, and in that, I don’t have strong feelings.

5 Likes

I’m with @michalmuskala and @starbelly that this is bound to make things very messy. It adds a lot of chatter to function heads that is in the way of reading.

Also, in my view a spec serves two purposes.

One is type checking, but this is done with dialyzer, a different tool. The specs (IMO) have nothing to do with the language itself, that is, even if there are no specs or the actual code totally violates the spec, it will happily compile without any complaints. For me, this is clear a reason not to sprinkle specs into actual code.

The other purpose of specs, at least in my view, is to provide some sort of description of the function in terms of what types it expects as arguments and what it returns, without reading any of the code. With specs clearly separated from the code, this is easy, it’s all in one place. But with specs mixed into the function heads, I will have to examine all of its clauses and put it all together in my head.

11 Likes

With things like that, resolving the semantics of what this actually means is a bigger issue. AFAIK right now for both Dialyzer and EqWAlizer this is exactly the same as writing:

-spec f(integer(), number()) -> number().

The extra variables and when is mostly “decorative”

2 Likes

There’s a distinction when considering the docs generated from the type specs however!

1 Like