El protocolo no se ajusta a sí mismo?

125

¿Por qué no compila este código Swift?

protocol P { }
struct S: P { }

let arr:[P] = [ S() ]

extension Array where Element : P {
    func test<T>() -> [T] {
        return []
    }
}

let result : [S] = arr.test()

El compilador dice: "El tipo Pno se ajusta al protocolo P" (o, en versiones posteriores de Swift, "No se admite el uso de 'P' como tipo concreto conforme al protocolo 'P'").

Por qué no? Esto se siente como un agujero en el idioma, de alguna manera. Me doy cuenta de que el problema surge de declarar la matriz arrcomo una matriz de un tipo de protocolo , pero ¿es eso algo irracional? ¿Pensé que los protocolos estaban allí exactamente para ayudar a suministrar estructuras con algo así como una jerarquía de tipos?

mate
fuente
1
Cuando elimina la anotación de tipo en la let arrlínea, el compilador infiere el tipo [S]y el código se compila. Parece que un tipo de protocolo no se puede usar de la misma manera que una relación clase - superclase.
vadian
1
@vadian Correcto, eso es a lo que me refería en mi pregunta cuando dije "Me doy cuenta de que el problema se deriva de declarar la matriz arr como una matriz de un tipo de protocolo". Pero, como continúo diciendo en mi pregunta, ¡el punto central de los protocolos es que pueden usarse de la misma manera que una relación clase - superclase! Su objetivo es proporcionar una especie de estructura jerárquica al mundo de las estructuras. Y generalmente lo hacen. La pregunta es, ¿por qué no debería funcionar eso aquí ?
mate
1
Todavía no funciona en Xcode 7.1, pero el mensaje de error ahora está "usando 'P' como un tipo concreto conforme al protocolo 'P' no es compatible" .
Martin R
1
@ MartininR Es un mejor mensaje de error. Pero todavía me parece un agujero en el idioma.
mate
¡Por supuesto! Incluso con protocol P : Q { }P no se ajusta a Q.
Martin R

Respuestas:

66

EDITAR: Dieciocho meses más de trabajo con Swift, otra versión importante (que proporciona un nuevo diagnóstico), y un comentario de @AyBayBay me dan ganas de volver a escribir esta respuesta. El nuevo diagnóstico es:

"No se admite el uso de 'P' como tipo concreto conforme al protocolo 'P'".

Eso realmente hace que todo esto sea mucho más claro. Esta extensión:

