Scopes et variables

Catnip utilise des portées lexicales : l'endroit où un nom est défini détermine où il peut être lu. Les fonctions et certains blocs créent une portée ; les structures de branchement n'en créent pas.

Assignation

Formes courantes

= lie un nom, modifie un attribut ou remplace un élément indexé :

x = 42
a = b = c = 0

point.x = 10
config.database.host = "localhost"

scores["alice"] = 100
values[0] = 99

Une assignation chaînée évalue la partie droite une seule fois, puis lie la même valeur à chaque cible.

Dans une suite d'assignations ou d'expressions séparées par ;, _ désigne la dernière valeur calculée :

x = 10; y = _ + 5; y
# ⇒ 15

_ n'est pas une variable magique globale : sa valeur suit l'évaluation de la séquence courante.

Mutation et liaison

Ces deux opérations ne changent pas le même emplacement :

items = list(1, 2)
alias = items

items[0] = 9       # mutation de l'objet partagé
items = list(3, 4) # nouvelle liaison locale

Après la mutation, alias[0] vaut 9. Après la réassignation, alias désigne toujours l'ancienne liste. Cette distinction devient importante dans les fonctions et les closures.

Portée des blocs

Les blocs autonomes, for et while créent une portée. Les noms qu'ils introduisent disparaissent à la sortie :

{
    x = 42
    print(x)
}

print(x)  # NameError: Unknown identifier 'x'
for i in range(3) {
    square = i * i
    print(square)
}

print(i)       # NameError
print(square)  # NameError

Une variable déjà définie hors du bloc reste accessible. Une assignation qui la cible met à jour cette liaison :

total = 0

for value in list(1, 2, 3) {
    total = total + value
}

total
# ⇒ 6

if, elif, else et match ne créent pas de portée supplémentaire. Un nom défini dans une branche appartient donc à la portée qui contient le branchement :

if True {
    status = "ready"
}

status
# ⇒ "ready"

La branche doit cependant avoir été exécutée. Catnip ne fabrique pas de valeur pour une assignation placée dans une branche non prise :

if False {
    missing = 1
}

print(missing)  # NameError
Construction Nouvelle portée Les noms introduits survivent
Fonction ou lambda Oui Non
Bloc autonome { ... } Oui Non
for / while Oui Non
if / elif / else Non Oui, si la branche s'exécute
match / case Non Oui, si le cas s'exécute

Cette séparation limite les fuites de variables temporaires sans obliger à recopier une valeur produite par un branchement.

Les déclarations de type ne suivent pas ce tableau

Le tableau porte sur les liaisons de valeurs. Une déclaration struct, enum, union ou trait enregistre un type, et un type reste joignable après le corps qui l'a déclaré — bloc autonome, lambda ou boucle comprises :

{ struct Q { x } }
Q(x=1)                                  # → Q(x=1)

Un nom de type déjà pris est redéfini pour la suite du programme, il n'est pas masqué le temps du corps. La déclaration interne remplace la précédente, y compris pour le code qui suit le corps :

struct P { a; b }
f = () => { struct P { x }; P(x=1) }
f()                                     # → P(x=1)
P(x=2)                                  # → P(x=2)

Après cet appel, P(a=1, b=2) échoue : la définition à deux champs n'est plus joignable. Déclarer un type dans un corps appelé plusieurs fois, ou dans une boucle, est donc une réécriture répétée du même nom — pas une déclaration locale.

Un callback de broadcast ne fait pas exception, quel que soit le nd_mode : une déclaration qu'il porte remonte à l'appelant, comme celle d'une lambda ordinaire.

pragma("nd_mode", ND.thread)
range(0, 4).[~> (n) => { struct P { x }
P(x=n) }]
P(x=1)                                  # → P(x=1)

La portée d'une valeur dit combien de temps elle vit. La portée d'un type dit sous quel nom on le retrouve. Les deux notions partagent un mot et rien d'autre.

Portée globale et portée locale

Les noms liés au niveau racine appartiennent au module :

app_name = "catnip"
limit = 100

Chaque appel de fonction possède sa propre portée locale. Les paramètres et les assignations effectuées dans le corps n'en sortent pas :

x = 10

f = (x) => {
    y = x + 1
    y
}

f(20)
# ⇒ 21

x
# ⇒ 10

Une assignation dans une fonction crée une liaison locale, même si le module contient déjà un nom identique :

mode = "global"

change = () => {
    mode = "local"
    mode
}

change()
# ⇒ "local"

mode
# ⇒ "global"

Pour modifier l'état visible depuis plusieurs portées, on peut muter un objet partagé :

state = dict(count=0)

increment = () => {
    state["count"] = state["count"] + 1
}

increment()
state["count"]
# ⇒ 1

Ici, la liaison state ne change pas ; le dictionnaire qu'elle désigne est modifié.

Résolution des noms

Catnip cherche un nom dans cet ordre lexical :

  1. la portée locale de la fonction courante ;
  2. les portées englobantes capturées par la closure ;
  3. la portée globale du module ;
  4. les builtins et modules de l'hôte ;
  5. sinon, une NameError.

