You are on page 1of 17

Abstracción de datos y jerarquía

Bárbara Liskov
Laboratorio de Ciencias de la Computación MIT
Cambridge, Massachussets 02139

Resumen

La abstracción de datos es un método valioso para la organización de programas


para hacerlos fáciles de modificar y mantener. La herencia permite a una implementación
de abstracción de datos ser relacionada con otra jerárquicamente. Esta publicación
investiga la utilidad de la jerarquía en el desarrollo de programas y concluye que aunque
la abstracción de datos es la idea más importante, la jerarquía extiende su utilidad en
algunas situaciones.

1. Introducción

Una meta importante en el diseño es el identificar una estructura de programa que


simplifique al mismo tiempo el mantenimiento y las modificaciones hechas para obedecer
a requerimientos cambiantes. Las abstracciones de datos son una buena manera de
llegar a esa meta. Ellas nos permiten abstraernos de la forma en que las estructuras de
datos están implementadas, del comportamiento que proveen y con el cual otros
programas pueden contar. Ellas permiten que la representación de los datos pueda ser
cambiada localmente sin afectar a los programas que usan esos datos. Son
particularmente importantes porque ocultan cosas complicadas (estructuras de datos) que
son proclives a ser cambiadas en el futuro. Por ejemplo, reducen el número de
argumentos de los procedimientos porque objetos abstractos son los que son
comunicados en vez de sus representaciones.

La programación orientada a objetos es principalmente una técnica de abstracción


de datos, y mucho de su potencia deriva de esto. De todos modos, elabora su técnica con
la noción de “herencia”. La herencia puede ser usada en un número de formas, algunas
de las cuales aumenta el poder de la abstracción de datos. En estos casos, la herencia
provee una adición útil a la abstracción de datos.

Esta publicación discute la relación entre la abstracción de datos y la programación


orientada a objetos. Comenzamos en la sección 2 definiendo la abstracción de datos y su
rol en el proceso de desarrollo del programa. En la sección 3 discutimos la herencia e
identificamos dos maneras en que es usada, para jerarquía de implementación y para
jerarquía de tipos. De los dos métodos, la jerarquía de tipos realmente suma algo a la
abstracción de datos, por lo que en la sección 4 discutimos tipos de jerarquía en diseño y
desarrollo de programas. A continuación discutimos algunos asuntos que surgen al
implementar la jerarquía de tipos. Concluimos con un resumen de nuestros resultados.

2. Abstracción de datos

El propósito de la abstracción de datos en programación es para separar el


comportamiento de la implementación. El primer mecanismo de abstracción en
programación fue el procedimiento. Un procedimiento realiza una tarea o función; otras
partes del programa llaman al procedimiento para realizar la tarea. Para usar el
procedimiento, a un programador sólo le importa qué hace y no cómo está implementado.
Cualquier implementación que provea la función necesaria será adecuada, mientras que
implemente la función correctamente y sea suficientemente eficiente.

Los procedimientos son un mecanismo de abstracción útil, pero en el principio de los


setentas algunos investigadores se dieron cuenta de que no eran suficiente [15, 16, 7] y
propusieron una nueva forma de organizar los programas alrededor de las “conexiones”
entre módulos. El concepto de abstracción de datos o tipo de dato abstracto surgieron de
estas ideas [5, 12].

Las abstracciones de datos proveen los mismos beneficios que los procedimientos,
pero para los datos. Recordemos que la idea principal es separar lo que una abstracción
es de cómo está implementada de manera que las implementaciones de la misma
abstracción puedan ser substituidas libremente. La implementación de un objeto de datos
tiene que ver con cómo ese objeto está representado en la memoria de la computadora;
esta información es llamada representación, o rep, para ser más breve. Para permitir
cambios de implementaciones sin afectar a los usuarios, necesitamos una manera de
encapsular la representación (rep) con un conjunto de operaciones que la manipulen y
restringiendo a los programas que la utilicen de manera de que no puedan manipular la
rep directamente, sino que en cambio llamen a las operaciones. Entonces, para
implementar o re-implementar la abstracción de datos, es necesario definir la
representación (rep) e implementar las operaciones en términos de ella pero el código que
la usa no es afectado por el cambio.

Así, una abstracción de datos es un conjunto de objetos que pueden ser


manipulados directamente por un conjunto de operaciones. Un ejemplo de una
abstracción de datos son los enteros: los objetos son 1, 2, 3 y así y hay operaciones para
sumar dos enteros, para testear su igualdad, etc. Los programas que usan enteros los
manipulan mediante sus operaciones y son protegidos de detalles de implementación
tales como si la representación es complemento de 2. Otro ejemplo son las cadenas de
caracteres (strings), con objetos como “a” y “xyz”, y las operaciones para seleccionar
caracteres en estas cadenas (strings) y para concatenarlos. Un ejemplo final son los
conjuntos (sets) de enteros, con objetos tales como { } (el conjunto vacío) y {3, 7}, y
operaciones para insertar un elementos en un conjunto, y testear si el entero está o no en
ese conjunto. Nótese que los enteros y las cadenas (strings) son tipos de datos provistos
por la mayoría de los lenguajes de programación, mientras que los conjuntos y otras
abstracciones de datos orientadas a aplicaciones como pilas (stacks) y tablas de símbolos
no lo están. Los mecanismos lingüísticos que permiten implementar tipos de datos
abstractos definidos por el usuario son discutidos en la sección 2.2.

Una abstracción de datos o de procedimiento es definida por una especificación e


implementada por un módulo de programa (program module) codificado en un lenguaje de
programación. La especificación describe lo que la abstracción hace, pero omite cualquier
información acerca de cómo está implementada. Al omitir tal detalle permitimos muchas
diferentes implementaciones. Una implementación es correcta si provee el
comportamiento definido por la especificación. La corrección puede ser probada
matemáticamente si la especificación es escrita en un lenguaje con semántica precisa; de
otra manera podemos establecer corrección mediante razonamiento informal o mediante
la poco satisfactoria técnica del testeo. Las implementaciones correctas difieren de una a
la otra en cómo trabajan, por ejemplo, qué algoritmos usan, y por lo tanto, pueden tener
diferente desempeño. Cualquier implementación correcta es aceptable para el que la
llama mientras cumpla con los requerimientos de desempeño del que la llama. Nótese
que las implementaciones correctas no necesitan ser idénticas una a la otra; el punto es
que se permita que las implementaciones difieran mientras que aseguren que
permanecen iguales ahí donde es importante. La especificación describe lo que es
importante.

