Punteros en C/C++
Punteros en C/C++
Metodolog铆a de la Programaci贸n II
Tema 1 1.3.1 Nociones elementales 1.3.1.1 Que es una variable? Una computadora opera manipulando direcciones de memoria y los valores almacenados en dichas direcciones. Un lenguaje de programación es una herramienta que permite al programador codificar operaciones binarias en un lenguaje mas cercano a nuestras lenguas naturales. Un programa que realiza la traducción de instrucciones desde un lenguaje de programación 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 función, este NOMBRE representa para la maquina una localidad de memoria donde el programa puede almacenar y manipular un dato. Una declaración de variable como: int var; produce una asociación 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 allí y una dirección de memoria para la variable, algunos autores se refieren a estos dos aspectos como el "rvalue" y "lvalue" de la variable. Además del identificador "var", tenemos la palabra "int" que nos indica el TIPO (type) de la variable. El tipo nos 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. • Un byte es la menor unidad de información que pueden direccionar la mayoría de las computadoras. En la mayoría de las arquitecturas el tipo char ocupa un solo byte, por lo tanto es la unidad mínima. 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 así con el resto de los tipos. • El otro punto es la relación entre LO QUE HAY en una celda de memoria y COMO ES INTERPRETADO. Lo que hay en una celda cuya extensión 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, podría contener algo como lo siguiente:
Que es esto? Depende en gran parte del TIPO (type) que hayamos asociado a esa celda (y suponiendo que exista tal asociación). 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 36
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ ninguna localidad de memoria hay algo como la letra 'a', lo que encontramos son valores binarios que en caso de estar asociados a char y en caso de que lo saquemos en pantalla como char hará que veamos encendidos ciertos pixeles de pantalla, en los cuales reconoceremos una representación de la letra 'a'. La representación binaria de datos ocupa demasiado espacio, por ese motivo es preferible utilizar el sistema hexadecimal, además de ser muy fácil de traducir a binario es mas económico que este o el decimal. Observar los bytes de un sector de memoria de un programa facilita la comprensión sobre el modo en que cada tipo (type) se asocia a direcciones de memoria. Supongamos 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 representación de estos datos en memoria, en el segmento de datos, tal como lo muestra un debugger, tendrá el siguiente aspecto (se omiten caracteres problemáticos para navegadores y dejamos 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 código (int a) figuran al final, los bytes 05 00 son la representación 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. Además podemos 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 así porque esos valores (como 0B 01) guardan valores de STACK para restablecer algunos registros cuando el programa salga de main() y termine. Sobrescribir ese valor podría producir un crash. 2. Que la variable entera de valor 5 guarda este valor ubicando los bytes al revez. Lo lógico seria que la representación fuera 00 05, pero los bytes están invertidos, esto es una norma general de la mayoría de los procesadores y responde a una pauta de mayor eficiencia en la lectura de variables numéricas. 3. El array 'cad' se declara de modo implícito con 5 bytes, las cuatro letras mas el carácter terminador '\0', se ocupa un byte mas porque un numero par de bytes es mas eficiente. Obsérvese que un array no invierte la posición de sus elementos.
Metodología de la Programación II
37
Tema 1 4. Un char ocupa exactamente un byte. El primer char esta definido con el carácter '6' que corresponde al ascii 0x36, la segunda variable char (hh) es seteada a partir de un valor entero (6) lo que genera una conversión implícita de tipos.
Se podría profundizar mas el tema de que función tiene este sector de memoria, su relación con la pila (STACK) y los modelos de memoria, pero eso se vera en otros apartados. Por ahora es importante tener en cuenta la relación 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: DECLARACION
Inicialización
Representación Numero en memoria bytes
int a;
a = 5;
05 00
2
char ch;
ch = 'e';
65
1
char cad[]="hola";
-
68 6F 6C 61 00
5
long a;
a=4
04 00 00 00
4
long a;
a=0x1234
34 12 00 00
4
long a;
a = 65535
ff ff 00 00
4
de
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 la variables son seteadas con tal valor. La asociación entre localidades de memoria y variable no siempre existe desde el comienzo al final de un programa. Las variables declaradas como 'locales' a una función solo tienen asociada una localidad de memoria mientras el flujo del programa se encuentra en tal función, al salir de la misma tales localidades serán usadas por otros datos. En cambio las variables 'globales' o declaradas como 'static' conservan su localidad de memoria durante toda la ejecución del programa. 1.3.1.2 Que es un array? Un array es una colección ordenada de elementos del mismo tipo (type), estos tipos pueden ser los que proporciona el lenguaje, como char, integer, float, long integer, etc., o bien puede tratarse de un tipo definido por el programador, como una estructura o una clase. Estos elementos se encuentran ordenados en celdas consecutivas de memoria. Veamos los siguientes ejemplos: Declaración e inicialización
Representación en memoria
Bytes
int a []= {3, 345, 54, 4};
03 00 63 01 72 01 03 27
2x4=8
int a[4]={2};
02 00 00 00 00 00 00 00
2x4=8
char a [] = {"Mensaje 1"};
4d 65 6e 73 61 6a 65 20 31 00
9+1= 10
char a [8] = {hola};
68 6F 6C 61 00 00 00 00
7+1 = 8
long a [] = {9, 16, 0x23b2a};
09 00 00 00 12 00 00 00 2a 3b 02 00
3 x 4 = 12
38
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ El tipo (type) del array determina cuantos bytes ocupa cada uno de sus elementos, y también de que modo se almacena el dato. Es importante mencionar que este modo de inicialización es solo posible cuando se realiza en la misma línea que en la declaración, no es posible inicializar al mismo tiempo varios elementos de un array si lo hacemos en una línea diferente a la de la declaración. También hay que mencionar el hecho de que si damos mas elementos inicializadores que los que figuran entre corchetes se genera un error de compilación, si damos 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 carácter 'terminador' para indicar el fin de la cadena, ese carácter es el correspondiente al Ascii = 0. Para mas detalles ver cadenas estilo C/C++ . La notación "Nombre_array[int n]" nos permite seleccionar cada uno de los elementos de ese array, esa expresión tiene el mismo tipo (type) que un elemento individual, y esto puede ser importante para distinguir mas claramente las asignaciones y conversiones posibles. 1.3.1.3 Que es un puntero? Un puntero es un tipo especial de variable, que almacena el valor de una dirección de memoria, esta dirección puede ser la de una variable individual, pero mas frecuentemente será la de un elemento de un array, una estructura u objeto de una clase. Los punteros, al igual que una variable común, pertenecen a un tipo (type), se dice que un puntero 'apunta a' ese tipo al que pertenece. Ejemplos: int* pint; //Declara un puntero a entero char* pchar; //Puntero a char fecha* pfecha; //Puntero a objeto de clase 'fecha' Independientemente del tamaño (sizeof) del objeto apuntado, el valor almacenado por el puntero será el de una única dirección de memoria. En sentido estricto un puntero no puede almacenar la dirección de memoria de 'un array' (completo), sino la de un elemento de un array, y por este motivo no existen diferencias sintácticas entre punteros a elementos individuales y punteros a arrays. La declaración 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, así 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 dirección de otro segmento) Veamos los efectos de un código como el siguiente, en la zona de almacenamiento de datos: char cad[] = "hola"; char * p; p = cad; //Puntero 'p' apunta a 'cad'
Metodología de la Programación II
39
Tema 1
El puntero esta en la dirección 0xffee pero el valor que hay en esa localidad de memoria es otra dirección, 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 líneas de código no hemos indicado a que carácter del array apunta el puntero, pero esa notación es equivalente a: p = &cad[0]; que indica de modo mas explicito que se trata de la dirección del primer elemento de ese array de caracteres. El juego con las direcciones puede ilustrarse también del siguiente modo: ffee F0 <----- El puntero ocupa dos bytes para representar la dirección FFF0, dirección a la que 'apunta'. ffef FF <----fff0 61 <------ cad[0]. .Primer char del array de caracteres, dirección apuntada por el puntero fff1 61 <------ cad[1] fff2 61 <------ cad[2] fff3 61 <------ cad[3] fff4 0 <------ cad[4] Fin del array, carácter ascii = 0 de fin de cadena Puesto que un puntero tiene como valor una dirección de memoria, es lógico que al llamar a funciones de impresión con un puntero como argumento, la salida en pantalla sea la de una dirección de memoria. Para este tipo de pruebas es interesante usar la librería iostream.h de C++, pues no obliga a especificar el formato (como hace printf ). Para un puntero 'p' la salida en pantalla será algo similar a lo siguiente: cout<<p; //sale: 0x8f82fff0; printf("%p",p) //sale: FFF0 En este caso se trata de un puntero que almacena en 2 bytes una dirección de memoria, la cual es FFF0. Porque razón la impresión con 'cout' nos da 4 bytes? Porque agrega 2 bytes (8f y 82) para indicar el 'segmento' donde se encuentra esa dirección. Se trata en todo caso de una misma localidad de memoria, con distinto formato de presentación 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 dirección de memoria, sino un conjunto de caracteres hasta encontrar el '\0'. Un puntero puede almacenar la dirección de ("apuntar a") muy diferentes entidades: una variable, un objeto, una función, un miembro de clase, otro puntero, o un array de cada uno de estos tipos 40
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ de elementos, también puede contener un valor que indique que no apunta actualmente a ningún objeto (puntero nulo). Tipos definidos por el programador Tipos como 'bool', 'int' o 'char', son "tipos predefinidos", pertenecientes al lenguaje. En C++ al igual que otros lenguajes, es posible definir tipos nuevos. Las enumeraciones, uniones, estructuras y clases, son tipos nuevos que implementa el programador. La declaración de un tipo no produce ningún efecto en memoria, no hay ningún identificador donde almacenar un dato, por esa razón no tendría sentido, dentro de la definición de una estructura o clase , intentar dar un valor a sus datos, seria lo mismo que intentar dar un valor a un tipo predefinido, por ejemplo: long = 8; Para asignar un valor necesitamos un objeto, pues un objeto implica una región de memoria donde almacenar un valor. El almacenamiento en memoria de una unión, enumeración o estructura (C), no presenta importantes cambios respecto a los tipos predefinidos, sus elementos se ordenaran de modo consecutivo de acuerdo a su 'sizeof'. Respecto a C, C++ aporta un nuevo tipo predefinido, las clases, entidad que no solo es un agregado de datos sino también de funciones, y que por ello presenta novedades de importancia respecto a los tipos anteriores. 1.3.1.4 Clases Una clase es básicamente un agregado de datos y funciones para manipular esos datos. Las clases, y la programación 'orientada a objetos' en general, ha representado un gran avance para producción de software a gran escala, los recursos de herencia, encapsulamiento, ocultación de datos, clases virtuales, etc., están pensados con esa finalidad. Aquí solo nos detendremos en la noción mínima de 'clase' y el modo en que es almacenado un objeto en memoria. Supongamos 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 función. Una vez declarada la clase podemos definir objetos como pertenecientes a ese tipo. Una clase no ocupa espacio, pero si Metodología de la Programación II
41
Tema 1 un objeto perteneciente a esa clase. El espacio ocupado en memoria por tal objeto puede ser conocido a través de 'sizeof'. gente pp1; cout<<sizeof(pp1);
//saca en pantalla '12'
El valor podría ser ligeramente diferente según el compilador, por efecto de optimización. 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 función. 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 función (aquí el constructor) utilizado por todos los objetos. Declaremos dos objetos de tipo 'gente': gente pp1("gerardo", 33); gente pp2("miguel",34); Observaremos ahora que efectos producen estas entidades 'pp1' y 'pp2', en memoria. Los datos que utilizaremos se obtienen en TurboC++ (cualquier versión) posando el cursor sobre el objeto que nos interesa (aquí 'pp1' y 'pp2') y pulsando 'Alt+f4', también consultaremos 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 líneas de código previas, se puede observar lo siguiente:
El valor especifico de cada dato (como el valor de segmento) puede variar con cada ejecución, lo que cuenta es la relación entre tales valores. Interpretemos estos datos. 1. En la ventana de cada objeto (pp1 y pp2) figura en primer lugar la dirección 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 están almacenados allí donde indica el offset, no en otro sitio, por lo tanto un puntero al objeto 'pp1' apuntara (en este caso) a la misma dirección de memoria que un puntero a su elemento 'cad', y otro tanto para 'pp2'. Las datos miembros se exponen con sus nombres a la izquierda y el valor que contienen a la
42
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ 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 línea, se encuentra un espacio donde se enumeran las funciones miembro de la clase. Allí encontramos el prototipo de la función miembro y al lado la dirección de memoria donde se inicia su código. Ese es el valor que almacenaría un puntero a dicha función. Obsérvese que tal dirección es la misma para ambos objetos, por la razón antes mencionada de que hay solo una copia de funciones miembro por objeto. El segmento donde se encuentra tal función se corresponde con el valor que muestra la ventana CPU para CS (segmento de código). Podemos 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 ningún valor. Un objeto de tal clase si define una región 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 sucesión consecutiva, mientras que un puntero a objeto será (como todo puntero) un par de bytes que apunte a una dirección de memoria donde se almacena el objeto. 1.3.2 Declaración e inicialización Un puntero, como cualquier variable u objeto, además de ser declarado (para comenzar a existir) necesita ser inicializado (darle un valor de modo controlado), lo cual se realiza mediante el operador de asignación ('='). 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. 1.3.2.1 Assignación errónea : "Cannot assign..." En primer lugar veremos un caso simple de asignación errónea para comprender el mensaje enviado por el compilador. void main(){ int a; int* b; b = a; //Error } El programa no compila, y recibimos el mensaje de error: "Cannnot assign 'int' to 'int near*' en function main(); Hemos tratado de inicializar el puntero 'b' asignándole un valor equivocado, de otro tipo. El análisis de los mensajes de error siempre es instructivo, profundicemos en este. Metodología de la Programación II
43
Tema 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 dirección apuntada se encuentra en el mismo segmento que el puntero. Las principales diferencias se exponen en el siguiente cuadro. Tipo puntero near far
de
Características
Cantidad de bytes que utiliza el puntero
La dirección apuntada se encuentra en el mismo segmento que el dos - (offset) 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 serán 'near' por defecto, en caso contrario el default será 'far'. Esto es determinado directamente por el modelo de memoria utilizado por nuestro programa. En segundo lugar, el mensaje nos indica una discordancia de tipos. Uno es 'int', y el otro es 'int near*', si obviamos la característica de 'near' vemos que la expresión "int*" coincide con nuestra declaración 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 única 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 aquí no hay nada de 'indireciton', es solo una declaración de un identificador (b), ligado a un tipo (int*). Es el mensaje del compilador el que nos indica esta ultima interpretación. 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 dirección en memoria de la variable, la cual se asigna naturalmente a un puntero. void main(){ int a; int* b; b = &a; //El puntero 'b' apunta a 'a'. } Una variable individual de tipo 'T' y un array de elementos de tipo 'T' pertenecen a tipos diferentes, no es posible la asignación entre un entero y un array de enteros. Intentemos sin embargo tal asignación:
44
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ void main(){ int a; int b[4]; a = b; //Asignación errónea, no compila } Lo mas interesante del ejemplo es que el mensaje de error es similar (pero inverso) al de nuestro primer ejemplo fallido: "Cannnot assign 'int near*' to 'int' en function main(); Lo cual puede resultar sorprendente, pues en el ejemplo no hemos declarado ningún puntero, solo un entero y un array de enteros. Lo que esta sucediendo es que el compilador se esta refiriendo 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 relación que existe entre ambos es muy estrecha y la sintaxis aplicable a ambas entidades es en gran parte idéntica. Esta relación explica que el siguiente ejemplo compile bien sin ninguna complicación: void main(){ int a [4]; int* b; b = a; //o bien --> b = &a[0]; } Podría haberse esperado algún problema, puesto que no hemos obtenido la dirección del array con el operador '&', pero no ocurre así, el solo nombre del array es tomado como sinónimo de la dirección de su primer elemento (o puntero a su primer elemento). Bien, esta ha sido una introducción para comprender el mensaje de error típico en una inicialización fallida. El intento de asignar una variable individual a un array produce un mensaje de error distinto ("Lvalue requerido"). Veamos ahora ejemplos de inicializaciones de punteros correctas. 1.3.2.2 Opciones de inicialización Un puntero puede ser inicializado con la dirección de memoria de un objeto, tal objeto debe pertenecer a un tipo acorde al tipo al que apunta el puntero. Puede tratarse de la dirección de un elemento de un array o de una variable individual, el operador '&' antepuesto a un objeto nos devuelve su dirección de memoria. También puede utilizarse un "literal", ya sea numérico, de carácter, o de otro tipo, y puede inicializarse como puntero nulo, en este caso esta permitido usar el 0, el único entero permitido, por su equivalencia con el valor NULL. Suponiendo un tipo cualquiera "T", son inicializaciones validas las siguientes:
Metodología de la Programación II
45
Tema 1 Puntero inicializado partir de: Un objeto individual T x; Un array de objetos T x [10]; Otro mismo T* x; Valor 0 Null=0
puntero
=
Un literal
puntero
a Declaración e inicialización Declaración e inicialización en una misma línea desdobladas T* ptr; T* ptr = &x; ptr = &x; T* ptr; T* ptr = &x[0]; ptr = &x[0]; T* ptr; T* ptr = x; ptr = x; del T* ptr; tipo T* ptr = x; ptr = x; nulo
T* ptr = 0; T* ptr = NULL; T* ptr = [literal]
T* ptr; ptr = 0; T* ptr; ptr = NULL; T* ptr; ptr = [literal];
Sobre este cuadro caben las siguientes aclaraciones: 1. Inicializar un puntero apuntando al primer elemento de un array admite dos notaciones equivalentes, en la segunda se sobreentiende que el elemento apuntado es el primer elemento del array. 2. La equivalencia entre el valor 0 (cero) y NULL es de uso general, sin embargo existen compiladores que dan a NULL un valor diferente a cero. 3. Un 'literal' debe ser el apropiado para el tipo de puntero inicializado. Si es un puntero a char, una cadena de caracteres cualquiera (ej: "hola") será un literal adecuado, si se trata de tipo numérico, para un int "4" Será apropiado. Si tomamos como ejemplo el tipo "char", siguiendo al cuadro anterior, tenemos las siguientes opciones de inicialización: Puntero inicializado a partir Declaración e inicialización en de: una misma línea Un elemento char* p = &ch; char ch; Un array char cad[10];
char* p = cad; char* p = &cad[0];
Valor 0 = puntero nulo char* p = 0; Null=0 (0 es el único valor entero que puede char* p = NULL; inicializar un puntero) 46
Dpl. Ing. Carlos Balderrama Vázquez
Declaración e inicialización desdobladas char* p; p = &ch; char* p; p = cad; char* p; p = &cad[0]; char* p; p = 0; char* p; p = NULL;
Punteros en C/C++ Otro puntero (ya inicializado) char* p = ptr; char *ptr; Un literal de cadena char* p = "casa"; "casa";
char* p; p = ptr; char* p; p = "casa";
Se ha insistido lo suficiente en que un puntero almacena como valor una dirección 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 dirección (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 función pueda retornar una constante sin temor a que dicho almacenamiento se pierda al salir de la función, un literal no es una variable 'local'. No hay obstáculos 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 inicialización de punteros, aun no se han mencionado los casos donde el puntero apunta a una función o un objeto miembro de una clase, ni la opción de inicializar a través de memoria dinámica. 1.3.2.3 Inicialización a través de memoria dinámica Esta modalidad se diferencia de todas las enumeradas hasta ahora y puede considerarse como la principal. Todas las formas vistas hasta aquí asignan al puntero la dirección de memoria de otra entidad (elemento, array, puntero, literal) además del caso especial del valor NULL. Ya se ha mencionado que la declaración de un puntero no implica la reserva de memoria, salvo 2 bytes para almacenar una dirección, por esa razón podría decirse que el puntero, cuando es inicializado por otro elemento, 'vive' de la memoria que le aporta el objeto al que apunta. La reserva de memoria dinámica requiere el uso obligado de un puntero, el cual apuntara al comienzo de la zona reservada. Lo diferente aquí es que se trata del único caso donde el puntero no necesita de otro elemento que le aporte memoria necesaria, no necesita apuntar a algún otro objeto. Cuando reservamos dinámicamente 40 bytes para un puntero a char, operaremos con el puntero 'como si' apuntara a un segundo objeto (un array de caracteres), pero tal array no existe. A pesar de no existir propiamente un segundo objeto, sigue siendo esencial, el tipo (type) según el cual se declara el puntero, pues esto determina el modo en que el puntero nos permitirá acceder a tal zona de memoria. No hay un 'objeto apuntado', pero el puntero se conduce igual que si lo hubiera, por esa razón hablaremos en general del 'objeto apuntado' por el puntero, sin aclarar el caso especial que estamos considerando. En C++ la reserva y liberación de memoria dinámica se realiza a través de los operadores new y delete, y su sintaxis, para un puntero de nombre 'ptr' es la siguiente:
Metodología de la Programación II
47
Tema 1 Reserva
liberación
Elemento individual T* ptr = new T; delete ptr; de tipo 'T' Array de 'n' elementos T* ptr = new T[n]; delete [] ptr; de tipo 'T' A través del operador new solicitamos una cierta cantidad de memoria dinámica, es posible que no exista suficiente memoria disponible, en tal caso el operador nos devolverá un puntero NULL (o apuntando a 0), y es por esta razón que luego de una solicitud es recomendable inspeccionar si el puntero devuelto es nulo. Esta seria la respuesta 'clásica' a una reserva fallida de memoria dinámica, sin embargo existen diferentes compiladores que, ajustándose al standard C++ no devuelven un puntero nulo sino que lanzan una excepción (bad_alloc). Algunos viejos compiladores no reconocen la opción de borrar el puntero con corchetes vacíos y nos exigen que especifiquemos el numero de bytes a borrar (los mismos que los reservados), TC++ a partir de su versión 3.0 admite esa notación. Escribir "delete ptr;", sin los corchetes, solo libera el primer elemento del array, y es por lo tanto un error importante. 1.3.2.4 Desreferenciacion ("indirection") Un puntero almacena una dirección de memoria de alguna entidad, esto en si mismo no seria demasiado útil si no fuera posible, a través del puntero, acceder a lo que esta almacenado en esa dirección. según el creador de C++: "La operación fundamental de un puntero es desreferenciar, es decir, referir al objeto al que apunta el puntero." (Stroustrup, 1997). A continuación desarrollaremos esta definición. La función de 'desreferenciar' un puntero es llevada a cabo por el operador '*', que además cumple otras funciones en C++. Como su papel es complementario a una de las funciones del operador '&' se comenzara estudiando la relación y diferencia de estos dos operadores Ambos operadores tienen mas de un sentido dependiendo del contexto en que aparecen, por lo tanto son casos de sobrecarga de operadores. Veamos sus distintos usos: OPERADOR
Usos
* Multiplicación int a = 3, b=2,c; c = a * b; declaración type puntero int n; int* p = n; Dereferencing (indirection) cout<<*p;
& Bitwise
Operation AND char a=0x37; a &=0x0F; declaración del type referencia int a; int &b = a; Referencing cout<<&a;
El primer uso de cada operador se distingue claramente de los otros dos, derivan de C y no tienen relación con el tema punteros. Los que figuran en segundo lugar pertenecen a la sintaxis básica de declaración de punteros y referencias. Nos concentraremos en el tercer significado de estos operadores. 48
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ El papel opuesto y complementario del tercer uso de ambos operadores se podría sintetizar así: dadas las siguientes declaraciones: int v = 4; int* p = &v; El puntero 'p' es equivalente a la dirección de memoria a la que apunta. cout<<p saca en pantalla una dirección de memoria (por ej: 0x8f70fff0) Mientras que la expresión '*p' es sinónimo del elemento individual que se encuentra en la localidad apuntada por el puntero cout<<*p saca en pantalla '4'
La variable 'v' es equivalente al valor que almacena cout<<v saca en pantalla '4' Mientras que la expresión '&v' es un sinónimo de la dirección de memoria donde se encuentra esa variable cout<<&v saca en pantalla una dirección de memoria (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 accedemos a la dirección de memoria donde almacena su valor. El termino usado para este efecto del operador '*' es el de 'indirection' o 'dereferencing' traducido generalmente como 'in dirección' o 'desreferenciacion'. Su sentido mas llano seria: operador que permite referirnos al elemento individual apuntado por el puntero, en lugar de la dirección 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 línea "cout<<p" no hubiera sacado en pantalla una dirección de memoria, sino una cadena de caracteres. El caso especifico de un puntero a char Dadas las siguientes declaraciones e inicializaciones: char cad[] = "hola"; char* ptr = cad; //aquí el '*' es un indicador de tipo, no de 'indirection' El puntero 'ptr' apunta a 'cad', al char inicial de 'cad'. Veamos ahora que saldría en pantalla con 'p' y con '*p', para el caso volveremos a usar la librería "iostream.h" de C++, pues las funciones de C impondrían su formato a la salida. cout<<ptr; cout<<*ptr;
//sale en pantalla: "hola" //sale en pantalla: 'h'
Lo que puede desorientar aquí es que 'ptr' no imprima en pantalla una dirección de memoria, que es lo esperable tratándose de un puntero. Se trata de una característica 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 características generales de cualquier puntero. Utilizando una función C de "stdio.h", las líneas anteriores son equivalentes a Metodología de la Programación II
49
Tema 1 printf("%s", ptr); printf("%c", *ptr); Analicemos el funcionamiento de printf. Esta función, recibe como argumento un puntero a char, algo cuyo tipo es char*, es decir una dirección de memoria. En C o C++ no hay otro modo de pasar un array a una función que a través de una dirección de memoria. El especificador de formato, "%s", le indica a la función que interprete esa dirección como siendo el comienzo de una cadena de caracteres (la 's' es de 'string'). Lo que la función 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 inserción '<<', no requieren de un formato especifico para sacar algo en pantalla, esto significa que imponen un formato predeterminado según el dato enviado como parámetro. En el caso de que este parámetro 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 podrían sacarlo en pantalla como una dirección de memoria, pero no ocurre así. Es por esta razón que la idea de 'indirection' se oscurece en relación a 'punteros a char', pues las funciones standard de impresión en pantalla de C y C++ no tratan a tal puntero como una dirección de memoria mas (aunque lo sea). Siendo 'p' un puntero a tipo char, para las funciones standard de impresión: 'p' es la cadena apuntada, '*p' el carácter individual apuntado. 1.3.2.5 Asignación de punteros Un puntero puede ser asignado a otro puntero del mismo tipo a través del operador '='. El significado de tal asignación es similar al de una asignación entre variables, el valor almacenado en el elemento de la derecha se copia en el elemento de la izquierda. Solo que en el caso de punteros este valor es una dirección de memoria, y esto puede producir un efecto distinto al esperado. void f (char* cad1, char* cad2) { cad1 = cad2; *cad1 = '3'; //Efecto: modificación de cadena "dos". //................................. } char uno = "1111"; char dos = "2222"; f(uno, dos); //Llamado a función f(); La función 'f()' recibe dos punteros a char desde otra función, sigue luego una asignación de 'cad2' en 'cad1', y una modificación de un char a través de desreferenciacion del puntero. Si la intención era copiar el contenido de la cadena original "dos" en la cadena "uno", para modificar "uno" sin alterar "dos", estamos ante un error. El carácter '3' se copiara en la cadena original "dos", por la razón de que luego de la asignación de punteros (cad1=cad2) ambos apuntan a "dos". 50
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Hay casos donde puede ser útil que dos punteros apunten a una misma dirección, y entonces Serra 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 asignación de punteros y copia de array son los siguientes: Operación pt1 = pt2; while (*pt2!=0) *pt1=pt2;
Efecto asignación de punteros. Lo que se copia realmente son los 2 (o 4) bytes de dirección de memoria. El puntero 'pt1' deja de apuntar a al array original, ahora apunta a la misma dirección 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 asignación '=', 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 confusión entre copia de punteros y copia de array puede provocar otro tipo de problemas en relación a memoria dinámica o constructores de copia, problemas que se analizan mas adelante. 1.3.3 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 función 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 un función Ej: void func(){....... Puntero a Ej: void* p;
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. Significa que la función no retorna ningún valor ('tipo pseudodevuelto'). Un puntero a void es tratado Puntero a objeto de tipo desconocido. void como un puntero a char. Requiere conversión explicita a otro tipo antes de ser utilizado. Conversión implícita.
En C se accede a bytes no vinculados a ningún tipo mediante punteros a char, esto es natural si se considera que un 'char' ocupa 1 byte de almacenamiento, y de may que exista conversión implícita entre ambos tipos. Metodología de la Programación II
51
Tema 1 La función C standard "memset()", retorna un puntero a void. El siguiente código es aceptable en C: void f(char*cad, char ch, int n){ char* s; s = memset(cad,ch,n); //conversión implícita de void* a char* } //valido en C, pero no en C++. C++ no lo permite debido a su mas estricta comprobación 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 función es enviada como parámetro. Si se quisiera utilizar el puntero de retorno seria necesario un puntero a "void" o una conversión 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 código que trabaja a nivel hardware o relacionado con administración 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 cualquier tipo Operaciones 2- Asignar un void a otro void permitidas 3- Convertir explícitamente un void a otro tipo 4-Comparaciones de igualdad o desigualdad entre void* 1-Usar un void Operaciones 2-Convertir implícitamente un void a otro tipo (no void) prohibidas 3-Desreferenciar un void. 4-Asignar a void punteros a funciones o a miembros
void*=T* void* = void* T* = (T*)void* (void*!=void*) (void*)++; T* = void* *v;
1.3.3.1 Punteros y 'const' Un puntero implica la intervención de dos elementos: el puntero y el objeto apuntado (salvo que sea nulo). El termino reservado "const" puede tener dos significados diferentes según el sitio que ocupe en la declaración, haciendo constante al puntero o al objeto apuntado. 1.3.3.2 Puntero constante El operador '*const', en lugar de '*' solo, declara al puntero como constante, esto significa que la dirección a la que apunta el puntero no puede cambiar en todo el programa. La variable apuntada si puede cambiar. Ejemplo: 52
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ int a = 5; int *const ptr = &a; //Puntero constante a int *ptr = 4; //Bien, se modifica la variable ptr = NULL; //Error, intento de modificar el puntero constante Al no poder modificar la dirección a la que apuntan, estos punteros se aproximan al sentido que tiene una referencia 1.3.3.3 Puntero a constante aquí el termino "const" afecta al tipo al que apunta el puntero. int a = 5; const int* ptr = &a; ptr = NULL; //Bien, el puntero puede cambiar, ser reasignado *ptr = 6; //Error. No se puede cambiar el objeto apuntado. Es importante observar que en el ejemplo la variable 'a' no fue declarada originalmente como "const", pero el puntero la toma como "constante". En este sentido, aunque la variable no puede ser modificada a través de ese puntero, si podría serlo a través de otro identificador, el propio nombre de la variable u otro puntero que no apunte a const. Declarar un puntero a const suele ser útil al declarar argumentos de funciones, sirve para especificar que el argumento puntero no puede ser modificado dentro de la función. La función C standars "strcpy", en su declaración: char* strcpy (char*p, const char* q); impide que el segundo argumento sea modificado por la función. La función copia el contenido de 'q' en 'p', por esa razón el primer argumento, que no es 'const', Serra modificado. Esto no significa que al llamar a la función el segundo parámetro necesite ser una constante, es 'tomado' como constante por el puntero de la función. El siguiente cuadro resume la sintaxis de punteros y 'const': Entidad Puntero constante
Ejemplo
Comentario Puntero constante, no puede modificar la dirección a la int *const ptr = &a; que apunta. const int* ptr = &a; Puntero que apunta a const. No puede modificarse el Puntero a const objeto apuntado a través de ese puntero. int const* ptr = &a; notación alternativa para puntero a const. Puntero const const int *const ptr = No puede modificarse la dirección apuntada ni el objeto a const &a; a través de ese puntero. Como ya hemos visto, se puede asignar una variable no constante a un puntero a constante, esto por la razón de que no puede producir ningún perjuicio, un puntero a const es un puntero con Metodología de la Programación II
53
Tema 1 restricciones. Lo inverso, asignar una variable constante a un puntero que no apunte a const, no esta permitido, pues se perdería el sentido de haber restringido la operatividad de la variable y existiría el peligro de modificar sus datos. 1.3.3.4 Puntero nulo ("Null pointer") Algunos autores definen a este puntero como "aquel que no apunta a ningún sitio" y otros como "un puntero que no apunta a ningún objeto" (Stourtrup-1997). La segunda definición es mas clara, mientras que la primera puede introducir alguna confusión. De hecho no esta claro que podría significar que 'no apuntar a ningún sitio'. El concepto de 'puntero nulo' existe por la necesidad practica de hablar de un puntero que no esta ligado a ningún objeto, muchísimas funciones de las librerías de c/c++ devuelven punteros y entre los posibles valores de retorno cuentan con el de puntero nulo (ej: strchr () ), también se presenta cuando solicitamos memoria dinámica, el operador 'new' retorna un puntero nulo si no hay memoria suficiente (no todos los compiladores). La localidad de memoria donde esta el puntero contiene siempre algún valor (!no existen celdas vacías! cero es un valor!). Un puntero apunta a una dirección, la indicada por el valor que almacena, por lo tanto es lógico concluir que un puntero siempre apunta a algún sitio. Lo que distingue a un puntero nulo no es que 'no apunte a ningún sitio' sino que apunta a alguna localidad de memoria que, por convención del compilador utilizado, no puede estar asociada a ningún objeto o variable. Ese valor, esa dirección 'prohibida' para almacenar allí algún objeto, varia para diferentes compiladores, para Borland (y la mayoría) es la dirección 0 (cero) del segmento de datos. Cuando el puntero apunta a la localidad 0 el compilador considera que su valor es 'null', o lo que es lo mismo, para este compilador 'null' es equivalente a cero. Esa dirección existe y es el comienzo del segmento de datos-stack, puede ser visualizado, y si no hay errores su valor debería ser 0. Existe otro concepto que no debe confundirse con el de puntero nulo, el de "wild pointer". Un puntero nulo apunta a un sitio bien determinado, en cambio un 'wild pointer' puede estar apuntando a cualquier sitio, una dirección indeterminada dentro del segmento. 1.3.3.5 Puntero a puntero Un puntero almacena la dirección de un objeto, puesto que ese objeto puede ser otro puntero, es posible declarar un puntero que apunta a puntero. La notación de puntero a puntero requiere de un doble asterisco, '**', la sola notación suele generar un efecto de confusión considerable, y es la razón de que Mats Henricson y Erik Nyquist, en Rules and Recommendations on C++, sugieran en lo posible reemplazar punteros a punteros por alguna otra alternativa (una clase con miembro puntero) en su Rec 48. Sin embargo, como se vera, el concepto en si mismo no es complejo. La relación entre una variable común, un puntero y un puntero a puntero se muestra en las siguientes líneas: int a = 4; int* pt1 = &a; int**pt2 = &pt1; 54
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Por un lado tenemos el valor que almacena la variable 'a', el puntero 'pt1' almacena la dirección de esa variable, y el puntero 'pt2' almacena la dirección del puntero 'pt1'. Son tres identificadores, cada uno tiene un doble aspecto: la localidad de memoria donde se asienta, y el valor que almacena en esa localidad de memoria. declaración e inicialización int a = 4; int* pt1 = &a; int**pt2 = &pt1;
dirección de memoria (hipotética) 0xfff6 0xfff4 0xfff2
Valor que almacena en tal dirección de memoria 4 0xfff6 0xfff4
Es interesante comprobar las diferentes salidas en pantalla de 'pt2' en los siguientes casos: cout<<pt2; //Imprime la dirección del propio puntero 'pt2', aquí: "0xfff2" cout<<*pt2; //Imprime la dirección 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 == pt1; //Desreferenciacion de 'pt2' *(*pt2) == *(pt1); //Aplicamos '*' a ambos lados *pt1 == a; //De esto y la línea previa se deduce... **pt2 == a; //...esta igualdad Léanse las anteriores líneas como 'igualdades' (comparaciones que dan 'Verdadero') y no como asignaciones. La estrecha relación existente entre los conceptos de puntero y array, es la razón de que el asterisco doble (**) pueda ser interpretado indistintamente como puntero a puntero, o bien como un array de punteros. 1.3.4 Puntero a función Es posible tomar la dirección de una función y asignarla a un puntero. Una función tiene una dirección, esta se encuentra dentro del segmento de código y marca el comienzo del código para esa función. Un puntero a función se declara especificando el tipo devuelto y los argumentos aceptados por la función a la que apunta, estos dos elementos del puntero deben coincidir con los de la función. La sintaxis para estos punteros es la siguiente: FUNCION Ejemplo: PUNTERO A FUNCION Ejemplo: Inicialización de puntero a función
Type devuelto Nombre Int f1 Type devuelto (*Nombre) int (*pf) pf = &f1; (Las dos formas están pf = f1; bien)
Metodología de la Programación II
(argumentos) (int,char*); (argumentos) (int, char*);
55
Tema 1 La sintaxis de una expresión como: int (*pf) (int, char*); puede resultar poco obvia, a veces es cómodo definir un tipo (con typedef) para simplificar las declaraciones. También puede declararse un array de punteros a función (todas deben coincidir en tipo devuelto y parámetros), un ejemplo de ambos recursos se ve a continuación: 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 función no requiere de desreferenciacion y es muy similar a un llamado común de función. Su sintaxis es: Nombre_de_funcion (argumentos); //Puntero a función Nombre_de_funcion [indice] (argumentos); //Para un array de punteros a función Un puntero a función solo puede ser inicializado utilizando la dirección de una función, debe existir concordancia entre función 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 aritmética de punteros, solo para almacenar la dirección de una función, por medio de la cual esta es invocada. 1.3.4.1 Punteros a objetos Pueden declararse punteros que apuntan a objetos instancias-de-clase de cierto tipo, la sintaxis a utilizar para la declaración es la común, solo es diferente el modo en que es invocado un dato o función miembro. Con tal fin se utiliza el operador "->". Al igual que punteros que apuntan a tipos definidos en el lenguaje, un puntero a objeto, luego de declarado, puede ser inicializado con la dirección de memoria de un objeto conveniente (del mismo tipo), sea de un objeto individual o un array, también se lo puede inicializar como puntero nulo o a través de otro puntero ya inicializado. Sin embargo estas opciones hacen al puntero una entidad dependiente de la memoria que se haya reservado para los objetos que asignan su dirección al puntero. Para que el puntero tenga respaldo independiente en memoria la vía indicada es la reserva de memoria dinámica para el mismo. Si existiera una clase llamada Fecha, una declaración de puntero a objeto y la reserva de memoria dinámica tendría la forma: Fecha* hoy; hoy = new Fecha; O bien: 56
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Fecha* hoy = new Fecha; La cantidad de memoria a reservar Serra calculada de modo automático a través del sizeof de la clase. Ahora bien, el sizeof de una clase no es un valor dinámico, es un valor fijo que queda establecido en tiempo de compilación y no se modifica, de modo que si miembros de la clase necesitaran memoria dinámica esto debería implementarse de algún modo en otro sitio, específicamente a través de un constructor. 1.3.4.2 El puntero implícito "this" Cuando una función miembro (método) es invocada para un objeto, la función siempre recibe un parámetro extra, no declarado, que es un puntero al objeto que invoco la función, ese puntero recibe el nombre genérico de "this", y puede ser usado de modo implícito o explicito. Veamos esto detenidamente. Una clase puede contener miembros 'privados', 'públicos' o 'protegidos', para este tema solo consideraremos los dos primeros. Se trata de especificadores de acceso. Uno de los primeros pasos al manipular clases es la comprobación de que un dato declarado como 'private' no puede ser accedido de modo normal en el cuerpo de cualquier función. Para poder acceder a los datos privados debemos utilizar funciones miembros de la clase donde están declarados. Supongamos la siguiente clase: class num{ int x; public: par (){x=0;} void set () {x=3;} void set2 (int a) {x=a;} void set3 (int x) {this->x=x;} .............. }; Declara varias funciones miembro, se han definido dentro de la clase solo para simplificar la exposición. La primera se distingue por ser un constructor, pero todas tienen en común el acceder al dato privado 'x' para darle un valor. No es inmediatamente obvio como es posible para una función miembro acceder al dato privado 'x', recordemos el modo en que una función cualquiera accede generalmente a datos. Una función común (no miembro) puede acceder a datos: • • •
Globales: declarados fuera de toda función. Locales: declarados dentro de esa función. parámetros: enviados por otra función y que son utilizados directamente (por referencia) o a través de una copia local (por valor)
Metodología de la Programación II
57
Tema 1 En apariencia el dato 'x' no pertenece a ninguna de estas categorías. Cuando declaramos un objeto perteneciente a una clase lo que tenemos es una entidad compuesta de una copia individual de los datos de la clase y de las direcciones de las funciones miembros. Es decir, luego de: num uno; num dos; Existen dos objetos y cada uno tiene su propia 'x'. Esa es la 'x' que aparece en las funciones definidas en esta clase, Serra una variable distinta para cada objeto que invoque las funciones (métodos). La cuestión es: ¿como llega ese valor a la función, para que esta puede operar con el mismo? La respuesta es: llega de modo implícito a través 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 implícito, no es necesario mencionarlo en la lista de parámetros y la mayoría de las veces no es necesario mencionarlo en el cuerpo de la función tampoco. Se puede acceder a la variable 'x' no porque sea 'global' ni 'local', sino porque llega como parámetro, solo que es un parámetro especial, implícito. Vamos a mencionar dos casos, frecuentes, donde es necesario explicitar el puntero "this": • •
Existe ambigüedad respecto a los nombres de variables. Una función miembro retorna una referencia al objeto que la invoco.
El primer caso se produce si un parámetro tiene el mismo nombre que un dato privado, en tal caso la ambigüedad se resuelve utilizando el nombre del parámetro, y para poder acceder al dato privado Serra necesario explicitar el puntero 'this'. Es lo que sucede en el siguiente caso: void set3 (int x) {this->x=x;} Si el parámetro tuviera otro nombre ya no seria necesario (aunque tampoco seria un error) explicitar el 'this'. Puede parecer una complicación innecesaria dar al parámetro el mismo nombre que un dato privado, pero se trata de un recurso a veces útil para detectar rápidamente, en una función de seteo, la relación entre parámetros y datos privados. El segundo caso se produce al retornar una referencia al objeto que invoca la función, 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 implementación: class complejo { double x, y; 58
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ public: complejo& operator+= (complejo a){ x+= a.x; y+= a.y; return *this; } La función retorna una referencia a un "complejo" a través del puntero 'this'. Los datos 'x' e 'y' del objeto que invoca la función no necesitan un 'this' explicito, esto ocurriría en caso de que el parámetro fuera, por ej, un entero 'x' o 'y'. La línea final, que explicita el retorno de un puntero al mismo objeto que la invoco, es necesaria para detalles relacionados con la concatenación de operadores, la modificación de los datos privados ya se ha hecho en las dos líneas anteriores. 1.3.5 aritmética de punteros Son posibles ciertas operaciones con punteros y los operadores de suma y resta (-,+,--,++). Siendo 'T' el tipo a que apunta el puntero, el siguiente cuadro sintetiza las distintas posibilidades y el tipo de resultado generado:
Puntero entero
Puntero puntero
Operación Puntero a T mas +- entero Puntero a T menos entero Puntero mas puntero +Puntero a T menos puntero a T
Resultado Puntero a T Puntero a T ------------Entero
Comentarios Si el puntero apunta mas allá del limite superior del array el resultado es no definido Si el puntero apunta mas allá del limite inferior del array el resultado es no definido No permitido El resultado indica el numero de elementos T entre los dos punteros
Los punteros son direcciones de memoria pero la aritmética de punteros no es una simple suma o resta de direcciones. Estas operaciones están adaptadas especialmente para tratar con arrays, de may que incrementar en 1 el valor de un puntero no apunte a la próxima dirección de memoria, sino al próximo elemento de un array. El único caso donde 'próxima dirección' es igual a 'próximo 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. Puntero +- entero Es el tipo de operación mas frecuente con punteros, especialmente porque el incremento del puntero en 1 permite recorrer un array elemento por elemento. Hay dos modos de realizar esto, el primero consiste en modificar el valor del puntero, y el segundo en direccionar el elemento igual a [puntero+ entero], procedimiento que tiene la ventaja relativa de no modificar el valor inicial del puntero, que seguirá apuntando al mismo elemento del array. Por ejemplo: long k [4] = {35,34524,543594,354}; long* pk = k; int a;
Metodología de la Programación II
59
Tema 1 //Primer modo ------------------------------------for {a=0;a<4;a++) { printf("%ld",*pk); pk++; } //Segundo modo-------------------------------------for (a=0;a<4;a++){ printf("%ld, *(pk+a)); } En ambos casos se obtiene el mismo resultado, pero al salir del bucle el estado del puntero Serra diferente según la modalidad adoptada. En el primer caso el puntero estará apuntando fuera del array (pk+4), en el segundo el puntero seguirá apuntando al comienzo del array, pues las direcciones sucesivas se tomaban del valor temporal de (pk+a), sin afectar al valor del puntero. En el bucle de la segunda modalidad es importante la presencia del paréntesis, la notación *pk+a tendría un efecto por completo diferente, el operador '*' tiene mayor precedencia que '+', por lo tanto se tomaría siempre el elemento *pk (el primer elemento del array) y luego se le sumaria 'a'. Debe quedar claro que el puntero se puede incrementar con cualquier valor entero, hay rutinas que necesitan tomar un elemento por medio, en tal caso dentro de un bucle tomaríamos los sucesivos valores (p+2). O si necesitáramos obviar los primeros 'n' caracteres de una cadena, y copiar el resto en un buffer, podríamos escribir: strcpy(buffer, cad+n); La suma de enteros a punteros tiene muchas aplicaciones, en especial si se combinan con las librerías standard de C y C++. 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 dirección que almacena), sino el valor almacenado en tal dirección. Es decir, dado un puntero-resultado 'pr', por mas que ese valor desborde el array al que apuntaba, 'pr' Serra previsible mientras que '*pr' no lo Serra, se dice que su valor es 'indefinido'. 1.3.5.1 Puntero - puntero Esta operación da como resultado no un puntero sino un valor entero. Es necesario que ambos punteros sean del mismo tipo, en caso contrario se producirá un error en tiempo de compilación. La resta de punteros tiene la propiedad de que su valor es independiente de los tipos implicados, es decir: dado un puntero 'p1' y otro 'p2' que apuntan respectivamente a los elementos 'n' y 'm' de un array, el valor entero de 'p1-p2' Serra igual a 'n-m', independientemente del tipo a que apunten los punteros. El valor entero del resultado debe ser interpretado como 'numero de elementos (del mismo tipo que los punteros) entre ambos punteros', y no debe ser tomado como una simple resta de valores de memoria. Por ejemplo: int t [] = {45,345,5,354,345}; 60
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ int* pt1 = &t[1]; int* pt2 = &t[4]; int res = pt2-pt1;
//en vez de &t[2] se puede t+2;
El valor de 'res' Serra 3, mientras que en termino de direcciones de memoria la distancia entre ambos punteros es de 6, pues cada entero ocupa 2 bytes. No es necesario involucrarse con demasiados detalles de lo que sucede en memoria, los punteros y su aritmética están adaptados para tratar con direcciones de modo implícito. La resta de punteros no es tan frecuente como las operaciones de incremento pero puede prestar usos valiosos, sobre todo en relación a cadenas de caracteres y junto a librerías standard de funciones. Supongamos que necesitáramos una rutina que extraiga una subcadena de una cadena dada, y que la subcadena estuviera determinada por dos caracteres delimitadores, por ej 'ch1' y 'ch2', el esquema de una función de extracción seria el siguiente: • •
Se setean dos punteros (p1 y p2) apuntando a los delimitadores 'ch1' y 'ch2'. función que realiza strchr() En un bucle se extraen (p1-p2) caracteres a partir de 'ch1'.
En general se recomienda que la aritmética de punteros se realice en el nivel mas simple posible. La principal fuente de error proviene de desbordar los limites (inferior o superior) del array al que apunta el puntero. Estas dificultades no se presentan si se esta tratando con mapeo de memoria, flujos de bytes, análisis sintáctico a nivel compilador o rutinas similares de bajo nivel, pero cuando los datos presenten mayor nivel de estructuración serán necesarias mayores precauciones. Itinerar en un array Supongamos una cadena 'cad' y un puntero que apunta a esa cadena. char cad [] = "hola"; char * ptr = cad; El puntero 'ptr' apunta al primer elemento de 'cad'. Si ahora incrementamos el puntero: ptr++; este apuntara a cad[1], es decir el segundo byte de cad, y si sacamos en pantalla 'p' y '*p' se vera lo siguiente: printf ("%s", p); printf("%c", *p);
// sale "ola" // sale 'o'
Como hemos visto antes, printf() saca en pantalla todo lo que haya desde el char al que apunta el puntero recibido como parámetro hasta el primer '\0' que encuentre. La explicación de lo sucedido es la siguiente: 'p' contiene (como cualquier variable) un valor, este valor es una localidad de memoria, por ej 0xfff2, al incrementar el puntero con p++ lo que hacemos es incrementar el valor que contiene, por eso pasara de 0xfff2 a 0xfff3. Si el puntero hubiera sido tipo entero, el incremento 'p++' habría sumado en 2 la localidad apuntada (0xfff4), pues un entero ocupa 2 bytes de memoria. Metodología de la Programación II
61
Tema 1 El mecanismo es simple y muy eficaz para itinerar a través de un array, pero no solo eso, también nos permite itinerar por cualquier zona de memoria y es el método mas cómodo para hacerlo. 1.3.5.2 Mapear localidades de memoria Casi siempre se mencionan a los punteros en relación a arrays, pero un puntero puede operar de modo totalmente independiente de cualquier array, precisamente para itinerar libremente por regiones de memoria, según Stroustrup (1995), "la implementación de punteros tiene por finalidad mapear directamente los mecanismos de direccionamiento de la maquina en que se ejecuta un programa ". Veamos un ejemplo. Supongamos que nuestro programa opera en modelo small (como la mayoría de los ejemplos dados) y que queremos observar el estado del segmento de datos-stack durante la ejecución del programa. Una función como la siguiente podría cumplir esa función: int función() { char *tt=0; unsigned char ch; int a,fil,col;
//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); } return 0;}
//Representation ascii
La función saca en pantalla los primeros 256 bytes del segmento de datos-stack. Los detalles del bucle de impresión son para que la salida sea similar a la de un editor hexadecimal, en una columna 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 código. En otra columna se exhibirán 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 función. Para observar tal sector, con el código anterior, basta con reemplazar la primera línea debajo del bucle por la indicada en el comentario. El esquema de la función, aplicada a lectura de ficheros (archivos), podría ser de utilidad en una salida a pantalla de un editor hexadecimal.
62
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ 1.3.5.3 Paso de parámetros en funciones Por default las variables declaradas dentro de una función no están disponibles para otras funciones. Cuando necesitamos que una función acceda a datos de otra el principal recurso es el paso de argumentos. El paso de argumentos se hace principalmente a través de la pila (stack), un bloque de memoria especializado en el almacenamiento de datos temporales. El espacio total disponible para uso de la pila varia según el modelo de memoria utilizado por el programa (aparte de las limitaciones de hardware), si nuestro programa utiliza el modelo 'small' de memoria el espacio total para uso de pila y datos Serra de 64 Kb. 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 guardándola en la pila, motivo por el cual no es raro encontrar desbordes de pila provocados por recursiones mal calculadas. Hay muchos motivos para utilizar la pila del modo mas económico posible, y los punteros cumplen una gran utilidad en este caso. Un parámetro puede ser pasado a una función de dos modos diferentes: por valor y por referencia. Pasarlo por valor implica que la función receptora hace una copia del argumento y trabaja con ese doble del original, cualquier modificación realizada en la variable-copia no producirá ningún cambio en el parámetro enviado. En cambio al pasar un valor por referencia estamos pasando la dirección 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 envía el dato y la que lo recibe. Una variable común puede ser pasada por valor o por referencia. En el siguiente ejemplo la función 'f ' recibe dos parámetros pasados por la función '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 función como 'b' están 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 función que recibe el parámetro recibe un puntero al elemento inicial del array. Por ejemplo: Metodología de la Programación II
63
Tema 1 int xstrlen (char* str) { int a=0; while (*str++!=0) { a++; } return a; } //----------------------------void principal () { char cad[] = "hola"; printf ("%d", xstrlen(cad)); .......................................
//alternativa-->
while (str[a]!=0) {a++;}
En el ejemplo, la función xstrlen() nos da el largo de un array de caracteres buscando la posición del '\0' de fin de cadena, y la función 'principal' sacara ese valor entero en pantalla. Obsérvese la sintaxis alternativa para el bucle, en un caso anotamos 'str' como puntero y en el otro como 'array', ambas notaciones son intercambiables. Al pasar un array por referencia, la función receptora solo recibe la dirección 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 parámetro por referencia radica en la posibilidad que tiene la función receptora de alterar todos los datos del parámetro, por esta causa es frecuente que, en la declaración de la función, tal parámetro se declare como "const" para evitar la corrupción accidental de ese dato. 1.3.5.4 Reserva de Memoria dinámica En primer lugar recordemos que es la 'memoria dinámica'. Hay tres formas de usar la memoria en C++ para almacenar valores: Memoria estática. Es el caso de las variables globales y las declaradas como 'static'. Tales objetos tienen asignada la misma dirección de memoria desde el comienzo al final del programa. Memoria automática. Usada por los argumentos y las variables locales. Cada entrada en una función crea tales objetos, y son destruidos al salir de la función. Estas operaciones se realizan en la pila (stack). Memoria dinámica. También 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 operación de reservar y liberar espacio para variables globales, estáticas o locales son realizadas de modo implícito por el programa, la única modalidad que requiere mayor atención por parte del programador es la de reservar memoria en forma dinámica. 64
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ El papel de los punteros en relación a la memoria dinámica es muy importante, por la razón de que al pedir, al sistema operativo, una cantidad determinada de memoria dinámica para un objeto, el sistema nos retorna un puntero que apunta a esa zona de memoria libre, la respuesta dependerá de si hay o no tanto espacio como el solicitado. • •
Si hay suficiente memoria se retorna un puntero que apunta al comienzo de esa zona de memoria. Si no hay suficiente, retorna un puntero nulo.
En C++ los operadores usados para requerir y liberar memoria dinámica son new y delete. La sintaxis es la siguiente: Variable individual Array de elementos individuales Reserva de memoria dinámica int* a = new int int* a = new int [n]; liberación de memoria reservada delete a; delete [] a; Nota: las primeras versiones de TurboC++ no admiten la línea "delete [ ] a" con corchetes vacíos, para liberar memoria dinámica de un array requiere que explicitemos cuantos elementos hay que borrar, explicitando este valor entre corchetes. En la versión TurboC++3.0 esta característica de la sintaxis standard ya se encuentra implementada. Las ventajas de utilizar memoria dinámica se valoran mejor en comparación con las características de la reserva estática de memoria. Reserva estática de memoria Los objetos locales son creados al Creación entrar en la función que los de declara. Los globales son creados objetos al iniciarse el programa. Los objetos locales se destruyen al Duración salir de la función en que han sido de los creados. Los globales, al salir del objetos programa. Al reservar memoria estática 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
Reserva dinámica de memoria La memoria se reserva explícitamente mediante el operador new.
Los objetos necesitan ser destruidos explícitamente, con el operador delete. El indice de un array puede ser un valor variable, de modo que la cantidad de memoria reservada por una línea de código puede variar en tiempo de ejecución (runtime). Ej: int* n = new int [variable no const] //correcto
La estrecha relación que existe entre arrays y punteros explica que la solicitud de memoria dinámica para un array culmine en la devolución de un puntero, una vez que ha sido reservada la memoria suficiente operamos sobre el puntero directamente, de modo muy similar a como operamos con un array. Metodología de la Programación II
65
Tema 1 Los mecanismos de bajo nivel que implementan el uso de memoria dinámica 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 coordinación entre operadores new y delete, sea que olvidemos liberar la memoria que ya no utilicemos, o que intentemos borrar, o utilizar, un objeto ya borrado. 1.3.6 Punteros no inicializados La sola declaración de un puntero, independientemente del tipo (type) a que apunte, no reserva en memoria mas espacio que el necesario para almacenar un valor que representa una dirección de memoria: es decir 2 o 4 bytes. La siguiente línea de código tiene ese efecto. char * ptr; Declarar un puntero de ese modo no es ningún error, el error es olvidar lo siguiente: • •
Que se trata de un puntero 'no inicializado', que puede estar apuntando a cualquier localidad de memoria, tal vez alguna en la cual sea erróneo escribir algún dato (por ej: el comienzo del segmento de datos). Que no estamos reservando ningún espacio extra para asociar un array, una estructura o un objeto a ese puntero.
Es suficiente continuar la línea anterior con una de las siguientes: *ptr = 'a'; strcpy (ptr, "hola"); para cometer un error, es probable que al final del programa aparezca el mensaje 'Null pointer assignment', indicando que se ha sobrescrito una zona 'prohibida' del segmento de datos. Los detalles de tal mensaje de error se tratan aparte, por ahora lo importante es insistir en que un puntero no inicializado es peligroso, pues apunta a una localidad de memoria indeterminada, y que es un error setear una localidad de memoria (desreferenciando el puntero) con un valor sin tener claro de que localidad de memoria se trata, o a que variable se encuentra ligada. Si el puntero hubiera sido inicializado por ej, apuntando a un array, entonces el primer problema, adonde apunta, estaría solucionado. Pero aun podríamos olvidar el segundo, el espacio de memoria reservado debe ser suficiente. Por ejemplo: int main() { int x = 4; //Variable cuyo valor Serra destruido en este ejemplo char cad [] = "hola"; //Reserva estática de 5 bytes (4 mas 1 del '\0') para 'cad' char* ptr = cad; //ptr apunta a cad[0] strcpy(ptr, "casa"); //bien, 'casa' tiene 4 bytes de texto, no excede a 'hola' strcpy(ptr, "Buen dia"); //Mal!, esta cadena excede la capacidad de 'cad'. ........................etc
66
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ La ultima cadena se copiara de todos modos en la dirección apuntada por 'ptr', desbordando la capacidad del array 'cad' para almacenar ese dato, como consecuencia el valor de la variable 'x' Serra destruido. Se trata de un error que no es difícil de cometer, la regla que podemos seguir es tratar al puntero como un 'alias' del array al que apunta, los problemas de desbordar la capacidad del array, escribiendo por ejemplo cad[7]='a', son exactamente los mismos que los de hacer lo mismo con el puntero, solo que por alguna razón es mas fácil cometer el error con el puntero olvidando la capacidad del array al que apunta. En el siguiente ejemplo se visualizara lo que sucede con las localidades de memoria implicadas cuando se produce un error de sobre escritura como el antes mencionado. int main () { int x = 5, y = 4, z = 3; char cad[] = "abcde"; char* ptr = cad;
strcpy(ptr,"Hasta luego");
Como puede observarse se han perdido los valores originales de las tres variables enteras. Las consecuencias concretas de sobrescribir 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). 1.3.6.1 Punteros y literales de cadena Un literal de cadena es un conjunto de caracteres encerrados entre comillas, por ejemplo la cadena "Hasta luego" del ejemplo anterior. Los literales de cualquier tipo son tratados como valores constantes y son almacenados, a diferencia de las variables locales, cerca del comienzo del segmento de datos. Con la línea siguiente: char* ptr = "hola"; se crean dos entidades, no una. Por una parte se reservan 2 bytes para el puntero 'ptr' (para almacenar una dirección), pero también es creada otra entidad, un literal de cadena, que es constante, su contenido aquí es 'hola' y no es modificable en el transcurso del programa. Los bytes reservados por esta línea de código son:
Metodología de la Programación II
67
Tema 1 • •
2 para que el puntero almacene una dirección (aquí la dirección donde se encuentra el literal 'hola') 5 para el literal.
Es importante comprender que un 'literal de cadena' es una entidad diferente a un array de caracteres o a un puntero a char*, es un valor constante y su contenido se almacena en un sector especial del segmento de datos, en la parte inicial del mismo. En los casos en que un puntero es inicializado con un 'literal de cadena' el error es tomar el puntero e intentar copiar algo desreferenciandolo. Algunos compiladores darán un mensaje de error en tiempo de compilación, otros mas antiguos pueden permitir tal copia. Lo recomendable, cualquiera sea el compilador, y permita o no modificar el valor apuntado, es no intentar modificar el contenido apuntado por 'ptr' en ningún caso. Si se necesita modificar el contenido lo mejor es copiar el literal en un array, reservando la memoria suficiente, y operar sobre el array. Ligar un puntero a un literal de cadena no es un error, si lo es el intentar modificar un valor que debe ser tratado como constante. Cuidando estos detalles, declarar un puntero a literales puede ser muy cómodo para manejar cadenas constantes, como las que conforman menús, en estos casos un array de punteros es un buen recurso. char* menu1[] = {"Archivo", "Abrir", "Nuevo", "Guardar", "Guardar como...", "Salir"}; Esto es mas fácil de manejar que un array multidimensional, con "menú[n]" accederemos a cada una de las cadenas, en un estilo muy similar al de lenguajes que conciben las cadenas de caracteres como un tipo (type) propio y no un array. Si las cadenas necesitaran ser modificadas habrá que implementarlo de otro modo, por ejemplo asignando memoria dinámica para su almacenamiento. 1.3.6.2 El mensaje "Null pointer assignment" Un progreso importante en el manejo y comprensión de bugs y mensajes de error es nuestra capacidad de reproducirlos de modo previsible. Por alguna razón el mensaje "Null pointer assignment" es uno de los que mas cuesta reproducir, posiblemente porque existe cierta confusión en torno a la noción de 'puntero nulo'. La traducción literal del mensaje de error seria: "asignación de puntero nulo", o en otros terminos: "se ha asignado un valor a un puntero que es nulo". A continuación veremos en detalle que significa esto, cuales son los pasos que lleva a cabo un compilador (aquí TurboC++1.01) para emitirlo, como podemos reproducirlo de manera controlada, y por ultimo que cuidados podemos tener para evitarlo. Antes que nada recordaremos que es un puntero nulo: es un puntero que apunta a un sitio donde no debe (pero si puede) estar almacenado ningún dato, por así decir, se trata de una zona 'prohibida', se puede leer pero no setear valores allí. De este modo, cuando una función que retorna punteros retorna un puntero "Null", se usara esto como significando 'nada'. Por ejemplo: la función strchr() busca un carácter dentro de una cadena, si lo encuentra retorna un puntero a ese carácter, si no lo encuentra retorna un puntero nulo. Ahora bien, un puntero apunta siempre a algún sitio, y el 'puntero nulo' no es una excepción. Con un compilador Borland y modelo de memoria small o médium un puntero nulo apunta a la dirección 0 del segmento de datos, es decir 68
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ DS::0000. Escribir allí un valor que no sea 0 garantiza la presencia del mensaje de error, pero no es la única localidad de memoria que lo produce. Las condiciones para producir el mensaje de "Null pointer assignment" son las siguientes: • •
El modelo de memoria utilizado por el programa es SMALL o MEDIUM. Se sobrescribe algún valor del comienzo del segmento de datos-stack. Es decir el intervalo compuesto por 4 bytes con valor 0 (cero) mas el copyright de Borland.
Si se modifica algún 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 habría que decir que no es el hecho de 'escribir' allí 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 sobrescribe 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 ningún 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 a donde 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 estamos 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; Metodología de la Programación II
69
Tema 1 } Ejemplo N: 3 - Olvidar que una función ha retornado un puntero nulo #include <string.h> int main () { char* p; char ch = 'a'; char cad[] = "jorge"; p = strchr(cad, ch); //buscamos 'ch' dentro de 'cad'. ................... *p = 'x'; //Como 'ch' no esta en 'cad' strchr() retorno un puntero nulo, ....................etc //que ahora es desreferenciado y escrito. En este ejemplo hemos invocado una función, la misma retorno un puntero nulo y luego, y sin redireccionar el puntero, le damos un valor, como resultado se escribe 'x' en el primer byte del segmento provocando el mensaje de error. Algo similar ocurriría si al solicitar memoria dinámica 'new' retornara un puntero nulo, y operáramos con el mismo sin antes comprobar el éxito de la solicitud. Existen muchísimos modos de producir el mensaje de error, pero todos se basan en lo mismo. Una observación mas: el mensaje de error nos avisa que hemos 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 hemos visto que no es esa la única 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 'asignación de un puntero nulo' la que provoca el mensaje, aunque así lo da a entender "Null pointer assignment", por lo menos así sucede en los compiladores Borland. 1.3.6.3 "Dangling pointers" Este tipo de problemas suscita muchas preguntas en las diversos foros (o Faq's) sobre C y C++, y se presenta con frecuencia en funciones que retornan un puntero. La causa del problema es esta: la función retorna un puntero que apunta a una variable o array declarados como locales, al salir de la función todas las variables locales son 'deallocated', se pierde la conexión entre dirección de memoria y variable, la zona de memoria que utilizaban es liberada, por lo tanto el puntero (al salir de la función) apunta a una 'zona liberada', no ligada con ningún array o variable. La siguiente función 'f1' reproduce el problema: char * f1() { char buffer[128]; //Reserva de memoria estática para variable local cout << "Entre su nombre: "; cin.getline( buffer, 128 ); return buffer; //Retorna como puntero de variable local } int main(){ char* ptr; .................. 70
//Resto del código aquí Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ ptr = f1(); f2();
//El puntero ptr recibe la dirección de 'buffer' //Llamado a una función 'f2' cualquiera
El puntero 'ptr' recibirá la dirección 'correcta', la misma en que estaba almacenada la cadena 'buffer', el problema es que 'buffer', al ser declarada como local, pierde su localización de memoria. El rol que juega la stack (pila) en el llamado a funciones se ilustra en el siguiente grafico:
Vamos a comentar paso a paso la relación entre stack, funciones y el código 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 Serra 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 estamos viendo. II- La función main ( ) llama a la función '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 términos de ensamblador diríamos 'la pila crece (hacia abajo) seteando un nuevo valor de BP (bass pointer) y SP (stack pointer)'. En esas localidades de memoria, estarán los valores de 'f1' hasta que salgamos de la función. III- Salimos de la función f1 retornando un puntero a una variable local. Lo que el puntero retorna es, obviamente, una dirección de memoria, esa dirección 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 función? Mientras no se llame a otra función sus valores pueden perdurar, pero no es algo que un compilador garantice. IV- Llamamos a otra función. En ese momento las localidades de memoria asociadas a las variables de la anterior función 'f1' serán sobrescritas por las variables locales de la nueva función llamada 'f2'. Nuestro puntero a la variable local de f1 seguirá apuntando a la misma localidad de memoria, pero su contenido Serra indeterminado, y Serra muy peligroso usarlo para cualquier propósito (a menos que sea reasignado). La regla practica seria esta: no retornar nunca un puntero que apunte a una variable Metodología de la Programación II
71
Tema 1 declarada como local. Pero entonces, ¿que camino seguir para retornar un array o puntero de modo seguro desde una función? 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': • • •
Declarar a la variable 'static' Reservar memoria dinámica para la variable dentro de la función Retornar el valor utilizando un parámetro de la función llamadora.
Se analizaran 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. Al declarar una variable como 'static' le estamos reservando un sitio especial dentro del segmento que no Serra 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 declaración de la variable: static char buffer[128]; El único inconveniente es que esa zona de memoria no Serra liberada en todo el transcurso del programa, la cantidad de bytes reservados determinara si este recurso es demasiado costoso o no. Reservar memoria dinámica dentro de la función llamada. En nuestro ejemplo seria: char* buffer = new char [128]; aquí Serra responsabilidad del programador liberar la memoria reservada, en caso contrario se producirán '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 técnicamente complejo, al punto de que muchos compiladores no son enteramente eficaces en la liberación de memoria reservada dinámicamente (tardan en hacerlo), existe software 'recolector de basura' (garbage collection) cuya función es liberar zonas de memoria a las que ya no puede acceder ninguna variable en tal punto de un programa. Devolver el valor a través de un parámetro. La variable de retorno no se declara dentro de la función que retorna, sino en la función que llama. Por ejemplo: void f1 (char* buff) { .................. } int main () { ............... char buffer[128]; f1(buffer); .................etc,
72
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ No es necesario retornar explícitamente la variable pues el parámetro 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 único defecto del método radica que puede disminuir ligeramente la legibilidad del código, la función retorna un puntero pero de modo disimulado, el tipo (type) de la función no nos informa nada al respecto. Las funciones de las librerías de C y C++ utilizan en general las dos ultimas alternativas, (2) y (3). 1.3.7 Memoria dinámica Las operaciones de reservar memoria dinámica y liberarla, con new y delete, están enteramente en manos del programador, esto proporciona gran flexibilidad de recursos pero también oportunidad para diversos tipos de errores. Cada vez que aparece el operador new en relación a un objeto debería haber una aplicación del operador delete a ese mismo objeto. Los problemas suelen generarse por dos modalidades de error: • •
No liberar la memoria dinámica reservada para un objeto. Intentar borrar, o desreferenciar, un objeto ya borrado.
1.3.7.1 Memoria no liberada La memoria reservada dinámicamente necesita ser liberada de modo explicito con delete. Si existe un objeto que ya no usamos, y que fue almacenado dinámicamente, nos encontramos frente a una 'fuga de memoria' (memory leak), el caso mas critico se presenta cuando ya no es posible acceder al objeto, pues no Serra posible borrarlo. Durante la ejecución del programa el numero de 'fugas de memoria' puede multiplicarse hasta agotar los recursos disponibles. Un caso muy simple donde no es posible acceder al objeto para liberar memoria es el siguiente: int f (int a) { char* p = new char [a]; return 0; } La función no hace nada interesante, es simplemente el esquema de un error posible. La memoria reservada para el puntero 'p' no ha sido liberada al salir de la función, la dirección apuntada por 'p' se pierde, y no Serra posible liberar esa memoria en ningún sitio. Es el modo mas simple de producir una fuga de memoria. habría sido necesario agregar la línea: delete [] p; antes de salir de la función para que ese monto de memoria hubiera sido liberado. Mientras que es técnicamente posible reservar memoria en una función y liberar esa memoria en otra función, se considera una practica riesgosa, por la posibilidad de olvidar quien tiene la responsabilidad de liberar memoria. Una posible solución es reservar memoria para el objeto en la función llamadora, pasar el objeto como parámetro (por referencia) y retornarlo, así la Metodología de la Programación II
73
Tema 1 responsabilidad de reservar y liberar memoria respecto al objeto estará en manos de una misma función. El esquema seria el siguiente: int f (char* c) { ............... return 0; } int main() { int b = 34; char* t = new char[b]; f(t); ............... delete [] t; ............... Otro modo de provocar problemas es reservar memoria dinámica por segunda vez para un puntero, antes que haya sido liberada la primer reserva. Por ejemplo: void f (int a, int b) { char* p = new char[a]; ...................... p = new char[b]; ..................... aquí la memoria reservada por el primer uso de new ya no podrá ser liberada, pues se ha perdido su dirección. Toda segunda asignación del puntero 'p' sin antes liberar la memoria dinámica asociada a el, producirá fugas de memoria. Otra variación 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: • • 74
La memoria reservada originalmente por 'p' no podrá ser liberada. La memoria reservada por 'q' Serra liberada dos veces (muy problemático). Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Un modo de fallar en liberar acertadamente la memoria reservada con new, en relación 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; //error, solo libera un elemento de 'p' delete [] p; //bien, libera el array apuntado por 'p' 1.3.7.2 Operar con un objeto ya borrado La segunda familia de problemas se produce por intentar desreferenciar o usar un puntero al cual ya se ha aplicado el operador delete. El principal recurso para evitar este problema es (una vez aplicado el operador delete) setear este puntero a NULL, esto protege contra posteriores usos equivocados de delete, pues por convención la aplicación de delete a un puntero nulo no tiene ningún efecto. También es un error desreferenciar un puntero al que se ha aplicado delete sin antes asignarle una nueva dirección. La razón es que el puntero esta apuntando a alguna zona que almacena valores indeterminados, sobrescribir allí puede destruir datos pertenecientes a otras variables o a otras reservas dinámicas. La solución es reasignar una dirección al puntero antes de desreferenciarlo, norma general que también soluciona el problema de los punteros nulos. Los pasos correctos se ejemplifican en el siguiente código: int f () { char cad[] = "hola"; char* p = new char[40]; //Primera reserva de memoria dinámica ....................... delete [] p; //liberación de mem dinámica p = NULL; //Precaucion por posible sobreborrado de 'p' ....................... p = cad; //Nueva asignación *p = 'a'; //Desreferenciacion de 'p' Nunca se debe desreferenciar un puntero sin antes asignarle un valor. 1.3.7.3 Datos miembros punteros y copia de objetos Los datos miembro de un objeto pueden ser inicializados mediante un constructor, una inicialización de copia, o asignación de copia. Suponiendo la existencia de una clase llamada "Clasex" veamos las siguientes líneas: Clasex a; Clasex b; Clase c; Metodología de la Programación II
75
Tema 1 Clasex d = b; //inicialización de copia (constructor copia) c = a; //asignación de copia La sola declaración de los objetos 'b' y 'a', sin parámetros, invoca un constructor por defecto. En la tercera línea 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 'asignación 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 problemático, 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]; } //Constructor default ~Clasex () {delete [] cad;} //Destructor .............. } void f() { Clasex a; //Invoca constructor Clasex b = a; //inicialización de copia - Problemas con el puntero! Clase c; //Invoca constructor c = b; //asignación de copia - Problemas con el puntero! } aquí hay tres objetos "Clasex" en juego. Se trata de objetos locales, por lo tanto el destructor Serra invocado de modo automático tres veces al salir de la función. El primer problema se plantea cuando tomamos conciencia de que, en la función "f ( )", el constructor es llamado solo dos veces (el destructor: tres veces), tanto la inicialización de copia como la asignación 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 a.ch b.ch c.ch por cada objeto a.cad b.cad c.cad Funciones publicas: Clasex::Clasex (int); una copia para todos Clasex::~Clasex(); los objetos La copia de los datos 'x' y 'ch' no presenta ningún 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 también una localidad de memoria para 'su' puntero 'cad', el problema es adonde apuntan esos punteros. 76
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ La copia de punteros (ej, a.cad = b.cad) es copia de las direcciones a la que apuntan. Pero esas direcciones, en nuestro ejemplo, son localidades de memoria reservadas mediante memoria dinámica, por lo tanto, luego de: a = b; Sucederá que los punteros "cad" de ambos objetos apuntaran a la misma zona de memoria reservada con new, y es aquí donde se presenta el problema. Hay tres objetos y por lo tanto tres punteros, cada puntero debería tener sus propios bytes reservados (el default para nuestro ejemplo es 40). Una llamada común al constructor reserva esos bytes, y son diferentes para cada objeto, pero una asignación 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 problemático.
La solución. 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 también proveer de un asignador de copia diferente al default, esto significa que para disponer de la notación "a = b" Serra necesario sobrecargar el operador '=' y darle un sentido diferente, que evite la copia de punteros. La naturaleza del problema puede aclararse con un código que no utiliza clases pero que presenta el mismo error. void f () { char* a = new char[40]; char* b = new char[40]; a = b; //Error! no se debe reasignar sin antes liberar memoria delete [] b; delete [] a; } Cada puntero tiene 'sus' 40 bytes de memoria dinámica, al reasignar "a=b" el puntero 'a' deja de apuntar a la zona de memoria reservada con 'new', esa dirección se pierde y no podrá ser liberada (fuga de memoria). Por otra parte, las dos invocaciones de 'delete' cometen el error de liberar una misma zona de memoria. 1.3.8 Que es una cadena de caracteres en C/C++? En cualquier lenguaje de programación las cadenas de caracteres tienen una importancia especial, no solo porque es el tipo mediante el cual se almacenan los mensajes a pantalla o entradas del teclado, sino porque un carácter (char) es del tamaño de un byte, y un byte es la menor unidad de información 'natural' para la maquina. Una cadena de caracteres es una colección ordenada de Metodología de la Programación II
77
Tema 1 bytes. Un fichero (archivo), la información de pantalla en el sector de memoria de video, las entradas de bytes por los puertos y muchas otras entidades se pueden conceptualizar cómodamente como esto: una colección ordenada de bytes. Es cierto que en muchos casos se adoptan tipos definidos o clases, para una mejor administración de datos, pero aun en estos casos tales estructuras de datos complejas suelen utilizar arrays de tipo char en su nivel mas elemental. Hay lenguajes que tienen un tipo (type) preestablecido para tratar con cadenas de caracteres, es así en las distintas versiones de basic, donde el tipo 'String' es un tipo mas (como 'integer'), y así como existen arrays de enteros hay arrays de strings. En cambio en C/C++ podría decirse que el tipo 'String' no existe como tal (en C++ hay implementaciones de la clase 'String' pero no pertenecen propiamente al lenguaje sino a librerías anexas, dependiendo de cada compilador), el recurso usado por la mayoría de las funciones de estos lenguajes es representar una cadena de caracteres como un array de elementos tipo char, un array de caracteres. El solo hecho de que una cadena (string) sea un array plantea dudas a quien viene de otros lenguajes, por ej: como representar entonces un array de strings? (un array bidimensional no es cómodo de manejar), cual es el largo permitido de una cadena de caracteres? que sucede si ese largo se modifica? como conocer en tiempo de ejecución el limite de ese array? etc. Una cadena de caracteres, representada en memoria, es una simple sucesión de bytes, cada carácter se corresponde con un byte, si queremos sacar en pantalla una cadena de caracteres, el problema es: como sabe el programa donde finaliza esa cadena?, cual es su ultimo byte? A esta pregunta diferentes lenguajes plantean diferentes respuestas, según un modelo de 'string', los dos modelos clásicos son el de Pascal y el de C. •
•
En Pascal el primer byte es reservado para almacenar el largo de cadena, es decir que la cadena de caracteres propiamente dicha comienza en la segunda posición. Si solo hay 1 byte de espacio para almacenar el largo el máximo permitido para una cadena Serra de 255 bytes. Para cadenas de mayor extensión habrá que utilizar algún otro recurso. En C se reserva una función especial al carácter cuyo valor ascii es 0 (cero), ese carácter indicara con su presencia que la cadena finaliza allí, ese char forma parte de la cadena pero por convención no se lo tiene en cuenta al determinar el largo de la cadena. De una cadena como "hola" se dice que tiene 4 caracteres, aunque en memoria luego de 'a' se encuentre el '\0' que forma parte de ella.
En memoria esa celda que indica el fin de cadena tendrá el valor 0, insistimos en que no se trata de un elemento extra (como el EOF de ficheros) sino del char que en el juego de caracteres ascii corresponde al cero. Ambos modelos de cadena presentan ventajas e inconvenientes. En el modelo 'Pascal' es muy rápida la operación de encontrar la longitud de cadena (se consulta el primer byte) mientras que en C/C++ hay que recorrer toda la cadena en búsqueda del char '\0'. Por otra parte una cadena tipo C no tiene ninguna limitación en longitud (salvo las indicadas por el modelo de memoria utilizado o razones de hardware) mientras que en Pascal tendrá un limite dictado por el byte que almacena el largo. El rol que juega el carácter '\0' es absolutamente esencial para comprender y manejar fluidamente cadenas de caracteres en C y C++, y la mayor parte de los problemas y bugs tienen relación con accidentes y descuidos en relación a ese carácter. 78
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Seria un error creer que en C/C++ el largo de la cadena esta registrado en algún sitio y que luego, en un segundo momento, el programa sitúa un '\0' en esa posición, no!, el largo de cadena no existe como dato en ningún sitio. Existe una función standard que nos retorna un entero con el largo de una cadena enviada como parámetro, es strlen(char*), lo que hace esta función es simplemente contar caracteres hasta que encuentra el '\0', para una masa muy grande de bytes se podría pensar en una función menos costosa, pero strlen funciona de ese modo. 1.3.8.1 Array y cadena de caracteres: algunas diferencias En muchos textos sobre C y C++ se encuentra la siguiente afirmación: "En
C/C++
una
cadena
de
caracteres
(string)
es
un
array
de
caracteres"
es claro en que sentido esta afirmación es valida, una cadena de caracteres es un array y no una variable de tipo individual, como un entero o un float, sin embargo hay ligeras diferencias entre los dos conceptos, el de array y el de cadena de caracteres.. Mientras que un array de caracteres: es un conjunto ordenado de 'n' bytes de cualquier valor, una cadena de caracteres es solo un subconjunto de ese array, desde el primer char hasta el primer '\0' encontrado en el array. En realidad no es totalmente exacto decir 'subconjunto', pues si por error nuestro array de caracteres no tiene un '\0', las funciones standard lo buscaran fuera del array, y así la cadena de caracteres llegara a tener mayor extensión que el propio array, desbordando su capacidad. Luego de la declaración-inicialización siguiente: char cad[20] = "hola"; el largo de la cadena de caracteres es 4, valor que se puede obtener con el llamado "strlen(cad)", sin embargo el largo del array es 20, pues siguen siendo 20 los bytes en memoria asociados al nombre 'cad', este valor puede ser obtenido con: sizeof(cad); Este valor de limite de array no impide que se pueda sobrescribir mas allá del mismo utilizando un indice que exceda el sizeof() del mismo (generalmente un grave error), para evitarlo se podría consultar ese valor numérico. Lamentablemente es un recurso que no esta disponible cuando se pasan arrays como argumentos a otra función, pues todo array es recibido por la función llamada a través de un puntero a su elemento inicial. El hecho de que sizeof(cad) y strlen(cad) ofrezcan dos valores diferentes justifica el que se hable de diferencias entre los dos conceptos. A este respecto se pueden formular dos preguntas interesantes: • •
Es posible que una cadena de caracteres tenga dos bytes con valor 0 ? Es posible que un array de caracteres tenga dos bytes con valor 0?
Si uno se atiene a las definición estricta de cadena de caracteres la respuesta a la primera pregunta es 'NO', en una cadena de caracteres hay un solo byte (char) con valor 0, y coincide con su ultimo elemento.
Metodología de la Programación II
79
Tema 1 En cambio, según creo, la segunda pregunta debe responderse afirmativamente, un array de caracteres no cambia de tamaño durante la ejecución de un programa, si lo hemos declarado de 20 char seguirá siendo de 20 hasta el final, hay 20 bytes en memoria que le pertenecen solo a ese array. Y nada impide que dos o mas de esos bytes tengan el valor 0. La cuestión no es solo teórica, supongamos que queremos elaborar un programa para analizar datos de ficheros binarios, por ejemplo de ficheros EXE, en tal caso nos encontraremos con bytes que valen 0 y están en cualquier posición, aquí no tiene sentido el pensar en esas colecciones de bytes como 'cadenas de caracteres', no son 'palabras' ni 'texto', sin embargo queremos hacer un programa que lea y almacene los bytes en un array, y construir funciones que analicen sintacticamente ese flujo de bytes. Algunas funciones standard de lectura de filas detienen cada lectura ante un '\n' o ante un '\0', pero hay otras que permiten leer conjuntos de bytes y los almacenan cualquiera sea su valor, y puesto que son bytes, y estos se corresponden con el tipo char, no tenemos otra opción que almacenarlos en arrays de caracteres. Ahora bien, la mayoría de las funciones de tratamiento de cadenas, como las de string.h, no nos serán útiles, pues interpretan el char 0 como corte de una cadena de caracteres, por lo tanto deberemos construir funciones alternativas. Teniendo en cuenta las diferencias enumeradas respecto a array y cadena de caracteres, podríamos adoptar la siguiente definición: -En C y C++, una cadena de caracteres es un array de caracteres terminado en '\0'. 1.3.8.2 Aseguramiento del fin de cadena Cuando declaramos 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 excepción pues son inicializadas con un valor default por el compilador, pero la mayor parte de los datos serán locales a una función (sea main() o cualquier otra) y por lo tanto no serán inicializados automáticamente. Supongamos ahora las siguientes líneas de código: 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' podría ser 0, 1, 9932, 234, o casualmente 5. Porque razón?, con la declaración de 'cad' hemos declarado un array de caracteres, reservando 5 bytes de memoria estática para ese array, pero no hemos inicializado ningún dato, lo mas probable es que esa región de memoria conserve datos aleatorios de algún programa anterior, el valor que nos dará "strlen(cad)" se basara en haber comenzado a contar caracteres en memoria hasta encontrar el '\0', que podría estar en cualquier sitio, si se encontró en el primer byte inspeccionado el valor de 'largo' Serra 0. 80
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ Si sacamos en pantalla el contenido de esa cadena, sea con printf ("%s", cad) de C, o con cout<<cad de C++, su contenido Serra totalmente arbitrario y muy probablemente veamos caracteres 'extraños', esto ocurrirá porque las funciones de impresión en pantalla también confían en el char 0 para determinar el fin de cadena. Los dos siguientes gráficos muestran un contenido hipotético 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 código anterior, los primeros cinco bytes están 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 función strlen(cad) retornaría el entero 15 (cuenta 15 antes de encontrar el 00), y si se sacara en pantalla con "printf" o "cout" se verían los caracteres de la izquierda (subrayados con rojo).
En cambio, si al declarar el array lo inicializamos, char cad[5]="hola"; o bien en un primer paso lo declaramos y luego copiamos "hola" en la cadena con strcpy() char cad[5]; strcpy(cad, "hola"); en ambos casos estará asegurada la presencia del '\0' que indica el fin de cadena.
Ahora strlen() nos indicaría que el largo de cadena es 4, y las funciones de impresión 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 cómodo con ellas. Las librerías "string.h" y "mem.h" contienen muchas rutinas para tratar con cadenas de caracteres, es necesario conocer en detalle el modo en que cada función trata la cuestión del '\0' final para no encontrarse con sorpresas. 1.3.8.3 Ejemplos de funciones standard Como ejemplo observaremos el modo de operar de "strset" y "memset" y las precauciones necesarias en relación al valor '\0'. Memset (char *cad, int ch, int n); Esta función setea los primeros 'n' char de una cadena al valor pasado como segundo argumento Metodología de la Programación II
81
Tema 1 (ch). Si queremos setear los primeros 5 bytes de una cadena con espacios se podría llamar a la función del siguiente modo: memset (cad, ' ', 5); La operación es muy simple y aparentemente inofensiva, sin embargo !nada relacionado con cadenas en C/C++ es simple!. Esta función no hace nada en relación al '\0', ni lo usa ni lo setea, podría ocurrir que antes de la anterior línea de código el array 'cad' tuviera la cadena "uno", con el cero en su cuarto byte, en tal caso el llamado a memset estaría sobrescribiendo ese cero y el limite pasaría a ser indeterminado. Si nuestro propósito es que la cadena contenga solo esos cinco bytes seseados es muy importante que, luego de haber invocado a memset, nos aseguremos del fin de cadena, por ejemplo con: cad[5]='\0';
//También es posible "cad[5]=0", pues el 0 (entero) //se convierte implícitamente en 0 (char)
podría ser el caso de que tuviéramos muy claro que el fin de cadena esta mas allá de esos 5 bytes y lo quisiéramos conservar, pero si solo nos interesa una cadena con el carácter ascii 'ch' repetido 'n' veces, una buena practica Serra asegurarnos el fin de cadena con "cad[n]=0", para evitar problemas posteriores. Strset (char *cad, int ch); La explicación de ayuda en línea de Borland dice que strset "setea todos los caracteres de una cadena a 'ch' ". Esto significa que si queremos que 'toda' nuestra cadena 's' pase a estar compuesta por asteriscos (ascii=42) podemos escribir: strset (cad, '*');
// es equivalente "strset (cad, 42)"
La diferencia con memset() es bastante clara, aquella era para setear 'n' caracteres y esta para 'toda' la cadena, y may esta el principal peligro, en la palabra 'toda'. Pues esta fusión confía exclusivamente en el '\0' para determinar el fin de la cadena que seteara. Supongamos un programa que comenzara con las siguientes líneas: int main () { char cad[20]; strset (cad, '*'); .....................etc. Estas líneas de código son muy peligrosas, todo depende de si la función encuentra o no un '\0' antes de llenar de asteriscos todo lo que encuentre, hasta llegar al fin del segmento, donde sobrescribiría los valores de stack para salir bien de main() y posiblemente habría que resetear la maquina. La explicación de la función dada por la ayuda de TurboC++ es demasiado escueta y no menciona este tipo de problemas, la enunciación es correcta: "setea toda la cadena" con el carácter indicado, pero es fácil olvidar que significa esto en los lenguajes C/C++, significa "todo lo que encuentre hasta dar con un '\0' " 82
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ podría darse una lista de posibles errores para los usos de cada una de las funciones que involucran a cadenas. Todos están relacionados con el problema del limite del array de caracteres. Se mencionan a continuación algunos de los muchos errores posibles. 1.3.8.4 sobre escritura de variables Ya se vio un ejemplo con strset() de como es posible exceder los bytes que corresponden a un array de caracteres y sobrescribir bytes que no le corresponden a esa variable. Veamos un ejemplo simple que involucra a la función strcat. La función 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 líneas serán problemáticas, 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 función strcat() hará lo que se le pide de todos modos, sobrescribiendo 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 estática o dinámica. 1.3.8.5 Distintos síntomas de sobre escritura de variables Los efectos de sobrescribir una variable son muy variados, dependiendo en parte del tipo de dato sobrescrito y de la operación 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 sobre escritura del dato 'fstream' o el puntero "FILE*". 2. La aparición de caracteres ascii 'extraños' casi siempre se debe a la supresión de un '\0' de fin de cadena. Si se escucha un pitido (beep) esto solo significa que entre los caracteres 'extraños' estaba el ascii =7. 3. sobrescribir 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 típicos con punteros. Son solo algunos ejemplos, los síntomas de sobre escritura de variables son tan variados como las múltiples posibilidades que produciría dar un valor random, de modo no controlado, a uno o mas datos de nuestro programa.
Metodología de la Programación II
83
Tema 1 1.3.9 Modelos de memoria La memoria se puede representar como una colección de celdas contiguas con la capacidad de almacenar valores. Cada celda de memoria es individualizada por una 'dirección', que consta de un valor de segmento y otro de offset (desplazamiento dentro del segmento). Los detalles de como opera la cpu en relación 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 están en el modo en que utilizan los segmentos para almacenar código, datos o ubicar la pila (stack). Al compilar y ejecutar un programa, en el IDE de TurboC++, podemos examinar los registros de la CPU para datos, código y stack, estas son las siglas de tales registros: CS (code seg) Segmento de código DS (date seg) Segmento de datos SS (stack seg) Segmento de pila El modelo de memoria utilizado por nuestro programa determinara cuanto espacio (en termino de segmentos) se usara para código, datos y stack. El siguiente cuadro sintetiza las distintas opciones: Modelo memoria Tiny
Small
Medium
Compact
Large Huge 84
de
Segmentos Comentarios código, datos y stack utilizan un único segmento, por lo tanto el cs = ds = ejecutable no ss podrá ser mayor a 64 Kb. Es muy similar a un ejecutable con extensión .COM Un segmento para código y uno para datos y stack. Es el modelo cs default ds = ss utilizado, a menos que se especifique uno diferente. código usa múltiples segmentos, datos y pila comparten uno. Es cs el modelo ds = ss de elección si hay gran cantidad de código y pocos datos Un segmento para código y múltiples segmentos para datos y stack. cs Modelo apropiado cuando hay poco código pero gran cantidad de ds = ss datos. Los datos son referenciados por punteros 'far' múltiples segmentos para código y múltiples seg para código y cs stack. Se usan ds = ss punteros 'far' para código y para datos cs Similar a 'large' Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ ds = ss Flat
cs ds = ss
Usa punteros 'near' como el modelo 'small', pero hecho a medida para sistemas operativos de 32 bits
Estas categorías no son especificas de un lenguaje de programación, la mayoría 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' también. Cuando escribimos código para librerías, un importante y complejo punto en la implementación 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 librerías 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. 1.3.9.1 Rol de la STACK (pila) La distinción entre código y datos es bastante natural, la sintaxis de C (o Modula-2) obliga a declarar, en una función, primero todos los datos antes de ingresar cualquier operación (código). Pero la noción de STACK tiene una correspondencia menos obvia con lo que observamos en un lenguaje de alto nivel, se trata de algo manejado de modo automático por el compilador. A lo sumo aparecerá en relación a mensajes de error como 'Stack overflow' o 'Desborde de pila' (también 'volcado de pila', en Windows). En programación de bajo nivel (ensamblador) podemos operar directamente sobre la misma. Ahora bien, ¿que es la pila?. Es una zona de memoria (no diferente del resto de memoria) requerida por todo programa (la misma cpu lo requiere) para un uso especial. Su función, sintéticamente, es la de servir para el intercambio dinámico de datos durante la ejecución de un programa, principalmente para la reserva y liberación de variables locales y paso de argumentos entre funciones. El espacio utilizado para uso de la pila variara según el modelo de memoria que utilice nuestro programa. Cuando un programa utiliza el modelo de memoria SMALL usa un mismo segmento para código y stack, 64 Kb entre ambos. Suponiendo que nuestro programa opera con tal modelo de memoria, en la mayoría de los compiladores de BorlandC++, el segmento de datos/stack presentara el siguiente aspecto, luego de ingresar en la función main() de un programa cualquiera:
Metodología de la Programación II
85
Tema 1 El inicio del segmento(0x0000) contiene una cadena de Copyright de Borland que no debe ser sobrescrita (pues daría el mensaje "Null pointer assignment"), luego se ubican las variables globales y constantes. Los literales, sean 'de cadena' o 'numéricos' son tratados como constantes y almacenados en la parte baja. Al final de la pila (desde 0xFFFF) se guardan datos fundamentales para una buena salida del programa, y debajo se extiende una zona usada para almacenar variables locales y datos pasados como parámetros, por lo tanto es la parte mas dinámica del segmento (en el grafico la parte en blanco). El espacio total del segmento es de 64 Kb, esto significa que el monto de datos que podemos pasar a una función Serra un poco menor pues hay espacio ocupado por otros elementos. Esta limitación se puede sortear utilizando otro modelo de memoria, pero por ahora nos centraremos en nuestro 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 guardándola 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 económico posible, y los punteros cumplen una gran utilidad en este caso, por ej al pasar arrays, estructuras u objetos entre funciones a través de una dirección (solo 2 bytes). Otros detalles en relación a punteros. Todo puntero que este dentro de este segmento y apunte a otra dirección del mismo segmento Serra un puntero 'near', para apuntar a un segmento diferente deberemos (en modelo small) explicitar un puntero 'far'. Una cuestión interesante es la de si la memoria dinámica se almacena en este segmento o en algún otro. Los detalles en la implementación de memoria dinámica 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 estará 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. 1.3.9.2 Gestión de memoria en C++ Todas las variables, arrays, punteros y objetos en general tienen una duración determinada en el transcurso del programa. Tales objetos son 'creados' y 'destruidos', o en otros términos: se asocian sus nombres (identificadores) a una zona de memoria en la cual no puede asentarse otro objeto, y tales zonas de memoria son liberadas para el uso de otros objetos. 86
Dpl. Ing. Carlos Balderrama Vázquez
Punteros en C/C++ La existencia de tales objetos esta determinada según tres formas básicas de usar la memoria en C++. 1-Memoria estática Los objetos son creados al comenzar el programa y destruidos solo al finalizar el mismo. Mantienen la misma localización en memoria durante todo el transcurso del programa. Estos objetos son almacenados (en compiladores Borland) al principio del segmento de datos. Los objetos administrados de este modo son: variables globales, variables estáticas de funciones, miembros static de clases, y literales de cualquier tipo. 2- Memoria automática Los objetos son creados al entrar en el bloque en que están declarados, y se destruyen al salir del bloque. Se trata de un proceso dinámico pero manejado de modo automático por el compilador (no confundir con memoria dinámica). Tales objetos se almacenan en la parte alta de la pila al entrar en la función o bloque. Este procedimiento se aplica a: variables locales y argumentos de función. 3-Memoria dinámica En este caso tanto la creación como destrucción de los objetos esta en manos del programador, a través de los operadores 'new' y 'delete'. El sitio donde se almacenan tales objetos se suele denominar en ingles 'heap' o 'free store', traducido como 'montículo' o 'memoria libre'. Pero el sitio preciso donde se encuentre tal 'montículo' depende del compilador y el tipo de puntero utilizado en la reserva de memoria dinámica. Cualquier tipo de objeto puede ser creado y destruido a través de este procedimiento. En C y C++ la administración explicita de memoria por parte del programador juega un rol muy importante, no es así en otros lenguajes (Basic, Smalltalk, Perl) donde la gestión principal es automática. La administración 'manual' permite un mayor grado de flexibilidad pero también multiplica la posibilidad de errores. Un modo de gestionar memoria dinámica en C++, aprovechando las ventajas de la memoria automática, es la implementación de destructores que sean llamados de modo automático al salir de un bloque, y que se encarguen de la liberación de memoria dinámica. 1.3.9.3 'R-value' y 'L-value' La forma mas simple de almacenar un valor en una dirección de memoria es a través de asignaciones. A veces, ante una asignación fallida, aparece el mensaje "Lvalue required", veamos que significa. Las expresiones "R-value" y "L-value" ("rigth-value" y "left-value"), originalmente significaban algo que puede estar a la derecha o a la izquierda de una asignación. Por ejemplo: int x; 4 = x;
//Error en tiempo de compilación: "Lvalue required" Metodología de la Programación II
87
Tema 1 El compilador nos informa de que se requiere un 'l-value' a la izquierda de la asignación. El '4' es un literal numérico, y los literales son tratados como valores constantes, no pueden estar en esa posición. Ahora bien, ¿como definir "l-value"?. T. Jensen lo define como: "valor de la dirección de una variable", y Stroustrup como: "algo que esta en la memoria, que ocupa una región continua de memoria". Esto ultimo se ilustra con las siguientes líneas: int a, b=4, c=5; a = b + c; el valor de 'a' Serra 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 "lvalue", por esa razón: b + c = a;
//Error. "Lvalue required"
nos da ese mensaje de error. A pesar de la definición de 'lvalue', como aquello "que puede estar a la izquierda de la asignación", 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 asignación.
88
Dpl. Ing. Carlos Balderrama Vázquez