I was watching a video on Julia about Multiple Dispatch and was curious if I could write something similar in Swift. I see that Swift relies on the compiler where Julia seems to determine a type at run time, but I also discovered something I don't understand about Swift.
Why would the below function encounters
work when the 2 parameters are of the same type of Pet but not when one is a Cat and the other is a Dog?
For example the two functions below work
encounters(Arturo, Gabby) // both Cat
encounters(Cc, Bb) // both Dog
but these give a compiler error
Conflicting arguments to generic parameter 'T' ('Cat' vs. 'Dog')
encounters(Arturo, Bb) // Cat and Dog
encounters(Bb, Arturo) // Dog and Cat
protocol Pet: Equatable {
var name: String { get }
}
struct Cat: Pet {
let name: String
}
struct Dog: Pet {
let name: String
}
let Arturo = Cat(name: "Arturo")
let Gabby = Cat(name: "Gabby")
let Bb = Dog(name: "Bb")
let Cc = Dog(name: "Cc")
func encounters<T: Pet>(_ a: T, _ b: T) {
var verb: String
switch (a, b) {
case is (Cat, Dog):
verb = meet(a as! Cat, b as! Dog)
case is (Dog, Dog):
verb = meet(a as! Dog, b as! Dog)
case is (Cat, Cat):
verb = meet(a as! Cat, b as! Cat)
case is (Dog, Cat):
verb = meet(a as! Dog, b as! Cat)
default:
fatalError()
}
print("\(a.name) meets \(b.name) and \(verb)")
}
func meet(_ a: Cat, _ b: Cat) -> String {
return "Slinks"
}
func meet(_ a: Cat, _ b: Dog) -> String {
return "Hisses"
}
func meet(_ a: Dog, _ b: Dog) -> String {
return "Howles"
}
func meet(_ a: Dog, _ b: Cat) -> String {
return "Barks"
}
I would not take this approach, but the reason it doesn't work is because you attached Equatable to Pet. You probably meant something like "Pets should be comparable to other Pets," but that's not what that means. It means that types that conform to Pet must themselves be Equatable. If you remove Equatable, this works as written without the generic as Claus notes:
protocol Pet { ... }
func encounters(_ a: Pet, _ b: Pet) { ... }
So much as!
makes Swift devs nervous and is unnecessary. The as!
isn't wrong, but it opens the door for easy mistakes that the compiler can't catch. This style lets the compiler help you a little more. It still won't be able to catch missing cases.
func encounters(_ a: Pet, _ b: Pet) {
var verb: String
switch (a, b) {
case let (a as Cat, b as Dog):
verb = meet(a, b)
case let (a as Dog, b as Dog):
verb = meet(a, b)
case let (a as Cat, b as Cat):
verb = meet(a, b)
case let (a as Dog, b as Cat):
verb = meet(a, b)
default:
fatalError()
}
print("\(a.name) meets \(b.name) and \(verb)")
}
I definitely do not like the fatalError
here. Both this code and the Julia I assume you've written would crash if some new Pet were created. That's not very good. But I assume it's not a major part of this question. In Swift, you'd put something in the default leg. In Julia, you'd add a more general multimethod for meet(a::Pet, b::Pet)
.
If there really are only the two possible Pets, then you really should be thinking about enums instead of dynamic dispatch, but again I assume that's not the question.
Like I said, I don't really like this approach. I think it's very verbose, and adds a lot of places for mistakes. If you want this kind of dynamic type lookup, I'd do dynamic type lookup in data.
struct Meeting {
let lhs: Pet.Type
let rhs: Pet.Type
let verb: String
func matches(_ lhs: Pet, _ rhs: Pet) -> Bool {
self.lhs == type(of: lhs) && self.rhs == type(of: rhs)
}
}
let meetings = [
Meeting(lhs: Cat.self, rhs: Dog.self, verb: "Hisses"),
Meeting(lhs: Dog.self, rhs: Dog.self, verb: "Howles"),
Meeting(lhs: Cat.self, rhs: Cat.self, verb: "Slinks"),
Meeting(lhs: Dog.self, rhs: Cat.self, verb: "Barks"),
]
func encounters(_ a: Pet, _ b: Pet) {
let verb = meetings
.first(where: { $0.matches(a, b) })?.verb
?? "passes by"
print("\(a.name) meets \(b.name) and \(verb)")
}
Even if you need functions, you can expand this to storing functions as data (which is an incredibly powerful way of implementing dynamic dispatch).
Instead of just a verb, add an action function to Meeting:
struct Meeting {
let lhs: Pet.Type
let rhs: Pet.Type
let action: (Pet) -> String // Function that takes a Pet and gives a String
func matches(_ lhs: Pet, _ rhs: Pet) -> Bool {
self.lhs == type(of: lhs) && self.rhs == type(of: rhs)
}
}
Now it's starting to look more like your multi-methods, putting all the specialized logic in one place:
let meetings = [
Meeting(lhs: Cat.self, rhs: Dog.self, action: { "Hisses at \($0.name)" }),
Meeting(lhs: Dog.self, rhs: Dog.self, action: { "Howles at \($0.name)" }),
Meeting(lhs: Cat.self, rhs: Cat.self, action: { _ in "Slinks by" }),
Meeting(lhs: Dog.self, rhs: Cat.self, action: { "Barks at \($0.name)" }),
]
And encounters
calls it:
func encounters(_ a: Pet, _ b: Pet) {
let action = meetings
.first(where: { $0.matches(a, b) })?.action
?? { _ in "passes by" }
let verb = action(b)
print("\(a.name) meets \(b.name) and \(verb)")
}
It's not completely as powerful as multimethods, but it's easier to reason about the type safety at compile-time.