Para que una abstracción funcione debe ser encapsulada. Si una implementación es
encapsulada, entonces ningún otro módulo puede depender de sus detalles de
implementación. el encapsulamiento garantiza que los módulos puedan ser
implementados y re-implementados independientemente, esto se relaciona con el
principio de “ocultación de la información” de Parnas [15].

2.1. Localidad

La abstracción permite hablar de localidad dentro de un programa cuando está


soportada por las especificaciones. El concepto de localidad permite a un programa ser
implementado, entendido o modificado de a un módulo por vez:

1 – El implementador de una abstracción sabe qué se necesita porque esto está


escrito en la especificación. Por lo tanto, no necesita interactuar con programadores de
otros módulos (o al menos las interacciones pueden ser muy limitadas).

2 – De forma similar, el implementador de un módulo en uso sabe qué esperar de


una abstracción ya que su comportamiento está descripto en la especificación.

3 – Sólo el razonamiento local es necesario para determinar qué hace un programa


y si hace la cosa correcta. El programa es estudiado un módulo por vez. En cada caso
nos interesa si el módulo hace lo que se supone que hace, lo que es, si cumple con su
especificación. De todas maneras, podemos limitar nuestra atención a justo ese módulo e
ignorar tanto a los módulos que lo usan como a los módulos que usa. Los módulos que lo
usan pueden ser ignorados porque dependen solamente de la especificación de este
módulo, no de su código. Los módulos usados son ignorados al utilizar el razonamiento
acerca de lo que hacen utilizando sus especificaciones en vez de su código. Existe un
ahorramiento de esfuerzo ya que las especificaciones son mucho más pequeñas que las
implementaciones. Por ejemplo, si tuviéramos que mirar un código de una abstracción
llamada, nos interesaría no sólo su código sino también el código de los módulos que use,
etc., etc.

4 – Finalmente, la modificación puede ser hecha módulo por módulo. Si una


abstracción en particular necesita ser re-implementada para proveer mejor desempeño o
corregir un error o proveer una extensión de sus facultades, la vieja implementación
puede ser reemplazada por una nueva sin afectar a otros módulos.

La localidad provee una base firme para el prototipado rápido. Típicamente hay una
situación de compromiso entre el desempeño de un algoritmo y la velocidad a la que
necesita ser diseñado e implementado. La implementación inicial puede ser simple y tener
mal desempeño. Luego puede ser reemplazada por otra implementación con mejor
desempeño. Provisto que ambas implementaciones son correctas, la corrección del
programa no va a ser afectada por el cambio.

La localidad también conlleva evolución del programa. Las abstracciones pueden


ser utilizadas para encapsular potenciales modificaciones. Por ejemplo, supongamos que
queremos un programa que corra sobre diferentes máquinas. Podemos lograr esto
inventando abstracciones que oculten las diferencias entre máquinas de manera que para
mover el programa a otra máquina diferente solamente esas abstracciones deban ser re-
implementadas. Un buen principio de diseño es pensar acerca de las modificaciones
esperadas y organizar el diseño usando abstracciones que encapsulen los cambios.

Los beneficios de la localidad son particularmente importantes para las


abstracciones de datos. Las estructuras de datos son frecuentemente complicadas y por
lo tanto la vista abstracta más simple provista por la especificación permite al resto del
programa ser más simple. Además, los cambios en las estructuras de almacenamiento
son muy probables a medida que los programas evolucionan; los efectos de dichos
cambios pueden ser minimizados al encapsularlos dentro de abstracciones de datos.

2.2. Soporte lingüístico para abstracciones de datos

Las abstracciones de datos son soportadas por mecanismos lingüísticos en varios


lenguajes. El más antiguo de todos fue Simula67 [3]. Dos variaciones principales, en CLU
y Smalltalk, son discutidas debajo.

CLU [8, 11] provee un mecanismo llamado cluster para implementar un tipo
abstracto. Una plantilla de un cluster se muestra en la figura 2-1. El encabezado identifica
el tipo de dato implementado y además lista las operaciones para el tipo; sirve para
identificar cuáles definiciones de procedimientos dentro del cluster pueden ser llamadas
desde afuera. La línea “rep = “ define cómo los objetos del tipo son representados; en el
ejemplo, implementamos conjuntos (sets) como listas enlazadas. El resto del cluster
consiste en procedimientos; debe haber un procedimiento por cada operación, y sumado
a esto, pueden haber algunos procedimientos que pueden ser usados sólo dentro del
cluster.

int_set = cluster is create, insert, is_in, size, ...

rep = int_list

create = proc ... end create

insert = proc ... end insert

...

end int_set

Figura 2-1: Plantilla de un cluster de CLU.

En Smalltalk [4], las abstracciones de datos están implementadas por clases. Las
clases pueden ser acomodadas jerárquicamente, pero ignoremos esto por ahora. Una
clase implementa una abstracción de datos de manera similar a un cluster. En vez de la
línea “rep = “, la representación (rep) es descripta por una secuencia de declaraciones de
variables; estas son las variables de instancia (ignoramos las variables de clase aquí ya
que no son importantes para las distinciones que estamos intentando hacer. CLU tiene un
mecanismo análogo; un cluster puede tener algunas variables “propias” [9]). El resto de la
clase consiste en métodos, los cuales son definiciones de procedimientos. Hay un método
para cada operación del tipo de dato implementado por la clase (no puede haber métodos
internos en clases de Smalltalk porque no es posible excluir el uso de un método desde
fuera del objeto). Los métodos son llamados “enviando mensajes”, que tiene el mismo
efecto que llamar operaciones en CLU.

Tanto CLU como Smalltalk fuerzan el encapsulamiento pero CLU usa chequeo de
tipos en tiempo de compilación, mientras que Smalltalk lo chequea en tiempo de
ejecución. El chequeo en tiempo de compilación es mejor porque permite atrapar errores
de cierta clase antes de que el programa corra, y permite generar código más eficiente
por el compilador (el chequeo en tiempo de compilación puede limitar el poder expresivo
salvo que el lenguaje de programación tenga un sistema de tipos poderoso; este asunto
es discutido en la próxima sección 5). Otros lenguajes orientados a objetos, por ej [1, 13]
no fuerzan el encapsulamiento de ninguna manera. Es verdad que en la ausencia de
soporte por parte del lenguaje para el encapsulamiento, éste se puede garantizar por
procedimientos manuales como la lectura del código, pero estas técnicas son tendientes a
generar errores, y aunque la situación pueda ser de alguna manera manejable para un
programa implementado recientemente, se degradará rápidamente cuando las
modificaciones sean hechas. El chequeo automático, sea en tiempo de ejecución o de
compilación, pueden ser delegado con confianza y sin necesidad de leer código en
absoluto.

