[Closed] Pitch: Explicit marker for autoclosure parameters

I'd like to spin off the thing mentioned briefly in another thread as a pre-pitch.

Pitch

Introduce an explicit marker to denote autoclosure parameters making them prominent.

Motivation

bar(foo(42))

Question: is foo(42) being actually called here or not? If called - how many times?

As of now - we don't know the answer and that's a problem. We need to go see the foo's declaration and check if its parameter is a normal parameter or an autoclosure parameter:

bar(_ v: Int) { ... }
baz(_ v: @autoclosure () -> Int) { ... }

If it's normal (this would be in the majority of cases) the bar is definitely called and called just once.
If it's an autoclosure – it could be called zero, one or more times – to know for sure we must dive deeper and either look at foo's documentation or its implementation.

Nothing at the call site hints us that bar could be not called at all (or called more than once), and given that in the vast majority of cases parameters are not "autoclosed" it is very easy to miss the rare occasions when they are. That we are not hinted and alert at the call site about this possibility is not ideal.

Proposal

Use the existing precedent established for inout parameters:

someFunc(&value) // 🧐 our attention is called
otherFunc(value) // business as usual

which makes it clear at the call site how parameters are passed use some explicit marker at the call site to denote the autoclosure case. Bike-shedding:

baz(foo(42)) // 🛑 compilation error or warning
baz(autoclosure foo(42)) // 🧐 our attention is called
bar(foo(42)) // business as usual

There's a well known precedent that in the Bool expressions:

apples && oranges
apples || oranges

the second parameter is evaluated lazily. We could require the marker there as well:

apples && autoclosure oranges // 🤔

Although this feels too harsh and too against the established tradition of short-circuiting bool operators. We could make an exceptions for (all) operators to not require the marker.

Compatibility

This is a breaking change. To ease the transition we could use a warning instead of an error initially, then deprecate the old way, and in some future version make the old way a compiler error. As an interim step we may expose a compiler option that would toggle between "old way is ok" / "warning for old way" / "error for old way".

Alternatives considered

  1. Don't make the change. The problem with this is broken principle of least surprise. Swift is designed to make code easy to read and the absence of explicit marker at the call site doesn't help with that.

  2. Prohibit autoclosure parameters altogether, writing the closures explicitly. This deemed too harsh of a change. Avoiding extra nesting is why autoclosure parameters were invented to begin with.

  3. Prohibit autoclosure parameters in functions but leave it for operators. This seems to be harsh as well. The nesting is increased which is not good. While the last autoclosure parameter can be changed to a not so bad trailing closure:

    // current:
    baz(foo(42))

    // could be:
    baz {
        foo(42)
    }

it's not so rosy for parameters which are not last:

    // current:
    baz(foo(42), param2)

    // could be:
    baz({foo(42)}, param2) // 😑
  1. Don't make an exception for operators (i.e. require the marker for them as well). This feels too harsh and against what we love about bool operators.

  2. Use @lazy instead of @autoclosure marker. Perhaps this makes sense if @autoclosure designation is also renamed to @lazy otherwise it would be inconsistent.

  3. Improve the @autoclosure declaration:

  • remove @
  • remove the () -> part // there can't be anything else!
  • don't require parens to evaluate parameter
  • in rare cases when parameter should be passed further as an autoclosure use the marker.
    // current:
    baz(_ param: @autoclosure () -> Int) {
        qux(param) // passing param further down as an autoclosure
        quux(param()) // param is evaluated
    	let x = param() // ditto
    }
    // current usage:
    baz(autoclosure foo(42))

    // proposed:
    baz(_ param: autoclosure Int) {
        qux(autoclosure param) // passing param further down as an autoclosure
        quux(param) // param is evaluated, as if it was written as param()
        let x = param // ditto
    }
    // proposed usage
    baz(autoclosure foo(42))
  1. combine 5 and 6:
    // proposed:
    baz(_ param: lazy Int) {
        qux(lazy param) // passing param further down as an autoclosure
        quux(param)     // param is evaluated, as if it was written as param()
        let x = param   // ditto
    }
    // proposed usage
    baz(lazy foo(42))
  1. Use some non-word marker. After all with inout parameters we do not use the "inout" keyword itself:
    someFunc(inout value) // ❌

