Protocolo não se conforma a si mesmo?

125

Por que esse código Swift não é compilado?

protocol P { }
struct S: P { }

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

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

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

O compilador diz: "O tipo Pnão está em conformidade com o protocolo P" (ou, em versões posteriores do Swift, "não é suportado o uso de 'P' como um tipo concreto em conformidade com o protocolo 'P'.").

Por que não? Isso parece um buraco na linguagem, de alguma forma. Sei que o problema decorre de declarar a matriz arrcomo uma matriz de um tipo de protocolo , mas isso não é razoável? Eu pensei que os protocolos existiam exatamente para ajudar a fornecer estruturas com algo como uma hierarquia de tipos?

mate
fonte
1
Quando você remove a anotação de tipo na let arrlinha, o compilador deduz o tipo [S]e o código é compilado. Parece que um tipo de protocolo não pode ser usado da mesma maneira que um relacionamento de classe - superclasse.
Vadian
1
@ Vadian Correto, é a isso que eu estava me referindo na minha pergunta quando disse "Percebo que o problema decorre de declarar a matriz arr como uma matriz de um tipo de protocolo". Mas, como eu continuo dizendo na minha pergunta, o ponto principal dos protocolos geralmente é que eles podem ser usados ​​da mesma maneira que um relacionamento de superclasse de classe! Eles pretendem fornecer uma espécie de estrutura hierárquica ao mundo das estruturas. E eles costumam fazer. A questão é: por que isso não deveria funcionar aqui ?
Matt
1
Ainda não funciona no Xcode 7.1, mas a mensagem de erro agora é "usando 'P' como um tipo concreto em conformidade com o protocolo 'P' não é suportado" .
Martin R
1
@ MartinR É uma mensagem de erro melhor. Mas ainda me parece um buraco na linguagem.
Matt
Certo! Mesmo com protocol P : Q { }, P não está em conformidade com Q.
Martin R

Respostas:

66

Edição: Mais dezoito meses de trabalho com Swift, outra versão importante (que fornece um novo diagnóstico) e um comentário de @AyBayBay me faz querer reescrever esta resposta. O novo diagnóstico é:

"Usar 'P' como um tipo concreto em conformidade com o protocolo 'P' não é suportado."

Isso realmente torna tudo muito mais claro. Esta extensão:

extension Array where Element : P {

não se aplica quando, Element == Pdesde que, Pnão é considerado uma conformidade concreta de P. (A solução "coloque em uma caixa" abaixo ainda é a solução mais geral.)


Resposta antiga:

É mais um caso de metatipos. Swift realmente quer que você chegue a um tipo concreto para a maioria das coisas não triviais. [P]não é um tipo concreto (não é possível alocar um bloco de memória de tamanho conhecido P). (Eu não acho que isso seja verdade; você pode absolutamente criar algo de tamanho, Pporque isso é feito por meio de indireção .) Parece muito com um dos casos "ainda não funciona". (Infelizmente, é quase impossível conseguir que a Apple confirme a diferença entre esses casos.) O fato de que Array<P>pode ser um tipo variável (em queArraynão pode) indica que eles já fizeram algum trabalho nessa direção, mas os metatipos Swift têm muitas arestas vivas e casos não implementados. Eu não acho que você terá uma resposta melhor do "por que" do que isso. "Porque o compilador não permite." (Insatisfatório, eu sei. Toda a minha vida rápida ...)

A solução é quase sempre colocar as coisas em uma caixa. Construímos uma borracha de tipo.

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()

Quando o Swift permite que você faça isso diretamente (o que eu espero, eventualmente), provavelmente será apenas criando essa caixa para você automaticamente. As enumerações recursivas tinham exatamente essa história. Você tinha que encaixotá-los e isso era incrivelmente irritante e restritivo, e finalmente o compilador adicionou indirectpara fazer a mesma coisa mais automaticamente.

Rob Napier
fonte
Muitas informações úteis nesta resposta, mas a solução real na resposta de Tomohiro é melhor do que a solução de boxe apresentada aqui.
Jsadler #
@jsadler A questão não era como solucionar a limitação, mas por que ela existe. De fato, no que diz respeito à explicação, a solução alternativa de Tomohiro levanta mais perguntas do que respostas. Se usarmos ==no meu exemplo Array, teremos um erro, a exigência do tipo Same faz parâmetro genérico 'elemento' não-genérico "Por que não uso do do Tomohiro. ==Gerar o mesmo erro?
Matt
@ Rob Napier Ainda estou perplexo com a sua resposta. Como o Swift vê mais concretude em sua solução do que o original? Você parecia ter as coisas simplesmente envolto em um struct ... Idk talvez eu estou lutando para entender o sistema de tipo rápida, mas tudo isso parece voodoo mágica
AyBayBay
@AyBayBay Resposta atualizada.
Rob Napier
Muito obrigado @RobNapier Fico sempre impressionado com a velocidade das suas respostas e, francamente, como você encontra tempo para ajudar as pessoas tanto quanto você. No entanto, suas novas edições definitivamente o colocam em perspectiva. Mais uma coisa que eu gostaria de salientar, entender o apagamento de tipo também me ajudou. Este artigo em particular fez um trabalho fantástico: krakendev.io/blog/generic-protocols-and-their-shortcomings TBH Idk como me sinto em relação a algumas dessas coisas. Parece que estamos respondendo a buracos no idioma, mas não sei como a Apple criaria um pouco disso.
AyBayBay
109

Por que os protocolos não se conformam?

Permitir que os protocolos se ajustem a si mesmos no caso geral não é válido. O problema está nos requisitos de protocolo estático.

Esses incluem:

