Programmazione anormale

Il peggior linguaggio di programmazione di tutti i tempi /s

14 luglio 202624 min

Su YouTube c'è un video di cinquanta minuti dal titolo orgoglioso «il peggior linguaggio di programmazione di tutti i tempi». Non mi stupirei se tu avessi dato per scontato che parlasse di C++. Parla davvero di C++, e l'ho visto circa mezzo anno fa, beh, «visto» è una parola grossa... l'ho scorso a 2x saltando parecchio, qualunque cosa un junior offeso possa aver blaterato lì dentro, ma il buon @alyokhinme l'ha ricordato di nuovo, e ora l'ho guardato per intero. E sai qual è la parte più spiacevole? Se togli l'intonazione da junior offeso e lasci solo gli argomenti, allora circa il settanta percento è vero. Non «discutibile», non «dipende dal contesto», ma letteralmente vero, del tipo che qualsiasi sviluppatore che abbia passato un paio d'anni con il linguaggio confermerà senza pensarci due volte.

Il paradosso è che questo video è stato fatto sul linguaggio in cui è scritta metà del mondo che ci circonda. Il browser in cui stai leggendo questo, il game engine, come potrei scrivere un articolo senza i giochi, del gioco a cui hai giocato ieri, il firmware dell'hardware su cui gira tutto, e il compilatore che ha costruito il browser, l'engine e il firmware.

Il genere «perché il C++ fa schifo» è stato bruciato fino alle fondamenta su Habr, e sullo zoo dell'Init, lo static sovraccarico, il vector chiamato in modo sbagliato, lo std::move che non sposta, la regex super lenta, la unordered_map lenta hai letto tutto una dozzina di volte. La lista stessa di questi dolori è roba vecchia, e aggiungo a titolo personale che tutte le lamentele e gli esempi qui sotto sono conseguenze di una singola decisione, e ci arriverò. Oppure apri gli spoiler, nascondono la storia del perché ogni parte del linguaggio è venuta fuori così com'è.


Molti modi di inizializzare una variabile

Cominciamo dalla parte più infernale, dall'inizio di qualsiasi programma, cioè dalla creazione di una variabile. E mentre negli altri linguaggi questa è di solito una singola operazione, in C++ ci si scrivono interi libri e ci si conducono studi, poi trascinati alle conferenze, come se non ci fossero già abbastanza altri problemi. Aiutami. Ho dimenticato qualcosa?

int f;                    // memoria automatica, spazzatura
static int f_static;      // ma in scope namespace/static è già zero (zero-init all'avvio)
std::string s;            // chiamata del costruttore di default
int* p = new int;         // heap, spazzatura
int e{};                  // 0
int e2 = {};              // 0
int* p = new int{};       // 0
int* q = new int();       // anche 0
auto t = T{};             // temporaneo, value-init
int b(5);
std::string s("hello");
T obj(a1, a2);
auto p = new T(a1, a2);
int x = static_cast<int>(3.5);   // cast, anche questo è direct-init
int y(int(5));                   // notazione funzionale
int a = 5;
std::string s = "hello";
T obj = other;
f(arg);                   // passaggio per valore, viene inizializzato il PARAMETRO della funzione
                          // non una variabile visibile
f({1, 2, 3});             // stessa cosa
return x;                 // ritorno per valore
return {1, 2, 3};         // anche, se la copy-elision non entra in gioco
throw x;                  // viene inizializzato l'oggetto eccezione
int c{5};                 // direct-list
int d = {5};              // copy-list
std::vector<int> v{1, 2, 3};
std::vector<int> v2 = {1, 2, 3};
std::vector<int> a(10);   // 10 elementi, tutti zeri
std::vector<int> b{10};   // UN elemento con valore 10
struct Point { int x, y; };
Point p{1, 2};            // puoi fare così
Point p2 = {1, 2};        // oppure puoi fare così
int arr[3] = {1, 2, 3};   // con gli zeri e senza
int arr2[3] = {};         // tutti zeri
Point p3{.x = 1, .y = 2}; // designated initializers, C++20
Point p4{1};              // x=1, y=0 (value-init della coda)
int& r = x;
const int& cr = 5;        // binding a un temporaneo + lifetime extension
int&& rr = 10;
constexpr int n = 42;     // deve essere constant-init
const int m = foo();      // può essere sia costante che dinamica
constinit int g = bar();  // C++20: garantisce static-init, ma NON la rende const
static int s = compute(); // dynamic-init, con il suo Static Init Order Fiasco
struct S {
    int x = 5;            // default member initializer
    int y{10};
    S() : y{2}, x(1) {}   // member initializer list (ordine per dichiarazione, non per lista)
};
auto [a, b] = std::pair{1, 2};   // structured bindings, C++17
for (int v : arr) { }            // anche il range-based for inizializza v

Se conti le «semantiche comportamentali» dell'inizializzazione, ne vengono fuori circa dieci (= v, (v), {v}, = {v}, {}, = {}, () nel new, ecc.), e ci saranno nove categorie semantiche, e la corrispondenza tra loro non è uno-a-uno, e una singola voce {} vive contemporaneamente in value-init, list-init e aggregate-init a seconda del tipo a destra. Sul tema dell'inizializzazione è stato scritto un libro a parte di trecento pagine, e non è uno scherzo, una volta un conoscente me l'ha consigliato, esiste davvero ed è davvero lungo trecento pagine, come si dice, buon divertimento.

E ogni metodo fa qualcosa di leggermente diverso, mi piace particolarmente la differenza tra e e f, dove la presenza o l'assenza di una coppia di parentesi graffe determina se la tua variabile contiene zero oppure qualsiasi cosa sia rimasta in questo pezzo di stack dalla chiamata di funzione precedente.

struct S {
    int x = 5;     // default member initializer
    int y{10};
};

NSDMI, Non-Static Data Member Initializer, un inizializzatore per un membro dati non statico proprio nella dichiarazione della classe. Nel punto int x = 5; l'oggetto non esiste ancora e nulla viene inizializzato. Questo è un default member initializer, cioè solo un modello che verrà usato durante la costruzione, SE il costruttore non ha inizializzato lui stesso il membro nella mem-init-list, cioè è un «inizializzatore di riserva», non un'inizializzazione. Formalmente è il brace-or-equal-initializer del membro.

auto [a, b] = std::pair{1, 2};

E qui viene inizializzato un oggetto anonimo nascosto (un hidden object o decomposition object , chiamiamolo e), e a e b non saranno variabili separate, sono binding names a parti dell'oggetto anonimo. L'inizializzazione è applicata a e, non ad a/b. Una sottigliezza, ma dato che stiamo cavillando, ripeto che queste non sono proprio vere variabili ma campi di una struttura, e e stesso non è accessibile direttamente, ma fisicamente lo storage è più o meno così:

e
+---------+
| first   | <--- a
| second  | <--- b
+---------+

auto e = std::pair{1, 2};
auto& a = e.first;
auto& b = e.second;
Asm
int k, e;

