Definición de Clases

Objetivos: Mostrar (i) cómo se definen las clases de objetos, y (ii) cómo beneficiarse de este mecanismo para diseñar programas más simples de entender.

Temas:


Motivación

La clase Tiempo permite construir objetos para la manipulación de instantes de tiempo. La clase tiene los siguientes métodos:

Ejemplo Significado Declaración
Tiempo t= new Tiempo(12,30); Construye el instante 12h30min Tiempo(int horas, int min)
t.escribir(); Escribe t en la forma hh:mm void escribir()
t.sumar(t2); suma el instante t2 al instante t void sumar(Tiempo t2)
if (t.comparar(t2)<0) ... compara t con t2 entregando -1, 0 o 1 int comparar(Tiempo t2)

Esta clase se puede usar para sumar tiempos en horas y minutos. Por ejemplo, si se desea un programa que entable el siguiente diálogo con el usuario:

    Horas 1 ? 10
    Minutos 1 ? 15
    Horas 2 ? 1
    Minutos 2 ? 30
    Suma= 11:45
se puede usar la clase Tiempo para simplificar el cálculo:

   print("Horas 1 ? ");
   int horas1= readInt();
   print("Minutos 1 ? ");
   int min1= readInt();
   Tiempo t1= new Tiempo(horas1, minutos1);

   print("Horas 2 ? ");
   int horas2= readInt();
   print("Minutos 2 ? ");
   int min2= readInt();
   Tiempo t2= new Tiempo(horas2, minutos2);

   t1.sumar(t2);
   print("Suma= ");
   t1.escribir();
Observe que el tiempo t1 fue modificado cuando se le sumó t2. En cambio t2 no fue alterado.

Ejercicio en clases:

Un enfermo debe tomar sus medicamentos a intervalos separados por una hora y 25 minutos a partir de las 8:00 y hasta las 22:00. Escriba un programa que utilice la clase Tiempo para desplegar las horas a las que el enfermo debe tomar sus medicamentos. El programa debe desplegar en pantalla:

    8:00
    9:25
    10:50
    ...
Su programa debe usar la clase Tiempo para resolver el problema.

Solución:

    Tiempo t= new Tiempo(8,0);
    Tiempo tfin= new Tiempo(22,0);
    Tiempo periodo= new Tiempo(1,25);
    while (t.comparar(tfin)<=0) {
      t.imprimir();
      t.sumar(periodo);
    }
Observación: ¿Por qué sería un error si la última línea fuese periodo.sumar(t)? ¿O t=t.sumar(periodo)?


Definición de la clase Tiempo

    class Tiempo extends Program {
      // Variables de instancia
      int horas;
      int min;

      // constructor de instantes de tiempo
      Tiempo(int h, int m) {
        this.horas= h;
        this.min= m;
      }

      // Métodos para sumar, escribir y comparar
      void sumar(Tiempo t2) {
        this.horas= this.horas+t2.horas + (this.min+t2.min)/60;
        this.min= (this.min+t2.min)%60;
      }

      void escribir() {
        println(this.horas+":"+this.min);
      }

      int comparar(Tiempo t2) {
        int min1= this.horas*60+this.min;
        int min2= t2.horas*60+t2.min;
        if (min1==min2)
          return 0;
        if (min1<min2)
          return -1;
        return 1;
      }
    }
Explicaciones:

Observe que los métodos para sumar, escribir y comparar se ven como funciones o procedimientos. Esto no es coincidencia. Más adelante veremos que en realidad ¡las funciones y procedimientos son métodos!

Al comienzo de la clase se declaran variables que no están dentro de ningún método. Estas variables se denominan variables de instancia.

Si estudia detenidamente el programa se dará cuenta que existe una definición extraña: la del pseudo método Tiempo. Es extraña porque no lleva tipo de retorno y porque lleva el mismo nombre de la clase. No se trata de un error sintáctico. Se trata del constructor de la clase. Este es un método especial, pues nunca se invoca explícitamente, si no que se invoca automáticamente cada vez que se construye un nuevo objeto. En él se inicializan las características del objeto.


