Go: proposition : spec : ajouter des types de somme/unions discriminées

Créé le 6 mars 2017  ·  320Commentaires  ·  Source: golang/go

Il s'agit d'une proposition de types de somme, également connus sous le nom d'unions discriminées. Les types de somme dans Go devraient essentiellement agir comme des interfaces, sauf que :

  • ce sont des types valeur, comme les structs
  • les types qu'ils contiennent sont fixés au moment de la compilation

Les types de somme peuvent être mis en correspondance avec une instruction switch. Le compilateur vérifie que toutes les variantes correspondent. À l'intérieur des bras de l'instruction switch, la valeur peut être utilisée comme s'il s'agissait de la variante qui a été mise en correspondance.

Go2 LanguageChange NeedsInvestigation Proposal

Commentaire le plus utile

Merci d'avoir créé cette proposition. Cela fait un an que je joue avec cette idée.
Ce qui suit est aussi loin que j'ai avec une proposition concrète. je pense
"type de choix" pourrait en fait être un meilleur nom que "type de somme", mais YMMV.

Types de somme dans Go

Un type somme est représenté par deux ou plusieurs types combinés avec le "|"
opérateur.

type: type1 | type2 ...

Les valeurs du type résultant ne peuvent contenir qu'un des types spécifiés. Les
type est traité comme un type d'interface - son type dynamique est celui du
valeur qui lui est attribuée.

Dans un cas particulier, "nil" peut être utilisé pour indiquer si la valeur peut
devenir nul.

Par exemple:

type maybeInt nil | int

L'ensemble de méthodes du type somme contient l'intersection de l'ensemble de méthodes
de tous ses types de composants, à l'exclusion des méthodes qui ont le même
nom mais des signatures différentes.

Comme tout autre type d'interface, le type somme peut faire l'objet d'une dynamique
conversion de type. Dans les interrupteurs de type, le premier bras de l'interrupteur qui
correspond au type enregistré sera choisi.

La valeur zéro d'un type somme est la valeur zéro du premier type dans
la somme.

Lors de l'affectation d'une valeur à un type de somme, si la valeur peut tenir dans plus
qu'un des types possibles, alors le premier est choisi.

Par exemple:

var x int|float64 = 13

donnerait une valeur de type dynamique int, mais

var x int|float64 = 3.13

entraînerait une valeur de type dynamique float64.

Mise en œuvre

Une implémentation naïve pourrait implémenter des types sum exactement comme interface
valeurs. Une approche plus sophistiquée pourrait utiliser une représentation
approprié à l'ensemble des valeurs possibles.

Par exemple un type somme composé uniquement de types concrets sans pointeurs
pourrait être implémenté avec un type non pointeur, en utilisant une valeur supplémentaire pour
rappelez-vous le type réel.

Pour les types sum-of-struct, il pourrait même être possible d'utiliser un rembourrage de rechange
octets communs aux structures à cet effet.

Tous les 320 commentaires

Cela a été discuté à plusieurs reprises dans le passé, à partir d'avant la sortie de l'open source. Le consensus passé était que les types somme n'ajoutaient pas grand-chose aux types d'interface. Une fois que vous avez tout réglé, vous obtenez à la fin un type d'interface où le compilateur vérifie que vous avez rempli tous les cas d'un changement de type. C'est un avantage assez faible pour un nouveau changement de langue.

Si vous souhaitez pousser cette proposition plus loin, vous devrez rédiger un document de proposition plus complet, comprenant : Quelle est la syntaxe ? Comment fonctionnent-ils exactement ? (Vous dites qu'il s'agit de "types valeur", mais les types d'interface sont également des types valeur). Quels sont les compromis ?

Je pense que c'est un changement trop important du système de types pour Go1 et qu'il n'y a pas de besoin urgent.
Je suggère que nous réexaminions cela dans le contexte plus large de Go 2.

Merci d'avoir créé cette proposition. Cela fait un an que je joue avec cette idée.
Ce qui suit est aussi loin que j'ai avec une proposition concrète. je pense
"type de choix" pourrait en fait être un meilleur nom que "type de somme", mais YMMV.

Types de somme dans Go

Un type somme est représenté par deux ou plusieurs types combinés avec le "|"
opérateur.

type: type1 | type2 ...

Les valeurs du type résultant ne peuvent contenir qu'un des types spécifiés. Les
type est traité comme un type d'interface - son type dynamique est celui du
valeur qui lui est attribuée.

Dans un cas particulier, "nil" peut être utilisé pour indiquer si la valeur peut
devenir nul.

Par exemple:

type maybeInt nil | int

L'ensemble de méthodes du type somme contient l'intersection de l'ensemble de méthodes
de tous ses types de composants, à l'exclusion des méthodes qui ont le même
nom mais des signatures différentes.

Comme tout autre type d'interface, le type somme peut faire l'objet d'une dynamique
conversion de type. Dans les interrupteurs de type, le premier bras de l'interrupteur qui
correspond au type enregistré sera choisi.

La valeur zéro d'un type somme est la valeur zéro du premier type dans
la somme.

Lors de l'affectation d'une valeur à un type de somme, si la valeur peut tenir dans plus
qu'un des types possibles, alors le premier est choisi.

Par exemple:

var x int|float64 = 13

donnerait une valeur de type dynamique int, mais

var x int|float64 = 3.13

entraînerait une valeur de type dynamique float64.

Mise en œuvre

Une implémentation naïve pourrait implémenter des types sum exactement comme interface
valeurs. Une approche plus sophistiquée pourrait utiliser une représentation
approprié à l'ensemble des valeurs possibles.

Par exemple un type somme composé uniquement de types concrets sans pointeurs
pourrait être implémenté avec un type non pointeur, en utilisant une valeur supplémentaire pour
rappelez-vous le type réel.

Pour les types sum-of-struct, il pourrait même être possible d'utiliser un rembourrage de rechange
octets communs aux structures à cet effet.

@rogpeppe Comment cela interagirait-il avec les assertions de type et les commutateurs de type ? Vraisemblablement, ce serait une erreur de compilation d'avoir un case sur un type (ou une assertion sur un type) qui n'est pas membre de la somme. Serait-ce aussi une erreur d'avoir un switch non exhaustif sur un tel type ?

Pour les commutateurs de type, si vous avez

type T int | interface{}

et vous faites:

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

et t contient une interface{} contenant un int, correspond-il au premier cas ? Et si le premier cas est case interface{} ?

Ou les types somme peuvent-ils contenir uniquement des types concrets ?

Qu'en est-il de type T interface{} | nil ? Si vous écrivez

var t T = nil

quel est son type ? Ou cette construction est-elle interdite ? Une question similaire se pose pour type T []int | nil , il ne s'agit donc pas seulement d'interfaces.

Oui, je pense qu'il serait raisonnable d'avoir une erreur de compilation
avoir un cas qui ne peut pas être égalé. Je ne sais pas si c'est
une bonne idée d'autoriser des switchs non exhaustifs sur un tel type - nous
n'exigent l'exhaustivité nulle part ailleurs. Une chose qui pourrait
être bon quand même : si le switch est exhaustif, on ne pourrait pas exiger un défaut
pour en faire une déclaration finale.

Cela signifie que vous pouvez obtenir une erreur du compilateur si vous avez :

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

et vous modifiez le type de somme pour ajouter un cas supplémentaire.

Pour les commutateurs de type, si vous avez

tapez T int | interface{}

et vous faites:

commutateur t := t.(tapez) {
cas entier :
// ...
et t contient une interface{} contenant un int, correspond-il au premier cas ? Que faire si le premier cas est l'interface de cas{} ?

t ne peut pas contenir une interface{} contenant un int. c'est une interface
tapez comme n'importe quel autre type d'interface, sauf qu'il ne peut
contiennent l'ensemble énuméré de types qui le composent.
Tout comme une interface{} ne peut pas contenir une interface{} contenant un int.

Les types de somme peuvent correspondre aux types d'interface, mais ils n'obtiennent toujours qu'un
type pour la valeur dynamique. Par exemple, il serait bien d'avoir :

type R io.Reader | io.ReadCloser

Qu'en est-il de l'interface de type T{} | néant? Si vous écrivez

var t T = nul

quel est son type ? Ou cette construction est-elle interdite ? Une question similaire se pose pour le type T []int | nil, il ne s'agit donc pas que d'interfaces.

Selon la proposition ci-dessus, vous obtenez le premier élément
dans la somme à laquelle la valeur peut être affectée, donc
vous obtiendrez l'interface nil.

En fait interface{} | nil est techniquement redondant, car toute interface{}
peut être nul.

Pour []int | nil, un nil []int n'est pas la même chose qu'une interface nil, donc le
la valeur concrète de ([]int|nil)(nil) serait []int(nil) non non typée nil .

Le cas []int | nil est intéressant. Je m'attendrais à ce que nil dans la déclaration de type signifie toujours "la valeur d'interface nulle", auquel cas

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

impliquerait que x est l'interface nil, pas le nil []int .

Cette valeur serait distincte du nil []int encodé dans le même type :

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

Nil ne serait-il pas toujours requis même si la somme correspond à tous les types de valeur ? Sinon, que serait var x int64 | float64 ? Ma première pensée, en extrapolant à partir des autres règles, serait la valeur zéro du premier type, mais alors qu'en est-il de var x interface{} | int ? Comme le souligne

Cela semble trop subtil.

Des interrupteurs de type exhaustif seraient bien. Vous pouvez toujours ajouter un default: vide lorsque ce n'est pas le comportement souhaité.

La proposition dit "Lors de l'attribution d'une valeur à un type de somme, si la valeur peut tenir dans plus
qu'un des types possibles, alors le premier est choisi."

Donc avec:

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

x aurait le type concret []int car nil est assignable à []int et []int est le premier élément du type. Il serait égal à toute autre valeur []int (nil).

Nil ne serait-il pas toujours requis même si la somme correspond à tous les types de valeur ? Sinon, qu'est-ce que var x int64 | float64 être?

La proposition dit "La valeur zéro d'un type somme est la valeur zéro du premier type dans
la somme.", donc la réponse est int64(0).

Ma première pensée, extrapolant à partir des autres règles, serait la valeur zéro du premier type, mais alors qu'en est-il de var x interface{} | int? Comme le souligne

Non, ce serait juste la valeur nil habituelle de l'interface dans ce cas. Ce type (interface{} | nil) est redondant. Peut-être que ce serait une bonne idée d'en faire un compilateur pour spécifier des types de somme où un élément est un sur-ensemble d'un autre, car je ne vois actuellement aucun intérêt à définir un tel type.

La valeur zéro d'un type somme est la valeur zéro du premier type de la somme.

C'est une suggestion intéressante, mais puisque le type de somme doit enregistrer quelque part le type de la valeur qu'il contient actuellement, je pense que cela signifie que la valeur zéro du type de somme n'est pas entièrement en octets-zéro, ce qui le rendrait différent de tous les autres types dans Go. Ou peut-être pourrions-nous ajouter une exception disant que si les informations de type ne sont pas présentes, alors la valeur est la valeur zéro du premier type répertorié, mais alors je ne sais pas comment représenter nil si ce n'est pas le cas le premier type répertorié.

Donc, (stuff) | nil n'a de sens que lorsque rien dans (trucs) ne peut être nul et que nil | (stuff) signifie quelque chose de différent selon que quelque chose dans les trucs peut être nul ? Quelle valeur n'ajoute-t-il ?

@ianlancetaylor Je crois que de nombreux langages fonctionnels implémentent des types de somme (fermés) essentiellement comme vous le feriez en C

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

si which indexe dans les champs de l'union dans l'ordre, 0 = a, 1 = b, 2 = c, la définition de la valeur zéro correspond à tous les octets sont zéro. Et vous auriez besoin de stocker les types ailleurs, contrairement aux interfaces. Vous auriez également besoin d'un traitement spécial pour la balise nil d'un certain type partout où vous stockez les informations de type.

Cela ferait des types de valeur d'union au lieu d'interfaces spéciales, ce qui est également intéressant.

Existe-t-il un moyen de faire fonctionner toute la valeur zéro si le champ qui enregistre le type a une valeur zéro représentant le premier type ? Je suppose qu'une façon possible de représenter cela serait:

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

[Éditer]

Désolé @jimmyfrasche m'a devancé.

Y a-t-il quelque chose d'ajouté par zéro qui ne pourrait pas être fait avec

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

?

Cela semble éviter beaucoup de confusion (que j'ai, au moins)

Ou mieux

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

de cette façon, vous pouvez taper switch on None et assigner avec None{}

@jimmyfrasche struct{} n'est pas égal à nil . C'est un détail mineur, mais cela ferait que les changements de type sur des sommes divergeaient inutilement (?) Des changements de type sur d'autres types.

@bcmills Ce n'était pas mon intention de prétendre le contraire - je voulais dire qu'il pouvait être utilisé dans le même but que de différencier un manque de valeur sans chevaucher la signification de nil dans l'un des types de la somme.

@rogpeppe qu'est-ce que cela imprime?

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

Je supposerais "Lecteur"

@jimmyfrasche Je suppose que ReadCloser , comme vous le feriez avec un commutateur de type sur n'importe quelle autre interface.

(Et je m'attendrais également à ce que les sommes qui incluent uniquement les types d'interface n'utilisent pas plus d'espace qu'une interface ordinaire, bien que je suppose qu'une balise explicite pourrait économiser un peu de temps de recherche dans le changement de type.)

@bcmills c'est la mission qui est intéressante, pensez à : https://play.golang.org/p/PzmWCYex6R

@ianlancetaylor C'est un excellent point à soulever, merci. Je ne pense pas

Étant donné:

 var x int | nil = nil

la valeur d'exécution de x n'a pas besoin d'être uniquement des zéros. Lors de l'activation du type de x ou de la conversion
à un autre type d'interface, la balise pourrait être indirecte via une petite table contenant
les pointeurs de type réels.

Une autre possibilité serait d'autoriser un type nil uniquement s'il s'agit du premier élément, mais
qui exclut des constructions comme :

var t nil | int
var u float64 | t

@jimmyfrasche Je supposerais que ReadCloser, comme vous le feriez avec un commutateur de type sur n'importe quelle autre interface.

Oui.

@bcmills c'est la mission qui est intéressante, pensez à : https://play.golang.org/p/PzmWCYex6R

Je ne comprends pas. Pourquoi « cela doit [...] être valide pour que le commutateur de type imprime ReadCloser »
Comme tout type d'interface, un type somme ne stockerait pas plus que la valeur concrète de ce qu'il contient.

Lorsqu'il y a plusieurs types d'interface dans une somme, la représentation à l'exécution n'est qu'une valeur d'interface - c'est juste que nous savons que la valeur sous-jacente doit implémenter une ou plusieurs des possibilités déclarées.

C'est-à-dire que lorsque vous affectez quelque chose à un type (I1 | I2) où I1 et I2 sont des types d'interface, il n'est pas possible de dire plus tard si la valeur que vous avez entrée était connue pour implémenter I1 ou I2 à l'époque.

Si vous avez un type qui est io.ReadCloser | io.Reader, vous ne pouvez pas être sûr lorsque vous tapez switch ou assert sur io.Reader qu'il ne s'agit pas d'un io.ReadCloser, à moins que l'affectation à un type de somme ne déboîte et reboîte l'interface.

Aller dans l'autre sens, si vous aviez io.Reader | io.ReadCloser, soit il n'accepterait jamais un io.ReadCloser car il va strictement de droite à gauche, soit l'implémentation devrait rechercher l'interface "la mieux adaptée" à partir de toutes les interfaces de la somme, mais cela ne peut pas être bien défini.

@rogpeppe Dans votre proposition, en ignorant les possibilités d'optimisation dans la mise en œuvre et les subtilités des valeurs nulles, le principal avantage de l'utilisation d'un type somme sur un type d'interface conçu manuellement (contenant l'intersection des méthodes pertinentes) est que le vérificateur de type peut signaler les erreurs à la compilation plutôt qu'à l'exécution. Un deuxième avantage est que la valeur d'un type est plus discriminée et peut donc aider à la lisibilité/compréhension d'un programme. Y a-t-il un autre avantage majeur?

(Je n'essaie en aucun cas de diminuer la proposition, j'essaie juste de bien comprendre mon intuition. Surtout si la complexité syntaxique et sémantique supplémentaire est "raisonnablement petite" - quoi que cela puisse signifier - je peux définitivement voir l'avantage d'avoir le compilateur attraper les erreurs tôt.)

@griesemer Oui, c'est à peu près vrai.

En particulier lors de la communication de messages sur des canaux ou sur le réseau, je pense que cela aide à la lisibilité et à l'exactitude de pouvoir avoir un type qui exprime exactement les possibilités disponibles. Il est courant actuellement de tenter sans enthousiasme de le faire en incluant une méthode non exportée dans un type d'interface, mais c'est a) contournable par l'intégration et b) il est difficile de voir tous les types possibles car la méthode non exportée est masquée.

@jimmyfrasche

Si vous avez un type qui est io.ReadCloser | io.Reader, vous ne pouvez pas être sûr lorsque vous tapez switch ou assert sur io.Reader qu'il ne s'agit pas d'un io.ReadCloser, à moins que l'affectation à un type de somme ne déboîte et reboîte l'interface.

Si vous avez ce type, vous savez que c'est toujours un io.Reader (ou nil, car tout io.Reader peut aussi être nil). Les deux alternatives ne sont pas exclusives - le type de somme tel que proposé est un "inclusif ou" pas un "exclusif ou".

Aller dans l'autre sens, si vous aviez io.Reader | io.ReadCloser, soit il n'accepterait jamais un io.ReadCloser car il va strictement de droite à gauche, soit l'implémentation devrait rechercher l'interface "la mieux adaptée" à partir de toutes les interfaces de la somme, mais cela ne peut pas être bien défini.

Si par "aller dans l'autre sens", vous entendez attribuer à ce type, la proposition dit :

"Lors de l'attribution d'une valeur à un type de somme, si la valeur peut tenir dans plus
qu'un des types possibles, alors le premier est choisi."

Dans ce cas, un io.ReadCloser peut s'intégrer à la fois dans un io.Reader et un io.ReadCloser, il choisit donc io.Reader, mais il n'y a en fait aucun moyen de le savoir par la suite. Il n'y a pas de différence détectable entre le type io.Reader et le type io.Reader | io.ReadCloser, car io.Reader peut également contenir tous les types d'interface qui implémentent io.Reader. C'est pourquoi je pense que ce pourrait être une bonne idée de faire en sorte que le compilateur rejette des types comme celui-ci. Par exemple, il pourrait rejeter tout type de somme impliquant interface{} car interface{} peut déjà contenir n'importe quel type, de sorte que les qualifications supplémentaires n'ajoutent aucune information.

@rogpeppe il y a beaucoup de choses que j'aime dans ta proposition. La sémantique d'affectation de gauche à droite et la valeur zéro est la valeur zéro des règles de type les plus à gauche sont très claires et simples. Très allez.

Ce qui m'inquiète, c'est d'attribuer une valeur déjà encadrée dans une interface à une variable typée somme.

Utilisons pour le moment mon exemple précédent et disons que RC est une structure qui peut être affectée à un io.ReadCloser.

Si tu fais ça

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

les résultats sont évidents et clairs.

Cependant, si vous faites cela

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

la seule chose sensée à faire est d'avoir v store r en tant que io.Reader, mais cela signifie que lorsque vous tapez switch on v vous ne pouvez pas être sûr que lorsque vous appuyez sur le cas io.Reader que vous n'avez pas en fait un io.ReadCloser. Il faudrait quelque chose comme ça :

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

Maintenant, il y a un sens dans lequel io.ReadCloser <: io.Reader, et vous pouvez simplement les interdire, comme vous l'avez suggéré, mais je pense que le problème est plus fondamental et peut s'appliquer à n'importe quelle proposition de type somme pour Go†.

Disons que vous avez trois interfaces A, B et C, avec les méthodes A(), B() et C() respectivement, et une structure ABC avec les trois méthodes. A, B et C sont disjoints donc A | B | C et ses permutations sont tous des types valides. Mais vous avez encore des cas comme

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

Il existe de nombreuses façons de réorganiser cela et vous n'obtenez toujours aucune garantie significative sur ce qu'est v lorsque les interfaces sont impliquées. Après avoir déballé la somme, vous devez déballer l'interface si la commande est importante.

Peut-être que la restriction devrait être qu'aucune des commandes ne peut être une interface du tout ?

La seule autre solution à laquelle je puisse penser est d'interdire l'affectation d'une interface à une variable typée somme, mais cela semble à sa manière plus sévère.

† cela n'implique pas de constructeurs de types pour les types de la somme à lever l'ambiguïté (comme dans Haskell où vous devez dire Just v pour construire une valeur de type Maybe) - mais je ne suis pas du tout en faveur de cela.

@jimmyfrasche Le cas d'utilisation du est important , il est facile de travailler autour avec boîte explicites struct:

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

var v ReadCloser | Reader = Reader{r}

@bcmills C'est plus que les résultats ne sont pas évidents et compliqués et signifie que toutes les garanties que vous voulez avec un type somme s'évaporent lorsque des interfaces sont impliquées. Je peux le voir causer toutes sortes de bugs subtils et de malentendus.

L'exemple de structure de boîte explicite que vous fournissez montre que l'interdiction des interfaces dans les types sum ne limite en rien la puissance des types sum. Il crée effectivement les constructeurs de type pour la désambiguïsation que j'ai mentionnés dans la note de bas de page. Certes, c'est légèrement ennuyeux et une étape supplémentaire, mais c'est simple et cela correspond tout à fait à la philosophie de Go de laisser les constructions de langage être aussi orthogonales que possible.

toutes les garanties que vous souhaitez avec un type somme

Cela dépend des garanties que vous attendez. Je pense que vous vous attendez à ce qu'un type de somme soit
une valeur strictement étiquetée, donc étant donné tous les types A|B|C, vous savez exactement ce que static
tapez que vous lui avez attribué. Je le vois comme une restriction de type sur une seule valeur de béton
type - la restriction est que la valeur est de type compatible avec (au moins) l'un de A, B et C.
En fin de compte, ce n'est qu'une interface avec une valeur in.

C'est-à-dire, si une valeur peut être affectée à un type somme du fait qu'il est compatible avec l'affectation
avec l'un des membres du type somme, nous n'enregistrons pas lequel de ces membres a été
"chosen" - nous enregistrons simplement la valeur elle-même. La même chose que lorsque vous attribuez un io.Reader
à une interface{}, vous perdez le type statique io.Reader et n'avez que la valeur elle-même
qui est compatible avec io.Reader mais aussi avec tout autre type d'interface qu'il arrive
implémenter.

Dans ton exemple :

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

Une assertion de type de v à l'un de A, B et C réussirait. Cela me semble raisonnable.

@rogpeppe, cette sémantique a plus de sens que ce que j'imaginais. Je ne suis pas encore tout à fait convaincu que les interfaces et les sommes font bon ménage, mais je ne suis plus certain qu'elles ne le soient plus. Le progrès!

Disons que vous avez type U I | *TI est un type d'interface et *T est un type qui implémente I .

Étant donné

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

le type dynamique de u est *T , et dans

var u U = new(T)

vous pouvez accéder à ce *T tant que I avec une assertion de type. Est-ce exact?

Cela signifierait que l'affectation d'une valeur d'interface valide à une somme devrait rechercher le premier type correspondant dans la somme.

Ce serait aussi quelque peu différent de quelque chose comme var v uint8 | int32 | int64 = i qui, j'imagine, irait toujours avec celui de ces trois types i est même si i était un int64 qui pourrait tenir dans un uint8 .

Le progrès!

Yay!

vous pouvez accéder à ce *T en tant que I avec une assertion de type. Est-ce exact?

Oui.

Cela signifierait que l'affectation d'une valeur d'interface valide à une somme devrait rechercher le premier type correspondant dans la somme.

Oui, comme le dit la proposition (bien sûr, le compilateur sait statiquement lequel choisir, il n'y a donc pas de recherche à l'exécution).

Ce serait également quelque peu différent de quelque chose comme var v uint8 | int32 | int64 = i qui, j'imagine, irait toujours avec l'un de ces trois types, même si j'étais un int64 qui pourrait tenir dans un uint8.

Oui, car à moins que i ne soit une constante, il ne sera attribuable qu'à l'une de ces alternatives.

Oui, car à moins que i ne soit une constante, il ne sera attribuable qu'à l'une de ces alternatives.

Ce n'est pas tout à fait vrai, je me rends compte, à cause de la règle permettant l'affectation de types sans nom à des types nommés. Je ne pense pas que cela fasse trop de différence cependant. La règle reste la même.

Donc le type I | *T de mon dernier message est effectivement le même que le type I et io.ReadCloser | io.Reader est effectivement le même type que io.Reader ?

C'est exact. Les deux types seraient couverts par ma règle suggérée selon laquelle le compilateur rejette les types de somme où un type est une interface implémentée par un autre des types. La même règle ou une règle similaire pourrait couvrir les types de somme avec des types en double comme int|int .

Une pensée : il n'est peut-être pas intuitif que int|byte ne soit pas la même chose que byte|int , mais c'est probablement bien dans la pratique.

Cela signifierait que l'affectation d'une valeur d'interface valide à une somme devrait rechercher le premier type correspondant dans la somme.

Oui, comme le dit la proposition (bien sûr, le compilateur sait statiquement lequel choisir, il n'y a donc pas de recherche à l'exécution).

Je ne suis pas ça. La façon dont je l'ai lu (ce qui pourrait être différent de ce qui était prévu), il y a au moins deux façons de traiter une union U de I et T-implémente-I.

1a) à l'attribution de U u = t , la balise est définie sur T. Une sélection ultérieure donne un T car la balise est un T.
1b) à l'affectation de U u = i (i est vraiment un T), la balise est définie sur I. Une sélection ultérieure donne un T car la balise est un I mais une deuxième vérification (effectuée parce que T implémente I et T est membre de U) découvre un T.

2a) comme 1a
2b) à l'affectation de U u = i (i est vraiment un T), le code généré vérifie la valeur (i) pour voir s'il s'agit bien d'un T, car T implémente I et T est également membre de U. Parce que c'est le cas, la balise est définie sur T. Une sélection ultérieure donne directement un T.

Dans le cas où T, V, W implémentent tous I et U = *T | *V | *W | I , l'affectation U u = i nécessite (jusqu'à) 3 tests de type.

Les interfaces et les pointeurs n'étaient pas le cas d'utilisation d'origine pour les types d'union, n'est-ce pas ?

Je peux imaginer certains types de piratage où une "bonne" implémentation effectuerait un peu de frappe - par exemple, si vous avez une union de 4 types de pointeurs ou moins où tous les référents sont alignés sur 4 octets, stockez la balise dans les 2 inférieurs bits de la valeur. Cela implique à son tour qu'il n'est pas bon de prendre l'adresse d'un membre d'un syndicat (ce ne serait pas le cas de toute façon, puisque cette adresse pourrait être utilisée pour restaurer un "ancien" type sans ajuster la balise).

Ou si nous avions un espace d'adressage de 50 bits et que nous étions prêts à prendre des libertés avec les NaN, nous pourrions transformer des entiers, des pointeurs et des doubles en une union de 64 bits, et le coût possible d'un peu de bidouillage.

Les deux sous-suggestions sont grossières, je suis certain que les deux auraient un petit (?) nombre de partisans fanatiques.

Cela implique à son tour qu'il n'est pas bon de prendre l'adresse d'un membre d'un syndicat

Correct. Mais je ne pense pas que le résultat d'une assertion de type soit adressable aujourd'hui de toute façon, n'est-ce pas ?

à l'affectation de U u = i (i est vraiment un T), la balise est définie sur I.

Je pense que c'est le point crucial - il n'y a pas de balise I.

Ignorez un instant la représentation d'exécution et considérez un type somme comme une interface. Comme pour toute interface, elle a un type dynamique (le type qui y est stocké). Le "tag" auquel vous faites référence est exactement ce type dynamique.

Comme vous le suggérez (et j'ai essayé de l'impliquer dans le dernier paragraphe de la proposition), il peut exister des moyens de stocker la balise de type de manière plus efficace qu'avec un pointeur vers le type d'exécution, mais à la fin, il s'agit toujours de simplement encoder la dynamique type de la valeur de type somme, et non laquelle des alternatives a été "choisie" lors de sa création.

Les interfaces et les pointeurs n'étaient pas le cas d'utilisation d'origine pour les types d'union, n'est-ce pas ?

Ce n'était pas le cas, mais toute proposition doit être aussi orthogonale que possible par rapport aux autres caractéristiques linguistiques, à mon avis.

@dr2chase ma compréhension jusqu'à présent est que, si un type de somme inclut des types d'interface dans sa définition, alors au moment de l'exécution son implémentation est identique à une interface (contenant l'intersection des ensembles de méthodes) mais les invariants au moment de la compilation sur les types autorisés sont toujours forcée.

Même si un type somme ne contenait que des types concrets et qu'il était implémenté comme une union discriminée de style C, vous ne seriez pas en mesure d'adresser une valeur dans le type somme car cette adresse pourrait devenir un type (et une taille) différent après avoir pris l'adresse. Vous pouvez cependant prendre l'adresse de la valeur saisie par la somme elle-même.

Est-il souhaitable que les types sum se comportent de cette façon ? Nous pourrions tout aussi bien déclarer que le type sélectionné/affirmé est le même que ce que le programmeur a dit/implique lorsqu'une valeur a été affectée à l'union. Sinon, nous pourrions être conduits à des endroits intéressants par rapport à int8 vs int16 vs int32, etc. Ou, par exemple, int8 | uint8 .

Est-il souhaitable que les types sum se comportent de cette façon ?

C'est une question de jugement. Je pense que oui, car nous avons déjà le concept d'interfaces dans le langage - des valeurs avec à la fois un type statique et un type dynamique. Les types de somme tels que proposés fournissent simplement un moyen plus précis de spécifier les types d'interface dans certains cas. Cela signifie également que les types somme peuvent fonctionner sans restriction sur les autres types. Si vous ne le faites pas, vous devez exclure les types d'interface et la fonctionnalité n'est pas entièrement orthogonale.

Sinon, nous pourrions être conduits à des endroits intéressants par rapport à int8 vs int16 vs int32, etc. Ou, par exemple, int8 | uint8.

Quelle est votre préoccupation ici ?

Vous ne pouvez pas utiliser un type de fonction comme type de clé d'une carte. Je ne dis pas que c'est équivalent, juste qu'il existe un précédent pour les types restreignant d'autres types de types. Toujours ouvert aux interfaces autorisées, toujours pas vendu.

Quel genre de programmes pouvez-vous écrire avec un type sum contenant des interfaces que vous ne pourriez pas autrement ?

Contre-proposition.

Un type union est un type qui répertorie zéro ou plusieurs types, écrits

union {
  T0
  T1
  //...
  Tn
}

Tous les types répertoriés (T0, T1, ..., Tn) dans une union doivent être différents et aucun ne peut être un type d'interface.

Les méthodes peuvent être déclarées sur un type d'union défini (nommé) par les règles habituelles. Aucune méthode n'est promue à partir des types répertoriés.

Il n'y a pas d'incorporation pour les types d'union. Énumérer un type d'union dans un autre revient à énumérer tout autre type valide. Cependant, une union ne peut pas répertorier son propre type de manière récursive, pour la même raison que type S struct { S } n'est pas valide.

Les unions peuvent être incorporées dans des structs.

La valeur d'un type d'union est un type dynamique, limité à l'un des types répertoriés, et une valeur du type dynamique, considérée comme la valeur stockée. L'un des types répertoriés est le type dynamique à tout moment.

La valeur zéro de l'union vide est unique. La valeur zéro d'une union non vide est la valeur zéro du premier type répertorié dans l'union.

Une valeur pour un type d'union, U , peut être créée avec U{} pour la valeur zéro. Si U a un ou plusieurs types et que v est une valeur de l'un des types listés, T , U{v} crée une valeur d'union stockant v avec le type dynamique T . Si v est d'un type non répertorié dans U qui peut être affecté à plus d'un des types répertoriés, une conversion explicite est requise pour lever l'ambiguïté.

Une valeur d'un type d'union U peut être convertie en un autre type d'union V comme dans V(U{}) ssi l'ensemble de types dans U est un sous-ensemble du ensemble de types dans V . Autrement dit, en ignorant l'ordre, U doit avoir tous les mêmes types que V , et U ne peut pas avoir de types qui ne sont pas dans V mais V peut avoir des types qui ne sont pas dans U .

L'assignabilité entre les types d'union est définie comme la convertibilité, tant qu'au plus un des types d'union est défini (nommé).

Une valeur de l'un des types répertoriés, T , d'un type d'union U peut être affectée à une variable de type d'union U . Cela définit le type dynamique sur T et stocke la valeur. Les valeurs compatibles avec l'affectation fonctionnent comme ci-dessus.

Si tous les types répertoriés prennent en charge les opérateurs d'égalité :

  • les opérateurs d'égalité peuvent être utilisés sur deux valeurs d'un même type d'union. Deux valeurs d'un type union ne sont jamais égales si leurs types dynamiques diffèrent.
  • une valeur de cette union peut être comparée à une valeur de n'importe lequel de ses types répertoriés. Si le type dynamique de l'union n'est pas le type de l'autre opérande, == est faux et != est vrai quelle que soit la valeur stockée. Les valeurs compatibles avec l'affectation fonctionnent comme ci-dessus.
  • l'union peut être utilisée comme clé de carte

Aucun autre opérateur n'est pris en charge sur les valeurs d'un type union.

Une assertion de type par rapport à un type d'union pour l'un de ses types répertoriés est valable si le type affirmé est le type dynamique.

Une assertion de type par rapport à un type d'union pour un type d'interface est valable si son type dynamique implémente cette interface. (Notamment, si tous les types répertoriés implémentent cette interface, l'assertion est toujours valable).

Les commutateurs de type doivent être soit exhaustifs, y compris tous les types répertoriés, soit contenir une casse par défaut.

Les assertions de type et les commutateurs de type renvoient une copie de la valeur stockée.

Le package reflect nécessiterait un moyen d'obtenir le type dynamique et la valeur stockée d'une valeur d'union reflétée et un moyen d'obtenir les types répertoriés d'un type d'union reflété.

Remarques:

La syntaxe union{...} été choisie en partie pour se différencier de la proposition de type somme dans ce fil, principalement pour conserver les belles propriétés de la grammaire Go, et accessoirement pour renforcer qu'il s'agit d'une union discriminée. En conséquence, cela permet des unions quelque peu étranges telles que union{} et union{ int } . Le premier est à bien des égards équivalent à struct{} (bien que par définition un type différent), il n'ajoute donc rien au langage, à part l'ajout d'un autre type vide. La seconde est peut-être plus utile. Par exemple, type Id union { int } ressemble beaucoup à type Id struct { int } sauf que la version union permet une affectation directe sans avoir à spécifier idValue.int ce qui lui permet de ressembler davantage à un type intégré.

La conversion de suppression d'ambiguïté requise lorsqu'il s'agit de types compatibles avec l'affectation est un peu dure, mais permettrait de détecter des erreurs si une union est mise à jour pour introduire une ambiguïté à laquelle le code en aval n'est pas préparé.

Le manque d'intégration est une conséquence de l'autorisation des méthodes sur les unions et de l'exigence d'une correspondance exhaustive dans les commutateurs de type.

Autoriser les méthodes sur l'union elle-même plutôt que de prendre l'intersection valide des méthodes des types répertoriés évite d'obtenir accidentellement des méthodes indésirables. Le type affirmant la valeur stockée dans les interfaces communes permet des méthodes d'encapsulation simples et explicites lorsque la promotion est souhaitée. Par exemple, sur une union de type U tous les types listés implémentent fmt.Stringer :

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

Dans le fil reddit lié, rsc a déclaré :

Ce serait bizarre pour la valeur zéro de sum { X; Y } être différent de celui de sum { Y; X }. Ce n'est pas comme ça que les sommes fonctionnent habituellement.

J'y ai réfléchi, car cela s'applique vraiment à toute proposition.

Ce n'est pas un bug : c'est une fonctionnalité.

Envisager

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

vs.

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

UndefinedOrInt dit par défaut que ce n'est pas encore défini, mais, quand ce sera le cas, ce sera une int . C'est analogue à *int qui est la façon dont le type de somme (1 + int) doit être représenté dans Go now et la valeur zéro est également analogue.

IntOrIllegal , d'autre part, dit par défaut que c'est l'entier 0, mais il peut à un moment donné être marqué comme illégal. C'est toujours analogue à *int mais la valeur zéro est plus expressive de l'intention, comme imposer qu'elle soit par défaut new(int) .

C'est un peu comme être capable de formuler un champ booléen dans une structure dans le négatif, donc la valeur zéro est ce que vous voulez comme valeur par défaut.

Les deux valeurs zéro des sommes sont utiles et significatives en elles-mêmes et le programmeur peut choisir la plus appropriée à la situation.

Si la somme était une énumération de jours de la semaine (chaque jour étant une énumération définie de struct{} ), celui qui est répertorié en premier est le premier jour de la semaine, de même pour une énumération de style iota .

De plus, je ne connais aucun langage avec des types de somme ou des unions discriminées/étiquetées qui ont le concept d'une valeur zéro. C serait le plus proche, mais la valeur zéro est une mémoire non initialisée - à peine une piste à suivre. Java par défaut est null, je crois, mais c'est parce que tout est une référence. Tous les autres langages que je connais ont des constructeurs de type obligatoires pour les summands, il n'y a donc pas vraiment de notion de valeur zéro. Existe-t-il un tel langage ? Qu'est ce que ça fait?

Si la différence avec les concepts mathématiques de « somme » et « union » est le problème, nous pouvons toujours les appeler autrement (par exemple « variante »).

Pour les noms : Union confond les puristes c/c++. La variante est principalement familière aux programmeurs COBRA et COM où l'union discriminée semble être préférée par les langages fonctionnels. Set est un verbe et un nom. J'aime le mot-clé _pick_. Limbo a utilisé _pick_. Il est court et décrit l'intention du type de choisir parmi un ensemble fini de types.

Le nom/la syntaxe est en grande partie hors de propos. Choisir serait bien.

L'une ou l'autre des propositions de ce fil correspond à la définition théorique des ensembles.

Le premier type étant spécial pour la valeur zéro n'est pas pertinent puisque les sommes théoriques de type commutent, donc l'ordre n'est pas pertinent (A + B = B + A). Ma proposition maintient cette propriété, mais les types de produits commutent également en théorie et sont considérés comme différents dans la pratique par la plupart des langues (Go inclus), donc ce n'est probablement pas essentiel.

@jimmyfrasche

Personnellement, je pense que refuser les interfaces en tant que membres « choisir » est un très gros inconvénient. Premièrement, cela irait complètement à l'encontre de l'un des grands cas d'utilisation des types « pick » : avoir une erreur sur l'un des membres. Ou vous voulez gérer un type de sélection qui a soit un io.Reader, soit une chaîne, si vous ne voulez pas forcer l'utilisateur à utiliser un StringReader au préalable. Mais dans l'ensemble, une interface n'est qu'un autre type, et je pense qu'il ne devrait pas y avoir de restrictions de type pour les membres « choisir ». Cela étant le cas, si un type de sélection a 2 membres d'interface, où l'un est entièrement entouré par l'autre, cela devrait être une erreur de compilation, comme mentionné précédemment.

Ce que j'aime dans votre contre-proposition, c'est le fait que les méthodes peuvent être définies sur le type de prélèvement. Je ne pense pas qu'il devrait fournir une section transversale des méthodes des membres, car je ne pense pas qu'il y aurait beaucoup de cas où des méthodes appartiendraient à tous les membres (et vous avez des interfaces pour cela de toute façon). Et un switch exhaustif + boitier par défaut est une très bonne idée.

@rogpeppe @jimmyfrasche Quelque chose que je ne vois pas dans vos propositions, c'est pourquoi nous devrions le faire. Il y a un inconvénient évident à ajouter un nouveau type de type : c'est un nouveau concept que tout le monde qui apprend le Go devra apprendre. Quel est l'avantage compensatoire ? En particulier, que nous apporte le nouveau type de type que nous n'obtenons pas des types d'interface ?

@ianlancetaylor Robert l'a bien résumé ici : https://github.com/golang/go/issues/19412#issuecomment -288608089

@ianlancetaylor
En fin de compte, cela rend le code plus lisible, et c'est la principale directive de Go. Considérez json.Token, il est actuellement défini comme une interface{}, mais la documentation indique qu'il ne peut en fait être qu'un type parmi un nombre spécifique de types. Si, par contre, c'est écrit comme

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

L'utilisateur pourra voir immédiatement toutes les possibilités, et l'outillage pourra créer automatiquement un interrupteur exhaustif. De plus, le compilateur vous empêchera également d'y coller un type inattendu.

En fin de compte, cela rend le code plus lisible, et c'est la principale directive de Go.

Plus de fonctionnalités signifie qu'il faut en savoir plus pour comprendre le code. Pour une personne n'ayant qu'une connaissance moyenne d'une langue, sa lisibilité est nécessairement inversement proportionnelle au nombre de caractéristiques [nouvellement ajoutées].

@cznic

Plus de fonctionnalités signifie qu'il faut en savoir plus pour comprendre le code.

Pas toujours. Si vous pouvez substituer "en savoir plus sur le langage" à "en savoir plus sur les invariants mal ou incohérents documentés dans le code", cela peut toujours être un gain net. (C'est-à-dire que les connaissances mondiales peuvent remplacer le besoin de connaissances locales.)

Si une meilleure vérification du type au moment de la compilation est en effet le seul avantage, alors nous pouvons obtenir un avantage très similaire sans changer la langue en introduisant un commentaire vérifié par vet. Quelque chose comme

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

Maintenant, nous n'avons actuellement aucun type de commentaires de vétérinaires, ce n'est donc pas une suggestion tout à fait sérieuse. Mais je suis sérieux au sujet de l'idée de base : si le seul avantage que nous obtenons est quelque chose que nous pouvons faire entièrement avec un outil d'analyse statique, cela vaut-il vraiment la peine d'ajouter un nouveau concept complexe au langage proprement dit ?

Beaucoup, peut-être tous, des tests effectués par cmd/vet pourraient être ajoutés au langage, dans le sens où ils pourraient être vérifiés par le compilateur plutôt que par un outil d'analyse statique séparé. Mais pour diverses raisons, nous trouvons utile de séparer vet du compilateur. Pourquoi ce concept relève-t-il du côté linguistique plutôt que du côté vétérinaire ?

@ianlancetaylor pour ce qui est de savoir si le changement est justifié, j'ai activement ignoré cela ou plutôt le repousser. En parler dans l'abstrait est vague et ne m'aide pas : tout cela sonne comme « les bonnes choses sont bonnes et les mauvaises choses sont mauvaises » pour moi. Je voulais avoir une idée de ce que serait réellement le type - quelles sont ses limites, quelles implications il a, quels sont les avantages, quels sont les inconvénients - afin que je puisse voir comment il s'intégrerait dans le langage (ou pas ! ) et avoir une idée de la façon dont je pourrais/pourrais l'utiliser dans les programmes. Je pense avoir une bonne idée de ce que les types de somme devraient signifier dans Go maintenant, du moins de mon point de vue. Je ne suis pas entièrement convaincu qu'ils en valent la peine (même si je les veux vraiment pas), mais maintenant que j'ai quelque chose de solide à analyser avec des propriétés bien définies sur lesquelles je peux raisonner. Je sais que ce n'est pas vraiment une réponse en soi, mais c'est au moins là où j'en suis avec ça.

Si une meilleure vérification du type au moment de la compilation est en effet le seul avantage, alors nous pouvons obtenir un avantage très similaire sans changer la langue en introduisant un commentaire vérifié par vet.

Ceci est toujours vulnérable à la critique du besoin d'apprendre de nouvelles choses. Si je dois en apprendre davantage sur ces commentaires magiques de vétérinaire pour déboguer/comprendre/utiliser le code, c'est une taxe mentale, peu importe que nous l'attribuions au budget de la langue Go ou au budget techniquement pas la langue Go. Si quoi que ce soit, les commentaires magiques sont plus coûteux parce que je ne savais pas que j'avais besoin de les apprendre quand je pensais avoir appris la langue.

@cznic
Je ne suis pas d'accord. Avec votre hypothèse actuelle, vous ne pouvez pas être sûr qu'une personne comprenne alors ce qu'est un canal, ou même ce qu'est une fonction. Pourtant, ces choses existent dans la langue. Et une nouvelle fonctionnalité ne signifie pas automatiquement qu'elle rendrait la langue plus difficile. Dans ce cas, je dirais que cela faciliterait en fait la compréhension, car cela indique immédiatement au lecteur ce qu'un type est censé être, par opposition à l'utilisation d'un type d'interface de boîte noire{}.

@ianlancetaylor
Personnellement, je pense que cette fonctionnalité a plus à voir avec le fait de rendre le code plus facile à lire et à raisonner. La sécurité du temps de compilation est une fonctionnalité très intéressante, mais pas la principale. Non seulement cela rendrait une signature de type immédiatement plus évidente, mais son utilisation ultérieure serait également plus facile à comprendre et à écrire. Les gens n'auraient plus besoin de recourir à la panique s'ils recevaient un type auquel ils ne s'attendaient pas - c'est le comportement actuel même dans la bibliothèque standard, mais ils auraient plus de facilité à réfléchir à l'utilisation, sans être encombré par l'inconnu . Et je ne pense pas que ce soit une bonne idée de s'appuyer sur des commentaires et d'autres outils (même s'ils sont de première partie) pour cela, car une syntaxe plus propre est plus lisible qu'un tel commentaire. Et les commentaires sont sans structure et beaucoup plus faciles à gâcher.

@ianlancetaylor

Pourquoi ce concept relève-t-il du côté linguistique plutôt que du côté vétérinaire ?

Vous pouvez appliquer la même question à n'importe quelle fonctionnalité en dehors du noyau turing-complet, et nous ne voulons sans doute pas que Go soit un "turing tarpit". D'autre part, nous avons des exemples de langues qui ont bousculé des sous - ensembles importants de la langue réelle au large dans une syntaxe « extension » générique. (Par exemple, "attributs" dans Rust, C++ et GNU C.)

La principale raison de placer des fonctionnalités dans des extensions ou des attributs plutôt que dans un langage de base est de préserver la compatibilité de la syntaxe, y compris la compatibilité avec les outils qui ne sont pas conscients de la nouvelle fonctionnalité. (Le fait que la « compatibilité avec les outils » fonctionne réellement dans la pratique dépend fortement de ce que fait réellement la fonctionnalité.)

Dans le contexte de Go, il semble que la principale raison de mettre des fonctionnalités dans vet soit d'implémenter des modifications qui ne préserveraient pas la compatibilité Go 1 si elles étaient appliquées au langage lui-même. Je ne vois pas cela comme un problème ici.

Une des raisons de ne pas mettre de fonctionnalités dans vet est si elles doivent être propagées lors de la compilation. Par exemple, si j'écris :

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

vais-je recevoir les avertissements appropriés pour les types qui ne sont pas dans la somme, au-delà des limites du package ? Il n'est pas évident pour moi que vet puisse faire une analyse transitive aussi approfondie, alors c'est peut-être une raison pour laquelle il faudrait entrer dans le langage de base.

@dr2chase En général, bien sûr, vous avez raison, mais avez-vous raison pour cet exemple spécifique ? Le code est complètement compréhensible sans savoir ce que signifie le commentaire magique. Le commentaire magique ne change en rien ce que fait le code. Les messages d'erreur du vétérinaire doivent être clairs.

@bcmills

Pourquoi ce concept relève-t-il du côté linguistique plutôt que du côté vétérinaire ?

Vous pouvez appliquer la même question à n'importe quelle fonctionnalité en dehors du noyau turing-complet....

Je ne suis pas d'accord. Si la fonctionnalité en discussion affecte le code compilé, alors il y a un argument automatique en sa faveur. Dans ce cas, la fonctionnalité n'affecte apparemment pas le code compilé.

(Et, oui, le vétérinaire peut analyser la source des packages importés.)

Je n'essaie pas de prétendre que mon argument sur le vétérinaire est concluant. Mais chaque changement de langue part d'une position négative : une langue simple est très très souhaitable, et une nouvelle fonctionnalité importante comme celle-ci rend inévitablement la langue plus complexe. Vous avez besoin d'arguments solides en faveur d'un changement de langue. Et de mon point de vue, ces arguments forts ne sont pas encore apparus. Après tout, nous avons réfléchi à cette question depuis longtemps, et c'est une FAQ (https://golang.org/doc/faq#variant_types).

@ianlancetaylor

Dans ce cas, la fonctionnalité n'affecte apparemment pas le code compilé.

Je pense que cela dépend des détails spécifiques? Le comportement "la valeur zéro de la somme est la valeur zéro du premier type" que @jimmyfrasche a mentionné ci-dessus (https://github.com/golang/go/issues/19412#issuecomment-289319916) le ferait certainement.

@urandom J'écrivais une longue explication sur la raison pour laquelle les types d'interface et d'union ne se mélangeaient pas sans constructeurs de types explicites, mais j'ai ensuite réalisé qu'il y avait une sorte de moyen sensé de le faire, donc:

Contre-proposition rapide et sale à ma contre-proposition. (Tout ce qui n'est pas explicitement mentionné est le même que ma proposition précédente). Je ne suis pas sûr qu'une proposition soit meilleure qu'une autre, mais celle-ci autorise les interfaces et est plus explicite :

L'union a des « noms de champs » explicites ci-après appelés « noms de balises » :

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

Il n'y a toujours pas d'encastrement. C'est toujours une erreur d'avoir un type sans nom de balise.

Les valeurs d'union ont une balise dynamique plutôt qu'un type dynamique.

Création de valeur littérale : U{v} n'est valide que s'il n'est pas ambigu, sinon il doit être U{Tag: v} .

La convertibilité et la compatibilité des affectations prennent également en compte les noms de balises.

L'affectation à un syndicat n'est pas magique. Cela signifie toujours attribuer une valeur d'union compatible. Pour définir la valeur stockée, le nom de balise souhaité doit être explicitement utilisé : v.Good = 1 définit la balise dynamique sur Good et la valeur stockée sur 1.

L'accès à la valeur stockée utilise une assertion de balise plutôt qu'une assertion de type :

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

v.Tag est une erreur sur les droites car il est ambigu.

Les commutateurs de balise sont comme les commutateurs de type, écrits switch v.[type] , sauf que les cas sont les balises de l'union.

Les assertions de type sont valables par rapport au type de la balise dynamique. Les commutateurs de type fonctionnent de la même manière.

Étant donné les valeurs a, b d'un certain type d'union, a == b si leurs balises dynamiques sont les mêmes et que la valeur stockée est la même.

Vérifier si la valeur stockée est une valeur particulière nécessite une assertion de balise.

Si un nom de balise n'est pas exporté, il ne peut être défini et accessible que dans le package qui définit l'union. Cela signifie qu'un changement de balise d'une union avec des balises mixtes exportées et non exportées ne peut jamais être exhaustif en dehors du package de définition sans cas par défaut. Si toutes les balises ne sont pas exportées, il s'agit d'une boîte noire.

La réflexion doit également gérer les noms des balises.

e : Clarification pour les unions imbriquées. Étant donné

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

La valeur de u est la balise dynamique A et la valeur stockée est l'union anonyme avec la balise dynamique A1 et sa valeur stockée est la valeur zéro de T1.

u.B.B2 = returnsSomeT3()

est tout ce qui est nécessaire pour faire passer u de la valeur zéro, même s'il passe d'une des unions imbriquées à l'autre car tout est stocké dans un emplacement mémoire. Mais

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

a deux chances de paniquer car sa balise affirme sur deux valeurs d'union et la version à 2 valeurs de l'assertion de balise n'est pas disponible sans fractionnement sur plusieurs lignes. Les commutateurs de balises imbriquées seraient plus propres, dans ce cas.

edit2 : Clarification sur les assertions de type.

Étant donné

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

une assertion de type comme u.(int) est tout à fait raisonnable. Dans le package de définition, cela tiendrait toujours. Cependant, si u est en dehors du package de définition, u.(int) paniquerait lorsque la balise dynamique est unexported pour éviter de divulguer les détails de l'implémentation. De même pour les assertions à un type d'interface.

@ianlancetaylor Voici quelques exemples de la façon dont cette fonctionnalité pourrait vous aider :

  1. Au cœur de certains packages ( go/ast par exemple) se trouve un ou plusieurs types de sommes importantes. Il est difficile de naviguer dans ces packages sans comprendre ces types. Plus confusément, parfois un type somme est représenté par une interface avec des méthodes (par exemple go/ast.Node ), d'autres fois par l'interface vide (par exemple go/ast.Object.Decl ).

  2. La compilation de la fonctionnalité protobuf oneof sur Go génère un type d'interface non exporté dont le seul but est de s'assurer que l'affectation au champ oneof est de type sûr. Cela nécessite à son tour de générer un type pour chaque branche de oneof. Les littéraux de type pour le produit final sont difficiles à lire et à écrire :

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

    Certains (mais pas tous) des oneofs pourraient être exprimés par des types de somme.

  3. Parfois, un type "peut-être" est exactement ce dont on a besoin. Par exemple, de nombreuses opérations de mise à jour des ressources de l'API Google permettent de modifier un sous-ensemble des champs de la ressource. Une façon naturelle d'exprimer cela dans Go est d'utiliser une variante de la structure de ressource avec un type "peut-être" pour chaque champ. Par exemple, la ressource ObjectAttrs de Google Cloud Storage ressemble à

    type ObjectAttrs struct {
       ContentType string
       ...
    }
    

    Pour prendre en charge les mises à jour partielles, le package définit également

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

    optional.String ressemble à ceci ( godoc ):

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

    C'est à la fois difficile à expliquer et peu sûr, mais cela s'avère pratique dans la pratique, car un littéral ObjectAttrsToUpdate ressemble exactement à un littéral ObjectAttrs , tout en encodant la présence. J'aurais aimé qu'on puisse écrire

    type ObjectAttrsToUpdate struct {
       ContentType string | nil
       ...
    }
    
  4. De nombreuses fonctions renvoient (T, error) avec une sémantique xor (T est significatif si l'erreur est nulle). Écrire le type de retour sous la forme T | error clarifierait la sémantique, augmenterait la sécurité et offrirait plus de possibilités de composition. Même si nous ne pouvons pas (pour des raisons de compatibilité) ou ne voulons pas changer la valeur de retour d'une fonction, le type sum est toujours utile pour transporter cette valeur, comme l'écrire dans un canal.

Une annotation go vet aiderait certes beaucoup de ces cas, mais pas ceux où un type anonyme a du sens. Je pense que si nous avions des types de somme, nous verrions beaucoup de

chan *Response | error

Ce type est suffisamment court pour être écrit plusieurs fois.

@ianlancetaylor ce n'est probablement pas un bon début, mais voici tout ce que vous pouvez faire avec les syndicats que vous pouvez déjà faire dans Go1, car j'ai pensé qu'il n'était que juste de reconnaître et de résumer ces arguments :

(En utilisant ma dernière proposition avec des balises pour la syntaxe/sémantique ci-dessous. En supposant également que le code émis est essentiellement comme le code C que j'ai posté beaucoup plus tôt dans le fil.)

Les types de somme se chevauchent avec l'iota, les pointeurs et les interfaces.

iota

Ces deux types sont à peu près équivalents :

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

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

et

type Stoplight int

const (
  Green Stoplight = iota
  Yellow
  Red
)

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

Le compilateur émettrait probablement exactement le même code pour les deux.

Dans la version union, l'int est transformé en un détail d'implémentation caché. Avec la version iota, vous pouvez demander ce qu'est le jaune/rouge ou définir une valeur Stoplight sur -42, mais pas avec la version union - ce sont toutes des erreurs de compilateur et des invariants qui peuvent être pris en compte lors de l'optimisation. De même, vous pouvez écrire un commutateur (valeur) qui ne prend pas en compte les lumières jaunes, mais avec un commutateur de balise, vous auriez besoin d'un cas par défaut pour le rendre explicite.

Bien sûr, il y a des choses que vous pouvez faire avec iota que vous ne pouvez pas faire avec des types d'union.

pointeurs

Ces deux types sont à peu près équivalents

type MaybeInt64 union {
  None struct{}
  Int64 int64
}

et

type MaybeInt64 *int64

La version pointeur est plus compacte. La version union aurait besoin d'un bit supplémentaire (qui à son tour serait probablement de la taille d'un mot) pour stocker la balise dynamique, de sorte que la taille de la valeur serait probablement la même que https://golang.org/pkg/database/sql/ #NullInt64

La version syndicale documente plus clairement l'intention.

Bien sûr, il y a des choses que vous pouvez faire avec des pointeurs que vous ne pouvez pas faire avec des types d'union.

interfaces

Ces deux types sont à peu près équivalents

type AB union {
  A A
  B B
}

et

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

La version union ne peut pas être contournée avec l'intégration. A et B n'ont pas besoin de méthodes communes - ils pourraient en fait être des types primitifs ou avoir des ensembles de méthodes entièrement disjoints, comme l'exemple json.Token publié par

Il est vraiment facile de voir ce que vous pouvez mettre dans une union AB par rapport à une interface AB : la définition est la documentation (j'ai dû lire plusieurs fois la source go/ast pour comprendre ce qu'est quelque chose).

L'union AB ne peut jamais être nulle et peut recevoir des méthodes en dehors de l'intersection de ses constituants (cela pourrait être simulé en imbriquant l'interface dans une structure, mais la construction devient alors plus délicate et sujette aux erreurs).

Bien sûr, il y a des choses que vous pouvez faire avec des interfaces que vous ne pouvez pas faire avec des types union.

Sommaire

Peut-être que ce chevauchement est trop chevauchant.

Dans chaque cas, le principal avantage des versions d'union est en effet une vérification plus stricte du temps de compilation. Ce que vous ne pouvez pas faire est plus important que ce que vous pouvez. Pour le compilateur qui traduit en invariants plus forts, il peut utiliser pour optimiser le code. Pour le programmeur qui se traduit par une autre chose, vous pouvez laisser le compilateur s'inquiéter — il vous dira simplement si vous vous trompez. Dans la version d'interface, à tout le moins, il y a des avantages importants en matière de documentation.

Des versions maladroites des exemples d'iota et de pointeur peuvent être construites à l'aide de la stratégie « interface avec une méthode non exportée ». Pour cette question, cependant, les structures pourraient être simulées avec des interfaces map[string]interface{} et (non vides) avec des types de fonctions et des valeurs de méthode. Personne ne le ferait parce que c'est plus dur et moins sûr.

Toutes ces fonctionnalités ajoutent quelque chose au langage, mais leur absence pourrait être contournée (douloureuse et sous protestation).

Je suppose donc que la barre n'est pas pour démontrer un programme qui ne peut même pas être approximé en Go, mais plutôt pour démontrer un programme qui est beaucoup plus facilement et proprement écrit en Go avec des unions que sans. Donc ce qu'il reste à montrer, c'est ça.

@jimmyfrasche

Je ne vois aucune raison pour laquelle le type d'union aurait dû nommer des champs. Les noms ne sont utiles que si vous souhaitez faire la distinction entre différents champs du même type. Cependant, une union ne doit jamais avoir plusieurs champs du même type, car cela n'a aucun sens. Ainsi, avoir des noms est tout simplement redondant et conduit à la confusion et à plus de frappe.

En gros, votre type d'union devrait ressembler à quelque chose comme :

union {
    struct{}
    int
    err
}

Les types eux-mêmes fourniront les identificateurs uniques qui peuvent être utilisés pour attribuer à une union, assez similaires à la façon dont les types intégrés dans les structs sont utilisés comme identificateurs.

Cependant, pour que les affectations explicites fonctionnent, il est impossible de créer un type d'union en spécifiant un type sans nom en tant que membre, car la syntaxe permettrait une telle expression. Par exemple v.struct{} = struct{}

Ainsi, des types tels que raw struct, unions et funcs doivent être nommés au préalable pour faire partie d'une union et devenir assignables. Dans cet esprit, une union imbriquée n'aura rien de spécial, car l'union interne sera simplement un autre type de membre.

Maintenant, je ne sais pas quelle syntaxe serait la meilleure.

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

Ce qui précède semble plus go-like, mais est un peu verbeux pour un tel type.

D'un autre côté, type1 | package1.type2 peut ne pas ressembler à votre type de go habituel, mais il a l'avantage d'utiliser le '|' symbole, qui est principalement reconnu comme un OU. Et cela réduit la verbosité sans être cryptique.

@urandom si vous n'avez pas de "noms de balises" mais autorisez les interfaces, les sommes s'effondrent en un interface{} avec des vérifications supplémentaires. Ils cessent d'être des types de somme puisque vous pouvez mettre une chose mais la sortir de plusieurs manières. Les noms de balises leur permettent d'être des types de somme et contiennent des interfaces sans ambiguïté.

Les noms de balises réparent bien plus que le simple problème d'interface{}, cependant. Ils rendent le type beaucoup moins magique et laissent tout être glorieusement explicite sans avoir à inventer un tas de types juste pour se différencier. Vous pouvez avoir une affectation explicite et des littéraux de type, comme vous le faites remarquer.

Le fait que vous puissiez donner à un type plus d'une balise est une caractéristique. Considérez un type pour mesurer combien de succès ou d'échecs se sont produits d'affilée (1 succès annule N échecs et vice versa)

type Counter union {
  Successes, Failures uint 
}

sans les noms de balises dont vous auriez besoin

type (
  Success uint
  Failures uint
  Counter Successes | Failures
)

et l'affectation ressemblerait à c = Successes(1) au lieu de c.Successes = 1 . Vous n'y gagnez pas grand chose.

Un autre exemple est un type qui représente une défaillance locale ou distante. Avec les noms de balises, c'est facile à modéliser :

type Failure union {
  Local, Remote error
}

La source de l'erreur peut être spécifiée avec son nom de balise, quelle que soit l'erreur réelle. Sans noms de balises, vous auriez besoin de type Local { error } et de même pour la télécommande, même si vous autorisez les interfaces directement dans la somme.

Les noms de balises créent en quelque sorte des types spéciaux ni alias ni nommés localement dans l'union. Avoir plusieurs "tags" avec des types identiques n'est pas unique à ma proposition : c'est ce que fait chaque langage fonctionnel (que je connais).

La possibilité de créer des balises non exportées pour les types exportés et vice versa est également une variante intéressante.

Le fait d'avoir également des assertions de balise et de type séparées permet un code intéressant, comme la possibilité de promouvoir une méthode partagée à l'union avec un wrapper d'une ligne.

Il semble que cela résout plus de problèmes qu'il n'en cause et que tout s'emboîte beaucoup mieux. Honnêtement, je n'étais pas si sûr quand je l'ai écrit, mais je suis de plus en plus convaincu que c'est le seul moyen de résoudre tous les problèmes liés à l'intégration de sommes dans Go.

Pour développer un peu cela, l'exemple motivant pour moi était de @rogpeppe io.Reader | io.ReadCloser . Autorisant les interfaces sans balises, c'est le même type que io.Reader .

Vous pouvez insérer un ReadCloser et le retirer en tant que lecteur. Vous perdez le A | B signifie la propriété A ou B des types de somme.

Si vous devez être précis sur la gestion parfois d'un io.ReadCloser tant que io.Reader vous devez créer des structures wrapper comme l' a souligné type Reader struct { io.Reader } etc. et avoir le type Reader | ReadCloser .

Même si vous limitez les sommes aux interfaces avec des ensembles de méthodes disjoints, vous avez toujours ce problème car un type peut implémenter plusieurs de ces interfaces. Vous perdez l'explicitation des types de somme : ils ne sont pas « A ou B » : ils sont « A ou B ou parfois ce que vous voulez ».

Pire encore, si ces types proviennent d'autres packages, ils peuvent soudainement se comporter différemment après une mise à jour, même si vous avez fait très attention à construire votre programme de sorte que A ne soit jamais traité de la même manière que B.

À l'origine, j'ai exploré l'interdiction des interfaces pour résoudre le problème. Personne n'était content de ça ! Mais cela n'a pas non plus éliminé des problèmes comme a = b signifiant des choses différentes selon les types de a et b, avec lesquels je ne suis pas à l'aise. Il devait également y avoir beaucoup de règles sur le type choisi dans le choix lorsque l'assignabilité du type entre en jeu. C'est beaucoup de magie.

Vous ajoutez des balises et tout s'en va.

Avec union { R io.Reader | RC io.ReadCloser } vous pouvez explicitement dire que je veux que ce ReadCloser soit considéré comme un lecteur si c'est ce qui a du sens. Aucun type d'emballage nécessaire. C'est implicite dans la définition. Quel que soit le type de balise, c'est l'une ou l'autre balise.

L'inconvénient est que, si vous obtenez un io.Reader d'ailleurs, disons un appel de réception ou de fonction chan, et il peut s'agir d'un io.ReadCloser et vous devez l'affecter à la balise appropriée que vous devez taper assert sur io. Lisez plus près et testez. Mais cela rend l'intention du programme beaucoup plus claire - exactement ce que vous voulez dire est dans le code.

De plus, parce que les assertions de balise sont différentes des assertions de type, si vous ne vous en souciez pas vraiment et que vous voulez juste un io.Reader, vous pouvez utiliser une assertion de type pour l'extraire, quelle que soit la balise.

Il s'agit d'une translittération au mieux d'un exemple de jouet en Go sans unions/sommes/etc. Ce n'est probablement pas le meilleur exemple mais c'est celui que j'ai utilisé pour voir à quoi cela ressemblerait.

Il montre la sémantique d'une manière plus opérationnelle, ce qui sera probablement plus facile à comprendre que quelques puces laconiques dans une proposition.

Il y a un peu de passe-partout dans la translittération, donc je n'ai généralement écrit que la première instance de plusieurs méthodes avec une note sur la répétition.

En Go avec proposition syndicale :

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

Translittéré en Go actuel :

(des notes sont incluses sur les différences entre la translittération et ce qui précède)

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

Puisque l'union contient des balises qui peuvent avoir le même type, la syntaxe suivante ne serait-elle pas mieux adaptée :

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

La façon dont je le vois, lorsqu'elle est utilisée avec un commutateur, une union est assez similaire à des types tels que int ou string. La principale différence étant qu'il n'y a que des « valeurs » finies qui peuvent lui être attribuées, par opposition aux premiers types, et le commutateur lui-même est exhaustif. Ainsi, dans ce cas, je ne vois pas vraiment la nécessité d'une syntaxe particulière, réduisant le travail mental du développeur.

De plus, dans le cadre de cette proposition, un tel code serait-il valide :

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 J'ai choisi une syntaxe pour refléter la sémantique en utilisant des analogies avec la syntaxe Go existante dans la mesure du possible.

Avec les types d'interface, vous pouvez faire

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

C'est bien et sans ambiguïté car peu importe le type de someValue tant que le contrat est satisfait.

Lorsque vous introduisez des tags† sur les syndicats, cela peut parfois être ambigu. L'affectation magique ne serait valable que dans certains cas. Boîtier spécial, cela vous permet seulement d'être explicite parfois.

Je ne vois pas l'intérêt de pouvoir parfois sauter une étape, surtout lorsqu'un changement de code peut facilement invalider ce cas particulier et que vous devez quand même revenir en arrière et mettre à jour tout le code. Pour utiliser votre exemple Foo/Bar si C int est ajouté à Foo alors Bar(1) doit changer mais pas Bar("hello world") . Cela complique tout pour économiser quelques frappes dans des situations qui peuvent ne pas être si courantes et rend les concepts plus difficiles à comprendre car parfois ils ressemblent à ceci et parfois ils ressemblent à cela - il suffit de consulter cet organigramme pratique pour voir ce qui s'applique à vous !

† J'aimerais avoir un meilleur nom pour ceux-là. Il existe déjà des balises struct. Je les aurais appelés des étiquettes mais Go les a aussi. Les appeler des champs semble à la fois plus approprié et le plus déroutant. Si quelqu'un veut faire du vélo, celui-ci pourrait vraiment utiliser un nouveau manteau.

Dans un sens, les unions étiquetées ressemblent plus à une structure qu'à une interface. Il s'agit d'un type spécial de structure qui ne peut avoir qu'un seul champ défini à la fois. Vu sous cet angle, votre exemple Foo/Bar reviendrait à dire ceci :

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

Bien que ce soit sans ambiguïté dans ce cas, je ne pense pas que ce soit une bonne idée.

Également dans la proposition, Bar(Foo{1}) est autorisé lorsqu'il n'y a pas d'ambiguïté si vous voulez vraiment enregistrer les frappes. Vous pouvez également avoir des pointeurs vers des unions afin que la syntaxe littérale composite soit toujours nécessaire pour &Foo{"hello world"} .

Cela dit, les unions ont une similitude avec les interfaces en ce sens qu'elles ont une balise dynamique dont le "champ" est actuellement défini.

Le switch v := u.[type] {... reflète bien le switch v := i.(type) {... pour les interfaces tout en autorisant les changements de type et les assertions directement sur les valeurs d'union. Peut-être que cela devrait être u.[union] pour le rendre plus facile à repérer, mais dans tous les cas, la syntaxe n'est pas si lourde et ce que cela signifie est clair.

Vous pourriez faire valoir le même argument que le .(type) est inutile, mais quand vous voyez cela, vous savez toujours exactement ce qui se passe et cela le justifie pleinement, à mon avis.

C'était mon raisonnement derrière ces choix.

@jimmyfrasche
La syntaxe du switch me semble un peu contre-intuitive, même après vos explications. Avec une interface, switch v := i.(type) {... commute entre les types possibles, comme indiqué par les cas de commutation, et indiqué par .(type) .
Cependant, avec une union, un commutateur ne bascule pas entre les types possibles, mais les valeurs. Chaque cas représente une valeur possible différente, où les valeurs peuvent en fait partager le même type. Ceci est plus similaire aux chaînes et aux commutateurs int, où les cas répertorient également les valeurs et leur syntaxe est un simple switch v := u {... . À partir de là, il me semble plus naturel que le basculement entre les valeurs d'une union soit switch v := u { ... , car les cas sont similaires, mais plus restrictifs, que les cas pour les ints et les chaînes.

@urandom c'est un très bon point sur la syntaxe. La vérité est que c'est un vestige de ma proposition précédente sans étiquettes, donc c'était le type à l'époque. Je l'ai juste copié aveuglément sans réfléchir. Merci de l'avoir signalé.

switch u {... fonctionnerait, mais le problème avec switch v := u {... est qu'il ressemble trop à switch v := f(); v {... (ce qui rendrait le signalement d'erreurs plus difficile, ce qui n'est pas clair).

Si le mot-clé union été renommé en pick comme cela a été suggéré par @as, alors le commutateur de balise pourrait être écrit comme switch u.[pick] {... ou switch v := u.[pick] {... ce qui maintient la symétrie avec un type switch mais perd la confusion et a l'air plutôt sympa.

Même si l'implémentation active un int, il y a toujours une déstructuration implicite du choix en balise dynamique et valeur stockée, ce qui, à mon avis, devrait être explicite, quelles que soient les règles grammaticales

vous savez, il suffit d'appeler les champs des balises et de les faire affirmer et changer de champ.

edit: cela rendrait l'utilisation de Reflect avec des choix maladroite, cependant

[Désolé pour le retard de réponse - j'étais en vacances]

@ianlancetaylor a écrit :

Quelque chose que je ne vois pas dans vos propositions, c'est pourquoi nous devrions faire cela. Il y a un inconvénient évident à ajouter un nouveau type de type : c'est un nouveau concept que tout le monde qui apprend le Go devra apprendre. Quel est l'avantage compensatoire ? En particulier, que nous apporte le nouveau type de type que nous n'obtenons pas des types d'interface ?

Il y a deux avantages principaux que je vois. Le premier est un avantage linguistique ; le second est un avantage de performance.

  • lors du traitement des messages, en particulier lorsqu'ils sont lus à partir d'un processus concurrent, il est très utile de pouvoir connaître l'ensemble complet des messages pouvant être reçus, car chaque message peut être accompagné d'exigences de protocole associées. Pour un protocole donné, le nombre de types de messages possibles peut être très petit, mais lorsque nous utilisons une interface ouverte pour représenter les messages, cet invariant n'est pas clair. Souvent, les gens utiliseront un canal différent pour chaque type de message pour éviter cela, mais cela a ses propres coûts.

  • il y a des moments où il y a un petit nombre de types de messages possibles connus, dont aucun ne contient de pointeurs. Si nous utilisons une interface ouverte pour les représenter, nous devons effectuer une allocation pour créer des valeurs d'interface. L'utilisation d'un type qui restreint les types de messages possibles signifie que cela peut être évité et donc soulager la pression du GC et augmenter la localisation du cache.

Une douleur particulière pour moi que les types de somme pourraient résoudre est godoc. Prenez ast.Spec par exemple : https://golang.org/pkg/go/ast/#Spec

De nombreux packages répertorient manuellement les types sous-jacents possibles d'un type d'interface nommé, afin qu'un utilisateur puisse rapidement se faire une idée sans avoir à regarder le code ou à se fier aux suffixes ou préfixes de nom.

Si le langage connaît déjà toutes les valeurs possibles, cela pourrait être automatisé dans godoc peu comme les types enum avec iotas. Ils pourraient également être liés aux types, au lieu d'être simplement du texte en clair.

Edit : un autre exemple : https://github.com/mvdan/sh/commit/ebbfda50dfe167bee741460a4491ffec1006bdef

@mvdan c'est un excellent point pratique pour améliorer l'histoire dans Go1 sans aucun changement de langue. Pouvez-vous déposer un problème séparé pour cela et faire référence à celui-ci ?

Désolé, faites-vous uniquement référence à des liens vers d'autres noms dans la page godoc, mais toujours en les répertoriant manuellement ?

Désolé, j'aurais dû être plus clair.

Je voulais dire une demande de fonctionnalité pour gérer automatiquement les types qui implémentent les interfaces définies dans le package actuel dans godoc.

(Je crois qu'il y a une demande de fonctionnalité quelque part pour lier les noms répertoriés manuellement, mais je n'ai pas le temps de la traquer pour le moment).

Je ne souhaite pas reprendre ce fil (déjà très long), j'ai donc créé un problème séparé - voir ci-dessus.

@Merovius Je réponds à https://github.com/golang/go/issues/19814#issuecomment -298833986 dans ce numéro car les trucs AST s'appliquent davantage aux types de somme qu'aux énumérations. Toutes mes excuses pour vous avoir entraîné sur un autre problème.

Tout d'abord, je voudrais réitérer que je ne suis pas sûr que les types de somme appartiennent à Go. Je dois encore me convaincre qu'ils n'ont définitivement pas leur place. Je travaille en supposant qu'ils le font afin d'explorer l'idée et de voir si elles correspondent. Je suis prêt à être convaincu de toute façon, cependant.

Deuxièmement, vous avez mentionné la réparation progressive du code dans votre commentaire. L'ajout d'un nouveau terme à un type de somme est par définition un changement radical, comparable à l'ajout d'une nouvelle méthode à une interface ou à la suppression d'un champ d'une structure. Mais c'est le comportement correct et souhaité.

Considérons l'exemple d'un AST, implémenté avec une interface Node, qui ajoute un nouveau type de nœud. Disons que l'AST est défini dans un projet externe et que vous importez cela dans un package de votre projet, qui parcourt l'AST.

Il existe plusieurs cas :

  1. Votre code s'attend à parcourir chaque nœud :
    1.1. Vous n'avez pas de déclaration par défaut, votre code est silencieusement incorrect
    1.2. Vous avez une instruction par défaut avec une panique, votre code échoue à l'exécution au lieu de la compilation (les tests n'aident pas car ils ne connaissent que les nœuds qui existaient lorsque vous avez écrit les tests)
  2. Votre code inspecte uniquement un sous-ensemble de types de nœuds :
    2.1. Ce nouveau type de nœud n'aurait de toute façon pas été dans le sous-ensemble
    2.1.1. Tant que ce nouveau nœud ne contient jamais aucun des nœuds qui vous intéressent, tout s'arrange
    2.1.2. Sinon, vous êtes dans la même situation que si votre code s'attendait à parcourir chaque nœud
    2.2. Ce nouveau type de nœud aurait été dans le sous-ensemble qui vous intéresse, si vous l'aviez su.

Avec l'AST basé sur l'interface, seul le cas 2.1.1 fonctionne correctement. C'est une coïncidence plus qu'autre chose. La réparation progressive du code ne fonctionne pas. L'AST doit modifier sa version et votre code doit modifier sa version.

Un linter exhaustif serait utile, mais comme le linter ne peut pas examiner tous les types d'interface, il doit être informé d'une certaine manière qu'une interface particulière doit être vérifiée. Cela signifie soit un commentaire dans la source, soit une sorte de fichier de configuration dans votre référentiel. S'il s'agit d'un commentaire dans la source, puisque par définition l'AST est défini dans un projet séparé, vous êtes à la merci de ce projet pour baliser l'interface pour la vérification de l'exhaustivité. Cela ne fonctionne à grande échelle que s'il existe un seul linter d'exhaustivité sur lequel toute la communauté s'accorde et utilise toujours.

Avec un AST basé sur la somme, vous devez toujours utiliser la gestion des versions. La seule différence dans ce cas est que le linter d'exhaustivité est intégré au compilateur.

Ni l'un ni l'autre n'aide avec 2.2, mais qu'est-ce qui pourrait?

Il existe un cas plus simple, adjacent à l'AST, où les types de somme seraient utiles : les jetons. Disons que vous écrivez un lexer pour une calculatrice plus simple. Il y a des jetons comme * qui n'ont aucune valeur associée et des jetons comme Var qui ont une chaîne représentant le nom, et des jetons comme Val qui contiennent un float64 .

Vous pouvez implémenter cela avec des interfaces mais ce serait fastidieux. Vous feriez probablement quelque chose comme ça, cependant :

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 exhaustif sur les énumérations basées sur l'iota pourrait garantir qu'un type illégal n'est jamais utilisé, mais cela ne fonctionnerait pas trop bien contre quelqu'un qui attribue à Name lorsque Type == Times ou utilise Number lorsque Type == Var. Au fur et à mesure que le nombre et le type de jetons augmentent, cela ne fait qu'empirer. Vraiment, le mieux que vous puissiez faire ici est d'ajouter une méthode, Valid() error , qui vérifie toutes les contraintes et un tas de documentation expliquant quand vous pouvez faire quoi.

Un type somme encode facilement toutes ces contraintes et la définition serait toute la documentation nécessaire. L'ajout d'un nouveau type de jeton serait un changement décisif, mais tout ce que j'ai dit à propos des AST s'applique toujours ici.

Je pense que plus d'outillage est nécessaire. Je ne suis tout simplement pas convaincu que ce soit suffisant.

@jimmyfrasche

Deuxièmement, vous avez mentionné la réparation progressive du code dans votre commentaire. L'ajout d'un nouveau terme à un type de somme est par définition un changement radical, comparable à l'ajout d'une nouvelle méthode à une interface ou à la suppression d'un champ d'une structure.

Non, ce n'est pas à la hauteur. Vous pouvez effectuer ces deux modifications dans un modèle de réparation progressive (pour les interfaces : 1. Ajouter une nouvelle méthode à toutes les implémentations, 2. Ajouter une méthode à l'interface. Pour les champs de structure : 1. Supprimer toutes les utilisations de champ, 2. Supprimer le champ). L'ajout d'un cas dans un type somme ne peut

Il ne s'agit pas de savoir s'il s'agit ou non d'un changement décisif, il s'agit de savoir s'il s'agit d'un changement radical qui peut être orchestré avec une interruption minimale.

Mais c'est le comportement correct et souhaité.

Exactement. Les types de somme, de par leur définition même et toutes les raisons pour lesquelles les gens les veulent, sont fondamentalement incompatibles avec l'idée de réparation progressive du code.

Avec l'AST basé sur l'interface, seul le cas 2.1.1 fonctionne correctement.

Non, cela fonctionne également correctement dans le cas 1.2 (échouer au moment de l'exécution pour une grammaire non reconnue est parfaitement acceptable. Je ne voudrais probablement pas paniquer, mais simplement renvoyer une erreur) et aussi dans de nombreux cas de 2.1. Le reste est un problème fondamental avec la mise à niveau du logiciel ; si vous ajoutez une nouvelle fonctionnalité à une bibliothèque, les utilisateurs de votre bibliothèque doivent modifier le code pour l'utiliser. Cela ne signifie pas que votre logiciel est incorrect jusqu'à ce qu'il le fasse, cependant.

L'AST doit modifier sa version et votre code doit modifier sa version.

Je ne vois pas du tout en quoi cela découle de ce que vous dites. Pour moi, dire "cette nouvelle grammaire ne fonctionnera pas encore avec tous les outils, mais elle est disponible pour le compilateur" est bien. Tout comme "si vous exécutez cet outil sur cette nouvelle grammaire, il échouera à l'exécution" est très bien. Au pire, cela ne fait qu'ajouter une autre étape au processus de réparation graduelle : a) Ajoutez le nouveau nœud au package AST et à l'analyseur. b) Corrigez les outils à l'aide du package AST pour tirer parti du nouveau nœud. c) Mettre à jour le code pour utiliser le nouveau nœud. Oui, le nouveau nœud ne deviendra utilisable qu'une fois a) et b) terminés ; mais à chaque étape de ce processus, sans aucune casse, tout sera toujours compilé et fonctionnera correctement.

Je ne dis pas que vous serez automatiquement bien dans un monde de réparation de code progressive et sans vérifications exhaustives du compilateur. Cela nécessitera toujours une planification et une exécution minutieuses, vous casserez probablement toujours les dépendances inverses non maintenues et il pourrait encore y avoir des changements que vous ne pourrez peut-être pas du tout faire (bien que je ne puisse penser à aucun). Mais au moins a) il existe un chemin de mise à niveau progressif et b) la décision de casser ou non votre outil au moment de l'exécution appartient à l'auteur de l'outil. Ils peuvent décider quoi faire dans un cas qui est inconnu.

Un linter exhaustif serait utile, mais comme le linter ne peut pas examiner tous les types d'interface, il doit être informé d'une certaine manière qu'une interface particulière doit être vérifiée.

Pourquoi? Je dirais que c'est bien pour switchlint™ de se plaindre de n'importe quel type de commutateur sans cas par défaut ; après tout, vous vous attendez à ce que le code fonctionne avec n'importe quelle définition d'interface, donc ne pas avoir de code en place pour fonctionner avec des implémentations inconnues est probablement un problème de toute façon. Oui, il existe des exceptions à cette règle, mais les exceptions peuvent déjà être ignorées manuellement.

Je serais probablement plus à l'aise avec l'application de "chaque changement de type devrait nécessiter un cas par défaut, même s'il est vide" dans le compilateur, qu'avec les types de somme réels. Cela permettrait et forcerait à la fois les gens à prendre la décision de ce que leur code devrait faire face à un choix inconnu.

Vous pouvez implémenter cela avec des interfaces mais ce serait fastidieux.

haussement d'épaules c'est un effort ponctuel dans un cas qui revient très rarement. Cela me semble bien.

Et FWIW, je ne fais actuellement qu'argumenter contre la notion de vérification exhaustive des types de somme. Je n'ai pas encore d'opinion tranchée sur la commodité supplémentaire de dire "l'un de ces types structurellement définis".

@Merovius Je vais devoir réfléchir davantage à vos excellents points sur la réparation progressive du code. En attendant:

contrôles d'exhaustivité

Je ne fais actuellement qu'argumenter contre la notion de vérification exhaustive des types de somme.

Vous pouvez explicitement refuser les contrôles d'exhaustivité avec un cas par défaut (enfin, effectivement : le cas par défaut le rend exhaustif en ajoutant un cas qui couvre "tout le reste, quel qu'il soit"). Vous avez toujours le choix, mais vous devez le faire explicitement.

Je dirais que c'est bien pour switchlint™ de se plaindre de n'importe quel type de commutateur sans cas par défaut ; après tout, vous vous attendez à ce que le code fonctionne avec n'importe quelle définition d'interface, donc ne pas avoir de code en place pour fonctionner avec des implémentations inconnues est probablement un problème de toute façon. Oui, il existe des exceptions à cette règle, mais les exceptions peuvent déjà être ignorées manuellement.

C'est une idée intéressante. Bien qu'il frappe les types de somme simulés avec l'interface et les énumérations simulées avec const/iota, cela ne vous dit pas que vous avez manqué un cas connu, juste que vous n'avez pas géré le cas inconnu. Quoi qu'il en soit, cela semble bruyant. Envisager:

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

C'est exhaustif si n est intégral (pour les flottants, il manque n != n ) mais sans encoder beaucoup d'informations sur les types, il est probablement plus facile de simplement marquer cela comme manquant par défaut. Pour quelque chose comme :

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

même si les p[i] forment une relation d'équivalence sur les types de a et b il ne pourra pas le prouver, il doit donc signaler le commutateur comme manquant par défaut case, ce qui signifie un moyen de le faire taire avec un manifeste, une annotation dans la source, un script wrapper pour egrep -v sur la liste blanche, ou une valeur par défaut inutile sur le commutateur qui implique à tort que le p[i] ne sont pas exhaustifs.

En tout cas, cela serait trivial à mettre en œuvre si la voie "toujours se plaindre de l'absence de défaut en toutes circonstances" est prise. Il serait intéressant de le faire et de l'exécuter sur go-corpus et de voir à quel point il est bruyant et/ou utile dans la pratique.

jetons

Implémentations de jetons alternatives :

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

Cela élimine la possibilité de définir un état de jeton illégal où quelque chose a à la fois une chaîne et une valeur numérique mais n'interdit pas la création d'un StringToken avec un type qui devrait être un SimpleToken ou vice versa.

Pour ce faire avec les interfaces, vous devez définir un type par jeton ( type Plus struct{} , type Mul struct{} , etc.) et la plupart des définitions sont exactement les mêmes pour le nom de type. Un effort ponctuel ou non, c'est beaucoup de travail (bien que bien adapté à la génération de code dans ce cas).

Je suppose que vous pourriez avoir une "hiérarchie" d'interfaces de jetons pour partitionner les types de jetons en fonction des valeurs autorisées : (en supposant dans cet exemple qu'il existe plusieurs types de jetons pouvant contenir un nombre ou une chaîne, 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
}

Quoi qu'il en soit, cela signifie que chaque jeton nécessite une déférence de pointeur pour accéder à sa valeur, contrairement au type struct ou sum qui ne nécessite des pointeurs que lorsque des chaînes sont impliquées. Ainsi, avec les linters appropriés et les améliorations apportées à godoc, la grande victoire pour les types de somme dans ce cas est liée à la minimisation des allocations tout en interdisant les états illégaux et la quantité de frappe (au sens du clavier), ce qui ne semble pas sans importance.

Vous pouvez explicitement refuser les contrôles d'exhaustivité avec un cas par défaut (enfin, effectivement : le cas par défaut le rend exhaustif en ajoutant un cas qui couvre "tout le reste, quel qu'il soit"). Vous avez toujours le choix, mais vous devez le faire explicitement.

Donc, il semble que dans les deux cas, nous aurons tous les deux le choix d'activer ou de désactiver la vérification exhaustive :)

cela ne vous dit pas que vous avez raté un cas connu, juste que vous n'avez pas géré le cas inconnu.

Effectivement, je crois, le compilateur fait déjà une analyse de l'ensemble du programme pour déterminer quels types concrets sont utilisés dans quelles interfaces je pense ? Je m'attendrais au moins à ce qu'il génère, au moins pour les assertions de type non-interface (c'est-à-dire les assertions de type qui ne s'appliquent pas à un type d'interface, mais à un type concret), les tables de fonctions utilisées dans les interfaces au moment de la compilation.
Mais, honnêtement, cela est soutenu à partir des premiers principes, je n'ai aucune idée de la mise en œuvre réelle.

Dans tous les cas, il devrait être assez facile, a) de répertorier tout type concret défini dans un programme entier et b) pour tout type de changement, de les filtrer pour déterminer s'ils implémentent cette interface. Si vous utilisez quelque chose comme ça , vous vous retrouverez avec une liste fiable. Je pense.

Je ne suis pas convaincu à 100% qu'un outil peut être écrit aussi fiable que d'énoncer explicitement les options, mais je suis convaincu que vous pourriez couvrir 90% des cas et vous pourriez certainement écrire un outil qui le fait en dehors de le compilateur, étant donné les annotations correctes (c'est-à-dire faire de sum-types un commentaire de type pragma, pas un type réel). Pas une bonne solution, c'est vrai.

Quoi qu'il en soit, cela semble bruyant. Envisager:

Je pense que c'est injuste. Les cas que vous mentionnez n'ont rien à voir avec les types de somme. Si je devais écrire un tel outil, je le limiterais aux commutateurs de type et aux commutateurs avec une expression, car ceux-ci semblent être la façon dont les types de somme seraient également gérés.

Implémentations de jetons alternatives :

Pourquoi pas une méthode marqueur ? Vous n'avez pas besoin d'un champ de type, vous l'obtenez gratuitement à partir de la représentation de l'interface. Si vous craignez de répéter indéfiniment la méthode du marqueur ; définissez une structure non exportée{}, donnez-lui cette méthode de marqueur et intégrez-la dans chaque implémentation, pour un coût supplémentaire nul et moins de frappe par option que votre méthode.

Quoi qu'il en soit, cela signifie que chaque jeton nécessite une déférence de pointeur pour accéder à sa valeur

Oui. C'est un coût réel, mais je ne pense pas que cela l'emporte sur tout autre argument.

Je pense que c'est injuste.

C'est vrai.

J'ai écrit une version rapide et sale et je l'ai exécutée sur stdlib. La vérification de toute instruction switch avait 1956 hits, la limitant à ignorer le formulaire switch { réduit ce nombre à 1677. Je n'ai inspecté aucun de ces emplacements pour voir si le résultat est significatif.

https://github.com/jimmyfrasche/switchlint

Il y a certainement beaucoup de place pour l'amélioration. Ce n'est pas très sophistiqué. Les demandes de tirage sont les bienvenues.

(je répondrai au reste plus tard)

edit: mauvais format de balisage

Je pense que c'est un résumé (assez biaisé) de tout jusqu'à présent (et en supposant narcissiquement ma deuxième proposition)

Avantages

  • concis, facile à écrire un certain nombre de contraintes de manière succincte de manière auto-documentée
  • un meilleur contrôle des allocations
  • plus facile à optimiser (toutes les possibilités connues du compilateur)
  • vérification exhaustive (le cas échéant, possibilité de se retirer)

Les inconvénients

  • toute modification apportée aux membres d'un type de somme est une modification radicale, interdisant la réparation graduelle du code à moins que tous les packages externes se désengagent des contrôles d'exhaustivité
  • une chose de plus dans la langue à apprendre, un chevauchement conceptuel avec des fonctionnalités existantes
  • le ramasse-miettes doit savoir quels membres sont des pointeurs
  • gênant pour les sommes de la forme 1 + 1 + ⋯ + 1

Alternatives

  • iota "enum" pour les sommes de la forme 1 + 1 + ⋯ + 1
  • interfaces avec une méthode de balise non exportée pour des sommes plus compliquées (éventuellement générées)
  • ou struct avec une énumération iota et des règles extralinguistiques sur les champs définis en fonction de la valeur enums

Indépendamment

  • meilleur outillage, toujours meilleur outillage

Pour une réparation progressive, et c'est un gros problème, je pense que la seule option est que les packages externes se désengagent des contrôles d'exhaustivité. Cela implique qu'il doit être légal d'avoir un cas par défaut "inutile" uniquement concerné par la vérification future, même si vous correspondez par ailleurs à tout le reste. Je crois que c'est implicitement vrai maintenant, et si ce n'est pas assez facile à spécifier.

Il pourrait y avoir une annonce d'un responsable de paquet disant "hé, nous allons ajouter un nouveau membre à ce type de somme dans la prochaine version, assurez-vous que vous pouvez le gérer", puis un outil switchlint pourrait trouver tous les cas qui doivent être être exclu.

Pas aussi simple que d'autres cas, mais toujours tout à fait faisable.

Lors de l'écriture d'un programme qui utilise un type de somme défini en externe, vous pouvez commenter la valeur par défaut pour vous assurer de ne manquer aucun cas connu, puis la décommenter avant de valider. Ou il pourrait y avoir un outil pour vous faire savoir que la valeur par défaut est "inutile" qui vous indique que vous avez tout connu et que vous êtes à l'épreuve de l'inconnu.

Disons que nous voulons opter pour la vérification d'exhaustivité avec un linter lors de l'utilisation de types d'interface simulant des types de somme, quel que soit le package dans lequel ils sont définis.

@Merovius votre betterSumType() BetterSumType est très cool, mais cela signifie que les changements doivent se produire dans le package de définition (ou vous exposez quelque chose comme

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

et aussi des peluches qu'on appelle à chaque fois).

Quels sont les critères nécessaires pour vérifier que tous les commutateurs d'un programme sont exhaustifs ?

Cela ne peut pas être l'interface vide, car alors tout est jeu. Il faut donc au moins une méthode.

Si l'interface n'a pas de méthodes non exportées, n'importe quel type pourrait l'implémenter, donc l'exhaustivité dépendrait de tous les packages du graphe d'appel de chaque commutateur. Il est possible d'importer un package, d'implémenter son interface, puis d'envoyer cette valeur à l'une des fonctions du package ; donc un changement dans cette fonction ne pourrait pas être exhaustif sans créer un cycle d'import. Il faut donc au moins une méthode non exportée. (Ceci englobe le critère précédent).

L'intégration gâcherait la propriété que nous recherchons, nous devons donc nous assurer qu'aucun des importateurs du package n'intègre jamais l'interface ou l'un des types qui l'implémentent à aucun moment. Un linter vraiment sophistiqué peut être en mesure de dire que parfois l'intégration est correcte si nous n'appelons jamais une certaine fonction qui crée une valeur intégrée ou qu'aucune des interfaces intégrées n'« échappe » jamais à la limite de l'API du package.

Pour être complet, nous devons soit vérifier que la valeur zéro de l'interface n'est jamais transmise, soit imposer qu'un commutateur exhaustif vérifie également case nil . (Ce dernier est plus facile mais le premier est préféré car l'inclusion de zéro transforme une somme de « type A ou de type B ou de type C » en une somme « nul ou de type A ou de type B ou de type C »).

Disons que nous avons un linter, avec toutes ces capacités, même facultatives, qui peuvent vérifier cette sémantique pour n'importe quel arbre d'importations et n'importe quelle interface donnée au sein de cet arbre.

Disons maintenant que nous avons un projet avec une dépendance D. Nous voulons nous assurer qu'une interface définie dans l'un des packages de D est exhaustive dans notre projet. Disons que oui.

Maintenant, nous devons ajouter une nouvelle dépendance à notre projet D′. Si D′ importe le package dans D qui a défini le type d'interface en question mais n'utilise pas ce linter, il peut facilement détruire les invariants qui doivent être conservés pour que nous puissions utiliser des commutateurs exhaustifs.

D'ailleurs, disons que D vient de passer le linter par hasard, non pas parce que le mainteneur l'exécute. Une mise à niveau vers D pourrait tout aussi bien détruire les invariants que D′.

Même si le linter peut dire "en ce moment c'est 100% exhaustif 👍" ça peut changer sans que nous ne fassions rien.

Un vérificateur d'exhaustivité des "iota enums" semble plus simple.

Pour tous les type t uu est entier et t est utilisé comme un const avec des valeurs spécifiées individuellement ou iota tel que le zéro la valeur de u est incluse parmi ces constantes.

Remarques:

  • Les valeurs en double peuvent être traitées comme des alias et ignorées dans cette analyse. Nous supposerons que toutes les constantes nommées ont des valeurs distinctes.
  • 1 << iota peut être traité comme un ensemble de puissance, je crois au moins la plupart du temps, mais nécessiterait probablement des conditions supplémentaires, en particulier autour du complément bit à bit. Pour le moment, ils ne seront pas pris en compte

Pour un raccourci, appelons min(t) la constante telle que pour toute autre constante, C , min(t) <= C , et, de même, appelons max(t) la constante telle que pour toute autre constante, C , C <= max(t) .

Pour garantir que t est utilisé de manière exhaustive, nous devons nous assurer que

  • les valeurs de t sont toujours les constantes nommées (ou 0 dans certaines positions idiomatiques, comme l'invocation de fonction)
  • Il n'y a pas de comparaisons d'inégalité d'une valeur de t , v , en dehors de min(t) <= v <= max(t)
  • les valeurs de t ne sont jamais utilisées dans les opérations arithmétiques + , / , etc. Une exception possible pourrait être lorsque le résultat est limité entre min(t) et max(t) immédiatement après, mais cela pourrait être difficile à détecter en général, cela peut donc nécessiter une annotation dans les commentaires et devrait probablement être limité au package qui définit t .
  • Les commutateurs contiennent toutes les constantes de t ou un cas par défaut.

Cela nécessite toujours de vérifier tous les packages dans l'arbre d'importation et peut être invalidé aussi facilement, bien qu'il soit moins susceptible d'être invalidé dans le code idiomatique.

D'après ce que j'ai compris, cela, similaire aux alias de type, ne provoquera pas de changements de rupture, alors pourquoi le suspendre pour Go 2 ?

Les alias de type n'introduisent pas de nouveau mot-clé, ce qui est un changement décisif. Il semble également y avoir un moratoire sur les changements de langue, même mineurs, et ce serait un changement majeur . Même la simple mise à niveau de toutes les routines de marshal/unmarshal pour gérer les valeurs de somme réfléchies serait une énorme épreuve.

Les alias de type résolvent un problème pour lequel il n'y avait pas de solution de contournement. Les types de somme offrent un avantage en matière de sécurité de type, mais ce n'est pas un obstacle de ne pas les avoir.

Juste un (mineur) point en faveur de quelque chose comme la proposition originale de @rogpeppe . Dans le package http , il y a le type d'interface Handler et un type de fonction qui l'implémente, HandlerFunc . Pour le moment, pour passer une fonction à http.Handle , vous devez explicitement la convertir en HandlerFunc . Si http.Handle acceptait plutôt un argument de type HandlerFunc | Handler , il pourrait accepter n'importe quelle fonction/fermeture directement attribuable à HandlerFunc . L'union sert effectivement d'indication de type indiquant au compilateur comment les valeurs avec des types sans nom peuvent être converties en type d'interface. Puisque HandlerFunc implémente Handler , le type union se comporterait exactement comme Handler sinon.

@griesemer en réponse à votre commentaire dans le fil enum, https://github.com/golang/go/issues/19814#issuecomment -322752526, je pense que ma proposition plus tôt dans ce fil https://github.com/golang/ go/issues/19412#issuecomment -289588569 répond à la question de savoir comment les types de somme ("swift style enums") devraient fonctionner dans Go. Autant que je les aimerais, je ne sais pas s'ils seraient un ajout nécessaire à Go, mais je pense que s'ils étaient ajoutés, ils devraient ressembler / fonctionner beaucoup comme ça.

Ce message n'est pas complet et il y a des éclaircissements tout au long de ce fil, avant et après, mais cela ne me dérange pas de réitérer ces points ou de résumer car ce fil est assez long.

Si vous avez un type somme simulé par une interface avec une balise de type et que vous ne pouvez absolument pas le contourner en l'intégrant, c'est la meilleure défense que j'ai trouvée : https://play.golang.org/p/FqdKfFojp-

@jimmyfrasche J'ai écrit ceci il y a quelque temps.

Une autre approche possible est la suivante : https://play.golang.org/p/p2tFm984S8

@rogpeppe si vous allez utiliser la réflexion pourquoi ne pas simplement utiliser la réflexion ?

J'ai rédigé une version révisée de ma deuxième proposition sur la base des commentaires ici et dans d'autres numéros.

Notamment, j'ai supprimé la vérification d'exhaustivité. Cependant, un vérificateur d'exhaustivité externe est trivial à écrire pour la proposition ci-dessous, bien que je ne pense pas qu'il soit possible d'en écrire pour d'autres types Go utilisés pour simuler un type somme.

Edit : j'ai supprimé la possibilité de taper assert sur la valeur dynamique d'une valeur de sélection. C'est trop magique et la raison de l'autoriser est tout aussi bien servie par la génération de code.

Edit2 : a clarifié le fonctionnement des noms de champ avec les assertions et les commutateurs lorsque le choix est défini dans un autre package.

Edit3 : intégration restreinte et noms de champs implicites clarifiés

Edit4 : clarifier la valeur par défaut dans le commutateur

Choisissez des types

Un pick est un type composite syntaxiquement similaire à une structure :

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

Dans ce qui précède, A , B , C , D et E sont les noms de champ du choix et S , T et U sont les types respectifs de ces champs. Les noms de champs peuvent être exportés ou non.

Un choix ne peut pas être récursif sans indirection.

Légal

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

Illégal

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

Il n'y a pas d'incorporation pour les choix, mais un choix peut être incorporé dans une structure. Si un choix est incorporé dans une structure, la méthode sur le choix est promue à la structure mais les champs d'un choix ne le sont pas.

Un type sans nom de champ est un raccourci pour définir un champ avec le même nom que le type. (Il s'agit d'une erreur si le type n'est pas nommé, avec une exception pour *T où le nom est T ).

Par exemple,

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

a trois champs Reader , Writer et string , avec les types respectifs. Notez que le champ string n'est pas exporté même s'il se trouve dans la portée de l'univers.

Une valeur d'un type de sélection se compose d'un champ dynamique et de la valeur de ce champ.

La valeur zéro d'un type de prélèvement est son premier champ dans l'ordre source et la valeur zéro de ce champ.

Étant donné deux valeurs du même type de sélection, a et b , la valeur de sélection peut être attribuée comme n'importe quelle autre valeur

a = b

L'attribution d'une valeur sans sélection, même celle d'un type de l'un des champs d'une sélection, est illégale.

Un type de sélection n'a qu'un seul champ dynamique à un moment donné.

La syntaxe littérale composite est similaire aux structs, mais il existe des restrictions supplémentaires. À savoir, les littéraux sans clé sont toujours invalides et une seule clé peut être spécifiée.

Les éléments suivants sont valables

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, "")

Les erreurs de temps de compilation sont les suivantes :

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

Étant donné une valeur p de type pick {A int; B string} l'affectation suivante

p.B = "hi"

définit le champ dynamique de p à B et la valeur de B à "hi".

L'affectation au champ dynamique actuel met à jour la valeur de ce champ. L'affectation qui définit un nouveau champ dynamique doit mettre à zéro tout emplacement mémoire non spécifié. L'affectation à un champ de sélection ou de structure d'un champ de sélection met à jour ou définit le champ dynamique si nécessaire.

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

La valeur contenue dans une sélection n'est accessible que par une assertion de champ ou un commutateur de champ.

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
}

Les noms de champ dans les assertions de champ et les commutateurs de champ sont une propriété du type, et non du package dans lequel il a été défini. Ils ne sont pas et ne peuvent pas être qualifiés par le nom de package qui définit le pick .

Ceci est valable :

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

Ceci n'est pas valide :

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

Les assertions de champ et les commutateurs de champ renvoient toujours une copie de la valeur du champ dynamique.

Les noms de champs non exportés ne peuvent être affirmés que dans leur package de définition.

Les assertions de type et les commutateurs de type fonctionnent également sur les choix.

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

Les assertions de type et les commutateurs de type renvoient toujours une copie de la valeur du champ dynamique.

Si le choix est stocké dans une interface, les assertions de type pour les interfaces ne correspondent qu'à l'ensemble de méthodes du choix lui-même. [toujours vrai mais redondant car ce qui précède a été supprimé]

Si tous les types de sélection prennent en charge les opérateurs d'égalité, alors :

  • les valeurs de ce choix peuvent être utilisées comme clés de carte
  • deux valeurs du même choix sont == si elles ont le même champ dynamique et ses valeurs sont ==
  • deux valeurs avec des champs dynamiques différents sont != même si les valeurs sont == .

Aucun autre opérateur n'est pris en charge sur les valeurs d'un type de sélection.

Une valeur d'un type de sélection P peut être convertie en un autre type de sélection Q si l'ensemble des noms de champ et leurs types dans P est un sous-ensemble des noms de champ et leur saisit Q .

Si P et Q sont définis dans des packages différents et ont des champs non exportés, ces champs sont considérés comme différents quels que soient le nom et le type.

Exemple:

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

L'assignabilité entre deux types de prélèvement est définie comme la convertibilité, tant que pas plus d'un des types est défini.

Les méthodes peuvent être déclarées sur un type de sélection défini.

J'ai créé (et ajouté au wiki) un rapport d'expérience https://gist.github.com/jimmyfrasche/ba2b709cdc390585ba8c43c989797325

Edit: et :heart: à @mewmew qui a laissé un rapport bien meilleur et plus détaillé en réponse à cet essentiel

Et si on avait un moyen de dire, pour un type T , la liste des types qui pourraient être convertis en type T ou affectés à une variable de type T ? Par exemple

type T interface{} restrict { string, error }

définit un type d'interface vide nommé T tel que les seuls types qui peuvent lui être assignés sont string ou error . Toute tentative d'affectation d'une valeur de tout autre type produit une erreur de compilation. Maintenant je peux dire

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

Quels éléments clés des types de somme (ou types de sélection) ne seraient pas satisfaits par ce type d'approche ?

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

Ce n'est pas strictement vrai, puisque v pourrait aussi être nil . Il faudrait un changement assez important dans le langage pour supprimer cette possibilité, car cela signifierait introduire des types qui n'ont pas de valeurs nulles et tout ce que cela implique. La valeur zéro simplifie certaines parties du langage, mais rend également la conception de ce type de fonctionnalités plus difficile.

Fait intéressant, cette approche est assez similaire à la proposition originale de @rogpeppe . Ce qu'il n'a pas, c'est la coercition sur les types répertoriés, ce qui pourrait être utile dans des situations comme je l'ai souligné plus tôt ( http.Handler ). Une autre chose est que chaque variante doit être un type distinct, car les variantes sont discriminées par type plutôt que par balise distincte. Je pense que c'est strictement aussi expressif, mais certaines personnes préfèrent que les balises et les types de variantes soient distincts.

@ianlancetaylor

les avantages

  • possible de restreindre à un ensemble fermé de types - et c'est certainement la chose principale
  • possible d'écrire un vérificateur d'exhaustivité précis
  • vous obtenez la propriété « vous pouvez attribuer une valeur qui satisfait le contrat à cette ». (Je m'en fous, mais j'imagine que d'autres le font).

les inconvénients

  • ce ne sont que des interfaces avec des avantages et pas vraiment un type différent (de beaux avantages cependant !)
  • vous avez toujours nil donc ce n'est pas vraiment un type somme au sens de la théorie des types. Quel que soit le A + B + C vous spécifiez, c'est vraiment un 1 + A + B + C lequel vous n'avez pas le choix. Comme @stevenblenkinsop l'a souligné pendant que je travaillais là-dessus.
  • plus important encore, à cause de ce pointeur implicite, vous avez toujours une indirection. Avec la proposition de sélection, vous pouvez choisir d'avoir un p ou un *p vous donnant plus de contrôle sur les compromis de mémoire. Vous ne pouviez pas les implémenter en tant qu'unions discriminées (au sens C) en tant qu'optimisation.
  • pas de choix de valeur zéro, ce qui est une propriété vraiment sympa d'autant plus qu'il est très important en Go d'avoir une valeur zéro aussi utile que possible
  • Vraisemblablement, vous ne pouviez pas définir de méthodes sur T (mais vous auriez probablement les méthodes de l'interface modifiées par la restriction mais les types de la restriction devraient le satisfaire? Sinon, je ne vois pas l'intérêt de pas seulement avoir type T restrict {string, error} )
  • si vous perdez les étiquettes des champs/sommes/qu'avez-vous, cela devient confus lorsqu'il interagit avec les types d'interface. Vous perdez la propriété forte "exactement ceci ou exactement cela" des types de somme. Vous pouvez mettre un io.Reader et en retirer un io.Writer . Cela a du sens pour les interfaces (non restreintes) mais pas pour les types de somme.
  • Si vous voulez que deux types identiques signifient des choses différentes, vous devez utiliser des types wrapper pour lever l'ambiguïté ; une telle balise devrait être dans un espace de noms externe plutôt que confinée à un type comme l'est un champ de structure
  • cela peut être trop important dans votre formulation spécifique, mais il semble que cela change les règles d'assignabilité en fonction du type de cessionnaire (je le lis comme disant que vous ne pouvez pas assigner quelque chose d'assignable à error à T doit être exactement une erreur).

Cela dit, il coche les principales cases (les deux premiers avantages que j'ai énumérés) et je le prendrais sans hésiter si c'est tout ce que je pouvais obtenir. J'espère quand même mieux.

J'ai supposé que les règles d'assertion de type s'appliquaient. Le type doit donc être soit identique à un type concret, soit assignable à un type d'interface. Fondamentalement, cela fonctionne exactement comme une interface, mais toute valeur (autre que nil ) doit être assertable pour au moins l'un des types répertoriés.

@jimmyfrasche
Dans votre proposition mise à jour, l'affectation suivante serait-elle possible, si tous les éléments du type sont de types distincts :

type p pick {
    A int
    B string
}

func Foo(P p) {
}

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

Foo(42)
Foo("foo")

La facilité d'utilisation des types de somme lorsque de telles affectations sont possibles est beaucoup plus grande.

Avec la proposition de sélection, vous pouvez choisir d'avoir un p ou un *p vous donnant plus de contrôle sur les compromis de mémoire.

La raison pour laquelle les interfaces allouent au stockage des valeurs scalaires est que vous n'avez pas à lire un mot type pour décider si l'autre mot est un pointeur ; voir #8405 pour discussion. Les mêmes considérations d'implémentation s'appliqueraient probablement à un type de sélection, ce qui pourrait signifier en pratique que p finissent par être alloués et non locaux de toute façon.

@urandom non, vu vos définitions il faudrait l'écrire

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

Il est préférable de les considérer comme une structure qui ne peut avoir qu'un seul champ défini à la fois.

Si vous n'avez pas cela et que vous ajoutez un C uint à p qu'arrive-t-il à p = 42 ?

Vous pouvez créer de nombreuses règles basées sur l'ordre et l'assignabilité, mais elles signifient toujours que les modifications apportées à la définition du type peuvent avoir des effets subtils et spectaculaires sur tout le code utilisant le type.

Dans le meilleur des cas, un changement casse tout le code en se fondant sur l'absence d'ambiguïté et indique que vous devez le changer en p = int(42) ou p = uint(42) avant qu'il ne soit à nouveau compilé. Un changement d'une ligne ne devrait pas nécessiter la correction d'une centaine de lignes. Surtout si ces lignes sont dans des packages de personnes en fonction de votre code.

Vous devez soit être 100% explicite, soit avoir un type très fragile que personne ne peut toucher car cela pourrait tout casser.

Cela s'applique à n'importe quelle proposition de type somme, mais s'il existe des étiquettes explicites, vous avez toujours l'assignabilité car l'étiquette est explicite sur le type auquel est attribuée.

@josharian donc si je lis cela correctement, la raison pour laquelle iface est maintenant toujours (*type, *value) au lieu de ranger des valeurs de la taille d'un mot dans le deuxième champ comme Go l'a fait auparavant, c'est pour que le GC simultané n'ait pas besoin d'inspecter les deux champs pour voir si le second est un pointeur - il peut simplement supposer qu'il l'est toujours. Ai-je bien compris ?

En d'autres termes, si le type de sélection était implémenté (en utilisant la notation C) comme

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

le GC aurait besoin de prendre un verrou (ou quelque chose de fantaisie mais équivalent) pour inspecter which afin de déterminer si summands devait être scanné ?

la raison pour laquelle iface est maintenant toujours (*type, *valeur) au lieu de stocker des valeurs de la taille d'un mot dans le deuxième champ comme Go le faisait auparavant est que le GC concurrent n'a pas besoin d'inspecter les deux champs pour voir si le second est un pointeur - il peut simplement supposer qu'il l'est toujours.

C'est exact.

Bien sûr, la nature limitée des types de sélection permettrait des implémentations alternatives. Le type de sélection pourrait être défini de telle sorte qu'il y ait toujours un modèle cohérent de pointeur/non-pointeur ; par exemple, tous les types scalaires peuvent se chevaucher, et un champ de chaîne pourrait se chevaucher avec le début d'un champ de tranche (car les deux commencent par "pointeur, non pointeur"). Donc

pick {
  a uintptr
  b string
  c []byte
}

pourrait être présenté grossièrement :

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

mais d'autres types de pics pourraient ne pas permettre un tel emballage optimal. (Désolé pour l'ASCII cassé, je n'arrive pas à faire en sorte que GitHub le rende correctement. Vous avez compris, j'espère.)

Cette capacité à faire une mise en page statique pourrait même être un argument de performance en faveur de l'inclusion de types de sélection ; mon objectif ici est simplement de signaler les détails de mise en œuvre pertinents pour vous.

@josharian et merci de le faire. Je n'y avais pas pensé (honnêtement, j'ai juste cherché sur Google s'il existait des recherches sur la façon de GC discriminé les syndicats, j'ai vu que oui, vous pouvez le faire et l'ai appelé un jour - pour une raison quelconque, mon cerveau n'a pas associé "concurrence" avec "Go" ce jour-là : facepalm !).

Il y aurait moins de choix si l'un des types était une structure définie qui avait déjà une disposition.

Une option serait de ne pas "compacter" les summands si elles contiennent des pointeurs, ce qui signifie que la taille serait la même que la structure équivalente (+ 1 pour le discriminateur int). Peut-être adopter une approche hybride, lorsque cela est possible, afin que tous les types qui peuvent partager la mise en page le fassent.

Ce serait dommage de perdre les propriétés de belle taille mais ce n'est vraiment qu'une optimisation.

Même si c'était toujours 1 + la taille d'une structure équivalente même lorsqu'elle ne contenait aucun pointeur, elle aurait toujours toutes les autres propriétés intéressantes du type lui-même, y compris le contrôle des allocations. Des optimisations supplémentaires pourraient être ajoutées au fil du temps et seraient au moins possibles comme vous le soulignez.

type p pick {
    A int
    B string
}

A et B doivent-ils être là ? Un choix fait partie d'un ensemble de types, alors pourquoi ne pas supprimer complètement leurs noms d'identifiant :

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

Je crois que ce formulaire est déjà valide pour struct. Il peut y avoir une contrainte selon laquelle il est requis pour la sélection.

@comme si le nom de champ est omis, il est identique au type, donc votre exemple fonctionne, mais comme ces noms de champ ne sont pas exportés, ils ne peuvent être définis/accessibles qu'à partir du package de définition.

Les noms de champs doivent être présents, même s'ils sont générés implicitement sur la base du nom de type, ou il y a de mauvaises interactions avec les types d'assignabilité et d'interface. Les noms de champs sont ce qui le fait fonctionner avec le reste de Go.

@comme excuses, je viens de réaliser que vous vouliez dire quelque chose de différent de ce que j'ai lu.

Votre formulation fonctionne mais vous avez alors des choses qui ressemblent à des champs de structure mais se comportent différemment en raison de la chose habituelle exportée/non exportée.

La chaîne est-elle accessible depuis l'extérieur du package définissant p car elle se trouve dans l'univers ?

Qu'en est-il de

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

?

En séparant le nom du champ du nom du type, vous pouvez faire des choses comme

pick {
  unexported Exported
  Exported unexported
}

ou même

pick { Recoverable, Fatal error }

Si les champs de sélection se comportent comme des champs de structure, vous pouvez utiliser une grande partie de ce que vous savez déjà sur les champs de structure pour réfléchir aux champs de sélection. La seule vraie différence est qu'un seul champ peut être défini à la fois.

@jimmyfrasche
Go prend déjà en charge l'intégration de types anonymes dans des structures, donc la restriction de portée existe déjà dans le langage, et je pense que ce problème est résolu par les alias de type. Mais avouez que je n'ai pas pensé à tous les cas d'utilisation possibles. Cela semble dépendre de savoir si cet idiome est courant en Go :

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

Le petit _t_ existe dans un package où il est intégré dans le grand T , et sa seule exposition se fait par le biais de ces types exportés.

@comme

Je ne suis pas sûr de suivre tout à fait, cependant:

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

De plus, si vous n'aviez que le nom de type pour l'étiquette, pour inclure un []string vous auriez besoin de faire un type Strings = []string .

C'est vraiment la façon dont je veux voir les types de sélection implémentés. Dans
en particulier, c'est ainsi que Rust et C++ (les normes d'or pour la performance) font
ce.

Si je voulais juste vérifier l'exhaustivité, je pourrais utiliser un vérificateur. je veux
la performance gagne. Cela signifie que les types de sélection ne peuvent pas non plus être nuls.

Prendre l'adresse d'un membre d'un élément pick ne devrait pas être autorisé (il
n'est pas sûr en mémoire, même dans le cas d'un seul thread, comme il est bien connu dans
la communauté de Rust.). Si cela nécessite d'autres restrictions sur un type de prélèvement,
alors qu'il en soit ainsi. Mais pour moi, les types de sélection allouent toujours sur le tas
serait mauvais.

Le 18 août 2017 à 12h01, "jimmyfrasche" [email protected] a écrit :

@josharian https://github.com/josharian donc si je lis correctement
la raison pour laquelle iface est maintenant toujours (*type, *valeur) au lieu de se cacher
les valeurs de la taille d'un mot dans le deuxième champ, comme Go l'a fait précédemment, est telle que le
le GC simultané n'a pas besoin d'inspecter les deux champs pour voir si le deuxième
est un pointeur - il peut simplement supposer qu'il l'est toujours. Ai-je bien compris ?

En d'autres termes, si le type de sélection était implémenté (en utilisant la notation C) comme

structure {
int qui ;
syndicat {
Un un ;
Bb;
Cc ;
} sommations ;
}

le GC aurait besoin de prendre un verrou (ou quelque chose de fantaisie mais équivalent) pour
inspecter lequel pour déterminer si les sommations devaient être analysées ?

-
Vous recevez ceci parce que vous avez créé le fil.
Répondez directement à cet e-mail, consultez-le sur GitHub
https://github.com/golang/go/issues/19412#issuecomment-323393003 , ou couper le son
le fil
https://github.com/notifications/unsubscribe-auth/AGGWB3Ayi31dYwotewcfgmCQL-XVrfxIks5sZbVrgaJpZM4MTmSr
.

@DemiMarie

Prendre l'adresse d'un membre d'un élément pick ne devrait pas être autorisé (ce n'est pas sûr en mémoire, même dans le cas d'un seul thread, comme cela est bien connu dans la communauté Rust.). Si cela nécessite d'autres restrictions sur un type de prélèvement, qu'il en soit ainsi.

C'est un bon point. J'avais ça là-dedans mais ça a dû se perdre dans un montage. J'ai inclus que lorsque vous accédez à la valeur à partir d'un choix, il renvoie toujours une copie pour la même raison.

Comme exemple de pourquoi c'est vrai, pour la postérité, considérez

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 optimisé pour que les champs A et B prennent la même position en mémoire alors p ne pointe pas vers un int : il pointe à un bool. La sécurité de la mémoire a été violée.

@jimmyfrasche

La deuxième raison pour laquelle vous ne voudriez pas que le contenu soit adressable est la sémantique des mutations. Si la valeur est stockée indirectement dans certaines circonstances, alors

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 endroit où pick est similaire aux interfaces est que vous souhaitez conserver la sémantique des valeurs si vous y stockez des valeurs. Si vous pourriez avoir besoin d'une indirection comme détail d'implémentation, la seule option est de rendre le contenu non adressable (ou plus précisément, adressable de manière mutable , mais la distinction n'existe pas dans Go à l'heure actuelle), afin que vous ne puissiez pas observer l'aliasing .

Edit : Oups (voir ci-dessous)

@jimmyfrasche

La valeur zéro d'un type de prélèvement est son premier champ dans l'ordre source et la valeur zéro de ce champ.

Notez que cela ne fonctionnera pas si le premier champ doit être stocké indirectement, à moins que vous ne spécifiiez la valeur zéro pour que v.[A] et v.(error) fassent ce qu'il faut.

@stevenblenkinsop Je ne sais pas ce que vous entendez par "le premier champ doit être stocké indirectement". Je suppose que vous voulez dire si le premier champ est un pointeur ou un type qui contient implicitement un pointeur. Si oui, voici un exemple ci-dessous. Si non, pourriez-vous s'il vous plaît préciser?

Étant donné

var p pick { A error; B int }

la valeur zéro, p , a un champ dynamique A et la valeur de A est nulle.

Je ne faisais pas référence à la valeur stockée dans le pick étant/contenant un pointeur, je faisais référence à une valeur non-pointeur stockée indirectement en raison des contraintes de mise en page imposées par le ramasse-miettes, comme décrit par @josharian .

Dans votre exemple, p.B — n'étant pas un pointeur — ne serait pas en mesure de partager le stockage qui se chevauche avec p.A , qui comprend deux pointeurs. Il devrait très probablement être stocké indirectement (c'est-à-dire représenté comme un *int qui est automatiquement déréférencé lorsque vous y accédez, plutôt que comme un int ). Si p.B était le premier champ, la valeur zéro du pick serait new(int) , ce qui n'est pas une valeur zéro acceptable car elle nécessite une initialisation. Vous auriez besoin d'un cas particulier pour qu'un *int nil soit traité comme un new(int) .

@jimmyfrasche
Oh pardon. En revenant sur la conversation, j'ai réalisé que vous envisagiez d'utiliser un stockage

Edit : oups, condition de course. Posté puis vu votre commentaire.

@stevenblenkinsop ah, d'accord je vois ce que tu veux dire. Mais ce n'est pas un problème.

Le partage de stockage qui se chevauchent est une optimisation. Il ne pourrait jamais faire cela : la sémantique du type est le bit important.

Si le compilateur peut optimiser le stockage et choisit de le faire, c'est un bon bonus.

Dans votre exemple, le compilateur pourrait le stocker exactement comme il le ferait pour la structure équivalente (en ajoutant une balise pour savoir quel est le champ actif). Ce serait

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

La valeur zéro est toujours tous les octets 0 et il n'est pas nécessaire d'allouer subrepticement comme un cas particulier.

L'important est de s'assurer qu'un seul champ est en jeu à un moment donné.

La motivation pour autoriser les assertions/commutations de type sur les choix était que, par exemple, si chaque type dans le choix satisfaisait fmt.Stringer vous pouviez écrire une méthode sur le choix comme

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

Mais comme les types de champs de sélection peuvent être des interfaces, cela crée une subtilité.

Si le choix P dans l'exemple précédent avait un champ dont le type est lui-même fmt.Stringer cette méthode String paniquerait si c'était le champ dynamique et sa valeur est nil . Vous ne pouvez pas taper assert une interface nil vers quoi que ce soit, même pas lui-même. https://play.golang.org/p/HMYglwyVbl Bien que cela ait toujours été vrai, cela ne revient tout simplement pas régulièrement, mais cela pourrait apparaître plus régulièrement avec des choix.

Cependant, la nature fermée des types sum permettrait à un linter exhaustif de trouver partout où cela se présenterait (potentiellement avec quelques faux positifs) et de signaler le cas à traiter.

Il serait également surprenant, si vous pouvez implémenter des méthodes au choix, que ces méthodes ne soient pas utilisées pour satisfaire une assertion de type.

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

Vous pouvez demander à l'assertion de type de promouvoir des méthodes à partir du champ actuel si elles satisfont à l'interface, mais cela se heurte à ses propres problèmes, par exemple s'il faut promouvoir des méthodes à partir d'une valeur dans un champ d'interface qui ne sont pas définies sur l'interface elle-même (ou même comment mettre cela en œuvre efficacement). En outre, on pourrait alors s'attendre à ce que les méthodes communes à tous les champs soient promues au choix lui-même, mais elles devraient alors être envoyées via la sélection de variantes à chaque appel, en plus potentiellement d'un envoi virtuel si le choix est stocké dans une interface , et/ou à un dispatch virtuel si le champ est une interface.

Edit: Soit dit en passant, l'emballage optimal d'un choix est un exemple du problème de superchaîne commun le

La règle est que s'il s'agit d'une valeur de sélection, l'assertion de type affirme sur le champ dynamique de la valeur de sélection, mais si la valeur de sélection est stockée dans une interface, l'assertion de type est sur l'ensemble de méthodes du type de sélection. C'est peut-être surprenant au début mais c'est assez cohérent.

Ce ne serait pas un problème de simplement supprimer les assertions de type autorisées sur une valeur de sélection. Ce serait dommage car cela rend très facile la promotion de méthodes que tous les types du choix partagent sans avoir à écrire tous les cas ou à utiliser la réflexion.

Cependant, il serait assez facile d'utiliser la génération de code pour écrire le

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

Je suis allé de l'avant et j'ai abandonné les assertions de type. Ils devraient peut-être être ajoutés, mais ils ne font pas nécessairement partie de la proposition.

Je veux revenir au commentaire précédent de @ianlancetaylor , car j'ai une nouvelle perspective à ce sujet après avoir réfléchi un peu plus à la gestion des erreurs (en particulier, https://github.com/golang/go/issues/21161# questioncomment-320294933).

En particulier, que nous apporte le nouveau type de type que nous n'obtenons pas des types d'interface ?

À mon avis, le principal avantage des types sum est qu'ils nous permettraient de faire la distinction entre le retour de plusieurs valeurs et le retour d'une valeur parmi plusieurs, en particulier lorsque l'une de ces valeurs est une instance de l'interface d'erreur.

Nous avons actuellement beaucoup de fonctions de la forme

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

Certains d'entre eux, tels que io.Reader.Read et io.Reader.Write , Renvoyez un T avec un error , tandis que d' autres reviennent soit un T ou un error mais jamais les deux. Pour l'ancien style d'API, ignorer le T en cas d'erreur est souvent un bogue (par exemple, si l'erreur est io.EOF ); pour ce dernier style, retourner un T différent

Les outils automatisés, y compris lint , peuvent vérifier l'utilisation de fonctions spécifiques pour s'assurer que la valeur est (ou n'est pas) ignorée correctement lorsque l'erreur est non nulle, mais ces vérifications ne s'étendent pas naturellement aux fonctions arbitraires.

Par exemple, proto.Marshal est censé être le style "valeur et erreur" si l'erreur est un RequiredNotSetError , mais semble être le style "valeur ou erreur" dans le cas contraire. Parce que le système de types ne fait pas la distinction entre les deux, il est facile d'introduire accidentellement des régressions : soit en ne retournant pas une valeur quand nous devrions, soit en retournant une valeur quand nous ne devrions pas. Et les implémentations de proto.Marshaler compliquent encore les choses.

D'un autre côté, si nous pouvions exprimer le type comme une union, nous pourrions être beaucoup plus explicites à ce sujet :

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

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

@ianlancetaylor , j'ai joué avec votre proposition sur papier. Pouvez-vous me faire savoir si quelque chose ci-dessous est incorrect ?

Étant donné

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

le type dynamique de r est int , et

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

est illégal.

Étant donné

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

alors var _ R = S{} serait illégal.

Mais étant donné

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

les deux var _ R = C{} et var _ R = A(C{}) seraient légaux.

Les deux

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

et

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

sont équivalents.

De même,

interface{} restrict { error, net.Error }

est équivalent à

interface { Error() string }

Étant donné

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

alors le type sous-jacent de R est équivalent à

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

Edit : petite correction en italique

@jimmyfrasche Je n'irais pas jusqu'à dire que ce que j'ai écrit ci-dessus était une proposition. C'était plutôt une idée. Il faudrait que je réfléchisse à vos commentaires, mais à première vue, ils semblent plausibles.

La proposition de @jimmyfrasche correspond à peu près à la façon dont je m'attendrais intuitivement à ce qu'un type de sélection se comporte dans Go. Je pense qu'il est particulièrement intéressant de noter que sa proposition d'utiliser la valeur zéro du premier champ pour la valeur zéro du choix est intuitive avec la "valeur zéro signifie mettre les octets à zéro", à condition que les valeurs des balises commencent à zéro (peut-être cela a déjà été noté; ce fil est maintenant très long...). J'aime aussi les implications en termes de performances (pas d'allocations inutiles) et le fait que les choix soient complètement orthogonaux aux interfaces (pas de comportement surprenant lors de l'activation d'un choix contenant une interface).

La seule chose que j'envisagerais de changer est de muter la balise : foo.X = 0 semble être foo = Foo{X: 0} ; quelques caractères de plus, mais plus explicite qu'il réinitialise la balise et met la valeur à zéro. C'est un point mineur, et je serais toujours très heureux si sa proposition était acceptée telle quelle.

@ns-cweber merci mais je ne peux pas m'attribuer le comportement de valeur zéro. Les idées flottaient depuis un certain temps et figuraient dans la proposition de

En ce qui concerne foo.X = 0 vs foo = Foo{X: 0} , ma proposition permet les deux, en fait. Ce dernier est utile si ce champ d'un choix est une structure, vous pouvez donc faire foo.X.Y = 0 au lieu de foo = Foo{X: image.Point{X: foo.[X].X, 0}} qui, en plus d'être détaillé, pourrait échouer à l'exécution.

Je pense aussi que cela aide à le garder comme tel car cela renforce l'ascenseur pour sa sémantique : c'est une structure qui ne peut avoir qu'un seul champ défini à la fois.

Une chose qui peut empêcher son acceptation telle quelle est la façon dont l'intégration d'un choix dans une structure fonctionnerait. J'ai réalisé l'autre jour que j'avais passé sous silence les différents effets que cela aurait sur l'utilisation de la structure. Je pense que c'est réparable mais je ne sais pas exactement quelles sont les meilleures réparations. Le plus simple serait qu'il n'hérite que des méthodes et que vous devez vous référer directement au choix intégré par son nom pour accéder à ses champs et je penche vers cela afin d'éviter qu'une structure ait à la fois des champs de structure et des champs de sélection.

@jimmyfrasche Merci de m'avoir corrigé à propos du comportement à valeur zéro. Je suis d'accord que votre proposition permet les deux mutateurs, et je pense que votre point d'ascenseur est bon. Votre explication de votre proposition est logique, même si je me voyais définir foo.XY, sans me rendre compte que cela changerait automatiquement le champ de sélection. Je serais toujours positivement joyeux si votre proposition réussissait, même avec cette légère réserve.

Enfin, votre proposition simple pour l'intégration de pics semble être celle que j'aurais l'intuition. Même si nous changeons d'avis, nous pouvons passer de la proposition simple à la proposition complexe sans casser le code existant, mais l'inverse n'est pas vrai.

@ns-cweber

Je me voyais définir foo.XY, sans me rendre compte que cela changerait automatiquement le champ de sélection

C'est un point juste, mais vous pourriez parler de beaucoup de choses dans la langue, ou dans n'importe quelle langue, d'ailleurs. En général, Go a des rails de sécurité mais pas de ciseaux de sécurité.

Il y a beaucoup de grandes choses dont il vous protège généralement, si vous ne faites pas tout votre possible pour les subvertir, mais vous devez toujours savoir ce que vous faites.

Cela peut être ennuyeux lorsque vous faites une erreur comme celle-ci, mais, otoh, ce n'est pas très différent de "J'ai défini bar.X = 0 mais je voulais définir bar.Y = 0 " car l'hypothèse repose sur le fait que vous ne vous en rendez pas compte que foo est un type de sélection.

De même, i.Foo() , p.Foo() et v.Foo() ressemblent tous, mais si i est une interface nil , p est un pointeur nul et Foo ne gère pas ce cas, les deux premiers pourraient paniquer alors que si v utilise un récepteur de méthode de valeur, il ne le pourrait pas (du moins pas à partir de l'invocation elle-même, de toute façon) .

-

En ce qui concerne l'intégration, il est bon qu'il soit facile à desserrer plus tard, alors je suis allé de l'avant et j'ai modifié la proposition.

Les types de somme ont souvent un champ sans valeur. Par exemple, dans le package database/sql , nous avons :

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

Si nous avions des types de somme / choix / unions, cela pourrait être exprimé comme suit :

type NullString pick {
  Null   struct{}
  String string
}

Un type sum a des avantages évidents par rapport à une structure dans ce cas. Je pense que c'est une utilisation suffisamment courante pour qu'il vaudrait la peine de l'inclure comme exemple dans toute proposition.

Bikeshedding (désolé), je dirais que cela vaut la peine d'un support syntaxique et d'une incohérence avec la syntaxe d'incorporation de champ struct :

type NullString union {
  Null
  String string
}

@neild

Atteindre le dernier point en premier : comme changement de dernière minute avant de publier (pas strictement requis dans aucun sens), j'ai ajouté que s'il y a un type nommé (ou un pointeur vers un type nommé) sans nom de champ, le choix crée un champ implicite avec le même nom que le type. Ce n'est peut-être pas la meilleure idée, mais il semblait que cela couvrirait l'un des cas courants de "l'un de ces types" sans trop de problèmes. Étant donné que votre dernier exemple pourrait s'écrire :

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

Revenons à votre point principal, cependant, oui, c'est une excellente utilisation. En fait, vous pouvez l'utiliser pour construire des énumérations : type Stoplight pick { Stop, Slow, Go struct{} } . Cela ressemblerait beaucoup à un faux-enum const/iota. Il compilerait même jusqu'à la même sortie. Le principal avantage dans ce cas est que le nombre représentant l'état est entièrement encapsulé et que vous ne pouvez pas le mettre dans un autre état que les trois indiqués.

Malheureusement, il existe une syntaxe quelque peu maladroite pour créer et définir des valeurs de Stoplight qui est exacerbée dans ce cas :

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

Permettre à {} ou _ d'être un raccourci pour struct{}{} , comme proposé ailleurs, aiderait.

De nombreux langages, en particulier les langages fonctionnels, contournent ce problème en plaçant les étiquettes dans la même portée que le type. Cela crée beaucoup de complexité et empêcherait deux choix définis dans la même portée de partager des noms de champ.

Cependant, il est facile de contourner ce problème avec un générateur de code qui crée une fonction avec le même nom de chaque champ dans le choix qui prend le type du champ comme argument. S'il ne prenait pas non plus d'arguments si le type était de taille zéro, alors la sortie de l'exemple Stoplight ressemblerait à ceci

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

et pour votre exemple NullString cela ressemblerait à ceci :

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

Ce n'est pas joli, mais c'est à go generate et probablement très facilement intégré.

Cela ne fonctionnerait pas dans le cas où il créait des champs implicites basés sur les noms de type (sauf si les types provenaient d'autres packages) ou s'il était exécuté sur deux sélections dans le même package qui partageaient les noms de champ, mais ça va. La proposition ne fait pas tout par défaut mais elle permet beaucoup de choses et donne au programmeur la flexibilité de décider ce qui est le mieux pour une situation donnée.

Plus de syntaxe bikeshedding :

type NullString union {
  Null
  Value string
}

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

Concrètement, un littéral avec une liste d'éléments qui ne contient aucune clé est interprété comme nommant le champ à définir.

Cela serait syntaxiquement incompatible avec d'autres utilisations de littéraux composites. D'un autre côté, c'est une utilisation qui semble sensée et intuitive dans le contexte des types union/pick/sum (pour moi du moins), puisqu'il n'y a pas d'interprétation sensée d'un initialiseur d'union sans clé.

@neild

Cela serait syntaxiquement incompatible avec d'autres utilisations de littéraux composites.

Cela me semble être un énorme négatif, même si cela a du sens dans le contexte.

Notez également que

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

Pour traiter avec struct{}{} quand j'utilise un map[T]struct{} je lance

var set struct{}

quelque part et utilisez theMap[k] = set , Similaire fonctionnerait avec des choix

Bikeshedding supplémentaire : le type vide (dans le contexte des types somme) est conventionnellement nommé « unité », et non « null ».

@bcmills Sorta.

Dans les langages fonctionnels, lorsque vous créez un type somme, ses étiquettes sont en fait des fonctions qui créent les valeurs de ce type (bien que des fonctions spéciales connues sous le nom de "constructeurs de type" ou "tycons" que le compilateur connaît pour permettre la correspondance de motifs), donc

data Bool = False | True

crée le type de données Bool et deux fonctions dans la même portée, True et False , chacune avec la signature () -> Bool .

Ici, () est la façon dont vous écrivez le type prononcé unité—le type avec une seule valeur. En Go, ce type peut être écrit de différentes manières, mais il est idiomatiquement écrit sous la forme struct{} .

Ainsi, le type de l'argument du constructeur s'appellerait unit. La convention pour le nom du constructeur est généralement None lorsqu'il est utilisé comme un type d'option comme celui-ci, mais il peut être modifié pour s'adapter au domaine. Null serait un bon nom si la valeur provenait d'une base de données, par exemple.

@bcmills

À mon avis, le principal avantage des types sum est qu'ils nous permettraient de faire la distinction entre le retour de plusieurs valeurs et le retour d'une valeur parmi plusieurs, en particulier lorsque l'une de ces valeurs est une instance de l'interface d'erreur.

Pour une perspective alternative, je vois cela comme un inconvénient majeur des types de somme dans Go.

De nombreux langages utilisent bien sûr des types de somme pour exactement le cas de renvoi d'une valeur ou d'une erreur, et cela fonctionne bien pour eux. Si des types sum étaient ajoutés à Go, la tentation serait grande de les utiliser de la même manière.

Cependant, Go dispose déjà d'un vaste écosystème de code qui utilise plusieurs valeurs à cette fin. Si le nouveau code utilise des types de somme pour renvoyer des tuples (valeur, erreur), alors cet écosystème deviendra fragmenté. Certains auteurs continueront à utiliser plusieurs retours pour assurer la cohérence avec leur code existant ; certains auteurs utiliseront des types sum ; certains tenteront de convertir leurs API existantes. Les auteurs bloqués sur les anciennes versions de Go, pour une raison quelconque, seront exclus des nouvelles API. Ce sera un gâchis, et je ne pense pas que les gains commenceront à valoir les coûts.

Si le nouveau code utilise des types de somme pour renvoyer des tuples (valeur, erreur), alors cet écosystème deviendra fragmenté.

Si nous ajoutons des types de somme dans Go 2 et les utilisons uniformément, alors le problème se réduit à un problème de migration, pas de fragmentation : il devrait être possible de convertir une API Go 1 (valeur, erreur) en une API Go 2 (valeur | erreur ) API et vice-versa, mais il pourrait s'agir de types distincts dans les parties Go 2 du programme.

Si nous ajoutons des types de somme dans Go 2 et les utilisons uniformément

Notez qu'il s'agit d'une proposition assez différente de celles vues ici jusqu'à présent : la bibliothèque standard devra être remaniée en profondeur, la traduction entre les styles d'API devra être définie, etc. Suivez cette voie et cela devient un assez grand et proposition compliquée pour une transition API avec un codicille mineur concernant la conception des types de somme.

L'intention est que Go 1 et Go 2 puissent coexister de manière transparente dans le même projet, donc je ne pense pas que le problème soit que quelqu'un puisse être coincé avec un compilateur Go 1 "pour une raison quelconque" et être incapable d'utiliser un Aller 2 bibliothèque. Cependant, si vous avez une dépendance A qui dépend à son tour de B , et des mises à jour de B pour utiliser une nouvelle fonctionnalité comme pick dans son API, alors cela casserait la dépendance A moins qu'il ne soit mis à jour pour utiliser la nouvelle version de B . A pourrait simplement vendre B et continuer à utiliser l'ancienne version, mais si l'ancienne version n'est pas maintenue pour des bogues de sécurité, etc... ou si vous devez utiliser la nouvelle version de B directement et vous ne pouvez pas avoir deux versions dans votre projet pour une raison quelconque, cela pourrait créer un problème.

En fin de compte, le problème ici a peu à voir avec les versions linguistiques, et plus à voir avec la modification des signatures des fonctions exportées existantes. Le fait qu'il s'agisse d'une nouvelle fonctionnalité donnant l'impulsion est un peu une distraction par rapport à cela. Si l'intention est de permettre aux API existantes d'être modifiées pour utiliser pick sans rompre la compatibilité descendante, il peut alors être nécessaire d'avoir une syntaxe de pont d'une certaine sorte. Par exemple (complètement en homme de paille) :

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

Le compilateur pourrait simplement éclabousser le ReadResult lorsqu'il est accessible par le code hérité, en utilisant des valeurs zéro si un champ n'est pas présent dans une variante particulière. Je ne sais pas comment aller dans l'autre sens ou si cela en vaut la peine. Des API comme template.Must pourraient simplement devoir continuer à accepter plusieurs valeurs plutôt qu'un pick et compter sur les éclaboussures pour compenser la différence. Ou quelque chose comme ça pourrait être utilisé:

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

Cela complique les choses, mais je peux voir à quel point l'introduction d'une fonctionnalité qui change la façon dont les API doivent être écrites nécessite une histoire sur la façon de faire la transition sans briser le monde. Il existe peut-être un moyen de le faire qui ne nécessite pas de syntaxe de pont.

Il est trivial de passer des types de somme aux types de produit (structures, valeurs de retour multiples) - il suffit de définir tout ce qui n'est pas la valeur à zéro. Passer des types de produits aux types de sommes n'est pas bien défini en général.

Si une API souhaite passer en toute transparence et progressivement d'une implémentation basée sur un type de produit à une implémentation basée sur un type de somme, la voie la plus simple serait d'avoir deux versions de tout ce qui est nécessaire où la version de type de somme a l'implémentation réelle et la version de type de produit appelle le version de type somme, effectuer toute vérification d'exécution requise et toute projection dans l'espace produit.

C'est vraiment abstrait alors voici un exemple

version 1 sans sommes

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
}

version 2 avec sommes

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 version 3 supprimerait Give/Take

la version 4 déplacerait l'implémentation de GiveSum/TakeSum vers Give/Take, make GiveSum/TakeSum appelle simplement Give/Take et désapprouve GiveSum/TakeSum.

la version 5 supprimerait GiveSum/TakeSum

Ce n'est ni joli ni rapide, mais c'est la même chose que toute autre perturbation à grande échelle de nature similaire et ne nécessite rien de plus de la langue

Je pense que (la plupart) l'utilité d'un type sum pourrait être réalisée avec un mécanisme pour contraindre l'affectation à un type d'interface de type{} au moment de la compilation.

Dans mes rêves, ça ressemble à :

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

... ce serait également une erreur de compilation d'affirmer qu'un type de commutateur n'est pas explicitement défini :

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
}

et go vet critiquerait les affectations constantes ambiguës à des types comme T3, mais à toutes fins utiles (au moment de l'exécution) var x T3 = 32 serait var x interface{} = 32 . Peut-être que certains types de commutateurs prédéfinis pour les fonctions intégrées dans un package nommé quelque chose comme des commutateurs ou des poneys seraient également groovy.

@j7b , @ianlancetaylor a proposé une idée similaire dans https://github.com/golang/go/issues/19412#issuecomment -323256891

J'ai posté ce que je pense être les conséquences logiques de cela plus tard sur https://github.com/golang/go/issues/19412#issuecomment -325048452

Il semble que beaucoup d'entre eux s'appliqueraient également compte tenu de la similitude.

Ce serait vraiment génial si quelque chose comme ça fonctionnait. Il serait facile de passer des interfaces aux interfaces+restrictions (surtout avec la syntaxe de Ian : il suffit d'ajouter le restrict à la fin des pseudo-sommes existantes construites avec les interfaces). Ce serait facile à implémenter car au moment de l'exécution, ils seraient essentiellement identiques aux interfaces et la plupart du travail consisterait simplement à faire en sorte que le compilateur émette des erreurs supplémentaires lorsque leurs invariants sont cassés.

Mais je ne pense pas qu'il soit possible de le faire fonctionner.

Tout s'aligne si près que cela ressemble à un ajustement, mais vous zoomez et ce n'est pas tout à fait correct, alors vous lui donnez un petit coup de pouce, puis quelque chose d'autre sort de l'alignement. Vous pouvez essayer de le réparer, mais vous obtenez alors quelque chose qui ressemble beaucoup à des interfaces mais qui se comporte différemment dans des cas étranges.

Peut-être que j'ai raté quelque chose.

Il n'y a rien de mal avec la proposition d'interface restreinte tant que vous êtes d'accord pour que les cas ne soient pas nécessairement disjoints. Je ne pense pas qu'il soit aussi surprenant que vous le faites qu'une union entre deux types d'interface (comme io.Reader / io.Writer ) ne soit pas disjointe. Cela est tout à fait cohérent avec le fait que vous ne pouvez pas déterminer si une valeur attribuée à un interface{} a été stockée en tant que io.Reader ou io.Writer s'il implémente les deux. Le fait que l'on puisse construire une union disjointe tant que chaque cas est un type concret semble parfaitement adéquat.

Le compromis est que, si les unions sont des interfaces restreintes, vous ne pouvez pas définir de méthodes directement sur elles. Et s'il s'agit de types d'interface restreints, vous n'obtenez pas le stockage direct garanti que les types pick fournissent. Je ne sais pas si cela vaut la peine d'ajouter un type distinct de chose à la langue pour obtenir ces avantages supplémentaires.

@jimmyfrasche pour type T switch {io.Reader,io.Writer} c'est bien d'attribuer un ReadWriter à T mais vous ne pouvez affirmer que T est un io.Reader ou Io.Writer, vous auriez besoin d'une autre assertion pour affirmer que io.Reader ou io.Writer est un ReadWriter, ce qui devrait encourager son ajout au switchtype s'il s'agit d'une assertion utile.

@stevenblenkinsop Vous pouvez définir la proposition de sélection sans méthodes. En fait, si vous vous débarrassez des méthodes et des noms de champs implicites, vous pouvez autoriser l'intégration de sélection. (Bien que je pense clairement que les méthodes et, dans une moindre mesure, les noms de champs implicites, sont le compromis le plus utile).

Et, d'autre part, la syntaxe de @ianlancetaylor permettrait

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

qui compilerait tant que A , B et C ont chacun les méthodes Foo et Bar (bien que vous deviez vous inquiéter environ nil valeurs).

edit: clarification en italique

Je pense qu'une certaine forme d'_interface restreinte_ serait utile, mais je ne suis pas d'accord avec la syntaxe. Voici ce que je propose. Il agit de la même manière qu'un type de données algébrique, qui regroupe des objets liés au domaine qui n'ont pas nécessairement un comportement commun.

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

Cette approche présente plusieurs avantages par rapport à l'approche conventionnelle de l'interface vide interface{} :

  • vérification de type statique lorsque la fonction est utilisée
  • l'utilisateur peut déduire quel type d'argument est requis à partir de la signature de la fonction seule, sans avoir à regarder l'implémentation de la fonction

L'interface vide interface{} est utile lorsque le nombre de types impliqués est inconnu. Vous n'avez vraiment pas d'autre choix que de vous fier à la vérification de l'exécution. En revanche, lorsque le nombre de types est limité et connu à la compilation, pourquoi ne pas nous faire assister par le compilateur ?

@henryas Je pense qu'une comparaison plus utile serait la manière actuellement recommandée de faire (ouvrir) des types de somme : des interfaces non vides (si aucune interface claire ne peut être distillée, en utilisant des fonctions de marqueur non exportées).
Je ne pense pas que vos arguments s'appliquent à cela de manière significative.

Voici un rapport d'expérience concernant les protobufs Go :

  • La syntaxe proto2 permet des champs "facultatifs", qui sont des types où il y a une distinction entre la valeur zéro et une valeur non définie. La solution actuelle consiste à utiliser un pointeur (par exemple, *int ), où un pointeur nul indique non défini, tandis qu'un pointeur défini pointe vers la valeur réelle. Le désir est une approche qui permet de faire une distinction entre zéro et unset possible, sans compliquer le cas courant de n'avoir besoin que d'accéder à la valeur (où la valeur zéro est bien si non définie).

    • Ceci est non performant en raison d'une allocation supplémentaire (bien que les syndicats puissent subir le même sort en fonction de la mise en œuvre).
    • C'est pénible pour les utilisateurs car la nécessité de vérifier constamment le pointeur nuit à la lisibilité (bien que des valeurs par défaut non nulles dans protos puissent signifier que la nécessité de vérifier est une bonne chose...).
  • Le langage proto autorise les "un des", qui sont la version proto des types somme. L'approche actuellement adoptée est la suivante ( exemple brut ) :

    • Définir un type d'interface avec une méthode cachée (par exemple, type Communique_Union interface { isCommunique_Union() } )
    • Pour chacun des types Go possibles autorisés dans l'union, définissez une structure wrapper, dont le seul but est d'envelopper chaque type autorisé (par exemple, type Communique_Number struct { Number int32 } ) où chaque type a la méthode isCommunique_Union .
    • Ceci est également non performant car les wrappers provoquent une allocation. Un type somme aiderait puisque nous savons que la plus grande valeur (une tranche) n'occuperait pas plus de 24B.

@henryas Je pense qu'une comparaison plus utile serait la manière actuellement recommandée de faire (ouvrir) des types de somme : des interfaces non vides (si aucune interface claire ne peut être distillée, en utilisant des fonctions de marqueur non exportées).
Je ne pense pas que vos arguments s'appliquent à cela de manière significative.

Vous voulez dire en ajoutant une méthode non exportée factice à un objet afin que l'objet puisse être passé en tant qu'interface, comme suit ?

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

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

Je ne pense pas que cela devrait être recommandé du tout. Cela ressemble plus à une solution de contournement qu'à une solution. Personnellement, je préférerais renoncer à la vérification de type statique plutôt que d'avoir des méthodes vides et une définition de méthode inutile qui traîne.

Voici les problèmes avec l'approche _dummy method_ :

  • Méthodes et définitions de méthodes inutiles encombrant l'objet et l'interface.
  • Chaque fois qu'un nouveau _groupe_ est ajouté, vous devez modifier l'implémentation de l'objet (par exemple en ajoutant des méthodes factices). C'est faux (voir le point suivant).
  • Le type de données algébriques (ou le regroupement basé sur _domain_ plutôt que sur le comportement) est spécifique au domaine . Selon le domaine, vous devrez peut-être afficher la relation d'objet différemment. Un comptable groupe les documents différemment d'un responsable d'entrepôt. Ce regroupement concerne le consommateur de l'objet, et non l'objet lui-même. L'objet n'a pas besoin de savoir quoi que ce soit sur le problème du consommateur, et il ne devrait pas en avoir besoin. Une facture a-t-elle besoin de savoir quelque chose sur la comptabilité ? Si ce n'est pas le cas, pourquoi une facture doit-elle changer sa mise en œuvre _(par exemple, en ajoutant de nouvelles méthodes factices)_ chaque fois qu'il y a un changement dans la règle comptable _(par exemple, en appliquant un nouveau regroupement de documents)_ ? En utilisant l'approche _dummy method_, vous couplez votre objet au domaine du consommateur et faites des hypothèses significatives sur le domaine du consommateur. Vous ne devriez pas avoir besoin de faire cela. C'est encore pire que l'approche de l'interface vide interface{} . Il existe de meilleures approches.

@henryas

Je ne vois pas votre troisième point comme un argument solide. Si le comptable souhaite voir les relations d'objet différemment, il peut créer sa propre interface qui correspond à ses spécifications. L'ajout d'une méthode privée à une interface ne signifie pas que les types concrets qui la satisfont sont incompatibles avec des sous-ensembles de l'interface définis ailleurs.

L'analyseur Go fait un usage intensif de cette technique et honnêtement, je ne peux pas imaginer des choix rendant ce paquet tellement meilleur qu'il justifie l'implémentation de choix dans la langue.

@as Mon point est que chaque fois qu'une nouvelle _vue de relation_ est créée, les objets concrets pertinents doivent être mis à jour pour assurer un certain aménagement pour cette vue. Cela semble faux, car pour ce faire, les objets doivent souvent faire une certaine hypothèse sur le domaine du consommateur. Si les objets et les consommateurs sont étroitement liés ou vivent dans le même domaine, comme dans le cas de l'analyseur Go, cela peut ne pas avoir beaucoup d'importance. Cependant, si les objets fournissent des fonctionnalités de base qui doivent être consommées par plusieurs autres domaines, cela devient un problème. Les objets doivent maintenant connaître un peu tous les autres domaines pour que l'approche _dummy method_ fonctionne.

Vous vous retrouvez avec de nombreuses méthodes vides attachées aux objets, et il n'est pas évident pour les lecteurs pourquoi vous avez besoin de ces méthodes car les interfaces qui les nécessitent vivent dans un domaine/paquet/couche séparé.

Le fait que l'approche des sommes ouvertes via des interfaces ne vous permet pas facilement d'utiliser des sommes est assez juste. Des types de somme explicites rendraient évidemment plus facile d'avoir des sommes. C'est un argument très différent de "les types de somme vous donnent une sécurité de type", cependant - vous pouvez toujours obtenir une sécurité de type aujourd'hui, si vous en avez besoin.

Cependant, je vois toujours deux inconvénients aux sommes fermées telles qu'elles sont implémentées dans d'autres langages : un, la difficulté de les faire évoluer dans un processus de développement distribué à grande échelle. Et deux, que je pense qu'ils ajoutent de la puissance au système de types et j'aime que Go n'ait pas un système de types très puissant, car cela décourage les types de codage et à la place les programmes de code - quand je sens qu'un problème peut bénéficier d'un système de types plus puissant, je passe à un langage plus puissant (comme Haskell ou Rust).

Cela étant dit, au moins la seconde est définitivement une préférence et même si vous êtes d'accord, le fait que les inconvénients soient considérés comme supérieurs aux avantages dépend également des préférences personnelles. Je voulais juste souligner que vous ne pouvez pas obtenir de sommes sûres sans types de somme fermés n'est pas vraiment vrai :)

[1] notamment, ce n'est pas facile, mais toujours possible , par exemple vous pouvez faire

type Node interface {
    node()
}

type Foo struct {
    bar.Baz
}

func (foo) node() {}

@Merovius
Je ne suis pas d'accord avec votre deuxième point négatif. Le fait qu'il y ait beaucoup d'endroits dans la bibliothèque standard qui bénéficieraient énormément des types sum, mais sont maintenant implémentés à l'aide d'interfaces vides et de paniques, montre que ce manque nuit au codage. Bien sûr, les gens pourraient dire que puisque ce code a été écrit en premier lieu, il n'y a pas de problème et nous n'avons pas besoin de types de somme, mais la folie de cette logique est que nous n'aurions alors besoin d'aucun autre type pour la fonction signatures, et nous devrions simplement utiliser des interfaces vides à la place.

Quant à l'utilisation d'interfaces avec une méthode pour représenter les types de somme en ce moment, il y a un gros inconvénient. Vous ne savez pas quels types vous pouvez utiliser pour cette interface, car ils sont implémentés implicitement. Avec un type de somme approprié, le type lui-même décrit exactement quels types peuvent réellement être utilisés.

Je ne suis pas d'accord avec votre deuxième point négatif.

Êtes-vous en désaccord avec l'énoncé « les types de somme encouragent la programmation avec des types », ou n'êtes-vous pas d'accord avec le fait que cela soit un inconvénient ? Parce qu'il ne semble pas que vous soyez en désaccord avec le premier (votre commentaire n'est fondamentalement qu'une réaffirmation de cela) et en ce qui concerne le second, j'ai reconnu que cela dépendait de la préférence ci-dessus.

Le fait qu'il y ait beaucoup d'endroits dans la bibliothèque standard qui bénéficieraient énormément des types sum, mais sont maintenant implémentés à l'aide d'interfaces vides et de paniques, montre que ce manque nuit au codage. Bien sûr, les gens pourraient dire que puisque ce code a été écrit en premier lieu, il n'y a pas de problème et nous n'avons pas besoin de types de somme, mais la folie de cette logique est que nous n'aurions alors besoin d'aucun autre type pour la fonction signatures, et nous devrions simplement utiliser des interfaces vides à la place.

Ce type d'argument en noir et blanc n'aide pas vraiment . Je suis d'accord, que les types de somme réduiraient la douleur dans certains cas. Chaque changement rendant le système de types plus puissant réduira la douleur dans certains cas - mais cela causera également de la douleur dans certains cas. Donc la question est, qui l'emporte sur l'autre (et c'est, dans une bonne mesure, une question de préférence).

Les discussions ne devraient pas porter sur la question de savoir si nous voulons un système de types python-esque (pas de types) ou un système de types coq-esque (preuves d'exactitude pour tout). La discussion devrait être « les avantages des types de somme l'emportent-ils sur leurs inconvénients » et il est utile de reconnaître les deux.


FTR, je tiens à souligner à nouveau que, personnellement, je ne serais pas si opposé aux types de somme ouverts (c'est-à-dire que chaque type de somme a un cas "SomethingElse" implicite ou explicite), car cela atténuerait la plupart des inconvénients techniques de (surtout qu'ils sont difficiles à faire évoluer) tout en fournissant la plupart des avantages techniques (vérification de type statique, la documentation que vous avez mentionnée, vous pouvez énumérer les types d'autres packages…).

Je suppose également, cependant, que les sommes ouvertes a) ne seront pas un compromis satisfaisant pour les personnes qui recherchent généralement des types de somme et b) ne seront probablement pas considérées comme un avantage suffisamment important pour justifier leur inclusion par l'équipe Go. Mais je serais prêt à me tromper sur l'une ou l'autre de ces hypothèses :)

Une dernière question:

Le fait qu'il y a beaucoup d'endroits dans la bibliothèque standard qui bénéficieraient énormément des types de somme

Je ne peux penser qu'à deux endroits dans la bibliothèque standard, où je dirais qu'il y a un avantage significatif pour eux : réfléchir et go/ast. Et même là, les packages semblent fonctionner parfaitement sans eux. À partir de ce point de référence, les mots « beaucoup » et « immensément » semblent exagérés - mais je ne verrai peut-être pas un tas d'endroits légitimes, bien sûr.

database/sql/driver.Value pourrait bénéficier d'un type de somme (comme indiqué dans #23077).
https://godoc.corp.google.com/pkg/database/sql/driver#Value

L'interface plus publique de database/sql.Rows.Scan ne serait cependant pas sans une perte de fonctionnalité. Scan peut lire des valeurs dont le type sous-jacent est par exemple int ; changer son paramètre de destination en un type somme nécessiterait de limiter ses entrées à un ensemble fini de types.
https://godoc.corp.google.com/pkg/database/sql#Rows.Scan

@Merovius

Je ne serais pas si opposé aux types de somme ouverts (c'est-à-dire que chaque type de somme a un cas "SomethingElse" implicite ou explicite), car cela atténuerait la plupart de leurs inconvénients techniques (principalement qu'ils sont difficiles à faire évoluer)

Il existe au moins deux autres options qui atténuent le problème « difficile à faire évoluer » des sommes fermées.

La première consiste à autoriser les correspondances sur des types qui ne font pas réellement partie de la somme. Ensuite, pour ajouter un membre à la somme, vous mettez d'abord à jour ses consommateurs pour qu'ils correspondent au nouveau membre, et n'ajoutez réellement ce membre qu'une fois les consommateurs mis à jour.

Une autre consiste à autoriser les membres « impossibles » : c'est-à-dire les membres qui sont explicitement autorisés dans les correspondances mais explicitement interdits dans les valeurs réelles. Pour ajouter un membre à la somme, vous l'ajoutez d'abord en tant que membre impossible, puis mettez à jour les consommateurs et enfin modifiez le nouveau membre pour qu'il soit possible.

database/sql/driver.Value pourrait bénéficier d'être un type de somme

D'accord, je ne savais pas pour celui-là. Merci :)

La première consiste à autoriser les correspondances sur des types qui ne font pas réellement partie de la somme. Ensuite, pour ajouter un membre à la somme, vous mettez d'abord à jour ses consommateurs pour qu'ils correspondent au nouveau membre, et n'ajoutez réellement ce membre qu'une fois les consommateurs mis à jour.

Solution intrigante.

Les interfaces default: . Sans types de somme finie, cependant, default: signifie soit un cas valide que vous ne connaissiez pas, soit un cas invalide qui est un bogue quelque part dans le programme — avec des sommes finies, ce n'est que le premier et jamais le dernier.

json.Token et les types sql.Null* sont d'autres exemples canoniques. go/types bénéficierait de la même manière que go/ast. Je suppose qu'il y a beaucoup d'exemples qui ne figurent pas dans les API exportées où il aurait été plus facile de déboguer et de tester une plomberie complexe en limitant le domaine de l'état interne. Je les trouve très utiles pour les contraintes d'état interne et d'application qui n'apparaissent pas si souvent dans les API publiques pour les bibliothèques générales, bien qu'elles y soient également utilisées occasionnellement.

Personnellement, je pense que les types sum donnent à Go juste assez de puissance supplémentaire mais pas trop. Le système de type Go est déjà très agréable et flexible, même s'il a ses défauts. Les ajouts Go2 au système de types ne fourniront tout simplement pas autant de puissance que ce qui existe déjà – les 80 à 90 % de ce qui est nécessaire sont déjà en place. Je veux dire, même les génériques ne vous laisseraient pas fondamentalement faire quelque chose de nouveau : cela vous permettrait de faire des choses que vous faites déjà de manière plus sûre, plus facile, plus performante et d'une manière qui permet un meilleur outillage. Les types de somme sont similaires, imo (bien qu'évidemment, s'il s'agissait de l'un ou l'autre des génériques, il prévaudrait (et ils s'apparient plutôt bien)).

Si vous autorisez une valeur par défaut étrangère (tous les cas + la valeur par défaut sont autorisés) sur les commutateurs de type somme et que le compilateur n'impose pas l'exhaustivité (bien qu'un linter puisse le faire), ajouter un cas à une somme est tout aussi facile (et tout aussi difficile ) comme modifiant toute autre API publique.

json.Token et les types sql.Null* sont d'autres exemples canoniques.

Jeton - bien sûr. Une autre instance du problème AST (essentiellement, tout analyseur profite des types de somme).

Je ne vois pas l'avantage de sql.Null*, cependant. Sans génériques (ou en ajoutant une option générique générique "magique"), vous devrez toujours avoir les types et il ne semble pas qu'il y ait de différence significative entre type NullBool enum { Invalid struct{}; Value Int } et type NullBool struct { Valid bool; Value Int } . Oui, je suis conscient qu'il y a une différence, mais elle est infime.

Si vous autorisez une valeur par défaut étrangère (tous les cas + la valeur par défaut sont autorisés) sur les commutateurs de type somme et que le compilateur n'impose pas l'exhaustivité (bien qu'un linter puisse le faire), ajouter un cas à une somme est tout aussi facile (et tout aussi difficile ) comme modifiant toute autre API publique.

Voir au dessus. C'est ce que j'appelle des sommes ouvertes, je m'y oppose moins.

C'est ce que j'appelle des sommes ouvertes, je m'y oppose moins.

Ma proposition spécifique est https://github.com/golang/go/issues/19412#issuecomment -323208336 et je pense qu'elle peut satisfaire votre définition d'ouvert, bien qu'elle soit encore un peu approximative et je suis sûr qu'il y a encore plus à faire enlever et polir. En particulier, j'ai remarqué qu'il n'était pas clair qu'un cas par défaut était recevable même si tous les cas étaient répertoriés, alors je l'ai juste mis à jour.

J'ai convenu que les types facultatifs ne sont pas l'application qui tue les types de somme. Ils sont quand même assez sympas et comme vous le faites remarquer avec les génériques définissant un

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

une fois et couvrir tous les cas serait formidable. Mais, comme vous le soulignez également, nous pourrions faire la même chose avec un produit générique (struct). Il y a l'état invalide de Valid = false, Value != 0. Dans ce scénario, il serait facile d'éliminer si cela causait des problèmes puisque 2 T est petit, même s'il n'est pas aussi petit que 1 + T.

Bien sûr, s'il s'agissait d'une somme plus compliquée avec beaucoup de cas et de nombreux invariants qui se chevauchent, il devient plus facile de faire une erreur et plus difficile de découvrir l'erreur, même avec une programmation défensive, donc rendre les choses impossibles tout simplement pas compilées peut sauver beaucoup de cheveux tirant.

Jeton - bien sûr. Une autre instance du problème AST (essentiellement, tout analyseur profite des types de somme).

J'écris beaucoup de programmes qui prennent des entrées, effectuent des traitements et produisent des sorties et je divise généralement cela de manière récursive en un grand nombre de passes qui divisent l'entrée en cas et la transforment en fonction de ces cas au fur et à mesure que je me rapproche du Sortie désirée. Je n'écris peut-être pas littéralement un analyseur (il est vrai que parfois je le suis parce que c'est amusant !) exigences et cas de bord pour s'adapter à ma petite tête.

Lorsque j'écris une bibliothèque générale, cela n'apparaît pas dans l'API aussi souvent que de faire un ETL ou de faire un rapport fantaisiste ou de s'assurer que les utilisateurs dans l'état X ont l'action Y s'ils ne sont pas marqués Z. Même dans une bibliothèque générale bien que je trouve des endroits où pouvoir limiter l'état interne aiderait, même si cela réduit juste un débogage de 10 minutes à 1 seconde "oh le compilateur a dit que je me trompais".

Avec Go en particulier, un endroit où j'utiliserais des types de somme est une goroutine sélectionnant sur un tas de canaux où je dois donner 3 canaux à une goroutine et 2 à une autre. Cela m'aiderait à suivre ce qui se passe pour pouvoir utiliser un chan pick { a A; b B; c C } sur chan A , chan B , chan C bien qu'un chan stuct { kind MsgKind; a A; b B; c C } puisse faire le travail en un clin d'œil au prix d'un espace supplémentaire et de moins de validation.

Au lieu d'un nouveau type, qu'en est-il de la vérification de la liste des types au moment de la compilation en tant qu'ajout à la fonctionnalité de changement de type d'interface existante ?

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

En toute justice, nous devrions explorer les moyens d'approcher les types de somme dans le système de types actuel et peser leurs avantages et leurs inconvénients. Si rien d'autre, cela donne une base de comparaison.

Le moyen standard est une interface avec une méthode non exportée et à ne rien faire en tant que balise.

Un argument contre cela est que chaque type de la somme doit avoir cette balise définie dessus. Ce n'est pas strictement vrai, du moins pour les membres qui sont des structs, nous pourrions faire

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

et intégrez simplement cette balise de largeur 0 dans nos structures.

Nous pouvons ajouter des types externes à notre somme en introduisant un wrapper

type External struct {
  sum
  *pkg.SomeType
}

bien que ce soit un peu maladroit.

Si tous les membres de la somme partagent un comportement commun, nous pouvons inclure ces méthodes dans la définition de l'interface.

Des constructions comme celle-ci disons qu'un type est dans une somme, mais cela ne nous permet pas de dire ce qui n'est pas dans cette somme. En plus du cas obligatoire nil , la même astuce d'intégration peut être utilisée par des packages externes comme

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

Dans le package, nous devons prendre soin de valider les valeurs qui se compilent mais qui sont illégales.

Il existe différentes manières de récupérer une certaine sécurité de type lors de l'exécution. J'ai trouvé une méthode valid() error dans la définition de l'interface sum couplée à une fonction comme

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

utile car il permet de s'occuper de deux types de validation à la fois. Pour les membres qui sont toujours valides, nous pouvons éviter certains passe-partout avec

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

L'une des plaintes les plus courantes concernant ce modèle est qu'il n'indique pas clairement l'appartenance à la somme dans godoc. Comme cela ne nous permet pas non plus d'exclure des membres et nous oblige à valider de toute façon, il existe un moyen simple de contourner cela : exporter la méthode factice.
À la place de,

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

écrivez

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

Nous ne pouvons empêcher personne de satisfaire Node alors autant leur faire savoir ce qu'il fait. Bien que cela ne précise pas d'un coup d'œil quels types satisfont à Node (pas de liste centrale), cela indique clairement si le type particulier que vous regardez satisfait maintenant Node .

Ce modèle est utile lorsque la majorité des types de la somme sont définis dans le même package. Lorsqu'il n'y en a pas, le recours courant est de revenir à interface{} , comme json.Token ou driver.Value . Nous pourrions utiliser le modèle précédent avec des types de wrapper pour chacun, mais à la fin, il en dit autant que interface{} donc cela ne sert pas à grand-chose. Si nous nous attendons à ce que de telles valeurs viennent de l'extérieur du paquet, nous pouvons être courtois et définir une usine :

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

Une utilisation courante des sommes concerne les types facultatifs, où vous devez faire la différence entre "aucune valeur" et "une valeur qui peut être nulle". Il y a deux façons de faire ça.

*T signifions aucune valeur en tant que pointeur nil et une valeur (éventuellement) zéro en tant que résultat du déréférencement d'un pointeur non nul.

Comme les précédentes approximations basées sur les interfaces, et les diverses propositions d'implémentation de types sum en tant qu'interfaces avec restrictions, cela nécessite un déréférencement de pointeur supplémentaire et une éventuelle allocation de tas.

Pour les options, cela peut être évité en utilisant la technique du package sql

type OptionalT struct {
  Valid bool
  Value T
}

L'inconvénient majeur de ceci est qu'il permet de coder un état invalide : Valid peut être faux et Value peut être différent de zéro. Il est également possible de saisir Value lorsque Valid est faux (bien que cela puisse être utile si vous voulez le zéro T s'il n'a pas été spécifié). La définition par hasard de Valid sur false sans mise à zéro de Value suivi de la définition de Valid sur true (ou en l'ignorant) sans affecter Value provoque la réapparition accidentelle d'une valeur précédemment ignorée. Cela peut être contourné en fournissant des setters et des getters pour protéger les invariants du type.

La forme la plus simple des types de somme est lorsque vous vous souciez de l'identité, pas de la valeur : les énumérations.

La façon traditionnelle de gérer cela dans Go est const/iota :

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

Comme le type OptionalT il n'y a pas d'indirection inutile. Comme les sommes d'interface, cela ne limite pas le domaine : il n'y a que trois valeurs valides et de nombreuses valeurs invalides, nous devons donc valider au moment de l'exécution. S'il y a exactement deux valeurs, nous pouvons utiliser bool.

Il y a aussi la question du nombre fondamental de ce type. A+B == C . Nous pouvons convertir des constantes intégrales non typées en ce type un peu trop facilement. Il y a beaucoup d'endroits où c'est souhaitable, mais nous l'obtenons quoi qu'il arrive. Avec un peu de travail supplémentaire, nous pouvons limiter cela à une simple identité :

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

Maintenant, ce ne sont que des étiquettes opaques. On peut les comparer mais c'est tout. Malheureusement, maintenant, nous avons perdu la constance, mais nous pourrions la récupérer avec un peu plus de travail :

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

Nous avons regagné l'incapacité pour un utilisateur externe de modifier les noms au prix de certains appels passe-partout et de certains appels de fonction hautement compatibles.

Cependant, c'est à certains égards plus agréable que les sommes de l'interface puisque nous avons presque complètement fermé le type. Le code externe ne peut utiliser que A() , B() ou C() . Ils ne peuvent pas échanger les étiquettes comme dans l'exemple var et ils ne peuvent pas faire A() + B() et nous sommes libres de définir les méthodes que nous voulons sur Enum . Il serait toujours possible que du code dans le même package crée ou modifie par erreur une valeur, mais si l'on prend soin de s'assurer que cela n'arrive pas, c'est le premier type somme qui ne nécessite pas de code de validation : s'il existe, il est valide .

Parfois, vous avez de nombreuses étiquettes et certaines d'entre elles ont une date supplémentaire et celles qui ont le même type de données. Supposons que vous ayez une valeur qui a trois états sans valeur (A, B, C), deux avec une valeur de chaîne (D, E) et un avec une valeur de chaîne et une valeur int (F). Nous pourrions utiliser un certain nombre de combinaisons des tactiques ci-dessus, mais la manière la plus simple est

type Value struct {
  Which int // could have consts for A, B, C, D, E, F
  String string
  Int int
}

Cela ressemble beaucoup au type OptionalT ci-dessus, mais au lieu d'un booléen, il a une énumération et plusieurs champs peuvent être définis (ou non) en fonction de la valeur de Which . La validation doit veiller à ce que ceux-ci soient définis (ou non) de manière appropriée.

Il existe de nombreuses façons d'exprimer "l'un des éléments suivants" dans Go. Certains nécessitent plus de soins que d'autres. Ils nécessitent souvent la validation de l'invariant "un de" au moment de l'exécution ou des déréférencements externes. Un inconvénient majeur qu'ils partagent tous est que, puisqu'ils sont simulés dans le langage au lieu de faire partie du langage, l'invariant "un de" n'apparaît pas dans reflect ou go/types, ce qui rend difficile la métaprogrammation avec eux. Pour les utiliser dans la métaprogrammation, vous devez tous les deux être capable de reconnaître et de valider la saveur correcte de la somme et de vous dire que c'est ce que vous recherchez, car ils ressemblent tous beaucoup à du code valide sans l'invariant "un de".

Si les types de somme faisaient partie du langage, ils pourraient être réfléchis et facilement extraits du code source, ce qui se traduirait par de meilleures bibliothèques et outils. Le compilateur pourrait faire un certain nombre d'optimisations s'il était conscient de cet invariant "un de". Les programmeurs pourraient se concentrer sur le code de validation important au lieu de la maintenance triviale consistant à vérifier qu'une valeur est bien dans le bon domaine.

Des constructions comme celle-ci disons qu'un type est dans une somme, mais cela ne nous permet pas de dire ce qui n'est pas dans cette somme. En plus du cas nil obligatoire, la même astuce d'intégration peut être utilisée par des packages externes tels que
[…]
Dans le package, nous devons prendre soin de valider les valeurs qui se compilent mais qui sont illégales.

Pourquoi? En tant qu'auteur de package, cela me semble fermement du domaine de "votre problème". Si vous me passez un io.Reader , dont la méthode Read panique, je ne vais pas m'en remettre et le laisser paniquer. De même, si vous faites tout votre possible pour créer une valeur invalide d'un type que j'ai déclaré, qui suis-je pour discuter avec vous ? C'est-à-dire que je considère "J'ai intégré une somme fermée émulée" comme un problème qui survient rarement (voire jamais) par accident.

Cela étant dit, vous pouvez éviter ce problème en modifiant l'interface en type Sum interface { sum() Sum } et en retournant chaque valeur d'elle-même. De cette façon, vous pouvez simplement utiliser le retour de sum() , qui se comportera bien même en cas d'intégration.

L'une des plaintes les plus courantes concernant ce modèle est qu'il n'indique pas clairement l'appartenance à la somme dans godoc.

Cela peut vous aider .

L'inconvénient majeur de ceci est qu'il permet de coder un état invalide : Valid peut être faux et Value peut être différent de zéro.

Ce n'est pas un état invalide pour moi. Les valeurs zéro ne sont pas magiques. Il n'y a pas de différence, IMO, entre sql.NullInt64{false,0} et NullInt64{false,42} . Les deux sont des représentations valides et équivalentes d'un SQL NULL. Si tout le code vérifie Valide avant d'utiliser Value, la différence n'est pas observable pour un programme.

C'est une critique juste et correcte que le compilateur n'impose pas de faire cette vérification (ce qu'il ferait probablement, pour les "vrais" types optionnels/somme), ce qui rend plus facile de ne pas le faire. Mais si vous l'oubliez, je ne considérerais pas mieux d'utiliser accidentellement une valeur nulle que d'utiliser accidentellement une valeur non nulle (à l'exception possible des types en forme de pointeur, car ils paniqueraient lorsqu'ils seraient utilisés, donc échouer bruyamment - mais pour ceux-là, vous devriez quand même utiliser le type en forme de pointeur nu et utiliser nil comme "unset").

Il y a aussi la question du nombre fondamental de ce type. A+B == C. Nous pouvons convertir des constantes intégrales non typées en ce type un peu trop facilement.

Est-ce une préoccupation théorique ou est-elle apparue dans la pratique ?

Les programmeurs pourraient se concentrer sur le code de validation important au lieu de la maintenance triviale consistant à vérifier qu'une valeur est bien dans le bon domaine.

Juste FTR, dans les cas où j'utilise sum-types-as-sum-types (c'est-à-dire que le problème ne peut pas être modélisé plus élégamment via des interfaces de variété dorée), je n'écris jamais de code de validation. Tout comme je ne vérifie pas l'absence de pointeurs passés en tant que récepteurs ou arguments (à moins que cela ne soit documenté comme une variante valide). Aux endroits où le compilateur m'oblige à gérer cela (c'est-à-dire des problèmes de style "pas de retour à la fin de la fonction"), je panique dans le cas par défaut.

Personnellement, je considère Go comme un langage pragmatique, qui n'ajoute pas seulement des fonctionnalités de sécurité pour leur propre intérêt ou parce que "tout le monde sait qu'ils sont meilleurs", mais basé sur un besoin démontré. Je pense que l'utiliser de manière pragmatique est donc bien.

Le moyen standard est une interface avec une méthode non exportée et à ne rien faire en tant que balise.

Il existe une différence fondamentale entre les interfaces et les types de somme (je ne l'ai pas vue mentionnée dans votre message). Lorsque vous approximez un type de somme via une interface, il n'y a vraiment aucun moyen de gérer la valeur. En tant que consommateur, vous n'avez aucune idée de ce qu'il contient réellement et ne pouvez que deviner. Ce n'est pas mieux que d'utiliser simplement une interface vide. Sa seule utilité est de savoir si une implémentation ne peut provenir que du même package qui définit l'interface, car ce n'est qu'alors que vous pouvez contrôler ce que vous pouvez obtenir.

D'un autre côté, avoir quelque chose comme :

func foo(val string|int|error) {
    switch v:= val.(type) {
    case string:
        ...
    }
}

Donne au consommateur tout le pouvoir d'utiliser la valeur du type somme. Sa valeur est concrète, non sujette à interprétation.

@Merovius
Ces "sommes ouvertes" que vous mentionnez ont ce que certaines personnes pourraient qualifier d'inconvénient important, en ce sens qu'elles permettraient d'en abuser pour du "fluage de caractéristiques". C'est précisément la raison pour laquelle les arguments de fonction facultatifs ont été rejetés en tant que fonctionnalité.

Ces "sommes ouvertes" que vous mentionnez ont ce que certaines personnes pourraient qualifier d'inconvénient important, en ce sens qu'elles permettraient d'en abuser pour du "fluage de caractéristiques". C'est précisément la raison pour laquelle les arguments de fonction facultatifs ont été rejetés en tant que fonctionnalité.

Cela me semble être un argument assez faible - si rien d'autre, alors parce qu'ils existent, donc vous autorisez déjà tout ce qu'ils permettent. En effet, nous avons déjà des arguments optionnels , à toutes fins utiles (pas que j'aime ce modèle, mais c'est clairement déjà possible dans le langage).

Il existe une différence fondamentale entre les interfaces et les types de somme (je ne l'ai pas vue mentionnée dans votre message). Lorsque vous approximez un type de somme via une interface, il n'y a vraiment aucun moyen de gérer la valeur. En tant que consommateur, vous n'avez aucune idée de ce qu'il contient réellement et ne pouvez que deviner.

J'ai essayé d'analyser cela une deuxième fois et je n'y arrive toujours pas. Pourquoi ne pourriez-vous pas les utiliser ? Il peut s'agir de types exportés normaux. Oui, ils doivent être des types créés dans votre package (évidemment), mais à part cela, il ne semble y avoir aucune restriction dans la façon dont vous pouvez les utiliser, par rapport aux sommes réelles et fermées.

J'ai essayé d'analyser cela une deuxième fois et je n'y arrive toujours pas. Pourquoi ne pourriez-vous pas les utiliser ? Il peut s'agir de types exportés normaux. Oui, ils doivent être des types créés dans votre package (évidemment), mais à part cela, il ne semble y avoir aucune restriction dans la façon dont vous pouvez les utiliser, par rapport aux sommes réelles et fermées.

Que se passe-t-il dans le cas où la méthode factice est exportée et que n'importe quel tiers peut implémenter le « type somme » ? Ou le scénario assez réaliste où un membre de l'équipe n'est pas familier avec les différents consommateurs de l'interface, décide d'ajouter une autre implémentation dans le même package, et une instance de cette implémentation finit par être transmise à ces consommateurs par divers moyens du code ? Au risque de répéter ma déclaration apparente « impossible à analyser » : « En tant que consommateur, vous n'avez aucune idée de ce que [la valeur de la somme] contient réellement et ne pouvez que deviner ». Vous savez, puisque c'est une interface, et cela ne vous dit pas qui l'implémente.

@Merovius

Juste FTR, dans les cas où j'utilise sum-types-as-sum-types (c'est-à-dire que le problème ne peut pas être modélisé plus élégamment via des interfaces de variété dorée), je n'écris jamais de code de validation. Tout comme je ne vérifie pas l'absence de pointeurs passés en tant que récepteurs ou arguments (à moins que cela ne soit documenté comme une variante valide). Aux endroits où le compilateur m'oblige à gérer cela (c'est-à-dire des problèmes de style "pas de retour à la fin de la fonction"), je panique dans le cas par défaut.

Je ne considère pas cela comme une chose toujours ou jamais .

Si quelqu'un passant une mauvaise entrée explosait immédiatement, je ne me soucie pas du code de validation.

Mais si quelqu'un qui passe une mauvaise entrée peut éventuellement provoquer une panique mais qu'il n'apparaîtra pas pendant un certain temps, alors j'écris un code de validation afin que la mauvaise entrée soit signalée dès que possible et que personne n'ait à comprendre que l'erreur a été introduite 150 cadres dans la pile d'appels (d'autant plus qu'ils peuvent alors devoir remonter 150 autres cadres dans la pile d'appels pour déterminer où cette mauvaise valeur a été introduite).

Passer une demi-minute maintenant pour économiser potentiellement une demi-heure de débogage plus tard est pragmatique. Surtout pour moi puisque je fais des erreurs stupides tout le temps et plus tôt je serai scolarisé, plus tôt je pourrai passer à la prochaine erreur stupide.

Si j'ai une fonction qui prend un lecteur et commence immédiatement à l'utiliser, je ne vérifierai pas nil, mais si la fonction est une usine pour une structure qui n'appellera pas le lecteur tant qu'une certaine méthode n'est pas invoquée, je vais vérifiez qu'il n'est pas nul et paniquez ou retournez une erreur avec quelque chose comme "le lecteur ne doit pas être nul" afin que la cause de l'erreur soit aussi proche que possible de la source de l'erreur.

godoc -analyse

Je suis au courant mais je ne le trouve pas utile. Il a fonctionné pendant 40 minutes sur mon espace de travail avant d'appuyer sur ^C et cela doit être actualisé chaque fois qu'un package est installé ou modifié. Il y a # 20131 (fourché à partir de ce fil même!) Cependant.

Cela étant dit, vous pouvez éviter ce problème en modifiant l'interface en type Sum interface { sum() Sum } et en retournant chaque valeur d'elle-même. De cette façon, vous pouvez simplement utiliser le retour de sum() , qui se comportera bien même en cas d'intégration.

Je n'ai pas trouvé ça utile. Il n'offre pas plus d'avantages que la validation explicite et il fournit moins de validation.

Est-ce que [le fait que vous puissiez ajouter des membres à une énumération const/iota] est un problème théorique ou est-il apparu dans la pratique ?

Celui-là en particulier était théorique : j'essayais d'énumérer tous les avantages et inconvénients auxquels je pouvais penser, théoriques et pratiques. Mon point le plus important, cependant, était qu'il y avait de nombreuses façons d'essayer d'exprimer l'invariant "un de" dans la langue qui sont utilisées assez couramment, mais aucune n'est aussi simple que de simplement en faire une sorte de type dans la langue.

Est-ce que [le fait que vous puissiez attribuer une intégrale non typée à une énumération const/iota] est un problème théorique ou est-il apparu dans la pratique ?

Celui-là est venu dans la pratique. Il n'a pas fallu longtemps pour comprendre ce qui n'allait pas, mais cela aurait pris encore moins de temps si le compilateur avait dit "là, cette ligne, c'est celle qui ne va pas". On parle d'autres façons de traiter ce cas particulier, mais je ne vois pas en quoi elles seraient d'une utilité générale.

Ce n'est pas un état invalide pour moi. Les valeurs zéro ne sont pas magiques. Il n'y a pas de différence, IMO, entre sql.NullInt64{false,0} et NullInt64{false,42} . Les deux sont des représentations valides et équivalentes d'un SQL NULL. Si tout le code vérifie Valide avant d'utiliser Value, la différence n'est pas observable pour un programme.

C'est une critique juste et correcte que le compilateur n'impose pas de faire cette vérification (ce qu'il ferait probablement, pour les "vrais" types optionnels/somme), ce qui rend plus facile de ne pas le faire. Mais si vous l'oubliez, je ne considérerais pas mieux d'utiliser accidentellement une valeur nulle que d'utiliser accidentellement une valeur non nulle (à l'exception possible des types en forme de pointeur, car ils paniqueraient lorsqu'ils seraient utilisés, donc échouer bruyamment - mais pour ceux-ci, vous devriez juste utiliser le type en forme de pointeur nu de toute façon et utiliser nil comme "unset").

Ce "Si tout le code vérifie la validité avant d'utiliser la valeur" est l'endroit où les bogues se glissent et ce que le compilateur pourrait appliquer. J'ai eu des bogues comme ça (bien qu'avec des versions plus grandes de ce modèle, où il y avait plus d'un champ de valeur et plus de deux états pour le discriminateur). Je crois / j'espère que j'ai trouvé tout cela pendant le développement et les tests et qu'aucun ne s'est échappé dans la nature, mais ce serait bien si le compilateur pouvait simplement me dire quand j'ai fait cette erreur et je pourrais être sûr que le seul moyen de ces passé était s'il y avait un bogue dans le compilateur, de la même manière qu'il me le dirait si j'essayais d'affecter une chaîne à une variable de type int.

Et, bien sûr, je préfère *T pour les types facultatifs bien que cela entraîne des coûts non nuls, à la fois dans l'espace-temps d'exécution et dans la lisibilité du code.

(Pour cet exemple particulier, le code permettant d'obtenir la valeur réelle ou la valeur zéro correcte avec la proposition de sélection serait v, _ := nullable.[Value] ce qui est concis et sûr.)

Ce n'est pas du tout ce que je voudrais. Les types de sélection doivent être des types de valeur,
comme à Rust. Leur premier mot doit être un pointeur vers les métadonnées du GC, si nécessaire.

Sinon, leur utilisation s'accompagne d'une pénalité de performance qui pourrait être
inacceptable. Pour moi, le pass 10h41, "Josh Bleecher Snyder" <
[email protected]> a écrit :

Avec la proposition de sélection, vous pouvez choisir d'avoir ap ou *p vous en donnant plus
un meilleur contrôle sur les compromis de mémoire.

La raison pour laquelle les interfaces allouent pour stocker des valeurs scalaires est que vous ne
devez lire un mot type pour décider si l'autre mot est un
aiguille; voir #8405 https://github.com/golang/go/issues/8405 pour
discussion. Les mêmes considérations de mise en œuvre s'appliqueraient probablement à un
pick type, ce qui pourrait signifier en pratique que p finissent par allouer et être
non local de toute façon.

-
Vous recevez ceci parce que vous avez créé le fil.
Répondez directement à cet e-mail, consultez-le sur GitHub
https://github.com/golang/go/issues/19412#issuecomment-323371837 , ou couper le son
le fil
https://github.com/notifications/unsubscribe-auth/AGGWB-wQD75N44TGoU6LWQhjED_uhKGUks5sZaKbgaJpZM4MTmSr
.

@urandom

Que se passe-t-il dans le cas où la méthode factice est exportée et que n'importe quel tiers peut implémenter le « type somme » ?

Il existe une différence entre la méthode exportée et le type exporté. Nous semblons nous parler. Pour moi, cela semble fonctionner très bien, sans aucune différence entre les sommes ouvertes et fermées :

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 }

Il n'y a pas d'extension possible en dehors du package, mais les consommateurs du package peuvent utiliser, créer et transmettre les valeurs comme n'importe quel autre.

Vous pouvez intégrer X, ou l'un des types locaux qui le satisfont, en externe, puis le transmettre à une fonction de votre package qui prend un X.

Si cette fonction appelle x, elle panique (si X lui-même était intégré et n'est défini sur rien) ou renvoie une valeur sur laquelle votre code peut fonctionner, mais ce n'est pas ce qui a été transmis par l'appelant, ce qui serait un peu surprenant pour l'appelant (et leur code est déjà suspect s'ils tentent quelque chose comme ça parce qu'ils n'ont pas lu la doc).

Appeler un validateur qui panique avec un message « ne faites pas ça » semble être la façon la moins surprenante de gérer cela et permet à l'appelant de corriger son code.

Si cette fonction appelle x, elle panique […] ou renvoie une valeur sur laquelle votre code peut fonctionner, mais ce n'est pas ce qui a été passé par l'appelant, ce qui serait un peu surprenant pour l'appelant

Comme je l'ai dit plus haut : si vous êtes surpris que votre construction intentionnelle d'une valeur invalide soit invalide, vous devez repenser vos attentes. Mais en tout cas, ce n'est pas le sujet de cette tension particulière de discussion et il serait utile de garder des arguments séparés. Celui-ci concernait @urandom disant que les sommes ouvertes via des interfaces avec des méthodes de balises ne seraient pas introspectives ou utilisables par d'autres packages. Je trouve qu'il s'agit d'une affirmation douteuse, ce serait formidable si elle pouvait être clarifiée.

Le problème est que quelqu'un peut créer un type qui n'est pas dans la somme qui se compile et qui peut être passé à votre package.

Sans ajouter de types de somme appropriés au langage, il existe trois options pour le gérer

  1. ignorer la situation
  2. valider et paniquer/retourner une erreur
  3. essayez de "faire ce que vous voulez dire" en extrayant implicitement la valeur intégrée et en l'utilisant

3 me semble être un étrange mélange de 1 et 2 : je ne vois pas ce qu'il achète.

Je suis d'accord que "Si vous êtes surpris, que votre construction intentionnelle d'une valeur invalide est invalide, vous devez repenser vos attentes", mais, avec 3, il peut être très difficile de remarquer que quelque chose s'est mal passé et même lorsque vous le faites il serait difficile de comprendre pourquoi.

2 semble le meilleur car il protège à la fois le code contre le glissement dans un état invalide et envoie une fusée si quelqu'un se trompe en lui faisant savoir pourquoi il a tort et comment le corriger.

Est-ce que je comprends mal l'intention du modèle ou est-ce que nous l'abordons simplement à partir de philosophies différentes ?

@urandom J'apprécierais également des éclaircissements; Je ne suis pas sûr à 100% de ce que vous essayez de dire non plus.

Le problème est que quelqu'un peut créer un type qui n'est pas dans la somme qui se compile et qui peut être passé à votre package.

Vous pouvez toujours le faire ; en cas de doute, vous pouvez toujours utiliser unsafe, même avec des types de somme vérifiés par le compilateur (et je ne vois pas cela comme une manière qualitativement différente de construire des valeurs invalides d'incorporer quelque chose qui est clairement conçu comme une somme et de ne pas l'initialiser à un valeur valide). La question est "à quelle fréquence cela posera-t-il un problème dans la pratique et quelle sera la gravité de ce problème". À mon avis, avec la solution d'en haut, la réponse est "à peu près jamais et très faible" - vous n'êtes apparemment pas d'accord, ce qui est bien. Mais de toute façon, il ne semble pas y avoir beaucoup d'intérêt à travailler là-dessus - les arguments et les points de vue des deux côtés de ce point particulier doivent être suffisamment clairs et j'essaie d'éviter trop de répétition bruyante et de me concentrer sur le véritable nouveaux arguments. J'ai évoqué la construction ci-dessus pour démontrer qu'il n'y a pas de différence d'exportabilité entre les types de somme de première classe et les sommes émulées via des interfaces. Ne pas montrer qu'ils sont strictement meilleurs à tous égards.

en cas de doute, vous pouvez toujours utiliser unsafe, même avec des types de somme vérifiés par le compilateur (et je ne vois pas cela comme une manière qualitativement différente de construire des valeurs invalides d'incorporer quelque chose qui est clairement conçu comme une somme et de ne pas l'initialiser à un valeur valide).

Je pense que c'est qualitativement différent : lorsque les gens abusent de l'intégration de cette manière (au moins avec proto.Message et les types concrets qui l'implémentent), ils ne se demandent généralement pas si c'est sûr et quels invariants cela pourrait casser . (Les utilisateurs supposent que les interfaces décrivent complètement les comportements requis, mais lorsque les interfaces sont utilisées en tant que types d'union ou de somme, elles ne le font souvent pas. Voir aussi https://github.com/golang/protobuf/issues/364.)

En revanche, si quelqu'un utilise le package unsafe pour définir une variable sur un type auquel il ne peut normalement pas faire référence, il prétend plus ou moins explicitement avoir au moins réfléchi à ce qu'il pourrait casser et pourquoi.

@Merovius Peut-être que je n'ai pas été clair: le fait que le compilateur dise à quelqu'un qu'il a mal utilisé l'intégration est plus un avantage secondaire intéressant.

Le plus grand gain de la fonction de sécurité est qu'elle serait honorée par reflect et représentée dans go/types. Cela donne aux outils et aux bibliothèques plus d'informations avec lesquelles travailler. Il existe de nombreuses façons de simuler des types de somme dans Go, mais ils sont tous identiques au code de type non-somme, donc l'outillage et la bibliothèque ont besoin d'informations hors bande pour savoir qu'il s'agit d'un type de somme et doivent être capables de reconnaître le modèle spécifique utilisé, mais même ces modèles permettent une variation significative.

Cela rendrait également dangereux le seul moyen de créer une valeur invalide : vous avez maintenant un code normal, un code généré et un reflet, les deux derniers étant plus susceptibles de causer un problème car contrairement à une personne, ils ne peuvent pas lire la documentation.

Un autre avantage secondaire de la sécurité signifie que le compilateur dispose de plus d'informations et peut générer un code meilleur et plus rapide.

Il y a aussi le fait qu'en plus de pouvoir remplacer la pseudo-somme par des interfaces, vous pouvez remplacer la pseudo-somme "un de ces types réguliers" comme json.Token ou driver.Value . Ceux-ci sont rares mais ce serait un endroit de moins où interface{} est nécessaire.

Cela rendrait également dangereux le seul moyen de créer une valeur invalide

Je ne pense pas comprendre la définition de "valeur invalide" qui conduit à cette déclaration.

@neild si vous aviez

var v pick {
  None struct{}
  A struct { X int; Y *T}
  B int
}

il serait mis en mémoire comme

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
}

et avec unsafe, vous pouvez définir thePtr même si activeField était 0 ou 2 ou définir une valeur de theInt même si activeField était 0.

Dans les deux cas, cela invaliderait les hypothèses que le compilateur ferait et autoriserait le même type de bogues théoriques que nous pouvons avoir aujourd'hui.

Mais comme @bcmills l'a souligné, si vous utilisez des produits dangereux, vous feriez mieux de savoir ce que vous faites car c'est l'option nucléaire.

Ce que je ne comprends pas, c'est pourquoi unsafe est le seul moyen de créer une valeur invalide.

var t time.Timer

t est une valeur non valide ; t.C n'est pas configuré, l'appel de t.Stop va paniquer, etc. Aucun danger requis.

Certains langages ont des systèmes de types qui se donnent beaucoup de mal pour empêcher la création de valeurs « invalides ». Go n'en fait pas partie. Je ne vois pas comment les syndicats déplacent cette aiguille de manière significative. (Il y a bien sûr d'autres raisons de soutenir les syndicats.)

@neild oui désolé je suis lâche avec mes définitions.

J'aurais dû dire invalide en ce qui concerne les invariants du type somme .

Les types individuels dans la somme peuvent bien sûr être dans un état invalide.

Cependant, le maintien des invariants de type sum signifie qu'ils sont accessibles pour refléter et go/types ainsi que le programmeur, donc les manipuler dans les bibliothèques et les outils maintient cette sécurité et fournit plus d'informations au métaprogrammeur.

@jimmyfrasche , je dis que contrairement à un type somme, qui vous indique tous les types possibles, une interface est opaque en ce sens que vous ne savez pas, ou du moins vous ne pouvez pas utiliser, quelle est la liste des types qui implémentent l'interface sont. Cela rend l'écriture de la partie switch du code un peu aléatoire :

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

Il me semble donc que la plupart des problèmes rencontrés par les gens avec l'émulation de type somme basée sur l'interface peuvent être résolus par péage et/ou convention. Par exemple, si une interface contient une méthode non exportée, il serait trivial de déterminer toutes les implémentations possibles (oui, contournements intentionnels). De même, pour résoudre la plupart des problèmes avec les énumérations basées sur l'iota, une convention simple de "une énumération est un type Foo int avec une déclaration de la forme const ( FooA Foo = iota; FooB; FooC ) " permettrait d'écrire des outils complets et précis pour eux aussi.

Oui, cela n'est pas équivalent aux types de somme réels (entre autres, ils n'obtiendraient pas un support de réflexion de première classe, bien que je ne comprenne pas vraiment à quel point ce serait de toute façon important), mais cela signifie que les solutions existantes apparaissent, de mon POV, mieux qu'ils ne sont souvent peints. Et selon l'OMI, il vaudrait la peine d'explorer cet espace de conception avant de les mettre réellement dans Go 2 - du moins s'ils sont vraiment si importants pour les gens.

(et je tiens à souligner à nouveau que je suis conscient des avantages des types de somme, il n'est donc pas nécessaire de les reformuler à mon avantage. Je ne les pèse tout simplement pas aussi lourdement que les autres, je vois aussi les inconvénients et donc arriver à des conclusions différentes sur les mêmes données)

@Merovius c'est une bonne position.

Le support de reflect permettrait aux bibliothèques ainsi qu'aux outils hors ligne - linters, générateurs de code, etc.

Quoi qu'il en soit, c'est une bonne idée à explorer, alors explorons-la.

Pour récapituler les familles de pseudosommes les plus courantes en Go sont : (approximativement par ordre d'occurrence)

  • const/iota enum.
  • Interface avec méthode de balise pour la somme sur les types définis dans le même package.
  • *T pour un T optionnel
  • struct avec une énumération dont la valeur détermine quels champs peuvent être définis (lorsque l'énumération est un booléen et qu'il n'y a qu'un autre champ, c'est un autre type de T facultatif)
  • interface{} qui est limité à un sac d'un ensemble fini de types.

Tous ces éléments peuvent être utilisés à la fois pour les types somme et pour les types non-somme. Les deux premiers sont si rarement utilisés pour autre chose qu'il peut être logique de supposer simplement qu'ils représentent des types de somme et acceptent les faux positifs occasionnels. Pour les sommes d'interface, cela pourrait le limiter à une méthode non exportée sans paramètres ni retours et sans corps sur aucun membre. Pour les énumérations, il serait logique de ne les reconnaître que lorsqu'elles ne sont que Type = iota afin qu'elles ne se déclenchent pas lorsque iota est utilisé dans le cadre d'une expression.

*T pour un T optionnel serait vraiment difficile à distinguer d'un pointeur normal. Cela pourrait être donné la convention type O = *T . Ce serait possible à détecter, bien qu'un peu difficile puisque le nom d'alias ne fait pas partie du type. type O *T serait plus facile à détecter mais plus difficile à utiliser dans le code. D'un autre côté, tout ce qui doit être fait est essentiellement intégré au type, il n'y a donc pas grand-chose à gagner en outillage à le reconnaître. Ignorons simplement celui-ci. (Les génériques permettraient probablement quelque chose du genre type Optional(T) *T ce qui simplifierait le "marquage" de ceux-ci).

La structure avec une énumération serait difficile à raisonner dans l'outillage, quels champs vont avec quelle valeur pour l'énumération ? Nous pourrions simplifier cela à la convention qu'il doit y avoir un champ par membre dans l'énumération et que la valeur enum et la valeur du champ doivent être les mêmes, par exemple :

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

Cela n'obtiendrait pas de types facultatifs, mais nous pourrions cas particulier "2 champs, le premier est bool" dans le module de reconnaissance.

Utiliser un interface{} pour une somme d'argent serait impossible à détecter sans un commentaire magique comme //gosum: int, float64, string, Foo

Alternativement, il pourrait y avoir un package spécial avec les définitions suivantes :

package sum
type (
  Type struct{}
  Enum int
  OneOf interface{}
)

et ne reconnaissent les énumérations que si elles sont de la forme type MyEnum sum.Enum , ne reconnaissent les interfaces et les structures que si elles intègrent sum.Type , et ne reconnaissent que les sacs de saisie interface{} comme type GrabBag sum.OneOf (mais cela nécessiterait toujours un commentaire reconnaissable par la machine pour expliquer ses commentaires). Cela aurait les avantages et les inconvénients suivants :
Avantages

  • explicite dans le code : s'il est ainsi marqué, il s'agit à 100% d'un type somme, pas de faux positifs.
  • ces définitions pourraient avoir une documentation expliquant ce qu'elles signifient et la documentation du package pourrait être liée à des outils pouvant être utilisés avec ces types
  • certains auraient une certaine visibilité en reflet
    Les inconvénients
  • Beaucoup de faux négatifs de l'ancien code et de la stdlib (qui ne les utiliserait pas).
  • Ils devraient être utilisés pour être utiles, donc l'adoption serait lente et n'atteindrait probablement jamais 100% et l'efficacité des outils qui reconnaîtraient ce package spécial serait fonction de l'adoption, si intéressante bien qu'expérimentée mais probablement irréaliste.

Indépendamment de laquelle de ces deux manières est utilisée pour identifier les types de somme, supposons qu'elles ont été reconnues et passons à l'utilisation de ces informations pour voir quel type d'outillage nous pouvons construire.

Nous pouvons grossièrement regrouper l'outillage en génératif (comme stringer) et introspectif (comme golint).

Le code génératif le plus simple serait un outil pour remplir une instruction switch avec les cas manquants. Cela pourrait être utilisé par les éditeurs. Une fois qu'un type de somme est identifié comme un type de somme, c'est trivial (un peu fastidieux mais la logique de génération réelle sera la même avec ou sans prise en charge du langage).

Dans tous les cas, il serait possible de générer une fonction qui valide l'invariant « un de ».

Pour les énumérations, il pourrait y avoir plus d'outils comme stringer. Dans https://github.com/golang/go/issues/19814#issuecomment -291002852 j'ai mentionné quelques possibilités.

Le plus grand outil génératif est le compilateur qui pourrait produire un meilleur code machine avec cette information, mais bon.

Je ne peux pas penser à d'autres pour le moment. Y a-t-il quelque chose sur la liste de souhaits de quelqu'un?

Pour l'introspection, le candidat évident est l'exhaustivité du peluchage. Sans prise en charge linguistique, il existe en fait deux types de linting différents requis

  1. s'assurer que tous les états possibles sont traités
  2. s'assurer qu'aucun état invalide n'est créé (ce qui invaliderait le travail effectué par 1)

1 est trivial, mais cela nécessiterait tous les états possibles et un cas par défaut car 2 ne peut pas être vérifié à 100% (même en ignorant unsafe) et vous ne pouvez pas vous attendre à ce que tout le code utilisant votre code exécute ce linter de toute façon.

2 ne pouvait pas vraiment suivre les valeurs en réfléchissant ou en identifiant tout le code qui pourrait générer un état invalide pour la somme, mais il pourrait détecter de nombreuses erreurs simples, comme si vous incorporiez un type de somme puis appeliez une fonction avec, cela pourrait dire "vous avez écrit pkg.F(v) mais vous vouliez dire pkg.F(v.EmbeddedField)" ou "vous avez passé 2 à pkg.F, utilisez pkg.B". Pour la structure, cela ne pouvait pas faire grand-chose pour imposer l'invariant selon lequel un champ est défini à la fois, sauf dans des cas vraiment évidents comme "vous activez lequel et dans le cas X, vous définissez le champ F sur une valeur non nulle ". Il pourrait insister pour que vous utilisiez la fonction de validation générée lors de l'acceptation de valeurs provenant de l'extérieur du package.

L'autre gros problème serait d'apparaître dans godoc. godoc regroupe déjà const/iota et #20131 aiderait avec les pseudosums d'interface. Il n'y a vraiment rien à voir avec la version struct qui n'est pas explicite dans la définition autre que de spécifier l'invariant.

ainsi que des outils hors ligne : linters, générateurs de code, etc.

Non. Les informations statiques sont présentes, vous n'avez pas besoin du système de type (ou de refléter) pour cela, la convention fonctionne bien. Si votre interface contient des méthodes non exportées, n'importe quel outil statique peut choisir de traiter cela comme une somme fermée (car c'est effectivement le cas) et de faire toute analyse/codegen que vous souhaitez. De même avec la convention des iota-enums.

reflect est pour les informations de type d'

(également, FTR, selon le cas d'utilisation, vous pouvez toujours avoir un outil qui utilise les informations statiquement connues pour générer les informations d'exécution nécessaires - par exemple, il pourrait énumérer les types qui ont la méthode de balise requise et générer une table de recherche pour eux. Mais je ne comprends pas ce que serait un cas d'utilisation, il est donc difficile d'évaluer la faisabilité de cela).

Donc, ma question était intentionnellement : quel serait le cas d'utilisation d'avoir ces informations disponibles au moment de l'exécution ?

Quoi qu'il en soit, c'est une bonne idée à explorer, alors explorons-la.

Quand j'ai dit « l'explorer », je ne voulais pas dire « les énumérer et en discuter dans le vide », je voulais dire « mettre en œuvre des outils qui utilisent ces conventions et voir à quel point elles sont utiles/nécessaires/pratiques ».

L'avantage des rapports d'expérience , c'est qu'ils sont basés sur l'expérience : vous aviez besoin de faire quelque chose, vous avez essayé d'utiliser des mécanismes existants pour cela, vous avez trouvé qu'ils ne suffisaient pas. Cela concentre la discussion sur le cas d'utilisation réel (comme dans "le cas dans lequel il a été utilisé") et permet d'évaluer toutes les solutions proposées par rapport à celles-ci, par rapport aux alternatives essayées et de voir comment une solution ne présenterait pas les mêmes pièges.

Vous sautez la partie "essayer d'utiliser les mécanismes existants pour cela". Vous voulez avoir des contrôles d'exhaustivité statiques des sommes (problème). Ecrivez un outil qui trouve des interfaces avec des méthodes non exportées, effectue les vérifications d'exhaustivité pour tout type de commutateur dans lequel il est utilisé, utilisez cet outil pendant un certain temps (utilisez les mécanismes existants pour cela). Écrivez là où il a échoué.

Je réfléchissais à haute voix et j'ai commencé à travailler sur un outil de reconnaissance statique basé sur ces pensées que les outils peuvent utiliser. J'étais, je suppose, implicitement à la recherche de commentaires et d'autres idées (et cela a payé en générant les informations nécessaires à la réflexion).

FWIW, si je vous en faisais, j'ignorerais simplement les cas complexes et me concentrerais sur les choses qui fonctionnent : a) les méthodes non exportées dans les interfaces et b) les simples const-iota-enums, qui ont comme type sous-jacent int et un seul const- déclaration du format attendu. L'utilisation d'un outil nécessiterait l'utilisation de l'une de ces deux solutions de contournement, mais IMO, c'est bien (pour utiliser l'outil de compilation, vous devez également utiliser explicitement des sommes, donc cela semble correct).

C'est certainement un bon point de départ et il peut être composé après l'avoir exécuté sur un grand nombre de packages et vu combien de faux positifs/négatifs il y a

https://godoc.org/github.com/jimmyfrasche/closed

Encore beaucoup de travail en cours. Je ne peux pas promettre que je n'aurai pas à ajouter de paramètres supplémentaires au constructeur. Il a probablement plus de bugs que de tests. Mais c'est assez bon pour jouer avec.

Il existe un exemple d'utilisation dans cmds/closed-exporer qui répertorie également tous les types fermés détectés dans un package spécifié par son chemin d'importation.

J'ai simplement commencé à détecter toutes les interfaces avec des méthodes non exportées, mais elles sont assez courantes et alors que certaines étaient clairement des types de somme, d'autres ne l'étaient clairement pas. Si je me limitais à la convention de méthode de balise vide, j'ai perdu beaucoup de types de somme, j'ai donc décidé d'enregistrer les deux séparément et de généraliser le package un peu au-delà des types de somme aux types fermés.

Avec les énumérations, je suis allé dans l'autre sens et j'ai juste enregistré chaque const non-bitset d'un type défini. Je prévois également d'exposer les bitsets découverts.

Il ne détecte pas encore les structures facultatives ou les interfaces vides définies car elles nécessiteront une sorte de commentaire de marqueur, mais il fait un cas particulier de ceux de la stdlib.

J'ai simplement commencé à détecter toutes les interfaces avec des méthodes non exportées, mais elles sont assez courantes et alors que certaines étaient clairement des types de somme, d'autres ne l'étaient clairement pas.

Je trouverais utile si vous pouviez fournir certains des exemples qui ne l'étaient pas.

@Merovius désolé de ne pas avoir gardé de liste. Je les ai trouvés en exécutant stdlib.sh (dans cmds/closed-explorer). Si je tombe sur un bon exemple la prochaine fois que je joue avec ça, je le posterai.

Ceux que je ne considère pas comme des types de somme étaient tous des interfaces non exportées qui étaient utilisées pour brancher l'une des nombreuses implémentations : rien ne se souciait de ce qu'il y avait dans l'interface, juste qu'il y avait quelque chose qui le satisfaisait. Ils étaient très utilisés comme des interfaces et non comme des sommes, mais ils étaient simplement fermés parce qu'ils n'étaient pas exportés. C'est peut-être une distinction sans différence, mais je peux toujours changer d'avis après une enquête plus approfondie.

@jimmyfrasche Je dirais que ceux-ci devraient être correctement traités comme des sommes fermées. Je dirais que s'ils ne se soucient pas du type dynamique (c'est-à-dire n'appelant que les méthodes de l'interface), alors un linter statique ne se plaindrait pas, car "tous les commutateurs sont exhaustifs" - il n'y a donc aucun inconvénient à les traiter comme des sommes fermées. Si, OTOH, ils se plaindre taperais-switch parfois et laisser un cas, être correct - ce serait exactement le genre de chose que le linter est censé attraper.

J'aimerais ajouter un bon mot pour explorer comment les types d'union pourraient réduire l'utilisation de la mémoire. J'écris un interpréteur en Go et j'ai un type Value qui est nécessairement implémenté en tant qu'interface car les valeurs peuvent être des pointeurs vers différents types. Cela signifie vraisemblablement qu'une []Value prend deux fois plus de mémoire par rapport à l'emballage du pointeur avec une petite balise bit comme vous pourriez le faire en C. Cela semble beaucoup ?

La spécification du langage n'a pas besoin de le mentionner, mais il semble que réduire de moitié l'utilisation de la mémoire d'un tableau pour certains petits types d'union pourrait être un argument assez convaincant pour les unions? Cela vous permet de faire quelque chose qui, à ma connaissance, est impossible à faire dans Go aujourd'hui. En revanche, l'implémentation d'unions au-dessus des interfaces pourrait aider à l'exactitude et à la compréhension du programme, mais ne fait rien de nouveau au niveau de la machine.

Je n'ai effectué aucun test de performance ; indiquant simplement une direction pour la recherche.

Vous pouvez implémenter une valeur en tant que unsafe.Pointer à la place.

Le 6 février 2018 à 15 h 54, "Brian Slesinsky" [email protected] a écrit :

J'aimerais ajouter un bon mot pour explorer comment les types d'union pourraient réduire
utilisation de la mémoire. J'écris un interpréteur dans Go et j'ai un type Value qui
est nécessairement implémenté comme une interface car les valeurs peuvent être des pointeurs
à différents types. Cela signifie probablement qu'une []Valeur prend deux fois plus
mémoire par rapport à l'emballage du pointeur avec une petite balise bit comme vous pourriez le faire
en C. Cela semble beaucoup?

La spécification de la langue n'a pas besoin de le mentionner, mais cela semble couper la mémoire
l'utilisation d'un tableau en deux pour certains petits types d'union pourrait être une jolie
argument convaincant pour les syndicats? Cela vous permet de faire quelque chose qui, pour autant que je
savoir qu'il est impossible de le faire en Go aujourd'hui. En revanche, la mise en place d'unions sur
haut des interfaces pourrait aider à l'exactitude du programme et
intelligibilité, mais ne fait rien de nouveau au niveau de la machine.

Je n'ai effectué aucun test de performance ; indiquant juste une direction pour
recherche.

-
Vous recevez ceci parce que vous avez été mentionné.
Répondez directement à cet e-mail, consultez-le sur GitHub
https://github.com/golang/go/issues/19412#issuecomment-363561070 , ou couper le son
le fil
https://github.com/notifications/unsubscribe-auth/AGGWBz-L3t0YosVIJmYNyf2iQ-YgIXLGks5tSLv9gaJpZM4MTmSr
.

@skybrian Cela semble assez présomptueux en ce qui concerne la mise en œuvre des types de somme. Cela nécessite non seulement des types de somme, mais aussi que le compilateur reconnaisse le cas particulier des seuls pointeurs dans une somme et les optimise en tant que pointeur compacté - et cela nécessite que le GC sache combien de bits de balise sont requis dans le pointeur , pour les masquer. Comme, je ne vois pas vraiment ces choses se produire, TBH.

Cela vous laisse avec : Les types de somme seront probablement des unions étiquetées et prendront probablement autant d'espace dans une tranche que maintenant. À moins que la tranche ne soit homogène, vous pouvez également utiliser un type de tranche plus spécifique dès maintenant.

Donc voilà. Dans des cas très particuliers, vous pourrez peut-être économiser un peu de mémoire, si vous optimisez spécifiquement pour eux, mais il semblerait que vous puissiez également optimiser manuellement pour cela, si vous en avez réellement besoin.

@DemiMarie unsafe.Pointer ne fonctionne pas sur App Engine, et dans tous les cas, il ne vous laissera pas emballer des bits sans gâcher le ramasse-miettes. Même si c'était possible, ce ne serait pas portable.

@Merovius oui, cela nécessite de modifier le runtime et le ramasse-miettes pour comprendre les dispositions de la mémoire compressée. C'est un peu le point; les pointeurs sont gérés par le runtime Go, donc si vous voulez faire mieux que les interfaces de manière sûre, vous ne pouvez pas le faire dans une bibliothèque ou dans le compilateur.

Mais j'admettrai volontiers que l'écriture d'un interpréteur rapide est un cas d'utilisation rare. Peut-être y en a-t-il d'autres ? Il semble qu'un bon moyen de motiver une fonctionnalité de langage est de trouver des choses qui ne peuvent pas être facilement faites dans Go aujourd'hui.

C'est vrai.

Je pense que le go n'est pas la meilleure langue pour écrire un interprète,
en raison de la dynamique folle de ces logiciels. Si vous avez besoin de hautes performances,
vos boucles chaudes doivent être écrites en assembleur. Y a-t-il une raison pour laquelle vous
besoin d'écrire un interpréteur qui fonctionne sur App Engine ?

Le 6 février 2018 à 18h15, "Brian Slesinsky" [email protected] a écrit :

@DemiMarie https://github.com/demimarie unsafe.Pointer ne fonctionne pas sur l'application
Moteur, et de toute façon, ça ne va pas te laisser emballer des morceaux sans
gâcher le ramasse-miettes. Même si c'était possible, ce ne serait pas
portable.

@metrovius oui, cela nécessite de changer le runtime et le ramasse-miettes
pour comprendre les dispositions de mémoire compressée. C'est un peu le point; les pointeurs sont
géré par le runtime Go, donc si vous voulez faire mieux que les interfaces dans un
manière sûre, vous ne pouvez pas le faire dans une bibliothèque ou dans le compilateur.

Mais j'admettrai volontiers qu'écrire un interpréteur rapide est une utilisation peu courante
Cas. Peut-être y en a-t-il d'autres ? Cela semble être un bon moyen de motiver un
La fonction de langage est de trouver des choses qui ne peuvent pas être facilement faites dans Go aujourd'hui.

-
Vous recevez ceci parce que vous avez été mentionné.
Répondez directement à cet e-mail, consultez-le sur GitHub
https://github.com/golang/go/issues/19412#issuecomment-363598572 , ou couper le son
le fil
https://github.com/notifications/unsubscribe-auth/AGGWB65jRKg_qVPWTiq8LbGk3YM1RUasks5tSN0tgaJpZM4MTmSr
.

Je trouve la proposition de @rogpeppe assez séduisante. Je me demande également s'il est possible de débloquer des avantages supplémentaires pour accompagner ceux déjà identifiés par @griesemer.

La proposition dit : "L'ensemble de méthodes du type somme contient l'intersection de l'ensemble de méthodes
de tous ses types de composants, à l'exclusion des méthodes qui ont le même
nom mais des signatures différentes.".

Mais un type est plus qu'un simple ensemble de méthodes. Et si le type somme supportait l'intersection des opérations supportées par ses types de composants ?

Par exemple, considérez :

var x int|float64

L'idée étant que ce qui suit fonctionnerait.

x += 5

Cela équivaudrait à écrire le commutateur de type complet :

switch i := x.(type) {
case int:
    x = i + 5
case float64:
    x = i + 5
}

Une autre variante implique un changement de type où un type de composant est lui-même un type somme.

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

De plus, je pense qu'il y a potentiellement une très belle synergie entre les types sum et un système générique qui utilise des contraintes de type.

var x int|float64

Qu'en est-il de var x, y int | float64 ? Quelles sont les règles ici, lors de l'ajout de ceux-ci? Quelle conversion avec perte est effectuée (et pourquoi) ? Quel sera le type de résultat ?

Go ne fait pas exprès de conversions automatiques dans les expressions (comme le fait C) - ces questions ne sont pas faciles à répondre et entraînent des bogues.

Et pour encore plus de fun :

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)

Tous les int , string et rune ont un opérateur + ; quelle est l'impression ci-dessus, pourquoi et surtout, comment le résultat ne peut-il

Qu'en est-il de var x, y int | float64 ? Quelles sont les règles ici, lors de l'ajout de ceux-ci? Quelle conversion avec perte est effectuée (et pourquoi) ? Quel sera le type de résultat ?

@Merovius aucune conversion avec perte n'est implicitement effectuée, bien que je puisse voir comment ma formulation pourrait donner cette impression désolé. Ici, un simple x + y ne compilerait pas car il implique une possible conversion implicite. Mais l'un des éléments suivants compilerait :

z = int(x) + int(y)
z = float64(x) + float64(y)

De même, votre exemple xyz ne compilerait pas car il nécessite d'éventuelles conversions implicites.

Je pense que "pris en charge l'intersection des opérations prises en charge" sonne bien mais ne traduit pas tout à fait ce que j'avais l'intention de faire. L'ajout de quelque chose comme "compilation pour tous les types de composants" aide à décrire comment je pense que cela pourrait fonctionner.

Un autre exemple est si tous les types de composants sont des tranches et des cartes. Ce serait bien de pouvoir appeler len sur le type sum sans avoir besoin d'un commutateur de type.

Tous les int, string et rune ont un opérateur + ; quelle est l'impression ci-dessus, pourquoi et surtout, comment le résultat ne peut-il pas être complètement déroutant ?

Je voulais juste ajouter que mon "Et si le type somme prenait en charge l'intersection des opérations prises en charge par ses types de composants ?" a été inspiré par la description d'un type par Go Spec comme "Un type détermine un ensemble de valeurs ainsi que des opérations et des méthodes spécifiques à ces valeurs.".

Le point que j'essayais de faire valoir est qu'un type est plus que de simples valeurs et méthodes, et donc un type somme pourrait essayer de capturer les points communs de ces autres éléments à partir de ses types de composants. Cet "autre truc" est plus nuancé qu'un simple ensemble d'opérateurs.

Un autre exemple est la comparaison avec nil :

var x []int | []string
fmt.Println(x == nil)  // Prints true
x = []string(nil)
fmt.Println(x == nil)  // Still prints true

Les deux types de composants sont Au moins un type est comparable à nil, nous permettons donc au type sum d'être comparé à nil sans changement de type. Bien sûr, cela est quelque peu en contradiction avec le comportement actuel des interfaces, mais ce n'est peut-être pas une mauvaise chose selon https://github.com/golang/go/issues/22729

Edit: les tests d'égalité sont un mauvais exemple ici car je pense qu'ils devraient être plus permissifs et ne nécessiter qu'une correspondance potentielle d'un ou plusieurs types de composants. Affectation des miroirs à cet égard.

Le problème est que le résultat a) aura les mêmes problèmes que les conversions automatiques ou b) aura une portée extrêmement (et confuse pour l'OMI) - à savoir, tous les opérateurs ne travailleraient qu'avec des littéraux non typés, au mieux.

J'ai également un autre problème, à savoir qu'autoriser cela limitera encore plus leur robustesse contre l'évolution de leurs types constitutifs - désormais, les seuls types que vous pourriez jamais ajouter tout en préservant la compatibilité descendante sont ceux qui autorisent toutes les opérations de leurs types constitutifs.

Tout cela me semble vraiment désordonné, pour un très petit (voire aucun) avantage tangible.

désormais, les seuls types que vous puissiez ajouter tout en préservant la compatibilité descendante sont ceux qui autorisent toutes les opérations de leurs types constitutifs.

Oh et pour être explicite sur celui-ci aussi : cela implique que vous ne pouvez

@Merovius note qu'une variante du problème de compatibilité existe déjà avec la proposition d'origine car "L'ensemble de méthodes du type somme contient l'intersection de l'ensemble de méthodes
de tous ses types de composants". Donc, si vous ajoutez un nouveau type de composant qui n'implémente pas cet ensemble de méthodes, ce sera un changement non rétrocompatible.

Oh et pour être explicite sur celui-ci aussi : cela implique que vous ne pouvez jamais décider d'étendre un paramètre ou un type de retour ou une variable ou… d'un type singleton à une somme. Parce que l'ajout de tout nouveau type fera échouer la compilation de certaines opérations (comme les affectations).l

Le comportement d'affectation resterait tel que décrit par @rogpeppe mais dans l'ensemble, je ne suis pas sûr de comprendre ce point.

Si rien d'autre, je pense que la proposition originale de rogpeppe doit être clarifiée en ce qui concerne le comportement du type sum en dehors d'un commutateur de type. L'affectation et l'ensemble de méthodes sont couverts, mais c'est tout. Et l'égalité ? Je pense que nous pouvons faire mieux que ce que fait l'interface{} :

var x int | float64
fmt.Println(x == "hello")  // compilation error?
x = 0.0
fmt.Println(x == 0) // true or false?  I vote true :-)

Donc, si vous ajoutez un nouveau type de composant qui n'implémente pas cet ensemble de méthodes, ce sera un changement non rétrocompatible.

Vous pouvez toujours ajouter des méthodes, mais vous ne pouvez pas surcharger les opérateurs pour travailler sur de nouveaux types. C'est précisément la différence - dans leur proposition, vous ne pouvez appeler les méthodes courantes que sur une valeur de somme (ou lui affecter), à moins que vous ne la déballiez avec un type-assertion/-switch. Ainsi, tant que le type que vous ajoutez a les méthodes nécessaires, ce ne serait pas un changement décisif. Dans votre proposition, ce serait toujours un changement décisif, car les utilisateurs pourraient utiliser des opérateurs que vous ne pouvez pas surcharger.

(vous voudrez peut-être souligner que l'ajout de types à la somme serait toujours un changement décisif, car les commutateurs de type n'auraient pas le nouveau type. C'est exactement pourquoi je ne suis pas non plus en faveur de la proposition d'origine - je je ne veux pas de sommes fermées pour cette raison même)

Le comportement d'attribution resterait tel que décrit par @rogpeppe

Leur proposition ne parle que d'affectation à une valeur-somme, je parle d'affectation à partir d' une valeur-somme (à l'une de ses parties constitutives). Je suis d'accord que leur proposition ne le permet pas non plus, mais la différence est que leur proposition ne consiste pas à ajouter cette possibilité. c'est-à-dire que mon argument est exactement que la sémantique que vous suggérez n'est pas particulièrement bénéfique, car en pratique, l'utilisation qu'elles obtiennent est sévèrement limitée.

fmt.Println(x == "hello") // compilation error?

Cela serait probablement ajouté à leur proposition également. Nous avons déjà un cas spécial équivalent pour les interfaces , à savoir

Une valeur x de type X sans interface et une valeur t de type interface T sont comparables lorsque les valeurs de type X sont comparables et que X implémente T. Elles sont égales si le type dynamique de t est identique à X et la valeur dynamique de t est égale à x .

fmt.Println(x == 0) // true or false? I vote true :-)

Vraisemblablement faux. Étant donné que le même

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)

devrait être une erreur de compilation (comme nous l'avons conclu ci-dessus), cette question n'a vraiment de sens que lorsqu'elle est comparée à des constantes numériques non typées. À ce stade, cela dépend en quelque sorte de la façon dont cela est ajouté à la spécification. Vous pourriez argumenter que cela est similaire à l'affectation d'une constante à un type d'interface et qu'elle devrait donc avoir son type par défaut (et la comparaison serait alors fausse). Quelle OMI est plus que bien, nous acceptons déjà

Cependant, répondre à cette question de toute façon ne nécessite pas d'autoriser toutes les expressions utilisant des types de somme qui pourraient avoir un sens pour les parties constituantes.

Mais je le répète : je ne plaide pas en faveur d'une autre proposition de sommes. Je m'oppose à celui-ci.

fmt.Println(x == "hello") // compilation error?

Cela serait probablement ajouté à leur proposition également.

Correction : La spécification couvre déjà cette erreur de compilation, étant donné qu'elle contient l'instruction

Dans toute comparaison, le premier opérande doit être assignable au type du deuxième opérande, ou vice versa.

@Merovius, vous faites de bons points sur ma variante de la proposition. Je m'abstiendrai d'en débattre plus avant, mais j'aimerais approfondir la comparaison avec la question 0 un peu plus car elle s'applique également à la proposition originale.

fmt.Println(x == 0) // true or false? I vote true :-)

Vraisemblablement faux. Étant donné que le même

var x int|float64 = 0.0
y := 0
fmt.Println(x == y)
devrait être une erreur de compilation (comme nous l'avons conclu ci-dessus),

Je ne trouve pas cet exemple très convaincant car si vous modifiez la première ligne en var x float64 = 0.0 vous pouvez utiliser le même raisonnement pour affirmer que comparer un float64 à 0 devrait être faux. (Points mineurs : (a) Je suppose que vous vouliez dire float64(0) sur la première ligne, puisque 0.0 est assignable à int. (b) x==y ne devrait pas être une erreur de compilation dans votre exemple. Il devrait cependant afficher false.)

Je pense que votre idée que "que cela soit similaire à l'attribution d'une constante à un type d'interface et qu'elle devrait donc avoir son type par défaut" est plus convaincante (en supposant que vous vouliez dire le type somme), donc l'exemple serait :

var x,y int|float64 = float64(0), 0
fmt.Println(x == y) // faux

Je dirais quand même que x == 0 devrait être vrai. Mon modèle mental est qu'un type est donné à 0 le plus tard possible. Je me rends compte que cela est contraire au comportement actuel des interfaces et c'est précisément pourquoi j'en ai parlé. Je suis d'accord que cela n'a pas conduit à "beaucoup de flou", mais le problème similaire de comparer les interfaces à zéro a entraîné beaucoup de confusion. Je crois que nous verrions une quantité similaire de confusion pour la comparaison avec 0 si les types de somme venaient à exister et que l'ancienne sémantique d'égalité était conservée.

Je ne trouve pas cet exemple très convaincant car si vous modifiez la première ligne en var x float64 = 0.0, vous pouvez utiliser le même raisonnement pour affirmer que comparer un float64 à 0 devrait être faux.

Je n'ai pas dit que cela devrait , j'ai dit que ce serait probablement le

Notez que comparer float64(0) à int(0) (c'est-à-dire l'exemple avec la somme remplacée par var x float64 = 0.0 ) n'est pas false , cependant, c'est un temps de compilation erreur (comme il se doit). C'est exactement mon propos ; votre proposition n'est vraiment utile que lorsqu'elle est combinée avec des constantes non typées, car pour tout le reste, elle ne compilerait pas.

(a) Je suppose que vous vouliez dire float64(0) sur la première ligne, puisque 0.0 est attribuable à int.

Bien sûr (je supposais une sémantique plus proche du "type par défaut" actuel pour les expressions constantes, mais je suis d'accord que la formulation actuelle ne l'implique pas).

(b) x==y ne devrait pas être une erreur de compilation dans votre exemple. Il devrait imprimer false cependant.)

Non, il devrait s'agir d'une erreur de compilation. Vous avez dit que l'opération e1 == y , avec e1 étant une expression de type somme, devrait être autorisée si et seulement si l'expression serait compilée avec n'importe quel choix de type de constituant. Étant donné que dans mon exemple, x a le type int|float64 et y a le type int et étant donné que float64 et int ne sont pas comparables, cette condition est clairement violée.

Pour effectuer cette compilation, vous devez supprimer la condition selon laquelle toute expression typée constituante de substitution doit également être compilée; à quel point nous sommes dans la situation de devoir mettre en place des règles sur la façon dont les types sont promus ou convertis lorsqu'ils sont utilisés dans ces expressions (également connu sous le nom de "désordre C").

Le consensus passé était que les types somme n'ajoutaient pas grand-chose aux types d'interface.

Ce n'est pas le cas pour la plupart des cas d'utilisation de Go : services et utilitaires réseau triviaux. Mais une fois que le système grandit, il y a de fortes chances qu'ils soient utiles.
J'écris actuellement un service fortement distribué avec des garanties de cohérence des données mises en œuvre via beaucoup de logique et je me suis retrouvé dans une situation où ils seraient utiles. Ces NPD sont devenus trop ennuyeux à mesure que le service grandissait et que nous ne voyons pas de moyen sensé de le diviser.
Je veux dire que les garanties du système de type Go sont un peu trop faibles pour quelque chose de plus complexe que les services réseau primitifs typiques.

Mais, l'histoire avec Rust montre que c'est une mauvaise idée d'utiliser des types de somme pour le NPD et la gestion des erreurs comme ils le font dans Haskell : il existe un workflow impératif naturel typique et l'approche Haskellish ne s'y intègre pas bien.

Exemple

considérez la fonction iotuils.WriteFile -like dans le pseudocode. Le flux impératif ressemblerait à ceci

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

et à quoi ça ressemble dans 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)

c'est sûr mais moche.

Et ma proposition :

type result[T, Err] oneof {
    default T
    Error Err
}

et à quoi pourrait ressembler le programme ( 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

Ici, la branche par défaut est anonyme et la branche d'erreur est accessible avec .Error (une fois connue, le résultat est Erreur). Une fois qu'il est connu que le fichier a été ouvert avec succès, l'utilisateur peut y accéder via la variable elle-même. En premier, si nous nous assurons que file été ouvert avec succès ou si nous sortons autrement (et donc d'autres instructions savent que le fichier n'est pas une erreur).

Comme vous le voyez, cette approche préserve le flux impératif et offre une sécurité de type. La gestion du NPD peut être effectuée de la même manière :

type Reference[T] oneof {
    default T
    nil
}
// Reference[T] = *T

la manipulation est similaire au résultat

@sirkon , votre exemple de Rust ne me convainc pas qu'il y a quelque chose qui ne va pas avec les types de somme simples comme dans Rust. Au contraire, cela suggère que la correspondance de modèles sur les types de somme pourrait être rendue plus semblable à celle de Go en utilisant les instructions if . Quelque chose comme:

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

(Dans l'esprit des types sum, ce serait une erreur de compilation si le compilateur ne peut pas prouver qu'une correspondance inconditionnelle réussit toujours car il reste exactement un cas.)

Pour la vérification des erreurs de base, cela ne semble pas être une amélioration par rapport aux valeurs de retour multiples, car il s'agit d'une ligne plus longue et déclare une variable locale de plus. Cependant, il serait mieux adapté à plusieurs cas (en ajoutant plus d'instructions if) et le compilateur pourrait vérifier que tous les cas sont traités.

@sirkon

Ce n'est pas le cas pour la plupart des cas d'utilisation de Go : services et utilitaires réseau triviaux. Mais une fois que le système grandit, il y a de fortes chances qu'ils soient utiles.
[…]
Je veux dire que les garanties du système de type Go sont un peu trop faibles pour quelque chose de plus complexe que les services réseau primitifs typiques.

Des déclarations comme celles-ci sont inutilement conflictuelles et désobligeantes. Ils sont également un peu embarrassants, TBH, car il existe des services extrêmement volumineux et non triviaux écrits en Go. Et étant donné qu'une partie importante de ses développeurs travaille chez Google, vous devez simplement supposer qu'ils savent mieux que vous s'il est approprié d'écrire des services volumineux et non triviaux. Go ne couvre peut-être pas tous les cas d' utilisation (il ne le devrait pas non plus, IMO), mais empiriquement, il ne fonctionne pas uniquement pour les "services réseau primitifs".

La gestion du NPD peut être effectuée de la même manière

Je pense que cela illustre vraiment que votre approche n'ajoute en fait aucune valeur significative. Comme vous le soulignez, cela ajoute simplement une syntaxe différente pour le déréférencement. Mais AFAICT rien n'empêche un programmeur d'utiliser cette syntaxe sur une valeur nulle (ce qui devrait probablement encore paniquer). à savoir chaque programme qui est valide en utilisant *p est valide en utilisant p.T (ou est - il p.default ? Il est difficile de dire ce que votre idée est spécifiquement) et vice versa.

Le seul avantage que les types de somme peuvent ajouter à la gestion des erreurs et aux déréférencements nil est que le compilateur peut vous obliger à prouver que l'opération est sûre en faisant correspondre le modèle. Une proposition qui passe sous silence que l' application ne semble pas apporter d' importantes nouvelles choses à la table (sans doute, il est pire que d' utiliser des sommes ouvertes via des interfaces), alors qu'une proposition qui ne comprend que c'est exactement ce que vous décrivez comme « laid ».

@Merovius

Et étant donné qu'une grande partie de ses développeurs travaillent chez Google, vous devez simplement supposer qu'ils savent mieux que vous,

Heureux les croyants.

Comme vous le soulignez, cela ajoute simplement une syntaxe différente pour le déréférencement.

de nouveau

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

cette variable intermédiaire est ce qui m'oblige à quitter cette idée. Comme vous le voyez, mon approche est spécifiquement pour la gestion des erreurs et du néant. Ces petites tâches sont trop importantes et méritent une attention particulière IMO.

@sirkon Vous avez apparemment très peu d'intérêt à parler aux gens face à face. Je vais en rester là.

Gardons nos conversations civiles et évitons les commentaires non constructifs. On peut être en désaccord sur les choses, mais tout de même maintenir un discours respectable. https://golang.org/conduct.

Et étant donné qu'une grande partie de ses développeurs travaillent chez Google, vous devez simplement supposer qu'ils savent mieux que vous

Je doute que vous puissiez faire ce genre d'argument à Google.

@hasufell ce gars vient d'Allemagne où ils n'ont pas de grandes entreprises informatiques avec des entretiens de merde pour pomper l'ego de l'intervieweur et la gestion des mastodontes, c'est pourquoi ces mots.

@sirkon il en va de même pour vous. Les arguments ad-hominem et sociaux ne sont pas utiles. C'est plus qu'un problème de CoC. J'ai vu ce genre d'"arguments sociaux" apparaître assez fréquemment lorsqu'il s'agit du langage de base : les développeurs du compilateur savent mieux, les concepteurs de langage savent mieux, les gens de Google savent mieux.

Non, ils ne le font pas. Il n'y a pas d'autorité intellectuelle. Il n'y a qu'un pouvoir de décision. Passer à autre chose.

Cacher quelques commentaires pour réinitialiser la conversation (et merci @agnivade d' avoir essayé de la remettre sur les rails).

Chers amis, veuillez considérer votre rôle dans ces discussions à la lumière de nos valeurs Gopher : tout le monde dans la communauté a une perspective à apporter, et nous devons nous efforcer d'être respectueux et charitables dans la façon dont nous interprétons et répondons les uns aux autres.

Permettez-moi, s'il vous plaît, d'ajouter mes 2 cents à cette discussion :

Nous avons besoin d'un moyen de regrouper différents types par des fonctionnalités autres que leurs ensembles de méthodes (comme avec les interfaces). Une nouvelle fonctionnalité de regroupement devrait permettre d'inclure des types primitifs (ou basiques), qui n'ont aucune méthode, et des types d'interface à catégoriser comme étant similaires. Nous pouvons conserver les types primitifs (booléens, numériques, chaîne et même []byte, []int, etc.) tels qu'ils sont, mais permettre de faire abstraction des différences entre les types lorsqu'une définition de type les regroupe dans une famille.

Je suggère que nous ajoutions quelque chose comme une construction de type _family_ au langage.

La syntaxe

Une famille de types peut être définie comme n'importe quel autre type :

type theFamilyName family {
    someType
    anotherType
}

La syntaxe formelle serait quelque chose comme :
FamilyType = "family" "{" { TypeName ";" } "}" .

Une famille de types peut être définie à l'intérieur d'une signature de fonction :

func Display(s family{string; fmt.Stringer}) { /* function body */ }

C'est-à-dire que la définition d'une ligne nécessite des points-virgules entre les noms de type.

La valeur zéro d'un type de famille est nil, comme avec une interface nil.

(Sous le capot, une valeur située derrière l'abstraction de la famille est implémentée un peu comme une interface.)

Le raisonnement

Nous avons besoin de quelque chose de plus précis que l'interface vide où nous voulons spécifier quels types sont valides comme arguments d'une fonction ou comme retours d'une fonction.

La solution proposée permettrait une meilleure sécurité de type, entièrement vérifiée au moment de la compilation et n'ajoutant aucune surcharge supplémentaire au moment de l'exécution.

Le fait est que _Go code devrait être plus auto-documenté_. Ce qu'une fonction peut prendre comme argument doit être intégré dans le code lui-même.

Trop de code exploite à tort le fait que « interface{} ne dit rien ». C'est un peu embarrassant qu'une construction aussi largement utilisée (et maltraitée) en Go, sans laquelle nous ne serions pas capables de faire grand-chose, dise _rien_.

Quelques exemples

La documentation de la fonction sql.Rows.Scan comprend un gros bloc détaillant les types pouvant être transmis à la fonction :

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)

Et pour la fonction sql.Row.Scan , la documentation comprend la phrase « Voir la documentation sur Rows.Scan pour plus de détails. » Voir la documentation pour _une autre fonction_ pour plus de détails ? Ce n'est pas du genre Go—et dans ce cas, cette phrase n'est pas correcte car en fait Rows.Scan peut prendre une *RawBytes mais pas Row.Scan .

Le problème est que nous sommes souvent obligés de nous fier aux commentaires pour les garanties et les contrats de comportement, que le compilateur ne peut pas faire respecter.

Lorsque la documentation d'une fonction indique que la fonction fonctionne exactement comme une autre fonction - « alors allez voir la documentation de cette autre fonction » - vous pouvez presque garantir que la fonction sera parfois utilisée à mauvais escient. Je parie que la plupart des gens, comme moi, n'ont découvert qu'un *RawBytes n'est pas autorisé comme argument dans Row.Scan qu'après avoir obtenu une erreur du Row.Scan ( disant "sql : RawBytes n'est pas autorisé sur Row.Scan"). C'est triste que le système de types permette de telles erreurs.

On pourrait plutôt avoir :

type Value family {
    *string
    *[]byte
    *int; *int8; *int16; *int32; *int64
    *uint; *uint8; *uint16; *uint32; *uint64
    *bool
    *float32; *float64
    *interface{}
    *RawBytes
    Scanner
}

De cette façon, la valeur transmise doit être l'un des types de la famille donnée, et le commutateur de type à l'intérieur de la fonction Rows.Scan n'aura pas besoin de gérer les cas inattendus ou par défaut ; il y aurait une autre famille pour la fonction Row.Scan .

Considérez également comment la structure cloud.google.com/go/datastore.Property a un champ "Value" de type interface{} et nécessite toute cette documentation :

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

Cela pourrait être :

type PropertyVal family {
  int64
  bool
  string
  float64
  *Key
  time.Time
  GeoPoint
  []byte
  *Entity
  nil
  []int64; []bool; []string; []float64; []*Key; []time.Time; []GeoPoint; [][]byte; []*Entity
}

(Vous pouvez imaginer comment cela pourrait être divisé en deux familles.)

Le type json.Token été mentionné ci-dessus. Sa définition de type serait :

type Token family {
    Delim
    bool
    float64
    Number
    string
    nil
}

Un autre exemple que j'ai eu récemment :
Lors de l'appel de fonctions comme sql.DB.Exec , ou sql.DB.Query , ou toute fonction qui prend une liste variadique de interface{} où chaque élément doit avoir un type dans un ensemble particulier et _pas lui-même être un slice_, il est important de se rappeler d'utiliser l'opérateur « spread » lors du passage des arguments d'un []interface{} dans une telle fonction : il est faux de dire DB.Exec("some query with placeholders", emptyInterfaceSlice) ; la manière correcte est : DB.Exec("the query...", emptyInterfaceSlice...)emptyInterfaceSlice a le type []interface{} . Une manière élégante de rendre de telles erreurs impossibles serait de faire en sorte que cette fonction prenne un argument variadique de Value , où Value est défini comme une famille comme décrit ci-dessus.

Le point de ces exemples est que _de vraies erreurs sont commises_ à cause de l'imprécision du interface{} .

var x int | float64 | string | rune
z = int(x) + int(y)
z = float64(x) + float64(y)

Cela devrait certainement être une erreur du compilateur car le type de x n'est pas vraiment compatible avec ce qui peut être passé à int() .

J'aime l'idée d'avoir family . Ce serait essentiellement une interface contrainte (restreinte ?) aux types répertoriés et le compilateur peut s'assurer que vous faites correspondre tout le temps et modifie le type de la variable dans le contexte local du case .

Le problème, c'est qu'on est souvent obligé de se fier aux commentaires pour les garanties et
contrats de comportement, que le compilateur ne peut pas appliquer.

C'est en fait la raison pour laquelle j'ai commencé à détester légèrement des choses comme

func foo() (..., error) 

parce que vous n'avez aucune idée du type d'erreur qu'il renvoie.

et quelques autres choses qui renvoient une interface au lieu d'un type concret. Quelques fonctions
renvoie net.Addr et il est parfois un peu difficile de fouiller dans le code source pour déterminer quel type de net.Addr il renvoie réellement, puis l'utiliser de manière appropriée. Il n'y a pas vraiment d'inconvénient à renvoyer un type concret (car il implémente l'interface et peut donc être utilisé partout où l'interface peut être utilisée) sauf lorsque vous
plus tard, prévoyez d'étendre votre méthode pour renvoyer un autre type de net.Addr . Mais si votre
L'API mentionne qu'elle renvoie OpError alors pourquoi ne pas en faire une partie de la spécification du « temps de compilation » ?

Par exemple:

 OpError is the error type usually returned by functions in the net package. It describes the operation, network type, and address of an error. 

D'habitude? Ne vous dit pas exactement quelles fonctions renvoient cette erreur. Et c'est la documentation pour le type, pas la fonction. La documentation de Read ne mentionne nulle part qu'elle renvoie OpError. Aussi, si vous faites

err := blabla.(*OpError)

il plantera une fois qu'il renverra un autre type d'erreur. C'est pourquoi j'aimerais vraiment que cela fasse partie de la déclaration de fonction. Au moins *OpError | error vous diraient qu'il revient
une telle erreur et le compilateur s'assure que vous ne faites pas d'assertion de type non vérifiée pour faire planter votre programme à l'avenir.

BTW : Un système comme le polymorphisme de type de Haskell a-t-il déjà été envisagé ? Ou un système de type basé sur les « traits », c'est-à-dire :

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 signifie "quel que soit le type de a, il doit exister une fonction add(typeof a, typeof a) typeof a)". < widgets.draw() error> signifie que "quel que soit le type de widget, il doit fournir une méthode draw qui renvoie une erreur". Cela permettrait de créer des fonctions plus génériques :

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
}

(Notez que ce n'est pas égal aux "génériques" traditionnels).

Il n'y a pas vraiment d'inconvénient à renvoyer un type concret (car il implémente l'interface et peut donc être utilisé n'importe où où l'interface peut être utilisée) sauf lorsque vous envisagez plus tard d'étendre votre méthode pour renvoyer un autre type de net.Addr .

De plus, Go n'a pas de sous-typage de variante, vous ne pouvez donc pas utiliser un func() *FooError comme func() error là où c'est nécessaire. Ce qui est particulièrement important pour la satisfaction de l'interface. Et enfin, cela ne compile pas :

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

c'est-à-dire que pour que cela fonctionne (j'aimerais si nous pouvions d'une manière ou d'une autre), nous aurions besoin d'une inférence de type beaucoup plus sophistiquée - actuellement, Go n'utilise que des informations de type local à partir d'une seule expression. D'après mon expérience, ces types d'algorithmes d'inférence de type sont non seulement considérablement plus lents (ralentissement de la compilation et généralement même pas d'exécution limitée), mais produisent également des messages d'erreur beaucoup moins compréhensibles.

De plus, Go n'a pas de sous-typage de variante, vous ne pouvez donc pas utiliser un func() *FooError comme erreur func() si nécessaire. Ce qui est particulièrement important pour la satisfaction de l'interface. Et enfin, cela ne compile pas :

Je m'attendais à ce que cela fonctionne bien dans Go mais je ne suis jamais tombé dessus car la pratique actuelle consiste simplement à utiliser error . Mais oui, dans ce cas, ces restrictions vous obligent pratiquement à utiliser error comme type de retour.

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

Je ne connais aucun langage qui permette cela (enfin, à l'exception des esolangs) mais tout ce que vous auriez à faire est de garder un "type world" (qui est essentiellement une carte de variable -> type ) et si vous -affectez la variable dont vous venez de mettre à jour son type dans le "type world".

Je ne pense pas que vous ayez besoin d'une inférence de type compliquée pour faire cela, mais vous devez garder une trace des types de variables, mais je suppose que vous devez le faire de toute façon parce que

var int i = 0;
i = "hi";

vous devez sûrement vous rappeler quelles variables/déclarations ont quels types et pour i = "hi" vous devez faire une "recherche de type" sur i pour vérifier si vous pouvez lui attribuer une chaîne.

Existe-t-il des problèmes pratiques qui compliquent l'attribution d'un func () *ConcreteError à un func() error autre que le vérificateur de type qui ne le prend pas en charge (comme des raisons d'exécution/des raisons de code compilé) ? Je suppose qu'actuellement, vous devriez l'envelopper dans une fonction comme celle-ci :

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 vous êtes confronté à un func (a, b) c mais que vous obtenez un func (x, y) z tout ce qu'il vous reste à faire est de vérifier si z est attribuable à c (et a , b doit être assignable à x , y ) qui au moins au niveau du type n'implique pas d'inférence de type compliquée (il s'agit simplement de vérifier si un type est assignable/compatible avec/avec un autre type). Bien sûr, si cela cause des problèmes avec l'exécution/la compilation... Je ne sais pas, mais au moins en regardant strictement le niveau de type, je ne vois pas pourquoi cela impliquerait une inférence de type compliquée. Le vérificateur de type sait déjà si un x peut être affecté à a ainsi il sait aussi facilement si func () x peut être affecté à func () a . Bien sûr, il peut y avoir des raisons pratiques (en pensant aux représentations d'exécution) pour lesquelles cela ne sera pas facilement possible. (Je soupçonne que c'est le vrai point crucial ici, pas la vérification de type réelle).

Théoriquement, vous pouvez contourner les problèmes d'exécution (s'il y en a) avec des fonctions d'emballage automatique (comme dans l'extrait ci-dessus) avec l'inconvénient _potentiellement énorme_ qu'il bousille les comparaisons de funcs avec funcs (car le func encapsulé ne sera pas égal au func ça s'enroule).

Je ne connais aucune langue qui permette cela (enfin, à l'exception des esolangs)

Pas exactement, mais je dirais que ce parce que les langues avec les systèmes de type puissants sont en général les langages fonctionnels qui n'utilisent pas vraiment variables (et donc ne pas vraiment besoin de la capacité de réutilisation des identifiants). FWIW, je dirais que, par exemple, le système de types de Haskell serait capable de gérer cela très bien - du moins tant que vous n'utilisez pas d'autres propriétés de FooError ou BarError , il devrait être capable de déduire que err est de type error et de s'en occuper. Bien sûr, encore une fois, il s'agit d'une hypothèse, car cette situation exacte ne se transfère pas facilement dans un langage fonctionnel.

mais je suppose que vous devez le faire de toute façon parce que

La différence étant que dans votre exemple, i a un type clair et bien compris après la première ligne, qui est int et vous rencontrez ensuite une erreur de type lorsque vous affectez un string à cela. Pendant ce temps, pour quelque chose comme je l'ai mentionné, chaque utilisation d'un identifiant crée essentiellement un ensemble de contraintes sur le type utilisé et le vérificateur de type essaie ensuite de déduire le type le plus général remplissant toutes les contraintes données (ou se plaindre qu'il n'y a pas de type remplissant cela Contrat). C'est à cela que servent les théories des types formels.

Existe-t-il des problèmes pratiques qui compliquent l'attribution d'un func () *ConcreteError à un func() error autre que le vérificateur de type qui ne le prend pas en charge (comme des raisons d'exécution/des raisons de code compilé) ?

Il y a des problèmes pratiques, mais je pense que pour func ils sont probablement solubles (en émettant du code de désencapsulage, de la même manière que le passage d'interface fonctionne). J'ai écrit un peu sur la variance dans Go et j'explique certains des problèmes pratiques que je vois en bas. Je ne suis pas totalement convaincu qu'il vaut la peine d'ajouter cependant. C'est-à-dire que je ne suis pas sûr que cela résout des problèmes importants à lui seul.

avec l'inconvénient potentiellement énorme que cela gâche les comparaisons de funcs avec funcs (car le func encapsulé ne sera pas égal au func qu'il encapsule).

les fonctions ne sont pas comparables.

Quoi qu'il en soit, TBH, tout cela semble un peu hors sujet pour ce problème :)

Pour info : je viens de faire ça . Ce n'est pas agréable, mais c'est sûr que c'est sûr. (La même chose peut être faite pour #19814 FWIW)

Je suis un peu en retard pour la fête, mais je voudrais moi aussi partager avec vous mon ressenti après 4 ans de Go :

  • Les retours multi-valeurs ont été une énorme erreur.
  • Les interfaces Nil-able étaient une erreur.
  • Les pointeurs ne sont pas des synonymes de "facultatif", des unions discriminées auraient dû être utilisées à la place.
  • Le unmarshaller JSON aurait dû renvoyer une erreur si un champ obligatoire n'est pas inclus dans le document JSON.

Au cours de ces 4 dernières années, j'ai trouvé de nombreux problèmes associés :

  • les données d'ordures retournent en cas d'erreur.
  • encombrement de la syntaxe (retour de valeurs à zéro en cas d'erreur).
  • retours multi-erreurs (API déroutantes, s'il vous plaît, ne faites pas ça !).
  • des interfaces non nulles pointant vers des pointeurs pointant vers nil (cela déconcerte les gens qui font passer la déclaration « Go est un langage facile » comme une mauvaise blague).
  • les champs JSON non cochés font planter les serveurs (oui !).
  • les pointeurs renvoyés non vérifiés font planter les serveurs, mais personne n'a documenté que le pointeur renvoyé représente un (peut-être-type) facultatif et pourrait donc être nil (oui !)

Les modifications nécessaires pour résoudre tous ces problèmes, cependant, nécessiteraient une version Go 2.0.0 (pas Go2) vraiment rétrocompatible, qui ne sera jamais réalisée, je suppose. De toute façon...

Voici à quoi aurait dû ressembler la gestion des erreurs :

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

Les interfaces ne remplacent pas les unions discriminées , ce sont deux animaux complètement différents. Le compilateur s'assure que les changements de type sur les unions discriminées sont complets, ce qui signifie que les cas couvrent tous les types possibles, si vous ne le souhaitez pas, vous pouvez utiliser l'instruction d'assertion de type.

J'ai trop souvent vu des gens être totalement confus au sujet des _interfaces non nulles vers des valeurs nulles_ : https://play.golang.org/p/JzigZ2Q6E6F. Habituellement, les gens sont confus lorsqu'une interface error pointe vers un pointeur d'un type d'erreur personnalisé qui pointe vers nil , c'est l'une des raisons pour lesquelles je pense que rendre les interfaces nil-able était une erreur.

Une interface est comme une réceptionniste, vous savez que c'est un humain quand vous lui parlez, mais dans Go, ce pourrait être une figure en carton et le monde s'effondrera soudainement si vous essayez de lui parler.

Les unions discriminantes auraient dû être utilisées pour les options (peut-être les types) et passer des pointeurs nil aux interfaces aurait dû provoquer une panique :

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

Les pointeurs et les types peut-être ne sont pas interchangeables. L'utilisation de pointeurs pour les types facultatifs est mauvaise car elle conduit à des API déroutantes :

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

Ensuite, il y a aussi JSON. Cela ne pourrait jamais arriver avec les unions car le compilateur vous oblige à les vérifier avant de les utiliser . Le unmarshaller JSON devrait échouer si un champ obligatoire (y compris les champs de type pointeur) n'est pas inclus dans le document JSON :

type DataModel struct {
  // Optional needs to be type-checked before use
  // and is therefore allowed to no be included in the JSON document
  Optional string | nil `json:"optional,omitempty"`
  // Required won't ever be nil
  // If the JSON document doesn't include it then unmarshalling will return an error
  Required *T `json:"required"`
}

PS
Je travaille également sur une conception de langage fonctionnel en ce moment et voici comment j'utilise des unions discriminées pour la gestion des erreurs :

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

J'aimerais que cela devienne réalité un jour. Voyons donc si je peux aider un peu :

Peut-être que le problème est que nous essayons de couvrir trop de choses avec la proposition. Nous pourrions aller avec une version simplifiée qui apporte la majorité de la valeur afin qu'il soit beaucoup plus facile de l'ajouter à la langue à court terme.

De mon point de vue, cette version simplifiée serait juste liée à nil . Voici les idées principales (presque toutes ont déjà été mentionnées dans les commentaires) :

  1. Autoriser uniquement le | version
    <any pointer type> | nil
    Où serait n'importe quel type de pointeur : pointeurs, fonctions, canaux, tranches et cartes (les types de pointeur Go)
  2. Interdire d'attribuer nil à un type de pointeur nu. Si vous souhaitez affecter nil, le type doit être <pointer type> | nil . Par exemple:
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.

Ce sont les idées principales. Les idées suivantes sont dérivées des idées principales :

  1. Vous ne pouvez pas déclarer une variable de type pointeur nu et la laisser non initialisée. Si vous voulez faire cela, vous devez ajouter le type discriminé | nil
var maybeAString *string       // Wrong: invalid initial value
var maybeAString *string | nil // Good
  1. Vous pouvez affecter un type de pointeur nu à un type de pointeur "nilable", mais pas l'inverse :
var value int = 42
var barePointer *int = &value          // Valid
var nilablePointer *int | nil = &value // Valid

nilablePointer = barePointer // Valid
barePointer = nilablePointer // Invalid: Incompatible types
  1. Le seul moyen d'obtenir la valeur d'un type de pointeur "nilable" est via le commutateur de type, comme d'autres l'ont souligné. Par exemple, en suivant l'exemple ci-dessus, si nous voulons vraiment affecter la valeur de nilablePointer à barePointer , alors nous devons faire :
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
}

Et c'est tout. Je sais que les unions discriminées peuvent être utilisées pour beaucoup plus (notamment en cas de retour d'erreurs), mais je dirais que s'en tenir à ce que j'ai écrit ci-dessus, nous apporterions une valeur ÉNORME au langage avec moins d'effort et sans le compliquer plus que nécessaire.
Avantages que je vois avec cette proposition simple :

  • a) Aucune erreur de pointeur nulle . D'accord, jamais 4 mots n'ont signifié autant. C'est pourquoi je ressens le besoin de le dire d'un autre point de vue : aucun programme Go n'aura _JAMAIS_ une erreur nil pointer dereference ! ??
  • b) Vous pouvez passer des pointeurs vers des paramètres de fonction sans échanger "performance vs intention" .
    Ce que je veux dire par là, c'est qu'il y a des moments où je veux passer une structure à une fonction, et non un pointeur vers elle, parce que je ne veux pas que cette fonction s'inquiète de la nullité et la force à vérifier les paramètres . Cependant, je finis normalement par passer un pointeur pour éviter la surcharge de copie.
  • c) Plus de maps nulles ! OUI! Nous terminerons par l'incohérence sur les "safe nil-slices" et les "unsafe nil-maps" (cela va paniquer si vous essayez de leur écrire). Une carte sera soit initialisée, soit de type map | nil , auquel cas vous devrez utiliser un commutateur de type 😃

Mais il y a aussi un autre intangible ici qui apporte beaucoup de valeur : la tranquillité d'esprit du

Un avantage de commencer avec cette version plus simple de la proposition est qu'elle ne nous empêchera pas d'aller vers la proposition complète à l'avenir, ni même d'aller pas à pas (étant, pour moi, la prochaine étape naturelle pour permettre des retours d'erreurs discriminés , mais oublions ça maintenant).

Un problème est que même cette version simple de la proposition est rétrocompatible, mais elle peut être facilement corrigée par gofix : remplacez simplement toutes les déclarations de type de pointeur par <pointer type> | nil .

Qu'est-ce que tu penses? J'espère que cela pourrait faire la lumière et accélérer l'inclusion de la sécurité nulle dans le langage. Il semble que cette voie (à travers les "unions discriminées") soit la voie la plus simple et la plus orthogonale pour y parvenir.

@alvaroloes

Vous ne pouvez pas déclarer une variable de type pointeur nu et la laisser non initialisée.

C'est le nœud du problème. Ce n'est tout simplement pas une chose que Go fait - chaque type a une valeur zéro, point final. Sinon, vous auriez à répondre que fait, par exemple, make([]T, 100) ? D'autres choses que vous mentionnez (par exemple, les cartes nil paniquent lors des écritures) sont une conséquence de cette règle de base. (Et en passant, je ne pense pas qu'il soit vraiment vrai de dire que les tranches nil sont plus sûres que les cartes - écrire sur une tranche nulle paniquera tout autant que d'écrire sur une map nil).

En d'autres termes : votre proposition n'est en fait pas si simple, car elle s'écarte assez sensiblement d'une décision de conception assez fondamentale dans le langage Go.

Je pense que la chose la plus importante que fait Go est de rendre les valeurs zéro utiles et de ne pas simplement donner à tout une valeur zéro. Nil map est une valeur nulle mais ce n'est pas utile. C'est nocif, en fait. Alors pourquoi ne pas interdire la valeur zéro dans les cas où ce n'est pas utile. Changer de Go à cet égard serait bénéfique mais la proposition n'est en effet pas si simple.

La proposition ci-dessus ressemble plus à un type de chose facultatif/non facultatif comme dans Swift et d'autres. C'est cool et tout sauf :

  1. Cela casserait à peu près tous les programmes et le correctif ne serait pas trivial à réparer. Vous ne pouvez pas tout remplacer par <pointer type> | nil car, par proposition, cela nécessiterait un changement de type pour décompresser la valeur.
  2. Pour que cela soit réellement utilisable et supportable, Go aurait besoin d'avoir beaucoup plus de sucre syntaxique autour de ces options. Prenez Swift, par exemple. Il existe de nombreuses fonctionnalités dans le langage spécifiquement pour travailler avec les options - garde, liaison optionnelle, chaînage optionnel, fusion nulle, etc. Je ne pense pas que Go irait dans cette direction, mais sans eux, travailler avec les options serait une corvée.

Alors pourquoi ne pas interdire la valeur zéro dans les cas où ce n'est pas utile.

Voir au dessus. Cela signifie que certaines choses qui ont l'air bon marché ont des coûts très non négligeables qui leur sont associés.

Changer Go à cet égard serait bénéfique

Il a des avantages, mais ce n'est pas la même chose qu'être bénéfique. Il a aussi des méfaits. Ce qui pèse plus lourd dépend de la préférence et d'un compromis. Les concepteurs de Go ont choisi cela.

FTR, c'est un modèle général dans ce fil et l'un des principaux contre-arguments à tout concept de types de somme - que vous devez dire quelle est la valeur zéro. C'est pourquoi toute nouvelle idée doit l'aborder explicitement. Mais de manière quelque peu frustrante, la plupart des personnes qui publient ici ces jours-ci n'ont pas lu le reste du fil et ont tendance à ignorer cette partie.

Ah ! Je savais qu'il y avait quelque chose d'évident qui me manquait. Oh ! Le mot "simple" a des significations complexes. Ok, n'hésitez pas à supprimer le mot "simple" de mon commentaire précédent.

Désolé si cela a été frustrant pour certains d'entre vous. Mon intention était d'essayer d'aider un peu. J'essaie de suivre le fil, mais je n'ai pas trop de temps libre à y consacrer.

Revenons à la question : il semble donc que la principale raison qui retient cela soit la valeur zéro.
Après avoir réfléchi pendant un certain temps et écarté de nombreuses options, la seule chose que je pense qui pourrait ajouter de la valeur et qui mérite d'être mentionnée est la suivante :

Si je me souviens bien, la valeur zéro de tout type consiste à remplir son espace mémoire avec des 0.
Comme vous le savez déjà, c'est bien pour les types non pointeurs, mais c'est une source de bogues pour les types pointeurs :

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

Alors, et si nous :

  • Définir une valeur zéro utile pour chaque type de pointeur
  • Ne l'initialisez qu'à la première utilisation (initialisation paresseuse).

Je pense que cela a été suggéré dans un autre numéro, pas sûr. Je l'écris simplement ici parce qu'il aborde le principal point de retenue de cette proposition.

Ce qui suit pourrait être une liste des valeurs zéro pour les types de pointeur. Notez que ces valeurs zéro ne seront utilisées que lors de l'accès à la valeur . Nous pourrions l'appeler "valeur zéro dynamique", et ce n'est qu'une propriété des types de pointeur :

| Type de pointeur | Valeur zéro | Valeur zéro dynamique | Commentaire |
| --- | --- | --- | --- |
| *T | nil | nouveau(T) |
| []T | nil | []T{} |
| map[T]U | nil | carte[T]U{} |
| func | nil | noop | Ainsi, la valeur zéro dynamique d'une fonction ne fait rien et renvoie des valeurs zéro. Si la liste des valeurs de retour se termine par error , une erreur par défaut est renvoyée indiquant que la fonction est une "aucune opération" |
| chan T | nil | faire (chan T) |
| interface | nil | - | une implémentation par défaut où toutes les méthodes sont initialisées avec la fonction noop décrite ci-dessus |
| union discriminée | nil | valeur zéro dynamique du premier type | |

Maintenant, lorsque ces types sont initialisés, ils seront nil , comme ils le sont actuellement. La différence réside dans le moment où un nil est accessible. A ce moment, la valeur zéro dynamique sera utilisée. Quelques exemples :

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
}

Il me manque probablement des détails de mise en œuvre et des difficultés possibles, mais je voulais d'abord me concentrer sur l'idée.

Le principal inconvénient est que nous ajoutons une vérification nil supplémentaire à chaque fois que vous accédez à la valeur d'un type de pointeur. Mais je dirais :

  • C'est un bon compromis pour les avantages que nous obtenons. La même situation se produit avec les vérifications liées dans les accès aux tableaux/tranches et nous acceptons de payer cette pénalité de performance pour la sécurité qu'elle apporte.
  • Les vérifications nil pourraient être évitées de la même manière que les vérifications liées aux tableaux : si le type pointeur a été initialisé dans la portée courante, le compilateur pourrait le savoir et éviter d'ajouter la vérification nil.

Avec cela, nous avons tous les avantages expliqués dans le commentaire précédent, avec le plus que nous n'avons pas besoin d'utiliser un commutateur de type pour accéder à la valeur (ce ne serait que pour les unions discriminées), en gardant le code go aussi propre que c'est maintenant.

Qu'est-ce que tu penses? Désolé si cela a déjà été discuté. De plus, je suis conscient que cette proposition de commentaire est plus liée à nil qu'à des syndicats discriminés. Je pourrais déplacer cela vers un problème lié au néant mais, comme je l'ai dit, je l'ai posté ici car il essaie de résoudre le problème principal des syndicats discriminés : les valeurs zéro utiles.

Revenons à la question : il semble donc que la principale raison qui retient cela soit la valeur zéro.

C'est une raison technique importante qui doit être abordée. Pour moi, la raison principale est qu'ils rendent la réparation progressive catégoriquement impossible (voir ci-dessus). c'est-à-dire que pour moi personnellement, il ne s'agit pas tant de savoir comment les mettre en œuvre, c'est que je suis fondamentalement opposé au concept.
Dans tous les cas, quelle raison est "principale" est vraiment une question de goût et de préférence.

Alors, et si nous :

  • Définir une valeur zéro utile pour chaque type de pointeur
  • Ne l'initialisez qu'à la première utilisation (initialisation paresseuse).

Cela échoue si vous passez un type de pointeur. par exemple

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
}

Cette discussion est tout sauf nouvelle. Il y a des raisons pour lesquelles les types de référence se comportent comme ils le font et ce n'est pas que les développeurs de Go n'y ont pas pensé :)

C'est le nœud du problème. Ce n'est tout simplement pas une chose que Go fait - chaque type a une valeur zéro, point final. Sinon, vous auriez à répondre que fait, par exemple, make([]T, 100) ?

Ceci (et new(T) ) devrait être interdit si T n'a pas de valeur zéro. Vous devrez faire make([]T, 0, 100) puis utiliser append pour remplir la tranche. Retrancher plus grand ( v[:0][:100] ) devrait également être une erreur. [10]T serait fondamentalement un type impossible (à moins que la possibilité d'affirmer une tranche sur un pointeur de tableau ne soit ajoutée au langage). Et vous auriez besoin d'un moyen de marquer les types nilables existants comme non nilables afin de maintenir la compatibilité descendante.

Cela poserait un problème si des génériques étaient ajoutés, en ce sens que vous auriez besoin de traiter tous les paramètres de type comme n'ayant pas de valeur zéro à moins qu'ils ne satisfassent à une certaine limite. Un sous-ensemble de types aurait également besoin d'un suivi d'initialisation pratiquement partout. Ce serait un changement assez important à lui seul, même sans ajouter des types de somme par dessus. C'est certainement faisable, mais cela contribue de manière significative au côté coût d'une analyse coût/bénéfice. Le choix délibéré de garder l'initialisation simple ("il y a toujours une valeur zéro") aurait plutôt pour impact de rendre l'initialisation plus complexe que si le suivi de l'initialisation était dans le langage dès le premier jour.

C'est une raison technique importante qui doit être abordée. Pour moi, la raison principale est qu'ils rendent la réparation progressive catégoriquement impossible (voir ci-dessus). c'est-à-dire que pour moi personnellement, il ne s'agit pas tant de savoir comment les mettre en œuvre, c'est que je suis fondamentalement opposé au concept.
Dans tous les cas, quelle raison est "principale" est vraiment une question de goût et de préférence.

D'accord, je comprends cela. Nous devons juste aussi voir le point de vue des autres (je ne dis pas que vous ne le faites pas, je fais juste un point :wink:) où ils voient cela comme quelque chose de puissant pour écrire leurs programmes. Est-ce que ça rentre dans Go ? Cela dépend de la façon dont l'idée est exécutée et intégrée dans le langage, et c'est ce que nous essayons tous de faire dans ce fil (je suppose)

Cela échoue si vous passez un type de pointeur. par exemple (...)

Je ne comprends pas très bien. Pourquoi est-ce un échec ? Vous passez simplement une valeur dans le paramètre de fonction, qui se trouve être un pointeur avec une nil . Ensuite, vous modifiez cette valeur à l'intérieur de la fonction. On s'attend à ce que vous ne voyiez pas ces effets en dehors de la fonction. Permettez-moi de commenter quelques exemples :

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

Une situation similaire se produit avec les méthodes de réception sans pointeur, et c'est déroutant pour les nouveaux arrivants à Go (mais une fois que vous l'avez compris, cela a du sens):

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
}

Il faut donc choisir entre :

  • A) Échec avec un crash
  • B) Échec avec une non-modification silencieuse de la valeur pointée par un pointeur lorsque ce pointeur est passé à une fonction.

Le correctif pour les deux cas est le même : vérifiez la valeur nil avant de faire quoi que ce soit. Mais, pour moi, A) est beaucoup plus dommageable (toute l'application plante !).
B) pourrait être considéré comme une "erreur silencieuse", mais je ne le considérerais pas comme une erreur. Cela ne se produit que lorsque vous passez des pointeurs vers des fonctions et, comme je l'ai montré, il existe des cas avec des structures qui se comportent de manière similaire. Ceci sans considérer les énormes avantages qu'il apporte.

Note : je n'essaye pas de défendre aveuglément « mon » idée, j'essaye vraiment d'améliorer le Go (qui est déjà très bien). S'il y a d'autres points qui font que l'idée n'en vaut pas la peine, alors je m'en fiche de la jeter et de continuer à penser dans d'autres directions

Note 2 : Finalement, cette idée n'est valable que pour les valeurs « nul » et n'a rien à voir avec les unions discriminées. Je vais donc créer un autre problème pour éviter de polluer celui-ci

D'accord, je comprends cela. Il faut juste aussi voir le point de vue des autres (je ne dis pas que tu ne fais pas ça, je fais juste un point )

Cette épée coupe dans les deux sens, cependant. Vous avez dit "la principale raison pour laquelle cela a été retenu était". Cette déclaration implique que nous sommes tous d'accord sur la question de savoir si nous voulons l' effet de cette proposition. Je peux certainement convenir qu'il s'agit d' un détail technique retenant les suggestions spécifiques faites (ou du moins, que toute suggestion devrait dire quelque chose sur cette question ). Mais je n'aime pas que la discussion soit recadrée tranquillement dans un monde parallèle où nous supposons que tout le monde le veut réellement.

Pourquoi est-ce un échec ?

Parce qu'une fonction qui prend un pointeur fera, au moins souvent, une promesse de modifier la pointe. Si la fonction ne fait rien en silence, je considérerais cela comme un bogue. Ou du moins, c'est un argument facile à faire valoir, qu'en empêchant une panique nulle de cette façon, vous introduisez une nouvelle classe de bogue.

Si vous passez un pointeur nil à une fonction qui attend quelque chose là-bas, c'est un bogue - et je ne vois pas l'intérêt réel de faire en sorte qu'un logiciel aussi bogué continue en silence. Je peux voir la valeur de l'idée originale d'attraper ce bogue au moment de la compilation en prenant en charge les pointeurs non nilables, mais je ne vois pas l'intérêt de permettre à ce bogue de ne pas être attrapé du tout.

c'est-à-dire pour ainsi dire, vous abordez une sorte de problème différent de la proposition réelle de pointeurs non nilables : pour cette proposition, la panique à l'exécution n'est pas le problème, mais juste un symptôme - le problème est le bogue en passant accidentellement nil à quelque chose qui ne s'y attend pas et que ce bogue n'est détecté qu'à l'exécution.

Une situation similaire se produit avec les méthodes de réception sans pointeur

Je n'achète pas cette analogie. OMI, il est tout à fait raisonnable de considérer

func Foo(p *int) { *p = 42 }

func main() {
    var v int
    Foo(&v)
    if v != 42 { panic("") }
}

pour être le code correct. Je ne pense pas qu'il soit raisonnable de considérer

func Foo(v int) { v = 42 }

func main( ){
    var v int
    Foo(v)
    if v != 42 { panic("") }
}

être correct. Peut-être que si vous êtes un débutant absolu en Go et que vous venez d'un langage où chaque valeur est une référence (bien que j'aie honnêtement du mal à en trouver une - même Python et Java ne font que la plupart des références de valeurs). Mais IMO, l'optimisation pour ce cas est futile, il est juste de supposer que les gens ont une certaine familiarité avec les pointeurs par rapport aux valeurs. Je pense que même un développeur Go chevronné considérerait, disons, une méthode avec un récepteur de pointeur accédant à ses champs comme étant correcte, et le code appelant ces méthodes étant correct. En effet, c'est tout l'argument pour empêcher les nil -pointeurs de manière statique, qu'il est trop facile d'avoir involontairement un pointeur nil et que le code d'apparence correcte échoue à l'exécution.

Le correctif pour les deux cas est le même : vérifiez la valeur nil avant de faire quoi que ce soit.

OMI, le correctif dans la sémantique actuelle consiste à ne pas vérifier la valeur zéro et à considérer cela comme un bogue si quelqu'un passe à zéro. Comme, dans votre exemple, vous écrivez

// The correct code would be:
if p == nil {
    // panic or return error
}
*p = 42

Mais je ne considère pas ce code comme correct. Le nil -check ne fait rien, car le déréférencement de nil panique déjà .

Mais, pour moi, A) est beaucoup plus dommageable (toute l'application plante !).

C'est bien, mais gardez à l'esprit que beaucoup de gens seront fortement en désaccord sur ce point. Personnellement, je considère qu'un crash est toujours préférable à continuer avec des données corrompues et des hypothèses erronées. Dans un monde idéal, mon logiciel n'a pas de bugs et ne plante jamais. Dans un monde moins idéal, mes programmes auront des bogues et échoueront en toute sécurité en plantant lorsqu'ils sont détectés. Dans le pire des mondes, mes programmes auront des bogues et continueront à faire des ravages lorsqu'ils seront rencontrés.

Cette épée coupe dans les deux sens, cependant. Vous avez dit « la principale raison pour laquelle cela a été retenu était ». Cette déclaration implique que nous sommes tous d'accord sur la question de savoir si nous voulons l'effet de cette proposition. Je peux certainement convenir qu'il s'agit d'un détail technique qui retient les suggestions spécifiques faites (ou du moins, que toute suggestion devrait dire quelque chose sur cette question). Mais je n'aime pas que la discussion soit recadrée tranquillement dans un monde parallèle où nous supposons que tout le monde le veut réellement.

Eh bien, je ne voulais pas insinuer cela. Si c'est ce qui a été compris, alors je n'ai peut-être pas choisi les bons mots et je m'en excuse. Je voulais juste apporter quelques idées pour une solution possible, c'est tout.

J'ai écrit _"... il semble que la principale raison qui retient cela soit...."_ basé sur votre phrase _"C'est le nœud du problème"_ en référence à la valeur zéro. C'est pourquoi j'ai supposé que la valeur zéro était la principale chose qui retenait cela. C'était donc ma mauvaise hypothèse.

En ce qui concerne le traitement silencieux de nil rapport à leur vérification au moment de la compilation : je suis d'accord qu'il est préférable de les vérifier au moment de la compilation. La "valeur zéro dynamique" n'était qu'une itération de la suggestion d'origine lorsque je me suis concentré sur le problème de tous les types qui devraient avoir une valeur nulle. Une motivation supplémentaire était que je pensais que c'était aussi le principal frein à la proposition des syndicats discriminés.
Si nous nous concentrons uniquement sur le problème lié à nil, je préférerais que les types de pointeurs non nil soient vérifiés au moment de la compilation.

Je dirais qu'à un moment donné, nous (avec "nous" je fais référence à l'ensemble de la communauté Go) devrons accepter _une sorte_ de changement. Par exemple : s'il existe une bonne solution pour éviter complètement les erreurs de nil et que la chose qui la retient est la décision de conception "tous les types ont une valeur nulle et sont composés de 0", alors nous pourrions envisager l'idée de faire quelques ajustements ou des changements à cette décision si elle apporte de la valeur.

La principale raison pour laquelle je dis cela est votre phrase _"chaque type a une valeur zéro, point final "_. Normalement, je n'aime pas "écrire des points". Ne vous méprenez pas ! J'accepte complètement que vous pensiez ainsi, c'est juste ma façon de penser : je ne préfère pas les dogmes car ils peuvent cacher des chemins qui peuvent conduire à de meilleures solutions.

Enfin, à ce sujet :

C'est bien, mais gardez à l'esprit que beaucoup de gens seront fortement en désaccord sur ce point. Personnellement, je considère qu'un crash est toujours préférable à la poursuite de données corrompues et d'hypothèses erronées. Dans un monde idéal, mon logiciel n'a pas de bugs et ne plante jamais. Dans un monde moins idéal, mes programmes auront des bogues et échoueront en toute sécurité en plantant lorsqu'ils sont détectés. Dans le pire des mondes, mes programmes auront des bogues et continueront à faire des ravages lorsqu'ils seront rencontrés.

Je suis complètement d'accord avec ça. Échouer à voix haute est toujours mieux que d'échouer en silence. Cependant, il y a un hic dans Go :

  • Si vous avez une application avec des milliers de goroutines, une panique non gérée dans l'une d'entre elles fait planter tout le programme. C'est différent des autres langages, où seul le fil qui panique plante

Mis à part cela (bien que ce soit assez dangereux), l'idée est donc d'éviter toute une catégorie de pannes ( nil -related pannes).

Continuons donc à répéter et essayons de trouver une solution.

Merci pour votre temps et votre énergie !

J'aimerais voir la syntaxe des unions discriminées de rust plutôt que les types de somme de haskell, cela permet de nommer des variantes et permet une meilleure proposition de syntaxe de correspondance de modèle.
La mise en œuvre peut être effectuée comme une structure avec un champ de balise (type uint, dépend du nombre de variantes) et un champ union (contenant les données).
Cette fonctionnalité est requise pour un ensemble fermé de variantes (la représentation de l'état serait beaucoup plus facile et plus propre, avec une vérification du temps de compilation). D'après les questions sur les interfaces et leur représentation, je pense que leur implémentation dans le type sum ne doit pas être terminée qu'un autre cas de type sum, car l'interface concerne tout type répondant à certaines exigences, mais le cas d'utilisation du type sum est différent.

Syntaxe:

type Type enum {
         Tuple (int,int),
         One int,
         None,
};

Dans l'exemple ci-dessus, la taille serait sizeof((int,int)).
La correspondance de modèle peut être effectuée avec un nouvel opérateur de correspondance créé ou au sein d'un opérateur de commutation existant, comme :

var a Type
switch (a) {
         case Tuple{(b,c)}:
                    //do something
         case One{b}:
                    //do something else
         case None:
                    //...
}

Syntaxe de création :
var a Type = Type{One=12}
Notez que dans la construction d'instance enum, une seule variante peut être spécifiée.

Valeur zéro (problème) :
Nous pouvons trier les noms par ordre alphabétique, la valeur zéro de l'énumération sera la valeur zéro du type du premier membre dans la liste des membres triés.

PS La solution du problème de la valeur zéro est principalement définie par accord.

Je pense que garder la valeur zéro de la somme comme valeur zéro du premier champ de somme défini par l'utilisateur serait moins déroutant, peut-être

Je pense que garder la valeur zéro de la somme comme valeur zéro du premier champ de somme défini par l'utilisateur serait moins déroutant, peut-être

Mais faire dépendre la valeur zéro de l'ordre de déclaration des champs, je pense que c'est pire.

Quelqu'un a écrit un document de conception ?

J'en ai un:
19412-discriminated_unions_and_pattern_matching.md.zip

J'ai changé ça :

Je pense que garder la valeur zéro de la somme comme valeur zéro du premier champ de somme défini par l'utilisateur serait moins déroutant, peut-être

Maintenant, dans ma proposition, l'accord sur la valeur zéro (problème) est passé à la position urandoms.

UPD : doc de conception modifiée, corrections mineures.

J'ai deux cas d'utilisation récents, où j'avais besoin de types de somme intégrés :

  1. Représentation de l'arbre AST, comme prévu. Initialement trouvé une bibliothèque qui était une solution à première vue, mais leur approche avait une grande structure avec beaucoup de champs nilables. Le pire des deux mondes OMI. Pas de type sécurité bien sûr. A écrit le nôtre à la place.
  2. Avait une file d'attente de tâches d'arrière-plan prédéfinies : nous avons un service de recherche qui est en cours de développement et nos opérations de recherche pourraient être trop longues, etc. Nous avons donc décidé de les exécuter en arrière-plan en envoyant des tâches d'opération d'index de recherche dans un canal. Ensuite, un répartiteur décidera quoi faire avec eux. Pourrait utiliser le modèle de visiteur, mais c'est évidemment exagéré pour une simple requête gRPC. Et ce n'est pas particulièrement clair à dire du moins, car cela introduit un lien entre un répartiteur et un visiteur.

Dans les deux cas implémenté quelque chose comme ça (sur l'exemple de la 2ème tâche):

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

Puis

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

C'est presque bon. La mauvaise chose que Go ne fournit pas une sécurité de type complète, c'est-à-dire qu'il n'y aura pas d'erreur de compilation après l'ajout de la nouvelle tâche d'opération d'index de recherche.

À mon humble avis, l'utilisation de types de somme est la solution la plus claire pour ce type de tâches généralement résolues avec un visiteur et un ensemble de répartiteurs, où les fonctions du visiteur ne sont pas nombreuses et petites et le visiteur lui-même est un type fixe.

Je crois vraiment avoir quelque chose comme

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

serait beaucoup plus Goish dans l'esprit que toute autre approche que Go permet dans son état actuel. Pas besoin de correspondance de motifs Haskellish, la plongée juist jusqu'à un certain type est plus que suffisante.

Aïe, raté le point de la proposition de syntaxe. Répare le.

Deux versions, une pour le type somme générique et le type somme pour les énumérations :

Types de somme génériques

type Sum oneof {
    T₁ TypeDecl₁
    T₂ TypeDecl₂
    …
    Tₙ TypeDeclₙ
}

T₁Tₙ sont des définitions de type au même niveau avec Sum ( oneof expose en dehors de sa portée) et Sum déclare une interface qui ne satisfait que T₁Tₙ .

Le traitement est similaire à ce que nous avons (type) commutateur oneof et qu'il doit y avoir une vérification du compilateur si toutes les variantes ont été répertoriées.

Énumérations sûres de type réel

type Enum oneof {
    Value = iota
}

assez similaire à iota de consts, sauf que seules les valeurs explicitement répertoriées sont des Enums et tout le reste ne l'est pas.

switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

serait beaucoup plus Goish dans l'esprit que toute autre approche que Go permet dans son état actuel. Pas besoin de correspondance de motifs Haskellish, la plongée juist jusqu'à un certain type est plus que suffisante.

Je ne pense pas que manipuler la signification de la variable task soit une bonne idée, bien qu'acceptable.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

serait beaucoup plus Goish dans l'esprit que toute autre approche que Go permet dans son état actuel. Pas besoin de correspondance de motifs Haskellish, la plongée juist jusqu'à un certain type est plus que suffisante.

Je ne pense pas que manipuler le sens de la variable de tâche soit une bonne idée, bien qu'acceptable.
```

Bonne chance avec vos visiteurs alors.

@sirkon Qu'entendez-vous par visiteurs ? J'ai aimé cette syntaxe d'ailleurs, mais le commutateur devrait-il être écrit comme ceci :

switch task {
case Task.SearchAdd:
    // task is Task.SearchAdd in this scope
case Task.SearchUpdate:
case Task.SearchDelete:
}

Quelle serait également la valeur sans valeur pour Task ? Par exemple:

var task Task

Serait-ce nil ? Si oui, les switch devraient-ils avoir un case nil supplémentaire ?
Ou serait-il initialisé au premier type ? Ce serait gênant, car alors l'ordre de la déclaration de type importe d'une manière qu'il n'avait pas auparavant, mais ce serait probablement OK pour les énumérations numériques.

Je suppose que cela équivaut à switch task.(type) mais le changement nécessiterait que tous les cas soient là, non? comme dans .. si vous manquez un cas, erreur de compilation. Et aucun default autorisé. Est-ce correct?

Que voulez-vous dire par visiteurs ?

Je voulais dire qu'ils sont la seule option sûre de type dans Go pour ce type de fonctionnalité. Bien pire pour un certain ensemble de cas (nombre limité d'alternatives prédéfinies).

Quelle serait également la valeur sans valeur pour la tâche ? Par exemple:

var task Task

J'ai peur que ce soit un type nilable dans Go comme ceci

Ou serait-il initialisé au premier type ?

serait beaucoup trop bizarre, surtout pour un but prévu.

Je suppose que cela équivaut à changer de tâche. (type) mais le changement nécessiterait que tous les cas soient là, non? comme dans .. si vous manquez un cas, erreur de compilation.

Oui, d'accord.

Et aucun défaut autorisé. Est-ce correct?

Non, les valeurs par défaut sont autorisées. Découragé quand même.

PS Je semble avoir une idée de Go @ianlancetaylor et d'autres Go sur les types de somme. Il semble que le néant les rend assez sujets au NPD, puisque Go n'a aucun contrôle sur les valeurs nulles.

Si c'est nul, alors je suppose que c'est bon. Je préférerais que case nil soit une exigence pour l'instruction switch. Faire un if task != nil avant c'est bien aussi, je n'aime pas trop ça :|

Cela serait-il autorisé aussi ?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

Cela serait-il autorisé aussi ?

type Foo oneof {
  A = 3
  B = "3"
  C = 3.0
  D = struct { E bool }{ true }
}

Eh bien, pas de const alors, seulement

type Foo oneof {
    A <type reference>
}

ou

type Foo oneof {
    A = iota
    B
    C
}

ou

type Foo oneof {
    A = 1
    B = 2
    C = 3
}

Aucune combinaison d'iotas et de valeurs. Ou en combinaison avec un contrôle sur les valeurs, elles ne doivent pas être répétées.

FWIW, une chose que j'ai trouvée intéressante à propos de la nouvelle conception des génériques est qu'elle a montré un autre moyen de traiter au moins certains des cas d'utilisation des types de somme tout en évitant l'écueil des valeurs zéro. Il définit des contrats disjonctifs, qui sont en quelque sorte des sommes, mais comme ils décrivent des contraintes et non des types, ils n'ont pas besoin d'avoir une valeur nulle (car vous ne pouvez pas déclarer de variables de ce type). C'est-à-dire qu'il est au moins possible d'écrire une fonction qui accepte un ensemble limité de types possibles, avec une vérification de type à la compilation de cet ensemble.

Maintenant, bien sûr, en l'état, la conception ne fonctionne pas vraiment pour les cas d'utilisation prévus ici : les disjonctions ne répertorient que les types ou méthodes sous-jacents et sont donc toujours largement ouvertes. Et bien sûr, même en tant qu'idée générale, c'est assez limité car vous ne pouvez pas instancier une fonction ou une valeur générique (ou de prise de somme). Mais l'OMI montre que l'espace de conception pour traiter certains des cas d'utilisation des sommes est beaucoup plus vaste que l'idée des types de somme eux-mêmes. Et que penser à des sommes est donc davantage axé sur une solution spécifique, plutôt que sur des problèmes spécifiques.

De toute façon. Je pensais juste que c'était intéressant.

@Merovius fait un excellent point sur la dernière conception générique capable de traiter certains des cas d'utilisation des types de somme. Par exemple, cette fonction qui a été utilisée plus tôt dans le fil :

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

deviendrait:

contract intOrFloat64(T) {
    T int, float64
}

func addOne(type T intOrFloat64) (x T) T {
    return x + 1
}

En ce qui concerne les types de somme eux-mêmes, si les génériques finissaient par débarquer, je serais encore plus dubitatif que je ne le suis maintenant quant à savoir si les avantages de les introduire l'emporteraient sur les coûts d'un langage simple comme Go.

Cependant, si quelque chose devait être fait, alors la solution la plus simple et la moins perturbatrice de l'OMI serait l'idée de @ianlancetaylor d '"interfaces restreintes" qui seraient implémentées exactement de la même manière que les interfaces "non restreintes" le sont aujourd'hui mais ne pourraient être satisfaites que par les types spécifiés. En fait, si vous avez pris une feuille du livre de conception générique et avez fait de la contrainte de type la première ligne du bloc d'interface :

type intOrFloat64 interface{ type int, float64 }    

alors ce serait complètement rétrocompatible car vous n'auriez pas du tout besoin d'un nouveau mot-clé (tel que restrict ). Vous pouvez toujours ajouter des méthodes à l'interface et ce serait une erreur de compilation si les méthodes n'étaient pas prises en charge par tous les types spécifiés.

Je ne vois aucun problème à attribuer des valeurs à une variable du type d'interface restreinte. Si le type de la valeur sur le RHS (ou le type par défaut d'un littéral non typé) n'était pas une correspondance exacte pour l'un des types spécifiés, il ne serait tout simplement pas compilé. On aurait donc :

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

Ce serait une erreur de compilation pour les cas d'un changement de type ne correspondant pas à un type spécifié et un contrôle d'exhaustivité pourrait être mis en œuvre. Cependant, une assertion de type serait toujours nécessaire pour convertir la valeur d'interface restreinte en une valeur de son type dynamique telle qu'elle est aujourd'hui.

Les valeurs nulles ne posent pas de problème avec cette approche (ou en tout cas pas plus qu'elles ne le sont aujourd'hui avec les interfaces en général). La valeur zéro d'une interface restreinte serait nil (impliquant qu'elle ne contient rien actuellement) et les types spécifiés auraient bien sûr leurs propres valeurs zéro, en interne, qui seraient nil pour les types nilables.

Tout cela me semble parfaitement réalisable, cependant, comme je l'ai dit plus tôt, la sécurité du temps de compilation gagnée vaut vraiment la complexité supplémentaire - j'ai des doutes car je n'ai jamais vraiment ressenti le besoin de types de somme dans ma propre programmation.

IIUC, la chose générique ne sera pas de type dynamique, donc tout ce point ne tient pas. Cependant, si les interfaces sont autorisées à fonctionner comme des contrats (ce dont je doute), cela ne résoudrait pas les vérifications et les énumérations exhaustives, ce dont (je pense, peut-être pas ?) les sumtypes.

@alanfo , @Merovius Merci pour le signal; il est intéressant que cette discussion tourne dans cette direction :

J'aime inverser le point de vue pendant une fraction de seconde : j'essaie de comprendre pourquoi les contrats ne peuvent pas être entièrement remplacés par des interfaces paramétrées qui permettent la restriction de type mentionnée ci-dessus. Pour le moment, je ne vois aucune raison technique forte, sauf que de tels types d'interface "somme", lorsqu'ils sont utilisés comme types "somme", voudraient restreindre les valeurs dynamiques possibles aux types énumérés dans l'interface, tandis que - si le même interface ont été utilisées en position de contrat - les types énumérés dans l'interface devraient servir de types sous-jacents pour constituer une restriction générique raisonnablement utile.

@Bon vin
Je ne suggérais pas que la conception des génériques aborderait tout ce que l'on pourrait vouloir faire avec les types de somme - comme @Merovius l'a clairement expliqué dans son dernier message, ils ne le feront pas. En particulier, les contraintes de type proposées pour les génériques ne couvrent que les types intégrés et tous les types qui en dérivent. D'un point de vue de type somme, le premier est trop étroit et le second trop large.

Cependant, la conception générique permettrait d'écrire une fonction qui opère sur un ensemble limité de types que le compilateur appliquerait et c'est quelque chose que nous ne pouvons pas faire du tout pour le moment.

En ce qui concerne les interfaces restreintes, le compilateur connaîtrait les types précis qui pourraient être utilisés et il deviendrait donc possible pour lui de faire une vérification exhaustive dans une instruction de changement de type.

@Griesemer

Je suis intrigué par ce que vous dites car je pensais que le projet de document de conception générique expliquait assez clairement (dans la section "Pourquoi ne pas utiliser des interfaces au lieu de contrats") pourquoi ces derniers étaient considérés comme un meilleur véhicule que le premier pour exprimer les contraintes génériques.

En particulier, un contrat peut exprimer une relation entre des paramètres de type et donc un seul contrat est nécessaire. N'importe lequel de ses paramètres de type peut être utilisé comme type de récepteur d'une méthode répertoriée dans le contrat.

On ne peut pas en dire autant d'une interface, paramétrée ou non. S'ils avaient des contraintes, chaque paramètre de type aurait besoin d'une interface distincte.

Cela rend plus difficile l'expression d'une relation entre des paramètres de type à l'aide d'interfaces, bien que cela ne soit pas impossible, comme l'a montré l'exemple de graphique.

Cependant, si vous pensez que nous pourrions "faire d'une pierre deux coups" en ajoutant des contraintes de type aux interfaces, puis en les utilisant à la fois à des fins génériques et de type somme, alors (mis à part le problème que vous avez mentionné), je pense que vous êtes probablement raison que ce serait techniquement faisable.

Je suppose que cela n'aurait pas vraiment d'importance si les contraintes de type d'interface pouvaient inclure des types "non intégrés" en ce qui concerne les génériques, bien qu'il faudrait trouver un moyen de les restreindre aux types exacts (et non aux types dérivés également) ils conviendraient donc aux types de somme. Peut-être pourrions-nous utiliser const type pour ce dernier (ou même simplement const ) si nous voulons nous en tenir aux mots-clés actuels.

@griesemer Il y a plusieurs raisons pour lesquelles les types d'interface paramétrés ne remplacent pas directement les contrats.

  1. Les paramètres de type sont les mêmes que sur les autres types paramétrés.
    Dans un genre comme

    type C2(type T C1) interface { ... }
    

    le paramètre de type T existe en dehors de l'interface elle-même. Tout argument de type passé en tant que T doit déjà être connu pour satisfaire le contrat C1 , et le corps de l'interface ne peut pas davantage contraindre T . Ceci est différent des paramètres de contrat, qui sont contraints par le corps du contrat en raison de leur transmission. Cela signifierait que chaque paramètre de type d'une fonction devrait être contraint indépendamment avant d'être passé en tant que paramètre à la contrainte sur tout autre paramètre de type.

  2. Il n'y a aucun moyen de nommer le type de récepteur dans le corps de l'interface.
    Les interfaces devraient vous permettre d'écrire quelque chose comme :

    type C3(type U C1) interface(T) {
        Add(T) T
    }
    

    T désigne le type de récepteur.

  3. Certains types d'interface ne se satisferaient pas en tant que contraintes génériques.
    Toute opération reposant sur plusieurs valeurs du type de récepteur n'est pas compatible avec la répartition dynamique. Ces opérations ne seraient donc pas utilisables sur des valeurs d'interface. Cela signifierait que l'interface ne se satisferait pas (par exemple en tant qu'argument de type d'un paramètre de type contraint par la même interface). Ce serait surprenant. Une solution consiste simplement à ne pas autoriser du tout la création de valeurs d'interface pour de telles interfaces, mais cela interdirait de toute façon le cas d'utilisation envisagé ici.

En ce qui concerne la distinction entre les contraintes de type sous-jacentes et les contraintes d'identité de type, il existe une méthode qui pourrait fonctionner. Imaginez que nous puissions définir des contraintes personnalisées, comme

contract (T) indenticalTo(U) {
    *T *U
}

(Ici, j'utilise une notation inventée pour spécifier un seul type comme "récepteur". Je prononcerai un contrat avec un type de récepteur explicite comme "contrainte", tout comme une fonction avec un récepteur se prononce "méthode". Les paramètres après le nom du contrat sont des paramètres de type normal et ne peuvent pas apparaître à gauche d'une clause de contrainte dans le corps de la contrainte.)

Étant donné que le type sous-jacent d'un type pointeur littéral est lui-même, cette contrainte implique que T est identique à U . Comme ceci est déclaré comme une contrainte, vous pouvez écrire (identicalTo(int)), (identicalTo(uint)), ... comme une disjonction de contrainte.

Bien que les contrats puissent être utiles pour exprimer une sorte de types de somme, je ne pense pas que vous puissiez exprimer des types de somme génériques avec eux. D'après ce que j'ai vu du brouillon, il faut lister des types concrets, vous ne pouvez donc pas écrire quelque chose comme ceci :

contract Foo(T, U) {
    T U, int64
}

Lequel aurait besoin d'exprimer un type somme générique d'un type inconnu et d'un ou plusieurs types connus. Même si la conception permettait de telles constructions, elles auraient l'air étranges lorsqu'elles étaient utilisées, car les deux paramètres seraient effectivement la même chose.

J'ai réfléchi un peu plus à la façon dont le projet de conception générique pourrait changer si les interfaces étaient étendues pour inclure des contraintes de type, puis utilisées pour remplacer les contrats dans la conception.

Il est peut-être plus facile d'analyser la situation si l'on considère différents nombres de paramètres de type :

Aucun paramètre

Pas de changement :)

Un paramètre

Pas de vrais problèmes ici. Une interface paramétrée (par opposition à une interface non générique) ne serait nécessaire que si le paramètre de type fait référence à lui-même et/ou d'autres types fixes indépendants étaient nécessaires pour instancier l'interface.

Deux ou plusieurs paramètres

Comme mentionné précédemment, chaque paramètre de type devrait être contraint individuellement s'il avait besoin d'une contrainte.

Une interface paramétrée ne serait nécessaire que si :

  1. Le paramètre de type fait référence à lui-même.

  2. L'interface faisait référence à un ou plusieurs autres paramètres de type qui _avaient déjà été déclarés_ dans la section des paramètres de type (nous ne voudrions probablement pas revenir en arrière ici).

  3. D'autres types fixes indépendants étaient nécessaires pour instancier l'interface.

Parmi ceux-ci, (2) est vraiment le seul cas gênant car il exclurait que les paramètres de type se réfèrent les uns aux autres, comme dans l'exemple de graphique. Que l'on ait déclaré 'Node' ou 'Edge' en premier, son interface contraignante aurait toujours besoin que l'autre soit passée en tant que paramètre de type.

Cependant, comme indiqué dans le document de conception, vous pouvez contourner ce problème en déclarant non paramétrés (car ils ne se réfèrent pas à eux-mêmes) NodeInterface et EdgeInterface au niveau supérieur car il n'y aurait alors aucun problème à se référer l'un à l'autre quel que soit le ordre de déclaration. Vous pouvez ensuite utiliser ces interfaces pour contraindre les paramètres de type de la structure Graph et ceux de sa méthode 'New' associée.

Il ne semble donc pas qu'il y ait de problèmes insurmontables ici même si l'idée des contrats est plus agréable.

Vraisemblablement, comparable pourrait maintenant simplement devenir une interface intégrée plutôt qu'un contrat.

Les interfaces pourraient, bien sûr, être intégrées les unes dans les autres comme elles le peuvent déjà.

Je ne sais pas comment traiter le problème de la méthode du pointeur (dans les cas où ceux-ci devraient être spécifiés dans le contrat) car vous ne pouvez pas spécifier de récepteur pour une méthode d'interface. Peut-être qu'une syntaxe spéciale (comme faire précéder le nom de la méthode d'un astérisque) serait nécessaire pour indiquer une méthode de pointeur.

En ce qui concerne maintenant les observations de @stevenblenkinsop , je me demande si cela rendrait la vie plus facile si les interfaces paramétrées ne permettaient pas du tout de contraindre leurs propres paramètres de type? Je ne suis pas sûr que ce soit vraiment une fonctionnalité utile de toute façon, à moins que quelqu'un puisse penser à un cas d'utilisation raisonnable.

Personnellement, je ne trouve pas surprenant que certains types d'interfaces ne puissent pas se satisfaire de contraintes génériques. Un type d'interface n'est en aucun cas un type de récepteur valide et ne peut donc avoir aucune méthode.

Bien que l'idée de Steven d'une fonction intégrée identiqueTo () fonctionnerait, elle me semble potentiellement longue pour spécifier des types de somme. Je préférerais une syntaxe qui permette de spécifier une ligne entière de types comme étant exacts.

@urandom est correct, bien sûr, que dans l'état actuel du projet générique, on ne peut répertorier que les types concrets (intégrés ou intégrés agrégés). Cependant, cela devrait clairement changer si des interfaces restreintes sont utilisées à la place pour les types génériques et les types de somme. Je n'exclurais donc pas que quelque chose comme ça soit autorisé dans un environnement unifié :

interface Foo(T) {
    const type T, int64  // 'const' indicates types are exact i.e. no derived types
}

pourquoi ne pouvons-nous pas simplement ajouter des syndicats discriminés au langage au lieu d'inventer une autre promenade autour de leur absence ?

@griesemer Vous êtes peut-être au courant ou non, mais j'ai été en faveur de l'utilisation d'interfaces pour spécifier les contraintes depuis le début :) Je ne pense plus que les idées exactes que j'évoque dans ce post sont la voie à suivre (en particulier les choses Je suggère de s'adresser aux opérateurs). Et j'aime beaucoup plus l'itération la plus récente de la conception des contrats que la précédente. Mais en général, je suis tout à fait d'accord pour dire que les interfaces (éventuellement étendues) en tant que contraintes sont viables et méritent d'être considérées.

@urandom

Je ne pense pas que vous puissiez exprimer des types de somme génériques avec eux

Je tiens à réitérer que mon propos n'était pas "vous pouvez créer des types de somme avec eux", mais "vous pouvez résoudre certains problèmes que les types de somme résolvent avec eux". Si votre énoncé de problème est "Je veux des types de somme", alors il n'est pas surprenant que les types de somme soient la seule solution. Je voulais simplement exprimer qu'il serait peut-être possible de s'en passer, si nous nous concentrons sur les problèmes que vous souhaitez résoudre avec eux.

@alanfo

Cela rend plus difficile l'expression d'une relation entre des paramètres de type à l'aide d'interfaces, bien que cela ne soit pas impossible, comme l'a montré l'exemple de graphique.

Je pense que "maladroit" est subjectif. Personnellement, je trouve l'utilisation d'interfaces paramétrées plus naturelle et l'exemple de graphe une très bonne illustration. Pour moi, un Graph est une entité, pas une relation entre une sorte de Edge et une sorte de Node.

Mais TBH, je ne pense pas que l'un ou l'autre soit vraiment plus ou moins gênant - vous écrivez à peu près exactement le même code pour exprimer à peu près exactement les mêmes choses. Et FWIW, il existe un art antérieur pour cela. Les classes de types Haskell se comportent beaucoup comme des interfaces et comme le souligne cet article wiki, utiliser des classes de types multi-paramètres pour exprimer les relations entre les types est une chose assez normale à faire.

@stevenblenkinsop

Il n'y a aucun moyen de nommer le type de récepteur dans le corps de l'interface.

La façon dont vous traiteriez cela est avec des arguments de type sur le site d'utilisation. c'est à dire

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
}

Cela nécessite un certain soin quant au fonctionnement de l'unification, afin que vous puissiez autoriser les paramètres de type auto-référentiels, mais je pense que cela peut fonctionner.

Vos 1. et 3. Je ne comprends pas vraiment, je dois l'admettre. Je bénéficierais de quelques exemples concrets.


Quoi qu'il en soit, il est un peu fallacieux de laisser tomber cela à la fin de la poursuite de cette discussion, mais ce n'est probablement pas le bon problème pour parler des détails de la conception des génériques. Je n'en ai parlé que pour élargir un peu l'espace de conception de ce numéro :) Parce qu'il me semblait que cela faisait longtemps que de nouvelles idées n'avaient pas été introduites dans la discussion sur les types de somme.

```go
switch task {
case tasks.SearchAdd:
    // task is tasks.SearchAdd in this scope
case tasks.SearchUpdate:
case tasks.SearchDelete:
}

serait beaucoup plus Goish dans l'esprit que toute autre approche que Go permet dans son état actuel. Pas besoin de correspondance de motifs Haskellish, la plongée juist jusqu'à un certain type est plus que suffisante.
Je ne pense pas que manipuler le sens de la variable de tâche soit une bonne idée, bien qu'acceptable.

Bonne chance avec vos visiteurs alors.

Pourquoi pensez-vous que la correspondance de motifs ne peut pas être effectuée dans Go ? Si vous manquez d'exemples de correspondance de motifs, consultez, par exemple, Rust.

@Merovius re: "Pour moi, un graphique est une entité"

Est-ce une entité au moment de la compilation ou a-t-elle une représentation au moment de l'exécution ? L'une des principales différences entre les contrats et les interfaces est qu'une interface est un objet d'exécution. Il participe au ramasse-miettes, possède des pointeurs vers d'autres objets d'exécution, etc. La conversion d'un contrat en une interface signifierait l'introduction d'un nouvel objet d'exécution temporaire qui contient des pointeurs vers les nœuds/sommets qu'il contient (combien ?), ce qui semble gênant lorsque vous avez une collection de fonctions graphiques, dont chacune pourrait plus naturellement prendre des paramètres pointant vers diverses parties des graphiques à leur manière, en fonction des besoins de la fonction.

Votre intuition pourrait être induite en erreur en utilisant « Graph » pour un contrat, car « Graph » ressemble à un objet et le contrat ne spécifie pas vraiment de sous-graphe particulier ; c'est plus comme définir un ensemble de termes à utiliser plus tard, comme vous le feriez en mathématiques ou en droit. Dans certains cas, vous voudrez peut-être à la fois un contrat de graphique et une interface graphique, ce qui entraînera un conflit de noms ennuyeux. Je ne peux pas penser à un meilleur nom du haut de ma tête, cependant.

En revanche, une union discriminée est un objet d'exécution. Sans restreindre la mise en œuvre, vous devez penser à ce que pourrait être un tableau d'entre eux. Un tableau à N éléments a besoin de N discriminateurs et N valeurs, et il existe diverses manières de procéder. (Julia a des représentations intéressantes, mettant parfois les discriminateurs et les valeurs dans des tableaux séparés.)

Pour suggérer une réduction des erreurs qui se produisent actuellement partout avec les schémas interface{} , mais pour supprimer la frappe continue de l'opérateur | , je suggérerais ce qui suit :

type foobar union {
    int
    float64
}

Le simple cas d'utilisation consistant à remplacer de nombreux interface{} par ce type de sécurité de type serait un gain énorme pour la bibliothèque. Il suffit de regarder la moitié des éléments de la bibliothèque cryptographique pour l'utiliser.

Des problèmes tels que : ah vous avez donné ecdsa.PrivateKey au lieu de *ecdsa.PrivateKey - voici une erreur générique que seul ecdsa.PrivateKey est pris en charge. Le simple fait qu'il s'agisse de types d'union clairs augmenterait considérablement la sécurité des types.

Bien que cette suggestion prenne plus d'_espace_ par rapport à int|float64 elle oblige l'utilisateur à y réfléchir. Garder la base de code beaucoup plus propre.

Pour suggérer une réduction des erreurs qui se produisent actuellement partout avec les schémas interface{} , mais pour supprimer la frappe continue de l'opérateur | , je suggérerais ce qui suit :

type foobar union {
    int
    float64
}

Le simple cas d'utilisation consistant à remplacer de nombreux interface{} par ce type de sécurité de type serait un gain énorme pour la bibliothèque. Il suffit de regarder la moitié des éléments de la bibliothèque cryptographique pour l'utiliser.

Des problèmes tels que : ah vous avez donné ecdsa.PrivateKey au lieu de *ecdsa.PrivateKey - voici une erreur générique que seul ecdsa.PrivateKey est pris en charge. Le simple fait qu'il s'agisse de types d'union clairs augmenterait considérablement la sécurité des types.

Bien que cette suggestion prenne plus d'_espace_ par rapport à int|float64 elle oblige l'utilisateur à y réfléchir. Garder la base de code beaucoup plus propre.

Voir ceci (commentaire) , c'est ma proposition.

En fait, nous pouvons introduire nos deux idées dans le langage. Cela conduira à l'existence de deux façons natives de faire ADT, mais avec des syntaxes différentes.

Ma proposition de fonctionnalités, en particulier la correspondance de modèles, votre compatibilité et votre capacité à bénéficier de la fonctionnalité pour les anciennes bases de code.

Mais ça a l'air exagéré, n'est-ce pas ?

En outre, le type de somme peut être défini pour avoir nil comme valeur par défaut. Bien sûr, cela nécessitera nil case dans chaque commutateur.
La correspondance de motifs peut être effectuée comme :
-- déclaration

type U enum{
    A(int64),
    B(string),
}

-- correspondant à

...
var a U
...
switch a {
    case A{b}:
         //process b here
    case B{b}:
         //...
    case nil:
         //...
}
...

Si l'on n'aime pas la correspondance de motifs - voir la proposition de Sirkon ci-dessus.

En outre, le type de somme peut être défini pour avoir nil comme valeur par défaut. Bien sûr, cela nécessitera nil case dans chaque commutateur.

Ne serait-il pas plus facile d'interdire les valeurs non initiées au moment de la compilation ? Pour les cas où nous avons besoin d'une valeur initialisée, nous pourrions l'ajouter au type de somme : c'est-à-dire

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

En outre, le type de somme peut être défini pour avoir nil comme valeur par défaut. Bien sûr, cela nécessitera nil case dans chaque commutateur.

Ne serait-il pas plus facile d'interdire les valeurs non initiées au moment de la compilation ? Pour les cas où nous avons besoin d'une valeur initialisée, nous pourrions l'ajouter au type de somme : c'est-à-dire

Casse le code existant.

En outre, le type de somme peut être défini pour avoir nil comme valeur par défaut. Bien sûr, cela nécessitera nil case dans chaque commutateur.

Ne serait-il pas plus facile d'interdire les valeurs non initiées au moment de la compilation ? Pour les cas où nous avons besoin d'une valeur initialisée, nous pourrions l'ajouter au type de somme : c'est-à-dire

Casse le code existant.

Il n'y a pas de code existant avec des types de somme. Bien que je pense que la valeur par défaut devrait être celle définie dans le type lui-même. Soit la première entrée, soit la première lettre alphabétique, ou quelque chose.

Il n'y a pas de code existant avec des types de somme. Bien que je pense que la valeur par défaut devrait être celle définie dans le type lui-même. Soit la première entrée, soit la première lettre alphabétique, ou quelque chose.

J'étais d'accord avec vous au premier abord, mais après réflexion, le nouveau nom réservé pour l'union aurait pu être utilisé précédemment dans une base de code (union, enum, etc.)

Je pense que l'obligation de vérifier le zéro serait assez pénible à utiliser.

Cela ressemble à un changement décisif pour la compatibilité descendante qui ne pourrait être résolu que par Go2.0

Il n'y a pas de code existant avec des types de somme. Bien que je pense que la valeur par défaut devrait être celle définie dans le type lui-même. Soit la première entrée, soit la première lettre alphabétique, ou quelque chose.

Mais il y a beaucoup de codes go existants qui ont un tout nil'able. Ce sera certainement un changement radical. Pire encore, gofix et des outils similaires ne peuvent changer que les types de variables en Options (du même type) produisant au moins un code moche, dans tous les autres cas, cela cassera simplement tout dans le monde.

Si rien d'autre, reflect.Zero doit retourner quelque chose . Mais ce sont tous des obstacles techniques qui peuvent être résolus - par exemple, cet obstacle est assez évident si la valeur zéro d'un type de somme est bien définie et sera probablement "panique", sinon. La plus grande question est toujours de savoir pourquoi un certain choix est le bon et si et comment tout choix s'intègre dans la langue en général. OMI, la meilleure façon de résoudre ces problèmes est toujours de parler de cas concrets où les types de somme abordent des problèmes spécifiques ou leur manque a créé ceux. Les trois critères d'un rapport d'expérience s'appliquent pour cela.

Notez en particulier que « il ne devrait pas y avoir de valeur zéro et il devrait être interdit de créer des valeurs non initialisées » et « la valeur par défaut devrait être la première entrée » ont été mentionnés ci-dessus à plusieurs reprises. Donc, que vous pensiez que cela devrait être ainsi ou que cela n'ajoute pas vraiment de nouvelles informations. Mais cela rend un fil déjà énorme encore plus long et plus difficile pour l'avenir d'y trouver les informations pertinentes.

Considérons Reflect.Kind. Il existe un type invalide, qui a la valeur int par défaut de 0. Si vous aviez une fonction qui acceptait un reflect.Kind et que vous passiez une variable non initialisée de ce type, elle finirait par être invalide. Si, par hypothèse, reflect.Kind peut être changé en un type sum, il devrait peut-être conserver le comportement d'avoir une entrée invalide nommée comme étant l'entrée par défaut, plutôt que de s'appuyer sur une valeur nulle.

Maintenant, considérons html/template.contentType. Le type Plain est sa valeur par défaut, et est en effet traité comme tel par la fonction stringify, car il s'agit du repli. Dans un futur hypothétique, non seulement vous auriez toujours besoin de ce comportement, mais il est également infaisable d'utiliser une valeur nulle pour cela, car nul ne signifiera rien pour un utilisateur de ce type. Il sera à peu près obligatoire de toujours renvoyer une valeur nommée ici, et vous avez une valeur par défaut claire de ce que devrait être cette valeur.

C'est encore moi avec un autre exemple où les types de données algébriques/variadiques/sommes/quels que soient les types de données fonctionnent bien.

Ainsi, nous utilisons une base de données noSQL sans transactions (système distribué, les transactions ne fonctionnent pas pour nous) mais nous aimons l'intégrité et la cohérence des données pour des raisons évidentes et devons contourner les problèmes d'accès simultané, généralement avec des requêtes de mise à jour conditionnelles un peu complexes sur un seul record (l'écriture d'un seul enregistrement est atomique).

J'ai une nouvelle tâche pour écrire un ensemble d'entités qui peuvent être insérées, ajoutées ou supprimées (une seule de ces opérations).

Si nous pouvions avoir quelque chose comme

type EntityOp oneof {
    Insert   Reference
    NewState string
    Delete   struct{}
}

La méthode pourrait être juste

type DB interface {
    …
    Capture(ctx context.Context, processID string, ops map[string]EntityOp) (bool, error)
}

Une utilisation fantastique des temps de somme est de représenter des nœuds dans un AST. Une autre consiste à remplacer nil par un option qui est vérifié au moment de la compilation.

@DemiMarie mais dans le Go d'aujourd'hui, cette somme peut aussi être nulle, comme je l'ai proposé plus haut, on peut simplement faire nil pour être variante de chaque enum, il y aura cas nil dans chaque switch mais cette obligation n'est pas si mal, surtout si on voulez cette fonctionnalité sans casser tout le code go existant (actuellement, nous avons tout nillable)

Je ne sais pas s'il a sa place ici, mais tout cela me reste dactylographié, où existe une fonctionnalité très intéressante appelée "Types littéraux de chaîne" et nous pouvons le faire :

var name: "Peter" | "Consuela"; // string type with compile-time constraint

C'est comme une énumération de chaîne, ce qui est bien mieux que les énumérations numériques traditionnelles à mon avis.

@Merovius
un exemple concret travaille avec JSON arbitraire.
Dans Rust, il peut être représenté comme
valeur enum {
Nul,
Bool(bool),
Nombre (Nombre),
Chaîne (chaîne),
Tableau (Vec),
Objet (Carte),
}

Un type union comme deux avantages :

  1. Auto-documentation du code
  2. Autoriser le compilateur ou go vet à vérifier l'utilisation incorrecte d'un type d'union
    (par exemple un commutateur où tous les types ne sont pas cochés)

Pour la syntaxe, ce qui suit doit être compatible avec Go1 , comme avec le type alias :

type Token = int | float64 | string

Un type union peut être implémenté en interne en tant qu'interface ; ce qui est important, c'est que l'utilisation d'un type d'union permette au code d'être plus lisible et d'attraper des erreurs comme

var tok Token

switch t := tok.(type) {
case int:
    // do something
}

Le compilateur devrait générer une erreur, car tous les types Token sont pas utilisés dans le commutateur.

Le problème avec cela, c'est qu'il n'y a (à ma connaissance) aucun moyen de stocker des types de pointeur (ou des types qui contiennent des pointeurs, tels que string ) et des types non pointeurs ensemble. Même les types avec des mises en page différentes ne fonctionneraient pas. N'hésitez pas à me corriger mais le problème est que le GC précis ne fonctionne pas bien avec des variables qui peuvent être à la fois des pointeurs et des variables simples.

Nous pouvons emprunter la voie de la boxe implicite - comme le fait actuellement interface{} . Mais je ne pense pas que cela offre suffisamment d'avantages - cela ressemble toujours à un type d'interface glorifié. Peut-être qu'une sorte de vérification de vet peut être développée à la place ?

Le ramasse-miettes aurait besoin de lire les bits de balise de l'union pour déterminer la disposition. Ce n'est pas impossible mais ce serait un gros changement dans l'exécution qui pourrait ralentir gc.

Peut-être qu'une sorte de contrôle vétérinaire peut être développé à la place ?

https://github.com/BurntSushi/go-sumtype

Le ramasse-miettes aurait besoin de lire les bits de balise de l'union pour déterminer la disposition.

C'est exactement la même race qui existait avec les interfaces, quand elles pouvaient contenir des non-pointeurs. Cette conception a été explicitement éloignée de.

go-sumtype est intéressant, merci. Mais que se passe-t-il si le même package définit deux types d'union ?

Le compilateur pourrait implémenter le type union en interne comme interface, mais en ajoutant une syntaxe uniforme et une vérification de type standard.

S'il y a N projets utilisant des types d'union, chacun différemment et avec N assez grand, peut-être que l'introduction de la seule façon de le faire peut être la meilleure solution.

Mais que se passe-t-il si le même package définit deux types d'union ?

Pas grand chose? La logique est par type et utilise une méthode factice pour reconnaître les implémenteurs. Utilisez simplement des noms différents pour les méthodes factices.

@skybrian IIRC bitmap actuel qui spécifie la disposition du type est actuellement stocké à un seul endroit. Ajouter une telle chose par objet ajouterait beaucoup de sauts et ferait de chaque objet facultatif une racine GC.

Le problème avec cela, c'est qu'il n'y a (à ma connaissance) aucun moyen de stocker des types de pointeur (ou des types qui contiennent des pointeurs, tels que des chaînes) et des types non pointeurs ensemble

Je ne crois pas que ce soit nécessaire. Le compilateur peut chevaucher la disposition des types lorsque les pointeurs-maps correspondent, et pas autrement. Lorsqu'elles ne correspondent pas, il serait libre de les disposer consécutivement ou d'utiliser une approche de pointeur telle qu'elle est utilisée actuellement pour les interfaces. Il pourrait même utiliser des dispositions non contiguës pour les membres de la structure.

Mais je ne pense pas que cela offre suffisamment d'avantages - cela ressemble toujours à un type d'interface glorifié.

Dans ma proposition , les types d'union sont _exactement_ un type d'interface glorifié - un type d'union n'est qu'un sous-ensemble d'une interface qui n'est autorisé à stocker qu'un ensemble de types énumérés. Cela donne potentiellement au compilateur la liberté de choisir une méthode de stockage plus efficace pour certains ensembles de types, mais c'est un détail d'implémentation, pas la motivation principale.

@rogpeppe - Par curiosité, puis-je utiliser le type sum directement ou dois-je explicitement le

Est-ce que je peux faire

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 cela ne peut pas être fait, je ne vois pas beaucoup de différence avec

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

Par curiosité, puis-je utiliser le type sum directement ou dois-je explicitement le convertir en un type connu pour en faire quoi que ce soit? Parce que si je dois constamment le convertir en un type connu, je ne sais vraiment pas quels avantages cela donne par rapport à ce qui nous est déjà donné avec les interfaces.

@rogpeppe , merci de me corriger si je me trompe
Devoir toujours effectuer une correspondance de modèle (c'est ainsi que le "casting" est appelé lorsque l'on travaille avec des types de somme dans les langages de programmation fonctionnels) est en fait l'un des plus grands avantages de l'utilisation de types de somme. Forcer le développeur à gérer explicitement toutes les formes possibles d'un type somme est un moyen d'empêcher le développeur d'utiliser une variable en pensant qu'elle est d'un type donné alors qu'elle est en réalité différente. Un exemple exagéré serait, en JavaScript :

const a = "1" // string "1"
const b = a + 5 // string "15" and not number 6

Si cela ne peut pas être fait, je ne vois pas beaucoup de différence avec

Je pense que vous énoncez vous-même certains avantages, n'est-ce pas ?
??

Le principal avantage que je vois est la vérification des erreurs au moment de la compilation, en quelque sorte, car le désalignement se produirait toujours au moment de l'exécution, ce qui est plus probable lorsque vous verriez un problème avec un type non valide transmis. L'autre avantage étant une interface plus contrainte, ce qui, à mon avis, ne justifie pas un changement de langue.

// Would the compiler error out on incomplete switch types?

Sur la base de ce que font les langages de programmation fonctionnels, je pense que cela devrait être possible et configurable 👍

@xibz également des performances car cela peut être fait au moment de la compilation par rapport à l'exécution, mais il y a ensuite des génériques, espérons-le, un jour avant ma mort.

@xibz

Par curiosité, puis-je utiliser le type sum directement ou dois-je explicitement le convertir en un type connu pour en faire quoi que ce soit?

Vous pouvez appeler des méthodes dessus si tous les membres du type partagent cette méthode.

En prenant votre int | float64 comme exemple, quel serait le résultat de :

var x int|float64 = int(2)
var y int|float64 = float64(0.5)
fmt.Println(x * y)

Est-ce qu'il ferait une conversion implicite de int à float64 ? Ou de float64 à int . Ou ça paniquerait ?

Vous avez donc presque raison - vous auriez besoin d'une vérification de type avant de l'utiliser dans la plupart des cas. Je pense que c'est un avantage, pas un inconvénient cependant.

L'avantage de l'exécution peut être important, BTW. Pour continuer avec votre exemple de type, une tranche de type [](int|float64) n'aurait pas besoin de contenir de pointeurs car il est possible de représenter toutes les instances du type en quelques octets (probablement 16 octets en raison de restrictions d'alignement), ce qui pourrait conduire à des améliorations significatives des performances dans certains cas.

@rogpeppe

vous devrez effectuer une vérification de type avant de l'utiliser dans la plupart des cas. Je pense que c'est un avantage, pas un inconvénient cependant.

Je suis d'accord pour dire que ce n'est pas un inconvénient. J'essaie juste de voir quels avantages cela nous donne par rapport aux interfaces.

une tranche de type n'aurait pas besoin de contenir de pointeurs car il est possible de représenter toutes les instances du type en quelques octets (probablement 16 octets en raison de restrictions d'alignement), ce qui pourrait conduire à des améliorations significatives des performances dans certains cas.

Hm, je ne suis pas trop sûr d'acheter la partie importante de cela. Je suis sûr que dans de très rares cas, cela réduirait la taille de la mémoire de moitié. Cela dit, je ne pense pas que la mémoire sauvegardée ne soit pas assez importante pour un changement de langue.

@stouf

Devoir toujours effectuer une correspondance de modèle (c'est ainsi que l'on appelle "casting" lorsque l'on travaille avec des types de somme dans des langages de programmation fonctionnels) est en fait l'un des plus grands avantages de l'utilisation de types de somme

Mais quels avantages cela apporte-t-il au langage qui n'est pas déjà géré avec des interfaces ? À l'origine, j'étais complètement pour les types de somme, mais en commençant à y penser, j'ai en quelque sorte perdu les avantages que cela apporterait.


Cela dit, si l'utilisation d'un type somme peut fournir un code plus propre et plus lisible, je serais à 100% pour cela. Cependant, à première vue, il semble que cela semblerait presque identique au code d'interface.

La correspondance de modèle

C'est un peu artificiel, mais par exemple, si vous avez un arbre de syntaxe d'expression, pour faire correspondre une équation quadratique, vous pouvez faire quelque chose comme :

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

Des exemples simples qui ne vont qu'à un niveau de profondeur ne montreront pas une grande différence, mais ici nous allons jusqu'à cinq niveaux de profondeur, ce qui serait assez compliqué à faire avec des commutateurs de type imbriqués. Un langage avec correspondance de modèles peut aller à plusieurs niveaux tout en s'assurant que vous ne manquez aucun cas.

Je ne sais pas à quel point cela revient en dehors des compilateurs, cependant.

@xibz
Un avantage des types sum est que vous et le compilateur savez tous les deux exactement quels types peuvent exister dans la somme. C'est essentiellement la différence. Avec des interfaces vides, vous devrez toujours vous inquiéter et vous prémunir contre les abus dans l'api, en ayant toujours une branche dont le seul but est de récupérer lorsqu'un utilisateur vous donne un type que vous n'attendez pas.

Comme il semble qu'il y ait peu d'espoir que les types sum soient implémentés dans le compilateur, j'espère qu'au moins une directive de commentaire standard, comme //go:union A | B | C est proposée et prise en charge par go vet .

Avec un moyen standard de déclarer un type somme, après N ans, il sera possible de savoir combien de packages l'utilisent.

Avec les récentes ébauches de conception pour les génériques, peut-être que les types de somme pourraient leur être liés.

L'un des projets a lancé l'idée d'utiliser des interfaces au lieu de contrats, et les interfaces devraient prendre en charge les listes de types :

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Bien que cela ne produise pas en soi une union chargée en mémoire, mais peut-être lors de l'utilisation dans une fonction ou une structure générique, il ne serait pas encadré, et cela fournirait au moins une sécurité de type lorsqu'il s'agirait d'une liste finie de types.

Et peut-être que l'utilisation de ces interfaces particulières au sein de commutateurs de type nécessiterait qu'un tel commutateur soit exhaustif.

Ce n'est pas la syntaxe courte idéale (par exemple : Foo | int32 | []Bar ), mais c'est quelque chose.

Avec les récentes ébauches de conception pour les génériques, peut-être que les types de somme pourraient leur être liés.

L'un des projets a lancé l'idée d'utiliser des interfaces au lieu de contrats, et les interfaces devraient prendre en charge les listes de types :

type Foo interface { 
     int64, int32, int, uint, uint32, uint64
}

Bien que cela ne produise pas en soi une union chargée en mémoire, mais peut-être lors de l'utilisation dans une fonction ou une structure générique, il ne serait pas encadré, et cela fournirait au moins une sécurité de type lorsqu'il s'agirait d'une liste finie de types.

Et peut-être que l'utilisation de ces interfaces particulières au sein de commutateurs de type nécessiterait qu'un tel commutateur soit exhaustif.

Ce n'est pas la syntaxe courte idéale (par exemple : Foo | int32 | []Bar ), mais c'est quelque chose.

Assez similaire à ma proposition : https://github.com/golang/go/issues/19412#issuecomment -520306000

type foobar union {
  int
  float
}

@mathieudevos wow, j'aime bien ça en fait.

Pour moi, la plus grande bizarrerie (la seule bizarrerie restante, vraiment) avec la dernière proposition de génériques est les listes de types dans les interfaces. Ils ne correspondent tout simplement pas. Ensuite, vous vous retrouvez avec des interfaces que vous ne pouvez utiliser que comme contraintes de paramètres de type, et ainsi de suite...

Le concept union fonctionne très bien dans mon esprit car vous pouvez ensuite intégrer un union dans un interface pour accomplir une "contrainte qui inclut des méthodes et des types bruts". Les interfaces continuent de fonctionner telles quelles, et avec une sémantique définie autour d'une union, elles peuvent être utilisées dans du code normal et le sentiment d'étrangeté disparaît.

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

EDIT - En fait, je viens de voir ce CL : https://github.com/golang/go/commit/af48c2e84b52f99d30e4787b1b8d527b5cd2ab64

Le principal avantage de ce changement est qu'il ouvre la porte à des
utilisation (sans contrainte) d'interfaces avec listes de types

...Super! Les interfaces deviennent pleinement utilisables en tant que types de somme, ce qui unifie la sémantique à travers l'utilisation régulière et contrainte. (Évidemment pas encore allumé, mais je pense que c'est une excellente destination vers laquelle se diriger.)

J'ai ouvert #41716 pour discuter de la façon dont une version des types de somme apparaît dans le projet de conception générique actuel.

Je voulais juste partager une vieille proposition de @henryas sur les types de données algébriques. C'est très bien écrit avec des cas d'utilisation fournis.
https://github.com/golang/go/issues/21154
Malheureusement, il a été fermé par @mvdan le même jour sans aucune appréciation du travail. Je suis presque sûr que cette personne a vraiment ressenti cela et qu'il n'y a donc plus d'activités sur le compte gh. Je suis désolé pour ce gars.

J'aime beaucoup le #21154. Cela semble être une chose différente cependant (et donc le commentaire de

Oui, j'aimerais vraiment avoir la possibilité de modéliser une logique métier de plus haut niveau d'une manière similaire à celle décrite dans ce numéro. Les types de somme pour les options restreintes de type énumération et les types acceptés suggérés comme dans l'autre problème seraient géniaux dans la boîte à outils. Le code d'entreprise/de domaine dans Go semble parfois un peu maladroit en ce moment.

Mon seul commentaire est que type foo,bar intérieur d'une interface semble un peu gênant et de seconde classe, et je suis d'accord qu'il devrait y avoir un choix entre nullable et non nullable (si possible).

@ProximaB Je ne comprends pas pourquoi vous dites "il n'y a plus d'activités sur le compte gh". Depuis, ils ont créé et commenté un tas d'autres problèmes, dont beaucoup sur le projet Go. Je ne vois aucune preuve que leur activité ait été influencée par ce problème.

De plus, je suis tout à fait d'accord avec Daniel pour clore ce problème en dupe de celui-ci. Je ne comprends pas pourquoi @andig dit qu'ils proposent quelque chose de différent. Pour autant que je puisse comprendre le texte de #21154, il propose exactement la même chose dont nous discutons ici et je ne serais pas du tout surpris si même la syntaxe exacte était déjà suggérée quelque part dans ce mégathread (la sémantique, dans la mesure où décrits, l'étaient très certainement. Plusieurs fois). En fait, j'irais jusqu'à dire que la fermeture de Daniels est prouvée par la longueur de ce numéro, car il contient déjà une discussion assez détaillée et nuancée de # 21154, donc répéter tout cela aurait été ardu et redondant.

Je suis d'accord et je comprends qu'il est probablement décevant d'avoir une proposition fermée comme dupe. Mais je ne connais pas de moyen pratique de l'éviter. Avoir la discussion en un seul endroit semble bénéfique pour toutes les personnes impliquées et garder plusieurs problèmes pour la même chose ouverts, sans aucune discussion sur eux, est clairement inutile.

De plus, je suis tout à fait d'accord avec Daniel pour clore ce problème en dupe de celui-ci. Je ne comprends pas pourquoi @andig dit qu'ils proposent quelque chose de différent. Pour autant que je puisse comprendre le texte de #21154, il propose exactement la même chose dont nous discutons ici

En relisant ce problème, je suis d'accord. Il semble que j'aie confondu ce problème avec les contrats génériques. Je soutiendrais fortement les types de somme. Je ne voulais pas paraître dur, veuillez m'excuser si cela vous paraissait comme ça.

Je suis peut être difficile parfois, donc par tous les moyens indiquent quand je fais une erreur :) Mais dans ce cas , je pense que toute proposition de types de somme spécifique devrait fourche de ce fil comme un jardinage humain et numéro https: / /github.com/golang/go/issues/19412#issuecomment -701625548

Je suis un jardinage humain et problème peut être difficile parfois, donc par tous les moyens indiquent quand je fais une erreur :) Mais dans ce cas , je pense que toute proposition de types de somme spécifique devrait fourche de ce fil comme # 19412 ( commenter)

@mvdan n'est pas humain. Croyez-moi. Je suis son voisin. Je rigole.

Merci pour l'attention. Je ne suis pas si attaché à mes propositions. N'hésitez pas à mutiler, à modifier et à abattre n'importe quelle partie d'entre eux. J'ai été occupé dans la vraie vie, donc je n'ai pas eu la chance d'être actif dans les discussions. Il est bon de savoir que les gens lisent mes propositions et que certains les aiment vraiment.

L'intention initiale est de permettre le regroupement des types selon leur pertinence de domaine, où ils ne partagent pas nécessairement des comportements communs, et de demander au compilateur de l'appliquer. À mon avis, il s'agit simplement d'un problème de vérification statique, qui se fait lors de la compilation. Le compilateur n'a pas besoin de générer du code qui conserve la relation complexe entre les types. Le code généré peut traiter ces types de domaine normalement comme s'il s'agissait du type interface{} normal. La différence est que le compilateur effectue désormais une vérification de type statique supplémentaire lors de la compilation. C'est fondamentalement l'essence de ma proposition #21154

@henryas Content de te voir ! ??
Je me demande si Golang n'avait pas utilisé la dactylographie du canard, cela aurait rendu la relation entre les types beaucoup plus stricte et permettrait de regrouper les objets en fonction de leur pertinence de domaine, comme vous l'avez décrit dans votre proposition.

@henryas Content de te voir ! ??
Je me demande si Golang n'avait pas utilisé la dactylographie du canard, cela aurait rendu la relation entre les types beaucoup plus stricte et permettrait de regrouper les objets en fonction de leur pertinence de domaine, comme vous l'avez décrit dans votre proposition.

Ce serait le cas, mais cela briserait la promesse de compatibilité avec Go 1. Nous n'aurions probablement pas besoin de types de somme si nous avons une interface explicite. Cependant, taper au canard n'est pas nécessairement une mauvaise chose. Cela rend certaines choses plus légères et pratiques. J'aime taper du canard. Il s'agit d'utiliser le bon outil pour le travail.

@henryas je suis d'accord. C'était une question hypothétique. Les créateurs de Go ont définitivement pris en compte tous les hauts et les bas.
D'un autre côté, des guides de codage tels que la vérification de la conformité de l'interface n'apparaîtraient jamais.
https://github.com/uber-go/guide/blob/master/style.md#verify -interface-compliance

Pouvez-vous s'il vous plaît avoir cette discussion hors sujet ailleurs? Il y a beaucoup de gens qui sont abonnés à ce numéro.
La satisfaction de l'interface ouverte fait partie de Go depuis sa création et cela ne va pas changer.

Cette page vous a été utile?
0 / 5 - 0 notes