Referencias y Null
Java tiene, por ahora, dos tipos de variables: primitivas, como boolean, char, int, etc.; y tipos de referencia, String, LocalDate y todas las clases que escribas. La mayor diferencia es que para los tipos de referencia no manejas objetos directamente sino que lo haces a través de una referencia. En un código como este:
La variable <foo> no es estrictamente un String y <ahora> no es estrictamente un LocalTime, ¡ambas son referencias a esos valores! Veremos cómo afecta esto a tus programas.
Pasar por valor
public class Main {
public static void main(String[] args) {
var n = 1;
duplicar(n);
System.out.println(n); // 1
}
public static void duplicar(int n) {
n *= 2;
}
}
¿Qué imprime el Código 01? ¡Puede que te sorprenda saber que imprime 1! ¿Qué pasó? Bueno, sucede que Java, como muchos otros lenguajes, al llamar a una función, creará una copia del argumento y la utilizará en la ejecución del método. Esto se llama pasar por valor. La línea 03 crea una variable <n> que solo es visible dentro de main(). La línea 08 establece que el parámetro a utilizar también se llama <n> pero no tiene relación con el otro, ya que este solo sería visible dentro de duplicar(). Si ambas variables comparten su nombre es sólo coincidencia. En la línea 09, la variable <n> local de duplicar() se duplica, pero esa variable se destruye cuando la función regresa.
En la Línea 05 lo que estás imprimiendo es la variable «n» que es local a main() y que siempre ha tenido valor 1.
Diseño de memoria para el código 1 en la línea 09
| Duplicar |
| n=2 |
| main |
| n=1 |
Estudiemos ahora una nueva versión, ¿qué crees que se imprime? Recuerda que mencionamos que los objetos se manejan por referencia:
public class Main {
private int n = 1;
public static void main(String[] args) {
var obj = new Main();
duplicar(obj);
System.out.println(obj.n); // 2
}
public static void duplicar(Main obj) {
obj.n *= 2;
}
}
¡Ahora el código imprime 2! ¿Cómo ocurrió eso? Bueno, ahora no estamos tratando con tipos de datos primitivos, sino con objetos. La clase Main tiene un atributo, un int llamado, ¿qué más?, <n>. En la Línea 05, se crearon dos cosas diferentes: un objeto de tipo Main y una referencia a él llamada <obj>. Ambos se crean en lugares diferentes: el objeto se llama en un espacio de memoria especial llamado "heap" y la referencia se crea en otro llamado el stack de ejecución.
Dado que los objetos pueden ser complejos, cada uno de ellos con varios atributos, y sus atributos potencialmente con otros atributos, sería inconveniente que todo este objeto complejo se copiara al ser usado como parámetro. Pero al tener una grado de indirección, el objeto nunca se copia se mantiene en el heap y solo se copia la referencia.
Un objeto sólo será “destruido”, o su memoria será recuperada, cuando todas sus referencias hayan sido destruidas. La línea 06 usa <obj> como argumento y, como se mencionó anteriormente, la línea 10 usa una copia de ese argumento, también denominada <obj>. Al igual que antes, el <obj> de tipo Main solo es visible dentro de él y el <obj> de duplicar() solo es visible en esa función, pero ambos tienen el mismo valor: ¡ambos apuntan al mismo objeto, el creado en la línea 05!
Entonces, en la Línea 11 modificamos el estado del único objeto que se ha creado, ¡pero al que hacemos referencia a través de dos variables: <obj> de main() y el <obj> de duplicar()! Cuando duplicar() regresa, solo hay una referencia, pero el estado se modificó previamente y podemos ver el cambio al usar el <obj> de main(). Recuerda que new es el operador para crear, bueno, nuevos objetos.
Diseño de memoria para el código 2 en la línea 10
Duplicar
obj = @7ad041f3
main
obj = @7ad041f3
@7ad041f3
n = 1
Al ejecutar la línea 05 se crea una variable de tipo Main en el heap y en mi máquina se crea en la dirección en memoría 7ad041f3. Adicionalmente se crea una referencia a ese objeto y en él se almacena la dirección del objeto.
Una vez que se ejecuta la línea 11, ya se ha creado en una nueva referencia que tiene el mismo valor que la de main(), la dirección en memoria del objeto de tipo Main.
public class Main {
private n = 1;
public void main(String[] args) {
var a = 1;
var b = a;
a *= 2;
System.out.println(String.format("b == %d", b);
var obj = new Main();
var copy = obj;
obj.n *= 2;
System.out.println(String.format("n == %d", copy.n);
| b == 1 n == 2 |
La primera parte de esto no debería sorprendernos. Línea 05 creamos <a> con valor 1, en Línea 06 creamos <b> como una copia de a, por lo que también tiene el valor de 1. Modificamos <a> duplicándolo y ahora tiene valor 2, pero <b> tiene 1, ya que lo que le sucede a <a> no afecta a <b>.
La segunda parte es complicada si no recuerdas las referencias, pero esto es lo que realmente sucede: en la línea 10 creamos un objeto de tipo Main y obtenemos una referencia llamada <b>. Luego, en la línea 11, creamos una copia de la referencia del objeto creado en la línea anterior y le asignamos el nombre <copia>. Las referencias son diferentes de las primitivas, por lo que al copiar lo que estamos haciendo se crea otra referencia al mismo objeto. Ahora solo hay un objeto de tipo Main al que se hace referencia 2 veces: ¡con <obj> y <copia>! ¡Cualquier cambio que hagamos en el objeto será visible desde cualquiera de ellos! Observe que modificamos el objeto en la Línea 12 usando la referencia <obj>, pero en la Línea 13 lo imprimimos usando <copia>
Diseño de memoria para el Código 3 en la línea 13. En esta computadora, el objeto principal se creó en la dirección 251a69d7
main
a = 2
b = 1
obj = @251a69d7
copy = @251a69d7
251a69d7
n = 2
El objetivo de este ejemplo es que recuerdes que tener múltiples referencias a objetos mutables es complicado y es posible que parte del código no se comporte como esperabas si no tomas en cuenta que los objetos son manejados por referencias.
Entonces, ¿cuál es la forma segura de modificar el estado de un objeto como parte de una funcionalidad? La mejor acción es hacer que tus clases sean inmutables, que es un tema que cubriremos pronto. Pero si se trata de una clase mutable, entonces se puede crear una copia del objeto. Veamos un ejemplo muy sencillo con una clase que ya has visto antes:
public class Length {
private double measure;
public Length(double measure) {
if (measure <= 0.0) {
var message = String.format("Measure can't be zero or
negative. Current value is %f", measure);
throw new IllegalArgumentException(message);
}
this.measure = measure;
}
double measure() {
return measure;
}
}
|
Length length = new Length(1.0); Length copy = new Length(length.measure()); |
En lugar de <copiar = longitud> creamos un nuevo objeto y pasamos la medida de la primera longitud como parámetro en el constructor. Por supuesto, este ejemplo es específico de Longitud, pero diferentes clases necesitarán enfoques diferentes, pero similares. Cuando trabaje con clases mutables, intente hacer una copia de los objetos antes de manipular su estado. Para tus clases, aprende cómo hacerlos inmutables, ¡que es un tema que cubriremos pronto! Solo haga que una clase sea mutable si tiene una buena razón para hacerlo, p. cuando un generador de perfiles te muestra que tu programa tiene un problema de rendimiento y de hecho está relacionado con tu clase y la creación de nuevos objetos con el estado actualizado de los mismos. ¡Este tipo de escenarios rara vez se ven en el software cotidiano!
NULL
Dado que tenemos referencias, ¿cuál es el valor de una referencia que actualmente no se refiere a un objeto? Ese valor es <null>. Hubo un tiempo en el que era muy común inicializar la referencia a <null> y luego asignarles otro valor. Como mencionamos antes, la reasignación de variables es una mala idea, y eso también se aplica a las referencias. Entonces, si la reasignación de variables es una mala idea, ¿qué papel debería tener null en su código?
Durante mucho tiempo, <null> fue la única opción para indicar la ausencia de un valor en un método de devolución. Por ejemplo, considere un método find(String) en una clase WordSearch que busca la palabra pasada como parámetro en la cuadrícula con letras. Cuando se encuentra la palabra, devuelve un rango de coordenadas donde están todas las letras de la palabra. Pero ¿qué pasa si no se encuentra la palabra? Como se mencionó, durante mucho tiempo la única opción posible era devolver nulo para indicar que no se encuentra dicha palabra.
Pero devolver nulo es peligroso. Piensa en cuando lees un método como el mencionado anteriormente:
public class WordSearch {
// more code
public Range find(String word) {
// implementation
}
}
¿Cómo saber si el método puede devolver nulo o no? No puedes simplemente mirar la firma del método. Por lo tanto, confía en leer la documentación del método, algo que muchas personas nunca hacen o que podría ser incorrecta, o necesita leer cómo se implementa el código y asegurarse de que esté lo suficientemente limpio como para que sea obvio cuando el método devuelve nulo.
¿Por qué es tan malo que no exista una forma sencilla de saber si un método devuelve nulo? Veamos el error más común que cometen los programadores al codificar:
String s = null;
s.length();
| Exception java.lang.NullPointerException |
Dado que no hay ningún objeto al que se refiera s, si intenta llamar a cualquier método de String en él, fallará con una excepción: NullPointerException, generalmente abreviada como NPE. Puedes pensar que si inicializas <s> en nulo, ¿por qué intentarías llamar a un método en él?
String s = mysteryMethod();
s.length();
¿Crees que arrojaría una NPE? Bueno, no lo sabes. Entonces, ¿qué haces si no puedes estar seguro? Haces un control defensivo:
String s = mysteryMethod();
if (s != null) {
s.length();
// more code
}
Eso no es tan malo, ¿no? Sí, siempre y cuando recuerdes realizar comprobaciones defensivas para todos tus tipos de referencia. Pero en un programa largo es fácil pasar por alto algunos.
Devolver nulo no es una solución ideal, pero como se mencionó, era la única opción disponible. Y es, incluso hoy en día, la solución más sencilla para "señalar la ausencia de un valor", y verás mucho código que la utilizaría, por lo que es bueno que te familiarices con ella. Desafortunadamente, la mejor alternativa para un valor de retorno, Opcional, está fuera del alcance de este material.
Valores Nulos
String domain(String url) {
var doubleSlash = "//";
var startOfDoubleSlash = url.indexOf(doubleSlash);
if (startOfDoubleSlash == -1) {
return"";
}
var start = startOfDoubleSlash + doubleSlash.length();
var nextSlash = url.indexOf("/", start);
var end = nextSlash != -1 ? nextSlash : url.length();
return url.substring(start, end);
}
No siempre es necesario devolver un valor nulo u otra alternativa si su tipo tiene un valor obvio que se consideraría "vacío", generalmente llamado valor nulo. En nuestro ejemplo, extraemos el dominio de una URL, pero si tiene un formato incorrecto podemos devolver una cadena vacía para indicar que no había ningún dominio en ella. Otro tipo que ha utilizado que tiene un valor nulo obvio: si devuelve una matriz o una colección, puede devolver una vacía.
El problema con los valores nulos es que no todos los tipos tienen uno. Por ejemplo, si su método devuelve LocalDateTime, ¿cuál sería un posible valor nulo? Entonces, a veces necesitas un valor como nulo.
Ejercicios
E10.1
Escribe un conjunto de clases para encontrar palabras en un rompecabezas de WordSearch. La clase WordSearch tiene internamente una cuadrícula, posiblemente una matriz de caracteres. Para mostrar dónde están las palabras se utilizan dos clases: Coordenadas y Rango. Una Coordenada es la encapsulación de una fila y una columna, y un Rango es la encapsulación de dos Coordenadas, una para la primera letra de una palabra y otra para la letra final de una palabra.
Coordenada
| Constructores |
|---|
public Coordenada(int hilera, int columna) |
| Métodos |
public int hilera() |
public int columna()
|
public Coordenada desplazar(int desplazamientoHilera, int desplazamientoColumna)
Retorna la coordenada resultante de tomar la hilera actual y sumarla
con desplazamientoHilera y la columna actual y sumarla con
desplazamientoColumna
|
public boolean equals(Object obj)
|
public String toString()
Retorna un String con el formato "(this.hilera, this.columna)"
|
Asserts
var unoTres = new Coordenada(1, 3); |
var unoTres = new Coordenada(1, 3);
assertEquals("(1, 3)", unoTres.toString());
|
var unoTres = new Coordenada(1, 3);
var dosCuatro = unoTres.desplazar(1, 1);
assertEquals(2, dosCuatro.hilera());
assertEquals(4, dosCuatro.columna());
|
Rango
| Constructores |
|---|
public Rango(Coordenada inicio, Coordenada fin) |
| Métodos |
public Coordenada inicio() |
public Coordenada fin()
|
public boolean equals(Object obj)
|
public String toString()
Retorna un String con el formato "this.inicio - this.fin"
|
Asserts
var ceroCeroUnoUno = new Rango(new Coordenada(0, 0), new Coordenada(1, 1));
assertEquals("(0, 0) - (1, 1)", ceroCeroUnoUno.toString()); |
SopaDeLetras
| Constructores |
|---|
public SopaDeLetras(int hileras, int columnas, String[] sopa) |
| Métodos |
public Rango buscar(String palabra) |
Siéntete libre de agregar métodos adicionales a cualquier clase si lo crees apropiado.
Asserts
Para estas afirmaciones, suponga que tiene esta constante de clase:
private static final String[] board = {
"VBREEFISHRACHP",
"ANACROCODILEEB",
"AOSTRICHTEGRDA",
"IADDHCHEETAHGD",
"BHRODRAVENENEG",
"EYWDLSAMOLELHE",
"ARTPVPRCBOLROR",
"RHTOAAHCROWAGH",
"CCANNORIAZEBRA",
"HANYTAEKNINAWA"
};
var juego = new SopaDeLetras(10, 14, sopa);
assertEquals(new Rango(new Coordenada(0, 5), new Coordenada(0, 8)), juego.buscar("FISH")); |
var juego = new SopaDeLetras(10, 14, sopa);
var hedgehog = juego.buscar("HEDGEHOG");
assertEquals(new Rango(new Coordenada(0, 12), new Coordenada(7, 12)), hedgehog); |
var juego = new SopaDeLetras(10, 14, sopa);
var bat = juego.buscar("BAT");
assertEquals(new Rango(new Coordinate(0, 1), new Coordenada(2, 3)), bat); |
© 2022 Carlos Obregón. This work is licensed under Creative Commons Attribution 4.0 International (CC BY 4.0)