Fonctions

Les fonctions Catnip sont des valeurs : on peut les lier à un nom, les passer en argument, les retourner et les stocker dans une collection.

Définition et appel

Une fonction est une lambda assignée à un nom :

greet = () => {
    "hello"
}

add = (a, b) => {
    a + b
}

greet()
# ⇒ "hello"

add(2, 3)
# ⇒ 5

Les arguments peuvent être positionnels, nommés, ou combiner les deux :

format_name = (first, last, separator=" ") => {
    first + separator + last
}

format_name("Ada", "Lovelace")
# ⇒ "Ada Lovelace"

format_name("Ada", last="Lovelace", separator=", ")
# ⇒ "Ada, Lovelace"

Les arguments positionnels précèdent les arguments nommés. Un paramètre variadique, s'il existe, doit être le dernier.

Une liste de paramètres accepte une virgule finale, comme une liste d'arguments, une liste d'arguments nommés ou une cible de déballage. Elle vaut aussi consigne pour le formatter, qui garde alors un paramètre par ligne :

print_table = (
    regions: list[set[str]],
    sections: list[Section],
    site: list[str],
) => {
    len(site)
}

Sans virgule finale, une signature qui tient dans la largeur est réunie sur une ligne.

Valeurs par défaut

Une valeur par défaut est évaluée à chaque appel où l'argument manque :

append_one = (items=list()) => {
    items + list(1)
}

append_one()
# ⇒ [1]
append_one()
# ⇒ [1]

Les deux listes sont distinctes. Ce comportement évite de partager implicitement un objet mutable entre les appels.

Une valeur par défaut peut lire les paramètres déjà liés :

rectangle = (width, height=width) => {
    width * height
}

rectangle(4)
# ⇒ 16
rectangle(4, 3)
# ⇒ 12

Valeur de retour

Le corps retourne sa dernière expression :

square = (x) => {
    result = x * x
    result
}

return permet de sortir plus tôt :

classify = (x) => {
    if x < 0 {
        return "negative"
    }
    if x == 0 {
        return "zero"
    }
    "positive"
}

Une fonction sans expression finale utile retourne None. Un return sans valeur retourne aussi None. return en dehors d'une fonction est une erreur.

Lambdas

Une lambda utilise directement la forme (paramètres) => { corps } :

double = (x) => { x * 2 }

apply = (value, function) => {
    function(value)
}

apply(5, (x) => { x + 1 })
# ⇒ 6

Nommer une lambda est utile pour la réutiliser ou pour apparaître dans une trace. La retourner directement réduit une liaison quand elle n'a pas d'autre rôle :

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

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

La lambda retournée est une closure : elle capture factor. Les règles de capture et de mutation sont détaillées dans Scopes et variables.

Paramètres variadiques

*name collecte les arguments positionnels restants dans une liste :

collect = (first, *rest) => {
    list(first, rest)
}

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

Il peut suivre des paramètres avec valeur par défaut, mais aucun paramètre ne peut le suivre :

valid = (prefix=">", *items) => {
    tuple(prefix, items)
}

bad = (*items, last) => { last }  # Erreur de syntaxe

Arité permissive

Les fonctions et les lambdas acceptent une arité permissive :

  • un paramètre manquant vaut None ;
  • un argument positionnel en trop est ignoré.
identity = (x) => { x }
identity()
# ⇒ None

first = (x) => { x }
first(1, 2)
# ⇒ 1

Cette règle ne rend pas toutes les opérations permissives. double() échoue lorsque le corps tente None * 2.

Les constructeurs de structures restent stricts : un champ requis manquant ou un argument en trop produit une erreur.

struct Point { x; y; }

Point(1)        # TypeError: argument 'y' manquant
Point(1, 2, 3)  # TypeError: trop d'arguments

Un paramètre annoté suit la même règle d'arité : omis, il reçoit d'abord None, puis le contrôle de type s'applique. Ainsi, (x: int) => { x } appelé sans argument échoue sur le type ; l'annotation ne rend pas le paramètre syntaxiquement obligatoire.

