Scopes et Variables dans Catnip
Vue d'Ensemble
Catnip utilise la portée lexicale (lexical scoping) : la résolution des variables dépend de la structure du code source, pas du flux d'exécution.
Propriétés fondamentales :
- Scopes imbriqués : Chaque bloc crée un nouveau scope enfant
- Capture de variables : Les closures capturent les variables du scope parent
- Résolution statique : Les variables sont résolues au moment de l'analyse, pas à l'exécution
- Pas de hoisting : Une variable n'existe que dans le scope où elle est définie
Conséquence pratique : Les variables locales ne "fuient" jamais en dehors de leur bloc. Chaque fonction a son propre espace de noms isolé.
Détails Techniques
Chaque fonction crée un nouveau frame de scope, et les variables sont résolues en O(1).
Concepts clés
- Scope global : Le scope de niveau racine où les variables sont accessibles partout
- Scope local : Un scope créé par une fonction, bloc ou lambda
- Pile de scopes : Une structure de pile qui gère les scopes imbriqués
- Modification en place : L'assignation
x = valuemodifie une variable existante dans n'importe quel scope parent - Création locale : Une variable est créée localement seulement si elle n'existe nulle part ailleurs
Scope global vs scope local
Scope global
Le scope global contient :
- Les builtins Python (
print,len,range, etc.) - Les variables définies au niveau racine du programme
- Les fonctions définies au niveau racine
# Au niveau racine - variables globales
x = 10
y = 20
print(x, y) # 10 20
Comportement important : Quand il n'y a qu'un seul scope (le scope global), toutes les assignations vont dans
context.globals.
Scope local
Un nouveau scope local est créé pour :
- Les fonctions (
name = () => { … }) - Les lambdas (
(params) => { … }) - Les blocs (indirectement via les fonctions)
f = () => {
y = 20 # Variable locale (y n'existe pas ailleurs)
print(y) # 20
}
f()
# print(y) # NameError: 'y' is not defined
# Mais attention avec les variables existantes :
x = 10
g = () => {
x = 20 # Modifie la globale (x existe déjà)
print(x) # 20
}
g()
print(x) # 20 - la globale a été modifiée !
Pile de frames
Catnip maintient une pile de frames dans le scope avec context.locals (Scope) :
Frame 0 (global) # Variables globales + builtins
Frame 1 (fonction 1) # Variables locales de fonction 1
Frame 2 (fonction 2) # Variables locales de fonction 2 imbriquée
Contrairement à un modèle de scopes chaînés, Scope utilise un HashMap flat où toutes les variables sont stockées dans un seul dictionnaire, avec des métadonnées pour suivre quel frame a introduit chaque variable.
Détection du scope global
Le code utilise context.locals.depth() == 1 pour détecter si on est au scope global :
# Détection du scope global (Registry)
is_global_scope = self.ctx.locals.depth() == 1
if is_global_scope:
self.ctx.globals[name] = value # Variable globale
else:
self.ctx.locals._set(name, value) # Variable locale
Résolution de variables
Ordre de résolution
Quand Catnip cherche une variable, il suit cet ordre :
- HashMap local : Lookup O(1) dans
context.locals.symbols - Scope global :
context.globalssi non trouvé - Erreur :
NameErrorsi non trouvé
Scope stocke toutes les variables locales dans un seul HashMap flat, évitant les traversées de chaîne parent. La résolution est donc toujours O(1).
Exemple de résolution
x = 100 # Global
f1 = () => {
y = 200 # Locale à f1 (x n'existe pas localement)
f2 = () => {
z = 300 # Locale à f2 (y et x n'existent pas localement)
# Résolution :
# - z : trouvé dans scope local de f2
# - y : trouvé dans scope parent (f1)
# - x : trouvé dans globals
print(x, y, z) # 100 200 300
}
f2()
}
f1()
Important : Si f2 faisait y = 999, cela modifierait le y de f1 (modification en place), pas création d'une nouvelle locale.
Réassignation dans les closures
Règle fondamentale : Catnip utilise une sémantique de modification en place pour les variables existantes.
Quand tu écris x = value dans une fonction :
- Si
xexiste dans un scope parent → modification en place (update) - Si
xn'existe pas → création d'une nouvelle variable locale
Cette sémantique est différente de Python, où toute assignation crée une locale masquante (sauf avec nonlocal/global).
x = 10 # Variable globale
f = () => {
# x existe déjà → modification en place
x = 20
print(x) # 20
}
f()
print(x) # 20 - la globale a été modifiée !
Closures avec état mutable :
compteur = () => {
count = 0 # Variable capturée
increment = () => {
# count existe dans le scope parent → modification en place
count = count + 1
count
}
increment
}
c = compteur()
print(c()) # 1
print(c()) # 2
print(c()) # 3
Cas sans variable existante :
f = () => {
# y n'existe nulle part → création locale
y = 100
print(y) # 100
}
f()
# print(y) # NameError: 'y' is not defined
Pas d'équivalent à nonlocal/global : Catnip n'a pas de mot-clé pour forcer le comportement. L'assignation cherche toujours une variable existante d'abord.
Note historique : Ce choix simplifie la gestion des closures au prix d'une légère ambiguïté. Si tu veux garantir une création locale, assure-toi qu'aucune variable de ce nom n'existe dans les scopes parents. Pour modifier un objet global sans le réassigner, utilise l'affectation par index ou attribut (
config[key] = value,obj.attr = value).
Fonctions et scopes
Création de scope
Chaque appel de fonction crée un nouveau scope :
compteur = (start) => {
count = start # Variable locale
increment = () => {
count = count + 1 # Accède au count du scope parent
count
}
increment
}
c1 = compteur(0)
c2 = compteur(100)
print(c1()) # 1
print(c1()) # 2
print(c2()) # 101
print(c2()) # 102
Note : Dans l'exemple ci-dessus, chaque lambda increment a accès au count de son scope parent, créant ainsi une
closure.
Paramètres de fonction
Les paramètres sont des variables locales au scope de la fonction :
f = (a, b, c) => {
# a, b, c sont dans le scope local
result = a + b + c
result # Aussi dans le scope local
}
f(1, 2, 3) # 6
# a, b, c, result ne sont pas accessibles ici
Paramètres variadiques
Les paramètres variadiques *args créent une liste dans le scope local :
somme = (*nums) => {
# nums est une liste locale
total = 0
for n in nums {
total = total + n
}
total
}
somme(1, 2, 3, 4, 5) # 15
Exemples pratiques
Compteur avec état
# Closure qui maintient un état privé
make_counter = () => {
count = 0 # Variable capturée par les lambdas
# Retourne un dict de fonctions
dict(
("increment", () => {
count = count + 1 # Modifie le count parent en place
count
}),
("decrement", () => {
count = count - 1 # Modifie le count parent en place
count
}),
("get", () => { count }) # Lecture seule
)
}
counter = make_counter()
increment = counter.__getitem__("increment")
decrement = counter.__getitem__("decrement")
get = counter.__getitem__("get")
print(increment()) # 1
print(increment()) # 2
print(decrement()) # 1
print(get()) # 1
Explication : Les lambdas increment et decrement modifient le count capturé car il existe dans leur scope parent. Chaque appel à make_counter() crée un count indépendant.
Fabrique de fonctions
# Crée une fonction qui multiplie par n
make_multiplier = (n) => {
(x) => { x * n } # n est capturé du scope parent
}
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Modification de variables globales
# Au niveau racine
config = dict(("debug", True), ("version", "1.0"))
# Les fonctions peuvent lire les globales
get_config = (key) => {
config[key]
}
print(get_config("version")) # "1.0"
# Réassigner une globale depuis une fonction
update_config = () => {
# config existe déjà globalement → modification en place
config = dict(("new", True), ("debug", False))
}
update_config()
print(config) # {'new': True, 'debug': False}
# Modifier le contenu d'une globale
set_config = (key, value) => {
config[key] = value # Modifie le dict global en place
}
set_config("port", 8080)
print(config["port"]) # 8080
Différence avec Python :
- Python :
config = ...dans une fonction crée une locale masquante (sansglobal) - Catnip :
config = ...modifie la globale si elle existe déjà
Pas de mot-clé global/nonlocal : Catnip n'a pas d'équivalent. L'assignation cherche automatiquement une variable existante.
Cas particuliers et pièges
Paramètres de fonction isolés
Les paramètres de fonction sont toujours isolés et créent de nouvelles variables locales, même si une variable du même nom existe dans le scope parent :
x = 100
f = (x) => {
# Ce 'x' est le paramètre, PAS la globale
x = x + 1 # Modifie le paramètre local
x
}
print(f(10)) # 11
print(x) # 100 - la globale n'a pas changé
Raison : Les paramètres doivent être isolés pour garantir qu'un appel de fonction ne modifie pas accidentellement des variables parentes.
Variables de boucle
Les boucles for peuvent créer un scope local selon la complexité du bloc. Le comportement exact dépend de l'implémentation interne et peut varier. Pour éviter toute ambiguïté :
- Utilise un nom unique pour les variables de boucle si tu veux éviter toute interaction avec des variables existantes
- Ou vérifie explicitement la valeur après la boucle si c'est important
# Bonne pratique : nom unique pour variable de boucle
for idx in range(10) {
print(idx)
}
# idx n'est plus accessible ici (scope local)
Créer intentionnellement une locale
Pour forcer la création d'une variable locale même si une globale existe, il faut d'abord s'assurer qu'elle n'existe pas dans les scopes parents. Une approche est d'utiliser un nom unique :
x = 10
f = () => {
_local_x = 20 # Nom unique, pas de conflit
print(_local_x) # 20
}
f()
print(x) # 10 - inchangée
Objets mutables vs réassignation
Attention à la différence entre réassignation et mutation :
config = [1, 2, 3]
f = () => {
# Mutation : modifie l'objet en place
config.append(4)
}
g = () => {
# Réassignation : remplace la variable globale
config = [10, 20]
}
f()
print(config) # [1, 2, 3, 4]
g()
print(config) # [10, 20] - remplacée !
Comparaison avec Python
Catnip et Python ont des règles de scoping différentes. Voici les principales différences :
| Aspect | Python | Catnip |
|---|---|---|
| Assignation | Crée toujours une locale (sauf avec global/nonlocal) |
Modifie variable existante, sinon crée locale |
| Lecture puis écrit | UnboundLocalError si lecture avant assignation |
Modifie la variable parente |
| Mots-clés | global, nonlocal pour forcer le scope |
Aucun - détection automatique |
| Closures | Lecture OK, écriture nécessite nonlocal |
Écriture automatique si variable existe |
| Paramètres | Toujours locaux, shadowent variables parentes | Identique |
Exemple Python :
# Python
x = 10
def f():
x = 20 # Crée une locale masquante
print(x) # 20
f()
print(x) # 10 - la globale n'a pas changé
# Pour modifier la globale, il faut `global`
def g():
global x
x = 30
g()
print(x) # 30
Équivalent Catnip :
# Catnip
x = 10
f = () => {
x = 20 # Modifie la globale directement
print(x) # 20
}
f()
print(x) # 20 - la globale a été modifiée
# Pas besoin de mot-clé, le comportement est automatique
Choix de design : Catnip privilégie la simplicité et évite les mots-clés pour la gestion de scope. Le compromis est qu'on ne peut pas forcer facilement la création d'une locale si une variable de même nom existe dans un scope parent.
Tail-Call Optimization (TCO)
Les fonctions tail-récursives réutilisent le même frame d'exécution pour éviter la croissance de pile :
# Factorielle tail-récursive
factorial = (n, acc=1) => {
if n <= 1 {
acc
} else {
factorial(n - 1, n * acc) # Tail call
}
}
# Un seul frame utilisé même pour factorial(1000)
factorial(1000) # Pas de stack overflow !
Note : TCO permet d'exécuter des récursions profondes sans dépassement de pile.
Résumé
| Concept | Description |
|---|---|
| Scope global | Niveau racine, accessible partout |
| Scope local | Créé par fonction/lambda, limité à cette fonction |
| Résolution | Local → Parents → Global |
| Modification en place | x = value modifie une variable existante (n'importe quel scope) |
| Création locale | Variable créée localement seulement si inexistante ailleurs |
| Closure | Fonction capture variables de son scope parent |
| TCO | Un seul scope pour tail-récursion |
Pas de global/nonlocal |
Assignation cherche automatiquement variable existante |