Une liaison proche masque une liaison plus éloignée :

value = 100

outer = (value) => {
    inner = () => { value }
    inner()
}

outer(7)
# ⇒ 7

Le paramètre de outer masque le global, y compris dans inner et dans un callback de broadcast. Une assignation locale n'altère pas le nom masqué.

Masquage et durée de vie

Le masquage ne supprime pas la liaison éloignée. Il la rend inaccessible tant que la liaison plus proche existe :

label = "module"

show = () => {
    label = "function"
    {
        label = "block"
        print(label)
    }
    print(label)
}

show()
# ⇒ block
# ⇒ block
print(label)
# ⇒ module

Un bloc ne crée pas de liaison pour un nom déjà visible : l'assignation atteint celle de la fonction, et sa valeur survit à la fermeture du bloc. Le retour de fonction, lui, restaure la liaison du module.

Seul un nom que le bloc introduit lui appartient, et celui-là disparaît à la sortie :

count = () => {
    {
        temp = 1
    }
    temp
}

count()  # NameError

Les builtins occupent le dernier niveau de résolution. Un nom local peut donc masquer un builtin :

inspect = (len) => {
    len(list(1, 2, 3))  # TypeError si len n'est pas appelable
}

Ce masquage est autorisé, mais il change toutes les lectures de len dans cette portée.

Closures

Une closure est une fonction qui lit des noms définis dans une portée englobante. Catnip distingue les captures de fonction, les globals du module et les noms introduits par un bloc.

Capture d'une portée de fonction

Une closure capture par copie les variables englobantes qu'elle lit au moment de sa création. Sa copie persiste entre ses appels :

make_counter = () => {
    count = 0

    increment = () => {
        count = count + 1
        count
    }

    increment
}

counter = make_counter()
counter()
# ⇒ 1
counter()
# ⇒ 2

increment lit count, puis l'écrit : l'assignation met donc à jour sa capture. Le count de make_counter n'est pas réassigné. Deux closures créées dans la même portée ont chacune leur propre copie :

make_pair = () => {
    n = 0
    left = () => { n = n + 1; n }
    right = () => { n = n + 10; n }
    list(left, right)
}

pair = make_pair()
pair[0]()
# ⇒ 1
pair[1]()
# ⇒ 10

Une écriture sans lecture préalable crée au contraire une variable locale :

x = 10

f = () => {
    x = 42
    x
}

f()
# ⇒ 42
x
# ⇒ 10

La règle ne dépend pas de l'ordre des lignes, mais de la présence d'une lecture du nom par la closure.

Une lecture placée dans une branche suffit à établir la capture, même si cette branche n'est pas prise lors d'un appel. Le choix est lexical : il dépend du corps de la fonction, pas du chemin d'exécution observé.

make_switch = () => {
    value = 1

    switch = (enabled) => {
        if enabled {
            value = value + 1
        }
        value
    }

    switch
}

switch = make_switch()
switch(False)
# ⇒ 1
switch(True)
# ⇒ 2

Cette règle permet à une closure de maintenir un état privé sans déclaration nonlocal. Elle implique aussi qu'une réassignation volontairement locale doit employer un autre nom si le même corps lit déjà la liaison englobante.

La mutation d'un objet capturé suit les règles de l'objet, indépendamment de la réassignation du nom :

make_log = () => {
    entries = list()

    append = (entry) => {
        entries = entries + list(entry)
        entries
    }

    append
}

log = make_log()
log("start")
# ⇒ ["start"]
log("stop")
# ⇒ ["start", "stop"]

Ici, l'opérateur construit une nouvelle liste puis remplace la capture privée. Avec entries[index] = value, la closure muterait au contraire la liste déjà capturée.

La même règle vaut pour une structure : c.v = y dans une closure imbriquée mute l'instance que la portée englobante détient, elle n'en fabrique pas une privée. Un appel de méthode qui mute self suit le même chemin.

Globals résolues à l'appel

Les variables du module ne sont pas capturées par copie. Une closure les résout au moment de l'appel :

threshold = 10
accept = (x) => { x > threshold }

threshold = 20
accept(15)
# ⇒ False

Une écriture qui lit d'abord le global met à jour cette liaison vivante. Une écriture simple sans lecture reste locale, comme dans l'exemple précédent.

Cette liaison vivante s'arrête à une frontière de processus. Un callback ND exécuté avec ND.process reçoit un instantané des globals lors de sa soumission ; ses mutations ne reviennent pas au processus parent. Les modes ND.sequential et ND.thread partagent la liaison. Le détail des frontières et des valeurs sérialisables se trouve dans Concurrence ND.

Une mise à jour globale qui lit le nom suit la même liaison vivante :

requests = 0

record = () => {
    requests = requests + 1
    requests
}

record()
# ⇒ 1
record()
# ⇒ 2
requests
# ⇒ 2

À l'inverse, requests = 1 seul créerait une locale. Pour éviter qu'une modification globale dépende subtilement de cette distinction, un objet d'état passé explicitement reste préférable dès que plusieurs fonctions écrivent la même donnée.

Captures créées dans un bloc

