Go: propuesta: especificación: agregar tipos de suma / uniones discriminadas

Creado en 6 mar. 2017  ·  320Comentarios  ·  Fuente: golang/go

Esta es una propuesta para tipos de suma, también conocidos como uniones discriminadas. Los tipos de suma en Go deberían actuar esencialmente como interfaces, excepto que:

  • son tipos de valor, como estructuras
  • los tipos contenidos en ellos se fijan en tiempo de compilación

Los tipos de suma se pueden combinar con una instrucción de cambio. El compilador comprueba que todas las variantes coincidan. Dentro de los brazos de la declaración de cambio, el valor se puede usar como si fuera de la variante que se emparejó.

Go2 LanguageChange NeedsInvestigation Proposal

Comentario más útil

Gracias por crear esta propuesta. He estado jugando con esta idea durante un año más o menos.
Lo siguiente es todo lo que tengo con una propuesta concreta. creo
"tipo de elección" podría ser un nombre mejor que "tipo de suma", pero YMMV.

Tipos de suma en Go

Un tipo de suma está representado por dos o más tipos combinados con "|"
operador.

type: type1 | type2 ...

Los valores del tipo resultante solo pueden contener uno de los tipos especificados. los
tipo se trata como un tipo de interfaz - su tipo dinámico es el de la
valor que se le asigna.

Como caso especial, "nil" se puede utilizar para indicar si el valor puede
se vuelve nulo.

Por ejemplo:

type maybeInt nil | int

El conjunto de métodos del tipo suma contiene la intersección del conjunto de métodos
de todos sus tipos de componentes, excluyendo cualquier método que tenga el mismo
nombre pero con firmas diferentes.

Como cualquier otro tipo de interfaz, el tipo de suma puede ser objeto de una dinámica
conversión de tipo. En interruptores de tipo, el primer brazo del interruptor que
coincide con el tipo almacenado.

El valor cero de un tipo de suma es el valor cero del primer tipo en
la suma.

Al asignar un valor a un tipo de suma, si el valor puede caber en más
que uno de los tipos posibles, se elige el primero.

Por ejemplo:

var x int|float64 = 13

daría como resultado un valor con tipo dinámico int, pero

var x int|float64 = 3.13

daría como resultado un valor con tipo dinámico float64.

Implementación

Una implementación ingenua podría implementar tipos de suma exactamente como interfaz
valores. Un enfoque más sofisticado podría usar una representación
apropiado al conjunto de valores posibles.

Por ejemplo, un tipo de suma que consta solo de tipos concretos sin punteros
podría implementarse con un tipo no puntero, usando un valor extra para
recuerde el tipo real.

Para tipos de suma de estructuras, incluso podría ser posible usar relleno adicional
bytes comunes a las estructuras para ese propósito.

Todos 320 comentarios

Esto se ha discutido varias veces en el pasado, comenzando desde antes del lanzamiento de código abierto. El consenso anterior ha sido que los tipos de suma no aportan mucho a los tipos de interfaz. Una vez que lo soluciona todo, lo que obtiene al final es un tipo de interfaz donde el compilador verifica que haya completado todos los casos de un cambio de tipo. Ese es un beneficio bastante pequeño para un nuevo cambio de idioma.

Si desea seguir adelante con esta propuesta, deberá escribir un documento de propuesta más completo, que incluya: ¿Cuál es la sintaxis? ¿Exactamente cómo funcionan? (Dice que son "tipos de valor", pero los tipos de interfaz también son tipos de valor). ¿Cuáles son las compensaciones?

Creo que este es un cambio demasiado significativo del sistema de tipos para Go1 y no hay una necesidad urgente.
Sugiero que revisemos esto en el contexto más amplio de Go 2.

Gracias por crear esta propuesta. He estado jugando con esta idea durante un año más o menos.
Lo siguiente es todo lo que tengo con una propuesta concreta. creo
"tipo de elección" podría ser un nombre mejor que "tipo de suma", pero YMMV.

Tipos de suma en Go

Un tipo de suma está representado por dos o más tipos combinados con "|"
operador.

type: type1 | type2 ...

Los valores del tipo resultante solo pueden contener uno de los tipos especificados. los
tipo se trata como un tipo de interfaz - su tipo dinámico es el de la
valor que se le asigna.

Como caso especial, "nil" se puede utilizar para indicar si el valor puede
se vuelve nulo.

Por ejemplo:

type maybeInt nil | int

El conjunto de métodos del tipo suma contiene la intersección del conjunto de métodos
de todos sus tipos de componentes, excluyendo cualquier método que tenga el mismo
nombre pero con firmas diferentes.

Como cualquier otro tipo de interfaz, el tipo de suma puede ser objeto de una dinámica
conversión de tipo. En interruptores de tipo, el primer brazo del interruptor que
coincide con el tipo almacenado.

El valor cero de un tipo de suma es el valor cero del primer tipo en
la suma.

Al asignar un valor a un tipo de suma, si el valor puede caber en más
que uno de los tipos posibles, se elige el primero.

Por ejemplo:

var x int|float64 = 13

daría como resultado un valor con tipo dinámico int, pero

var x int|float64 = 3.13

daría como resultado un valor con tipo dinámico float64.

Implementación

Una implementación ingenua podría implementar tipos de suma exactamente como interfaz
valores. Un enfoque más sofisticado podría usar una representación
apropiado al conjunto de valores posibles.

Por ejemplo, un tipo de suma que consta solo de tipos concretos sin punteros
podría implementarse con un tipo no puntero, usando un valor extra para
recuerde el tipo real.

Para tipos de suma de estructuras, incluso podría ser posible usar relleno adicional
bytes comunes a las estructuras para ese propósito.

@rogpeppe ¿Cómo interactuaría eso con las afirmaciones de tipo y los cambios de tipo? Presumiblemente, sería un error en tiempo de compilación tener un case en un tipo (o afirmación de un tipo) que no es miembro de la suma. ¿También sería un error tener un interruptor no exhaustivo en un tipo de este tipo?

Para interruptores de tipo, si tiene

type T int | interface{}

y lo hace:

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

yt contiene una interfaz {} que contiene un int, ¿coincide con el primer caso? ¿Qué pasa si el primer caso es case interface{} ?

¿O los tipos de suma pueden contener solo tipos concretos?

¿Qué pasa con type T interface{} | nil ? Si tú escribes

var t T = nil

¿Cuál es el tipo de t? ¿O esa construcción está prohibida? Surge una pregunta similar para type T []int | nil , por lo que no se trata solo de interfaces.

Sí, creo que sería razonable tener un error en tiempo de compilación.
tener un caso que no se puede igualar. No estoy seguro de si es
una buena idea permitir interruptores no exhaustivos en un tipo de este tipo;
no requiere exhaustividad en ningún otro lugar. Una cosa que podría
Sin embargo, sea bueno: si el cambio es exhaustivo, no podríamos requerir un valor predeterminado
para convertirlo en una declaración de terminación.

Eso significa que puede hacer que el compilador falle si tiene:

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

y cambia el tipo de suma para agregar un caso adicional.

Para interruptores de tipo, si tiene

tipo T int | interfaz{}

y lo hace:

cambiar t: = t. (tipo) {
caso int:
// ...
yt contiene una interfaz {} que contiene un int, ¿coincide con el primer caso? ¿Qué pasa si el primer caso es la interfaz de caso {}?

No puede contener una interfaz {} que contenga un int. es una interfaz
escriba como cualquier otro tipo de interfaz, excepto que solo puede
contienen el conjunto enumerado de tipos que lo componen.
Al igual que una interfaz {} no puede contener una interfaz {} que contenga un int.

Los tipos de suma pueden coincidir con los tipos de interfaz, pero aún así obtienen una
escriba para el valor dinámico. Por ejemplo, estaría bien tener:

type R io.Reader | io.ReadCloser

¿Qué pasa con la interfaz tipo T {} | ¿nulo? Si tú escribes

var t T = cero

¿Cuál es el tipo de t? ¿O esa construcción está prohibida? Surge una pregunta similar para el tipo T [] int | nil, por lo que no se trata solo de interfaces.

De acuerdo con la propuesta anterior, obtienes el primer artículo
en la suma a la que se puede asignar el valor, por lo que
obtendría la interfaz nula.

De hecho, interfaz {} | nil es técnicamente redundante, porque cualquier interfaz {}
puede ser nulo.

Para [] int | nil, un nil [] int no es lo mismo que una interfaz nil, por lo que
el valor concreto de ([]int|nil)(nil) sería []int(nil) no sin tipo nil .

El caso []int | nil es interesante. Esperaría que nil en la declaración de tipo siempre signifique "el valor de interfaz nulo", en cuyo caso

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

implicaría que x es la interfaz nula, no la nula []int .

Ese valor sería distinto del nulo []int codificado en el mismo tipo:

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

¿No se requeriría siempre nil incluso si la suma es de todos los tipos de valor? De lo contrario, ¿qué sería var x int64 | float64 ? Mi primer pensamiento, extrapolando las otras reglas, sería el valor cero del primer tipo, pero ¿qué pasa con var x interface{} | int ? Como señala

Parece demasiado sutil.

Los interruptores de tipo exhaustivo estarían bien. Siempre puede agregar un default: vacío cuando no sea el comportamiento deseado.

La propuesta dice "Al asignar un valor a un tipo de suma, si el valor puede caber en más
que uno de los tipos posibles, se elige el primero ".

Entonces, con:

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

x tendría un tipo concreto [] int porque nil se puede asignar a [] int y [] int es el primer elemento del tipo. Sería igual a cualquier otro valor [] int (nil).

¿No se requeriría siempre nil incluso si la suma es de todos los tipos de valor? De lo contrario, ¿qué sería var x int64 | float64 be?

La propuesta dice "El valor cero de un tipo de suma es el valor cero del primer tipo en
the sum. ", por lo que la respuesta es int64 (0).

Mi primer pensamiento, extrapolando las otras reglas, sería el valor cero del primer tipo, pero luego ¿qué pasa con la interfaz var x {} | ¿En t? Como señala

No, solo sería el valor nulo de la interfaz habitual en ese caso. Ese tipo (interfaz {} | nil) es redundante. Quizás sería una buena idea convertirlo en un compilador para especificar tipos de suma donde un elemento es un superconjunto de otro, ya que actualmente no veo ningún sentido en definir tal tipo.

El valor cero de un tipo de suma es el valor cero del primer tipo de la suma.

Esa es una sugerencia interesante, pero dado que el tipo de suma debe registrar en algún lugar el tipo de valor que tiene actualmente, creo que significa que el valor cero del tipo de suma no es todo-bytes-cero, lo que lo haría diferente de cualquier otro tipo en Go. O tal vez podríamos agregar una excepción que diga que si la información del tipo no está presente, entonces el valor es el valor cero del primer tipo enumerado, pero entonces no estoy seguro de cómo representar nil si no lo está el primer tipo enumerado.

Entonces, (stuff) | nil solo tiene sentido cuando nada en (cosas) puede ser nulo y nil | (stuff) significa algo diferente dependiendo de si algo en las cosas puede ser nulo. ¿Qué valor agrega nada?

@ianlancetaylor Creo que muchos lenguajes funcionales implementan tipos de suma (cerrados) esencialmente como lo haría en C

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

si which indexa en los campos de la unión en orden, 0 = a, 1 = b, 2 = c, la definición de valor cero funciona para que todos los bytes sean cero. Y necesitaría almacenar los tipos en otro lugar, a diferencia de las interfaces. También necesitaría un manejo especial para la etiqueta nil de algún tipo donde sea que almacene la información de tipo.

Eso haría que los tipos de valor de union en lugar de interfaces especiales, lo cual también es interesante.

¿Hay alguna manera de hacer que todo el valor cero funcione si el campo que registra el tipo tiene un valor cero que representa el primer tipo? Supongo que una forma posible de representar esto sería:

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

[editar]

Lo siento, @jimmyfrasche se me adelantó.

¿Hay algo agregado por nil que no se pueda hacer con

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

?

Eso parece que evita mucha confusión (que tengo, al menos)

O mejor

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

de esa manera, podría escribir cambiar en None y asignar con None{}

@jimmyfrasche struct{} no es igual a nil . Es un detalle menor, pero haría que los cambios de tipo en sumas divergieran innecesariamente (?) De los cambios de tipo en otros tipos.

@bcmills No era mi intención afirmar lo contrario; quise decir que podría usarse con el mismo propósito que diferenciar una falta de valor sin superponerse con el significado de nil en cualquiera de los tipos de la suma.

@rogpeppe ¿qué imprime esto?

// 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")
}

Asumiría "Lector"

@jimmyfrasche Asumiría que ReadCloser , lo mismo que obtendría de un cambio de tipo en cualquier otra interfaz.

(Y también esperaría que las sumas que incluyen solo tipos de interfaz no usen más espacio que una interfaz normal, aunque supongo que una etiqueta explícita podría ahorrar un poco de sobrecarga de búsqueda en el cambio de tipo).

@bcmills es la asignación que es interesante, considere: https://play.golang.org/p/PzmWCYex6R

@ianlancetaylor Ese es un excelente punto para plantear, gracias. Sin embargo, no creo que sea difícil de sortear, aunque implica que mi sugerencia de "implementación ingenua" es en sí misma demasiado ingenua. Un tipo de suma, aunque se trata como un tipo de interfaz, no tiene que contener realmente un puntero directo al tipo y su conjunto de métodos; en su lugar, podría, cuando sea apropiado, contener una etiqueta de número entero que implique el tipo. Esa etiqueta podría ser distinta de cero incluso cuando el tipo en sí sea nulo.

Dado:

 var x int | nil = nil

no es necesario que el valor de tiempo de ejecución de x sea todo ceros. Al encender el tipo de xo convertir
a otro tipo de interfaz, la etiqueta podría ser indirectamente a través de una pequeña tabla que contiene
los punteros de tipo real.

Otra posibilidad sería permitir un tipo nulo solo si es el primer elemento, pero
que excluye construcciones como:

var t nil | int
var u float64 | t

@jimmyfrasche Asumiría ReadCloser, lo mismo que obtendría de un cambio de tipo en cualquier otra interfaz.

Si.

@bcmills es la asignación que es interesante, considere: https://play.golang.org/p/PzmWCYex6R

No entiendo esto. ¿Por qué "esto tiene que ser [...] válido para que el interruptor de tipo imprima ReadCloser"?
Como cualquier tipo de interfaz, un tipo de suma no almacenaría más que el valor concreto de lo que contiene.

Cuando hay varios tipos de interfaz en una suma, la representación en tiempo de ejecución es solo un valor de interfaz; es solo que sabemos que el valor subyacente debe implementar una o más de las posibilidades declaradas.

Es decir, cuando asigna algo a un tipo (I1 | I2) donde tanto I1 como I2 son tipos de interfaz, no es posible saber más adelante si se sabía que el valor que ingresó implementó I1 o I2 en ese momento.

Si tiene un tipo que es io.ReadCloser | io.Reader no puede estar seguro cuando escribe switch o afirmar en io.Reader que no es un io.ReadCloser a menos que la asignación a un tipo de suma desempaquete y vuelva a encuadrar la interfaz.

Haciendo lo contrario, si tuvieras io.Reader | io.ReadCloser nunca aceptaría un io.ReadCloser porque va estrictamente de derecha a izquierda o la implementación tendría que buscar la interfaz de "mejor coincidencia" de todas las interfaces en la suma, pero eso no puede estar bien definido.

@rogpeppe En su propuesta, ignorando las posibilidades de optimización en la implementación y las sutilezas de los valores cero, el principal beneficio de usar un tipo de suma sobre un tipo de interfaz creado manualmente (que contiene la intersección de los métodos relevantes) es que el verificador de tipos puede señalar errores en tiempo de compilación en lugar de tiempo de ejecución. Un segundo beneficio es que el valor de un tipo está más discriminado y, por lo tanto, puede ayudar con la legibilidad / comprensión de un programa. ¿Existe algún otro beneficio importante?

(No estoy tratando de disminuir la propuesta de ninguna manera, solo trato de entender bien mi intuición. Especialmente si la complejidad sintáctica y semántica adicional es "razonablemente pequeña", sea lo que sea que eso signifique, definitivamente puedo ver el beneficio de tener el compilador detectar errores temprano.)

@griesemer Sí, eso es correcto.

Particularmente cuando se comunican mensajes a través de canales o la red, creo que ayuda a la legibilidad y la corrección el poder tener un tipo que exprese exactamente las posibilidades disponibles. En la actualidad, es común hacer un intento a medias para hacer esto al incluir un método no exportado en un tipo de interfaz, pero esto es a) evitable mediante la incrustación yb) es difícil ver todos los tipos posibles porque el método no exportado está oculto.

@jimmyfrasche

Si tiene un tipo que es io.ReadCloser | io.Reader no puede estar seguro cuando escribe switch o afirmar en io.Reader que no es un io.ReadCloser a menos que la asignación a un tipo de suma desempaquete y vuelva a encuadrar la interfaz.

Si tienes ese tipo, sabes que siempre es un io.Reader (o nil, porque cualquier io.Reader también puede ser nil). Las dos alternativas no son exclusivas: el tipo de suma propuesto es "inclusivo o" no "exclusivo o".

Haciendo lo contrario, si tuvieras io.Reader | io.ReadCloser nunca aceptaría un io.ReadCloser porque va estrictamente de derecha a izquierda o la implementación tendría que buscar la interfaz de "mejor coincidencia" de todas las interfaces en la suma, pero eso no puede estar bien definido.

Si por "ir en sentido contrario" te refieres a asignar a ese tipo, la propuesta dice:

"Al asignar un valor a un tipo de suma, si el valor puede caber en más
que uno de los tipos posibles, se elige el primero ".

En este caso, un io.ReadCloser puede caber tanto en un io.Reader como en un io.ReadCloser, por lo que elige io.Reader, pero en realidad no hay forma de saberlo después. No hay diferencia detectable entre el tipo io.Reader y el tipo io.Reader | io.ReadCloser, porque io.Reader también puede contener todos los tipos de interfaz que implementan io.Reader. Por eso sospecho que podría ser una buena idea hacer que el compilador rechace tipos como este. Por ejemplo, podría rechazar cualquier tipo de suma que involucre interfaz {} porque interfaz {} ya puede contener cualquier tipo, por lo que las calificaciones adicionales no agregan ninguna información.

@rogpeppe, hay muchas cosas que me gustan de tu propuesta. La semántica de asignación de izquierda a derecha y el valor cero es el valor cero de las reglas de tipo más a la izquierda son muy claras y simples. Muy ido.

Lo que me preocupa es asignar un valor que ya está en un cuadro en una interfaz a una variable de tipo suma.

Por el momento, usemos mi ejemplo anterior y digamos que RC es una estructura que se puede asignar a un io.ReadCloser.

Si haces esto

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

los resultados son obvios y claros.

Sin embargo, si haces esto

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

lo único sensato que se puede hacer es tener v store r como un io.Reader, pero eso significa que cuando escribes switch on v no puedes estar seguro de que cuando presionas el caso io.Reader no tienes un io.ReadCloser. Necesitarías tener algo como esto:

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

Ahora, hay un sentido en el que io.ReadCloser <: io.Reader, y podría rechazarlos, como sugirió, pero creo que el problema es más fundamental y puede aplicarse a cualquier propuesta de tipo de suma para Go †.

Digamos que tiene tres interfaces A, B y C, con los métodos A (), B () y C () respectivamente, y una estructura ABC con los tres métodos. A, B y C son disjuntos, por lo que A | B | C y sus permutaciones son todos tipos válidos. Pero todavía tienes casos como

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

Hay muchas formas de reorganizar eso y aún no obtienes garantías significativas sobre qué es v cuando hay interfaces involucradas. Después de desempaquetar la suma, debe desempacar la interfaz si el orden es importante.

¿Quizás la restricción debería ser que ninguno de los sumandos pueda ser interfaz en absoluto?

La única otra solución en la que puedo pensar es no permitir la asignación de una interfaz a una variable de tipo suma, pero eso parece, a su manera, más severo.

† eso no implica constructores de tipos para los tipos en la suma para eliminar la ambigüedad (como en Haskell, donde tienes que decir Just v para construir un valor de tipo Maybe), pero no estoy a favor de eso en absoluto.

@jimmyfrasche ¿Es realmente importante el caso de uso del es importante, es fácil trabajar con estructuras de caja explícitas:

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

var v ReadCloser | Reader = Reader{r}

@bcmills Es más que los resultados no son obvios y complicados y significa que todas las garantías que desea con un tipo de suma se evaporan cuando las interfaces están involucradas. Puedo ver que causa todo tipo de errores sutiles y malentendidos.

El ejemplo de estructuras de caja explícitas que proporciona muestra que no permitir interfaces en tipos de suma no limita en absoluto la potencia de los tipos de suma. Está creando efectivamente los constructores de tipos para la desambiguación que mencioné en la nota al pie. Es cierto que es un poco molesto y un paso adicional, pero es simple y se siente muy en línea con la filosofía de Go de permitir que las construcciones del lenguaje sean lo más ortogonales posible.

todas las garantías que quieras con un tipo de suma

Depende de las garantías que espere. Creo que esperas que un tipo de suma sea
un valor estrictamente etiquetado, por lo que dado cualquier tipo A | B | C, sabes exactamente qué estático
tipo que le asignó. Lo veo como una restricción de tipo en un solo valor de concreto
tipo: la restricción es que el valor es compatible con (al menos) uno de A, B y C.
Al final, es solo una interfaz con un valor en.

Es decir, si se puede asignar un valor a un tipo de suma en virtud de que es compatible con la asignación
con uno de los miembros del tipo de suma, no registramos cuál de esos miembros ha sido
"elegido": simplemente registramos el valor en sí. Lo mismo que cuando asigna un io.Reader
a una interfaz {}, pierde el tipo estático io.Reader y solo tiene el valor en sí
que es compatible con io.Reader pero también con cualquier otro tipo de interfaz que suceda
para implementar.

En tu ejemplo:

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

Una afirmación de tipo de v a cualquiera de A, B y C tendría éxito. Eso me parece razonable.

@rogpeppe, esa semántica tiene más sentido de lo que estaba imaginando. Todavía no estoy del todo convencido de que las interfaces y las sumas se mezclen bien, pero ya no estoy seguro de que no sea así. ¡Progreso!

Digamos que tiene type U I | *T donde I es un tipo de interfaz y *T es un tipo que implementa I .

Dado

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

el tipo dinámico de u es *T , y en

var u U = new(T)

puede acceder a ese *T como I con una afirmación de tipo. ¿Es eso correcto?

Eso significaría que la asignación de un valor de interfaz válido a una suma tendría que buscar el primer tipo coincidente en la suma.

También sería algo diferente de algo como var v uint8 | int32 | int64 = i que, imagino, siempre iría con cualquiera de esos tres tipos i incluso si i fuera un int64 que podría caber en un uint8 .

¡Progreso!

¡Hurra!

puede acceder a ese * T como una I con una aserción de tipo. ¿Es eso correcto?

Si.

Eso significaría que la asignación de un valor de interfaz válido a una suma tendría que buscar el primer tipo coincidente en la suma.

Sí, como dice la propuesta (por supuesto, el compilador sabe estáticamente cuál elegir, por lo que no hay búsqueda en tiempo de ejecución).

También sería algo diferente de algo como var v uint8 | int32 | int64 = i que, imagino, siempre iría con cualquiera de esos tres tipos i, incluso si fuera un int64 que podría caber en un uint8.

Sí, porque a menos que i sea una constante, solo se podrá asignar a una de esas alternativas.

Sí, porque a menos que i sea una constante, solo se podrá asignar a una de esas alternativas.

Me doy cuenta de que eso no es del todo cierto debido a la regla que permite la asignación de tipos sin nombre a tipos con nombre. Sin embargo, no creo que eso haga mucha diferencia. La regla sigue siendo la misma.

Entonces, el tipo I | *T de mi última publicación es efectivamente el mismo que el tipo I y io.ReadCloser | io.Reader es efectivamente el mismo tipo que io.Reader ?

Eso es correcto. Ambos tipos estarían cubiertos por mi regla sugerida de que el compilador rechaza los tipos de suma donde un tipo es una interfaz implementada por otro de los tipos. La misma regla o una similar podría cubrir tipos de suma con tipos duplicados como int|int .

Un pensamiento: quizás no sea intuitivo que int|byte no sea lo mismo que byte|int , pero probablemente esté bien en la práctica.

Eso significaría que la asignación de un valor de interfaz válido a una suma tendría que buscar el primer tipo coincidente en la suma.

Sí, como dice la propuesta (por supuesto, el compilador sabe estáticamente cuál elegir, por lo que no hay búsqueda en tiempo de ejecución).

No estoy siguiendo esto. La forma en que lo leo (que podría ser diferente de lo que pretendía) hay al menos dos formas de lidiar con una unión U de I y T-implementos-I.

1a) en la asignación de U u = t , la etiqueta se establece en T. La selección posterior da como resultado una T porque la etiqueta es una T.
1b) en la asignación de U u = i (i es realmente una T), la etiqueta se establece en I. La selección posterior da como resultado una T porque la etiqueta es una I pero una segunda verificación (realizada porque T implementa I y T es miembro de U) descubre una T.

2a) como 1a
2b) en la asignación de U u = i (i es realmente una T), el código generado verifica el valor (i) para ver si es realmente una T, porque T implementa I y T también es miembro de U. Porque es decir, la etiqueta se establece en T. La selección posterior da como resultado directamente una T.

En el caso de que T, V, W implementen I y U = *T | *V | *W | I , la asignación U u = i requiere (hasta) 3 pruebas de tipo.

Sin embargo, las interfaces y los punteros no eran el caso de uso original para los tipos de unión, ¿verdad?

Puedo imaginar ciertos tipos de piratería en los que una implementación "agradable" realizaría algunos golpes, por ejemplo, si tiene una unión de 4 o menos tipos de punteros donde todos los referentes están alineados en 4 bytes, almacene la etiqueta en los 2 inferiores. bits del valor. Esto, a su vez, implica que no es bueno tomar la dirección de un miembro de un sindicato (no lo sería de todos modos, ya que esa dirección podría usarse para volver a almacenar un tipo "antiguo" sin ajustar la etiqueta).

O si tuviéramos un espacio de direcciones de 50 bits y estuviéramos dispuestos a tomarnos algunas libertades con los NaN, podríamos colocar números enteros, punteros y dobles en una unión de 64 bits, y el posible costo de un poco de manipulación de bits.

Ambas sub-sugerencias son asquerosas, estoy seguro de que ambas tendrían un pequeño (?) Número de defensores fanáticos.

Esto a su vez implica que no es bueno tomar la dirección de un miembro de un sindicato.

Correcto. Pero no creo que el resultado de una afirmación de tipo sea abordable hoy de todos modos, ¿verdad?

en la asignación de U u = i (i es realmente una T), la etiqueta se establece en I.

Creo que este es el quid: no hay etiqueta I.

Ignore la representación en tiempo de ejecución por un momento y considere un tipo de suma como interfaz. Como con cualquier interfaz, tiene un tipo dinámico (el tipo que se almacena en ella). La "etiqueta" a la que se refiere es exactamente ese tipo dinámico.

Como sugieres (y traté de insinuar en el último párrafo de la propuesta), puede haber formas de almacenar la etiqueta de tipo de manera más eficiente que con un puntero al tipo de tiempo de ejecución, pero al final siempre es solo codificar la dinámica. tipo del valor de tipo de suma, no cuál de las alternativas fue "elegida" cuando se creó.

Sin embargo, las interfaces y los punteros no eran el caso de uso original para los tipos de unión, ¿verdad?

No lo fue, pero cualquier propuesta debe ser lo más ortogonal posible con respecto a otras características del lenguaje, en mi opinión.

@ dr2chase, hasta ahora tengo entendido que, si un tipo de suma incluye cualquier tipo de interfaz en su definición, entonces en tiempo de ejecución su implementación es idéntica a una interfaz (que contiene la intersección de conjuntos de métodos) pero los invariantes en tiempo de compilación sobre los tipos permitidos siguen siendo en vigor.

Incluso si un tipo de suma solo contuviera tipos concretos y se implementó como una unión discriminada de estilo C, no podría abordar un valor en el tipo de suma ya que esa dirección podría convertirse en un tipo (y tamaño) diferente después de tomar la dirección. Sin embargo, puede tomar la dirección del valor de la suma tecleada.

¿Es deseable que los tipos de suma se comporten de esta manera? Podríamos declarar fácilmente que el tipo seleccionado / afirmado es el mismo que el programador dijo / insinuó cuando se asignó un valor a la unión. De lo contrario, podríamos llegar a lugares interesantes con respecto a int8 vs int16 vs int32, etc. O, por ejemplo, int8 | uint8 .

¿Es deseable que los tipos de suma se comporten de esta manera?

Eso es cuestión de juicio. Creo que lo es, porque ya tenemos el concepto de interfaces en el lenguaje: valores con un tipo tanto estático como dinámico. Los tipos de suma propuestos solo proporcionan una forma más precisa de especificar los tipos de interfaz en algunos casos. También significa que los tipos de suma pueden funcionar sin restricciones sobre cualquier otro tipo. Si no lo hace, debe excluir los tipos de interfaz y entonces la función no es completamente ortogonal.

De lo contrario, podríamos llegar a lugares interesantes con respecto a int8 vs int16 vs int32, etc. O, por ejemplo, int8 | uint8.

¿Cuál es tu preocupación aquí?

No puede utilizar un tipo de función como tipo de clave de mapa. No estoy diciendo que eso sea equivalente, solo que hay precedentes de tipos que restringen otros tipos de tipos. Todavía abierto a permitir interfaces, todavía no vendido.

¿Qué tipo de programas puede escribir con un tipo de suma que contenga interfaces que no podría de otra manera?

Contrapropuesta.

Un tipo de unión es un tipo que enumera cero o más tipos, escrito

union {
  T0
  T1
  //...
  Tn
}

Todos los tipos enumerados (T0, T1, ..., Tn) en una unión deben ser diferentes y ninguno puede ser un tipo de interfaz.

Los métodos pueden declararse en un tipo de unión definido (con nombre) mediante las reglas habituales. No se promueve ningún método de los tipos enumerados.

No hay incrustaciones para tipos de unión. Enumerar un tipo de unión en otro es lo mismo que enumerar cualquier otro tipo válido. Sin embargo, una unión no puede enumerar su propio tipo de forma recursiva, por la misma razón que type S struct { S } no es válido.

Las uniones se pueden incrustar en estructuras.

El valor de un tipo de unión es un tipo dinámico, limitado a uno de los tipos enumerados, y un valor del tipo dinámico, que se dice que es el valor almacenado. Exactamente uno de los tipos enumerados es el tipo dinámico en todo momento.

El valor cero de la unión vacía es único. El valor cero de una unión no vacía es el valor cero del primer tipo enumerado en la unión.

Se puede crear un valor para un tipo de unión, U , con U{} para el valor cero. Si U tiene uno o más tipos y v es un valor de uno de los tipos enumerados, T , U{v} crea un valor de unión almacenando v con tipo dinámico T . Si v es de un tipo que no figura en U que puede asignarse a más de uno de los tipos enumerados, se requiere una conversión explícita para eliminar la ambigüedad.

Un valor de un tipo de unión U se puede convertir en otro tipo de unión V como en V(U{}) si el conjunto de tipos en U es un subconjunto del conjunto de tipos en V . Es decir, ignorando el orden, U deben tener todos los mismos tipos que V , y U no pueden tener tipos que no estén en V sino V puede tener tipos que no estén en U .

La asignabilidad entre tipos de unión se define como convertibilidad, siempre que como máximo se defina (nombre) uno de los tipos de unión.

Un valor de uno de los tipos enumerados, T , de un tipo de unión U puede asignarse a una variable del tipo de unión U . Esto establece el tipo dinámico en T y almacena el valor. Los valores compatibles con la asignación funcionan como se indicó anteriormente.

Si todos los tipos enumerados admiten los operadores de igualdad:

  • los operadores de igualdad se pueden utilizar en dos valores del mismo tipo de unión. Dos valores de un tipo de unión nunca son iguales si sus tipos dinámicos difieren.
  • un valor de esa unión puede compararse con un valor de cualquiera de los tipos enumerados. Si el tipo dinámico de la unión no es el tipo del otro operando, == es falso y != es verdadero independientemente del valor almacenado. Los valores compatibles con la asignación funcionan como se indicó anteriormente.
  • la unión se puede utilizar como clave de mapa

Ningún otro operador es compatible con valores de un tipo de unión.

Una aserción de tipo contra un tipo de unión para uno de sus tipos enumerados se mantiene si el tipo afirmado es el tipo dinámico.

Una aserción de tipo contra un tipo de unión para un tipo de interfaz se cumple si su tipo dinámico implementa esa interfaz. (En particular, si todos los tipos enumerados implementan esta interfaz, la aserción siempre se mantiene).

Los interruptores de tipo deben ser exhaustivos, incluidos todos los tipos enumerados, o contener un caso predeterminado.

Las afirmaciones de tipo y los interruptores de tipo devuelven una copia del valor almacenado.

Package reflect requeriría una forma de obtener el tipo dinámico y el valor almacenado de un valor de unión reflejado y una forma de obtener los tipos enumerados de un tipo de unión reflejada.

Notas:

La sintaxis union{...} se eligió parcialmente para diferenciarse de la propuesta de tipo suma en este hilo, principalmente para retener las propiedades agradables en la gramática Go y, de paso, para reforzar que se trata de una unión discriminada. Como consecuencia, esto permite uniones algo extrañas como union{} y union{ int } . El primero es en muchos sentidos equivalente a struct{} (aunque por definición es un tipo diferente) por lo que no se agrega al lenguaje, aparte de agregar otro tipo vacío. El segundo es quizás más útil. Por ejemplo, type Id union { int } es muy parecido a type Id struct { int } excepto que la versión de unión permite la asignación directa sin tener que especificar idValue.int lo que permite que parezca más como un tipo integrado.

La conversión de eliminación de ambigüedades requerida cuando se trata de tipos compatibles con asignaciones es un poco severa, pero detectaría errores si se actualiza una unión para introducir una ambigüedad para la que el código descendente no está preparado.

La falta de incrustación es una consecuencia de permitir métodos en las uniones y requerir un emparejamiento exhaustivo en los interruptores de tipo.

Permitir métodos en la unión en sí en lugar de tomar la intersección válida de métodos de los tipos enumerados evita obtener accidentalmente métodos no deseados. El tipo afirmando el valor almacenado en interfaces comunes permite métodos envoltorios simples y explícitos cuando se desea promoción. Por ejemplo, en un tipo de unión U todos cuyos tipos enumerados implementan fmt.Stringer :

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

En el hilo de reddit vinculado, rsc dijo:

Sería extraño para el valor cero de la suma {X; Y} sea diferente de la suma {Y; X }. No es así como suelen funcionar las sumas.

He estado pensando en esto, ya que realmente se aplica a cualquier propuesta.

Eso no es un error: es una característica.

Considerar

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

vs.

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

UndefinedOrInt dice que por defecto aún no está definido, pero, cuando lo esté, será un int . Esto es análogo a *int que es cómo se debe representar el tipo de suma (1 + int) en Ir ahora y el valor cero también es análogo.

IntOrIllegal , por otro lado, dice que por defecto es el int 0, pero puede que en algún momento se marque como ilegal. Esto sigue siendo análogo a *int pero el valor cero es más expresivo de la intención, como exigir que el valor predeterminado sea new(int) .

Es como poder expresar un campo bool en una estructura en negativo, por lo que el valor cero es lo que desea como predeterminado.

Ambos valores cero de las sumas son útiles y significativos por derecho propio y el programador puede elegir el más apropiado para la situación.

Si la suma fuera una enumeración de días de la semana (cada día es un struct{} definido), el primero que se enumere es el primer día de la semana, lo mismo para una enumeración de estilo iota .

Además, no conozco ningún idioma con tipos de suma o uniones discriminadas / etiquetadas que tengan el concepto de valor cero. C sería el más cercano, pero el valor cero es memoria no inicializada, difícilmente una pista a seguir. Java tiene un valor predeterminado nulo, creo, pero eso se debe a que todo es una referencia. Todos los demás lenguajes que conozco tienen constructores de tipo obligatorios para los sumandos, por lo que realmente no existe una noción de valor cero. ¿Existe tal lenguaje? ¿Qué hace?

Si la diferencia con los conceptos matemáticos de "suma" y "unión" es el problema, siempre podemos llamarlos de otra manera (por ejemplo, "variante").

Para los nombres: Union confunde a los puristas de c / c ++. La variante es principalmente familiar para los programadores de COBRA y COM, donde la unión discriminada parece ser la preferida por los lenguajes funcionales. Set es un verbo y un sustantivo. Me gusta la palabra clave _pick_. Limbo usó _pick_. Es breve y describe la intención del tipo de elegir entre un conjunto finito de tipos.

El nombre / sintaxis es en gran parte irrelevante. Elegir estaría bien.

Cualquiera de las propuestas en este hilo se ajusta a la definición teórica establecida.

El primer tipo que es especial para el valor cero es irrelevante ya que las sumas teóricas de tipos conmutan, por lo que el orden es irrelevante (A + B = B + A). Mi propuesta mantiene esa propiedad, pero los tipos de productos también se desplazan en teoría y la mayoría de los idiomas los consideran diferentes en la práctica (incluido Go), por lo que probablemente no sea esencial.

@jimmyfrasche

Personalmente, creo que no permitir interfaces como miembros "elegidos" es un gran inconveniente. Primero, derrotaría por completo uno de los grandes casos de uso de los tipos de 'selección': tener un error en uno de los miembros. O desea tratar con un tipo de selección que tiene un io.Reader o una cadena, si no desea forzar al usuario a usar un StringReader de antemano. Pero en general, una interfaz es solo otro tipo, y creo que no debería haber restricciones de tipo para los miembros 'pick'. Siendo ese el caso, si un tipo de selección tiene 2 miembros de interfaz, donde uno está completamente encerrado por el otro, debería ser un error en tiempo de compilación, como se mencionó anteriormente.

Lo que me gusta de su contrapropuesta es el hecho de que los métodos se pueden definir en el tipo de selección. No creo que deba proporcionar una sección transversal de los métodos de los miembros, ya que no creo que haya muchos casos en los que algún método pertenezca a todos los miembros (y de todos modos tiene interfaces para eso). Y un cambio exhaustivo + caso predeterminado es una muy buena idea.

@rogpeppe @jimmyfrasche Algo que no veo en sus propuestas es por qué deberíamos hacer esto. Hay una clara desventaja de agregar un nuevo tipo de tipo: es un nuevo concepto que todos los que se inclinan por Go tendrán que aprender. ¿Cuál es la ventaja compensatoria? En particular, ¿qué nos da el nuevo tipo de tipo que no obtenemos de los tipos de interfaz?

