Engee documentation

Constructors

Constructors [1] — these are functions that create new objects, in particular instances composite types. In Julia, type objects also serve as constructor functions: when applied to a tuple of arguments as a function, they create instances of themselves. This was already mentioned briefly when we first got acquainted with composite types. For example:

julia> struct Foo
           bar
           baz
       end

julia> foo = Foo(1, 2)
Foo(1, 2)

julia> foo.bar
1

julia> foo.baz
2

Binding field values is sufficient to create instances of many types. However, in some cases, additional features are required when creating composite objects. Sometimes it is necessary to apply invariants by checking or converting arguments. Recursive data structures, especially those that can be self-referential, often cannot be created directly: they must first be created in an incomplete state, and then programmatically modified in a separate action to bring them into a complete state. Sometimes it’s just convenient to create objects without specifying all fields or using other types. Julia’s object creation system supports all of these and other scenarios.

External constructor methods

The constructor is similar to any other function in Julia in that its behavior is generally determined by the cumulative behavior of its methods. Therefore, to extend the functionality of the constructor, it is enough to define new methods. Let’s say you want to add a constructor method for Foo objects that takes only one argument and uses the provided value for both the bar field and the baz field. It’s not difficult to do this.

julia> Foo(x) = Foo(x,x)
Foo

julia> Foo(1)
Foo(1, 1)

You can also add a constructor method without arguments for Foo, which provides default values for the bar and baz fields.:

julia> Foo() = Foo(0)
Foo

julia> Foo()
Foo(0, 0)

This method of the constructor with no arguments calls the method of the constructor with one argument, which, in turn, calls the automatically provided method with two arguments. For reasons that will soon become clear, the additional constructor methods declared as regular methods (as in this case) are called external_. External constructor methods can create an instance only by calling another constructor method, such as one provided automatically.

Internal methods of the constructor

Although the external methods of the constructor successfully cope with the task of providing additional convenience when creating objects, they do not allow solving the other two tasks mentioned in the introductory part of this chapter: applying invariants and creating self-related objects. To implement these tasks, you need the internal methods of the constructor. The internal method of the constructor is similar to the external one, but it has two differences.

  1. It is declared inside the type declaration block, not outside it, like regular methods.

  2. It has access to a special local function named new, which creates objects of the type specified in the block.

Let’s say you want to declare a type that contains a pair of real numbers, but with the restriction that the first number is not greater than the second. It can be done like this:

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

