Defining an outer constructor leads to infinite recursion

This question really has two parts

  • I have observed that defining an outer constructor leads to infinite recursion

I understand why this is happening, but I do not know if I am doing something obviously stupid, which could be avoided to prevent this recursion from taking place.

Additionally

  • Does this observation mean that inner constructors should always be preferred to outer constructors?

This actually leads to a second question which I think it makes more sense to ask separately. I will probably pick this up tomorrow.


This is my observation.

I tried to define an outer constructor for an example type which I found in the documentation.

struct OtherOrderedPair
        x::Real
        y::Real
end

function OtherOrderedPair(x::Real, y::Real)
        if x > y
                error("bad order 2")
        else
                OtherOrderedPair(x, y)
        end
end

From the REPL:

julia> import("constructors.jl")
OtherOrderedPair # !!! why did it print this?

julia>OtherOrderedPair(1, 2) # correct order
ERROR: StackOverflowError:
    ...

Why this happens is (relatively) obvious. By default, the type OrderOrderedPair defines a default inner constructor. It looks like this:

struct OtherOrderedPair
        x::Real
        y::Real
        OtherOrderedPair(x::Real, y::Real) new(x, y)
end

This inner constructor is still provided by default even if an outer constructor is defined. From the docs,

If any inner constructor method is defined, no default constructor method is provided

However, when the outer constructor calls OtherOrderedPair the multimethod resolution system (not sure what it is called) calls the same function again, because both the inner and outer constructor functions bind with equal precidence based on the types of their arguments alone.

It makes sense that the same function is called again rather than a different one. (Consider the case of three identical function signatures. If the same function was not called again there would be no way to figure out which of the remaining two should be called.)

So, some questions:

  • Am I doing anything obviously stupid here which causes this recursion to take place when it need not?
  • Is there a way to disambiguate between the two constructors, and break the infinite recursion?
  • Is the observation here a reason to always prefer to define inner constructors rather than outer constructors?

Relating to this final point, the docs also say this

It is good practice to provide as few inner constructor methods as possible: only those taking all arguments explicitly and enforcing essential error checking and transformation. Additional convenience constructor methods, supplying default values or auxiliary transformations, should be provided as outer constructors that call the inner constructors to do the heavy lifting. This separation is typically quite natural.

but there is no explanation provided as to why this is the case .

Defining the inner constructor is necessary exactly to replace the default inner constructor, when the argument types are identical. For other argument types, outer constructors are usually preferred.

3 Likes

In fact you redefine one of the default inner constructors:

julia> struct OtherOrderedPair
               x::Real
               y::Real
       end

julia> methods(OtherOrderedPair)
# 2 methods for type constructor:
 [1] OtherOrderedPair(x::Real, y::Real)
     @ REPL[1]:2
 [2] OtherOrderedPair(x, y)
     @ REPL[1]:2