@ianlancetaylor Robert lo resumió bien aquí: https://github.com/golang/go/issues/19412#issuecomment -288608089

@ianlancetaylor
Al final del día, hace que el código sea más legible, y esa es la directiva principal de Go. Considere json.Token, actualmente está definido como una interfaz {}, sin embargo, la documentación indica que en realidad puede ser solo uno de un número específico de tipos. Si, por otro lado, está escrito como

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

El usuario podrá ver inmediatamente todas las posibilidades y las herramientas podrán crear un interruptor exhaustivo de forma automática. Además, el compilador evitará que inserte un tipo inesperado allí también.

Al final del día, hace que el código sea más legible, y esa es la directiva principal de Go.

Más funciones significa que uno tiene que saber más para comprender el código. Para una persona con un conocimiento promedio de un idioma, su legibilidad es necesariamente inversamente proporcional al número de características [recién agregadas].

@cznic

Más funciones significa que uno tiene que saber más para comprender el código.

No siempre. Si puede sustituir "saber más sobre el lenguaje" por "saber más sobre las invariantes del código documentadas de forma deficiente o inconsistente", todavía puede ser una ganancia neta. (Es decir, el conocimiento global puede desplazar la necesidad del conocimiento local).

Si una mejor verificación de tipos en tiempo de compilación es de hecho el único beneficio, entonces podemos obtener un beneficio muy similar sin cambiar el idioma al introducir un comentario verificado por veterinario. Algo como

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

Ahora, actualmente no tenemos ningún tipo de comentario veterinario, por lo que esta no es una sugerencia del todo seria. Pero hablo en serio acerca de la idea básica: si la única ventaja que obtenemos es algo que podemos hacer por completo con una herramienta de análisis estático, ¿realmente vale la pena agregar un concepto nuevo y complejo al lenguaje propiamente dicho?

Muchas, quizás todas, las pruebas realizadas por cmd / vet podrían agregarse al lenguaje, en el sentido de que podrían ser verificadas por el compilador en lugar de por una herramienta de análisis estático separada. Pero, por varias razones, nos resulta útil separar vet del compilador. ¿Por qué este concepto cae del lado del lenguaje en lugar del lado del veterinario?

@ianlancetaylor re revisó los comentarios: https://github.com/BurntSushi/go-sumtype

@ianlancetaylor en cuanto a si el cambio está justificado, lo he estado ignorando activamente, o más bien lo he

Si una mejor verificación de tipos en tiempo de compilación es de hecho el único beneficio, entonces podemos obtener un beneficio muy similar sin cambiar el idioma al introducir un comentario verificado por veterinario.

Esto todavía es vulnerable a la crítica de la necesidad de aprender cosas nuevas. Si tengo que aprender sobre esos comentarios mágicos del veterinario para depurar / comprender / usar el código, es un impuesto mental, sin importar si lo asignamos al presupuesto de idiomas Go o al presupuesto de idiomas técnicamente no válidos. En todo caso, los comentarios mágicos son más costosos porque no sabía que tenía que aprenderlos cuando pensé que había aprendido el idioma.

@cznic
Estoy en desacuerdo. Con su suposición actual, no puede estar seguro de que una persona entienda qué es un canal, o incluso qué es una función. Sin embargo, estas cosas existen en el idioma. Y una nueva característica no significa automáticamente que dificultaría el idioma. En este caso, yo diría que de hecho lo haría más fácil de entender, porque deja claro inmediatamente al lector lo que se supone que es un tipo, en lugar de usar un tipo de interfaz de caja negra {}.

@ianlancetaylor
Personalmente, creo que esta característica tiene más que ver con hacer que el código sea más fácil de leer y razonar. La seguridad del tiempo de compilación es una característica muy agradable, pero no la principal. No solo haría una firma de tipo más obvia inmediatamente, sino que su uso posterior también sería más fácil de entender y más fácil de escribir. Las personas ya no necesitarían recurrir al pánico si reciben un tipo que no esperaban; ese es el comportamiento actual incluso en la biblioteca estándar, por lo que sería más fácil pensar en el uso, sin verse obstaculizados por lo desconocido. . Y no creo que sea una buena idea confiar en los comentarios y otras herramientas (incluso si son propias) para esto, porque una sintaxis más limpia es más legible que un comentario de este tipo. Y los comentarios no tienen estructura y son mucho más fáciles de estropear.

@ianlancetaylor

¿Por qué este concepto cae del lado del lenguaje en lugar del lado del veterinario?

Podría aplicar la misma pregunta a cualquier característica fuera del núcleo de turing-complete, y podría decirse que no queremos que Go sea un "tarpit de turing". Por otro lado, tenemos ejemplos de lenguajes que han empujado por subconjuntos significativos de la lengua real fuera en una sintaxis genérica "extensión". (Por ejemplo, "atributos" en Rust, C ++ y GNU C.)

La razón principal para incluir características en extensiones o atributos en lugar de en un lenguaje central es preservar la compatibilidad de sintaxis, incluida la compatibilidad con herramientas que no conocen la nueva característica. (Si la "compatibilidad con las herramientas" realmente funciona en la práctica depende en gran medida de lo que realmente hace la función).

En el contexto de Go, parece que la razón principal para poner características en vet es implementar cambios que no preservarían la compatibilidad con Go 1 si se aplicaran al lenguaje en sí. No veo eso como un problema aquí.

Una razón para no incluir características en vet es si es necesario propagarlas durante la compilación. Por ejemplo, si escribo:

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

¿Recibiré las advertencias adecuadas para los tipos que no están en la suma, a través de los límites del paquete? No es obvio para mí que vet pueda hacer un análisis transitivo tan profundo, por lo que quizás esa sea una razón por la que necesitaría ir al lenguaje central.

@ dr2chase En general, por supuesto, tiene razón, pero ¿está en lo correcto para este ejemplo específico? El código es completamente comprensible sin saber qué significa el comentario mágico. El comentario mágico no cambia lo que hace el código de ninguna manera. Los mensajes de error del veterinario deben ser claros.

@bcmills

¿Por qué este concepto cae del lado del lenguaje en lugar del lado del veterinario?

Puede aplicar la misma pregunta a cualquier característica fuera del núcleo de turing-complete ...

No estoy de acuerdo Si la característica en discusión afecta el código compilado, entonces hay un argumento automático a su favor. En este caso, la característica aparentemente no afecta el código compilado.

(Y, sí, el veterinario puede analizar el origen de los paquetes importados).

No estoy tratando de afirmar que mi argumento sobre el veterinario sea concluyente. Pero cada cambio de idioma comienza desde una posición negativa: un idioma simple es muy, muy deseable, y una característica nueva y significativa como esta inevitablemente hace que el idioma sea más complejo. Necesita argumentos sólidos a favor de un cambio de idioma. Y desde mi perspectiva, esos fuertes argumentos aún no han aparecido. Después de todo, hemos pensado en este tema durante mucho tiempo y es una pregunta frecuente (https://golang.org/doc/faq#variant_types).

@ianlancetaylor

En este caso, la característica aparentemente no afecta el código compilado.

Creo que eso depende de los detalles específicos. El "valor cero de la suma es el valor cero del primer tipo" comportamiento que @jimmyfrasche mencionó anteriormente (https://github.com/golang/go/issues/19412#issuecomment-289319916) ciertamente lo haría.

@urandom Estaba escribiendo una larga explicación de por qué la interfaz y los tipos de unión no se mezclaban sin constructores de tipos explícitos, pero luego me di cuenta de que había una forma sensata de hacerlo, así que:

Contrapropuesta rápida y sucia a mi contrapropuesta. (Todo lo que no se mencione explícitamente es lo mismo que mi propuesta anterior). No estoy seguro de que una propuesta sea mejor que la otra, pero esta permite interfaces y es más explícita:

La unión tiene "nombres de campo" explícitos, en lo sucesivo, "nombres de etiquetas":

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

Todavía no hay incrustaciones. Siempre es un error tener un tipo sin un nombre de etiqueta.

Los valores de unión tienen una etiqueta dinámica en lugar de un tipo dinámico.

Creación de valor literal: U{v} solo es válido si es completamente inequívoco, de lo contrario tiene que ser U{Tag: v} .

La convertibilidad y la compatibilidad de asignaciones también tienen en cuenta los nombres de las etiquetas.

La asignación a un sindicato no es mágica. Siempre significa asignar un valor de unión compatible. Para establecer el valor almacenado, el nombre de etiqueta deseado debe usarse explícitamente: v.Good = 1 establece la etiqueta dinámica en Bueno y el valor almacenado en 1.

Para acceder al valor almacenado, se utiliza una aserción de etiqueta en lugar de una aserción de tipo:

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

v.Tag es un error en el rhs ya que es ambiguo.

Los interruptores de etiquetas son como interruptores de tipo, escritos switch v.[type] , excepto que los casos son las etiquetas de la unión.

Las afirmaciones de tipo se mantienen con respecto al tipo de etiqueta dinámica. Los interruptores de tipo funcionan de manera similar.

Dados los valores a, b de algún tipo de unión, a == b si sus etiquetas dinámicas son las mismas y el valor almacenado es el mismo.

Verificar si el valor almacenado es un valor particular requiere una afirmación de etiqueta.

Si el nombre de una etiqueta no se exporta, solo se puede configurar y acceder en el paquete que define la unión. Esto significa que un cambio de etiqueta de una unión con etiquetas mixtas exportadas y no exportadas nunca puede ser exhaustivo fuera del paquete de definición sin un caso predeterminado. Si todas las etiquetas no se exportan, es una caja negra.

Reflection también necesita manejar los nombres de las etiquetas.

e: Aclaración para uniones anidadas. Dado

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

El valor de u es la etiqueta dinámica A y el valor almacenado es la unión anónima con la etiqueta dinámica A1 y su valor almacenado es el valor cero de T1.

u.B.B2 = returnsSomeT3()

es todo lo que se necesita para cambiar u desde el valor cero, aunque se mueva de una de las uniones anidadas a la otra, ya que todo está almacenado en una ubicación de memoria. Pero

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

tiene dos posibilidades de entrar en pánico ya que la etiqueta afirma en dos valores de unión y la versión de 2 valores de la afirmación de la etiqueta no está disponible sin dividirse en varias líneas. Los interruptores de etiquetas anidados serían más limpios, en este caso.

edit2: Aclaración sobre el tipo afirma.

Dado

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

una afirmación de tipo como u.(int) es completamente razonable. Dentro del paquete de definición, eso siempre se mantendría. Sin embargo, si u está fuera del paquete de definición, u.(int) entraría en pánico cuando la etiqueta dinámica sea unexported para evitar filtrar detalles de implementación. De manera similar para las aserciones a un tipo de interfaz.

@ianlancetaylor A continuación, se muestran algunos ejemplos de cómo ayudaría esta función:

  1. En el corazón de algunos paquetes ( go/ast por ejemplo) hay uno o más tipos de suma grande. Es difícil navegar por estos paquetes sin comprender esos tipos. Más confuso, a veces un tipo de suma está representado por una interfaz con métodos (por ejemplo, go/ast.Node ), otras veces por la interfaz vacía (por ejemplo, go/ast.Object.Decl ).

  2. La compilación de la función protobuf oneof para Go da como resultado un tipo de interfaz no exportado cuyo único propósito es asegurarse de que la asignación al campo oneof sea de tipo seguro. Eso a su vez requiere generar un tipo para cada rama del oneof. Los literales de tipo para el producto final son difíciles de leer y escribir:

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

    Algunas (aunque no todas) una de las opciones se pueden expresar mediante tipos de suma.

  3. A veces, un tipo de "tal vez" es exactamente lo que uno necesita. Por ejemplo, muchas operaciones de actualización de recursos de la API de Google permiten cambiar un subconjunto de los campos del recurso. Una forma natural de expresar esto en Go es mediante una variante de la estructura de recursos con un tipo "tal vez" para cada campo. Por ejemplo, el recurso ObjectAttrs de Google Cloud Storage se parece a

    type ObjectAttrs struct {
       ContentType string
       ...
    }
    

    Para admitir actualizaciones parciales, el paquete también define

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

    Donde optional.String ve así ( godoc ):

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

    Esto es difícil de explicar y no es seguro para escribir, pero resulta conveniente en la práctica, porque un literal ObjectAttrsToUpdate ve exactamente como un literal ObjectAttrs , mientras codifica la presencia. Ojalá pudiéramos haber escrito

    type ObjectAttrsToUpdate struct {
       ContentType string | nil
       ...
    }
    
  4. Muchas funciones devuelven (T, error) con semántica xor (T es significativo si el error es nulo). Escribir el tipo de retorno como T | error aclararía la semántica, aumentaría la seguridad y brindaría más oportunidades de composición. Incluso si no podemos (por razones de compatibilidad) o no queremos cambiar el valor de retorno de una función, el tipo de suma sigue siendo útil para llevar ese valor, como escribirlo en un canal.

Una anotación go vet ciertamente ayudaría a muchos de estos casos, pero no a aquellos en los que un tipo anónimo tiene sentido. Creo que si tuviéramos tipos de suma, veríamos muchos

chan *Response | error

Ese tipo es lo suficientemente corto para escribir varias veces.

@ianlancetaylor, este probablemente no sea un gran comienzo, pero aquí está todo lo que puede hacer con las uniones que ya puede hacer en Go1, porque pensé que era justo reconocer y resumir esos argumentos:

(Usando mi última propuesta con etiquetas para la sintaxis / semántica a continuación. También asumiendo que el código emitido es básicamente como el código C que publiqué mucho antes en el hilo).

Los tipos de suma se superponen con iota, punteros e interfaces.

iota

Estos dos tipos son aproximadamente equivalentes:

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

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

y

type Stoplight int

const (
  Green Stoplight = iota
  Yellow
  Red
)

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

Es probable que el compilador emita exactamente el mismo código para ambos.

En la versión de unión, el int se convierte en un detalle de implementación oculto. Con la versión iota, puede preguntar qué es Amarillo / Rojo o establecer un valor de Semáforo en -42, pero no con la versión de unión; todos esos son errores del compilador e invariantes que se pueden tener en cuenta durante la optimización. De manera similar, puede escribir un interruptor (de valor) que no tenga en cuenta las luces amarillas, pero con un interruptor de etiqueta necesitaría un caso predeterminado para hacerlo explícito.

Por supuesto, hay cosas que puede hacer con iota que no puede hacer con los tipos de unión.

punteros

Estos dos tipos son aproximadamente equivalentes

type MaybeInt64 union {
  None struct{}
  Int64 int64
}

y

type MaybeInt64 *int64

La versión de puntero es más compacta. La versión de unión necesitaría un bit adicional (que a su vez probablemente tendría el tamaño de una palabra) para almacenar la etiqueta dinámica, por lo que el tamaño del valor probablemente sería el mismo que https://golang.org/pkg/database/sql/ # NullInt64

La versión sindical documenta más claramente la intención.

Por supuesto, hay cosas que puede hacer con los punteros que no puede hacer con los tipos de unión.

interfaces

Estos dos tipos son aproximadamente equivalentes

type AB union {
  A A
  B B
}

y

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

La versión de unión no se puede eludir con la incrustación. A y B no necesitan métodos en común; de hecho, podrían ser tipos primitivos o tener conjuntos de métodos completamente disjuntos, como el ejemplo de json.Token @urandom publicado.

Es realmente fácil ver lo que puede poner en una unión AB frente a una interfaz AB: la definición es la documentación (he tenido que leer la fuente go / ast varias veces para averiguar qué es algo).

La unión AB nunca puede ser nula y se le pueden dar métodos fuera de la intersección de sus constituyentes (esto podría simularse incrustando la interfaz en una estructura, pero luego la construcción se vuelve más delicada y propensa a errores).

Por supuesto, hay cosas que puede hacer con interfaces que no puede hacer con tipos de unión.

Resumen

Quizás esa superposición es demasiada superposición.

En cada caso, el beneficio principal de las versiones de unión es de hecho una verificación más estricta del tiempo de compilación. Lo que no puede hacer es más importante que lo que puede. Para el compilador que se traduce en invariantes más fuertes, puede usarlo para optimizar el código. Para el programador, eso se traduce en otra cosa de la que puede dejar que el compilador se preocupe; solo le dirá si está equivocado. En la versión de interfaz, como mínimo, existen importantes ventajas de documentación.

Se pueden construir versiones torpes de los ejemplos de iota y pointer usando la estrategia de "interfaz con un método no exportado". Sin embargo, para el caso, las estructuras se pueden simular con interfaces map[string]interface{} y (no vacías) con tipos de funciones y valores de métodos. Nadie lo haría porque es más difícil y menos seguro.

Todas esas características agregan algo al lenguaje, pero su ausencia podría solucionarse (dolorosamente y bajo protesta).

Así que supongo que la barra no es para demostrar un programa que ni siquiera se puede aproximar en Go, sino más bien para demostrar un programa que se escribe mucho más fácil y limpiamente en Go con sindicatos que sin ellos. Entonces, lo que queda por mostrar es eso.

@jimmyfrasche

No veo ninguna razón por la que el tipo de unión deba tener campos con nombre. Los nombres solo son útiles si desea distinguir entre diferentes campos del mismo tipo. Sin embargo, una unión nunca debe tener varios campos del mismo tipo, ya que eso no tiene sentido. Por lo tanto, tener nombres es simplemente redundante y genera confusión y más escritura.

En esencia, su tipo de unión debería verse así:

union {
    struct{}
    int
    err
}

Los tipos en sí mismos proporcionarán identificadores únicos que se pueden usar para asignar a una unión, bastante similar a la forma en que los tipos incrustados en estructuras se usan como identificadores.

Sin embargo, para que las asignaciones explícitas funcionen, no se puede crear un tipo de unión especificando un tipo sin nombre como miembro, ya que la sintaxis permitiría tal expresión. Por ejemplo, v.struct{} = struct{}

Por lo tanto, los tipos como estructuras sin formato, uniones y funciones deben nombrarse de antemano para formar parte de una unión y ser asignables. Con esto en mente, una unión anidada no será nada especial, ya que la unión interna será solo otro tipo de miembro.

Ahora, no estoy seguro de qué sintaxis sería mejor.

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

Lo anterior parece más irrisorio, pero es un poco detallado para ese tipo.

Por otro lado, type1 | package1.type2 puede no parecerse a su tipo de go habitual, sin embargo, obtiene el beneficio de usar el '|' símbolo, que se reconoce predominantemente como OR. Y reduce la verbosidad sin ser críptico.

@urandom si no tiene "nombres de etiqueta" pero permite interfaces, las sumas se colapsan en interface{} con comprobaciones adicionales. Dejan de ser tipos de suma, ya que puedes poner una cosa pero sacarla de varias formas. Los nombres de las etiquetas les permiten ser tipos de suma y contener interfaces sin ambigüedad.

Sin embargo, los nombres de las etiquetas reparan mucho más que el problema de la interfaz {}. Hacen que el tipo sea mucho menos mágico y permiten que todo sea gloriosamente explícito sin tener que inventar un montón de tipos solo para diferenciar. Puede tener asignaciones explícitas y escribir literales, como señala.

El hecho de que pueda asignar más de una etiqueta a un tipo es una característica. Considere un tipo para medir cuántos éxitos o fracasos han sucedido seguidos (1 éxito cancela N fracasos y viceversa)

type Counter union {
  Successes, Failures uint 
}

sin los nombres de etiqueta que necesitarías

type (
  Success uint
  Failures uint
  Counter Successes | Failures
)

y la asignación se vería como c = Successes(1) lugar de c.Successes = 1 . No ganas mucho.

Otro ejemplo es un tipo que representa una falla local o remota. Con nombres de etiquetas, esto es fácil de modelar:

type Failure union {
  Local, Remote error
}

La procedencia del error se puede especificar con su nombre de etiqueta, independientemente de cuál sea el error real. Sin nombres de etiquetas, necesitaría type Local { error } y lo mismo para el control remoto, incluso si permite interfaces directamente en la suma.

Los nombres de las etiquetas están creando una especie de alias especial ni tipos con nombre localmente en la unión. Tener múltiples "etiquetas" con tipos idénticos no es exclusivo de mi propuesta: es lo que hacen todos los lenguajes funcionales (que yo conozca).

La capacidad de crear etiquetas no exportadas para tipos exportados y viceversa también es un giro interesante.

Además, tener aserciones de tipo y etiqueta separadas permite cierto código interesante, como poder promover un método compartido a la unión con un contenedor de una línea.

Parece que resuelve más problemas de los que causa y hace que todo encaje mucho mejor. Honestamente, no estaba tan seguro cuando lo escribí, pero estoy cada vez más convencido de que es la única forma de resolver todos los problemas con la integración de sumas en Go.

Para ampliar eso un poco, el ejemplo motivador para mí fue de @rogpeppe io.Reader | io.ReadCloser . Permitiendo interfaces sin etiquetas, este es del mismo tipo que io.Reader .

Puede colocar un ReadCloser y sacarlo como Reader. Pierdes la A | B significa propiedad A o B de tipos de suma.

Si necesita ser específico acerca de cómo manejar un io.ReadCloser como io.Reader , necesita crear estructuras contenedoras como señaló @bcmills , type Reader struct { io.Reader } etc. y que el tipo sea Reader | ReadCloser .

Incluso si limita las sumas a interfaces con conjuntos de métodos disjuntos, todavía tiene este problema porque un tipo puede implementar más de una de esas interfaces. Se pierde el carácter explícito de los tipos de suma: no son "A o B": son "A o B o, a veces, lo que te apetezca".

Peor aún, si esos tipos son de otros paquetes, de repente pueden comportarse de manera diferente después de una actualización, incluso si tuvo mucho cuidado al construir su programa para que A nunca sea tratado de la misma manera que B.

Originalmente exploré la posibilidad de no permitir interfaces para resolver el problema. ¡Nadie estaba contento con eso! Pero tampoco eliminó problemas como a = b significan cosas diferentes dependiendo de los tipos de ayb, con lo que no me siento cómodo. También tenía que haber muchas reglas sobre qué tipo se elige en la selección cuando entra en juego la asignación de tipos. Es mucha magia.

Agrega etiquetas y todo desaparece.

Con union { R io.Reader | RC io.ReadCloser } puedes decir explícitamente que quiero que este ReadCloser sea considerado como un lector si eso es lo que tiene sentido. No se necesitan tipos de envoltorios. Está implícito en la definición. Independientemente del tipo de etiqueta, es una etiqueta o la otra.

La desventaja es que, si obtiene un io.Reader de otro lugar, diga una llamada de recepción o función de chan, y podría ser un io.ReadCloser y debe asignarlo a la etiqueta adecuada que debe escribir aser en io. LeerCloser y probar. Pero eso hace que la intención del programa sea mucho más clara: exactamente lo que quiere decir está en el código.

Además, debido a que las aserciones de etiquetas son diferentes de las aserciones de tipo, si realmente no te importa y solo quieres un io.Reader independientemente, puedes usar una aserción de tipo para sacar eso, independientemente de la etiqueta.

Esta es una transliteración del mejor esfuerzo de un ejemplo de juguete en Ir sin uniones / sumas / etc. Probablemente no sea el mejor ejemplo, pero es el que usé para ver cómo se vería.

Muestra la semántica de una manera más operativa, que probablemente será más fácil de entender que algunos puntos concisos en una propuesta.

Hay bastante texto repetitivo en la transliteración, por lo que generalmente solo escribí la primera instancia de varios métodos con una nota sobre la repetición.

En Go con propuesta sindical:

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

Transcrito al Go actual:

(se incluyen notas sobre las diferencias entre la transliteración y lo 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

Dado que la unión contiene etiquetas que pueden tener el mismo tipo, ¿no sería más adecuada la siguiente sintaxis:

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 mi modo de ver, cuando se usa con un interruptor, una unión es bastante similar a tipos como int o string. La principal diferencia es que solo hay 'valores' finitos que se le pueden asignar, a diferencia de los tipos anteriores, y el cambio en sí es exhaustivo. Por lo tanto, en este caso, realmente no veo la necesidad de una sintaxis especial, lo que reduce el trabajo mental del desarrollador.

Además, según esta propuesta, dicho código sería 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 Elegí una sintaxis para reflejar la semántica usando analogías con la sintaxis Go existente siempre que sea posible.

Con los tipos de interfaz que puede hacer

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

Eso está bien e inequívoco, ya que no importa cuál sea el tipo de someValue siempre que se cumpla el contrato.

Cuando introduce etiquetas † en uniones, a veces puede resultar ambiguo. La asignación mágica solo sería válida en ciertos casos. Carcasa especial que solo te ayuda a tener que ser explícito a veces.

No veo ningún sentido en poder omitir un paso a veces, especialmente cuando un cambio de código puede invalidar fácilmente ese caso especial y luego tienes que volver atrás y actualizar todo el código de todos modos. Para usar su ejemplo de Foo / Bar, si C int se agrega a Foo entonces Bar(1) tiene que cambiar, pero no Bar("hello world") . Lo complica todo para guardar algunas pulsaciones de teclas en situaciones que pueden no ser tan comunes y hace que los conceptos sean más difíciles de entender porque a veces se ven así y otras veces se ven así. ¡Solo consulte este práctico diagrama de flujo para ver cuál se aplica a usted!

† Desearía tener un mejor nombre para esos. Ya existen etiquetas de estructura. Los habría llamado etiquetas, pero Go también las tiene. Llamarlos campos parece tanto más apropiado como más confuso. Si alguien quiere montar en bicicleta, este podría usar un abrigo nuevo.

En cierto sentido, las uniones etiquetadas son más similares a una estructura que a una interfaz. Son un tipo especial de estructura que solo puede tener un campo configurado a la vez. Visto así, su ejemplo de Foo / Bar sería como decir esto:

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

Si bien no es ambiguo en este caso, no creo que sea una buena idea.

También en la propuesta se permite Bar(Foo{1}) cuando no es ambiguo si realmente desea guardar las pulsaciones de teclas. También puede tener punteros a uniones, por lo que la sintaxis literal compuesta sigue siendo necesaria para &Foo{"hello world"} .

Dicho esto, los sindicatos tienen una similitud con las interfaces en el sentido de que tienen una etiqueta dinámica cuyo "campo" está configurado actualmente.

El switch v := u.[type] {... refleja muy bien el switch v := i.(type) {... para las interfaces al mismo tiempo que permite cambios de tipo y aserciones directamente en los valores de unión. Tal vez debería ser u.[union] para que sea más fácil de detectar, pero de cualquier manera la sintaxis no es tan pesada y está claro lo que significa.

Podrías hacer el mismo argumento de que el .(type) es innecesario, pero cuando ves que siempre sabes exactamente lo que está sucediendo y eso lo justifica completamente, en mi opinión.

Ese fue mi razonamiento detrás de estas elecciones.

@jimmyfrasche
La sintaxis del interruptor me parece un poco contraintuitiva, incluso después de tus explicaciones. Con una interfaz, switch v := i.(type) {... cambia a través de los tipos posibles, como se enumeran en los casos de los interruptores y se indican mediante .(type) .
Sin embargo, con una unión, un interruptor no está cambiando entre posibles tipos, sino valores. Cada caso representa un valor posible diferente, donde los valores pueden, de hecho, compartir el mismo tipo. Esto es más similar a las cadenas y los conmutadores int, donde los casos también enumeran valores, y su sintaxis es simple switch v := u {... . A partir de eso, me parece más natural que cambiar los valores de una unión sea switch v := u { ... , ya que los casos son similares, pero más restrictivos, que los casos para ints y strings.

@urandom ese es un muy buen punto sobre la sintaxis. La verdad es que es un vestigio de mi propuesta anterior sin etiquetas, así que era el tipo de entonces. Lo copié a ciegas sin pensar. Gracias por mencionarlo.

switch u {... funcionaría, pero el problema con switch v := u {... es que se parece demasiado a switch v := f(); v {... (lo que haría más difícil la notificación de errores, no está claro cuál fue la intención).

Si se cambió el nombre de la palabra clave union a pick como sugirió @as, entonces el cambio de etiqueta podría escribirse como switch u.[pick] {... o switch v := u.[pick] {... que mantiene la simetría con un cambio de tipo pero pierde la confusión y se ve bastante bien.

Incluso si la implementación está activando un int, todavía hay una desestructuración implícita de la selección en una etiqueta dinámica y un valor almacenado, que creo que debería ser explícito, independientemente de las reglas gramaticales

ya sabes, simplemente llamar a los campos de etiquetas y hacer que sea un campo de afirmación y un cambio de campo tiene mucho sentido.

editar: eso haría que el uso de reflexionar con picos sea incómodo, aunque

[Perdón por la demora en la respuesta, estaba de vacaciones]

@ianlancetaylor escribió:

Algo que no veo en sus propuestas es por qué deberíamos hacer esto. Hay una clara desventaja de agregar un nuevo tipo de tipo: es un nuevo concepto que todos los que se inclinan por Go tendrán que aprender. ¿Cuál es la ventaja compensatoria? En particular, ¿qué nos da el nuevo tipo de tipo que no obtenemos de los tipos de interfaz?

Hay dos ventajas principales que veo. La primera es una ventaja del idioma; el segundo es una ventaja de rendimiento.

  • al procesar mensajes, particularmente cuando se leen de un proceso concurrente, es muy útil poder conocer el conjunto completo de mensajes que se pueden recibir, porque cada mensaje puede venir con requisitos de protocolo asociados. Para un protocolo dado, la cantidad de posibles tipos de mensajes puede ser muy pequeña, pero cuando usamos una interfaz abierta para representar los mensajes, esa invariante no está clara. A menudo, las personas utilizarán un canal diferente para cada tipo de mensaje para evitar esto, pero eso tiene sus propios costos.

  • hay ocasiones en las que hay una pequeña cantidad de posibles tipos de mensajes conocidos, ninguno de los cuales contiene punteros. Si usamos una interfaz abierta para representarlos, necesitamos incurrir en una asignación para hacer los valores de la interfaz. El uso de un tipo que restringe los posibles tipos de mensajes significa que se pueden evitar y, por lo tanto, aliviar la presión del GC y aumentar la localidad de la caché.

Un problema particular para mí que los tipos de suma podrían resolver es godoc. Tome ast.Spec por ejemplo: https://golang.org/pkg/go/ast/#Spec

Muchos paquetes enumeran manualmente los posibles tipos subyacentes de un tipo de interfaz con nombre, de modo que un usuario pueda hacerse una idea rápidamente sin tener que mirar el código o depender de sufijos o prefijos de nombres.

Si el lenguaje ya conoce todos los valores posibles, esto podría automatizarse en godoc al igual que los tipos de enumeración con iotas. En realidad, también podrían vincularse a los tipos, en lugar de ser solo texto sin formato.

Editar: otro ejemplo: https://github.com/mvdan/sh/commit/ebbfda50dfe167bee741460a4491ffec1006bdef

@mvdan es un punto excelente y práctico para mejorar la historia en Go1 sin ningún cambio de idioma. ¿Puede presentar un problema por separado para eso y hacer referencia a este?

Lo siento, ¿te refieres solo a enlaces a otros nombres dentro de la página de godoc, pero aún los enumeras manualmente?

Lo siento, debería haber sido más claro.

Me refiero a una solicitud de función para manejar automáticamente tipos que implementan interfaces definidas en el paquete actual en godoc.

(Creo que hay una solicitud de función en algún lugar para vincular nombres enumerados manualmente, pero no tengo tiempo para buscarla en este momento).

No deseo ocuparme de este hilo (que ya es muy largo), por lo que he creado un problema separado, ver más arriba.

@Merovius Estoy respondiendo a https://github.com/golang/go/issues/19814#issuecomment -298833986 en este número, ya que las cosas de AST se aplican más a los tipos de suma que a las enumeraciones. Disculpas por meterte en un problema diferente.

Primero, me gustaría reiterar que no estoy seguro de si los tipos de suma pertenecen a Go. Todavía tengo que convencerme de que definitivamente no pertenecen. Estoy trabajando bajo el supuesto de que lo hacen para explorar la idea y ver si encajan. Sin embargo, estoy dispuesto a estar convencido de cualquier manera.

En segundo lugar, mencionaste la reparación gradual del código en tu comentario. Agregar un nuevo término a un tipo de suma es, por definición, un cambio radical, a la par con agregar un nuevo método a una interfaz o eliminar un campo de una estructura. Pero este es el comportamiento correcto y deseado.

Consideremos el ejemplo de un AST, implementado con una interfaz de nodo, que agrega un nuevo tipo de nodo. Digamos que el AST está definido en un proyecto externo y lo está importando en un paquete en su proyecto, que recorre el AST.

Hay varios casos:

  1. Su código espera recorrer cada nodo:
    1.1. No tiene una declaración predeterminada, su código es silenciosamente incorrecto
    1.2. Tiene una declaración predeterminada con pánico, su código falla en tiempo de ejecución en lugar de tiempo de compilación (las pruebas no ayudan porque solo conocen los nodos que existían cuando escribió las pruebas)
  2. Su código solo inspecciona un subconjunto de tipos de nodos:
    2.1. Este nuevo tipo de nodo no habría estado en el subconjunto de todos modos.
    2.1.1. Siempre que este nuevo nodo nunca contenga ninguno de los nodos que le interesan, todo funciona
    2.1.2. De lo contrario, se encuentra en la misma situación que si su código esperara recorrer todos los nodos
    2.2. Este nuevo tipo de nodo habría estado en el subconjunto que le interesa, si lo hubiera sabido.

Con AST basado en interfaz, solo el caso 2.1.1 funciona correctamente. Esto es tanto una coincidencia como cualquier otra cosa. La reparación gradual del código no funciona. El AST tiene que mejorar su versión y su código necesita mejorar su versión.

Un linter exhaustivo ayudaría, pero dado que el linter no puede examinar todos los tipos de interfaz, es necesario que se le diga de alguna manera que se debe verificar una interfaz en particular. Eso significa un comentario en la fuente o algún tipo de archivo de configuración en su repositorio. Si se trata de un comentario en la fuente, dado que, por definición, el AST se define en un proyecto separado, está a merced de ese proyecto para etiquetar la interfaz para una verificación exhaustiva. Esto solo funciona a escala si hay un solo linter exhaustivo con el que toda la comunidad está de acuerdo y que siempre usa.

Con un AST basado en sumas, aún necesita usar el control de versiones. La única diferencia en este caso es que el linter exhaustivo está integrado en el compilador.

Ninguno de los dos ayuda con 2.2, pero ¿qué podría?

Hay un caso más simple, adyacente a AST, donde los tipos de suma serían útiles: tokens. Digamos que está escribiendo un lexer para una calculadora más simple. Hay tokens como * que no tienen ningún valor asociado y tokens como Var que tienen una cadena que representa el nombre, y tokens como Val que contienen un float64 .

Podría implementar esto con interfaces, pero sería tedioso. Sin embargo, probablemente harías algo como esto:

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
}

Un linter exhaustivo en enumeraciones basadas en iota podría garantizar que nunca se use un Tipo ilegal, pero no funcionaría demasiado bien contra alguien que asigne un Nombre cuando Tipo == Veces o que use Número cuando Tipo == Var. A medida que aumenta el número y el tipo de tokens, solo empeora. Realmente, lo mejor que puede hacer aquí es agregar un método, Valid() error , que verifique todas las restricciones y un montón de documentación que explique cuándo puede hacer qué.

Un tipo de suma codifica fácilmente todas esas restricciones y la definición sería toda la documentación necesaria. Agregar un nuevo tipo de token sería un cambio importante, pero todo lo que dije sobre AST todavía se aplica aquí.

Creo que se necesitan más herramientas. Simplemente no estoy convencido de que sea suficiente.

@jimmyfrasche

En segundo lugar, mencionaste la reparación gradual del código en tu comentario. Agregar un nuevo término a un tipo de suma es, por definición, un cambio radical, a la par con agregar un nuevo método a una interfaz o eliminar un campo de una estructura.

No, no está a la par. Puede hacer ambos cambios en un modelo de reparación gradual (para interfaces: 1. Agregar nuevo método a todas las implementaciones, 2. Agregar método a la interfaz. Para campos de estructura: 1. Eliminar todos los usos de campo, 2. Eliminar campo). Agregar un caso en un tipo de suma no puede funcionar en un modelo de reparación gradual; si lo agrega, haga la biblioteca primero, rompería a todos los usuarios, ya que ya no verifican exhaustivamente, pero no puede agregarlo a los usuarios primero, porque el nuevo caso aún no existe. Lo mismo ocurre con la eliminación.

No se trata de si es un cambio rotundo o no, se trata de si se trata de un cambio rotundo que se puede orquestar con una interrupción mínima.

Pero este es el comportamiento correcto y deseado.

Exactamente. Los tipos de suma, por su propia definición y por todas las razones por las que la gente los quiere, son fundamentalmente incompatibles con la idea de la reparación gradual del código.

Con AST basado en interfaz, solo el caso 2.1.1 funciona correctamente.

No, también funciona correctamente en el caso 1.2 (fallar en tiempo de ejecución por una gramática no reconocida está perfectamente bien. Sin embargo, probablemente no querría entrar en pánico, sino devolver un error) y también en muchos casos de 2.1. El resto es un problema fundamental con la actualización del software; si agrega una nueva característica a una biblioteca, los usuarios de su biblioteca deben cambiar el código para hacer uso de ella. Sin embargo, no significa que su software sea incorrecto hasta que lo haga.

El AST tiene que mejorar su versión y su código necesita mejorar su versión.

No veo en absoluto cómo se desprende esto de lo que está diciendo. Para mí, decir "esta nueva gramática no funcionará con todas las herramientas todavía, pero está disponible para el compilador" está bien. Al igual que "si ejecuta esta herramienta en esta nueva gramática, fallará en tiempo de ejecución" está bien. En el peor de los casos, esto solo agrega otro paso al proceso de reparación gradual: a) Agregue el nuevo nodo al paquete AST y al analizador. b) Arregle las herramientas usando el paquete AST para aprovechar el nuevo nodo. c) Actualice el código para usar el nuevo nodo. Sí, el nuevo nodo solo se podrá utilizar después de que se hayan realizado a) yb); pero en cada paso de este proceso, sin roturas, todo se compilará y funcionará correctamente.

No estoy diciendo que esté automáticamente bien en un mundo de reparación gradual de código y sin comprobaciones exhaustivas del compilador. Aún requerirá una planificación y ejecución cuidadosas, probablemente aún romperá las dependencias inversas no mantenidas y es posible que aún haya cambios que quizás no pueda hacer en absoluto (aunque no puedo pensar en ninguno). Pero al menos a) hay una ruta de actualización gradual yb) la decisión de si esto debería romper su herramienta en tiempo de ejecución, o no, depende del autor de la herramienta. Pueden decidir qué hacer en un caso desconocido.

Un linter exhaustivo ayudaría, pero dado que el linter no puede examinar todos los tipos de interfaz, es necesario que se le diga de alguna manera que se debe verificar una interfaz en particular.

¿Por qué? Yo diría que está bien que switchlint ™ se queje de cualquier tipo de cambio sin un caso predeterminado; después de todo, esperaría que el código funcione con cualquier definición de interfaz, por lo que no tener un código en su lugar para trabajar con implementaciones desconocidas es probablemente un problema de todos modos. Sí, hay excepciones a esta regla, pero las excepciones ya se pueden ignorar manualmente.

Probablemente estaría más de acuerdo con hacer cumplir "cada cambio de tipo debería requerir un caso predeterminado, incluso si está vacío" en el compilador, que con los tipos de suma reales. Permitiría y obligaría a las personas a tomar la decisión de lo que debería hacer su código cuando se enfrentaran a una elección desconocida.

Podría implementar esto con interfaces, pero sería tedioso.

encogerse de hombros es un esfuerzo único en un caso que rara vez surge. Me parece bien.

Y FWIW, actualmente solo estoy argumentando en contra de la noción de verificación exhaustiva de tipos de suma. Todavía no tengo opiniones sólidas sobre la conveniencia adicional de decir "cualquiera de estos tipos definidos estructuralmente".

@Merovius Voy a tener que pensar más en sus excelentes puntos sobre la reparación gradual del código. Mientras tanto:

controles de exhaustividad

Actualmente solo estoy argumentando en contra de la noción de verificación exhaustiva de tipos de suma.

Puede excluirse explícitamente de las comprobaciones exhaustivas con un caso predeterminado (bueno, de manera efectiva: el valor predeterminado lo hace exhaustivo al agregar un caso que cubra "cualquier otra cosa, sea lo que sea"). Aún tiene una opción, pero debe hacerlo explícitamente.

Yo diría que está bien que switchlint ™ se queje de cualquier tipo de cambio sin un caso predeterminado; después de todo, esperaría que el código funcione con cualquier definición de interfaz, por lo que no tener un código en su lugar para trabajar con implementaciones desconocidas es probablemente un problema de todos modos. Sí, hay excepciones a esta regla, pero las excepciones ya se pueden ignorar manualmente.

Esa es una idea interesante. Si bien llegaría a tipos de suma simulados con interfaz y enumeraciones simuladas con const / iota, no le dice que se perdió un caso conocido, solo que no manejó el caso desconocido. Independientemente, parece ruidoso. Considerar:

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

Eso es exhaustivo si n es integral (para los flotantes falta n != n ) pero sin codificar mucha información sobre los tipos, probablemente sea más fácil marcarlo como falta por defecto. Por algo como:

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

incluso si p[i] forman una relación de equivalencia en los tipos de a y b , no podrá probar eso, por lo que debe marcar el interruptor como faltante por defecto case, lo que significa una forma de silenciarlo con un manifiesto, una anotación en la fuente, un script de envoltura para egrep -v fuera de la lista blanca, o un valor predeterminado innecesario en el interruptor que implica falsamente que el p[i] no son exhaustivos.

En cualquier caso, sería trivial implementarlo si se toma la ruta de "siempre quejarse de que no hay incumplimiento en todas las circunstancias". Sería interesante hacerlo y ejecutarlo en go-corpus y ver qué tan ruidoso y / o útil es en la práctica.

tokens

Implementaciones de token alternativas:

//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
}

Eso elimina la posibilidad de definir un estado de token ilegal donde algo tiene tanto una cadena como un valor numérico, pero no impide la creación de un StringToken con un tipo que debería ser SimpleToken o viceversa. al revés.

Para hacer eso con interfaces, necesita definir un tipo por token ( type Plus struct{} , type Mul struct{} , etc.) y la mayoría de las definiciones son exactamente iguales para el nombre del tipo. Esfuerzo único o no, eso es mucho trabajo (aunque es muy adecuado para la generación de código en este caso).

Supongo que podría tener una "jerarquía" de interfaces de tokens para particionar los tipos de tokens en función de los valores permitidos: (suponiendo en este ejemplo que hay más de un tipo de token que puede contener un número o cadena, 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
}

Independientemente, significa que cada token requiere una deferencia de puntero para acceder a su valor, a diferencia de la estructura o el tipo de suma que solo requieren punteros cuando hay cadenas involucradas. Entonces, con los linters apropiados y las mejoras en godoc, la gran ventaja para los tipos de suma en este caso está relacionada con minimizar las asignaciones mientras se rechazan los estados ilegales y la cantidad de escritura (en el sentido del teclado), lo cual no parece poco importante.

Puede excluirse explícitamente de las comprobaciones exhaustivas con un caso predeterminado (bueno, de manera efectiva: el valor predeterminado lo hace exhaustivo al agregar un caso que cubra "cualquier otra cosa, sea lo que sea"). Aún tiene una opción, pero debe hacerlo explícitamente.

Entonces, parece que de cualquier manera, ambos tendremos la opción de optar por participar o no en la verificación exhaustiva :)

no le dice que se perdió un caso conocido, solo que no manejó el caso desconocido.

En efecto, creo que el compilador ya hace un análisis de todo el programa para determinar qué tipos concretos se utilizan en qué interfaces creo . Al menos esperaría que, al menos para las aserciones de tipo que no sean de interfaz (es decir, las aserciones de tipo que no se asientan en un tipo de interfaz, sino en un tipo concreto), genere las tablas de funciones utilizadas en las interfaces en el momento de la compilación.
Pero, honestamente, esto se argumenta desde los primeros principios, no tengo idea sobre la implementación real.

En cualquier caso, debería ser bastante fácil, a) enumerar cualquier tipo concreto definido en un programa completo yb) para cualquier cambio de tipo, filtrarlos para ver si implementan esa interfaz. Si usa algo como esto , terminará con una lista confiable. Creo.

No estoy 100% convencido de que se pueda escribir una herramienta que sea tan confiable como declarar explícitamente las opciones, pero estoy convencido de que podrías cubrir el 90% de los casos y definitivamente podrías escribir una herramienta que haga esto fuera de el compilador, dadas las anotaciones correctas (es decir, hacer de los tipos de suma un comentario de tipo pragma, no un tipo real). Sin duda, no es una gran solución.

Independientemente, parece ruidoso. Considerar:

Creo que esto es injusto. Los casos que menciona no tienen nada que ver con los tipos de suma. Si tuviera que escribir una herramienta de este tipo, la restringiría a interruptores de tipo e interruptores con una expresión, ya que esa parece ser la forma en que también se manejarían los tipos de suma.

Implementaciones de token alternativas:

¿Por qué no un método de marcador? No necesita un campo de tipo, lo obtiene gratis de la representación de la interfaz. Si le preocupa repetir el método del marcador una y otra vez; define una estructura no exportada {}, dale ese método de marcador e incrústalo en cada implementación, sin costo adicional y menos escritura por opción que tu método.

Independientemente, significa que cada token requiere una deferencia de puntero para acceder a su valor

Si. Es un costo real, pero no creo que supere básicamente cualquier otro argumento.

Creo que esto es injusto.

Eso es cierto.

Escribí una versión rápida y sucia y la ejecuté en stdlib. Verificar cualquier declaración de cambio tuvo 1956 visitas, restringiéndola para omitir el formulario switch { redujo ese recuento a 1677. No he inspeccionado ninguna de esas ubicaciones para ver si el resultado es significativo.

https://github.com/jimmyfrasche/switchlint

Ciertamente, hay mucho margen de mejora. No es terriblemente sofisticado. Solicitudes de extracción son bienvenidas.

(Responderé al resto más tarde)

editar: formato de marcado incorrecto

Creo que este es un resumen (bastante sesgado) de todo hasta ahora (y asumiendo narcisistamente mi segunda propuesta)

Pros

  • conciso, fácil de escribir una serie de restricciones de forma sucinta de manera autodocumentada
  • mejor control de las asignaciones
  • más fácil de optimizar (todas las posibilidades conocidas por el compilador)
  • control exhaustivo (cuando lo desee, puede optar por no participar)

Contras

  • cualquier cambio en los miembros de un tipo de suma es un cambio rotundo, que no permite la reparación gradual del código a menos que todos los paquetes externos opten por no participar en las comprobaciones exhaustivas
  • una cosa más en el lenguaje para aprender, cierta superposición conceptual con características existentes
  • El recolector de basura debe saber qué miembros son indicadores.
  • incómodo para sumas de la forma 1 + 1 + ⋯ + 1

Alternativas

  • iota "enum" para sumas de la forma 1 + 1 + ⋯ + 1
  • interfaces con un método de etiqueta no exportado para sumas más complicadas (posiblemente generadas)
  • o estructura con una enumeración iota y reglas extralingüísticas sobre qué campos se establecen dependiendo del valor de las enumeraciones

A pesar de todo

  • mejores herramientas, siempre mejores herramientas

Para una reparación gradual, y esa es una de las más importantes, creo que la única opción es que los paquetes externos opten por no participar en las comprobaciones exhaustivas. Esto implica que debe ser legal tener un caso predeterminado "innecesario" que solo se ocupe de la prueba futura, aunque de lo contrario, coincida con todo lo demás. Creo que eso es implícitamente cierto ahora, y si no es lo suficientemente fácil de especificar.

Podría haber un anuncio de un mantenedor del paquete que "oye, vamos a agregar un nuevo miembro a este tipo de suma en la próxima versión, asegúrate de que puedes manejarlo" y luego una herramienta de switchlint podría encontrar cualquier caso que necesite ser excluido.

No es tan sencillo como otros casos, pero sigue siendo bastante factible.

Al escribir un programa que utiliza un tipo de suma definido externamente, puede comentar el valor predeterminado para asegurarse de no perder ningún caso conocido y luego descomentarlo antes de confirmarlo. O podría haber una herramienta para hacerle saber que el valor predeterminado es "innecesario", lo que le indica que tiene todo lo conocido y está preparado para el futuro contra lo desconocido.

Digamos que queremos optar por la verificación exhaustiva con un linter cuando usamos tipos de interfaz que simulan tipos de suma, independientemente del paquete en el que estén definidos.

@Merovius tu betterSumType() BetterSumType es muy bueno, pero significa que los cambios tienen que suceder en el paquete de definición (o expones 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
}

y también pelusa que se llama siempre).

¿Cuáles son los criterios necesarios para comprobar que todos los interruptores de un programa son exhaustivos?

No puede ser la interfaz vacía, porque entonces todo está en juego. Entonces necesita al menos un método.

Si la interfaz no tiene métodos no exportados, cualquier tipo podría implementarla, por lo que la exhaustividad dependería de todos los paquetes del gráfico de llamadas de cada conmutador. Es posible importar un paquete, implementar su interfaz y luego enviar ese valor a una de las funciones del paquete; por lo que un cambio en esa función no podría ser exhaustivo sin crear un ciclo de importación. Entonces necesita al menos un método no exportado. (Esto subsume el criterio anterior).

La incrustación estropearía la propiedad que estamos buscando, por lo que debemos asegurarnos de que ninguno de los importadores del paquete incruste la interfaz ni ninguno de los tipos que la implementan en ningún momento. Un linter realmente elegante puede decir que a veces la incrustación está bien si nunca llamamos a una determinada función que crea un valor incrustado o si ninguna de las interfaces incrustadas "escapa" del límite de la API del paquete.

Para ser minuciosos, debemos verificar que el valor cero de la interfaz nunca se transmita o hacer cumplir una verificación exhaustiva del cambio case nil también. (Lo último es más fácil, pero se prefiere lo primero, ya que incluir cero convierte una suma "tipo A o tipo B o tipo C" en una suma "cero o tipo A o tipo B o tipo C").

Digamos que tenemos un linter, con todas esas habilidades, incluso las opcionales, que pueden verificar esta semántica para cualquier árbol de importaciones y cualquier interfaz dentro de ese árbol.

Ahora digamos que tenemos un proyecto con una dependencia D. Queremos asegurarnos de que una interfaz definida en uno de los paquetes de D sea exhaustiva en nuestro proyecto. Digamos que sí.

Ahora, necesitamos agregar una nueva dependencia a nuestro proyecto D ′. Si D ′ importa el paquete en D que definió el tipo de interfaz en cuestión pero no usa este linter, puede destruir fácilmente las invariantes que debemos mantener para que podamos usar interruptores exhaustivos.

Para el caso, digamos que D acaba de pasar el linter casualmente no porque el encargado de mantenimiento lo ejecute. Una actualización a D podría destruir las invariantes con la misma facilidad que D ′.

Incluso si el linter puede decir "ahora mismo esto es 100% exhaustivo", eso puede cambiar sin que hagamos nada.

Un verificador exhaustivo para "enumeraciones iota" parece más fácil.

Para todos type t u donde u es integral y t se usa como const con valores especificados individualmente o iota tales que el cero El valor de u se incluye entre estas constantes.

Notas:

  • Los valores duplicados pueden tratarse como alias e ignorarse en este análisis. Asumiremos que todas las constantes nombradas tienen valores distintos.
  • 1 << iota puede tratarse como un conjunto de potencias, creo que al menos la mayor parte del tiempo, pero probablemente requeriría condiciones adicionales, especialmente alrededor del complemento bit a bit. Por el momento, no se considerarán

Para abreviar, llamemos min(t) la constante tal que para cualquier otra constante, C , min(t) <= C , y, de manera similar, llamemos max(t) la constante tal que para cualquier otra constante, C , C <= max(t) .

Para garantizar que t se utilice de forma exhaustiva, debemos asegurarnos de que

  • los valores de t son siempre las constantes nombradas (o 0 en ciertas posiciones idiomáticas, como la invocación de funciones)
  • No hay comparaciones de desigualdad de un valor de t , v , fuera de min(t) <= v <= max(t)
  • los valores de t nunca se utilizan en operaciones aritméticas + , / , etc. Una posible excepción podría ser cuando el resultado se fija entre min(t) y max(t) inmediatamente después, pero eso podría ser difícil de detectar en general, por lo que puede requerir una anotación en los comentarios y probablemente debería restringirse al paquete que define t .
  • Los conmutadores contienen todas las constantes de t o un caso predeterminado.

Esto todavía requiere verificar todos los paquetes en el árbol de importación y se puede invalidar con la misma facilidad, aunque es menos probable que se invalide en código idiomático.

Tengo entendido que esto, similar a los alias de tipo, no interrumpirá los cambios, así que ¿por qué esperar para Go 2?

Los alias de tipo no introducen una nueva palabra clave, lo cual es un cambio definitivo. También parece haber una moratoria incluso en cambios de idioma menores y esto sería un cambio importante . Incluso el simple hecho de adaptar todas las rutinas marshal / unmarshal para manejar valores de suma reflejados sería una gran prueba.

Los alias de tipo están solucionando un problema para el que no había una solución alternativa. Los tipos de suma proporcionan un beneficio en la seguridad de los tipos, pero no es un obstáculo no tenerlos.

Solo un punto (menor) a favor de algo como la propuesta original de @rogpeppe . En el paquete http , está el tipo de interfaz Handler y un tipo de función que lo implementa, HandlerFunc . En este momento, para pasar una función a http.Handle , tienes que convertirla explícitamente en HandlerFunc . Si http.Handle lugar aceptó un argumento de tipo HandlerFunc | Handler , podría aceptar cualquier función / cierre asignable a HandlerFunc directamente. La unión sirve efectivamente como una sugerencia de tipo que le dice al compilador cómo los valores con tipos sin nombre se pueden convertir al tipo de interfaz. Dado que HandlerFunc implementa Handler , el tipo de unión se comportaría exactamente como Handler caso contrario.

@griesemer en respuesta a su comentario en el hilo de enumeración, https://github.com/golang/go/issues/19814#issuecomment -322752526, creo que mi propuesta anteriormente en este hilo https://github.com/golang/ go / issues / 19412 # issuecomment -289588569 aborda la cuestión de cómo los tipos de suma ("enumeraciones de estilo rápido") tendrían que funcionar en Go. Por mucho que me gustaría que, no sé si serían un complemento necesario para ir, pero yo creo que si se añade que tendrían que ver / operar mucho a eso.

Esa publicación no está completa y hay aclaraciones a lo largo de este hilo, antes y después, pero no me importa reiterar esos puntos o resumir, ya que este hilo es bastante largo.

Si tiene un tipo de suma simulado por una interfaz con una etiqueta de tipo y absolutamente no puede evitarlo incrustando, esta es la mejor defensa que he encontrado: https://play.golang.org/p/FqdKfFojp-

@jimmyfrasche Escribí esto hace un tiempo.

Otro posible enfoque es este: https://play.golang.org/p/p2tFm984S8

@rogpeppe, si vas a usar la reflexión, ¿por qué no usar la reflexión?

Escribí una versión revisada de mi segunda propuesta basada en los comentarios aquí y en otros números.

En particular, he eliminado la comprobación de exhaustividad. Sin embargo, es trivial escribir un verificador de exhaustividad externo para la siguiente propuesta, aunque no creo que se pueda escribir uno para otros tipos de Go utilizados para simular un tipo de suma.

Editar: eliminé la capacidad de escribir aserción en el valor dinámico de un valor de selección. Es demasiado mágico y la razón para permitirlo también es servida por la generación de código.

Edit2: aclaró cómo funcionan los nombres de campo con aserciones y conmutadores cuando la selección se define en otro paquete.

Edit3: incrustación restringida y nombres de campo implícitos aclarados

Edit4: aclarar el valor predeterminado en el interruptor

Tipos de selección

Una selección es un tipo compuesto sintácticamente similar a una estructura:

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

En lo anterior, A , B , C , D y E son los nombres de campo de la selección y S , T y U son los tipos respectivos de esos campos. Los nombres de campo pueden exportarse o no.

Una selección no puede ser recursiva sin indirección.

Legal

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

Ilegal

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

No hay incrustaciones para selecciones, pero una selección puede estar incrustada en una estructura. Si una selección está incrustada en una estructura, el método de la selección se promueve a la estructura, pero los campos de una selección no.

Un tipo sin un nombre de campo es una forma abreviada de definir un campo con el mismo nombre que el tipo. (Esto es un error si el tipo no tiene nombre, con una excepción para *T donde el nombre es T ).

Por ejemplo,

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

tiene tres campos Reader , Writer y string , con los tipos respectivos. Tenga en cuenta que el campo string se exporta aunque esté en el ámbito del universo.

Un valor de un tipo de selección consta de un campo dinámico y el valor de ese campo.

El valor cero de un tipo de selección es su primer campo en el orden de origen y el valor cero de ese campo.

Dados dos valores del mismo tipo de selección, a y b , el valor de selección puede asignarse como cualquier otro valor

a = b

Asignar un valor que no sea de selección, incluso uno de un tipo de uno de los campos en una selección, es ilegal.

Un tipo de selección solo tiene un campo dinámico en un momento dado.

La sintaxis literal compuesta es similar a las estructuras, pero existen restricciones adicionales. Es decir, los literales sin clave siempre no son válidos y solo se puede especificar una clave.

Los siguientes son 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, "")

