La collecte des ordures est-elle nécessaire pour mettre en œuvre des fermetures sûres?


14

J'ai récemment suivi un cours en ligne sur les langages de programmation dans lequel, entre autres concepts, des fermetures ont été présentées. J'écris deux exemples inspirés de ce cours pour donner un peu de contexte avant de poser ma question.

Le premier exemple est une fonction SML qui produit une liste des nombres de 1 à x, où x est le paramètre de la fonction:

fun countup_from1 (x: int) =
    let
        fun count (from: int) =
            if from = x
            then from :: []
            else from :: count (from + 1)
    in
        count 1
    end

Dans le SML REPL:

val countup_from1 = fn : int -> int list
- countup_from1 5;
val it = [1,2,3,4,5] : int list

La countup_from1fonction utilise la fermeture d'aide countqui capture et utilise la variable à xpartir de son contexte.

Dans le deuxième exemple, lorsque j'appelle une fonction create_multiplier t, je récupère une fonction (en fait, une fermeture) qui multiplie son argument par t:

fun create_multiplier t = fn x => x * t

Dans le SML REPL:

- fun create_multiplier t = fn x => x * t;
val create_multiplier = fn : int -> int -> int
- val m = create_multiplier 10;
val m = fn : int -> int
- m 4;
val it = 40 : int
- m 2;
val it = 20 : int

Donc, la variable mest liée à la fermeture retournée par l'appel de fonction et maintenant je peux l'utiliser à volonté.

Maintenant, pour que la fermeture fonctionne correctement tout au long de sa durée de vie, nous devons prolonger la durée de vie de la variable capturée t(dans l'exemple, il s'agit d'un entier mais il peut s'agir d'une valeur de n'importe quel type). Pour autant que je sache, en SML, cela est rendu possible par la collecte des ordures: la fermeture conserve une référence à la valeur capturée qui est ensuite éliminée par le garbage collector lorsque la fermeture est détruite.

Ma question: en général, la collecte des ordures est-elle le seul mécanisme possible pour garantir la sécurité des fermetures (appelables pendant toute leur durée de vie)?

Ou quels sont les autres mécanismes qui pourraient garantir la validité des fermetures sans ramassage des ordures: copier les valeurs capturées et les stocker dans la fermeture? Restreindre la durée de vie de la fermeture elle-même afin qu'elle ne puisse pas être invoquée après l'expiration de ses variables capturées?

Quelles sont les approches les plus populaires?

ÉDITER

Je ne pense pas que l'exemple ci-dessus puisse être expliqué / implémenté en copiant la ou les variables capturées dans la fermeture. En général, les variables capturées peuvent être de tout type, par exemple, elles peuvent être liées à une très grande liste (immuable). Ainsi, dans l'implémentation, il serait très inefficace de copier ces valeurs.

Par souci d'exhaustivité, voici un autre exemple utilisant des références (et des effets secondaires):

(* Returns a closure containing a counter that is initialized
   to 0 and is incremented by 1 each time the closure is invoked. *)
fun create_counter () =
    let
        (* Create a reference to an integer: allocate the integer
           and let the variable c point to it. *)
        val c = ref 0
    in
        fn () => (c := !c + 1; !c)
    end

(* Create a closure that contains c and increments the value
   referenced by it it each time it is called. *)
val m = create_counter ();

Dans le SML REPL:

val create_counter = fn : unit -> unit -> int
val m = fn : unit -> int
- m ();
val it = 1 : int
- m ();
val it = 2 : int
- m ();
val it = 3 : int

Ainsi, les variables peuvent également être capturées par référence et sont toujours actives une fois l'appel de fonction qui les a créées ( create_counter ()) terminé.


2
Toutes les variables fermées doivent être protégées de la récupération de place et toutes les variables qui ne sont pas fermées doivent être éligibles pour la récupération de place. Il s'ensuit que tout mécanisme qui peut suivre de manière fiable si une variable est fermée ou non peut également récupérer de manière fiable la mémoire occupée par cette variable.
Robert Harvey

3
@btilly: Le recomptage n'est qu'une des nombreuses stratégies d'implémentation différentes pour un garbage collector. Peu importe comment le GC est mis en œuvre aux fins de cette question.
Jörg W Mittag

3
@btilly: Que signifie le "vrai" ramasse-miettes? Le recomptage n'est qu'une autre façon de mettre en œuvre GC. Le traçage est plus populaire, probablement en raison des difficultés de collecte des cycles avec recomptage. (Habituellement, vous vous retrouvez avec un GC de suivi distinct, alors pourquoi vous embêter à implémenter deux GC si vous pouvez vous en sortir avec un seul.) Mais il existe d'autres façons de gérer les cycles. 1) Interdisez-les. 2) Ignorez-les simplement. (Si vous faites une implémentation pour des scripts ponctuels rapides, pourquoi pas?) 3) Essayez de les détecter explicitement. (Il s'avère que la disponibilité du refcount peut accélérer cela.)
Jörg W Mittag

