Comment les langues avec des types Maybe au lieu de null gèrent-elles les conditions de bord?

53

Eric Lippert a fait un point très intéressant dans sa discussion sur les raisons pour lesquelles C # utilise un type nullplutôt qu'un Maybe<T>type :

La cohérence du système de types est importante. Pouvons-nous toujours savoir qu'une référence non Nullable n'est jamais considérée comme invalide? Qu'en est-il dans le constructeur d'un objet avec un champ de type référence non nullable? Qu'en est-il dans le finaliseur d'un tel objet, où l'objet est finalisé parce que le code qui était censé remplir la référence renvoyait une exception? Un système de type qui vous ment sur ses garanties est dangereux.

C'était un peu révélateur. Les concepts en jeu m'intéressent et j'ai un peu joué avec les compilateurs et les systèmes de typage, mais je n'ai jamais pensé à ce scénario. Comment les langues ayant le type Maybe au lieu d'un caractère null, telles que l'initialisation et la récupération après erreur, dans lesquelles une référence non nulle supposée garantie n'est pas, en fait, dans un état valide?

Maçon Wheeler
la source
Je suppose que si le groupe Maybe fait partie du langage, il se peut qu’il soit implémenté en interne via un pointeur null et qu’il s’agisse simplement d’un sucre syntaxique. Mais je pense qu'aucune langue ne le fait réellement comme ça.
Panzi
1
@panzi: Ceylan utilise une dactylographie sensible au flux pour faire la distinction entre Type?(peut-être) et Type(non nul)
Lukas Eder
1
@RobertHarvey N'y a-t-il pas déjà un bouton "question intéressante" dans Stack Exchange?
user253751
2
@panzi C'est une optimisation valable et valable, mais cela ne résout pas le problème: lorsque quelque chose n'est pas Maybe T, cela ne doit pas l'être Noneet vous ne pouvez donc pas initialiser son stockage sur le pointeur null.
@immibis: Je l'ai déjà poussé. Nous avons quelques précieuses questions ici. Je pensais que celui-ci méritait un commentaire.
Robert Harvey

Réponses:

45