Pour imposer la présence d'une valeur, utilise un constructeur de structure ou vérifie explicitement None.

Annotations de paramètres

Cette section résume les conséquences visibles lors d'un appel. La syntaxe complète, la variance, les contrôles statiques et les types de retour sont décrits dans Annotations de type.

Types primitifs et nominaux

Les types primitifs sont contrôlés à l'entrée. La tour numérique autorise bool vers int et int/bool vers float; str est contrôlé sans coercition :

as_float = (x: float) => { x }
as_float(2)
# ⇒ 2.0

as_int = (x: int) => { x }
as_int("2")  # TypeError

Les structures, enums, unions taggées et traits utilisent le sous-typage nominal. Une fonction qui attend une structure de base accepte un sous-struct ; une fonction qui attend un trait accepte une structure qui l'implémente. Ces valeurs ne sont jamais converties.

struct Shape { name }
struct Circle extends(Shape) { radius }

label = (shape: Shape) => { shape.name }
label(Circle("unit", 1))
# ⇒ "unit"

Un nom de type inconnu laisse l'annotation inerte : Catnip ne rejette pas une valeur sur un contrat qu'il ne sait pas vérifier.

Types composites et unions

list[T], set[T], dict[K, V] et tuple[...] contrôlent le conteneur puis leur contenu, récursivement. L'arité fait partie du type d'un tuple :

sum_pair = (pair: tuple[int, int]) => {
    pair[0] + pair[1]
}

sum_pair(tuple(2, 3))
# ⇒ 5
sum_pair(tuple(2, "3"))  # TypeError

Les conteneurs mutables déjà typés sont invariants, car une mutation via un alias pourrait violer leur type. Un littéral fraîchement construit peut utiliser la covariance de la tour numérique : [1, 2] satisfait list[float]. Les tuples, immuables, sont covariants.

Une union de types accepte une valeur qui satisfait au moins un membre, sans coercition :

kind = (x: int | str) => { typeof(x) }

kind(1)
# ⇒ "int"
kind("one")
# ⇒ "string"

Point | None est donc la forme explicite d'un paramètre optionnel. Une union dont un membre ne peut pas être vérifié reste inerte.

Les unions taggées génériques vérifient aussi leurs payloads :

union Option[T] {
    Some(value: T)
    None
}

read = (value: Option[int]) => { value }
read(Option.Some(42))
read(Option.Some("no"))  # TypeError

Types de fonction

(int) -> int décrit un callback qui reçoit un entier et retourne un entier. La flèche absorbe à droite : (int) -> int | None retourne une union ; ((int) -> int) | None est une union contenant une fonction.

apply_twice = (callback: (int) -> int, value: int) => {
    callback(callback(value))
}

apply_twice((x) => { x + 1 }, 10)
# ⇒ 12

Le contrat est vérifié à trois endroits :

  1. lorsqu'une lambda littérale permet de détecter statiquement une arité ou un type incompatible ;
  2. à l'entrée, où la valeur doit être appelable et accepter l'arité promise ;
  3. au retour de chaque appel du callback, où le résultat est contrôlé.

Les paramètres sont contravariants, le retour est covariant. Une lambda avec un paramètre supplémentaire couvert par une valeur par défaut peut satisfaire une arité plus courte ; une fonction qui exige deux arguments ne satisfait pas un contrat à un argument. Un callable Python dont la signature n'est pas introspectable passe sur sa callabilité, puis ses appels restent responsables de signaler leurs erreurs.

La comparaison statique des lambdas porte composant par composant sur la signature promise. Un callback qui accepte un type plus large convient, et un callback qui retourne un type plus précis convient. Lorsqu'un mismatch ne peut pas être prouvé à l'analyse, le runtime conserve les deux contrôles observables :

apply = (callback: (int) -> int) => {
    callback(21) * 2
}

