How do class extensions, duck typing and multiple dispatch compare when extending and reusing existing code?
Object-oriented programming is one way to help you organize large programs, but it is not the only approach. Here I compare Swift, Go and Julia in how they tackle reuse of code. Swift takes the OOP approach and supercharges it with interface and class extensions. Go tries to rethink this whole thing by bringing Duck typing into a statically typed language. Meanwhile Julia rejects the whole OOP paradigm and invents its own: Multiple dispatch.
We'll go through pros and cons of each approach.
Swift Protocol and Class Extensions
If you want to do classic object-oriented programming, then very few mainstream languages can actually beat Swift. Yes, I will argue that languages such as Java and C# which can most directly be compared don't compare well. But how about something like Python? It is a beloved object-oriented language. Here the comparison is tricky as Python is a dynamically typed language. Swift being statically typed makes for a better comparison with Java and C#.
But what makes Swift such a powerful Object-Oriented language? Class extensions and protocols are the secret sauce to making Object-Oriented programming really shine in Swift. Here is an example from some recent code. I needed to support binary search on sorted arrays. This would allow me to find a given element, say 8 inside an array xs in O(log N) time, rather than look at every element. Here is how it would look in Swift:
let xs = [2, 4, 8, 10]
let i = xs.binarySearch { x in x < 8 }But arrays in Swift don't have a binarySearch method. Something working in a traditional OO language might decide to subclass the array. But this leads to deep messy inheritance hierarchies. Inheritance should be used to express new concepts, not add new handy functionality.
However in Swift you can extend interfaces (called protocols in Swift) with new methods. The Swift Array class implements the RandomAccessCollection interface. This is bundled with the standard library. But we are free to add new methods to this interface. We can even provide a default implementation.
extension RandomAccessCollection {
func binarySearch(predicate: (Iterator.Element) -> Bool) -> Index {
var low = startIndex
var high = endIndex
while low != high {
let mid = index(low, offsetBy: distance(
from: low,
to: high)/2)
if predicate(self[mid]) {
low = index(after: mid)
} else {
high = mid
}
}
return low
}
}This means any collection type in Swift implementing the RandomAccessCollection interface now has access to the binarySearch method.
You can extend almost anything, not just interfaces. You can extend classes, enums and structs as well. NSRange is a struct (class with value semantics) to represent ranges. It could be something like text selected in a text view or a range of characters with the same font or color. I made my own class WrittenDoc containing text into which you can put tags. I wanted to create ranges from one tag/marker in the text to the next. For this I simply extended the NSRange struct and gave it an extra initializer:
extension NSRange {
init(doc: WrittenDoc, tag: Tag) {
self.init(doc.charIndex(ofTag: tag)..<doc.scriptText.count)
}
}Thus I could write snippet of code looking like this:
let nextTag : Tag = tags[i+1]
let range : NSRange = NSRange(doc: self.writtenDoc,
tag: nextTag)This feature allows you to do an amazing amount of stuff that probably does not even occur to you. For example, in Swift a string can actually draw itself on screen. This sounds like bad design, because why would you put GUI and graphics code inside core libraries? However, that is not what is done. If you only import the Foundation library, you will not have drawing functionality available.
However, if you import Cocoa in a source code file, it contains class extensions to String that allow it to draw itself. Hence extensions can be bundled into different libraries. Graphics-related code can to places in a graphics library but still extend basic types.
But why might this be useful? It solves problems you often need clunky patterns such as the visitor pattern to solve: Making Visitor Pattern Obsolete using Swift.
Duck Typing in Go
Of course, object-oriented programming is not the only way to solve problems. Go uses elements of object-oriented programming but prefers to keep things simple.
In Go, you don't explicitly state that an object implements an interface; it just automatically does if has all the methods listed in an interface. Thus any object with a Write method looking like this implements the Writer interface in Go.
type Writer interface {
Write(p []byte) (n int, err error)
}Thus in Go you can invent interface that types from existing libraries will then magically implement, all without having been designed specifically for that interface — much like Swift.
However, unlike Swift you cannot extend an existing type with a custom. Instead the Go way is to add free functions adding functionality to simple interfaces. An example may illustrate this better:
func main() {
filename := "rocket-engine.txt"
file, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not create %s because: %v\n", filename, err)
os.Exit(1)
}
defer file.Close()
engine := "RD-180"
var thrust float64 = 3830
fmt.Fprintf(file, "%s has thrust %0.1f\n", engine, thrust)
}Here you can see the fmt.Fprintf function is used to write formatted text both the stderr as well as a file we opened. All fmt.Fprintf cares about is that its first argument adheres to the Writer interface. Further you can build more functionality on top of this, thus creating sophisticated functionality available to all types implementing the Writer interface.
The limitation of this approach is that you cannot create different versions of e.g. Fprintf for different types. In Swift you can do this. If you used Swift to add an interface extension with Fprintf you could provide a default implementation akin to what we do with free functions in Go. However in addition you could add specific versions of Fprintf to specific types. Go has no solution to this other than to use a type switch statement, which is less elegant.
Multiple Dispatch in Julia
While protocol oriented programming and extensions are very powerful in Swift, I would still argue that multiple dispatch offered by Julia is a more powerful paradigm in terms of organizing and reusing code.
To explain how this works, I will pretend that Go and Swift have types and interfaces that are similar to Julia, just to make it easier to grasp the distinction. So we are going to assume Swift and Go both have the show function/method, the IO interface and the Any interface.
In Julia if I evaluate some expression, I get an object which gets displayed in the interactive command line environment (REPL) like this:
julia> xs = [3, 4, 8]
3-element Vector{Int64}:
3
4
8At the Julia prompt I created an array with 3 elements which I stored in the variable xs. After I hit enter you can see that Julia prints out a description of this array. In Julia this is accomplished with the show method. Whenever the command line needs to display an object it calls show. In our case it would be something like this:
show(output, xs)We have two arguments. The output represents your console and xs is the object to display. Julia has a default implementation of this looking something like this:
function show(io::IO, obj::Any)
# implementation code goes here
endThis will use introspection to find out what fields the type has and write those out. Hence if I created my own type in Julia and instantiated it, I would get a sensible default display, which look like the constructor call:
julia> struct Point
x::Int
y::Int
end
julia> p = Point(3, 4)
Point(3, 4)However I can change the display in Julia by overriding show.
import Base: show
function show(io::IO, p::Point)
print(io, "<", p.x, ", ", p.y, ">")
endNow if I try to show p in the REPL (command line) it will look different.
julia> p
<3, 4>So far, so good. You could do something similar in Swift. Imagine everybody implemented the Any interface, then we could define a default show method like this:
extension Any {
func show(io: IO) {
// implementation code goes here
}
}Then for our Point class we could simply write:
extension Point {
func show(io: IO) {
io.print("<", self.x, ", ", self.y, ">")
}
}But here is where the Swift approach would come up short. Imagine you want a special representation when sending a textual representation of a Point cross a UDP socket. In Julia I could write the following:
function show(io::UDPSocket, p::Point)
print(io, "(", p.x, ", ", p.y, ")")
endWhich causes the point to be sent as (3, 4) over a UDP socket instead of <3, 4>. Yes, this is a contrived example, but gives an inkling of why multiple dispatch is a more powerful paradigm.
A more obvious example of its utility would be when dealing with intersections between different geometric shapes. This pops up when making computer games and you have different entities colliding with each other which use different geometric shapes to represent their collision shape. Some could be a square, others a circle or polygon. In Julia you could define a myriad of methods handling each of these cases:
collide(a::Circle, b::Triangle)
collide(a::Square, b::Circle)
collide(a::Polygon, b::Square)In fact this is very much how the number promotion system in Julia works. It finds minimum common denominators between multiple number types this way.
But if you want to understand this better, I have a larger example with Knights, Archers and Pikemen in dealing with a sort of Rock, Paper, Scissors challenge: Knights, Pikemen, Archers and Multiple Dispatch.
Downsides to Multiple-Dispatch
This sounds really amazing, so what are the downsides? A benefit of the OOP approach is that methods always belongs to an object. If you have an object, you can look up its methods. In Julia, methods belong to functions, not objects (or types more specifically).
The method terminology may throw you off if you are from an OOP background. If we look at the collide example, you could say that shows the collide function with 3 different methods. A method is a concrete implementation of a function. A function can have many implementations depending on the number of arguments and their types.
Thus if I have a Circle I cannot readily discover that it has a collide method, because the method sort of belongs to all the arguments.
It is also means it can be harder to grasp what an object specifically is. There is no central definition of an object with all its methods like in an OOP setting. However, you could say the Julia thinking is more functional. One simply doesn't care about objects. It is all about functions and what they do. While OOP is noun-focused, Julia is more verb-focused.