Swift method dispatch is weird: Overridden method is using superclass' default parameter value

44 Views Asked by At

Why is so weird result in playground? And what is the mechanism of so weird dispatching?

class A {
    func execute(param: Int = 123) {
        print("A: \(param)")
    }

}

class B: A {
    override func execute(param: Int = 456) {
        print("B: \(param)")
    }
}

let instance: A = B()
instance.execute()

// print B: 123

I watched SIL files but it looks ok. Vtables looks right too.

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @riddle.instance : riddle.A         // id: %2
  %3 = global_addr @riddle.instance : riddle.A : $*A // users: %9, %8
  %4 = metatype $@thick B.Type                    // user: %6
  // function_ref B.__allocating_init()
  %5 = function_ref @riddle.B.__allocating_init() -> riddle.B : $@convention(method) (@thick B.Type) -> @owned B // user: %6
  %6 = apply %5(%4) : $@convention(method) (@thick B.Type) -> @owned B // user: %7
  %7 = upcast %6 : $B to $A                       // user: %8
  store %7 to %3 : $*A                            // id: %8
  %9 = load %3 : $*A                              // users: %12, %13
  // function_ref default argument 0 of A.execute(param:)
  %10 = function_ref @default argument 0 of riddle.A.execute(param: Swift.Int) -> () : $@convention(thin) () -> Int // user: %11
  %11 = apply %10() : $@convention(thin) () -> Int // user: %13
  %12 = class_method %9 : $A, #A.execute : (A) -> (Int) -> (), $@convention(method) (Int, @guaranteed A) -> () // user: %13
  %13 = apply %12(%11, %9) : $@convention(method) (Int, @guaranteed A) -> ()
  %14 = integer_literal $Builtin.Int32, 0         // user: %15
  %15 = struct $Int32 (%14 : $Builtin.Int32)      // user: %16
  return %15 : $Int32                             // id: %16
} // end sil function 'main'

Why is so strange behaviuor?

1

There are 1 best solutions below

1
Rob Napier On

This is a very old, controversial behavior. The short answer is: don't do that. There is quite a bit of debate as to whether this is a bug or a design choice.

My personal opinion is that this should be a compiler error, or at the very least a warning. IMO, the func in B is a different func, and therefore does not override A (so override should be an error). Or it is the "same" func with a different signature somehow, which should also be an error. There at least is an open bug to make it a warning (well, something similar, but it's in the same ballpark).

But it is what it is, and I doubt there is going to be much interest in changing it. It would be a breaking change, so I wouldn't expect it before Swift 6 in any case. And since inheritance is not the preferred approach in Swift, most new features focus on more preferred approaches using structs, enums, or actors. So I don't really expect it in Swift 6. Of course, if it's important to you, I would bring it up on the forums, and make a pitch. At least making the warning might be a very nice starter bug if that interest you.

As with most issues involving defaults, the solution is always to remember that they're really shorthand for a more explicit syntax:

class A {
    func execute() {
        execute(param: 123)
    }

    func execute(param: Int) {
        print("A: \(param)")
    }
}

class B: A {
    override func execute() {
        execute(param: 456)
    }

    override func execute(param: Int) {
        print("B: \(param)")
    }
}

Or you can use a technique I've been finding useful with default parameters. Perfer them to be Optional, and assign their default value in the implementation:

class A {
    func execute(param: Int? = nil) {
        let param = param ?? 123
        print("A: \(param)")
    }

}

class B: A {
    override func execute(param: Int? = nil) {
        let param = param ?? 456
        print("B: \(param)")
    }
}

This is a technique I've been finding I often need for actor methods with defaults, if those defaults might access a @MainActor property. It works in this case, too.

(But you're right to be surprised. Folks have been rightly surprised about this for years.)


You asked in the comments about what is really happening, and that's a good question, too. The answer, as you expected, is in the SIL (commentary added):

  # ...
  # Take the B just created in %6 and upcast it to A; store in %9
  %7 = upcast %6 : $B to $A                       // user: %8
  store %7 to %3 : $*A                            // id: %8
  %9 = load %3 : $*A                              // users: %12, %13

  # Look up the default parameter A.execute(param:), since %9 is of type A
  // function_ref default argument 0 of A.execute(param:)
  %10 = function_ref @$s1x1AC7execute5paramySi_tFfA_ : $@convention(thin) () -> Int // user: %11
  %11 = apply %10() : $@convention(thin) () -> Int // user: %13


  # And apply the function `execute` to %9
  %12 = class_method %9 : $A, #A.execute : (A) -> (Int) -> (), $@convention(method) (Int, @guaranteed A) -> () // user: %13
  %13 = apply %12(%11, %9) : $@convention(method) (Int, @guaranteed A) -> ()

The default parameter is actually a function:

// default argument 0 of A.execute(param:)
sil hidden @$s1x1AC7execute5paramySi_tFfA_ : $@convention(thin) () -> Int {
bb0:
  %0 = integer_literal $Builtin.Int64, 123        // user: %1
  %1 = struct $Int (%0 : $Builtin.Int64)          // user: %2
  return %1 : $Int                                // id: %2
} // end sil function '$s1x1AC7execute5paramySi_tFfA_'

So, since instance is statically of type A (which is all the compiler knows), the code instance.execute() is transformed into:

func defaultArg0OfAExecuteParam() { 123 }

// ...

let param = defaultArg0OfAExecuteParam()
instance.execute(param: param)

All the choices are made at the point of the caller, not at the point of the implementation. So the default value is entirely dependent on statically known type information.

Working the way you probably expect here would be a major change to how Swift works, and I don't think that's plausible, or even particularly desirable, due to how it may reduce opportunities for inlining and other optimizations. But, IMO, the fact that this situation is allowed is a mistake.