Autor Tema: Proyecto de Curso (Dictado - Notas): Programación en C (2013, por Argentinator)

0 Usuarios y 1 Visitante están viendo este tema.

12 Octubre, 2013, 04:20 am
Respuesta #50

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
50. Expresiones constantes enteras. Enumeraciones. Tipos enteros

1.Operadores, operandos.

Necesitamos algunas formalidades para continuar.

\( \bullet \) Operador: Es "algo" que realiza una operación sobre ciertas entidades.

   Esa definición es muy abstracta, y parece no decir nada. Es una definición mía, que se ajusta a la que da el estándar C99. No doy la definición tal cual como está en el documento estándar, porque se necesitan conceptos que aún no hemos visto. Así que le estoy dando algunos rodeos al asunto.
   En realidad, si dijéramos qué o cuáles son las operaciones que se mencionan, y cuáles son las entidades a las cuales se aplican, tendríamos una certeza absoluta de a qué nos estamos refiriendo.

   Dejaremos sin definir el término operación, hasta muchos posts más tarde.

   A partir de ahora ojo con las definiciones: muchas de ellas son recursivas y se mezclan varios conceptos entre sí.

   La mayoría de los puntuadores actúan, en ciertos contextos, como operadores.
   Esto quiere decir que, en general, en C vamos a asociar operadores a ciertos signos, que son los que en post anteriores hemos denomimnado puntuadores.
   No obstante, esto no descarta la posibilidad de que existan otro tipo de operadores (por ejemplo, el cast implícito).

\( \bullet \) Operando: es toda "entidad" sobre la que actúan un operador.

   Típicamente, estas entidades serán constantes o variables, y también expresiones formadas con ellas.



2. Expresiones constantes

   En este post nos basta introducir el concepto de expresión constante entera, que es un caso particular de expresión constante aritmética, que a su vez es un caso particular de una expresión constante, y que finalmente es un caso particular de una expresión.
   Aqui sólo definiremos las expresiones constantes enteras, y postergaremos a las demás, esperando que eso no cause un problema al lector.
   Se puede construir una expresión constante entera en forma recursiva, a base de subexpresiones que también sean expresiones constantes enteras.
   Escollo: una expresión constante entera puede contener también algunas subexpresiones que no son constantes enteras (ver ítem (5) abajo), ni siquiera constantes (ver aclaración al final).
   Iremos con cuidado.
 
\( \bullet \) Expresión constante entera: Será definida en forma recursiva, así:

            (0) Las expresiones, expresiones constantes, y las expresiones constantes aritméticas en general, se definirán más adelante en el curso. Pero un caso particular de todas ellas son las expresiones constantes enteras.
            (1) Una constante entera (decimal, octal o hexadecimal [ver párrafos 5 y 6 en Enteros en C]) o de caracter o de caracter extendido ([ver Caracteres en el lenguaje C (III)]) es una expresión constante entera.
            (2) Una expresión entre paréntesis (c), donde c es una expresión constante entera, es también una expresión constante entera.
            (3) Una constante de enumeración (esto lo vemos abajo, y es más o menos parte de toda esta definición, en forma recursiva) es también una expresión constante entera.
            (4) Con sizeof:
                (a) Una operación sizeof (T), donde T es un tipo de datos cuyo tamaño es predecible durante la etapa de compilación;
                (b) una operación sizeof c donde c es una expresión cuyo tipo tiene tamaño predecible durante la etapa de compilación;
                son ambas casos de expresiones constantes enteras.
            (5) Un cast explícito (T) c donde T es un tipo enteros (ver abajo), y donde c es una constante aritmética de punto flotante (literal), es también una expresión constante entera.
            (6) Una operación unaria ~c, !c, +c, -c, donde c es una expresión constante entera, también es una expresión constante entera.
            (7) Una operación binaria a*b, a/b, a%b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
            (8) Una operación binaria a+b, a-b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
            (9) Una operación de bits a<<b, a>>b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (10) Una operación binaria a<b, a>b, a<=b, a>=b, donde a y b son expresiones constantes enteras, es una expresión constante entera.
           (11) Una operación binaria a==b, a!=b, donde a y b son expresiones constantes enteras, es una expresión constante entera.
           (12) Una operación de bits a&b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (13) Una operación de bits a^b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (14) Una operación de bits a|b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (15) Una operación binaria a&&b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (16) Una operación binaria a||b, donde a y b son expresiones constantes enteras, es también una expresión constante entera.
           (17) Una operación ternaria q?a:b, donde q, a y b son expresiones constantes enteras, es también una expresión constante entera.

   A una expresión encerrada entre paréntesis se le llama subexpresión.

   El operador sizeof da el tamaño en memoria que ocupa un tipo de datos, o el de un valor resultante de una expresión dada. El resultado siempre es un entero no negativo de tipo size_t.
   Salvo honrosas excepciones, sizeof da como resultado un número que puede predecirse en la etapa de compilación. En ese caso, se considera que es, o forma parte de, una expresión constante entera.

   En el ítem (5) hemos mencionado la generalidad de que T sea un tipo entero.
   Los tipos enteros incluyen a los tipos básicos que son enteros, pero incluyen otros más: los tipos enumerados, el tipo size_t, y otros tipos enteros definidos por el usuario.

   Al hacer un cast explícito, el operando tiene permitido ser no entero, pero debe ser una constante (o literal) de punto flotante.
   No se permiten operadores en este caso, aún si luego se convierten a entero. Por ejemplo, (int) (3.14 + 4.1) no sería válida.
   Sólo algo así estará permitido: (int) 3.14. Desde el punto de vista del compilador, "3.14" no necesita representarse como valor de punto flotante, pues ya sabe que va a ser convertido a su parte entera. (Se queda con la parte entera "3" al convertir a int).
   Interpretando estrictamente el estándar, no se permite algo como (int) (3.14) porque los paréntesis en torno a "3.14" son un operador. Mas, es "lo mismo" que (int) 3.14, incluso respecto la interpretación de un compilador competente.
   Algunos expertos opinan que esto es un error en el estándar, y que conviene considerar el caso de "cast" + "paréntesis" + "constante literal de punto flotante" como un caso válido de expresión constante entera. GCC así lo hace, y no es descabellado.

   En los demás casos, todos los operandos tienen que ser de tipo entero.
   Esto es una muy fuerte restricción que el estándar C hace al concepto de expresión constante entera
   Es un concepto que tiene su razón de ser en varios aspectos del lenguaje, que de a poco descubriremos.

Excepción: Cuando una subexpresión no se evalúa jamás (lo cual puede reconocerse en la etapa de compilación), puede formar parte de una expresión constante entera.
   ¡Agreguemos esto a las reglas anteriores!

   Para completar el concepto de expresión aritmética entera, nos hace falta definir las constantes de enumeración.
   Ellas son identificadores que aparecen en la lista de enumeración de una declaración enum. Esto sigue a continuación.

   Notemos que, si bien hemos definido sintácticamente cuáles son las expresiones constante enteras, no hemos dicho nada de su semántica, es decir, del valor que se obtiene como resultado tras evaluar esas operaciones. Postergaremos esto para más adelante, pues requiere un análisis minucioso.
   Anticipemos sólo que tal resultado es un valor en el rango de valores de uno de los tipos enteros de la implementación local.

\( \bullet \)Valor de una expresión constante entera: Este término no existe propiamente en el estándar C, pero nosotros lo usaremos para referirnos al resultado aritmético de una expresión constante entera.



3. Enumeraciones

   Una enumeración es un conjunto de valores enteros constantes que tienen un nombre (dado por un identificador, como en la Sección 39).
   Un especificador de enumeración es una declaración del siguiente tipo:

enum nombre {id1 = valor1, id2 = valor2, ..., idN = valorN} ;

   Primero va la palabra clave enum.
   Luego sigue un identificador, que denotamos "nombre", que es el nombre de la enumeración.
   Entre llaves { } va una lista de identificadores, que hemos denotado "id1, id2, ..., idN".
   Cada uno de esos identificadores se define igual a un valor de tipo entero expresado como una expresión constante entera, que hemos indicado aquí con "valor1, valor2, ..., valorN".
   Se acaba la declaración con punto y coma: ; (hay otras posibilidades...).

   Las expresiones constantes enteras "valor1, valor2, ..., valorN", son tal como las definimos en el párrafo 2.
   Deben ser tales que su resultado es un valor de tipo entero que sea representable en un int.
   Esto quiere decir que puede ser un entero de cualquier tipo, siempre y cuando el valor quepa en el rango de valores del tipo int.
   Si la expresión que define el valor da un resultado más allá del rango de valores de int, entonces se produce una violación de restricción, que significa un error durante la fase compilación: el programa no compilará.
   ¿Podemos especificar un valor como 35LL? Es decir, estamos forzando una constante pequeña 35 a ser de tipo long long int. En ese caso, el valor sigue siendo aceptado, porque no importa el tipo, sino que el valor 35 esté en el rango de valores de int (que de hecho lo está).

   Los identificadores "id1, id2, ..., idN" se denominan constantes de enumeración, y se consideran constantes enteras de tipo int (ver ítem (2) del párrafo 2). Se utilizan en cualquier contexto donde sea válido un valor de tipo int.
   En particular, si el "valor" asociado al "identificador" no tenía tipo int, se produce una conversión a int. Esta conversión no cambiará el valor, porque la expresión constante que definía el valor, estaba ya en el rango de un int.
   Si tenemos un valor como -35LL, que es de tipo long long int, no produce cambios al convertirlo a tipo int, al menos en lo que al valor se refiere, pues se convierte a un mero -35, esta vez de tipo int.

   En adelante, queda definido un nuevo tipo entero, de los llamados tipos enumerados, el nombre completo del tipo sería éste:

enum nombre

   Este tipo será compatible, o bien con char, o bien con alguno de los tipos enteros con signo, o bien con alguno de los tipos enteros sin signo. La elección del tipo compatible la hace el compilador.
   Esto de "compatible" se entiende en el sentido de "tener el mismo rango de valores".
   Por ejemplo, si el tipo "compatible" fuera char, y si no se hubieran definido constantes para todos los valores posibles en el rango de char, aún así esos valores se consideran admisibles en el rango del tipo enumerado recién definido.
   El único requisito sobre este tipo compatible es que su rango de valores sea capaz de alojar todos los valores declarados en la enumeración.
   Así, aunque los valores y los identificadores son de tipo int, puede que el tipo enumerado sea compatible con un tipo de rango más pequeño :o que int (por ejemplo, signed char), o incluso un tipo sin signo (ejemplo: unsigned long).
   La elección de este tipo compatible la hace el compilador según su propio criterio, ya sea para optimizar espacio, o alguna otra cosa. Así, no siempre puede el programador predecir el tamaño en memoria de los objetos de un tipo enumerado dado.

   Una vez que estas constantes de enumeración ya las hemos hecho aparecer, podemos usarlas como parte de expresiones constantes enteras que definan otras constantes ¡en la misma enumeración! (ver item (3) del párrafo 2).
   Sin embargo, el tipo de datos enum nombre no se considera "definido" hasta que se ha cerrado la llave que termina la lista de constantes de enumeración.

   Es posible definir implícitamente los valores de las constantes enumeradas.
   Si ponemos aisladamente un identificador, sin que le siga un signo = y la expresión constante entera, entonces su valor se fija en exactamente el mismo valor de la constante previa en la enumeración aunque aumentado en 1.
   Si se tratase del primer identificador en la enumeración, entonces su valor se fija a 0.
   Por ejemplo, la siguiente enumeración:

enum fibolist {fibo1, fibo2, fibo3 = fibo2, fibo4, fibo5, fibo6=5, fibo7=fibo6+fibo5} ;

Produce constantes de enumeración tales que:

fibo1 vale 0 (porque se define implícitamente como 0, al ser el primer miembro de la enumeración).
fibo2 vale 1 (porque aumenta en 1 el valor anterior).
fibo3 vale 1 (porque se lo definió igual a fibo2).
fibo4 vale 2 (porque aumenta en 1 el valor anterior).
fibo5 vale 3 (porque aumenta en 1 el valor anterior).
fibo6 vale 5
fibo7 vale 8 (la suma de 5 y 3).

   Observamos que los valores 0, 1, 1, 2, 3, 5, 8, están en el rango de un int, y por tanto son todos válidos.
   Se convierten a valores de tipo int al ser asignados a las constantes fibo1, fibo2, fibo3, fibo4, fibo5, fibo6, fibo7.
   Esos identificadores funcionarán todos como constantes de tipo int cuando se los introduzca en expresiones.
   El tipo enumerado recién definido tiene nombre: enum fibolist
   Este tipo es compatible con algún tipo entero, pero no sabemos cuál será. Sólo sabemos que puede ser cualquiera que contenga a los valores del 0 al 8 en su rango de valores.

   Veamos un ejemplo quisquilloso.
   Por ejemplo:
enum prueba {grande = INT_MAX, masgrande = grande+9} ;
   Esa declaración no es válida, porque el compilador tratará de encajar el valor grande+9 en un tipo mayor que int, digamos long int o long long int.
   Al salirse del rango de un int, se produce una violación de restricción, dando un error de compilación.

   Algo más ingenioso podría ser lo siguiente:
enum prueba {grande = INT_MAX, masgrande} ;
   Aquí no estamos definiendo la constante masgrande a través de una expresión constante entera, sino sólo en forma implícita.
   El valor que le corresponde es 1 más el valor anterior, que era INT_MAX.
   Este valor de nuevo está fuera del rango de un int. El estándar es flojo en aclarar este punto, pero debemos suponer que aquí la intención del estándar es la misma que antes: que se produzca una violación de restricción, causando un mensaje de error de compilación.
   Es decir, es una declaración prohibida.

   ¿Puede aparecer un mismo identificador en dos enumeraciones distintas? Ejemplo:

enum listaA {up = 8, down = 2, right = 6, left = 4} ;
enum listaB {center, justify, left} ;

   La respuesta es negativa.
   Se producirá un error, porque estaríamos intentando redefinir la constante left.



4. Primer uso de typedef

   En general typedef se usa para definir nuevos tipos en C, mediante complejas construcciones.

   Por ahora digamos que con la palabra clave typedef es posible dar nombres sustitutos para los tipos enteros que hemos estado estudiando.
   La sintaxis genérica que usaremos será:

typedef tipo_entero_existente nuevo_nombre_tipo;

   O sea: primero se pone la palabra clave typedef, luego el nombre de algún tipo entero que ya existe de antes, que puede ser alguno de los tipos básicos, char, enumerados, o incluso tipos enteros previamente definidos por el usuario con declaraciones typedef. Finalmente, un identificador que será el nombre que elegimos para nuestro nuevo tipo de datos entero.

   Ejemplos:

