Документация Engee

Преобразование и продвижение

В Julia есть система для продвижения аргументов математических операторов до общего типа, о которой упоминалось в других разделах, включая Целые числа и числа с плавающей запятой, Математические операции и элементарные функции, Типы и Методы. В этом разделе объясняется, как работает эта система продвижения, как распространить ее на новые типы и применять не только к математическим операторам, но и к другим функциям. В плане продвижения арифметических аргументов языки программирования традиционно делятся на две категории.

  • Автоматическое продвижение встроенных арифметических типов и операторов. В большинстве языков при использовании встроенных числовых типов в качестве операндов арифметических операторов с инфиксным синтаксисом, таких как +, -, * и /, они автоматически продвигаются до общего типа для получения ожидаемого результата. В C, Java, Perl, Python и многих других языках результатом сложения 1 + 1.5 будет значение с плавающей запятой 2.5, хотя один из операндов + — целое число. Эти системы удобны и достаточно грамотно продуманы, так что обычно практически незаметны для программиста: мало кто задумывается о продвижении при написании таких выражений. Однако перед сложением компиляторам и интерпретаторам необходимо выполнить преобразование, так как целочисленные значения и значения с плавающей запятой сами по себе складывать нельзя. Поэтому сложные правила автоматических преобразований типов неизбежно являются частью спецификации и реализации таких языков.

  • Отсутствие автоматического продвижения. К этой категории относятся Ада и ML — языки с очень строгой статической типизацией. В этих языках любое преобразование должно явным образом определяться программистом. Поэтому и в Аде, и в ML при компиляции примера выражения 1 + 1.5 произойдет ошибка. Вместо этого следует использовать выражение real(1) + 1.5, явно преобразовав целочисленное значение 1 в значение с плавающей запятой перед сложением. Однако постоянно осуществлять явные преобразования так неудобно, что даже в Аде до некоторой степени реализовано автоматическое преобразование: целочисленные литералы автоматически продвигаются до ожидаемого целочисленного типа, а литералы с плавающей запятой — до соответствующих типов с плавающей запятой.

В некотором смысле Julia относится к категории языков без автоматического продвижения: математические операторы — это просто функции с особым синтаксисом, а аргументы функций никогда не преобразовываются автоматически. Однако можно заметить, что применение математических операций к сочетаниям аргументов самых разных типов — это крайний случай полиморфической множественной диспетчеризации, а это именно то, для чего системы типов и диспетчеризации Julia очень хорошо подходят. Автоматическое продвижение математических операндов оказывается просто особым случаем применения: в Julia предварительно определены универсальные правила диспетчеризации для математических операций, которые вызываются, когда для определенного сочетания типов операндов нет конкретной реализации. Согласно этим универсальным правилам все операнды сначала продвигаются до общего типа с учетом правил продвижения, определенных пользователем, а затем для полученных значений, которые теперь одного типа, вызывается специализированная реализация оператора. Пользовательские типы можно легко интегрировать в эту систему продвижения, определив методы их преобразования в другие типы и наоборот и установив правила продвижения, которые определяют, до каких типов они должны продвигаться при использовании в сочетании с другими типами.

Преобразование

Стандартный способ получения значения определенного типа T — вызов конструктора этого типа T(x). Однако в некоторых случаях удобнее преобразовывать значения из одного типа в другой без явного запроса со стороны программиста. Примером может служить присваивание значения элементу массива: если A — это объект Vector{Float64}, выражение A[1] = 2 должно автоматически преобразовывать значение 2 из типа Int во Float64 и сохранять результат в массиве. Это делает функция convert.

Функция convert в общем случае принимает два аргумента: первый — это объект типа, а второй — значение, которое нужно преобразовать в этот тип. Возвращается значение, преобразованное в экземпляр указанного типа. Понять, как работает эта функция, проще всего на примерах.

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

Преобразование возможно не всегда. В этом случае происходит исключение MethodError с сообщением о том, что convert не поддерживает запрошенное преобразование.

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

В некоторых языках анализ строк в виде числовых значений или форматирование чисел как строк считается преобразованием (более того, во многих языках эти преобразования выполняются автоматически). В Julia это не так. Хотя некоторые строки можно проанализировать как числа, это относится лишь к небольшому их подмножеству: большинство строк не являются допустимыми представлениями чисел. Поэтому в Julia для выполнения этой операции следует использовать специальную функцию parse, что делает операцию более явной.

Когда вызывается convert?

Функция convert вызывается следующими конструкциями языка.

  • При присваивании значения массиву происходит преобразование в тип элементов массива.

  • При присваивании значения полю объекта происходит преобразование в объявленный тип поля.

  • При создании объекта с помощью new происходит преобразование в объявленные типы полей объекта.

  • При присваивании значения переменной с объявленным типом (например, local x::T) происходит преобразование в этот тип.

  • Если для функции объявлен возвращаемый тип, возвращаемое значение преобразовывается в этот тип.

  • При передаче значения в ccall оно преобразовывается в соответствующий тип аргумента.

