Why specify argument types and return types

This is one point of tension in julia. People are used to using type systems as a way to enforce some notion of correctness or for documentation purposes, but that’s not really what Julia’s type system is for. Julia’s type system, especially the annotation of function arguments, is designed for directing dispatch first and foremost.

One thing you can do that kinda lets you have your cake and eat it too here (not always), is to write the generic version of your function, say something like

f(x, y) = sin(x^2 - 2y)

and then you can write a specific, constrained version of this that just invokes the generic version:

f(x::Float64, y::Float64)::Float64 = invoke(f, Tuple{Any, Any}, x, y)

This way you can have the warm security blanket of very strict type annotations, but also write code that’s actually generic and someone is free to stick a Complex{Int} into or whatever and have it ‘just work’.

7 Likes

I’m not familiar with the:

Tuple{Any,Any}

syntax. You’re invoking f and specifying the types of the arguments. Why a Tuple?

Tuples are abstractions of function arguments in Julia, so the types of arguments are represented with a Tuple. Without annotating types, the argument signature of f(x, y) is Tuple{Any, Any}. Similarly, the argument types of f(x::Float64, y::Float64) is Tuple{Float64, Float64}.

5 Likes

It really seem to me that this approach fails on both fronts. On the type safety front it does not guarantee the types of the parameters, on the generality front it adds an unnecessary line/method. The only positive point here is that for a specific set of parameters you declare the return type (at the method definition), however, as it was pointed before annotating the return type is not incredibly useful (you could basically add an assertion just before the return for the same effect). On your toy example, I would see more value in:

function f(x :: T, y :: T) :: T where {T}
    sin(x^2 - 2y)
end

That guarantee both parameters are the same type and the returned value match them. You can also put a subtyping constraint on T if you want.

I think it can help desk with ambiguities.
Maybe you have a generic method, and a few specializations for specific types.
It’s easy to get method ambiguities this way.

My pattern has been

_generic_f(x, y) = sin(x^2 - 2y)
f(x, y) = _generic_f(x, y)
f(x::Float64, y) = cos(x^2 - 2y) # if exactly one argument is
f(x, y::Float64) = cos(x^2 - 2y) # a `Float64` use `cos` instead
f(x::Float64, y::Float64) = _generic_f(x, y)

And then to make the generic foo and the specific Float64 version dispatch to it, while other specializations can go elsewhere.
Mason’s approach requires 4 definitions/lines instead of 5:

f(x, y) = sin(x^2 - 2y)
f(x::Float64, y) = cos(x^2 - 2y) # if exactly one argument is
f(x, y::Float64) = cos(x^2 - 2y) # a `Float64` use `cos` instead
f(x::Float64, y::Float64) = invoke(f, Tuple{Any, Any}, x, y)

It also allocates slightly less memory:

> jm -e '@time include("invoketest.jl")'
  0.096317 seconds (388.82 k allocations: 22.578 MiB, 97.99% compilation time)
> jm -e '@time include("invoketest.jl")'
  0.093686 seconds (388.82 k allocations: 22.578 MiB, 98.08% compilation time)
> jm -e '@time include("invoketest.jl")'
  0.093515 seconds (388.82 k allocations: 22.578 MiB, 97.86% compilation time)
> jm -e '@time include("invoketest.jl")'
  0.098744 seconds (388.82 k allocations: 22.578 MiB, 97.95% compilation time)
> jm -e '@time include("invoketest.jl")'
  0.095133 seconds (388.82 k allocations: 22.578 MiB, 98.15% compilation time)
> jm -e '@time include("noinvoketest.jl")'
  0.098623 seconds (389.85 k allocations: 22.644 MiB, 97.96% compilation time)
> jm -e '@time include("noinvoketest.jl")'
  0.098635 seconds (389.85 k allocations: 22.644 MiB, 98.02% compilation time)
> jm -e '@time include("noinvoketest.jl")'
  0.095617 seconds (389.85 k allocations: 22.644 MiB, 97.65% compilation time)
> jm -e '@time include("noinvoketest.jl")'
  0.096679 seconds (389.85 k allocations: 22.644 MiB, 97.76% compilation time)
> jm -e '@time include("noinvoketest.jl")'
  0.098724 seconds (389.85 k allocations: 22.644 MiB, 97.78% compilation time)
2 Likes

I definitely disagree with your assertion that it fails on both fronts, but It’s certainly not a big win either. I suggest it because some people talk about how they appreciate the ‘self documenting’ nature of type constraints.

When you write (now using v1.7 features)

f(x, y) = sin(x^2 - 2y)
f(x::Float64, y::Float64) :: Float64 = @invoke f(x::Any, y::Any)

