Spec-Zone.ru › Julia 1.8

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

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

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

В некотором смысле, 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 преобразует его в соответствующий тип аргумента.

Преобразование против создания

Обратите внимание, что поведение convert(T, x) кажется почти идентичным T(x). В действительности, так обычно и есть. Однако есть ключевое семантическое различие: поскольку convert может вызываться неявно, его методы ограничены случаями, которые считаются «безопасными» или «неожиданными». convert будет преобразовывать только между типами, представляющими один и тот же базовый вид (например, различные представления чисел или различные кодировки строк). Обычно оно также без потерь; преобразование значения в другой тип и обратно должно привести к точно такому же значению.

Существует четыре основных типа случаев, где конструкторы отличаются от convert:

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

Некоторые конструкторы не реализуют концепцию «преобразования». Например, Timer(2) создаёт таймер на 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 для аргумента функции, тип которого задан, но значение которого не нужно ссылаться по имени.

Все экземпляры некоторых абстрактных типов по умолчанию считаются «достаточно похожими», поэтому универсальное определение convert предоставляется в Julia Base. Например, это определение указывает на то, что допустимо преобразовать любой тип convert в любой другой, вызвав конструктор с 1 аргументом:

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

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

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

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

Промоция

Промоция означает преобразование значений смешанных типов к одному общему типу. Хотя это не строго необходимо, обычно подразумевается, что общий тип, в который преобразуются значения, может достоверно представить все исходные значения. В этом смысле термин "промоция" уместен, поскольку значения преобразуются к типу "большего" размера — типу, который может представить все входные значения в одном общем типе. Однако важно не путать это с объектно-ориентированным (структурным) супертипированием или понятием абстрактных супертипов в 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 есть определения универсальных методов повышения для ряда других арифметических и математических функций, но помимо этого, в Julia Base практически нет вызовов promote. Наиболее распространённое использование 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-битного значения с плавающей точкой. Тип повышения не обязательно должен быть одним из типов аргументов. Например, следующие правила повышения оба встречаются в Julia Base:

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 точно так же естественно.

© 2009–2022 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.8/manual/conversion-and-promotion/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API