Now, when creating OrderedPair objects, the condition `x <=y' must be met.:

julia> OrderedPair(1, 2)
OrderedPair(1, 2)

julia> OrderedPair(2,1)
ERROR: out of order
Stacktrace:
 [1] error at ./error.jl:33 [inlined]
 [2] OrderedPair(::Int64, ::Int64) at ./none:4
 [3] top-level scope

If the type was declared mutable, you could directly change the values of the fields to break this invariant. But, of course, it is not recommended to interfere with the internal structure of the object. You (or someone else) can define additional external constructor methods later, but after declaring the type, you can no longer add internal constructor methods. Since external constructor methods can create objects only by calling other constructor methods, some internal constructor method must eventually be called to create an object. Thus, it is guaranteed that any object of the declared type can be created only as a result of calling one of the internal methods of the type constructor, which ensures the application of type invariants to one degree or another.

If at least one internal constructor method is defined, the default constructor method is not provided: it is assumed that you have provided all the necessary functionality. The default constructor is similar to the constructor’s own internal method, which takes as parameters all fields of an object (necessarily of the correct type if the corresponding field has a type), passes them to the new function and returns the resulting object.:

julia> struct Foo
           bar
           baz
           Foo(bar,baz) = new(bar,baz)
       end

This declaration is equivalent to the above definition of the type Foo without an explicit internal constructor method. The following two types — with a default constructor and an explicitly defined constructor — are equivalent:

julia> struct T1
           x::Int64
       end

julia> struct T2
           x::Int64
           T2(x) = new(x)
       end

julia> T1(1)
T1(1)

julia> T2(1)
T2(1)

julia> T1(1.0)
T1(1)

julia> T2(1.0)
T2(1)

It is recommended to provide as few internal constructor methods as possible, namely, only those that accept all arguments explicitly and perform the necessary checks for errors and transformations. Auxiliary constructor methods that provide default values or perform additional transformations should be implemented as external constructors that call internal constructors to do the main work. Such a division is usually quite natural.

Incomplete initialization

The last task that remains to be solved is the creation of self-referential objects or, more generally, recursive data structures. Since the main difficulty may not be obvious, let’s figure it out a bit. Let’s take the following recursive type update.

julia> mutable struct SelfReferential
           obj::SelfReferential
       end

This type may seem unremarkable until it comes to creating an instance of it. If a is an instance of SelfReferential, then a second instance can be created using the following call:

julia> b = SelfReferential(a)

But how can I create the first instance if there is not yet an instance that can be passed as the value of the obj field? The only solution is to allow the creation of an incompletely initialized instance of SelfReferential with an unspecified obj field and use this incomplete instance as the value of the obj field of another or the same instance, i.e. itself.

To create objects that are not fully initialized, Julia supports calling the function new with fewer fields than the type has. For the returned object, the unset fields will not be initialized. The internal methods of the constructor can then use this incomplete object to complete its initialization before returning. For example, here is another attempt to define the type SelfReferential, this time with an internal constructor without arguments, which returns an instance with the obj field pointing to itself.:

julia> mutable struct SelfReferential
           obj::SelfReferential
           SelfReferential() = (x = new(); x.obj = x)
       end

We can make sure that this constructor works and creates objects that are self-referential.:

julia> x = SelfReferential();

julia> x === x
true

julia> x === x.obj
true

julia> x === x.obj.obj
true

Although it is usually desirable to return a fully initialized object from the internal constructor, it is possible to return objects that are not fully initialized.:

julia> mutable struct Incomplete
           data
           Incomplete() = new()
       end

julia> z = Incomplete();

Although you can create objects with uninitialized fields, attempting to access an uninitialized reference will immediately cause an error.:

julia> z.data
ERROR: UndefRefError: access to undefined reference

This eliminates the need to constantly check for null values. However, not all fields of objects are links. Some types in Julia are considered "simple". This means that they contain independent data and do not reference other objects. Simple data types include primitive types (for example, Int) and immutable structures consisting of other simple types (see also: isbits, isbitstype). Initially, the contents of a simple data type are undefined:

julia> struct HasPlain
           n::Int
           HasPlain() = new()
       end

julia> HasPlain()
HasPlain(438103441441)

Arrays of simple data types behave the same way.

You can pass incomplete objects from internal constructors to other functions that should complete their creation.:

julia> mutable struct Lazy
           data
           Lazy(v) = complete_me(new(), v)
       end

Just as in the case of incomplete objects returned by constructors, if the function "complete_me" or any of the functions it calls tries to access the not-yet-initialized "data" field of the "Lazy" object, an error will immediately occur.

Parametric constructors

Parametric types add a number of nuances to the design scheme. As you remember from the chapter Parametric types, by default, instances of parametric composite types can be created either with explicitly specified type parameters, or with type parameters that are derived from the types of arguments passed to the constructor. Here are some examples:

julia> struct Point{T<:Real}
           x::T
           y::T
       end

julia> Point(1,2) ## неявный тип T ##
Point{Int64}(1, 2)

julia> Point(1.0,2.5) ## неявный тип T ##
Point{Float64}(1.0, 2.5)

julia> Point(1,2.5) ## неявный тип T ##
ERROR: MethodError: no method matching Point(::Int64, ::Float64)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  Point(::T, ::T) where T<:Real at none:2

julia> Point{Int64}(1, 2) ## явный тип T ##
Point{Int64}(1, 2)

julia> Point{Int64}(1.0,2.5) ## явный тип T ##
ERROR: InexactError: Int64(2.5)
Stacktrace:
[...]

julia> Point{Float64}(1.0, 2.5) ## явный тип T ##
Point{Float64}(1.0, 2.5)

julia> Point{Float64}(1,2) ## явный тип T ##
Point{Float64}(1.0, 2.0)

As you can see, when calling constructors with explicitly specified type parameters, the arguments are converted to the implied field types: The Point' call.{Int64}(1,2) works, but +Point{Int64}(1.0,2.5)+`when converting the value `2.5 in type Int64 will cause an error InexactError. If the type is derived from the arguments of the constructor call, as in Point(1,2), the types of the arguments must be consistent, otherwise the type T cannot be determined. However, any pair of real arguments of the appropriate type can be passed to the universal constructor Point.

The reason is that Point, Point{Float64} and Point{Int64} are different constructor functions. Actually Point{T} is a separate constructor function for each type of T'. If the internal constructors are not explicitly defined, when declaring the composite type `Point{T<:Real}`internal constructor '+Point{T}+ is provided automatically for every possible type of T<:Real. It works the same way as the default nonparametric internal constructor. In addition, one common external constructor Point is provided, which accepts a pair of real arguments that must be of the same type. These automatically provided constructors are equivalent to the following declaration.

julia> struct Point{T<:Real}
           x::T
           y::T
           Point{T}(x,y) where {T<:Real} = new(x,y)
       end

julia> Point(x::T, y::T) where {T<:Real} = Point{T}(x,y);

Note that each definition looks like a constructor call, which it processes. Calling Point{Int64}(1,2) is related to the definition of Point{T}(x,y) inside the struct block. Declaring an external constructor, on the other hand, defines a method for a generic Point constructor that applies only to pairs of values of the same real type. This declaration is responsible for making constructor calls without explicitly specified type parameters, such as Point(1,2) or Point(1.0,2.5)'. Since, according to the method declaration, the arguments must be of the same type, calls with arguments of different types, such as `Point(1,2.5), will result in the error "method missing".