When you define the outer constructor, it replaces the first inner constructor (you can tell from the “@ REPL[3]” line, which replaces “@ REPL[1]”:

julia> function OtherOrderedPair(x::Real, y::Real)
               if x > y
                       error("bad order 2")
               else
                       OtherOrderedPair(x, y)
               end
       end
OtherOrderedPair

julia> methods(OtherOrderedPair)
# 2 methods for type constructor:
 [1] OtherOrderedPair(x::Real, y::Real)
     @ REPL[3]:1
 [2] OtherOrderedPair(x, y)
     @ REPL[1]:2

So there is no other function to call (the “Real” constructor is more specific than the other constructor), you have created a recursive outer constructor.

Inner constructors should be used to enforce constraints on the fields, they are the only ways to actually construct the object. The manual states that

It is good practice to provide as few inner constructor methods as possible: only those taking all arguments explicitly and enforcing essential error checking and transformation. Additional convenience constructor methods, supplying default values or auxiliary transformations, should be provided as outer constructors that call the inner constructors to do the heavy lifting.

2 Likes

The distinction between outer and inner constructors is that inner constructors are defined inside the scope of the type definition, giving them access to the type’s new (and technically local variables, but those are almost never used in type definitions). new is the actual direct instantiation function that puts all the fields together, and it’s heavily recommended that you do any fundamental processing from the inputs right before new within the same inner constructors. Since you need to call new at some point, that means outer constructors need to call inner constructors, and since they share the type’s method table, you need to be careful that you don’t overwrite inner constructors with outer ones.

A sensible type doesn’t have 15 different fundamental constraints depending on the inputs, and you don’t want to duplicate the same constraints code across 15 different inner constructor methods. Instead, put the fundamental processing into an inner constructor with specific input types, then let the outer constructors process a wider variety of input types to those specific types before calling the inner constructor. For example, say you have a composite type representing a file format’s header. Your inner constructor might take the most basic strings and numbers and check that they’re a valid combination before new glues them together into an instance. The outer constructors could be processing a file path or an open file object to the strings and numbers for the inner constructor.

It’s worth pointing out that since it’s all in the same method table, you could technically move all outer constructors into the type definition so they become inner constructors that don’t call new. You just don’t want to do that in practice because it’s much easier to add or interactively overwrite outer constructors in the global scope. To add or overwrite inner constructors after the definition, you have to copy and paste the entire type definition with the same fields, and that’s too unwieldy for manual interactive usage.

1 Like

As explained by @lmiq ,@sgaure and @Benny you should probably just create an inner constructor here, but to directly answer this question, you could invoke the Tuple{Any, Any} constructor:

struct OtherOrderedPair
    x::Real
    y::Real
end

function OtherOrderedPair(x::Real, y::Real)
    if x > y
        error("bad order 2")
    else
        invoke(OtherOrderedPair, Tuple{Any, Any}, x, y)
    end
end
julia> OtherOrderedPair(2, 1)
ERROR: bad order 2
Stacktrace:
 [1] error(s::String)
   @ Base .\error.jl:35
 [2] OtherOrderedPair(x::Int64, y::Int64)
   @ Main .\REPL[2]:3
 [3] top-level scope
   @ REPL[4]:1

julia> OtherOrderedPair(1, 2)
OtherOrderedPair(1, 2)
1 Like

It’s also possible to call the definition from an older world, though it’s undocumented:

struct OtherOrderedPair
    x::Real
    y::Real
end

const world = Base.get_world_counter()

function OtherOrderedPair(x::Real, y::Real)
    if x > y
        error("bad order 2")
    else
        Base.invoke_in_world(world, OtherOrderedPair, x, y)
    end
end

julia> OtherOrderedPair(1,2)
OtherOrderedPair(1, 2)

1 Like

I am surprised to see this. Two methods, not a single one? Why is that the case? I thought Julia by default will only add one default constructor.

Two constructors are generated automatically (these are called default constructors). One accepts any arguments and calls convert to convert them to the types of the fields, and the other accepts arguments that match the field types exactly. The reason both of these are generated is that this makes it easier to add new definitions without inadvertently replacing a default constructor.
Types · The Julia Language

I don’t quite understand the reasoning, but that’s what the docs say.

1 Like

That’s a good point too - another factor to consider.

This suggests to me that inner or outer, it makes no difference to the JIT compiler. The executable code produced at the end is the same. I guess that’s kind of obvious when you think about it. Another useful thing to keep in mind though.

My mistake - I must have missed this in the docs. I recall reading somewhere that only a single constructor is created. Perhaps what was meant was one function not necessarily one method.

I guess because the docs says something along these lines

  • if you define your own inner constructor, then the default one will not be provided

That implies there is one constructor. But again, it could mean one function, not one method.

There’s also this:

Only one default constructor is generated for parametric types, since overriding it is not possible. This constructor accepts any arguments and converts them to the field types.
Types · The Julia Language

I get one function with one method if I overwrite the inner constructor (meaning, define it inside the struct ... end block with a new.

julia> struct A
       x::Bool
       A(x) = new(x)
       end

julia> methods(A)
# 1 method for type constructor:
 [1] A(x)
     @ REPL[1]:3

I think this is because the default constructor provides two methods. One which takes Any and does a convert, and the other with the same types as the types specified in the struct. (In your case, Bool.)

I could be mistaken however.

You can get even zero methods by just putting any code inside the struct:

julia> struct T
       x::Bool
       2 + 2
       end

julia> methods(T)
# 0 methods for type constructor

julia> T(1)
ERROR: MethodError: no method matching T(::Int64)
The type `T` exists, but no method is defined for this combination of argument types when trying to construct it.
Stacktrace:
 [1] top-level scope
   @ REPL[43]:1

julia> T(true)
ERROR: MethodError: no method matching T(::Bool)
The type `T` exists, but no method is defined for this combination of argument types when trying to construct it.
Stacktrace:
 [1] top-level scope
   @ REPL[44]:1
1 Like

Just found this in the docs after re-reading some pages for a separate issue.

Two constructors are generated automatically (these are called default constructors ). One accepts any arguments and calls convert to convert them to the types of the fields, and the other accepts arguments that match the field types exactly. The reason both of these are generated is that this makes it easier to add new definitions without inadvertently replacing a default constructor.