Bypass inner constructor / call `new` outside inner constructor

I have defined a struct with an inner constructor that enforces some invariants,
similar to the example in the Documentation

julia> struct OrderedPair
           x::Real
           y::Real
           OrderedPair(x,y) = x > y ? error("out of order") : new(x,y)
       end

and was wondering if there is a way to bypass the inner constructor and call new directly when I am absolutely sure that the invariants are satisfied. E.g. to define copy I can do the following

julia> Base.copy(op::OrderedPair) = OrderedPair(op.x, op.y)

but now the inner constructor is called again and op.x > op.y tested again, even though we can be sure this is the case. Is there way to do e.g.

julia> Base.copy(op::OrderedPair) = new(OrderedPair, op.x, op.y)

that directly constructs the struct without going through the inner constructor?

One way would be just to add another inner constructor.

struct OrderedPair
  x::Real
  y::Real
  OrderedPair(x,y) = x > y ? error("out of order") : new(x,y)
  OrderedPair(op::OrderedPair) = new(copy(op.x), copy(op.y))
end

However, this generally won’t do what you want because since this is immutable and most Real types are also immutable, it doesn’t actually make a copy but just returns the same one.

For example, for all (?) the numeric types in Base, b === copy(b).

(Also, if performance is at all a consideration this really should be a parametric type)

3 Likes

This was just an example (in fact the one from the docs) to demonstrate what I want to do. In the actual use case the fields of the structs are arrays (hence mutable) and I am using parametric types. But adding a second inner constructor gave me an idea how to do it:

struct OrderedPair
    x::Real
    y::Real
    OrderedPair(x, y, safe::Val{True}) = new(op.x, op.y)
    OrderedPair(x, y, safe::Val{False}) = x > y ? error("out of order") : new(x,y)
    OrderdPair(x, y) = OrderedPair(x, y, Val(false))  # could probably also have this as an outer constructor
end

and now I can do

Base.copy(op::OrderedPair) = OrderedPair(op.x, op.y, Val(true))

to achieve what I wanted.

2 Likes

Another option is to use reinterpret. I like this method because it emphasizes the danger of bypassing the inner constructor checks:

struct OrderedPair{T <: Real}
    x::T
    y::T
    OrderedPair(x, y) = x > y ? error("out of order") : 
      new{promote_type(typeof(x),typeof(y))}(x,y)
end

with this definition, the normal construction works as expected:

julia> OrderedPair(1,2)
OrderedPair{Int64}(1, 2)

julia> OrderedPair(1,2.2)
OrderedPair{Float64}(1.0, 2.2)

julia> OrderedPair(2,1)
ERROR: out of order

And the unsafe construction can be done as follows:

julia> reinterpret(OrderedPair{Int}, [2,1])[1]
OrderedPair{Int64}(2, 1)
2 Likes

I’ve started to define an internal constructor that is “unsafe” any time I have a new type defined.

struct OrderedPair{T <: Real}
    x::T
    y::T

    global unsafe_oredered_pair(x::T, y::T) = new{T}(x, y)
end
function OrderedPair(x::T, y::T) where {T}
    x > y && error("out of order")
    unsafe_oredered_pair(x, y)
end
OrderedPair(x, y) = OrderedPair(promote(x, y)...)

This way you can freely skip checking conditions when you know they’ve been met elsewhere in your code without moving everything to an inner constructor. You can name the inner constructor whatever you want but the naming convention ensures downstream users don’t have any excuses for inappropriately bypassing checks.

4 Likes

Oh, that seems even better than my solution!

If you define it within in the struct, you are allowed to use new even in things that are not constructors
e.g.

julia> struct OrderedPair
          x::Real
          y::Real
          OrderedPair(x,y) = x > y ? error("out of order") : new(x,y)
          
          Base.copy(op::OrderedPair) = new(op.x, op.y)
       end

julia> op1 = OrderedPair(10, 20)
OrderedPair(10, 20)

julia> copy(op1)
OrderedPair(10, 20)

julia> @which copy(op1)
copy(op::OrderedPair)
     @ Main REPL[1]:6

Same principle as what @Zach_Christensen did.
But forcing it to be kept near the defintion so noone can just call things.

5 Likes

Significantly more evily you can directly generate calls to Expr(:new, T, args...) and to Expr{:splatnew, T, args) e.g. with generated functions or other metaprogramming.

julia> struct OrderedPair
          x::Real
          y::Real
          OrderedPair(x,y) = x > y ? error("out of order") : new(x,y)
       end

julia> @generated direct_new(B::Type, args...) = Expr(:splatnew, :B, :args)
direct_new (generic function with 1 method)

julia> Base.copy(op::OrderedPair) = direct_new(OrderedPair, op.x, op.y)

julia> op1 = OrderedPair(10, 20)
OrderedPair(10, 20)

julia> copy(op1)
OrderedPair(10, 20)

julia> @which copy(op1)
copy(op::OrderedPair)
     @ Main REPL[3]:1

Should you?
idk, I am not your mum.

2 Likes

I’m not seeing the problem here. For immutable number types, the desired behavior should be achieved both with and without the inner copy.

I’m not seeing the problem here

Not a problem, per se, but just that in the situation as posed in the first post there’s no need for bypassing the inner constructor as you could just define copy(op::OrderedPair) = op and move on.

It was only in the next post that it was clarified that the actual use case was more complex.

1 Like

Minor stylistic point, but you’re using the Val here as a trait (sort of, you’re using it for compile-time dispatch on a type), so it should probably come first in order:

struct OrderedPair
    x::Real
    y::Real
    OrderedPair(::Val{True}, x, y) = new(op.x, op.y)
    OrderedPair(::Val{False}, x, y) = x > y ? error("out of order") : new(x,y)
    OrderdPair(x, y) = OrderedPair(Val(false), x, y)  # could probably also have this as an outer constructor
end

This also shows the convention that arguments which aren’t directly referenced because they’re used for their type don’t receive names.

Nothing bad will happen the way you wrote it, of course, this is just a matter of idiom.

2 Likes