  • static métodos e propriedades
  • Inicializadores
  • Tipos associados (embora atualmente evitem o uso de um protocolo como um tipo real)

Podemos acessar esses requisitos em um espaço reservado genérico Tonde T : P- no entanto, não podemos acessá-los no próprio tipo de protocolo, pois não há um tipo de conformidade concreto para o qual encaminhar. Portanto, não podemos permitir Tque seja P.

Considere o que aconteceria no exemplo a seguir se permitirmos que a Arrayextensão seja aplicável 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()

Não é possível chamar appendNew()a [P], porque P(the Element) não é um tipo concreto e, portanto, não pode ser instanciado. Ele deve ser chamado em uma matriz com elementos do tipo concreto, onde esse tipo está em conformidade P.

É uma história semelhante com requisitos de método e propriedade estáticos:

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()

Não podemos falar em termos de SomeGeneric<P>. Precisamos de implementações concretas dos requisitos do protocolo estático (observe como não implementações foo()ou bardefinidas no exemplo acima). Embora possamos definir implementações desses requisitos em uma Pextensão, eles são definidos apenas para os tipos concretos em conformidade P- você ainda não pode chamá-los por Psi.

Por causa disso, o Swift nos impede completamente de usar um protocolo como um tipo que se adapta a si mesmo - porque quando esse protocolo tem requisitos estáticos, não.

Os requisitos do protocolo da instância não são problemáticos, pois você deve chamá-los em uma instância real que esteja em conformidade com o protocolo (e, portanto, deve ter implementado os requisitos). Portanto, ao chamar um requisito em uma instância digitada como P, podemos apenas encaminhar essa chamada para a implementação desse tipo do tipo concreto subjacente.

No entanto, abrir exceções especiais para a regra nesse caso pode levar a inconsistências surpreendentes no modo como os protocolos são tratados pelo código genérico. Embora isso tenha sido dito, a situação não é muito diferente dos associatedtyperequisitos - o que (atualmente) impede que você use um protocolo como um tipo. Ter uma restrição que o impeça de usar um protocolo como um tipo que se adapta a si mesmo quando possui requisitos estáticos pode ser uma opção para uma versão futura do idioma

Edit: E, como explorado abaixo, isso parece com o que a equipe Swift está buscando.


@objc protocolos

Na verdade, é exatamente assim que a linguagem trata os @objcprotocolos. Quando eles não têm requisitos estáticos, eles se adaptam a si mesmos.

O seguinte compila perfeitamente:

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)

bazrequer que Testeja em conformidade com P; mas podemos substituir em Ppara Tporque Pnão tem requisitos estáticos. Se adicionarmos um requisito estático P, o exemplo não será mais compilado:

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)'

Portanto, uma solução alternativa para esse problema é criar seu protocolo @objc. Concedido, isso não é uma solução ideal em muitos casos, pois força os tipos conformes a serem classes, além de exigir o tempo de execução do Obj-C, portanto, não o torna viável em plataformas que não sejam da Apple, como o Linux.

Mas suspeito que essa limitação é (uma das) principais razões pelas quais a linguagem já implementa 'protocolo sem requisitos estáticos em conformidade com ela mesma' para @objcprotocolos. O código genérico escrito em torno deles pode ser significativamente simplificado pelo compilador.

Por quê? Como @objcos valores digitados no protocolo são efetivamente apenas referências de classe cujos requisitos são despachados usando objc_msgSend. Por outro lado, os @objcvalores que não são do tipo protocolo são mais complicados, pois carregam tabelas de valores e de testemunhas para gerenciar a memória de seu valor empacotado (armazenado de maneira indireta) e determinar quais implementações exigem os diferentes requisitos, respectivamente.

Devido a essa representação simplificada para @objcprotocolos, um valor desse tipo de protocolo Ppode compartilhar a mesma representação de memória que um 'valor genérico' do tipo de algum espaço reservado genérico T : P, provavelmente facilitando para a equipe Swift permitir a auto-conformidade. O mesmo não se aplica a não @objcprotocolos, no entanto, como esses valores genéricos atualmente não possuem tabelas de testemunhas de valor ou protocolo.