Los siguientes son errores de tiempo de compilación:

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 un valor p de tipo pick {A int; B string} la siguiente asignación

p.B = "hi"

establece el campo dinámico de p en B y el valor de B en "hi".

La asignación al campo dinámico actual actualiza el valor de ese campo. La asignación que establece un nuevo campo dinámico debe poner a cero cualquier ubicación de memoria no especificada. La asignación a un campo de selección o estructura de un campo de selección actualiza o establece el campo dinámico según sea necesario.

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

Solo se puede acceder al valor contenido en una selección mediante una afirmación de campo o un cambio 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
}

Los nombres de campo en las aserciones de campo y los cambios de campo son una propiedad del tipo, no del paquete en el que se definieron. No están ni pueden ser calificados por el nombre del paquete que define pick .

Esto es válido:

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

Esto no es válido:

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

Las afirmaciones de campo y los cambios de campo siempre devuelven una copia del valor del campo dinámico.

Los nombres de campo no exportados solo se pueden afirmar en su paquete de definición.

Las afirmaciones de tipo y los conmutadores de tipo también funcionan en las selecciones.

//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

Las afirmaciones de tipo y los conmutadores de tipo siempre devuelven una copia del valor del campo dinámico.

Si la selección se almacena en una interfaz, las aserciones de tipo para las interfaces solo coinciden con el conjunto de métodos de la selección en sí. [sigue siendo cierto pero redundante ya que se ha eliminado lo anterior]

Si todos los tipos de selección admiten los operadores de igualdad, entonces:

  • los valores de esa selección pueden usarse como claves de mapa
  • dos valores de la misma selección son == si tienen el mismo campo dinámico y sus valores son ==
  • dos valores con diferentes campos dinámicos son != incluso si los valores son == .

Ningún otro operador es compatible con valores de un tipo de selección.

Un valor de un tipo de selección P se puede convertir en otro tipo de selección Q si el conjunto de nombres de campo y sus tipos en P es un subconjunto de los nombres de campo y sus escribe Q .

Si P y Q están definidos en paquetes diferentes y tienen campos no exportados, esos campos se consideran diferentes independientemente del nombre y tipo.

Ejemplo:

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

La asignabilidad entre dos tipos de picos se define como convertibilidad, siempre que no se defina más de uno de los tipos.

Los métodos se pueden declarar en un tipo de selección definido.

Creé (y agregué a la wiki) un informe de experiencia https://gist.github.com/jimmyfrasche/ba2b709cdc390585ba8c43c989797325

Editar: y: corazón: a @mewmew, quien dejó un informe mucho mejor y más detallado como respuesta sobre esa esencia.

¿Qué pasaría si tuviéramos una manera de decir, para un tipo dado T , la lista de tipos que podrían convertirse al tipo T o asignarse a una variable de tipo T ? Por ejemplo

type T interface{} restrict { string, error }

define un tipo de interfaz vacío llamado T modo que los únicos tipos que se le pueden asignar son string o error . Cualquier intento de asignar un valor de cualquier otro tipo produce un error de tiempo de compilación. Ahora puedo decir

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.
}

¿Qué elementos clave de los tipos de suma (o tipos de selección) no quedarían satisfechos con este tipo de enfoque?

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

Esto no es estrictamente cierto, ya que v también podría ser nil . Sería necesario un cambio bastante importante en el lenguaje para eliminar esta posibilidad, ya que supondría introducir tipos que no tengan valores cero y todo lo que eso conlleva. El valor cero simplifica partes del lenguaje, pero también dificulta el diseño de este tipo de funciones.

Curiosamente, este enfoque es bastante similar a la propuesta original de @rogpeppe . Lo que no tiene es coerción sobre los tipos enumerados, lo que podría ser útil en situaciones como las que señalé anteriormente ( http.Handler ). Otra cosa es que requiere que cada variante sea de un tipo distinto, ya que las variantes se discriminan por tipo en lugar de por una etiqueta distinta. Creo que esto es estrictamente expresivo, pero algunas personas prefieren tener etiquetas variantes y los tipos sean distintos.

@ianlancetaylor

los profesionales

  • posible restringir a un conjunto cerrado de tipos, y eso es definitivamente lo principal
  • posible escribir un verificador de exhaustividad preciso
  • obtienes el "puedes asignar un valor que satisfaga el contrato a esta" propiedad. (No me importa esto, pero imagino que a otros sí).

los contras

  • son solo interfaces con beneficios y no un tipo diferente de tipo (¡buenos beneficios!)
  • todavía tiene nil, por lo que no es realmente un tipo de suma en el sentido teórico de tipos. Cualquier A + B + C que especifique es realmente un 1 + A + B + C que no tiene elección. Como señaló @stevenblenkinsop mientras trabajaba en esto.
  • lo que es más importante, debido a ese puntero implícito, siempre tiene una indirección. Con la propuesta de selección, puede elegir tener un p o *p lo que le brinda un mayor control sobre las compensaciones de memoria. No podría implementarlos como uniones discriminadas (en el sentido de C) como una optimización.
  • no hay opción de valor cero, que es una propiedad realmente agradable, especialmente porque es muy importante en Go tener un valor cero tan útil como sea posible
  • presumiblemente no podrías definir métodos en T (pero presumiblemente tendrías los métodos de la interfaz que modifica la restricción, pero los tipos en la restricción tendrían que satisfacerlo? De lo contrario, no veo el punto de no solo tener type T restrict {string, error} )
  • si pierde las etiquetas de los campos / sumandos / lo que tiene, entonces se vuelve confuso cuando interactúa con los tipos de interfaz. Se pierde la propiedad fuerte de "exactamente esto o exactamente aquello" de los tipos de suma. Puede poner io.Reader adentro y sacar un io.Writer . Eso tiene sentido para interfaces (no restringidas) pero no para tipos de suma.
  • Si desea que dos tipos idénticos signifiquen cosas diferentes, debe usar tipos de envoltura para eliminar la ambigüedad; tal etiqueta tendría que estar en un espacio de nombres externo en lugar de estar confinada a un tipo de la forma en que lo está un campo de estructura
  • esto puede estar leyendo demasiado en su redacción específica, pero parece que cambia las reglas de asignabilidad según el tipo de cesionario (lo leo como diciendo que no puede asignar algo asignable a error a T tiene que ser exactamente un error).

Dicho esto, marca las casillas principales (las dos primeras ventajas que enumeré) y lo tomaría en un santiamén si eso fuera todo lo que pudiera obtener. Sin embargo, espero algo mejor.

Supuse que se aplicaban las reglas de aserción de tipo. Por lo tanto, el tipo debe ser idéntico a un tipo concreto o asignable a un tipo de interfaz. Básicamente, funciona exactamente como una interfaz, pero cualquier valor (que no sea nil ) debe poder asignarse al menos a uno de los tipos enumerados.

@jimmyfrasche
En su propuesta actualizada, sería posible la siguiente asignación, si todos los elementos del tipo son 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")

La usabilidad de los tipos de suma cuando tales asignaciones son posibles es mucho mayor.

Con la propuesta de selección, puede elegir tener un p o *p lo que le brinda un mayor control sobre las compensaciones de la memoria.

La razón por la que las interfaces se asignan para almacenar valores escalares es para que no tenga que leer una palabra de tipo para decidir si la otra palabra es un puntero; ver # 8405 para discusión. Es probable que se apliquen las mismas consideraciones de implementación para un tipo de selección, lo que podría significar en la práctica que p terminará asignando y siendo no local de todos modos.

@urandom no, dadas sus definiciones, tendría que estar 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"})

Es mejor pensar en ellos como una estructura que solo puede tener un campo configurado a la vez.

Si no tiene eso y luego agrega C uint a p ¿qué pasa con p = 42 ?

Puede crear muchas reglas basadas en el orden y la asignabilidad, pero siempre significan que los cambios en la definición de tipo pueden tener efectos sutiles y dramáticos en todo el código que usa el tipo.

En el mejor de los casos, un cambio rompe todo el código basándose en la falta de ambigüedad y dice que debe cambiarlo a p = int(42) o p = uint(42) antes de que vuelva a compilarse. Un cambio de una línea no debería requerir arreglar cien líneas. Especialmente si esas líneas están en paquetes de personas que dependen de su código.

Tienes que ser 100% explícito o tener un tipo muy frágil que nadie pueda tocar porque podría romperlo todo.

Esto se aplica a cualquier propuesta de tipo de suma, pero si hay etiquetas explícitas, aún tiene la posibilidad de asignación porque la etiqueta es explícita sobre el tipo al que se está asignando.

@josharian, así que si estoy leyendo eso correctamente, la razón por la que iface ahora es siempre (*type, *value) lugar de guardar valores del tamaño de una palabra en el segundo campo como lo hizo Go anteriormente es para que el GC concurrente no necesite inspeccionar ambos campos para ver si el segundo es un puntero; simplemente puede asumir que siempre lo es. ¿Lo entendí bien?

En otras palabras, si se implementó el tipo de selección (usando notación C) como

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

el GC necesitaría tener un candado (o algo elegante pero equivalente) para inspeccionar which y determinar si summands necesitaba ser escaneado?

la razón por la que iface ahora es siempre (* tipo, * valor) en lugar de guardar valores del tamaño de una palabra en el segundo campo como lo hizo Go anteriormente es para que el GC concurrente no necesite inspeccionar ambos campos para ver si el segundo es un puntero —Puede suponer que siempre lo es.

Eso es correcto.

Por supuesto, la naturaleza limitada de los tipos de selección permitiría algunas implementaciones alternativas. El tipo de selección se puede diseñar de manera que siempre haya un patrón consistente de puntero / no puntero; por ejemplo, todos los tipos escalares pueden superponerse, y un campo de cadena podría superponerse con el comienzo de un campo de corte (porque ambos comienzan como "puntero, no puntero"). Entonces

pick {
  a uintptr
  b string
  c []byte
}

podría presentarse a grandes rasgos:

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

pero es posible que otros tipos de picos no permitan un embalaje tan óptimo. (Lo siento por el ASCII roto, parece que no puedo hacer que GitHub lo represente correctamente. Espero que entiendas el punto).

Esta capacidad de realizar un diseño estático podría incluso ser un argumento de rendimiento a favor de incluir tipos de selección; mi objetivo aquí es simplemente señalar los detalles de implementación relevantes para usted.

@josharian y gracias por hacerlo. No había pensado en eso (honestamente, busqué en Google si existía una investigación sobre cómo GC discriminaba las uniones, vi que sí se puede hacer eso y lo llamé un día; por alguna razón, mi cerebro no asoció la "concurrencia" con "Go" ese día: facepalm!).

Habría menos opciones si uno de los tipos es una estructura definida que ya tiene un diseño.

Una opción sería no "compactar" los sumandos si contienen punteros, lo que significa que el tamaño sería el mismo que el de la estructura equivalente (+ 1 para el discriminador int). Tal vez adoptando un enfoque híbrido, cuando sea posible, para que todos los tipos que pueden compartir el diseño lo hagan.

Sería una pena perder las propiedades de buen tamaño, pero eso es solo una optimización.

Incluso si siempre fuera 1 + el tamaño de una estructura equivalente, incluso cuando no contuvieran punteros, aún tendría todas las otras propiedades agradables del tipo en sí, incluido el control sobre las asignaciones. Se podrían agregar optimizaciones adicionales con el tiempo y al menos serían posibles como usted señala.

type p pick {
    A int
    B string
}

¿Es necesario que A y B estén allí? Una selección elige entre un conjunto de tipos, así que, ¿por qué no descartar completamente los nombres de sus identificadores?

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

Creo que esta forma ya es válida para struct. Puede haber una restricción de que sea necesario para la selección.

@como si se omite el nombre del campo, es el mismo que el tipo, por lo que su ejemplo funciona, pero dado que esos nombres de campo no se exportan, solo se pueden configurar / acceder desde dentro del paquete de definición.

Los nombres de campo deben estar allí, incluso si se generan implícitamente en función del nombre del tipo, o hay malas interacciones con la asignabilidad y los tipos de interfaz. Los nombres de campo son los que lo hacen funcionar con el resto de Go.

@ como disculpas, me acabo de dar cuenta de que

Su formulación funciona, pero luego tiene cosas que parecen campos de estructura pero se comportan de manera diferente debido a lo habitual exportado / no exportado.

¿Se puede acceder a la cadena desde fuera del paquete que define p porque está en el universo?

Qué pasa

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

?

Al separar el nombre del campo del nombre del tipo, puede hacer cosas como

pick {
  unexported Exported
  Exported unexported
}

o incluso

pick { Recoverable, Fatal error }

Si los campos de selección se comportan como campos de estructura, puede usar mucho de lo que ya sabe sobre los campos de estructura para pensar en los campos de selección. La única diferencia real es que solo se puede establecer un campo de selección a la vez.

@jimmyfrasche
Go ya admite la incrustación de tipos anónimos dentro de estructuras, por lo que la restricción de alcance es una que ya existe en el lenguaje, y creo que el problema se está resolviendo mediante alias de tipo. Pero admito que no he pensado en todos los casos de uso posibles. Parece depender de si este modismo es común en Go:

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

La pequeña _t_ existe en un paquete donde está incrustada en una gran T , y su única exposición es a través de estos tipos exportados.

@como

Sin embargo, no estoy seguro de seguir por completo:

//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
  //...
}

Además, si solo tuviera el nombre del tipo para la etiqueta, para incluir, por ejemplo, []string , necesitaría hacer un type Strings = []string .

Esa es en gran medida la forma en que quiero ver implementados los tipos de selección. En
en particular, así es como funcionan Rust y C ++ (los estándares de oro para el rendimiento)
eso.

Si solo quisiera una verificación exhaustiva, podría usar un verificador. quiero
la actuación gana. Eso significa que los tipos de selección tampoco pueden ser nulos.

No se debe permitir tomar la dirección de un miembro de un elemento de selección (se
no es seguro para la memoria, incluso en el caso de un solo subproceso, como es bien sabido en
la comunidad de Rust.). Si eso requiere otras restricciones en un tipo de selección,
entonces que así sea. Pero para mí, tener tipos de selección siempre se asignan en el montón
sería malo.

El 18 de agosto de 2017 a las 12:01 p.m., "jimmyfrasche" [email protected] escribió:

@josharian https://github.com/josharian así que si estoy leyendo eso correctamente
la razón por la que iface ahora es siempre (* tipo, * valor) en lugar de esconder
valores del tamaño de una palabra en el segundo campo, como lo hizo Go anteriormente, es
GC concurrente no necesita inspeccionar ambos campos para ver si el segundo
es un puntero, simplemente puede asumir que siempre lo es. ¿Lo entendí bien?

En otras palabras, si se implementó el tipo de selección (usando notación C) como

estructura {
int que;
Unión {
A a;
B b;
C c;
} sumandos;
}

el GC necesitaría tener un candado (o algo elegante pero equivalente) para
inspeccionar cuál para determinar si los sumandos debían escanearse?

-
Estás recibiendo esto porque eres el autor del hilo.
Responda a este correo electrónico directamente, véalo en GitHub
https://github.com/golang/go/issues/19412#issuecomment-323393003 , o silenciar
la amenaza
https://github.com/notifications/unsubscribe-auth/AGGWB3Ayi31dYwotewcfgmCQL-XVrfxIks5sZbVrgaJpZM4MTmSr
.

@DemiMarie

No se debe permitir tomar la dirección de un miembro de un elemento de selección (no es seguro para la memoria, incluso en el caso de un solo subproceso, como es bien conocido en la comunidad de Rust). Si eso requiere otras restricciones en un tipo de selección, que así sea.

Ese es un buen punto. Tenía eso ahí, pero debe haberse perdido en una edición. Sin embargo, incluí que cuando accede al valor de una selección, siempre devuelve una copia por la misma razón.

Como ejemplo de por qué eso es cierto, para la posteridad, 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

Si v está optimizado para que los campos A y B ocupen la misma posición en la memoria, entonces p no apunta a un int: está apuntando a un bool. Se violó la seguridad de la memoria.

@jimmyfrasche

La segunda razón por la que no querría que los contenidos fueran direccionables es la semántica de mutación. Si el valor se almacena indirectamente en determinadas circunstancias, entonces

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

Un lugar donde pick es similar a las interfaces es que desea retener la semántica del valor si almacena valores en ella. Si puede necesitar indirección como un detalle de implementación, la única opción es hacer que el contenido no sea direccionable (o más precisamente, direccionable de manera mutante , pero la distinción no existe en Go en la actualidad), de modo que no pueda observar el aliasing .

Editar: Vaya (ver más abajo)

@jimmyfrasche

El valor cero de un tipo de selección es su primer campo en el orden de origen y el valor cero de ese campo.

Tenga en cuenta que esto no funcionaría si el primer campo necesita almacenarse indirectamente, a menos que aplique un caso especial al valor cero para que v.[A] y v.(error) hagan lo correcto.

@stevenblenkinsop No estoy seguro de lo que quiere decir con "el primer campo debe almacenarse indirectamente". Supongo que te refieres a si el primer campo es un puntero o un tipo que contiene implícitamente un puntero. Si es así, hay un ejemplo a continuación. Si no es así, ¿podría aclararlo?

Dado

var p pick { A error; B int }

el valor cero, p , tiene un campo dinámico A y el valor de A es nulo.

No me refería al valor almacenado en el pick es / contiene un puntero, me refería a un valor no puntero que se almacena indirectamente debido a las restricciones de diseño impuestas por el recolector de basura, como lo describe @josharian .

En su ejemplo, p.B no es un puntero, no podría compartir el almacenamiento superpuesto con p.A , que consta de dos punteros. Lo más probable es que tenga que almacenarse indirectamente (es decir, representarse como *int que se desreferencia automáticamente cuando accede a él, en lugar de como int ). Si p.B fuera el primer campo, el valor cero de pick sería new(int) , que no es un valor cero aceptable ya que requiere inicialización. Necesitaría un caso especial para que un *int nulo sea tratado como un new(int) .

@jimmyfrasche
Oh, lo siento. Volviendo a la conversación, me di cuenta de que estaba considerando usar almacenamiento adyacente para almacenar variantes con diseños incompatibles, en lugar de copiar el mecanismo de interfaz de almacenamiento indirecto de tipos que no son de puntero. Mis últimos tres comentarios no tienen sentido en ese caso.

Editar: Ups, condición de carrera. Publicado luego vi tu comentario.

@stevenblenkinsop ah, está bien, veo lo que quieres decir. Pero eso no es problema.

Compartir el almacenamiento superpuesto es una optimización. Nunca podría hacer eso: la semántica del tipo es la parte importante.

Si el compilador puede optimizar el almacenamiento y elige hacerlo, es una buena ventaja.

En su ejemplo, el compilador podría almacenarlo exactamente como lo haría con la estructura equivalente (agregando una etiqueta para saber cuál es el campo activo). Esto sería

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

El valor cero sigue siendo todos los bytes 0 y no es necesario realizar una asignación subrepticia como un caso especial.

Lo importante es asegurarse de que solo haya un campo en juego en un momento dado.

La motivación para permitir afirmaciones / cambios de tipo en las selecciones fue que, por ejemplo, si cada tipo en la selección satisfacía fmt.Stringer , podría escribir un método en la selección como

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

Pero dado que los tipos de campos de selección pueden ser interfaces, esto crea una sutileza.

Si la selección P en el ejemplo anterior tuviera un campo cuyo tipo es en sí mismo fmt.Stringer ese método String entraría en pánico si ese fuera el campo dinámico y su valor es nil . No puede escribir afirmar una interfaz nil a nada, ni siquiera a sí mismo. https://play.golang.org/p/HMYglwyVbl Si bien esto siempre ha sido cierto, simplemente no aparece con regularidad, pero podría aparecer con más frecuencia con selecciones.

Sin embargo, la naturaleza cerrada de los tipos de suma permitiría que un linter exhaustivo encontrara en todos los lugares en los que surgiera esto (potencialmente con algunos falsos positivos) e informara el caso que debe manejarse.