I see it as saying something like

I wrote this function with Float64s in mind and I imagine it returning a Float64. I want to be guaranteed that if I call this method with Float64 input, I get Float64 outputs.
Technically though, nothing here requires Float64 and indeed this can actually work on square matrices of Dual numbers if you want, but I haven’t tested or thought about that usecase.

It’ll be evident in your stacktraces or even static tracing using JET.jl if the fallback method gets hit or not, so the presence of the generic method doesn’t preclude static analysis. It just helps you write open, generic code but maybe also make some of your thinking and expectations more explicit.

Just to be clear though, I don’t think this is a great solution or very important though.

Note that this is actually incorrect for some inputs:

julia> function f(x :: T, y :: T) :: T where {T}
           sin(x^2 - 2y)
       end
f (generic function with 1 method)

julia> f(1 + im, 1 - im)
ERROR: InexactError: Int64(-24.83130584894638)

That’s exactly why I chose the example I did with concrete types

The only difference I see here would be to throw an error if the parameters have different types, the generic version will also specialize for the input type if both are the same. On the contrary, maybe parameterizing for the case of different types, to internally decided what to to do and return, maybe useful.

Since this has come up over and over in Julia discussion forums, I’ve created a PR to add a basic summary to the manual: add basic overview of when to use type declarations by stevengj · Pull Request #39812 · JuliaLang/julia · GitHub

10 Likes

Sorry I have to comment here, or maybe it would be better to comment in the pull request.

I feel like there is a fury with which most(?) people push others to not declare parameter/return types. It is to the point that it feel like the language designers would prefer if you couldn’t declare types at all, but since they couldn’t swing that any unnecessary type declaration are vehemently attacked.

I do see your point of view for the data processing space. Many math/array operations don’t really care if the types are Ints, Floats, or BigInts, and code reuse is always good. However there is a minority(?) of us that are using Julia as a general programming language.

When I write a method to handle an HTTP.Request and return an HTTP.Response there is no need for generalization unless someone creates a new HTTP package and mirrors the HTTP.jl package’s API, which I find significantly less likely than defining a function for Ints that could also be used for Floats.

Defining the parameter types allows me to catch errors with clear errors. When the calling method is compiled I get an error that “SomethingElse” cannot be passed to a method. I don’t get the obscurer error that “SomethingElse” doesn’t have a “query” property. Or possibly 5 levels above that method that the HTTP.decode method doesn’t take “SomethingElse”, and I have to go down through the call stack trying to figure out when “SomethingElse” started getting passed up.

Defining the return types also allows me to catch errors early, and more importantly at the point of the error. In the HTTP processing case not returning an HTTP.Response means a bug. When the compiler tries to compile that method it tells me that “SomethingElse” could not be converted to an HTTP.Respons. I do not get some obscure error 5 levels down the call stack where it can’t pass “SomethingElse” back to HTTP.jl. Or that “SomethingElse” doesn’t have a “body” property. Then I need to go up through the stack (a much bigger search space than going down) to figure out what function returned the “SomethingElse” that is causing all the issues.

This means that even when the types CAN be generic I still define the return type based off the input types. i.e. foo(a::T)::T where T.

In your documentation update you mention “…you should instead write ‘type-stable’ functions…” which is all well and good, but it’s nice to have the compiler double checking your work in case you forget to run @code_warntype on your changes to ensure the function is still type stable in it’s return.

A final note, TypeScript was created for a reason. It added types to an inherently un-typed language. I’m curious how the “don’t declare types” people feel about TypeScript? Do they feel that it was a waste of time, adding an unnecessary level of type validation to a beautifully un-typed language? Personally I have lived the hell that can be JavaScript and I fricken love TypeScript, it gives me much more confidence that my code will actually run when I try to run it.

Sorry for the long response, just my two cents on type declarations. They are not needed for performance, agreed, but they can provide a level of confidence about the function and the program actually having a shot at working.

6 Likes

For certain cases, as you say, specifying concrete argument and return types makes total sense. However, there are many scenarios when explicitly written types, even abstract ones, hinder reusability. I’m not sure why you say that this generality is needed only in math or working with arrays, and contrast it to some “general programming”

As a specific example, I’ve seen lots of code in julia, even in popular packages, that does something like this:

do_work(x::Data) = ... work with data ...
do_work(x::AbstractString) = do_work(read_data(x))

read_data(x::AbstractString) = parse_data(read(x))

or even some isa checks for a similar effect. This means when one creates a custom type to represent a file in an archive/remote file/etc, implements relevant Base. methods, it still won’t work for such packages: they require a file specification to be an AbstractString.

