¿Cuáles son las barreras para comprender los indicadores y qué se puede hacer para superarlas? [cerrado]
¿Por qué los punteros son un factor de confusión tan importante para muchos estudiantes nuevos, e incluso antiguos, de nivel universitario en C o C++? ¿Existe alguna herramienta o proceso de pensamiento que le haya ayudado a comprender cómo funcionan los punteros en el nivel de variable, función y más allá?
¿Cuáles son algunas buenas prácticas que se pueden hacer para llevar a alguien al nivel de "Ah, ja, ya lo tengo", sin que quede estancado en el concepto general? Básicamente, escenarios tipo simulacro.
Punteros es un concepto que para muchos puede resultar confuso al principio, en particular cuando se trata de copiar valores de puntero y seguir haciendo referencia al mismo bloque de memoria.
Descubrí que la mejor analogía es considerar el puntero como una hoja de papel con la dirección de una casa y el bloque de memoria al que hace referencia como la casa real. De este modo se pueden explicar fácilmente todo tipo de operaciones.
Agregué algo de código Delphi a continuación y algunos comentarios cuando corresponda. Elegí Delphi porque mi otro lenguaje de programación principal, C#, no presenta pérdidas de memoria de la misma manera.
Si sólo desea aprender el concepto de alto nivel de los punteros, debe ignorar las partes denominadas "Diseño de la memoria" en la explicación siguiente. Su objetivo es dar ejemplos de cómo podría verse la memoria después de las operaciones, pero son de naturaleza más baja. Sin embargo, para explicar con precisión cómo funcionan realmente las saturaciones de búfer, era importante agregar estos diagramas.
Descargo de responsabilidad: para todos los efectos, esta explicación y los diseños de memoria de ejemplo están enormemente simplificados. Hay más gastos generales y muchos más detalles que necesitaría saber si necesita manejar la memoria en un nivel bajo. Sin embargo, a efectos de explicar la memoria y los punteros, es lo suficientemente preciso.
Supongamos que la clase THouse utilizada a continuación tiene este aspecto:
type
THouse = class
private
FName : array[0..9] of Char;
public
constructor Create(name: PChar);
end;
Cuando inicializa el objeto de la casa, el nombre dado al constructor se copia en el campo privado FName. Hay una razón por la que se define como una matriz de tamaño fijo.
En la memoria, habrá algunos gastos generales asociados con la asignación de la casa. Lo ilustraré a continuación de esta manera:
---[ttttNNNNNNNNNN]--- ^ ^ | | | +- la matriz FName | +- gastos generales
El área "tttt" está sobrecargada; normalmente habrá más para varios tipos de tiempos de ejecución y lenguajes, como 8 o 12 bytes. Es imperativo que cualquier valor almacenado en esta área nunca sea modificado por nada que no sea el asignador de memoria o las rutinas centrales del sistema, o corre el riesgo de bloquear el programa.
Asignar memoria
Consiga que un empresario construya su casa y le dé la dirección de la casa. A diferencia del mundo real, a la asignación de memoria no se le puede decir dónde asignarla, sino que encontrará un lugar adecuado con suficiente espacio e informará la dirección a la memoria asignada.
Es decir, el emprendedor elegirá el lugar.
THouse.Create('My house');
Diseño de memoria:
---[ttttNNNNNNNNNN]--- 1234Mi casa
Mantener una variable con la dirección.
Escriba la dirección de su nueva casa en una hoja de papel. Este documento le servirá como referencia para su casa. Sin este trozo de papel, estás perdido y no puedes encontrar la casa, a menos que ya estés en ella.
var
h: THouse;
begin
h := THouse.Create('My house');
...
Diseño de memoria:
h v ---[ttttNNNNNNNNNN]--- 1234Mi casa
Copiar valor del puntero
Simplemente escriba la dirección en una hoja de papel nueva. Ahora tienes dos hojas de papel que te llevarán a la misma casa, no a dos casas separadas. Cualquier intento de seguir la dirección de un documento y reorganizar los muebles de esa casa hará que parezca que la otra casa ha sido modificada de la misma manera, a menos que puedas detectar explícitamente que en realidad es solo una casa.
Nota Este suele ser el concepto que tengo más problemas para explicarle a la gente: dos punteros no significan dos objetos o bloques de memoria.
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := h1; // copies the address, not the house
...
h1 v ---[ttttNNNNNNNNNN]--- 1234Mi casa ^ h2
Liberando la memoria
Derribar la casa. Luego podrás reutilizar el papel para una nueva dirección si así lo deseas, o borrarlo para olvidar la dirección de la casa que ya no existe.
var
h: THouse;
begin
h := THouse.Create('My house');
...
h.Free;
h := nil;
Aquí primero construyo la casa y consigo su dirección. Luego le hago algo a la casa (lo uso, el... código, lo dejo como ejercicio para el lector), y luego la libero. Por último borro la dirección de mi variable.
Diseño de memoria:
h <--+ v +- antes gratis ---[ttttNNNNNNNNNN]--- | 1234Mi casa <--+ h (ahora apunta a ninguna parte) <--+ +- después gratis ---------------------- | (nota, es posible que la memoria aún xx34Mi casa <--+ contiene algunos datos)
Punteros colgantes
Le dices a tu empresario que destruya la casa, pero te olvidas de borrar la dirección de tu papel. Cuando más tarde miras el papel, olvidas que la casa ya no está allí y vas a visitarla, con resultados fallidos (ver también la parte sobre una referencia no válida más abajo).
var
h: THouse;
begin
h := THouse.Create('My house');
...
h.Free;
... // forgot to clear h here
h.OpenFrontDoor; // will most likely fail
Usarlo h
después de la llamada .Free
podría funcionar, pero es pura suerte. Lo más probable es que falle, en el lugar del cliente, en medio de una operación crítica.
h <--+ v +- antes gratis ---[ttttNNNNNNNNNN]--- | 1234Mi casa <--+ h <--+ v +- después gratis ---------------------- | xx34Mi casa <--+
Como puede ver, h todavía apunta a los restos de los datos en la memoria, pero como es posible que no estén completos, su uso como antes podría fallar.
Pérdida de memoria
Pierdes el papel y no encuentras la casa. Sin embargo, la casa todavía está en algún lugar, y cuando más adelante quieras construir una nueva casa, no podrás reutilizar ese lugar.
var
h: THouse;
begin
h := THouse.Create('My house');
h := THouse.Create('My house'); // uh-oh, what happened to our first house?
...
h.Free;
h := nil;
Aquí sobrescribimos el contenido de la h
variable con la dirección de una casa nueva, pero la antigua todavía está en pie... en alguna parte. Después de este código, no hay forma de llegar a esa casa y quedará en pie. En otras palabras, la memoria asignada permanecerá asignada hasta que se cierre la aplicación, momento en el que el sistema operativo la eliminará.
Diseño de la memoria después de la primera asignación:
h v ---[ttttNNNNNNNNNN]--- 1234Mi casa
Diseño de la memoria después de la segunda asignación:
h v ---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN] 1234Mi casa 5678Mi casa
Una forma más común de obtener este método es simplemente olvidarse de liberar algo, en lugar de sobrescribirlo como se indicó anteriormente. En términos de Delphi, esto ocurrirá con el siguiente método:
procedure OpenTheFrontDoorOfANewHouse;
var
h: THouse;
begin
h := THouse.Create('My house');
h.OpenFrontDoor;
// uh-oh, no .Free here, where does the address go?
end;
Después de que se haya ejecutado este método, no hay ningún lugar en nuestras variables donde exista la dirección de la casa, pero la casa todavía está ahí.
Diseño de memoria:
h <--+ v +- antes de perder el puntero ---[ttttNNNNNNNNNN]--- | 1234Mi casa <--+ h (ahora apunta a ninguna parte) <--+ +- después de perder el puntero ---[ttttNNNNNNNNNN]--- | 1234Mi casa <--+
Como puede ver, los datos antiguos se dejan intactos en la memoria y el asignador de memoria no los reutilizará. El asignador realiza un seguimiento de qué áreas de memoria se han utilizado y no las reutilizará a menos que las libere.
Liberar la memoria pero mantener una referencia (ahora no válida)
Derriba la casa, borra uno de los trozos de papel pero también tienes otro trozo de papel con la dirección antigua, cuando vayas a la dirección no encontrarás una casa, pero puede que encuentres algo que se parezca a las ruinas. de uno.
Quizás incluso encuentre una casa, pero no es la casa cuya dirección le dieron originalmente y, por lo tanto, cualquier intento de usarla como si le perteneciera podría fracasar terriblemente.
A veces, incluso puede encontrar que una dirección vecina tiene una casa bastante grande que ocupa tres direcciones (calle principal 1-3), y su dirección va al centro de la casa. Cualquier intento de tratar esa parte de la casa grande de 3 direcciones como una sola casa pequeña también podría fracasar terriblemente.
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := h1; // copies the address, not the house
...
h1.Free;
h1 := nil;
h2.OpenFrontDoor; // uh-oh, what happened to our house?
Aquí la casa fue derribada, según la referencia en h1
, y aunque h1
también fue limpiada, h2
todavía conserva la dirección antigua y desactualizada. El acceso a la casa que ya no está en pie puede funcionar o no.
Esta es una variación del puntero colgante de arriba. Vea su diseño de memoria.
Desbordamiento del búfer
Mueves más cosas a la casa de las que caben, y se derraman en la casa o el jardín de los vecinos. Cuando más tarde el dueño de esa casa vecina regrese a casa, encontrará todo tipo de cosas que considerará suyas.
Ésta es la razón por la que elegí una matriz de tamaño fijo. Para preparar el escenario, supongamos que la segunda casa que asignamos, por alguna razón, se colocará antes que la primera en la memoria. Es decir, la segunda casa tendrá una dirección más baja que la primera. Además, están ubicados uno al lado del otro.
Así, este código:
var
h1, h2: THouse;
begin
h1 := THouse.Create('My house');
h2 := THouse.Create('My other house somewhere');
^-----------------------^
longer than 10 characters
0123456789 <-- 10 characters
Diseño de la memoria después de la primera asignación:
h1 v -----------------------[ttttNNNNNNNNNN] 5678Mi casa
Diseño de la memoria después de la segunda asignación:
h2 h1 vv ---[ttttNNNNNNNNNN]----[ttttNNNNNNNNNN] 1234Mi otra casa en algún lugar ^---+--^ | +- sobrescrito
La parte que con mayor frecuencia causará fallas es cuando sobrescribe partes importantes de los datos que almacenó y que realmente no deberían cambiarse aleatoriamente. Por ejemplo, puede que no sea un problema que se hayan cambiado partes del nombre de la casa h1, en términos de bloquear el programa, pero sobrescribir la sobrecarga del objeto probablemente fallará cuando intente utilizar el objeto roto, al igual que sobrescribir enlaces que se almacenan en otros objetos del objeto.
Listas enlazadas
Cuando sigues una dirección en una hoja de papel, llegas a una casa, y en esa casa hay otra hoja de papel con una nueva dirección, para la siguiente casa de la cadena, y así sucesivamente.
var
h1, h2: THouse;
begin
h1 := THouse.Create('Home');
h2 := THouse.Create('Cabin');
h1.NextHouse := h2;
Aquí creamos un enlace desde nuestra casa de origen a nuestra cabaña. Podemos seguir la cadena hasta que una casa no tenga NextHouse
referencia, lo que significa que es la última. Para visitar todas nuestras casas, podríamos utilizar el siguiente código:
var
h1, h2: THouse;
h: THouse;
begin
h1 := THouse.Create('Home');
h2 := THouse.Create('Cabin');
h1.NextHouse := h2;
...
h := h1;
while h <> nil do
begin
h.LockAllDoors;
h.CloseAllWindows;
h := h.NextHouse;
end;
Diseño de memoria (se agregó NextHouse como enlace en el objeto, señalado con los cuatro LLLL en el siguiente diagrama):
h1 h2 vv ---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL] 1234Casa + 5678Cabina + | ^ | +--------+ * (sin enlace)
En términos básicos, ¿qué es una dirección de memoria?
Una dirección de memoria es, en términos básicos, solo un número. Si piensa en la memoria como una gran variedad de bytes, el primer byte tiene la dirección 0, el siguiente la dirección 1 y así sucesivamente. Esto está simplificado, pero es suficiente.
Entonces este diseño de memoria:
h1 h2 vv ---[ttttNNNNNNNNNN]---[ttttNNNNNNNNNN] 1234Mi casa 5678Mi casa
Podría tener estas dos direcciones (la más a la izquierda es la dirección 0):
- h1 = 4
- h2 = 23
Lo que significa que nuestra lista enlazada anterior podría verse así:
h1 (=4) h2 (=28) vv ---[ttttNNNNNNNNNNLLLL]----[ttttNNNNNNNNNNLLLL] 1234Casa 0028 5678Cabina 0000 | ^ | +--------+ * (sin enlace)
Es típico almacenar una dirección que "no apunta a ninguna parte" como una dirección cero.
En términos básicos, ¿qué es un puntero?
Un puntero es simplemente una variable que contiene una dirección de memoria. Normalmente puedes pedirle al lenguaje de programación que te dé su número, pero la mayoría de los lenguajes de programación y tiempos de ejecución intentan ocultar el hecho de que hay un número debajo, simplemente porque el número en sí realmente no tiene ningún significado para ti. Es mejor pensar en un puntero como una caja negra, es decir. realmente no sabes ni te importa cómo se implementa realmente, siempre y cuando funcione.
En mi primera clase de Comp Sci, hicimos el siguiente ejercicio. Por supuesto, esta era una sala de conferencias con aproximadamente 200 estudiantes en ella...
El profesor escribe en la pizarra:int john;
Juan se levanta
El profesor escribe:int *sally = &john;
Sally se levanta y señala a John.
Profesor:int *bill = sally;
Bill se levanta y señala a John.
Profesor:int sam;
Sam se levanta
Profesor:bill = &sam;
Bill ahora señala a Sam.
Creo que entiendes la idea. Creo que pasamos aproximadamente una hora haciendo esto, hasta que repasamos los conceptos básicos de la asignación de punteros.
Una analogía que he encontrado útil para explicar los punteros son los hipervínculos. La mayoría de las personas pueden entender que un enlace en una página web "apunta" a otra página en Internet, y si puede copiar y pegar ese hipervínculo, ambos apuntarán a la misma página web original. Si va y edita esa página original, luego sigue cualquiera de esos enlaces (indicadores) y obtendrá esa nueva página actualizada.
La razón por la que los punteros parecen confundir a tanta gente es que en su mayoría tienen poca o ninguna experiencia en arquitectura informática. Dado que muchos no parecen tener una idea de cómo se implementan realmente las computadoras (la máquina), trabajar en C/C++ parece extraño.
Un ejercicio consiste en pedirles que implementen una máquina virtual simple basada en código de bytes (en cualquier idioma que elijan, Python funciona muy bien para esto) con un conjunto de instrucciones centrado en operaciones de puntero (carga, almacenamiento, direccionamiento directo/indirecto). Luego pídales que escriban programas simples para ese conjunto de instrucciones.
Cualquier cosa que requiera un poco más que una simple suma implicará sugerencias y seguramente lo entenderán.
¿Por qué los punteros son un factor de confusión tan importante para muchos estudiantes nuevos, e incluso antiguos, de nivel universitario en el lenguaje C/C++?
El concepto de marcador de posición para un valor (variables) se relaciona con algo que nos enseñan en la escuela: el álgebra. No existe un paralelo que puedas establecer sin comprender cómo se distribuye físicamente la memoria dentro de una computadora, y nadie piensa en este tipo de cosas hasta que se ocupa de cosas de bajo nivel, en el nivel de comunicaciones C/C++/byte. .
¿Existe alguna herramienta o proceso de pensamiento que le haya ayudado a comprender cómo funcionan los punteros en el nivel de variable, función y más allá?
Casillas de direcciones. Recuerdo que cuando estaba aprendiendo a programar BASIC en microcomputadoras, había estos bonitos libros con juegos y, a veces, había que introducir valores en direcciones particulares. Tenían una imagen de un montón de cajas, etiquetadas incrementalmente con 0, 1, 2... y se les explicó que sólo una cosa pequeña (un byte) podía caber en estas cajas, y había muchas de ellas: algunas computadoras. ¡Tenía hasta 65535! Estaban uno al lado del otro y todos tenían una dirección.
¿Cuáles son algunas buenas prácticas que se pueden hacer para llevar a alguien al nivel de "Ah, ja, ya lo tengo", sin que quede estancado en el concepto general? Básicamente, simula escenarios similares.
¿Para un taladro? Haz una estructura:
struct {
char a;
char b;
char c;
char d;
} mystruct;
mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';
char* my_pointer;
my_pointer = &mystruct.b;
cout << 'Start: my_pointer = ' << *my_pointer << endl;
my_pointer++;
cout << 'After: my_pointer = ' << *my_pointer << endl;
my_pointer = &mystruct.a;
cout << 'Then: my_pointer = ' << *my_pointer << endl;
my_pointer = my_pointer + 3;
cout << 'End: my_pointer = ' << *my_pointer << endl;
Mismo ejemplo que el anterior, excepto en C:
// Same example as above, except in C:
struct {
char a;
char b;
char c;
char d;
} mystruct;
mystruct.a = 'r';
mystruct.b = 's';
mystruct.c = 't';
mystruct.d = 'u';
char* my_pointer;
my_pointer = &mystruct.b;
printf("Start: my_pointer = %c\n", *my_pointer);
my_pointer++;
printf("After: my_pointer = %c\n", *my_pointer);
my_pointer = &mystruct.a;
printf("Then: my_pointer = %c\n", *my_pointer);
my_pointer = my_pointer + 3;
printf("End: my_pointer = %c\n", *my_pointer);
Producción:
Start: my_pointer = s
After: my_pointer = t
Then: my_pointer = r
End: my_pointer = u
¿Quizás eso explique algunos de los conceptos básicos mediante un ejemplo?