Apuntes · Capítulo 2Objetos y clases

Clases, objetos y referencias

Clases, objetos y referencias

La semana pasada escribimos una primera clase, TransitCard, y vimos que un objeto junta estado y comportamiento: campos que recuerdan y métodos que responden a mensajes. También dejamos tres preguntas abiertas. ¿Qué pasa si se usa una variable que no apunta a ningún objeto? ¿Qué diferencia hay entre que dos objetos sean el mismo y que se parezcan? ¿Y qué guarda, exactamente, una variable de tipo clase?

Este capítulo responde esas preguntas en dos partes. En la primera escribimos una clase completa, con todas sus piezas: campos y sus valores por defecto, métodos de instancia y this, varios constructores y el overloading. En la segunda miramos la memoria: el stack y el heap, las referencias, los alias, cómo se pasan los objetos a un método, null, el garbage collector y la diferencia entre identidad e igualdad. Al final usamos un debugger para ver el estado de un objeto mientras el programa corre.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), secciones 5.1 Objects, Instance Methods, and Instance Variables (completa, con atención a 5.1.2 y 5.1.4), 5.2 Constructors and Object Initialization, la subsección 5.6.1 sobre this, la 4.3.3 sobre overloading y la 2.3.3 sobre equals en String. Para el laboratorio, la 3.2.3 Coding, Testing, Debugging. El código del capítulo está en code/week-02 y se prueba con make test dentro del contenedor del curso (env/run.sh).

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): la introducción de Una clase completa: Thermometer y Stack y heap.
  • En clase: en la sesión A, el resto de Una clase completa: campos, métodos y this, constructores y overloading; en la sesión B, Referencias, heap y null, de Alias a Identidad frente a igualdad. En el laboratorio, Laboratorio: depurar con jdb.
  • Después de clase: los dos ejemplos resueltos (Ejemplo resuelto: usar la clase y Ejemplo resuelto: alias en jshell), el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de las notas Lectura.

Una clase completa: Thermometer

Nuestro ejemplo de esta semana es un termómetro que recuerda la temperatura actual y también la mínima y la máxima registradas desde que se instaló, como los termómetros de máximas y mínimas de una estación meteorológica. Thermometer.java:

public class Thermometer {
    private String place;           // dónde está instalado
    private double current;         // grados Celsius
    private double min;
    private double max;
    private int readings;           // cuántas lecturas lleva, contando la inicial

    /** Crea un termómetro en un lugar, con una primera lectura. */
    public Thermometer(String place, double initial) {
        this.place = place;
        this.current = initial;
        this.min = initial;
        this.max = initial;
        this.readings = 1;
    }

    /** Crea un termómetro sin lugar conocido. */
    public Thermometer(double initial) {
        this("sin lugar", initial);
    }

    /** Crea un termómetro sin lugar conocido que empieza en 0 °C. */
    public Thermometer() {
        this(0.0);
    }

    /** Registra una lectura nueva: cambia la actual y, si hace falta, la mínima o la máxima. */
    public void record(double celsius) {
        current = celsius;
        if (celsius < min) {
            min = celsius;
        }
        if (celsius > max) {
            max = celsius;
        }
        readings++;
    }

    public String place() {
        return place;
    }

    public double current() {
        return current;
    }

    // min(), max() y readings() son iguales: devuelven su campo.

    /** Diferencia entre la máxima y la mínima registradas. */
    public double range() {
        return max - min;
    }
}

La clase es el molde; cada objeto creado con new Thermometer(...) es una instancia (instance) con su propio estado. El termómetro de Lima y el del laboratorio son dos instancias de la misma clase: el código de record se escribió una vez, pero cada objeto tiene su current, su min y su max. Vamos pieza por pieza.

Campos y valores por defecto

Los campos (fields) se declaran dentro de la clase y fuera de cualquier método. Cada objeto tiene su propia copia de cada campo, y la copia vive mientras viva el objeto, no mientras se ejecuta un método.

Cuando new reserva un objeto, todos sus campos empiezan en un valor por defecto, antes de que corra el constructor:

Tipo del campo Valor por defecto
int, long, short, byte 0
double, float 0.0
char el carácter de código 0
boolean false
cualquier referencia (String, arreglos, clases) null

Las variables locales, las que se declaran dentro de un método, son distintas: no tienen valor por defecto, y el compilador exige que tengan un valor antes de usarlas:

void main() {
    int count;
    IO.println(count + 1);
}
$ javac -Xlint:all Uninit.java
Uninit.java:3: error: variable count might not have been initialized
    IO.println(count + 1);
               ^
1 error