int main() {
    auto [a, b] = std::pair{k, e};

    return a + b;
}
leaq    -12(%rbp), %rdi
leaq    k(%rip), %rsi
leaq    e(%rip), %rdx
callq   std::pair<int, int>::pair<int&, int&, true>(int&, int&)

È qui che viene creato l'oggetto nascosto dello structured binding.rdi è il primo argomento del costruttore, cioè l'indirizzo del posto dove viene costruita la pair

main:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $32, %rsp
        movl    $0, -4(%rbp)
        leaq    -12(%rbp), %rdi  <<<<<
        leaq    k(%rip), %rsi    <<<<<
        leaq    e(%rip), %rdx    <<<<<
        callq   std::pair<int, int>::pair<int&, int&, true>(int&, int&) <<<<<
        leaq    -12(%rbp), %rdi
        callq   tuple_element<0ul, std::pair<int, int>>::type&& std::get<0ul, int, int>(std::pair<int, int>&&)
        movq    %rax, -24(%rbp)
        leaq    -12(%rbp), %rdi
        callq   tuple_element<1ul, std::pair<int, int>>::type&& std::get<1ul, int, int>(std::pair<int, int>&&)
        movq    %rax, -32(%rbp)
        movq    -24(%rbp), %rax
        movl    (%rax), %eax
        movq    -32(%rbp), %rcx
        addl    (%rcx), %eax
        addq    $32, %rsp
        popq    %rbp
        retq

k:
        .long   0

e:
        .long   0

E proprio quando decidi di averlo capito, salta fuori che le parentesi graffe in un contesto sono inizializzazione di aggregato, e in un altro sono std::initializer_list, e la stessissima riga di codice può restituire un tipo diverso a seconda della versione dello standard con cui compili. Non un risultato diverso, ma un tipo diverso. Benvenuto.

auto i = 1;      // sempre int
auto j = {1};    // sempre initializer_list<int> (copy-list-init)
auto k{1};       // C++11: initializer_list<int>;  C++17+: int
Come è nato lo zoo

Lo zoo dell'inizializzazione è cresciuto proprio dal tentativo di chiudere questo zoo, e nel 2011 il comitato ha sinceramente provato a ridurre tutto a una singola sintassi, e alla fine ha aggiunto ancora un altro recinto di tipi.

Dal C abbiamo ereditato l'ordinaria assegnazione in dichiarazione (int a = 5;) e le parentesi graffe per gli aggregati come array e struct (int arr[] = {1,2,3};). Le graffe in C potevano disporre i valori sui campi di un aggregato POD, e non pretendevano nulla di più.

Poi il C++ ha portato i costruttori, e una volta che c'è un costruttore bisogna in qualche modo chiamarlo nel punto di creazione dell'oggetto, e la sintassi più naturale si è rivelata essere le parentesi tonde, perché una chiamata di costruttore assomiglia visivamente a una chiamata di funzione, da qui Widget w(args);. Ha senso? Ha senso.

Fino al giorno in cui scrivi Widget w(); e scopri di aver dichiarato una funzione che restituisce un Widget. Questo è il famoso most vexing parse, e non è un bug nell'implementazione di qualcuno, ma una diretta conseguenza della grammatica ereditata dal C, dove «una dichiarazione assomiglia a un uso», ma una dichiarazione di funzione è sintatticamente indistinguibile dalla creazione di un oggetto con parentesi vuote.

Poi è arrivato il C++11 e una grande idea chiamata uniform initialization. L'intento era buono, un tentativo di introdurre una singola sintassi, {}, che funzionasse ovunque: per gli aggregati, per le classi con costruttori, per gli scalari e per i container.

Allo stesso tempo avrebbe risolto il most vexing parse, perché Widget w{}; non può essere interpretato come una funzione, e proibiva anche le conversioni con narrowing comeint x{3.5}; che sarebbe un errore, mentre int x = 3.5; taglierebbe via la parte frazionaria.

Cioè, {} era concepito non come «ancora un altro modo», ma come l'unico, il solo corretto e razzialmente puro, a cui tutti avrebbero dovuto passare.

Qui entra in gioco proprio quella cosa a cui tutto in questo linguaggio si riduce, la cosa di cui ho scritto alla fine. Perché {} diventasse l'unico, bisognava buttare via = e (), e quelli reggono miliardi di righe di codice, engine, lib, API di altri.

Salta fuori che non puoi buttarli via, quindi la nuova sintassi universale non ha sostituito quelle vecchie, ma si è sistemata nel proprio recinto, e il numero di modi è cresciuto. Uno strumento inventato per porre fine allo zoo è diventato il suo abitante più vistoso.

E per rendere le cose davvero divertenti, quelle stesse parentesi graffe sono state caricate di un secondo significato tramitestd::initializer_list, ora se un tipo ha un costruttore initializer-list, le graffe cominciano a significare quello, e per di più vince nella risoluzione degli overload.

Una storia a parte è la differenza traint e{}; (zero) e int f; (spazzatura). Questo viene dal nonno C, dove le variabili automatiche non venivano azzerate. Non perché se ne fossero dimenticati, ma perché azzerare costa cicli, e la filosofia del C era «non paghi per ciò che non usi», e se vuoi lo zero, scrivi tu stesso lo zero.

Quindi int f; lascia sullo stack qualunque cosa fosse rimasta dalla chiamata precedente, e questo è by design, una feature circa del 1972. E Stroustrup più tardi ha voluto dare un default zero sicuro, ma non ha potuto imporlo, perché avrebbe rotto sia la compatibilità sia la stessa idea di «non pagare per l'inutile». E così è venuto fuori che l'opzione sicura esiste, ma non è il default, perché la regola di default stessa è stata fissata mezzo secolo fa in nome della velocità.

Rimettere tutto in una singola sintassi ora non è più possibile, e ogni metodo si porta appeso il proprio codice e le proprie regole per lavorarci. Quindi quando esce l'ennesimo libro di trecento pagine sull'inizializzazione con diagrammi di flusso lunghi mezzo schermo, non sono gli autori che si divertono, è il prezzo di ciò che è stato scaricato nel linguaggio in quarant'anni a causa della singola feature più importante del linguaggio.

Le cose semplici non vengono fatte semplicemente

L'esempio da manuale di semplicità complicata è un numero casuale, e da qualche parte in Java o Python scrivi semplicemente random.randint(1, 100) e continui a programmare, ma non qui. È troppo semplice per essere vero.

std::random_device rd;
std::mt19937 gen(rd());                          // e cos'è mt19937?
std::uniform_int_distribution<int> dist(1, 100); // e perché separatamente?
int value = dist(gen);                           // finalmente

Il codice non è proprio illeggibile, ma devi cercarlo su cppreference una volta su due che ti serve davvero. Poi vado a googlare cosa sia questo mt19937, è il Mersenne Twister, ora vivi con questa conoscenza, e sì, ora devi conoscere il nome del generatore di numeri pseudo-casuali solo per lanciare un dado.

Cose semplici

Prima del C++11 il linguaggio aveva rand(), ereditato dal C, che restituiva un numero nell'intervallo fino a RAND_MAX, che lo standard garantisce essere almeno 32767, cioè su alcune piattaforme fisicamente non puoi ottenere un numero uniforme in un intervallo ampio.

