More consistent function types

I wrote this pitch up this morning, but am just now finding the time to get it posted.

It's something that I've thought about on and off for a little while but was motivated to get written up today both because of a recent source compatibility issue that was raised (as mentioned in the pitch) and because it has potential ABI impact and thus needs to be addressed in the short term.

This was a quick write-up of "pitch" quality, and there is no implementation yet, but I wanted to get something out in short order to see if people think this is worth pursuing.

Looking forward to your feedback.

https://github.com/rudkx/swift-evolution/blob/more-consistent-function-representation/proposals/9999-more-consistent-function-representation.md

5 Likes

But a function taking zero arguments isnā€™t the same as a function taking one, even if said argument is Void.

At first glance, it seems that any code that needs those to be equivalent is conceptually broken. I donā€™t understand why you think they should be interchangeable.

There's currently no way in Swift to achieve @rudkx's example. I'm not sure how the example is ā€˜conceptually brokenā€™.

The proposal is conceptually similar to having (_: (String, Int)) -> () be equivalent to (String, Int) -> (). Bringing back tuple splat, but with well defined rules, has always been an option. This proposal is a start to achieving that, by defining a specific case.

I don't see generics-based variadics coming along any time soon, so this kind of feature is going to have supporters.

Thereā€™s currently no way in Swift to achieve @rudkxā€™s example. Iā€™m not sure how the example is ā€˜conceptually brokenā€™.

() -> U and (T) -> U shouldn't be considered mutual IS-A for the same reason (Float) -> String and (Int, Double) -> String aren't considered mutual IS-A; their parameter profiles don't match. The code in the Introduction, "fails: We cannot infer T from no type" is correct.

The proposal is conceptually similar to having (_: (String, Int)) -> () be equivalent to (String, Int) -> (). Bringing back tuple splat, but with well defined rules, has always been an option. This proposal is a start to achieving that, by defining a specific case.

I donā€™t see generics-based variadics coming along any time soon, so this kind of feature is going to have supporters.

I wasn't around in the early Swift days; do we want the strange chumminess between tuples and function arguments from those early days back?

How would variadic generic parameters be a substitute for regulated tuple-splatting?

In plain English, when T equals Void they are both functions that take nothing and return U, doesnā€™t that make sense?

Indeed, when working with generics it would be Niceā„¢ if ā€œ(T) -> Uā€ could work for any number of arguments T and any number of return values U. Notably, when the only requirement is that the output of one function f matches the input of another function g, it would be great to write g(f(x)) and have it Just Workā„¢.

For the case of ā€œsimpleā€ functions which do not use inout, variadic parameters, default arguments, nor any other tricky things, we could consider once again treating these simple function as maps from tuples to tuples, for the purpose of generics at least.

2 Likes

Seconding @Nevin's answer, which addresses these points.

A simplified use case is as follows, for an event object which notifies its subscribers with a value:

class Event<T> {
    func notify(_ value: T) { ā€¦ }
    func subscribe(_ handler: (T) -> ()) { ā€¦ }
}

let event = Event<(A, B)>()
event.subscribe {a, b in
    ā€¦
}
event.notify(a, b)

It's what a lot of people want from variadic generic parameters, with a very simple syntax which requires no special handling by the API author.

1 Like

This issue of implicit tuple splatting has been discussed extensively, and I'm not sure what we're going to get here by rehashing it. The feature was removed, with the determination that it'll never come back, in favor of some future explicit tuple splatting syntax.

I have trouble with this particular proposal here because, although practical, it's asking for one special exception to the decision above. It seems we're formalizing specific instances of implicit tuple splatting piecemeal when, as already settled, the solution is intended to be an explicit tuple splatting syntax on which we've made zero progress.

event.subscribe { a, b in }

I'll point out that this works today, but only because of another short-term hack that was added last year. Long term I think we would like to make this specific case (of a closure literal) work with the understanding that closure literals are already special in many ways, and once we have some implementation issues straightened out in the compiler we should be able to make this work in a more principled and deliberate fashion.

Agreed. It's a separate issue and shouldn't be conflated with this pitch.

I would suggest another way of interpreting this proposal.

It is eliminating the odd asymmetry between function arguments and function results. All functions must have a result type, and if it's not explicitly declared, it defaults to (). Likewise, if a return appears in such a function, but the () is elided, the compiler provides it. If no return appears, both the return and the returned value () are provided.

  func impliedReturnType() { return } 

is desugared to:

  func impliedReturnType() -> () { return () }

The symmetric approach for arguments would be taking:

  func impliedArgument() -> Int { return 42 }

and desugaring it to:

  func impliedArgument(_: () = ()) -> Int { return 42 }
7 Likes

That's...interesting. It makes sense, and I can see why that consistency can come in handy. In this model, then, there's no such thing as a function that takes zero arguments.