También sería sorprendente, si puede implementar métodos en la selección, que esos métodos no se utilicen para satisfacer una afirmación 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

Puede hacer que la aserción de tipo promueva métodos del campo actual si satisfacen la interfaz, pero esto tiene sus propios problemas, como si promover métodos a partir de un valor en un campo de interfaz que no está definido en la interfaz misma (o incluso cómo implementar esto de manera eficiente). Además, uno podría esperar que los métodos comunes a todos los campos se promocionen a la selección en sí, pero luego tendrían que enviarse a través de la selección de variantes en cada llamada, además de potencialmente un envío virtual si la selección se almacena en una interfaz. , y / oa un despacho virtual si el campo es una interfaz.

Editar: Por cierto, empaquetar un pico de manera óptima es una instancia del problema de supercuerdas común más corto , que es NP-completo, aunque hay aproximaciones codiciosas que se usan comúnmente.

La regla es que si es un valor de selección, la aserción de tipo afirma en el campo dinámico del valor de selección, pero si el valor de selección se almacena en una interfaz, la aserción de tipo está en el conjunto de métodos del tipo de selección. Puede resultar sorprendente al principio, pero es bastante consistente.

No sería un problema eliminar las afirmaciones de tipo permitidas en un valor de selección. Sin embargo, sería una pena, ya que facilita mucho la promoción de métodos que comparten todos los tipos en la selección sin tener que escribir todos los casos o utilizar la reflexión.

Sin embargo, sería bastante fácil usar la generación de código para escribir el

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

Simplemente siguió adelante y eliminó las afirmaciones de tipo. Quizás deberían agregarse, pero no son una parte necesaria de la propuesta.

Quiero volver al comentario anterior de @ianlancetaylor , porque tengo una nueva perspectiva después de pensar un poco más sobre el manejo de errores (específicamente, https://github.com/golang/go/issues/21161# número comentario-320294933).

En particular, ¿qué nos da el nuevo tipo de tipo que no obtenemos de los tipos de interfaz?

A mi modo de ver, la principal ventaja de los tipos de suma es que nos permitirían distinguir entre devolver varios valores y devolver uno de varios valores, especialmente cuando uno de esos valores es una instancia de la interfaz de error.

Actualmente tenemos muchas funciones del formulario

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

Algunos de ellos, tales como io.Reader.Read y io.Reader.Write , regresa una T junto con un error , mientras que otros devuelven un T o un error pero nunca ambos. Para el estilo anterior de API, ignorar T en caso de error suele ser un error (por ejemplo, si el error es io.EOF ); para el último estilo, devolver un T distinto

Las herramientas automatizadas, incluido lint , pueden verificar el uso de funciones específicas para asegurarse de que el valor se ignore (o no) correctamente cuando el error no es nulo, pero tales comprobaciones no se extienden naturalmente a funciones arbitrarias.

Por ejemplo, proto.Marshal tiene la intención de ser el estilo "valor y error" si el error es un RequiredNotSetError , pero parece ser el estilo "valor o error" en caso contrario. Debido a que el sistema de tipos no distingue entre los dos, es fácil introducir regresiones accidentalmente: no devolver un valor cuando deberíamos o devolver un valor cuando no deberíamos. Y las implementaciones de proto.Marshaler complican aún más el asunto.

Por otro lado, si pudiéramos expresar el tipo como una unión, podríamos ser mucho más explícitos al respecto:

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

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

@ianlancetaylor , he estado jugando con tu propuesta en papel. ¿Puedes avisarme si algo a continuación es incorrecto?

Dado

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

el tipo dinámico de r es int , y

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

es ilegal.

Dado

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

entonces var _ R = S{} sería ilegal.

Pero dado

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

tanto var _ R = C{} como var _ R = A(C{}) serían legales.

Ambos

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

y

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

son equivalentes.

Igualmente,

interface{} restrict { error, net.Error }

es equivalente a

interface { Error() string }

Dado

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

entonces el tipo subyacente de R es equivalente a

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

Editar: pequeña corrección en cursiva

@jimmyfrasche No iría tan lejos como para decir que lo que escribí arriba fue una propuesta. Fue más como una idea. Tendría que pensar en tus comentarios, pero a primera vista parecen plausibles.

La propuesta de @jimmyfrasche es más o menos como esperaría intuitivamente que se comportara un tipo de selección en Go. Creo que vale la pena señalar especialmente que su propuesta de usar el valor cero del primer campo para el valor cero de la selección es intuitiva con el "valor cero significa poner a cero los bytes", siempre que los valores de la etiqueta comiencen en cero (tal vez esto ya se ha señalado; este hilo es muy largo ahora ...). También me gustan las implicaciones de rendimiento (sin asignaciones innecesarias), y que las selecciones son completamente ortogonales a las interfaces (no es un comportamiento sorprendente al cambiar una selección que contiene una interfaz).

Lo único que consideraría cambiar es mutar la etiqueta: foo.X = 0 parece que podría ser foo = Foo{X: 0} ; algunos caracteres más, pero más explícito que es restablecer la etiqueta y poner a cero el valor. Este es un punto menor, y aún estaría muy feliz si su propuesta fuera aceptada como está.

@ ns-cweber gracias, pero no puedo atribuirme el mérito del comportamiento de valor cero. Las ideas han estado flotando por un tiempo y estaban en la propuesta de @rogpeppe que vino antes en este hilo (como usted señala bastante largo). Mi justificación fue la misma que la que diste.

En cuanto a foo.X = 0 vs foo = Foo{X: 0} , mi propuesta permite ambos, en realidad. Esto último es útil si ese campo de una selección es una estructura, por lo que puede hacer foo.X.Y = 0 lugar de foo = Foo{X: image.Point{X: foo.[X].X, 0}} que, además de ser detallado, podría fallar en tiempo de ejecución.

También creo que ayuda a mantenerlo como tal porque refuerza el tono del ascensor por su semántica: es una estructura que solo puede tener un campo establecido por tiempo.

Una cosa que puede impedir que se acepte tal como está es cómo funcionaría la inserción de una selección en una estructura. Me di cuenta el otro día de que había pasado por alto los diversos efectos que tendría al usar la estructura. Creo que es reparable, pero no estoy del todo seguro de cuáles son las mejores reparaciones. La más simple sería que solo hereda los métodos y tienes que referirte directamente a la selección incrustada por nombre para llegar a sus campos y me inclino hacia eso para evitar que una estructura tenga tanto campos de estructura como campos de selección.

@jimmyfrasche Gracias por corregirme sobre el comportamiento de valor cero. Estoy de acuerdo en que su propuesta permite ambos mutadores, y creo que su punto de lanzamiento de ascensor es bueno. Su explicación para su propuesta tiene sentido, aunque podría verme configurando foo.XY, sin darme cuenta de que cambiaría automáticamente el campo de selección. Me alegraría mucho si su propuesta tuviera éxito, incluso con esa pequeña reserva.

Por último, su sencilla propuesta de incrustación de selecciones parece la que yo intuiría. Incluso si cambiamos de opinión, podemos pasar de la propuesta simple a la propuesta compleja sin romper el código existente, pero lo contrario no es cierto.

@ ns-cweber

Pude verme configurando foo.XY, sin darme cuenta de que cambiaría automáticamente el campo de selección

Ese es un punto justo, pero podría hacerlo sobre muchas cosas en el idioma, o en cualquier idioma, para el caso. En general, Go tiene barandillas de seguridad pero no tijeras de seguridad.

Hay muchas cosas importantes de las que generalmente te protege, si no te esfuerzas por subvertirlas, pero aún tienes que saber lo que estás haciendo.

Eso puede ser molesto cuando comete un error como este, pero, otoh, no es muy diferente de "Configuré bar.X = 0 pero quise configurar bar.Y = 0 ", ya que la hipótesis se basa en que no se dé cuenta que foo es un tipo de selección.

De manera similar, i.Foo() , p.Foo() y v.Foo() tienen el mismo aspecto, pero si i es una interfaz nil , p es un puntero nulo y Foo no maneja ese caso, los dos primeros podrían entrar en pánico, mientras que si v usa un receptor de método de valor, no podría (al menos no desde la invocación en sí, de todos modos) .

-

En cuanto a la incrustación, un buen punto es que es fácil de aflojar más tarde, así que seguí adelante y edité la propuesta.

Los tipos de suma suelen tener un campo sin valor. Por ejemplo, en el paquete database/sql , tenemos:

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

Si tuviéramos tipos de suma / selecciones / uniones, esto podría expresarse como:

type NullString pick {
  Null   struct{}
  String string
}

Un tipo de suma tiene ventajas obvias sobre una estructura en este caso. Creo que este es un uso bastante común que valdría la pena incluir como ejemplo en cualquier propuesta.

Bikeshedding (lo siento), diría que vale la pena el apoyo sintáctico y la inconsistencia con la sintaxis de incrustación del campo de estructura:

type NullString union {
  Null
  String string
}

@neild

Golpear el último punto primero: como un cambio de último minuto antes de publicar (no es estrictamente necesario en ningún sentido), agregué que si hay un tipo con nombre (o un puntero a un tipo con nombre) sin nombre de campo, la selección crea un campo implícito con el mismo nombre que el tipo. Puede que esa no sea la mejor idea, pero parece que cubriría uno de los casos comunes de "cualquiera de estos tipos" sin mucho alboroto. Dado que su último ejemplo podría escribirse:

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

Sin embargo, volviendo a su punto principal, sí, es un uso excelente. De hecho, puede usarlo para crear enumeraciones: type Stoplight pick { Stop, Slow, Go struct{} } . Esto sería muy parecido a una const / iota faux-enum. Incluso se compilaría con la misma salida. El principal beneficio en este caso es que el número que representa el estado está completamente encapsulado y no se puede poner en ningún otro estado que no sean los tres enumerados.

Desafortunadamente, hay una sintaxis algo incómoda para crear y establecer valores de Stoplight que se agrava en este caso:

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

Sería útil permitir que {} o _ sean la abreviatura de struct{}{} , como se propone en otra parte.

Muchos lenguajes, especialmente los lenguajes funcionales, evitan esto poniendo las etiquetas en el mismo ámbito que el tipo. Esto crea mucha complejidad y no permitiría que dos selecciones definidas en el mismo ámbito compartan nombres de campo.

Sin embargo, es fácil solucionar esto con un generador de código que crea una función con el mismo nombre de cada campo en la selección que toma el tipo de campo como argumento. Si también, como caso especial, no tomó argumentos si el tipo era de tamaño cero, entonces la salida para el ejemplo Stoplight se vería así

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

y para su ejemplo de NullString se vería así:

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

No es bonito, pero está a go generate distancia y probablemente se alinee fácilmente.

Eso no funcionaría en el caso de que creara campos implícitos basados ​​en los nombres de los tipos (a menos que los tipos fueran de otros paquetes) o se ejecutara en dos selecciones en el mismo paquete que compartían los nombres de los campos, pero eso está bien. La propuesta no hace todo de inmediato, pero permite muchas cosas y le da al programador la flexibilidad de decidir qué es lo mejor para una situación determinada.

Más sintaxis bikeshedding:

type NullString union {
  Null
  Value string
}

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

Concretamente, un literal con una lista de elementos que no contiene claves se interpreta como nombrar el campo a establecer.

Esto sería sintácticamente inconsistente con otros usos de literales compuestos. Por otro lado, es un uso que parece sensato e intuitivo en el contexto de los tipos union / pick / sum (al menos para mí), ya que no hay una interpretación sensata de un inicializador de unión sin una clave.

@neild

Esto sería sintácticamente inconsistente con otros usos de literales compuestos.

Eso me parece muy negativo, aunque tiene sentido en el contexto.

También tenga en cuenta que

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

Para lidiar con struct{}{} cuando estoy usando un map[T]struct{} lanzo

var set struct{}

en algún lugar y use theMap[k] = set , Similar funcionaría con selecciones

Más bicishedding: el tipo vacío (en el contexto de los tipos de suma) se denomina convencionalmente "unidad", no "nulo".

@bcmills Sorta.

En los lenguajes funcionales, cuando creas un tipo de suma, sus etiquetas son en realidad funciones que crean los valores de ese tipo (aunque funciones especiales conocidas como "constructores de tipos" o "tycons" que el compilador conoce para permitir la coincidencia de patrones), por lo que

data Bool = False | True

crea el tipo de datos Bool y dos funciones en el mismo ámbito, True y False , cada una con la firma () -> Bool .

Aquí () es cómo se escribe la unidad pronunciada del tipo: el tipo con un solo valor. En Go, este tipo se puede escribir de muchas formas diferentes, pero se escribe idiomáticamente como struct{} .

Entonces, el tipo de argumento del constructor se llamaría unidad. La convención para el nombre del constructor es generalmente None cuando se usa como un tipo de opción como este, pero se puede cambiar para adaptarse al dominio. Null sería un buen nombre si el valor viniera de una base de datos, por ejemplo.

@bcmills

A mi modo de ver, la principal ventaja de los tipos de suma es que nos permitirían distinguir entre devolver varios valores y devolver uno de varios valores, especialmente cuando uno de esos valores es una instancia de la interfaz de error.

Para una perspectiva alternativa, veo esto como una gran desventaja de los tipos de suma en Go.

Por supuesto, muchos lenguajes utilizan tipos de suma para el caso exacto de devolver algún valor o un error, y esto funciona bien para ellos. Si se añadieran tipos de suma a Go, habría una gran tentación de utilizarlos de la misma forma.

Sin embargo, Go ya tiene un gran ecosistema de código que usa múltiples valores para este propósito. Si el nuevo código usa tipos de suma para devolver tuplas (valor, error), ese ecosistema se fragmentará. Algunos autores continuarán usando múltiples retornos para mantener la coherencia con su código existente; algunos autores usarán tipos de suma; algunos intentarán convertir sus API existentes. Los autores atascados en versiones anteriores de Go, por el motivo que sea, no podrán acceder a las nuevas API. Será un desastre y no creo que las ganancias comiencen a valer la pena.

Si el nuevo código usa tipos de suma para devolver tuplas (valor, error), ese ecosistema se fragmentará.

Si agregamos tipos de suma en Go 2 y los usamos de manera uniforme, entonces el problema se reduce a uno de migración, no de fragmentación: debería ser posible convertir una API de Go 1 (valor, error) en una API de Go 2 (valor | error ) API y viceversa, pero podrían ser tipos distintos en las partes de Go 2 del programa.

Si agregamos tipos de suma en Go 2 y los usamos uniformemente

Tenga en cuenta que esta es una propuesta que es bastante diferente a las que se han visto aquí hasta ahora: la biblioteca estándar deberá refactorizarse ampliamente, la traducción entre los estilos de API deberá definirse, etc. Siga este camino y esto se convertirá en un gran y propuesta complicada para una transición API con un codicilo menor en cuanto al diseño de tipos de suma.

La intención es que Go 1 y Go 2 puedan coexistir sin problemas en el mismo proyecto, por lo que no creo que la preocupación sea que alguien pueda quedarse atascado con un compilador de Go 1 "por alguna razón" y no pueda usar un Ir a la biblioteca 2. Sin embargo, si tiene dependencia A que depende a su vez de B , y B actualizaciones para usar una nueva característica como pick en su API, entonces eso rompería la dependencia A menos que se actualice para usar la nueva versión de B . A podría simplemente vender B y seguir usando la versión anterior, pero si la versión anterior no se mantiene por errores de seguridad, etc ... o si necesita usar la nueva versión de B directamente y no puede tener dos versiones en su proyecto por alguna razón, eso podría crear un problema.

En última instancia, el problema aquí tiene poco que ver con las versiones de idioma y más con cambiar las firmas de las funciones exportadas existentes. El hecho de que sea una característica nueva que proporcione el ímpetu es una distracción de eso. Si la intención es permitir que las API existentes se modifiquen para usar pick sin romper la compatibilidad con versiones anteriores, es posible que deba haber una sintaxis puente de algún tipo. Por ejemplo (completamente como un hombre de paja):

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

El compilador podría simplemente colocar el ReadResult cuando se accede a él mediante un código heredado, usando valores cero si un campo no está presente en una variante en particular. No estoy seguro de cómo ir al revés o si vale la pena. Las API como template.Must podrían tener que seguir aceptando múltiples valores en lugar de pick y depender de splatting para compensar la diferencia. O podría usarse algo como esto:

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

Esto complica las cosas, pero puedo ver cómo la introducción de una función que cambia la forma en que se

Es trivial pasar de tipos de suma a tipos de productos (estructuras, valores de retorno múltiples); simplemente establezca todo lo que no sea el valor en cero. Pasar de tipos de productos a tipos de suma no está bien definido en general.

Si una API desea realizar una transición sin problemas y gradualmente de una implementación basada en el tipo de producto a una basada en el tipo de suma, la ruta más fácil sería tener dos versiones de todo lo necesario donde la versión del tipo de suma tiene la implementación real y la versión del tipo de producto llama al versión de tipo suma, realizar cualquier comprobación de tiempo de ejecución que requiera y cualquier proyección hacia el espacio del producto.

Eso es realmente abstracto, así que aquí tienes un ejemplo.

versión 1 sin sumas

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
}

versión 2 con sumas

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

la versión 3 eliminaría Give / Take

La versión 4 movería la implementación de GiveSum / TakeSum a Give / Take, haría GiveSum / TakeSum simplemente llamar a Give / Take y desaprobar GiveSum / TakeSum.

la versión 5 eliminaría GiveSum / TakeSum

No es bonito ni rápido, pero es lo mismo que cualquier otra disrupción a gran escala de naturaleza similar y no requiere nada adicional del lenguaje.

Creo que (la mayor parte de) la utilidad de un tipo de suma podría realizarse con un mecanismo para restringir la asignación a un tipo de interfaz de tipo {} en tiempo de compilación.

En mis sueños se ve así:

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... 

... también sería un error en tiempo de compilación afirmar que un tipo de conmutador es un tipo no definido explícitamente:

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
}

y el veterinario se quejaba de asignaciones constantes ambiguas a tipos como T3, pero para todos los efectos (en tiempo de ejecución) var x T3 = 32 sería var x interface{} = 32 . Tal vez algunos tipos de interruptores predefinidos para incorporados en un paquete llamado algo como interruptores o ponis también sean geniales.

@ j7b , @ianlancetaylor ofreció una idea similar en https://github.com/golang/go/issues/19412#issuecomment -323256891

Publiqué lo que creo que serían las consecuencias lógicas de esto más adelante en https://github.com/golang/go/issues/19412#issuecomment -325048452

Parece que muchos de ellos se aplicarían por igual dada la similitud.

Sería genial si algo así funcionara. Sería fácil hacer la transición de interfaces a interfaces + restricciones (especialmente con la sintaxis de Ian: simplemente agregue restrict al final de las pseudo-sumas existentes creadas con interfaces). Sería fácil de implementar ya que en tiempo de ejecución serían esencialmente idénticos a las interfaces y la mayor parte del trabajo consistiría en hacer que el compilador emita errores adicionales cuando sus invariantes se rompan.

Pero no creo que sea posible hacerlo funcionar.

Todo se alinea tan cerca que parece un ajuste, pero al acercar la imagen no está del todo bien, así que le da un pequeño empujón y luego algo más sale fuera de alineación. Puede intentar repararlo, pero luego obtiene algo que se parece mucho a las interfaces, pero que se comporta de manera diferente en casos extraños.

Quizás me estoy perdiendo algo.

No hay nada de malo en la propuesta de interfaz restringida siempre que esté de acuerdo con que los casos no sean necesariamente inconexos. No creo que sea tan sorprendente como tú que una unión entre dos tipos de interfaz (como io.Reader / io.Writer ) no sea inconexa. Es totalmente coherente con el hecho de que no puede determinar si un valor asignado a interface{} se ha almacenado como io.Reader o como io.Writer si implementa ambos. El hecho de que se pueda construir una unión disjunta siempre que cada caso sea de un tipo concreto parece perfectamente adecuado.

La desventaja es que, si las uniones son interfaces restringidas, no se pueden definir métodos directamente en ellas. Y si son tipos de interfaz restringidos, no obtiene el almacenamiento directo garantizado que proporcionan los tipos pick . No estoy seguro de si vale la pena agregar algo distinto al lenguaje para obtener estos beneficios adicionales.

@jimmyfrasche por type T switch {io.Reader,io.Writer} está bien asignar un ReadWriter a T, pero solo puede afirmar que T es un io.Reader o Io.Writer, necesitaría otra aserción para afirmar que io.Reader o io.Writer es un ReadWriter, que debería alentar a agregarlo al tipo de cambio si es una afirmación útil.

@stevenblenkinsop Puede definir la propuesta de picking sin métodos. De hecho, si elimina los métodos y los nombres de campo implícitos, puede permitir la incrustación de picking. (Aunque claramente creo que los métodos y, en mucho menor grado, los nombres de campo implícitos, son el intercambio más útil allí).

Y, por otro lado, la sintaxis de @ianlancetaylor permitiría

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

que compilaría siempre que A , B y C tengan cada uno métodos Foo y Bar (aunque debería preocuparse aproximadamente nil valores).

editar: aclaración en cursiva

Creo que alguna forma de _interfaz restringida_ sería útil, pero no estoy de acuerdo con la sintaxis. Esto es lo que sugiero. Actúa de manera similar a un tipo de datos algebraicos, que agrupa objetos relacionados con el dominio que no necesariamente tienen un comportamiento común.

//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..
   }
}

Hay varios beneficios de este enfoque sobre el enfoque de interfaz vacía convencional interface{} :

  • comprobación de tipo estático cuando se utiliza la función
  • el usuario puede inferir qué tipo de argumento se requiere solo de la firma de la función, sin tener que mirar la implementación de la función

La interfaz vacía interface{} es útil cuando se desconoce el número de tipos involucrados. Realmente no tiene más remedio que confiar en la verificación en tiempo de ejecución. Por otro lado, cuando el número de tipos es limitado y conocido durante el tiempo de compilación, ¿por qué no conseguir que el compilador nos ayude?

@henryas Creo que una comparación más útil sería la forma actualmente recomendada de hacer tipos de suma (abiertos): interfaces no vacías (si no se puede destilar una interfaz clara, utilizando funciones de marcador no exportadas).
No creo que sus argumentos se apliquen a eso de manera significativa.

Aquí hay un informe de experiencia con respecto a los protobufs de Go:

  • La sintaxis proto2 permite campos "opcionales", que son tipos en los que hay una distinción entre el valor cero y un valor no establecido. La solución actual es utilizar un puntero (por ejemplo, *int ), donde un puntero nulo indica no establecido, mientras que un puntero configurado apunta al valor real. El deseo es un enfoque que permite hacer una distinción posible entre cero y no armado, sin complicar el caso común de solo necesitar acceder al valor (donde el valor cero está bien si no está configurado).

    • Esto no funciona debido a una asignación adicional (aunque los sindicatos pueden sufrir el mismo destino dependiendo de la implementación).
    • Esto es doloroso para los usuarios porque la necesidad de verificar constantemente el puntero perjudica la legibilidad (aunque los valores predeterminados distintos de cero en protos pueden significar que la necesidad de verificar es algo bueno ...).
  • El lenguaje proto permite "one ofs", que son la versión proto de los tipos de suma. El enfoque adoptado actualmente es el siguiente ( ejemplo bruto ):

    • Defina un tipo de interfaz con un método oculto (por ejemplo, type Communique_Union interface { isCommunique_Union() } )
    • Para cada uno de los posibles tipos de Go permitidos en la unión, defina una estructura contenedora, cuyo único propósito es envolver cada tipo permitido (por ejemplo, type Communique_Number struct { Number int32 } ) donde cada tipo tiene el método isCommunique_Union .
    • Esto tampoco es eficaz, ya que los contenedores provocan una asignación. Un tipo de suma ayudaría, ya que sabemos que el valor más grande (una porción) no ocuparía más de 24B.

@henryas Creo que una comparación más útil sería la forma actualmente recomendada de hacer tipos de suma (abiertos): interfaces no vacías (si no se puede destilar una interfaz clara, utilizando funciones de marcador no exportadas).
No creo que sus argumentos se apliquen a eso de manera significativa.

¿Quiere decir agregar un método ficticio no exportado a un objeto para que el objeto se pueda pasar como una interfaz, de la siguiente manera?

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

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

No creo que eso deba recomendarse en absoluto. Es más una solución alternativa que una solución. Personalmente, preferiría renunciar a la verificación de tipo estático en lugar de tener métodos vacíos y una definición de método innecesaria por ahí.

Estos son los problemas con el enfoque del método _dummy_:

  • Métodos innecesarios y definiciones de métodos que abarrotan el objeto y la interfaz.
  • Cada vez que se agrega un nuevo _grupo_, es necesario modificar la implementación del objeto (por ejemplo, agregar métodos ficticios). Esto está mal (vea el siguiente punto).
  • El tipo de datos algebraicos (o agrupamiento basado en _dominio_ en lugar de comportamiento) es específico del dominio . Dependiendo del dominio, es posible que deba ver la relación de objetos de manera diferente. Un contador agrupa los documentos de manera diferente a un gerente de almacén. Esta agrupación concierne al consumidor del objeto y no al objeto en sí. El objeto no necesita saber nada sobre el problema del consumidor y no debería necesitarlo. ¿Necesita una factura saber algo sobre contabilidad? Si no es así, ¿por qué una Factura necesita cambiar su implementación _ (por ejemplo, agregar nuevos métodos ficticios) _ cada vez que hay un cambio en la regla de contabilidad _ (por ejemplo, aplicar una nueva agrupación de documentos) _? Al utilizar el método _dummy method_, acopla su objeto al dominio del consumidor y hace una suposición significativa sobre el dominio del consumidor. No debería necesitar hacer esto. Esto es incluso peor que el enfoque de la interfaz vacía interface{} . Hay mejores enfoques disponibles.

@henryas

No veo su tercer punto como un argumento sólido. Si el contador desea ver las relaciones de objetos de manera diferente, el contador puede crear su propia interfaz que se ajuste a sus especificaciones. Agregar un método privado a una interfaz no significa que los tipos concretos que lo satisfacen sean incompatibles con subconjuntos de la interfaz definida en otra parte.

El analizador de Go hace un uso intensivo de esta técnica y, sinceramente, no puedo imaginar que las selecciones hagan que ese paquete sea mucho mejor que justifique la implementación de selecciones en el idioma.

@como Mi punto es que cada vez que se crea una nueva _vista de relación_, los objetos concretos relevantes deben actualizarse para hacer ciertos ajustes para esta vista. Parece incorrecto, porque para hacer eso, los objetos a menudo deben hacer una cierta suposición sobre el dominio del consumidor. Si los objetos y los consumidores están estrechamente relacionados o viven dentro del mismo dominio, como en el caso del analizador Go, puede que no importe mucho. Sin embargo, si los objetos proporcionan funcionalidades básicas que van a ser consumidas por varios otros dominios, se convierte en un problema. Los objetos ahora necesitan saber un poco sobre todos los demás dominios para que funcione el método _dummy_.

Termina con muchos métodos vacíos adjuntos a los objetos, y no es obvio para los lectores por qué necesita esos métodos porque las interfaces que los requieren viven en un dominio / paquete / capa separada.

El punto de que el enfoque de sumas abiertas a través de interfaces no le permite usar sumas fácilmente es bastante justo. Obviamente, los tipos de suma explícitos facilitarían tener sumas. Sin embargo, es un argumento muy diferente al de "los tipos de suma le brindan seguridad de tipos"; todavía puede obtener seguridad de tipos hoy, si lo necesita.

Sin embargo, todavía veo dos desventajas de las sumas cerradas implementadas en otros idiomas: una, la dificultad de desarrollarlas en un proceso de desarrollo distribuido a gran escala. Y dos, creo que agregan potencia al sistema de tipos y me gusta que Go no tiene un sistema de tipos muy potente, ya que desalienta la codificación de tipos y, en su lugar, los programas de código, cuando siento que un problema puede beneficiarse de un sistema de tipos más potente, paso a un lenguaje más potente (como Haskell o Rust).

Dicho esto, al menos el segundo es definitivamente uno de preferencia e incluso si está de acuerdo, si se considera que las desventajas superan a las ventajas también depende de las preferencias personales. Solo quería señalar, que no puede obtener sumas seguras de tipos sin tipos de suma cerrados, no es realmente cierto :)

[1] en particular, no es fácil, pero aún es posible , por ejemplo, puede hacer

type Node interface {
    node()
}

type Foo struct {
    bar.Baz
}

func (foo) node() {}

@Merovio
No estoy de acuerdo con tu segundo punto negativo. El hecho de que haya muchos lugares en la biblioteca estándar que se beneficiarían enormemente de los tipos de suma, pero que ahora se implementan usando interfaces vacías y pánicos, muestra que esto carece de codificación perjudicial. Por supuesto, la gente podría decir que, dado que dicho código se ha escrito en primer lugar, no hay ningún problema y no necesitamos tipos de suma, pero la locura de esa lógica es que entonces no necesitaríamos ningún otro tipo para la función. firmas, y deberíamos usar interfaces vacías en su lugar.

En cuanto al uso de interfaces con algún método para representar tipos de suma en este momento, hay un gran inconveniente. No sabe qué tipos puede usar para esa interfaz, ya que se implementan implícitamente. Con el tipo de suma adecuado, el tipo en sí describe exactamente qué tipos se pueden usar realmente.

No estoy de acuerdo con tu segundo punto negativo.

¿No está de acuerdo con la afirmación "los tipos de suma fomentan la programación con tipos", o no está de acuerdo con que eso sea un inconveniente? Debido a que no parece que esté en desacuerdo con el primero (su comentario es básicamente una reafirmación de eso) y con respecto al segundo, reconocí que depende de la preferencia anterior.

El hecho de que haya muchos lugares en la biblioteca estándar que se beneficiarían enormemente de los tipos de suma, pero que ahora se implementan usando interfaces vacías y pánicos, muestra que esto carece de codificación perjudicial. Por supuesto, la gente podría decir que, dado que dicho código se ha escrito en primer lugar, no hay ningún problema y no necesitamos tipos de suma, pero la locura de esa lógica es que entonces no necesitaríamos ningún otro tipo para la función. firmas, y deberíamos usar interfaces vacías en su lugar.

Este tipo de argumento en blanco y negro realmente no ayuda . Estoy de acuerdo, que los tipos de suma reducirían el dolor en algunos casos. Cada cambio que haga que el sistema de tipos sea más poderoso reducirá el dolor en algunos casos, pero también causará dolor en algunos casos. Entonces, la pregunta es, ¿cuál es más importante que la otra? (Y eso es, en buena medida, una cuestión de preferencia).

Las discusiones no deberían ser sobre si queremos un sistema de tipos al estilo de python (sin tipos) o un sistema de tipos al estilo coq (pruebas de corrección para todo). La discusión debería ser "si los beneficios de los tipos de suma superan sus desventajas" y es útil reconocer ambos.


FTR, quiero volver a enfatizar que, personalmente, no me opondría tanto a los tipos de suma abiertos (es decir, cada tipo de suma tiene un caso "SomethingElse" implícito o explícito), ya que aliviaría la mayoría de las desventajas técnicas de ellos (sobre todo porque son difíciles de evolucionar) al mismo tiempo que proporcionan la mayor parte de las ventajas técnicas de ellos (comprobación de tipos estáticos, la documentación que mencionaste, puedes enumerar tipos de otros paquetes ...).

Sin embargo, también asumo que las sumas abiertas a) no serán un compromiso satisfactorio para las personas que generalmente presionan por tipos de suma yb) probablemente no se considerarán un beneficio lo suficientemente grande como para justificar su inclusión por parte del equipo de Go. Pero estaría listo para que se demuestre que estoy equivocado en cualquiera de estas suposiciones o en ambas :)

Una pregunta más:

El hecho de que haya muchos lugares en la biblioteca estándar que se beneficiarían enormemente de los tipos de suma

Solo puedo pensar en dos lugares en la biblioteca estándar, donde yo diría que hay un beneficio significativo para ellos: reflexionar e ir / ast. E incluso allí, los paquetes parecen funcionar bien sin ellos. Desde este punto de referencia, las palabras "abundancia" e "inmensamente" parecen exageraciones, pero es posible que no vea muchos lugares legítimos, por supuesto.

database/sql/driver.Value podría beneficiarse de ser un tipo de suma (como se indica en # 23077).
https://godoc.corp.google.com/pkg/database/sql/driver#Value

Sin embargo, la interfaz más pública en database/sql.Rows.Scan no funcionaría sin una pérdida de funcionalidad. El escaneo puede leer valores cuyo tipo subyacente es, por ejemplo, int ; cambiar su parámetro de destino a un tipo de suma requeriría limitar sus entradas a un conjunto finito de tipos.
https://godoc.corp.google.com/pkg/database/sql#Rows.Scan

@Merovio

No me opondría tanto a los tipos de suma abiertos (es decir, cada tipo de suma tiene un caso "SomethingElse" implícito o explícito), ya que aliviaría la mayoría de las desventajas técnicas de ellos (principalmente que son difíciles de evolucionar)

Hay al menos otras dos opciones que alivian el problema de "difícil evolución" de las sumas cerradas.

Una es permitir coincidencias en tipos que en realidad no forman parte de la suma. Luego, para agregar un miembro a la suma, primero actualiza sus consumidores para que coincidan con el nuevo miembro, y solo agrega ese miembro una vez que los consumidores se actualizan.

Otro es permitir miembros "imposibles": es decir, miembros que están explícitamente permitidos en las coincidencias pero explícitamente no permitidos en los valores reales. Para agregar un miembro a la suma, primero lo agrega como un miembro imposible, luego actualiza los consumidores y finalmente cambia el nuevo miembro para que sea posible.

database/sql/driver.Value podría beneficiarse de ser un tipo de suma

De acuerdo, no sabía nada de eso. Gracias :)

Una es permitir coincidencias en tipos que en realidad no forman parte de la suma. Luego, para agregar un miembro a la suma, primero actualiza sus consumidores para que coincidan con el nuevo miembro, y solo agrega ese miembro una vez que los consumidores se actualizan.

Solución intrigante.

Las interfaces de default: . Sin embargo, sin tipos de suma finita, default: significa un caso válido que no conocía o un caso inválido que es un error en algún lugar del programa; con sumas finitas es solo el primero y nunca el último.

json.Token y los tipos sql.Null * son otros ejemplos canónicos. go / types se beneficiaría de la misma forma que go / ast. Supongo que hay muchos ejemplos que no están en las API exportadas donde sería más fácil depurar y probar algunas tuberías complejas limitando el dominio del estado interno. Los encuentro más útiles para el estado interno y las restricciones de aplicación que no aparecen con tanta frecuencia en las API públicas para bibliotecas generales, aunque también tienen sus usos ocasionales allí.

Personalmente, creo que los tipos de suma dan a Go el poder extra suficiente, pero no demasiado. El sistema de tipo Go ya es muy agradable y flexible, aunque tiene sus defectos. Las adiciones de Go2 al sistema de tipos simplemente no brindarán tanta potencia como la que ya está allí; el 80-90% de lo que se necesita ya está en su lugar. Quiero decir, incluso los genéricos no le permitirían fundamentalmente hacer algo nuevo: le permitirían hacer cosas que ya hace de manera más segura, más fácil, con más rendimiento y de una manera que permita mejores herramientas. Los tipos de suma son similares, en mi opinión (aunque, obviamente, si fuera uno u otro, los genéricos tendrían prioridad (y se emparejan bastante bien)).

Si permite un valor predeterminado extraño (todos los casos + el valor predeterminado está permitido) en los conmutadores de tipo suma y no hace que el compilador imponga la exhaustividad (aunque un linter podría hacerlo), agregar un caso a una suma es igual de fácil (e igual de difícil ) como cambiar cualquier otra API pública.

json.Token y los tipos sql.Null * son otros ejemplos canónicos.

Token - seguro. Otra instancia del problema AST (básicamente, cualquier analizador se beneficia de los tipos de suma).

Sin embargo, no veo el beneficio de sql.Null *. Sin genéricos (o agregando algunos incorporados genéricos opcionales "mágicos"), todavía tendrá que tener los tipos y no parece haber una diferencia significativa entre type NullBool enum { Invalid struct{}; Value Int } y type NullBool struct { Valid bool; Value Int } . Sí, soy consciente de que hay una diferencia, pero es muy pequeña.

Si permite un valor predeterminado extraño (todos los casos + el valor predeterminado está permitido) en los conmutadores de tipo suma y no hace que el compilador imponga la exhaustividad (aunque un linter podría hacerlo), agregar un caso a una suma es igual de fácil (e igual de difícil ) como cambiar cualquier otra API pública.

Véase más arriba. Esas son las que yo llamo sumas abiertas, me opongo menos a ellas.

Esas son las que yo llamo sumas abiertas, me opongo menos a ellas.

Mi propuesta específica es https://github.com/golang/go/issues/19412#issuecomment -323208336 y creo que puede satisfacer su definición de abierto, aunque todavía es un poco tosco y estoy seguro de que hay más por hacer. quitar y pulir. En particular, noté que no estaba claro que un caso predeterminado fuera admisible incluso si todos los casos estaban enumerados, así que lo actualicé.

Estuvo de acuerdo en que los tipos opcionales no son la mejor aplicación de los tipos de suma. Sin embargo, son bastante agradables y, como señala con los genéricos que definen un

type Nullable(T) pick { // or whatever syntax (on all counts)
  Null struct{}
  Value T
}

una vez y cubrir todos los casos sería genial. Pero, como también señala, podríamos hacer lo mismo con un producto genérico (estructura). Existe el estado inválido de Valid = false, Value! = 0. En ese escenario, sería fácil eliminar si eso estuviera causando problemas, ya que 2 ⨯ T es pequeño, incluso si no es tan pequeño como 1 + T.

Por supuesto, si fuera una suma más complicada con muchos casos y muchas invariantes superpuestas, sería más fácil cometer un error y más difícil descubrir el error incluso con la programación defensiva, por lo que hacer que las cosas imposibles simplemente no se compilen puede ahorrar mucho cabello. tracción.

Token - seguro. Otra instancia del problema AST (básicamente, cualquier analizador se beneficia de los tipos de suma).

Escribo muchos programas que toman algo de entrada, procesan y producen algo de salida y generalmente divido esto de forma recursiva en muchas pasadas que dividen la entrada en casos y la transforman en función de esos casos a medida que se acercan cada vez más a la salida deseada. Puede que no esté escribiendo literalmente un analizador (¡hay que admitir que a veces lo hago porque es divertido!), Pero encuentro que el problema de AST, como usted dice, se aplica a una gran cantidad de código, especialmente cuando se trata de una lógica empresarial abstrusa que tiene demasiados elementos extraños. requisitos y casos de borde para caber en mi pequeña cabeza.