No entanto, esse recurso é intencional e espera-se que seja implementado em não @objcprotocolos, conforme confirmado pelo membro da equipe Swift Slava Pestov nos comentários do SR-55 em resposta à sua pergunta sobre o assunto (solicitado por esta pergunta ):

Matt Neuburg adicionou um comentário - 7 set 2017 13:33

Isso 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) }

Adicionar @objcfaz com que seja compilado; removê-lo não compila novamente. Alguns de nós, no Stack Overflow, acham isso surpreendente e gostariam de saber se isso é deliberado ou se é um caso de buggy.

Slava Pestov adicionou um comentário - 7 set 2017 13:53

É deliberado - é o levantamento dessa restrição que trata esse bug. Como eu disse, é complicado e ainda não temos planos concretos.

Então, espero que seja algo que a linguagem um dia também suporte para não @objcprotocolos.

Mas que soluções atuais existem para não @objcprotocolos?


Implementando extensões com restrições de protocolo

No Swift 3.1, se você deseja uma extensão com uma restrição de que um determinado espaço reservado genérico ou tipo associado deve ser um determinado tipo de protocolo (não apenas um tipo concreto que esteja em conformidade com esse protocolo) - você pode simplesmente defini-lo com uma ==restrição.

Por exemplo, poderíamos escrever sua extensão de matriz como:

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

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

Obviamente, isso agora nos impede de chamá-lo em uma matriz com elementos do tipo concreto em conformidade P. Para resolver isso, basta definir uma extensão adicional para quando Element : Pe avançar para a == Pextensão:

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

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

No entanto, vale a pena notar que isso executará uma conversão O (n) da matriz em a [P], pois cada elemento precisará ser encaixotado em um contêiner existencial. Se o desempenho for um problema, você pode simplesmente resolver isso reimplementando o método de extensão. Essa não é uma solução totalmente satisfatória - espero que uma versão futura da linguagem inclua uma maneira de expressar uma restrição de 'tipo de protocolo ou conforme o tipo de protocolo'.

Antes do Swift 3.1, a maneira mais geral de conseguir isso, como Rob mostra em sua resposta , é simplesmente criar um tipo de wrapper para a [P], no qual você pode definir o (s) método (s) de extensão.


Passando uma instância do tipo de protocolo para um espaço reservado genérico restrito

Considere a seguinte situação (artificial, mas não incomum):

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)

Não podemos passar ppara takesConcreteP(_:), pois atualmente não podemos substituir Pum espaço reservado genérico T : P. Vamos dar uma olhada em algumas maneiras pelas quais podemos resolver esse problema.

1. Abrir existenciais

Ao invés de tentar substituir Ppara T : P, o que se poderia cavar o tipo concreto subjacente de que o Pvalor digitado foi acondicionamento e substituto que, em vez? Infelizmente, isso requer um recurso de idioma chamado abertura existencial , que atualmente não está diretamente disponível para os usuários.

No entanto, o Swift abre implicitamente existenciais (valores do tipo protocolo) ao acessar membros neles (ou seja, ele extrai o tipo de tempo de execução e o torna acessível na forma de um espaço reservado genérico). Podemos explorar esse fato em uma extensão de protocolo em P:

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

Observe o Selfespaço reservado genérico implícito usado pelo método de extensão, que é usado para digitar o selfparâmetro implícito - isso acontece nos bastidores com todos os membros da extensão de protocolo. Ao chamar esse método em um valor digitado em protocolo P, Swift desenterra o tipo concreto subjacente e o usa para satisfazer o Selfespaço reservado genérico. É por isso que somos capazes de chamar takesConcreteP(_:)com self- estamos satisfazendo Tcom Self.

Isso significa que agora podemos dizer:

p.callTakesConcreteP()

E takesConcreteP(_:)é chamado com seu espaço reservado genérico Tsendo satisfeito pelo tipo concreto subjacente (neste caso S). Observe que isso não é "protocolos em conformidade com eles mesmos", pois estamos substituindo um tipo concreto em vez de P- tente adicionar um requisito estático ao protocolo e ver o que acontece quando você o chama de dentro takesConcreteP(_:).

Se o Swift continuar a proibir a conformidade dos protocolos, a próxima melhor alternativa seria abrir implicitamente os existenciais ao tentar passá-los como argumentos para parâmetros do tipo genérico - efetivamente fazendo exatamente o que nosso trampolim de extensão de protocolo fez, apenas sem o clichê.

No entanto, observe que abrir existenciais não é uma solução geral para o problema de protocolos que não estão em conformidade. Ele não lida com coleções heterogêneas de valores do tipo protocolo, que podem todos ter tipos concretos subjacentes diferentes. Por exemplo, 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) 