Cette citation pointe vers un problème qui se produit si la déclaration et l'affectation d'identificateurs (ici: membres d'instance) sont séparées l'une de l'autre. En tant qu’esquisse rapide de pseudocode:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

Le scénario est maintenant que lors de la construction d'une instance, une erreur sera générée, de sorte que la construction sera abandonnée avant que l'instance ne soit entièrement construite. Ce langage offre une méthode de destruction qui s'exécutera avant la libération de la mémoire, par exemple pour libérer manuellement des ressources non-mémoire. Il doit également être exécuté sur des objets partiellement construits, car des ressources gérées manuellement peuvent déjà avoir été allouées avant l'abandon de la construction.

Avec des valeurs NULL, le destructeur peut tester si une variable a été assignée comme if (foo != null) foo.cleanup(). Sans null, l'objet est maintenant dans un état indéfini - quelle est la valeur de bar?

Cependant, ce problème existe en raison de la combinaison de trois aspects:

  • L'absence de valeurs par défaut telles que nullou l'initialisation garantie pour les variables membres.
  • La différence entre déclaration et cession. Forcer l’affectation immédiate de variables (par exemple avec une letinstruction telle qu’elle est vue dans les langages fonctionnels) est un moyen facile de forcer l’initialisation garantie - mais limite le langage de différentes manières.
  • La variante spécifique des destructeurs en tant que méthode appelée par le runtime du langage.

Il est facile de choisir une autre conception qui ne présente pas ces problèmes, par exemple en combinant toujours déclaration avec affectation et en faisant en sorte que la langue offre plusieurs blocs de finaliseur au lieu d'une seule méthode de finalisation:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Il n'y a donc pas de problème avec l'absence de null, mais avec la combinaison d'un ensemble d'autres fonctionnalités avec une absence de null.

La question intéressante est maintenant de savoir pourquoi C # a choisi un design mais pas l’autre. Ici, le contexte de la citation énumère de nombreux autres arguments en faveur d'un null dans le langage C #, qui peuvent généralement être résumés comme suit: «familiarité et compatibilité» - et ce sont de bonnes raisons.

Amon
la source
Il existe également une autre raison pour laquelle le finaliseur doit traiter nulls: l'ordre de finalisation n'est pas garanti en raison de la possibilité de cycles de référence. Mais je suppose que votre FINALIZEconception résout également ceci: si elle fooa déjà été finalisée, sa FINALIZEsection ne fonctionnera tout simplement pas.
svick
14

De la même manière, vous garantissez que toutes les autres données sont dans un état valide.

On peut structurer la sémantique et le flux de contrôle de sorte que vous ne pouvez pas avoir une variable / un champ de quelque type que ce soit sans créer entièrement une valeur pour celle-ci. Au lieu de créer un objet et de laisser un constructeur affecter des valeurs "initiales" à ses champs, vous ne pouvez créer un objet qu'en spécifiant des valeurs pour tous ses champs à la fois. Au lieu de déclarer une variable, puis d'attribuer une valeur initiale, vous pouvez uniquement introduire une variable avec une initialisation.

Par exemple, dans Rust, vous créez un objet de type struct via Point { x: 1, y: 2 }au lieu d'écrire un constructeur qui le fait self.x = 1; self.y = 2;. Bien sûr, cela peut entrer en conflit avec le style de langage que vous avez en tête.

Une autre approche complémentaire consiste à utiliser l'analyse de la vivacité pour empêcher l'accès au stockage avant son initialisation. Cela permet de déclarer une variable sans l’initialiser immédiatement, à condition qu’elle soit affectée de manière prouvable avant la première lecture. Il peut également intercepter des cas d’échec,

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Techniquement, vous pouvez également définir une initialisation arbitraire par défaut pour les objets, par exemple, mettre à zéro tous les champs numériques, créer des tableaux vides pour les champs de tableau, etc.


la source
7

Voici comment Haskell le fait: (ce n’est pas vraiment un contrepoint aux déclarations de Lippert puisque Haskell n’est pas un langage orienté objet).

AVERTISSEMENT: une longue réponse d’un fanboy sérieux de Haskell.

TL; DR

Cet exemple montre à quel point Haskell est différent de C #. Au lieu de déléguer la logistique de la construction d'une structure à un constructeur, celle-ci doit être gérée dans le code environnant. Il n’existe aucun moyen pour une valeur nulle (ou Nothingen Haskell) de s'afficher là où nous attendons une valeur non nulle car les valeurs nulles ne peuvent apparaître que dans des types d'encapsuleurs spéciaux appelés Maybequi ne sont pas interchangeables avec / directement convertibles en normales, types nullable. Pour utiliser une valeur rendue nullable en l'enveloppant dans un Maybe, nous devons d'abord extraire la valeur à l'aide d'une correspondance de modèle, ce qui nous oblige à dévier le flux de contrôle dans une branche où nous savons avec certitude que nous avons une valeur non nulle.

Donc:

Pouvons-nous toujours savoir qu'une référence non Nullable n'est jamais considérée comme invalide?

Oui. Intet Maybe Intsont deux types complètement séparés. Trouver Nothingdans une plaine Intserait comparable à trouver la chaîne "poisson" dans un fichier Int32.

Qu'en est-il dans le constructeur d'un objet avec un champ de type référence non nullable?

Ce n’est pas un problème: les constructeurs de valeurs de Haskell ne peuvent rien faire à part prendre les valeurs qui leur sont données et les assembler. Toute la logique d'initialisation a lieu avant l'appel du constructeur.

Qu'en est-il dans le finaliseur d'un tel objet, où l'objet est finalisé parce que le code qui était censé remplir la référence renvoyait une exception?

Il n'y a pas de finalistes à Haskell, donc je ne peux pas vraiment répondre à cela. Ma première réponse est cependant toujours valable.

Réponse complète :

Haskell n'a pas de valeur null et utilise le Maybetype de données pour représenter les valeurs nullables. Peut-être qu'un type de données algabrique est défini comme suit:

data Maybe a = Just a | Nothing

Pour ceux qui ne connaissent pas Haskell, lisez ceci comme suit: "A Maybeest soit un Nothingsoit un Just a". Plus précisément:

  • Maybeest le constructeur de type : il peut être considéré (à tort) comme une classe générique (où aest la variable de type). L'analogie avec C # est class Maybe<a>{}.
  • Justest un constructeur de valeur : c'est une fonction qui prend un argument de type aet retourne une valeur de type Maybe aqui contient la valeur. Donc, le code x = Just 17est analogue à int? x = 17;.
  • Nothingest un autre constructeur de valeur, mais il ne prend aucun argument et le résultat Mayberenvoyé n'a pas de valeur autre que "Nothing". x = Nothingest analogue à int? x = null;(en supposant que nous ayons limité notre aprésence en Haskell Int, ce qui peut être fait en écrivant x = Nothing :: Maybe Int).

Maintenant que les bases de ce Maybetype sont finies, comment Haskell peut-il éviter les problèmes abordés dans la question du PO?

Eh bien, Haskell est vraiment différent de la plupart des langues discutées jusqu'à présent, je vais donc commencer par expliquer quelques principes linguistiques de base.

Tout d'abord, à Haskell, tout est immuable . Tout. Les noms font référence à des valeurs et non à des emplacements de mémoire où des valeurs peuvent être stockées (cela constitue à lui seul une énorme source d’élimination des bogues). Contrairement à C #, où la déclaration des variables et l' affectation sont deux opérations distinctes, des valeurs Haskell sont créés en définissant leur valeur (par exemple x = 15, y = "quux", z = Nothing), qui ne peut jamais changer. Par conséquent, code comme:

ReferenceType x;

N'est pas possible à Haskell. Il n'y a aucun problème avec l'initialisation des valeurs nullcar tout doit être explicitement initialisé à une valeur pour que celle-ci existe.

Deuxièmement, Haskell n’est pas un langage orienté objet : c’est un langage purement fonctionnel , il n’existe donc aucun objet au sens strict du terme. Au lieu de cela, il existe simplement des fonctions (constructeurs de valeurs) qui prennent leurs arguments et renvoient une structure fusionnée.

Ensuite, il n'y a absolument aucun code de style impératif. Par cela, je veux dire que la plupart des langues suivent un schéma semblable à celui-ci:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

Le comportement du programme est exprimé sous forme d'une série d'instructions. Dans les langages orientés objet, les déclarations de classe et de fonction jouent également un rôle important dans le déroulement du programme, mais l'essentiel est que la "viande" de l'exécution d'un programme se présente sous la forme d'une série d'instructions à exécuter.

En Haskell, ce n'est pas possible. Au lieu de cela, le déroulement du programme est entièrement dicté par les fonctions de chaînage. Même la doréférence impériale est simplement un sucre syntaxique pour transmettre des fonctions anonymes à l' >>=opérateur. Toutes les fonctions prennent la forme de:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

body-expressionpeut être n'importe quoi qui évalue à une valeur. Évidemment, il y a plus de fonctionnalités de syntaxe disponibles, mais le point principal est l'absence complète de séquences d'instructions.

Enfin, et probablement le plus important, le système de types de Haskell est incroyablement strict. Si je devais résumer la philosophie de conception centrale du système de typage de Haskell, je dirais: "Faites en sorte que tout ce qui est possible se passe mal à la compilation pour que le moins possible se passe mal à l'exécution." Il n'y a aucune conversion implicite que ce soit (vous voulez promouvoir un Inten un Double? Utilisez la fromIntegralfonction). Le seul cas possible où une valeur non valide se produit au moment de l'exécution est d'utiliser Prelude.undefined(ce qui doit simplement être présent et est impossible à supprimer ).

Gardant tout cela à l’esprit, examinons l’exemple "cassé" d’Amon et essayons de ré-exprimer ce code en Haskell. Tout d’abord, la déclaration de données (en utilisant la syntaxe d’enregistrement pour les champs nommés):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( fooet barsont vraiment des fonctions d’accès aux champs anonymes ici au lieu des champs réels, mais nous pouvons ignorer ce détail).

Le NotSoBrokenconstructeur de valeur est incapable de prendre des mesures autres que prendre un Fooet a Bar(qui ne sont pas nullables) et en faire un NotSoBroken. Il n'y a pas de place pour mettre du code impératif ou même assigner manuellement les champs. Toute la logique d'initialisation doit avoir lieu ailleurs, probablement dans une fonction d'usine dédiée.

Dans l'exemple, la construction de Brokenéchoue toujours. Il n'y a aucun moyen de casser le NotSoBrokenconstructeur de valeur de la même manière (il n'y a tout simplement rien pour écrire le code), mais nous pouvons créer une fonction fabrique qui est tout aussi défectueuse.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(La première ligne est une déclaration de signature de type: makeNotSoBrokenprend un Fooet a Barcomme arguments et produit un Maybe NotSoBroken).

Le type de retour doit être Maybe NotSoBrokenet pas simplement NotSoBrokenparce que nous lui avons dit d'évaluer Nothing, qui est un constructeur de valeur pour Maybe. Les types ne s'aligneraient tout simplement pas si nous écrivions quelque chose de différent.

En plus d'être absolument inutile, cette fonction ne remplit même pas son véritable objectif, comme nous le verrons lorsque nous essayerons de l'utiliser. Créons une fonction appelée useNotSoBrokenqui attend un NotSoBrokencomme argument:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenaccepte a NotSoBrokencomme argument et produit a Whatever).

Et utilisez-le comme ceci:

useNotSoBroken (makeNotSoBroken)

Dans la plupart des langages, ce type de comportement peut provoquer une exception de pointeur nul. En Haskell, les types ne correspondent pas: makeNotSoBrokenrenvoie un Maybe NotSoBroken, mais useNotSoBrokenattend un NotSoBroken. Ces types ne sont pas interchangeables et la compilation du code échoue.

Pour contourner ce problème, nous pouvons utiliser une caseinstruction pour créer une branche basée sur la structure de la Maybevaleur (à l'aide d'une fonctionnalité appelée correspondance de modèle ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Il est évident que cet extrait doit être placé dans un contexte pour être compilé, mais il montre les bases de la manière dont Haskell gère les éléments null. Voici une explication pas à pas du code ci-dessus:

  • Tout d'abord, makeNotSoBrokenest évalué, ce qui garantit de produire une valeur de type Maybe NotSoBroken.
  • L' caseinstruction inspecte la structure de cette valeur.
  • Si la valeur est Nothing, le code "gérer la situation ici" est évalué.
  • Si la valeur correspond à une Justvaleur, l'autre branche est exécutée. Notez que la clause correspondante identifie simultanément la valeur en tant que Justconstruction et lie son NotSoBrokenchamp interne à un nom (dans ce cas, x). xpeut alors être utilisé comme la NotSoBrokenvaleur normale qui est.

Ainsi, la mise en correspondance de modèles fournit une installation puissante pour appliquer la sécurité de type, car la structure de l'objet est indissociable de la ramification du contrôle.

J'espère que c'était une explication compréhensible. Si cela n’a aucun sens, lancez-vous dans Learn You A Haskell For Great Good! , l'un des meilleurs tutoriels linguistiques en ligne que j'ai jamais lu. J'espère que vous verrez la même beauté dans cette langue que moi.

ApprocherDarknessFish
la source
TL; DR devrait être sur le dessus :)
andrew.fox
@ andrew.fox Bon point. Je vais éditer.
ApproachingDarknessFish le
0

Je pense que votre citation est un argument d'homme de paille.

Les langues modernes actuelles (y compris le C #) vous garantissent que le constructeur soit complet ou non.

S'il existe une exception dans le constructeur et que l'objet est laissé partiellement non initialisé, le fait d'avoir nullou Maybe::nonepour un état non initialisé ne fait aucune différence réelle dans le code du destructeur.

De toute façon, vous devrez simplement vous en occuper. Lorsqu'il y a des ressources externes à gérer, vous devez les gérer explicitement de toute façon. Les langues et les bibliothèques peuvent aider, mais vous devrez y réfléchir.

Btw: En C #, la nullvaleur est à peu près équivalente à Maybe::none. Vous ne pouvez affecter nullque des variables et des membres d'objet déclarés nullables au niveau du type :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Ce n'est en aucun cas différent de l'extrait suivant:

Maybe<String> optionalString = getOptionalString();

Donc, en conclusion, je ne vois pas en quoi l'annulation est opposée aux Maybetypes. Je dirais même que C # s'est faufilé dans son propre Maybetype et l'a appelé Nullable<T>.

Avec les méthodes d'extension, il est même facile d'obtenir le nettoyage du Nullable pour suivre le modèle monadique:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );
Roland Tepp
la source
2
que signifie-t-il, "le constructeur complète ou non"? En Java, par exemple, l'initialisation du champ (non final) dans le constructeur n'est pas protégée contre la course des données - est-ce que cela peut être qualifié de complet ou non?
moucher
@gnat: qu'entendez-vous par "En Java par exemple, l'initialisation du champ (non final) dans le constructeur n'est pas protégée contre la course des données". À moins que vous ne fassiez quelque chose de spectaculaire d'une complexité impliquant plusieurs threads, les chances d'une situation de compétition dans un constructeur sont (ou devraient être) presque impossibles. Vous ne pouvez pas accéder à un champ d'un objet non construit, sauf depuis le constructeur de l'objet. Et si la construction échoue, vous n'avez pas de référence à l'objet.
Roland Tepp
La grande différence entre en nulltant que membre implicite de chaque type et Maybe<T>c'est que, avec Maybe<T>, vous pouvez également avoir juste T, qui n'a pas de valeur par défaut.
svick
Lors de la création de tableaux, il sera souvent impossible de déterminer des valeurs utiles pour tous les éléments sans avoir à en lire, ni de vérifier de manière statique qu'aucun élément n'est lu sans qu'une valeur utile n'ait été calculée pour celui-ci. Le mieux que l'on puisse faire est d'initialiser les éléments d'un tableau de manière à ce qu'ils puissent être reconnus comme étant inutilisables.
Supercat
@svick: En C # (qui était le langage en question par l'OP), nulln'est pas un membre implicite de tous les types. Pour nullque lebal soit une valeur, vous devez définir explicitement le type à nullable, ce qui rend un T?(sucre de syntaxe pour Nullable<T>) essentiellement équivalent à Maybe<T>.
Roland Tepp le
-3

C ++ le fait en ayant accès à l'initialiseur qui se produit avant le corps du constructeur. C # exécute l'initialiseur par défaut avant le corps du constructeur, il attribue approximativement 0 à tout, floatsdevient 0.0, boolsdevient false, les références deviennent nulles, etc. En C ++, vous pouvez le faire exécuter avec un initialiseur différent afin de garantir qu'un type de référence non null ne soit jamais nul .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}
ryancerium
la source
2
la question portait sur les langues avec Maybe types
gnat
3
«Les références deviennent nulles » - la prémisse de la question est que nous n’avons pas null, et le seul moyen d’indiquer l’absence de valeur est d’utiliser un Maybetype (également appelé Option) que AFAIK C ++ n’a pas dans la liste. bibliothèque standard. L'absence de null nous permet de garantir qu'un champ sera toujours valide en tant que propriété du système de types . C'est une garantie plus forte que de s'assurer manuellement qu'aucun chemin de code n'existe là où une variable pourrait encore se trouver null.
amon
Bien que c ++ ne contienne explicitement les types Maybe de manière native, des choses comme std :: shared_ptr <T> sont suffisamment proches pour que j'estime qu'il est toujours pertinent que c ++ gère le cas où l'initialisation de variables peut se produire "hors de portée" du constructeur, et est en fait requis pour les types de référence (&), car ils ne peuvent pas être null.
FryGuy