Преобразование vs. создание

Может показаться, что вызов convert(T, x) работает практически так же, как T(x). Действительно, обычно этот так. Однако обратите внимание на важное семантическое отличие: так как функция convert может вызываться неявно, ее методы ограничены случаями, которые считаются безопасными или ожидаемыми. convert выполняет преобразование только между типами, которые представляют сущности одинаковой природы (например, разные представления чисел или строки в разной кодировке). Кроме того, преобразование обычно осуществляется без потерь: если преобразовать значение в другой тип, а затем обратно, должно получиться точно то же самое значение.

Есть четыре основных сценария, в которых конструкторы отличаются от функции convert.

Конструкторы для типов, не связанных с аргументами

К некоторым конструкторам само понятие преобразования неприменимо. Например, вызов Timer(2) создает двухсекундный таймер, но это нельзя назвать преобразованием из целого числа в объект таймера.

Изменяемые коллекции

Вызов convert(T, x) должен возвращать исходное значение x, если x уже имеет тип T. Напротив, если T — тип изменяемой коллекции, вызов T(x) всегда должен создавать новую коллекцию (копируя элементы из x).

Типы-оболочки

Для некоторых типов, которые служат оболочкой для других значений, конструктор может заключать свой аргумент в новый объект, даже если аргумент уже имеет запрошенный тип. Например, Some(x) служит оболочкой для x, сообщая, что значение существует (в контексте, где результатом может быть Some или nothing). Однако x также может быть объектом Some(y). В этом случае результатом будет Some(Some(y)), то есть двухуровневая оболочка. В свою очередь, выражение convert(Some, x) просто возвращает x, так как эта переменная уже относится к типу Some.

Конструкторы, которые не возвращают экземпляры своего типа

В очень редких случаях есть смысл в том, чтобы конструктор T(x) возвращал объект не типа T. Такое может иметь место, если тип-оболочка является обратным самому себе (например, Flip(Flip(x)) === x) или если при переработке библиотеки необходимо обеспечить поддержку старого синтаксиса в целях обратной совместимости. Однако вызов convert(T, x) должен всегда возвращать значение типа T.

Определение новых преобразований

При определении нового типа изначально все способы его создания должны определяться как конструкторы. Если выяснится, что могут быть полезны неявные преобразования и что некоторые конструкторы отвечают изложенным выше критериям безопасности, можно добавить методы convert. Как правило, эти методы достаточно простые, так как они всего лишь вызывают соответствующие конструкторы. Такое определение может выглядеть следующим образом.

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

Первый аргумент этого метода имеет тип Type{MyType}, единственный экземпляр которого — MyType. Таким образом, этот метод вызывается, только если первый аргумент — это значение типа MyType. Обратите внимание на синтаксис, используемый для первого аргумента: имя аргумента перед символом :: опускается, указывается только тип. Такой синтаксис применяется в Julia для аргумента функции, тип которого указан, но на значение которого не требуется ссылаться по имени.

Все экземпляры некоторых абстрактных типов по умолчанию считаются достаточно схожими, так что в модуле Base Julia предоставляется универсальное определение функции convert. Например, согласно следующему определению, любой числовой тип (Number) можно преобразовать с помощью convert в любой другой числовой тип, вызвав конструктор с одним аргументом.

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

Это означает, что для новых числовых типов (Number) достаточно определить конструкторы, так как за работу convert отвечает данное определение. Для случаев, когда аргумент уже имеет запрошенный тип, предоставляется тождественное преобразование.

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

Аналогичные определения имеются для типов AbstractString, AbstractArray и AbstractDict.

Продвижение

Под продвижением понимается преобразование значений смешанных типов в один общий тип. Хотя это и не строго обязательно, обычно предполагается, что общий тип, в который преобразовываются значения, позволяет верно представить все исходные значения. В этом смысле термин «продвижение» (promotion) удачный, так как значения преобразовываются в более широкий тип, то есть такой, который позволяет представить любые входные значения. Важно, однако, не путать это понятие с объектно ориентированной (структурной) системой супертипов или понятием абстрактных супертипов в Julia: продвижение не имеет никакого отношения к иерархии типов и касается лишь преобразования между альтернативными представлениями. Например, любое значение типа Int32 можно также представить как значение типа Float64, но Int32 не является подтипом Float64.

Продвижение до общего, более широкого типа осуществляется в Julia функцией promote, которая принимает любое количество аргументов и возвращает кортеж из того же количества значений, преобразованных в общий тип, или вызывает исключение, если продвижение невозможно. Самый распространенный вариант использования продвижения — преобразование числовых аргументов в общий тип.

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)

Значения с плавающей запятой продвигаются до самого большого из типов аргументов с плавающей запятой. Целочисленные значения продвигаются либо до размера машинного слова, либо до самого большого из типов целочисленных аргументов в зависимости от того, что больше. Сочетания целочисленных значений и значений с плавающей запятой продвигаются до типа с плавающей запятой, достаточно большого для хранения каждого из значений. Сочетания целочисленных и рациональных значений продвигаются до рациональных значений. Сочетания рациональных значений и значений с плавающей запятой продвигаются до значений с плавающей запятой. Сочетания комплексных и вещественных значений продвигаются до соответствующего типа комплексных значений.

