Professional Documents
Culture Documents
Bárbara Liskov
Laboratorio de Ciencias de la Computación MIT
Cambridge, Massachussets 02139
Resumen
1. Introducción
2. Abstracción de datos
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.
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 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.
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.
rep = int_list
...
end int_set
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.
3. Herencia y jerarquía
3.1. Herencia
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.
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.
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.
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.
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ó.
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 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.
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.
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
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.
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:
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.
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”.
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
Agradecimientos.
REFERENCIAS
4. Goldberg, A., and Robson, D. Smalltalk-80: The Language and its Implementation.
Addison-Wesley, Reading, Ma., 1983.
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.
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.
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).
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).
19. U. S. Department of Defense. Reference manual for the Ada programming language.
1983. ANSI standard Ada.