The choice of the marker is open though. Could it be "&" or would that feel completely wrong?

    // reuse "&" symbol? 🤔
    baz(&foo(42))

    // invent something new altogether? 🤔
    baz(^foo(42))

Thoughts?

5 Likes

I really don't have any clue what the point of autoclosures would be if using them is just strictly more verbose than regular closures. Letting users write foo(autoclosure x) instead of foo({in x}) is not a feature which would have any reason to exist.

40 Likes

Which alternative are you bumping, 1, 2, 3 or 8? For example this is slightly more concise than an explicit closure:

foo(^param) // alternative 8
bar({param})

while indeed it is equally explicit and obvious in either of these compared to the current autoclosure parameter passing.

The “auto” in “autoclosure” is short for automatic, meaning that the caller doesn’t have to write anything to indicate a closure. The entire purpose of the feature is to enable such use cases, and there is absolutely no circumstance in which Swift would change the spelling of the right-hand side of && and ||, nor the arguments to assert and precondition.

If it’s a problem for a particular function to take an autoclosure, then that is a mistake in API design that falls on the author of that function.

26 Likes

Anything which involves syntax at the call site is discarding the "auto" part of autoclosures, and thus the entire feature. Whether or not autoclosures provide such potential for misuse that the feature shouldn't exist at all is perhaps a conversation that could be had, but realistically that's a ship that's sailed. Trying to pretend that you aren't just removing autoclosures by introducing a completely different feature with the same name doesn't seem productive.

4 Likes

I see, you are bumping (1). The understandable "ship has sailed" argument aside for a moment, from your perspective where did we do the mistake initially? Is it not having a marker for autoclosure parameters or having the marker for inout parameters, or not having the inout parameters named with "auto" in it, like "auto_inout"? Or do you think that it's totally fine to have this inconsistency and fine that we don't know upright if foo is being called (and how many times) in the bar(foo(42))?

I think that the motivating example is just poor API design where SwiftUI is being overly clever rather than a problem with the feature which makes the poor design possible. @autoclosure should be used very sparingly; it's a footgun by design and so should only be used when there is a very good reason (as in assert()) or where the conditional evaluation is self-evident (as in ?., where it's the whole point of the operator). It's use in @StateObject doesn't come anywhere close to satisfying either of these, but it's far from the only place where SwiftUI does that sort of thing.

6 Likes

Do you have a good example where autoclosure is appropriate to use as a function (not operator) parameter? If we don't have a good example, perhaps the alternative (3) is then the thing to consider?

One real-world example I contributed to a codebase I work on:

extension UITraitCollection {
    @inlinable
    func valueForHorizontalSizeClass<T>(
        whenRegular regularValue: @autoclosure () -> T, 
        otherwise defaultValue: @autoclosure () -> T
    ) -> T {
        switch horizontalSizeClass {
        case .regular:
            return regularValue()

        case .compact, .unspecified: fallthrough
        @unknown default:
            return defaultValue()
        }
    }
}

In various places in our app, we need to use different values for .compact vs .regular horizontal size classes (read: iPhone vs. iPad).

Background
  1. I wanted to avoid checking horizontalSizeClass == .compact or horizontalSizeClass == .regular because UIUserInterfaceSizeClass is not a frozen enum, and may gain additional cases in the future. A switch requires us to be explicit in all cases, and if such a value were introduced in the future, we'd avoid unintentionally falling into some default case were it not appropriate
  2. Because this 5+ line switch statement is noisy to have all over the place, one central location that kept the benefits of the switch seemed appropriate
  3. Most often, the value being returned here is dead simple: usually a CGFloat constant used for setting constraint constants differently between size classes. However, sometimes the value is the result of a complex calculation that would be nice to avoid when unused

