Pitch: Allow functions with void-params to be called as void-functions

I think some of the responses already echo this sentiment but I'd like to also spell it out myself.

Even if the end-user syntax change seems minuscule this might be a hit on the compile times and additionally cause confusion.

For the first point: consider that for each call in code of the type myFunc() which isn't found the compiler needs to do a second call to check for myFunc(_: Void) and if that's not found, it needs to check for myFunc(_: Void, _: Void) and if that's not found it needs to check for myFunc(_: Void, _: Void, _: Void), etc. Additionally, how would the rule work for functions like myFunc(_: Void, _: Int, _: Void)?

For the second point: would precise matches be preferred when there are multiple ones. For example when the code is myFunc() would that resolve to myFunc(), myFunc(_: Void), etc.

My opinion is that it is better to handle exceptions like this on the authoring side instead of the compiler. I.e. instead of making the compiler do guesswork as to what the developer intended to do, source code can clearly and unambiguously describe the case for Void parameters like Combine does in your example with PassthroughSubject: Apple Developer Documentation

10 Likes

Yes, indeed, the only limitation here to keep in mind is that Void or () cannot add custom conformances at the moment, so if Output were to ever gain a conformance requirement send(_: value: Output = ()) would fail to type-check.

1 Like

In such circumstances Void could never end up as the generic parameter anyway, so the issue noted in this thread wouldn't apply. :slightly_smiling_face:

1 Like

Thanks @xedin (at least I did not misunderstood your pitch). Now... I think this particular use case (defining a default argument for Void) should not be addressed with your pitch, because Void is not the one and only type that could profit from a default value.

Sure, people only focused on Void think so, because it is the problem they want to solve now. But Subject should not jump too fast. After Void, more needs may start to appear. See [Pitch] Type inference from default expressions - #31 by gwendal.roue

Some may answer "but send() can only have a clear meaning when the type is Void! So Void is indeed privileged, and deserves a special treatment!".

Such answer only reveals the focus on Void. For example, send() would work just as well for Ping:

let pings = PassthroughSubject<Ping, Never>()
pings.send(Ping()) // Obviously correct
pings.send() // Looks quite fine to me as well

