¿Cuál es la diferencia en la práctica entre los parámetros de función genéricos y tipificados por protocolo?
Dado un protocolo sin ningún tipo asociado:
protocol SomeProtocol
{
var someProperty: Int { get }
}
¿Cuál es la diferencia entre estas dos funciones, en la práctica (es decir, no "una es genérica y la otra no")? ¿Generan código diferente, tienen características de tiempo de ejecución diferentes? ¿Cambian estas diferencias cuando el protocolo o las funciones dejan de ser triviales? (ya que un compilador probablemente podría incluir algo como esto)
func generic<T: SomeProtocol>(some: T) -> Int
{
return some.someProperty
}
func nonGeneric(some: SomeProtocol) -> Int
{
return some.someProperty
}
Principalmente pregunto sobre las diferencias en lo que hace el compilador; entiendo las implicaciones de ambos a nivel de lenguaje. Básicamente, ¿ nonGeneric
implica un tamaño de código constante pero un envío dinámico más lento, en lugar de generic
utilizar un tamaño de código creciente por tipo pasado, pero con un envío estático rápido?
(Me doy cuenta de que OP pregunta menos sobre las implicaciones del lenguaje y más sobre lo que hace el compilador, pero creo que también vale la pena enumerar las diferencias generales entre los parámetros de función genéricos y tipificados por protocolo)
1. Un marcador de posición genérico restringido por un protocolo debe satisfacerse con un tipo concreto
Esto es una consecuencia de que los protocolos no se ajustan a sí mismos , por lo tanto no se puede llamar generic(some:)
con un SomeProtocol
argumento escrito.
struct Foo : SomeProtocol {
var someProperty: Int
}
// of course the solution here is to remove the redundant 'SomeProtocol' type annotation
// and let foo be of type Foo, but this problem is applicable anywhere an
// 'anything that conforms to SomeProtocol' typed variable is required.
let foo : SomeProtocol = Foo(someProperty: 42)
generic(some: something) // compiler error: cannot invoke 'generic' with an argument list
// of type '(some: SomeProtocol)'
Esto se debe a que la función genérica espera un argumento de algún tipo T
que se ajuste a SomeProtocol
, pero noSomeProtocol
es un tipo que se ajuste a .SomeProtocol
Sin embargo , una función no genérica, con un tipo de parámetro de SomeProtocol
, aceptaráfoo
como argumento:
nonGeneric(some: foo) // compiles fine
Esto se debe a que acepta "cualquier cosa que pueda escribirse como SomeProtocol
", en lugar de "un tipo específico que se ajuste a SomeProtocol
".
2. Especialización
Como se explica en esta fantástica charla de la WWDC , se utiliza un "contenedor existencial" para representar un valor escrito en un protocolo.
Este contenedor se compone de:
Un búfer de valor para almacenar el valor en sí, que tiene 3 palabras de longitud. Los valores mayores que esto se asignarán en el montón y se almacenará una referencia al valor en el búfer de valores (ya que la referencia tiene un tamaño de solo 1 palabra).
Puntero a los metadatos del tipo. En los metadatos del tipo se incluye un puntero a su tabla testigo de valores, que gestiona la vida útil del valor en el contenedor existencial.
Uno o (en el caso de la composición del protocolo ) varios punteros a tablas testigo de protocolo para el tipo determinado. Estas tablas realizan un seguimiento de la implementación del tipo de los requisitos de protocolo disponibles para llamar en la instancia de tipo de protocolo determinada.
De forma predeterminada, se utiliza una estructura similar para pasar un valor a un argumento escrito de marcador de posición genérico.
El argumento se almacena en un búfer de valores de 3 palabras (que puede asignarse en montón), que luego se pasa al parámetro.
Para cada marcador de posición genérico, la función toma un parámetro de puntero de metadatos. El metatipo del tipo que se utiliza para satisfacer el marcador de posición se pasa a este parámetro al llamar.
Para cada restricción de protocolo en un marcador de posición determinado, la función toma un parámetro de puntero de tabla testigo de protocolo.
Sin embargo, en compilaciones optimizadas, Swift puede especializar las implementaciones de funciones genéricas, lo que permite al compilador generar una nueva función para cada tipo de marcador de posición genérico con el que se aplica. Esto permite que los argumentos siempre se pasen simplemente por valor, a costa de aumentar el tamaño del código. Sin embargo, como continúa la charla, las optimizaciones agresivas del compilador, particularmente en línea, pueden contrarrestar esta hinchazón.
3. Envío de requisitos de protocolo
Debido al hecho de que las funciones genéricas se pueden especializar, las llamadas a métodos sobre argumentos genéricos pasados se pueden enviar estáticamente (aunque obviamente no para tipos que usan polimorfismo dinámico, como clases no finales).
Sin embargo, las funciones tipo protocolo generalmente no pueden beneficiarse de esto, ya que no se benefician de la especialización. Por lo tanto, las llamadas a métodos en un argumento tipo protocolo se enviarán dinámicamente a través de la tabla testigo de protocolo para ese argumento determinado, lo cual es más costoso.
Aunque dicho esto, las funciones simples escritas en protocolos pueden beneficiarse de la integración. En tales casos, el compilador puede eliminar la sobrecarga del búfer de valores y del protocolo y de las tablas testigo de valores (esto se puede ver examinando el SIL emitido en una compilación -O), lo que le permite distribuir métodos estáticamente de la misma manera que funciones genéricas. Sin embargo, a diferencia de la especialización genérica, esta optimización no está garantizada para una función determinada (a menos que aplique el @inline(__always)
atributo , pero normalmente es mejor dejar que el compilador decida esto).
Por lo tanto, en general, las funciones genéricas se prefieren a las funciones tipo protocolo en términos de rendimiento, ya que pueden lograr el envío estático de métodos sin tener que estar integrados.
4. Resolución de sobrecarga
Al realizar la resolución de sobrecarga, el compilador favorecerá la función tipificada por protocolo sobre la genérica.
struct Foo : SomeProtocol {
var someProperty: Int
}
func bar<T : SomeProtocol>(_ some: T) {
print("generic")
}
func bar(_ some: SomeProtocol) {
print("protocol-typed")
}
bar(Foo(someProperty: 5)) // protocol-typed
Esto se debe a que Swift prefiere un parámetro escrito explícitamente sobre uno genérico (consulte estas preguntas y respuestas ).
5. Los marcadores de posición genéricos imponen el mismo tipo.
Como ya se dijo, el uso de un marcador de posición genérico le permite exigir que se use el mismo tipo para todos los parámetros/devoluciones que se escriben con ese marcador de posición en particular.
La función:
func generic<T : SomeProtocol>(a: T, b: T) -> T {
return a.someProperty < b.someProperty ? b : a
}
toma dos argumentos y tiene un retorno del mismo tipo concreto, donde ese tipo se ajusta a SomeProtocol
.
Sin embargo la función:
func nongeneric(a: SomeProtocol, b: SomeProtocol) -> SomeProtocol {
return a.someProperty < b.someProperty ? b : a
}
no conlleva más promesas que los argumentos y la devolución debe cumplir SomeProtocol
. Los tipos concretos reales que se pasan y devuelven no necesariamente tienen que ser los mismos.
Si su generic
método tuviera más de un parámetro que involucrara T
, habría una diferencia.
func generic<T: SomeProtocol>(some: T, someOther: T) -> Int
{
return some.someProperty
}
En el método anterior, some
y someOther
deben ser del mismo tipo. Pueden ser de cualquier tipo que se ajuste a SomeProtocol
, pero tienen que ser del mismo tipo.
Sin embargo, sin genéricos:
func nonGeneric(some: SomeProtocol, someOther: SomeProtocol) -> Int
{
return some.someProperty
}
some
y someOther
pueden ser de distintos tipos, siempre y cuando se ajusten a SomeProtocol
.