1.1 Introducción a la POO
§1 Qué es eso de los "Objetos"?
En la programación tradicional existía una clara diferenciación entre los datos y su manipulación, es decir, el conjunto de algoritmos para manejarlos. Los tipos de datos eran muy simples; todo lo más números de diverso tipo y caracteres (aislados o agrupados en cadenas o matrices), pero nunca elementos heterogéneos.
La situación para el programador era que el lenguaje parecía decirle: estos son los posibles contenedores para la información que debes manejar (los tipos de datos soportados por el lenguaje) y aquí aparte, las operaciones que puedes realizar con ellos (las operaciones definidas para dichos datos). Por ejemplo, si son datos numéricos las operaciones aritméticas, las comparaciones, las asignaciones, etc. Con estas herramientas el programador construía su código, y para poner un poco de orden en la maraña de algoritmos solo tenía el recurso de agruparlo en funciones.
Si se tenía que hacer un programa de gestión comercial, la información correspondiente a un concepto más o menos real, por ejemplo un "cliente", tenía que ser desmenuzada en una serie de datos aislados: nombre, domicilio, población, provincia, teléfono, condiciones de compra, saldo, tipo de descuento, número de vendedor asignado, fecha de última compra, etc. etc. Luego, para hacer una factura, todos estos elementos eran tomados y procesados individualmente.
Un primer intento de construir tipos de datos más complejos, que se parecieran a las situaciones del mundo real y que pudieran ser tratados (más o menos) como una unidad, lo constituyen, entre otros, las "estructuras" del C clásico ("registros" en otros lenguajes). Una estructura es una agregación de datos de diverso tipo construida a criterio del programador mediante la agrupación de elementos ya incluidos en el lenguaje (básicamente los antedichos números y caracteres). Esto supuso un paso en el nivel de abstracción de los datos, aunque las herramientas para manipularlos seguían siendo elementos totalmente diferenciados.
En esta etapa ya podía crearse una estructura "cliente" que albergara toda la información antes dispersa, pero aparte de leer y escribir la información en un solo bloque, poco más podía hacerse, los datos tenían que seguir siendo procesados individualmente con expresiones parecidas a:
cliente->fecha_ult_compra = date();
tot_factura = suma * cliente->descuento
cliente->saldo = cliente->saldo + tot_factura;
etc.
Ante esta situación surge una nueva idea: crear nuevos tipos de datos agrupando los tipos tradicionales en entidades más complejas que reciben el nombre genérico de clases. Las clases son conjuntos de datos definidos por el programador, parecidos a las mencionadas "estructuras", pero con la importante novedad de incluir también las herramientas necesarias para su manipulación; es decir: operadores y funciones de la programación tradicional. Dicho de otro modo: los nuevos tipos de datos pueden ir acompañados de su "álgebra"; de las operaciones que se pueden realizar con ellos [3].
La idea es construir una especie de caja negra que incluya no solo los datos, también los algoritmos necesarios para su manipulación (sus operaciones), de forma que pueda ser vista desde el exterior (el programador que las usa) como un ente al que se le envían preguntas u órdenes y responde con el resultado [2]. Este comportamiento de la clase frente al mundo exterior es lo que se llama su interfaz o protocolo. Cómo es el detalle de su interior no le interesa al usuario-programador en absoluto, solo le interesa conocer la forma de "comunicarse" con ella y que posibilidades le ofrece. El hecho de empaquetar juntos datos y funcionalidad, dejando visible al exterior solo lo estrictamente necesario, se denomina encapsulamiento y es otro de los distintivos de la POO.
Para ilustrar la idea en un caso concreto consideremos un ejemplo clásico: Supongamos que en un programa tradicional hay que manejar números complejos. La solución es definir dos variables fraccionarias (de coma flotante) R e Y para representar respectivamente las partes reales e imaginaria de cada complejo. Luego para obtener, por ejemplo la suma (Rr, Yr), de dos complejos (R1, Y1), (R2, Y2), tenemos que codificar:
Rr = R1 + R2
Yr = Y1 + Y2
para calcular el valor del módulo m tenemos que hacer:
m = SQRT( (Rr * Rr) + ( Yr * Yr) )
así sucesivamente para cualquier manipulación que queramos efectuar.
Supongamos ahora que tenemos esta posibilidad de "construir" un nuevo tipo de variable, que denominaremos la clase de los complejos (la designamos por Cc), y que hemos incluido en dicha clase la información necesaria para realizar todas las operaciones permitidas en el álgebra de los números complejos. No solo la suma, también la asignación, la comparación, el cálculo del módulo, etc. Incluso los procedimientos para crear elementos nuevos (complejos) y para destruirlos (eliminarlos de la memoria). Ahora el funcionamiento sería algo parecido a:
Cc x = (R1, Y1);
Cc y = (R2, Y2);
Estamos declarando que x e y son objetos de la nueva clase y les
estamos asignando los valores correspondientes. Fíjese que estos objetos
son casos concretos del nuevo tipo, la clase de los complejos (en el mismo sentido que un 5 es un caso concreto del
concepto genérico de "número entero"). En el ejemplo suponemos que R1, Y1, R2,
Y2 siguen siendo números fraccionarios tradicionales, los mismos que en el ejemplo anterior. Fíjese también
que la asignación x = (R1, Y1)
supone que el compilador sabe que al ser x
un complejo, el símbolo "="
no representa aquí la misma operación que en el caso de, por ejemplo, z = 6
; también supone que el compilador
sabe que significa exactamente la expresión (R1, Y1) a la derecha.
Si ahora queremos calcular el resultado de la suma de los dos complejos y el valor de su módulo, solo tenemos que hacer:
Cc z = x + y;
float m = z.mod();
Observe que en la primera línea el operador "+"
no es exactamente el mismo que cuando se utiliza con enteros (por ejemplo, en
3 + 5). Esto lo deduce el compilador, porque sabe que x e y son complejos (dentro de la nueva
clase hemos definido la operación "suma" entre sus elementos y le
hemos asignado el operador "+").
Por otra parte, en la segunda línea solo tenemos que mandarle al complejo z
el mensaje mod() - calcula el módulo -; la sintaxis es la señalada: z.mod()
.
Ya sabemos que el resultado es un escalar y lo asignamos al número fraccionario m. Fíjese también que
en ambas líneas el operador de asignación "="
no representa la misma operación. En la primera se trata de una asignación
entre complejos; en la segunda es la tradicional asignación entre números
fraccionarios. Esta capacidad "camaleónica" de los operadores
es típica de la POO, y se conoce como sobrecarga
[4] (veremos que también puede haber sobrecarga de
funciones). El compilador sabe en cada momento que operador debe
utilizar de toda la panoplia disponible aunque estén bajo el mismo símbolo;
lo sabe por el contexto (por la naturaleza de los operandos involucrados).
En la POO es frecuente que métodos análogos de clases distintas se referencien utilizando las mismas etiquetas. Es decir: supongamos que tenemos tres clases: los Complejos, los Vectores y los Racionales. Puede ocurrir que, por ejemplo, el método para calcular el módulo se denomine mod() en las tres clases. Aunque el resultado sea un número racional positivo en los tres casos, naturalmente su operatoria es distinta si se aplica a racionales, vectores o matrices. Sin embargo, no existe posibilidad de confusión en el programa porque cuando apliquemos el método en cada caso concreto, el compilador sabe a cual de ellos nos estamos refiriendo en base a los objetos involucrados. Esta característica de la POO, da origen a lo que se llama polimorfismo. El nombre es bastante descriptivo de su significado, y la posibilidad resulta ser de gran utilidad, pues simplifica la lectura del código. Cada vez que encontremos una invocación al método mod tenemos una imagen mental del resultado con independencia del objeto concreto al que se aplique.
Llegados a este punto, introduciremos algún vocabulario específico para adaptarnos a la terminología, algo especial, que utiliza la POO:
En vez de "variables" como en la programación clásica, los datos contenidos en las clases (y en los objetos concretos) se denominan propiedades, de forma que en el ejemplo anterior, nos referiremos a las partes real R1 e imaginaria Y1 del complejo x como las propiedades R e Y de x (los complejos siguen teniendo una parte "real" y otra "imaginaria" incluso en la POO).
Por su parte las operaciones de manipulación contenidas en las clases (bajo la forma de funciones) reciben el nombre de métodos. Siguiendo con el ejemplo anterior, en vez de referirnos a función mod(), que calcula el módulo, diremos que mod es un método de la clase de los complejos. En lenguajes como C++, que puede tener elementos de ambos mundos, de la programación tradicional y de la POO, poder utilizar indistintamente los vocablos "función" o "método" es una ventaja, ya que nos permite distinguir en cada caso a que nos estamos refiriendo, si a funciones tradicionales o a funciones pertenecientes a clases.
En uno de los párrafos anteriores hemos dicho: "... estos objetos son casos concretos del nuevo tipo, la clase de los complejos..."; utilizando la terminología correcta diríamos: "... estos objetos son instancias de la clase de los complejos...".
Como puede verse, instancia significa una sustanciación concreta de un concepto genérico (la clase). En el mismo sentido podríamos decir que el número 5 (un objeto), una instancia de la clase de los números enteros, que tiene a su vez determinados métodos; en este caso las conocidas operaciones aritméticas: suma, resta, multiplicación..., así como las operaciones de asignación, la comparación, etc.
Siguiendo este criterio, la expresión: Cc x = (R1, Y1) no se dice: "declaración de x como objeto de la nueva clase". Es más correcto señalar: "x es una instancia de la clase de los complejos". Al hecho de sustanciar un objeto concreto de una clase se le denomina instanciar dicho objeto. Esta operación (crear un objeto) está encomendada a un tipo especial de métodos denominados constructores. Siguiendo con nuestro ejemplo, podemos suponer que la expresión:
Cc c1(1, 3);
crea un complejo c1 cuyas partes real e imaginaria valen respectivamente 1 y 3, y que este objeto ha sido creado por una función (método) de Cc cuya misión es precisamente crear objetos según ciertos parámetros que son pasados como argumentos de la función.
En ocasiones, cuando se quiere distinguir entre los valores y funcionalidades genéricos (de las clases) y los concretos (de los objetos), se suelen emplear las expresiones: propiedades de clase y métodos de clase para las primeras, y propiedades de instancia y métodos de instancia para los segundos. Así mismo, cuando se quiere distinguir entre una función normal (de la programación tradicional) y una función perteneciente a una clase (un método de la clase), se suele emplear para esta última la expresión función miembro (se sobreentiende "función miembro_de_una_clase") [1].
Como puede suponerse, el encapsulamiento, junto con la capacidad de sobrecarga, constituyen en si mismos una posibilidad de abstracción para los datos mayor que la que facilitaba la programación tradicional. Ya sí puede pensarse en un complejo como algo que tiene existencia "real" dentro de nuestro código ya que lo tratamos (manipulamos) como ente independiente con características propias; de forma parecida a la imagen que de él tenemos en nuestra mente.
Con ser lo anterior un paso importante, sin embargo, las mejoras de la POO respecto de la programación clásica no terminan aquí. Los Lenguajes Orientados a Objeto como el C++, además de los tipos de datos tradicionales, incluyen algunas clases pre-construidas, pero debido al hecho de que la "construcción" de un nuevo tipo de variable (como la clase Cc de los complejos en el ejemplo anterior) requiere un cierto trabajo de programación inicial para diseñar la clase, se pensó que sería estupendo poder aprovechar el trabajo realizado en determinados diseños para, a partir de ellos, obtener otros nuevos de características más o menos parecidas. Se trataba en suma de intentar reutilizar el código dentro de lo posible; en el mismo sentido que lo hacemos cuando tenemos que escribir una carta circular y buscamos en el ordenador si hay alguna anterior parecida para utilizarla junto con el procesador de textos, como punto de comienzo para la nueva. Para esto se dotó al lenguaje de dos nuevas capacidades: la Herencia y la Composición.
Por herencia ("Inheritance") se entiende la capacidad de poder crear nuevas clases a partir de alguna anterior, de forma que las nuevas "heredan" las características de sus ancestros (propiedades y métodos). Se trata por tanto de la capacidad de crear nuevos tipos de datos a partir de los anteriores. Una característica especial de la herencia es que si se cambia el comportamiento de la clase antecesora (también llamada padre, base o super), también cambiará el comportamiento de las clases derivadas de ella (descendientes).
Como puede deducirse fácilmente, la herencia establece lo que se llama una jerarquía de clases del mismo aspecto que el árbol genealógico de una familia. Se entiende también que estos conceptos representan niveles de abstracción que permiten acercar la programación a la realidad del mundo físico tal como lo concebimos. Por ejemplo, entendemos que un motor eléctrico deriva de la clase general de los motores, de la cual derivan también los de gasolina, diesel, vapor, etc. y que, sin dejar de ser motores, cada subclase tiene sus características peculiares.
Por supuesto que no tendría ningún sentido utilizar la herencia para crear simplemente un clónico de la clase base. En realidad, como en el ejemplo de la carta circular, la herencia se emplea como un primer paso (partir de algo existente) para a continuación, perfilar los comportamientos o datos que queremos pulir en la nueva versión de la clase. Como lo que principalmente interesa al usuario de una clase es su interfaz, son justamente algunos aspectos de esta interfaz los que se modifican, al objeto de adecuar la nueva subclase a las necesidades específicas del caso. Por lo general esta modificación de la interfaz se consigue de dos formas:
- 1. Añadiendo propiedades y/o métodos que no existían en la clase base
- 2. Sobrescribiendo propiedades del mismo nombre con distintos comportamientos (sobrecarga y/o polimorfismo).
Aunque la herencia es uno de los pilares de la POO, tiene también sus inconvenientes. Por ejemplo, dado que el
compilador debe imponer ciertas características en tiempo de compilación sobre las clases creadas
por herencia, esto resulta en cierta rigidez posterior.
Sin embargo, como hemos visto, una de sus ventajas es la reutilización del código.
La composición (también llamada herencia múltiple) es la segunda vía para
crear nuevas clases a partir de las existentes. Por composición se entiende la capacidad que presenta la POO de
ensamblar un nuevo tipo (clase) cuyos elementos o piezas son otras
clases. Es posible declarar clases derivadas de las existentes
especificando que heredan los miembros de una o más clases antecesoras.
Siguiendo con el símil de la carta circular, la composición equivaldría a
escribirla reutilizando trozos de cartas anteriores. Es clásico el
ejemplo de señalar que podríamos crear una clase "coche"
declarando que tiene un motor y cuatro ruedas, bastidor, aire acondicionado,
etc, elementos estos pertenecientes a la clase de los motores, de las ruedas,
los bastidores y los sistemas de climatización respectivamente. Este
sistema tiene también sus ventajas e inconvenientes, pero es muy flexible, ya
que incluso pueden cambiarse los componentes en tiempo de ejecución.
§2 Resumen:
Como resumen de este rápido repaso, podemos señalar que la Programación Orientada a Objetos (POO) tiene sus propios paradigmas y ventajas, entre las que destaca la reutilización del código. A los académicos les gusta decir que se sustenta en cuatro columnas a las que ya hemos hecho referencia:
Encapsulamiento: Poder separar la interfaz de una clase de su implementación, o dicho en otras palabras: no es necesario conocer los detalles de cómo están implementadas las propiedades para poder utilizarlas. Los objetos funcionan a modo de caja negra en la que están empaquetados los datos y las instrucciones para su manipulación, de las que conocemos solo lo necesario para utilizarla.
Herencia Crear nuevos elementos a partir de los existentes de forma que heredan las propiedades de sus ancestros. Existen dos clases de herencia: simple y múltiple.
Sobrecarga: Posibilidad de crear diferentes métodos dentro de una clase que comparten el mismo nombre, pero que aceptan argumentos diferentes y se comporten de forma distinta según la naturaleza de estos argumentos ( 4.4.1a).
Polimorfismo Es una característica que resulta de gran ayuda en programación pues facilita la claridad y consistencia del código, aunque es un concepto bastante genérico (y frecuentemente malinterpretado). Se conoce con este nombre el hecho de que un método tiene el mismo nombre y resulta en el mismo efecto básico pero está implementado de forma distinta en las distintas clases de una jerarquía.
§3 Bibliografía:
Para una más detallada exposición sobre los conceptos involucrados en la POO y su relación con el C++, sugerimos dos referencias: "Thinking in C++" ( Bruce Eckel) y "What is ‘Object-Oriented Programming’?" ( Stroustrup), aunque desafortunadamente ambas en inglés. La primera bastante didáctica enfocada a los aspectos "++" del C; la segunda algo más teórica pero con la autoridad que le confiere ser del inventor del C++.
[1] Si en principio le parece confusa toda esta terminología no se preocupe; lo esencial son los conceptos, puede seguir pensando en términos de "variables" y "funciones". Poco a poco los nuevos vocablos "propiedades", "métodos", "instancias", etc. irán fluyendo por sí solos.
En cualquier caso, tenga en cuenta que la terminología utilizada en la POO es utilizada a veces de forma no demasiado consistente, lo que puede inducir a confusión durante el tránsito de la programación tradicional a la POO. En otras ocasiones, en especial en algunos textos universitarios, se cuidan en exceso el rigor y el formalismo con lo que se tienen textos elegantísimos e irreprochables con los que es casi imposible enterarse de que se trata.
[2] Por esta razón, en la literatura forense pueden encontrarse frases como las siguiente: "Un mensaje es una petición que se hace a un objeto para que aplique uno de sus métodos". En realidad es la otra forma de decir que se invoca una función de una clase desde una de sus instancias (un objeto).
Debemos reconocer que esta forma de expresar el comportamiento de los objetos, cuando se explican sus funciones miembro refiriéndose a ellas como "mensajes" que se lanzan a los objetos, es muy del gusto de los partidarios de la POO, de forma que Bruce Eckel ( Thinking in C++) llega a afirmar que: "La POO puede resumirse en una sola frase: Enviar mensajes a objetos", y que cuando se ha asumido esto, el uso de C++ resulta sorprendentemente sencillo. Gran parte de esta terminología proviene en realidad de Smalltalk. Uno de los primeros lenguajes orientados a objeto desarrollado en el hoy legendario PARC "Palo Alto Research Center" de Xerox en California, donde nacieron muchas de las ideas que hoy pueblan el mundo de la informática.
[3] Precisamente el hecho de poder incluir funciones en las estructuras es la síntesis del avance que supuso C++ frente al C clásico ( 0.Iw1).
[4] En realidad los operadores C++ son funciones disfrazadas ( 4.9).