Variables de instancia

Un objeto de la clase Tiempo se crea mediante la expresión:

    new Tiempo(1, 25)
Se dice que el objeto construido es una instancia de la clase Tiempo. Una clase puede tener innumerables instancias de sus objetos. Cada instancia se representa mediante variables que describen las características de ese objeto. Estas variables se denominan variables de instancia. Para la clase Tiempo las variables de instancia son horas y min. Representaremos un objeto mediante una figura que indique el contenido de sus variables de instancia:

Los objetos son referenciados por variables. Para indicar que una determinada variable referencia un objeto se coloca una flecha desde la variable hacia el objeto:

Nunca olvide la metáfora que visualiza los objetos como robots y que las variables almacenan el teléfono celular del robot. Por lo tanto, la flecha indica que la variable periodo contiene el celular del robot al que apunta la flecha.

Cada instancia de la clase Tiempo tiene su propio juego de variables. Si hay tres objetos existirán 3 variables horas y 3 variables min:

En la figura cada objeto es referenciado por alguna variable. Las variables de instancia se crean al construir un objeto con el operador new. Las variables que hemos usado hasta al momento y que se crean al ejecutar funciones, procedimientos o métodos se denominan variables locales, porque ellas son locales a los métodos en donde se definen, o en otras palabras no son visibles desde otros métodos. En cambio las variables de instancia sí son visibles desde cualquier método que tenga una referencia al objeto al que pertencen.


Acceso a las variables de instancia

Teniendo una referencia de un objeto es posible recuperar el contenido de sus variables de instancia y modificar su contenido. Para recuperar el contenido de la variable horas del objeto referenciado por t:
    int h= t.horas; // 8
Para cambiar su valor:

    t.horas= 10;
Esta asignación sólo modificará la variable horas del objeto referenciado por t. Los demás objetos no serán modificados:


Definición de métodos

En la definición de un método se indican las instrucciones que llevan a cabo la acción asociada a ese método.

Un método se invoca por ejemplo mediante:

    t.sumar(periodo);
Lo que hace que se ejecute el código especificado en la definición del método sumar, de la misma forma como se ejecuta el código de funciones o procedimientos. En esta invocación diremos que el objeto referenciado por la variable t es el objeto de la invocación.

El enlace que se hace entre argumentos de la invocación y parámetros en la definición del método es el siguiente:

Como en las funciones y procedimientos, los parámetros se inicializan con los valores de los argumentos de la invocación. Pero durante la invocación de un método existe un argumento adicional: el objeto de la invocación. Este objeto es accesible por medio de la variable this, que no es necesario declarar. Ella está siempre presente dentro de la definición de un método.

La siguiente figura muestra el contenido de las variables en tiempo de ejecución, al invocarse el método anterior:

El código del método sumar es:

    void sumar(Tiempo t2) {
      this.horas= this.horas+t2.horas + (this.min+t2.min)/60;
      this.min= (this.min+t2.min)%60;
    }
Observe que dentro del método se obtienen los valores de las variables de instancia del objeto de la invocación y del objeto referenciado por t2. Además se modifican las variables del objeto de la invocación, pero no se alteran las variables de t2. Cuando el método sumar retorna, estas modificaciones serán apreciables desde la variable t, porque t referencia el mismo objeto que fue modificado.

Estudie cuidadosamente la definición de los métodos comparar y escribir.


Propuesto: definición de la clase Fecha

Defina la clase Fecha que fue presentanda en clases anteriores. Simplifique el problema suponiendo que no hay años bisiestos.


Resumen

Los objetos de una misma clase poseen, en general, atributos o propiedades distintas. Esta diferenciación hace que realicen acciones distintas. Por ejemplo, dos colas pueden almacenar elementos distintos y por lo tanto al extraer el primer elemento de ambas colas, se obtendrán valores distintos, aún cuando pertenecen a la misma clase.