typedef unsigned int entero_sin_signo;
typedef _Bool booleano;
typedef long double _Complex complejos_que_estan_cool;
typedef enum conjuntosemana {Domingo = 1, Lunes, Martes, Miercoles, Jueves, Viernes, Sabado, Feriado = 1000} dia_semana;

   Hemos definido los tipos aritméticos: entero_sin_signo, booleano, complejos_que_estan_cool, dia_seamana.

   Esto nos permite definir objetos con esos nombres como su tipo de datos:

entero_sin_signo w;
booleano flag0;
complejos_que_estan_cool z;
dia_semana d;


   ¿El tipo entero_sin_signo que hemos definido nosotros, es sinónimo de unsigned int.
   La respuesta es: .
   Un tipo de datos definido con typedef no introduce un nuevo tipo de datos, sino un sinónimo para un tipo ya existenten.

   Lo que se obtiene en estos casos es una redundancia que aporta algo más de significado a un programa dado.
   (Puede confundir esto de ir renombrando tipos preexistentes, pero es una técnica que no debemos menospreciar).

   Observemos que la sintaxis de typedef es análoga a la usada en la declaración de variables, en que un tipo de datos ya preexistente se pone primero en la declaracición, y el nuevo identificador que estamos definiendo aparece al final.

   Ahora podemos decir que hemos completado la especificación del ítem (4) del párrafo 2, pues allí se hacía referencia a un cast explícito hacia un tipo entero cualquiera, que podía ser inclusive uno de estos definidos por el usuario.

   Los tipos enteros definidos en <stdint.h> se declaran todos mediante typedef (ver Secciones 8 y 9). Esto está impuesto así por el estándar C99.
   ¿Qué consecuencias trae el hecho de que estos tipos estén obligatoriamente definidos con typedef?
   Claramente esto implica que sus rangos de valores y demás comportamientos son compatibles con algún tipo entero que ya existe previamente en la implementación, ya sea como un tipo estándar o uno extendido.



5. Tipos enteros exóticos.

   En las Secciones 8 y 9 vimos estos otros tipos enteros:

wchar_t    wint_t
size_t
ptrdiff_t
sig_atomic_t


   El estándar los nombra en todas partes como tipos enteros. Sin embargo no aclara si deben definirse mediante typedef.
   Esto implica que algunos de estos tipos pueden ser ellos mismos tipos enteros extendidos (o sea, no estándar), definidos directamente por la implemmentación local.

   En cualquier caso, ellos pueden aparecer como el tipo entero de un cast explícito en el ítem (4) del párrafo 2. :o

   En la librería estándar <time.h> se definen los tipos:

clock_t
time_t


   Esos tipos son aritméticos, pero en ninguna parte el estándar especifica si son enteros o de punto flotante. Eso depende de la implementación local.
   Sólo en el caso de que fueran tipos enteros, podrían ponerse en el ítem (4) de las expresiones constantes enteras.



6. Tipos enteros

\( \bullet \) En conjunto, los tipos enteros básicos, el tipo char, los tipos enumerados (aquellos inventados a través de declaraciones enum), se llaman en conjunto tipos enteros.



Organización

Comentarios y Consultas

20 Octubre, 2013, 04:15 am
Respuesta #51

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
51. Tipos en C, derivados a partir de los tipos aritméticos. Parte I: Tipos compuestos.

   En este post vamos a estudiar una gran variedad de tipos derivados, pero no vamos a discutirlos a todos ellos.
   Todavía una buena parte de ello nos van a quedar fuera.
   Solamente vamos a enforcarnos en los tipos derivados que pueden obtenerse de forma recursiva a partir de los tipos aritméticos.
   Esto abarca los tipos asociados a objetos.

Tipos aritméticos

   En la Sección 43 logramos definir los tipos básicos.
   En la Sección 50, tras introducir las expresiones constantes enteras fuimos capaces de definir los tipos enumerados, y así completar la colección de todos los tipos enteros.
   Ahora podemos proseguir con el estudio de los tipos en C.

   La colección de los tipos enteros junto con los tipos reales (float, double y long double) definen la clase de los tipos reales.
   Si a los tipos reales añadimos los tipos complejos, obtenemos la colección de los tipos aritméticos.

   A partir de ahora, los tipos en C sólo pueden definirse de forma recursiva.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Tipos "compuestos"

   Los tipos derivados son aquellos que se "construyen" a partir de tipos preexistentes.
   Estos tipos de datos en C pueden extenderse ad infinitum.
   Hay algunas formalidades que impiden que demos la definición completa aquí.
   Por ahora hablaremos de "tipos compuestos", un concepto más o menos informal, que no llega a cubrir todos los casos.
   Podemos formar "tipos compuestos" con los siguientes métodos:
   
\( \bullet \) Arrays: Un  bloque de objetos de un mismo tipo.
\( \bullet \) Estructuras: Una lista de objetos de distintos tipos (que ocupan posiciones disjuntas de memoria).
\( \bullet \) Uniones: Una lista de objetos de distintos tipos que comparten una misma posición de memoria.
\( \bullet \) Punteros: Posiciones de memorias de objetos dados.

   Esto es recursivo, ya que es posible construir "arrays de estructuras", "punteros de uniones", y un largo etcétera.

   Hasta ahora tenemos a nuestra disposición los tipos aritméticos solamente. A partir de ellos construiremos todos los demás.
   Veamos la manera de hacer con toda formalidad en C99.


CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Organización

Comentarios y Consultas

20 Octubre, 2013, 04:18 am
Respuesta #52

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
52. Tipos en C, derivados a partir de los tipos aritméticos. Parte II: Arrays.

Arrays

   Supongamos que tenemos un cierto tipo que denotamos en abstracto como T.
   Supongamos que tenemos un valor entero positivo que denotamos en abstracto como N.
   \( \bullet \) Se define un array de tipo T de N elementos como un objeto (en memoria) que contiene exactamente N objetos de tipo T, y tal que todos ellos se alojan de forma "contigua".
   \( \bullet \) Un tipo array de T comprende a todos los objetos posibles que son construidos como arrays de tipo T.
   \( \bullet \) Cada tipo array está caracterizado por dos informaciones concretas: el tipo T de cada elemento y el número N de elementos.
   \( \bullet \) Se denomina a T el tipo de los elementos del array. Se dice que el array es un tipo derivado de T, y se habla del tipo como de un array de T.
   \( \bullet \) A este proceso de construcción de tipos se le denomina derivación de tipos array.

   Nota: El estándar C habla de un "conjunto no vacío de objetos de tipo T". He seguido otro camino, describiendo los objetos array, para luego definir los tipos array, y por eso he preferido hablar de "valor entero positivo N" para indicar el número de objetos del array.

   Si dos tipos array tienen tipo de elemento distinto, o tienen número de elementos distinto, se consideran tipos diferentes.
   Así por ejemplo, un array de int de 5 elementos es diferente de un array de int de 7 elementos.

   La declaración de un objeto, digamos A, de tipo array de T de N elementos se puede hacer de la siguiente manera:

T A[N];

   El número N "puede" indicarse mediante una expresión constante entera, tal como explicamos en la Sección 50.

   Hay muchas otras maneras de declarar arrays, pero por ahora hay que postergar los detalles.

   Ahora se puede calcular el tamaño del objeto en memoria, mediante:

sizeof A

   El resultado de esa operación es el valor entero N (siempre de tipo size_t).

   Llamamos referencia de un objeto a su posición en el espacio de almacenamiento.
   En la jerga de programación se le dice dirección (de memoria) a las referencias.

   ¿Cuál es el valor de un objeto de tipo array de T? ¿La colección de sus elementos, o su referencia?
   En teoría, el valor de "carne y hueso" del array es la colección (ordenada) de todos sus elementos.
   Pero en la mayoría de las expresiones "se convierte" a la referencia (o dirección de memoria) del array, que coincide con la dirección del primer elemento de ese array.

   El valor del objeto A es "considerado", en la mayoría de los casos, como la referencia al objeto A.

(Cuál es esa "mayoría" de situaciones en que el array "decae" a su referencia, es algo que precisaremos con posterioridad).

   En cambio, siempre es posible acceder a y modificar los valores de los elementos individuales del array.
   Se supone que los valores de los elementos de un array están indexados, comenzando desde el índice 0, y siguiendo luego en secuencia con números enteros no negativos, hasta N-1, donde N es el tamaño del array.
   Para acceder al elemento en la posición J (donde J denota el valor de cualquier objeto de tipo entero), se usa la expresión:

A[J]

   Importante: Esto no quiere decir que la única forma de acceder a un elemento de un array sea esa. Hay otras formas, que de a poco iremos desentrañando, pues son joyas del lenguaje.

   La dirección (o referencia) de un array no puede modificarse.
   O sea que no puede aparecer del lado izquierdo de un operador de asignación.
   Sin embargo, sí es posible modificar cada uno de los elementos individuales del array.

   Una expresión como la siguiente no es válida para arrays A y B:

A = B;   // Prohibido, pues es una expresión que involucra las direcciones de memoria de los arrays

   Una expresión como la siguiente sí es válida para elementos de arrays A y B:

A[3] = B[7];


   Se pueden declarar arrays de arrays, arrays de arrays de arrays, y así sucesivamente:

T A[N1][N2]...[Nk];

   En la línea anterior los puntos suspensivos son meta-sintaxis, que indica que podemos poner una cantidad k de corchetes, donde k es un entero positivo, y cada número N1, N2, ..., Nk, es un entero positivo, indicando los sucesivos tamaños.
   El objeto resultante es una secuencia de N = N1 . N2 . ... . Nk elementos contiguos en el espacio de almacenamiento, de tipo T.
   Podemos imaginarlo como una hipermatriz k-dimensional, de dimensión \( N1\times N2\times ... \times Nk \).
   Para acceder a un elemento de un tal array multidimensional debemos usar enteros no negativos que accedan a los k índices:

A[J1][J2]...[Jk]

en donde J1, J2, ..., Jk son enteros no negativos, menores respectivamente que N1, N2, ..., Nk.

   Más aún, podemos pensar en estos objetos como arrays de arrays de arrays de....
   Por ejemplo, la declaración:

T A[N1][N2];

crea un objeto en memoria que es un array de N1 elementos de tipo T, donde T es el tipo array de N2 elementos de tipo T.
   Un ejemplo aún más concreto:

int A[3][5];

   Allí A es un objeto del tipo array 2-dimensional de tipo int de tamaño \( 3\times 5 \).
   Pero más aún, podemos decir que A es un array (1-dimensional) de 3 elementos, tal que cada elemento es de tipo array de 5 elementos de tipo int.

   Quizás podamos verlo más claramente si utilizamos declaraciones typedef.
   Con typedef podemos declarar tipos que son arrays específicos, de un tamaño y elementos de un tipo dado.
   Consideremos esta secuencia de declaraciones:

typedef int arrint5[5];
arrint5 A[3];

   La 1er línea declara el tipo arrint5, que consiste en arrays de tamaño 5, cuyos elementos son de tipo int.
   La 2da línea declara el objeto A, que es un array de tamaño 3, cuyos elementos son de tipo arrint5.
   Esto es equivalente a haber declarado A como un array 2-dimensional de tamaño \( 3\times 5 \), cuyos elementos son de tipo int.

   Tras reflexionar cuidadosamente sobre esto, generalizamos diciendo que:

typedef T arrayT_N2[N2];
arrayT_N2 A[N1];

es equivalente a la declaración T A[N1][N2].
   Otro modo aún equivalente sería este otro:

typedef T          arrayT_N2[N2];
typedef array_T_N2 arrayT_N1[N1];
arrayT_N1 A;

   Más generalmente, T A[N1][N2]...[Nk] es un array de N1 elementos de tipo T1, donde T1 es un array multidimensional de tamaño \( N2\times ... \times Nk \) cuyos elementos son de tipo T.
   Con typedef tendríamos equivalencia con esto:

typedef T         arrayT_Nk[Nk];
typedef arrayT_Nk arrayT_Nk_menos_1[Nk_menos_1];
// ... k-2, k-3, ..., 3
typedef arrayT_N3 arrayT_N2[N2];
            arrayT_N2 A[N1];

   La última línea no lleva typedef, porque no declara un tipo de datos, sino el objeto A.
   Otra manera aún equivalente sería ésta:

typedef T         arrayT_Nk[Nk];
typedef arrayT_Nk arrayT_Nk_menos_1[Nk_menos_1];
// ... k-2, k-3, ..., 3
typedef arrayT_N3 arrayT_N2[N2];
typedef arrayT_N2 arrayT_N1[N1];
            arrayT_N1 A;


Tan solo meditemos...

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Números complejos como arrays

   Según el estándar C99, un objeto de tipo estrictamente complejo (o sea, cualquiera que lleve la palabra _Complex o complex en su definición) aparece en el espacio de almacenamiento (o sea, la memoria RAM) como si se tratase de un array de 2 reales de punto flotante.
   O sea: un objeto de tipo float _Complex se almacena como un array de tipo float de tamaño 2, un double _Complex como un array de tipo double de tamaño 2, y un long double _Complex como un array de tipo long double de tamaño 2.
   En cualquier caso, la componente con índice 0 correspondería a la parte real, mientras que la componente con índice 1 correspondería a la parte imaginaria.

   Sin embargo, es importante notar aquí que, aunque un objeto de algún tipo complejo se almacene como un array de 2 objetos consecutivos de un tipo de punto flotante real, esto no implica que un array de punto flotante de 2 elementos coincida con un tipo complejo.
   Lo que ocurre es que el tipo de datos es distinto. Y es que el rango de valores también es distinto al de un array de 2 componentes de punto flotante.
   Al efectuar operaciones aritméticas, los datos de tipos complejos se siguen comportando numéricamente según las reglas de la aritmética de números complejos, mientras que si operásemos con arrays de reales directamente, el resultado obedecería las reglas de operaciones con arrays (que, de hecho, tienen varias restricciones, y en particular suelen "decaer" hacia la dirección del array).
   La correspondencia entre objetos complejos y arrays de 2 elementos reales es sólo a fines de almacenamiento en memoria, y de representación interna en bits, pero no en otros sentidos.

   Si definimos:

double _Complex z;
double arr[2];
arr[0] = 1.0;
arr[1] = 2.0;
z = 1.0 + 2.0*I;  // I se declara en <complex.h>