Otra diferencia entre CLU y Smalltalk se encuentra en la semántica de los objetos de


datos. En Smalltalk, las operaciones son parte del objeto y pueden acceder a las variables
de la instancia que construyen la representación (rep) del objeto ya que esas variables
son parte del objeto también. En CLU, las operaciones no pertenecen al objeto sino que al
tipo. Esto les da privilegios especiales respecto de objetos de tipo que otras partes del
programa no tienen, o sea, pueden ver las representaciones (reps) de esos objetos (esta
vista fue descripta primero por Morris [14]). La vista de CLU trabaja mejor para
operaciones que manipulan algunos objetos de tipo simultáneamente porque una
operación puede ver las representaciones (reps) de varios objetos al mismo tiempo.
Ejemplos de tales operaciones son sumar dos enteros o formar la unión de dos conjuntos
(sets). En Smalltalk la vista no soporta estas operaciones tampoco, ya que una operación
puede estar en el interior de solo un objeto. Por otra parte, la vista de Smalltalk funciona
mejor cuando queremos tener varias implementaciones del mismo tipo corriendo dentro
del mismo programa. En CLU una operación puede ser la representación (rep) de
cualquier objeto de su tipo, y por lo tanto, debe ser codificada explícitamente para operar
con múltiples representaciones. Smalltalk evita este problema ya que una operación
puede ser la representación (rep) de sólo un objeto.

3. Herencia y jerarquía

Esta sección discute acerca de herencia y de cómo soporta jerarquía. Comenzamos


hablando de lo que significa construir un programa usando herencia. Entonces discutimos
dos grandes usos de la herencia, la de jerarquía de implementación y la de jerarquía de
tipos; véase [18] para una discusión similar. Solamente uno de estos tipos, la jerarquía de
tipos, agrega algo nuevo a la abstracción de datos.

3.1. Herencia

En un lenguaje con herencia, la abstracción de datos puede ser implementada en


varias partes que están relacionadas unas con otras. Aunque varios lenguajes proveen
mecanismos diferentes para juntar las piezas, son todos similares. Así, podemos
ilustrarlos examinando un sólo mecanismo, el mecanismo de subclase de Smalltalk.
En Smalltalk, una clase puede ser declarada como subclase de otra clase, la cual es
su superclase (aquí se ignora la herencia múltiple para simplificar la discusión). La
primera cosa que necesita ser entendida acerca del mecanismo es qué código resulta de
tal definición. Esta pregunta es importante para entender lo que hace una subclase. Por
ejemplo, si fuéramos a razonar acerca de su corrección, necesitaríamos mirar su código.

Desde el punto de vista del código resultante, decir que una case es subclase de
otra es simplemente una anotación a la mano para construir programas. El programa
exacto que es construido depende de las reglas del lenguaje, por ejemplo, tales cosas
como cuando los métodos de la subclase sobrescriben a los de la superclase. Los
detalles exactos de estas reglas no son importantes para nuestra discusión (aunque son
claramente importantes si el lenguaje va a ser sensible y útil). El punto es que el resultado
es equivalente a directamente implementar una clase conteniendo las variables de
instancia y métodos que resulten de aplicar las reglas.

Por ejemplo, supongamos que una clase T tiene operaciones O 1 y O2 y la variable de


instancia v1 y la clase S, que se declara subclase de T, tiene operaciones O 1 y O3 y una
variable de instancia v2. Entonces el resultado en Smalltalk es efectivamente una clase
con dos variables de instancia v 1 y v2 y tres operaciones O1,O2 y O3 donde el código de O2
es provisto por T y el código de las otras dos operaciones son provistos por S. Es ese
código combinado el que debe ser entendido, o modificado si S es re-implementado,
aunque S sea restringido como se discute más adelante.

Un problema con casi todos los mecanismos de herencia es que comprometen la


abstracción de datos hasta cierto punto. En lenguajes con herencia, una abstracción de
datos (por ejemplo, una clase) tiene dos tipos de usuario. Están los externos, quienes
simplemente usan los objetos llamando a sus operaciones, y los internos. Estas son las
subclases, que tienen típicamente permitido violar el encapsulamiento. Existen tres
maneras de que el encapsulamiento sea violado [18]: la subclase podría acceder una
variable de la instancia de su superclase, llamar a una operación de la superclase, o
referirse directamente a superclases de su superclase (esta última posibilidad no es
posible en Smalltalk).

Cuando el encapsulamiento no es violado, podemos razonar acerca de operaciones


de la superclase usando sus especificaciones e ignorar la representación (rep) de la
superclase. Cuando el encapsulamiento es violado, perdemos los beneficios de la
localidad. Debemos considerar el código combinado de la sub- y la superclase en el
razonamiento acerca de la subclase, y si la subclase necesita ser implementada,
podemos llegar a necesitar re-implementar sus subclases también. Por ejemplo, esto
sería necesario si una variable de instancia de la superclase cambiara, o si la subclase se
refiriera directamente a una superclase de su superclase T y entonces T es re-
implementada para que ya no tenga esta superclase.

Violar el encapsulamiento puede ser útil en hacer surgir un prototipo rápidamente ya


que permite escribir código mediante la extensión y la modificación del código existente.
No es realista, de todas maneras, esperar que las modificaciones a la implementación de
la superclase se pueda propagar automáticamente a la subclase. La propagación es útil
sólo si el código resultante funciona, lo que significa que todas las expectativas de la
subclase respecto de la superclase deben ser satisfechas por la nueva implementación.
Estas expectativas pueden ser capturadas al proveer otra especificación para la
superclase; esta es una especificación diferente de aquella para los usuarios externos, ya
que contiene restricciones adicionales. Usando esta especificación adicional, un
programador puede determinar si un cambio propuesto para la superclase puede
propagarse con utilidad a la subclase. Nótese que cuanto más específica es la
especificación adicional acerca de los detalles de la implementación previa de la
superclase, menos probable es que la nueva implementación de la superclase pueda
satisfacerla. Además, la situación se haría inmanejable si cada clase se apoya en una
diferente especificación de la superclase. Un acercamiento posible es definir una única
especificación para que usen todas las subclases que contienen más detalle que la
especificación para los usuarios externos pero aún abstractos respecto a varios detalles
de implementación. Algo de trabajo en esta dirección se describe en [17].

