Programação Orientada a Protocolo, Parte 2

Na continuação do tópico, examinaremos os tipos de protocolo e o código generalizado.


Os seguintes problemas serão considerados ao longo do caminho:


  • implementação de polimorfismo sem herança e tipos de referência
  • como os objetos do tipo protocolo são armazenados e usados
  • como o envio do método funciona com eles

Tipos de protocolo


Implementação de polimorfismo sem herança e tipos de referência:


protocol Drawable { func draw() } struct Point: Drawable { var x, y: Int func draw() { ... } } struct Line: Drawable { var x1, x2, y1, y2: Int func draw() { ... } } var drawbles = [Drawable]() for d in drawbles { d.draw() } 

  1. Indique o protocolo Drawable, que possui um método draw.
  2. Implementamos este protocolo para Ponto e Linha - agora você pode lidar com eles como no Drawable (chame o método draw)

Ainda temos um código polimórfico. O elemento d da matriz drawables possui uma interface, que é indicada no protocolo Drawable, mas possui implementações diferentes de seus métodos, indicadas em Line e Point.


O principal princípio (ad-hoc) do polimorfismo: "Interface comum - muitas implementações"

Despacho dinâmico sem tabela virtual


Lembre-se de que a definição da implementação correta do método ao trabalhar com classes (tipos de referência) é alcançada por meio do envio dinâmico e de uma tabela virtual. Cada tipo de classe possui uma tabela virtual e armazena implementações de seus métodos. O despacho dinâmico define a implementação do método para um tipo, examinando sua tabela virtual. Tudo isso é necessário devido à possibilidade de herança e substituição de métodos.


No caso de estruturas, a herança e a redefinição de métodos são impossíveis. Então, à primeira vista, não há necessidade de uma tabela virtual, mas como então o despacho dinâmico funcionará? Como um programa pode entender qual método será chamado em d.draw ()?


Vale ressaltar que o número de implementações desse método é igual ao número de tipos que estão em conformidade com o protocolo Drawable.

Tabela de testemunhas de protocolo


é a resposta para esta pergunta. Cada tipo que implementa um protocolo possui esta tabela. Como uma tabela virtual para classes, ela armazena implementações dos métodos exigidos pelo protocolo.


daqui em diante, a Tabela de Testemunha de Protocolo será chamada de “tabela de método de protocolo”

Ok, agora sabemos onde procurar implementações de métodos. Apenas duas perguntas permanecem:


  1. Como encontrar a tabela de método de protocolo apropriada para um objeto que implementou esse protocolo? Como, no nosso caso, encontrar esta tabela para o elemento d da matriz de drawables?
  2. Os elementos da matriz devem ter o mesmo tamanho (essa é a essência da matriz). Então, como um array desenhável pode atender a esse requisito se ele pode armazenar linhas e pontos nele, e eles têm tamanhos diferentes?

 MemoryLayout.size(ofValue: Line(...)) // 32 bits MemoryLayout.size(ofValue: Point(...)) // 16 bits 

Contêiner existente


Para resolver esses dois problemas, o Swift usa um esquema de armazenamento especial para instâncias de tipos de protocolo chamados de contêiner existencial. É assim:



São necessárias 5 palavras de máquina (no sistema x64 bits 5 * 8 = 40 bits). É dividido em três partes:


buffer de valor - espaço para a própria instância
vwt - ponteiro para a tabela de testemunhas de valor
pwt - ponteiro para a tabela de testemunhas de protocolo


Considere todas as três partes em mais detalhes:


Buffer de Conteúdo


Apenas três palavras-máquina para armazenar uma instância. Se a instância puder caber no buffer de conteúdo, ela será armazenada nele. Se a instância tiver mais de três palavras de máquina, ela não caberá no buffer e o programa será forçado a alocar memória no heap, colocar a instância lá e colocar um ponteiro para essa memória no buffer de conteúdo. Considere um exemplo:


 let point: Drawable = Point(...) 

O Point () ocupa 2 palavras de máquina e se encaixa perfeitamente no buffer de valor - o programa o colocará lá:



 let line: Drawable = Line(...) 

Line () ocupa 4 palavras de máquina e não pode caber em um buffer de valor - o programa alocará memória para ele para heap e adicionará um ponteiro a essa memória no buffer de valor:



ptr aponta para uma instância de Line () colocada na pilha:



Tabela do ciclo de vida