Las propiedades de los objetos se almacenan en las variables de instancias. Estas se crean al construir el objeto y se inicializan en el constructor. Las variables de instancia se destruyen junto con el objeto, cuando éste pierde su utilidad.

Al definir una clase es necesario indicar cuales serán las variables de las instancias (i.e. los objetos). Por ejemplo:

    class Tiempo {
      int horas;
      int min;
      ...
    }

Las variables de una instancia se puede obtener o modificar teniendo una referencia de esa instancia. Por ejemplo:

    int h= t.horas; // Obtiene el valor de la variable horas
    t.min= 15;      // Modifica el valor de la variable min
Al definir una clase también se especifican sus métodos, que son los que se encargan de manipular las variables de instancia. Los métodos son en esencia funciones o procedimientos como los que hemos visto hasta ahora. La diferencia está en que los métodos se invocan adjuntando un objeto que constituye el objeto de la invocación. Por ejemplo en:

    t.sumar(periodo);
el objeto de la invocación es el objeto referenciado por t.

En la definición de un método, el objeto de la invocación es un parámetro implícito, porque no es necesario declararlo. Este parámetro es accesible por medio de la variable this:

    class Tiempo {
      ...
      void sumar(Tiempo t2) {
        this.horas= ... this.horas ... t2.horas ...
        ...
      }
    }

Definición del constructor

El constructor es un pseudo método que lleva el mismo nombre de la clase y no posee tipo de retorno. Se invoca automáticamente al crear un objeto con new. En él, se inicializan las variables de instancia del nuevo objeto. En la clase Tiempo el constructor es:

      Tiempo(int h, int m) {
        this.horas= h;
        this.min= m;
      }
En él, se asignan los valores iniciales para las variables de instancia this.horas y this.min. Los valores que se le asignan se reciben como parámetros.

El siguiente ejemplo permite visualizar mejor los eventos que ocurren las construir un objeto de la clase Tiempo. Supongamos que se ejecuta la siguiente instrucción:

    Tiempo t= new Tiempo(8, 0);
El operador new Tiempo(...) hace que se construya un nuevo objeto de la clase Tiempo, creando variables de instancia para él. En seguida se invoca el constructor, asignando a h el primer argumento especificado (8) y a m el segundo argumento (0). Luego se ejecutan las instrucciones del constructor.

Una vez terminada la ejecución del constructor, el operador new entrega una referencia del objeto recién construido, que finalmente es asignada a la variable t.


Beneficios de la definición de clases

El diseño de la solución de un problema consiste en descomponerlo en subproblemas más simples. Una forma avanzada de diseñar soluciones es lograr que cada subproblema sea resuelto por una clase de objetos. Cada clase resuelve una parte del problema abstrayéndose de los detalles de la resolución de todo el problema.

Al trabajar con clases de objetos es importante distinguir entre (i) el empleo de una clase y (ii) la definición o implementación de esa clase. Una clase se emplea al resolver otras partes del problema y por lo tanto, sus objetos se manipulan únicamente invocando sus métodos. Nunca se accesan directamente sus variables de instancia, porque éstas son parte de la implementación de la clase. Accesar las variables de instancia al emplear una clase es una violación del principio de abstracción.

En cambio cuando se define la clase, el programador está precisamente resolviendo el subproblema que se ha delegado a esa clase. Y por lo tanto es necesario accesar sus variables de instancia, pero también se pueden invocar su métodos.

Esta forma de trabajar con las clases es importante porque a menudo uno se encuentra con la necesidad de cambiar la representación de los objetos (es decir, cambiar sus variables de instancia) y los algoritmos que se emplean en la clase, sin cambiar la forma en que se emplean estos objetos.