Этим вопрос использования продвижений исчерпывается. Остается только грамотно их применять, причем, как правило, грамотное применение заключается в определении универсальных методов для числовых операций, таких как арифметические операторы +, -, * и /. Вот ряд определений универсальных методов из 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)...)

Согласно этим определениям методов, если отсутствуют более конкретные правила для сложения, вычитания, умножения и деления пар числовых значений, значения продвигаются до общего типа и попытка повторяется. И на этом все: больше никому и никогда не нужно беспокоиться о продвижении до общего числового типа при выполнении арифметических операций — оно происходит автоматически. В promotion.jl есть определения универсальных методов продвижения для ряда других арифметических и математических функций, но сверх того другие вызовы promote в модуле Base Julia практически не требуются. Чаще всего функция promote применяется во внешних методах конструкторов, которые предоставляются для удобства. Благодаря ей вызовы конструктора с разными типами аргументов передаются во внутренний метод с полями, продвинутыми до соответствующего общего типа. Например, напомним, что в rational.jl имеется следующий внешний метод конструктора.

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

Он позволяет выполнять следующие вызовы.

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

julia> typeof(x)
Rational{Int32}

Для большинства пользовательских типов программистам рекомендуется явным образом передавать в функции-конструкторы ожидаемые типы, но иногда, особенно при решении числовых задач, может быть удобно выполнять продвижение автоматически.

Определение правил продвижения

Хотя в принципе определить методы для функции promote можно было бы напрямую, это потребовало бы множества определений для всех возможных сочетаний типов аргументов. Вместо этого поведение promote определяется посредством вспомогательной функции promote_rule, для которой можно предоставить методы. Функция promote_rule принимает пару объектов типов и возвращает другой объект типа так, что экземпляры типов аргументов продвигаются до возвращаемого типа. Таким образом, с помощью следующего правила:

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

объявляется, что при продвижении 64-битного и 32-битного значений с плавающей запятой они должны продвигаться до 64-битного значения с плавающей запятой. Результирующий тип продвижения необязательно должен быть одним из типов аргументов. Например, в модуле Base Julia есть следующие правила продвижения.

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

В последнем случае результирующим типом является BigInt, так как BigInt — это единственный тип, размера которого достаточно для хранения целых чисел, используемых в арифметических операциях произвольной точности. Кроме того, обратите внимание, что определять и правило promote_rule(::Type{A}, ::Type{B}), и правило promote_rule(::Type{B}, ::Type{A}) не нужно — в процессе продвижения функция promote_rule применяется симметрично.

Функция promote_rule служит компонентом для определения другой функции — promote_type, которая принимает любое количество объектов типов и возвращает общий тип, до которого будут продвинуты соответствующие значения, переданные в качестве аргументов функции promote. Таким образом, с помощью функции promote_type можно узнать, до какого типа будет продвинут набор значений определенных типов, когда сами значения еще не известны.

julia> promote_type(Int8, Int64)
Int64

Обратите внимание, что функция promote_type не перегружается напрямую: вместо этого перегружается promote_rule. Функция promote_type использует promote_rule и добавляет симметричное правило. Если перегрузить ее напрямую, могут происходить ошибки вследствие неоднозначности. Чтобы определить, как должны продвигаться типы, мы перегружаем promote_rule, а чтобы узнать, как оно происходит, используем promote_type.

На внутреннем уровне функция promote_type используется внутри promote для определения типа, в который значения аргументов должны быть преобразованы для продвижения. Любопытный читатель может ознакомиться с кодом в модуле promotion.jl, в котором примерно в 35 строках полностью определяется механизм продвижения.

Пример: продвижение рациональных чисел

В завершение мы продолжим изучение примера с типом рациональных чисел в Julia, который имеет относительно сложные правила для реализации механизма продвижения.

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)

Согласно первому правилу, сочетание рационального числа с любым целочисленным типом продвигается до рационального типа, тип числителя и знаменателя которого является результатом продвижения исходного типа числителя и знаменателя и исходного целочисленного типа. Второе правило применяет ту же логику к двум рациональным числам разных типов: получившийся рациональный тип является результатом продвижения исходных типов числителей и знаменателей. Третье и последнее правило предписывает продвигать сочетание рационального числа и числа с плавающей запятой до типа, до которого продвигаются тип числителя и знаменателя и тип с плавающей запятой.

Этого небольшого набора правил продвижения вместе с конструкторами типа и методом convert по умолчанию для чисел достаточно для того, чтобы рациональные числа можно было совершенно естественным образом использовать вместе с любыми другими числовыми типами в Julia — целочисленными, с плавающей запятой и комплексными. Предоставив аналогичным образом методы преобразования и правила продвижения для любого пользовательского числового типа, можно обеспечить его работу с предопределенными числовыми типами Julia.