Allow Default Parameter Values to use local properties

Hello everyone,

I would like to address a limitation around default parameter values.
Currently, default parameter only allow concrete values and does not support accessing to a local property.

The following is not a valid Swift code:

class Builder {
  let defaultProperty: String
  init(defaultProperty: String) { ... }

  func make(property: String = self.defaultProperty) -> NewObject {
    return NewObject(property: property)
  }
}

The intention looks clear enough, we want to use self.defaultProperty as default property for property.

In order to achieve a similar result, we are required to perform some changes. In particular, to wrap our parameter type in an optional, so that we can use the self.defaultProperty whenever it is nil.

func make(property: String? = nil) -> NewObject {
  let property = property ?? self.defaultProperty
  return NewObject(property: property)
}

This approach gets the job done, yet, looking at the method declaration String is now String?, and I see two issues:

  • In case we perform a similar change (moving from a constant default value to a local property as in the example), it may be a breaking change for libraries since we are changing the type.
  • Reading the API it is not super clear why that property is now optional, will my NewObject actually have the property set to nil if we pass nil?. (Eventually this could be "solved" by make it clear in the method documentation.)

Anyway, I would like to hear your opinions and thoughts about this. Maybe there is already a valid reason for the current limitation? If so, could anyone give me more context? :slight_smile:

Thank you very much!

4 Likes

I think in general we don't want default arguments to depend on other arguments because you might end up having to deal with a chain of arguments to resolve, but self is special. I'd be mildly in favor of making this work (it's slightly easier now that default arguments are always inlined into callers). I'm not sure how hard the implementation would be, though.

It's also tricky because default arguments can reference static properties, and allowing them to also access instance properties, even with an explicit self, could be confusing:

struct Test {
  static var name = "Foo"
  func test(message: String = name) {
    print(message)
  }
}

Test().test()
2 Likes

Why would that be more confusing than having a static and instance property with the same name used in other contexts?

Unlike C++, Swift doesn't allow you to use a static property from an instance context; you'll get an error if you do. But doing that here would be source-breaking, so we'd have to make it a warning at best.

(The alternative of continuing to allow the static access but also allowing self.instanceProp seems more confusing to me.)

Right. I forgot that we need to prepend the type name for static vars. When are we going to get the static equivalent of ObjC's [self class] to eliminate the need to hardcode type names?

:-/ SE-0068

3 Likes

I think in principle this would be a nice improvement to the usability of the language. I've seen a few dupes of this already that were filed as bugs. However there are a few caveats, and we need to think about them carefully:

  • Would the default argument expression always capture self as a value, or are inout captures possible, if the default argument expression calls a mutating method?

  • There are potentially issues with evaluation order here. When calling a method on a complex lvalue (like foo.bar.baz(x, y, z)) we currently delay evaluating the lvalue (foo.bar) until as late as possible, to avoid exclusivity violations if the arguments themselves reference the lvalue again. This is what allows foo.bar(foo.baz()) to work without an exclusivity violation when bar() is a mutating method. If the default argument can reference 'self', this might complicate the evaluation order further. I recall something similar coming up recently when the default argument was in a protocol extension and captures the Self type. cc @Joe_Groff

  • How would we implement this in multi-file mode without slowing down compile time? Right now we don't have to type check default argument expressions on functions in other source files -- we just have to know that they exist. If a default argument expression may or may not capture self, we would have to type check it to determine this fact. We could also say that the calling convention for a default argument expression is to always take self.

6 Likes

This would clearly be a significantly breaking change in Swift to do it at this point, but given all the complexity of default argument expressions for ABI stability as well as the capability asked for here, I'm curious why the implementation of argument defaults hasn't always been syntactic sugar over the workaround talked about in the original post: wrapping the real parameter type in an extra optional and then immediately unwrapping with ?? and the default expression.

It seems like that would make a lot of issues easier. Defaulted arguments would just always be passed as nil in to the callee, but the type checker would be using the 'real' type, so syntactically you wouldn't be able to mistakenly pass an explicit nil.

1 Like

I'd be happy to say default argument expressions always take self as __shared (even for a mutating or __consuming function, and even if they don't use it). That answers all three of your questions, even if it does close off a few things you can do with explicit arguments.