Il familiare rand() % 100 produce una distorsione, perché 100 non divide in modo uniforme il numero di valori possibili, quindi alcuni numeri escono più spesso di altri, e la sequenza non è riproducibile tra compilatori. Cioè, era una funzione semplice per spararsi sui piedi.

Così nel C++11 hanno trascinato dentro un design fondamentalmente nuovo da Boost.Random, che deliberatamente ha separato ciò che rand() metteva tutto insieme. Ora c'è il motore di generazione stesso, o la sorgente di bit grezzi (mt19937, lo stessissimo Mersenne Twister di Matsumoto e Nishimura del '97), a parte. A parte c'è la distribuzione, che trasforma i bit grezzi in uniforme-nell'intervallo senza alcuna distorsione, e a parte c'è la sorgente del seed.

Questo è stato progettato da persone a cui servivano run Monte Carlo riproducibili per simulazioni scientifiche, e per i loro compiti funzionava perfettamente. Il guaio è che hanno costruito una bella cattedrale, ma non hanno mai montato una porticina per «lancia un dado da 1 a 100», e lo standard tuttora non ha una one-liner randint.

Ma persino l'incantesimo «corretto» è anch'esso scorretto, perché mt19937 contiene quasi ventimila bit di stato, e lo seediamo con un singolo numero a 32 bit da random_device, cioè lo sotto-seediamo. Questo è uno.

E lo standard stesso permette a random_device di essere deterministico, e sul vecchio MinGW produceva per anni la stessa sequenza a ogni esecuzione, cioè il lungo mantra corretto è in realtà lungo e corretto, ma non funziona ovunque, mentre rand() % 100 è sia più corto sia perlomeno funziona.

Problemi di casting

Il casting è tutta una canzone a parte, e dove in Java scrivi il valore tra parentesi e basta, in C++ c'è un intero set di questi cast, per tutti i gusti e colori. static_cast, dynamic_cast, reinterpret_cast, const_cast, bit_cast, e ognuno per il suo caso, e ognuno va digitato per intero a mano, e c'è anche un nascosto rvalue_cast, ma di questo più avanti.

double d = 3.9;
int i = static_cast<int>(d);        // 3, parte frazionaria scartata

Base* b = new Derived;
auto* der = static_cast<Derived*>(b); // downcast SENZA controllo
                                      // garantisci tu che sia un Derived

Base* b = get_something();
if (Derived* d = dynamic_cast<Derived*>(b)) {
    d->derived_only();              // arriviamo qui solo se è davvero un Derived
}                                   // altrimenti d == nullptr

int x = 42;
std::uintptr_t addr = reinterpret_cast<std::uintptr_t>(&x);  // indirizzo come numero

void legacy_api(char* s);           // non tocca la stringa, ma ha dimenticato const

const std::string str = "hello";
legacy_api(const_cast<char*>(str.c_str()));   // ok, dato che l'api davvero non modifica

float f = 1.0f;
auto bits = std::bit_cast<std::uint32_t>(f);  // 0x3F800000, niente UB

// come si faceva prima del C++20:
std::uint32_t old;
std::memcpy(&old, &f, sizeof f);              // stessa cosa, ma a mano

int i = (int)d;                     // compila, sembra familiare

I nuovi arrivati si stancano così tanto di questo che si impostano un breve alias per sé, e questo è considerato bad practice, perché ora il tuo codice è scritto in un tuo dialetto personale di C++ che nessuno conosce tranne te.

// antipattern: "sono stanco di scrivere static_cast"
template <class T, class U>
constexpr T sc(U&& u) { return static_cast<T>(std::forward<U>(u)); }

int i = sc<int>(3.9);               // corto, sì, corretto? no

// oppure ancora peggio:
#define CAST(T, x) static_cast<T>(x)

Questa è una vera trappola, e spesso il C++ corretto sembra scorretto, perché la soluzione corta e bella quasi sempre si rivela buggata e scorretta, mentre quella corretta sembra che tu abbia complicato inutilmente la vita al tuo team. L'intuizione per «come dovrebbe apparire il buon codice» richiede anni per svilupparsi, e finché non si sviluppa, vivi con la sensazione di stare facendo qualcosa di sbagliato. Spoiler: stai davvero facendo qualcosa di sbagliato, è il linguaggio che è fatto così.

Perché così tanti?

In C c'è un singolo cast (C-style cast), (T)expr, e fa tutto. Cambia il valore, reinterpreta un puntatore, toglie const, taglia il tipo — e tutto ciò sembra assolutamente identico. Il che significa che non puoi trovare nella codebase i posti dove qualcuno toglie la constness, perché sono indistinguibili da qualsiasi altra conversione.

Il C++ ha spezzato questo coltellino svizzero in quattro operazioni con nome: static_cast, dynamic_cast, const_cast, reinterpret_cast proprio a causa di questa indistinguibilità delle operazioni.

Affinché l'intento sia esplicito e il const_cast, quello che dice «qui sto facendo saltare in aria const», venga colto dall'occhio durante la review.

Affinché lo si possa greppare e tu possa trovare tutti i reinterpret_cast e controllarne ognuno.

I cast sono di per sé un male, quindi sono stati resi brutti apposta. Come diceva Alexandrescu, "a cast is a code smell", e Stroustrup ha reso la sintassi macchinosa affinché fare un cast fosse doloroso da digitare, e quando fai davvero un cast esplicito da un tipo a un altro, salta all'occhio durante la review. Il vecchio cast del C era troppo leggero e senza peso, ed era proprio questa leggerezza a renderlo pericoloso.

E così viene fuori che il corretto sembra scorretto letteralmente by design, non per caso. La soluzione corta e bella è quasi sempre proprio quel legacy rotto che non si può rimuovere in nome della singola feature più importante del linguaggio, mentre quella corretta è stata resa deliberatamente prolissa e scomoda affinché tu inciampi e pensi, e ti costringe ogni volta, digitando un cast a mano, a pensare.

Parole chiave che mentono

In un mondo normale una parola chiave descrive ciò che fa, ma in C++ questo è diventato un accessorio opzionale. Ricorda static.

E ricordi quanti significati ha? Fare una variabile che vive tra le chiamate di funzione, questo è uno. Fare una variabile o un metodo condivisi tra tutte le istanze di una classe, questo è due. Il terzo significato, quando static prima di una funzione in un file .cpp la rende invisibile fuori da quel file, cioè è private, ma l'hanno chiamato static. Perché non internal, non private, non file_local? Perché storicamente è semplicemente andata così, che è la risposta a circa metà delle domande di questo articolo.

void counter() {
    static int calls = 0;   // inizializzata UNA VOLTA, al primo ingresso
    int local = 0;          // locale ordinaria, di nuovo a ogni chiamata
    ++calls;
    ++local;
    std::cout << "static: " << calls << ", local: " << local << '\n';
}

E c'è un comportamento di static arrivato dopo il C++11, ora il tuo staticsignifica anche un light mutex guarded. Lo sapevi di questo?

Logger& logger() {
    static Logger instance;   // una riga
    return instance;
}