Suppose we want the constructor call Point(1,2.5) to be performed by "advancing" the integer value 1 to the floating-point value `1.0'. The easiest way is to define the following additional external constructor method.

julia> Point(x::Int64, y::Float64) = Point(convert(Float64,x),y);

This method uses the function convert to explicitly convert x to type Float64, and then transfers the further work of creating an instance to a common constructor designed for cases where both arguments are of type Float64. With this method definition, where an error was caused before MethodError, a point of type Point' is now being successfully created{Float64}:

julia> p = Point(1,2.5)
Point{Float64}(1.0, 2.5)

julia> typeof(p)
Point{Float64}

However, other similar calls still don’t work.:

julia> Point(1.5,2)
ERROR: MethodError: no method matching Point(::Float64, ::Int64)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  Point(::T, !Matched::T) where T<:Real
   @ Main none:1
  Point(!Matched::Int64, !Matched::Float64)
   @ Main none:1

Stacktrace:
[...]

For a more universal way to ensure that all such calls are executed properly, see the chapter Transformation and promotion. Let’s reveal the intrigue a bit: in order for all calls to the common constructor Point to be performed properly, the following definition of an external method is sufficient:

julia> Point(x::Real, y::Real) = Point(promote(x,y)...);

The promote function converts all arguments to a common type, in this case Float64. With this method definition, the Point constructor advances its arguments in the same way as numeric operators do, for example +, and works with all types of real numbers:

julia> Point(1.5,2)
Point{Float64}(1.5, 2.0)

julia> Point(1,1//2)
Point{Rational{Int64}}(1//1, 1//2)

julia> Point(1.0,1//2)
Point{Float64}(1.0, 0.5)

Thus, although the constructors with implicit type parameters provided in Julia by default are quite strict, they can easily be redefined so that they work more flexibly, but within reasonable limits. Moreover, since designers can use all the features of the type system, methods, and multiple dispatching, it is usually quite simple to define a complex behavior pattern.

Example: rational numbers

To put all these fragments together, it might be best to take a real example of a parametric composite type and its constructor methods. To do this, we implement our own type of rational numbers, OurRational, similar to the built-in Julia type. Rational, which is defined in https://github.com/JuliaLang/julia/blob/master/base/rational.jl [rational.jl]:

julia> struct OurRational{T<:Integer} <: Real
           num::T
           den::T
           function OurRational{T}(num::T, den::T) where T<:Integer
               if num == 0 && den == 0
                    error("invalid rational: 0//0")
               end
               num = flipsign(num, den)
               den = flipsign(den, den)
               g = gcd(num, den)
               num = div(num, g)
               den = div(den, g)
               new(num, den)
           end
       end

julia> OurRational(n::T, d::T) where {T<:Integer} = OurRational{T}(n,d)
OurRational

julia> OurRational(n::Integer, d::Integer) = OurRational(promote(n,d)...)
OurRational

julia> OurRational(n::Integer) = OurRational(n,one(n))
OurRational

julia> ⊘(n::Integer, d::Integer) = OurRational(n,d)
⊘ (generic function with 1 method)

julia> ⊘(x::OurRational, y::Integer) = x.num ⊘ (x.den*y)
⊘ (generic function with 2 methods)

julia> ⊘(x::Integer, y::OurRational) = (x*y.den) ⊘ y.num
⊘ (generic function with 3 methods)

julia> ⊘(x::Complex, y::Real) = complex(real(x) ⊘ y, imag(x) ⊘ y)
⊘ (generic function with 4 methods)

julia> ⊘(x::Real, y::Complex) = (x*y') ⊘ real(y*y')
⊘ (generic function with 5 methods)

julia> function ⊘(x::Complex, y::Complex)
           xy = x*y'
           yy = real(y*y')
           complex(real(xy) ⊘ yy, imag(xy) ⊘ yy)
       end
⊘ (generic function with 6 methods)

In the first line — struct OurRational{T<:Integer} <: Real — it is declared that the type OurRational accepts a single parameter of an integer type, and itself is a real type. In the declarations of the fields num::T and den::T it is reported that in the object OurRational{T} stores a pair of integers of type T, one of which represents the numerator of a rational value, and the other represents the denominator.

It gets more interesting. The OurRational type has a single internal constructor method that ensures that the values of num and den are not zero at the same time and that any rational number is reduced to an irreducible form with a non-negative denominator. To do this, first the signs of the numerator and the denominator are reversed if the denominator is negative. The numerator and denominator are then divided by the largest common factor (gcd always returns a non-negative number, regardless of the sign of the arguments). Since this is the only internal constructor of the OurRational type, we can be sure that 'OurRational` objects will always be created in this normalized form.

