Engee documentation

Transformation and promotion

Julia has a system for advancing arguments of mathematical operators to a general type, which has been mentioned in other sections, including Integers and floating point numbers, Mathematical operations and elementary functions, Types and Methods. This section explains how this promotion system works, how to extend it to new types and apply it not only to mathematical operators, but also to other functions. In terms of promoting arithmetic arguments, programming languages are traditionally divided into two categories.

  • Automatic promotion of built-in arithmetic types and operators. In most languages, when using built-in numeric types as operands of infix-syntax arithmetic operators, such as +, -, * and /, they are automatically promoted to the general type to get the expected result. In C, Java, Perl, Python, and many other languages, the result of adding 1 + 1.5 will be a floating-point value of 2.5, although one of the operands of + is an integer. These systems are convenient and well thought out, so they are usually almost invisible to the programmer: few people think about promotion when writing such expressions. However, compilers and interpreters need to perform the conversion before adding, since integer and floating-point values cannot be added by themselves. Therefore, complex rules for automatic type conversions are inevitably part of the specification and implementation of such languages.

  • Lack of automatic promotion. Ada and ML languages with very strict static typing belong to this category. In these languages, any transformation must be explicitly defined by the programmer. Therefore, an error will occur in both Ada and ML when compiling the example expression 1 + 1.5'. Instead, use the expression `real(1) + 1.5, explicitly converting the integer value 1 to a floating-point value before addition. However, it is so inconvenient to constantly perform explicit conversions that even in Hell automatic conversion is implemented to some extent: integer literals are automatically promoted to the expected integer type, and floating-point literals to the corresponding floating-point types.

In a sense, Julia belongs to the category of languages without automatic promotion: mathematical operators are just functions with a special syntax, and function arguments are never converted automatically. However, it can be noted that applying mathematical operations to combinations of arguments of various types is an extreme case of polymorphic multiple dispatch, and this is exactly what Julia’s type and dispatch systems are very well suited for. Automatic promotion of mathematical operands turns out to be just a special case of application: Julia pre-defines universal dispatch rules for mathematical operations that are called when there is no specific implementation for a certain combination of operand types. According to these universal rules, all operands are first promoted to a common type, taking into account the promotion rules defined by the user, and then a specialized implementation of the operator is called for the received values, which are now of the same type. Custom types can be easily integrated into this promotion system by defining methods for converting them to other types and vice versa and setting promotion rules that determine which types they should be promoted to when used in combination with other types.

Transformation

The standard way to get a value of a certain type T is to call the constructor of this type T(x). However, in some cases it is more convenient to convert values from one type to another without an explicit request from the programmer. An example is assigning a value to an array element: if A is an object Vector{Float64}, the expression A[1] = 2 should automatically convert the value 2 from the type Int to Float64 and save the result in an array. This is done by the function convert.

The convert function generally takes two arguments: the first is an object of the type, and the second is the value to be converted to that type. The value converted to an instance of the specified type is returned. The easiest way to understand how this function works is by using examples.

julia> x = 12
12

julia> typeof(x)
Int64

julia> xu = convert(UInt8, x)
0x0c

julia> typeof(xu)
UInt8

julia> xf = convert(AbstractFloat, x)
12.0

julia> typeof(xf)
Float64

julia> a = Any[1 2 3; 4 5 6]
2×3 Matrix{Any}:
 1  2  3
 4  5  6

julia> convert(Array{Float64}, a)
2×3 Matrix{Float64}:
 1.0  2.0  3.0
 4.0  5.0  6.0

Conversion is not always possible. In this case, an exception occurs. MethodError with the message that `convert' does not support the requested conversion.

julia> convert(AbstractFloat, "foo")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type AbstractFloat
[...]

In some languages, analyzing strings as numeric values or formatting numbers as strings is considered a conversion (moreover, in many languages these conversions are performed automatically). This is not the case in Julia. Although some strings can be analyzed as numbers, this applies only to a small subset of them: most strings are not valid representations of numbers. Therefore, in Julia, a special function should be used to perform this operation. parse, which makes the operation more explicit.

When is convert called?

The convert function is called by the following language constructs.

  • When assigning a value to an array, it is converted to the type of array elements.

  • When assigning a value to an object field, it is converted to the declared field type.

  • When creating an object using new is being converted to the declared object field types.

  • When assigning a value to a variable with a declared type (for example, local x::T) is being converted to this type.

  • If a return type is declared for a function, the return value is converted to that type.

  • When passing a value to ccall it is converted to the appropriate argument type.

Transformation and creation

It may seem that calling convert(T, x) works almost the same as T(x). Indeed, this is usually the case. However, note an important semantic difference.: since the convert function can be called implicitly, its methods are limited to cases that are considered safe or expected. 'convert` performs conversion only between types that represent entities of the same nature (for example, different representations of numbers or strings in different encoding). In addition, the conversion is usually lossless: if you convert a value to a different type and then back, you should get exactly the same value.