Logger& logger() {
    // percorso FAST: byte basso del guard != 0 → già inizializzato
    if ((reinterpret_cast<volatile char&>(__guard_for_instance)) == 0) {

        // percorso SLOW: arriviamo qui solo la prima volta e sotto sincronizzazione
        if (__cxa_guard_acquire(&__guard_for_instance)) {
            // acquire ha restituito 1 → proprio questo thread è obbligato a inizializzare.
            // Gli altri thread ora DORMONO dentro __cxa_guard_acquire.
            try {
                ::new (&__instance_storage) Logger();          // chiamata del costruttore
                __cxa_guard_release(&__guard_for_instance);    // imposta il flag, sveglia i dormienti
                __cxa_atexit(&destroy_logger, ...);            // registra la distruzione
            } catch (...) {
                __cxa_guard_abort(&__guard_for_instance);
                throw;
            }
        }
        // i thread che hanno perso la gara sono usciti da acquire solo dopo release
    }
    return reinterpret_cast<Logger&>(__instance_storage);
}
Perché static fa quattro cose non correlate

In C static aveva già due significati. Il primo, quello ordinario, riguarda la durata, quando una variabile vive per tutto il programma.

Il secondo riguardava l'internal linkage, quando il nome non era visibile fuori dalla translation unit. E anche se semanticamente queste sono cose di universi diversi, una sulla memoria e l'altra sulla visibilità, K&R ne hanno fatto una singola parola chiave, perché un linguaggio dovrebbe avere poche parole chiave, e la parola static era vicina di significato a «allocato staticamente».

Il C++ ha ereditato questi significati in blocco, come si dice, senza guardare, perché rinunciare a un pezzo di C significava rinunciare alla compatibilità con il C, ed era proprio ciò che volevano evitare. Poi a Stroustrup servivano membri di classe condivisi tra tutte le istanze, e avrebbe potuto introdurre qualcosa tiposhared, classwide, per analogia con private, protected ,qualsiasi cosa descrittiva, ma capisci che ogni nuova parola chiave nel linguaggio è una mina per gli sviluppatori.

Da qualche parte nel mondo sicuramente qualcuno ha chiamato una variabile shared o internal, o classwide, e nel giorno in cui quella parola diventa una parola chiave, il loro codice smette di compilare. Quindi prima di inventare una parola nuova, hanno prima provato a riutilizzarne una esistente, estatic viveva già nel linguaggio ed era già torbida, quindi ci si poteva appendere altra torbidezza senza il rischio di rompere la prod di qualcuno. Ecco come è apparso il terzo significato, non perché si adatti nel senso, ma perché la parola era a portata di mano e non rompeva nulla.

Il quarto comportamento è arrivato nel C++11, quando hanno voluto rendere thread-safe l'inizializzazione di un static locale di funzione, e il compilatore ha cominciato a inserire un guard nascosto al primo passaggio.

Il linguaggio ha provato a fare pulizia una volta, e nel C++98 lo static a livello di file per l'internal linkage è stato dichiarato deprecato e alla gente è stato detto di usare i namespace anonimi, ma nel C++11 questa raccomandazione è stata revocata, perché disabituare la gente dalla parola familiare si è rivelato assai più costoso che aggiustare il linguaggio, cioè il linguaggio non ha potuto buttare via nemmeno il significato che esso stesso aveva ritenuto ridondante.

Ora nessuno dei quattro può più essere rimosso, e a ognuno è appeso il codice di qualcuno, qualche contatore locale, o una funzione privata, o un membro di classe in un'API usata da centomila persone.

inline un tempo chiedeva al compilatore di fare l'inlining di una funzione, ma oggi il compilatore è furbo e fa l'inlining da solo quando lo ritiene opportuno, e inline ora risolve problemi di linking e la One Definition Rule. E inline su una funzione e inline su una variabile fanno semanticamente cose opposte, e su una funzione è il permesso di duplicare codice, mentre su una variabile è un divieto di duplicare dati.

// math.h
inline int square(int x) { return x * x; }

// a.cpp
#include "math.h"
int use_a() { return square(3); }

// b.cpp
#include "math.h"
int use_b() { return square(4); }

// counter.h
inline int g_calls = 0;   // esattamente un'istanza per tutto il programma

// a.cpp
#include "counter.h"
void hit_a() { ++g_calls; }

// b.cpp
#include "counter.h"
void hit_b() { ++g_calls; }

// config.h
struct Config {
    static inline int instances = 0;          // un contatore per tutti gli oggetti, proprio nell'.h
};

inline constexpr double kPi = 3.14159265358979;  // costante header-only, un oggetto
Cosa doveva essere inline

Nel primo C++ era un sostituto type-safe della macro #define sulla falsariga di «sostituisci il corpo della funzione proprio nel punto di chiamata invece di una chiamata» ed era letteralmente un'ottimizzazione di srotolamento. Da qui il nome, e da qui la credenza popolare che inline sia per «renderlo più veloce», dimenticalo, non è più su quello da circa quindici anni.

Oggi il suo significato portante non riguarda più l'ottimizzazione, ma la ODR e il linking. A un'entità inline è permesso essere definita in più translation unit (cioè mettere la definizione in un header e includerlo ovunque), e il linker è obbligato a fondere queste definizioni in una, invece di crollare con multiple definition.

Come si relaziona inline all'inlining vero e proprio? Beh, quasi per niente. Come suggerimento all'ottimizzatore è puramente consultivo, e i compilatori moderni spesso lo ignorano, avendo modelli di costo attraverso cui fanno felicemente l'inlining di funzioni senza inline e rifiutano di fare l'inlining di quelle marcate, e con la LTO fanno l'inlining anche attraverso i confini delle TU indipendentemente dalla parola.

L'unico legame tra inline e l'inlining reale ora è indiretto, e questa parola chiave ora ti permette di mettere il corpo in un header senza sbattere contro un errore di linking, e l'ottimizzatore può collocare il corpo visibile in una TU separata, perché senza quello la LTO non si può fare.

I metodi definiti proprio nel corpo della classe sono già implicitamente marcatiinline, e tutte le funzioniconstexpr sono anch'esse rese implicitamenteinline, ma la gente continua ad aggiungere la parola chiave «giusto in caso», anche se lì non cambia nulla.

L'inlining ora va fatto tramiteforceinline (MSVC) e attribute__((always_inline)), [[gnu::always_inline]], e persino quelli spesso rifiutano in caso di ricorsione, varargs, o presa di un indirizzo. Questo nome è un vestigio dell'epoca in cui funzionava davvero così, ma ora riguarda il linker.

const, che dovrebbe riguardare l'immutabilità, può essere scritto su entrambi i lati del tipo, ed entrambe le grafie significano esattamente la stessa cosa, giusto perché tu non ti rilassi. E anche mutable const è a volte codice valido. E anche la constness può essere tolta con un cast, e questa è bad practice, ma fisicamente si può fare.

Per capire a cosa si riferisce const in una dichiarazione di puntatore, al valore o al puntatore stesso, c'è una regola ufficiale «leggi da destra a sinistra». Ti ricordo che la maggior parte delle persone legge da sinistra a destra.