apply((x) => { x + 1 })
# ⇒ 44

apply((a, b) => { a })  # TypeError : le callback exige deux arguments
apply((x) => { "no" })  # TypeError : le retour n'est pas un int

Le type de retour est vérifié là où l'appel devient observable, y compris lorsque le callback traverse une closure. Les types de ses paramètres restent vérifiés par le mécanisme d'appel ordinaire.

Un générique nominal substitue ses paramètres jusque dans le payload d'une union taggée. Un variant sans payload passe sans valeur à contrôler ; l'arité du générique reste obligatoire. Les détails et les diagnostics se trouvent dans Unions.

Une annotation décrit ce qui franchit la frontière de l'appel. Elle ne crée ni argument ni valeur.

Fonctions dans les collections

Les fonctions gardent leur identité lorsqu'elles sont stockées :

operations = list(
    (x) => { x + 1 },
    (x) => { x * 2 },
)

list(operations[0](5), operations[1](5))
# ⇒ [6, 10]

Elles peuvent aussi être diffusées ou passées à map, filter, fold et reduce. Les règles d'agrégation sont dans Fold et reduce.

Décorateurs

@decorator applique une fonction de transformation au résultat de la définition :

@trace
compute = (x) => {
    x * 2
}

Cette forme équivaut à :

compute = trace((x) => {
    x * 2
})

Plusieurs décorateurs sont appliqués de bas en haut :

@outer
@inner
compute = (x) => { x }

équivaut à compute = outer(inner((x) => { x })).

Un décorateur personnalisé reçoit la fonction et retourne une valeur appelable :

logger = (function) => {
    (x) => {
        print("call", x)
        result = function(x)
        print("result", result)
        result
    }
}

@logger
double = (x) => { x * 2 }

double(5)
# ⇒ 10

Les décorateurs intégrés comme @pure ajoutent des informations utilisées par l'optimiseur. Ils ne doivent être appliqués que lorsque leur contrat est vrai : une fonction pure produit le même résultat pour les mêmes arguments et n'a pas d'effet de bord observable.

Les décorateurs intégrés les plus courants ciblent des frontières différentes :

Décorateur Contrat ou effet
@pure déclare une fonction déterministe sans effet de bord
@jit demande une compilation JIT immédiate si le corps est éligible
@static retire la liaison de self sur une méthode de structure
@abstract déclare une méthode dont un sous-type doit fournir le corps

@static et @abstract appartiennent aux méthodes de structures et de traits ; leurs règles complètes sont dans Structures et traits. @jit et @pure sont aussi reliés aux directives pragma.

Un décorateur reste une application de fonction. Si cette application retourne une valeur non appelable, le nom décoré désigne cette valeur et un appel ultérieur échoue normalement.

Tail calls (appels en position terminale)

Un appel est terminal lorsque son résultat devient immédiatement celui de la fonction courante. Catnip peut alors réutiliser l'espace d'exécution au lieu d'empiler un nouvel appel.

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

factorial(1000)

L'appel à factorial est terminal : aucune opération ne reste à effectuer après son retour. Dans la forme classique, il ne l'est pas :

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

La multiplication attend le résultat récursif, donc chaque appel garde son frame.

Une récursion non terminale peut souvent être convertie en ajoutant un accumulateur qui transporte le travail restant :

# Non terminale : l'addition attend le retour.
sum_to = (n) => {
    if n <= 0 { 0 }
    else { n + sum_to(n - 1) }
}

# Terminale : la somme partielle est un paramètre.
sum_to_tail = (n, accumulator=0) => {
    if n <= 0 {
        accumulator
    } else {
        sum_to_tail(n - 1, accumulator + n)
    }
}

sum_to_tail(100000)
# ⇒ 5000050000

La conversion ne consiste pas à déplacer arbitrairement l'appel en dernière ligne : toutes les opérations qui auraient suivi doivent être représentées dans les paramètres du prochain appel.

