Creating an object by passing a metatype to a generic function uses the default parameter values of the base class' init

class Thing {
    var name: String
    
    required init(name: String = "Thing") {
        self.name = name
    }
}

class ThingSubclass: Thing {
    required init(name: String = "ThingSubclass") {
        super.init(name: name)
    }
}

func makeThing(thingClass: Thing.Type) {
    let newThing = thingClass.init()
    print(newThing.name)
}

makeThing(thingClass: ThingSubclass.self) // Prints "Thing"

let directlyCreatedThingSubclass = ThingSubclass()
print(directlyCreatedThingSubclass.name) // Prints "ThingSubclass"

Same if I write func makeThing<T: Thing>(thingClass: T.Type)

How can I make makeThing(thingClass:) use the exact type passed to it at runtime?

1 Like

I don't think this is possible at the moment, since Swift does not generally allow overriding default parameters in subclasses. See the long discussion in [Pitch] Allow default parameter overrides about this topic.

Note that it does call the correct initializer at runtime, but the default argument is dispatched statically. One possible alternate design would be to make the type of the defaulted argument a String? with a default value of nil. Then each override of the initializer can check if the parameter value is nil, substituting its own default in place.

This static dispatching of default arguments catches a lot of people out, it has come up a lot on both Swift Users and Swift Evolution. I have championed changing to a default parameter implementation that writes two functions for each default parameter with one function missing the defaulted argument and calling the other with the default. In the case of your example I am proposing the compiler would write:

class Thing {
    var name: String
    
    required convenience init() {
        self.init(name: "Thing")
    }
    required init(name: String) {
        self.name = name
    }
}

class ThingSubclass: Thing {
    required convenience init() {
        self.init(name: "ThingSubclass")
    }
    required init(name: String) {
        super.init(name: name)
    }
}

Which will work as most people would expect.

This will solve the issue at hand here, but won't scale to more than one default argument. The compiler would have to emit an entry point for every combination of provided and missing default args.

2 Likes

I think it would be fine. There aren’t that many default arguments and since you can’t change argument order the number is only 2^defaults.

I have functions with up to 8 defaulted arguments in my code right now. You think it would be fine to create 256 variations?

1 Like

Or worse: Foundation.DateComponents has a memberwise initializer taking 16 arguments. 256 might be pushing it, but I’d say 65536 functions is right out.

3 Likes

I still think the thing to do is to automatically generate thunks when used in this sort of context. Obviously we'd want to reuse thunks most of the time, but if the specific defaults are things like #file and #line then no reuse can be done. This wouldn't deal with the combination explosion in practice, because no one is going to write 2^default different kinds of uses of a function for when there's a non-trivial number of defaults.

func increment(_ x: Int, by y: Int = 1) -> Int {
    return x + y
}

func log(_ message: String, file: StaticString = #file, line: UInt = #line) {
    print("\(file):\(line) \(message)")
}

let ages = [20, 16, 24, 18, 37]
let nextYear = ages.map(increment) // The same as numbers.map { n in increment(n) }
let numbers = [69105, 42, 360, 255]
let incremented = numbers.map(increment) // reuse previous thunk

let letters = ["A", "B", "C", "D"]
letters.map(log) // same as letters.map { s in log(s) }
let fish =  ["Tuna", "Skate", "Swordfish", "Bass", "Catfish"]
fish.map(log) // log uses #file and #line, so make a new thunk.

protocol Foo {
    func bar()
}

struct Baz: Foo {
    func bar(x: Int = 0) {
        let f = x * x / 2
         print("f(\(x)) = \(f)")
    }
    // Generates a thunk that matches the protocol's bar(), as if we also wrote:
    // func bar() { bar(x: 0) }
    // Can't use #file or #line here, since the thunk has to be static. There might be a way to design around this, but I think it's a good starting place.
}