3.2. Jerarquía de implementación

La primera manera de que la herencia es usada es simplemente una técnica para


implementar tipos de datos que son similares a otros tipos existentes. Por ejemplo,
supongamos que queremos implementar conjuntos (sets) de enteros, con operaciones
(entre otras) para distinguir si un elemento es un miembro del conjunto y para determinar
el tamaño actual del conjunto. Supongamos además, que un tipo de datos llamado lista ha
sido implementado, y que provee la operación miembro y la operación tamaño, así como
también una manera conveniente de representar al conjunto. Entonces podríamos
implementar el conjunto como una subclase de la lista; deberíamos tener en la lista los
elementos del conjunto sin duplicación, por ejemplo, si un elemento fuera añadido dos
veces al conjunto, aparecería sólo una vez en la lista. Entonces no necesitaríamos
proveer implementaciones para miembro y tamaño, pero necesitaríamos implementar
otras operaciones tales como una que inserte un nuevo elemento en el conjunto. Además,
deberíamos suprimir ciertas otras operaciones, como automóvil, para hacerlas no
disponibles ya que no tienen sentido para los conjuntos (esto puede ser hecho en
Smalltalk proveyendo implementaciones en la subclase para las operaciones suprimidas;
tal implementación debería emitir una señal si una excepción surgiera).

Otra manera de hacer la misma cosa es usar un tipo (abstracto) como la


representación (rep) de otro. Por ejemplo, deberíamos implementar conjuntos usando
listas como la representación (rep). En este caso, necesitaríamos implementar las
operaciones tamaño y miembro; cada una de estas simplemente llamaría a la
correspondiente operación sobre las listas. Escribiendo implementaciones para estas dos
operaciones, aunque el código sea muy simple, es más trabajo que no escribir nada para
ellas. Por otro lado, no necesitamos hacer nada para quitar operaciones no deseadas
como auto.

Debido a que la herencia de implementación no nos permite hacer nada que no


podamos hacer ya con la abstracción de datos, no la vamos a considerar más allá de
esto. Nos permite violar el encapsulamiento, con los beneficios y problemas que ello
implica. De todas maneras, esta habilidad podría existir también en la aproximación de la
representación (rep), si se desea.

3.3. Jerarquía de tipos.

Una jerarquía de tipos está compuesta de subtipos y supertipos. La idea intuitiva de


que un subtipo es aquél cuyos objetos proveen todo el comportamiento de otro tipo (el
supertipo) más algo extra. Lo que se quiere aquí es algo como la siguiente propiedad de
sustitución [6]: Si por cada objeto O 1 de tipo S hay un objeto O2 de tipo T tal que para
todos los programas P definidos en términos de T, el comportamiento de P no es
cambiado cuando O1 es sustituido por O 2, entonces S es un subtipo de T (véase también
[2, 17] para otros trabajos en este área).

Estamos usando las palabras “subtipo” y “supertipo” aquí para enfatizar que ahora
estamos hablando de una distinción semántica. Por contraste, “subclase” y “superclase”
son simplemente conceptos lingüísticos en lenguajes de programación que permiten a los
programas ser construidos de una manera particular. Ellos pueden ser usados para
implementar subtipos pero también, como se mencionó arriba, de otras maneras.

Comenzamos con algunos ejemplos de tipos que no son subtipos uno del otro.
Primero, un conjunto (set) no es un subtipo de una lista y tampoco es cierto lo inverso. Si
el mismo elemento es añadido dos veces al conjunto, el resultado es el mismo que si
hubiera sido añadido una sola vez y el elemento es contado solamente una vez al
computar el tamaño del conjunto. De todas maneras, si el mismo elemento es añadido
dos veces a una lista, aparece dos veces en ella. Así, un programa esperando una lista no
funcionaría si se le pasa un conjunto; de forma similar, un programa esperando un
conjunto no funcionaría si se le pasa una lista. Otro ejemplo de los no-subtipos son las
pilas (stacks) y las colas (queues). Las pilas son LIFO (último en entrar, primero en salir,
“last in, first out”) ; cuando un elemento es removido de una pila, el último ítem añadido
(pushed) es removido. En contraste con esto, las colas son FIFO (primero en entrar,
primero en salir, “first in, first out”). Un programa que las utilice es probable que note la
diferencia entre estos dos tipos.

Los ejemplos anteriores ignoraron la diferencia simple entre pares de tipos, llamadas
operaciones relacionadas. Un subtipo debe tener todas las operaciones (necesita los
métodos de instancia pero no los métodos de clase) de su supertipo ya que de otra
manera el programa que las use podría no utilizar una operación de la que él depende. De
todas maneras, no es suficiente con tener simplemente operaciones con los nombres y
las “firmas” correctos (La “firma” -signature- de una operación define los números y tipos
de sus argumentos de entrada y salida). Las operaciones deben también hacer las
mismas cosas. Por ejemplo, las pilas y colas deberían tener operaciones con los mismos
nombres, por ejemplo add_el, para hacer “push” o poner en cola y rem_el para quitar el
elemento de la pila o de la cola, pero aún así no son subtipos uno del otro porque los
significados de las operaciones son diferentes para ellos.

Ahora damos algunos ejemplos de jerarquías de subtipos. El primero es la colección


indexada, la cual tiene operaciones para acceder a los elementos por índice, por ejemplo,
habría una opreración fetch (ir a buscar) para traer el elemento i-ésimo de la colección.
Todos los subtipos tienen estas operaciones también, pero, además, cada uno proveería
operaciones extra. Son ejemplos de subtipos los arreglos, secuencias y conjuntos
indexados; por ejemplo, las secuencias pueden ser concatenadas y los arreglos pueden
ser modificados al guardar nuevos objetos en los elementos.