For convenience, the OurRational' type also provides a number of external constructor methods. The first one is a "standard" generic constructor that outputs a parameter of type `T from the type of the numerator and denominator, if they are of the same type. The second one is used when the numerator and denominator values belong to different types: it promotes them to a common type, and then delegates creation to an external constructor dedicated to arguments of the appropriate type. The third external constructor converts integer values to rational numbers using the value 1 as the denominator.

According to the definitions of the external constructors, we have defined a number of methods for the operator , which provides a syntax for writing rational numbers (for example, 1 ⊘ 2). The Julia Rational type uses the operator for this purpose //. Before these definitions were introduced, was a completely indefinite operator that had syntax but no meaning. After their introduction, it starts working as described in the section Rational numbers — all its behavior is defined in these few lines. Note that the infix use of works because Julia has a set of characters that are recognized as infix operators. The first and most basic definition allows you to create an instance of OurRational using the expression a ⊘ b. To do this, the OurRational constructor is applied to a and b if they are integers. If one of the operands of is already a rational number, a new rational number for this fraction is created a little differently: in fact, this operation is identical to dividing a rational number by an integer. Finally, when applying the operator to complex integers, an instance of Complex' is created.{<:OurRational} is a complex number whose real and imaginary parts are rational:

julia> z = (1 + 2im) ⊘ (1 - 2im);

julia> typeof(z)
Complex{OurRational{Int64}}

julia> typeof(z) <: Complex{<:OurRational}
true

Thus, the operator usually returns an instance of the type OurRational, but if any of its arguments is a complex integer, an instance of Complex' is returned.{<:OurRational}. We recommend that the curious reader study the module to the end. https://github.com/JuliaLang/julia/blob/master/base/rational.jl [rational.jl]: It is quite concise, isolated, and contains a complete implementation of the Julia base type.