I would say 65 k is OK, because:

  1. It is a rare function that takes so many default arguments.
  2. The functions are all tiny and can be inlined.
  3. Compilation will probably be faster, since method lookup is more straightforward. At present every time you get a miss you have to start looking for default arguments, if the methods were expanded a mis is a mis. (But see point 6.)
  4. The current system does not have the expected behaviour; that's why we have threads like this.
  5. For inheritance the present system is all but useless.
  6. You only have to expand the method to all combinations with inheritance, i.e. non-final classes only. Protocols can't have defaults, so nothing to do. Structs and final classes can use the system as currently implemented. So DateComponent with its 16 defaults doesn't need to be expanded, because it is a struct.

How would this work with separate compilation?

But not if they're dynamically dispatched class methods, which is precisely the case where you're proposing we use the new scheme.

I think it would make more sense to consider dynamically dispatching default argument generators instead.

1 Like

Another technique is to store the default parameters as properties. Both the multiple methods and calculated property methods are shown in the example below.

class Parent {
    // Programmer writes.
    func doSomething(s: String = "parent", i: Int = 0) -> (String, Int) { return (s, i) }
    
    // 2^default functions.
    func doSomething2(_ s: String, _ i: Int) -> (String, Int) { return (s, i) }
    @inline(__always) func doSomething2(_ s: String) -> (String, Int) { return doSomething2(s, 0) }
    @inline(__always) func doSomething2(_ i: Int) -> (String, Int) { return doSomething2("parent", i) }
    @inline(__always) func doSomething2() -> (String, Int) { return doSomething2("parent", 0) }
    
    // Default properties.
    var doSomething3s: String { return "parent" }
    var doSomething3i: Int { return 0 }
    func doSomething3(_ s: String, _ i: Int) -> (String, Int) { return (s, i) }
}

final class Child : Parent {
    // Programmer writes.
    override func doSomething(s: String = "child", i: Int = 1) -> (String, Int) { return (s, i) }
    
    // 2^default functions.
    override func doSomething2(_ s: String, _ i: Int) -> (String, Int) { return (s, i) }
    @inline(__always) override func doSomething2(_ s: String) -> (String, Int) { return doSomething2(s, 1) }
    @inline(__always) override func doSomething2(_ i: Int) -> (String, Int) { return doSomething2("child", i) }
    @inline(__always) override func doSomething2() -> (String, Int) { return doSomething2("child", 1) }
    
    // Default properties.
    override var doSomething3s: String { return "child" }
    override var doSomething3i: Int { return 1 }
    override func doSomething3(_ s: String, _ i: Int) -> (String, Int) { return (s, i) }
}

let p: Parent = Child()
print(p.doSomething()) // ("parent", 0) :(
print(p.doSomething2()) // ("child", 1) :)
print(p.doSomething3(p.doSomething3s, p.doSomething3i)) // ("child", 1) :)

The calculated property method (3 in code above) is the technique that Scala uses. For static methods and inits the calculated properties would be static.

PS1: @Nobody1707 raised the issue of file and line directives, I am unsure how these are implemented in the compiler and therefore could comment on if they would work with any of these techniques.

PS2: As @Slava_Pestov said you can only inline for structs, enums, and final classes, but it is probably still worth adding the inline annotations as shown in the code above for the cases were it can be used.

1 Like

I have lots of functions with lots of default arguments, which is often the case when writing a game, where you may need to only change 1 or 2 visual properties for each character while leaving the rest as default.

I certainly don't want the compiler to silently generating hundreds of copies of each function behind my back, just because I did something as innocuous as overriding a method with different defaults.

This would be a worse surprise than a generic metatype using an init with unexpected defaults.

If we cannot efficiently produce the expected behavior, without potentially expensive solutions such as the one suggested by @hlovatt, then we should at least eliminate the surprise by issuing a warning:

Just let the programmer know when a overridden method/init may be called with different default arguments at runtime.

I don't know if there's any place in the module metadata to store information like that, but worse case scenario is that each module has to make it's own thunks. It should be doable though, since default values of public functions have to be visible to the compiler even across module boundaries.

In the case that a protocol conformance is specified as a function with defaults, the thunk should probably just be exposed as a public overload. Unless you can think of a better way to do it. I'm not familiar enough with the compiler internals to know if that's optimal.