[Pitch] Adding New Parameters w/ Default Values Should Not Break API

The Problem

As of now, packages which follow Semantic Versioning cannot add new parameters which have default values to existing methods, without breaking their public API and requiring a major Sem-Ver version bump.

Why?

Imagine you have a function like this:


// MARK: Your Public API
public func doSomething(value: String) -> String {
    value
}

// MARK: A User's Code
let values: [String] = ["a", "b", "c"]

_ = values.map(doSomething)
_ = values.map(doSomething(value:))
_ = values.map({ doSomething(value: $0) })

This is all fine and compiles without any problems.

Now imagine you want to add a new parameter to the function.
You know that'll be API-breaking, so you decide to provide a default value to the new parameter:

// MARK: Your Public API
public func doSomething(value: String, anotherValue: String = "") -> String {
    value + anotherValue
}

Little do you know, that still breaks the public API and requires a major Sem-Ver version bump.

Your user's code no longer compiles:

/// Error: Cannot convert value of type '(String, String) -> String' to expected argument type '(String) throws -> String'
_ = values.map(doSomething)

/// Error: Cannot find 'doSomething(value:)' in scope
/// Error: Converting non-escaping value to '(String) throws -> T' may allow it to escape
/// Error: Generic parameter 'T' could not be inferred
_ = values.map(doSomething(value:))

/// No Error
_ = values.map({ doSomething(value: $0) })

This means you can't add new parameters even with default values to any public methods, even if you're 99.9% sure no-one would even think of doing something like those values.map(doSomething(value:)) with your doSomething() function.

This is what i could call "a lot of pain with minimal gain", because even though you know no-one would use that syntax on your method, you still have to account for it.

Who Cares?

This is quite a burden on shoulders of maintainers of packages which follow Semantic Versioning.
It can result in a lot of meaningless boilerplate code which will only grow overtime.

The Solution

I understand that probably none of these solutions are doable in Swift 5. I can wait for Swift 6.

Here are some of the ways we could solve this problem:

  1. Just totally remove this syntax.

  2. Remove the values.map(doSomething) syntax, but keep values.map(doSomething(value:)).

Then the compiler can validate the function calls by expanding them to the full form (values.map({ doSomething(value: $0) })) instead of just looking at the function signature.

6 Likes

Isn't a better solution to let the compiler reabstract doSomething(value:anotherValue:) into a (String) -> String closure when used in a place that expects a function with one argument?

7 Likes

I feel like it can get hard for the compiler to try to work out all the possibilities and decide which parameters to choose. Though it could work.

Generally speaking i don't like that syntax anyway as it's too ambiguous.

EDIT: To be more clear, by "that syntax" i meant values.map(doSomething).

1 Like

What if, instead of going from 1 argument to 2, you’re going from 0 to 1? Then there is no equivalent to the latter, and only the former is even possible.

Ah right. Then values.map(doSomething) should only be accepted if the function doesn't take any arguments.

Interestingly even if you provide the two functions:

public func doSomething(value: String) { ... } // old
public func doSomething(value: String, anotherValue: String) // new

it could still be a breaking change as the user code might have already had it's own version of the new call, and adding that to the library would break user's code.

1 Like

It's considered a bad practice for users code to extend a library's code with thier own functions.
Doing something like that and having a broken code would be user's own fault, not library's.

2 Likes

I think the right solution here is to make this work consistently. A function with a default argument can always be equivalently rewritten as two functions with the same name but with different numbers of arguments. When you do that manually, you are relying on the type checker to pick the right overload and it works correctly:

func doSomething(value: String) -> String { 
  doSomething(value: value, anotherValue: "")
}
func doSomething(value: String, anotherValue: String) -> String { 
  value + anotherValue
}

print(["one arg"].map(doSomething))
print([("two", " arg")].map(doSomething))

So the fact that this doesn't work when you just write one function with a default argument smells like a bug or consistency issue.

16 Likes

I wouldn’t say it’s considered bad practice in my experience - it’s often the cleanest way to centralize some common usage patterns of external libraries. But absolutely an "at your own risk, a future update could easily break your code" option that you should weigh the positives and negatives of.

4 Likes

That works for me :slight_smile:

Either way i think we agree that it's not responsibility of the library maintainers.

I did mention that we should totally remove that .map(doSomething) syntax, but that's nothing near the main problem and focus of this pitch, so i'm more than happy if we could just make the compiler work consistently in these situations like you mentioned as that will solve this pitch's concern.

It works for a function with one or two default arguments, but in general this is a permutation problem. Imagine writing all possible overloads for a function with 10 default arguments.

3 Likes

Another thing, strictly speaking a function with a default argument isn't equivalent to two functions, because the expression you use as the default value becomes part of a client. When you write two function this expression remains as part of the module.

1 Like

It was a bit vague to me too, but i think what he meant was "This works when you have 2 different functions, so it should also work with just one function which has both parameters, one with a default value."

1 Like

I agree that functions with default parameters should be considered little more than syntactic sugar, at least wherever it's unambiguous. Issues might arise if you try to do too much overloading and you get overlapping method definitions, but in the case OP outlined it should be fine to simply convert one method with a default parameter to two separate methods.

BTW, which foo is called here?

func foo(_ value: Int, another: Int = 0) {}
func foo(_ value: Int) {}

foo(42)

It does feel a bit strange that there is no warning here. Arguably both calls could be a match, just the second one is a "more close match".

4 Likes

Generally that's always the behavior of the swift compiler. It just choses the closest match.
I don't see this as being too vague, but maybe i've just gotten used to it.
I'm not sure if there are any non-trivial solution for that. It could break a lot of other things for little gain.

Just totally ban the parameters with default values. That would make me really happy. :slight_smile:

Why?

Defaulted parameters are really useful sometimes.

func fooSomething(foo: Int = 0, bar: Int = 0, baz: Int = 0)

Without that you'd have to make a TON of permutations to achieve the same effect.

func fooSomething()
func fooSomething(foo: Int)
func fooSomething(bar: Int)
func fooSomething(baz: Int)
func fooSomething(foo: Int, bar: Int)
func fooSomething(bar: Int, baz: Int)
func fooSomething(foo: Int, baz: Int)
func fooSomething(foo: Int, bar: Int, baz: Int)
4 Likes

For a real world example look at this DateComponents initializer.

init(
    calendar: Calendar? = nil,
    timeZone: TimeZone? = nil,
    era: Int? = nil,
    year: Int? = nil,
    month: Int? = nil,
    day: Int? = nil,
    hour: Int? = nil,
    minute: Int? = nil,
    second: Int? = nil,
    nanosecond: Int? = nil,
    weekday: Int? = nil,
    weekdayOrdinal: Int? = nil,
    quarter: Int? = nil,
    weekOfMonth: Int? = nil,
    weekOfYear: Int? = nil,
    yearForWeekOfYear: Int? = nil
)

Imagine writing that out without default parameters.
Or this SpriteView initializer:

init(
    scene: SKScene,
    transition: SKTransition? = nil,
    isPaused: Bool = false,
    preferredFramesPerSecond: Int = 60,
    options: SpriteView.Options = [.shouldCullNonVisibleNodes],
    debugOptions: SpriteView.DebugOptions,
    shouldRender: @escaping (TimeInterval) -> Bool = { _ in true }
)

Default parameters allow APIs to be simple to use while offering more complex options easily.

6 Likes