extension Array where Element : P {

no se aplica cuando Element == Pya Pno se considera una conformidad concreta de P. (La solución "ponerlo en una caja" a continuación sigue siendo la solución más general).


Vieja respuesta:

Es otro caso más de metatipos. Swift realmente quiere que llegue a un tipo concreto para la mayoría de las cosas no triviales. [P]no es un tipo concreto (no puede asignar un bloque de memoria de tamaño conocido P). (No creo que eso sea realmente cierto; puedes crear absolutamente algo de tamaño Pporque se hace por vía indirecta ). No creo que haya ninguna evidencia de que este sea un caso de "no debería" funcionar. Esto se parece mucho a uno de sus casos de "todavía no funciona". (Desafortunadamente, es casi imposible hacer que Apple confirme la diferencia entre esos casos). El hecho de que Array<P>puede ser un tipo variable (dondeArrayno puede) indica que ya han hecho algo de trabajo en esta dirección, pero los metatipos Swift tienen muchos bordes afilados y casos sin implementar. No creo que vaya a obtener una mejor respuesta "por qué" que eso. "Porque el compilador no lo permite". (Insatisfactorio, lo sé. Toda mi vida de Swift ...)

La solución es casi siempre poner las cosas en una caja. Construimos un borrador de texto.

protocol P { }
struct S: P { }

struct AnyPArray {
    var array: [P]
    init(_ array:[P]) { self.array = array }
}

extension AnyPArray {
    func test<T>() -> [T] {
        return []
    }
}

let arr = AnyPArray([S()])
let result: [S] = arr.test()

Cuando Swift te permite hacer esto directamente (lo que espero eventualmente), es probable que solo sea creando este cuadro automáticamente. Las enumeraciones recursivas tenían exactamente esta historia. Tenías que encajonarlos y fue increíblemente molesto y restrictivo, y finalmente el compilador agregó indirectque hiciera lo mismo más automáticamente.

Rob Napier
fuente
Mucha información útil en esta respuesta, pero la solución real en la respuesta de Tomohiro es mejor que la solución de boxeo presentada aquí.
jsadler 01 de
@jsadler La pregunta no era cómo solucionar la limitación, sino por qué existe la limitación. De hecho, en lo que respecta a la explicación, la solución de Tomohiro plantea más preguntas de las que responde. Si usamos ==en mi ejemplo de matriz, obtenemos un error, el requisito del mismo tipo hace que el parámetro genérico 'Elemento' no sea genérico. "¿Por qué el uso de Tomohiro de no ==genera el mismo error?
Matt
@Rob Napier Todavía estoy perplejo por su respuesta. ¿Cómo ve Swift más concreción en su solución frente al original? Parecía que acababas de envolver las cosas en una estructura ... Idk, tal vez estoy luchando por comprender el sistema de tipo rápido, pero todo esto parece vudú mágico
AyBayBay
@AyBayBay Respuesta actualizada.
Rob Napier
Muchas gracias @RobNapier Siempre me sorprende la rapidez de sus respuestas y, francamente, cómo encuentra el tiempo para ayudar a las personas tanto como usted. Sin embargo, sus nuevas ediciones definitivamente lo ponen en perspectiva. Una cosa más que me gustaría señalar, comprender la eliminación de tipos también me ayudó. Este artículo en particular hizo un trabajo fantástico: krakendev.io/blog/generic-protocols-and-their-shortcomings TBH Idk cómo me siento acerca de algunas de estas cosas. Parece que estamos teniendo en cuenta los agujeros en el lenguaje, pero no sé cómo Apple podría construir algo de esto.
AyBayBay
109

¿Por qué los protocolos no se ajustan a sí mismos?

Permitir que los protocolos se ajusten a sí mismos en el caso general es poco sólido. El problema radica en los requisitos del protocolo estático.

Éstos incluyen:

  • static métodos y propiedades
  • Iniciadores
  • Tipos asociados (aunque estos actualmente evitan el uso de un protocolo como tipo real)

Podemos acceder a estos requisitos en un marcador de posición genérico Tdonde T : P, sin embargo, no podemos acceder a ellos en el tipo de protocolo en sí, ya que no hay ningún tipo de conformación concreta para reenviar. Por lo tanto no podemos permitir Tser P.

Considere lo que sucedería en el siguiente ejemplo si permitiéramos que la Arrayextensión sea aplicable a [P]:

protocol P {
  init()
}

struct S  : P {}
struct S1 : P {}

extension Array where Element : P {
  mutating func appendNew() {
    // If Element is P, we cannot possibly construct a new instance of it, as you cannot
    // construct an instance of a protocol.
    append(Element())
  }
}

var arr: [P] = [S(), S1()]

// error: Using 'P' as a concrete type conforming to protocol 'P' is not supported
arr.appendNew()

No podemos recurrir appendNew()a a [P], porque P(el Element) no es un tipo concreto y, por lo tanto, no se puede instanciar. Se debe invocar en una matriz con elementos de tipo concreto, donde ese tipo se ajusta P.

Es una historia similar con el método estático y los requisitos de propiedad:

protocol P {
  static func foo()
  static var bar: Int { get }
}

struct SomeGeneric<T : P> {

