Funzioni in TypeScript e C++: un confronto

Funzioni in TypeScript e C++: un confronto

Le funzioni sono il mattone fondamentale di qualsiasi linguaggio di programmazione, ma il modo in cui vengono dichiarate, tipizzate e gestite cambia profondamente a seconda del contesto. TypeScript e C++ rappresentano due mondi distanti: il primo è un linguaggio a tipizzazione statica che compila verso JavaScript e vive in un ambiente con garbage collector, il secondo è un linguaggio compilato nativamente, con gestione manuale della memoria e un sistema di tipi che agisce a tempo di compilazione senza alcun costo a runtime. Eppure entrambi condividono molti concetti: parametri, valori di ritorno, overloading, funzioni di ordine superiore, generici e closure. In questo articolo metteremo a confronto questi aspetti, evidenziando analogie e differenze.

Dichiarazione di base

In TypeScript una funzione si dichiara con la parola chiave function, annotando opzionalmente i tipi dei parametri e del valore di ritorno. Quando il tipo di ritorno viene omesso, il compilatore lo inferisce automaticamente.

// Dichiarazione con tipi espliciti
function add(a: number, b: number): number {
  return a + b;
}

// Il tipo di ritorno viene inferito come number
function multiply(a: number, b: number) {
  return a * b;
}

In C++ la firma di una funzione richiede sempre il tipo di ritorno e il tipo di ogni parametro. A partire dallo standard C++11 è disponibile la sintassi con auto e tipo di ritorno posticipato, mentre dal C++14 il compilatore può dedurre il tipo di ritorno autonomamente.

// Dichiarazione classica con tipi espliciti
int add(int a, int b) {
    return a + b;
}

// Tipo di ritorno dedotto dal compilatore (C++14)
auto multiply(int a, int b) {
    return a * b;
}

La differenza concettuale principale è che in C++ i tipi numerici non sono unificati come in TypeScript: number rappresenta sempre un valore in virgola mobile a doppia precisione, mentre in C++ occorre scegliere fra int, long, double, float e molti altri, ciascuno con dimensione e semantica precise.

Parametri opzionali e valori predefiniti

TypeScript distingue fra parametri opzionali, segnalati dal punto interrogativo, e parametri con valore predefinito. Un parametro opzionale assume il valore undefined se non viene passato.

// greeting è opzionale
function greet(name: string, greeting?: string): string {
  // Se greeting non viene fornito vale undefined
  return `${greeting ?? "Ciao"}, ${name}`;
}

// title ha un valore predefinito
function describe(name: string, title: string = "Sig."): string {
  return `${title} ${name}`;
}

C++ non possiede il concetto di parametro opzionale in senso stretto, ma supporta gli argomenti predefiniti. Questi devono comparire negli ultimi parametri della firma e vengono valutati nel punto di chiamata.

#include <string>

// title ha un valore predefinito
std::string describe(const std::string& name, const std::string& title = "Sig.") {
    return title + " " + name;
}

Per ottenere una semantica vicina a quella di un parametro davvero opzionale, in C++ moderno si ricorre tipicamente a std::optional, introdotto nel C++17, che esprime in modo esplicito l'assenza di un valore.

#include <optional>
#include <string>

std::string greet(const std::string& name, std::optional<std::string> greeting = std::nullopt) {
    // value_or restituisce il fallback se il valore è assente
    return greeting.value_or("Ciao") + ", " + name;
}

Numero variabile di argomenti

In TypeScript i parametri rest raccolgono un numero arbitrario di argomenti in un array tipizzato.

// numbers è un array di number
function sum(...numbers: number[]): number {
  return numbers.reduce((total, value) => total + value, 0);
}

sum(1, 2, 3, 4); // 10

C++ affronta lo stesso problema con i template variadici, una funzionalità del C++11 che opera interamente a tempo di compilazione. Il pacchetto di parametri viene espanso ricorsivamente oppure, dal C++17, tramite le fold expression.

#include <type_traits>

// Fold expression: somma tutti gli argomenti del pacchetto
template <typename... Args>
auto sum(Args... numbers) {
    // Espansione del pacchetto con l'operatore +
    return (numbers + ... + 0);
}

// sum(1, 2, 3, 4) viene risolto a tempo di compilazione

La differenza è sostanziale: in TypeScript gli argomenti finiscono in una struttura dati a runtime, mentre in C++ ogni combinazione di tipi genera una specializzazione distinta del template, senza alcun overhead di allocazione.

Funzioni anonime: arrow function e lambda

Le arrow function di TypeScript offrono una sintassi compatta per le funzioni anonime e catturano automaticamente il contesto lessicale di this.

const numbers = [1, 2, 3, 4];

// Arrow function passata come callback
const doubled = numbers.map((value) => value * 2);

// Forma con corpo a blocco e tipo di ritorno esplicito
const isEven = (value: number): boolean => {
  return value % 2 === 0;
};