Positions terminales

Un appel est terminal lorsqu'il est :

  • la dernière expression du corps ;
  • la dernière expression d'une branche if ou d'un cas match lui-même terminal ;
  • l'expression d'un return.

Il ne l'est pas lorsqu'un opérateur, un autre appel, une construction de collection ou une affectation doit encore traiter son résultat.

Expression finale Terminale
next(x) Oui
return next(x) Oui
1 + next(x) Non
list(next(x)) Non
next(x).method() Non

La détection porte sur tout appel par nom, pas seulement sur l'auto-récursion. La récursion mutuelle utilise donc aussi une pile constante :

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

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

even(100000)
# ⇒ True

Les paramètres sont rebondés à chaque saut terminal ; les règles de portée et de closure restent inchangées.

Un appel terminal vers une autre fonction peut continuer vers une troisième, puis revenir à la première. Le runtime réutilise le même espace tant que chaque transition reste terminale. Si une seule fonction effectue une opération après son appel, cette transition recrée une chaîne de frames à partir de ce point.

Les appels indirects dont la cible est calculée au dernier moment ne bénéficient pas nécessairement de la même détection. La forme documentée et garantie est l'appel par nom en position terminale.

Activation et debug

La TCO est activée par défaut. Elle peut être contrôlée par pragma ou par la ligne de commande :

pragma("tco", False)

La désactiver conserve les frames intermédiaires, ce qui rend une trace récursive plus détaillée, mais restaure la consommation de pile. Voir Directives pragma.

En position terminale, le passé de l'appel ne contient plus de travail. Le runtime peut donc l'archiver sans garder son bureau.

Fonctions intégrées

La plupart des opérations générales viennent des builtins ou des modules chargés par le runtime. Quelques frontières méritent d'être explicites :

  • print(...) est disponible partout : c'est un builtin du runtime, et le module io en fournit sa propre version ;
  • write(value) écrit sur stdout sans séparateur ni retour à la ligne, writeln(value) en ajoute un, eprint(value) écrit sur stderr ; les trois appartiennent au module io, auto-importé dans le CLI et la REPL, à importer explicitement ailleurs ;
  • map, filter, zip, enumerate et reversed retournent des itérateurs ; sorted retourne une liste ;
  • fold(iterable, initial, function) a toujours un accumulateur initial ;
  • reduce(iterable, function) utilise le premier élément et échoue sur une séquence vide.

Les formes list(...), tuple(...), set(...) et dict(...) sont des littéraux Catnip, pas des appels arbitraires aux constructeurs Python. Leur arité et leur syntaxe sont décrites dans Types de données.

total = fold(list(1, 2, 3, 4), 0, (acc, value) => {
    acc + value
})

total
# ⇒ 10

map et filter restent paresseux. Il faut les matérialiser explicitement lorsqu'une liste est requise ; avec les littéraux Catnip, l'expansion * exprime cette consommation :

doubled = map((x) => { x * 2 }, list(1, 2, 3))
list(*doubled)
# ⇒ [2, 4, 6]

fold accepte toujours une séquence vide parce que l'accumulateur initial fournit le résultat. reduce n'a pas cette valeur de départ :

fold(list(), 0, (acc, value) => { acc + value })
# ⇒ 0

reduce(list(), (left, right) => { left + right })  # TypeError

Cette distinction est utile lorsque l'agrégation possède un élément neutre explicite.

Conversion et introspection

int, float, str, bool et complex convertissent une valeur selon les règles du type hôte. typeof est différent : c'est un intrinsic qui retourne le nom logique du type sans convertir la valeur.

int("42")
# ⇒ 42

typeof(42)
# ⇒ "int"

Les grands entiers restent des "int" pour typeof; leur stockage n'est pas exposé dans l'API du langage.

Pour les compositions avec map et filter, voir Fold et reduce. Pour la diffusion structurée d'une fonction sur des données, voir Broadcasting.

Voir aussi