En C, ese count tendría basura: lo que hubiera en esa posición del stack. En Java el programa ni siquiera compila. Para los campos, Java no puede hacer ese análisis (un campo se puede asignar desde cualquier método, en cualquier orden), así que elige otro camino: los pone en cero.

Idea clave. Un campo nunca tiene basura: empieza en 0, false o null. Una variable local no tiene valor hasta que se le asigna uno, y el compilador lo verifica. En ninguno de los dos casos se lee memoria vieja.

Métodos de instancia y this

Un método de instancia (instance method) se ejecuta siempre sobre un objeto: el que recibió el mensaje, llamado el receptor (receiver). En lima.record(22.0), el receptor es el objeto al que apunta lima. Dentro de record, el nombre current se refiere al campo current de ese objeto.

¿Cómo sabe el método cuál es el receptor? Java le pasa una referencia escondida, que dentro del método se llama this. Es lo que en C hacíamos a mano con el parámetro struct card *c: this.current es el equivalente de c->current. Casi nunca hace falta escribirlo, porque current a secas ya significa this.current. Se escribe cuando hay que distinguir el campo de un parámetro o variable local con el mismo nombre, como en el constructor:

public Thermometer(String place, double initial) {
    this.place = place;     // campo = parámetro
    ...
}

Los métodos de una clase suelen ser de dos clases:

  • Consultas (queries): devuelven algo sobre el estado y no lo cambian. current(), max() y range() son consultas. Se pueden llamar las veces que se quiera, en cualquier orden, sin consecuencias.
  • Modificadores (commands): cambian el estado y, en general, no devuelven nada (void). record es un modificador.

Separarlos ayuda a razonar sobre un programa: si un método se llama max(), nadie espera que cambie el termómetro. Fíjense en que range() no tiene un campo propio: se calcula a partir de otros dos. Quien la usa no sabe, ni necesita saber, si el valor estaba guardado o se calculó.

Constructores

Un constructor (constructor) se llama igual que la clase y no tiene tipo de retorno, ni siquiera void. Se ejecuta una sola vez por objeto, justo después de que new reserva la memoria, y su trabajo es dejar los campos en un estado con sentido. En Thermometer, la primera lectura es a la vez la actual, la mínima y la máxima, y el contador empieza en 1.

Si una clase no declara ningún constructor, el compilador agrega uno invisible, sin parámetros y que no hace nada: el constructor por defecto (default constructor). Pero desaparece apenas escribimos un constructor propio. Si Thermometer tuviera solo Thermometer(double initial), esto no compilaría:

Thermometer t = new Thermometer();
Main.java:2: error: constructor Thermometer in class Thermometer cannot be applied to given types;
    Thermometer t = new Thermometer();
                    ^
  required: double
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error

El mensaje lo dice todo: el único constructor que existe pide un double (required) y le dimos nada (found).

Overloading y this(...)

Nuestro Thermometer tiene tres constructores. Eso es posible gracias al overloading: en una clase puede haber varios métodos, o varios constructores, con el mismo nombre, siempre que sus listas de parámetros sean distintas en número o en tipos. El compilador elige cuál usar mirando los argumentos de cada llamada:

new Thermometer("Lima", 18.5)   // (String, double)
new Thermometer(21.0)           // (double)
new Thermometer()               // ()

En el overloading, el tipo de retorno no cuenta para distinguir las versiones; solo los parámetros. IO.println es un ejemplo que ya usaban sin saberlo: hay una versión para cada tipo de argumento.

Los tres constructores hacen casi lo mismo. Para no repetir el código, uno puede llamar a otro con this(...), que tiene que ser la primera instrucción del constructor:

public Thermometer(double initial) {
    this("sin lugar", initial);     // delega en el constructor (String, double)
}

public Thermometer() {
    this(0.0);                      // delega en el constructor (double)
}

Así, toda la inicialización real vive en un solo constructor. Si mañana agregamos un campo, se inicializa en un solo lugar.

Ejemplo resuelto: usar la clase

ThermometerDemo.java crea termómetros con los tres constructores y les envía mensajes:

Thermometer lima = new Thermometer("Lima", 18.5);
lima.record(22.0);
lima.record(16.0);
IO.println(lima.place() + ": actual " + lima.current()
        + ", mín " + lima.min() + ", máx " + lima.max()
        + ", " + lima.readings() + " lecturas");

Thermometer lab = new Thermometer(21.0);    // overloading: otro constructor
Thermometer fresh = new Thermometer();      // y otro más
IO.println(lab.place() + ": " + lab.current());
IO.println(fresh.place() + ": " + fresh.current());