obtendríamos que el tamaño en memoria de z es exactamente el mismo que el de arr: sizeof(z) == sizeof(arr), o bien: sizeof(z) == 2*sizeof(double).
   También obtendríamos que z se representa en el espacio de almacenamiento como 2 datos consecutivos de tipo double, siendo el 1ero de ellos exactamente igual a arr[0], y el 2do exactamente igual a arr[1]. (En realidad la igualdad puede asegurarse sólo en el sentido del operador de comparación ==, pues un mismo valor a veces puede representarse de varias maneras en memoria).

   En los posts que siguen hablaremos de estructuras y punteros.
   Aquí digamos que un array puede ser de un tipo T declarado como estructura o como puntero, y así sucesivamente, en forma recursiva, combinando tipos aritméticos, arrays, estructuras y punteros, en el orden que se nos dé la gana.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Organización

Comentarios y Consultas

20 Octubre, 2013, 04:42 am
Respuesta #53

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
53. Tipos en C, derivados a partir de los tipos aritméticos. Parte III: Estructuras.

Estructuras

   En esta primera aproximación a las estructuras vamos a omitir algunas posibilidades.

   La palabra clave struct se usa para declarar estructuras que, como ya dijimos, son tipos de datos que engloban objetos de varios tipos que pueden ser distintos, y cada uno con su nombre.
   La manera típica (que es la única que analizaremos en este post) de una estructura es una colección de objetos de tipos que pueden ser distintos. Estos objetos se almacenan en el espacio de almacenamiento en forma ordenada, en el orden especificado en la declaración de la estrcutura (ver abajo).
   Cada uno de estos objetos individuales que conforman la estructura se puede llamar componente o elemento o ítem. También le llamaremos campo, pero esta nomenclatura se usará con un doble sentido, pues se designará como campo también a cada identificador en una declaración struct. (Es una mera sutileza técnica).
   Todos los objetos "se juntan" en un único bloque de memoria, que no deja huecos para ser usados por otros objetos, ni tampoco por el sistema operativo.
   Sin embargo, esto no quiere decir que no haya "huecos" entre los objetos individuales que conforman una estructura. Puede ocurrir que, por razones de alineamiento, o alguna otra, se dejen bytes vacíos entre campos sucesivos. Esto es opcional, y depende de reglas impuestas por el compilador que hayamos elegido.
   Es decir, no es posible predecir el alineamiento ni el tamaño de una estructura en forma teórica, sino que es dependiente del compilador.

   El tamaño de un objeto de tipo estructura puede obtenerse como siempre, con sizeof.
   Veremos en futuros posts que hay estructuras de tamaño variable.
   Sin embargo, cuando el tamaño de una estructura es fija, el valor que da sizeof es fijo, y determinable en la etapa de compilación.

   Una estructura no sólo se caracteriza por la lista de objetos de cierto tipo que contiene, sino también por una etiqueta-marcador (en inglés: tag).

   La declaración típica de un tipo de datos de estructura es como sigue:

struct tag {
    T1 V1;
    T2 V2;
    // ... ...
    Tn Vn;
}

en donde V1 denota un campo que tendrá tipo T1, V2 será un campo de tipo T2, y así siguiendo hasta Vn, un campo de tipo Tn.
   Importante: La lista de campos no puede estar vacía (es decir, debe haber al menos 1 campo en la lista que define la estructura).
   El identificador tag es un nombre que elegimos nosotros. Es cualquier identificador válido que no esté en uso para otra cosa.
   La declaración anterior tan sólo define un tipo de datos de estructura, de manera formal.
   Así, los campos V1, V2, ..., Vn, son formales, y no aluden a objetos reales en memoria.
   El tipo de datos así definido tiene de ahí en más el nombre:

struct tag

   Así, cuando queramos declarar objetos cuyo tipo de datos sea la estructura recién definida, lo haremos de la siguiente manera:

struct tag var;


   Ahora var es un objeto que tiene reservada memoria en el espacio de almacenamiento.
   Cada campo Vj de var tiene una dirección y ocupa sizeof (Tj) bytes de dicho espacio.
   No puede asegurarse que los sucesivos campos están "pegados" en la memoria, sino que puede haber bytes sin utilizar entre ellos.
   Sin embargo, C99 asegura que la dirección de memoria de cada campo de la estructura var respeta el orden creciente en que se han declarado.
   Por ejemplo, la dirección del campo V1 dentro de var precede a la dirección de V2, ésta a la de V3, y así sucesivamente.
   Esa es la única información que se puede predecir desde las normas del estándar C99 acerca de la posición en memoria de los campos de un objeto cuyo tipo es una estructura.
   Información más detallada puede hallarse en las notas del compilador, pero usar esta información hace a un programa dependiente de la implementación local.

   Para acceder a los campos de un objeto cuyo tipo es una estructura, se utiliza el operador punto: .
   Por ejemplo, para acceder al primer campo de var escribimos:

var.V1

   Ahora, en el programa, cada campo var.Vj así accesado es un objeto de tipo Tj, y esto quiere decir que se le puede usar como a cualquier otra variable en C que se haya declarado con ese tipo: se le pueden asignar valores de su tipo correspondiente, se le puede usar en expresiones aritméticas, o se le puede calcular su tamaño con sizeof, o se le puede solicitar su dirección en memoria (esto lo veremos en otro post), etc.

   Los identificadores V1, V2, ..., Vn de los campos no definen objetos, ni tampoco pueden modificarse en modo alguno cuando se declaran objetos cuyo tipo es la estructura que contiene esos campos. Son identificadores "nominales", que sirven para indicar aquella porción del objeto de tipo estructura a la que nos interesa referirnos.

   Pongamos un ejemplo concreto.

enum sexualidad {masculino, femenino};
typedef char array_name[1000];

struct creencias {
      _Bool en_Dios;
      _Bool en_Ovnis;
      _Bool en_Homeopatia;
      unsigned short int numero_de_la_suerte;
} ;

struct perfil_persona {
    enum sexualidad sex;
    float lealtad;
    float paciencia;
    unsigned long long int nro_hijos_deseados;
    array_name nombre;
    _Bool tiene_facebook;
    struct creencias lista_creencias;   
} ;

int main (void) {
  struct perfil_persona argentinator;
 
  argentinator.sex = masculino;
  argentinator.lealtad = 0.9;    /* valor entre 0 y 1 */
  argentinator.paciencia = 0.8;  /* valor entre 0 y 1 */
  argentinator.nro_hijos_deseados = 3; /* entre 0 y ULONGLONG_MAX */
  argentinator.nombre[0]  = 'A';
  argentinator.nombre[1]  = 'r';
  argentinator.nombre[2]  = 'g';
  argentinator.nombre[3]  = 'e';
  argentinator.nombre[4]  = 'n';
  argentinator.nombre[5]  = 't';
  argentinator.nombre[6]  = 'i';
  argentinator.nombre[7]  = 'a';
  argentinator.nombre[8]  = 't';
  argentinator.nombre[9]  = 'o';
  argentinator.nombre[10] = 'r';
  argentinator.nombre[11] = '\0';

  argentinator.tiene_facebook = 1; /* true */
  argentinator.lista_creencias.en_Dios = 0;
  argentinator.lista_creencias.en_Ovnis = 0;
  argentinator.lista_creencias.en_Homeopatia = 0;
  argentinator.lista_creencias.numero_de_la_suerte = 4; 
 
  return argentinator.lista_creencias.numero_de_la_suerte;
}

   Esa declaración define un nuevo tipo de datos denominado struct creencias, que por lo tanto es una estructura.
   No hay ningún objeto en el programa declarado con tipo struct creencias. Es, por ahora, sólo una formalidad que no tiene repercusión en el programa ejecutable.
   A continuación se declara otro tipo de datos estructura denominado struct perfil_persona.
   Tiene campos de tipos básicos (lealtad, paciencia, nro_hijos_deseados, tiene_facebook), tipos enumerados (sexualidad), tipos array (nombre), y tipos estructura (lista_creencias).
   Esto muestra que es posible colocar arrays y estructuras como campos de una estructura (se pueden también poner punteros, pero no lo haremos por ahora).
   De nuevo, es sólo una declaración formal.

   Dentro de la función main() hemos declarado un objeto con identificador argentinator cuyo tipo es struct perfil_persona.
   Para acceder a sus campos hemos usado el operador punto.
   El campo argentinator.lista_creencias tiene tipo estructura. Asi que, para poder trabajar con él, tendremos que acceder a sus campos más internos, mediante un segundo operador punto.
   Así, hemos puesto el campo argentinator.lista_creencias.en_Ovnis igual a 0.

   Me parece una práctica recomendable prestar atención de entrada a la ensalada de identificadores y tipos de datos que se produce al usar estructuras.
   Una cosa es el nombre del tag que sirve para declara el nuevo tipo de datos estructura, y otra cosa es el nombre de los objetos (o variables) cuyo tipo es esa estructura.
   En el programa se usan estos últimos, obviamente, para referirse a los campos de una estructura, lo cual es muy lógico, pero que podemos olvidarlo si nos dejamos marear.
   Por otra parte, los nombres de los campos son los mismos, tanto para la declaración formal struct como cuando se usa el operador punto . a fin de accesar una componente de un objeto dado, cuyo tipo es una estructura.

   Supongamos que en nuestro sistema tenemos:

sizeof(_Bool) == 1
sizeof(unsigned short int) == 2
sizeof(unsigned long long int) == 8
sizeof(enum sexualidad) == 4
sizeof(float) == 4

   Dado que en una estructura tiene que haber espacio para cada campo en forma separada, podemos predecir que todo objeto de tipo struct creencias tendrá un tamaño de al menos 5 bytes, o sea:

sizeof(struct creencias) >= 5

   ¿Pero es exactamente 5 obligadamente, o puede ser más?
   En mi sistema obtengo 6 bytes para el tamaño de esta estructura. O sea que hay 1 byte que se usa para propósitos de alineamiento, o quien sabe qué. O sea, hay 5 bytes con datos y 1 byte basura.
   No es posible predecir con total certidumbre cómo serán alineados estos bytes. No tiene sentido preguntárselo mientras no tengamos declarado un objeto de tipo struct creencias.
   Ahora sabemos que un objeto de tipo struct perfil_persona necesitará al menos 4+4+8+1000+1+5 bytes (o sea, 1022). En realidad 1023, porque el campo lista_creencias ahora sabemos que necesita 6 bytes en vez de 5.
   En mi sistema me da que, en realidad, una tal estructura ocupará en memoria 1032 bytes, con lo cual tengo 10 bytes "basura".

   A pesar de ello, y aunque no puede saber exactamente dónde se alinearán exactamente los campos de la estructura en la memoria RAM, puedo saber al menos que:
\( \bullet \) La dirección del objeto argentinator coincide con la de su 1er campo argentinator.sex. :D
\( \bullet \) La dirección del campo argentinator.sex precede a la de argentinator.lealtad, ésta precede a la de argentinator.paciencia, ésta a la de argentinator.nro_hijos_deseados, ésta a la de argentinator.nombre, y ésta a la de argentinator.tiene_facebook.
\( \bullet \) La dirección del campo argentinator.lista_creencias coincide con la de su primer campo argentinator.lista_creencias.en_Dios.
\( \bullet \) La dirección del campo argentinator.lista_creencias.en_Dios precede a la de argentinator.lista_creencias.en_Ovnis, ésta precede a la de argentinator.lista_creencias.en_Homeopatia, ésta a la de argentinator.lista_creencias.numero_de_la_suerte.

   El modo de definir tipos de datos de estructuras con typedef sería el siguiente:

struct tag {
    // declaraciones de campos de la estructura
} ;

typedef struct tag nombreTipoEstructura;

   Luego, declarar un objeto de tipo nombreTipoEstructura será lo mismo que mediante struct tag. Ejemplo:

struct creencias {
      _Bool en_Dios;
      _Bool en_Ovnis;
      _Bool en_Homeopatia;
      unsigned short int numero_de_la_suerte;
} ;

typedef struct creencias tipoCreencias;

int main(void) {
   struct creencias mis_creencias;
   tipoCreencias    tus_creencias;     
}

En este ejemplo, mis_creencias y tus_creencias son del mismo tipo. ;)

   Por si quedan dudas, remarcamos aquí algo que ya dijimos, y que el estándar C99 establece claramente:
   Puede haber bytes "basura" (o de "acomodamiento", en inglés padding) en una estructura, pero nunca al principio de la misma.
   Así, la dirección de un objeto cuyo tipo es una estructura coincide con la dirección del primero de sus campos.

   Ejercicio: ¿Los objetos hola1 y hola2 que siguen a continuación, tienen el mismo tipo de datos de estructura?

struct tagtipo1 {
    int x;
    char z;
} ;

struct tagtipo2 {
    int x;
    char z;
} ;

struct tagtipo1 hola1;
struct tagtipo2 hola2;

   Respuesta: No. La razón es que, a pesar de que los campos son del mismo tipo, y hasta tienen los mismos identificadores, resulta que tienen tags diferentes, que les distinguen. En este caso, son: tagtipo1, tagtipo2.

   En cambio, si agregamos:

typedef struct tagtipo1 tipo1;

tipo1 hola3;

resultará que hola3 tiene el mismo tipo de datos que hola1.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Recursión en los tipos de datos

   ¿Se puede poner el tipo de una estructura como el tipo de un campo dentro de la misma estructura que estamos definiendo? Ejemplo:

struct recursion_fallida {
      int x;
      struct recursion_fallida intentando;
} ;

   Eso no va a funcionar. Los detalles teóricos los estudiaremos en otro momento. Por ahora conformémonos con saber que no podemos hacer ese tipo de cosas en C.

   Sin embargo es posible algo similar: punteros a la estructura que se está declarando:

struct recursion_que_anda {
      int x;
      struct recursion_que_anda *puntero;
} ;

   El signo * indica "puntero", que es el tema que sigue a continuación. Allí retomaremos esta discusión.


CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Organización

Comentarios y Consultas

20 Octubre, 2013, 07:09 am
Respuesta #54

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
54. Tipos en C, derivados a partir de los tipos aritméticos. Parte IV: Uniones.

Uniones

   Una unión es un objeto cuyo tipo de datos es una lista ordenada de objetos que pueden ser de tipos distintos, y que comparten una misma dirección de memoria.
   Las declaraciones, sintaxis, uso y acceso a los campos de uniones funcionan de la misma manera que para las estructuras.
   Las únicas diferencias son:
\( \bullet \) Se usa la palabra clave union (en lugar de struct).
\( \bullet \) Los campos de un objeto con un tipo de unión tienen todos la misma dirección de memoria, que a su vez es la dirección asociada a la unión misma.
   Ejemplo:

