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

Today if a function takes a parameter Void, it has to called like so:

func foo(_: Void) { ... }

foo(())

I propose it can be called as this:

foo()

This may not seem particularly useful, but especially for generic functions where Void is a valid generic parameter, it seems strange to have to pass inn () explicitly.

Consider:

let subject = PassthroughSubject<Void, Never>()

// today
subject.send(())

// proposed
subject.send()
8 Likes

I don't understand the value in privileging Void just because it has only one expressible a value in Swift's syntax.

I think this was in the language, maybe before Swift 3, if I'm not mistaken?

It's convenient... but it would be yet another special case, so I'd rather continue to write extensions for void parameters instead of adding more magic to the compiler.

1 Like

I don't quite remember, but I think there were some special treatments that Void had previously and now it doesn't have anymore. So, I think this might not be aligned with the core team goals?

-- EDIT

As mentioned by @xwu and @ktoso below, the feature was related to tuples in general. It wasn't a special case for Void.

So... this isn't exactly what you're suggesting, but since this reminds me of a similar feature in Scala which caused all kinds of issues might as well drop it in for reference and consideration.


This seems like a limited - specifically only for Void - version of Scala 2's auto-tupling, though there it is more powerful since it allowed for func hi(_: (Int, Int)) to be called as hi(2, 3). And yes, it'd also allow void(_ : Void) as void() since it'd "auto tuple it into void ( () ) basically.

I'm mentioning this because this feature generally caused more pain than benefit, especially with method overloads and eventually became an anti pattern to depend on and in the end was completely removed from Scala 3.

Having that said... the Scala feature was way more powerful than what you're suggesting -- but in any case, throwing it in here as a word of caution that this can get very complex very fast if generalized.

5 Likes

@xedin, could this be handled with your Type inference from default expressions pitch?

struct PassthroughSubject<Output, Failure: Error> {
    fun send(_ value: Output = ()) { ... }
}

(Assuming PassthroughSubject did not already provide the naked send() as a specific overload.)

3 Likes

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

9 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