Преобразование и продвижение
В 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> convert(UInt8, x)
0x0c
julia> typeof(ans)
UInt8
julia> convert(AbstractFloat, x)
12.0
julia> typeof(ans)
Float64
julia> a = Any[1 2 3; 4 5 6]
2×3 Array{Any,2}:
1 2 3
4 5 6
julia> convert(Array{Float64}, a)
2×3 Array{Float64,2}:
1.0 2.0 3.0
4.0 5.0 6.0
Преобразование не всегда возможно, в этом случае выбрасывается ошибка «нет метода», указывающая, что 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 любой тип Number в любой другой, вызывая конструктор с 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> Rational(Int8(15),Int32(-5))
-3//1
julia> typeof(ans)
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 для определения, к какому типу аргументы должны быть преобразованы для преобразования. Однако это может быть полезно само по себе. Любознательный читатель может прочитать код в 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–2019 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.2.0/manual/conversion-and-promotion/