union ejemplo {
    int A;
    int B;
    float C;
    char D[45];
} ;   

union ejemplo EJ;

EJ.A = 3;

if (EJ.B == 3)
   printf("Verdadero siempre!!");


   El tamaño (calculado con sizeof) de una unión es un entero mayor o igual al mayor de los tamaños de cada uno de los campos que la conforman.

   En el ejemplo anterior, toda la unión abarca al menos los 45 bytes del array de caracteres D que allí aparece.
   Las componentes A, B, C, D tienen distintos tamaños, y así, al mirar el valor de un campo con más bytes (como D) permite ver información que los otros campos no muestran.
   En mi sistema, A tiene sólo 4 bytes, mientras D tiene 45. O sea que modificando A sólo repercute en la modificación de los primeros 4 bytes de D.
   La asignación EJ.A = 3 modifica todos los campos de la unión al mismo tiempo, porque comparten la misma dirección de memoria.
   Dado que A, B se alinean del mismo modo, por tener el  mismo tipo, lo que se obtiene es que el valor de EJ.B será el mismo que el e EJ.A.
   En cambio EJ.C da un resultado "sin sentido" para nosotros. Lo que ocurre en ese caso es que un valor entero, 3, ha sido asignado a un objeto de tipo int, el campo EJ.A. Para ello se ha elegido una representación binaria que el compilador utiliza exclusivamente para enteros. Luego, si los bits de esa representación se intentan leer como si fueran los bits de un float, el reaultado es un desastre.
   No obstante, podemos hacer ese desastre si así se nos antoja, accediendo al campo EJ.C.

   Este ejemplo ilustra algo importante: No es lo mismo "convertir" con casts explícitos que acceder a campos de una unión de diversos tipos.

   La unión traslapa la representación binaria de objetos de tipos distintos, que no es lo mismo que "convertir el tipo de un objeto cuyo tipo es aritmético a otro tipo".
   El resultado de un cast es predecible. Por ejemplo (float) 3 produce el valor 3.0. Pero en la unión del ejemplo, al acceder al campo, de tipo float, EJ.C, no se obtendrá 3.0. Mi sistema produce este valor: 4.2039e-039.

   Una unión puede tener diversos usos, todos extraños. Ejemplo:
   
\( \bullet \) Para investigar la representación binaria de un determinado tipo de datos:
         Dado que los enteros sin signo tienen una representación binaria concreta, y las operaciones bit-a-bit están definidas con exactitud desde el estándar, al superponerse a otros tipos de datos es posible hacer operaciones de bits para extraer la estructura binaria conque se han almacenado datos de estos otros tipos. Sin embargo, aún puede que existan algunos bits con información "extraña", y así el único tipos entero que el estándar garantiza que tienen un tamaño concreto, cuyos bits son sólo para albergar un valor entero, es unsigned char.
         Si el tipo unsigned char no fuera suficiente para albergar todos los bits del tipo que se quiere investigar, se puede usar sin temor un array de unsigned char, con tantos elementos como sea necesario. Esto es confiable, porque sabemos que los arrays se almacenan en memoria "sin dejar huecos", siempre en forma contigua. Por lo tanto, no hay paddings, no se pierde información por "alineamiento".
         Ejemplo:

#include <stdio.h>
#include <limits.h>

int main(void) {

    typedef double type;
   
    union unmask_type {
         type x;
         unsigned char bytes[sizeof(type)];
    } ;
   
    union unmask_type the_pi_number;
   
    the_pi_number.x = 3.14159265358979; // Número pi aproximado a 14 decimales
   
    for (int j = 0; j < sizeof(type); j++)
       for (int k = CHAR_BIT - 1; k >= 0; k--)
           printf("%d", (the_pi_number.bytes[j] & (1u << k)) >> k );
       
    getchar();   
    return 0;
}

         Este código permite analizar la representación binaria de cualquier tipo de datos que represente objetos en memoria.
         Se declara con el identificador type un cierto tipo de datos, a través de una declaración typedef.
         En nuestro ejemplo hemos definido type como sinónimo de double, pero bien puede ser cualquier otro tipo.
         Debajo definimos el tipo de datos de unión llamado unmask_type (desenmascarar tipo) que tiene 2 campos.
         El primer campo x va a indicar un objeto de tipo type.
         El 2do campo bytes va a ser un array de elementos de tipo unsigned char, cuyo tamaño es igual al número de bytes necesarios para representar un objeto de tipo type. Esto es una expresión aritmética constante: sizeof(type), y como tal es válida en una declaración de arrays.
         Esto define el tipo union unmask_type.
         Ahora declaramos un objeto llamado the_pi_number cuyo tipo es union unmask_type.
         En él están superpuestos en memoria un objeto de tipo type y un array de unsigned char, del mismo tamaño que el campo de tipo type.
         Luego le asignamos un valor a la componente de tipo type (en este caso, un double con el valor del número \( \pi \)).
         Finalmente recorremos el array con un doble for().
         El for() más externo, controlado por j, recorre cada elemento del array.
         El for() más interno analiza dicho elemento, mostrando cada bit en forma individual.
         Elegimos mostrar desde el bit más significativo por izquierda hacia el bit menos significativo hacia la derecha. Por eso la cuenta va hacia atrás con el contador k (comenzando desde el último bit de un byte, decreciendo hasta 0).
         El resultado en pantalla será la secuencia ordenada de bits que conforman el valor de the_pi_number.x.
         ¿Por qué esto siempre funciona?
         La razón es que el estándar C99 asegura que unsigned char ocupa exactamente 1 byte.
         Además, un objeto de tipo unsigned char tiene reglas bien precisas de representación binaria (como todo tipo entero no negativo), y las operaciones bit a bit (como & ó << ó >>) tienen un comportamiento bien preciso (no hay lío alguno con el bit de signo ni ninguna ambigüedad imaginable).
         También se asegura en C99 que los unsigned char no tienen padding bits, que son bits adicionales que algunos sistemas o implementaciones pueden querer adosar a los tipos de datos enteros.
         Finalmente, los bytes de un array se pegan en una secuencia "que no deja huecos en memoria", o sea, no hay padding (esta vez de "bytes").
         Todo esto asegura que un array de unsigned char superpuesto a un objeto de cualquier tipo, permite "ver" la representación binaria, bit a bit, del dato correspondiente, sin que sobre o falte información, sin ambiguedades, sin errores, etc. Es decir, sirve para el propósito de ver los bits del dato con exactitud.

Nota técnica: Algunos programadores pueden albergar dudas sobre esta técnica. De hecho, si bien es cierto que la estructura interna del dato analizado de tipo type es tal como se ha explicado, vale decir, un array de bytes en formato de unsigned char, hay que preguntarse si es posible o no acceder a esta información. Más generalmente, ¿si escribimos un miembro de una unión, qué sucede cuando intentamos "leer" la información accediendo a otros miembros de la misma unión? ¿Hay un comportamiento definido? Por ahora digamos que el acceso a través de un array de unsigned char es una forma segura de lograr este tipo de acceso, y se obtienen los bytes y bits del objeto de tipo type.

\( \bullet \) Darle nombre al primer elemento de un array (un uso raro, digo yo):

union cabecera {
    int x[3000];
    int primer_elemento;
} ;


\( \bullet \) Acceder rápidamente a las componentes real e imaginaria de un número complejo. Veamos este programa:

#include <stdio.h>
#include <complex.h>

int main (void) {
   union complex_number {
         double complex numb;
         double parts[2];
    }  ;
   
    union complex_number z;
       
    double complex w;
   
    w = 3.0 + I * 2.0;
   
    z.numb = w;
   
    printf("%g + %g i\n", z.parts[0], z.parts[1]);
   
    getchar();   
    return 0; 
}

         Este ejemplo declara una unión con dos componentes: una de tipo double complex y otra de tipo array de double de 2 elementos.
         Como hemos visto en la sección de arrays, el modo de almacenarse en memoria de un double complex es el mismo que la de un array de double de 2 elementos.
         Como además ambas componentes de la unión arrancan desde la misma dirección de memoria RAM, se deduce que el 1er elemento del array corresponderá a la parte real del número complejo, y el 2do elemento será la parte imaginaria.
         Para comprobarlo, definimos un double complex "de verdad", se lo asignamos a la componente double complex de una unión, y luego, en vez de calcular de algún modo las partes real e imaginaria del número, accedemos directamente a sus componentes, mediante el array de double de 2 elementos, con la ayuda de printf().

\( \bullet \) Para ahorrar espacio en memoria:
         En efecto, si en vez de usar una struct usamos una union, ahorramos mucho espacio en memoria.
         El problema es que la programación se vuelve insegura, y el programador tiene que asegurarse de que el valor al que accede corresponde al último campo que ha sido modificado, pues si no, se obtendrán valores sin sentido.

\( \bullet \) Para obtener una forma primitiva de polimorfismo:
         Esto se refiere a que un objeto pueda comportarse de maneras distintas, según la situación. Ejemplo:

union varios {
    int x;
    float y;
    char c;
} ;

enum comportamiento { beh_int, beh_float, beh_char } ;

struct polimorfic {
    enum comportamiento beh;
    union varios modo;   
} ;

struct polimorfic objeto;

switch(objeto.beh) {
    case beh_int:
        objeto.modo.x = 11;
    break;
   
    case beh_float:
        objeto.modo.y = 1.1;
    break;
   
    case beh_char:
        objeto.modo.c = 'K';
    break;
   
    default:
        ;
}

         Esta "porción de programa" define una enumeración que usaremos para referirnos al cambio de tipo del objeto que "cambiará de forma", según los casos.
         Definimos luego un tipo unión que superpone varios tipos distintos.
         Finalmente, creamos una estructura que tiene 2 campos: uno que se usa para llevar la cuenta del tipo de datos que actualmente "se supone" que tendrá el objeto, y otro, que será la unión propiamente dicha.
         Así, un objeto polimórfico tiene que tener 2 informaciones bien claramente separadas, al menos: la que indica cuál es la "forma actual" del objeto, y la parte que "cambia de forma", expresada como una unión.
         Finalmente, vemos cómo con una estructura de control switch() actuamoms considerando a la parte "cambiante" de diverso tipo, según el valor del campo objeto.beh.
         Este ejemplo en particular muestra una versión rudimentaria de polimorfismo, ya que, a pesar de que hay un campo objeto.beh que controla mejor el tipo de acceso de la unión, no deja de ser una convención del programador, quien debe seguir teniendo cuidado en la lógica de su programa.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Recursión

   Pueden declararse arrays de uniones o estructuras cuyos campos sean uniones.
   Obviamente, también pueden ponerse en una unión campos que sean arrays o estructuras.
   También puede haber uniones como campos de uniones, y esto traerá como consecuencia que los campos de los campos de la unión miembro tiene la misma dirección que la de los miembros de la unión a la que pertenece.
   Además, pueden combinarse recursivamente uniones y punteros, tema que analizamos a continuación.


CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Organización

Comentarios y Consultas

21 Octubre, 2013, 05:46 am
Respuesta #55

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
55. Tipos en C, derivados a partir de los tipos aritméticos. Parte V: Punteros.

1. Direcciones en el espacio de almacenamiento

   Hay distintas arquitecturas de computadoras, cada cual con su correspondiente modelo de espacio de almacenamiento, a la que coloquialmente nombramos como memoria RAM, ya que esta es la manera más común en que tal concepto se realiza.
   El modelo simplificado que usamos en la Sección 42 ordena los bytes de los objetos en lo que parece ser un array indexado desde 0 en adelante.
   No necesariamente es este modelo aplicable a todos los sistemas. De hecho, conviene pensar que habrá maneras más bien complicadas en que un sistema informático representa las direcciones en su espacio de almacenamiento.
   Tal es así, que el estándar C99 no asume absolutamente nada acerca de las direcciones de memoria.
   Solamente se les llama referencias, y sirven para diferenciar unos objetos de otros en el espacio de almacenamiento.

   Sin embargo, algunos detalles más sí que son especificados por el estándar C99. Pareciera que, por ejemplo, algunas porciones del espacio de almacenamiento están al menos "ordenadas en forma secuencial", siguiendo un patrón de números enteros consecutivos.
   Estos números enteros no se sabe si pueden ser negativos, o sólo positivos. O si en realidad son otra cosa distinta a números enteros.
   En la Sección 54 vimos cómo superponer un objeto de un tipo type dado con un objeto de tipo unsigned char  [sizeof(type)].
   El estándar C es "amigo" de esta manera de escrutar los bytes de un objeto.

   Más precisamente, podemos decir estas cosas:
   
\( \bullet \) Todo objeto en C se representa con una cantidad entera positiva de bytes. (No hay objetos con 0 bytes).

\( \bullet \) Todo byte está compuesto de una cantidad de bits igual a CHAR_BIT.

\( \bullet \) Un objeto de tipo unsigned char utiliza para sus valores una representación binaria, o sea, con bits 0 ó 1, con sus bits ordenados desde el más significativo al menos significativo, ocupando exactamente 1 byte, y aprovechando todos los bits para su valor.
         Esto en realidad permite eliminar ambigüedades cuando queremos usar unsigned char para desenmascarar la representación de otros tipos de datos.
         Por ejemplo, no es claro lo que significa que los bits estén ordenados dentro de 1 byte de un modo u otro. En todo caso, si agarramos un byte de un objeto dado, el modo de acceder a sus bits será siguiendo el orden secuencial que le otorga su representación como entero no negativo de tipo unsigned char.
         Ni siquiera importa si eso se entendió...
         Los bytes de un objeto son "contiguos", lo cual quiere decir que no se le intercalan bytes que el programa o el sistema pudiera usar para otra cosa.
         La "contigüidad" es una propiedad de los arrays.
         No está claro si podemos decir que los elementos de un array son "contiguos" en relación a una ordenación previa de los bytes del espacio de almacenamiento, o si sólo podemos hablar de contigüidad dentro de un array.
         En ese caso, el hecho de que los unsigned char ocupan exactamente 1 byte, hace que los arrays de tipo unsigned char permitan "mirar" una porción del espacio de almacenamiento como indexada por números enteros no negativos.
         ¿Es posible expresar toda el espacio de almacenamiento como un solo array de unsigned char?
         Si la respuesta es afirmativa, podríamos intercambiar números enteros con direcciones sin inconveniente alguno.
         Pero esto dependerá del sistema y el compilador conque estemos trabajando. No puede asegurarse.
         Imaginemos los arrays unsigned char como "débiles linternas" que nos permiten alumbrar el espacio de almacenamiento, y ver cómo está "embaldozado".
         Cada vez que miramos una porción del espacio de almacenamiento con un tal array vemos que está ordenado secuencialmente, con 1 byte al lado del otro.
         Pero no podemos ver todo el espacio de almacenamiento con una sola "linterna", y entonces no sabemos cómo es la estructura completa.
         Sí podemos "ver" que un objeto, del tipo que sea, está dentro de una región de bytes secuencialmente ordenados, tal que todas las direcciones de cada uno de los bytes que conforman el objeto son contiguas en esa región.
         Más básico e importante: todo objeto aloja sus bytes en el espacio de almacenamiento y tiene una dirección "inicial", que es la del primer byte que aloja el objeto.
         Esto quiere decir que la dirección de un objeto siempre es "accesible" de algún modo. Y también son accesibles las direcciones de los bytes individuales que componen el objeto.