1
Cela dépend de la raison pour laquelle vous souhaitez des fermetures en premier lieu. Si vous voulez implémenter, disons, une sémantique de calcul lambda complète, vous avez certainement besoin de GC, point final. Il n'y a pas d'autre solution. Si vous voulez quelque chose qui ressemble de loin à des fermetures, mais ne suit pas la sémantique exacte de celles-ci (comme en C ++, Delphi, peu importe) - faites ce que vous voulez, utilisez l'analyse de région, utilisez une gestion de mémoire entièrement manuelle.
SK-logic

2
@Mason Wheeler: Les fermetures ne sont que des valeurs, en général, il n'est pas possible de prédire comment elles seront déplacées lors de l'exécution. En ce sens, ils n'ont rien de spécial, il en serait de même pour une chaîne, une liste, etc.
Giorgio

Réponses:


14

Le langage de programmation Rust est intéressant à cet égard.

Rust est un langage système, avec un GC en option, et a été conçu avec des fermetures depuis le début.

Comme les autres variables, les fermetures à la rouille se présentent sous différentes saveurs. Les fermetures de pile , les plus courantes, sont à usage unique. Ils vivent sur la pile et peuvent référencer n'importe quoi. Les fermetures en propriété prennent possession des variables capturées. Je pense qu'ils vivent sur le soi-disant «tas d'échange», qui est un tas mondial. Leur durée de vie dépend de qui les possède. Les fermetures gérées vivent sur le tas local de la tâche et sont suivies par le GC de la tâche. Je ne suis pas sûr de leurs limites de capture, cependant.


1
Lien très intéressant et référence à la langue Rust. Merci. +1.
Giorgio

1
J'ai beaucoup réfléchi avant d'accepter une réponse car je trouve que la réponse de Mason est également très informative. J'ai choisi celui-ci car il est à la fois informatif et cite un langage moins connu avec une approche originale des fermetures.
Giorgio

Merci pour ça. Je suis très enthousiaste à propos de cette jeune langue et je suis heureux de partager mon intérêt. Je ne savais pas si des fermetures sûres étaient possibles sans GC, avant d'entendre parler de Rust.
barjak

9

Malheureusement, commencer par un GC fait de vous une victime du syndrome XY:

  • les fermetures nécessitent que les variables sur lesquelles elles se sont fermées vivent aussi longtemps que la fermeture le fait (pour des raisons de sécurité)
  • en utilisant le GC, nous pouvons prolonger la durée de vie de ces variables assez longtemps
  • Syndrome XY: existe-t-il d'autres mécanismes pour prolonger la durée de vie?

Notez, cependant, que l'idée d' étendre la durée de vie d'une variable n'est pas nécessaire pour une fermeture; c'est juste apporté par le GC; la déclaration de sécurité d'origine est juste les variables fermées devraient vivre aussi longtemps que la fermeture (et même cela est fragile, nous pourrions dire qu'elles devraient vivre jusqu'à la dernière invocation de la fermeture).

Il y a, essentiellement, deux approches que je peux voir (et elles pourraient potentiellement être combinées):

  1. Prolongez la durée de vie des variables fermées (comme le fait un GC, par exemple)
  2. Restreindre la durée de vie de la fermeture

Ce dernier n'est qu'une approche symétrique. Il n'est pas souvent utilisé, mais si, comme Rust, vous avez un système de type sensible à la région, c'est certainement possible.


7

Le ramasse-miettes n'est pas nécessaire pour les fermetures sécurisées, lors de la capture de variables par valeur. Un exemple frappant est C ++. C ++ n'a pas de ramasse-miettes standard. Les lambdas en C ++ 11 sont des fermetures (elles capturent des variables locales de la portée environnante). Chaque variable capturée par un lambda peut être spécifiée pour être capturée par valeur ou par référence. S'il est capturé par référence, vous pouvez dire qu'il n'est pas sûr. Cependant, si une variable est capturée par valeur, elle est sûre, car la copie capturée et la variable d'origine sont séparées et ont des durées de vie indépendantes.

Dans l'exemple SML que vous avez donné, il est simple à expliquer: les variables sont capturées par valeur. Il n'est pas nécessaire de "prolonger la durée de vie" d'une variable car vous pouvez simplement copier sa valeur dans la fermeture. Ceci est possible car, en ML, les variables ne peuvent pas être affectées à. Il n'y a donc pas de différence entre une copie et de nombreuses copies indépendantes. Bien que SML possède un garbage collection, il n'est pas lié à la capture de variables par des fermetures.