  func baz() {
    // If T is P, what's the value of bar? There isn't one – because there's no
    // implementation of bar's getter defined on P itself.
    print(T.bar)

    T.foo() // If T is P, what method are we calling here?
  }
}

// error: Using 'P' as a concrete type conforming to protocol 'P' is not supported
SomeGeneric<P>().baz()

No podemos hablar en términos de SomeGeneric<P>. Necesitamos implementaciones concretas de los requisitos del protocolo estático (observe cómo no hay implementaciones de foo()o bardefinidas en el ejemplo anterior). Si bien podemos definir implementaciones de estos requisitos en una Pextensión, estos se definen solo para los tipos concretos que se ajustan a ellos P; aún no puede Pinvocarlos.

Debido a esto, Swift simplemente nos impide usar un protocolo como un tipo que se ajusta a sí mismo, porque cuando ese protocolo tiene requisitos estáticos, no los tiene.

Los requisitos del protocolo de instancia no son problemáticos, ya que debe llamarlos en una instancia real que se ajuste al protocolo (y, por lo tanto, debe haber implementado los requisitos). Entonces, cuando se llama a un requisito en una instancia escrita como P, simplemente podemos reenviar esa llamada a la implementación de ese requisito por parte del tipo concreto subyacente.

Sin embargo, hacer excepciones especiales para la regla en este caso podría conducir a inconsistencias sorprendentes en la forma en que los protocolos son tratados por código genérico. Aunque dicho esto, la situación no es muy diferente de los associatedtyperequisitos, que (actualmente) le impiden usar un protocolo como tipo. Tener una restricción que le impide usar un protocolo como un tipo que se ajusta a sí mismo cuando tiene requisitos estáticos podría ser una opción para una futura versión del idioma

Editar: y como se explora a continuación, esto se parece a lo que el equipo Swift está buscando.


@objc protocolos

Y, de hecho, así es exactamente como el lenguaje trata los @objcprotocolos. Cuando no tienen requisitos estáticos, se conforman a sí mismos.

Lo siguiente compila muy bien:

import Foundation

@objc protocol P {
  func foo()
}

class C : P {
  func foo() {
    print("C's foo called!")
  }
}

func baz<T : P>(_ t: T) {
  t.foo()
}

let c: P = C()
baz(c)

bazrequiere que se Tajuste a P; pero podemos sustituir en Ppara T, porque Pno tiene requisitos estáticos. Si agregamos un requisito estático a P, el ejemplo ya no se compila:

import Foundation

@objc protocol P {
  static func bar()
  func foo()
}

class C : P {

  static func bar() {
    print("C's bar called")
  }