Cuando estoy escribiendo una biblioteca general, no aparece en la API con tanta frecuencia como hacer un ETL o un informe fantasioso o asegurarme de que los usuarios en el estado X tengan la acción Y si no están marcados con Z. Incluso en una biblioteca general, aunque encuentro lugares donde poder limitar el estado interno ayudaría, incluso si solo reduce una depuración de 10 minutos a 1 segundo "oh, el compilador dijo que estoy equivocado".

Con Go, en particular, un lugar donde usaría tipos de suma es una goroutine seleccionando un montón de canales donde necesito dar 3 canales a una goroutine y 2 a otra. Me ayudaría a rastrear lo que está sucediendo para poder usar un chan pick { a A; b B; c C } sobre chan A , chan B , chan C aunque un chan stuct { kind MsgKind; a A; b B; c C } puede haga el trabajo en un apuro a costa de espacio adicional y menos validación.

En lugar de un nuevo tipo, ¿qué pasa con la verificación de la lista de tipos en tiempo de compilación como una adición a la función de cambio de tipo de interfaz 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 justos, deberíamos explorar formas de aproximar los tipos de suma en el sistema de tipos actual y sopesar sus pros y contras. Si nada más, proporciona una línea de base para la comparación.

El medio estándar es una interfaz con un método de no hacer nada no exportado como etiqueta.

Un argumento en contra es que cada tipo de la suma debe tener esta etiqueta definida. Esto no es estrictamente cierto, al menos para los miembros que son estructuras, podríamos hacer

type Sum interface { sum() }
type sum struct{}
func (sum) sum() {}

e incruste esa etiqueta de ancho 0 en nuestras estructuras.

Podemos agregar tipos externos a nuestra suma introduciendo un contenedor

type External struct {
  sum
  *pkg.SomeType
}

aunque esto es un poco desgarbado.

Si todos los miembros de la suma comparten un comportamiento común, podemos incluir esos métodos en la definición de la interfaz.

Construcciones como esta nos permiten decir que un tipo está en una suma, pero no nos deja decir lo que no está en esa suma. Además del caso obligatorio nil , paquetes externos como

import "p"
var member struct {
  p.Sum
}

Dentro del paquete tenemos que cuidarnos de validar los valores que se compilan pero que son ilegales.

Hay varias formas de recuperar algo de seguridad de tipos en tiempo de ejecución. Descubrí que se incluye un método valid() error en la definición de la interfaz de suma junto con una función 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")
}

sea ​​útil ya que permite encargarse de dos tipos de validación a la vez. Para los miembros que resultan ser siempre válidos, podemos evitar algunos boilerplate con

type alwaysValid struct{}
func (alwaysValid) valid() error { return nil }

Una de las quejas más comunes sobre este patrón es que no deja clara la pertenencia a la suma en godoc. Dado que tampoco nos permite excluir miembros y requiere que validemos de todos modos, hay una forma sencilla de evitar esto: exportar el método ficticio.
En lugar de,

//A Node is one of (list of types).
type Node interface { node() }

escribir

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

No podemos evitar que nadie satisfaga Node por lo que también podemos hacerles saber lo que hace. Si bien esto no deja claro de un vistazo qué tipos satisfacen Node (sin lista central), sí deja claro si el tipo en particular que está viendo ahora satisface Node .

Este patrón es útil cuando la mayoría de los tipos de la suma se definen en el mismo paquete. Cuando no hay ninguno, el recurso común es volver a interface{} , como json.Token o driver.Value . Podríamos usar el patrón anterior con tipos de envoltura para cada uno, pero al final dice tanto como interface{} por lo que no tiene mucho sentido. Si esperamos que dichos valores provengan de fuera del paquete, podemos ser corteses y definir una 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")
}

Un uso común de las sumas es para tipos opcionales, donde debe diferenciar entre "sin valor" y "un valor que puede ser cero". Hay dos maneras de hacer esto.

*T permite significar ningún valor como un puntero nil y un valor (posiblemente) cero como resultado de eliminar la cerca de un puntero que no sea nulo.

Al igual que las aproximaciones anteriores basadas en interfaces y las diversas propuestas para implementar tipos de suma como interfaces con restricciones, esto requiere una desreferencia de puntero adicional y una posible asignación de montones.

Para los opcionales, esto se puede evitar usando la técnica del paquete sql

type OptionalT struct {
  Valid bool
  Value T
}

La principal desventaja de esto es que permite codificar el estado no válido: Valid puede ser falso y Value puede ser distinto de cero. También es posible tomar Value cuando Valid es falso (aunque esto puede ser útil si desea el T cero si no se especificó). Si se establece de forma casual Válido en falso sin poner a cero el Valor seguido de establecer Válido en verdadero (o ignorarlo) sin asignar un Valor, un valor previamente descartado resurge accidentalmente. Esto se puede solucionar proporcionando setters y getters para proteger las invariantes del tipo.

La forma más simple de tipos de suma es cuando te preocupas por la identidad, no por el valor: enumeraciones.

La forma tradicional de manejar esto en Go es const / iota:

type Enum int
const (
  A Enum = iota
  B
  C
)

Al igual que el tipo OptionalT esto no tiene ninguna indirección innecesaria. Al igual que las sumas de la interfaz, no limita el dominio: solo hay tres valores válidos y muchos valores no válidos, por lo que debemos validar en tiempo de ejecución. Si hay exactamente dos valores, podemos usar bool.

También está la cuestión de la numeración fundamental de este tipo. A+B == C . Podemos convertir constantes integrales sin tipo a este tipo con demasiada facilidad. Hay muchos lugares donde eso es deseable, pero lo conseguimos pase lo que pase. Con un poco de trabajo adicional, podemos limitar esto solo a la identidad:

type Enum struct { v int }
var (
  A = Enum{0}
  B = Enum{1}
  C = Enum{2}
)

Ahora bien, estas son solo etiquetas opacas. Se pueden comparar pero eso es todo. Desafortunadamente, ahora perdimos la consistencia, pero podríamos recuperarla con un poco más de trabajo:

func A() Enum { return Enum{0} }
func B() Enum { return Enum{1} }
func C() Enum { return Enum{2} }

Hemos recuperado la incapacidad de un usuario externo para alterar los nombres a costa de algunas llamadas repetitivas y algunas funciones que son altamente compatibles.

Sin embargo, esto es en cierto modo más agradable que las sumas de la interfaz, ya que casi hemos cerrado el tipo por completo. El código externo solo puede usar A() , B() o C() . No pueden intercambiar las etiquetas como en el ejemplo var y no pueden hacer A() + B() y somos libres de definir los métodos que queramos en Enum . Aún sería posible que el código en el mismo paquete creara o modificara erróneamente un valor, pero si nos aseguramos de que eso no suceda, este es el primer tipo de suma que no requiere código de validación: si existe, es válido .

A veces tienes muchas etiquetas y algunas de ellas tienen fecha adicional y las que sí tienen el mismo tipo de datos. Digamos que tiene un valor que tiene tres estados sin valor (A, B, C), dos con un valor de cadena (D, E) y uno con un valor de cadena y un valor int (F). Podríamos usar varias combinaciones de las tácticas anteriores, pero la forma más sencilla es

type Value struct {
  Which int // could have consts for A, B, C, D, E, F
  String string
  Int int
}

Esto se parece mucho al tipo OptionalT anterior, pero en lugar de un bool tiene una enumeración y hay varios campos que se pueden configurar (o no) dependiendo del valor de Which . La validación debe tener cuidado de que se establezcan (o no) de forma adecuada.

Hay muchas formas de expresar "una de las siguientes" en Go. Algunos requieren más cuidados que otros. A menudo requieren validar el invariante "uno de" en tiempo de ejecución o desreferencias extrañas. Una desventaja importante que todos comparten es que, dado que se están simulando en el idioma en lugar de ser parte del idioma, el invariante "uno de" no aparece en reflect o go / types, lo que dificulta la metaprogramación con ellos. Para usarlos en la metaprogramación, ambos deben ser capaces de reconocer y validar el tipo correcto de suma y que se les diga que eso es lo que está buscando, ya que todos se parecen mucho a un código válido sin el invariante "uno de".

Si los tipos de suma fueran parte del lenguaje, podrían reflejarse y extraerse fácilmente del código fuente, lo que resultaría en mejores bibliotecas y herramientas. El compilador podría realizar una serie de optimizaciones si fuera consciente del invariante "uno de". Los programadores podrían centrarse en el código de validación importante en lugar del mantenimiento trivial de comprobar que un valor está en el dominio correcto.

Construcciones como esta nos permiten decir que un tipo está en una suma, pero no nos deja decir lo que no está en esa suma. Además del caso obligatorio nil, el mismo truco de incrustación puede ser utilizado por paquetes externos como
[…]
Dentro del paquete tenemos que cuidarnos de validar los valores que se compilan pero que son ilegales.

¿Por qué? Como autor de un paquete, esto me parece firmemente en el ámbito de "su problema". Si me pasa un io.Reader , cuyo método Read entra en pánico, no voy a recuperarme de eso y dejar que entre en pánico. Del mismo modo, si se esfuerza por crear un valor no válido de un tipo que declaré, ¿quién soy yo para discutir con usted? Es decir, considero que "incrusté una suma cerrada emulada" un problema que rara vez (si es que alguna vez) surge por accidente.

Dicho esto, puede evitar ese problema cambiando la interfaz a type Sum interface { sum() Sum } y hacer que todos los valores se devuelvan por sí mismos. De esa manera, puede usar la devolución de sum() , que se comportará bien incluso en la incrustación.

Una de las quejas más comunes sobre este patrón es que no deja clara la pertenencia a la suma en godoc.

Esto puede ayudarte .

La principal desventaja de esto es que permite codificar el estado no válido: Valid puede ser falso y Value puede ser distinto de cero.

Este no es un estado inválido para mí. Los valores cero no son mágicos. No hay diferencia, en mi opinión, entre sql.NullInt64{false,0} y NullInt64{false,42} . Ambas son representaciones válidas y equivalentes de un SQL NULL. Si todos los códigos son válidos antes de usar Value, la diferencia no es observable para un programa.

Es una crítica justa y correcta que el compilador no obligue a hacer esta verificación (lo que probablemente haría, para opcionales / tipos de suma "reales"), por lo que es más fácil no hacerlo. Pero si lo olvida, no consideraría mejor usar accidentalmente un valor cero que usar accidentalmente un valor distinto de cero (con la posible excepción de los tipos en forma de puntero, ya que entrarían en pánico cuando se usaran, por lo tanto fallando en voz alta, pero para esos, debe usar el tipo en forma de puntero de todos modos y usar nil como "desarmado").

También está la cuestión de la numeración fundamental de este tipo. A + B == C. Podemos convertir constantes integrales sin tipo a este tipo con demasiada facilidad.

¿Es esta una preocupación teórica o ha surgido en la práctica?

Los programadores podrían centrarse en el código de validación importante en lugar del mantenimiento trivial de comprobar que un valor está en el dominio correcto.

Solo FTR, en los casos en que uso sum-types-as-sum-types (es decir, el problema no se puede modelar de manera más elegante a través de interfaces de variedad dorada) nunca escribo ningún código de validación. Al igual que no verifico la ausencia de punteros pasados ​​como receptores o argumentos (a menos que esté documentado como una variante válida). En los lugares donde el compilador me obliga a lidiar con eso (es decir, problemas de estilo "sin retorno al final de la función"), entro en pánico en el caso predeterminado.

Personalmente, considero que Go es un lenguaje pragmático, que no solo agrega características de seguridad por su propio bien o porque "todos saben que son mejores", sino que se basa en una necesidad demostrada. Creo que usarlo de manera pragmática está bien.

El medio estándar es una interfaz con un método de no hacer nada no exportado como etiqueta.

Hay una diferencia fundamental entre las interfaces y los tipos de suma (no vi que se mencionara en su publicación). Cuando se aproxima un tipo de suma a través de una interfaz, realmente no hay forma de manejar el valor. Como consumidor, no tiene idea de lo que realmente contiene y solo puede adivinar. Esto no es mejor que usar una interfaz vacía. Su única utilidad es si alguna implementación solo puede provenir del mismo paquete que define la interfaz, ya que solo entonces puedes controlar lo que puedes obtener.

Por otro lado, tener algo como:

func foo(val string|int|error) {
    switch v:= val.(type) {
    case string:
        ...
    }
}

Otorga al consumidor pleno poder para utilizar el valor del tipo de suma. Su valor es concreto, no está abierto a interpretaciones.

@Merovio
Estas "sumas abiertas" que mencionas tienen lo que algunas personas podrían clasificar como un inconveniente significativo, ya que permitirían abusar de ellas para "fluencia de características". Esta misma razón se ha dado por la cual los argumentos de funciones opcionales se han rechazado como característica.

Estas "sumas abiertas" que mencionas tienen lo que algunas personas podrían clasificar como un inconveniente significativo, ya que permitirían abusar de ellas para "fluencia de características". Esta misma razón se ha dado por la cual los argumentos de funciones opcionales se han rechazado como característica.

Eso me parece un argumento bastante débil; si nada más, entonces porque existen, por lo que ya está permitiendo lo que permitan. De hecho, ya tenemos argumentos opcionales , para todos los efectos (no es que me guste ese patrón, pero claramente ya es posible en el lenguaje).

Hay una diferencia fundamental entre las interfaces y los tipos de suma (no vi que se mencionara en su publicación). Cuando se aproxima un tipo de suma a través de una interfaz, realmente no hay forma de manejar el valor. Como consumidor, no tiene idea de lo que realmente contiene y solo puede adivinar.

Intenté analizar esto por segunda vez y todavía no puedo. ¿Por qué no podrías usarlos? Pueden ser tipos exportados regulares. Sí, tienen que ser tipos creados en su paquete (obviamente), pero aparte de eso, no parece haber ninguna restricción en cómo puede usarlos, en comparación con las sumas cerradas reales.

Intenté analizar esto por segunda vez y todavía no puedo. ¿Por qué no podrías usarlos? Pueden ser tipos exportados regulares. Sí, tienen que ser tipos creados en su paquete (obviamente), pero aparte de eso, no parece haber ninguna restricción en cómo puede usarlos, en comparación con las sumas cerradas reales.

¿Qué sucede en el caso en que se exporta el método ficticio y cualquier tercero puede implementar el "tipo de suma"? ¿O el escenario bastante realista en el que un miembro del equipo no está familiarizado con los diversos consumidores de la interfaz, decide agregar otra implementación en el mismo paquete y una instancia de esa implementación termina pasando a estos consumidores a través de varios medios del código? A riesgo de repetir mi afirmación aparente "imposible de analizar": "Como consumidor, no tiene idea de lo que [el valor de la suma] tiene realmente, y sólo puede adivinar". Ya sabes, ya que es una interfaz y no te dice quién la está implementando.

@Merovio

Solo FTR, en los casos en que uso sum-types-as-sum-types (es decir, el problema no se puede modelar de manera más elegante a través de interfaces de variedad dorada) nunca escribo ningún código de validación. Al igual que no verifico la ausencia de punteros pasados ​​como receptores o argumentos (a menos que esté documentado como una variante válida). En los lugares donde el compilador me obliga a lidiar con eso (es decir, problemas de estilo "sin retorno al final de la función"), entro en pánico en el caso predeterminado.

No trato esto como algo de siempre o nunca .

Si alguien que pasa una entrada incorrecta explota inmediatamente, no me molesto con el código de validación.

Pero si alguien que pasa una entrada incorrecta puede eventualmente causar pánico pero no aparecerá por un tiempo, entonces escribo el código de validación para que la entrada incorrecta se marque lo antes posible y nadie tenga que darse cuenta de que el error se introdujo 150 marcos en la pila de llamadas (especialmente porque luego pueden tener que subir otros 150 marcos en la pila de llamadas para averiguar dónde se introdujo ese valor incorrecto).

Pasar medio minuto ahora para ahorrar potencialmente media hora de depuración más tarde es pragmático. Especialmente para mí, ya que cometo errores tontos todo el tiempo y cuanto antes me eduque, antes puedo seguir adelante para cometer el siguiente error tonto.

Si tengo una función que toma un lector e inmediatamente comienza a usarla, no buscaré nil, pero si la función es una fábrica para una estructura que no llamará al lector hasta que se invoque un determinado método, lo haré verifique que no sea nulo y entre en pánico o devuelva un error con algo como "el lector no debe ser nulo" para que la causa del error esté lo más cerca posible de la fuente del error.

godoc -análisis

Soy consciente pero no lo encuentro útil. Se ejecutó durante 40 minutos en mi espacio de trabajo antes de presionar ^ C y debe actualizarse cada vez que se instala o modifica un paquete. Sin embargo, hay # 20131 (¡bifurcado de este mismo hilo!).

Dicho esto, puede evitar ese problema cambiando la interfaz a type Sum interface { sum() Sum } y hacer que todos los valores se devuelvan por sí mismos. De esa manera, puede usar el retorno de sum() , que se comportará bien incluso en la incrustación.

No lo he encontrado tan útil. No proporciona más beneficios que la validación explícita y proporciona menos validación.

¿Es [el hecho de que pueda agregar miembros de una enumeración const / iota] una preocupación teórica o ha surgido en la práctica?

Ese en particular era teórico: estaba tratando de enumerar todos los pros y los contras que podía pensar, teóricos y prácticos. Mi punto más importante, sin embargo, fue que había muchas formas de tratar de expresar el invariante "uno de" en el lenguaje que se usan con bastante frecuencia, pero ninguna tan simple como hacer que sea una especie de tipo en el lenguaje.

¿Es [el hecho de que pueda asignar una integral no tipificada a una enumeración const / iota] una preocupación teórica o ha surgido en la práctica?

Ese ha surgido en la práctica. No tomó mucho tiempo averiguar qué salió mal, pero habría tomado incluso menos tiempo si el compilador hubiera dicho "ahí, esa línea, esa es la que está mal". Se habla de otras formas de manejar ese caso en particular, pero no veo cómo serían de uso general.

Este no es un estado inválido para mí. Los valores cero no son mágicos. No hay diferencia, en mi opinión, entre sql.NullInt64{false,0} y NullInt64{false,42} . Ambas son representaciones válidas y equivalentes de un SQL NULL. Si todos los códigos son válidos antes de usar Value, la diferencia no es observable para un programa.

Es una crítica justa y correcta que el compilador no obligue a hacer esta verificación (lo que probablemente haría, para los tipos opcionales / de suma "reales"), por lo que es más fácil no hacerlo. Pero si lo olvida, no consideraría mejor usar accidentalmente un valor cero que usar accidentalmente un valor distinto de cero (con la posible excepción de los tipos en forma de puntero, ya que entrarían en pánico cuando se usaran, por lo tanto fallando en voz alta, pero para esos, debe usar el tipo en forma de puntero desnudo de todos modos y usar nil como "desarmado").

Ese "Si todo el código es válido antes de usar Value" es donde se introducen los errores y lo que el compilador podría hacer cumplir. He tenido errores como ese (aunque con versiones más grandes de ese patrón, donde había más de un campo de valor y más de dos estados para el discriminador). Creo / espero haber encontrado todos estos durante el desarrollo y las pruebas y ninguno se escapó a la naturaleza, pero sería bueno si el compilador me hubiera dicho cuándo cometí ese error y pudiera estar seguro de que la única forma en que uno de estos Pasó por alto si había un error en el compilador, de la misma manera que me diría si intentaba asignar una cadena a una variable de tipo int.

Y, claro, prefiero *T para tipos opcionales, aunque eso tiene costos distintos de cero asociados, tanto en el espacio-tiempo de ejecución como en la legibilidad del código.

(Para ese ejemplo en particular, el código para obtener el valor real o el valor cero correcto con la propuesta de selección sería v, _ := nullable.[Value] que es conciso y seguro).

Eso no es lo que quisiera. Los tipos de selección deben ser tipos de valor,
como en Rust. Su primera palabra debe ser un puntero a los metadatos de GC, si es necesario.

De lo contrario, su uso conlleva una penalización de rendimiento que podría ser
inaceptable. Para mí, el pase 10:41 a. M., "Josh Bleecher Snyder" <
[email protected]> escribió:

Con la propuesta de selección, puede elegir que ap o * p le brinden más
mayor control sobre las compensaciones de la memoria.

La razón por la que las interfaces se asignan para almacenar valores escalares es para que usted no
tiene que leer una palabra de tipo para decidir si la otra palabra es un
puntero; consulte # 8405 https://github.com/golang/go/issues/8405 para
discusión. Es probable que se apliquen las mismas consideraciones de implementación para un
tipo de selección, lo que podría significar en la práctica que p terminan asignando y siendo
no local de todos modos.

-
Estás recibiendo esto porque eres el autor del hilo.
Responda a este correo electrónico directamente, véalo en GitHub
https://github.com/golang/go/issues/19412#issuecomment-323371837 , o silenciar
la amenaza
https://github.com/notifications/unsubscribe-auth/AGGWB-wQD75N44TGoU6LWQhjED_uhKGUks5sZaKbgaJpZM4MTmSr
.

@urandom

¿Qué sucede en el caso en que se exporta el método ficticio y cualquier tercero puede implementar el "tipo de suma"?

Existe una diferencia entre el método que se exporta y el tipo que se exporta. Parece que estamos hablando entre nosotros. Para mí, esto parece funcionar bien, sin ninguna diferencia entre sumas abiertas y cerradas:

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 }

No es posible una extensión fuera del paquete, sin embargo, los consumidores del paquete pueden usar, crear y pasar los valores como cualquier otro.

Puede incrustar X, o uno de los tipos locales que lo satisfagan, externamente y luego pasarlo a una función en su paquete que toma una X.

Si esa función llama a x, entra en pánico (si X mismo estaba incrustado y no se configuró en nada) o devuelve un valor con el que su código puede operar, pero no es lo que pasó la persona que llama, lo que sería un poco sorprendente para la persona que llama. (y su código ya es sospechoso si están intentando algo como esto porque no leyeron los documentos).

Llamar a un validador que entra en pánico con un mensaje de "no hagas eso" parece la forma menos sorprendente de manejar eso y permite que la persona que llama corrija su código.

Si esa función llama a x, entra en pánico […] o devuelve un valor con el que su código puede operar, pero no es lo que pasó la persona que llama, lo que sería un poco sorprendente para la persona que llama.

Como dije anteriormente: si le sorprende que su construcción intencional de un valor no válido no sea válida, debe reconsiderar sus expectativas. Pero en cualquier caso, no se trata de eso en particular este tipo de discusión y sería útil mantener separados los argumentos. Este fue sobre @urandom diciendo que las sumas abiertas a través de interfaces con métodos de etiqueta no serían introspectables ni utilizables por otros paquetes. Encuentro que es una afirmación dudosa, sería genial si pudiera aclararse.

El problema es que alguien puede crear un tipo que no está en la suma que compila y se puede pasar a su paquete.

Sin agregar los tipos de suma adecuados al idioma, hay tres opciones para manejarlo

  1. ignora la situación
  2. validar y entrar en pánico / devolver un error
  3. intenta "hacer lo que quieres decir" extrayendo implícitamente el valor incorporado y usándolo

3 me parece una extraña mezcla de 1 y 2: no veo lo que compra.

Estoy de acuerdo en que "si le sorprende que su construcción intencional de un valor no válido no sea válido, debe reconsiderar sus expectativas", pero, con 3, puede ser muy difícil darse cuenta de que algo salió mal e incluso cuando lo hace Sería difícil averiguar por qué.

2 parece mejor porque protege el código para que no caiga en un estado no válido y envía un destello si alguien se equivoca y les deja saber por qué están equivocados y cómo corregirlos.

¿Estoy malinterpretando la intención del patrón o simplemente nos estamos acercando a esto desde diferentes filosofías?

@urandom también agradecería una aclaración; Tampoco estoy 100% seguro de lo que intentas decir.

El problema es que alguien puede crear un tipo que no está en la suma que compila y se puede pasar a su paquete.

Siempre puedes hacer eso; en caso de duda, siempre puede usar inseguro, incluso con tipos de suma comprobados por el compilador (y no veo eso como una forma cualitativamente diferente de construir valores inválidos de incrustar algo que está claramente destinado a ser una suma y no inicializarlo en un valor válido). La pregunta es "con qué frecuencia esto planteará un problema en la práctica y qué tan grave será ese problema". En mi opinión, con la solución anterior, la respuesta es "prácticamente nunca y muy poco"; aparentemente no estás de acuerdo, lo cual está bien. Pero de cualquier manera, no parece tener mucho sentido trabajar en esto: los argumentos y puntos de vista en ambos lados de este punto en particular deben ser lo suficientemente claros y estoy tratando de evitar la repetición demasiado ruidosa y enfocarme en lo genuino nuevos argumentos. Mencioné la construcción anterior para demostrar que no hay diferencia en la exportabilidad entre los tipos de suma de primera clase y las sumas a través de interfaces emuladas. No para demostrar que son estrictamente mejores en todos los sentidos.

en caso de duda, siempre puede usar inseguro, incluso con tipos de suma comprobados por el compilador (y no veo eso como una forma cualitativamente diferente de construir valores inválidos de incrustar algo que está claramente destinado a ser una suma y no inicializarlo en un valor válido).

Creo que es cualitativamente diferente: cuando las personas hacen un mal uso de la incrustación de esta manera (al menos con proto.Message y los tipos concretos que la implementan), generalmente no piensan en si es segura y qué invariantes podría romper. . (Los usuarios asumen que las interfaces describen completamente los comportamientos requeridos, pero cuando las interfaces se emplean como tipos de unión o suma, a menudo no lo hacen. Consulte también https://github.com/golang/protobuf/issues/364).

Por el contrario, si alguien usa el paquete unsafe para establecer una variable en un tipo al que normalmente no puede referirse, está más o menos explícitamente afirmando haber pensado al menos en lo que podría romper y por qué.

@Merovius Quizás no he sido claro: el hecho de que el compilador le diga a alguien que usó la incrustación incorrecta es más un buen beneficio secundario.

La mayor ventaja de la característica de seguridad es que se honraría con reflect y se representaría en go / types. Eso le da a las herramientas y bibliotecas más información con la que trabajar. Hay muchas formas de simular tipos de suma en Go, pero todas son idénticas al código de tipo no suma, por lo que las herramientas y la biblioteca necesitan información fuera de banda para saber que es un tipo de suma y debe poder reconocer el patrón específico que se utilizan, pero incluso esos patrones permiten una variación significativa.

También haría insegura la única forma de crear un valor no válido: ahora tiene código regular, código generado y reflejo; es más probable que los dos últimos causen un problema, ya que, a diferencia de una persona, no puede leer la documentación.

Otro beneficio secundario de la seguridad significa que el compilador tiene más información y puede generar un código mejor y más rápido.

También está el hecho de que además de poder reemplazar la pseudo-suma con interfaces, podría reemplazar la pseudo-suma "uno de estos tipos regulares" como json.Token o driver.Value . Esos son pocos y distantes entre sí, pero sería un lugar menos donde interface{} es necesario.

También haría insegura la única forma de crear un valor no válido

No creo que entiendo la definición de "valor inválido" que lleva a esta afirmación.

@neild si tuvieras

var v pick {
  None struct{}
  A struct { X int; Y *T}
  B int
}

se plasmaría en la memoria 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
}

y con inseguro podría establecer thePtr incluso si activeField fuera 0 o 2 o establecer un valor de theInt incluso si activeField fuera 0.

En cualquier caso, esto invalidaría las suposiciones que haría el compilador y permitiría el mismo tipo de errores teóricos que podemos tener hoy.

Pero como @bcmills señaló, si está usando inseguro, es mejor que sepa lo que está haciendo porque es la opción nuclear.

Lo que no entiendo es por qué inseguro es la única forma de crear un valor no válido.

var t time.Timer

t es un valor no válido; t.C está configurado, llamar a t.Stop entrará en pánico, etc. No se requiere seguridad.

Algunos lenguajes tienen sistemas de tipos que hacen todo lo posible para evitar la creación de valores "no válidos". Go no es uno de ellos. No veo cómo los sindicatos mueven esa aguja de manera significativa. (Hay otras razones para apoyar a los sindicatos, por supuesto).

@neild sí lo siento, estoy siendo flojo con mis definiciones.

Debería haber dicho inválido con respecto a las invariantes del tipo suma .

Por supuesto, los tipos individuales de la suma pueden estar en un estado no válido.

Sin embargo, mantener los invariantes de tipo suma significa que son accesibles para reflejar e ir / tipos, así como para el programador, por lo que manipularlos en bibliotecas y herramientas mantiene esa seguridad y proporciona más información al metaprogramador.

@jimmyfrasche , estoy diciendo que a diferencia de un tipo de suma, que te dice todos los tipos posibles que puede ser, una interfaz es opaca en el sentido de que no sabes, o al menos no puedes usar, cuál es la lista de tipos que implementan la interfaz son. Esto hace que escribir la parte switch del código sea un poco una conjetura:

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

Entonces, me parece que la mayoría de los problemas que la gente tiene con la emulación de tipo suma basada en interfaz se puede resolver mediante peajes y / o convención. Por ejemplo, si una interfaz contiene un método no exportado, sería trivial averiguar todas las implementaciones posibles (sí, elusión intencional). De manera similar, para abordar la mayoría de los problemas con enumeraciones basadas en iota, una simple convención de "una enumeración es un type Foo int con una declaración de la forma const ( FooA Foo = iota; FooB; FooC ) " permitiría escribir herramientas extensas y precisas para ellos también.

Sí, esto no es equivalente a los tipos de suma reales (entre otras cosas, no obtendrían soporte de reflejo de primera clase, aunque realmente no entiendo cuán importante sería eso de todos modos), pero sí significa que las soluciones existentes parecen, desde mi punto de vista, mejores de lo que suelen pintarse. Y en mi opinión, valdría la pena explorar ese espacio de diseño antes de ponerlos en Go 2, al menos si realmente son tan importantes para las personas.

(y quiero volver a enfatizar que soy consciente de las ventajas de los tipos de suma, por lo que no hay necesidad de reformularlos para mi beneficio. Simplemente no los pongo tanto como a otras personas, también veo las desventajas y, por lo tanto, llegar a conclusiones diferentes sobre los mismos datos)

@Merovius esa es una buena posición.

El soporte de reflect permitiría tanto a las bibliotecas como a las herramientas fuera de línea (linters, generadores de código, etc.) acceder a la información y no permitirle modificarla de forma inapropiada, lo que no puede ser detectado estáticamente con precisión.

Independientemente, es una buena idea explorar, así que explorémosla.

Para recapitular las familias más comunes de pseudosums en Go son: (aproximadamente en orden de aparición)

  • const / iota enum.
  • Interfaz con método de etiqueta para tipos de suma definidos en el mismo paquete.
  • *T por un T opcional
  • estructura con una enumeración cuyo valor determina qué campos se pueden establecer (cuando la enumeración es un bool y solo hay otro campo, este es otro tipo de T opcional)
  • interface{} que está restringido a una bolsa de sorpresas de un conjunto finito de tipos.

Todos ellos se pueden usar tanto para tipos de suma como para tipos que no son de suma. Los dos primeros se usan tan raramente para cualquier otra cosa que podría tener sentido asumir que representan tipos de suma y aceptar el falso positivo ocasional. Para sumas de interfaz, podría limitarlo a un método no exportado sin parámetros o devoluciones y sin cuerpo en ningún miembro. Para las enumeraciones, tendría sentido reconocerlas solo cuando son solo Type = iota para que no se tropiece cuando se usa iota como parte de una expresión.

*T para un T opcional sería muy difícil de distinguir de un puntero normal. A esto se le podría dar la convención type O = *T . Eso sería posible de detectar, aunque un poco difícil ya que el nombre de alias no es parte del tipo. type O *T sería más fácil de detectar pero más difícil de trabajar en el código. Por otro lado, todo lo que se necesita hacer está esencialmente integrado en el tipo, por lo que hay poco que ganar en herramientas reconociendo esto. Simplemente ignoremos este. (Los genéricos probablemente permitirían algo como type Optional(T) *T lo que simplificaría el "etiquetado" de estos).

La estructura con una enumeración sería difícil de razonar en las herramientas, ¿qué campos van con qué valor para la enumeración? Podríamos simplificar esto a la convención de que debe haber un campo por miembro en la enumeración y que el valor de la enumeración y el valor del campo deben ser iguales, por ejemplo:

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

Eso no obtendría tipos opcionales, pero podríamos usar un caso especial "2 campos, el primero es bool" en el reconocedor.

Usar un interface{} para una suma de bolsa de sorpresas sería imposible de detectar sin un comentario mágico como //gosum: int, float64, string, Foo

Alternativamente, podría haber un paquete especial con las siguientes definiciones:

package sum
type (
  Type struct{}
  Enum int
  OneOf interface{}
)

y solo reconocen enumeraciones si tienen la forma type MyEnum sum.Enum , solo reconocen interfaces y estructuras solo si incrustan sum.Type , y solo reconocen interface{} bolsas de mano como type GrabBag sum.OneOf (pero eso aún necesitaría un comentario reconocible por la máquina para explicar sus comentarios). Eso tendría los siguientes pros y contras:
Pros

  • explícito en el código: si está así marcado es 100% un tipo de suma, sin falsos positivos.
  • esas definiciones podrían tener documentación que explique lo que significan y la documentación del paquete podría vincular a herramientas que se pueden usar con estos tipos
  • algunos tendrían algo de visibilidad en el reflejo
    Contras
  • Un montón de falsos negativos del código antiguo y el stdlib (que no los usaría).
  • Tendrían que usarse para ser útiles, por lo que la adopción sería lenta y probablemente nunca llegaría al 100% y la efectividad de las herramientas que reconocieron este paquete especial sería una función de la adopción, tan interesante aunque experimental pero probablemente poco realista.

Independientemente de cuál de esas dos formas se use para identificar los tipos de suma, supongamos que fueron reconocidos y pasemos a usar esa información para ver qué tipo de herramientas podemos construir.

Podemos agrupar aproximadamente las herramientas en generativas (como stringer) e introspectivas (como golint).

El código generativo más simple sería una herramienta para completar una declaración de cambio con los casos que faltan. Esto podría ser utilizado por los editores. Una vez que un tipo de suma se identifica como un tipo de suma, esto es trivial (un poco tedioso, pero la lógica de generación real será la misma con o sin soporte de lenguaje).

En todos los casos sería posible generar una función que valide el invariante "uno de".

Para enumeraciones, podría haber más herramientas como stringer. En https://github.com/golang/go/issues/19814#issuecomment -291002852 mencioné algunas posibilidades.

La herramienta generadora más grande es el compilador que podría producir un mejor código de máquina con esta información, pero bueno.

No puedo pensar en otros en este momento. ¿Hay algo en la lista de deseos de alguien?

Para la introspección, el candidato obvio es la exhaustividad. Sin soporte de idiomas, en realidad se requieren dos tipos diferentes de pelusa

  1. asegurándose de que se manejen todos los estados posibles
  2. asegurándose de que no se creen estados inválidos (lo que invalidaría el trabajo realizado por 1)

1 es trivial, pero requeriría todos los estados posibles y un caso predeterminado porque 2 no se puede verificar al 100% (incluso ignorando inseguro) y no puede esperar que todo el código que usa su código ejecute este linter de todos modos.

2 realmente no pudo seguir los valores a través de reflect o identificar todo el código que podría generar un estado no válido para la suma, pero podría detectar muchos errores simples, como si insertaras un tipo de suma y luego llamaras a una función con él, podría decir "escribiste pkg.F (v) pero te referías a pkg.F (v.EmbeddedField)" o "pasaste 2 a pkg.F, usa pkg.B". Para la estructura, no pudo hacer mucho para hacer cumplir el invariante de que un campo se establece a la vez, excepto en casos realmente obvios como "está activando Cuál y en el caso X establece el campo F en un valor distinto de cero ". Podría insistir en que utilice la función de validación generada al aceptar valores externos al paquete.

La otra gran cosa sería aparecer en godoc. godoc ya agrupa const / iota y # 20131 ayudaría con los pseudosums de la interfaz. Realmente no hay nada que ver con la versión de la estructura que no sea explícita en la definición más que especificar el invariante.

así como herramientas fuera de línea: linters, generadores de código, etc.

No. La información estática está presente, no necesita el sistema de tipos (o reflect) para eso, la convención funciona bien. Si su interfaz contiene métodos no exportados, cualquier herramienta estática puede optar por tratar eso como una suma cerrada (porque efectivamente lo es) y hacer cualquier análisis / codegen que desee. Lo mismo ocurre con la convención de iota-enums.

reflect es para información de tipo de tiempo de

(también, FTR, dependiendo del caso de uso, aún podría tener una herramienta que use la información conocida estáticamente para generar la información de tiempo de ejecución necesaria; por ejemplo, podría enumerar los tipos que tienen el método de etiqueta requerido y generar una tabla de búsqueda para ellos. Pero no entiendo cuál sería un caso de uso, por lo que es difícil evaluar la practicidad de esto).

Entonces, mi pregunta fue intencionalmente: ¿Cuál sería el caso de uso de tener esta información disponible en tiempo de ejecución?

Independientemente, es una buena idea explorar, así que explorémosla.

Cuando dije "explorarlo", no quise decir "enumerarlos y discutir sobre ellos en el vacío", quise decir "implementar herramientas que usen estas convenciones y ver cuán útiles / necesarias / prácticas son".

La ventaja de los informes de

Se está saltando la parte de "intentar utilizar los mecanismos existentes para esa". Quiere tener controles de sumas exhaustivos estáticos (problema). Escriba una herramienta que encuentre interfaces con métodos no exportados, realice comprobaciones exhaustivas para cualquier cambio de tipo en el que se use, use esa herramienta por un tiempo (use los mecanismos existentes para ello). Escribe, donde falló.

Estaba pensando en voz alta y comencé a trabajar en un reconocedor estático basado en esos pensamientos que las herramientas pueden usar. Supongo que estaba buscando implícitamente comentarios y más ideas (y eso valió la pena al volver a generar la información necesaria para reflexionar).

FWIW, si yo fuera tú, simplemente ignoraría los casos complejos y me enfocaría en las cosas que funcionan: a) métodos no exportados en interfaces yb) simples const-iota-enums, que tienen int como tipo subyacente y una sola constante declaración del formato esperado. El uso de una herramienta requeriría el uso de una de estas dos soluciones, pero IMO está bien (para usar la herramienta del compilador, también necesitaría usar explícitamente sumas, por lo que parece estar bien).

Definitivamente es un buen lugar para comenzar y se puede marcar después de ejecutarlo en un gran conjunto de paquetes y ver cuántos falsos positivos / negativos hay.

https://godoc.org/github.com/jimmyfrasche/closed

Todavía es un trabajo en progreso. No puedo prometer que no tendré que agregar parámetros adicionales al constructor. Probablemente tenga más errores que pruebas. Pero es lo suficientemente bueno para jugar.