// Cada objeto tiene su propio estado: registrar en lab no cambia lima.
lab.record(35.0);
IO.println("rango de lab: " + lab.range() + ", rango de lima: " + lima.range());
$ java -cp build ThermometerDemo
Lima: actual 16.0, mín 16.0, máx 22.0, 3 lecturas
sin lugar: 21.0
sin lugar: 0.0
rango de lab: 14.0, rango de lima: 6.0

Sigan el termómetro de Lima: empieza en 18.5 (mínima y máxima 18.5), sube a 22.0 (nueva máxima), baja a 16.0 (nueva mínima). Tres lecturas, rango 22.0 − 16.0 = 6.0. El de lab empieza en 21.0 y sube a 35.0: rango 14.0. Las lecturas de uno no tocan al otro.

Error común. Escribir un “constructor” con tipo de retorno:

public class Thermometer {
    private double current;

    public void Thermometer(double initial) {    // tiene tipo de retorno: es un método
        current = initial;
    }
}

Con void, eso ya no es un constructor: es un método común que se llama igual que la clase (Java lo permite, aunque nadie debería hacerlo). La clase se queda con el constructor por defecto, y el error aparece lejos, donde se crea el objeto:

Main.java:2: error: constructor Thermometer in class Thermometer cannot be applied to given types;
    Thermometer t = new Thermometer(25.0);
                    ^
  required: no arguments
  found:    double
  reason: actual and formal argument lists differ in length
1 error

Cuando un error dice que el constructor pide no arguments y ustedes escribieron uno con parámetros, busquen un void de más.

Error común. Un parámetro que se llama igual que un campo lo tapa: dentro del método, el nombre se refiere al parámetro. Eso se llama shadowing. ShadowBug.java:

class BuggyThermometer {
    private double current;

    BuggyThermometer(double current) {
        current = current;          // asigna el parámetro a sí mismo; el campo queda en 0.0
    }
    ...
}
$ java -cp build ShadowBug
esperaba 25.0 y obtuve 0.0

Compila sin una sola advertencia, incluso con -Xlint:all. El campo nunca se asignó y se quedó en su valor por defecto. La corrección es this.current = current;.

Referencias, heap y null

Stack y heap

Cuando un método se ejecuta, sus variables locales y sus parámetros viven en un stack frame, la parte del stack que le toca a esa llamada, igual que en C: el stack frame aparece al llamar al método y desaparece cuando el método termina. Los objetos, en cambio, viven en el heap, y duran mientras alguien pueda llegar a ellos.

Una variable de tipo clase vive en el stack, pero no contiene el objeto: contiene una referencia (reference) a él. Después de ThermometerDemo, la memoria se ve así:

A la izquierda, el stack frame de main en el stack con las variables lima y lab; cada una guarda un punto del que sale una flecha. A la derecha, en el heap, dos objetos Thermometer: el de lima con current 16.0, min 16.0, max 22.0 y readings 3; el de lab con current 35.0, min 21.0, max 35.0 y readings 2. El campo place de cada objeto es otra flecha, a un objeto String: "Lima" y "sin lugar"A la izquierda, el stack frame de main en el stack con las variables lima y lab; cada una guarda un punto del que sale una flecha. A la derecha, en el heap, dos objetos Thermometer: el de lima con current 16.0, min 16.0, max 22.0 y readings 3; el de lab con current 35.0, min 21.0, max 35.0 y readings 2. El campo place de cada objeto es otra flecha, a un objeto String: "Lima" y "sin lugar"
Las variables locales viven en el stack frame del método; los objetos, en el heap. Un campo de tipo String es también una referencia, a otro objeto.

Tres cosas para mirar en la figura:

  • lima y lab son variables pequeñas: guardan solo la flecha, sin importar cuántos campos tenga el objeto.
  • Los campos primitivos (current, min, max, readings) guardan su valor dentro del objeto.
  • El campo place es de tipo String, así que también es una referencia: el texto vive en otro objeto del heap.

Si vienen de C, una referencia se parece mucho a un puntero a un struct reservado con malloc. Las diferencias son las de la semana 1: no hay aritmética de punteros, no se puede convertir un número en referencia, solo puede apuntar a un objeto del tipo correcto (o a ninguno) y no hay free.

En Java. En HotSpot, una referencia ocupa 4 u 8 bytes según la configuración de la JVM, y cada objeto lleva además una cabecera con información para la JVM (entre otras cosas, cuál es su clase). No necesitan recordar esos números; sí que copiar una referencia es barato y nunca copia el objeto.

Alias

Como una variable guarda una referencia, asignar una variable a otra copia la referencia, no el objeto. Las dos variables quedan apuntando al mismo objeto: son alias (aliases).