El segundo ejemplo son los dispositivos abstractos, los cuales unifican un número
diferente de tipos de dispositivos de entrada y salida. Los dispositivos particulares
proveerían de operaciones extra. En este caso, las operaciones abstractas de los
dispositivos serían aquellas que todos los dispositivos provean, por ejemplo, el test de “fin
de archivo”, mientras que las operaciones de los subtipos serían específicas de cada
dispositivo. Por ejemplo, una impresora tendría operaciones de modificación tales como
put_char pero no de lectura como get_char, y así todos los subtipos tendrían el mismo
conjunto de operaciones. En este caso, las operaciones que no todos los dispositivos
reales pueden realizar deberían ser especificadas de una manera general que permita
señalizar las excepciones. Por ejemplo, get_char señalaría una excepción cuando fuera
llamada para operar en una impresora.

Un mecanismo de herencia puede ser usado para implementar una jerarquía de


subtipos. Habría una clase para implementar el supertipo y otra clase para implementar
cada subtipo. La clase que implemente el subtipo declararía la clase del supertipo como
su propia superclase.

4. Beneficios de la jerarquía de tipos

La abstracción de datos en una herramienta poderosa por su propio derecho. La


jerarquía de tipos es una adición útil a la abstracción de datos. Esta sección discute cómo
los subtipos pueden ser usados en el desarrollo del diseño para un programa (una
discusión detallada del diseño basado en la abstracción de datos puede ser encontrada
en [11]). También se discute su uso en la organización de la biblioteca de programas.

4.1. Diseño incremental

Las abstracciones de datos son usualmente desarrolladas incrementalmente como


parte de los procesos de diseño. En etapas primitivas de un diseño, solamente
conocemos algunas de las operaciones de abstracción de datos y una parte de su
comportamiento. Ese tipo de diseño es mostrado en la figura 4-1a. El diseño se muestra
mediante un gráfico que ilustra cómo un programa es subdividido en módulos. Hay dos
tipos de nodos; un nodo con una barra simple encima representa una abstracción de
procedimiento, y un nodo con una barra doble encima representa una abstracción de
datos. Una flecha apuntando de un nodo al otro significa que la abstracción del primer
nodo será implementada usando la abstracción del segundo nodo. Así, la figura muestra
dos procedimientos, P y Q, y una abstracción de datos, T. P será implementada usando a
Q (o sea que su código llama a Q) y a T (su código usa objetos de tipo T). (La recursión
de indica por ciclos (cycles) en el gráfico. Así, si esperamos que la implementación de P
llame a P, habría una flecha de P a P).

Esta figura representa una etapa inicial del diseño, en la cual el diseñador ha
pensado acerca de cómo implementar P y ha inventado Q y T. En este punto, algunas
operaciones de T han sido identificadas, y el diseñador ha decidido que un objeto del tipo
T será usado para comunicarse entre P y Q.

Figura 4-1. El comienzo de un diseño.

La próxima etapa de diseño es investigar cómo implementar Q (no tendría sentido


mirar a la implementación de T en este punto, porque no conocemos todas sus
operaciones todavía). Al estudiar a Q definiríamos seguramente operaciones adicionales
para T. Esto puede ser visto como redefinir a T como un subtipo de S como se muestra en
la figura 4-2. Aquí hay una doble flecha que apunta de un supertipo a un subtipo; las
flechas dobles pueden conectar solamente abstracciones de datos (y puede no haber
ningún ciclo incluyendo solamente dobles flechas).

Figura 4-2. Más adelante en el diseño.

El tipo de refinamiento ilustrado en las figuras puede ocurrir varias veces; por
ejemplo, S a su vez puede tener un subtipo R y así. Además, un simple tipo puede tener
varios subtipos, representando las necesidades de diferentes subpartes del programa.

Manteniendo la atención sobre estas distinciones como subtipos es mejor que tratar
el grupo de tipos como un único tipo, por varias razones. Primero, puede limitar el efecto
de errores de diseño. Por ejemplo, supongamos que una investigación más adelante
indica un problema con la interfaz de S. Cuando un problema de este tipo ocurre es
necesario que se mire a cada abstracción que usa la abstracción que se modifica. Para la
figura, significa que debemos mirar a Q. De todas maneras, si la interfaz de T no es
afectada, no necesitamos mirar a P. Si S y T han sido tratadas como un sólo tipo,
entonces P debería haber sido examinada también.

Otra ventaja de distinguir los tipos es que ello puede ayudar a organizar la
racionalidad del diseño. La racionalidad del diseño describe las decisiones hechas en
puntos particulares del diseño, y discute por qué fueron tomadas y cuáles alternativas
existen. Al mantener la jerarquía para representar las decisiones al ser tomadas en el
tiempo podemos evitar la confusión y ser más precisos. Si un error es descubierto tarde,
podemos identificar precisamente en cuál punto del diseño ocurrió.

Finalmente, la distinción puede ayudar durante la implementación, por ejemplo, si S


pero no T, necesita ser re-implementada. De todas maneras, puede ser que la jerarquía
no se mantenga en la implementación. Frecuentemente el fin del diseño es sólo un único
tipo, el último subtipo inventado, porque implementar un módulo único es más
conveniente que tener módulos separados para el supertipo y los subtipos. Aún así, de
todas formas, la distinción permanece útil después de la implementación, porque los
efectos de los cambios de especificación pueden ser todavía localizados aún si los
cambios de implementación no pueden. Por ejemplo, un cambio en la especificación de S
pero no de T significa que necesitamos re-implementar Q pero no P. No obstante, si S y T
son re-implementadas como un simple módulo, debemos re-implementar a ambos, en vez
de sólo re-implementar a S.

4.2. Tipos relacionados

El segundo uso de subtipos es para los tipos relacionados. El diseñador puede


reconocer que un programa va a usar varias abstracciones de datos que son similares
pero diferentes. Las diferencias representan variantes de la misma idea general, donde
los subtipos pueden tener todos el mismo conjunto de operaciones, o algunos de ellos
pueden extender el supertipo. Un ejemplo de esto es el dispositivo abstracto mencionado
anteriormente. Para acomodar los tipos relacionados, el diseñador presenta el supertipo al
momento en que todo el conjunto de tipos es concebido, y entonces introduce los subtipos
a la vez que se necesitan más adelante en el diseño.