const int a = 5;   // "west const"
int const b = 5;   // "east const" esattamente la stessa cosa, entrambe sono costanti

const int* p = &a; // puntatore a const int
int const* q = &a; // e questo è la stessa cosa

struct Cache {
    mutable int hits = 0;   // può essere cambiato anche su un oggetto const
    int value = 0;
};

const Cache c;
c.hits++;       // OK, mutable
// c.value++;   // errore, un membro ordinario di un oggetto const

struct S {
    mutable const int* p = nullptr;  // VALIDO
};

struct Bad {
    mutable const int x = 5;  // NON VALIDO, mutable non può stare su un membro const
};

// Caso 1: l'oggetto è DAVVERO non const, ok
int x = 5;
const int& cref = x;
const_cast<int&>(cref) = 10;   // legale, x è davvero cambiato, x == 10

// Caso 2: l'oggetto è DAVVERO const, Undefined Behavior
const int y = 5;
const_cast<int&>(y) = 10;      // compila, ma questo è UB
                               // y può restare 5, crashare, o qualsiasi cosa

int x = 0, y = 0;

const int* p1 = &x;        // p1: puntatore a const int
int* const p2 = &x;        // p2: puntatore const a int
const int* const p3 = &x;  // p3: puntatore const a const int

*p1 = 5;   // errore: il valore è const
p1 = &y;   // ok: il puntatore può essere reindirizzato

*p2 = 5;   // ok: il valore può essere cambiato
p2 = &y;   // errore: il puntatore è const

*p3 = 5;   // errore
p3 = &y;   // errore
Perché const, se la memoria non è const?

Questa è una delle poche parole chiave che è nata in C++ ed è stata esportata in C, non il contrario. Stroustrup l'ha introdotta già in «C with Classes» (inizialmente sotto il nome di lavoro readonly), e da lì è entrata nello standard C89 e ha cominciato a vivere una vita propria e indipendente. Ma una parola inventata per portare ordine ha immediatamente ereditato il disordine della grammatica in cui è stata inserita.

Tanto per cominciare, const int e int const sono letteralmente la stessa cosa, econst è solo un qualificatore di tipo, e sta nella parte della dichiarazione che la grammatica chiama decl-specifier-seq, cioè una sequenza di specificatori.

E questa sequenza era non ordinata molto prima che const apparisse, per esattamente lo stesso motivo per cuiunsigned long e long unsigned significano un unico tipo. Ora const ha pestato il vecchio rastrello, dove l'ordine delle parole comunque non contava, e per di più, la forma "corretta" è precisamente int const, perché const si riferisce a ciò che sta alla sua sinistra, e const int è una concessione grammaticale, perché i vecchi compilatori lo permettevano.

Ecco proprio perché c'è tutto un movimento «east const» (a suo tempo lo promuoveva Jon Kalb, e prima di lui Dan Saks scriveva molto sul districare le dichiarazioni). Nessuno ha progettato le due grafie, sono semplicemente saltate fuori dalla grammatica, perché era comodo e non rompeva il vecchio codice.

const int* p;   // puntatore a const int, cambia il puntatore, non il valore
int* const p;   // puntatore const a int, cambia il valore, non il puntatore

Qui c'è una differenza su quale lato dell'asterisco stia const, perché a sinistra dell'asterisco cade in quella stessa sequenza di specificatori e qualifica ciò che è puntato.

E a destra dell'asterisco diventa parte del declarator e qualifica il puntatore stesso, e la dichiarazione è stata deliberatamente resa somigliante all'espressione che più tardi lavorerà con la variabile. Per int p questo è elegante, per int (*f)(int) è già una presa in giro, e const qui complica una grammatica che è difficile di per sé.

E di nuovo non puoi assemblare questo in un tutt'uno, perché la grammatica di const è saldamente fissata dalla compatibilità con il nonno C, e cambiare come viene interpretata significherebbe rompere sia il C sia quarant'anni di codice in entrambi i linguaggi. Quindi viene fuori che la parola sembra un lucchetto sulla porta, ma in realtà è un cartello che dice «per favore non entrare», girato con le sue lettere rivolte verso la porta.

Lo zoo dei tipi interi

Quanti tipi interi ci sono in C++? Circa cinquanta (se conti anche gli alias a larghezza fissa). E la dimensione non di tutti è fissa, ma dipende dal compilatore e dalla piattaforma.

bool                    // sì, bool — è anch'esso un tipo integrale

char                    // un tipo SEPARATO
signed char             // un tipo SEPARATO
unsigned char           // un tipo SEPARATO, questi sono tre tipi diversi, non due

char8_t                 // C++20
char16_t                // C++11
char32_t                // C++11
wchar_t

short
unsigned short
int
unsigned int
long
unsigned long
long long               // C++11
unsigned long long      // C++11

short, short int, signed short, signed short int          // 4 grafie → 1 tipo
int, signed, signed int                                   // 3 grafie → 1 tipo
long, long int, signed long, signed long int              // 4 → 1
long long, long long int, signed long long, signed long long int  // 4 → 1
unsigned, unsigned int                                    // 2 → 1

int8_t   int16_t   int32_t   int64_t        // larghezza esatta (opzionale)
uint8_t  uint16_t  uint32_t  uint64_t       // 8 di questi

int_least8_t  ... int_least64_t             // larghezza minima
uint_least8_t ... uint_least64_t            // 8 di questi (obbligatori)

int_fast8_t   ... int_fast64_t              // larghezza "veloce"
uint_fast8_t  ... uint_fast64_t             // 8 di questi

intmax_t  uintmax_t                          // il più largo
intptr_t  uintptr_t                          // dimensione-puntatore (opzionale)

std::size_t        // <cstddef>
std::ptrdiff_t     // <cstddef>
std::sig_atomic_t  // <csignal>
std::wint_t        // <cwchar>
std::streamsize, std::streamoff   // <ios>

int non è «32 bit», è «almeno 16 bit, ma forse 32», ed è garantita solo la catena short <= int <= long <= long long. Su Linux a 64 bit long è 64 bit, e su Windows a 64 bit long è ancora 32 bit, a causa della retrocompatibilità (ricorda questa frase). Per ottenere un tipo prevedibile a 64 bit, c'è int64_t, e onestamente non capisco perché abbia il suffisso _t, non siamo più negli anni novanta, ma non si può più cambiare, di più sotto lo spoiler.

I tipi carattere sono a parte meravigliosi, ce ne sono sette, e prima o poi ti imbatterai nella domanda perché un carattere possa persino essere signed e unsigned, come un numero, come char differisca da signed char e unsigned char (e questi sono tre tipi diversi, non due), cosa sia wchar_t, e quale sia la differenza tra std::string e std::wstring, e perché a causa di essa la tua codifica si romperà dal nulla, ma di questo un'altra volta.

La cronaca del linguaggio

Lo zoo dei tipi è una cronaca paleontologica di tutte le macchine su cui il C abbia mai girato, e per capire perché int non sia «32 bit», bisogna ricordare su cosa sia cresciuto il C stesso.