The argument for symmetry is imperfect, though, because return specifically only allows one value to be returned, not zero, two, three, or more. By contrast, without implicit tuple splatting, a function explicitly takes any nonzero number of arguments. Eliminating zero but not two, three, four, etc. wouldn't necessarily improve consistency. And making two, three, four, etc., arguments equivalent to one tuple argument is the issue of tuple splatting.

Correct.

Yes, I will grant you the point that there isn't perfect symmetry, but that's by intentional design of the language. Rather than each function taking a tuple and returning a tuple, each function takes some number of arguments and returns a single result. So I don't see the lack of perfect symmetry as a flaw so much as a concession that we aren't aiming for perfect symmetry. This change just makes things slightly more symmetrical and consistent in this one way.

The current implementation does model multiple argument functions as tuples, and that's a poor choice if only because tuples do not in the general case allow for things like inout elements.

We're slowly moving toward the right implementation, but each step in that direction tends to break existing code in subtle and surprising ways (the issue in the JIRA that motivated sending this pitch at this time was broken in exactly this way - a small tweak to the representation).

Mark is right, it is a symmetry issue.

No, you should look at it the other way around. Instead of focusing on what it allows, look at what it prohibits: It prohibits zero return value, although it has sugar to make it appear as if it allows it.

In general, functions have argument(s) and return a result. He wants to enforce having (at least one) argument the same way Swift already enforces having a return value. This is very useful.

Consider the following case I ran into recently:

I had to write these four different overloads to cover the generic code I was writing:

() -> () -> T
(T) -> () -> U
() -> (T) -> U
(T) -> (U) -> V

While with Mark's (@rudkx) proposal, the following single overload would have been enough:

(T)->(U)->V

The symmetry issue is also apparent here:

(T) -> U matches (T) -> Void (a function with no return value)
but it does not match () -> U, (a function with no argument).

This clearly breaks symmetry and creates a lot of extra boilerplate for a certain kind of generic code.

3 Likes

I already get Mark's point, and he gets mine. It's not necessary to restate it here. But FWIW, that sentence is the fudge factor that I'm talking about:

Functions may have any number of arguments, including zero arguments or two arguments, but must have exactly one result. To restore symmetry, we need tuple splatting.

Can you clarify how tuple splatting would solve the particular four generic overloads example that I gave?

You would use tuple splatting to pass any number of arguments to your function of type (T) -> (U) -> V. And then you would use tuple splatting to pass any number of arguments to your function of type (U) -> V that you get back.

Or am I misunderstanding your use case?

Also, where does this statement come from? Mathematically speaking, a function has both input and output, although the domain of input and/or output might be an empty set. The symmetry of handling empty input vs empty output is broken now. It has nothing to do with tuple splatting.

In Swift, function parameters and tuples were once treated identically, and therefore there was symmetry. Now, that's no longer the case because implicit tuple splatting has been removed. When a function has two parameters or zero parameters, you used to be able to pass it a tuple instead, and vice versa, but now you can't. That's what broke the symmetry. To restore that feature, we would need tuple splatting, except it must now be explicit instead of implicit.

This proposal doesn't restore full symmetry; it only makes a function that takes zero arguments equivalent to one that takes one argument, but does not address the general case. Anyway, I'm repeating myself now and this isn't productive.

If I'm understanding Xiaodi correctly, symmetry requires (T) -> U is convertible to and from all ā€˜mapā€™ functions (T matching the input domain, U the output domain).

This was possible implicitly with tuple splatting, and could be restored with an explicit form of tuple splatting.

Tuple splat is undoubtedly related to this proposal, as its feature set is a strict superset what's proposed:

  • '(T) -> U where T == ()' ā†” '() -> U' (proposal's use case)
  • '(T) -> U where T == (A)' ā†” '(A) -> U' (single parameter)
  • '(T) -> U where T == (A, B)' ā†” '(A, B) -> U' (multiple parameters)
  • And so on for higher arity tuplesā€¦

However, from what I gather, the alternative suggestion is to aim for a different kind of symmetry, which could be defined as so:

  • '(T) -> U where T == ()' ā†” '(()) -> U' ā†” '() -> U' (zero element sugar)
  • '(T) -> U where T == (A)' ā†” '((A)) -> U' ā†” '(A) -> U' (single element)
  • '(T) -> U where T == (A, B)' ā†” '((A, B)) -> U' (multiple elements)
  • And so on for higher arity tuplesā€¦

Essentially, treating functions in generics as always taking a single tuple parameter (taking ā€˜parameter listsā€™ out of the picture), then providing usability sugar for the zero-tuple case.

Void is not nothing. Itā€™s the type of the empty tuple. The fact that itā€™s called Void is a misnomer.

5 Likes