In C++ l'equivalente sono le lambda, introdotte nel C++11. La loro caratteristica più rilevante è la lista di cattura, che controlla esplicitamente come le variabili dell'ambiente circostante vengono acquisite: per valore o per riferimento.

#include <vector>
#include <algorithm>

std::vector<int> numbers{1, 2, 3, 4};

// Lambda senza catture
auto doubled = [](int value) { return value * 2; };

int factor = 3;

// Cattura di factor per valore
auto scale = [factor](int value) { return value * factor; };

// Cattura di factor per riferimento
auto scaleRef = [&factor](int value) { return value * factor; };

Questa esplicitazione delle catture non ha corrispettivo in TypeScript, dove la cattura è sempre per riferimento al binding lessicale ed è gestita dal garbage collector. In C++ catturare per riferimento una variabile locale che esce dal proprio scope produce un riferimento pendente, un errore che il programmatore deve evitare manualmente.

Funzioni come valori e tipi di funzione

TypeScript tratta le funzioni come cittadini di prima classe e dispone di una sintassi dedicata per descriverne il tipo, utilissima quando una funzione viene passata o restituita da un'altra funzione.

// Alias per un tipo di funzione
type BinaryOperation = (a: number, b: number) => number;

function applyOperation(a: number, b: number, operation: BinaryOperation): number {
  return operation(a, b);
}

applyOperation(4, 2, (a, b) => a - b); // 2

In C++ esistono più strumenti per lo stesso scopo. Il più generale è std::function, un wrapper polimorfico che può contenere qualsiasi oggetto chiamabile: puntatori a funzione, lambda e funtori. Esiste un piccolo costo a runtime dovuto al type erasure.

#include <functional>

// std::function che accetta una qualsiasi operazione binaria
int applyOperation(int a, int b, const std::function<int(int, int)>& operation) {
    return operation(a, b);
}

// Chiamata con una lambda
applyOperation(4, 2, [](int a, int b) { return a - b; });

Quando le prestazioni sono critiche e il chiamabile è noto a tempo di compilazione, si preferisce un parametro template generico, che evita il type erasure e consente l'inlining.

// Versione template: nessun costo di astrazione a runtime
template <typename Operation>
int applyOperation(int a, int b, Operation operation) {
    return operation(a, b);
}

Closure

Entrambi i linguaggi supportano le closure, ovvero funzioni che ricordano lo stato dell'ambiente in cui sono state create. In TypeScript la closure è naturale e lo stato sopravvive finché esiste un riferimento alla funzione.

