Scopes et variables
Sommaire
- Assignation
- Formes courantes
- Mutation et liaison
- Portée des blocs
- Les déclarations de type ne suivent pas ce tableau
- Portée globale et portée locale
- Résolution des noms
- Masquage et durée de vie
- Closures
- Capture d'une portée de fonction
- Globals résolues à l'appel
- Captures créées dans un bloc
- Fonctions imbriquées et récursion
- Paramètres
- Variables de boucle
- Comparaison avec Python
- Récursion terminale
- Introspection
- Voir aussi
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 :
- la portée locale de la fonction courante ;
- les portées englobantes capturées par la closure ;
- la portée globale du module ;
- les builtins et modules de l'hôte ;
- 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,whileet 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