Los tipos relacionados surgen en dos maneras diferentes. Algunas veces la relación
es definida con anticipación, antes que los tipos sean inventados; esta es la situación
discutida arriba. Alternativamente, la relación puede no ser reconocida hasta que varios
tipos relacionados ya existan. Esto ocurre por el deseo de definir un módulo que trabaje
con cada uno de los tipos relacionados pero dependa sólo de alguna parte pequeña
común a ellos. Por ejemplo, el módulo podría ser una rutina de ordenamiento que se basa
en su argumento "colección" para permitirse recuperar elementos, y se basa en el propio
tipo del elemento para proveer una operación "<" (menor que).

Cuando la relación se define con anticipación, la jerarquía es una buena forma de


describirla, y probablemente querríamos usar la herencia como un mecanismo de
implementación. Esto nos permite implementar sólo una vez (en el supertipo) lo que sea
posible hacer en una forma independiente del subtipo. El módulo para un subtipo tiene
que ver solamente con el comportamiento específico de ese subtipo, y es independiente
de los módulos que implementan otros subtipos. Tener módulos separados para los
supertipos y los subtipos brinda más modularidad que al usar un sólo módulo para
implementarlos a todos. Además, si un nuevo subtipo es sumado después, no es
necesario modificar el código existente.

Cuando la relación es reconocida después que los tipos fueron definidos, la jerarquía
puede no ser la mejor manera de organizar el programa. Este asunto es discutido en la
sección 5.1.

4.3. Organizando la biblioteca de tipos

Hay otra manera en que la jerarquía es útil y esto es para ayudar en la organización
de una biblioteca de tipos. Ha sido reconocido que programar es más efectivo si puede
ser hecho en un contexto que anime a la reutilización de módulos de programas
implementados por otros. De todas formas, para que un contexto así pueda ser utilizable,
debe poder ser fácilmente navegable para determinar si los módulos deseados existen. La
jerarquía es útil como una forma de organizar la biblioteca de un programa para hacer las
búsquedas más fáciles, especialmente cuando están presentes las herramientas de
búsquedas de tipos, por ejemplo, en el entorno de Smalltalk.

La jerarquía permite que los tipos similares sean agrupados juntos. Así, si un usuario
quiere un tipo particular de “colección” de abstracciones, hay una buena posibilidad de
que el que desea, si existe pueda ser encontrado con las otras colecciones. La jerarquía
en uso es, o bien una jerarquía de subtipos, o casi (por ejemplo, un subtipo difiere de una
extensión del supertipo en una manera regularmente menor). El punto es que los tipos
son agrupados basados en su comportamiento más que en cómo están usados para
implementarse unos a otros.

La búsqueda de tipos de colección, tipos numéricos, o lo que sea, está ayudada por
dos cosas: la primera es considerar la biblioteca entera como creciendo de una única raíz
o raíces, y proveyendo de una herramienta de exploración o navegación (browsing) que
permita al usuario moverse dentro del árbol de jerarquías. La segunda es la elección
sabia de nombres para las categorías principales, de manera que un usuario pueda
reconocer que “colección” es la parte de la jerarquía que le interesa.

Usar la jerarquía como una manera de organizar la biblioteca es una buena idea,
pero no necesita estar acoplada con un mecanismo de subclases en un lenguaje de
programación. En cambio, un sistema interactivo que provea construcción y exploración
(browsing) de la biblioteca podría organizarla de esta forma.

5. Jerarquía de tipos y herencia

No todos los usos de jerarquía de tipos requieren soporte del lenguaje. No se


requiere soporte para la biblioteca de programa; en cambio, todo lo que se necesita es
usar la noción de jerarquía de tipos como un principio de organización. El soporte no es
además necesario usualmente para la jerarquía introducida como una técnica de
refinamiento. Como se mencionó antes, lo que más probablemente surge de esta técnica
de diseño es al final el tipo único (el último subtipo introducido) el cual es más
convenientemente implementado como una unidad. De esta manera, cualquier lenguaje
que provea de abstracción de datos es adecuado aquí, aunque la herencia puede ser útil
para introducir operaciones adicionales descubiertas posteriormente.

De todas maneras, algún soporte del lenguaje puede ser necesario para los tipos
relacionados. Este soporte es discutido en la sección 5.1. La sección 5.2 discute la
relación de la herencia con múltiples implementaciones de un tipo.

5.1. Polimorfismo

Un procedimiento polimórfico o abstracción de datos es aquella que funciona de


varias formas diferentes. Por ejemplo, consideremos un procedimiento que hace un
ordenamiento. En varios lenguajes, tal procedimiento sería implementado para trabajar en
un arreglo de enteros; posteriormente, si necesitamos ordenar un arreglo de cadenas de
caracteres, otro procedimiento sería necesario. Esto es desafortunado. La idea de ordenar
es independiente de cualquier tipo de elemento en el arreglo, dado que sea posible
comparar los elementos para determinar cuales son más pequeños que cuáles otros.
Deberíamos ser capaces de implementar un procedimiento de ordenamiento que funcione
para todos los tipos. Tal procedimiento sería polimórfico.

Cuando hay tipos relacionados en un programa hay probabilidades de usar


polimorfismo. Esto es ciertamente el caso cuando la relación está indicada por la
necesidad de un módulo polimórfico. Incluso cuando la relación está identificada con
anticipación, no obstante, el polimorfismo es lo más probable. En tal caso el supertipo es
frecuentemente virtual: no tiene objetos propios pero es simplemente un guardador del
lugar en la jerarquía de la familia de tipos relacionados. En este caso, cualquier módulo
que use el supertipo es polimórfico. Por otro lado, si el supertipo tiene objetos propios,
algunos módulos podrían usarlo pero a ninguno de sus subtipos.

Usar jerarquía para proveer polimorfismo significa que un módulo polimórfico es


concebido como usando un supertipo, y cada tipo que es tratado de usar es hecho un
subtipo del supertipo. Cuando los supertipos son introducidos antes que los subtipos, la
jerarquía es una buena manera de capturar las relaciones. El supertipo es agregado al
universo de tipos cuando es inventado, y los subtipos son agregados debajo de él
después.