@autoclosure here helps keep call sites clean when regularValue and defaultValue are constants (e.g., (whenRegular: 42, otherwise: 36) vs. (whenRegular: { 42 }, otherwise: { 36 })), and avoids unnecessary computation when they are expensive operations. Theoretically, I could have introduced two different overloads (one for T and one for () -> T), but critically, @autoclosure helps us avoid accidentally eagerly passing in the result of an expensive computation that won't be used (i.e., we would have to remember to explicitly call the closure overload every time where the value were expensive to compute, with the compiler unable to offer us help in remembering to do so).

This has worked out for us really well in what I think is a good use-case for @autoclosure.

8 Likes

Thanks for your example. So you write this:

collection.valueForHorizontalSizeClass(whenRegular: foo(), otherwise: bar())

Isn't that a longer version of:

collection.isHorizontalSizeClassRegular ? foo() : bar()

with a similar switch in the extension:

extension UITraitCollection {
    var isHorizontalSizeClassRegular: Bool {
        switch horizontalSizeClass {
        case .regular: return true
        case .compact, .unspecified: return false
        @unknown default: return false
        }
    }
}

However that'd be only good for two options. Should you wanted more options, then your method would be indeed superior:

let value = collection.valueForHorizontalSizeClass(
    regular: foo(),
    compact: bar(),
    unspecified: baz(),
    unknown: qux()
)

... until we have switch expressions:

let value = switch collection.horizontalSizeClass {
    case .regular: foo()
    case .compact: bar()
    case .unspecified: baz()
    @unknown default: qux()
}
1 Like

Indeed it is — this form would have worked too. We do, however, both use this method directly, and also layer some additional helpers on top of it, so we've found it helpful to keep it around. One other key benefit to the method form: you can't accidentally forget to handle either case, like you can with

if traitCollection.isHorizontalSizeClassRegular {
    // Do the `.regular` thing.
} // Oops, forgot the `else`

// ...

Indeed; because the existence of this method is for the purpose of being forward-looking, we preferred a form that would be easily extensible with minimal code churn, if possible.

This seems like a non-problem that doesn’t need solving. This is evidenced by the lack of compelling example of harm in the pitch, as well as the argument against the “do nothing” option which cites (IMO dubious) “principle of least surprise” – a principle that is often cited when arguing for otherwise-unmotivated ceremony, something Swift tries to avoid.

(The classic example of a function that needs to take an autoclosure would be assert, though I guess these days the cool kids would use a macro)

15 Likes

I understand the concern but agree with what most people are saying (i.e. auto describes not having to explicitly express any extra syntax).

Maybe this is more of an issue that can be solved with a style guide for your project and some type of optional notation like an inline comment?

3 Likes

I also wish this wasn't overused, last I tripped on this was with DocumentGroup's init newDocument: argument (see here). Utterly confusing. The documentation only makes this worse as it hides the autoclosure (and other important keywords such as async and throws) in the overview, so one needs to go there to actually see the autoclosure. But alas this is what @autoclosure was designed to allow so, like the others here, I do not see how this can be addressed.

2 Likes

I can certainly introduce some style guideline for my own project(s). Taking assert example:

my_assert(lazy: expression())

The problem (which most responders of this thread found "non-existing") is working with other people projects / API's that won't use this guideline or any particular guideline in regards to autoclosure parameters:

other_people_assert(closure: expression()) // 🤔 perhaps lazily evaluated, let's read the docs / the code
other_people_assert(expression())       // 🤔 perhaps lazily evaluated, let's read the docs / the code
other_people_precondition(expression()) // 🤔 perhaps lazily evaluated, let's read the docs / the code
other_people_foo(expression())          // 🤔 could still be lazy evaluated, let's read the docs / the code