Assim como a tabela de método de protocolo, cada tabela que possui o protocolo possui essa tabela. Ele contém uma implementação de quatro métodos: alocar, copiar, destruir, desalocar. Esses métodos controlam todo o ciclo de vida de um objeto. Considere um exemplo:


  1. Ao criar um objeto (Ponto (...) como Drawable), o método de alocação de T.Zh. esse objeto. O método de alocação decidirá onde o conteúdo do objeto deve ser colocado (no buffer de valor ou na pilha) e, se for colocado na pilha, alocará a quantidade necessária de memória
  2. O método de cópia colocará o conteúdo do objeto no local apropriado.
  3. Após concluir o trabalho com o objeto, o método destruct será chamado, o que reduzirá todas as contagens de links, se houver
  4. Após a destruição, o método de desalocação será chamado, o que liberará a memória alocada no heap, se houver

Tabela de método de protocolo


Como descrito acima, ele contém implementações dos métodos exigidos pelo protocolo para o tipo ao qual esta tabela está vinculada.


Contêiner Existencial - Respostas


Assim, respondemos a duas perguntas colocadas:


  1. A tabela de método de protocolo é armazenada no contêiner existencial desse objeto e pode ser facilmente obtida dele
  2. Se o tipo de elemento da matriz for um protocolo, qualquer elemento dessa matriz terá um valor fixo de 5 palavras de máquina - é exatamente isso que é necessário para um contêiner Existencial. Se o conteúdo do elemento não puder ser colocado no buffer de valor, ele será colocado no heap. Se possível, todo o conteúdo será colocado no buffer de valor. De qualquer forma, concluímos que o tamanho do objeto com o tipo de protocolo é de 5 palavras-máquina (40 bits), e segue-se que todos os elementos da matriz terão o mesmo tamanho.

 let line: Drawable = Line(...) MemoryLayout.size(ofValue: line) // 40 bits let drawables: [Drawable] = [Line(...), Point(...), Line(...)] MemoryLayout.size(ofValue: drawables._content) // 120 bits 

Contêiner Existencial - Exemplo


Considere o comportamento de um contêiner existencial neste código:


 func drawACopy(local: Drawable) { local.draw() } let val: Drawable = Line(...) drawACopy(val) 

Um contêiner existencial pode ser representado assim:


 struct ExistContDrawable { var valueBuffer: (Int, Int, Int) var vwt: ValueWitnessTable var pwt: ProtocolWitnessTable } 

Pseudo código


Nos bastidores, a função drawACopy recebe ExistContDrawable:


 func drawACopy(val: ExistContDrawable) { ... } 

O parâmetro da função é criado manualmente: crie um contêiner, preencha seus campos a partir do argumento recebido:


 func drawACopy(val: ExistContDrawable) { var local = ExistContDrawable() let vwt = val.vwt let pwt = val.pwt local.type = type local.pwt = pwt ... } 

Decidimos onde o conteúdo será armazenado (no buffer ou na pilha). Chamamos vwt.allocate e vwt.copy para preencher o conteúdo local com val:


 func drawACopy(val: ExistContDrawable) { ... vwt.allocateBufferAndCopy(&local, val) } 

Chamamos o método draw e passamos um ponteiro para self (o método projectBuffer decidirá onde o self está localizado - no buffer ou na pilha - e retornará o ponteiro correto):


 func drawACopy(val: ExistContDrawable) { ... pwt.draw(vwt.projectBuffer(&local)) } 

Terminamos o trabalho com o local. Limpamos todos os links de quadril do local. A função retorna um valor - limpamos toda a memória alocada para drawACopy (quadro da pilha):


 func drawACopy(val: ExistContDrawable) { ... vwt.destructAndDeallocateBuffer(&local) } 

Contêiner Existencial - Finalidade


Usar um contêiner existencial requer muito trabalho - o exemplo acima confirmou isso - mas por que é necessário, qual é o objetivo? O objetivo é implementar o polimorfismo usando protocolos e os tipos que os implementam. No OOP, usamos classes abstratas e as herdamos substituindo métodos. No EPP, usamos protocolos e implementamos seus requisitos. Novamente, mesmo com protocolos, implementar o polimorfismo é um trabalho grande e que consome energia. Portanto, para evitar trabalho "desnecessário", você precisa entender quando o polimorfismo é necessário e quando não.


O polimorfismo na implementação do EPP vence porque, usando estruturas, não precisamos de contagens constantes de referências, não há herança de classe. Sim, tudo é muito parecido, as classes usam uma tabela virtual para determinar a implementação de um método, os protocolos usam o método de protocolo. As aulas são colocadas na pilha, às vezes as estruturas também podem ser colocadas lá. Mas o problema é que o maior número possível de ponteiros pode ser direcionado para a classe colocada na pilha, e a contagem de referência é necessária, e apenas um ponteiro para as estruturas colocadas na pilha e ele é armazenado em um contêiner existencial.


