Che domanda provocatoria!
Anche la scansione superficiale delle risposte e dei commenti in questo thread rivelerà come emotiva la tua query apparentemente semplice e diretta.
Non dovrebbe essere sorprendente.
Indubbiamente, i malintesi sul concetto e sull'uso dei puntatori rappresentano una causa predominante di gravi errori nella programmazione in generale.
Il riconoscimento di questa realtà è prontamente evidente nell'ubiquità delle lingue progettate specificamente per affrontare e preferibilmente per evitare le sfide che i puntatori introducono del tutto. Pensa al C ++ e ad altri derivati di C, Java e delle sue relazioni, Python e altri script - semplicemente come quelli più importanti e prevalenti, e più o meno ordinati in ordine di gravità del problema.
Sviluppare una comprensione più profonda dei principi sottostanti, pertanto, deve essere pertinente per ogni individuo che aspira all'eccellenza nella programmazione, specialmente a livello di sistemi .
Immagino che questo sia esattamente ciò che il tuo insegnante intende dimostrare.
E la natura di C lo rende un veicolo conveniente per questa esplorazione. Meno chiaramente dell'assemblaggio - sebbene forse più facilmente comprensibile - e ancora molto più esplicitamente dei linguaggi basati sull'astrazione più profonda dell'ambiente di esecuzione.
Progettato per facilitare la traduzione deterministica dell'intento del programmatore in istruzioni che le macchine possono comprendere, C è un linguaggio a livello di sistema . Sebbene classificato come di alto livello, appartiene davvero a una categoria "media"; ma poiché non esiste nulla del genere, la designazione di "sistema" deve essere sufficiente.
Questa caratteristica è in gran parte responsabile di renderla una lingua di scelta per i driver di dispositivo , il codice del sistema operativo e le implementazioni integrate . Inoltre, un'alternativa meritatamente favorita in applicazioni in cui l'efficienza ottimale è fondamentale; dove ciò significa la differenza tra sopravvivenza ed estinzione, e quindi è una necessità al contrario di un lusso. In tali casi, la convenienza attraente della portabilità perde tutto il suo fascino e optare per le prestazioni di scarsa lucentezza del minimo comune denominatore diventa un'opzione impensabilmente dannosa .
Ciò che rende C - e alcuni dei suoi derivati - abbastanza speciale, è che consente ai suoi utenti il controllo completo - quando questo è ciò che desiderano - senza imporre loro le relative responsabilità quando non lo fanno. Tuttavia, non offre mai più del più sottile degli isolanti dalla macchina , pertanto un uso corretto richiede una comprensione approfondita del concetto di puntatori .
In sostanza, la risposta alla tua domanda è sublimemente semplice e soddisfacente in modo dolce - a conferma dei tuoi sospetti. Purché , tuttavia, si attribuisca il significato necessario a ogni concetto di questa affermazione:
- Gli atti di esame, confronto e manipolazione dei puntatori sono sempre e necessariamente validi, mentre le conclusioni derivate dal risultato dipendono dalla validità dei valori contenuti, e quindi non è necessario.
Il primo è invariabilmente sicuro e potenzialmente adeguato , mentre il secondo può essere sempre corretto solo quando è stato stabilito come sicuro . Sorprendentemente - per alcuni - quindi stabilire la validità di quest'ultimo dipende e richiede il primo.
Naturalmente, parte della confusione deriva dall'effetto della ricorsione intrinsecamente presente nel principio di un indicatore - e dalle sfide poste dalla differenziazione del contenuto dall'indirizzo.
Hai ipotizzato abbastanza correttamente ,
Sono stato indotto a pensare che qualsiasi puntatore può essere confrontato con qualsiasi altro puntatore, indipendentemente da dove puntino individualmente. Inoltre, penso che l'aritmetica del puntatore tra due puntatori vada bene, indipendentemente da dove puntino individualmente perché l'aritmetica sta semplicemente usando gli indirizzi di memoria archiviati dai puntatori.
E diversi collaboratori hanno affermato: i puntatori sono solo numeri. A volte qualcosa di più vicino ai numeri complessi , ma ancora non più dei numeri.
L'acrimonia divertente in cui questa tesi è stata ricevuta qui rivela più sulla natura umana che sulla programmazione, ma rimane degno di nota ed elaborazione. Forse lo faremo più tardi ...
Come un commento inizia a suggerire; tutta questa confusione e costernazione deriva dalla necessità di discernere ciò che è valido da ciò che è sicuro , ma questa è una semplificazione eccessiva. Dobbiamo anche distinguere ciò che è funzionale e ciò che è affidabile , ciò che è pratico e ciò che potrebbe essere corretto , e ancora di più: ciò che è appropriato in una particolare circostanza da ciò che può essere proprio in un senso più generale . Per non parlare di; la differenza tra conformità e proprietà .
A questo scopo, abbiamo prima bisogno di apprezzare esattamente ciò che un puntatore è .
- Hai dimostrato una presa salda sul concetto e, come alcuni altri, potresti trovare queste illustrazioni pignolosamente semplicistiche, ma il livello di confusione evidente qui richiede tale semplicità di chiarimento.
Come molti hanno sottolineato: il termine puntatore è semplicemente un nome speciale per ciò che è semplicemente un indice , e quindi niente di più di qualsiasi altro numero .
Ciò dovrebbe già essere evidente in considerazione del fatto che tutti i computer tradizionali contemporanei sono macchine binarie che necessariamente funzionano esclusivamente con e sui numeri . Il calcolo quantistico può cambiarlo, ma è altamente improbabile e non ha raggiunto la maggiore età.
Tecnicamente, come hai notato, i puntatori sono indirizzi più accurati ; un'ovvia intuizione che introduce naturalmente l'analogia gratificante della correlazione con gli "indirizzi" delle case, o trame su una strada.
In un modello di memoria piatta : l'intera memoria del sistema è organizzata in un'unica sequenza lineare: tutte le case della città si trovano sulla stessa strada e ogni casa è identificata in modo univoco dal solo numero. Deliziosamente semplice.
In schemi segmentati : un'organizzazione gerarchica di strade numerate viene introdotta sopra quella di case numerate in modo da richiedere indirizzi compositi.
- Alcune implementazioni sono ancora più contorte e la totalità di "strade" distinte non deve necessariamente riassumere in una sequenza contigua, ma nulla di tutto ciò cambia nulla sul sottostante.
- Siamo necessariamente in grado di scomporre ogni collegamento gerarchico in un'organizzazione piatta. Più complessa è l'organizzazione, più cerchi dovremo saltare per farlo, ma deve essere possibile. In effetti, questo vale anche per la "modalità reale" su x86.
- Altrimenti la mappatura dei collegamenti alle posizioni non sarebbe biiettiva , in quanto l'esecuzione affidabile - a livello di sistema - richiede che DEVE esserlo.
- più indirizzi non devono essere associati a posizioni di memoria singolari e
- gli indirizzi singolari non devono mai essere associati a più posizioni di memoria.
Portandoci all'ulteriore svolta che trasforma l'enigma in un groviglio così affascinante e complicato . Sopra, era opportuno suggerire che i puntatori sono indirizzi, per ragioni di semplicità e chiarezza. Certo, questo non è corretto. Un puntatore non è un indirizzo; un puntatore è un riferimento a un indirizzo , contiene un indirizzo . Come la busta sfoggia un riferimento alla casa. Contemplare questo può portare a intravedere cosa si intendesse con il suggerimento di ricorsione contenuto nel concetto. Ancora; abbiamo solo così tante parole, e parlando del indirizzi dei riferimenti agli indirizzie così, blocca presto la maggior parte dei cervelli coneccezione del codice operativo non valida . E per la maggior parte, l'intento è prontamente raccolto dal contesto, quindi torniamo in strada.
I lavoratori delle poste in questa nostra città immaginaria sono molto simili a quelli che troviamo nel mondo "reale". È probabile che nessuno subisca un ictus quando parli o chiedi di un indirizzo non valido , ma ogni ultimo si opporrà quando gli chiedi di agire su tali informazioni.
Supponiamo che ci siano solo 20 case sulla nostra strada singolare. Fingi inoltre che un'anima fuorviante o dislessica abbia indirizzato una lettera, molto importante, al numero 71. Ora, possiamo chiedere al nostro corriere Frank se esiste un tale indirizzo e riferirà semplicemente e con calma: no . Possiamo anche aspettarci che per valutare la distanza al di fuori della strada questa posizione si troverebbe se ha fatto esistono: circa 2,5 volte oltre la fine. Niente di tutto ciò gli causerà alcuna esasperazione. Tuttavia, se dovessimo chiedergli di consegnare questa lettera o di ritirare un oggetto da quel luogo, è probabile che sia piuttosto sincero riguardo al suo dispiacere e al rifiuto di conformarsi.
I puntatori sono solo indirizzi e gli indirizzi sono solo numeri.
Verificare l'output di quanto segue:
void foo( void *p ) {
printf(“%p\t%zu\t%d\n”, p, (size_t)p, p == (size_t)p);
}
Chiamalo su tutti i puntatori che desideri, validi o meno. Si prega di non inviare i vostri risultati se non riesce sulla vostra piattaforma, o il vostro (contemporaneo) compilatore si lamenta.
Ora, poiché i puntatori sono semplicemente numeri, è inevitabilmente valido confrontarli. In un certo senso, questo è esattamente ciò che il tuo insegnante sta dimostrando. Tutte le seguenti affermazioni sono perfettamente valide e appropriate! - C, e quando compilato verrà eseguito senza problemi , anche se nessuno dei due puntatori deve essere inizializzato e i valori in essi contenuti potrebbero pertanto non essere definiti :
- Stiamo solo calcolando
result
esplicitamente per motivi di chiarezza e stampandolo per forzare il compilatore a calcolare quello che altrimenti sarebbe codice ridondante e morto.
void foo( size_t *a, size_t *b ) {
size_t result;
result = (size_t)a;
printf(“%zu\n”, result);
result = a == b;
printf(“%zu\n”, result);
result = a < b;
printf(“%zu\n”, result);
result = a - b;
printf(“%zu\n”, result);
}
Naturalmente, il programma è mal formato quando a o b non è definito (leggi: non correttamente inizializzato ) al momento del test, ma questo è assolutamente irrilevante per questa parte della nostra discussione. Questi frammenti, come anche le seguenti affermazioni, sono garantiti - dallo "standard" - per compilare ed eseguire in modo impeccabile, nonostante la validità IN di qualsiasi puntatore coinvolto.
I problemi sorgono solo quando un puntatore non valido è dereferenziato . Quando chiediamo a Frank di ritirare o consegnare all'indirizzo non valido e inesistente.
Dato qualsiasi puntatore arbitrario:
int *p;
Mentre questa affermazione deve compilare ed eseguire:
printf(“%p”, p);
... come deve questo:
size_t foo( int *p ) { return (size_t)p; }
... i due seguenti, in netto contrasto, si compileranno ancora prontamente, ma falliranno nell'esecuzione a meno che il puntatore non sia valido - con il quale qui intendiamo semplicemente che fa riferimento a un indirizzo a cui è stato concesso l'accesso alla presente applicazione :
printf(“%p”, *p);
size_t foo( int *p ) { return *p; }
Quanto è sottile il cambiamento? La distinzione sta nella differenza tra il valore del puntatore - che è l'indirizzo e il valore dei contenuti: della casa in quel numero. Nessun problema si pone fino a quando il puntatore non viene referenziato ; fino a quando si tenta di accedere all'indirizzo a cui si collega. Nel tentativo di consegnare o ritirare il pacco oltre il tratto di strada ...
Per estensione, lo stesso principio si applica necessariamente ad esempi più complessi, inclusa la necessità di cui sopra per stabilire la validità richiesta:
int* validate( int *p, int *head, int *tail ) {
return p >= head && p <= tail ? p : NULL;
}
Il confronto relazionale e l'aritmetica offrono un'utilità identica al test dell'equivalenza e sono equivalentemente validi - in linea di principio. Tuttavia , ciò che i risultati di tale calcolo sarebbe significare , è una questione del tutto diversa - e precisamente il problema affrontato dalle quotazioni hai incluso.
In C, un array è un buffer contiguo, una serie lineare ininterrotta di posizioni di memoria. Confronto e aritmetica applicati a puntatori che fanno riferimento a posizioni all'interno di un tale singolare serie così sono naturalmente e ovviamente significative in relazione sia l'una con l'altra, sia a questo "array" (che è semplicemente identificato dalla base). Lo stesso vale per ogni blocco allocato tramite malloc
, o sbrk
. Poiché queste relazioni sono implicite , il compilatore è in grado di stabilire relazioni valide tra loro e quindi può essere sicuro che i calcoli forniranno le risposte previste.
L'esecuzione di una ginnastica simile su puntatori che fanno riferimento a blocchi o matrici distinti non offrono tale utilità intrinseca e apparente . Tanto più che qualsiasi relazione esistente in un momento può essere invalidata da una riallocazione che segue, in cui è altamente probabile che cambi, può persino essere invertita. In tali casi il compilatore non è in grado di ottenere le informazioni necessarie per stabilire la fiducia che aveva nella situazione precedente.
È , tuttavia, come il programmatore, potrebbe avere una tale conoscenza! E in alcuni casi sono obbligati a sfruttarlo.
Vi sono quindi circostanze in cui ANCHE QUESTO è interamente VALIDO e perfettamente CORRETTO.
In effetti, questo è esattamente ciò che malloc
deve fare internamente quando arriva il momento di provare a fondere i blocchi recuperati, nella stragrande maggioranza delle architetture. Lo stesso vale per l'allocatore del sistema operativo, come quello dietro sbrk
; se più ovviamente , frequentemente , su entità più disparate , di più criticamente - e rilevanti anche su piattaforme dove ciòmalloc
potrebbe non essere. E quanti di questi non sono scritti in C?
La validità, la sicurezza e il successo di un'azione sono inevitabilmente la conseguenza del livello di comprensione su cui è premessa e applicata.
Nelle citazioni che hai offerto, Kernighan e Ritchie stanno affrontando un problema strettamente correlato, ma comunque separato. Stanno definendo i limiti del linguaggio e spiegando come è possibile sfruttare le capacità del compilatore per proteggerti rilevando almeno costrutti potenzialmente errati. Stanno descrivendo le lunghezze in cui il meccanismo è in grado - è progettato - di andare per aiutarvi nel vostro compito di programmazione. Il compilatore è il tuo servitore, tu sei il padrone. Un maestro saggio, tuttavia, è intimamente familiare con le capacità dei suoi vari servi.
In questo contesto, un comportamento indefinito serve a indicare un potenziale pericolo e la possibilità di danno; non implicare un destino imminente, irreversibile, o la fine del mondo come la conosciamo. Significa semplicemente che noi - "intendendo il compilatore" - non siamo in grado di fare congetture su ciò che questa cosa potrebbe essere o rappresentare e per questo motivo scegliamo di lavarci le mani. Non saremo ritenuti responsabili per eventuali disavventure che potrebbero derivare dall'uso o dal cattivo uso di questa struttura .
In effetti, dice semplicemente: "Oltre questo punto, cowboy : sei da solo ..."
Il tuo professore sta cercando di dimostrarti le sfumature più sottili .
Notate quale grande cura hanno preso nell'elaborare il loro esempio; e quanto è ancora fragile . Prendendo l'indirizzo di a
, in
p[0].p0 = &a;
il compilatore è costretto ad allocare l'archiviazione effettiva per la variabile, piuttosto che metterlo in un registro. Essendo una variabile automatica, tuttavia, il programmatore non ha alcun controllo su dove viene assegnato e quindi incapace di formulare congetture valide su ciò che la seguirebbe. Ecco perché a
deve essere impostato uguale a zero affinché il codice funzioni come previsto.
Semplicemente cambiando questa linea:
char a = 0;
a questo:
char a = 1; // or ANY other value than 0
fa sì che il comportamento del programma diventi indefinito . Come minimo, la prima risposta sarà ora 1; ma il problema è molto più sinistro.
Ora il codice invita al disastro.
Sebbene sia ancora perfettamente valido e persino conforme allo standard , ora è mal formato e sebbene sicuro di essere compilato, potrebbe non riuscire nell'esecuzione per vari motivi. Per ora ci sono molti problemi - nessuno dei quali il compilatore è in grado di riconoscere.
strcpy
inizierà all'indirizzo di a
e proseguirà oltre per consumare - e trasferire - byte dopo byte, fino a quando non incontra un valore nullo.
Il p1
puntatore è stato inizializzato su un blocco di esattamente 10 byte.
Se a
capita di trovarsi alla fine di un blocco e il processo non ha accesso a ciò che segue, la lettura successiva - di p0 [1] - genererà un segfault. Questo scenario è improbabile sull'architettura x86, ma possibile.
Se l'area oltre l'indirizzo di a
è accessibile, non si verificherà alcun errore di lettura, ma il programma non viene comunque salvato dalla sfortuna.
Se un byte zero capita che si verifichi entro dieci a partire dall'indirizzo di a
, si può ancora sopravvivere, perché allora strcpy
si ferma e almeno noi non subirà una violazione di scrittura.
Se è non violata per la lettura male, ma nessun byte zero si verifica in questo arco di 10, strcpy
continuerà e tentare di scrivere oltre il blocco allocato da malloc
.
Se quest'area non è di proprietà del processo, il segfault dovrebbe essere immediatamente attivato.
La situazione ancora più disastrosa - e sottile - si presenta quando il blocco seguente è di proprietà del processo, poiché quindi l'errore non può essere rilevato, nessun segnale può essere generato e quindi può "apparire" ancora "funzionante" , mentre in realtà sovrascriverà altri dati, le strutture di gestione dell'allocatore o persino il codice (in determinati ambienti operativi).
Questo è il motivo per cui i bug relativi ai puntatori possono essere così difficili da rintracciare . Immagina che queste righe siano sepolte in profondità in migliaia di righe di codice intrinsecamente correlato, che qualcun altro ha scritto e che sei diretto a scavare.
Tuttavia , il programma deve ancora essere compilato, poiché rimane perfettamente valido e conforme agli standard C.
Questo tipo di errori, nessuno standard e nessun compilatore può proteggere gli incauti. Immagino che sia esattamente ciò che intendono insegnarti.
Le persone paranoiche cercano costantemente di cambiare la natura di C per smaltire queste possibilità problematiche e quindi salvarci da noi stessi; ma questo è disonesto . Questa è la responsabilità che siamo tenuti ad accettare quando scegliamo di perseguire il potere e ottenere la libertà che ci offre un controllo più diretto e completo della macchina. I promotori e gli inseguitori della perfezione nell'esecuzione non accetteranno mai niente di meno.
La portabilità e la generalità che rappresenta è una considerazione sostanzialmente separata e tutto ciò che lo standard cerca di affrontare:
Questo documento specifica il modulo e stabilisce l'interpretazione dei programmi espressa nel linguaggio di programmazione C. Its scopo è promuovere la portabilità , l'affidabilità, la manutenibilità e l'esecuzione efficiente dei programmi in linguaggio C su una varietà di sistemi di elaborazione .
Ecco perché è perfettamente corretto tenerlo distinto dalla definizione e specifiche tecniche della lingua stessa. Contrariamente a quanto molti credono che la generalità sia antitetica a eccezionale ed esemplare .
Concludere:
- Esaminare e manipolare gli stessi puntatori è invariabilmente valido e spesso fruttuoso . L'interpretazione dei risultati, può o non può essere significativa, ma la calamità non è mai invitata fino a quando il puntatore non viene rimosso ; fino a quando non viene effettuato un tentativo di accesso all'indirizzo collegato.
Se ciò non fosse vero, programmare come lo conosciamo - e lo adoriamo - non sarebbe stato possibile.
C
con ciò che è al sicuro inC
. Il confronto tra due puntatori con lo stesso tipo può sempre essere eseguito (verificando l'uguaglianza, ad esempio), tuttavia, utilizzando l'aritmetica e il confronto dei puntatori>
ed<
è sicuro solo se utilizzato all'interno di un determinato array (o blocco di memoria).