Animación: el código Thermometer a = new Thermometer(); Thermometer b = a; b.record(30);. Paso 1: aparece un objeto Thermometer con current 0.0, min 0.0, max 0.0 y readings 1, y la variable a apunta a él. Paso 2: aparece la variable b, con una flecha al mismo objeto. Paso 3: el mensaje record(30) enviado por b cambia current y max a 30.0 y readings a 2Animación: el código Thermometer a = new Thermometer(); Thermometer b = a; b.record(30);. Paso 1: aparece un objeto Thermometer con current 0.0, min 0.0, max 0.0 y readings 1, y la variable a apunta a él. Paso 2: aparece la variable b, con una flecha al mismo objeto. Paso 3: el mensaje record(30) enviado por b cambia current y max a 30.0 y readings a 2
b = a copia la flecha. Hay un solo objeto, así que un cambio hecho a través de b se ve a través de a.

Por eso Aliasing.java empieza así:

Thermometer a = new Thermometer("Lima", 18.0);
Thermometer b = a;              // alias: dos variables, un solo objeto
b.record(30.0);
IO.println("a.max() = " + a.max() + "   (a == b: " + (a == b) + ")");
a.max() = 30.0   (a == b: true)

Nadie le envió un mensaje a a, y sin embargo su máxima cambió. En un programa corto se ve enseguida. En uno grande, con referencias guardadas en campos de otros objetos y en listas, un alias olvidado produce errores difíciles de encontrar: un objeto “cambia solo” porque otra parte del programa, que tenía otra referencia al mismo objeto, lo modificó.

Idea clave. = entre variables de tipo clase copia la flecha, no el objeto. Antes de modificar un objeto, pregúntense quién más tiene una referencia a él.

Paso de parámetros: siempre por valor

En Java, los argumentos se pasan siempre por valor: el parámetro es una variable nueva del método llamado, que recibe una copia del valor del argumento. Con un int, se copia el número. Con un objeto, el valor de la variable es una referencia, así que se copia la referencia. Eso tiene dos consecuencias, y Aliasing.java muestra las dos:

// Recibe una copia de la referencia: llega al mismo objeto y lo modifica.
void heatUp(Thermometer t) {
    t.record(t.current() + 5);
}

// También recibe una copia de la referencia, pero le asigna otro objeto:
// solo cambia el parámetro, no la variable de quien llamó.
void replace(Thermometer t) {
    t = new Thermometer("Cusco", 4.0);
    t.record(-2.0);
}
heatUp(a);
IO.println("después de heatUp:  " + a.place() + ", actual " + a.current());

replace(a);
IO.println("después de replace: " + a.place() + ", actual " + a.current());
después de heatUp:  Lima, actual 35.0
después de replace: Lima, actual 35.0
Animación en cuatro pasos. Paso 1: durante heatUp(a), aparece el stack frame de heatUp con la variable t, cuya flecha llega al mismo objeto Thermometer de Lima al que apunta a. Paso 2: t.record cambia current de 30.0 a 35.0 en ese objeto. Paso 3: durante replace(a), t primero apunta al objeto de Lima y luego a un objeto nuevo de Cusco con current -2.0; la flecha de a no se mueve. Paso 4: replace termina, su stack frame desaparece y el objeto de Cusco queda gris, sin flechas que lleguen a élAnimación en cuatro pasos. Paso 1: durante heatUp(a), aparece el stack frame de heatUp con la variable t, cuya flecha llega al mismo objeto Thermometer de Lima al que apunta a. Paso 2: t.record cambia current de 30.0 a 35.0 en ese objeto. Paso 3: durante replace(a), t primero apunta al objeto de Lima y luego a un objeto nuevo de Cusco con current -2.0; la flecha de a no se mueve. Paso 4: replace termina, su stack frame desaparece y el objeto de Cusco queda gris, sin flechas que lleguen a él
El parámetro es una copia de la referencia. Modificar el objeto se ve desde afuera; asignarle otro objeto al parámetro, no.
  • heatUp usa su copia de la referencia para enviarle un mensaje al objeto. El objeto es el mismo que ve main, así que el cambio se ve afuera.
  • replace le asigna al parámetro un objeto nuevo. Eso cambia la copia que tiene replace, no la variable a de main, que sigue apuntando al termómetro de Lima. Cuando replace termina, su stack frame desaparece y nadie recuerda el termómetro de Cusco.

Si vienen de C++, noten que Java no tiene paso por referencia (&): ningún método puede hacer que una variable de quien lo llamó apunte a otro objeto.

Error común. Decir que “Java pasa los objetos por referencia”. Lo que se pasa, por valor, es la referencia. La prueba es replace: si el objeto se pasara por referencia, a habría terminado en Cusco.