IMHO that's a mine field, and many other languages consciously decided not having this feature (and not to take it from Swift) - † for that very reason: lack of clarity at the call site.

† there's of course the "let's not change things" bias for older languages, so the best litmus test would be "do the new languages that were created after Swift have this feature or not".

Now, compare this or a similar adhoc parameter naming style:

foo(lazy: expression())
bar(lazy_param: expression())

to:

foo(lazy expression())
bar(param: lazy expression())

Almost the same verbosity wise. Just it's no longer an optional guideline but a codified rule checked by the compiler, there are no more doubts, this works universally across all projects and API's, and even when you work with an unknown API it's immediately clear at the call site if the parameter is a closure or not.

I think you'll have some difficulty convincing people that this:

precondition(lazy denominator != 0, lazy "Can't divide \(numerator) by zero")
logger.info(lazy "Starting division of \(numerator) by \(denominator)…")

is an improvement over this:

precondition(denominator != 0, "Can't divide \(numerator) by zero")
logger.info("Starting division of \(numerator) by \(denominator)…")

I'll also note that lazy is the wrong keyword here since auto-closures can be called multiple times while lazy implies the result is cached after the first call. Perhaps lazy would have been a better feature than auto-closure though, preventing multiple evaluations and thus making things less surprising. But when used "correctly", I don't think auto-closures are bad or need extra markings at the call site.


It'd be interesting to learn about what exactly pushed you to write this pitch though. Did you read the documentation and came up with "this is surprising"? Or did an @autoclosure in an API somewhere caused an issue for you?

5 Likes

It'd a little more verbose but Swift prefers clarity at the point of use over brevity, witness would be existing precedents of & and @ parameters.

Good point. "closure" keyword would be more exact.

Agree.

Just that looking at the call site of any unfamiliar_api_foo(bar(42)) I don't understand if the parameter in question is normal (evaluated exactly once) or an (auto)closure (in which case it could be not evaluated at all, or evaluated more than once) – to me this lack of clarity at the call site is a huge drawback, and the new languages that decided to not have a feature analogous to Swift's autoclosure are in agreement with me.

Reading API documentation / implementation could be fragile and misleading (e.g. I read it today, start using the API, then update to a new version of API in a month time, recompile the code and as the code still compiles ok I am not alert of the change when some parameter changed to use @autoclosure – just my app could start misbehaving). I found the answer "everyone just needs to be wise when using "@autoclosure" parameters, use them sparingly, and if they don't - talk to them" weak and unsatisfying.

Another alternative that works in the IDE only - have the IDE to show this marker inline, similar to how Android studio shows parameter names inline for Kotlin.

1 Like

But what would be the purpose of having autoclosures, but then still having to decorate them? The entire point of autoclosures is to allow us to write

let isboldAndItalic = isBold && isItalic

instead of

let isboldAndItalic = isBold && { isItalic }

What would be the point of allowing us to write that as

let isboldAndItalic = isBold && lazy isItalic

Then all we do is introduce an alternative spelling for closures.

1 Like

The pitch suggests leaving the operators as they are (other than in alternative #2), so isBold && isItalic will remain unchanged.

Note, there are languages that have short-circuiting behaviour for boolean expressions but have no general notion of autoclosure function or operator parameters.

Right, or even any "single expression fragment enclosed in { } brackets". I am not suggesting it in the pitch, but for the sake of considering all alternatives here you are:

items.map --> $0.name
...
func foo() --> print("hello")
...
if condition --> foo() else --> bar()
...
while condition --> foo()
...
etc

OK, && was a bad example. But what is the point of allowing "autoclosure" but requiring it to be annotated? How is annotated autoclosure different from just using a normal closure?

Given

func foo(@autoclosure () -> Bool)  { }

You want to replace

foo(5 > 2)

with

foo(lazy 5 > 2)

but why not just abolish @autoclosure then and require

foo { 5 > 2 }

What purpose will @autoclosure serve in a world where your pitch has been accepted?`

5 Likes