2. Punteros

   Un tipo puntero es la colección de direcciones asociadas a un tipo dado.
   En esta sección no podemos aún estudiar todas las variantes de tipos puntero. Nos conformamos con los punteros que apuntan a datos definidos en forma recursiva según lo dicho en las secciones 51 a 54.
   Un puntero es un objeto de un tipo puntero. Denota una dirección (o mejor dicho: referencia).
   Cada tipo T implica un tipo puntero a T distinto.
   Esto implica que, aunque tengamos dos variables puntero apuntando a la misma dirección, no se considera que tengan el mismo valor, ya que pueden estar representando punteros a tipos distintos.
   En muchas ocasiones es posible intercambiar punteros a tipos distintos, pero debe hacerse respetando ciertas reglas. No sale de la definición.

   Un tipo puntero se declara anteponiendo un asterisco * al identificador de la variable, así:

    TIPO * variable;

   Eso declara a variable como un objeto de tipo puntero a TIPO, donde TIPO es un tipo previamente definido.

   Nos referiremos al nombre de este tipo puntero como T* (y lo leeremos: puntero a T).

\( \bullet \) El operador de referencia es &. Es un operador unario cuyo resultado es la dirección (o mejor dicho, la referencia) de un objeto que hayamos declarado en el programa.
         También puede darnos la dirección de un elemento específico de un array, o un campo de una estructura.
         Ejemplos:

int x;

char array[100];

struct dos {
    bool  primero;
    float segundo;
} ;

struct dos dato_tipo_dos;

&x;          // Dirección de memoria de la variable x
&array;      // Direccción de memoria de la variable array
&array[0];   // Dirección de memoria del objeto array[0]
&array[17];  // Dirección de memoria del objeto array[17]
&dato_tipo_dos; // Dirección de memoria del objeto dato_tipo_dos
&dato_tipo_dos.segundo; // Dirección de memoria del campo dato_tipo_dos.segundo

         En este ejemplo, &array[0] apunta a la misma dirección que &array, pero se consideran valores distintos porque el tipo al que apuntan es distinto (en el primer caso a char y en el segundo a array de char),
         Asimismo, &dato_tipo_dos apunta a la misma dirección que &dato_tipo_dos.primero, pero en el primer caso apunta a un struct dos y en el segundo a bool.

   Por ejemplo, consideremos estas declaraciones, que son continuación del ejemplito anterior:

int   *px;
char  *pa;
dos   *pdos;
float *pf;

px = &x;
pa = &array[17];
pdos = &dato_tipo_dos;
pf = &dato_tipo_dos.segundo;

   Los objetos px, pa, pdos, pf, son punteros.
   px es un puntero a int, y entonces es válido asignarle la dirección del objeto x, que es de tipo int.
   pa es un puntero a char, y entonces es válido asignarle la dirección del objeto array[17], que es de tipo char.
   pdos es un puntero a dos, y entonces es válido asignarle la dirección del objeto dato_tipo_dos, que es de tipo dos.
   pf es un puntero a float, y entonces es válido asignarle la dirección del objeto dato_tipo_dos.segundo, que es de tipo float.

   Es posible proceder en forma recursiva: haciendo que un campo de una estructura sea un puntero, o tener arrays de punteros o punteros a arrays, o punteros a punteros, etc.
   Ejemplito:

int x;
int *px;
int **ppx;
int ***pppx;

x = 5;
px = &x;
ppx = &px;
pppx = &ppx;


   Todo esto parece extraño y no se ve su utilidad.
   Pero tener acceso a las direcciones de los objetos, y más aún, a las direcciones de los punteros, y de los punteros de punteros, etc., otorga una gran potencia a un lenguaje de programación, permitiendo resolver problemas siguiendo casi exactamente los mismos patrones estructurales que dichos problemas ocasionarían en nuestra mente.

   Dijimos antes que las referencias no son necesariamente intercambiables con números enteros.
   Entonces no queda claro qué aspecto tienen los valores de los objetos de tipo puntero.
   Esto no es importante, y conviene mantener las ideas en un nivel abstracto.
   No nos sirve como programadores tener certidumbre de todo lo que ocurre a nivel de arquitectura interna del sistema.
   Es más, el modelo de gestión de memoria se diversifica cada vez más a medida que la tecnología avanza.
   No hay un tal modelo de espacio de almacenamiento que nos resulte cómodo.
   Conviene entonces atenerse a algunas pocas certidumbres:

\( \bullet \) Las direcciones de objetos distintos, mientras existen en un programa, son distintas.
\( \bullet \) Las direcciones de los objetos de un programa se conservan sólo durante una ejecución de dicho programa. Si lo corremos de nuevo, los mismos objetos se acomodarán en direcciones distintas del espacio de almacenamiento.
\( \bullet \) Los bits y bytes de un objeto se pueden acceder en forma secuencial, contigua, y sin saltos, como se explicó al principio de este post, aprovechando un mecanismo de solapamiento con arrays de tipo unsigned char (esto lo implementamos con una union en la Sección 54).
\( \bullet \) Una variable tipo puntero que no se ha inicializado a un valor concreto, "apunta hacia cualquier parte de la memoria".
\( \bullet \) Usar punteros antes de inicializarlos dará lugar a desastres, literalmente hablando, y sin exageración alguna.

   El uso de punteros en C es muy asiduo, debido a que los arrays "decaen" en punteros en la mayoría de las situaciones.

   Es posible realizar algunas operaciones con punteros, como sumas, restas, y ciertas comparaciones.
   Todo esto sólo es posible bajo ciertas restricciones y convenciones, que estudiaremos en otros posts.



3. Valor apuntado por el puntero

   Hemos dicho que un puntero "apunta" a una dirección de memoria que aloja un objeto de cierto tipo.
   Ese objeto tiene un valor, que depende del tipo al que pertenece.
   ¿Cómo se accede o se visualiza ese valor?
   Con el operador indirección, el asterisco *
   Ejemplo:

int *px; // Declara un puntero a int

// Supongamos que la dirección de memoria apuntada por px es válida y correctamente inicializada

*px = 45; // Asigna el valor 45 al objeto *px, que tiene tipo int

++*px;    // Incrementa en 1 el valor de *px

printf("%d", *px); // Muestra el valor de *px, que ahora es 46

   En este ejemplo, el valor de px no se ha modificado, salvo con alguna operación de inicialización que hemos dejado indicada con un comentario...
   La variable px es por lo tanto de tipo int *, o sea, puntero a int, y como tal, su valor no cambia.
   Sin embargo, el valor al que "apunta", es decir, el contenido que hay en la dirección px corresponde a un objeto de tipo int.
   Este objeto se puede designar con *px. Así, *px es una variable de tipo int. Es la "variable apuntada".
   Su valor se pone a 45, y luego lo variamos. Sobre *px podemos efectuar operaciones aritméticas, porque es una variable entera.

   En general, si tenemos una declaración

  T *p;

en la cual p es una variable de tipo puntero a T, tenemos que p contiene como dato una dirección de memoria, mientras que *p es ahora una variable de tipo T con el contenido alojado en dicha dirección de memoria.
   Podemos hacer con *p cualquier operación válida para un objeto cualquiera que tenga tipo T.



4. Punteros genéricos

   Para definir con toda precisión los punteros genéricos necesitamos la teoría de los tipos incompletos, que corresponde a futuras secciones.
   Digamos aquí que existe un modo de comunicar todos los tipos puntero entre sí, mediante un tipo puntero especial: el puntero a void, o bien void*.
   Un objeto declarado con tipo void* se usa para alojar direcciones de memoria sin un tipo específico.
   Es posible convertir, con casts entre punteros de un tipo dado y punteros a void y viceversa, sin pérdida alguna de información.
   El tipo void* es compatible con char*.



5. Constantes de puntero

   Existe la constante de puntero nulo.
   Es, o bien una expresión constante con valor 0 (esta constante tiene tipo entero),
   o bien es esa constante con el cast void *: (void*) 0 (esta constante tiene tipo puntero a void).

   Ambas sirven para indicar la constante de puntero nulo.

   Acá varios me estarán mirando enojados. ¿Esa constante es de tipo entero o de tipo void*?
   Si se usa una expresión constante con valor 0, es de tipo entero.
   ¿Y cómo es que representa un puntero en ese caso?
   Obviamente, al asignarle ese valor a una variable de tipo puntero, se produce algún tipo de conversión, que transforma ese "0" en una dirección de memoria.

   Pero la constante puntero nulo no es, en principio, una dirección de memoria con índice 0. :o
   En muchos sistemas sí tendremos que el puntero nulo apunta a la dirección 0.
   Pero en general no es cierto, y no podemos asumirlo.
   Es una dirección de memoria que no tendrá ningún otro objeto ni función del programa.
   Esto está garantizado por C99
   Normalmente, el puntero nulo se usa para inicializar punteros a algo que tenga sentido, y al mismo tiempo, que no tenga ningún valor útil.
   Para saber si nuestro puntero apunta "lógicamente" a algo con sentido en nuestro programa, basta que lo comparemos con el puntero nulo, a ver si apunta allí o no.
   La dirección del puntero nulo no puede usarse por ningún objeto o función del programa. Es una dirección que está "en blanco".

   Cuando se le hace un cast a void *, permanece considerada como constante, y entonces se puede utilizar en cualquier contexto en que el lenguaje C acepte o requiera uso de expresiones constantes.
   Se le puede hacer un cast a cualquier otro tipo puntero, y seguirá siendo el puntero nulo, apuntando a la misma dirección. Pero ya no es una constante en el programa. Las expresiones que contengan un cast de esta naturaleza, ya no se consideran constantes.

   Varias librerías de C definen la macro NULL, declarada como la constante puntero nulo.
   La práctica recomendada es invocar la constante puntero nulo siempre a través de la macro NULL.
   Está definida en varias librerías, y lo más probable es que si necesitamos usar NULL, seguramente ya hemos incluido una de las librerías estándar que la define.

   Si bien hay restricciones para convertir alegremente unos punteros de cierto tipo en punteros de otros tipos, los punteros nulos son una excepción. Veamos:
   
   Cada tipo puntero tiene su propia versión de puntero nulo en su rango de valores, y se obtiene mediante el cast (T*) NULL (o bien (T*) 0), cuando el tipo puntero es T*.
   En particular, el puntero nulo de cualquier tipo, apunta siempre a la misma dirección.
   Está permitido asignar a una variable puntero de un tipo dado, un puntero nulo transformado desde cualquier otro tipo.
   La comparación de cualesquiera punteros nulos (de cualquier tipo) siempre dará que son iguales.

   La macro NULL está definida en las siguientes librerías estándar:

<stddef.h>
<locale.h>
<stdio.h>
<stdlib.h>
<string.h>
<time.h>
<wchar.h>




6. Conversiones entre punteros y enteros

   El estándar C99 establece que se le permite a un compilador convertir enteros en punteros y/o viceversa.
   Que esté permitido no quiere decir que siempre sea posible.
   Y si es posible, no quiere decir que la corresponencia entre direcciones de memoria y valores enteros sea uno-a-uno.

   En general, se utilizarán los tipos intptr_t, uintptr_t, para manejar el rango de valores de enteros que pueden usarse para representar punteros convertidos a enteros.
   La mera existencia de estos tipos enteros en la librrería <stdint.h> es opcional.
   O sea que no tenemos garantías de algún mecanismo cómodo de intercambio entre valores enteros y punteros.

   Como sea, podemos ver que se refuerza nuestro conocimiento de que no podemos concebir alegremente a la memoria RAM como un array indexado por enteros.
   Esta correspondencia entre punteros y enteros es parcial.

   Si bien C99 deja detalles teóricos un poco en el aire, la intención es que, cuando realmente existen conversiones entre enteros y punteros en una implementación dada, obedezca a un criterio razonable, o sea, que tenga un significado respecto la arquitectura subyacente del entorno de ejecución.
   No sirve de nada asignar números enteros alocadamente a referencias, sino más bien tratar de imitar la lógica del dispositivo de memoria del sistema subyacente.
   El hecho de que haya esta discusión tratando de arrimar los enteros a las direcciones de memoria, tiene que ver conque el direccionamiento de memoria es, a grandes rasgos, secuencial, con grandes porciones ordenadas como los números enteros.



Organización

Comentarios y Consultas

21 Octubre, 2013, 06:28 am
Respuesta #56

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
56. Tipos en C, derivados a partir de los tipos aritméticos. Parte VI: Generalidades.

   En este post aprovechamos la teoría recién introducida sobre tipos derivados, y así introducimos conceptos que están listos ya para ser presentados.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

1. Arrays como punteros

   Consideremos un array de tipo T de tamaño N:

  T arr[N];

   Como objeto, corresponde a un dato de tipo array de N elementos de tipo T.
   Su tamaño en memoria es sizeof(arr) == N * sizeof(T).
   El operador sizeof aplicado al array da como resultado ese número.
   El operador de referencia da la dirección de memoria del array, como lo haría con cualquier otro objeto: &arr es la dirección de arr.

   Pero para todo otro caso, el identificador arr "decae" (o se le considera) como denotando un puntero a T cuyo valor es la dirección de memoria del array.
   Así, en cualquier caso en que no aparecen antepuestos los operadores sizeof ni &, tenemos que:

 arr == &arr[0]


   ¿Cuál es la diferencia entre arr y &arr? ¿Por qué no se usa este último?
   En realidad arr "significa" &arr[0] que, como se ve, es de tipo puntero a T.
   Pero &arr es un puntero a "array de N elementos de tipo T".
   Se ve, entonces, que son punteros "hacia" tipos distintos.
   Y esto implica que, como punteros, tienen distinto tipo.
   No importa que ambos punteros &arr y &arr[0] apunten a la misma dirección de memoria. Como tienen tipos distintos no se los puede intercambiar alegremente. Ni siquiera se los puede comparar para decir si son o no son iguales. Compararlos sería una operación prohibida.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

