The runtime type and the variable type

Here’s a short experiment which demonstrates that the runtime type is an independent piece of information from the type of a variable.

julia> x::Any="hello world"
"hello world"

julia> typeof(x)
String

julia> x=1
1

julia> typeof(x)
Int64

In the example above, the type of the variable x is Any, but the type of the data in memory which x refers to is either String or Int64 depending on the concrete type of the data in memory.

julia> x::String="hello world"
"hello world"

julia> typeof(x)
String

julia> x=1
ERROR: MethodError: Cannot `convert` an object of type Int64 to an object of type String
The function `convert` exists, but no method is defined for this combination of argument types.

In this example, x is constrained and therefore the variable x cannot refer to data in memory of type Int64.

Is there a way to retrieve or query the type of the variable x itself, rather than the type of the data which x references?

In other words, a function f such that

  • f(x) -> Any in the first example
  • f(x) -> String in the second example

Sorry if the answer to this question is obvious or mentioned somewhere in the docs. If it is, I missed it.

There might be two misunderstandings at play here:

First, variables don’t have types in Julia, only values do. In your first example, the variable x does not have a type in itself. The value is a String and therefore x is bound to a value of type String.

In the code x::Any="hello world", the x::Any part means that in the given scope, the variable x may only be bound to values of type Any. Again, the variable itself has no type independent from the value

The second issue is that since Any is a supertype of String and Int, all instances of the two latter types are also instances of Any. So the two statements “x is an instance of Any” and “x is an instance of String” are not mutually exclusive.

Therefore, the function f you are looking for cannot exist. In both your cases, the type of the value x is String AND Any. This is analogous to how I am both a human, a mammal, a vertebrate, an animal and an organism.

There is a related question which is similar to, but not quite the same as the question you are asking. You could ask “what type has the compiler inferred that the type of x is?”. Note that the difference between this question and the above is that the compiler could infer only that the type is Any, whereas the type could be String. The compiler only needs to infer a supertype of the concrete type - it does not have to infer the concrete type of the value.
Semantically, there is no difference whether the compiler infers Any, or Union{Int, Char, String}, or String - the code will behave the same. However, the generated code will be more efficient if it infers a concrete type.
And there is currently no supported, stable way of programmatically getting the inferred type of a value.

7 Likes

@jakobnissen’s point notwithstanding, there is a function Core.get_binding_type which, similarly to fieldtype, returns the type of a binding in a given module. In your example, Core.get_binding_type(@__MODULE__, :x) would get you the type of the binding x in the current module. Note however how you need to specify both the module where the binding lives and the name of the binding.

3 Likes

To elaborate on jakobnissens description. Variables in julia should not be thought of as a particular memory location or register with a particular size, like in C, rust or similar. A variable is just a name. The name might reference a value. The value has a type. Only values have types, and the type of a value is always concrete. Variables are just names. The syntax myvar::Int = 0 constrains myvar to only reference values of type Int, but the variable myvar still has no type, the value it references has a type.

When assigning to variables with such a declared type, the right hand side will implicitly be converted to the declared type. If that can’t be done, an error will be thrown. Unlike other dynamical languages, there is usually no performance to gain by declaring a type for a variable. (Unless the variable is global).

julia> c::Int = 0
julia> c = 2.0
julia> c = 2.5
ERROR: InexactError: Int64(2.5)
Stacktrace:
...
2 Likes

The reason why I think variables themselves must have type information associated with them is without this (runtime) type information, there would be no way for x to reject the assignment of a value of the wrong type to itself.

It is quite likely that my mental model for where this data lives is wrong.

At runtime, there must be two (three) pieces of information.

  • The type information which states what type x has right now
  • The type information which states what the set of possible types which can be assigned to x are
  • (The data, or value, assigned to x)

For example

x::Any = "Hello World"
  • Right now, the type of x is String
  • But, we can change it: x = 1. There must be a way at runtime for Julia to know this is permissible, surely?
  • (The value is "Hello World", which has the type of String)

Possibly I’m wrong and misunderstand the internal workings of the typesystem. (Quite likely.) But, without this additional piece of information, how can Julia know, at runtime, whether assignment of another type to an existing variable is permitted.

Actually, it could be that I have answered my own question.

  • If the type information is gone by the time the JIT process is completed, then this may explain the solution. However, elsewhere in the docs I saw some code/pseudocode which showed that Julia variables at runtime are represented in a very similar way to Python. Maybe I misunderstood something here.
  • See: Memory layout of Julia Objects · The Julia Language

This might be related to the above?

Yes - I think you summarized it more clearly than I have. My question, in part, is what mechanism provides this? How does it work?

Yeah you are basically right. However, Julia doesn’t provide any way of programmatically getting the “assigned type of a variable”, e.g. Any in the example x::Any = "Hello World".

Or rather, the function Core.get_binding_type will do that, but this is an internal function, and may be removed or change behaviour in future versions of Julia. It is essentially part of the compiler/runtime internals.
At the level of the exposed, public Julia semantics (as opposed to the compiler internals), only values and their types exist.

In a similar vein, for a certain functions, the compiler may completely optimise the existence of runtime types away. However, from the viewpoint of Julia semantics, the types are always present even if any particular generated piece of code may have optimised it away.

No. It’s mostly a compile time thing. Or, to take an example. Say you do

x::String = "hello"
x = 1