Ed è cresciuto nei primi anni settanta, quando l'hardware era molto vario, e lo stesso PDP-11 aveva parole a 16 bit, mentre l'Honeywell, una delle prime macchine su cui il C è stato portato, usava parole a 36 bit, e in alcune modalità venivano usati caratteri a 6 bit o 9 bit. Storicamente un byte in C non è affatto necessariamente uguale a 8 bit, nello standard CCHAR_BIT è il numero di bit nell'unità di memoria minima indirizzabile, cioè una macchina perfettamente legale conCHAR_BIT == 9.

Il CDC aveva parole a 60 bit, qualcosa usava il complemento a due, qualcosa usava il complemento a uno, e Ritchie prese la decisione, all'epoca sensata, di non fissare affatto le dimensioni. Cioè, int è semplicemente «la parola macchina naturale, la cosa con cui il processore lavora più velocemente, ma non meno di 16 bit».

Il linguaggio garantiva solo gli intervalli minimi e l'ordine short <= int <= long <= long long, e lasciava le dimensioni concrete alla piattaforma, e grazie a questo lo stessissimo sorgente compilava in modo efficiente sul PDP-11 a 16 bit, e sull'Honeywell a 36 bit, e su una dozzina di altre macchine, e il tipo int non fisso era proprio la proprietà che permetteva al C di girare su qualsiasi hardware.

Poi arrivarono le macchine a 32 bit, e int divenne 32, e quando arrivarono le macchine a 64 bit, int rimase incastrato lì a 32, perché il mondo era già sepolto sotto codice in cui sizeof(int) == 4 era incorporato nelle config, e allargare int avrebbe significato rompere tutto ciò e, per di più, invitare la rottura di ABI future.

Ma Microsoft ha lasciato long a 32 bit. Perché? Una codebase grande, prodotti e utenti, che in breve si chiama retrocompatibilità.

Tonnellate di codice e la stessa Win32 API consideravano long quattro byte, lo trattavano alla pari di int e DWORD, lo serializzavano su disco e in rete come quattro byte, e rompere quello è stato ritenuto più costoso che sopportare la divisione. Cioè, long significa cose diverse su due piattaforme proprio perché alla fine degli anni novanta due ecosistemi fecero scommesse diverse sulla compatibilità.

int64_t e il suo suffisso _t sono, in sostanza, un'ammissione di un errore, che il linguaggio non è riuscito a rendere prevedibili i suoi tipi base, quindi decenni dopo ha bullonato un secondo set di lato, questo a larghezza fissa e con un header separato (<stdint.h> in C99, e il t non sono affatto gli anni novanta, sono gli anni settanta, e un ricordo della convenzione Unix per i nomi di typedef (size_t, time_t, wchar_t), dove t è riservato all'implementazione, affinché lo standard potesse continuare ad aggiungere tipi senza collidere con i tuoi identificatori.

La libreria non standard

Se volessi confondere un nuovo arrivato il più possibile, chiamerei le cose esattamente come sono chiamate nella STL. Il container più usato si chiama vector ed è il nostro array dinamico, ma un vettore nel senso ordinario è una quantità con una direzione, e lo stesso Alexander Stepanov, l'autore della STL, ha riconosciuto che il nome fu un errore.

Se volessi una tabella hash, dovrai prendere std::map, ma quello è un albero bilanciato con lookup logaritmico. E una vera tabella hash è std::unordered_map, che, spoiler, è anch'essa meglio non usare, perché è lenta, e non è che «l'implementazione è pigra», è incorporato nello standard stesso. Le garanzie che std::unordered_map è obbligata a fornire non lasciano allo sviluppatore di libstdc++/libc++ altra scelta che renderla lenta.

std::map<std::string, int> ordered;
ordered["banana"] = 1;
ordered["apple"]  = 2;
ordered["cherry"] = 3;

for (auto& [k, v] : ordered)
    std::cout << k << ' ';       // SEMPRE: apple banana cherry, in ordine crescente di chiave

std::unordered_map<std::string, int> hashed;
hashed["banana"] = 1;
hashed["apple"]  = 2;
hashed["cherry"] = 3;

for (auto& [k, v] : hashed)
    std::cout << k << ' ';       // ordine arbitrario, dipende dall'hash e dai bucket

std::unordered_map<std::string, int> m;
m.insert({"key", 1});
m.insert({"key", 2});            // NON ha sovrascritto, ma ha restituito {iterator, false}
std::cout << m["key"];           // 1, non 2

m.insert_or_assign("key", 2);    // dopo il C++17 questo sovrascriverà
std::cout << m["key"];           // 2
m["key"] = 2;                    // oppure semplicemente così

Lo standard di fatto richiede il separate chaining (storage separato) con nodi e unordered_map è obbligata a garantire la stabilità di riferimenti e puntatori agli elementi dopo insert/erase (tranne quello rimosso), affinché un puntatore a un elemento resti valido anche quando il container fa il rehash.

E questo è possibile solo se ogni elemento è allocato separatamente sull'heap (std::pair<const Key, Value> più un puntatore al successivo), e un bucket è fatto come una lista concatenata di tali elementi. Cioè, secondo lo standard non è una «tabella hash in un array», ma un «array di puntatori a liste sparpagliate per l'heap». O un'altra gag con la stessa cosa:

std::unordered_map<std::string, int> m;
if (m["key"] == 0) { }    // se la chiave NON c'era, è stata appena inserita
                          // con il valore di default 0. Il controllo "esiste la chiave" l'ha creata.

operator[] su una chiave mancante inserisce silenziosamente un valore di default, quindi devi controllare la presenza tramite find/contains, non tramite []. Una sciocchezza, certo, ma i nuovi arrivati pestano questo rastrello regolarmente.

E anche i container hanno empty(), che sembra «svuotalo», ma è la nostra domanda «il container è vuoto?». In termini umani sarebbe is_empty(), ma no, mentre c'èremove(), che non rimuove affatto nulla, ma invece sposta gli elementi verso la fine e restituisce un iteratore, e tu rimuoverai separatamente (ciao, erase-remove idiom). E c'è anche std::stoi, std::stol, std::stoll, std::stof, std::stod, std::stold, e devi semplicemente sapere cosa sono. Lo sai, vero?

È standard?

Per capire perché la libreria standard sembri essere stata progettata contro lo sviluppatore, bisogna ricordare che l'ha ideata un matematico. Alexander Stepanov ha passato decenni a coltivare l'idea della programmazione generica, su come gli algoritmi dovrebbero essere scritti attraverso requisiti astratti sui tipi, piuttosto che legati a strutture dati concrete.

Prima del C++ provò a farlo in Scheme, in Ada insieme a Musser, poi arrivò al C++, e i template, inventati per tutt'altra cosa, si rivelarono abbastanza potenti da esprimere tutte le sue idee. Nel '93–'94 portò tutto il suo lavoro a quello che non era ancora il comitato ma il gruppo di sviluppo del linguaggio, e questo, caso molto raro, lo trascinò nel C++98 quasi per intero.