De fato, é importante observar que uma estrutura que é armazenada em um contêiner existencial manterá a semântica dos tipos de valor, independentemente de ser colocada na pilha ou na pilha. A Tabela de Ciclo de Vida é responsável pela preservação da semântica, pois descreve métodos que determinam a semântica.


Contêiner Existencial - Propriedades Armazenadas


Examinamos como uma variável do tipo protocolo é passada e usada por uma função. Vamos considerar como essas variáveis ​​são armazenadas:


 struct Pair { init(_ f: Drawable, _ s: Drawable) { first = f second = s } var first: Drawable var second: Drawable } var pair = Pair(Line(), Point()) 

Como essas duas estruturas Drawable são armazenadas dentro da estrutura Pair? Qual é o conteúdo do par? Consiste em dois contêineres existenciais - um para o primeiro e outro para o segundo. A linha não pode caber no buffer e é colocada no heap. Ponto de ajuste no buffer. Também permite que a estrutura Pair armazene objetos de tamanhos diferentes:


 pair.second = Line() 

Agora, o conteúdo do segundo também é colocado no heap, pois ele não se encaixava no buffer. Considere o que isso pode levar a:


 let aLine = Line(...) let pair = Pair(aLine, aLine) let copy = pair 

Após executar este código, o programa receberá o seguinte status de memória:



Temos quatro alocações de memória no heap, o que não é bom. Vamos tentar consertar:


  1. Criar uma linha de classe analógica

 class LineStorage: Drawable { var x1, y1, x2, y2: Double func draw() {} } 

  1. Usamos em par

 let lineStorage = LineStorage(...) let pair = Pair(lineStorage, lineStorage) let copy = pair 

Temos um posicionamento no heap e 4 ponteiros para ele:



Mas estamos lidando com comportamento referencial. Alterar copy.first afetará pair.first (o mesmo para .second), que nem sempre é o que queremos.


Armazenamento indireto e cópia na alteração (copiar na gravação)


Antes disso, foi mencionado que String é uma estrutura de cópia na gravação (armazena seu conteúdo no heap e o copia quando ele muda). Considere como você pode implementar sua estrutura, que é copiada ao alterar:


 struct BetterLine: Drawable { private var storage: LineStorage init() { storage = LineStorage((0, 0), (10, 10)) } func draw() -> Double { ... } mutating func move() { if !isKnownUniquelyReferenced(&storage) { storage = LineStorage(self.storage) } // storage editing } } 

  1. O BetterLine armazena todas as propriedades no armazenamento, e o armazenamento é uma classe e é armazenado no heap.
  2. O armazenamento só pode ser alterado usando o método de movimentação. Nele, verificamos que apenas um ponteiro aponta para o armazenamento. Se houver mais indicadores, este BetterLine compartilhará o armazenamento com alguém e, para que o BetterLine se comporte completamente como uma estrutura, o armazenamento deve ser individual - fazemos uma cópia e trabalhamos com ele no futuro.

Vamos ver como isso funciona na memória:


 let aLine = BetterLine() let pair = Pair(aLine, aLine) let copy = pair copy.second.x1 = 3.0 

Como resultado da execução desse código, obtemos:



Em outras palavras, temos duas instâncias de Pair que compartilham o mesmo armazenamento: LineStorage. Ao alterar o armazenamento em um de seus usuários (primeiro / segundo), uma cópia separada do armazenamento para esse usuário será criada para que a alteração não afete outros. Isso resolve o problema de violação da semântica dos tipos de valor do exemplo anterior.


Tipos de protocolo - Resumo


  1. Valores pequenos . Se trabalharmos com objetos que ocupam pouca memória e podem ser colocados no buffer de um contêiner existencial, então:

  • não haverá posicionamento na pilha
  • sem contagem de referência
  • polimorfismo (envio dinâmico) usando uma tabela de protocolo

  1. Ótimo valor. Se trabalharmos com objetos que não cabem no buffer, então:

  • posicionamento de pilha
  • referência contando se os objetos contêm links.

Os mecanismos de uso da reescrita para alteração e armazenamento indireto foram demonstrados e podem melhorar significativamente a situação com a contagem de referência no caso de um grande número deles.

Descobrimos que tipos de protocolo, como classes, são capazes de realizar polimorfismos. Isso acontece armazenando em um contêiner existencial e usando tabelas de protocolo - tabelas de ciclo de vida e tabelas de método de protocolo.

Source: https://habr.com/ru/post/pt474558/


All Articles