There are four main scenarios in which constructors differ from the convert function.

Constructors for types unrelated to arguments

For some constructors, the very concept of transformation does not apply. For example, calling Timer(2) creates a two-second timer, but this cannot be called converting from an integer to a timer object.

Mutable collections

The call to convert(T, x) should return the original value of x if x is already of type T'. On the contrary, if 'T is a type of mutable collection, calling T(x) should always create a new collection (copying elements from x).

Shell Types

For some types that serve as wrappers for other values, the constructor can enclose its argument in a new object, even if the argument already has the requested type. For example, Some(x) serves as a wrapper for x, indicating that the value exists (in a context where the result may be Some or nothing'). However, `x can also be an object of Some(y). In this case, the result will be Some(Some(y)), that is, a two-level shell. In turn, the expression convert(Some, x) simply returns x, since this variable is already of type `Some'.

Constructors that do not return instances of their type

In very rare cases, it makes sense for the constructor T(x) to return an object not of type T'. This may be the case if the wrapper type is the reverse of itself (for example, `Flip(Flip(x)) ===x) or if the library needs to be redesigned to support the old syntax for backward compatibility. However, the call to convert(T, x) must always return a value of type `T'.

Defining new transformations

When defining a new type, initially all methods of its creation must be defined as constructors. If it turns out that implicit conversions can be useful and that some constructors meet the security criteria outlined above, you can add the convert methods. As a rule, these methods are quite simple, since they only call the appropriate constructors. Such a definition may look like this.

import Base: convert
convert(::Type{MyType}, x) = MyType(x)

The first argument of this method has the type Type{MyType}, the only instance of which is MyType'. Thus, this method is called only if the first argument is a value of type `MyType'. Note the syntax used for the first argument: the name of the argument before the character `:: is omitted, only the type is specified. This syntax is used in Julia for a function argument whose type is specified, but whose value does not need to be referenced by name.

All instances of some abstract types are considered similar enough by default, so that the Base Julia module provides a universal definition of the convert function. For example, according to the following definition, any numeric type (Number) can be converted using convert to any other numeric type by calling the constructor with one argument.

convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T

This means that for new numeric types (Number), it is sufficient to define constructors, since this definition is responsible for the operation of convert. For cases where the argument already has the requested type, an identical conversion is provided.

convert(::Type{T}, x::T) where {T<:Number} = x

Similar definitions are available for the AbstractString types, AbstractArray and AbstractDict.

Promotion

Promotion refers to the conversion of values of mixed types into one common type. Although this is not strictly necessary, it is usually assumed that the general type into which the values are converted allows you to correctly represent all the original values. In this sense, the term "promotion" is a good one, since the values are converted into a broader type, that is, one that allows you to represent any input values. However, it is important not to confuse this concept with the object-oriented (structural) supertype system or the concept of abstract supertypes in Julia: the promotion has nothing to do with the type hierarchy and only concerns the transformation between alternative representations. For example, any value of the type Int32 can also be represented as a value of the type Float64, but Int32 is not a subtype of `Float64'.

The promotion to a general, broader type is carried out in Julia by the function promote, which takes any number of arguments and returns a tuple of the same number of values converted to a common type, or throws an exception if promotion is not possible. The most common use case for promotion is to convert numeric arguments to a generic type.

julia> promote(1, 2.5)
(1.0, 2.5)

julia> promote(1, 2.5, 3)
(1.0, 2.5, 3.0)

julia> promote(2, 3//4)
(2//1, 3//4)

julia> promote(1, 2.5, 3, 3//4)
(1.0, 2.5, 3.0, 0.75)

julia> promote(1.5, im)
(1.5 + 0.0im, 0.0 + 1.0im)

julia> promote(1 + 2im, 3//4)
(1//1 + 2//1*im, 3//4 + 0//1*im)

Floating-point values are promoted to the largest of the floating-point argument types. Integer values are promoted to the largest of the integer argument types. If the types have the same size but differ in sign, the unsigned type is selected. Combinations of integer and floating-point values are promoted to a floating-point type large enough to store each of the values. Combinations of integer and rational values are promoted to rational values. Combinations of rational values and floating-point values are promoted to floating-point values. Combinations of complex and real values are promoted to the appropriate type of complex values.

This ends the issue of using promotions. All that remains is to apply them competently, and, as a rule, competent application consists in defining universal methods for numerical operations, such as arithmetic operators. +, -, * and /. Here are a number of definitions of universal methods from https://github.com/JuliaLang/julia/blob/master/base/promotion.jl [promotion.jl].

+(x::Number, y::Number) = +(promote(x,y)...)
-(x::Number, y::Number) = -(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)
/(x::Number, y::Number) = /(promote(x,y)...)

According to these definitions of methods, if there are no more specific rules for adding, subtracting, multiplying, and dividing pairs of numeric values, the values are promoted to the general type and the attempt is repeated. And that’s it: no one ever needs to worry about upgrading to a common numeric type when performing arithmetic operations anymore - it happens automatically. In https://github.com/JuliaLang/julia/blob/master/base/promotion.jl [promotion.jl] there are definitions of universal promotion methods for a number of other arithmetic and mathematical functions, but beyond that, other promote calls in the Base Julia module are practically not required. Most often, the promote' function is used in external constructor methods, which are provided for convenience. Thanks to it, constructor calls with different types of arguments are passed to an internal method with fields advanced to the corresponding general type. For example, recall that in https://github.com/JuliaLang/julia/blob/master/base/rational.jl [`rational.jl] there is the following external constructor method.

Rational(n::Integer, d::Integer) = Rational(promote(n,d)...)

It allows you to make the following calls.

julia> x = Rational(Int8(15),Int32(-5))
-3//1

julia> typeof(x)
Rational{Int32}

For most user types, programmers are advised to explicitly pass expected types to the constructor functions, but sometimes, especially when solving numerical problems, it may be convenient to perform the promotion automatically.

Defining promotion rules

Although, in principle, it would be possible to define methods for the promote function directly, this would require many definitions for all possible combinations of argument types. Instead, the behavior of promote is determined through an auxiliary function promote_rule, for which methods can be provided. The `promote_rule' function takes a pair of type objects and returns another type object so that instances of argument types are promoted to the returned type. Thus, using the following rule:

import Base: promote_rule
promote_rule(::Type{Float64}, ::Type{Float32}) = Float64

it declares that when advancing 64-bit and 32-bit floating-point values, they must advance to a 64-bit floating-point value. The resulting promotion type does not necessarily have to be one of the argument types. For example, the Base Julia module has the following promotion rules.

promote_rule(::Type{BigInt}, ::Type{Float64}) = BigFloat
promote_rule(::Type{BigInt}, ::Type{Int8}) = BigInt

In the latter case, the resulting type is BigInt, because BigInt is the only type that is large enough to store integers used in arbitrary precision arithmetic operations. In addition, note that the rule promote_rule(::Type) should also be defined.{A}, ::Type{B}), and the rule promote_rule(::Type{B}, ::Type{A}) not necessary — during the promotion process, the promote_rule function is applied symmetrically.

The function promote_rule' serves as a component for defining another function — `promote_type', which accepts any number of objects of types and returns the general type to which the corresponding values passed as arguments to the `promote function will be promoted. Thus, using the promote_type function, you can find out to which type a set of values of certain types will be promoted, when the values themselves are not yet known.

julia> promote_type(Int8, Int64)
Int64

Note that the promote_type function does not overload directly: instead, the promote_rule is overloaded. The promote_type' function uses `promote_rule' and adds a symmetric rule. If you overload it directly, errors may occur due to ambiguity. To determine how types should be promoted, we overload `promote_rule, and to find out how it happens, we use `promote_type'.

Internally, the function promote_type is used internally by promote to determine the type to which the argument values should be converted for promotion. The curious reader can read the code in the module. https://github.com/JuliaLang/julia/blob/master/base/promotion.jl [promotion.jl], which fully defines the promotion mechanism in about 35 lines.

Example: promotion of rational numbers

In conclusion, we will continue to study the example of the rational number type in Julia, which has relatively complex rules for implementing the promotion mechanism.

import Base: promote_rule
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{Rational{S}}) where {T<:Integer,S<:Integer} = Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:AbstractFloat} = promote_type(T,S)

According to the first rule, the combination of a rational number with any integer type is promoted to a rational type, the type of the numerator and denominator of which is the result of the promotion of the original type of numerator and denominator and the original integer type. The second rule applies the same logic to two rational numbers of different types: the resulting rational type is the result of advancing the original types of numerators and denominators. The third and final rule prescribes to promote the combination of a rational number and a floating-point number to the type to which the numerator and denominator types and the floating-point type are promoted.

This small set of promotion rules, along with the default type constructors and the "convert" method for numbers, is enough for rational numbers to be used quite naturally along with any other numeric types in Julia — integers, floating point, and complex. By providing conversion methods and promotion rules for any custom numeric type in a similar way, you can ensure that it works with predefined Julia numeric types.