RFD: Mapping issues

A few things that popped up recently in discussions about mapping. Sharing them here for a wider discussion.

Multiargument Map

You cannot map a function with multiple arguments, even when the defaulted arguments mean an effective arity of 0 or 1:

["a", "b", "c"].map(print) 
// Ambiguous reference to member 'print(_:separator:terminator:)'

func prettify(_ value: Int, _ radix: Int = 10) -> String {
  let stringed = String(value, radix: radix)
  return ("Value: \(stringed)")
}

[1, 2, 3].map(prettify)
// cannot convert value of type '(Int, Int) -> String' to expected argument type '(Int) -> _'

Could Swift work around this and allow mapping when a zero or one argument signature is generated due to defaulting?

Applying Methods

Mapping an instance method, gives you the partially applied method but does not execute them.

var array = ["a", "b", "c"].map(String.uppercased)
// [(Function), (Function), (Function)]
// Unexpected!

To get the expected result (["A", "B", "C"]), you must either map ({ $0() }) or create something that does that for you. Here's one version:

extension Sequence {
  /// Returns an array created by applying an instance
  /// method to each element of a source sequence.
  /// This method address the problem caused by mapping
  /// instance methods:
  ///
  ///     ["a", "b", "c"].map(String.uppercased)
  ///
  /// This statement returns `[(Function), (Function), (Function)]`,
  /// which is essentially `[{ String.uppercased("a"), ... }]`
  /// An instance method is `Type.method(instance)()`. Using
  /// `mapApply` forces each partial result to execute and returns those
  /// results, producing ["A", "B", "C"] as expected.
  ///
  ///     ["a", "b", "c"].mapApply(String.uppercased) // ["A", "B", "C"]
  ///
  /// Thanks, Malcolm Jarvis, Gordon Fontenot
  ///
  /// - Parameter transform: The partially applicable instance method
  /// - Parameter element: The sequence element to be transformed by the
  ///   applied method
  /// - Returns: An array containing the results of applying the method
  ///   to each value of the source sequence
  public func mapApply<T>(_ transform: (_ element: Element) throws -> () -> T) rethrows -> [T] {
    return try map(transform).map({ $0() })
  }

Applying Keypaths

Similarly, Swift doesn't seem to allow you to map keypaths. Here's one approach:

extension Sequence {
  /// Returns an array created by applying a keypath to
  /// each element of a source sequence.
  ///
  ///     let keyPath = \String.count
  ///     ["hello", "the", "sailor"].map(keyPath) // 5, 3, 6
  ///
  /// Thanks, Malcolm Jarvis, Gordon Fontenot
  ///
  /// - Parameter keyPath: A keyPath on the `Element`
  /// - Returns: An array containing the results of applying
  ///   the keyPath to each element of the source sequence.
  public func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
    return map({ $0[keyPath: keyPath] })
  }
}

Thoughts?

2 Likes

I believe most of these have been discussed before. There's likely an intersection with function application as well. But yes, I'd like to see something like this. Though I think the non-transformative usage should use forEach, which has the same issue.

The problems with the way default arguments are implemented have been discussed before. It would be a breaking change but having:

func x(a: Int = 0) { ... }

Be equivalent to:

func x() { x(0) }
func x(a: Int) { ... }

Would solve a lot of problems.

3 Likes

This seems to be a separable topic from Erica’s pitch. Unless I’m mistaken, regardless of how default arguments are implemented, it would seem that we could make Erica’s issue #1 work.

I always thought Swift modeled default arguments as substitutions to be made at the call site. Which is why default arguments are not considered part of ABI. It would be a big change in the mental model if function(parameter:T, defaulted:U = foo) became “sometimes equivalent” to function(parameter:T)

1 Like

I am sure you could get it to work, but problems with default arguments keep on coming up. Maybe changing would be good!

It does. But that is problematic in many circumstances. I think the change to the better system of defining multiple methods can be made without breaking code since it would be an additive change.

PS You might break somethings, but I doubt it would be common because the present system is limited in what it can do.

Maybe, maybe not. But certainly not part of this thread.

I don't think that it is very likely… but why, exactly, would you say this in this way? It isn't harmful to suggest the possibility.

There are probably many ways of solving Erica's problem. However one solution is to change how default arguments work. To be specific, if:

func prettify(_ value: Int, _ radix: Int = 10) -> String {
  let stringed = String(value, radix: radix)
  return ("Value: \(stringed)")
}

Meant:

func prettify(_ value: Int) -> String {
    return prettify(value, 10)
}
func prettify(_ value: Int, _ radix: Int) -> String {
  let stringed = String(value, radix: radix)
  return ("Value: \(stringed)")
}

Then:

[1, 2, 3].map(prettify)

Would work. Which is Erica's first example.

Therefore I think changing the way default arguments work is on topic.

2 Likes

That's clever.

You create exactly two versions of each method: one excluding all defaulted values, which are set in the first function and relayed to the second.

How would you handle calls where only some of the defaulted values are specified at the call site as there no longer seems to be an API to support that, and if you re-introduced default values there is a naming conflict that cannot be resolved

I would suggest the following:

class Parent {
    // func m(x0: Int, x1: Int = 0, x2: Int = 0) -> String {
    //     return "\(x0), \(x1), \(x2)"
    // }
    func m(x0: Int) -> String {
        return m(x0: x0, x1: 0, x2: 0)
    }
    func m(x0: Int, x1: Int) -> String {
        return m(x0: x0, x1: x1, x2: 0)
    }
    func m(x0: Int, x2: Int) -> String {
        return m(x0: x0, x1: 0, x2: x2)
    }
    func m(x0: Int, x1: Int, x2: Int) -> String {
        return "\(x0), \(x1), \(x2)"
    }
}

