Conversion and Promotion

Джулия имеет систему для продвижения аргументов математических операторов к общему типу, о которой упоминалось в различных других разделах, включая Integers and Floating-Point Numbers, Mathematical Operations and Elementary Functions, Types и Methods. В этом разделе мы объясняем, как работает эта система продвижения, а также как расширить ее для новых типов и применить к функциям, помимо встроенных математических операторов. Традиционно языки программирования делятся на два лагеря в отношении продвижения арифметических аргументов:

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

Conversion

Стандартный способ получить значение определенного типа 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 функцию для выполнения этой операции, что делает её более явной.

When is convert called?

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

  • Присваивание массиву преобразует в тип элемента массива.
  • Присвоение полю объекта приводит к преобразованию в объявленный тип поля.
  • Создание объекта с new преобразует в объявленные типы полей объекта.
  • Присваивание переменной с объявленным типом (например, local x::T) приводит к преобразованию в этот тип.
  • Функция с объявленным типом возвращаемого значения преобразует свое возвращаемое значение в этот тип.
  • Передача значения в ccall преобразует его в соответствующий тип аргумента.

Conversion vs. Construction

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

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

Constructors for types unrelated to their arguments

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

Mutable collections

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

Wrapper types

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

Constructors that don't return instances of their own type

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

Defining New Conversions

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

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

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

Все экземпляры некоторых абстрактных типов по умолчанию считаются "достаточно похожими", поэтому в Julia Base предоставлено универсальное определение convert. Например, это определение утверждает, что допустимо convert любой тип Number в любой другой, вызывая конструктор с 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.

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, но за пределами этого в 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}

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

Defining Promotion Rules

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

import Base: 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 строк.

Case Study: Rational Promotions

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

import Base: promote_rule
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.