Hay un ejemplo de uso en cmds / closed-exporer que también enumerará todos los tipos cerrados detectados en un paquete especificado por su ruta de importación.

Comencé a detectar todas las interfaces con métodos no exportados, pero son bastante comunes y, si bien algunas eran claramente tipos de suma, otras claramente no lo eran. Si lo limité a la convención del método de etiqueta vacía, perdí muchos tipos de suma, así que decidí registrar ambos por separado y generalizar el paquete un poco más allá de los tipos de suma a tipos cerrados.

Con las enumeraciones fui por el otro lado y acabo de grabar cada constante no bitset de un tipo definido. También planeo exponer los conjuntos de bits descubiertos.

Todavía no detecta estructuras opcionales o interfaces vacías definidas, ya que requerirán algún tipo de comentario de marcador, pero sí en casos especiales los que están en stdlib.

Comencé a detectar todas las interfaces con métodos no exportados, pero son bastante comunes y, si bien algunas eran claramente tipos de suma, otras claramente no lo eran.

Me resultaría útil si pudiera proporcionar algunos de los ejemplos que no lo fueron.

@Merovius lo siento, no

Las que no estoy considerando como tipos de suma eran todas interfaces no exportadas que se estaban utilizando para conectar una de varias implementaciones: a nada le importaba lo que había en la interfaz, solo que había algo que lo satisfacía. Se estaban utilizando mucho como interfaces, no como sumas, pero resultó que estaban cerradas porque no se exportaron. Quizás sea una distinción sin diferencia, pero siempre puedo cambiar de opinión después de una investigación más profunda.

@jimmyfrasche Yo diría que esos deberían tratarse adecuadamente como sumas cerradas. Yo diría que si no les importa el tipo dinámico (es decir, solo llaman a los métodos en la interfaz), entonces un linter estático no se quejaría, ya que "todos los interruptores son exhaustivos", por lo que no hay inconvenientes en tratarlos. como sumas cerradas. Si, otoh, ellos de tipo interruptor de veces y dejar de lado un caso, quejándose sería correcto - eso sería exactamente el tipo de cosas que la desfibradora se supone que captura.

Me gustaría decir una buena palabra para explorar cómo los tipos de unión podrían reducir el uso de la memoria. Estoy escribiendo un intérprete en Go y tengo un tipo de valor que se implementa necesariamente como una interfaz porque los valores pueden ser indicadores de diferentes tipos. Esto supuestamente significa que un valor [] ocupa el doble de memoria en comparación con empaquetar el puntero con una pequeña etiqueta de bits como lo haría en C. ¿Parece mucha?

La especificación del lenguaje no necesita mencionar esto, pero parece que cortar el uso de memoria de una matriz a la mitad para algunos tipos de sindicatos pequeños podría ser un argumento bastante convincente para los sindicatos. Te permite hacer algo que, hasta donde yo sé, es imposible hacer en Go hoy. Por el contrario, implementar uniones en la parte superior de las interfaces podría ayudar a que el programa sea correcto y comprensible, pero no hace nada nuevo a nivel de la máquina.

No he realizado ninguna prueba de rendimiento; simplemente señalando una dirección para la investigación.

En su lugar, puede implementar un valor como un puntero inseguro.

El 6 de febrero de 2018 a las 15:54, "Brian Slesinsky" [email protected] escribió:

Me gustaría hablar bien para explorar cómo los tipos de sindicatos podrían reducir
uso de memoria. Estoy escribiendo un intérprete en Go y tengo un tipo de valor que
se implementa necesariamente como una interfaz porque los valores pueden ser punteros
a diferentes tipos. Esto probablemente significa que un valor [] ocupa el doble
memoria en comparación con empaquetar el puntero con una pequeña etiqueta de bits como podría hacer
en C. ¿Parece mucho?

La especificación de idioma no necesita mencionar esto, pero parece que se corta la memoria
el uso de una matriz a la mitad para algunos tipos de uniones pequeñas podría ser bastante
argumento convincente para los sindicatos? Te deja hacer algo que por lo que yo
saber que es imposible de hacer en Go hoy. Por el contrario, implementar sindicatos en
la parte superior de las interfaces podría ayudar con la corrección del programa y
comprensibilidad, pero no hace nada nuevo a nivel de máquina.

No he realizado ninguna prueba de rendimiento; solo señalando una dirección para
investigar.

-
Recibes esto porque te mencionaron.
Responda a este correo electrónico directamente, véalo en GitHub
https://github.com/golang/go/issues/19412#issuecomment-363561070 , o silenciar
la amenaza
https://github.com/notifications/unsubscribe-auth/AGGWBz-L3t0YosVIJmYNyf2iQ-YgIXLGks5tSLv9gaJpZM4MTmSr
.

@skybrian Eso parece bastante presuntuoso con respecto a la implementación de tipos de suma. No solo requiere tipos de suma, sino también que el compilador reconozca el caso especial de solo punteros en una suma y los optimice como un puntero empaquetado, y requiere que el GC sepa cuántos bits de etiqueta se requieren en el puntero , para enmascararlos. Como, realmente no veo que estas cosas sucedan, TBH.

Eso te deja con: Los tipos de suma probablemente serán uniones etiquetadas y probablemente ocupen tanto espacio en un segmento como ahora. A menos que el corte sea homogéneo, pero también puede usar un tipo de corte más específico ahora mismo.

Así que sí. En casos muy especiales, es posible que pueda ahorrar un poco de memoria, si optimiza específicamente para ellos, pero parece que también puede optimizar manualmente para eso, si realmente lo necesita.

@DemiMarie unsafe.Pointer no funciona en App Engine y, en cualquier caso, no te permitirá empacar bits sin estropear el recolector de basura. Incluso si fuera posible, no sería portátil.

@Merovius sí, requiere cambiar el tiempo de ejecución y el recolector de basura para comprender los diseños de memoria empaquetados. Ese es el punto; Los punteros son administrados por el tiempo de ejecución de Go, por lo que si desea hacerlo mejor que las interfaces de una manera segura, no puede hacerlo en una biblioteca o en el compilador.

Pero admitiré que escribir un intérprete rápido es un caso de uso poco común. ¿Quizás hay otros? Parece que una buena forma de motivar una función de idioma es encontrar cosas que no se pueden hacer fácilmente en Go hoy.

Eso es verdad.

Mi pensamiento es que Go no es el mejor idioma para escribir un intérprete,
debido a la tremenda dinámica de dicho software. Si necesita alto rendimiento,
sus hot loops deben escribirse en ensamblado. ¿Hay alguna razón por la que tú
¿Necesitas escribir un intérprete que funcione en App Engine?

El 6 de febrero de 2018 a las 6:15 p.m., "Brian Slesinsky" [email protected] escribió:

@DemiMarie https://github.com/demimarie unsafe.Pointer no funciona en la aplicación
Motor, y en cualquier caso, no le permitirá empacar bits sin
arruinando el recolector de basura. Incluso si fuera posible, no lo sería
portátil.

@metrovius sí, requiere cambiar el tiempo de ejecución y el recolector de basura
para comprender los diseños de memoria empaquetados. Ese es el punto; los punteros son
administrado por el tiempo de ejecución de Go, por lo que si desea hacerlo mejor que las interfaces en un
de forma segura, no puede hacerlo en una biblioteca o en el compilador.

Pero admitiré fácilmente que escribir un intérprete rápido es un uso poco común
caso. ¿Quizás hay otros? Parece una buena forma de motivar a un
La función de idioma es encontrar cosas que no se pueden hacer fácilmente en Go hoy.

-
Recibes esto porque te mencionaron.
Responda a este correo electrónico directamente, véalo en GitHub
https://github.com/golang/go/issues/19412#issuecomment-363598572 , o silenciar
la amenaza
https://github.com/notifications/unsubscribe-auth/AGGWB65jRKg_qVPWTiq8LbGk3YM1RUasks5tSN0tgaJpZM4MTmSr
.

Encuentro la propuesta de @rogpeppe bastante atractiva. También me pregunto si existe la posibilidad de desbloquear beneficios adicionales que acompañen a los ya identificados por @griesemer.

La propuesta dice: "El conjunto de métodos del tipo suma contiene la intersección del conjunto de métodos
de todos sus tipos de componentes, excluyendo cualquier método que tenga el mismo
nombre pero con firmas diferentes. ".

Pero un tipo es más que un conjunto de métodos. ¿Qué pasaría si el tipo de suma admitiera la intersección de las operaciones admitidas por sus tipos de componentes?

Por ejemplo, considere:

var x int|float64

La idea es que lo siguiente funcionaría.

x += 5

Sería equivalente a escribir el cambio de tipo completo:

switch i := x.(type) {
case int:
    x = i + 5
case float64:
    x = i + 5
}

Otra variante implica un cambio de tipo donde un tipo de componente es en sí mismo un tipo de suma.

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.
}

Además, creo que existe potencialmente una sinergia realmente agradable entre los tipos de suma y un sistema genérico que usa restricciones de tipo.

var x int|float64

¿Qué pasa con var x, y int | float64 ? ¿Cuáles son las reglas aquí, al agregarlas? ¿Qué conversión con pérdida se realiza (y por qué)? ¿Cuál será el tipo de resultado?

Go no realiza conversiones automáticas en expresiones (como lo hace C) a propósito; estas preguntas no son fáciles de responder y generan errores.

Y para divertirse aún más:

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 los de int , string y rune tienen un operador + ; ¿Qué es la impresión anterior, por qué y, sobre todo, cómo puede el resultado no ser completamente confuso?

¿Qué pasa con var x, y int | float64 ? ¿Cuáles son las reglas aquí, al agregarlas? ¿Qué conversión con pérdida se realiza (y por qué)? ¿Cuál será el tipo de resultado?

@Merovius no se realiza implícitamente ninguna conversión con pérdida, aunque puedo ver cómo mi redacción podría dar esa impresión, lo siento. Aquí, un x + y simple no se compilaría porque implica una posible conversión implícita. Pero cualquiera de los siguientes se compilaría:

z = int(x) + int(y)
z = float64(x) + float64(y)

De manera similar, su ejemplo xyz no se compilaría porque requiere posibles conversiones implícitas.

Creo que "apoyó la intersección de las operaciones apoyadas" suena bien, pero no transmite lo que pretendía. Agregar algo como "compila para todos los tipos de componentes" ayuda a describir cómo creo que podría funcionar.

Otro ejemplo es si todos los tipos de componentes son sectores y mapas. Sería bueno poder llamar a len en el tipo de suma sin necesidad de un interruptor de tipo.

Todos los int, string y rune tienen un operador +; ¿Qué es la impresión anterior, por qué y, sobre todo, cómo puede el resultado no ser completamente confuso?

Solo quería agregar que mi "¿Qué pasaría si el tipo de suma admitiera la intersección de las operaciones admitidas por sus tipos de componentes?" se inspiró en la descripción de Go Spec de un tipo como "Un tipo determina un conjunto de valores junto con operaciones y métodos específicos de esos valores".

El punto que estaba tratando de hacer es que un tipo es más que solo valores y métodos, y por lo tanto, un tipo de suma podría intentar capturar la similitud de esas otras cosas de sus tipos de componentes. Este "otro material" tiene más matices que solo un conjunto de operadores.

Otro ejemplo es la comparación con nil:

var x []int | []string
fmt.Println(x == nil)  // Prints true
x = []string(nil)
fmt.Println(x == nil)  // Still prints true

Ambos tipos de componentes son Al menos un tipo es comparable a nil, por lo que permitimos que el tipo de suma se compare con nil sin un cambio de tipo. Por supuesto, esto está algo en desacuerdo con la forma en que se comportan las interfaces actualmente, pero eso podría no ser algo malo según https://github.com/golang/go/issues/22729

Editar: la prueba de igualdad es un mal ejemplo aquí, ya que creo que debería ser más permisivo y solo requiere una coincidencia potencial de uno o más tipos de componentes. Asignación de espejos en ese sentido.

El problema es que el resultado a) tendrá los mismos problemas que tienen las conversiones automáticas ob) tendrá un alcance extremadamente (y en mi opinión confusamente) limitado, es decir, todos los operadores solo trabajarían con literales sin tipo, en el mejor de los casos.

También tengo otro problema, que es que permitir eso limitará aún más su robustez frente a la evolución de sus tipos constituyentes; ahora los únicos tipos que podría agregar mientras conserva la compatibilidad con versiones anteriores son los que permiten todas las operaciones de sus tipos constituyentes.

Todo esto me parece realmente complicado, por un beneficio tangible muy pequeño (si es que lo hay).

ahora, los únicos tipos que podría agregar mientras conserva la compatibilidad con versiones anteriores son los que permiten todas las operaciones de sus tipos constituyentes.

Ah, y ser explícito sobre esto también: Implica que nunca puede decidir que le gustaría extender un parámetro o tipo de retorno o variable o… de un tipo singleton a una suma. Porque agregar cualquier tipo nuevo hará que algunas operaciones (como asignaciones) no se compilen.

@Merovius tenga en cuenta que ya existe una variante del problema de compatibilidad con la propuesta original porque "El conjunto de métodos del tipo de suma contiene la intersección del conjunto de métodos
de todos sus tipos de componentes ". Entonces, si agrega un nuevo tipo de componente que no implementa ese conjunto de métodos, entonces ese será un cambio no compatible con versiones anteriores.

Ah, y ser explícito sobre esto también: Implica que nunca puede decidir que le gustaría extender un parámetro o tipo de retorno o variable o… de un tipo singleton a una suma. Porque agregar cualquier tipo nuevo hará que algunas operaciones (como asignaciones) no se compilen.

El comportamiento de la asignación seguiría siendo el descrito por @rogpeppe, pero en general no estoy seguro de entender este punto.

Por lo menos, creo que la propuesta original de rogpeppe debe aclararse con respecto al comportamiento del tipo de suma fuera de un cambio de tipo. La asignación y el conjunto de métodos están cubiertos, pero eso es todo. ¿Y la igualdad? Creo que podemos hacerlo mejor que lo que hace la interfaz {}:

var x int | float64
fmt.Println(x == "hello")  // compilation error?
x = 0.0
fmt.Println(x == 0) // true or false?  I vote true :-)

Entonces, si agrega un nuevo tipo de componente que no implementa ese conjunto de métodos, entonces ese será un cambio no compatible con versiones anteriores.

Siempre puede agregar métodos, pero no puede sobrecargar operadores para trabajar en nuevos tipos. Cuál es precisamente la diferencia: en su propuesta, solo puede llamar a los métodos comunes en un valor de suma (o asignarle), a menos que lo desenvuelva con un tipo de aserción / interruptor. Por lo tanto, siempre que el tipo que agregue tenga los métodos necesarios, no sería un cambio importante. En su propuesta, aún sería un cambio importante, porque los usuarios pueden usar operadores que no puede sobrecargar.

(es posible que desee señalar que agregar tipos a la suma aún sería un cambio rotundo, porque los interruptores de tipo no tendrían el nuevo tipo en ellos. Que es exactamente la razón por la que tampoco estoy a favor de la propuesta original - yo no quiero sumas cerradas por esa misma razón)

El comportamiento de la asignación permanecería como lo describe @rogpeppe

Su propuesta solo habla de asignación a un valor de suma, yo hablo de asignación de un valor de suma (a una de sus partes constituyentes). Estoy de acuerdo en que su propuesta tampoco lo permite, pero la diferencia es que su propuesta no se trata de agregar esta posibilidad. es decir, mi argumento es exactamente que la semántica que sugieres no es particularmente beneficiosa, porque en la práctica, el uso que obtienen es muy limitado.

fmt.Println(x == "hello") // compilation error?

Esto probablemente también se agregaría a su propuesta. Ya tenemos un caso especial equivalente para interfaces , a saber

Un valor x del tipo X que no es de interfaz y un valor t del tipo de interfaz T son comparables cuando los valores del tipo X son comparables y X implementa T. Son iguales si el tipo dinámico de t es idéntico a X y el valor dinámico de t es igual ax .

fmt.Println(x == 0) // true or false? I vote true :-)

Presuntamente falso. Dado que el similar

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)

debería ser un error de compilación (como concluimos anteriormente), esta pregunta solo tiene sentido cuando se compara con constantes numéricas sin tipo. En ese punto, depende de cómo se agregue esto a la especificación. Se podría argumentar que esto es similar a asignar una constante a un tipo de interfaz y, por lo tanto, debería tener su tipo predeterminado (y luego la comparación sería falsa). Lo que en mi opinión está más que bien, ya aceptamos esa situación hoy sin mucha confusión. Sin embargo, también podría agregar un caso a la especificación para constantes sin tipo que cubriría el caso de asignarlas / compararlas con sumas y resolver la pregunta de esa manera.

Sin embargo, responder a esta pregunta de cualquier manera no requiere permitir que todas las expresiones usen tipos de suma que puedan tener sentido para las partes constituyentes.

Pero para reiterar: no estoy argumentando a favor de una propuesta diferente de montos. Estoy discutiendo en contra de este.

fmt.Println(x == "hello") // compilation error?

Esto probablemente también se agregaría a su propuesta.

Corrección: la especificación ya cubre este error de compilación, dado que contiene la declaración

En cualquier comparación, el primer operando debe poder asignarse al tipo del segundo operando, o viceversa.

@Merovius hace algunos buenos puntos sobre mi variante de la propuesta. Me abstendré de seguir debatiéndolos, pero me gustaría profundizar un poco más en la comparación con la pregunta 0 porque se aplica igualmente a la propuesta original.

fmt.Println(x == 0) // true or false? I vote true :-)

Presuntamente falso. Dado que el similar

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)
debería ser un error de compilación (como concluimos anteriormente),

No encuentro este ejemplo muy convincente porque si cambia la primera línea a var x float64 = 0.0 entonces podría usar el mismo razonamiento para argumentar que comparar un float64 con 0 debería ser falso. (Puntos menores: (a) Supongo que te refieres a float64 (0) en la primera línea, ya que 0.0 se puede asignar a int. (B) x == y no debería ser un error de compilación en tu ejemplo. Sin embargo, debería imprimirse como falso).

Creo que su idea de que "que esto es similar a asignar una constante a un tipo de interfaz y, por lo tanto, debería tener su tipo predeterminado" es más convincente (asumiendo que se refería al tipo de suma), por lo que el ejemplo sería:

var x,y int|float64 = float64(0), 0
fmt.Println(x == y) // falso

Sin embargo, todavía diría que x == 0 debería ser cierto. Mi modelo mental es que se le da un tipo a 0 lo más tarde posible. Me doy cuenta de que esto es contrario al comportamiento actual de las interfaces, y es precisamente por eso que lo mencioné. Estoy de acuerdo en que esto no ha dado lugar a "mucha confusión", pero el problema similar de comparar interfaces con cero ha resultado en bastante confusión. Creo que veríamos una cantidad similar de confusión en comparación con 0 si los tipos de suma llegan a existir y se mantiene la antigua semántica de igualdad.

No encuentro este ejemplo muy convincente porque si cambia la primera línea a var x float64 = 0.0, entonces podría usar el mismo razonamiento para argumentar que comparar un float64 con 0 debería ser falso.

No dije que debería , dije que presumiblemente lo haría , dado lo que percibo como el compromiso más probable entre simplicidad / utilidad de cómo se implementaría su propuesta. No estaba tratando de hacer un juicio de valor. De hecho, si con reglas tan simples pudiéramos hacer que se imprimiera fielmente, probablemente lo preferiría. Simplemente no soy optimista.

Tenga en cuenta que comparar float64(0) con int(0) (es decir, el ejemplo con la suma reemplazada por var x float64 = 0.0 ) no es false , sin embargo, es un tiempo de compilación error (como debería ser). Este es exactamente mi punto ; su propuesta solo es realmente útil cuando se combina con constantes sin tipo, porque para cualquier otra cosa no se compilaría.

(a) Supongo que te refieres a float64 (0) en la primera línea, ya que 0.0 es asignable a int.

Claro (estaba asumiendo una semántica más cercana al "tipo predeterminado" actual para las expresiones constantes, pero estoy de acuerdo en que la redacción actual no implica eso).

(b) x == y no debería ser un error de compilación en su ejemplo. Sin embargo, debería imprimir falso.)

No, debería ser un error de tiempo de compilación. Ha dicho que la operación e1 == y , con e1 como expresión de tipo suma, debería permitirse si y solo si la expresión se compilaría con cualquier elección de tipo constituyente. Dado que en mi ejemplo, x tiene el tipo int|float64 y y tiene el tipo int y dado que float64 y int no son comparables, esta condición está claramente violada.

Para hacer esta compilación, necesitaría eliminar la condición de que la sustitución de cualquier expresión con tipo constituyente también debe compilarse; momento en el que nos encontramos en la situación de tener que establecer reglas sobre cómo se promueven o convierten los tipos cuando se usan en estas expresiones (también conocido como "el desorden de C").

El consenso anterior ha sido que los tipos de suma no aportan mucho a los tipos de interfaz.

De hecho, no es así para la mayoría de los casos de uso de Go: utilidades y servicios de red triviales. Pero una vez que el sistema crece, es muy probable que sean útiles.
Actualmente estoy escribiendo un servicio muy distribuido con garantías de coherencia de datos implementadas a través de mucha lógica y me dirigí a la situación en la que serían útiles. Estos NPD se volvieron demasiado molestos a medida que el servicio crecía y no vemos una forma sensata de dividirlo.
Quiero decir que las garantías del sistema de tipos de Go son demasiado débiles para algo más complejo que los típicos servicios de red primitivos.

Pero, la historia con Rust muestra que es una mala idea usar tipos de suma para NPD y manejo de errores como lo hacen en Haskell: hay un flujo de trabajo imperativo natural típico y el enfoque de Haskellish no encaja bien en él.

Ejemplo

considere una función similar a iotuils.WriteFile en pseudocódigo. El flujo imperativo se vería así

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

y como se ve en 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)

es seguro pero feo.

Y mi propuesta:

type result[T, Err] oneof {
    default T
    Error Err
}

y cómo podría verse el programa ( 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

Aquí la rama predeterminada es anónima y se puede acceder a la rama de error con .Error (una vez que se sabe, el resultado es Error). Una vez que se sabe que el archivo se abrió correctamente, el usuario puede acceder a él a través de la propia variable. En primer lugar, si nos aseguramos de que file se abrió correctamente o salimos de lo contrario (y, por lo tanto, las declaraciones posteriores saben que el archivo no es un error).

Como puede ver, este enfoque preserva el flujo imperativo y proporciona seguridad de tipos. El manejo de NPD se puede realizar de manera similar:

type Reference[T] oneof {
    default T
    nil
}
// Reference[T] = *T

el manejo es similar al resultado

@sirkon , tu ejemplo de Rust no me convence de que haya algo malo con los tipos de suma sencillos como en Rust. Más bien, sugiere que la coincidencia de patrones en tipos de suma podría hacerse más parecida a Go usando declaraciones if . 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)
...

(En el espíritu de los tipos de suma, sería un error de compilación si el compilador no puede probar que una coincidencia incondicional siempre tiene éxito porque queda exactamente un caso restante).

Para la verificación básica de errores, esto no parece una mejora con respecto a los valores de retorno múltiples, ya que es una línea más larga y declara una variable local más. Sin embargo, se escalaría mejor a varios casos (agregando más declaraciones if) y el compilador podría verificar que se manejan todos los casos.

@sirkon

De hecho, no es así para la mayoría de los casos de uso de Go: utilidades y servicios de red triviales. Pero una vez que el sistema crece, es muy probable que sean útiles.
[…]
Quiero decir que las garantías del sistema de tipos de Go son demasiado débiles para algo más complejo que los típicos servicios de red primitivos.

Declaraciones como estas son innecesariamente confrontativas y despectivas. También son un poco vergonzosos, TBH, porque hay servicios extremadamente grandes y no triviales escritos en Go. Y dado que una gran parte de sus desarrolladores trabajan en Google, debe asumir que ellos saben mejor que usted si es adecuado para escribir servicios grandes y no triviales. Es posible que Go no cubra todos los casos de uso (tampoco debería hacerlo, en mi opinión), pero empíricamente no solo funciona para "servicios de red primitivos".

El manejo de NPD se puede realizar de manera similar

Creo que esto realmente ilustra que su enfoque en realidad no agrega ningún valor significativo. Como señala, simplemente agrega una sintaxis diferente para la desreferencia. Pero AFAICT nada impide que un programador use esa sintaxis en un valor nulo (lo que presumiblemente aún entraría en pánico). es decir, cada programa que es válido el uso de *p también es válido el uso de p.T (o es p.default ? Es difícil decir cuál es su idea es específicamente) y viceversa.

La única ventaja que los tipos de suma pueden agregar al manejo de errores y nil-desreferencias es que el compilador puede exigir que usted demuestre que la operación es segura al hacer coincidir patrones en ella. Una propuesta que omite esa aplicación no parece traer cosas nuevas significativas a la mesa (posiblemente, es peor que usar sumas abiertas a través de interfaces), mientras que una propuesta que lo incluye es exactamente lo que usted describe como "feo".

@Merovio

Y dado que una gran parte de sus desarrolladores trabajan en Google, debe asumir que ellos saben mejor que usted,

Bienaventurados los creyentes.

Como señala, simplemente agrega una sintaxis diferente para la desreferencia.

de nuevo

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

esta variable intermedia es la que me obliga a dejar esta idea. Como puede ver, mi enfoque es específicamente para el manejo de errores y nulos. Estas pequeñas tareas son demasiado importantes y merecen una atención especial en mi opinión.

@sirkon Aparentemente, tienes muy poco interés en hablar con la gente cara a cara. Lo dejo así.

Mantengamos nuestras conversaciones civilizadas y evitemos comentarios no constructivos. Podemos estar en desacuerdo en algunas cosas, pero aun así mantenemos un discurso respetable. https://golang.org/conduct.

Y dado que una gran parte de sus desarrolladores trabajan en Google, debes asumir que ellos saben mejor que tú.

Dudo que puedas hacer ese tipo de argumento en Google.

@hasufell, ese tipo es de Alemania, donde no tienen grandes empresas de TI con entrevistas de mierda para bombear el ego del entrevistador y la gestión gigante, por eso estas palabras.

@sirkon lo mismo vale para ti. Los argumentos sociales y ad-hominem no son útiles. Esto es más que un problema de CoC. He visto este tipo de "argumentos sociales" aparecer con bastante frecuencia cuando se trata del lenguaje central: los desarrolladores de compiladores lo saben mejor, los diseñadores de idiomas lo saben mejor, la gente de Google lo sabe mejor.

No, no lo hacen. No hay autoridad intelectual. Solo hay autoridad para tomar decisiones. Superalo.

Ocultar algunos comentarios para restablecer la conversación (y gracias @agnivade por intentar volver a

Amigos, consideren su papel en estas discusiones a la luz de nuestros valores Gopher : todos en la comunidad tienen una perspectiva que aportar, y debemos esforzarnos por ser respetuosos y caritativos en la forma en que nos interpretamos y respondemos unos a otros.

Permítame, por favor, agregar mis 2 centavos a esta discusión:

Necesitamos una forma de agrupar diferentes tipos por características distintas de sus conjuntos de métodos (como con las interfaces). Una nueva función de agrupación debería permitir la inclusión de tipos primitivos (o básicos), que no tienen ningún método, y que los tipos de interfaz se clasifiquen como similares de manera relevante. Podemos mantener los tipos primitivos (booleanos, numéricos, de cadena e incluso [] byte, [] int, etc.) como están, pero permiten abstraernos de las diferencias entre tipos donde una definición de tipo los agrupa en una familia.

Sugiero que agreguemos algo como una construcción de tipo _family_ al lenguaje.

La sintaxis

Una familia de tipos se puede definir de forma muy similar a cualquier otro tipo:

type theFamilyName family {
    someType
    anotherType
}

La sintaxis formal sería algo como:
FamilyType = "family" "{" { TypeName ";" } "}" .

Una familia de tipos se puede definir dentro de una firma de función:

func Display(s family{string; fmt.Stringer}) { /* function body */ }

Es decir, la definición unifilar requiere punto y coma entre los nombres de los tipos.

El valor cero de un tipo de familia es nulo, como con una interfaz nula.

(Bajo el capó, un valor que se encuentra detrás de la abstracción familiar se implementa de manera muy similar a una interfaz).

El razonamiento

Necesitamos algo más preciso que la interfaz vacía donde queremos especificar qué tipos son válidos como argumentos para una función o como retornos de una función.

La solución propuesta permitiría una mejor seguridad de tipos, completamente verificada en tiempo de compilación y sin agregar gastos generales adicionales en tiempo de ejecución.

El punto es que el código _Go debería ser más autodocumentado_. Lo que una función puede tomar como argumento debe integrarse en el código mismo.

Demasiado código aprovecha incorrectamente el hecho de que "la interfaz {} no dice nada". Es un poco vergonzoso que una construcción tan ampliamente utilizada (y abusada) en Go, sin la cual no podríamos hacer mucho, dice _n nothing_.

Algunos ejemplos

La documentación de la función sql.Rows.Scan incluye un bloque grande que detalla qué tipos se pueden pasar a la función:

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)

Y para la función sql.Row.Scan , la documentación incluye la frase "Consulte la documentación sobre Rows.Scan para obtener más detalles". Consulte la documentación de _alguna otra función_ para obtener más detalles. Esto no es como Go, y en este caso la oración no es correcta porque, de hecho, Rows.Scan puede tomar un *RawBytes pero Row.Scan no.

El problema es que a menudo nos vemos obligados a confiar en los comentarios para obtener garantías y contratos de comportamiento, que el compilador no puede hacer cumplir.

Cuando los documentos de una función dicen que la función funciona igual que cualquier otra función, "así que vaya a ver la documentación de esa otra función", casi puede garantizar que la función se utilizará incorrectamente a veces. Apuesto a que la mayoría de las personas, como yo, solo han descubierto que un *RawBytes no está permitido como argumento en Row.Scan solo después de recibir un error de Row.Scan ( diciendo "sql: RawBytes no está permitido en Row.Scan"). Es triste que el sistema de tipos permita tales errores.

En su lugar, podríamos tener:

type Value family {
    *string
    *[]byte
    *int; *int8; *int16; *int32; *int64
    *uint; *uint8; *uint16; *uint32; *uint64
    *bool
    *float32; *float64
    *interface{}
    *RawBytes
    Scanner
}

De esta manera, el valor pasado debe ser uno de los tipos de la familia dada, y el tipo de cambio dentro de la función Rows.Scan no necesitará lidiar con casos inesperados o predeterminados; habría otra familia para la función Row.Scan .

Considere también cómo la estructura cloud.google.com/go/datastore.Property tiene un campo "Valor" de tipo interface{} y requiere toda esta documentación:

// 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.

Esto podría 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
}

(Puede imaginarse cómo se podría dividir este limpiador en dos familias).

El tipo json.Token se mencionó anteriormente. Su definición de tipo sería:

type Token family {
    Delim
    bool
    float64
    Number
    string
    nil
}

Otro ejemplo que me mordió recientemente:
Al llamar a funciones como sql.DB.Exec , o sql.DB.Query , o cualquier función que tome una lista variada de interface{} donde cada elemento debe tener un tipo en un conjunto particular y _no ser él mismo un segmento_, es importante recordar usar el operador de "extensión" al pasar los argumentos de un []interface{} a una función de este tipo: es incorrecto decir DB.Exec("some query with placeholders", emptyInterfaceSlice) ; la forma correcta es: DB.Exec("the query...", emptyInterfaceSlice...) donde emptyInterfaceSlice tiene el tipo []interface{} . Una manera elegante de hacer que tales errores fueran imposibles sería hacer que esta función tomara un argumento variable de Value , donde Value se define como una familia como se describe arriba.

El punto de estos ejemplos es que _se están cometiendo errores reales_ debido a la imprecisión del interface{} .

var x int | float64 | string | rune
z = int(x) + int(y)
z = float64(x) + float64(y)

Esto definitivamente debería ser un error del compilador porque el tipo de x no es realmente compatible con lo que se puede pasar a int() .

Me gusta la idea de tener family . Básicamente, sería una interfaz restringida (¿restringida?) A los tipos enumerados y el compilador puede asegurarse de que está haciendo coincidir todo el tiempo y cambia el tipo de la variable dentro del contexto local del case .

El problema es que a menudo nos vemos obligados a confiar en los comentarios para obtener garantías y
contratos de comportamiento, que el compilador no puede hacer cumplir.

En realidad, esa es la razón por la que comencé a no gustarme un poco cosas como

func foo() (..., error) 

porque no tiene idea de qué tipo de error devuelve.

y algunas otras cosas que devuelven una interfaz en lugar de un tipo concreto. Algunas funciones
return net.Addr ya veces es un poco difícil buscar en el código fuente para averiguar qué tipo de net.Addr devuelve realmente y luego usarlo de manera apropiada. Realmente no hay muchas desventajas en devolver un tipo concreto (porque implementa la interfaz y, por lo tanto, se puede usar en cualquier lugar donde se pueda usar la interfaz) excepto cuando
más adelante, planee extender su método para devolver un tipo diferente de net.Addr . Pero si tu
La API menciona que devuelve OpError entonces ¿por qué no hacer eso parte de la especificación de "tiempo de compilación"?

Por ejemplo:

 OpError is the error type usually returned by functions in the net package. It describes the operation, network type, and address of an error. 

¿Generalmente? No le dice exactamente qué funciones devuelven este error. Y esta es la documentación para el tipo, no la función. La documentación de Read ninguna parte menciona que devuelve OpError. Además, si lo haces

err := blabla.(*OpError)

se bloqueará una vez que devuelva un tipo de error diferente. Es por eso que realmente me gustaría ver esto como parte de la declaración de función. Al menos *OpError | error le diría que regresa
tal error y el compilador se asegura de que no haga una aserción de tipo sin marcar que bloquee su programa en el futuro.

Por cierto: ¿Se consideró ya un sistema como el polimorfismo de tipo de Haskell? O un sistema de tipos basado en 'rasgos', es decir:

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 "sea cual sea el tipo de a, debe existir una función add (typeof a, typeof a) typeof a)". < widgets.draw() error> significa que "sea cual sea el tipo de widget, debe proporcionar un método de dibujo que devuelva un error". Esto permitiría crear funciones más 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
}

(Tenga en cuenta que esto no es igual a los "genéricos" tradicionales).

Realmente no hay muchas desventajas en devolver un tipo concreto (porque implementa la interfaz y, por lo tanto, se puede usar en cualquier lugar donde se pueda usar la interfaz), excepto cuando luego planee extender su método para devolver un tipo diferente de net.Addr .

Además, Go no tiene subtipos de variantes, por lo que no puede usar func() *FooError como func() error donde sea necesario. Lo cual es especialmente importante para la satisfacción de la interfaz. Y por último, esto no se 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)
    }
}

es decir, para hacer que esto funcione (me gustaría si pudiéramos de alguna manera) necesitaríamos una inferencia de tipo mucho más sofisticada; actualmente, Go solo usa información de tipo local de una sola expresión. En mi experiencia, ese tipo de algoritmos de inferencia de tipos no solo son significativamente más lentos (ralentizan la compilación y, por lo general, ni siquiera el tiempo de ejecución limitado), sino que también producen mensajes de error mucho menos comprensibles.

Además, Go no tiene subtipos de variantes, por lo que no puede usar un func () * FooError como un error de func () donde sea necesario. Lo cual es especialmente importante para la satisfacción de la interfaz. Y por último, esto no se compila:

Esperaba que esto funcionara bien en Go, pero nunca me he encontrado con esto porque la práctica actual es usar error . Pero sí, en estos casos estas restricciones prácticamente te obligan a usar error como tipo de devolución.

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

No conozco ningún lenguaje que permita esto (bueno, excepto esolangs) pero todo lo que tendrías que hacer es mantener un "mundo de tipos" (que es básicamente un mapa de variable -> type ) y si estás -asigne la variable que acaba de actualizar su tipo en el "mundo de tipos".

No creo que necesite una inferencia de tipo complicada para hacer esto, pero debe realizar un seguimiento de los tipos de variables, pero supongo que debe hacerlo de todos modos porque

var int i = 0;
i = "hi";

seguramente de alguna manera tienes que recordar qué variables / declaraciones tienen qué tipos y para i = "hi" necesitas hacer una "búsqueda de tipo" en i para comprobar si puedes asignarle una cadena.

¿Hay problemas prácticos que complican la asignación de un func () *ConcreteError a un func() error no sea el comprobador de tipos que no lo admite (como razones de tiempo de ejecución / razones de código compilado)? Supongo que actualmente tendrías que envolverlo en una función 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
}

Si se enfrenta a un func (a, b) c pero obtiene un func (x, y) z todo lo que debe hacer es verificar si z se puede asignar a c (y a , b debe ser asignable a x , y ) que al menos en el nivel de tipo no implica una inferencia de tipo complicada (solo implica comprobar si un tipo es asignable / compatible con / con otro tipo). Por supuesto, si esto causa problemas con el tiempo de ejecución / compilación ... no lo sé, pero al menos al menos estrictamente en el nivel de tipo, no veo por qué esto implicaría una inferencia de tipo complicada. El verificador de tipos ya sabe si se puede asignar un x a a lo que también sabe fácilmente si func () x se puede asignar a func () a . Por supuesto, puede haber razones prácticas (pensando en las representaciones en tiempo de ejecución) por las que esto no será posible fácilmente. (Sospecho que ese es el verdadero quid aquí, no la verificación de tipo real).

Teóricamente, podría solucionar los problemas de tiempo de ejecución (si los hay) con funciones de ajuste automático (como en el fragmento anterior) con la desventaja _potencialmente enorme_ de que arruina las comparaciones de funciones con funciones (ya que la función envuelta no será igual a la función envuelve).

No conozco ningún idioma que permita esto (bueno, excepto esolangs)

No exactamente, pero yo diría que eso se debe a que los lenguajes con sistemas de tipos potentes suelen ser lenguajes funcionales que realmente no usan variables (por lo que realmente no necesitan la capacidad de reutilizar identificadores). FWIW, diría que, por ejemplo, el sistema de tipos de Haskell podría lidiar con esto muy bien, al menos siempre que no esté usando ninguna otra propiedad de FooError o BarError , debería poder inferir que err es del tipo error y resolverlo. Por supuesto, nuevamente, esto es hipotético, porque esta situación exacta no se transfiere fácilmente a un lenguaje funcional.

pero supongo que debes hacerlo de todos modos porque

La diferencia es que, en su ejemplo, i tiene un tipo claro y bien entendido después de la primera línea, que es int y luego se encuentra con un error de tipo cuando asigna un string . Mientras tanto, para algo como lo que mencioné, cada uso de un identificador crea esencialmente un conjunto de restricciones en el tipo usado y el verificador de tipo luego intenta inferir el tipo más general que cumple con todas las restricciones dadas (o se queja de que no hay ningún tipo que cumpla con eso contrato). Para eso están las teorías de tipos formales.

