Es posible crear cualquier función computable con 3 estructuras lógicas (llamadas estructuras de control):
- secuencial
- condicional (
if
) - iterativo (
for
,while
)
Más información: Wikipedia
Todo programa se puede entender como una serie de instrucciones que operan sobre un conjunto de datos de entrada y generan otro conjunto de datos de salida. Por eso, los tipos de datos son conceptos fundamentales de un lenguaje de programación.
En Java:
- Tipos de datos primitivos: equivalentes a los de C, vinculados a la representación en el computador.
- Clases: tipos de datos abstractos (TADs) de alto nivel, no representables directamente en el computador.
Encapsulación
La encapsulación es una forma de abstracción de datos, que consiste en la agrupación unos datos con los métodos (funciones) que los manipulan. Estos especifican los estados posibles y las operaciones permitidas.
Se utiliza para ocultar los valores o estados internos, evitando el acceso directo a ellos por el resto del programa, porque de lo contrario, se podrían dar errores de estados inválidos. Los métodos actúan de intermediarios para que esto no ocurra.
El usuario utiliza los métodos que tiene accesibles para operar sobre los datos, sin preocuparse por su representación interna ni otros detalles de implementación, a modo de caja negra. Lo importante es qué hacen, no cómo lo hacen.
Es la característica fundamental de la Programación Orientada a Objetos.
- Integridad de datos: limite al código que puede acceder a los datos.
- Principio de ocultación: solo un trozo de código puede acceder a los datos y ninguno otro puede.
Este concepto facilita mucho la modularidad de los programas y reduce los errores.
Clases y objetos
Una clase es una plantilla para la creación de objetos. Define unas características comunes mediante unas variables llamadas atributos y una serie de acciones mediante funciones llamados métodos.
Están vinculados a entidades de alto nivel del programa, y se utilizan tipos de datos primitivos para representarlas.
Coloquialmente, una clase es un struct
con una colección de funciones que
operan sobre ella, y cuando el struct
está en memoria, se le llama objeto.
Lo mejor es ver un ejemplo:
La clase Persona tiene:
- Atributos:
nombre
,edad
- Métodos:
presentarse()
1public class Persona {
2 private String nombre;
3 private int edad;
4
5 public Persona(String nombre, int edad) {
6 this.nombre = nombre;
7 this.edad = edad;
8 }
9
10 public void presentarse() {
11 System.out.printf("Hola! Me llamo %s ya tengo %d años\n", nombre, edad);
12 }
13
14 // ...
15}
Entonces:
Persona pepe = new Persona("Pepe Fernández", 27);
pepe.presentarse();
// Imprime: "Hola! Me llamo Pepe Fernández y tengo 27 años"
pepe
es un objeto de la clase Persona
. Tiene un nombre concreto (Pepe
Fernández) y una edad concreta (27). No confundir el nombre de variable que se
le ha puesto (pepe
) con el atributo de nombre.
Estructura de una clase en Java
1public <modificador clase> class <NombreClase> {
2 // Atributos
3 <tipo acceso> [<modificador atributo>] <TipoDato> <nombreAtributo>;
4
5 // Constructor
6 public <NombreClase> (<argumentos>) {
7 // ...
8 }
9
10 // Getters
11 public <TipoAtributo> get<NombreAtributo>() {}
12 return <nombreAtributo>;
13 }
14
15 // Setters
16 public void set<NombreAtributo>(<TipoAtributo> <nombreAtributo>) {
17 // Comprobación del argumento
18 this.<nombreAtributo> = <nombreAtributo>;
19 }
20
21 // Métodos funcionales
22 <modificador acceso> [<modificador método>] <TipoDato>
23 <nombreMétodo>(<TipoDado> <nombreParam>, ...) [throws <NombreExcepción>]
24 {
25 // ...
26 }
27}
Donde <modificador clase>
uno de:
final
: la clase no puede ser superclase.abstract
: la clase puede tener métodos abstractos y no se puede crear connew
.
Y <modificador atributo>
y <modificador método>
puede ser cualquier número
de (hay algunos que colisionan y no pueden ir juntos, el compilador dará un
error):
final
: atributos/métodos no se pueden sobreescribir.static
: pertenecen a la clase, en lugar de al objeto.transient
: se saltan cuando se serializa el objeto.
Solo para <modificador atributo>
:
abstract
: el método no tiene una implementación definida.synchronized
: solo se puede acceder al método un thread a la vez.
Para llamar a un método se usa la sintaxis <objeto>.<método>(<params>)
, y para
acceder a un atributo <objeto>.<atributo>
.
Nótese que un atributo es una variable de ámbito de clase (todos los métodos tienen acceso), si se declara una variable se declara dentro de un método, solo será local al método, por lo que el resto de no lo verán y se borrará cuando termine.
Tipos de acceso en Java
Tipo de acceso | Palabra clave | Clase | Paquete | Subclase | Otros |
---|---|---|---|---|---|
Público | public | ✅ | ✅ | ✅ | ✅ |
Protegido | protected | ✅ | ✅ | ✅ | ❌ |
Acceso a paquete | ⬜ | ✅ | ✅ | ❌ | ❌ |
Privado | private | ✅ | ❌ | ❌ | ❌ |
- Los atributos de una clase siempre deben ser privados, de lo contrario se pierde la encapsulación. Para acceder y modificar los datos, se usan los getters y setters.
- Los métodos pueden ser públicos o privados. Un método privado sirve de ayuda a otros métodos.
- Los setters deben incluir comprobaciones para escribir valores erróneos
como
null
. - Todo atributo debe tener su getter y setter, salvo casos donde el atributo sea solo lectura o interno a la clase.
Métodos de clase
Constructores
Para tipos de datos que no son clases, los tipos de datos primitivos, se realiza una reserva de memoria y se inicia con su valor por defecto de forma automática.
Tipo | Valor inicial |
---|---|
boolean | false |
char | '\0' |
byte short int long | 0 |
float double | +0.0 |
Sin embargo, para crear objetos, no se reservará memoria automáticamente,
dado que se desconoce cuánto ocupará. En su lugar, se asigna el valor null
por defecto, por lo que si se intenta usar dará un NullPointerException
(similar a un Segmentation Fault
).
Persona pepe; // Aquí pepe es null
Como el compilador ya sabe que contiene el valor null
, da error: variable pepe might not have been initialized
cuando se intente llamar a algún método.
La reserva de memoria se hace con el operador new
y llamando al
constructor.
Persona pepe = new Persona("Pepe Fernández", 27);
// ~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Uso de new Llamada al constructor
Un constructor es un método especial que se invoca una única vez por cada objeto (si se llama dos veces, se crean dos objetos distintos) para:
- Reservar memoria para el objeto
- Reservar memoria para los atributos
- Asignar los valores iniciales a los atributos (desde parámetro o valores por defecto)
Nunca devuelve nada y se llama igual que la clase.
Si el programador no ha creado de forma explícita un constructor, Java automáticamente añade el constructor por defecto.
- No tiene argumentos
- Inicializa todo por defecto: clases a
null
y datos primitivos según la tabla anterior.
Por tanto, es equivalente a añadir el siguiente código:
public <NombreClase>() {}
Si no fuese por este mecanismo, nunca sería posible crear instancias de esta clase.
Getters y setters
Los getters (de la palabra get), devuelven el valor que tienen los atributos
en cada momento, llamados por convención get<NombreAtributo>
.
Los setters (de la palabra set), escriben en valor que se pasa por
parámetros en el atributo, siempre y cuando cumplan determinadas condiciones (no
ser null
, estar entre un rango de valores, etc). Se llaman por convención
set<NombreAtributo>
.
Solo hay como máximo un único getter y un único setter por cada atributo. Esta es la única forma que tiene el usuario de la clase para acceder/modificar al atributo, por lo que si es programador decide que no quiere que se modifique cierto valor, simplemente no añade un setter.
Métodos funcionales
Son el resto de métodos que implementan la funcionalidad de la clase. Pueden acceder directamente sobre los atributos de la clase, no hace falta que se use también los getters/setters.
Métodos (y atributos) estáticos
La palabra clave static
se puede usar tanto en atributos como en métodos,
y significa propio de la clase y no del objeto. Esto requiere que el método
o atributo esté siempre en memoria estática por si se usa, a diferencia del
resto que requieren de un objeto: cuando se carga el objeto, se trae consigo sus
métodos y se reserva memoria para sus atributos.
Para métodos, esto quiere decir que se puede invocar sin haber creado un objeto de la clase. Esto implica que no se puedan hacer llamadas a métodos no estáticos desde un método estático:
1public class Consola {
2 public static void imprimir(String msg) {
3 System.out.printf("Método estático: %s\n", msg);
4 }
5}
6
7// ...
8Consola.imprimir("HOLA");
Para atributos, el valor se conserva independientemente del objeto en el que se utilice. Esto resulta útil para mantener un estado global entre todos, como para hacer un generador de identificadores:
1public class Jugador {
2 public static int ultimoId = 0;
3 public final int id;
4
5 public Jugador() {
6 ultimoId++;
7 this.id = ultimoId;
8 }
9}
Sobrecarga de métodos
Se pueden tener varios métodos con el mismo nombre, siempre y cuando tengan parámetros distintos (no aplica a valor de retorno).
public void cargarDatos(File archivo) { ... }
public void cargarDatos(Dato dato) { ... }
public void cargarDatos(Arraylist<Dato> datos) { ... }
Condiciones de uso:
- El objetivo el método debe ser el mismo en todas las implementaciones.
- Se desaconseja el uso de condiciones sobre los argumentos.
- Es más habitual la sobrecarga de constructores.
Métodos especiales
Método toString
Todas las clases de Java heredan de la clase Object
, que implementa el método
toString
. Esta devuelve una representación en texto de un objeto. Es
posible reimplementar este método en cada subclase para cambiar el
funcionamiento por defecto (muestra el nombre de la clase junto con un código,
lo que no es muy útil):
@Override
public String toString() {
return """
{
nombre: %s
edad: %d
}
""".formatted(this.nombre, this.edad);
}
En general nos interesa que todas las clases deriven este método para describir el objeto incluyendo los datos de los atributos más relevantes.
Método equals
¿Cómo consideramos que dos métodos son iguales?
Opción 1: que tengan la misma dirección de memoria.
public boolean equals(Object obj) { return this == obj; }
Es una definición muy restrictiva, pero es lo que se usa por defecto.
Opción 2: comprobar los atributos de cada objeto, implementando un criterio de igualdad. Esto lo debe hacer el programador de la clase.
Para esta segunda opción, hay que comprobar 3 condiciones:
- Dirección de memoria: si se apunta a la misma posición, entonces los objetos son iguales.
- Tipo de clase: si son clases distintas, entonces son objetos distintos
- Criterio de igualdad
1@Override
2public boolean equals(Object obj) {
3 // Comprobar la dirección de memoria
4 if (this == obj) {
5 return true;
6 }
7
8 // Comprobar que no sea null
9 if (obj == null) {
10 return false;
11 }
12
13 // Comprobar que sea del mismo tipo
14 if (this.getClass() != obj.getClass()) {
15 return false;
16 }
17
18 <NombreClase> <nombre> = (<NombreClase>) obj;
19 return <criterio de igualdad>;
20}
Requisitos que debe cumplir el método equals
:
x != null ==> x.equals(null) = false
x != null ==> x.equals(x) = true
x, y != null ==> x.equals(y) = y.equals(x)
x, y, z != null ==> x.equals(y) and y.equals(z)
==> x.equals(z)
Dicho criterio de igualdad debe hacerse sobre atributos de valor inmutable, que no se puedan modificar usando aliasing.
Además, este método se usa internamente en las colecciones de datos (contains
y otros), por lo que al implementarlo se reutiliza mucho código.
equals
.Método hashCode
Este método devuelve un entero generado un algoritmo de hash, que necesitan
ciertas estructuras de datos como el HashMap
o el HashSet
. La implementación
por defecto del método es específico a la JVM que se esté usando.
- El método
hashCode
debe devolver el mismo valor de forma consistente para el mismo objeto. - Si dos objetos son iguales según el método
equals
, su código hash debe ser el mismo. - Dos objetos son diferentes, no tienen porqué devolver hashes diferentes.
Nótese que return 1
es un método hashCode
que cumple todos los requisitos,
pero no es particularmente bueno. Cuanto mejor sea la función hash, mejor será
el rendimiento de las estructuras de datos que lo utilicen.
@Override
public int hashCode() {
return (int) id * name.hashCode() * email.hashCode();
}
Existen muchas formas de implementar un una función hash, unas mejores que otras. En este link puede encontrar algunos ejemplos.
Método main
El programa comienza llamando al método main
(análogo a lenguajes como C, que
inician en la función main
) y debe tener muy poco código: debe limitarse
a crear una clase que se encargará de todo.
La clase principal también debe ser muy pequeña y no tener atributos.
Registros
En Java 14 se introdujo los record
s, clases inmutables que carecen de
setters, por lo que no se puede modificar los valores de sus atributos en
tiempo de ejecución. Tienen las siguientes restricciones:
- Todos sus atributos son privados (
private final
). - Todo atributo tiene su getter correspondiente.
- Los atributos no tienen setters ni ningún otro método para modificar los atributos.
- Tienen un constructor llamado constructor canónico, cuyos argumentos
determinan los atributos del
record
. Dado que no se pueden modificar, se deben asignar cuando se crea el objeto. - El método
toString
incluye el nombre de la clase y el nombre de los atributos con sus correspondientes valores. - El método
equals
devuelvetrue
cuando todos los atributos tienen el mismo valor. - El método
hashCode
devuelve el mismo valor cuando todos los atributos son iguales.
1public record Persona(String nombre, int edad) {
2 // Se puede reimplementar el constructor canónico para añadir chequeos
3 // y otras operaciones relevantes
4 public Persona {
5 Objects.requireNonNull(nombre); // assert nombre != null
6 System.out.println(nombre + " creado");
7 }
8
9 // Se pueden añadir constructores adicionales que llaman al canónico
10 public Persona(String nombre) {
11 this(nombre, 18);
12 }
13
14 // Métodos funcionales que no modifiquen los atributos
15 // ...
16}
Su gran ventaja es que ahorra al programador tener que escribir mucho código para producir este mismo resultado.