Spec-Zone.ru › Julia 1.9

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

В 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–2023 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.9/manual/conversion-and-promotion/

Spec-Zone.ru

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