  func foo() {
    print("C's foo called!")
  }
}

func baz<T : P>(_ t: T) {
  t.foo()
}

let c: P = C()
baz(c) // error: Cannot invoke 'baz' with an argument list of type '(P)'

Entonces, una solución a este problema es hacer su protocolo @objc. Por supuesto, esta no es una solución ideal en muchos casos, ya que obliga a sus tipos conformes a ser clases, además de requerir el tiempo de ejecución Obj-C, por lo que no lo hace viable en plataformas que no son de Apple como Linux.

Pero sospecho que esta limitación es (una de) las razones principales por las que el lenguaje ya implementa 'protocolo sin requisitos estáticos se ajusta a sí mismo' para @objcprotocolos. El compilador puede simplificar significativamente el código genérico escrito alrededor de ellos.

¿Por qué? Debido a que @objclos valores de protocolo-tecleado son referencias efectivamente acaba de clase cuyos requisitos se envían utilizando objc_msgSend. Por otro lado, los @objcvalores no tipificados por protocolo son más complicados, ya que transportan tanto tablas de valores como de testigo para administrar la memoria de su valor empaquetado (potencialmente almacenado indirectamente) y determinar qué implementaciones llamar para las diferentes requisitos, respectivamente.

Debido a esta representación simplificada para @objcprotocolos, un valor de dicho tipo de protocolo Ppuede compartir la misma representación de memoria que un 'valor genérico' de tipo algún marcador de posición genérico T : P, presumiblemente haciendo que sea fácil para el equipo de Swift permitir la autosuficiencia. @objcSin embargo, lo mismo no es cierto para los que no son protocolos, ya que dichos valores genéricos no tienen actualmente tablas de valores o testigos de protocolo.

Sin embargo, esta característica es intencional y es de esperar que sea implementada en no @objcprotocolos, como lo confirmó Slava Pestov, miembro del equipo de Swift, en los comentarios del SR-55 en respuesta a su consulta al respecto (provocada por esta pregunta ):

Matt Neuburg agregó un comentario - 7 Sep 2017 1:33 PM

Esto compila:

@objc protocol P {}
class C: P {}

func process<T: P>(item: T) -> T { return item }
func f(image: P) { let processed: P = process(item:image) }

Agregar lo @objchace compilar; eliminarlo hace que no se vuelva a compilar. Algunos de nosotros en Stack Overflow encuentran esto sorprendente y nos gustaría saber si eso es deliberado o un caso marginal con errores.

Slava Pestov agregó un comentario - 7 sep 2017 1:53 PM

Es deliberado: eliminar esta restricción es de lo que se trata este error. Como dije, es complicado y todavía no tenemos planes concretos.

Espero que sea algo que el lenguaje algún día también admitirá para los no @objcprotocolos.

Pero, ¿qué soluciones actuales hay para los no @objcprotocolos?


Implementación de extensiones con restricciones de protocolo

En Swift 3.1, si desea una extensión con la restricción de que un marcador de posición genérico dado o un tipo asociado debe ser un tipo de protocolo dado (no solo un tipo concreto que se ajuste a ese protocolo), simplemente puede definir esto con una ==restricción.

Por ejemplo, podríamos escribir su extensión de matriz como:

extension Array where Element == P {
  func test<T>() -> [T] {
    return []
  }
}

let arr: [P] = [S()]
let result: [S] = arr.test()

Por supuesto, esto ahora nos impide llamarlo en una matriz con elementos de tipo concreto que se ajustan a P. Podríamos resolver esto simplemente definiendo una extensión adicional para cuándo Element : Py simplemente avanzar a la == Pextensión:

extension Array where Element : P {
  func test<T>() -> [T] {
    return (self as [P]).test()
  }
}

let arr = [S()]
let result: [S] = arr.test()

Sin embargo, vale la pena señalar que esto realizará una conversión O (n) de la matriz a a [P], ya que cada elemento tendrá que estar en un contenedor existencial. Si el rendimiento es un problema, simplemente puede resolverlo volviendo a implementar el método de extensión. Esta no es una solución completamente satisfactoria; es de esperar que una versión futura del lenguaje incluya una forma de expresar una restricción de 'tipo de protocolo o conforme al tipo de protocolo'.

Antes de Swift 3.1, la forma más general de lograr esto, como lo muestra Rob en su respuesta , es simplemente construir un tipo de contenedor para a [P], en el que luego puede definir sus métodos de extensión.


Pasar una instancia de tipo de protocolo a un marcador de posición genérico restringido

Considere la siguiente situación (artificial, pero no infrecuente):

protocol P {
  var bar: Int { get set }
  func foo(str: String)
}

struct S : P {
  var bar: Int
  func foo(str: String) {/* ... */}
}

func takesConcreteP<T : P>(_ t: T) {/* ... */}

let p: P = S(bar: 5)

// error: Cannot invoke 'takesConcreteP' with an argument list of type '(P)'
takesConcreteP(p)

No podemos pasar pa takesConcreteP(_:), ya que actualmente no podemos sustituir Pa un marcador de posición genérico T : P. Echemos un vistazo a un par de formas en que podemos resolver este problema.

1. Aperturas existenciales

En lugar de intentar sustituir Ppor T : P, qué pasaría si pudiéramos cavar en el tipo de hormigón subyacente de que el Pvalor con tipo era envolver y sustituto que en lugar? Desafortunadamente, esto requiere una función de lenguaje llamada existenciales de apertura , que actualmente no está disponible directamente para los usuarios.

Sin embargo, Swift hace implícitamente existenciales abiertas (valores de protocolo-mecanografiado) cuando se accede a los miembros en ellos (es decir, se excava a cabo el tipo de tiempo de ejecución y hace que sea accesible en forma de un marcador de posición genérico). Podemos explotar este hecho en una extensión de protocolo en P:

extension P {
  func callTakesConcreteP/*<Self : P>*/(/*self: Self*/) {
    takesConcreteP(self)
  }
}

Tenga en cuenta el Selfmarcador de posición genérico implícito que toma el método de extensión, que se utiliza para escribir el selfparámetro implícito ; esto sucede detrás de escena con todos los miembros de extensión de protocolo. Al llamar a un método de este tipo en un valor de protocolo escrito P, Swift desentierra el tipo concreto subyacente y lo utiliza para satisfacer el Selfmarcador de posición genérico. Es por esto que estamos en condiciones de llamar takesConcreteP(_:)a self- estamos satisfaciendo Tcon Self.

Esto significa que ahora podemos decir:

p.callTakesConcreteP()

Y takesConcreteP(_:)se llama con su marcador de posición genérico Tsatisfecho por el tipo de hormigón subyacente (en este caso S). Tenga en cuenta que esto no es "protocolos que se ajustan a sí mismos", ya que estamos sustituyendo un tipo concreto en lugar de P: intente agregar un requisito estático al protocolo y ver qué sucede cuando lo llama desde adentro takesConcreteP(_:).

Si Swift continúa impidiendo que los protocolos se ajusten a sí mismos, la siguiente mejor alternativa sería abrir implícitamente los existenciales al intentar pasarlos como argumentos a parámetros de tipo genérico, efectivamente haciendo exactamente lo que hizo nuestro trampolín de extensión de protocolo, solo sin la placa repetitiva.

Sin embargo, tenga en cuenta que abrir existenciales no es una solución general al problema de los protocolos que no se ajustan a sí mismos. No trata con colecciones heterogéneas de valores tipificados por protocolo, que pueden tener diferentes tipos concretos subyacentes. Por ejemplo, considere:

struct Q : P {
  var bar: Int
  func foo(str: String) {}
}

// The placeholder `T` must be satisfied by a single type
func takesConcreteArrayOfP<T : P>(_ t: [T]) {}

// ...but an array of `P` could have elements of different underlying concrete types.
let array: [P] = [S(bar: 1), Q(bar: 2)]

// So there's no sensible concrete type we can substitute for `T`.
takesConcreteArrayOfP(array) 

Por las mismas razones, una función con múltiples Tparámetros también sería problemática, ya que los parámetros deben tomar argumentos del mismo tipo; sin embargo, si tenemos dos Pvalores, no hay forma de garantizar en el momento de la compilación que ambos tengan el mismo concreto subyacente. tipo.

Para resolver este problema, podemos usar un borrador de tipo.

2. Construye un borrador de tipo

Como dice Rob , un borrador tipo , es la solución más general al problema de los protocolos que no se ajustan a sí mismos. Nos permiten ajustar una instancia de tipo de protocolo en un tipo concreto que se ajusta a ese protocolo, reenviando los requisitos de la instancia a la instancia subyacente.

Entonces, construyamos un cuadro de borrado de tipo que reenvíe Plos requisitos de instancia a una instancia arbitraria subyacente que se ajuste a P:

struct AnyP : P {

