When I graduated from university, I couldn't imagine anything better than object oriented programming. Sure, every so often you had to exit that world for the sake of making some ultra-efficient code, but if you could, objects just seemed like a much better and more maintainable way of expressing ideas.
When I first encountered Ruby, it seemed like madness. At least it had my beloved classes and objects, but no way to define an interface
?!?! What is this "duck typing" you speak of? Even worse, many of the design patterns that had seemed necessary to write elegant, maintainable Java code just seemed like massive overkill with things like blocks and easy meta-programming at your fingertips. I actually worried that because it made a lot of stuff easy that was hard in my Java programming, it would chip away at my "good programming" principles.
I mean, yes, I'd learned things like Lisp and Prolog in school, and you either learned to embrace how different they were or you'd go a little mad. But I didn't know anyone who would use them to do anything other than extend their text editor or torture undergrads.
But the funny thing about spending a few years with one language as your go to language, then picking up another, then another... is that you begin to put less importance in a particular kind of abstraction and become more interested in how the abstractions a language chooses help you think about the problems you're interested in solving.
My best advice to anyone picking up a 2nd programming language for the first time is to consciously avoid writing code like they would in their 1st programming language. It's natural to look for familiar territory, and because most programming languages share similar syntax and borrow popular concepts from each other, you can sometimes make your 2nd programming language feel almost the same as the 1st. Except for those few annoying things you can't quite do. "You know in (my first true language love) this was soooo easy!"
So, of course when I first started using Julia for a project, I followed all of this advice, right?
Ha!
See, the big problem was that it only seemed to have functions and types. No objects! I mean, with a certain amount of ugliness, you could wedge a function into a struct and have it refer to the struct so that it acts like a method on an object. But it was pretty obvious pretty quickly that making my code object oriented in Julia would be swimming against a strong current.
Okay, so I decide to relax and embrace this new object-less world, and I start paying more attention to Julia's multi dispatch functionality. And a whole new world that seems much better than objects for many problems opens up.
Want to multiply two numbers?
a = 2
b = 3
a * b
Result:
6
Want to multiply a vector by a scalar?
a = [2
1]
b = 3
a * b
Result:
2-element Array{Int64,1}:
6
3
Want to multiply a matrix by a vector?
A = [1 0 0
1 1 0
0 2 1]
b = [2
4
1]
A * b
Result:
3-element Array{Int64,1}:
2
6
9
What?!?!?! How can you just use the *
operator on these completely different types? Next you're going to say someone could just go:
"repeat me" * 3
Result:
ERROR: MethodError: no method matching *(::String, ::Int64)
Ha! There are limits to this magic!
But wait... what if we just tried to define that function?
Base.:*(s::String, amt::Int64) = repeat(s, amt)
"repeat me" * 3
Result:
"repeat merepeat merepeat me"
In many ways, this is much better than the sort of object orientation you get in most OO languages. How so?
Let's think about how a language like Python gets you to define methods for a class of object:
class Hello(object):
def __init__(self, name):
self.name = name
def talk(self):
print("Hi, I'm %s." % self.name)
obj = Hello("Dave")
obj.talk()
Result:
Hi, I'm Dave.
Now, I find the need to include self
as the first parameter of every object method much less annoying than using whitespace for scoping, but it's still kind of annoying. At least when I forget self
, there's a pretty obvious error message, but I forget it all the time. However, this provides a great insight into how a lot of object oriented programming works.
Let's say we define a second class like so:
class Goodbye(object):
def __init__(self, name):
self.name = name
def talk(self):
print("Goodbye %s." % self.name)
obj = Goodbye("Dave")
obj.talk()
Result:
Goodbye Dave.
Now let's think about how we might get the same sort of behaviour with a talk
function that isn't attached to an object:
class Hello(object):
def __init__(self, name):
self.name = name
class Goodbye(object):
def __init__(self, name):
self.name = name
def talk(obj):
if type(obj) == Hello:
print("Hi, I'm %s." % obj.name)
elif type(obj) == Goodbye:
print("Goodbye %s." % obj.name)
else:
raise Exception("I don't know what to do!")
obj = Goodbye("Dave")
talk(obj)
talk
will produce the same result as our object methods, so long as we don't give it something that isn't a Hello
or Goodbye
object. Defining the talk
function this way simulates the polymorphism we get out of the object oriented approach, albeit with potential errors that the object oriented approach doesn't allow us to make.
Now, this is just a very basic use of objects. We're not doing any fancy inheritance stuff. But at this level, we can see how an object method is acting like a function that is polymorphic on its first parameter. For a Python object method, that first parameter just happens to be self
.
But when you look at it this way, you're only getting that polymorphism on the first parameter. You can't just do something like this:
a = A()
b = B()
c = C()
a.do_something(b)
a.do_something(c)
and expect different behaviour based on the different classes of b
and c
unless you define that different behaviour on the B
and C
classes and have A
's do_something
method simply delegate to the method on the parameter object. Or unless you explicitly check the class of your parameter within the do_something
function before deciding what to actually do. In both cases, you're defining behaviour on one or the other class of object that should occur when both objects are of the correct classes. Wouldn't it be nice if you could have that sort of polymorphic behaviour that you get with self
when you're using objects... but on every parameter?
That's essentially what you get with Julia's multi dispatch. And when you really start using it, much of what you'd do in a classic object oriented style seems both overly verbose and limited. It's why you can write out matrix multiplication the same way you would scalar multiplication, and then do something weird like make a string repeat, all using a simple operator like *
. Normally you'd be worried about name collisions or other nastiness. But the parameter types effectively create a *_astring_and_aninteger
function behind the scenes.
In fact, Julia makes this clear by differentiating functions from methods. If you do the following:
methods(*)
You'll list out all of the methods of the *
function. There are a lot of them! Multi dispatch combines with Julia's type system to give a very powerful way of expressing ideas. And it's especially good at expressing math-y ideas that always seem kind of awkward when expressed in a completely object oriented style.
If you haven't taken Julia for a spin yet, I hope this makes you curious enough to try!