function makeCounter(): () => number {
  let count = 0;
  // La funzione restituita conserva un riferimento a count
  return () => {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
counter(); // 1
counter(); // 2

In C++ una closure analoga richiede attenzione alla durata dello stato catturato. Catturando per valore con mutable si ottiene una copia modificabile interna alla lambda, indipendente dalla variabile originale.

#include <functional>

std::function<int()> makeCounter() {
    int count = 0;
    // mutable consente di modificare la copia catturata
    return [count]() mutable {
        count += 1;
        return count;
    };
}

auto counter = makeCounter();
counter(); // 1
counter(); // 2

La parola chiave mutable è necessaria perché, per impostazione predefinita, una lambda che cattura per valore tratta le variabili catturate come costanti. In TypeScript non esiste questa restrizione, dato che le variabili catturate restano scrivibili.

Overloading delle funzioni

L'overloading consente di avere più funzioni con lo stesso nome ma firme diverse. In C++ è una funzionalità nativa del linguaggio: il compilatore sceglie l'implementazione corretta in base ai tipi degli argomenti attraverso la risoluzione dell'overload.

#include <string>

// Tre implementazioni distinte selezionate dal compilatore
int describe(int value) {
    return value;
}

double describe(double value) {
    return value;
}

std::string describe(const std::string& value) {
    return value;
}

In TypeScript l'overloading funziona in modo molto diverso. Si dichiarano più firme di overload, ma esiste un'unica implementazione concreta che deve gestire tutti i casi tramite controlli a runtime. Le firme servono soltanto al sistema di tipi e scompaiono dopo la compilazione.

// Firme di overload
function describe(value: number): number;
function describe(value: string): string;

// Unica implementazione, con controllo a runtime
function describe(value: number | string): number | string {
  if (typeof value === "string") {
    return value.toUpperCase();
  }
  return value * 2;
}

Questa distinzione riflette le filosofie dei due linguaggi: in C++ la selezione avviene a tempo di compilazione e ogni overload è codice macchina separato, mentre in TypeScript la discriminazione è demandata al codice JavaScript eseguito a runtime.

Generici e template

I generici di TypeScript permettono di scrivere funzioni parametrizzate rispetto al tipo, mantenendo la sicurezza statica. I vincoli si esprimono con la clausola extends.

// Funzione generica con vincolo
function first<T>(items: T[]): T | undefined {
  return items[0];
}

// Vincolo che richiede una proprietà length
function logLength<T extends { length: number }>(item: T): number {
  return item.length;
}

I template C++ sono concettualmente analoghi ma più potenti e operano interamente a tempo di compilazione, generando codice specializzato per ogni tipo effettivamente utilizzato. Dal C++20 i concept permettono di esprimere vincoli in modo leggibile, in modo paragonabile alla clausola extends.

#include <vector>
#include <optional>
#include <concepts>

// Template di funzione generico
template <typename T>
std::optional<T> first(const std::vector<T>& items) {
    if (items.empty()) {
        return std::nullopt;
    }
    return items.front();
}

// Vincolo espresso con un concept (C++20)
template <typename T>
    requires std::integral<T>
T doubleValue(T value) {
    return value * 2;
}

La differenza chiave riguarda il modello di compilazione. I generici TypeScript vengono cancellati durante la compilazione e non lasciano traccia nel JavaScript prodotto, secondo il principio del type erasure. I template C++ adottano invece la monomorfizzazione: ogni istanziazione genera una versione concreta e ottimizzata della funzione, con benefici prestazionali ma a costo di tempi di compilazione maggiori e di un binario più grande.

Ricorsione

La ricorsione si esprime in modo simile nei due linguaggi. In TypeScript il limite pratico è dato dalla dimensione dello stack del motore JavaScript.

function factorial(n: number): number {
  if (n <= 1) {
    return 1;
  }
  return n * factorial(n - 1);
}

In C++ la ricorsione ha la stessa forma, ma il compilatore può applicare ottimizzazioni come la tail call optimization in alcuni casi. Inoltre, grazie a constexpr, una funzione ricorsiva può essere valutata interamente a tempo di compilazione.

// constexpr permette la valutazione a tempo di compilazione
constexpr long factorial(long n) {
    if (n <= 1) {
        return 1;
    }
    return n * factorial(n - 1);
}

// Il valore viene calcolato durante la compilazione
constexpr long result = factorial(5); // 120

La possibilità di eseguire codice a tempo di compilazione tramite constexpr e consteval non ha equivalente in TypeScript, dove ogni calcolo avviene a runtime.

Asincronia

TypeScript eredita da JavaScript un modello asincrono basato su Promise e sulle parole chiave async e await, costruito attorno a un event loop a singolo thread.

// Una funzione async restituisce sempre una Promise
async function fetchValue(): Promise<number> {
  // await sospende l'esecuzione senza bloccare il thread
  const response = await Promise.resolve(42);
  return response;
}

C++ non possiede un event loop integrato nel linguaggio, ma offre primitive di concorrenza nella libreria standard, come std::async e std::future. Dal C++20 sono disponibili anche le coroutine, che introducono parole chiave come co_await, sebbene richiedano infrastruttura aggiuntiva per essere usate comodamente.

#include <future>

int computeValue() {
    return 42;
}

// std::async avvia il calcolo, potenzialmente su un altro thread
std::future<int> fetchValue() {
    return std::async(std::launch::async, computeValue);
}

// get() attende il risultato
// int value = fetchValue().get();

La differenza è profonda: il modello di TypeScript è cooperativo e a singolo thread, mentre quello di C++ è costruito attorno a thread reali del sistema operativo, con tutto ciò che comporta in termini di sincronizzazione e gestione delle risorse condivise.

Tabella riassuntiva

Aspetto TypeScript C++
Tipi numerici Unico tipo number Molti tipi distinti (int, double, ...)
Parametri opzionali Sintassi nativa con ? Argomenti predefiniti o std::optional
Argomenti variadici Parametri rest a runtime Template variadici a compile time
Funzioni anonime Arrow function, cattura implicita Lambda con lista di cattura esplicita
Overloading Firme multiple, unica implementazione Implementazioni distinte risolte dal compilatore
Generici Type erasure Monomorfizzazione dei template
Calcolo a compile time Assente Disponibile con constexpr e consteval
Asincronia Event loop, async e await Thread, std::async, coroutine

Conclusione

TypeScript e C++ condividono un vocabolario comune quando si parla di funzioni: parametri, ritorni, closure, generici e overloading sono presenti in entrambi. Le differenze emergono però appena si guarda sotto la superficie. TypeScript privilegia l'ergonomia e la flessibilità, delegando molte decisioni al runtime di JavaScript e affidando la gestione della memoria al garbage collector. C++ sposta invece quanto più possibile a tempo di compilazione, offrendo controllo capillare sulle prestazioni, sulla memoria e sulla semantica dei tipi, al prezzo di una maggiore complessità.

Comprendere come ciascun linguaggio modella le funzioni aiuta a sfruttarne i punti di forza: la rapidità di sviluppo e la sicurezza statica leggera di TypeScript da un lato, l'efficienza nativa e la potenza espressiva dei template di C++ dall'altro. Conoscere entrambi i modelli rende più consapevoli anche quando si lavora con uno solo dei due.