  private var base: P

  init(_ base: P) {
    self.base = base
  }

  var bar: Int {
    get { return base.bar }
    set { base.bar = newValue }
  }

  func foo(str: String) { base.foo(str: str) }
}

Ahora podemos hablar en términos de en AnyPlugar de P:

let p = AnyP(S(bar: 5))
takesConcreteP(p)

// example from #1...
let array = [AnyP(S(bar: 1)), AnyP(Q(bar: 2))]
takesConcreteArrayOfP(array)

Ahora, considere por un momento por qué tuvimos que construir esa caja. Como discutimos anteriormente, Swift necesita un tipo concreto para los casos en que el protocolo tiene requisitos estáticos. Considere si Ptuviera un requisito estático: habríamos tenido que implementarlo en AnyP. Pero, ¿cómo debería haberse implementado? Estamos lidiando con instancias arbitrarias que se conforman Paquí: no sabemos cómo sus tipos concretos subyacentes implementan los requisitos estáticos, por lo tanto, no podemos expresar esto de manera significativa AnyP.

Por lo tanto, la solución en este caso solo es realmente útil en el caso de los requisitos de protocolo de instancia . En el caso general, todavía no podemos tratar Pcomo un tipo concreto que se ajuste a P.

Hamish
fuente
2
Tal vez solo estoy siendo denso, pero no entiendo por qué el caso estático es especial. Nosotros (el compilador) sabemos tanto o tan poco sobre la propiedad estática de un prototipo en el momento de la compilación como lo sabemos sobre la propiedad de instancia de un protocolo, es decir, que el adoptante la implementará. Entonces, ¿cuál es la diferencia?
mate
1
@matt Una instancia de tipo de protocolo (es decir, una instancia de tipo concreto envuelta en existencial P) está bien porque simplemente podemos reenviar llamadas a los requisitos de la instancia a la instancia subyacente. Sin embargo, para un tipo de protocolo en sí mismo (es decir P.Protocol, literalmente solo el tipo que describe un protocolo), no hay un adoptante, por lo tanto, no hay nada a lo que recurrir los requisitos estáticos, por lo que en el ejemplo anterior no podemos tener SomeGeneric<P>(es diferente para un P.Type(metatipo existencial), que describe un metatipo concreto de algo que se ajusta a P, pero esa es otra historia)
Hamish
La pregunta que hago en la parte superior de esta página es por qué un adoptante de tipo de protocolo está bien y un tipo de protocolo en sí no. Entiendo que para un tipo de protocolo en sí no hay un adoptante. - Lo que no entiendo es por qué es más difícil reenviar llamadas estáticas al tipo de adopción que reenviar llamadas de instancia al tipo de adopción. Está argumentando que la razón por la que hay una dificultad aquí es debido a la naturaleza de los requisitos estáticos en particular, pero no veo cómo los requisitos estáticos son más difíciles que los requisitos de instancia.
mate
@matt No es que los requisitos estáticos sean "más difíciles" que los requisitos de la instancia: el compilador puede manejar bien a través de los existenciales para las instancias (es decir, las instancias escritas como P) y los metatipos existenciales (es decir, los P.Typemetatipos). El problema es que para los genéricos, en realidad no estamos comparando lo mismo. Cuando Tes así P, no hay ningún tipo de hormigón subyacente (meta) para reenviar los requisitos estáticos a ( Tes un P.Protocol, no un P.Type) ...
Hamish
1
Realmente no me importa la solidez, etc., solo quiero escribir aplicaciones, y si parece que debería funcionar, debería hacerlo. El lenguaje debería ser solo una herramienta, no un producto en sí mismo. Si hay algunos casos en los que realmente no funcionaría, no lo permita en esos casos, pero deje que todos los demás usen los casos para los que funciona y que sigan escribiendo aplicaciones.
Jonathan.
17

Si extiende el CollectionTypeprotocolo en lugar de una Arrayrestricción por protocolo como un tipo concreto, puede volver a escribir el código anterior de la siguiente manera.

protocol P { }
struct S: P { }

let arr:[P] = [ S() ]

extension CollectionType where Generator.Element == P {
    func test<T>() -> [T] {
        return []
    }
}

let result : [S] = arr.test()
Tomohiro Kumagai
fuente
No creo Colección vs matriz es pertinente en este caso, el cambio importante está utilizando == Pvs : P. Con == el ejemplo original también funciona. Y un problema potencial (dependiendo del contexto) con == es que excluye sub-protocolos: si creo una protocol SubP: P, y luego defino arrcomo [SubP]entonces arr.test()ya no funcionarán (error: SUBP y P deben ser equivalentes).
Imre