Da qui vector, perché nel calcolo numerico in Scheme un «vettore» significava un array contiguo unidimensionale, quindi nel contesto di Stepanov il nome era logico. Poi si accorsero e vollero chiamarlo array, ma il nome era già diventato familiare nello standard e nei progetti, quindi rinominarlo di nuovo era impossibile, di nuovo la stessa cosa che ovunque altrove nel linguaggio.

E map è della stessa opera, ma il nome descrive onestamente l'astrazione «mapping chiave→valore», è solo che la gente arriva da Java e Python, dove «map/dict» è di default un hash, e inconsciamente si aspetta lo stesso. E quando una vera tabella hash fu finalmente aggiunta nel C++11, il nome ovvio hash_map non poté essere preso, perché era stato accaparrato da estensioni vendor mutuamente incompatibili di SGI, Microsoft, Dinkumware e altre aziende, e di nuovo, per non rompere tutto questo zoo di hash_map già scritti, il comitato prese il nome libero, per quanto goffo — unordered_map. Quindi il nome brutto è solo un'altra cicatrice della strada scelta verso la retrocompatibilità.

E non dimenticare i ...value

E per rendere le cose davvero belle per te, ricorda che ci sono lvalue, rvalue, glvalue, prvalue e xvalue. Li ricordi?

E ci sono anche situazioni in cui std::move fa una copia, e che a volte non ti serve std::move, ma std::move_if_noexcept. Dato che siamo in tema di spostamento, std::move ha storicamente un nome scorretto, e non sposta nulla, ma fa solo il cast di un valore a rvalue reference, permettendogli di legarsi a un costruttore di move. Avrebbe dovuto chiamarsi rvalue_cast, ah dannazione... allora avremmo un cast in più.

Non tutto ciò che è xvalue è lvalue

In C c'erano due nozioni, lvalue e rvalue. Nel C++11 è apparsa la semantica di spostamento (move), e due categorie hanno smesso di bastare, dato che è diventato necessario distinguere «un oggetto con nome che non si può toccare» e «un temporaneo da cui il valore può essere preso», ma queste sono due proprietà indipendenti, e le loro combinazioni danno cinque categorie, e apparexvalue (eXpiring, «in scadenza»), è un oggetto con identità da cui il valore può essere preso.

Per marcare tali oggetti xvalue, serviva una semantica separata, che avrebbe dovuto chiamarsi rvalue_cast, ma il comitato non voleva aggiungere ancora un altro cast, quindi Howard Hinnant e compagnia scelsero un nome per intento, e ora nel punto di chiamata move va letto come «ho finito con questo, puoi prenderlo».

E anche la libreria standard ha un secondo std::move, che onestamente sposta gli elementi, quindi non confondere quello che sposta con quello che non sposta. E qualcuno mi ha persino mostrato un PDF di 70 pagine su come e quando usare correttamente entrambi gli std::move, peccato non aver ricordato il titolo.

E non sto nemmeno ancora cavillando sui nomi degli idiomi. Ricordi RAII dall'articolo su C++101? Sta per Resource Acquisition Is Initialization, cioè acquisire una risorsa è la sua inizializzazione, ma descrive esattamente il momento opposto, e nell'idioma la cosa principale non è l'acquisizione di una risorsa, ma il suo rilascio automatico nel distruttore all'uscita dallo scope. Cioè, sarebbe CADR (Constructor Acquires, Destructor Releases, «il costruttore acquisisce, il distruttore rilascia»), non RAII (crea una risorsa nel costruttore).

void process() {
    FileHandle file("data.txt");   // aperto

    if (nothing_to_do())
        return;                    // uscita anticipata — il file è chiuso automaticamente

    might_throw();                 // ha lanciato un'eccezione — il file è COMUNQUE chiuso
}                                  // fine ordinaria dello scope — il distruttore ha chiuso il file

O un altro esempio, CRTP - hai letto dentro il nome dell'idioma? CRTP è il Curiously Recurring Template Pattern, «un pattern curiosamente ricorrente», e il nome descrive non ciò che il pattern fa, ma il fatto che la persona che l'ha ideato ci si è imbattuta più volte nei progetti ed è rimasta sorpresa da questa coincidenza, e poi ha chiamato così il pattern.

Modern C++

Hai sentito che dovresti imparare il modern C++? Ma quando googli cosa sia il modern C++, atterri su libri di non so quanti anni fa.

Effective Modern C++ di Scott Meyers (2014), e proprio nel sottotitolo «42 Specific Ways to Improve Your Use of C++11 and C++14»

Modern C++ Design: Generic Programming and Design Patterns Applied

The Modern C++ Challenge

Modern C++: Efficient and Scalable Application Development

Il C++11 è stato il primo «moderno», ha portato smart pointer, lambda e semantica di spostamento (move). Poi sono arrivati 14, 17, 20, 23, e ognuno si è dichiarato il più nuovo e ora di sicuro, definitivamente «moderno». E già il libro di testo più venduto sul modern C++, «Effective Modern C++», non è più moderno, e in alcuni punti nemmeno particolarmente efficace. Nel frattempo l'industria vive da qualche parte intorno al C++17, perché nessuno vuole riscrivere milioni di righe di codice funzionante per essere «più moderno del moderno», perché fa paura.

Il risultato è che sei costretto a conoscere tutte le versioni dello standard in una volta (e differiscono, credimi), perché il vecchio codice al lavoro è scritto in un dialetto, quello nuovo in un altro, il libro di testo ne ha un terzo, il tutorial su YouTube un quarto, e nessuno di essi è modern.

Errori che non stanno in un monitor

Quando finalmente compili qualcosa, ti accolgono i messaggi di errore. Il C++ può scaricare mille righe di spazzatura illeggibile per un singolo ghirigoro fuori posto, con la vera causa sepolta da qualche parte nel mezzo, ma il tuo cervello non riesce più a percepirla, perché metà del testo sono le viscere della libreria standard trapelate all'esterno.

Un errore di istanziazione di template è cinese non convenzionale fatto di parentesi angolari, che occupa così tanto spazio orizzontale che non sta più in un monitor 4K. Ho seriamente cominciato a capire il senso dei monitor ultrawide proprio guardando questi errori, e col tempo ho anche cominciato a disattivare l'a capo automatico, perché con l'a capo era solo peggio. Non c'è da stupirsi che molti sviluppatori C++ abbiano monitor ultrawide, occhiali e un collo malandato.

Perché gli errori sono così prolissi

Un template non è codice. Quando ci sostituisci tipi concreti, il compilatore lo istanzia, inserendo i tuoi tipi dentro e solo dopo controllando il risultato per errori. Istanzia l'intero template, avendo sostituito il tuo tipo ovunque.

Ora, quando il controllo avviene in profondità nell'algoritmo (e il tuo tipo non va bene per sort), il codice si è già srotolato dieci strati più in giù, e l'unico modo per risalire è scaricarlo tutto sul tuo schermo. In sostanza questo è duck typing, solo spostato alla fase di compilazione. Un template semplicemente funziona se le operazioni che usa dentro si sono rivelate valide. Ma anche la modalità di segnalazione errori del duck typing è singolare, scatta non dove hai fatto l'errore, ma da qualche parte in profondità nella libreria di qualcun altro, nel punto d'uso, e il template non ha la minima idea di cosa stia chiamando in quel punto, perché il codice stesso non esiste, c'è solo un controllo va-non-va.

