Types in C/C++ and Julia
On the surface C/C++ code and Julia code will often look quite similar despite C++ being statically typed and Julia being dynamically typed. Comparing the two is an interesting case to highlight the difference between static and dynamic typing at a deeper level.
Static typing proponents like to say that dynamic type is really just a special case of static typing where every object has the same general type. That is misleading and hides the deeper differences.
Similarities
Lets look at a code example in C/C++ and Julia which looks superficially almost the same.
struct Rocket {
int stages;
float fuel;
bool reusable;
};
void refuel(Rocket &rocket, float fuel) {
rocket.fuel += fuel;
}
So we got a struct representing a rocket, which keeps track of how many stages it has left, fuel and whether it may be reused such as the Falcon 9 rocket. In Julia I could write:
mutable struct Rocket
stages::Int
fuel::Float64
reusable::Bool
end
function refuel(rocket::Rocket, fuel::Float64)
rocket.fuel += fuel
end
Notice how similar it looks, if you disregard the fact that type and variable name has swapped places and become separated with a ::
symbol. Also Julia doesn't use curly braces but begin
and end
, skipping the begin part if there is some other keyword such as struct
or function
to define it.
Similarities also exist at a more detailed level. In Python a composite type such as Rocket would have ended up as a dictionary. A Julia struct often has identical layout in memory to a C/C++ struct.
Compile Time and Runtime Differences
This is were all the similarities end. C/C++ types are defined and really only exists at compile-time. In a dynamic language types exist at run-time and type information can be manipulated and introspected at runtime with the same syntax as everything else is. Lets start the Julia REPL and look at how:
$ julia
Define variable a
to contain 5.
julia> a = 5
5
Since types are regular objects existing at runtime, we can stick them in an array:
julia> mytypes = [Int, Float64, Bool]
3-element Array{DataType,1}:
Int64
Float64
Bool
Now for the fun part. This example show how you got the full language available when defining a type:
julia> mutable struct Rocket
stages :: supertype(Int)
fuel :: (a > 5 ? Float32 : Float64)
height :: if a > 20 typeof(a) else Float16 end
reusable :: mytypes[3]
end
There is no real difference between using if
statement and ?:
here, I am just doing it for the sake of showing that we are not limited to some specific subset of the language. while
, for
and if
are not statements but expression in Julia so they return a value. That is why I don't need a return statement inside the if
-else
.
We can look at the actual type created with dump()
julia> dump(Rocket)
Rocket <: Any
stages::Signed
fuel::Float64
height::Float16
reusable::Bool
What happened is that the definition of the Rocket struct ran like any other code, resolved the types at runtime and assigned those types to the fields of the Rocket type.
Just like any other type we can instantiate an object of this type:
julia> r = Rocket(3, 40.6, 120, true)
Rocket(3, 40.6, Float16(120.0), true)
We are not limited to using the language to determine types for definition of composite types such as a struct
. You can also use it when defining functions. Here is a bit of a contrived example of the refuel()
function:
a = 20
function refuel(rocket::Rocket, fuel :: (a < 20 ? fieldtype(Rocket, :fuel) : Float64))
rocket.fuel += fuel
end
If you are from a static typing background it might be hard to wrap your head around the notion that functions don’t really exist in dynamic languages at the point where they are run. It is at runtime when the program encounters a function definition that the code for it is actually created. And in Julia’s case it is really just a sort of code template which is created.
It is upon calling the function with arguments of specific types that Julia will specialize the function and have the Julia JIT emit machine code specifically tailored to those particular function arguments.
This means that as in this case, we have the option of using all available state in the program to determine at runtime what the definition of the function should be. In our silly case, we are making a check on the contents of a variable a < 20 ?
. If it is less than 20, we call a function fieldtype()
which returns the type of a specific field.
julia> fieldtype(Rocket, :fuel)
Float64
All this dynamic and potentially complex code doesn’t really cost us anything in the long run, as the Julia JIT will do away with it. To demonstrate let me create another simple type:
julia> struct Point
x::Int
y::Int
end
Then we define a function which uses fieldtype()
to define one of its arguments.
julia> add(p::Point, x::fieldtype(Point, :x)) = p.x + x
add (generic function with 1 method)
julia> p = Point(4, 8)
Point(4, 8)
julia> add(p, 5)
9
Now lets look at the generated assembly code.
julia> @code_native add(p, 5)
pushq %rbp
movq %rsp, %rbp
addq (%rdi), %rsi
movq %rsi, %rax
popq %rbp
retq
As you can see there are just some push
and pop
to save registers when calling a function. The meat of the function is just:
addq (%rdi), %rsi
Thus despite all sorts of crazy stuff added to the function definition, the JIT compiler throws away all of that away and leaves us with highly optimized machine code.
What Does Types mean in a Dynamic Language?
It might still not be clear why Julia uses all these type annotations, especially if you come from a single dispatch language such as Python or Ruby. It makes it look as if Julia is a statically typed language. It is easy to demonstrate that Julia doesn’t require type annotations:
mutable struct Rocket
stages
fuel
reusable
end
function refuel(rocket, fuel)
rocket.fuel += fuel
end
If we test this code in the Julia REPL, you can see that it works fine to create an instance of the Rocket type.
julia> r = Rocket(3, 40.6, true)
Rocket(3, 40.6, true)
The types of the fields end up the same:
julia> dump(r)
Rocket
stages: Int64 3
fuel: Float64 40.6
reusable: Bool true
The refuel function works as before:
julia> refuel(r, 4.0)
44.6
The point of specifying types in Julia is the same as in Python and Ruby. In both you are stating explicitly the type of the first argument to a function when that function is a member of a class. I am referring to the first argument as the self
argument which is often implicit.
You need to do this because the same function can have multiple different implementations for different classes. That is what polymorphism is all about. Specific code runs when the this
or self
argument is of a specific type.
Julia simply takes this reasoning one step further and picks a specific implementation of the function based on any argument, not just the first one (self or this).
So the purpose of Julia type annotations is really to just be able to tell the runtime system which implementation belongs to which combination of argument types.
That is not the purpose of type annotations in statically typed languages. In a statically typed language this is used to catch type errors. Such as attempting to call a function with the arguments of the wrong type, at compile time.
That would be impossible to do in Julia as there is no clear distinction between compile time and runtime. Functions are compiled as they are encountered at runtime.
One way to look at this is to say that statically typed languages care about the type of expressions while dynamically typed languages care about the types of values. And expression has no type in a dynamic language. It is when the expression is evaluated that we get an object with some type.
This does not suggest one way is better than the other, and that was not the point of this article either. The point was to convey that dynamic typing is not the same as writing in a statically typed language where everything is of type void. Static and dynamic typing are fundamental different ways of dealing with types.