null y el NullPointerException

Una variable de tipo referencia puede no apuntar a ningún objeto. Ese valor se llama null. Es el valor por defecto de los campos de tipo referencia y se puede asignar a mano: Thermometer t = null;.

Con una referencia null se puede hacer poco: compararla (t == null), asignarla o pasarla a un método. Lo que no se puede es usarla para llegar a un objeto, porque no hay objeto. Enviar un mensaje a través de null (t.max()) o leer un campo produce un NullPointerException, el error más común de Java. NullDemo.java lo provoca a propósito:

void main() {
    Thermometer ok = new Thermometer("Lima", 18.0);
    IO.println(ok.place() + " tiene " + ok.place().length() + " letras");

    Thermometer broken = new Thermometer(null, 18.0);    // nada lo impide (todavía)
    IO.println("lugar: " + broken.place());
    IO.println(broken.place() + " tiene " + broken.place().length() + " letras");
}
$ java -cp build NullDemo
Lima tiene 4 letras
lugar: null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "Thermometer.place()" is null
	at NullDemo.main(NullDemo.java:8)

Lean el stack trace de abajo hacia arriba como una dirección:

  • at NullDemo.main(NullDemo.java:8): el error ocurrió en el método main, en la línea 8 del archivo NullDemo.java.
  • Cannot invoke "String.length()": se intentó enviar el mensaje length()…
  • because the return value of "Thermometer.place()" is null: …a algo que era null, y ese algo era lo que devolvió place().

Desde Java 15, el mensaje dice qué expresión era null (JEP 358). En una línea como la 8, con dos llamadas a place() y una a length(), eso ahorra mucho tiempo. Fíjense también en la línea 7: imprimir una referencia null con + no falla, escribe null. Lo que falla es enviarle un mensaje.

En Java. Los nombres de las variables locales aparecen en el mensaje solo si el programa se compiló con información de depuración (javac -g); sin ella, Java escribe algo como "<local2>". El Makefile de esta semana compila con -g por eso, y porque el debugger también la necesita.

El problema real está en la línea 6: un termómetro sin lugar no tiene sentido, y la clase lo aceptó sin decir nada. Cuando el error salta, en la línea 8, el dato malo ya lleva rato dentro del objeto. La semana 3 resuelve eso: el constructor va a validar sus argumentos y a rechazar un null en el momento en que alguien intente crearlo.

El garbage collector

En la animación del paso por valor, el termómetro de Cusco quedó sin ninguna referencia que llegue a él. Un objeto así es inalcanzable (unreachable): el programa no tiene forma de volver a usarlo. El garbage collector de la JVM recupera la memoria de esos objetos cuando le conviene; no sabemos cuándo, y no hace falta saberlo.

Un objeto se vuelve inalcanzable cuando se pierde la última referencia a él: porque se le asignó otro objeto (o null) a la variable, o porque terminó el método cuyo stack frame tenía la variable. Lo que cuenta es si se puede llegar desde las variables vivas, siguiendo flechas, no si “alguien lo usa”.

Java no tiene free ni destructores. La otra cara de esto es que un objeto al que todavía apunta algo no se libera, aunque el programa ya no lo necesite. Si una lista guarda referencias a objetos viejos que nadie va a volver a consultar, esos objetos siguen ocupando memoria: es la forma que toman los memory leaks en Java.

Idea clave. El garbage collector libera los objetos a los que no se puede llegar. No libera los que siguen siendo alcanzables, aunque ya no sirvan.

Identidad frente a igualdad

La semana 1 vimos que == entre referencias pregunta si las dos flechas llegan al mismo objeto. Eso es la identidad (identity). Muchas veces queremos otra cosa: saber si dos objetos, quizá distintos, representan lo mismo. Eso es la igualdad (equality), y en Java la responde el método equals, cuando la clase lo define.

String define equals: compara los caracteres. Equality.java:

String s1 = "Lima";
String s2 = "Lima";
String part = "Li";
String s3 = part + "ma";        // se arma mientras el programa corre

