03.1 - POO I: Diseño de Clases, Encapsulación y Organización
La Programación Orientada a Objetos (POO) es un paradigma donde se organiza el código en unidades denominadas clases, las cuales contienen propiedades (o atributos) y métodos.
Al ser Java un lenguaje orientado a objetos, hemos utilizado este paradigma desde la primera línea de código:
public class HolaMundo {
static void main() {
IO.println("Hola mundo");
}
}
Definimos una clase mediante la palabra reservada class. La idea es encapsular (proteger y aislar) el código de nuestra aplicación en clases que se comuniquen entre sí mediante métodos.
Las características más importantes de la POO son:
- Abstracción: Identifica las características y comportamientos esenciales de una entidad del mundo real omitiendo los detalles irrelevantes. La herramienta principal para la abstracción es la clase.
- Encapsulación: Mecanismo para restringir el acceso directo a los datos internos de un objeto. Protege la información frente a modificaciones indebidas.
- Modularidad: Divide el programa en módulos o clases independientes almacenadas en archivos separados. Permite modificar un componente sin alterar el resto.
- Jerarquía: Clasifica y relaciona clases mediante dos estructuras principales:
- Generalización / Especialización (Herencia - “Es un”): Permite crear clases derivadas a partir de una clase base (ej.
Cochees unVehiculo). - Composición / Agregación (“Tiene un” / “Es parte de”): Permite agrupar objetos dentro de otro (ej. un
Cochetiene unMotor).
- Polimorfismo: Capacidad de objetos de distintas clases derivadas de responder de forma diferente ante una misma llamada a un método.
1. Clases y Objetos
Una clase es la plantilla o prototipo abstracto que define las propiedades y métodos comunes. Un objeto es una instancia concreta creada a partir de esa plantilla.
Las propiedades fundamentales de un objeto son:
- Identidad: Elemento que distingue de forma única a un objeto de cualquier otro (ej. su dirección de memoria o una clave primaria), aunque compartan los mismos datos.
- Estado: Conjunto de valores almacenados en sus atributos en un momento determinado.
- Comportamiento: Conjunto de acciones (métodos) que el objeto puede ejecutar o recibir.
1.1 Definición e instanciación de clases
Todo código ejecutable en Java debe estructurarse dentro de una clase.
Creación de una clase
Para definir una entidad (por ejemplo, un vehículo), creamos un archivo .java con la estructura base:
public class Coche {
String marca;
String modelo;
String color;
int numeroBastidor;
int velocidad = 0;
}
Acceso a propiedades y necesidad de instanciación
Intentar acceder directamente a los atributos de una clase sin crear una instancia genera un error de compilación:
public class Main {
static void main() {
// ERROR: Marca no es accesible de forma estática
// IO.println(Coche.marca);
}
}
Para utilizar una clase debemos declarar la variable e instanciar el objeto con el operador new:
public class Main {
static void main() {
// Instanciación del objeto
Coche coche1 = new Coche();
// Asignación y lectura de atributos
coche1.marca = "Kia";
IO.println("La marca de mi coche es " + coche1.marca);
}
}
1.2 Métodos de clase
Los métodos son las funciones definidas dentro de una clase que operan sobre sus atributos o realizan acciones específicas.
public class Coche {
String marca;
String modelo;
String color;
int numeroBastidor;
int velocidad = 0;
public void acelerar(int aumento) {
velocidad += aumento;
}
}
Invocación del método sobre el objeto instanciado desde la clase principal:
public class Main {
static void main() {
Coche coche1 = new Coche();
IO.println("Velocidad inicial: " + coche1.velocidad);
coche1.acelerar(20);
IO.println("Velocidad tras acelerar: " + coche1.velocidad);
}
}
- Tipos primitivos (
int,double,boolean, etc.): Se pasa una copia del valor. Cualquier modificación dentro del método afecta únicamente a la variable local del método y no modifica la variable original.
- Objetos (Tipos por referencia): Lo que se pasa por valor es la copia de la referencia (la dirección de memoria) que apunta al objeto. Por tanto, tanto la variable original como el parámetro del método apuntan al mismo objeto en memoria.
Consecuencia clave: Si un método modifica el estado interno de un objeto recibido como parámetro (por ejemplo, llamando a sus métodos setter o modificando sus atributos), los cambios se reflejarán en el objeto original fuera del método.
2. Visibilidad
Hasta ahora, la mayoría de nuestros métodos, propiedades y clases tenían asociada la palabra reservada public en su declaración. Esto hace referencia a los modificadores de acceso (o visibilidad) de los elementos.
En la POO existen habitualmente 3 tipos de visibilidad:
public: El elemento es accesible desde cualquier clase.
private: El elemento es accesible solo desde la propia clase.
protected: El elemento es accesible desde la propia clase y sus clases derivadas (herencia).
En Java, existe un cuarto tipo de visibilidad, además de un matiz específico para protected:
protected(en Java): Accesible desde la propia clase, sus clases derivadas y también desde cualquier otra clase definida en el mismo paquete (package).
package-private(por defecto): Si no se especifica ningún modificador de acceso, el elemento es accesible únicamente por las clases definidas en el mismo paquete.
Por ejemplo, modificamos los atributos de la clase Coche haciéndolos privados:
public class Coche {
private String marca;
private String modelo;
private String color;
private int numeroBastidor;
private int velocidad = 0;
public void acelerar(int aumento) {
velocidad += aumento;
}
}
Si intentamos acceder directamente a un atributo privado desde la clase Main:
public class Main {
static void main() {
Coche coche1 = new Coche();
// ERROR de compilación: el atributo 'marca' es privado
coche1.marca = "Kia";
IO.println("La marca de mi coche es " + coche1.marca);
}
}
El compilador mostrará el siguiente mensaje de error indicando la falta de visibilidad:
The field Coche.marca is not visible
Si cambiamos la visibilidad de los atributos a pública (public):
public class Coche {
public String marca;
public String modelo;
public String color;
public int numeroBastidor;
public int velocidad = 0;
public void acelerar(int aumento) {
velocidad += aumento;
}
}
El acceso directo desde otra clase vuelve a estar permitido:
public class Main {
static void main() {
Coche coche1 = new Coche();
coche1.marca = "Kia";
IO.println("La marca de mi coche es " + coche1.marca);
}
}
La marca de mi coche es Kia
2.1 Criterios de diseño para la visibilidad
Como regla general de diseño orientada a objetos:
- Atributos: Deben ser privados (
private). Esto protege el estado del objeto frente a modificaciones no válidas desde el exterior (por ejemplo, evitar asignaciones comocoche1.velocidad = -300000;).
- Métodos públicos: Conforman la interfaz externa de la clase (las operaciones que el resto del programa puede solicitar al objeto).
- Métodos privados: Funciones auxiliares internas que no deben exponerse fuera de la clase.
3. Getters y Setters
Dado que los atributos deben ser privados, para consultar o modificar sus valores desde fuera de la clase empleamos métodos accesores: getters (métodos de lectura) y setters (métodos de escritura).
Estos métodos permiten controlar el acceso a la información y aplicar validaciones antes de modificar el estado interno de un objeto.
La estructura base de un método de lectura y de escritura es la siguiente:
public String getMarca() {
return marca;
}
public void setMarca(String marca) {
this.marca = marca;
}
getAtributo() y el de escritura como setAtributo(). En atributos de tipo booleano se suele emplear isAtributo() para la lectura (ej. isElectrico()).
No. Únicamente debemos crear los métodos accesores que queramos exponer al exterior:
- Clases inmutables: Si el estado de un objeto no debe cambiar una vez instanciado, se definen solo los getters y se omiten los setters.
- Atributos internos: Si una propiedad solo sirve para la lógica interna de la clase, no debe llevar ni getter ni setter, manteniéndose completamente aislada del resto del programa.
this.marca = marca;, la palabra reservada this hace referencia a la propiedad perteneciente a la instancia actual del objeto.
4. Constructores
Un constructor es un método especial que se invoca automáticamente al instanciar un objeto mediante el operador new. Su función principal es inicializar los atributos de la instancia.
Considera la clase Coche con atributos privados e incialización por defecto:
public class Coche {
private String marca;
private String modelo;
private String color;
private int numeroBastidor;
private int velocidad;
public String getMarca() {
return marca;
}
}
Al instanciarla desde la clase principal:
public class Main {
static void main() {
Coche coche1 = new Coche();
IO.println("La marca del coche es " + coche1.getMarca());
}
}
La marca del coche es null
4.1 Definición de constructores
Para asegurar que un objeto nazca con un estado válido, definimos un constructor con los datos iniciales necesarios. Reglas de los constructores en Java:
- Su nombre debe coincidir exactamente con el nombre de la clase.
- No especifica ningún tipo de retorno (ni siquiera
void).
- Se invoca automáticamente solo durante la instanciación con
new.
public class Coche {
private String marca;
private String modelo;
private String color;
private int numeroBastidor;
private int velocidad;
public Coche(String marca) {
IO.println("Creando un objeto de la clase Coche");
this.marca = marca;
}
public String getMarca() {
return marca;
}
}
Si el constructor exige argumentos, es obligatorio proporcionarlos al invocar new:
public class Main {
static void main() {
Coche coche1 = new Coche("Kia");
IO.println("La marca del coche es " + coche1.getMarca());
}
}
Creando un objeto de la clase Coche La marca del coche es Kia
5. La palabra reservada this
En la instrucción this.marca = marca;:
marca: Hace referencia al parámetro local recibido en el constructor o método.
this.marca: Hace referencia al atributo de la instancia actual de la clase.
El identificador this es una referencia implícita al objeto sobre el cual se está ejecutando el código.
public class Coche {
private String marca;
// Resuelve la ambigüedad cuando el parámetro y el atributo comparten nombre
public Coche(String marca) {
this.marca = marca;
}
}
También permite llamar a otros métodos dentro de la misma clase:
this.acelerar(30);
6. Métodos y atributos estáticos
Para ejecutar un método estándar de una clase es necesario instanciar previamente un objeto de la misma. Intentar invocar un método de instancia directamente sobre la clase provoca un error de compilación.
Sin embargo, Java proporciona los miembros estáticos (definidos mediante la palabra reservada static), los cuales pertenecen a la clase en sí y no a un objeto concreto, permitiendo su uso sin necesidad de instanciar la clase.
main()) utiliza el modificador static precisamente por esta razón: para que la Máquina Virtual de Java (JVM) pueda ejecutarlo sin necesidad de instanciar previamente la clase principal.
Si definimos un método de instancia dentro de la clase principal e intentamos invocarlo directamente desde un contexto estático como main(), el compilador generará un error:
public class Main {
private int sumar(int numero1, int numero2) {
return numero1 + numero2;
}
static void main() {
int suma;
// ERROR de compilación: no se puede usar 'this' o métodos de instancia desde un contexto estático
suma = this.sumar(3, 5);
IO.println("La suma es " + suma);
}
}
El mismo problema sucede al intentar usar métodos de instancia de una clase externa sin instanciarla con el operador new:
public class Calculadora {
public int sumar(int numero1, int numero2) {
return numero1 + numero2;
}
public int restar(int numero1, int numero2) {
return numero1 - numero2;
}
public int multiplicar(int numero1, int numero2) {
return numero1 * numero2;
}
public int dividir(int numero1, int numero2) {
return numero1 / numero2;
}
}
public class Main {
static void main() {
Calculadora calc;
// ERROR: La variable local 'calc' no ha sido inicializada con 'new'
IO.println("3 + 5 = " + calc.sumar(3, 5));
}
}
6.1 Definición de métodos estáticos
Para poder invocar los métodos directamente a través del nombre de la clase, añadimos el modificador static en sus declaraciones:
public class Calculadora {
public static int sumar(int numero1, int numero2) {
return numero1 + numero2;
}
public static int restar(int numero1, int numero2) {
return numero1 - numero2;
}
public static int multiplicar(int numero1, int numero2) {
return numero1 * numero2;
}
public static int dividir(int numero1, int numero2) {
return numero1 / numero2;
}
}
6.2 Invocación de métodos estáticos
Desde la clase principal o cualquier otro punto del programa, podemos ejecutar los métodos estáticos utilizando la sintaxis NombreClase.nombreMetodo():
public class Main {
static void main() {
IO.println("3 + 5 = " + Calculadora.sumar(3, 5));
}
}
Calculadora calc = new Calculadora();
IO.println("2 + 4 = " + calc.sumar(2, 4)); // Desaconsejado: debe usarse Calculadora.sumar(2, 4)
6.3 Acceso a propiedades de clase desde contextos estáticos
Si intentamos almacenar el resultado de una operación en una propiedad de la clase desde un método estático:
public class Calculadora {
private int resultado;
public static int sumar(int numero1, int numero2) {
// ERROR: No se puede acceder a atributos de instancia desde un contexto estático
this.resultado = numero1 + numero2;
return this.resultado;
}
}
Un método estático no está asociado a ninguna instancia en particular, por lo que no existe la referencia this ni se puede acceder directamente a variables de instancia. Para solucionar esto, la propiedad también debe ser declarada como estática (private static):
public class Calculadora {
private static int resultado;
public static int sumar(int numero1, int numero2) {
resultado = numero1 + numero2;
return resultado;
}
public static int restar(int numero1, int numero2) {
resultado = numero1 - numero2;
return resultado;
}
public static int multiplicar(int numero1, int numero2) {
resultado = numero1 * numero2;
return resultado;
}
public static int dividir(int numero1, int numero2) {
resultado = numero1 / numero2;
return resultado;
}
}
public class Main {
static void main() {
IO.println("3 + 5 = " + Calculadora.sumar(3, 5));
}
}
this. Si existe ambigüedad de nombres entre un parámetro y una propiedad estática, se puede calificar la propiedad indicando el nombre de la clase: Calculadora.resultado.
6.4 Comportamiento en memoria de las propiedades estáticas
Las propiedades estáticas son variables de clase, lo que significa que existe una única copia almacenada en memoria compartida por todas las instancias de dicha clase.
Observa la diferencia entre un atributo de instancia y uno estático:
Atributo de instancia
public class Contador {
private int cont = 0;
public Contador() {
cont++;
IO.println(cont);
}
}
public class Main {
static void main() {
Contador cont1 = new Contador();
Contador cont2 = new Contador();
Contador cont3 = new Contador();
}
}
1 1 1
Cada objeto instanciado crea su propio espacio reservado para la variable cont.
Atributo estático
public class Contador {
private static int cont = 0;
public Contador() {
cont++;
IO.println(cont);
}
}
1 2 3
Al ser estática, la variable cont pertenece a la clase y es compartida por todos los objetos creados.
- Clases de utilidad / Helpers: Agrupación de funciones matemáticas o de conversión sin estado (ej. la clase estándar
java.lang.Math).
- Constantes de aplicación: Valores globales compartidos (combinado con
final).
- Patrones de diseño: Implementaciones como Singleton o factorías (Factory).
7. Enumerados
Los enumerados (enum) fueron introducidos en Java 5 para definir un conjunto fijo de constantes con tipo seguro. A nivel interno, los enumerados son clases especiales que extienden implícitamente de la clase abstracta java.lang.Enum.
Debido a su naturaleza:
- No pueden extender de ninguna otra clase (no admiten herencia múltiple de clases).
- Sus constructores son siempre privados o con visibilidad de paquete.
- Garantizan que solo existirán las instancias explícitamente declaradas.
7.1 Creación de enumerados
Para declarar una enumeración utilizamos la palabra reservada enum. Los valores se escriben habitualmente en mayúsculas:
public enum KiaModel {
NIRO,
XCEED,
SPORTAGE
}
7.2 Uso de enumerados en clases
Utilizar un enumerado nos permite limitar las opciones de un atributo a los valores predefinidos en el enum, evitando errores tipográficos propios de las cadenas de texto:
public class Kia {
private KiaModel model;
public Kia(KiaModel model) {
this.model = model;
}
public KiaModel getModel() {
return model;
}
@Override
public String toString() {
return "Kia " + this.getModel();
}
}
Para instanciar objetos pasando valores del enumerado:
public class App {
static void main() {
Kia niro = new Kia(KiaModel.NIRO);
Kia sportage = new Kia(KiaModel.SPORTAGE);
Kia xceed = new Kia(KiaModel.XCEED);
IO.println(niro);
IO.println(sportage);
IO.println(xceed);
}
}
7.3 Enumerados con atributos y constructores
Un enum en Java puede tener propiedades, métodos y constructores. Esto es útil para asociar información adicional a cada constante:
public enum KiaModel {
NIRO("1.6 GDi HEV 103kW"),
XCEED("1.6 TGDi 112kW"),
SPORTAGE("1.0 T-GDi 88kW");
private String engine;
// El constructor de un enum SIEMPRE es privado
private KiaModel(String engine) {
this.engine = engine;
}
public String getEngine() {
return this.engine;
}
}
Podemos consultar los métodos del enum directamente desde el objeto contenedor:
public class Kia {
private KiaModel model;
public Kia(KiaModel model) {
this.model = model;
}
public KiaModel getModel() {
return model;
}
@Override
public String toString() {
return "Kia " + this.getModel() + " - Motor: " + this.getModel().getEngine();
}
}
Ejercicios
Ejercicio 1a
Crea una clase llamada Product con los siguientes atributos públicos:
name(String)
price(double)
stockQuantity(int)
Añade a la clase los siguientes métodos:
restock(int quantity): Incrementa el valor del atributostockQuantity.
sell(int quantity): Reduce el valor del atributostockQuantity.
En la clase principal (Main), realiza las siguientes acciones:
- Instancia un objeto de la clase Product.
- Asigna valores a sus atributos de forma directa.
- Muestra por pantalla sus datos iniciales.
- Modifica su stock llamando al método
restock()y muestra el stock actualizado.
- Vende una cantidad válida llamando a
sell()y muestra el stock resultante.
- Simula una venta superior al stock disponible para observar cómo responde el método sin validación previa.
Ejercicio 1b
Modifica la clase Product para que sus métodos de control de inventario gestionen errores mediante valores de retorno:
- Método
restock(int quantity): Si la cantidad ingresada es negativa (quantity < 0), no debe modificar el stock y devolverá $-1$ como código de error. Si es una cantidad válida, incrementa el stock y devuelve el nuevo valor destockQuantity.
- Método
sell(int quantity): Comprueba si hay suficiente stock disponible. Si hay suficiente stock, reduce la cantidad, devuelve la cifra exacta de unidades vendidas (quantity). Si no hay suficiente stock, no modifica la propiedad y devuelve $-1$ para indicar que la operación ha fallado.
En la clase principal (Main), realiza operaciones de venta y añadir stock válidas e inválidas, evaluando el resultado devuelto por los métodos para mostrar los mensajes informativos correspondientes al usuario.
Ejercicio 1c
Cambia la visibilidad de los atributos de Product a private y crea los métodos necesarios para leer y escribir los atributos.
Ejercicio 1d
Crea un constructor de Product donde se inicialicen todos sus atributos.
Ejercicio 1e
Crea dos productos con el mismo valor en sus atributos y utiliza == para comprobar si son el mismo producto.
Ejercicio 1f
Crea el método boolean isSameProduct(Product other) en la clase Product que compruebe si sus atributos son iguales. Usa el método recién creado para comparar los productos del ejercicio anterior.
Ejercicio 2a
Evoluciona el diseño del sistema introduciendo categorías y simulando un repositorio de datos en memoria (base de datos estática) para consultar información mediante identificadores únicos (IDs):
Category:- Atributos privados e inmutables (
final):id(long),name(String) ydescription(String). - Constructor completo y métodos accesores (
getters).
Product:- Atributos privados e inmutables (
final):id(long),name(String),price(double),stockQuantity(int) ycategory(de tipo Category). - Constructor completo y métodos accesores (
getters).
CategoryDb:- Define un array estático privado
categoriesinicializado con 2 categorías (“Libros” y “Electrónica”). - Implementa el método estático
getById(long id), que recorre el array y devuelve la categoría coincidente (onullsi no existe).
ProductDb:- Define un array estático privado
productsinicializado con 4 productos asociados a sus respectivas categorías medianteCategoryDb.getById(). - Implementa el método estático
getById(long id). - Implementa el método estático
getByCategoryId(long categoryId), el cual debe devolver un nuevo arrayProduct[]conteniendo exclusivamente los productos pertenecientes a esa categoría.
En la clase principal (Main), prueba la recuperación individual de un producto y una categoría por su ID, e imprime el listado de productos devuelto al filtrar por la categoría de “Electrónica” (ID 2).
Ejercicio 2b
Añade las siguientes clases al ejercicio anterior:
OrderStatus(enum):- Define las constantes:
PENDING,PROCESSING,SHIPPED,DELIVEREDyCANCELLED. - Añade un atributo privado e inmutable
description(String) que guarde una frase descriptiva en español (ej. “Pendiente de pago”, “Enviado”, etc.). - Define el constructor del enumerado y el método accesor
getDescription().
Order:- Atributos privados:
id(long),product(Product),quantity(int) ystatus(de tipo OrderStatus). - Constructor que reciba
id,productyquantity, e inicialice por defecto el estadostatusenOrderStatus.PENDING. - Métodos accesores (
getters) y unsetterpara modificar el atributostatus. - Método
calculateTotal()que devuelva el precio total (multiplicando el precio del producto por la cantidad).
En la clase principal (Main), crea un pedido, muestra su estado inicial utilizando el método getDescription() del enumerado, simula el avance del envío cambiando su estado y evalúa la situación del pedido mediante una estructura de control switch.