Luckily, such explicit checks seem less ubiquitous in julia compared to other languages - notably, Python.

3 Likes

My understanding is that TypeScript adds static type checking, which indeed does type validation before you run it.

All of the type declarations in Julia, in contrast, are still only checked at runtime, so basically they just change the backtrace — which can be useful, essentially as a form of runtime documentation, but is not a validation tool in the same sense as you have in a static language. (And even then, return-type declarations are technically a “forced conversion” and not a “check”, so they may hide type-stability problems rather than catching them.)

And no one is saying “never use type declarations” in Julia, just to be aware of what they do, what they don’t do (performance), and what the tradeoffs are (generality).

10 Likes

Another minor point:

struct Salad{T}
    one::T
    another::T
end

Can make a Banana Salad or an Apple Salad.

struct Salad
    one::Fruit
    another::Fruit
end

cannot enforce such a distinction.

Sorry, I’m hungry.

Declaring the types of parameters is essential for multiple dispatch, which is essential for Julia design, so you are fundamentally misunderstanding something if you have such impression. The problem is more like the language designers suffering at looking their users hammer a screw instead of using a screwdriver.

I think it is a lack of vision that makes you believe this is more useful for math/array operations than for “general” programming. Object orientation is often sold as an important tool for “general” programming, and its polymorphism is based on a less powerful version of multiple dispatch.

And so, you lose the possibility, for example, of making a test suite using a mock HTTP object, that acts as is expected of one, but does not really make an HTTP connection, speeding up tests and making sure you are not at the whim of other network problems.

@assert(typeof(x)) == SomeType would also serve to this purpose.

As already pointed by @stevengj, this was to add optional static checking. Moreover, considering the type system of Javascript was (I may not up to date here) not only dynamic but also weakly type (not duck typed, but weakly typed, with some worrying automatic conversions), almost any change would be an improvement. Finally, the language designers many times had expressed their interest and respect in Rust, Haskell, and other very strongly static typed languages; these languages made their trade-offs, shine on what they focused on, but they are not Julia, that was designed considering other trade-offs. If you fell the need of a string and strong statically typed language consider using one, Julia will always fall short at this point.

I myself am a great fan of Haskell, and I love the strict static type checking that makes my code almost always work as intended at first moment there are no compiler errors. However, each language has its design choices, strong points, and weaknesses; and even trying to combine everything like C++ has strong negative points.

6 Likes

That’s just my attempt to figure out the fury/vehemence I feel expressed when people suggest adding type declarations to their methods when they are not needed for compilation. Maybe I’m reading too much feeling into people’s responses when they rail against defining types or return values to methods.

Well that feels like an attack at me, thanks for that. Is there a particular category of methods that are inherently generic that I should have used instead? I figured most people would see that math and array iteration are two problem sets where types don’t mater much. I didn’t feel the need to list all all categories that benefit from being generic. Maybe your feeling is that all methods benefit from being generic? But my method that queries the database for the hierarchy tree couldn’t take a MySQL connection instead of a PostgreSQL connection, the SQL would have to be different! And I haven’t check the MySQL library to verify that it’s API is the same

Agreed, and if I planned on passing in mock objects then I would have to do something different. Unfortunately I’ve never worked a company that have had enough extra money/time to spend hitting the kind of software engineering ideal, i.e. 100% unit test coverage. And this current project, definitely not.

Just to be clear, are you suggesting I use this instead of defining method types and return types? Like should I put that after every call to the method?

Really that’s your answer? Go use something else if you want to define your parameters when not strictly required? Ouch. This as a great example of the fury/vehemence toward people who want to define their parameters.

I think you are.

If you are reading my aformentioned blog post, then do be aware that:

  1. As i quote in the introduction from Press, Teukolsky, Vetting and Flannery’s “Numerical Recipes”:

We do, therefore, offer you our practical judgements wherever we can. As you gain experience, you will form your own opinion of how reliable our advice is. Be assured that it is not perfect!

  1. It is written in a intentionally direct and strongly stated way. For clarity of reading of the blog post, i find it better not to hedge statements too much.
    In practice I do on occation “over-constrain” things, mostly to match existing conventions. Generally at the level of Real or Number rather than AbstractFloat or Flaot64.

I really don’t think it was.
Tone is hard in text.
I would recommend assuming charitably.


From my experience there are 3 killer use cases for Multiple Dispatch.

And the most significant of them is for linear algebra.