La récupération de place n'est pas non plus nécessaire pour les fermetures sécurisées lors de la capture de variables par référence (type de). Un exemple est l'extension Apple Blocks aux langages C, C ++, Objective-C et Objective-C ++. Il n'y a pas de garbage collection standard en C et C ++. Les blocs capturent les variables par valeur par défaut. Cependant, si une variable locale est déclarée avec variable dans le tas, et que le bloc gère sa mémoire, je crois grâce au comptage des références.__block , les blocs les capturent apparemment "par référence" et ils sont sûrs - ils peuvent être utilisés même après la portée dans laquelle le bloc a été défini. Ce qui se passe ici, c'est que les __blockvariables sont en fait un structure spéciale en dessous, et lorsque les blocs sont copiés (les blocs doivent être copiés afin de les utiliser en dehors de la portée en premier lieu), ils "déplacent" la structure pour le__block


4
"La collecte des ordures n'est pas nécessaire pour les fermetures.": La question est de savoir si elle est nécessaire pour que le langage puisse appliquer des fermetures sûres. Je sais que je peux écrire des fermetures sûres en C ++ mais le langage ne les applique pas. Pour les fermetures qui prolongent la durée de vie des variables capturées, voir la modification de ma question.
Giorgio

1
Je suppose que la question pourrait être reformulée en: pour des fermetures sûres .
Matthieu M.

1
Le titre contient le terme "fermetures sûres", pensez-vous que je pourrais mieux le formuler?
Giorgio

1
Pouvez-vous corriger le deuxième paragraphe? En SML, les fermetures prolongent la durée de vie des données référencées par les variables capturées. De plus, il est vrai que vous ne pouvez pas assigner de variables (changer leur liaison) mais vous avez des données modifiables (via ref's'). Donc, OK, on ​​peut débattre si la mise en œuvre des fermetures est liée à la collecte des ordures ou non, mais les déclarations ci-dessus doivent être corrigées.
Giorgio

1
@Giorgio: Et maintenant? De plus, dans quel sens trouvez-vous incorrecte ma déclaration selon laquelle les fermetures n'ont pas besoin de prolonger la durée de vie d'une variable capturée? Lorsque vous parlez de données mutables, vous parlez de types de référence ( refs, tableaux, etc.) qui pointent vers une structure. Mais la valeur est la référence elle-même, pas la chose vers laquelle elle pointe. Si vous en avez var a = ref 1et que vous en faites une copie var b = aet que vous l'utilisez b, cela signifie-t-il que vous l'utilisez toujours a? Vous avez accès à la même structure pointée par a? Oui. Voilà comment ces types fonctionnent en SML et n'ont rien à voir avec les fermetures
user102008

6

La collecte des ordures n'est pas nécessaire pour mettre en œuvre des fermetures. En 2008, le langage Delphi, qui n'est pas ramassé, a ajouté une implémentation de fermetures. Cela fonctionne comme ceci:

Le compilateur crée un objet fonctor sous le capot qui implémente une interface représentant une fermeture. Toutes les variables locales fermées sont changées des sections locales pour la procédure englobante aux champs de l'objet functor. Cela garantit que l'état est conservé aussi longtemps que le foncteur l'est.

La limitation de ce système est que tout paramètre transmis par référence à la fonction englobante, ainsi que la valeur de résultat de la fonction, ne peuvent pas être capturés par le foncteur car ce ne sont pas des locaux dont la portée est limitée à celle de la fonction englobante.

Le foncteur est désigné par la référence de fermeture, en utilisant du sucre syntaxique pour le faire ressembler au développeur comme un pointeur de fonction au lieu d'une interface. Il utilise le système de comptage de références de Delphi pour les interfaces afin de garantir que l'objet functor (et tout l'état qu'il contient) reste "vivant" aussi longtemps qu'il le faut, puis il est libéré lorsque le décompte tombe à 0.


1
Ah, il n'est donc possible de capturer que la variable locale, pas les arguments! Cela semble un compromis raisonnable et intelligent! +1
Giorgio

1
@Giorgio: Il peut capturer des arguments, mais pas ceux qui sont des paramètres var .
Mason Wheeler

2
Vous perdez également la possibilité d'avoir 2 fermetures qui communiquent via un état privé partagé. Vous ne rencontrerez pas cela dans les cas d'utilisation de base, mais cela limite votre capacité à faire des choses complexes. Encore un bel exemple de ce qui est possible!
btilly

3
@btilly: En fait, si vous mettez 2 fermetures dans la même fonction de clôture, c'est parfaitement légal. Ils finissent par partager le même objet foncteur, et s'ils modifient le même état les uns que les autres, les changements dans l'un seront reflétés dans l'autre.
Mason Wheeler

2
@MasonWheeler: "Non. La collecte des ordures est de nature non déterministe; il n'y a aucune garantie qu'un objet donné sera jamais collecté, et encore moins quand cela se produira. Mais le comptage des références est déterministe: le compilateur vous garantit que l'objet sera libéré immédiatement après que le décompte soit tombé à 0. ". Si j'avais un sou pour chaque fois que j'entendais ce mythe se perpétuer. OCaml a un GC déterministe. Le thread sécurisé C ++ shared_ptrn'est pas déterministe car les destructeurs courent pour décrémenter à zéro.
Jon Harrop
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.