Forcing type declaration in Julia

I was pitching Julia as a viable option to my PI. The biggest potential problem they foresaw was same issues as with python, dynamic typing of variables.
As most of our codes will be authored by multiple people, I would like to know if there is a way to run julia interpreter where any undeclared variable type or mismatched type declaration is raised as an error.
So that a consistent variable typing and naming can be enforced. Is there anyway in julia to force this ? (Basically make it slightly more like Fortran and slightly less like python)

People who have written large collaborative packages in julia, how do you maintain/enforce type sanity in your packages?

2 Likes

Julia is a dynamic language and type declarations will (probably) always be optional. A lot of the strength and interoperability of julia comes from the fact that it is dynamic. Usually, using tools like JET.jl, @code_warntype helps, but as far as I know, there is no fully static type checker (also seems quite hard to do, with eval being a thing).

5 Likes

The first thing to ask is where type declarations in Julia make sense and where they do more harm than good.
It clearly does not make sense to arbitrarly restrict functions to specific types, e.g.

function double_me(x:: Float64)
   return 2*x
end

Why should this function not be used on a Float32 (or Int)?
Enforcing such a thing is a bad idea because it makes the code actively worse.

However, it makes sense to restrict types to the highest supertype for which the function works and makes sense, e.g.

function double_me(x:: Number)

This is not really required in Julia (and somewhat a matter of taste), but makes the code imho more readable and gives usually more conclusive error messages.

This is unfortunately not always possible, e.g. Callables and Iterables have no single abstract supertype in Julia. A first class support for traits or interfaces would be a great addition to Julia imho.
What is usually done here is not to do any type annotation (i.e. accept type Any) but document that the function expects e.g. an iterable. If the input is not iterable, an error anyhow occurs somewhere in the function.

12 Likes

Limiting the type like that will also lead to e.g. DifferentialEquations.jl not being able to autodiff/use that code, since it relies on at least a ::Number for its dual numbers. There can also be problems with e.g. Unitful.jl, which types are not a subtype of number.

4 Likes
julia> using Unitful.DefaultSymbols

julia> 1m isa Number
true
4 Likes

Curious - I seem to remember there being a problem with some of them :thinking:

Ah, I think it was this thread:

Specifically, this:

Though that’s orthogonal to limiting types per se, so I must have mixed the two up.

I think the Julian approach is more like: split your code in various packages with specific tasks. Most of the packages will be as loosely typed as possible. The actual types of one application are defined by the application, and the fact that the smaller packages where written to generically allows them to be used and reused in the original and in new contexts. Also by other people to other applications.

Note that by using that approach one may benefit from that in the original application, for instance by suddenly being able to differentiate the code, propagate errors, use units, and maybe unforseen cool stuff that will be interesting in the future.

One important part of the learning process of Julia is to get used to implementing functions that guarantee type stability within the function, not for specific input types, but any types. It turns out that most times this is actually less verbose than declaring types. It also sort of stimulates one to write smaller functions, which at the end is a good thing.

8 Likes

Thank you for your replies. I guess i see the point of loosely defined types specially not having to write same function several times for every possible combinations of number kinds!
When I was posting that question major benefit of strongly typed system in my mind was an error I once faced in matlab once where a variable name was accidentally overwritten by a different variable (If i remember correctly it was the clash between j complex number and j loop counter). In lieu of above suggestions let me rephrase as

Is there a way for julia to flag change of variable kind as an error?

That is if a variable is initially Int, then somewhere it is getting converted to Float etc?

1 Like

@code_warntype can be useful for this.

2 Likes

And

https://github.com/JuliaDebug/Cthulhu.jl

What is that name about? I can never type it wright on the first try (edit: googled already :imp: )

@leandromartinez98 I think the name comes from writer Lovecraft see List of Cthulhu Mythos books - Wikipedia, a serie of horror histories.

4 Likes

spelling it is one thing, pronouncing it another:

“the first syllable pronounced gutturally and very thickly. The ‘u’ is about like that in ‘full’, and the first syllable is not unlike ‘klul’ in sound, hence the ‘h’ represents the guttural thickness”

(H.P. Lovecraft, yes his real birth name)

1 Like

