Tema 12
Registros
En este Tema, tal como indica su título, se introduce la estructura de tupla y su realización mediante la construcción RECORD de Modula-2. Se ilustra su uso con ejemplos, mostrando la posibilidad de combinar estructuras tupla y formación.
Se introduce también, de forma breve, la estructura "unión" y su realización mediante registros con variantes en Modula-2.
Finalmente se analiza la correspondencia entre los esquemas abstractos de las estructuras de datos y los de las estructuras de control.
12.1 El esquema tupla
Ya se ha explicado anteriormente que los datos pueden ser simples o estructurados, y que un dato estructurado contiene varios elementos de información, que son sus componentes. Hasta el momento se han visto dos esquemas de datos estructurados: los conjuntos y las formaciones.
Otra forma de construir un dato estructurado a base de agrupar elementos de información es usando el esquema de tupla o agregado. En este esquema el dato estructurado está formado por una colección de componentes, cada una de las cuales puede ser de un tipo diferente.
Por ejemplo, una fecha se describe habitualmente como un dato compuesto de los elementos día, mes y año. Un punto en el plano cartesiano se describe mediante dos números, que son sus coordenadas. El nombre completo de una persona es la colección formada por el nombre de pila y sus dos apellidos. Como ejemplos concretos podemos poner:
Dato Valor
fecha < 12, Octubre, 1992 >
punto < 4, -2 >
nombre_completo < Fernando, Jiménez, Rodríguez >
En estos ejemplos se ha supuesto un orden implícito entre las componentes. En la fecha se ha escrito primero el día, luego el mes, y luego el año. Es importante identificar claramente a qué componentes corresponde cada elemento de información. El punto <4, -2> es distinto del punto <-2, 4>.
En realidad el orden es hasta cierto punto arbitrario. Normalmente cada componente se identifica mediante un nombre simbólico. En los ejemplos anteriores podríamos nombrar las componentes en la forma:
Dato Valor
fecha < día: 12, mes: Octubre, año: 1992 >
punto < x: 4, y: -2 >
nombre_completo < nombre: Fernando, apellido1: Jiménez, apellido2: Rodríguez >
Identificando cada componente por su nombre, se pueden escribir en el orden que convenga:
Dato Valor
fecha < mes: Octubre, día: 12, año: 1992 >
punto < x: 4, y: -2 >
nombre_completo < apellido1: Jiménez, apellido2: Rodríguez, nombre: Fernando >
Tras las consideraciones anteriores, podríamos dar una definición de este esquema de datos:
TUPLA: Colección de elementos componentes, de diferentes tipos, cada uno de los cuales se identifica por un nombre.
Un aspecto importante del empleo de datos estructurados corresponde al punto de vista de abstracción. Una tupla, como cualquier otro dato compuesto, puede verse en forma abstracta como un todo, prescindiendo del detalle de sus componentes. La posibilidad de hacer referencia a toda la colección de elementos mediante un nombre único correspondiente al dato compuesto, simplifica en muchos casos la escritura del programa que lo maneja.
12.2 Los tipos registros
Los esquemas de tupla pueden usarse en programas en Modula-2 definiéndolos como estructuras del tipo registro. Un registro (en inglés "record") es una estructura de datos formada por una colección de elementos de información llamados campos (en inglés "fields").
12.2.1 Definición de registros
La descripción de un tipo registro en Modula-2 se hace en la forma:
RECORD
nombre: tipo;
nombre: tipo;
. . . .
END;
donde cada una de las parejas nombre: tipo, separadas por punto y coma (;), define un campo o elemento componente. Esta descripción puede usarse para definir el tipo registro como un tipo con nombre, o bien directamente como descripción del tipo de una variable. Como ejemplos de definiciones tenemos:
TYPE TipoMes = (Enero, Febrero, … Diciembre);
TYPE TipoFecha = RECORD
dia: [1..31];
mes: TipoMes;
anno: INTEGER
END;
VAR punto1, punto2: RECORD
x: REAL;
y: REAL
END;
Cuando varios campos seguidos son del mismo tipo, la descripción puede abreviarse escribiendo seguidos los nombres de los campos, separados por comas (,) y poniendo el tipo común a todos ellos sólo una vez al final, detrás del carácter de dos puntos (:). Por ejemplo:
VAR punto1, punto2: RECORD
x, y: REAL;
END;
12.2.2 Uso de registros
Al manejar datos estructurados de tipo registro se dispone de dos posibilidades: operar con el dato completo, o bien operar con cada campo por separado.
Las posibilidades de operar con el dato completo son bastante limitadas. La única operación admisible en Modula-2 es la de asignación. El valor de un dato de tipo registro puede asignarse directamente a una variable de su mismo tipo. Por ejemplo, con las definiciones anteriores es posible escribir:
punto2 := punto1
En estas asignaciones debe cumplirse la compatibilidad de tipos. Dos estructuras son compatibles en asignación si son del mismo tipo, o de tipos sinónimos, o son variables declaradas conjuntamente, como en este ejemplo. No es suficiente la llamada compatibilidad estructural; es decir, dos estructuras con los mismos campos no son compatibles si sus definiciones se hacen por separado.
También es posible pasar como argumento un dato de tipo registro a una función o procedimiento. En este caso es obligatorio que la estructura registro esté definida como un tipo con nombre, ya que en la definición de los argumentos de un subprograma sus tipos han de designarse mediante identificadores. Por ejemplo, podemos especificar los subprogramas siguientes:
PROCEDURE LeerFecha( VAR fecha: TipoFecha );
(* Leer el día, mes y año *)
PROCEDURE Distancia( p1, p2: TipoPunto ): REAL;
(* Distancia entre p1 y p2 *)
Para poder escribir la segunda especificación, habrá sido necesario definir previamente:
TYPE TipoPunto = RECORD
x, y: REAL;
END;
No sería válido poner:
PROCEDURE Distancia( p1, p2: RECORD … END ): REAL;
ya que no es admisible según las reglas de Modula-2. Además, no tendría sentido hacerlo, pues no se podría invocar el subprograma, ya que al no existir nombre para el tipo de los puntos, no podría definirse y pasarse como argumento ningún dato estructurado que fuera compatible con el tipo argumento.
Las operaciones de tratamiento de estructuras registro consisten normalmente en operar con sus campos por separado. La forma de hacer referencia a un campo es mediante la notación:
registro.campo
Cada campo se puede usar como cualquier otro dato del correspondiente tipo; es decir, se pueden usar los valores de los campos en expresiones aritméticas, se puede asignar valor a cada uno de los campos de una variable de tipo registro, y se pueden pasar los campos como argumentos en llamadas a subprogramas. Como ejemplo daremos una posible definición de los subprogramas anteriores:
PROCEDURE LeerFecha( VAR fecha: TipoFecha );
(* Leer el día, mes y año *)
VAR d: INTEGER;
BEGIN
ReadInt( d );
IF (d>0) AND (d<=31) THEN
fecha.dia := d
ELSE
fecha.dia := 1
END;
LeerMes( fecha.mes );
ReadInt( fecha.anno )
END LeerFecha;
PROCEDURE Distancia( p1, p2: TipoPunto ); REAL;
(* Distancia entre p1 y p2 *)
VAR dx, dy: REAL;
BEGIN
dx := p2.x – p1.x;
dy := p2.y – p1.y;
RETURN sqrt( dx*dx + dy*dy )
END Distancia;
Las versiones modernas de Modula-2 permiten que una función devuelva como resultado un valor estructurado. Esto representa una gran ventaja a la hora de escribir programas claros, bien organizados, pero la posibilidad de hacerlo depende del compilador particular que se esté utilizando. Por ejemplo, es muy elegante poder escribir:
PROCEDURE PuntoMedio( p1, p2: TipoPunto ): TipoPunto;
VAR m: TipoPunto;
BEGIN
m.x := (p1.x + p2.x) / 2.0;
m.y := (p1.y + p2.y) / 2.0;
RETURN m
END PuntoMedio;
. . . .
VAR a, b, centro: TipoPunto;
. . . .
centro := PuntoMedio( a, b );
Si el compilador no admite esta posibilidad, siempre es posible transformar el planteamiento anterior en otro equivalente, en que la función se transforma en un procedimiento, y el resultado se devuelve como un argumento adicional, pasado por referencia. El ejemplo siguiente, aunque menos elegante, es equivalente al anterior y será admitido por cualquier compilador.
PROCEDURE PuntoMedio( p1, p2: TipoPunto; VAR m: TipoPunto );
BEGIN
m.x := (p1.x + p2.x) / 2.0;
m.y := (p1.y + p2.y) / 2.0
END PuntoMedio;
. . . .
VAR a, b, centro: TipoPunto;
. . . .
PuntoMedio( a, b, centro );
12.2.3 La sentencia WITH
Para simplificar la redacción de los programas que usan registros campo a campo, existe una sentencia que permite fijar de antemano la estructura registro con la que se quiere operar, de manera que la referencia a cada campo pueda hacerse simplemente con el nombre; es decir, en lugar de escribir
registro.campo
bastará escribir:
campo
ya que el registro concreto con que se ha de operar ha sido establecido de antemano. El formato de esta sentencia es:
WITH registro DO
Secuencia_de_sentencias
END
En la secuencia de sentencias se puede hacer referencia a los campos del registro escribiendo sólo su nombre. Usando esta sentencia podemos reescribir, en forma más sencilla, alguno de los ejemplos anteriores:
PROCEDURE LeerFecha( VAR fecha: TipoFecha );
(* Leer el día, mes y año *)
VAR d: INTEGER;
BEGIN
WITH fecha DO
ReadInt( d );
IF (d>0) AND (d<=31) THEN
dia := d
ELSE
dia := 1
END;
LeerMes( mes );
ReadInt( anno )
END
END LeerFecha;
Las sentencias WITH pueden anidarse. Al hacerlo hay que tener en cuenta que si los registros tienen campos con los mismo nombres, se presenta una situación de ambigüedad en la parte más interna. En este caso se entiende que el nombre de un campo hace referencia al campo del registro de la sentencia WITH más interna que lo incluya y que tenga un campo con dicho nombre. Así se indica en el siguiente esquema:
VAR
a: RECORD
uno, dos: …
END;
b: RECORD
dos, tres:
END;
. . . .
WITH a DO
… uno … = a.uno
… dos … = a.dos
WITH b DO
… uno … = a.uno
… dos … = b.dos
… tres … = b.tres
END
END
De acuerdo con lo anterior, en caso de manejarse a la vez varios registros con los mismos campos sólo se podrá usar la sentencia WITH para simplificar las referencias a los campos de uno de ellos, lo cual podrá resultar quizá más confuso que si no se simplificase, por la asimetría que introduce. Por ejemplo:
PROCEDURE Distancia( p1, p2: TipoPunto ): REAL;
(* Distancia entre p1 y p2 *)
VAR dx, dy: REAL;
BEGIN
WITH p1 DO
dx := p2.x – x;
dy := p2.y – y
END;
RETURN sqrt( dx*dx + dy*dy )
END Distancia;
Como puede observarse, se hace referencia de manera diferente a los campos p1 y p2, lo cual introduce cierta confusión.
12.2.4 Ejemplo: Cálculos con fracciones
Como ejemplo de empleo de registros, se reescriben aquí algunos de los subprogramas desarrollados en el Tema 8 para realizar cálculos con fracciones. La nueva versión aparece listada a continuación:
FROM InOut IMPORT
WriteString, Write, WriteInt, WriteLn, Read, ReadInt;
TYPE TipoFraccion =
RECORD
numerador, denominador: INTEGER
END;
. . . . . . . .
PROCEDURE ReducirFraccion( VAR fracion: TipoFraccion );
(* Simplificar la fracción *)
VAR divisor: INTEGER;
BEGIN
WITH fracion DO
divisor := 2;
WHILE (divisor <= numerador) AND (divisor <= denominador) DO
WHILE (numerador MOD divisor = 0) AND (denominador MOD divisor = 0) DO
numerador := numerador DIV divisor;
denominador := denominador DIV divisor
END;
INC( divisor )
END
END
END ReducirFraccion;
PROCEDURE SumaFracciones( f1, f2: TipoFraccion ): TipoFraccion;
(* SumaFracciones = f1 + f2 *)
VAR suma: TipoFraccion;
BEGIN
suma.numerador := f1.numerador*f2.denominador + f2.numerador*f1.denominador;
suma.denominador := f1.denominador*f2denominador;
ReducirFraccion( suma );
RETURN suma
END SumaFracciones;
PROCEDURE RestaFracciones( f1, f2: TipoFraccion ): TipoFraccion;
(* RestaFracciones = f1 – f2 *)
VAR menosf2: TipoFraccion;
BEGIN
menosf2.numerador := -f2.numerador;
menosf2.denominador := -f2.denominador;
RETURN SumaFracciones( f1, menosf2 )
END RestaFracciones;
PROCEDURE LeerFraccion( VAR fraccion: TipoFraccion );
(* Lee la fraccion y la simplifica *)
BEGIN
WITH fraccion DO
ReadInt( numerador ); Write( ‘/’ );
ReadInt( denominador ); Write( ‘ ‘ );
ReducirFraccion( fraccion )
END
END LeerFraccion;
PROCEDURE EscribirFraccion( fraccion: TipoFraccion );
(* Imprime la fracción como ‘numerador/denominador’ *)
BEGIN
WITH fraccion DO
WriteInt( numerador, 1 );
Write( ‘/’ );
WriteInt( denominador, 1 );
END
END EscribirFraccion;
En este ejemplo cada fracción se almacena como un registro con dos campos, correspondientes al numerador y al denominador, respectivamente. Comparando esta versión con la del Tema 8 se aprecia que las cabeceras de los subprogramas, y por tanto las llamadas, son ahora más sencillas. El precio a pagar es una ligera complicación al tener que hacer referencia a los valores de numerador y denominador como campos de registros, y no directamente como argumentos separados.
12.3 Estructuras combinadas
Una característica de los lenguajes de programación modernos es que se pueden combinar con bastante libertad elementos de la misma naturaleza. Esto ocurre en Modula-2 con las sentencias estructuradas, que permiten anidar esquemas de tipo secuencia, selección e iteración unos dentro de otros.
Con las estructuras de datos ocurre algo similar. Se pueden definir estructuras cuyas componentes son a su vez estructuras, sin límite de complejidad de los esquemas de datos resultantes.
12.3.1 Formas de combinación
Las estructuras ARRAY y RECORD se pueden combinar entre sí de la forma que se desee. Los campos de un registro pueden ser formaciones, y las componentes de una formación pueden ser registros. Por supuesto, también se pueden definir registros cuyos campos sean a su vez registros o cualquier otro tipo de datos. Podemos analizar algunos ejemplos:
TYPE TipoPunto = RECORD
x, y: REAL;
END;
TYPE Triangulo = ARRAY [1 .. 3] OF TipoPunto;
CONST maxPuntos = 100;
TYPE ListaDePuntos =
RECORD
numeroPuntos: INTEGER;
puntos: ARRAY [1 .. maxPuntos] OF TipoPunto;
END;
TYPE Poligonal = ListaDePuntos;
TYPE Poligono = ListaDePuntos;
En estos ejemplos tenemos la representación de un triángulo como una formación de tres puntos, que son registros. La lista de puntos se estructura como un registro, uno de cuyos campos es una formación de puntos, que son a su vez registros. Las últimas declaraciones definen tipos sinónimos de la lista de puntos, en general, con nombres particulares para ser usados en casos particulares.
Los siguientes ejemplos definen registros que contienen campos que son ristras de caracteres. El último ejemplo es un registro que contiene a su vez otro registro anidado.
TYPE NombreCompleto =
RECORD
nombre: ARRAY [0 .. 20] OF CHAR;
apellido1, apellido2: ARRAY [0 .. 30] OF CHAR
END;
TYPE LineaDeTexto = ARRAY [0 .. 40] OF CHAR;
TYPE TipoProvincia =
( SinProvincia, Alava, Albacete, … Zaragoza );
TYPE DatosPersonales =
RECORD
nombreyApellidos: NombreCompleto;
domicilio: RECORD
calle: LineaDeTexto;
numero: INTEGER;
zona, poblacion: LineaDeTexto;
codigoPostal: INTEGER;
provincia: TipoProvincia;
END;
END;
Cuando se utilizan estructuras combinadas, para hacer referencia a una componente en particular hay que usar los selectores apropiados, combinados uno tras otro si es necesario. Como ejemplos de uso de las estructuras definidas en este apartado, podemos poner:
VAR
pieza: Triangulo;
camino: ListaDePuntos;
empleado: DatosPersonales;
. . . .
pieza[1].x := 2.33;
pieza[1].y := -3.45;
pieza[2].x := pieza[1].y;
pieza[2].y := 88.3;
. . . .
camino.numeroPuntos := 3;
camino.puntos[1].x := 5.67;
camino.puntos[1].y := 7.21;
camino.puntos[2] := pieza[3];
camino.puntos[3] := camino.puntos[2];
. . . .
empleado.nombreyApellidos.nombre := ‘Alberto ‘;
WriteString( empleado.domicilio.calle );
IF empleado.domicilio.poblacion[0] = ‘ ‘ THEN
WriteString( ‘Sin domicilio’ )
END;
. . . .
En estos ejemplos aparecen combinados los selectores de campo de registro y de componentes de formación. La combinación es admisible cuando una componente de una estructura es a su vez una estructura de datos. A cada estructura se debe aplicar el selector que le corresponda.
12.3.2 Tablas
Aunque el estudio de las estructuras de datos excede del ámbito de este libro, resulta interesante mencionar algunos esquemas típicos que se obtienen combinando estructuras básicas. Este es el caso del esquema de tabla, que puede plantearse como una formación simple de registros. Por ejemplo, podemos definir una tabla destinada a contener la identificación de las provincias:
VAR provincias: ARRAY TipoProvincia OF
RECORD
siglas: ARRAY [1 .. 2] OF CHAR;
nombre: ARRAY [0 .. 30] OF CHAR;
codigo, prefijo: INTEGER;
END;
En esta tabla se podrán almacenar los valores apropiados, asignándolos a los correspondientes campos, por ejemplo:
WITH provincias[ Madrid ] DO
siglas[1] := ‘M’;
siglas[2] := ‘ ‘;
nombre := ‘Madrid’;
codigo := 28;
prefijo := 91
END
Estos datos podrán ser usados luego en programas que manejen direcciones postales, matrículas de coches, números de teléfono, etc.
Pueden construirse estructuras de datos bastante complejas combinándolas de manera que en algunas de ellas hagamos referencia a datos almacenados en otras. Una forma de hacer referencia a los datos de una tabla es usando el índice correspondiente a la posición de cada registro. A continuación se muestra un ejemplo para manejo de figuras geométricas a partir de puntos:
TYPE TipoPunto = RECORD
x, y: REAL;
END;
CONST maxPuntos = 1000;
TYPE IndicePunto = [1 .. maxPuntos];
VAR puntos: ARRAY IndicePunto OF TipoPunto;
TYPE TipoTriangulo = ARRAY [1 .. 3] OF IndicePunto;
TYPE TipoCirculo = RECORD
centro: IndicePunto;
radio: REAL
END;
En este ejemplo, la tabla puntos almacena las coordenadas de todos los puntos utilizados para definir cualquiera de las figuras geométricas que se manejan. Las figuras se definen almacenando referencias a los puntos de la tabla, en lugar de almacenar directamente las coordenadas de los puntos. Un triángulo T definido por tres puntos A, B y C se podría registrar, por ejemplo, almacenando los puntos, arbitrariamente, en las posiciones 10, 11 y 12 de la tabla de puntos:
VAR trianguloT: TipoTriangulo;
. . . .
LeerPunto( puntos[10] );
LeerPunto( puntos[11] );
LeerPunto( puntos[12] );
trianguloT[1] := 10;
trianguloT[2] := 11;
trianguloT[3] := 12
Cuando se hacen recorridos o búsquedas en tablas y se quiere usar la sentencia WITH para simplificar el manejo de los registros, es importante tener en cuenta que el registro al que hace referencia la sentencia WITH se determina sólo una vez, al comienzo, y no cada vez que se hace referencia a un campo.
Por ejemplo, si queremos convertir todas las figuras geométricas en su imagen especular respecto al eje vertical, tendremos que invertir el signo de la abscisa de cada punto, es decir, tendremos que sustituir el valor de la coordenada x por -x. Para ello será válido escribir:
VAR k: INTEGER;
. . . .
k := 1;
WHILE k <= maxPuntos DO
WITH puntos[k] DO
x := -x
END;
INC( k )
END
En cambio, sería incorrecto escribir:
VAR k: INTEGER;
. . . .
k := 1;
WITH puntos[k] DO
WHILE k <= maxPuntos DO
x := -x;
INC( k )
END
END
Esta segunda versión lo que haría es cambiar de signo la coordenada x del primer punto repetidamente, ya que el bucle WHILE se ejecuta íntegramente dentro del ámbito de la sentencia WITH referida al registro puntos[k] evaluado con k=1.
12.4 El esquema unión
Hay aplicaciones en las que resultaría deseable que el tipo de un dato variase según las circunstancias. Si las posibilidades de variación son un conjunto finito de tipos, entonces se puede decir que el tipo del dato corresponde a un esquema que es la unión de los tipos particulares posibles. Cada uno de los tipos particulares constituye una variante del tipo unión. Representamos simbólicamente este esquema en la forma:
tipo_unión = variante | variante …
Como situaciones típicas en las que pueden aplicarse los esquemas unión tenemos, entre otras, las siguientes:
- Datos que pueden representarse de diferentes maneras.
- Programas que operan indistintamente con varias clases de datos.
- Datos estructurados con elementos opcionales.
Algunos ejemplos concretos serían:
número_general = entero | fracción | real | complejo
coordenadas = coordenadas_cartesianas | coordenadas_polares
figura = punto | círculo | cuadrado | rectángulo | rombo | triángulo | elipse
datos_persona = datos_soltero | datos_menor | datos_casado
El primer caso correspondería a un programa que opere indistintamente con números de diferentes clases. Los números enteros son datos simples, al igual que los reales, aunque las colecciones de valores son diferentes. Los números fraccionarios se representan como dos números enteros (numerador y denominador), y los complejos como dos números reales (partes real e imaginaria).
En el segundo caso tenemos dos sistemas de coordenadas diferentes para representar los puntos del plano. Las coordenadas cartesianas son dos longitudes, mientras que las polares son una longitud y un ángulo.
En el tercer caso tenemos un programa que maneja un repertorio limitado de elementos gráficos. Para cada figura se necesitará conocer una colección de parámetros diferentes.
En el último caso, los datos de un soltero mayor de edad constituirían la información básica de una persona. Los menores deberían tener además un tutor o persona responsable de ellos, y los casados deberían tener una fecha de matrimonio y un cónyuge.