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 = value modifie 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 :

  1. HashMap local : Lookup O(1) dans context.locals.symbols
  2. Scope global : context.globals si non trouvé
  3. Erreur : NameError si 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 :

  1. Si x existe dans un scope parent → modification en place (update)
  2. Si x n'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 (sans global)
  • 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