Si los tipos existen antes que la relación, la jerarquía tampoco funciona. En este
caso, introducir el supertipo complica el universo de tipos: un nuevo tipo (el supertipo)
debe ser agregado, todos los tipos usados por el módulo polimórfico deben ser hechos
sus subordinados, y las clases que implementan los subtipos deben ser modificadas para
reflejar la jerarquía (y recompilados en un sistema que los compile). Por ejemplo,
tendríamos que añadir un nuevo tipo “ordenable” al universo y hacer que cada nuevo tipo
de elemento sea un subtipo de él. Nótese que cada supertipo debe ser considerado
cuando un nuevo tipo es inventado: cada nuevo tipo debe ser hecho un subtipo del
supertipo si hay alguna posibilidad de que queramos usar sus objetos en el módulo
polimórfico. Más aún, el supertipo puede ser inútil en cuanto al hecho de compartir el
código, ya que puede no haber nada que implementar en él.

Un abordaje alternativo es simplemente permitir que el módulo polimórfico use


cualquier tipo que provea las operaciones necesarias. En este caso, no se hace ningún
intento de relacionar los tipos. En cambio, un objeto perteneciente a alguno de los tipos
relacionados puede ser pasado como argumento al módulo polimórfico. Así, obtenemos el
mismo efecto pero sin la necesidad de complicar el universo de tipos. Nos referiremos a
este abordaje como el abordaje de agrupamiento (grouping approach).

Los dos enfoques difieren en que lo que se requiere razonar acerca de la corrección
del programa. En ambos casos se requiere que los objetos argumento tengan
operaciones con la “signature” y comportamiento correctos. Los requerimientos de
signature pueden ser chequeados por chequeo de tipos, lo que puede ocurrir en tiempo
de ejecución o de compilación. Los chequeos en tiempo de ejecución no requieren
mecanismos especiales; los objetos son simplemente pasados al módulo polimórfico, y
los errores de tipos serán encontrados si una operación necesaria no está presente. El
chequeo en tiempo de compilación requiere un adecuado sistema de tipos. Si el enfoque
de jerarquía es usado, entonces el lenguaje debe combinar chequeo en tiempo de
compilación con herencia; un ejemplo de tal lenguaje se discute en [7]. Si el enfoque de
agrupamiento (grouping) es usado, entonces necesitamos una forma de expresar las
restricciones en operaciones en tiempo de compilación. Por ejemplo, ambos CLU y Ada
[19] pueden hacer esto. En CLU el encabezado de un procedimiento ordenar podría ser:

ordenar = proc [T: type](a: array[T])

where T has lt: proctype (T,T) returns (bool)

Este encabezado restringe al parámetro T a ser un tipo con una operación llamada lt
con la firma (signature) indicada; la especificación de ordenar explicaría que lt debe ser un
test “menor que”. Un ejemplo de una llamada a ordenar es:

ordenar[int](x)
Al compilar tal llamada, el compilador chequea que el tipo de x sea un arreglo de
enteros (array[int]) y además, que el tipo int tenga una operación llamada lt con la firma
requerida. Podríamos definir una rutina de ordenamiento más polimórfica en CLI que
funcionaría para todas las colecciones que son de tipo parecido a un arreglo, por ejemplo
cuyos elementos pueden ser traídos y guardados por índice.

Los requerimientos de comportamiento deben ser chequeados por alguna forma de


verificación del programa. El comportamiento requerido debe ser parte de una
especificación, y la especificación va en diferentes lugares en los dos métodos. Con la
jerarquía, la especificación pertenece al supertipo; con los tipos relacionados, es parte de
la especificación del módulo polimórfico. El chequeo de comportamiento también ocurre
en diferentes momentos. Con la jerarquía, el chequeo ocurre cuando un programador
hace un tipo de subtipo del supertipo; con el agrupamiento, ocurre cuando un
programador escribe código que usa el módulo polimórfico.

Ambos métodos de agrupamiento (grouping) y de jerarquía tienen sus limitaciones.


Es deseable más flexibilidad en el uso del módulo polimórfico. Por ejemplo, si ordenar es
llamado con una operación que hace un test “mayor que”, esto llevaría a un diferente
ordenamiento del arreglo, pero ese ordenamiento diferente puede ser justamente lo que
se quiere. En suma, puede haber conflictos entre los tipos intentados usar en el módulo
polimórfico:

1. No todos los tipos proveen la operación requerida.

2. Los tipos usan diferentes nombres para la operación.

3. Algunos tipos usan el nombre de la operación requerida para otra operación; por
ejemplo, el nombre lt es usado en el tipo T para identificar la operación “longitud”.

Una manera de obtener más generalidad es simplemente pasar las operaciones


necesarias como argumentos de procedimientos, por ejemplo, ordenar realmente toma
dos argumentos, el arreglo y la rutina usada para determinar el ordenamiento (por
supuesto esta solución no funcionaría bien con Smalltalk porque los procedimientos no
pueden ser convenientemente definidos como entidades individuales y tampoco tratados
como objetos). Este método es general, pero puede ser inconveniente. En Argus [10] y en
Ada, existen métodos que evitan esta inconveniencia en conjunción con el enfoque de
agrupamiento.

En suma, cuando los tipos relacionados son descubiertos tempranamente en el


diseño, la jerarquía es una buena forma de expresar la relación. De otra manera, tanto el
enfoque de agrupamiento (con un adecuado soporte por parte del lenguaje) o los
procedimientos como argumentos podrían ser mejores.

5.2. Implementaciones múltiples

Es frecuentemente útil tener múltiples implementaciones del mismo tipo. Por


ejemplo, para algunas matrices usamos la representación dispersa 1 (rala o hueca) y para
otras la representación no dispersa (o llena). Más aún, es a veces deseable usar objetos
del mismo tipo pero diferentes representaciones dentro del mismo programa.

1 En álgebra lineal numérica una matriz dispersa o matriz rala o matriz hueca es una matriz de gran tamaño en la
que la mayor parte de sus elementos son cero.
Los lenguajes orientados a objetos aparentemente permiten a los usuarios simular
múltiples implementaciones con herencia. Cada implementación sería una subclase de
otra clase que implementa el tipo. Esta última clase probablemente sería virtual; por
ejemplo, habría una clase virtual implementando matrices, y subclases implementando
matrices dispersas y no dispersas.

Usar la herencia de esta manera nos permite tener varias implementaciones del
mismo tipo en uso dentro de un programa, pero interfiere con la jerarquía de tipos. Por
ejemplo, supongamos que inventamos un subtipo de matrices llamadas
matrices_extendidas. Querríamos implementar matrices_extendidas con una clase que
herede de matrices en vez de heredar de una implementación particular de matrices, ya
que esto nos permitiría combinarla con cualquier implementación de matrices. Sin
embargo, esto no es posible. En cambio, la clase matrices_extendidas debe ser
explícitamente establecida en el texto del programa que es un subtipo de matriz dispersa
o no dispersa.