2. Constantes literales de cadena (strings literals)

   En C podemos definir valores constantes literales de diversos tipos. Hasta ahora hemos visto cómo escribir constantes de tipos enteros o punto flotante.
   También vimos las constante de caracter que, en definitiva, representan valores enteros (y te digo más: de tipo int).
   Y sabemos también declarar constantes literales de cadena, y de cadena ancha (o de caracteres anchos).
   Lo que no hemos dicho hasta ahora es "de qué tipo" son los valores de dichas cadenas.

   Las constantes literales de cadena (como "Hola mundo") son de tipo array de char.
   Las constantes literales de cadena ancha (como L"Hola mundo"), o sea, las que anteponen L, son de tipo array de wchar_t.
   En ambos casos, el número de elementos del array es igual al número de caracteres que figuran en la cadena literal, más 1 caracter más, correspondiente al caracter nulo (en el primer caso) o al caracter nulo ancho (en el segundo caso).

   Naturalmente, cuando aparecen en expresiones, o en cualquier contexto que no involucre los operadores sizeof o &, las constantes de cadena decaerán otra vez a punteros a char ó wchar_t, según el caso, apuntando al primer caracter de la cadena.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

3. Estructuras recursivas con punteros

   Hablemos someramente de estructuras tal que algunos de sus campos son punteros.
   Esto no es nada extraño, ya que hemos dicho que todos estos métodos de construcción de nuevos tipos que estamos discutiendo, se pueden combinar en forma recursiva.
   Sin embargo, desde el punto de vista práctico constituyen una herramiento digna de destacar, porque son la clave para definir estructuras típicas de las ciencias de la computación: pilas, listas, colas, árboles, etc.
   Se trata de las estructuras recursivas de datos.

   Supongamos que estamos definiendo una estructura cuyo tipo se designa con struct record.
   Hemos visto que ningún miembro de tal estructura puede tener, de nuevo, el tipo struct record.
   Sin embargo, sí que está permitido que un miembro tenga tipo struct record*, es decir, puntero a struct record.
   Las razones teóricas de por qué una construcción no es posible y la otra sí, se las contaré otro día.

   Lo importante es que ahora podemos referirnos, dentro de una estructura, a elementos que tienen su mismo tipo... aunque vía punteros.
   Esto no es tan "recursivo" como parece, porque ahora un puntero necesita ser inicializado para tener sentido.
   Normalmente lo inicializaremos a NULL, el puntero nulo, pero puede inicializarse de otra manera.
   Veamos un ejemplo de una lista enlazada:

struct lista {
   int dato;
   bool es_el_ultimo;
   struct lista* siguiente;
} ;

struct lista L1;
struct lista L2;
struct lista L3;
struct lista L4;

   Hemos definido una estructura que tiene 3 campos: un entero, un booleano, y un puntero que apunta a un dato cuyo tipo es la misma estructura que estamos declarando.
   Luego declaramos 4 variables de tipo struct lista.
   Lo que queremos hacer es armar una lista enlazada, poniendo información en cada una de las variables L1, L2, L3, L4, pero enlazándolos de manera que desde L2 apuntemos a L3, de L3 a L1, de L1 a L4, y L4 a NULL.
   El esquema sería éste:

L2  ------>   L3  ------->  L1  ------>  L4  ------>  NULL

   ¿Cómo lo logramos? Así:

L2.dato = 90;
L2.es_el_ultimo = false;
L2.siguiente = &L3;

L3.dato = 73;
L3.es_el_ultimo = false;
L3.siguiente = &L1;

L1.dato = 2094;
L1.es_el_ultimo = false;
L1.siguiente = &L4;

L4.dato = 115;
L4.es_el_ultimo = true;
L4.siguiente = NULL;

   Fue bastante sencillo esta vez. (En la práctica no tendremos variables tan fácilmente accesibles como en este ejemplo).

   Ahora quisiéramos acceder a los campos del objeto apuntado por L2.

   Para acceder a los campos de un objeto de tipo estructura, apuntado por una variable de tipo puntero, se usa el operador flecha: ->

   En nuestro ejemplo, tendríamos que:

L2.dato; // Es igual a 90
L2.siguiente->dato; // Es igual a L3.dato, o sea, 73
L2.siguiente->siguiente->dato; // Es igual a L3.siguiente->dato, o sea, L1.dato, o sea 2094
L2.siguiente->siguiente->siguiente->dato; // Es igual a L3.siguiente->siguiente->dato, o sea, L1.siguiente->dato, o sea L4.dato, o sea 115


   El operador flecha es equivalente a la siguiente construcción:

   E->m;
   // Es lo mismo que:
   (*E).m;

   Es decir, se toma un puntero, se le hace una desreferencia o indirección, y luego se accede al campo de la estructura que corresponde.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

4. Uniones y punteros

   El operador -> funciona de la misma forma con punteros a uniones. Lo único que cambia son las vicisitudes en torno a la utilización de uniones, que requieren otro tipo de precauciones en la práctica.

   Otro detalle en relación a los punteros a uniones es que la dirección a la que apunta un puntero a una unión es la misma que la de cada uno de sus miembros, y viceversa.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

5. Cuestiones técnicas de punteros a estructuras y uniones

   Todos los punteros a estructuras tienen la misma alineación y representación.
   Todos los punteros a uniones tienen la misma alineación y representación.
   ¿Qué significa esto?

   Un puntero es también un objeto que ocupa un lugar en memoria, y como tal, es susceptible de ser alineado por el compilador en la memoria RAM, según diversos criterios. También, la manera en que se representa internamente la dirección de memoria del dato apuntado puede diferir según las circunstancias.
   El alineamiento se refiere al modo en que se administra la memoria, por ejemplo, alojando objetos en posiciones múltiplos de una potencia de 2, o cualquier otro criterio conveniente, y teniendo en cuenta cuántos bytes se requieren para resepresentar toda la información del puntero, o sea, la información de "la posición a la que apunta".
   La representación se refiere a la convención utilizada para codificar mediante bits la información de "la dirección de memoria a que apunta" el objeto puntero.
   Resulta que alineamiento y representación son intercambiables para cualesquiera estructuras. Esto implica, por ejemplo, que al hacer un cast entre distintos punteros a tipos struct, si bien se cambia el tipo apuntado, los bits que representan la dirección de memoria no cambian.
   Lo mismo ocurre entre punteros a uniones.

   Sin embargo, esto no puede asegurarse para punteros a otros tipos de datos. De hecho, hay casos reales de esta situación (aunque son excepcionales).
   Es más, el estándar declara esto en dos cláusulas separadas, de lo cual se desprende que el alineamiento y/o representación de una estructura "puede" ser diferente que el alineamiento y/o estructura de una unión.
   Sólo podemos asegurar compatibilidad entre los punteros a estructuras entre sí, y por otro lado entre los punteros a uniones entre sí.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

6. Acceso a campos puntero mediante el operador dirección

   Ya hemos visto que el operador -> permite acceder a la estructura apuntada por un puntero.
   De modo que, si por ejemplo x es un puntero a una estructura que contiene un campo llamado obj, es equivalente escribir x->obj a (*x).obj, o sea, primero se desreferencia el puntero, se obtiene el objeto apuntado, que es *x, y se accede a su campo obj.
   Hay una forma sinónima y formalmente equivalente, explícitamente indicado por el estándar C99 (a fin de eliminar cualquier posible ambigüedad), y es la siguiente:
   Si q es una estructura que contiene un campo, digamos c, es lo mismo escribir (&q)->c que q.c.
   Es decir, si se toma la dirección de memoria de un objeto con tipo estructura, y luego se accede a uno de sus campos con el operador "flecha" ->, se obtiene exactamente el mismo resultado, en todo sentido, que haber accedido directamente al campo mediante el operador punto ..
   Naturalmente, lo mismo vale para uniones.

CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
 
Organización

Comentarios y Consultas

25 Febrero, 2014, 05:53 am
Respuesta #57

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
57. Clasificación de tipos incualificados

Para esta sección y las que siguen necesitamos tener presentes las clases de tipos de datos que hemos definido hasta ahora (ver Sección 51 y siguientes):

(Precaución)
   No hemos acabado aún de definir todos los posibles tipos de datos en C, ni mucho menos abarcar todas sus vicisitudes.
   Esto no sería problemático en principio, porque podemos decir que los agregamos después, y listo...
   Pero no es tan simple: los nuevos tipos que falta definir se construyen también en forma recursiva.
   Esto implica que cuando hablamos de tipos puntero a T, consideramos que T puede ser uno de esos tipos que aún no hemos discutido. Lo mismo con tipos de arrays, o con campos de estructuras y uniones.
   Lo que podemos hacer, por ahora, para no marearnos, es considerar que lo que se dice a continuación abarca a los arrays, punteros, estructuras y uniones sólo de los tipos que hemos visto hasta ahora.
   Cuando completemos todos los detalles en el futuro, podemos volver a esta sección y "redefinir" de manera más amplia los arrays, punteros, estructuras y uniones.
[cerrar]

   Hasta ahora tenemos esta clasificación:
   
   Tipos enteros (ver secciones 50 y 51). [Incluye a los enumerados.]
   Tipos aritméticos (ver sección 51).
   Tipos escalares: abarca a los tipos aritméticos y también a los tipos que son puntero a T para un tipo dado T.
   Tipos agregados: son los tipos array y estructura. Las uniones no se consideran "agregados".
   Tipos unión: las uniones.
   Tipos de declarador derivado:: son los array de T, o punteros a T, o las funciones que retornan T, para un tipo dado T.
         Nota: Los tipos función aún no los hemos definido, y serán tratados en secciones posteriores.[/color]
         Definición: Una derivación de tipo declarador a un tipo dado T es un tipo array de T o punteros a T o función que retorna T
         Esa definición no permite que haya otras posibilidades en esta clase de tipos.
   Tipos función: (Definidas más adelante).
   Tipos incompletos: (Definidos más adelante. Incluyen el tipo void).
   Tipos derivados: Aquellos que se obtienen recursivamente desde los tipos anteriores mediante arrays, estructuras, uniones, punteros, funciones (las que especifican a su vez otros tipos en sus parémetros y en el tipo de retorno).
         Nota: Hay ciertas restricciones en los tipos de los parámetros y/o del retorno de una función. (Sobretodo, en lo referido a la prohibición de arrays).
         Categoría de tipo: Es la derivación más exterior en la construcción de un tipo derivado.
   Tipo incalificado: Cualquiera de los tipos mencionados hasta ahora (o sea, los de la presente lista).

La fiesta de tipos continúa, pero por ahora no conviene siquiera mencionar cómo sigue.




Organización

Comentarios y Consultas

25 Febrero, 2014, 05:56 am
Respuesta #58

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
58. Expresiones constantes en C

1. Expresiones constantes enteras

   Ya hemos hablado de las expresiones constantes enteras en C (ver: 50. Expresiones constantes enteras. Enumeraciones. Tipos enteros).

   Una expresión constante entera se requiere, al menos, en casos como los que siguen:

   (1) Para especificar el tamaño de un campo de bits en una estructura con tales campos (es un tema que aún no hemos visto y aquí no explicaremos).
   (2) Para especificar el valor de una constante de enumeración.
   (3) Para especificar el tamaño de un array (en ciertos casos esto no se exige a algunos arrays).
   (4) Para especificar un valor en una constante case (dentro de un bloque switch()).
   Puede que haya otros posibles usos.

La lista es sólo informativa, y los detalles aparecerán cuando sea (o haya sido) conveniente.



2. Expresiones constantes aritméticas

