A = A .+ 1 yields different type than A .+= 1; why?

In the code below, I had expected the three additions at the end to produce the same type. Plus, given that the constructor of Share should throw an error when its argument is outside the range 0 to 1, I would not have expected to see a matrix of type Share with values outside that range.

What is going on?

julia> mutable struct Share{đť’Ż<:Real} <:Real
           x :: đť’Ż
       end

julia> function Share( x :: đť’Ż ) where đť’Ż<:Real
           @assert 0 ≤ x ≤ 1 "Shares must be between 0 and 1 inclusive"
           return Share{đť’Ż}( x )
       end
Share

julia> Base.show( io :: IO, x :: Share{đť’Ż} ) where đť’Ż <:Real =  show( io, x.x )

julia> import Base: promote_rule, convert

julia> promote_rule( ::Type{ Share{đť’Ż} }, ::Type{đť’Żâ‚‚} ) where {đť’Ż <:Real, đť’Żâ‚‚ <:Real } = promote_type( đť’Ż, đť’Żâ‚‚ )
promote_rule (generic function with 103 methods)

julia> convert( ::Type{ đť’Żâ‚‚ }, x :: Share{đť’Ż} ) where {đť’Ż <:Real, đť’Żâ‚‚ <:Real } = đť’Żâ‚‚( x.x )
convert (generic function with 137 methods)

julia> Base.:+( x :: Share, y :: Share ) = Share( x.x + y.x )

julia> s = Share.( rand(20,3) );

julia> println( typeof( s .+ 1 ) )
Matrix{Float64}

julia> s .+= 1;

julia> println( typeof( s ) );
Matrix{Share{Float64}}

julia> s = s .+ 1;

julia> println( typeof( s ) );
Matrix{Float64}

If you want s .+ 1 to be an array of Shares, you can change promote_rule to

promote_rule( ::Type{ Share{đť’Ż} }, ::Type{đť’Żâ‚‚} ) where {đť’Ż <:Real, đť’Żâ‚‚ <:Real } = Share{promote_type( đť’Ż, đť’Żâ‚‚ )}
3 Likes

First, note that s .+= 1 (literally equivalent to s .= s .+ 1 can never change the type of s. It simply loops through s, adds 1 to each entry, and stores the result into the entries of s (calling convert on them before saving to ensure they are compatible). So although the entries can change, s as a “binding” does not and so cannot change type.

Okay, so what is s .+ 1? Your promote_rule says that when you promote a Share{Float64} and Int, the result is a promote_type(Float64, Int) == Float64. So s[i] + 1 promotes both arguments to Float64 and then adds them, resulting in a Float64. This is why s .+ 1 (which creates a new array) results in a Matrix{Float64}.

Writing s = s .+ 1 (without the .=) does not assign the results elementwise into s. Instead, it computes s .+ 1 (which we established is a Matrix{Float64}) and then binds that matrix to the name s (forgetting anything s might have meant before).

Okay, now to your question about the Share constructor and its @assert. When we assign s .= s .+ 1 we compute (s[i] + 1)::Float64 then call convert(Share{Float64}, (s[i]+1)::Float64) as we store it into s[i]. Let’s try it:

julia> convert(Share{Float64}, s[1] + 1)
1.3852102999353921

julia> convert(Share, s[1] + 1)
ERROR: AssertionError: Shares must be between 0 and 1 inclusive

julia> @which convert(Share{Float64}, s[1] + 1)
convert(::Type{T}, x::Number) where T<:Number
     @ Base number.jl:7

julia> @which convert(Share, s[1] + 1)
convert(::Type{T}, x::Number) where T<:Number
     @ Base number.jl:7

So both hit the same fallback (probably defined as T(x)). If T === Share then that hits your constructor which throws the error. But T === Share{Float64} is actually more specific not the same thing, such that it goes right into the struct constructor and skips the check. Note that you yourself call Share{T}(x) in your Share(x) function, so without this behavior you would have an infinite loop there.

You might be looking for an inner constructor.

Be aware that as a mutable struct, it is possible to change (::Share).x and such changes will not trigger any constructors (or bounds checks therein). You should consider whether a (immutable) struct can work for you here. It will probably be more performant anyway. If you decide to stay mutable, you can change Base.setproperty! on your type to check the input before setting an illegal value.

6 Likes

I know you know this, but I think it’s probably good to reinforce the difference between renaming and in-place operations:

A = A .+ 1 is deciding that you have a better use for the name A. You’ll take the object you first named A and throw it away in favor of a brand new one whose contents are all one more than the original thing.

A .+= 1 is doing that same operation in-place. It’s the same as A .= A .+ 1. It’s updating the contents in the object you first named A without renaming it to something new.

3 Likes

Small quibble with the wording of “specific”, there’s a likely unintentional implication of method specificity thus fallbacks. While Share{Float64} <: Share, they’re not type annotations for input instances in their constructor methods, so as you said, Share{Float64} calls won’t ever fall back to Share methods. Either the exact parameter or a parametric method is necessary.

julia> struct X{T}
         X{Int}() = 0
         X() = 1.0
       end

julia> X{Int}(), X()
(0, 1.0)

julia> X{Float64}()
ERROR: MethodError: no method matching X{Float64}()
...
julia> X{T}() where T = X()

julia> X{Float64}()
1.0
2 Likes

Thanks Paul.

Aha!

From Overload addition assignment += operator - #2 by stevengj I deduced that x += ydoes exactly the same thing as x = x + y. This appears to be an exception.

This isn’t an exception, it is the rule. Whatever you attach on the left side of that = assignment is moved to the right. The dot in the .+ doesn’t “stick around” as part of the = — it moves with the +.

Whatever you manage to plop in the box in x □= y — be it one or two or more characters — is the operator and it becomes x = x □ y.

1 Like

Ok, I see.

If s is of type Matrix{Share{Float64}} then s .+ ones(10,10) is of type Matrix{Float64}, which if I stick it back into s via .= does not change the type of s, i.e. Matrix{Share{Float64}} .

And yes, I had not appreciated that the inner versus outer constructor distinction mattered here.

Wait, it does both, doesn’t it? Both sticks around and moves

x  â–ˇ= y becomes x  = x  â–ˇ y  (reassignment)
x .â–ˇ= y becomes x .= x .â–ˇ y  (in-place mutation)
3 Likes

Well that’s embarrassing! Hah. Got myself thinking the wrong way around — and that’s at the crux of this very thread! Thanks :slight_smile:

2 Likes

It’s worth pointing out that enforcing invariants, the sort of instantiation conditions like 0 ≤ x ≤ 1, are done in inner constructors because invariants by definition happen all the time; outer constructors have to call inner constructors but not vice versa, so naturally inner constructors would cover what happens all the time. If for whatever reason you only want to check for the condition in an outer constructor or other method, then you could do that.

Also just remembered, generally don’t use @assert for invariants, unless you’re limiting it to internal code that is rigorously tested to not need it in routine usage. The docs warn that “an assert might be disabled at some optimization levels. Assert should therefore only be used as a debugging tool and not used for authentication verification…” Granted, @assert currently isn’t turned off at any optimization levels, but that’s a pretty explicit warning about not guaranteeing this in updates. Save yourself the annoying patch in the future, enforce invariants by throwing other errors. In this case the unit interval restriction could potentially use the more specific DomainError, but if not, the documentation on invariants use the convenience function error for throwing the most generic ErrorException.

1 Like