I calcoli vengono fatti per default a 16 bit, se non c'è una variabile più grande o con decimali. 40*1000 fa 40000, quindi il calcolo va già in overflow. Scrivendo 1000ul, invece, comincia subito facendo i calcoli a 32bit senza segno (solo valori positivi).
Quando il compilatore incontra un numero, lo rappresenta con il tipo più piccolo possibile, iniziando con int. Qui, 1000 e 40 possono essere rappresentati come int, quindi è quello che il compilatore utilizzerà. E l'operazione 40 * 1000 sarà eseguita come int.
Hai ragione, su Arduino a 8 bit, un int ha solo 16 bit e il valore massimo rappresentabile è 32767. Quando il compilatore esegue il calcolo 40 * 1000, supera il limite massimo (overflow), quindi il risultato non è 40000.
Se aggiungi il suffisso "l" (long) o "ul" (unsigned long) al numero, stai chiedendo al compilatore di utilizzare un tipo diverso rispetto a quello predefinito, e il compilatore promuoverà l'altro valore nell'operazione per corrispondere al tipo più grande. Quindi, se fai 1000ul * 40, il 1000 sarà rappresentato come unsigned long (32 bit) e il 40 sarà promosso a unsigned long (anziché int), e l'operazione sarà eseguita come unsigned long ➜ nessun problema nella rappresentazione di 40000.
Se si inserisce direttamente il valore 40000, il compilatore riconoscerà che non può essere contenuto in un int e userà un long, quindi non ci sono problemi neanche in questo caso.
Il quadro è completo se ci aggiungi che il compilatore riconosce di potere svolgere alcune operazioni durante la compilazione (compile-time). Ed altre che devono per forza essere eseguite a (run-time) dalla cpu. Nel caso specifico nessuna delle operazioni viene eseguita dalla cpu.
E questo come lo spiegate?
long x = 0; -> Contenitore per numeri interi nel range numerico, da -2147483648 a 2147483647. Occupa 4 byte, è a 32 bit
Qui non dovrebbe esserci il problema dell'intero, però escono due risultati diversi
Come spiegato in precedenza, il compilatore calcolerà prima 40*1000 e verrà effettuato un overflow. Successivamente memorizzerà il valore in overflow in x, che ha molto spazio, ma è troppo tardi...
➜ Il tipo della variabile in cui si memorizza il risultato non influisce sul modo in cui la parte destra dell'assegnazione viene calcolata.
È così che funziona il C++.
Il compilatore valuta la parte destra secondo le sue regole.
Il compilatore ottiene un risultato.
Questo risultato ha un tipo.
Il compilatore verifica se il tipo corrisponde al tipo della variabile di destinazione.
Se sì, il risultato viene assegnato alla variabile.
Se no, il compilatore verifica se esiste una conversione (cast) che consente di trasformare il tipo del risultato nel tipo della variabile di destinazione (Se non è possibile, allora è un errore.)
Non li fa a modo suo li fa a modo tuo o meglio come tu gli hai ordinato di fare. Cioè potresti volere ottenere quel valore e astutamente sfrutti l'overflow. Mentre li farà "a modo nostro" se almeno uno dei 2 operandi è grande abbastanza per contenere il risultato senza cadere in overflow.
Quindi ad esempio dovrebbe bastare:
long x = 40*1000U/3;
Se 40 diventa 66 non basta più la u ma almeno serve la l.
Prova tutte queste cose direttamente su wokwi così eviti di scrivere in flash. Ovviamente funziona anche così:
long x = 40U*1000/3;
Poiché è sufficiente che almeno uno dei due operandi sia grande abbastanza da contenere il risultato.
PS: esiste anche ull che è l'abbreviazione di unsigned long long 64-bit
Se si dispone solo di numeri interi, sì. Sono le regole a cui ho fatto riferimento nella mia prima risposta: Integer literal - cppreference.com
Se stessi facendo 40.0 * 1000 / 3
Allora 40 viene considerato come un numero decimale e quindi l’operazione viene eseguita secondo le regole e la precisione dei numeri in virgola mobile.
Però non è più garantito che un risultato che dovrebbe essere intero venga realmente intero!
Per esempio, x=100.0/4 potrebbe fare 24.999999! Se usi un float, non puoi fare cose come if(x==25), perché con x=24.999999 è falso.
D'altra parte, se facesse 24.999999, int x = 100.0/4 metterebbe in x 24 anziché 25!
Nei libri che ho letto io c'è scritto che una variabile:
Si dichiara
Si dichiara e inizializza.
A seguire viene specificato che una dichiarazione ha come conseguenza l'allocazione di memoria ram.
Il termine "definire" viene invece usato per le classi e le macro del preprocessore e mai in relazione ad una variabile, cioè "definire una variabile" non c'è in alcun libro sul C/C++ che io ho letto.
Altra cosa da sperimentare è di sostituire alcune "costanti letterali" con le variabili dichiarate con i vari tipi di dato numerico, quali uint, int, uint8_t, int8_t ecc. Perché così puoi forzare il compilatore a generare codice che effettua il calcolo durante l'esecuzione dello sketch.
PS: Immagino le sensazioni che provi e che ho provato io all'inizio e che credo abbiano provato tutti. Poi però a distanza di molti anni vai scoprendo che le cose internamente sono ancora più complicate di così e quando inizi a studiare il linguaggio assembly il cervello si rifiuta di constatare questa complessità..
In quelli che ho letto io c'è scritto "invece" che la parola chiave "extern" serve a "dichiarare" una variabile "definita" ed allocata in un'altra unità di compilazione
Quindi, in un'operazione, se il calcolo è più grande di quello che può contenere una variabile int, devo aggiungere UL vicino a uno dei due operandi, giusto?
Diciamo di si, ma potrebbe anche bastare la sola U se il risultato della operazione tra due operandi non supera 65535. Ho appunto fatto l'esempio con 66 x 1000 dove U non è sufficiente e occorre un tipo di dato grande 32-bit.
Però è fondamentale comprendere la differenza tra compile-time e run-time. Ad esempio i tuoi esempi con le costanti non le trovi nel firmware generato dal compilatore, al loro posto trovi solo il risultato della operazione tra costanti. Il calcolo tra costanti viene eseguito dalla cpu del tuo computer quando viene avviata la compilazione.
Il risultato calcolato dalla cpu del tuo PC finisce nel firmware.
float a = 10;
float result = 1 / a;
Serial.println(result);
Stampa correttamente 0.10.
Mentre:
Serial.println(1 / 10);
Stampa 0. Per fargli stampare 0.10 uno dei due operandi deve essere in virgola mobile, cioè ad esempio 1.0 oppure 10.0.
Se il compilatore riconosce che in fase di compilazione può effettuare dei calcoli li eseguirà in questa fase e sarà la CPU del tuo PC ad eseguirli (compile-time). Quasi certamente (quasi perché NON ho appena verificato) il calcolo di 1 / a è svolto a compile-time.
Esatto, per il linker (cioè il programma che interviene nella fase finale di building) una variabile è sempre definita e chi l'ha definita è il compilatore, questo (il compilatore) riconosce il comando "dichiarazione" e definisce la variabile perché gli ha allocato spazio.
Detto in altri termini il compilatore definisce una dichiarazione.
Extern in particolare viene risolto da linker e possiamo benissimo dire che la dichiarazione seguente:
extern LiquidCrystal lcd;
è una modo per comunicare al linker che il simbolo lcd di tipo LiquidCrystal è stato definito dal compilatore in un diversa compile-unit. Come dire, ok il linker tu il simbolo in questa compile unit non lo trovi ma ti posso garantire che esiste. Allora il linker resta in fiducia in attesa di trovare il simbolo lcd già definito più avanti. Ovviamente se prima di terminare il processo di collegamento non trova il simbolo definito emette un errore descritto nell'articolo seguente:
PS: si lo so posso passare per puntiglioso, preciso (gergo Palermitano pillicusu), ma non faccio altro che riportare ciò che ho imparato sui libri.