\( \bullet \) Expresión constante aritmética: Será definida en forma recursiva, así:

            (0) Una expresión constante entera es también considerada una expresión constante aritmética
            (1) Una constante de punto flotante (decimal o hexadecimal [ver párrafos constantes de punto flotante en C y Constantes hexadecimales en 19. Números de punto flotante. Estándar C99. Constantes]).
            (2) Una expresión entre paréntesis (c), donde c es una expresión constante aritmética, es también una expresión constante aritmética.
            (5) Un cast explícito (T) c donde T es un tipo aritmético, y donde c es una expresión constante aritmética, es también una expresión constante aritmética.
                Notemos que no se admiten aquí casts desde tipos escalares no aritméticos, ni tipos derivados, etc.
            (5') Un cast explícito dentro de una operación hecha con sizeof, cuyo resultado es una expresión constante entera.
            (6) Una operación unaria ~c, !c, +c, -c, donde c es una expresión constante aritmética, también es una expresión constante aritmética.
            (7) Una operación binaria a*b, a/b, a%b, donde a y b son expresiones constantes aritméticas, es también una expresión constante aritmética.
            (8) Una operación binaria a+b, a-b, donde a y b son expresiones constantes aritméticas, es también una expresión constante aritmética.
            (9) Una operación de bits a<<b, a>>b, donde a y b son expresiones constantes aritméticas, es también una expresión constante aritmética.
           (10) Una operación binaria a<b, a>b, a<=b, a>=b, donde a y b son expresiones constantes aritméticas, es una expresión constante aritmética.
           (11) Una operación binaria a==b, a!=b, donde a y b son expresiones constantes aritméticas, es una expresión constante aritmética.
           (12) Una operación de bits a&b, donde a y b son expresiones constantes aritméticas, es también una expresión constante aritmética.
           (13) Una operación de bits a^b, donde a y b son expresiones constantes aritmética, es también una expresión constante aritmética.
           (14) Una operación de bits a|b, donde a y b son expresiones constantes aritmética, es también una expresión constante aritmética.
           (15) Una operación binaria a&&b, donde a y b son expresiones constantes aritmética, es también una expresión constante aritmética.
           (16) Una operación binaria a||b, donde a y b son expresiones constantes aritmética, es también una expresión constante aritmética.
           (17) Una operación ternaria q?a:b, donde q, a y b son expresiones constantes aritmética, es también una expresión constante aritmética.

   Dada una expresión constante aritmética, decimos que es una expresión constante de punto flotante si alguno de los operandos es de punto flotante, y no se trata de una expresión constante entera.
   Para entender bien esto, tenemos que revisar la definición de expresión constante entera, en la cual puede haber operandos no enteros en algunos casos (por ejemplo, son sizeof, o un cast de una constante literal de punto flotante a un tipo entero).
   Una observación importante es que no pueden haber operandos que sean punteros (ni siquiera constantes), así como tampoco constantes de otros tipos.

   Tanto en el caso de expresiones constantes enteras como en el de expresiones aritméticas constantes vemos algo de discusión en torno a los casts explícitos y sizeof (ver (5) y (5')).
   Mi opinión (y la de otros también) es que el estándar no es exacto al último detalle en la definición de lo que se supone que es una expresión constante entera y una expresión constante aritmética.
   El estándar afirma que, salvo en el caso de VLA (variable length arrays, o sea: arrays de longitud variable, que aún no hemos explicado), el operador sizeof arroja como resultado un valor entero que es conocido en la etapa de compilación, y se puede considerar una constante (en un sentido más amplio que explicamos abajo), que además es entera.
   Podemos decir, sin temor a equivocarnos, que el resultado de sizeof es, en esos casos, una expresión constante entera.
   Las dudas vienen cuando sizeof es también aplicado a una expresión muy complicada, que puede tener variables de todo tipo.
   El estándar establece que, el operador sizeof no evalúa la expresión a la que afecta, y que sólo "se fija" en los tipos de datos involucrados en la expresión.
   Salvo para VLAs, el tamaño de cualquier tipo de datos (que no esté incompleto), o del tipo de datos de cualquier expresión, puede saberse en la etapa de compilación.
   Por esa razón se le considera una expresión constante entera.
   No discutiremos aquí una variedad de sutilezas que sólo desvirtuarían la exposición.

   Importante: Las constantes aritméticas que involucran operandos de punto flotante se tienen que evaluar durante la etapa de compilación.
   No obstante, los valores de punto flotante pueden tener un comportamiento distinto en el entorno de traducción que en el entorno de ejecución.
   Para prevenirse de posibles problemas al respecto, el estándar C establece convenciones de con cuánta precisión, y de qué manera se tienen que evaluar este tipo de constantes.

       (*) Si una expresión de punto flotante se evalúa en el entorno de traducción, tanto la precisión como el rango será al menos tan grade como si la expresión fuera evaluada en el entorno de ejecución.
           Esta norma implica que se conocen de antemano algunos detalles del método de evaluación, redondeo y otras minucias.
           Por ejemplo, se tiene que conocer de antemano el valor de la macro FLT_EVAL_METHOD.
           El método de redondeo es to-nearest.




3. Constantes de dirección

   Las constantes de dirección (o de referencia) se definen mediante las siguientes especificaciones:

     (1) Un puntero nulo es una constante de dirección.
     (2) Un puntero a un lvalue que designa un objeto de duración static.
     (3) Un puntero a un designador de función.

   Los casos (2) y (3) merecen más explicación, y no es tema de esta sección, sino de secciones futuras.
   Observemos que no se habla de "constantes escalares", porque las constantes aritméticas no se incluyen en la lista.
   Este tipo de constantes no necesariamente tienen un valor predecible en la etapa de compilación.

     (4) Este tipo de constantes se debe obtener mediante el operador de dirección &
         (es decir, tomando la dirección de un objeto mediante ese operador);
     (5) o bien convirtiendo una constante entera (un literal entero) a un puntero mediante un cast explícito;
     (6) o implícitamente por el uso de un expresión de tipo array o de tipo función.

   El caso (4) se refiere a los puntos (2) y (3), cuando sea aplicable.
   El caso (5) es llamativo, pues nos permite especificar una dirección explicita de la memoria RAM de la computadora. Esto en general no tiene mucho sentido, porque los sistemas operativos en los que nuestros programas correrán son quienes controlan las direcciones de memoria RAM, y a veces prohíben el acceso a determindas direcciones.
   Pero eso no tiene nada que ver con la sintaxis del lenguaje: el punto (5) es una manera más de definir una constate de dirección.
   Su uso tiene sentido en contextos free-standing, vale decir, donde seguramente no hay un sistema operativo como Windows o Linux. Por ejemplo, en la programación de micros, o en cualesquiera casos donde sea posible acceder a determinadas zonas de memoria que se conocen predeterminadas por alguna razón (dispositivos con una memoria ROM ó Read Only Memory).
   El punto (6) se refiere a que el resultado de la expresión tiene tipo de datos de un array o de una función.

     (7) Para crear la constante de dirección pueden usarse expresiones involucrando los operadores que hemos visto para las expresiones constante enteras y aritméticas, pero también pueden usarse los siguientes: [], ., ->, &, *, y casts explícitos a tipos puntero.
         Sin embargo, en ningún caso se debe acceder (ni siquiera con truquitos) al valor del objeto mediante aprovechando combinaciones de estos operadores.
         El índice pasado a [] tendrá que ser una expresión constante entera para que todo funcione correctamente.
         Se observa que no están permitidas constantes de dirección apuntando a la dirección de estructuras o uniones, pero sí está permitido apuntar a la dirección de un puntero a una estructura o unión.



4. Expresiones constantes en/para inicializadores

   Una expresión constante en/para inicializadores es una de las siguientes:

      (1) Una expresión aritmética constante.
      (2) Una constante puntero nulo.
      (3) Una constante de dirección.
      (4) Una constante de dirección para un tipo de objeto (o sea, no apunta a un tipo "función") más o menos una expresión constante entera.
          Esto último puede expresarse como A + N donde A denota la constante de dirección (definida en párrafo 4), y N denota una expresión constante entera.

   Como su nombre lo indica, este tipo de constantes son admitidas cuando se inicializa una variable. Aún no hemos tratado este tema...



5. Expresiones constantes

   Una expresión constante es una expresión que involucra operadores y operandos aritméticos tales que:

      (0) La expresión dada contiene los operadores listados, por ejemplo, en los puntos (1) a (15) de la Sección 50, en el párrafo dedicado a las expresiones constantes enteras.
      (1) Ninguno de los operadores es de asignación, incremento (++), decremento (--), operador coma (,), o una llamada a una función.
          Se exceptúa el caso de subexpresiones que por alguna razón nunca serían evaluadas.
      (2) Es posible determinar su valor en la etapa de compilación.
          Esto quiere decir que el compilador es capaz de determinar el valor de la expresión, y ponerlo directamente en la versión ejecutable del programa.
          Que "sea capaz" no quiere decir que efectivamente lo haga. El compilador puede negarse a hacerlo.
          Pero "ser capaz" de esto es lo que determina qué tipo de expresiones son las consideradas constantes.
          Para cumplir un requisito como éste, ninguno de los operandos de la expresión, idealmente, tendría que ser un objeto o una llamada a función.
          La excepción serían algunas de las expresiones afectadas por el operador sizeof (sólo aquellas que permiten anticipar el tamaño de un objeto durante la etapa de compilación),
          o ser parte de una subexpresión que nunca es evaluada.
          (Estas misteriosas expresiones nunca evaluadas son las afectadas por sizeof y también algunos casos de "corto-circuito" en expresiones booleanas, asunto que analizaremos en posts futuros).
      (3) Las expresiones constantes pueden usarse en cualquier sitio del programa donde una constante (literal) podría usarse.
      (4) Una expresión constante tiene un valor, calculado a partir de sus operandos y operadores, que ha de ajustarse al tipo de datos resultante de aplicar dichas operaciones (este tema lo veremos en futuras secciones).
          Este valor debe estar en el rango de valores asociado al tipo de datos de la expresión.

   Las constantes literales, obviamente, son un caso especial de expresión constante.
   Las expresiones constantes enteras son un caso especial de expresiones constantes aritméticas.
   A su vez las expresiones aritméticas son un caso especial de expresiones constantes.

   En la Sección 50 nosotros nos hemos restringido a ciertos operadores específicos para construir las expresiones constantes enteras.
   Los mismos operadores hemos usado aquí para construir las expresiones constantes aritméticas.
   Hemos descartado los operadores unarios: & * . ->, el operador binario [] y las expresiones del tipo literal compuesto (aún no hemos tratado este tema).
   Se puede demostrar que estos operadores no pueden estar en las expresiones constantes enteras.
   El operador & requeriría, si fuera aplicable, crearía una referencia, es decir, un valor de tipo puntero a, pero sólo se permiten operandos enteros en una expresión constante entera.
   El operador * requeriría aplicarse a un objeto, el cual tiene una dirección de memoria asociada. Esta información no puede saberse en la etapa de compilación.
   Los operadores . y -> requieren ser aplicados a objetos tipo estructura, o bien a referencias (que apuntan a estructuras). En cualquier caso, el operando al que se aplicarían no sería un valor entero.
   El operador binario [] se usa con arrays, y requeriría entonces que uno de sus operandos sea de tipo array que, de nuevo, no es un entero.
   (El único caso en que se permite un operando no entero es un valor de punto flotante inmediatamente precedido por un cast a tipo entero).

   De este modo, la definición dada por el estándar coincide con la nuestra.
   Lo mismo se puede hacer para las expresiones constantes aritméticas.

   Dado que los objetos requieren una posición en memoria determinada durante la ejecución del programa, no es completamente determinable en la etapa de compilación.
   Por lo tanto, las variables y cualesquiera otros objetos no pueden aparecer en una expresión constante.

   Un ejemplo poco intuitivo de esto son las cadenas literales. Ellas son "constantes" de tipo array de caracteres, por definición, pero tienen el inconveniente de que un array no es sólo un valor, sino también un objeto.
   Esto implica que tiene asociada una dirección de memoria, y es por eso que pueden, por ejemplo, aparecer en expresiones que requieren un puntero a char.
   O sea que las cadenas literales no se pueden considerar como una expresión constante.

   Existe un tipo de expresión en C99 llamada literal compuesto que crea un objeto en memoria de un determinado tipo.
   Aunque en general este objeto se inicializa con valores constantes, el hecho de que sea un objeto en memoria hace que no pueda formar parte de una expresión constante.

   Las expresiones constantes son una subclase de las expresiones constantes en/para inicializadores.
   Las constantes de dirección no son una subclase de las expresiones constantes.



6. Expresiones constantes enteras para inclusión condicional

   Todavía existe una clase de expresiones constantes aún más restringidas, que son una subclase de las expresiones constantes enteras,
   las cuales serían idóneas para usar con la directiva #if.
   Aquí no explicaremos los detalles de #if, sino que sólo detallaremos el tipo de expresiones constantes que admite.

      (0) Una expresión constante entera para inclusión condicional es, sintácticamente, una expresión constante entera, sujeta a estas restricciones:
      (1) No puede haber operadores cast explícitos.
          En particular, no pueden aparecer constantes de punto flotante, porque en las expresiones constantes enteras éstas sólo se aceptaban precedidas por un cast explícito a un tipo entero.
      (2) No puede haber constantes de enumeración. (O sea, declaradas con enum).
          Más generalmente, no puede haber identificadores, salvo aquellos que designan macros.
          Este requerimiento obedece a que #if es una directiva analizada por el preprocesador en una etapa temprana de las acciones del compilador.
          Se procesa dicha directiva en una etapa en la que se sólo es posible saber si un identificador denota una macro o no.
          Luego, otro tipo de identificadores, directamente no se sabe lo que son.
      (3) No puede aparecer el operador sizeof.
          El problema es que sizeof es una palabra clave del lenguaje, y las
          palabras clave no son interpretadas por el preprocesador en etapas demasiado tempranas.

   Sin embargo, la palabra "sizeof" sí que puede aparecer en una declaración #if, pero será considerada un identificador que no ha sido asociado a macro alguna, y por lo tanto tenrá un valor constante de 0.



7. Conclusión.

   Las expresiones constantes de distintos tipos son requeridas en ciertos contextos.
   En general hacen se trata de valores que no cambian a lo largo de un programa, y que pueden determinarse en la etapa de compilación.

   Aquí conviene reflexionar un poco sobre las consecuencias de predeterminar valores en la etapa de compilación.
   Puede ocurrir que el cálculo del valor de ciertas constantes sea distinta en el entorno de traducción que en el entorno de ejecución.
   Por ejemplo, esto podría pasar con los valores de punto flotante, o el rango de los valores enteros.
   Sin embargo, el estándar C99 obliga a los compiladores a trabajar de forma coherente, asegurando que las evaluaciones de expresiones constantes en el entorno de traducción se comporten de igual modo que en el entorno de ejecución.
   O sea que los valores obtenidos en la etapa de compilación serán los mismos que los del programa ejecutable.
   Puede haber excepciones, pero en ese caso lo que ocurre es que en la etapa de compilación se realizan cálculos más precisos, de manera que no se pierda información o precisión al llevar el valor resultanto a la versión ya ejecutable.
   En resumen, no habrá sorpresas al utilizar constantes pues los valores obtenidos con expresiones constantes serán transparentes al programa en ejecución, comportándose como si hubieran sido calculados allí.

   Pero todavía queda algo en el tintero: las constantes de caracter.
   Como hemos discutido asiduamente en varios posts anteriores, en que estudiamos la codificación de caracteres y sus vicisitudes, bien puede ocurrir que la codificación de caracteres en el entorno de traducción sea distinta al del entorno de ejecución.
   Así, una constante de caracter como 'A', ¿qué valor tendría?
   Supongamos que la compilación se hace en un entorno con EBCDIC, y que el programa ejecuta en un entorno con ASCII.
   El código de la letra A en EBCDIC es 193, mientras que en ASCII es 65.

   Según el estándar C99, los valores numéricos de las constantes de caracter en el entorno de traducción se transforman en "otro" valor en el entorno de ejecución, según una fórmula definida por el compilador.
   Si bien no es demasiado claro en el documento estándar, cada caracter se transforma en su equivalente. O sea que nuestro ejemplo de 193 se transformaría en 65.
   Por lo tanto, podemos esperar que las constantes de caracter, que adquieren un valor entero, al formar parte de una expresión constante entera, van a tener el valor que tendrían al ejecutar el programa. Así que 'A' valdría 65.
   Pero esta transformación ocurre en una etapa algo tardía de las etapas del preprocesador.
   La etapa que procesa las declaraciones #if es anterior, y entonces, si en una tal declaración tenemos una expresión constante enterna para inclusión condicional, no podemos asegurar que el valor de 'A' sea 65.
   Podría ser que, en tal caso, mantenga el valor del código de A en el entorno de traducción, o sea 193.
   La moralaja es que 'A' puede tener dos valores distintos como expresión constante, en contextos distintos de nuestro programa.
   Y una observación más. Esto que acabamos de observar no necesariamente implica que, en nuestro ejemplo, tengamos obligadamente los dos valores distintos de 193 y 65 para 'A'.
   En lo que se refiere a las cinclusiones condicionales #if nunca es claro qué camino va a tomar el compilador:
       (a) ¿Elegirá darle a 'A' el mismo valor que tendría en el entorno de ejecución? (nuestro 65).
       (b) ¿Elegirá darle a 'A' el valor del código de la letra A en el entorno de traducción? (nuestro 193).
       (c) ¿Elegirá darle a 'A' algún otro valor, basado en quién sabe qué caprichos? (qué sé yo, ¿un 49 te va?)

   El estándar C99 nada especifica al respecto, y esto nos obliga a ser muy cuidadosos en el modo en que usamos expresiones constantes enteras que involucren constantes de caracter.
   Si las usamos dentro de inclusiones condicionales #if podemos tener valores diferentes a los que tendrían en otros contextos.
   Si no, al menos tenemos la certeza de que tendrán siempre un comportamiento bien definido, y determinado por los valores de los códigos de los caracteres en el entorno de ejecución.
   Pero esto no nos produce valores siempre predecibles, porque no son valores universales, sino que dependen de qué entorno de ejecución tengamos.

   (Nota: El entorno de ejecución no es exactamente el sistema operativo en el que se nos ocurre ejecutar nuestro programa, sino más bien el sistema en el que el compilador cree que será ejecutado nuestro programa. Esta información se la debemos proveer en el momento de la compilación de nuestro programa, o sea, avisarle para qué sistema queremos que compile).



Organización

Comentarios y Consultas

28 Febrero, 2014, 01:57 am
Respuesta #59

argentinator

  • Consultar la FIRMAPEDIA
  • Administrador
  • Mensajes: 7,332
  • País: ar
  • Karma: +0/-0
  • Sexo: Masculino
  • Vean mis posts activos en mi página personal
    • Mis posts activos (click aquí)
59. Efectos colaterales, puntos de secuencia y ejecución del programa

1. Introducción informal a los operadores

   A grandes rasgos, un operador en C es "algo" que instruye al programa a que haga un cálculo.
   Ese algo viene indicado en general por un signo que indica la operación que se desea realizar.
   Pero hay ocasiones en que un mismo signo se usa para varias operaciones distintas,
   o bien para operar sobre tipos de datos distintos (teniendo que especificar cuál es el resultado de la operación en cada caso),
   e inclusive hay casos en que una operación se hace sin operador alguno.

   Los operadores permiten formar unas entidades sintácticas del lenguaje que se llaman expresiones.
   Sin embargo, pueden existir expresiones que no se formen necesariamente mediante operadores, sino por otros medios.

   En esta Sección estudiamos cuestiones formales del flujo del programa que están involucradas en la definición de los operadores que permiten formar la mayoría de las expresiones.



2. Explicación del flujo del programa, y escala de tiempo discreta

   El programa ejecuta instrucciones a lo largo de una escala de tiempo que podemos suponer discreta, pues las computadoras realizan ciertas operaciones internas distribuyendo sus quehaceres en "clocks" del CPU.
   Lo que sea que la computadora haga, nos lo devuelve sólo tras haber terminado cada uno de sus "clocks" o ciclos de tiempo.
   Y entonces el programa inicia en un instante o clock \( t_0 \), y ejecuta (o no) instrucciones en los instantes siguientes \( t_1,t_2,... \).
   Este modelo de tiempo computacional se ajusta bastante bien al estándar C, y no conviene discutirlo más en este lugar. Lo tomaremos así.

   Sin embargo, cada tarea que realiza un programa en C se traduce en "muchas" (o "muchísimas") instrucciones que hace directamente el CPU.
   O sea que cada tarea programada en C puede consumir cientos, miles o millones de "clocks".
   Estos detalles no son, por lo general, de incumbencia de un programador en C, porque no tiene el programador demasiado control sobre cuánto tarda en ejecutarse una instrucción dada.
   Esta imprecisión obedece a que un mismo programa, en una misma computadora, puede compilarse con compiladores diferentes, y éstos pueden adoptar criterios de optimización (o ralentización) distintos, sin que podamos anticiparnos a los detalles.
   Aún si supiéramos cómo nuestro compilador optimiza ciertas instrucciones en C, resulta que nuestra programa se ejecutará en sistemas operativos distintos, o simplemente en máquinas o entornos distintos, que en general no podemos predecir. Esto también es un factor de incertidumbre en el tiempo consumido por las instrucciones individuales de nuestro programa.

   Como programadores de C tenemos que preocuparnos mejor por otro tipo de asuntos.
   ¿Qué instrucciones se ejecutan primero, y cuáles después?
   Esto depende de la sintaxis del lenguaje, pero también de factores imprecisos que el estándar no regula, y que tenemos que tener en cuenta.
   ¿Cómo tener en cuenta esas imprecisiones en el orden de ejecución de ciertas instrucciones, y para qué?
   Básicamente, tenemos que tenerlas en cuenta para no asumir prejuiciosamente que nuestro programa se comportará de un modo u otro.
   Tenemos que tener completa certeza de todas las incertezas del lenguaje.

   El lenguaje C nos asegura que, en determinados sitios del programa, sí o sí podemos estar seguros de que ciertas operaciones han sido realizadas.
   Esos sitios en el programa fuente se llaman puntos de secuencia.
   Las tareas que quedan pendientes o a medio hacer son, a grandes rasgos, las que se denominan efectos colaterales.
   El lenguaje C nos aporta sólo la certeza de que en los puntos de secuencia se dan por terminados todos los efectos colaterales acumulados hasta ese momento.



3. Descripción genérica de tareas comunes en C

   El tipo de tareas que un programa en C realiza son las siguientes (quizá haya algunas más):

         (a) Crear un objeto en el espacio de almacenamiento (la memoria RAM).
             Esto incluye reservar un espacio contiguo en la RAM para alojar el objeto, e inicializarlo (o no) con cierto valor.
             En general no se asigna ningún valor concreto a un objeto nuevo, a menos que el programador explícitamente lo indique.
             Así que al crear un objeto, el "valor" viene a ser los bits que ya estaban por una u otra razón en esa posición de memoria.
             O sea, es contenido basura. A veces, incluso, este contenido puede ser inválido (por no representar valores del tipo asociado al objeto)
         (b) Modificar el contenido de un objeto (es decir, cambiar los bits de un objeto en memoria).
             Esto en general se realiza con operadores de asignación. Pero puede haber otros métodos.
             Un detalle muy sutil a tener en cuenta aquí es que en determinadas aplicaciones el valor
             de un determinado objeto puede ser modificado por vías externas al programa.
             Por ejemplo, por otro proceso del sistema operativo que tenga permiso para meter sus garfios ahí.
         (c) Destruir un objeto en el espacio de almacenamiento.
             Como consecuencia, la posición de memoria que antes se había reservado para el objeto ya no es válida.
             Tampoco son válidos los valores que allí estaban.
             Al intentar acceder al objeto o su valor, puede haber problemas. Es un error de programación.
         (d) Evaluar una expresión.
             Esto es, generar un valor de un determinado tipo, acorde a los valores y tipos de operandos afectados por un operador.
             El orden de las operaciones no siempre está determinado.
             Por ejemplo, en la suma (3 + 4) + (5 * 3) no está claro si C primero evaluará la expresión (3 + 4) o bien (5 * 3). En cualquier caso el resultado será el correcto: 22.
             Por cosas como ésta se dice que el lenguaje C no funciona exactamente como una máquina determinística.
             Para ello, tendría que saberse a ciencia cierta qué operación se hace primero, en todo momento.
             En ciertas circunstancias al programador le conviene jugar con el orden en que las operaciones se realizan, porque puede redundar en beneficios de costo de ejecución, entre otras cosas.
             Pero lamentablemente esto no siempre se tiene, y el programador debe poner de su parte para lograr determinado orden de ejecución.
         (e) Lanzar una señal.
             Las señales son indicadores del estado del programa.
             Informan de si ciertas operaciones han dado algún tipo de error o si está todo O.K.
             Puede haber señales de todo tipo (producidas en operaciones aritméticas, operaciones de archivo, de memoria, etc.)
             No hay consenso en las señales que lanza el programa. Depende del compilador, el entorno, etc.
             Puede ocurrir que ciertas señales sean causadas por el sistema operativo, metiendo él sus narices en nuestro programa.
             El programador debe estar atento a las posibles señales, y elegir a voluntad darles o no tratamiento.
         (f) Llamar a una función.
             Una función es un bloque de código con un nombre, que nos permite resumir fácilmente una operación importante.
             (Serán tratadas unos posts más adelante.)
             Al llamar una función, se ha de esperar que ésta haga todas sus operaciones internas, y luego retorno el control
             al flujo principal del programa. Cuando hay varias funciones evaluándose, las llamadas pueden superponerse.
             No debemos creernos que los parámetros de una función se evalúan en el orden que aparecen,
             ni tampoco que una función se termina de evaluar justo antes de las expresiones que siguen en una expresión.
         (g) Modificar un archivo.
             Los archivos, a grandes rasgos, son bloques de información de tamaño variable que residen fuera de la memoria RAM.
             (Hoy en día lo más común es que estén en discos rígidos, pero también CDs, pendrives, etc.
             También teclado, pantalla, impresora y otros dispositivos se consideran archivos.)
             Las operaciones de archivo suelen ser lentas, e involucran acceso al mismo, comprobar su estado y/o disponibilidad,
             modificar o leer datos, descargar buffers, cerrar el archivo, etc.
             Luego de terminadas las operaciones deseadas, se devuelve el control al programa.
             Esto no implica que mientras se accede al archivo el programa no pueda estar haciendo otras tareas al mismo tiempo.
         (h) Acceder a objetos volátiles.
             (Tema avanzado, que no estudiaremos aquí.)
             Se refiere a objetos que pueden ser modificados por medios externos al programa mismo,
             o bien de otras maneras no especificadas, y en cualquier momento,
             sin respeto por el flujo temporal del programa.



4. Operadores, operandos, operaciones, puntos de secuencia y efectos colaterales: definición formal

   Damos aquí definiciones más formales de estos términos, con la idea de definir con precisión el comportamiento de los operadores en C.

   Puntuador: es un símbolo que tiene significancia sintáctica y semántica independiente.
                  Algunas veces puede realizar alguna operación.
                  En cuyo caso, denota un operador.
                  A veces son delimitadores, entre otras posibilidades sintácticas.
   Operador:  Un elemento del programa que determina y lleva a cabo una operación.
   Operación: (Este término no viene definido directamente en el estándar, pero puede deducirse que dice esto:)
                  Calcular un valor (de un cierto tipo), dar un designador de función,
                  producir un efecto colateral, o bien una combinación de todo esto.
   Operando:  Es una entidad sobre la cual un operador actúa.
   Efecto colateral: Es un cambio en el estado del entorno de ejecución.
                  Los tipos de efectos colaterales considerados por el estándar C son los siguientes:
                  (1) Modificar un objeto (esto incluye las tareas (a), (b), (c)).
                  (2) Acceder a (o leer) un objeto volátil (acción (h)).
                      (Este "acceso" se explicará mejor al estudiar objetos volátiles).
                  (3) Modificar un archivo (tarea (g)).
                  (4) Llamar a una función que en su interior realiza al menos alguna de las acciones (1) a (3).

   Las tareas (d) (evaluar expresiones) y (f) (llamar a una función) sólo producen efectos colaterales si modifican un objeto mediante un operador de asignación (caso (1)), o bien realiza alguna de las otras acciones (2), (3) ó (4).
   La tarea (e) (lanzar señales), lo cual se dice que produce efectos colaterales, ha de llevarse a cabo por medio de algunas de las (4) maneras que se han mencionado.

   En el estándar C se habla mucho de los "famosos" efectos colaterales, y se dice que tal o cual cosa puede producir efectos colaterales.
   No siempre queda claro a qué diablos se refiere con eso en cada caso.
   Pero, dada la manera en que se han especificado los efectos colaterales, a saber, como una lista de (4) opciones disponibles, tenemos que entender que un efecto colateral tiene la libertad de manifestarse sólo como una de esas (4) posibilidades (o una combinación de ellas).

   Acerca de cuáles acciones producen, o no, efectos colaterales, lo iremos viendo a lo largo del curso.
   Si todo va bien, al final podremos hacer una lista con todas las situaciones que producen dichos efectos colaterales.

   Lo que nos interesa ahora a nosotros es que, entre dos puntos de secuencia dados, nunca es claro en qué orden se van a producir determinados efectos colaterales.
   Esto puede afectar tanto al resultado de algunas expresiones, como al flujo mismo del programa.
   Sin embargo, al llegar a un punto de secuencia, estamos seguros que todo efecto colateral ha sido ya "resuelto".
   Esto quiere decir que todo acceso a un objeto volátil se ha terminado, que toda modificación a un objeto en memoria se ha culminado exitosamente, que toda modificación que se planeaba hacer a un archivo está lista, y que toda función que iba a realizar alguna de esas cosas también ha terminado con lo suyo.

   Solemos ver en los libros de texto que se aconseja "evitar los efectos colaterales".
   Eso es una superstición y un sinsentido.
   Programar en C es provocar continuamente efectos colaterales.

   En todo caso, lo importante, es que el programador sea conciente de en qué partes del programa es dable predecir el orden en que ciertas operaciones se han realizado, y en qué partes no.
   Y en todo caso, si resulta importante para un programa que las operaciones se realicen en un orden predeterminado, entonces el programador debe encargarse de establecer a mano los puntos de secuencia que mejor convengan.
   Es decir, poniendo un punto de secuencia en determinado lugar, forzamos al programa a esperar a  que terminen las acciones que tiene pendientes (las llamadas efectos colaterales), y no continuar realizando las siguientes operaciones hasta haber terminado completamente éstas.
   O sea, se espera a que "se resuelvan" todos los efectos colaterales.

   Entonces, una cosa interesante a estudiar es: ¿cuáles son los puntos de secuencia en el lenguaje C?
   Esto no lo podemos estudiar completamente en este momento, pues nos faltan muchas herramientas importantes.
   Pero podemos decir, por ejemplo, que el terminador de instrucción "punto y coma" ; determina un punto de secuencia.
   También, cuando se encierra un bloque entre llaves: {  /* ... */ }, las "llaves que cierran" determinan un punto de secuencia.

   Importante: Un compilador puede decidir que una porción de programa no se ejecute, ni se tenga en cuenta de modo alguno, si es capaz de deducir que no tendrá efectos colaterales de ningún tipo.
   Un ejemplo de esto sería la siguiente instrucción:

     7 + 3*5/9 - 88*5;

   Esas constantes evalúan a una expresión cuyo resultado es un número entero.
   Pero no producen ningún efecto externo: no modifican ninguna variable, no modifican ningún archivo, no producen ningún efecto visible, ni cambian el estado del programa.
   ¿Y entonces para qué evaluar esa expresión? Respuesta: para nada. Se puede obviar.



Organización

Comentarios y Consultas