I would not try and program julia like you would type-script.
Type-script added types to the language for safety (as i understand it).
To be more like a language where errors can be caught at compile time.
Julia added types to the language for expressivity.
To allow things to be extended.
They are conceptually different things.

If you try and use types for safety in julia you will still be disappointed.
Since even with everything strictly type constraint the compiler won’t prevent you from calling a method that doesn’t exist, it will just error at run time.
There are some tools like JET.jl GitHub - aviatesk/JET.jl: An experimental code analyzer for Julia. No need for additional type annotations. that can help with this.

But its not going to be an on par experience as type-script’s type-checker.
Let alone Haskell’s.
Its just not the focus of the language.
And that is ok.

C’mon.
That’s not fury, or at least my statement above that is similar isn’t fury.
We (well at least I) just want you go into this with the right expectations, so you don’t have a bad time.
There is nothing wrong with wanting a statically typed language.
But julia isn’t one; and your just going to be disappointed if you hope it is.
You can constrain your type-parameters if you want, but its not going to be anywhere near as rewarding as it will in other languages.

I love me a statically typed language, prefer it even.
I am for example really enjoying DexLang right now.
I put up with Julia not being a statically typed language, because it has other strong points.
And I want to take advantage of those strong points; rather than try and force it to be something it isn’t.

Go ahead and write things heavily constrained if you want.
If you do, do try out JET.jl and see if it is catching bugs and stuff that it wouldn’t if you didn’t constrain things.
and then come tell us all about how good it is. (or if it all sucks)

12 Likes

To be fair, it should go both ways: the receiver should assume goodwill but the person writing should try to be overly nice (to compensate for the “tone is hard in text”). Phrases like “it is a lack of vision that makes you believe this” and “you are fundamentally misunderstanding something” are too easy to be interpreted as rudeness. One can do better here. The “Tone is hard in text” argument shouldn’t be used as an excuse :slight_smile:

Anyways, assuming (and hoping) that no one wanted to offend anyone, let’s talk about type declarations again :slight_smile:

12 Likes

“If you fell the need of a string and strong statically typed language consider using one, Julia will always fall short at this point.”

I am not sure what you are reading as fury here, I am literally saying that if you fell like X then you should consider Y. Also, as someone that loves Julia, and would like to see a strong Julia community, I am accepting the shortcomings of the language, and that the most honest advice for some people is that maybe you would be better without Julia, even if this reduce our numbers. I am literally working against my interests here.

Yes, this interpretation. As you can see in my comment about HTTP objects.

… In every case? I am maybe very out of date here, but most databases supported some basic operations with the same syntax. Nevertheless, this can be a case in which type parameters should be annotated, again, I am not saying they should be left out in every case, but if you have a same function that will act different for many different objects then you have to annotate type parameters for multiple dispatch.

Not generally. I am just trying to point out there are other ways to reach the same objective, if you fell uncomfortable with how people react to you annotating type parameters. The only advantage of my suggestion is that it allows for more extensive testing (accepting a short range of types, and checking things in value-space) without making the function definition gigantic but this is a cosmetic and, therefore, minor improvement.

Ok, maybe it is better to remind everyone (and myself too) that answering to the tone of a post is not recommended by the community guideline.

[…] Please avoid:
[…]

  • Responding to a post’s tone instead of its actual content.

I think that, while discussing about the community tone in general (like @pixel27 started) is useful, maybe it should be restricted to the Meta category of the forum. Also, the discussion ended up a little more personal, instead of being about the general tone of the community. My advice is: next time someone feels that I am being rude just report me. This avoids wasting forum energy on these personal discussions, and if the moderators (that are neutral part in the dispute) find that I am really being too rude for the forum standards, they can put me back in line in private, without unnecessary drama. One could reach in private too, if not feeling like spending the time of the moderation, but I see a lot of value in involving a neutral and more experienced party.

3 Likes

So much to respond to, most I won’t, I feel like responding to some of it would drag this thread off topic. So I’m going to try to stay on topic. My post was trying to point out that some of us do like defining the parameter types and return values, and Julia lets us to that which is great.

Does this reduce the re-usability of the method? Absolutely. Does it provide us additional safety checks? Absolutely. Are we will willing to sacrifice re-usability of our method for those safety checks? Absolutely. Can we use the where T syntax to open that method back up for more data types data types? Absolutely.

Telling me I shouldn’t define the types or returns types just in case I want to re-use the function falls on deaf ears because 99.9% of them won’t. It’s up there with taking an umbrella with you on a clear sunny day, just in case. Or making sure the code will run on a 32bit computer just in case. Yeah I know if 32bit Atom CPUs take off in popularity, I’m screwed (also if it rains.)

2 Likes