Spec-Zone.ru › Julia 1.10

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

В 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)::T

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

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

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

Продвижение

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

Продвижение к общему «более» типу выполняется в Юлии функцией 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 строках.

Случайное исследование: продвижения рациональных чисел

Наконец, мы завершаем наше текущее исследование рационального типа чисел Юлии, что относительно сложно использует механизм продвижения со следующими правилами продвижения:

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 для чисел, достаточно, чтобы рациональные числа взаимодействовали полностью естественно со всеми другими числовыми типами Юлии – целыми числами, числами с плавающей точкой и комплексными числами. Предоставив соответствующие методы преобразования и правила продвижения аналогичным образом, любой пользовательский числовой тип может взаимодействовать так же естественно с предопределёнными числовыми типами Юлии.

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

Spec-Zone.ru

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