IO.println("s1 == s2:      " + (s1 == s2));
IO.println("s1 == s3:      " + (s1 == s3));
IO.println("s1.equals(s3): " + s1.equals(s3));
s1 == s2:      true
s1 == s3:      false
s1.equals(s3): true
A la izquierda, tres variables s1, s2 y s3. Las flechas de s1 y s2 llegan al mismo objeto String "Lima", rotulado como el literal, uno solo. La flecha de s3 llega a otro objeto String "Lima", armado al ejecutar. A la derecha, los resultados: s1 == s2 es true (misma flecha), s1 == s3 es false (flechas distintas), s1.equals(s3) es true (mismo texto)A la izquierda, tres variables s1, s2 y s3. Las flechas de s1 y s2 llegan al mismo objeto String "Lima", rotulado como el literal, uno solo. La flecha de s3 llega a otro objeto String "Lima", armado al ejecutar. A la derecha, los resultados: s1 == s2 es true (misma flecha), s1 == s3 es false (flechas distintas), s1.equals(s3) es true (mismo texto)
== compara flechas; equals compara contenido. Dos literales iguales comparten un objeto; un texto armado al ejecutar es otro objeto.

¿Por qué s1 == s2 es true? Java guarda una sola copia de cada literal de texto que aparece en el programa, así que s1 y s2 apuntan al mismo objeto. s3, en cambio, se arma al ejecutar con +, y eso crea un objeto nuevo con los mismos caracteres.

Nuestras clases todavía no definen equals. Cuando una clase no lo define, hereda uno que hace exactamente lo mismo que ==:

Thermometer t1 = new Thermometer("Lima", 18.0);
Thermometer t2 = new Thermometer("Lima", 18.0);
IO.println("t1 == t2:      " + (t1 == t2));
IO.println("t1.equals(t2): " + t1.equals(t2));
t1 == t2:      false
t1.equals(t2): false

Decidir cuándo dos termómetros “son iguales” es una decisión de diseño (¿el mismo lugar?, ¿las mismas lecturas?), y escribir un equals correcto tiene sus trampas. Lo hacemos en la semana 5.

Error común. Comparar textos con ==. Con literales, a veces “funciona”, porque dos literales iguales son el mismo objeto; con un texto que llega del teclado, de un archivo o de una concatenación, no:

// ReadCompare.java
String city = IO.readln("Ciudad: ");
IO.println("city == \"Lima\":      " + (city == "Lima"));
IO.println("city.equals(\"Lima\"): " + city.equals("Lima"));
$ echo Lima | java -cp build ReadCompare
Ciudad: city == "Lima":      false
city.equals("Lima"): true

El usuario escribió exactamente Lima y == dice false. Para comparar textos, siempre equals.

Ejemplo resuelto: alias en jshell

jshell muestra cada objeto como Clase@número. Ese número no es una dirección de memoria, pero sirve para ver si dos variables apuntan al mismo objeto. Dentro de code/week-02:

jshell> /open Thermometer.java

jshell> Thermometer a = new Thermometer()
a ==> Thermometer@31221be2

jshell> Thermometer b = a
b ==> Thermometer@31221be2

jshell> b.record(30)

jshell> double m = a.max()
m ==> 30.0

jshell> boolean same = a == b
same ==> true

jshell> String s1 = "Lima"
s1 ==> "Lima"

jshell> String s3 = "Li" + s1.substring(2)
s3 ==> "Lima"

jshell> boolean r1 = s1 == s3
r1 ==> false

jshell> boolean r2 = s1.equals(s3)
r2 ==> true
  • a y b muestran el mismo @31221be2: es un solo objeto.
  • b.record(30) es un modificador void, así que jshell no muestra nada; pero a.max() ya vale 30.0.
  • s1 y s3 se imprimen igual, "Lima", y sin embargo son dos objetos distintos. Mirar la salida no basta para saber si dos referencias son idénticas.

Laboratorio: depurar con jdb

Un debugger permite detener un programa en una línea, mirar el valor de las variables y de los campos, y avanzar instrucción por instrucción. El JDK trae uno de línea de comandos, jdb, que funciona dentro del contenedor. Los editores (VS Code, IntelliJ) tienen uno gráfico que hace lo mismo con clics; las ideas son las mismas.

El programa tiene que estar compilado con -g para que el debugger conozca los nombres de las variables locales; el Makefile de la semana ya lo hace. Pongamos un breakpoint en la primera línea de record (línea 33 de Thermometer.java) y ejecutemos ThermometerDemo:

$ make
$ jdb -classpath build ThermometerDemo
Initializing jdb ...
> stop at Thermometer:33
Deferring breakpoint Thermometer:33.
It will be set after the class is loaded.
> run
run ThermometerDemo
...
VM Started: Set deferred breakpoint Thermometer:33

Breakpoint hit: "thread=main", Thermometer.record(), line=33 bci=0

main[1] where
  [1] Thermometer.record (Thermometer.java:33)
  [2] ThermometerDemo.main (ThermometerDemo.java:4)
main[1] print celsius
 celsius = 22.0
main[1] dump this
 this = {
    place: "Lima"
    current: 18.5
    min: 18.5
    max: 18.5
    readings: 1
}

