Semantics of :: in return type vs. argument type annotations

That utterly baffled me when I found out. A return annotation has the same appearance as an argument annotation, but a subtly different semantic.

I still use them, but I don’t like having to think about whether I should. I’d like Julia 2.0 to change (a,b)::Type to have the same semantics as (a::Type) and make (a,b)::Type() the converting version. Or something like that, now we’re conflating constructors and convert, but that’s closer imho.

It’s more similar to a local-variable declaration. If you declare local x::T, then assignments x = y call convert(T, y).

(This was the motivation for the behavior, in fact, as discussed in add `function f()::T` return type declaration syntax by JeffBezanson · Pull Request #16432 · JuliaLang/julia · GitHub)

7 Likes

:: just declares a type in several contexts where it needs to do something different. I’d say callable/argument annotations actually stand out because the types are constrained by dispatch, not convert or typeassert. If we’re speculating about 2.0, I expect the latter would be simplified if it’s decided to (Require constructors and `convert` to return objects of stated type? · Issue #42372 · JuliaLang/julia · GitHub), or error, hence builtin typeasserts.

1 Like

It doesn’t absolutely have to do something different though, in fact it easily couldn’t. Struct fields and variable assertions convert, that’s good, function arguments don’t convert, also good, function return annotations behave like the former, not the latter. Two things there: I find it confusing, and it isn’t what I want.

Confusing:

julia> f(a::Int) = a
f (generic function with 1 method)

julia> f(0x01)
MethodError: no method matching f(::UInt8)

julia> f(a::Integer) = a
f (generic function with 2 methods)

julia> f(0x01)
0x01

julia> g(a::Integer)::Int = a
g (generic function with 1 method)

julia> g(0x01)
1

All those type declarations are on the same line, but they do different things, and I want g to throw an error, or I would have said ::Integer, or made it g(a::Number) = Int(a), something like that. There’s no easy way to get that behavior, which causes this kind of problem:

julia> h(a::Number)::UInt8 = a
h (generic function with 1 method)

julia> h(1)
0x01

julia> h(256)
InexactError: trunc(UInt8, 256)

Which is a big part of what type annotations are supposed to prevent. In a real method a would be coming from somewhere else and I would expect it to be a UInt8 already, and it only blows up when the value can’t be converted. So yeah, I’d like h(a)::UInt8() for the converting kind, that way I know it can blow up at runtime. Technically the other one blows up at runtime as well, Julia being dynamic and all, but if the type signature is wrong it will blow up the first time, rather than at some random future point when the value is too large.

1 Like

and setindex!

If you make a return type annotation that doesn’t convert, only typeassert like right-hand expressions’ annotations (which makes sense because you can’t assign to a return value like you can with a variable, field, or index), then I’d rather there not be 2 nearly identical return annotation syntaxes. UInt8() isn’t even syntactically consistent with constructor calls. Instead you could just write a convert call at the end, and it’s not much more to write than a return type annotation.

I’m guessing that return type annotations were chosen to also convert because it was already expected that more proper type annotations like h(a::UInt8)::UInt8 = a would be written as part of type stability principles, so it ends up as a nice header-level shorthand for an ending convert step. It still prevents impossible converts like in h(256). Even if return type annotations don’t convert, it’d be strange to have had the foresight to restrict the return type but not the input type.

Yes, there’s a way to do that — use the :: operator in a “type-assert” context:

julia> h(a::Number) = return a::UInt8
h (generic function with 1 method)

julia> h(123)
ERROR: TypeError: in typeassert, expected UInt8, got a value of type Int64

Yes, there are different behaviors in different contexts — and it is documented in ::'s docstring (although it looks like that’s not making it into the online manual right now).

help?> ::
search: ::

  ::

  The :: operator either asserts that a value has the given type, or
  declares that a local variable or function return always has the given
  type.

  Given expression::T, expression is first evaluated. If the result is
  of type T, the value is simply returned. Otherwise, a TypeError is
  thrown.

  In local scope, the syntax local x::T or x::T = expression declares
  that local variable x always has type T. When a value is assigned to
  the variable, it will be converted to type T by calling convert.

  In a method declaration, the syntax function f(x)::T causes any value
  returned by the method to be converted to type T.

  See the manual section on Type Declarations.
7 Likes

It is nice for a syntax to do just one job rather than depending on context.

In Rust,

fn f(x: i64) -> f64 {
    x
}

fails with

1 | fn f(x: i64) -> f64 {
  |                 --- expected `f64` because of return type
2 |     x
  |     ^ expected `f64`, found `i64`
  |
help: you can convert an `i64` to an `f64`, producing the floating point representation of the integer, rounded if necessary
  |
2 |     x as f64
  |       ++++++

and you have to do

fn f(x: i64) -> f64 {
    x as f64
}

x as T is maybe a little verbose for Julia, but maybe there’s another syntax that could distinguish typeassert from convert — or just use functions and macros.

3 Likes

Thanks for explaining this. The type assert also works a lot better with JET.jl

In the abstract, I’m fine with just changing the semantics in 2.0 land. I think having an after-the-parens syntax which means what (...)::Type used to mean might make it easier to convert code, but then again, maybe most code out there is using it for assertion/restriction, and the implicit conversion is just along for the ride. Certainly true in my case.

That said (...)::Type() is kinda ugly (pure aesthetics, ymmv) but I think it’s pretty clear, and I kiiiinda see the argument for having the ability to specify a conversion as part of the signature.

Sure, and if there are a bunch of returns, I can just scatter those all over the function, instead of putting it in the signature where it belongs.

Or, what I’d prefer: the in and out parts of the function signature behave consistently, and people who want conversion can ask for it. It’s not an uncommon request, so make it part of the function signature also, since “give me one of these” doesn’t always want to carry “only if it already is” as the semantics. I’m not in love with (...)::UInt8() but it’s suggestive of the difference at least.

A surprising semantic which is (again, just my opinion) a worse choice than the alternative, being nonetheless documented, is at most mitigating. I’d rather write Julia than complain about it, but this is the one thing I would emphatically endorse changing.

The question is whether a return signature should be L-value coded, or R-value coded, and I think R but Julia does L.

f(a)::UInt8 = a 

 # should this mean:

u::UInt8  = f(a) 

# or should it mean 

u = f(a)::UInt8

# ???

Right now it means the former, and I think the latter is much more consistent. Notice how f(a)::UInt8; and f(a)::UInt8 = a mean the same thing now? Which is the same thing you get in the rest of the signature, whether calling or defining it.

To me it isn’t about :: having a single meaning, which wouldn’t be great, I think the L value behavior is just fine. It’s specifically about what is most useful and consistent in a function signature. The other one.

1 Like

You’d need to do this for possibly multiple return points. The return type annotation provides a behavior without forcing you to refactor into 1 return point.

This is subjective and it’s been decided the other way for v1, but I would lean towards agreeing. I don’t really mind inserting multiple explicit convert calls, I’d appreciate the clarity, and right now I can’t opt out of the convert.

1 Like

All of this is definitely 2.0 kvetching, and most of the time the difference isn’t important, so I just add the return annotation and carry on with my day.

As you say here:

Imposing a requirement that type constructors must always return their type is a good fit with changing the semantics from convert to typeassert, because the user can replace all return a with return Type(a) and nothing unexpected can happen. At that point, f(a)::Type() is fairly clearly saying “transform all return a into return Type(a)”. I still don’t like how that looks, maybe f(a) as {Type}? I’m not in love with that either, but the other one is really busy as lambda syntax: (a,b)::T() -> a * b! Don’t love it. (a,b) as {T} -> a * b?

The ::Type() form does indicate that an implicit function is being called on the return value. I think it’s suggestive that there’s no clear way to represent a return typeassert if f(a)::Type means conversion (as it does now), though.

Edit: another point in favor of the type-call syntax is one can mix and match in Tuple types: f(a)::Tuple{Int,UInt8()}, the first return must be a literal Int, the second one goes through UInt8(retval). Tuple{A,B}() converts both. As much as I dislike how it looks, it reads perfectly clearly to me.

The distinctive thing about function returns is that they can come from a number of different places. Compare to parameter, field, and variable definitions, and R-value assertions: every other use of :: applies to one item of syntax. So it’s a good candidate for having a way to express both intentions.

I wish :: meant typeassert and only that. To have to remember that it may silently convert things adds cognitive load. I don’t think I have ever wanted things implicitly converted (though implicit type promotion is sensible, otherwise you couldn’t do 1 + 1.).

I was initially excited that I could write functions with the signature

function foo(x::Integer)::Float64
	answer = pi * x

	return answer
end

But since I learned that it applies conversion, I do:

function foo(x::Integer)
	answer = pi * x

	return answer::Float64
end

So its more digging around to see what type a function returns.

The implicit conversion makes it hard to find bugs. Say I end up writing the following:

_1st(x::Vector)::Float64 = first(x)

Now if :: were just a typeassert, I would probably realize after some testing that I meant to write:

_1st(x::Vector{<:Number})::Float64 = first(x)

Instead, I go on blissfully doing things like _1st(['a', 'b']) without errors.

2 Likes

Not necessarily. T(x) and convert(T, x) could only be interchangeable if T is primitive or if conversion is exactly equivalent to constructing a struct T’s fields from x. For a counterexample, Ref(Ref(1)) versus convert(Ref, Ref(1)).

That is even more horrific to me because that’s not a valid type. I find it pretty important especially to beginners that different things are not written in visually similar but non-interchangeable ways for different contexts. The human brain likes to connect and extrapolate, so either keep things the same or very different.

Even for left hand annotations? That could be helpful, actually. Just throws an error in your face if you didn’t intend for a conversion.

Taking this all together, it could be justifiable that there be a different annotation syntax for additional conversion, could even make it an infix operator to replace convert calls.

Funny you mention that because I’m not a fan of how there’s multiple function syntaxes (named function, named =, nameless function, anonymous ->. It’d be more okay if they had the same features, but parsing difficulties means they actually don’t. If function and end were too long, I’d rather they’d just be shortened. Rust does fn which seems clear enough, but I wouldn’t know what could replace end; my first instinct is a simple spaced period but seems weird. Anyway, if -> syntax is removed, then -> could actually be a drop-in replacement for :: meaning “assert but also convert first”.

1 Like

It’s worth noting that :: is pure syntax and doesn’t have a functional form (as most operators in the language do). It’s also worth noting that it has six distinct contexts in which it is used:

  • Assert that an expression isa particular type
  • Declare the type:
    • of a local or global variable
    • of a struct field
    • of a method’s return value
    • of the positional arguments of a method
    • of the keyword arguments of a method

Perhaps this is overly pedantic, but this discussion isn’t so much about the behavior of :: itself, but rather how things with a declared type should behave after you’ve defined them.

For example, the ::-declared method arguments (even kwargs!) don’t do conversion. It gets into tree-falling-in-forest-with-no-one-to-hear-land if you ask if the positional args even do type assertions.

For structs, it’s the default behavior that defines setproperty! as convert-and-assert — a behavior that can be overridden.

Would it be better if typed variables and method-returns just asserted? Maybe, it’d be a different choice. Is the syntax ::T() available? No, it’d be quite confused (even as a breaking change) as it currently means calling a function to see what type to use. Would it be better to have two flavors of declared types, one that does both convert-and-assert and one that only asserts? Definitely not.

5 Likes

Maybe the confusion does in fact come from the word “declare”. I’ve seen the word used in the documentation too. What does it mean to “declare” a type when the only options available are asserting or converting?

So when I “declare” x::Int = 0, it’s a conversion. But when I “declare” the type of a function argument in f(x::Int), it is not a conversion but an assertion.

To me, it would be clearer if :: just meant assert (yes, in the left hand side as well Benny). If x::Float64 = 0 gives me an error, okay, I’ll go back and add a dot. But if someone is comfortable with it meaning convert, maybe it’s not too strange to them to expect f(x::Int) to actually do something like f(Int(x::Any)).

If that is a call, yes. If that is a method definition, then no, dispatch will guarantee x isa Int, no convert or typeassert needed. To me, :: is very consistently guaranteeing something will have a type if the program continues. Removing convert still won’t make :: do so the same way everywhere.

Oh that’s interesting — and I think it gets to the crux of the matter. Type declarations are neither converts nor asserts in and of themselves. Asserting and converting are actions. Declarations are statements of fact about something, more like a label than an action.

Not really. It’s two steps: first you declare that x will only ever be of type Int, and then you immediately perform an assignment. It’s this:

local x::Int
x = typeassert(convert(Int, 0), Int)

You might do it all in the same line or the assignment might be miles away. But the behavior is really in how Julia assigns to a typed variable, and not in the declaration itself.

2 Likes

There is another potential source of confusion, which is that = returns the value on the right, pre-conversion:

julia> x::Float64 = 1
1

julia> x
1.0
6 Likes

Giving an assignment expression the value of the left hand variables would be ambiguous now:

julia> x::Float32=0; y::Float64=0;

julia> x = y = 1
1

julia> x, y
(1.0f0, 1.0)

That said, if we did away with :: conversions then the right hand value would match all the variables’ values, so it doesn’t matter which side we choose.

Couldn’t you just say control flows right to left so the value is the leftmost?