Por ejemplo, otra otra forma de implementar la clase Tiempo consiste en representar el tiempo en términos de minutos (que pueden exceder la hora):

    class Tiempo {
      // Variables de instancia:
      int min;
      // El constructor:
      Tiempo(int h, int m) {
        this.min= h*60+m;
      }
      // Los métodos:
      void sumar(Tiempo t2) {
        this.min= this.min+t2.min;
      }
      void imprimir() {
        println((this.min/60)+":"(this.min%60));
      }
      int comparar(Tiempo t2) {
        if (this.min<t2.min)
          return -1;
        if (this.min==t2.min)
          return 0;
        return 1;
      }
    }
Los programas que emplean esta nueva definición de la clase Tiempo no necesitan alterarse. Seguirán funcionando correctamente.


Ejercicio: Adivina mi número

Se dispone de una clase que juega al adivina mi número. Los objetos de esta clase pueden adivinar el número del usuario, pero no saben dialogar con el usuario. Los objetos poseen los siguientes métodos:

Ejemplo Significado Declaración
Adivino a= new Adivino(0, 1023); Construye un adivino Adivino(int min, int max)
int n= a.jugar(); Juega el número n int jugar()
a.mayor(n); se le indica que es mayor que n void mayor(int n)
a.menor(n); se le indica que es menor que n void menor(int n)
int cont= a.intentos(); entrega cuantos intentos ha hecho int intentos()

El ejercicio consiste en emplear esta clase para escribir un programa que entable el siguiente diálogo con el usuario:

   Piense un número entre 0 y 1023 y trataré de adivinarlo.
   Esta listo ?
   Digame si es menor, mayor o igual que 512 ? menor
   Digame si es menor, mayor o igual que 200 ? mayor
   Digame si es menor, mayor o igual que 400 ? mayor
   ...
   Digame si es menor, mayor o igual que 415 ? igual
   Ok, lo adivine en x intentos
Supuestos: (a) el usuario no hace trampas, y (b) la clase Adivino se encarga de la estrategia de juego.

Solución:

   println("Piense un número entre 0 y 1023 y trataré de adivinarlo.");
   println("Esta listo ? ");
   readLine(); // Espera que el usuario ingrese una línea
   Adivino adiv= new Adivino(0, 1023);
   while(true) {
     int n= adiv.jugar();
     println("Digame si es menor, mayor o igual que "+n+" ? ");
     String resp= readLine();
     if (compare(resp, "igual")==0)
       break;
     if (compare(resp, "menor")==0)
       adiv.menor(n);
     else
       adiv.mayor(n);
   }
   println("Ok, lo adivine en "+adiv.intentos()+" intentos");
Al resolver este problema nos hemos abstraído de la parte difícil que consite en adivinar el número. La inteligencia para adivinar el número la hemos puesto en el objeto adivino.


Ejercicio:

Escriba una versión de la clase Adivino que siga una estrategia de juego trivial: juegue sistemáticamente el mínimo valor posible. Cuando se le diga que el número del usuario es mayor que el número jugado, incremente el mínimo en 1.

Solución:

Los objetos de la clase Adivino se representarán mediante 2 variables de instancia:

La estrategia que se empleará es muy simple: se juega el mínimo. Cuando se diga que es mayor, se aumenta el mínimo en uno y se juega el nuevo mínimo.

    class Adivino {
      int intentos;
      int min;
      Adivino(int min, int max) {
        this.min= min; // ¡se ignora max!
        intentos= 0;
      }
      int jugar() {
        this.intentos= this.intentos+1;
        return this.min;
      }
      int mayor(int n) {
        this.min= n+1;
      }
      int menor(int n) {
      }
      int intentos() {
        return this.intentos;
      }
    }
Observaciones:


Estrategia inteligente

La estrategia implementada para adivinar el número no es muy inteligente y probablemente aburrirá rápidamente a cualquier jugador antes que termine el juego.

A continuación escribiremos una nueva versión de la clase Adivino que, por supuesto, no requiere que se modifique el programa que dialoga con el usuario. Esta clase adivinará en no más de 10 intentos el número del usuario.

Las características de la clase son las siguientes:

La definición de la clase es la siguiente:

    class Adivino {
      int intentos;
      int min;
      int max;
      Adivino(int min, int max) {
        this.min= min;
        this.max= max; // Ahora *si* se considera max.
        intentos= 0;
      }
      int jugar() {
        this.intentos= this.intentos+1;
        return (this.min+this.max)/2;
      }
      int mayor(int n) {
        this.min= n+1;
      }
      int menor(int n) {
        this.max= n-1;
      }
      int intentos() {
        return this.intentos;
      }
    }

Clases mutables vs. clases no mutable

Podemos agrupar las clases de objetos en dos grandes categorías: las clases mutables y las clases inmutables. Las clase mutables como la clase Tiempo y la clase Adivino poseen métodos que alteran su estado interno. Por ejemplo, cuando se le entrega información al adivino diciéndole que el número desconocido es mayor que n, el no volverá a intentar con un número inferior o igual a n. Esto se traduce en que cada vez que se invoca el método jugar, entrega un número distinto.

Al contrario, las clases inmutables no poseen ningún método que haga variar su estado interno. En este tipo de clases, si se invoca dos veces un mismo método con los mismos argumentos, el método siempre retornará el mismo valor. También se dice que los métodos de una clase inmutable no producen efectos laterales en sus objetos.

Ejemplo: los hiper ints

La clase HiperInt permite representar números enteros positivos de hasta 1000 dígitos. Esta clase posee los siguientes métodos:

Ejemplo Significado Declaración
HiperInt hi= new HiperInt(1023); Construye un HiperInt HiperInt(int n)
HiperInt hk= hi.suma(hj); entrega la suma de hi y hj HiperInt suma(HiperInt h)
HiperInt hk= hi.producto(hj); entrega el producto de hi y hj HiperInt producto(HiperInt h)
if (hi.comparar(hj)==0) ... comparar hi con hj (-1, 0, 1) int comparar(HiperInt h)
println(hi.toString()) entrega el HiperInt como string String toString()

Esta clase se puede usar para calcular el factorial de números tan grandes como 500 en forma exacta:

    print("n ? ");
    int n= readInt();
    int i= 1;
    HiperInt fact= new HiperInt(1);
    while (i<=n) {
      fact= fact.producto(new HiperInt(i));
      i= i+1;
    }
    println(fact.toString());
Definición de la clase HiperInt

La idea es representar los dígitos de un HiperInt en un arreglo de enteros. El dígito menos significativo en el índice 0, el segundo dígito menos significativo en el índice 1 y así en adelante. Por ejemplo, el número 1075 será almacenado en el siguiente arreglo:

    class HiperInt {
      int[] digitos;
      HiperInt(int k) {
        this.digitos= new int[1000]; // Inicialmente todos en 0
        int i= 0;
        while (k>0) {
          this.digitos[i]= k%10;
          k= k/10;
        }
      }
      HiperInt suma(HiperInt hk) {
        HiperInt res= new HiperInt(0);
        int i= 0;
        int acarreo= 0;
        while (i<1000) {
          res.digitos[i]= this.digitos[i]+hk.digitos[i]+acarreo; // (*)
          i= i+1;
        }
        // Si acarreo!=0 hubo desborde, pero no haremos nada
        return res;
      }
      // Resto de los métodos: tarea
    }
Observación:

Como regla general, las clases inmutables se pueden distinguir de las clases mutables, mirando qué retornan sus métodos. Las clases que tienen algún método void son mutables. En cambio, cuando los métodos siempre retornan un valor, es altamente probable que se trate de una clase inmutable.

Por ejemplo la clase Fecha vista anteriormente es inmutable, mientras que la clase Tortuga es mutable, puesto que tiene métodos void. La clase TextReader no tiene métodos void, sin embargo es mutable porque las distintas invocaciones de readInt entregan enteros distintos.


Tarea:

Complete la clase HiperInt definiendo primero el método comparar y luego el método producto.