Both @code_typed and Cthulhu looks liking tools for finding type instabilities. What I was thinking was a much simpler code checker, that tracks variable names and scopes, and gives error when in same scope a variable changes type.

1 Like

The problem is that if you have

x=1
x=f(2)

you need to check the inference result of f to determine whether you have a variable change type.

1 Like

You can see that several of the performance tips for Julia have to do with avoiding such ambiguities in the types of variables - but not necessarily by declaring their types explicitly.

There are a couple of things that help you control the types of variables:

In local scopes (e.g. functions) you can annotate variables e.g. as x::Float64. In global scopes, declare variables as const and you will get an additional bonus on performance.

Best defense for a multi-author project is good unit tests and version control. Don’t let anyone check in code without adding a unit test that covers the code for situations of interest.

Generic typing can help here. Unit tests can use artificial types that have only the minimal expected set of operations, or are instrumented to do extra checking. E.g., if matrix multiplication routine claims to work for any element type T that has a +, *, and zero(T), create a custom type that has only those operations. If the routine claims to work for non-commutative *, define such for T.

8 Likes

Arch has already said it, but to emphasize a bit more — good testing is really needed to ensure correctness. Write tests for all new code, and get CI set up for your project to automatically run tests whenever changes are made (it’s quite easy to get started if your package is open - try PkgTemplates.jl with the GitHubActions runner)

Testing is important in any long-lived project including single-author projects, but generally even more important in a team environment. When new people start to contribute to a codebase, they’ll have no context to know what to look out for, or which important manual testing procedures need to be followed to keep the code working.

It’s best to assume that any untested functionality will end up broken the next time the code is modified.

Static typing certainly helps with correctness as well (though often at the cost of verbosity or loss of experssivity). Statically annotating types for all variables is never going to fit into Julia in a natural way and would only give you runtime errors anyway. However, you can use static analysis for Julia code via JET.jl, and this will give you many of the same benefits that you’d get from static type checking.

9 Likes

You always do declare variables in Julia, e.g. x = 1; y = 1.0; s = "s" implies doing x = 1::Int; y = 1.0::Float64; s = "s"::String and doing the latter would just be annoying and redundant.

What should be the variable declaration of:

a = ['s', "s"]
2-element Vector{Any}:
 's': ASCII/Unicode U+0073 (category Ll: Letter, lowercase)
 "s"

That’s likely an error in your code (still a valid declaration) and you likely wanted a = ["s", "s"] implying a = String["s", "s"] not a = Any['s', "s"], not even a = AbstractString['s', "s"] works and a = Any['s', "s"] is rather useless, also admits numbers, maybe not what you had in mind.

Please don’t declare functions taking a String (only in structs if you want/must), at least for packages, as it rules out other string types, rather use AbstractString (ruling out Char, what I would like to get rid of from the language) or declare nothing (implying Any). [Also never declare Char in you function, ruling out Strings as intended, but also other char types, rather use AbstractChar or declare nothing.]

[Was it possibly intended to keep [Abstract]String and [Abstract]Char separate or should they have been subtypes of AbstractStringorChar? Or would it be ok if simply AbstractChar was a subtype of AbstractString?]

Maybe const AString = AbstractString should be added to Julia Base? Because people may not like writing the latter. I’m not sure you should do it in your own code, in case it does get added to Julia.

This has sort of been alluded to, but Any is a valid type in Julia that all values belong to. So one can always just declare anything untyped as ::Any, which means that requiring type annotations doesn’t really accomplish anything. Another idea would be to require that all variables have a concrete type annotation, but that eliminates the ability to express a large amount of useful, correct and common Julia code, so that’s also not a good idea.

1 Like

While I mostly agree with your point here, I’ll just point out that there is a valid and useful type delcaration that would have caught this error.

julia> let
           a::Vector{<:AbstractString} = ['a', "a"]
           a
       end
ERROR: MethodError: no method matching (Vector{<:AbstractString})(::Vector{Any})
Stacktrace:
 [1] convert(#unused#::Type{Vector{<:AbstractString}}, a::Vector{Any})
   @ Base ./array.jl:554
 [2] top-level scope
   @ REPL[15]:2

versus

julia> let
           a::Vector{<:AbstractString} = ["a", "a"]
           a
       end
2-element Vector{String}:
 "a"
 "a"