Une closure définie dans un for, un while ou un bloc capture par copie les noms introduits par cette portée. Chaque itération conserve donc sa valeur :

adders = list()

for value in list(10, 20, 30) {
    adders = adders + list((x) => { x + value })
}

list(adders[0](1), adders[1](1), adders[2](1))
# ⇒ [11, 21, 31]

Sans cette copie par itération, les trois fonctions reliraient la dernière valeur. Les noms définis hors du bloc, comme adders, gardent leur liaison externe.

Une closure emporte les noms temporaires de son bloc, mais consulte les globals au moment où on l'appelle. Les deux frontières n'ont pas le même calendrier.

Fonctions imbriquées et récursion

Une fonction imbriquée peut lire les paramètres et variables de la fonction qui la contient :

make_multiplier = (factor) => {
    (value) => { value * factor }
}

triple = make_multiplier(3)
triple(7)
# ⇒ 21

La récursion d'une fonction imbriquée fonctionne même si la fonction est retournée :

make_factorial = () => {
    factorial = (n) => {
        if n <= 1 { 1 }
        else { n * factorial(n - 1) }
    }
    factorial
}

make_factorial()(5)
# ⇒ 120

Les groupes récursifs imbriqués sont résolus comme un groupe lexical : deux fonctions définies dans la même portée peuvent s'appeler mutuellement, même si la seconde apparaît plus bas dans le fichier.

parity = () => {
    even = (n) => {
        if n == 0 { True } else { odd(n - 1) }
    }
    odd = (n) => {
        if n == 0 { False } else { even(n - 1) }
    }
    even
}

parity()(10)
# ⇒ True

Cette résolution anticipée concerne les définitions de fonctions du groupe, pas les valeurs arbitraires calculées plus tard.

Une fonction sortie de sa portée conserve les autres membres du groupe dont elle a besoin. Il n'est donc pas nécessaire de retourner toutes les fonctions mutuellement récursives :

make_even = () => {
    even = (n) => {
        if n == 0 { True } else { odd(n - 1) }
    }
    odd = (n) => {
        if n == 0 { False } else { even(n - 1) }
    }
    even
}

is_even = make_even()
is_even(11)
# ⇒ False

Le groupe reste privé : odd n'est pas visible au niveau module.

Paramètres

Les paramètres sont locaux à chaque appel, y compris les valeurs par défaut et le paramètre variadique :

describe = (head, tail="none", *rest) => {
    list(head, tail, rest)
}

describe(1, 2, 3, 4)
# ⇒ [1, 2, [3, 4]]

Ils masquent les globals de même nom et disparaissent au retour. Pour l'arité permissive, les valeurs par défaut, les annotations et les callbacks, voir Fonctions.

Variables de boucle

La variable d'un for et les noms liés par déstructuration sont locaux à la boucle :

for pair in list(tuple("a", 1), tuple("b", 2)) {
    key, value = pair
    print(key, value)
}

print(key)  # NameError

Une boucle peut néanmoins mettre à jour un accumulateur déjà défini :

found = None

for value in list(2, 4, 7, 8) {
    if value % 2 != 0 {
        found = value
    }
}

found
# ⇒ 7

Les noms capturés dans une boucle et les accumulateurs externes obéissent donc à deux règles complémentaires :

  • la variable d'itération et les temporaires du corps sont copiés dans chaque closure créée pendant l'itération ;
  • une liaison définie avant la boucle reste accessible et peut recevoir les résultats successifs.

Cette distinction permet de construire une collection de callbacks tout en conservant un compteur ou une liste de sortie au niveau englobant.

Comparaison avec Python

Les deux langages utilisent une résolution lexicale et isolent les variables locales d'une fonction. Trois différences modifient toutefois le raisonnement :

  • for, while et les blocs autonomes ont leur propre portée en Catnip ;
  • une closure Catnip peut mettre à jour une capture lue sans mot-clé nonlocal ;
  • les globals sont résolues tardivement, mais une assignation sans lecture crée une locale.

Il n'existe pas de déclaration global ou nonlocal. Le comportement découle de l'emplacement de la liaison et de la lecture effectuée par le corps.

Récursion terminale

L'optimisation des appels terminaux réutilise l'espace d'exécution, mais ne change pas les règles lexicales : chaque itération logique reçoit ses paramètres, et les closures gardent leurs captures. Les positions terminales, la récursion mutuelle et le mode debug sont documentés dans Fonctions.

Introspection

globals() retourne les liaisons du module. locals() retourne les liaisons visibles dans la fonction courante :

answer = 42
globals()["answer"]
# ⇒ 42
inspect = (a, b) => {
    c = a + b
    locals()
}

inspect(1, 2)
# ⇒ {"a": 1, "b": 2, "c": 3}

Ces fonctions exposent l'état courant pour le debug et l'introspection. Le code métier devrait passer ses dépendances par paramètres ou closures : le flux de données reste alors local à la définition qui l'utilise.

Voir aussi

  • Fonctions — appels, paramètres, callbacks et récursion terminale
  • Expressions — assignations indexées et accès aux attributs
  • Concurrence ND — copies et frontières de processus