El problema surge porque la herencia es usada para dos cosas diferentes: para
implementar un tipo y para indicar que un tipo es un subtipo de otro. Estos usos deberían
ser mantenidos separados. Entonces tendríamos lo que realmente queremos: dos tipos
(matriz y matriz_extendida), una siendo subtipo de la otra, cada una teniendo algunas
implementaciones, y la habilidad de combinar las implementaciones del subtipo con
aquellos del supertipo de varias formas.

6. Conclusiones

La abstracción, especialmente la abstracción de datos, es una técnica importante


para desarrollar programas que son razonablemente fáciles de mantener y modificar
según los cambios de requerimientos. Las abstracciones de datos son particularmente
importantes porque ocultan cosas complicadas (estructuras de datos) que son posibles de
cambiar en el futuro. Permiten que la representación de los datos sea cambiada
localmente sin afectar a programas que usan los datos.

La herencia es un mecanismo de implementación que permite a un tipo ser


relacionado con otro jerárquicamente. Es usada en dos formas: para implementar una
derivación de la implementación de otro tipo y para definir subtipos. Hemos discutido
acerca de que el primer uso no es interesante porque podemos obtener el mismo
resultado usando un tipo como la representación (rep) de otro. Los subtipos, por otra
parte, suman una nueva habilidad. Fueron identificados tres usos para los subtipos.
Durante el diseño incremental proveen una forma de limitar el impacto de los cambios de
diseño y para organizar la documentación del diseño. Proveen además de una forma de
agrupar tipos relacionados, especialmente en el caso cuando el supertipo es inventado
antes que cualquier subtipo. Cuando las relaciones son descubiertas después de que
algunos tipos hayan sido ya definidos, otros métodos, como el de agrupamiento o de
procedimientos como argumentos son probablemente mejores que la jerarquía.
Finalmente, la jerarquía es una manera conveniente y sensible de organizar una biblioteca
de tipos. La jerarquía es o bien una jerarquía de subtipos o casi una; los subtipos pueden
no satisfacer nuestra definición estricta, pero son similares al supertipo en algún sentido
intuitivo.

La herencia puede ser usada para implementar una jerarquía de subtipos. Es


necesaria principalmente en el caso de tipos relativos cuando el supertipo es inventado
primero, porque aquí es conveniente para implementar características comunes solo una
vez en la superclase, y luego implementar las extensiones separadamente para cada
subtipo.

Concluimos que aunque la abstracción de datos es más importante, la jerarquía de


tipos extiende su utilidad. Más aún, la herencia es a veces necesaria para expresar la
jerarquía de tipos y es por eso un mecanismo útil para ser provisto en un lenguaje de
programación.

Agradecimientos.

Algunas personas han hecho comentarios y sugerencias que mejoraron el contenido


de esta publicación. Los autores agradecen esta ayuda y especialmente los esfuerzos de
Toby Bloom y Gary Leavens.

REFERENCIAS

1. Bobrow, D., et al. "CommonLoops: Merging Lisp and Object-Oriented Programming".


Proc. of the ACM Conference on Object-Oriented Programming Systems, Languages
and Applications, SIGPLAN Notices 21, 11 (November 1986).

2. Bruce, K., and Wegner, P. "An Algebraic Model of Subtypes in Object-Oriented


Languages (Draft)".SIGPLAN Notices 21, 10 (October 1986).

3. Dahl, O.-J., and Hoare, C.A.R. Hierarchical Program Structures. In Structured


Programming, Academic Press, 1972.

4. Goldberg, A., and Robson, D. Smalltalk-80: The Language and its Implementation.
Addison-Wesley, Reading, Ma., 1983.

5. Hoare, C. A. R. "Proof of correctness of data representations". Acta Informatica 4 (1972),


271-281.

6. Leavens, G. Subtyping and Generic Invocation: Semantic and Language Design. Ph.D.
Th., Massachusetts Institute of Technology, Department of Electrical Engineering and
Computer Science, forthcoming.

7. Liskov, B. A Design Methodology for Reliable Software Systems. In Tutorial on Software


Design Techniques, P. Freeman and A. Wasserman, Eds., IEEE, 1977. Also published
in the Proc. of the Fall Joint Computer Conference, 1972.

8. Liskov, B., Snyder, A., Atkinson, R. R., and Schaffert, J. C. "Abstraction mechanisms in
CLU". Comm. Of the ACM 20, 8 (August 1977), 564-576.

9. Liskov, B., et al.. CLU Reference Manual. Springer-Verlag, 1984.

10. Liskov, B., et al. Argus Reference Manual. Technical Report MIT/LCS/TR-400, M.I.T.
Laboratory for Computer Science, Cambridge, Ma., 1987.

11. Liskov, B., and Guttag, J.. Abstraction and Specification in Program Development. MIT
Press and McGraw Hill, 1986.

12. Liskov, B., and Zilles, S. "Programming with abstract data types". Proc. of ACM SIGPLAN
Conference on Very High Level Languages, SIGPLAN Notices 9 (1974).
13. Moon, D. "Object-Oriented Programming with Flavors". Proc. of the ACM Conference on
Object-Oriented Programming Systems, Languages, and Applications, SIGPLAN
Notices 21, 11(November 1986).

14. Morris, J. H. "Protection in Programming Languages". Comm. of the ACM 16, 1 (January
1973).

15. Parnas, D. Information Distribution Aspects of Design Methodology. In Proceedings of


IFIP Congress, North Holland Publishing Co., 1971.

16. Parnas, D. "On the Criteria to be Used in Decomposing Systems into Modules". Comm.
of the ACM 15, 12 (December 1972).

17. Schaffert, C., et al. "An Introduction to Trellis/Owl". Proc. of the ACM Conference on
Object-Oriented Programming Systems, Languages, and Applications, SIGPLAN
Notices 21, 11 (November 1986).

18. Snyder, A. "Encapsulation and Inheritance in Object-Oriented Programming Languages".


Proc. of the ACM Conference on Object-Oriented Programming Systems, Languages,
and Applications, SIGPLAN Notices 21, 11 (November 1986).

19. U. S. Department of Defense. Reference manual for the Ada programming language.
1983. ANSI standard Ada.

You might also like