So no, Void is not special. And it should not be addressed with Type inference from default expressions. (I'm sorry for having brought this bad idea in this pitch :sweat_smile:)

2 Likes

pings.send() would not work at call site if Output == Ping because () is not convertible to Ping so that's where the disconnect is here? Default would only work if for Output == Void.

struct S<Output> {
  func test(_: Output = ()) {
  }
}

let s = S<Int>()

s.test()

Would produce the following error:

error: cannot convert default value of type 'Int' to expected argument type '()' for parameter #0
s.test()
  ^
note: default value declared here
  func test(_: Output = ()) {
            ^           ~~

Yes, that's exactly the point. Both Void and Ping (and other types that should support a naked send()) should be handled with explicit overloads. Not with your pitch.

The () literal should not privileged. Void should not privileged.

1 Like

I am not familiar with that APIbut doesn’t it say that send only works if Output is Void? I don’t understand what do you mean by should in your previous message.

It is discussed in the thread of your pitch: meet you there!

One thing to consider is that named parameters can distinguish between otherwise-identical functions. For example, if you have this Objective-C code:

@interface Example: NSObject

- (instancetype)initForProduction;
- (instancetype)initForTesting;

@end

It gets imported into Swift like this:

class Example: NSObject {
  init(forProduction: Void)
  init(forTesting: Void)
}

And in order to call them, you do this:

let prod = Example(forProduction: ())
let test = Example(forTesting: ())

If this pitch were accepted, how would you call these two functions in a way that distinguishes between them, without passing in the Void tuple? I think you'd probably have to avoid applying this to functions that had named parameters, which diminishes its utility further.

1 Like

Correct, this was the implicit tuple splatting feature removed from Swift in SE-0029.

The topic of restoring this just for Void, essentially inserting a default value of () for arguments of type Void, has been raised on these forums off an on for at least 5 years. There’s a nice symmetry to the implicitness of returning (). As noted by @bjhomer it would be ambiguous in overloaded cases—and indeed the bigger problem is that it could change overload resolution for existing code and/or increase compilation times due to typechecker complexity.

3 Likes

Void as function argument is already treated specially in some places in the language:

Thankfully, I don't have to write this:

let promise: Promise<Void, Error> = ...

promise.onSuccess { _ in // <- void argument 
   // do something
}

But can write this instead:

promise.onSuccess {
   // do something
}

However, I still would want f() to consider func f(_: Void) as a candidate when type checking.

I also write lots of these:

public extension Result where Success == Void {
    static var success: Result { return .success(()) }
}

Just so I can write return .success instead of return .success(())

And I see many library authors writing these same conveniences, including at Apple. However, it's pretty random when these conveniences are afforded us and not. It seems inconsistent to me.

But I'm not a compiler engineer.

1 Like

This is sort of the inverse of the SE-0029 implicit tuple splat, isn’t it?

Tuple splat let you pass a single tuple (T1, …, Tn) as the argument to a function of type (T1, …, Tn) -> U, but this proposal is asking for the pre-splatted elements of Void (i.e., an empty argument list) to be accepted as an argument to a function which accepts the un-splatted tuple ((()) -> U).

This isn’t Void getting special treatment I don’t think, this is a deliberate mitigation to the impact of SE-0110, which allows any function (T1, …, Tn) -> U to be passed as an argument to a parameter of type ((T1, …, Tn)) -> U. As the empty tuple, Void of course benefits from this convenience, but it works just the same for tuples of arbitrary arity.

Brainfart on my part: yes, it’s SE-0110 instead of SE-0029.

1 Like

To be clear, what’s discussed here for functions wouldn’t roll back the distinction between .success(()) and .success(), because this has to do with enum cases, and that distinction specifically was introduced in a separate Swift Evolution proposal. Additionally, it wouldn’t take you from .success(()) to .success.

Sure, I realize that. It was just an example of how cumbersome these () as arguments are.

I don't think you'll find any disagreement on that: quite sure that no one likes writing nested parens like (()).

My overarching point is that Swift was released with some very clever ideas about this that were generalized consistently to cover all of these use cases and (at least, IMO) really nice—and then we spent numerous Swift Evolution proposals (5, I think?, not counting amendments) spanning more than one release to rip it all out, with significant implementation headaches, "temporary" rules (like bringing back implicit tuple splatting for closures) to paper over the transition that still remain in the language today, and parts of these proposals only partly done (like the revised rules for enum cases with associated types).

We're going to have to be very careful poking at this.

1 Like

Sure, I remember the implicit tuple splatting from Swift's early years. And, like you, I quite liked it. However, I am not familiar with the compiler implementation details of how this worked, nor how the still present remains of it works to this day.

Are you saying that implicit tuple splatting for closures, implicit void-returns, and its like are hacked-on one-offs, and that adding more of those, is likely going to get rejected? I can certainly understand if that's the case, but I also do feel that () is different from most other tuples. In fact, I feel like the fact that () even is a tuple, is an implementation detail, rather than some intrinsic property of the void or unit type.

But I digress.

Implicit tuple splatting, yes—it was a one-off measure to make the transition tolerable. I'd imagine more of that is going to be frowned upon; on the other hand, an addition to the language that can be shown to result in a more consistent set of rules, rather than adding more exceptions, would likely be welcomed.

Well consistency on use-site, doesn't necessarily mean consistency in implementation. Especially if there is a discrepancy between how compiler-internal structures are modeled, the formal linguistic syntax rules, and programmer's mental models.

Personally, I think this is already insistent:

// some implicit void-returning function "bar"
func bar() { ... }

func foo() -> Void {
  // can be used as the return value from an explicit 
  // void-returning function "foo"
  return bar()
}

// but cannot be used as argument to the same implicit 
// void-consuming function
foo(bar())

Maybe foo isn't void-consuming at all, since we have a way to spell func foo(_: Void) with an explicit void argument. But then, as closures go, it is.

But that aside. Personally, I'm just really tired of having to type (()) all over in generic functions.

This was also proposed in 2018 and rather extensively discussed: