Академический Документы
Профессиональный Документы
Культура Документы
Los problemas y 'bugs' ocasionados por el uso de punteros suelen ser dificiles de localizar y depurar. Aqui se reune informacion sobre algunos problemas que se pueden considerar tipicos, mas algunas notas sobre conceptos generales relacionados con el manejo de memoria. Se utiliza poco codigo, el minimo suficiente para generar el efecto buscado. Algunos temas pueden ser dependientes del compilador, en este caso se utiliza distintas versiones de compiladores Borland. Se ha puesto mayor atencion en C++, pero buena parte del material se aplica tambien a C. Cualquier genero de dudas, comentarios, correcciones o sugerencias son bienvenidos en el email del autor.
Tabla de contenido
Nociones elementales ................................................................................................................................. 3 1.1 Que es una variable? ......................................................................................................................... 3 1.2 Que es un array? ................................................................................................................................ 5 1.3 Que es un puntero? ........................................................................................................................... 5 1.4 Tipos definidos por el programador .................................................................................................... 7 1.4.1 Clases ........................................................................................................................................... 7 2 Punteros I .................................................................................................................................................... 9 2.1 Declaracion e inicializacion.................................................................................................................. 9 2.2 Desreferenciacion ("indirection")...................................................................................................... 12 2.3 Asignacion de punteros ..................................................................................................................... 14 3 Punteros II ................................................................................................................................................. 16 3.1 Punteros a 'void' ................................................................................................................................ 16 3.2 Punteros y 'const' .............................................................................................................................. 17 3.3 Puntero nulo ("Null pointer") ............................................................................................................ 18 3.4 Puntero a puntero ............................................................................................................................. 18 4 Punteros III ................................................................................................................................................ 20 4.1 Puntero a funcion .............................................................................................................................. 20 4.2 Punteros a objetos............................................................................................................................. 20 4.3 El puntero implicito "this" ................................................................................................................. 21 5 Utilidad de los punteros ............................................................................................................................ 23 5.1 Aritmetica de punteros...................................................................................................................... 23 5.2 Itinerar en un array............................................................................................................................ 24 5.3 Mapear localidades de memoria ....................................................................................................... 25 5.4 Paso de parametros en funciones ..................................................................................................... 25 5.5 Reserva de Memoria Dinamica ......................................................................................................... 26 6 Problemas con punteros I.......................................................................................................................... 28 6.1 Punteros no inicializados ................................................................................................................... 28 6.2 Punteros y literales de cadena .......................................................................................................... 29 6.3 El mensaje "Null pointer assignment" ............................................................................................... 30 6.4 "Dangling pointers" ........................................................................................................................... 31 7 Problemas con punteros II......................................................................................................................... 34 7.1 Memoria dinamica............................................................................................................................. 34 7.2 Datos miembros punteros y copia de objetos .................................................................................. 36 A. Anexo 1: Cadenas de caracteres o strings estilo 'C' .................................................................................. 38 A.1 Que es una cadena de caracteres en C/C++? .................................................................................. 38 A.2 Array y cadena de caracteres: algunas diferencias ........................................................................... 39 A.3 Ejemplos de funciones standard ....................................................................................................... 41 A.4 Sobreescritura de variables ............................................................................................................... 41 B. ANEXO 2: Memoria .................................................................................................................................... 43 B.1 Modelos de memoria ........................................................................................................................ 43 B.2 Rol de la STACK (pila) ......................................................................................................................... 44 B.3 Gestion de memoria en C++ .............................................................................................................. 45 B.4 'R-value' y 'L-value' ............................................................................................................................ 45 1
1 Nociones elementales
1.1 Que es una variable?
Un computador opera manipulando direcciones de memoria y los valores almacenados en dichas direcciones. Un lenguaje de programacion es una herramienta que permite al programador codificar operaciones binarias en un lenguaje mas cercano al natural. Un programa que realiza la traduccion de instrucciones desde un lenguaje de programacion dado al lenguaje de maquina es un compilador. Una variable es un recurso, entre otros, para manipular un dato binario de modo mas legible. Una variable es un identificador, al igual que el nombre de una funcion, este NOMBRE representa para la maquina una localidad de memoria donde el programa puede almacenar y manipular un dato. Una declaracion de variable como: int var; produce una asociacion entre el nombre 'var' y un espacio de almacenamiento en memoria. Por lo tanto hay dos elementos relacionados con el nombre 'var': un valor que se puede almacenar alli y una direccion de memoria para la variable, algunos autores se refieren a estos dos aspectos como el "rvalue" (rigth-value) y "lvalue" (left-value), de la variable (ver tem B.4). Ademas del identificador "var", se tiene la palabra "int" que indica el TIPO (type) de la variable. El tipo indica: 1. CUANTAS CELDAS DE MEMORIA (bytes) se asocian a ese nombre de variable. 2. DE QUE MODO SERAN INTERPRETADOS los datos que se encuentren en tal localidad de memoria, 1- Un byte es la menor unidad de informacion que pueden direccionar la mayoria de las computadoras. En la mayoria de las arquitecturas el tipo char ocupa un solo byte, por lo tanto es la unidad minima. Un bool admite solo dos valores diferentes, pero es almacenado como un byte. El tipo integer ocupa generalmente 2 bytes, un long 4, double 8, y asi con el resto de los tipos. 2- El otro punto es la relacion entre LO QUE HAY en una celda de memoria y COMO ES INTERPRETADO. Lo que hay en una celda cuya extension es un byte es simplemente un conjunto de ocho estados posibles (8 bits) que a nivel hardware admiten dos estados diferenciales, estados que pueden ser interpretados como 'verdadero/falso', 0/1, o cualquier otro par de valores. Una celda de memoria del sector de datos, podria contener algo como lo siguiente:
Que es esto? Depende en gran parte del TIPO (type) que se haya asociado a esa celda (y suponiendo que exista tal asociacion). Ese valor interpretado como un hexadecimal es 0x61, en decimal es 97, y si fue asociada al tipo char representara la letra 'a', cuyo Ascii es igual a 97. En ninguna localidad de memoria hay algo como la letra 'a', lo que se encuentra son valores binarios que en caso de estar asociados a char y en caso de que se muestre en pantalla como char hara que se vean encendidos ciertos pixeles de pantalla, en los cuales se reconoce una representacion de la letra 'a'. La representacion binaria de datos ocupa demasiado espacio, por ese motivo es preferible utilizar el sistema hexadecimal, ademas de ser muy facil de traducir a binario es mas economico que este o el decimal. Observar los bytes de un sector de memoria de un programa facilita la comprension sobre el modo en que cada tipo (type) se asocia a direcciones de memoria. Suponga un programa que declara, define e inicializa las siguientes variables:
int main() { int a = 5; long b = 8; char cad[ ]= "abcd"; char ch = '6'; char hh = 6; etc.... La representacion de estos datos en memoria, en el segmento de datos, tal como lo muestra un debugger, tendra el siguiente aspecto (se omiten caracteres problematicos para navegadores y se deja constancia que diferentes compiladores pueden ordenar los datos de otro modo): ffd0 ........................ ffe0 ............6abcd.. fff0 ....................... 20 20 20 20 00 8F 12 00 00 00 F6 FF BC 04 00 FF F6 F6 00 00 F6 FF C7 04 06 36 61 62 63 64 00 00 08 00 00 00 05 00 00 00 0B 01 00 00 00 00 00 00
Los datos que se han declarado primero en el codigo (int a) figuran al final, los bytes 05 00 son la representacion de la variable entera 'a' de valor = 5, los cuatro bytes 08 00 00 00 lo son del long 'b', luego sigue el array "aaaa", el char '6' que corresponde con el hexadecimal 0x36, y por ultimo un char seteado con el valor entero 6. Ademas se puede realizar las siguientes observaciones: 1. Que el segmento de datos almacena los datos comenzando desde el final (0xffff). La primera variable declarada y definida es el entero 'a', que no esta verdaderamente en el final del segmento, es asi porque esos valores (como 0B 01) guardan valores de STACK para restablecer algunos registros cuando el programa salga de main() y termine. Sobreescribir ese valor podria producir un crash. 2. Que la variable entera de valor 5 guarda este valor ubicando los bytes al reves. Lo logico seria que la representacion fuera 00 05, pero los bytes estan invertidos, esto es una norma general de la mayoria de los procesadores y responde a una pauta de mayor eficiencia en la lectura de variables numericas. 3. El array 'cad' se declara de modo implicto con 5 bytes, las cuatro letras mas el caracter terminador '\0', se ocupa un byte mas porque un numero par de bytes es mas eficiente. Observese que un array no invierte la posicion de sus elementos. 4. Un char ocupa exactamente un byte. El primer char esta definido con el caracter '6' que corresponde al ascii 0x36, la segunda variable char (hh) es seteada a partir de un valor entero (6) lo que genera una conversion implicita de tipos. Se podria profundizar mas el tema de que funcion tiene este sector de memoria, su relacion con la pila (STACK) y los modelos de memoria, pero eso se vera en otros apartados. Por ahora es importante tener en cuenta la relacion entre el tipo (type) usado para declarar una variable y el modo en que se almacena en memoria. En la siguiente tabla se encuentran mas ejemplos: Representacion Numero en memoria de bytes 05 00 65 68 6F 6C 61 00 04 00 00 00 34 12 00 00 FF FF 00 00 2 1 5 4 4 4
Cuando en el flujo de un programa se asigna un valor a una variable lo que sucede es que la localidad (o localidades) de memoria asociadas a las variables son seteadas con tal valor. La asociacion entre localidades de memoria y variable no siempre existe desde el comienzo al final de un programa. Las variables declaradas como 'locales' a una funcion solo tienen asociada una localidad de memoria mientras el flujo del programa se encuentra en tal funcion, al salir de la misma tales localidades seran usadas por otros datos. En cambio las variables 'globales' o declaradas como 'static' conservan su localidad de memoria durante toda la ejecucion del programa.
El tipo (type) del array determina cuantos bytes ocupa cada uno de sus elementos, y tambien de que modo se almacena el dato. Es importante mencionar que este modo de inicializacion es solo posible cuando se realiza en la misma linea que en la declaracion, no es posible inicializar al mismo tiempo varios elementos de un array en una linea diferente a la de la declaracion. Tambien hay que mencionar el hecho de que al dar mas elementos inicializadores que los que figuran entre corchetes se genera un error de compilacion, al dar menos elementos el compilador setea el resto de los elementos con el valor '0'. Una cadena en C/C++ es representada internamente como un array de tipo char y utiliza un caracter 'terminador' para indicar el fin de la cadena, ese caracter es el correspodiente al Ascii = 0 (para mas detalles ver cadenas estilo C/C++ en A. Anexo 1 o directamente en http://es.geocities.com/jorge_hefty/String1.htm). La notacion "Nombre_array[int n]" permite seleccionar cada uno de los elementos de ese array, esa expresion tiene el mismo tipo (type) que un elemento individual, y esto puede ser importante para distinguir mas claramente las asignaciones y conversiones posibles.
Independientemente del tamao (sizeof) del objeto apuntado, el valor almacenado por el puntero sera el de una unica direccion de memoria. En sentido estricto un puntero no puede almacenar la direccion de memoria de 'un array'
(completo), sino la de un elemento de un array, y por este motivo no existen diferencias sintacticas entre punteros a elementos individuales y punteros a arrays. La declaracion de un puntero a char y otro a array de char es igual. Al definir variables o arrays hemos visto que el tipo (type) modifica la cantidad de bytes que se usaran para almacenar tales elementos, asi un elemento de tipo 'char' utiliza 1 byte, y un entero 2 o 4. No ocurre lo mismo con los punteros, el tipo no influye en la cantidad de bytes asociados al puntero, pues todas las direcciones de memoria se pueden expresar con solo 2 bytes (o 4 si es una direccion de otro segmento). Veamos los efectos de un codigo como el siguiente, en la zona de almancenamiento de datos: char cad[] = "hola"; char * p; p = cad; //Puntero 'p' apunta a 'cad'
El puntero esta en la direccion 0xffee pero el valor que hay en esa localidad de memoria es otra direccion, los bytes "F0 FF" indican que el puntero apunta a FFF0, donde comienza la cadena de caracteres 'cad' con el contenido 'hola' mas el cero de fin de cadena. En las lineas de codigo no se indica a que caracter del array apunta el puntero, pero esa notacion es equivalente a: p = &cad[0]; que indica de modo mas explicito que se trata de la direccion del primer elemento de ese array de caracteres. El juego con las direcciones puede ilustrarse tambien del siguiente modo: ffee ffef fff0 fff1 fff2 fff3 fff4 F0 FF 61 61 61 61 00 <----- El puntero ocupa dos bytes para representar la direccion FFF0, direccion a la que 'apunta'. <----<------ cad[0]. .Primer char del array de caracteres, direccion apuntada por el puntero <------ cad[1] <------ cad[2] <------ cad[3] <------ cad[4] Fin del array, caracter ascii = 0 de fin de cadena
Puesto que un puntero tiene como valor una direccion de memoria, es logico que al llamar a funciones de impresion con un puntero como argumento, la salida en pantalla sea la de una direccion de memoria. Para este tipo de pruebas es interesante usar la libreria iostream.h de C++, pues no obliga a especificar el formato (como hace printf ). Para un puntero 'p' la salida en pantalla sera algo similar a lo siguiente: cout<<p; printf("%p",p) //sale: 0x8f82fff0; //sale: FFF0
En este caso se trata de un puntero que almacena en 2 bytes una direccion de memoria, la cual es FFF0. Porque razon la impresion con 'cout' da 4 bytes? Porque agrega 2 bytes (8f y 82) para indicar el 'segmento' donde se encuentra esa direccion. Se trata en todo caso de una misma localidad de memoria, con distinto formato de presentacion en pantalla. La salida en pantalla de un puntero a char es diferente, pues es tratado como apuntando a una cadena de caracteres, en tal caso no sale en pantalla una direccion de memoria, sino un conjunto de caracteres hasta encontrar el '\0'. Un puntero puede almacenar la direccion de ("apuntar a") muy diferentes entidades: una variable, un objeto, una funcion, un miembro de clase, otro puntero, o un array de cada uno de estos tipos de elementos, tambien puede contener un valor que indique que no apunta actualmente a ningun objeto (puntero nulo).
Una clase es basicamente un agregado de datos y funciones para manipular esos datos. Las clases, y la programacion 'orientada a objetos' en general, ha representado un gran avance para produccion de software a gran escala, los recursos de herencia, encapsulamiento, ocultacion de datos, clases virtuales, etc., estan pensados con esa finalidad. Aqui solo nos detendremos en la nocion minima de 'clase' y el modo en que es almacenado un objeto en memoria. Suponga una clase muy simple: class gente { char nombre[10]; int edad; public: gente (char*cad, int a) { strcpy(nombre,cad); edad = a; } }; Se trata de una clase cuyos miembros son dos datos y una sola funcion. Una vez declarada la clase se puede definir objetos como pertenecientes a ese tipo. Una clase no ocupa espacio, pero si un objeto perteneciente a esa clase. El espacio ocupado en memoria por tal objeto puede ser conocido a traves de 'sizeof'. gente pp1; cout<<sizeof(pp1);
El valor podria ser ligeramente diferente segun el compilador, por efecto de optimizacion. Lo importante es observar que el monto de memoria del objeto (retornado por sizeof), esta determinado por la suma del espacio ocupado por los datos, 'sizeof' no tiene en cuenta a la funcion. Cada objeto de tipo 'gente' ocupara 12 bytes, pues posee una copia individual de los datos de clase, en cambio hay una sola copia del miembro funcion (aqui el constructor) utilizado por todos los objetos. Al declarar dos objetos de tipo 'gente': gente pp1("gerardo", 33); gente pp2("miguel", 34);
Al observar ahora que efectos producen estas entidades 'pp1' y 'pp2', en memoria. Los datos que se utilizan se obtienen en TurboC++ (cualquier version) posando el cursor sobre el objeto de interes (aqui 'pp1' y 'pp2') y pulsando 'Alt+f4', tambien al consultar los registros de la CPU (con "Windows/Registers"). En un programa, que define la clase 'gente' y dos objetos (pp1 y pp2) inicializados como muestran las lineas de codigo previas, se puede observar lo siguiente:
El valor especifico de cada dato (como el valor de segmento) puede variar con cada ejecucion, lo que cuenta es la relacion entre tales valores. Interpretando estos datos. 1- En la ventana de cada objeto (pp1 y pp2) figura en primer lugar la direccion de memoria donde almacena sus valores, ambas direcciones tienen el mismo valor de segmento (0x8F86), que coincide por otra parte con el valor de DS (segmento de datos) y de SS (segmento de stack) de la CPU. Sus direcciones difieren ligeramente en offset, la resta de los mismos (0xFFEA - 0xFFDE) es igual a 12, que es el espacio que ocupa (en bytes) cada objeto en memoria. 2- Esos 12 bytes por objeto corresponden a 10 para la cadena de caracteres ('cad') y 2 para almacenar el entero ('edad'). Estos datos estan almacenados alli donde indica el offset, no en otro sitio, por lo tanto un puntero al objeto 'pp1' apuntara (en este caso) a la misma direccion de memoria que un puntero a su elemento 'cad', y otro tanto para 'pp2'. Los datos miembros se exponen con sus nombres a la izquierda y el valor que contienen a la derecha. La cadena de caracteres es terminada en '\0' (seguido de caracteres aleatorios), y el entero es mostrado en formato decimal y hexadecimal 3- Debajo, y separado por una linea, se encuentra un espacio donde se enumeran las funciones miembro de la clase. Alli se encuentra el prototipo de la funcion miembro y al lado la direccion de memoria donde se inicia su codigo. Ese es el valor que almacenaria un puntero a esa funcion. Observese que tal direccion es la misma para ambos objetos, por la razon antes mencionada de que hay solo una copia de funciones miembro por objeto. El segmento donde se encuentra tal funcion se corresponde con el valor que muestra la ventana CPU para CS (segmento de codigo). Se puede sintetizar lo visto respecto a clases del siguiente modo: Una clase no es un 'dato' (es un tipo), no tiene una localidad de memoria asociada y por lo tanto no puede almacenar ningun valor. Un objeto de tal clase si define una region de memoria, un espacio de almacenamiento de datos. Esta es la diferencia entre 'clase' y 'objeto'. Cada objeto de una misma clase posee una copia propia de cada uno de los datos miembros de la clase, pero comparte una misma copia de las funciones miembros. Por otra parte, un array de objetos (instancias de clase) es almacenado como una sucesion consecutiva, mientras que un puntero a objeto sera (como todo puntero) un par de bytes que apunte a una direccion de memoria donde se almacena el objeto.
2 Punteros I
2.1 Declaracion e inicializacion
Un puntero, como cualquier variable u objeto, ademas de ser declarado (para comenzar a existir) necesita ser inicializado (darle un valor de modo controlado), lo cual se realiza mediante el operador de asignacion ('='). Desde que el puntero es declarado almacena un valor, el problema es que se trata de un valor aleatorio, intentar operar con un puntero sin haberlo inicializado es una frecuente causa de problemas.
//Error
El programa no compila, y se recibe el mensaje de error: "Cannnot assign 'int' to 'int near*' en function main(); Se ha tratado de inicializar el puntero 'b' asignandole un valor equivocado, de otro tipo. El analisis de los mensajes de error siempre es instructivo, al profundizar en este: 1- En primer lugar: Que es un "int near*"? Los punteros pueden ser clasificados como 'near' (cercano) o 'far' (lejano) de acuerdo a si la direccion apuntada se encuentra en el mismo segmento que el puntero. Las principales diferencias se exponen en el siguiente cuadro. Tipo de puntero Caracteristicas Near Far Cantidad de bytes que utiliza el puntero
La direccion apuntada se encuentra dos - (offset) en el mismo segmento que el puntero Se encuentran en diferente segmento cuatro - (segmento::offset)
Si un programa no requiere de una gran cantidad de datos significa que pueden entrar en un solo segmento, y los punteros seran 'near' por defecto, en caso contrario el default sera 'far'. Esto es determinado directamente por el modelo de memoria utilizado por el programa. 2- En segundo lugar, el mensaje indica una discordancia de tipos. Uno es 'int', y el otro es 'int near*', al obviar la caracteristica de 'near' se ve que la expresion "int*" coincide con la declaracion del puntero. Lo mas instructivo de esto es comprender que el asterisco pertenece al tipo (type), no al nombre ('b'). Algunos autores discuten sobre cual de las dos siguientes declaraciones es la mas adecuada para declarar un puntero: int *b; int* b; Ambas son perfectamente validas, la unica diferencia es que el primer caso se sugiere que '*' forma parte de 'b', y en el segundo que '*' forma parte del tipo. Lo recomendable es adoptar la segunda forma, la primera se presta a confundir el operador '*' con el operador de 'indirection', y es muy importante comprender que aqui no hay nada de 'indirection', es solo una declaracion de un identificador (b), ligado a un tipo (int*). Es el mensaje del compilador el que indica esta ultima interpretacion. Para que el programa compile sin problemas es necesario utilizar el operador '&' antes del nombre de la variable, el efecto del operador es devolver la direccion en memoria de la variable, la cual se asigna naturalmente a un puntero.
Una variable individual de tipo 'T' y un array de elementos de tipo 'T' pertenecen a tipos diferentes, no es posible la asignacion entre un entero y un array de enteros. Al intentar sin embargo tal asignacion: void main(){ int a; int b[4]; a = b; }
Lo mas interesante del ejemplo es que el mensaje de error es similar (pero inverso) al primer ejemplo fallido: "Cannnot assign 'int near*' to 'int' en function main(); Lo cual puede resultar sorprendente, pues en el ejemplo no se ha declarado ningun puntero, solo un entero y un array de enteros. Lo que esta sucediendo es que el compilador se esta refieriendo al array de enteros como un "int near*", como un puntero. Un array y un puntero no son exactamente lo mismo, hay algunas diferencias, pero la relacion que existe entre ambos es muy estrecha y la sintaxis aplicable a ambas entidades es en gran parte identica. Esta relacion explica que el siguiente ejemplo compile bien sin ninguna complicacion: void main(){ int a [4]; int* b; b = a; }
Podria haberse esperado algun problema, puesto que no se ha obtenido la direccion del array con el operador '&', pero no ocurre asi, el solo nombre del array es tomado como sinonimo de la direccion de su primer elemento (o puntero a su primer elemento). Bien, esta ha sido una introduccion para comprender el mensaje de error tipico en una inicializacion fallida. El intento de asignar una variable individual a un array produce un mensaje de error distinto ("Lvalue requerido"), ver B Anexo2. Veamos ahora ejemplos de inicializaciones de punteros correctas.
Puntero inicializado a Declaracion e inicializacin Declaracion e inicializacin partir de: en una misma linea desdobladas Un objeto individual T x; Un array de objetos T x [10]; T* ptr = &x; T* ptr = &x[0]; T* ptr = x; T* ptr; ptr = &x; T* ptr; ptr = &x[0]; T* ptr; ptr = x; T* ptr; ptr = x; T* ptr; ptr = 0; T* ptr; ptr = NULL; T* ptr; ptr = [literal];
Otro puntero del mismo tipo T* ptr = x; T* x; Valor 0 = puntero nulo Null=0 T* ptr = 0; T* ptr = NULL; T* ptr = [literal]
Un literal
Al tomar como ejemplo el tipo "char", siguiendo al cuadro anterior, se tiene las siguientes opciones de inicializacion: Puntero inicializado a partir Declaracion e inicializacion en Declaracion e inicializacion de: una misma linea desdobladas Un elemento char ch; Un array char cad[10]; char* p = &ch; char* p = cad; char* p = &cad[0]; char* p; p = &ch; char* p; p = cad; char* p; p = &cad[0]; char* p; p = 0; char* p; p = NULL; char* p; p = ptr; char* p; p = "casa";
Valor 0 = puntero nulo char* p = 0; Null=0 (0 es el unico valor entero que puede inicializar un char* p = NULL; puntero) Otro puntero (ya inicializado) char* p = ptr; char *ptr; Un literal de cadena "casa"; char* p = "casa";
Se ha insistido lo suficiente en que un puntero almacena como valor una direccion de memoria, por eso la presencia de un 'literal', en esta ultima tabla el literal de cadena "casa", puede sorprender. Es importante tener claro que todos los literales se almacenan desde el comienzo del programa en un lugar del segmento de datos, no es posible obtener su direccion (por medios normales) pero existe, es al comienzo de dicho segmento, el mismo sitio que se reserva a valores constantes y variables globales. Un literal es tratado como constante, esto es lo que permite que una funcion pueda retornar una constante sin temor a que dicho almacenamiento se pierda al salir de la funcion, un literal no es una variable 'local'. No hay obstaculos para inicializar un puntero con una variable constante, por lo tanto lo mismo se aplica a literales de cualquier tipo. Las tablas anteriores no abarcan todos los casos posibles de inicializacion de punteros, aun no se han mencionado los casos donde el puntero apunta a una funcion o un objeto miembro de una clase, ni la opcion de inicializar a traves de memoria dinamica.
Array de 'n' elementos T* ptr = new T[n]; delete [] ptr; de tipo 'T' Atraves del operador new se solicita una cierta cantidad de memoria dinamica, es posible que no exista suficiente memoria disponible, en tal caso el operador devolvera un puntero NULL (o apuntando a 0), y es por esta razon que luego de una solicitud es recomendable inspeccionar si el puntero devuelto es nulo. Esta seria la respuesta 'clasica' a una reserva fallida de memoria dinamica, sin embargo existen diferentes compiladores que, ajustandose al standard C++ no devuelven un puntero nulo sino que lanzan una excepcion (bad_alloc). Algunos viejos compiladores no reconocen la opcion de borrar el puntero con corchetes vacios y exigen que se especifique el numero de bytes a borrar (los mismos que los reservados), TC++ a partir de su version 3.0 admite esa notacion. Escribir "delete ptr;", sin los corchetes, solo libera el primer elemento del array, y es por lo tanto un error importante.
OPERADOR
& Operacion Bitwise AND char a=0x37; a &=0x0F; Declaracion del type referencia int a; int &b = a;
Usos
El papel opuesto y complementario dadas las siguientes declaraciones: int v = 4; int* p = &v;
del
tercer
uso
de
ambos
operadores
se
podria
sintetizar
asi:
El puntero 'p' es equivalente a la direccion de memoria a la que apunta. La variable 'v' es equivalente al valor que almacena cout<<p saca en pantalla una direccion de memoria (por ej: 0x8f70fff0) cout<<v saca en pantalla '4'
Mientras que la expresion '*p' es sinonimo del elemento Mientras que la expresion '&v' es un sinonimo de la individual que se encuentra en la localidad apuntada por el direccion de memoria donde se encuentra esa variable puntero cout<<&v saca en pantalla una direccion de memoria cout<<*p saca en pantalla '4' (ej: 0x8f70fff0) Como puede observarse, el efecto de ambos operadores es inverso, en un caso dada una localidad de memoria se accede al elemento almacenado en ella (el caso de '*'), en el otro ('&') dada una variable se accede a la direccion de memoria donde almacena su valor. El termino usado para este efecto del operador '*' es el de 'indirection' o 'dereferencing' traducido generalmente como 'indireccion' o 'desreferenciacion'. Su sentido mas llano seria: operador que permite referirnos al elemento individual apuntado por el puntero, en lugar de la direccion en que ese elemento se encuentra almacenado. A veces se utiliza, para ejemplificar la 'indirection', un puntero que apunta a char, estos ejemplos pueden oscurecer el sentido del termino 'indirection', en especial porque con tales punteros la linea "cout<<p" no hubiera sacado en pantalla una direccion de memoria, sino una cadena de caracteres.
Lo que puede desorientar aqui es que 'ptr' no imprima en pantalla una direccion de memoria, que es lo esperable tratandose de un puntero. Se trata de una caracteristica propia de las funciones que tratan con punteros a char, y no de un rasgo diferencial de los punteros a char, estos tienen las mismas caracteristicas generales de cualquier puntero. Utilizando una funcion C de "stdio.h", las lineas anteriores son equivalentes a printf("%s", ptr); printf("%c", *ptr); Al analizar el funcionamiento de printf, esta funcion recibe como argumento un puntero a char, algo cuyo tipo es char*, es decir una direccion de memoria. En C o C++ no hay otro modo de pasar un array a una funcion que a traves de una direccion de memoria. El especificador de formato, "%s", le indica a la funcion que interprete esa direccion como siendo el comienzo de una cadena de caracteres (la 's' es de 'string'). Lo que la funcion hace es interpretar los bytes, uno a uno, como indicando caracteres ascii, y los sacara ordenadamente en pantalla hasta encontrar un '\0', sin importar donde se encuentre ese '\0' o si excede o no la capacidad del array original. En C++, el flujo de salida 'cout' y el operador de insercion '<<', no requieren de un formato especifico para sacar algo en pantalla, esto significa que imponen un formato predeterminado segun el dato enviado como parametro. En el caso de que este parametro sea un puntero a char imponen el formato "cadena de caracteres", exactamente igual que printf con formato "%s". Esto no es obvio, dado que se trata de un puntero podrian sacarlo en pantalla como una direccion de memoria, pero no ocurre asi. Es por esta razon que la idea de 'indirection' se oscurece en relacion a 'punteros a char', pues las funciones standard de impresion en pantalla de C y C++ no tratan a tal puntero como una direccion de memoria mas (aunque lo sea). Siendo 'p' un puntero a tipo char, para las funciones standard de impresion: 'p' es la cadena apuntada, '*p' el caracter individual apuntado.
La funcion 'f()' recibe dos punteros a char desde otra funcion, sigue luego una asignacion de 'cad2' en 'cad1', y una modificacion de un char a traves de desreferenciacion del puntero. Si la intencion era copiar el contenido de la cadena original "dos" en la cadena "uno", para modificar "uno" sin alterar "dos", estamos ante un error. El caracter '3' se copiara en la cadena original "dos", por la razon de que luego de la asignacion de punteros (cad1=cad2) ambos apuntan a "dos". Hay casos donde puede ser util que dos punteros apunten a una misma direccion, y entonces sera correcto asignar punteros mediante el operador '=', pero si lo que se busca es copiar el contenido de un array, entonces se debe hacer de otro modo, copiando uno a uno los elementos de dicho array. Dados dos punteros ("pt1" y "pt2") a array, que apuntan a direcciones diferentes (son dos arrays diferentes), los efectos de una asignacion de punteros y copia de array son los siguientes:
Efecto Asignacion de punteros. Lo que se copia realmente son los 2 (o 4) bytes de direccion de memoria. El puntero 'pt1' deja de apuntar al array original, ahora apunta a la misma direccion que 'pt2'. Copia de array. La copia se realiza elemento por elemento. Se copian tantos elementos como caracteres tenga el array 'pt2'. En el caso de una cadena de caracteres, podemos confiar en el '\0' para saber cuantos elementos copiar.
Es muy importante diferenciar ambas operaciones. Un array no puede ser copiado mediante el operador de asignacion '=', hay que copiar elemento por elemento, un puntero puede ser copiado con tal operador, pero el efecto provocado puede ser distinto al efecto deseado. La confusion entre copia de punteros y copia de array puede provocar otro tipo de problemas en relacion a memoria dinamica o constructores de copia, problemas que se analizan mas adelante.
3 Punteros II
3.1 Punteros a 'void'
Un puntero puede apuntar a un objeto de cualquier tipo, predefinido por el lenguaje o definido por el usuario. 'Void' es el nombre de un tipo, pero su uso esta sujeto a mayores restricciones respecto a otros tipos. El termino 'void' puede ser usado como tipo de una funcion o de un puntero, pero no para declarar un objeto. Las declaraciones posibles de tipo void, en C y C++, se resumen en el siguiente cuadro. Declaraciones Objeto de tipo void Ej: void x; Retorno de funcin Ej: void func(){ ...................... Puntero a void Ej: void* p; un Significa que la funcion no retorna ningun valor ('tipo pseudodevuelto'). Un puntero a void es Puntero a objeto de tipo desconocido. tratado como un puntero a Requiere conversion explicita a otro tipo char. antes de ser utilizado. Conversion implicita. C C++
No permitido. Mensaje de error : "Objeto de sizeof desconocido." El compilador no esta en condiciones de determinar el monto de memoria que requiere el objeto.
En C se accede a bytes no vinculados a ningun tipo mediante punteros a char, esto es natural si se considera que un 'char' ocupa 1 byte de almacenamiento, y de ahi que exista conversion implicita entre ambos tipos. La funcion C standard "memset()", retorna un puntero a void. El siguiente codigo es aceptable en C: void f(char*cad, char ch, int n){ char* s; s = memset(cad,ch,n); //conversion implicita de void* a char* } //valido en C, pero no en C++. C++ no lo permite debido a su mas estricta comprobacion de tipos. Lanza el mensaje de error, "no se puede convertir void* a char*". Hay otras funciones similares a memset que derivan de C y retornan void, algunas declaradas en "mem.h" y "string.h". Todas pueden ser utilizadas igualmente en C++, pues la cadena afectada por la funcion es enviada como parametro. Si se quisiera utilizar el puntero de retorno seria necesario un puntero a "void" o una conversion explicita, por ejemplo: s = (char*)memset(cad,ch,n); //valido en C++
El significado en C++ de un puntero a 'void' es el de un puntero que apunta a una zona de memoria no inicializada, memoria 'en bruto', o en la cual se encuentra almacenado un objeto de tipo desconocido, en general se trata de codigo que trabaja a nivel hardware o relacionado con administracion de memoria. Las operaciones permitidas y no permitidas, en C++, para un puntero a void se resumen en el siguiente cuadro:
1- Asignar a void* un puntero de cualqueir tipo Operaciones 2- Asignar un void a otro void permitidas 3- Convertir explicitamente un void a otro tipo 1-Usar un void
4-Comparaciones de igualdad o desigualdad entre void* (void*!=void*) Operaciones 2-Convertir implicitamente un void a otro tipo (no void) T* = void* prohibidas 3-Desreferenciar un void. *v; 4-Asignar a void punteros a funciones o a miembros
//Puntero constante a int //Bien, se modifica la variable //Error, intento de modificar el puntero constante
Al no poder modificar la direccion a la que apuntan, estos punteros se aproximan al sentido que tiene una referencia.
Comentario Puntero constante, no puede modificar la direccion a la que apunta. Puntero que apunta a const. No puede modificarse el objeto apuntado a traves de ese puntero. Notacion alternativa para puntero a const. No puede modificarse la direccion apuntada ni el objeto a traves de ese puntero.
Puntero a const int const* ptr = &a; Puntero a const const const int *const ptr = &a;
e Direccion de memoria Valor que almacena en (hipotetica) tal direccion de memoria 0xfff6 0xfff4 0xfff2 4 0xfff6 0xfff4
int**pt2 = &pt1;
Es interesante comprobar las diferentes salidas en pantalla de 'pt2' en los siguientes casos: cout<<pt2; //Imprime la direccion del propio puntero 'pt2', aqui: "0xfff2" cout<<*pt2; //Imprime la direccion almacenada en 'pt2', "0xfff4" cout<<**pt2; //Imprime el valor almacenado en '*pt1 = a', "4". El comportamiento de la salida en pantalla es coherente, pues se cumplen las siguientes igualdades: *pt2 *(*pt2) *pt1 **pt2 == == == == pt1; *(pt1); a; a; //Desreferenciacion de 'pt2' //Aplicamos '*' a ambos lados //De esto y la linea previa se deduce... //...esta igualdad
Leanse las anteriores lineas como 'igualdades' (comparaciones que dan 'Verdadero') y no como asignaciones. La estrecha relacion existente entre los conceptos de puntero y array, es la razon de que el asterisco doble (**) pueda ser interpretado indistintamente como puntero a puntero, o bien como un array de punteros.
4 Punteros III
4.1 Puntero a funcion
Es posible tomar la direccion de una funcion y asignarla a un puntero. Una funcion tiene una direccion, esta se encuentra dentro del segmento de codigo y marca el comienzo del codigo para esa funcion. Un puntero a funcion se declara especificando el tipo devuelto y los argumentos aceptados por la funcion a la que apunta, estos dos elementos del puntero deben coincidir con los de la funcion. La sintaxis para estos punteros es la siguiente: FUNCION Ejemplo: PUNTERO A FUNCION Ejemplo: Type devuelto Int Type devuelto Int Nombre f1 (*Nombre) (*pf) (argumento/s) (int, char*); (argumento/s) (int, char*);
Inicializacion de puntero pf = &f1; (Las dos formas estn pf = f1; bien) a funcion La sintaxis de una expresion como: int (*pf) (int, char*); puede resultar poco obvia, a veces es comodo definir un tipo (con typedef) para simplificar las declaraciones. Tambien puede declararse un array de punteros a funcion (todas deben coincidir en tipo devuelto y parametros), un ejemplo de ambos recursos se ve a continuacion: typedef void (*pmenu) (); pmenu Archivo [] = {&Abrir, &Guardar, &Cerrar, &Salir}; En primer lugar definimos un tipo, que es un puntero a funciones. Ese tipo nos permite inicializar otros punteros a funciones, en este caso un array de punteros a funciones. Invocar un puntero a funcion no requiere de desreferenciacion y es muy similar a un llamado comun de funcion. Su sintaxis es: Nombre_de_funcion (argumento/s); //Puntero a funcion Nombre_de_funcion [indice] (argumento/s); //Para un array de punteros a funcin Un puntero a funcion solo puede ser inicializado utilizando la direccion de una funcion, debe existir concordarncia entre funcion y puntero respecto a tipo devuelto y argumentos. Se trata de un puntero especial, no requiere almacenamiento extra de memoria y no esta hecho para itinerar ni para aritmetica de punteros, solo para almacenar la direccion de una funcion, por medio de la cual esta es invocada.
al puntero. Para que el puntero tenga respaldo independiente en memoria la via indicada es la reserva de memoria dinamica para el mismo. Si existiera una clase llamada Fecha, una declaracion de puntero a objeto y la reserva de memoria dinamica tendria la forma: Fecha* hoy; hoy = new Fecha; O bien: Fecha* hoy = new Fecha; La cantidad de memoria a reservar sera calculada de modo automatico a traves del sizeof de la clase. Ahora bien, el sizeof de una clase no es un valor dinamico, es un valor fijo que queda establecido en tiempo de compilacion y no se modifica, de modo que si miembros de la clase necesitaran memoria dinamica esto deberia implementarse de algun modo en otro sitio, especificamente a traves de un constructor.
Existen dos objetos y cada uno tiene su propia 'x'. Esa es la 'x' que aparece en las funciones definidas en esta clase, sera una variable distinta para cada objeto que invoque las funciones (metodos). La cuestion es: como llega ese valor a la funcion, para que esta puede operar con el mismo? La respuesta es: llega de modo implicito a traves de un puntero a ese objeto, el puntero "this". Tomando como ejemplo la clase definida antes, es como si sus funciones miembro hubieran sido declaradas y definidas de este modo: void set2 (num* this, int a) { this->x = a; } Solo que el puntero 'this' esta implicito, no es necesario mencionarlo en la lista de parametros y la mayoria de las veces no es necesario mencionarlo en el cuerpo de la funcion tampoco. Se puede acceder a la variable 'x' no porque sea 'global' ni 'local', sino porque llega como parametro, solo que es un parametro especial, implicito. Dos casos frecuentes, donde es necesario explicitar el puntero "this": 1- Existe ambiguedad respecto a los nombres de variables. 2- Una funcion miembro retorna una referencia al objeto que la invoco. 1- El primer caso se produce si un parametro tiene el mismo nombre que un dato privado, en tal caso la ambiguedad se resuelve utilizando el nombre del parametro, y para poder acceder al dato privado sera necesario explicitar el puntero 'this'. Es lo que sucede en el siguiente caso: void set3 (int x) {this->x=x;} Si el parametro tuviera otro nombre ya no seria necesario (aunque tampoco seria un error) explicitar el 'this'. Puede parecer una complicacion innecesaria dar al parametro el mismo nombre que un dato privado, pero se trata de un recurso a veces util para detectar rapidamente, en una funcion de seteo, la relacion entre parametros y datos privados. 2- El segundo caso se produce al retornar una referencia al objeto que invoca la funcion, se trata de un recurso frecuente en la sobrecarga de operadores, pues permite concatenar operaciones, al modo de los operadores '<<' y '>>' en iostream.h. El siguiente ejemplo, simplificado, muestra una posible implementacion: class complejo { double x, y; public: complejo& operator+= (complejo a){ x+= a.x; y+= a.y; return *this; } La funcion retorna una referncia a un "complejo" a traves del puntero 'this'. Los datos 'x' e 'y' del objeto que invoca la funcion no necesitan un 'this' explicito, esto ocurriria en caso de que el parametro fuera, por ej, un entero 'x' o 'y'. La linea final, que explicita el retorno de un puntero al mismo objeto que la invoco, es necesaria para detalles relacionados con la concatenacion de operadores, la modificacion de los datos privados ya se ha hecho en las dos lineas anteriores.
Los punteros son direcciones de memoria pero la aritmetica de punteros no es una simple suma o resta de direcciones. Estas operaciones estan adaptadas especialmente para tratar con arrays, de ahi que incrementar en 1 el valor de un puntero no apunte a la proxima direccion de memoria, sino al proximo elemento de un array. El unico caso donde 'proxima direccion' es igual a 'proximo elemento' es el caso de un array de caracteres, los restantes tipos (por lo menos los propios del lenguaje) ocupan mas de un byte por elemento.
En la tabla previa se menciona que de darse el caso de desbordar el limite del array el resultado de la suma (o resta) es 'indeterminado'. Esto puede depender en parte de cada compilador, pero como norma general lo que es indeterminado no es el valor-resultado del puntero (la direccion que almacena), sino el valor almacenado en tal direccion. Es decir, dado un puntero-resultado 'pr', por mas que ese valor desborde el array al que apuntaba, 'pr' sera previsible mientras que '*pr' no lo sera, se dice que su valor es 'indefinido'.
El mecanismo es simple y muy eficaz para itinerar a traves de un array, pero no solo eso, tambien permite itinerar por cualquier zona de memoria y es el metodo mas comodo para hacerlo.
//Apunta al comienzo del segmento de datos //Unsigned, para no lidiar con valores negativos
for (a=0;a<256;a++) { ch = *(tt+a); //Para ver el final del segmento seria: ch=*(tt+0xff00+a); col = a%16; fil = a/16; gotoxy(col*3+24,fil+4); printf("%02X",ch); //Representacion hexadecimal if (ch<32) ch=46; //Si ch <32 se reemplaza con puntos gotoxy(col+2,fil+4); printf("%c",ch); //Representacion ascii } return 0;} La funcion saca en pantalla los primeros 256 bytes del segmento de datos-stack. Los detalles del bucle de impresion son para que la salida sea similar a la de un editor hexadecimal, en una columan los caracteres ascii, excluyendo a aquellos cuyo valor es menor a 32 (0x20), en realidad muchos de estos caracteres se pueden imprimir bien, mientras que es mejor evitar algunos como 7, 8, 10, 13, pero se han evitado todo los menores a 32 para simplificar el codigo. En otra columna se exhibiran los valores hexadecimales de esos caracteres. El final del segmento de datos es muy interesante pues almacena los valores de las variables locales, por esta causa sufre importantes cambios con cada llamado a funcion. Para observar tal sector, con el codigo anterior, basta con reemplazar la primera linea debajo del bucle por la indicada en el comentario. El esquema de la funcion, aplicada a lectura de archivos, podria ser de utilidad en una salida a pantalla de un editor hexadecimal.
Un parametro puede ser pasado a una funcion de dos modos diferentes: por valor o por referencia. Pasarlo por valor implica que la funcion receptora hace una copia del argumento y trabaja con ese doble del original, cualquier modificacion realizada en la variable-copia no producira ningun cambio en el parametro enviado. En cambio al pasar un valor por referencia se esta pasando la direccion de memoria del mismo argumento, en este caso no hay otra 'copia' del mismo, el dato es uno y el mismo para las dos funciones, la que envia el dato y la que lo recibe. Una variable comun puede ser pasada por valor o por referencia. En el siguiente ejemplo la funcion 'f ' recibe dos parametros pasados por la funcion 'principal', el primero es pasado por valor y el segundo por referencia. En el primer caso 'f ' incrementara el valor de la variable 'a' que es una copia local del argumento 'x', ese incremento afectara a 'a' pero no a 'x'. En el segundo caso la variable 'y' es pasada por referencia, por lo tanto el incremento operado sobre 'b' es al mismo tiempo un incremento de 'y'. Tanto la variable 'y' de la primera funcion como 'b' estan asociadas a la misma localidad de memoria (hay dos nombres para una misma localidad de memoria). void f (int a, int& b) { a++; b++; } //--------------------------void principal() { int x = 1; int y = 1; f(x,y); ................... Un array en cambio siempre es pasado por referencia, la funcion que recibe el parametro recibe un puntero al elemento inicial del array. Por ejemplo: int xstrlen (char* str) { int a=0; while (*str++!=0) { //alternativa--> a++; } return a; } //----------------------------void principal () { char cad[] = "hola"; printf ("%d", xstrlen(cad)); .......................................
En el ejemplo, la funcion xstrlen() da el largo de un array de caracteres buscando la posicion del '\0' de fin de cadena, y la funcion 'principal' sacara ese valor entero en pantalla. Observese la sintaxis alternativa para el bucle, en un caso se anota 'str' como puntero y en el otro como 'array', ambas notaciones son intercambiables. Al pasar un array por referencia, la funcion receptora solo recibe la direccion inicial del array, es decir 2 bytes, por lo tanto pasar un array de 30 KB consume menos recursos de stack que pasar una variable de tipo long (pasada por valor), que requiere 4 bytes. El principal inconveniente de pasar un parametro por referencia radica en la posibilidad que tiene la funcion receptora de alterar todos los datos del parametro, por esta causa es frecuente que, en la declaracion de la funcion, tal parametro se declare como "const" para evitar la corrupcion accidental de ese dato.
1- Memoria estatica. Es el caso de las variables globables y las declaradas como 'static'. Tales objetos tienen asignada la misma direccion de memoria desde el comienzo al final del programa. 2- Memoria automatica. Usada por los argumentos y las variables locales. Cada entrada en una funcion crea tales objetos, y son destruidos al salir de la funcion. Estas operaciones se realizan en la pila (stack). 3- Memoria dinamica. Tambien llamado 'almacenamiento libre' (free store). En estos casos el programador solicita memoria para almacenar un objeto y es responsable de liberar tal memoria para que pueda ser reutilizada por otros objetos. La operacion de reservar y liberar espacio para variables globables, estaticas o locales son realizadas de modo implicito por el programa, la unica modalidad que requiere mayor atencion por parte del programador es la de reservar memoria en forma dinamica. El papel de los punteros en relacion a la memoria dinamica es muy importante, por la razon de que al pedir, al sistema operativo, una cantidad determinada de memoria dinamica para un objeto, el sistema retorna un puntero que apunta a esa zona de memoria libre, la respuesta dependera de si hay o no el espacio solicitado. a) Si hay suficiente memoria se retorna un puntero que apunta al comienzo de esa zona de memoria. b) Si no hay suficiente, retorna un puntero nulo. En C++ los operadores usados para requerir y liberar memoria dinamica son new y delete. La sintaxis es la siguiente: Variable individual Reserva de memoria dinamica int* a = new int Liberacion de memoria reservada delete a; Array de elementos individuales int* a = new int [n]; delete [] a;
Nota: las primeras versiones de TurboC++ no admiten la linea "delete [ ] a" con corchetes vacios, para liberar memoria dinamica de un array requiere que se explicite cuantos elementos hay que borrar, explicitando este valor entre corchetes. En la version TurboC++ 3.0 esta caracteristica de la sintaxis standard ya se encuentra implementada. Las ventajas de utilizar memoria dinamica se valoran mejor en comparacion con las caracteristicas de la reserva estatica de memoria. Reserva estatica de memoria Reserva dinmica de memoria reserva explicitamente mediante Creacion Los objetos locales son creados al entrar en La memoria se de la funcion que los declara. Los globales son el operador new. objetos creados al iniciarse el programa.
Duracion Los objetos locales se destruyen al salir Los objetos necesitan de los de la funcion en que han sido creados. Los con el operador delete. objetos globales, al salir del programa. Al reservar memoria estatica para un array el valor del indice debe ser un valor constante. Indice de Ej: arrays int n [20]; int n [variable no const]; //no permitido
ser
destruidos
explicitamente,
El indice de un array puede ser un valor variable, de modo que la cantidad de memoria reservada por una linea de cdigo puede variar en tiempo de ejecucion (runtime). Ej: int* n = new int [variable no const] //correcto
La estrecha relacion que existe entre arrays y punteros explica que la solicitud de memoria dinamica para un array culmine en la devolucion de un puntero, una vez que ha sido reservada la memoria suficiente se opera sobre el puntero directamente, de modo muy similar a como se opera con un array. Los mecanismos de bajo nivel que implementan el uso de memoria dinamica son bastante complejos y no nos detendremos en ello. Desde el punto de vista del programador, la principal fuente de errores se deriva de una mala coordinacion entre operadores new y delete, sea que se olvido liberar la memoria que ya no se utiliza, o que se intente borrar, o utilizar, un objeto ya borrado.
strcpy(ptr,"Hasta luego");
Como puede observarse se han perdido los valores originales de las tres variables enteras. Las consecuencias concretas de sobreescribir variables dependen enteramente de lo que haga el resto del programa, en todo caso se trata de un error. Por lo tanto es importante evitar la presencia de punteros no inicializados y que por ello apuntan a una zona de memoria indeterminada, en ingles generalmente se los denomina 'wild pointers' (punteros salvajes).
Si se modifica algun valor a partir de la localidad DS::0x3d, donde comienza el mensaje "Null pointer..." ya no se produce el mensaje de error, de hecho solo las localidades resaltadas con color pueden producirlo. Con mas exactitud habria que decir que no es el hecho de 'escribir' alli lo que genera el mensaje de error, sino el hecho de que, al terminar el programa, alguno de esos bytes contenga un valor diferente al que se observa en la imagen. Por lo tanto si uno sobreescribe el mismo valor que tiene, o bien lo altera pero antes de salir del programa lo vuelve a reestablecer, el mensaje de "Null pointer assignment" no se produce. Esto ultimo solo a titulo informativo, no es recomendable de ningun modo intentar operar sobre esas localidades de memoria. Los siguientes ejemplos ilustran algunos modos de generar el mensaje de error. Ejemplo N:1 - Desreferenciacion de un 'wild pointer' int main () { char* p; *p = 'a'; return 0; }
No esta determinado adonde apuntara 'p', pero en los ejemplos observados apunta siempre a 0x0000 o 0x000c, ambos bytes 'prohibidos', al darle el valor 'a' modifica el comienzo del segmento y aparece el mensaje de error que se esta estudiando. Ejemplo N:2 - No requiere comentarios, sucede lo mismo que en el ejemplo anterior. #include <string.h> int main() { char *p; strcpy (p, "wxsjkwe"); return 0; } Ejemplo N: 3 - Olvidar que una funcion ha retornado un puntero nulo #include <string.h> int main () { char* p; char ch = 'a'; char cad[] = "jorge"; p = strchr(cad, ch); //busca 'ch' dentro de 'cad'. ................... *p = 'x'; //Como 'ch' no esta en 'cad' strchr() retorna un puntero nulo, ..............etc //que ahora es desreferenciado y escrito. En este ejemplo se invoca una funcion, que retorna un puntero nulo y luego, y sin redireccionar el puntero, se le da un valor, como resultado se escribe 'x' en el primer byte del segmento provocando el mensaje de error. Algo similar ocurriria si al solicitar memoria dinamica 'new' retornara un puntero nulo, y se operara con el mismo sin antes comprobar el exito de la solicitud. Existen muchisimos modos de producir el mensaje de error, pero todos se basan en lo mismo. Una observacion mas: el mensaje de error avisa que se ha escrito en un puntero 'nulo', en este contexto eso significa un puntero que apunta a 0x0000, si apuntara a 0x0001 ya no seria un puntero nulo. Sin embargo se ha visto que no es esa la unica localidad de memoria que produce el mensaje de error, se trata mas bien de un intervalo de 45 bytes, desde 0x0000 hasta 0x002c, por lo tanto no es solo la 'asignacion de un puntero nulo' la que provoca el mensaje, aunque asi lo da a entender "Null pointer assignment", por lo menos asi sucede en los compiladores Borland.
El puntero 'ptr' recibira la direccion 'correcta', la misma en que estaba almacenada la cadena 'buffer', el problema es que 'buffer', al ser declarada como local, pierde su localizacion de memoria. El rol que juega la stack (pila) en el llamado a funciones se ilustra en la siguiente figura:
Comentemos paso a paso la relacion entre stack, funciones y el codigo anterior: I. Al comenzar el programa se hace lugar en la pila para albergar todas las variables locales de 'main', este lugar se encuentra al final del segmento de pila y solo sera liberado al terminar el programa. Hasta ese momento los datos locales de las restantes funciones 'no existen', en el sentido de que no tienen localidades de memoria donde almacenar un valor. Distinto es el caso con las variables declaradas como 'static', pero estas se encuentran en la parte baja de la pila y no producen el problema que se esta viendo. II. La funcion main ( ) llama a la funcion 'f1'. Como los valores de las variables de main no se pierden hasta el final del programa, en la pila se hace lugar, debajo de estos valores, para almacenar las variables locales de 'f1', en terminos de ensamblador diriamos 'la pila crece (hacia abajo) seteando un nuevo valor de BP (base pointer) y SP (stack pointer)'. En esas localidades de memoria, estaran los valores de 'f1' hasta salir de la funcion. III. Saliendo de la funcion f1 se retorna un puntero a una variable local. Lo que el puntro retorna es, obviamente, una direccion de memoria, esa direccion se mantiene, no es borrada ni se pierde. El problema no es el puntero, el problema es que se pierde la variable local. Que sucede con las variables locales al salir de la funcion? Mientras no se llame a otra funcion sus valores pueden perdurar, pero no es algo que un compilador garantice. IV. Al llamar a otra funcion. En ese momento las localidades de memoria asociadas a las variables de la anterior funcion 'f1' seran sobreescritas por las variables locales de la nueva funcion llamada 'f2'. El puntero a la variable local de f1 seguira apuntando a la misma localidad de memoria, pero su contenido sera indeterminado, y sera muy peligroso usarlo para cualquier proposito (a menos que sea reasignado). La regla practica seria esta: no retornar nunca un puntero que apunte a una variable declarada como local. Pero entonces, que camino seguir para retornar un array o puntero de modo seguro desde una funcion? En la literatura existente sobre el tema se analizan y recomiendan tres posibles soluciones, todas apuntan a preservar el valor de la variable, evitando que sea 'local': 1- Declarar a la variable 'static'. 2- Reservar memoria dinamica para la variable dentro de la funcin. 3- Retornar el valor utilizando un parametro de la funcion llamadora. Se analizan cada una de las soluciones, anticipando que las tres son eficaces en evitar el problema de punteros 'dangling', solo se trata de evaluar sus efectos. 1- Al declarar una variable como 'static' se esta reservando un sitio especial dentro del segmento que no sera alterado por el flujo general del programa, ese sitio es en la parte baja de la stack, lejos de la parte alta donde se produce todo el movimiento de variables locales de las distintas funciones. Para esto basta con anteponer 'static' a la declaracion de la variable: static char buffer[128]; El unico inconveniente es que esa zona de memoria no sera liberada en todo el transcurso del programa, la cantidad de bytes reservados determinara si este recurso es demasiado costoso o no.
2- Reservar memoria dinamica dentro de la funcion llamada. En el ejemplo seria: char* buffer = new char [128]; Aqui sera responsabilidad del programador liberar la memoria reservada, en caso contrario se produciran 'fugas de memoria' (memory leaks), es decir, memoria fuera de uso que no puede ser reutilizada para almacenar nuevas variables. Se trata de un tema tecnicamente complejo, al punto de que muchos compiladores no son enteramente eficaces en la liberacion de memoria reservada dinamicamente (tardan en hacerlo), existe software 'recolector de basura' (garbage collection) cuya funcion es liberar zonas de memoria a las que ya no puede acceder ninguna variable en tal punto de un programa. 3- Devolver el valor a traves de un parametro. La variable de retorno no se declara dentro de la funcion que retorna, sino en la funcion que llama. Por ejemplo: void f1 (char* buff) { .................. } int main () { ............... char buffer[128]; f1(buffer); .................etc, No es necesario retornar explicitamente la variable pues el parametro ha sido pasado 'por referencia', 'buff' de 'f1' apunta a la misma localidad que 'buffer' de 'main', y pueden ser tratados como un mismo puntero. El unico defecto del metodo radica que puede disminuir ligeramente la legibilidad del codigo, la funcion retorna un puntero pero de modo disimulado, el tipo (type) de la funcion no informa nada al respecto. Las funciones de las librerias de C y C++ utilizan en general las dos ultimas alternativas, (2) y (3).
void f (int a, int b) { char* p = new char[a]; ...................... p = new char[b]; ..................... Aqui la memoria reservada por el primer uso de new ya no podra ser liberada, pues se ha perdido su direccion. Toda segunda asignacion del puntero 'p' sin antes liberar la memoria dinamica asociada a el, producira fugas de memoria. Otra variacion del mismo problema es el siguiente: void f (int a) { char* p = new char[a]; char* q = new char[a]; ...................... p = q; ..................... delete [] p; delete [] q; } Este esquema de error es importante pues, como se vera mas adelante, se presenta en forma velada en problemas con constructores de objetos. El error es reasignar el puntero 'p' antes de liberar la memoria por el reservada. Esto tiene dos consecuencias negativas: 1- La memoria reservada originalmente por 'p' no podra ser liberada. 2- La memoria reservada por 'q' sera liberada dos veces (muy problematico). Un modo de fallar en liberar acertadamente la memoria reservada con new, en relacion a un array, consiste en aplicar el operador delete, olvidando los corchetes entre el operador y el nombre del puntero. int f () { char* p = new char[100]; ........................ delete p; delete [] p;
//error, solo libera un elemento de 'p' //bien, libera el array apuntado por 'p'
//Primera reserva de memoria dinamica //Liberacion de mem dinamica //Precaucion por posible sobreborrado de 'p' //Nueva asignacion //Desreferenciacion de 'p'
La sola declaracion de los objetos 'b' y 'a', sin parametros, invoca un constructor por defecto. En la tercera linea no se invoca al constructor, se realiza una copia del objeto 'b' en el objeto 'a'. A menos que se especifique algo distinto, esta copia (llamada 'asignacion de copia'), produce una replica miembro a miembro de los datos privados de 'b' en los datos privados de 'a'. A primera vista esto es muy natural y no problematico, pero si entre los datos privados figuran punteros entonces pueden plantearse importantes problemas. class Clasex { int x; char ch; char* cad; public: Clasex (int n = 40) { cad = new char [n]; } ~Clasex () {delete [] cad;} .............. } void f() { Clasex a; Clasex b = a; Clase c; c = b; }
//Invoca constructor //Inicializacion de copia - Problemas con el puntero! //Invoca constructor //Asignacion de copia - Problemas con el puntero!
Aqui hay tres objetos "Clasex" en juego. Se trata de objetos locales, por lo tanto el destructor sera invocado de modo automatico tres veces al salir de la funcion. El primer problema se plantea cuando se toma conciencia de que, en la funcion "f ( )", el constructor es llamado solo dos veces (el destructor: tres veces), tanto la inicializacion de copia como la asignacion de copia no utilizan el constructor, sino que copian datos miembro a miembro. El esquema de los tres objetos seria el siguiente: 'a' 'b' 'c' Datos privados. a.x b.x c.x Una copia individual por cada a.ch b.ch c.ch objeto a.cad b.cad c.cad Funciones publicas: Clasex::Clasex (int); una copia para todos los objetos Clasex::~Clasex(); La copia de los datos 'x' y 'ch' no presenta ningun problema, cada objeto tiene su 'x' y su 'ch' en distintas localidades de memoria, copiar estas variables es copiar el valor almacenado. Cada objeto tiene tambien una localidad de memoria para 'su' puntero 'cad', el problema es adonde apuntan esos punteros.
La copia de punteros (ej, a.cad = b.cad) es copia de las direcciones a la que apuntan. Pero esas direcciones, en el ejemplo, son localidades de memoria reservadas mediante memoria dinamica, por lo tanto, luego de: a = b; Sucede que los punteros "cad" de ambos objetos apuntan a la misma zona de memoria reservada con new, y es aqui donde se presenta el problema. Hay tres objetos y por lo tanto tres punteros, cada puntero deberia tener sus propios bytes reservados (el default para el ejemplo es 40). Una llamada comun al constructor reserva esos bytes, y son diferentes para cada objeto, pero una asignacion de copia hace que un puntero deje de apuntar a 'su propia zona' y apunte a los mismos bytes que el otro puntero. Como consecuencia: Una zona de memoria queda fuera de alcance, no pudiendo ser liberada y se crea una fuga de memoria Dos punteros apuntan a la misma zona, cuando se invoque el destructor, este liberara dos veces una misma zona de memoria, lo que es muy problematico. La solucion. Cuando entre los datos privados hay punteros es necesario explicitar un constructor de copia diferente al default, para evitar la copia entre punteros, y es necesario tambien proveer de un asignador de copia diferente al default, esto significa que para disponer de la notacion "a = b" sera necesario sobrecargar el operador '=' y darle un sentido diferente, que evite la copia de punteros. La naturaleza del problema puede aclararse con un codigo que no utiliza clases pero que presenta el mismo error. void f () char* a = char* b = a = b; delete [] delete [] } { new char[40]; new char[40]; //Error! no se debe reasignar sin antes liberar memoria b; a;
Cada puntero tiene 'sus' 40 bytes de memoria dinamica, al reasignar "a=b" el puntero 'a' deja de apuntar a la zona de memoria reservada con 'new', esa direccion se pierde y no podra ser liberada (fuga de memoria). Por otra parte, las dos invocaciones de 'delete' cometen el error de liberar una misma zona de memoria.
Aseguramiento del fin de cadena Cuando se declara un array o una variable cualquiera sin darle un valor inmediatamente, sin 'inicializarla', esa variable o array pueden contener cualquier valor, se dice que su valor es indeterminado, las variables globales son una excepcion pues son inicializadas con un valor default por el compilador, pero la mayor parte de los datos seran locales a una funcion (sea main() o cualquier otra) y por lo tanto no seran inicializados automaticamente. Suponga ahora las siguientes lineas de codigo: int main () { int largo; char cad[5]; largo = strlen(cad); ........................etc. La pregunta es "cual es el valor de la variable 'largo'?", y el error es creer que ese valor deba ser necesariamente 5, de hecho el valor de 'largo' podria ser 0, 1, 9932, 234, o casualmente 5. Porque razon?, con la declaracion de 'cad' se ha declarado un array de caracteres, reservando 5 bytes de memoria estatica para ese array, pero no se ha inicializado ningun dato, lo mas probable es que esa region de memoria conserve datos aleatorios de algun programa anterior, el valor que dara "strlen(cad)" se basara en haber comenzado a contar caracteres en memoria hasta encontrar el '\0', que podria estar en cualquier sitio, si se encontro en el primer byte inspeccionado el valor de 'largo' sera 0. Si se saca en pantalla el contenido de esa cadena, sea con printf ("%s", cad) de C, o con cout<<cad de C++, su contenido sera totalmente arbitrario y muy probablemente se vean caracteres 'extraos', esto ocurrira porque las funciones de impresion en pantalla tambien confian en el char 0 para determinar el fin de cadena. Los dos graficos siguientes muestran un contenido hipotetico de los bytes de memoria asociados a la variable 'cad', son los primeros cinco bytes representados. En el primero se muestra un estado posible luego del codigo anterior, los primeros cinco bytes estan reservados para el array de caracteres 'cad', pero en memoria esos bytes contienen un valor aleatorio e independiente, en este caso si se llamara a la funcion strlen(cad) retornaria el entero 15 (cuenta 15 antes de encontrar el 00), y si se sacara en pantalla con "printf" o "cout" se verian los caracteres de la izquierda (subrayados).
En cambio, si al declarar el array se inicializa: char cad[5]="hola"; o bien en un primer paso se declara y luego se copia "hola" en la cadena con strcpy(): char cad[5]; strcpy(cad, "hola"); en ambos casos estara asegurada la presencia del '\0' que indica el fin de cadena.
Ahora strlen() indicaria que el largo de cadena es 4, y las funciones de impresion en pantalla funcionaran normalmente, todo gracias a la presencia del char '\0'. La idea es muy simple, sin embargo es necesario cometer muchos errores y desarrollar mucha practica con cadenas 'tipo C' antes de sentirse comodo con ellas. Las librerias "string.h" y "mem.h" contienen muchas rutinas para tratar con cadenas de caracteres, es necesario conocer en detalle el modo en que cada funcion trata la cuestion del '\0' final para no encontrarse con sorpresas.
Podria ser el caso de que se tuviera muy claro que el fin de cadena esta mas alla de esos 5 bytes y se quisiera conservar, pero si solo interesa una cadena con el caracter ascii 'ch' repetido 'n' veces, una buena practica sea asegurar el fin de cadena con "cad[n]=0", para evitar problemas posteriores.
La diferencia con memset() es bastante clara, aquella era para setear 'n' caracteres y esta para 'toda' la cadena, y ahi esta el principal peligro, en la palabra 'toda'. Pues esta funcion confia exclusivamente en el '\0' para determinar el fin de la cadena que seteara. Suponga un programa que comenzara con las siguientes lineas: int main () { char cad[20]; strset (cad, '*'); .....................etc. Estas lineas de codigo son muy peligrosas, todo depende de si la funcion encuentra o no un '\0' antes de llenar de asteriscos todo lo que encuentre, hasta llegar al fin del segmento, donde sobreescribiria los valores de stack para salir bien de main() y posiblemente habria que resetear la maquina. La explicacion de la funcion dada por la ayuda de TurboC++ es demasiado escueta y no menciona este tipo de problemas, la enunciacion es correcta: "setea toda la cadena" con el caracter indicado, pero es facil olvidar que significa esto en los lenguajes C/C++, significa "todo lo que encuentre hasta dar con un '\0' " Podria darse una lista de posibles errores para los usos de cada una de las funciones que involucran a cadenas. Todos estan relacionados con el problema del limite del array de caracteres. Se mencionan a continuacion algunos de los muchos errores posibles.
La funcion strcat(cad1, cad2) concatena dos arrays de caracteres 'pegando' el segundo argumento luego del primero. int main () { char cad1[] = "hola"; char cad2[] = " mundo"; strcat (cad1,cad2); ....................etc Lamentablemente estas lineas seran problematicas, el modo en que se declararon los arrays hace que para 'cad1' haya cinco bytes en memoria ("hola" mas el '\0') y 6 bytes para 'cad2'. El resultado esperable seria el de que 'cad1' tuviera ahora la cadena "hola mundo", pero esto implica 10 bytes mas el '\0', lo cual desborda la capacidad de cad1. La funcion strcat() hara lo que se le pide de todos modos, sobreescribiendo de ese modo bytes que pertenecen a otras variables. Muchas veces el problema no aparece inmediatamente sino cuando se intenta operar con las variables 'pisadas', en todo caso hay que tener en claro que es necesario reservar los suficientes bytes de memoria al usar una variable, ya se trate de una reserva estatica o dinamica. Distintos sintomas de sobreescritura de variables Los efectos de sobreescribir una variable son muy variados, dependiendo en parte del tipo de dato sobreescrito y de la operacion involucrada. Algunos ejemplos sacados de la practica: 1- El corte imprevisto, durante el transcurso de un programa, como un flujo de archivo puede ser provocado por la sobreescritura del dato 'fstream' o el puntero "FILE*". 2- La aparicion de caracteres ascii 'extraos' casi siempre se debe a la supresion de un '\0' de fin de cadena. Si se escucha un pitido (beep) esto solo significa que entre los caracteres 'extraos' estaba el ascii =7. 3- Sobreescribir el comienzo del segmento de datos provoca el mensaje "Null pointer assignment", este tema se tratara con mas detalle en el apartado dedicado a problemas tipicos con punteros. Son solo algunos ejemplos, los sintomas de sobreescritura de variables son tan variados como las multiples posibilidades que produciria dar un valor random, de modo no controlado, a uno o mas datos de un programa.
B. ANEXO 2: Memoria
B.1 Modelos de memoria
La memoria se puede representar como una coleccion de celdas contiguas con la capacidad de almacenar valores. Cada celda de memoria es individualizada por una 'direccion', que consta de un valor de segmento y otro de offset (desplazamiento dentro del segmento). Los detalles de como opera la CPU en relacion a la memoria dependen del tipo de procesador, si este esta funcionando en modo 'real' o 'protegido', sistema operativo y muchos otros factores. Cada segmento tiene una capacidad de 64 KB. Una importante directiva en todos los programas es la que determina el MODELO DE MEMORIA que utilizara el programa al ejecutarse. El default suele ser el modelo 'small', pero existen varios modelos mas, sus principales diferencias estan en el modo en que utilizan los segmentos para almacenar codigo, datos o ubicar la pila (stack). Al compilar y ejecutar un programa, en el IDE de TurboC++, se puede examinar los registros de la CPU para datos, codigo y stack, estas son las siglas de tales registros: CS (code seg) Segmento de codigo DS (date seg) Segmento de datos SS (stack seg) Segmento de pila El modelo de memoria utilizado por un programa determinara cuanto espacio (en termino de segmentos) se usara para codigo, datos y stack. El siguiente cuadro sintetiza las distintas opciones: Modelo de memoria Segmentos Comentarios Tiny Small Medium Compact Large Huge Flat cs = ds = ss cs ds = ss cs ds = ss cs ds = ss cs ds = ss cs ds = ss cs ds = ss Codigo, datos y stack utilizan un unico segmento, por lo tanto el ejecutable no podra ser mayor a 64 KB. Es muy similar a un ejecutable con extension .COM Un segmento para codigo y uno para datos y stack. Es el modelo default utilizado, a menos que se especifique uno diferente Codigo usa multiples segmentos, datos y pila comparten uno. Es el modelo de eleccion si hay gran cantidad de codigo y pocos datos Un segmento para codigo y multiples segmentos para datos y stack. Modelo apropiado cuando hay poco codigo pero gran cantidad de datos. Los datos son referenciados por punteros 'far' Multiples segmentos para codigo y multiples seg para codigo y stack. Se usan punteros 'far' para codigo y para datos Similar a 'large' Usa punteros 'near' como el modelo 'small', pero hecho a medida para sistemas operativos de 32 bits
Estas categorias no son especificas de un lenguaje de programacion, la mayoria de los compiladores de los diferentes lenguajes permiten optar por estos diferentes modelos de memoria. Las primeras versiones TurboC++ admiten solo los primeros seis modelos de la tabla, a partir de TurboC++3.01 esta disponible el modelo 'Flat' tambien. Cuando se escribe codigo para librerias, un importante y complejo punto en la implementacion es tener en cuenta que las funciones deben tener la flexibilidad necesaria para adaptarse a diferentes modelos de memoria al pasar por el linker. Es instructivo observar las declaraciones de las librerias standard y el modo en que resuelven este tema. El principal detalle es el uso default de punteros 'near', para los modelos de memoria mas restringidos, y punteros 'far' para los mas extensos.
El espacio total del segmento es de 64 KB, esto significa que el monto de datos que se puede pasar a una funcion sera un poco menor pues hay espacio ocupado por otros elementos. Esta limitacion se puede sortear utilizando otro modelo de memoria, pero por ahora nos centraremos en el ejemplo con modelo small. Cuando se guardan en la pila mas valores de los que caben se produce un 'stack overflow', un desborde de pila. Las funciones recursivas trabajan haciendo una copia de si mismas y guardandola en la pila, por esa causa es frecuente provocar desbordes de pila de ese modo. Hay muchos motivos para utilizar la pila del modo mas economico posible, y los punteros cumplen una gran utilidad en este caso, por ej al pasar arrays, estructuras u objetos entre funciones a traves de una direccion (solo 2 bytes). Otros detalles en relacion a punteros. Todo puntero que este dentro de este segmento y apunte a otra direccion del mismo segmento sera un puntero 'near', para apuntar a un segmento diferente se debe (en modelo small) explicitar un puntero 'far'. Una cuestion interesante es la de si la memoria dinamica se almacena en este segmento o en algun otro. Los detalles en la implementacion de memoria dinamica son en general bastante oscuros y dependen mucho del compilador utilizado, pero si el espacio reservado con 'new' se asocia a un puntero 'near' es claro que la memoria reservada estara dentro de este mismo segmento. Para estudiar este aspecto es recomendable ejecutar el programa en modo debugger y consultar los datos del puntero, el valor de segmento donde se encuentra y el valor de segmento adonde apunta.
El compilador informa que se requiere un 'l-value' a la izquierda de la asignacion. El '4' es un literal numerico, y los literales son tratados como valores constantes, no pueden estar en esa posicion. Ahora bien, como definir "l-value"? T. Jensen lo define como: "valor de la direccion de una variable", y Stroustrup como: "algo que esta en la memoria, que ocupa una region continua de memoria". Esto ultimo se ilustra con las siguientes lineas: int a, b=4, c=5; a = b + c;
el valor de 'a' sera igual a 9, este valor 9 es producto de "a+b", no ocupa una zona de memoria, no hay un 'nombre' de variable (de una variable) asociado a el, es un valor temporal sin identificador propio. Por lo tanto, al no estar en memoria bajo un identificador, no es un "l-value", por esa razon: b + c = a; //Error. "Lvalue required"
da ese mensaje de error. A pesar de la definicion de 'lvalue', como aquello "que puede estar a la izquierda de la asignacion", hay cosas donde esto no se cumple. Una variable declarada como constante es un 'lvalue', es un objeto en memoria, pero no puede estar a la izquierda en una asignacion.