If the variable x has a declared type T, every assignment x = a will be lowered to x = convert(T, a). The convert function is almost always inlined, and if the program is written properly, the type of a can be inferred by the compiler, and the convert does not need any runtime information, but may emit a single instruction e.g. converting from integer to float.

If the types of variables can not be inferred, i.e. the code is not type stable, there will be a lot of type checks, as you suggest. Performant code should be type stable, i.e. in every function, the types of every variable can be inferred from the types of the input variables.

julia> f() = (x::String="hello"; x = 1)
f (generic function with 1 method)

julia> @code_lowered f()
CodeInfo(
1 ─       Core.NewvarNode(:(x))
│         @_3 = "hello"
│   %3  = @_3
│   %4  = %3 isa Main.String
└──       goto #3 if not %4
2 ─       goto #4
3 ─ %7  = @_3
│   %8  = Base.convert(Main.String, %7)
│   %9  = Main.String
└──       @_3 = Core.typeassert(%8, %9)
4 ┄ %11 = @_3
│         x = %11
│         @_4 = 1
│   %14 = @_4
│   %15 = %14 isa Main.String
└──       goto #6 if not %15
5 ─       goto #7
6 ─ %18 = @_4
│   %19 = Base.convert(Main.String, %18)
│   %20 = Main.String
└──       @_4 = Core.typeassert(%19, %20)
7 ┄ %22 = @_4
│         x = %22
└──       return 1
)

And, then:

julia> @code_typed f()
CodeInfo(
1 ─     nothing::Nothing
│       nothing::Nothing
│       nothing::Nothing
│       Base.convert(Main.String, 1)::Union{}
└──     unreachable
) => Union{}
1 Like

Thanks - very useful to know about this.

Ah, so this explains it. The compiler knows the “target type” and it knows the target type differs from the source type, so it will insert calls to convert(targetType, referenceToSourceData)

This is potentially more interesting.

If a function is written, and somewhere in that function it uses some global value as input, then the compiler cannot (might not) know what type that global value has.

function anon(x::Int64)
    y::Union{Int64, String} = globalVariable
    if y isa String
        return x + 1
    else
        return x + 2
    end
end

I think the interesting line here is

y::Union{Int64, String} = globalVariable

where globalVariable::Any.

What does the compiler have to do here? At runtime, globalVariable can have any type, and it could be converted to one of two types. So it doesn’t look like a simple call to convert(Union{Int64, String}, globalVariable) can be inserted here… Or can it?

globalVariable can have any type, but y still has a finite union type, and it won’t be re-bound if globalVariable is re-bound.

This is very easy to check:

julia> glob = 1;

julia> y::Union{Int, String} = glob;

julia> glob = [1,2,3];

julia> y
1

julia> glob
3-element Vector{Int64}:
 1
 2
 3

This means that a function referencing y always knows it’ll get Int or String, whereas referencing glob, it doesn’t know anything.

Oh, yes. It inserts a convert. But since it can’t know which instance of convert to call, it will do runtime dispatch based on the current value of the global globalVariable. So it can’t inline, it can’t eliminate the convert, it has to find out which one to call at run time.

When there are relatively few possible types (four or less, I think), runtime dispatch is fairly fast, so in this case there might be some help in declaring a type for y.

The typical performance trick if such instability can’t be removed, is to use a function barrier:

y = globalVariable
g(y)
...
g(y::Integer) = ... whatever ...
g(y::String) = ... whatever ...

Inside the new function g, there is no instability. So it will run fast.

Ok seems like the compiler behavior here is to change

y = glob

to

y = convert(Union{Int64, String}, glob)

Which it complains it cannot do because, naturally, there is no such function defined.

julia> y = glob
ERROR: MethodError: Cannot `convert` an object of type
  Vector{Int64} to an object of type
  Union{Int64, String}
The function `convert` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  convert(::Type{T}, ::T) where T
   @ Base Base.jl:126

Given what was posted previously this is nothing new, really.

This is the key point, I think. Thanks. This is going to take me down another wormhole figuring out how Julia does runtime aka dynamic dispatch. I’m sure there are documentation pages about this so I will go and try and look for them.

Thanks for all the responses.

This is perhaps a place to start: https://fzn.fr/projects/lambdajulia/paper.pdf

Thank you for the link. I did a bit of searching earlier and didn’t find much in the way of results.

Not sure if I would recommend that to a beginner. Probably better to start with the docs and playing in the REPL.

Also, FTR, the paper is a bit outdated if I remember correctly. Although it’s certainly a nice read, and it gives links to relevant Github issues.

I know some type theory - I just don’t know how Julia implements it.

Some pointers to relevant documentation:

  • In the manual:
  • In the base docs:
    • The <: doc string. It’s much expanded in v1.12.
  • In the developer docs:
    • More about types
      • NB: I feel that the start of the article, which draws the “types as sets of values” analogy is somewhat misleading. While this is a useful analogy, it’s not perfectly accurate for Julia’s type system: there exist types which are not equal but have the same sets of values. For example, on versions of Julia prior to v1.12, this doesn’t hold: Tuple <: Union{Tuple{}, Tuple{Any,Vararg}}. The fact that this is fixed on nightly Julia is nice, and also shows that the subtyping isn’t fixed in stone.
1 Like

You might also find this old StackOverflow answer of mine helpful at a high level. It’s also old, but nothing fundamental has changed to my knowledge.

1 Like