Conversion and Promotion
Julia tiene un sistema para promover argumentos de operadores matemáticos a un tipo común, que se ha mencionado en varias otras secciones, incluyendo Integers and Floating-Point Numbers, Mathematical Operations and Elementary Functions, Types, y Methods. En esta sección, explicamos cómo funciona este sistema de promoción, así como cómo extenderlo a nuevos tipos y aplicarlo a funciones además de los operadores matemáticos incorporados. Tradicionalmente, los lenguajes de programación se dividen en dos grupos con respecto a la promoción de argumentos aritméticos:
- Promoción automática para tipos aritméticos integrados y operadores. En la mayoría de los lenguajes, los tipos numéricos integrados, cuando se utilizan como operandos de operadores aritméticos con sintaxis infija, como
+
,-
,*
y/
, se promueven automáticamente a un tipo común para producir los resultados esperados. C, Java, Perl y Python, por nombrar algunos, calculan correctamente la suma1 + 1.5
como el valor de punto flotante2.5
, a pesar de que uno de los operandos de+
es un entero. Estos sistemas son convenientes y están diseñados con suficiente cuidado para que generalmente sean casi invisibles para el programador: casi nadie piensa conscientemente en esta promoción que ocurre al escribir tal expresión, pero los compiladores e intérpretes deben realizar la conversión antes de la adición, ya que los enteros y los valores de punto flotante no se pueden sumar tal como están. Por lo tanto, las reglas complejas para tales conversiones automáticas son inevitablemente parte de las especificaciones e implementaciones de tales lenguajes. - No promoción automática. Este campamento incluye Ada y ML, lenguajes de programación de tipos estáticos muy "estrictos". En estos lenguajes, cada conversión debe ser especificada explícitamente por el programador. Así, la expresión de ejemplo
1 + 1.5
generaría un error de compilación tanto en Ada como en ML. En su lugar, uno debe escribirreal(1) + 1.5
, convirtiendo explícitamente el entero1
a un valor de punto flotante antes de realizar la suma. Sin embargo, la conversión explícita en todas partes es tan inconveniente que incluso Ada tiene cierto grado de conversión automática: los literales enteros se promueven al tipo entero esperado automáticamente, y los literales de punto flotante se promueven de manera similar a los tipos de punto flotante apropiados.
En cierto sentido, Julia cae en la categoría de "sin promoción automática": los operadores matemáticos son solo funciones con una sintaxis especial, y los argumentos de las funciones nunca se convierten automáticamente. Sin embargo, se puede observar que aplicar operaciones matemáticas a una amplia variedad de tipos de argumentos mixtos es solo un caso extremo de despacho múltiple polimórfico, algo para lo cual los sistemas de despacho y tipos de Julia están particularmente bien equipados. La promoción "automática" de los operandos matemáticos simplemente surge como una aplicación especial: Julia viene con reglas de despacho predefinidas que abarcan todos los casos para los operadores matemáticos, invocadas cuando no existe una implementación específica para alguna combinación de tipos de operandos. Estas reglas de captura primero promueven todos los operandos a un tipo común utilizando reglas de promoción definidas por el usuario, y luego invocan una implementación especializada del operador en cuestión para los valores resultantes, ahora del mismo tipo. Los tipos definidos por el usuario pueden participar fácilmente en este sistema de promoción definiendo métodos para la conversión hacia y desde otros tipos, y proporcionando un puñado de reglas de promoción que definen a qué tipos deben promoverse cuando se mezclan con otros tipos.
Conversion
La forma estándar de obtener un valor de un tipo determinado T
es llamar al constructor del tipo, T(x)
. Sin embargo, hay casos en los que es conveniente convertir un valor de un tipo a otro sin que el programador lo pida explícitamente. Un ejemplo es asignar un valor a un arreglo: si A
es un Vector{Float64}
, la expresión A[1] = 2
debería funcionar convirtiendo automáticamente el 2
de Int
a Float64
, y almacenando el resultado en el arreglo. Esto se hace a través de la función convert
.
La función convert
generalmente toma dos argumentos: el primero es un objeto de tipo y el segundo es un valor para convertir a ese tipo. El valor devuelto es el valor convertido a una instancia del tipo dado. La forma más sencilla de entender esta función es verla en acción:
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
La conversión no siempre es posible, en cuyo caso se lanza un MethodError
que indica que convert
no sabe cómo realizar la conversión solicitada:
julia> convert(AbstractFloat, "foo")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type AbstractFloat
[...]
Algunos lenguajes consideran que analizar cadenas como números o formatear números como cadenas son conversiones (muchos lenguajes dinámicos incluso realizarán la conversión automáticamente por ti). Este no es el caso en Julia. Aunque algunas cadenas pueden ser analizadas como números, la mayoría de las cadenas no son representaciones válidas de números, y solo un subconjunto muy limitado de ellas lo es. Por lo tanto, en Julia se debe utilizar la función dedicada parse
para realizar esta operación, haciéndola más explícita.
When is convert
called?
Las siguientes construcciones de lenguaje llaman a convert
:
- Asignar a un arreglo convierte al tipo de elemento del arreglo.
- Asignar a un campo de un objeto se convierte en el tipo declarado del campo.
- Construyendo un objeto con
new
se convierte en los tipos de campo declarados del objeto. - Asignar a una variable con un tipo declarado (por ejemplo,
local x::T
) se convierte en ese tipo. - Una función con un tipo de retorno declarado convierte su valor de retorno a ese tipo.
- Pasar un valor a
ccall
lo convierte en el tipo de argumento correspondiente.
Conversion vs. Construction
Tenga en cuenta que el comportamiento de convert(T, x)
parece ser casi idéntico a T(x)
. De hecho, generalmente lo es. Sin embargo, hay una diferencia semántica clave: dado que convert
se puede llamar implícitamente, sus métodos están restringidos a casos que se consideran "seguros" o "no sorprendentes". convert
solo convertirá entre tipos que representen el mismo tipo básico de cosa (por ejemplo, diferentes representaciones de números o diferentes codificaciones de cadenas). También suele ser sin pérdida; convertir un valor a un tipo diferente y volver a convertirlo debería resultar en el mismo valor exacto.
Hay cuatro tipos generales de casos en los que los constructores difieren de convert
:
Constructors for types unrelated to their arguments
Algunos constructores no implementan el concepto de "conversión". Por ejemplo, Timer(2)
crea un temporizador de 2 segundos, que no es realmente una "conversión" de un entero a un temporizador.
Mutable collections
convert(T, x)
se espera que devuelva el original x
si x
ya es del tipo T
. En contraste, si T
es un tipo de colección mutable, entonces T(x)
siempre debería crear una nueva colección (copiando elementos de x
).
Wrapper types
Para algunos tipos que "envuelven" otros valores, el constructor puede envolver su argumento dentro de un nuevo objeto incluso si ya es del tipo solicitado. Por ejemplo, Some(x)
envuelve x
para indicar que un valor está presente (en un contexto donde el resultado podría ser un Some
o nothing
). Sin embargo, x
en sí mismo podría ser el objeto Some(y)
, en cuyo caso el resultado es Some(Some(y))
, con dos niveles de envoltura. convert(Some, x)
, por otro lado, simplemente devolvería x
ya que ya es un Some
.
Constructors that don't return instances of their own type
En casos muy raros puede tener sentido que el constructor T(x)
devuelva un objeto que no sea del tipo T
. Esto podría suceder si un tipo envoltorio es su propio inverso (por ejemplo, Flip(Flip(x)) === x
), o para soportar una sintaxis de llamada antigua por compatibilidad hacia atrás cuando se reestructura una biblioteca. Pero convert(T, x)
siempre debería devolver un valor del tipo T
.
Defining New Conversions
Al definir un nuevo tipo, inicialmente todas las formas de crearlo deben definirse como constructores. Si se hace evidente que la conversión implícita sería útil, y que algunos constructores cumplen con los criterios de "seguridad" mencionados anteriormente, entonces se pueden agregar métodos convert
. Estos métodos son típicamente bastante simples, ya que solo necesitan llamar al constructor apropiado. Tal definición podría verse así:
import Base: convert
convert(::Type{MyType}, x) = MyType(x)
El tipo del primer argumento de este método es Type{MyType}
, la única instancia de la cual es MyType
. Por lo tanto, este método solo se invoca cuando el primer argumento es el valor de tipo MyType
. Nota la sintaxis utilizada para el primer argumento: el nombre del argumento se omite antes del símbolo ::
, y solo se da el tipo. Esta es la sintaxis en Julia para un argumento de función cuyo tipo está especificado pero cuyo valor no necesita ser referenciado por nombre.
Todas las instancias de algunos tipos abstractos se consideran por defecto "suficientemente similares" de modo que se proporciona una definición universal de convert
en Julia Base. Por ejemplo, esta definición establece que es válido convert
cualquier tipo Number
a cualquier otro llamando a un constructor de 1 argumento:
convert(::Type{T}, x::Number) where {T<:Number} = T(x)::T
Esto significa que los nuevos tipos Number
solo necesitan definir constructores, ya que esta definición manejará convert
por ellos. También se proporciona una conversión de identidad para manejar el caso en el que el argumento ya es del tipo solicitado:
convert(::Type{T}, x::T) where {T<:Number} = x
Existen definiciones similares para AbstractString
, AbstractArray
, y AbstractDict
.
Promotion
La promoción se refiere a convertir valores de tipos mixtos a un único tipo común. Aunque no es estrictamente necesario, generalmente se implica que el tipo común al que se convierten los valores puede representar fielmente todos los valores originales. En este sentido, el término "promoción" es apropiado ya que los valores se convierten a un tipo "mayor", es decir, uno que puede representar todos los valores de entrada en un único tipo común. Sin embargo, es importante no confundir esto con la supertipificación orientada a objetos (estructural), o la noción de supertipos abstractos de Julia: la promoción no tiene nada que ver con la jerarquía de tipos, y todo que ver con la conversión entre representaciones alternativas. Por ejemplo, aunque cada valor Int32
también puede ser representado como un valor Float64
, Int32
no es un subtipo de Float64
.
La promoción a un tipo "mayor" común se realiza en Julia mediante la función promote
, que toma cualquier número de argumentos y devuelve una tupla con la misma cantidad de valores, convertidos a un tipo común, o lanza una excepción si la promoción no es posible. El caso de uso más común para la promoción es convertir argumentos numéricos a un tipo común:
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)
Los valores de punto flotante se promueven al más grande de los tipos de argumento de punto flotante. Los valores enteros se promueven al más grande de los tipos de argumento entero. Si los tipos son del mismo tamaño pero difieren en signo, se elige el tipo sin signo. Las mezclas de enteros y valores de punto flotante se promueven a un tipo de punto flotante lo suficientemente grande como para contener todos los valores. Los enteros mezclados con racionales se promueven a racionales. Los racionales mezclados con flotantes se promueven a flotantes. Los valores complejos mezclados con valores reales se promueven al tipo apropiado de valor complejo.
Eso es realmente todo lo que hay que saber sobre el uso de promociones. El resto es solo una cuestión de aplicación ingeniosa, siendo la aplicación "ingeniosa" más típica la definición de métodos generales para operaciones numéricas como los operadores aritméticos +
, -
, *
y /
. Aquí están algunas de las definiciones de métodos generales dadas en 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)...)
Estas definiciones de métodos dicen que, en ausencia de reglas más específicas para sumar, restar, multiplicar y dividir pares de valores numéricos, se promueven los valores a un tipo común y luego se intenta de nuevo. Eso es todo: en ningún otro lugar uno necesita preocuparse por la promoción a un tipo numérico común para las operaciones aritméticas; simplemente sucede automáticamente. Hay definiciones de métodos de promoción que abarcan una serie de otras funciones aritméticas y matemáticas en promotion.jl
, pero más allá de eso, rara vez se requieren llamadas a promote
en Julia Base. Los usos más comunes de promote
ocurren en métodos de constructores externos, proporcionados por conveniencia, para permitir que las llamadas a constructores con tipos mixtos deleguen a un tipo interno con campos promovidos a un tipo común apropiado. Por ejemplo, recuerda que rational.jl
proporciona el siguiente método de constructor externo:
Rational(n::Integer, d::Integer) = Rational(promote(n,d)...)
Esto permite que llamadas como las siguientes funcionen:
julia> x = Rational(Int8(15),Int32(-5))
-3//1
julia> typeof(x)
Rational{Int32}
Para la mayoría de los tipos definidos por el usuario, es una mejor práctica requerir que los programadores suministren los tipos esperados a las funciones constructoras de manera explícita, pero a veces, especialmente para problemas numéricos, puede ser conveniente hacer la promoción automáticamente.
Defining Promotion Rules
Aunque, en principio, se podrían definir métodos para la función promote
directamente, esto requeriría muchas definiciones redundantes para todas las posibles permutaciones de tipos de argumentos. En cambio, el comportamiento de promote
se define en términos de una función auxiliar llamada promote_rule
, para la cual se pueden proporcionar métodos. La función promote_rule
toma un par de objetos de tipo y devuelve otro objeto de tipo, de modo que las instancias de los tipos de argumento se promoverán al tipo devuelto. Así, al definir la regla:
import Base: promote_rule
promote_rule(::Type{Float64}, ::Type{Float32}) = Float64
uno declara que cuando los valores de punto flotante de 64 bits y 32 bits se promocionan juntos, deben ser promovidos a punto flotante de 64 bits. El tipo de promoción no necesita ser uno de los tipos de argumento. Por ejemplo, las siguientes reglas de promoción ocurren en Julia Base:
promote_rule(::Type{BigInt}, ::Type{Float64}) = BigFloat
promote_rule(::Type{BigInt}, ::Type{Int8}) = BigInt
En el último caso, el tipo de resultado es BigInt
ya que BigInt
es el único tipo lo suficientemente grande como para contener enteros para aritmética de enteros de precisión arbitraria. También cabe señalar que no es necesario definir tanto promote_rule(::Type{A}, ::Type{B})
como promote_rule(::Type{B}, ::Type{A})
– la simetría se implica por la forma en que se utiliza promote_rule
en el proceso de promoción.
La función promote_rule
se utiliza como un bloque de construcción para definir una segunda función llamada promote_type
, que, dado cualquier número de objetos de tipo, devuelve el tipo común al que esos valores, como argumentos de promote
, deberían ser promovidos. Así, si uno quiere saber, en ausencia de valores reales, a qué tipo se promovería una colección de valores de ciertos tipos, se puede usar promote_type
:
julia> promote_type(Int8, Int64)
Int64
Tenga en cuenta que no sobrecargamos promote_type
directamente: sobrecargamos promote_rule
en su lugar. promote_type
utiliza promote_rule
y añade la simetría. Sobrecargarlo directamente puede causar errores de ambigüedad. Sobrecargamos promote_rule
para definir cómo deben ser promovidas las cosas, y usamos promote_type
para consultar eso.
Internamente, promote_type
se utiliza dentro de promote
para determinar a qué tipo deben convertirse los valores de argumento para la promoción. El lector curioso puede leer el código en promotion.jl
, que define el mecanismo de promoción completo en unas 35 líneas.
Case Study: Rational Promotions
Finalmente, concluimos nuestro estudio de caso en curso sobre el tipo de número racional de Julia, que hace un uso relativamente sofisticado del mecanismo de promoción con las siguientes reglas de promoción:
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)
La primera regla dice que promover un número racional con cualquier otro tipo de entero se promueve a un tipo racional cuyo tipo de numerador/denominador es el resultado de la promoción de su tipo de numerador/denominador con el otro tipo de entero. La segunda regla aplica la misma lógica a dos tipos diferentes de números racionales, resultando en un racional de la promoción de sus respectivos tipos de numerador/denominador. La tercera y última regla dicta que promover un racional con un flotante resulta en el mismo tipo que promover el tipo de numerador/denominador con el flotante.
Este pequeño conjunto de reglas de promoción, junto con los constructores del tipo y el método convert
predeterminado para números, son suficientes para hacer que los números racionales interoperen de manera completamente natural con todos los otros tipos numéricos de Julia: enteros, números de punto flotante y números complejos. Al proporcionar métodos de conversión apropiados y reglas de promoción de la misma manera, cualquier tipo numérico definido por el usuario puede interoparar de manera tan natural con los numéricos predefinidos de Julia.