class Child: Parent {
    // override func m(x0: Int, x1: Int = -1, x2: Int = -1) -> String {
    //     return super.m(x0: x0, x1: x1, x2: x2)
    // }
    override func m(x0: Int) -> String {
        return m(x0: x0, x1: -1, x2: -1)
    }
    override func m(x0: Int, x1: Int) -> String {
        return m(x0: x0, x1: x1, x2: -1)
    }
    override func m(x0: Int, x2: Int) -> String {
        return m(x0: x0, x1: -1, x2: x2)
    }
    override func m(x0: Int, x1: Int, x2: Int) -> String {
        return super.m(x0: x0, x1: x1, x2: x2)
    }
}

class Grandchild: Child {
    // override func m(x0: Int, x1: Int = -2) -> String { // Note only x1 redefined
    //     return super.m(x0: x0, x1: x1)
    // }
    override func m(x0: Int) -> String {
        return m(x0: x0, x1: -2)
    }
    override func m(x0: Int, x1: Int) -> String {
        return super.m(x0: x0, x1: x1)
    }
}

func test(_ p: Parent, _ x: Int) {
    print(p.m(x0: x))               // x, d, d
    print(p.m(x0: x, x1: 1))        // x, 1, d
    print(p.m(x0: x, x2: 2))        // x, d, 2
    print(p.m(x0: x, x1: 1, x2: 2)) // x, 1, 2
}

test(Parent(), 0)
//0, 0, 0
//0, 1, 0
//0, 0, 2
//0, 1, 2

test(Child(), -1)
//-1, -1, -1
//-1, 1, -1
//-1, -1, 2
//-1, 1, 2

test(Grandchild(), -2)
//-2, -2, -1
//-2, 1, -1
//-2, -1, 2
//-2, 1, 2
1 Like

This is already looking ridiculous for 2 arguments, so let's do 4 next. Though I can't tell if you describing an implementation (all these overloads should actually be compiler-generated on the type itself, presumably with some fixes so they don't all appear in autocomplete, etc) or the semantics (the compiler consider functions with default arguments “compatible” in a subtyping sort of way).

the number of generated functions increases exponentially with the number of defaulted arguments…

1 Like

Would it be possible for the compiler to automatically generate thunks for defaulted functions when used as closures that don't specify all of the arguments?

e.g.

// Default values work like they do today when called directly
func prettify(_ value: Int, radix: Int = 10) -> String {
    let stringed = String(value, radix: radix)
    return ("Value: \(stringed)")
}

// This is shorthand for prettify(17, radix: 10)
prettify(17)

/* But when used in as a closure that doesn't specify all the parameters
 * the compiler creates (or reuses) a thunk that passes the default parameters in.
 */
[1, 2, 3].map(prettify) // Making this roughly equvilant to:
[1, 2, 3].map { n in prettify(n) } // This

Since the default parameters have to be at least visible to the compiler even in a different module, then the compiler should always be able to create this thunk even if it wasn't created in the module containing the function in question.

The number of thunks wouldn't grow exponentially, they would grow linearly with the number of times that a function with defaulted parameters is passed to a new kind of closure that doesn't specify all the parameters. Although the upper bound on the number of thunks would still be an exponential explosion.

1 Like

The implementation so that it works correctly at runtime, the compiler only knows the static type.

The number of methods added is small in the scheme of things, I just wouldn’t worry about it. It is 2^defaults methods and the number of methods with lots of defaults is small.

Having them show up in autocomplete in some form is what you want, so that you know defaults exist.

This would work for the example because the type is known statically. It would fail if you didn’t know the type.

If you don't know the type statically, then you must be either dealing with a protocol or a non-final class. In both cases you either know the defaults because they're specified at the protocol or base class level, or Swift needs to create a thunk anyway in order for the method to conform to the protocol or override the base class (if you added new parameters for example). Otherwise, how could you even call it dynamically?

In the original example everything is statically known, so the thunk solution would work. But suppose that there was a base class Prettify and that derived classes had different defaults then:

func mapping(_ p: Prettify) -> [String] {
    return [1, 2, 3].map { n in p.prettify(n) }
}

Fails. You can try this today, it is valid code. There just one level of abstraction fails. The current solution and the proposed thunk solution are fragile.

Surely it would use the base class defaults. I seem to remember a previous thread where someone was complaining that the default values from a subclass weren't used if you called the method from a superclass variable.

e.g

class Foo {
    func prettify(_ n: Int, radix: Int = 10) -> String {
        let stringed = String(n, radix: radix)
        return ("Value: \(stringed)")
    }
}

class Bar: Foo {
    override func prettify(_ n: Int, radix: Int = 16) -> String {
        print(radix)
        return super.prettify(n, radix: radix)
    }
}

let bar: Foo = bar()
print(bar.prettify(17))
// Output:
// 10
// Value: 17

func mapping(_ foo: Foo) -> [String] {
    return [1, 2, 3].map { n in foo.prettify(n) }
}

print(mapping(bar))

// Output:
// 10
// 10
// 10
// ["Value: 1", "Value: 2", "Value: 3"]

I just checked, this is actually what happens right now. This is the only sensible thing to do anyway as default parameters are API.

2 Likes