El programa se detuvo al entrar a record, antes de ejecutar la línea 33.

  • where muestra el call stack: estamos en record, que fue llamado desde la línea 4 de ThermometerDemo (lima.record(22.0)).
  • print celsius muestra el parámetro.
  • dump this muestra todos los campos del receptor: el termómetro de Lima, todavía con su primera lectura.

Ahora avanzamos línea por línea con next y volvemos a mirar el objeto:

main[1] next
Step completed: "thread=main", Thermometer.record(), line=34 bci=5
main[1] next
Step completed: "thread=main", Thermometer.record(), line=37 bci=19
main[1] next
Step completed: "thread=main", Thermometer.record(), line=38 bci=28
main[1] dump this
 this = {
    place: "Lima"
    current: 22.0
    min: 18.5
    max: 18.5
    readings: 1
}
main[1] clear Thermometer:33
Removed: breakpoint Thermometer:33
main[1] cont
Lima: actual 16.0, mín 16.0, máx 22.0, 3 lecturas
sin lugar: 21.0
sin lugar: 0.0
rango de lab: 14.0, rango de lima: 6.0

The application exited

De la línea 34 saltamos a la 37: como 22.0 no es menor que 18.5, el cuerpo del primer if no se ejecutó. En la 38 estamos a punto de actualizar max: el objeto ya tiene current en 22.0, pero max sigue en 18.5. clear quita el breakpoint y cont deja que el programa termine.

Comando de jdb Qué hace
stop at Clase:línea Breakpoint en esa línea
run Arranca el programa
where Call stack: en qué método estamos y quién lo llamó
print expr Valor de una variable o expresión
dump expr Todos los campos de un objeto
locals Todas las variables locales del stack frame actual
next Ejecuta la línea actual sin entrar a los métodos que llama
step Ejecuta la línea actual entrando a los métodos que llama
cont Sigue hasta el próximo breakpoint o el final
clear Clase:línea Quita un breakpoint

Las actividades del laboratorio:

  1. Escribir una clase pequeña con overloading de constructores (uno que delegue en el otro con this(...)), consultas y modificadores, y probarla desde un archivo compacto con IO.println.
  2. Dibujar a mano el stack y el heap después de cada línea de un programa corto con alias, y comprobar el dibujo en jshell.
  3. Provocar a propósito un NullPointerException y un error de alias; leer el stack trace y encontrar la línea responsable.
  4. Depurar con jdb (o con el debugger del editor): poner un breakpoint dentro de un método, avanzar con next y ver cómo cambian los campos del receptor con dump this.

Lectura. Eck, 3.2.3 Coding, Testing, Debugging, sobre cómo buscar errores con método, y 2.6.6 About jshell.

El código de la semana y make test

Dentro del contenedor, en code/week-02:

$ make test
mkdir -p build
javac -Xlint:all -Werror -g -cp /opt/junit/junit.jar -d build Aliasing.java Equality.java NullDemo.java ReadCompare.java ShadowBug.java ThermometerDemo.java Thermometer.java ThermometerTest.java
touch build/.stamp
== ThermometerDemo
Lima: actual 16.0, mín 16.0, máx 22.0, 3 lecturas
...
== NullDemo (termina con NullPointerException a propósito)
Lima tiene 4 letras
lugar: null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "Thermometer.place()" is null
	at NullDemo.main(NullDemo.java:8)
...
├─ JUnit Jupiter ✔
│  └─ ThermometerTest ✔
│     ├─ firstReadingIsMinAndMax() ✔
│     ├─ noArgConstructorStartsAtZero() ✔
│     └─ recordUpdatesMinAndMax() ✔
...
[         3 tests successful      ]
[         0 tests failed          ]
Archivo Qué muestra
Thermometer.java La clase completa: campos, tres constructores, consultas y un modificador
ThermometerDemo.java Crear objetos con cada constructor y enviarles mensajes
ShadowBug.java Shadowing: un parámetro que tapa al campo
Aliasing.java Alias y paso de parámetros por valor
Equality.java == frente a equals
ReadCompare.java == con un texto leído de la entrada
NullDemo.java Un NullPointerException y su mensaje (termina con error a propósito)
ThermometerTest.java Pruebas JUnit de muestra; a escribirlas se aprende la semana 3

NullDemo es el único programa que termina con una excepción; el Makefile lo da por bueno solo si falla, que es lo que tiene que hacer.

