Allow default argument expressions to reference earlier parameters

Occasionally, I find myself wanting to write something such as:

func f(x: Int, y: Int = x) { // 'y' defaults to whatever is passed for 'x'
  print(x, y)
}

AFAIU, Swift guarantees argument evaluation order from left to right (though I couldn't find a reference for this... so I could be misremembering?), so I believe that letting default argument expressions refer to previous parameters should be well defined. A call such as f(x: 0) would implicitly expand to something like:

let _x = 0
f(x: _x, y: _x)

Currently, if you want later argument defaults to depend on earlier arguments, the solution is to provide two entry points:

func f(x: Int) {
  f(x: x, y: x)
}

func f(x: Int, y: Int) {
  print(x, y)
}

A potential issue with this feature is that code such as the following is legal today:

struct S {
  static let x = 1
  func f(x: Int = 0, y: Int = x) { // 'x' here is 'S.x'
    print(x, y)
  }
}

so we'd have to special case name lookup to prefer any names from outer scopes before other arguments (or some other mitigation).

What do people think? Useful, or too opaque?

9 Likes

A possible mitigation might be allowing the function name to act as a scope specifier:

struct S {
  static let x = 1
  func f(x: Int = 0, y: Int = f.x) { // 'x' here is 'function parameter x'
    print(x, y)
  }
  func g(x: Int = 0, y: Int = S.x) { // 'x' here is 'S.x'
    print(x, y)
  }
}

I guess even this doesn't work if there's some variable f in scope that has a property x.

1 Like

I had the same initial thought, but looks like that is actually available, the following code is an error today:

struct F {
  var x: Int
}

let f: F = F(x: 0)

struct S {
  func f(x: Int, y: Int = f.x) { // error: use of 'f' refers to instance method rather than let 'f' in module 'Playground'
    print(x, y)
  }
}

So it seems like that disambiguation would be possible.

I'm going to call it "useful". I don't remember why, but I know I've wanted to do exactly that a few times. Furthermore, I don't see how the concept would be any more "opaque" to users than the language currently is. At the call site, it's just "default values", which we already have. I supposed a particular syntax could make it unergonomic to write such functions which utilize this, but avoiding that kind of syntax is part of why we have the Swift Evolution process.

2 Likes

What about labels ?

func f(x a: Int, y: Int = x)

or

func f(x a: Int, y: Int = a)

I think this might pose fun ABI problems. Specifically, Swift function default arguments are produced by little function thunks that the caller can call. These functions are currently zero-argument: to have them depend on earlier arguments, they'd need to take earlier arguments as their own arguments. Put another way, these little thunks would now be able to have an arity other than 0.

This change would mean that function default arguments sometimes leak into the ABI, at least in terms of whether they depend on a previous value. That seems like a bit of a subtle footgun to me. It's definitely manageable (tooling can report it if you get this wrong), but I do think it would be quite hard to communicate and likely surprising to people.

6 Likes

That would be the latter (consider the case func foo(_ a: Int, y: Int = a)

Overall, I don't think this feature is frequently used to deserve language change.

(In this area I'd like to see func foo(x = false) /* type is inferred */, similar to let x = false)

Good point. We could potentially mitigate this by always emitting the full-arity version of the thunk in library evolution mode (i.e. the one that accepts all preceding parameters) as soon as the author introduces any default argument. But perhaps that would be too heavy for the ‘normal’ case where default values don’t depend on earlier parameters (and that scheme wouldn’t help library authors who have already shipped default argument thinks under the current scheme…)

Yeah this would have a really nasty code size impact I think: trying to manage caller-preserved registers starts to get really hairy as these functions can invoke arbitrary code, and so they'll rapidly start to stomp on other registers (particularly the argument-passing and return value registers). If we implicitly widened their arity for no reason you would start to see code size balloon around them.

2 Likes

a DIY workaround that works today:

func foo(x: Int, y: Int! = nil) {
    let y = y ?? x
    print(x, y)
}

foo(x: 1, y: 2) // prints: 1, 2
foo(x: 1) // prints: 1, 1
foo(x: 1, y: nil) // strange I admit, but still prints 1, 1
4 Likes

If we implicitly widened their arity for no reason you would start to see code size balloon around them.

Today, functions like foo(x: Int = 0, y: Int = 1, z: Int = 2) do combinatorial thunk explosion. IMO, that is the real issue, and they make default arguments unwieldy regardless of what features they have.

The primary consequence of this proposal is it makes default arguments more friendly, which makes you more likely to use them, and that leads to more explosion, but IMO it's incidental.

The combinatorial explosion is straightforward to solve, although it requires taking a step back to see from a different perspective. If you look broadly at Swift features they are mostly shorthand for some longer, "obvious" implementation. For example, implicit Equatable/Hashable/Codable are shorthands for the obvious implementations. Generics are to some extent shorthand for a templating system, etc.

But "thunks" are not an obvious expansion of default arguments, at least not in the sense of being emitted into the executable with a prologue, etc., as evidenced by all the proposals that 'discover' the explosion problem. I think if you asked someone to tell you what foo(x: Int, y: Int = x) is "short for", tthey might give you tera's solution. Or they might give you something like this:

protocol FooArgs {
    var args: (Int,Int) { get }
}
struct FooOneArg: FooArgs {
    let x: Int
    var args: (Int, Int) { (x,x) }
}
struct FooTwoArgs {
    let x: Int
    let y: Int
    var args: (Int, Int) { (x,y) }
}

func fooPAT(args: FooArgs) {
    let (x,y) = args.args
    print(x,y)
}

Most developers who use default arguments really wanted "semantic sugar for one of these".

Technically, in this situation the explosion is now on the types, but since the types are trivial and anonymous we don't need to emit them. We do need to emit default values, e.g. an indirect mapping of fields. But layout could be implicitly a tuple for example. Since the compiler is the only user of the type, protocol witness tables etc. are not needed, nor is anything beyond what the compiler uses internally. So ultimately all the combinatorial bits "boil away".

An expression-based solution like tera's may be simpler than boiling away anonymous types, depending on what features we want to add later.

This does have runtime overhead, which seems in line with Swift features that expand to an implementation. But there are other options, such as

func fooGenerics<A: FooArgs>(args: A) {
    let (x,y) = args.args
    print(x,y)
}

This has the combinatorial explosion but it's at specialization-time, since specializations are late you only pay for the combinations you use.

But basically, I think thunks are just not a good fit for what people are actually doing with the feature, nor are they in line with how the language is used in general. We should probably fix that, although it is a bit tricky to make the ABI compatible.

AFAIK Swift's thunks for default args are generated on a per-argument basis, and specifically don't exhibit the combinatorial blowup you've mentioned. E.g., if we inspect the unoptimized SIL for this function:

func foo(w: Int = 0, x: Int = 1, y: Int = 2, z: Int = 4) {}

we get the following entry points:

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
...
// default argument 0 of foo(w:x:y:z:)
sil hidden @$s4test3foo1w1x1y1zySi_S3itFfA_ : $@convention(thin) () -> Int {
...
// Int.init(_builtinIntegerLiteral:)
sil public_external [transparent] @$sSi22_builtinIntegerLiteralSiBI_tcfC : $@convention(method) (Builtin.IntLiteral, @thin Int.Type) -> Int {
...
// default argument 1 of foo(w:x:y:z:)
sil hidden @$s4test3foo1w1x1y1zySi_S3itFfA0_ : $@convention(thin) () -> Int {
...
// default argument 2 of foo(w:x:y:z:)
sil hidden @$s4test3foo1w1x1y1zySi_S3itFfA1_ : $@convention(thin) () -> Int {
...
// default argument 3 of foo(w:x:y:z:)
sil hidden @$s4test3foo1w1x1y1zySi_S3itFfA2_ : $@convention(thin) () -> Int {
...
// foo(w:x:y:z:)
sil hidden @$s4test3foo1w1x1y1zySi_S3itF : $@convention(thin) (Int, Int, Int, Int) -> () {

Notably, there is only one entry point for foo itself, as well as one for each default argument—no combinatorial explosion.

Unless I've misunderstood what you're saying?


Anyway, another solution for the ABI issue would be to simply disallow default arguments depending on earlier parameters in library evolution mode. If that would make library evolution mode too dialect-y, we could additionally extend @frozen to enable it to be applied to functions (where it would explicitly promise "the signature of this function, including its default arguments, will never change") and only allow this feature to be used on @frozen functions.

While I appreciate the elegance of the originally proposed syntax, between the issue about special-cased name lookup and this stuff about ABI and frozen functions, it seems a lot—not necessarily in terms of what a user has to write but rather what a user has to understand when it comes to subtleties and caveats—compared to the status quo of just writing another overload.

2 Likes

I'm personally less concerned about the library evolution issues since it's an expert feature already fraught with special cases and extra considerations, and would be fine to (at least initially) say this feature was unavailable in library evolution mode.

The source compatibility issue is more troubling to me. We could maybe get away without totally inverting name lookup if we allowed for a (I suspect very minor?) source break in a new language version, so that in the problematic example:

struct S {
  static let x = 1
  func f(x: Int = 0, y: Int = x) {
    print(x, y)
  }
}

we would just emit an "ambiguous use of x" error rather than maintaining the old meaning.

Another potential use for this feature is allowing default arguments which reference instance members:

struct S {
  var x: Int // note: no 'static'
  func f(y: Int = x) { // error today: Cannot use instance member 'x' as a default parameter
    print(y)
  }
}
1 Like

While I understand why a thunk would be generated for something like this:

// String.init(_:, radix: = 10)
let nums = [1, 2, 3, 4, 5].map(String.init)

I've never understood why simply calling a function with default arguments requires a thunk at all.

If I didn't already know that thunks were used I would expect the metadata for each function to simply include the list of all default parameters and have the compiler insert them into the call for you. e.g.

// I would expect this:
let two = String(2)
// to desugar into this
let two = String(2, radix: 10)
// instead of
let two = { String($0, radix: 10) }(2)
2 Likes

As far as I was aware this is not how thunks are used for default arguments. With the foo(w:x:y:z:) definition above, if we call foo(w: 1, y: 3), we get the following SIL:

  %2 = integer_literal $Builtin.Int64, 1          // user: %3
  %3 = struct $Int (%2 : $Builtin.Int64)          // user: %11
  %4 = integer_literal $Builtin.Int64, 3          // user: %5
  %5 = struct $Int (%4 : $Builtin.Int64)          // user: %11
  // function_ref default argument 1 of foo(w:x:y:z:)
  %6 = function_ref @$s4test3foo1w1x1y1zySi_S3itFfA0_ : $@convention(thin) () -> Int // user: %7
  %7 = apply %6() : $@convention(thin) () -> Int  // user: %11
  // function_ref default argument 3 of foo(w:x:y:z:)
  %8 = function_ref @$s4test3foo1w1x1y1zySi_S3itFfA2_ : $@convention(thin) () -> Int // user: %9
  %9 = apply %8() : $@convention(thin) () -> Int  // user: %11
  // function_ref foo(w:x:y:z:)
  %10 = function_ref @$s4test3foo1w1x1y1zySi_S3itF : $@convention(thin) (Int, Int, Int, Int) -> () // user: %11
  %11 = apply %10(%3, %7, %5, %9) : $@convention(thin) (Int, Int, Int, Int) -> ()
  %12 = integer_literal $Builtin.Int32, 0         // user: %13
  %13 = struct $Int32 (%12 : $Builtin.Int32)      // user: %14
  return %13 : $Int32                             // id: %14

In this snippet %7 and %9 contain the defaulted arguments, and come from the default argument thunks corresponding to that argument. In other words, the definition and call of foo desugars to something like:

func _foo_w_default() -> Int { 0 }
func _foo_x_default() -> Int { 0 }
func _foo_y_default() -> Int { 0 }
func _foo_z_default() -> Int { 0 }
func foo(w: Int, x: Int, y: Int, z: Int) {}

foo(w: 1, x: _foo_x_default(), y: 3, z: _foo_z_default())

It's the default argument expressions that are 'thunked', not the call to foo itself.

2 Likes

Why is this done this way? I am with @Nobody1707 here.

So that the default values can change without breaking ABI.

Edit: perhaps I’m misunderstanding the question? In order to support ABI safe evolution of default function arguments, you either need to provide getters for the default values, or you need to synthesize the set of overloads that accept the combinatorial explosion of all defaulted and non-defaulted arguments. In addition to increased code size, the latter scheme results in a very different impact on the branch predictor: call the overload which jumps to the real implementation.

To be honest I do not understand the end goal.. Suppose there is an API:

func equal(_ x: String, _ b: String, _ options: Options = .caseInsensitive)

My app uses this API and the following holds:

precondition(equal("A", "a"))
precondition(!equal("à", "â"))

Now in the next version the API is changed to:

func equal(_ x: String, _ b: String, _ options: Options = [.caseInsensitive, .diacInsensitive])

The app is not rebuilt, and:

precondition(equal("A", "a"))
precondition(!equal("à", "â")) // now fails

If arguments were passed the same way as if I wrote them in my app explicitly (without thunks), then the original app would continue to work. And once I rebuild the app with the new API - the will fail during testing (which is expected).

Shall argument default values be NOT part of ABI?