Per trent'anni i template non hanno avuto un meccanismo per esprimere requisiti su un parametro. Semplicemente non potevi scrivere «questo template ha bisogno di un tipo comparabile», perché con il duck typing i requisiti sono sempre impliciti.

Il compilatore fisicamente non poteva dire «hai violato il requisito X», perché nessun X è scritto da nessuna parte, e poteva solo trascinarti per il colletto alla riga 4212 nelle viscere di <algorithm> e mostrare che lì operator< non è definito per il tuo tipo, e affinché tu ci creda, deve esporre lungo la strada l'intero stack di istanziazione.

I concept, la capacità stessa di dare un nome ai requisiti, erano un'idea che risaliva ai «concept» informali di Stepanov della fine degli anni ottanta, venivano preparati per il C++0x — e furono tagliati dal C++11, il design ritenuto troppo complesso (ne ho scritto in uno dei miei articoli). Ma sono arrivati solo con il C++20, ecco perché si chiamano requires (requisiti)

Astrazioni a costo-forse-zero

Per gli ultimi vent'anni circa il C++ si è venduto come un linguaggio di astrazioni a costo zero, ma qualsiasi astrazione non è più gratuita, e std::unique_ptr è più lento di un puntatore grezzo: ha un distruttore non triviale, e un tale tipo non può essere passato in un registro secondo l'Itanium ABI. Solo attraverso lo stack, mentre un puntatore nudo volerebbe in un registro.

E questo non riguarda solo gli smart pointer, ma qualsiasi tipo con un distruttore non triviale: shared_ptr, string, vector vengono spostati per valore attraverso la memoria per lo stesso motivo. La semantica di spostamento (move) non è gratuita, il problema è nel distruttore, quando si usa il move non distruttivo, quindi l'oggetto da cui hai fatto il move esegue comunque il suo distruttore, e un codice scritto a mano equivalente sarebbe più veloce.

Le espressioni regolari nella libreria standard sono considerate una delle peggiori implementazioni esistenti, in alcuni punti decine e centinaia di volte più lente delle alternative, e già il solo includere <regex> aggiunge un buon secondo di compilazione a ogni translation unit se sei fortunato.

unordered_map è lenta perché è lenta e cache-unfriendly, e se ti serve velocità, ti tiri dentro flat_hash_map da Abseil di Google o F14 da Folly di Facebook. Noti lo schema? Un sacco di cose in C++ potrebbero diventare significativamente più veloci, ma non lo faranno. Perché romperebbe la compatibilità ABI, e qui arriviamo al punto principale.

Compatibilità a costo di tutto

Tutte le lamentele qui sopra, dai nomi storti ai container lenti, dallo zoo di static alle copie invisibili, convergono in un unico punto. La gente pensa che il C++ sia un linguaggio che mette la performance al primo posto, ma in realtà mette la retrocompatibilità al primo posto. Fanculo la performance, fanculo l'ergonomia dello sviluppo, fanculo la developer experience, fanculo tutto in generale.

È proprio l'impegno verso la compatibilità che ha trasformato il linguaggio in un mostro, quando non puoi rinominare vector perché si romperebbe un miliardo di righe di codice, e non puoi velocizzare unordered_map perché cambierebbe l'ABI, e non puoi assolutamente fare il move distruttivo, perché il momento è stato perso una decina d'anni fa, e non puoi rompere ciò che esiste. Ogni bruttura del linguaggio è un fiore di pietra di una decisione sbagliata del passato che non si può buttare via, perché ci sta già sopra qualcosa: la lib di qualcuno, un engine, un gioco o una pipeline.

Ma proprio la proprietà per cui il C++ viene sgridato è proprio la proprietà grazie a cui metà del mondo è scritta in esso. La compatibilità è sia la malattia sia il motivo della sopravvivenza, perché il codice che hai scritto vent'anni fa, con qualche danza e un tamburello, compilerà ancora. Una libreria abbandonata nel 2008 fa ancora il link, e per l'industria dei videogiochi, dove il costo di riscrivere si misura in persona-decenni, questo non è un bug, è un muro portante.

E Rust invece?

Qui nei commenti mi hanno chiesto di dire la mia su Rust. Spero si possa fare a meno delle guerre di religione, è semplicemente diverso.

Rust è di un ordine di grandezza migliore per design. Un compilatore standard, una build standard, un package manager standard, niente file header, i migliori messaggi di errore che abbia visto, default sensati, l'assenza di conversioni implicite, UTF-8 come si deve, sum type, e memory safety a tempo di compilazione. Molti problemi che in C++ non sono risolti e probabilmente non lo saranno nei prossimi dieci anni, in Rust erano già risolti ieri.

Ma «migliore per design» non equivale a «dovresti prenderlo». Lo sviluppo di videogiochi è iterazione rapida, caos creativo e «proviamo così», e Rust non te lo dà, e onestamente - nemmeno il C++ te lo dà più, per quanto nuovo e moderno possa essere. Ecco perché i giochi sono passati a script, DSL e programmazione dichiarativa.

E Rust è fondamentalmente sulla correttezza e la disciplina, e questo conflitto è fondamentale, il che ha portato al fatto che c'è un mucchio crescente di giochi che sono partiti su Rust e ne sono usciti. In più l'ecosistema, per quanto lo giri e rigiri, CUDA, engine, tooling, gazillioni di righe di codice già pronto, tutto ciò è C++, e lo sarà per molto tempo ancora.

Il [P/M]eggior linguaggio di programmazione di tutti i tempi?

Il C++ è un linguaggio terribile. Prolisso dove non serve, e silenzioso anche dove non serve. Con uno zoo di tipi, parole chiave bugiarde, copie invisibili, errori illeggibili e inferno di build.

Per scriverci correttamente ti serve una memoria enciclopedica per le eccezioni, e una volta imparate le eccezioni, separatamente per le eccezioni alle eccezioni. Ma il C++ è più vivo di tutti i vivi, e baratterà via altri diversi decenni, perché ha un'inerzia mostruosa ed è uno dei pochi che ha scelto la compatibilità a ogni costo, probabilmente a costo di tutto. E questa scelta l'ha reso simultaneamente insopportabile e insostituibile. Metà del mondo ci gira sopra, e ci girerà per molto tempo, che mi piaccia o no.

Scrivo in questo linguaggio terribile, romanticizzando la sua complessità e dimostrando a me stesso di essere intelligente. E continuerò a scriverci, perché nel mio campo è finora l'unico che fornisce la performance necessaria, essendo diventato un muro portante. E sostituire questo muro portante in una casa "abitata" e "viva" non è semplicemente «riscrivi un paio di stanze in un linguaggio alla moda», è demolire tutta la dannata casa e costruirne una nuova al suo posto, avendo vissuto un paio d'anni fuori in strada in una baracca.

Quindi sì, il linguaggio è terribile, allora forza, apri già il tuo IDE terribile, e continua a scrivere nel peggior linguaggio di programmazione.

← Tutti gli articoli