¿Hay problemas prácticos que complican la asignación de un func () *ConcreteError a un func() error no sea el comprobador de tipos que no lo admite (como razones de tiempo de ejecución / razones de código compilado)?

Hay problemas prácticos, pero creo que por func probablemente se puedan resolver (emitiendo un código de desempaquetado, de manera similar a cómo funciona el paso de interfaz). Escribí un poco sobre la variación en Go y explico algunos de los problemas prácticos que veo en la parte inferior. Sin embargo, no estoy totalmente convencido de que valga la pena agregarlo. Es decir, no estoy seguro de que resuelva problemas importantes por sí solo.

con la desventaja potencialmente enorme de que arruina las comparaciones de funciones con funciones (ya que la función envuelta no será igual a la función que envuelve).

las funciones no son comparables.

De todos modos, TBH, todo esto parece un poco fuera de tema para este problema :)

FYI: Acabo de hacer esto . No es agradable, pero seguro que es seguro para los tipos. (Se puede hacer lo mismo para # 19814 FWIW)

Llego un poco tarde a la fiesta, pero también me gustaría compartir con ustedes mis sentimientos después de 4 años de Go:

  • Las devoluciones de valor múltiple fueron un gran error.
  • Las interfaces nulables fueron un error.
  • Los punteros no son sinónimos de "opcional", deberían haberse utilizado uniones discriminadas en su lugar.
  • El unmarshaller JSON debería haber devuelto un error si no se incluye un campo obligatorio en el documento JSON.

En los últimos 4 años he encontrado muchos problemas asociados con él:

  • los datos basura regresan en caso de error.
  • desorden de sintaxis (devolviendo valores cero en caso de error).
  • devoluciones de errores múltiples (API confusas, por favor, ¡no hagas eso!).
  • interfaces no nulas que apuntan a punteros que apuntan a nil (confunde muchísimo a la gente haciendo que la declaración "Go es un lenguaje fácil" suene como una broma de mal gusto).
  • los campos JSON sin marcar hacen que los servidores se bloqueen (¡sí!).
  • Los punteros devueltos sin marcar hacen que los servidores se bloqueen, sin embargo, nadie documentó que el puntero devuelto representa un opcional (tipo quizás) y podría, por lo tanto, ser nil (¡yey!)

Sin embargo, los cambios necesarios para solucionar todos esos problemas requerirían una versión Go 2.0.0 (no Go2) verdaderamente incompatible con las versiones anteriores, que supongo que nunca se realizará. De todas formas...

Así es como debería haber sido el manejo de errores:

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

Las interfaces no reemplazan las uniones discriminadas , son dos animales completamente diferentes. El compilador se asegura de que los cambios de tipo en uniones discriminadas estén completos, lo que significa que los casos cubren todos los tipos posibles, si no desea esto, puede usar la declaración de aserción de tipo.

Demasiado a menudo he visto personas totalmente confundidas acerca de _interfaces no nulos para valores nulos_ : https://play.golang.org/p/JzigZ2Q6E6F. Por lo general, la gente se confunde cuando una interfaz error apunta a un puntero de un tipo de error personalizado que apunta a nil , esa es una de las razones por las que creo que hacer que las interfaces sean nulas fue un error.

Una interfaz es como una recepcionista, sabes que es un humano cuando estás hablando con ella, pero en Go, podría ser una figura de cartón y el mundo se colapsará repentinamente si intentas hablar con ella.

Las uniones discriminatorias deberían haberse utilizado para opcionales (tipos-tal vez) y pasar nil punteros a las interfaces debería haber provocado un 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)
  }
}

Los punteros y los tipos de tal vez no son intercambiables. El uso de punteros para tipos opcionales es malo porque genera API 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 {}

Luego también está JSON. Sin embargo, esto nunca podría suceder con las uniones porque el compilador te obliga a verificarlas antes de usarlas . El unmarshaller JSON debería fallar si un campo obligatorio (incluidos los campos de tipo puntero) no se incluye en el 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"`
}

PD
También estoy trabajando en un diseño de lenguaje funcional en este momento y así es como uso uniones discriminadas para el manejo de errores allí:

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

Me encantaría que esto se hiciera realidad algún día. Así que veamos si puedo ayudar un poco:

Quizás el problema es que estamos tratando de abarcar demasiado con la propuesta. Podríamos optar por una versión simplificada que aporte la mayor parte del valor para que sea mucho más fácil agregarlo al idioma a corto plazo.

Desde mi punto de vista, esta versión simplificada estaría relacionada con nil . Aquí están las ideas principales (casi todas ya se han mencionado en los comentarios):

  1. Permitir sólo el | versión
    <any pointer type> | nil
    Dónde estaría cualquier tipo de puntero: punteros, funciones, canales, cortes y mapas (los tipos de puntero Go)
  2. Prohibir asignar nil a un tipo de puntero simple. Si desea asignar nil, entonces el tipo debe ser <pointer type> | nil . Por ejemplo:
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.

Éstas son las ideas principales. Las siguientes son las ideas derivadas de las principales:

  1. No puede declarar una variable de un tipo de puntero simple y dejarla sin inicializar. Si desea hacer eso, debe agregar el tipo discriminado | nil
var maybeAString *string       // Wrong: invalid initial value
var maybeAString *string | nil // Good
  1. Puede asignar un tipo de puntero simple a un tipo de puntero "nilable", pero no al revés:
var value int = 42
var barePointer *int = &value          // Valid
var nilablePointer *int | nil = &value // Valid

nilablePointer = barePointer // Valid
barePointer = nilablePointer // Invalid: Incompatible types
  1. La única forma de obtener el valor de un tipo de puntero "nilable" es mediante el conmutador de tipo, como han señalado otros. Por ejemplo, siguiendo con el ejemplo anterior, si realmente queremos asignar el valor de nilablePointer a barePointer , entonces tendríamos que hacer:
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
}

Y eso es. Sé que las uniones discriminadas se pueden usar para mucho más (especialmente en el caso de devolver errores), pero diría que si nos atenemos a lo que he escrito anteriormente, aportaríamos un ENORME valor al lenguaje con menos esfuerzo y sin complicándolo más de lo necesario.
Beneficios que veo con esta sencilla propuesta:

  • a) Sin errores de puntero nulo . De acuerdo, nunca 4 palabras significaron tanto. Es por eso que siento la necesidad de decirlo desde otro punto de vista: ¡El programa No Go tendrá _ SIEMPRE_ un error nil pointer dereference nuevamente! 💥
  • b) Puede pasar punteros a parámetros de función sin intercambiar "rendimiento frente a intención" .
    Lo que quiero decir con esto es que hay ocasiones en las que quiero pasar una estructura a una función, y no un puntero a ella, porque no quiero que esa función se preocupe por la nulidad y la fuerce a verificar los parámetros. . Sin embargo, normalmente termino pasando un puntero para evitar la sobrecarga de copia.
  • c) ¡ No más mapas nulos! ¡SÍ! Terminaremos con la inconsistencia sobre los "segmentos nulos seguros" y los "mapas nulos inseguros" (que entrarán en pánico si intenta escribir en ellos). Un mapa se inicializará o será de tipo map | nil , en cuyo caso necesitaría usar un cambio de tipo 😃

Pero también hay otro intangible aquí que aporta mucho valor: la tranquilidad del desarrollador . Puede trabajar y jugar con punteros, funciones, canales, mapas, etc. con la sensación relajada de que no tiene que preocuparse de que sean nulos. _¡Yo pagaría por esto! _ 😂

Un beneficio de comenzar con esta versión más simple de la propuesta es que no nos impedirá ir a por la propuesta completa en el futuro, o incluso ir paso a paso (siendo, para mí, el siguiente paso natural para permitir retornos de error discriminados , pero olvidémonos de eso ahora).

Un problema es que incluso esta versión simple de la propuesta es incompatible con versiones anteriores, pero se puede solucionar fácilmente con gofix : simplemente reemplace todas las declaraciones de tipo de puntero por <pointer type> | nil .

¿Qué piensas? Espero que esto pueda arrojar algo de luz y acelerar la inclusión de seguridad nula en el idioma. Parece que así (a través de las "uniones discriminadas") es la forma más sencilla y ortogonal de conseguirlo.

@alvaroloes

No puede declarar una variable de un tipo de puntero simple y dejarla sin inicializar.

Este es el quid del asunto. Eso no es algo que hace Go: cada tipo tiene un valor cero, punto y final. De lo contrario, tendría que responder qué, por ejemplo, make([]T, 100) hace? Otras cosas que mencionas (por ejemplo, ningún mapa con pánico en las escrituras) es una consecuencia de esta regla básica. (Y aparte, no creo que sea realmente cierto decir que los segmentos nulos son más seguros que los mapas; escribir en un segmento nulo provocará tanto pánico como escribir en un mapa nulo).

En otras palabras: su propuesta en realidad no es tan simple, ya que se desvía bastante de una decisión de diseño bastante fundamental en el lenguaje Go.

Creo que lo más importante que hace Go es hacer que los valores cero sean útiles y no simplemente dar a todo un valor cero. El mapa nulo es un valor cero pero no es útil. De hecho, es dañino. Entonces, ¿por qué no rechazar el valor cero en los casos en que no sea útil? Cambiar Go en este sentido sería beneficioso, pero la propuesta no es tan simple.

La propuesta anterior se parece más a algo opcional / no opcional como en Swift y otros. Es genial y todo menos:

  1. Eso rompería casi todos los programas y la solución no sería trivial para gofix. No puede simplemente reemplazar todo con <pointer type> | nil ya que, según la propuesta, esto requeriría un cambio de tipo para descomprimir el valor.
  2. Para que esto sea realmente utilizable y soportable, Go necesitaría tener mucho más azúcar sintáctico alrededor de estos opcionales. Tomemos a Swift, por ejemplo. Hay muchas características en el lenguaje específicamente para trabajar con opcionales: protección, enlace opcional, encadenamiento opcional, fusión nula, etc. No creo que Go iría en esa dirección, pero sin ellos trabajar con opcionales sería una tarea ardua.

Entonces, ¿por qué no rechazar el valor cero en los casos en que no sea útil?

Véase más arriba. Significa que algunas cosas que parecen baratas tienen costos muy no triviales asociados.

Cambiar Go en este sentido sería beneficioso

Tiene beneficios, pero eso no es lo mismo que ser beneficioso. También tiene daños. Qué peso más pesado depende de la preferencia y una compensación. Los diseñadores de Go eligieron esto.

FTR, este es un patrón general en este hilo y uno de los principales argumentos en contra de cualquier concepto de tipos de suma: es necesario decir cuál es el valor cero. Por eso, cualquier idea nueva debería abordarla explícitamente. Pero de manera algo frustrante, la mayoría de las personas que publican aquí en estos días no han leído el resto del hilo y tienden a ignorar esa parte.

🤔 ¡Ajá! Sabía que había algo obvio que me estaba perdiendo. Doh! La palabra "simple" tiene significados complejos. Ok, siéntete libre de eliminar la palabra "simple" de mi comentario anterior.

Lo siento si fue frustrante para algunos de ustedes. Mi intención era intentar ayudar un poco. Intento seguir el hilo, pero no tengo demasiado tiempo libre para dedicarlo a esto.

Volviendo al asunto: parece que la razón principal por la que se está frenando es el valor cero.
Después de pensar un rato y descartar muchas opciones, lo único que creo que podría agregar valor y que vale la pena mencionar es lo siguiente:

Si mal no recuerdo, el valor cero de cualquier tipo consiste en llenar su espacio de memoria con ceros.
Como ya sabe, esto está bien para los tipos que no son de puntero, pero es una fuente de errores para los tipos de puntero:

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

Entonces, ¿qué pasa si nosotros:

  • Definir un valor cero útil para cada tipo de puntero.
  • Inicialícelo solo la primera vez que lo utilice (inicialización diferida).

Creo que esto se ha sugerido en otro número, no estoy seguro. Lo escribo aquí porque aborda el principal obstáculo de esta propuesta.

Lo siguiente podría ser una lista de los valores cero para los tipos de puntero. Tenga en cuenta que esos valores cero se utilizarán solo cuando se acceda al valor . Podríamos llamarlo "valor cero dinámico", y es solo una propiedad de los tipos de puntero:

| Tipo de puntero | Valor cero | Valor cero dinámico | Comentar |
| --- | --- | --- | --- |
| * T | nil | nuevo (T) |
| [] T | nil | [] T {} |
| mapa [T] U | nil | mapa [T] U {} |
| func | nil | noop | Entonces, el valor cero dinámico de una función no hace nada y devuelve valores cero. Si la lista de valores devueltos termina en error , entonces se devuelve un error predeterminado que dice que la función es una "no operación" |
| chan T | nil | hacer (chan T) |
| interface | nil | - | una implementación predeterminada donde todos los métodos se inicializan con la función noop descrita anteriormente |
| sindicato discriminado | nil | valor cero dinámico del primer tipo | |

Ahora, cuando se inicialicen esos tipos, serán nil , como están ahora. La diferencia está en el momento en que se accede a nil . En ese momento, se utilizará el valor cero dinámico. Algunos ejemplos:

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
}

Probablemente me falten detalles de implementación y posibles dificultades, pero primero quería centrarme en la idea.

El principal inconveniente es que agregamos una verificación nula adicional cada vez que accede al valor de un tipo de puntero. Pero yo diría:

  • Es una buena compensación por los beneficios que obtenemos. La misma situación sucede con las comprobaciones vinculadas en los accesos de matriz / corte y aceptamos pagar esa penalización de rendimiento por la seguridad que brinda.
  • Las comprobaciones nulas podrían evitarse de la misma forma que las comprobaciones vinculadas a matrices: si el tipo de puntero se ha inicializado en el ámbito actual, el compilador podría saberlo y evitar agregar la comprobación nula.

Con esto, tenemos todos los beneficios explicados en el comentario anterior, con el plus de que no necesitamos usar un conmutador de tipo para acceder al valor (que sería solo para las uniones discriminadas), manteniendo el código go tan limpio como Esto es ahora.

¿Qué piensas? Disculpas si esto ya se ha discutido. Además, soy consciente de que esta propuesta de comentario está más relacionada con nil que con sindicatos discriminados. Podría mover esto a un problema relacionado con nada pero, como dije, lo publiqué aquí porque intenta solucionar el problema principal de las uniones discriminadas: los valores cero útiles.

Volviendo al asunto: parece que la razón principal por la que se está frenando es el valor cero.

Es una razón técnica importante que debe abordarse. Para mí, la razón principal es que hacen que la reparación gradual sea categóricamente imposible (ver más arriba). es decir, para mí personalmente, no se trata tanto de cómo implementarlos, es que me opongo fundamentalmente al concepto.
En cualquier caso, cuál es la razón "principal" es realmente una cuestión de gusto y preferencia.

Entonces, ¿qué pasa si nosotros:

  • Definir un valor cero útil para cada tipo de puntero.
  • Inicialícelo solo la primera vez que lo utilice (inicialización diferida).

Esto falla si pasa un tipo de puntero. p.ej

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 discusión es todo menos nueva. Hay razones por las que los tipos de referencia se comportan de la forma en que lo hacen y no es que los desarrolladores de Go no lo hayan pensado :)

Este es el quid del asunto. Eso no es algo que hace Go: cada tipo tiene un valor cero, punto y final. De lo contrario, tendrías que responder ¿qué hace, por ejemplo, make ([] T, 100)?

Esto (y new(T) ) deberían rechazarse si T no tiene un valor cero. Tendría que hacer make([]T, 0, 100) y luego usar append para completar el segmento. Rebobinar más grande ( v[:0][:100] ) también tendría que ser un error. [10]T sería básicamente un tipo imposible (a menos que se agregue al lenguaje la capacidad de afirmar un segmento en un puntero de matriz). Y necesitaría una forma de marcar los tipos nilables existentes como no nilables para mantener la compatibilidad con versiones anteriores.

Esto presentaría un problema si se agregan genéricos, ya que necesitaría tratar todos los parámetros de tipo como si no tuvieran un valor cero a menos que satisfagan algún límite. Un subconjunto de tipos también necesitaría un seguimiento de inicialización básicamente en todas partes. Este sería un cambio bastante grande por sí solo, incluso sin agregar tipos de suma encima. Ciertamente es factible, pero contribuye significativamente al lado del costo de un análisis de costo / beneficio. La elección deliberada de mantener la inicialización simple ("siempre hay un valor cero") tendría el impacto de hacer la inicialización más compleja que si el seguimiento de inicialización estuviera en el idioma desde el día 1.

Es una razón técnica importante que debe abordarse. Para mí, la razón principal es que hacen que la reparación gradual sea categóricamente imposible (ver más arriba). es decir, para mí personalmente, no se trata tanto de cómo implementarlos, es que me opongo fundamentalmente al concepto.
En cualquier caso, cuál es la razón "principal" es realmente una cuestión de gusto y preferencia.

Ok, entiendo esto. Solo tenemos que ver también el punto de vista de otras personas (no estoy diciendo que no estés haciendo eso, solo estoy haciendo un punto: guiño :) donde ellos ven esto como algo poderoso para escribir sus programas. ¿Encaja en Go? Depende de cómo se ejecute la idea y se integre en el lenguaje, y eso es lo que todos estamos tratando de hacer en este hilo (supongo)

Esto falla si pasa un tipo de puntero. p.ej (...)

No entiendo bien esto. ¿Por qué es esto un fracaso? Simplemente está pasando un valor al parámetro de la función, que resulta ser un puntero con el nil . Entonces estás modificando ese valor dentro de la función. Se espera que no vea esos efectos fuera de la función. Permítanme comentar algunos ejemplos:

// 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
}

Una situación similar está sucediendo con los métodos de receptor sin puntero, y es confuso para los recién llegados ir (pero una vez que lo entiendes, entonces tiene 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
}

Entonces tenemos que elegir entre:

  • A) Fallo con choque
  • B) Fallo con una no modificación silenciosa del valor señalado por un puntero cuando ese puntero se pasa a una función.

La solución para ambos casos es la misma: compruebe si hay cero antes de hacer nada. Pero, para mí, A) es mucho más dañino (¡toda la aplicación falla!).
B) podría considerarse un "error silencioso", pero no lo consideraría un error. Solo sucede cuando pasa punteros a funciones y, como he mostrado, hay casos con estructuras que se comportan de manera similar. Esto sin considerar los enormes beneficios que aporta.

Nota: No estoy tratando de defender ciegamente "mi" idea, realmente estoy tratando de mejorar Go (que ya es realmente bueno). Si hay algunos otros puntos que hacen que la idea no valga la pena, entonces no me importa desecharla y seguir pensando en otras direcciones.

Nota 2: Eventualmente, esta idea es solo para valores "nulos" y no tiene nada que ver con las uniones discriminadas. Entonces crearé un problema diferente para evitar contaminar este.

Ok, entiendo esto. Solo tenemos que ver también el punto de vista de otras personas (no estoy diciendo que no estés haciendo eso, solo estoy haciendo un punto )

Sin embargo, esa espada corta en ambos sentidos. Dijiste "la razón principal por la que retenías esto era". Esa declaración implica que todos estamos de acuerdo sobre si queremos el efecto de esta propuesta. Ciertamente puedo estar de acuerdo en que es un detalle técnico que impide las sugerencias específicas hechas (o al menos, que cualquier sugerencia debería decir algo sobre esa pregunta ) Pero no me gusta que la discusión se replantee silenciosamente en un mundo paralelo en el que asumimos que todos realmente lo quieren .

¿Por qué es esto un fracaso?

Porque una función que toma un puntero, al menos a menudo, promete modificar el puntero. Si la función no hace nada silenciosamente, lo consideraría un error. O al menos, es un argumento fácil de hacer, que al evitar un pánico nulo de esta manera, está introduciendo una nueva clase de error.

Si pasa un puntero nulo a una función que espera algo allí, eso es un error, y no veo el valor real de hacer que un software tan defectuoso continúe silenciosamente. Puedo ver el valor de la idea original de la captura de ese error en tiempo de compilación por tener soporte para punteros no nilable, pero no veo el punto de permitir que los errores para no ser capturado en absoluto.

es decir, por así decirlo, está abordando una especie de problema diferente de la propuesta real de punteros no nilables: para esa propuesta, el pánico en tiempo de ejecución no es el problema, sino solo un síntoma : el problema es el error al pasar accidentalmente nil a algo que no lo espera y que este error solo se detecta en tiempo de ejecución.

Una situación similar ocurre con los métodos de receptor sin puntero.

No compro esta analogía. En mi opinión, es totalmente razonable considerar

func Foo(p *int) { *p = 42 }

func main() {
    var v int
    Foo(&v)
    if v != 42 { panic("") }
}

para ser el código correcto. No creo que sea razonable considerar

func Foo(v int) { v = 42 }

func main( ){
    var v int
    Foo(v)
    if v != 42 { panic("") }
}

ser correcto. Tal vez si eres un principiante absoluto en Go y vienes de un lenguaje donde cada valor es una referencia (aunque honestamente estoy en apuros para encontrar uno, incluso Python y Java solo hacen referencias a la mayoría de los valores). Sin embargo, la OMI, la optimización para ese caso es inútil, es razonable suponer que la gente tiene cierta familiaridad con los punteros vs. valores. Creo que incluso un desarrollador experimentado de Go consideraría, digamos, un método con un receptor de puntero que accede a sus campos como correcto, y que el código que llama a esos métodos sea correcto. De hecho, ese es todo el argumento para evitar nil -pointers estáticamente, que es demasiado fácil que involuntariamente un puntero sea nulo y que el código de aspecto correcto falle en tiempo de ejecución.

La solución para ambos casos es la misma: compruebe si hay cero antes de hacer nada.

En mi opinión, la solución en la semántica actual es no verificar cero y considerarlo un error si alguien pasa cero. Como, en tu ejemplo escribes

// The correct code would be:
if p == nil {
    // panic or return error
}
*p = 42

Pero no considero que ese código sea correcto. La nil no hace nada, porque desreferenciar nil ya entra en pánico.

Pero, para mí, A) es mucho más dañino (¡toda la aplicación falla!).

Eso está bien, pero tenga en cuenta que muchas personas no estarán de acuerdo con esto. Yo, personalmente, considero que un bloqueo siempre es preferible a continuar con datos corruptos y suposiciones erróneas. En un mundo ideal, mi software no tiene errores y nunca falla. En un mundo menos ideal, mis programas tendrán errores y fallarán de forma segura al fallar cuando se detecten. En el peor mundo, mis programas tendrán errores y seguirán causando estragos cuando se encuentren.

Sin embargo, esa espada corta en ambos sentidos. Dijiste que "la principal razón por la que retenías esto era". Esa declaración implica que todos estamos de acuerdo sobre si queremos el efecto de esta propuesta. Ciertamente puedo estar de acuerdo en que es un detalle técnico que impide las sugerencias específicas hechas (o al menos, que cualquier sugerencia debería decir algo sobre esa pregunta). Pero no me gusta que la discusión se replantee silenciosamente en un mundo paralelo en el que asumimos que todos realmente lo quieren.

Bueno, no quería insinuar esto. Si eso es lo que se entendió, es posible que no haya elegido las palabras correctas y me disculpo. Solo quería dar algunas ideas para una posible solución, eso es todo.

Escribí _ "... parece que la razón principal que está reteniendo esto es ...." _ basado en su oración _ "Este es el quid de la cuestión" _ refiriéndose al valor cero. Es por eso que asumí que el valor cero era lo principal que lo frenaba. Así que fue mi mala suposición.

Con respecto a tratar nil silencio frente a verificarlos en el momento de la compilación: estoy de acuerdo en que es mejor verificarlos en el momento de la compilación. El "valor cero dinámico" fue solo una iteración de la sugerencia original cuando me concentré en abordar el problema de que todos los tipos deberían tener valor cero. Una motivación adicional fue que _pensé_ que también era el principal freno de la propuesta de sindicatos discriminados.
Si nos enfocamos solo en el problema relacionado con cero, preferiría que los tipos de punteros que no sean nulos se verifiquen en el momento de la compilación.

Yo diría que en algún momento, nosotros (con "nosotros", me refiero a toda la comunidad de Go) tendremos que aceptar _algún tipo_ de cambio. Por ejemplo: si hay una buena solución para evitar errores nil completo y lo que lo frena es la decisión de diseño "todos los tipos tienen valor cero y están hechos de ceros", entonces podríamos considerar la idea de hacer algunos ajustes o cambios a esa decisión si aporta valor.

La razón principal por la que digo esto es su oración _ "cada tipo tiene un valor cero, punto y coma" _. Normalmente no me gusta "escribir puntos". ¡No me malinterpretes! Acepto totalmente que pienses así, es solo mi forma de pensar: prefiero no dogmas porque pueden esconder caminos que pueden conducir a mejores soluciones.

Finalmente, con respecto a esto:

Eso está bien, pero tenga en cuenta que muchas personas no estarán de acuerdo con esto. Yo, personalmente, considero que un bloqueo siempre es preferible a continuar con datos corruptos y suposiciones erróneas. En un mundo ideal, mi software no tiene errores y nunca falla. En un mundo menos ideal, mis programas tendrán errores y fallarán de forma segura al fallar cuando se detecten. En el peor mundo, mis programas tendrán errores y seguirán causando estragos cuando se encuentren.

Estoy totalmente de acuerdo con esto. Fallar en voz alta siempre es mejor que fallar en silencio. Sin embargo, hay una trampa en Go:

  • Si tiene una aplicación con miles de goroutines, un pánico no controlado en una de ellas hace que todo el programa se bloquee. Esto es diferente que en otros idiomas, donde solo el hilo que entra en pánico se bloquea

Dejando eso a un lado (aunque es bastante peligroso), la idea es, entonces, evitar toda una categoría de fallas (fallas relacionadas con nil ).

Así que sigamos iterando sobre esto e intentemos encontrar una solución.

¡Gracias por su tiempo y energía!

Me gustaría ver la sintaxis de uniones discriminadas de rust en lugar de los tipos de suma de haskell, permite nombrar variantes y permite una mejor propuesta de sintaxis de coincidencia de patrones.
La implementación se puede hacer como una estructura con un campo de etiqueta (tipo uint, depende del recuento de variantes) y un campo de unión (que contiene los datos).
Esta característica es necesaria para un conjunto cerrado de variantes (la representación del estado sería mucho más fácil y limpia, con la verificación del tiempo de compilación). De acuerdo con las preguntas sobre interfaces y su representación, creo que su implementación en tipo suma no debe ser más que otro caso de tipo suma, porque la interfaz es de cualquier tipo que se ajuste a algunos requisitos, pero el caso de uso del tipo suma es diferente.

Sintaxis:

type Type enum {
         Tuple (int,int),
         One int,
         None,
};

En el ejemplo anterior, el tamaño sería sizeof ((int, int)).
La coincidencia de patrones se puede realizar con un nuevo operador de coincidencia creado, o dentro de un operador de interruptor existente, al igual que:

var a Type
switch (a) {
         case Tuple{(b,c)}:
                    //do something
         case One{b}:
                    //do something else
         case None:
                    //...
}

Sintaxis de creación:
var a Type = Type{One=12}
Tenga en cuenta que en la construcción de instancias de enumeración solo se puede especificar una variante.

Valor cero (problema):
Podemos ordenar los nombres en orden alfabético, el valor cero de la enumeración será el valor cero del tipo del primer miembro en la lista de miembros ordenados.

PS El problema de la solución de valor cero se define principalmente por acuerdo.

Creo que mantener el valor cero de la suma como el valor cero del primer campo de suma definido por el usuario sería menos confuso, tal vez

Creo que mantener el valor cero de la suma como el valor cero del primer campo de suma definido por el usuario sería menos confuso, tal vez

Pero hacer que el valor cero dependa del orden de declaración de campo, creo que es peor.

¿Alguien escribió un documento de diseño?

Tengo uno:
19412-uniones_discriminadas_y_patrón_coincidir.md.zip

Cambié esto:

Creo que mantener el valor cero de la suma como el valor cero del primer campo de suma definido por el usuario sería menos confuso, tal vez

Ahora, en mi propuesta, el acuerdo sobre el valor cero (problema) pasó a la posición de los urandoms.

UPD: Documento de diseño modificado, correcciones menores.

Tengo dos casos de uso recientes, en los que necesitaba tipos de suma integrados:

  1. Representación del árbol AST, como se esperaba. Inicialmente encontré una biblioteca que fue una solución a primera vista, pero su enfoque era tener una estructura grande con muchos campos nilables. Lo peor de ambos mundos, en mi opinión. Sin tipo de seguridad, por supuesto. En su lugar, escribí el nuestro.
  2. Tenía una cola de tareas en segundo plano predefinidas: tenemos un servicio de búsqueda que está en desarrollo en este momento y nuestras operaciones de búsqueda pueden ser demasiado largas, etc. Así que decidimos ejecutarlas en segundo plano enviando tareas de operación de índice de búsqueda a un canal. Luego, un despachador decidirá qué hacer con ellos. Podría usar el patrón de visitante, pero obviamente es una exageración para una simple solicitud de gRPC. Y no es particularmente claro decir al menos, ya que introduce un vínculo entre un despachador y un visitante.

En ambos casos se implementó algo como esto (en el ejemplo de la segunda tarea):

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

Y luego

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:
    …
}

Esto es casi bueno. Lo malo es que Go no proporciona seguridad de tipo completo, es decir, no habrá ningún error de compilación después de que se agregue la nueva tarea de operación de índice de búsqueda.

En mi humilde opinión, el uso de tipos de suma es la solución más clara para este tipo de tareas que generalmente se resuelven con el visitante y el conjunto de despachadores, donde las funciones del visitante no son numerosas y pequeñas y el visitante en sí es un tipo fijo.

Realmente creo que tener 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:
}

sería mucho más goish en espíritu que cualquier otro enfoque que Go permita en su estado actual. No hay necesidad de combinar patrones de Haskellish, solo bucear hasta cierto tipo es más que suficiente.

Ay, perdí el punto de la propuesta de sintaxis. Arreglalo.

Dos versiones, una para el tipo de suma genérico y el tipo de suma para enumeraciones:

Tipos de suma genéricos

type Sum oneof {
    T₁ TypeDecl₁
    T₂ TypeDecl₂
    …
    Tₙ TypeDeclₙ
}

donde T₁Tₙ son definiciones de tipo al mismo nivel con Sum ( oneof expone fuera de su alcance) y Sum declara alguna interfaz que solo T₁Tₙ satisface.

El procesamiento es similar al que tenemos (type) switch, excepto que se realiza implícitamente sobre los objetos oneof y tiene que haber una verificación del compilador si se enumeraron todas las variantes.

Enumeraciones seguras de tipo real

type Enum oneof {
    Value = iota
}

bastante similar a iota de consts, excepto que solo los valores enumerados explícitamente son Enums y todo lo demás no lo es.

switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

sería mucho más goish en espíritu que cualquier otro enfoque que Go permita en su estado actual. No hay necesidad de combinar patrones de Haskellish, solo bucear hasta cierto tipo es más que suficiente.

No creo que manipular el significado de la variable task sea ​​una buena idea, aunque aceptable.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

sería mucho más goish en espíritu que cualquier otro enfoque que Go permita en su estado actual. No hay necesidad de combinar patrones de Haskellish, solo bucear hasta cierto tipo es más que suficiente.

No creo que manipular el significado de la variable de la tarea sea una buena idea, aunque aceptable.
''

Entonces, buena suerte con tus visitantes.

@sirkon ¿Qué quieres decir con los visitantes? Me gustó esta sintaxis por cierto, sin embargo, el interruptor debería escribirse así:

switch task {
case Task.SearchAdd:
    // task is Task.SearchAdd in this scope
case Task.SearchUpdate:
case Task.SearchDelete:
}

Además, ¿cuál sería el valor sin valor de Task ? Por ejemplo:

var task Task

¿Sería nil ? Si es así, ¿el switch tener un case nil extra?
¿O se inicializaría con el primer tipo? Aunque esto sería incómodo, porque entonces el orden de la declaración de tipo importa de una manera que no lo hacía antes, sin embargo, probablemente estaría bien para las enumeraciones numéricas.

Supongo que esto es equivalente a switch task.(type) pero el cambio requeriría que todos los casos estuvieran allí, ¿verdad? como en .. si se pierde un caso, error de compilación. Y no se permiten default . ¿Está bien?

¿Qué quieres decir con los visitantes?

Me refiero a que son la única opción segura de tipos en Go para ese tipo de funcionalidad. Mucho peor en eso para un cierto conjunto de casos (número limitado de alternativas predefinidas).

Además, ¿cuál sería el valor sin valor de Task? Por ejemplo:

var task Task

Me temo que debería ser un tipo nilable en Go como este

¿O se inicializaría con el primer tipo?

sería demasiado extraño, especialmente para un propósito previsto.

Supongo que esto es equivalente a cambiar de tarea. (Tipo) pero el cambio requeriría que todos los casos estuvieran allí, ¿verdad? como en .. si se pierde un caso, error de compilación.

Si claro.

Y no se permite ningún valor predeterminado. ¿Está bien?

No, se permiten valores predeterminados. Aunque desanimado.

PD: Parece que tengo una idea que Go @ianlancetaylor y otras personas de Go tienen sobre los tipos de suma. Parece que el nilness los hace bastante propensos a NPD, ya que Go no tiene ningún control sobre los valores nulos.

Si es nulo, supongo que está bien. Preferiría que case nil fuera un requisito para la declaración de cambio. Hacer un if task != nil antes también está bien, simplemente no me gusta tanto: |

¿Esto también estaría permitido?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

¿Esto también estaría permitido?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

Bueno, entonces no hay constes, solo

type Foo oneof {
    A <type reference>
}

o

type Foo oneof {
    A = iota
    B
    C
}

o

type Foo oneof {
    A = 1
    B = 2
    C = 3
}

Sin combinación de iotas y valores. O combinación con control de valores, no deben repetirse.

FWIW, una cosa que encontré interesante sobre el diseño de genéricos más nuevo es que mostró otro lugar para abordar al menos algunos de los casos de uso de tipos de suma evitando el escollo de los valores cero. Define contratos disyuntivos, que en cierto modo son sumas, pero debido a que describen restricciones y no tipos, no es necesario que tengan un valor cero (ya que no se pueden declarar variables de ese tipo). Es decir, al menos es posible escribir una función que tome un conjunto limitado de tipos posibles, con verificación de tipos en tiempo de compilación de ese conjunto.

Ahora, por supuesto, el diseño tal como está no funciona realmente para los casos de uso previstos aquí: las disyunciones enumeran solo los tipos o métodos subyacentes y, por lo tanto, todavía están ampliamente abiertas. Y, por supuesto, incluso como idea general, es bastante limitado, ya que no se puede crear una instancia de una función o valor genérico (o de resumen). Pero en mi opinión, muestra que el espacio de diseño para abordar algunos de los casos de uso de las sumas es mucho más grande que la idea de los tipos de suma en sí. Y que pensar en sumas se centra más en una solución específica que en problemas específicos.

De todas formas. Solo pensé que era interesante.

@Merovius hace un excelente comentario acerca de que el último diseño genérico es capaz de lidiar con algunos de los casos de uso de tipos de suma. Por ejemplo, esta función que se usó anteriormente en el hilo:

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

se convertiría:

contract intOrFloat64(T) {
    T int, float64
}

func addOne(type T intOrFloat64) (x T) T {
    return x + 1
}

En lo que respecta a los tipos de suma, si los genéricos finalmente aterrizan, tendría aún más dudas de lo que tengo ahora sobre si los beneficios de introducirlos superarían los costos de un lenguaje simple como Go.

Sin embargo, si se hiciera algo, entonces la solución más simple y menos disruptiva, en mi opinión, sería la idea de @ianlancetaylor de 'interfaces restringidas' que se implementarían exactamente de la misma manera que las interfaces 'no restringidas' en la actualidad, pero que solo podrían satisfacerse. por los tipos especificados. De hecho, si tomó una hoja del libro del diseño genérico y convirtió la restricción de tipo en la primera línea del bloque de interfaz:

type intOrFloat64 interface{ type int, float64 }    

entonces esto sería completamente compatible con versiones anteriores, ya que no necesitaría una nueva palabra clave (como restrict ) en absoluto. Aún podría agregar métodos a la interfaz y sería un error de tiempo de compilación si los métodos no fueran compatibles con todos los tipos especificados.

No veo ningún problema en la asignación de valores a una variable del tipo de interfaz restringida. Si el tipo del valor en el RHS (o el tipo predeterminado de un literal sin tipo) no fuera una coincidencia exacta para uno de los tipos especificados, simplemente no se compilaría. Entonces tendrí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

Sería un error de tiempo de compilación para los casos de un cambio de tipo que no coincidiera con un tipo especificado y se podría implementar una verificación de exhaustividad. Sin embargo, aún se necesitaría una aserción de tipo para convertir el valor de la interfaz restringida en un valor de su tipo dinámico como es hoy.

Los valores cero no son un problema con este enfoque (o, en cualquier caso, no son un problema mayor que en la actualidad con las interfaces en general). El valor cero de una interfaz restringida sería nil (lo que implica que actualmente no contiene nada) y los tipos especificados, por supuesto, tendrían sus propios valores cero, internamente, que serían nil para tipos nilables.

Aunque todo esto me parece perfectamente viable, como dije antes, la seguridad en el tiempo de compilación ganado realmente vale la pena la complejidad adicional; tengo mis dudas, ya que nunca sentí la necesidad de tipos de suma en mi propia programación.

IIUC lo genérico no será de tipos dinámicos, por lo que todo este punto no se sostiene. Sin embargo, si se permite que las interfaces funcionen como contratos (lo cual dudo), no resolvería comprobaciones y enumeraciones exhaustivas, de lo que se trata (creo, ¿tal vez no?) Sumtypes.

@alanfo , @Merovius Gracias por la señal; Es interesante que esta discusión esté girando en esta dirección:

Me gusta cambiar el punto de vista por solo una fracción de segundo: estoy tratando de entender por qué los contratos no se pueden reemplazar por completo con interfaces parametrizadas que permiten la restricción de tipo mencionada anteriormente. Por el momento, no veo ninguna razón técnica sólida, excepto que tales tipos de interfaz de "suma", cuando se utilizan como tipos de "suma", querrían restringir los posibles valores dinámicos a exactamente los tipos enumerados en la interfaz, mientras que - si el Se utilizó la misma interfaz en la posición del contrato: los tipos enumerados en la interfaz deberían servir como tipos subyacentes para ser una restricción genérica razonablemente útil.

