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.
-
It is declared inside the type declaration block, not outside it, like regular methods.
-
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)