Pelas mesmas razões, uma função com vários Tparâmetros também seria problemática, pois os parâmetros devem receber argumentos do mesmo tipo - no entanto, se tivermos dois Pvalores, não há como garantir em tempo de compilação que ambos tenham o mesmo concreto subjacente tipo.

Para resolver esse problema, podemos usar um apagador de tipo.

2. Crie uma borracha de tipo

Como diz Rob , uma borracha de tipo , é a solução mais geral para o problema de protocolos que não estão em conformidade. Eles nos permitem agrupar uma instância do tipo de protocolo em um tipo concreto que esteja em conformidade com esse protocolo, encaminhando os requisitos da instância para a instância subjacente.

Portanto, vamos criar uma caixa de exclusão de tipo que encaminhe Pos requisitos da instância para uma instância arbitrária subjacente que esteja em conformidade com 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) }
}

Agora podemos apenas falar em termos de, em AnyPvez 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)

Agora, considere por um momento exatamente por que tivemos que construir essa caixa. Como discutimos anteriormente, o Swift precisa de um tipo concreto para os casos em que o protocolo possui requisitos estáticos. Considere se Ptivesse um requisito estático - precisaríamos implementá-lo AnyP. Mas como deveria ter sido implementado? Estamos lidando com instâncias arbitrárias que estão em conformidade com Paqui - não sabemos como seus tipos concretos subjacentes implementam os requisitos estáticos; portanto, não podemos expressar isso de maneira significativa AnyP.

Portanto, a solução nesse caso é realmente realmente útil no caso de requisitos de protocolo de instância . No caso geral, ainda não podemos tratar Pcomo um tipo concreto em conformidade P.

Hamish
fonte
2
Talvez eu esteja apenas sendo denso, mas não entendo por que o caso estático é especial. Nós (o compilador) sabemos tanto ou pouco sobre a propriedade estática de um prototol em tempo de compilação quanto sabemos sobre a propriedade da instância de um protocolo, ou seja, que o adotante a implementará. Então qual a diferença?
18717 matt-
1
@matt Uma instância do tipo de protocolo (ou seja, instância do tipo concreta envolvida em existencial P) é boa porque podemos encaminhar chamadas para os requisitos da instância para a instância subjacente. No entanto, para um tipo de protocolo propriamente dito (ou seja P.Protocol, literalmente, apenas o tipo que descreve um protocolo) - não há adotante; portanto, não há nada para chamar os requisitos estáticos, e é por isso que no exemplo acima não podemos ter SomeGeneric<P>(é diferente para um P.Type(metatype existencial), que descreve um metatype concreto de algo que está em conformidade com P- mas isso é outra história)
Hamish
A pergunta que faço na parte superior desta página é por que um adotante de tipo de protocolo é bom e um tipo de protocolo não é. Entendo que, para um tipo de protocolo em si, não há adotante. - O que não entendo é por que é mais difícil encaminhar chamadas estáticas para o tipo adotante do que encaminhar chamadas de instância para o tipo adotante. Você está argumentando que a razão pela qual há uma dificuldade aqui é devido à natureza dos requisitos estáticos em particular, mas não vejo como os requisitos estáticos são mais difíceis do que os requisitos da instância.
Matt
@matt Não é que os requisitos estáticos sejam "mais difíceis" do que os requisitos da instância - o compilador pode lidar tanto com existenciais para instâncias (por exemplo, instância digitada como P) quanto com metatipos existenciais (por exemplo, P.Typemetatipos). O problema é que, para os genéricos - não estamos realmente comparando igual para igual. Quando Té P, não há nenhum tipo underyling concreto (meta) para requisitos estáticos frente para ( Té um P.Protocol, não um P.Type) ....
Hamish
1
Realmente não me importo com a solidez, etc., só quero escrever aplicativos e, se parecer que deve funcionar, deve. A linguagem deve ser apenas uma ferramenta, não um produto em si. Se houver alguns casos em que realmente não funcionaria, então não o permita, mas permita que todos os outros usem os casos para os quais trabalha e que continuem escrevendo aplicativos.
Jonathan.
17

Se você estender o CollectionTypeprotocolo em vez de Arraye restringir por protocolo como um tipo concreto, poderá reescrever o código anterior da seguinte maneira.

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
fonte
Eu não acho Coleção vs Array é relevante aqui, o importante mudança está usando == Pvs : P. Com == o exemplo original também funciona. E um potencial problema (dependendo do contexto) com == é que exclui sub-protocolos: se eu criar um protocol SubP: P, e, em seguida, definir arrcomo [SubP], em seguida, arr.test()não funciona mais (erro: SUBP e P deve ser equivalente).
imre 18/02