Resumen

  • Una clase es el molde; cada instancia tiene su propio estado. Los campos empiezan en 0, false o null; las variables locales no tienen valor por defecto y el compilador exige asignarlas.
  • Un método de instancia se ejecuta sobre un receptor, al que se llega con this. Conviene separar consultas (devuelven, no cambian) de modificadores (cambian, no devuelven).
  • Un constructor se llama como la clase y no tiene tipo de retorno. El constructor por defecto desaparece al escribir uno propio. El overloading permite varios constructores; this(...) evita repetir código.
  • Las variables locales viven en el stack; los objetos, en el heap. Una variable de tipo clase guarda una referencia.
  • = copia la referencia: dos variables pueden ser alias del mismo objeto.
  • Java pasa los argumentos por valor; con objetos, se copia la referencia. Un método puede modificar el objeto, pero no cambiar a qué objeto apunta la variable de quien llamó.
  • null no apunta a nada. Enviarle un mensaje produce un NullPointerException, cuyo mensaje dice qué expresión era null y en qué línea.
  • El garbage collector libera los objetos inalcanzables. No hay free.
  • == compara identidad (las flechas); equals compara igualdad cuando la clase lo define. Los textos se comparan con equals.
  • Un debugger detiene el programa y muestra las variables y los campos: stop at, run, next, print, dump.

Ejercicios

  1. Sin ejecutarlo, dibujen el stack y el heap al final de este fragmento y digan qué imprime. ¿Cuántos objetos Thermometer hay en el heap? ¿Alguno es inalcanzable?

    Thermometer x = new Thermometer("Piura", 30.0);
    Thermometer y = new Thermometer("Puno", 5.0);
    Thermometer z = x;
    z.record(34.0);
    x = y;
    x.record(-3.0);
    IO.println(z.max() + " " + y.min() + " " + (x == y));
  2. Una clase Probe tiene solo el constructor Probe(double initial). ¿Compila new Probe()? ¿Y si se agrega un segundo constructor sin parámetros que llame a this(20.0)? ¿Qué pasa si this(20.0) no es la primera línea?

  3. Agreguen a Thermometer una consulta average() que devuelva el promedio de todas las lecturas registradas. ¿Qué campo nuevo necesitan? ¿Qué métodos tienen que cambiar? Agreguen una prueba en ThermometerTest.java, copiando el estilo de las que hay, y verifiquen con make test.

  4. Escriban un método void swap(Thermometer p, Thermometer q) que intente intercambiar los objetos a los que apuntan dos variables de main. Pruébenlo. ¿Funciona? Expliquen por qué con un dibujo del stack. ¿Pueden escribir un método que intercambie los estados de dos termómetros?

  5. En jshell, evalúen String a = "uni", String b = "UNI".toLowerCase(), a == b, a.equals(b) y a.equals(null). Después, String c = null y c.equals(a). Expliquen cada resultado.

  6. Modifiquen NullDemo.java para que el null esté en una variable local (Thermometer t = null;) y llamen a t.max(). Copien el mensaje del NullPointerException. Compilen después sin -g (javac -d out NullDemo.java Thermometer.java) y comparen el mensaje.

  7. Este constructor tiene dos errores que compilan sin advertencias. Encuentren ambos y digan qué valores quedan en los campos después de new Clock(10, 30):

    public class Clock {
        private int hours;
        private int minutes;
    
        public Clock(int hours, int minutes) {
            hours = hours;
            this.minutes = minutes;
        }
    
        public void Clock() {
            this.hours = 0;
        }
    }
  8. Un programa guarda en una lista todos los Thermometer que crea, para mostrarlos al final, y a la mitad deja de usar la mitad de ellos. ¿Puede el garbage collector liberar esos objetos? ¿Qué habría que cambiar para que pueda?

  9. Usen jdb con ThermometerDemo: pongan un breakpoint en la línea del constructor Thermometer(String, double) que asigna this.min y ejecuten dump this. ¿Qué valores tienen los campos que todavía no se asignaron? Relacionen la respuesta con los valores por defecto.

  10. Usen step en lugar de next sobre la línea this(0.0); del constructor sin parámetros (pongan el breakpoint ahí y ejecuten ThermometerDemo). ¿Por qué constructores pasa la ejecución, y en qué orden?

  11. Escriban Ticket, la entrada de un concierto, con un código, el nombre de quien la compró y un booleano used. Den dos constructores (uno recibe ese nombre; el otro no y usa "sin nombre"), una consulta isUsed() y un modificador use() que devuelva false si la entrada ya se había usado. Pruébenla desde un archivo compacto y muestren un caso de alias en que usar la entrada a través de una variable la “gasta” para la otra.

  12. Lean la subsección 5.1.2 de Eck. Él compara las variables de objeto con las de tipo primitivo en una asignación y en ==. Escriban, con sus palabras y en no más de cinco líneas, la regla general que une esos casos.