Go: proposta: especificação: adicionar tipos de soma / uniões discriminadas

Criado em 6 mar. 2017  ·  320Comentários  ·  Fonte: golang/go

Esta é uma proposta para tipos de soma, também conhecidos como sindicatos discriminados. Os tipos de soma no Go devem agir essencialmente como interfaces, exceto que:

  • eles são tipos de valor, como structs
  • os tipos contidos neles são fixados em tempo de compilação

Os tipos de soma podem ser combinados com uma instrução switch. O compilador verifica se todas as variantes correspondem. Dentro dos braços da instrução switch, o valor pode ser usado como se fosse da variante que foi correspondida.

Go2 LanguageChange NeedsInvestigation Proposal

Comentários muito úteis

Obrigado por criar esta proposta. Estou brincando com essa ideia há mais ou menos um ano.
O que se segue é o que tenho de uma proposta concreta. eu penso
"tipo de escolha" pode ser um nome melhor do que "tipo de soma", mas YMMV.

Tipos de soma em Go

Um tipo de soma é representado por dois ou mais tipos combinados com "|"
operador.

type: type1 | type2 ...

Os valores do tipo resultante podem conter apenas um dos tipos especificados. o
tipo é tratado como um tipo de interface - seu tipo dinâmico é o do
valor que é atribuído a ele.

Como um caso especial, "nil" pode ser usado para indicar se o valor pode
torna-se nulo.

Por exemplo:

type maybeInt nil | int

O conjunto de métodos do tipo soma mantém a interseção do conjunto de métodos
de todos os seus tipos de componentes, excluindo quaisquer métodos que tenham o mesmo
nome, mas assinaturas diferentes.

Como qualquer outro tipo de interface, o tipo de soma pode estar sujeito a uma dinâmica
conversão de tipo. Em interruptores de tipo, o primeiro braço do interruptor que
corresponde ao tipo armazenado será escolhido.

O valor zero de um tipo de soma é o valor zero do primeiro tipo em
a soma.

Ao atribuir um valor a um tipo de soma, se o valor pode caber em mais
de um dos tipos possíveis, então o primeiro é escolhido.

Por exemplo:

var x int|float64 = 13

resultaria em um valor com tipo dinâmico int, mas

var x int|float64 = 3.13

resultaria em um valor com tipo dinâmico float64.

Implementação

Uma implementação ingênua poderia implementar tipos de soma exatamente como interface
valores. Uma abordagem mais sofisticada poderia usar uma representação
apropriado ao conjunto de valores possíveis.

Por exemplo, um tipo de soma consistindo apenas em tipos concretos sem ponteiros
pode ser implementado com um tipo não-ponteiro, usando um valor extra para
lembre-se do tipo real.

Para sum-of-struct-types, pode até ser possível usar preenchimento sobressalente
bytes comuns às estruturas para esse fim.

Todos 320 comentários

Isso foi discutido várias vezes no passado, começando antes do lançamento do código aberto. O consenso anterior era que os tipos de soma não acrescentam muito aos tipos de interface. Depois de resolver tudo, o que você obtém no final é um tipo de interface onde o compilador verifica se você preencheu todos os casos de uma mudança de tipo. Esse é um benefício bastante pequeno para uma nova mudança de idioma.

Se quiser levar essa proposta adiante, você precisará escrever um documento de proposta mais completo, incluindo: Qual é a sintaxe? Exatamente como eles funcionam? (Você diz que eles são "tipos de valor", mas os tipos de interface também são tipos de valor). Quais são as vantagens e desvantagens?

Acho que é uma mudança muito significativa do sistema de tipo para Go1 e não há necessidade de urgência.
Sugiro que revisitemos isso no contexto mais amplo do Go 2.

Obrigado por criar esta proposta. Estou brincando com essa ideia há mais ou menos um ano.
O que se segue é o que tenho de uma proposta concreta. eu penso
"tipo de escolha" pode ser um nome melhor do que "tipo de soma", mas YMMV.

Tipos de soma em Go

Um tipo de soma é representado por dois ou mais tipos combinados com "|"
operador.

type: type1 | type2 ...

Os valores do tipo resultante podem conter apenas um dos tipos especificados. o
tipo é tratado como um tipo de interface - seu tipo dinâmico é o do
valor que é atribuído a ele.

Como um caso especial, "nil" pode ser usado para indicar se o valor pode
torna-se nulo.

Por exemplo:

type maybeInt nil | int

O conjunto de métodos do tipo soma mantém a interseção do conjunto de métodos
de todos os seus tipos de componentes, excluindo quaisquer métodos que tenham o mesmo
nome, mas assinaturas diferentes.

Como qualquer outro tipo de interface, o tipo de soma pode estar sujeito a uma dinâmica
conversão de tipo. Em interruptores de tipo, o primeiro braço do interruptor que
corresponde ao tipo armazenado será escolhido.

O valor zero de um tipo de soma é o valor zero do primeiro tipo em
a soma.

Ao atribuir um valor a um tipo de soma, se o valor pode caber em mais
de um dos tipos possíveis, então o primeiro é escolhido.

Por exemplo:

var x int|float64 = 13

resultaria em um valor com tipo dinâmico int, mas

var x int|float64 = 3.13

resultaria em um valor com tipo dinâmico float64.

Implementação

Uma implementação ingênua poderia implementar tipos de soma exatamente como interface
valores. Uma abordagem mais sofisticada poderia usar uma representação
apropriado ao conjunto de valores possíveis.

Por exemplo, um tipo de soma consistindo apenas em tipos concretos sem ponteiros
pode ser implementado com um tipo não-ponteiro, usando um valor extra para
lembre-se do tipo real.

Para sum-of-struct-types, pode até ser possível usar preenchimento sobressalente
bytes comuns às estruturas para esse fim.

@rogpeppe Como isso interagiria com asserções de tipo e opções de tipo? Presumivelmente, seria um erro em tempo de compilação ter case em um tipo (ou afirmação para um tipo) que não é membro da soma. Também seria um erro ter um switch não exaustivo em tal tipo?

Para interruptores de tipo, se você tiver

type T int | interface{}

e você faz:

switch t := t.(type) {
  case int:
    // ...

e t contém uma interface {} contendo um int, ele corresponde ao primeiro caso? E se o primeiro caso for case interface{} ?

Ou os tipos de soma podem conter apenas tipos concretos?

E quanto a type T interface{} | nil ? Se você escrever

var t T = nil

qual é o tipo de t? Ou essa construção é proibida? Uma questão semelhante surge para type T []int | nil , portanto, não se trata apenas de interfaces.

Sim, acho que seria razoável ter um erro de tempo de compilação
ter um caso que não pode ser correspondido. Não tenho certeza se é
uma boa ideia permitir interruptores não exaustivos em tal tipo - nós
não exija exaustividade em nenhum outro lugar. Uma coisa que pode
ser bom, porém: se a mudança for exaustiva, não poderíamos exigir um padrão
para torná-lo uma declaração de encerramento.

Isso significa que você pode fazer o compilador errar se tiver:

func addOne(x int|float64) int|float64 {
    switch x := x.(type) {
    case int:
        return x + 1
    case float64:
         return x + 1
    }
}

e você altera o tipo de soma para adicionar um caso extra.

Para interruptores de tipo, se você tiver

tipo T int | interface{}

e você faz:

switch t: = t. (tipo) {
case int:
// ...
e t contém uma interface {} contendo um int, ele corresponde ao primeiro caso? E se o primeiro caso for a interface do caso {}?

t não pode conter uma interface {} contendo um int. t é uma interface
tipo como qualquer outro tipo de interface, exceto que só pode
contém o conjunto enumerado de tipos em que consiste.
Assim como uma interface {} não pode conter uma interface {} contendo um int.

Os tipos de soma podem corresponder aos tipos de interface, mas ainda assim obtêm uma
digite para o valor dinâmico. Por exemplo, seria bom ter:

type R io.Reader | io.ReadCloser

E quanto à interface do tipo T {} | nada? Se você escrever

var t T = nulo

qual é o tipo de t? Ou essa construção é proibida? Uma questão semelhante surge para o tipo T [] int | nulo, portanto, não se trata apenas de interfaces.

De acordo com a proposta acima, você obtém o primeiro item
na soma à qual o valor pode ser atribuído, então
você obteria a interface nula.

Na verdade, interface {} | nil é tecnicamente redundante, porque qualquer interface {}
pode ser nulo.

Para [] int | nil, um nil [] int não é o mesmo que uma interface nil, então o
o valor concreto de ([]int|nil)(nil) seria []int(nil) não descompactado nil .

O caso []int | nil é interessante. Eu esperaria que nil na declaração de tipo sempre significasse "o valor nulo da interface", caso em que

type T []int | nil
var x T = nil

implicaria que x é a interface nula, não a nula []int .

Esse valor seria diferente do nulo []int codificado no mesmo tipo:

var y T = []int(nil)  // y != x

Nil não seria sempre necessário, mesmo se a soma fosse de todos os tipos de valor? Caso contrário, o que var x int64 | float64 seria? Meu primeiro pensamento, extrapolando a partir das outras regras, seria o valor zero do primeiro tipo, mas então o que dizer de var x interface{} | int ? Seria, como @bcmills aponta, teria que ser uma soma nula distinta.

Parece muito sutil.

Opções de tipo exaustivas seriam boas. Você sempre pode adicionar um default: vazio quando não for o comportamento desejado.

A proposta diz "Ao atribuir um valor a um tipo de soma, se o valor pode caber em mais
do que um dos tipos possíveis, então o primeiro é escolhido. "

Então com:

type T []int | nil
var x T = nil

x teria tipo concreto [] int porque nil pode ser atribuído a [] int e [] int é o primeiro elemento do tipo. Seria igual a qualquer outro valor [] int (nil).

Nil não seria sempre necessário, mesmo se a soma fosse de todos os tipos de valor? Caso contrário, o que var x int64 | float64 be?

A proposta diz "O valor zero de um tipo de soma é o valor zero do primeiro tipo em
the sum. ", então a resposta é int64 (0).

Meu primeiro pensamento, extrapolando a partir das outras regras, seria o valor zero do primeiro tipo, mas então e quanto à interface var x {} | int? Seria, como @bcmills aponta, teria que ser uma soma distinta nil

Não, seria apenas o valor nulo de interface usual nesse caso. Esse tipo (interface {} | nulo) é redundante. Talvez seja uma boa ideia torná-lo um compilador para especificar os tipos de soma em que um elemento é um superconjunto de outro, pois atualmente não vejo nenhum ponto em definir esse tipo.

O valor zero de um tipo de soma é o valor zero do primeiro tipo na soma.

Essa é uma sugestão interessante, mas uma vez que o tipo de soma deve registrar em algum lugar o tipo do valor que ele detém atualmente, acredito que significa que o valor zero do tipo de soma não é all-bytes-zero, o que o tornaria diferente de todos os outros tipos em Go. Ou talvez pudéssemos adicionar uma exceção dizendo que se a informação do tipo não estiver presente, então o valor é o valor zero do primeiro tipo listado, mas então não tenho certeza de como representar nil se não estiver o primeiro tipo listado.

Portanto, (stuff) | nil só faz sentido quando nada em (coisas) pode ser nulo e nil | (stuff) significa algo diferente dependendo se algo em coisas pode ser nulo? Qual valor nil adiciona?

@ianlancetaylor Eu acredito que muitas linguagens funcionais implementam tipos de soma (fechada) essencialmente como você faria em C

struct {
    int which;
    union {
         A a;
         B b;
         C c;
    } summands;
}

se which índices nos campos da união em ordem, 0 = a, 1 = b, 2 = c, a definição de valor zero resulta em todos os bytes são zero. E você precisaria armazenar os tipos em outro lugar, ao contrário das interfaces. Você também precisaria de um tratamento especial para a tag nula de algum tipo, onde quer que você armazene as informações de tipo.

Isso tornaria os tipos de valor de união em vez de interfaces especiais, o que também é interessante.

Existe uma maneira de fazer o valor zero funcionar se o campo que registra o tipo tiver um valor zero representando o primeiro tipo? Estou assumindo que uma maneira possível de isso ser representado seria:

type A = B|C
struct A {
  choice byte // value 0 or 1
  value ?// (thing big enough to store B | C)
}

[editar]

Desculpe, @jimmyfrasche foi mais rápido do que eu.

Existe algo adicionado por nil que não poderia ser feito com

type S int | string | struct{}
var None struct{}

?

Parece que evita muita confusão (que eu tenho, pelo menos)

Ou melhor

type (
     None struct{}
     S int | string | None
)

dessa forma, você poderia digitar switch on None e atribuir None{}

@jimmyfrasche struct{} não é igual a nil . É um pequeno detalhe, mas faria com que as opções de tipo em somas divergissem desnecessariamente (?) Das opções de tipo em outros tipos.

@bcmills Não era minha intenção afirmar o contrário - quis dizer que poderia ser usado com o mesmo propósito de diferenciar uma falta de valor sem se sobrepor ao significado de nulo em qualquer um dos tipos na soma.

@rogpeppe o que isso imprime?

// r is an io.Reader interface value holding a type that also implements io.Closer
var v io.ReadCloser | io.Reader = r
switch v.(type) {
case io.ReadCloser: fmt.Println("ReadCloser")
case io.Reader: fmt.Println("Reader")
}

Eu presumiria "Leitor"

@jimmyfrasche Eu presumiria ReadCloser , o mesmo que você obteria de uma chave de tipo em qualquer outra interface.

(E eu também esperaria que somas que incluem apenas tipos de interface não usem mais espaço do que uma interface normal, embora eu suponha que uma tag explícita poderia economizar um pouco de sobrecarga de pesquisa na chave de tipo.)

@bcmills é a atribuição que é interessante, considere: https://play.golang.org/p/PzmWCYex6R

@ianlancetaylor Esse é um excelente ponto a levantar, obrigado. Não acho que seja difícil contornar, embora isso implique que minha sugestão de "implementação ingênua" seja ela mesma ingênua demais. Um tipo de soma, embora tratado como um tipo de interface, não precisa realmente conter um ponteiro direto para o tipo e seu conjunto de métodos - em vez disso, pode, quando apropriado, conter uma tag inteira que indica o tipo. Essa tag pode ser diferente de zero mesmo quando o próprio tipo é nulo.

Dado:

 var x int | nil = nil

o valor de tempo de execução de x não precisa ser todo zeros. Ao ligar o tipo de x ou converter
para outro tipo de interface, a tag pode ser direcionada por meio de uma pequena tabela contendo
os ponteiros de tipo reais.

Outra possibilidade seria permitir um tipo nil apenas se for o primeiro elemento, mas
que impede construções como:

var t nil | int
var u float64 | t

@jimmyfrasche Eu assumiria ReadCloser, o mesmo que você obteria de uma chave de tipo em qualquer outra interface.

sim.

@bcmills é a atribuição que é interessante, considere: https://play.golang.org/p/PzmWCYex6R

Eu não entendo isso. Por que "isso [...] tem que ser válido para a chave de tipo imprimir ReadCloser"
Como qualquer tipo de interface, um tipo de soma não armazenaria mais do que o valor concreto do que está nele.

Quando há vários tipos de interface em uma soma, a representação do tempo de execução é apenas um valor de interface - apenas sabemos que o valor subjacente deve implementar uma ou mais das possibilidades declaradas.

Ou seja, quando você atribui algo a um tipo (I1 | I2) onde I1 e I2 são tipos de interface, não é possível dizer posteriormente se o valor que você colocou foi conhecido para implementar I1 ou I2 no momento.

Se você tiver um tipo que seja io.ReadCloser | io.Reader, você não pode ter certeza ao digitar switch ou afirmar em io.Reader que não é um io.ReadCloser, a menos que a atribuição a um tipo de soma desembrulhe e reencaixote a interface.

Indo na direção contrária, se você tivesse o io.Reader | io.ReadCloser ele nunca aceitaria um io.ReadCloser porque vai estritamente da direita para a esquerda ou a implementação teria que procurar a interface de "melhor correspondência" de todas as interfaces na soma, mas isso não pode ser bem definido.

@rogpeppe Em sua proposta, ignorando as possibilidades de otimização na implementação e sutilezas de valores zero, o principal benefício de usar um tipo de soma sobre um tipo de interface criado manualmente (contendo a interseção dos métodos relevantes) é que o verificador de tipo pode apontar erros em tempo de compilação, em vez de tempo de execução. Um segundo benefício é que o valor de um tipo é mais discriminado e, portanto, pode ajudar na legibilidade / compreensão de um programa. Existe algum outro benefício importante?

(Não estou tentando diminuir a proposta de forma alguma, apenas tentando acertar minha intuição. Especialmente se a complexidade extra sintática e semântica for "razoavelmente pequena" - o que quer que isso signifique - posso definitivamente ver o benefício de ter o compilador detectar erros antecipadamente.)

@griesemer Sim, está certo.

Particularmente ao comunicar mensagens em canais ou na rede, acho que ajuda na legibilidade e correção poder ter um tipo que expresse exatamente as possibilidades disponíveis. É comum atualmente fazer uma tentativa indiferente de fazer isso incluindo um método não exportado em um tipo de interface, mas isso é a) contornável por incorporação eb) é difícil ver todos os tipos possíveis porque o método não exportado está oculto.

@jimmyfrasche

Se você tiver um tipo que seja io.ReadCloser | io.Reader, você não pode ter certeza ao digitar switch ou afirmar em io.Reader que não é um io.ReadCloser, a menos que a atribuição a um tipo de soma desembrulhe e reencaixote a interface.

Se você tem esse tipo, sabe que é sempre um io.Reader (ou nulo, porque qualquer io.Reader também pode ser nulo). As duas alternativas não são exclusivas - o tipo de soma proposto é um "inclusivo ou" não um "ou exclusivo".

Indo na direção contrária, se você tivesse o io.Reader | io.ReadCloser ele nunca aceitaria um io.ReadCloser porque vai estritamente da direita para a esquerda ou a implementação teria que procurar a interface de "melhor correspondência" de todas as interfaces na soma, mas isso não pode ser bem definido.

Se por "ir na outra direção" você quer dizer atribuir a esse tipo, a proposta diz:

"Ao atribuir um valor a um tipo de soma, se o valor pode caber em mais
do que um dos tipos possíveis, então o primeiro é escolhido. "

Nesse caso, um io.ReadCloser pode caber em um io.Reader e em um io.ReadCloser, então ele escolhe io.Reader, mas na verdade não há como saber depois. Não há diferença detectável entre o tipo io.Reader e o tipo io.Reader | io.ReadCloser, porque io.Reader também pode conter todos os tipos de interface que implementam io.Reader. É por isso que suspeito que seja uma boa ideia fazer o compilador rejeitar tipos como este. Por exemplo, ele poderia rejeitar qualquer tipo de soma envolvendo interface {} porque interface {} já pode conter qualquer tipo, portanto, as qualificações extras não adicionam nenhuma informação.

@rogpeppe, há muitas coisas que gosto na sua proposta. A semântica de atribuição da esquerda para a direita e o valor zero é o valor zero das regras de tipo mais à esquerda são muito claras e simples. Very Go.

O que me preocupa é atribuir um valor que já está encaixotado em uma interface a uma variável digitada por soma.

Vamos, por enquanto, usar meu exemplo anterior e dizer que RC é uma estrutura que pode ser atribuída a um io.ReadCloser.

Se você fizer isto

var v io.ReadCloser | io.Reader = RC{}

os resultados são óbvios e claros.

No entanto, se você fizer isso

var r io.Reader = RC{}
var v io.ReadCloser | io.Reader = r

a única coisa sensata a fazer é ter v store r como um io.Reader, mas isso significa que quando você digita switch on v, não pode ter certeza de que, ao atingir o io.Reader, você não tem de fato um io.ReadCloser. Você precisa ter algo assim:

switch v := v.(type) {
case io.ReadCloser: useReadCloser(v)
case io.Reader:
  if rc, ok := v.(io.ReadCloser); ok {
    useReadCloser(rc)
  } else {
    useReader(v)
  }
}

Agora, há um sentido em que io.ReadCloser <: io.Reader, e você poderia simplesmente desautorizá-los, como sugeriu, mas acho que o problema é mais fundamental e pode se aplicar a qualquer proposta de tipo de soma para Go †.

Digamos que você tenha três interfaces A, B e C, com os métodos A (), B () e C (), respectivamente, e uma estrutura ABC com todos os três métodos. A, B e C são disjuntos, então A | B | C e suas permutações são todos tipos válidos. Mas você ainda tem casos como

var c C = ABC{}
var v A | B | C = c

Existem várias maneiras de reorganizar isso e você ainda não obtém garantias significativas sobre o que é v quando as interfaces estão envolvidas. Depois de desembalar a soma, você precisa desembalar a interface se o pedido for importante.

Talvez a restrição deva ser que nenhum dos summands pode ser interface?

A única outra solução em que consigo pensar é proibir a atribuição de uma interface a uma variável digitada em soma, mas isso parece, à sua maneira, mais severo.

† que não envolve construtores de tipo para os tipos na soma para eliminar a ambigüidade (como em Haskell, onde você tem que dizer Just v para construir um valor do tipo Maybe) - mas não sou a favor disso de forma alguma.

@jimmyfrasche O caso de uso para unboxing ordenado é realmente importante? Isso não é óbvio para mim e, nos casos em que é importante, é fácil contornar com estruturas de caixa explícitas:

type ReadCloser struct {  io.ReadCloser }
type Reader struct { io.Reader }

var v ReadCloser | Reader = Reader{r}

@bcmills É mais que os resultados não são óbvios e complicados e significa que todas as garantias que você deseja com um tipo de soma evaporam quando as interfaces estão envolvidas. Eu posso ver isso causando todos os tipos de erros sutis e mal-entendidos.

O exemplo de estrutura de caixa explícita que você fornece mostra que não permitir interfaces em tipos de soma não limita o poder dos tipos de soma de forma alguma. Ele está efetivamente criando os construtores de tipo para desambiguação que mencionei na nota de rodapé. É certo que é um pouco chato e um passo a mais, mas é simples e parece muito em linha com a filosofia de Go de permitir que as construções de linguagem sejam tão ortogonais quanto possível.

todas as garantias que você deseja com um tipo de soma

Depende das garantias que você espera. Acho que você está esperando que um tipo de soma seja
um valor estritamente marcado, então dado qualquer tipo A | B | C, você sabe exatamente o que estático
tipo que você atribuiu a ele. Eu vejo isso como uma restrição de tipo em um único valor de concreto
tipo - a restrição é que o valor é compatível com o tipo com (pelo menos) um de A, B e C.
No final, é apenas uma interface com um valor em.

Ou seja, se um valor pode ser atribuído a um tipo de soma em virtude de ser compatível com atribuição
com um dos membros do tipo de soma, não registramos qual desses membros foi
"escolhido" - apenas registramos o próprio valor. O mesmo que quando você atribui um io.Reader
para uma interface {}, você perde o tipo io.Reader estático e apenas tem o próprio valor
que é compatível com io.Reader, mas também com qualquer outro tipo de interface que aconteça
implementar.

No seu exemplo:

var c C = ABC{}
var v A | B | C = c

Uma asserção de tipo de v para qualquer um de A, B e C seria bem-sucedida. Isso me parece razoável.

@rogpeppe essas semânticas fazem mais sentido do que eu estava imaginando. Ainda não estou totalmente convencido de que interfaces e somas se misturam bem, mas não tenho mais certeza de que não. Progresso!

Digamos que você tenha type U I | *T onde I é um tipo de interface e *T é um tipo que implementa I .

Dado

var i I = new(T)
var u U = i

o tipo dinâmico de u é *T , e dentro

var u U = new(T)

você pode acessar esse *T como um I com uma declaração de tipo. Isso é correto?

Isso significaria que a atribuição de um valor de interface válido a uma soma teria que procurar o primeiro tipo correspondente na soma.

Também seria um pouco diferente de algo como var v uint8 | int32 | int64 = i que, eu imagino, sempre iria com qualquer um dos três tipos i é mesmo se i fosse um int64 que caberia em uint8 .

Progresso!

Yay!

você pode acessar esse * T como um I com uma declaração de tipo. Isso é correto?

sim.

Isso significaria que a atribuição de um valor de interface válido a uma soma teria que procurar o primeiro tipo correspondente na soma.

Sim, como diz a proposta (é claro que o compilador sabe estaticamente qual escolher, então não há pesquisa em tempo de execução).

Também seria um pouco diferente de algo como var v uint8 | int32 | int64 = i que, imagino, sempre iria com qualquer um dos três tipos que i é, mesmo se eu fosse um int64 que pudesse caber em um uint8.

Sim, porque a menos que i seja uma constante, só será atribuível a uma dessas alternativas.

Sim, porque a menos que i seja uma constante, só será atribuível a uma dessas alternativas.

Isso não é bem verdade, eu percebo, por causa da regra que permite a atribuição de tipos não nomeados a tipos nomeados. Não acho que isso faça muita diferença. A regra continua a mesma.

Portanto, o tipo I | *T da minha última postagem é efetivamente igual ao tipo I e io.ReadCloser | io.Reader é efetivamente o mesmo tipo que io.Reader ?

Isso mesmo. Ambos os tipos seriam cobertos por minha regra sugerida de que o compilador rejeita tipos de soma em que um tipo é uma interface implementada por outro dos tipos. A mesma regra ou uma regra semelhante pode abranger tipos de soma com tipos duplicados como int|int .

Um pensamento: talvez não seja intuitivo que int|byte não seja o mesmo que byte|int , mas provavelmente está ok na prática.

Isso significaria que a atribuição de um valor de interface válido a uma soma teria que procurar o primeiro tipo correspondente na soma.

Sim, como diz a proposta (é claro que o compilador sabe estaticamente qual escolher, então não há pesquisa em tempo de execução).

Eu não estou entendendo isso. Do jeito que eu li (que pode ser diferente do pretendido), há pelo menos duas maneiras de lidar com uma união U de I e T-implementos-I.

1a) na atribuição de U u = t , a tag é definida como T. A seleção posterior resulta em um T porque a tag é um T.
1b) na atribuição de U u = i (i é realmente um T), a tag é definida como I. A seleção posterior resulta em um T porque a tag é um I, mas uma segunda verificação (realizada porque T implementa I e T é um membro de U) descobre um T.

2a) como 1a
2b) na atribuição de U u = i (i é realmente um T), o código gerado verifica o valor (i) para ver se é realmente um T, porque T implementa I e T também é um membro de U. ou seja, a tag é definida como T. A seleção posterior resulta diretamente em um T.

No caso em que T, V, W implementam I e U = *T | *V | *W | I , a atribuição U u = i requer (até) 3 testes de tipo.

Porém, interfaces e ponteiros não eram o caso de uso original para tipos de união, era?

Posso imaginar certos tipos de hackeamentos em que uma implementação "legal" executaria alguns bits batendo - por exemplo, se você tiver uma união de 4 ou menos tipos de ponteiro onde todos os referentes são alinhados de 4 bytes, armazene a tag nos 2 bits do valor. Isso, por sua vez, implica que não é bom pegar o endereço de um membro de um sindicato (não seria de forma alguma, já que esse endereço poderia ser usado para re-armazenar um tipo "antigo" sem ajustar a tag).

Ou se tivéssemos um espaço de endereço de 50 bits e estivéssemos dispostos a tomar algumas liberdades com NaNs, poderíamos colocar inteiros, ponteiros e dobra em uma união de 64 bits e o possível custo de alguns bits mexidos.

Ambas as sub-sugestões são grosseiras, estou certo de que ambas teriam um pequeno (?) Número de proponentes fanáticos.

Isso, por sua vez, implica que não é bom levar o endereço de um membro de um sindicato

Correto. Mas eu não acho que o resultado de uma afirmação de tipo seja endereçável hoje de qualquer maneira, não é?

na atribuição de U u = i (i é realmente um T), a tag é definida como I.

Acho que este é o ponto crucial - não há etiqueta I.

Ignore a representação do tempo de execução por um momento e considere um tipo de soma como uma interface. Como acontece com qualquer interface, ele tem um tipo dinâmico (o tipo que está armazenado nele). A "tag" a que você se refere é exatamente esse tipo dinâmico.

Como você sugere (e eu tentei insinuar no último parágrafo da proposta), pode haver maneiras de armazenar a tag de tipo de maneira mais eficiente do que com um ponteiro para o tipo de tempo de execução, mas no final é sempre apenas codificar o dinâmico tipo do valor do tipo soma, não qual das alternativas foi "escolhida" quando foi criada.

Porém, interfaces e ponteiros não eram o caso de uso original para tipos de união, era?

Não foi, mas qualquer proposta precisa ser o mais ortogonal possível em relação a outras características da linguagem, na minha opinião.

@ dr2chase meu entendimento até agora é que, se um tipo de soma inclui qualquer tipo de interface em sua definição, então em tempo de execução sua implementação é idêntica a uma interface (contendo a interseção de conjuntos de métodos), mas as invariáveis ​​de tempo de compilação sobre os tipos permitidos ainda são aplicada.

Mesmo se um tipo de soma contivesse apenas tipos concretos e fosse implementado como uma união discriminada de estilo C, você não seria capaz de endereçar um valor no tipo de soma, uma vez que esse endereço poderia se tornar um tipo (e tamanho) diferente após você pegar o endereço. Você poderia pegar o endereço do próprio valor digitado, no entanto.

É desejável que os tipos de soma se comportem dessa maneira? Poderíamos facilmente declarar que o tipo selecionado / declarado é o mesmo que o programador disse / sugeriu quando um valor foi atribuído à união. Caso contrário, podemos ser levados a lugares interessantes com respeito a int8 vs int16 vs int32, etc. Ou, por exemplo, int8 | uint8 .

É desejável que os tipos de soma se comportem dessa maneira?

Isso é uma questão de julgamento. Acredito que sim, porque já temos o conceito de interfaces na linguagem - valores de tipo estático e dinâmico. Os tipos de soma propostos apenas fornecem uma maneira mais precisa de especificar os tipos de interface em alguns casos. Isso também significa que os tipos de soma podem funcionar sem restrição em quaisquer outros tipos. Se você não fizer isso, será necessário excluir os tipos de interface e o recurso não será totalmente ortogonal.

Do contrário, podemos ser levados a lugares interessantes com relação a int8 vs int16 vs int32, etc. Ou, por exemplo, int8 | uint8.

Qual é a sua preocupação aqui?

Você não pode usar um tipo de função como o tipo de chave de um mapa. Não estou dizendo que isso seja equivalente, apenas que há precedentes para tipos que restringem outros tipos de tipos. Ainda aberto para permitir interfaces, ainda não vendido.

Que tipo de programa você pode escrever com um tipo de soma contendo interfaces que você não conseguiria de outra forma?

Contra proposta.

Um tipo de união é um tipo que lista zero ou mais tipos, escritos

union {
  T0
  T1
  //...
  Tn
}

Todos os tipos listados (T0, T1, ..., Tn) em uma união devem ser diferentes e nenhum pode ser tipo de interface.

Os métodos podem ser declarados em um tipo de união definido (nomeado) pelas regras usuais. Nenhum método é promovido dos tipos listados.

Não há incorporação de tipos de união. Listar um tipo de união em outro é o mesmo que listar qualquer outro tipo válido. No entanto, uma união não pode listar seu próprio tipo recursivamente, pela mesma razão que type S struct { S } é inválido.

Os sindicatos podem ser incorporados em estruturas.

O valor de um tipo de união é um tipo dinâmico, limitado a um dos tipos listados e um valor do tipo dinâmico - considerado o valor armazenado. Exatamente um dos tipos listados é o tipo dinâmico em todos os momentos.

O valor zero da união vazia é único. O valor zero de uma união não vazia é o valor zero do primeiro tipo listado na união.

Um valor para um tipo de união, U , pode ser criado com U{} para o valor zero. Se U tem um ou mais tipos e v é um valor de um dos tipos listados, T , U{v} cria um valor de união armazenando v com tipo dinâmico T . Se v é de um tipo não listado em U que pode ser atribuído a mais de um dos tipos listados, uma conversão explícita é necessária para eliminar a ambigüidade.

Um valor de um tipo de união U pode ser convertido em outro tipo de união V como em V(U{}) se o conjunto de tipos em U for um subconjunto de conjunto de tipos em V . Ou seja, ignorando a ordem, U deve ter todos os mesmos tipos que V , e U não pode ter tipos que não estejam em V mas em V pode ter tipos que não estão em U .

A atribuibilidade entre tipos de união é definida como convertibilidade, desde que no máximo um dos tipos de união seja definido (nomeado).

Um valor de um dos tipos listados, T , de um tipo de união U pode ser atribuído a uma variável do tipo de união U . Isso define o tipo dinâmico para T e armazena o valor. Valores compatíveis de atribuição funcionam como acima.

Se todos os tipos listados suportam os operadores de igualdade:

  • os operadores de igualdade podem ser usados ​​em dois valores do mesmo tipo de união. Dois valores de um tipo de união nunca são iguais se seus tipos dinâmicos forem diferentes.
  • um valor dessa união pode ser comparado com um valor de qualquer um dos tipos listados. Se o tipo dinâmico da união não for o tipo do outro operando, == é falso e != é verdadeiro independentemente do valor armazenado. Valores compatíveis de atribuição funcionam como acima.
  • a união pode ser usada como uma chave de mapa

Nenhum outro operador tem suporte em valores de um tipo de união.

Uma asserção de tipo contra um tipo de união para um de seus tipos listados será mantida se o tipo declarado for o tipo dinâmico.

Uma asserção de tipo contra um tipo de união para um tipo de interface é mantida se seu tipo dinâmico implementar essa interface. (Notavelmente, se todos os tipos listados implementarem essa interface, a declaração sempre será válida).

As opções de tipo devem ser completas, incluindo todos os tipos listados, ou conter um caso padrão.

As asserções de tipo e as opções de tipo retornam uma cópia do valor armazenado.

A reflexão de pacote exigiria uma maneira de obter o tipo dinâmico e o valor armazenado de um valor de união refletido e uma maneira de obter os tipos listados de um tipo de união refletida.

Notas:

A sintaxe union{...} foi escolhida parcialmente para diferenciar da proposta de tipo de soma neste tópico, principalmente para reter as boas propriedades na gramática Go e, incidentalmente, para reforçar que esta é uma união discriminada. Como consequência, isso permite uniões um tanto estranhas, como union{} e union{ int } . O primeiro é em muitos sentidos equivalente a struct{} (embora, por definição, seja um tipo diferente), portanto, não adiciona nada à linguagem, a não ser adicionar outro tipo vazio. O segundo talvez seja mais útil. Por exemplo, type Id union { int } é muito parecido com type Id struct { int } exceto que a versão de união permite atribuição direta sem ter que especificar idValue.int permitindo que pareça mais com um tipo embutido.

A conversão de eliminação de ambigüidade necessária ao lidar com tipos de atribuição compatíveis é um pouco dura, mas detectaria erros se uma união fosse atualizada para introduzir uma ambigüidade para a qual o código downstream não está preparado.

A falta de incorporação é uma consequência de permitir métodos em uniões e exigir correspondência exaustiva em opções de tipo.

Permitir métodos na própria união em vez de obter a interseção válida de métodos dos tipos listados evita a obtenção acidental de métodos indesejados. O tipo que afirma o valor armazenado para interfaces comuns permite métodos de invólucro simples e explícitos quando a promoção é desejada. Por exemplo, em um tipo de união U todos cujos tipos listados implementam fmt.Stringer :

func (u U) String() string {
  return u.(fmt.Stringer).String()
}

No encadeamento do reddit vinculado, rsc disse:

Seria estranho para o valor zero da soma {X; Y} seja diferente da soma {Y; X}. Não é assim que as somas geralmente funcionam.

Estive pensando sobre isso, pois se aplica a qualquer proposta realmente.

Isso não é um bug: é um recurso.

Considerar

type (
  Undefined = struct{}
  UndefinedOrInt union { Undefined; int }
)

vs.

type (
  Illegal = struct{}
  IntOrIllegal union { int; Illegal }
)

UndefinedOrInt diz que por padrão ainda não está definido, mas, quando estiver, será um int . Isso é análogo a *int que é como o tipo de soma (1 + int) precisa ser representado no Go now e o valor zero também é análogo.

IntOrIllegal , por outro lado, diz que por padrão é o int 0, mas pode em algum momento ser marcado como ilegal. Isso ainda é análogo a *int mas o valor zero é mais expressivo da intenção, como forçar que o padrão seja new(int) .

É como ser capaz de expressar um campo bool em uma estrutura no negativo, de forma que o valor zero seja o que você deseja como padrão.

Ambos os valores zero das somas são úteis e significativos por si próprios e o programador pode escolher o mais adequado para a situação.

Se a soma fosse um enum de dias da semana (cada dia sendo um struct{} definido), o que for listado primeiro é o primeiro dia da semana, o mesmo para um enum iota -style.

Além disso, não conheço nenhuma linguagem com tipos de soma ou uniões discriminadas / marcadas que tenham o conceito de valor zero. C seria o mais próximo, mas o valor zero é memória não inicializada - dificilmente uma pista a seguir. O padrão do Java é null, eu acredito, mas isso porque tudo é uma referência. Todas as outras linguagens que conheço têm construtores de tipo obrigatórios para os summands, portanto, não há realmente uma noção de valor zero. Existe tal linguagem? O que isso faz?

Se a diferença dos conceitos matemáticos de "soma" e "união" é o problema, podemos sempre chamá-los de outra coisa (por exemplo, "variante").

Para nomes: Union confunde puristas c / c ++. Variant é principalmente familiar para programadores COBRA e COM, onde a união discriminada parece ser a preferida pelas linguagens funcionais. Set é um verbo e um substantivo. Eu gosto da palavra-chave _pick_. Limbo usado _pick_. É curto e descreve a intenção do tipo de escolher entre um conjunto finito de tipos.

O nome / sintaxe é amplamente irrelevante. Escolha seria bom.

Qualquer uma das propostas neste tópico se encaixa na definição teórica do conjunto.

O primeiro tipo sendo especial para o valor zero é irrelevante uma vez que as somas teóricas de tipo comutam, então a ordem é irrelevante (A + B = B + A). Minha proposta mantém essa propriedade, mas os tipos de produto também se deslocam em teoria e são considerados diferentes na prática pela maioria dos idiomas (vá incluído), portanto, provavelmente não é essencial.

@jimmyfrasche

Pessoalmente, acredito que não permitir interfaces como membros 'escolhidos' é uma grande desvantagem. Primeiro, isso anularia completamente um dos grandes casos de uso de tipos de 'escolha' - ter um erro ser um dos membros. Ou você deseja lidar com um tipo de seleção que tenha um io.Reader ou uma string, se não quiser forçar o usuário a usar um StringReader de antemão. Mas, em suma, uma interface é apenas outro tipo, e eu acredito que não deveria haver restrições de tipo para 'escolher' membros. Sendo esse o caso, se um tipo de seleção tiver 2 membros de interface, sendo um totalmente delimitado pelo outro, isso deve ser um erro em tempo de compilação, conforme mencionado anteriormente.

O que eu gosto da sua contra-proposta é o fato de que os métodos podem ser definidos no tipo de seleção. Não acho que ele deva fornecer uma seção cruzada dos métodos dos membros, já que não acho que haveria muitos casos em que qualquer método pertenceria a todos os membros (e você tem interfaces para isso de qualquer maneira). E um switch + case padrão exaustivo é uma idéia muito boa.

@rogpeppe @jimmyfrasche Algo que não vejo em suas propostas é por que devemos fazer isso. Há uma desvantagem clara em adicionar um novo tipo de tipo: é um novo conceito que todos que aprenderem Go terão que aprender. Qual é a vantagem compensatória? Em particular, o que o novo tipo de tipo nos oferece que não obtemos dos tipos de interface?

@ianlancetaylor Robert resumiu bem aqui: https://github.com/golang/go/issues/19412#issuecomment -288608089

@ianlancetaylor
No final do dia, torna o código mais legível, e essa é a principal diretriz do Go. Considere json.Token, ele atualmente é definido como uma interface {}, no entanto, a documentação afirma que ele pode realmente ser apenas um de um número específico de tipos. Se, por outro lado, é escrito como

type Token Delim | bool | float64 | Number | string | nil

O usuário será capaz de ver imediatamente todas as possibilidades e o ferramental será capaz de criar um switch exaustivo automaticamente. Além disso, o compilador impedirá que você insira um tipo inesperado nele também.

No final do dia, torna o código mais legível, e essa é a principal diretriz do Go.

Mais recursos significa que é preciso saber mais para entender o código. Para uma pessoa com um conhecimento apenas médio de um idioma, sua legibilidade é necessariamente inversamente proporcional ao número de recursos [recém-adicionados].

@cznic

Mais recursos significa que é preciso saber mais para entender o código.

Nem sempre. Se você puder substituir "saber mais sobre a linguagem" por "saber mais sobre invariantes mal documentados ou inconsistentemente documentados no código", isso ainda pode ser uma vitória líquida. (Ou seja, o conhecimento global pode substituir a necessidade de conhecimento local.)

Se uma melhor verificação de tipo em tempo de compilação é de fato o único benefício, então podemos obter um benefício muito semelhante sem alterar a linguagem, introduzindo um comentário verificado pelo veterinário. Algo como

//vet:types Delim | bool | float64 | Number | string | nil
type Token interface{}

Agora, nós não temos nenhum tipo de comentário veterinário, então esta não é uma sugestão totalmente séria. Mas estou falando sério sobre a ideia básica: se a única vantagem que obtemos é algo que podemos fazer inteiramente com uma ferramenta de análise estática, realmente vale a pena adicionar um novo conceito complexo à linguagem adequada?

Muitos, talvez todos, os testes feitos por cmd / vet poderiam ser adicionados à linguagem, no sentido de que eles poderiam ser verificados pelo compilador em vez de por uma ferramenta de análise estática separada. Mas, por várias razões, achamos útil separar o veterinário do compilador. Por que esse conceito recai no lado da linguagem, em vez do lado do veterinário?

@ianlancetaylor verificou os comentários: https://github.com/BurntSushi/go-sumtype

@ianlancetaylor, no que diz respeito à justificativa da mudança, tenho ignorado ativamente - ou melhor, rejeitado. Falar sobre isso em abstrato é vago e não me ajuda: tudo soa como "coisas boas são boas e coisas ruins são ruins" para mim. Eu queria ter uma ideia de como seria o tipo - quais são suas limitações, quais são as implicações, quais são os prós, quais são os contras - para ver como se encaixaria na linguagem (ou não! ) e tenho uma ideia de como eu usaria / poderia usá-lo em programas. Acho que tenho uma boa ideia do que os tipos de soma devem significar no Go agora, pelo menos da minha perspectiva. Não estou totalmente convencido de que valem a pena (mesmo que eu os queira muito), mas agora que tenho algo sólido para analisar com propriedades bem definidas que posso raciocinar. Eu sei que não é realmente uma resposta, por si só, mas é onde estou com isso, pelo menos.

Se uma melhor verificação de tipo em tempo de compilação é de fato o único benefício, então podemos obter um benefício muito semelhante sem alterar a linguagem, introduzindo um comentário verificado pelo veterinário.

Isso ainda é vulnerável à crítica da necessidade de aprender coisas novas. Se eu tiver que aprender sobre esses comentários mágicos do veterinário para depurar / entender / usar o código, é um imposto mental, não importa se atribuímos isso ao orçamento da linguagem Go ou ao orçamento da linguagem tecnicamente diferente. Na verdade, comentários mágicos são mais caros porque eu não sabia que precisava aprendê-los quando pensei que tinha aprendido o idioma.

@cznic
Discordo. Com sua suposição atual, você não pode ter certeza de que uma pessoa entenderia o que é um canal ou mesmo o que é uma função. No entanto, essas coisas existem na linguagem. E um novo recurso não significa automaticamente que tornaria a linguagem mais difícil. Nesse caso, eu argumentaria que, de fato, seria mais fácil de entender, porque torna imediatamente claro para o leitor o que um tipo deve ser, em oposição a usar um tipo de interface {} de caixa preta.

@ianlancetaylor
Pessoalmente, acho que esse recurso tem mais a ver com tornar o código mais fácil de ler e raciocinar. A segurança do tempo de compilação é um recurso muito bom, mas não o principal. Isso não apenas tornaria uma assinatura de tipo imediatamente mais óbvia, mas seu uso subsequente também seria mais fácil de entender e escrever. As pessoas não precisariam mais recorrer ao pânico se recebessem um tipo que não esperavam - esse é o comportamento atual mesmo na biblioteca padrão, portanto, teriam mais facilidade para pensar no uso, sem se preocupar com o desconhecido . E não acho uma boa ideia contar com comentários e outras ferramentas (mesmo que sejam originais) para isso, porque uma sintaxe mais limpa é mais legível do que esse tipo de comentário. E os comentários não têm estrutura e são muito mais fáceis de bagunçar.

@ianlancetaylor

Por que esse conceito recai no lado da linguagem, em vez do lado do veterinário?

Você poderia aplicar a mesma pergunta a qualquer recurso fora do núcleo de turing-complete e, sem dúvida, não queremos que Go seja um "turing tarpit". Por outro lado, nós temos exemplos de idiomas que têm empurrado subconjuntos significativos da linguagem real fora em uma sintaxe genérica "extensão". (Por exemplo, "atributos" em Rust, C ++ e GNU C.)

O principal motivo para colocar recursos em extensões ou atributos em vez de em uma linguagem central é preservar a compatibilidade de sintaxe, incluindo compatibilidade com ferramentas que não estão cientes do novo recurso. (Se a "compatibilidade com ferramentas" realmente funciona na prática depende fortemente do que o recurso realmente faz.)

No contexto do Go, parece que a principal razão para colocar recursos em vet é implementar mudanças que não preservariam a compatibilidade do Go 1 se aplicadas à própria linguagem. Não vejo isso como um problema aqui.

Uma razão para não colocar recursos em vet é se eles precisam ser propagados durante a compilação. Por exemplo, se eu escrever:

switch x := somepkg.SomeFunc().(type) {
…
}

irei obter os avisos adequados para os tipos que não estão na soma, além dos limites do pacote? Não é óbvio para mim que vet pode fazer uma análise transitiva tão profunda, então talvez essa seja a razão pela qual ele precisaria ir para a linguagem central.

@ dr2chase Em geral, é claro, você está correto, mas você está correto para este exemplo específico? O código é completamente compreensível sem saber o que o comentário mágico significa. O comentário mágico não muda o que o código faz de forma alguma. As mensagens de erro do veterinário devem ser claras.

@bcmills

Por que esse conceito recai no lado da linguagem, em vez do lado do veterinário?

Você pode aplicar a mesma pergunta a qualquer recurso fora do núcleo de turing-complete ....

Eu não concordo. Se o recurso em discussão afeta o código compilado, então há um argumento automático a favor dele. Nesse caso, o recurso aparentemente não afeta o código compilado.

(E, sim, o veterinário pode analisar a origem dos pacotes importados.)

Não estou tentando afirmar que meu argumento sobre o veterinário seja conclusivo. Mas toda mudança de linguagem começa de uma posição negativa: uma linguagem simples é muito desejável, e um novo recurso significativo como esse inevitavelmente torna a linguagem mais complexa. Você precisa de argumentos fortes a favor de uma mudança de idioma. E, da minha perspectiva, esses argumentos fortes ainda não apareceram. Afinal, pensamos muito sobre esse assunto e ele é um FAQ (https://golang.org/doc/faq#variant_types).

@ianlancetaylor

Nesse caso, o recurso aparentemente não afeta o código compilado.

Acho que depende dos detalhes específicos? O comportamento de "valor zero da soma é o valor zero do primeiro tipo" que @jimmyfrasche mencionou acima (https://github.com/golang/go/issues/19412#issuecomment-289319916) certamente faria.

@urandom Eu estava escrevendo uma longa explicação de por que os tipos de interface e união não se misturavam sem construtores de tipo explícitos, mas então percebi que havia uma maneira sensata de fazer isso, então:

Contraproposta rápida e suja à minha contraproposta. (Qualquer coisa não mencionada explicitamente é igual à minha proposta anterior). Não tenho certeza se uma proposta é melhor do que a outra, mas esta permite interfaces e é mais explícita:

A união tem "nomes de campo" explícitos daqui em diante chamados de "nomes de tag":

union { //or whatever
  None, invalid struct{} //None is zero value
  Good, Bad int
  Err error //okay because it's explicitly named
}

Ainda não há incorporação. É sempre um erro ter um tipo sem um nome de tag.

Os valores de união têm uma tag dinâmica em vez de um tipo dinâmico.

Criação de valor literal: U{v} só é válido se for totalmente inequívoco, caso contrário, deve ser U{Tag: v} .

A conversibilidade e a compatibilidade de atribuição levam os nomes das tags em consideração também.

A designação para um sindicato não é mágica. Isso sempre significa atribuir um valor de união compatível. Para definir o valor armazenado, o nome do tag desejado deve ser usado explicitamente: v.Good = 1 define o tag dinâmico como Bom e o valor armazenado como 1.

Acessar o valor armazenado usa uma declaração de tag em vez de uma declaração de tipo:

g := v.[Tag] //may panic
g, ok := v.[Tag] //no panic but could return zero-value, false

v.Tag é um erro no rhs, pois é ambíguo.

As opções de tag são como as de tipo, escritas switch v.[type] , exceto que os casos são as tags da união.

As asserções de tipo são válidas em relação ao tipo da tag dinâmica. Os interruptores de tipo funcionam de forma semelhante.

Dados os valores a, b de algum tipo de união, a == b se suas tags dinâmicas forem iguais e o valor armazenado for o mesmo.

Verificar se o valor armazenado é algum valor particular requer uma declaração de tag.

Se um nome de tag não for exportado, ele só pode ser definido e acessado no pacote que define a união. Isso significa que uma troca de tag de uma união com tags mistas exportadas e não exportadas nunca pode ser exaustiva fora do pacote de definição sem um caso padrão. Se todas as tags não forem exportadas, é uma caixa preta.

A reflexão também precisa lidar com os nomes das tags.

e: Esclarecimento para uniões aninhadas. Dado

type U union {
  A union {
    A1 T1
    A2 T2
  }
  B union {
    B1 T3
    B2 T4
  }
}
var u U

O valor de u é o tag dinâmico A e o valor armazenado é a união anônima com o tag dinâmico A1 e seu valor armazenado é o valor zero de T1.

u.B.B2 = returnsSomeT3()

é tudo o que é necessário para mudar u do valor zero, mesmo que ele se mova de uma das uniões aninhadas para a outra, pois está tudo armazenado em um local da memória. Mas

v := u.[A].[A2]

tem duas chances de entrar em pânico, pois a tag afirma em dois valores de união e a versão de 2 valores da afirmação da tag não está disponível sem a divisão em várias linhas. As chaves de tag aninhadas seriam mais limpas, neste caso.

edit2: Esclarecimento sobre afirmações de tipo.

Dado

type U union {
  Exported, unexported int
}
var u U

uma declaração de tipo como u.(int) é totalmente razoável. Dentro do pacote de definição, isso sempre valeria. No entanto, se u estiver fora do pacote de definição, u.(int) entraria em pânico quando a tag dinâmica fosse unexported para evitar o vazamento de detalhes de implementação. Da mesma forma para asserções para um tipo de interface.

@ianlancetaylor Aqui estão alguns exemplos de como esse recurso pode ajudar:

  1. No coração de alguns pacotes ( go/ast por exemplo) estão um ou mais tipos de grandes somas. É difícil navegar por esses pacotes sem entender esses tipos. Mais confuso, às vezes um tipo de soma é representado por uma interface com métodos (por exemplo, go/ast.Node ), outras vezes pela interface vazia (por exemplo, go/ast.Object.Decl ).

  2. Compilar o recurso protobuf oneof para Go resulta em um tipo de interface não exportado cujo único propósito é garantir que a atribuição ao campo oneof seja segura para o tipo. Isso, por sua vez, requer a geração de um tipo para cada ramo do oneof. Literais de tipo para o produto final são difíceis de ler e escrever:

    &sppb.Mutation{
               Operation: &sppb.Mutation_Delete_{
                   Delete: &sppb.Mutation_Delete{
                       Table:  m.table,
                       KeySet: keySetProto,
                   },
               },
    }
    

    Alguns (embora não todos) oneofs podem ser expressos por tipos de soma.

  3. Às vezes, um tipo "talvez" é exatamente o que se precisa. Por exemplo, muitas operações de atualização de recursos da API do Google permitem que um subconjunto dos campos do recurso seja alterado. Uma maneira natural de expressar isso no Go é por meio de uma variante da estrutura de recurso com um tipo "talvez" para cada campo. Por exemplo, o recurso ObjectAttrs do Google Cloud Storage parece

    type ObjectAttrs struct {
       ContentType string
       ...
    }
    

    Para suportar atualizações parciais, o pacote também define

    type ObjectAttrsToUpdate struct {
       ContentType optional.String
       ...
    }
    

    Onde optional.String parece com isto ( godoc ):

    // String is either a string or nil.
    type String interface{}
    

    Isso é difícil de explicar e não é seguro para o tipo, mas acaba sendo conveniente na prática, porque um ObjectAttrsToUpdate literal se parece exatamente com um ObjectAttrs literal, enquanto codifica a presença. Eu gostaria que pudéssemos ter escrito

    type ObjectAttrsToUpdate struct {
       ContentType string | nil
       ...
    }
    
  4. Muitas funções retornam (T, error) com semântica xor (T é significativo se o erro for nulo). Escrever o tipo de retorno como T | error esclareceria a semântica, aumentaria a segurança e forneceria mais oportunidades para composição. Mesmo se não pudermos (por razões de compatibilidade) ou não quisermos alterar o valor de retorno de uma função, o tipo de soma ainda é útil para transportar esse valor, como gravá-lo em um canal.

Uma anotação go vet certamente ajudaria muitos desses casos, mas não aqueles em que um tipo anônimo faz sentido. Acho que se tivéssemos tipos de soma, veríamos muitos

chan *Response | error

Esse tipo é curto o suficiente para ser escrito várias vezes.

@ianlancetaylor provavelmente não é um bom começo, mas aqui está tudo o que você pode fazer com sindicatos que já pode fazer no Go1, porque achei que seria justo reconhecer e resumir esses argumentos:

(Usando minha última proposta com tags para a sintaxe / semântica abaixo. Também assumindo que o código emitido é basicamente como o código C que postei muito antes no tópico.)

Os tipos de soma se sobrepõem a iota, ponteiros e interfaces.

iota

Esses dois tipos são aproximadamente equivalentes:

type Stoplight union {
  Green, Yellow, Red struct {}
}

func (s Stoplight) String() string {
  switch s.[type] {
  case Green: return "green" //etc
  }
}

e

type Stoplight int

const (
  Green Stoplight = iota
  Yellow
  Red
)

func (s Stoplight) String() string {
  switch s {
  case Green: return "green" //etc
  }
}

O compilador provavelmente emitirá exatamente o mesmo código para ambos.

Na versão de união, o int é transformado em um detalhe de implementação oculto. Com a versão iota, você pode perguntar o que é Amarelo / Vermelho ou definir um valor de Stoplight como -42, mas não com a versão Union - todos esses são erros do compilador e invariantes que podem ser levados em consideração durante a otimização. Da mesma forma, você pode escrever uma chave (valor) que não leva em conta as luzes amarelas, mas com uma chave de tag, você precisaria de um caso padrão para tornar isso explícito.

Claro, há coisas que você pode fazer com o iota que você não pode fazer com os tipos sindicalizados.

ponteiros

Esses dois tipos são aproximadamente equivalentes

type MaybeInt64 union {
  None struct{}
  Int64 int64
}

e

type MaybeInt64 *int64

A versão do ponteiro é mais compacta. A versão de união precisaria de um bit extra (que por sua vez provavelmente teria o tamanho da palavra) para armazenar a tag dinâmica, então o tamanho do valor provavelmente seria o mesmo que https://golang.org/pkg/database/sql/ # NullInt64

A versão sindical documenta mais claramente a intenção.

Claro, há coisas que você pode fazer com ponteiros que não pode fazer com tipos de união.

interfaces

Esses dois tipos são aproximadamente equivalentes

type AB union {
  A A
  B B
}

e

type AB interface {
  secret()
}
func (A) secret() {}
func (B) secret() {}

A versão de união não pode ser contornada com incorporação. A e B não precisam de métodos em comum - eles podem, na verdade, ser tipos primitivos ou ter conjuntos de métodos inteiramente disjuntos, como o exemplo json.Token @urandom postado.

É realmente fácil ver o que você pode colocar em uma união AB versus uma interface AB: a definição é a documentação (eu tive que ler a fonte go / ast várias vezes para descobrir o que é algo).

A união AB nunca pode ser nula e pode receber métodos fora da interseção de seus constituintes (isso pode ser simulado incorporando a interface em uma estrutura, mas a construção se torna mais delicada e sujeita a erros).

Claro, existem coisas que você pode fazer com interfaces que não pode ser feito com tipos de união.

Resumo

Talvez essa sobreposição seja muita sobreposição.

Em cada caso, o principal benefício das versões de união é, de fato, uma verificação mais rigorosa do tempo de compilação. O que você não pode fazer é mais importante do que o que você pode. Para o compilador que se traduz em invariantes mais fortes, ele pode ser usado para otimizar o código. Para o programador que se traduz em outra coisa, você pode deixar o compilador se preocupar - ele apenas dirá se você estiver errado. Na versão da interface, pelo menos, existem importantes benefícios da documentação.

Versões desajeitadas dos exemplos iota e ponteiro podem ser construídas usando a estratégia "interface com um método não exportado". No entanto, as estruturas podem ser simuladas com map[string]interface{} e interfaces (não vazias) com tipos de funções e valores de método. Ninguém faria isso porque é mais difícil e menos seguro.

Todos esses recursos acrescentam algo à linguagem, mas sua ausência poderia ser contornada (dolorosamente e sob protesto).

Portanto, estou assumindo que a barra não é para demonstrar um programa que nem pode ser aproximado em Go, mas sim para demonstrar um programa que é muito mais fácil e claramente escrito em Go com sindicatos do que sem. Então, o que falta mostrar é isso.

@jimmyfrasche

Não vejo razão para que o tipo de união deva ter campos nomeados. Os nomes são úteis apenas se você deseja distinguir entre diferentes campos do mesmo tipo. No entanto, uma união nunca deve ter vários campos do mesmo tipo, pois isso não faz sentido. Assim, ter nomes é simplesmente redundante e leva a confusão e mais digitação.

Em essência, seu tipo de sindicato deve ser semelhante a:

union {
    struct{}
    int
    err
}

Os próprios tipos fornecerão os identificadores exclusivos que podem ser usados ​​para atribuir a uma união, bastante semelhante à maneira como os tipos embutidos em estruturas são usados ​​como identificadores.

No entanto, para que as atribuições explícitas funcionem, não se pode criar um tipo de união especificando um tipo não nomeado como membro, uma vez que a sintaxe permitiria tal expressão. Por exemplo, v.struct{} = struct{}

Assim, tipos como estrutura bruta, uniões e funções devem ser nomeados de antemão para fazer parte de uma união e se tornarem atribuíveis. Com isso em mente, uma união aninhada não será nada especial, pois a união interna será apenas outro tipo de membro.

Agora, não tenho certeza de qual sintaxe seria melhor.

[union|sum|pick|oneof] {
    type1
    package1.type2
    ....
}

O texto acima parece mais semelhante, mas é um pouco prolixo para esse tipo.

Por outro lado, type1 | package1.type2 pode não se parecer com o seu tipo de go normal, no entanto, ganha o benefício de usar o caractere '|' símbolo, que é predominantemente reconhecido como OR. E reduz a verbosidade sem ser enigmática.

@urandom se você não tiver "nomes de tag", mas permitir interfaces, as somas interface{} com verificações extras. Eles param de ser tipos de soma, pois você pode inserir uma coisa, mas retirá-la de várias maneiras. Os nomes das tags permitem que sejam tipos de soma e contenham interfaces sem ambigüidade.

Os nomes de tag corrigem muito mais do que apenas o problema de interface {}, no entanto. Eles tornam o tipo muito menos mágico e permitem que tudo seja gloriosamente explícito, sem ter que inventar um monte de tipos apenas para diferenciar. Você pode ter atribuição explícita e digitar literais, como você indicou.

O fato de você poder atribuir a um tipo mais de uma tag é um recurso. Considere um tipo para medir quantos sucessos ou falhas aconteceram em uma linha (1 sucesso cancela N falhas e vice-versa)

type Counter union {
  Successes, Failures uint 
}

sem os nomes de tag que você precisa

type (
  Success uint
  Failures uint
  Counter Successes | Failures
)

e a atribuição seria semelhante a c = Successes(1) vez de c.Successes = 1 . Você não ganha muito.

Outro exemplo é um tipo que representa falha local ou remota. Com nomes de tag, isso é fácil de modelar:

type Failure union {
  Local, Remote error
}

A providência do erro pode ser especificada com seu nome de tag, independentemente de qual seja o erro real. Sem nomes de tag, você precisaria de type Local { error } e o mesmo para remoto, mesmo se permitir interfaces diretamente na soma.

Os nomes das tags são uma espécie de criação de tipos não especiais nem apelidos nem tipos nomeados localmente no sindicato. Ter várias "tags" com tipos idênticos não é exclusivo da minha proposta: é o que toda linguagem funcional (que eu conheço) faz.

A capacidade de criar tags não exportadas para tipos exportados e vice-versa também é uma reviravolta interessante.

Além disso, ter asserções de tag e tipo separadas permite algum código interessante, como ser capaz de promover um método compartilhado para a união com um wrapper de uma linha.

Parece que resolve mais problemas do que causa e faz com que tudo se encaixe de forma muito mais agradável. Sinceramente, não tinha tanta certeza quando o escrevi, mas estou cada vez mais convencido de que é a única maneira de resolver todos os problemas com a integração de somas em Go.

Para expandir um pouco isso, o exemplo motivador para mim foi de @rogpeppe io.Reader | io.ReadCloser . Permitindo interfaces sem tags, este é o mesmo tipo que io.Reader .

Você pode colocar um ReadCloser e retirá-lo como um Reader. Você perde o A | B significa propriedade A ou B de tipos de soma.

Se você precisa ser específico sobre como às vezes manipular io.ReadCloser como io.Reader você precisa criar estruturas de invólucro como @bcmills apontou, type Reader struct { io.Reader } etc. e ter o tipo Reader | ReadCloser .

Mesmo se você limitar somas a interfaces com conjuntos de métodos separados, ainda terá esse problema porque um tipo pode implementar mais de uma dessas interfaces. Você perde a clareza dos tipos de soma: eles não são "A ou B": eles são "A ou B ou às vezes o que você quiser".

Pior, se esses tipos forem de outros pacotes, eles podem repentinamente se comportar de maneira diferente após uma atualização, mesmo que você tenha muito cuidado ao construir seu programa de forma que A nunca seja tratado da mesma forma que B.

Originalmente, explorei a proibição de interfaces para resolver o problema. Ninguém ficou feliz com isso! Mas também não eliminou problemas como a = b significando coisas diferentes dependendo dos tipos de aeb, com os quais não estou confortável. Também deve haver muitas regras sobre que tipo é escolhido na escolha quando a atribuição de tipo entra em jogo. É muita magia.

Você adiciona tags e tudo vai embora.

Com union { R io.Reader | RC io.ReadCloser } você pode dizer explicitamente que quero que este ReadCloser seja considerado um leitor, se isso fizer sentido. Não são necessários tipos de invólucro. Está implícito na definição. Independentemente do tipo de tag, é uma tag ou outra.

A desvantagem é que, se você obtiver um io.Reader de algum outro lugar, digamos um chan receber ou uma chamada de função, e pode ser um io.ReadCloser e você precisa atribuí-lo à tag apropriada que você precisa digitar assert on io. LeiaCloser e teste. Mas isso torna a intenção do programa muito mais clara - exatamente o que você quer dizer está no código.

Além disso, como as asserções de tag são diferentes das asserções de tipo, se você realmente não se importa e apenas deseja um io.Reader independentemente, você pode usar uma asserção de tipo para retirá-la, independentemente da tag.

Esta é uma transliteração de melhor esforço de um exemplo de brinquedo em Go without unions / summs / etc. Provavelmente não é o melhor exemplo, mas é o que usei para ver como seria.

Ele mostra a semântica de uma forma mais operacional, o que provavelmente será mais fácil de entender do que alguns pontos concisos em uma proposta.

Há um pouco de clichê na transliteração, então geralmente só escrevi a primeira instância de vários métodos com uma nota sobre a repetição.

Proposta de sindicato em andamento:

type fail union { //zero value: (Local, nil)
  Local, Remote error
}

func (f fail) Error() string {
  //Could panic if local/remote nil, but assuming
  //it will be constructed purposefully
  return f.(error).Error()
}

type U union { //zero value: (A, "")
  A, B, C string
  D, E    int
  F       fail
}

//in a different package

func create() pkg.U {
  return pkg.U{D: 7}
}

func process(u pkg.U) {
  switch u := u.[type] {
  case A:
    handleA(u) //undefined here, just doing something with unboxed value
  case B:
    handleB(u)
  case C:
    handleC(u)
  case D:
    handleD(u)
  case E:
    handleE(u)
  case F:
    switch u := u.[type] {
    case Local:
      log.Fatal(u)
    case Remote:
      log.Printf("remote error %s", u)
      retry()
    } 
  }
}

Transliterado para Go atual:

(estão incluídas notas sobre as diferenças entre a transliteração e a anterior)

const ( //simulates tags, namespaced so other packages can see them without overlap
  Fail_Local = iota
  Fail_Remote
)

//since there are only two tags with a single type this can
//be represented precisely and safely
//the error method on the full version of fail can be
//put more succinctly with type embedding in this case

type fail struct { //zero value (Fail_Local, nil) :)
  remote bool
  error
}

// e, ok := f.[Local]
func (f *fail) TagAssertLocal2() (error, bool) { //same for TagAssertRemote2
  if !f.remote {
    return nil, false
  }
  return f.error, true
}

// e := f.[Local]
func (f *fail) TagAssertLocal() error { //same for TagAssertRemote
  if !f.remote {
    panic("invalid tag assert")
  }
  return f.error
}

// f.Local = err
func (f *fail) SetLocal(err error) { //same for SetRemote
  f.remote = false
  f.error = err
}

// simulate tag switch
func (f *fail) TagSwitch() int {
  if f.remote {
    return Fail_Remote
  }
  return Fail_Local
}

// f.(someType) needs to be written as f.TypeAssert().(someType)
func (f *fail) TypeAssert() interface{} {
  return f.error
}

const (
  U_A = iota
  U_B
  // ...
  U_F
)

type U struct { //zero value (U_A, "", 0, fail{}) :(
  kind int //more than two types, need an int
  s string //these would all occupy the same space
  i int
  f fail
}

//s, ok := u.[A]
func (u *U) TagAssertA2() (string, bool) { //similar for B, etc.
  if u.kind == U_A {
    return u.s, true
  }
  return "", false
}

//s := u.[A]
func (u *U) TagAssertA() string { //similar for B, etc.
  if u.kind != U_A {
    panic("invalid tag assert")
  }
  return u.s
}

// u.A = s
func (u *U) SetA(s string) { //similar for B, etc.
  //if there were any pointers or reference types
  //in the union, they'd have to be nil'd out here,
  //since the space isn't shared
  u.kind = U_A
  u.s = s
}

// special case of u.F.Local = err
func (u *U) SetF_Local(err error) { //same for SetF_Remote
  u.kind = U_F
  u.f.SetLocal(err)
}

func (u *U) TagSwitch() int {
  return u.kind
}

func (u *U) TypeAssert() interface{} {
  switch u.kind {
  case U_A, U_B, U_C:
    return u.s
  case U_D, U_E:
    return u.i
  }
  return u.f
}

//in a different package

func create() pkg.U {
  var u pkg.U
  u.SetD(7)
  return u
}

func process(u pkg.U) {
  switch u.TagSwitch() {
  case U_A:
    handleA(u.TagAssertA())
  case U_B:
    handleB(u.TagAssertB())
  case U_C:
    handleC(u.TagAssertC())
  case U_D:
    handleD(u.TagAssertD())
  case U_E:
    handleE(u.TagAssertE())
  case U_F:
    switch u := u.TagAssertF(); u.TagSwitch() {
    case Fail_Local:
      log.Fatal(u.TagAssertLocal())
    case Fail_Remote:
      log.Printf("remote error %s", u.TagAssertRemote())
    }
  }
}

@jimmyfrasche

Visto que a união contém tags que podem ter o mesmo tipo, a seguinte sintaxe não seria mais adequada:

func process(u pkg.U) {
  switch v := u {
  case A:
    handleA(v) //undefined here, just doing something with unboxed value
  case B:
    handleB(v)
  case C:
    handleC(v)
  case D:
    handleD(v)
  case E:
    handleE(v)
  case F:
    switch w := v {
    case Local:
      log.Fatal(w)
    case Remote:
      log.Printf("remote error %s", w)
      retry()
    } 
  }
}

A meu ver, quando usado com um switch, uma união é bastante semelhante a tipos como int ou string. A principal diferença é que existem apenas 'valores' finitos que podem ser atribuídos a ele, ao contrário dos primeiros tipos, e a troca em si é exaustiva. Assim, neste caso não vejo realmente a necessidade de uma sintaxe especial, reduzindo o trabalho mental do desenvolvedor.

Além disso, sob esta proposta, esse código seria válido:

type Foo union {
    // Completely different types, no ambiguity
    A string
    B int
}

func Bar(f Foo) {
    switch v := f {
        ....
    }
}

....

func main() {
    // No need for Bar(Foo{A: "hello world"})
    Bar("hello world")
    Bar(1)
}

@urandom Eu escolhi uma sintaxe para refletir a semântica usando analogias com a sintaxe Go existente sempre que possível.

Com os tipos de interface, você pode fazer

var i someInterface = someValue //where someValue implements someInterface.
var j someInterface = i //this assignment is different from the last one.

Isso é bom e inequívoco, pois não importa o tipo de someValue , desde que o contrato seja satisfeito.

Quando você introduz tags † em uniões, às vezes pode ser ambíguo. Atribuição de magia só seria válida em certos casos. O caso especial só evita que você precise ser explícito às vezes.

Não vejo sentido em poder às vezes pular uma etapa, especialmente quando uma alteração no código pode facilmente invalidar aquele caso especial e então você tem que voltar e atualizar todo o código de qualquer maneira. Para usar seu exemplo de Foo / Bar, se C int for adicionado a Foo então Bar(1) terá que mudar, mas não Bar("hello world") . Isso complica tudo para economizar alguns toques de tecla em situações que podem não ser tão comuns e torna os conceitos mais difíceis de entender porque às vezes eles se parecem com isso e outras vezes com isso - basta consultar este fluxograma prático para ver o que se aplica a você!

† Eu gostaria de ter um nome melhor para eles. Já existem tags de estrutura. Eu os teria chamado de rótulos, mas Go os tem também. Chamá-los de campos parece mais apropriado e mais confuso. Se alguém quiser andar de bicicleta, este precisa de um casaco novo.

Em certo sentido, as uniões marcadas são mais semelhantes a uma estrutura do que a uma interface. Eles são um tipo especial de estrutura que só pode ter um campo definido por vez. Visto por essa luz, seu exemplo Foo / Bar seria como dizer o seguinte:

type Foo struct {
  A string
  B int
}

func Bar(f Foo) {...}

func main() {
  Bar("hello world") //same as Bar(Foo{A: "hello world", B: 0})
  Bar(1) //same as Bar(Foo{A: "", B: 1})
}

Embora não seja ambíguo neste caso, não acho que seja uma boa ideia.

Também na proposta Bar(Foo{1}) é permitido quando não é ambíguo se você realmente deseja salvar as teclas digitadas. Você também pode ter ponteiros para uniões, de forma que a sintaxe literal composta ainda seja necessária para &Foo{"hello world"} .

Dito isso, os sindicatos têm uma semelhança com as interfaces no sentido de que possuem uma tag dinâmica cujo "campo" está definido no momento.

O switch v := u.[type] {... espelha agradavelmente o switch v := i.(type) {... para interfaces enquanto ainda permite alternar tipos e asserções diretamente em valores de união. Talvez devesse ser u.[union] para facilitar a localização, mas de qualquer forma a sintaxe não é tão pesada e está claro o que significa.

Você poderia fazer o mesmo argumento de que .(type) é desnecessário, mas quando você vê isso, você sempre sabe exatamente o que está acontecendo e isso justifica totalmente, na minha opinião.

Esse foi o meu raciocínio por trás dessas escolhas.

@jimmyfrasche
A sintaxe do switch parece um pouco contra-intuitiva para mim, mesmo depois de suas explicações. Com uma interface, switch v := i.(type) {... alterna entre os tipos possíveis, conforme listado pelos casos de alternância e indicado por .(type) .
No entanto, com uma união, um switch não está alternando entre os tipos possíveis, mas os valores. Cada caso representa um valor possível diferente, onde os valores podem de fato compartilhar o mesmo tipo. Isso é mais semelhante a strings e chaves int, onde os casos também listam valores e sua sintaxe é um simples switch v := u {... . A partir disso, para mim parece mais natural que alternar entre os valores de uma união seria switch v := u { ... , uma vez que os casos são semelhantes, mas mais restritivos, do que os casos de ints e strings.

@urandom é um ponto muito bom sobre a sintaxe. A verdade é que é um resquício da minha proposta anterior sem rótulos, então era o tipo. Eu apenas copiei cegamente sem pensar. Obrigado por apontar isso.

switch u {... funcionaria, mas o problema com switch v := u {... é que ele se parece muito com switch v := f(); v {... (o que tornaria o relato de erros mais difícil - não está claro qual era o objetivo).

Se a palavra-chave union fosse renomeada para pick conforme sugerido por @as, então a troca de tag poderia ser escrita como switch u.[pick] {... ou switch v := u.[pick] {... que mantém a simetria com um switch de tipo, mas perde a confusão e parece muito bom.

Mesmo se a implementação estiver ativando um int, ainda há uma desestruturação implícita da seleção em uma tag dinâmica e valor armazenado, o que eu acho que deveria ser explícito, independentemente das regras gramaticais

você sabe, apenas chamar os campos de tags e tê-los como campo assert e field switch faz muito sentido.

editar: isso tornaria o uso de refletir com picaretas estranho, embora

[Desculpe pela demora na resposta - eu estava de férias]

@ianlancetaylor escreveu:

Algo que não vejo em suas propostas é por que devemos fazer isso. Há uma desvantagem clara em adicionar um novo tipo de tipo: é um novo conceito que todos que aprenderem Go terão que aprender. Qual é a vantagem compensatória? Em particular, o que o novo tipo de tipo nos oferece que não obtemos dos tipos de interface?

Existem duas vantagens principais que vejo. O primeiro é uma vantagem do idioma; o segundo é uma vantagem de desempenho.

  • Ao processar mensagens, principalmente quando lidas de um processo concorrente, é muito útil saber o conjunto completo de mensagens que podem ser recebidas, pois cada mensagem pode vir com requisitos de protocolo associados. Para um determinado protocolo, o número de tipos de mensagens possíveis pode ser muito pequeno, mas quando usamos uma interface aberta para representar as mensagens, essa invariante não é clara. Freqüentemente, as pessoas usam um canal diferente para cada tipo de mensagem para evitar isso, mas isso tem seus próprios custos.

  • há momentos em que há um pequeno número de tipos de mensagens possíveis conhecidos, nenhum dos quais contém ponteiros. Se usarmos uma interface aberta para representá-los, precisamos incorrer em uma alocação para fazer os valores da interface. Usar um tipo que restringe os possíveis tipos de mensagem significa que pode ser evitado e, portanto, aliviar a pressão do GC e aumentar a localidade do cache.

Um problema particular para mim que os tipos de soma podem resolver é o godoc. Pegue ast.Spec por exemplo: https://golang.org/pkg/go/ast/#Spec

Muitos pacotes listam manualmente os possíveis tipos subjacentes de um tipo de interface nomeado, para que um usuário possa rapidamente ter uma ideia sem ter que olhar para o código ou depender de sufixos ou prefixos de nome.

Se a linguagem já conhece todos os valores possíveis, isso poderia ser automatizado em godoc muito semelhante aos tipos enum com iotas. Eles também podem ser vinculados aos tipos, em vez de serem apenas texto simples.

Editar: outro exemplo: https://github.com/mvdan/sh/commit/ebbfda50dfe167bee741460a4491ffec1006bdef

@mvdan é um ponto excelente e prático para melhorar a história no Go1 sem nenhuma alteração de idioma. Você pode registrar um problema separado para isso e fazer referência a este?

Desculpe, você está se referindo apenas a links para outros nomes na página godoc, mas ainda listando-os manualmente?

Desculpe, deveria ter sido mais claro.

Eu quis dizer uma solicitação de recurso para lidar automaticamente com tipos que implementam interfaces definidas no pacote atual em godoc.

(Acredito que haja uma solicitação de recurso em algum lugar para vincular nomes listados manualmente, mas não tenho tempo para procurá-la no momento).

Não desejo assumir este tópico (já muito longo), então criei um problema separado - veja acima.

@Merovius Estou respondendo a https://github.com/golang/go/issues/19814#issuecomment -298833986 nesta edição, já que o material AST se aplica mais a tipos de soma do que enums. Desculpas por puxar você para um problema diferente.

Em primeiro lugar, gostaria de reiterar que não tenho certeza se os tipos de soma pertencem ao Go. Ainda tenho que me convencer de que eles definitivamente não pertencem. Estou trabalhando supondo que sim, a fim de explorar a ideia e ver se eles se encaixam. Estou disposto a ser convencido de qualquer maneira, no entanto.

Em segundo lugar, você mencionou o reparo gradual do código em seu comentário. Adicionar um novo termo a um tipo de soma é, por definição, uma alteração significativa, semelhante a adicionar um novo método a uma interface ou remover um campo de uma estrutura. Mas esse é o comportamento correto e desejado.

Vamos considerar o exemplo de um AST, implementado com uma interface Node, que adiciona um novo tipo de nó. Digamos que a AST seja definida em um projeto externo e você esteja importando isso em um pacote em seu projeto, que percorre a AST.

Existem vários casos:

  1. Seu código espera percorrer todos os nós:
    1.1. Você não tem uma instrução padrão, seu código está silenciosamente incorreto
    1.2. Você tem uma instrução padrão com pânico, seu código falha em tempo de execução em vez de tempo de compilação (os testes não ajudam porque eles só sabem sobre os nós que existiam quando você escreveu os testes)
  2. Seu código inspeciona apenas um subconjunto de tipos de nós:
    2.1. Este novo tipo de nó não estaria no subconjunto de qualquer maneira
    2.1.1. Contanto que este novo nó nunca contenha nenhum dos nós em que você está interessado, tudo funcionará
    2.1.2. Caso contrário, você está na mesma situação em que se esperasse que seu código percorresse todos os nós
    2.2. Esse novo tipo de nó estaria no subconjunto em que você está interessado, caso soubesse disso.

Com o AST baseado em interface, apenas o caso 2.1.1 funciona corretamente. Isso é coincidência tanto quanto qualquer coisa. O reparo gradual do código não funciona. O AST deve alterar sua versão e seu código deve alterar sua versão.

Um linter exaustivo ajudaria, mas como o linter não pode examinar todos os tipos de interface, ele precisa ser informado de alguma maneira que uma interface específica precisa ser verificada. Isso significa um comentário na fonte ou algum tipo de arquivo de configuração em seu repo. Se for um comentário na fonte, já que por definição o AST é definido em um projeto separado, você está à mercê desse projeto para marcar a interface para verificação exaustiva. Isso só funciona em escala se houver um único linter de exaustividade com o qual toda a comunidade concorda e sempre usa.

Com um AST baseado em soma, você ainda precisa usar o controle de versão. A única diferença neste caso é que o linter de exaustividade está embutido no compilador.

Nem ajuda com 2.2, mas o que poderia?

Há um caso mais simples, adjacente ao AST, em que os tipos de soma seriam úteis: tokens. Digamos que você esteja escrevendo um Lexer para uma calculadora mais simples. Existem tokens como * que não têm nenhum valor associado a eles e tokens como Var que têm uma string representando o nome e tokens como Val que contêm um float64 .

Você poderia implementar isso com interfaces, mas seria cansativo. Você provavelmente faria algo assim:

package token
type Type int
const (
  Times Type = iota
  // ...
  Var
  Val
)
type Value struct {
  Type
  Name string // only valid if Type == Var
  Number float64 // only valid if Type == Val
}

Um linter exaustivo em enums baseados em iota poderia garantir que um Tipo ilegal nunca seja usado, mas não funcionaria muito bem contra alguém atribuindo a Nome quando Tipo == Vezes ou usando Número quando Tipo == Var. À medida que o número e o tipo de tokens aumentam, só piora. Realmente, o melhor que você pode fazer aqui é adicionar um método, Valid() error , que verifica todas as restrições e um monte de documentação explicando quando você pode fazer o quê.

Um tipo de soma codifica facilmente todas essas restrições e a definição seria toda a documentação necessária. Adicionar um novo tipo de token seria uma alteração importante, mas tudo o que eu disse sobre AST ainda se aplica aqui.

Acho que mais ferramentas são necessárias. Só não estou convencido de que seja suficiente.

@jimmyfrasche

Em segundo lugar, você mencionou o reparo gradual do código em seu comentário. Adicionar um novo termo a um tipo de soma é, por definição, uma alteração significativa, semelhante a adicionar um novo método a uma interface ou remover um campo de uma estrutura.

Não, não está no par. Você pode fazer ambas as mudanças em um modelo de reparo gradual (para interfaces: 1. Adicionar novo método a todas as implementações, 2. Adicionar método à interface. Para campos de estrutura: 1. Remover todos os usos de campo, 2. Remover campo). Adicionar uma caixa em um tipo de soma não pode funcionar em um modelo de reparo gradual; se você adicionar faça a lib primeiro, quebraria todos os usuários, já que eles não verificam mais exaustivamente, mas você não pode adicionar aos usuários primeiro, porque o novo caso ainda não existe. O mesmo vale para a remoção.

Não se trata de ser ou não uma alteração significativa, mas sim se é uma alteração significativa que pode ser orquestrada com o mínimo de interrupção.

Mas esse é o comportamento correto e desejado.

Exatamente. Os tipos de soma, por sua própria definição e todos os motivos pelos quais as pessoas os desejam, são fundamentalmente incompatíveis com a ideia de reparo gradual de código.

Com o AST baseado em interface, apenas o caso 2.1.1 funciona corretamente.

Não, ele também funciona corretamente no caso 1.2 (falhar em tempo de execução para gramática não reconhecida é perfeitamente normal. Eu provavelmente não gostaria de entrar em pânico, mas apenas retornar um erro) e também em muitos casos de 2.1. O resto é uma questão fundamental com a atualização de software; se você adicionar um novo recurso a uma biblioteca, os usuários de sua biblioteca precisarão alterar o código para fazer uso dele. Isso não significa que seu software esteja incorreto até que isso aconteça.

O AST deve alterar sua versão e seu código deve alterar sua versão.

Não vejo como isso decorre do que você está dizendo, de forma alguma. Para mim, dizer "esta nova gramática ainda não funcionará com todas as ferramentas, mas está disponível para o compilador" é bom. Assim como "se você executar esta ferramenta nesta nova gramática, ela falhará em tempo de execução" está bem. Na pior das hipóteses, isso apenas adiciona outra etapa ao processo de reparo gradual: a) Adicione o novo nó ao pacote e analisador AST. b) Corrija as ferramentas usando o pacote AST para aproveitar as vantagens do novo nó. c) Atualize o código para usar o novo nó. Sim, o novo nó só se tornará utilizável depois que a) eb) estiverem concluídas; mas em cada etapa desse processo, sem nenhuma quebra, tudo ainda vai compilar e funcionar corretamente.

Não estou dizendo que você ficará automaticamente bem em um mundo de reparo gradual de código e nenhuma verificação exaustiva do compilador. Isso ainda exigirá planejamento e execução cuidadosos, você provavelmente ainda quebrará dependências reversas não mantidas e ainda poderá haver alterações que você não poderá fazer (embora eu não consiga pensar em nenhuma). Mas pelo menos a) há um caminho de atualização gradual eb) a decisão de se isso deve interromper sua ferramenta em tempo de execução, ou não, depende do autor da ferramenta. Eles podem decidir o que fazer em um caso desconhecido.

Um linter exaustivo ajudaria, mas como o linter não pode examinar todos os tipos de interface, ele precisa ser informado de alguma maneira que uma interface específica precisa ser verificada.

Porque? Eu diria que não há problema em switchlint ™ reclamar de qualquer switch de tipo sem caixa padrão; afinal, você esperaria que o código funcionasse com qualquer definição de interface, portanto, não ter código para funcionar com implementações desconhecidas é provavelmente um problema de qualquer maneira. Sim, existem exceções a esta regra, mas as exceções já podem ser ignoradas manualmente.

Eu provavelmente estaria mais empenhado em impor "cada troca de tipo deve exigir um caso padrão, mesmo se estiver vazio" no compilador, do que com os tipos de soma reais. Isso permitiria e forçaria as pessoas a tomar a decisão sobre o que seu código deveria fazer quando confrontado com uma escolha desconhecida.

Você poderia implementar isso com interfaces, mas seria cansativo.

encolher os ombros é um esforço único em um caso que muito raramente surge. Parece bom para mim.

E FWIW, atualmente estou apenas argumentando contra a noção de verificação exaustiva dos tipos de soma. Eu ainda não tenho nenhuma opinião forte sobre a conveniência adicional de dizer "qualquer um desses tipos estruturalmente definidos".

@Merovius Vou ter que pensar mais sobre seus excelentes pontos sobre o reparo gradual de código. Enquanto isso:

verificações de exaustividade

No momento, estou apenas argumentando contra a noção de verificação exaustiva dos tipos de soma.

Você pode excluir explicitamente as verificações de exaustividade com um caso padrão (bem, efetivamente: o padrão o torna exaustivo ao adicionar um caso que cobre "qualquer outra coisa, seja o que for"). Você ainda tem uma escolha, mas deve torná-la explicitamente.

Eu diria que não há problema em switchlint ™ reclamar de qualquer switch de tipo sem caixa padrão; afinal, você esperaria que o código funcionasse com qualquer definição de interface, portanto, não ter código para funcionar com implementações desconhecidas é provavelmente um problema de qualquer maneira. Sim, existem exceções a esta regra, mas as exceções já podem ser ignoradas manualmente.

É uma ideia interessante. Embora acertasse os tipos de soma simulados com interface e enums simulados com const / iota, não informa que você perdeu um caso conhecido, apenas que não tratou do caso desconhecido. Apesar de tudo, parece barulhento. Considerar:

switch {
case n < 0:
case n == 0:
case n > 0:
}

Isso é exaustivo se n for integral (para flutuantes está faltando n != n ), mas sem codificar muitas informações sobre os tipos, é provavelmente mais fácil apenas sinalizar como padrão ausente. Para algo como:

switch {
case p[0](a, b):
case p[1](a, b):
//...
case p[N](a, b):
}

mesmo que p[i] formem uma relação de equivalência nos tipos a e b ele não será capaz de provar isso, então deve sinalizar o switch como sem um padrão caso, o que significa uma maneira de silenciá-lo com um manifesto, uma anotação na fonte, um script de wrapper para egrep -v da lista de permissões ou um padrão desnecessário no switch que falsamente implica que o p[i] não são exaustivos.

De qualquer forma, isso seria trivial de implementar se a rota "sempre reclamar sobre nenhum default em todas as circunstâncias" for seguida. Seria interessante fazer isso e executá-lo no go-corpus e ver o quão barulhento e / ou útil ele é na prática.

tokens

Implementações alternativas de token:

//Type defined as before
type SimpleToken { Type }
type StringToken { Type; Value string }
type NumberToken { Type; Value float64 }
type Interface interface {
  //some method common to all these types, maybe just token() Interface
}

Isso elimina a possibilidade de definir um estado de token ilegal onde algo tem uma string e um valor numérico, mas não impede a criação de StringToken com um tipo que deveria ser SimpleToken ou vice versa.

Para fazer isso com interfaces, você precisa definir um tipo por token ( type Plus struct{} , type Mul struct{} , etc.) e a maioria das definições são exatamente as mesmas exatamente para o nome do tipo. Um esforço ou não é muito trabalhoso (embora seja adequado para geração de código neste caso).

Suponho que você possa ter uma "hierarquia" de interfaces de token para particionar os tipos de tokens com base nos valores permitidos: (Supondo que neste exemplo haja mais de um tipo de token que pode conter um número ou string, etc.)

type SimpleToken int //implements token.Interface
const (
  Plus SimpleToken = iota
  // ...
}
type NumericToken interface {
  Interface
  Value() float64
  nt() NumericToken
}
type IntToken struct { //implements NumericToken, and a FloatToken
type StringToken interface { // for Var and Func and Const, etc.
  Interface
  Value() string
  st() StringToken
}

Independentemente disso, isso significa que cada token requer uma deferência de ponteiro para acessar seu valor, ao contrário do tipo struct ou soma, que só exige ponteiros quando strings estão envolvidas. Portanto, com linters apropriados e melhorias para godoc, a grande vitória para tipos de soma, neste caso, está relacionada à minimização de alocações, ao mesmo tempo em que não permite estados ilegais e a quantidade de digitação (no sentido do teclado), o que não parece sem importância.

Você pode excluir explicitamente as verificações de exaustividade com um caso padrão (bem, efetivamente: o padrão o torna exaustivo ao adicionar um caso que cobre "qualquer outra coisa, seja o que for"). Você ainda tem uma escolha, mas deve torná-la explicitamente.

Portanto, parece que de qualquer maneira, nós dois teremos a opção de ativar ou desativar a verificação exaustiva :)

não diz que você perdeu um caso conhecido, apenas que não tratou do caso desconhecido.

Efetivamente, eu acredito, o compilador já faz uma análise de todo o programa para determinar quais tipos concretos são usados ​​em quais interfaces eu acho ? Eu esperava pelo menos que, pelo menos para asserções de tipo não-interface (ou seja, asserções de tipo que não são afirmativas para um tipo de interface, mas para um tipo concreto), gere as tabelas de funções usadas em interfaces em tempo de compilação.
Mas, honestamente, isso é argumentado a partir dos primeiros princípios, não tenho ideia sobre a implementação real.

Em qualquer caso, deve ser muito fácil a) listar qualquer tipo concreto definido em um programa inteiro eb) para qualquer switch de tipo, filtrá-los para saber se eles implementam essa interface. Se você usar algo como isso , você pode acabar com uma lista confiável. Eu penso.

Não estou 100% convencido de que uma ferramenta pode ser escrita que seja tão confiável quanto realmente declarar explicitamente as opções, mas estou convencido de que você poderia cobrir 90% dos casos e definitivamente poderia escrever uma ferramenta que faça isso fora de o compilador, dadas as anotações corretas (ou seja, fazer sum-types um comentário do tipo pragma, não um tipo real). Não é uma ótima solução, admito.

Apesar de tudo, parece barulhento. Considerar:

Eu acho que isso está sendo injusto. Os casos que você está mencionando não têm absolutamente nada a ver com sum-types. Se eu fosse escrever tal ferramenta, iria restringi-la a comutadores de tipo e comutadores com uma expressão, já que esses parecem ser a forma como os tipos de soma também seriam tratados.

Implementações alternativas de token:

Por que não um método de marcador? Você não precisa de um campo de tipo, você o obtém gratuitamente na representação da interface. Se você está preocupado em repetir o método do marcador indefinidamente; definir uma estrutura não exportada {}, fornecer esse método de marcador e incorporá-lo em cada implementação, sem custo extra e menos digitação por opção do que seu método.

Independentemente disso, significa que cada token requer uma deferência de ponteiro para acessar seu valor

sim. É um custo real, mas não acho que supere basicamente qualquer outro argumento.

Eu acho que isso está sendo injusto.

Isso é verdade.

Eu escrevi uma versão rápida e suja e a executei no stdlib. A verificação de qualquer instrução switch tinha 1956 ocorrências, restringindo-a para ignorar a forma switch { , reduzindo essa contagem para 1677. Não inspecionei nenhum desses locais para ver se o resultado é significativo.

https://github.com/jimmyfrasche/switchlint

Certamente há muito espaço para melhorias. Não é muito sofisticado. Solicitações de pull são bem-vindas.

(Vou responder ao resto mais tarde)

editar: formato de marcação errado

Acho que este é um resumo (bastante tendencioso) de tudo até agora (e narcisisticamente assumindo minha segunda proposta)

Prós

  • conciso, fácil de escrever uma série de restrições sucintamente de uma maneira autodocumentada
  • melhor controle de alocações
  • mais fácil de otimizar (todas as possibilidades conhecidas pelo compilador)
  • verificação exaustiva (quando desejado, pode cancelar)

Contras

  • qualquer alteração aos membros de um tipo de soma é uma alteração significativa, não permitindo o reparo gradual do código, a menos que todos os pacotes externos optem por não fazer as verificações de exaustividade
  • mais uma coisa a aprender na linguagem, alguma sobreposição conceitual com recursos existentes
  • o coletor de lixo precisa saber quais membros são ponteiros
  • estranho para somas da forma 1 + 1 + ⋯ + 1

Alternativas

  • iota "enum" para somas na forma 1 + 1 + ⋯ + 1
  • interfaces com um método de tag não exportado para somas mais complicadas (possivelmente geradas)
  • ou struct com um iota enum e regras extralinguísticas sobre quais campos são definidos de acordo com o valor de enums

Sem considerar

  • ferramentas melhores, ferramentas sempre melhores

Para reparos graduais, e isso é um grande problema, acho que a única opção é os pacotes externos optarem pelas verificações de exaustividade. Isso implica que deve ser legal ter um caso padrão "desnecessário" apenas preocupado com a prova futura, mesmo que você corresponda a todo o resto. Acredito que isso seja implicitamente verdade agora, e embora não seja fácil de especificar.

Pode haver um anúncio de um mantenedor do pacote que "ei, vamos adicionar um novo membro a este tipo de soma na próxima versão, certifique-se de que você pode lidar com isso" e, em seguida, uma ferramenta switchlint poderia encontrar qualquer caso que precise ser desativado.

Não é tão simples quanto outros casos, mas ainda é bastante factível.

Ao escrever um programa que usa um tipo de soma definido externamente, você pode comentar o padrão para ter certeza de não perder nenhum caso conhecido e, em seguida, descomentar antes de confirmar. Ou pode haver uma ferramenta para informá-lo de que o padrão é "desnecessário", o que indica que você tem tudo que é conhecido e está preparado para o futuro contra o desconhecido.

Digamos que desejamos optar pela verificação de exaustividade com um linter ao usar tipos de interface que simulam tipos de soma, independentemente do pacote em que estão definidos.

@Merovius, seu betterSumType() BetterSumType é muito legal, mas significa que as mudanças têm que acontecer no pacote de definição (ou você expõe algo como

func CallBeforeSwitches(b BetterSumType) (BetterSumType, bool) {
    if b == nil {
        return nil, false
    }
    b = b.betterSumType()
    if b == nil {
        return nil, false
    }
    return b, true
}

e também lint que é chamado sempre).

Quais são os critérios necessários para verificar se todas as opções em um programa são exaustivas?

Não pode ser a interface vazia, porque então tudo está em jogo. Portanto, é necessário pelo menos um método.

Se a interface não tiver métodos não exportados, qualquer tipo poderia implementá-la, de forma que a exaustividade dependeria de todos os pacotes até o gráfico de chamada de cada central. É possível importar um pacote, implementar sua interface e enviar esse valor para uma das funções do pacote; portanto, uma opção nessa função não seria capaz de ser exaustiva sem criar um ciclo de importação. Portanto, é necessário pelo menos um método não exportado. (Isso inclui o critério anterior).

A incorporação bagunçaria a propriedade que procuramos, portanto, precisamos garantir que nenhum dos importadores do pacote jamais incorporará a interface ou qualquer um dos tipos que a implementam em qualquer ponto. Um linter realmente sofisticado pode ser capaz de dizer que às vezes a incorporação está bem se nunca chamarmos uma determinada função que cria um valor incorporado ou se nenhuma das interfaces incorporadas "escapar" do limite da API do pacote.

Para sermos completos, precisamos verificar se o valor zero da interface nunca é transmitido ou fazer com que uma chave exaustiva verifique case nil também. (O último é mais fácil, mas o primeiro é preferido, pois a inclusão de nulo transforma uma soma de "tipo A ou tipo B ou tipo C" em uma soma "nula ou tipo A ou tipo B ou tipo C").

Digamos que temos um linter, com todas essas habilidades, mesmo as opcionais, que pode verificar essas semânticas para qualquer árvore de importações e qualquer interface dentro dessa árvore.

Agora, digamos que temos um projeto com uma dependência D. Queremos ter certeza de que uma interface definida em um dos pacotes de D é exaustiva em nosso projeto. Digamos que sim.

Agora, precisamos adicionar uma nova dependência ao nosso projeto D ′. Se D ′ importar o pacote em D que definiu o tipo de interface em questão, mas não usar esse linter, ele pode facilmente destruir as invariantes que precisam ser mantidas para que possamos usar opções completas.

Por falar nisso, digamos que D acabou de passar no linter por coincidência, não porque o mantenedor o executa. Uma atualização para D poderia destruir os invariantes com a mesma facilidade com que D ′.

Mesmo que o linter possa dizer "neste momento, isso é 100% exaustivo 👍", isso pode mudar sem que façamos nada.

Um verificador de exaustividade para "iota enums" parece mais fácil.

Para todos type t u onde u é integral e t é usado como const com valores especificados individualmente ou iota tal que o zero valor para u está incluído entre essas constantes.

Notas:

  • Valores duplicados podem ser tratados como apelidos e ignorados nesta análise. Vamos assumir que todas as constantes nomeadas têm valores distintos.
  • 1 << iota pode ser tratado como um conjunto de poderes, acredito, pelo menos na maioria das vezes, mas provavelmente exigiria condições extras, especialmente em torno do complemento bit a bit. Por enquanto, eles não serão considerados

Para uma abreviatura, vamos chamar min(t) a constante tal que para qualquer outra constante, C , min(t) <= C , e, da mesma forma, vamos chamar max(t) a constante tal que para qualquer outra constante, C , C <= max(t) .

Para garantir que t seja usado exaustivamente, precisamos garantir que

  • valores de t são sempre as constantes nomeadas (ou 0 em certas posições idiomáticas, como invocação de função)
  • Não há comparações de desigualdade de um valor de t , v , fora de min(t) <= v <= max(t)
  • valores de t nunca são usados ​​em operações aritméticas + , / , etc. Uma possível exceção pode ser quando o resultado é fixado entre min(t) e max(t) imediatamente depois, mas isso pode ser difícil de detectar em geral, por isso pode exigir uma anotação nos comentários e provavelmente deve ser restrito ao pacote que define t .
  • switches contêm todas as constantes de t ou um caso padrão.

Isso ainda requer a verificação de todos os pacotes na árvore de importação e pode ser invalidado com a mesma facilidade, embora seja menos provável de ser invalidado no código idiomático.

Meu entendimento é que isso, semelhante aos aliases de tipo, não interromperá as alterações, então por que esperar para Go 2?

Os apelidos de tipo não introduzem uma nova palavra-chave, o que é uma alteração significativa. Também parece haver uma moratória até mesmo em mudanças menores de linguagem e isso seria uma grande mudança. Mesmo apenas o retrofit de todas as rotinas de marechal / unmarshal para lidar com os valores de soma refletidos seria uma grande provação.

O alias de tipo está corrigindo um problema para o qual não havia solução. Os tipos de soma fornecem um benefício na segurança de tipos, mas não é um empecilho não tê-los.

Apenas um (menor) ponto a favor de algo como a proposta original de @rogpeppe . No pacote http , existe o tipo de interface Handler e um tipo de função que o implementa, HandlerFunc . Agora, para passar uma função para http.Handle , você deve explicitamente convertê-la em HandlerFunc . Se http.Handle vez disso aceitasse um argumento do tipo HandlerFunc | Handler , ele poderia aceitar qualquer função / encerramento atribuível a HandlerFunc diretamente. A união serve efetivamente como uma dica de tipo, informando ao compilador como os valores com tipos não nomeados podem ser convertidos para o tipo de interface. Visto que HandlerFunc implementa Handler , o tipo de união se comportaria exatamente como Handler caso contrário.

@griesemer em resposta ao seu comentário no tópico enum, https://github.com/golang/go/issues/19814#issuecomment -322752526, acho que minha proposta anterior neste tópico https://github.com/golang/ go / issues / 19412 # issuecomment -289588569 aborda a questão de como os tipos de soma ("enums de estilo swift") teriam que funcionar no Go. Por mais que eu goste deles, não sei se eles seriam uma adição necessária ao Go, mas eu acho que se eles fossem adicionados, eles teriam que se parecer / operar muito assim.

Essa postagem não está completa e há esclarecimentos ao longo deste tópico, antes e depois, mas não me importo de reiterar esses pontos ou resumir, já que este tópico é bastante longo.

Se você tiver um tipo de soma simultâneo por uma interface com uma etiqueta de tipo e absolutamente não puder ser contornado por incorporação, esta é a melhor defesa que eu inventei: https://play.golang.org/p/FqdKfFojp-

@jimmyfrasche Escrevi isso há um tempo.

Outra abordagem possível é esta: https://play.golang.org/p/p2tFm984S8

@rogpeppe se você vai usar reflexão, por que não usar apenas reflexão?

Eu escrevi uma versão revisada de minha segunda proposta com base em comentários aqui e em outras edições.

Notavelmente, removi a verificação de exaustividade. No entanto, um verificador de exaustividade externo é trivial de escrever para a proposta abaixo, embora eu não acredite que um possa ser escrito para outros tipos de Go usados ​​para simular um tipo de soma.

Edit: Eu removi a capacidade de digitar assert no valor dinâmico de um valor de seleção. É mágico demais e a razão para permitir isso também é servida pela geração de código.

Edit2: esclareceu como os nomes dos campos funcionam com asserções e opções quando a seleção é definida em outro pacote.

Edit3: incorporação restrita e nomes de campo implícitos esclarecidos

Edit4: esclarece o padrão no switch

Escolha os tipos

Uma seleção é um tipo composto sintaticamente semelhante a uma estrutura:

pick {
  A, B S
  C, D T
  E U "a pick tag"
}

Acima, A , B , C , D e E são os nomes dos campos da escolha e S , T e U são os respectivos tipos desses campos. Os nomes dos campos podem ser exportados ou não exportados.

Uma escolha não pode ser recursiva sem direção indireta.

Jurídico

type p pick {
    //...
    p *p
}

Ilegal

type p pick {
    //...
    p p
}

Não há embedding para picks, mas um pick pode ser embutido em um struct. Se um pick estiver embutido em um struct, o método no pick é promovido ao struct, mas os campos de um pick não são.

Um tipo sem um nome de campo é uma abreviação para definir um campo com o mesmo nome do tipo. (Isso é um erro se o tipo não tiver nome, com uma exceção para *T onde o nome é T ).

Por exemplo,

type p pick {
    io.Reader
    io.Writer
    string
}

tem três campos Reader , Writer e string , com os respectivos tipos. Observe que o campo string foi exportado, embora esteja no escopo do universo.

Um valor de um tipo de seleção consiste em um campo dinâmico e no valor desse campo.

O valor zero de um tipo de seleção é seu primeiro campo na ordem de origem e o valor zero desse campo.

Dados dois valores do mesmo tipo de escolha, a e b , o valor de escolha pode ser atribuído como qualquer outro valor

a = b

Atribuir um valor não selecionado, mesmo um de um tipo de um dos campos em uma escolha, é ilegal.

Um tipo de seleção sempre tem um campo dinâmico em um determinado momento.

A sintaxe literal composta é semelhante a structs, mas há restrições extras. Ou seja, literais sem chave são sempre inválidos e apenas uma chave pode ser especificada.

Os seguintes são válidos

pick{A string; B int}{A: "string"} //value is (B, "string")
pick{A, B int}{B: 1} //value is (B, 1)
pick{A, B string}{} //value is (A, "")

Os seguintes são erros de tempo de compilação:

pick{A int; B string}{A: 1, B: "string"} //a pick can only have one value at a time
pick{A int; B uint}{1} //pick composite literals must be keyed

Dado um valor p do tipo pick {A int; B string} a seguinte atribuição

p.B = "hi"

define o campo dinâmico de p para B e o valor de B para "hi".

A atribuição ao campo dinâmico atual atualiza o valor desse campo. A atribuição que define um novo campo dinâmico deve zerar todos os locais de memória não especificados. A atribuição a um campo de seleção ou estrutura de um campo de seleção atualiza ou define o campo dinâmico conforme necessário.

type P pick {
    A, B image.Point
}

var p P
fmt.Println(P) //{A: {0 0}}

p.A.X = 1 //A is the dynamic field, update
fmt.Println(P) //{A: {1 0}}

p.B.Y = 2 //B is not the dynamic value, create zero image.Point first
fmt.Println(P) //{B: {0 2}}

O valor mantido em uma seleção só pode ser acessado por um campo assert ou mudança de campo.

x := p.[X] //panics if X is not the dynamic field of p
x, ok := p.[X] //returns the zero value of X and false if X is not the dynamic field of p

switch v := p.[var] {
case A:
case B, C: // v is only defined in this case if fields B and C have identical type names
case D:
default: // always legal even if all fields are exhaustively listed above
}

Os nomes de campo em asserções de campo e opções de campo são uma propriedade do tipo, não do pacote no qual foi definido. Eles não são, e não podem ser, qualificados pelo nome do pacote que define pick .

Isso é válido:

_, ok := externalPackage.ReturnsPick().[Field]

Isso é inválido:

_, ok := externalPackage.ReturnsPick().[externalPackage.Field]

Asserções de campo e mudanças de campo sempre retornam uma cópia do valor do campo dinâmico.

Os nomes de campo não exportados só podem ser declarados em seu pacote de definição.

Asserções de tipo e interruptores de tipo também funcionam em escolhas.

//removed, see note at top
//v, ok := p.(fmt.Stringer) //holds if the type of the dynamic field implements fmt.Stringer
//v, ok := p.(int) //holds if the type of the dynamic field is an int

As asserções de tipo e as opções de tipo sempre retornam uma cópia do valor do campo dinâmico.

Se a seleção for armazenada em uma interface, as asserções de tipo para interfaces correspondem apenas ao conjunto de métodos da própria seleção. [ainda verdadeiro, mas redundante, pois o acima foi removido]

Se todos os tipos de uma escolha suportam os operadores de igualdade, então:

  • valores dessa escolha podem ser usados ​​como chaves de mapa
  • dois valores da mesma escolha são == se eles têm o mesmo campo dinâmico e seus valores são ==
  • dois valores com campos dinâmicos diferentes são != mesmo se os valores forem == .

Nenhum outro operador é compatível com valores de um tipo de seleção.

Um valor de um tipo de seleção P pode ser convertido em outro tipo de seleção Q se o conjunto de nomes de campo e seus tipos em P for um subconjunto dos nomes de campo e seus digita em Q .

Se P e Q são definidos em pacotes diferentes e têm campos não exportados, esses campos são considerados diferentes, independentemente do nome e tipo.

Exemplo:

type P pick {A int; B string}
type Q pick {B string; A int; C float64}

//legal
var p P
q := Q(p)

//illegal
var q Q
p := P(Q) //cannot handle field C

A atribuição entre dois tipos de escolha é definida como conversibilidade, desde que não mais de um dos tipos seja definido.

Os métodos podem ser declarados em um tipo de seleção definido.

Criei (e adicionei ao wiki) um relatório de experiência https://gist.github.com/jimmyfrasche/ba2b709cdc390585ba8c43c989797325

Edit: and: heart: to @mewmew que deixou um relatório muito melhor e mais detalhado como uma resposta sobre essa essência

E se tivéssemos uma maneira de dizer, para um determinado tipo T , a lista de tipos que poderiam ser convertidos para o tipo T ou atribuídos a uma variável do tipo T ? Por exemplo

type T interface{} restrict { string, error }

define um tipo de interface vazio denominado T forma que os únicos tipos que podem ser atribuídos a ele são string ou error . Qualquer tentativa de atribuir um valor de qualquer outro tipo produz um erro de tempo de compilação. Agora posso dizer

func FindOrFail(m map[int]string, key int) T {
    if v, ok := m[key]; ok {
        return v
    }
    return errors.New("no such key")
}

func Lookup() {
    v := FindOrFail(m, key)
    if err, ok := v.(error); ok {
        log.Fatal(err)
    }
    s := v.(string) // This type assertion must succeed.
}

Quais elementos-chave dos tipos de soma (ou escolha de tipos) não seriam satisfeitos por esse tipo de abordagem?

s := v.(string) // This type assertion must succeed.

Isso não é estritamente verdadeiro, já que v também pode ser nil . Seria necessária uma mudança bastante grande na linguagem para remover essa possibilidade, já que isso significaria introduzir tipos que não têm valores zero e tudo o que isso acarreta. O valor zero simplifica partes da linguagem, mas também torna o projeto desses tipos de recursos mais difícil.

Curiosamente, essa abordagem é bastante semelhante à proposta original de @rogpeppe . O que não há é coerção para os tipos listados, o que pode ser útil em situações como apontei anteriormente ( http.Handler ). Outra coisa é que exige que cada variante seja um tipo distinto, uma vez que as variantes são discriminadas por tipo, em vez de uma marca distinta. Acho que isso é estritamente expressivo, mas algumas pessoas preferem que as tags e os tipos variantes sejam distintos.

@ianlancetaylor

os prós

  • possível restringir a um conjunto fechado de tipos - e isso é definitivamente o principal
  • possível escrever um verificador de exaustividade preciso
  • você obtém a propriedade "você pode atribuir um valor que satisfaça o contrato a esta". (Eu não me importo com isso, mas imagino que outros se importem).

os contras

  • eles são apenas interfaces com benefícios e não são realmente um tipo diferente de tipo (bons benefícios, no entanto!)
  • você ainda tem nulo, então não é realmente um tipo de soma no sentido teórico do tipo. Qualquer A + B + C você especificar é realmente um 1 + A + B + C qual você não tem escolha. Como @stevenblenkinsop apontou enquanto eu trabalhava nisso.
  • mais importante, por causa desse ponteiro implícito, você sempre tem uma indireção. Com a proposta de escolha, você pode escolher ter p ou *p dando a você maior controle sobre as trocas de memória. Você não poderia implementá-los como uniões discriminadas (no sentido C) como uma otimização.
  • nenhuma escolha de valor zero, o que é uma propriedade muito boa, especialmente porque é muito importante em Go ter um valor zero tão útil quanto possível
  • presumivelmente, você não poderia definir métodos em T (mas presumivelmente você teria os métodos da interface que o restrito modifica, mas os tipos no restrito precisariam satisfazê-lo? Caso contrário, não vejo o ponto de não apenas tendo type T restrict {string, error} )
  • se você perder os rótulos dos campos / summands / what-have-you, fica confuso quando interage com os tipos de interface. Você perde a propriedade forte "exatamente isso ou exatamente aquilo" dos tipos de soma. Você poderia colocar io.Reader e retirar io.Writer . Isso faz sentido para interfaces (irrestritas), mas não para tipos de soma.
  • Se você deseja ter dois tipos idênticos com significados diferentes, você precisa usar tipos de invólucro para eliminar a ambigüidade; tal tag teria que estar em um namespace externo ao invés de confinada a um tipo da maneira que um campo de estrutura é
  • isso pode significar muito em seu texto específico, mas parece que muda as regras de atribuição com base no tipo de destinatário (estou lendo como dizendo que você não pode atribuir algo atribuível a error a T deve ser exatamente um erro).

Dito isso, ele marca as caixas principais (os dois primeiros profissionais que listei) e eu pegaria em um piscar de olhos se isso fosse tudo que eu pudesse obter. Espero melhor, no entanto.

Presumi que as regras de asserção de tipo fossem aplicadas. Portanto, o tipo precisa ser idêntico a um tipo concreto ou atribuível a um tipo de interface. Basicamente, ele funciona exatamente como uma interface, mas qualquer valor (diferente de nil ) deve ser declarado para pelo menos um dos tipos listados.

@jimmyfrasche
Em sua proposta atualizada, a seguinte atribuição seria possível, se todos os elementos do tipo fossem de tipos distintos:

type p pick {
    A int
    B string
}

func Foo(P p) {
}

var P p = 42
var Q p = "foo"

Foo(42)
Foo("foo")

A usabilidade dos tipos de soma quando tais atribuições são possíveis é muito maior.

Com a proposta de escolha, você pode escolher ter p ou *p dando a você maior controle sobre as trocas de memória.

A razão pela qual as interfaces são alocadas para armazenar valores escalares é para que você não precise ler uma palavra de tipo para decidir se a outra palavra é um ponteiro; veja # 8405 para discussão. As mesmas considerações de implementação provavelmente se aplicariam a um tipo de seleção, o que pode significar, na prática, que p acabará sendo alocado e não local de qualquer maneira.

@urandom não, dadas suas definições, ele teria que ser escrito

var p P = P{A: 42} // p := P{A: 42}
var q P = P{B: "foo")
Foo(P{A: 42}) // or Foo({A: 42}) if types can be elided here
Foo(P{B: "foo"})

É melhor pensar neles como uma estrutura que só pode ter um campo definido por vez.

Se você não tiver isso e depois adicionar C uint a p que acontecerá a p = 42 ?

Você pode criar várias regras com base na ordem e na capacidade de atribuição, mas elas sempre significam que as alterações na definição do tipo podem ter efeitos sutis e dramáticos em todo o código que usa o tipo.

Na melhor das hipóteses, uma alteração quebra todo o código que depende da falta de ambigüidade e diz que você precisa alterá-lo para p = int(42) ou p = uint(42) antes de compilar novamente. Uma mudança de uma linha não deve exigir a correção de cem linhas. Especialmente se essas linhas estiverem em pacotes de pessoas dependendo do seu código.

Você tem que ser 100% explícito ou ter um tipo muito frágil que ninguém pode tocar porque pode quebrar tudo.

Isso se aplica a qualquer proposta de tipo de soma, mas se houver rótulos explícitos, você ainda tem capacidade de atribuição porque o rótulo é explícito sobre o tipo ao qual está sendo atribuído.

@josharian, então, se estou lendo corretamente, o motivo iface agora é sempre (*type, *value) vez de armazenar valores de tamanho de palavra no segundo campo como Go fez anteriormente é para que o GC simultâneo não precise inspecionar ambos campos para ver se o segundo é um ponteiro - ele pode simplesmente assumir que sempre é. Eu entendi direito?

Em outras palavras, se o tipo de seleção foi implementado (usando notação C) como

struct {
    int which;
    union {
         A a;
         B b;
         C c;
    } summands;
}

o GC precisaria de um cadeado (ou algo sofisticado, mas equivalente) para inspecionar which para determinar se summands precisava ser verificado?

o motivo pelo qual iface agora é sempre (* tipo, * valor) em vez de armazenar valores de tamanho de palavra no segundo campo como Go fazia anteriormente é para que o GC simultâneo não precise inspecionar ambos os campos para ver se o segundo é um ponteiro - ele pode simplesmente supor que sempre será.

Isso mesmo.

Obviamente, a natureza limitada dos tipos de seleção permitiria algumas implementações alternativas. O tipo de seleção pode ser organizado de forma que haja sempre um padrão consistente de ponteiro / não ponteiro; por exemplo, todos os tipos escalares podem se sobrepor, e um campo de string pode se sobrepor ao início de um campo de fatia (porque ambos iniciam "apontador, não apontador"). Então

pick {
  a uintptr
  b string
  c []byte
}

poderia ser definido aproximadamente:

[ word 1 (ptr) ] [ word 2 (non-ptr) ] [ word 3 (non-ptr) ]
[    <nil>         ] [                 a           ] [                              ]
[       b.ptr      ] [            b.len          ] [                              ]
[       c.ptr      ] [             c.len         ] [        c.cap             ]

mas outros tipos de seleção podem não permitir essa embalagem ideal. (Desculpe pelo ASCII quebrado, não consigo fazer o GitHub renderizá-lo corretamente. Você entendeu, espero.)

Essa capacidade de fazer layout estático pode até ser um argumento de desempenho a favor da inclusão de tipos de seleção; meu objetivo aqui é simplesmente sinalizar detalhes de implementação relevantes para você.

@josharian e obrigado por fazer isso. Eu não tinha pensado nisso (para ser sincero, apenas pesquisei se existia pesquisa sobre como a GC discriminou sindicatos, vi que sim, você pode fazer isso e encerrou o dia - por algum motivo, meu cérebro não associava "simultaneidade" com "Go" naquele dia: facepalm!).

Haveria menos escolha se um dos tipos fosse uma estrutura definida que já tivesse um layout.

Uma opção seria não "compactar" os summands se eles contiverem ponteiros, o que significa que o tamanho seria o mesmo que a estrutura equivalente (+ 1 para o discriminador int). Talvez adotando uma abordagem híbrida, quando possível, para que todos os tipos que podem compartilhar o layout o façam.

Seria uma pena perder as boas propriedades de tamanho, mas isso realmente é apenas uma otimização.

Mesmo se fosse sempre 1 + o tamanho de uma estrutura equivalente, mesmo quando eles não contivessem ponteiros, ainda teria todas as outras propriedades legais do próprio tipo, incluindo controle sobre alocações. Otimizações adicionais podem ser adicionadas ao longo do tempo e, pelo menos, são possíveis como você indicou.

type p pick {
    A int
    B string
}

A e B precisam estar lá? Uma escolha faz parte de um conjunto de tipos, então por que não descartar seus nomes de identificadores completamente:

type p pick {
    int
    string
}
q := p{string: "hello"}

Acredito que este formulário já seja válido para struct. Pode haver uma restrição de que seja necessário para a seleção.

@como se o nome do campo for omitido, ele é o mesmo que o tipo, então seu exemplo funciona, mas como esses nomes de campo não foram exportados, eles só poderiam ser definidos / acessados ​​de dentro do pacote de definição.

Os nomes dos campos precisam estar lá, mesmo se gerados implicitamente com base no nome do tipo, ou se houver interações incorretas com capacidade de atribuição e tipos de interface. Os nomes dos campos são o que o faz funcionar com o resto do Go.

Como desculpas, acabei de perceber que você quis dizer algo diferente do que li.

Sua formulação funciona, mas você tem coisas que parecem campos de estrutura, mas se comportam de maneira diferente por causa da coisa exportada / não exportada usual.

A string é acessível de fora do pacote que define p porque está no universo?

A respeito

type t struct {}
type P pick {
  t
  //other stuff
}

?

Ao separar o nome do campo do nome do tipo, você pode fazer coisas como

pick {
  unexported Exported
  Exported unexported
}

ou mesmo

pick { Recoverable, Fatal error }

Se os campos de seleção se comportarem como campos de estrutura, você poderá usar muito do que já sabe sobre campos de estrutura para pensar sobre os campos de seleção. A única diferença real é que apenas um campo de seleção pode ser definido por vez.

@jimmyfrasche
Go já suporta a incorporação de tipos anônimos dentro de structs, então a restrição de escopo é aquela que já existe na linguagem, e eu acredito que o problema está sendo resolvido por apelidos de tipo. Mas admita que não pensei em todos os casos de uso possíveis. Parece depender de se este idioma é comum em Go:

package p
type T struct{
    Exported t
}
type t struct{}

O pequeno _t_ existe em um pacote onde está embutido em um grande T , e sua única exposição é por meio desses tipos exportados.

@Como

Não tenho certeza se entendi inteiramente, no entanto:

//with the option to have field names
pick { //T is in the namespace of the pick and the type isn't exposed to other packages
  T t
  //...
}

//without
type T = t //T has to be defined in the outer scope and now t is exposed to other packages
pick {
  T
  //...
}

Além disso, se você tivesse apenas o nome do tipo para o rótulo, para incluir, digamos, um []string você precisaria fazer um type Strings = []string .

É assim que desejo ver os tipos de seleção implementados. No
em particular, é como Rust e C ++ (os padrões de ouro para desempenho) fazem
isto.

Se eu quisesse apenas uma verificação de exaustão, poderia usar um verificador. eu quero
a vitória de desempenho. Isso significa que os tipos de seleção também não podem ser nulos.

Pegar o endereço de um membro de um elemento de seleção não deve ser permitido (
não é seguro para a memória, mesmo no caso de thread único, como é bem conhecido em
comunidade Rust.). Se isso exigir outras restrições em um tipo de escolha,
então, que seja. Mas, para mim, ter tipos de seleção sempre aloca na pilha
seria ruim.

Em 18 de agosto de 2017, às 12:01, "jimmyfrasche" [email protected] escreveu:

@josharian https://github.com/josharian então se estou lendo isso corretamente
o motivo iface agora é sempre (* tipo, * valor) em vez de esconder
valores de tamanho de palavra no segundo campo, como Go fez anteriormente é para que o
o GC simultâneo não precisa inspecionar ambos os campos para ver se o segundo
é um ponteiro - ele pode simplesmente assumir que sempre é. Eu entendi direito?

Em outras palavras, se o tipo de seleção foi implementado (usando notação C) como

struct {
int qual;
União {
A a;
B b;
C c;
} summands;
}

o GC precisaria ter um cadeado (ou algo sofisticado, mas equivalente) para
inspecionar qual para determinar se os summands precisam ser digitalizados?

-
Você está recebendo isso porque é o autor do tópico.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/golang/go/issues/19412#issuecomment-323393003 ou mudo
o segmento
https://github.com/notifications/unsubscribe-auth/AGGWB3Ayi31dYwotewcfgmCQL-XVrfxIks5sZbVrgaJpZM4MTmSr
.

@DemiMarie

Pegar o endereço de um membro de um elemento pick não deve ser permitido (não é seguro para a memória, mesmo no caso de single-threaded, como é bem conhecido na comunidade Rust). Se isso exigir outras restrições a um tipo de seleção, que seja.

Este é um bom ponto. Eu tinha isso lá, mas deve ter se perdido em uma edição. Eu incluí que quando você acessa o valor de um pick, ele sempre retorna uma cópia pelo mesmo motivo.

Como um exemplo de por que isso é verdade, para a posteridade, considere

v := pick{ A int; B bool }{A: 5}
p := &v.[A] //(this would be illegal but pretending it's not for a second)
v.B = true

Se v for otimizado para que os campos A e B ocupem a mesma posição na memória, então p não está apontando para um int: está apontando para um bool. A segurança da memória foi violada.

@jimmyfrasche

A segunda razão pela qual você não gostaria que o conteúdo fosse endereçável é a semântica de mutação. Se o valor for armazenado indiretamente sob certas circunstâncias, então

v := pick{ A int; ... }{A: 5}
v2 := v

v2.[A] = 6 // (this would be illegal under the proposal, but would be 
           // permitted if `v2.[A]` were addressable)

fmt.Println(v.[A]) // would print 6 if the contents of the pick are stored indirectly

Um lugar onde pick é semelhante a interfaces é que você deseja reter a semântica de valor se armazenar valores nela. Se você precisar de indireção como um detalhe de implementação, a única opção é tornar o conteúdo não endereçável (ou mais precisamente, endereçável mutuamente , mas a distinção não existe em Go no momento), de modo que você não possa observar o aliasing .

Editar: Oops (veja abaixo)

@jimmyfrasche

O valor zero de um tipo de seleção é seu primeiro campo na ordem de origem e o valor zero desse campo.

Observe que isso não funcionaria se o primeiro campo precisasse ser armazenado indiretamente, a menos que você coloque em particular o valor zero para que v.[A] e v.(error) façam a coisa certa.

@stevenblenkinsop Não tenho certeza do que você quer dizer com "o primeiro campo precisa ser armazenado indiretamente". Presumo que você queira dizer se o primeiro campo é um ponteiro ou um tipo que contém implicitamente um ponteiro. Nesse caso, há um exemplo abaixo. Se não, você poderia esclarecer?

Dado

var p pick { A error; B int }

o valor zero, p , tem campo dinâmico A e o valor de A é nulo.

Eu não estava me referindo ao valor armazenado em pick sendo / contendo um ponteiro, estava me referindo a um valor não-ponteiro sendo armazenado indiretamente devido a restrições de layout impostas pelo coletor de lixo, conforme descrito por @josharian .

Em seu exemplo, p.B - não sendo um ponteiro - não seria capaz de compartilhar o armazenamento sobreposto com p.A , que compreende dois ponteiros. Ele provavelmente teria que ser armazenado indiretamente (ou seja, ser representado como *int que é automaticamente desreferenciado quando você o acessa, ao invés de int ). Se p.B fosse o primeiro campo, o valor zero de pick seria new(int) , que não é um valor zero aceitável, pois requer inicialização. Você precisaria de um caso especial para que *int nulo fosse tratado como new(int) .

@jimmyfrasche
Oh, desculpe. Voltando à conversa, percebi que você estava pensando em usar armazenamento adjacente para armazenar variantes com layouts incompatíveis, em vez de copiar o mecanismo de interface de armazenamento indireto de tipos não apontadores. Meus últimos três comentários não fazem sentido nesse caso.

Edit: ops, condição de corrida. Postado então vi seu comentário.

@stevenblenkinsop ah, ok, entendo o que você quer dizer. Mas isso não é problema.

Compartilhar armazenamento sobreposto é uma otimização. Isso nunca poderia acontecer: a semântica do tipo é a parte importante.

Se o compilador puder otimizar o armazenamento e decidir fazer isso, é um bom bônus.

Em seu exemplo, o compilador poderia armazená-lo exatamente como faria com a estrutura equivalente (adicionando uma tag para saber qual é o campo ativo). Este seria

struct {
  which_field int // 0 = A, 1 = B
  A error
  B int
}

O valor zero ainda é todos os bytes 0 e não há necessidade de alocar clandestinamente como um caso especial.

O importante é garantir que apenas um campo esteja em jogo em um determinado momento.

A motivação para permitir asserções / trocas de tipo nas escolhas era para que, por exemplo, se cada tipo na escolha satisfizesse fmt.Stringer você poderia escrever um método na escolha como

func (p P) String() string {
  return p.(fmt.Stringer).String()
}

Mas, como os tipos de campos de seleção podem ser interfaces, isso cria uma sutileza.

Se o pick P no exemplo anterior tivesse um campo cujo tipo é ele mesmo fmt.Stringer String método nil . Você não pode digitar declarar uma interface nil para nada, nem mesmo para si mesmo. https://play.golang.org/p/HMYglwyVbl Embora isso sempre tenha sido verdade, simplesmente não aparece regularmente, mas pode surgir mais regularmente com escolhas.

No entanto, a natureza fechada dos tipos de soma permitiria a um linter de exaustividade localizar em todos os lugares em que isso surgisse (potencialmente com alguns falsos positivos) e relatar o caso que precisa ser tratado.

Também seria surpreendente, se você pudesse implementar métodos na escolha, que esses métodos não fossem usados ​​para satisfazer uma asserção de tipo.

type Num pick { A int; B float32 }

func (n Num) String() string {
      switch v := n.[var] {
      case A:
          return fmt.Sprint(v)
      case B:
          return fmt.Sprint(v)
      }
}
...
n := Num{A: 5}
s1, ok := p.(fmt.Stringer) // ok == false
var i interface{} = p
s2, ok := i.(fmt.Stringer) // ok == true

Você pode fazer com que a asserção de tipo promova métodos do campo atual se eles satisfizerem a interface, mas isso tem seus próprios problemas, como se deve promover métodos de um valor em um campo de interface que não está definido na própria interface (ou até mesmo como implementar isso de forma eficiente). Além disso, pode-se esperar que métodos comuns a todos os campos sejam promovidos à escolha em si, mas então eles teriam que ser despachados por meio de seleção de variante em cada chamada, além de potencialmente um envio virtual se a escolha for armazenada em uma interface , e / ou para um despacho virtual se o campo for uma interface.

Edit: A propósito, empacotar de forma ideal uma picareta é uma instância do problema de supercorda comum mais curto , que é NP-completo, embora existam aproximações gananciosas que são comumente usadas.

A regra é se é um valor de seleção que a asserção de tipo afirma no campo dinâmico do valor de seleção, mas se o valor de seleção é armazenado em uma interface, a asserção de tipo está no conjunto de métodos do tipo de seleção. Pode ser surpreendente no início, mas é bastante consistente.

Não seria um problema simplesmente descartar a permissão de asserções de tipo em um valor de escolha. Seria uma pena, pois torna muito fácil promover métodos que todos os tipos na seleção compartilham sem ter que escrever todos os casos ou usar reflexão.

Porém, seria bastante fácil usar a geração de código para escrever o

func (p Pick) String() string {
  switch v := p.[var] {
  case A:
    return v.String()
  case B:
    return v.String()
  //etc
  }
}

Simplesmente foi em frente e abandonou as afirmações de tipo. Talvez devam ser adicionados, mas não são uma parte necessária da proposta.

Quero voltar ao comentário anterior de @ianlancetaylor , porque tenho uma nova perspectiva sobre isso depois de pensar um pouco mais sobre o tratamento de erros (especificamente, https://github.com/golang/go/issues/21161# issuecomment-320294933).

Em particular, o que o novo tipo de tipo nos oferece que não obtemos dos tipos de interface?

A meu ver, a principal vantagem dos sum-types é que eles nos permitiriam distinguir entre retornar vários valores e retornar um de vários valores - particularmente quando um desses valores é uma instância da interface de erro.

Atualmente, temos muitas funções do formulário

func F(…) (T, error) {
    …
}

Alguns deles, tais como io.Reader.Read e io.Reader.Write , devolva um T , juntamente com um error , ao passo que outros retornar tanto um T ou um error mas nunca ambos. Para o estilo anterior de API, ignorar T em caso de erro costuma ser um bug (por exemplo, se o erro for io.EOF ); para o último estilo, retornar um T diferente de zero

Ferramentas automatizadas, incluindo lint , podem verificar o uso de funções específicas para garantir que o valor seja (ou não) ignorado corretamente quando o erro não é nulo, mas essas verificações não se estendem naturalmente a funções arbitrárias.

Por exemplo, proto.Marshal pretende ser o estilo "valor e erro" se o erro for RequiredNotSetError , mas parece ser o estilo "valor ou erro" caso contrário. Como o sistema de tipos não distingue entre os dois, é fácil introduzir acidentalmente regressões: não retornando um valor quando deveríamos ou retornando um valor quando não deveríamos. E as implementações de proto.Marshaler complicam ainda mais o assunto.

Por outro lado, se pudéssemos expressar o tipo como uma união, poderíamos ser muito mais explícitos sobre isso:

type PartialMarshal struct {
    Data []byte // The marshalled value, ignoring unset required fields.
    MissingFields []string
}

func Marshal(pb Message) []byte | PartialMarshal | error

@ianlancetaylor , estou brincando com sua proposta no papel. Você pode me informar se algo abaixo estiver incorreto?

Dado

var r interface{} restrict { uint, int } = 1

o tipo dinâmico de r é int , e

var _ interface{} restrict { uint32, int32 } = 1

é ilegal.

Dado

type R interface{} restrict { struct { n int }, etc }
type S struct { n int }

então var _ R = S{} seria ilegal.

Mas dado

type R interface{} restrict { int, error }
type A interface {
  error
  foo()
}
type C struct { error }
func (C) foo() {}

tanto var _ R = C{} quanto var _ R = A(C{}) seriam legais.

Ambos

interface{} restrict { io.Reader, io.Writer }

e

interface{} restrict { io.Reader, io.Writer, *bytes.Buffer }

são equivalentes.

Da mesma forma,

interface{} restrict { error, net.Error }

é equivalente a

interface { Error() string }

Dado

type IO interface{} restrict { io.Reader, io.Writer }
type R interface{} restrict {
  interface{} restrict { int, uint },
  IO,
}

então o tipo subjacente de R é equivalente a

interface{} restrict { io.Writer, uint, io.Reader, int }

Editar: pequena correção em itálico

@jimmyfrasche Eu não iria tão longe a ponto de dizer que o que escrevi acima foi uma proposta. Foi mais como uma ideia. Eu teria que pensar sobre seus comentários, mas à primeira vista eles parecem plausíveis.

A proposta de @jimmyfrasche é basicamente como eu esperava intuitivamente que um tipo de seleção se comportasse no Go. Acho que é especialmente importante notar que sua proposta de usar o valor zero do primeiro campo para o valor zero da escolha é intuitiva com o "valor zero significa zerar os bytes", desde que os valores da tag comecem em zero (talvez este já foi observado; este tópico é muito longo agora ...). Eu também gosto das implicações de desempenho (sem alocações desnecessárias) e que as escolhas são completamente ortogonais às interfaces (nenhum comportamento surpreendente alternando em uma escolha que contém uma interface).

A única coisa que eu consideraria mudar é alterar a tag: foo.X = 0 parece que poderia ser foo = Foo{X: 0} ; mais alguns caracteres, mas mais explícito que está redefinindo a tag e zerando o valor. Este é um ponto menor, e eu ainda ficaria muito feliz se sua proposta fosse aceita como está.

@ ns-cweber obrigado, mas não posso levar o crédito pelo comportamento de valor zero. As idéias estavam flutuando por um tempo e estavam na proposta do @rogpeppe que veio anteriormente neste (como você apontou um longo) tópico. Minha justificativa foi a mesma que você deu.

Tanto quanto foo.X = 0 vs foo = Foo{X: 0} , minha proposta permite ambos, na verdade. O último é útil se o campo de uma seleção for uma estrutura para que você possa fazer foo.X.Y = 0 vez de foo = Foo{X: image.Point{X: foo.[X].X, 0}} que, além de ser prolixo, pode falhar em tempo de execução.

Também acho que ajuda mantê-lo assim porque reforça o tom de elevador por sua semântica: é uma estrutura que só pode ter um campo definido por vez.

Uma coisa que pode impedi-lo de ser aceito no estado em que se encontra é como a incorporação de uma seleção em uma estrutura funcionaria. Percebi outro dia que encerrei os vários efeitos que teriam no uso da estrutura. Acho que pode ser reparado, mas não tenho certeza de quais são os melhores reparos. O mais simples seria que ele só herda os métodos e você tem que se referir diretamente ao pick embutido pelo nome para chegar aos seus campos e estou inclinado a fazer isso para evitar que um struct tenha tanto campos de struct quanto campos de pick.

@jimmyfrasche Obrigado por me corrigir sobre o comportamento de valor zero. Concordo que sua proposta permite ambos os modificadores, e acho que seu argumento de venda de elevador é bom. Sua explicação para sua proposta faz sentido, embora eu possa me imaginar configurando foo.XY, sem perceber que isso mudaria automaticamente o campo de seleção. Ainda assim, ficaria positivamente feliz se a sua proposta fosse bem-sucedida, mesmo com essa ligeira reserva.

Por fim, sua proposta simples de incorporação de seleção parece ser a que intuí. Mesmo se mudarmos de ideia, podemos ir da proposta simples para a proposta complexa sem quebrar o código existente, mas o inverso não é verdade.

@ ns-cweber

Eu podia me ver configurando foo.XY, sem perceber que isso mudaria automaticamente o campo de seleção

Esse é um ponto justo, mas você poderia falar sobre muitas coisas no idioma, ou qualquer outro idioma, para esse assunto. Em geral, Go tem trilhos de segurança, mas não tesouras de segurança.

Existem muitas coisas grandes das quais ele geralmente protege, se você não sair do seu caminho para subvertê-las, mas você ainda precisa saber o que está fazendo.

Isso pode ser irritante quando você comete um erro como este, mas, otoh, não é muito diferente de "Eu defini bar.X = 0 mas pretendia definir bar.Y = 0 ", pois a hipotética depende de você não perceber que foo é um tipo de escolha.

Da mesma forma, i.Foo() , p.Foo() e v.Foo() têm a mesma aparência, mas se i for uma interface de nil , p é um ponteiro nulo e Foo não lida com esse caso, os dois primeiros podem entrar em pânico, enquanto se v usa um receptor de método de valor, não poderia (pelo menos não da própria invocação, de qualquer maneira) .

-

Quanto à incorporação, bom ponto sobre ser fácil de soltar mais tarde, então eu apenas fui em frente e editei a proposta.

Os tipos de soma geralmente têm um campo sem valor. Por exemplo, no pacote database/sql , temos:

type NullString struct {
    String string
    Valid  bool // Valid is true if String is not NULL
}

Se tivéssemos tipos / escolhas / uniões de soma, isso poderia ser expresso como:

type NullString pick {
  Null   struct{}
  String string
}

Um tipo de soma tem vantagens óbvias sobre uma estrutura neste caso. Acho que é um uso comum o suficiente que valeria a pena incluir como exemplo em qualquer proposta.

Bikeshedding (desculpe), eu diria que vale a pena ter suporte sintático e inconsistência com a sintaxe de incorporação de campo de struct:

type NullString union {
  Null
  String string
}

@neild

Chegando ao último ponto primeiro: Como uma alteração de última hora antes de postar (não estritamente obrigatório em nenhum sentido), adicionei que se houver um tipo nomeado (ou um ponteiro para um tipo nomeado) sem nome de campo, o pick cria um campo implícito com o mesmo nome do tipo. Essa pode não ser a melhor ideia, mas parecia que cobriria um dos casos comuns de "qualquer um desses tipos" sem muito barulho. Dado que seu último exemplo poderia ser escrito:

type Null = struct{} //though this puts Null in the same scope as NullString
type NullString pick {
  Null
  String string
}

Mas voltando ao seu ponto principal, sim, é um excelente uso. Na verdade, você pode usá-lo para criar enums: type Stoplight pick { Stop, Slow, Go struct{} } . Isso seria muito parecido com um const / iota faux-enum. Ele até compilaria para a mesma saída. O principal benefício nesse caso é que o número que representa o estado é totalmente encapsulado e você não pode colocar em nenhum estado diferente dos três listados.

Infelizmente, há uma sintaxe um tanto estranha para criar e definir valores de Stoplight que é exacerbada neste caso:

light := Stoplight{Slow: struct{}{}}
light.Go = struct{}{}

Permitir que {} ou _ sejam abreviações para struct{}{} , como proposto em outro lugar, ajudaria.

Muitas linguagens, especialmente linguagens funcionais, contornam isso colocando os rótulos no mesmo escopo do tipo. Isso cria muita complexidade e não permitiria duas escolhas definidas no mesmo escopo para compartilhar nomes de campo.

No entanto, é fácil contornar isso com um gerador de código que cria uma função com o mesmo nome de cada campo no pick que leva o tipo do campo como um argumento. Se também, como um caso especial, não recebesse argumentos se o tipo fosse de tamanho zero, a saída para o exemplo Stoplight seria semelhante a esta

func Stop() Stoplight {
  return Stoplight{Stop: struct{}{}}
}
func Slow() Stoplight {
  return Stoplight{Slow: struct{}{}}
}
func Go() Stoplight {
  return Stoplight{Go: struct{}{}}
}

e para o seu exemplo NullString seria assim:

func Null() NullString {
  return NullString{Null: struct{}{}}
}
func String(s string) NullString {
  return NullString{String: s}
}

Não é bonito, mas está a go generate distância e provavelmente embutido facilmente.

Isso não funcionaria no caso em que ele criou campos implícitos com base nos nomes de tipo (a menos que os tipos fossem de outros pacotes) ou foi executado em duas escolhas no mesmo pacote que compartilhou nomes de campo, mas tudo bem. A proposta não faz tudo pronto, mas permite muitas coisas e dá ao programador a flexibilidade de decidir o que é melhor para uma determinada situação.

Mais motos de sintaxe:

type NullString union {
  Null
  Value string
}

var _ = NullString{Null}
var _ = NullString{Value: "some value"}
var _ = NullString{Value} // equivalent to NullString{Value: ""}.

Concretamente, um literal com uma lista de elementos que não contém chaves é interpretado como nomeando o campo a ser definido.

Isso seria sintaticamente inconsistente com outros usos de literais compostos. Por outro lado, é um uso que parece sensato e intuitivo no contexto de tipos de união / escolha / soma (pelo menos para mim), uma vez que não há interpretação sensata de um inicializador de união sem uma chave.

@neild

Isso seria sintaticamente inconsistente com outros usos de literais compostos.

Isso parece ser uma grande negativa para mim, embora faça sentido no contexto.

Observe também que

var ns NullString // == NullString{Null: struct{}{}} == NullString{}
ns.String = "" // == NullString{String: ""}

Para lidar com struct{}{} quando estou usando um map[T]struct{} eu jogo

var set struct{}

em algum lugar e usar theMap[k] = set , semelhante funcionaria com picaretas

Mais bicicletas: o tipo vazio (no contexto dos tipos de soma) é convencionalmente denominado "unidade", não "nulo".

@bcmills Sorta.

Em linguagens funcionais, quando você cria um tipo de soma, seus rótulos são na verdade funções que criam os valores desse tipo, (embora sejam funções especiais conhecidas como "construtores de tipo" ou "tycons" que o compilador conhece para permitir a correspondência de padrões),

data Bool = False | True

cria o tipo de dados Bool e duas funções no mesmo escopo, True e False , cada uma com a assinatura () -> Bool .

Aqui () é como você escreve a unidade pronunciada do tipo - o tipo com apenas um único valor. No Go, esse tipo pode ser escrito de muitas maneiras diferentes, mas é idiomicamente escrito como struct{} .

Portanto, o tipo de argumento do construtor seria chamado de unidade. A convenção para o nome do construtor é geralmente None quando usado como um tipo de opção como este, mas pode ser alterado para se adequar ao domínio. Null seria um bom nome se o valor viesse de um banco de dados, por exemplo.

@bcmills

A meu ver, a principal vantagem dos sum-types é que eles nos permitiriam distinguir entre retornar vários valores e retornar um de vários valores - particularmente quando um desses valores é uma instância da interface de erro.

Para uma perspectiva alternativa, vejo isso como uma grande desvantagem dos tipos de soma em Go.

Muitas linguagens, é claro, usam tipos de soma exatamente para o caso de retornar algum valor ou um erro, e isso funciona bem para eles. Se os tipos de soma forem adicionados ao Go, haverá uma grande tentação de usá-los da mesma maneira.

No entanto, Go já possui um grande ecossistema de código que usa vários valores para essa finalidade. Se o novo código usar tipos de soma para retornar tuplas (valor, erro), esse ecossistema se tornará fragmentado. Alguns autores continuarão a usar retornos múltiplos para consistência com seu código existente; alguns autores usarão tipos de soma; alguns tentarão converter suas APIs existentes. Autores presos em versões anteriores do Go, por qualquer motivo, não terão acesso a novas APIs. Vai ser uma bagunça, e não acho que os ganhos começarão a compensar os custos.

Se o novo código usar tipos de soma para retornar tuplas (valor, erro), esse ecossistema se tornará fragmentado.

Se adicionarmos tipos de soma no Go 2 e usá-los uniformemente, o problema se reduzirá a um problema de migração, não de fragmentação: seria necessário converter uma API Go 1 (valor, erro) em Go 2 (valor | erro ) API e vice-versa, mas podem ser tipos distintos nas partes Go 2 do programa.

Se adicionarmos tipos de soma no Go 2 e usá-los uniformemente

Observe que esta é uma proposta bem diferente das vistas aqui até agora: a biblioteca padrão precisará ser amplamente refatorada, a tradução entre os estilos de API precisará ser definida, etc. Siga por esse caminho e isso se tornará um e uma proposta complicada para uma transição de API com um codicilo menor em relação ao projeto de tipos de soma.

A intenção é que Go 1 e Go 2 possam coexistir perfeitamente no mesmo projeto, então não acho que a preocupação é que alguém possa ficar preso a um compilador Go 1 "por algum motivo" e não ser capaz de usar um Biblioteca Go 2. No entanto, se você tiver uma dependência A que por sua vez depende de B , e B atualiza para usar um novo recurso como pick em sua API, então isso quebraria a dependência A menos que atualize para usar a nova versão de B . A poderia apenas vender B e continuar usando a versão antiga, mas se a versão antiga não for mantida por bugs de segurança, etc ... ou se você precisar usar a nova versão do B diretamente e você não pode ter duas versões em seu projeto por algum motivo, isso pode criar um problema.

Em última análise, o problema aqui tem pouco a ver com as versões de idioma e mais com a alteração das assinaturas das funções exportadas existentes. O fato de que seria um novo recurso proporcionando o ímpeto é um pouco de distração disso. Se a intenção é permitir que APIs existentes sejam alteradas para usar pick sem quebrar a compatibilidade com versões anteriores, então pode ser necessário haver uma sintaxe de ponte de algum tipo. Por exemplo (completamente como um espantalho):

type ReadResult pick(N int, Err error) {
    N
    PartialResult struct { N; Err }
    Err
}

O compilador poderia apenas dividir o ReadResult quando ele for acessado pelo código legado, usando valores zero se um campo não estiver presente em uma variante específica. Não tenho certeza de como fazer o contrário ou se vale a pena. APIs como template.Must podem ter que continuar aceitando vários valores em vez de pick e contar com splatting para compensar a diferença. Ou algo assim pode ser usado:

type ReadResult pick(N int, Err error) {
case Err == nil:
    N
default:
    PartialResult struct { N; Err }
case N == 0:
    Err
}

Isso complica as coisas, mas posso ver como a introdução de um recurso que muda como as APIs devem ser escritas requer uma história de como fazer a transição sem quebrar o mundo. Talvez haja uma maneira de fazer isso que não exija uma sintaxe de ponte.

É trivial ir de tipos de soma para tipos de produto (structs, vários valores de retorno) - basta definir tudo o que não é o valor para zero. Ir de tipos de produtos para tipos de soma não é bem definido em geral.

Se uma API deseja fazer a transição perfeita e gradualmente de uma implementação baseada no tipo de produto para uma baseada no tipo de soma, o caminho mais fácil seria ter duas versões de tudo o que é necessário, onde a versão do tipo de soma tem a implementação real e a versão do tipo de produto chama o versão do tipo soma, fazendo qualquer verificação de tempo de execução necessária e qualquer projeção no espaço do produto.

Isso é muito abstrato, então aqui está um exemplo

versão 1 sem somas

func Take(i interface{}) error {
  switch i.(type) {
  case int: //do something
  case string:
  default: return fmt.Errorf("invalid %T", i)
  }
}
func Give() (interface{}, error) {
   i := f() //something
   if i == nil {
     return nil, errors.New("whoops v:)v")
  }
  return i
}

versão 2 com somas

type Value pick {
  I int
  S string
}
func TakeSum(v Value) {
  // do something
}
// Deprecated: use TakeSum
func Take(i interface{}) error {
  switch x := i.(type) {
  case int: TakeSum(Value{I: x})
  case string: TakeSum(Value{S: x})
  default: return fmt.Errorf("invalid %T", i)
  }
}
type ErrValue pick {
  Value
  Err error
}
func GiveSum() ErrValue { //though honestly (Value, error) is fine
  return f()
}
// Deprecated: use GiveSum
func Give() (interface{}, error) {
  switch v := GiveSum().(var) {
  case Value:
    switch v := v.(var) {
    case I: return v, nil
    case S: return v, nil
    }
  case Err:
    return nil, v
  }
}

a versão 3 removeria dar / receber

a versão 4 moveria a implementação de GiveSum / TakeSum para Give / Take, faça GiveSum / TakeSum, basta chamar Give / Take e descontinuar GiveSum / TakeSum.

a versão 5 removeria GiveSum / TakeSum

Não é bonito ou rápido, mas é o mesmo que qualquer outra interrupção em grande escala de natureza semelhante e não requer nada extra da linguagem

Eu acho (a maior parte) da utilidade de um tipo de soma pode ser realizada com um mecanismo para restringir a atribuição a um tipo de interface de tipo {} em tempo de compilação.

Em meus sonhos, parece:

type T1 switch {T2,T3} // only nil, T2 and T3 may be assigned to T1
type T2 struct{}
type U switch {} // only nil may be assigned to U
type V switch{interface{} /* ,... */} // V equivalent to interface{}
type Invalid switch {T2,T2} // only uniquely named types
type T3 switch {int,uint} // switches can contain switches but... 

... também seria um erro em tempo de compilação afirmar que um tipo de switch é um tipo não definido explicitamente:

var t1 T1
i,ok := t1.(int) // T1 can't be int, only T2 or T3 (but T3 could be int)
switch t := t1.(type) {
    case int: // compile error, T1 is just nil, T2 or T3
}

e ir ao veterinário reclamaria sobre atribuições constantes ambíguas a tipos como T3, mas para todos os efeitos e propósitos (em tempo de execução) var x T3 = 32 seria var x interface{} = 32 . Talvez alguns tipos de switch predefinidos para integrados em um pacote chamado algo como switches ou pôneis também seriam legais.

@ j7b , @ianlancetaylor ofereceu uma ideia semelhante em https://github.com/golang/go/issues/19412#issuecomment -323256891

Postei o que acredito seriam as consequências lógicas disso mais tarde em https://github.com/golang/go/issues/19412#issuecomment -325048452

Parece que muitos deles se aplicariam igualmente, dada a semelhança.

Seria ótimo se algo assim funcionasse. Seria fácil fazer a transição de interfaces para interfaces + restrições (especialmente com a sintaxe de Ian: basta adicionar restrict no final das pseudo-somas existentes construídas com interfaces). Seria fácil de implementar, já que em tempo de execução eles seriam essencialmente idênticos às interfaces e a maior parte do trabalho seria apenas fazer o compilador emitir erros adicionais quando suas invariantes fossem quebradas.

Mas não acho que seja possível fazer funcionar.

Tudo se alinha tão perto que parece um ajuste, mas você aumenta o zoom e não fica bem , então você dá um pequeno empurrão e então algo sai do alinhamento. Você pode tentar consertá-lo, mas então você obtém algo que se parece muito com interfaces, mas se comporta de maneira diferente em casos estranhos.

Talvez eu esteja faltando alguma coisa.

Não há nada de errado com a proposta de interface restrita, contanto que você esteja de acordo com os casos não sendo necessariamente separados. Não acho tão surpreendente quanto você que uma união entre dois tipos de interface (como io.Reader / io.Writer ) não é disjunta. É totalmente consistente com o fato de que você não pode determinar se um valor atribuído a interface{} foi armazenado como io.Reader ou io.Writer se implementar ambos. O fato de que você pode construir uma união disjunta, desde que cada caso seja um tipo concreto, parece perfeitamente adequado.

A desvantagem é que, se os sindicatos são interfaces restritas, você não pode definir métodos diretamente neles. E se forem tipos de interface restritos, você não terá o armazenamento direto garantido que os tipos pick fornecem. Não tenho certeza se vale a pena adicionar um tipo distinto de coisa à linguagem para obter esses benefícios adicionais.

@jimmyfrasche para type T switch {io.Reader,io.Writer} não há problema em atribuir um ReadWriter a T, mas você só pode afirmar que T é um io.Reader ou Io.Writer, você precisaria de outra declaração para afirmar que io.Reader ou io.Writer é um ReadWriter, que deve encorajar sua adição ao switchtype se for uma afirmação útil.

@stevenblenkinsop Você pode definir a proposta de seleção sem métodos. Na verdade, se você se livrar de métodos e nomes de campo implícitos, poderá permitir a incorporação de pick. (Embora eu claramente ache que os métodos e, em um grau muito menor os nomes de campo implícitos, são as opções mais úteis).

E, por outro lado, a sintaxe de @ianlancetaylor permitiria

type IR interface {
  Foo()
  Bar()
} restrict { A, B, C }

que compilaria desde que A , B , e C cada um tivesse Foo e Bar métodos (embora você tenha que se preocupar cerca de nil valores).

editar: esclarecimento em itálico

Acho que alguma forma de _interface restrita_ seria útil, mas discordo da sintaxe. Aqui está o que estou sugerindo. Ele atua de maneira semelhante a um tipo de dados algébrico, que agrupa objetos relacionados ao domínio que não têm necessariamente um comportamento comum.

//MyGroup can be any of these. It can contain other groups, interfaces, structs, or primitive types
type MyGroup group {
   MyOtherGroup
   MyInterface
   MyStruct
   int
   string
   //..possibly some other types as well
}

//type definitions..
type MyInterface interface{}
type MyStruct struct{}
//etc..

func DoWork(item MyGroup) {
   switch t:=item.(type) {
      //do work here..
   }
}

Existem vários benefícios desta abordagem em relação à abordagem convencional de interface vazia interface{} :

  • verificação de tipo estático quando a função é usada
  • o usuário pode inferir que tipo de argumento é necessário apenas a partir da assinatura da função, sem ter que olhar para a implementação da função

A interface vazia interface{} é útil quando o número de tipos envolvidos é desconhecido. Você realmente não tem escolha aqui a não ser confiar na verificação do tempo de execução. Por outro lado, quando o número de tipos é limitado e conhecido durante o tempo de compilação, por que não pedir ao compilador para nos ajudar?

@henryas Acho que uma comparação mais útil seria a maneira atualmente recomendada de fazer (abrir) tipos de soma: Interfaces não vazias (se nenhuma interface clara puder ser destilada, usando funções de marcador não exportadas).
Não acho que seus argumentos se apliquem a isso de forma significativa.

Aqui está um relato de experiência em relação aos protobufs Go:

  • A sintaxe proto2 permite campos "opcionais", que são tipos onde há distinção entre o valor zero e um valor não definido. A solução atual é usar um ponteiro (por exemplo, *int ), onde um ponteiro nulo indica não definido, enquanto um ponteiro definido aponta para o valor real. O desejo é uma abordagem que permita fazer uma distinção entre zero e não definido possível, sem complicar o caso comum de apenas precisar acessar o valor (onde o valor zero é bom se não definido).

    • Isso não apresenta desempenho devido a uma alocação extra (embora os sindicatos possam sofrer do mesmo destino, dependendo da implementação).
    • Isso é doloroso para os usuários porque a necessidade de verificar constantemente o ponteiro prejudica a legibilidade (embora valores padrão diferentes de zero em protos possam significar que a necessidade de verificar é uma coisa boa ...).
  • A protolinguagem permite "um dos", que são as versões do proto dos tipos de soma. A abordagem atualmente adotada é a seguinte ( exemplo bruto ):

    • Defina um tipo de interface com um método oculto (por exemplo, type Communique_Union interface { isCommunique_Union() } )
    • Para cada um dos possíveis tipos Go permitidos na união, defina uma estrutura wrapper, cujo único propósito é envolver cada tipo permitido (por exemplo, type Communique_Number struct { Number int32 } ) onde cada tipo tem o método isCommunique_Union .
    • Isso também não apresenta desempenho, pois os invólucros causam uma alocação. Um tipo de soma ajudaria, pois sabemos que o maior valor (uma fatia) ocuparia não mais do que 24B.

@henryas Acho que uma comparação mais útil seria a maneira atualmente recomendada de fazer (abrir) tipos de soma: Interfaces não vazias (se nenhuma interface clara puder ser destilada, usando funções de marcador não exportadas).
Não acho que seus argumentos se apliquem a isso de forma significativa.

Você quer dizer adicionar um método não exportado fictício a um objeto para que o objeto possa ser passado como uma interface, como segue?

type MyInterface interface {
   belongToMyInterface() //dummy method definition
}

type MyObject struct{}
func (MyObject) belongToMyInterface(){} //dummy method

Não acho que isso deva ser recomendado de forma alguma. É mais uma solução alternativa do que uma solução. Eu pessoalmente prefiro renunciar à verificação de tipo estática em vez de ter métodos vazios e definições de método desnecessárias por aí.

Estes são os problemas com a abordagem de _método fictício_:

  • Métodos desnecessários e definições de método bagunçando o objeto e a interface.
  • Cada vez que um novo _grupo_ é adicionado, você precisa modificar a implementação do objeto (por exemplo, adicionar métodos fictícios). Isso está errado (veja o próximo ponto).
  • O tipo de dados algébrico (ou agrupamento baseado em _domínio_ em vez de comportamento) é específico do domínio . Dependendo do domínio, você pode precisar ver a relação de objeto de maneira diferente. Um contador agrupa documentos de maneira diferente de um gerente de depósito. Esse agrupamento diz respeito ao consumidor do objeto, e não ao objeto em si. O objeto não precisa saber nada sobre o problema do consumidor, e não deveria. Uma fatura precisa saber alguma coisa sobre contabilidade? Se não, então por que uma fatura precisa mudar sua implementação _ (por exemplo, adicionar novos métodos fictícios) _ toda vez que houver uma mudança na regra de contabilidade _ (por exemplo, aplicar novo agrupamento de documentos) _? Usando a abordagem _método fictício_, você acopla seu objeto ao domínio do consumidor e faz suposições significativas sobre o domínio do consumidor. Você não deveria precisar fazer isso. Isso é ainda pior do que a abordagem vazia da interface interface{} . Existem melhores abordagens disponíveis.

@henryas

Não vejo seu terceiro ponto como um argumento forte. Se o contador deseja ver as relações de objeto de maneira diferente, ele pode criar sua própria interface que se adapte às suas especificações. Adicionar um método privado a uma interface não significa que os tipos concretos que o satisfazem são incompatíveis com subconjuntos da interface definidos em outro lugar.

O analisador Go faz uso pesado dessa técnica e, honestamente, não consigo imaginar picks tornando esse pacote muito melhor a ponto de justificar a implementação de picks na linguagem.

Como meu ponto é que toda vez que uma nova _visão de relacionamento_ é criada, os objetos concretos relevantes devem ser atualizados para fazer certas acomodações para esta visão. Parece errado, porque para fazer isso, os objetos muitas vezes devem fazer uma certa suposição sobre o domínio do consumidor. Se os objetos e os consumidores estiverem intimamente relacionados ou residirem no mesmo domínio, como no caso do analisador Go, pode não importar muito. No entanto, se os objetos fornecem funcionalidades básicas que devem ser consumidas por vários outros domínios, isso se torna um problema. Os objetos agora precisam saber um pouco sobre todos os outros domínios para que a abordagem _método fictício_ funcione.

Você acaba com muitos métodos vazios anexados aos objetos, e não é óbvio para os leitores por que você precisa desses métodos, porque as interfaces que os requerem vivem em um domínio / pacote / camada separado.

O ponto de que a abordagem de somas abertas via interfaces não permite que você use somas facilmente é bastante justo. Tipos de soma explícitos obviamente tornariam mais fácil ter somas. É um argumento muito diferente de "tipos de soma fornecem segurança de tipo", embora - você ainda pode obter segurança de tipo hoje, se precisar.

Ainda vejo duas desvantagens nas somas fechadas implementadas em outras linguagens: primeiro, a dificuldade de evoluí-las em um processo de desenvolvimento distribuído em grande escala. E dois, que eu acho que eles adicionam poder ao sistema de tipos e eu gosto que Go não tenha um sistema de tipos muito poderoso, pois isso desencoraja tipos de codificação e, em vez disso, programas de código - quando eu sinto que um problema pode se beneficiar de um sistema de tipos mais poderoso, eu mudo para uma linguagem mais poderosa (como Haskell ou Rust).

Dito isso, pelo menos o segundo é definitivamente um de preferência e, mesmo que você concorde, se as desvantagens são consideradas como superando as vantagens também depende da preferência pessoal. Só queria salientar que não é possível obter somas seguras de tipo sem tipos de somas fechadas, não é verdade :)

[1] notavelmente, não é fácil, mas ainda é possível , por exemplo, você pode fazer

type Node interface {
    node()
}

type Foo struct {
    bar.Baz
}

func (foo) node() {}

@Merovius
Eu discordo do seu segundo ponto negativo. O fato de haver muitos lugares na biblioteca padrão que se beneficiariam imensamente com os tipos de soma, mas agora são implementados usando interfaces vazias e pânico, mostra que essa falta está prejudicando a codificação. Claro, as pessoas podem dizer que, uma vez que esse código foi escrito em primeiro lugar, não há problema e não precisamos de tipos de soma, mas a loucura dessa lógica é que não precisaríamos de nenhum outro tipo para a função assinaturas, e devemos apenas usar interfaces vazias.

Quanto ao uso de interfaces com algum método para representar tipos de soma agora, há uma grande desvantagem. Você não sabe quais tipos você pode usar para essa interface, uma vez que eles são implementados implicitamente. Com o tipo de soma adequado, o próprio tipo descreve exatamente quais tipos podem realmente ser usados.

Eu discordo do seu segundo ponto negativo.

Você discorda da afirmação "os tipos de soma encorajam a programação com tipos" ou discorda de que isso seja uma desvantagem? Porque não parece que você está discordando do primeiro (seu comentário é basicamente apenas uma reafirmação disso) e em relação ao segundo, eu reconheci que é preferência acima.

O fato de haver muitos lugares na biblioteca padrão que se beneficiariam imensamente com os tipos de soma, mas agora são implementados usando interfaces vazias e pânico, mostra que essa falta está prejudicando a codificação. Claro, as pessoas podem dizer que, uma vez que esse código foi escrito em primeiro lugar, não há problema e não precisamos de tipos de soma, mas a loucura dessa lógica é que não precisaríamos de nenhum outro tipo para a função assinaturas, e devemos apenas usar interfaces vazias.

Esse tipo de argumento preto e branco não ajuda muito . Eu concordo, esses tipos de soma reduziriam a dor em alguns casos. Cada mudança que torna o sistema de tipos mais poderoso reduzirá a dor em alguns casos - mas também causará dor em alguns casos. Portanto, a questão é: qual é mais importante do que o outro (e isso é, em um bom grau, uma questão de preferência).

As discussões não deveriam ser sobre se queremos um sistema de tipos do tipo python (sem tipos) ou um sistema de tipos do tipo coq (provas de exatidão para tudo). A discussão deve ser "os benefícios dos tipos de soma superam suas desvantagens" e é útil reconhecer ambos.


FTR, quero enfatizar novamente que, pessoalmente, eu não seria tão contrário aos tipos de soma abertos (ou seja, todo tipo de soma tem um caso "SomethingElse" implícito ou explícito), pois isso aliviaria a maioria das desvantagens técnicas de eles (principalmente porque eles são difíceis de evoluir) enquanto também fornecem a maioria das vantagens técnicas deles (verificação de tipo estático, a documentação que você mencionou, você pode enumerar tipos de outros pacotes ...).

Eu também presumo, porém, que as somas abertas a) não serão um compromisso satisfatório para pessoas que normalmente buscam tipos de soma eb) provavelmente não serão consideradas um benefício grande o suficiente para justificar a inclusão pela equipe de Go. Mas eu estaria pronto para ser provado que estou errado em uma ou ambas as suposições :)

Mais uma pergunta:

O fato de haver muitos lugares na biblioteca padrão que se beneficiariam imensamente com os tipos de soma

Só consigo pensar em dois lugares na biblioteca padrão, onde diria que há algum benefício significativo para eles: refletir e ir / ast. E mesmo assim, os pacotes parecem funcionar muito bem sem eles. Deste ponto de referência, as palavras "abundância" e "imensamente" parecem exageros - mas posso não ver um monte de lugares legítimos, é claro.

database/sql/driver.Value pode se beneficiar por ser um tipo de soma (conforme observado em # 23077).
https://godoc.corp.google.com/pkg/database/sql/driver#Value

A interface mais pública em database/sql.Rows.Scan não funcionaria, entretanto, sem uma perda de funcionalidade. A varredura pode ler valores cujo tipo subjacente é, por exemplo, int ; alterar seu parâmetro de destino para um tipo de soma exigiria a limitação de suas entradas a um conjunto finito de tipos.
https://godoc.corp.google.com/pkg/database/sql#Rows.Scan

@Merovius

Eu não me oporia tanto aos tipos de soma abertos (ou seja, todo tipo de soma tem um caso "SomethingElse" implícito ou explícito), pois isso aliviaria a maioria das desvantagens técnicas deles (principalmente que são difíceis de evoluir)

Existem pelo menos duas outras opções que amenizam o problema de “difícil evolução” dos valores encerrados.

Uma é permitir correspondências em tipos que não fazem realmente parte da soma. Em seguida, para adicionar um membro à soma, primeiro você atualiza seus consumidores para corresponder ao novo membro e só adiciona esse membro quando os consumidores são atualizados.

Outra é permitir membros “impossíveis”: isto é, membros que são explicitamente permitidos em correspondências, mas explicitamente proibidos em valores reais. Para adicionar um membro à soma, primeiro você o adiciona como um membro impossível, depois atualiza os consumidores e, finalmente, altera o novo membro para ser possível.

database/sql/driver.Value pode se beneficiar por ser um tipo de soma

Concordo, não sabia sobre isso. Obrigado :)

Uma é permitir correspondências em tipos que não fazem realmente parte da soma. Em seguida, para adicionar um membro à soma, primeiro você atualiza seus consumidores para corresponder ao novo membro e só adiciona esse membro quando os consumidores são atualizados.

Solução intrigante.

As interfaces default: . Sem tipos de soma finita, entretanto, default: significa um caso válido que você não sabia sobre ele ou um caso inválido que é um bug em algum lugar do programa - com somas finitas, é apenas o primeiro e nunca o último.

json.Token e os tipos sql.Null * são outros exemplos canônicos. go / types se beneficiariam da mesma forma que go / ast. Suponho que haja muitos exemplos que não estão nas APIs exportadas, onde seria mais fácil depurar e testar alguns encanamentos intrincados, limitando o domínio do estado interno. Eu os considero mais úteis para estado interno e restrições de aplicativo que não aparecem com frequência em APIs públicas para bibliotecas gerais, embora eles também tenham seus usos ocasionais lá.

Pessoalmente, acho que os tipos de soma dão ao Go apenas a potência extra suficiente, mas não muito. O sistema de tipo Go já é muito bom e flexível, embora tenha suas deficiências. Os acréscimos do Go2 ao sistema de tipos simplesmente não vão fornecer tanta potência quanto o que já está lá - 80-90% do que é necessário já está no lugar. Quer dizer, até mesmo os genéricos não seria fundamentalmente deixá-lo fazer algo novo: seria deixá-lo fazer coisas que você já fazer de forma mais segura, mais facilmente, mais perfomantly, e de maneira que permite um melhor ferramental. Os tipos de soma são semelhantes, imo (embora, obviamente, se fosse um ou outro genérico teria precedência (e eles combinam muito bem)).

Se você permitir um padrão estranho (todos os casos + padrão é permitido) nas opções do tipo soma e não tiver o compilador impondo exaustividade (embora um linter pudesse), adicionar um caso a uma soma é tão fácil (e tão difícil quanto ), alterando qualquer outra API pública.

json.Token e os tipos sql.Null * são outros exemplos canônicos.

Token - claro. Outra instância do problema AST (basicamente qualquer analisador se beneficia dos tipos de soma).

Não vejo benefício para sql.Null *, no entanto. Sem os genéricos (ou adicionando algum embutido opcional genérico "mágico"), você ainda terá que ter os tipos e não parece haver uma diferença significativa entre type NullBool enum { Invalid struct{}; Value Int } e type NullBool struct { Valid bool; Value Int } . Sim, estou ciente de que há uma diferença, mas é extremamente pequena.

Se você permitir um padrão estranho (todos os casos + padrão é permitido) nas opções do tipo soma e não tiver o compilador impondo exaustividade (embora um linter pudesse), adicionar um caso a uma soma é tão fácil (e tão difícil quanto ), alterando qualquer outra API pública.

Veja acima. São o que chamo de somas em aberto, sou menos contra elas.

São o que chamo de somas em aberto, sou menos contra elas.

Minha proposta específica é https://github.com/golang/go/issues/19412#issuecomment -323208336 e acredito que pode satisfazer sua definição de aberto, embora ainda seja um pouco grosseiro e tenho certeza de que ainda há mais para remover e polir. Em particular, notei que não estava claro se um caso padrão era admissível, mesmo se todos os casos estivessem listados, então apenas atualizei.

Concordou que tipos opcionais não são o aplicativo matador de tipos de soma. Eles são muito bons e, como você apontou, com os genéricos que definem um

type Nullable(T) pick { // or whatever syntax (on all counts)
  Null struct{}
  Value T
}

uma vez e cobrir todos os casos seria ótimo. Mas, como você também apontou, poderíamos fazer o mesmo com um produto genérico (struct). Existe o estado inválido de Valid = false, Value! = 0. Nesse cenário, seria fácil erradicar se isso estivesse causando problemas, já que 2 ⨯ T é pequeno, mesmo que não seja tão pequeno quanto 1 + T.

Claro, se fosse uma soma mais complicada com muitos casos e muitas invariantes sobrepostos, se tornaria mais fácil cometer um erro e mais difícil descobrir o erro, mesmo com programação defensiva, então tornar coisas impossíveis apenas não compilar pode economizar muito cabelo puxar.

Token - claro. Outra instância do problema AST (basicamente qualquer analisador se beneficia dos tipos de soma).

Eu escrevo muitos programas que pegam alguma entrada, fazem algum processamento e produzem alguma saída e eu normalmente divido isso recursivamente em várias passagens que dividem a entrada em casos e a transformam com base nesses casos conforme se movem cada vez mais perto do saída desejada. Posso não estar literalmente escrevendo um analisador (admito que às vezes estou porque é divertido!), Mas acho que o problema AST, como você disse, se aplica a muitos códigos - especialmente ao lidar com lógica de negócios obscura que tem muitos requisitos e casos extremos para caber na minha cabeça minúscula.

Quando estou escrevendo uma biblioteca geral, ela não aparece na API com tanta frequência como fazer algum ETL ou algum relatório fantasioso ou garantir que os usuários no estado X tenham a ação Y aconteça se eles não estiverem marcados como Z. Mesmo em uma biblioteca geral embora eu encontre lugares onde ser capaz de limitar o estado interno ajudaria, mesmo que apenas reduza uma depuração de 10 minutos para um segundo "oh, o compilador disse que estou errado".

Com Go em particular, um lugar onde eu usaria tipos de soma é uma goroutine selecionando vários canais onde eu preciso dar 3 chans para uma goroutine e 2 para outra. Isso me ajudaria a rastrear o que está acontecendo ao poder usar uma chan pick { a A; b B; c C } sobre chan A , chan B , chan C embora uma lata de chan stuct { kind MsgKind; a A; b B; c C } faça o trabalho em uma pitada ao custo de espaço extra e menos validação.

Em vez de um novo tipo, que tal a verificação da lista de tipos em tempo de compilação como um acréscimo ao recurso de alternância de tipo de interface existente?

func main() {
    if FlipCoin() == false {
        printCertainTypes(FlipCoin(), int(5))
    } else {
        printCertainTypes(FlipCoin(), string("5"))
    }
}
// this function compiles with main
func printCertainTypes(flip bool, in interface{}) {
    if flip == false {
        switch v := in.(type) {
        case int:
            fmt.Printf(“integer %v\n”, v)
        default:
            fmt.Println(v)
        }
    } else {
        switch v := in.(type) {
        case int:
            fmt.Printf(“integer %v\n”, v)
        case string:
            fmt.Printf(“string %v\n”, v)
        }
    }
}
// this function compiles with main
func printCertainTypes(flip bool, in interface{}) {
    switch v := in.(type) {
    case int:
        fmt.Printf(“integer %v\n”, v)   
    case bool:
        fmt.Printf(“bool %v\n”, v)
    }
    fmt.Println(flip)
    switch v := in.(type) {
    case string:
        fmt.Printf(“string %v\n”, v)
    case bool:
        fmt.Printf(“bool 2 %v\n”, v)
    }
}
// this function emits a type switch not complete error when compiled with main
func printCertainTypes(flip bool, in interface{}) {
    if flip == false {
        switch v := in.(type) {
        case int:
            fmt.Printf(“integer %v\n”, v)
        case bool:
            fmt.Printf(“bool %v\n”, v)
        }
    } else {
        switch v := in.(type) {
        case string:
            fmt.Printf(“string %v\n”, v)
        case bool:
            fmt.Printf(“bool %v\n”, v)
        }
    }
}
// this function emits a type switch not complete error when compiled with main
func printCertainTypes(flip bool, in interface{}) {
    fmt.Println(flip)
    switch v := in.(type) {
    case int:
        fmt.Printf(“integer %v\n”, v)
    case bool:
        fmt.Printf(“bool %v\n”, v)
    }
}

Para ser justo, devemos explorar maneiras de aproximar os tipos de soma no sistema de tipos atual e pesar seus prós e contras. Se nada mais, ele fornece uma linha de base para comparação.

O meio padrão é uma interface com um método não exportado que não faz nada como uma tag.

Um argumento contra isso é que cada tipo na soma precisa ter essa tag definida. Isso não é estritamente verdadeiro, pelo menos para membros que são structs, poderíamos fazer

type Sum interface { sum() }
type sum struct{}
func (sum) sum() {}

e apenas incorporar essa tag de largura 0 em nossos structs.

Podemos adicionar tipos externos à nossa soma, introduzindo um invólucro

type External struct {
  sum
  *pkg.SomeType
}

embora isso seja um pouco desajeitado.

Se todos os membros da soma compartilham um comportamento comum, podemos incluir esses métodos na definição da interface.

Construções como esta digamos que um tipo está em uma soma, mas não nos permitem dizer o que não está nessa soma. Além do caso nil obrigatório, o mesmo truque de incorporação pode ser usado por pacotes externos como

import "p"
var member struct {
  p.Sum
}

Dentro do pacote, temos que tomar cuidado para validar os valores que compilam, mas são ilegais.

Existem várias maneiras de recuperar algum tipo de segurança em tempo de execução. Eu descobri incluir um método valid() error na definição da interface de soma juntamente com uma função como

func valid(s Sum) error {
  switch s.(type) {
  case nil:
    return errors.New("pkg: Sum must be non-nil")
  case A, B, C, ...: // listing each valid member
    return s.valid()
  }
  return fmt.Errorf("pkg: %T is not a valid member of Sum")
}

para ser útil, pois permite cuidar de dois tipos de validação ao mesmo tempo. Para membros que sempre são válidos, podemos evitar alguns clichês com

type alwaysValid struct{}
func (alwaysValid) valid() error { return nil }

Uma das reclamações mais comuns sobre esse padrão é que ele não torna a associação na soma clara em godoc. Como também não nos permite excluir membros e exige que validemos de qualquer maneira, há uma maneira simples de contornar isso: exportar o método fictício.
Ao invés de,

//A Node is one of (list of types).
type Node interface { node() }

escrever

//A Node is only valid if it is defined in this package.
type Node interface { 
  //Node is a dummy method that signifies that a type is a Node.
  Node()
}

Não podemos impedir ninguém de satisfazer Node portanto, podemos também informá-los do que faz. Embora isso não deixe claro à primeira vista quais tipos satisfazem Node (sem lista central), torna claro se o tipo específico que você está olhando agora satisfaz Node .

Esse padrão é útil quando a maioria dos tipos na soma são definidos no mesmo pacote. Quando nenhum está, o recurso comum é voltar para interface{} , como json.Token ou driver.Value . Poderíamos usar o padrão anterior com tipos de invólucro para cada um, mas no final ele diz tanto quanto interface{} portanto, não há muito sentido. Se esperamos que esses valores venham de fora do pacote, podemos ser corteses e definir uma fábrica:

//Sum is one of int64, float64, or bool.
type Sum interface{}
func New(v interface{}) (Sum, error) {
  switch v.(type) {
  case nil:
    return errors.New("pkg: Sum must be non-nil")
  case int64, float64, bool:
     return v
  }
  return fmt.Printf("pkg: %T is not a valid member of Sum")
}

Um uso comum de somas é para tipos opcionais, onde você precisa diferenciar entre "nenhum valor" e "um valor que pode ser zero". Existem duas maneiras de fazer isso.

*T permite que você não signifique nenhum valor como um ponteiro nil e um (possivelmente) valor zero como resultado do desreferenciamento de um ponteiro não nulo.

Como as aproximações anteriores baseadas em interface e as várias propostas para implementar tipos de soma como interfaces com restrições, isso requer uma desreferenciação de ponteiro extra e uma possível alocação de heap.

Para opcionais isto pode ser evitado usando a técnica do pacote sql

type OptionalT struct {
  Valid bool
  Value T
}

A principal desvantagem disso é que ele permite a codificação de estado inválido: válido pode ser falso e o valor pode ser diferente de zero. Também é possível pegar Value quando Valid for false (embora isso possa ser útil se você quiser o zero T se não foi especificado). Definir casualmente válido como falso sem zerar o valor seguido de definir Válido como verdadeiro (ou ignorá-lo) sem atribuir valor faz com que um valor previamente descartado ressurja acidentalmente. Isso pode ser contornado fornecendo setters e getters para proteger os invariantes do tipo.

A forma mais simples de tipos de soma é quando você se preocupa com a identidade, não com o valor: enumerações.

A maneira tradicional de lidar com isso no Go é const / iota:

type Enum int
const (
  A Enum = iota
  B
  C
)

Como o tipo OptionalT isso não tem nenhum engano desnecessário. Como as somas da interface, ela não limita o domínio: existem apenas três valores válidos e muitos valores inválidos, por isso precisamos validar em tempo de execução. Se houver exatamente dois valores, podemos usar bool.

Há também a questão da quantidade fundamental desse tipo. A+B == C . Podemos converter constantes integrais não tipadas para esse tipo com um pouco mais de facilidade. Existem muitos lugares em que isso é desejável, mas isso acontece de qualquer maneira. Com um pouco de trabalho extra, podemos limitar isso apenas à identidade:

type Enum struct { v int }
var (
  A = Enum{0}
  B = Enum{1}
  C = Enum{2}
)

Agora, esses são apenas rótulos opacos. Eles podem ser comparados, mas é isso. Infelizmente agora perdemos a constância, mas poderíamos recuperá-la com um pouco mais de trabalho:

func A() Enum { return Enum{0} }
func B() Enum { return Enum{1} }
func C() Enum { return Enum{2} }

Recuperamos a incapacidade de um usuário externo de alterar os nomes ao custo de alguns clichês e algumas chamadas de função que são altamente inline.

No entanto, de certa forma, isso é melhor do que as somas da interface, já que fechamos quase totalmente o tipo. O código externo só pode usar A() , B() ou C() . Eles não podem trocar os rótulos como no exemplo var e não podem fazer A() + B() e somos livres para definir quaisquer métodos que quisermos em Enum . Ainda seria possível que um código no mesmo pacote crie ou modifique erroneamente um valor, mas se tomarmos cuidado para garantir que isso não aconteça, este é o primeiro tipo de soma que não requer código de validação: se existe, é válido .

Às vezes, você tem muitos rótulos e alguns deles têm data adicional e os que têm o mesmo tipo de dados. Digamos que você tenha um valor com três estados sem valor (A, B, C), dois com um valor de string (D, E) e um com um valor de string e um valor int (F). Poderíamos usar uma série de combinações das táticas acima, mas a maneira mais simples é

type Value struct {
  Which int // could have consts for A, B, C, D, E, F
  String string
  Int int
}

É muito parecido com o tipo OptionalT acima, mas em vez de um bool, ele tem uma enumeração e há vários campos que podem ser definidos (ou não) dependendo do valor de Which . A validação deve ter cuidado para que eles sejam configurados (ou não) apropriadamente.

Existem várias maneiras de expressar "uma das seguintes" no Go. Alguns requerem mais cuidado do que outros. Freqüentemente, eles exigem a validação do invariante "um dos" em tempo de execução ou desreferências estranhas. Uma grande desvantagem que todos eles compartilham é que, uma vez que estão sendo simulados na linguagem em vez de fazerem parte da linguagem, o invariante "um de" não aparece em refletir ou ir / tipos, tornando difícil metaprogramar com eles. Para usá-los em metaprogramação, você precisa ser capaz de reconhecer e validar o sabor correto de sum e ser informado de que é isso que você está procurando, já que todos eles se parecem muito com código válido sem o invariante "um de".

Se sum types fizessem parte da linguagem, eles poderiam ser refletidos e facilmente retirados do código-fonte, resultando em melhores bibliotecas e ferramentas. O compilador poderia fazer várias otimizações se estivesse ciente de que "um de" invariante. Os programadores podem se concentrar no código de validação importante em vez da manutenção trivial de verificar se um valor está de fato no domínio correto.

Construções como esta digamos que um tipo está em uma soma, mas não nos permitem dizer o que não está nessa soma. Além do caso nulo obrigatório, o mesmo truque de incorporação pode ser usado por pacotes externos como
[…]
Dentro do pacote, temos que tomar cuidado para validar os valores que compilam, mas são ilegais.

Porque? Como autor de um pacote, isso parece firmemente no domínio do "seu problema" para mim. Se você me passar um io.Reader , cujo método de Read entra em pânico, não vou me recuperar disso e apenas deixá-lo entrar em pânico. Da mesma forma, se você sair do seu caminho para criar um valor inválido de um tipo que declarei - quem sou eu para discutir com você? Ou seja, considero "embuti uma soma fechada emulada" um problema que raramente (ou nunca) surge por acidente.

Dito isso, você pode evitar esse problema alterando a interface para type Sum interface { sum() Sum } e fazer com que cada valor retorne a si mesmo. Dessa forma, você pode apenas usar o retorno de sum() , que será bem comportado mesmo sob incorporação.

Uma das reclamações mais comuns sobre esse padrão é que ele não torna a associação na soma clara em godoc.

Isso pode te ajudar .

A principal desvantagem disso é que ele permite a codificação de estado inválido: válido pode ser falso e o valor pode ser diferente de zero.

Este não é um estado inválido para mim. Valores zero não são mágicos. Não há diferença, IMO, entre sql.NullInt64{false,0} e NullInt64{false,42} . Ambos são representações válidas e equivalentes de um SQL NULL. Se todo o código verificar Válido antes de usar Valor, a diferença não será observável para um programa.

É uma crítica justa e correta que o compilador não obrigue a fazer esta verificação (o que provavelmente faria, para tipos de opcionais / soma "reais"), tornando mais fácil não fazê-lo. Mas se você esquecer, eu não consideraria melhor usar acidentalmente um valor zero do que acidentalmente usar um valor diferente de zero (com a possível exceção de tipos em forma de ponteiro, pois eles entrariam em pânico quando usados, portanto falhando alto - mas para aqueles, você deve apenas usar o tipo em forma de ponteiro simples de qualquer maneira e usar nil como "não definido").

Há também a questão da quantidade fundamental desse tipo. A + B == C. Podemos converter constantes integrais não tipadas para este tipo com um pouco mais de facilidade.

É uma preocupação teórica ou surgiu na prática?

Os programadores podem se concentrar no código de validação importante em vez da manutenção trivial de verificar se um valor está de fato no domínio correto.

Apenas FTR, nos casos em que uso sum-types-as-sum-types (ou seja, o problema não pode ser modelado com mais elegância por meio de interfaces de variedade dourada), nunca escrevo nenhum código de validação. Assim como eu não verifico a inexistência de ponteiros passados ​​como receptores ou argumentos (a menos que seja documentado como uma variante válida). Nos lugares onde o compilador me força a lidar com isso (ou seja, problemas de estilo "sem retorno no final da função"), entro em pânico no caso padrão.

Pessoalmente, considero Go uma linguagem pragmática, que não adiciona recursos de segurança apenas para seu próprio bem ou porque "todos sabem que eles são melhores", mas com base na necessidade demonstrada. Acho que usá-lo de forma pragmática é bom.

O meio padrão é uma interface com um método não exportado que não faz nada como uma tag.

Há uma diferença fundamental entre interfaces e tipos de soma (não vi isso mencionado em seu post). Quando você aproxima um tipo de soma por meio de uma interface, realmente não há como lidar com o valor. Como consumidor, você não tem ideia do que realmente contém e só pode adivinhar. Isso não é melhor do que apenas usar uma interface vazia. Só tem utilidade se qualquer implementação puder vir apenas do mesmo pacote que define a interface, já que só então você pode controlar o que pode obter.

Por outro lado, ter algo como:

func foo(val string|int|error) {
    switch v:= val.(type) {
    case string:
        ...
    }
}

Dá ao consumidor total poder no uso do valor do tipo soma. Seu valor é concreto, não aberto a interpretações.

@Merovius
Essas "somas abertas" que você mencionou têm o que algumas pessoas podem classificar como uma desvantagem significativa, na medida em que permitiriam abusar delas por "aumento de recursos". Essa mesma razão foi dada para a rejeição de argumentos de função opcionais como um recurso.

Essas "somas abertas" que você mencionou têm o que algumas pessoas podem classificar como uma desvantagem significativa, na medida em que permitiriam abusar delas por "aumento de recursos". Essa mesma razão foi dada para a rejeição de argumentos de função opcionais como um recurso.

Isso parece um argumento muito fraco para mim - se nada mais, então porque eles existem, então você já está permitindo tudo o que eles permitem. Na verdade, já temos argumentos opcionais , para todos os efeitos (não que eu goste desse padrão, mas claramente já é possível na linguagem).

Há uma diferença fundamental entre interfaces e tipos de soma (não vi isso mencionado em seu post). Quando você aproxima um tipo de soma por meio de uma interface, realmente não há como lidar com o valor. Como consumidor, você não tem ideia do que realmente contém e só pode adivinhar.

Tentei analisar isso uma segunda vez e ainda não consigo. Por que você não seria capaz de usá-los? Eles podem ser tipos regulares exportados. Sim, eles têm que ser tipos criados em seu pacote (obviamente), mas fora isso não parece haver qualquer restrição em como você pode usá-los, em comparação com as somas fechadas reais.

Tentei analisar isso uma segunda vez e ainda não consigo. Por que você não seria capaz de usá-los? Eles podem ser tipos regulares exportados. Sim, eles têm que ser tipos criados em seu pacote (obviamente), mas fora isso não parece haver qualquer restrição em como você pode usá-los, em comparação com as somas fechadas reais.

O que acontece no caso em que o método dummy é exportado e qualquer terceiro pode implementar o "tipo de soma"? Ou o cenário bastante realista em que um membro da equipe não está familiarizado com os vários consumidores da interface, decide adicionar outra implementação no mesmo pacote e uma instância dessa implementação acaba sendo passada para esses consumidores por meio de vários meios do código? Correndo o risco de repetir minha declaração aparentemente "não analisável": "Como consumidor, você não tem idéia do que [o valor da soma] realmente contém, e só pode adivinhar." Você sabe, já que é uma interface e não diz quem a está implementando.

@Merovius

Apenas FTR, nos casos em que uso sum-types-as-sum-types (ou seja, o problema não pode ser modelado com mais elegância por meio de interfaces de variedade dourada), nunca escrevo nenhum código de validação. Assim como eu não verifico a inexistência de ponteiros passados ​​como receptores ou argumentos (a menos que seja documentado como uma variante válida). Nos lugares onde o compilador me força a lidar com isso (ou seja, problemas de estilo "sem retorno no final da função"), entro em pânico no caso padrão.

Eu não trato isso como uma coisa sempre ou nunca .

Se alguém passando uma entrada incorreta explodir imediatamente, não me preocupo com o código de validação.

Mas se alguém passando uma entrada incorreta pode eventualmente causar um pânico, mas ela não aparecerá por algum tempo, então eu escrevo o código de validação para que a entrada incorreta seja sinalizada o mais rápido possível e ninguém tenha que descobrir que o erro foi introduzido 150 quadros na pilha de chamadas (especialmente porque eles podem ter que subir mais 150 quadros na pilha de chamadas para descobrir onde aquele valor incorreto foi introduzido).

Gastar meio minuto agora para potencialmente economizar meia hora de depuração depois é pragmático. Especialmente para mim, já que eu cometo erros estúpidos o tempo todo e quanto mais cedo eu for educado, mais cedo poderei prosseguir para cometer o próximo erro estúpido.

Se eu tiver uma função que pega um leitor e começa a usá-la imediatamente, não verificarei se há nil, mas se a função for uma fábrica para uma estrutura que não chamará o leitor até que um determinado método seja invocado, irei verifique se há nulo e entre em pânico ou retorne um erro com algo como "o leitor não deve ser nulo" para que a causa do erro seja o mais próximo possível da origem do erro.

godoc -analysis

Estou ciente, mas não acho útil. Ele foi executado por 40 minutos em minha área de trabalho antes de eu clicar em ^ C e isso precisa ser atualizado sempre que um pacote é instalado ou modificado. No entanto, há # 20131 (bifurcado neste mesmo tópico).

Dito isso, você pode evitar esse problema, alterando a interface para type Sum interface { sum() Sum } e fazer com que cada valor retorne a si mesmo. Dessa forma, você pode apenas usar o retorno de sum() , que será bem comportado mesmo sob incorporação.

Eu não achei isso útil. Ele não fornece mais benefícios do que a validação explícita e fornece menos validação.

É [o fato de você poder adicionar membros de uma enumeração const / iota] uma preocupação teórica ou surgiu na prática?

Aquele em particular era teórico: eu estava tentando listar todos os prós e contras em que conseguia pensar, teóricos e práticos. Meu ponto principal, porém, foi que havia muitas maneiras de tentar expressar o "um dos" invariante na linguagem que é usado com bastante frequência, mas nenhuma tão simples quanto apenas ter que ser um tipo de tipo na linguagem.

É [o fato de que você pode atribuir uma integral não tipada a uma enumeração const / iota] uma preocupação teórica ou surgiu na prática?

Esse surgiu na prática. Não demorou muito para descobrir o que deu errado, mas teria demorado ainda menos se o compilador tivesse dito "lá, essa linha - essa é a que está errada". Fala-se de outras maneiras de lidar com esse caso específico, mas não vejo como seriam de uso geral.

Este não é um estado inválido para mim. Valores zero não são mágicos. Não há diferença, IMO, entre sql.NullInt64{false,0} e NullInt64{false,42} . Ambos são representações válidas e equivalentes de um SQL NULL. Se todo o código verificar Válido antes de usar Valor, a diferença não será observável para um programa.

É uma crítica justa e correta que o compilador não obrigue a fazer esta verificação (o que provavelmente faria, para tipos de opcionais / soma "reais"), tornando mais fácil não fazê-lo. Mas se você esquecer, eu não consideraria melhor usar acidentalmente um valor zero do que acidentalmente usar um valor diferente de zero (com a possível exceção de tipos em forma de ponteiro, pois eles entrariam em pânico quando usados, portanto falhando alto - mas para aqueles, você deve apenas usar o tipo em forma de ponteiro simples de qualquer maneira e usar nil como "não definido").

Que "Se todo o código verificar Válido antes de usar Valor" é onde os bugs aparecem e o que o compilador poderia impor. Eu tive erros como esse acontecer (embora com versões maiores desse padrão, onde havia mais de um campo de valor e mais de dois estados para o discriminador). Eu acredito / espero ter encontrado tudo isso durante o desenvolvimento e teste e nenhum escapou para o mundo selvagem, mas seria bom se o compilador pudesse apenas ter me dito quando eu cometi esse erro e eu pudesse ter certeza de que a única maneira de um desses esqueci foi se houvesse um bug no compilador, da mesma forma que ele me diria se eu tentasse atribuir uma string a uma variável do tipo int.

E, claro, eu prefiro *T para tipos opcionais, embora isso tenha custos diferentes de zero associados a ele, tanto no espaço-tempo de execução quanto na legibilidade do código.

(Para esse exemplo específico, o código para obter o valor real ou o valor zero correto com a proposta de seleção seria v, _ := nullable.[Value] que é conciso e seguro.)

Isso não é exatamente o que eu gostaria. Os tipos de escolha devem ser tipos de valor,
como em Rust. A primeira palavra deve ser um indicador para os Metadados GC, se necessário.

Caso contrário, seu uso vem com uma penalidade de desempenho que pode ser
inaceitável. Para mim, o passe 10:41, "Josh Bleecher Snyder" <
notificaçõ[email protected]> escreveu:

Com a proposta de escolha, você pode escolher ter um ap ou * p dando-lhe mais
maior controle sobre as trocas de memória.

A razão pela qual as interfaces são alocadas para armazenar valores escalares é para que você não
tem que ler uma palavra-tipo para decidir se a outra palavra é uma
ponteiro; consulte # 8405 https://github.com/golang/go/issues/8405 para
discussão. As mesmas considerações de implementação provavelmente se aplicariam a um
escolher o tipo, o que pode significar na prática que p acaba alocando e sendo
não local de qualquer maneira.

-
Você está recebendo isso porque é o autor do tópico.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/golang/go/issues/19412#issuecomment-323371837 ou mudo
o segmento
https://github.com/notifications/unsubscribe-auth/AGGWB-wQD75N44TGoU6LWQhjED_uhKGUks5sZaKbgaJpZM4MTmSr
.

@urandom

O que acontece no caso em que o método dummy é exportado e qualquer terceiro pode implementar o "tipo de soma"?

Há uma diferença entre o método que está sendo exportado e o tipo que está sendo exportado. Parece que estamos conversando. Para mim, isso parece funcionar muito bem, sem qualquer diferença entre valores abertos e fechados:

type X interface { x() X }
type IntX int
func (v IntX) x() X { return v }
type StringX string
func (v StringX) x() X { return v }
type StructX struct{
    Foo bool
    Bar int
}
func (v StructX) x() X { return v }

Não há extensão fora do pacote possível, mas os consumidores do pacote podem usar, criar e passar os valores como qualquer outro.

Você pode incorporar X, ou um dos tipos locais que o satisfaçam, externamente e, em seguida, passá-lo para uma função em seu pacote que recebe um X.

Se essa função chama x, ela entra em pânico (se o próprio X foi incorporado e não foi definido como nada) ou retorna um valor no qual seu código pode operar - mas não é o que foi passado pelo chamador, o que seria um pouco surpreendente para o chamador (e seu código já é suspeito se eles estão tentando algo assim porque não leram os documentos).

Chamar um validador que entre em pânico com uma mensagem "não faça isso" parece a maneira menos surpreendente de lidar com isso e permite que o chamador corrija seu código.

Se essa função chamar x, ela entrará em pânico [...] ou retornará um valor no qual seu código pode operar - mas não é o que foi passado pelo chamador, o que seria um pouco surpreendente para o chamador

Como eu disse acima: Se você está surpreso que sua construção intencional de um valor inválido é inválida, você precisa repensar suas expectativas. Mas, em qualquer caso, não era disso que tratava essa linha particular de discussão e seria útil manter os argumentos separados separados. Este era sobre @urandom dizendo que somas abertas via interfaces com métodos de tag não seriam introspectáveis ​​ou usáveis ​​por outros pacotes. Acho que é uma afirmação duvidosa, seria ótimo se pudesse ser esclarecida.

O problema é que alguém pode criar um tipo que não está na soma que compila e pode ser passado para o seu pacote.

Sem adicionar tipos de soma adequados ao idioma, existem três opções para lidar com isso

  1. ignore a situação
  2. validar e entrar em pânico / retornar um erro
  3. tente "fazer o que você quer" extraindo implicitamente o valor incorporado e usando-o

3 parece uma mistura estranha de 1 e 2 para mim: não vejo o que compra.

Eu concordo que "Se você está surpreso que sua construção intencional de um valor inválido é inválida, você precisa repensar suas expectativas", mas, com 3, pode ser muito difícil perceber que algo deu errado e mesmo quando você o faz seria difícil descobrir por quê.

2 parece melhor porque protege o código de entrar em um estado inválido e envia um sinalizador se alguém errar, informando por que está errado e como corrigi-lo.

Estou entendendo mal a intenção do padrão ou estamos apenas abordando isso a partir de filosofias diferentes?

@urandom Eu também gostaria de esclarecimentos; Também não estou 100% certo do que você está tentando dizer.

O problema é que alguém pode criar um tipo que não está na soma que compila e pode ser passado para o seu pacote.

Você sempre pode fazer isso; em caso de dúvida, você sempre pode usar não seguro, mesmo com tipos de soma verificados pelo compilador (e não vejo isso como uma maneira qualitativamente diferente de construir valores inválidos de incorporar algo que é claramente pretendido como uma soma e não inicializá-lo para um Valor válido). A questão é "com que frequência isso representará um problema na prática e quão grave será esse problema". Na minha opinião, com a solução de cima a resposta é "praticamente nunca e muito baixo" - você aparentemente discorda, o que é bom. Mas, de qualquer forma, não parece haver muito sentido em trabalhar nisso - os argumentos e visões de ambos os lados deste ponto em particular devem ser suficientemente claros e estou tentando evitar repetições muito barulhentas e me concentrar no que é genuinamente novos argumentos. Eu mencionei a construção acima para demonstrar que não há diferença na exportabilidade entre os tipos de soma de primeira classe e somas emuladas via interfaces. Não para mostrar que eles são estritamente melhores em todos os aspectos.

em caso de dúvida, você sempre pode usar não seguro, mesmo com tipos de soma verificados pelo compilador (e não vejo isso como uma maneira qualitativamente diferente de construir valores inválidos de incorporar algo que é claramente pretendido como uma soma e não inicializá-lo para um Valor válido).

Acho que é qualitativamente diferente: quando as pessoas usam indevidamente a incorporação desta forma (pelo menos com proto.Message e os tipos concretos que a implementam), geralmente não estão pensando se é seguro e quais invariáveis ​​podem quebrar . (Os usuários presumem que as interfaces descrevem completamente os comportamentos necessários, mas quando as interfaces são empregadas como tipos de união ou soma, geralmente não o fazem. Consulte também https://github.com/golang/protobuf/issues/364.)

Em contraste, se alguém usa o pacote unsafe para definir uma variável para um tipo ao qual ela normalmente não pode se referir, está mais ou menos explicitamente alegando ter pelo menos pensado sobre o que pode quebrar e por quê.

@Merovius Talvez eu não tenha sido claro: o fato de que o compilador diria a alguém que usaram a incorporação incorreta é mais um bom benefício colateral.

O maior ganho do recurso de segurança é que ele seria homenageado por refletir e representado em go / types. Isso fornece ferramentas e bibliotecas mais informações para trabalhar. Existem muitas maneiras de simular tipos de soma em Go, mas todos eles são idênticos ao código de tipo sem soma, então o conjunto de ferramentas e a biblioteca precisam de informações fora da banda para saber que é um tipo de soma e deve ser capaz de reconhecer o padrão específico sendo usados, mas mesmo esses padrões permitem uma variação significativa.

Também tornaria insegura a única maneira de criar um valor inválido: agora você tem código regular, código gerado e reflexão - os dois últimos sendo mais propensos a causar um problema, pois ao contrário de uma pessoa eles não podem ler a documentação.

Outro benefício colateral da segurança significa que o compilador tem mais informações e pode gerar um código melhor e mais rápido.

Há também o fato de que além de ser capaz de substituir a pseudo-soma por interfaces, você poderia substituir a pseudo-soma "um desses tipos regulares" como json.Token ou driver.Value . Esses são poucos e distantes entre si, mas seria um lugar a menos onde interface{} é necessário.

Também tornaria insegura a única maneira de criar um valor inválido

Acho que não entendo a definição de "valor inválido" que leva a essa afirmação.

@neild se você tivesse

var v pick {
  None struct{}
  A struct { X int; Y *T}
  B int
}

seria colocado na memória como

struct {
  activeField int //which of None (0), A (1), or B (2) is the current field
  theInt int // If None always 0
  thePtr *T // If None or B, always nil
}

e com inseguro você poderia definir thePtr mesmo se activeField fosse 0 ou 2 ou definir um valor de theInt mesmo se activeField fosse 0.

Em ambos os casos, isso invalidaria as suposições que o compilador estaria fazendo e permitiria o mesmo tipo de bugs teóricos que podemos ter hoje.

Mas, como @bcmills apontou, se você estiver usando o inseguro, é melhor saber o que está fazendo porque é a opção nuclear.

O que não entendo é por que inseguro é a única maneira de criar um valor inválido.

var t time.Timer

t é um valor inválido; t.C não está definido, chamar t.Stop entrará em pânico, etc. Não é necessário fazer nada inseguro.

Algumas linguagens têm sistemas de tipos que fazem o possível para evitar a criação de valores "inválidos". Go não é um deles. Não vejo como os sindicatos movem essa agulha de forma significativa. (Existem outras razões para apoiar os sindicatos, é claro.)

@neild sim, desculpe, estou sendo solto com minhas definições.

Eu deveria ter dito inválido com respeito aos invariantes do tipo soma .

Os tipos individuais na soma podem, é claro, estar em um estado inválido.

No entanto, manter os invariantes de tipo sum significa que eles estão acessíveis para refletir e ir / tipos, bem como o programador, portanto, manipulá-los em bibliotecas e ferramentas mantém essa segurança e fornece mais informações para o metaprogramador

@jimmyfrasche , estou dizendo que, ao contrário de um tipo de soma, que informa todos os tipos possíveis, uma interface é opaca porque você não sabe, ou pelo menos não pode usar, qual é a lista de tipos que implementam a interface são. Isso torna a escrita da parte switch do código um tanto quanto uma suposição:

func F(sum SumInterface) {
    switch v := sum {
    case Screwdriver:
             ...
    default:
           panic ("Someone implementing a new type which gets passed to F and causes a runtime panic 3 weeks into production")
    }
}

Portanto, parece-me que a maioria dos problemas que as pessoas estão tendo com a emulação do tipo soma baseada na interface podem ser resolvidos por meio de cobrança e / ou convenção. Por exemplo, se uma interface contém um método não exportado, seria trivial descobrir todas as implementações possíveis (sim, evasões intencionais). Da mesma forma, para resolver a maioria dos problemas com enums baseados em iota, uma convenção simples de "um enum é um type Foo int com uma declaração no formato const ( FooA Foo = iota; FooB; FooC ) " permitiria escrever ferramentas extensas e precisas para eles também.

Sim, isso não é equivalente a tipos de soma reais (entre outras coisas, eles não teriam suporte de reflexo de primeira classe, embora eu realmente não entenda como isso seria importante de qualquer maneira), mas significa que as soluções existentes parecem, do meu ponto de vista, melhores do que costumam ser pintados. E, IMO, valeria a pena explorar esse espaço de design antes de colocá-los no Go 2 - pelo menos se eles realmente forem tão importantes para as pessoas.

(e quero enfatizar novamente que estou ciente das vantagens dos tipos de soma, então não há necessidade de reapresentá-los para meu benefício. Eu apenas não os peso tão pesadamente quanto outras pessoas, vejo também as desvantagens e, portanto, chegar a conclusões diferentes sobre os mesmos dados)

@Merovius, essa é uma boa posição.

O suporte de reflexão permitiria que bibliotecas, bem como ferramentas off-line - linters, geradores de código, etc. - acessassem as informações e não permitissem modificá-las inadequadamente, o que não pode ser detectado estaticamente com qualquer precisão.

Independentemente disso, é uma ideia justa para explorar, então vamos explorá-la.

Para recapitular as famílias mais comuns de pseudossomos em Go são: (aproximadamente na ordem de ocorrência)

  • const / iota enum.
  • Interface com método de tag para soma sobre tipos definidos no mesmo pacote.
  • *T para um opcional T
  • struct com um enum cujo valor determina quais campos podem ser definidos (quando o enum é um bool e há apenas um outro campo, este é outro tipo de opcional T )
  • interface{} que está restrito a um conjunto finito de tipos.

Todos eles podem ser usados ​​para tipos de soma e tipos de não soma. Os dois primeiros são tão raramente usados ​​para qualquer outra coisa que pode fazer sentido apenas supor que eles representam tipos de soma e aceitar o falso positivo ocasional. Para somas de interface, ele poderia limitá-lo ao método não exportado sem parâmetros ou retornos e sem corpo em nenhum membro. Para enums, faria sentido reconhecê-los apenas quando eles forem apenas Type = iota portanto, não haverá erro quando iota for usado como parte de uma expressão.

*T para um opcional T seria realmente difícil de distinguir de um ponteiro regular. Isso poderia ser dado a convenção type O = *T . Isso seria possível detectar, embora um pouco difícil, pois o nome do alias não faz parte do tipo. type O *T seria mais fácil de detectar, mas mais difícil de trabalhar no código. Por outro lado, tudo o que precisa ser feito é essencialmente integrado ao tipo, então há pouco a ganhar em ferramentas ao reconhecer isso. Vamos apenas ignorar este. (Os genéricos provavelmente permitiriam algo na linha de type Optional(T) *T que simplificaria a "marcação" deles).

Seria difícil raciocinar sobre a estrutura com um enum em ferramentas, quais campos vão com qual valor para o enum? Poderíamos simplificar isso para a convenção de que deve haver um campo por membro no enum e que o valor do enum e o valor do campo devem ser iguais, por exemplo:

type Which int
const (
  A Which = iota
  B
  C
)
type Sum struct {
  Which
  A struct{} // has to be included to line up with the value of Which
  B struct { X int; Y float64 }
  C struct { X int; Y int } 
}

Isso não obteria tipos opcionais, mas poderíamos colocar "2 campos, o primeiro é bool" no reconhecedor.

Usar um interface{} para uma soma total seria impossível de detectar sem um comentário mágico como //gosum: int, float64, string, Foo

Como alternativa, pode haver um pacote especial com as seguintes definições:

package sum
type (
  Type struct{}
  Enum int
  OneOf interface{}
)

e só reconhecerá enums se eles estiverem no formato type MyEnum sum.Enum , só reconhecerá interfaces e estruturas se incorporarem sum.Type e só reconhecerem interface{} sacos de agarrar como type GrabBag sum.OneOf (mas isso ainda precisaria de um comentário reconhecível da máquina para explicar seus comentários). Isso teria os seguintes prós e contras:
Prós

  • explícito no código: se for assim marcado, é 100% um tipo de soma, sem falsos positivos.
  • essas definições podem ter documentação explicando o que significam e a documentação do pacote pode conter links para ferramentas que podem ser usadas com esses tipos
  • alguns teriam alguma visibilidade em refletir
    Contras
  • Muitos falsos negativos do código antigo e do stdlib (que não os usaria).
  • Eles teriam que ser usados ​​para serem úteis, de forma que a adoção seria lenta e provavelmente nunca chegaria a 100% e a eficácia das ferramentas que reconheceram este pacote especial seria uma função da adoção, tão interessante embora experimente, mas provavelmente irrealista.

Independentemente de qual dessas duas maneiras são usadas para identificar os tipos de soma, vamos supor que eles foram reconhecidos e passar a usar essas informações para ver que tipo de ferramenta podemos construir.

Podemos grosso modo agrupar o ferramental em generativo (como longarina) e introspectivo (como golint).

O código gerador mais simples seria uma ferramenta para preencher uma instrução switch com casos ausentes. Isso pode ser usado por editores. Uma vez que um tipo de soma é identificado como um tipo de soma, isso é trivial (um pouco cansativo, mas a lógica de geração real será a mesma com ou sem suporte de linguagem).

Em todos os casos seria possível gerar uma função que valide o invariante "um de".

Para enums, pode haver mais ferramentas como o stringer. Em https://github.com/golang/go/issues/19814#issuecomment -291002852, mencionei algumas possibilidades.

A maior ferramenta geradora é o compilador, que poderia produzir um código de máquina melhor com essa informação, mas tudo bem.

Não consigo pensar em nenhum outro no momento. Existe alguma coisa na lista de desejos de alguém?

Para a introspecção, o candidato óbvio é a exaustão. Sem suporte de idioma, existem dois tipos diferentes de linting necessários

  1. certificando-se de que todos os estados possíveis são tratados
  2. certificando-se de que nenhum estado inválido seja criado (o que invalidaria o trabalho realizado por 1)

1 é trivial, mas exigiria todos os estados possíveis e um caso padrão porque 2 não pode ser verificado 100% (mesmo ignorando o inseguro) e você não pode esperar que todo o código usando seu código execute este linter de qualquer maneira.

2 não poderia realmente seguir os valores refletindo ou identificando todo o código que poderia gerar um estado inválido para a soma, mas poderia capturar muitos erros simples, como se você incorporasse um tipo de soma e chamasse uma função com ele, poderia dizer "você escreveu pkg.F (v) mas quis dizer pkg.F (v.EmbeddedField)" ou "você passou 2 para pkg.F, use pkg.B". Para a estrutura, ele não poderia fazer muito para forçar a invariante de que um campo seja definido por vez, exceto em casos realmente óbvios como "você está ativando o qual e no caso de X você configura o campo F com um valor diferente de zero " Ele pode insistir que você use a função de validação gerada ao aceitar valores de fora do pacote.

A outra grande coisa seria aparecer no godoc. godoc já agrupa const / iota e # 20131 ajudaria com os pseudosums de interface. Não há realmente nada a ver com a versão da estrutura que não seja explícita na definição, exceto especificar a invariante.

bem como ferramentas off-line - linters, geradores de código, etc.

Não. A informação estática está presente, você não precisa do sistema de tipos (ou reflexão) para isso, a convenção funciona bem. Se sua interface contém métodos não exportados, qualquer ferramenta estática pode escolher tratar isso como uma soma fechada (porque efetivamente é) e fazer qualquer análise / codegen que você desejar. Da mesma forma com a convenção de iota-enums.

refletir é para informações de tipo de tempo de

(também, FTR, dependendo do caso de uso, você ainda pode ter uma ferramenta que usa as informações estaticamente conhecidas para gerar as informações de tempo de execução necessárias - por exemplo, pode enumerar os tipos que têm o método de tag necessário e gerar uma tabela de pesquisa para eles. Mas eu não entendo o que seria um caso de uso, então é difícil avaliar a praticidade disso).

Então, minha pergunta foi intencionalmente: qual seria o caso de uso, de ter essa informação disponível em tempo de execução?

Independentemente disso, é uma ideia justa para explorar, então vamos explorá-la.

Quando eu disse "explore", não quis dizer "enumerá-los e argumentar sobre eles no vácuo", quis dizer "implementar ferramentas que usem essas convenções e ver o quão úteis / necessárias / práticas elas são".

A vantagem dos relatos de

Você está pulando a parte "tentar usar os mecanismos existentes para essa" parte. Você deseja ter verificações de exaustividade estáticas das somas (problema). Escreva uma ferramenta que encontre interfaces com métodos não exportados, faça as verificações de exaustividade para qualquer switch de tipo em que seja usado, use essa ferramenta por um tempo (use os mecanismos existentes para isso). Escreva, onde falhou.

Eu estava pensando em voz alta e comecei a trabalhar em um reconhecedor estático com base nos pensamentos que as ferramentas podem usar. Eu estava, suponho, implicitamente procurando feedback e mais ideias (e isso valeu a pena re-gerar as informações necessárias para refletir).

FWIW, se eu fosse você, simplesmente ignoraria os casos complexos e me concentraria nas coisas que funcionam: a) métodos não exportados em interfaces eb) simples const-iota-enums, que têm int como um tipo subjacente e uma única const declaração do formato esperado. Usar uma ferramenta exigiria o uso de uma dessas duas soluções alternativas, mas IMO tudo bem (para usar a ferramenta de compilador, você também precisaria usar somas explicitamente, de modo que parece normal).

Esse é definitivamente um bom lugar para começar e pode ser discado depois de examiná-lo em um grande conjunto de pacotes e ver quantos falsos positivos / negativos existem

https://godoc.org/github.com/jimmyfrasche/closed

Ainda é um trabalho em andamento. Não posso prometer que não terei que adicionar parâmetros extras ao construtor. Provavelmente tem mais bugs do que testes. Mas é bom o suficiente para brincar.

Há um exemplo de uso em cmds / closed-exporer que também lista todos os tipos fechados detectados em um pacote especificado por seu caminho de importação.

Comecei apenas detectando todas as interfaces com métodos não exportados, mas eles são bastante comuns e enquanto alguns eram claramente tipos de soma, outros claramente não eram. Se eu apenas o limitasse à convenção do método de tag vazia, perdi muitos tipos de soma, então decidi registrar ambos separadamente e generalizar o pacote um pouco além dos tipos de soma para tipos fechados.

Com enums, fui para o outro lado e apenas gravei cada const não bitset de um tipo definido. Também pretendo expor os bitsets descobertos.

Ele não detecta estruturas opcionais ou interfaces vazias definidas ainda, uma vez que exigirão algum tipo de comentário de marcador, mas faz caso especial daqueles no stdlib.

Comecei apenas detectando todas as interfaces com métodos não exportados, mas eles são bastante comuns e enquanto alguns eram claramente tipos de soma, outros claramente não eram.

Eu acharia útil se você pudesse fornecer alguns dos exemplos que não foram.

@Merovius, desculpe, eu não

Aquelas que não estou considerando como tipos de soma eram todas interfaces não exportadas que estavam sendo usadas para conectar uma das várias implementações: nada se importava com o que estava na interface, apenas que havia algo que a satisfizesse. Eles estavam sendo usados ​​como interfaces, não como somas, mas simplesmente foram fechados porque não eram exportados. Talvez seja uma distinção sem diferença, mas sempre posso mudar de ideia após uma investigação mais aprofundada.

@jimmyfrasche Eu diria que essas devem ser tratadas apropriadamente como somas fechadas. Eu diria que se eles não se importassem com o tipo dinâmico (ou seja, apenas chamando os métodos na interface), então um linter estático não reclamaria, pois "todas as opções são exaustivas" - então não há nenhuma desvantagem em tratá-los como somas fechadas. Se, OTOH, às vezes eles não digite-switch e deixar de fora um caso, queixando-se seria correto - que iria ser exatamente o tipo de coisa que o linter é suposto captura.

Eu gostaria de apresentar uma boa palavra para explorar como os tipos de união podem reduzir o uso de memória. Estou escrevendo um interpretador em Go e tenho um tipo de valor que é necessariamente implementado como uma interface porque os valores podem ser ponteiros para tipos diferentes. Presumivelmente, isso significa que um [] Value ocupa o dobro da memória em comparação com empacotar o ponteiro com uma pequena tag de bit como faria em C. Parece muito?

A especificação da linguagem não precisa mencionar isso, mas parece que cortar o uso de memória de um array pela metade para alguns tipos de sindicatos pequenos pode ser um argumento bastante convincente para os sindicatos. Ele permite que você faça algo que, pelo que eu sei, é impossível fazer no Go hoje. Por outro lado, a implementação de uniões em cima de interfaces pode ajudar na correção e compreensão do programa, mas não faz nada de novo no nível da máquina.

Eu não fiz nenhum teste de desempenho; apenas apontando uma direção para a pesquisa.

Em vez disso, você pode implementar um Value como um unsafe.Pointer.

Em 6 de fevereiro de 2018, 15:54, "Brian Slesinsky" [email protected] escreveu:

Eu gostaria de apresentar uma boa palavra para explorar como os tipos de sindicatos podem reduzir
uso de memória. Estou escrevendo um intérprete em Go e tenho um tipo de valor que
é necessariamente implementado como uma interface porque os valores podem ser ponteiros
para diferentes tipos. Isso provavelmente significa que um [] Valor ocupa o dobro
memória em comparação com empacotar o ponteiro com uma pequena etiqueta de bit, como você poderia fazer
em C. Parece muito?

A especificação do idioma não precisa mencionar isso, mas parece que está cortando a memória
o uso de uma matriz pela metade para alguns tipos de união pequenos pode ser uma ótima
argumento convincente para sindicatos? Ele permite que você faça algo que, tanto quanto eu
saber que é impossível fazer em Go hoje. Em contraste, a implementação de sindicatos em
topo das interfaces pode ajudar com a correção do programa e
compreensibilidade, mas não faz nada de novo no nível da máquina.

Eu não fiz nenhum teste de desempenho; apenas apontando uma direção para
pesquisar.

-
Você está recebendo isso porque foi mencionado.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/golang/go/issues/19412#issuecomment-363561070 ou mudo
o segmento
https://github.com/notifications/unsubscribe-auth/AGGWBz-L3t0YosVIJmYNyf2iQ-YgIXLGks5tSLv9gaJpZM4MTmSr
.

@skybrian Isso parece bastante presunçoso em relação à implementação de tipos de soma. Não apenas requer sum-types, mas também que o compilador reconheça o caso especial de apenas ponteiros em uma soma e os otimize como um ponteiro empacotado - e requer que o GC esteja ciente de quantos bits de tag são necessários no ponteiro , para mascará-los. Tipo, eu realmente não vejo essas coisas acontecendo, TBH.

Isso deixa você com: Tipos de soma provavelmente serão uniões marcadas e provavelmente ocuparão tanto espaço em uma fatia quanto agora. A menos que a fatia seja homogênea, você também pode usar um tipo de fatia mais específico agora.

Então sim. Em casos muito especiais, você pode economizar um pouco de memória, se otimizar especificamente para eles, mas parece que você também pode otimizar manualmente para isso, se realmente precisar.

@DemiMarie unsafe.Pointer não funciona no App Engine e, em qualquer caso, não permitirá que você empacote bits sem bagunçar o coletor de lixo. Mesmo se fosse possível, não seria portátil.

@Merovius sim, é necessário alterar o tempo de execução e o coletor de lixo para entender os layouts de memória compactada. Esse é o ponto; os ponteiros são gerenciados pelo tempo de execução Go, portanto, se você quiser fazer melhor do que as interfaces de maneira segura, não pode fazer isso em uma biblioteca ou no compilador.

Mas vou admitir prontamente que escrever um intérprete rápido é um caso de uso incomum. Talvez existam outros? Parece que uma boa maneira de motivar um recurso de linguagem é encontrar coisas que não podem ser feitas facilmente no Go hoje.

Isso é verdade.

Meu pensamento é que Go não é a melhor língua para escrever um intérprete,
devido à dinâmica descontrolada de tal software. Se você precisa de alto desempenho,
seus hot loops devem ser escritos em conjunto. Existe alguma razão para você
precisa escrever um intérprete que funcione no App Engine?

Em 6 de fevereiro de 2018, 18h15, "Brian Slesinsky" [email protected] escreveu:

@DemiMarie https://github.com/demimarie unsafe.Pointer does not work on App
Motor, e em qualquer caso, não vai deixar você embalar bits sem
bagunçando o coletor de lixo. Mesmo se fosse possível, não seria
portátil.

@metrovius sim, requer alteração do tempo de execução e do coletor de lixo
para entender layouts de memória compactada. Esse é o ponto; ponteiros são
gerenciado pelo tempo de execução Go, então se você quiser fazer melhor do que interfaces em um
maneira segura, você não pode fazer isso em uma biblioteca ou no compilador.

Mas vou admitir prontamente que escrever um intérprete rápido é um uso incomum
caso. Talvez existam outros? Parece uma boa maneira de motivar um
recurso de linguagem é encontrar coisas que não podem ser feitas facilmente no Go hoje.

-
Você está recebendo isso porque foi mencionado.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/golang/go/issues/19412#issuecomment-363598572 ou mudo
o segmento
https://github.com/notifications/unsubscribe-auth/AGGWB65jRKg_qVPWTiq8LbGk3YM1RUasks5tSN0tgaJpZM4MTmSr
.

Acho a proposta @rogpeppe bastante atraente. Também me pergunto se há potencial para desbloquear benefícios adicionais para ir junto com aqueles já identificados por @griesemer.

A proposta diz: "O conjunto de métodos do tipo soma mantém a interseção do conjunto de métodos
de todos os seus tipos de componentes, excluindo quaisquer métodos que tenham o mesmo
nome, mas assinaturas diferentes. ".

Mas um tipo é mais do que apenas um conjunto de métodos. E se o tipo de soma suportasse a interseção das operações suportadas por seus tipos de componentes?

Por exemplo, considere:

var x int|float64

A ideia é que o seguinte funcionaria.

x += 5

Seria equivalente a escrever o switch de tipo completo:

switch i := x.(type) {
case int:
    x = i + 5
case float64:
    x = i + 5
}

Outra variante envolve um switch de tipo em que um tipo de componente é ele próprio um tipo de soma.

type Num int | float64
type StringOrNum string | Num 
var x StringOrNum

switch i := x.(type) {
case string:
    // Do string stuff.
case Num:
    // Would be nice if we could use i as a Num here.
}

Além disso, acho que há potencialmente uma sinergia muito boa entre os tipos de soma e um sistema genérico que usa restrições de tipo.

var x int|float64

E quanto a var x, y int | float64 ? Quais são as regras aqui, ao adicioná-los? Qual conversão com perdas é feita (e por quê)? Qual será o tipo de resultado?

Go não faz conversões automáticas em expressões (como o C) propositalmente - essas perguntas não são fáceis de responder e levam a bugs.

E para ainda mais diversão:

var x, y, z int|string|rune
x = 42
y = 'a'
z = "b"
fmt.Println(x + y + z)
fmt.Println(x + z + y)
fmt.Println(y + x + z)
fmt.Println(y + z + x)
fmt.Println(z + x + y)
fmt.Println(z + y + x)

Todos int , string e rune têm um operador + ; o que é a impressão acima, por que e acima de tudo, como o resultado não pode ser completamente confuso?

E quanto a var x, y int | float64 ? Quais são as regras aqui, ao adicioná-los? Qual conversão com perdas é feita (e por quê)? Qual será o tipo de resultado?

@Merovius nenhuma conversão com perdas é feita implicitamente, embora eu possa ver como minhas palavras podem dar essa impressão de desculpe. Aqui, um x + y simples não compilaria porque implica em uma possível conversão implícita. Mas qualquer um dos seguintes compilaria:

z = int(x) + int(y)
z = float64(x) + float64(y)

Da mesma forma, seu exemplo xyz não seria compilado porque requer possíveis conversões implícitas.

Acho que "apoiou a interseção das operações com suporte" parece bom, mas não transmite exatamente o que eu pretendia. Adicionar algo como "compila para todos os tipos de componentes" ajuda a descrever como acho que poderia funcionar.

Outro exemplo é se todos os tipos de componentes são fatias e mapas. Seria bom poder chamar len no tipo de soma sem precisar de um switch de tipo.

Todos int, string e rune têm um operador +; o que é a impressão acima, por que e acima de tudo, como o resultado não pode ser completamente confuso?

Só queria acrescentar que meu "E se o tipo de soma suportasse a interseção das operações suportadas por seus tipos de componentes?" foi inspirado na descrição de um tipo do Go Spec como "Um tipo determina um conjunto de valores junto com operações e métodos específicos para esses valores.".

O que eu estava tentando enfatizar é que um tipo é mais do que apenas valores e métodos e, portanto, um tipo de soma pode tentar capturar a comunalidade de outras coisas de seus tipos de componentes. Essas "outras coisas" têm mais nuances do que apenas um conjunto de operadores.

Outro exemplo é a comparação com nulo:

var x []int | []string
fmt.Println(x == nil)  // Prints true
x = []string(nil)
fmt.Println(x == nil)  // Still prints true

Ambos os tipos de componentes são. Pelo menos um tipo é comparável a nulo, então permitimos que o tipo de soma seja comparado a nulo sem uma chave de tipo. Claro que isso está um pouco em desacordo com a forma como as interfaces se comportam atualmente, mas isso pode não ser uma coisa ruim por https://github.com/golang/go/issues/22729

Edit: o teste de igualdade é um mau exemplo aqui, pois acho que deveria ser mais permissivo e requerer apenas uma correspondência potencial de um ou mais tipos de componentes. Atribuição de espelhos a esse respeito.

O problema é que o resultado a) terá os mesmos problemas que as conversões automáticas ou b) será extremamente (e confusamente) limitado em escopo - ou seja, todos os operadores trabalhariam apenas com literais não digitados, na melhor das hipóteses.

Eu também tenho outro problema, que é permitir isso limitará ainda mais sua robustez contra a evolução de seus tipos constituintes - agora os únicos tipos que você pode adicionar enquanto preserva a compatibilidade com versões anteriores são aqueles que permitem todas as operações de seus tipos constituintes.

Tudo isso parece muito confuso para mim, para um benefício tangível muito pequeno (se houver).

agora, os únicos tipos que você pode adicionar enquanto preserva a compatibilidade com versões anteriores são aqueles que permitem todas as operações de seus tipos constituintes.

Ah, e para ser explícito sobre isso também: Isso implica que você nunca pode decidir que gostaria de estender um parâmetro ou tipo de retorno ou variável ou ... de um tipo singleton para uma soma. Porque adicionar qualquer novo tipo fará com que algumas operações (como atribuições) falhem ao compilar.

@Merovius observe que uma variante do problema de compatibilidade já existe com a proposta original porque "O conjunto de métodos do tipo soma mantém a interseção do conjunto de métodos
de todos os seus tipos de componentes ". Portanto, se você adicionar um novo tipo de componente que não implementa esse conjunto de métodos, será uma alteração incompatível com versões anteriores.

Ah, e para ser explícito sobre isso também: Isso implica que você nunca pode decidir que gostaria de estender um parâmetro ou tipo de retorno ou variável ou ... de um tipo singleton para uma soma. Porque adicionar qualquer novo tipo fará com que algumas operações (como atribuições) falhem ao compilar.

O comportamento da atribuição permaneceria conforme descrito por @rogpeppe, mas, no geral, não tenho certeza se entendi esse ponto.

Se nada mais, acho que a proposta rogpeppe original precisa ser esclarecida com relação ao comportamento do tipo soma fora de um switch de tipo. A atribuição e o conjunto de métodos são cobertos, mas isso é tudo. E quanto à igualdade? Acho que podemos fazer melhor do que a interface {}:

var x int | float64
fmt.Println(x == "hello")  // compilation error?
x = 0.0
fmt.Println(x == 0) // true or false?  I vote true :-)

Portanto, se você adicionar um novo tipo de componente que não implementa esse conjunto de métodos, essa alteração não será compatível com versões anteriores.

Você sempre pode adicionar métodos, mas não pode sobrecarregar os operadores para trabalhar em novos tipos. Qual é precisamente a diferença - na proposta deles, você só pode chamar os métodos comuns em um valor de soma (ou atribuir a ele), a menos que você o desembrulhe com uma declaração de tipo / comutador. Portanto, contanto que o tipo adicionado tenha os métodos necessários, não seria uma alteração significativa. Em sua proposta, ainda seria uma mudança radical, porque os usuários podem usar operadores que você não pode sobrecarregar.

(você pode querer salientar que adicionar tipos à soma ainda seria uma alteração importante, porque as chaves de tipo não teriam o novo tipo nelas. É exatamente por isso que também não sou a favor da proposta original - eu não quero somas fechadas por isso mesmo)

O comportamento da atribuição permaneceria conforme descrito por @rogpeppe

A proposta deles fala apenas de atribuição a um valor de soma, eu falo de atribuição de um valor de soma (a uma de suas partes constituintes). Concordo que a proposta deles também não permite isso, mas a diferença é que a proposta deles não é sobre adicionar essa possibilidade. ou seja, meu argumento é exatamente que a semântica que você sugere não é particularmente benéfica, porque, na prática, o uso que elas obtêm é severamente limitado.

fmt.Println(x == "hello") // compilation error?

Isso provavelmente seria adicionado à sua proposta também. Já temos um caso especial equivalente para interfaces , a saber

Um valor x do tipo não de interface X e um valor t do tipo de interface T são comparáveis ​​quando os valores do tipo X são comparáveis ​​e X implementa T. Eles são iguais se o tipo dinâmico de t é idêntico a X e o valor dinâmico de t é igual a x .

fmt.Println(x == 0) // true or false? I vote true :-)

Presumivelmente falso. Dado que o semelhante

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)

deve ser um erro de compilação (como concluímos acima), esta questão só faz sentido quando comparada com constantes numéricas não digitadas. Nesse ponto, meio que depende de como isso é adicionado à especificação. Você poderia argumentar que isso é semelhante a atribuir uma constante a um tipo de interface e, portanto, deve ter seu tipo padrão (e então a comparação seria falsa). O que IMO é mais do que bom, já aceitamos essa situação hoje sem muita confusão. Você poderia, no entanto, também adicionar um caso à especificação de constantes não digitadas que cobriria o caso de atribuir / compará-los a somas e resolver a questão dessa maneira.

Responder a essa pergunta de qualquer maneira, no entanto, não exige permitir todas as expressões usando tipos de soma que podem fazer sentido para as partes constituintes.

Mas, para reiterar: não estou defendendo uma proposta diferente de somas. Estou argumentando contra este.

fmt.Println(x == "hello") // compilation error?

Isso provavelmente seria adicionado à sua proposta também.

Correção: A especificação já cobre este erro de compilação, visto que contém a instrução

Em qualquer comparação, o primeiro operando deve ser atribuível ao tipo do segundo operando ou vice-versa.

@Merovius, você faz alguns pontos positivos sobre a minha variante da proposta. Vou abster-me de debatê-los mais, mas gostaria de aprofundar um pouco mais na comparação com a pergunta 0, porque ela se aplica igualmente à proposta original.

fmt.Println(x == 0) // true or false? I vote true :-)

Presumivelmente falso. Dado que o semelhante

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)
deve ser um erro de compilação (como concluímos acima),

Não acho este exemplo muito convincente porque se você alterar a primeira linha para var x float64 = 0.0 , você poderia usar o mesmo raciocínio para argumentar que comparar um float64 com 0 deve ser falso. (Pontos menores: (a) Suponho que você quis dizer float64 (0) na primeira linha, uma vez que 0.0 é atribuível a int. (B) x == y não deve ser um erro de compilação em seu exemplo. Deve imprimir falso.)

Acho que sua ideia de que "isso é semelhante a atribuir uma constante a um tipo de interface e, portanto, deve ter seu tipo padrão" é mais convincente (supondo que você quis dizer o tipo de soma), então o exemplo seria:

var x,y int|float64 = float64(0), 0
fmt.Println(x == y) // falso

Eu ainda diria que x == 0 deveria ser verdade. Meu modelo mental é que um tipo é atribuído a 0 o mais tarde possível. Percebo que isso é contrário ao comportamento atual das interfaces, e é exatamente por isso que o mencionei. Eu concordo que isso não gerou "muita confusão", mas o problema semelhante de comparar interfaces com nil resultou em muita confusão. Acredito que veríamos uma quantidade semelhante de confusão na comparação com 0 se os tipos de soma surgissem e a antiga semântica de igualdade fosse mantida.

Não acho este exemplo muito convincente porque se você alterar a primeira linha para var x float64 = 0.0, você poderia usar o mesmo raciocínio para argumentar que comparar um float64 com 0 deve ser falso.

Eu não disse que deveria , disse que presumivelmente sim , dado o que considero a troca mais provável entre simplicidade / utilidade para como sua proposta seria implementada. Eu não estava tentando fazer um julgamento de valor. Na verdade, se com regras tão simples pudéssemos fazer com que fosse impresso, eu provavelmente tenderia a preferir. Não estou otimista.

Observe que comparar float64(0) com int(0) (ou seja, o exemplo com a soma substituída por var x float64 = 0.0 ) não é false , embora seja um tempo de compilação erro (como deveria ser). Este é exatamente o meu ponto ; sua proposta só é realmente útil quando combinada com constantes não digitadas, porque para qualquer outra coisa ela não compilaria.

(a) Suponho que você quis dizer float64 (0) na primeira linha, uma vez que 0.0 é atribuível a int.

Claro (eu estava assumindo uma semântica mais próxima do "tipo padrão" atual para expressões constantes, mas concordo que o texto atual não implica isso).

(b) x == y não deve ser um erro de compilação em seu exemplo. Deve ser impresso falso.)

Não, deve ser um erro de tempo de compilação. Você disse que a operação e1 == y , com e1 sendo uma expressão do tipo soma, deveria ser permitida se e somente se a expressão pudesse ser compilada com qualquer escolha do tipo constituinte. Dado que em meu exemplo, x tem o tipo int|float64 e y tem o tipo int e dado que float64 e int não são comparáveis, esta condição é claramente violada.

Para fazer essa compilação, você precisa descartar a condição de que a substituição de qualquer expressão tipada constituinte também precisa ser compilada; Nesse ponto, estamos na situação de ter que definir regras como os tipos são promovidos ou convertidos quando usados ​​nessas expressões (também conhecido como "a bagunça C").

O consenso anterior era que os tipos de soma não acrescentam muito aos tipos de interface.

De fato, não para a maioria dos casos de uso do Go: serviços e utilitários de rede triviais. Mas, uma vez que o sistema se torne maior, há uma boa chance de que sejam úteis.
Atualmente, estou escrevendo um serviço altamente distribuído com garantias de consistência de dados implementadas por meio de muita lógica e cheguei a uma situação em que elas seriam úteis. Esses NPDs se tornaram muito irritantes à medida que o serviço cresceu e não vemos uma maneira sensata de dividi-lo.
Quero dizer, as garantias do sistema de tipos de Go são um pouco fracas para algo mais complexo do que os serviços de rede primitivos típicos.

Mas, a história com Rust mostra que é uma má ideia usar tipos de soma para NPD e tratamento de erros assim como fazem em Haskell: há um fluxo de trabalho imperativo natural típico e a abordagem de Haskell não se encaixa bem nele.

Exemplo

considere uma função semelhante a iotuils.WriteFile em pseudocódigo. O fluxo imperativo seria assim

file = open(name, os.write)
if file is error
    return error("cannot open " + name + " writing: " + file.error)
if file.write(data) is error:
    return error("cannot write into " + name + " : " + file.error)
return ok

e como fica em Rust

match open(name, os.write)
    file
        match file.write(data, os.write)
            err
                return error("cannot open " + name + " writing: " + err)
            ok
                return ok
    err
        return error("cannot write into " + name + " : " + err)

é seguro, mas feio.

E minha proposta:

type result[T, Err] oneof {
    default T
    Error Err
}

e como o programa poderia ser ( result[void, string] = !void )

file := os.Open(name, ...)
if !file {
    return result.Error("cannot open " + name + " writing: " + file.Error)
}
if res := file.Write(data); !res {
    return result.Error("cannot write into " + name + " : " + res.Error)
}
return ok

Aqui, a ramificação padrão é anônima e a ramificação de erro pode ser acessada com .Error (uma vez que seja conhecido, o resultado é Erro). Uma vez que é conhecido que o arquivo foi aberto com sucesso, o usuário pode acessá-lo através da própria variável. Em primeiro lugar, se nos certificarmos de que file foi aberto com sucesso ou sair de outra forma (e, portanto, instruções adicionais saberão que o arquivo não é um erro).

Como você pode ver, essa abordagem preserva o fluxo imperativo e fornece segurança de tipo. O manuseio do NPD pode ser feito de maneira semelhante:

type Reference[T] oneof {
    default T
    nil
}
// Reference[T] = *T

o manuseio é semelhante ao resultado

@sirkon , seu exemplo do Rust não me convence de que há algo de errado com tipos de soma simples como no Rust. Em vez disso, sugere que a correspondência de padrões em tipos de soma pode ser mais semelhante a Go usando if declarações. Algo como:

ferr := os.Open(name, ...)
if err(e) := ferr {           // conditional match and unpack, initializing e
  return fmt.Errorf("cannot open %v: %v", name, e)
}
ok(f) := ferr                  // unconditional match and unpack, initializing f
werr := f.Write(data)
...

(No espírito dos tipos de soma, seria um erro de compilação se o compilador não pudesse provar que uma correspondência incondicional sempre é bem-sucedida porque há exatamente um caso restante.)

Para verificação básica de erros, isso não parece uma melhoria em relação a vários valores de retorno, uma vez que é uma linha a mais e declara mais uma variável local. No entanto, seria melhor escalar para vários casos (adicionando mais instruções if) e o compilador poderia verificar se todos os casos foram tratados.

@sirkon

De fato, não para a maioria dos casos de uso do Go: serviços e utilitários de rede triviais. Mas, uma vez que o sistema se torne maior, há uma boa chance de que sejam úteis.
[…]
Quero dizer, as garantias do sistema de tipos de Go são um pouco fracas para algo mais complexo do que os serviços de rede primitivos típicos.

Declarações como essas são desnecessariamente confrontadoras e depreciativas. Eles também são meio constrangedores, TBH, porque há serviços extremamente grandes e nada triviais escritos em Go. E dado que uma parte significativa de seus desenvolvedores trabalha no Google, você deve simplesmente supor que eles sabem melhor do que você, se é adequado escrever serviços grandes e não triviais. Go pode não cobrir todos os casos de uso (nem deveria, IMO), mas empiricamente não funciona apenas para "serviços de rede primitivos".

O manuseio de NPD pode ser feito de maneira semelhante

Acho que isso realmente ilustra que sua abordagem não agrega nenhum valor significativo. Como você indicou, ele simplesmente adiciona uma sintaxe diferente para desreferência. Mas nada AFAICT está impedindo um programador de usar essa sintaxe em um valor nil (o que provavelmente ainda entraria em pânico). ou seja, cada programa que é válido usando *p também é válido usando p.T (ou é p.default ? É difícil dizer o que sua idéia é especificamente) e vice-versa.

A única vantagem que os tipos de soma podem agregar ao tratamento de erros e às desreferências nulas é que o compilador pode exigir que você prove que a operação é segura por correspondência de padrões nela. Uma proposta que omite que a aplicação não parecem trazer coisas novas significativas para a mesa (sem dúvida, é pior do que usar somas abertas por meio de interfaces), enquanto uma proposta que não incluí-lo é exatamente o que você descreve como "feio".

@Merovius

E dado que uma parte significativa de seus desenvolvedores trabalha no Google, você deve apenas supor que eles sabem mais do que você,

Bem-aventurados os crentes.

Como você indicou, ele simplesmente adiciona uma sintaxe diferente para desreferência.

novamente

var written int64
...
res := os.Stdout.Write(data) // Write([]byte) -> Result[int64, string] ≈ !int64
written += res // Will not compile as res is a packed result type
if !res {
    // we are living on non-default res branch thus the only choice left is the default
    return Result.Error(...)
}
written += res // is OK

@skybrian

ferr := os.Open(...)

essa variável intermediária é o que me força a deixar essa ideia. Como você pode ver, minha abordagem é especificamente para tratamento de erros e erros. Essas minúsculas tarefas são muito importantes e merecem uma atenção especial da OMI.

@sirkon Você aparentemente tem muito pouco interesse em conversar

Vamos manter nossas conversas civilizadas e evitar comentários não construtivos. Podemos discordar nas coisas, mas ainda assim manter um discurso respeitável. https://golang.org/conduct.

E dado que uma parte significativa de seus desenvolvedores trabalha no Google, você deve apenas supor que eles sabem mais do que você

Duvido que você possa ter esse tipo de argumento no Google.

@hasufell, aquele cara é da Alemanha, onde não há grandes empresas de TI com entrevistas de merda para bombear o ego do entrevistador e a administração gigantesca, é por isso que essas palavras.

@sirkon o mesmo vale para você. Argumentos ad-hominem e sociais não são úteis. Isso é mais do que um problema de CoC. Já vi esse tipo de "argumento social" surgir com bastante frequência quando se trata da linguagem central: os desenvolvedores de compiladores sabem melhor, os designers de linguagem sabem melhor, o pessoal do Google sabe melhor.

Não, eles não querem. Não existe autoridade intelectual. Existe apenas autoridade de decisão. Deixe isso para trás.

Escondendo alguns comentários para reiniciar a conversa (e obrigado @agnivade por tentar colocá-la de volta nos trilhos).

Pessoal, por favor, considerem seu papel nessas discussões à luz de nossos valores Gopher : todos na comunidade têm uma perspectiva a oferecer e devemos nos esforçar para ser respeitosos e caridosos na forma como interpretamos e respondemos uns aos outros.

Permita-me, por favor, adicionar meus 2 centavos a esta discussão:

Precisamos de uma maneira de agrupar diferentes tipos por recursos diferentes de seus conjuntos de métodos (como acontece com as interfaces). Um novo recurso de agrupamento deve permitir a inclusão de tipos primitivos (ou básicos), que não têm nenhum método, e tipos de interface a serem categorizados como semelhantes. Podemos manter os tipos primitivos (booleano, numérico, string e até [] byte, [] int, etc.) como estão, mas permitir a abstração das diferenças entre os tipos onde uma definição de tipo os agrupa em uma família.

Eu sugiro que adicionemos algo como uma construção do tipo _family_ à linguagem.

A sintaxe

Uma família de tipos pode ser definida como qualquer outro tipo:

type theFamilyName family {
    someType
    anotherType
}

A sintaxe formal seria algo como:
FamilyType = "family" "{" { TypeName ";" } "}" .

Uma família de tipos pode ser definida dentro de uma assinatura de função:

func Display(s family{string; fmt.Stringer}) { /* function body */ }

Ou seja, a definição de uma linha requer ponto e vírgula entre os nomes dos tipos.

O valor zero de um tipo de família é nulo, como em uma interface nula.

(Por baixo do capô, um valor situado por trás da abstração da família é implementado como uma interface.)

O raciocínio

Precisamos de algo mais preciso do que a interface vazia onde queremos especificar quais tipos são válidos como argumentos para uma função ou como retornos de uma função.

A solução proposta permitiria uma melhor segurança de tipo, totalmente verificada em tempo de compilação e sem adicionar sobrecarga em tempo de execução.

O ponto é que _Go code deve ser mais autodocumentável_. O que uma função pode tomar como argumento deve ser incorporado ao próprio código.

O excesso de código explora incorretamente o fato de que “interface {} não diz nada”. É um pouco embaraçoso que uma construção tão amplamente usada (e abusada) em Go, sem a qual não poderíamos fazer muito, diz _nada_.

Alguns exemplos

A documentação para a função sql.Rows.Scan inclui um grande bloco detalhando quais tipos podem ser passados ​​para a função:

Scan converts columns read from the database into the following common Go types and special types provided by the sql package:
 *string
 *[]byte
 *int, *int8, *int16, *int32, *int64
 *uint, *uint8, *uint16, *uint32, *uint64
 *bool
 *float32, *float64
 *interface{}
 *RawBytes
 any type implementing Scanner (see Scanner docs)

E para a função sql.Row.Scan , a documentação inclui a frase “Consulte a documentação em Rows.Scan para obter detalhes”. Consulte a documentação de _alguma outra função_ para obter detalhes. Isso não é parecido com Go — e neste caso a frase não está correta porque de fato Rows.Scan pode assumir um *RawBytes mas Row.Scan não.

O problema é que muitas vezes somos forçados a confiar em comentários para garantias e contratos de comportamento, que o compilador não pode fazer cumprir.

Quando a documentação de uma função diz que a função funciona como alguma outra função - “então vá ver a documentação dessa outra função” - você quase pode garantir que a função será mal utilizada às vezes. Aposto que a maioria das pessoas, como eu, só descobriu que *RawBytes não é permitido como argumento em Row.Scan somente após obter um erro de Row.Scan ( dizendo "sql: RawBytes não é permitido no Row.Scan"). É triste que o sistema de tipos permita tais erros.

Em vez disso, poderíamos ter:

type Value family {
    *string
    *[]byte
    *int; *int8; *int16; *int32; *int64
    *uint; *uint8; *uint16; *uint32; *uint64
    *bool
    *float32; *float64
    *interface{}
    *RawBytes
    Scanner
}

Dessa forma, o valor passado deve ser um dos tipos da família fornecida, e a troca de tipo dentro da função Rows.Scan não precisará lidar com nenhum caso inesperado ou padrão; haveria outra família para a função Row.Scan .

Considere também como a estrutura cloud.google.com/go/datastore.Property tem um campo “Valor” do tipo interface{} e requer toda esta documentação:

// Value is the property value. The valid types are:
// - int64
// - bool
// - string
// - float64
// - *Key
// - time.Time
// - GeoPoint
// - []byte (up to 1 megabyte in length)
// - *Entity (representing a nested struct)
// Value can also be:
// - []interface{} where each element is one of the above types
// This set is smaller than the set of valid struct field types that the
// datastore can load and save. A Value's type must be explicitly on
// the list above; it is not sufficient for the underlying type to be
// on that list. For example, a Value of "type myInt64 int64" is
// invalid. Smaller-width integers and floats are also invalid. Again,
// this is more restrictive than the set of valid struct field types.
//
// A Value will have an opaque type when loading entities from an index,
// such as via a projection query. Load entities into a struct instead
// of a PropertyLoadSaver when using a projection query.
//
// A Value may also be the nil interface value; this is equivalent to
// Python's None but not directly representable by a Go struct. Loading
// a nil-valued property into a struct will set that field to the zero
// value.

Isto pode ser:

type PropertyVal family {
  int64
  bool
  string
  float64
  *Key
  time.Time
  GeoPoint
  []byte
  *Entity
  nil
  []int64; []bool; []string; []float64; []*Key; []time.Time; []GeoPoint; [][]byte; []*Entity
}

(Você pode imaginar como isso poderia ser dividido em duas famílias).

O tipo json.Token foi mencionado acima. Sua definição de tipo seria:

type Token family {
    Delim
    bool
    float64
    Number
    string
    nil
}

Outro exemplo que recebi recentemente:
Ao chamar funções como sql.DB.Exec , ou sql.DB.Query , ou qualquer função que receba uma lista variável de interface{} onde cada elemento deve ter um tipo em um conjunto particular e _não ser um slice_, é importante lembrar de usar o operador “spread” ao passar os argumentos de um []interface{} para tal função: é errado dizer DB.Exec("some query with placeholders", emptyInterfaceSlice) ; a maneira correta é: DB.Exec("the query...", emptyInterfaceSlice...) onde emptyInterfaceSlice tem o tipo []interface{} . Uma maneira elegante de tornar esses erros impossíveis seria fazer com que essa função assumisse um argumento variável de Value , onde Value é definido como uma família conforme descrito acima.

O ponto desses exemplos é que _ erros reais estão sendo cometidos_ por causa da imprecisão do interface{} .

var x int | float64 | string | rune
z = int(x) + int(y)
z = float64(x) + float64(y)

Isso definitivamente deve ser um erro do compilador porque o tipo de x não é realmente compatível com o que pode ser passado para int() .

Gosto da ideia de ter family . Seria essencialmente uma interface restrita (restrita?) Aos tipos listados e o compilador pode garantir que você está correspondendo o tempo todo e altera o tipo da variável dentro do contexto local do case .

O problema é que muitas vezes somos forçados a confiar nos comentários para obter garantias e
contratos de comportamento, que o compilador não pode impor.

Essa é a razão pela qual comecei a não gostar de coisas como

func foo() (..., error) 

porque você não tem ideia de que tipo de erro ele retorna.

e algumas outras coisas que retornam uma interface em vez de um tipo concreto. Algumas funções
return net.Addr e às vezes é um pouco difícil vasculhar o código-fonte para descobrir que tipo de net.Addr ele realmente retorna e então usá-lo apropriadamente. Não há realmente muita desvantagem em retornar um tipo concreto (porque ele implementa a interface e, portanto, pode ser usado em qualquer lugar onde a interface pode ser usada), exceto quando você
posteriormente, planeje estender seu método para retornar um tipo diferente de net.Addr . Mas se o seu
API menciona que retorna OpError então por que não tornar essa parte da especificação de "tempo de compilação"?

Por exemplo:

 OpError is the error type usually returned by functions in the net package. It describes the operation, network type, and address of an error. 

Usualmente? Não informa exatamente quais funções retornam esse erro. E esta é a documentação do tipo, não da função. A documentação para Read nenhum lugar menciona que ele retorna OpError. Além disso, se você fizer

err := blabla.(*OpError)

ele irá travar assim que retornar um tipo diferente de erro. É por isso que eu realmente gostaria de ver isso como parte da declaração da função. Pelo menos *OpError | error diria que ele retorna
tal erro e o compilador garante que você não faça uma declaração de tipo não verificada, travando seu programa no futuro.

BTW: um sistema como o polimorfismo de tipo de Haskell já foi considerado? Ou um sistema de tipo baseado em 'traço', ou seja:

func calc(a < add(a, a) a >, b a) a {
   return add(a, b)
}

func drawWidgets(widgets []< widgets.draw() error >) error {
  for _, widgets := range widgets {
    err := widgets.draw()
    if err != nil {
      return err
    }
  }
  return nil
}

a < add(a, a) a significa "qualquer que seja o tipo de a, deve existir uma função add (typeof a, typeof a) typeof a)". < widgets.draw() error> significa que "qualquer que seja o tipo do widget, ele deve fornecer um método draw que retorne um erro". Isso permitiria a criação de funções mais genéricas:

func Sum(a []< add(a,a) a >) a {
  sum := a[0]
  for i := 1; i < len(a); i++ {
    sum = add(sum,a[i])
  }
  return sum
}

(Observe que isso não é igual aos "genéricos" tradicionais).

Não há realmente muita desvantagem em retornar um tipo concreto (porque ele implementa a interface e pode, portanto, ser usado em qualquer lugar onde a interface possa ser usada), exceto quando você planeja estender seu método para retornar um tipo diferente de net.Addr .

Além disso, Go não tem subtipos de variantes, então você não pode usar func() *FooError como func() error quando necessário. O que é especialmente importante para a satisfação da interface. E, por último, isso não compila:

func Foo() (FooVal, FooError) {
    // ...
}

func Bar(f FooVal) (BarVal, BarError) {
    // ...
}

func main() {
    foo, err := Foo()
    if err != nil {
        log.Fatal(err)
    }
    bar, err := Bar(foo) // Type error: Can not assign BarError to err (type FooError)
    if err != nil {
        log.Fatal(err)
    }
}

ou seja, para fazer isso funcionar (gostaria se pudéssemos de alguma forma), precisaríamos de uma inferência de tipo muito mais sofisticada - atualmente, Go usa apenas informações de tipo local de uma única expressão. Em minha experiência, esses tipos de algoritmos de inferência de tipo não são apenas significativamente mais lentos (tornando a compilação mais lenta e geralmente nem mesmo o tempo de execução limitado), mas também produzem mensagens de erro muito menos compreensíveis.

Além disso, Go não tem subtipagem variante, então você não pode usar um func () * FooError como um erro func () quando necessário. O que é especialmente importante para a satisfação da interface. E, por último, isso não compila:

Eu esperava que isso funcionasse bem no Go, mas nunca me deparei com isso porque a prática atual é apenas usar error . Mas sim, nesses casos essas restrições praticamente o forçam a usar error como o tipo de retorno.

func main() {
    foo, err := Foo()
    if err != nil {
        log.Fatal(err)
    }
    bar, err := Bar(foo) // Type error: Can not assign BarError to err (type FooError)
    if err != nil {
        log.Fatal(err)
    }
}

Não conheço nenhuma linguagem que permita isso (bem, exceto esolangs), mas tudo o que você precisa fazer é manter um "tipo de mundo" (que é basicamente um mapa de variable -> type ) e se você for -atribuir a variável que você acabou de atualizar seu tipo no "tipo de mundo".

Eu não acho que você precisa de inferência de tipo complicada para fazer isso, mas você precisa acompanhar os tipos de variáveis, mas estou assumindo que você precisa fazer isso de qualquer maneira, porque

var int i = 0;
i = "hi";

você certamente de alguma forma tem que lembrar quais variáveis ​​/ declarações têm quais tipos e para i = "hi" você precisa fazer uma "pesquisa de tipo" em i para verificar se você pode atribuir uma string a ele.

Existem questões práticas que complicam a atribuição de func () *ConcreteError a func() error diferente do verificador de tipo que não o suporta (como motivos de tempo de execução / motivos de código compilado)? Eu acho que atualmente você teria que envolvê-lo em uma função como esta:

type MyFunc func() error

type A struct {
}

func (_ *A) Error() string { return "" }

func NewA() *A {
    return &A{}
}

func main() {
    var err error = &A{}
    fmt.Println(err.Error())
    var mf MyFunc = MyFunc(func() error { return NewA() }) // type checks fine
        //var mf MyFunc = MyFunc(NewA) // doesn't type check
    _ = mf
}

Se você se depara com um func (a, b) c mas recebe um func (x, y) z tudo o que precisa ser feito é verificar se z ser atribuído a c (e a , b deve ser atribuível a x , y ) que pelo menos no nível de tipo não envolve inferência de tipo complicada (envolve apenas verificar se um tipo é atribuível / compatível com / com outro tipo). Claro, se isso causa problemas com tempo de execução / compilação ... Não sei, mas pelo menos olhando estritamente para o nível de tipo, não vejo por que isso envolveria inferência de tipo complicada. O verificador de tipo já sabe se x pode ser atribuído a a portanto, ele também sabe facilmente se func () x pode ser atribuído a func () a . Claro, pode haver razões práticas (pensando em representações de tempo de execução) porque isso não será facilmente possível. (Estou suspeitando que esse é o ponto crucial aqui, não a verificação de tipo real).

Teoricamente, você poderia contornar os problemas de tempo de execução (se houver) com funções empacotadas automaticamente (como no trecho acima) com a desvantagem _potencialmente enorme_ que atrapalha as comparações de funcs com funcs (já que a função encapsulada não será igual à func ele embrulha).

Não conheço nenhum idioma que permita isso (bem, exceto esolangs)

Não exatamente, mas eu diria que é porque as linguagens com sistemas de tipos poderosos são geralmente linguagens funcionais que realmente não usam variáveis ​​(e, portanto, não precisam realmente da capacidade de reutilizar identificadores). FWIW, eu diria que, por exemplo, o sistema de tipos de Haskell seria capaz de lidar com isso muito bem - pelo menos enquanto você não estiver usando nenhuma outra propriedade de FooError ou BarError , deveria ser capaz de inferir que err é do tipo error e lidar com isso. Claro, mais uma vez, isso é uma hipótese, porque essa situação exata não se transfere facilmente para uma linguagem funcional.

mas estou assumindo que você precisa fazer isso de qualquer maneira, porque

A diferença é que, em seu exemplo, i tem um tipo claro e bem compreendido após a primeira linha, que é int e você então se depara com um erro de tipo quando atribui um string para ele. Enquanto isso, para algo como mencionei, cada uso de um identificador essencialmente cria um conjunto de restrições no tipo usado e o verificador de tipo tenta inferir o tipo mais geral que cumpre todas as restrições dadas (ou reclama que não há nenhum tipo que cumpra isso contrato). É para isso que servem as teorias de tipo formal.

Existem questões práticas que complicam a atribuição de func () *ConcreteError a func() error diferente do verificador de tipo que não o suporta (como motivos de tempo de execução / motivos de código compilado)?

Existem problemas práticos, mas acredito que por func eles provavelmente podem ser resolvidos (emitindo código un / -wrapping, de forma semelhante a como funciona a passagem de interface). Escrevi um pouco sobre variância em Go e expliquei alguns dos problemas práticos que vejo na parte inferior. Não estou totalmente convencido de que vale a pena acrescentar. Ou seja, não tenho certeza se ele resolve problemas importantes por conta própria.

com a desvantagem potencialmente enorme de atrapalhar comparações de funcs com funcs (já que a função empacotada não será igual à função que envolve).

funções não são comparáveis.

De qualquer forma, TBH, tudo isso parece um pouco fora do tópico desta edição :)

FYI: Eu acabei de fazer isso . Não é legal, mas com certeza é seguro para tipos. (O mesmo pode ser feito para # 19814 FWIW)

Estou um pouco atrasado para a festa, mas também gostaria de compartilhar com vocês meus sentimentos após 4 anos de Go:

  • Os retornos de vários valores foram um grande erro.
  • Interfaces com capacidade nula foram um erro.
  • Ponteiros não são sinônimos para "opcional", uniões discriminadas deveriam ser usadas em seu lugar.
  • O unmarshaller JSON deve ter retornado um erro se um campo obrigatório não estiver incluído no documento JSON.

Nesses últimos 4 anos, encontrei muitos problemas associados a ele:

  • dados de lixo retornam em caso de erro.
  • confusão de sintaxe (retornando valores zerados em caso de erro).
  • retornos de erros múltiplos (APIs confusas, por favor, não faça isso!).
  • interfaces não nulas apontando para ponteiros apontando para nil (confunde muito as pessoas fazendo a declaração "Go is a easy language" soar como uma piada de mau gosto).
  • campos JSON desmarcados fazem os servidores travarem (yey!).
  • ponteiros retornados desmarcados fazem os servidores travarem, mas ninguém documentou que o ponteiro retornado representa um opcional (tipo talvez) e poderia, portanto, ser nil (yey!)

As mudanças necessárias para corrigir todos esses problemas, no entanto, exigiriam uma versão Go 2.0.0 (não Go2) verdadeiramente incompatível com versões anteriores, que nunca será realizada, suponho. Qualquer forma...

É assim que o tratamento de erros deveria ser:

// Divide returns either a float64 or an arbitrary error
func Divide(dividend, divisor float64) float64 | error {
  if dividend == 0 {
    return errors.New("dividend is zero")
  }
  if divisor == 0 {
    return errors.New("divisor is zero")
  }
  return dividend / divisor
}

func main() {
  // type-switch statements enforce completeness:
  switch v := Divide(1, 0).(type) {
  case float64:
    log.Print("1/0 = ", v)
  case error:
    log.Print("1/0 = error: ", v)
  }

  // type-assertions, however, do not:
  divisionResult := Divide(3, 1)
  if v, ok := divisionResult.(float64); ok {
    log.Print("3/1 = ", v)
  }
  if v, ok := divisionResult.(error); ok {
    log.Print("3/1 = error: ", v.Error())
  }
  // yet they don't allow asserting types not included in the union:
  if v, ok := divisionResult.(string); ok { // compile-time error!
    log.Print("3/1 = string: ", v)
  }
}

As interfaces não são um substituto para as uniões discriminadas , são dois animais completamente diferentes. O compilador garante que as alternâncias de tipo nas uniões discriminadas sejam completas, o que significa que os casos cobrem todos os tipos possíveis; se você não quiser isso, poderá usar a instrução de declaração de tipo.

Muitas vezes tenho visto pessoas ficando totalmente confusas sobre _interfaces não nulas para valores nulas_ : https://play.golang.org/p/JzigZ2Q6E6F. Normalmente, as pessoas ficam confusas quando uma interface error aponta para um ponteiro de um tipo de erro personalizado que aponta para nil , esse é um dos motivos pelos quais acho que tornar as interfaces nulas foi um erro.

Uma interface é como uma recepcionista, você sabe que é um humano quando está falando com ela, mas em Go, pode ser uma figura de papelão e o mundo vai quebrar de repente se você tentar falar com ela.

As uniões discriminadas deveriam ter sido usadas para opcionais (tipos talvez) e passar nil ponteiros para interfaces deveria ter resultado em pânico:

type CustomErr struct {}
func (err *CustomErr) Error() string { return "custom error" }

func CouldFail(foo int) error | nil {
  var err *customErr
  if foo > 10 {
    // you can't return a nil pointer as an interface value
    return err // this will panic!
  }
  // no error
  return nil
}

func main() {
  // assume no error
  if err, ok := CouldFail().(error); ok {
    log.Fatalf("it failed, Jim! %s", err)
  }
}

Ponteiros e tipos talvez não são intercambiáveis. Usar ponteiros para tipos opcionais é ruim porque leva a APIs confusas:

// P returns a pointer to T, but it's not clear whether or not the pointer
// will always reference a T instance. It might be an optional T,
// but the documentation usually doesn't tell you.
func P() *T {}

// O returns either a pointer to T or nothing, this implies (but still doesn't guarantee)
// that the pointer is always expected to not be nil, in any other case nil is returned.
func O() *T | nil {}

Depois, há também JSON. Isso nunca poderia acontecer com os sindicatos, porque o compilador força você a verificá-los antes de usar . O unmarshaller JSON deve falhar se um campo obrigatório (incluindo campos de tipo de ponteiro) não estiver incluído no documento JSON:

type DataModel struct {
  // Optional needs to be type-checked before use
  // and is therefore allowed to no be included in the JSON document
  Optional string | nil `json:"optional,omitempty"`
  // Required won't ever be nil
  // If the JSON document doesn't include it then unmarshalling will return an error
  Required *T `json:"required"`
}

PS
Também estou trabalhando em um projeto de linguagem funcional no momento e é assim que uso uniões discriminadas para tratamento de erros:

read = (s String) -> (Array<Byte> or Error) => match s {
  "A" then Error<NotFound>
  "B" then Error<AccessDenied>
  "C" then Error<MemoryLimitExceeded>
  else Array<Byte>("this is fine")
}

main = () -> ?Error => {
  // assume the result is a byte array
  // otherwise throw the error up the stack wrapped in a "type-assertion-failure" error
  r = read("D") as Array<Byte>
  log::print("data: %s", r)
}

Eu adoraria ver isso se tornar realidade um dia. Então, vamos ver se posso ajudar um pouco:

Talvez o problema seja que estamos tentando cobrir muito com a proposta. Poderíamos escolher uma versão simplificada que trouxesse a maior parte do valor para que fosse muito mais fácil adicioná-lo ao idioma em curto prazo.

Do meu ponto de vista, esta versão simplificada estaria relacionada apenas a nil . Aqui estão as idéias principais (quase todas elas já foram mencionadas nos comentários):

  1. Permitir apenas o | versão
    <any pointer type> | nil
    Onde qualquer tipo de ponteiro seria: ponteiros, funções, canais, fatias e mapas (os tipos de ponteiro Go)
  2. Proíbe atribuir nil a um tipo de ponteiro vazio. Se você deseja atribuir nulo, o tipo precisa ser <pointer type> | nil . Por exemplo:
var n *int       = nil // Does not compile, wrong type
var n *int | nil = nil // Ok!

var set map[string] bool       = nil // Does not compile
var set map[string] bool | nil = nil // Ok!

var myFunc func(int) err       = nil // Nope!
var myFunc func(int) err | nil = nil // All right.

Essas são as ideias principais. As seguintes são as ideias derivadas das principais:

  1. Você não pode declarar uma variável de um tipo de ponteiro vazio e deixá-la não inicializada. Se você quiser fazer isso, será necessário adicionar o tipo discriminado | nil
var maybeAString *string       // Wrong: invalid initial value
var maybeAString *string | nil // Good
  1. Você pode atribuir um tipo de ponteiro simples a um tipo de ponteiro "anulável", mas não o contrário:
var value int = 42
var barePointer *int = &value          // Valid
var nilablePointer *int | nil = &value // Valid

nilablePointer = barePointer // Valid
barePointer = nilablePointer // Invalid: Incompatible types
  1. A única maneira de obter o valor de um tipo de ponteiro "anulável" é por meio da opção de tipo, como outros apontaram. Por exemplo, seguindo o exemplo acima, se realmente quisermos atribuir o valor de nilablePointer a barePointer , então precisaríamos fazer:
switch val := nilablePointer.(type) {
  case *int:
    barePointer = val // Yeah! Types are compatible now. It is imposible that "val = nil"
  case nil:
    // Do what you need to do when nilablePointer is nil
}

E é isso. Eu sei que as uniões discriminadas podem ser usadas para muito mais (principalmente no caso de retornar erros), mas eu diria que nos atermos apenas ao que escrevi acima, traríamos um grande valor para a linguagem com menos esforço e sem complicando mais do que o necessário.
Benefícios que vejo com esta proposta simples:

  • a) Sem erros de ponteiro nulo . Ok, nunca 4 palavras significaram tanto. É por isso que sinto a necessidade de dizer isso de outro ponto de vista: o programa No Go _NUNCA_ terá um erro nil pointer dereference novamente! 💥
  • b) Você pode passar ponteiros para parâmetros de função sem negociar "desempenho versus intenção" .
    O que quero dizer com isso é que há momentos em que desejo passar um struct para uma função, e não um ponteiro para ela, porque não quero que essa função se preocupe com a nulidade e force-a a verificar os parâmetros . No entanto, normalmente acabo passando um ponteiro para evitar o overhead de cópia.
  • c) Chega de mapas nulos! ISSO! Terminaremos com a inconsistência sobre os "nil-slice seguro" e os "nil-maps inseguros" (isso entrará em pânico se você tentar escrever para eles). Um mapa será inicializado ou será do tipo map | nil , caso em que você precisaria usar uma opção de tipo 😃

Mas também há outro intangível aqui que traz muito valor: a paz de espírito do

Uma vantagem de começar com esta versão mais simples da proposta é que isso não nos impedirá de ir para a proposta completa no futuro, ou mesmo ir passo a passo (sendo, para mim, o próximo passo natural para permitir retornos de erro discriminados , mas vamos esquecer isso agora).

Um problema é que mesmo esta versão simples da proposta é incompatível com versões anteriores, mas pode ser facilmente corrigida por gofix : basta substituir todas as declarações de tipo de ponteiro por <pointer type> | nil .

O que você acha? Espero que isso possa lançar alguma luz e acelerar a inclusão de nil-safety na linguagem. Parece que esta forma (através das "uniões discriminadas") é a forma mais simples e ortogonal de o conseguir.

@alvaroloes

Você não pode declarar uma variável de um tipo de ponteiro vazio e deixá-la não inicializada.

Este é o ponto crucial da questão. Isso não é algo que Go faz - todo tipo tem um valor zero, ponto final. Caso contrário, você teria que responder o que, por exemplo, make([]T, 100) faz? Outras coisas que você mencionou (por exemplo, mapas nulos em gravações) são uma consequência desta regra básica. (E como um aparte, eu não acho que seja realmente verdade dizer que nil-slice é mais seguro do que mapas - escrever para um nil-slice causa pânico tanto quanto escrever para um nil-map).

Em outras palavras: na verdade, sua proposta não é tão simples, pois se desvia significativamente de uma decisão de design bastante fundamental na linguagem Go.

Acho que a coisa mais importante que Go faz é tornar os valores zero úteis e não simplesmente dar valor zero a tudo. O mapa nulo é um valor zero, mas não é útil. É prejudicial, na verdade. Então, por que não proibir o valor zero nos casos em que não é útil. Mudar Go nesse aspecto seria benéfico, mas a proposta não é tão simples assim.

A proposta acima se parece mais com o tipo de coisa opcional / não opcional, como em Swift e outros. É legal e tudo menos:

  1. Isso quebraria praticamente todos os programas existentes e a correção não seria trivial para o gofix. Você não pode simplesmente substituir tudo por <pointer type> | nil , pois, por proposta, isso exigiria uma chave de tipo para descompactar o valor.
  2. Para que isso seja realmente utilizável e suportável, Go precisaria ter muito mais açúcar sintático em torno desses opcionais. Veja o Swift, por exemplo. Existem muitos recursos na linguagem especificamente para trabalhar com opcionais - guarda, ligação opcional, encadeamento opcional, coalescência nula etc. etc. Não acho que Go iria nessa direção, mas sem eles trabalhar com opcionais seria uma tarefa árdua.

Então, por que não proibir o valor zero nos casos em que não é útil.

Veja acima. Isso significa que algumas coisas que parecem baratas têm custos muito não triviais associados a elas.

Mudar Go a este respeito seria benéfico

Tem benefícios, mas não é o mesmo que ser benéfico. Ele também causa danos. Qual é o peso mais pesado depende da preferência e uma troca. Os designers de Go escolheram isso.

FTR, este é um padrão geral neste segmento e um dos principais contra-argumentos para qualquer conceito de tipos de soma - que você precisa dizer qual é o valor zero. É por isso que qualquer nova ideia deve abordá-lo explicitamente. Mas, de forma um tanto frustrante, a maioria das pessoas que postam aqui hoje em dia não leu o resto do tópico e tende a ignorar essa parte.

🤔 Aha! Eu sabia que havia algo óbvio que estava faltando. Doh! A palavra "simples" tem significados complexos. Ok, sinta-se à vontade para remover a palavra "simples" do meu comentário anterior.

Desculpe se foi frustrante para alguns de vocês. Minha intenção era tentar ajudar um pouco. Tento acompanhar o tópico, mas não tenho muito tempo livre para gastar com isso.

Voltando ao assunto: então, parece que o principal motivo que está impedindo isso é o valor zero.
Depois de pensar um pouco e descartar várias opções, a única coisa que acho que poderia agregar valor e que vale a pena mencionar é o seguinte:

Se bem me lembro, o valor zero de qualquer tipo consiste em preencher seu espaço de memória com zeros.
Como você já sabe, isso é bom para tipos que não são ponteiros, mas é uma fonte de bugs para tipos de ponteiros:

type S struct {
    n int
}
var s S 
s.n  // Fine

var s *S
s.n // runtime error

var f func(int)
f() // runtime error

Então, e se nós:

  • Defina um valor zero útil para cada tipo de ponteiro
  • Inicialize-o apenas na primeira vez que for usado (inicialização lenta).

Acho que isso foi sugerido em outra edição, não tenho certeza. Eu apenas o escrevo aqui porque ele aborda o principal ponto de retenção desta proposta.

O seguinte pode ser uma lista dos valores zero para os tipos de ponteiro. Observe que esses valores zero serão usados apenas quando o valor for acessado . Poderíamos chamá-lo de "valor zero dinâmico" e é apenas uma propriedade dos tipos de ponteiro:

| Tipo de ponteiro | Valor zero | Valor zero dinâmico | Comentário |
| --- | --- | --- | --- |
| * T | nil | novo (T) |
| [] T | nil | [] T {} |
| mapa [T] U | nil | mapa [T] U {} |
| func | nil | noop | Portanto, o valor zero dinâmico de uma função não faz nada e retorna valores zero. Se a lista de valores de retorno terminar em error , um erro padrão será retornado informando que a função é "sem operação" |
| chan T | nil | fazer (chan T) |
| interface | nil | - | uma implementação padrão onde todos os métodos são inicializados com a função noop descrita acima |
| sindicato discriminado | nil | valor zero dinâmico do primeiro tipo | |

Agora, quando esses tipos forem inicializados, eles serão nil , como estão agora. A diferença está no momento em que um nil é acessado. Nesse momento, o valor zero dinâmico será usado. Alguns exemplos:

type S struct {
    n int
}
var s *S
if s == nil { // true. Nothing different happens here
...
}
s.n = 1       // At this moment the go runtime would check if it is nil, and if it is, 
              // do "s = new(S)". We could say the code would be replaced by:
/*
if s == nil {
    s = new(S)
}
s.n = 1
*/

// -------------
var pointers []*S = make([]*S, 100) // Everything as usual
for _,p := range pointers {
    p.n = 1 // This is translated to:
    /*
        if p == nil {
            p = new(S)
        }
        p.n = 1
    */
}

// ------------
type I interface {
    Add(string) (int, error)
}

var i I
n, err := i.Add("yup!") // This method returns 0, and the default error "Noop"
if err != nil { // This condition is true and the error is returned
    return err
}

Provavelmente estou perdendo detalhes de implementação e possíveis dificuldades, mas queria me concentrar na ideia primeiro.

A principal desvantagem é que adicionamos uma verificação nil extra toda vez que você acessa o valor de um tipo de ponteiro. Mas eu diria:

  • É uma boa troca pelos benefícios que obtemos. A mesma situação acontece com as verificações de limite em acessos de array / slice e aceitamos pagar essa penalidade de desempenho pela segurança que isso traz.
  • As verificações nulas podem ser evitadas da mesma forma que as verificações de limites de matrizes: se o tipo de ponteiro foi inicializado no escopo atual, o compilador pode saber disso e evitar adicionar a verificação nula.

Com isso, temos todas as vantagens explicadas no comentário anterior, com a vantagem de não precisarmos usar um switch de tipo para acessar o valor (isso seria apenas para as uniões discriminadas), mantendo o código go tão limpo quanto é agora.

O que você acha? Peço desculpas se isso já foi discutido. Além disso, estou ciente de que este comentário-proposta está mais relacionado a nil que sindicatos discriminados. Eu poderia mover isso para um problema relacionado ao zero, mas, como eu disse, eu postei aqui porque ele tenta corrigir o principal problema das uniões discriminadas: os valores zero úteis.

Voltando ao assunto: então, parece que o principal motivo que está impedindo isso é o valor zero.

É um motivo técnico significativo que precisa ser abordado. Para mim, o principal motivo é que eles tornam o reparo gradual categoricamente impossível (veja acima). ou seja, para mim, pessoalmente, não é tanto uma questão de como implementá-los, é que sou fundamentalmente contra o conceito.
Em todo caso, qual razão é "principal" é realmente uma questão de gosto e preferência.

Então, e se nós:

  • Defina um valor zero útil para cada tipo de ponteiro
  • Inicialize-o apenas na primeira vez que for usado (inicialização lenta).

Isso falha se você passar um tipo de ponteiro. por exemplo

func F(p *T) {
    *p = 42 // same as if p == nil { p = new(T) } *p = 42
}

func G() {
    var p *T
    F(p)
    fmt.Println(p == nil) // Has to be true, as F can't modify p. But now F is silently misbehaving
}

Esta discussão é tudo menos nova. Há razões pelas quais os tipos de referência se comportam da maneira que se comportam e não é que os desenvolvedores de Go não tenham pensado nisso :)

Este é o ponto crucial da questão. Isso simplesmente não é uma coisa que Go faz - todo tipo tem um valor zero, ponto final. Caso contrário, você teria que responder o que, por exemplo, make ([] T, 100) faz?

Isso (e new(T) ) teria que ser desabilitado se T não tivesse um valor zero. Você teria que fazer make([]T, 0, 100) e então usar append para preencher a fatia. Reslicing maior ( v[:0][:100] ) também deve ser um erro. [10]T seria basicamente um tipo impossível (a menos que a capacidade de declarar uma fatia para um ponteiro de array seja adicionada à linguagem). E você precisaria de uma maneira de marcar os tipos existentes que podem ser anulados como não anuláveis ​​para manter a compatibilidade com versões anteriores.

Isso seria um problema se os genéricos fossem adicionados, pois seria necessário tratar todos os parâmetros de tipo como não tendo um valor zero, a menos que satisfaçam algum limite. Um subconjunto de tipos também precisaria de rastreamento de inicialização basicamente em todos os lugares. Esta seria uma mudança bastante grande por si só, mesmo sem adicionar tipos de soma em cima dela. É certamente factível, mas contribui significativamente para o lado do custo de uma análise de custo / benefício. A escolha deliberada de manter a inicialização simples ("sempre há um valor zero") teria, em vez disso, o impacto de tornar a inicialização mais complexa do que se o rastreamento de inicialização estivesse na linguagem desde o dia 1.

É um motivo técnico significativo que precisa ser abordado. Para mim, a principal razão é que eles tornam o reparo gradual categoricamente impossível (veja acima). ou seja, para mim, pessoalmente, não é tanto uma questão de como implementá-los, é que sou fundamentalmente contra o conceito.
Em todo caso, qual razão é "principal" é realmente uma questão de gosto e preferência.

Ok, eu entendo isso. Nós apenas temos que ver o ponto de vista das outras pessoas (não estou dizendo que você não está fazendo isso, estou apenas fazendo uma observação: wink :) onde eles vêem isso como algo poderoso para escrever seus programas. Ele se encaixa no Go? Depende de como a ideia é executada e integrada à linguagem, e é isso que todos nós estamos tentando fazer neste tópico (eu acho)

Isso falha se você passar um tipo de ponteiro. por exemplo (...)

Eu não entendo muito bem. Por que isso é um fracasso? Você está apenas passando um valor para o parâmetro da função, que por acaso é um ponteiro com o nil . Então você está modificando esse valor dentro da função. Espera-se que você não veja esses efeitos fora da função. Deixe-me comentar alguns exemplos:

// Augmenting your example with more comments:
func FCurrentGo(p *T) {
    // Here "p" is just a value, which happens to be a pointer type. Doing...
    *p = 42
    // ...without checking first for "nil" is the recipe for hiding a bug that will crash the entire program, 
    // which is exactly what is happening in current Go code bases

    // The correct code would be:
    if p == nil {
        // panic or return error
    }
    *p = 42
}

func FWithDynamicZero(p *T) {
    // Here, again, p is just a value of a pointer type. Doing...
    *p = 42
    // would allocate a new T and assign 42. It is true that this doesn't have any effect on the "outside
    // world", which could be considered "incorrect" because you expected the function to do that.
    // If you really want to be sure "p" is pointing to something valid in the "outside world", then
    // check that:
    if p == nil {
        // panic or return error
    }
    *p = 42
}

func main() {
    var p *T
    FCurrentGo(p) // This will crash the program
        FWithDynamicZero(p) // This won't have any effect on "p". This is expected because "p" is not pointing
                            // to anything. No crash here.
    fmt.Println(p == nil) // It is true, as expected
}

Uma situação semelhante está acontecendo com os métodos de receptor sem ponteiro, e é confuso para os recém-chegados Go (mas uma vez que você entende isso, então faz sentido):

type Point struct {
    x, y int
}

func (p Point) SetXY(x, y int) {
    p.x = x
    p.y = y
}

func main() {
    p := Point{x: 1, y: 2}
    p.SetXY(24, 42)

    pointerToP := &Point{x: 1, y: 2}
    pointerToP.SetXY(24, 42)

    fmt.Println(p, pointerToP) // Will print "{1 2} &{1 2}", which could confuse at first
}

Portanto, precisamos escolher entre:

  • A) Falha com colisão
  • B) Falha com uma não modificação silenciosa do valor apontado por um ponteiro quando esse ponteiro é passado para uma função.

A correção para ambos os casos é a mesma: verifique se há nulo antes de fazer qualquer coisa. Mas, para mim, A) é muito mais prejudicial (todo o aplicativo trava!).
B) poderia ser considerado um "erro silencioso", mas não o consideraria um erro. Isso só acontece quando você passa ponteiros para funções e, como mostrei, há casos com estruturas que se comportam de maneira semelhante. Isso sem considerar os enormes benefícios que traz.

Observação: não estou tentando defender cegamente "minha" ideia, estou genuinamente tentando melhorar o Go (o que já é muito bom). Se houver alguns outros pontos que fazem a ideia não valer a pena, então não me importo em jogá-la fora e continuar pensando em outras direções

Nota 2: Eventualmente, esta ideia é apenas para valores "nulos" e não tem nada a ver com sindicatos discriminados. Então, vou criar um problema diferente para evitar poluir este aqui

Ok, eu entendo isso. Precisamos apenas ver o ponto de vista das outras pessoas (não estou dizendo que você não está fazendo isso, estou apenas enfatizando )

Essa espada corta os dois lados, no entanto. Você disse que "o principal motivo para segurar isso foi". Essa afirmação implica que todos estamos de acordo sobre se queremos o efeito desta proposta. Posso certamente concordar que é um detalhe técnico que impede as sugestões específicas feitas (ou pelo menos, que qualquer sugestão deveria dizer algo sobre essa questão ). Mas não gosto que a discussão seja discretamente reenquadrada em um mundo paralelo, onde presumimos que todos realmente a desejam .

Por que isso é um fracasso?

Porque uma função que está pegando um ponteiro irá, pelo menos freqüentemente, fazer uma promessa de modificar o ponteiro. Se a função silenciosamente não fizer nada, eu consideraria isso um bug. Ou, pelo menos, é um argumento fácil de fazer, que ao prevenir um nil-panic dessa forma, você está introduzindo uma nova classe de bug.

Se você passar um ponteiro nil para uma função que espera algo lá, isso é um bug - e não vejo o valor real de fazer um software com erros continuar silenciosamente. Eu posso ver o valor na ideia original de detectar esse bug em tempo de compilação, tendo suporte para ponteiros não nilable, mas não vejo o ponto em permitir que esse bug não seja detectado de forma alguma.

ou seja, por assim dizer, você está tratando de um tipo de problema diferente da proposta real de ponteiros não niláveis: Para essa proposta, o pânico em tempo de execução não é o problema, mas apenas um sintoma - o problema é o bug que ocorre acidentalmente nil para algo que não espera e que esse bug só é detectado em tempo de execução.

Uma situação semelhante está acontecendo com métodos de receptor sem ponteiro

Eu não compro essa analogia. IMO, é totalmente razoável considerar

func Foo(p *int) { *p = 42 }

func main() {
    var v int
    Foo(&v)
    if v != 42 { panic("") }
}

para ser o código correto. Eu não acho que seja razoável considerar

func Foo(v int) { v = 42 }

func main( ){
    var v int
    Foo(v)
    if v != 42 { panic("") }
}

estar correto. Talvez se você for um iniciante absoluto em Go e estiver vindo de uma linguagem onde cada valor é uma referência (embora eu esteja honestamente pressionado para encontrar um - mesmo Python e Java só fazem referências à maioria dos valores). Mas, IMO, otimizar para esse caso é fútil, é justo supor que as pessoas tenham alguma familiaridade com indicadores vs. valores. Acho que mesmo um desenvolvedor Go experiente consideraria, digamos, um método com receptor de ponteiro acessando seus campos como sendo correto, e o código de chamada desses métodos sendo correto. Na verdade, esse é todo o argumento para evitar nil -pointers estaticamente, que é muito fácil ter um ponteiro involuntariamente nulo e que o código de aparência correta falhe no tempo de execução.

A correção para ambos os casos é a mesma: verifique se há nulo antes de fazer qualquer coisa.

IMO, a correção na semântica atual é não verificar se há nulo e considerar um bug se alguém passar nulo. Tipo, em seu exemplo você escreve

// The correct code would be:
if p == nil {
    // panic or return error
}
*p = 42

Mas não considero esse código correto. A verificação nil não faz nada, porque desreferenciar nil entra

Mas, para mim, A) é muito mais prejudicial (todo o aplicativo trava!).

Tudo bem, mas lembre-se de que muitas pessoas discordarão totalmente disso. Eu, pessoalmente, considero uma falha sempre preferível a continuar com dados corrompidos e suposições erradas. Em um mundo ideal, meu software não tem bugs e nunca trava. Em um mundo menos ideal, meus programas terão bugs e falharão com segurança travando quando forem detectados. No pior mundo, meus programas terão bugs e continuarão causando estragos quando forem encontrados.

Essa espada corta os dois lados, no entanto. Você disse que "o principal motivo para segurar isso foi". Essa declaração implica que todos estamos de acordo sobre se queremos o efeito desta proposta. Posso certamente concordar que é um detalhe técnico que impede as sugestões específicas feitas (ou, pelo menos, que qualquer sugestão deveria dizer algo sobre essa questão). Mas não gosto que a discussão seja reenquadrada silenciosamente em um mundo paralelo, onde presumimos que todos realmente a desejam.

Bem, eu não queria sugerir isso. Se isso foi entendido, então posso não ter escolhido as palavras certas e peço desculpas. Eu só queria fornecer algumas idéias para uma possível solução, é isso.

Eu escrevi _ "... parece que a razão principal que está atrasando isso é ..." _ com base em sua frase _ "Este é o ponto crucial da questão" _ referindo-se ao valor zero. É por isso que presumi que o valor zero era a principal coisa que impedia isso. Então foi minha suposição errada.

Com relação a tratar nil silenciosamente versus verificá-los em tempo de compilação: Eu concordo que é melhor verificá-los em tempo de compilação. O "valor zero dinâmico" foi apenas uma iteração da sugestão original quando me concentrei em abordar o problema de todos os tipos devem ter valor zero. Uma motivação extra foi que eu _ pensava_ que era também o principal entrave à proposta sindical discriminada.
Se nos concentrarmos apenas no problema relacionado ao nil, prefiro que os tipos de ponteiro não nulo sejam verificados em tempo de compilação.

Eu diria que em algum momento, nós (com "nós" estou me referindo a toda a comunidade Go) precisaremos aceitar _algo tipo_ de mudança. Por exemplo: se houver uma boa solução para evitar nil erros inteiramente e o que impede isso é a decisão de design "todos os tipos têm valor zero e são feitos de 0", então poderíamos considerar a ideia de fazer alguns ajustes ou mudanças nessa decisão se ela agregar valor.

O principal motivo pelo qual estou dizendo isso é sua frase _ "todo tipo tem valor zero, ponto final " _. Normalmente não gosto de "escrever pontos finais". Não me interpretem mal! Aceito plenamente que você pense assim, é apenas a minha maneira de pensar: prefiro não dogmas, pois podem esconder caminhos que podem levar a melhores soluções.

Finalmente, com relação a isso:

Tudo bem, mas lembre-se de que muitas pessoas discordarão totalmente disso. Eu, pessoalmente, considero uma falha sempre preferível a continuar com dados corrompidos e suposições erradas. Em um mundo ideal, meu software não tem bugs e nunca trava. Em um mundo menos ideal, meus programas terão bugs e falharão com segurança travando quando forem detectados. No pior mundo, meus programas terão bugs e continuarão causando estragos quando forem encontrados.

Concordo completamente com isto. Falhar em voz alta é sempre melhor do que falhar silenciosamente. No entanto, há um problema em Go:

  • Se você tem um aplicativo com milhares de goroutines, um pânico não controlado em um deles faz com que todo o programa trave. Isso é diferente do que em outras línguas, onde apenas o tópico que causa pânico trava

Deixando isso de lado (embora seja bastante perigoso), a ideia é, então, evitar toda uma categoria de falhas (falhas relacionadas a nil ).

Portanto, vamos continuar iterando sobre isso e tentar encontrar uma solução.

Obrigado pelo seu tempo e energia!

Eu gostaria de ver a sintaxe de uniões discriminadas da ferrugem em vez dos tipos de soma do haskell, permite nomear variantes e permitir uma melhor proposta de sintaxe de correspondência de padrões.
A implementação pode ser feita como struct com campo de tag (tipo uint, depende da contagem de variantes) e campo de união (contendo os dados).
Este recurso necessário para um conjunto fechado de variantes (a representação do estado seria muito mais fácil e limpa, com verificação em tempo de compilação). De acordo com as questões sobre interfaces e sua representação, acho que sua implementação no tipo soma não deve terminar mais do que apenas mais um caso do tipo soma, pois a interface é sobre qualquer tipo que se enquadre em alguns requisitos, mas o caso de uso do tipo soma é diferente.

Sintaxe:

type Type enum {
         Tuple (int,int),
         One int,
         None,
};

No exemplo acima, o tamanho seria sizeof ((int, int)).
A correspondência de padrões pode ser feita com o novo operador de correspondência criado ou dentro do operador de switch existente, assim como:

var a Type
switch (a) {
         case Tuple{(b,c)}:
                    //do something
         case One{b}:
                    //do something else
         case None:
                    //...
}

Sintaxe de criação:
var a Type = Type{One=12}
Observe que, na construção da instância enum, apenas uma variante pode ser especificada.

Valor zero (problema):
Podemos classificar os nomes em ordem alfabética, o valor zero de enum será o valor zero do tipo do primeiro membro na lista de membros classificados.

A solução PS do problema de valor zero é geralmente definida por acordo.

Acho que manter o valor zero da soma como o valor zero do primeiro campo de soma definido pelo usuário seria menos confuso, talvez

Acho que manter o valor zero da soma como o valor zero do primeiro campo de soma definido pelo usuário seria menos confuso, talvez

Mas fazer com que o valor zero dependa da ordem de declaração do campo, acho que é pior.

Alguém escreveu um documento de design?

Eu tenho um:
19412-discriminated_unions_and_pattern_matching.md.zip

Eu mudei isso:

Acho que manter o valor zero da soma como o valor zero do primeiro campo de soma definido pelo usuário seria menos confuso, talvez

Agora, em minha proposta de acordo sobre Valor Zero (Problema), mudei para a posição urandoms.

UPD: Documento de design alterado, pequenas correções.

Tenho dois casos de uso recentes, em que precisei de tipos de soma integrados:

  1. Representação da árvore AST, conforme esperado. Inicialmente encontrei uma biblioteca que foi uma solução à primeira vista, mas a abordagem deles era ter uma grande estrutura com muitos campos nilable. Pior dos dois mundos IMO. Sem segurança de tipo, é claro. Em vez disso, escrevemos o nosso próprio.
  2. Tínhamos uma fila de tarefas predefinidas em segundo plano: temos um serviço de pesquisa que está em desenvolvimento agora e nossas operações de pesquisa podem ser muito longas etc. Portanto, decidimos executá-las em segundo plano enviando tarefas de operação de índice de pesquisa para um canal. Então, um despachante decidirá o que fazer com eles. Poderia usar o padrão de visitante, mas obviamente é um exagero para uma solicitação gRPC simples. E não é particularmente claro para dizer pelo menos, pois introduz um vínculo entre um despachante e um visitante.

Em ambos os casos implementado algo assim (no exemplo da 2ª tarefa):

type Task interface {
    task()
}

type SearchAdd struct {
    Ctx   context.Context
    ID    string
    Attrs Attributes
}

func (SearchAdd) task() {}

type SearchUpdate struct {
    Ctx         context.Context
    ID          string
    UpdateAttrs UpdateAttributes
}

func (SearchUpdate) task() {}

type SearchDelete struct {
    Ctx context.Context
    ID  string
}

func (SearchDelete) task() {}

E então

task := <- taskChannel

switch v := task.(type) {
case tasks.SearchAdd:
    resp, err := search.Add(task.Ctx, &search2.RequestAdd{…}
    if err != nil {
        log.Error().Err(err).Msg("blah-blah-blah")
    } else {
        if resp.GetCode() != search2.StatusCodeSuccess  {
            …
        } 
    }
case tasks.SearchUpdate:
    …
case tasks.SearchDelete:
    …
}

Isso é quase bom. O ruim, Go não fornece segurança de tipo total, ou seja, não haverá nenhum erro de compilação depois que a nova tarefa de operação de índice de pesquisa for adicionada.

IMHO usando tipos de soma é a solução mais clara para este tipo de tarefas normalmente resolvidas com visitante e conjunto de despachantes, onde as funções do visitante não são numerosas e pequenas e o próprio visitante é um tipo fixo.

Eu realmente acredito em ter algo como

type Task oneof {
    // SearchAdd holds a data for a new record in the search index
    SearchAdd {
        Ctx   context.Context
        ID    string
        Attrs Attributes   
    }

    // SearchUpdate update a record
    SearchUpdate struct {
        Ctx         context.Context
        ID          string
        UpdateAttrs UpdateAttributes
    }

    // SearchDelete delete a record
    SearchDelete struct {
        Ctx context.Context
        ID  string
    }
}

+

switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

seria muito mais goish em espírito do que qualquer outra abordagem que Go permite em seu estado atual. Não há necessidade de correspondência de padrões Haskellish, basta mergulhar até certo tipo.

Ai, perdi o ponto da proposta de sintaxe. Consertá-lo.

Duas versões, uma para o tipo de soma genérico e o tipo de soma para enumerações:

Tipos de soma genérica

type Sum oneof {
    T₁ TypeDecl₁
    T₂ TypeDecl₂
    …
    Tₙ TypeDeclₙ
}

onde T₁Tₙ são definições de tipo no mesmo nível com Sum ( oneof expõe fora de seu escopo) e Sum declara alguma interface que apenas T₁Tₙ satisfaça.

O processamento é semelhante ao que temos (type) switch, exceto que é feito implicitamente sobre oneof objetos e deve haver uma verificação do compilador se todas as variantes foram listadas.

Enumerações seguras de tipo real

type Enum oneof {
    Value = iota
}

bastante semelhante a iota de consts, exceto que apenas os valores explicitamente listados são Enums e todo o resto não é.

switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

seria muito mais goish em espírito do que qualquer outra abordagem que Go permite em seu estado atual. Não há necessidade de correspondência de padrões Haskellish, basta mergulhar até certo tipo.

Não acho que manipular o significado da variável task seja uma boa ideia, embora seja aceitável.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

seria muito mais goish em espírito do que qualquer outra abordagem que Go permite em seu estado atual. Não há necessidade de correspondência de padrões Haskellish, basta mergulhar até certo tipo.

Não acho que manipular o significado da variável de tarefa seja uma boa ideia, embora seja aceitável.
`` `

Boa sorte com seus visitantes então.

@sirkon O que você quer dizer com visitantes? Gostei dessa sintaxe btw, no entanto, a opção deve ser escrita assim:

switch task {
case Task.SearchAdd:
    // task is Task.SearchAdd in this scope
case Task.SearchUpdate:
case Task.SearchDelete:
}

Além disso, qual seria o valor sem valor para Task ? Por exemplo:

var task Task

Seria nil ? Em caso afirmativo, o switch ter um case nil extra?
Ou seria inicializado com o primeiro tipo? Isso seria estranho, porque então a ordem da declaração de tipo é importante de uma maneira que não importava antes, no entanto, provavelmente seria OK para enums numéricos.

Estou assumindo que isso é equivalente a switch task.(type) mas a troca exigiria que todos os casos estivessem lá, certo? como em .. se você perder um caso, erro de compilação. E nenhum default permitido. Isso está certo?

O que você quer dizer com visitantes?

Eu quis dizer que eles são a única opção de tipo seguro no Go para esse tipo de funcionalidade. Muito pior para um determinado conjunto de casos (número limitado de alternativas predefinidas).

Além disso, qual seria o valor sem valor para Tarefa? Por exemplo:

var task Task

Temo que deva ser um tipo nilable em Go como este

Ou seria inicializado com o primeiro tipo?

seria muito estranho, especialmente para um propósito pretendido.

Estou assumindo que isso é equivalente à tarefa de troca. (Tipo), mas a troca exigiria que todos os casos estivessem lá, certo? como em .. se você perder um caso, erro de compilação.

Sim certo.

E nenhum padrão permitido. Isso está certo?

Não, os padrões são permitidos. Porém, desanimado.

PS: Parece que tenho uma ideia que Go @ianlancetaylor e outras pessoas de Go têm sobre os tipos de soma. Parece que o zero os torna bastante propensos a NPD, já que Go não tem nenhum controle sobre os valores nulos.

Se for nulo, então acho que está bom. Eu preferiria que case nil fosse um requisito para a instrução switch. Fazer if task != nil antes também está ok, só não gosto muito: |

Isso também seria permitido?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

Isso também seria permitido?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

Bem, sem consts então, apenas

type Foo oneof {
    A <type reference>
}

ou

type Foo oneof {
    A = iota
    B
    C
}

ou

type Foo oneof {
    A = 1
    B = 2
    C = 3
}

Nenhuma combinação de iotas e valores. Ou combinação com controle de valores, eles não devem ser repetidos.

FWIW, uma coisa que achei interessante sobre o design de genéricos mais recente é que ele mostrou outra forma de abordar pelo menos alguns dos casos de uso de tipos de soma, evitando a armadilha dos valores zero. Ele define contratos disjuntivos, que de certa forma são somados, mas como descrevem restrições e não tipos, não precisam ter valor zero (já que não é possível declarar variáveis ​​desse tipo). Ou seja, é pelo menos possível escrever uma função que tenha um conjunto limitado de tipos possíveis, com verificação de tipo em tempo de compilação desse conjunto.

Agora, é claro, o design como está realmente não funciona para os casos de uso pretendidos aqui: Disjunções listam apenas tipos ou métodos subjacentes e, portanto, ainda estão amplamente abertos. E, claro, mesmo como uma ideia geral, é bastante limitado, pois você não pode instanciar uma função ou valor genérico (ou soma-ish). Mas, IMO, isso mostra que o espaço de design para lidar com alguns dos casos de uso de somas é muito maior do que a ideia dos próprios tipos de soma. E que pensar em somas é, portanto, mais fixar-se em uma solução específica, em vez de em problemas específicos.

Qualquer forma. Achei interessante.

@Merovius faz uma observação excelente sobre o design genérico mais recente ser capaz de lidar com alguns dos casos de uso de tipos de soma. Por exemplo, esta função que foi usada anteriormente no tópico:

func addOne(x int|float64) int|float64 {
    switch x := x.(type) {
    case int:
        return x + 1
    case float64:
         return x + 1
    }
}

se tornaria:

contract intOrFloat64(T) {
    T int, float64
}

func addOne(type T intOrFloat64) (x T) T {
    return x + 1
}

No que diz respeito aos próprios tipos de soma, se os genéricos eventualmente chegarem, eu ficaria ainda mais duvidoso do que agora sobre se os benefícios de introduzi-los superariam os custos de uma linguagem simples como Go.

No entanto, se algo fosse feito, então a solução mais simples e menos perturbadora da IMO seria a ideia de @ianlancetaylor de 'interfaces restritas' que seriam implementadas exatamente da mesma forma que as interfaces 'irrestritas' são hoje, mas só poderiam ser satisfeitas pelos tipos especificados. Na verdade, se você pegou uma folha do livro de design genérico e fez da restrição de tipo a primeira linha do bloco de interface:

type intOrFloat64 interface{ type int, float64 }    

então isso seria completamente compatível com as versões anteriores, pois você não precisaria de uma nova palavra-chave (como restrict ). Você ainda pode adicionar métodos à interface e seria um erro de tempo de compilação se os métodos não fossem suportados por todos os tipos especificados.

Não vejo nenhum problema em atribuir valores a uma variável do tipo de interface restrito. Se o tipo do valor no RHS (ou o tipo padrão de um literal sem tipo) não fosse uma correspondência exata para um dos tipos especificados, ele simplesmente não compilaria. Portanto, teríamos:

var v1 intOrFloat64 = 1        // compiles, dynamic type int
var v2 intOrFloat64 = 1.0      // compiles, dynamic type float64
var v3 intOrFloat64 = 1 + 2i   // doesn't compile, complex128 is not a specified type

Seria um erro em tempo de compilação para os casos de uma chave de tipo não corresponder a um tipo especificado e uma verificação de exaustividade poderia ser implementada. No entanto, uma asserção de tipo ainda seria necessária para converter o valor da interface restrita em um valor de seu tipo dinâmico como é hoje.

Valores zero não são um problema com essa abordagem (ou pelo menos não são mais problemáticos do que são hoje com as interfaces em geral). O valor zero de uma interface restrita seria nil (implicando que ela não contém nada no momento) e os tipos especificados teriam seus próprios valores zero, internamente, que seriam nil para tipos anuláveis.

Porém, tudo isso parece perfeitamente viável para mim, como eu disse antes, é que a segurança de tempo de compilação ganha realmente vale a complexidade adicional - tenho minhas dúvidas, pois nunca realmente senti a necessidade de tipos de soma em minha própria programação.

IIUC a coisa dos genéricos não será de tipos dinâmicos, então toda essa questão não vale. No entanto, se as interfaces pudessem funcionar como contratos (o que eu duvido), isso não resolveria verificações e enumerações exaustivas, que é o que (acho, talvez não?) O sumtypes trata.

@alanfo , @Merovius Obrigado pela deixa; é interessante que esta discussão esteja se voltando nesta direção:

Eu gosto de mudar o ponto de vista por apenas uma fração de segundo: estou tentando entender por que os contratos não podem ser substituídos inteiramente por interfaces parametrizadas que permitem a restrição de tipo mencionada acima. No momento, não vejo nenhuma razão técnica forte, exceto que tais tipos de interface de "soma", quando usados ​​como tipos de "soma", iriam restringir os valores dinâmicos possíveis exatamente aos tipos enumerados na interface, enquanto - se o mesma interface foi usada na posição do contrato - os tipos enumerados na interface precisariam servir como tipos subjacentes para ser uma restrição genérica razoavelmente útil.

@Bom vinho
Eu não estava sugerindo que o design dos genéricos abordaria tudo o que alguém poderia querer fazer com os tipos de soma - como @Merovius claramente explicou em seu último post, eles não o farão. Em particular, as restrições de tipo propostas para genéricos cobrem apenas os tipos embutidos e quaisquer tipos derivados deles. Do ponto de vista do tipo soma, o primeiro é muito estreito e o último muito largo.

No entanto, o design genérico permitiria escrever uma função que opera em um conjunto limitado de tipos que o compilador aplicaria e isso é algo que não podemos fazer no momento.

No que diz respeito às interfaces restritas, o compilador saberia os tipos precisos que poderiam ser usados ​​e, portanto, seria viável fazer uma verificação exaustiva em uma instrução de switch de tipo.

@Griesemer

Estou intrigado com o que você disse, pois pensei que o rascunho do documento de design de genéricos explicava de forma bastante clara (na seção "Por que não usar interfaces em vez de contratos") por que os últimos foram considerados um veículo melhor do que o anterior para expressar restrições genéricas.

Em particular, um contrato pode expressar uma relação entre parâmetros de tipo e, portanto, apenas um único contrato é necessário. Qualquer um de seus parâmetros de tipo pode ser usado como o tipo de receptor de um método listado no contrato.

O mesmo não se pode dizer de uma interface, parametrizada ou não. Se eles tivessem alguma restrição, cada parâmetro de tipo precisaria de uma interface separada.

Isso torna mais difícil expressar uma relação entre parâmetros de tipo usando interfaces, embora não seja impossível, como o exemplo de gráfico mostrou.

No entanto, se você está pensando que poderíamos "matar dois coelhos com uma cajadada" adicionando restrições de tipo às interfaces e, em seguida, usando-as para fins de tipo genérico e de soma, então (além do problema que você mencionou), acho que você provavelmente certo que isso seria tecnicamente viável.

Eu acho que não faria diferença se as restrições de tipo de interface pudessem incluir tipos 'não integrados' no que diz respeito aos genéricos, embora fosse necessário encontrar alguma forma para restringi-los aos tipos exatos (e não aos tipos derivados também) então eles seriam adequados para tipos de soma. Talvez pudéssemos usar const type para o último (ou mesmo apenas const ) se quisermos ficar com as palavras-chave atuais.

@griesemer Existem alguns motivos pelos quais os tipos de interface parametrizada não são uma substituição direta dos contratos.

  1. Os parâmetros de tipo são iguais aos de outros tipos parametrizados.
    Em um tipo como

    type C2(type T C1) interface { ... }
    

    o parâmetro de tipo T existe fora da própria interface. Qualquer argumento de tipo passado como T já deve ser conhecido para satisfazer o contrato C1 , e o corpo da interface não pode restringir mais T . Isso é diferente dos parâmetros do contrato, que são restringidos pelo corpo do contrato como resultado de serem passados ​​para ele. Isso significaria que cada parâmetro de tipo para uma função teria que ser restringido de forma independente antes de ser passado como um parâmetro para a restrição em qualquer outro parâmetro de tipo.

  2. Não há como nomear o tipo de receptor no corpo da interface.
    As interfaces deveriam permitir que você escrevesse algo como:

    type C3(type U C1) interface(T) {
        Add(T) T
    }
    

    onde T denota o tipo de receptor.

  3. Alguns tipos de interface não se satisfariam como restrições genéricas.
    Quaisquer operações que dependem de vários valores do tipo receptor não são compatíveis com o despacho dinâmico. Essas operações, portanto, não seriam utilizáveis ​​em valores de interface. Isso significaria que a interface não se satisfaria (por exemplo, como o argumento de tipo para um parâmetro de tipo restringido pela mesma interface). Isso seria surpreendente. Uma solução é simplesmente não permitir a criação de valores de interface para tais interfaces, mas isso impediria o caso de uso que está sendo visualizado aqui de qualquer maneira.

Quanto à distinção entre restrições de tipo subjacentes e restrições de identidade de tipo, existe um método que pode funcionar. Imagine que pudéssemos definir restrições personalizadas, como

contract (T) indenticalTo(U) {
    *T *U
}

(Aqui, estou usando uma notação inventada para especificar um único tipo como o "receptor". Eu pronunciarei um contrato com um tipo de receptor explícito como "restrição", assim como uma função com um receptor é pronunciada "método". Os parâmetros após o nome do contrato são parâmetros de tipo normal e não podem aparecer no lado esquerdo de uma cláusula de restrição no corpo da restrição.)

Como o tipo subjacente de um tipo de ponteiro literal é ele mesmo, essa restrição implica que T é idêntico a U . Como isso é declarado como uma restrição, você poderia escrever (identicalTo(int)), (identicalTo(uint)), ... como uma disjunção de restrição.

Embora os contratos possam ser úteis para expressar algum tipo de tipo de soma, não acho que você possa expressar tipos de soma genéricos com eles. Pelo que vi no rascunho, é preciso listar os tipos concretos, então você não pode escrever algo assim:

contract Foo(T, U) {
    T U, int64
}

Qual deles precisaria expressar um tipo de soma genérico de um tipo desconhecido e um / mais tipos conhecidos. Mesmo que o design permitisse tais construções, elas pareceriam estranhas quando usadas, uma vez que ambos os parâmetros seriam efetivamente a mesma coisa.

Estive pensando um pouco mais sobre como o esboço do design dos genéricos poderia mudar se as interfaces fossem estendidas para incluir restrições de tipo e, em seguida, usadas para substituir os contratos no design.

Talvez seja mais fácil analisar a situação se considerarmos diferentes números de parâmetros de tipo:

Sem parâmetros

Sem alterações :)

Um parâmetro

Sem problemas reais aqui. Uma interface parametrizada (em oposição a uma não genérica) só seria necessária se o parâmetro de tipo referido a si mesmo e / ou algum outro tipo fixo independente fosse necessário para instanciar a interface.

Dois ou mais parâmetros

Conforme mencionado anteriormente, cada parâmetro de tipo precisaria ser restringido individualmente se fosse necessário uma restrição.

Uma interface parametrizada só seria necessária se:

  1. O parâmetro de tipo referia-se a si mesmo.

  2. A interface se referia a outro parâmetro de tipo ou parâmetros que _já foram declarados_ na seção de parâmetro de tipo (presumivelmente, não queremos retroceder aqui).

  3. Alguns outros tipos fixos independentes foram necessários para instanciar a interface.

Destes, (2) é realmente o único caso problemático, pois excluiria os parâmetros de tipo que se referem uns aos outros, como no exemplo do gráfico. Independentemente de alguém declarar 'Nó' ou 'Borda' primeiro, sua interface de restrição ainda precisa que a outra seja passada como um parâmetro de tipo.

No entanto, conforme indicado no documento de design, você pode contornar isso declarando NodeInterface e EdgeInterface não parametrizados (já que eles não se referem a si mesmos) no nível superior, pois não haveria nenhum problema em se referirem um ao outro seja qual for o pedido de declaração. Você pode então usar essas interfaces para restringir os parâmetros de tipo da estrutura do Graph e os de seu método 'Novo' associado.

Portanto, não parece que haja problemas insuperáveis ​​aqui, mesmo que a ideia de contratos seja mais agradável.

Presumivelmente, comparable agora poderia se tornar apenas uma interface integrada em vez de um contrato.

As interfaces podem, é claro, ser incorporadas umas às outras como já podem.

Não tenho certeza de como alguém lidaria com o problema do método de ponteiro (nos casos em que eles precisariam ser especificados no contrato), pois você não pode especificar um receptor para um método de interface. Talvez alguma sintaxe especial (como precedendo o nome do método com um asterisco) seja necessária para indicar um método de ponteiro.

Voltando agora para as observações de @stevenblenkinsop , eu me pergunto se tornaria a vida mais fácil se as interfaces parametrizadas não permitissem que seus próprios parâmetros de tipo fossem restringidos de alguma forma? Não tenho certeza se esse é um recurso realmente útil, a menos que alguém possa pensar em um caso de uso sensato.

Pessoalmente, não considero surpreendente que alguns tipos de interface não consigam se satisfazer como restrições genéricas. Um tipo de interface não é um tipo de receptor válido em qualquer caso e, portanto, não pode ter métodos.

Embora a ideia de Steven de uma função integrada IdentityTo () funcione, parece-me potencialmente prolixo para especificar tipos de soma. Eu prefiro uma sintaxe que permite especificar uma linha inteira de tipos como sendo exatos.

@urandom está correto, é claro, que como o rascunho dos genéricos está atualmente, só se pode listar tipos concretos (embutidos ou agregados embutidos). No entanto, isso teria que mudar claramente se interfaces restritas fossem usadas para os tipos genéricos e de soma. Portanto, eu não descartaria que algo assim fosse permitido em um ambiente unificado:

interface Foo(T) {
    const type T, int64  // 'const' indicates types are exact i.e. no derived types
}

por que não podemos simplesmente adicionar Uniões Discriminadas à linguagem, em vez de inventar outra volta à sua ausência?

@griesemer Você pode ou não estar ciente, mas desde o início sou a

@urandom

Eu não acho que você pode expressar tipos de soma genéricos com eles

Quero reiterar que meu ponto não era "você pode construir tipos de soma com eles", mas "você pode resolver alguns problemas que os tipos de soma resolvem com eles". Se sua declaração de problema for "Eu quero tipos de soma", então não é surpreendente que os tipos de soma sejam a única solução. Queria apenas expressar que talvez seja possível passar sem eles, se nos concentrarmos nos problemas que você deseja resolver com eles.

@alanfo

Isso torna mais difícil expressar uma relação entre parâmetros de tipo usando interfaces, embora não seja impossível, como o exemplo de gráfico mostrou.

Acho que "estranho" é subjetivo. Pessoalmente, acho o uso de interfaces parametrizadas mais natural e o exemplo de gráfico uma ilustração muito boa. Para mim, um Graph é uma entidade, não uma relação entre um tipo de Edge e um tipo de Node.

Mas, TBH, não acho que nenhum deles seja realmente mais ou menos estranho - você escreve exatamente o mesmo código para expressar exatamente as mesmas coisas. E FWIW, há arte anterior para isso. Classes de tipo Haskell se comportam muito como interfaces e como aquele artigo wiki aponta, usar classes de tipo multiparâmetros para expressar relacionamentos entre tipos é uma coisa bastante normal de se fazer.

@stevenblenkinsop

Não há como nomear o tipo de receptor no corpo da interface.

A maneira como você trataria disso é com argumentos de tipo no site de uso. ie

type Adder(type T) interface {
    Add(t T) T
}

func Sum(type T Adder(T)) (vs []T) T {
    var t T
    for _, v := range vs {
        t = t.Add(v)
    }
    return t
}

Isso requer alguns cuidados em como a unificação funciona, de modo que você possa permitir parâmetros de tipo de autorreferência, mas acho que pode ser feito para funcionar.

Seu 1. e 3. Eu realmente não entendo, tenho que admitir. Eu me beneficiaria com alguns exemplos concretos.


De qualquer forma, é um pouco hipócrita abandonar isso no final da continuação desta discussão, mas essa provavelmente não é a questão certa para falar sobre as minúcias do design dos genéricos. Eu apenas trouxe isso para alargar um pouco o espaço de design para este problema :) Porque parece que já faz um tempo desde que novas ideias foram trazidas para a discussão em torno dos tipos de soma.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

seria muito mais goish em espírito do que qualquer outra abordagem que Go permite em seu estado atual. Não há necessidade de correspondência de padrões Haskellish, basta mergulhar até certo tipo.
Não acho que manipular o significado da variável de tarefa seja uma boa ideia, embora seja aceitável.

Boa sorte com seus visitantes então.

Por que você acha que a correspondência de padrões não pode ser feita no Go? Se você não tiver exemplos de correspondência de padrões, consulte, por exemplo, Rust.

@Merovius re: "Para mim, um gráfico é uma entidade"

É uma entidade de tempo de compilação ou tem uma representação em tempo de execução? Uma das principais diferenças entre contratos e interfaces é que uma interface é um objeto de tempo de execução. Ele participa da coleta de lixo, possui ponteiros para outros objetos de tempo de execução e assim por diante. A conversão de um contrato em uma interface significaria a introdução de um novo objeto de tempo de execução temporário que tem ponteiros para os nós / vértices que contém (quantos?), O que parece estranho quando você tem uma coleção de funções de gráfico, cada uma das quais pode ser mais naturalmente pegue parâmetros apontando para várias partes dos gráficos de suas próprias maneiras, dependendo das necessidades da função.

Sua intuição pode ser enganada usando "Gráfico" para um contrato, uma vez que "Gráfico" parece um objeto e o contrato não especifica nenhum subgrafo em particular; é mais como definir um conjunto de termos para usar mais tarde, como você faria em matemática ou direito. Em alguns casos, você pode querer um contrato gráfico e uma interface gráfica, resultando em um conflito de nomes irritante. Não consigo pensar em um nome melhor de cabeça, no entanto.

Em contraste, uma união discriminada é um objeto de tempo de execução. Embora não restrinja a implementação, você precisa pensar em como pode ser uma série deles. Uma matriz de N itens precisa de N discriminadores e valores de N, e há uma variedade de maneiras que podem ser feitas. (Julia tem representações interessantes, às vezes colocando os discriminadores e valores em matrizes separadas.)

Para sugerir uma redução de erros que estão ocorrendo atualmente em todo o lugar com os esquemas interface{} , mas para remover a digitação contínua do operador | , eu sugeriria o seguinte:

type foobar union {
    int
    float64
}

Apenas o caso de uso de substituir muitos interface{} por esse tipo de segurança de tipo seria um grande ganho para a biblioteca. Basta olhar para metade das coisas na biblioteca de criptografia para usar isso.

Problemas como: ah você forneceu ecdsa.PrivateKey vez de *ecdsa.PrivateKey - aqui está um erro genérico que apenas ecdsa.PrivateKey é compatível. O simples fato de que esses tipos de união devem ser claros aumentaria um pouco a segurança de tipo.

Embora esta sugestão ocupe mais _espaço_ em comparação com int|float64 ela força o usuário a pensar sobre isso. Manter a base de código muito mais limpa.

Para sugerir uma redução de erros que estão ocorrendo atualmente em todo o lugar com os esquemas interface{} , mas para remover a digitação contínua do operador | , eu sugeriria o seguinte:

type foobar union {
    int
    float64
}

Apenas o caso de uso de substituir muitos interface{} por esse tipo de segurança de tipo seria um grande ganho para a biblioteca. Basta olhar para metade das coisas na biblioteca de criptografia para usar isso.

Problemas como: ah você forneceu ecdsa.PrivateKey vez de *ecdsa.PrivateKey - aqui está um erro genérico que apenas ecdsa.PrivateKey é compatível. O simples fato de que esses tipos de união devem ser claros aumentaria um pouco a segurança de tipo.

Embora esta sugestão ocupe mais _espaço_ em comparação com int|float64 ela força o usuário a pensar sobre isso. Manter a base de código muito mais limpa.

Veja este (comentário) , é a minha proposta.

Na verdade, podemos introduzir ambas as nossas ideias na linguagem. Isso levará à existência de duas maneiras nativas de fazer ADT, mas com sintaxes diferentes.

Minha proposta para recursos, especialmente correspondência de padrões, para compatibilidade e capacidade de se beneficiar do recurso para bases de código antigas.

Mas parece um exagero, não é?

Além disso, o tipo de soma pode ser definido nil como valor padrão. Claro, isso exigirá nil case em cada troca.
A correspondência de padrões pode ser feita como:
- declaração

type U enum{
    A(int64),
    B(string),
}

-- Coincidindo

...
var a U
...
switch a {
    case A{b}:
         //process b here
    case B{b}:
         //...
    case nil:
         //...
}
...

Se alguém não gosta de correspondência de padrões - veja a proposta de sirkon acima.

Além disso, o tipo de soma pode ser definido nil como valor padrão. Claro, isso exigirá nil case em cada troca.

Não seria mais fácil proibir o valor não iniciado em tempo de compilação? Para os casos em que precisamos de um valor inicializado, podemos adicioná-lo ao tipo de soma: ou seja,

type U enum {
  None
  A(string)
  B(uint64)
}
...
var a U.None
...
switch a {
  case U.None: ...
  case U.A(str): ...
  case U.B(i): ...
}

Além disso, o tipo de soma pode ser definido nil como valor padrão. Claro, isso exigirá nil case em cada troca.

Não seria mais fácil proibir o valor não iniciado em tempo de compilação? Para os casos em que precisamos de um valor inicializado, podemos adicioná-lo ao tipo de soma: ou seja,

Quebra o código existente.

Além disso, o tipo de soma pode ser definido nil como valor padrão. Claro, isso exigirá nil case em cada troca.

Não seria mais fácil proibir o valor não iniciado em tempo de compilação? Para os casos em que precisamos de um valor inicializado, podemos adicioná-lo ao tipo de soma: ou seja,

Quebra o código existente.

Não existe nenhum código existente com tipos de soma. Embora eu ache que o valor padrão deve ser algo definido no próprio tipo. Ou a primeira entrada, ou a primeira ordem alfabética, ou algo assim.

Não existe nenhum código existente com tipos de soma. Embora eu ache que o valor padrão deve ser algo definido no próprio tipo. Ou a primeira entrada, ou a primeira ordem alfabética, ou algo assim.

Eu concordei com você no primeiro pensamento, mas após alguma reflexão, o novo nome reservado para a união poderia ter sido usado anteriormente em alguma base de código (união, enum, etc.)

Acho que a obrigação de verificar se há zero seria muito dolorosa de usar.

Parece uma mudança significativa para compatibilidade com versões anteriores que só poderia ser resolvida pelo Go2.0

Não existe nenhum código existente com tipos de soma. Embora eu ache que o valor padrão deve ser algo definido no próprio tipo. Ou a primeira entrada, ou a primeira ordem alfabética, ou algo assim.

Mas há muitos códigos go existentes que têm tudo que pode ser nulo. Isso definitivamente vai quebrar a mudança. Pior, gofix e ferramentas semelhantes só podem alterar os tipos de variáveis ​​para Opções (do mesmo tipo) produzindo pelo menos um código feio, em todos os outros casos, ele simplesmente quebrará tudo no mundo.

Se nada mais, reflita.Zero precisa retornar algo . Mas todos esses são obstáculos técnicos que podem ser resolvidos - por exemplo, esse obstáculo é bastante óbvio se o valor zero de um tipo de soma for bem definido e provavelmente será "pânico", se não. A grande questão ainda é por que determinada escolha é a correta e se e como qualquer escolha se encaixa no idioma em geral. IMO, a melhor maneira de abordar isso ainda é falar sobre casos concretos em que os tipos de soma atendem a problemas específicos ou a sua falta criou problemas. Os três critérios para um relato de experiência se aplicam a isso.

Observe, em particular, que "não deve haver valor zero e não deve ser permitido criar valores não inicializados" e "o padrão deve ser a primeira entrada" foram mencionados acima, várias vezes. Portanto, se você pensa que deveria ser assim ou aquilo, não acrescenta nenhuma informação nova. Mas torna um fio já gigantesco ainda mais longo e difícil para o futuro encontrar as informações relevantes nele.

Vamos considerar reflect.Kind. Existe um tipo inválido, que tem o valor int padrão de 0. Se você tivesse uma função que aceitasse um reflect.Kind e passasse uma variável não inicializada desse tipo, ela acabaria sendo inválida. Se, reflect.Kind puder ser hipoteticamente alterado para um tipo de soma, talvez deva manter o comportamento de ter uma entrada inválida nomeada como sendo a padrão, em vez de depender de um valor nulo.

Agora, vamos considerar html / template.contentType. O tipo Plain é seu valor padrão e, de fato, é tratado como tal pela função stringify, pois é o fallback. Em um futuro de soma hipotética, não apenas você ainda precisaria desse comportamento, mas também seria inviável usar um valor nulo para ele, já que nil não significará nada para um usuário desse tipo. Será praticamente obrigatório sempre retornar um valor nomeado aqui, e você tem um padrão claro de qual deve ser esse valor.

Sou eu novamente com outro exemplo onde algébrico / variadic / soma / quaisquer tipos de dados funcionam bem.

Portanto, estamos usando o banco de dados noSQL sem transações (sistema distribuído, transações não funcionam para nós), mas amamos a integridade e consistência dos dados por motivos óbvios e temos que contornar os problemas de acesso simultâneo, geralmente com consultas de atualização condicional um pouco complexas em um único registro (a gravação de um único registro é atômica).

Estou tendo uma nova tarefa para escrever um conjunto de entidades que podem ser inseridas, anexadas ou excluídas (apenas uma dessas ops).

Se pudéssemos ter algo como

type EntityOp oneof {
    Insert   Reference
    NewState string
    Delete   struct{}
}

O método pode ser apenas

type DB interface {
    …
    Capture(ctx context.Context, processID string, ops map[string]EntityOp) (bool, error)
}

Um uso fantástico para tempos de soma é representar nós em um AST. Outro é substituir nil por um option que é verificado em tempo de compilação.

@DemiMarie, mas no Go de hoje, essa soma também pode ser nula, como propus acima, podemos simplesmente fazer nil ser a variante de cada enum, haverá caso nil em cada switch, mas essa obrigação não é tão ruim, especialmente se nós deseja esse recurso sem quebrar todo o código go existente (atualmente, temos tudo nillable)

Não sei se ele pertence aqui, mas tudo isso me resta Texto do tipo, onde existe um recurso muito legal chamado "Tipos de strings literais" e podemos fazer isso:

var name: "Peter" | "Consuela"; // string type with compile-time constraint

É como enum de string, que é muito melhor do que enums numéricos tradicionais na minha opinião.

@Merovius
um exemplo concreto é trabalhar com JSON arbitrário.
Em Rust, pode ser representado como
enum Value {
Nulo,
Bool (bool),
Número (número),
String (String),
Array (Vec),
Objeto (mapa),
}

Um tipo de união tem duas vantagens:

  1. Autodocumentar o código
  2. Permitir que o compilador ou go vet verifique o uso incorreto de um tipo de união
    (por exemplo, um interruptor em que nem todos os tipos são verificados)

Para a sintaxe, o seguinte deve ser compatível com Go1 , como tipo de alias :

type Token = int | float64 | string

Um tipo de união pode ser implementado internamente como uma interface; o que é importante é que usar um tipo de união permite que o código seja mais legível e detecte erros como

var tok Token

switch t := tok.(type) {
case int:
    // do something
}

O compilador deve gerar um erro, uma vez que nem todos os tipos Token são usados ​​no switch.

O problema com isso é que não há (que eu saiba) nenhuma maneira de armazenar tipos de ponteiros (ou tipos que contenham ponteiros, como string ) e tipos que não sejam ponteiros juntos. Mesmo tipos com layouts diferentes não funcionariam. Sinta-se à vontade para me corrigir, mas o problema é que o GC preciso não funciona bem com variáveis ​​que podem ser ponteiros e variáveis ​​simples ao mesmo tempo.

Podemos seguir o caminho do boxe implícito - como interface{} faz atualmente. Mas não acho que isso ofereça benefícios suficientes - ainda parece um tipo de interface glorificado. Talvez algum tipo de cheque vet possa ser desenvolvido em vez disso?

O coletor de lixo precisaria ler os bits da tag do sindicato para determinar o layout. Isso não é impossível, mas seria uma grande mudança no tempo de execução que pode desacelerar o gc.

Talvez algum tipo de exame veterinário possa ser desenvolvido em vez disso?

https://github.com/BurntSushi/go-sumtype

O coletor de lixo precisaria ler os bits da tag do sindicato para determinar o layout.

Essa é exatamente a mesma corrida que existia com interfaces, quando elas poderiam conter não-ponteiros. Esse design foi explicitamente afastado.

go-sumtype é interessante, obrigado. Mas o que acontece se o mesmo pacote definir dois tipos de união?

O compilador pode implementar o tipo de união internamente como interface, mas adicionando uma sintaxe uniforme e verificação de tipo padrão.

Se houver N projetos usando tipos de união, cada um diferente e com N grandes o suficiente, talvez apresentar uma maneira de fazer isso possa ser a melhor solução.

Mas o que acontece se o mesmo pacote definir dois tipos de união?

Praticamente nada? A lógica é por tipo e usa um método fictício para reconhecer implementadores. Basta usar nomes diferentes para os métodos fictícios.

O bitmap atual do

O problema com isso é que não há (que eu saiba) nenhuma maneira de armazenar tipos de ponteiro (ou tipos que contenham ponteiros, como string) e tipos não-ponteiro juntos

Eu não acredito que isso seja necessário. O compilador pode sobrepor o layout para tipos quando os mapas de ponteiros correspondem, e não de outra forma. Quando eles não combinam, seria livre para organizá-los consecutivamente ou usar uma abordagem de ponteiro como o usado para interfaces atualmente. Ele pode até usar layouts não contíguos para membros de estrutura.

Mas não acho que isso ofereça benefícios suficientes - ainda parece um tipo de interface glorificado.

Em minha proposta , os tipos de união são _exatamente_ um tipo de interface glorificado - um tipo de união é apenas um subconjunto de uma interface que só tem permissão para armazenar um conjunto enumerado de tipos. Isso potencialmente dá ao compilador a liberdade de escolher um método de armazenamento mais eficiente para determinados conjuntos de tipos, mas isso é um detalhe de implementação, não a motivação principal.

@rogpeppe - Por curiosidade, posso usar o tipo sum diretamente ou preciso explicitamente

Posso fazer

type FooType int | float64

func AddOne(foo FooType) FooType {
    return foo + 1
}

// if this can be done, what happens here?
type FooType nil | int
func AddOne(foo FooType) FooType {
    return foo + 1
}

Se isso não puder ser feito, não vejo muita diferença com

type FooType interface{}

func AddOne(foo FooType) (FooType, error) {
    switch v := foo.(type) {
        case int:
              return v + 1, nil
        case float64:
              return v + 1.0, nil
    }

    return nil, fmt.Errorf("invalid type %T", foo)
}

// versus
type FooType int | float64

func AddOne(foo FooType) FooType {
    switch v := foo.(type) {
        case int:
              return v + 1
        case float64:
              return v + 1.0
    }

    // assumes the compiler knows that there is no other type is 
    // valid and thus this should always returns a value
    // Would the compiler error out on incomplete switch types?
}

@xibz

Por curiosidade, posso usar o tipo sum diretamente ou preciso explicitamente convertê-lo em um tipo conhecido para fazer algo com ele? Porque se eu tenho que constantemente convertê-lo para um tipo conhecido, eu realmente não sei quais benefícios isso traz do que o que já é dado a nós com interfaces.

@rogpeppe , corrija-me se eu estiver errado 🙏
Ter que sempre realizar correspondência de padrões (é assim que "casting" é chamado ao trabalhar com tipos de soma em linguagens de programação funcionais) é na verdade um dos maiores benefícios de usar tipos de soma. Forçar o desenvolvedor a lidar explicitamente com todas as formas possíveis de um tipo de soma é uma maneira de evitar que o desenvolvedor use uma variável pensando que é de um determinado tipo, mas na verdade é diferente. Um exemplo exagerado seria, em JavaScript:

const a = "1" // string "1"
const b = a + 5 // string "15" and not number 6

Se isso não puder ser feito, não vejo muita diferença com

Acho que você mesmo afirma algumas vantagens, não é?

O principal benefício que vejo é a verificação de erro em tempo de compilação, mais ou menos, já que o desempacotamento ainda ocorreria em tempo de execução, o que é mais provável quando você veria um problema com um tipo inválido sendo passado. O outro benefício é uma interface mais restrita, que eu não acho que justifique uma mudança de idioma.

// Would the compiler error out on incomplete switch types?

Com base no que as linguagens de programação funcionais fazem, acho que isso deve ser possível e configurável 👍

@xibz também tem desempenho, já que pode ser feito em tempo de compilação versus tempo de execução, mas há genéricos, espero, um dia antes de morrer.

@xibz

Por curiosidade, posso usar o tipo sum diretamente ou preciso explicitamente convertê-lo em um tipo conhecido para fazer algo com ele?

Você pode chamar métodos nele se todos os membros do tipo compartilharem esse método.

Tomando seu int | float64 como exemplo, qual seria o resultado de:

var x int|float64 = int(2)
var y int|float64 = float64(0.5)
fmt.Println(x * y)

Ele faria uma conversão implícita de int para float64 ? Ou de float64 a int . Ou entraria em pânico?

Então você está quase certo - você precisa verificar o tipo antes de usá-lo na maioria dos casos. Acredito que seja uma vantagem, mas não uma desvantagem.

A vantagem do tempo de execução pode ser significativa, BTW. Para continuar com seu tipo de exemplo, uma fatia do tipo [](int|float64) não precisaria conter nenhum ponteiro porque é possível representar todas as instâncias do tipo em alguns bytes (provavelmente 16 bytes devido a restrições de alinhamento), o que poderia levar a melhorias significativas de desempenho em alguns casos.

A correspondência de padrões

Isso é um pouco artificial, mas, por exemplo, se você tem uma árvore de sintaxe de expressão, para corresponder a uma equação quadrática, você pode fazer algo como:

match Add(Add(Mult(Const(a), Power(Var(x), 2)), Mult(Const(b), Var(x))), Const(c)) {
  // here a, b, c are bound to the constants and x is bound to the variable name.
  // x must have been the same in both var expressions or it wouldn't match.
}

Exemplos simples que atingem apenas um nível de profundidade não mostram uma grande diferença, mas aqui estamos subindo para cinco níveis de profundidade, o que seria bastante complicado de fazer com interruptores de tipo aninhados. Uma linguagem com correspondência de padrões pode atingir vários níveis de profundidade e, ao mesmo tempo, garantir que você não perca nenhum caso.

Não tenho certeza de quanto sai fora dos compiladores, no entanto.

@xibz
Uma vantagem dos tipos de soma é que você e o compilador sabem exatamente quais tipos podem existir dentro da soma. Essa é essencialmente a diferença. Com interfaces vazias, você sempre terá que se preocupar e se proteger contra abusos na api, por ter sempre um branch cujo único propósito é recuperar quando um usuário lhe dá um tipo que você não está esperando.

Como parece haver pouca esperança de que os tipos de soma sejam implementados no compilador, espero que pelo menos uma diretiva de comentário padrão, como //go:union A | B | C seja proposta e suportada por go vet .

Com uma forma padrão de declaração de tipo de soma, após N anos será possível saber quantos pacotes estão utilizando.

Com os recentes rascunhos de design de genéricos, talvez os tipos de soma pudessem ser associados a eles.

Em um dos rascunhos, surgiu a ideia de usar interfaces em vez de contratos, e as interfaces teriam que oferecer suporte a listas de tipos:

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Embora isso por si só não produza uma união de memória, mas talvez ao usar em uma função ou estrutura genérica, ela não seria encaixotada e, pelo menos, forneceria segurança de tipo ao lidar com uma lista finita de tipos.

E talvez, usar essas interfaces específicas dentro de switches de tipo exigiria que tal switch fosse exaustivo.

Esta não é a sintaxe curta ideal (por exemplo: Foo | int32 | []Bar ), mas é alguma coisa.

Com os recentes rascunhos de design de genéricos, talvez os tipos de soma pudessem ser associados a eles.

Em um dos rascunhos, surgiu a ideia de usar interfaces em vez de contratos, e as interfaces teriam que oferecer suporte a listas de tipos:

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Embora isso por si só não produza uma união de memória, mas talvez ao usar em uma função ou estrutura genérica, ela não seria encaixotada e, pelo menos, forneceria segurança de tipo ao lidar com uma lista finita de tipos.

E talvez, usar essas interfaces específicas dentro de switches de tipo exigiria que tal switch fosse exaustivo.

Esta não é a sintaxe curta ideal (por exemplo: Foo | int32 | []Bar ), mas é alguma coisa.

Muito semelhante à minha proposta: https://github.com/golang/go/issues/19412#issuecomment -520306000

type foobar union {
  int
  float
}

@mathieudevos uau, gosto bastante disso, na verdade.

Para mim, a maior esquisitice (a única esquisitice restante, na verdade) com a última proposta de genéricos são as listas de tipos nas interfaces. Eles simplesmente não se encaixam . Então você acaba com algumas interfaces que você só pode usar como restrições de parâmetro de tipo, e assim por diante ...

O conceito union funciona muito bem em minha mente porque você poderia incorporar union em interface para realizar "restrição que inclui métodos e tipos brutos". As interfaces continuam a funcionar como estão e, com a semântica definida em torno de uma união, podem ser usadas em código regular e a sensação de estranheza vai embora.

// Ordinary interface
type Stringer interface {
    String() string
}

// Type union
type Foobar union {
    int
    float
}

// Equivalent to an interface with a type list
type FoobarStringer interface {
    Stringer
    Foobar
}

// Unions can intersect
type SuperFoo union {
    Foobar
    int
}

// Doesn't compile since these unions can't intersect
type Strange interface {
    Foobar
    union {
        int
        string
    }
}

EDITAR - Na verdade, acabei de ver este CL: https://github.com/golang/go/commit/af48c2e84b52f99d30e4787b1b8d527b5cd2ab64

O principal benefício dessa mudança é que ela abre a porta para
(sem restrição) uso de interfaces com listas de tipo

...Excelente! As interfaces tornam-se totalmente utilizáveis ​​como tipos de soma, o que unifica a semântica entre o uso regular e de restrição. (Obviamente ainda não ligado, mas acho que é um ótimo destino para se seguir.)

Abri o # 41716 para discutir a maneira como uma versão dos tipos de soma aparece no rascunho do projeto de genéricos atual.

Eu só queria compartilhar uma velha proposta de @henryas sobre tipos de dados algébricos. É muito bom escrito com casos de uso fornecidos.
https://github.com/golang/go/issues/21154
Infelizmente, foi fechado por @mvdan no mesmo dia sem qualquer apreciação do trabalho. Tenho certeza de que essa pessoa realmente se sentiu assim e, portanto, não há mais atividades na conta do gh. Eu sinto muito por aquele cara.

Eu realmente gosto de # 21154. Parece ser uma coisa diferente (e, portanto, o comentário de @mvdan ) fechando-o como se não estivesse acertando bem. Reabrir lá ou incluir na discussão aqui?

Sim, eu realmente gostaria de ter a capacidade de modelar mais lógica de negócios de alto nível de maneira semelhante à descrita nessa edição. Tipos de soma para opções restritas semelhantes a enum e os tipos aceitos sugeridos, como na outra edição, seriam fantásticos na caixa de ferramentas. O código de negócios / domínio no Go às vezes parece um pouco desajeitado no momento.

Meu único feedback é que type foo,bar dentro de uma interface parece um pouco estranho e de segunda classe, e eu concordo que deve haver uma escolha entre anulável e não anulável (se possível).

@ProximaB Não entendo por que você diz "não há mais atividades na conta gh". Desde então, eles criaram e comentaram vários outros problemas também, muitos deles no projeto Go. Não vejo nenhuma evidência de que sua atividade tenha sido influenciada por esse problema.

Além disso, concordo totalmente com Daniel encerrando essa questão como um idiota deste. Não entendo porque @andig diz que propõe algo diferente. Tanto quanto eu posso entender o texto de # 21154, ele propõe exatamente a mesma coisa que estamos discutindo aqui e eu não ficaria surpreso se até mesmo a sintaxe exata já tivesse sido sugerida em algum lugar neste megathread (a semântica, no que diz respeito a descrito, com certeza foram. Várias vezes). Na verdade, eu chegaria ao ponto de dizer que o fechamento de Daniels está comprovado pela extensão deste número, porque ele já contém uma discussão bastante detalhada e matizada de # 21154, portanto, repetir tudo isso seria árduo e redundante.

Eu concordo e entendo que provavelmente é decepcionante ter uma proposta fechada como um ingênuo. Mas não conheço uma maneira prática de evitá-lo. Ter a discussão em um lugar parece benéfico para todos os envolvidos e manter várias questões para a mesma coisa em aberto, sem qualquer discussão sobre elas, é claramente inútil.

Além disso, concordo totalmente com Daniel encerrando essa questão como um idiota deste. Não entendo porque @andig diz que propõe algo diferente. Tanto quanto eu posso entender o texto de # 21154, ele propõe exatamente a mesma coisa que estamos discutindo aqui

Relendo este assunto, eu concordo. Parece que confundi esse problema com contratos de genéricos. Eu apoiaria fortemente os tipos de soma. Não tive a intenção de parecer rude, por favor, aceite minhas desculpas se foi assim que soou.

Eu sou um humano e a jardinagem pode ser complicada às vezes, então, por favor, aponte quando eu cometer um erro :) Mas, neste caso, eu acho que qualquer proposta de tipo de soma específica deve ser bifurcada a partir deste tópico, assim como https: / /github.com/golang/go/issues/19412#issuecomment -701625548

Eu sou um humano e a jardinagem pode ser complicada às vezes, então, por favor, aponte quando eu cometer um erro :) Mas, neste caso, eu acho que qualquer proposta de tipo de soma específica deve ser bifurcada neste tópico, assim como

@mvdan não é humano. Confie em mim. Eu sou seu vizinho. Estou brincando.

Obrigado pela atenção. Eu não sou tão apegado às minhas propostas. Sinta-se à vontade para destroçar, modificar e derrubar qualquer parte deles. Tenho estado muito ocupado na vida real, então não tive a chance de ser ativo nas discussões. É bom saber que as pessoas lêem minhas propostas e algumas gostam delas.

A intenção original é permitir o agrupamento de tipos por relevância de domínio, onde eles não necessariamente compartilham comportamentos comuns, e que o compilador imponha isso. Na minha opinião, este é apenas um problema de verificação estática, que é feito durante a compilação. Não há necessidade de o compilador gerar código que retenha o relacionamento complexo entre os tipos. O código gerado pode tratar esses tipos de domínio normalmente como se fossem o tipo de interface {} regular. A diferença é que o compilador agora faz uma verificação adicional de tipo estático durante a compilação. Essa é basicamente a essência da minha proposta # 21154

@henryas Que bom ver você! 😊
Estou me perguntando se Golang não tinha usado a digitação duck, isso tornaria o relacionamento entre os tipos muito mais estrito e permitiria o agrupamento de objetos por sua relevância de domínio, conforme você descreveu em sua proposta.

@henryas Que bom ver você! 😊
Estou me perguntando se Golang não tinha usado a digitação duck, isso tornaria o relacionamento entre os tipos muito mais estrito e permitiria o agrupamento de objetos por sua relevância de domínio, conforme você descreveu em sua proposta.

Seria, mas isso quebraria a promessa de compatibilidade com Go 1. Provavelmente não precisaríamos de tipos de soma se tivéssemos uma interface explícita. No entanto, a digitação de pato não é necessariamente uma coisa ruim. Isso torna certas coisas mais leves e convenientes. Eu gosto de digitar como pato. É uma questão de usar a ferramenta certa para o trabalho.

@henryas eu concordo. Foi uma pergunta hipotética. Os criadores de Go definitivamente consideraram profundamente todos os altos e baixos.
Por outro lado, o guia de codificação, como verificar a conformidade da interface, nunca apareceria.
https://github.com/uber-go/guide/blob/master/style.md#verify -interface-compliance

Você pode ter essa discussão fora do tópico em outro lugar? Existem muitas pessoas que subscreveram esta edição.
A satisfação com a interface aberta faz parte do Go desde seu início e não vai mudar.

Esta página foi útil?
0 / 5 - 0 avaliações

Questões relacionadas

natefinch picture natefinch  ·  3Comentários

myitcv picture myitcv  ·  3Comentários

Miserlou picture Miserlou  ·  3Comentários

mingrammer picture mingrammer  ·  3Comentários

OneOfOne picture OneOfOne  ·  3Comentários