@Buen vino
No estaba sugiriendo que el diseño de genéricos abordaría todo lo que uno podría querer hacer con los tipos de suma, como @Merovius explicó claramente en su última publicación que no lo harán. En particular, las restricciones de tipo propuestas para genéricos solo cubren los tipos incorporados y cualquier tipo derivado de ellos. Desde el punto de vista del tipo de suma, el primero es demasiado estrecho y el segundo demasiado ancho.

Sin embargo, el diseño genérico permitiría escribir una función que opere en un conjunto limitado de tipos que el compilador haría cumplir y esto es algo que no podemos hacer en absoluto en este momento.

En lo que respecta a las interfaces restringidas, el compilador sabría los tipos precisos que podrían usarse y, por lo tanto, sería factible realizar una verificación exhaustiva en una declaración de cambio de tipo.

@Griesemer

Estoy desconcertado por lo que dice, ya que pensé que el borrador del documento de diseño de genéricos explicaba con bastante claridad (en la sección "Por qué no usar interfaces en lugar de contratos") por qué estos últimos se consideraban un vehículo mejor que el primero para expresar restricciones genéricas.

En particular, un contrato puede expresar una relación entre parámetros de tipo y, por lo tanto, solo se necesita un único contrato. Cualquiera de sus parámetros de tipo se puede utilizar como el tipo de receptor de un método enumerado en el contrato.

No se puede decir lo mismo de una interfaz, parametrizada o no. Si tuvieran alguna restricción, cada parámetro de tipo necesitaría una interfaz separada.

Esto hace que sea más incómodo expresar una relación entre los parámetros de tipo utilizando interfaces, aunque no es imposible, como se muestra en el ejemplo del gráfico.

Sin embargo, si está pensando que podríamos "matar dos pájaros de un tiro" agregando restricciones de tipo a las interfaces y luego usándolas para fines genéricos y de tipo suma, entonces (aparte del problema que mencionó) creo que está Probablemente tenga razón en que esto sería técnicamente factible.

Supongo que realmente no importaría si las restricciones de tipo de interfaz pudieran incluir tipos 'no incorporados' en lo que respecta a los genéricos, aunque se necesitaría encontrar alguna forma para restringirlos a los tipos exactos (y no a los tipos derivados también) por lo que serían adecuados para tipos de suma. Quizás podríamos usar const type para este último (o incluso solo const ) si queremos seguir con las palabras clave actuales.

@griesemer Hay algunas razones por las que los tipos de interfaz parametrizados no son un reemplazo directo de los contratos.

  1. Los parámetros de tipo son los mismos que en otros tipos parametrizados.
    En un tipo como

    type C2(type T C1) interface { ... }
    

    el parámetro de tipo T existe fuera de la propia interfaz. Cualquier argumento de tipo pasado como T ya debe ser conocido para satisfacer el contrato C1 , y el cuerpo de la interfaz no puede restringir más T . Esto es diferente de los parámetros del contrato, que están limitados por el cuerpo del contrato como resultado de su incorporación. Esto significaría que cada parámetro de tipo de una función tendría que restringirse de forma independiente antes de pasar como parámetro a la restricción de cualquier otro parámetro de tipo.

  2. No hay forma de nombrar el tipo de receptor en el cuerpo de la interfaz.
    Las interfaces tendrían que permitirte escribir algo como:

    type C3(type U C1) interface(T) {
        Add(T) T
    }
    

    donde T denota el tipo de receptor.

  3. Algunos tipos de interfaz no se satisfarían por sí mismos como restricciones genéricas.
    Cualquier operación que se base en múltiples valores del tipo de receptor no es compatible con el envío dinámico. Por tanto, estas operaciones no se podrían utilizar en valores de interfaz. Esto significaría que la interfaz no se satisfaría a sí misma (por ejemplo, como el argumento de tipo para un parámetro de tipo restringido por la misma interfaz). Sería sorprendente. Una solución es simplemente no permitir la creación de valores de interfaz para tales interfaces, pero esto no permitiría el caso de uso que se visualiza aquí de todos modos.

En cuanto a distinguir entre restricciones de tipo subyacentes y restricciones de identidad de tipo, hay un método que podría funcionar. Imagina que podemos definir restricciones personalizadas, como

contract (T) indenticalTo(U) {
    *T *U
}

(Aquí, estoy usando una notación inventada para especificar un solo tipo como "receptor". Pronunciaré un contrato con un tipo de receptor explícito como "restricción", así como una función con un receptor se pronuncia "método". Los parámetros después del nombre del contrato son parámetros de tipo normal y no pueden aparecer en el lado izquierdo de una cláusula de restricción en el cuerpo de la restricción).

Debido a que el tipo subyacente de un tipo de puntero literal es él mismo, esta restricción implica que T es idéntico a U . Debido a que esto se declara como una restricción, podría escribir (identicalTo(int)), (identicalTo(uint)), ... como una disyunción de restricción.

Si bien los contratos pueden ser útiles para expresar algún tipo de tipos de suma, no creo que pueda expresar tipos de suma genéricos con ellos. Por lo que he visto en el borrador, uno tiene que enumerar tipos concretos, por lo que no puede escribir algo como esto:

contract Foo(T, U) {
    T U, int64
}

Cuál necesitaría expresar un tipo de suma genérico de un tipo desconocido y uno o más tipos conocidos. Incluso si el diseño permitiera tales construcciones, se verían extrañas cuando se usaran, ya que ambos parámetros serían efectivamente lo mismo.

He estado pensando un poco más sobre cómo podría cambiar el borrador del diseño de genéricos si las interfaces se extendieran para incluir restricciones de tipo y luego se usaran para reemplazar los contratos en el diseño.

Quizás sea más fácil analizar la situación si consideramos diferentes números de parámetros de tipo:

Sin parámetros

Ningún cambio :)

Un parámetro

No hay verdaderos problemas aquí. Una interfaz parametrizada (a diferencia de una no genérica) solo sería necesaria si el parámetro de tipo se refiere a sí mismo y / o algún otro tipo fijo independiente fuera necesario para instanciar la interfaz.

Dos o más parámetros

Como se mencionó anteriormente, cada parámetro de tipo debería restringirse individualmente si necesitara una restricción.

Una interfaz parametrizada solo sería necesaria si:

  1. El parámetro de tipo se refería a sí mismo.

  2. La interfaz hacía referencia a otro parámetro de tipo o parámetros que _ ya habían sido declarados_ en la sección de parámetros de tipo (presumiblemente no querríamos retroceder aquí).

  3. Se necesitaban otros tipos fijos independientes para crear una instancia de la interfaz.

De estos (2) es realmente el único caso problemático, ya que descartaría que los parámetros de tipo se refieran entre sí, como en el ejemplo del gráfico. Ya sea que uno declare 'Nodo' o 'Borde' primero, su interfaz restrictiva aún necesitaría que el otro se pase como parámetro de tipo.

Sin embargo, como se indica en el documento de diseño, puede solucionar esto declarando no parametrizado (ya que no se refieren a sí mismos) NodeInterface y EdgeInterface en el nivel superior, ya que entonces no habría ningún problema en que se refieran entre sí, sea cual sea el orden de declaración. Luego, podría usar estas interfaces para restringir los parámetros de tipo de la estructura Graph y los de su método 'Nuevo' asociado.

Por lo tanto, no parece que haya ningún problema insuperable aquí, incluso si la idea de los contratos es más agradable.

Presumiblemente, comparable ahora podría convertirse en una interfaz incorporada en lugar de un contrato.

Las interfaces podrían, por supuesto, integrarse entre sí como ya es posible.

No estoy seguro de cómo se trataría el problema del método de puntero (en aquellos casos en los que estos deberían especificarse en el contrato) ya que no se puede especificar un receptor para un método de interfaz. Quizás se necesite alguna sintaxis especial (como antes del nombre del método con un asterisco) para indicar un método de puntero.

Pasando ahora a las observaciones de @stevenblenkinsop , me pregunto si haría la vida más fácil si las interfaces parametrizadas no permitieran que sus propios parámetros de tipo estén restringidos de ninguna manera. De todos modos, no estoy seguro de que esta sea una característica realmente útil, a menos que alguien pueda pensar en un caso de uso sensato.

Personalmente, no considero sorprendente que algunos tipos de interfaz no puedan satisfacerse como restricciones genéricas. Un tipo de interfaz no es un tipo de receptor válido en ningún caso y, por lo tanto, no puede tener métodos.

Aunque la idea de Steven de una función incorporada idéntica a () funcionaría, me parece que es potencialmente larga para especificar tipos de suma. Preferiría una sintaxis que le permita a uno especificar una línea completa de tipos como exacta.

@urandom es correcto, por supuesto, que tal como se encuentra actualmente el borrador de genéricos, solo se pueden enumerar tipos concretos (incorporados o agregados incorporados). Sin embargo, esto claramente tendría que cambiar si se usaran interfaces restringidas tanto para genéricos como para tipos de suma. Por lo tanto, no descartaría que algo como esto esté permitido en un entorno unificado:

interface Foo(T) {
    const type T, int64  // 'const' indicates types are exact i.e. no derived types
}

¿Por qué no podemos simplemente agregar sindicatos discriminados al lenguaje en lugar de inventar otro paseo por su ausencia?

@griesemer Puede que lo he estado a favor de usar interfaces para especificar restricciones desde el principio :) Ya no creo que las ideas exactas que menciono en esa publicación sean el camino a seguir (especialmente las cosas Sugiero dirigirse a los operadores). Y me gusta mucho más la iteración más reciente del diseño de contratos que la anterior. Pero en general, estoy completamente de acuerdo en que las interfaces (posiblemente extendidas) como restricciones son viables y vale la pena considerarlas.

@urandom

No creo que puedas expresar tipos de suma genéricos con ellos.

Quiero reiterar que mi punto no era "puedes construir tipos de suma con ellos", sino "puedes resolver algunos problemas que los tipos de suma resuelven con ellos". Si el enunciado de su problema es "Quiero tipos de suma", entonces no es sorprendente que los tipos de suma sean la única solución. Simplemente quería expresar que podría ser posible prescindir de ellos, si nos centramos en los problemas que desea resolver con ellos.

@alanfo

Esto hace que sea más incómodo expresar una relación entre los parámetros de tipo utilizando interfaces, aunque no es imposible, como se muestra en el ejemplo del gráfico.

Creo que "incómodo" es subjetivo. Personalmente, encuentro más natural el uso de interfaces parametrizadas y el ejemplo gráfico es una muy buena ilustración. Para mí, un Graph es una entidad, no una relación entre una especie de Edge y una especie de Node.

Pero TBH, no creo que ninguno de ellos sea realmente más o menos incómodo: escribes exactamente el mismo código para expresar casi exactamente las mismas cosas. Y FWIW, existe una técnica anterior para esto. Las clases de tipos de Haskell se comportan de forma muy parecida a las interfaces y, como señala el artículo de wiki, el uso de clases de tipos de múltiples parámetros para expresar relaciones entre tipos es algo bastante normal.

@stevenblenkinsop

No hay forma de nombrar el tipo de receptor en el cuerpo de la interfaz.

La forma en que lo abordaría es con argumentos de tipo en el sitio de uso. es decir

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
}

Esto requiere cierto cuidado en cuanto a cómo funciona la unificación, de modo que pueda permitir la autorreferencia de parámetros de tipo, pero creo que se puede hacer que funcione.

Tu 1. y 3. No entiendo realmente, tengo que admitirlo. Me beneficiaría de algunos ejemplos concretos.


De todos modos, es un poco falso dejar esto al final de continuar con esta discusión, pero probablemente este no sea el tema correcto para hablar sobre los detalles del diseño de genéricos. Solo lo mencioné para ampliar un poco el espacio de diseño para este número :) Porque parecía que había pasado un tiempo desde que se introdujeron nuevas ideas en la discusión sobre los tipos de suma.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

sería mucho más goish en espíritu que cualquier otro enfoque que Go permita en su estado actual. No hay necesidad de combinar patrones de Haskellish, solo bucear hasta cierto tipo es más que suficiente.
No creo que manipular el significado de la variable de la tarea sea una buena idea, aunque aceptable.

Entonces, buena suerte con tus visitantes.

¿Por qué cree que la coincidencia de patrones no se puede realizar en Go? Si no tiene ejemplos de coincidencia de patrones, consulte, por ejemplo, Rust.

@Merovius re: "Para mí, un gráfico es una entidad"

¿Es una entidad en tiempo de compilación o tiene una representación en tiempo de ejecución? Una de las principales diferencias entre contratos e interfaces es que una interfaz es un objeto de tiempo de ejecución. Participa en la recolección de basura, tiene punteros a otros objetos en tiempo de ejecución, etc. La conversión de un contrato a una interfaz significaría introducir un nuevo objeto de tiempo de ejecución temporal que tiene punteros a los nodos / vértices que contiene (¿cuántos?), Lo que parece incómodo cuando tienes una colección de funciones gráficas, cada una de las cuales podría ser más natural toman parámetros que apuntan a varias partes de los gráficos a su manera, dependiendo de las necesidades de la función.

Su intuición puede ser engañada al usar "Gráfico" para un contrato, ya que "Gráfico" parece un objeto y el contrato no especifica realmente ningún subgrafo en particular; es más como definir un conjunto de términos para usar más adelante, como lo haría en matemáticas o leyes. En algunos casos, es posible que desee tanto un contrato de gráfico como una interfaz de gráfico, lo que resulta en un molesto conflicto de nombres. Sin embargo, no puedo pensar en un nombre mejor que se me ocurra.

Por el contrario, una unión discriminada es un objeto en tiempo de ejecución. Sin restringir la implementación, debe pensar en cómo podría ser una matriz de ellos. Una matriz de N elementos necesita N discriminadores y N valores, y hay varias formas de hacerlo. (Julia tiene representaciones interesantes, a veces poniendo los discriminadores y valores en matrices separadas).

Para sugerir una reducción de los errores que están ocurriendo actualmente en todo el lugar con los esquemas interface{} , pero para eliminar la escritura continua del operador | , sugeriría lo siguiente:

type foobar union {
    int
    float64
}

Solo el caso de uso de reemplazar muchos interface{} con este tipo de seguridad de tipos sería una gran ganancia para la biblioteca. Solo con mirar la mitad de las cosas en la biblioteca de cifrado podría usar esto.

Problemas como: ah, dio ecdsa.PrivateKey lugar de *ecdsa.PrivateKey : aquí hay un error genérico que solo admite ecdsa.PrivateKey. El simple hecho de que estos sean tipos de unión claros aumentaría bastante la seguridad del tipo.

Si bien esta sugerencia ocupa más _espacio_ en comparación con int|float64 , obliga al usuario a pensar en esto. Mantener el código base mucho más limpio.

Para sugerir una reducción de los errores que están ocurriendo actualmente en todo el lugar con los esquemas interface{} , pero para eliminar la escritura continua del operador | , sugeriría lo siguiente:

type foobar union {
    int
    float64
}

Solo el caso de uso de reemplazar muchos interface{} con este tipo de seguridad de tipos sería una gran ganancia para la biblioteca. Solo con mirar la mitad de las cosas en la biblioteca de cifrado podría usar esto.

Problemas como: ah, dio ecdsa.PrivateKey lugar de *ecdsa.PrivateKey : aquí hay un error genérico que solo admite ecdsa.PrivateKey. El simple hecho de que estos sean tipos de unión claros aumentaría bastante la seguridad del tipo.

Si bien esta sugerencia ocupa más _espacio_ en comparación con int|float64 , obliga al usuario a pensar en esto. Mantener el código base mucho más limpio.

Mira esto (comentario) , es mi propuesta.

De hecho, podemos introducir ambas ideas en el lenguaje. Esto conducirá a la existencia de dos formas nativas de hacer ADT, pero con diferentes sintaxis.

Mi propuesta de características, especialmente la coincidencia de patrones, es compatible con la compatibilidad y la capacidad de beneficiarse de la característica para bases de código antiguas.

Pero parece exagerado, ¿no?

Además, se puede hacer que el tipo de suma tenga nil como valor predeterminado. Por supuesto, requerirá nil case en cada cambio.
La coincidencia de patrones se puede hacer como:
- declaración

type U enum{
    A(int64),
    B(string),
}

- coincidencia

...
var a U
...
switch a {
    case A{b}:
         //process b here
    case B{b}:
         //...
    case nil:
         //...
}
...

Si a uno no le gusta la coincidencia de patrones, consulte la propuesta de sirkon anterior.

Además, se puede hacer que el tipo de suma tenga nil como valor predeterminado. Por supuesto, requerirá nil case en cada cambio.

¿No sería más fácil rechazar el valor no iniciado en el momento de la compilación? Para los casos en los que necesitamos un valor inicializado, podríamos agregarlo al tipo de suma: es decir

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): ...
}

Además, se puede hacer que el tipo de suma tenga nil como valor predeterminado. Por supuesto, requerirá nil case en cada cambio.

¿No sería más fácil rechazar el valor no iniciado en el momento de la compilación? Para los casos en los que necesitamos un valor inicializado, podríamos agregarlo al tipo de suma: es decir

Rompe el código existente.

Además, se puede hacer que el tipo de suma tenga nil como valor predeterminado. Por supuesto, requerirá nil case en cada cambio.

¿No sería más fácil rechazar el valor no iniciado en el momento de la compilación? Para los casos en los que necesitamos un valor inicializado, podríamos agregarlo al tipo de suma: es decir

Rompe el código existente.

No existe ningún código con tipos de suma. Aunque creo que el valor predeterminado debería ser algo definido en el tipo en sí. O la primera entrada, o la primera en orden alfabético, o algo así.

No existe ningún código con tipos de suma. Aunque creo que el valor predeterminado debería ser algo definido en el tipo en sí. O la primera entrada, o la primera en orden alfabético, o algo así.

Estuve de acuerdo con usted en el primer pensamiento, pero después de reflexionar un poco, el nuevo nombre reservado para la unión podría haberse usado previamente en algún código base (unión, enumeración, etc.)

Creo que la obligación de verificar cero sería bastante dolorosa de usar.

Parece un cambio importante para la compatibilidad con versiones anteriores que solo Go2.0 podría resolver

No existe ningún código con tipos de suma. Aunque creo que el valor predeterminado debería ser algo definido en el tipo en sí. O la primera entrada, o la primera en orden alfabético, o algo así.

Pero hay una gran cantidad de código go existente que tiene un todo nulable. Eso definitivamente será un cambio radical. Peor aún, gofix y herramientas similares solo pueden cambiar los tipos de variables a Opciones (del mismo tipo) produciendo al menos código feo, en todos los demás casos simplemente romperá todo en el mundo.

Si nada más, reflexiona . algo . Pero todos estos son obstáculos técnicos que pueden resolverse; por ejemplo, este obstáculo es bastante obvio si el valor cero de un tipo de suma está bien definido y probablemente será "pánico", si no. La pregunta más importante sigue siendo por qué una determinada elección es la correcta y si y cómo encaja en el idioma en general. En mi opinión, la mejor manera de abordarlos es aún hablando de casos concretos en los que los tipos de suma abordan problemas específicos o su falta creó. Los tres criterios para un informe de experiencia se aplican a eso.

Tenga en cuenta en particular, que tanto "no debería haber un valor cero y debería estar prohibido crear valores no inicializados" y "el valor predeterminado debería ser la primera entrada" se han mencionado anteriormente, varias veces. Entonces, ya sea que piense que debería ser de esta manera o de esa manera, realmente no agrega nueva información. Pero hace que un hilo ya enorme sea aún más largo y más difícil para el futuro encontrar la información relevante en él.

Consideremos reflexionar. Hay un tipo no válido, que tiene el valor int predeterminado de 0. Si tuvieras una función que aceptara un tipo de reflejo y pasaras una variable no inicializada de ese tipo, terminaría siendo no válido. Si, hipotéticamente, reflect.Kind puede cambiarse a un tipo de suma, tal vez debería conservar el comportamiento de tener una entrada no válida nombrada como la predeterminada, en lugar de depender de un valor nulo.

Ahora, consideremos html / template.contentType. El tipo Plain es su valor predeterminado y, de hecho, la función stringify lo trata como tal, ya que es la alternativa. En un futuro de suma hipotética, no solo seguiría necesitando ese comportamiento, sino que tampoco es factible usar un valor nulo para él, ya que nulo no significará nada para un usuario de este tipo. Será prácticamente obligatorio devolver siempre un valor con nombre aquí, y tendrá un valor predeterminado claro de cuál debería ser ese valor.

Soy yo de nuevo con otro ejemplo en el que los tipos de datos algebraicos / variadic / sum / cualquier tipo de datos funcionan bien.

Por lo tanto, estamos usando una base de datos noSQL sin transacciones (sistema distribuido, las transacciones no funcionan para nosotros), pero nos encanta la integridad y la coherencia de los datos por una razón obvia y tenemos que solucionar los problemas de acceso simultáneo, generalmente con consultas de actualización condicional un poco complejas en una sola registro (la escritura de un solo registro es atómica).

Tengo una nueva tarea para escribir un conjunto de entidades que se pueden insertar, agregar o eliminar (solo una de estas operaciones).

Si pudiéramos tener algo como

type EntityOp oneof {
    Insert   Reference
    NewState string
    Delete   struct{}
}

El método podría ser solo

type DB interface {
    …
    Capture(ctx context.Context, processID string, ops map[string]EntityOp) (bool, error)
}

Un uso fantástico de los tiempos de suma es representar nodos en un AST. Otro es reemplazar nil con un option que se comprueba en tiempo de compilación.

@DemiMarie pero en el Go de hoy, esta suma también puede ser nula, como propuse anteriormente, simplemente podemos hacer nil para que sea una variante de cada enumeración, habrá caso nil en cada switch pero esta obligación no es tan mala, especialmente si queremos esta función sin romper todo el código go existente (actualmente tenemos todo nillable)

No sé si pertenece aquí, pero todo esto me queda en Typecript, donde existe una característica muy interesante llamada "String Literal Types" y podemos hacer eso:

var name: "Peter" | "Consuela"; // string type with compile-time constraint

Es como una enumeración de cadenas, que es mucho mejor que las enumeraciones numéricas tradicionales en mi opinión.

@Merovio
un ejemplo concreto es trabajar con JSON arbitrario.
En Rust se puede representar como
enum Value {
Nulo,
Bool (bool),
Número (Número),
Cadena (Cadena),
Matriz (Vec),
Objeto (Mapa),
}

Un tipo de unión con dos ventajas:

  1. Autodocumentar el código
  2. Permitir al compilador o go vet verificar el uso incorrecto de un tipo de unión
    (por ejemplo, un interruptor donde no todos los tipos están marcados)

Para la sintaxis, lo siguiente debería ser compatible con Go1 , como con el tipo alias :

type Token = int | float64 | string

Un tipo de unión se puede implementar internamente como interfaz; lo importante es que el uso de un tipo de unión permita que el código sea más legible y detecte errores como

var tok Token

switch t := tok.(type) {
case int:
    // do something
}

El compilador debería generar un error, ya que no todos los tipos Token se utilizan en el conmutador.

El problema con esto es que (que yo sepa) no hay forma de almacenar tipos de puntero (o tipos que contienen punteros, como string ) y tipos que no son de puntero juntos. Incluso los tipos con diferentes diseños no funcionarían. Siéntase libre de corregirme, pero el problema es que la GC precisa no funciona bien con variables que pueden ser punteros y variables simples al mismo tiempo.

Podemos seguir el camino del boxeo implícito, como lo hace actualmente interface{} . Pero no creo que esto proporcione suficientes beneficios, todavía parece un tipo de interfaz glorificado. ¿Quizás se pueda desarrollar algún tipo de cheque vet su lugar?

El recolector de basura necesitaría leer los bits de la etiqueta de la unión para determinar el diseño. Esto no es imposible, pero sería un gran cambio en el tiempo de ejecución que podría ralentizar gc.

¿Quizás se pueda desarrollar algún tipo de control veterinario en su lugar?

https://github.com/BurntSushi/go-sumtype

El recolector de basura necesitaría leer los bits de la etiqueta de la unión para determinar el diseño.

Esa es exactamente la misma raza que existía con las interfaces, cuando podían contener no punteros. Ese diseño se alejó explícitamente de.

go-sumtype es interesante, gracias. Pero, ¿qué sucede si el mismo paquete define dos tipos de unión?

El compilador podría implementar el tipo de unión internamente como interfaz, pero agregando una sintaxis uniforme y una verificación de tipo estándar.

Si hay N proyectos que usan tipos de unión, cada uno de manera diferente y con N lo suficientemente grande, quizás introducir la única forma de hacerlo puede ser la mejor solución.

Pero, ¿qué sucede si el mismo paquete define dos tipos de unión?

¿Poco? La lógica es por tipo y utiliza un método ficticio para reconocer a los implementadores. Simplemente use diferentes nombres para los métodos ficticios.

El mapa de bits actual de

El problema con esto es que (que yo sepa) no hay forma de almacenar tipos de punteros (o tipos que contienen punteros, como cadenas) y tipos que no son punteros juntos

No creo que esto sea necesario. El compilador podría superponer el diseño de tipos cuando coinciden los mapas de punteros, y no de otra manera. Cuando no coincidan, sería libre de distribuirlos consecutivamente o usar un enfoque de puntero como se usa actualmente para las interfaces. Incluso podría usar diseños no contiguos para miembros de estructura.

Pero no creo que esto proporcione suficientes beneficios, todavía parece un tipo de interfaz glorificado.

En mi propuesta , los tipos de unión son _exactamente_ un tipo de interfaz glorificado: un tipo de unión es solo un subconjunto de una interfaz que solo puede almacenar un conjunto enumerado de tipos. Esto potencialmente le da al compilador la libertad de elegir un método de almacenamiento más eficiente para ciertos conjuntos de tipos, pero ese es un detalle de implementación, no la principal motivación.

@rogpeppe - Por curiosidad, ¿puedo usar el tipo de suma directamente o necesito explícitamente convertirlo a un tipo conocido para hacer algo con él? Porque si tengo que convertirlo constantemente a un tipo conocido, realmente no sé qué beneficios da esto que lo que ya se nos brinda con las interfaces. El principal beneficio que veo es la comprobación de errores en tiempo de compilación, como que la desordenación todavía se produciría en tiempo de ejecución, lo que es más probable cuando vea un problema con un tipo no válido que se pasa. El otro beneficio es una interfaz más restringida, que no creo que justifique un cambio de idioma.

Puedo

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
}

Si esto no se puede hacer, no veo mucha diferencia con

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 curiosidad, ¿puedo usar el tipo de suma directamente o necesito explícitamente convertirlo a un tipo conocido para hacer algo con él? Porque si tengo que convertirlo constantemente a un tipo conocido, realmente no sé qué beneficios da esto que lo que ya se nos brinda con las interfaces.

@rogpeppe ,
Tener que realizar siempre la coincidencia de patrones (así es como se llama "conversión" cuando se trabaja con tipos de suma en lenguajes de programación funcionales) es en realidad uno de los mayores beneficios de usar tipos de suma. Obligar al desarrollador a manejar explícitamente todas las formas posibles de un tipo de suma es una forma de evitar que el desarrollador use una variable pensando que es de un tipo dado, mientras que en realidad es diferente. Un ejemplo exagerado sería, en JavaScript:

const a = "1" // string "1"
const b = a + 5 // string "15" and not number 6

Si esto no se puede hacer, no veo mucha diferencia con

Creo que tú mismo declaras algunas ventajas, ¿no?

El principal beneficio que veo es la comprobación de errores en tiempo de compilación, como que la desordenación todavía se produciría en tiempo de ejecución, lo que es más probable cuando vea un problema con un tipo no válido que se pasa. El otro beneficio es una interfaz más restringida, que no creo que justifique un cambio de idioma.

// Would the compiler error out on incomplete switch types?

Según lo que hacen los lenguajes de programación funcional, creo que esto debería ser posible y configurable 👍

@xibz también rendimiento, ya que se puede hacer en tiempo de compilación frente a tiempo de ejecución, pero luego están los genéricos, con suerte, un día antes de que muera.

@xibz

Por curiosidad, ¿puedo usar el tipo de suma directamente o necesito explícitamente convertirlo a un tipo conocido para hacer algo con él?

Puede llamar a métodos en él si todos los miembros del tipo comparten ese método.

Tomando su int | float64 como ejemplo, ¿cuál sería el resultado de:

var x int|float64 = int(2)
var y int|float64 = float64(0.5)
fmt.Println(x * y)

¿Haría una conversión implícita de int a float64 ? O desde float64 a int . ¿O entraría en pánico?

Así que casi tiene razón: en la mayoría de los casos, necesitaría verificar el tipo antes de usarlo. Creo que es una ventaja, no una desventaja.

La ventaja del tiempo de ejecución podría ser significativa, por cierto. Para continuar con su tipo de ejemplo, un segmento del tipo [](int|float64) no necesitaría contener ningún puntero porque es posible representar todas las instancias del tipo en unos pocos bytes (probablemente 16 bytes debido a restricciones de alineación), lo que podría conducir a mejoras significativas en el rendimiento en algunos casos.

La coincidencia de patrones de

Esto es un poco artificial, pero, por ejemplo, si tiene un árbol de sintaxis de expresión, para hacer coincidir una ecuación cuadrática, puede hacer 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.
}

Los ejemplos simples que solo van a un nivel de profundidad no mostrarán una gran diferencia, pero aquí vamos a subir a cinco niveles de profundidad, lo que sería bastante complicado de hacer con interruptores de tipo anidado. Un lenguaje con coincidencia de patrones podría llegar a varios niveles y al mismo tiempo asegurarse de que no se pierda ningún caso.

Sin embargo, no estoy seguro de cuánto surge fuera de los compiladores.

@xibz
Una ventaja de los tipos de suma es que usted y el compilador saben exactamente qué tipos pueden existir dentro de la suma. Esa es esencialmente la diferencia. Con interfaces vacías, siempre tendrás que preocuparte y protegerte contra usos indebidos en la api, al tener siempre una rama cuyo único propósito es recuperarte cuando un usuario te da un tipo que no esperabas.

Como parece que hay pocas esperanzas de que se implementen tipos de suma en el compilador, espero que al menos una directiva de comentario estándar, como //go:union A | B | C sea ​​propuesta y respaldada por go vet .

Con una forma estándar de declarar un tipo de suma, después de N años será posible saber cuántos paquetes lo están usando.

Con los borradores de diseño recientes para genéricos, tal vez los tipos de suma podrían vincularse a ellos.

En uno de los borradores flotaba la idea de usar interfaces en lugar de contratos, y las interfaces tendrían que admitir listas de tipos:

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Si bien eso en sí mismo no produciría una unión llena de memoria, pero tal vez cuando se usa en una función o estructura genérica, no estaría encuadrada, y al menos proporcionaría seguridad de tipos cuando se trata de una lista finita de tipos.

Y tal vez, el uso de estas interfaces particulares dentro de los conmutadores de tipo requeriría que dicho conmutador fuera exhaustivo.

Esta no es la sintaxis corta ideal (por ejemplo: Foo | int32 | []Bar ), pero es algo.

Con los borradores de diseño recientes para genéricos, tal vez los tipos de suma podrían vincularse a ellos.

En uno de los borradores flotaba la idea de usar interfaces en lugar de contratos, y las interfaces tendrían que admitir listas de tipos:

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Si bien eso en sí mismo no produciría una unión llena de memoria, pero tal vez cuando se usa en una función o estructura genérica, no estaría encuadrada, y al menos proporcionaría seguridad de tipos cuando se trata de una lista finita de tipos.

Y tal vez, el uso de estas interfaces particulares dentro de los conmutadores de tipo requeriría que dicho conmutador fuera exhaustivo.

Esta no es la sintaxis corta ideal (por ejemplo: Foo | int32 | []Bar ), pero es algo.

Bastante similar a mi propuesta: https://github.com/golang/go/issues/19412#issuecomment -520306000

type foobar union {
  int
  float
}

@mathieudevos wow, en realidad me gusta bastante.

Para mí, la mayor rareza (la única rareza que queda, en realidad) con la última propuesta de genéricos son las listas de tipos en las interfaces. Simplemente no encajan del todo. Luego terminas con algunas interfaces que solo puedes usar como restricciones de parámetro de tipo, y así sucesivamente ...

El concepto union funciona muy bien en mi mente porque luego podría incrustar un union en un interface para lograr una "restricción que incluye métodos y tipos sin formato". Las interfaces continúan funcionando como están, y con la semántica definida en torno a una unión, se pueden usar en código regular y la sensación de extrañeza desaparece.

// 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 - En realidad, acabo de ver este CL: https://github.com/golang/go/commit/af48c2e84b52f99d30e4787b1b8d527b5cd2ab64

El beneficio principal de este cambio es que abre la puerta a
(sin restricciones) uso de interfaces con listas de tipos

...¡Excelente! Las interfaces se vuelven completamente utilizables como tipos de suma, lo que unifica la semántica en el uso regular y restringido. (Obviamente aún no está encendido, pero creo que es un gran destino al que dirigirse).

Abrí el número 41716 para analizar la forma en que aparece una versión de los tipos de suma en el borrador de diseño de genéricos actual.

Solo quería compartir una vieja propuesta de @henryas sobre tipos de datos algebraicos. Está muy bien escrito con los casos de uso proporcionados.
https://github.com/golang/go/issues/21154
Desafortunadamente, @mvdan lo cerró el mismo día sin ninguna apreciación del trabajo. Estoy bastante seguro de que esa persona realmente se sintió así y, por lo tanto, no hay más actividades en la cuenta de gh. Lo siento por ese tipo.

Realmente me gusta el # 21154. Sin embargo, parece ser algo diferente (y, por lo tanto, el comentario de

Sí, realmente me gustaría tener la capacidad de modelar alguna lógica empresarial de más alto nivel de una manera similar a como se describe en ese número. Los tipos de suma para opciones restringidas, similares a enumeraciones, y los tipos aceptados sugeridos como en el otro problema serían increíbles en la caja de herramientas. El código comercial / de dominio en Go a veces se siente un poco torpe en este momento.

Mi único comentario es que type foo,bar dentro de una interfaz se ve un poco incómodo y de segunda clase, y estoy de acuerdo en que debería haber una opción entre anulable y no anulable (si es posible).

@ProximaB No entiendo por qué dice "no hay más actividades en la cuenta de gh". Desde entonces, han creado y comentado muchos otros problemas, muchos de ellos en el proyecto Go. No veo ninguna evidencia de que su actividad haya sido influenciada por ese tema en absoluto.

Además, estoy totalmente de acuerdo con que Daniel cierre ese tema como un engaño de este. No entiendo por qué @andig dice que proponen algo diferente. Por lo que puedo entender el texto de # 21154, propone exactamente lo mismo que estamos discutiendo aquí y no me sorprendería en absoluto si incluso la sintaxis exacta ya se sugirió en algún lugar de este megathread (la semántica, en cuanto a descrito, sin duda lo fueron. Varias veces). De hecho, iría tan lejos como para decir que el cierre de Daniels está probado por la extensión de este número, porque ya contiene una discusión bastante detallada y matizada de # 21154, por lo que repetir todo eso habría sido arduo y redundante.

Estoy de acuerdo y entiendo que probablemente sea decepcionante tener una propuesta cerrada como una trampa. Pero no conozco una forma práctica de evitarlo. Tener la discusión en un solo lugar parece beneficioso para todos los involucrados y mantener abiertos varios temas para el mismo tema, sin ninguna discusión sobre ellos, es claramente inútil.

Además, estoy totalmente de acuerdo con que Daniel cierre ese tema como un engaño de este. No entiendo por qué @andig dice que proponen algo diferente. Hasta donde puedo entender el texto de # 21154, propone exactamente lo mismo que estamos discutiendo aquí.

Releyendo este número estoy de acuerdo. Parece confundir este problema con los contratos de genéricos. Apoyaría firmemente los tipos de suma. No quise sonar severo, por favor acepte mis disculpas si me pareció así.

Soy un ser humano y el problema de la jardinería puede ser complicado a veces, así que, por supuesto, señale cuando cometo un error :) Pero en este caso, creo que cualquier propuesta específica de tipos de suma debería bifurcarse de este hilo como https: / /github.com/golang/go/issues/19412#issuecomment -701625548

Soy un ser humano y el problema de la jardinería puede ser complicado a veces, así que, por supuesto, señale cuando cometo un error :) Pero en este caso, creo que cualquier propuesta específica de tipos de suma debería bifurcarse de este hilo como # 19412 ( comentario)

@mvdan no es humano. Confía en mí. Yo soy su vecino. Es una broma.

Gracias por la atención. No estoy tan apegado a mis propuestas. Siéntete libre de destrozar, modificar y derribar cualquier parte de ellos. He estado ocupado en la vida real, así que no he tenido la oportunidad de participar activamente en las discusiones. Es bueno saber que la gente lee mis propuestas y que a algunos les gustan.

La intención original es permitir la agrupación de tipos por su relevancia de dominio, donde no necesariamente comparten comportamientos comunes, y que el compilador lo haga cumplir. En mi opinión, esto es solo un problema de verificación estática, que se realiza durante la compilación. No es necesario que el compilador genere código que conserve la relación compleja entre tipos. El código generado puede tratar estos tipos de dominio normalmente como si fueran el tipo de interfaz {} normal. La diferencia es que el compilador ahora realiza una verificación de tipo estática adicional al compilar. Esa es básicamente la esencia de mi propuesta # 21154

@henryas ¡Qué bueno verte! 😊
Me pregunto si Golang no hubiera usado el tipo de pato que hubiera hecho que la relación entre los tipos fuera mucho más estricta y permitiera agrupar objetos por su relevancia de dominio como describió en su propuesta.

@henryas ¡Qué bueno verte! 😊
Me pregunto si Golang no hubiera usado el tipo de pato que hubiera hecho que la relación entre los tipos fuera mucho más estricta y permitiera agrupar objetos por su relevancia de dominio como describió en su propuesta.

Lo haría, pero eso rompería la promesa de compatibilidad con Go 1. Probablemente no necesitaríamos tipos de suma si tuviéramos una interfaz explícita. Sin embargo, escribir pato no es necesariamente algo malo. Hace que ciertas cosas sean más livianas y convenientes. Disfruto escribiendo pato. Se trata de utilizar la herramienta adecuada para el trabajo.

@henryas estoy de acuerdo. Era una pregunta hipotética. Los creadores de Go definitivamente consideraron profundamente todos los altibajos.
Por otro lado, la guía de codificación como la verificación del cumplimiento de la interfaz nunca aparecería.
https://github.com/uber-go/guide/blob/master/style.md#verify -interface-compliance

¿Puedes tener esta discusión fuera de tema en otro lugar? Hay mucha gente suscrita a este número.
La satisfacción de la interfaz abierta ha sido parte de Go desde sus inicios y no va a cambiar.

¿Fue útil esta página
0 / 5 - 0 calificaciones