Completely external constructors

As you already know, the standard parametric type has internal constructors that are called when the type parameters are known, that is, they are applied to Point{Int}, but not to Point'. If necessary, you can add external constructors that define type parameters automatically, for example, by creating a `+Point'.{Int}+ when calling `Point(1,2)'. To create instances, external constructors call internal ones. However, in some cases, it is undesirable to provide internal constructors so that certain type parameters cannot be requested manually.

Let’s say we define the type in which the vector is stored along with the exact representation of its sum.:

julia> struct SummedArray{T<:Number,S<:Number}
           data::Vector{T}
           sum::S
       end

julia> SummedArray(Int32[1; 2; 3], Int32(6))
SummedArray{Int32, Int32}(Int32[1, 2, 3], 6)

The problem is that the type S must be larger than T' so that many elements can be summed without much loss of precision. For example, if `T is a type Int32, type S should be Int64. Therefore, we need to prevent the user from creating instances of the SummedArray' type.{Int32,Int32}. One way is to provide a constructor only for SummedArray, and prohibit the use of default constructors inside the struct definition block.:

julia> struct SummedArray{T<:Number,S<:Number}
           data::Vector{T}
           sum::S
           function SummedArray(a::Vector{T}) where T
               S = widen(T)
               new{T,S}(a, sum(S, a))
           end
       end

julia> SummedArray(Int32[1; 2; 3], Int32(6))
ERROR: MethodError: no method matching SummedArray(::Vector{Int32}, ::Int32)
The type `SummedArray` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  SummedArray(::Vector{T}) where T
   @ Main none:4

Stacktrace:
[...]

This constructor will be called when using the syntax SummedArray(a). The syntax is new{T,S} allows you to specify parameters for the type being created, meaning this call will return SummedArray{T,S}. new{T,S}`can be used in any constructor definition, but for convenience, the `new{} parameters are automatically inherited from the type being created, if possible.

Constructors are just callable objects.

An object of any type can be make it callable by defining a method. This includes types, that is, objects of the type Type. And constructors, in fact, can be considered as just callable objects of the type. For example, there are many methods defined for Bool and its various supertypes.:

julia> methods(Bool)
# 10 методов для конструктора типов:
  [1] Bool(x::BigFloat)
     @ Base.MPFR mpfr.jl:393
  [2] Bool(x::Float16)
     @ Base float.jl:338
  [3] Bool(x::Rational)
     @ Base rational.jl:138
  [4] Bool(x::Real)
     @ Base float.jl:233
  [5] (dt::Type{<:Integer})(ip::Sockets.IPAddr)
     @ Sockets ~/tmp/jl/jl/julia-nightly-assert/share/julia/stdlib/v1.11/Sockets/src/IPAddr.jl:11
  [6] (::Type{T})(x::Enum{T2}) where {T<:Integer, T2<:Integer}
     @ Base.Enums Enums.jl:19
  [7] (::Type{T})(z::Complex) where T<:Real
     @ Base complex.jl:44
  [8] (::Type{T})(x::Base.TwicePrecision) where T<:Number
     @ Base twiceprecision.jl:265
  [9] (::Type{T})(x::T) where T<:Number
     @ boot.jl:894
 [10] (::Type{T})(x::AbstractChar) where T<:Union{AbstractChar, Number}
     @ char.jl:50

The usual syntax of a constructor is the exact equivalent of that of a function-like object, so trying to define a method with each syntax will result in the first method being overwritten by the next.:

julia> struct S
           f::Int
       end

julia> S() = S(7)
S

julia> (::Type{S})() = S(8)  # переопределяет предыдущий метод конструктора

julia> S()
S(8)

1. Although the term "constructor" usually means the entire function that creates objects of some type, often this strict understanding of the term is slightly deviated from and refers to specific constructor methods. In such cases, it is usually obvious from the context that the constructor method is meant, and not the constructor function, especially when a certain constructor method is highlighted in contrast to others.