1 Like

The main reason I can think of is just to avoid overhead. Some types can been packed into Optionals without changing their representation (those with "extra inhabitants", including object references, pointers, and a few others), but those that cannot would possibly make the call less efficient (by, say, requiring two registers instead of one for a particular argument). Even with that, by doing the defaulting in the callee instead of the caller, you're deferring the check until run time, and it'll happen for every caller. Fast, but not free.

Implementing default arguments with Optionals also has interesting implications elsewhere because of this change of ABI. Right now it's an ABI-compatible change to add a default to an existing method, but with the "hidden Optional" implementation that wouldn't be the case.

Similarly, we currently have strange behavior around overridable methods and defaults, but this would make it worse. Consider:

class Base {
  func foo(_ value: Int = 42) { print(value) }
}

class Sub: Base {
  override func foo(_ value: Int) {
    super.foo(-value)
  }
}

let obj: Base = Sub()
obj.foo() // ???

If the callee is supposed to be resolving default arguments here, how does Sub.foo know what the default argument is supposed to be? Or is there some kind of function that resolves default arguments before dispatching through the vtable? (This is a totally possible implementation strategy, by the way. I'm just pointing out how it gets more complicated.)

Note that we have a version of this problem today anyway: how can the subclass declare a default argument that's the same as the base class's, or based on it?

I don't know if these arguments (pun retroactively intended) are fully convincing, but I think if default argument values had overhead over not having them, or even over passing values explicitly, both clients and library authors would feel a lot less comfortable using them.

5 Likes

Mmh, you are right, overhead is not fun :slight_smile:

One question, does this applies also to Structs? Or we could be able to optimize things in that case?

I was thinking about other uses cases where this applies.
Such as a copy method autogenerated (same way as default initializer) by the compiler for structs.
For example for the following struct:

struct Foo {
  let value1: Int
  let value2: Int
}

We could have something like:

func copy(value1: Int = self.value1, value2: Int = self.value2) { ... }

I was planning to pitch it in a separate thread after the evaluation of this topic.
Do you think it could be something valuable to talk about? Should we wait that we have a clear direction (positive or not) for Default Parameters first?

You're right, we could just always pass in self even if its not used, and if we pass it in borrowed the overhead should not be great. I'm still concerned about evaluation order. If the base of the call is an existential, we may or may not have opened the existential or begun the formal access on the lvalue when the default argument runs, correct?

[delayed response]:

You're right; it does get tricky. I had to come up with an example to convince myself:

struct Inner {
  private var data: Data
  mutating func mutate(newContents: Data = self.data, tagForLogging tag: Int = 0)
}

struct Wrapper {
  var inner: Inner
  var tag: Int
}

func problem(_ wrapper: inout Wrapper) {
  // Good
  wrapper.inner.mutate(newContents: Data(), tagForLogging: wrapper.tag)
  // Bad
  wrapper.inner.mutate(/*newContents: default,*/ tagForLogging: wrapper.tag)
}

Order of evaluation ("Good") is something like this:

  1. %1 = Data()
  2. %2 = wrapper (the parameter one)
  3. %3 = %2.tag
  4. %4 = wrapper (begins mutating access)
  5. %5 = %4.inner
  6. %6 = %5.mutate(newContents: %1, tagForLogging: %3)
  7. %4.inner = %6 (ends mutating access)

(I might have this a little wrong; the "begins mutating access" line might not apply right when we read from wrapper. But the basic idea is correct.)

In the "Bad" case, the evaluation of %1, the first argument, has to access the self value, which is currently %5.

  • If we just move %4 and %5 up, the computation of %2 for the second argument is an access violation.
  • We could do a non-mutating access followed by a mutating access, but that could have unwanted side effects. At the very least it's probably expensive.
  • If we move %1 down, the parameters aren't being evaluated left-to-right.

I think the last one is probably what we'd want to do if we decided to implement this: "all provided arguments are evaluated, then the self value, then the default argument values", but that is a change in behavior from what we have today, and it is rather subtle.

3 Likes