More consistent function types

I agree that swift functions are unbalanced: we are permitted to have zero arguments, but we are required to always have at least one return (even if it is of type Void). This is indeed an inconsistency that I have even noted myself. However, my argument is from the other angle: why can we not truly have a no return function: func noArgNoReturn() should not return even void. To me this would be the better option as far as symmetry is concerned. Both sides would then have the ability to contain 0 to n elements. I am curious to your's and @hooman's thoughts are on this.

The other problem I see, is that you are asking for the type Void be allowed to equal whatever type T becomes at compile time. Again, this also goes completely against the type safety that has progressively been put into place over each version. Great lengths have been made to limit the use of Any and AnyObject in favor of explicit types. I believe that could even be said regarding the generic types. While they don't have an actual inherent type when you're writing your code, they do expect T to not be A to not be U or however many generics are available in that signature. For Void to allow T or A, B, etc. is certainly very C, but it is not very Swift.

func noArg() -> ()
func oneArg<T>(T) -> ()
func takesNoArgFunc(() -> ()) -> ()

takesNoArgFunc(noArg) /* the types matches, so this is allowed */
takesNoArgFunc(oneArg) /* the types do no match here, and should not be allowed */
/* Even if it did accept the function, the type signature is not expecting `T`, so what should we expect the function to do with `T`? */

Removing Void implicit return type is out of the question. It will be seriously source breaking without a tangible benefit.

The reason this proposal is put forward is to reduce the source breaking effect of enforcing a change in Swift arguments model that will show up in Swift 4.1. Up to Swift 4.0 no-arg functions have been compatible with Void taking functions.

@xwu opposes this because this proposal itself is source breaking, although it reduces the source breaking effect of the newly enforced parameter model. Mark and I believe that this change reduces overall amount of actual source break and is desirable for generic programming.

Concerning T matching Void: T is an unconstrained generic type, and Void is a type. I acknowledge that this is surprising if you consider Void to be the absence of argument or return value. This does not cause a problem in generic code. If your generic code really doesn't have any requirement for a type (protocol constraint, etc) then Void is as good a type as any.

I am not sure if I understand what you mean in your sample code:

It does not match now and will not match as a result of this proposal.

The following example will match as a result of this proposal which may be surprising:

func takeFunc<T,U>(_ f: (T)->U) { print("Worked!") }
func noArg() -> Int { return 0 }

takeFunc(noArg) // Will accept it and print "Worked!"
// Actually this works now with the shipping 4.0.3 compiler, but
// will stop working without the proposal when Swift 4.1 ships. 

But again if we look for surprises, the other surprise will remain. Given the same takeFunc above:

func noResult(_ x: Int) { /*...*/ }
takeFunc(noResult) // Will accept it and print "Worked!"
// This is working now and will keep working after 4.1 release.

If you want to avoid such cases, you need to add explicit overloads of the function to handle these cases differently if you want to treat no-arg or no-result functions differently:

func takeFunc<T>(_ f: (T)->Void) { /* No Result */ }
func takeFunc<T>(_ f: ()->T) { /* No Argument */ }
func takeFunc(_ f: ()->Void) { /* No Argument and No Result */ }
1 Like

A small correction here. I added a hack to swift-4.1-branch to keep allowing this for -swift-version 4.

The general point is correct, though - This once worked by accident and no longer works on master as a result of improvements that were made to the way functions types are represented. Those improvements are ongoing. Once those improvements are complete we should see far less of this sort of unintended behavior than we've seen in the past.

This pitch is all about recognizing that the particular allowance for inferring T to be Void in:

  func f(_ fn: () -> ()) {...}
  func g<T>() -> (T) -> () {...}
  f(g())

much like we infer U to Void in:

func apply<T, U>(_ x: T, _ fn: (T) -> U) -> U { return fn(x) }
func i(_ i: Int) { ... }

apply(3, i)

is useful to avoid code duplication and/or odd work-arounds.

3 Likes

Its a corner case, but it would be nice if it worked. You could easily imagine that this would be a real pain with some generic algorithms.

I thought that might be the case, but I also thought it was worth mentioning as an alternative. I also see that by allowing the return list to be zero, “no return”, that when working with genetics you no longer could infer ‘Void’ to be ‘T’ anymore.

I believe this example is to be at least one of the major use cases you are concerned about in regard to keeping this feature. I see the convenience here, but I also think it allows for “unsplatting” (as @rudkx pointed out). This may be a happy side effect, but it still allows, as you have shown above, use of function types where one may not expect them to work, at first glance. I won’t push this issue further as I see why this is a “certainly cannot do”. It was just a thought.

By this I meant non-matching types () -> () and (T) -> () should not be expected to be used where the other is requested. In my example I accidentally reversed them from your examples and created confusion. My apologies.

I see what you mean here. However, how do we prevent splatting, but still allow for the inference of () -> () in a pace where (T) -> () is expected? If we allow T to be () -> () we also allow T to be anything as @MutatingFunk described above. Or is this also something that you would like to allow for generic functions.

Definitely not all of these things.

Let's look at each example specifically.

This is the only thing proposed. Effectively redefine ( ) -> U to be synonymous with (()) -> U.

We don't have single element tuples as a result of the syntactic ambiguity. We could have unambiguous single-element labeled tuples, but do not as mentioned here.

So no, this doesn't happen and this proposal doesn't change that in any way.

This happens today, and is completely legitimate. You can infer T to be a single argument, of tuple type. However that does not imply:

Which is splatting.

1 Like

I just replied to @MutatingFunk. There is no splatting being proposed here. It's perfectly legitimate to infer a single argument T to be anything, including tuples, function types, etc. This happens today. It's not legitimate to take a T that has been inferred to a N-tuple and use it in a location where N independent arguments are expected. That is tuple splatting and would not be a natural consequence of this proposal.

1 Like

Agreed

Also agreed, hence my example of your suggestion was simply:

  • '(T) -> U where T == (A, B)' ↔ '((A, B)) -> U' (multiple elements)

Understood, I wasn't meaning to suggest this proposal changed the status quo here. My reasoning is that there is already an equivalency in the language between single-element tuples and raw types, to the point that removing single-value tuples as their own entity was deemed acceptable. To my knowledge, (T) and T have always had the exact same meaning in Swift.

My understanding of your suggestion is that you are satisfied with using tuples as tuples (no splatting), so my examples translated the generics to tuple-taking functions, then added your suggested sugar to the empty-tuple case.

My intent was to illustrate the subtle difference between the essence of your proposal (an indirect equivalency between (T) and ()), and that of tuple-splat (a direct equivalency).

Your suggestion uses Void similar to how C uses it, where a function taking no arguments is spelled f(void). Introducing this spelling to Swift, as you suggest, would make generics just that bit nicer.

In Swift, however, Void == (). A call to f(_: Void) can be spelled f(()), making the effect of your suggestion a subset of tuple splat, when the reasoning behind the suggestion is in fact quite different. You have to consider that, however different your reasoning is, existing Swift syntax means that you are in fact proposing a subset of tuple splat, and you'll get the pushback that implies.

Yes, and that meaning is T because the parens here are superfluous, as they are in (42).

1 Like

I'll point out that SE-0066 directly touches on the ambiguity of () -> T.

This is the proposal that introduced the requirement of explicit parens around function arguments in function types.

1 Like